@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,96 +1,92 @@
1
1
  // GENERATED FILE - a byte-for-byte copy of the engine module www/js/recovery/recoveryFile.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
- // Recovery-файл сделки: реальная криптография в браузере (WebCrypto).
5
+ // The deal recovery file: real cryptography in the browser (WebCrypto).
7
6
  //
8
- // Litepaper §5: без сохранённого recovery-файла сделка не фондируется; если вкладка
9
- // потеряна, этот файл - единственный способ забрать XMR или вернуть ETH (в том числе
10
- // через standalone-клиент без сайта). Поэтому здесь настоящий AES-GCM, а не заглушка:
11
- // файл можно расшифровать только паролем, который знает пользователь.
7
+ // Litepaper §5: without a saved recovery file a deal is not funded; if the tab is lost, this file is the only way
8
+ // to claim the XMR or refund the ETH (including through a standalone client without the site). So it is real
9
+ // AES-GCM, not a stub: the file can be decrypted only with the password the user knows.
12
10
  //
13
- // Формат файла (Arrakis-swap-<id>.json.enc):
11
+ // File format (Ninsei-swap-<id>.json.enc):
14
12
  // { v:2, alg:"AES-GCM-256/PBKDF2-SHA256", iter, salt, iv, ciphertext, meta:{id,side,createdAt} }
15
- // Внутри ciphertext - payload (см. buildPayload), и его формат версионирован отдельно:
16
- // версия оболочки (v) и версия payload (version) меняются независимо - шифрование и
17
- // содержимое сделки это разные вещи.
13
+ // Inside ciphertext is the payload (see buildPayload), versioned separately: the envelope version (v) and the
14
+ // payload version (version) change independently - encryption and deal content are different things.
18
15
  //
19
- // ЧТО ЛЕЖИТ В PAYLOAD И ПОЧЕМУ ИМЕННО ЭТО
20
- // v3: МОЯ ПОЛОВИНА ключа траты и МОЯ ПОЛОВИНА ключа просмотра (spendHalf, viewHalf) плюс ПУБЛИЧНЫЕ точки
21
- // контрагента (otherSpendPoint, otherViewPoint). Полного ключа траты не существует НИ У КОГО: адрес
22
- // собран как сумма половин, и потратить может только тот, у кого окажутся обе. Вторую даёт цепь -
23
- // поэтому в файле лежит ещё и адрес эскроу: из него метание прочитает раскрытую половину.
24
- // Точки контрагента публичны (они и так в адресе и в ордере), но без них адрес из половин не собрать.
25
- // v2: mnemonic - единственный источник ключей: и ключ траты, и view key выводятся из
26
- // него. Ключи в payload НЕ дублируются: копия секрета в файле - это
27
- // лишний способ его потерять и лишний повод рассинхронизировать поля
28
- // (раньше в файле лежали и seedHex, и оба приватных ключа).
16
+ // WHAT IS IN THE PAYLOAD, AND WHY EXACTLY THIS
17
+ // v3: MY HALF of the spend key and MY HALF of the view key (spendHalf, viewHalf) plus the counterparty's PUBLIC
18
+ // points (otherSpendPoint, otherViewPoint). No full spend key exists for ANYONE: the address is a sum of halves,
19
+ // and only the holder of both can spend. The second half is given by the chain - hence the escrow address is in
20
+ // the file too: the sweep reads the revealed half from it. The counterparty's points are public (they are in the
21
+ // address and the order anyway), but without them the address cannot be assembled from the halves.
22
+ // v2: the mnemonic is the single source of keys: both the spend key and the view key derive from it. Keys are NOT
23
+ // duplicated in the payload: a copy of the secret in the file is one more way to lose it and one more reason for
24
+ // the fields to drift (the file used to carry both seedHex and the two private keys).
29
25
  // publicSpendKey /
30
26
  // publicViewKey /
31
- // address - для проверки: если из мнемоники выводится другой адрес, файл повреждён
32
- // или подменён, и восстанавливать по нему кошелёк нельзя.
33
- // restoreHeight - высота, с которой начинать скан. Без неё кошелёк сканирует историю
34
- // Monero с нуля, а это десятки часов (замерено: ~35 блоков/с). Высота
35
- // создания сделки - корректный пол: лок XMR случится не раньше.
27
+ // address - for checking: if the mnemonic derives another address, the file is corrupt or substituted, and a
28
+ // wallet must not be restored from it.
29
+ // restoreHeight - the height to start the scan from. Without it the wallet scans Monero history from zero,
30
+ // which is tens of hours (measured ~35 blocks/s). The deal's creation height is a correct floor: the XMR lock
31
+ // cannot happen earlier.
36
32
  //
37
- // ЧЕГО В ФАЙЛЕ НЕТ: ключа траты в открытом виде (только внутри шифротекста), номера карты,
38
- // адреса вывода и вообще ничего, что не нужно для завершения или отката сделки.
33
+ // WHAT IS NOT IN THE FILE: the spend key in the clear (only inside the ciphertext), a card number, a withdrawal
34
+ // address, and anything not needed to complete or roll back the deal.
39
35
  //
