@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.
- package/CHANGELOG.md +30 -0
- package/README.md +18 -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 +74 -2
- package/package.json +4 -2
- package/src/actions.mjs +15 -0
- 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 +16 -0
- package/src/quotes.mjs +100 -3
- package/src/recovery-key.mjs +101 -13
- package/src/swaps.mjs +17 -4
- package/src/sweep-half.mjs +14 -0
- package/src/sweep-restore.mjs +4 -1
- 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 {
|
|
@@ -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
|
|
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.
|
|
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).
|
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,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);
|