@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.
- package/CHANGELOG.md +33 -0
- package/README.md +28 -0
- package/assets/vendors/atomic/atomic-browser.js +24 -0
- package/assets/vendors/monero/monero-js-browser.js +58 -0
- package/assets/vendors/monero-js/LICENSE +9 -0
- package/assets/vendors/sdk/sdk-browser.js +14 -0
- package/assets/vendors/walletconnect/wc-browser.js +6202 -0
- package/engine/atomic/order-worker.js +129 -0
- package/engine/core/config.js +3 -1
- package/engine/sdk/bridge.js +3 -3
- package/index.d.ts +85 -4
- package/package.json +4 -2
- package/src/adapters/monero-wallet.mjs +26 -0
- package/src/config.mjs +9 -0
- package/src/index.mjs +2 -0
- package/src/public/networks.d.ts +2 -0
- package/src/public/recovery.d.ts +28 -1
- package/src/public/recovery.mjs +1 -0
- package/src/quotes.mjs +67 -1
- package/src/recovery-key.mjs +124 -23
- package/src/swaps.mjs +26 -4
- package/src/sweep-half.mjs +14 -0
- package/src/sweep-restore.mjs +78 -15
- package/src/sweep.mjs +85 -5
- package/src/version.mjs +1 -1
|
@@ -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
|
+
};
|
package/engine/core/config.js
CHANGED
|
@@ -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
|
-
|
|
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" },
|
package/engine/sdk/bridge.js
CHANGED
|
@@ -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
|
|
70
|
-
//
|
|
71
|
-
const SDK_BUNDLE = "
|
|
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.
|
|
1311
|
-
*
|
|
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
|
|
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.
|
|
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).
|
package/src/public/networks.d.ts
CHANGED
|
@@ -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
|
|
package/src/public/recovery.d.ts
CHANGED
|
@@ -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. */
|
package/src/public/recovery.mjs
CHANGED
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);
|