40
- // ВИДА КОТИРОВКИ В PAYLOAD НЕТ, и НАБОР КЛЮЧЕЙ payload не проверяется: файл, выпущенный раньше, с лишним
41
- // ключом вида читается по-прежнему (лишний ключ просто остаётся непрочитанным), а новый выпускается без него.
42
- // Формат от этого не меняется: PAYLOAD_VERSION остаётся 3 - вид котировки к версии файла отношения не имел.
36
+ // THERE IS NO QUOTE KIND IN THE PAYLOAD, and the payload key set is not checked: a file issued earlier with an
37
+ // extra kind key is still read (the extra key just goes unread) and new files are issued without it. The format
38
+ // does not change: PAYLOAD_VERSION stays 3 - the quote kind never had to do with the file version.
43
39
 
44
- // ПАРАМЕТРЫ ШИФРОВАНИЯ НАЗВАНЫ ЯВНО И НЕ БЕРУТСЯ ИЗ УМОЛЧАНИЙ WEB-CRYPTO.
45
- // Длина тега и длина ключа - часть ФОРМАТА, а не деталь реализации: файл, выпущенный сегодня, обязан
46
- // читаться через годы, поэтому оба числа стоят в каждом вызове (encrypt/decrypt/deriveKey), лежат в
47
- // замороженных объектах и подтверждены ЗАМЕРОМ в tools/check-recovery.mjs: длина тега (шифротекст минус
48
- // открытый текст = 16 байт) и длина выведенного ключа (256 бит - доказано расшифровкой данным ключом,
49
- // выведенным отдельно как 256 бит). Число итераций PBKDF2 - тоже часть формата, но оно ОСОЗНАННО
50
- // изменяемо: растёт со временем, и тогда старые файлы читаются по своему числу (см. ITER_MIN ниже).
40
+ // THE ENCRYPTION PARAMETERS ARE NAMED EXPLICITLY AND ARE NOT TAKEN FROM WEB-CRYPTO DEFAULTS.
41
+ // The tag length and the key length are part of the FORMAT, not an implementation detail: a file issued today must
42
+ // be readable years later, so both numbers stand in every call (encrypt/decrypt/deriveKey), live in frozen objects
43
+ // and were confirmed by MEASUREMENT: the tag length (ciphertext minus plaintext = 16 bytes) and the derived key
44
+ // length (256 bits - proven by decrypting with a key derived separately as 256 bits). The PBKDF2 iteration count
45
+ // is also part of the format, but it is DELIBERATELY changeable: it grows with time, and old files are then read
46
+ // by their own count (see ITER_MIN below).
51
47
  //
52
- // ЧИСЛО ИТЕРАЦИЙ: 600 000 - РЕКОМЕНДАЦИЯ OWASP ДЛЯ PBKDF2-HMAC-SHA256.
53
- // Источник: OWASP Password Storage Cheat Sheet, раздел PBKDF2 - «PBKDF2-HMAC-SHA256: 600,000 iterations
54
- // (recommended)»; там же: при требовании FIPS-140 брать PBKDF2 с «work factor of 600,000 or more» и
55
- // внутренним HMAC-SHA-256, а сами числа получены замерами на GPU RTX 4000 (на декабрь 2022).
56
- // ПОЧЕМУ PBKDF2, А НЕ Argon2id/scrypt, КОТОРЫЕ OWASP СТАВИТ ВЫШЕ: в браузере доступно только то, что даёт
57
- // WebCrypto, а в нём из password-hashing есть ОДИН PBKDF2 - Argon2id и scrypt в стандарт не входят. Выбирать
58
- // не из чего, поэтому важна хотя бы верная для PBKDF2 цифра.
59
- // ПОЧЕМУ 210 000 БЫЛО НЕВЕРНО: это число стоит в таблице OWASP для PBKDF2-HMAC-SHA512 (220 000), а у нас
60
- // HMAC-SHA256 - то есть счёт был втрое ниже рекомендации для нашего же алгоритма.
61
- // ЧЕГО ЭТО НЕ ДАЁТ, ЧТОБЫ НЕ ОБМАНЫВАТЬСЯ: итерации поднимают цену ОДНОЙ догадки (замер на машине
62
- // разработки: 210k - 109 мс, 600k - 283 мс; на слабом устройстве в браузере заметно больше), но не
63
- // спасают от слабого пароля: офлайн-перебор упирается в ЭНТРОПИЮ пароля, а не в счёт. Минимум в 12 знаков
64
- // остаётся слабым местом - записано в .hermes/docs/10-recovery-file.md.
48
+ //
49
+ // THE ITERATION COUNT: 600,000 - OWASP's recommendation for PBKDF2-HMAC-SHA256, i.e. when FIPS-140 requires
50
+ // a "work factor of 600,000 or more" with an internal HMAC-SHA-256 (the numbers come from GPU measurements on an
51
+ // RTX 4000, as of December 2022).
52
+ // WHY PBKDF2 AND NOT Argon2id/scrypt, WHICH OWASP RANKS HIGHER: a browser offers only what WebCrypto gives, and
53
+ // among password-hashing it has ONE - PBKDF2; Argon2id and scrypt are not in the standard. There is nothing to
54
+ // choose from, so at least the number correct for PBKDF2 matters.
55
+ // WHY 210,000 WAS WRONG: that number is in the OWASP table for PBKDF2-HMAC-SHA512 (220,000), while we use
56
+ // HMAC-SHA256 - the count was three times below the recommendation for our own algorithm.
57
+ // WHAT THIS DOES NOT GIVE, SO AS NOT TO BE MISLED: iterations raise the price of ONE guess (measured on the
58
+ // development machine: 210k - 109 ms, 600k - 283 ms; noticeably more on a weak device in a browser) but do not
59
+ // save a weak password: an offline brute force is bounded by the password ENTROPY, not by the count. The 12-char
60
+ // minimum remains the weak spot.
65
61
  const ITER = 600_000;
