@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,55 +1,50 @@
1
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.
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
- // ИНДЕКСЕР MONERO: находим поступления на адрес сделки САМИ, разбирая блоки.
5
+ // MONERO INDEXER: we find arrivals at the swap address ourselves by parsing blocks.
6
+ // WHY. Before, arrivals were learned from monero-wallet-rpc through a watch-only wallet, a path that hit a defect
7
+ // (the wallet+keys pair is created and never reopens) and where one instance held one wallet, so scanning 25
8
+ // swaps took 140 s. Our own block parsing depends on neither.
9
+ // WHAT IS NEEDED: the VIEW KEY and the PUBLIC SPEND KEY - both on the client and already sent to the backend by
10
+ // an allowlist. The spend key is NOT needed: seeing arrivals and spending them are different rights, the
11
+ // non-custodial boundary.
12
+ // FORMULAS FROM THE MONERO SOURCES, not from memory:
13
+ // derivation = 8 * (view_secret * tx_public_key)
14
+ // scalar_i = Hs(derivation || varint(i))
15
+ // derived_key_i = scalar_i * G + spend_public_key
16
+ // amount_i = ecdhInfo[i].amount XOR Hs("amount" || derivation)[0..8] (RingCT v2)
17
+ // amount_i = ecdhInfo[i].amount - Hs(derivation) (RingCT v1)
18
+ // Both versions are implemented: old transactions in the chain are still there.
19
+ // keccak-256 from @noble/hashes, the same package as the SDK core: the standalone keccak256 npm package needs Buffer.
20
+ // THE unlock_time RULE IS THE SAME ONE, lives next door (./unlock.js) and is re-exported from here.
7
21
  //
8
- // ЗАЧЕМ. До этого поступления узнавались у monero-wallet-rpc через watch-only кошелёк
9
- // (generate_from_keys). Этот путь упёрся в дефект: пара «кошелёк + ключи» создаётся и больше не
10
- // открывается (.hermes/docs/19), а кроме того один экземпляр держит один кошелёк и обход 25 свопов
11
- // занимал 140 с. Свой разбор блоков не зависит ни от сборки кошелька, ни от числа свопов.
12
22
  //
13
- // ЧТО НУЖНО И ЧЕГО НЕ НУЖНО. Нужен КЛЮЧ ПРОСМОТРА и ПУБЛИЧНЫЙ КЛЮЧ ТРАТЫ - оба есть у клиента и оба уже
14
- // уходят на бэкенд по белому списку полей. Ключ траты НЕ нужен: смотреть поступления и распоряжаться
15
- // ими - разные права, и это ровно та граница, на которой стоит некастодиальность.
16
23
  //
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
24
 
25
25
  import { ed25519 } from "@noble/curves/ed25519.js";
26
- // keccak-256 - из @noble/hashes, того же пакета, что у ядра SDK: собственный npm-пакет keccak256 требует
27
- // Buffer и в браузер не входит. keccak_256 даёт тот же результат (проверено на известном значении).
28
26
  import { keccak_256 } from "@noble/hashes/sha3.js";
29
- // ПРАВИЛО unlock_time - ОДНО И ТО ЖЕ, живёт рядом (./unlock.js) и реэкспортируется отсюда, чтобы все, кто
30
- // ходил в app/indexer.mjs, получали те же имена.
31
27
  import { UNLOCK_TIME_BLOCK_MAX, unlockStateOf, laterBoundary } from "./unlock.js";
32
28
  export { UNLOCK_TIME_BLOCK_MAX, unlockStateOf, laterBoundary };
33
29
 
34
30
  export function keccak(bytes) { return keccak_256(bytes); }
35
31
 
36
- // Порядок группы edwards25519 (l). Берём у самой кривой, а не числом из памяти: ошибка в этой
37
- // константе сделала бы неверным и скаляр, и вывод, и сумму - то есть «невидимое поступление».
32
+ // Order of the edwards25519 group (l). Taken from the curve, not a remembered number: a wrong constant would
33
+ // corrupt the scalar, the derivation and the sum - an "invisible arrival".
38
34
  export const ED_ORDER = ed25519.Point.CURVE().n;
39
35
 
40
36
  export function hexToBytes(hex) {
41
37
  const t = String(hex).trim().replace(/^0x/, "");
42
- if (t.length % 2) throw new Error("нечётная длина hex");
38
+ if (t.length % 2) throw new Error("odd hex length");
43
39
  const out = new Uint8Array(t.length / 2);
44
40
  for (let i = 0; i < out.length; i++) out[i] = parseInt(t.substr(i * 2, 2), 16);
45
41
  return out;
46
42
  }
47
43
  export function bytesToHex(b) { return Array.from(b, (x) => x.toString(16).padStart(2, "0")).join(""); }
48
44
 
