@ninsei-labs/sdk 0.39.0 → 0.39.1

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
@@ -1,465 +1,465 @@
1
- // LIVE INDICATIVE QUOTES: the SDK's own quote client.
2
- //
3
- // WHY OUR OWN AND NOT THE ENGINE'S. The package must work without `www/js`: the consumer does not have it. The
4
- // rules for reading the quote book are a specification (docs 41 and 54), not foreign code, so they are embodied
5
- // here anew and pinned by a run on fixtures next to the engine implementation. For now the demo screen computes
6
- // with its own copy: it is a test bench, and it is not scary for it to diverge from the SDK - the bench exists
7
- // precisely to notice that.
8
- //
9
- // WHAT MUST NOT BE LOST HERE (all three rules cost the project mistakes):
10
- // 1) a row has TWO rates: rateQuote - as written in the pair, rate - how much non-XMR per 1 XMR. This is not
11
- // duplication but different orientations; zero means "no direction", and it is NEVER divided and never
12
- // becomes a price;
13
- // 2) the size is requested in the units of the pair's ASSET (the first element): for a buy that is XMR, for a
14
- // sell - the token; a size outside the provider's grid is not accepted, so the size is snapped to the grid
15
- // DOWN, not to the nearest;
16
- // 3) a quote older than its own ttlMs is not shown, and a row that contradicts itself (the named sides disagree
17
- // with the pair's name) is not read at all.
18
- import { fail } from "./errors.mjs";
19
- import * as engine from "./engine.mjs";
20
- import { orderQuoteMissingFields, recoverOrderQuoteKey } from "./quoteEip712.mjs";
21
- // THE CHAIN READ NEEDED TO ASK THE REGISTRY WHO OWNS THE KEY. The selector is COMPUTED from the signature, not
22
- // remembered: a single wrong hex digit would call another function and return a zero address - and a zero
23
- // address is exactly the answer "nobody owns this key", which the caller would take as the truth.
24
- import { keccak256 } from "./primitives.mjs";
25
-
26
- const utf8 = (value) => new TextEncoder().encode(String(value));
27
- const bytesToHexQ = (bytes) => { let out = "0x"; for (const b of bytes) out += b.toString(16).padStart(2, "0"); return out; };
28
- const providerOfSelector = () => bytesToHexQ(keccak256(utf8("providerOf(address)")).slice(0, 4));
29
- const providerOfData = (key) => providerOfSelector() + "0".repeat(24) + String(key).toLowerCase().replace(/^0x/, "");
30
- // THE FACTORY IS READ FROM THE RECORD, NOT FROM SETTINGS: `factoryOf(provider)` answers the factory the
31
- // provider named in its record (#103). Selected by a COMPUTED selector, like providerOf.
32
- const factoryOfSelector = () => bytesToHexQ(keccak256(utf8("factoryOf(address)")).slice(0, 4));
33
- const factoryOfData = (provider) => factoryOfSelector() + "0".repeat(24) + String(provider).toLowerCase().replace(/^0x/, "");
34
- const hexToBytesQ = (hex) => {
35
- const h = String(hex == null ? "" : hex).replace(/^0x/, "");
36
- if (h.length % 2) return null;
37
- const out = new Uint8Array(h.length / 2);
38
- for (let i = 0; i < out.length; i++) { const b = parseInt(h.slice(i * 2, i * 2 + 2), 16); if (Number.isNaN(b)) return null; out[i] = b; }
39
- return out;
40
- };
41
- const ZERO_ADDRESS = "0x" + "00".repeat(20);
42
- const addressFromWord = (raw) => {
43
- const h = String(raw == null ? "" : raw).replace(/^0x/, "");
44
- return h.length >= 40 ? "0x" + h.slice(-40).toLowerCase() : null;
45
- };
46
-
47
- const num = (v) => (v === undefined || v === null || v === "" ? null : (Number.isFinite(Number(v)) ? Number(v) : null));
48
- const pairAsset = (pair) => String(pair || "").split("/")[0].toUpperCase();
49
- const pairCurrency = (pair) => String(pair || "").split("/")[1] ? String(pair).split("/")[1].toUpperCase() : "";
50
-
51
- /** The pair the user sees: buying XMR - looking at XMR/<token>, selling - at the reverse. */
52
- export const pairFacingUser = (direction, token) =>
53
- (direction === "sell" ? String(token).toUpperCase() + "/XMR" : "XMR/" + String(token).toUpperCase());
54
-
55
- /** Reading one quote row. The shape comes from the specification; networks are NOT filled in. */
56
- export function readQuote(pair, raw, owner = {}, data = {}) {
57
- const q = raw && typeof raw === "object" ? raw : {};
58
- const text = String(pair || q.pair || "").trim().toUpperCase();
59
- if (!text.includes("/")) return null;
60
- const statedAsset = q.asset ? String(q.asset).toUpperCase() : null;
61
- const statedCurrency = q.currency ? String(q.currency).toUpperCase() : null;
62
- const asset = pairAsset(text);
63
- const currency = pairCurrency(text);
64
- const givenRateQuote = num(q.rateQuote);
65
- const givenRate = num(q.rate);
66
- const rateQuote = givenRateQuote !== null ? givenRateQuote : givenRate;
67
- let rate = null;
68
- if (givenRateQuote !== null) rate = givenRate;
69
- else if (rateQuote !== null) rate = currency === "XMR" ? (rateQuote > 0 ? 1 / rateQuote : null) : rateQuote;
70
- const contradicts = (statedAsset && statedAsset !== asset) || (statedCurrency && statedCurrency !== currency);
71
- const at = num(q.at);
72
- return {
73
- providerId: String(owner.providerId || q.providerId || "").trim(),
74
- displayName: owner.displayName || q.displayName || null,
75
- // THE PROVIDER'S MATURITY LEVEL RIDES WITH THE OFFER: a person chooses not only by price but also by how long
76
- // to wait. No field - no value either, not an invented default.
77
- finality: owner.finality || q.finality || null,
78
- pair: text,
79
- asset: statedAsset || asset,
80
- currency: statedCurrency || currency,
81
- assetNetwork: q.assetNetwork ? String(q.assetNetwork) : null,
82
- currencyNetwork: q.currencyNetwork ? String(q.currencyNetwork) : null,
83
- rateQuote,
84
- rate,
85
- min: num(q.min),
86
- max: num(q.max),
87
- step: num(q.step),
88
- ttlMs: num(q.ttlMs) && num(q.ttlMs) > 0 ? num(q.ttlMs) : null,
89
- at,
90
- why: q.why ? String(q.why) : null,
91
- disabled: q.disabled === true || (givenRate !== null && !(givenRate > 0)),
92
- // NETWORKS NOT NAMED / FILLED IN BY THE RECEIVER - this is a FACT ABOUT THE QUOTE, and it is visible apart
93
- // from "named and matched": the screen must say "nothing to check against" rather than attribute our network
94
- // to the quote.
95
- networksUnnamed: q.networksUnnamed === true,
96
- networksDerived: q.networksDerived === true,
97
- seq: num(q.seq) !== null ? num(q.seq) : (num(owner.seq) !== null ? num(owner.seq) : num(data.seq)),
98
- signature: q.signature || owner.signature || null,
99
- keyId: q.keyId || null,
100
- networkStated: Boolean(q.assetNetwork || q.currencyNetwork),
101
- contradicts,
102
- ageMs: at === null ? null : Math.max(0, Date.now() - at),
103
- };
104
- }
105
-
106
- /**
107
- * Parsing the book in one place. The envelope is accepted in three forms (a map by pairs, a list of rows, a list
108
- * of providers) and is brought to a single list: the screen must not know which form the answer came in.
109
- * Refusals are named separately from offers: "no direction" and "the quote is stale" are not emptiness.
110
- */
111
- export function readBook(data, now = Date.now()) {
112
- const quotes = data && data.quotes;
113
- const rows = [];
114
- const add = (pair, raw, owner) => { const row = readQuote(pair, raw, owner, data || {}); if (row) rows.push(row); };
115
- if (Array.isArray(quotes)) {
116
- for (const row of quotes) {
117
- if (!row || typeof row !== "object") continue;
118
- const nested = row.quotes && !Array.isArray(row.quotes) && typeof row.quotes === "object" ? row.quotes : null;
119
- if (nested) for (const [pair, q] of Object.entries(nested)) add(pair, q, row);
120
- else add(row.pair, row, row);
121
- }
122
- } else if (quotes && typeof quotes === "object") {
123
- for (const [pair, q] of Object.entries(quotes)) add(pair, q, q && typeof q === "object" ? q : {});
124
- }
125
- // ONE QUOTE PER PAIR PER PROVIDER: if several arrived, we keep the fresh one rather than choosing arbitrarily
126
- // (these are two different offers under one name).
127
- const byKey = new Map();
128
- for (const row of rows) {
129
- const key = (row.providerId || "?") + "|" + row.pair;
130
- const prev = byKey.get(key);
131
- if (!prev || Number(row.at) > Number(prev.at)) byKey.set(key, row);
132
- }
133
- const offers = [];
134
- const refused = [];
135
- for (const row of byKey.values()) {
136
- if (row.contradicts) { refused.push({ ...row, code: "malformed" }); continue; }
137
- if (row.rateQuote === null && row.rate === null) { refused.push({ ...row, code: "malformed" }); continue; }
138
- if (row.disabled || !(row.rate > 0)) { refused.push({ ...row, code: "disabled" }); continue; }
139
- if (row.ttlMs && row.ageMs !== null && row.ageMs > row.ttlMs) { refused.push({ ...row, code: "stale" }); continue; }
140
- offers.push(row);
141
- }
142
- const best = offers.slice().sort((a, b) => b.rate - a.rate)[0] || null;
143
- return { offers, refused, best, at: now };
144
- }
145
-
146
- /**
147
- * The size is snapped to the provider's grid DOWN. A size refusal is not aligned: a size outside the range is a
148
- * provider's refusal and must reach the interface as a code, not turn into permission.
149
- */
150
- export function snapToGrid(size, { min = null, max = null, step = null } = {}) {
151
- const s = Number(size);
152
- if (!Number.isFinite(s) || s <= 0) return { size: null, asked: size, snapped: false, code: "bad-size" };
153
- const st = Number(step);
154
- const lo = Number.isFinite(Number(min)) && min !== null && min !== "" ? Number(min) : 0;
155
- const hi = Number.isFinite(Number(max)) && max !== null && max !== "" ? Number(max) : null;
156
- if (s < lo) return { size: s, asked: s, snapped: false, code: "below-min" };
157
- if (hi !== null && s > hi) return { size: s, asked: s, snapped: false, code: "above-max" };
158
- if (!Number.isFinite(st) || st <= 0) return { size: s, asked: s, snapped: false, code: null };
159
- const steps = Math.floor((s - lo) / st + 1e-9);
160
- let snapped = Number((lo + steps * st).toFixed(12));
161
- if (snapped < lo) snapped = lo;
162
- return { size: snapped, asked: s, snapped: Math.abs(snapped - s) > 1e-12, code: null };
163
- }
164
-
165
- /**
166
- * What the user will receive and what they pay for it, by the best offer and its rate.
167
- * buy: they pay with the token and receive XMR, so the size (the ASSET of the pair XMR/<token>) is XMR.
168
- * sell: they give XMR and receive the token, the ASSET of the pair <token>/XMR is the token itself.
169
- */
170
- export function derivedFor(offer, direction, amount, now = Date.now()) {
171
- const rate = offer && Number.isFinite(Number(offer.rate)) && Number(offer.rate) > 0 ? Number(offer.rate) : null;
172
- const size = direction === "sell" ? (rate !== null && amount > 0 ? amount * rate : null)
173
- : (rate !== null && amount > 0 ? amount / rate : null);
174
- if (size === null) return { rate, size: null, unit: null, code: "no-offer", xmr: null };
175
- const settled = snapToGrid(size, offer || {});
176
- return {
177
- rate,
178
- size: settled.size,
179
- unit: direction === "sell" ? String(offer.currency || "").toUpperCase() || null : "XMR",
180
- snapped: settled.snapped,
181
- code: settled.code,
182
- // HOW MUCH XMR FOR THE REQUESTED SIZE. For a buy this is the size, for a sell - the amount the person gives.
183
- xmr: direction === "sell" ? amount : (Number.isFinite(Number(settled.size)) ? Number(settled.size) : null),
184
- };
185
- }
186
-
187
- // THE ROW FIELDS THAT GO OUT. This is a translation of the SHAPE, not a recomputation: the values are taken from
188
- // the read row and are not changed. The fields are listed explicitly (and not `...row`) so that internal parsing
189
- // markers do not leak out. Selection and priority are done by readBook above: a row gets here only if it has
190
- // already been recognised as an offer.
191
- const compactOffer = (o) => ({
192
- providerId: o.providerId, displayName: o.displayName, pair: o.pair, rate: o.rate, rateQuote: o.rateQuote,
193
- min: o.min, max: o.max, step: o.step, ttlMs: o.ttlMs, at: o.at, seq: o.seq,
194
- // THE MATURITY LEVEL IS PART OF THE CHOICE: one provider gives a better price but only on finalized, another is
195
- // pricier but faster. No field - null, not an invented default.
196
- finality: o.finality || null,
197
- // THE PAIR'S SIDES AND BOTH NETWORKS: the network is part of the market's identity, and the display must
198
- // distinguish arbitrum from arbitrum-sepolia. `networks` is kept as the assembled view of the same values.
199
- asset: o.asset, currency: o.currency,
200
- assetNetwork: o.assetNetwork, currencyNetwork: o.currencyNetwork, networkStated: o.networkStated,
201
- networksDerived: o.networksDerived === true, networksUnnamed: o.networksUnnamed === true,
202
- // THE REASON (in the provider's words), THE PACKAGE'S SIGNATURE AND KEY, THE AGE BY ITS OWN MARK.
203
- why: o.why, signature: o.signature, keyId: o.keyId, ageMs: o.ageMs,
204
- networks: { asset: o.assetNetwork, currency: o.currencyNetwork, stated: o.networkStated },
205
- });
206
-
207
- // A REFUSAL IS THE SAME ROW PLUS A REASON CODE. The fields are not trimmed: the interface needs the range and
208
- // the price of the failed row to tell the person WHY there is no offer (and not just "none").
209
- const compactRefusal = (r) => ({ ...compactOffer(r), code: r.code, why: r.why });
210
-
211
- export function createQuotes({ config, http, now = () => Date.now(), evmCall = null, evmCode = null, providerAllowed = null, knownFactoryCodes = null }) {
212
- // THE INTERFACE'S FACTORY POLICY (#103): the code hashes of factories it is willing to trade through
213
- // (extcodehash semantics = keccak256 of the runtime code). An EMPTY list is "no policy" (like
214
- // allowedProviders); a NON-EMPTY one refuses any factory whose code is not on it. Lower-cased once.
215
- const factoryPolicy = Array.isArray(knownFactoryCodes) && knownFactoryCodes.length
216
- ? knownFactoryCodes.map((h) => String(h).toLowerCase()).filter(Boolean)
217
- : null;
218
- const listeners = new Set();
219
- let timer = null;
220
- let size = 1; // the request size in units of the pair's ASSET; we start from one, as the engine does
221
- let state = null;
222
- let request = { direction: "buy", token: null, amount: null };
223
- let inFlight = false;
224
-
225
- const emit = () => { for (const handler of listeners) handler(state); };
226
-
227
- const tick = async () => {
228
- if (inFlight) return;
229
- inFlight = true;
230
- try {
231
- // THE REQUEST SIZE. Usually it is derived from the person's amount (amount) and refined on the next tick.
232
- // But the caller can name it HIMSELF (size) - then it is taken AS IS rather than derived anew: it is the
233
- // same size the interface has already aligned to the provider's grid. There is no second size rule here.
234
- const explicit = Number.isFinite(Number(request.size)) && Number(request.size) > 0;
235
- const asked = explicit ? Number(request.size) : size;
236
- const data = await http.json("/rate?amount=" + encodeURIComponent(asked) + "&side=" + encodeURIComponent(request.direction));
237
- const book = readBook(data, now());
238
- const offer = book.best;
239
- const amount = Number.isFinite(Number(request.amount)) ? Number(request.amount) : null;
240
- // The size derived from the person's amount becomes the size of the NEXT request: the price does not depend
241
- // on volume, and the volume is needed by the provider to say whether it takes such a size. With a named size
242
- // there is nothing to derive - it stays what it was asked as.
243
- const derived = offer && amount !== null && !explicit ? derivedFor(offer, request.direction, amount, now()) : null;
244
- if (derived && derived.size !== null) size = derived.size;
245
- // THE UNIT with a named size: a buy is measured in XMR (the ASSET of the pair XMR/<token>), a sell - in the token.
246
- const unitFromOffer = offer ? (String(offer.currency || "").toUpperCase() || null) : null;
247
- const settled = explicit ? (offer ? snapToGrid(asked, offer) : null) : null;
248
- state = {
249
- direction: request.direction,
250
- token: request.token,
251
- amount,
252
- size: explicit ? asked : size,
253
- unit: explicit ? (request.direction === "sell" ? unitFromOffer : "XMR") : (derived ? derived.unit : null),
254
- rate: explicit ? (offer ? Number(offer.rate) : null) : (derived ? derived.rate : null),
255
- xmr: derived ? derived.xmr : null,
256
- at: now(),
257
- providerId: offer ? offer.providerId : null,
258
- sizeOk: explicit ? Boolean(settled && settled.code === null) : Boolean(derived && derived.code === null),
259
- sizeCode: explicit ? (settled ? settled.code : "no-offer") : (derived ? derived.code : "no-offer"),
260
- seq: book.offers.length ? (book.offers.find((o) => o.seq !== null) || {}).seq ?? null : null,
261
- // THE BEST, AS THE SERVICE NAMED IT (for the requested volume and side). The core does NOT recompute it:
262
- // these are response data the interface needs to confirm its own "best" mark by pair and provider.
263
- best: data && data.best && typeof data.best === "object"
264
- ? { providerId: String(data.best.providerId || ""), pair: String(data.best.pair || "") } : null,
265
- offers: book.offers.map(compactOffer),
266
- refused: book.refused.map(compactRefusal),
267
- references: Array.isArray(data && data.references) ? data.references.map((r) => ({ source: r.source, value: num(r.value), at: num(r.at) })) : [],
268
- ok: book.offers.length > 0,
269
- };
270
- emit();
271
- } catch (error) {
272
- // A REFUSAL IS NOT REPLACED BY PAST NUMBERS: showing a stale price as live is worse than saying the connection
273
- // is gone. The data is kept, but ok is cleared, and the interface sees it.
274
- state = { ...(state || { direction: request.direction, token: request.token, amount: null, size, unit: null, rate: null, xmr: null,
275
- at: now(), providerId: null, sizeOk: false, sizeCode: null, seq: null, best: null, offers: [], refused: [], references: [] }),
276
- at: now(), ok: false, error: { code: error && error.code === "server-unavailable" ? "quote-unavailable" : "unknown" } };
277
- emit();
278
- } finally {
279
- inFlight = false;
280
- }
281
- };
282
-
283
- const restart = () => {
284
- if (timer) clearInterval(timer);
285
- timer = null;
286
- if (listeners.size > 0) {
287
- void tick();
288
- timer = setInterval(() => { void tick(); }, config.limits.quotePollMs);
289
- }
290
- };
291
-
292
- return {
293
- watch(next, onSnapshot) {
294
- if (typeof onSnapshot !== "function") fail("bad-input", { field: "onSnapshot" });
295
- const direction = next && next.direction === "sell" ? "sell" : "buy";
296
- const token = next && typeof next.token === "string" && next.token ? next.token.toLowerCase() : config.settlement.chain.nativeSymbol.toLowerCase();
297
- request = {
298
- direction,
299
- token,
300
- amount: next && next.amount !== undefined ? next.amount : null,
301
- // A NAMED SIZE (in units of `unit`): an interface that has already aligned the volume to the provider's
302
- // grid hands it here, and the core asks for EXACTLY it rather than deriving it from the amount anew.
303
- size: next && Number.isFinite(Number(next.size)) && Number(next.size) > 0 ? Number(next.size) : null,
304
- };
305
- size = 1;
306
- state = null;
307
- listeners.add(onSnapshot);
308
- restart();
309
- return () => {
310
- listeners.delete(onSnapshot);
311
- if (listeners.size === 0) {
312
- if (timer) clearInterval(timer);
313
- timer = null;
314
- }
315
- };
316
- },
317
- // WHAT THE SDK KNOWS RIGHT NOW: a snapshot without waiting. Needed by an interface that subscribed later than
318
- // the first poll happened.
319
- snapshot: () => state,
320
- // A FIRM QUOTE FOR AN ORDER. It carries a commitment under keys, so THE PROVIDER'S SIGNATURE IS CONFIRMED
321
- // HERE, BY RECOVERING THE ADDRESS FROM THE EIP-712 SIGNATURE - not taken on trust from a flag the backend
322
- // reports. A quote that cannot be verified is not "probably fine": a missing field, a missing signature or
323
- // an unusable domain is REFUSED, never accepted (the rule the old flag carried is kept). The digest is
324
- // built from the quote's own fields, so nothing signed can be swapped afterwards; the named quoteKey is
325
- // matched against the recovered address. THEN THE CHAIN IS ASKED WHO OWNS THE KEY (providerOf): the
326
- // signature alone certifies itself, because a key can sign anything and call itself any provider. The
327
- // registry is read from the quote/order conditions - never from SDK settings: any registry can be deployed
328
- // by anyone, and the order carries which one it is (the field is signed and bound into the terms). Finally
329
- // the interface's allow-list, when it supplied one, is applied as policy (who is admitted at all).
330
- //
331
- // The path and the body are the same as the engine's (`requestOrderQuote` in www/js/core/rfqSource.js): POST to
332
- // the named path, body { providerId, order }. The path is named by name, so it is overridable by options.
333
- async firm(request) {
334
- if (!request || typeof request !== "object") fail("bad-input", { field: "request" });
335
- if (typeof request.providerId !== "string" || !request.providerId) fail("bad-input", { field: "providerId" });
336
- if (!request.order || typeof request.order !== "object") fail("bad-input", { field: "order" });
337
- let body = null;
338
- try {
339
- body = await http.json(config.route("orderQuote"), {
340
- method: "POST",
341
- headers: { "content-type": "application/json" },
342
- body: JSON.stringify({ providerId: request.providerId, order: request.order }),
343
- });
344
- } catch (error) {
345
- if (error && error.code === "bad-input") throw error;
346
- fail("server-unavailable", { step: "order-quote" });
347
- }
348
- if (!body || body.ok !== true || !body.quote) fail("quote-refused", { step: "order-quote" });
349
- const quote = body.quote;
350
- // A MISSING FIELD OF THE SIGNED SET IS NOT "PROBABLY ZERO": the digest cannot be built, so the quote is
351
- // not verifiable and is refused (its own code, so the interface can say which field is unbound).
352
- const missingFields = orderQuoteMissingFields(quote);
353
- if (missingFields.length) fail("quote-field-unbound", { step: "signature", missing: missingFields });
354
- // THE QUOTE'S OWN EXPIRY, checked against the clock the core already has. It is bound in the signature too,
355
- // but a stale quote is refused by its own code rather than looking like a signature failure.
356
- const validUntil = Number(quote.validUntil);
357
- // THE SCALE IS SECONDS: the same as the order's deadlines and block.timestamp. The core's clock is in
358
- // milliseconds (Date.now), so exactly ONE boundary is converted - "now", not the quote value itself: one
359
- // unit for the value, one conversion at the input.
360
- const nowSec = Math.floor(now() / 1000);
361
- if (Number.isFinite(validUntil) && validUntil > 0 && validUntil <= nowSec) {
362
- fail("quote-expired", { step: "signature", validUntil });
363
- }
364
- // RECOVER, THEN MATCH THE NAMED KEY. A signature that does not recover is one refusal; a valid signature
365
- // that recovers to a DIFFERENT address is another (the quote names a key that did not sign it).
366
- const recoveredKey = recoverOrderQuoteKey(quote);
367
- if (recoveredKey === null) fail("quote-signature-invalid", { step: "signature" });
368
- const declaredKey = String(quote.quoteKey).toLowerCase();
369
- if (recoveredKey !== declaredKey) fail("quote-key-mismatch", { step: "signature", got: recoveredKey, want: declaredKey });
370
- // WHO OWNS THE KEY - ASKED OF THE REGISTRY NAMED BY THE QUOTE. Both fields are part of the signed set, so
371
- // they cannot be swapped after signing; this is what turns "a key signed it" into "this provider's key
372
- // signed it". No chain-read seam - refuse rather than take the signature on trust.
373
- const registry = String(quote.registry || "").toLowerCase();
374
- if (!/^0x[0-9a-f]{40}$/.test(registry) || registry === ZERO_ADDRESS) {
375
- fail("quote-field-unbound", { step: "registry", field: "registry" });
376
- }
377
- const declaredProvider = String(quote.provider || "").toLowerCase();
378
- if (!/^0x[0-9a-f]{40}$/.test(declaredProvider) || declaredProvider === ZERO_ADDRESS) {
379
- fail("quote-field-unbound", { step: "registry", field: "provider" });
380
- }
381
- if (typeof evmCall !== "function") fail("quote-registry-unchecked", { step: "registry", why: "no-chain-read" });
382
- let mappedProvider = null;
383
- try {
384
- mappedProvider = addressFromWord(await evmCall({ to: registry, data: providerOfData(declaredKey) }));
385
- } catch {
386
- fail("quote-registry-unchecked", { step: "registry", why: "read-failed", registry });
387
- }
388
- if (mappedProvider === null || mappedProvider === ZERO_ADDRESS) {
389
- fail("quote-key-unknown", { step: "registry", registry, quoteKey: declaredKey });
390
- }
391
- if (mappedProvider !== declaredProvider) {
392
- fail("quote-provider-mismatch", { step: "registry", got: mappedProvider, want: declaredProvider });
393
- }
394
- // THE INTERFACE'S OWN ADMISSION LIST - POLICY, NOT PROOF. The chain says whose key it is; whether this
395
- // interface trades with that provider at all is the interface's decision, and it rides as a hook.
396
- if (typeof providerAllowed === "function" && providerAllowed(declaredProvider) !== true) {
397
- fail("quote-provider-not-allowed", { step: "policy", provider: declaredProvider });
398
- }
399
- // THE FACTORY: NAMED BY THE PROVIDER'S RECORD, CHECKED BY ITS CODE (#103). The factory will verify the
400
- // quote in EVM (it is the EIP-712 verifyingContract), so it must be the one the record names AND its
401
- // code must be a build this interface knows. The record lookup always runs; the code check runs when a
402
- // policy list was supplied (nothing to pin against otherwise).
403
- const declaredFactory = String(quote.factory || "").toLowerCase();
404
- if (!/^0x[0-9a-f]{40}$/.test(declaredFactory) || declaredFactory === ZERO_ADDRESS) {
405
- fail("quote-field-unbound", { step: "factory", field: "factory" });
406
- }
407
- let recordedFactory = null;
408
- try {
409
- recordedFactory = addressFromWord(await evmCall({ to: registry, data: factoryOfData(declaredProvider) }));
410
- } catch {
411
- fail("quote-factory-unchecked", { step: "factory", why: "read-failed", registry });
412
- }
413
- if (recordedFactory === null || recordedFactory === ZERO_ADDRESS) {
414
- fail("quote-factory-unknown", { step: "factory", registry, provider: declaredProvider });
415
- }
416
- if (recordedFactory !== declaredFactory) {
417
- fail("quote-factory-mismatch", { step: "factory", got: declaredFactory, want: recordedFactory });
418
- }
419
- if (factoryPolicy) {
420
- if (typeof evmCode !== "function") fail("quote-factory-unchecked", { step: "factory", why: "no-code-read" });
421
- let codeHex = null;
422
- try { codeHex = await evmCode(declaredFactory); } catch { fail("quote-factory-unchecked", { step: "factory", why: "code-read-failed", factory: declaredFactory }); }
423
- const codeBytes = hexToBytesQ(codeHex);
424
- // EXTCODEHASH SEMANTICS: keccak256 of the runtime code. An address with no code (empty/0x) hashes to
425
- // the keccak of empty, which is not on any build list - refused like any unknown build.
426
- const codeHash = bytesToHexQ(keccak256(codeBytes || new Uint8Array(0)));
427
- if (!factoryPolicy.includes(codeHash)) {
428
- fail("quote-factory-code-unknown", { step: "factory", factory: declaredFactory, codeHash });
429
- }
430
- }
431
- // BINDING THE QUOTE TO OUR TERMS. The signature confirms the provider signed THIS quote, but not that it is
432
- // about our swap: we are responsible for the binding. The context string is computed by OUR engine module
433
- // (format arrakis-order-v3; the field order is part of the protocol and must not be rearranged). There is
434
- // deliberately no copy of the format here: a copy would silently drift from the protocol.
435
- //
436
- // THE ORDER OF THE GATES: signature first, then binding. So a refusal names the nearest reason, not the first
437
- // one at hand.
438
- // THE PROVIDER MAY NAME THE CLAIMER HIMSELF, AND THAT IS ALSO AN ASSEMBLED CONTEXT (a defect found). The
439
- // provider may be the claimer: then it substitutes ITS OWN address into the order terms and signs the context
440
- // with it already in place. Requiring the context from the caller in that case is impossible - they do not
441
- // know the provider's address, and then the quote cannot be obtained at all (the live contour answered this
442
- // with bad-input {field:"order"} BEFORE any network). So if the sent terms had no claimer, the context is
443
- // computed from the terms WITH HIS value from the quote - exactly as the engine does it
444
- // (sdk/src/swap-flow.mjs: `{ ...orderBase, claimer: claimerResolved }`). No OPTIONAL checks appeared here:
445
- // a mismatched string is still a refusal (quote-refused { step: "context" }).
446
- let expectedContext = null;
447
- try { expectedContext = engine.orderContext.orderContextString(request.order); }
448
- catch { expectedContext = null; }
449
- if (expectedContext === null) {
450
- const providerClaimer = quote && quote.claimer;
451
- if (providerClaimer) {
452
- try { expectedContext = engine.orderContext.orderContextString({ ...request.order, claimer: providerClaimer }); }
453
- catch { fail("bad-input", { field: "order" }); }
454
- } else {
455
- fail("bad-input", { field: "order" });
456
- }
457
- }
458
- const quoted = quote.context || "";
459
- if (String(quoted) !== expectedContext) {
460
- fail("quote-refused", { step: "context", why: quoted ? "other-terms" : "not-reported" });
461
- }
462
- return quote;
463
- },
464
- };
465
- }
1
+ // LIVE INDICATIVE QUOTES: the SDK's own quote client.
2
+ //
3
+ // WHY OUR OWN AND NOT THE ENGINE'S. The package must work without `www/js`: the consumer does not have it. The
4
+ // rules for reading the quote book are a specification (docs 41 and 54), not foreign code, so they are embodied
5
+ // here anew and pinned by a run on fixtures next to the engine implementation. For now the demo screen computes
6
+ // with its own copy: it is a test bench, and it is not scary for it to diverge from the SDK - the bench exists
7
+ // precisely to notice that.
8
+ //
9
+ // WHAT MUST NOT BE LOST HERE (all three rules cost the project mistakes):
10
+ // 1) a row has TWO rates: rateQuote - as written in the pair, rate - how much non-XMR per 1 XMR. This is not
11
+ // duplication but different orientations; zero means "no direction", and it is NEVER divided and never
12
+ // becomes a price;
13
+ // 2) the size is requested in the units of the pair's ASSET (the first element): for a buy that is XMR, for a
14
+ // sell - the token; a size outside the provider's grid is not accepted, so the size is snapped to the grid
15
+ // DOWN, not to the nearest;
16
+ // 3) a quote older than its own ttlMs is not shown, and a row that contradicts itself (the named sides disagree
17
+ // with the pair's name) is not read at all.
18
+ import { fail } from "./errors.mjs";
19
+ import * as engine from "./engine.mjs";
20
+ import { orderQuoteMissingFields, recoverOrderQuoteKey } from "./quoteEip712.mjs";
21
+ // THE CHAIN READ NEEDED TO ASK THE REGISTRY WHO OWNS THE KEY. The selector is COMPUTED from the signature, not
22
+ // remembered: a single wrong hex digit would call another function and return a zero address - and a zero
23
+ // address is exactly the answer "nobody owns this key", which the caller would take as the truth.
24
+ import { keccak256 } from "./primitives.mjs";
25
+
26
+ const utf8 = (value) => new TextEncoder().encode(String(value));
27
+ const bytesToHexQ = (bytes) => { let out = "0x"; for (const b of bytes) out += b.toString(16).padStart(2, "0"); return out; };
28
+ const providerOfSelector = () => bytesToHexQ(keccak256(utf8("providerOf(address)")).slice(0, 4));
29
+ const providerOfData = (key) => providerOfSelector() + "0".repeat(24) + String(key).toLowerCase().replace(/^0x/, "");
30
+ // THE FACTORY IS READ FROM THE RECORD, NOT FROM SETTINGS: `factoryOf(provider)` answers the factory the
31
+ // provider named in its record (#103). Selected by a COMPUTED selector, like providerOf.
32
+ const factoryOfSelector = () => bytesToHexQ(keccak256(utf8("factoryOf(address)")).slice(0, 4));
33
+ const factoryOfData = (provider) => factoryOfSelector() + "0".repeat(24) + String(provider).toLowerCase().replace(/^0x/, "");
34
+ const hexToBytesQ = (hex) => {
35
+ const h = String(hex == null ? "" : hex).replace(/^0x/, "");
36
+ if (h.length % 2) return null;
37
+ const out = new Uint8Array(h.length / 2);
38
+ for (let i = 0; i < out.length; i++) { const b = parseInt(h.slice(i * 2, i * 2 + 2), 16); if (Number.isNaN(b)) return null; out[i] = b; }
39
+ return out;
40
+ };
41
+ const ZERO_ADDRESS = "0x" + "00".repeat(20);
42
+ const addressFromWord = (raw) => {
43
+ const h = String(raw == null ? "" : raw).replace(/^0x/, "");
44
+ return h.length >= 40 ? "0x" + h.slice(-40).toLowerCase() : null;
45
+ };
46
+
47
+ const num = (v) => (v === undefined || v === null || v === "" ? null : (Number.isFinite(Number(v)) ? Number(v) : null));
48
+ const pairAsset = (pair) => String(pair || "").split("/")[0].toUpperCase();
49
+ const pairCurrency = (pair) => String(pair || "").split("/")[1] ? String(pair).split("/")[1].toUpperCase() : "";
50
+
51
+ /** The pair the user sees: buying XMR - looking at XMR/<token>, selling - at the reverse. */
52
+ export const pairFacingUser = (direction, token) =>
53
+ (direction === "sell" ? String(token).toUpperCase() + "/XMR" : "XMR/" + String(token).toUpperCase());
54
+
55
+ /** Reading one quote row. The shape comes from the specification; networks are NOT filled in. */
56
+ export function readQuote(pair, raw, owner = {}, data = {}) {
57
+ const q = raw && typeof raw === "object" ? raw : {};
58
+ const text = String(pair || q.pair || "").trim().toUpperCase();
59
+ if (!text.includes("/")) return null;
60
+ const statedAsset = q.asset ? String(q.asset).toUpperCase() : null;
61
+ const statedCurrency = q.currency ? String(q.currency).toUpperCase() : null;
62
+ const asset = pairAsset(text);
63
+ const currency = pairCurrency(text);
64
+ const givenRateQuote = num(q.rateQuote);
65
+ const givenRate = num(q.rate);
66
+ const rateQuote = givenRateQuote !== null ? givenRateQuote : givenRate;
67
+ let rate = null;
68
+ if (givenRateQuote !== null) rate = givenRate;
69
+ else if (rateQuote !== null) rate = currency === "XMR" ? (rateQuote > 0 ? 1 / rateQuote : null) : rateQuote;
70
+ const contradicts = (statedAsset && statedAsset !== asset) || (statedCurrency && statedCurrency !== currency);
71
+ const at = num(q.at);
72
+ return {
73
+ providerId: String(owner.providerId || q.providerId || "").trim(),
74
+ displayName: owner.displayName || q.displayName || null,
75
+ // THE PROVIDER'S MATURITY LEVEL RIDES WITH THE OFFER: a person chooses not only by price but also by how long
76
+ // to wait. No field - no value either, not an invented default.
77
+ finality: owner.finality || q.finality || null,
78
+ pair: text,
79
+ asset: statedAsset || asset,
80
+ currency: statedCurrency || currency,
81
+ assetNetwork: q.assetNetwork ? String(q.assetNetwork) : null,
82
+ currencyNetwork: q.currencyNetwork ? String(q.currencyNetwork) : null,
83
+ rateQuote,
84
+ rate,
85
+ min: num(q.min),
86
+ max: num(q.max),
87
+ step: num(q.step),
88
+ ttlMs: num(q.ttlMs) && num(q.ttlMs) > 0 ? num(q.ttlMs) : null,
89
+ at,
90
+ why: q.why ? String(q.why) : null,
91
+ disabled: q.disabled === true || (givenRate !== null && !(givenRate > 0)),
92
+ // NETWORKS NOT NAMED / FILLED IN BY THE RECEIVER - this is a FACT ABOUT THE QUOTE, and it is visible apart
93
+ // from "named and matched": the screen must say "nothing to check against" rather than attribute our network
94
+ // to the quote.
95
+ networksUnnamed: q.networksUnnamed === true,
96
+ networksDerived: q.networksDerived === true,
97
+ seq: num(q.seq) !== null ? num(q.seq) : (num(owner.seq) !== null ? num(owner.seq) : num(data.seq)),
98
+ signature: q.signature || owner.signature || null,
99
+ keyId: q.keyId || null,
100
+ networkStated: Boolean(q.assetNetwork || q.currencyNetwork),
101
+ contradicts,
102
+ ageMs: at === null ? null : Math.max(0, Date.now() - at),
103
+ };
104
+ }
105
+
106
+ /**
107
+ * Parsing the book in one place. The envelope is accepted in three forms (a map by pairs, a list of rows, a list
108
+ * of providers) and is brought to a single list: the screen must not know which form the answer came in.
109
+ * Refusals are named separately from offers: "no direction" and "the quote is stale" are not emptiness.
110
+ */
111
+ export function readBook(data, now = Date.now()) {
112
+ const quotes = data && data.quotes;
113
+ const rows = [];
114
+ const add = (pair, raw, owner) => { const row = readQuote(pair, raw, owner, data || {}); if (row) rows.push(row); };
115
+ if (Array.isArray(quotes)) {
116
+ for (const row of quotes) {
117
+ if (!row || typeof row !== "object") continue;
118
+ const nested = row.quotes && !Array.isArray(row.quotes) && typeof row.quotes === "object" ? row.quotes : null;
119
+ if (nested) for (const [pair, q] of Object.entries(nested)) add(pair, q, row);
120
+ else add(row.pair, row, row);
121
+ }
122
+ } else if (quotes && typeof quotes === "object") {
123
+ for (const [pair, q] of Object.entries(quotes)) add(pair, q, q && typeof q === "object" ? q : {});
124
+ }
125
+ // ONE QUOTE PER PAIR PER PROVIDER: if several arrived, we keep the fresh one rather than choosing arbitrarily
126
+ // (these are two different offers under one name).
127
+ const byKey = new Map();
128
+ for (const row of rows) {
129
+ const key = (row.providerId || "?") + "|" + row.pair;
130
+ const prev = byKey.get(key);
131
+ if (!prev || Number(row.at) > Number(prev.at)) byKey.set(key, row);
132
+ }
133
+ const offers = [];
134
+ const refused = [];
135
+ for (const row of byKey.values()) {
136
+ if (row.contradicts) { refused.push({ ...row, code: "malformed" }); continue; }
137
+ if (row.rateQuote === null && row.rate === null) { refused.push({ ...row, code: "malformed" }); continue; }
138
+ if (row.disabled || !(row.rate > 0)) { refused.push({ ...row, code: "disabled" }); continue; }
139
+ if (row.ttlMs && row.ageMs !== null && row.ageMs > row.ttlMs) { refused.push({ ...row, code: "stale" }); continue; }
140
+ offers.push(row);
141
+ }
142
+ const best = offers.slice().sort((a, b) => b.rate - a.rate)[0] || null;
143
+ return { offers, refused, best, at: now };
144
+ }
145
+
146
+ /**
147
+ * The size is snapped to the provider's grid DOWN. A size refusal is not aligned: a size outside the range is a
148
+ * provider's refusal and must reach the interface as a code, not turn into permission.
149
+ */
150
+ export function snapToGrid(size, { min = null, max = null, step = null } = {}) {
151
+ const s = Number(size);
152
+ if (!Number.isFinite(s) || s <= 0) return { size: null, asked: size, snapped: false, code: "bad-size" };
153
+ const st = Number(step);
154
+ const lo = Number.isFinite(Number(min)) && min !== null && min !== "" ? Number(min) : 0;
155
+ const hi = Number.isFinite(Number(max)) && max !== null && max !== "" ? Number(max) : null;
156
+ if (s < lo) return { size: s, asked: s, snapped: false, code: "below-min" };
157
+ if (hi !== null && s > hi) return { size: s, asked: s, snapped: false, code: "above-max" };
158
+ if (!Number.isFinite(st) || st <= 0) return { size: s, asked: s, snapped: false, code: null };
159
+ const steps = Math.floor((s - lo) / st + 1e-9);
160
+ let snapped = Number((lo + steps * st).toFixed(12));
161
+ if (snapped < lo) snapped = lo;
162
+ return { size: snapped, asked: s, snapped: Math.abs(snapped - s) > 1e-12, code: null };
163
+ }
164
+
165
+ /**
166
+ * What the user will receive and what they pay for it, by the best offer and its rate.
167
+ * buy: they pay with the token and receive XMR, so the size (the ASSET of the pair XMR/<token>) is XMR.
168
+ * sell: they give XMR and receive the token, the ASSET of the pair <token>/XMR is the token itself.
169
+ */
170
+ export function derivedFor(offer, direction, amount, now = Date.now()) {
171
+ const rate = offer && Number.isFinite(Number(offer.rate)) && Number(offer.rate) > 0 ? Number(offer.rate) : null;
172
+ const size = direction === "sell" ? (rate !== null && amount > 0 ? amount * rate : null)
173
+ : (rate !== null && amount > 0 ? amount / rate : null);
174
+ if (size === null) return { rate, size: null, unit: null, code: "no-offer", xmr: null };
175
+ const settled = snapToGrid(size, offer || {});
176
+ return {
177
+ rate,
178
+ size: settled.size,
179
+ unit: direction === "sell" ? String(offer.currency || "").toUpperCase() || null : "XMR",
180
+ snapped: settled.snapped,
181
+ code: settled.code,
182
+ // HOW MUCH XMR FOR THE REQUESTED SIZE. For a buy this is the size, for a sell - the amount the person gives.
183
+ xmr: direction === "sell" ? amount : (Number.isFinite(Number(settled.size)) ? Number(settled.size) : null),
184
+ };
185
+ }
186
+
187
+ // THE ROW FIELDS THAT GO OUT. This is a translation of the SHAPE, not a recomputation: the values are taken from
188
+ // the read row and are not changed. The fields are listed explicitly (and not `...row`) so that internal parsing
189
+ // markers do not leak out. Selection and priority are done by readBook above: a row gets here only if it has
190
+ // already been recognised as an offer.
191
+ const compactOffer = (o) => ({
192
+ providerId: o.providerId, displayName: o.displayName, pair: o.pair, rate: o.rate, rateQuote: o.rateQuote,
193
+ min: o.min, max: o.max, step: o.step, ttlMs: o.ttlMs, at: o.at, seq: o.seq,
194
+ // THE MATURITY LEVEL IS PART OF THE CHOICE: one provider gives a better price but only on finalized, another is
195
+ // pricier but faster. No field - null, not an invented default.
196
+ finality: o.finality || null,
197
+ // THE PAIR'S SIDES AND BOTH NETWORKS: the network is part of the market's identity, and the display must
198
+ // distinguish arbitrum from arbitrum-sepolia. `networks` is kept as the assembled view of the same values.
199
+ asset: o.asset, currency: o.currency,
200
+ assetNetwork: o.assetNetwork, currencyNetwork: o.currencyNetwork, networkStated: o.networkStated,
201
+ networksDerived: o.networksDerived === true, networksUnnamed: o.networksUnnamed === true,
202
+ // THE REASON (in the provider's words), THE PACKAGE'S SIGNATURE AND KEY, THE AGE BY ITS OWN MARK.
203
+ why: o.why, signature: o.signature, keyId: o.keyId, ageMs: o.ageMs,
204
+ networks: { asset: o.assetNetwork, currency: o.currencyNetwork, stated: o.networkStated },
205
+ });
206
+
207
+ // A REFUSAL IS THE SAME ROW PLUS A REASON CODE. The fields are not trimmed: the interface needs the range and
208
+ // the price of the failed row to tell the person WHY there is no offer (and not just "none").
209
+ const compactRefusal = (r) => ({ ...compactOffer(r), code: r.code, why: r.why });
210
+
211
+ export function createQuotes({ config, http, now = () => Date.now(), evmCall = null, evmCode = null, providerAllowed = null, knownFactoryCodes = null }) {
212
+ // THE INTERFACE'S FACTORY POLICY (#103): the code hashes of factories it is willing to trade through
213
+ // (extcodehash semantics = keccak256 of the runtime code). An EMPTY list is "no policy" (like
214
+ // allowedProviders); a NON-EMPTY one refuses any factory whose code is not on it. Lower-cased once.
215
+ const factoryPolicy = Array.isArray(knownFactoryCodes) && knownFactoryCodes.length
216
+ ? knownFactoryCodes.map((h) => String(h).toLowerCase()).filter(Boolean)
217
+ : null;
218
+ const listeners = new Set();
219
+ let timer = null;
220
+ let size = 1; // the request size in units of the pair's ASSET; we start from one, as the engine does
221
+ let state = null;
222
+ let request = { direction: "buy", token: null, amount: null };
223
+ let inFlight = false;
224
+
225
+ const emit = () => { for (const handler of listeners) handler(state); };
226
+
227
+ const tick = async () => {
228
+ if (inFlight) return;
229
+ inFlight = true;
230
+ try {
231
+ // THE REQUEST SIZE. Usually it is derived from the person's amount (amount) and refined on the next tick.
232
+ // But the caller can name it HIMSELF (size) - then it is taken AS IS rather than derived anew: it is the
233
+ // same size the interface has already aligned to the provider's grid. There is no second size rule here.
234
+ const explicit = Number.isFinite(Number(request.size)) && Number(request.size) > 0;
235
+ const asked = explicit ? Number(request.size) : size;
236
+ const data = await http.json("/rate?amount=" + encodeURIComponent(asked) + "&side=" + encodeURIComponent(request.direction));
237
+ const book = readBook(data, now());
238
+ const offer = book.best;
239
+ const amount = Number.isFinite(Number(request.amount)) ? Number(request.amount) : null;
240
+ // The size derived from the person's amount becomes the size of the NEXT request: the price does not depend
241
+ // on volume, and the volume is needed by the provider to say whether it takes such a size. With a named size
242
+ // there is nothing to derive - it stays what it was asked as.
243
+ const derived = offer && amount !== null && !explicit ? derivedFor(offer, request.direction, amount, now()) : null;
244
+ if (derived && derived.size !== null) size = derived.size;
245
+ // THE UNIT with a named size: a buy is measured in XMR (the ASSET of the pair XMR/<token>), a sell - in the token.
246
+ const unitFromOffer = offer ? (String(offer.currency || "").toUpperCase() || null) : null;
247
+ const settled = explicit ? (offer ? snapToGrid(asked, offer) : null) : null;
248
+ state = {
249
+ direction: request.direction,
250
+ token: request.token,
251
+ amount,
252
+ size: explicit ? asked : size,
253
+ unit: explicit ? (request.direction === "sell" ? unitFromOffer : "XMR") : (derived ? derived.unit : null),
254
+ rate: explicit ? (offer ? Number(offer.rate) : null) : (derived ? derived.rate : null),
255
+ xmr: derived ? derived.xmr : null,
256
+ at: now(),
257
+ providerId: offer ? offer.providerId : null,
258
+ sizeOk: explicit ? Boolean(settled && settled.code === null) : Boolean(derived && derived.code === null),
259
+ sizeCode: explicit ? (settled ? settled.code : "no-offer") : (derived ? derived.code : "no-offer"),
260
+ seq: book.offers.length ? (book.offers.find((o) => o.seq !== null) || {}).seq ?? null : null,
261
+ // THE BEST, AS THE SERVICE NAMED IT (for the requested volume and side). The core does NOT recompute it:
262
+ // these are response data the interface needs to confirm its own "best" mark by pair and provider.
263
+ best: data && data.best && typeof data.best === "object"
264
+ ? { providerId: String(data.best.providerId || ""), pair: String(data.best.pair || "") } : null,
265
+ offers: book.offers.map(compactOffer),
266
+ refused: book.refused.map(compactRefusal),
267
+ references: Array.isArray(data && data.references) ? data.references.map((r) => ({ source: r.source, value: num(r.value), at: num(r.at) })) : [],
268
+ ok: book.offers.length > 0,
269
+ };
270
+ emit();
271
+ } catch (error) {
272
+ // A REFUSAL IS NOT REPLACED BY PAST NUMBERS: showing a stale price as live is worse than saying the connection
273
+ // is gone. The data is kept, but ok is cleared, and the interface sees it.
274
+ state = { ...(state || { direction: request.direction, token: request.token, amount: null, size, unit: null, rate: null, xmr: null,
275
+ at: now(), providerId: null, sizeOk: false, sizeCode: null, seq: null, best: null, offers: [], refused: [], references: [] }),
276
+ at: now(), ok: false, error: { code: error && error.code === "server-unavailable" ? "quote-unavailable" : "unknown" } };
277
+ emit();
278
+ } finally {
279
+ inFlight = false;
280
+ }
281
+ };
282
+
283
+ const restart = () => {
284
+ if (timer) clearInterval(timer);
285
+ timer = null;
286
+ if (listeners.size > 0) {
287
+ void tick();
288
+ timer = setInterval(() => { void tick(); }, config.limits.quotePollMs);
289
+ }
290
+ };
291
+
292
+ return {
293
+ watch(next, onSnapshot) {
294
+ if (typeof onSnapshot !== "function") fail("bad-input", { field: "onSnapshot" });
295
+ const direction = next && next.direction === "sell" ? "sell" : "buy";
296
+ const token = next && typeof next.token === "string" && next.token ? next.token.toLowerCase() : config.settlement.chain.nativeSymbol.toLowerCase();
297
+ request = {
298
+ direction,
299
+ token,
300
+ amount: next && next.amount !== undefined ? next.amount : null,
301
+ // A NAMED SIZE (in units of `unit`): an interface that has already aligned the volume to the provider's
302
+ // grid hands it here, and the core asks for EXACTLY it rather than deriving it from the amount anew.
303
+ size: next && Number.isFinite(Number(next.size)) && Number(next.size) > 0 ? Number(next.size) : null,
304
+ };
305
+ size = 1;
306
+ state = null;
307
+ listeners.add(onSnapshot);
308
+ restart();
309
+ return () => {
310
+ listeners.delete(onSnapshot);
311
+ if (listeners.size === 0) {
312
+ if (timer) clearInterval(timer);
313
+ timer = null;
314
+ }
315
+ };
316
+ },
317
+ // WHAT THE SDK KNOWS RIGHT NOW: a snapshot without waiting. Needed by an interface that subscribed later than
318
+ // the first poll happened.
319
+ snapshot: () => state,
320
+ // A FIRM QUOTE FOR AN ORDER. It carries a commitment under keys, so THE PROVIDER'S SIGNATURE IS CONFIRMED
321
+ // HERE, BY RECOVERING THE ADDRESS FROM THE EIP-712 SIGNATURE - not taken on trust from a flag the backend
322
+ // reports. A quote that cannot be verified is not "probably fine": a missing field, a missing signature or
323
+ // an unusable domain is REFUSED, never accepted (the rule the old flag carried is kept). The digest is
324
+ // built from the quote's own fields, so nothing signed can be swapped afterwards; the named quoteKey is
325
+ // matched against the recovered address. THEN THE CHAIN IS ASKED WHO OWNS THE KEY (providerOf): the
326
+ // signature alone certifies itself, because a key can sign anything and call itself any provider. The
327
+ // registry is read from the quote/order conditions - never from SDK settings: any registry can be deployed
328
+ // by anyone, and the order carries which one it is (the field is signed and bound into the terms). Finally
329
+ // the interface's allow-list, when it supplied one, is applied as policy (who is admitted at all).
330
+ //
331
+ // The path and the body are the same as the engine's (`requestOrderQuote` in www/js/core/rfqSource.js): POST to
332
+ // the named path, body { providerId, order }. The path is named by name, so it is overridable by options.
333
+ async firm(request) {
334
+ if (!request || typeof request !== "object") fail("bad-input", { field: "request" });
335
+ if (typeof request.providerId !== "string" || !request.providerId) fail("bad-input", { field: "providerId" });
336
+ if (!request.order || typeof request.order !== "object") fail("bad-input", { field: "order" });
337
+ let body = null;
338
+ try {
339
+ body = await http.json(config.route("orderQuote"), {
340
+ method: "POST",
341
+ headers: { "content-type": "application/json" },
342
+ body: JSON.stringify({ providerId: request.providerId, order: request.order }),
343
+ });
344
+ } catch (error) {
345
+ if (error && error.code === "bad-input") throw error;
346
+ fail("server-unavailable", { step: "order-quote" });
347
+ }
348
+ if (!body || body.ok !== true || !body.quote) fail("quote-refused", { step: "order-quote" });
349
+ const quote = body.quote;
350
+ // A MISSING FIELD OF THE SIGNED SET IS NOT "PROBABLY ZERO": the digest cannot be built, so the quote is
351
+ // not verifiable and is refused (its own code, so the interface can say which field is unbound).
352
+ const missingFields = orderQuoteMissingFields(quote);
353
+ if (missingFields.length) fail("quote-field-unbound", { step: "signature", missing: missingFields });
354
+ // THE QUOTE'S OWN EXPIRY, checked against the clock the core already has. It is bound in the signature too,
355
+ // but a stale quote is refused by its own code rather than looking like a signature failure.
356
+ const validUntil = Number(quote.validUntil);
357
+ // THE SCALE IS SECONDS: the same as the order's deadlines and block.timestamp. The core's clock is in
358
+ // milliseconds (Date.now), so exactly ONE boundary is converted - "now", not the quote value itself: one
359
+ // unit for the value, one conversion at the input.
360
+ const nowSec = Math.floor(now() / 1000);
361
+ if (Number.isFinite(validUntil) && validUntil > 0 && validUntil <= nowSec) {
362
+ fail("quote-expired", { step: "signature", validUntil });
363
+ }
364
+ // RECOVER, THEN MATCH THE NAMED KEY. A signature that does not recover is one refusal; a valid signature
365
+ // that recovers to a DIFFERENT address is another (the quote names a key that did not sign it).
366
+ const recoveredKey = recoverOrderQuoteKey(quote);
367
+ if (recoveredKey === null) fail("quote-signature-invalid", { step: "signature" });
368
+ const declaredKey = String(quote.quoteKey).toLowerCase();
369
+ if (recoveredKey !== declaredKey) fail("quote-key-mismatch", { step: "signature", got: recoveredKey, want: declaredKey });
370
+ // WHO OWNS THE KEY - ASKED OF THE REGISTRY NAMED BY THE QUOTE. Both fields are part of the signed set, so
371
+ // they cannot be swapped after signing; this is what turns "a key signed it" into "this provider's key
372
+ // signed it". No chain-read seam - refuse rather than take the signature on trust.
373
+ const registry = String(quote.registry || "").toLowerCase();
374
+ if (!/^0x[0-9a-f]{40}$/.test(registry) || registry === ZERO_ADDRESS) {
375
+ fail("quote-field-unbound", { step: "registry", field: "registry" });
376
+ }
377
+ const declaredProvider = String(quote.provider || "").toLowerCase();
378
+ if (!/^0x[0-9a-f]{40}$/.test(declaredProvider) || declaredProvider === ZERO_ADDRESS) {
379
+ fail("quote-field-unbound", { step: "registry", field: "provider" });
380
+ }
381
+ if (typeof evmCall !== "function") fail("quote-registry-unchecked", { step: "registry", why: "no-chain-read" });
382
+ let mappedProvider = null;
383
+ try {
384
+ mappedProvider = addressFromWord(await evmCall({ to: registry, data: providerOfData(declaredKey) }));
385
+ } catch {
386
+ fail("quote-registry-unchecked", { step: "registry", why: "read-failed", registry });
387
+ }
388
+ if (mappedProvider === null || mappedProvider === ZERO_ADDRESS) {
389
+ fail("quote-key-unknown", { step: "registry", registry, quoteKey: declaredKey });
390
+ }
391
+ if (mappedProvider !== declaredProvider) {
392
+ fail("quote-provider-mismatch", { step: "registry", got: mappedProvider, want: declaredProvider });
393
+ }
394
+ // THE INTERFACE'S OWN ADMISSION LIST - POLICY, NOT PROOF. The chain says whose key it is; whether this
395
+ // interface trades with that provider at all is the interface's decision, and it rides as a hook.
396
+ if (typeof providerAllowed === "function" && providerAllowed(declaredProvider) !== true) {
397
+ fail("quote-provider-not-allowed", { step: "policy", provider: declaredProvider });
398
+ }
399
+ // THE FACTORY: NAMED BY THE PROVIDER'S RECORD, CHECKED BY ITS CODE (#103). The factory will verify the
400
+ // quote in EVM (it is the EIP-712 verifyingContract), so it must be the one the record names AND its
401
+ // code must be a build this interface knows. The record lookup always runs; the code check runs when a
402
+ // policy list was supplied (nothing to pin against otherwise).
403
+ const declaredFactory = String(quote.factory || "").toLowerCase();
404
+ if (!/^0x[0-9a-f]{40}$/.test(declaredFactory) || declaredFactory === ZERO_ADDRESS) {
405
+ fail("quote-field-unbound", { step: "factory", field: "factory" });
406
+ }
407
+ let recordedFactory = null;
408
+ try {
409
+ recordedFactory = addressFromWord(await evmCall({ to: registry, data: factoryOfData(declaredProvider) }));
410
+ } catch {
411
+ fail("quote-factory-unchecked", { step: "factory", why: "read-failed", registry });
412
+ }
413
+ if (recordedFactory === null || recordedFactory === ZERO_ADDRESS) {
414
+ fail("quote-factory-unknown", { step: "factory", registry, provider: declaredProvider });
415
+ }
416
+ if (recordedFactory !== declaredFactory) {
417
+ fail("quote-factory-mismatch", { step: "factory", got: declaredFactory, want: recordedFactory });
418
+ }
419
+ if (factoryPolicy) {
420
+ if (typeof evmCode !== "function") fail("quote-factory-unchecked", { step: "factory", why: "no-code-read" });
421
+ let codeHex = null;
422
+ try { codeHex = await evmCode(declaredFactory); } catch { fail("quote-factory-unchecked", { step: "factory", why: "code-read-failed", factory: declaredFactory }); }
423
+ const codeBytes = hexToBytesQ(codeHex);
424
+ // EXTCODEHASH SEMANTICS: keccak256 of the runtime code. An address with no code (empty/0x) hashes to
425
+ // the keccak of empty, which is not on any build list - refused like any unknown build.
426
+ const codeHash = bytesToHexQ(keccak256(codeBytes || new Uint8Array(0)));
427
+ if (!factoryPolicy.includes(codeHash)) {
428
+ fail("quote-factory-code-unknown", { step: "factory", factory: declaredFactory, codeHash });
429
+ }
430
+ }
431
+ // BINDING THE QUOTE TO OUR TERMS. The signature confirms the provider signed THIS quote, but not that it is
432
+ // about our swap: we are responsible for the binding. The context string is computed by OUR engine module
433
+ // (format arrakis-order-v3; the field order is part of the protocol and must not be rearranged). There is
434
+ // deliberately no copy of the format here: a copy would silently drift from the protocol.
435
+ //
436
+ // THE ORDER OF THE GATES: signature first, then binding. So a refusal names the nearest reason, not the first
437
+ // one at hand.
438
+ // THE PROVIDER MAY NAME THE CLAIMER HIMSELF, AND THAT IS ALSO AN ASSEMBLED CONTEXT (a defect found). The
439
+ // provider may be the claimer: then it substitutes ITS OWN address into the order terms and signs the context
440
+ // with it already in place. Requiring the context from the caller in that case is impossible - they do not
441
+ // know the provider's address, and then the quote cannot be obtained at all (the live contour answered this
442
+ // with bad-input {field:"order"} BEFORE any network). So if the sent terms had no claimer, the context is
443
+ // computed from the terms WITH HIS value from the quote - exactly as the engine does it
444
+ // (sdk/src/swap-flow.mjs: `{ ...orderBase, claimer: claimerResolved }`). No OPTIONAL checks appeared here:
445
+ // a mismatched string is still a refusal (quote-refused { step: "context" }).
446
+ let expectedContext = null;
447
+ try { expectedContext = engine.orderContext.orderContextString(request.order); }
448
+ catch { expectedContext = null; }
449
+ if (expectedContext === null) {
450
+ const providerClaimer = quote && quote.claimer;
451
+ if (providerClaimer) {
452
+ try { expectedContext = engine.orderContext.orderContextString({ ...request.order, claimer: providerClaimer }); }
453
+ catch { fail("bad-input", { field: "order" }); }
454
+ } else {
455
+ fail("bad-input", { field: "order" });
456
+ }
457
+ }
458
+ const quoted = quote.context || "";
459
+ if (String(quoted) !== expectedContext) {
460
+ fail("quote-refused", { step: "context", why: quoted ? "other-terms" : "not-reported" });
461
+ }
462
+ return quote;
463
+ },
464
+ };
465
+ }