@ninsei-labs/sdk 0.53.0 → 0.55.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/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
  }
package/src/record.mjs CHANGED
@@ -54,6 +54,16 @@ export function createRecord(input, { order = null, side: knownSide = null } = {
54
54
  // both the slot check against the chain and the address check read them.
55
55
  half: halfHex(side.half), viewHalf: halfHex(side.viewHalf),
56
56
  edPointLocker: norm(side.pub.ed), edViewPointLocker: norm(side.viewPub) };
57
+ // THE XMR AMOUNT THE PROVIDER SIGNED, IN ATOMIC UNITS. The record's `xmrAmount` is a display number rounded
58
+ // from the quote; the signed order carries the exact integer. The arrival check compares against the signed
59
+ // figure, so it must survive the record and the recovery file unchanged (never re-derived from the display
60
+ // number). A malformed value is a refusal before anything is saved.
61
+ if (input.expectedAtomic !== undefined && input.expectedAtomic !== null) {
62
+ let atomic;
63
+ try { atomic = BigInt(String(input.expectedAtomic)); } catch { throw new SdkError("bad-input", { field: "expectedAtomic" }); }
64
+ if (atomic <= 0n) throw new SdkError("bad-input", { field: "expectedAtomic" });
65
+ escrow.expectedAtomic = atomic.toString();
66
+ }
57
67
  // THE SIDE'S ENCRYPTION KEY - only the public one: the private one stays with the side and never enters the record.
58
68
  if (side.enc && side.enc.pub) escrow.encPub = norm(side.enc.pub);
59
69
  const record = { ...swap, escrow };
@@ -30,6 +30,13 @@ export const RECOVERY_KEY_WORDS = 12;
30
30
  // The allocator's ceiling. Discovery must use the same range: a search that stops earlier can miss an order
31
31
  // whose number was skipped by an abandoned start or by a swap on another chain.
32
32
  export const RECOVERY_INDEX_MAX = 100000;
33
+ // A DISCOVERY SCAN IS BOUNDED BY DEFAULT. The allocator can reach 100,000, but deriving a secp256k1 point for
34
+ // every number on a phone is not a page-open cost. One thousand indices keeps the common case inside a short
35
+ // call and, more importantly, the result NAMES that it stopped: the caller can continue from `nextIndex`.
36
+ export const RECOVERY_SCAN_DEFAULT = 1000;
37
+ // The scan yields to the event loop between chunks. A page must be able to paint and answer a cancel click even
38
+ // when a caller explicitly asks for a large range; a synchronous loop of 100,000 derivations froze the page.
39
+ export const RECOVERY_SCAN_YIELD_EVERY = 64;
33
40
  export const RECOVERY_KEY_STORE = "ninsei.recovery.key.v1";
34
41
  export const RECOVERY_KEY_FILE_MAGIC = "NINSEI RECOVERY KEY";
35
42
  export const RECOVERY_KEY_DOMAIN = "ninsei-taker-v1";
@@ -650,7 +657,9 @@ async function recoveredStartHeight(order, { evmBlockTime, moneroTip, moneroBloc
650
657
  * positions) and merges the answers. Without one, every OrderCreated of the factory is read and matched locally.
651
658
  */
652
659
  export async function findSwaps({ mnemonic, chainId, factory, address = null, evmLogs,
653
- fromBlock = null, deploymentBlock = null, toBlock = "latest", gap = 5, maxIndex = RECOVERY_INDEX_MAX,
660
+ fromBlock = null, deploymentBlock = null, toBlock = "latest", gap = 5,
661
+ fromIndex = 0, maxIndex = RECOVERY_SCAN_DEFAULT,
662
+ signal = null, onProgress = null,
654
663
  evmBlockTime = null, evmCode = null, moneroTip = null, moneroBlockTimestamp = null,
655
664
  xmrNodes = null, moneroNetwork = null, fetchImpl = globalThis.fetch } = {}) {
656
665
  if (typeof factory !== "string" || !ADDRESS_RE.test(factory)) throw bad("factory");
@@ -659,7 +668,10 @@ export async function findSwaps({ mnemonic, chainId, factory, address = null, ev
659
668
  // numbers are ordinary when a start was abandoned or the key was used on another chain, and stopping there
660
669
  // hides a funded order whose log is already in `logs`.
661
670
  void gap;
662
- const max = Math.min(numberField(maxIndex, "maxIndex") || RECOVERY_INDEX_MAX, RECOVERY_INDEX_MAX);
671
+ const startIndex = numberField(fromIndex, "fromIndex");
672
+ if (startIndex < 0 || startIndex >= RECOVERY_INDEX_MAX) throw bad("fromIndex");
673
+ const asked = numberField(maxIndex, "maxIndex");
674
+ const max = Math.min(asked, RECOVERY_INDEX_MAX - startIndex);
663
675
  // THE CHAIN SAYS WHERE ITS ORDERS CAN START. An explicit fromBlock wins; otherwise the caller can pass the
664
676
  // factory's deployment block (the SDK's chain registry exposes it as escrow.deploymentBlock). Zero remains the
665
677
  // last resort for a caller that knows the factory existed at genesis.
@@ -733,19 +745,45 @@ export async function findSwaps({ mnemonic, chainId, factory, address = null, ev
733
745
  byCommit.get(claimer).push({ order, side: "claimer" });
734
746
  }
735
747
  }
736
- const v1Needed = orders.some((order) => !v2Matched.has(orderKey(order)));
748
+ const v1Keys = orders.filter((order) => !v2Matched.has(orderKey(order))).map(orderKey);
749
+ const v1Needed = v1Keys.length > 0;
750
+ const remaining = new Set(v1Keys);
737
751
  let scanned = 0;
738
- if (v1Needed) {
739
- for (let index = 0; index < max; index += 1) {
740
- scanned = index + 1;
752
+ let stoppedBy = null;
753
+ if (onProgress !== null && typeof onProgress !== "function") throw bad("onProgress");
754
+ if (v1Needed && signal && signal.aborted === true) stoppedBy = "aborted";
755
+ if (v1Needed && stoppedBy === null) {
756
+ for (let offset = 0; offset < max; offset += 1) {
757
+ const index = startIndex + offset;
758
+ scanned = offset + 1;
741
759
  const commitment = spendCommitmentFromSeed(seed, chain, index);
742
760
  const matches = byCommit.get(commitment) || [];
743
761
  for (const { order, side } of matches) {
744
762
  const key = orderKey(order);
745
763
  if (foundKeys.has(key)) continue;
746
764
  foundKeys.add(key);
765
+ remaining.delete(key);
747
766
  found.push({ ...order, index, derivation: "index-v1", side });
748
767
  }
768
+ // EVERY MATCHED ORDER ENDS ITS OWN SEARCH. A key with only its own orders in the logs does not pay for
769
+ // the rest of the bound once the last one is found. A shared factory keeps the unrelated logs, so the
770
+ // scan continues to the requested bound and reports that it stopped there.
771
+ const final = remaining.size === 0 || offset === max - 1;
772
+ if (final || scanned % RECOVERY_SCAN_YIELD_EVERY === 0) {
773
+ // Progress is advisory: a page callback that throws must not turn a completed scan into a failed one.
774
+ if (typeof onProgress === "function") {
775
+ try { onProgress({ scanned, index }); } catch { /* progress is not the result */ }
776
+ }
777
+ }
778
+ // A CALLER CAN CANCEL. The check is after the progress callback, so a worker that aborts from its
779
+ // progress handler stops at this chunk instead of finishing the requested range.
780
+ if (signal && signal.aborted === true) { stoppedBy = "aborted"; break; }
781
+ if (remaining.size === 0) break;
782
+ // A LARGE CALLER-SUPPLIED BOUND MUST NOT OWN THE MAIN THREAD. The scan yields between chunks so the page
783
+ // can paint, and a later call can continue from `nextIndex`.
784
+ if (scanned % RECOVERY_SCAN_YIELD_EVERY === 0) {
785
+ await new Promise((resolve) => setTimeout(resolve, 0));
786
+ }
749
787
  }
750
788
  }
751
789
  // EACH FOUND ORDER CARRIES THE MONERO HEIGHT ITS WITHDRAWAL MAY START FROM. The rule lives here, not in a
@@ -796,15 +834,17 @@ export async function findSwaps({ mnemonic, chainId, factory, address = null, ev
796
834
  order.xmrAmount = read.value;
797
835
  order.xmrAmountReason = read.reason;
798
836
  }
799
- const complete = !v1Needed || max >= RECOVERY_INDEX_MAX;
837
+ const searchedUpTo = scanned ? startIndex + scanned - 1 : null;
838
+ const complete = !v1Needed || remaining.size === 0 || startIndex + scanned >= RECOVERY_INDEX_MAX;
839
+ const nextIndex = complete ? null : startIndex + scanned;
800
840
  return {
801
841
  swaps: found,
802
842
  scanned,
803
843
  // A CALLER MUST NOT READ "NO SWAPS" AS "THE WHOLE RANGE WAS SEARCHED". The allocator ceiling is the only
804
844
  // complete range; a smaller caller-supplied bound says where the search stopped. A result made only of v2
805
845
  // orders has no range to search and is complete by construction.
806
- searchedUpTo: scanned ? scanned - 1 : null,
846
+ searchedUpTo,
807
847
  complete,
808
- ...(complete ? {} : { incompleteReason: "max-index" }),
848
+ ...(complete ? {} : { incompleteReason: stoppedBy || "max-index", nextIndex }),
809
849
  };
810
850
  }
@@ -0,0 +1,206 @@
1
+ // THE PUBLIC FEATURES A RELAY MAKER SIGNS.
2
+ //
3
+ // A relay price answer carries a `node_features` string signed by the maker's quote key. The client must check
4
+ // that signature before it uses the `hpkeKey`, the registry or the firm windows: an unsigned or substituted key
5
+ // would receive view material for another maker. This module owns the EIP-712 type, the relay domain and the
6
+ // verification; the request builder takes only the verified result.
7
+ //
8
+ // The public object has no contact fields. The relay forwards the exact string it received; this module hashes
9
+ // the decoded values, not a reconstructed subset.
10
+ import { SdkError } from "./errors.mjs";
11
+ import { keccak256, secp256k1 } from "./primitives.mjs";
12
+
13
+ export const RELAY_FEATURES_TYPE_NAME = "NodeFeatures";
14
+ export const RELAY_DOMAIN_NAME = "Ninsei Relay";
15
+ export const RELAY_DOMAIN_VERSION = "1";
16
+
17
+ const DOMAIN_TYPE = "EIP712Domain(string name,string version)";
18
+ export const FIRM_WINDOW_TYPE =
19
+ "FirmWindow(string market,string side,string xmrNetwork,uint32 readyMinSeconds,uint32 readyMaxSeconds,uint32 settlementMinSeconds,uint32 settlementMaxSeconds,uint32 quoteTtlSeconds)";
20
+ export const NODE_FEATURES_TYPE =
21
+ "NodeFeatures(string relayId,address provider,address quoteKey,address registry,string[] capabilities,bytes32 hpkeKey,FirmWindow[] firmWindows,uint64 time,bytes32 nonce)" +
22
+ FIRM_WINDOW_TYPE;
23
+
24
+ export const FIRM_WINDOW_FIELDS = [
25
+ ["market", "string"],
26
+ ["side", "string"],
27
+ ["xmrNetwork", "string"],
28
+ ["readyMinSeconds", "uint32"],
29
+ ["readyMaxSeconds", "uint32"],
30
+ ["settlementMinSeconds", "uint32"],
31
+ ["settlementMaxSeconds", "uint32"],
32
+ ["quoteTtlSeconds", "uint32"],
33
+ ];
34
+ export const NODE_FEATURES_FIELDS = [
35
+ ["relayId", "string"],
36
+ ["provider", "address"],
37
+ ["quoteKey", "address"],
38
+ ["registry", "address"],
39
+ ["capabilities", "string[]"],
40
+ ["hpkeKey", "bytes32"],
41
+ ["firmWindows", "FirmWindow[]"],
42
+ ["time", "uint64"],
43
+ ["nonce", "bytes32"],
44
+ ];
45
+
46
+ const utf8 = (value) => new TextEncoder().encode(String(value));
47
+ const concat = (...arrays) => {
48
+ const total = arrays.reduce((n, a) => n + a.length, 0);
49
+ const out = new Uint8Array(total);
50
+ let at = 0;
51
+ for (const array of arrays) { out.set(array, at); at += array.length; }
52
+ return out;
53
+ };
54
+ const hexBytes = (value, field, width) => {
55
+ const text = String(value === undefined || value === null ? "" : value).replace(/^0x/, "");
56
+ if (!new RegExp("^[0-9a-fA-F]{" + width * 2 + "}$").test(text)) {
57
+ throw new SdkError("bad-input", { field });
58
+ }
59
+ const out = new Uint8Array(width);
60
+ for (let i = 0; i < width; i += 1) out[i] = parseInt(text.slice(i * 2, i * 2 + 2), 16);
61
+ return out;
62
+ };
63
+ const numberWord = (value, field, bits) => {
64
+ let n = null;
65
+ try { n = BigInt(String(value === undefined || value === null ? "" : value).trim()); } catch { n = null; }
66
+ if (n === null || n < 0n || n >= (1n << BigInt(bits))) throw new SdkError("bad-input", { field });
67
+ const out = new Uint8Array(32);
68
+ for (let i = 31; i >= 0 && n > 0n; i -= 1) { out[i] = Number(n & 0xffn); n >>= 8n; }
69
+ return out;
70
+ };
71
+ const addressWord = (value, field) => {
72
+ const text = String(value === undefined || value === null ? "" : value).trim();
73
+ if (!/^(0x)?[0-9a-fA-F]{40}$/.test(text)) throw new SdkError("bad-input", { field });
74
+ const out = new Uint8Array(32);
75
+ out.set(hexBytes(text, field, 20), 12);
76
+ return out;
77
+ };
78
+ const stringWord = (value) => keccak256(utf8(value === undefined || value === null ? "" : value));
79
+ const arrayWord = (values, field, hashOf) => {
80
+ if (!Array.isArray(values)) throw new SdkError("bad-input", { field });
81
+ const parts = values.map((value) => hashOf(value));
82
+ return keccak256(concat(...parts));
83
+ };
84
+
85
+ function firmWindowHash(window) {
86
+ if (!window || typeof window !== "object") throw new SdkError("bad-input", { field: "firmWindows" });
87
+ const parts = [keccak256(utf8(FIRM_WINDOW_TYPE))];
88
+ for (const [field, type] of FIRM_WINDOW_FIELDS) {
89
+ parts.push(type === "string" ? stringWord(window[field]) : numberWord(window[field], "firmWindows." + field, 32));
90
+ }
91
+ return keccak256(concat(...parts));
92
+ }
93
+
94
+ export function nodeFeaturesStructHash(features) {
95
+ if (!features || typeof features !== "object") throw new SdkError("bad-input", { field: "features" });
96
+ const parts = [keccak256(utf8(NODE_FEATURES_TYPE))];
97
+ for (const [field, type] of NODE_FEATURES_FIELDS) {
98
+ if (type === "string") parts.push(stringWord(features[field]));
99
+ else if (type === "address") parts.push(addressWord(features[field], "features." + field));
100
+ else if (type === "bytes32") parts.push(hexBytes(features[field], "features." + field, 32));
101
+ else if (type === "uint64") parts.push(numberWord(features[field], "features." + field, 64));
102
+ else if (type === "string[]") parts.push(arrayWord(features[field], "features." + field, (value) => stringWord(value)));
103
+ else if (type === "FirmWindow[]") parts.push(arrayWord(features[field], "features." + field, firmWindowHash));
104
+ else throw new SdkError("bad-input", { field: "features." + field });
105
+ }
106
+ return keccak256(concat(...parts));
107
+ }
108
+
109
+ export function nodeFeaturesDomainSeparator() {
110
+ return keccak256(concat(
111
+ keccak256(utf8(DOMAIN_TYPE)),
112
+ keccak256(utf8(RELAY_DOMAIN_NAME)),
113
+ keccak256(utf8(RELAY_DOMAIN_VERSION)),
114
+ ));
115
+ }
116
+
117
+ export function nodeFeaturesDigest(features) {
118
+ return keccak256(concat(Uint8Array.from([0x19, 0x01]), nodeFeaturesDomainSeparator(), nodeFeaturesStructHash(features)));
119
+ }
120
+
121
+ const bytesToHex = (bytes) => {
122
+ let out = "0x";
123
+ for (const byte of bytes) out += byte.toString(16).padStart(2, "0");
124
+ return out;
125
+ };
126
+
127
+ function recoverAddress(digest, signature) {
128
+ const sig = String(signature || "").replace(/^0x/, "");
129
+ if (!/^[0-9a-fA-F]{130}$/.test(sig)) return null;
130
+ let v = parseInt(sig.slice(128, 130), 16);
131
+ if (v >= 27) v -= 27;
132
+ if (v !== 0 && v !== 1) return null;
133
+ try {
134
+ const publicKey = secp256k1.Signature.fromBytes(concat(
135
+ hexBytes(sig.slice(0, 64), "signature", 32),
136
+ hexBytes(sig.slice(64, 128), "signature", 32),
137
+ )).addRecoveryBit(v).recoverPublicKey(digest);
138
+ return bytesToHex(keccak256(publicKey.toBytes(false).slice(1)).slice(12));
139
+ } catch {
140
+ return null;
141
+ }
142
+ }
143
+
144
+ /**
145
+ * Verify the signed public features. `terms` optionally binds the features to one request: the provider, the
146
+ * registry, the market, the side and the Monero network must agree. A zero key, a short key, a missing or empty
147
+ * window list, or a signature from another key is a refusal.
148
+ */
149
+ export function verifyNodeFeatures({ features, terms = null } = {}) {
150
+ if (!features || typeof features !== "object") throw new SdkError("bad-input", { field: "features" });
151
+ if (features.type !== "node_features" || Number(features.version) !== 1) {
152
+ throw new SdkError("quote-refused", { step: "features", why: "version" });
153
+ }
154
+ const digest = nodeFeaturesDigest(features);
155
+ const recovered = recoverAddress(digest, features.signature);
156
+ const quoteKey = String(features.quoteKey || "").toLowerCase();
157
+ if (recovered === null || recovered !== quoteKey) {
158
+ throw new SdkError("quote-refused", { step: "features", why: "signature" });
159
+ }
160
+ const hpkeKey = String(features.hpkeKey || "").replace(/^0x/, "").toLowerCase();
161
+ if (!Array.isArray(features.capabilities) || features.capabilities.length === 0
162
+ || features.capabilities.length > 16
163
+ || new Set(features.capabilities).size !== features.capabilities.length
164
+ || features.capabilities.some((name) => typeof name !== "string" || name === ""
165
+ || name.length > 32 || !/^[ -~]+$/.test(name))) {
166
+ throw new SdkError("quote-refused", { step: "features", why: "capabilities" });
167
+ }
168
+ const firm = features.capabilities.includes("firm/1");
169
+ if (firm && (!/^[0-9a-f]{64}$/.test(hpkeKey) || /^0+$/.test(hpkeKey))) {
170
+ throw new SdkError("quote-refused", { step: "features", why: "hpke-key" });
171
+ }
172
+ if (firm && (!Array.isArray(features.firmWindows) || features.firmWindows.length === 0)) {
173
+ throw new SdkError("quote-refused", { step: "features", why: "windows" });
174
+ }
175
+ if (!firm && hpkeKey !== "0".repeat(64)) {
176
+ throw new SdkError("quote-refused", { step: "features", why: "hpke-key" });
177
+ }
178
+ if (!firm && Array.isArray(features.firmWindows) && features.firmWindows.length !== 0) {
179
+ throw new SdkError("quote-refused", { step: "features", why: "windows" });
180
+ }
181
+ if (terms) {
182
+ if (String(features.provider || "").toLowerCase() !== String(terms.maker || "").toLowerCase()
183
+ || String(features.registry || "").toLowerCase() !== String(terms.registry || "").toLowerCase()) {
184
+ throw new SdkError("quote-refused", { step: "features", why: "identity" });
185
+ }
186
+ const market = String(terms.market || "");
187
+ const side = String(terms.side || "");
188
+ const network = String(terms.xmrNetwork || "");
189
+ const window = features.firmWindows.find((entry) => entry
190
+ && entry.market === market && entry.side === side && entry.xmrNetwork === network);
191
+ if (!window) throw new SdkError("quote-refused", { step: "features", why: "window" });
192
+ return { featuresHash: bytesToHex(digest), hpkeKey: "0x" + hpkeKey, window };
193
+ }
194
+ return { featuresHash: bytesToHex(digest), hpkeKey: "0x" + hpkeKey, window: null };
195
+ }
196
+
197
+ // Absent fields are named, not zero-filled.
198
+ export function nodeFeaturesMissingFields(features) {
199
+ if (!features || typeof features !== "object") return ["features"];
200
+ const missing = [];
201
+ for (const [field] of NODE_FEATURES_FIELDS) {
202
+ const value = features[field];
203
+ if (value === undefined || value === null || value === "") missing.push(field);
204
+ }
205
+ return missing;
206
+ }