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