@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,325 @@
1
+ // GENERATED FILE - a byte-for-byte copy of the engine module www/js/monero/scan.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
+ // ИНДЕКСЕР MONERO: находим поступления на адрес сделки САМИ, разбирая блоки.
7
+ //
8
+ // ЗАЧЕМ. До этого поступления узнавались у monero-wallet-rpc через watch-only кошелёк
9
+ // (generate_from_keys). Этот путь упёрся в дефект: пара «кошелёк + ключи» создаётся и больше не
10
+ // открывается (.hermes/docs/19), а кроме того один экземпляр держит один кошелёк и обход 25 свопов
11
+ // занимал 140 с. Свой разбор блоков не зависит ни от сборки кошелька, ни от числа свопов.
12
+ //
13
+ // ЧТО НУЖНО И ЧЕГО НЕ НУЖНО. Нужен КЛЮЧ ПРОСМОТРА и ПУБЛИЧНЫЙ КЛЮЧ ТРАТЫ - оба есть у клиента и оба уже
14
+ // уходят на бэкенд по белому списку полей. Ключ траты НЕ нужен: смотреть поступления и распоряжаться
15
+ // ими - разные права, и это ровно та граница, на которой стоит некастодиальность.
16
+ //
17
+ // ФОРМУЛЫ - ИЗ ИСХОДНИКОВ MONERO, не по памяти (src/crypto/crypto.cpp, src/ringct/rctOps.cpp):
18
+ // derivation = 8 * (view_secret * tx_public_key) - точка, 32 байта (generate_key_derivation)
19
+ // scalar_i = Hs(derivation || varint(i)) - хеш в скаляр (derivation_to_scalar)
20
+ // derived_key_i = scalar_i * G + spend_public_key - (derive_public_key); совпал с выводом - наш
21
+ // amount_i = ecdhInfo[i].amount XOR Hs("amount" || derivation)[0..8] - RingCT v2 (текущий)
22
+ // amount_i = ecdhInfo[i].amount - Hs(derivation) - RingCT v1 (старое)
23
+ // Здесь реализованы обе версии: старые транзакции в цепочке никуда не делись.
24
+
25
+ import { ed25519 } from "@noble/curves/ed25519.js";
26
+ // keccak-256 - из @noble/hashes, того же пакета, что у ядра SDK: собственный npm-пакет keccak256 требует
27
+ // Buffer и в браузер не входит. keccak_256 даёт тот же результат (проверено на известном значении).
28
+ import { keccak_256 } from "@noble/hashes/sha3.js";
29
+ // ПРАВИЛО unlock_time - ОДНО И ТО ЖЕ, живёт рядом (./unlock.js) и реэкспортируется отсюда, чтобы все, кто
30
+ // ходил в app/indexer.mjs, получали те же имена.
31
+ import { UNLOCK_TIME_BLOCK_MAX, unlockStateOf, laterBoundary } from "./unlock.js";
32
+ export { UNLOCK_TIME_BLOCK_MAX, unlockStateOf, laterBoundary };
33
+
34
+ export function keccak(bytes) { return keccak_256(bytes); }
35
+
36
+ // Порядок группы edwards25519 (l). Берём у самой кривой, а не числом из памяти: ошибка в этой
37
+ // константе сделала бы неверным и скаляр, и вывод, и сумму - то есть «невидимое поступление».
38
+ export const ED_ORDER = ed25519.Point.CURVE().n;
39
+
40
+ export function hexToBytes(hex) {
41
+ const t = String(hex).trim().replace(/^0x/, "");
42
+ if (t.length % 2) throw new Error("нечётная длина hex");
43
+ const out = new Uint8Array(t.length / 2);
44
+ for (let i = 0; i < out.length; i++) out[i] = parseInt(t.substr(i * 2, 2), 16);
45
+ return out;
46
+ }
47
+ export function bytesToHex(b) { return Array.from(b, (x) => x.toString(16).padStart(2, "0")).join(""); }
48
+
49
+ // ПОРЯДОК БАЙТ - МАЛЕНЬКИЙ (little-endian). Это не придирка: Monero хранит скаляры младшим байтом
50
+ // вперёд, и `sc_reduce32` толкует 32 байта хеша именно так. Чтение их большим порядком даёт ДРУГОЕ
51
+ // число - скаляр выходит неверным, вывод не сходится, и поступление не находится МОЛЧА. Здесь на этом
52
+ // уже один раз потеряли вечер.
53
+ export function scalarFromBytesLE(bytes) {
54
+ const rev = Uint8Array.from(bytes).reverse();
55
+ return BigInt("0x" + bytesToHex(rev));
56
+ }
57
+ export function scalarFromHexLE(hex) { return scalarFromBytesLE(hexToBytes(hex)); }
58
+ export function scalarToBytesLE(v, n = 32) {
59
+ const out = new Uint8Array(n);
60
+ let x = BigInt(v);
61
+ for (let i = 0; i < n; i++) { out[i] = Number(x & 0xffn); x >>= 8n; }
62
+ return out;
63
+ }
64
+
65
+ // Хеш в скаляр: Hs(...) в Monero = keccak256, прочитанный МЛАДШИМ байтом вперёд, с приведением по l.
66
+ export function hashToScalar(...parts) {
67
+ const total = parts.reduce((n, p) => n + p.length, 0);
68
+ const buf = new Uint8Array(total);
69
+ let o = 0;
70
+ for (const p of parts) { buf.set(p, o); o += p.length; }
71
+ return scalarFromBytesLE(keccak(buf)) % ED_ORDER;
72
+ }
73
+
74
+ // Varint Monero (LEB128, 7 бит на байт, старший бит - продолжение).
75
+ export function varint(n) {
76
+ const out = [];
77
+ let v = BigInt(n);
78
+ while (v >= 0x80n) { out.push(Number((v & 0x7fn) | 0x80n)); v >>= 7n; }
79
+ out.push(Number(v));
80
+ return new Uint8Array(out);
81
+ }
82
+
83
+ // Публичный ключ сделки проверяем на разборе: точка должна лежать на кривой.
84
+ function pointFromHex(hex) { return ed25519.Point.fromHex(String(hex).replace(/^0x/, "")); }
85
+
86
+ // derivation = 8 * (view_secret * tx_public_key) - ровно то, что делает generate_key_derivation.
87
+ //
88
+ // ПОРЯДОК БАЙТ: ЗДЕСЬ БЫЛА ТИХАЯ ПОТЕРЯ ПОСТУПЛЕНИЙ. Ключ просмотра приходит в двух видах, и вид не виден
89
+ // по строке: из monero-rpc - как БАЙТЫ младшим вперёд, а из записи сделки (её пишет страница) - как ЧИСЛО в
90
+ // hex. Чтение одного вида другим даёт другой скаляр: вывод не сходится, поступление не находится, и ни одной
91
+ // ошибки при этом не пишется. На живом прогоне оплата на общий адрес ордера лежала на цепи, а индексер
92
+ // честно докладывал ноль. Поэтому вид перебирается ЯВНО, а победивший называется в результате.
93
+ export function viewSecretCandidates(viewSecret) {
94
+ if (typeof viewSecret === "bigint") return [["число", viewSecret % ED_ORDER]];
95
+ const hex = String(viewSecret).replace(/^0x/, "");
96
+ if (!/^[0-9a-fA-F]{64}$/.test(hex)) return [["не разобран", null]];
97
+ // ОБА вида приводим по модулю порядка группы: без этого значение, большее порядка (а такое даёт любая
98
+ // мусорная строка), не даст посчитать точку, и проверка ключа упадёт вместо того, чтобы сказать «не подошёл».
99
+ return [["BE-число", BigInt("0x" + hex) % ED_ORDER], ["LE-байты", scalarFromHexLE(hex) % ED_ORDER]];
100
+ }
101
+
102
+ // ПОДХОДИТ ЛИ КЛЮЧ ПРОСМОТРА К АДРЕСУ. Сверка ровно одна: точка ключа обязана совпасть с точкой просмотра,
103
+ // записанной в адресе (её даёт decodeAddressKeys). Возврат: имя вида, которым совпало; false - не совпало НИ
104
+ // ОДНИМ видом, то есть ключ от другого адреса, и обход по нему не найдёт ничего, сколько его ни запускай;
105
+ // null - сверять не с чем.
106
+ export function viewKeyFormForAddress(viewSecret, expectedViewPubHex) {
107
+ const want = String(expectedViewPubHex || "").toLowerCase();
108
+ if (!want) return null;
109
+ for (const [form, v] of viewSecretCandidates(viewSecret)) {
110
+ if (v === null) continue;
111
+ if (ed25519.Point.BASE.multiply(v).toHex().toLowerCase() === want) return form;
112
+ }
113
+ return false;
114
+ }
115
+
116
+ export function keyDerivation(txPublicKeyHex, viewSecretHex) {
117
+ const txPub = pointFromHex(txPublicKeyHex);
118
+ // Ключ просмотра принимаем и как LE-байты из monero-rpc, и как число, если он уже разобран.
119
+ const a = (typeof viewSecretHex === "bigint" ? viewSecretHex : scalarFromHexLE(String(viewSecretHex).replace(/^0x/, ""))) % ED_ORDER;
120
+ const shared = txPub.multiply(a);
121
+ // ВАЖНО: возвращаем БАЙТЫ, а не hex-строку. Если отдать строку, дальше она поедет как «байты» строки
122
+ // (в хеш уйдут ASCII-коды символов hex), и поступление просто не найдётся - молча и без ошибок.
123
+ return hexToBytes(shared.multiply(8n).toHex()); // 32 байта точки (ge_tobytes) - это и есть derivation
124
+ }
125
+
126
+ // derived_key_i = Hs(derivation || varint(i)) * G + B
127
+ export function derivedOutputKey(derivation, outputIndex, spendPubHex) {
128
+ const scalar = hashToScalar(derivation, varint(outputIndex));
129
+ const point = ed25519.Point.BASE.multiply(scalar).add(pointFromHex(spendPubHex));
130
+ return point.toHex();
131
+ }
132
+
133
+ // Вспомогательное: tx public key лежит в extra транзакции, тег 0x01, 32 байта.
134
+ // Читаем extra как есть - это тот же разбор, что делает кошелёк.
135
+ // extra приходит из ноды МАССИВОМ БАЙТ (не hex-строкой), поэтому принимаем оба вида.
136
+ export function txPublicKeyFromExtra(extra) {
137
+ const b = Array.isArray(extra) ? Uint8Array.from(extra) : hexToBytes(extra);
138
+ let i = 0;
139
+ while (i < b.length) {
140
+ const tag = b[i];
141
+ if (tag === 0x01) return bytesToHex(b.subarray(i + 1, i + 33));
142
+ if (tag === 0x02) { i += 1 + b[i + 1]; continue; } // nonce
143
+ if (tag === 0x00) { i += 1 + b[i + 1]; continue; } // дополнительный public key
144
+ return null; // неизвестный тег: не гадаем
145
+ }
146
+ return null;
147
+ }
148
+
149
+ // Расшифровка суммы. version: 2 = текущая схема (xor первых 8 байт), 1 = старая (вычитание скаляра).
150
+ //
151
+ // СЕКРЕТ - ЭТО СКАЛЯР ВЫВОДА, А НЕ DERIVATION. Здесь легко ошибиться: и то и другое - 32 байта, но в
152
+ // genAmountEncodingFactor уходит именно скаляр (derivation_to_scalar), записанный МЛАДШИМ байтом вперёд.
153
+ // С derivation результат получается правдоподобным на вид и НЕВЕРНЫМ по существу - проверил на живых
154
+ // числах: правильный ответ 1000000000 atomic, с derivation выходило 8830061611517333961.
155
+ export function decodeAmount(ecdhAmountHex, scalarLE, rctVersion) {
156
+ const enc = hexToBytes(ecdhAmountHex);
157
+ if (Number(rctVersion) >= 2) {
158
+ // Множитель: keccak("amount" || скаляр_LE), 38 байт - порядок проверен по исходнику (rctOps.cpp).
159
+ const label = new TextEncoder().encode("amount");
160
+ const buf = new Uint8Array(label.length + scalarLE.length);
161
+ buf.set(label, 0); buf.set(scalarLE, label.length);
162
+ const factor = keccak(buf);
163
+ const out = enc.slice();
164
+ for (let i = 0; i < 8; i++) out[i] ^= factor[i]; // xor ТОЛЬКО первых 8 байт
165
+ // И снова МЛАДШИЙ байт вперёд: суммы Monero тоже little-endian.
166
+ return scalarFromBytesLE(out);
167
+ }
168
+ const s2 = hashToScalar(hexToBytes(hashToScalar(hexToBytes(bytesToHex(rctVersion === 1 ? scalarLE : scalarLE))).toString(16).padStart(64, "0")));
169
+ const amt = BigInt("0x" + bytesToHex(enc)) % ED_ORDER;
170
+ return (amt - s2 + ED_ORDER) % ED_ORDER;
171
+ }
172
+
173
+ // ГЛАВНОЕ: разобрать транзакцию и вернуть НАШИ выводы (её номера, ключи и суммы).
174
+ // txView - { txPublicKey, vout: [{key, index}], ecdhAmounts: [...], rctVersion }
175
+ export function findOurOutputs({ txPublicKey, viewSecretHex, spendPubHex, outputs, ecdhAmounts = [], rctVersion = 2 }) {
176
+ if (!txPublicKey) throw new Error("в транзакции нет публичного ключа (extra тег 0x01) - разбирать нечего");
177
+ // Вид ключа перебираем ЯВНО (см. viewSecretCandidates): верным может оказаться любой из них, и имя
178
+ // победившего уходит наверх - чтобы в журнале было видно, ЧЕМ считали. Если не нашли ничем, отдаём первый
179
+ // осмысленный вид, а не пустоту: иначе в журнале будет «ничем», и причина снова спрячется.
180
+ const usable = viewSecretCandidates(viewSecretHex).filter(([, v]) => v !== null);
181
+ const first = usable[0] || ["не разобран", null];
182
+ for (const [form, secret] of usable) {
183
+ const derivation = keyDerivation(txPublicKey, secret);
184
+ const found = [];
185
+ for (const o of outputs) {
186
+ const scalar = hashToScalar(derivation, varint(o.index));
187
+ const derived = derivedOutputKey(derivation, o.index, spendPubHex);
188
+ if (derived.toLowerCase() === String(o.key).toLowerCase()) {
189
+ // В расшифровку суммы идёт СКАЛЯР ЭТОГО вывода, младшим байтом вперёд.
190
+ const amount = ecdhAmounts[o.index] ? decodeAmount(ecdhAmounts[o.index], scalarToBytesLE(scalar), rctVersion) : null;
191
+ found.push({ index: o.index, key: o.key, amountAtomic: amount });
192
+ }
193
+ }
194
+ if (found.length) return { derivation: bytesToHex(derivation), outputs: found, viewKeyForm: form };
195
+ }
196
+ return {
197
+ derivation: first[1] === null ? null : bytesToHex(keyDerivation(txPublicKey, first[1])),
198
+ outputs: [],
199
+ viewKeyForm: first[0],
200
+ };
201
+ }
202
+
203
+ // ============================================================================================
204
+ // ОБХОД ДИАПАЗОНА ВЫСОТ. Это и есть собственно индексация: читаем блоки подряд, из каждого берём
205
+ // хеши транзакций и разбираем их ОДНИМ запросом на блок (нода принимает список хешей), а затем
206
+ // проверяем выводы каждой транзакции против ВСЕХ наблюдаемых адресов сразу. Именно поэтому обход не
207
+ // зависит от числа свопов: блок читается один раз для всех.
208
+ //
209
+ // Чего здесь СОЗНАТЕЛЬНО нет: обрезки, подтверждений «по своей высоте», записи состояния в файлы.
210
+ // Высота - это параметр вызова, состояние обхода решает вызывающий (у бэкенда это поле в базе).
211
+ // ============================================================================================
212
+
213
+
214
+ export async function fetchBlock(daemonUrl, height) {
215
+ const res = await fetch(`${String(daemonUrl).replace(/\/+$/, "")}/json_rpc`, {
216
+ method: "POST", headers: { "Content-Type": "application/json" },
217
+ body: JSON.stringify({ jsonrpc: "2.0", id: "block", method: "get_block", params: { height } }),
218
+ });
219
+ const j = await res.json();
220
+ if (!j.result) throw new Error(`блок ${height} не получен: ${j.error ? j.error.message : "пустой ответ"}`);
221
+ return j.result;
222
+ }
223
+
224
+ // Транзакции по списку хешей. Ходим на ПУТЬ /get_transactions, а не в /json_rpc: через JSON-RPC этот
225
+ // метод молча возвращает пусто (проверено, док 19), и это выглядит как «транзакций нет».
226
+ export async function fetchTransactions(daemonUrl, hashes) {
227
+ if (!hashes.length) return [];
228
+ const res = await fetch(`${String(daemonUrl).replace(/\/+$/, "")}/get_transactions`, {
229
+ method: "POST", headers: { "Content-Type": "application/json" },
230
+ body: JSON.stringify({ txs_hashes: hashes, decode_as_json: true }),
231
+ });
232
+ const j = await res.json();
233
+ const wrappers = j.txs || [];
234
+ // ХЕШ И ВЫСОТА ЖИВУТ В ОБЁРТКЕ, а не в разобранном теле транзакции: в as_json их просто нет. Без этого
235
+ // пришивания «найденный перевод» не имеет ни хеша, ни высоты - то есть бесполезен вызывающему, и при
236
+ // этом всё выглядит успешным.
237
+ return (j.txs_as_json || []).map((s, i) => {
238
+ const body = typeof s === "string" ? JSON.parse(s) : s;
239
+ const w = wrappers[i] || {};
240
+ body.tx_hash = w.tx_hash || body.tx_hash || null;
241
+ body.block_height = w.block_height ?? null;
242
+ body.confirmations = w.confirmations ?? null;
243
+ body.in_pool = w.in_pool ?? null;
244
+ return body;
245
+ });
246
+ }
247
+
248
+ // Разбор одной транзакции против списка наблюдаемых адресов.
249
+ // watched: [{ address, viewSecret, spendPub }] - viewSecret принимается и числом, и LE-hex, и BE-hex
250
+ // (см. viewSecretCandidates). В результате едет viewKeyForm: имя вида, которым вывод сошёлся.
251
+ export function matchTransaction(tx, watched, { height = null, nowSec = null } = {}) {
252
+ const out = [];
253
+ const txPublicKey = txPublicKeyFromExtra(tx.extra);
254
+ if (!txPublicKey) return out; // нет ключа в extra - смотреть нечего
255
+ // ЗАПЕРТ ЛИ ВЫХОД - РЕШАЕТСЯ ЗДЕСЬ, ОДИН РАЗ НА ТРАНЗАКЦИЮ: все её выходы несут один unlock_time.
256
+ // Обычная транзакция (unlock_time = 0) идёт как раньше; высота берётся у блока, в котором лежит перевод.
257
+ const blockHeight = height === null || height === undefined ? (tx.block_height ?? null) : height;
258
+ const unlock = unlockStateOf(tx.unlock_time, { height: blockHeight, nowSec });
259
+ const keyOf = (o) => ((o.target || {}).tagged_key || o.target || {}).key;
260
+ const outputs = (tx.vout || []).map((o, i) => ({ index: i, key: keyOf(o) }));
261
+ const ecdhAmounts = ((tx.rct_signatures || {}).ecdhInfo || []).map((e) => e.amount);
262
+ const rctVersion = (tx.rct_signatures || {}).type || 2;
263
+ for (const w of watched) {
264
+ const r = findOurOutputs({
265
+ txPublicKey, viewSecretHex: w.viewSecret, spendPubHex: w.spendPub,
266
+ outputs, ecdhAmounts, rctVersion: Number(rctVersion) >= 2 ? 2 : 1,
267
+ });
268
+ if (r.outputs.length) out.push({ address: w.address, index: w.index ?? null, outputs: r.outputs, derivation: r.derivation, viewKeyForm: r.viewKeyForm, unlockTime: Number(tx.unlock_time) || 0, unlock });
269
+ }
270
+ return out;
271
+ }
272
+
273
+ // Обход диапазона. Возвращает по каждому адресу сумму (receivedAtomic - только РАЗБЛОКИРОВАННОЕ), хеши,
274
+ // запертую сумму (lockedAtomic) с границей (lockedUntil) и лучшую (наибольшую) высоту найденного вывода.
275
+ export async function scanRange({ daemonUrl, fromHeight, toHeight, watched, log = () => {}, concurrency = 4, nowSec = null }) {
276
+ const from = Number(fromHeight), to = Number(toHeight);
277
+ if (!(from >= 0) || !(to >= from)) throw new Error("диапазон высот задан неверно: " + fromHeight + ".." + toHeight);
278
+ const acc = new Map();
279
+ for (const w of watched) acc.set(w.address, { address: w.address, receivedAtomic: 0n, lockedAtomic: 0n, lockedUntil: null, lockedTxids: [], txids: [], bestHeight: null, outputs: 0, viewKeyForm: null });
280
+ const heights = [];
281
+ for (let h = from; h <= to; h++) heights.push(h);
282
+
283
+ // Ограниченный параллелизм: нода - общий ресурс, а блоки независимы.
284
+ let cursor = 0;
285
+ const worker = async () => {
286
+ while (cursor < heights.length) {
287
+ const h = heights[cursor++];
288
+ try {
289
+ const block = await fetchBlock(daemonUrl, h);
290
+ const hashes = block.json ? (typeof block.json === "string" ? JSON.parse(block.json) : block.json).tx_hashes || [] : [];
291
+ if (!hashes.length) continue;
292
+ const txs = await fetchTransactions(daemonUrl, hashes);
293
+ for (const tx of txs) {
294
+ for (const hit of matchTransaction(tx, watched, { height: h, nowSec })) {
295
+ const a = acc.get(hit.address);
296
+ if (!a) continue;
297
+ if (hit.viewKeyForm) a.viewKeyForm = hit.viewKeyForm; // чем сошлось - это и уходит в журнал
298
+ const lockedHere = !!(hit.unlock && hit.unlock.locked);
299
+ const hash = String(tx.tx_hash || "").replace(/^0x/, "");
300
+ for (const o of hit.outputs) {
301
+ const amount = o.amountAtomic || 0n;
302
+ // ЗАПЕРТЫЙ ВЫХОД - ЭТО НЕ ПРИХОД. Его перевод виден в txids (деньги на адресе есть), но сумма
303
+ // НЕ идёт в receivedAtomic: иначе «готово» разрешило бы забор ETH под запертые XMR.
304
+ if (lockedHere) {
305
+ a.lockedAtomic += amount;
306
+ if (hash && !a.lockedTxids.includes(hash)) a.lockedTxids.push(hash);
307
+ if (laterBoundary(hit.unlock) > laterBoundary(a.lockedUntil)) a.lockedUntil = hit.unlock;
308
+ } else {
309
+ a.receivedAtomic += amount;
310
+ a.outputs += 1;
311
+ }
312
+ if (hash && !a.txids.includes(hash)) a.txids.push(hash);
313
+ if (a.bestHeight === null || h > a.bestHeight) a.bestHeight = h;
314
+ }
315
+ }
316
+ }
317
+ } catch (e) {
318
+ log({ event: "indexer_block_error", height: h, error: String(e.message).slice(0, 160) });
319
+ }
320
+ }
321
+ };
322
+ await Promise.all(Array.from({ length: Math.max(1, Math.min(concurrency, heights.length)) }, worker));
323
+ return { fromHeight: from, toHeight: to, results: Array.from(acc.values()) };
324
+ }
325
+
@@ -0,0 +1,69 @@
1
+ // GENERATED FILE - a byte-for-byte copy of the engine module www/js/monero/swapKeys.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
+ // КЛЮЧИ СОВМЕСТНОГО АДРЕСА ИЗ ПОЛОВИН. Разбор: .hermes/docs/27-half-release-by-settlement.md, раздел 2.
7
+ //
8
+ // ЧЕГО ЗДЕСЬ НЕТ И БЫТЬ НЕ ДОЛЖНО: полного ключа траты. Ни одна сторона не генерирует кошелёк свопа
9
+ // целиком - каждая даёт СВОЮ половину, а адрес собирается как СУММА. Именно поэтому «забрать XMR и тут же
10
+ // вернуть ETH» не получается: у противника нет чужой половины, и он не может потратить выход один.
11
+ // Прежняя функция combineAddress в wallet.js собирала адрес иначе - из seed мейкера, то есть из полного
12
+ // ключа на его стороне. Это кастодиальная схема, и она не часть того, что здесь, а то, от чего мы ушли.
13
+ //
14
+ // АРИФМЕТИКА. Monero - edwards25519. Ключ траты адреса есть СУММА половин, публичная точка адреса -
15
+ // СУММА точек. Это одно и то же, потому что (a+b)G == aG + bG, и именно на этом равенстве держится
16
+ // разделение: адрес знают оба, потратить может только тот, у кого обе половины.
17
+ //
18
+ // ДВЕ РАЗНЫЕ ОПЕРАЦИИ, И ИХ НЕЛЬЗЯ СЛИВАТЬ:
19
+ // 1) АДРЕС строится из ПУБЛИЧНЫХ данных: своя половина + чужая ТОЧКА. Секрет контрагента не нужен.
20
+ // Это делается сразу при заведении ордера.
21
+ // 2) КЛЮЧ ТРАТЫ (s_a + s_b) появляется только ПОСЛЕ того, как вторая половина раскрыта расчётом.
22
+ // До этого его нет ни у кого - иначе схема не работала бы.
23
+
24
+ // Пункт (1): адрес из публичных данных. Ничего секретного о контрагенте здесь не требуется.
25
+ export function swapAddressFromHalves({ halves, addressFromKeys, deps, ownSpendHalf, ownViewHalf, otherSpendPoint, otherViewPoint, network }) {
26
+ if (!halves || !addressFromKeys) throw new Error("нужны halves и addressFromKeys");
27
+ for (const [name, v] of Object.entries({ ownSpendHalf, ownViewHalf, otherSpendPoint, otherViewPoint })) {
28
+ if (v === undefined || v === null || v === "") throw new Error("не задано: " + name);
29
+ }
30
+ const ownSpendPub = halves.publicHalves(ownSpendHalf).ed;
31
+ const ownViewPub = halves.publicHalves(ownViewHalf).ed;
32
+ const spendPub = halves.combinePublic(ownSpendPub, otherSpendPoint);
33
+ const viewPub = halves.combinePublic(ownViewPub, otherViewPoint);
34
+ // Строка адреса собирается тем же энкодером, что и раньше: сеть берётся из конфига, поэтому работают
35
+ // и mainnet, и stagenet. Меняется только источник ключей - он теперь половины, а не seed.
36
+ // deps = { keccak256, Buffer }: кодировщик адреса считает контрольную сумму сам, полагаться на глобали нельзя.
37
+ const address = addressFromKeys({ network, kind: "primary", spendPub, viewPub }, deps);
38
+ return { address, spendPub, viewPub, ownSpendPub, ownViewPub };
39
+ }
40
+
41
+ // Пункт (2): ключ траты после раскрытия второй половины, С ОБЯЗАТЕЛЬНОЙ ПРОВЕРКОЙ.
42
+ //
43
+ // ПРОВЕРКА ЗДЕСЬ - НЕ ФОРМАЛЬНОСТЬ, А ТО, РАДИ ЧЕГО ВСЁ. Раскрытая половина может оказаться не той:
44
+ // по схеме контракт пока принимает любую, чей хеш совпал с обязательством, поэтому единственное место,
45
+ // где связь «раскрытая половина ↔ точка адреса» подтверждается, - вот это сравнение. Сверяем
46
+ // (s_a + s_b)*G с публичным ключом траты, который был в ордере: не сошлось - НЕ метём и говорим вслух.
47
+ export function sweepSpendSecret({ halves, ownSpendHalf, revealedOtherHalf, expectedSpendPub }) {
48
+ if (!halves) throw new Error("нужны halves");
49
+ for (const [name, v] of Object.entries({ ownSpendHalf, revealedOtherHalf, expectedSpendPub })) {
50
+ if (v === undefined || v === null || v === "") throw new Error("не задано: " + name);
51
+ }
52
+ const sum = halves.combineHalves(ownSpendHalf, BigInt(revealedOtherHalf));
53
+ const sumPub = halves.publicHalves(sum).ed;
54
+ const norm = (h) => "0x" + String(h).replace(/^0x/i, "").toLowerCase();
55
+ if (norm(sumPub) !== norm(expectedSpendPub)) {
56
+ throw new Error(
57
+ "раскрытая половина не соответствует адресу: (s_a + s_b)*G != публичный ключ траты ордера. " +
58
+ "Метать нельзя - половина чужая или из другого ордера"
59
+ );
60
+ }
61
+ return { spendSecret: sum, spendPub: sumPub };
62
+ }
63
+
64
+ // Ключ просмотра для мониторинга входа: тоже сумма, но у него своя пара половин.
65
+ // Отдельная функция, потому что просмотр и трата - разные секреты, и путать их нельзя.
66
+ export function watchViewSeedHex({ halves, ownViewHalf, otherViewHalf }) {
67
+ const sum = halves.combineHalves(ownViewHalf, BigInt(otherViewHalf));
68
+ return "0x" + sum.toString(16).padStart(64, "0");
69
+ }
@@ -0,0 +1,46 @@
1
+ // GENERATED FILE - a byte-for-byte copy of the engine module www/js/monero/unlock.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
+ // ВРЕМЯ РАЗБЛОКИРОВКИ ВЫХОДА (unlock_time). ОДНО ПРАВИЛО НА ВСЕХ, КТО РЕШАЕТ «ПРИХОД ЕСТЬ».
7
+ //
8
+ // У транзакции Monero есть поле unlock_time. Ноль - «выход открыт обычным порядком». Ненулевое значение
9
+ // ЗАПРЕЩАЕТ тратить выход до указанной границы, и граница бывает двух видов:
10
+ // * меньше 500 000 000 - это ВЫСОТА блока (CRYPTONOTE_MAX_BLOCK_NUMBER в исходниках Monero);
11
+ // * 500 000 000 и больше - это ВРЕМЯ (unix-секунды).
12
+ // Пока граница не пройдена, деньги ЛЕЖАТ на адресе, но потратить их нельзя: это НЕ приход, которым можно
13
+ // закрыть сделку. Мейкер, отправивший верную сумму с unlock_time на пять лет вперёд, получал ETH по отметке
14
+ // «готово», а XMR оставались запертыми (issue #84).
15
+ //
16
+ // ПОЧЕМУ ЗДЕСЬ, ПОД www/js. Это ЕДИНСТВЕННЫЙ источник правила. Его читает бэкенд-индексер
17
+ // (app/indexer.mjs -> ../www/js/monero/scan.js) и ЯДРО SDK - через ШТАТНОЕ ЗЕРКАЛО ДВИЖКА:
18
+ // sdk/engine/monero/unlock.js, собирается tools/build-sdk-engine.mjs и сверяется побайтно сторожем
19
+ // tools/check-sdk-engine.mjs. Вторая копия означала бы вторую правду о деньгах, поэтому копии нет:
20
+ // зеркало обязано совпасть с этим файлом до байта, и расхождение ловит проверка, а не человек.
21
+ export const UNLOCK_TIME_BLOCK_MAX = 500_000_000;
22
+
23
+ // Состояние выхода по его unlock_time. Возврат - НАЗВАННЫЙ результат, а не булево: интерфейс должен
24
+ // объяснить человеку, ДО ЧЕГО именно заперты деньги (высоты или даты), а на каком языке это сказать - дело
25
+ // словаря интерфейса, а не ядра.
26
+ export function unlockStateOf(unlockTime, { height = null, nowSec = null } = {}) {
27
+ const u = Number(unlockTime) || 0;
28
+ if (u <= 0) return { code: "unlocked", locked: false, untilHeight: null, untilTime: null };
29
+ if (u >= UNLOCK_TIME_BLOCK_MAX) {
30
+ const now = nowSec === null || nowSec === undefined ? Math.floor(Date.now() / 1000) : Number(nowSec);
31
+ const open = Number.isFinite(now) && now >= u;
32
+ return { code: open ? "unlocked" : "locked_time", locked: !open, untilHeight: null, untilTime: u };
33
+ }
34
+ const h = height === null || height === undefined ? null : Number(height);
35
+ const open = h !== null && Number.isFinite(h) && h >= u;
36
+ return { code: open ? "unlocked" : "locked_height", locked: !open, untilHeight: u, untilTime: null };
37
+ }
38
+
39
+ // Насколько «поздняя» граница: из двух запертых переводов в состоянии записи оставляем ту, что открывается
40
+ // позже, - иначе по одному из них показали бы «скоро откроется», когда второй держит деньги дальше.
41
+ export function laterBoundary(state) {
42
+ if (!state) return -1;
43
+ if (state.untilTime !== null && state.untilTime !== undefined) return Number(state.untilTime);
44
+ if (state.untilHeight !== null && state.untilHeight !== undefined) return Number(state.untilHeight);
45
+ return -1;
46
+ }
@@ -0,0 +1,168 @@
1
+ // GENERATED FILE - a byte-for-byte copy of the engine module www/js/monero/wallet.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
+ // Одноразовый Monero-кошелёк свапа.
7
+ //
8
+ // Ключи считает monero-js (ebellocchia/monero-js, MIT, вендорен в www/assets/vendors/monero-js):
9
+ // мнемоника и ключи чистым JS - реальные и мгновенные. Библиотека умеет только mainnet-префиксы
10
+ // адресов, поэтому строку адреса собирает наш энкодер (monero/address.js) для сети из конфига -
11
+ // так работает и stagenet (24/36).
12
+ //
13
+ // Ключи НЕ покидают браузер: наружу (на бэкенд, для watch-only кошелька) уходит только view key,
14
+ // и делает это отдельная явная функция - recovery/restore.js::watchOnlyShareFromWallet. Так это
15
+ // и описано в litepaper §5.4: кошелёк одноразовый, на одну сделку, seed основного кошелька
16
+ // пользователя никогда не импортируется.
17
+
18
+ import { MONERO } from "../core/config.js";
19
+ import { randomHex, toHex } from "../core/format.js";
20
+ import { addressFromKeys } from "./address.js";
21
+
22
+ const B58 = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz";
23
+
24
+ function base58(bytes) {
25
+ let num = 0n;
26
+ for (const b of bytes) num = num * 256n + BigInt(b);
27
+ let out = "";
28
+ while (num > 0n) {
29
+ out = B58[Number(num % 58n)] + out;
30
+ num /= 58n;
31
+ }
32
+ return out;
33
+ }
34
+
35
+ // Псевдо-адрес для мок-режима: правильная длина (95) и префикс сети, но БЕЗ checksum.
36
+ // В UI он всегда помечен как mock - чтобы никто не отправил туда XMR.
37
+
38
+ // ---------------------------------------------------------------------------
39
+ // monero-js (мнемоника + ключи + адреса, чистый JS)
40
+ // ---------------------------------------------------------------------------
41
+
42
+ let moneroJsModule = null;
43
+ let moneroJsError = null;
44
+
45
+ export async function loadMoneroJs() {
46
+ if (moneroJsModule) return moneroJsModule;
47
+ if (moneroJsError) throw new Error(moneroJsError);
48
+ try {
49
+ const mod = await import("../../assets/vendors/monero/monero-js-browser.js?v=05c682ea");
50
+ moneroJsModule = mod.default || mod;
51
+ return moneroJsModule;
52
+ } catch (e) {
53
+ // Причину не подменяем: «бандл не собран», «не залит» и «заблокирован CSP» - разные проблемы
54
+ moneroJsError = "monero-js unavailable: " + e.message +
55
+ " (соберите npm run build:vendor; про CSP см. .hermes/docs/06-deploy.md)";
56
+ throw new Error(moneroJsError);
57
+ }
58
+ }
59
+
60
+ async function withMoneroJs({ mnemonic: phrase, network = MONERO.networkType } = {}) {
61
+ const lib = await loadMoneroJs();
62
+ await lib.wallet.initEcc();
63
+
64
+ // Если фразу передали - она обязана быть валидной. Раньше невалидная фраза молча заменялась
65
+ // свежесгенерированной, и восстановление из испорченного recovery-файла давало ДРУГОЙ кошелёк:
66
+ // ошибка выглядела бы как успех, а средства остались бы недоступны. Генерация - только когда
67
+ // фразы нет вовсе.
68
+ let words;
69
+ if (phrase) {
70
+ if (!lib.mnemonic.isValid(phrase)) {
71
+ throw new Error("мнемоника не прошла проверку контрольной суммы (файл или форма повреждены?)");
72
+ }
73
+ words = String(phrase).trim();
74
+ } else {
75
+ words = lib.mnemonic.generateWithChecksum();
76
+ }
77
+ const seed = lib.mnemonic.toSeed(words); // 32 байта: для Monero сид == декодированная мнемоника
78
+ const w = lib.wallet.fromSeed(seed);
79
+
80
+ const net = network || "mainnet";
81
+ const deps = { keccak256: lib.keccak256, Buffer: lib.Buffer };
82
+ const spendPub = toHex(w.publicSpendKey);
83
+ const viewPub = toHex(w.publicViewKey);
84
+ const sub = w.subaddress(1, 0);
85
+ // адрес своей сети собираем сами (библиотека умеет только mainnet);
86
+ // addressMainnet оставляем для сверки: на mainnet они обязаны совпадать
87
+ const address = addressFromKeys({ network: net, kind: "primary", spendPub, viewPub }, deps);
88
+ const subaddress = addressFromKeys(
89
+ { network: net, kind: "subaddress", spendPub: toHex(sub.publicSpendKey), viewPub: toHex(sub.publicViewKey) },
90
+ deps
91
+ );
92
+
93
+ return {
94
+ source: "monero-js",
95
+ library: "ebellocchia/monero-js (MIT)",
96
+ network: net,
97
+ mnemonic: words,
98
+ mnemonicWords: words.split(" ").length,
99
+ seedHex: toHex(seed),
100
+ privateSpendKey: toHex(w.privateSpendKey),
101
+ privateViewKey: toHex(w.privateViewKey),
102
+ publicSpendKey: spendPub,
103
+ publicViewKey: viewPub,
104
+ address,
105
+ subaddress,
106
+ addressMainnet: w.primaryAddress().encode(),
107
+ createdAt: Date.now(),
108
+ };
109
+ }
110
+
111
+ // ---------------------------------------------------------------------------
112
+ // monero-ts убран
113
+ // ---------------------------------------------------------------------------
114
+ // Здесь был второй режим кошелька - WebAssembly wallet2 (woodser/monero-ts), который собирался
115
+ // в www/assets/vendors/monero/monero-ts-browser.js. Он удалён из демки по двум причинам:
116
+ // 1) WASM-кошельку нужен SharedArrayBuffer, то есть COOP/COEP, а на домене демки COEP заблокировал
117
+ // бы сторонние ресурсы (шрифты, Verify WalletConnect);
118
+ // 2) сам бандл переехал в sweep/ - у него свой домен с COOP/COEP (см. .hermes/docs/09-sweep-origin.md).
119
+ // Раньше здесь называлась третья причина - «политика демки не пускает webpack-сборку monero-ts, она
120
+ // использует eval». Причина была настоящей, но перестала ею быть: с monero-ts 0.11.16 бандл под
121
+ // строгой политикой (script-src 'self' 'wasm-unsafe-eval', без 'unsafe-eval') работает, и это
122
+ // доказано прогоном tools/check-csp-eval.mjs - прежний 0.11.15 под ней же падает на импорте.
123
+ // Пока ветка кода оставалась, она тянула import по несуществующему пути - то есть была мёртвой
124
+ // и при этом выглядела рабочей. Ключи в демке считает monero-js, и это единственный путь.
125
+
126
+ // ---------------------------------------------------------------------------
127
+
128
+ export async function createSwapWallet({ network = MONERO.networkType, seed } = {}) {
129
+ return withMoneroJs({ mnemonic: seed, network });
130
+ }
131
+
132
+ // Доступна ли библиотека ключей (панель Demo controls показывает это одной строкой).
133
+ export async function probeMode() {
134
+ try {
135
+ const lib = await loadMoneroJs();
136
+ await lib.wallet.initEcc();
137
+ return { available: true, info: "mnemonic + keys + addresses (" + MONERO.networkType + ")" };
138
+ } catch (e) {
139
+ return { available: false, reason: e.message };
140
+ }
141
+ }
142
+
143
+ // Совместный адрес свапа. В реальном протоколе это адрес из СУММЫ публичных ключей
144
+ // двух сторон (S_a + S_b, V_a + V_b): потратить выход можно только собрав обе половины
145
+ // spend-ключа, а DLEQ-доказательство связывает половины с секретом.
146
+ // В демке сумма считается по хешу от ключей: интерфейс и поток те же, криптография подменена явно.
147
+ export function combineAddress({ swapId, userPublicSpendKey, userPublicViewKey, makerKeySeed }) {
148
+ const mk = (salt) => {
149
+ const payload = `${salt}|${swapId}|${userPublicSpendKey}|${userPublicViewKey}`;
150
+ let hh = 0x811c9dc5;
151
+ for (let i = 0; i < payload.length; i++) {
152
+ hh ^= payload.charCodeAt(i);
153
+ hh = (hh * 0x01000193) >>> 0;
154
+ }
155
+ return hh.toString(16).padStart(8, "0") + randomHex(28);
156
+ };
157
+ return {
158
+ mock: true,
159
+ address: "8" + mk(makerKeySeed).slice(0, 94),
160
+ spendShare: "0x" + mk("spend-share").slice(0, 64),
161
+ viewShare: "0x" + mk("view-share").slice(0, 64),
162
+ note: "mock: в реальном протоколе это сумма публичных ключей обеих сторон с DLEQ-доказательством",
163
+ };
164
+ }
165
+
166
+ export function networkLabel(network = MONERO.networkType) {
167
+ return { mainnet: "Monero mainnet", stagenet: "Monero stagenet", testnet: "Monero testnet" }[network] || network;
168
+ }