@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.
@@ -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,191 @@
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,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
+ ["hpkeKey", "bytes32"],
40
+ ["firmWindows", "FirmWindow[]"],
41
+ ["time", "uint64"],
42
+ ["nonce", "bytes32"],
43
+ ];
44
+
45
+ const utf8 = (value) => new TextEncoder().encode(String(value));
46
+ const concat = (...arrays) => {
47
+ const total = arrays.reduce((n, a) => n + a.length, 0);
48
+ const out = new Uint8Array(total);
49
+ let at = 0;
50
+ for (const array of arrays) { out.set(array, at); at += array.length; }
51
+ return out;
52
+ };
53
+ const hexBytes = (value, field, width) => {
54
+ const text = String(value === undefined || value === null ? "" : value).replace(/^0x/, "");
55
+ if (!new RegExp("^[0-9a-fA-F]{" + width * 2 + "}$").test(text)) {
56
+ throw new SdkError("bad-input", { field });
57
+ }
58
+ const out = new Uint8Array(width);
59
+ for (let i = 0; i < width; i += 1) out[i] = parseInt(text.slice(i * 2, i * 2 + 2), 16);
60
+ return out;
61
+ };
62
+ const numberWord = (value, field, bits) => {
63
+ let n = null;
64
+ try { n = BigInt(String(value === undefined || value === null ? "" : value).trim()); } catch { n = null; }
65
+ if (n === null || n < 0n || n >= (1n << BigInt(bits))) throw new SdkError("bad-input", { field });
66
+ const out = new Uint8Array(32);
67
+ for (let i = 31; i >= 0 && n > 0n; i -= 1) { out[i] = Number(n & 0xffn); n >>= 8n; }
68
+ return out;
69
+ };
70
+ const addressWord = (value, field) => {
71
+ const text = String(value === undefined || value === null ? "" : value).trim();
72
+ if (!/^(0x)?[0-9a-fA-F]{40}$/.test(text)) throw new SdkError("bad-input", { field });
73
+ const out = new Uint8Array(32);
74
+ out.set(hexBytes(text, field, 20), 12);
75
+ return out;
76
+ };
77
+ const stringWord = (value) => keccak256(utf8(value === undefined || value === null ? "" : value));
78
+ const arrayWord = (values, field, hashOf) => {
79
+ if (!Array.isArray(values)) throw new SdkError("bad-input", { field });
80
+ const parts = values.map((value) => hashOf(value));
81
+ return keccak256(concat(...parts));
82
+ };
83
+
84
+ function firmWindowHash(window) {
85
+ if (!window || typeof window !== "object") throw new SdkError("bad-input", { field: "firmWindows" });
86
+ const parts = [keccak256(utf8(FIRM_WINDOW_TYPE))];
87
+ for (const [field, type] of FIRM_WINDOW_FIELDS) {
88
+ parts.push(type === "string" ? stringWord(window[field]) : numberWord(window[field], "firmWindows." + field, 32));
89
+ }
90
+ return keccak256(concat(...parts));
91
+ }
92
+
93
+ export function nodeFeaturesStructHash(features) {
94
+ if (!features || typeof features !== "object") throw new SdkError("bad-input", { field: "features" });
95
+ const parts = [keccak256(utf8(NODE_FEATURES_TYPE))];
96
+ for (const [field, type] of NODE_FEATURES_FIELDS) {
97
+ if (type === "string") parts.push(stringWord(features[field]));
98
+ else if (type === "address") parts.push(addressWord(features[field], "features." + field));
99
+ else if (type === "bytes32") parts.push(hexBytes(features[field], "features." + field, 32));
100
+ else if (type === "uint64") parts.push(numberWord(features[field], "features." + field, 64));
101
+ else if (type === "string[]") parts.push(arrayWord(features[field], "features." + field, (value) => stringWord(value)));
102
+ else if (type === "FirmWindow[]") parts.push(arrayWord(features[field], "features." + field, firmWindowHash));
103
+ else throw new SdkError("bad-input", { field: "features." + field });
104
+ }
105
+ return keccak256(concat(...parts));
106
+ }
107
+
108
+ export function nodeFeaturesDomainSeparator() {
109
+ return keccak256(concat(
110
+ keccak256(utf8(DOMAIN_TYPE)),
111
+ keccak256(utf8(RELAY_DOMAIN_NAME)),
112
+ keccak256(utf8(RELAY_DOMAIN_VERSION)),
113
+ ));
114
+ }
115
+
116
+ export function nodeFeaturesDigest(features) {
117
+ return keccak256(concat(Uint8Array.from([0x19, 0x01]), nodeFeaturesDomainSeparator(), nodeFeaturesStructHash(features)));
118
+ }
119
+
120
+ const bytesToHex = (bytes) => {
121
+ let out = "0x";
122
+ for (const byte of bytes) out += byte.toString(16).padStart(2, "0");
123
+ return out;
124
+ };
125
+
126
+ function recoverAddress(digest, signature) {
127
+ const sig = String(signature || "").replace(/^0x/, "");
128
+ if (!/^[0-9a-fA-F]{130}$/.test(sig)) return null;
129
+ let v = parseInt(sig.slice(128, 130), 16);
130
+ if (v >= 27) v -= 27;
131
+ if (v !== 0 && v !== 1) return null;
132
+ try {
133
+ const publicKey = secp256k1.Signature.fromBytes(concat(
134
+ hexBytes(sig.slice(0, 64), "signature", 32),
135
+ hexBytes(sig.slice(64, 128), "signature", 32),
136
+ )).addRecoveryBit(v).recoverPublicKey(digest);
137
+ return bytesToHex(keccak256(publicKey.toBytes(false).slice(1)).slice(12));
138
+ } catch {
139
+ return null;
140
+ }
141
+ }
142
+
143
+ /**
144
+ * Verify the signed public features. `terms` optionally binds the features to one request: the provider, the
145
+ * registry, the market, the side and the Monero network must agree. A zero key, a short key, a missing or empty
146
+ * window list, or a signature from another key is a refusal.
147
+ */
148
+ export function verifyNodeFeatures({ features, terms = null } = {}) {
149
+ if (!features || typeof features !== "object") throw new SdkError("bad-input", { field: "features" });
150
+ if (features.type !== "node_features" || Number(features.version) !== 1) {
151
+ throw new SdkError("quote-refused", { step: "features", why: "version" });
152
+ }
153
+ const digest = nodeFeaturesDigest(features);
154
+ const recovered = recoverAddress(digest, features.signature);
155
+ const quoteKey = String(features.quoteKey || "").toLowerCase();
156
+ if (recovered === null || recovered !== quoteKey) {
157
+ throw new SdkError("quote-refused", { step: "features", why: "signature" });
158
+ }
159
+ const hpkeKey = String(features.hpkeKey || "").replace(/^0x/, "").toLowerCase();
160
+ if (!/^[0-9a-f]{64}$/.test(hpkeKey) || /^0+$/.test(hpkeKey)) {
161
+ throw new SdkError("quote-refused", { step: "features", why: "hpke-key" });
162
+ }
163
+ if (!Array.isArray(features.firmWindows) || features.firmWindows.length === 0) {
164
+ throw new SdkError("quote-refused", { step: "features", why: "windows" });
165
+ }
166
+ if (terms) {
167
+ if (String(features.provider || "").toLowerCase() !== String(terms.maker || "").toLowerCase()
168
+ || String(features.registry || "").toLowerCase() !== String(terms.registry || "").toLowerCase()) {
169
+ throw new SdkError("quote-refused", { step: "features", why: "identity" });
170
+ }
171
+ const market = String(terms.market || "");
172
+ const side = String(terms.side || "");
173
+ const network = String(terms.xmrNetwork || "");
174
+ const window = features.firmWindows.find((entry) => entry
175
+ && entry.market === market && entry.side === side && entry.xmrNetwork === network);
176
+ if (!window) throw new SdkError("quote-refused", { step: "features", why: "window" });
177
+ return { featuresHash: bytesToHex(digest), hpkeKey: "0x" + hpkeKey, window };
178
+ }
179
+ return { featuresHash: bytesToHex(digest), hpkeKey: "0x" + hpkeKey, window: null };
180
+ }
181
+
182
+ // Absent fields are named, not zero-filled.
183
+ export function nodeFeaturesMissingFields(features) {
184
+ if (!features || typeof features !== "object") return ["features"];
185
+ const missing = [];
186
+ for (const [field] of NODE_FEATURES_FIELDS) {
187
+ const value = features[field];
188
+ if (value === undefined || value === null || value === "") missing.push(field);
189
+ }
190
+ return missing;
191
+ }
@@ -0,0 +1,86 @@
1
+ // ONE HTTP HOP TO A RELAY FIRM QUOTE.
2
+ //
3
+ // The three crypto layers are already separate: features, request and answer. This module joins them to the
4
+ // wire: it builds the exact payload, sends it once, maps a refusal to a named code and verifies the answer
5
+ // before returning anything a caller could fund. The payload string is never re-serialised.
6
+ import { SdkError } from "./errors.mjs";
7
+ import { createHttp } from "./http.mjs";
8
+ import { buildFirmRequest } from "./relayRequest.mjs";
9
+ import { verifyFirmAnswer } from "./relayQuote.mjs";
10
+
11
+ const REFUSAL_CODES = new Set([
12
+ "compliance",
13
+ "no_liquidity",
14
+ "size",
15
+ "paused",
16
+ "unavailable",
17
+ "bad_request",
18
+ "price_changed",
19
+ ]);
20
+ const sameRelay = (a, b) => String(a === undefined || a === null ? "" : a) === String(b === undefined || b === null ? "" : b);
21
+
22
+ /**
23
+ * Request a firm quote from a relay.
24
+ *
25
+ * `relayUrl` is the relay origin (for example `https://relay.example`), `partner` is the optional partner
26
+ * label and every other option is passed to `buildFirmRequest`. A transport error is
27
+ * `server-unavailable`; a relay refusal keeps its code in `quote-refused`. A timeout is not retried here:
28
+ * the salt is part of the signed terms, so a retry needs the caller's decision and a fresh proof.
29
+ */
30
+ export async function requestFirmQuote({
31
+ relayUrl,
32
+ partner = null,
33
+ fetchImpl = null,
34
+ timeoutMs = 20_000,
35
+ ...request
36
+ } = {}) {
37
+ const base = String(relayUrl === undefined || relayUrl === null ? "" : relayUrl).trim().replace(/\/+$/, "");
38
+ if (!base) throw new SdkError("bad-input", { field: "relayUrl" });
39
+
40
+ const built = await buildFirmRequest(request);
41
+ const body = JSON.stringify({
42
+ version: 1,
43
+ payload: built.payload,
44
+ ...(partner ? { partner: String(partner) } : {}),
45
+ });
46
+
47
+ const http = createHttp({ apiBase: base, fetchImpl, timeoutMs });
48
+ const response = await http.tryJson("/v1/quote", {
49
+ method: "POST",
50
+ headers: { "content-type": "application/json" },
51
+ body,
52
+ });
53
+
54
+ if (!response.ok) {
55
+ const code = response.body && response.body.code ? String(response.body.code) : null;
56
+ if (code && REFUSAL_CODES.has(code)) {
57
+ throw new SdkError("quote-refused", { step: "relay", code, status: response.status });
58
+ }
59
+ throw new SdkError("server-unavailable", { step: "relay", status: response.status });
60
+ }
61
+
62
+ const answer = response.body && response.body.answer;
63
+ if (!response.body || Number(response.body.version) !== 1 || !answer) {
64
+ throw new SdkError("quote-refused", { step: "answer", why: "envelope" });
65
+ }
66
+ if (typeof response.body.relay !== "string" || !response.body.relay
67
+ || !response.body.forwarded || response.body.forwarded.relayId !== response.body.relay) {
68
+ throw new SdkError("quote-refused", { step: "answer", why: "relay" });
69
+ }
70
+ if (!sameRelay(answer.relayId, response.body.relay)) {
71
+ throw new SdkError("quote-refused", { step: "answer", why: "relay" });
72
+ }
73
+
74
+ const verified = verifyFirmAnswer({
75
+ answer,
76
+ features: request.features,
77
+ request: built,
78
+ });
79
+ return {
80
+ ...verified,
81
+ relay: response.body.relay ?? null,
82
+ offered: response.body.offered ?? null,
83
+ forwarded: response.body.forwarded ?? null,
84
+ answer,
85
+ };
86
+ }
@@ -0,0 +1,209 @@
1
+ // THE SIGNED ANSWER A RELAY RETURNS.
2
+ //
3
+ // The relay's outer `quote` frame is signed by the maker's quote key in the relay domain. Its `payload` is a
4
+ // JSON string with the factory, the signed `OrderQuote`, the node half and both view points. This module checks
5
+ // both signatures and binds the answer to the request terms before the caller can fund anything.
6
+ import { SdkError } from "./errors.mjs";
7
+ import { keccak256, secp256k1 } from "./primitives.mjs";
8
+ import { recoverOrderQuoteKey } from "./quoteEip712.mjs";
9
+ import * as engine from "./engine.mjs";
10
+
11
+ export const RELAY_QUOTE_TYPE =
12
+ "Quote(string relayId,address provider,bytes32 session,bytes32 requestId,uint64 counter,bytes32 requestHash,string payload,uint64 time)";
13
+
14
+ const DOMAIN_TYPE = "EIP712Domain(string name,string version)";
15
+ const DOMAIN_NAME = "Ninsei Relay";
16
+ const DOMAIN_VERSION = "1";
17
+
18
+ const utf8 = (value) => new TextEncoder().encode(String(value));
19
+ const concat = (...arrays) => {
20
+ const total = arrays.reduce((n, a) => n + a.length, 0);
21
+ const out = new Uint8Array(total);
22
+ let at = 0;
23
+ for (const array of arrays) { out.set(array, at); at += array.length; }
24
+ return out;
25
+ };
26
+ const hexBytes = (value, field, width) => {
27
+ const text = String(value === undefined || value === null ? "" : value).replace(/^0x/, "");
28
+ if (!new RegExp("^[0-9a-fA-F]{" + width * 2 + "}$").test(text)) throw new SdkError("bad-input", { field });
29
+ const out = new Uint8Array(width);
30
+ for (let i = 0; i < width; i += 1) out[i] = parseInt(text.slice(i * 2, i * 2 + 2), 16);
31
+ return out;
32
+ };
33
+ const wordAddress = (value, field) => {
34
+ const out = new Uint8Array(32);
35
+ out.set(hexBytes(value, field, 20), 12);
36
+ return out;
37
+ };
38
+ const wordBytes32 = (value, field) => hexBytes(value, field, 32);
39
+ const wordUint = (value, field) => {
40
+ let n = null;
41
+ try { n = BigInt(value); } catch { n = null; }
42
+ if (n === null || n < 0n) throw new SdkError("bad-input", { field });
43
+ const out = new Uint8Array(32);
44
+ for (let i = 31; i >= 0 && n > 0n; i -= 1) { out[i] = Number(n & 0xffn); n >>= 8n; }
45
+ return out;
46
+ };
47
+ const wordString = (value) => keccak256(utf8(value === undefined || value === null ? "" : value));
48
+
49
+ export function relayQuoteStructHash(answer) {
50
+ if (!answer || typeof answer !== "object") throw new SdkError("bad-input", { field: "answer" });
51
+ return keccak256(concat(
52
+ keccak256(utf8(RELAY_QUOTE_TYPE)),
53
+ wordString(answer.relayId),
54
+ wordAddress(answer.provider, "answer.provider"),
55
+ wordBytes32(answer.session, "answer.session"),
56
+ wordBytes32(answer.requestId, "answer.requestId"),
57
+ wordUint(answer.counter, "answer.counter"),
58
+ wordBytes32(answer.requestHash, "answer.requestHash"),
59
+ wordString(answer.payload),
60
+ wordUint(answer.time, "answer.time"),
61
+ ));
62
+ }
63
+
64
+ export function relayQuoteDomainSeparator() {
65
+ return keccak256(concat(
66
+ keccak256(utf8(DOMAIN_TYPE)),
67
+ keccak256(utf8(DOMAIN_NAME)),
68
+ keccak256(utf8(DOMAIN_VERSION)),
69
+ ));
70
+ }
71
+
72
+ export function relayQuoteDigest(answer) {
73
+ return keccak256(concat(Uint8Array.from([0x19, 0x01]), relayQuoteDomainSeparator(), relayQuoteStructHash(answer)));
74
+ }
75
+
76
+ const hex = (bytes) => {
77
+ let out = "0x";
78
+ for (const byte of bytes) out += byte.toString(16).padStart(2, "0");
79
+ return out;
80
+ };
81
+ function recoverAddress(digest, signature) {
82
+ const sig = String(signature || "").replace(/^0x/, "");
83
+ if (!/^[0-9a-fA-F]{130}$/.test(sig)) return null;
84
+ let v = parseInt(sig.slice(128, 130), 16);
85
+ if (v >= 27) v -= 27;
86
+ if (v !== 0 && v !== 1) return null;
87
+ try {
88
+ const publicKey = secp256k1.Signature.fromBytes(concat(
89
+ hexBytes(sig.slice(0, 64), "signature", 32),
90
+ hexBytes(sig.slice(64, 128), "signature", 32),
91
+ )).addRecoveryBit(v).recoverPublicKey(digest);
92
+ return hex(keccak256(publicKey.toBytes(false).slice(1)).slice(12));
93
+ } catch {
94
+ return null;
95
+ }
96
+ }
97
+
98
+ // The outer frame: signed by the quote key in the relay domain.
99
+ export function verifyRelayQuoteFrame(answer, quoteKey) {
100
+ if (!answer || typeof answer !== "object" || answer.type !== "quote" || Number(answer.version) !== 1) {
101
+ throw new SdkError("quote-refused", { step: "answer", why: "frame" });
102
+ }
103
+ const recovered = recoverAddress(relayQuoteDigest(answer), answer.signature);
104
+ if (recovered === null || recovered !== String(quoteKey || "").toLowerCase()) {
105
+ throw new SdkError("quote-refused", { step: "answer", why: "signature" });
106
+ }
107
+ return answer;
108
+ }
109
+
110
+ const ZERO = "0x" + "00".repeat(20);
111
+ const lower = (value) => String(value === undefined || value === null ? "" : value).toLowerCase();
112
+ const same = (a, b) => lower(a) === lower(b);
113
+
114
+ function checkOrderQuote(quote, terms, nowSec) {
115
+ const order = terms.order;
116
+ if (!quote || typeof quote !== "object") throw new SdkError("quote-refused", { step: "answer", why: "quote" });
117
+ const pairs = [
118
+ ["chainId", String(order.chainId)],
119
+ ["factory", order.factory],
120
+ ["locker", order.locker],
121
+ ["claimer", order.claimer],
122
+ ["amount", String(order.amount)],
123
+ ["readyBy", String(order.readyBy)],
124
+ ["t1", String(order.t1)],
125
+ ["salt", order.salt],
126
+ ["xmrAmount", String(terms.xmrAmount)],
127
+ ["claimTip", String(terms.claimTip)],
128
+ ["provider", terms.maker],
129
+ ["registry", terms.registry],
130
+ ];
131
+ for (const [field, want] of pairs) {
132
+ if (!same(quote[field], want)) throw new SdkError("quote-refused", { step: "answer", why: "field", field });
133
+ }
134
+ if (same(quote.feeRecipient, ZERO)) {
135
+ throw new SdkError("quote-refused", { step: "answer", why: "fee-recipient" });
136
+ }
137
+ const bps = Number(quote.feeBps);
138
+ let fee = null;
139
+ let amount = null;
140
+ try { fee = BigInt(String(quote.fee)); amount = BigInt(String(order.amount)); }
141
+ catch { throw new SdkError("quote-refused", { step: "answer", why: "fee" }); }
142
+ if (!Number.isSafeInteger(bps) || bps < 0 || bps > 200) {
143
+ throw new SdkError("quote-refused", { step: "answer", why: "fee" });
144
+ }
145
+ if (fee !== (amount * BigInt(bps)) / 10000n) throw new SdkError("quote-refused", { step: "answer", why: "fee" });
146
+ const validUntil = Number(quote.validUntil);
147
+ if (!Number.isSafeInteger(validUntil) || validUntil <= nowSec) {
148
+ throw new SdkError("quote-expired", { step: "answer", validUntil: quote.validUntil });
149
+ }
150
+ }
151
+
152
+ /**
153
+ * Verify an answer against the request terms and the verified features. `request` is the result of
154
+ * `buildFirmRequest`: it carries the normalised terms and both view points.
155
+ */
156
+ export function verifyFirmAnswer({ answer, features, request, now = null } = {}) {
157
+ if (!request || !request.terms) throw new SdkError("bad-input", { field: "request" });
158
+ verifyRelayQuoteFrame(answer, features && features.quoteKey);
159
+ if (!answer.relayId || !same(answer.relayId, features && features.relayId)) {
160
+ throw new SdkError("quote-refused", { step: "answer", why: "relay" });
161
+ }
162
+ let payload = null;
163
+ try { payload = JSON.parse(answer.payload); } catch { throw new SdkError("quote-refused", { step: "answer", why: "payload" }); }
164
+ if (!payload || typeof payload !== "object" || !payload.quote) {
165
+ throw new SdkError("quote-refused", { step: "answer", why: "payload" });
166
+ }
167
+ if (payload.contextVersion !== "ninsei-order-v3" || Number(payload.quoteVersion) !== 9) {
168
+ throw new SdkError("quote-refused", { step: "answer", why: "version" });
169
+ }
170
+ if (!same(payload.factory, request.terms.order.factory)) {
171
+ throw new SdkError("quote-refused", { step: "answer", why: "factory" });
172
+ }
173
+ if (!same(answer.provider, request.terms.maker)) {
174
+ throw new SdkError("quote-refused", { step: "answer", why: "provider" });
175
+ }
176
+ const quote = { ...payload.quote, factory: request.terms.order.factory };
177
+ if (!quote.quoteSignature) quote.quoteSignature = payload.quoteSignature;
178
+ let context = null;
179
+ try { context = engine.orderContext.orderContextString(request.terms.order); } catch { context = null; }
180
+ if (!context || payload.context !== context) throw new SdkError("quote-refused", { step: "context", why: "other-terms" });
181
+ const recovered = recoverOrderQuoteKey({ ...quote, signature: payload.quoteSignature });
182
+ if (recovered === null) throw new SdkError("quote-refused", { step: "signature", why: "invalid" });
183
+ if (!same(recovered, quote.quoteKey)) throw new SdkError("quote-refused", { step: "signature", why: "key" });
184
+ const nowSec = Math.floor((now === null ? Date.now() : now) / 1000);
185
+ checkOrderQuote(quote, request.terms, nowSec);
186
+ const side = request.terms.side;
187
+ const lockerPoint = side === "ask" ? request.edViewPointClient : request.edViewPointNode;
188
+ const claimerPoint = side === "ask" ? request.edViewPointNode : request.edViewPointClient;
189
+ if (!same(payload.edViewPointLocker, lockerPoint) || !same(payload.edViewPointClaimer, claimerPoint)) {
190
+ throw new SdkError("quote-refused", { step: "answer", why: "view-points" });
191
+ }
192
+ const nodeHalf = payload.nodeHalf;
193
+ if (!nodeHalf || typeof nodeHalf !== "object" || !nodeHalf.proof) {
194
+ throw new SdkError("quote-refused", { step: "answer", why: "node-half" });
195
+ }
196
+ if (side === "ask") {
197
+ if (!same(nodeHalf.commitHalf, quote.commitHalfClaimer) || !same(nodeHalf.edPoint, quote.edPointClaimer)) {
198
+ throw new SdkError("quote-refused", { step: "answer", why: "node-half" });
199
+ }
200
+ }
201
+ return {
202
+ quote,
203
+ orderQuote: { ...quote, factory: payload.factory, signature: payload.quoteSignature },
204
+ nodeHalf,
205
+ edViewPointLocker: payload.edViewPointLocker,
206
+ edViewPointClaimer: payload.edViewPointClaimer,
207
+ context,
208
+ };
209
+ }