@ninsei-labs/sdk 0.50.0 → 0.51.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.
@@ -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 {
@@ -1124,6 +1140,9 @@ export interface StartRequest {
1124
1140
  recoveryKey?: string | { readonly mnemonic: string };
1125
1141
  /** The swap number, when the caller already reserved one. Absent - the SDK reserves the next free number. */
1126
1142
  recoveryIndex?: number;
1143
+ /** The first block for the on-chain number check. Absent - the chain registry's escrow deployment block, and
1144
+ * zero only when the registry has no height. */
1145
+ fromBlock?: string | number;
1127
1146
  /** The Monero height the swap starts at. Absent - the SDK asks the configured nodes (`get_info`) and refuses
1128
1147
  * the start if none answers on the swap's network. */
1129
1148
  restoreHeight?: number;
@@ -1260,6 +1279,11 @@ export interface SweepApi {
1260
1279
  destination(request: SweepDestinationRequest): Promise<SweepDestination>;
1261
1280
  /** What is known about the withdrawal from the recovery file: the address, the restore height, what we can dispose of. */
1262
1281
  plan(request: SweepPlanRequest): Promise<SweepPlan>;
1282
+ /**
1283
+ * Build the same withdrawal material from the 12 words and an order found by `findSwaps`, without a recovery file.
1284
+ * The checks are the same: the side, the person's points against the key, the joint address.
1285
+ */
1286
+ fromOrder(request: SweepFromOrderRequest): Promise<SweepFromOrderResult>;
1263
1287
  /**
1264
1288
  * RESTORE THE WALLET FROM A HEIGHT AND REPORT WHAT IT HOLDS. Sets the restore height a little below the one given,
1265
1289
  * rescans, syncs and reads the total and the spendable balance. Coins that exist but are locked get ONE more pass
@@ -1307,8 +1331,9 @@ export interface SweepRestoreRequest {
1307
1331
  readonly rescanWhenLocked?: boolean;
1308
1332
  /**
1309
1333
  * THE BLOCK WINDOW AROUND THE LOCK. When present, the wallet is set at the node tip and only these blocks are
1310
- * scanned. An empty window falls back to the full scan unless `fallback` is false. `txHashes` may be supplied by
1311
- * the caller; otherwise the SDK reads `get_block` from `nodeUrl`.
1334
+ * scanned. An empty window falls back to the full scan unless `fallback` is false. A window read that fails
1335
+ * (429, timeout, 5xx) is retried; if it still fails, the full scan runs and `fallbackWhy` names the read.
1336
+ * `txHashes` may be supplied by the caller; otherwise the SDK reads `get_block` from `nodeUrl`.
1312
1337
  */
1313
1338
  readonly window?: {
1314
1339
  readonly nodeUrl?: string;
@@ -1336,6 +1361,18 @@ export interface SweepRestoreResult {
1336
1361
  /** Present when a window was requested: whether it was used, and why it was skipped. */
1337
1362
  readonly window?: {
1338
1363
  readonly used: boolean;
1364
+ /** Whether the full scan ran because the window found nothing, could not scan, or could not be read. */
1365
+ readonly fallback: boolean;
1366
+ /** Why the fallback ran: `empty-window`, `window-read`, or a named skip. */
1367
+ readonly fallbackWhy?: string;
1368
+ /** The node's answer when the window read failed; absent when the window was read. */
1369
+ readonly error?: {
1370
+ readonly code: string;
1371
+ readonly surface: string;
1372
+ readonly status: number | null;
1373
+ readonly height: number | null;
1374
+ readonly why: string | null;
1375
+ };
1339
1376
  readonly skipped: string | null;
1340
1377
  readonly fromHeight: number;
1341
1378
  readonly toHeight: number;
@@ -1375,6 +1412,11 @@ export interface SweepWallet {
1375
1412
  chainId(): string;
1376
1413
  unlockedBalance(): Promise<bigint | number | string>;
1377
1414
  sync(): Promise<void>;
1415
+ /** The withdrawal role: no-op when this wallet is already open on the plan's address. */
1416
+ open(plan: SweepPlan): Promise<void>;
1417
+ sweepUnlocked(request: { readonly address: string; readonly relay: boolean }): Promise<unknown>;
1418
+ relay(signed: unknown): Promise<readonly string[]>;
1419
+ fee?(signed: unknown): Promise<string | null>;
1378
1420
  close(): Promise<void>;
1379
1421
  driver(): unknown;
1380
1422
  }
@@ -1389,6 +1431,43 @@ export interface SweepReportResult {
1389
1431
  readonly reported: boolean;
1390
1432
  }
1391
1433
 
1434
+ export interface SweepFromOrderRequest {
1435
+ /** The 12 words of the recovery key. */
1436
+ readonly mnemonic: string;
1437
+ readonly chainId: number;
1438
+ /** The settlement network slug, e.g. `arbitrum-sepolia`. */
1439
+ readonly network: string;
1440
+ /** The Monero network the joint address belongs to, e.g. `stagenet`. */
1441
+ readonly moneroNetwork: string;
1442
+ /** One entry from `findSwaps`. */
1443
+ readonly order: {
1444
+ readonly escrow: string;
1445
+ readonly locker: string;
1446
+ readonly claimer: string;
1447
+ readonly edPointLocker: string;
1448
+ readonly edPointClaimer: string;
1449
+ readonly edViewPointLocker: string;
1450
+ readonly index: number;
1451
+ readonly side: "locker" | "claimer";
1452
+ readonly startHeight?: number | null;
1453
+ readonly blockTime?: number | null;
1454
+ readonly readyBy?: number;
1455
+ readonly t1?: number;
1456
+ readonly [field: string]: unknown;
1457
+ };
1458
+ /** Optional encryption boundary for the generated file; empty means an unencrypted file. */
1459
+ readonly passphrase?: string;
1460
+ }
1461
+
1462
+ export interface SweepFromOrderResult {
1463
+ /** The payload `buildPayload` produced; the same fields a recovery file carries. */
1464
+ readonly payload: unknown;
1465
+ /** The same file `plan`/`keys` accept, so a caller can keep using the file-based API. */
1466
+ readonly contents: string;
1467
+ /** The joint Monero address built from the key and the order. */
1468
+ readonly address: string;
1469
+ }
1470
+
1392
1471
  export interface SweepPlanRequest {
1393
1472
  readonly file: { readonly contents: string } | string;
1394
1473
  readonly passphrase: string;
@@ -1429,8 +1508,10 @@ export type SweepMissing = "restore-height" | "spendable" | "revealed-half";
1429
1508
  export interface SweepRunRequest extends SweepPlanRequest {
1430
1509
  /** Where to withdraw the XMR to. */
1431
1510
  readonly to: string;
1511
+ /** An already opened and synced adapter, the same one `restore` used. */
1512
+ readonly wallet?: MoneroWalletAdapter;
1432
1513
  /** Returns a Monero wallet: the core does not drag in WASM. */
1433
- readonly connectWallet: (plan: SweepPlan) => Promise<MoneroWalletAdapter>;
1514
+ readonly connectWallet?: (plan: SweepPlan) => Promise<MoneroWalletAdapter>;
1434
1515
  }
1435
1516
 
1436
1517
  /**
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.1",
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,12 +325,24 @@ 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
331
343
  * not pass the readers; `startHeightReason` then names why. */
332
344
  readonly startHeight: number | null;
333
- readonly startHeightReason: "no-block-number" | "no-height-readers" | "no-block-time" | "monero-header" | null;
345
+ readonly startHeightReason: "no-block-number" | "no-height-readers" | "monero-nodes-unreachable" | "monero-network-mismatch" | "no-block-time" | "monero-header" | null;
334
346
  }
335
347
 
336
348
  /** A storage adapter: the same shape the SDK store takes. */
@@ -364,6 +376,17 @@ export declare function saveRecoveryKey(storage: RecoveryKeyStorage, key: Recove
364
376
  export declare function loadRecoveryKey(storage: RecoveryKeyStorage): (RecoveryKey & { nextIndex: number; reserved: Record<string, unknown>; retired: Record<string, unknown> }) | null;
365
377
  /** Reserves a swap number; the caller releases it if the request is abandoned. */
366
378
  export declare function reserveRecoveryIndex(storage: RecoveryKeyStorage, request: { chainId: number; swapId?: string | null }): number;
379
+ /** True when an order already holds this number's commitment on chain. A page that starts swaps without the SDK's
380
+ * start path calls this before using a number; an unreadable log is a refusal, not "free". */
381
+ export declare function recoveryIndexTaken(request: {
382
+ mnemonic: string;
383
+ chainId: number;
384
+ index: number;
385
+ factory: string;
386
+ evmLogs: (request: { address: string; topics: (string | null)[]; fromBlock: number | string; toBlock: number | string }) => Promise<unknown[]>;
387
+ fromBlock?: number | string;
388
+ toBlock?: number | string;
389
+ }): Promise<boolean>;
367
390
  /** Releases a number that never reached the chain. */
368
391
  export declare function releaseRecoveryIndex(storage: RecoveryKeyStorage, index: number): void;
369
392
  /** Retires a number after its order exists on chain. */
@@ -376,11 +399,15 @@ export declare function findSwaps(request: {
376
399
  address?: string | null;
377
400
  evmLogs: (request: { address: string; topics: (string | null)[]; fromBlock: number | string; toBlock: number | string }) => Promise<unknown[]>;
378
401
  fromBlock?: number | string;
402
+ /** The factory's deployment block from the chain registry; used when `fromBlock` is omitted. */
403
+ deploymentBlock?: number | null;
379
404
  toBlock?: number | string;
380
405
  gap?: number;
381
406
  maxIndex?: number;
382
407
  /** Reads the settlement block timestamp, in seconds: (blockNumber) -> seconds. */
383
408
  evmBlockTime?: (blockNumber: number) => Promise<number>;
409
+ /** Reads contract code: (address) -> 0x-hex. Used to read the XMR amount from an order clone. */
410
+ evmCode?: (address: string) => Promise<string>;
384
411
  /** The Monero tip at the time of the search: () -> height. */
385
412
  moneroTip?: () => Promise<number>;
386
413
  /** A Monero block header timestamp, in seconds: (height) -> seconds. */
@@ -41,6 +41,7 @@ export {
41
41
  loadRecoveryKey,
42
42
  openRecoveryKeyFile,
43
43
  recoveryBinding,
44
+ recoveryIndexTaken,
44
45
  recoveryKeyFileName,
45
46
  recoveryKeyText,
46
47
  releaseRecoveryIndex,
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);