@ninsei-labs/sdk 0.52.0 → 0.54.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 +266 -0
- package/README.md +83 -5
- package/engine/atomic/order-worker.js +29 -2
- package/engine/atomic/order.js +24 -0
- package/engine/core/store.js +60 -12
- 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 +154 -26
- package/package.json +35 -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 +7 -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 +53 -5
- package/src/public/recovery.mjs +6 -0
- package/src/public/xmr.d.ts +26 -8
- package/src/quotes.mjs +410 -3
- package/src/recovery-key.mjs +217 -39
- package/src/recovery.mjs +9 -1
- package/src/relayFeatures.mjs +191 -0
- package/src/relayFirmClient.mjs +86 -0
- package/src/relayQuote.mjs +209 -0
- package/src/relayRequest.mjs +188 -0
- package/src/relayTerms.mjs +145 -0
- package/src/relayView.mjs +110 -0
- package/src/swap-flow.mjs +3 -2
- package/src/swaps.mjs +402 -80
- 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
|
@@ -27,9 +27,23 @@ 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;
|
|
33
|
+
// A DISCOVERY SCAN IS BOUNDED BY DEFAULT. The allocator can reach 100,000, but deriving a secp256k1 point for
|
|
34
|
+
// every number on a phone is not a page-open cost. One thousand indices keeps the common case inside a short
|
|
35
|
+
// call and, more importantly, the result NAMES that it stopped: the caller can continue from `nextIndex`.
|
|
36
|
+
export const RECOVERY_SCAN_DEFAULT = 1000;
|
|
37
|
+
// The scan yields to the event loop between chunks. A page must be able to paint and answer a cancel click even
|
|
38
|
+
// when a caller explicitly asks for a large range; a synchronous loop of 100,000 derivations froze the page.
|
|
39
|
+
export const RECOVERY_SCAN_YIELD_EVERY = 64;
|
|
30
40
|
export const RECOVERY_KEY_STORE = "ninsei.recovery.key.v1";
|
|
31
41
|
export const RECOVERY_KEY_FILE_MAGIC = "NINSEI RECOVERY KEY";
|
|
32
42
|
export const RECOVERY_KEY_DOMAIN = "ninsei-taker-v1";
|
|
43
|
+
// THE SALT-BASED VERSION. A v1 number is the same at every factory and every device; a v2 binding names the fresh
|
|
44
|
+
// order salt, the factory and the role, so two orders cannot share a taker half even when the words are restored
|
|
45
|
+
// elsewhere. The salt is already public in `OrderCreated`; recovery reads it back instead of reserving a number.
|
|
46
|
+
export const RECOVERY_KEY_DOMAIN_V2 = "ninsei-taker-v2";
|
|
33
47
|
// The counterparty's view half is derived from the SAME ONE KEY, in a domain of its own. The taker sends this
|
|
34
48
|
// number to the maker (the node), which uses it as its own view half; the taker keeps it, so the joint view key
|
|
35
49
|
// can be rebuilt from the twelve words without any server.
|
|
@@ -40,6 +54,7 @@ export const RECOVERY_KEY_ITER = 600000;
|
|
|
40
54
|
const ITER_MIN = 600000;
|
|
41
55
|
const ITER_MAX = 6000000;
|
|
42
56
|
|
|
57
|
+
const ADDRESS_RE = /^0x[0-9a-fA-F]{40}$/;
|
|
43
58
|
const te = new TextEncoder();
|
|
44
59
|
const td = new TextDecoder();
|
|
45
60
|
|
|
@@ -121,8 +136,38 @@ export function recoveryBinding(chainId, index) {
|
|
|
121
136
|
* package is a hex string, and a BigInt that reaches JSON.stringify fails the whole record.
|
|
122
137
|
*/
|
|
123
138
|
export function takerHalves({ mnemonic, chainId, index }) {
|
|
124
|
-
|
|
125
|
-
|
|
139
|
+
return takerHalvesFromSeed(seedOf(mnemonic), chainId, index);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// THE SAME KEY, A PUBLIC PER-ORDER SALT. The factory and role are in the binding, so the same words, chain and
|
|
143
|
+
// salt at another factory produce another half. `role` is fixed for the taker's side; the maker's view half is
|
|
144
|
+
// separated by its own domain. Salt is the `salt` word from `OrderCreated`, never a secret.
|
|
145
|
+
export function recoveryBindingV2({ chainId, factory, role = "taker", salt } = {}) {
|
|
146
|
+
if (typeof factory !== "string" || !ADDRESS_RE.test(factory)) throw bad("factory");
|
|
147
|
+
if (typeof role !== "string" || !/^[a-z0-9-]{1,32}$/.test(role)) throw bad("role");
|
|
148
|
+
return `${RECOVERY_KEY_DOMAIN_V2}|${numberField(chainId, "chainId")}|${factory.toLowerCase()}|${role}|${recoverySaltOf(salt)}`;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
export function takerHalvesV2({ mnemonic, chainId, factory, role = "taker", salt } = {}) {
|
|
152
|
+
const normalized = recoverySaltOf(salt);
|
|
153
|
+
const binding = recoveryBindingV2({ chainId, factory, role, salt: normalized });
|
|
154
|
+
return {
|
|
155
|
+
...halvesFromBinding(seedOf(mnemonic), binding),
|
|
156
|
+
index: null,
|
|
157
|
+
derivation: "salt-v2",
|
|
158
|
+
factory: factory.toLowerCase(),
|
|
159
|
+
role,
|
|
160
|
+
salt: normalized,
|
|
161
|
+
};
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// Discovery derives thousands of numbers from one mnemonic. The BIP39 seed is the expensive part (PBKDF2), so it
|
|
165
|
+
// is computed once by the caller and passed here; the per-number work is only HMAC and a curve point.
|
|
166
|
+
function takerHalvesFromSeed(seed, chainId, index) {
|
|
167
|
+
return { index: numberField(index, "index"), ...halvesFromBinding(seed, recoveryBinding(chainId, index)) };
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function halvesFromBinding(seed, binding) {
|
|
126
171
|
const spendHalf = halves.halfFromSeed(seed, binding, halves.HALF_DOMAIN);
|
|
127
172
|
const viewHalf = halves.halfFromSeed(seed, binding, halves.VIEW_HALF_DOMAIN);
|
|
128
173
|
// THE OTHER SIDE'S VIEW HALF. The same key, a third domain: the taker gives this number to the maker, so the
|
|
@@ -132,7 +177,6 @@ export function takerHalves({ mnemonic, chainId, index }) {
|
|
|
132
177
|
const view = halves.publicHalves(viewHalf);
|
|
133
178
|
const makerView = halves.publicHalves(makerViewHalf);
|
|
134
179
|
return {
|
|
135
|
-
index: numberField(index, "index"),
|
|
136
180
|
binding,
|
|
137
181
|
spendHalf: hex32(spendHalf),
|
|
138
182
|
viewHalf: hex32(viewHalf),
|
|
@@ -144,6 +188,41 @@ export function takerHalves({ mnemonic, chainId, index }) {
|
|
|
144
188
|
};
|
|
145
189
|
}
|
|
146
190
|
|
|
191
|
+
// DISCOVERY NEEDS ONE COMMITMENT, NOT THREE POINTS. The view and maker-view halves are not compared during a
|
|
192
|
+
// search, and their Edwards points are the expensive part of `takerHalves`; deriving only the spend scalar keeps
|
|
193
|
+
// a full-range scan practical.
|
|
194
|
+
function spendCommitmentFromSeed(seed, chainId, index) {
|
|
195
|
+
const spendHalf = halves.halfFromSeed(seed, recoveryBinding(chainId, index), halves.HALF_DOMAIN);
|
|
196
|
+
return halfCommitmentHex(spendHalf, secp256k1, keccak256).toLowerCase();
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function spendCommitmentFromSeedV2(seed, { chainId, factory, role = "taker", salt }) {
|
|
200
|
+
const binding = recoveryBindingV2({ chainId, factory, role, salt });
|
|
201
|
+
const spendHalf = halves.halfFromSeed(seed, binding, halves.HALF_DOMAIN);
|
|
202
|
+
return halfCommitmentHex(spendHalf, secp256k1, keccak256).toLowerCase();
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// 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
|
|
206
|
+
// across an event decoder and a caller that hand-wrote the value.
|
|
207
|
+
/** A fresh public salt for one v2 order. The client owns it; a maker must not hand the same value back twice. */
|
|
208
|
+
export function newRecoverySalt() {
|
|
209
|
+
return "0x" + Array.from(randomBytes(32), (byte) => byte.toString(16).padStart(2, "0")).join("");
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
export function recoverySaltOf(value) {
|
|
213
|
+
if (typeof value === "bigint") return saltField("0x" + value.toString(16).padStart(64, "0"));
|
|
214
|
+
if (typeof value === "number" && Number.isSafeInteger(value) && value >= 0) {
|
|
215
|
+
return saltField("0x" + BigInt(value).toString(16).padStart(64, "0"));
|
|
216
|
+
}
|
|
217
|
+
return saltField(value);
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
function saltField(value) {
|
|
221
|
+
const salt = String(value == null ? "" : value).trim().toLowerCase();
|
|
222
|
+
if (!/^0x[0-9a-f]{64}$/.test(salt)) throw bad("salt");
|
|
223
|
+
return salt;
|
|
224
|
+
}
|
|
225
|
+
|
|
147
226
|
// ---------------------------------------------------------------------------------------------------
|
|
148
227
|
// THE BACKUP FILE
|
|
149
228
|
// ---------------------------------------------------------------------------------------------------
|
|
@@ -274,23 +353,31 @@ export async function openRecoveryKeyFile(contents, passphrase = "") {
|
|
|
274
353
|
// RESERVATION
|
|
275
354
|
// ---------------------------------------------------------------------------------------------------
|
|
276
355
|
|
|
356
|
+
const storageFailure = (step) => new SdkError("storage-unavailable", { step });
|
|
277
357
|
const readStore = (storage) => {
|
|
278
358
|
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
|
-
|
|
359
|
+
let raw;
|
|
360
|
+
try { raw = storage.get(RECOVERY_KEY_STORE); } catch { throw storageFailure("read"); }
|
|
361
|
+
// NOTHING STORED IS NOT AN UNREADABLE STORE. `null`/absent means no key yet; a throwing read or a corrupt
|
|
362
|
+
// record must be named, because the next start would otherwise overwrite a key it could not read.
|
|
363
|
+
if (raw === undefined || raw === null || raw === "") return null;
|
|
364
|
+
let value;
|
|
365
|
+
try { value = JSON.parse(raw); } catch { throw storageFailure("parse"); }
|
|
366
|
+
if (!value || value.version !== RECOVERY_KEY_VERSION || typeof value.mnemonic !== "string") throw storageFailure("parse");
|
|
367
|
+
return value;
|
|
368
|
+
};
|
|
369
|
+
|
|
370
|
+
const writeStore = (storage, value) => {
|
|
371
|
+
let text;
|
|
372
|
+
try { text = JSON.stringify(value); } catch { throw storageFailure("serialize"); }
|
|
373
|
+
try { storage.set(RECOVERY_KEY_STORE, text); } catch { throw storageFailure("write"); }
|
|
288
374
|
};
|
|
289
375
|
|
|
290
376
|
/** Save the key next to the swap records. The words are the secret; the store is the caller's. */
|
|
291
377
|
export function saveRecoveryKey(storage, key) {
|
|
292
378
|
const checked = recoveryKeyOf(key && key.mnemonic, key && key.createdAt);
|
|
293
|
-
storage
|
|
379
|
+
writeStore(storage, { ...checked, nextIndex: Number(key.nextIndex) || 0,
|
|
380
|
+
reserved: key.reserved || {}, retired: key.retired || {}, backupConfirmed: key.backupConfirmed === true });
|
|
294
381
|
return checked;
|
|
295
382
|
}
|
|
296
383
|
|
|
@@ -316,13 +403,13 @@ export function reserveRecoveryIndex(storage, { chainId, swapId = null } = {}) {
|
|
|
316
403
|
for (let i = 0; i < index; i += 1) {
|
|
317
404
|
if (!Object.prototype.hasOwnProperty.call(reserved, i) && !Object.prototype.hasOwnProperty.call(retired, i)) {
|
|
318
405
|
reserved[i] = { chainId: chain, swapId, at: new Date().toISOString() };
|
|
319
|
-
storage
|
|
406
|
+
writeStore(storage, { ...key, reserved, retired });
|
|
320
407
|
return i;
|
|
321
408
|
}
|
|
322
409
|
}
|
|
323
|
-
if (index >=
|
|
410
|
+
if (index >= RECOVERY_INDEX_MAX) throw failed("index-exhausted");
|
|
324
411
|
reserved[index] = { chainId: chain, swapId, at: new Date().toISOString() };
|
|
325
|
-
storage
|
|
412
|
+
writeStore(storage, { ...key, nextIndex: index + 1, reserved, retired });
|
|
326
413
|
return index;
|
|
327
414
|
}
|
|
328
415
|
|
|
@@ -331,7 +418,7 @@ export function releaseRecoveryIndex(storage, index) {
|
|
|
331
418
|
if (!key) throw failed("key-missing");
|
|
332
419
|
const reserved = { ...(key.reserved || {}) };
|
|
333
420
|
delete reserved[String(numberField(index, "index"))];
|
|
334
|
-
storage
|
|
421
|
+
writeStore(storage, { ...key, reserved });
|
|
335
422
|
}
|
|
336
423
|
|
|
337
424
|
/** Retire a number after its order exists on chain: it is never reused, so a later scan has no collision. */
|
|
@@ -343,15 +430,13 @@ export function commitRecoveryIndex(storage, index) {
|
|
|
343
430
|
const n = numberField(index, "index");
|
|
344
431
|
delete reserved[String(n)];
|
|
345
432
|
retired[String(n)] = true;
|
|
346
|
-
storage
|
|
433
|
+
writeStore(storage, { ...key, reserved, retired });
|
|
347
434
|
}
|
|
348
435
|
|
|
349
436
|
// ---------------------------------------------------------------------------------------------------
|
|
350
437
|
// THE SEARCH
|
|
351
438
|
// ---------------------------------------------------------------------------------------------------
|
|
352
439
|
|
|
353
|
-
const ADDRESS_RE = /^0x[0-9a-fA-F]{40}$/;
|
|
354
|
-
|
|
355
440
|
function topicAddress(address) {
|
|
356
441
|
if (typeof address !== "string" || !ADDRESS_RE.test(address)) throw bad("address");
|
|
357
442
|
return pad32(address);
|
|
@@ -543,7 +628,13 @@ async function recoveredStartHeight(order, { evmBlockTime, moneroTip, moneroBloc
|
|
|
543
628
|
// timestamp is already needed here for the Monero height, so it is returned with it.
|
|
544
629
|
let timestamp = cache.get(order.blockNumber);
|
|
545
630
|
if (timestamp === undefined) {
|
|
546
|
-
|
|
631
|
+
// ONE UNREADABLE BLOCK TIME MUST NOT THROW AWAY THE ORDERS ALREADY MATCHED. The order is still a fact from
|
|
632
|
+
// its log; only the start height is unknown, and the reason says which read failed.
|
|
633
|
+
try {
|
|
634
|
+
timestamp = typeof evmBlockTime === "function" ? secondsOf(await evmBlockTime(order.blockNumber)) : null;
|
|
635
|
+
} catch {
|
|
636
|
+
return { startHeight: null, startHeightReason: "block-time-read", blockTime: null };
|
|
637
|
+
}
|
|
547
638
|
if (timestamp === null) return { startHeight: null, startHeightReason: "no-block-time", blockTime: null };
|
|
548
639
|
cache.set(order.blockNumber, timestamp);
|
|
549
640
|
}
|
|
@@ -566,13 +657,21 @@ async function recoveredStartHeight(order, { evmBlockTime, moneroTip, moneroBloc
|
|
|
566
657
|
* positions) and merges the answers. Without one, every OrderCreated of the factory is read and matched locally.
|
|
567
658
|
*/
|
|
568
659
|
export async function findSwaps({ mnemonic, chainId, factory, address = null, evmLogs,
|
|
569
|
-
fromBlock = null, deploymentBlock = null, toBlock = "latest", gap = 5,
|
|
660
|
+
fromBlock = null, deploymentBlock = null, toBlock = "latest", gap = 5,
|
|
661
|
+
fromIndex = 0, maxIndex = RECOVERY_SCAN_DEFAULT,
|
|
662
|
+
signal = null, onProgress = null,
|
|
570
663
|
evmBlockTime = null, evmCode = null, moneroTip = null, moneroBlockTimestamp = null,
|
|
571
664
|
xmrNodes = null, moneroNetwork = null, fetchImpl = globalThis.fetch } = {}) {
|
|
572
665
|
if (typeof factory !== "string" || !ADDRESS_RE.test(factory)) throw bad("factory");
|
|
573
666
|
const chain = numberField(chainId, "chainId");
|
|
574
|
-
|
|
575
|
-
|
|
667
|
+
// `gap` is retained in the signature for callers of the old API, but it is deliberately NOT used: five empty
|
|
668
|
+
// numbers are ordinary when a start was abandoned or the key was used on another chain, and stopping there
|
|
669
|
+
// hides a funded order whose log is already in `logs`.
|
|
670
|
+
void gap;
|
|
671
|
+
const startIndex = numberField(fromIndex, "fromIndex");
|
|
672
|
+
if (startIndex < 0 || startIndex >= RECOVERY_INDEX_MAX) throw bad("fromIndex");
|
|
673
|
+
const asked = numberField(maxIndex, "maxIndex");
|
|
674
|
+
const max = Math.min(asked, RECOVERY_INDEX_MAX - startIndex);
|
|
576
675
|
// THE CHAIN SAYS WHERE ITS ORDERS CAN START. An explicit fromBlock wins; otherwise the caller can pass the
|
|
577
676
|
// factory's deployment block (the SDK's chain registry exposes it as escrow.deploymentBlock). Zero remains the
|
|
578
677
|
// last resort for a caller that knows the factory existed at genesis.
|
|
@@ -601,23 +700,90 @@ export async function findSwaps({ mnemonic, chainId, factory, address = null, ev
|
|
|
601
700
|
logs = await readLogs(evmLogs, { ...base, topics: [ORDER_CREATED_TOPICS] });
|
|
602
701
|
}
|
|
603
702
|
const orders = logs.map(decodeOrderLog).filter(Boolean);
|
|
703
|
+
for (const order of orders) order.factory = factory;
|
|
704
|
+
const orderKey = (order) => `${order.transactionHash || ""}:${order.logIndex ?? ""}`;
|
|
705
|
+
const seed = seedOf(mnemonic);
|
|
604
706
|
const found = [];
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
707
|
+
const foundKeys = new Set();
|
|
708
|
+
const v2Matched = new Set();
|
|
709
|
+
|
|
710
|
+
// A V2 ORDER IS FOUND FROM ITS OWN SALT. No index is reserved and no range is scanned: the binding names the
|
|
711
|
+
// factory, the role and the public `OrderCreated` salt, so the same words on another device or at another
|
|
712
|
+
// factory produce a different half. The v2 pass runs first; v1 orders are left for the index scan below.
|
|
713
|
+
for (const order of orders) {
|
|
714
|
+
let commitment;
|
|
715
|
+
try {
|
|
716
|
+
commitment = spendCommitmentFromSeedV2(seed, { chainId: chain, factory, role: "taker", salt: order.salt });
|
|
717
|
+
} catch {
|
|
616
718
|
continue;
|
|
617
719
|
}
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
720
|
+
const locker = order.commitHalfLocker.toLowerCase();
|
|
721
|
+
const claimer = order.commitHalfClaimer.toLowerCase();
|
|
722
|
+
const side = commitment === locker ? "locker" : commitment === claimer ? "claimer" : null;
|
|
723
|
+
if (!side) continue;
|
|
724
|
+
const key = orderKey(order);
|
|
725
|
+
if (foundKeys.has(key)) continue;
|
|
726
|
+
foundKeys.add(key);
|
|
727
|
+
v2Matched.add(key);
|
|
728
|
+
found.push({ ...order, index: null, derivation: "salt-v2", side });
|
|
729
|
+
}
|
|
730
|
+
|
|
731
|
+
// MATCH V1 BY COMMITMENT, NOT BY A DENSE INDEX RANGE. The logs are already in hand; the only cost of a missing
|
|
732
|
+
// index is one derivation. Building the map first also keeps each order from being compared against every
|
|
733
|
+
// index.
|
|
734
|
+
const byCommit = new Map();
|
|
735
|
+
for (const order of orders) {
|
|
736
|
+
if (v2Matched.has(orderKey(order))) continue;
|
|
737
|
+
const locker = order.commitHalfLocker.toLowerCase();
|
|
738
|
+
const claimer = order.commitHalfClaimer.toLowerCase();
|
|
739
|
+
if (locker) {
|
|
740
|
+
if (!byCommit.has(locker)) byCommit.set(locker, []);
|
|
741
|
+
byCommit.get(locker).push({ order, side: "locker" });
|
|
742
|
+
}
|
|
743
|
+
if (claimer && claimer !== locker) {
|
|
744
|
+
if (!byCommit.has(claimer)) byCommit.set(claimer, []);
|
|
745
|
+
byCommit.get(claimer).push({ order, side: "claimer" });
|
|
746
|
+
}
|
|
747
|
+
}
|
|
748
|
+
const v1Keys = orders.filter((order) => !v2Matched.has(orderKey(order))).map(orderKey);
|
|
749
|
+
const v1Needed = v1Keys.length > 0;
|
|
750
|
+
const remaining = new Set(v1Keys);
|
|
751
|
+
let scanned = 0;
|
|
752
|
+
let stoppedBy = null;
|
|
753
|
+
if (onProgress !== null && typeof onProgress !== "function") throw bad("onProgress");
|
|
754
|
+
if (v1Needed && signal && signal.aborted === true) stoppedBy = "aborted";
|
|
755
|
+
if (v1Needed && stoppedBy === null) {
|
|
756
|
+
for (let offset = 0; offset < max; offset += 1) {
|
|
757
|
+
const index = startIndex + offset;
|
|
758
|
+
scanned = offset + 1;
|
|
759
|
+
const commitment = spendCommitmentFromSeed(seed, chain, index);
|
|
760
|
+
const matches = byCommit.get(commitment) || [];
|
|
761
|
+
for (const { order, side } of matches) {
|
|
762
|
+
const key = orderKey(order);
|
|
763
|
+
if (foundKeys.has(key)) continue;
|
|
764
|
+
foundKeys.add(key);
|
|
765
|
+
remaining.delete(key);
|
|
766
|
+
found.push({ ...order, index, derivation: "index-v1", side });
|
|
767
|
+
}
|
|
768
|
+
// EVERY MATCHED ORDER ENDS ITS OWN SEARCH. A key with only its own orders in the logs does not pay for
|
|
769
|
+
// the rest of the bound once the last one is found. A shared factory keeps the unrelated logs, so the
|
|
770
|
+
// scan continues to the requested bound and reports that it stopped there.
|
|
771
|
+
const final = remaining.size === 0 || offset === max - 1;
|
|
772
|
+
if (final || scanned % RECOVERY_SCAN_YIELD_EVERY === 0) {
|
|
773
|
+
// Progress is advisory: a page callback that throws must not turn a completed scan into a failed one.
|
|
774
|
+
if (typeof onProgress === "function") {
|
|
775
|
+
try { onProgress({ scanned, index }); } catch { /* progress is not the result */ }
|
|
776
|
+
}
|
|
777
|
+
}
|
|
778
|
+
// A CALLER CAN CANCEL. The check is after the progress callback, so a worker that aborts from its
|
|
779
|
+
// progress handler stops at this chunk instead of finishing the requested range.
|
|
780
|
+
if (signal && signal.aborted === true) { stoppedBy = "aborted"; break; }
|
|
781
|
+
if (remaining.size === 0) break;
|
|
782
|
+
// A LARGE CALLER-SUPPLIED BOUND MUST NOT OWN THE MAIN THREAD. The scan yields between chunks so the page
|
|
783
|
+
// can paint, and a later call can continue from `nextIndex`.
|
|
784
|
+
if (scanned % RECOVERY_SCAN_YIELD_EVERY === 0) {
|
|
785
|
+
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
786
|
+
}
|
|
621
787
|
}
|
|
622
788
|
}
|
|
623
789
|
// EACH FOUND ORDER CARRIES THE MONERO HEIGHT ITS WITHDRAWAL MAY START FROM. The rule lives here, not in a
|
|
@@ -668,5 +834,17 @@ export async function findSwaps({ mnemonic, chainId, factory, address = null, ev
|
|
|
668
834
|
order.xmrAmount = read.value;
|
|
669
835
|
order.xmrAmountReason = read.reason;
|
|
670
836
|
}
|
|
671
|
-
|
|
837
|
+
const searchedUpTo = scanned ? startIndex + scanned - 1 : null;
|
|
838
|
+
const complete = !v1Needed || remaining.size === 0 || startIndex + scanned >= RECOVERY_INDEX_MAX;
|
|
839
|
+
const nextIndex = complete ? null : startIndex + scanned;
|
|
840
|
+
return {
|
|
841
|
+
swaps: found,
|
|
842
|
+
scanned,
|
|
843
|
+
// A CALLER MUST NOT READ "NO SWAPS" AS "THE WHOLE RANGE WAS SEARCHED". The allocator ceiling is the only
|
|
844
|
+
// complete range; a smaller caller-supplied bound says where the search stopped. A result made only of v2
|
|
845
|
+
// orders has no range to search and is complete by construction.
|
|
846
|
+
searchedUpTo,
|
|
847
|
+
complete,
|
|
848
|
+
...(complete ? {} : { incompleteReason: stoppedBy || "max-index", nextIndex }),
|
|
849
|
+
};
|
|
672
850
|
}
|
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" });
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
// THE PUBLIC FEATURES A RELAY MAKER SIGNS.
|
|
2
|
+
//
|
|
3
|
+
// A relay price answer carries a `node_features` string signed by the maker's quote key. The client must check
|
|
4
|
+
// that signature before it uses the `hpkeKey`, the registry or the firm windows: an unsigned or substituted key
|
|
5
|
+
// would receive view material for another maker. This module owns the EIP-712 type, the relay domain and the
|
|
6
|
+
// verification; the request builder takes only the verified result.
|
|
7
|
+
//
|
|
8
|
+
// The public object has no contact fields. The relay forwards the exact string it received; this module hashes
|
|
9
|
+
// the decoded values, not a reconstructed subset.
|
|
10
|
+
import { SdkError } from "./errors.mjs";
|
|
11
|
+
import { keccak256, secp256k1 } from "./primitives.mjs";
|
|
12
|
+
|
|
13
|
+
export const RELAY_FEATURES_TYPE_NAME = "NodeFeatures";
|
|
14
|
+
export const RELAY_DOMAIN_NAME = "Ninsei Relay";
|
|
15
|
+
export const RELAY_DOMAIN_VERSION = "1";
|
|
16
|
+
|
|
17
|
+
const DOMAIN_TYPE = "EIP712Domain(string name,string version)";
|
|
18
|
+
export const FIRM_WINDOW_TYPE =
|
|
19
|
+
"FirmWindow(string market,string side,string xmrNetwork,uint32 readyMinSeconds,uint32 readyMaxSeconds,uint32 settlementMinSeconds,uint32 settlementMaxSeconds,uint32 quoteTtlSeconds)";
|
|
20
|
+
export const NODE_FEATURES_TYPE =
|
|
21
|
+
"NodeFeatures(string relayId,address provider,address quoteKey,address registry,bytes32 hpkeKey,FirmWindow[] firmWindows,uint64 time,bytes32 nonce)" +
|
|
22
|
+
FIRM_WINDOW_TYPE;
|
|
23
|
+
|
|
24
|
+
export const FIRM_WINDOW_FIELDS = [
|
|
25
|
+
["market", "string"],
|
|
26
|
+
["side", "string"],
|
|
27
|
+
["xmrNetwork", "string"],
|
|
28
|
+
["readyMinSeconds", "uint32"],
|
|
29
|
+
["readyMaxSeconds", "uint32"],
|
|
30
|
+
["settlementMinSeconds", "uint32"],
|
|
31
|
+
["settlementMaxSeconds", "uint32"],
|
|
32
|
+
["quoteTtlSeconds", "uint32"],
|
|
33
|
+
];
|
|
34
|
+
export const NODE_FEATURES_FIELDS = [
|
|
35
|
+
["relayId", "string"],
|
|
36
|
+
["provider", "address"],
|
|
37
|
+
["quoteKey", "address"],
|
|
38
|
+
["registry", "address"],
|
|
39
|
+
["hpkeKey", "bytes32"],
|
|
40
|
+
["firmWindows", "FirmWindow[]"],
|
|
41
|
+
["time", "uint64"],
|
|
42
|
+
["nonce", "bytes32"],
|
|
43
|
+
];
|
|
44
|
+
|
|
45
|
+
const utf8 = (value) => new TextEncoder().encode(String(value));
|
|
46
|
+
const concat = (...arrays) => {
|
|
47
|
+
const total = arrays.reduce((n, a) => n + a.length, 0);
|
|
48
|
+
const out = new Uint8Array(total);
|
|
49
|
+
let at = 0;
|
|
50
|
+
for (const array of arrays) { out.set(array, at); at += array.length; }
|
|
51
|
+
return out;
|
|
52
|
+
};
|
|
53
|
+
const hexBytes = (value, field, width) => {
|
|
54
|
+
const text = String(value === undefined || value === null ? "" : value).replace(/^0x/, "");
|
|
55
|
+
if (!new RegExp("^[0-9a-fA-F]{" + width * 2 + "}$").test(text)) {
|
|
56
|
+
throw new SdkError("bad-input", { field });
|
|
57
|
+
}
|
|
58
|
+
const out = new Uint8Array(width);
|
|
59
|
+
for (let i = 0; i < width; i += 1) out[i] = parseInt(text.slice(i * 2, i * 2 + 2), 16);
|
|
60
|
+
return out;
|
|
61
|
+
};
|
|
62
|
+
const numberWord = (value, field, bits) => {
|
|
63
|
+
let n = null;
|
|
64
|
+
try { n = BigInt(String(value === undefined || value === null ? "" : value).trim()); } catch { n = null; }
|
|
65
|
+
if (n === null || n < 0n || n >= (1n << BigInt(bits))) throw new SdkError("bad-input", { field });
|
|
66
|
+
const out = new Uint8Array(32);
|
|
67
|
+
for (let i = 31; i >= 0 && n > 0n; i -= 1) { out[i] = Number(n & 0xffn); n >>= 8n; }
|
|
68
|
+
return out;
|
|
69
|
+
};
|
|
70
|
+
const addressWord = (value, field) => {
|
|
71
|
+
const text = String(value === undefined || value === null ? "" : value).trim();
|
|
72
|
+
if (!/^(0x)?[0-9a-fA-F]{40}$/.test(text)) throw new SdkError("bad-input", { field });
|
|
73
|
+
const out = new Uint8Array(32);
|
|
74
|
+
out.set(hexBytes(text, field, 20), 12);
|
|
75
|
+
return out;
|
|
76
|
+
};
|
|
77
|
+
const stringWord = (value) => keccak256(utf8(value === undefined || value === null ? "" : value));
|
|
78
|
+
const arrayWord = (values, field, hashOf) => {
|
|
79
|
+
if (!Array.isArray(values)) throw new SdkError("bad-input", { field });
|
|
80
|
+
const parts = values.map((value) => hashOf(value));
|
|
81
|
+
return keccak256(concat(...parts));
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
function firmWindowHash(window) {
|
|
85
|
+
if (!window || typeof window !== "object") throw new SdkError("bad-input", { field: "firmWindows" });
|
|
86
|
+
const parts = [keccak256(utf8(FIRM_WINDOW_TYPE))];
|
|
87
|
+
for (const [field, type] of FIRM_WINDOW_FIELDS) {
|
|
88
|
+
parts.push(type === "string" ? stringWord(window[field]) : numberWord(window[field], "firmWindows." + field, 32));
|
|
89
|
+
}
|
|
90
|
+
return keccak256(concat(...parts));
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export function nodeFeaturesStructHash(features) {
|
|
94
|
+
if (!features || typeof features !== "object") throw new SdkError("bad-input", { field: "features" });
|
|
95
|
+
const parts = [keccak256(utf8(NODE_FEATURES_TYPE))];
|
|
96
|
+
for (const [field, type] of NODE_FEATURES_FIELDS) {
|
|
97
|
+
if (type === "string") parts.push(stringWord(features[field]));
|
|
98
|
+
else if (type === "address") parts.push(addressWord(features[field], "features." + field));
|
|
99
|
+
else if (type === "bytes32") parts.push(hexBytes(features[field], "features." + field, 32));
|
|
100
|
+
else if (type === "uint64") parts.push(numberWord(features[field], "features." + field, 64));
|
|
101
|
+
else if (type === "string[]") parts.push(arrayWord(features[field], "features." + field, (value) => stringWord(value)));
|
|
102
|
+
else if (type === "FirmWindow[]") parts.push(arrayWord(features[field], "features." + field, firmWindowHash));
|
|
103
|
+
else throw new SdkError("bad-input", { field: "features." + field });
|
|
104
|
+
}
|
|
105
|
+
return keccak256(concat(...parts));
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export function nodeFeaturesDomainSeparator() {
|
|
109
|
+
return keccak256(concat(
|
|
110
|
+
keccak256(utf8(DOMAIN_TYPE)),
|
|
111
|
+
keccak256(utf8(RELAY_DOMAIN_NAME)),
|
|
112
|
+
keccak256(utf8(RELAY_DOMAIN_VERSION)),
|
|
113
|
+
));
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export function nodeFeaturesDigest(features) {
|
|
117
|
+
return keccak256(concat(Uint8Array.from([0x19, 0x01]), nodeFeaturesDomainSeparator(), nodeFeaturesStructHash(features)));
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const bytesToHex = (bytes) => {
|
|
121
|
+
let out = "0x";
|
|
122
|
+
for (const byte of bytes) out += byte.toString(16).padStart(2, "0");
|
|
123
|
+
return out;
|
|
124
|
+
};
|
|
125
|
+
|
|
126
|
+
function recoverAddress(digest, signature) {
|
|
127
|
+
const sig = String(signature || "").replace(/^0x/, "");
|
|
128
|
+
if (!/^[0-9a-fA-F]{130}$/.test(sig)) return null;
|
|
129
|
+
let v = parseInt(sig.slice(128, 130), 16);
|
|
130
|
+
if (v >= 27) v -= 27;
|
|
131
|
+
if (v !== 0 && v !== 1) return null;
|
|
132
|
+
try {
|
|
133
|
+
const publicKey = secp256k1.Signature.fromBytes(concat(
|
|
134
|
+
hexBytes(sig.slice(0, 64), "signature", 32),
|
|
135
|
+
hexBytes(sig.slice(64, 128), "signature", 32),
|
|
136
|
+
)).addRecoveryBit(v).recoverPublicKey(digest);
|
|
137
|
+
return bytesToHex(keccak256(publicKey.toBytes(false).slice(1)).slice(12));
|
|
138
|
+
} catch {
|
|
139
|
+
return null;
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Verify the signed public features. `terms` optionally binds the features to one request: the provider, the
|
|
145
|
+
* registry, the market, the side and the Monero network must agree. A zero key, a short key, a missing or empty
|
|
146
|
+
* window list, or a signature from another key is a refusal.
|
|
147
|
+
*/
|
|
148
|
+
export function verifyNodeFeatures({ features, terms = null } = {}) {
|
|
149
|
+
if (!features || typeof features !== "object") throw new SdkError("bad-input", { field: "features" });
|
|
150
|
+
if (features.type !== "node_features" || Number(features.version) !== 1) {
|
|
151
|
+
throw new SdkError("quote-refused", { step: "features", why: "version" });
|
|
152
|
+
}
|
|
153
|
+
const digest = nodeFeaturesDigest(features);
|
|
154
|
+
const recovered = recoverAddress(digest, features.signature);
|
|
155
|
+
const quoteKey = String(features.quoteKey || "").toLowerCase();
|
|
156
|
+
if (recovered === null || recovered !== quoteKey) {
|
|
157
|
+
throw new SdkError("quote-refused", { step: "features", why: "signature" });
|
|
158
|
+
}
|
|
159
|
+
const hpkeKey = String(features.hpkeKey || "").replace(/^0x/, "").toLowerCase();
|
|
160
|
+
if (!/^[0-9a-f]{64}$/.test(hpkeKey) || /^0+$/.test(hpkeKey)) {
|
|
161
|
+
throw new SdkError("quote-refused", { step: "features", why: "hpke-key" });
|
|
162
|
+
}
|
|
163
|
+
if (!Array.isArray(features.firmWindows) || features.firmWindows.length === 0) {
|
|
164
|
+
throw new SdkError("quote-refused", { step: "features", why: "windows" });
|
|
165
|
+
}
|
|
166
|
+
if (terms) {
|
|
167
|
+
if (String(features.provider || "").toLowerCase() !== String(terms.maker || "").toLowerCase()
|
|
168
|
+
|| String(features.registry || "").toLowerCase() !== String(terms.registry || "").toLowerCase()) {
|
|
169
|
+
throw new SdkError("quote-refused", { step: "features", why: "identity" });
|
|
170
|
+
}
|
|
171
|
+
const market = String(terms.market || "");
|
|
172
|
+
const side = String(terms.side || "");
|
|
173
|
+
const network = String(terms.xmrNetwork || "");
|
|
174
|
+
const window = features.firmWindows.find((entry) => entry
|
|
175
|
+
&& entry.market === market && entry.side === side && entry.xmrNetwork === network);
|
|
176
|
+
if (!window) throw new SdkError("quote-refused", { step: "features", why: "window" });
|
|
177
|
+
return { featuresHash: bytesToHex(digest), hpkeKey: "0x" + hpkeKey, window };
|
|
178
|
+
}
|
|
179
|
+
return { featuresHash: bytesToHex(digest), hpkeKey: "0x" + hpkeKey, window: null };
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// Absent fields are named, not zero-filled.
|
|
183
|
+
export function nodeFeaturesMissingFields(features) {
|
|
184
|
+
if (!features || typeof features !== "object") return ["features"];
|
|
185
|
+
const missing = [];
|
|
186
|
+
for (const [field] of NODE_FEATURES_FIELDS) {
|
|
187
|
+
const value = features[field];
|
|
188
|
+
if (value === undefined || value === null || value === "") missing.push(field);
|
|
189
|
+
}
|
|
190
|
+
return missing;
|
|
191
|
+
}
|