66
- // ПОЛ ДЛЯ ЧУЖИХ ФАЙЛОВ. Число итераций приходит ИЗ ФАЙЛА (иначе нельзя поднять его, не потеряв старые
67
- // файлы), поэтому слабое значение обязано упереться в отказ: файл с числом ниже пола не расшифровываем.
62
+ // A FLOOR FOR FOREIGN FILES. The iteration count comes FROM THE FILE (otherwise it could not be raised without
63
+ // losing old files), so a weak value must meet a refusal: a file below the floor is not decrypted.
68
64
  const ITER_MIN = 100_000;
69
65
  const ITER_MAX = 10_000_000;
70
- // Идентификатор алгоритма - ОДНА строка на весь модуль: раньше он был вписан дважды (запись и чтение),
71
- // и расхождение между копиями выяснилось бы только на живом файле.
66
+ // The algorithm identifier is ONE string for the whole module: it used to be written twice (write and read), and a
67
+ // divergence between the copies would surface only on a live file.
72
68
  const ALG_ID = "AES-GCM-256/PBKDF2-SHA256";
73
69
  const AES = Object.freeze({ name: "AES-GCM", tagLength: 128, keyBits: 256 });
74
70
  const KDF = Object.freeze({ name: "PBKDF2", hash: "SHA-256" });
75
71
  const te = new TextEncoder();
76
72
  const td = new TextDecoder();
77
73
 
78
- export const FILE_MAGIC = "ARRAKIS-SWAP-RECOVERY";
74
+ export const FILE_MAGIC = "NINSEI-SWAP-RECOVERY";
79
75
 
80
- // Версия оболочки (шифрование) и версия полезной нагрузки (состав сделки) - разные числа.
81
- // ВЕРСИЯ КОНВЕРТА: 1 - без AAD (все файлы, выпущенные до 28.09.2026), 2 - заголовок аутентифицирован (AAD).
82
- // Читать обязаны ОБЕ: у людей на руках файлы версии 1, и потерять их нельзя. Версия поднимается, когда
83
- // меняется САМ КОНВЕРТ, а не полезная нагрузка внутри.
76
+ // The envelope version (encryption) and the payload version (deal content) are different numbers.
77
+ // ENVELOPE VERSION: 1 - no AAD (all files issued before it was added), 2 - the header is authenticated (AAD).
78
+ // BOTH must be readable: people hold version-1 files and they must not be lost. The version is raised when the
79
+ // ENVELOPE itself changes, not the payload inside.
84
80
  export const ENVELOPE_VERSION = 2;
85
81
  const READABLE_ENVELOPE_VERSIONS = [1, 2];
86
82
  export const PAYLOAD_VERSION = 3;
87
- // v1 - самые первые файлы (seedHex и оба ключа отдельными полями), v2 - мнемоника как единственный
88
- // источник, v3 - ПОЛОВИНЫ. Читать обязаны все три: у людей на руках есть файлы всех версий, и файл
89
- // восстановления - единственный способ забрать свои XMR. Выбрасывать старые версии нельзя.
83
+ // v1 - the very first files (seedHex and both keys as separate fields), v2 - the mnemonic as the single source,
84
+ // v3 - HALVES. All three must be readable: people hold files of every version, and a recovery file is the only way
85
+ // to claim your XMR. Old versions must not be dropped.
90
86
  const SUPPORTED_PAYLOAD_VERSIONS = [1, 2, 3];
91
- // СЕТИ, КОТОРЫЕ ЧИТАТЕЛЬ ФАЙЛА ЗНАЕТ. Три стандартные плюс fakechain - официальное имя regtest-режима
92
- // monerod: нода контура CI называет себя именно так, и файл, выпущенный на такой сети, обязан читаться.
93
- // Список поимённый: сеть вне него отвергается как неизвестная, и это остаётся в силе.
87
+ // NETWORKS THE FILE READER KNOWS. The three standard ones plus fakechain - the official name of monerod's regtest
88
+ // mode: the CI contour node calls itself exactly that, and a file issued on such a network must be readable. The
89
+ // list is by name: a network outside it is rejected as unknown, and that stays.
94
90
  const NETWORKS = ["mainnet", "stagenet", "testnet", "fakechain"];
95
91
 
