@ninsei-labs/sdk 0.53.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 CHANGED
@@ -1,4 +1,31 @@
1
- ## Unreleased
1
+ ## 0.54.0 — 2026-10-29
2
+
3
+ - `findSwaps` no longer walks the allocator's 100,000 v1 indices in one synchronous call. One call scans at most
4
+ `RECOVERY_SCAN_DEFAULT` (1000) indices, yields to the event loop between chunks, and returns `nextIndex` with
5
+ `complete: false` and `incompleteReason: "max-index"` when it stopped. A caller continues from `nextIndex`; a search
6
+ that matched every order in the logs is complete without scanning the rest. `maxIndex` is the number of indices for
7
+ this call.
8
+ - `findSwaps` accepts an AbortSignal-like `signal` and an `onProgress` callback. An aborted scan returns
9
+ `incompleteReason: "aborted"` with the same `nextIndex`; progress is reported after every chunk and after the final
10
+ index. The SDK creates no worker: a caller can run the same call in one.
11
+ - The relay firm client has its signed-features check and request builder: a `NodeFeatures` digest with the
12
+ `FirmWindow` array, signature recovery, request binding, a normalised client half, and one HPKE envelope per
13
+ request. Ask carries one view scalar; bid carries two. Wire revision 13 removed the `capabilities` array: the
14
+ features now always carry a nonzero HPKE key and at least one firm window.
15
+ - The relay firm client's HTTP hop is `requestFirmQuote`: it posts the exact payload once, keeps a relay refusal
16
+ code (`compliance`, `no_liquidity`, `size`, `paused`, `unavailable`, `bad_request`, `price_changed`), maps a
17
+ transport failure to `server-unavailable`, and verifies the outer frame and the inner `OrderQuote` before
18
+ returning. It also checks the relay/forwarded/answer relay-id equality.
19
+ - `quotes.firmRelay` is the recovery-key adapter: it derives the client half and both view points from one
20
+ key, fills the provider's role for ask or bid, verifies the node half's DLEQ proof and commitment, sends one
21
+ relay request, and checks the registry provider and factory against the answer before returning the signed
22
+ `orderQuote`, the one-time joint XMR address, a `counterparty` object ready for `swaps.start`, and the maker
23
+ view half. `FirmOffer` now matches the direct `firm` call (`providerId`, `order`, optional `viewHalfClaimer`).
24
+ - `swaps.sellWatch` is the client-side sell wait: it reads the escrow itself and returns `ready`,
25
+ `claimed` or `refunded` (or `sell-timeout`), with no backend watcher and no signature.
26
+ - `swaps.startSell` records a sell from a verified relay offer: the one-time address, escrow, our half and the
27
+ order expectations survive a reload; the same offer resumes the same record. `swaps.sellClaim` claims from
28
+ that record, from the claimer's own wallet.
2
29
 
3
30
  ## 0.53.0 — 2026-10-28
4
31
 
package/README.md CHANGED
@@ -17,7 +17,8 @@ machine). The wallet arrives as an EIP-1193 provider, storage as an adapter, and
17
17
  | `recovery.open` — parsing the recovery file | works |
18
18
  | `sim` — sandbox scenarios and time | works (applying to a swap — stage 2) |
19
19
  | `quotes.watch` | works: live indicative quotes, its own client; a snapshot carries the display fields (sides, networks, `why`, signature, age, maturity), and the size can be NAMED (`size`) |
20
- | `quotes.firm` | answers `not-implemented` (stage 2: signing with a key and the order protocol) |
20
+ | `quotes.firm` | works: a signed, binding quote with a TTL; a refusal is a named code |
21
+ | `quotes.firmRelay` | works: a firm quote through a relay, built from one recovery key; the node half is DLEQ-checked |
21
22
  | `swaps.list` / `swaps.get` | read state from storage (and after a page reload) |
22
23
  | `swaps.start` | holds the order: guards → recovery file and the person's confirmation → order → locking funds → **recording with us and watching the Monero address**; returns the named state `step: "xmr-incoming"` |
