@ninsei-labs/sdk 0.39.0 → 0.39.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +15 -0
- package/engine/core/chainSource.js +306 -306
- package/engine/core/sellWindow.js +43 -43
- package/engine/core/swap.js +1153 -1153
- package/engine/evm/claimGas.js +87 -87
- package/engine/evm/dex.js +836 -836
- package/engine/evm/gasReserve.js +88 -88
- package/engine/evm/prices.js +179 -179
- package/package.json +1 -1
- package/src/actions.mjs +72 -72
- package/src/adapters/node-test-evm-wallet.mjs +345 -345
- package/src/engine.mjs +53 -53
- package/src/lock.mjs +140 -140
- package/src/order.mjs +212 -212
- package/src/quotes.mjs +465 -465
- package/src/swaps.mjs +935 -935
- package/src/xmr-arrival.mjs +289 -289
package/engine/core/swap.js
CHANGED
|
@@ -1,1153 +1,1153 @@
|
|
|
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, then run: node tools/build-sdk-engine.mjs
|
|
3
|
-
// tools/check-sdk-engine.mjs reddens on any drift, so a stale copy cannot ship silently.
|
|
4
|
-
// Everything below this header is identical to the source.
|
|
5
|
-
|
|
6
|
-
// Ядро демки: состояние сделки во времени.
|
|
7
|
-
//
|
|
8
|
-
// Принцип: состояние НЕ хранится как «текущий шаг». Хранятся таймстемпы и события,
|
|
9
|
-
// а фаза выводится функцией derive() из sim-времени. Поэтому демо переживает
|
|
10
|
-
// перезагрузку страницы, а «закрыть вкладку и вернуться» работает по-настоящему.
|
|
11
|
-
//
|
|
12
|
-
// Модель соответствует litepaper §4.2 и §5.3:
|
|
13
|
-
// - T0: дедлайн лока XMR мейкером. Не залочил - пользователь возвращает ETH (permissionless).
|
|
14
|
-
// - ready: пользователь подтверждает, что увидел лок своим view-ключом.
|
|
15
|
-
// - T1: дедлайн claim мейкера. Не сделал claim - пользователь возвращает ETH.
|
|
16
|
-
// - Если пользователь не сделал ready - мейкер забирает XMR назад, пользователь возвращает ETH.
|
|
17
|
-
// - Watchtower может вернуть деньги пользователю без его участия.
|
|
18
|
-
|
|
19
|
-
// DEMO НУЖЕН ЗДЕСЬ, И ЭТО НЕ МЕЛОЧЬ: ниже из него берётся умолчание режима цепи. Имя не было импортировано,
|
|
20
|
-
// поэтому ЛЮБАЯ сделка, заведённая без явного chainMode, падала с "DEMO is not defined" - а страница живой
|
|
21
|
-
// сделки передаёт режим явно, и поломка не была видна до первой проверки, которая заводит сделку без него.
|
|
22
|
-
import { TIMING, MONERO, SCENARIO_LABELS, DEFAULT_CHAIN, API, DEMO, chainById, SIGNING_GUARDS } from "./config.js";
|
|
23
|
-
// ОКНО ПРОДАЖИ XMR (issue #111) - ОДНО правило на ноду и страницу. Здесь оно только читается для отсчёта.
|
|
24
|
-
import { sellWindowSec, SELL_WINDOW } from "./sellWindow.js";
|
|
25
|
-
import { simElapsed, simWall, nowReal, rescaleStart } from "./clock.js";
|
|
26
|
-
import { loadState, saveState } from "./store.js";
|
|
27
|
-
import { shortId, randomHex } from "./format.js";
|
|
28
|
-
import * as chain from "../mock/chain.js";
|
|
29
|
-
import * as node from "../monero/node.js";
|
|
30
|
-
// Источник Monero-ноги: в режиме "app" подтверждения и поступления приходят от бэкенда,
|
|
31
|
-
// в режиме "mock" - от симулятора демки. Что именно уходит наружу (только адрес и view key),
|
|
32
|
-
// проверяется в tools/check-chain-source.mjs.
|
|
33
|
-
import { XMR_STATUS, explorerXmrTx, fetchSession, isLiveSource, openSession, pollInterval, xmrView } from "./chainSource.js";
|
|
34
|
-
import { watchOnlyShareFromWallet } from "../recovery/restore.js";
|
|
35
|
-
|
|
36
|
-
export const STEP_LABELS = [
|
|
37
|
-
["funded", "Funded on-chain"],
|
|
38
|
-
["maker_locking", "Maker locking XMR"],
|
|
39
|
-
["xmr_locked", "XMR locked"],
|
|
40
|
-
["ready", "Verified by you"],
|
|
41
|
-
["claimed", "Claimed by maker"],
|
|
42
|
-
["swept", "Sent to your address"],
|
|
43
|
-
];
|
|
44
|
-
|
|
45
|
-
export const TERMINAL = {
|
|
46
|
-
success: { phase: "success", label: "XMR delivered" },
|
|
47
|
-
refunded_eth: { phase: "refunded_eth", label: "ETH refunded" },
|
|
48
|
-
xmr_returned: { phase: "xmr_returned", label: "Swap cancelled, funds returned" },
|
|
49
|
-
};
|
|
50
|
-
|
|
51
|
-
export function createSwap(input) {
|
|
52
|
-
const s = loadState();
|
|
53
|
-
const now = Date.now();
|
|
54
|
-
const swap = {
|
|
55
|
-
id: shortId(),
|
|
56
|
-
side: input.side || "buy",
|
|
57
|
-
scenario: input.scenario || "happy",
|
|
58
|
-
speed: input.speed || 60,
|
|
59
|
-
// ВИДА КОТИРОВКИ В ЗАПИСИ НЕТ. Прежнее поле mode ("floating"/"fixed") было выбором вида цены, а в
|
|
60
|
-
// версии 4 второго вида не существует (док 41, §7): твёрдая котировка ровно одна - ордерная.
|
|
61
|
-
network: input.network || DEFAULT_CHAIN,
|
|
62
|
-
moneroNetwork: MONERO.networkType,
|
|
63
|
-
realStart: now,
|
|
64
|
-
simStartWall: now,
|
|
65
|
-
createdAt: new Date(now).toISOString(),
|
|
66
|
-
payToken: input.payToken,
|
|
67
|
-
payAmount: Number(input.payAmount),
|
|
68
|
-
xmrAmount: Number(input.xmrAmount),
|
|
69
|
-
rate: Number(input.rate),
|
|
70
|
-
fee: Number(input.fee),
|
|
71
|
-
maker: { id: input.makerId, name: input.makerName, spread: input.makerSpread },
|
|
72
|
-
dexSpread: input.dexSpread,
|
|
73
|
-
gasUsd: input.gasUsd,
|
|
74
|
-
receiveAddress: input.receiveAddress,
|
|
75
|
-
quoteSignature: input.quoteSignature,
|
|
76
|
-
swapWallet: input.swapWallet || null, // ключи одноразового Monero-кошелька
|
|
77
|
-
timeline: {
|
|
78
|
-
fundAtSim: 0,
|
|
79
|
-
lockAtSim: input.scenario === "maker_no_lock" ? null : TIMING.makerLock,
|
|
80
|
-
blockSim: MONERO.blockTimeSim,
|
|
81
|
-
confTarget: MONERO.confirmTarget,
|
|
82
|
-
t0Sim: TIMING.t0Refund,
|
|
83
|
-
},
|
|
84
|
-
// ТО ЖЕ УМОЛЧАНИЕ, ЧТО И В НАСТРОЙКАХ: "live" - настоящая высота цепи, "mock" - таймер песочницы.
|
|
85
|
-
// Второй умолчал здесь значил бы, что сделка без явного режима считается по таймеру.
|
|
86
|
-
chainMode: input.chainMode || DEMO.defaultChainMode, // mock | live (реальная высота Monero mainnet)
|
|
87
|
-
// Источник Monero-ноги: "app" - настоящий бэкенд (см. core/chainSource.js), "mock" - симулятор.
|
|
88
|
-
xmrSource: input.xmrSource || API.xmrSource,
|
|
89
|
-
xmrSession: null, // последний опрос бэкенда: статус, сумма, подтверждения, txid
|
|
90
|
-
xmrSessionError: null, // причина отказа бэкенда: показываем как состояние, а не как падение
|
|
91
|
-
events: [],
|
|
92
|
-
escrow: null,
|
|
93
|
-
xmr: null,
|
|
94
|
-
settlement: null,
|
|
95
|
-
log: [],
|
|
96
|
-
};
|
|
97
|
-
|
|
98
|
-
// ДЕМОНСТРАЦИОННОЕ ФОНДИРОВАНИЕ ТОЛЬКО В ДЕМОНСТРАЦИИ. Здесь стоял безусловный вызов моковой ветки
|
|
99
|
-
// (chain.fundEscrow), и он выполнялся В ТОМ ЧИСЛЕ на живом пути: запись получала ВЫДУМАННЫЙ адрес эскроу
|
|
100
|
-
// и ВЫДУМАННЫЙ хеш транзакции, а живой поток потом с ними боролся. Снаружи это выглядело так: настоящий
|
|
101
|
-
// ордер создан и оплачен, а экран прогресса, ссылка на обозреватель, файл восстановления и плательщик
|
|
102
|
-
// смотрят на адрес, которого на цепи нет. На живом пути адрес и хеш обязан дать ТОЛЬКО живой поток.
|
|
103
|
-
if (swap.chainMode !== "live") {
|
|
104
|
-
const fund = chain.fundEscrow({ swapId: swap.id, ethAmountUsd: swap.payAmount, ethUsd: 3212.4 });
|
|
105
|
-
swap.escrow = {
|
|
106
|
-
address: fund.escrow,
|
|
107
|
-
fundTx: fund.txHash,
|
|
108
|
-
method: fund.method,
|
|
109
|
-
fundedAtSim: 0,
|
|
110
|
-
gasUsd: fund.gasUsd,
|
|
111
|
-
};
|
|
112
|
-
pushLog(swap, 0, "Escrow funded", `eth escrow ${fund.escrow}`);
|
|
113
|
-
} else {
|
|
114
|
-
// ЗАГОТОВКА ПОД ЖИВОЙ ОРДЕР - И БЕЗ ЕДИНОГО ВЫДУМАННОГО ЗНАЧЕНИЯ. Адрес и хеш появятся из живого
|
|
115
|
-
// потока фондирования (confirm.js записывает их из ответа потока). Пустые поля здесь нужны не для
|
|
116
|
-
// красоты: без объекта запись оставалась без места, куда живому пути писать, и адрес терялся молча.
|
|
117
|
-
swap.escrow = { address: null, fundTx: null, method: null, fundedAtSim: 0, gasUsd: input.gasUsd ?? null };
|
|
118
|
-
}
|
|
119
|
-
|
|
120
|
-
s.swaps[swap.id] = swap;
|
|
121
|
-
s.activeSwapId = swap.id;
|
|
122
|
-
saveState(s);
|
|
123
|
-
return swap;
|
|
124
|
-
}
|
|
125
|
-
|
|
126
|
-
// REVERSE-FLOW DEAL RECORD (issue #118, second half). The sell screen used to stop at the request, and there was
|
|
127
|
-
// nowhere to show a refund: a reverse deal had no record. It is created here - from exactly what the screen
|
|
128
|
-
// already has (the node ticket plus the computed halves) - and lands in the same store as the buy flow, so the
|
|
129
|
-
// deal card, the recovery file and the XMR sweep all work off it.
|
|
130
|
-
//
|
|
131
|
-
// WHAT THE REVERSE DEAL DOES DIFFERENTLY. On the buy flow the PAGE funds the escrow, and the counterparty's half
|
|
132
|
-
// is only learned after settlement. On the sell flow the PROVIDER funds the escrow, the page merely requests the
|
|
133
|
-
// order, and it computes ITS OWN halves (atomic/order-worker.js, newMakerMaterial). So fields are named by side:
|
|
134
|
-
// our half is `half`, and the DEPOSITOR's (provider's) point and view half are counterSpendPoint/counterView*.
|
|
135
|
-
// The page does not know the depositor's SPEND half and must not: the contract publishes it on settlement, and
|
|
136
|
-
// only then does the sweep of our own XMR open up (see refundHalfRevealed).
|
|
137
|
-
export function createReverseSwap(input = {}) {
|
|
138
|
-
const s = loadState();
|
|
139
|
-
const now = Date.now();
|
|
140
|
-
const xmr = Number(input.xmrAmount);
|
|
141
|
-
const swap = {
|
|
142
|
-
id: shortId(),
|
|
143
|
-
side: "reverse",
|
|
144
|
-
scenario: "happy",
|
|
145
|
-
speed: Number(input.speed) || 60,
|
|
146
|
-
network: input.network || DEFAULT_CHAIN,
|
|
147
|
-
moneroNetwork: MONERO.networkType,
|
|
148
|
-
realStart: now,
|
|
149
|
-
simStartWall: now,
|
|
150
|
-
createdAt: new Date(now).toISOString(),
|
|
151
|
-
// THE USER GIVES XMR AND RECEIVES THE NATIVE COIN: payToken is XMR, receiveAddress is their payout address
|
|
152
|
-
// on the settlement chain.
|
|
153
|
-
payToken: "XMR",
|
|
154
|
-
payAmount: xmr,
|
|
155
|
-
xmrAmount: xmr,
|
|
156
|
-
rate: Number(input.rate) || 0,
|
|
157
|
-
fee: Number(input.fee) || 0,
|
|
158
|
-
maker: { id: input.providerId || null, name: input.providerName || null, spread: null },
|
|
159
|
-
receiveAddress: input.claimer || null,
|
|
160
|
-
quoteSignature: null,
|
|
161
|
-
ticket: input.ticketId || null,
|
|
162
|
-
timeline: { fundAtSim: 0, lockAtSim: 0, blockSim: MONERO.blockTimeSim, confTarget: MONERO.confirmTarget, t0Sim: TIMING.t0Refund },
|
|
163
|
-
chainMode: input.chainMode || DEMO.defaultChainMode,
|
|
164
|
-
xmrSource: input.xmrSource || API.xmrSource,
|
|
165
|
-
xmrSession: null,
|
|
166
|
-
xmrSessionError: null,
|
|
167
|
-
events: [],
|
|
168
|
-
// HALVES WALLET OF THE REVERSE ORDER: our own spend and view half plus the depositor's PUBLIC halves. The
|
|
169
|
-
// recovery file builds the wallet from here (recovery/recoveryFile.js, buildPayload v3), and after the half
|
|
170
|
-
// is revealed, so does the spend key. The counterparty's SPEND half is not here: the chain provides it
|
|
171
|
-
// (refundHalfRevealed).
|
|
172
|
-
escrow: {
|
|
173
|
-
address: input.escrowAddress || null,
|
|
174
|
-
moneroAddress: input.moneroAddress || null,
|
|
175
|
-
half: input.ownSpendHalf || null,
|
|
176
|
-
viewHalf: input.ownViewHalf || null,
|
|
177
|
-
counterViewHalf: input.counterViewHalf || null,
|
|
178
|
-
counterViewPoint: input.counterViewPoint || null,
|
|
179
|
-
counterSpendPoint: input.counterSpendPoint || null,
|
|
180
|
-
moneroNetwork: MONERO.networkType,
|
|
181
|
-
birthHeight: input.escrowBirthHeight || null,
|
|
182
|
-
readyBy: input.readyBy || null,
|
|
183
|
-
t1: input.t1 || null,
|
|
184
|
-
// The DEPOSITOR's REVEALED HALF appears when the chain publishes it on settlement. Until then there is
|
|
185
|
-
// nothing to sweep with.
|
|
186
|
-
counterHalf: null,
|
|
187
|
-
},
|
|
188
|
-
xmr: null,
|
|
189
|
-
settlement: null,
|
|
190
|
-
log: [],
|
|
191
|
-
};
|
|
192
|
-
s.swaps[swap.id] = swap;
|
|
193
|
-
s.activeSwapId = swap.id;
|
|
194
|
-
saveState(s);
|
|
195
|
-
return swap;
|
|
196
|
-
}
|
|
197
|
-
|
|
198
|
-
function pushLog(swap, atSim, title, detail) {
|
|
199
|
-
swap.log.push({ atSim, wall: simWall(swap, atSim), title, detail: detail || "" });
|
|
200
|
-
// Дублируем в КОНСОЛЬ и это основной канал для разбора. Раньше журнал показывался только на странице,
|
|
201
|
-
// где жил в состоянии экрана и исчезал при перерисовке - то есть ровно тогда, когда он нужнее всего.
|
|
202
|
-
// В консоли история сохраняется, и её можно скопировать целиком.
|
|
203
|
-
console.log(`[swap ${swap.id}] ${title}${detail ? " - " + detail : ""}`);
|
|
204
|
-
}
|
|
205
|
-
|
|
206
|
-
// Убрать сделку из состояния браузера. Только локально: на сервере запись остаётся, и файлы кошелька,
|
|
207
|
-
// которые им управляет бэкенд, тоже. Массово удалять их по маске нельзя - там кошельки настоящих свопов,
|
|
208
|
-
// поэтому серверная уборка делается отдельным скриптом осознанно (tools/cleanup-swaps.mjs).
|
|
209
|
-
// КОШЕЛОК ИЗ ПОЛОВИН ЗАПИСИ - ДЛЯ ФАЙЛА ВОССТАНОВЛЕНИЯ. Половины сохраняются в записи сделки в момент
|
|
210
|
-
// фондирования (escrow.half и соседние поля), и это единственный их дом: полного ключа траты не существует
|
|
211
|
-
// ни у одной стороны, а своя половина случайна - мнемоникой её не восстановить. Раньше выгрузка брала
|
|
212
|
-
// swapWallet (кошелёк прежней схемы), и файл получался версией 2 - по такому файлу свои XMR не забрать.
|
|
213
|
-
// Собираем ровно те поля, которых ждёт recoveryFile.js версии 3, и в одном месте, чтобы выгрузка и
|
|
214
|
-
// проверки не расходились.
|
|
215
|
-
export function halvesWalletOf(swap) {
|
|
216
|
-
const e = (swap && swap.escrow) || {};
|
|
217
|
-
if (!e.half || !e.viewHalf || !e.counterViewHalf || !e.moneroAddress) return null;
|
|
218
|
-
return {
|
|
219
|
-
source: "halves",
|
|
220
|
-
library: "monero-js",
|
|
221
|
-
network: e.moneroNetwork || MONERO.networkType,
|
|
222
|
-
address: e.moneroAddress,
|
|
223
|
-
subaddress: null,
|
|
224
|
-
spendHalf: e.half,
|
|
225
|
-
viewHalf: e.viewHalf,
|
|
226
|
-
// THE COUNTERPARTY'S SPEND POINT CARRIES DIFFERENT NAMES PER FLOW: on the direct swap it is the claimer's
|
|
227
|
-
// point (edPointClaimer); on the reverse swap it is the DEPOSITOR's point, which the contract publishes on
|
|
228
|
-
// settlement (counterSpendPoint). One consumer (the recovery file and the sweep) must read both, or the
|
|
229
|
-
// reverse order would end up without its own spend key.
|
|
230
|
-
otherSpendPoint: e.counterSpendPoint || e.edPointClaimer || null,
|
|
231
|
-
otherViewPoint: e.counterViewPoint || null,
|
|
232
|
-
otherViewHalf: e.counterViewHalf,
|
|
233
|
-
};
|
|
234
|
-
}
|
|
235
|
-
|
|
236
|
-
export function forgetSwap(id) {
|
|
237
|
-
const s = loadState();
|
|
238
|
-
if (!s.swaps[id]) return false;
|
|
239
|
-
delete s.swaps[id];
|
|
240
|
-
saveState(s);
|
|
241
|
-
return true;
|
|
242
|
-
}
|
|
243
|
-
|
|
244
|
-
export function getSwap(id) {
|
|
245
|
-
return loadState().swaps[id] || null;
|
|
246
|
-
}
|
|
247
|
-
|
|
248
|
-
export function allSwaps() {
|
|
249
|
-
const s = loadState();
|
|
250
|
-
return Object.values(s.swaps).sort((a, b) => (a.realStart < b.realStart ? 1 : -1));
|
|
251
|
-
}
|
|
252
|
-
|
|
253
|
-
// ---- СЛИЯНИЕ С СПИСКОМ СЕРВЕРА --------------------------------------------------------------------
|
|
254
|
-
//
|
|
255
|
-
// Зачем. Пока записи живут только в localStorage, список привязан к браузеру: очистили данные сайта или
|
|
256
|
-
// открыли с другого устройства - сделок не видно, хотя деньги в эскроу на месте. Серверный список это
|
|
257
|
-
// снимает.
|
|
258
|
-
//
|
|
259
|
-
// ПРАВИЛА СЛИЯНИЯ, и они намеренно осторожные:
|
|
260
|
-
// 1) локальная запись ВСЕГДА главнее: она знает больше (секрет, кошелёк, стадии). Серверная строка только
|
|
261
|
-
// ДОБАВЛЯЕТ то, чего в этом браузере нет, и никогда не перезаписывает найденное;
|
|
262
|
-
// 2) запись, пришедшая с сервера, помечается source: "server" - чтобы человек и код видели, откуда она,
|
|
263
|
-
// и чтобы на экране можно было честно сказать об этом;
|
|
264
|
-
// 3) из серверной строки берём только публичные поля. Секрета там нет и быть не может - значит, такая
|
|
265
|
-
// сделка будет видна, но подписать забор средств из неё нельзя: секрет лежит в файле восстановления.
|
|
266
|
-
// Это не недоделка, а следствие того, что секрет на сервер не уходит.
|
|
267
|
-
export function serverRowToSwap(row) {
|
|
268
|
-
const escrow = String((row && row.escrow) || "").toLowerCase();
|
|
269
|
-
return {
|
|
270
|
-
id: String(row.id),
|
|
271
|
-
network: row.network || null,
|
|
272
|
-
chain: row.network || null,
|
|
273
|
-
source: "server",
|
|
274
|
-
status: "in_progress",
|
|
275
|
-
escrow: {
|
|
276
|
-
address: escrow,
|
|
277
|
-
t0: row.t0 != null ? Number(row.t0) : null,
|
|
278
|
-
t1: row.t1 != null ? Number(row.t1) : null,
|
|
279
|
-
hashlock: row.hashlock || null,
|
|
280
|
-
amountWei: row.amount != null ? String(row.amount) : null,
|
|
281
|
-
fromServer: true,
|
|
282
|
-
},
|
|
283
|
-
log: [],
|
|
284
|
-
events: [],
|
|
285
|
-
};
|
|
286
|
-
}
|
|
287
|
-
|
|
288
|
-
// Добавить записи с сервера, которых нет локально. Возвращает число добавленных: вызывающий по нему решает,
|
|
289
|
-
// перерисовывать ли список, а не догадывается.
|
|
290
|
-
export function addServerSwaps(rows) {
|
|
291
|
-
if (!Array.isArray(rows) || rows.length === 0) return 0;
|
|
292
|
-
const s = loadState();
|
|
293
|
-
let added = 0;
|
|
294
|
-
for (const row of rows) {
|
|
295
|
-
if (!row || !row.id || !row.escrow) continue;
|
|
296
|
-
if (s.swaps[row.id]) continue; // локальная запись главнее - не трогаем
|
|
297
|
-
s.swaps[row.id] = serverRowToSwap(row);
|
|
298
|
-
added += 1;
|
|
299
|
-
}
|
|
300
|
-
if (added) saveState(s);
|
|
301
|
-
return added;
|
|
302
|
-
}
|
|
303
|
-
|
|
304
|
-
// Найти сделку по адресу эскроу: нужно, чтобы связать запись из списка сервера с настоящим ордером.
|
|
305
|
-
|
|
306
|
-
export function activeSwaps() {
|
|
307
|
-
return allSwaps().filter((sw) => !sw.settlement);
|
|
308
|
-
}
|
|
309
|
-
|
|
310
|
-
// СОХРАНИТЬ СДЕЛКУ ПОСЛЕ ПРАВОК ИЗВНЕ. persist() был внутренним, и вызывающие модули (например
|
|
311
|
-
// confirm.js) правили поля записи - адрес эскроу, соль, сроки, секрет - и НЕ сохраняли её: правки жили в
|
|
312
|
-
// памяти и исчезали вместе с вкладкой, а экран прогресса читал из хранилища СТАРУЮ запись. Именно
|
|
313
|
-
// поэтому интерфейс показывал адрес, которого на цепи нет, даже когда фондирование прошло успешно.
|
|
314
|
-
export function saveSwap(swap) {
|
|
315
|
-
return persist(swap);
|
|
316
|
-
}
|
|
317
|
-
|
|
318
|
-
function persist(swap) {
|
|
319
|
-
const s = loadState();
|
|
320
|
-
s.swaps[swap.id] = swap;
|
|
321
|
-
saveState(s);
|
|
322
|
-
return swap;
|
|
323
|
-
}
|
|
324
|
-
|
|
325
|
-
function event(swap, type, atSim, extra = {}) {
|
|
326
|
-
swap.events.push({ type, atSim, ...extra });
|
|
327
|
-
return swap;
|
|
328
|
-
}
|
|
329
|
-
|
|
330
|
-
function findEvent(swap, type) {
|
|
331
|
-
return swap.events.find((e) => e.type === type) || null;
|
|
332
|
-
}
|
|
333
|
-
|
|
334
|
-
// ---------------------------------------------------------------------------
|
|
335
|
-
// Действия пользователя
|
|
336
|
-
// ---------------------------------------------------------------------------
|
|
337
|
-
|
|
338
|
-
export function confirmReady(id) {
|
|
339
|
-
const swap = getSwap(id);
|
|
340
|
-
if (!swap || swap.settlement) return swap;
|
|
341
|
-
const view = derive(swap);
|
|
342
|
-
if (!view.can.ready) throw new Error("ready() недоступен в этой фазе");
|
|
343
|
-
const atSim = simElapsed(swap);
|
|
344
|
-
event(swap, "ready", atSim);
|
|
345
|
-
pushLog(swap, atSim, "ready() called by you", "maker may now claim the ETH escrow");
|
|
346
|
-
if (!findEvent(swap, "funded_note")) event(swap, "funded_note", 0);
|
|
347
|
-
return persist(swap);
|
|
348
|
-
}
|
|
349
|
-
|
|
350
|
-
// THE COUNTERPARTY'S SPEND HALF - THE ONE PLACE THAT SHOWS WHETHER THERE IS ANYTHING TO ASSEMBLE. The Monero
|
|
351
|
-
// spend key is the sum of two halves, and the user holds their own; sweeping XMR back after a refund is possible
|
|
352
|
-
// 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, docs/36).
|
|
354
|
-
export function refundHalfRevealed(swap) {
|
|
355
|
-
const e = (swap && swap.escrow) || {};
|
|
356
|
-
return e.revealedHalf || e.counterHalf || null;
|
|
357
|
-
}
|
|
358
|
-
|
|
359
|
-
// XMR SWEEP. In the ordinary phase this happens after the other side reveals its half through settlement. A
|
|
360
|
-
// REVERSE-FLOW REFUND (issue #118, second half) is the SECOND case: the order was refunded, the user's coins
|
|
361
|
-
// stayed on the swap's one-time address, and the contract revealed the DEPOSITOR's half on refund. Then the same
|
|
362
|
-
// sweep takes our coins back, and the settlement is marked recovered: the money returned to the user rather
|
|
363
|
-
// than leaving through the swap.
|
|
364
|
-
export function sweepNow(id) {
|
|
365
|
-
const swap = getSwap(id);
|
|
366
|
-
if (!swap) return swap;
|
|
367
|
-
// The deal already settled: no second sweep - EXCEPT a reverse-flow refund, which is how our own XMR are taken
|
|
368
|
-
// back.
|
|
369
|
-
if (swap.settlement && !(swap.settlement.kind === "refund_eth" && swap.side === "reverse")) return swap;
|
|
370
|
-
const view = derive(swap);
|
|
371
|
-
if (!view.can.sweep) throw new Error("sweep недоступен в этой фазе");
|
|
372
|
-
const atSim = simElapsed(swap);
|
|
373
|
-
const tx = chain.sweepXmr({ swapId: swap.id });
|
|
374
|
-
swap.xmr = swap.xmr || {};
|
|
375
|
-
swap.xmr.sweepTxid = tx.txid;
|
|
376
|
-
const fromRefund = Boolean(swap.settlement && swap.settlement.kind === "refund_eth");
|
|
377
|
-
swap.settlement = { kind: "success", phase: TERMINAL.success.phase, atSim, txid: tx.txid, by: "you",
|
|
378
|
-
...(fromRefund ? { recovered: true } : {}) };
|
|
379
|
-
pushLog(swap, atSim, "XMR swept to your address", tx.txid);
|
|
380
|
-
return persist(swap);
|
|
381
|
-
}
|
|
382
|
-
|
|
383
|
-
export function refundEth(id, by = "you") {
|
|
384
|
-
const swap = getSwap(id);
|
|
385
|
-
if (!swap || swap.settlement) return swap;
|
|
386
|
-
const view = derive(swap);
|
|
387
|
-
if (!view.can.refund) throw new Error("refund недоступен в этой фазе");
|
|
388
|
-
const atSim = simElapsed(swap);
|
|
389
|
-
const tx = chain.refundEscrow({ swapId: swap.id, by });
|
|
390
|
-
swap.settlement = { kind: "refund_eth", phase: TERMINAL.refunded_eth.phase, atSim, txHash: tx.txHash, by };
|
|
391
|
-
pushLog(swap, atSim, `ETH refunded to your wallet (${by})`, tx.txHash);
|
|
392
|
-
return persist(swap);
|
|
393
|
-
}
|
|
394
|
-
|
|
395
|
-
// ---------------------------------------------------------------------------
|
|
396
|
-
// Вывод состояния
|
|
397
|
-
// ---------------------------------------------------------------------------
|
|
398
|
-
|
|
399
|
-
// Живая ли сеть сделки: escrow.mode = live в конфиге сети. Настройка сети - факт, одинаковый для старых и
|
|
400
|
-
// новых сделок, в отличие от поля chainMode внутри записи.
|
|
401
|
-
function isLiveNetwork(swap) {
|
|
402
|
-
const conf = (chainById((swap && swap.network) || DEFAULT_CHAIN) || {}).escrow || {};
|
|
403
|
-
return conf.mode === "live";
|
|
404
|
-
}
|
|
405
|
-
|
|
406
|
-
// СУММА НА АДРЕСЕ ПРОТИВ ОБЕЩАННОЙ КОТИРОВКОЙ. Отметка ready разрешает забирающему расчёт по своей половине -
|
|
407
|
-
// то есть отдаёт ему ETH - поэтому она делается не на "что-то пришло", а на ДОСТАТОЧНУЮ сумму. Допуск 1e-6 XMR
|
|
408
|
-
// покрывает округление показа, а не скидку. Неизвестная сумма - тоже отказ: незнание не разрешение.
|
|
409
|
-
const XMR_AMOUNT_EPS = 1e-6;
|
|
410
|
-
export function xmrAmountCheck(swap, xmr) {
|
|
411
|
-
const expected = Number(swap && swap.xmrAmount);
|
|
412
|
-
const hasExpected = Number.isFinite(expected) && expected > 0;
|
|
413
|
-
// «УВИДЕННАЯ СУММА» - ЭТО ВСЁ, ЧТО ЛЕЖИТ НА АДРЕСЕ: и разблокированное, и запертое unlock_time. Иначе у
|
|
414
|
-
// запертого поступления сумма показывалась бы «не увидена», хотя деньги на адресе есть. Что из неё можно
|
|
415
|
-
// потратить - отдельный вопрос, и его решает статус (ready не включается, пока деньги заперты).
|
|
416
|
-
const seenSource = xmr && xmr.arrived !== null && xmr.arrived !== undefined ? xmr.arrived : (xmr ? xmr.received : null);
|
|
417
|
-
const seen = seenSource !== null && seenSource !== undefined && Number.isFinite(Number(seenSource))
|
|
418
|
-
? Number(seenSource) : null;
|
|
419
|
-
if (!hasExpected) {
|
|
420
|
-
return { ok: true, checked: false, expected: null, seen,
|
|
421
|
-
why: "the record has no quoted XMR amount, so the arrived sum cannot be compared - this is named, not assumed" };
|
|
422
|
-
}
|
|
423
|
-
if (seen === null) {
|
|
424
|
-
return { ok: false, checked: true, expected, seen: null,
|
|
425
|
-
why: "the amount of XMR on the address is not known (" + expected + " XMR was quoted): ready stays closed until the sum is seen" };
|
|
426
|
-
}
|
|
427
|
-
// СВЕРКА ДВУСТОРОННЯЯ, И ЭТО ИСПРАВЛЕНИЕ issue #116. Прежде проверка была односторонней («пришло не
|
|
428
|
-
// меньше») и оттого СЛЕПОЙ к переплате ноды: нода считала долг по сырому эталону без спреда и отдавала
|
|
429
|
-
// 5,000 XMR там, где обещала 4,950 - приход был БОЛЬШЕ обещанного, и проверка молчала. Число, с которым
|
|
430
|
-
// сверяем, - то же, что нода кладёт долгом в журнал (amount_xmr): оно идёт от той же ленты цен, что и
|
|
431
|
-
// показанное человеку. Расхождение в ЛЮБУЮ сторону значит, что записанное и показанное - разные числа.
|
|
432
|
-
if (seen + XMR_AMOUNT_EPS < expected) {
|
|
433
|
-
return { ok: false, checked: true, expected, seen,
|
|
434
|
-
why: "only " + seen + " XMR arrived, the quote promised " + expected + " XMR: ready is blocked - the escrow would pay out for less than agreed" };
|
|
435
|
-
}
|
|
436
|
-
if (seen > expected + XMR_AMOUNT_EPS) {
|
|
437
|
-
return { ok: false, checked: true, expected, seen,
|
|
438
|
-
why: seen + " XMR arrived, but the record promises " + expected + " XMR: the sum on the address does not match the quoted trade - ready is blocked until they agree (a node paying more than it quoted loses its spread)" };
|
|
439
|
-
}
|
|
440
|
-
return { ok: true, checked: true, expected, seen, why: null };
|
|
441
|
-
}
|
|
442
|
-
|
|
443
|
-
export function derive(swap, now = nowReal()) {
|
|
444
|
-
const sim = simElapsed(swap, now);
|
|
445
|
-
const t = swap.timeline;
|
|
446
|
-
const ev = (type) => findEvent(swap, type);
|
|
447
|
-
const readyEv = ev("ready");
|
|
448
|
-
|
|
449
|
-
// Слепок Monero-ноги: в мок-режиме считается по sim-времени, в live - читается из последнего
|
|
450
|
-
// опроса бэкенда. Дальше движок работает одинаково и не знает, откуда данные.
|
|
451
|
-
const live = isLiveSource(swap.xmrSource);
|
|
452
|
-
const xmr = xmrView(swap, sim);
|
|
453
|
-
// ПРИХОД - ЭТО ВСЁ, ЧТО ВИДНО НА АДРЕСЕ (arrived), а не только разблокированное: запертое unlock_time
|
|
454
|
-
// пришло, но потратить его нельзя - это отдельное состояние, которое называется ниже и не пускает «готово».
|
|
455
|
-
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 => мейкер не лочит (сценарий maker_no_lock)
|
|
457
|
-
const conf = xmr.confirmations;
|
|
458
|
-
const lockDone = live ? arrived > 0 : lockAt !== null && sim >= lockAt;
|
|
459
|
-
|
|
460
|
-
const readyAt = readyEv ? readyEv.atSim : null;
|
|
461
|
-
// СРОКИ ИЗ ЦЕПИ - В НАСТОЯЩЕМ ВРЕМЕНИ: readyBy и t1 проставляет контракт, и они не имеют отношения ни к
|
|
462
|
-
// ускоренному времени демонстрации, ни к симулятору.
|
|
463
|
-
const chainReadyByMs = swap.escrow && swap.escrow.readyBy ? Number(swap.escrow.readyBy) * 1000 : null;
|
|
464
|
-
const chainT1Ms = swap.escrow && swap.escrow.t1 ? Number(swap.escrow.t1) * 1000 : null;
|
|
465
|
-
// ОКНО ПРОДАЖИ XMR (issue #111). Отсчёт идёт ОТ КОНТРАКТНОГО readyBy (это проставляют не мы и не симулятор),
|
|
466
|
-
// а не от TIMING.readyWindow: тот живёт в ускоренном времени демонстрации и на живой сделке показал бы
|
|
467
|
-
// демо-время. Мягкое обещание (600 с) продлевается до контрактного (1200 с), пока в пуле нет ПОЛНОЙ суммы, -
|
|
468
|
-
// тем же правилом, что у ноды (sellWindowSec). Право на действие это НЕ меняет: отметку контракт принимает
|
|
469
|
-
// до readyBy, а окно здесь - обещание на экране.
|
|
470
|
-
const sellSeen = Number(xmr.arrived !== undefined && xmr.arrived !== null ? xmr.arrived : (xmr.received || 0)) > 0;
|
|
471
|
-
const sellWindow = (() => {
|
|
472
|
-
if (swap.side !== "reverse" || chainReadyByMs === null) return null;
|
|
473
|
-
const startMs = chainReadyByMs - SELL_WINDOW.hardSec * 1000; // начало окна = readyBy минус контрактный максимум
|
|
474
|
-
const softAtMs = startMs + SELL_WINDOW.softSec * 1000;
|
|
475
|
-
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; // до продления счёт идёт до мягкого обещания
|
|
478
|
-
return { startMs, atMs, leftMs: atMs - nowReal(), seen: sellSeen, extended,
|
|
479
|
-
softSec: SELL_WINDOW.softSec, hardSec: SELL_WINDOW.hardSec, windowSec };
|
|
480
|
-
})();
|
|
481
|
-
const claimAt = readyAt !== null && swap.scenario !== "maker_stall" ? readyAt + TIMING.makerClaim : null;
|
|
482
|
-
const sweptAt = claimAt !== null ? claimAt + TIMING.sweep : null;
|
|
483
|
-
|
|
484
|
-
const readyDeadline = lockAt !== null ? lockAt + TIMING.readyWindow : null;
|
|
485
|
-
const makerReclaimAt = readyDeadline !== null ? readyDeadline + TIMING.makerReclaim : null;
|
|
486
|
-
const t1At = readyAt !== null ? readyAt + TIMING.t1Refund : null;
|
|
487
|
-
|
|
488
|
-
// Сценарий «пользователь не подтвердил»: мейкер забирает XMR назад.
|
|
489
|
-
const xmrReclaimed = readyAt === null && makerReclaimAt !== null && sim >= makerReclaimAt;
|
|
490
|
-
|
|
491
|
-
// Доступные действия/дедлайны
|
|
492
|
-
const can = { ready: false, sweep: false, refund: false };
|
|
493
|
-
let phase = "xmr_locked";
|
|
494
|
-
let phaseLabel = "Waiting";
|
|
495
|
-
let refundAfterSim = null;
|
|
496
|
-
let note = null;
|
|
497
|
-
|
|
498
|
-
if (swap.settlement) {
|
|
499
|
-
phase = swap.settlement.phase;
|
|
500
|
-
phaseLabel = Object.values(TERMINAL).find((x) => x.phase === swap.settlement.phase)?.label || "Settled";
|
|
501
|
-
// SWEEPING OUR OWN XMR AFTER A REFUND (issue #118, second half). On the reverse flow a refund leaves the
|
|
502
|
-
// user's coins on the swap's one-time address: the claimer's ETH came back, and nobody moved the XMR. They can
|
|
503
|
-
// only be taken by assembling the spend key from OUR half and the OTHER half that the chain revealed on
|
|
504
|
-
// settlement. While the revealed half is missing we do NOT grant the right - can.sweep stays false precisely
|
|
505
|
-
// so that the button cannot appear without a working action. The "arrived / expected" numbers are already
|
|
506
|
-
// handed to the screen below (xmrAmount): only the right to sweep is decided here.
|
|
507
|
-
if (swap.settlement.kind === "refund_eth" && swap.side === "reverse") {
|
|
508
|
-
const revealed = refundHalfRevealed(swap);
|
|
509
|
-
const stillOnAddress = arrived > 0;
|
|
510
|
-
can.sweep = Boolean(revealed && stillOnAddress && halvesWalletOf(swap));
|
|
511
|
-
// THE REASON IS NAMED, NOT HIDDEN: the screen must say what is missing, not merely omit the button.
|
|
512
|
-
note = can.sweep
|
|
513
|
-
? "The refund published the provider's half, so your XMR can be swept back from the swap's one-time address to your own wallet."
|
|
514
|
-
: (!stillOnAddress
|
|
515
|
-
? "The refund is done, but no XMR is on the swap's one-time address: there is nothing of yours to sweep back."
|
|
516
|
-
: "The refund is done and your XMR is on the swap's one-time address, but the provider's half of the spend key is not published yet: sweeping needs both halves, so the button waits for the chain.");
|
|
517
|
-
}
|
|
518
|
-
} else if (live && readyAt === null && chainReadyByMs !== null && nowReal() >= chainReadyByMs
|
|
519
|
-
&& (chainT1Ms === null || nowReal() < chainT1Ms)) {
|
|
520
|
-
// БЫЛА МЁРТВАЯ ЗОНА - СТАЛА ОТКРЫТАЯ ДВЕРЬ (решение 30.09.2026). Отметку внёсший уже не поставит, забор
|
|
521
|
-
// без неё контракт запрещает, значит окно забора НЕ ОТКРЫЛОСЬ ВООБЩЕ - и возврат разрешён сразу после
|
|
522
|
-
// readyBy (условие контракта: `block.timestamp < readyBy || (block.timestamp < t1 && ready)`). Он же
|
|
523
|
-
// публикует половину внёсшего, поэтому мейкер забирает свои XMR, не дожидаясь t1. Это и есть ответ на
|
|
524
|
-
// мёртвую зону: раньше деньги и половина ждали t1, а Monero-нога была недоступна обеим сторонам.
|
|
525
|
-
phase = "dead_zone";
|
|
526
|
-
phaseLabel = "Ready deadline passed - the refund is open";
|
|
527
|
-
can.ready = false;
|
|
528
|
-
can.refund = true;
|
|
529
|
-
can.refundByAnyone = true;
|
|
530
|
-
note = "The ready() deadline has passed without your confirmation, so the maker cannot claim - and the " +
|
|
531
|
-
"refund is open NOW, not at T1. It returns your ETH and publishes your half, so the maker can reclaim " +
|
|
532
|
-
"the XMR from Monero right away. " +
|
|
533
|
-
(chainT1Ms !== null ? "T1 closes the order for good: " + new Date(chainT1Ms).toISOString() + "." : "");
|
|
534
|
-
} else if (live && readyAt === null) {
|
|
535
|
-
// Live-режим: состояния Monero-ноги приходят от бэкенда. Симулированные таймауты EVM-ноги
|
|
536
|
-
// (T0/T1, watchtower) здесь намеренно НЕ показываем: контракта ещё нет, и кнопка «вернуть ETH»
|
|
537
|
-
// была бы обманом. Пометка «EVM-нога - симуляция» висит в UI отдельно (derive().simulated).
|
|
538
|
-
if (xmr.status === XMR_STATUS.LOCKED) {
|
|
539
|
-
// ДЕНЬГИ НА АДРЕСЕ ЕСТЬ, НО ЗАПЕРТЫ unlock_time. Это НАЗВАННОЕ состояние, а не «ждём приход»: иначе
|
|
540
|
-
// экран молчал бы, а сумма выглядела бы нулевой. Отметка готовности здесь невозможна - забор ETH под
|
|
541
|
-
// запертые XMR отдал бы мейкеру ETH за деньги, которыми нельзя заплатить (issue #84).
|
|
542
|
-
phase = "xmr_unlock_pending";
|
|
543
|
-
phaseLabel = "XMR is on your address but locked on-chain";
|
|
544
|
-
can.ready = false;
|
|
545
|
-
const u = xmr.lockedUntil || {};
|
|
546
|
-
const untilText = u.untilHeight !== null && u.untilHeight !== undefined
|
|
547
|
-
? "block " + u.untilHeight
|
|
548
|
-
: (u.untilTime !== null && u.untilTime !== undefined
|
|
549
|
-
? new Date(Number(u.untilTime) * 1000).toISOString() + " UTC"
|
|
550
|
-
: "a time the sender set");
|
|
551
|
-
note = "The sender set the transaction's unlock_time: " + (xmr.locked || 0) +
|
|
552
|
-
" XMR is on your address but cannot be spent until " + untilText +
|
|
553
|
-
". Ready stays closed until then - confirming now would let the maker take the ETH behind locked XMR.";
|
|
554
|
-
} else if (!xmr.received) {
|
|
555
|
-
phase = "maker_locking";
|
|
556
|
-
phaseLabel = "Waiting for XMR to arrive";
|
|
557
|
-
note = xmr.error
|
|
558
|
-
? "Backend unavailable: " + xmr.error
|
|
559
|
-
: "The backend watches your swap address with the view key only - it cannot spend your XMR.";
|
|
560
|
-
} else if (xmr.status === XMR_STATUS.READY) {
|
|
561
|
-
phase = "xmr_locked";
|
|
562
|
-
phaseLabel = "XMR received and confirmed (" + conf + "/" + t.confTarget + ")";
|
|
563
|
-
// СУММА ПРОВЕРЯЕТСЯ ВМЕСТЕ С ГОТОВНОСТЬЮ. Бэкенд видит XMR по ключу просмотра и называет сумму, а
|
|
564
|
-
// котировка обещала своё число: расхождение означает, что отметка отдала бы ETH дешевле договора.
|
|
565
|
-
const paid = xmrAmountCheck(swap, xmr);
|
|
566
|
-
can.ready = paid.ok;
|
|
567
|
-
can.readyAmount = paid;
|
|
568
|
-
note = paid.ok
|
|
569
|
-
? "Confirm that you see the XMR on your address (ready)."
|
|
570
|
-
: paid.why;
|
|
571
|
-
} else if (xmr.status === XMR_STATUS.SWEPT) {
|
|
572
|
-
phase = "success";
|
|
573
|
-
phaseLabel = "XMR delivered";
|
|
574
|
-
} else {
|
|
575
|
-
phase = "xmr_locked";
|
|
576
|
-
phaseLabel = "XMR received, waiting for confirmations (" + conf + "/" + t.confTarget + ")";
|
|
577
|
-
note = "About " + Math.max(1, Math.round(((t.confTarget - conf) * t.blockSim) / 60000)) + " min left of confirmations.";
|
|
578
|
-
}
|
|
579
|
-
} else if (lockAt === null) {
|
|
580
|
-
// Мейкер не залочил XMR. После T0 пользователь может вернуть ETH сам.
|
|
581
|
-
if (sim >= t.t0Sim) {
|
|
582
|
-
phase = "refund_available";
|
|
583
|
-
phaseLabel = "Maker did not lock XMR";
|
|
584
|
-
refundAfterSim = t.t0Sim;
|
|
585
|
-
can.refund = true;
|
|
586
|
-
note = "The maker never locked XMR. You can take your ETH back from the escrow - no permission needed.";
|
|
587
|
-
} else {
|
|
588
|
-
phase = "maker_locking";
|
|
589
|
-
phaseLabel = "Waiting for the maker to lock XMR";
|
|
590
|
-
note = "If the maker does not lock XMR by T0, you get a refund button.";
|
|
591
|
-
}
|
|
592
|
-
} else if (!lockDone) {
|
|
593
|
-
phase = "maker_locking";
|
|
594
|
-
phaseLabel = "Maker is locking XMR";
|
|
595
|
-
} else if (xmrReclaimed) {
|
|
596
|
-
phase = "refund_available";
|
|
597
|
-
phaseLabel = "XMR returned to the maker";
|
|
598
|
-
refundAfterSim = makerReclaimAt;
|
|
599
|
-
can.refund = true;
|
|
600
|
-
note = "You did not confirm (ready) in time, so the maker took the XMR back. Your ETH is still in escrow - refund it.";
|
|
601
|
-
} else if (readyAt === null) {
|
|
602
|
-
phase = "xmr_locked";
|
|
603
|
-
phaseLabel = conf < t.confTarget ? `Waiting for Monero confirmations (${conf}/${t.confTarget})` : "XMR locked and verified - waiting for you";
|
|
604
|
-
can.ready = live ? xmr.status === XMR_STATUS.READY : conf >= t.confTarget;
|
|
605
|
-
refundAfterSim = readyDeadline;
|
|
606
|
-
note = conf < t.confTarget
|
|
607
|
-
? `About ${Math.max(1, Math.round(((t.confTarget - conf) * t.blockSim) / 60000))} min left of confirmations. You can close this tab.`
|
|
608
|
-
: "Confirm that you see the lock with your view key (ready). Until you do, the maker keeps the XMR.";
|
|
609
|
-
} else if (claimAt !== null && sim < claimAt) {
|
|
610
|
-
phase = "ready_wait_claim";
|
|
611
|
-
phaseLabel = "Waiting for the maker to claim ETH";
|
|
612
|
-
note = "The maker claims the escrow and reveals the secret; your swap wallet uses it to sweep the XMR.";
|
|
613
|
-
} else if (claimAt !== null && sim >= claimAt && sweptAt !== null && sim >= sweptAt) {
|
|
614
|
-
phase = "success";
|
|
615
|
-
phaseLabel = "XMR delivered";
|
|
616
|
-
can.sweep = true;
|
|
617
|
-
} else if (claimAt !== null) {
|
|
618
|
-
phase = "claimed";
|
|
619
|
-
phaseLabel = "Secret revealed - sweeping XMR";
|
|
620
|
-
can.sweep = true;
|
|
621
|
-
} else {
|
|
622
|
-
// maker_stall: ready сделано, но claim не происходит
|
|
623
|
-
phase = "refund_available";
|
|
624
|
-
phaseLabel = "Maker did not claim in time";
|
|
625
|
-
refundAfterSim = t1At;
|
|
626
|
-
// ПРАВО НА ВОЗВРАТ ОПРЕДЕЛЯЕТ КОНТРАКТ, И ПРАВИЛО ЗАВИСИТ ОТ ВОЗРАСТА ОРДЕРА - так и надо, это не
|
|
627
|
-
// ветвление ради ветвления. Новые ордера создаются с ОДНОЙ границей t1: до неё возврата нет ни у кого,
|
|
628
|
-
// с неё - у кого угодно (решение 2026-09-16, док 26). У ордеров, созданных раньше, в записи лежит t0,
|
|
629
|
-
// и у них правило прежнее: с t0 вернуть вправе только locker, с t1 - кто угодно. Роли и сроки в старых
|
|
630
|
-
// ордерах неизменяемы, поэтому никакой миграции нет - они доживают свой срок по прежним правилам.
|
|
631
|
-
// Сравнивать надо с настоящим временем и по срокам из записи, а не по времени симулятора (оно ускорено).
|
|
632
|
-
const escrowT0 = swap.escrow && swap.escrow.t0 ? swap.escrow.t0 * 1000 : null; // есть только у старых
|
|
633
|
-
const chainT1 = swap.escrow && swap.escrow.t1 ? swap.escrow.t1 * 1000 : null;
|
|
634
|
-
const realNow = nowReal();
|
|
635
|
-
if (chainT1 !== null) {
|
|
636
|
-
if (escrowT0 !== null) {
|
|
637
|
-
can.refund = realNow >= escrowT0; // старый ордер: с t0, и только сам locker
|
|
638
|
-
can.refundByAnyone = realNow >= chainT1; // с t1 - кто угодно
|
|
639
|
-
} else {
|
|
640
|
-
// НОВЫЙ ОРДЕР: ГРАНИЦ ДВЕ, И ЭТО РЕШЕНИЕ 30.09.2026. С t1 - как было, возврат открыт всем. Но если
|
|
641
|
-
// отметки `ready` нет, окно забора не открылось вовсе, и возврат открыт сразу после readyBy: иначе
|
|
642
|
-
// мёртвая зона держала бы деньги и половину до t1. Отметку берём из ЗАПИСИ (`readyAt`) - её ставит сам
|
|
643
|
-
// пользователь, и в этой ветке она пуста. Если запись потеряна, а на цепи отметка есть, контракт
|
|
644
|
-
// откажет: честный отказ вместо обещания, которого мы не исполнили бы.
|
|
645
|
-
const chainReadyBy = swap.escrow && swap.escrow.readyBy ? Number(swap.escrow.readyBy) * 1000 : null;
|
|
646
|
-
const earlyRefund = chainReadyBy !== null && realNow >= chainReadyBy && readyAt === null;
|
|
647
|
-
can.refund = realNow >= chainT1 || earlyRefund;
|
|
648
|
-
can.refundByAnyone = can.refund; // и с t1 возврат открыт всем одновременно
|
|
649
|
-
if (earlyRefund && realNow < chainT1) refundAfterSim = null; // возврат уже открыт: отсчёта нет
|
|
650
|
-
}
|
|
651
|
-
} else if (!isLiveNetwork(swap)) {
|
|
652
|
-
can.refund = sim >= t1At; // демо-сделка: как раньше, по симулятору
|
|
653
|
-
} else {
|
|
654
|
-
can.refund = false; // живая сделка без прочитанных сроков: не обещаем
|
|
655
|
-
}
|
|
656
|
-
// ЧТО МОЖНО СДЕЛАТЬ СЕЙЧАС - БЕЗ ОБЕЩАНИЙ, КОТОРЫХ МЫ НЕ ИСПОЛНИМ. Сторож сейчас ручной вызов, а не
|
|
657
|
-
// служба: он НЕ вернёт средства сам по себе. Поэтому говорим не "will be refunded automatically", а что
|
|
658
|
-
// право появилось у кого угодно, включая сторожа, и кнопка по-прежнему работает.
|
|
659
|
-
note = can.refund
|
|
660
|
-
? (can.refundByAnyone
|
|
661
|
-
? "The claim deadline passed: the refund is open to anyone who can present the half of the depositor - " +
|
|
662
|
-
"that half is YOURS (it is in your recovery file), and the watchtower does not have it. " +
|
|
663
|
-
"Press Refund my ETH, or restore the file if you have lost this tab."
|
|
664
|
-
: "The maker missed the claim deadline. You can take your ETH back from the escrow yourself.")
|
|
665
|
-
: (escrowT0 !== null
|
|
666
|
-
? "This order predates the one-deadline rule: you could refund from T0, and from T1 anyone can."
|
|
667
|
-
: "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
|
-
}
|
|
669
|
-
|
|
670
|
-
// Шаги для степпера
|
|
671
|
-
const confDone = conf >= t.confTarget;
|
|
672
|
-
const claimed = claimAt !== null && sim >= claimAt;
|
|
673
|
-
const steps = [
|
|
674
|
-
{ key: "funded", done: true, atSim: 0 },
|
|
675
|
-
{ key: "maker_locking", done: lockDone, active: !lockDone && !swap.settlement && lockAt !== null, failed: lockAt === null && sim >= t.t0Sim, atSim: lockAt },
|
|
676
|
-
{ key: "xmr_locked", done: confDone && !xmrReclaimed, active: lockDone && !confDone, atSim: lockAt },
|
|
677
|
-
{ key: "ready", done: readyAt !== null, active: confDone && readyAt === null && !xmrReclaimed, atSim: readyAt },
|
|
678
|
-
{ key: "claimed", done: claimed, active: readyAt !== null && !claimed, atSim: claimAt },
|
|
679
|
-
{ key: "swept", done: !!swap.settlement && swap.settlement.kind === "success", active: claimed && !swap.settlement, atSim: swappedAtOrNull(swap) },
|
|
680
|
-
];
|
|
681
|
-
|
|
682
|
-
return {
|
|
683
|
-
swap,
|
|
684
|
-
sim,
|
|
685
|
-
simWallTime: simWall(swap, sim),
|
|
686
|
-
phase,
|
|
687
|
-
phaseLabel,
|
|
688
|
-
note,
|
|
689
|
-
conf,
|
|
690
|
-
confTarget: t.confTarget,
|
|
691
|
-
confProgress: t.confTarget ? conf / t.confTarget : 0,
|
|
692
|
-
// СРОКИ ДЛЯ ПОКАЗА БЕРУТСЯ ИЗ ТОГО ЖЕ ИСТОЧНИКА, ЧТО И ПРАВО НА ВОЗВРАТ - ИЗ ЗАПИСИ О СДЕЛКЕ.
|
|
693
|
-
// Здесь стояло simWall(swap, t.t0Sim): это время ДЕМО-СИМУЛЯТОРА (ускоренного в 60 раз), к живой сделке
|
|
694
|
-
// отношения не имеющее. Из-за этого получалось противоречие на одном экране: can.refund считался по
|
|
695
|
-
// НАСТОЯЩЕМУ сроку из escrow (и говорил "ещё нельзя"), а таймер кнопки и строка "T0 (refund if no XMR
|
|
696
|
-
// lock)" показывали симулированный (и говорили "available now" и "in 00:00").
|
|
697
|
-
// Два показателя одного и того же события обязаны считаться из одной величины: иначе экран противоречит
|
|
698
|
-
// сам себе, и человек не знает, какому сроку верить.
|
|
699
|
-
// Для ЖИВОЙ сделки сроки контрактные (те же, что у can.refund выше), для демонстрационной - из симулятора.
|
|
700
|
-
deadlines: {
|
|
701
|
-
// t0 ЕСТЬ ТОЛЬКО У СТАРЫХ ОРДЕРОВ. У новых граница одна, и для живой сделки без t0 честнее показать
|
|
702
|
-
// ничего (null), чем выдуманное число: строка T0 на экране по этому признаку и не рисуется.
|
|
703
|
-
// В демонстрационном режиме срок берётся из симулятора - там своё время, и оно осознанно ускорено.
|
|
704
|
-
t0: swap.escrow && swap.escrow.t0 ? swap.escrow.t0 * 1000 : (swap.escrow ? null : simWall(swap, t.t0Sim)),
|
|
705
|
-
t0Sim: t.t0Sim,
|
|
706
|
-
lockAtSim: lockAt,
|
|
707
|
-
readyBy: readyDeadline !== null ? simWall(swap, readyDeadline) : null,
|
|
708
|
-
readyBySim: readyDeadline,
|
|
709
|
-
t1: swap.escrow && swap.escrow.t1 ? swap.escrow.t1 * 1000 : (t1At !== null ? simWall(swap, t1At) : null),
|
|
710
|
-
t1Sim: t1At,
|
|
711
|
-
refundAfterSim,
|
|
712
|
-
refundAfterWall: refundAfterSim !== null ? simWall(swap, refundAfterSim) : null,
|
|
713
|
-
},
|
|
714
|
-
refundLeftSim: refundAfterSim !== null ? refundAfterSim - sim : null,
|
|
715
|
-
// Данные Monero-ноги для экрана: статус, адрес, txid и ссылка в обозревателе.
|
|
716
|
-
xmr: {
|
|
717
|
-
live: xmr.live,
|
|
718
|
-
source: swap.xmrSource,
|
|
719
|
-
status: xmr.status,
|
|
720
|
-
received: xmr.received,
|
|
721
|
-
// Запертое unlock_time и всё, что видно на адресе: экран показывает причину и границу.
|
|
722
|
-
locked: xmr.locked || 0,
|
|
723
|
-
lockedUntil: xmr.lockedUntil || null,
|
|
724
|
-
arrived: Number(xmr.arrived !== undefined && xmr.arrived !== null ? xmr.arrived : (xmr.received || 0)),
|
|
725
|
-
confirmations: xmr.confirmations,
|
|
726
|
-
confTarget: xmr.confTarget,
|
|
727
|
-
txids: xmr.txids,
|
|
728
|
-
address: xmr.address,
|
|
729
|
-
explorer: explorerXmrTx(xmr.txids[0], (swap.xmrSession && swap.xmrSession.network) || "stagenet"),
|
|
730
|
-
error: xmr.error,
|
|
731
|
-
sweepTxid: xmr.sweepTxid,
|
|
732
|
-
},
|
|
733
|
-
// Что сейчас настоящее, а что симуляция. UI обязан показывать это, а не угадывать.
|
|
734
|
-
// EVM-нога симулирована тогда и только тогда, когда сеть сделки не переведена в live. Раньше здесь стояло
|
|
735
|
-
// жёсткое true, и на ЖИВОЙ сделке с настоящим эскроу экран писал "EVM leg ... is still simulated" - то есть
|
|
736
|
-
// врал, хотя ордер существовал в контракте. Признак берём из настроек сети, а не из поля в сделке: поле
|
|
737
|
-
// проставляется при создании, и у сделок, заведённых раньше, там осталось "mock".
|
|
738
|
-
simulated: { evm: !isLiveNetwork(swap), xmr: !xmr.live },
|
|
739
|
-
can,
|
|
740
|
-
// СКОЛЬКО ЖДАТЬ ДО ГРАНИЦЫ ОТМЕТКИ и не подошла ли она: граница есть, отметки нет. Предупреждение за
|
|
741
|
-
// SIGNING_GUARDS.minReadyLeadSec секунд до неё - потому что после границы не работает НИ ОДНА кнопка.
|
|
742
|
-
readyDeadline: {
|
|
743
|
-
atMs: chainReadyByMs,
|
|
744
|
-
soonMs: chainReadyByMs !== null && readyAt === null ? chainReadyByMs - nowReal() : null,
|
|
745
|
-
soon: chainReadyByMs !== null && readyAt === null
|
|
746
|
-
&& chainReadyByMs - nowReal() > 0
|
|
747
|
-
&& chainReadyByMs - nowReal() <= Number(SIGNING_GUARDS.readyLeadWarnSec || 0) * 1000,
|
|
748
|
-
},
|
|
749
|
-
// ОБЕ СУММЫ НАРУЖУ: обещанная котировкой и увиденная на адресе. Экран обязан показать обе, а не одну.
|
|
750
|
-
xmrAmount: { expected: (() => { const e = Number(swap.xmrAmount); return Number.isFinite(e) && e > 0 ? e : null; })(),
|
|
751
|
-
seen: xmr.received !== null && xmr.received !== undefined ? Number(xmr.received) : null },
|
|
752
|
-
steps,
|
|
753
|
-
// ОКНО ПРОДАЖИ: адрес уже в xmr.address, здесь - отсчёт и его границы (issue #111). null у прямого потока.
|
|
754
|
-
sellWindow,
|
|
755
|
-
terminal: swap.settlement,
|
|
756
|
-
xmrReclaimed,
|
|
757
|
-
};
|
|
758
|
-
}
|
|
759
|
-
|
|
760
|
-
function swappedAtOrNull(swap) {
|
|
761
|
-
return swap.settlement && swap.settlement.kind === "success" ? swap.settlement.atSim : null;
|
|
762
|
-
}
|
|
763
|
-
|
|
764
|
-
// ---------------------------------------------------------------------------
|
|
765
|
-
// Тикер: завершает сделки, которые должны завершиться без пользователя
|
|
766
|
-
// (авто-sweep и watchtower), и рассылает подписчикам изменения.
|
|
767
|
-
// ---------------------------------------------------------------------------
|
|
768
|
-
|
|
769
|
-
let timer = null;
|
|
770
|
-
const listeners = new Set();
|
|
771
|
-
const notified = new Set();
|
|
772
|
-
|
|
773
|
-
export function subscribe(fn) {
|
|
774
|
-
listeners.add(fn);
|
|
775
|
-
return () => listeners.delete(fn);
|
|
776
|
-
}
|
|
777
|
-
|
|
778
|
-
function emit() {
|
|
779
|
-
for (const fn of listeners) {
|
|
780
|
-
try { fn(); } catch (e) { console.error(e); }
|
|
781
|
-
}
|
|
782
|
-
}
|
|
783
|
-
|
|
784
|
-
// --- live-режим: сессия свопа на бэкенде -----------------------------------------------
|
|
785
|
-
//
|
|
786
|
-
// Опрос идёт без ожидания: тикер экрана не должен ждать сеть, иначе при недоступном бэкенде демка
|
|
787
|
-
// начнёт тормозить. Результат кладём в сам своп (xmrSession / xmrSessionError), а derive() читает
|
|
788
|
-
// его как обычное состояние - она остаётся чистой функцией.
|
|
789
|
-
const liveInFlight = new Set();
|
|
790
|
-
|
|
791
|
-
function applyLive(id, patch) {
|
|
792
|
-
const s = loadState();
|
|
793
|
-
if (!s.swaps[id]) return;
|
|
794
|
-
Object.assign(s.swaps[id], patch);
|
|
795
|
-
saveState(s);
|
|
796
|
-
}
|
|
797
|
-
|
|
798
|
-
// Время последней ПОПЫТКИ обращения к бэкенду по каждому свопу. Именно попытки, а не успеха: ограничение
|
|
799
|
-
// частоты должно работать и когда запрос падает.
|
|
800
|
-
const liveLastTry = new Map();
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
// КЛЮЧ СДЕЛКИ: СТРАНИЦА И ПРИЛОЖЕНИЕ НАЗЫВАЮТ ЕЁ ПО-РАЗНОМУ. У страницы свой id (живёт в браузере), у
|
|
804
|
-
// приложения - свой (localtest-...): id задаёт приложение, когда заводит запись наблюдения. Состояние XMR
|
|
805
|
-
// страница спрашивала по СВОЕМУ id, приложение такого не знает и отвечает отказом. Снаружи это выглядит
|
|
806
|
-
// так: XMR пришли, подтверждений десяток, а экран сделки показывает прежнее состояние, кнопка отметки
|
|
807
|
-
// готовности не открывается - и окно сделки закрывается впустую (проверено живым прогоном).
|
|
808
|
-
// СВЯЗЫВАЕМ ПО АДРЕСУ MONERO: страница и так сообщает его приложению при заведении наблюдения, значит по
|
|
809
|
-
// нему и находит свою запись, а дальше спрашивает состояние по её id. Правок в приложении не требуется.
|
|
810
|
-
// ВСЕ ЗАПИСИ ОДНОЙ СДЕЛКИ В БРАУЗЕРЕ. Их две (обмен и эскроу), и следить нужно за сделкой, а не за той записью,
|
|
811
|
-
// на которую случайно попал экран: у записи эскроу нет суммы ожидания XMR, поэтому её сессия не заводится, и
|
|
812
|
-
// состояние до экрана не доходит. Связь - общий адрес Monero, он один на сделку.
|
|
813
|
-
export function dealSwapIds(swap) {
|
|
814
|
-
const ids = new Set([String((swap && swap.id) || "")].filter(Boolean));
|
|
815
|
-
const w0 = (() => { try { return halvesWalletOf(swap) || {}; } catch { return {}; } })();
|
|
816
|
-
const address = w0.address || (swap && swap.escrow && swap.escrow.moneroAddress) || null;
|
|
817
|
-
if (!address) return [...ids];
|
|
818
|
-
for (const other of Object.values((loadState() || {}).swaps || {})) {
|
|
819
|
-
if (!other || !other.id) continue;
|
|
820
|
-
const w = (() => { try { return halvesWalletOf(other) || {}; } catch { return {}; } })();
|
|
821
|
-
if (w.address === address) ids.add(String(other.id));
|
|
822
|
-
}
|
|
823
|
-
return [...ids];
|
|
824
|
-
}
|
|
825
|
-
|
|
826
|
-
const serverIdByAddress = new Map();
|
|
827
|
-
|
|
828
|
-
// ФАКТЫ СДЕЛКИ ИЩЕМ ПО ВСЕМ ЕЁ ЗАПИСЯМ В БРАУЗЕРЕ. Об одной сделке страница держит ДВЕ записи: одну завёл
|
|
829
|
-
// обмен (адрес Monero, сумма ожидания XMR), другую - эскроу (половины, адрес эскроу). Экран, открытый по
|
|
830
|
-
// записи эскроу, не знал суммы ожидания, сессия не заводилась вовсе, и кнопка готовности не включалась -
|
|
831
|
-
// при том что приложение готовность уже отдавало. Записи связаны ОБЩИМ адресом Monero: он один на сделку.
|
|
832
|
-
function dealFactsFor(swap) {
|
|
833
|
-
const w0 = (() => { try { return halvesWalletOf(swap) || {}; } catch { return {}; } })();
|
|
834
|
-
let address = w0.address || (swap && swap.escrow && swap.escrow.moneroAddress) || (swap && swap.xmrSession && swap.xmrSession.address) || null;
|
|
835
|
-
let amountXmr = Number(swap && swap.xmrAmount) > 0 ? Number(swap.xmrAmount) : null;
|
|
836
|
-
let source = "своя запись";
|
|
837
|
-
if (address && amountXmr === null) {
|
|
838
|
-
for (const other of Object.values((loadState() || {}).swaps || {})) {
|
|
839
|
-
if (!other || other === swap) continue;
|
|
840
|
-
const w = (() => { try { return halvesWalletOf(other) || {}; } catch { return {}; } })();
|
|
841
|
-
if (!w.address || w.address !== address) continue;
|
|
842
|
-
if (Number(other.xmrAmount) > 0) { amountXmr = Number(other.xmrAmount); source = "другая запись той же сделки (" + String(other.id || "?") + ")"; break; }
|
|
843
|
-
}
|
|
844
|
-
}
|
|
845
|
-
if (!address) {
|
|
846
|
-
for (const other of Object.values((loadState() || {}).swaps || {})) {
|
|
847
|
-
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 = "другая запись той же сделки"; break; }
|
|
849
|
-
}
|
|
850
|
-
}
|
|
851
|
-
return { address, amountXmr, source };
|
|
852
|
-
}
|
|
853
|
-
|
|
854
|
-
// АДРЕС СДЕЛКИ БЕРЁМ ОТОВСЮДУ, ГДЕ ОН МОЖЕТ ЛЕЖАТЬ. Сделка в браузере и запись наблюдения в приложении - это
|
|
855
|
-
// РАЗНЫЕ записи об одной сделке (страница заводит свою на эскроу, приложение - на наблюдение за адресом),
|
|
856
|
-
// поэтому у записи страницы адрес есть не всегда: у записи эскроу он лежит в половинах, а не в поле
|
|
857
|
-
// escrow.moneroAddress. Живой прогон из-за этого опрашивал мёртвый id, получал 404 и оставлял кнопку
|
|
858
|
-
// готовности выключенной. Вынесено отдельно, чтобы этим же адресом пользовался поиск высоты у сервера.
|
|
859
|
-
function dealMoneroAddress(swap) {
|
|
860
|
-
const fromHalves = (() => { try { return (halvesWalletOf(swap) || {}).address || null; } catch { return null; } })();
|
|
861
|
-
return (swap && swap.escrow && swap.escrow.moneroAddress)
|
|
862
|
-
|| (swap && swap.xmrSession && swap.xmrSession.address)
|
|
863
|
-
|| (swap && swap.swapWallet && swap.swapWallet.address)
|
|
864
|
-
|| fromHalves
|
|
865
|
-
|| null;
|
|
866
|
-
}
|
|
867
|
-
|
|
868
|
-
// ВЫСОТА СКАНА, КОТОРУЮ ЗНАЕТ ПРИЛОЖЕНИЕ. Нужна файлам СТАРЫХ сделок: в их записи нет birthHeight (поле
|
|
869
|
-
// появилось позже), а высоту при регистрации наблюдения приложение разрешило само и хранит. Источник наш,
|
|
870
|
-
// поэтому от чужой ноды Monero не зависит. Сервер молчит - возвращаем null, и вызывающий остаётся на
|
|
871
|
-
// прежнем поведении: высота берётся из других источников или файл честно предупреждает о её отсутствии.
|
|
872
|
-
export async function serverRestoreHeightFor(swap, { attempts = 3, pauseMs = 1200 } = {}) {
|
|
873
|
-
const addr = dealMoneroAddress(swap);
|
|
874
|
-
if (!addr) return null;
|
|
875
|
-
// НЕ ОДНА ПОПЫТКА. Строка наблюдения появляется у приложения в тот же миг, что и эскроу, и файл может
|
|
876
|
-
// быть выпущен на волосок раньше: тогда запасной источник молчит, а в файл ложится null - и высоту взять
|
|
877
|
-
// больше негде, файл уже у владельца. Несколько коротких попыток закрывают этот зазор, а не превращают
|
|
878
|
-
// его в пустое поле; цена - доли секунды в редком случае, когда строки нет вовсе.
|
|
879
|
-
for (let i = 0; i < attempts; i++) {
|
|
880
|
-
try {
|
|
881
|
-
const r = await fetch("/api/swaps", { headers: { accept: "application/json" } });
|
|
882
|
-
if (r.ok) {
|
|
883
|
-
const j = await r.json();
|
|
884
|
-
const rows = Array.isArray(j) ? j : (j.swaps || []);
|
|
885
|
-
const row = rows.find((x) => String((x && x.address) || "") === String(addr));
|
|
886
|
-
const h = Number(row && row.restoreHeight);
|
|
887
|
-
if (Number.isFinite(h) && h > 0) return h;
|
|
888
|
-
}
|
|
889
|
-
} catch {
|
|
890
|
-
/* попробуем ещё раз: сервис мог быть занят первым обращением */
|
|
891
|
-
}
|
|
892
|
-
if (i < attempts - 1) await new Promise((r) => setTimeout(r, pauseMs));
|
|
893
|
-
}
|
|
894
|
-
return null;
|
|
895
|
-
}
|
|
896
|
-
|
|
897
|
-
async function resolveServerSwapId(swap) {
|
|
898
|
-
const addr = dealMoneroAddress(swap);
|
|
899
|
-
if (!addr) return null;
|
|
900
|
-
if (serverIdByAddress.has(addr)) return serverIdByAddress.get(addr);
|
|
901
|
-
try {
|
|
902
|
-
const r = await fetch("/api/swaps", { headers: { accept: "application/json" } });
|
|
903
|
-
if (!r.ok) return null;
|
|
904
|
-
const j = await r.json();
|
|
905
|
-
const rows = Array.isArray(j) ? j : (j.swaps || []);
|
|
906
|
-
const row = rows.find((x) => String((x && x.address) || "") === String(addr));
|
|
907
|
-
if (!row || !row.id) return null;
|
|
908
|
-
serverIdByAddress.set(addr, String(row.id));
|
|
909
|
-
return String(row.id);
|
|
910
|
-
} catch {
|
|
911
|
-
return null; // сервер молчит - остаёмся на прежнем поведении, экран не ждёт сеть
|
|
912
|
-
}
|
|
913
|
-
}
|
|
914
|
-
|
|
915
|
-
function refreshLiveSession(id) {
|
|
916
|
-
if (liveInFlight.has(id)) return;
|
|
917
|
-
// Не чаще одного раза в pollMs на своп, НЕЗАВИСИМО от того, сохранилась ли сессия. Раньше ограничение
|
|
918
|
-
// стояло только в ветке "сессия уже есть": если запрос падал (429 от прокси, 502 от таймаута), сессия
|
|
919
|
-
// не сохранялась, ограничение не применялось - и тик повторял запрос каждые 5 секунд по КАЖДОМУ свопу.
|
|
920
|
-
// При накопившихся восьми свопах это давало поток 429: приложение боролось само с собой.
|
|
921
|
-
const now = Date.now();
|
|
922
|
-
const lastTry = liveLastTry.get(id) || 0;
|
|
923
|
-
if (now - lastTry < pollInterval()) return;
|
|
924
|
-
liveLastTry.set(id, now);
|
|
925
|
-
liveInFlight.add(id);
|
|
926
|
-
(async () => {
|
|
927
|
-
try {
|
|
928
|
-
const fresh = loadState().swaps[id];
|
|
929
|
-
if (!fresh || fresh.settlement) return;
|
|
930
|
-
if (!fresh.xmrSession) {
|
|
931
|
-
// Кошелёк свопа создан в браузере; наружу уходят только адрес и view key (см. chainSource).
|
|
932
|
-
// СУММА ОЖИДАНИЯ ОБЯЗАТЕЛЬНА. Без неё сессию не заводим вовсе: кошелёк тот же, что у экрана
|
|
933
|
-
// подтверждения, поэтому адрес уже под наблюдением. Иначе на тот же адрес появлялась бы вторая
|
|
934
|
-
// правда об одной сделке, а запись без суммы пропускала бы недоплату. Как только сумма появится,
|
|
935
|
-
// следующий тик заведёт сессию сам.
|
|
936
|
-
const facts = dealFactsFor(fresh);
|
|
937
|
-
const amountXmr = facts.amountXmr;
|
|
938
|
-
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) ?? "нет"));
|
|
940
|
-
return;
|
|
941
|
-
}
|
|
942
|
-
if (facts.source !== "своя запись") {
|
|
943
|
-
pushLog(fresh, simElapsed(fresh), "Amount of XMR taken from the deal's other record", facts.source + ", " + String(amountXmr));
|
|
944
|
-
}
|
|
945
|
-
const share = watchOnlyShareFromWallet({ swapId: fresh.id, wallet: fresh.swapWallet });
|
|
946
|
-
const opened = await openSession({
|
|
947
|
-
swapId: fresh.id,
|
|
948
|
-
share,
|
|
949
|
-
expectedAmountXmr: amountXmr,
|
|
950
|
-
chain: fresh.network,
|
|
951
|
-
});
|
|
952
|
-
if (opened.ok) {
|
|
953
|
-
applyLive(id, { xmrSession: opened.session, xmrSessionError: null });
|
|
954
|
-
const s2 = loadState();
|
|
955
|
-
if (s2.swaps[id]) {
|
|
956
|
-
pushLog(s2.swaps[id], simElapsed(s2.swaps[id]), "Backend session opened (watch-only: address + view key)", opened.session.address || "");
|
|
957
|
-
saveState(s2);
|
|
958
|
-
}
|
|
959
|
-
} else {
|
|
960
|
-
applyLive(id, { xmrSessionError: opened.error });
|
|
961
|
-
}
|
|
962
|
-
} else if (Date.now() - (fresh.xmrSession.at || 0) >= pollInterval()) {
|
|
963
|
-
// СПРАШИВАЕМ ПО ID ПРИЛОЖЕНИЯ, а не по своему: см. resolveServerSwapId. Если запись не нашлась, НЕ
|
|
964
|
-
// спрашиваем по своему id - приложение ответит 404, и экран будет молча ждать вечно. Вместо этого
|
|
965
|
-
// заводим наблюдение заново: приложение само связывает его по адресу с уже существующей записью и
|
|
966
|
-
// возвращает её же, а не вторую правду об одной сделке.
|
|
967
|
-
const serverId = await resolveServerSwapId(fresh);
|
|
968
|
-
const polled = await fetchSession(serverId || id);
|
|
969
|
-
if (polled.ok) {
|
|
970
|
-
applyLive(id, { xmrSession: polled.session, xmrSessionError: null });
|
|
971
|
-
} else if (!serverId) {
|
|
972
|
-
const share = watchOnlyShareFromWallet({ swapId: fresh.id, wallet: fresh.swapWallet });
|
|
973
|
-
const again = await openSession({
|
|
974
|
-
swapId: fresh.id, share, expectedAmountXmr: Number(fresh.xmrAmount), chain: fresh.network,
|
|
975
|
-
});
|
|
976
|
-
if (again.ok) {
|
|
977
|
-
applyLive(id, { xmrSession: again.session, xmrSessionError: null });
|
|
978
|
-
const s3 = loadState();
|
|
979
|
-
if (s3.swaps[id]) { pushLog(s3.swaps[id], simElapsed(s3.swaps[id]), "Backend record re-linked by the Monero address", again.session.address || ""); saveState(s3); }
|
|
980
|
-
} else {
|
|
981
|
-
applyLive(id, { xmrSessionError: again.error });
|
|
982
|
-
}
|
|
983
|
-
} else {
|
|
984
|
-
applyLive(id, { xmrSessionError: polled.error });
|
|
985
|
-
}
|
|
986
|
-
}
|
|
987
|
-
} catch (e) {
|
|
988
|
-
applyLive(id, { xmrSessionError: "сессия не открылась: " + e.message });
|
|
989
|
-
} finally {
|
|
990
|
-
liveInFlight.delete(id);
|
|
991
|
-
}
|
|
992
|
-
})();
|
|
993
|
-
}
|
|
994
|
-
|
|
995
|
-
// Сделки, которые сейчас открыты на экране. Ядро опрашивает бэкенд ТОЛЬКО по ним.
|
|
996
|
-
//
|
|
997
|
-
// Зачем: раньше tick опрашивал все незавершённые сделки без разбора. После нескольких нажатий кнопки
|
|
998
|
-
// подписи их накопилось восемь, и приложение само заваливало прокси запросами - приходил 429. При этом
|
|
999
|
-
// на главной странице нечего отслеживать вообще: там ещё нет сделки.
|
|
1000
|
-
//
|
|
1001
|
-
// Пустой набор (по умолчанию) означает "не опрашивать ничего". Экраны, которым слежение нужно, сообщают
|
|
1002
|
-
// свой набор сами: список сделок - все незавершённые, экран сделки - одну.
|
|
1003
|
-
let liveFocusIds = new Set();
|
|
1004
|
-
|
|
1005
|
-
export function setLiveFocus(ids) {
|
|
1006
|
-
liveFocusIds = new Set(Array.isArray(ids) ? ids.map(String) : []);
|
|
1007
|
-
return liveFocusIds.size;
|
|
1008
|
-
}
|
|
1009
|
-
|
|
1010
|
-
export function tick() {
|
|
1011
|
-
const s = loadState();
|
|
1012
|
-
let changed = false;
|
|
1013
|
-
|
|
1014
|
-
for (const swap of Object.values(s.swaps)) {
|
|
1015
|
-
if (swap.settlement) continue;
|
|
1016
|
-
|
|
1017
|
-
// Live-режим: сессия на бэкенде. Заводим при первом тике после создания свопа и дальше
|
|
1018
|
-
// опрашиваем не чаще API.pollMs. Не await: экран не должен ждать сеть.
|
|
1019
|
-
// Опрашиваем только то, что открыто на экране.
|
|
1020
|
-
if (isLiveSource(swap.xmrSource) && liveFocusIds.has(String(swap.id))) refreshLiveSession(swap.id);
|
|
1021
|
-
|
|
1022
|
-
const view = derive(swap);
|
|
1023
|
-
|
|
1024
|
-
// мейкер лочит XMR на совместном одноразовом адресе (кроме сценария maker_no_lock)
|
|
1025
|
-
if (!isLiveSource(swap.xmrSource) && !swap.xmr && view.deadlines.lockAtSim !== null && view.sim >= view.deadlines.lockAtSim) {
|
|
1026
|
-
const lock = chain.lockXmr({ swapId: swap.id, xmrAmount: swap.xmrAmount });
|
|
1027
|
-
swap.xmr = { ...lock, reclaimed: false, sweepTxid: null };
|
|
1028
|
-
|
|
1029
|
-
// В режиме live фиксируем реальную высоту нода на момент лока: по ней считаются
|
|
1030
|
-
// подтверждения (блок Monero ~2 минуты, поэтому сделка идёт по-настоящему ~20 минут).
|
|
1031
|
-
if (swap.chainMode === "live") {
|
|
1032
|
-
const snap = node.snapshot();
|
|
1033
|
-
swap.xmr.lockHeight = snap ? snap.height : null;
|
|
1034
|
-
if (!swap.xmr.lockHeight) {
|
|
1035
|
-
node.getInfo({ force: true }).then((info) => {
|
|
1036
|
-
if (info && !swap.settlement) {
|
|
1037
|
-
const s2 = loadState();
|
|
1038
|
-
if (s2.swaps[swap.id]) {
|
|
1039
|
-
s2.swaps[swap.id].xmr.lockHeight = info.height;
|
|
1040
|
-
saveState(s2);
|
|
1041
|
-
}
|
|
1042
|
-
}
|
|
1043
|
-
});
|
|
1044
|
-
}
|
|
1045
|
-
}
|
|
1046
|
-
|
|
1047
|
-
pushLog(swap, view.sim, "Maker locked XMR on the shared address", lock.txid);
|
|
1048
|
-
const key = swap.id + ":lock";
|
|
1049
|
-
if (!notified.has(key)) { notified.add(key); notify(swap.xmrAmount.toFixed(4) + " XMR locked; view key verified", "ok"); }
|
|
1050
|
-
changed = true;
|
|
1051
|
-
}
|
|
1052
|
-
|
|
1053
|
-
// Live-режим: первый замеченный приход XMR превращаем в запись swap.xmr с НАСТОЯЩИМ txid,
|
|
1054
|
-
// чтобы остальной код (логи, шаги, экраны) работал с ним так же, как с моковым локом.
|
|
1055
|
-
if (isLiveSource(swap.xmrSource) && !swap.xmr && view.xmr && view.xmr.received > 0) {
|
|
1056
|
-
swap.xmr = {
|
|
1057
|
-
mock: false,
|
|
1058
|
-
txid: view.xmr.txids[0] || null,
|
|
1059
|
-
address: view.xmr.address,
|
|
1060
|
-
amount: view.xmr.received,
|
|
1061
|
-
confirmations: view.xmr.confirmations,
|
|
1062
|
-
status: view.xmr.status,
|
|
1063
|
-
explorer: view.xmr.explorer,
|
|
1064
|
-
reclaimed: false,
|
|
1065
|
-
sweepTxid: null,
|
|
1066
|
-
};
|
|
1067
|
-
pushLog(swap, view.sim, "XMR received (backend, " + ((swap.xmrSession && swap.xmrSession.network) || "stagenet") + ")", swap.xmr.txid || "");
|
|
1068
|
-
const key = swap.id + ":xmr-in";
|
|
1069
|
-
if (!notified.has(key)) {
|
|
1070
|
-
notified.add(key);
|
|
1071
|
-
notify(view.xmr.received.toFixed(6) + " XMR received; confirmations " + view.xmr.confirmations + "/" + view.xmr.confTarget, "ok");
|
|
1072
|
-
}
|
|
1073
|
-
changed = true;
|
|
1074
|
-
}
|
|
1075
|
-
|
|
1076
|
-
// Live-режим: факт вывода подтверждает страница sweep (watch-only кошелёк исходящие переводы
|
|
1077
|
-
// видеть не может), поэтому финальное состояние берём из статуса сессии, а хеш - не выдумываем.
|
|
1078
|
-
if (isLiveSource(swap.xmrSource) && swap.xmrSession && swap.xmrSession.status === XMR_STATUS.SWEPT && !swap.settlement) {
|
|
1079
|
-
const atSim = view.sim;
|
|
1080
|
-
swap.settlement = {
|
|
1081
|
-
kind: "success",
|
|
1082
|
-
phase: TERMINAL.success.phase,
|
|
1083
|
-
atSim,
|
|
1084
|
-
txid: swap.xmrSession.sweepTxid || null,
|
|
1085
|
-
by: "your sweep wallet",
|
|
1086
|
-
};
|
|
1087
|
-
pushLog(swap, atSim, "XMR swept by you (backend reported)", swap.settlement.txid || "");
|
|
1088
|
-
const key = swap.id + ":success";
|
|
1089
|
-
if (!notified.has(key)) {
|
|
1090
|
-
notified.add(key);
|
|
1091
|
-
notify("Swap " + swap.id + " complete: XMR is on your address", "ok");
|
|
1092
|
-
}
|
|
1093
|
-
changed = true;
|
|
1094
|
-
continue;
|
|
1095
|
-
}
|
|
1096
|
-
|
|
1097
|
-
// happy path: после claim пользовательский кошелёк сам сметает XMR (litepaper §4.2)
|
|
1098
|
-
if (!isLiveSource(swap.xmrSource) && view.can.sweep && view.phase === "success") {
|
|
1099
|
-
const atSim = view.sim;
|
|
1100
|
-
swap.xmr.sweepTxid = chain.sweepXmr({ swapId: swap.id }).txid;
|
|
1101
|
-
swap.settlement = { kind: "success", phase: TERMINAL.success.phase, atSim, txid: swap.xmr.sweepTxid, by: "your swap wallet" };
|
|
1102
|
-
pushLog(swap, atSim, "XMR swept to your address", swap.xmr.sweepTxid);
|
|
1103
|
-
const key = swap.id + ":success";
|
|
1104
|
-
if (!notified.has(key)) { notified.add(key); notify("Swap " + swap.id + " complete: XMR is on your address", "ok"); }
|
|
1105
|
-
changed = true;
|
|
1106
|
-
continue;
|
|
1107
|
-
}
|
|
1108
|
-
|
|
1109
|
-
// watchtower: если пользователь ушёл, а деньги можно вернуть - возвращает он
|
|
1110
|
-
if (!isLiveSource(swap.xmrSource) && view.can.refund && view.refundLeftSim !== null && view.refundLeftSim <= -TIMING.watchtowerGrace) {
|
|
1111
|
-
const atSim = view.sim;
|
|
1112
|
-
const tx = chain.refundEscrow({ swapId: swap.id, by: "watchtower" });
|
|
1113
|
-
swap.settlement = { kind: "refund_eth", phase: TERMINAL.refunded_eth.phase, atSim, txHash: tx.txHash, by: "watchtower" };
|
|
1114
|
-
pushLog(swap, atSim, "Watchtower refunded your ETH", tx.txHash);
|
|
1115
|
-
const key = swap.id + ":refund";
|
|
1116
|
-
if (!notified.has(key)) { notified.add(key); notify("Watchtower refunded the ETH escrow of swap " + swap.id, "bad"); }
|
|
1117
|
-
changed = true;
|
|
1118
|
-
continue;
|
|
1119
|
-
}
|
|
1120
|
-
|
|
1121
|
-
// сценарий «пользователь не подтвердил»: мейкер забирает XMR
|
|
1122
|
-
if (!isLiveSource(swap.xmrSource) && swap.xmr && view.xmrReclaimed && !swap.xmr.reclaimed) {
|
|
1123
|
-
swap.xmr.reclaimed = true;
|
|
1124
|
-
pushLog(swap, view.sim, "Maker reclaimed the XMR", swap.xmr.txid);
|
|
1125
|
-
const key = swap.id + ":reclaim";
|
|
1126
|
-
if (!notified.has(key)) { notified.add(key); notify("Maker took the XMR back: no ready() call in time", "bad"); }
|
|
1127
|
-
changed = true;
|
|
1128
|
-
}
|
|
1129
|
-
}
|
|
1130
|
-
|
|
1131
|
-
if (changed) {
|
|
1132
|
-
saveState(s);
|
|
1133
|
-
}
|
|
1134
|
-
}
|
|
1135
|
-
|
|
1136
|
-
const notifier = { fn: null };
|
|
1137
|
-
export function onNotify(fn) {
|
|
1138
|
-
notifier.fn = fn;
|
|
1139
|
-
}
|
|
1140
|
-
function notify(msg, kind) {
|
|
1141
|
-
if (notifier.fn) notifier.fn(msg, kind);
|
|
1142
|
-
}
|
|
1143
|
-
|
|
1144
|
-
export function startTicker(intervalMs = 400) {
|
|
1145
|
-
if (timer) clearInterval(timer);
|
|
1146
|
-
timer = setInterval(() => {
|
|
1147
|
-
try { tick(); } catch (e) { console.error(e); }
|
|
1148
|
-
emit(); // экран перерисовывается каждый тик: подтверждения и таймеры идут живьём
|
|
1149
|
-
}, intervalMs);
|
|
1150
|
-
}
|
|
1151
|
-
|
|
1152
|
-
|
|
1153
|
-
|
|
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, then run: node tools/build-sdk-engine.mjs
|
|
3
|
+
// tools/check-sdk-engine.mjs reddens on any drift, so a stale copy cannot ship silently.
|
|
4
|
+
// Everything below this header is identical to the source.
|
|
5
|
+
|
|
6
|
+
// Ядро демки: состояние сделки во времени.
|
|
7
|
+
//
|
|
8
|
+
// Принцип: состояние НЕ хранится как «текущий шаг». Хранятся таймстемпы и события,
|
|
9
|
+
// а фаза выводится функцией derive() из sim-времени. Поэтому демо переживает
|
|
10
|
+
// перезагрузку страницы, а «закрыть вкладку и вернуться» работает по-настоящему.
|
|
11
|
+
//
|
|
12
|
+
// Модель соответствует litepaper §4.2 и §5.3:
|
|
13
|
+
// - T0: дедлайн лока XMR мейкером. Не залочил - пользователь возвращает ETH (permissionless).
|
|
14
|
+
// - ready: пользователь подтверждает, что увидел лок своим view-ключом.
|
|
15
|
+
// - T1: дедлайн claim мейкера. Не сделал claim - пользователь возвращает ETH.
|
|
16
|
+
// - Если пользователь не сделал ready - мейкер забирает XMR назад, пользователь возвращает ETH.
|
|
17
|
+
// - Watchtower может вернуть деньги пользователю без его участия.
|
|
18
|
+
|
|
19
|
+
// DEMO НУЖЕН ЗДЕСЬ, И ЭТО НЕ МЕЛОЧЬ: ниже из него берётся умолчание режима цепи. Имя не было импортировано,
|
|
20
|
+
// поэтому ЛЮБАЯ сделка, заведённая без явного chainMode, падала с "DEMO is not defined" - а страница живой
|
|
21
|
+
// сделки передаёт режим явно, и поломка не была видна до первой проверки, которая заводит сделку без него.
|
|
22
|
+
import { TIMING, MONERO, SCENARIO_LABELS, DEFAULT_CHAIN, API, DEMO, chainById, SIGNING_GUARDS } from "./config.js";
|
|
23
|
+
// ОКНО ПРОДАЖИ XMR (issue #111) - ОДНО правило на ноду и страницу. Здесь оно только читается для отсчёта.
|
|
24
|
+
import { sellWindowSec, SELL_WINDOW } from "./sellWindow.js";
|
|
25
|
+
import { simElapsed, simWall, nowReal, rescaleStart } from "./clock.js";
|
|
26
|
+
import { loadState, saveState } from "./store.js";
|
|
27
|
+
import { shortId, randomHex } from "./format.js";
|
|
28
|
+
import * as chain from "../mock/chain.js";
|
|
29
|
+
import * as node from "../monero/node.js";
|
|
30
|
+
// Источник Monero-ноги: в режиме "app" подтверждения и поступления приходят от бэкенда,
|
|
31
|
+
// в режиме "mock" - от симулятора демки. Что именно уходит наружу (только адрес и view key),
|
|
32
|
+
// проверяется в tools/check-chain-source.mjs.
|
|
33
|
+
import { XMR_STATUS, explorerXmrTx, fetchSession, isLiveSource, openSession, pollInterval, xmrView } from "./chainSource.js";
|
|
34
|
+
import { watchOnlyShareFromWallet } from "../recovery/restore.js";
|
|
35
|
+
|
|
36
|
+
export const STEP_LABELS = [
|
|
37
|
+
["funded", "Funded on-chain"],
|
|
38
|
+
["maker_locking", "Maker locking XMR"],
|
|
39
|
+
["xmr_locked", "XMR locked"],
|
|
40
|
+
["ready", "Verified by you"],
|
|
41
|
+
["claimed", "Claimed by maker"],
|
|
42
|
+
["swept", "Sent to your address"],
|
|
43
|
+
];
|
|
44
|
+
|
|
45
|
+
export const TERMINAL = {
|
|
46
|
+
success: { phase: "success", label: "XMR delivered" },
|
|
47
|
+
refunded_eth: { phase: "refunded_eth", label: "ETH refunded" },
|
|
48
|
+
xmr_returned: { phase: "xmr_returned", label: "Swap cancelled, funds returned" },
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
export function createSwap(input) {
|
|
52
|
+
const s = loadState();
|
|
53
|
+
const now = Date.now();
|
|
54
|
+
const swap = {
|
|
55
|
+
id: shortId(),
|
|
56
|
+
side: input.side || "buy",
|
|
57
|
+
scenario: input.scenario || "happy",
|
|
58
|
+
speed: input.speed || 60,
|
|
59
|
+
// ВИДА КОТИРОВКИ В ЗАПИСИ НЕТ. Прежнее поле mode ("floating"/"fixed") было выбором вида цены, а в
|
|
60
|
+
// версии 4 второго вида не существует (док 41, §7): твёрдая котировка ровно одна - ордерная.
|
|
61
|
+
network: input.network || DEFAULT_CHAIN,
|
|
62
|
+
moneroNetwork: MONERO.networkType,
|
|
63
|
+
realStart: now,
|
|
64
|
+
simStartWall: now,
|
|
65
|
+
createdAt: new Date(now).toISOString(),
|
|
66
|
+
payToken: input.payToken,
|
|
67
|
+
payAmount: Number(input.payAmount),
|
|
68
|
+
xmrAmount: Number(input.xmrAmount),
|
|
69
|
+
rate: Number(input.rate),
|
|
70
|
+
fee: Number(input.fee),
|
|
71
|
+
maker: { id: input.makerId, name: input.makerName, spread: input.makerSpread },
|
|
72
|
+
dexSpread: input.dexSpread,
|
|
73
|
+
gasUsd: input.gasUsd,
|
|
74
|
+
receiveAddress: input.receiveAddress,
|
|
75
|
+
quoteSignature: input.quoteSignature,
|
|
76
|
+
swapWallet: input.swapWallet || null, // ключи одноразового Monero-кошелька
|
|
77
|
+
timeline: {
|
|
78
|
+
fundAtSim: 0,
|
|
79
|
+
lockAtSim: input.scenario === "maker_no_lock" ? null : TIMING.makerLock,
|
|
80
|
+
blockSim: MONERO.blockTimeSim,
|
|
81
|
+
confTarget: MONERO.confirmTarget,
|
|
82
|
+
t0Sim: TIMING.t0Refund,
|
|
83
|
+
},
|
|
84
|
+
// ТО ЖЕ УМОЛЧАНИЕ, ЧТО И В НАСТРОЙКАХ: "live" - настоящая высота цепи, "mock" - таймер песочницы.
|
|
85
|
+
// Второй умолчал здесь значил бы, что сделка без явного режима считается по таймеру.
|
|
86
|
+
chainMode: input.chainMode || DEMO.defaultChainMode, // mock | live (реальная высота Monero mainnet)
|
|
87
|
+
// Источник Monero-ноги: "app" - настоящий бэкенд (см. core/chainSource.js), "mock" - симулятор.
|
|
88
|
+
xmrSource: input.xmrSource || API.xmrSource,
|
|
89
|
+
xmrSession: null, // последний опрос бэкенда: статус, сумма, подтверждения, txid
|
|
90
|
+
xmrSessionError: null, // причина отказа бэкенда: показываем как состояние, а не как падение
|
|
91
|
+
events: [],
|
|
92
|
+
escrow: null,
|
|
93
|
+
xmr: null,
|
|
94
|
+
settlement: null,
|
|
95
|
+
log: [],
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
// ДЕМОНСТРАЦИОННОЕ ФОНДИРОВАНИЕ ТОЛЬКО В ДЕМОНСТРАЦИИ. Здесь стоял безусловный вызов моковой ветки
|
|
99
|
+
// (chain.fundEscrow), и он выполнялся В ТОМ ЧИСЛЕ на живом пути: запись получала ВЫДУМАННЫЙ адрес эскроу
|
|
100
|
+
// и ВЫДУМАННЫЙ хеш транзакции, а живой поток потом с ними боролся. Снаружи это выглядело так: настоящий
|
|
101
|
+
// ордер создан и оплачен, а экран прогресса, ссылка на обозреватель, файл восстановления и плательщик
|
|
102
|
+
// смотрят на адрес, которого на цепи нет. На живом пути адрес и хеш обязан дать ТОЛЬКО живой поток.
|
|
103
|
+
if (swap.chainMode !== "live") {
|
|
104
|
+
const fund = chain.fundEscrow({ swapId: swap.id, ethAmountUsd: swap.payAmount, ethUsd: 3212.4 });
|
|
105
|
+
swap.escrow = {
|
|
106
|
+
address: fund.escrow,
|
|
107
|
+
fundTx: fund.txHash,
|
|
108
|
+
method: fund.method,
|
|
109
|
+
fundedAtSim: 0,
|
|
110
|
+
gasUsd: fund.gasUsd,
|
|
111
|
+
};
|
|
112
|
+
pushLog(swap, 0, "Escrow funded", `eth escrow ${fund.escrow}`);
|
|
113
|
+
} else {
|
|
114
|
+
// ЗАГОТОВКА ПОД ЖИВОЙ ОРДЕР - И БЕЗ ЕДИНОГО ВЫДУМАННОГО ЗНАЧЕНИЯ. Адрес и хеш появятся из живого
|
|
115
|
+
// потока фондирования (confirm.js записывает их из ответа потока). Пустые поля здесь нужны не для
|
|
116
|
+
// красоты: без объекта запись оставалась без места, куда живому пути писать, и адрес терялся молча.
|
|
117
|
+
swap.escrow = { address: null, fundTx: null, method: null, fundedAtSim: 0, gasUsd: input.gasUsd ?? null };
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
s.swaps[swap.id] = swap;
|
|
121
|
+
s.activeSwapId = swap.id;
|
|
122
|
+
saveState(s);
|
|
123
|
+
return swap;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// REVERSE-FLOW DEAL RECORD (issue #118, second half). The sell screen used to stop at the request, and there was
|
|
127
|
+
// nowhere to show a refund: a reverse deal had no record. It is created here - from exactly what the screen
|
|
128
|
+
// already has (the node ticket plus the computed halves) - and lands in the same store as the buy flow, so the
|
|
129
|
+
// deal card, the recovery file and the XMR sweep all work off it.
|
|
130
|
+
//
|
|
131
|
+
// WHAT THE REVERSE DEAL DOES DIFFERENTLY. On the buy flow the PAGE funds the escrow, and the counterparty's half
|
|
132
|
+
// is only learned after settlement. On the sell flow the PROVIDER funds the escrow, the page merely requests the
|
|
133
|
+
// order, and it computes ITS OWN halves (atomic/order-worker.js, newMakerMaterial). So fields are named by side:
|
|
134
|
+
// our half is `half`, and the DEPOSITOR's (provider's) point and view half are counterSpendPoint/counterView*.
|
|
135
|
+
// The page does not know the depositor's SPEND half and must not: the contract publishes it on settlement, and
|
|
136
|
+
// only then does the sweep of our own XMR open up (see refundHalfRevealed).
|
|
137
|
+
export function createReverseSwap(input = {}) {
|
|
138
|
+
const s = loadState();
|
|
139
|
+
const now = Date.now();
|
|
140
|
+
const xmr = Number(input.xmrAmount);
|
|
141
|
+
const swap = {
|
|
142
|
+
id: shortId(),
|
|
143
|
+
side: "reverse",
|
|
144
|
+
scenario: "happy",
|
|
145
|
+
speed: Number(input.speed) || 60,
|
|
146
|
+
network: input.network || DEFAULT_CHAIN,
|
|
147
|
+
moneroNetwork: MONERO.networkType,
|
|
148
|
+
realStart: now,
|
|
149
|
+
simStartWall: now,
|
|
150
|
+
createdAt: new Date(now).toISOString(),
|
|
151
|
+
// THE USER GIVES XMR AND RECEIVES THE NATIVE COIN: payToken is XMR, receiveAddress is their payout address
|
|
152
|
+
// on the settlement chain.
|
|
153
|
+
payToken: "XMR",
|
|
154
|
+
payAmount: xmr,
|
|
155
|
+
xmrAmount: xmr,
|
|
156
|
+
rate: Number(input.rate) || 0,
|
|
157
|
+
fee: Number(input.fee) || 0,
|
|
158
|
+
maker: { id: input.providerId || null, name: input.providerName || null, spread: null },
|
|
159
|
+
receiveAddress: input.claimer || null,
|
|
160
|
+
quoteSignature: null,
|
|
161
|
+
ticket: input.ticketId || null,
|
|
162
|
+
timeline: { fundAtSim: 0, lockAtSim: 0, blockSim: MONERO.blockTimeSim, confTarget: MONERO.confirmTarget, t0Sim: TIMING.t0Refund },
|
|
163
|
+
chainMode: input.chainMode || DEMO.defaultChainMode,
|
|
164
|
+
xmrSource: input.xmrSource || API.xmrSource,
|
|
165
|
+
xmrSession: null,
|
|
166
|
+
xmrSessionError: null,
|
|
167
|
+
events: [],
|
|
168
|
+
// HALVES WALLET OF THE REVERSE ORDER: our own spend and view half plus the depositor's PUBLIC halves. The
|
|
169
|
+
// recovery file builds the wallet from here (recovery/recoveryFile.js, buildPayload v3), and after the half
|
|
170
|
+
// is revealed, so does the spend key. The counterparty's SPEND half is not here: the chain provides it
|
|
171
|
+
// (refundHalfRevealed).
|
|
172
|
+
escrow: {
|
|
173
|
+
address: input.escrowAddress || null,
|
|
174
|
+
moneroAddress: input.moneroAddress || null,
|
|
175
|
+
half: input.ownSpendHalf || null,
|
|
176
|
+
viewHalf: input.ownViewHalf || null,
|
|
177
|
+
counterViewHalf: input.counterViewHalf || null,
|
|
178
|
+
counterViewPoint: input.counterViewPoint || null,
|
|
179
|
+
counterSpendPoint: input.counterSpendPoint || null,
|
|
180
|
+
moneroNetwork: MONERO.networkType,
|
|
181
|
+
birthHeight: input.escrowBirthHeight || null,
|
|
182
|
+
readyBy: input.readyBy || null,
|
|
183
|
+
t1: input.t1 || null,
|
|
184
|
+
// The DEPOSITOR's REVEALED HALF appears when the chain publishes it on settlement. Until then there is
|
|
185
|
+
// nothing to sweep with.
|
|
186
|
+
counterHalf: null,
|
|
187
|
+
},
|
|
188
|
+
xmr: null,
|
|
189
|
+
settlement: null,
|
|
190
|
+
log: [],
|
|
191
|
+
};
|
|
192
|
+
s.swaps[swap.id] = swap;
|
|
193
|
+
s.activeSwapId = swap.id;
|
|
194
|
+
saveState(s);
|
|
195
|
+
return swap;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
function pushLog(swap, atSim, title, detail) {
|
|
199
|
+
swap.log.push({ atSim, wall: simWall(swap, atSim), title, detail: detail || "" });
|
|
200
|
+
// Дублируем в КОНСОЛЬ и это основной канал для разбора. Раньше журнал показывался только на странице,
|
|
201
|
+
// где жил в состоянии экрана и исчезал при перерисовке - то есть ровно тогда, когда он нужнее всего.
|
|
202
|
+
// В консоли история сохраняется, и её можно скопировать целиком.
|
|
203
|
+
console.log(`[swap ${swap.id}] ${title}${detail ? " - " + detail : ""}`);
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// Убрать сделку из состояния браузера. Только локально: на сервере запись остаётся, и файлы кошелька,
|
|
207
|
+
// которые им управляет бэкенд, тоже. Массово удалять их по маске нельзя - там кошельки настоящих свопов,
|
|
208
|
+
// поэтому серверная уборка делается отдельным скриптом осознанно (tools/cleanup-swaps.mjs).
|
|
209
|
+
// КОШЕЛОК ИЗ ПОЛОВИН ЗАПИСИ - ДЛЯ ФАЙЛА ВОССТАНОВЛЕНИЯ. Половины сохраняются в записи сделки в момент
|
|
210
|
+
// фондирования (escrow.half и соседние поля), и это единственный их дом: полного ключа траты не существует
|
|
211
|
+
// ни у одной стороны, а своя половина случайна - мнемоникой её не восстановить. Раньше выгрузка брала
|
|
212
|
+
// swapWallet (кошелёк прежней схемы), и файл получался версией 2 - по такому файлу свои XMR не забрать.
|
|
213
|
+
// Собираем ровно те поля, которых ждёт recoveryFile.js версии 3, и в одном месте, чтобы выгрузка и
|
|
214
|
+
// проверки не расходились.
|
|
215
|
+
export function halvesWalletOf(swap) {
|
|
216
|
+
const e = (swap && swap.escrow) || {};
|
|
217
|
+
if (!e.half || !e.viewHalf || !e.counterViewHalf || !e.moneroAddress) return null;
|
|
218
|
+
return {
|
|
219
|
+
source: "halves",
|
|
220
|
+
library: "monero-js",
|
|
221
|
+
network: e.moneroNetwork || MONERO.networkType,
|
|
222
|
+
address: e.moneroAddress,
|
|
223
|
+
subaddress: null,
|
|
224
|
+
spendHalf: e.half,
|
|
225
|
+
viewHalf: e.viewHalf,
|
|
226
|
+
// THE COUNTERPARTY'S SPEND POINT CARRIES DIFFERENT NAMES PER FLOW: on the direct swap it is the claimer's
|
|
227
|
+
// point (edPointClaimer); on the reverse swap it is the DEPOSITOR's point, which the contract publishes on
|
|
228
|
+
// settlement (counterSpendPoint). One consumer (the recovery file and the sweep) must read both, or the
|
|
229
|
+
// reverse order would end up without its own spend key.
|
|
230
|
+
otherSpendPoint: e.counterSpendPoint || e.edPointClaimer || null,
|
|
231
|
+
otherViewPoint: e.counterViewPoint || null,
|
|
232
|
+
otherViewHalf: e.counterViewHalf,
|
|
233
|
+
};
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
export function forgetSwap(id) {
|
|
237
|
+
const s = loadState();
|
|
238
|
+
if (!s.swaps[id]) return false;
|
|
239
|
+
delete s.swaps[id];
|
|
240
|
+
saveState(s);
|
|
241
|
+
return true;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
export function getSwap(id) {
|
|
245
|
+
return loadState().swaps[id] || null;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
export function allSwaps() {
|
|
249
|
+
const s = loadState();
|
|
250
|
+
return Object.values(s.swaps).sort((a, b) => (a.realStart < b.realStart ? 1 : -1));
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
// ---- СЛИЯНИЕ С СПИСКОМ СЕРВЕРА --------------------------------------------------------------------
|
|
254
|
+
//
|
|
255
|
+
// Зачем. Пока записи живут только в localStorage, список привязан к браузеру: очистили данные сайта или
|
|
256
|
+
// открыли с другого устройства - сделок не видно, хотя деньги в эскроу на месте. Серверный список это
|
|
257
|
+
// снимает.
|
|
258
|
+
//
|
|
259
|
+
// ПРАВИЛА СЛИЯНИЯ, и они намеренно осторожные:
|
|
260
|
+
// 1) локальная запись ВСЕГДА главнее: она знает больше (секрет, кошелёк, стадии). Серверная строка только
|
|
261
|
+
// ДОБАВЛЯЕТ то, чего в этом браузере нет, и никогда не перезаписывает найденное;
|
|
262
|
+
// 2) запись, пришедшая с сервера, помечается source: "server" - чтобы человек и код видели, откуда она,
|
|
263
|
+
// и чтобы на экране можно было честно сказать об этом;
|
|
264
|
+
// 3) из серверной строки берём только публичные поля. Секрета там нет и быть не может - значит, такая
|
|
265
|
+
// сделка будет видна, но подписать забор средств из неё нельзя: секрет лежит в файле восстановления.
|
|
266
|
+
// Это не недоделка, а следствие того, что секрет на сервер не уходит.
|
|
267
|
+
export function serverRowToSwap(row) {
|
|
268
|
+
const escrow = String((row && row.escrow) || "").toLowerCase();
|
|
269
|
+
return {
|
|
270
|
+
id: String(row.id),
|
|
271
|
+
network: row.network || null,
|
|
272
|
+
chain: row.network || null,
|
|
273
|
+
source: "server",
|
|
274
|
+
status: "in_progress",
|
|
275
|
+
escrow: {
|
|
276
|
+
address: escrow,
|
|
277
|
+
t0: row.t0 != null ? Number(row.t0) : null,
|
|
278
|
+
t1: row.t1 != null ? Number(row.t1) : null,
|
|
279
|
+
hashlock: row.hashlock || null,
|
|
280
|
+
amountWei: row.amount != null ? String(row.amount) : null,
|
|
281
|
+
fromServer: true,
|
|
282
|
+
},
|
|
283
|
+
log: [],
|
|
284
|
+
events: [],
|
|
285
|
+
};
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
// Добавить записи с сервера, которых нет локально. Возвращает число добавленных: вызывающий по нему решает,
|
|
289
|
+
// перерисовывать ли список, а не догадывается.
|
|
290
|
+
export function addServerSwaps(rows) {
|
|
291
|
+
if (!Array.isArray(rows) || rows.length === 0) return 0;
|
|
292
|
+
const s = loadState();
|
|
293
|
+
let added = 0;
|
|
294
|
+
for (const row of rows) {
|
|
295
|
+
if (!row || !row.id || !row.escrow) continue;
|
|
296
|
+
if (s.swaps[row.id]) continue; // локальная запись главнее - не трогаем
|
|
297
|
+
s.swaps[row.id] = serverRowToSwap(row);
|
|
298
|
+
added += 1;
|
|
299
|
+
}
|
|
300
|
+
if (added) saveState(s);
|
|
301
|
+
return added;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
// Найти сделку по адресу эскроу: нужно, чтобы связать запись из списка сервера с настоящим ордером.
|
|
305
|
+
|
|
306
|
+
export function activeSwaps() {
|
|
307
|
+
return allSwaps().filter((sw) => !sw.settlement);
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
// СОХРАНИТЬ СДЕЛКУ ПОСЛЕ ПРАВОК ИЗВНЕ. persist() был внутренним, и вызывающие модули (например
|
|
311
|
+
// confirm.js) правили поля записи - адрес эскроу, соль, сроки, секрет - и НЕ сохраняли её: правки жили в
|
|
312
|
+
// памяти и исчезали вместе с вкладкой, а экран прогресса читал из хранилища СТАРУЮ запись. Именно
|
|
313
|
+
// поэтому интерфейс показывал адрес, которого на цепи нет, даже когда фондирование прошло успешно.
|
|
314
|
+
export function saveSwap(swap) {
|
|
315
|
+
return persist(swap);
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
function persist(swap) {
|
|
319
|
+
const s = loadState();
|
|
320
|
+
s.swaps[swap.id] = swap;
|
|
321
|
+
saveState(s);
|
|
322
|
+
return swap;
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
function event(swap, type, atSim, extra = {}) {
|
|
326
|
+
swap.events.push({ type, atSim, ...extra });
|
|
327
|
+
return swap;
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
function findEvent(swap, type) {
|
|
331
|
+
return swap.events.find((e) => e.type === type) || null;
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
// ---------------------------------------------------------------------------
|
|
335
|
+
// Действия пользователя
|
|
336
|
+
// ---------------------------------------------------------------------------
|
|
337
|
+
|
|
338
|
+
export function confirmReady(id) {
|
|
339
|
+
const swap = getSwap(id);
|
|
340
|
+
if (!swap || swap.settlement) return swap;
|
|
341
|
+
const view = derive(swap);
|
|
342
|
+
if (!view.can.ready) throw new Error("ready() недоступен в этой фазе");
|
|
343
|
+
const atSim = simElapsed(swap);
|
|
344
|
+
event(swap, "ready", atSim);
|
|
345
|
+
pushLog(swap, atSim, "ready() called by you", "maker may now claim the ETH escrow");
|
|
346
|
+
if (!findEvent(swap, "funded_note")) event(swap, "funded_note", 0);
|
|
347
|
+
return persist(swap);
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
// THE COUNTERPARTY'S SPEND HALF - THE ONE PLACE THAT SHOWS WHETHER THERE IS ANYTHING TO ASSEMBLE. The Monero
|
|
351
|
+
// spend key is the sum of two halves, and the user holds their own; sweeping XMR back after a refund is possible
|
|
352
|
+
// 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, docs/36).
|
|
354
|
+
export function refundHalfRevealed(swap) {
|
|
355
|
+
const e = (swap && swap.escrow) || {};
|
|
356
|
+
return e.revealedHalf || e.counterHalf || null;
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
// XMR SWEEP. In the ordinary phase this happens after the other side reveals its half through settlement. A
|
|
360
|
+
// REVERSE-FLOW REFUND (issue #118, second half) is the SECOND case: the order was refunded, the user's coins
|
|
361
|
+
// stayed on the swap's one-time address, and the contract revealed the DEPOSITOR's half on refund. Then the same
|
|
362
|
+
// sweep takes our coins back, and the settlement is marked recovered: the money returned to the user rather
|
|
363
|
+
// than leaving through the swap.
|
|
364
|
+
export function sweepNow(id) {
|
|
365
|
+
const swap = getSwap(id);
|
|
366
|
+
if (!swap) return swap;
|
|
367
|
+
// The deal already settled: no second sweep - EXCEPT a reverse-flow refund, which is how our own XMR are taken
|
|
368
|
+
// back.
|
|
369
|
+
if (swap.settlement && !(swap.settlement.kind === "refund_eth" && swap.side === "reverse")) return swap;
|
|
370
|
+
const view = derive(swap);
|
|
371
|
+
if (!view.can.sweep) throw new Error("sweep недоступен в этой фазе");
|
|
372
|
+
const atSim = simElapsed(swap);
|
|
373
|
+
const tx = chain.sweepXmr({ swapId: swap.id });
|
|
374
|
+
swap.xmr = swap.xmr || {};
|
|
375
|
+
swap.xmr.sweepTxid = tx.txid;
|
|
376
|
+
const fromRefund = Boolean(swap.settlement && swap.settlement.kind === "refund_eth");
|
|
377
|
+
swap.settlement = { kind: "success", phase: TERMINAL.success.phase, atSim, txid: tx.txid, by: "you",
|
|
378
|
+
...(fromRefund ? { recovered: true } : {}) };
|
|
379
|
+
pushLog(swap, atSim, "XMR swept to your address", tx.txid);
|
|
380
|
+
return persist(swap);
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
export function refundEth(id, by = "you") {
|
|
384
|
+
const swap = getSwap(id);
|
|
385
|
+
if (!swap || swap.settlement) return swap;
|
|
386
|
+
const view = derive(swap);
|
|
387
|
+
if (!view.can.refund) throw new Error("refund недоступен в этой фазе");
|
|
388
|
+
const atSim = simElapsed(swap);
|
|
389
|
+
const tx = chain.refundEscrow({ swapId: swap.id, by });
|
|
390
|
+
swap.settlement = { kind: "refund_eth", phase: TERMINAL.refunded_eth.phase, atSim, txHash: tx.txHash, by };
|
|
391
|
+
pushLog(swap, atSim, `ETH refunded to your wallet (${by})`, tx.txHash);
|
|
392
|
+
return persist(swap);
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
// ---------------------------------------------------------------------------
|
|
396
|
+
// Вывод состояния
|
|
397
|
+
// ---------------------------------------------------------------------------
|
|
398
|
+
|
|
399
|
+
// Живая ли сеть сделки: escrow.mode = live в конфиге сети. Настройка сети - факт, одинаковый для старых и
|
|
400
|
+
// новых сделок, в отличие от поля chainMode внутри записи.
|
|
401
|
+
function isLiveNetwork(swap) {
|
|
402
|
+
const conf = (chainById((swap && swap.network) || DEFAULT_CHAIN) || {}).escrow || {};
|
|
403
|
+
return conf.mode === "live";
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
// СУММА НА АДРЕСЕ ПРОТИВ ОБЕЩАННОЙ КОТИРОВКОЙ. Отметка ready разрешает забирающему расчёт по своей половине -
|
|
407
|
+
// то есть отдаёт ему ETH - поэтому она делается не на "что-то пришло", а на ДОСТАТОЧНУЮ сумму. Допуск 1e-6 XMR
|
|
408
|
+
// покрывает округление показа, а не скидку. Неизвестная сумма - тоже отказ: незнание не разрешение.
|
|
409
|
+
const XMR_AMOUNT_EPS = 1e-6;
|
|
410
|
+
export function xmrAmountCheck(swap, xmr) {
|
|
411
|
+
const expected = Number(swap && swap.xmrAmount);
|
|
412
|
+
const hasExpected = Number.isFinite(expected) && expected > 0;
|
|
413
|
+
// «УВИДЕННАЯ СУММА» - ЭТО ВСЁ, ЧТО ЛЕЖИТ НА АДРЕСЕ: и разблокированное, и запертое unlock_time. Иначе у
|
|
414
|
+
// запертого поступления сумма показывалась бы «не увидена», хотя деньги на адресе есть. Что из неё можно
|
|
415
|
+
// потратить - отдельный вопрос, и его решает статус (ready не включается, пока деньги заперты).
|
|
416
|
+
const seenSource = xmr && xmr.arrived !== null && xmr.arrived !== undefined ? xmr.arrived : (xmr ? xmr.received : null);
|
|
417
|
+
const seen = seenSource !== null && seenSource !== undefined && Number.isFinite(Number(seenSource))
|
|
418
|
+
? Number(seenSource) : null;
|
|
419
|
+
if (!hasExpected) {
|
|
420
|
+
return { ok: true, checked: false, expected: null, seen,
|
|
421
|
+
why: "the record has no quoted XMR amount, so the arrived sum cannot be compared - this is named, not assumed" };
|
|
422
|
+
}
|
|
423
|
+
if (seen === null) {
|
|
424
|
+
return { ok: false, checked: true, expected, seen: null,
|
|
425
|
+
why: "the amount of XMR on the address is not known (" + expected + " XMR was quoted): ready stays closed until the sum is seen" };
|
|
426
|
+
}
|
|
427
|
+
// СВЕРКА ДВУСТОРОННЯЯ, И ЭТО ИСПРАВЛЕНИЕ issue #116. Прежде проверка была односторонней («пришло не
|
|
428
|
+
// меньше») и оттого СЛЕПОЙ к переплате ноды: нода считала долг по сырому эталону без спреда и отдавала
|
|
429
|
+
// 5,000 XMR там, где обещала 4,950 - приход был БОЛЬШЕ обещанного, и проверка молчала. Число, с которым
|
|
430
|
+
// сверяем, - то же, что нода кладёт долгом в журнал (amount_xmr): оно идёт от той же ленты цен, что и
|
|
431
|
+
// показанное человеку. Расхождение в ЛЮБУЮ сторону значит, что записанное и показанное - разные числа.
|
|
432
|
+
if (seen + XMR_AMOUNT_EPS < expected) {
|
|
433
|
+
return { ok: false, checked: true, expected, seen,
|
|
434
|
+
why: "only " + seen + " XMR arrived, the quote promised " + expected + " XMR: ready is blocked - the escrow would pay out for less than agreed" };
|
|
435
|
+
}
|
|
436
|
+
if (seen > expected + XMR_AMOUNT_EPS) {
|
|
437
|
+
return { ok: false, checked: true, expected, seen,
|
|
438
|
+
why: seen + " XMR arrived, but the record promises " + expected + " XMR: the sum on the address does not match the quoted trade - ready is blocked until they agree (a node paying more than it quoted loses its spread)" };
|
|
439
|
+
}
|
|
440
|
+
return { ok: true, checked: true, expected, seen, why: null };
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
export function derive(swap, now = nowReal()) {
|
|
444
|
+
const sim = simElapsed(swap, now);
|
|
445
|
+
const t = swap.timeline;
|
|
446
|
+
const ev = (type) => findEvent(swap, type);
|
|
447
|
+
const readyEv = ev("ready");
|
|
448
|
+
|
|
449
|
+
// Слепок Monero-ноги: в мок-режиме считается по sim-времени, в live - читается из последнего
|
|
450
|
+
// опроса бэкенда. Дальше движок работает одинаково и не знает, откуда данные.
|
|
451
|
+
const live = isLiveSource(swap.xmrSource);
|
|
452
|
+
const xmr = xmrView(swap, sim);
|
|
453
|
+
// ПРИХОД - ЭТО ВСЁ, ЧТО ВИДНО НА АДРЕСЕ (arrived), а не только разблокированное: запертое unlock_time
|
|
454
|
+
// пришло, но потратить его нельзя - это отдельное состояние, которое называется ниже и не пускает «готово».
|
|
455
|
+
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 => мейкер не лочит (сценарий maker_no_lock)
|
|
457
|
+
const conf = xmr.confirmations;
|
|
458
|
+
const lockDone = live ? arrived > 0 : lockAt !== null && sim >= lockAt;
|
|
459
|
+
|
|
460
|
+
const readyAt = readyEv ? readyEv.atSim : null;
|
|
461
|
+
// СРОКИ ИЗ ЦЕПИ - В НАСТОЯЩЕМ ВРЕМЕНИ: readyBy и t1 проставляет контракт, и они не имеют отношения ни к
|
|
462
|
+
// ускоренному времени демонстрации, ни к симулятору.
|
|
463
|
+
const chainReadyByMs = swap.escrow && swap.escrow.readyBy ? Number(swap.escrow.readyBy) * 1000 : null;
|
|
464
|
+
const chainT1Ms = swap.escrow && swap.escrow.t1 ? Number(swap.escrow.t1) * 1000 : null;
|
|
465
|
+
// ОКНО ПРОДАЖИ XMR (issue #111). Отсчёт идёт ОТ КОНТРАКТНОГО readyBy (это проставляют не мы и не симулятор),
|
|
466
|
+
// а не от TIMING.readyWindow: тот живёт в ускоренном времени демонстрации и на живой сделке показал бы
|
|
467
|
+
// демо-время. Мягкое обещание (600 с) продлевается до контрактного (1200 с), пока в пуле нет ПОЛНОЙ суммы, -
|
|
468
|
+
// тем же правилом, что у ноды (sellWindowSec). Право на действие это НЕ меняет: отметку контракт принимает
|
|
469
|
+
// до readyBy, а окно здесь - обещание на экране.
|
|
470
|
+
const sellSeen = Number(xmr.arrived !== undefined && xmr.arrived !== null ? xmr.arrived : (xmr.received || 0)) > 0;
|
|
471
|
+
const sellWindow = (() => {
|
|
472
|
+
if (swap.side !== "reverse" || chainReadyByMs === null) return null;
|
|
473
|
+
const startMs = chainReadyByMs - SELL_WINDOW.hardSec * 1000; // начало окна = readyBy минус контрактный максимум
|
|
474
|
+
const softAtMs = startMs + SELL_WINDOW.softSec * 1000;
|
|
475
|
+
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; // до продления счёт идёт до мягкого обещания
|
|
478
|
+
return { startMs, atMs, leftMs: atMs - nowReal(), seen: sellSeen, extended,
|
|
479
|
+
softSec: SELL_WINDOW.softSec, hardSec: SELL_WINDOW.hardSec, windowSec };
|
|
480
|
+
})();
|
|
481
|
+
const claimAt = readyAt !== null && swap.scenario !== "maker_stall" ? readyAt + TIMING.makerClaim : null;
|
|
482
|
+
const sweptAt = claimAt !== null ? claimAt + TIMING.sweep : null;
|
|
483
|
+
|
|
484
|
+
const readyDeadline = lockAt !== null ? lockAt + TIMING.readyWindow : null;
|
|
485
|
+
const makerReclaimAt = readyDeadline !== null ? readyDeadline + TIMING.makerReclaim : null;
|
|
486
|
+
const t1At = readyAt !== null ? readyAt + TIMING.t1Refund : null;
|
|
487
|
+
|
|
488
|
+
// Сценарий «пользователь не подтвердил»: мейкер забирает XMR назад.
|
|
489
|
+
const xmrReclaimed = readyAt === null && makerReclaimAt !== null && sim >= makerReclaimAt;
|
|
490
|
+
|
|
491
|
+
// Доступные действия/дедлайны
|
|
492
|
+
const can = { ready: false, sweep: false, refund: false };
|
|
493
|
+
let phase = "xmr_locked";
|
|
494
|
+
let phaseLabel = "Waiting";
|
|
495
|
+
let refundAfterSim = null;
|
|
496
|
+
let note = null;
|
|
497
|
+
|
|
498
|
+
if (swap.settlement) {
|
|
499
|
+
phase = swap.settlement.phase;
|
|
500
|
+
phaseLabel = Object.values(TERMINAL).find((x) => x.phase === swap.settlement.phase)?.label || "Settled";
|
|
501
|
+
// SWEEPING OUR OWN XMR AFTER A REFUND (issue #118, second half). On the reverse flow a refund leaves the
|
|
502
|
+
// user's coins on the swap's one-time address: the claimer's ETH came back, and nobody moved the XMR. They can
|
|
503
|
+
// only be taken by assembling the spend key from OUR half and the OTHER half that the chain revealed on
|
|
504
|
+
// settlement. While the revealed half is missing we do NOT grant the right - can.sweep stays false precisely
|
|
505
|
+
// so that the button cannot appear without a working action. The "arrived / expected" numbers are already
|
|
506
|
+
// handed to the screen below (xmrAmount): only the right to sweep is decided here.
|
|
507
|
+
if (swap.settlement.kind === "refund_eth" && swap.side === "reverse") {
|
|
508
|
+
const revealed = refundHalfRevealed(swap);
|
|
509
|
+
const stillOnAddress = arrived > 0;
|
|
510
|
+
can.sweep = Boolean(revealed && stillOnAddress && halvesWalletOf(swap));
|
|
511
|
+
// THE REASON IS NAMED, NOT HIDDEN: the screen must say what is missing, not merely omit the button.
|
|
512
|
+
note = can.sweep
|
|
513
|
+
? "The refund published the provider's half, so your XMR can be swept back from the swap's one-time address to your own wallet."
|
|
514
|
+
: (!stillOnAddress
|
|
515
|
+
? "The refund is done, but no XMR is on the swap's one-time address: there is nothing of yours to sweep back."
|
|
516
|
+
: "The refund is done and your XMR is on the swap's one-time address, but the provider's half of the spend key is not published yet: sweeping needs both halves, so the button waits for the chain.");
|
|
517
|
+
}
|
|
518
|
+
} else if (live && readyAt === null && chainReadyByMs !== null && nowReal() >= chainReadyByMs
|
|
519
|
+
&& (chainT1Ms === null || nowReal() < chainT1Ms)) {
|
|
520
|
+
// БЫЛА МЁРТВАЯ ЗОНА - СТАЛА ОТКРЫТАЯ ДВЕРЬ (решение 30.09.2026). Отметку внёсший уже не поставит, забор
|
|
521
|
+
// без неё контракт запрещает, значит окно забора НЕ ОТКРЫЛОСЬ ВООБЩЕ - и возврат разрешён сразу после
|
|
522
|
+
// readyBy (условие контракта: `block.timestamp < readyBy || (block.timestamp < t1 && ready)`). Он же
|
|
523
|
+
// публикует половину внёсшего, поэтому мейкер забирает свои XMR, не дожидаясь t1. Это и есть ответ на
|
|
524
|
+
// мёртвую зону: раньше деньги и половина ждали t1, а Monero-нога была недоступна обеим сторонам.
|
|
525
|
+
phase = "dead_zone";
|
|
526
|
+
phaseLabel = "Ready deadline passed - the refund is open";
|
|
527
|
+
can.ready = false;
|
|
528
|
+
can.refund = true;
|
|
529
|
+
can.refundByAnyone = true;
|
|
530
|
+
note = "The ready() deadline has passed without your confirmation, so the maker cannot claim - and the " +
|
|
531
|
+
"refund is open NOW, not at T1. It returns your ETH and publishes your half, so the maker can reclaim " +
|
|
532
|
+
"the XMR from Monero right away. " +
|
|
533
|
+
(chainT1Ms !== null ? "T1 closes the order for good: " + new Date(chainT1Ms).toISOString() + "." : "");
|
|
534
|
+
} else if (live && readyAt === null) {
|
|
535
|
+
// Live-режим: состояния Monero-ноги приходят от бэкенда. Симулированные таймауты EVM-ноги
|
|
536
|
+
// (T0/T1, watchtower) здесь намеренно НЕ показываем: контракта ещё нет, и кнопка «вернуть ETH»
|
|
537
|
+
// была бы обманом. Пометка «EVM-нога - симуляция» висит в UI отдельно (derive().simulated).
|
|
538
|
+
if (xmr.status === XMR_STATUS.LOCKED) {
|
|
539
|
+
// ДЕНЬГИ НА АДРЕСЕ ЕСТЬ, НО ЗАПЕРТЫ unlock_time. Это НАЗВАННОЕ состояние, а не «ждём приход»: иначе
|
|
540
|
+
// экран молчал бы, а сумма выглядела бы нулевой. Отметка готовности здесь невозможна - забор ETH под
|
|
541
|
+
// запертые XMR отдал бы мейкеру ETH за деньги, которыми нельзя заплатить (issue #84).
|
|
542
|
+
phase = "xmr_unlock_pending";
|
|
543
|
+
phaseLabel = "XMR is on your address but locked on-chain";
|
|
544
|
+
can.ready = false;
|
|
545
|
+
const u = xmr.lockedUntil || {};
|
|
546
|
+
const untilText = u.untilHeight !== null && u.untilHeight !== undefined
|
|
547
|
+
? "block " + u.untilHeight
|
|
548
|
+
: (u.untilTime !== null && u.untilTime !== undefined
|
|
549
|
+
? new Date(Number(u.untilTime) * 1000).toISOString() + " UTC"
|
|
550
|
+
: "a time the sender set");
|
|
551
|
+
note = "The sender set the transaction's unlock_time: " + (xmr.locked || 0) +
|
|
552
|
+
" XMR is on your address but cannot be spent until " + untilText +
|
|
553
|
+
". Ready stays closed until then - confirming now would let the maker take the ETH behind locked XMR.";
|
|
554
|
+
} else if (!xmr.received) {
|
|
555
|
+
phase = "maker_locking";
|
|
556
|
+
phaseLabel = "Waiting for XMR to arrive";
|
|
557
|
+
note = xmr.error
|
|
558
|
+
? "Backend unavailable: " + xmr.error
|
|
559
|
+
: "The backend watches your swap address with the view key only - it cannot spend your XMR.";
|
|
560
|
+
} else if (xmr.status === XMR_STATUS.READY) {
|
|
561
|
+
phase = "xmr_locked";
|
|
562
|
+
phaseLabel = "XMR received and confirmed (" + conf + "/" + t.confTarget + ")";
|
|
563
|
+
// СУММА ПРОВЕРЯЕТСЯ ВМЕСТЕ С ГОТОВНОСТЬЮ. Бэкенд видит XMR по ключу просмотра и называет сумму, а
|
|
564
|
+
// котировка обещала своё число: расхождение означает, что отметка отдала бы ETH дешевле договора.
|
|
565
|
+
const paid = xmrAmountCheck(swap, xmr);
|
|
566
|
+
can.ready = paid.ok;
|
|
567
|
+
can.readyAmount = paid;
|
|
568
|
+
note = paid.ok
|
|
569
|
+
? "Confirm that you see the XMR on your address (ready)."
|
|
570
|
+
: paid.why;
|
|
571
|
+
} else if (xmr.status === XMR_STATUS.SWEPT) {
|
|
572
|
+
phase = "success";
|
|
573
|
+
phaseLabel = "XMR delivered";
|
|
574
|
+
} else {
|
|
575
|
+
phase = "xmr_locked";
|
|
576
|
+
phaseLabel = "XMR received, waiting for confirmations (" + conf + "/" + t.confTarget + ")";
|
|
577
|
+
note = "About " + Math.max(1, Math.round(((t.confTarget - conf) * t.blockSim) / 60000)) + " min left of confirmations.";
|
|
578
|
+
}
|
|
579
|
+
} else if (lockAt === null) {
|
|
580
|
+
// Мейкер не залочил XMR. После T0 пользователь может вернуть ETH сам.
|
|
581
|
+
if (sim >= t.t0Sim) {
|
|
582
|
+
phase = "refund_available";
|
|
583
|
+
phaseLabel = "Maker did not lock XMR";
|
|
584
|
+
refundAfterSim = t.t0Sim;
|
|
585
|
+
can.refund = true;
|
|
586
|
+
note = "The maker never locked XMR. You can take your ETH back from the escrow - no permission needed.";
|
|
587
|
+
} else {
|
|
588
|
+
phase = "maker_locking";
|
|
589
|
+
phaseLabel = "Waiting for the maker to lock XMR";
|
|
590
|
+
note = "If the maker does not lock XMR by T0, you get a refund button.";
|
|
591
|
+
}
|
|
592
|
+
} else if (!lockDone) {
|
|
593
|
+
phase = "maker_locking";
|
|
594
|
+
phaseLabel = "Maker is locking XMR";
|
|
595
|
+
} else if (xmrReclaimed) {
|
|
596
|
+
phase = "refund_available";
|
|
597
|
+
phaseLabel = "XMR returned to the maker";
|
|
598
|
+
refundAfterSim = makerReclaimAt;
|
|
599
|
+
can.refund = true;
|
|
600
|
+
note = "You did not confirm (ready) in time, so the maker took the XMR back. Your ETH is still in escrow - refund it.";
|
|
601
|
+
} else if (readyAt === null) {
|
|
602
|
+
phase = "xmr_locked";
|
|
603
|
+
phaseLabel = conf < t.confTarget ? `Waiting for Monero confirmations (${conf}/${t.confTarget})` : "XMR locked and verified - waiting for you";
|
|
604
|
+
can.ready = live ? xmr.status === XMR_STATUS.READY : conf >= t.confTarget;
|
|
605
|
+
refundAfterSim = readyDeadline;
|
|
606
|
+
note = conf < t.confTarget
|
|
607
|
+
? `About ${Math.max(1, Math.round(((t.confTarget - conf) * t.blockSim) / 60000))} min left of confirmations. You can close this tab.`
|
|
608
|
+
: "Confirm that you see the lock with your view key (ready). Until you do, the maker keeps the XMR.";
|
|
609
|
+
} else if (claimAt !== null && sim < claimAt) {
|
|
610
|
+
phase = "ready_wait_claim";
|
|
611
|
+
phaseLabel = "Waiting for the maker to claim ETH";
|
|
612
|
+
note = "The maker claims the escrow and reveals the secret; your swap wallet uses it to sweep the XMR.";
|
|
613
|
+
} else if (claimAt !== null && sim >= claimAt && sweptAt !== null && sim >= sweptAt) {
|
|
614
|
+
phase = "success";
|
|
615
|
+
phaseLabel = "XMR delivered";
|
|
616
|
+
can.sweep = true;
|
|
617
|
+
} else if (claimAt !== null) {
|
|
618
|
+
phase = "claimed";
|
|
619
|
+
phaseLabel = "Secret revealed - sweeping XMR";
|
|
620
|
+
can.sweep = true;
|
|
621
|
+
} else {
|
|
622
|
+
// maker_stall: ready сделано, но claim не происходит
|
|
623
|
+
phase = "refund_available";
|
|
624
|
+
phaseLabel = "Maker did not claim in time";
|
|
625
|
+
refundAfterSim = t1At;
|
|
626
|
+
// ПРАВО НА ВОЗВРАТ ОПРЕДЕЛЯЕТ КОНТРАКТ, И ПРАВИЛО ЗАВИСИТ ОТ ВОЗРАСТА ОРДЕРА - так и надо, это не
|
|
627
|
+
// ветвление ради ветвления. Новые ордера создаются с ОДНОЙ границей t1: до неё возврата нет ни у кого,
|
|
628
|
+
// с неё - у кого угодно (решение 2026-09-16, док 26). У ордеров, созданных раньше, в записи лежит t0,
|
|
629
|
+
// и у них правило прежнее: с t0 вернуть вправе только locker, с t1 - кто угодно. Роли и сроки в старых
|
|
630
|
+
// ордерах неизменяемы, поэтому никакой миграции нет - они доживают свой срок по прежним правилам.
|
|
631
|
+
// Сравнивать надо с настоящим временем и по срокам из записи, а не по времени симулятора (оно ускорено).
|
|
632
|
+
const escrowT0 = swap.escrow && swap.escrow.t0 ? swap.escrow.t0 * 1000 : null; // есть только у старых
|
|
633
|
+
const chainT1 = swap.escrow && swap.escrow.t1 ? swap.escrow.t1 * 1000 : null;
|
|
634
|
+
const realNow = nowReal();
|
|
635
|
+
if (chainT1 !== null) {
|
|
636
|
+
if (escrowT0 !== null) {
|
|
637
|
+
can.refund = realNow >= escrowT0; // старый ордер: с t0, и только сам locker
|
|
638
|
+
can.refundByAnyone = realNow >= chainT1; // с t1 - кто угодно
|
|
639
|
+
} else {
|
|
640
|
+
// НОВЫЙ ОРДЕР: ГРАНИЦ ДВЕ, И ЭТО РЕШЕНИЕ 30.09.2026. С t1 - как было, возврат открыт всем. Но если
|
|
641
|
+
// отметки `ready` нет, окно забора не открылось вовсе, и возврат открыт сразу после readyBy: иначе
|
|
642
|
+
// мёртвая зона держала бы деньги и половину до t1. Отметку берём из ЗАПИСИ (`readyAt`) - её ставит сам
|
|
643
|
+
// пользователь, и в этой ветке она пуста. Если запись потеряна, а на цепи отметка есть, контракт
|
|
644
|
+
// откажет: честный отказ вместо обещания, которого мы не исполнили бы.
|
|
645
|
+
const chainReadyBy = swap.escrow && swap.escrow.readyBy ? Number(swap.escrow.readyBy) * 1000 : null;
|
|
646
|
+
const earlyRefund = chainReadyBy !== null && realNow >= chainReadyBy && readyAt === null;
|
|
647
|
+
can.refund = realNow >= chainT1 || earlyRefund;
|
|
648
|
+
can.refundByAnyone = can.refund; // и с t1 возврат открыт всем одновременно
|
|
649
|
+
if (earlyRefund && realNow < chainT1) refundAfterSim = null; // возврат уже открыт: отсчёта нет
|
|
650
|
+
}
|
|
651
|
+
} else if (!isLiveNetwork(swap)) {
|
|
652
|
+
can.refund = sim >= t1At; // демо-сделка: как раньше, по симулятору
|
|
653
|
+
} else {
|
|
654
|
+
can.refund = false; // живая сделка без прочитанных сроков: не обещаем
|
|
655
|
+
}
|
|
656
|
+
// ЧТО МОЖНО СДЕЛАТЬ СЕЙЧАС - БЕЗ ОБЕЩАНИЙ, КОТОРЫХ МЫ НЕ ИСПОЛНИМ. Сторож сейчас ручной вызов, а не
|
|
657
|
+
// служба: он НЕ вернёт средства сам по себе. Поэтому говорим не "will be refunded automatically", а что
|
|
658
|
+
// право появилось у кого угодно, включая сторожа, и кнопка по-прежнему работает.
|
|
659
|
+
note = can.refund
|
|
660
|
+
? (can.refundByAnyone
|
|
661
|
+
? "The claim deadline passed: the refund is open to anyone who can present the half of the depositor - " +
|
|
662
|
+
"that half is YOURS (it is in your recovery file), and the watchtower does not have it. " +
|
|
663
|
+
"Press Refund my ETH, or restore the file if you have lost this tab."
|
|
664
|
+
: "The maker missed the claim deadline. You can take your ETH back from the escrow yourself.")
|
|
665
|
+
: (escrowT0 !== null
|
|
666
|
+
? "This order predates the one-deadline rule: you could refund from T0, and from T1 anyone can."
|
|
667
|
+
: "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
|
+
}
|
|
669
|
+
|
|
670
|
+
// Шаги для степпера
|
|
671
|
+
const confDone = conf >= t.confTarget;
|
|
672
|
+
const claimed = claimAt !== null && sim >= claimAt;
|
|
673
|
+
const steps = [
|
|
674
|
+
{ key: "funded", done: true, atSim: 0 },
|
|
675
|
+
{ key: "maker_locking", done: lockDone, active: !lockDone && !swap.settlement && lockAt !== null, failed: lockAt === null && sim >= t.t0Sim, atSim: lockAt },
|
|
676
|
+
{ key: "xmr_locked", done: confDone && !xmrReclaimed, active: lockDone && !confDone, atSim: lockAt },
|
|
677
|
+
{ key: "ready", done: readyAt !== null, active: confDone && readyAt === null && !xmrReclaimed, atSim: readyAt },
|
|
678
|
+
{ key: "claimed", done: claimed, active: readyAt !== null && !claimed, atSim: claimAt },
|
|
679
|
+
{ key: "swept", done: !!swap.settlement && swap.settlement.kind === "success", active: claimed && !swap.settlement, atSim: swappedAtOrNull(swap) },
|
|
680
|
+
];
|
|
681
|
+
|
|
682
|
+
return {
|
|
683
|
+
swap,
|
|
684
|
+
sim,
|
|
685
|
+
simWallTime: simWall(swap, sim),
|
|
686
|
+
phase,
|
|
687
|
+
phaseLabel,
|
|
688
|
+
note,
|
|
689
|
+
conf,
|
|
690
|
+
confTarget: t.confTarget,
|
|
691
|
+
confProgress: t.confTarget ? conf / t.confTarget : 0,
|
|
692
|
+
// СРОКИ ДЛЯ ПОКАЗА БЕРУТСЯ ИЗ ТОГО ЖЕ ИСТОЧНИКА, ЧТО И ПРАВО НА ВОЗВРАТ - ИЗ ЗАПИСИ О СДЕЛКЕ.
|
|
693
|
+
// Здесь стояло simWall(swap, t.t0Sim): это время ДЕМО-СИМУЛЯТОРА (ускоренного в 60 раз), к живой сделке
|
|
694
|
+
// отношения не имеющее. Из-за этого получалось противоречие на одном экране: can.refund считался по
|
|
695
|
+
// НАСТОЯЩЕМУ сроку из escrow (и говорил "ещё нельзя"), а таймер кнопки и строка "T0 (refund if no XMR
|
|
696
|
+
// lock)" показывали симулированный (и говорили "available now" и "in 00:00").
|
|
697
|
+
// Два показателя одного и того же события обязаны считаться из одной величины: иначе экран противоречит
|
|
698
|
+
// сам себе, и человек не знает, какому сроку верить.
|
|
699
|
+
// Для ЖИВОЙ сделки сроки контрактные (те же, что у can.refund выше), для демонстрационной - из симулятора.
|
|
700
|
+
deadlines: {
|
|
701
|
+
// t0 ЕСТЬ ТОЛЬКО У СТАРЫХ ОРДЕРОВ. У новых граница одна, и для живой сделки без t0 честнее показать
|
|
702
|
+
// ничего (null), чем выдуманное число: строка T0 на экране по этому признаку и не рисуется.
|
|
703
|
+
// В демонстрационном режиме срок берётся из симулятора - там своё время, и оно осознанно ускорено.
|
|
704
|
+
t0: swap.escrow && swap.escrow.t0 ? swap.escrow.t0 * 1000 : (swap.escrow ? null : simWall(swap, t.t0Sim)),
|
|
705
|
+
t0Sim: t.t0Sim,
|
|
706
|
+
lockAtSim: lockAt,
|
|
707
|
+
readyBy: readyDeadline !== null ? simWall(swap, readyDeadline) : null,
|
|
708
|
+
readyBySim: readyDeadline,
|
|
709
|
+
t1: swap.escrow && swap.escrow.t1 ? swap.escrow.t1 * 1000 : (t1At !== null ? simWall(swap, t1At) : null),
|
|
710
|
+
t1Sim: t1At,
|
|
711
|
+
refundAfterSim,
|
|
712
|
+
refundAfterWall: refundAfterSim !== null ? simWall(swap, refundAfterSim) : null,
|
|
713
|
+
},
|
|
714
|
+
refundLeftSim: refundAfterSim !== null ? refundAfterSim - sim : null,
|
|
715
|
+
// Данные Monero-ноги для экрана: статус, адрес, txid и ссылка в обозревателе.
|
|
716
|
+
xmr: {
|
|
717
|
+
live: xmr.live,
|
|
718
|
+
source: swap.xmrSource,
|
|
719
|
+
status: xmr.status,
|
|
720
|
+
received: xmr.received,
|
|
721
|
+
// Запертое unlock_time и всё, что видно на адресе: экран показывает причину и границу.
|
|
722
|
+
locked: xmr.locked || 0,
|
|
723
|
+
lockedUntil: xmr.lockedUntil || null,
|
|
724
|
+
arrived: Number(xmr.arrived !== undefined && xmr.arrived !== null ? xmr.arrived : (xmr.received || 0)),
|
|
725
|
+
confirmations: xmr.confirmations,
|
|
726
|
+
confTarget: xmr.confTarget,
|
|
727
|
+
txids: xmr.txids,
|
|
728
|
+
address: xmr.address,
|
|
729
|
+
explorer: explorerXmrTx(xmr.txids[0], (swap.xmrSession && swap.xmrSession.network) || "stagenet"),
|
|
730
|
+
error: xmr.error,
|
|
731
|
+
sweepTxid: xmr.sweepTxid,
|
|
732
|
+
},
|
|
733
|
+
// Что сейчас настоящее, а что симуляция. UI обязан показывать это, а не угадывать.
|
|
734
|
+
// EVM-нога симулирована тогда и только тогда, когда сеть сделки не переведена в live. Раньше здесь стояло
|
|
735
|
+
// жёсткое true, и на ЖИВОЙ сделке с настоящим эскроу экран писал "EVM leg ... is still simulated" - то есть
|
|
736
|
+
// врал, хотя ордер существовал в контракте. Признак берём из настроек сети, а не из поля в сделке: поле
|
|
737
|
+
// проставляется при создании, и у сделок, заведённых раньше, там осталось "mock".
|
|
738
|
+
simulated: { evm: !isLiveNetwork(swap), xmr: !xmr.live },
|
|
739
|
+
can,
|
|
740
|
+
// СКОЛЬКО ЖДАТЬ ДО ГРАНИЦЫ ОТМЕТКИ и не подошла ли она: граница есть, отметки нет. Предупреждение за
|
|
741
|
+
// SIGNING_GUARDS.minReadyLeadSec секунд до неё - потому что после границы не работает НИ ОДНА кнопка.
|
|
742
|
+
readyDeadline: {
|
|
743
|
+
atMs: chainReadyByMs,
|
|
744
|
+
soonMs: chainReadyByMs !== null && readyAt === null ? chainReadyByMs - nowReal() : null,
|
|
745
|
+
soon: chainReadyByMs !== null && readyAt === null
|
|
746
|
+
&& chainReadyByMs - nowReal() > 0
|
|
747
|
+
&& chainReadyByMs - nowReal() <= Number(SIGNING_GUARDS.readyLeadWarnSec || 0) * 1000,
|
|
748
|
+
},
|
|
749
|
+
// ОБЕ СУММЫ НАРУЖУ: обещанная котировкой и увиденная на адресе. Экран обязан показать обе, а не одну.
|
|
750
|
+
xmrAmount: { expected: (() => { const e = Number(swap.xmrAmount); return Number.isFinite(e) && e > 0 ? e : null; })(),
|
|
751
|
+
seen: xmr.received !== null && xmr.received !== undefined ? Number(xmr.received) : null },
|
|
752
|
+
steps,
|
|
753
|
+
// ОКНО ПРОДАЖИ: адрес уже в xmr.address, здесь - отсчёт и его границы (issue #111). null у прямого потока.
|
|
754
|
+
sellWindow,
|
|
755
|
+
terminal: swap.settlement,
|
|
756
|
+
xmrReclaimed,
|
|
757
|
+
};
|
|
758
|
+
}
|
|
759
|
+
|
|
760
|
+
function swappedAtOrNull(swap) {
|
|
761
|
+
return swap.settlement && swap.settlement.kind === "success" ? swap.settlement.atSim : null;
|
|
762
|
+
}
|
|
763
|
+
|
|
764
|
+
// ---------------------------------------------------------------------------
|
|
765
|
+
// Тикер: завершает сделки, которые должны завершиться без пользователя
|
|
766
|
+
// (авто-sweep и watchtower), и рассылает подписчикам изменения.
|
|
767
|
+
// ---------------------------------------------------------------------------
|
|
768
|
+
|
|
769
|
+
let timer = null;
|
|
770
|
+
const listeners = new Set();
|
|
771
|
+
const notified = new Set();
|
|
772
|
+
|
|
773
|
+
export function subscribe(fn) {
|
|
774
|
+
listeners.add(fn);
|
|
775
|
+
return () => listeners.delete(fn);
|
|
776
|
+
}
|
|
777
|
+
|
|
778
|
+
function emit() {
|
|
779
|
+
for (const fn of listeners) {
|
|
780
|
+
try { fn(); } catch (e) { console.error(e); }
|
|
781
|
+
}
|
|
782
|
+
}
|
|
783
|
+
|
|
784
|
+
// --- live-режим: сессия свопа на бэкенде -----------------------------------------------
|
|
785
|
+
//
|
|
786
|
+
// Опрос идёт без ожидания: тикер экрана не должен ждать сеть, иначе при недоступном бэкенде демка
|
|
787
|
+
// начнёт тормозить. Результат кладём в сам своп (xmrSession / xmrSessionError), а derive() читает
|
|
788
|
+
// его как обычное состояние - она остаётся чистой функцией.
|
|
789
|
+
const liveInFlight = new Set();
|
|
790
|
+
|
|
791
|
+
function applyLive(id, patch) {
|
|
792
|
+
const s = loadState();
|
|
793
|
+
if (!s.swaps[id]) return;
|
|
794
|
+
Object.assign(s.swaps[id], patch);
|
|
795
|
+
saveState(s);
|
|
796
|
+
}
|
|
797
|
+
|
|
798
|
+
// Время последней ПОПЫТКИ обращения к бэкенду по каждому свопу. Именно попытки, а не успеха: ограничение
|
|
799
|
+
// частоты должно работать и когда запрос падает.
|
|
800
|
+
const liveLastTry = new Map();
|
|
801
|
+
|
|
802
|
+
|
|
803
|
+
// КЛЮЧ СДЕЛКИ: СТРАНИЦА И ПРИЛОЖЕНИЕ НАЗЫВАЮТ ЕЁ ПО-РАЗНОМУ. У страницы свой id (живёт в браузере), у
|
|
804
|
+
// приложения - свой (localtest-...): id задаёт приложение, когда заводит запись наблюдения. Состояние XMR
|
|
805
|
+
// страница спрашивала по СВОЕМУ id, приложение такого не знает и отвечает отказом. Снаружи это выглядит
|
|
806
|
+
// так: XMR пришли, подтверждений десяток, а экран сделки показывает прежнее состояние, кнопка отметки
|
|
807
|
+
// готовности не открывается - и окно сделки закрывается впустую (проверено живым прогоном).
|
|
808
|
+
// СВЯЗЫВАЕМ ПО АДРЕСУ MONERO: страница и так сообщает его приложению при заведении наблюдения, значит по
|
|
809
|
+
// нему и находит свою запись, а дальше спрашивает состояние по её id. Правок в приложении не требуется.
|
|
810
|
+
// ВСЕ ЗАПИСИ ОДНОЙ СДЕЛКИ В БРАУЗЕРЕ. Их две (обмен и эскроу), и следить нужно за сделкой, а не за той записью,
|
|
811
|
+
// на которую случайно попал экран: у записи эскроу нет суммы ожидания XMR, поэтому её сессия не заводится, и
|
|
812
|
+
// состояние до экрана не доходит. Связь - общий адрес Monero, он один на сделку.
|
|
813
|
+
export function dealSwapIds(swap) {
|
|
814
|
+
const ids = new Set([String((swap && swap.id) || "")].filter(Boolean));
|
|
815
|
+
const w0 = (() => { try { return halvesWalletOf(swap) || {}; } catch { return {}; } })();
|
|
816
|
+
const address = w0.address || (swap && swap.escrow && swap.escrow.moneroAddress) || null;
|
|
817
|
+
if (!address) return [...ids];
|
|
818
|
+
for (const other of Object.values((loadState() || {}).swaps || {})) {
|
|
819
|
+
if (!other || !other.id) continue;
|
|
820
|
+
const w = (() => { try { return halvesWalletOf(other) || {}; } catch { return {}; } })();
|
|
821
|
+
if (w.address === address) ids.add(String(other.id));
|
|
822
|
+
}
|
|
823
|
+
return [...ids];
|
|
824
|
+
}
|
|
825
|
+
|
|
826
|
+
const serverIdByAddress = new Map();
|
|
827
|
+
|
|
828
|
+
// ФАКТЫ СДЕЛКИ ИЩЕМ ПО ВСЕМ ЕЁ ЗАПИСЯМ В БРАУЗЕРЕ. Об одной сделке страница держит ДВЕ записи: одну завёл
|
|
829
|
+
// обмен (адрес Monero, сумма ожидания XMR), другую - эскроу (половины, адрес эскроу). Экран, открытый по
|
|
830
|
+
// записи эскроу, не знал суммы ожидания, сессия не заводилась вовсе, и кнопка готовности не включалась -
|
|
831
|
+
// при том что приложение готовность уже отдавало. Записи связаны ОБЩИМ адресом Monero: он один на сделку.
|
|
832
|
+
function dealFactsFor(swap) {
|
|
833
|
+
const w0 = (() => { try { return halvesWalletOf(swap) || {}; } catch { return {}; } })();
|
|
834
|
+
let address = w0.address || (swap && swap.escrow && swap.escrow.moneroAddress) || (swap && swap.xmrSession && swap.xmrSession.address) || null;
|
|
835
|
+
let amountXmr = Number(swap && swap.xmrAmount) > 0 ? Number(swap.xmrAmount) : null;
|
|
836
|
+
let source = "своя запись";
|
|
837
|
+
if (address && amountXmr === null) {
|
|
838
|
+
for (const other of Object.values((loadState() || {}).swaps || {})) {
|
|
839
|
+
if (!other || other === swap) continue;
|
|
840
|
+
const w = (() => { try { return halvesWalletOf(other) || {}; } catch { return {}; } })();
|
|
841
|
+
if (!w.address || w.address !== address) continue;
|
|
842
|
+
if (Number(other.xmrAmount) > 0) { amountXmr = Number(other.xmrAmount); source = "другая запись той же сделки (" + String(other.id || "?") + ")"; break; }
|
|
843
|
+
}
|
|
844
|
+
}
|
|
845
|
+
if (!address) {
|
|
846
|
+
for (const other of Object.values((loadState() || {}).swaps || {})) {
|
|
847
|
+
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 = "другая запись той же сделки"; break; }
|
|
849
|
+
}
|
|
850
|
+
}
|
|
851
|
+
return { address, amountXmr, source };
|
|
852
|
+
}
|
|
853
|
+
|
|
854
|
+
// АДРЕС СДЕЛКИ БЕРЁМ ОТОВСЮДУ, ГДЕ ОН МОЖЕТ ЛЕЖАТЬ. Сделка в браузере и запись наблюдения в приложении - это
|
|
855
|
+
// РАЗНЫЕ записи об одной сделке (страница заводит свою на эскроу, приложение - на наблюдение за адресом),
|
|
856
|
+
// поэтому у записи страницы адрес есть не всегда: у записи эскроу он лежит в половинах, а не в поле
|
|
857
|
+
// escrow.moneroAddress. Живой прогон из-за этого опрашивал мёртвый id, получал 404 и оставлял кнопку
|
|
858
|
+
// готовности выключенной. Вынесено отдельно, чтобы этим же адресом пользовался поиск высоты у сервера.
|
|
859
|
+
function dealMoneroAddress(swap) {
|
|
860
|
+
const fromHalves = (() => { try { return (halvesWalletOf(swap) || {}).address || null; } catch { return null; } })();
|
|
861
|
+
return (swap && swap.escrow && swap.escrow.moneroAddress)
|
|
862
|
+
|| (swap && swap.xmrSession && swap.xmrSession.address)
|
|
863
|
+
|| (swap && swap.swapWallet && swap.swapWallet.address)
|
|
864
|
+
|| fromHalves
|
|
865
|
+
|| null;
|
|
866
|
+
}
|
|
867
|
+
|
|
868
|
+
// ВЫСОТА СКАНА, КОТОРУЮ ЗНАЕТ ПРИЛОЖЕНИЕ. Нужна файлам СТАРЫХ сделок: в их записи нет birthHeight (поле
|
|
869
|
+
// появилось позже), а высоту при регистрации наблюдения приложение разрешило само и хранит. Источник наш,
|
|
870
|
+
// поэтому от чужой ноды Monero не зависит. Сервер молчит - возвращаем null, и вызывающий остаётся на
|
|
871
|
+
// прежнем поведении: высота берётся из других источников или файл честно предупреждает о её отсутствии.
|
|
872
|
+
export async function serverRestoreHeightFor(swap, { attempts = 3, pauseMs = 1200 } = {}) {
|
|
873
|
+
const addr = dealMoneroAddress(swap);
|
|
874
|
+
if (!addr) return null;
|
|
875
|
+
// НЕ ОДНА ПОПЫТКА. Строка наблюдения появляется у приложения в тот же миг, что и эскроу, и файл может
|
|
876
|
+
// быть выпущен на волосок раньше: тогда запасной источник молчит, а в файл ложится null - и высоту взять
|
|
877
|
+
// больше негде, файл уже у владельца. Несколько коротких попыток закрывают этот зазор, а не превращают
|
|
878
|
+
// его в пустое поле; цена - доли секунды в редком случае, когда строки нет вовсе.
|
|
879
|
+
for (let i = 0; i < attempts; i++) {
|
|
880
|
+
try {
|
|
881
|
+
const r = await fetch("/api/swaps", { headers: { accept: "application/json" } });
|
|
882
|
+
if (r.ok) {
|
|
883
|
+
const j = await r.json();
|
|
884
|
+
const rows = Array.isArray(j) ? j : (j.swaps || []);
|
|
885
|
+
const row = rows.find((x) => String((x && x.address) || "") === String(addr));
|
|
886
|
+
const h = Number(row && row.restoreHeight);
|
|
887
|
+
if (Number.isFinite(h) && h > 0) return h;
|
|
888
|
+
}
|
|
889
|
+
} catch {
|
|
890
|
+
/* попробуем ещё раз: сервис мог быть занят первым обращением */
|
|
891
|
+
}
|
|
892
|
+
if (i < attempts - 1) await new Promise((r) => setTimeout(r, pauseMs));
|
|
893
|
+
}
|
|
894
|
+
return null;
|
|
895
|
+
}
|
|
896
|
+
|
|
897
|
+
async function resolveServerSwapId(swap) {
|
|
898
|
+
const addr = dealMoneroAddress(swap);
|
|
899
|
+
if (!addr) return null;
|
|
900
|
+
if (serverIdByAddress.has(addr)) return serverIdByAddress.get(addr);
|
|
901
|
+
try {
|
|
902
|
+
const r = await fetch("/api/swaps", { headers: { accept: "application/json" } });
|
|
903
|
+
if (!r.ok) return null;
|
|
904
|
+
const j = await r.json();
|
|
905
|
+
const rows = Array.isArray(j) ? j : (j.swaps || []);
|
|
906
|
+
const row = rows.find((x) => String((x && x.address) || "") === String(addr));
|
|
907
|
+
if (!row || !row.id) return null;
|
|
908
|
+
serverIdByAddress.set(addr, String(row.id));
|
|
909
|
+
return String(row.id);
|
|
910
|
+
} catch {
|
|
911
|
+
return null; // сервер молчит - остаёмся на прежнем поведении, экран не ждёт сеть
|
|
912
|
+
}
|
|
913
|
+
}
|
|
914
|
+
|
|
915
|
+
function refreshLiveSession(id) {
|
|
916
|
+
if (liveInFlight.has(id)) return;
|
|
917
|
+
// Не чаще одного раза в pollMs на своп, НЕЗАВИСИМО от того, сохранилась ли сессия. Раньше ограничение
|
|
918
|
+
// стояло только в ветке "сессия уже есть": если запрос падал (429 от прокси, 502 от таймаута), сессия
|
|
919
|
+
// не сохранялась, ограничение не применялось - и тик повторял запрос каждые 5 секунд по КАЖДОМУ свопу.
|
|
920
|
+
// При накопившихся восьми свопах это давало поток 429: приложение боролось само с собой.
|
|
921
|
+
const now = Date.now();
|
|
922
|
+
const lastTry = liveLastTry.get(id) || 0;
|
|
923
|
+
if (now - lastTry < pollInterval()) return;
|
|
924
|
+
liveLastTry.set(id, now);
|
|
925
|
+
liveInFlight.add(id);
|
|
926
|
+
(async () => {
|
|
927
|
+
try {
|
|
928
|
+
const fresh = loadState().swaps[id];
|
|
929
|
+
if (!fresh || fresh.settlement) return;
|
|
930
|
+
if (!fresh.xmrSession) {
|
|
931
|
+
// Кошелёк свопа создан в браузере; наружу уходят только адрес и view key (см. chainSource).
|
|
932
|
+
// СУММА ОЖИДАНИЯ ОБЯЗАТЕЛЬНА. Без неё сессию не заводим вовсе: кошелёк тот же, что у экрана
|
|
933
|
+
// подтверждения, поэтому адрес уже под наблюдением. Иначе на тот же адрес появлялась бы вторая
|
|
934
|
+
// правда об одной сделке, а запись без суммы пропускала бы недоплату. Как только сумма появится,
|
|
935
|
+
// следующий тик заведёт сессию сам.
|
|
936
|
+
const facts = dealFactsFor(fresh);
|
|
937
|
+
const amountXmr = facts.amountXmr;
|
|
938
|
+
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) ?? "нет"));
|
|
940
|
+
return;
|
|
941
|
+
}
|
|
942
|
+
if (facts.source !== "своя запись") {
|
|
943
|
+
pushLog(fresh, simElapsed(fresh), "Amount of XMR taken from the deal's other record", facts.source + ", " + String(amountXmr));
|
|
944
|
+
}
|
|
945
|
+
const share = watchOnlyShareFromWallet({ swapId: fresh.id, wallet: fresh.swapWallet });
|
|
946
|
+
const opened = await openSession({
|
|
947
|
+
swapId: fresh.id,
|
|
948
|
+
share,
|
|
949
|
+
expectedAmountXmr: amountXmr,
|
|
950
|
+
chain: fresh.network,
|
|
951
|
+
});
|
|
952
|
+
if (opened.ok) {
|
|
953
|
+
applyLive(id, { xmrSession: opened.session, xmrSessionError: null });
|
|
954
|
+
const s2 = loadState();
|
|
955
|
+
if (s2.swaps[id]) {
|
|
956
|
+
pushLog(s2.swaps[id], simElapsed(s2.swaps[id]), "Backend session opened (watch-only: address + view key)", opened.session.address || "");
|
|
957
|
+
saveState(s2);
|
|
958
|
+
}
|
|
959
|
+
} else {
|
|
960
|
+
applyLive(id, { xmrSessionError: opened.error });
|
|
961
|
+
}
|
|
962
|
+
} else if (Date.now() - (fresh.xmrSession.at || 0) >= pollInterval()) {
|
|
963
|
+
// СПРАШИВАЕМ ПО ID ПРИЛОЖЕНИЯ, а не по своему: см. resolveServerSwapId. Если запись не нашлась, НЕ
|
|
964
|
+
// спрашиваем по своему id - приложение ответит 404, и экран будет молча ждать вечно. Вместо этого
|
|
965
|
+
// заводим наблюдение заново: приложение само связывает его по адресу с уже существующей записью и
|
|
966
|
+
// возвращает её же, а не вторую правду об одной сделке.
|
|
967
|
+
const serverId = await resolveServerSwapId(fresh);
|
|
968
|
+
const polled = await fetchSession(serverId || id);
|
|
969
|
+
if (polled.ok) {
|
|
970
|
+
applyLive(id, { xmrSession: polled.session, xmrSessionError: null });
|
|
971
|
+
} else if (!serverId) {
|
|
972
|
+
const share = watchOnlyShareFromWallet({ swapId: fresh.id, wallet: fresh.swapWallet });
|
|
973
|
+
const again = await openSession({
|
|
974
|
+
swapId: fresh.id, share, expectedAmountXmr: Number(fresh.xmrAmount), chain: fresh.network,
|
|
975
|
+
});
|
|
976
|
+
if (again.ok) {
|
|
977
|
+
applyLive(id, { xmrSession: again.session, xmrSessionError: null });
|
|
978
|
+
const s3 = loadState();
|
|
979
|
+
if (s3.swaps[id]) { pushLog(s3.swaps[id], simElapsed(s3.swaps[id]), "Backend record re-linked by the Monero address", again.session.address || ""); saveState(s3); }
|
|
980
|
+
} else {
|
|
981
|
+
applyLive(id, { xmrSessionError: again.error });
|
|
982
|
+
}
|
|
983
|
+
} else {
|
|
984
|
+
applyLive(id, { xmrSessionError: polled.error });
|
|
985
|
+
}
|
|
986
|
+
}
|
|
987
|
+
} catch (e) {
|
|
988
|
+
applyLive(id, { xmrSessionError: "сессия не открылась: " + e.message });
|
|
989
|
+
} finally {
|
|
990
|
+
liveInFlight.delete(id);
|
|
991
|
+
}
|
|
992
|
+
})();
|
|
993
|
+
}
|
|
994
|
+
|
|
995
|
+
// Сделки, которые сейчас открыты на экране. Ядро опрашивает бэкенд ТОЛЬКО по ним.
|
|
996
|
+
//
|
|
997
|
+
// Зачем: раньше tick опрашивал все незавершённые сделки без разбора. После нескольких нажатий кнопки
|
|
998
|
+
// подписи их накопилось восемь, и приложение само заваливало прокси запросами - приходил 429. При этом
|
|
999
|
+
// на главной странице нечего отслеживать вообще: там ещё нет сделки.
|
|
1000
|
+
//
|
|
1001
|
+
// Пустой набор (по умолчанию) означает "не опрашивать ничего". Экраны, которым слежение нужно, сообщают
|
|
1002
|
+
// свой набор сами: список сделок - все незавершённые, экран сделки - одну.
|
|
1003
|
+
let liveFocusIds = new Set();
|
|
1004
|
+
|
|
1005
|
+
export function setLiveFocus(ids) {
|
|
1006
|
+
liveFocusIds = new Set(Array.isArray(ids) ? ids.map(String) : []);
|
|
1007
|
+
return liveFocusIds.size;
|
|
1008
|
+
}
|
|
1009
|
+
|
|
1010
|
+
export function tick() {
|
|
1011
|
+
const s = loadState();
|
|
1012
|
+
let changed = false;
|
|
1013
|
+
|
|
1014
|
+
for (const swap of Object.values(s.swaps)) {
|
|
1015
|
+
if (swap.settlement) continue;
|
|
1016
|
+
|
|
1017
|
+
// Live-режим: сессия на бэкенде. Заводим при первом тике после создания свопа и дальше
|
|
1018
|
+
// опрашиваем не чаще API.pollMs. Не await: экран не должен ждать сеть.
|
|
1019
|
+
// Опрашиваем только то, что открыто на экране.
|
|
1020
|
+
if (isLiveSource(swap.xmrSource) && liveFocusIds.has(String(swap.id))) refreshLiveSession(swap.id);
|
|
1021
|
+
|
|
1022
|
+
const view = derive(swap);
|
|
1023
|
+
|
|
1024
|
+
// мейкер лочит XMR на совместном одноразовом адресе (кроме сценария maker_no_lock)
|
|
1025
|
+
if (!isLiveSource(swap.xmrSource) && !swap.xmr && view.deadlines.lockAtSim !== null && view.sim >= view.deadlines.lockAtSim) {
|
|
1026
|
+
const lock = chain.lockXmr({ swapId: swap.id, xmrAmount: swap.xmrAmount });
|
|
1027
|
+
swap.xmr = { ...lock, reclaimed: false, sweepTxid: null };
|
|
1028
|
+
|
|
1029
|
+
// В режиме live фиксируем реальную высоту нода на момент лока: по ней считаются
|
|
1030
|
+
// подтверждения (блок Monero ~2 минуты, поэтому сделка идёт по-настоящему ~20 минут).
|
|
1031
|
+
if (swap.chainMode === "live") {
|
|
1032
|
+
const snap = node.snapshot();
|
|
1033
|
+
swap.xmr.lockHeight = snap ? snap.height : null;
|
|
1034
|
+
if (!swap.xmr.lockHeight) {
|
|
1035
|
+
node.getInfo({ force: true }).then((info) => {
|
|
1036
|
+
if (info && !swap.settlement) {
|
|
1037
|
+
const s2 = loadState();
|
|
1038
|
+
if (s2.swaps[swap.id]) {
|
|
1039
|
+
s2.swaps[swap.id].xmr.lockHeight = info.height;
|
|
1040
|
+
saveState(s2);
|
|
1041
|
+
}
|
|
1042
|
+
}
|
|
1043
|
+
});
|
|
1044
|
+
}
|
|
1045
|
+
}
|
|
1046
|
+
|
|
1047
|
+
pushLog(swap, view.sim, "Maker locked XMR on the shared address", lock.txid);
|
|
1048
|
+
const key = swap.id + ":lock";
|
|
1049
|
+
if (!notified.has(key)) { notified.add(key); notify(swap.xmrAmount.toFixed(4) + " XMR locked; view key verified", "ok"); }
|
|
1050
|
+
changed = true;
|
|
1051
|
+
}
|
|
1052
|
+
|
|
1053
|
+
// Live-режим: первый замеченный приход XMR превращаем в запись swap.xmr с НАСТОЯЩИМ txid,
|
|
1054
|
+
// чтобы остальной код (логи, шаги, экраны) работал с ним так же, как с моковым локом.
|
|
1055
|
+
if (isLiveSource(swap.xmrSource) && !swap.xmr && view.xmr && view.xmr.received > 0) {
|
|
1056
|
+
swap.xmr = {
|
|
1057
|
+
mock: false,
|
|
1058
|
+
txid: view.xmr.txids[0] || null,
|
|
1059
|
+
address: view.xmr.address,
|
|
1060
|
+
amount: view.xmr.received,
|
|
1061
|
+
confirmations: view.xmr.confirmations,
|
|
1062
|
+
status: view.xmr.status,
|
|
1063
|
+
explorer: view.xmr.explorer,
|
|
1064
|
+
reclaimed: false,
|
|
1065
|
+
sweepTxid: null,
|
|
1066
|
+
};
|
|
1067
|
+
pushLog(swap, view.sim, "XMR received (backend, " + ((swap.xmrSession && swap.xmrSession.network) || "stagenet") + ")", swap.xmr.txid || "");
|
|
1068
|
+
const key = swap.id + ":xmr-in";
|
|
1069
|
+
if (!notified.has(key)) {
|
|
1070
|
+
notified.add(key);
|
|
1071
|
+
notify(view.xmr.received.toFixed(6) + " XMR received; confirmations " + view.xmr.confirmations + "/" + view.xmr.confTarget, "ok");
|
|
1072
|
+
}
|
|
1073
|
+
changed = true;
|
|
1074
|
+
}
|
|
1075
|
+
|
|
1076
|
+
// Live-режим: факт вывода подтверждает страница sweep (watch-only кошелёк исходящие переводы
|
|
1077
|
+
// видеть не может), поэтому финальное состояние берём из статуса сессии, а хеш - не выдумываем.
|
|
1078
|
+
if (isLiveSource(swap.xmrSource) && swap.xmrSession && swap.xmrSession.status === XMR_STATUS.SWEPT && !swap.settlement) {
|
|
1079
|
+
const atSim = view.sim;
|
|
1080
|
+
swap.settlement = {
|
|
1081
|
+
kind: "success",
|
|
1082
|
+
phase: TERMINAL.success.phase,
|
|
1083
|
+
atSim,
|
|
1084
|
+
txid: swap.xmrSession.sweepTxid || null,
|
|
1085
|
+
by: "your sweep wallet",
|
|
1086
|
+
};
|
|
1087
|
+
pushLog(swap, atSim, "XMR swept by you (backend reported)", swap.settlement.txid || "");
|
|
1088
|
+
const key = swap.id + ":success";
|
|
1089
|
+
if (!notified.has(key)) {
|
|
1090
|
+
notified.add(key);
|
|
1091
|
+
notify("Swap " + swap.id + " complete: XMR is on your address", "ok");
|
|
1092
|
+
}
|
|
1093
|
+
changed = true;
|
|
1094
|
+
continue;
|
|
1095
|
+
}
|
|
1096
|
+
|
|
1097
|
+
// happy path: после claim пользовательский кошелёк сам сметает XMR (litepaper §4.2)
|
|
1098
|
+
if (!isLiveSource(swap.xmrSource) && view.can.sweep && view.phase === "success") {
|
|
1099
|
+
const atSim = view.sim;
|
|
1100
|
+
swap.xmr.sweepTxid = chain.sweepXmr({ swapId: swap.id }).txid;
|
|
1101
|
+
swap.settlement = { kind: "success", phase: TERMINAL.success.phase, atSim, txid: swap.xmr.sweepTxid, by: "your swap wallet" };
|
|
1102
|
+
pushLog(swap, atSim, "XMR swept to your address", swap.xmr.sweepTxid);
|
|
1103
|
+
const key = swap.id + ":success";
|
|
1104
|
+
if (!notified.has(key)) { notified.add(key); notify("Swap " + swap.id + " complete: XMR is on your address", "ok"); }
|
|
1105
|
+
changed = true;
|
|
1106
|
+
continue;
|
|
1107
|
+
}
|
|
1108
|
+
|
|
1109
|
+
// watchtower: если пользователь ушёл, а деньги можно вернуть - возвращает он
|
|
1110
|
+
if (!isLiveSource(swap.xmrSource) && view.can.refund && view.refundLeftSim !== null && view.refundLeftSim <= -TIMING.watchtowerGrace) {
|
|
1111
|
+
const atSim = view.sim;
|
|
1112
|
+
const tx = chain.refundEscrow({ swapId: swap.id, by: "watchtower" });
|
|
1113
|
+
swap.settlement = { kind: "refund_eth", phase: TERMINAL.refunded_eth.phase, atSim, txHash: tx.txHash, by: "watchtower" };
|
|
1114
|
+
pushLog(swap, atSim, "Watchtower refunded your ETH", tx.txHash);
|
|
1115
|
+
const key = swap.id + ":refund";
|
|
1116
|
+
if (!notified.has(key)) { notified.add(key); notify("Watchtower refunded the ETH escrow of swap " + swap.id, "bad"); }
|
|
1117
|
+
changed = true;
|
|
1118
|
+
continue;
|
|
1119
|
+
}
|
|
1120
|
+
|
|
1121
|
+
// сценарий «пользователь не подтвердил»: мейкер забирает XMR
|
|
1122
|
+
if (!isLiveSource(swap.xmrSource) && swap.xmr && view.xmrReclaimed && !swap.xmr.reclaimed) {
|
|
1123
|
+
swap.xmr.reclaimed = true;
|
|
1124
|
+
pushLog(swap, view.sim, "Maker reclaimed the XMR", swap.xmr.txid);
|
|
1125
|
+
const key = swap.id + ":reclaim";
|
|
1126
|
+
if (!notified.has(key)) { notified.add(key); notify("Maker took the XMR back: no ready() call in time", "bad"); }
|
|
1127
|
+
changed = true;
|
|
1128
|
+
}
|
|
1129
|
+
}
|
|
1130
|
+
|
|
1131
|
+
if (changed) {
|
|
1132
|
+
saveState(s);
|
|
1133
|
+
}
|
|
1134
|
+
}
|
|
1135
|
+
|
|
1136
|
+
const notifier = { fn: null };
|
|
1137
|
+
export function onNotify(fn) {
|
|
1138
|
+
notifier.fn = fn;
|
|
1139
|
+
}
|
|
1140
|
+
function notify(msg, kind) {
|
|
1141
|
+
if (notifier.fn) notifier.fn(msg, kind);
|
|
1142
|
+
}
|
|
1143
|
+
|
|
1144
|
+
export function startTicker(intervalMs = 400) {
|
|
1145
|
+
if (timer) clearInterval(timer);
|
|
1146
|
+
timer = setInterval(() => {
|
|
1147
|
+
try { tick(); } catch (e) { console.error(e); }
|
|
1148
|
+
emit(); // экран перерисовывается каждый тик: подтверждения и таймеры идут живьём
|
|
1149
|
+
}, intervalMs);
|
|
1150
|
+
}
|
|
1151
|
+
|
|
1152
|
+
|
|
1153
|
+
|