96
92
  function b64(bytes) {
@@ -101,20 +97,20 @@ function unb64(str) {
101
97
  return Uint8Array.from(atob(str), (c) => c.charCodeAt(0));
102
98
  }
103
99
 
104
- // ЧИСЛО ИТЕРАЦИЙ - ПАРАМЕТР, А НЕ КОНСТАНТА ВЫЗОВА. При шифровании это ITER, при чтении - число ИЗ
105
- // ФАЙЛА: формат его хранит именно для того, чтобы счёт можно было поднять, не потеряв уже выпущенные
106
- // файлы. Прежде чтение брало константу и поле iter не читало вовсе - то есть повышение счёта сломало бы
107
- // все старые файлы молча, а само поле в файле было бы украшением.
108
- // AAD: ПОД АУТЕНТИФИКАЦИЕЙ ЗАГОЛОВОК, А НЕ ТОЛЬКО ШИФРОТЕКСТ. AES-GCM проверяет целостность того, что
109
- // передано ему как additionalData. Без AAD (так было в версии 1) под проверкой был ТОЛЬКО шифротекст, а
110
- // `meta` - идентификатор сделки, сторона, время, хеш условий - лежит рядом и правился бы без следа, и
111
- // читатель взял бы подменённое. Строку собирают и писатель, и читатель ИЗ ОДНИХ ПОЛЕЙ В ОДНОМ ПОРЯДКЕ:
112
- // правка любого поля меняет строку, и расшифровка отвергается.
113
- // ПОЧЕМУ МАССИВ, А НЕ ОБЪЕКТ: порядок ключей в объекте - деталь реализации JSON, и «одинаковый» объект
114
- // может дать разный текст. И почему ?? null: отсутствующее поле в массиве дало бы undefined, а это уже
115
- // другое значение.
116
- // ЭКСПОРТИРУЕТСЯ НАРОЧНО: строка - часть ФОРМАТА, и проверки обязаны собирать её тем же кодом, а не
117
- // собственной копией (копия разошлась бы с форматом ровно там, где это дороже всего).
100
+ // THE ITERATION COUNT IS A PARAMETER, NOT A CALL CONSTANT. On encryption it is ITER, on reading it is the number
101
+ // FROM THE FILE: the format stores it precisely so the count can be raised without losing already issued files.
102
+ // Reading used to take the constant and ignore the iter field entirely - so raising the count would have silently
103
+ // broken all old files, and the field in the file would have been decoration.
104
+ // AAD: THE HEADER IS UNDER AUTHENTICATION, NOT ONLY THE CIPHERTEXT. AES-GCM checks the integrity of what is passed
105
+ // to it as additionalData. Without AAD (as in version 1) only the ciphertext was covered, while `meta` - the deal
106
+ // id, side, time, terms hash - sits beside it and could be edited without a trace, and the reader would take the
107
+ // substituted value. The string is built by both writer and reader FROM THE SAME FIELDS IN THE SAME ORDER: any
108
+ // field edit changes the string and decryption is rejected.
109
+ // WHY AN ARRAY, NOT AN OBJECT: key order in an object is a JSON implementation detail and an "identical" object
110
+ // can yield different text. And why ?? null: a missing field in the array would give undefined, which is another
111
+ // value.
112
+ // EXPORTED ON PURPOSE: the string is part of the FORMAT, and checks must build it with the same code, not their
113
+ // own copy (a copy would diverge from the format exactly where it costs most).
118
114
  export function envelopeAad(envelope) {
119
115
  const m = envelope.meta || {};
120
116
  return JSON.stringify([
@@ -134,21 +130,21 @@ async function deriveKey(passphrase, salt, iterations) {
134
130
  );
135
131
  }
136
132
 
137
- // ВЫСОТА, С КОТОРОЙ НАЧИНАТЬ СКАН ПРИ ВОССТАНОВЛЕНИИ. Чистая функция: никуда не ходит, поэтому её
138
- // проверяют без ноды - и это важно, потому что именно здесь раньше и терялась высота.
133
+ // THE HEIGHT TO START THE RECOVERY SCAN FROM. A pure function: it goes nowhere, so it is checked without a node -
134
+ // and that matters, because this is exactly where the height used to get lost.
139
135
  //
140
- // ПОЧЕМУ ИСТОЧНИКОВ ДВА. Высоту брали ТОЛЬКО у ноды Monero, и когда нода не ответила, в файл честно
141
- // ложился null. Восстановление после этого сканировало историю с нуля (десятки часов), хотя точная
142
- // высота сделки уже была на руках: эскроу заводится транзакцией, и её блок лежит в квитанции. Нода -
143
- // чужая служба, которая может лежать (боевой прокси отдавал 502), а сделка - своя и уже есть.
136
+ // WHY TWO SOURCES. The height was taken ONLY from the Monero node, and when the node did not answer, null was
137
+ // honestly written into the file. Recovery then scanned history from zero (tens of hours), although the exact deal
138
+ // height was at hand: the escrow is created by a transaction and its block is in the receipt. The node is someone
139
+ // else's service that may be down (a live proxy returned 502), while the deal is our own and already exists.
144
140
  //
145
- // БЕРЁМ МЕНЬШУЮ И ОТСТУПАЕМ НА БЛОК. Поступление XMR может лечь блоком раньше записанной высоты
146
- // (на живом прогоне транзакция оказалась в 2206634 при высоте 2206635), а скан, начавшийся ниже денег,
147
- // их находит; начавшийся выше - не находит никогда. Поэтому Math.min и минус один, но не ниже единицы.
148
- // ТРЕТИЙ ИСТОЧНИК - ПРИЛОЖЕНИЕ. В записях прежних сделок поля birthHeight нет (оно появилось позже), но
149
- // высоту при регистрации наблюдения приложение разрешило само и хранит; страница забирает её у своего
150
- // сервера. Это НАШ источник, поэтому он не зависит от того, отвечает ли сейчас нода Monero, и именно он
151
- // спасает уже выпущенные файлы: у них в записи высоты нет вовсе.
141
+ // TAKE THE SMALLER AND STEP BACK A BLOCK. The XMR arrival may land a block earlier than the recorded height, and a
142
+ // scan that starts below the money finds it; one that starts above never does. Hence Math.min and minus one, but
143
+ // never below one.
144
+ // THE THIRD SOURCE - THE APPLICATION. Old deal records have no birthHeight field (it appeared later), but the app
145
+ // resolved and stored the height when it registered the watch; the page gets it from its server. This is OUR
146
+ // source, so it does not depend on whether the Monero node answers now, and it is what saves already issued
147
+ // files: their record has no height at all.
152
148
  export function scanStartHeight({ escrowBirthHeight = null, serverRestoreHeight = null, nodeHeight = null } = {}) {
153
149
  const known = [escrowBirthHeight, serverRestoreHeight, nodeHeight]
154
150
  .map((v) => Number(v))
@@ -157,21 +153,19 @@ export function scanStartHeight({ escrowBirthHeight = null, serverRestoreHeight
157
153
  return Math.max(1, Math.min(...known) - 1);
158
154
  }
159
155
 
160
- // Полезная нагрузка: всё, что нужно, чтобы завершить или откатить сделку без сайта.
161
- // restoreHeight передаёт вызывающий - сюда его кладёт scanStartHeight выше, а модуль не
162
- // ходит в сеть сам, чтобы его можно было тестировать без ноды.
156
+ // The payload: everything needed to complete or roll back the deal without the site. restoreHeight is passed by
157
+ // the caller - scanStartHeight puts it here, and the module does not go to the network itself so it can be tested
158
+ // without a node.
163
159
  export function buildPayload({ swap, swapWallet, escrow, receiveAddress, restoreHeight = null }) {
164
160
  return {
165
161
  magic: FILE_MAGIC,
166
- // ВЕРСИЯ ОТРАЖАЕТ СОСТАВ, А НЕ ВЕРСИЮ МОДУЛЯ. Раньше здесь стоял PAYLOAD_VERSION, и после появления
167
- // половин файл, собранный из мнемоники, получал номер 3 - то есть заявлял состав, которого в нём нет,
168
- // и проверка справедливо его отвергала. Номер должен говорить правду о содержимом: у файла с половин
169
- // тройка, у файла с мнемоникой двойка.
162
+ // THE VERSION REFLECTS THE CONTENT, NOT THE MODULE VERSION. It used to hold PAYLOAD_VERSION, so after halves
163
+ // appeared a file built from a mnemonic got number 3 - claiming content it did not have, and the check rightly
164
+ // rejected it. The number must tell the truth about the content: a halves file is three, a mnemonic file is two.
170
165
  version: swapWallet && swapWallet.spendHalf ? 3 : 2,
171
166
  swapId: swap.id,
172
- // Хеш условий ордера с КОНТРАКТА (keccak256(locker, claimer, hashlock, amount, t0, t1, адрес эскроу, chainid)).
173
- // Он и есть привязка файла к цепи: зная условия, можно проверить, что файл относится именно к этому ордеру.
174
- // Поле НЕОБЯЗАТЕЛЬНОЕ: у файлов, выпущенных раньше, его нет, и читаться они должны по-прежнему.
167
+ // The order terms hash FROM THE CONTRACT. It is the file's tie to the chain: knowing the terms, one can check
168
+ // the file belongs to this very order. The field is OPTIONAL: files issued earlier lack it and must still read.
175
169
  termsHash: (swap.escrow && swap.escrow.termsHash) || null,
176
170
  side: swap.side,
177
171
  network: swap.network,
@@ -183,8 +177,8 @@ export function buildPayload({ swap, swapWallet, escrow, receiveAddress, restore
183
177
  maker: swap.maker,
184
178
  quote: { signature: swap.quoteSignature, nonce: swap.quoteSignature ? swap.quoteSignature.slice(-8) : null },
185
179
  escrow: escrow || swap.escrow || null,
186
- // v3 (половины) и v2 (мнемоника) - разные составы, и они несовместимы: в v3 мнемоники НЕТ,
187
- // потому что полного ключа траты не существует. Что писать - решает источник кошелька.
180
+ // v3 (halves) and v2 (mnemonic) are different content, incompatible: v3 has NO mnemonic, because no full spend
181
+ // key exists. What to write is decided by the wallet source.
188
182
  moneroWallet: swapWallet
189
183
  ? swapWallet.spendHalf
190
184
  ? {
@@ -197,10 +191,10 @@ export function buildPayload({ swap, swapWallet, escrow, receiveAddress, restore
197
191
  viewHalf: swapWallet.viewHalf,
198
192
  otherSpendPoint: swapWallet.otherSpendPoint,
199
193
  otherViewPoint: swapWallet.otherViewPoint,
200
- // ПОЛОВИНА ПРОСМОТРА КОНТРАГЕНТА - и это не секрет. Полный ключ ПРОСМОТРА (сумма двух половин)
201
- // позволяет ВИДЕТЬ входящие и не позволяет их потратить; трата требует суммы половин ТРАТЫ.
202
- // Без чужой половины просмотра кошелёк не сможет найти наши XMR в цепи: половины траты для
203
- // скана недостаточно, ключи просмотра и траты в Monero независимы.
194
+ // THE COUNTERPARTY'S VIEW HALF - and it is not a secret. The full VIEW key (the sum of two halves) lets
195
+ // one SEE incoming funds and not spend them; spending needs the sum of the SPEND halves. Without the
196
+ // other view half the wallet cannot find our XMR on chain: the spend halves alone are not enough for
197
+ // the scan, and Monero's view and spend keys are independent.
204
198
  otherViewHalf: swapWallet.otherViewHalf || null,
205
199
  escrowAddress: (escrow && escrow.address) || (swap.escrow && swap.escrow.address) || null,
206
200
  restoreHeight: Number.isFinite(restoreHeight) ? restoreHeight : null,
@@ -218,54 +212,53 @@ export function buildPayload({ swap, swapWallet, escrow, receiveAddress, restore
218
212
  }
219
213
  : null,
220
214
  deadlines: swap.timeline,
221
- recoveryClient: "any standalone client implementing the Arrakis swap protocol (see litepaper §5.3)",
215
+ recoveryClient: "any standalone client implementing the Ninsei swap protocol (see litepaper §5.3)",
222
216
  };
223
217
  }
224
218
 
225
- // Проверка payload ПОСЛЕ расшифровки. Отдельная функция, потому что расшифровать можно и
226
- // мусор (пароль подошёл, а внутри не то), и потому что тесты должны бить по каждому полю
227
- // отдельно. Бросает с понятной причиной, а не падает в глубине восстановления кошелька.
228
- // Секреты в тексте ошибок не появляются: мнемоника - это ключ траты.
219
+ // Payload validation AFTER decryption. A separate function, because garbage can decrypt too (the password
220
+ // matched but the content is wrong) and because checks must hit each field separately. It throws with a clear
221
+ // cause instead of failing deep inside wallet recovery. No secrets appear in error text: a mnemonic is a spend key.
229
222
  export function validatePayload(payload) {
230
223
  const fail = (m) => {
231
- throw new Error("Повреждённый recovery-файл: " + m);
224
+ throw new Error("Corrupt recovery file: " + m);
232
225
  };
233
- if (!payload || typeof payload !== "object") fail("внутри не объект");
234
- if (payload.magic !== FILE_MAGIC) fail("чужой файл (нет метки Arrakis)");
226
+ if (!payload || typeof payload !== "object") fail("the content is not an object");
227
+ if (payload.magic !== FILE_MAGIC) fail("foreign file (no Ninsei mark)");
235
228
  if (!SUPPORTED_PAYLOAD_VERSIONS.includes(payload.version)) {
236
- fail(`версия данных ${payload.version} не поддерживается (умеем ${SUPPORTED_PAYLOAD_VERSIONS.join(", ")})`);
229
+ fail(`payload version ${payload.version} is not supported (we support ${SUPPORTED_PAYLOAD_VERSIONS.join(", ")})`);
237
230
  }
238
- if (!payload.swapId) fail("нет идентификатора сделки");
231
+ if (!payload.swapId) fail("no deal id");
239
232
  const m = payload.moneroWallet;
240
- if (!m || typeof m !== "object") fail("нет данных кошелька Monero");
233
+ if (!m || typeof m !== "object") fail("no Monero wallet data");
241
234
  if (payload.version >= 3) {
242
- // ПОЛОВИНЫ. Мнемоники здесь нет и быть не должно: полного ключа траты не существует ни у одной
243
- // стороны, и файл, в котором он вдруг окажется, означает, что кошелёк собран не по схеме.
235
+ // HALVES. There is no mnemonic here and there must not be: no full spend key exists for either side, and a
236
+ // file that somehow contains one means the wallet was not built by the scheme.
244
237
  for (const field of ["spendHalf", "viewHalf", "otherSpendPoint", "otherViewPoint", "otherViewHalf"]) {
245
- if (!m[field]) fail("в файле половин нет поля " + field);
238
+ if (!m[field]) fail("the halves file has no field " + field);
246
239
  }
247
- if (m.mnemonic) fail("файл половин содержит мнемонику - кошелёк собран не по схеме");
240
+ if (m.mnemonic) fail("the halves file contains a mnemonic - the wallet was not built by the scheme");
248
241
  } else {
249
- if (!m.mnemonic) fail("нет мнемоники - восстановить кошелёк нечем");
242
+ if (!m.mnemonic) fail("no mnemonic - there is nothing that can restore the wallet");
250
243
  const words = String(m.mnemonic).trim().split(/\s+/).length;
251
- if (words !== 25 && words !== 13 && words !== 24) fail(`мнемоника не похожа на Monero-фразу (слов: ${words})`);
244
+ if (words !== 25 && words !== 13 && words !== 24) fail(`the mnemonic does not look like a Monero phrase (${words} words)`);
252
245
  }
253
- if (!m.address) fail("нет адреса кошелька - не с чем сверять восстановленный");
246
+ if (!m.address) fail("no wallet address - nothing to check the restored one against");
254
247
  const net = m.network || payload.moneroNetwork;
255
- if (!NETWORKS.includes(net)) fail(`неизвестная сеть "${net}"`);
248
+ if (!NETWORKS.includes(net)) fail(`unknown network "${net}"`);
256
249
  if (m.network && payload.moneroNetwork && m.network !== payload.moneroNetwork) {
257
- fail(`сеть кошелька (${m.network}) не совпадает с сетью сделки (${payload.moneroNetwork})`);
250
+ fail(`the wallet network (${m.network}) does not match the deal network (${payload.moneroNetwork})`);
258
251
  }
259
252
 
260
253
  const warnings = [];
261
254
  if (!Number.isFinite(m.restoreHeight) || m.restoreHeight <= 0) {
262
255
  warnings.push(
263
- "нет высоты восстановления (restoreHeight): кошелёк будет сканировать историю с нуля, " +
264
- "а это десятки часов - укажи высоту лока XMR вручную"
256
+ "no restore height (restoreHeight): the wallet will scan history from zero, " +
257
+ "which is tens of hours - set the XMR lock height manually"
265
258
  );
266
259
  }
267
260
  if (payload.version === 1) {
268
- warnings.push("файл старого формата (v1): ключи в нём лежали отдельными полями, ключ траты берём из мнемоники");
261
+ warnings.push("an old-format file (v1): its keys were separate fields, the spend key is taken from the mnemonic");
269
262
  }
270
263
  return { warnings };
271
264
  }
@@ -275,7 +268,7 @@ export async function encryptPayload(payload, passphrase) {
275
268
  const salt = crypto.getRandomValues(new Uint8Array(16));
276
269
  const iv = crypto.getRandomValues(new Uint8Array(12));
277
270
  const key = await deriveKey(passphrase, salt, ITER);
278
- // Заголовок собирается ДО шифрования: он же служит AAD, то есть шифротекст привязан к его содержимому.
271
+ // The header is built BEFORE encryption: it also serves as the AAD, so the ciphertext is tied to its content.
279
272
  const meta = { id: payload.swapId, side: payload.side, createdAt: payload.createdAt, termsHash: payload.termsHash || null };
280
273
  const header = { v: ENVELOPE_VERSION, alg: ALG_ID, iter: ITER, salt: b64(salt), iv: b64(iv) };
281
274
  const ct = await crypto.subtle.encrypt(
@@ -286,9 +279,9 @@ export async function encryptPayload(payload, passphrase) {
286
279
  return { ...header, ciphertext: b64(ct), meta };
287
280
  }
288
281
 
289
- // Расшифровка. Ошибки AES-GCM (неверный пароль, изменённый шифротекст) приходят как
290
- // OperationError без деталей - переводим в человеческое сообщение, иначе пользователь
291
- // видит «operation failed» и не понимает, пароль не тот или файл битый.
282
+ // Decryption. AES-GCM errors (wrong password, altered ciphertext) arrive as OperationError with no detail - we
283
+ // turn them into a human message, else the user sees "operation failed" and cannot tell a wrong password from a
284
+ // corrupt file.
292
285
  export async function decryptFile(fileObject, passphrase) {
293
286
  if (!fileObject || typeof fileObject !== "object") throw new Error("Not a recovery file (expecting JSON)");
294
287
  if (!READABLE_ENVELOPE_VERSIONS.includes(fileObject.v)) throw new Error(`Unsupported recovery file version: ${fileObject.v}`);
@@ -296,8 +289,8 @@ export async function decryptFile(fileObject, passphrase) {
296
289
  for (const field of ["salt", "iv", "ciphertext"]) {
297
290
  if (typeof fileObject[field] !== "string" || !fileObject[field]) throw new Error(`Recovery file is missing "${field}"`);
298
291
  }
299
- // ЧИСЛО ИТЕРАЦИЙ ЧИТАЕТСЯ ИЗ ФАЙЛА И ПРОВЕРЯЕТСЯ. Ниже пола - отказ: слабый счёт не должен приводить
300
- // к тихой расшифровке. Выше потолка - тоже отказ: это уже не файл, а попытка занять процессор.
292
+ // THE ITERATION COUNT IS READ FROM THE FILE AND CHECKED. Below the floor - a refusal: a weak count must not lead
293
+ // to a quiet decryption. Above the ceiling - also a refusal: that is no longer a file but an attempt to occupy the CPU.
301
294
  const iter = Number(fileObject.iter);
302
295
  if (!Number.isInteger(iter) || iter < ITER_MIN) {
303
296
  throw new Error(`Recovery file declares too few PBKDF2 iterations: ${fileObject.iter} (minimum ${ITER_MIN})`);
@@ -308,8 +301,8 @@ export async function decryptFile(fileObject, passphrase) {
308
301
  const salt = unb64(fileObject.salt);
309
302
  const iv = unb64(fileObject.iv);
310
303
  const key = await deriveKey(passphrase, salt, iter);
311
- // AAD ПОДМЕШИВАЕТСЯ ТОЛЬКО НАЧИНАЯ С ВЕРСИИ 2. Файлу версии 1 её подставить нельзя: он шифровался без
312
- // AAD, и с ней не расшифровался бы ни у кого - то есть «улучшение» обнулило бы все старые файлы.
304
+ // AAD IS MIXED IN ONLY FROM VERSION 2. A version-1 file cannot take it: it was encrypted without AAD and would
305
+ // decrypt for nobody with one - so the "improvement" would have wiped every old file.
313
306
  const additionalData = fileObject.v >= 2 ? te.encode(envelopeAad(fileObject)) : undefined;
314
307
  const decryptAlg = additionalData
315
308
  ? { name: AES.name, iv, tagLength: AES.tagLength, additionalData }
@@ -318,28 +311,28 @@ export async function decryptFile(fileObject, passphrase) {
318
311
  try {
319
312
  pt = await crypto.subtle.decrypt(decryptAlg, key, unb64(fileObject.ciphertext));
320
313
  } catch {
321
- throw new Error("Не удалось расшифровать файл: неверный пароль или файл изменён");
314
+ throw new Error("Could not decrypt the file: wrong password or the file was altered");
322
315
  }
323
316
  let payload;
324
317
  try {
325
318
  payload = JSON.parse(td.decode(pt));
326
319
  } catch {
327
- throw new Error("Расшифровалось, но внутри не JSON - файл повреждён");
320
+ throw new Error("It decrypted, but the content is not JSON - the file is corrupt");
328
321
  }
329
322
  validatePayload(payload);
330
- // У ВЕРСИИ 1 ЗАГОЛОВОК НЕ АУТЕНТИФИЦИРОВАН, ПОЭТОМУ СВЕРЯЕМ ЕГО С СОДЕРЖИМЫМ. Подменить meta у старого
331
- // файла можно (AAD там нет), но разойтись с данными внутри шифротекста ему не дадут: идентификатор
332
- // сделки обязан совпасть. Для версии 2 это уже избыточно - её проверяет AAD.
323
+ // IN VERSION 1 THE HEADER IS NOT AUTHENTICATED, SO WE CHECK IT AGAINST THE CONTENT. The meta of an old file can
324
+ // be substituted (no AAD), but it cannot disagree with the data inside the ciphertext: the deal id must match.
325
+ // For version 2 this is already redundant - AAD checks it.
333
326
  if (fileObject.meta && fileObject.meta.id != null && payload.swapId != null &&
334
327
  String(fileObject.meta.id) !== String(payload.swapId)) {
335
- throw new Error("Идентификатор сделки в заголовке не совпал с содержимым файла: файл изменён");
328
+ throw new Error("The deal id in the header does not match the file content: the file was altered");
336
329
  }
337
330
  return payload;
338
331
  }
339
332
 
340
- // BigInt В JSON НЕ СЕРИАЛИЗУЕТСЯ, а половины ключей это BigInt: JSON.stringify на них ПАДАЕТ, и файл
341
- // восстановления не выпускается. Приводим BigInt к шестнадцатеричной строке - тот же смысл, но сериализуемый.
342
- // Такой же обработчик стоит в записи состояния (core/store.js): это одна и та же граница сериализации.
333
+ // BigInt DOES NOT SERIALISE IN JSON, and key halves are BigInt: JSON.stringify FAILS on them and the recovery file
334
+ // is never issued. We turn BigInt into a hex string - same meaning, serialisable. The same handler sits in state
335
+ // writing (core/store.js): it is the same serialisation boundary.
343
336
  const bigintSafe = (key, value) => (typeof value === "bigint" ? "0x" + value.toString(16) : value);
344
337
  export async function createRecoveryFile(payload, passphrase) {
345
338
  const file = await encryptPayload(payload, passphrase);
@@ -347,20 +340,20 @@ export async function createRecoveryFile(payload, passphrase) {
347
340
  }
348
341
 
349
342
  export function recoveryFileName(swapId) {
350
- // Имя файла - строчными и с привязкой к свопу: по нему видно, к какой сделке файл относится, и его
351
- // можно сверить с id на сервере (там он тот же). Раньше было "Arrakis-swap-<id>.json.enc": заглавная
352
- // буква в начале и никакой связи с конкретным свопом, если id ещё нет.
353
- // Если id ещё нет (файл качают до создания свопа) - имя ФИКСИРОВАННОЕ, без случайного хвоста.
343
+ // The file name is lowercase and tied to the swap: it shows which deal the file belongs to, and it can be
344
+ // checked against the id on the server (same there). It used to be "Ninsei-swap-<id>.json.enc": a capital letter
345
+ // and no tie to a specific swap when there is no id yet.
346
+ // If there is no id yet (the file is downloaded before the swap is created) the name is FIXED, with no random tail.
354
347
  //
355
- // Раньше здесь был случайный хвост: он не давал файлам перезаписывать друг друга, но на практике каждое
356
- // нажатие кнопки "Download recovery file" рождало НОВОЕ имя, и в загрузках оказывалась кипа похожих
357
- // файлов непонятного происхождения. Именно это выглядело как "каждый раз скачивается другой файл".
358
- // Теперь имя стабильное, а повторное скачивание браузер сам пометит как (1) - это понятнее, чем новая
359
- // случайная строка. Имя совпадает с тем, что уже используется на сервере для незавершённого дела.
348
+ // There used to be a random tail here: it stopped files overwriting each other, but in practice every click of
349
+ // "Download recovery file" produced a NEW name and the downloads filled with a heap of look-alike files of
350
+ // unclear origin. That is exactly what looked like "a different file every time". Now the name is stable and a
351
+ // repeated download is marked (1) by the browser - clearer than a new random string. The name matches the one
352
+ // already used on the server for an unfinished deal.
360
353
  const raw = String(swapId || "").trim();
361
354
  const known = raw && raw !== "pending" ? raw : "pending";
362
- // Нижний регистр и только безопасные символы: имя уходит в файловую систему, где регистр и спецсимволы
363
- // ведут себя по-разному на Windows, Linux и macOS.
355
+ // Lowercase and safe characters only: the name goes to a filesystem, where case and special characters behave
356
+ // differently on Windows, Linux and macOS.
364
357
  const safe = known.toLowerCase().replace(/[^a-z0-9-]+/g, "-").replace(/-+/g, "-").replace(/^-|-$/g, "");
365
- return `arrakis-${safe}.json.enc`;
358
+ return `ninsei-${safe}.json.enc`;
366
359
  }