@ninsei-labs/sdk 0.52.0 → 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 +239 -0
- package/README.md +76 -3
- 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 +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 +42 -5
- 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 +177 -39
- 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/public/xmr.d.ts
CHANGED
|
@@ -407,17 +407,18 @@ export declare function judgeDaemonAddress(
|
|
|
407
407
|
options?: { network?: string; allowPlainLoopback?: boolean },
|
|
408
408
|
): DaemonAddressJudgement;
|
|
409
409
|
|
|
410
|
-
/** A per-
|
|
411
|
-
export declare function feeForBytes(
|
|
410
|
+
/** A per-byte price -> the fee for a transaction of `bytes`/weight. Exact integer multiplication. */
|
|
411
|
+
export declare function feeForBytes(feePerByte: bigint | number | string, bytes: bigint | number | string): string;
|
|
412
412
|
|
|
413
413
|
export interface FeeEstimateOk {
|
|
414
414
|
readonly ok: true;
|
|
415
415
|
readonly url: string;
|
|
416
|
-
/** The price per
|
|
417
|
-
readonly
|
|
418
|
-
|
|
419
|
-
/** The daemon's own priority list, in its order, or null. */
|
|
416
|
+
/** The price per byte for the next block, in atomic units, or null when the daemon did not send it. */
|
|
417
|
+
readonly feePerByte: string | null;
|
|
418
|
+
/** The daemon's own priority list, per byte and in its order, or null. */
|
|
420
419
|
readonly perPriority: readonly string[] | null;
|
|
420
|
+
/** The unit the final fee is rounded to, or null when the daemon did not send it. */
|
|
421
|
+
readonly quantizationMask: string | null;
|
|
421
422
|
}
|
|
422
423
|
|
|
423
424
|
export interface FeeEstimateRefusal {
|
|
@@ -430,7 +431,7 @@ export interface FeeEstimateRefusal {
|
|
|
430
431
|
|
|
431
432
|
/**
|
|
432
433
|
* ASK A NODE WHAT A TRANSACTION WILL COST (`get_fee_estimate`). The exact sum needs the assembled transaction, so
|
|
433
|
-
* this is a price per
|
|
434
|
+
* this is a price per byte; `feeForBytes` is the only arithmetic that turns it into a fee. Never throws.
|
|
434
435
|
*/
|
|
435
436
|
export declare function moneroFeeEstimate(options: {
|
|
436
437
|
url: string;
|
|
@@ -483,6 +484,19 @@ export interface ScannedMatch {
|
|
|
483
484
|
[field: string]: unknown;
|
|
484
485
|
}
|
|
485
486
|
|
|
487
|
+
/** A range scan's answer: the per-address totals and whether every block was read. */
|
|
488
|
+
export interface ScanRangeResult {
|
|
489
|
+
readonly fromHeight: number;
|
|
490
|
+
readonly toHeight: number;
|
|
491
|
+
readonly results: ScannedMatch[];
|
|
492
|
+
/** False when at least one block or its transactions could not be read. */
|
|
493
|
+
readonly complete: boolean;
|
|
494
|
+
/** The heights that failed; empty when `complete` is true. */
|
|
495
|
+
readonly missingHeights: number[];
|
|
496
|
+
/** `blocks-unreadable` when `complete` is false. */
|
|
497
|
+
readonly incompleteReason?: "blocks-unreadable";
|
|
498
|
+
}
|
|
499
|
+
|
|
486
500
|
/** Walk a block range and return the transactions that pay a watched address. */
|
|
487
501
|
export declare function scanRange(options: {
|
|
488
502
|
daemonUrl: string;
|
|
@@ -492,4 +506,8 @@ export declare function scanRange(options: {
|
|
|
492
506
|
log?: (event: unknown) => void;
|
|
493
507
|
concurrency?: number;
|
|
494
508
|
nowSec?: number | null;
|
|
495
|
-
|
|
509
|
+
/** The chain tip used to judge a height lock; defaults to `toHeight`. Inclusion height still drives confirmations. */
|
|
510
|
+
tipHeight?: number | null;
|
|
511
|
+
/** The fetch used for the daemon requests; absent - the global fetch. */
|
|
512
|
+
fetchImpl?: typeof fetch;
|
|
513
|
+
}): Promise<ScanRangeResult>;
|
package/src/quotes.mjs
CHANGED
|
@@ -16,9 +16,11 @@
|
|
|
16
16
|
// 3) a quote older than its own ttlMs is not shown, and a row that contradicts itself (the named sides disagree
|
|
17
17
|
// with the pair's name) is not read at all.
|
|
18
18
|
import { fail } from "./errors.mjs";
|
|
19
|
+
import { SDK_VERSION } from "./version.mjs";
|
|
19
20
|
import * as engine from "./engine.mjs";
|
|
20
21
|
import { orderQuoteMissingFields, recoverOrderQuoteKey } from "./quoteEip712.mjs";
|
|
21
22
|
import { claimGasGiftWei } from "../engine/evm/claimGas.js";
|
|
23
|
+
import { parseXmr } from "../engine/core/xmrAmount.js";
|
|
22
24
|
// THE CHAIN READ NEEDED TO ASK THE REGISTRY WHO OWNS THE KEY. The selector is COMPUTED from the signature, not
|
|
23
25
|
// remembered: a single wrong hex digit would call another function and return a zero address - and a zero
|
|
24
26
|
// address is exactly the answer "nobody owns this key", which the caller would take as the truth.
|
|
@@ -398,7 +400,12 @@ export function createQuotes({ config, http, now = () => Date.now(), evmCall = n
|
|
|
398
400
|
// plus the priority part of the gas price. A buy quote has no tip; a sale is the only route where one exists.
|
|
399
401
|
async sale(request) {
|
|
400
402
|
if (!request || typeof request !== "object") fail("bad-input", { field: "request" });
|
|
401
|
-
|
|
403
|
+
// THE NODE GATES THE THREE BROWSER BODY ROUTES ON THE CLIENT'S SDK LINE. The value is the package's own
|
|
404
|
+
// baked-in version, not a caller field: a caller cannot claim a line it was not built from.
|
|
405
|
+
// THE ENVELOPE IS THE REQUEST. The node accepts both `{ request: {...} }` and a bare body; every check below
|
|
406
|
+
// must read the same object the node priced, not the outer envelope that carries only `sdkVersion`.
|
|
407
|
+
const saleRequest = request.request && typeof request.request === "object" ? request.request : request;
|
|
408
|
+
const payload = { ...(request.request && typeof request.request === "object" ? request : { request }), sdkVersion: SDK_VERSION };
|
|
402
409
|
let res = null;
|
|
403
410
|
try {
|
|
404
411
|
res = await http.tryJson(config.route("orderSell"), {
|
|
@@ -412,6 +419,162 @@ export function createQuotes({ config, http, now = () => Date.now(), evmCall = n
|
|
|
412
419
|
const ticket = res.body;
|
|
413
420
|
const quote = (ticket.handoff && ticket.handoff.quote) || ticket.quote || null;
|
|
414
421
|
if (!quote || typeof quote !== "object") fail("quote-field-unbound", { step: "order-sell", field: "quote" });
|
|
422
|
+
// THE SALE TICKET IS A SIGNED QUOTE, AND IT GETS THE SAME GATES AS `firm`: every field of the signed set,
|
|
423
|
+
// its expiry, the EIP-712 signature and the named key, the registry's provider mapping, the interface's
|
|
424
|
+
// allow-list, and the factory/implementation policy. The one difference is the binding below: a sale has no
|
|
425
|
+
// caller-supplied order terms, so its context is checked against `ticket.order` when the node returns it.
|
|
426
|
+
// A MISSING FIELD OF THE SIGNED SET IS NOT "PROBABLY ZERO": the digest cannot be built, so the quote is
|
|
427
|
+
// not verifiable and is refused (its own code, so the interface can say which field is unbound).
|
|
428
|
+
const missingFields = orderQuoteMissingFields(quote);
|
|
429
|
+
if (missingFields.length) fail("quote-field-unbound", { step: "signature", missing: missingFields });
|
|
430
|
+
// THE QUOTE'S OWN EXPIRY, checked against the clock the core already has. It is bound in the signature too,
|
|
431
|
+
// but a stale quote is refused by its own code rather than looking like a signature failure.
|
|
432
|
+
const validUntil = Number(quote.validUntil);
|
|
433
|
+
// THE SCALE IS SECONDS: the same as the order's deadlines and block.timestamp. The core's clock is in
|
|
434
|
+
// milliseconds (Date.now), so exactly ONE boundary is converted - "now", not the quote value itself: one
|
|
435
|
+
// unit for the value, one conversion at the input.
|
|
436
|
+
const nowSec = Math.floor(now() / 1000);
|
|
437
|
+
if (Number.isFinite(validUntil) && validUntil > 0 && validUntil <= nowSec) {
|
|
438
|
+
fail("quote-expired", { step: "signature", validUntil });
|
|
439
|
+
}
|
|
440
|
+
// RECOVER, THEN MATCH THE NAMED KEY. A signature that does not recover is one refusal; a valid signature
|
|
441
|
+
// that recovers to a DIFFERENT address is another (the quote names a key that did not sign it).
|
|
442
|
+
const recoveredKey = recoverOrderQuoteKey(quote);
|
|
443
|
+
if (recoveredKey === null) fail("quote-signature-invalid", { step: "signature" });
|
|
444
|
+
const declaredKey = String(quote.quoteKey).toLowerCase();
|
|
445
|
+
if (recoveredKey !== declaredKey) fail("quote-key-mismatch", { step: "signature", got: recoveredKey, want: declaredKey });
|
|
446
|
+
// WHO OWNS THE KEY - ASKED OF THE REGISTRY NAMED BY THE QUOTE. Both fields are part of the signed set, so
|
|
447
|
+
// they cannot be swapped after signing; this is what turns "a key signed it" into "this provider's key
|
|
448
|
+
// signed it". No chain-read seam - refuse rather than take the signature on trust.
|
|
449
|
+
const registry = String(quote.registry || "").toLowerCase();
|
|
450
|
+
if (!/^0x[0-9a-f]{40}$/.test(registry) || registry === ZERO_ADDRESS) {
|
|
451
|
+
fail("quote-field-unbound", { step: "registry", field: "registry" });
|
|
452
|
+
}
|
|
453
|
+
const declaredProvider = String(quote.provider || "").toLowerCase();
|
|
454
|
+
if (!/^0x[0-9a-f]{40}$/.test(declaredProvider) || declaredProvider === ZERO_ADDRESS) {
|
|
455
|
+
fail("quote-field-unbound", { step: "registry", field: "provider" });
|
|
456
|
+
}
|
|
457
|
+
if (typeof evmCall !== "function") fail("quote-registry-unchecked", { step: "registry", why: "no-chain-read" });
|
|
458
|
+
let mappedProvider = null;
|
|
459
|
+
try {
|
|
460
|
+
mappedProvider = addressFromWord(await evmCall({ to: registry, data: providerOfData(declaredKey) }));
|
|
461
|
+
} catch {
|
|
462
|
+
fail("quote-registry-unchecked", { step: "registry", why: "read-failed", registry });
|
|
463
|
+
}
|
|
464
|
+
if (mappedProvider === null || mappedProvider === ZERO_ADDRESS) {
|
|
465
|
+
fail("quote-key-unknown", { step: "registry", registry, quoteKey: declaredKey });
|
|
466
|
+
}
|
|
467
|
+
if (mappedProvider !== declaredProvider) {
|
|
468
|
+
fail("quote-provider-mismatch", { step: "registry", got: mappedProvider, want: declaredProvider });
|
|
469
|
+
}
|
|
470
|
+
// THE INTERFACE'S OWN ADMISSION LIST - POLICY, NOT PROOF. The chain says whose key it is; whether this
|
|
471
|
+
// interface trades with that provider at all is the interface's decision, and it rides as a hook.
|
|
472
|
+
if (typeof providerAllowed === "function" && providerAllowed(declaredProvider) !== true) {
|
|
473
|
+
fail("quote-provider-not-allowed", { step: "policy", provider: declaredProvider });
|
|
474
|
+
}
|
|
475
|
+
// THE FACTORY: NAMED BY THE PROVIDER'S RECORD, CHECKED BY ITS CODE (#103). The factory will verify the
|
|
476
|
+
// quote in EVM (it is the EIP-712 verifyingContract), so it must be the one the record names AND its
|
|
477
|
+
// code must be a build this interface knows. The record lookup always runs; the code check runs when a
|
|
478
|
+
// policy list was supplied (nothing to pin against otherwise).
|
|
479
|
+
const declaredFactory = String(quote.factory || "").toLowerCase();
|
|
480
|
+
if (!/^0x[0-9a-f]{40}$/.test(declaredFactory) || declaredFactory === ZERO_ADDRESS) {
|
|
481
|
+
fail("quote-field-unbound", { step: "factory", field: "factory" });
|
|
482
|
+
}
|
|
483
|
+
let recordedFactory = null;
|
|
484
|
+
try {
|
|
485
|
+
recordedFactory = addressFromWord(await evmCall({ to: registry, data: factoryOfData(declaredProvider) }));
|
|
486
|
+
} catch {
|
|
487
|
+
fail("quote-factory-unchecked", { step: "factory", why: "read-failed", registry });
|
|
488
|
+
}
|
|
489
|
+
if (recordedFactory === null || recordedFactory === ZERO_ADDRESS) {
|
|
490
|
+
fail("quote-factory-unknown", { step: "factory", registry, provider: declaredProvider });
|
|
491
|
+
}
|
|
492
|
+
if (recordedFactory !== declaredFactory) {
|
|
493
|
+
fail("quote-factory-mismatch", { step: "factory", got: declaredFactory, want: recordedFactory });
|
|
494
|
+
}
|
|
495
|
+
if (factoryPolicy) {
|
|
496
|
+
if (typeof evmCode !== "function") fail("quote-factory-unchecked", { step: "factory", why: "no-code-read" });
|
|
497
|
+
let codeHex = null;
|
|
498
|
+
try { codeHex = await evmCode(declaredFactory); } catch { fail("quote-factory-unchecked", { step: "factory", why: "code-read-failed", factory: declaredFactory }); }
|
|
499
|
+
const codeBytes = hexToBytesQ(codeHex);
|
|
500
|
+
// EXTCODEHASH SEMANTICS: keccak256 of the runtime code. An address with no code (empty/0x) hashes to
|
|
501
|
+
// the keccak of empty, which is not on any build list - refused like any unknown build.
|
|
502
|
+
const codeHash = bytesToHexQ(keccak256(codeBytes || new Uint8Array(0)));
|
|
503
|
+
if (!factoryPolicy.includes(codeHash)) {
|
|
504
|
+
fail("quote-factory-code-unknown", { step: "factory", factory: declaredFactory, codeHash });
|
|
505
|
+
}
|
|
506
|
+
}
|
|
507
|
+
// THE IMPLEMENTATION THE CLONES WILL EXECUTE (#114). A pinned factory CODE is not enough on its own: the
|
|
508
|
+
// implementation ADDRESS lives in the factory's STORAGE, so the client re-reads the pair the factory itself
|
|
509
|
+
// checks (implementation() / implementationCodeHash()) and the CODE at that address, then applies the same
|
|
510
|
+
// verdict (verifyFactoryImplementationOnChain). A delegatecall to an address WITHOUT code SUCCEEDS, so a
|
|
511
|
+
// clone without an implementation would take the ETH and its markReady/claim/refund would appear to go
|
|
512
|
+
// through while doing nothing - that is exactly the case this refuses. The live code is re-hashed here, so
|
|
513
|
+
// the verdict does not trust the factory's own answer alone.
|
|
514
|
+
if (implementationPolicy) {
|
|
515
|
+
if (typeof evmCall !== "function") fail("quote-factory-unchecked", { step: "factory", why: "no-chain-read" });
|
|
516
|
+
if (typeof evmCode !== "function") fail("quote-factory-unchecked", { step: "factory", why: "no-code-read" });
|
|
517
|
+
const verdict = await engine.evm.verifyFactoryImplementationOnChain({
|
|
518
|
+
factory: declaredFactory,
|
|
519
|
+
read: evmCall,
|
|
520
|
+
code: (address) => evmCode(address),
|
|
521
|
+
policy: implementationPolicy,
|
|
522
|
+
hashCode: (codeHex) => bytesToHexQ(keccak256(hexToBytesQ(codeHex) || new Uint8Array(0))),
|
|
523
|
+
});
|
|
524
|
+
if (!verdict.ok) {
|
|
525
|
+
const unknown = verdict.why === "implementation-code-unknown" ||
|
|
526
|
+
verdict.why === "implementation-code-mismatch" || verdict.why === "implementation-has-no-code";
|
|
527
|
+
fail(unknown ? "quote-factory-code-unknown" : "quote-factory-unchecked",
|
|
528
|
+
{ step: "factory", why: verdict.why, factory: declaredFactory, implementation: verdict.implementation || null });
|
|
529
|
+
}
|
|
530
|
+
}
|
|
531
|
+
// THE PERSON SELLING IS THE CLAIMER. The interface's denylist is applied to that address, not to a
|
|
532
|
+
// caller-chosen value: a ticket that names a denied claimer must not reach the funding step.
|
|
533
|
+
const saleClaimer = String((saleRequest && saleRequest.claimer) || quote.claimer || "").toLowerCase();
|
|
534
|
+
if (saleClaimer && denylist && denylist.has(saleClaimer)) fail("address-denied", { step: "order-sell" });
|
|
535
|
+
if (saleRequest && saleRequest.claimer && quote.claimer && String(saleRequest.claimer).toLowerCase() !== String(quote.claimer).toLowerCase()) {
|
|
536
|
+
fail("quote-refused", { step: "order-sell", why: "claimer-not-bound" });
|
|
537
|
+
}
|
|
538
|
+
// THE XMR THE CALLER ASKED FOR IS THE XMR THE QUOTE SIGNED. A ticket is paid against the signed `xmrAmount`;
|
|
539
|
+
// accepting a different requested amount shows one figure and settles another.
|
|
540
|
+
const requestedXmr = saleRequest && (saleRequest.xmr !== undefined ? saleRequest.xmr
|
|
541
|
+
: (saleRequest.xmrAmount !== undefined ? saleRequest.xmrAmount : null));
|
|
542
|
+
if (requestedXmr !== null && requestedXmr !== undefined) {
|
|
543
|
+
let wanted = null;
|
|
544
|
+
try { wanted = BigInt(String(requestedXmr)); } catch { wanted = null; }
|
|
545
|
+
// A whole-XMR request (the public `xmr` field) is converted with the package's own parser; digits are
|
|
546
|
+
// already atomic. A value that cannot be read is a refusal, not a skipped check.
|
|
547
|
+
if (wanted === null || wanted < 10n ** 12n) {
|
|
548
|
+
try { wanted = parseXmr(String(requestedXmr)); } catch { wanted = null; }
|
|
549
|
+
}
|
|
550
|
+
let signedAtomic = null;
|
|
551
|
+
try { signedAtomic = quote.xmrAmount === undefined || quote.xmrAmount === null || quote.xmrAmount === ""
|
|
552
|
+
? null : BigInt(String(quote.xmrAmount)); } catch { signedAtomic = null; }
|
|
553
|
+
if (signedAtomic === null || wanted === null || wanted !== signedAtomic) {
|
|
554
|
+
fail("quote-refused", { step: "order-sell", why: "xmr-amount" });
|
|
555
|
+
}
|
|
556
|
+
}
|
|
557
|
+
// THE CONTEXT WHEN THE TICKET CARRIES ITS ORDER. The signed quote binds the order terms; when the node
|
|
558
|
+
// returns the order it priced, recompute the context with our own engine and refuse a mismatch.
|
|
559
|
+
if (ticket.order && typeof ticket.order === "object") {
|
|
560
|
+
// THE RETURNED ORDER MUST BE THE SIGNED ONE FIELD BY FIELD. `quote.context` is a derived label, not a
|
|
561
|
+
// signed field; comparing only it lets a node return another amount with a matching relabelled context.
|
|
562
|
+
const sameValue = (a, b) => String(a === undefined || a === null ? "" : a).replace(/^0x/i, "").toLowerCase()
|
|
563
|
+
=== String(b === undefined || b === null ? "" : b).replace(/^0x/i, "").toLowerCase();
|
|
564
|
+
for (const name of ["locker", "claimer", "amount", "readyBy", "t1", "salt"]) {
|
|
565
|
+
if (!sameValue(ticket.order[name], quote[name])) {
|
|
566
|
+
fail("quote-refused", { step: "order-sell", why: "other-terms", field: name });
|
|
567
|
+
}
|
|
568
|
+
}
|
|
569
|
+
// The context remains a useful cross-check when both sides carry it.
|
|
570
|
+
if (quote.context) {
|
|
571
|
+
let expectedSaleContext = null;
|
|
572
|
+
try { expectedSaleContext = engine.orderContext.orderContextString(ticket.order); } catch { expectedSaleContext = null; }
|
|
573
|
+
if (expectedSaleContext !== null && String(quote.context) !== expectedSaleContext) {
|
|
574
|
+
fail("quote-refused", { step: "order-sell", why: "other-terms", field: "context" });
|
|
575
|
+
}
|
|
576
|
+
}
|
|
577
|
+
}
|
|
415
578
|
// THE TICKET'S TIP MUST BE THE SIGNED ONE. The node returns both the ticket's `claimTip` object and the quote it
|
|
416
579
|
// signed; a difference means the figure shown to the person is not the figure the escrow will pay.
|
|
417
580
|
const shown = ticket.claimTip && ticket.claimTip.wei !== undefined && ticket.claimTip.wei !== null
|
|
@@ -467,7 +630,7 @@ export function createQuotes({ config, http, now = () => Date.now(), evmCall = n
|
|
|
467
630
|
method: "POST",
|
|
468
631
|
headers: { "content-type": "application/json" },
|
|
469
632
|
body: JSON.stringify({ providerId: request.providerId, order: request.order,
|
|
470
|
-
...(viewHalfClaimer === null ? {} : { viewHalfClaimer }) }),
|
|
633
|
+
...(viewHalfClaimer === null ? {} : { viewHalfClaimer }), sdkVersion: SDK_VERSION }),
|
|
471
634
|
});
|
|
472
635
|
} catch (error) {
|
|
473
636
|
if (error && error.code === "bad-input") throw error;
|
package/src/recovery-key.mjs
CHANGED
|
@@ -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);
|
|
@@ -543,7 +621,13 @@ async function recoveredStartHeight(order, { evmBlockTime, moneroTip, moneroBloc
|
|
|
543
621
|
// timestamp is already needed here for the Monero height, so it is returned with it.
|
|
544
622
|
let timestamp = cache.get(order.blockNumber);
|
|
545
623
|
if (timestamp === undefined) {
|
|
546
|
-
|
|
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
|
+
}
|
|
547
631
|
if (timestamp === null) return { startHeight: null, startHeightReason: "no-block-time", blockTime: null };
|
|
548
632
|
cache.set(order.blockNumber, timestamp);
|
|
549
633
|
}
|
|
@@ -566,13 +650,16 @@ async function recoveredStartHeight(order, { evmBlockTime, moneroTip, moneroBloc
|
|
|
566
650
|
* positions) and merges the answers. Without one, every OrderCreated of the factory is read and matched locally.
|
|
567
651
|
*/
|
|
568
652
|
export async function findSwaps({ mnemonic, chainId, factory, address = null, evmLogs,
|
|
569
|
-
fromBlock = null, deploymentBlock = null, toBlock = "latest", gap = 5, maxIndex =
|
|
653
|
+
fromBlock = null, deploymentBlock = null, toBlock = "latest", gap = 5, maxIndex = RECOVERY_INDEX_MAX,
|
|
570
654
|
evmBlockTime = null, evmCode = null, moneroTip = null, moneroBlockTimestamp = null,
|
|
571
655
|
xmrNodes = null, moneroNetwork = null, fetchImpl = globalThis.fetch } = {}) {
|
|
572
656
|
if (typeof factory !== "string" || !ADDRESS_RE.test(factory)) throw bad("factory");
|
|
573
657
|
const chain = numberField(chainId, "chainId");
|
|
574
|
-
|
|
575
|
-
|
|
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);
|
|
576
663
|
// THE CHAIN SAYS WHERE ITS ORDERS CAN START. An explicit fromBlock wins; otherwise the caller can pass the
|
|
577
664
|
// factory's deployment block (the SDK's chain registry exposes it as escrow.deploymentBlock). Zero remains the
|
|
578
665
|
// last resort for a caller that knows the factory existed at genesis.
|
|
@@ -601,23 +688,64 @@ export async function findSwaps({ mnemonic, chainId, factory, address = null, ev
|
|
|
601
688
|
logs = await readLogs(evmLogs, { ...base, topics: [ORDER_CREATED_TOPICS] });
|
|
602
689
|
}
|
|
603
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);
|
|
604
694
|
const found = [];
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
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 {
|
|
616
706
|
continue;
|
|
617
707
|
}
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
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
|
+
}
|
|
621
749
|
}
|
|
622
750
|
}
|
|
623
751
|
// EACH FOUND ORDER CARRIES THE MONERO HEIGHT ITS WITHDRAWAL MAY START FROM. The rule lives here, not in a
|
|
@@ -668,5 +796,15 @@ export async function findSwaps({ mnemonic, chainId, factory, address = null, ev
|
|
|
668
796
|
order.xmrAmount = read.value;
|
|
669
797
|
order.xmrAmountReason = read.reason;
|
|
670
798
|
}
|
|
671
|
-
|
|
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
|
+
};
|
|
672
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
|
|