@ninsei-labs/sdk 0.39.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 +866 -0
- package/LICENSE +21 -0
- package/README.md +187 -0
- package/engine/atomic/dleq2.js +330 -0
- package/engine/atomic/halfenc.js +205 -0
- package/engine/atomic/halves.js +119 -0
- package/engine/atomic/order-client.js +57 -0
- package/engine/atomic/order.js +132 -0
- package/engine/atomic/orderContext.js +70 -0
- package/engine/atomic/orderHalfCommit.js +29 -0
- package/engine/core/chainSource.js +306 -0
- package/engine/core/clock.js +40 -0
- package/engine/core/config.js +885 -0
- package/engine/core/finality.js +93 -0
- package/engine/core/format.js +168 -0
- package/engine/core/preSignGuards.js +495 -0
- package/engine/core/rfqSource.js +193 -0
- package/engine/core/sellWindow.js +43 -0
- package/engine/core/store.js +100 -0
- package/engine/core/swap.js +1153 -0
- package/engine/evm/amounts.js +131 -0
- package/engine/evm/auth.js +156 -0
- package/engine/evm/claimGas.js +87 -0
- package/engine/evm/depositor.js +127 -0
- package/engine/evm/dex.js +836 -0
- package/engine/evm/escrow.js +208 -0
- package/engine/evm/factory.js +149 -0
- package/engine/evm/fees.js +90 -0
- package/engine/evm/funding.js +309 -0
- package/engine/evm/gasReserve.js +88 -0
- package/engine/evm/index.js +123 -0
- package/engine/evm/injected.js +175 -0
- package/engine/evm/permit.js +125 -0
- package/engine/evm/prices.js +179 -0
- package/engine/evm/session.js +777 -0
- package/engine/mock/chain.js +98 -0
- package/engine/monero/address.js +265 -0
- package/engine/monero/halvesSweep.js +68 -0
- package/engine/monero/node.js +110 -0
- package/engine/monero/scan.js +325 -0
- package/engine/monero/swapKeys.js +69 -0
- package/engine/monero/unlock.js +46 -0
- package/engine/monero/wallet.js +168 -0
- package/engine/recovery/recoveryFile.js +363 -0
- package/engine/recovery/restore.js +176 -0
- package/engine/sdk/bridge.js +609 -0
- package/engine/ui/dom.js +90 -0
- package/engine/ui/walletModal.js +132 -0
- package/index.d.ts +1417 -0
- package/package.json +74 -0
- package/src/actions.mjs +72 -0
- package/src/adapters/evm-wallet.mjs +126 -0
- package/src/adapters/index.mjs +21 -0
- package/src/adapters/local-storage.mjs +32 -0
- package/src/adapters/monero-wallet.mjs +185 -0
- package/src/adapters/monero.mjs +51 -0
- package/src/adapters/node-test-evm-wallet.mjs +345 -0
- package/src/codes.mjs +188 -0
- package/src/config.mjs +277 -0
- package/src/engine.mjs +53 -0
- package/src/errors.mjs +24 -0
- package/src/http.mjs +72 -0
- package/src/index.mjs +256 -0
- package/src/legs/evm.mjs +118 -0
- package/src/legs/index.mjs +50 -0
- package/src/levelsWalkSpec.mjs +183 -0
- package/src/lock.mjs +140 -0
- package/src/order.mjs +212 -0
- package/src/preflight.mjs +137 -0
- package/src/primitives.mjs +47 -0
- package/src/quoteEip712.mjs +35 -0
- package/src/quoteEip712Spec.mjs +214 -0
- package/src/quotes.mjs +465 -0
- package/src/record.mjs +65 -0
- package/src/recovery.mjs +40 -0
- package/src/sim.mjs +37 -0
- package/src/swap-flow.mjs +492 -0
- package/src/swaps.mjs +935 -0
- package/src/sweep.mjs +222 -0
- package/src/wallet.mjs +164 -0
- package/src/xmr-arrival.mjs +289 -0
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
// GENERATED FILE - a byte-for-byte copy of the engine module www/js/evm/amounts.js for the SDK package.
|
|
2
|
+
// Edit the source under www/js, then run: node tools/build-sdk-engine.mjs
|
|
3
|
+
// tools/check-sdk-engine.mjs reddens on any drift, so a stale copy cannot ship silently.
|
|
4
|
+
// Everything below this header is identical to the source.
|
|
5
|
+
|
|
6
|
+
// Пересчёт балансов из «сырых» единиц контракта в человекочитаемые.
|
|
7
|
+
//
|
|
8
|
+
// Отдельным модулем, потому что это денежная математика и её надо проверять без браузера:
|
|
9
|
+
// правильность decimals - самая частая ошибка EVM-кошельков (у USDT/USDC на BSC их 18, а не 6),
|
|
10
|
+
// а цена ошибки - баланс, отличающийся на 10^12. Проверка живёт в tools/validate.mjs (npm test),
|
|
11
|
+
// поэтому она гоняется и в CI, и локально.
|
|
12
|
+
|
|
13
|
+
// BigInt (сырые единицы) -> число с точкой.
|
|
14
|
+
//
|
|
15
|
+
// Делим как Number, а не целочисленно: вариант "raw * 1e6 / 10^dec" съедал мелкие значения -
|
|
16
|
+
// 55000 wei при 18 decimals превращались ровно в 0, и демка показывала пустой баланс там, где
|
|
17
|
+
// деньги есть (поймано проверкой на конкретных числах). Точности double достаточно: 15-16
|
|
18
|
+
// значащих цифр против 6 знаков после точки, которые показывает интерфейс.
|
|
19
|
+
export function toHuman(raw, decimals) {
|
|
20
|
+
return Number(raw) / 10 ** decimals;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// Убирает хвостовые нули у строки с точкой: "12.180000" -> "12.18".
|
|
24
|
+
// Точка обязательна: у "1000" хвостовые нули трогать нельзя.
|
|
25
|
+
export function trimTrailingZeros(text) {
|
|
26
|
+
const s = String(text);
|
|
27
|
+
return s.includes(".") ? s.replace(/0+$/, "").replace(/\.$/, "") : s;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// Сумма для показа и для кнопки MAX: усечение (не округление) и не больше 6 знаков после точки.
|
|
31
|
+
//
|
|
32
|
+
// Почему усечение: MAX не имеет права подставить сумму больше реального баланса - иначе форма
|
|
33
|
+
// справедливо ответит "Not enough ...", и кнопка MAX приведёт в тупик. Округление вверх как раз
|
|
34
|
+
// это и делало: 0.0399187 -> 0.039919 > баланса.
|
|
35
|
+
// Почему 6 знаков: человеческая точность, и ровно столько же показывает подсказка с балансом.
|
|
36
|
+
// Возвращает ЧИСЛО (для сравнений); строку для поля ввода даёт amountInputValue ниже.
|
|
37
|
+
export function floorAmount(value, decimals = 6) {
|
|
38
|
+
const n = Number(value);
|
|
39
|
+
if (!Number.isFinite(n) || n <= 0) return 0;
|
|
40
|
+
const scaled = Math.floor(n * 10 ** decimals);
|
|
41
|
+
if (scaled > 0) return scaled / 10 ** decimals;
|
|
42
|
+
// Меньше 10^-decimals: это пыль. Отдаём значение целиком (точность до 18 знаков), чтобы
|
|
43
|
+
// MAX не подставил 0.0000001 вместо 0.000000123 - то есть не отбросил баланс почти целиком.
|
|
44
|
+
return Math.floor(n * 1e18) / 1e18;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// Строка для поля ввода и для подсказки с балансом: одна и та же функция, поэтому MAX
|
|
48
|
+
// подставляет ровно то число, которое видит пользователь.
|
|
49
|
+
//
|
|
50
|
+
// toFixed, а не String(): String(1.23e-7) даёт "1e-7", а поле ввода принимает только цифры
|
|
51
|
+
// и точку - "1e-7" превратилось бы в "17" (это поймано проверкой, а не пользователем).
|
|
52
|
+
export function amountInputValue(value, decimals = 6) {
|
|
53
|
+
// Сначала усекаем, потом форматируем: toFixed САМ округляет, и без усечения получалось
|
|
54
|
+
// 0.039919 - то есть больше баланса, и MAX упирался в "Not enough ...".
|
|
55
|
+
const cut = floorAmount(value, decimals);
|
|
56
|
+
if (!Number.isFinite(cut) || cut <= 0) return "0";
|
|
57
|
+
// Для пыли печатаем все знаки: иначе toFixed округлил бы до первого ненулевого разряда.
|
|
58
|
+
const d = cut >= 10 ** -decimals ? decimals : 18;
|
|
59
|
+
return trimTrailingZeros(cut.toFixed(d));
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// Набор случаев для проверки: [сырое значение, decimals, ожидаемое].
|
|
63
|
+
// 6 decimals - USDT/USDC на Ethereum, Arbitrum, Base; 18 - на BNB Chain и у нативных токенов.
|
|
64
|
+
// Ожидания сверены арифметикой вручную: 1 единица при 18 decimals это 1e-18, поэтому
|
|
65
|
+
// 55000 wei - это 5.5e-14, а не 0.000055 (последнее - это 5.5e13 wei).
|
|
66
|
+
export const AMOUNT_CASES = [
|
|
67
|
+
[12345678900n, 6, 12345.6789], // USDC, 6 decimals
|
|
68
|
+
[108275582n, 6, 108.275582], // реальный баланс, снятый с Arbitrum
|
|
69
|
+
[10n ** 18n, 18, 1], // 1 ETH
|
|
70
|
+
[1500000000000000000n, 18, 1.5], // 1.5 ETH
|
|
71
|
+
[55000000000000n, 18, 0.000055], // 0.000055 ETH - на экране видно, но старое округление врало
|
|
72
|
+
[55000n, 18, 5.5e-14], // пыль: старая формула давала ровно 0 (тихая потеря)
|
|
73
|
+
[3120000000000000000000n, 18, 3120], // 3120 BNB
|
|
74
|
+
[0n, 18, 0], // пустой кошелёк
|
|
75
|
+
];
|
|
76
|
+
|
|
77
|
+
// Случаи для показа баланса и MAX: [баланс, ожидаемая строка].
|
|
78
|
+
// Первый - ровно то, на что жаловался пользователь: 0.03991877164758065 попадало в поле целиком.
|
|
79
|
+
export const MAX_CASES = [
|
|
80
|
+
[0.03991877164758065, "0.039918"], // тот самый ETH: 6 знаков, как в подсказке
|
|
81
|
+
[0.0399187, "0.039918"], // усечение вниз, а не округление вверх до 0.039919
|
|
82
|
+
[12431.18, "12431.18"], // стейбл: хвостовых нулей нет
|
|
83
|
+
[3.4125, "3.4125"],
|
|
84
|
+
[1, "1"],
|
|
85
|
+
[0.000000123, "0.000000123"], // пыль: ни нуля, ни экспоненты "1e-7"
|
|
86
|
+
[0, "0"],
|
|
87
|
+
];
|
|
88
|
+
|
|
89
|
+
export const TRIM_CASES = [
|
|
90
|
+
["12,431.180000", "12,431.18"],
|
|
91
|
+
["0.039918", "0.039918"],
|
|
92
|
+
["1.000000", "1"],
|
|
93
|
+
["1000", "1000"],
|
|
94
|
+
["0.000000", "0"],
|
|
95
|
+
];
|
|
96
|
+
|
|
97
|
+
// Поле ввода принимает только цифры и точку (см. oninput в swapForm.js). Если строка этому
|
|
98
|
+
// не удовлетворяет, поле молча превратит её в другое число - поэтому проверяем и это.
|
|
99
|
+
const INPUT_PATTERN = /^[0-9]+(\.[0-9]+)?$/;
|
|
100
|
+
|
|
101
|
+
export function checkAmounts({ tolerance = 1e-9 } = {}) {
|
|
102
|
+
const failures = [];
|
|
103
|
+
|
|
104
|
+
for (const [raw, decimals, expected] of AMOUNT_CASES) {
|
|
105
|
+
const got = toHuman(raw, decimals);
|
|
106
|
+
if (!Number.isFinite(got) || Math.abs(got - expected) > tolerance) {
|
|
107
|
+
failures.push(`${raw} при ${decimals} decimals -> ${got}, ожидалось ${expected}`);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
for (const [balance, expected] of MAX_CASES) {
|
|
112
|
+
const got = amountInputValue(balance);
|
|
113
|
+
if (got !== expected) {
|
|
114
|
+
failures.push(`MAX при балансе ${balance} -> "${got}", ожидалось "${expected}"`);
|
|
115
|
+
}
|
|
116
|
+
if (!INPUT_PATTERN.test(got)) {
|
|
117
|
+
failures.push(`MAX при балансе ${balance} дал "${got}" - поле ввода такое значение исказит`);
|
|
118
|
+
}
|
|
119
|
+
// Главное свойство MAX: подставленная сумма НЕ больше баланса.
|
|
120
|
+
if (floorAmount(balance) > balance) {
|
|
121
|
+
failures.push(`MAX при балансе ${balance} подставил больше баланса: ${floorAmount(balance)}`);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
for (const [input, expected] of TRIM_CASES) {
|
|
126
|
+
const got = trimTrailingZeros(input);
|
|
127
|
+
if (got !== expected) failures.push(`показ баланса "${input}" -> "${got}", ожидалось "${expected}"`);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
return failures;
|
|
131
|
+
}
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
// GENERATED FILE - a byte-for-byte copy of the engine module www/js/evm/auth.js for the SDK package.
|
|
2
|
+
// Edit the source under www/js, then run: node tools/build-sdk-engine.mjs
|
|
3
|
+
// tools/check-sdk-engine.mjs reddens on any drift, so a stale copy cannot ship silently.
|
|
4
|
+
// Everything below this header is identical to the source.
|
|
5
|
+
|
|
6
|
+
// ВХОД ПО ПОДПИСИ КОШЕЛЬКА И СПИСОК СДЕЛОК НА СЕРВЕРЕ.
|
|
7
|
+
//
|
|
8
|
+
// Зачем. Пока записи о сделках живут только в localStorage, "мой список" привязан к браузеру: очистили
|
|
9
|
+
// данные сайта, открыли с другого устройства - список пуст, хотя деньги в эскроу на месте. Серверный
|
|
10
|
+
// список снимает это ограничение, а привязка к адресу кошелька не даёт видеть чужое.
|
|
11
|
+
//
|
|
12
|
+
// ПОДПИСЬ, А НЕ ПРОСТО ПОДКЛЮЧЕНИЕ. Подключение кошелька - только канал связи. Доказательство владения
|
|
13
|
+
// адресом даёт лишь подпись сообщения, и подписывать приходится КАЖДЫЙ РАЗ заново: пропуск живёт в
|
|
14
|
+
// sessionStorage, то есть умирает вместе с вкладкой. Так решено сознательно: безопасность важнее удобства.
|
|
15
|
+
//
|
|
16
|
+
// ЧТО УХОДИТ НА СЕРВЕР И ЧТО НЕТ. Уходят только публичные поля: адрес эскроу, суммы, сроки, hashlock,
|
|
17
|
+
// сеть, id сделки. СЕКРЕТ НЕ УХОДИТ НИКОГДА: он даёт право на деньги и остаётся в файле восстановления.
|
|
18
|
+
// Сервер не сможет распорядиться средствами, даже если захочет, - у него нет ни ключей, ни секрета.
|
|
19
|
+
//
|
|
20
|
+
// ОТКАЗ СЕРВЕРА НЕ ЛОМАЕТ СДЕЛКУ. Синхронизация - удобство, а не условие: если она не удалась, сделка
|
|
21
|
+
// продолжается на локальной записи, и об этом честно сказано в консоли. Хуже было бы наоборот.
|
|
22
|
+
import { signMessage, address, isConnected } from "./session.js";
|
|
23
|
+
|
|
24
|
+
const TOKEN_KEY = "arrakis.auth.token";
|
|
25
|
+
const ADDR_KEY = "arrakis.auth.address";
|
|
26
|
+
|
|
27
|
+
// Пропуск и адрес держим в sessionStorage: он переживает перезагрузку страницы, но исчезает вместе с
|
|
28
|
+
// вкладкой - ровно то поведение, которое выбрано ("пока открыт браузер валидно").
|
|
29
|
+
// СЕССИОННОЕ ХРАНИЛИЩЕ - СНАРУЖИ, как и хранилище состояния (www/js/core/store.js). В браузере это
|
|
30
|
+
// sessionStorage, в проверках и в SDK - память: модуль обязан грузиться без DOM.
|
|
31
|
+
const sessionAdapters = () => {
|
|
32
|
+
const store = typeof globalThis !== "undefined" ? globalThis.sessionStorage : undefined;
|
|
33
|
+
if (!store || typeof store.getItem !== "function") return null;
|
|
34
|
+
return {
|
|
35
|
+
get: (key) => store.getItem(key),
|
|
36
|
+
set: (key, value) => store.setItem(key, String(value)),
|
|
37
|
+
remove: (key) => store.removeItem(key),
|
|
38
|
+
};
|
|
39
|
+
};
|
|
40
|
+
const memoryStore = () => {
|
|
41
|
+
const map = new Map();
|
|
42
|
+
return { get: (key) => (map.has(key) ? map.get(key) : null), set: (key, value) => map.set(key, String(value)), remove: (key) => map.delete(key) };
|
|
43
|
+
};
|
|
44
|
+
let sessionStore = sessionAdapters() || memoryStore();
|
|
45
|
+
|
|
46
|
+
/** Подменить сессионное хранилище: годится и sessionStorage, и память, и адаптер SDK. */
|
|
47
|
+
export function setSessionStore(next) {
|
|
48
|
+
const ok = next && typeof next.get === "function" && typeof next.set === "function" && typeof next.remove === "function";
|
|
49
|
+
if (!ok) throw new Error("сессионное хранилище должно уметь get/set/remove");
|
|
50
|
+
sessionStore = next;
|
|
51
|
+
return sessionStore;
|
|
52
|
+
}
|
|
53
|
+
export const sessionStoreInUse = () => sessionStore;
|
|
54
|
+
|
|
55
|
+
function readSession(key) { try { return sessionStore.get(key); } catch { return null; } }
|
|
56
|
+
function writeSession(key, value) { try { value == null ? sessionStore.remove(key) : sessionStore.set(key, value); } catch { /* тишина намеренно: недоступное хранилище не должно ломать вход */ } }
|
|
57
|
+
|
|
58
|
+
export function token() { return readSession(TOKEN_KEY); }
|
|
59
|
+
export function authedAddress() { return readSession(ADDR_KEY); }
|
|
60
|
+
export function forgetSession() { writeSession(TOKEN_KEY, null); writeSession(ADDR_KEY, null); }
|
|
61
|
+
|
|
62
|
+
async function api(path, init = {}) {
|
|
63
|
+
const res = await fetch(path, init);
|
|
64
|
+
let body = null;
|
|
65
|
+
try { body = await res.json(); } catch { /* не JSON - ниже вернём как есть */ }
|
|
66
|
+
return { status: res.status, body };
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// Вход: получаем одноразовое число, подписываем сообщение КОТОРОЕ ДАЛ СЕРВЕР (не своё: иначе расхождение
|
|
70
|
+
// в одном символе давало бы "подпись не сходится" без объяснения) и обмениваем подпись на пропуск.
|
|
71
|
+
export async function signIn() {
|
|
72
|
+
if (!isConnected()) throw new Error("сначала подключите кошелёк");
|
|
73
|
+
const nonce = await api("/api/auth/nonce");
|
|
74
|
+
if (nonce.status !== 200 || !nonce.body || !nonce.body.message) {
|
|
75
|
+
throw new Error("сервер не выдал сообщение для подписи (ответ " + nonce.status + ")");
|
|
76
|
+
}
|
|
77
|
+
const signature = await signMessage(nonce.body.message);
|
|
78
|
+
const verified = await api("/api/auth/verify", {
|
|
79
|
+
method: "POST",
|
|
80
|
+
headers: { "content-type": "application/json" },
|
|
81
|
+
body: JSON.stringify({ nonce: nonce.body.nonce, address: address(), signature }),
|
|
82
|
+
});
|
|
83
|
+
if (verified.status !== 200 || !verified.body || !verified.body.token) {
|
|
84
|
+
const why = verified.body && verified.body.error ? verified.body.error : "ответ " + verified.status;
|
|
85
|
+
throw new Error("вход не принят: " + why);
|
|
86
|
+
}
|
|
87
|
+
writeSession(TOKEN_KEY, verified.body.token);
|
|
88
|
+
writeSession(ADDR_KEY, verified.body.address);
|
|
89
|
+
return verified.body.address;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// Запрос с пропуском. При 401 пропуск мог истечь или сервер перезапуститься - пробуем один раз войти
|
|
93
|
+
// заново, и только если и это не вышло, честно возвращаем отказ: молчаливое повторение подписи
|
|
94
|
+
// раздражало бы кошелёк запросами без причины.
|
|
95
|
+
async function authed(path, init = {}, retry = true) {
|
|
96
|
+
const t = token();
|
|
97
|
+
const headers = { ...(init.headers || {}) };
|
|
98
|
+
if (t) headers.authorization = "Bearer " + t;
|
|
99
|
+
const r = await api(path, { ...init, headers });
|
|
100
|
+
if (r.status === 401 && retry && isConnected()) {
|
|
101
|
+
forgetSession();
|
|
102
|
+
try { await signIn(); } catch (e) { return r; }
|
|
103
|
+
return authed(path, init, false);
|
|
104
|
+
}
|
|
105
|
+
return r;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// Отправить запись о сделке. Возвращает { ok, reason }: отказ не исключение, а факт, о котором вызывающий
|
|
109
|
+
// решает сам - сделка из-за него прерываться не должна.
|
|
110
|
+
export async function saveServerSwap(swap, network) {
|
|
111
|
+
const escrow = swap && swap.escrow && swap.escrow.address;
|
|
112
|
+
if (!escrow) return { ok: false, reason: "в записи нет адреса эскроу" };
|
|
113
|
+
if (!isConnected()) return { ok: false, reason: "кошелёк не подключён" };
|
|
114
|
+
try {
|
|
115
|
+
if (!token() || authedAddress() !== String(address()).toLowerCase()) { forgetSession(); await signIn(); }
|
|
116
|
+
const r = await authed("/api/evm-swaps", {
|
|
117
|
+
method: "POST",
|
|
118
|
+
headers: { "content-type": "application/json" },
|
|
119
|
+
body: JSON.stringify({
|
|
120
|
+
id: swap.id, network: network || swap.network || swap.chain, escrow,
|
|
121
|
+
// ССЫЛКА НА КОТИРОВКУ. По ней сервер и нода провайдера понимают, ЧЕЙ это ордер: без неё адрес эскроу
|
|
122
|
+
// остаётся ноде неизвестным, и забрать ETH она не сможет. Отсутствие поля не ошибка: старые записи
|
|
123
|
+
// и сделки без провайдера отправляются как есть.
|
|
124
|
+
quoteId: swap.orderQuoteId || null,
|
|
125
|
+
providerId: swap.providerId || null,
|
|
126
|
+
hashlock: (swap.escrow && swap.escrow.hashlock) || swap.hashlock || null,
|
|
127
|
+
amount: (swap.escrow && swap.escrow.amountWei) || swap.amountWei || null,
|
|
128
|
+
t0: swap.escrow && swap.escrow.t0 != null ? swap.escrow.t0 : null,
|
|
129
|
+
t1: swap.escrow && swap.escrow.t1 != null ? swap.escrow.t1 : null,
|
|
130
|
+
}),
|
|
131
|
+
});
|
|
132
|
+
if (r.status === 201 || r.status === 200) {
|
|
133
|
+
// СЕРВЕР ОТВЕЧАЕТ И ПРО ПРИВЯЗКУ КОТИРОВКИ. Без неё нода провайдера не заберёт ETH: её половина
|
|
134
|
+
// осталась бы невостребованной. Отказ привязки сделку не прерывает, но и молчать о нём нельзя -
|
|
135
|
+
// поэтому он уезжает вызывающему, а тот говорит в консоль.
|
|
136
|
+
return { ok: true, orderBinding: (r.body && r.body.orderBinding) || null };
|
|
137
|
+
}
|
|
138
|
+
const why = r.body && r.body.error ? r.body.error : "ответ " + r.status;
|
|
139
|
+
return { ok: false, reason: why };
|
|
140
|
+
} catch (e) {
|
|
141
|
+
return { ok: false, reason: String((e && e.message) || e) };
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// Список сделок с сервера. Пустой результат и отказ - разные вещи, и вызывающий видит разницу.
|
|
146
|
+
export async function loadServerSwaps() {
|
|
147
|
+
if (!isConnected()) return { ok: false, reason: "кошелёк не подключён", swaps: [] };
|
|
148
|
+
try {
|
|
149
|
+
if (!token()) await signIn();
|
|
150
|
+
const r = await authed("/api/evm-swaps");
|
|
151
|
+
if (r.status !== 200 || !r.body) return { ok: false, reason: "ответ " + r.status, swaps: [] };
|
|
152
|
+
return { ok: true, swaps: r.body.swaps || [] };
|
|
153
|
+
} catch (e) {
|
|
154
|
+
return { ok: false, reason: String((e && e.message) || e), swaps: [] };
|
|
155
|
+
}
|
|
156
|
+
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
// GENERATED FILE - a byte-for-byte copy of the engine module www/js/evm/claimGas.js for the SDK package.
|
|
2
|
+
// Edit the source under www/js, then run: node tools/build-sdk-engine.mjs
|
|
3
|
+
// tools/check-sdk-engine.mjs reddens on any drift, so a stale copy cannot ship silently.
|
|
4
|
+
// Everything below this header is identical to the source.
|
|
5
|
+
|
|
6
|
+
// ГАЗ ЗАБОРА ПРИ ПРОДАЖЕ XMR (claim): ОДИН ИСТОЧНИК ПРАВДЫ ДЛЯ КЛИЕНТА И ДЛЯ НОДЫ (issue #127).
|
|
7
|
+
//
|
|
8
|
+
// СУТЬ. В обратном обмене человек отдаёт XMR и забирает ETH вызовом `claim` в сети расчёта. Своего ETH у
|
|
9
|
+
// него нет, поэтому газ на claim ПРИСЫЛАЕТ НОДА (issue #78, rfq/reverseGas.mjs). Но правила газа жили в
|
|
10
|
+
// ДВУХ местах: страница/SDK отправляли claim с пределом 250 000 и комиссией `2 x baseFee + 1 gwei`
|
|
11
|
+
// (движок сделки теперь в пакете: sdk/src/swap-flow.mjs; www/js/evm/session.js), а нода считала подарок как `eth_gasPrice x 120 000`
|
|
12
|
+
// (rfq/reverseGas.mjs). Числа расходились примерно в 100 раз, и человек с одним подарком claim отправить
|
|
13
|
+
// не мог: узел принимает транзакцию, только если на балансе есть `gasLimit x maxFeePerGas`, а подарка на
|
|
14
|
+
// это не хватало. Теперь оба числа считает ЭТОТ модуль.
|
|
15
|
+
//
|
|
16
|
+
// ПОЧЕМУ ОТДЕЛЬНЫЙ МОДУЛЬ И ПОЧЕМУ ПОД www/js. Формула обязана быть ОДНА. Её читает и страница/SDK
|
|
17
|
+
// (движок живёт под www/js), и нода - rfq/reverseGas.mjs, а rfq уже импортирует из www/js (см.
|
|
18
|
+
// rfq/payoutFee.mjs, `../www/js/core/format.js`). Обратный импорт невозможен: боевой веб-корень - www/,
|
|
19
|
+
// каталог rfq/ браузеру не отдаётся. Поэтому единственный общий дом - www/js/evm/.
|
|
20
|
+
//
|
|
21
|
+
// ЧТО ЗДЕСЬ ОДНО. Предел газа claim, правило комиссии (2 x baseFee + чаевые) и РАЗМЕР ПОДАРКА - та же
|
|
22
|
+
// стоимость claim плюс ЯВНЫЙ ЗАПАС на рост базовой цены между выдачей билета и забором. Второго расчёта
|
|
23
|
+
// размера подарка нет ни у ноды, ни у прогонов: они зовут claimGasGiftWei отсюда.
|
|
24
|
+
|
|
25
|
+
// ПРЕДЕЛ ГАЗА CLAIM - ОДНО ЧИСЛО НА ОБЕИХ СТОРОНАХ. Это тот же предел, с которым нода подписывает свой
|
|
26
|
+
// claim в rfq/payout.mjs, и тот же, что отправляет страница/SDK (sdk/src/swap-flow.mjs). Число не
|
|
27
|
+
// выдумано: живой прогон меряет фактический gasUsed забора сверху (tools/live-gas-run.mjs).
|
|
28
|
+
export const CLAIM_GAS_LIMIT = 120_000n;
|
|
29
|
+
|
|
30
|
+
// ПОЛ ЧАЕВЫХ - ПОЛИТИКА СЕТИ, А НЕ КОНСТАНТА В КОДЕ ОТПРАВКИ. Раньше session.js ставил минимум 1 gwei
|
|
31
|
+
// ВСЕГДА. На сети с дешёвым газом (Arbitrum One: baseFee ~0.02 gwei) это раздувало maxFeePerGas примерно
|
|
32
|
+
// в 50 раз и поднимало ТРЕБУЕМЫЙ БАЛАНС пропорционально - то есть ломало ровно того, кому газ и
|
|
33
|
+
// предназначен. Для Arbitrum чаевые не нужны: пол 0. Сеть со своей политикой передаёт minTipWei
|
|
34
|
+
// аргументом (session.js берёт сеть из реестра, www/js/core/config.js).
|
|
35
|
+
export const CLAIM_TIP_FLOOR_WEI = 0n;
|
|
36
|
+
|
|
37
|
+
const toBig = (v) => {
|
|
38
|
+
if (v === null || v === undefined || v === "") return null;
|
|
39
|
+
try { return BigInt(typeof v === "bigint" ? v : String(v)); } catch { return null; }
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
// КОМИССИЯ ЗАБОРА (EIP-1559): `maxFeePerGas = 2 x baseFee + чаевые`. Удвоение базовой цены перекрывает её
|
|
43
|
+
// рост за время подтверждения СВОЕЙ транзакции; чаевые - это разница gasPrice - baseFee (её отдаёт любая
|
|
44
|
+
// цепь), не ниже пола сети. baseFee = 0 (сеть без EIP-1559) - полей комиссии нет вовсе, и это null, а не
|
|
45
|
+
// ноль: неизвестное обязано называться, а не выдаваться за измеренное.
|
|
46
|
+
export function claimMaxFeePerGasWei({ baseFeeWei, priorityFeeWei = 0n, minTipWei = CLAIM_TIP_FLOOR_WEI } = {}) {
|
|
47
|
+
const base = toBig(baseFeeWei);
|
|
48
|
+
if (base === null || base <= 0n) return null;
|
|
49
|
+
const prio = toBig(priorityFeeWei);
|
|
50
|
+
const floor = toBig(minTipWei);
|
|
51
|
+
const floored = floor === null || floor < 0n ? 0n : floor;
|
|
52
|
+
const tip = prio !== null && prio > floored ? prio : floored;
|
|
53
|
+
return base * 2n + tip;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// СКОЛЬКО НУЖНО НА БАЛАНСЕ ДЛЯ CLAIM - это же правило приёма транзакции узлом: `gasLimit x maxFeePerGas`.
|
|
57
|
+
// Один из множителей не измерен - null (не «ноль газа»).
|
|
58
|
+
export function claimRequiredWei({ gasLimit = CLAIM_GAS_LIMIT, maxFeePerGasWei } = {}) {
|
|
59
|
+
const limit = toBig(gasLimit);
|
|
60
|
+
const maxFee = toBig(maxFeePerGasWei);
|
|
61
|
+
if (limit === null || limit <= 0n || maxFee === null || maxFee <= 0n) return null;
|
|
62
|
+
return limit * maxFee;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// ЗАПАС НА РОСТ БАЗОВОЙ ЦЕНЫ МЕЖДУ ВЫДАЧЕЙ БИЛЕТА И ЗАБОРОМ, в базисных пунктах (10 000 = +100%).
|
|
66
|
+
//
|
|
67
|
+
// ПОЧЕМУ ОН НУЖЕН И ПОЧЕМУ СТОЛЬКО. Стоимость claim считается на момент БИЛЕТА, а сам claim случается
|
|
68
|
+
// позже: билет живёт RFQ_ORDER_QUOTE_TTL_MS, по умолчанию 40 минут (rfq/config.mjs). Правило клиента
|
|
69
|
+
// (2 x baseFee) перекрывает рост цены ТОЛЬКО на время подтверждения своей транзакции, а не эти 40 минут.
|
|
70
|
+
// Подарок без запаса не проходит, если базовая цена хоть немного поднялась с момента билета - это и
|
|
71
|
+
// показал issue #127. +100% перекрывает УДВОЕНИЕ базовой цены за окно билета, то есть подарок остаётся
|
|
72
|
+
// достаточным, даже если цена газа к забору вырастет вдвое. Значение - настройка ноды
|
|
73
|
+
// (RFQ_REVERSE_GAS_RESERVE_BPS), а не догадка: её видно и можно переопределить без правки кода.
|
|
74
|
+
export const DEFAULT_GIFT_RESERVE_BPS = 10_000n;
|
|
75
|
+
|
|
76
|
+
// РАЗМЕР ПОДАРКА - ТА ЖЕ стоимость claim, что требует клиент, ПЛЮС запас. Это ЕДИНСТВЕННЫЙ расчёт размера
|
|
77
|
+
// подарка: нода (rfq/reverseGas.mjs, requiredGasWei) и живой прогон зовут ИМЕННО ЕГО, а не свою копию.
|
|
78
|
+
// base берётся у цепи на момент билета; priorityFeeWei - реальные чаевые сети (gasPrice - baseFee), если
|
|
79
|
+
// известны. Нечитаемая база - null, а не ноль.
|
|
80
|
+
export function claimGasGiftWei({ baseFeeWei, priorityFeeWei, minTipWei, gasLimit = CLAIM_GAS_LIMIT, reserveBps = DEFAULT_GIFT_RESERVE_BPS } = {}) {
|
|
81
|
+
const maxFee = claimMaxFeePerGasWei({ baseFeeWei, priorityFeeWei, minTipWei });
|
|
82
|
+
const need = claimRequiredWei({ gasLimit, maxFeePerGasWei: maxFee });
|
|
83
|
+
if (need === null) return null;
|
|
84
|
+
const bps = toBig(reserveBps);
|
|
85
|
+
const step = bps === null || bps < 0n ? 0n : bps;
|
|
86
|
+
return need + (need * step) / 10_000n;
|
|
87
|
+
}
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
// GENERATED FILE - a byte-for-byte copy of the engine module www/js/evm/depositor.js for the SDK package.
|
|
2
|
+
// Edit the source under www/js, then run: node tools/build-sdk-engine.mjs
|
|
3
|
+
// tools/check-sdk-engine.mjs reddens on any drift, so a stale copy cannot ship silently.
|
|
4
|
+
// Everything below this header is identical to the source.
|
|
5
|
+
|
|
6
|
+
// ВНОСЯЩИЙ ПО ПОДПИСИ, КЛИЕНТСКАЯ СТОРОНА (issue #97, часть C): EIP-712 typed data над ДАЙДЖЕСТОМ КОТИРОВКИ.
|
|
7
|
+
//
|
|
8
|
+
// ЗАЧЕМ. Часть A дала фабрике путь `createOrderAndFundByDepositor`: транзакцию вправе отправить КТО УГОДНО
|
|
9
|
+
// (релейщик, наш сервис), но вносившим записывается тот, кто подписал дайджест котировки. Чтобы релейщик
|
|
10
|
+
// мог отправить транзакцию, вносящий обязан сперва подписать. Подпись кошелька - это typed data, а не
|
|
11
|
+
// транзакция: она не стоит газа и не двигает деньги, поэтому человек БЕЗ своего ETH тоже может её дать.
|
|
12
|
+
//
|
|
13
|
+
// ПОЧЕМУ ФОРМА ЗДЕСЬ, А НЕ ВЫДУМАНА. Набор полей и домен - ЧАСТЬ ПРОТОКОЛА: тот же OrderQuote, что
|
|
14
|
+
// проверяет эскроу (mvp/contracts/OrderQuote.sol) и подписывает нода (rfq/lib/quoteEip712Spec.mjs,
|
|
15
|
+
// sdk/src/quoteEip712Spec.mjs). Перестановка двух полей даёт ДРУГОЙ дайджест, подпись восстанавливается в
|
|
16
|
+
// чужой адрес, и фабрика отказывает `BadDepositorSignature`. Поэтому форма лежит ОДНИМ списком ниже, а
|
|
17
|
+
// проверка (tools/check-usdc-without-eth.mjs) считает этой формой дайджест и сверяет его с дайджестом,
|
|
18
|
+
// посчитанным СПЕКОЙ ноды/SDK на той же котировке - разойтись молча они не могут.
|
|
19
|
+
//
|
|
20
|
+
// ДОМЕН. verifyingContract - АДРЕС ФАБРИКИ ИЗ САМОЙ КОТИРОВКИ (поле `factory`), chainId - живой из котировки:
|
|
21
|
+
// подпись, сделанная для другой фабрики или сети, здесь не восстановится. Второго источника домена нет.
|
|
22
|
+
//
|
|
23
|
+
// ЧЕГО ЗДЕСЬ НЕТ. Криптографии: дайджест считает КОШЕЛЁК (eth_signTypedData_v4), а не этот модуль. Здесь -
|
|
24
|
+
// только форма и её проверка, чтобы кошелёк подписал ровно то, что проверит цепь.
|
|
25
|
+
|
|
26
|
+
// Имя и версия домена - протокол котировок v8, дословно как в OrderQuote.sol.
|
|
27
|
+
export const DEPOSITOR_DOMAIN_NAME = "NinseiEscrowFactory";
|
|
28
|
+
export const DEPOSITOR_DOMAIN_VERSION = "1";
|
|
29
|
+
|
|
30
|
+
// НАБОР ПОЛЕЙ В ПОРЯДКЕ КОНТРАКТА. Порядок - часть ABI: перестановка даёт другой дайджест. Список один -
|
|
31
|
+
// из него строится и строка типа, и сообщение, разойтись им нечем.
|
|
32
|
+
export const ORDER_QUOTE_TYPED_FIELDS = [
|
|
33
|
+
["provider", "address"],
|
|
34
|
+
["quoteKey", "address"],
|
|
35
|
+
["registry", "address"],
|
|
36
|
+
["cashier", "address"],
|
|
37
|
+
["locker", "address"],
|
|
38
|
+
["claimer", "address"],
|
|
39
|
+
["amount", "uint256"],
|
|
40
|
+
["readyBy", "uint64"],
|
|
41
|
+
["t1", "uint64"],
|
|
42
|
+
["salt", "bytes32"],
|
|
43
|
+
["commitHalfClaimer", "bytes32"],
|
|
44
|
+
["edPointClaimer", "bytes32"],
|
|
45
|
+
["chainId", "uint256"],
|
|
46
|
+
["feeBps", "uint256"],
|
|
47
|
+
["fee", "uint256"],
|
|
48
|
+
["feeRecipient", "address"],
|
|
49
|
+
["validUntil", "uint64"],
|
|
50
|
+
["nonce", "uint256"],
|
|
51
|
+
// THE XMR SIDE OF THE DEAL (#110 step 3), in atomic units: it joined the signed set together with the contract
|
|
52
|
+
// (OrderQuote.sol), the node/SDK spec and the vector, so the depositor's signature covers the same XMR the chain
|
|
53
|
+
// verifies. A field the chain could not see used to stay OUT of this list on purpose - that is no longer the case.
|
|
54
|
+
["xmrAmount", "uint256"],
|
|
55
|
+
];
|
|
56
|
+
|
|
57
|
+
// Тип сообщения. Собирается из списка: правка списка меняет и тип, и подпись - то есть не может остаться
|
|
58
|
+
// незамеченной, в отличие от второго литерала рядом.
|
|
59
|
+
export const ORDER_QUOTE_TYPED_TYPE = "OrderQuote(" + ORDER_QUOTE_TYPED_FIELDS.map(([n, t]) => t + " " + n).join(",") + ")";
|
|
60
|
+
|
|
61
|
+
const isHex = (v, bytes) => new RegExp("^(0x)?[0-9a-fA-F]{" + bytes * 2 + "}$").test(String(v === undefined || v === null ? "" : v).trim());
|
|
62
|
+
const isNum = (v) => /^(0x[0-9a-fA-F]+|[0-9]+)$/.test(String(v === undefined || v === null ? "" : v).trim());
|
|
63
|
+
|
|
64
|
+
// ФОРМА ПРИВОДИТСЯ, А НЕ ТРЕБУЕТСЯ. Точки ed25519 котировка несёт БЕЗ префикса 0x (так их пишет нода),
|
|
65
|
+
// а кошелёк и контракт ждут одинаковые БАЙТЫ. Поэтому приводим к одному виду здесь, на границе, и
|
|
66
|
+
// отвергаем всё, что не адрес/не bytes32/не число: молчаливое выравнивание нулями ушло бы ДРУГИМ значением.
|
|
67
|
+
function normaliseField(name, type, value) {
|
|
68
|
+
const raw = String(value === undefined || value === null ? "" : value).trim();
|
|
69
|
+
if (raw === "") throw new Error("котировка не несёт поля для подписи вносящего: " + name);
|
|
70
|
+
if (type === "address") {
|
|
71
|
+
if (!isHex(raw, 20)) throw new Error("поле " + name + " - не адрес: " + raw);
|
|
72
|
+
return "0x" + raw.replace(/^0x/, "").toLowerCase();
|
|
73
|
+
}
|
|
74
|
+
if (type === "bytes32") {
|
|
75
|
+
if (!isHex(raw, 32)) throw new Error("поле " + name + " - не bytes32: " + raw);
|
|
76
|
+
return "0x" + raw.replace(/^0x/, "").toLowerCase();
|
|
77
|
+
}
|
|
78
|
+
// uint64 / uint256: кошелёк принимает строку; десятичную и hex-форму даёт один и тот же разбор.
|
|
79
|
+
if (!isNum(raw)) throw new Error("поле " + name + " - не число: " + raw);
|
|
80
|
+
return String(BigInt(raw));
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* EIP-712 typed data, которое подписывает вносящий (eth_signTypedData_v4).
|
|
85
|
+
* Домен строится ИЗ КОТИРОВКИ: verifyingContract = quote.factory, chainId = quote.chainId.
|
|
86
|
+
* @returns {{ domain: object, types: object, primaryType: string, message: object }}
|
|
87
|
+
*/
|
|
88
|
+
export function orderQuoteTypedData(quote) {
|
|
89
|
+
if (!quote || typeof quote !== "object") throw new Error("нет подписанной котировки: подписывать нечего");
|
|
90
|
+
if (!isHex(quote.factory, 20)) throw new Error("в котировке нет адреса фабрики (factory) - домен подписи не собрать");
|
|
91
|
+
if (!isNum(quote.chainId)) throw new Error("в котировке нет chainId - домен подписи не собрать");
|
|
92
|
+
const message = {};
|
|
93
|
+
for (const [name, type] of ORDER_QUOTE_TYPED_FIELDS) {
|
|
94
|
+
message[name] = normaliseField(name, type, quote[name]);
|
|
95
|
+
}
|
|
96
|
+
return {
|
|
97
|
+
domain: {
|
|
98
|
+
name: DEPOSITOR_DOMAIN_NAME,
|
|
99
|
+
version: DEPOSITOR_DOMAIN_VERSION,
|
|
100
|
+
chainId: Number(BigInt(quote.chainId)),
|
|
101
|
+
verifyingContract: "0x" + String(quote.factory).replace(/^0x/, "").toLowerCase(),
|
|
102
|
+
},
|
|
103
|
+
types: {
|
|
104
|
+
// EIP712Domain явно: кошельки показывают его человеку, и он обязан совпасть с тем, что считает цепь.
|
|
105
|
+
EIP712Domain: [
|
|
106
|
+
{ name: "name", type: "string" },
|
|
107
|
+
{ name: "version", type: "string" },
|
|
108
|
+
{ name: "chainId", type: "uint256" },
|
|
109
|
+
{ name: "verifyingContract", type: "address" },
|
|
110
|
+
],
|
|
111
|
+
OrderQuote: ORDER_QUOTE_TYPED_FIELDS.map(([name, type]) => ({ name, type })),
|
|
112
|
+
},
|
|
113
|
+
primaryType: "OrderQuote",
|
|
114
|
+
message,
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// ПОДПИСЬ НА ПРОВОДЕ - 65 БАЙТ r || s || v. Проверяем форму ЗДЕСЬ: фабрика принимает подпись тремя словами,
|
|
119
|
+
// и «похожая на подпись» строка не должна уехать в контракт как согласие.
|
|
120
|
+
export function depositorSignatureShape(signature) {
|
|
121
|
+
const hex = String(signature === undefined || signature === null ? "" : signature).replace(/^0x/, "");
|
|
122
|
+
if (!/^[0-9a-fA-F]{130}$/.test(hex)) return { ok: false, reason: "подпись вносящего обязана быть 65 байт (r || s || v), а пришло " + hex.length / 2 + " байт" };
|
|
123
|
+
let v = parseInt(hex.slice(128, 130), 16);
|
|
124
|
+
if (v < 27) v += 27;
|
|
125
|
+
if (v !== 27 && v !== 28) return { ok: false, reason: "v подписи вносящего вне {27,28}: " + v };
|
|
126
|
+
return { ok: true, r: "0x" + hex.slice(0, 64).toLowerCase(), s: "0x" + hex.slice(64, 128).toLowerCase(), v: 27 + (v - 27) };
|
|
127
|
+
}
|