@ninsei-labs/sdk 0.51.2 → 0.53.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.
- package/CHANGELOG.md +252 -0
- package/README.md +76 -3
- package/engine/atomic/order-worker.js +29 -2
- package/engine/atomic/order.js +24 -0
- package/engine/core/config.js +16 -29
- package/engine/core/store.js +60 -12
- package/engine/evm/factory.js +9 -2
- package/engine/evm/funding.js +65 -18
- package/engine/monero/arrivalRead.js +3 -22
- package/engine/monero/halvesSweep.js +5 -1
- package/engine/monero/scan.js +144 -33
- package/index.d.ts +35 -21
- package/package.json +23 -3
- package/src/adapters/monero-wallet.mjs +13 -6
- package/src/adapters/monero.mjs +5 -7
- package/src/codes.mjs +4 -0
- package/src/config.mjs +6 -3
- package/src/decline.mjs +6 -1
- package/src/feeEstimate.mjs +20 -13
- package/src/index.mjs +5 -4
- package/src/legs/index.mjs +8 -0
- package/src/legs/profiles.mjs +51 -0
- package/src/lock.mjs +4 -6
- package/src/order.mjs +53 -7
- package/src/public/networks.d.ts +14 -1
- package/src/public/recovery.d.ts +46 -8
- package/src/public/recovery.mjs +5 -0
- package/src/public/xmr.d.ts +26 -8
- package/src/quotes.mjs +165 -2
- package/src/recovery-key.mjs +203 -54
- package/src/recovery.mjs +9 -1
- package/src/swap-flow.mjs +3 -2
- package/src/swaps.mjs +204 -79
- package/src/sweep-fees.mjs +32 -0
- package/src/sweep-restore.mjs +30 -9
- package/src/sweep.mjs +28 -5
- package/src/version.mjs +1 -1
- package/src/xmr-arrival.mjs +76 -32
package/src/recovery-key.mjs
CHANGED
|
@@ -17,7 +17,7 @@ import { generateMnemonic, mnemonicToSeedSync, validateMnemonic } from "@scure/b
|
|
|
17
17
|
import { wordlist } from "@scure/bip39/wordlists/english.js";
|
|
18
18
|
import { createHalves } from "../engine/atomic/halves.js";
|
|
19
19
|
import { halfCommitmentHex } from "../engine/atomic/orderHalfCommit.js";
|
|
20
|
-
import { ORDER_CREATED_TOPIC } from "../engine/evm/factory.js";
|
|
20
|
+
import { ORDER_CREATED_TOPIC, ORDER_CREATED_TOPICS } from "../engine/evm/factory.js";
|
|
21
21
|
import { SdkError } from "./errors.mjs";
|
|
22
22
|
import { probeMoneroNode } from "./probes.mjs";
|
|
23
23
|
import { ed25519, keccak256, randomBytes, secp256k1 } from "./primitives.mjs";
|
|
@@ -27,9 +27,16 @@ const halves = createHalves({ ed25519, secp256k1, keccak256, randomBytes });
|
|
|
27
27
|
/** A BIP39 mnemonic with 128 bits of entropy: twelve words. */
|
|
28
28
|
export const RECOVERY_KEY_VERSION = 1;
|
|
29
29
|
export const RECOVERY_KEY_WORDS = 12;
|
|
30
|
+
// The allocator's ceiling. Discovery must use the same range: a search that stops earlier can miss an order
|
|
31
|
+
// whose number was skipped by an abandoned start or by a swap on another chain.
|
|
32
|
+
export const RECOVERY_INDEX_MAX = 100000;
|
|
30
33
|
export const RECOVERY_KEY_STORE = "ninsei.recovery.key.v1";
|
|
31
34
|
export const RECOVERY_KEY_FILE_MAGIC = "NINSEI RECOVERY KEY";
|
|
32
35
|
export const RECOVERY_KEY_DOMAIN = "ninsei-taker-v1";
|
|
36
|
+
// THE SALT-BASED VERSION. A v1 number is the same at every factory and every device; a v2 binding names the fresh
|
|
37
|
+
// order salt, the factory and the role, so two orders cannot share a taker half even when the words are restored
|
|
38
|
+
// elsewhere. The salt is already public in `OrderCreated`; recovery reads it back instead of reserving a number.
|
|
39
|
+
export const RECOVERY_KEY_DOMAIN_V2 = "ninsei-taker-v2";
|
|
33
40
|
// The counterparty's view half is derived from the SAME ONE KEY, in a domain of its own. The taker sends this
|
|
34
41
|
// number to the maker (the node), which uses it as its own view half; the taker keeps it, so the joint view key
|
|
35
42
|
// can be rebuilt from the twelve words without any server.
|
|
@@ -40,6 +47,7 @@ export const RECOVERY_KEY_ITER = 600000;
|
|
|
40
47
|
const ITER_MIN = 600000;
|
|
41
48
|
const ITER_MAX = 6000000;
|
|
42
49
|
|
|
50
|
+
const ADDRESS_RE = /^0x[0-9a-fA-F]{40}$/;
|
|
43
51
|
const te = new TextEncoder();
|
|
44
52
|
const td = new TextDecoder();
|
|
45
53
|
|
|
@@ -121,8 +129,38 @@ export function recoveryBinding(chainId, index) {
|
|
|
121
129
|
* package is a hex string, and a BigInt that reaches JSON.stringify fails the whole record.
|
|
122
130
|
*/
|
|
123
131
|
export function takerHalves({ mnemonic, chainId, index }) {
|
|
124
|
-
|
|
125
|
-
|
|
132
|
+
return takerHalvesFromSeed(seedOf(mnemonic), chainId, index);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// THE SAME KEY, A PUBLIC PER-ORDER SALT. The factory and role are in the binding, so the same words, chain and
|
|
136
|
+
// salt at another factory produce another half. `role` is fixed for the taker's side; the maker's view half is
|
|
137
|
+
// separated by its own domain. Salt is the `salt` word from `OrderCreated`, never a secret.
|
|
138
|
+
export function recoveryBindingV2({ chainId, factory, role = "taker", salt } = {}) {
|
|
139
|
+
if (typeof factory !== "string" || !ADDRESS_RE.test(factory)) throw bad("factory");
|
|
140
|
+
if (typeof role !== "string" || !/^[a-z0-9-]{1,32}$/.test(role)) throw bad("role");
|
|
141
|
+
return `${RECOVERY_KEY_DOMAIN_V2}|${numberField(chainId, "chainId")}|${factory.toLowerCase()}|${role}|${recoverySaltOf(salt)}`;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
export function takerHalvesV2({ mnemonic, chainId, factory, role = "taker", salt } = {}) {
|
|
145
|
+
const normalized = recoverySaltOf(salt);
|
|
146
|
+
const binding = recoveryBindingV2({ chainId, factory, role, salt: normalized });
|
|
147
|
+
return {
|
|
148
|
+
...halvesFromBinding(seedOf(mnemonic), binding),
|
|
149
|
+
index: null,
|
|
150
|
+
derivation: "salt-v2",
|
|
151
|
+
factory: factory.toLowerCase(),
|
|
152
|
+
role,
|
|
153
|
+
salt: normalized,
|
|
154
|
+
};
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// Discovery derives thousands of numbers from one mnemonic. The BIP39 seed is the expensive part (PBKDF2), so it
|
|
158
|
+
// is computed once by the caller and passed here; the per-number work is only HMAC and a curve point.
|
|
159
|
+
function takerHalvesFromSeed(seed, chainId, index) {
|
|
160
|
+
return { index: numberField(index, "index"), ...halvesFromBinding(seed, recoveryBinding(chainId, index)) };
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function halvesFromBinding(seed, binding) {
|
|
126
164
|
const spendHalf = halves.halfFromSeed(seed, binding, halves.HALF_DOMAIN);
|
|
127
165
|
const viewHalf = halves.halfFromSeed(seed, binding, halves.VIEW_HALF_DOMAIN);
|
|
128
166
|
// THE OTHER SIDE'S VIEW HALF. The same key, a third domain: the taker gives this number to the maker, so the
|
|
@@ -132,7 +170,6 @@ export function takerHalves({ mnemonic, chainId, index }) {
|
|
|
132
170
|
const view = halves.publicHalves(viewHalf);
|
|
133
171
|
const makerView = halves.publicHalves(makerViewHalf);
|
|
134
172
|
return {
|
|
135
|
-
index: numberField(index, "index"),
|
|
136
173
|
binding,
|
|
137
174
|
spendHalf: hex32(spendHalf),
|
|
138
175
|
viewHalf: hex32(viewHalf),
|
|
@@ -144,6 +181,41 @@ export function takerHalves({ mnemonic, chainId, index }) {
|
|
|
144
181
|
};
|
|
145
182
|
}
|
|
146
183
|
|
|
184
|
+
// DISCOVERY NEEDS ONE COMMITMENT, NOT THREE POINTS. The view and maker-view halves are not compared during a
|
|
185
|
+
// search, and their Edwards points are the expensive part of `takerHalves`; deriving only the spend scalar keeps
|
|
186
|
+
// a full-range scan practical.
|
|
187
|
+
function spendCommitmentFromSeed(seed, chainId, index) {
|
|
188
|
+
const spendHalf = halves.halfFromSeed(seed, recoveryBinding(chainId, index), halves.HALF_DOMAIN);
|
|
189
|
+
return halfCommitmentHex(spendHalf, secp256k1, keccak256).toLowerCase();
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function spendCommitmentFromSeedV2(seed, { chainId, factory, role = "taker", salt }) {
|
|
193
|
+
const binding = recoveryBindingV2({ chainId, factory, role, salt });
|
|
194
|
+
const spendHalf = halves.halfFromSeed(seed, binding, halves.HALF_DOMAIN);
|
|
195
|
+
return halfCommitmentHex(spendHalf, secp256k1, keccak256).toLowerCase();
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// The salt is the 32-byte word from the order, not a secret and not a tag. Lower-case it so the binding is stable
|
|
199
|
+
// across an event decoder and a caller that hand-wrote the value.
|
|
200
|
+
/** A fresh public salt for one v2 order. The client owns it; a maker must not hand the same value back twice. */
|
|
201
|
+
export function newRecoverySalt() {
|
|
202
|
+
return "0x" + Array.from(randomBytes(32), (byte) => byte.toString(16).padStart(2, "0")).join("");
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
export function recoverySaltOf(value) {
|
|
206
|
+
if (typeof value === "bigint") return saltField("0x" + value.toString(16).padStart(64, "0"));
|
|
207
|
+
if (typeof value === "number" && Number.isSafeInteger(value) && value >= 0) {
|
|
208
|
+
return saltField("0x" + BigInt(value).toString(16).padStart(64, "0"));
|
|
209
|
+
}
|
|
210
|
+
return saltField(value);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
function saltField(value) {
|
|
214
|
+
const salt = String(value == null ? "" : value).trim().toLowerCase();
|
|
215
|
+
if (!/^0x[0-9a-f]{64}$/.test(salt)) throw bad("salt");
|
|
216
|
+
return salt;
|
|
217
|
+
}
|
|
218
|
+
|
|
147
219
|
// ---------------------------------------------------------------------------------------------------
|
|
148
220
|
// THE BACKUP FILE
|
|
149
221
|
// ---------------------------------------------------------------------------------------------------
|
|
@@ -274,23 +346,31 @@ export async function openRecoveryKeyFile(contents, passphrase = "") {
|
|
|
274
346
|
// RESERVATION
|
|
275
347
|
// ---------------------------------------------------------------------------------------------------
|
|
276
348
|
|
|
349
|
+
const storageFailure = (step) => new SdkError("storage-unavailable", { step });
|
|
277
350
|
const readStore = (storage) => {
|
|
278
351
|
if (!storage || typeof storage.get !== "function" || typeof storage.set !== "function" || typeof storage.remove !== "function") throw bad("storage");
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
} catch {
|
|
286
|
-
|
|
287
|
-
|
|
352
|
+
let raw;
|
|
353
|
+
try { raw = storage.get(RECOVERY_KEY_STORE); } catch { throw storageFailure("read"); }
|
|
354
|
+
// NOTHING STORED IS NOT AN UNREADABLE STORE. `null`/absent means no key yet; a throwing read or a corrupt
|
|
355
|
+
// record must be named, because the next start would otherwise overwrite a key it could not read.
|
|
356
|
+
if (raw === undefined || raw === null || raw === "") return null;
|
|
357
|
+
let value;
|
|
358
|
+
try { value = JSON.parse(raw); } catch { throw storageFailure("parse"); }
|
|
359
|
+
if (!value || value.version !== RECOVERY_KEY_VERSION || typeof value.mnemonic !== "string") throw storageFailure("parse");
|
|
360
|
+
return value;
|
|
361
|
+
};
|
|
362
|
+
|
|
363
|
+
const writeStore = (storage, value) => {
|
|
364
|
+
let text;
|
|
365
|
+
try { text = JSON.stringify(value); } catch { throw storageFailure("serialize"); }
|
|
366
|
+
try { storage.set(RECOVERY_KEY_STORE, text); } catch { throw storageFailure("write"); }
|
|
288
367
|
};
|
|
289
368
|
|
|
290
369
|
/** Save the key next to the swap records. The words are the secret; the store is the caller's. */
|
|
291
370
|
export function saveRecoveryKey(storage, key) {
|
|
292
371
|
const checked = recoveryKeyOf(key && key.mnemonic, key && key.createdAt);
|
|
293
|
-
storage
|
|
372
|
+
writeStore(storage, { ...checked, nextIndex: Number(key.nextIndex) || 0,
|
|
373
|
+
reserved: key.reserved || {}, retired: key.retired || {}, backupConfirmed: key.backupConfirmed === true });
|
|
294
374
|
return checked;
|
|
295
375
|
}
|
|
296
376
|
|
|
@@ -316,13 +396,13 @@ export function reserveRecoveryIndex(storage, { chainId, swapId = null } = {}) {
|
|
|
316
396
|
for (let i = 0; i < index; i += 1) {
|
|
317
397
|
if (!Object.prototype.hasOwnProperty.call(reserved, i) && !Object.prototype.hasOwnProperty.call(retired, i)) {
|
|
318
398
|
reserved[i] = { chainId: chain, swapId, at: new Date().toISOString() };
|
|
319
|
-
storage
|
|
399
|
+
writeStore(storage, { ...key, reserved, retired });
|
|
320
400
|
return i;
|
|
321
401
|
}
|
|
322
402
|
}
|
|
323
|
-
if (index >=
|
|
403
|
+
if (index >= RECOVERY_INDEX_MAX) throw failed("index-exhausted");
|
|
324
404
|
reserved[index] = { chainId: chain, swapId, at: new Date().toISOString() };
|
|
325
|
-
storage
|
|
405
|
+
writeStore(storage, { ...key, nextIndex: index + 1, reserved, retired });
|
|
326
406
|
return index;
|
|
327
407
|
}
|
|
328
408
|
|
|
@@ -331,7 +411,7 @@ export function releaseRecoveryIndex(storage, index) {
|
|
|
331
411
|
if (!key) throw failed("key-missing");
|
|
332
412
|
const reserved = { ...(key.reserved || {}) };
|
|
333
413
|
delete reserved[String(numberField(index, "index"))];
|
|
334
|
-
storage
|
|
414
|
+
writeStore(storage, { ...key, reserved });
|
|
335
415
|
}
|
|
336
416
|
|
|
337
417
|
/** Retire a number after its order exists on chain: it is never reused, so a later scan has no collision. */
|
|
@@ -343,15 +423,13 @@ export function commitRecoveryIndex(storage, index) {
|
|
|
343
423
|
const n = numberField(index, "index");
|
|
344
424
|
delete reserved[String(n)];
|
|
345
425
|
retired[String(n)] = true;
|
|
346
|
-
storage
|
|
426
|
+
writeStore(storage, { ...key, reserved, retired });
|
|
347
427
|
}
|
|
348
428
|
|
|
349
429
|
// ---------------------------------------------------------------------------------------------------
|
|
350
430
|
// THE SEARCH
|
|
351
431
|
// ---------------------------------------------------------------------------------------------------
|
|
352
432
|
|
|
353
|
-
const ADDRESS_RE = /^0x[0-9a-fA-F]{40}$/;
|
|
354
|
-
|
|
355
433
|
function topicAddress(address) {
|
|
356
434
|
if (typeof address !== "string" || !ADDRESS_RE.test(address)) throw bad("address");
|
|
357
435
|
return pad32(address);
|
|
@@ -359,10 +437,15 @@ function topicAddress(address) {
|
|
|
359
437
|
|
|
360
438
|
function decodeOrderLog(log) {
|
|
361
439
|
const topics = Array.isArray(log && log.topics) ? log.topics : [];
|
|
362
|
-
|
|
440
|
+
const topic0 = String(topics[0] || "").toLowerCase();
|
|
441
|
+
if (topics.length < 4 || !ORDER_CREATED_TOPICS.includes(topic0)) return null;
|
|
363
442
|
const data = String(log.data || "").replace(/^0x/, "");
|
|
364
|
-
|
|
443
|
+
const modern = topic0 === ORDER_CREATED_TOPIC;
|
|
444
|
+
if (data.length < 64 * (modern ? 13 : 12)) return null;
|
|
365
445
|
const word = (i) => "0x" + data.slice(i * 64, (i + 1) * 64).toLowerCase();
|
|
446
|
+
// THE FIELD OFFSETS MOVED WHEN `xmrAmount` JOINED THE EVENT. The old topic has no XMR amount; its orders fall
|
|
447
|
+
// back to the clone read, and a caller can still tell why from `xmrAmountReason`.
|
|
448
|
+
const shift = modern ? 1 : 0;
|
|
366
449
|
return {
|
|
367
450
|
escrow: "0x" + String(topics[1]).slice(26).toLowerCase(),
|
|
368
451
|
locker: "0x" + String(topics[2]).slice(26).toLowerCase(),
|
|
@@ -373,12 +456,13 @@ function decodeOrderLog(log) {
|
|
|
373
456
|
edPointClaimer: word(3),
|
|
374
457
|
edViewPointLocker: word(4),
|
|
375
458
|
amount: BigInt(word(5)).toString(),
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
459
|
+
xmrAmount: modern ? BigInt(word(6)).toString() : null,
|
|
460
|
+
readyBy: Number(BigInt(word(6 + shift))),
|
|
461
|
+
t1: Number(BigInt(word(7 + shift))),
|
|
462
|
+
salt: word(8 + shift),
|
|
463
|
+
registry: "0x" + data.slice((9 + shift) * 64 + 24, (10 + shift) * 64),
|
|
464
|
+
provider: "0x" + data.slice((10 + shift) * 64 + 24, (11 + shift) * 64),
|
|
465
|
+
quoteKey: "0x" + data.slice((11 + shift) * 64 + 24, (12 + shift) * 64),
|
|
382
466
|
transactionHash: log.transactionHash || null,
|
|
383
467
|
blockNumber: log.blockNumber != null ? Number(log.blockNumber) : null,
|
|
384
468
|
logIndex: log.logIndex != null ? Number(log.logIndex) : null,
|
|
@@ -431,7 +515,7 @@ async function readLogs(evmLogs, request) {
|
|
|
431
515
|
export async function recoveryIndexTaken({ mnemonic, chainId, index, factory, evmLogs, fromBlock = 0, toBlock = "latest" } = {}) {
|
|
432
516
|
if (typeof factory !== "string" || !ADDRESS_RE.test(factory)) throw bad("factory");
|
|
433
517
|
const own = takerHalves({ mnemonic, chainId, index });
|
|
434
|
-
const logs = await readLogs(evmLogs, { address: factory, topics: [
|
|
518
|
+
const logs = await readLogs(evmLogs, { address: factory, topics: [ORDER_CREATED_TOPICS], fromBlock, toBlock });
|
|
435
519
|
return logs.map(decodeOrderLog).filter(Boolean).some((order) =>
|
|
436
520
|
order.commitHalfLocker.toLowerCase() === own.spendCommitment.toLowerCase() ||
|
|
437
521
|
order.commitHalfClaimer.toLowerCase() === own.spendCommitment.toLowerCase());
|
|
@@ -537,7 +621,13 @@ async function recoveredStartHeight(order, { evmBlockTime, moneroTip, moneroBloc
|
|
|
537
621
|
// timestamp is already needed here for the Monero height, so it is returned with it.
|
|
538
622
|
let timestamp = cache.get(order.blockNumber);
|
|
539
623
|
if (timestamp === undefined) {
|
|
540
|
-
|
|
624
|
+
// ONE UNREADABLE BLOCK TIME MUST NOT THROW AWAY THE ORDERS ALREADY MATCHED. The order is still a fact from
|
|
625
|
+
// its log; only the start height is unknown, and the reason says which read failed.
|
|
626
|
+
try {
|
|
627
|
+
timestamp = typeof evmBlockTime === "function" ? secondsOf(await evmBlockTime(order.blockNumber)) : null;
|
|
628
|
+
} catch {
|
|
629
|
+
return { startHeight: null, startHeightReason: "block-time-read", blockTime: null };
|
|
630
|
+
}
|
|
541
631
|
if (timestamp === null) return { startHeight: null, startHeightReason: "no-block-time", blockTime: null };
|
|
542
632
|
cache.set(order.blockNumber, timestamp);
|
|
543
633
|
}
|
|
@@ -560,13 +650,16 @@ async function recoveredStartHeight(order, { evmBlockTime, moneroTip, moneroBloc
|
|
|
560
650
|
* positions) and merges the answers. Without one, every OrderCreated of the factory is read and matched locally.
|
|
561
651
|
*/
|
|
562
652
|
export async function findSwaps({ mnemonic, chainId, factory, address = null, evmLogs,
|
|
563
|
-
fromBlock = null, deploymentBlock = null, toBlock = "latest", gap = 5, maxIndex =
|
|
653
|
+
fromBlock = null, deploymentBlock = null, toBlock = "latest", gap = 5, maxIndex = RECOVERY_INDEX_MAX,
|
|
564
654
|
evmBlockTime = null, evmCode = null, moneroTip = null, moneroBlockTimestamp = null,
|
|
565
655
|
xmrNodes = null, moneroNetwork = null, fetchImpl = globalThis.fetch } = {}) {
|
|
566
656
|
if (typeof factory !== "string" || !ADDRESS_RE.test(factory)) throw bad("factory");
|
|
567
657
|
const chain = numberField(chainId, "chainId");
|
|
568
|
-
|
|
569
|
-
|
|
658
|
+
// `gap` is retained in the signature for callers of the old API, but it is deliberately NOT used: five empty
|
|
659
|
+
// numbers are ordinary when a start was abandoned or the key was used on another chain, and stopping there
|
|
660
|
+
// hides a funded order whose log is already in `logs`.
|
|
661
|
+
void gap;
|
|
662
|
+
const max = Math.min(numberField(maxIndex, "maxIndex") || RECOVERY_INDEX_MAX, RECOVERY_INDEX_MAX);
|
|
570
663
|
// THE CHAIN SAYS WHERE ITS ORDERS CAN START. An explicit fromBlock wins; otherwise the caller can pass the
|
|
571
664
|
// factory's deployment block (the SDK's chain registry exposes it as escrow.deploymentBlock). Zero remains the
|
|
572
665
|
// last resort for a caller that knows the factory existed at genesis.
|
|
@@ -581,8 +674,8 @@ export async function findSwaps({ mnemonic, chainId, factory, address = null, ev
|
|
|
581
674
|
if (address) {
|
|
582
675
|
const padded = topicAddress(address);
|
|
583
676
|
const [asLocker, asClaimer] = await Promise.all([
|
|
584
|
-
readLogs(evmLogs, { ...base, topics: [
|
|
585
|
-
readLogs(evmLogs, { ...base, topics: [
|
|
677
|
+
readLogs(evmLogs, { ...base, topics: [ORDER_CREATED_TOPICS, null, padded, null] }),
|
|
678
|
+
readLogs(evmLogs, { ...base, topics: [ORDER_CREATED_TOPICS, null, null, padded] }),
|
|
586
679
|
]);
|
|
587
680
|
const seen = new Set();
|
|
588
681
|
for (const log of asLocker.concat(asClaimer)) {
|
|
@@ -592,26 +685,67 @@ export async function findSwaps({ mnemonic, chainId, factory, address = null, ev
|
|
|
592
685
|
logs.push(log);
|
|
593
686
|
}
|
|
594
687
|
} else {
|
|
595
|
-
logs = await readLogs(evmLogs, { ...base, topics: [
|
|
688
|
+
logs = await readLogs(evmLogs, { ...base, topics: [ORDER_CREATED_TOPICS] });
|
|
596
689
|
}
|
|
597
690
|
const orders = logs.map(decodeOrderLog).filter(Boolean);
|
|
691
|
+
for (const order of orders) order.factory = factory;
|
|
692
|
+
const orderKey = (order) => `${order.transactionHash || ""}:${order.logIndex ?? ""}`;
|
|
693
|
+
const seed = seedOf(mnemonic);
|
|
598
694
|
const found = [];
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
695
|
+
const foundKeys = new Set();
|
|
696
|
+
const v2Matched = new Set();
|
|
697
|
+
|
|
698
|
+
// A V2 ORDER IS FOUND FROM ITS OWN SALT. No index is reserved and no range is scanned: the binding names the
|
|
699
|
+
// factory, the role and the public `OrderCreated` salt, so the same words on another device or at another
|
|
700
|
+
// factory produce a different half. The v2 pass runs first; v1 orders are left for the index scan below.
|
|
701
|
+
for (const order of orders) {
|
|
702
|
+
let commitment;
|
|
703
|
+
try {
|
|
704
|
+
commitment = spendCommitmentFromSeedV2(seed, { chainId: chain, factory, role: "taker", salt: order.salt });
|
|
705
|
+
} catch {
|
|
610
706
|
continue;
|
|
611
707
|
}
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
708
|
+
const locker = order.commitHalfLocker.toLowerCase();
|
|
709
|
+
const claimer = order.commitHalfClaimer.toLowerCase();
|
|
710
|
+
const side = commitment === locker ? "locker" : commitment === claimer ? "claimer" : null;
|
|
711
|
+
if (!side) continue;
|
|
712
|
+
const key = orderKey(order);
|
|
713
|
+
if (foundKeys.has(key)) continue;
|
|
714
|
+
foundKeys.add(key);
|
|
715
|
+
v2Matched.add(key);
|
|
716
|
+
found.push({ ...order, index: null, derivation: "salt-v2", side });
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
// MATCH V1 BY COMMITMENT, NOT BY A DENSE INDEX RANGE. The logs are already in hand; the only cost of a missing
|
|
720
|
+
// index is one derivation. Building the map first also keeps each order from being compared against every
|
|
721
|
+
// index.
|
|
722
|
+
const byCommit = new Map();
|
|
723
|
+
for (const order of orders) {
|
|
724
|
+
if (v2Matched.has(orderKey(order))) continue;
|
|
725
|
+
const locker = order.commitHalfLocker.toLowerCase();
|
|
726
|
+
const claimer = order.commitHalfClaimer.toLowerCase();
|
|
727
|
+
if (locker) {
|
|
728
|
+
if (!byCommit.has(locker)) byCommit.set(locker, []);
|
|
729
|
+
byCommit.get(locker).push({ order, side: "locker" });
|
|
730
|
+
}
|
|
731
|
+
if (claimer && claimer !== locker) {
|
|
732
|
+
if (!byCommit.has(claimer)) byCommit.set(claimer, []);
|
|
733
|
+
byCommit.get(claimer).push({ order, side: "claimer" });
|
|
734
|
+
}
|
|
735
|
+
}
|
|
736
|
+
const v1Needed = orders.some((order) => !v2Matched.has(orderKey(order)));
|
|
737
|
+
let scanned = 0;
|
|
738
|
+
if (v1Needed) {
|
|
739
|
+
for (let index = 0; index < max; index += 1) {
|
|
740
|
+
scanned = index + 1;
|
|
741
|
+
const commitment = spendCommitmentFromSeed(seed, chain, index);
|
|
742
|
+
const matches = byCommit.get(commitment) || [];
|
|
743
|
+
for (const { order, side } of matches) {
|
|
744
|
+
const key = orderKey(order);
|
|
745
|
+
if (foundKeys.has(key)) continue;
|
|
746
|
+
foundKeys.add(key);
|
|
747
|
+
found.push({ ...order, index, derivation: "index-v1", side });
|
|
748
|
+
}
|
|
615
749
|
}
|
|
616
750
|
}
|
|
617
751
|
// EACH FOUND ORDER CARRIES THE MONERO HEIGHT ITS WITHDRAWAL MAY START FROM. The rule lives here, not in a
|
|
@@ -637,9 +771,14 @@ export async function findSwaps({ mnemonic, chainId, factory, address = null, ev
|
|
|
637
771
|
for (const order of found) {
|
|
638
772
|
Object.assign(order, await recoveredStartHeight(order, { evmBlockTime, moneroTip: tip, moneroBlockTimestamp: header, cache, readersReason }));
|
|
639
773
|
}
|
|
640
|
-
// THE XMR AMOUNT
|
|
641
|
-
//
|
|
774
|
+
// THE XMR AMOUNT COMES FROM THE EVENT IN THE CURRENT FACTORY. The previous event has no field; those orders fall
|
|
775
|
+
// back to the clone read, where word 18 is read only from a clone of the exact shape. This is data, not a
|
|
776
|
+
// withdrawal verdict.
|
|
642
777
|
for (const order of found) {
|
|
778
|
+
if (order.xmrAmount !== null && order.xmrAmount !== undefined) {
|
|
779
|
+
order.xmrAmountReason = "event";
|
|
780
|
+
continue;
|
|
781
|
+
}
|
|
643
782
|
if (typeof evmCode !== "function") {
|
|
644
783
|
order.xmrAmount = null;
|
|
645
784
|
order.xmrAmountReason = "no-code-reader";
|
|
@@ -657,5 +796,15 @@ export async function findSwaps({ mnemonic, chainId, factory, address = null, ev
|
|
|
657
796
|
order.xmrAmount = read.value;
|
|
658
797
|
order.xmrAmountReason = read.reason;
|
|
659
798
|
}
|
|
660
|
-
|
|
799
|
+
const complete = !v1Needed || max >= RECOVERY_INDEX_MAX;
|
|
800
|
+
return {
|
|
801
|
+
swaps: found,
|
|
802
|
+
scanned,
|
|
803
|
+
// A CALLER MUST NOT READ "NO SWAPS" AS "THE WHOLE RANGE WAS SEARCHED". The allocator ceiling is the only
|
|
804
|
+
// complete range; a smaller caller-supplied bound says where the search stopped. A result made only of v2
|
|
805
|
+
// orders has no range to search and is complete by construction.
|
|
806
|
+
searchedUpTo: scanned ? scanned - 1 : null,
|
|
807
|
+
complete,
|
|
808
|
+
...(complete ? {} : { incompleteReason: "max-index" }),
|
|
809
|
+
};
|
|
661
810
|
}
|
package/src/recovery.mjs
CHANGED
|
@@ -23,9 +23,17 @@ export function createRecovery({ http = null, config = null } = {}) {
|
|
|
23
23
|
async open(file, passphrase) {
|
|
24
24
|
const contents = contentsOf(file);
|
|
25
25
|
if (contents === null) throw new SdkError("bad-input", { field: "file" });
|
|
26
|
+
// THE ENVELOPE IS PARSED AT THE BOUNDARY: `restoreFromFile` takes the OBJECT, while the file arrives as text.
|
|
27
|
+
// A text passed through reached the parser as a string and every valid file failed as `stage: restore`.
|
|
28
|
+
let envelope;
|
|
29
|
+
try {
|
|
30
|
+
envelope = JSON.parse(contents);
|
|
31
|
+
} catch {
|
|
32
|
+
throw new SdkError("recovery-failed", { stage: "parse" });
|
|
33
|
+
}
|
|
26
34
|
let restored;
|
|
27
35
|
try {
|
|
28
|
-
restored = await engine.restore.restoreFromFile(
|
|
36
|
+
restored = await engine.restore.restoreFromFile(envelope, passphrase);
|
|
29
37
|
} catch (error) {
|
|
30
38
|
// Parsing, decryption and the address check live in the engine; a code goes out, not the engine's wording.
|
|
31
39
|
throw new SdkError("recovery-failed", { stage: "restore" });
|
package/src/swap-flow.mjs
CHANGED
|
@@ -193,7 +193,8 @@ export async function runAtomicOrder({
|
|
|
193
193
|
// string, and that turned the check into a ritual: the stand-in's proof was bound to a different string,
|
|
194
194
|
// that is, to nothing. Now exactly the path a real provider would take is checked.
|
|
195
195
|
const mock = await worker.buildSide(orderContext);
|
|
196
|
-
counter = { proof: mock.proof, edPub: mock.pub.ed, mock, viewHalf: mock.viewHalf, viewPoint: mock.viewPub
|
|
196
|
+
counter = { proof: mock.proof, edPub: mock.pub.ed, mock, viewHalf: mock.viewHalf, viewPoint: mock.viewPub,
|
|
197
|
+
commitHalfClaimer: (await worker.halfCommitment(mock.half)).commitment };
|
|
197
198
|
}
|
|
198
199
|
// THE COUNTERPARTY CHECK REMAINS ONE AND IT IS THE MAIN ONE: the DLEQ proof in the context of this order.
|
|
199
200
|
// It confirms that both public points stand for ONE scalar - that is, that the half declared for the Monero
|
|
@@ -203,7 +204,7 @@ export async function runAtomicOrder({
|
|
|
203
204
|
// receive: without that check a correct proof would guarantee nothing about the chain.
|
|
204
205
|
say("verify", "dleq");
|
|
205
206
|
const verdict = await worker.verifyCounterparty(orderContext, {
|
|
206
|
-
proof: counter.proof, edPub: counter.edPub,
|
|
207
|
+
proof: counter.proof, edPub: counter.edPub, commitHalfClaimer: counter.commitHalfClaimer,
|
|
207
208
|
}, null);
|
|
208
209
|
if (!verdict.ok) throw new SdkError("quote-refused", { step: "proof", why: "rejected" });
|
|
209
210
|
|