@ninsei-labs/sdk 0.50.0 → 0.51.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.
@@ -0,0 +1,129 @@
1
+ // Воркер: считает сторону ордера ВНЕ главного потока.
2
+ //
3
+ // Зачем он нужен, если он ничего не ускоряет. Доказательство DLEQ в JS занимает около 11 секунд
4
+ // (измерено; разбор половин ключа). Если считать его в главном потоке, страница
5
+ // замирает целиком - вместе с любым индикатором прогресса, который на ней нарисован. Воркер не делает
6
+ // счёт быстрее ни на миллисекунду: он делает его невидимым для интерфейса. Пока воркер считает, страница
7
+ // жива и может честно показывать, что происходит.
8
+ //
9
+ // Протокол: { id, type, ...аргументы } -> { id, kind: "progress"|"done"|"error", ... }
10
+ import {
11
+ createOrderBuilder, ed25519, secp256k1, keccak256, sha3_512, randomBytes,
12
+ } from "../../assets/vendors/atomic/atomic-browser.js";
13
+
14
+ const builder = createOrderBuilder({ ed25519, secp256k1, keccak256, sha3_512, randomBytes });
15
+
16
+ // Ключи шифрования построенных сторон: приватный ключ по своему публичному. Наружу приватные не уходят -
17
+ // вскрытие посылки происходит здесь.
18
+ //
19
+ // ПОЧЕМУ КАРТА, А НЕ ОДНА ПЕРЕМЕННАЯ. Сторон в ордере две, и посылка шифруется на ключ КОНКРЕТНОЙ стороны.
20
+ // Одна переменная хранила ключ последней построенной стороны, поэтому посылка вскрывалась не тем ключом, и
21
+ // проверка честно отвечала "в шифртексте не та половина". Теперь ключ находится по публичному.
22
+ const encPrivByPub = new Map();
23
+
24
+ // Кому зашифрована посылка в данном контексте ордера. Контекст - идентификатор стороны.
25
+ const sealedToPub = new Map();
26
+
27
+ const canon = (h) => "0x" + String(h || "").replace(/^0x/i, "").toLowerCase();
28
+
29
+ self.onmessage = async (event) => {
30
+ const { id, type, orderContext, ownHalf, counterparty, sealedBytes, joint } = event.data || {};
31
+ const say = (stage, detail) => self.postMessage({ id, kind: "progress", stage, detail });
32
+ try {
33
+ if (type === "newSide") {
34
+ // Половина генерируется мгновенно, а доказательство считается долго: сообщаем оба этапа
35
+ // раздельно, чтобы интерфейс успел показать первый до начала второго.
36
+ say("half", "генерирую половину ключа");
37
+ // Текст идёт НА КНОПКУ: она неактивна во время доказательства, и это единственное место, куда
38
+ // смотрит пользователь. Поэтому коротко и на языке интерфейса.
39
+ say("proof", "proving DLEQ proof: about 10 seconds");
40
+ const side = builder.newSide(orderContext, false, (bit, total) => {
41
+ say("proof", "proving DLEQ proof: " + bit + " of " + total + " bits (" +
42
+ Math.round((bit / total) * 100) + "%)");
43
+ });
44
+ // Hex-поля отдаём в каноническом виде (с префиксом 0x, нижний регистр), как это делает seal():
45
+ // иначе одни и те же байты гуляют по коду в двух записях, и однажды это даёт ошибку в другом месте.
46
+ const canon = (h) => "0x" + String(h).replace(/^0x/i, "").toLowerCase();
47
+ // Ключ кладём в карту по публичному: он понадобится, если посылка шифруется на эту сторону.
48
+ encPrivByPub.set(canon(side.enc.pub), side.enc.priv);
49
+ self.postMessage({
50
+ id, kind: "done",
51
+ result: {
52
+ pub: { ed: canon(side.pub.ed), secp: canon(side.pub.secp) },
53
+ encPub: canon(side.enc.pub),
54
+ proof: side.proof,
55
+ half: side.half, // BigInt: структурное клонирование переносит его без потерь
56
+ // ПОЛОВИНА ПРОСМОТРА ТОЖЕ УХОДИТ НАРУЖУ. Без неё сборка общего адреса падала на «не задано:
57
+ // ownViewHalf»: воркер возвращал только половину КЛЮЧА ТРАТЫ, а вызывающий код брал из ответа обе.
58
+ // Сторона их создаёт (order.js: newSide), терялся именно ответ воркера.
59
+ viewHalf: side.viewHalf,
60
+ viewPub: canon(side.viewPub),
61
+ },
62
+ });
63
+ } else if (type === "seal") {
64
+ say("seal", "шифрую половину для контрагента");
65
+ // Запоминаем, на чей ключ зашифровали: именно им и надо будет вскрывать.
66
+ sealedToPub.set(String(orderContext), canon(counterparty && counterparty.encPub));
67
+ const sealed = builder.sealFor({ enc: { pub: counterparty.encPub } }, ownHalf, orderContext);
68
+ self.postMessage({ id, kind: "done", result: { commitment: sealed.commitment, bytes: sealed.bytes } });
69
+ } else if (type === "commit") {
70
+ // Обязательство на половину: наружу уходит только хеш, сама половина остаётся здесь.
71
+ const hex = "0x" + builder.halfCommitment(ownHalf).replace(/^0x/, "");
72
+ self.postMessage({ id, kind: "done", result: { commitment: hex } });
73
+ } else if (type === "pointOf") {
74
+ self.postMessage({ id, kind: "done", result: { ed: builder.pointOf(ownHalf) } });
75
+ } else if (type === "jointAddress") {
76
+ // АДРЕС СЧИТАЕТСЯ ЗДЕСЬ, А НЕ В ГЛАВНОМ ПОТОКЕ: криптография живёт в одном месте, и главный поток
77
+ // не должен обрастать половинами и кривыми. Наружу уходит строка адреса и обе публичные точки.
78
+ self.postMessage({ id, kind: "done", result: builder.jointAddress(joint || {}) });
79
+ } else if (type === "newMakerMaterial") {
80
+ // МАТЕРИАЛ МЕЙКЕРА ОБРАТНОЙ СДЕЛКИ: ДВЕ ПОЛОВИНЫ И ИХ ПУБЛИЧНАЯ ЧАСТЬ, БЕЗ ДОКАЗАТЕЛЬСТВА.
81
+ //
82
+ // ПОЧЕМУ БЕЗ ДОКАЗАТЕЛЬСТВА, хотя в прямом направлении сторона его несёт. Доказательство DLEQ
83
+ // связывает половину с ПРИВЯЗКОЙ ордера (соль, сроки, адрес забора), а привязку в обратном режиме
84
+ // назначает НОДА и присылает её в билете. Пока билета нет, контекста нет, и доказывать нечего -
85
+ // ровно поэтому фаза 1 мейкера у ноды (tools/maker-reverse-order.mjs) считает те же величины и
86
+ // ничего не доказывает. Считать здесь одиннадцатисекундное доказательство значило бы заморозить
87
+ // кнопку заявки на то, что ноде пока не нужно.
88
+ //
89
+ // ЧТО УХОДИТ НАРУЖУ: те же четыре имени, которыми нода принимает заявку мейкера
90
+ // (rfq/reverseOrder.mjs, makerRequestRefusal), - и две половины, которыми мейкер потом забирает
91
+ // выплату и видит приход XMR. Имена половин взяты у вывода (order.js: newSide) и у файла
92
+ // восстановления (recovery/recoveryFile.js: spendHalf, viewHalf), чтобы у одного и того же
93
+ // значения не появилось второго имени.
94
+ const spendHalf = builder.halves.newHalf();
95
+ const viewHalf = builder.halves.newHalf();
96
+ self.postMessage({
97
+ id, kind: "done",
98
+ result: {
99
+ // ОБЯЗАТЕЛЬСТВО И ТОЧКА - ОБ ОДНОЙ И ТОЙ ЖЕ ПОЛОВИНЕ ТРАТЫ, и считает их один модуль половин:
100
+ // цепь сверяет половину с обязательством (NinseiEscrow.sol:235), поэтому разойтись им нельзя.
101
+ commitHalfClaimer: canon(builder.halfCommitment(spendHalf)),
102
+ edPointClaimer: canon(builder.halves.publicHalves(spendHalf).ed),
103
+ // ТОЧКА ПРОСМОТРА: по ней нода собирает общий адрес (адрес - сумма точек).
104
+ edViewPointClaimer: canon(builder.halves.publicHalves(viewHalf).ed),
105
+ // ПОЛОВИНА ПРОСМОТРА - ПРИВАТНАЯ и уходит ноде единственной из двух приватных: без неё она не
106
+ // увидит приход XMR. Форма записи та же, что у ноды: 32 байта hex (maker-reverse-order.mjs:83).
107
+ viewHalfClaimer: canon("0x" + viewHalf.toString(16).padStart(64, "0")),
108
+ spendHalf,
109
+ viewHalf,
110
+ },
111
+ });
112
+ } else if (type === "verify") {
113
+ say("verify", "проверяю доказательство контрагента");
114
+ const side = { proof: counterparty.proof, pub: { ed: counterparty.edPub } };
115
+ // Вскрываем тем ключом, на который посылка была зашифрована в этом контексте.
116
+ const recipient = sealedToPub.get(String(orderContext));
117
+ const openWith = recipient ? encPrivByPub.get(recipient) : null;
118
+ if (sealedBytes && !openWith) {
119
+ throw new Error("нет ключа, которым вскрывать посылку: не было шифрования в этом контексте ордера");
120
+ }
121
+ const verdict = builder.verifySide(side, orderContext, sealedBytes || null, openWith);
122
+ self.postMessage({ id, kind: "done", result: verdict });
123
+ } else {
124
+ self.postMessage({ id, kind: "error", message: "неизвестный запрос воркеру: " + String(type) });
125
+ }
126
+ } catch (err) {
127
+ self.postMessage({ id, kind: "error", message: (err && err.message) || String(err) });
128
+ }
129
+ };
@@ -389,7 +389,9 @@ export const CHAINS = [
389
389
  // half is not sent but revealed by presentation. Switching back to "simulated" is one edit, to show a demo with
390
390
  // no money movement. cashier: the INTERFACE's fee cashier, named in quotes and now deployed (the second CREATE of
391
391
  // the same pass). implementationCodeHash: the escrow implementation the factory carries, checked against the chain.
392
- escrow: { address: "0x3eE25f12758ec28e5b3045d16132D89Cd0bf341F", mode: "live", cashier: "0x1123A3875775Da25FA181Af0d91EB278E94a5dfb", implementationCodeHash: "0xf97af2b4eed8c646d82bce001e52ff03c065d43127073f8ee52271b65c35d659" },
392
+ // deploymentBlock: the height the factory was created at; a log search for its orders starts here, not at zero,
393
+ // because public RPCs cap a log query at about 10M blocks and would refuse a scan from genesis.
394
+ escrow: { address: "0x3eE25f12758ec28e5b3045d16132D89Cd0bf341F", mode: "live", deploymentBlock: 321430239, cashier: "0x1123A3875775Da25FA181Af0d91EB278E94a5dfb", implementationCodeHash: "0xf97af2b4eed8c646d82bce001e52ff03c065d43127073f8ee52271b65c35d659" },
393
395
  tokens: [
394
396
  // Test USDC (Circle): decimals 6 and symbol "USDC" verified on chain by the same node.
395
397
  { symbol: "USDC", address: "0x75faf114eafb1BDbe2F0316DF893fd58CE46AA4d", decimals: 6, usd: 1, cls: "ar-token-usdc" },
@@ -66,9 +66,9 @@ import { cachedFeeRate } from "../evm/fees.js";
66
66
  import { networkFromShape } from "../monero/address.js";
67
67
  import { height as nodeHeightValue } from "../monero/node.js";
68
68
 
69
- // THE CORE ADDRESS AS A STRING - THE BUILT BUNDLE UNDER www/: the page gets the core as ONE module, not a pile
70
- // of sdk/ sources; ?v=<hash> keeps the cache fresh.
71
- const SDK_BUNDLE = "/assets/vendors/sdk/sdk-browser.js?v=69d79c13";
69
+ // THE CORE ADDRESS AS A STRING - THE BUILT BUNDLE SHIPPED WITH THE PACKAGE. A relative URL resolves both in the
70
+ // published `engine/` tree and in the page that serves this shim from `www/js/sdk/`; ?v=<hash> keeps the cache fresh.
71
+ const SDK_BUNDLE = new URL("../../assets/vendors/sdk/sdk-browser.js?v=69d79c13", import.meta.url).href;
72
72
 
73
73
  // A NAMED SEAM FOR CHECKS - THE SAME TECHNIQUE AS sdkBookUse BELOW. The asynchronous path is driven against a
74
74
  // stand-in order book (the demo network has no provider), and the ordinary escrow funding that follows a settled
package/index.d.ts CHANGED
@@ -80,7 +80,7 @@ export interface NinseiOptions {
80
80
  readonly rpc?: { readonly evm?: string; readonly monero?: string };
81
81
  };
82
82
  /**
83
- * Renaming API paths by name: { swaps, swap, evmSwaps, orderQuote, revealedHalf, swept }.
83
+ * Renaming API paths by name: { swaps, swap, evmSwaps, orderQuote, orderSell, revealedHalf, swept }.
84
84
  * An unknown name is bad-input (a typo must not silently fall back to the default).
85
85
  */
86
86
  routes?: Readonly<Record<string, string>>;
@@ -294,6 +294,12 @@ export interface ChainConfig {
294
294
  readonly nativeSymbol: string;
295
295
  readonly tokens: readonly TokenConfig[];
296
296
  readonly dex: { readonly routes: readonly unknown[] } | null;
297
+ /** The escrow deployment on this network, when there is one. `deploymentBlock` is the block that holds the factory's creation. */
298
+ readonly escrow: {
299
+ readonly address: string | null;
300
+ readonly mode: string | null;
301
+ readonly deploymentBlock: number | null;
302
+ } | null;
297
303
  }
298
304
 
299
305
  export interface TokenConfig {
@@ -423,6 +429,11 @@ export interface FirmQuoteRequest {
423
429
  readonly providerId: string;
424
430
  /** The order terms: how much, to whom, the deadlines, the salt. The context the signature is bound to is assembled from them. */
425
431
  readonly order: Readonly<Record<string, unknown>>;
432
+ /**
433
+ * THE TAKER'S MAKER VIEW HALF, when the order was built from a recovery key. Sent top-level to the node; the quote
434
+ * must echo it, so the joint view key can be rebuilt from the twelve words.
435
+ */
436
+ readonly viewHalfClaimer?: string;
426
437
  }
427
438
 
428
439
  export interface QuotesApi {
@@ -435,6 +446,11 @@ export interface QuotesApi {
435
446
  snapshot(): QuoteSnapshot | null;
436
447
  /** A binding quote with a TTL. A refusal comes as a code, not text. */
437
448
  firm(offer: FirmOffer): Promise<Quote>;
449
+ /**
450
+ * A reverse-mode ticket: the maker sells XMR. The request is the node's `request` object; the answer carries the
451
+ * signed order quote and its claim tip, which is checked against the gas price before it is returned.
452
+ */
453
+ sale(request: Readonly<Record<string, unknown>>): Promise<Readonly<Record<string, unknown>>>;
438
454
  }
439
455
 
440
456
  export interface QuoteRequest {
@@ -1260,6 +1276,11 @@ export interface SweepApi {
1260
1276
  destination(request: SweepDestinationRequest): Promise<SweepDestination>;
1261
1277
  /** What is known about the withdrawal from the recovery file: the address, the restore height, what we can dispose of. */
1262
1278
  plan(request: SweepPlanRequest): Promise<SweepPlan>;
1279
+ /**
1280
+ * Build the same withdrawal material from the 12 words and an order found by `findSwaps`, without a recovery file.
1281
+ * The checks are the same: the side, the person's points against the key, the joint address.
1282
+ */
1283
+ fromOrder(request: SweepFromOrderRequest): Promise<SweepFromOrderResult>;
1263
1284
  /**
1264
1285
  * RESTORE THE WALLET FROM A HEIGHT AND REPORT WHAT IT HOLDS. Sets the restore height a little below the one given,
1265
1286
  * rescans, syncs and reads the total and the spendable balance. Coins that exist but are locked get ONE more pass
@@ -1336,6 +1357,8 @@ export interface SweepRestoreResult {
1336
1357
  /** Present when a window was requested: whether it was used, and why it was skipped. */
1337
1358
  readonly window?: {
1338
1359
  readonly used: boolean;
1360
+ /** Whether the full scan ran because the window found nothing (or could not scan). */
1361
+ readonly fallback: boolean;
1339
1362
  readonly skipped: string | null;
1340
1363
  readonly fromHeight: number;
1341
1364
  readonly toHeight: number;
@@ -1375,6 +1398,11 @@ export interface SweepWallet {
1375
1398
  chainId(): string;
1376
1399
  unlockedBalance(): Promise<bigint | number | string>;
1377
1400
  sync(): Promise<void>;
1401
+ /** The withdrawal role: no-op when this wallet is already open on the plan's address. */
1402
+ open(plan: SweepPlan): Promise<void>;
1403
+ sweepUnlocked(request: { readonly address: string; readonly relay: boolean }): Promise<unknown>;
1404
+ relay(signed: unknown): Promise<readonly string[]>;
1405
+ fee?(signed: unknown): Promise<string | null>;
1378
1406
  close(): Promise<void>;
1379
1407
  driver(): unknown;
1380
1408
  }
@@ -1389,6 +1417,43 @@ export interface SweepReportResult {
1389
1417
  readonly reported: boolean;
1390
1418
  }
1391
1419
 
1420
+ export interface SweepFromOrderRequest {
1421
+ /** The 12 words of the recovery key. */
1422
+ readonly mnemonic: string;
1423
+ readonly chainId: number;
1424
+ /** The settlement network slug, e.g. `arbitrum-sepolia`. */
1425
+ readonly network: string;
1426
+ /** The Monero network the joint address belongs to, e.g. `stagenet`. */
1427
+ readonly moneroNetwork: string;
1428
+ /** One entry from `findSwaps`. */
1429
+ readonly order: {
1430
+ readonly escrow: string;
1431
+ readonly locker: string;
1432
+ readonly claimer: string;
1433
+ readonly edPointLocker: string;
1434
+ readonly edPointClaimer: string;
1435
+ readonly edViewPointLocker: string;
1436
+ readonly index: number;
1437
+ readonly side: "locker" | "claimer";
1438
+ readonly startHeight?: number | null;
1439
+ readonly blockTime?: number | null;
1440
+ readonly readyBy?: number;
1441
+ readonly t1?: number;
1442
+ readonly [field: string]: unknown;
1443
+ };
1444
+ /** Optional encryption boundary for the generated file; empty means an unencrypted file. */
1445
+ readonly passphrase?: string;
1446
+ }
1447
+
1448
+ export interface SweepFromOrderResult {
1449
+ /** The payload `buildPayload` produced; the same fields a recovery file carries. */
1450
+ readonly payload: unknown;
1451
+ /** The same file `plan`/`keys` accept, so a caller can keep using the file-based API. */
1452
+ readonly contents: string;
1453
+ /** The joint Monero address built from the key and the order. */
1454
+ readonly address: string;
1455
+ }
1456
+
1392
1457
  export interface SweepPlanRequest {
1393
1458
  readonly file: { readonly contents: string } | string;
1394
1459
  readonly passphrase: string;
@@ -1429,8 +1494,10 @@ export type SweepMissing = "restore-height" | "spendable" | "revealed-half";
1429
1494
  export interface SweepRunRequest extends SweepPlanRequest {
1430
1495
  /** Where to withdraw the XMR to. */
1431
1496
  readonly to: string;
1497
+ /** An already opened and synced adapter, the same one `restore` used. */
1498
+ readonly wallet?: MoneroWalletAdapter;
1432
1499
  /** Returns a Monero wallet: the core does not drag in WASM. */
1433
- readonly connectWallet: (plan: SweepPlan) => Promise<MoneroWalletAdapter>;
1500
+ readonly connectWallet?: (plan: SweepPlan) => Promise<MoneroWalletAdapter>;
1434
1501
  }
1435
1502
 
1436
1503
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ninsei-labs/sdk",
3
- "version": "0.50.0",
3
+ "version": "0.51.0",
4
4
  "publishConfig": {
5
5
  "access": "public",
6
6
  "registry": "https://registry.npmjs.org/"
@@ -28,6 +28,7 @@
28
28
  "check:terminal": "node tools/check.mjs --only=terminal",
29
29
  "check:terminal-teeth": "node tools/check-terminal-teeth.mjs",
30
30
  "check:sweep": "node tools/sweep-refusal.test.mjs",
31
+ "check:sweep-half": "node tools/check-sweep-half.mjs",
31
32
  "sync:version": "node tools/sync-version.mjs",
32
33
  "check:restore": "node tools/check-restore.mjs",
33
34
  "check:restore-height": "node tools/check-restore-height.mjs",
@@ -87,13 +88,14 @@
87
88
  "files": [
88
89
  "src",
89
90
  "engine",
91
+ "assets",
90
92
  "index.d.ts",
91
93
  "README.md",
92
94
  "CHANGELOG.md",
93
95
  "LICENSE"
94
96
  ],
95
97
  "engines": {
96
- "node": ">=22"
98
+ "node": ">=24.22.1"
97
99
  },
98
100
  "keywords": [
99
101
  "xmr",
@@ -164,6 +164,32 @@ export function moneroWalletAdapter({
164
164
  return undefined;
165
165
  },
166
166
 
167
+ // THE SAME ADAPTER ALSO PLAYS THE WITHDRAWAL ROLE. `sweep.restore` already takes this wallet; `sweep.sign`
168
+ // opened a second adapter over a second monero-ts wallet. `open` is a no-op when the address already matches
169
+ // (the page has opened and synced this wallet), and the withdrawal calls go to the same underlying wallet.
170
+ async open(plan) {
171
+ if (wallet === null) throw new SdkError("wallet-not-connected", { vm: XMR_VM, surface: "open" });
172
+ const wanted = plan && plan.address ? String(plan.address) : null;
173
+ if (wanted && address !== wanted) throw new SdkError("recovery-failed", { step: "address" });
174
+ return undefined;
175
+ },
176
+
177
+ async sweepUnlocked(request) {
178
+ return await need("sweepUnlocked").sweepUnlocked({ address: request.address, relay: request.relay });
179
+ },
180
+
181
+ async relay(signed) {
182
+ return await need("relay").relayTxs(signed);
183
+ },
184
+
185
+ // Optional, like every withdrawal adapter: a library that reports no fee answers null rather than a zero.
186
+ async fee(signed) {
187
+ const tx = Array.isArray(signed) ? signed[0] : signed;
188
+ if (!tx || typeof tx.getFee !== "function") return null;
189
+ const value = tx.getFee();
190
+ return value === null || value === undefined ? null : String(value);
191
+ },
192
+
167
193
  // THE MONERO WALLET HAS NO EVENTS (monero-ts has no on/removeListener): a subscriber receives a notification
168
194
  // after connect and after sync, and the subscription promises nothing more. Unsubscribing is the same as the EVM
169
195
  // adapter's.
package/src/config.mjs CHANGED
@@ -51,6 +51,13 @@ const chainConfig = (chain) => ({
51
51
  decimals: Number(t.decimals),
52
52
  })),
53
53
  dex: chain.dex ? { routes: chain.dex.routes || [] } : null,
54
+ // THE ESCROW'S BIRTH HEIGHT, when the network has an escrow. A caller can hand it to findSwaps instead of
55
+ // inventing a start block or asking from genesis, which public RPCs refuse.
56
+ escrow: chain.escrow ? {
57
+ address: chain.escrow.address || null,
58
+ mode: chain.escrow.mode || null,
59
+ deploymentBlock: Number.isSafeInteger(chain.escrow.deploymentBlock) ? chain.escrow.deploymentBlock : null,
60
+ } : null,
54
61
  });
55
62
 
56
63
  // --- ADDRESSES AND PATHS THAT ARE SUPPLIED FROM OUTSIDE ------------------------------------
@@ -79,6 +86,8 @@ export const DEFAULT_ROUTES = Object.freeze({
79
86
  evmSwaps: "/evm-swaps",
80
87
  // A quote for an order: the provider's commitment under keys, not a market snapshot.
81
88
  orderQuote: "/order-quote",
89
+ // A reverse-mode ticket: the maker supplies the XMR fields, the node answers with a signed quote and its claim tip.
90
+ orderSell: "/order-sell",
82
91
  revealedHalf: "/escrow/revealed-half",
83
92
  swept: "/swaps/{id}/swept",
84
93
  });
package/src/index.mjs CHANGED
@@ -269,6 +269,8 @@ export function createNinsei(options) {
269
269
  // A firm quote is stage 2: it is signed with a key and lives in the order protocol, not in a market snapshot.
270
270
  // A FIRM QUOTE: accepted only with a confirmed provider signature.
271
271
  firm: (request) => quotes.firm(request),
272
+ // A REVERSE TICKET: the node signs it; its claim tip is checked here before the caller sees it.
273
+ sale: (request) => quotes.sale(request),
272
274
  },
273
275
  // READING STATE WORKS, STARTING IS NAMED HONESTLY: list/get raise swaps from storage after a page reload;
274
276
  // start holds the step order and runs into the step that creates the record (stage 2).
@@ -32,6 +32,8 @@ export interface Chain {
32
32
  rpcUrl?: string;
33
33
  tokens?: ChainToken[];
34
34
  uniswap?: ChainUniswap;
35
+ /** The escrow deployment, including the factory's birth height. */
36
+ escrow?: { address?: string; mode?: string; deploymentBlock?: number; [field: string]: unknown };
35
37
  [field: string]: unknown;
36
38
  }
37
39
 
@@ -325,6 +325,18 @@ export interface FoundSwap {
325
325
  readonly transactionHash: string | null;
326
326
  readonly blockNumber: number | null;
327
327
  readonly logIndex: number | null;
328
+ /** The settlement block timestamp in seconds, when the caller passed `evmBlockTime`; null otherwise. */
329
+ readonly blockTime: number | null;
330
+ /** The settlement asset of the escrow. Current contracts are native-only. */
331
+ readonly settlementToken: "native";
332
+ /** The asset the person actually paid before the DEX leg. Not on the settlement chain, so `null` for a found order. */
333
+ readonly payToken: string | null;
334
+ /** Why `payToken` is absent. `not-on-chain` means the order event and clone carry no token; do not infer one. */
335
+ readonly payTokenReason: "not-on-chain";
336
+ /** The XMR side of the order in atomic units, read from the clone's condition word 18; null when no code reader
337
+ * was passed or the contract is not the expected clone. */
338
+ readonly xmrAmount: string | null;
339
+ readonly xmrAmountReason: "no-code-reader" | "code" | "not-clone" | null;
328
340
  readonly index: number;
329
341
  readonly side: "locker" | "claimer";
330
342
  /** The Monero height a withdrawal may start from, derived from the order's block time. Null when the caller did
@@ -376,11 +388,15 @@ export declare function findSwaps(request: {
376
388
  address?: string | null;
377
389
  evmLogs: (request: { address: string; topics: (string | null)[]; fromBlock: number | string; toBlock: number | string }) => Promise<unknown[]>;
378
390
  fromBlock?: number | string;
391
+ /** The factory's deployment block from the chain registry; used when `fromBlock` is omitted. */
392
+ deploymentBlock?: number | null;
379
393
  toBlock?: number | string;
380
394
  gap?: number;
381
395
  maxIndex?: number;
382
396
  /** Reads the settlement block timestamp, in seconds: (blockNumber) -> seconds. */
383
397
  evmBlockTime?: (blockNumber: number) => Promise<number>;
398
+ /** Reads contract code: (address) -> 0x-hex. Used to read the XMR amount from an order clone. */
399
+ evmCode?: (address: string) => Promise<string>;
384
400
  /** The Monero tip at the time of the search: () -> height. */
385
401
  moneroTip?: () => Promise<number>;
386
402
  /** A Monero block header timestamp, in seconds: (height) -> seconds. */
package/src/quotes.mjs CHANGED
@@ -393,6 +393,58 @@ export function createQuotes({ config, http, now = () => Date.now(), evmCall = n
393
393
  //
394
394
  // The path and the body are the same as the engine's (`requestOrderQuote` in www/js/core/rfqSource.js): POST to
395
395
  // the named path, body { providerId, order }. The path is named by name, so it is overridable by options.
396
+ // A REVERSE-MODE TICKET. The maker sells XMR; the node answers with a signed order quote that carries the claim
397
+ // tip. The tip is checked HERE, on the ticket's own signed figure, with the same formula the node used: base fee
398
+ // plus the priority part of the gas price. A buy quote has no tip; a sale is the only route where one exists.
399
+ async sale(request) {
400
+ if (!request || typeof request !== "object") fail("bad-input", { field: "request" });
401
+ const payload = request.request && typeof request.request === "object" ? request : { request };
402
+ let res = null;
403
+ try {
404
+ res = await http.tryJson(config.route("orderSell"), {
405
+ method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(payload),
406
+ });
407
+ } catch (error) {
408
+ if (error && error.code === "bad-input") throw error;
409
+ fail("server-unavailable", { step: "order-sell" });
410
+ }
411
+ if (!res || res.ok !== true || !res.body || res.body.ok !== true) fail("quote-refused", { step: "order-sell" });
412
+ const ticket = res.body;
413
+ const quote = (ticket.handoff && ticket.handoff.quote) || ticket.quote || null;
414
+ if (!quote || typeof quote !== "object") fail("quote-field-unbound", { step: "order-sell", field: "quote" });
415
+ // THE TICKET'S TIP MUST BE THE SIGNED ONE. The node returns both the ticket's `claimTip` object and the quote it
416
+ // signed; a difference means the figure shown to the person is not the figure the escrow will pay.
417
+ const shown = ticket.claimTip && ticket.claimTip.wei !== undefined && ticket.claimTip.wei !== null
418
+ ? String(ticket.claimTip.wei) : null;
419
+ if (shown === null) fail("quote-field-unbound", { step: "claimTip", field: "claimTip" });
420
+ if (ticket.claimTip.signed !== true) fail("quote-field-unbound", { step: "claimTip", field: "signed" });
421
+ let signedTip = null;
422
+ try { signedTip = quote.claimTip === undefined || quote.claimTip === null || quote.claimTip === "" ? "0" : BigInt(quote.claimTip).toString(); }
423
+ catch { fail("quote-field-unbound", { step: "claimTip", field: "claimTip" }); }
424
+ let shownBig = null;
425
+ try { shownBig = BigInt(shown); } catch { fail("quote-tip-unreadable", { step: "claimTip" }); }
426
+ if (signedTip === null || shownBig < 0n || BigInt(signedTip) !== shownBig) {
427
+ fail("quote-refused", { step: "claimTip", why: "not-the-signed-tip" });
428
+ }
429
+ if (shownBig > 0n) {
430
+ if (typeof evmBlock !== "function") fail("quote-tip-unchecked", { step: "claimTip", why: "no-block-read" });
431
+ let block = null;
432
+ try { block = await evmBlock("latest"); } catch { fail("quote-tip-unchecked", { step: "claimTip", why: "block-read-failed" }); }
433
+ const baseFeeWei = block && (block.baseFeePerGas !== undefined ? block.baseFeePerGas : block.baseFeeWei);
434
+ // THE NODE SIZES THE TIP FROM THE GAS PRICE, PRIORITY INCLUDED. The SDK used to pass only the base fee, so an
435
+ // honest L1 tip (or a base fee that dropped between sizing and checking) was refused.
436
+ let priorityFeeWei = block && block.priorityFeePerGas !== undefined ? block.priorityFeePerGas : null;
437
+ if (priorityFeeWei === null && block && block.gasPrice !== undefined && baseFeeWei !== undefined && baseFeeWei !== null) {
438
+ try {
439
+ const gas = BigInt(block.gasPrice); const base = BigInt(baseFeeWei);
440
+ priorityFeeWei = gas > base ? gas - base : 0n;
441
+ } catch { priorityFeeWei = 0n; }
442
+ }
443
+ const verdict = claimTipVerdict({ claimTipWei: shownBig, baseFeeWei, priorityFeeWei: priorityFeeWei === null ? 0n : priorityFeeWei });
444
+ if (!verdict.ok) fail(verdict.code, { step: "claimTip", ...verdict });
445
+ }
446
+ return ticket;
447
+ },
396
448
  async firm(request) {
397
449
  if (!request || typeof request !== "object") fail("bad-input", { field: "request" });
398
450
  if (typeof request.providerId !== "string" || !request.providerId) fail("bad-input", { field: "providerId" });
@@ -405,6 +457,8 @@ export function createQuotes({ config, http, now = () => Date.now(), evmCall = n
405
457
  fail("address-denied", { step: "order-quote" });
406
458
  }
407
459
  let res = null;
460
+ const viewHalfClaimer = request.viewHalfClaimer === undefined || request.viewHalfClaimer === null || request.viewHalfClaimer === ""
461
+ ? null : String(request.viewHalfClaimer);
408
462
  try {
409
463
  // THE ANSWER IS READ AS A VALUE (tryJson), NOT AS AN EXCEPTION: "did not answer" and "answered and
410
464
  // refused" must stay different, and a refusal's own body carries the reason. `json` throws
@@ -412,7 +466,8 @@ export function createQuotes({ config, http, now = () => Date.now(), evmCall = n
412
466
  res = await http.tryJson(config.route("orderQuote"), {
413
467
  method: "POST",
414
468
  headers: { "content-type": "application/json" },
415
- body: JSON.stringify({ providerId: request.providerId, order: request.order }),
469
+ body: JSON.stringify({ providerId: request.providerId, order: request.order,
470
+ ...(viewHalfClaimer === null ? {} : { viewHalfClaimer }) }),
416
471
  });
417
472
  } catch (error) {
418
473
  if (error && error.code === "bad-input") throw error;
@@ -428,6 +483,17 @@ export function createQuotes({ config, http, now = () => Date.now(), evmCall = n
428
483
  }
429
484
  const body = res.body;
430
485
  const quote = body.quote;
486
+ // THE MAKER'S VIEW HALF IS OURS, NOT THE NODE'S CHOICE. When the caller supplies it, the quote must carry the
487
+ // same scalar back; otherwise the maker used another half and the joint view key cannot be rebuilt from the key.
488
+ if (viewHalfClaimer !== null) {
489
+ const echoed = quote.viewHalfClaimer;
490
+ if (echoed === undefined || echoed === null || echoed === "") {
491
+ fail("quote-field-unbound", { step: "viewHalfClaimer", field: "viewHalfClaimer" });
492
+ }
493
+ if (String(echoed).toLowerCase() !== viewHalfClaimer.toLowerCase()) {
494
+ fail("quote-refused", { step: "viewHalfClaimer", why: "not-echoed" });
495
+ }
496
+ }
431
497
  // A MISSING FIELD OF THE SIGNED SET IS NOT "PROBABLY ZERO": the digest cannot be built, so the quote is
432
498
  // not verifiable and is refused (its own code, so the interface can say which field is unbound).
433
499
  const missingFields = orderQuoteMissingFields(quote);