@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.
@@ -407,17 +407,18 @@ export declare function judgeDaemonAddress(
407
407
  options?: { network?: string; allowPlainLoopback?: boolean },
408
408
  ): DaemonAddressJudgement;
409
409
 
410
- /** A per-KB price -> the fee for a transaction of `bytes`, rounded UP. Throws on a non-integer or a negative input. */
411
- export declare function feeForBytes(feePerKB: bigint | number | string, bytes: bigint | number | string): string;
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 kilobyte for the next block, in atomic units, or null when the daemon did not send it. */
417
- readonly feePerKB: string | null;
418
- readonly medianFeePerKB: string | null;
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 kilobyte; `feeForBytes` is the only arithmetic that turns it into a fee. Never throws.
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
- }): Promise<ScannedMatch[]>;
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
- const payload = request.request && typeof request.request === "object" ? request : { request };
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;