23
24
  | `swaps.watch` | waits for XMR to arrive at the joint address with the same request as the page (`GET /swaps/{id}`): arrived — returns the fact (amount, confirmations, txid); did not wait — a refusal with the code `xmr-timeout`; already on the address but barred by `unlock_time` — a refusal with the code `xmr-locked` (the boundary is in `params.until`), and NO ready mark; the maker DECLINED the order — returns `SwapDeclinedWatch` (`declined: true`, `terminal: "declined"`, the node's own `code`), with every action off; with expectations and a wallet, places the ready mark after the arrival |
@@ -173,7 +174,7 @@ signature, the age (`ageMs`) and the maturity level (`finality`) — display fie
173
174
  The price does not depend on volume: the volume is needed by the provider to say whether it takes such a size. So
174
175
  the size is computed from the person's amount and is snapped to the node's grid **down**, and a size outside the
175
176
  range is not aligned: it is the provider's refusal, and it arrives as a code. Indicative quotes commit to nothing;
176
- `quotes.firm` commits (stage 2).
177
+ `quotes.firm` and `quotes.firmRelay` commit, and the relay path carries the maker's signed terms and its view half.
177
178
 
178
179
  ## Found orders and the paying token
179
180
 
@@ -182,8 +183,12 @@ public `salt` in its own `OrderCreated`: the binding is `ninsei-taker-v2|<chain>
182
183
  words on another device or at another factory produce a different half and no number is reserved. The result says
183
184
  `derivation: "salt-v2"` and leaves `index` null. Existing v1 orders stay on `ninsei-taker-v1|<chain>|<index>` and are
184
185
  still found by the index path; a v2 result needs no range scan. For a v1 fallback, discovery does **not** stop after a
185
- run of empty numbers: the allocator's whole range is searched by default, a smaller caller-supplied `maxIndex` returns
186
- `complete: false` with `searchedUpTo`, and `gap` is ignored. Each found order reports:
186
+ run of empty numbers. One call scans at most `RECOVERY_SCAN_DEFAULT` (1000) indices; that keeps a shared factory from
187
+ freezing the page. The result names where it stopped: `complete: false`, `searchedUpTo`, `incompleteReason: "max-index"`
188
+ and `nextIndex`. A caller that needs more passes `nextIndex` back as `fromIndex`; the scan yields between chunks so the
189
+ page stays responsive. `maxIndex` is the number of indices for this call, and `gap` is ignored. `onProgress` receives
190
+ `{ scanned, index }` after each chunk and after the final index; `signal` stops between chunks and returns
191
+ `incompleteReason: "aborted"` with the same resumable shape.
187
192
 
188
193
  - `amount` — the escrow's **native** amount in wei;
189
194
  - `settlementToken: "native"` — what the escrow receives;
package/index.d.ts CHANGED
@@ -454,6 +454,11 @@ export interface QuotesApi {
454
454
  snapshot(): QuoteSnapshot | null;
455
455
  /** A binding quote with a TTL. A refusal comes as a code, not text. */
456
456
  firm(offer: FirmOffer): Promise<Quote>;
457
+ /**
458
+ * A firm quote through a relay, built from one recovery key. The relay answer is verified against the
459
+ * requested terms and the on-chain registry/factory binding before it is returned.
460
+ */
461
+ firmRelay(request: FirmRelayOffer): Promise<FirmRelayAnswer>;
457
462
  /**
458
463
  * A reverse-mode ticket: the maker sells XMR. The request is the node's `request` object; the answer carries the
459
464
  * signed order quote and its claim tip, which is checked against the gas price before it is returned.
@@ -660,11 +665,60 @@ export interface QuoteReference {
660
665
  }
661
666
 
662
667
  export interface FirmOffer {
663
- readonly direction: "buy" | "sell";
664
- readonly token: string;
665
- readonly amount: number;
666
- readonly receiveTo: string;
667
- readonly providerId?: string;
668
+ /** Who gives the quote. Without it there is no one to ask. */
669
+ readonly providerId: string;
670
+ /** The order terms the signature is bound to. */
671
+ readonly order: Readonly<Record<string, unknown>>;
672
+ /** The taker's maker view half when the order was built from a recovery key. */
673
+ readonly viewHalfClaimer?: string;
674
+ }
675
+
676
+ export interface FirmRelayOffer {
677
+ /** Relay origin, without the `/v1/quote` path. */
678
+ readonly relayUrl: string;
679
+ /** The signed public features the relay forwarded; they are verified before anything is sent. */
680
+ readonly features: Readonly<Record<string, unknown>>;
681
+ readonly order: Readonly<Record<string, unknown>>;
682
+ readonly side: "ask" | "bid";
683
+ /** The twelve-word recovery key or an object carrying it. */
684
+ readonly recoveryKey: string | { readonly mnemonic: string };
685
+ /** A fresh salt the client owns; it must equal `order.salt`. Defaults to `order.salt`. */
686
+ readonly recoverySalt?: string;
687
+ /** The v1 index; it is mutually exclusive with `recoverySalt`. */
688
+ readonly recoveryIndex?: number;
689
+ /** The market's chain and asset, for example `421614:ETH`. Derived when exactly one window matches. */
690
+ readonly market?: string;
691
+ readonly xmrNetwork?: string;
692
+ readonly xmrAmount: string | number;
693
+ readonly claimTip?: string | number;
694
+ readonly partner?: string;
695
+ readonly fetchImpl?: typeof fetch;
696
+ readonly timeoutMs?: number;
697
+ }
698
+
699
+ export interface FirmRelayAnswer {
700
+ readonly quote: Readonly<Record<string, unknown>>;
701
+ readonly orderQuote: Readonly<Record<string, unknown>>;
702
+ /** The exact order terms the quote is bound to. */
703
+ readonly order: Readonly<Record<string, unknown>>;
704
+ readonly nodeHalf: Readonly<Record<string, unknown>>;
705
+ /** Ready to pass as `swaps.start({ counterparty })`: proof, claimer commitment and maker view half. */
706
+ readonly counterparty: Readonly<Record<string, unknown>>;
707
+ readonly edViewPointLocker: string;
708
+ readonly edViewPointClaimer: string;
709
+ readonly context: string;
710
+ readonly viewHalfClaimer: string;
711
+ readonly viewHalfPoint: string;
712
+ /** The one-time Monero address assembled from the recovery key and the answer's points. */
713
+ readonly jointAddress: string;
714
+ /** The escrow address predicted from the signed quote and the depositor's half. */
715
+ readonly escrow: string;
716
+ readonly recovery: { readonly kind: "salt-v2"; readonly salt: string }
717
+ | { readonly kind: "index-v1"; readonly index: number };
718
+ readonly relay: string | null;
719
+ readonly offered: number | null;
720
+ readonly forwarded: Readonly<Record<string, unknown>> | null;
721
+ readonly answer: Readonly<Record<string, unknown>>;
668
722
  }
669
723
 
670
724
  /**
@@ -758,6 +812,13 @@ export interface SwapsApi {
758
812
  * the swap with if the browser is lost.
759
813
  */
760
814
  start(request: StartRequest): Promise<SwapStart>;
815
+ /**
816
+ * START A SELL from a verified relay offer. It records the one-time address, the escrow, our half and the
817
+ * order expectations; it sends nothing. The same offer retried yields the same record (`resumed: true`).
818
+ */
819
+ startSell(request: SellStartRequest): Promise<SellStart>;
820
+ /** CLAIM THE ETH on a sell from the record: the half and the expectations are the ones stored by `startSell`. */
821
+ sellClaim(request: SellClaimRequest, wallet: WalletAdapter): Promise<unknown>;
761
822
  /**
762
823
  * WAIT FOR THE XMR TO ARRIVE at the order's joint address. The polling uses the same request as the page's
763
824
  * (GET /swaps/{id}), the rate and the timeout - from `config.limits` and overridable here.
@@ -774,6 +835,12 @@ export interface SwapsApi {
774
835
  * signed, and the decline is written into the record, so every later read of the swap reports the same state.
775
836
  */
776
837
  watch(request: SwapWatchRequest): Promise<SwapWatch | SwapDeclinedWatch>;
838
+ /**
839
+ * WAIT FOR THE NODE'S SIDE ON A SELL. The escrow is the one predicted by `quotes.firmRelay`; the client sends
840
+ * XMR to the joint address, and this reads the escrow itself until the locker marks it ready, claims it or
841
+ * refunds it. No backend watcher is involved. A wait that expires is `sell-timeout`.
842
+ */
843
+ sellWatch(request: SellWatchRequest): Promise<SellWatchResult>;
777
844
  /**
778
845
  * THE XMR ARRIVAL CHECK WITHOUT THE WATCH LOOP (issue #85). The client reads the chain itself with its own view
779
846
  * key and `ready` opens only when at least two nodes of DIFFERENT operators see the transaction IN THE SAME BLOCK
@@ -1021,6 +1088,53 @@ export interface SwapStart {
1021
1088
  readonly next: "watch";
1022
1089
  }
1023
1090
 
1091
+ export interface SellStartRequest {
1092
+ /** The verified `quotes.firmRelay` answer for the bid side. */
1093
+ readonly offer: FirmRelayAnswer;
1094
+ /** The twelve-word key; required only when no key is stored yet. */
1095
+ readonly recoveryKey?: string | { readonly mnemonic: string };
1096
+ }
1097
+
1098
+ export interface SellStart {
1099
+ readonly id: string;
1100
+ readonly side: "sell";
1101
+ readonly escrow: string;
1102
+ /** The one-time Monero address the client sends XMR to. */
1103
+ readonly address: string;
1104
+ readonly xmrAmount: string;
1105
+ readonly claimTip: string | null;
1106
+ readonly orderQuote: Readonly<Record<string, unknown>> | null;
1107
+ readonly resumed: boolean;
1108
+ readonly next: "send-xmr";
1109
+ }
1110
+
1111
+ export interface SellClaimRequest {
1112
+ readonly id: string;
1113
+ readonly escrow?: string;
1114
+ readonly halfClaimer?: string;
1115
+ readonly expect?: Readonly<Record<string, unknown>>;
1116
+ readonly onStep?: (step: unknown) => void;
1117
+ }
1118
+
1119
+ export interface SellWatchRequest {
1120
+ /** The escrow predicted by the relay adapter. */
1121
+ readonly escrow: string;
1122
+ readonly timeoutMs?: number;
1123
+ readonly pollMs?: number;
1124
+ }
1125
+
1126
+ export interface SellWatchResult {
1127
+ readonly escrow: string;
1128
+ readonly state: "ready" | "claimed" | "refunded";
1129
+ readonly funded: boolean;
1130
+ readonly claimed: boolean;
1131
+ readonly refunded: boolean;
1132
+ /** `null` for a terminal claim/refund: the ready mark is not part of that answer. */
1133
+ readonly ready: boolean | null;
1134
+ readonly balanceWei: string;
1135
+ readonly waitedMs: number;
1136
+ }
1137
+
1024
1138
  export interface SwapWatchRequest {
1025
1139
  /** The watch record's identifier at the service (from SwapStart.watch.id). */
1026
1140
  readonly id: string;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ninsei-labs/sdk",
3
- "version": "0.53.0",
3
+ "version": "0.54.0",
4
4
  "publishConfig": {
5
5
  "access": "public",
6
6
  "registry": "https://registry.npmjs.org/"
@@ -41,6 +41,15 @@
41
41
  "check:sale-auth": "node tools/check-sale-auth.mjs",
42
42
  "check:backup-confirmation": "node tools/check-backup-confirmation.mjs",
43
43
  "check:key-view": "node tools/check-key-view.mjs",
44
+ "check:relay-view": "node tools/check-relay-view.mjs",
45
+ "check:relay-terms": "node tools/check-relay-terms.mjs",
46
+ "check:relay-features": "node tools/check-relay-features.mjs",
47
+ "check:relay-request": "node tools/check-relay-request.mjs",
48
+ "check:relay-answer": "node tools/check-relay-answer.mjs",
49
+ "check:relay-http": "node tools/check-relay-http.mjs",
50
+ "check:relay-adapter": "node tools/check-relay-adapter.mjs",
51
+ "check:sell-watch": "node tools/check-sell-watch.mjs",
52
+ "check:sell-start": "node tools/check-sell-start.mjs",
44
53
  "check:arrival-consensus": "node tools/check-arrival-consensus.mjs",
45
54
  "check:arrival-real": "node tools/check-arrival-real.mjs",
46
55
  "check:order-proof": "node tools/check-order-proof.mjs",
@@ -138,6 +147,9 @@
138
147
  "url": "https://github.com/Ninsei-Labs/sdk/issues"
139
148
  },
140
149
  "dependencies": {
150
+ "@hpke/chacha20poly1305": "1.8.0",
151
+ "@hpke/core": "1.9.0",
152
+ "@hpke/dhkem-x25519": "1.8.0",
141
153
  "@noble/curves": "^2.4.0",
142
154
  "@noble/hashes": "^2.4.0",
143
155
  "@scure/bip39": "2.4.0",
package/src/index.mjs CHANGED
@@ -270,6 +270,8 @@ export function createNinsei(options) {
270
270
  // A firm quote is stage 2: it is signed with a key and lives in the order protocol, not in a market snapshot.
271
271
  // A FIRM QUOTE: accepted only with a confirmed provider signature.
272
272
  firm: (request) => quotes.firm(request),
273
+ // THE RELAY PATH: one recovery key in, signed orderQuote and its maker view half out.
274
+ firmRelay: (request) => quotes.firmRelay(request),
273
275
  // A REVERSE TICKET: the node signs it; its claim tip is checked here before the caller sees it.
274
276
  sale: (request) => quotes.sale(request),
275
277
  },
@@ -269,6 +269,8 @@ export declare function recoveryFileName(swapId?: string | null): string;
269
269
  export declare const RECOVERY_KEY_VERSION: number;
270
270
  /** Twelve words, by the BIP39 rules. */
271
271
  export declare const RECOVERY_KEY_WORDS: number;
272
+ /** The default number of v1 indices one `findSwaps` call scans. */
273
+ export declare const RECOVERY_SCAN_DEFAULT: number;
272
274
  /** The store key a browser adapter keeps the key under. */
273
275
  export declare const RECOVERY_KEY_STORE: string;
274
276
  /** The first line of a plain backup file. */
@@ -431,8 +433,15 @@ export declare function findSwaps(request: {
431
433
  toBlock?: number | string;
432
434
  /** Deprecated and ignored: discovery no longer stops after a run of empty numbers. */
433
435
  gap?: number;
434
- /** The last index to try. Defaults to the allocator's ceiling; a smaller value returns `complete: false`. */
436
+ /** The first index to try. Defaults to 0; a continuation passes the previous `nextIndex`. */
437
+ fromIndex?: number;
438
+ /** How many indices this call may try. Defaults to `RECOVERY_SCAN_DEFAULT` (1000), not the allocator ceiling. */
435
439
  maxIndex?: number;
440
+ /** Stop the scan between chunks. An aborted scan returns `complete: false`, `incompleteReason: "aborted"` and
441
+ * the `nextIndex` to resume from. */
442
+ signal?: { readonly aborted: boolean } | null;
443
+ /** Called after every chunk and after the last index, with the count and the index just scanned. */
444
+ onProgress?: ((value: { readonly scanned: number; readonly index: number }) => void) | null;
436
445
  /** Reads the settlement block timestamp, in seconds: (blockNumber) -> seconds. */
437
446
  evmBlockTime?: (blockNumber: number) => Promise<number>;
438
447
  /** Reads contract code: (address) -> 0x-hex. Used to read the XMR amount from an order clone. */
@@ -453,8 +462,10 @@ export declare function findSwaps(request: {
453
462
  scanned: number;
454
463
  /** The last index tried, or null when no index was scanned. */
455
464
  searchedUpTo: number | null;
456
- /** True only when the whole allocator range was searched. */
465
+ /** True when every order in the logs was matched, or when the allocator range was searched to its end. */
457
466
  complete: boolean;
458
- /** `max-index` when a smaller caller-supplied bound stopped the search. */
467
+ /** `max-index` or `aborted` when a bound or the caller stopped the search before every order was matched. */
459
468
  incompleteReason?: string;
469
+ /** The next index to pass as `fromIndex`; absent when the search is complete. */
470
+ nextIndex?: number;
460
471
  }>;
@@ -33,6 +33,7 @@ export {
33
33
  RECOVERY_KEY_VERSION,
34
34
  MAKER_VIEW_HALF_DOMAIN,
35
35
  RECOVERY_KEY_WORDS,
36
+ RECOVERY_SCAN_DEFAULT,
36
37
  commitRecoveryIndex,
37
38
  createRecoveryKey,
38
39
  createRecoveryKeyFile,
package/src/quotes.mjs CHANGED
@@ -21,10 +21,13 @@ import * as engine from "./engine.mjs";
21
21
  import { orderQuoteMissingFields, recoverOrderQuoteKey } from "./quoteEip712.mjs";
22
22
  import { claimGasGiftWei } from "../engine/evm/claimGas.js";
23
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";
24
27
  // THE CHAIN READ NEEDED TO ASK THE REGISTRY WHO OWNS THE KEY. The selector is COMPUTED from the signature, not
25
28
  // remembered: a single wrong hex digit would call another function and return a zero address - and a zero
26
29
  // address is exactly the answer "nobody owns this key", which the caller would take as the truth.
27
- import { keccak256 } from "./primitives.mjs";
30
+ import { keccak256, secp256k1 } from "./primitives.mjs";
28
31
 
29
32
  const utf8 = (value) => new TextEncoder().encode(String(value));
30
33
  const bytesToHexQ = (bytes) => { let out = "0x"; for (const b of bytes) out += b.toString(16).padStart(2, "0"); return out; };
@@ -42,6 +45,8 @@ const hexToBytesQ = (hex) => {
42
45
  return out;
43
46
  };
44
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();
45
50
  const addressFromWord = (raw) => {
46
51
  const h = String(raw == null ? "" : raw).replace(/^0x/, "");
47
52
  return h.length >= 40 ? "0x" + h.slice(-40).toLowerCase() : null;
@@ -813,5 +818,244 @@ export function createQuotes({ config, http, now = () => Date.now(), evmCall = n
813
818
  }
814
819
  return quote;
815
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
+ },
816
1060
  };
817
1061
  }