49
- // ПОРЯДОК БАЙТ - МАЛЕНЬКИЙ (little-endian). Это не придирка: Monero хранит скаляры младшим байтом
50
- // вперёд, и `sc_reduce32` толкует 32 байта хеша именно так. Чтение их большим порядком даёт ДРУГОЕ
51
- // число - скаляр выходит неверным, вывод не сходится, и поступление не находится МОЛЧА. Здесь на этом
52
- // уже один раз потеряли вечер.
45
+ // BYTE ORDER IS LITTLE-ENDIAN. Monero stores scalars least-significant first, and sc_reduce32 reads the 32 hash
46
+ // bytes that way. Reading them big-endian gives a DIFFERENT number, the derivation does not match, and the
47
+ // arrival is not found SILENTLY.
53
48
  export function scalarFromBytesLE(bytes) {
54
49
  const rev = Uint8Array.from(bytes).reverse();
55
50
  return BigInt("0x" + bytesToHex(rev));
@@ -62,7 +57,7 @@ export function scalarToBytesLE(v, n = 32) {
62
57
  return out;
63
58
  }
64
59
 
65
- // Хеш в скаляр: Hs(...) в Monero = keccak256, прочитанный МЛАДШИМ байтом вперёд, с приведением по l.
60
+ // Hash to scalar: Hs(...) in Monero = keccak256 read LEAST-SIGNIFICANT first, reduced mod l.
66
61
  export function hashToScalar(...parts) {
67
62
  const total = parts.reduce((n, p) => n + p.length, 0);
68
63
  const buf = new Uint8Array(total);
@@ -71,7 +66,7 @@ export function hashToScalar(...parts) {
71
66
  return scalarFromBytesLE(keccak(buf)) % ED_ORDER;
72
67
  }
73
68
 
74
- // Varint Monero (LEB128, 7 бит на байт, старший бит - продолжение).
69
+ // Monero varint (LEB128, 7 bits per byte, high bit = continuation).
75
70
  export function varint(n) {
76
71
  const out = [];
77
72
  let v = BigInt(n);
@@ -80,29 +75,27 @@ export function varint(n) {
80
75
  return new Uint8Array(out);
81
76
  }
82
77
 
83
- // Публичный ключ сделки проверяем на разборе: точка должна лежать на кривой.
78
+ // The swap public key is checked on parse: the point must lie on the curve.
84
79
  function pointFromHex(hex) { return ed25519.Point.fromHex(String(hex).replace(/^0x/, "")); }
85
80
 
86
- // derivation = 8 * (view_secret * tx_public_key) - ровно то, что делает generate_key_derivation.
81
+ // derivation = 8 * (view_secret * tx_public_key) - exactly what generate_key_derivation does.
87
82
  //
88
- // ПОРЯДОК БАЙТ: ЗДЕСЬ БЫЛА ТИХАЯ ПОТЕРЯ ПОСТУПЛЕНИЙ. Ключ просмотра приходит в двух видах, и вид не виден
89
- // по строке: из monero-rpc - как БАЙТЫ младшим вперёд, а из записи сделки (её пишет страница) - как ЧИСЛО в
90
- // hex. Чтение одного вида другим даёт другой скаляр: вывод не сходится, поступление не находится, и ни одной
91
- // ошибки при этом не пишется. На живом прогоне оплата на общий адрес ордера лежала на цепи, а индексер
92
- // честно докладывал ноль. Поэтому вид перебирается ЯВНО, а победивший называется в результате.
83
+ // BYTE ORDER: HERE WAS A SILENT LOSS OF ARRIVALS. The view key comes in two forms that look identical as a
84
+ // string: from monero-rpc as LITTLE-ENDIAN BYTES, from the swap record as a NUMBER in hex. Reading one as the
85
+ // other gives another scalar: the derivation does not match, the arrival is not found, and no error is written.
86
+ // So the form is tried EXPLICITLY and the winner is named in the result.
93
87
  export function viewSecretCandidates(viewSecret) {
94
- if (typeof viewSecret === "bigint") return [["число", viewSecret % ED_ORDER]];
88
+ if (typeof viewSecret === "bigint") return [["number", viewSecret % ED_ORDER]];
95
89
  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]];
90
+ if (!/^[0-9a-fA-F]{64}$/.test(hex)) return [["unparsed", null]];
91
+ // Both forms are reduced mod the group order: without this, a value larger than the order (any garbage string)
92
+ // cannot produce a point and the key check would crash instead of saying "no match".
93
+ return [["BE-number", BigInt("0x" + hex) % ED_ORDER], ["LE-bytes", scalarFromHexLE(hex) % ED_ORDER]];
100
94
  }
101
95
 
102
- // ПОДХОДИТ ЛИ КЛЮЧ ПРОСМОТРА К АДРЕСУ. Сверка ровно одна: точка ключа обязана совпасть с точкой просмотра,
103
- // записанной в адресе (её даёт decodeAddressKeys). Возврат: имя вида, которым совпало; false - не совпало НИ
104
- // ОДНИМ видом, то есть ключ от другого адреса, и обход по нему не найдёт ничего, сколько его ни запускай;
105
- // null - сверять не с чем.
96
+ // WHETHER THE VIEW KEY MATCHES THE ADDRESS. One comparison: the key point must equal the view point in the
97
+ // address. Return: the form name that matched; false - no form matched, so the key is from another address;
98
+ // null - nothing to compare.
106
99
  export function viewKeyFormForAddress(viewSecret, expectedViewPubHex) {
107
100
  const want = String(expectedViewPubHex || "").toLowerCase();
108
101
  if (!want) return null;
@@ -115,12 +108,12 @@ export function viewKeyFormForAddress(viewSecret, expectedViewPubHex) {
115
108
 
116
109
  export function keyDerivation(txPublicKeyHex, viewSecretHex) {
117
110
  const txPub = pointFromHex(txPublicKeyHex);
118
- // Ключ просмотра принимаем и как LE-байты из monero-rpc, и как число, если он уже разобран.
111
+ // The view key is accepted both as LE bytes from monero-rpc and as a number if already parsed.
119
112
  const a = (typeof viewSecretHex === "bigint" ? viewSecretHex : scalarFromHexLE(String(viewSecretHex).replace(/^0x/, ""))) % ED_ORDER;
120
113
  const shared = txPub.multiply(a);
121
- // ВАЖНО: возвращаем БАЙТЫ, а не hex-строку. Если отдать строку, дальше она поедет как «байты» строки
122
- // (в хеш уйдут ASCII-коды символов hex), и поступление просто не найдётся - молча и без ошибок.
123
- return hexToBytes(shared.multiply(8n).toHex()); // 32 байта точки (ge_tobytes) - это и есть derivation
114
+ // IMPORTANT: return BYTES, not a hex string. A string would later travel as the string's "bytes" (ASCII codes
115
+ // into the hash) and the arrival simply would not be found - silently.
116
+ return hexToBytes(shared.multiply(8n).toHex()); // 32-byte point (ge_tobytes) - this is the derivation
124
117
  }
125
118
 
126
119
  // derived_key_i = Hs(derivation || varint(i)) * G + B
@@ -130,9 +123,8 @@ export function derivedOutputKey(derivation, outputIndex, spendPubHex) {
130
123
  return point.toHex();
131
124
  }
132
125
 
133
- // Вспомогательное: tx public key лежит в extra транзакции, тег 0x01, 32 байта.
134
- // Читаем extra как есть - это тот же разбор, что делает кошелёк.
135
- // extra приходит из ноды МАССИВОМ БАЙТ (не hex-строкой), поэтому принимаем оба вида.
126
+ // Helper: the tx public key sits in the transaction extra, tag 0x01, 32 bytes. We read extra as is - the same
127
+ // parse the wallet does. extra arrives from the node as a BYTE ARRAY (not hex), so both forms are accepted.
136
128
  export function txPublicKeyFromExtra(extra) {
137
129
  const b = Array.isArray(extra) ? Uint8Array.from(extra) : hexToBytes(extra);
138
130
  let i = 0;
@@ -140,29 +132,28 @@ export function txPublicKeyFromExtra(extra) {
140
132
  const tag = b[i];
141
133
  if (tag === 0x01) return bytesToHex(b.subarray(i + 1, i + 33));
142
134
  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; // неизвестный тег: не гадаем
135
+ if (tag === 0x00) { i += 1 + b[i + 1]; continue; } // additional public key
136
+ return null; // unknown tag: we do not guess
145
137
  }
146
138
  return null;
147
139
  }
148
140
 
149
- // Расшифровка суммы. version: 2 = текущая схема (xor первых 8 байт), 1 = старая (вычитание скаляра).
141
+ // Amount decryption. version: 2 = current scheme (xor of the first 8 bytes), 1 = old (scalar subtraction).
150
142
  //
151
- // СЕКРЕТ - ЭТО СКАЛЯР ВЫВОДА, А НЕ DERIVATION. Здесь легко ошибиться: и то и другое - 32 байта, но в
152
- // genAmountEncodingFactor уходит именно скаляр (derivation_to_scalar), записанный МЛАДШИМ байтом вперёд.
153
- // С derivation результат получается правдоподобным на вид и НЕВЕРНЫМ по существу - проверил на живых
154
- // числах: правильный ответ 1000000000 atomic, с derivation выходило 8830061611517333961.
143
+ // THE SECRET IS THE DERIVATION SCALAR, NOT THE DERIVATION. Easy to get wrong: both are 32 bytes, but what goes
144
+ // into genAmountEncodingFactor is the scalar (derivation_to_scalar), written LEAST-SIGNIFICANT first. With the
145
+ // derivation the result looks plausible and is WRONG (checked on live numbers).
155
146
  export function decodeAmount(ecdhAmountHex, scalarLE, rctVersion) {
156
147
  const enc = hexToBytes(ecdhAmountHex);
157
148
  if (Number(rctVersion) >= 2) {
158
- // Множитель: keccak("amount" || скаляр_LE), 38 байт - порядок проверен по исходнику (rctOps.cpp).
149
+ // Multiplier: keccak("amount" || scalar_LE), 38 bytes - order checked against the source.
159
150
  const label = new TextEncoder().encode("amount");
160
151
  const buf = new Uint8Array(label.length + scalarLE.length);
161
152
  buf.set(label, 0); buf.set(scalarLE, label.length);
162
153
  const factor = keccak(buf);
163
154
  const out = enc.slice();
164
- for (let i = 0; i < 8; i++) out[i] ^= factor[i]; // xor ТОЛЬКО первых 8 байт
165
- // И снова МЛАДШИЙ байт вперёд: суммы Monero тоже little-endian.
155
+ for (let i = 0; i < 8; i++) out[i] ^= factor[i]; // xor of the FIRST 8 bytes only
156
+ // And again LEAST-SIGNIFICANT first: Monero amounts are little-endian too.
166
157
  return scalarFromBytesLE(out);
167
158
  }
168
159
  const s2 = hashToScalar(hexToBytes(hashToScalar(hexToBytes(bytesToHex(rctVersion === 1 ? scalarLE : scalarLE))).toString(16).padStart(64, "0")));
@@ -170,15 +161,14 @@ export function decodeAmount(ecdhAmountHex, scalarLE, rctVersion) {
170
161
  return (amt - s2 + ED_ORDER) % ED_ORDER;
171
162
  }
172
163
 
173
- // ГЛАВНОЕ: разобрать транзакцию и вернуть НАШИ выводы (её номера, ключи и суммы).
164
+ // MAIN: parse a transaction and return OUR outputs (their indices, keys and amounts).
174
165
  // txView - { txPublicKey, vout: [{key, index}], ecdhAmounts: [...], rctVersion }
175
166
  export function findOurOutputs({ txPublicKey, viewSecretHex, spendPubHex, outputs, ecdhAmounts = [], rctVersion = 2 }) {
176
- if (!txPublicKey) throw new Error("в транзакции нет публичного ключа (extra тег 0x01) - разбирать нечего");
177
- // Вид ключа перебираем ЯВНО (см. viewSecretCandidates): верным может оказаться любой из них, и имя
178
- // победившего уходит наверх - чтобы в журнале было видно, ЧЕМ считали. Если не нашли ничем, отдаём первый
179
- // осмысленный вид, а не пустоту: иначе в журнале будет «ничем», и причина снова спрячется.
167
+ if (!txPublicKey) throw new Error("the transaction has no public key (extra tag 0x01) - nothing to parse");
168
+ // The key form is tried EXPLICITLY; the winner's name goes up so the log shows what was used. If none matched,
169
+ // the first meaningful form is returned, not nothing.
180
170
  const usable = viewSecretCandidates(viewSecretHex).filter(([, v]) => v !== null);
181
- const first = usable[0] || ["не разобран", null];
171
+ const first = usable[0] || ["unparsed", null];
182
172
  for (const [form, secret] of usable) {
183
173
  const derivation = keyDerivation(txPublicKey, secret);
184
174
  const found = [];
@@ -186,7 +176,7 @@ export function findOurOutputs({ txPublicKey, viewSecretHex, spendPubHex, output
186
176
  const scalar = hashToScalar(derivation, varint(o.index));
187
177
  const derived = derivedOutputKey(derivation, o.index, spendPubHex);
188
178
  if (derived.toLowerCase() === String(o.key).toLowerCase()) {
189
- // В расшифровку суммы идёт СКАЛЯР ЭТОГО вывода, младшим байтом вперёд.
179
+ // The amount decryption uses THIS output's SCALAR, least-significant first.
190
180
  const amount = ecdhAmounts[o.index] ? decodeAmount(ecdhAmounts[o.index], scalarToBytesLE(scalar), rctVersion) : null;
191
181
  found.push({ index: o.index, key: o.key, amountAtomic: amount });
192
182
  }
@@ -201,13 +191,12 @@ export function findOurOutputs({ txPublicKey, viewSecretHex, spendPubHex, output
201
191
  }
202
192
 
203
193
  // ============================================================================================
204
- // ОБХОД ДИАПАЗОНА ВЫСОТ. Это и есть собственно индексация: читаем блоки подряд, из каждого берём
205
- // хеши транзакций и разбираем их ОДНИМ запросом на блок (нода принимает список хешей), а затем
206
- // проверяем выводы каждой транзакции против ВСЕХ наблюдаемых адресов сразу. Именно поэтому обход не
207
- // зависит от числа свопов: блок читается один раз для всех.
194
+ // SCANNING A HEIGHT RANGE. This is the indexing itself: read blocks in order, take their transaction hashes
195
+ // and parse them with ONE request per block, then check each transaction's outputs against ALL watched
196
+ // addresses at once. That is why the scan does not depend on the number of swaps.
208
197
  //
209
- // Чего здесь СОЗНАТЕЛЬНО нет: обрезки, подтверждений «по своей высоте», записи состояния в файлы.
210
- // Высота - это параметр вызова, состояние обхода решает вызывающий (у бэкенда это поле в базе).
198
+ // Deliberately NOT here: truncation, "height-based" confirmations, writing state to files. The height is a call
199
+ // parameter; the caller owns the scan state.
211
200
  // ============================================================================================
212
201
 
213
202
 
@@ -217,12 +206,12 @@ export async function fetchBlock(daemonUrl, height) {
217
206
  body: JSON.stringify({ jsonrpc: "2.0", id: "block", method: "get_block", params: { height } }),
218
207
  });
219
208
  const j = await res.json();
220
- if (!j.result) throw new Error(`блок ${height} не получен: ${j.error ? j.error.message : "пустой ответ"}`);
209
+ if (!j.result) throw new Error(`block ${height} not received: ${j.error ? j.error.message : "empty answer"}`);
221
210
  return j.result;
222
211
  }
223
212
 
224
- // Транзакции по списку хешей. Ходим на ПУТЬ /get_transactions, а не в /json_rpc: через JSON-RPC этот
225
- // метод молча возвращает пусто (проверено, док 19), и это выглядит как «транзакций нет».
213
+ // Transactions by hash list. We hit the PATH /get_transactions, not /json_rpc: over JSON-RPC this method
214
+ // silently returns empty, which looks like "no transactions".
226
215
  export async function fetchTransactions(daemonUrl, hashes) {
227
216
  if (!hashes.length) return [];
228
217
  const res = await fetch(`${String(daemonUrl).replace(/\/+$/, "")}/get_transactions`, {
@@ -231,9 +220,8 @@ export async function fetchTransactions(daemonUrl, hashes) {
231
220
  });
232
221
  const j = await res.json();
233
222
  const wrappers = j.txs || [];
234
- // ХЕШ И ВЫСОТА ЖИВУТ В ОБЁРТКЕ, а не в разобранном теле транзакции: в as_json их просто нет. Без этого
235
- // пришивания «найденный перевод» не имеет ни хеша, ни высоты - то есть бесполезен вызывающему, и при
236
- // этом всё выглядит успешным.
223
+ // HASH AND HEIGHT LIVE IN THE WRAPPER, not in the parsed body: as_json omits them. Without this stitching a
224
+ // "found transfer" would have neither hash nor height - useless to the caller, yet everything would look successful.
237
225
  return (j.txs_as_json || []).map((s, i) => {
238
226
  const body = typeof s === "string" ? JSON.parse(s) : s;
239
227
  const w = wrappers[i] || {};
@@ -245,15 +233,14 @@ export async function fetchTransactions(daemonUrl, hashes) {
245
233
  });
246
234
  }
247
235
 
248
- // Разбор одной транзакции против списка наблюдаемых адресов.
249
- // watched: [{ address, viewSecret, spendPub }] - viewSecret принимается и числом, и LE-hex, и BE-hex
250
- // (см. viewSecretCandidates). В результате едет viewKeyForm: имя вида, которым вывод сошёлся.
236
+ // Parsing one transaction against the watched addresses. watched: [{ address, viewSecret, spendPub }] - viewSecret
237
+ // is taken as a number, LE-hex or BE-hex. The result carries viewKeyForm: the form name that matched.
251
238
  export function matchTransaction(tx, watched, { height = null, nowSec = null } = {}) {
252
239
  const out = [];
253
240
  const txPublicKey = txPublicKeyFromExtra(tx.extra);
254
- if (!txPublicKey) return out; // нет ключа в extra - смотреть нечего
255
- // ЗАПЕРТ ЛИ ВЫХОД - РЕШАЕТСЯ ЗДЕСЬ, ОДИН РАЗ НА ТРАНЗАКЦИЮ: все её выходы несут один unlock_time.
256
- // Обычная транзакция (unlock_time = 0) идёт как раньше; высота берётся у блока, в котором лежит перевод.
241
+ if (!txPublicKey) return out; // no key in extra - nothing to look at
242
+ // WHETHER THE OUTPUT IS LOCKED IS DECIDED HERE, ONCE PER TRANSACTION: all its outputs carry one unlock_time.
243
+ // A normal transaction (unlock_time = 0) goes as before; the height comes from the block holding the transfer.
257
244
  const blockHeight = height === null || height === undefined ? (tx.block_height ?? null) : height;
258
245
  const unlock = unlockStateOf(tx.unlock_time, { height: blockHeight, nowSec });
259
246
  const keyOf = (o) => ((o.target || {}).tagged_key || o.target || {}).key;
@@ -270,17 +257,17 @@ export function matchTransaction(tx, watched, { height = null, nowSec = null } =
270
257
  return out;
271
258
  }
272
259
 
273
- // Обход диапазона. Возвращает по каждому адресу сумму (receivedAtomic - только РАЗБЛОКИРОВАННОЕ), хеши,
274
- // запертую сумму (lockedAtomic) с границей (lockedUntil) и лучшую (наибольшую) высоту найденного вывода.
260
+ // Range scan. Returns per address the sum (receivedAtomic - only the UNLOCKED), hashes, the locked sum
261
+ // (lockedAtomic) with its boundary (lockedUntil) and the best (highest) height of a found output.
275
262
  export async function scanRange({ daemonUrl, fromHeight, toHeight, watched, log = () => {}, concurrency = 4, nowSec = null }) {
276
263
  const from = Number(fromHeight), to = Number(toHeight);
277
- if (!(from >= 0) || !(to >= from)) throw new Error("диапазон высот задан неверно: " + fromHeight + ".." + toHeight);
264
+ if (!(from >= 0) || !(to >= from)) throw new Error("bad height range: " + fromHeight + ".." + toHeight);
278
265
  const acc = new Map();
279
266
  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
267
  const heights = [];
281
268
  for (let h = from; h <= to; h++) heights.push(h);
282
269
 
283
- // Ограниченный параллелизм: нода - общий ресурс, а блоки независимы.
270
+ // Bounded parallelism: the node is a shared resource and blocks are independent.
284
271
  let cursor = 0;
285
272
  const worker = async () => {
286
273
  while (cursor < heights.length) {
@@ -294,13 +281,13 @@ export async function scanRange({ daemonUrl, fromHeight, toHeight, watched, log
294
281
  for (const hit of matchTransaction(tx, watched, { height: h, nowSec })) {
295
282
  const a = acc.get(hit.address);
296
283
  if (!a) continue;
297
- if (hit.viewKeyForm) a.viewKeyForm = hit.viewKeyForm; // чем сошлось - это и уходит в журнал
284
+ if (hit.viewKeyForm) a.viewKeyForm = hit.viewKeyForm; // what matched - this goes to the log
298
285
  const lockedHere = !!(hit.unlock && hit.unlock.locked);
299
286
  const hash = String(tx.tx_hash || "").replace(/^0x/, "");
300
287
  for (const o of hit.outputs) {
301
288
  const amount = o.amountAtomic || 0n;
302
- // ЗАПЕРТЫЙ ВЫХОД - ЭТО НЕ ПРИХОД. Его перевод виден в txids (деньги на адресе есть), но сумма
303
- // НЕ идёт в receivedAtomic: иначе «готово» разрешило бы забор ETH под запертые XMR.
289
+ // A LOCKED OUTPUT IS NOT AN ARRIVAL. Its transfer shows in txids (money is on the address) but the
290
+ // sum does NOT go into receivedAtomic: else 'ready' would allow claiming ETH against locked XMR.
304
291
  if (lockedHere) {
305
292
  a.lockedAtomic += amount;
306
293
  if (hash && !a.lockedTxids.includes(hash)) a.lockedTxids.push(hash);
@@ -1,68 +1,65 @@
1
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.
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
- // КЛЮЧИ СОВМЕСТНОГО АДРЕСА ИЗ ПОЛОВИН. Разбор: .hermes/docs/27-half-release-by-settlement.md, раздел 2.
5
+ // JOINT ADDRESS KEYS FROM HALVES.
7
6
  //
8
- // ЧЕГО ЗДЕСЬ НЕТ И БЫТЬ НЕ ДОЛЖНО: полного ключа траты. Ни одна сторона не генерирует кошелёк свопа
9
- // целиком - каждая даёт СВОЮ половину, а адрес собирается как СУММА. Именно поэтому «забрать XMR и тут же
10
- // вернуть ETH» не получается: у противника нет чужой половины, и он не может потратить выход один.
11
- // Прежняя функция combineAddress в wallet.js собирала адрес иначе - из seed мейкера, то есть из полного
12
- // ключа на его стороне. Это кастодиальная схема, и она не часть того, что здесь, а то, от чего мы ушли.
7
+ // THERE IS NO FULL SPEND KEY HERE. Neither side generates the swap wallet whole: each gives ITS half and the
8
+ // address is their SUM. So "take the XMR and hand the ETH back" does not work - the counterparty lacks the other
9
+ // half and cannot spend the output alone. The old combineAddress assembled the address from the maker's seed,
10
+ // i.e. a full key on one side - a custodial scheme we moved away from.
13
11
  //
14
- // АРИФМЕТИКА. Monero - edwards25519. Ключ траты адреса есть СУММА половин, публичная точка адреса -
15
- // СУММА точек. Это одно и то же, потому что (a+b)G == aG + bG, и именно на этом равенстве держится
16
- // разделение: адрес знают оба, потратить может только тот, у кого обе половины.
12
+ // ARITHMETIC. Monero is edwards25519. The address spend key is the SUM of halves and the public point is the SUM
13
+ // of points, because (a+b)G == aG + bG. That equality is what the split rests on: both sides know the address,
14
+ // only the holder of both halves can spend.
17
15
  //
18
- // ДВЕ РАЗНЫЕ ОПЕРАЦИИ, И ИХ НЕЛЬЗЯ СЛИВАТЬ:
19
- // 1) АДРЕС строится из ПУБЛИЧНЫХ данных: своя половина + чужая ТОЧКА. Секрет контрагента не нужен.
20
- // Это делается сразу при заведении ордера.
21
- // 2) КЛЮЧ ТРАТЫ (s_a + s_b) появляется только ПОСЛЕ того, как вторая половина раскрыта расчётом.
22
- // До этого его нет ни у кого - иначе схема не работала бы.
16
+ // TWO OPERATIONS, NOT TO BE MERGED:
17
+ // 1) THE ADDRESS is built from PUBLIC data: own half + the other side's POINT. No secret of the counterparty is
18
+ // needed, so it is built when the order is created.
19
+ // 2) THE SPEND KEY (s_a + s_b) appears only AFTER the second half is revealed by settlement.
23
20
 
24
- // Пункт (1): адрес из публичных данных. Ничего секретного о контрагенте здесь не требуется.
21
+ // (1) Address from public data: nothing secret about the counterparty is needed.
25
22
  export function swapAddressFromHalves({ halves, addressFromKeys, deps, ownSpendHalf, ownViewHalf, otherSpendPoint, otherViewPoint, network }) {
26
- if (!halves || !addressFromKeys) throw new Error("нужны halves и addressFromKeys");
23
+ if (!halves || !addressFromKeys) throw new Error("halves and addressFromKeys are required");
27
24
  for (const [name, v] of Object.entries({ ownSpendHalf, ownViewHalf, otherSpendPoint, otherViewPoint })) {
28
- if (v === undefined || v === null || v === "") throw new Error("не задано: " + name);
25
+ if (v === undefined || v === null || v === "") throw new Error("not set: " + name);
29
26
  }
30
27
  const ownSpendPub = halves.publicHalves(ownSpendHalf).ed;
31
28
  const ownViewPub = halves.publicHalves(ownViewHalf).ed;
32
29
  const spendPub = halves.combinePublic(ownSpendPub, otherSpendPoint);
33
30
  const viewPub = halves.combinePublic(ownViewPub, otherViewPoint);
34
- // Строка адреса собирается тем же энкодером, что и раньше: сеть берётся из конфига, поэтому работают
35
- // и mainnet, и stagenet. Меняется только источник ключей - он теперь половины, а не seed.
36
- // deps = { keccak256, Buffer }: кодировщик адреса считает контрольную сумму сам, полагаться на глобали нельзя.
31
+ // The address string uses the same encoder as before: the network comes from the config, so mainnet and
32
+ // stagenet both work. Only the key source changed - halves, not a seed.
33
+ // deps = { keccak256, Buffer }: the encoder computes the checksum itself and must not rely on globals.
37
34
  const address = addressFromKeys({ network, kind: "primary", spendPub, viewPub }, deps);
38
35
  return { address, spendPub, viewPub, ownSpendPub, ownViewPub };
39
36
  }
40
37
 
41
- // Пункт (2): ключ траты после раскрытия второй половины, С ОБЯЗАТЕЛЬНОЙ ПРОВЕРКОЙ.
38
+ // (2) Spend key after the second half is revealed, WITH A MANDATORY CHECK.
42
39
  //
43
- // ПРОВЕРКА ЗДЕСЬ - НЕ ФОРМАЛЬНОСТЬ, А ТО, РАДИ ЧЕГО ВСЁ. Раскрытая половина может оказаться не той:
44
- // по схеме контракт пока принимает любую, чей хеш совпал с обязательством, поэтому единственное место,
45
- // где связь «раскрытая половина ↔ точка адреса» подтверждается, - вот это сравнение. Сверяем
46
- // (s_a + s_b)*G с публичным ключом траты, который был в ордере: не сошлось - НЕ метём и говорим вслух.
40
+ // THE CHECK IS THE POINT. The revealed half may be the wrong one: the contract accepts any half whose hash
41
+ // matches the commitment, so this comparison is the only place the link "revealed half <-> address point" is
42
+ // confirmed. We compare (s_a + s_b)*G with the spend public key from the order: no match - we do NOT sweep and
43
+ // say so aloud.
47
44
  export function sweepSpendSecret({ halves, ownSpendHalf, revealedOtherHalf, expectedSpendPub }) {
48
- if (!halves) throw new Error("нужны halves");
45
+ if (!halves) throw new Error("halves are required");
49
46
  for (const [name, v] of Object.entries({ ownSpendHalf, revealedOtherHalf, expectedSpendPub })) {
50
- if (v === undefined || v === null || v === "") throw new Error("не задано: " + name);
47
+ if (v === undefined || v === null || v === "") throw new Error("not set: " + name);
51
48
  }
52
49
  const sum = halves.combineHalves(ownSpendHalf, BigInt(revealedOtherHalf));
53
50
  const sumPub = halves.publicHalves(sum).ed;
54
51
  const norm = (h) => "0x" + String(h).replace(/^0x/i, "").toLowerCase();
55
52
  if (norm(sumPub) !== norm(expectedSpendPub)) {
56
53
  throw new Error(
57
- "раскрытая половина не соответствует адресу: (s_a + s_b)*G != публичный ключ траты ордера. " +
58
- "Метать нельзя - половина чужая или из другого ордера"
54
+ "the revealed half does not match the address: (s_a + s_b)*G != the order's spend public key. " +
55
+ "Do not sweep - the half is foreign or from another order"
59
56
  );
60
57
  }
61
58
  return { spendSecret: sum, spendPub: sumPub };
62
59
  }
63
60
 
64
- // Ключ просмотра для мониторинга входа: тоже сумма, но у него своя пара половин.
65
- // Отдельная функция, потому что просмотр и трата - разные секреты, и путать их нельзя.
61
+ // The view key for watching the arrival: also a sum, but from its own pair of halves. A separate function
62
+ // because watching and spending are different secrets and must not be confused.
66
63
  export function watchViewSeedHex({ halves, ownViewHalf, otherViewHalf }) {
67
64
  const sum = halves.combineHalves(ownViewHalf, BigInt(otherViewHalf));
68
65
  return "0x" + sum.toString(16).padStart(64, "0");
@@ -1,28 +1,25 @@
1
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.
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
- // ВРЕМЯ РАЗБЛОКИРОВКИ ВЫХОДА (unlock_time). ОДНО ПРАВИЛО НА ВСЕХ, КТО РЕШАЕТ «ПРИХОД ЕСТЬ».
5
+ // OUTPUT UNLOCK TIME (unlock_time). ONE RULE FOR EVERYONE WHO DECIDES "ARRIVAL IS THERE".
7
6
  //
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).
7
+ // A Monero transaction has an unlock_time field. Zero - "the output is open in the normal order". A non-zero
8
+ // value FORBIDS spending the output until the given boundary, and the boundary is of two kinds:
9
+ // * less than 500 000 000 - this is a BLOCK HEIGHT (CRYPTONOTE_MAX_BLOCK_NUMBER in the Monero sources);
10
+ // * 500 000 000 and more - this is a TIME (unix seconds).
11
+ // Until the boundary has passed, the money LIES at the address but cannot be spent: this is NOT an arrival
12
+ // that can close the swap. A maker who sent the correct amount with unlock_time five years ahead received ETH
13
+ // on the "done" mark, while the XMR stayed locked.
15
14
  //
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
- // зеркало обязано совпасть с этим файлом до байта, и расхождение ловит проверка, а не человек.
15
+ // WHY THE ONE SOURCE LIVES HERE. The rule is read by the backend indexer through the scan module and by the
16
+ // SDK core through the engine mirror. A second copy would mean a second truth about money, so there is no copy:
17
+ // the mirror must match this file to the byte, and a divergence between them is caught mechanically, not by a human.
21
18
  export const UNLOCK_TIME_BLOCK_MAX = 500_000_000;
22
19
 
23
- // Состояние выхода по его unlock_time. Возврат - НАЗВАННЫЙ результат, а не булево: интерфейс должен
24
- // объяснить человеку, ДО ЧЕГО именно заперты деньги (высоты или даты), а на каком языке это сказать - дело
25
- // словаря интерфейса, а не ядра.
20
+ // Output state by its unlock_time. The return is a NAMED result, not a boolean: the interface must
21
+ // explain to the person UP TO WHAT exactly the money is locked (heights or dates), and in what language to
22
+ // say it is the business of the interface dictionary, not of the core.
26
23
  export function unlockStateOf(unlockTime, { height = null, nowSec = null } = {}) {
27
24
  const u = Number(unlockTime) || 0;
28
25
  if (u <= 0) return { code: "unlocked", locked: false, untilHeight: null, untilTime: null };
@@ -36,8 +33,8 @@ export function unlockStateOf(unlockTime, { height = null, nowSec = null } = {})
36
33
  return { code: open ? "unlocked" : "locked_height", locked: !open, untilHeight: u, untilTime: null };
37
34
  }
38
35
 
39
- // Насколько «поздняя» граница: из двух запертых переводов в состоянии записи оставляем ту, что открывается
40
- // позже, - иначе по одному из них показали бы «скоро откроется», когда второй держит деньги дальше.
36
+ // How "late" the boundary is: of two locked transfers in the record state we keep the one that opens
37
+ // later - otherwise one of them would show "opening soon" while the second holds the money longer.
41
38
  export function laterBoundary(state) {
42
39
  if (!state) return -1;
43
40
  if (state.untilTime !== null && state.untilTime !== undefined) return Number(state.untilTime);