@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/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;
|