@ninsei-labs/sdk 0.39.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (81) hide show
  1. package/CHANGELOG.md +866 -0
  2. package/LICENSE +21 -0
  3. package/README.md +187 -0
  4. package/engine/atomic/dleq2.js +330 -0
  5. package/engine/atomic/halfenc.js +205 -0
  6. package/engine/atomic/halves.js +119 -0
  7. package/engine/atomic/order-client.js +57 -0
  8. package/engine/atomic/order.js +132 -0
  9. package/engine/atomic/orderContext.js +70 -0
  10. package/engine/atomic/orderHalfCommit.js +29 -0
  11. package/engine/core/chainSource.js +306 -0
  12. package/engine/core/clock.js +40 -0
  13. package/engine/core/config.js +885 -0
  14. package/engine/core/finality.js +93 -0
  15. package/engine/core/format.js +168 -0
  16. package/engine/core/preSignGuards.js +495 -0
  17. package/engine/core/rfqSource.js +193 -0
  18. package/engine/core/sellWindow.js +43 -0
  19. package/engine/core/store.js +100 -0
  20. package/engine/core/swap.js +1153 -0
  21. package/engine/evm/amounts.js +131 -0
  22. package/engine/evm/auth.js +156 -0
  23. package/engine/evm/claimGas.js +87 -0
  24. package/engine/evm/depositor.js +127 -0
  25. package/engine/evm/dex.js +836 -0
  26. package/engine/evm/escrow.js +208 -0
  27. package/engine/evm/factory.js +149 -0
  28. package/engine/evm/fees.js +90 -0
  29. package/engine/evm/funding.js +309 -0
  30. package/engine/evm/gasReserve.js +88 -0
  31. package/engine/evm/index.js +123 -0
  32. package/engine/evm/injected.js +175 -0
  33. package/engine/evm/permit.js +125 -0
  34. package/engine/evm/prices.js +179 -0
  35. package/engine/evm/session.js +777 -0
  36. package/engine/mock/chain.js +98 -0
  37. package/engine/monero/address.js +265 -0
  38. package/engine/monero/halvesSweep.js +68 -0
  39. package/engine/monero/node.js +110 -0
  40. package/engine/monero/scan.js +325 -0
  41. package/engine/monero/swapKeys.js +69 -0
  42. package/engine/monero/unlock.js +46 -0
  43. package/engine/monero/wallet.js +168 -0
  44. package/engine/recovery/recoveryFile.js +363 -0
  45. package/engine/recovery/restore.js +176 -0
  46. package/engine/sdk/bridge.js +609 -0
  47. package/engine/ui/dom.js +90 -0
  48. package/engine/ui/walletModal.js +132 -0
  49. package/index.d.ts +1417 -0
  50. package/package.json +74 -0
  51. package/src/actions.mjs +72 -0
  52. package/src/adapters/evm-wallet.mjs +126 -0
  53. package/src/adapters/index.mjs +21 -0
  54. package/src/adapters/local-storage.mjs +32 -0
  55. package/src/adapters/monero-wallet.mjs +185 -0
  56. package/src/adapters/monero.mjs +51 -0
  57. package/src/adapters/node-test-evm-wallet.mjs +345 -0
  58. package/src/codes.mjs +188 -0
  59. package/src/config.mjs +277 -0
  60. package/src/engine.mjs +53 -0
  61. package/src/errors.mjs +24 -0
  62. package/src/http.mjs +72 -0
  63. package/src/index.mjs +256 -0
  64. package/src/legs/evm.mjs +118 -0
  65. package/src/legs/index.mjs +50 -0
  66. package/src/levelsWalkSpec.mjs +183 -0
  67. package/src/lock.mjs +140 -0
  68. package/src/order.mjs +212 -0
  69. package/src/preflight.mjs +137 -0
  70. package/src/primitives.mjs +47 -0
  71. package/src/quoteEip712.mjs +35 -0
  72. package/src/quoteEip712Spec.mjs +214 -0
  73. package/src/quotes.mjs +465 -0
  74. package/src/record.mjs +65 -0
  75. package/src/recovery.mjs +40 -0
  76. package/src/sim.mjs +37 -0
  77. package/src/swap-flow.mjs +492 -0
  78. package/src/swaps.mjs +935 -0
  79. package/src/sweep.mjs +222 -0
  80. package/src/wallet.mjs +164 -0
  81. package/src/xmr-arrival.mjs +289 -0
