@ninsei-labs/sdk 0.49.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 {
@@ -507,6 +523,8 @@ export interface QuoteOffer {
507
523
  readonly currency: string | null;
508
524
  readonly rate: number;
509
525
  readonly rateQuote: number;
526
+ /** The claim tip in native-token wei; a sale is ranked by the payout AFTER it. */
527
+ readonly claimTip: string;
510
528
  readonly min: number | null;
511
529
  readonly max: number | null;
512
530
  readonly step: number | null;
@@ -1258,6 +1276,11 @@ export interface SweepApi {
1258
1276
  destination(request: SweepDestinationRequest): Promise<SweepDestination>;
1259
1277
  /** What is known about the withdrawal from the recovery file: the address, the restore height, what we can dispose of. */
1260
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>;
1261
1284
  /**
1262
1285
  * RESTORE THE WALLET FROM A HEIGHT AND REPORT WHAT IT HOLDS. Sets the restore height a little below the one given,
1263
1286
  * rescans, syncs and reads the total and the spendable balance. Coins that exist but are locked get ONE more pass
@@ -1334,6 +1357,8 @@ export interface SweepRestoreResult {
1334
1357
  /** Present when a window was requested: whether it was used, and why it was skipped. */
1335
1358
  readonly window?: {
1336
1359
  readonly used: boolean;
1360
+ /** Whether the full scan ran because the window found nothing (or could not scan). */
1361
+ readonly fallback: boolean;
1337
1362
  readonly skipped: string | null;
1338
1363
  readonly fromHeight: number;
1339
1364
  readonly toHeight: number;
@@ -1373,6 +1398,11 @@ export interface SweepWallet {
1373
1398
  chainId(): string;
1374
1399
  unlockedBalance(): Promise<bigint | number | string>;
1375
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>;
1376
1406
  close(): Promise<void>;
1377
1407
  driver(): unknown;
1378
1408
  }
@@ -1387,6 +1417,43 @@ export interface SweepReportResult {
1387
1417
  readonly reported: boolean;
1388
1418
  }
1389
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
+
1390
1457
  export interface SweepPlanRequest {
1391
1458
  readonly file: { readonly contents: string } | string;
1392
1459
  readonly passphrase: string;
@@ -1427,8 +1494,10 @@ export type SweepMissing = "restore-height" | "spendable" | "revealed-half";
1427
1494
  export interface SweepRunRequest extends SweepPlanRequest {
1428
1495
  /** Where to withdraw the XMR to. */
1429
1496
  readonly to: string;
1497
+ /** An already opened and synced adapter, the same one `restore` used. */
1498
+ readonly wallet?: MoneroWalletAdapter;
1430
1499
  /** Returns a Monero wallet: the core does not drag in WASM. */
1431
- readonly connectWallet: (plan: SweepPlan) => Promise<MoneroWalletAdapter>;
1500
+ readonly connectWallet?: (plan: SweepPlan) => Promise<MoneroWalletAdapter>;
1432
1501
  }
1433
1502
 
1434
1503
  /**
@@ -1597,6 +1666,9 @@ export interface ActionsApi {
1597
1666
  export interface ActionsRequest {
1598
1667
  readonly escrow: string;
1599
1668
  readonly expect: Readonly<Record<string, unknown>>;
1669
+ /** The claimer whose half is revealed by `claim`; absent - `expect.claimer` is used. A claim without one is
1670
+ * refused: the package sends it only from the claimer's own wallet, never from a third-party sender. */
1671
+ readonly claimer?: string;
1600
1672
  readonly onStep?: (stage: string, detail?: string) => void;
1601
1673
  }
1602
1674
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ninsei-labs/sdk",
3
- "version": "0.49.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",
package/src/actions.mjs CHANGED
@@ -25,6 +25,18 @@ const walletOrFail = (wallet) => {
25
25
  return wallet;
26
26
  };
27
27
 
28
+ // THE CLAIM'S HALF IS REVEALED BY WHOEVER SENDS IT. The package sends a claim only from the claimer's own wallet:
29
+ // a third-party sender would see the half before the claim is mined, and the only reason to use one is the tip -
30
+ // not a reason to hand the half to another party. A caller with no claimer is refused, not sent blind.
31
+ export function claimSenderVerdict({ sender = null, claimer = null } = {}) {
32
+ const from = typeof sender === "string" ? sender.trim().toLowerCase() : "";
33
+ const to = typeof claimer === "string" ? claimer.trim().toLowerCase() : "";
34
+ if (!to) return { ok: false, code: "bad-input", field: "claimer" };
35
+ if (!from) return { ok: false, code: "bad-input", field: "wallet.address" };
36
+ if (from !== to) return { ok: false, code: "bad-input", field: "wallet", why: "not-the-claimer" };
37
+ return { ok: true };
38
+ }
39
+
28
40
  const requestOrFail = (request, extra) => {
29
41
  if (!request || typeof request !== "object") throw new SdkError("bad-input", { field: "request" });
30
42
  const missing = missingFields(request, ["escrow", "expect", ...extra]);
@@ -59,6 +71,9 @@ export function createActions({ call = null } = {}) {
59
71
  requestOrFail(request, ["halfClaimer"]);
60
72
  walletOrFail(wallet);
61
73
  const { escrow, halfClaimer, expect, onStep } = request;
74
+ const sender = typeof wallet.address === "function" ? await wallet.address() : null;
75
+ const verdict = claimSenderVerdict({ sender, claimer: request.claimer || (expect && expect.claimer) });
76
+ if (!verdict.ok) throw new SdkError(verdict.code, { field: verdict.field, why: verdict.why, surface: "actions.claim" });
62
77
  return await withCodes("claim", () => flow.claimOrder({ escrow, halfClaimer, expect, onStep, deps: deps(wallet) }));
63
78
  },
64
79
  /** Refunding: it also reveals a half. The core passes the expectations ALWAYS, though the engine does not require them. */
@@ -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
@@ -83,6 +83,13 @@ export function readQuote(pair, raw, owner = {}, data = {}) {
83
83
  currencyNetwork: q.currencyNetwork ? String(q.currencyNetwork) : null,
84
84
  rateQuote,
85
85
  rate,
86
+ // THE CLAIM TIP IS IN THE MAKER'S NATIVE TOKEN (wei). A sale is ranked by what the seller receives AFTER it;
87
+ // the value is kept as a string, because a wei amount does not fit a float reliably.
88
+ claimTip: (() => {
89
+ if (q.claimTip === undefined || q.claimTip === null || q.claimTip === "") return "0";
90
+ try { const n = BigInt(q.claimTip); return n < 0n ? "0" : n.toString(); }
91
+ catch { return "0"; }
92
+ })(),
86
93
  min: num(q.min),
87
94
  max: num(q.max),
88
95
  step: num(q.step),
@@ -104,6 +111,27 @@ export function readQuote(pair, raw, owner = {}, data = {}) {
104
111
  };
105
112
  }
106
113
 
114
+ /**
115
+ * THE BEST OFFER IS THE ONE THAT PAYS THE MOST, NOT THE ONE WITH THE HIGHEST RATE. On a sale the maker may move
116
+ * its margin into `claimTip`: the escrow pays that tip out of the seller's payout, so a higher rate with a larger
117
+ * tip can leave the seller with less. With no amount there is nothing to compare beyond the rate, and a buy has
118
+ * no tip. Native-token wei are converted at 18 decimals: a sale settles in the chain's native coin.
119
+ */
120
+ export function rankOffers(offers = [], { direction = "buy", amount = null } = {}) {
121
+ const list = Array.isArray(offers) ? offers.filter((offer) => offer && Number(offer.rate) > 0) : [];
122
+ const amountXmr = Number(amount);
123
+ const score = (offer) => {
124
+ const rate = Number(offer.rate);
125
+ if (direction !== "sell" || !(amountXmr > 0)) return rate;
126
+ let tip = 0;
127
+ try { tip = Number(BigInt(offer.claimTip || "0")) / 1e18; } catch { tip = 0; }
128
+ return amountXmr * rate - tip;
129
+ };
130
+ const ranked = list.map((offer) => ({ offer, net: score(offer) }))
131
+ .sort((a, b) => (b.net - a.net) || (Number(b.offer.rate) - Number(a.offer.rate)));
132
+ return { best: ranked.length ? ranked[0].offer : null, net: ranked.length ? ranked[0].net : null, ranked };
133
+ }
134
+
107
135
  /**
108
136
  * Parsing the book in one place. The envelope is accepted in three forms (a map by pairs, a list of rows, a list
109
137
  * of providers) and is brought to a single list: the screen must not know which form the answer came in.
@@ -201,7 +229,7 @@ const compactOffer = (o) => ({
201
229
  assetNetwork: o.assetNetwork, currencyNetwork: o.currencyNetwork, networkStated: o.networkStated,
202
230
  networksDerived: o.networksDerived === true, networksUnnamed: o.networksUnnamed === true,
203
231
  // THE REASON (in the provider's words), THE PACKAGE'S SIGNATURE AND KEY, THE AGE BY ITS OWN MARK.
204
- why: o.why, signature: o.signature, keyId: o.keyId, ageMs: o.ageMs,
232
+ claimTip: o.claimTip, why: o.why, signature: o.signature, keyId: o.keyId, ageMs: o.ageMs,
205
233
  networks: { asset: o.assetNetwork, currency: o.currencyNetwork, stated: o.networkStated },
206
234
  });
207
235
 
@@ -267,8 +295,11 @@ export function createQuotes({ config, http, now = () => Date.now(), evmCall = n
267
295
  const asked = explicit ? Number(request.size) : size;
268
296
  const data = await http.json("/rate?amount=" + encodeURIComponent(asked) + "&side=" + encodeURIComponent(request.direction));
269
297
  const book = readBook(data, now());
270
- const offer = book.best;
271
298
  const amount = Number.isFinite(Number(request.amount)) ? Number(request.amount) : null;
299
+ // A SALE IS RANKED BY THE PAYOUT AFTER THE TIP; a buy has no tip. Without an amount the rate is the only
300
+ // comparable number, and the service's own best remains the fallback.
301
+ const ranked = rankOffers(book.offers, { direction: request.direction, amount });
302
+ const offer = ranked.best || book.best;
272
303
  // The size derived from the person's amount becomes the size of the NEXT request: the price does not depend
273
304
  // on volume, and the volume is needed by the provider to say whether it takes such a size. With a named size
274
305
  // there is nothing to derive - it stays what it was asked as.
@@ -362,6 +393,58 @@ export function createQuotes({ config, http, now = () => Date.now(), evmCall = n
362
393
  //
363
394
  // The path and the body are the same as the engine's (`requestOrderQuote` in www/js/core/rfqSource.js): POST to
364
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
+ },
365
448
  async firm(request) {
366
449
  if (!request || typeof request !== "object") fail("bad-input", { field: "request" });
367
450
  if (typeof request.providerId !== "string" || !request.providerId) fail("bad-input", { field: "providerId" });
@@ -374,6 +457,8 @@ export function createQuotes({ config, http, now = () => Date.now(), evmCall = n
374
457
  fail("address-denied", { step: "order-quote" });
375
458
  }
376
459
  let res = null;
460
+ const viewHalfClaimer = request.viewHalfClaimer === undefined || request.viewHalfClaimer === null || request.viewHalfClaimer === ""
461
+ ? null : String(request.viewHalfClaimer);
377
462
  try {
378
463
  // THE ANSWER IS READ AS A VALUE (tryJson), NOT AS AN EXCEPTION: "did not answer" and "answered and
379
464
  // refused" must stay different, and a refusal's own body carries the reason. `json` throws
@@ -381,7 +466,8 @@ export function createQuotes({ config, http, now = () => Date.now(), evmCall = n
381
466
  res = await http.tryJson(config.route("orderQuote"), {
382
467
  method: "POST",
383
468
  headers: { "content-type": "application/json" },
384
- body: JSON.stringify({ providerId: request.providerId, order: request.order }),
469
+ body: JSON.stringify({ providerId: request.providerId, order: request.order,
470
+ ...(viewHalfClaimer === null ? {} : { viewHalfClaimer }) }),
385
471
  });
386
472
  } catch (error) {
387
473
  if (error && error.code === "bad-input") throw error;
@@ -397,6 +483,17 @@ export function createQuotes({ config, http, now = () => Date.now(), evmCall = n
397
483
  }
398
484
  const body = res.body;
399
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
+ }
400
497
  // A MISSING FIELD OF THE SIGNED SET IS NOT "PROBABLY ZERO": the digest cannot be built, so the quote is
401
498
  // not verifiable and is refused (its own code, so the interface can say which field is unbound).
402
499
  const missingFields = orderQuoteMissingFields(quote);