@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/quotes.mjs
CHANGED
|
@@ -16,13 +16,18 @@
|
|
|
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";
|
|
24
|
+
import { takerHalves, takerHalvesV2, recoverySaltOf } from "./recovery-key.mjs";
|
|
25
|
+
import { acceptCounterparty, buildOwnSideFromHalves, jointAddress } from "./order.mjs";
|
|
26
|
+
import { requestFirmQuote } from "./relayFirmClient.mjs";
|
|
22
27
|
// THE CHAIN READ NEEDED TO ASK THE REGISTRY WHO OWNS THE KEY. The selector is COMPUTED from the signature, not
|
|
23
28
|
// remembered: a single wrong hex digit would call another function and return a zero address - and a zero
|
|
24
29
|
// address is exactly the answer "nobody owns this key", which the caller would take as the truth.
|
|
25
|
-
import { keccak256 } from "./primitives.mjs";
|
|
30
|
+
import { keccak256, secp256k1 } from "./primitives.mjs";
|
|
26
31
|
|
|
27
32
|
const utf8 = (value) => new TextEncoder().encode(String(value));
|
|
28
33
|
const bytesToHexQ = (bytes) => { let out = "0x"; for (const b of bytes) out += b.toString(16).padStart(2, "0"); return out; };
|
|
@@ -40,6 +45,8 @@ const hexToBytesQ = (hex) => {
|
|
|
40
45
|
return out;
|
|
41
46
|
};
|
|
42
47
|
const ZERO_ADDRESS = "0x" + "00".repeat(20);
|
|
48
|
+
const sameText = (a, b) => String(a === undefined || a === null ? "" : a).toLowerCase()
|
|
49
|
+
=== String(b === undefined || b === null ? "" : b).toLowerCase();
|
|
43
50
|
const addressFromWord = (raw) => {
|
|
44
51
|
const h = String(raw == null ? "" : raw).replace(/^0x/, "");
|
|
45
52
|
return h.length >= 40 ? "0x" + h.slice(-40).toLowerCase() : null;
|
|
@@ -398,7 +405,12 @@ export function createQuotes({ config, http, now = () => Date.now(), evmCall = n
|
|
|
398
405
|
// plus the priority part of the gas price. A buy quote has no tip; a sale is the only route where one exists.
|
|
399
406
|
async sale(request) {
|
|
400
407
|
if (!request || typeof request !== "object") fail("bad-input", { field: "request" });
|
|
401
|
-
|
|
408
|
+
// THE NODE GATES THE THREE BROWSER BODY ROUTES ON THE CLIENT'S SDK LINE. The value is the package's own
|
|
409
|
+
// baked-in version, not a caller field: a caller cannot claim a line it was not built from.
|
|
410
|
+
// THE ENVELOPE IS THE REQUEST. The node accepts both `{ request: {...} }` and a bare body; every check below
|
|
411
|
+
// must read the same object the node priced, not the outer envelope that carries only `sdkVersion`.
|
|
412
|
+
const saleRequest = request.request && typeof request.request === "object" ? request.request : request;
|
|
413
|
+
const payload = { ...(request.request && typeof request.request === "object" ? request : { request }), sdkVersion: SDK_VERSION };
|
|
402
414
|
let res = null;
|
|
403
415
|
try {
|
|
404
416
|
res = await http.tryJson(config.route("orderSell"), {
|
|
@@ -412,6 +424,162 @@ export function createQuotes({ config, http, now = () => Date.now(), evmCall = n
|
|
|
412
424
|
const ticket = res.body;
|
|
413
425
|
const quote = (ticket.handoff && ticket.handoff.quote) || ticket.quote || null;
|
|
414
426
|
if (!quote || typeof quote !== "object") fail("quote-field-unbound", { step: "order-sell", field: "quote" });
|
|
427
|
+
// THE SALE TICKET IS A SIGNED QUOTE, AND IT GETS THE SAME GATES AS `firm`: every field of the signed set,
|
|
428
|
+
// its expiry, the EIP-712 signature and the named key, the registry's provider mapping, the interface's
|
|
429
|
+
// allow-list, and the factory/implementation policy. The one difference is the binding below: a sale has no
|
|
430
|
+
// caller-supplied order terms, so its context is checked against `ticket.order` when the node returns it.
|
|
431
|
+
// A MISSING FIELD OF THE SIGNED SET IS NOT "PROBABLY ZERO": the digest cannot be built, so the quote is
|
|
432
|
+
// not verifiable and is refused (its own code, so the interface can say which field is unbound).
|
|
433
|
+
const missingFields = orderQuoteMissingFields(quote);
|
|
434
|
+
if (missingFields.length) fail("quote-field-unbound", { step: "signature", missing: missingFields });
|
|
435
|
+
// THE QUOTE'S OWN EXPIRY, checked against the clock the core already has. It is bound in the signature too,
|
|
436
|
+
// but a stale quote is refused by its own code rather than looking like a signature failure.
|
|
437
|
+
const validUntil = Number(quote.validUntil);
|
|
438
|
+
// THE SCALE IS SECONDS: the same as the order's deadlines and block.timestamp. The core's clock is in
|
|
439
|
+
// milliseconds (Date.now), so exactly ONE boundary is converted - "now", not the quote value itself: one
|
|
440
|
+
// unit for the value, one conversion at the input.
|
|
441
|
+
const nowSec = Math.floor(now() / 1000);
|
|
442
|
+
if (Number.isFinite(validUntil) && validUntil > 0 && validUntil <= nowSec) {
|
|
443
|
+
fail("quote-expired", { step: "signature", validUntil });
|
|
444
|
+
}
|
|
445
|
+
// RECOVER, THEN MATCH THE NAMED KEY. A signature that does not recover is one refusal; a valid signature
|
|
446
|
+
// that recovers to a DIFFERENT address is another (the quote names a key that did not sign it).
|
|
447
|
+
const recoveredKey = recoverOrderQuoteKey(quote);
|
|
448
|
+
if (recoveredKey === null) fail("quote-signature-invalid", { step: "signature" });
|
|
449
|
+
const declaredKey = String(quote.quoteKey).toLowerCase();
|
|
450
|
+
if (recoveredKey !== declaredKey) fail("quote-key-mismatch", { step: "signature", got: recoveredKey, want: declaredKey });
|
|
451
|
+
// WHO OWNS THE KEY - ASKED OF THE REGISTRY NAMED BY THE QUOTE. Both fields are part of the signed set, so
|
|
452
|
+
// they cannot be swapped after signing; this is what turns "a key signed it" into "this provider's key
|
|
453
|
+
// signed it". No chain-read seam - refuse rather than take the signature on trust.
|
|
454
|
+
const registry = String(quote.registry || "").toLowerCase();
|
|
455
|
+
if (!/^0x[0-9a-f]{40}$/.test(registry) || registry === ZERO_ADDRESS) {
|
|
456
|
+
fail("quote-field-unbound", { step: "registry", field: "registry" });
|
|
457
|
+
}
|
|
458
|
+
const declaredProvider = String(quote.provider || "").toLowerCase();
|
|
459
|
+
if (!/^0x[0-9a-f]{40}$/.test(declaredProvider) || declaredProvider === ZERO_ADDRESS) {
|
|
460
|
+
fail("quote-field-unbound", { step: "registry", field: "provider" });
|
|
461
|
+
}
|
|
462
|
+
if (typeof evmCall !== "function") fail("quote-registry-unchecked", { step: "registry", why: "no-chain-read" });
|
|
463
|
+
let mappedProvider = null;
|
|
464
|
+
try {
|
|
465
|
+
mappedProvider = addressFromWord(await evmCall({ to: registry, data: providerOfData(declaredKey) }));
|
|
466
|
+
} catch {
|
|
467
|
+
fail("quote-registry-unchecked", { step: "registry", why: "read-failed", registry });
|
|
468
|
+
}
|
|
469
|
+
if (mappedProvider === null || mappedProvider === ZERO_ADDRESS) {
|
|
470
|
+
fail("quote-key-unknown", { step: "registry", registry, quoteKey: declaredKey });
|
|
471
|
+
}
|
|
472
|
+
if (mappedProvider !== declaredProvider) {
|
|
473
|
+
fail("quote-provider-mismatch", { step: "registry", got: mappedProvider, want: declaredProvider });
|
|
474
|
+
}
|
|
475
|
+
// THE INTERFACE'S OWN ADMISSION LIST - POLICY, NOT PROOF. The chain says whose key it is; whether this
|
|
476
|
+
// interface trades with that provider at all is the interface's decision, and it rides as a hook.
|
|
477
|
+
if (typeof providerAllowed === "function" && providerAllowed(declaredProvider) !== true) {
|
|
478
|
+
fail("quote-provider-not-allowed", { step: "policy", provider: declaredProvider });
|
|
479
|
+
}
|
|
480
|
+
// THE FACTORY: NAMED BY THE PROVIDER'S RECORD, CHECKED BY ITS CODE (#103). The factory will verify the
|
|
481
|
+
// quote in EVM (it is the EIP-712 verifyingContract), so it must be the one the record names AND its
|
|
482
|
+
// code must be a build this interface knows. The record lookup always runs; the code check runs when a
|
|
483
|
+
// policy list was supplied (nothing to pin against otherwise).
|
|
484
|
+
const declaredFactory = String(quote.factory || "").toLowerCase();
|
|
485
|
+
if (!/^0x[0-9a-f]{40}$/.test(declaredFactory) || declaredFactory === ZERO_ADDRESS) {
|
|
486
|
+
fail("quote-field-unbound", { step: "factory", field: "factory" });
|
|
487
|
+
}
|
|
488
|
+
let recordedFactory = null;
|
|
489
|
+
try {
|
|
490
|
+
recordedFactory = addressFromWord(await evmCall({ to: registry, data: factoryOfData(declaredProvider) }));
|
|
491
|
+
} catch {
|
|
492
|
+
fail("quote-factory-unchecked", { step: "factory", why: "read-failed", registry });
|
|
493
|
+
}
|
|
494
|
+
if (recordedFactory === null || recordedFactory === ZERO_ADDRESS) {
|
|
495
|
+
fail("quote-factory-unknown", { step: "factory", registry, provider: declaredProvider });
|
|
496
|
+
}
|
|
497
|
+
if (recordedFactory !== declaredFactory) {
|
|
498
|
+
fail("quote-factory-mismatch", { step: "factory", got: declaredFactory, want: recordedFactory });
|
|
499
|
+
}
|
|
500
|
+
if (factoryPolicy) {
|
|
501
|
+
if (typeof evmCode !== "function") fail("quote-factory-unchecked", { step: "factory", why: "no-code-read" });
|
|
502
|
+
let codeHex = null;
|
|
503
|
+
try { codeHex = await evmCode(declaredFactory); } catch { fail("quote-factory-unchecked", { step: "factory", why: "code-read-failed", factory: declaredFactory }); }
|
|
504
|
+
const codeBytes = hexToBytesQ(codeHex);
|
|
505
|
+
// EXTCODEHASH SEMANTICS: keccak256 of the runtime code. An address with no code (empty/0x) hashes to
|
|
506
|
+
// the keccak of empty, which is not on any build list - refused like any unknown build.
|
|
507
|
+
const codeHash = bytesToHexQ(keccak256(codeBytes || new Uint8Array(0)));
|
|
508
|
+
if (!factoryPolicy.includes(codeHash)) {
|
|
509
|
+
fail("quote-factory-code-unknown", { step: "factory", factory: declaredFactory, codeHash });
|
|
510
|
+
}
|
|
511
|
+
}
|
|
512
|
+
// THE IMPLEMENTATION THE CLONES WILL EXECUTE (#114). A pinned factory CODE is not enough on its own: the
|
|
513
|
+
// implementation ADDRESS lives in the factory's STORAGE, so the client re-reads the pair the factory itself
|
|
514
|
+
// checks (implementation() / implementationCodeHash()) and the CODE at that address, then applies the same
|
|
515
|
+
// verdict (verifyFactoryImplementationOnChain). A delegatecall to an address WITHOUT code SUCCEEDS, so a
|
|
516
|
+
// clone without an implementation would take the ETH and its markReady/claim/refund would appear to go
|
|
517
|
+
// through while doing nothing - that is exactly the case this refuses. The live code is re-hashed here, so
|
|
518
|
+
// the verdict does not trust the factory's own answer alone.
|
|
519
|
+
if (implementationPolicy) {
|
|
520
|
+
if (typeof evmCall !== "function") fail("quote-factory-unchecked", { step: "factory", why: "no-chain-read" });
|
|
521
|
+
if (typeof evmCode !== "function") fail("quote-factory-unchecked", { step: "factory", why: "no-code-read" });
|
|
522
|
+
const verdict = await engine.evm.verifyFactoryImplementationOnChain({
|
|
523
|
+
factory: declaredFactory,
|
|
524
|
+
read: evmCall,
|
|
525
|
+
code: (address) => evmCode(address),
|
|
526
|
+
policy: implementationPolicy,
|
|
527
|
+
hashCode: (codeHex) => bytesToHexQ(keccak256(hexToBytesQ(codeHex) || new Uint8Array(0))),
|
|
528
|
+
});
|
|
529
|
+
if (!verdict.ok) {
|
|
530
|
+
const unknown = verdict.why === "implementation-code-unknown" ||
|
|
531
|
+
verdict.why === "implementation-code-mismatch" || verdict.why === "implementation-has-no-code";
|
|
532
|
+
fail(unknown ? "quote-factory-code-unknown" : "quote-factory-unchecked",
|
|
533
|
+
{ step: "factory", why: verdict.why, factory: declaredFactory, implementation: verdict.implementation || null });
|
|
534
|
+
}
|
|
535
|
+
}
|
|
536
|
+
// THE PERSON SELLING IS THE CLAIMER. The interface's denylist is applied to that address, not to a
|
|
537
|
+
// caller-chosen value: a ticket that names a denied claimer must not reach the funding step.
|
|
538
|
+
const saleClaimer = String((saleRequest && saleRequest.claimer) || quote.claimer || "").toLowerCase();
|
|
539
|
+
if (saleClaimer && denylist && denylist.has(saleClaimer)) fail("address-denied", { step: "order-sell" });
|
|
540
|
+
if (saleRequest && saleRequest.claimer && quote.claimer && String(saleRequest.claimer).toLowerCase() !== String(quote.claimer).toLowerCase()) {
|
|
541
|
+
fail("quote-refused", { step: "order-sell", why: "claimer-not-bound" });
|
|
542
|
+
}
|
|
543
|
+
// THE XMR THE CALLER ASKED FOR IS THE XMR THE QUOTE SIGNED. A ticket is paid against the signed `xmrAmount`;
|
|
544
|
+
// accepting a different requested amount shows one figure and settles another.
|
|
545
|
+
const requestedXmr = saleRequest && (saleRequest.xmr !== undefined ? saleRequest.xmr
|
|
546
|
+
: (saleRequest.xmrAmount !== undefined ? saleRequest.xmrAmount : null));
|
|
547
|
+
if (requestedXmr !== null && requestedXmr !== undefined) {
|
|
548
|
+
let wanted = null;
|
|
549
|
+
try { wanted = BigInt(String(requestedXmr)); } catch { wanted = null; }
|
|
550
|
+
// A whole-XMR request (the public `xmr` field) is converted with the package's own parser; digits are
|
|
551
|
+
// already atomic. A value that cannot be read is a refusal, not a skipped check.
|
|
552
|
+
if (wanted === null || wanted < 10n ** 12n) {
|
|
553
|
+
try { wanted = parseXmr(String(requestedXmr)); } catch { wanted = null; }
|
|
554
|
+
}
|
|
555
|
+
let signedAtomic = null;
|
|
556
|
+
try { signedAtomic = quote.xmrAmount === undefined || quote.xmrAmount === null || quote.xmrAmount === ""
|
|
557
|
+
? null : BigInt(String(quote.xmrAmount)); } catch { signedAtomic = null; }
|
|
558
|
+
if (signedAtomic === null || wanted === null || wanted !== signedAtomic) {
|
|
559
|
+
fail("quote-refused", { step: "order-sell", why: "xmr-amount" });
|
|
560
|
+
}
|
|
561
|
+
}
|
|
562
|
+
// THE CONTEXT WHEN THE TICKET CARRIES ITS ORDER. The signed quote binds the order terms; when the node
|
|
563
|
+
// returns the order it priced, recompute the context with our own engine and refuse a mismatch.
|
|
564
|
+
if (ticket.order && typeof ticket.order === "object") {
|
|
565
|
+
// THE RETURNED ORDER MUST BE THE SIGNED ONE FIELD BY FIELD. `quote.context` is a derived label, not a
|
|
566
|
+
// signed field; comparing only it lets a node return another amount with a matching relabelled context.
|
|
567
|
+
const sameValue = (a, b) => String(a === undefined || a === null ? "" : a).replace(/^0x/i, "").toLowerCase()
|
|
568
|
+
=== String(b === undefined || b === null ? "" : b).replace(/^0x/i, "").toLowerCase();
|
|
569
|
+
for (const name of ["locker", "claimer", "amount", "readyBy", "t1", "salt"]) {
|
|
570
|
+
if (!sameValue(ticket.order[name], quote[name])) {
|
|
571
|
+
fail("quote-refused", { step: "order-sell", why: "other-terms", field: name });
|
|
572
|
+
}
|
|
573
|
+
}
|
|
574
|
+
// The context remains a useful cross-check when both sides carry it.
|
|
575
|
+
if (quote.context) {
|
|
576
|
+
let expectedSaleContext = null;
|
|
577
|
+
try { expectedSaleContext = engine.orderContext.orderContextString(ticket.order); } catch { expectedSaleContext = null; }
|
|
578
|
+
if (expectedSaleContext !== null && String(quote.context) !== expectedSaleContext) {
|
|
579
|
+
fail("quote-refused", { step: "order-sell", why: "other-terms", field: "context" });
|
|
580
|
+
}
|
|
581
|
+
}
|
|
582
|
+
}
|
|
415
583
|
// THE TICKET'S TIP MUST BE THE SIGNED ONE. The node returns both the ticket's `claimTip` object and the quote it
|
|
416
584
|
// signed; a difference means the figure shown to the person is not the figure the escrow will pay.
|
|
417
585
|
const shown = ticket.claimTip && ticket.claimTip.wei !== undefined && ticket.claimTip.wei !== null
|
|
@@ -467,7 +635,7 @@ export function createQuotes({ config, http, now = () => Date.now(), evmCall = n
|
|
|
467
635
|
method: "POST",
|
|
468
636
|
headers: { "content-type": "application/json" },
|
|
469
637
|
body: JSON.stringify({ providerId: request.providerId, order: request.order,
|
|
470
|
-
...(viewHalfClaimer === null ? {} : { viewHalfClaimer }) }),
|
|
638
|
+
...(viewHalfClaimer === null ? {} : { viewHalfClaimer }), sdkVersion: SDK_VERSION }),
|
|
471
639
|
});
|
|
472
640
|
} catch (error) {
|
|
473
641
|
if (error && error.code === "bad-input") throw error;
|
|
@@ -650,5 +818,244 @@ export function createQuotes({ config, http, now = () => Date.now(), evmCall = n
|
|
|
650
818
|
}
|
|
651
819
|
return quote;
|
|
652
820
|
},
|
|
821
|
+
|
|
822
|
+
/**
|
|
823
|
+
* A firm quote through the relay, starting from the one recovery key.
|
|
824
|
+
*
|
|
825
|
+
* The caller gives the signed features, the order terms and the recovery key; this method derives the
|
|
826
|
+
* client half and both view points, sends one relay request and verifies the relay answer plus the
|
|
827
|
+
* on-chain registry/factory binding. It returns the signed `orderQuote` and a `quote` carrying the maker's
|
|
828
|
+
* view half, so `swaps.start` does not have to map relay fields by hand.
|
|
829
|
+
*/
|
|
830
|
+
async firmRelay(request) {
|
|
831
|
+
if (!request || typeof request !== "object") fail("bad-input", { field: "request" });
|
|
832
|
+
const features = request.features;
|
|
833
|
+
const order = request.order && typeof request.order === "object" ? { ...request.order } : null;
|
|
834
|
+
if (!features || typeof features !== "object") fail("bad-input", { field: "features" });
|
|
835
|
+
if (!order) fail("bad-input", { field: "order" });
|
|
836
|
+
const side = String(request.side || "");
|
|
837
|
+
if (side !== "ask" && side !== "bid") fail("bad-input", { field: "side" });
|
|
838
|
+
const maker = String(features.provider || "").toLowerCase();
|
|
839
|
+
const registry = String(features.registry || "").toLowerCase();
|
|
840
|
+
if (!/^0x[0-9a-f]{40}$/.test(maker) || maker === ZERO_ADDRESS) fail("bad-input", { field: "features.provider" });
|
|
841
|
+
if (!/^0x[0-9a-f]{40}$/.test(registry) || registry === ZERO_ADDRESS) fail("bad-input", { field: "features.registry" });
|
|
842
|
+
if (denylist && typeof order.locker === "string" && denylist.has(order.locker.toLowerCase())) {
|
|
843
|
+
fail("address-denied", { step: "order-quote" });
|
|
844
|
+
}
|
|
845
|
+
// THE PROVIDER'S ROLE IS PART OF THE SIDE. A caller that names another claimer/locker is not asking
|
|
846
|
+
// for this side; a caller that omits it gets the only value the wire allows.
|
|
847
|
+
if (side === "ask") {
|
|
848
|
+
if (order.claimer && !sameText(order.claimer, maker)) fail("bad-input", { field: "order.claimer" });
|
|
849
|
+
order.claimer = maker;
|
|
850
|
+
} else {
|
|
851
|
+
if (order.locker && !sameText(order.locker, maker)) fail("bad-input", { field: "order.locker" });
|
|
852
|
+
order.locker = maker;
|
|
853
|
+
}
|
|
854
|
+
const chainId = Number(order.chainId);
|
|
855
|
+
if (!Number.isSafeInteger(chainId) || chainId <= 0) fail("bad-input", { field: "order.chainId" });
|
|
856
|
+
const windows = Array.isArray(features.firmWindows)
|
|
857
|
+
? features.firmWindows.filter((entry) => entry && entry.side === side)
|
|
858
|
+
: [];
|
|
859
|
+
const markets = [...new Set(windows.map((entry) => entry.market).filter((value) => typeof value === "string" && value))];
|
|
860
|
+
const market = request.market !== undefined ? String(request.market) : (markets.length === 1 ? markets[0] : null);
|
|
861
|
+
if (!market) fail("bad-input", { field: "market" });
|
|
862
|
+
const window = windows.find((entry) => entry.market === market) || null;
|
|
863
|
+
if (!window) fail("quote-refused", { step: "features", why: "window" });
|
|
864
|
+
const xmrNetwork = request.xmrNetwork !== undefined ? String(request.xmrNetwork) : window.xmrNetwork;
|
|
865
|
+
if (!xmrNetwork) fail("bad-input", { field: "xmrNetwork" });
|
|
866
|
+
if (request.xmrAmount === undefined || request.xmrAmount === null || request.xmrAmount === "") {
|
|
867
|
+
fail("bad-input", { field: "xmrAmount" });
|
|
868
|
+
}
|
|
869
|
+
const claimTip = request.claimTip === undefined || request.claimTip === null ? "0" : String(request.claimTip);
|
|
870
|
+
const mnemonic = typeof request.recoveryKey === "string"
|
|
871
|
+
? request.recoveryKey
|
|
872
|
+
: request.recoveryKey && typeof request.recoveryKey.mnemonic === "string" ? request.recoveryKey.mnemonic : null;
|
|
873
|
+
if (!mnemonic) fail("bad-input", { field: "recoveryKey" });
|
|
874
|
+
const saltRequested = request.recoverySalt !== undefined && request.recoverySalt !== null;
|
|
875
|
+
const indexRequested = request.recoveryIndex !== undefined && request.recoveryIndex !== null;
|
|
876
|
+
if (saltRequested && indexRequested) fail("bad-input", { field: "recoverySalt", why: "with-recovery-index" });
|
|
877
|
+
let derived = null;
|
|
878
|
+
let derivation = null;
|
|
879
|
+
if (indexRequested) {
|
|
880
|
+
const index = Number(request.recoveryIndex);
|
|
881
|
+
if (!Number.isSafeInteger(index) || index < 0) fail("bad-input", { field: "recoveryIndex" });
|
|
882
|
+
derived = takerHalves({ mnemonic, chainId, index });
|
|
883
|
+
derivation = { kind: "index-v1", index };
|
|
884
|
+
} else {
|
|
885
|
+
if (!order.factory || !/^0x[0-9a-f]{40}$/i.test(String(order.factory))) fail("bad-input", { field: "order.factory" });
|
|
886
|
+
let salt = null;
|
|
887
|
+
try { salt = recoverySaltOf(saltRequested ? request.recoverySalt : order.salt); }
|
|
888
|
+
catch { fail("bad-input", { field: "recoverySalt" }); }
|
|
889
|
+
if (saltRequested) {
|
|
890
|
+
let orderSalt = null;
|
|
891
|
+
try { orderSalt = recoverySaltOf(order.salt); } catch { fail("bad-input", { field: "recoverySalt", why: "order-salt" }); }
|
|
892
|
+
if (salt !== orderSalt) fail("bad-input", { field: "recoverySalt", why: "order-salt" });
|
|
893
|
+
}
|
|
894
|
+
derived = takerHalvesV2({ mnemonic, chainId, factory: order.factory, salt });
|
|
895
|
+
derivation = { kind: "salt-v2", salt };
|
|
896
|
+
}
|
|
897
|
+
const own = buildOwnSideFromHalves(order, { spendHalf: derived.spendHalf, viewHalf: derived.viewHalf });
|
|
898
|
+
const xa = secp256k1.Point.fromHex(own.side.proof.XA);
|
|
899
|
+
const clientHalf = {
|
|
900
|
+
commitHalf: bytesToHexQ(keccak256(xa.toBytes(false).slice(1))),
|
|
901
|
+
edPoint: own.side.pub.ed,
|
|
902
|
+
proof: own.side.proof,
|
|
903
|
+
};
|
|
904
|
+
const result = await requestFirmQuote({
|
|
905
|
+
relayUrl: request.relayUrl,
|
|
906
|
+
partner: request.partner,
|
|
907
|
+
fetchImpl: request.fetchImpl,
|
|
908
|
+
timeoutMs: request.timeoutMs,
|
|
909
|
+
features,
|
|
910
|
+
terms: {
|
|
911
|
+
market,
|
|
912
|
+
side,
|
|
913
|
+
maker,
|
|
914
|
+
registry,
|
|
915
|
+
xmrNetwork,
|
|
916
|
+
order,
|
|
917
|
+
xmrAmount: String(request.xmrAmount),
|
|
918
|
+
claimTip,
|
|
919
|
+
},
|
|
920
|
+
clientHalf,
|
|
921
|
+
edViewPointClient: own.side.viewPub,
|
|
922
|
+
edViewPointNode: derived.makerViewPoint,
|
|
923
|
+
nodeViewScalar: derived.makerViewHalf,
|
|
924
|
+
clientViewScalar: side === "bid" ? derived.viewHalf : null,
|
|
925
|
+
side,
|
|
926
|
+
});
|
|
927
|
+
const quote = result.quote;
|
|
928
|
+
// THE NODE HALF IS NOT A DECORATION. The answer's proof must stand in THIS order's context and the proven
|
|
929
|
+
// point must be the one the quote commits to. For ask that is the claimer's commitment; for bid the node
|
|
930
|
+
// half is the locker's and carries its own commitment. This is the same engine check the swap path uses.
|
|
931
|
+
const nodeHalf = result.nodeHalf;
|
|
932
|
+
acceptCounterparty({
|
|
933
|
+
context: own.context,
|
|
934
|
+
side: own.side,
|
|
935
|
+
counterparty: side === "ask"
|
|
936
|
+
? {
|
|
937
|
+
proof: nodeHalf.proof,
|
|
938
|
+
edPub: quote.edPointClaimer,
|
|
939
|
+
commitHalfClaimer: quote.commitHalfClaimer,
|
|
940
|
+
viewPoint: result.edViewPointClaimer,
|
|
941
|
+
viewHalf: derived.makerViewHalf,
|
|
942
|
+
}
|
|
943
|
+
: {
|
|
944
|
+
proof: nodeHalf.proof,
|
|
945
|
+
edPub: nodeHalf.edPoint,
|
|
946
|
+
commitHalfClaimer: nodeHalf.commitHalf,
|
|
947
|
+
viewPoint: result.edViewPointLocker,
|
|
948
|
+
viewHalf: derived.makerViewHalf,
|
|
949
|
+
},
|
|
950
|
+
});
|
|
951
|
+
// THE ONE-TIME XMR ADDRESS IS DERIVED, NOT TAKEN FROM THE RELAY. Both sides must assemble it from the same
|
|
952
|
+
// points: our spend/view halves from the recovery key, the node's spend point and view point from the answer.
|
|
953
|
+
// For ask we are the locker and the node is the claimer; for bid the roles are swapped.
|
|
954
|
+
const nodeSpendPoint = side === "ask" ? quote.edPointClaimer : nodeHalf.edPoint;
|
|
955
|
+
const nodeViewPoint = side === "ask" ? result.edViewPointClaimer : result.edViewPointLocker;
|
|
956
|
+
const oneTimeAddress = jointAddress({
|
|
957
|
+
ownSpendHalf: derived.spendHalf,
|
|
958
|
+
ownViewHalf: derived.viewHalf,
|
|
959
|
+
otherSpendPoint: nodeSpendPoint,
|
|
960
|
+
otherViewPoint: nodeViewPoint,
|
|
961
|
+
network: xmrNetwork,
|
|
962
|
+
});
|
|
963
|
+
// THE ESCROW ADDRESS IS A READ, NOT A GUESS. predict() is a free call and uses the same signed quote the
|
|
964
|
+
// factory will receive; the locker fields are the side that deposits ETH. A read failure is a named refusal,
|
|
965
|
+
// because a client that cannot name the escrow cannot watch it.
|
|
966
|
+
const lockerHalf = side === "ask"
|
|
967
|
+
? { commitHalfLocker: clientHalf.commitHalf, edPointLocker: own.side.pub.ed, edViewPointLocker: own.side.viewPub }
|
|
968
|
+
: { commitHalfLocker: nodeHalf.commitHalf, edPointLocker: nodeHalf.edPoint, edViewPointLocker: result.edViewPointLocker };
|
|
969
|
+
let escrow = null;
|
|
970
|
+
try {
|
|
971
|
+
const predicted = await evmCall({
|
|
972
|
+
to: quote.factory,
|
|
973
|
+
data: engine.evm.encodePredict({ ...result.orderQuote, ...lockerHalf, signature: result.orderQuote.signature }),
|
|
974
|
+
});
|
|
975
|
+
escrow = engine.evm.decodeAddress(predicted);
|
|
976
|
+
} catch {
|
|
977
|
+
fail("quote-escrow-unchecked", { step: "factory", why: "predict-failed", factory: quote.factory });
|
|
978
|
+
}
|
|
979
|
+
if (providerAllowed && providerAllowed(maker) !== true) {
|
|
980
|
+
fail("quote-provider-not-allowed", { step: "policy", provider: maker });
|
|
981
|
+
}
|
|
982
|
+
if (typeof evmCall !== "function") fail("quote-registry-unchecked", { step: "registry", why: "no-chain-read" });
|
|
983
|
+
let mappedProvider = null;
|
|
984
|
+
try {
|
|
985
|
+
mappedProvider = addressFromWord(await evmCall({ to: registry, data: providerOfData(quote.quoteKey) }));
|
|
986
|
+
} catch { fail("quote-registry-unchecked", { step: "registry", why: "read-failed", registry }); }
|
|
987
|
+
if (mappedProvider === null || mappedProvider === ZERO_ADDRESS) fail("quote-key-unknown", { step: "registry", registry, quoteKey: quote.quoteKey });
|
|
988
|
+
if (!sameText(mappedProvider, quote.provider)) {
|
|
989
|
+
fail("quote-provider-mismatch", { step: "registry", got: mappedProvider, want: quote.provider });
|
|
990
|
+
}
|
|
991
|
+
let recordedFactory = null;
|
|
992
|
+
try {
|
|
993
|
+
recordedFactory = addressFromWord(await evmCall({ to: registry, data: factoryOfData(quote.provider) }));
|
|
994
|
+
} catch { fail("quote-factory-unchecked", { step: "factory", why: "read-failed", registry }); }
|
|
995
|
+
if (recordedFactory === null || recordedFactory === ZERO_ADDRESS) {
|
|
996
|
+
fail("quote-factory-unknown", { step: "factory", registry, provider: quote.provider });
|
|
997
|
+
}
|
|
998
|
+
if (!sameText(recordedFactory, quote.factory)) {
|
|
999
|
+
fail("quote-factory-mismatch", { step: "factory", got: quote.factory, want: recordedFactory });
|
|
1000
|
+
}
|
|
1001
|
+
if (factoryPolicy) {
|
|
1002
|
+
if (typeof evmCode !== "function") fail("quote-factory-unchecked", { step: "factory", why: "no-code-read" });
|
|
1003
|
+
let codeHex = null;
|
|
1004
|
+
try { codeHex = await evmCode(quote.factory); } catch { fail("quote-factory-unchecked", { step: "factory", why: "code-read-failed", factory: quote.factory }); }
|
|
1005
|
+
const codeHash = bytesToHexQ(keccak256(hexToBytesQ(codeHex) || new Uint8Array(0)));
|
|
1006
|
+
if (!factoryPolicy.includes(codeHash)) fail("quote-factory-code-unknown", { step: "factory", factory: quote.factory, codeHash });
|
|
1007
|
+
}
|
|
1008
|
+
if (implementationPolicy) {
|
|
1009
|
+
if (typeof evmCall !== "function") fail("quote-factory-unchecked", { step: "factory", why: "no-chain-read" });
|
|
1010
|
+
if (typeof evmCode !== "function") fail("quote-factory-unchecked", { step: "factory", why: "no-code-read" });
|
|
1011
|
+
const verdict = await engine.evm.verifyFactoryImplementationOnChain({
|
|
1012
|
+
factory: quote.factory,
|
|
1013
|
+
read: evmCall,
|
|
1014
|
+
code: (address) => evmCode(address),
|
|
1015
|
+
policy: implementationPolicy,
|
|
1016
|
+
hashCode: (codeHex) => bytesToHexQ(keccak256(hexToBytesQ(codeHex) || new Uint8Array(0))),
|
|
1017
|
+
});
|
|
1018
|
+
if (!verdict.ok) {
|
|
1019
|
+
const unknown = verdict.why === "implementation-code-unknown" ||
|
|
1020
|
+
verdict.why === "implementation-code-mismatch" || verdict.why === "implementation-has-no-code";
|
|
1021
|
+
fail(unknown ? "quote-factory-code-unknown" : "quote-factory-unchecked",
|
|
1022
|
+
{ step: "factory", why: verdict.why, factory: quote.factory, implementation: verdict.implementation || null });
|
|
1023
|
+
}
|
|
1024
|
+
}
|
|
1025
|
+
const tipCheck = (() => {
|
|
1026
|
+
try { return claimTipVerdict({ claimTipWei: quote.claimTip }); }
|
|
1027
|
+
catch { return { ok: true, tipWei: "0" }; }
|
|
1028
|
+
})();
|
|
1029
|
+
if (!tipCheck.ok && tipCheck.code === "quote-tip-unreadable") {
|
|
1030
|
+
fail("quote-field-unbound", { step: "claimTip", field: "claimTip" });
|
|
1031
|
+
}
|
|
1032
|
+
if (tipCheck.tipWei !== "0") {
|
|
1033
|
+
if (typeof evmBlock !== "function") fail("quote-tip-unchecked", { step: "claimTip", why: "no-block-read" });
|
|
1034
|
+
let block = null;
|
|
1035
|
+
try { block = await evmBlock("latest"); } catch { fail("quote-tip-unchecked", { step: "claimTip", why: "block-read-failed" }); }
|
|
1036
|
+
const baseFeeWei = block && (block.baseFeePerGas !== undefined ? block.baseFeePerGas : block.baseFeeWei);
|
|
1037
|
+
const verdict = claimTipVerdict({ claimTipWei: quote.claimTip, baseFeeWei });
|
|
1038
|
+
if (!verdict.ok) fail(verdict.code, { step: "claimTip", ...verdict });
|
|
1039
|
+
}
|
|
1040
|
+
return {
|
|
1041
|
+
...result,
|
|
1042
|
+
quote: { ...quote, viewHalfClaimer: derived.makerViewHalf },
|
|
1043
|
+
order,
|
|
1044
|
+
// THE SHAPE `swaps.start` TAKES: the proof and the claimer commitment travel with the view half, so a
|
|
1045
|
+
// caller can pass this object as `counterparty` without rebuilding the relay fields.
|
|
1046
|
+
counterparty: {
|
|
1047
|
+
proof: nodeHalf.proof,
|
|
1048
|
+
edPointClaimer: side === "ask" ? quote.edPointClaimer : nodeHalf.edPoint,
|
|
1049
|
+
commitHalfClaimer: side === "ask" ? quote.commitHalfClaimer : nodeHalf.commitHalf,
|
|
1050
|
+
viewHalfClaimer: derived.makerViewHalf,
|
|
1051
|
+
viewPoint: side === "ask" ? result.edViewPointClaimer : result.edViewPointLocker,
|
|
1052
|
+
},
|
|
1053
|
+
viewHalfClaimer: derived.makerViewHalf,
|
|
1054
|
+
viewHalfPoint: derived.makerViewPoint,
|
|
1055
|
+
jointAddress: oneTimeAddress.address,
|
|
1056
|
+
escrow,
|
|
1057
|
+
recovery: derivation,
|
|
1058
|
+
};
|
|
1059
|
+
},
|
|
653
1060
|
};
|
|
654
1061
|
}
|