@@ -0,0 +1,363 @@
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.
4
+ // Everything below this header is identical to the source.
5
+
6
+ // Recovery-файл сделки: реальная криптография в браузере (WebCrypto).
7
+ //
8
+ // Litepaper §5: без сохранённого recovery-файла сделка не фондируется; если вкладка
9
+ // потеряна, этот файл - единственный способ забрать XMR или вернуть ETH (в том числе
10
+ // через standalone-клиент без сайта). Поэтому здесь настоящий AES-GCM, а не заглушка:
11
+ // файл можно расшифровать только паролем, который знает пользователь.
12
+ //
13
+ // Формат файла (Arrakis-swap-<id>.json.enc):
14
+ // { 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
+ // содержимое сделки это разные вещи.
18
+ //
19
+ // ЧТО ЛЕЖИТ В PAYLOAD И ПОЧЕМУ ИМЕННО ЭТО
20
+ // v3: МОЯ ПОЛОВИНА ключа траты и МОЯ ПОЛОВИНА ключа просмотра (spendHalf, viewHalf) плюс ПУБЛИЧНЫЕ точки
21
+ // контрагента (otherSpendPoint, otherViewPoint). Полного ключа траты не существует НИ У КОГО: адрес
22
+ // собран как сумма половин, и потратить может только тот, у кого окажутся обе. Вторую даёт цепь -
23
+ // поэтому в файле лежит ещё и адрес эскроу: из него метание прочитает раскрытую половину.
24
+ // Точки контрагента публичны (они и так в адресе и в ордере), но без них адрес из половин не собрать.
25
+ // v2: mnemonic - единственный источник ключей: и ключ траты, и view key выводятся из
26
+ // него. Ключи в payload НЕ дублируются: копия секрета в файле - это
27
+ // лишний способ его потерять и лишний повод рассинхронизировать поля
28
+ // (раньше в файле лежали и seedHex, и оба приватных ключа).
29
+ // publicSpendKey /
30
+ // publicViewKey /
31
+ // address - для проверки: если из мнемоники выводится другой адрес, файл повреждён
32
+ // или подменён, и восстанавливать по нему кошелёк нельзя.
33
+ // restoreHeight - высота, с которой начинать скан. Без неё кошелёк сканирует историю
34
+ // Monero с нуля, а это десятки часов (замерено: ~35 блоков/с). Высота
35
+ // создания сделки - корректный пол: лок XMR случится не раньше.
36
+ //
37
+ // ЧЕГО В ФАЙЛЕ НЕТ: ключа траты в открытом виде (только внутри шифротекста), номера карты,
38
+ // адреса вывода и вообще ничего, что не нужно для завершения или отката сделки.
39
+ //
40
+ // ВИДА КОТИРОВКИ В PAYLOAD НЕТ, и НАБОР КЛЮЧЕЙ payload не проверяется: файл, выпущенный раньше, с лишним
41
+ // ключом вида читается по-прежнему (лишний ключ просто остаётся непрочитанным), а новый выпускается без него.
42
+ // Формат от этого не меняется: PAYLOAD_VERSION остаётся 3 - вид котировки к версии файла отношения не имел.
43
+
44
+ // ПАРАМЕТРЫ ШИФРОВАНИЯ НАЗВАНЫ ЯВНО И НЕ БЕРУТСЯ ИЗ УМОЛЧАНИЙ WEB-CRYPTO.
45
+ // Длина тега и длина ключа - часть ФОРМАТА, а не деталь реализации: файл, выпущенный сегодня, обязан
46
+ // читаться через годы, поэтому оба числа стоят в каждом вызове (encrypt/decrypt/deriveKey), лежат в
47
+ // замороженных объектах и подтверждены ЗАМЕРОМ в tools/check-recovery.mjs: длина тега (шифротекст минус
48
+ // открытый текст = 16 байт) и длина выведенного ключа (256 бит - доказано расшифровкой данным ключом,
49
+ // выведенным отдельно как 256 бит). Число итераций PBKDF2 - тоже часть формата, но оно ОСОЗНАННО
50
+ // изменяемо: растёт со временем, и тогда старые файлы читаются по своему числу (см. ITER_MIN ниже).
51
+ //
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.
65
+ const ITER = 600_000;
66
+ // ПОЛ ДЛЯ ЧУЖИХ ФАЙЛОВ. Число итераций приходит ИЗ ФАЙЛА (иначе нельзя поднять его, не потеряв старые
67
+ // файлы), поэтому слабое значение обязано упереться в отказ: файл с числом ниже пола не расшифровываем.
68
+ const ITER_MIN = 100_000;
69
+ const ITER_MAX = 10_000_000;
70
+ // Идентификатор алгоритма - ОДНА строка на весь модуль: раньше он был вписан дважды (запись и чтение),
71
+ // и расхождение между копиями выяснилось бы только на живом файле.
72
+ const ALG_ID = "AES-GCM-256/PBKDF2-SHA256";
73
+ const AES = Object.freeze({ name: "AES-GCM", tagLength: 128, keyBits: 256 });
74
+ const KDF = Object.freeze({ name: "PBKDF2", hash: "SHA-256" });
75
+ const te = new TextEncoder();
76
+ const td = new TextDecoder();
77
+
78
+ export const FILE_MAGIC = "ARRAKIS-SWAP-RECOVERY";
79
+
80
+ // Версия оболочки (шифрование) и версия полезной нагрузки (состав сделки) - разные числа.
81
+ // ВЕРСИЯ КОНВЕРТА: 1 - без AAD (все файлы, выпущенные до 28.09.2026), 2 - заголовок аутентифицирован (AAD).
82
+ // Читать обязаны ОБЕ: у людей на руках файлы версии 1, и потерять их нельзя. Версия поднимается, когда
83
+ // меняется САМ КОНВЕРТ, а не полезная нагрузка внутри.
84
+ export const ENVELOPE_VERSION = 2;
85
+ const READABLE_ENVELOPE_VERSIONS = [1, 2];
86
+ export const PAYLOAD_VERSION = 3;
87
+ // v1 - самые первые файлы (seedHex и оба ключа отдельными полями), v2 - мнемоника как единственный
88
+ // источник, v3 - ПОЛОВИНЫ. Читать обязаны все три: у людей на руках есть файлы всех версий, и файл
89
+ // восстановления - единственный способ забрать свои XMR. Выбрасывать старые версии нельзя.
90
+ const SUPPORTED_PAYLOAD_VERSIONS = [1, 2, 3];
91
+ const NETWORKS = ["mainnet", "stagenet", "testnet"];
92
+
93
+ function b64(bytes) {
94
+ return btoa(String.fromCharCode(...new Uint8Array(bytes)));
95
+ }
96
+
97
+ function unb64(str) {
98
+ return Uint8Array.from(atob(str), (c) => c.charCodeAt(0));
99
+ }
100
+
101
+ // ЧИСЛО ИТЕРАЦИЙ - ПАРАМЕТР, А НЕ КОНСТАНТА ВЫЗОВА. При шифровании это ITER, при чтении - число ИЗ
102
+ // ФАЙЛА: формат его хранит именно для того, чтобы счёт можно было поднять, не потеряв уже выпущенные
103
+ // файлы. Прежде чтение брало константу и поле iter не читало вовсе - то есть повышение счёта сломало бы
104
+ // все старые файлы молча, а само поле в файле было бы украшением.
105
+ // AAD: ПОД АУТЕНТИФИКАЦИЕЙ ЗАГОЛОВОК, А НЕ ТОЛЬКО ШИФРОТЕКСТ. AES-GCM проверяет целостность того, что
106
+ // передано ему как additionalData. Без AAD (так было в версии 1) под проверкой был ТОЛЬКО шифротекст, а
107
+ // `meta` - идентификатор сделки, сторона, время, хеш условий - лежит рядом и правился бы без следа, и
108
+ // читатель взял бы подменённое. Строку собирают и писатель, и читатель ИЗ ОДНИХ ПОЛЕЙ В ОДНОМ ПОРЯДКЕ:
109
+ // правка любого поля меняет строку, и расшифровка отвергается.
110
+ // ПОЧЕМУ МАССИВ, А НЕ ОБЪЕКТ: порядок ключей в объекте - деталь реализации JSON, и «одинаковый» объект
111
+ // может дать разный текст. И почему ?? null: отсутствующее поле в массиве дало бы undefined, а это уже
112
+ // другое значение.
113
+ // ЭКСПОРТИРУЕТСЯ НАРОЧНО: строка - часть ФОРМАТА, и проверки обязаны собирать её тем же кодом, а не
114
+ // собственной копией (копия разошлась бы с форматом ровно там, где это дороже всего).
115
+ export function envelopeAad(envelope) {
116
+ const m = envelope.meta || {};
117
+ return JSON.stringify([
118
+ envelope.v, envelope.alg, envelope.iter, envelope.salt, envelope.iv,
119
+ m.id ?? null, m.side ?? null, m.createdAt ?? null, m.termsHash ?? null,
120
+ ]);
121
+ }
122
+
123
+ async function deriveKey(passphrase, salt, iterations) {
124
+ const base = await crypto.subtle.importKey("raw", te.encode(passphrase), KDF.name, false, ["deriveKey"]);
125
+ return crypto.subtle.deriveKey(
126
+ { name: KDF.name, salt, iterations, hash: KDF.hash },
127
+ base,
128
+ { name: AES.name, length: AES.keyBits },
129
+ false,
130
+ ["encrypt", "decrypt"]
131
+ );
132
+ }
133
+
134
+ // ВЫСОТА, С КОТОРОЙ НАЧИНАТЬ СКАН ПРИ ВОССТАНОВЛЕНИИ. Чистая функция: никуда не ходит, поэтому её
135
+ // проверяют без ноды - и это важно, потому что именно здесь раньше и терялась высота.
136
+ //
137
+ // ПОЧЕМУ ИСТОЧНИКОВ ДВА. Высоту брали ТОЛЬКО у ноды Monero, и когда нода не ответила, в файл честно
138
+ // ложился null. Восстановление после этого сканировало историю с нуля (десятки часов), хотя точная
139
+ // высота сделки уже была на руках: эскроу заводится транзакцией, и её блок лежит в квитанции. Нода -
140
+ // чужая служба, которая может лежать (боевой прокси отдавал 502), а сделка - своя и уже есть.
141
+ //
142
+ // БЕРЁМ МЕНЬШУЮ И ОТСТУПАЕМ НА БЛОК. Поступление XMR может лечь блоком раньше записанной высоты
143
+ // (на живом прогоне транзакция оказалась в 2206634 при высоте 2206635), а скан, начавшийся ниже денег,
144
+ // их находит; начавшийся выше - не находит никогда. Поэтому Math.min и минус один, но не ниже единицы.
145
+ // ТРЕТИЙ ИСТОЧНИК - ПРИЛОЖЕНИЕ. В записях прежних сделок поля birthHeight нет (оно появилось позже), но
146
+ // высоту при регистрации наблюдения приложение разрешило само и хранит; страница забирает её у своего
147
+ // сервера. Это НАШ источник, поэтому он не зависит от того, отвечает ли сейчас нода Monero, и именно он
148
+ // спасает уже выпущенные файлы: у них в записи высоты нет вовсе.
149
+ export function scanStartHeight({ escrowBirthHeight = null, serverRestoreHeight = null, nodeHeight = null } = {}) {
150
+ const known = [escrowBirthHeight, serverRestoreHeight, nodeHeight]
151
+ .map((v) => Number(v))
152
+ .filter((v) => Number.isFinite(v) && v > 0);
153
+ if (known.length === 0) return null;
154
+ return Math.max(1, Math.min(...known) - 1);
155
+ }
156
+
157
+ // Полезная нагрузка: всё, что нужно, чтобы завершить или откатить сделку без сайта.
158
+ // restoreHeight передаёт вызывающий - сюда его кладёт scanStartHeight выше, а модуль не
159
+ // ходит в сеть сам, чтобы его можно было тестировать без ноды.
160
+ export function buildPayload({ swap, swapWallet, escrow, receiveAddress, restoreHeight = null }) {
161
+ return {
162
+ magic: FILE_MAGIC,
163
+ // ВЕРСИЯ ОТРАЖАЕТ СОСТАВ, А НЕ ВЕРСИЮ МОДУЛЯ. Раньше здесь стоял PAYLOAD_VERSION, и после появления
164
+ // половин файл, собранный из мнемоники, получал номер 3 - то есть заявлял состав, которого в нём нет,
165
+ // и проверка справедливо его отвергала. Номер должен говорить правду о содержимом: у файла с половин
166
+ // тройка, у файла с мнемоникой двойка.
167
+ version: swapWallet && swapWallet.spendHalf ? 3 : 2,
168
+ swapId: swap.id,
169
+ // Хеш условий ордера с КОНТРАКТА (keccak256(locker, claimer, hashlock, amount, t0, t1, адрес эскроу, chainid)).
170
+ // Он и есть привязка файла к цепи: зная условия, можно проверить, что файл относится именно к этому ордеру.
171
+ // Поле НЕОБЯЗАТЕЛЬНОЕ: у файлов, выпущенных раньше, его нет, и читаться они должны по-прежнему.
172
+ termsHash: (swap.escrow && swap.escrow.termsHash) || null,
173
+ side: swap.side,
174
+ network: swap.network,
175
+ moneroNetwork: swap.moneroNetwork,
176
+ createdAt: swap.createdAt,
177
+ pay: { token: swap.payToken, amount: swap.payAmount },
178
+ receive: { token: "XMR", amount: swap.xmrAmount, address: receiveAddress },
179
+ rate: swap.rate,
180
+ maker: swap.maker,
181
+ quote: { signature: swap.quoteSignature, nonce: swap.quoteSignature ? swap.quoteSignature.slice(-8) : null },
182
+ escrow: escrow || swap.escrow || null,
183
+ // v3 (половины) и v2 (мнемоника) - разные составы, и они несовместимы: в v3 мнемоники НЕТ,
184
+ // потому что полного ключа траты не существует. Что писать - решает источник кошелька.
185
+ moneroWallet: swapWallet
186
+ ? swapWallet.spendHalf
187
+ ? {
188
+ source: "halves",
189
+ library: swapWallet.library || null,
190
+ network: swapWallet.network,
191
+ address: swapWallet.address,
192
+ subaddress: swapWallet.subaddress || null,
193
+ spendHalf: swapWallet.spendHalf,
194
+ viewHalf: swapWallet.viewHalf,
195
+ otherSpendPoint: swapWallet.otherSpendPoint,
196
+ otherViewPoint: swapWallet.otherViewPoint,
197
+ // ПОЛОВИНА ПРОСМОТРА КОНТРАГЕНТА - и это не секрет. Полный ключ ПРОСМОТРА (сумма двух половин)
198
+ // позволяет ВИДЕТЬ входящие и не позволяет их потратить; трата требует суммы половин ТРАТЫ.
199
+ // Без чужой половины просмотра кошелёк не сможет найти наши XMR в цепи: половины траты для
200
+ // скана недостаточно, ключи просмотра и траты в Monero независимы.
201
+ otherViewHalf: swapWallet.otherViewHalf || null,
202
+ escrowAddress: (escrow && escrow.address) || (swap.escrow && swap.escrow.address) || null,
203
+ restoreHeight: Number.isFinite(restoreHeight) ? restoreHeight : null,
204
+ }
205
+ : {
206
+ source: swapWallet.source,
207
+ library: swapWallet.library || null,
208
+ network: swapWallet.network,
209
+ address: swapWallet.address,
210
+ subaddress: swapWallet.subaddress || null,
211
+ mnemonic: swapWallet.mnemonic || null,
212
+ publicSpendKey: swapWallet.publicSpendKey,
213
+ publicViewKey: swapWallet.publicViewKey,
214
+ restoreHeight: Number.isFinite(restoreHeight) ? restoreHeight : null,
215
+ }
216
+ : null,
217
+ deadlines: swap.timeline,
218
+ recoveryClient: "any standalone client implementing the Arrakis swap protocol (see litepaper §5.3)",
219
+ };
220
+ }
221
+
222
+ // Проверка payload ПОСЛЕ расшифровки. Отдельная функция, потому что расшифровать можно и
223
+ // мусор (пароль подошёл, а внутри не то), и потому что тесты должны бить по каждому полю
224
+ // отдельно. Бросает с понятной причиной, а не падает в глубине восстановления кошелька.
225
+ // Секреты в тексте ошибок не появляются: мнемоника - это ключ траты.
226
+ export function validatePayload(payload) {
227
+ const fail = (m) => {
228
+ throw new Error("Повреждённый recovery-файл: " + m);
229
+ };
230
+ if (!payload || typeof payload !== "object") fail("внутри не объект");
231
+ if (payload.magic !== FILE_MAGIC) fail("чужой файл (нет метки Arrakis)");
232
+ if (!SUPPORTED_PAYLOAD_VERSIONS.includes(payload.version)) {
233
+ fail(`версия данных ${payload.version} не поддерживается (умеем ${SUPPORTED_PAYLOAD_VERSIONS.join(", ")})`);
234
+ }
235
+ if (!payload.swapId) fail("нет идентификатора сделки");
236
+ const m = payload.moneroWallet;
237
+ if (!m || typeof m !== "object") fail("нет данных кошелька Monero");
238
+ if (payload.version >= 3) {
239
+ // ПОЛОВИНЫ. Мнемоники здесь нет и быть не должно: полного ключа траты не существует ни у одной
240
+ // стороны, и файл, в котором он вдруг окажется, означает, что кошелёк собран не по схеме.
241
+ for (const field of ["spendHalf", "viewHalf", "otherSpendPoint", "otherViewPoint", "otherViewHalf"]) {
242
+ if (!m[field]) fail("в файле половин нет поля " + field);
243
+ }
244
+ if (m.mnemonic) fail("файл половин содержит мнемонику - кошелёк собран не по схеме");
245
+ } else {
246
+ if (!m.mnemonic) fail("нет мнемоники - восстановить кошелёк нечем");
247
+ const words = String(m.mnemonic).trim().split(/\s+/).length;
248
+ if (words !== 25 && words !== 13 && words !== 24) fail(`мнемоника не похожа на Monero-фразу (слов: ${words})`);
249
+ }
250
+ if (!m.address) fail("нет адреса кошелька - не с чем сверять восстановленный");
251
+ const net = m.network || payload.moneroNetwork;
252
+ if (!NETWORKS.includes(net)) fail(`неизвестная сеть "${net}"`);
253
+ if (m.network && payload.moneroNetwork && m.network !== payload.moneroNetwork) {
254
+ fail(`сеть кошелька (${m.network}) не совпадает с сетью сделки (${payload.moneroNetwork})`);
255
+ }
256
+
257
+ const warnings = [];
258
+ if (!Number.isFinite(m.restoreHeight) || m.restoreHeight <= 0) {
259
+ warnings.push(
260
+ "нет высоты восстановления (restoreHeight): кошелёк будет сканировать историю с нуля, " +
261
+ "а это десятки часов - укажи высоту лока XMR вручную"
262
+ );
263
+ }
264
+ if (payload.version === 1) {
265
+ warnings.push("файл старого формата (v1): ключи в нём лежали отдельными полями, ключ траты берём из мнемоники");
266
+ }
267
+ return { warnings };
268
+ }
269
+
270
+ export async function encryptPayload(payload, passphrase) {
271
+ if (!passphrase || passphrase.length < 12) throw new Error("Passphrase must be at least 12 characters");
272
+ const salt = crypto.getRandomValues(new Uint8Array(16));
273
+ const iv = crypto.getRandomValues(new Uint8Array(12));
274
+ const key = await deriveKey(passphrase, salt, ITER);
275
+ // Заголовок собирается ДО шифрования: он же служит AAD, то есть шифротекст привязан к его содержимому.
276
+ const meta = { id: payload.swapId, side: payload.side, createdAt: payload.createdAt, termsHash: payload.termsHash || null };
277
+ const header = { v: ENVELOPE_VERSION, alg: ALG_ID, iter: ITER, salt: b64(salt), iv: b64(iv) };
278
+ const ct = await crypto.subtle.encrypt(
279
+ { name: AES.name, iv, tagLength: AES.tagLength, additionalData: te.encode(envelopeAad({ ...header, meta })) },
280
+ key,
281
+ te.encode(JSON.stringify(payload, bigintSafe))
282
+ );
283
+ return { ...header, ciphertext: b64(ct), meta };
284
+ }
285
+
286
+ // Расшифровка. Ошибки AES-GCM (неверный пароль, изменённый шифротекст) приходят как
287
+ // OperationError без деталей - переводим в человеческое сообщение, иначе пользователь
288
+ // видит «operation failed» и не понимает, пароль не тот или файл битый.
289
+ export async function decryptFile(fileObject, passphrase) {
290
+ if (!fileObject || typeof fileObject !== "object") throw new Error("Not a recovery file (expecting JSON)");
291
+ if (!READABLE_ENVELOPE_VERSIONS.includes(fileObject.v)) throw new Error(`Unsupported recovery file version: ${fileObject.v}`);
292
+ if (fileObject.alg !== ALG_ID) throw new Error(`Unknown algorithm: ${fileObject.alg}`);
293
+ for (const field of ["salt", "iv", "ciphertext"]) {
294
+ if (typeof fileObject[field] !== "string" || !fileObject[field]) throw new Error(`Recovery file is missing "${field}"`);
295
+ }
296
+ // ЧИСЛО ИТЕРАЦИЙ ЧИТАЕТСЯ ИЗ ФАЙЛА И ПРОВЕРЯЕТСЯ. Ниже пола - отказ: слабый счёт не должен приводить
297
+ // к тихой расшифровке. Выше потолка - тоже отказ: это уже не файл, а попытка занять процессор.
298
+ const iter = Number(fileObject.iter);
299
+ if (!Number.isInteger(iter) || iter < ITER_MIN) {
300
+ throw new Error(`Recovery file declares too few PBKDF2 iterations: ${fileObject.iter} (minimum ${ITER_MIN})`);
301
+ }
302
+ if (iter > ITER_MAX) {
303
+ throw new Error(`Recovery file declares too many PBKDF2 iterations: ${iter} (maximum ${ITER_MAX})`);
304
+ }
305
+ const salt = unb64(fileObject.salt);
306
+ const iv = unb64(fileObject.iv);
307
+ const key = await deriveKey(passphrase, salt, iter);
308
+ // AAD ПОДМЕШИВАЕТСЯ ТОЛЬКО НАЧИНАЯ С ВЕРСИИ 2. Файлу версии 1 её подставить нельзя: он шифровался без
309
+ // AAD, и с ней не расшифровался бы ни у кого - то есть «улучшение» обнулило бы все старые файлы.
310
+ const additionalData = fileObject.v >= 2 ? te.encode(envelopeAad(fileObject)) : undefined;
311
+ const decryptAlg = additionalData
312
+ ? { name: AES.name, iv, tagLength: AES.tagLength, additionalData }
313
+ : { name: AES.name, iv, tagLength: AES.tagLength };
314
+ let pt;
315
+ try {
316
+ pt = await crypto.subtle.decrypt(decryptAlg, key, unb64(fileObject.ciphertext));
317
+ } catch {
318
+ throw new Error("Не удалось расшифровать файл: неверный пароль или файл изменён");
319
+ }
320
+ let payload;
321
+ try {
322
+ payload = JSON.parse(td.decode(pt));
323
+ } catch {
324
+ throw new Error("Расшифровалось, но внутри не JSON - файл повреждён");
325
+ }
326
+ validatePayload(payload);
327
+ // У ВЕРСИИ 1 ЗАГОЛОВОК НЕ АУТЕНТИФИЦИРОВАН, ПОЭТОМУ СВЕРЯЕМ ЕГО С СОДЕРЖИМЫМ. Подменить meta у старого
328
+ // файла можно (AAD там нет), но разойтись с данными внутри шифротекста ему не дадут: идентификатор
329
+ // сделки обязан совпасть. Для версии 2 это уже избыточно - её проверяет AAD.
330
+ if (fileObject.meta && fileObject.meta.id != null && payload.swapId != null &&
331
+ String(fileObject.meta.id) !== String(payload.swapId)) {
332
+ throw new Error("Идентификатор сделки в заголовке не совпал с содержимым файла: файл изменён");
333
+ }
334
+ return payload;
335
+ }
336
+
337
+ // BigInt В JSON НЕ СЕРИАЛИЗУЕТСЯ, а половины ключей это BigInt: JSON.stringify на них ПАДАЕТ, и файл
338
+ // восстановления не выпускается. Приводим BigInt к шестнадцатеричной строке - тот же смысл, но сериализуемый.
339
+ // Такой же обработчик стоит в записи состояния (core/store.js): это одна и та же граница сериализации.
340
+ const bigintSafe = (key, value) => (typeof value === "bigint" ? "0x" + value.toString(16) : value);
341
+ export async function createRecoveryFile(payload, passphrase) {
342
+ const file = await encryptPayload(payload, passphrase);
343
+ return JSON.stringify(file, bigintSafe, 2);
344
+ }
345
+
346
+ export function recoveryFileName(swapId) {
347
+ // Имя файла - строчными и с привязкой к свопу: по нему видно, к какой сделке файл относится, и его
348
+ // можно сверить с id на сервере (там он тот же). Раньше было "Arrakis-swap-<id>.json.enc": заглавная
349
+ // буква в начале и никакой связи с конкретным свопом, если id ещё нет.
350
+ // Если id ещё нет (файл качают до создания свопа) - имя ФИКСИРОВАННОЕ, без случайного хвоста.
351
+ //
352
+ // Раньше здесь был случайный хвост: он не давал файлам перезаписывать друг друга, но на практике каждое
353
+ // нажатие кнопки "Download recovery file" рождало НОВОЕ имя, и в загрузках оказывалась кипа похожих
354
+ // файлов непонятного происхождения. Именно это выглядело как "каждый раз скачивается другой файл".
355
+ // Теперь имя стабильное, а повторное скачивание браузер сам пометит как (1) - это понятнее, чем новая
356
+ // случайная строка. Имя совпадает с тем, что уже используется на сервере для незавершённого дела.
357
+ const raw = String(swapId || "").trim();
358
+ const known = raw && raw !== "pending" ? raw : "pending";
359
+ // Нижний регистр и только безопасные символы: имя уходит в файловую систему, где регистр и спецсимволы
360
+ // ведут себя по-разному на Windows, Linux и macOS.
361
+ const safe = known.toLowerCase().replace(/[^a-z0-9-]+/g, "-").replace(/-+/g, "-").replace(/^-|-$/g, "");
362
+ return `arrakis-${safe}.json.enc`;
363
+ }
@@ -0,0 +1,176 @@
1
+ // GENERATED FILE - a byte-for-byte copy of the engine module www/js/recovery/restore.js for the SDK package.
2
+ // Edit the source under www/js, then run: node tools/build-sdk-engine.mjs
3
+ // tools/check-sdk-engine.mjs reddens on any drift, so a stale copy cannot ship silently.
4
+ // Everything below this header is identical to the source.
5
+
6
+ // Обратный путь recovery-файла: файл → кошелёк, которым можно забрать XMR.
7
+ //
8
+ // Прямой путь (генерация файла) - в recoveryFile.js. Здесь то, ради чего файл вообще существует:
9
+ // восстановление, когда вкладка потеряна. Два правила закреплены кодом, а не комментарием:
10
+ //
11
+ // 1) кошелёк восстанавливается из мнемоники, и восстановленный адрес ОБЯЗАН совпасть с адресом
12
+ // в файле. Не совпал - файл повреждён или подменён, и продолжать нельзя: молчаливое
13
+ // «восстановилось» означало бы показать пользователю чужой кошелёк и потерять его средства;
14
+ // 2) на сервер (watch-only кошелёк) уходит ТОЛЬКО view key с адресом и высотой. Ключ траты,
15
+ // мнемоника и seed остаются в браузере - это граница продукта, поэтому она вынесена в
16
+ // отдельную функцию и проверяется отдельным тестом (tools/check-recovery.mjs).
17
+
18
+ import { createSwapWallet } from "../monero/wallet.js";
19
+ import { decryptFile, validatePayload } from "./recoveryFile.js";
20
+ import { swapAddressFromHalves, watchViewSeedHex } from "../monero/swapKeys.js";
21
+
22
+ // Восстановленный кошелёк + всё, что нужно для скана: высота и предупреждения формата.
23
+ //
24
+ // ДВЕ РАЗНЫЕ СХЕМЫ, И ОБЕ ЖИВЫЕ. Файл версии 2 несёт МНЕМОНИКУ: из неё собирается полный кошелёк. Файл версии 3
25
+ // несёт ПОЛОВИНЫ, и полного ключа траты в нём НЕТ И БЫТЬ НЕ МОЖЕТ: у каждой стороны только своя половина,
26
+ // вторая раскрывается в цепи при заборе ETH. Поэтому из файла версии 3 собирается ВИДЯЩИЙ кошелёк (ключ
27
+ // просмотра - сумма двух половин просмотра), а трата становится возможной только после раскрытия.
28
+ //
29
+ // deps нужен для проверяемости: в браузере библиотеки берутся из вендорного бандла (подгружаются здесь
30
+ // динамически, потому что статический импорт бандла сломал бы прогоны в Node), а в тестах передаются свои.
31
+ export async function restoreWalletFromPayload(payload, deps = {}) {
32
+ const { warnings } = validatePayload(payload);
33
+ const m = payload.moneroWallet;
34
+ const network = m.network || payload.moneroNetwork;
35
+ if (payload.version >= 3) return await restoreFromHalves(payload, warnings, deps);
36
+
37
+ // Мнемоника передаётся явно: monero-js проверит контрольную сумму и откажется работать,
38
+ // если фраза битая (в wallet.js это теперь ошибка, а не тихая генерация нового кошелька).
39
+ const wallet = await createSwapWallet({ seed: m.mnemonic, network });
40
+
41
+ if (wallet.address !== m.address) {
42
+ throw new Error("адрес из файла не совпал с адресом, выведенным из мнемоники: файл повреждён или изменён");
43
+ }
44
+ // Публичные ключи в файле - вторая проверка того же самого, но по другим данным: адрес
45
+ // собирается из них, поэтому расхождение означает правку файла руками.
46
+ for (const [field, value] of [
47
+ ["publicSpendKey", wallet.publicSpendKey],
48
+ ["publicViewKey", wallet.publicViewKey],
49
+ ]) {
50
+ if (m[field] && m[field] !== value) {
51
+ throw new Error(`ключ ${field} из файла не совпал с выведенным из мнемоники: файл изменён`);
52
+ }
53
+ }
54
+
55
+ return {
56
+ wallet,
57
+ payload,
58
+ // Ради этого высота и хранится: без неё кошелёк сканирует историю Monero с нуля (десятки часов).
59
+ restoreHeight: Number.isFinite(m.restoreHeight) && m.restoreHeight > 0 ? m.restoreHeight : 0,
60
+ warnings,
61
+ };
62
+ }
63
+
64
+ // ВОССТАНОВЛЕНИЕ ИЗ ПОЛОВИН (файл версии 3). Что здесь есть и чего здесь нет:
65
+ //
66
+ // есть: ПРОВЕРКА ЦЕЛОСТНОСТИ. Адрес собирается из своих половин и ЧУЖИХ ТОЧЕК и обязан совпасть с адресом
67
+ // в файле - ровно та же роль, что у сравнения адреса с мнемоникой в ветке v2. Не совпал - файл повреждён или
68
+ // подменён, и продолжать нельзя;
69
+ // есть: КЛЮЧ ПРОСМОТРА - сумма своей половины просмотра и половины контрагента (она в файле и не секрет).
70
+ // Им кошелёк ВИДИТ поступления на общий адрес;
71
+ // НЕТ: ключ траты. Его нельзя собрать: второй половины траты в файле нет, и это не упущение, а свойство
72
+ // схемы. Она придёт из цепи, когда контрагент заберёт ETH, - вот тогда трата и станет возможной
73
+ // (sweepSpendSecret сверяет её с публичным ключом ордера).
74
+ export async function restoreFromHalves(payload, warnings = [], deps = {}) {
75
+ const m = payload.moneroWallet;
76
+ const network = m.network || payload.moneroNetwork;
77
+ const libs = deps.halves && deps.addressFromKeys
78
+ ? { halves: deps.halves, addressFromKeys: deps.addressFromKeys, keccak256: deps.keccak256 }
79
+ : await (async () => {
80
+ const vendor = await import("../../assets/vendors/atomic/atomic-browser.js?v=0951c3dc");
81
+ const { createHalves } = await import("../atomic/halves.js");
82
+ const { addressFromKeys } = await import("../monero/address.js");
83
+ const halves = createHalves({
84
+ ed25519: vendor.ed25519, secp256k1: vendor.secp256k1,
85
+ keccak256: vendor.keccak256, randomBytes: vendor.randomBytes,
86
+ });
87
+ return { halves, addressFromKeys, keccak256: vendor.keccak256 };
88
+ })();
89
+ if (!libs.keccak256) throw new Error("нет keccak256: без него адрес не проверить");
90
+
91
+ const built = swapAddressFromHalves({
92
+ halves: libs.halves, addressFromKeys: libs.addressFromKeys, deps: { keccak256: libs.keccak256 },
93
+ ownSpendHalf: m.spendHalf, ownViewHalf: m.viewHalf,
94
+ otherSpendPoint: m.otherSpendPoint, otherViewPoint: m.otherViewPoint, network,
95
+ });
96
+ if (built.address !== m.address) {
97
+ throw new Error("адрес из файла не совпал с адресом, собранным из половин и точек контрагента: файл повреждён или изменён");
98
+ }
99
+ const viewSeedHex = watchViewSeedHex({
100
+ halves: libs.halves, ownViewHalf: m.viewHalf, otherViewHalf: m.otherViewHalf,
101
+ });
102
+ const wallet = {
103
+ address: built.address,
104
+ network,
105
+ spendPub: built.spendPub,
106
+ viewPub: built.viewPub,
107
+ viewSeedHex,
108
+ spendHalf: m.spendHalf,
109
+ viewHalf: m.viewHalf,
110
+ otherSpendPoint: m.otherSpendPoint,
111
+ otherViewPoint: m.otherViewPoint,
112
+ otherViewHalf: m.otherViewHalf,
113
+ source: "halves",
114
+ // ЯВНО: этим кошельком ПОКА нельзя распоряжаться. Ключ траты появится после раскрытия половины в цепи.
115
+ spendable: false,
116
+ };
117
+ return {
118
+ wallet,
119
+ payload,
120
+ restoreHeight: Number.isFinite(m.restoreHeight) && m.restoreHeight > 0 ? m.restoreHeight : 0,
121
+ warnings: [
122
+ ...warnings,
123
+ "этот файл описывает схему ПОЛОВИН: ключом просмотра видно поступления, а для траты нужна половина " +
124
+ "контрагента, которая раскрывается в цепи при заборе ETH",
125
+ ],
126
+ };
127
+ }
128
+
129
+ // Полный путь: расшифровать файл паролем и восстановить кошелёк.
130
+ export async function restoreFromFile(fileObject, passphrase, deps = {}) {
131
+ return restoreWalletFromPayload(await decryptFile(fileObject, passphrase), deps);
132
+ }
133
+
134
+ // Страховка на будущее, вынесена отдельной функцией именно для того, чтобы её можно было
135
+ // проверить тестом: сейчас share собирается по белому списку полей, поэтому секрет в него
136
+ // попасть не может. Но если завтра кто-то добавит в share поле «на всякий случай», падать это
137
+ // должно здесь, а не утекать на сервер. Проверяем двумя способами - по именам полей и по
138
+ // значениям (переименовать поле легко, а значение секрета останется тем же).
139
+ export function assertNoSecretsInShare(share, wallet) {
140
+ // spendHalf и viewHalf добавлены вместе со схемой половин: это секреты той же силы, что и ключ траты,
141
+ // и на сервер они попасть не должны - он видит только СУММУ половин просмотра, и этого хватает,
142
+ // чтобы видеть входящие, и недостаточно, чтобы их потратить.
143
+ for (const name of ["mnemonic", "seed", "seedHex", "privateSpendKey", "spendKey", "subaddressKeys", "spendHalf", "viewHalf"]) {
144
+ if (Object.hasOwn(share, name)) throw new Error("watch-only share не должен содержать " + name);
145
+ }
146
+ const json = JSON.stringify(share);
147
+ for (const [label, secret] of [
148
+ ["ключ траты", wallet.privateSpendKey],
149
+ ["мнемоника", wallet.mnemonic],
150
+ ["seed", wallet.seedHex],
151
+ ["половина ключа траты", wallet.spendHalf],
152
+ ["половина ключа просмотра", wallet.viewHalf],
153
+ ]) {
154
+ if (secret && json.includes(secret)) throw new Error(`в watch-only share попал секрет (${label})`);
155
+ }
156
+ return share;
157
+ }
158
+
159
+ // То, что уходит на сервер: адрес и view key - этого достаточно, чтобы видеть входящие
160
+ // на кошелёк, и недостаточно, чтобы их потратить.
161
+ export function watchOnlyShareFromWallet({ swapId = null, wallet, restoreHeight = null } = {}) {
162
+ if (!wallet || !wallet.address || !wallet.privateViewKey) {
163
+ throw new Error("для watch-only нужен кошелёк с адресом и приватным view key");
164
+ }
165
+ // Собираем по белому списку: поля кошелька, которых здесь нет, наружу не попадут никогда.
166
+ return assertNoSecretsInShare(
167
+ {
168
+ swapId: swapId || null,
169
+ network: wallet.network,
170
+ address: wallet.address,
171
+ privateViewKey: wallet.privateViewKey,
172
+ restoreHeight: Number.isFinite(restoreHeight) ? restoreHeight : null,
173
+ },
174
+ wallet
175
+ );
176
+ }