@ninsei-labs/sdk 0.39.0 → 0.39.2
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 +115 -0
- package/README.md +21 -0
- package/engine/core/chainSource.js +306 -306
- package/engine/core/config.js +29 -2
- package/engine/core/sellWindow.js +43 -43
- package/engine/core/swap.js +1153 -1153
- package/engine/evm/claimGas.js +88 -87
- package/engine/evm/dex.js +836 -836
- package/engine/evm/factory.js +106 -0
- package/engine/evm/funding.js +11 -11
- package/engine/evm/gasReserve.js +88 -88
- package/engine/evm/prices.js +179 -179
- package/engine/recovery/recoveryFile.js +4 -1
- package/engine/sdk/bridge.js +7 -2
- package/index.d.ts +18 -2
- package/package.json +4 -4
- package/src/actions.mjs +72 -72
- package/src/adapters/node-test-evm-wallet.mjs +345 -345
- package/src/config.mjs +9 -6
- package/src/engine.mjs +53 -53
- package/src/index.mjs +10 -1
- package/src/levelsWalkSpec.mjs +21 -4
- package/src/lock.mjs +140 -140
- package/src/order.mjs +212 -212
- package/src/quotes.mjs +495 -465
- package/src/swaps.mjs +935 -935
- package/src/sweep.mjs +29 -4
- package/src/xmr-arrival.mjs +289 -289
package/engine/evm/dex.js
CHANGED
|
@@ -1,836 +1,836 @@
|
|
|
1
|
-
// GENERATED FILE - a byte-for-byte copy of the engine module www/js/evm/dex.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
|
-
// DEX-нога: КОТИРОВКА Uniswap v3, полученная из цепи. ТОЛЬКО ЧТЕНИЕ: eth_call, ни одной транзакции.
|
|
7
|
-
//
|
|
8
|
-
// ЧТО ЗДЕСЬ ЗАМЕНЕНО. Раньше в разбивке цены стоял мок: "Uniswap v3 USDC/WETH 0.05%" и спред 0.0004
|
|
9
|
-
// брались из констант конфига (www/js/mock/market.js:76-77), то есть комиссия пула и выход были
|
|
10
|
-
// ВЫДУМАНЫ. Теперь и то и другое читается у самой цепи через QuoterV2 - тот же путь, которым страница
|
|
11
|
-
// уже читает цену и газ (evm/prices.js): POST к chain.rpcUrl, никаких новых доменов, никаких новых
|
|
12
|
-
// параметров в URL и никаких новых внешних адресов. Хост уже разрешён в CSP - он берётся из реестра сетей.
|
|
13
|
-
//
|
|
14
|
-
// СЕЛЕКТОРЫ И СТРУКТУРА АРГУМЕНТОВ ВЗЯТЫ ИЗ ВЫЗОВА, А НЕ ИЗ ПАМЯТИ (проверено 2026-09-23):
|
|
15
|
-
// * selector = keccak256(подпись)[0..4]. Считается в тесте (tools/check-uniswap-quote.mjs) из строки
|
|
16
|
-
// подписи, лежащей здесь рядом с селектором, и наличие селектора сверяется в КОДЕ контракта.
|
|
17
|
-
// * АРГУМЕНТЫ ИДУТ ПЯТЬЮ СЛОВАМИ ПОДРЯД, БЕЗ СЛОВА-СМЕЩЕНИЯ. Подпись формально принимает структуру
|
|
18
|
-
// (address,address,uint256,uint24,uint160), и по спецификации ABI первым словом должно идти
|
|
19
|
-
// смещение 0x20. НА РАЗВЁРНУТОМ QuoterV2 ЭТО ЛОЖЬ: вариант со смещением ОТКАТЫВАЕТ (execution
|
|
20
|
-
// reverted), а пять слов подряд отвечают четырьмя словами (amountOut, sqrtPriceX96After,
|
|
21
|
-
// initializedTicksCrossed, gasEstimate). Поэтому кодируем подряд - и это записано здесь, чтобы
|
|
22
|
-
// никто не "починил" код по спецификации и не сломал котировку молча.
|
|
23
|
-
//
|
|
24
|
-
// ЧЕГО МОДУЛЬ НЕ ДЕЛАЕТ: не отправляет транзакции, не делает approve, не свопает и НЕ трогает путь
|
|
25
|
-
// фондирования. Оценка газа здесь - это gasEstimate, который вернул сам QuoterV2 (число из цепи), а не
|
|
26
|
-
// eth_estimateGas по чужой транзакции.
|
|
27
|
-
//
|
|
28
|
-
// ЧЕСТНОЕ "НЕТ КОТИРОВКИ" ВМЕСТО ВЫДУМАННОГО ЧИСЛА. Пустой пул, отсутствие пула, откат котировки и
|
|
29
|
-
// цена, разошедшаяся с эталоном, - ЧЕТЫРЕ РАЗНЫХ ответа, и каждый назван словами (why). Число
|
|
30
|
-
// возвращается только тогда, когда его действительно назвала цепь и оно прошло сверку с эталоном.
|
|
31
|
-
//
|
|
32
|
-
// МАРШРУТ ЗДЕСЬ ОБЪЯВЛЕН, А НЕ ВЫБРАН. Перебора "прямой путь или через хаб" в рантайме нет и быть не
|
|
33
|
-
// должно: человек объявил маршрут в реестре (DEX_ROUTES), а модуль только переводит его в путь и
|
|
34
|
-
// спрашивает цепь ОДНИМ вызовом. Если объявленного маршрута нет (или в нём пусто) - это честное
|
|
35
|
-
// "маршрута нет", причина лежит в данных (route.why), и такая нога блокирует подпись, а не ищет обход.
|
|
36
|
-
//
|
|
37
|
-
// ПРОВЕРКА ЦЕНЫ (priceGateVerdict) - ВТОРАЯ ЗАЩИТА ТОЙ ЖЕ СЕМЬИ, что покрытие суммы: неверная цена
|
|
38
|
-
// означает, что сделка не сойдётся, и подпись не начинается. Считается двумя способами, ни один из
|
|
39
|
-
// которых не требует фида на каждый токен: неявная цена натива из котировки против эталона (Chainlink)
|
|
40
|
-
// и полоса "около 1:1" на шагах между стейблами (без оракула вовсе). Пороги - в реестре
|
|
41
|
-
// (DEX_PRICE_GATE), исключение для тестнета - тоже данные (dexPriceGate у сети): там цена негодная по
|
|
42
|
-
// природе, и негодная цена помечается словами, а не останавливает поток. Оценок ликвидности здесь
|
|
43
|
-
// по-прежнему нет: глубину пула решает человек.
|
|
44
|
-
|
|
45
|
-
// Селекторы. Рядом с каждым - строка подписи, из которой он посчитан: проверка
|
|
46
|
-
// (tools/check-uniswap-quote.mjs) пересчитывает keccak256 от этих строк и падает, если хоть один
|
|
47
|
-
// селектор разошёлся с подписью.
|
|
48
|
-
// ЧТО ЗДЕСЬ ПРОВЕРЕНО ВЫЗОВОМ (2026-09-23, Arbitrum One и Ethereum, tools/check-uniswap-quote.mjs):
|
|
49
|
-
// * quoteExactInputSingle 0xc6a5026a - котировка ОДНОЙ ступени, пять слов подряд без смещения;
|
|
50
|
-
// * quoteExactInput 0xcdca1753 - котировка ВСЕГО ПУТИ ОДНИМ ВЫЗОВОМ. Подпись
|
|
51
|
-
// quoteExactInput(bytes,uint256), селектор найден в коде QuoterV2 в обеих сетях, а сверка
|
|
52
|
-
// одноступенчатого пути против одиночной котировки дала РОВНО то же число (36685744820778786 на
|
|
53
|
-
// 100 USDC), то есть кодирование пути (20 байт адрес + 3 байта комиссия, подряд) подтверждено числом,
|
|
54
|
-
// а не по памяти. Эталонного значения нет - есть совпадение двух РАЗНЫХ вызовов.
|
|
55
|
-
export const DEX_METHOD = {
|
|
56
|
-
// ФОРМА С ТОЧНЫМ ВЫХОДОМ. Это НЕ "сколько выйдет", а "получить ровно столько, потратив не больше
|
|
57
|
-
// такого-то": при ней вопроса о разнице сумм не существует вовсе (док 45 §"Что добавит DEX-нога").
|
|
58
|
-
// ОБЕ формы подтверждены вызовом на Arbitrum One и основной сети (tools/check-uniswap-quote.mjs):
|
|
59
|
-
// - одиночная даёт amountIn на ровно 0.035 WETH;
|
|
60
|
-
// - многошаговая по пути из двух ног тоже отвечает, но ПУТЬ У НЕЁ ЗАДАЁТСЯ В ОБРАТНУЮ СТОРОНУ - от
|
|
61
|
-
// получаемого токена к оплачиваемому (прямой путь отказывает: "Unexpected error"). Это измерено, а
|
|
62
|
-
// не выведено: перепутать направление здесь стоит отказа вызова.
|
|
63
|
-
quoteExactOutputSingle: { hex: "0xbd21704a", signature: "quoteExactOutputSingle((address,address,uint256,uint24,uint160))" },
|
|
64
|
-
quoteExactOutput: { hex: "0x2f80bb1d", signature: "quoteExactOutput(bytes,uint256)" },
|
|
65
|
-
quoteExactInputSingle: { hex: "0xc6a5026a", signature: "quoteExactInputSingle((address,address,uint256,uint24,uint160))" },
|
|
66
|
-
// Многошаговая форма: путь передаётся УПАКОВАННЫМ (token|fee|token|fee|token), аргумент динамический,
|
|
67
|
-
// поэтому здесь смещение 0x40 (см. encodeQuoteExactInput), в отличие от одиночной формы.
|
|
68
|
-
quoteExactInput: { hex: "0xcdca1753", signature: "quoteExactInput(bytes,uint256)" },
|
|
69
|
-
getPool: { hex: "0x1698ee82", signature: "getPool(address,address,uint24)" },
|
|
70
|
-
liquidity: { hex: "0x1a686502", signature: "liquidity()" },
|
|
71
|
-
balanceOf: { hex: "0x70a08231", signature: "balanceOf(address)" },
|
|
72
|
-
// withdraw у WETH9: НЕ для отправки, только для оценки газа unwrap (и только если у нас есть
|
|
73
|
-
// измеренный держатель WETH - см. tools/check-uniswap-quote.mjs).
|
|
74
|
-
withdraw: { hex: "0x2e1a7d4d", signature: "withdraw(uint256)" },
|
|
75
|
-
};
|
|
76
|
-
|
|
77
|
-
// ПУТЬ ИЗ ОБЪЯВЛЕННОГО МАРШРУТА (DEX_ROUTES в реестре). Здесь НЕТ никакого выбора: человек объявил шаги
|
|
78
|
-
// со ступенями, а мы только переводим символы в адреса. Обёрнутый нативный токен берётся из данных сети
|
|
79
|
-
// (u.wrapped), а не пишется строкой "WETH": у сети на BNB обёрнутый натив - WBNB.
|
|
80
|
-
// Возвращает null и причину, если объявленный маршрут собрать нельзя (шаг назван токеном, которого в
|
|
81
|
-
// этой сети нет) - это НЕ "маршрута нет", а ошибка в данных, и называть её надо иначе.
|
|
82
|
-
export function declaredRoutePath({ token, tokens = [], wrapped, route }) {
|
|
83
|
-
if (!token || !route || !Array.isArray(route.steps) || !route.steps.length) return null;
|
|
84
|
-
const bySym = new Map((tokens || []).map((t) => [String(t.symbol).toUpperCase(), t]));
|
|
85
|
-
const addrOf = (sym) => {
|
|
86
|
-
const s = String(sym).toUpperCase();
|
|
87
|
-
if (wrapped && s === String(wrapped.symbol).toUpperCase()) return wrapped.address;
|
|
88
|
-
const t = bySym.get(s);
|
|
89
|
-
return t && t.address ? t.address : null;
|
|
90
|
-
};
|
|
91
|
-
const symbols = [token.symbol];
|
|
92
|
-
const addresses = [token.address];
|
|
93
|
-
const fees = [];
|
|
94
|
-
const pools = [];
|
|
95
|
-
for (const step of route.steps) {
|
|
96
|
-
const a = addrOf(step.to);
|
|
97
|
-
if (!a) return { error: `declared route names a token that this network has no address for: ${step.to}` };
|
|
98
|
-
addresses.push(a);
|
|
99
|
-
symbols.push(String(step.to).toUpperCase() === String(wrapped && wrapped.symbol).toUpperCase() ? wrapped.symbol : step.to);
|
|
100
|
-
fees.push(Number(step.fee));
|
|
101
|
-
pools.push(step.pool || null);
|
|
102
|
-
}
|
|
103
|
-
return { tokens: addresses, symbols, fees, pools };
|
|
104
|
-
}
|
|
105
|
-
|
|
106
|
-
// Путь одной строкой с комиссиями ступеней: "USDe → USDC 0.01% → WETH 0.05%". Одна и та же запись в
|
|
107
|
-
// интерфейсе и в отчёте - разойдясь, они начнут объяснять человеку одно, а оператору другое.
|
|
108
|
-
export function routeLabel(route) {
|
|
109
|
-
if (!route || !route.symbols) return "";
|
|
110
|
-
let s = route.symbols[0];
|
|
111
|
-
for (let i = 0; i < route.fees.length; i++) s += " → " + route.symbols[i + 1] + " " + feeTierLabel(route.fees[i]);
|
|
112
|
-
return s;
|
|
113
|
-
}
|
|
114
|
-
|
|
115
|
-
// Разбор пути на шаги: [{from, to, fee, feeLabel}] - для пошаговой строки в интерфейсе и в отчёте.
|
|
116
|
-
export function routeSteps(route) {
|
|
117
|
-
if (!route || !route.symbols) return [];
|
|
118
|
-
return route.fees.map((fee, i) => ({ from: route.symbols[i], to: route.symbols[i + 1], fee, feeLabel: feeTierLabel(fee) }));
|
|
119
|
-
}
|
|
120
|
-
|
|
121
|
-
const CACHE_MS = 20_000; // котировка живёт 20 с: дольше держать её нельзя (цена в пуле движется), чаще спрашивать незачем
|
|
122
|
-
|
|
123
|
-
const cache = new Map();
|
|
124
|
-
|
|
125
|
-
function cached(key) {
|
|
126
|
-
const hit = cache.get(key);
|
|
127
|
-
if (hit && Date.now() - hit.at < CACHE_MS) return hit.value;
|
|
128
|
-
return undefined;
|
|
129
|
-
}
|
|
130
|
-
function put(key, value) {
|
|
131
|
-
cache.set(key, { at: Date.now(), value });
|
|
132
|
-
return value;
|
|
133
|
-
}
|
|
134
|
-
export function resetDexCache() {
|
|
135
|
-
cache.clear();
|
|
136
|
-
}
|
|
137
|
-
|
|
138
|
-
// Один eth_call в сеть сети из реестра. Никогда не бросает: молчание узла - это состояние интерфейса,
|
|
139
|
-
// а не падение экрана (так же сделано в evm/prices.js).
|
|
140
|
-
async function rpc(chain, method, params = [], { timeoutMs = 15_000 } = {}) {
|
|
141
|
-
if (!chain || !chain.rpcUrl) return { ok: false, why: "network has no RPC endpoint" };
|
|
142
|
-
const ctl = typeof AbortController === "function" ? new AbortController() : null;
|
|
143
|
-
const timer = ctl ? setTimeout(() => ctl.abort(), timeoutMs) : null;
|
|
144
|
-
try {
|
|
145
|
-
const res = await fetch(chain.rpcUrl, {
|
|
146
|
-
method: "POST",
|
|
147
|
-
headers: { "content-type": "application/json" },
|
|
148
|
-
body: JSON.stringify({ jsonrpc: "2.0", id: 1, method, params }),
|
|
149
|
-
signal: ctl ? ctl.signal : undefined,
|
|
150
|
-
});
|
|
151
|
-
if (!res.ok) return { ok: false, why: method + ": HTTP " + res.status };
|
|
152
|
-
const json = await res.json();
|
|
153
|
-
if (json.error) return { ok: false, why: method + ": " + String(json.error.message || "rpc error").slice(0, 120) };
|
|
154
|
-
return { ok: true, result: json.result };
|
|
155
|
-
} catch (e) {
|
|
156
|
-
const aborted = e && e.name === "AbortError";
|
|
157
|
-
return { ok: false, why: method + ": " + (aborted ? "no answer in " + timeoutMs + " ms" : String((e && e.message) || e)) };
|
|
158
|
-
} finally {
|
|
159
|
-
if (timer) clearTimeout(timer);
|
|
160
|
-
}
|
|
161
|
-
}
|
|
162
|
-
|
|
163
|
-
// --- кодирование/декодирование ABI без библиотек (в проекте ноль зависимостей в app/) ---------------
|
|
164
|
-
const hexBody = (s) => String(s || "").replace(/^0x/, "");
|
|
165
|
-
const word = (v) => hexBody(v).toLowerCase().padStart(64, "0");
|
|
166
|
-
// АДРЕС - ЭТО ЦЕЛОЕ СЛОВО ABI (24 нуля и 20 байт адреса), а не только 20 байт. Здесь была ошибка ровно
|
|
167
|
-
// наоборот от той, что кажется очевидной: сначала адрес дополнялся нулями до слова, а потом у слова
|
|
168
|
-
// срезались первые 24 знака - то есть дополнение выбрасывалось, и в calldata уходили аргументы,
|
|
169
|
-
// сдвинутые на 12 байт. Вызов на этом откатывался ("execution reverted"), и выглядело это как "в пуле
|
|
170
|
-
// нет ликвидности", хотя пул отвечал - врал наш кодировщик (поймано сверкой с рабочим вызовом).
|
|
171
|
-
const addrWord = (a) => word(a);
|
|
172
|
-
const uintWord = (v) => {
|
|
173
|
-
let h = "";
|
|
174
|
-
let n = BigInt(v);
|
|
175
|
-
while (n > 0n) {
|
|
176
|
-
h = (n & 0xffn).toString(16).padStart(2, "0") + h;
|
|
177
|
-
n >>= 8n;
|
|
178
|
-
}
|
|
179
|
-
return h.padStart(64, "0");
|
|
180
|
-
};
|
|
181
|
-
function decodeWords(hex) {
|
|
182
|
-
const b = hexBody(hex);
|
|
183
|
-
const out = [];
|
|
184
|
-
for (let i = 0; i + 64 <= b.length; i += 64) out.push(BigInt("0x" + b.slice(i, i + 64)));
|
|
185
|
-
return out;
|
|
186
|
-
}
|
|
187
|
-
export function encodeQuoteExactInputSingle({ tokenIn, tokenOut, amountInWei, fee, sqrtPriceLimitX96 = 0 }) {
|
|
188
|
-
return (
|
|
189
|
-
DEX_METHOD.quoteExactInputSingle.hex +
|
|
190
|
-
addrWord(tokenIn) +
|
|
191
|
-
addrWord(tokenOut) +
|
|
192
|
-
uintWord(amountInWei) +
|
|
193
|
-
uintWord(fee) +
|
|
194
|
-
uintWord(sqrtPriceLimitX96)
|
|
195
|
-
);
|
|
196
|
-
}
|
|
197
|
-
export function encodeGetPool({ tokenA, tokenB, fee }) {
|
|
198
|
-
return DEX_METHOD.getPool.hex + addrWord(tokenA) + addrWord(tokenB) + uintWord(fee);
|
|
199
|
-
}
|
|
200
|
-
|
|
201
|
-
// ОБРАТНАЯ ЗАДАЧА ПО ОДНОМУ ПУЛУ: сколько надо отдать, чтобы получить РОВНО amountOutWei. Раскладка та же,
|
|
202
|
-
// что у одиночной формы на вход (статическая структура идёт ВНУТРИ, без слова смещения): адрес, адрес,
|
|
203
|
-
// сумма, комиссия, предел цены. Подтверждено вызовом, см. DEX_METHOD.quoteExactOutputSingle.
|
|
204
|
-
export function encodeQuoteExactOutputSingle({ tokenIn, tokenOut, amountOutWei, fee, sqrtPriceLimitX96 = 0 }) {
|
|
205
|
-
return DEX_METHOD.quoteExactOutputSingle.hex +
|
|
206
|
-
addrWord(tokenIn) + addrWord(tokenOut) + uintWord(amountOutWei) + uintWord(fee) + uintWord(sqrtPriceLimitX96);
|
|
207
|
-
}
|
|
208
|
-
|
|
209
|
-
// ЛИБО СКОЛЬКО ВЫЙДЕТ, ЛИБО ПОЧЕМУ НЕТ - так же, как у формы на вход. Здесь ЭТО НЕ ПРОГНОЗ, а ответ на
|
|
210
|
-
// вопрос "хватает ли пула, чтобы получить ровно столько": отказ и есть "не хватает".
|
|
211
|
-
export async function quoteExactOutSingle(chain, { tokenIn, tokenOut, amountOutWei, fee }, opts = {}) {
|
|
212
|
-
const u = chain && chain.uniswap;
|
|
213
|
-
if (!u || !u.quoter) return { ok: false, why: "no verified QuoterV2 for this network" };
|
|
214
|
-
const data = encodeQuoteExactOutputSingle({ tokenIn, tokenOut, amountOutWei, fee });
|
|
215
|
-
const res = await rpc(chain, "eth_call", [{ to: u.quoter, data }, "latest"], opts);
|
|
216
|
-
if (!res.ok) return { ok: false, why: res.why };
|
|
217
|
-
const words = decodeWords(res.result);
|
|
218
|
-
if (!words.length) return { ok: false, why: "quoter answered without data" };
|
|
219
|
-
if (words[0] <= 0n) return { ok: false, why: "pool cannot serve this output" };
|
|
220
|
-
return {
|
|
221
|
-
ok: true,
|
|
222
|
-
amountInWei: words[0].toString(),
|
|
223
|
-
gasEstimate: words.length > 1 ? words[words.length - 1].toString() : null,
|
|
224
|
-
};
|
|
225
|
-
}
|
|
226
|
-
|
|
227
|
-
// ОБРАТНАЯ ЗАДАЧА ПО ВСЕМУ ПУТИ: сколько надо отдать, чтобы получить РОВНО amountOutWei.
|
|
228
|
-
// ПУТЬ ЗДЕСЬ ИДЁТ В ОБРАТНУЮ СТОРОНУ - от получаемого токена к оплачиваемому, и комиссии в том же
|
|
229
|
-
// обратном порядке. Это не наше соглашение, а требование контракта: на прямом пути вызов отказывает
|
|
230
|
-
// ("Unexpected error"), на обратном отвечает числом. Измерено на обеих сетях, см. DEX_METHOD.
|
|
231
|
-
// ПРЕДПОЧТЕНИЕ ЭТОЙ ФОРМЫ: при ней минимум на выходе не "примерно равен" требуемой сумме, а равен ей
|
|
232
|
-
// точно, и вопроса "а что делать с разницей" не возникает вовсе (док 45).
|
|
233
|
-
export async function quoteExactOutPath(chain, { route, amountOutWei }, opts = {}) {
|
|
234
|
-
const u = chain && chain.uniswap;
|
|
235
|
-
if (!u || !u.quoter) return { ok: false, why: "no verified QuoterV2 for this network" };
|
|
236
|
-
if (!route || !Array.isArray(route.tokens) || route.tokens.length < 2) return { ok: false, why: "route has no path" };
|
|
237
|
-
const tokens = route.tokens.slice().reverse();
|
|
238
|
-
const fees = (route.fees || []).slice().reverse();
|
|
239
|
-
const path = encodePath({ tokens, fees });
|
|
240
|
-
// Динамический аргумент (bytes) идёт со словом смещения: 0x40, затем сумма точного выхода, затем сам путь.
|
|
241
|
-
const data = DEX_METHOD.quoteExactOutput.hex + uintWord(0x40) + uintWord(amountOutWei) + uintWord(path.bytes) +
|
|
242
|
-
path.packed.padEnd(Math.ceil(path.packed.length / 64) * 64, "0");
|
|
243
|
-
const res = await rpc(chain, "eth_call", [{ to: u.quoter, data }, "latest"], opts);
|
|
244
|
-
if (!res.ok) return { ok: false, why: res.why };
|
|
245
|
-
const out = decodeQuoteExactInput(res.result); // форма ответа та же: сумма, массивы, газ
|
|
246
|
-
if (!out || out.amountOutWei <= 0n) return { ok: false, why: "pool cannot serve exactly this output" };
|
|
247
|
-
return {
|
|
248
|
-
ok: true,
|
|
249
|
-
amountInWei: out.amountOutWei.toString(),
|
|
250
|
-
gasEstimate: out.gasEstimate.toString(),
|
|
251
|
-
shapeOk: out.sqrtPriceX96After.length === fees.length,
|
|
252
|
-
reversed: tokens.map((x) => x).length === fees.length + 1,
|
|
253
|
-
};
|
|
254
|
-
}
|
|
255
|
-
|
|
256
|
-
// ПУТЬ ДЛЯ МНОГОШАГОВОЙ КОТИРОВКИ. Путь в Uniswap v3 - это УПАКОВАННАЯ последовательность
|
|
257
|
-
// "адрес(20) + комиссия(3)" с завершающим адресом: token0|fee0|token1|fee1|token2. Никаких слов ABI
|
|
258
|
-
// внутри пути нет: 43 байта на одну ногу, 66 на две. Проверено числом: одноступенчатый путь через
|
|
259
|
-
// quoteExactInput вернул ровно тот же amountOut, что и quoteExactInputSingle по той же ступени.
|
|
260
|
-
export function encodePath({ tokens, fees }) {
|
|
261
|
-
const parts = [];
|
|
262
|
-
for (let i = 0; i < tokens.length; i++) {
|
|
263
|
-
parts.push(hexBody(tokens[i]).toLowerCase().padStart(40, "0"));
|
|
264
|
-
if (i < fees.length) parts.push(hexBody(Number(fees[i]).toString(16)).toLowerCase().padStart(6, "0"));
|
|
265
|
-
}
|
|
266
|
-
const packed = parts.join("");
|
|
267
|
-
return { packed, bytes: packed.length / 2 };
|
|
268
|
-
}
|
|
269
|
-
|
|
270
|
-
// ВЫЗОВ quoteExactInput(bytes path, uint256 amountIn) - ОДИН eth_call на ВЕСЬ маршрут. Собран по
|
|
271
|
-
// спецификации ABI, потому что здесь аргумент динамический: слово смещения (0x40), затем amountIn,
|
|
272
|
-
// затем длина пути и сам путь, добитый до границы слова. ЗДЕСЬ, В ОТЛИЧИЕ ОТ ОДИНОЧНОЙ ФОРМЫ, СМЕЩЕНИЕ
|
|
273
|
-
// ДЕЙСТВИТЕЛЬНО НУЖНО - одиночная форма принимает статичную структуру и идёт словами подряд.
|
|
274
|
-
export function encodeQuoteExactInput({ tokens, fees, amountInWei }) {
|
|
275
|
-
const { packed, bytes } = encodePath({ tokens, fees });
|
|
276
|
-
const padded = packed.padEnd(Math.ceil(bytes / 32) * 64, "0");
|
|
277
|
-
return DEX_METHOD.quoteExactInput.hex + word("0x40") + uintWord(amountInWei) + uintWord(bytes) + padded;
|
|
278
|
-
}
|
|
279
|
-
const wordToAddress = (w) => (w === undefined || w === null ? null : "0x" + w.toString(16).padStart(40, "0"));
|
|
280
|
-
const ZERO_ADDR = "0x" + "0".repeat(40);
|
|
281
|
-
|
|
282
|
-
// Ступень комиссии в процентах: 500 -> "0.05%". Это ЕДИНСТВЕННАЯ величина, которая раньше бралась из
|
|
283
|
-
// мока, и теперь она считается из самой ступени, а не берётся константой.
|
|
284
|
-
// Арифметика: fee измеряется в сотых долях базисной точки, 1 000 000 = 100%. Значит проценты = fee/10 000,
|
|
285
|
-
// то есть 500 -> 0.05%. Здесь сначала стояло ЕЩЁ деление на 100, и ступень 500 подписывалась как
|
|
286
|
-
// "0.0005%" - то есть комиссия занижалась в сто раз (поймано прогоном, а не на глаз).
|
|
287
|
-
export function feeTierPct(fee) {
|
|
288
|
-
const n = Number(fee);
|
|
289
|
-
if (!Number.isFinite(n)) return null;
|
|
290
|
-
return n / 10_000;
|
|
291
|
-
}
|
|
292
|
-
export function feeTierLabel(fee) {
|
|
293
|
-
const pct = feeTierPct(fee);
|
|
294
|
-
if (pct === null) return String(fee);
|
|
295
|
-
return String(Number(pct.toFixed(4))) + "%";
|
|
296
|
-
}
|
|
297
|
-
|
|
298
|
-
// ---------------------------------------------------------------------------
|
|
299
|
-
// Чтения
|
|
300
|
-
// ---------------------------------------------------------------------------
|
|
301
|
-
|
|
302
|
-
// Адрес пула у фабрики. null - пула нет (это ОТВЕТ, а не ошибка: значит менять не на что).
|
|
303
|
-
export async function poolAddress(chain, { tokenIn, tokenOut, fee }, opts = {}) {
|
|
304
|
-
const u = chain && chain.uniswap;
|
|
305
|
-
if (!u || !u.factory) return { ok: false, why: "no verified Uniswap factory for this network" };
|
|
306
|
-
const res = await rpc(chain, "eth_call", [{ to: u.factory, data: encodeGetPool({ tokenA: tokenIn, tokenB: tokenOut, fee }) }, "latest"], opts);
|
|
307
|
-
if (!res.ok) return { ok: false, why: res.why };
|
|
308
|
-
const words = decodeWords(res.result);
|
|
309
|
-
const pool = wordToAddress(words[0]);
|
|
310
|
-
if (!pool || pool === ZERO_ADDR) return { ok: true, pool: null };
|
|
311
|
-
return { ok: true, pool };
|
|
312
|
-
}
|
|
313
|
-
|
|
314
|
-
// Состояние пула: активная ликвидность и запасы обеих сторон. Нужно для ЧЕСТНОГО ответа "ликвидности
|
|
315
|
-
// нет": котировка пустого пула отвечает нулём, и без этого чтения мы не отличим "пула нет" от "пул есть,
|
|
316
|
-
// но пустой". Читается только в диагностическом пути (когда котировки нет или она неправдоподобна).
|
|
317
|
-
export async function poolState(chain, pool, { tokenIn, tokenOut }, opts = {}) {
|
|
318
|
-
if (!pool) return null;
|
|
319
|
-
const [liq, balIn, balOut] = await Promise.all([
|
|
320
|
-
rpc(chain, "eth_call", [{ to: pool, data: DEX_METHOD.liquidity.hex }, "latest"], opts),
|
|
321
|
-
rpc(chain, "eth_call", [{ to: tokenIn, data: DEX_METHOD.balanceOf.hex + addrWord(pool) }, "latest"], opts),
|
|
322
|
-
rpc(chain, "eth_call", [{ to: tokenOut, data: DEX_METHOD.balanceOf.hex + addrWord(pool) }, "latest"], opts),
|
|
323
|
-
]);
|
|
324
|
-
const one = (r) => (r.ok ? decodeWords(r.result)[0] ?? null : null);
|
|
325
|
-
return {
|
|
326
|
-
liquidity: liq.ok ? (BigInt(decodeWords(liq.result)[0] ?? 0n)).toString() : null,
|
|
327
|
-
reserveInWei: balIn.ok && one(balIn) !== null ? one(balIn).toString() : null,
|
|
328
|
-
reserveOutWei: balOut.ok && one(balOut) !== null ? one(balOut).toString() : null,
|
|
329
|
-
};
|
|
330
|
-
}
|
|
331
|
-
|
|
332
|
-
// ОДНА КОТИРОВКА ОДНОЙ СТУПЕНИ. Откат котировки - это НАЗВАННЫЙ отказ, а не исключение.
|
|
333
|
-
export async function quoteTier(chain, { tokenIn, tokenOut, amountInWei, fee }, opts = {}) {
|
|
334
|
-
const u = chain && chain.uniswap;
|
|
335
|
-
if (!u || !u.quoter) return { fee, ok: false, why: "no verified QuoterV2 for this network" };
|
|
336
|
-
const data = encodeQuoteExactInputSingle({ tokenIn, tokenOut, amountInWei, fee });
|
|
337
|
-
const res = await rpc(chain, "eth_call", [{ to: u.quoter, data }, "latest"], opts);
|
|
338
|
-
if (!res.ok) return { fee, ok: false, why: res.why };
|
|
339
|
-
const words = decodeWords(res.result);
|
|
340
|
-
if (words.length < 4) return { fee, ok: false, why: "quoter answered " + words.length + " words, expected 4" };
|
|
341
|
-
const amountOut = words[0];
|
|
342
|
-
if (amountOut <= 0n) return { fee, ok: false, why: "pool cannot serve this size" };
|
|
343
|
-
return {
|
|
344
|
-
fee,
|
|
345
|
-
ok: true,
|
|
346
|
-
amountOutWei: amountOut.toString(),
|
|
347
|
-
gasEstimate: words[3].toString(),
|
|
348
|
-
sqrtPriceX96After: words[1].toString(),
|
|
349
|
-
initializedTicksCrossed: words[2].toString(),
|
|
350
|
-
};
|
|
351
|
-
}
|
|
352
|
-
|
|
353
|
-
// Разбор ответа quoteExactInput. Ответ ДРУГОЙ, чем у одиночной формы: (uint256 amountOut,
|
|
354
|
-
// uint160[] sqrtPriceX96AfterList, uint32[] initializedTicksCrossedList, uint256 gasEstimate) - то есть
|
|
355
|
-
// на каждый шаг пути по элементу в двух массивах. Смещения читаются из самого ответа, а не
|
|
356
|
-
// предполагаются: длина массивов равна числу ног пути, и это можно СВЕРИТЬ, а не поверить.
|
|
357
|
-
export function decodeQuoteExactInput(hex) {
|
|
358
|
-
const words = decodeWords(hex);
|
|
359
|
-
if (words.length < 4) return null;
|
|
360
|
-
const at = (w) => Number(w) / 32;
|
|
361
|
-
const readArr = (w) => {
|
|
362
|
-
const i = at(w);
|
|
363
|
-
if (!Number.isInteger(i) || i < 0 || i >= words.length) return [];
|
|
364
|
-
const n = Number(words[i]);
|
|
365
|
-
if (!Number.isInteger(n) || n <= 0 || i + 1 + n > words.length) return [];
|
|
366
|
-
return words.slice(i + 1, i + 1 + n).map(String);
|
|
367
|
-
};
|
|
368
|
-
return {
|
|
369
|
-
amountOutWei: words[0],
|
|
370
|
-
sqrtPriceX96After: readArr(words[1]),
|
|
371
|
-
initializedTicksCrossed: readArr(words[2]),
|
|
372
|
-
gasEstimate: words[3],
|
|
373
|
-
};
|
|
374
|
-
}
|
|
375
|
-
|
|
376
|
-
// КОТИРОВКА ВСЕГО ПУТИ ОДНИМ ВЫЗОВОМ. Здесь НЕТ цепочки "котировка шага 1, потом шаг 2 от её выхода":
|
|
377
|
-
// такая цепочка считает оба шага по одному и тому же состоянию пулов и даёт оптимистично неверное число.
|
|
378
|
-
export async function quotePath(chain, { route, amountInWei }, opts = {}) {
|
|
379
|
-
const u = chain && chain.uniswap;
|
|
380
|
-
if (!u || !u.quoter) return { ...route, ok: false, why: "no verified QuoterV2 for this network" };
|
|
381
|
-
const data = encodeQuoteExactInput({ tokens: route.tokens, fees: route.fees, amountInWei });
|
|
382
|
-
const res = await rpc(chain, "eth_call", [{ to: u.quoter, data }, "latest"], opts);
|
|
383
|
-
if (!res.ok) return { ...route, ok: false, why: res.why };
|
|
384
|
-
const out = decodeQuoteExactInput(res.result);
|
|
385
|
-
if (!out) return { ...route, ok: false, why: "quoter answered a shape that is not quoteExactInput" };
|
|
386
|
-
if (out.amountOutWei <= 0n) return { ...route, ok: false, why: "pool cannot serve this size" };
|
|
387
|
-
return {
|
|
388
|
-
...route,
|
|
389
|
-
ok: true,
|
|
390
|
-
amountOutWei: out.amountOutWei.toString(),
|
|
391
|
-
gasEstimate: out.gasEstimate.toString(),
|
|
392
|
-
sqrtPriceX96After: out.sqrtPriceX96After,
|
|
393
|
-
initializedTicksCrossed: out.initializedTicksCrossed,
|
|
394
|
-
hops: route.fees.length,
|
|
395
|
-
// ЧИСЛО ШАГОВ ОТВЕТА СВЕРЯЕТСЯ С ЧИСЛОМ НОГ ПУТИ: если контракт вернул не по элементу на шаг, то это
|
|
396
|
-
// не тот ответ, который мы умеем читать, и лучше сказать это словами, чем показать число наугад.
|
|
397
|
-
shapeOk: out.sqrtPriceX96After.length === route.fees.length,
|
|
398
|
-
};
|
|
399
|
-
}
|
|
400
|
-
|
|
401
|
-
// ПОДПИСЬ ОБЪЯВЛЕННОГО МАРШРУТА: одна строка, по которой видно, ИЗ ЧЕГО он собран. Нужна для ключа
|
|
402
|
-
// кеша: если объявление поменяли (другая ступень, другой хаб), старый ответ на новый вопрос не годится.
|
|
403
|
-
export function routeSignature(route) {
|
|
404
|
-
if (!route || !Array.isArray(route.steps) || !route.steps.length) return route && route.unwrap ? "unwrap" : "none";
|
|
405
|
-
return route.steps.map((s) => String(s.to).toUpperCase() + ":" + Number(s.fee) + (s.pool ? ":" + String(s.pool).toLowerCase() : "")).join("+");
|
|
406
|
-
}
|
|
407
|
-
|
|
408
|
-
// ---------------------------------------------------------------------------
|
|
409
|
-
// ПРОВЕРКА ЦЕНЫ: СДЕЛКА НЕВЕРНАЯ ИЛИ НЕТ.
|
|
410
|
-
//
|
|
411
|
-
// Это НЕ оценка ликвидности - её, как и раньше, делает человек, и приговоров о глубине пулов здесь нет.
|
|
412
|
-
// Это проверка того же рода, что покрытие суммы: если цена не та, сделка не сойдётся, и она не должна
|
|
413
|
-
// начинаться. Признак неверной цены берётся ДВУМЯ способами, и ни одному из них не нужен фид на каждый
|
|
414
|
-
// токен (иначе USDe/USDS/PyUSD без своего фида сломали бы проверку вовсе):
|
|
415
|
-
//
|
|
416
|
-
// 1) ПОСЛЕДНИЙ ШАГ В НАТИВ. Из котировки выводится НЕЯВНАЯ цена натива в долларах (сколько входит в
|
|
417
|
-
// долларах / сколько натива выходит) и сравнивается с эталоном цены натива - тем самым Chainlink,
|
|
418
|
-
// который страница уже читает (evm/prices.js). Это работает для любого входа: у стейбла цена в
|
|
419
|
-
// долларах известна из реестра, у остальных маршрут всё равно заканчивается нативом;
|
|
420
|
-
// 2) ШАГ МЕЖДУ ДВУМЯ СТЕЙБЛАМИ (USDe→USDC, USDS→USDC, PyUSD→USDC): цена обязана быть около 1:1, и это
|
|
421
|
-
// проверяется САМОЙ КОТИРОВКОЙ, без оракула вообще. Абсурд вида "100 USDe дают 0.5 USDC" ловится
|
|
422
|
-
// именно здесь.
|
|
423
|
-
//
|
|
424
|
-
// ПОРОГИ ПРИХОДЯТ ДАННЫМИ (реестр: DEX_PRICE_GATE), а не лежат здесь числами: менять их - правка данных.
|
|
425
|
-
// ПРИЗНАК СЕТИ tolerated (реестр: dexPriceGate === "tolerated") ОТМЕНЯЕТ БЛОКИРОВКУ, НО НЕ ФАКТ: цена
|
|
426
|
-
// остаётся негодной и названа таковой (bad), а интерфейс обязан её показать. Так тестнет не встаёт на
|
|
427
|
-
// цене, которую мы не выбирали, и при этом не притворяется, что цена рыночная.
|
|
428
|
-
//
|
|
429
|
-
// Функция ЧИСТАЯ: ни одного вызова в сеть, только числа ноги и эталон. Поэтому её можно прогнать на
|
|
430
|
-
// любых числах без сети - чем и доказывается, что защита краснеет (tools/check-dex-gate.mjs).
|
|
431
|
-
// ---------------------------------------------------------------------------
|
|
432
|
-
export function priceGateVerdict({
|
|
433
|
-
amountInWei = null, amountOutWei = null, inDecimals = null, outDecimals = null,
|
|
434
|
-
tokenUsd = null, referenceUsd = null, stableSteps = [], tolerated = false, gates = null,
|
|
435
|
-
} = {}) {
|
|
436
|
-
const maxDev = gates && Number.isFinite(Number(gates.maxDeviationPct)) && Number(gates.maxDeviationPct) > 0 ? Number(gates.maxDeviationPct) : null;
|
|
437
|
-
const band = gates && Number.isFinite(Number(gates.stableBandPct)) && Number(gates.stableBandPct) >= 0 ? Number(gates.stableBandPct) : null;
|
|
438
|
-
const why = [];
|
|
439
|
-
|
|
440
|
-
// (1) ШАГИ МЕЖДУ СТЕЙБЛАМИ: около 1:1, без оракула.
|
|
441
|
-
const stable = (Array.isArray(stableSteps) ? stableSteps : []).map((s) => {
|
|
442
|
-
const row = {
|
|
443
|
-
from: s.from, to: s.to, fee: s.fee, feeLabel: feeTierLabel(s.fee),
|
|
444
|
-
inWei: s.inWei || null, outWei: s.outWei || null, exactAmount: Boolean(s.exact),
|
|
445
|
-
quoted: Boolean(s.ok), ok: false, ratio: null, deviationPct: null, why: null,
|
|
446
|
-
};
|
|
447
|
-
if (!s.ok) {
|
|
448
|
-
// ШАГ СТЕЙБЛ-СТЕЙБЛ НЕ ОТВЕТИЛ - И ЭТО НАЗВАНО, А НЕ ПРОПУЩЕНО: неизмеренная цена не проходит
|
|
449
|
-
// проверку, иначе "не проверили" выглядело бы как "цена в порядке".
|
|
450
|
-
row.why = "the 1:1 step could not be quoted (" + (s.why || "no answer") + ")";
|
|
451
|
-
why.push(row.why);
|
|
452
|
-
return row;
|
|
453
|
-
}
|
|
454
|
-
const inUnits = Number(s.inWei) / 10 ** Number(s.inDecimals);
|
|
455
|
-
const outUnits = Number(s.outWei) / 10 ** Number(s.outDecimals);
|
|
456
|
-
if (!(inUnits > 0) || !(outUnits > 0)) {
|
|
457
|
-
row.why = "the 1:1 step answered with a number that cannot be read";
|
|
458
|
-
why.push(row.why);
|
|
459
|
-
return row;
|
|
460
|
-
}
|
|
461
|
-
row.ratio = outUnits / inUnits;
|
|
462
|
-
row.deviationPct = Math.abs(1 - row.ratio) * 100;
|
|
463
|
-
if (band === null) { row.why = "the 1:1 band is not declared for this step, so this step cannot be checked"; why.push(row.why); return row; }
|
|
464
|
-
if (row.deviationPct > band) {
|
|
465
|
-
row.why = `${row.from}→${row.to} at ${row.feeLabel} pays ${row.ratio.toFixed(6)} ${row.to} per 1 ${row.from} - ${row.deviationPct.toFixed(2)}% off 1:1, and the band is ${band}%`;
|
|
466
|
-
why.push(row.why);
|
|
467
|
-
return row;
|
|
468
|
-
}
|
|
469
|
-
row.ok = true;
|
|
470
|
-
return row;
|
|
471
|
-
});
|
|
472
|
-
|
|
473
|
-
// (2) НЕЯВНАЯ ЦЕНА НАТИВА ПРОТИВ ЭТАЛОНА.
|
|
474
|
-
const inUnits = Number.isFinite(Number(inDecimals)) && Number(amountInWei) > 0 ? Number(amountInWei) / 10 ** Number(inDecimals) : null;
|
|
475
|
-
const outUnits = Number.isFinite(Number(outDecimals)) && Number(amountOutWei) > 0 ? Number(amountOutWei) / 10 ** Number(outDecimals) : null;
|
|
476
|
-
const valueUsd = inUnits !== null && Number(tokenUsd) > 0 ? inUnits * Number(tokenUsd) : null;
|
|
477
|
-
const impliedUsd = valueUsd !== null && outUnits !== null && outUnits > 0 ? valueUsd / outUnits : null;
|
|
478
|
-
const ref = Number(referenceUsd) > 0 ? Number(referenceUsd) : null;
|
|
479
|
-
const deviationPct = impliedUsd !== null && ref !== null ? Math.abs(impliedUsd - ref) / ref * 100 : null;
|
|
480
|
-
const nativeBad = deviationPct !== null && maxDev !== null && deviationPct > maxDev;
|
|
481
|
-
if (nativeBad) {
|
|
482
|
-
why.push(`the route implies the native coin at $${impliedUsd.toFixed(2)} while the reference price is $${ref.toFixed(2)} - ${deviationPct.toFixed(1)}% apart, and the limit is ${maxDev}%: this is not the market price`);
|
|
483
|
-
} else if (deviationPct === null) {
|
|
484
|
-
// НЕ ПРОВЕРЯЛИ - ЭТО НЕ "ВСЁ ХОРОШО". Причину называем ту, по которой проверить не вышло.
|
|
485
|
-
why.push(ref === null
|
|
486
|
-
? "the reference price of the native coin is not known, so the price cannot be checked"
|
|
487
|
-
: "the price of the token being paid is not known, so the price cannot be checked");
|
|
488
|
-
} else if (maxDev === null) {
|
|
489
|
-
why.push("the price limit is not declared for this route, so the price cannot be checked");
|
|
490
|
-
}
|
|
491
|
-
|
|
492
|
-
// ПРОВЕРКА СОСТОЯЛАСЬ - значит НАТИВНАЯ ЧАСТЬ состоялась (эталон есть, лимит объявлен). Шаги
|
|
493
|
-
// стейбл-стейбл не входят сюда намеренно: неизмеренный или вышедший из полосы шаг - это уже bad
|
|
494
|
-
// (см. выше), а не "не проверено".
|
|
495
|
-
const checked = deviationPct !== null && maxDev !== null;
|
|
496
|
-
const bad = nativeBad || stable.some((r) => !r.ok);
|
|
497
|
-
// БЛОКИРУЕТ ЛИ: негодная цена - да (кроме сети с признаком tolerated); непроверенная цена - тоже да,
|
|
498
|
-
// и по той же логике, по которой блокирует неизвестная требуемая сумма: "не проверили" нельзя
|
|
499
|
-
// показывать как "проверили и всё хорошо". На тестнете не блокирует ни то, ни другое.
|
|
500
|
-
const blocks = tolerated ? false : (bad || !checked);
|
|
501
|
-
return {
|
|
502
|
-
checked, bad, tolerated: Boolean(tolerated), blocks,
|
|
503
|
-
why: why.length ? why.join("; ") : null,
|
|
504
|
-
impliedUsd, referenceUsd: ref, deviationPct,
|
|
505
|
-
maxDeviationPct: maxDev, stableBandPct: band,
|
|
506
|
-
tokenUsd: Number(tokenUsd) > 0 ? Number(tokenUsd) : null,
|
|
507
|
-
stable,
|
|
508
|
-
};
|
|
509
|
-
}
|
|
510
|
-
|
|
511
|
-
// ---------------------------------------------------------------------------
|
|
512
|
-
// ПОЧЕМУ МАРШРУТА НЕТ - СЛОВАМИ, И ЭТИ СЛОВА - ИЗ ДАННЫХ. Причина лежит в самом объявлении (route.why):
|
|
513
|
-
// её писал человек, объявляя маршрут, и она подтверждена замером (tools/check-uniswap-quote.mjs - раздел
|
|
514
|
-
// "объявленные маршруты"). Одна и та же формулировка в интерфейсе и в отчёте: разойдясь, они начнут
|
|
515
|
-
// объяснять человеку одно, а оператору другое.
|
|
516
|
-
// Маршрута в реестре НЕТ ВОВСЕ (токен есть, объявления нет) - это ТОЖЕ честный ответ, а не "не нашли":
|
|
517
|
-
// значит, обход не объявляли, и подпись по нему не начинается.
|
|
518
|
-
export function noRouteWords({ paySymbol, wrappedSymbol, chainName, route = null }) {
|
|
519
|
-
// ТЕМПЛЕЙТ, А НЕ СКЛЕЙКА СТРОК: текст тот же, но подстроки `from "` в исходнике больше нет (склейка
|
|
520
|
-
// случайно читалась проверкой голых импортов пакета SDK как спецификатор). Правило то же, слова те же.
|
|
521
|
-
const head = `no route from ${paySymbol} to ${wrappedSymbol} on ${chainName}`;
|
|
522
|
-
const why = route && route.why ? String(route.why) : null;
|
|
523
|
-
// В ВИДИМОМ ТЕКСТЕ НЕТ НИ "registry", НИ "DEX_ROUTES": это имена НАШЕГО устройства, а не то, что
|
|
524
|
-
// человеку нужно понять. Смысл тот же: маршрут не ищут перебором, его объявляет человек.
|
|
525
|
-
return why ? head + ": " + why : head + ": no route for " + paySymbol + " is declared - routes are declared by hand, not searched";
|
|
526
|
-
}
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
// ---------------------------------------------------------------------------
|
|
530
|
-
// ВЕРДИКТ НОГИ: МОЖНО ЛИ ПО НЕЙ ПОДПИСЫВАТЬ (док 45). Две защиты ОДНОЙ СЕМЬИ, и обе БЛОКИРУЮТ подпись:
|
|
531
|
-
// * ПОКРЫТИЕ СУММЫ - маршрут обязан дать не меньше, чем требует ордер (сумма эскроу);
|
|
532
|
-
// * ЦЕНА - цена маршрута обязана быть рыночной (priceGateVerdict): неверная цена означает то же самое,
|
|
533
|
-
// что и недостача, - сделка не сойдётся.
|
|
534
|
-
// Отличается от наблюдений именно этим: "не покрывает" и "цена не та" - такие же ФАКТЫ, как "пула нет",
|
|
535
|
-
// и они обязаны останавливать человека ДО подписи, а не показываться строкой в разбивке цены. Отсюда
|
|
536
|
-
// правило: функция возвращает либо { blocked: false }, либо { blocked: true, kind, reason } - и
|
|
537
|
-
// вызывающий ОБЯЗАН считаться с blocked.
|
|
538
|
-
// Приговоров о ликвидности здесь нет: "не покрывает на 0.4%" - это число, а не оценка качества пула.
|
|
539
|
-
//
|
|
540
|
-
// kind: "unquoted" - котировки ещё нет вовсе (спрашивать нечего, ждать нельзя: блокируем)
|
|
541
|
-
// "no-route" - маршрута нет: причины лежат в leg.why (объявление реестра + отказ цепи)
|
|
542
|
-
// "price-unchecked" - цену проверить НЕЧЕМ (нет эталона цены натива): блокируем - "не
|
|
543
|
-
// проверили" нельзя показывать как "проверили и всё хорошо"
|
|
544
|
-
// "bad-price" - цена не рыночная: числа в verdict.price (блокирует на боевой сети)
|
|
545
|
-
// "unchecked" - требуемая сумма неизвестна (нет курса XMR/<натив>, чтобы её посчитать)
|
|
546
|
-
// "short" - маршрут есть, но выход МЕНЬШЕ требуемой суммы: вот насколько
|
|
547
|
-
// "bad-price-tolerated" - цена негодная, но у сети признак dexPriceGate: подпись НЕ блокируется,
|
|
548
|
-
// а негодность НАЗВАНА (тестнет: проверяем поток, а не рынок)
|
|
549
|
-
// "covers" - всё сходится: подпись разрешена (прочие защиты страницы в силе)
|
|
550
|
-
//
|
|
551
|
-
// ЧИСЛА ПОКРЫТИЯ ВОЗВРАЩАЮТСЯ ВСЕГДА, КОГДА ОНИ ИЗВЕСТНЫ (needWei/getsWei/shortfallWei/covers) - даже
|
|
552
|
-
// когда подпись заперта ценой: человеку должно быть видно, ИЗ ЧЕГО взялся вердикт, а не только слово
|
|
553
|
-
// "заблокировано".
|
|
554
|
-
//
|
|
555
|
-
// МИНИМУМ НА ВЫХОДЕ БУДУЩЕГО СВОПА БЕРЁТСЯ ОТСЮДА ЖЕ - из требуемой суммы эскроу, а не "на глазок"
|
|
556
|
-
// (docs 45). Поэтому недостача невозможна по построению, а излишек остаётся пользователю: НИКАКОЙ
|
|
557
|
-
// автоматической конвертации остатка в коде нет и не должно появиться - фондирование вносит ровно сумму
|
|
558
|
-
// ордера, всё прочее остаётся у человека.
|
|
559
|
-
export function dexLegVerdict(leg, requiredOutWei = null) {
|
|
560
|
-
if (leg && leg.unwrap) return { blocked: false, kind: "unwrap", reason: null, price: null };
|
|
561
|
-
if (!leg) return { blocked: true, kind: "unquoted", reason: "the swap leg has not been quoted yet", price: null };
|
|
562
|
-
if (!leg.ok) return { blocked: true, kind: "no-route", reason: leg.why || "no route", price: null };
|
|
563
|
-
// ЦЕНА ПРИХОДИТ ИЗ САМОЙ НОГИ: её считает quoteDexOut тем же ответом цепи, что и выход. Нога без
|
|
564
|
-
// проверки цены (её собрал кто-то руками) эту защиту не подтверждает - проверить её здесь нечем,
|
|
565
|
-
// поэтому и вердикта о цене у такой ноги нет.
|
|
566
|
-
const price = leg.price || null;
|
|
567
|
-
const priceBad = Boolean(price && price.bad);
|
|
568
|
-
const priceUnchecked = Boolean(price && price.checked === false);
|
|
569
|
-
const priceBlocks = Boolean(price && price.blocks && (priceBad || priceUnchecked));
|
|
570
|
-
// ТРЕБУЕМАЯ СУММА ПРИХОДИТ СЮДА АРГУМЕНТОМ, А НЕ БЕРЁТСЯ ИЗ САМОЙ НОГИ: котировка живёт в кеше 20 с, а
|
|
571
|
-
// размер ордера человек может поменять в любой момент. Возьми мы требуемую сумму из кешированного
|
|
572
|
-
// ответа - вердикт отставал бы от того, что человек видит в форме, и это ровно тот случай, когда
|
|
573
|
-
// защита "есть, но не срабатывает".
|
|
574
|
-
const need = requiredOutWei !== null && requiredOutWei !== undefined && Number(requiredOutWei) > 0 ? BigInt(requiredOutWei) : null;
|
|
575
|
-
const gets = BigInt(leg.amountOutWei);
|
|
576
|
-
const coverage = need === null ? null : {
|
|
577
|
-
needWei: need.toString(), getsWei: gets.toString(), covers: gets >= need,
|
|
578
|
-
shortfallWei: (gets >= need ? 0n : need - gets).toString(),
|
|
579
|
-
};
|
|
580
|
-
const base = {
|
|
581
|
-
...(coverage || {}),
|
|
582
|
-
price,
|
|
583
|
-
routeLabel: leg.routeLabel || null,
|
|
584
|
-
decimals: leg.wrapped ? leg.wrapped.decimals : null,
|
|
585
|
-
wrappedSymbol: leg.wrapped ? leg.wrapped.symbol : null,
|
|
586
|
-
};
|
|
587
|
-
// ПОРЯДОК ПРИЧИН: сначала "подписывать нельзя вообще" (маршрута нет - выше; цена непроверена; цена не
|
|
588
|
-
// та), потом "суммы не хватает", потом "всё сошлось". Он важен: при неверной цене вопрос о покрытии
|
|
589
|
-
// числам уже не верится.
|
|
590
|
-
if (priceBlocks) return { blocked: true, ...base, kind: priceBad ? "bad-price" : "price-unchecked", reason: price.why };
|
|
591
|
-
if (coverage === null) {
|
|
592
|
-
return { blocked: true, ...base, kind: "unchecked", reason: "the required escrow amount is not known yet, so coverage cannot be checked" };
|
|
593
|
-
}
|
|
594
|
-
if (!coverage.covers) return { blocked: true, ...base, kind: "short", reason: "the declared route returns less than the order needs" };
|
|
595
|
-
if (priceBad) return { blocked: false, ...base, kind: "bad-price-tolerated", reason: price.why };
|
|
596
|
-
return { blocked: false, ...base, kind: "covers", reason: null };
|
|
597
|
-
}
|
|
598
|
-
|
|
599
|
-
// ---------------------------------------------------------------------------
|
|
600
|
-
// Главный вход для страницы: сколько натива даст оплата этим токеном ПО ОБЪЯВЛЕННОМУ МАРШРУТУ.
|
|
601
|
-
//
|
|
602
|
-
// token - токен оплаты из реестра (адрес, decimals и цена)
|
|
603
|
-
// amountWei - сумма оплаты в его минимальных единицах
|
|
604
|
-
// requiredOutWei - СКОЛЬКО НАТИВА ТРЕБУЕТ ОРДЕР (сумма эскроу на этот размер входа). Это НЕ
|
|
605
|
-
// наблюдение, а условие выполнимости: по нему считается вердикт "покрывает /
|
|
606
|
-
// не покрывает", и "не покрывает" блокирует подпись (см. dexLegVerdict).
|
|
607
|
-
// route - ОБЪЯВЛЕННЫЙ маршрут из реестра (DEX_ROUTES[chainId][symbol]). Выбора здесь нет
|
|
608
|
-
// и быть не должно: что объявлено - то и котируется, ОДНИМ вызовом. Объявления
|
|
609
|
-
// нет или в нём пусто - это честное "маршрута нет" (слова - из route.why), и
|
|
610
|
-
// подпись по нему не начинается.
|
|
611
|
-
// tokens - токены этой сети: по символу из шага берутся адрес и decimals
|
|
612
|
-
// referenceNativeUsd - ЭТАЛОН цены натива в долларах (Chainlink у страницы). Без него проверка цены
|
|
613
|
-
// не состоится, и это не повод её пропустить (см. priceGateVerdict).
|
|
614
|
-
// gates - пороги проверки цены из реестра (DEX_PRICE_GATE)
|
|
615
|
-
//
|
|
616
|
-
// ХАБОВ ЗДЕСЬ БОЛЬШЕ НЕТ: обход через USDC/USDT - это тоже объявленный маршрут, а не поиск в рантайме.
|
|
617
|
-
//
|
|
618
|
-
// Возвращает ЛИБО { ok: true, ... } с числами из цепи, ЛИБО { ok: false, why } словами. Числа и
|
|
619
|
-
// "почему нет" вместе не возвращаются: показать человеку и то и другое - значит предложить ему
|
|
620
|
-
// догадаться, какому из двух ответов верить.
|
|
621
|
-
// ---------------------------------------------------------------------------
|
|
622
|
-
export async function quoteDexOut(chain, { token, amountWei, requiredOutWei = null, route = null, tokens = [], referenceNativeUsd = null, gates = null } = {}, opts = {}) {
|
|
623
|
-
const u = chain && chain.uniswap;
|
|
624
|
-
if (!token || !token.address) return { ok: false, why: token && token.native ? "paying the native coin needs no DEX leg" : "no token address" };
|
|
625
|
-
if (!u || !u.quoter || !u.wrapped) return { ok: false, why: "no verified Uniswap addresses for " + (chain && chain.name ? chain.name : "this network") };
|
|
626
|
-
const amount = BigInt(amountWei || 0);
|
|
627
|
-
if (amount <= 0n) return { ok: false, why: "amount not entered" };
|
|
628
|
-
const wrapped = u.wrapped;
|
|
629
|
-
const chainName = (chain && chain.name) || (chain && chain.id) || "this network";
|
|
630
|
-
if (String(token.address).toLowerCase() === String(wrapped.address).toLowerCase()) {
|
|
631
|
-
// Обёрнутый нативный токен меняется на натив НЕ в пуле: это withdraw у WETH9, один к одному.
|
|
632
|
-
return { ok: false, why: "this is the wrapped native coin: no pool needed, it unwraps 1:1", unwrap: true };
|
|
633
|
-
}
|
|
634
|
-
const declared = route || null;
|
|
635
|
-
// ПРИЗНАК СЕТИ: негодная цена на тестнете не блокирует (реестр: dexPriceGate у сети). Отсутствие
|
|
636
|
-
// признака - блокирует, и это умолчание: молчаливое "сойдёт" на боевой сети недопустимо.
|
|
637
|
-
const tolerated = String((chain && chain.dexPriceGate) || "") === "tolerated";
|
|
638
|
-
// ОБЪЯВЛЕНИЕ, КОТОРЫМ ПОЛЬЗУЕМСЯ, ВОЗВРАЩАЕТСЯ В ОТВЕТЕ ЦЕЛИКОМ (со ступенями, пулами и датой замера):
|
|
639
|
-
// интерфейс и отчёт обязаны называть не только ЧТО получилось, но и ПО ЧЕМУ это объявлено.
|
|
640
|
-
const declaration = declared
|
|
641
|
-
? {
|
|
642
|
-
steps: (Array.isArray(declared.steps) ? declared.steps : []).map((s) => ({ to: s.to, fee: s.fee, pool: s.pool || null, verified: s.verified || null })),
|
|
643
|
-
why: declared.why || null, unwrap: Boolean(declared.unwrap),
|
|
644
|
-
}
|
|
645
|
-
: null;
|
|
646
|
-
// (1) МАРШРУТА НЕТ - ЭТО ОТВЕТ ИЗ ДАННЫХ, А НЕ ОШИБКА. Пустой steps значит "объявленного маршрута нет":
|
|
647
|
-
// почему его нет - в route.why, и эти слова уходят и в интерфейс, и в отчёт (одни и те же).
|
|
648
|
-
if (!declared || !Array.isArray(declared.steps) || !declared.steps.length) {
|
|
649
|
-
if (declared && declared.unwrap) return { ok: false, why: "this is the wrapped native coin: no pool needed, it unwraps 1:1", unwrap: true };
|
|
650
|
-
return { ok: false, why: noRouteWords({ paySymbol: token.symbol, wrappedSymbol: wrapped.symbol, chainName, route: declared }), declared: true, declaration, steps: [] };
|
|
651
|
-
}
|
|
652
|
-
// (2) ПУТЬ ИЗ ОБЪЯВЛЕННЫХ ШАГОВ. Не собрать объявленное (шаг называет токен, которого в этой сети нет) -
|
|
653
|
-
// это ошибка В ДАННЫХ, и называть её надо иначе, чем "маршрута нет": по ней видно, что правки ждёт реестр.
|
|
654
|
-
const path = declaredRoutePath({ token, tokens, wrapped, route: declared });
|
|
655
|
-
if (!path || path.error) {
|
|
656
|
-
return {
|
|
657
|
-
ok: false, declared: true, dataError: true, declaration,
|
|
658
|
-
why: "the declared route for " + token.symbol + " on " + chainName + " cannot be built: " + ((path && path.error) || "unknown reason"),
|
|
659
|
-
};
|
|
660
|
-
}
|
|
661
|
-
const byAddr = new Map((Array.isArray(tokens) ? tokens : []).filter((t) => t && t.address).map((t) => [String(t.address).toLowerCase(), t]));
|
|
662
|
-
const dec = (addr) => {
|
|
663
|
-
if (String(addr).toLowerCase() === String(token.address).toLowerCase()) return token.decimals;
|
|
664
|
-
if (String(addr).toLowerCase() === String(wrapped.address).toLowerCase()) return wrapped.decimals;
|
|
665
|
-
const h = byAddr.get(String(addr).toLowerCase());
|
|
666
|
-
return h && Number.isFinite(Number(h.decimals)) ? Number(h.decimals) : null;
|
|
667
|
-
};
|
|
668
|
-
// (3) КЕШ НА 20 С ПО СЕТИ, ТОКЕНУ, СУММЕ И ПОДПИСИ ОБЪЯВЛЕНИЯ. Сменив объявление, мы спрашиваем ДРУГОЙ
|
|
669
|
-
// маршрут, и старый ответ на новый вопрос не годится. Требуемая сумма в ключ НЕ входит: она меняется от
|
|
670
|
-
// размера ордера, а покрытие и цена считаются ниже - каждый раз, из свежих чисел.
|
|
671
|
-
const key = [chain.id, token.symbol, amount.toString(), routeSignature(declared)].join("|");
|
|
672
|
-
let base = cached(key);
|
|
673
|
-
if (base === undefined) {
|
|
674
|
-
// ОДИН ВЫЗОВ НА ВЕСЬ ПУТЬ. Цепочки "котировка шага 1, потом шаг 2 от её выхода" здесь нет и быть не
|
|
675
|
-
// должно: такая цепочка считает оба шага по одному состоянию пулов и даёт оптимистично неверное число.
|
|
676
|
-
const quote = await quotePath(chain, { route: { tokens: path.tokens, fees: path.fees, symbols: path.symbols, via: null }, amountInWei: amount }, opts);
|
|
677
|
-
if (!quote.ok) return put(key, { ok: false, why: quote.why, declared: true });
|
|
678
|
-
base = { ok: true, quote };
|
|
679
|
-
put(key, base);
|
|
680
|
-
}
|
|
681
|
-
if (!base.ok) return { ...base, declaration };
|
|
682
|
-
const q = base.quote;
|
|
683
|
-
// ПУЛЫ МАРШРУТА ЧИТАЮТСЯ ВСЕГДА, КОГДА КОТИРОВКА ЕСТЬ - по одной записи на КАЖДУЮ ногу. Это ФАКТ,
|
|
684
|
-
// который человеку нужен рядом с числом: "маршрут ответил столько-то" само по себе не говорит, много
|
|
685
|
-
// ли лежит в пулах на его пути. Запасы в wei БЕЗ decimals человеку не читаются, поэтому decimals
|
|
686
|
-
// едут рядом - подставлять 18 "по похожести" значило бы однажды показать 6-значный токен как 18-значный.
|
|
687
|
-
if (base.hops === undefined) {
|
|
688
|
-
const hops = [];
|
|
689
|
-
for (let i = 0; i < path.fees.length; i++) {
|
|
690
|
-
const fee = path.fees[i];
|
|
691
|
-
const from = path.tokens[i], to = path.tokens[i + 1];
|
|
692
|
-
const declaredStep = (declared.steps && declared.steps[i]) || {};
|
|
693
|
-
const poolRes = await poolAddress(chain, { tokenIn: from, tokenOut: to, fee }, opts);
|
|
694
|
-
const pool = poolRes.ok ? poolRes.pool : null;
|
|
695
|
-
const declaredPool = declaredStep.pool || null;
|
|
696
|
-
hops.push({
|
|
697
|
-
step: i + 1, from: path.symbols[i], to: path.symbols[i + 1], fee, feeLabel: feeTierLabel(fee),
|
|
698
|
-
decimals: [dec(from), dec(to)],
|
|
699
|
-
pool,
|
|
700
|
-
// СВЕРКА ОБЪЯВЛЕННОГО АДРЕСА ПУЛА С ЦЕПЬЮ. Если фабрика отвечает другим пулом, объявление
|
|
701
|
-
// устарело: в отчёте инструмента это провал, здесь - факт рядом с числом (решает человек).
|
|
702
|
-
declaredPool,
|
|
703
|
-
poolMatchesDeclared: pool && declaredPool ? String(pool).toLowerCase() === String(declaredPool).toLowerCase() : null,
|
|
704
|
-
verified: declaredStep.verified || null,
|
|
705
|
-
// СОСТОЯНИЕ ПУЛА ЗДЕСЬ БОЛЬШЕ НЕ ЧИТАЕТСЯ. Три вызова eth_call на каждую ногу (liquidity(),
|
|
706
|
-
// balanceOf(tokenIn), balanceOf(tokenOut)) собирались ради одной строки в интерфейсе - liquidity,
|
|
707
|
-
// резервы пула, - и та убрана: после ссылки на пул человек смотрит глубину и состояние в обозревателе.
|
|
708
|
-
// Сама функция poolState остаётся: её зовёт отчёт инструмента (tools/check-uniswap-quote.mjs), и там
|
|
709
|
-
// эти числа нужны оператору. Здесь же они не нужны никому, а страница спрашивает их на каждом обновлении.
|
|
710
|
-
});
|
|
711
|
-
}
|
|
712
|
-
base.hops = hops;
|
|
713
|
-
}
|
|
714
|
-
const hops = base.hops;
|
|
715
|
-
// ШАГИ МЕЖДУ ДВУМЯ СТЕЙБЛАМИ ПРОВЕРЯЮТСЯ САМОЙ КОТИРОВКОЙ (около 1:1, без оракула вовсе). Сумма шага -
|
|
716
|
-
// сумма маршрута: у объявленных маршрутов такой шаг идёт ПЕРВЫМ, поэтому для него это ровно его сумма
|
|
717
|
-
// (и это отмечено полем exact у каждой строки).
|
|
718
|
-
if (base.stableSteps === undefined) {
|
|
719
|
-
const stables = (gates && Array.isArray(gates.stables) ? gates.stables : []).map((s) => String(s).toUpperCase());
|
|
720
|
-
const steps = [];
|
|
721
|
-
for (let i = 0; i < path.fees.length; i++) {
|
|
722
|
-
const fromSym = String(path.symbols[i]).toUpperCase(), toSym = String(path.symbols[i + 1]).toUpperCase();
|
|
723
|
-
if (!stables.includes(fromSym) || !stables.includes(toSym)) continue;
|
|
724
|
-
const stepQuote = await quoteTier(chain, { tokenIn: path.tokens[i], tokenOut: path.tokens[i + 1], amountInWei: amount, fee: path.fees[i] }, opts);
|
|
725
|
-
steps.push({
|
|
726
|
-
from: path.symbols[i], to: path.symbols[i + 1], fee: path.fees[i],
|
|
727
|
-
inWei: amount.toString(), outWei: stepQuote.ok ? stepQuote.amountOutWei : null,
|
|
728
|
-
inDecimals: dec(path.tokens[i]), outDecimals: dec(path.tokens[i + 1]),
|
|
729
|
-
ok: Boolean(stepQuote.ok), why: stepQuote.ok ? null : stepQuote.why, exact: i === 0,
|
|
730
|
-
});
|
|
731
|
-
}
|
|
732
|
-
base.stableSteps = steps;
|
|
733
|
-
}
|
|
734
|
-
// ПРОВЕРКА ЦЕНЫ. Считается КАЖДЫЙ РАЗ, а не кешируется вместе с котировкой: эталон цены натива
|
|
735
|
-
// подгружается отдельно и может появиться позже, чем ответ пула - ровно та же причина, по которой
|
|
736
|
-
// здесь не кешируется и вердикт покрытия.
|
|
737
|
-
const price = priceGateVerdict({
|
|
738
|
-
amountInWei: amount, amountOutWei: q.amountOutWei,
|
|
739
|
-
inDecimals: token.decimals, outDecimals: wrapped.decimals,
|
|
740
|
-
tokenUsd: Number(token.usd) > 0 ? Number(token.usd) : null,
|
|
741
|
-
referenceUsd: referenceNativeUsd,
|
|
742
|
-
stableSteps: base.stableSteps, tolerated, gates,
|
|
743
|
-
});
|
|
744
|
-
// ПРЕДПОЧТИТЕЛЬНАЯ ФОРМА: "получить РОВНО требуемую сумму, потратив не больше введённого" (док 45).
|
|
745
|
-
// Спрашивается тем же путём-победителем, но В ОБРАТНУЮ СТОРОНУ - контракт требует именно так. Ответ
|
|
746
|
-
// важен не только числом: он снимает вопрос о разнице сумм, потому что минимум на выходе становится
|
|
747
|
-
// равен требуемой сумме по построению. Отказ здесь - тоже ответ: значит ровно столько пул не отдаёт,
|
|
748
|
-
// и тогда остаётся обычная форма на вход с минимумом от требуемой суммы.
|
|
749
|
-
let exactOut = null;
|
|
750
|
-
if (requiredOutWei !== null && requiredOutWei !== undefined && Number(requiredOutWei) > 0) {
|
|
751
|
-
const eo = await quoteExactOutPath(chain, { route: q, amountOutWei: BigInt(requiredOutWei) }, opts);
|
|
752
|
-
exactOut = eo.ok
|
|
753
|
-
? {
|
|
754
|
-
ok: true, amountInWei: eo.amountInWei, gasEstimate: eo.gasEstimate,
|
|
755
|
-
// ХВАТИТ ЛИ ВВЕДЁННОГО: сравнивается сумма на входе, потому что у этой формы ответ - именно
|
|
756
|
-
// "сколько надо отдать". Излишек остаётся пользователю: никакой конвертации остатка в коде нет.
|
|
757
|
-
enough: BigInt(eo.amountInWei) <= amount,
|
|
758
|
-
enteredWei: amount.toString(),
|
|
759
|
-
spareInWei: (BigInt(eo.amountInWei) <= amount ? amount - BigInt(eo.amountInWei) : 0n).toString(),
|
|
760
|
-
shortInWei: (BigInt(eo.amountInWei) <= amount ? 0n : BigInt(eo.amountInWei) - amount).toString(),
|
|
761
|
-
}
|
|
762
|
-
: { ok: false, why: eo.why };
|
|
763
|
-
}
|
|
764
|
-
// ПОКРЫТИЕ СЧИТАЕТСЯ ЗДЕСЬ, А НЕ БЕРЁТСЯ ИЗ КЕША: пул и сумма входа кешируются на 20 с, а требуемая
|
|
765
|
-
// сумма приходит из ордера и может смениться (человек поправил размер XMR) - и тогда покрытие обязано
|
|
766
|
-
// пересчитаться, а не остаться прежним. Считается в целых wei.
|
|
767
|
-
const required = requiredOutWei !== null && requiredOutWei !== undefined && Number(requiredOutWei) > 0
|
|
768
|
-
? (() => {
|
|
769
|
-
const need = BigInt(requiredOutWei);
|
|
770
|
-
const gets = BigInt(q.amountOutWei);
|
|
771
|
-
return {
|
|
772
|
-
outWei: need.toString(),
|
|
773
|
-
getsWei: gets.toString(),
|
|
774
|
-
covers: gets >= need,
|
|
775
|
-
shortfallWei: (gets >= need ? 0n : need - gets).toString(),
|
|
776
|
-
};
|
|
777
|
-
})()
|
|
778
|
-
: null;
|
|
779
|
-
return {
|
|
780
|
-
ok: true,
|
|
781
|
-
leg: "pool",
|
|
782
|
-
venue: "Uniswap v3",
|
|
783
|
-
payToken: token.symbol,
|
|
784
|
-
wrapped,
|
|
785
|
-
// ОБЪЯВЛЕННЫЙ МАРШРУТ, КОТОРЫМ СПРОШЕНА ЦЕНА: ступени, их пулы и дата замера - целиком, как в реестре.
|
|
786
|
-
// Интерфейс обязан показывать не только ЧТО получилось, но и ПО ЧЕМУ это объявлено.
|
|
787
|
-
declaredRoute: declaration,
|
|
788
|
-
// ПУТЬ - ЭТО ШАГИ С КОМИССИЯМИ, а не одна ступень: у объявленного обхода через стейбл их две.
|
|
789
|
-
route: { symbols: q.symbols.slice(), fees: q.fees.slice(), pools: path.pools.slice(), via: null, hops: q.fees.length },
|
|
790
|
-
routeLabel: routeLabel(q),
|
|
791
|
-
steps: routeSteps(q),
|
|
792
|
-
fee: q.fees[q.fees.length - 1],
|
|
793
|
-
feeLabel: q.fees.map((f) => feeTierLabel(f)).join(" / "),
|
|
794
|
-
amountInWei: amount.toString(),
|
|
795
|
-
amountOutWei: q.amountOutWei,
|
|
796
|
-
gasEstimate: q.gasEstimate,
|
|
797
|
-
// ВЕРДИКТ ВЫПОЛНИМОСТИ: покрывает ли маршрут требуемую сумму ордера. null означает "требуемая сумма
|
|
798
|
-
// не передана" - и тогда подпись блокируется как непроверенная (см. dexLegVerdict), а не разрешается.
|
|
799
|
-
required,
|
|
800
|
-
// ПРОВЕРКА ЦЕНЫ: проверили ли, похожа ли цена на рыночную, и блокирует ли это подпись на этой сети.
|
|
801
|
-
price,
|
|
802
|
-
// ПРЕДПОЧТИТЕЛЬНАЯ ФОРМА (точный выход): сколько надо отдать, чтобы получить ровно требуемую сумму.
|
|
803
|
-
// Вопрос о разнице сумм при ней не встаёт - минимум на выходе равен требуемому по построению.
|
|
804
|
-
exactOut,
|
|
805
|
-
hopsDetail: hops,
|
|
806
|
-
// Одиночная ступень (прежняя форма ответа) - из первой ноги пути. Читать их как "пул всего маршрута"
|
|
807
|
-
// нельзя, и это сказано тут: у обхода через хаб ног две.
|
|
808
|
-
pool: hops.length ? hops[0].pool : null,
|
|
809
|
-
at: Date.now(),
|
|
810
|
-
};
|
|
811
|
-
}
|
|
812
|
-
|
|
813
|
-
// ---------------------------------------------------------------------------
|
|
814
|
-
// WETH -> ETH: unwrap у самого WETH9, один к одному, БЕЗ пула.
|
|
815
|
-
//
|
|
816
|
-
// Отсюда берётся описание строки "unwrap, 1:1". Оценка газа СЮДА НЕ ВХОДИТ: чтобы назвать газ unwrap,
|
|
817
|
-
// нужен счёт, который держит WETH и может платить за газ, а у страницы такого счёта нет (и отправлять
|
|
818
|
-
// ничего нельзя на этом шаге). Измеренный газ unwrap лежит в отчёте инструмента
|
|
819
|
-
// (tools/check-uniswap-quote.mjs: газ измеряется eth_estimateGas на настоящем держателе WETH, которого
|
|
820
|
-
// инструмент находит по журналам Transfer). Подставлять это число в интерфейс как "газ этой сделки"
|
|
821
|
-
// было бы подменой: оно измерено на чужом счёте и на другой сумме. Поэтому честно: gasEstimate === null.
|
|
822
|
-
// ---------------------------------------------------------------------------
|
|
823
|
-
export function unwrapLeg(chain) {
|
|
824
|
-
const u = chain && chain.uniswap;
|
|
825
|
-
if (!u || !u.wrapped) return { ok: false, why: "no verified wrapped native coin for this network" };
|
|
826
|
-
const w = u.wrapped;
|
|
827
|
-
return {
|
|
828
|
-
ok: true,
|
|
829
|
-
leg: "unwrap",
|
|
830
|
-
venue: w.name || w.symbol,
|
|
831
|
-
wrapped: w,
|
|
832
|
-
rate: 1, // один к одному по определению WETH9: withdraw(wad) отдаёт ровно wad нативного
|
|
833
|
-
gasEstimate: null,
|
|
834
|
-
why: null,
|
|
835
|
-
};
|
|
836
|
-
}
|
|
1
|
+
// GENERATED FILE - a byte-for-byte copy of the engine module www/js/evm/dex.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
|
+
// DEX-нога: КОТИРОВКА Uniswap v3, полученная из цепи. ТОЛЬКО ЧТЕНИЕ: eth_call, ни одной транзакции.
|
|
7
|
+
//
|
|
8
|
+
// ЧТО ЗДЕСЬ ЗАМЕНЕНО. Раньше в разбивке цены стоял мок: "Uniswap v3 USDC/WETH 0.05%" и спред 0.0004
|
|
9
|
+
// брались из констант конфига (www/js/mock/market.js:76-77), то есть комиссия пула и выход были
|
|
10
|
+
// ВЫДУМАНЫ. Теперь и то и другое читается у самой цепи через QuoterV2 - тот же путь, которым страница
|
|
11
|
+
// уже читает цену и газ (evm/prices.js): POST к chain.rpcUrl, никаких новых доменов, никаких новых
|
|
12
|
+
// параметров в URL и никаких новых внешних адресов. Хост уже разрешён в CSP - он берётся из реестра сетей.
|
|
13
|
+
//
|
|
14
|
+
// СЕЛЕКТОРЫ И СТРУКТУРА АРГУМЕНТОВ ВЗЯТЫ ИЗ ВЫЗОВА, А НЕ ИЗ ПАМЯТИ (проверено 2026-09-23):
|
|
15
|
+
// * selector = keccak256(подпись)[0..4]. Считается в тесте (tools/check-uniswap-quote.mjs) из строки
|
|
16
|
+
// подписи, лежащей здесь рядом с селектором, и наличие селектора сверяется в КОДЕ контракта.
|
|
17
|
+
// * АРГУМЕНТЫ ИДУТ ПЯТЬЮ СЛОВАМИ ПОДРЯД, БЕЗ СЛОВА-СМЕЩЕНИЯ. Подпись формально принимает структуру
|
|
18
|
+
// (address,address,uint256,uint24,uint160), и по спецификации ABI первым словом должно идти
|
|
19
|
+
// смещение 0x20. НА РАЗВЁРНУТОМ QuoterV2 ЭТО ЛОЖЬ: вариант со смещением ОТКАТЫВАЕТ (execution
|
|
20
|
+
// reverted), а пять слов подряд отвечают четырьмя словами (amountOut, sqrtPriceX96After,
|
|
21
|
+
// initializedTicksCrossed, gasEstimate). Поэтому кодируем подряд - и это записано здесь, чтобы
|
|
22
|
+
// никто не "починил" код по спецификации и не сломал котировку молча.
|
|
23
|
+
//
|
|
24
|
+
// ЧЕГО МОДУЛЬ НЕ ДЕЛАЕТ: не отправляет транзакции, не делает approve, не свопает и НЕ трогает путь
|
|
25
|
+
// фондирования. Оценка газа здесь - это gasEstimate, который вернул сам QuoterV2 (число из цепи), а не
|
|
26
|
+
// eth_estimateGas по чужой транзакции.
|
|
27
|
+
//
|
|
28
|
+
// ЧЕСТНОЕ "НЕТ КОТИРОВКИ" ВМЕСТО ВЫДУМАННОГО ЧИСЛА. Пустой пул, отсутствие пула, откат котировки и
|
|
29
|
+
// цена, разошедшаяся с эталоном, - ЧЕТЫРЕ РАЗНЫХ ответа, и каждый назван словами (why). Число
|
|
30
|
+
// возвращается только тогда, когда его действительно назвала цепь и оно прошло сверку с эталоном.
|
|
31
|
+
//
|
|
32
|
+
// МАРШРУТ ЗДЕСЬ ОБЪЯВЛЕН, А НЕ ВЫБРАН. Перебора "прямой путь или через хаб" в рантайме нет и быть не
|
|
33
|
+
// должно: человек объявил маршрут в реестре (DEX_ROUTES), а модуль только переводит его в путь и
|
|
34
|
+
// спрашивает цепь ОДНИМ вызовом. Если объявленного маршрута нет (или в нём пусто) - это честное
|
|
35
|
+
// "маршрута нет", причина лежит в данных (route.why), и такая нога блокирует подпись, а не ищет обход.
|
|
36
|
+
//
|
|
37
|
+
// ПРОВЕРКА ЦЕНЫ (priceGateVerdict) - ВТОРАЯ ЗАЩИТА ТОЙ ЖЕ СЕМЬИ, что покрытие суммы: неверная цена
|
|
38
|
+
// означает, что сделка не сойдётся, и подпись не начинается. Считается двумя способами, ни один из
|
|
39
|
+
// которых не требует фида на каждый токен: неявная цена натива из котировки против эталона (Chainlink)
|
|
40
|
+
// и полоса "около 1:1" на шагах между стейблами (без оракула вовсе). Пороги - в реестре
|
|
41
|
+
// (DEX_PRICE_GATE), исключение для тестнета - тоже данные (dexPriceGate у сети): там цена негодная по
|
|
42
|
+
// природе, и негодная цена помечается словами, а не останавливает поток. Оценок ликвидности здесь
|
|
43
|
+
// по-прежнему нет: глубину пула решает человек.
|
|
44
|
+
|
|
45
|
+
// Селекторы. Рядом с каждым - строка подписи, из которой он посчитан: проверка
|
|
46
|
+
// (tools/check-uniswap-quote.mjs) пересчитывает keccak256 от этих строк и падает, если хоть один
|
|
47
|
+
// селектор разошёлся с подписью.
|
|
48
|
+
// ЧТО ЗДЕСЬ ПРОВЕРЕНО ВЫЗОВОМ (2026-09-23, Arbitrum One и Ethereum, tools/check-uniswap-quote.mjs):
|
|
49
|
+
// * quoteExactInputSingle 0xc6a5026a - котировка ОДНОЙ ступени, пять слов подряд без смещения;
|
|
50
|
+
// * quoteExactInput 0xcdca1753 - котировка ВСЕГО ПУТИ ОДНИМ ВЫЗОВОМ. Подпись
|
|
51
|
+
// quoteExactInput(bytes,uint256), селектор найден в коде QuoterV2 в обеих сетях, а сверка
|
|
52
|
+
// одноступенчатого пути против одиночной котировки дала РОВНО то же число (36685744820778786 на
|
|
53
|
+
// 100 USDC), то есть кодирование пути (20 байт адрес + 3 байта комиссия, подряд) подтверждено числом,
|
|
54
|
+
// а не по памяти. Эталонного значения нет - есть совпадение двух РАЗНЫХ вызовов.
|
|
55
|
+
export const DEX_METHOD = {
|
|
56
|
+
// ФОРМА С ТОЧНЫМ ВЫХОДОМ. Это НЕ "сколько выйдет", а "получить ровно столько, потратив не больше
|
|
57
|
+
// такого-то": при ней вопроса о разнице сумм не существует вовсе (док 45 §"Что добавит DEX-нога").
|
|
58
|
+
// ОБЕ формы подтверждены вызовом на Arbitrum One и основной сети (tools/check-uniswap-quote.mjs):
|
|
59
|
+
// - одиночная даёт amountIn на ровно 0.035 WETH;
|
|
60
|
+
// - многошаговая по пути из двух ног тоже отвечает, но ПУТЬ У НЕЁ ЗАДАЁТСЯ В ОБРАТНУЮ СТОРОНУ - от
|
|
61
|
+
// получаемого токена к оплачиваемому (прямой путь отказывает: "Unexpected error"). Это измерено, а
|
|
62
|
+
// не выведено: перепутать направление здесь стоит отказа вызова.
|
|
63
|
+
quoteExactOutputSingle: { hex: "0xbd21704a", signature: "quoteExactOutputSingle((address,address,uint256,uint24,uint160))" },
|
|
64
|
+
quoteExactOutput: { hex: "0x2f80bb1d", signature: "quoteExactOutput(bytes,uint256)" },
|
|
65
|
+
quoteExactInputSingle: { hex: "0xc6a5026a", signature: "quoteExactInputSingle((address,address,uint256,uint24,uint160))" },
|
|
66
|
+
// Многошаговая форма: путь передаётся УПАКОВАННЫМ (token|fee|token|fee|token), аргумент динамический,
|
|
67
|
+
// поэтому здесь смещение 0x40 (см. encodeQuoteExactInput), в отличие от одиночной формы.
|
|
68
|
+
quoteExactInput: { hex: "0xcdca1753", signature: "quoteExactInput(bytes,uint256)" },
|
|
69
|
+
getPool: { hex: "0x1698ee82", signature: "getPool(address,address,uint24)" },
|
|
70
|
+
liquidity: { hex: "0x1a686502", signature: "liquidity()" },
|
|
71
|
+
balanceOf: { hex: "0x70a08231", signature: "balanceOf(address)" },
|
|
72
|
+
// withdraw у WETH9: НЕ для отправки, только для оценки газа unwrap (и только если у нас есть
|
|
73
|
+
// измеренный держатель WETH - см. tools/check-uniswap-quote.mjs).
|
|
74
|
+
withdraw: { hex: "0x2e1a7d4d", signature: "withdraw(uint256)" },
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
// ПУТЬ ИЗ ОБЪЯВЛЕННОГО МАРШРУТА (DEX_ROUTES в реестре). Здесь НЕТ никакого выбора: человек объявил шаги
|
|
78
|
+
// со ступенями, а мы только переводим символы в адреса. Обёрнутый нативный токен берётся из данных сети
|
|
79
|
+
// (u.wrapped), а не пишется строкой "WETH": у сети на BNB обёрнутый натив - WBNB.
|
|
80
|
+
// Возвращает null и причину, если объявленный маршрут собрать нельзя (шаг назван токеном, которого в
|
|
81
|
+
// этой сети нет) - это НЕ "маршрута нет", а ошибка в данных, и называть её надо иначе.
|
|
82
|
+
export function declaredRoutePath({ token, tokens = [], wrapped, route }) {
|
|
83
|
+
if (!token || !route || !Array.isArray(route.steps) || !route.steps.length) return null;
|
|
84
|
+
const bySym = new Map((tokens || []).map((t) => [String(t.symbol).toUpperCase(), t]));
|
|
85
|
+
const addrOf = (sym) => {
|
|
86
|
+
const s = String(sym).toUpperCase();
|
|
87
|
+
if (wrapped && s === String(wrapped.symbol).toUpperCase()) return wrapped.address;
|
|
88
|
+
const t = bySym.get(s);
|
|
89
|
+
return t && t.address ? t.address : null;
|
|
90
|
+
};
|
|
91
|
+
const symbols = [token.symbol];
|
|
92
|
+
const addresses = [token.address];
|
|
93
|
+
const fees = [];
|
|
94
|
+
const pools = [];
|
|
95
|
+
for (const step of route.steps) {
|
|
96
|
+
const a = addrOf(step.to);
|
|
97
|
+
if (!a) return { error: `declared route names a token that this network has no address for: ${step.to}` };
|
|
98
|
+
addresses.push(a);
|
|
99
|
+
symbols.push(String(step.to).toUpperCase() === String(wrapped && wrapped.symbol).toUpperCase() ? wrapped.symbol : step.to);
|
|
100
|
+
fees.push(Number(step.fee));
|
|
101
|
+
pools.push(step.pool || null);
|
|
102
|
+
}
|
|
103
|
+
return { tokens: addresses, symbols, fees, pools };
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// Путь одной строкой с комиссиями ступеней: "USDe → USDC 0.01% → WETH 0.05%". Одна и та же запись в
|
|
107
|
+
// интерфейсе и в отчёте - разойдясь, они начнут объяснять человеку одно, а оператору другое.
|
|
108
|
+
export function routeLabel(route) {
|
|
109
|
+
if (!route || !route.symbols) return "";
|
|
110
|
+
let s = route.symbols[0];
|
|
111
|
+
for (let i = 0; i < route.fees.length; i++) s += " → " + route.symbols[i + 1] + " " + feeTierLabel(route.fees[i]);
|
|
112
|
+
return s;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// Разбор пути на шаги: [{from, to, fee, feeLabel}] - для пошаговой строки в интерфейсе и в отчёте.
|
|
116
|
+
export function routeSteps(route) {
|
|
117
|
+
if (!route || !route.symbols) return [];
|
|
118
|
+
return route.fees.map((fee, i) => ({ from: route.symbols[i], to: route.symbols[i + 1], fee, feeLabel: feeTierLabel(fee) }));
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const CACHE_MS = 20_000; // котировка живёт 20 с: дольше держать её нельзя (цена в пуле движется), чаще спрашивать незачем
|
|
122
|
+
|
|
123
|
+
const cache = new Map();
|
|
124
|
+
|
|
125
|
+
function cached(key) {
|
|
126
|
+
const hit = cache.get(key);
|
|
127
|
+
if (hit && Date.now() - hit.at < CACHE_MS) return hit.value;
|
|
128
|
+
return undefined;
|
|
129
|
+
}
|
|
130
|
+
function put(key, value) {
|
|
131
|
+
cache.set(key, { at: Date.now(), value });
|
|
132
|
+
return value;
|
|
133
|
+
}
|
|
134
|
+
export function resetDexCache() {
|
|
135
|
+
cache.clear();
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// Один eth_call в сеть сети из реестра. Никогда не бросает: молчание узла - это состояние интерфейса,
|
|
139
|
+
// а не падение экрана (так же сделано в evm/prices.js).
|
|
140
|
+
async function rpc(chain, method, params = [], { timeoutMs = 15_000 } = {}) {
|
|
141
|
+
if (!chain || !chain.rpcUrl) return { ok: false, why: "network has no RPC endpoint" };
|
|
142
|
+
const ctl = typeof AbortController === "function" ? new AbortController() : null;
|
|
143
|
+
const timer = ctl ? setTimeout(() => ctl.abort(), timeoutMs) : null;
|
|
144
|
+
try {
|
|
145
|
+
const res = await fetch(chain.rpcUrl, {
|
|
146
|
+
method: "POST",
|
|
147
|
+
headers: { "content-type": "application/json" },
|
|
148
|
+
body: JSON.stringify({ jsonrpc: "2.0", id: 1, method, params }),
|
|
149
|
+
signal: ctl ? ctl.signal : undefined,
|
|
150
|
+
});
|
|
151
|
+
if (!res.ok) return { ok: false, why: method + ": HTTP " + res.status };
|
|
152
|
+
const json = await res.json();
|
|
153
|
+
if (json.error) return { ok: false, why: method + ": " + String(json.error.message || "rpc error").slice(0, 120) };
|
|
154
|
+
return { ok: true, result: json.result };
|
|
155
|
+
} catch (e) {
|
|
156
|
+
const aborted = e && e.name === "AbortError";
|
|
157
|
+
return { ok: false, why: method + ": " + (aborted ? "no answer in " + timeoutMs + " ms" : String((e && e.message) || e)) };
|
|
158
|
+
} finally {
|
|
159
|
+
if (timer) clearTimeout(timer);
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// --- кодирование/декодирование ABI без библиотек (в проекте ноль зависимостей в app/) ---------------
|
|
164
|
+
const hexBody = (s) => String(s || "").replace(/^0x/, "");
|
|
165
|
+
const word = (v) => hexBody(v).toLowerCase().padStart(64, "0");
|
|
166
|
+
// АДРЕС - ЭТО ЦЕЛОЕ СЛОВО ABI (24 нуля и 20 байт адреса), а не только 20 байт. Здесь была ошибка ровно
|
|
167
|
+
// наоборот от той, что кажется очевидной: сначала адрес дополнялся нулями до слова, а потом у слова
|
|
168
|
+
// срезались первые 24 знака - то есть дополнение выбрасывалось, и в calldata уходили аргументы,
|
|
169
|
+
// сдвинутые на 12 байт. Вызов на этом откатывался ("execution reverted"), и выглядело это как "в пуле
|
|
170
|
+
// нет ликвидности", хотя пул отвечал - врал наш кодировщик (поймано сверкой с рабочим вызовом).
|
|
171
|
+
const addrWord = (a) => word(a);
|
|
172
|
+
const uintWord = (v) => {
|
|
173
|
+
let h = "";
|
|
174
|
+
let n = BigInt(v);
|
|
175
|
+
while (n > 0n) {
|
|
176
|
+
h = (n & 0xffn).toString(16).padStart(2, "0") + h;
|
|
177
|
+
n >>= 8n;
|
|
178
|
+
}
|
|
179
|
+
return h.padStart(64, "0");
|
|
180
|
+
};
|
|
181
|
+
function decodeWords(hex) {
|
|
182
|
+
const b = hexBody(hex);
|
|
183
|
+
const out = [];
|
|
184
|
+
for (let i = 0; i + 64 <= b.length; i += 64) out.push(BigInt("0x" + b.slice(i, i + 64)));
|
|
185
|
+
return out;
|
|
186
|
+
}
|
|
187
|
+
export function encodeQuoteExactInputSingle({ tokenIn, tokenOut, amountInWei, fee, sqrtPriceLimitX96 = 0 }) {
|
|
188
|
+
return (
|
|
189
|
+
DEX_METHOD.quoteExactInputSingle.hex +
|
|
190
|
+
addrWord(tokenIn) +
|
|
191
|
+
addrWord(tokenOut) +
|
|
192
|
+
uintWord(amountInWei) +
|
|
193
|
+
uintWord(fee) +
|
|
194
|
+
uintWord(sqrtPriceLimitX96)
|
|
195
|
+
);
|
|
196
|
+
}
|
|
197
|
+
export function encodeGetPool({ tokenA, tokenB, fee }) {
|
|
198
|
+
return DEX_METHOD.getPool.hex + addrWord(tokenA) + addrWord(tokenB) + uintWord(fee);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// ОБРАТНАЯ ЗАДАЧА ПО ОДНОМУ ПУЛУ: сколько надо отдать, чтобы получить РОВНО amountOutWei. Раскладка та же,
|
|
202
|
+
// что у одиночной формы на вход (статическая структура идёт ВНУТРИ, без слова смещения): адрес, адрес,
|
|
203
|
+
// сумма, комиссия, предел цены. Подтверждено вызовом, см. DEX_METHOD.quoteExactOutputSingle.
|
|
204
|
+
export function encodeQuoteExactOutputSingle({ tokenIn, tokenOut, amountOutWei, fee, sqrtPriceLimitX96 = 0 }) {
|
|
205
|
+
return DEX_METHOD.quoteExactOutputSingle.hex +
|
|
206
|
+
addrWord(tokenIn) + addrWord(tokenOut) + uintWord(amountOutWei) + uintWord(fee) + uintWord(sqrtPriceLimitX96);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// ЛИБО СКОЛЬКО ВЫЙДЕТ, ЛИБО ПОЧЕМУ НЕТ - так же, как у формы на вход. Здесь ЭТО НЕ ПРОГНОЗ, а ответ на
|
|
210
|
+
// вопрос "хватает ли пула, чтобы получить ровно столько": отказ и есть "не хватает".
|
|
211
|
+
export async function quoteExactOutSingle(chain, { tokenIn, tokenOut, amountOutWei, fee }, opts = {}) {
|
|
212
|
+
const u = chain && chain.uniswap;
|
|
213
|
+
if (!u || !u.quoter) return { ok: false, why: "no verified QuoterV2 for this network" };
|
|
214
|
+
const data = encodeQuoteExactOutputSingle({ tokenIn, tokenOut, amountOutWei, fee });
|
|
215
|
+
const res = await rpc(chain, "eth_call", [{ to: u.quoter, data }, "latest"], opts);
|
|
216
|
+
if (!res.ok) return { ok: false, why: res.why };
|
|
217
|
+
const words = decodeWords(res.result);
|
|
218
|
+
if (!words.length) return { ok: false, why: "quoter answered without data" };
|
|
219
|
+
if (words[0] <= 0n) return { ok: false, why: "pool cannot serve this output" };
|
|
220
|
+
return {
|
|
221
|
+
ok: true,
|
|
222
|
+
amountInWei: words[0].toString(),
|
|
223
|
+
gasEstimate: words.length > 1 ? words[words.length - 1].toString() : null,
|
|
224
|
+
};
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
// ОБРАТНАЯ ЗАДАЧА ПО ВСЕМУ ПУТИ: сколько надо отдать, чтобы получить РОВНО amountOutWei.
|
|
228
|
+
// ПУТЬ ЗДЕСЬ ИДЁТ В ОБРАТНУЮ СТОРОНУ - от получаемого токена к оплачиваемому, и комиссии в том же
|
|
229
|
+
// обратном порядке. Это не наше соглашение, а требование контракта: на прямом пути вызов отказывает
|
|
230
|
+
// ("Unexpected error"), на обратном отвечает числом. Измерено на обеих сетях, см. DEX_METHOD.
|
|
231
|
+
// ПРЕДПОЧТЕНИЕ ЭТОЙ ФОРМЫ: при ней минимум на выходе не "примерно равен" требуемой сумме, а равен ей
|
|
232
|
+
// точно, и вопроса "а что делать с разницей" не возникает вовсе (док 45).
|
|
233
|
+
export async function quoteExactOutPath(chain, { route, amountOutWei }, opts = {}) {
|
|
234
|
+
const u = chain && chain.uniswap;
|
|
235
|
+
if (!u || !u.quoter) return { ok: false, why: "no verified QuoterV2 for this network" };
|
|
236
|
+
if (!route || !Array.isArray(route.tokens) || route.tokens.length < 2) return { ok: false, why: "route has no path" };
|
|
237
|
+
const tokens = route.tokens.slice().reverse();
|
|
238
|
+
const fees = (route.fees || []).slice().reverse();
|
|
239
|
+
const path = encodePath({ tokens, fees });
|
|
240
|
+
// Динамический аргумент (bytes) идёт со словом смещения: 0x40, затем сумма точного выхода, затем сам путь.
|
|
241
|
+
const data = DEX_METHOD.quoteExactOutput.hex + uintWord(0x40) + uintWord(amountOutWei) + uintWord(path.bytes) +
|
|
242
|
+
path.packed.padEnd(Math.ceil(path.packed.length / 64) * 64, "0");
|
|
243
|
+
const res = await rpc(chain, "eth_call", [{ to: u.quoter, data }, "latest"], opts);
|
|
244
|
+
if (!res.ok) return { ok: false, why: res.why };
|
|
245
|
+
const out = decodeQuoteExactInput(res.result); // форма ответа та же: сумма, массивы, газ
|
|
246
|
+
if (!out || out.amountOutWei <= 0n) return { ok: false, why: "pool cannot serve exactly this output" };
|
|
247
|
+
return {
|
|
248
|
+
ok: true,
|
|
249
|
+
amountInWei: out.amountOutWei.toString(),
|
|
250
|
+
gasEstimate: out.gasEstimate.toString(),
|
|
251
|
+
shapeOk: out.sqrtPriceX96After.length === fees.length,
|
|
252
|
+
reversed: tokens.map((x) => x).length === fees.length + 1,
|
|
253
|
+
};
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
// ПУТЬ ДЛЯ МНОГОШАГОВОЙ КОТИРОВКИ. Путь в Uniswap v3 - это УПАКОВАННАЯ последовательность
|
|
257
|
+
// "адрес(20) + комиссия(3)" с завершающим адресом: token0|fee0|token1|fee1|token2. Никаких слов ABI
|
|
258
|
+
// внутри пути нет: 43 байта на одну ногу, 66 на две. Проверено числом: одноступенчатый путь через
|
|
259
|
+
// quoteExactInput вернул ровно тот же amountOut, что и quoteExactInputSingle по той же ступени.
|
|
260
|
+
export function encodePath({ tokens, fees }) {
|
|
261
|
+
const parts = [];
|
|
262
|
+
for (let i = 0; i < tokens.length; i++) {
|
|
263
|
+
parts.push(hexBody(tokens[i]).toLowerCase().padStart(40, "0"));
|
|
264
|
+
if (i < fees.length) parts.push(hexBody(Number(fees[i]).toString(16)).toLowerCase().padStart(6, "0"));
|
|
265
|
+
}
|
|
266
|
+
const packed = parts.join("");
|
|
267
|
+
return { packed, bytes: packed.length / 2 };
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
// ВЫЗОВ quoteExactInput(bytes path, uint256 amountIn) - ОДИН eth_call на ВЕСЬ маршрут. Собран по
|
|
271
|
+
// спецификации ABI, потому что здесь аргумент динамический: слово смещения (0x40), затем amountIn,
|
|
272
|
+
// затем длина пути и сам путь, добитый до границы слова. ЗДЕСЬ, В ОТЛИЧИЕ ОТ ОДИНОЧНОЙ ФОРМЫ, СМЕЩЕНИЕ
|
|
273
|
+
// ДЕЙСТВИТЕЛЬНО НУЖНО - одиночная форма принимает статичную структуру и идёт словами подряд.
|
|
274
|
+
export function encodeQuoteExactInput({ tokens, fees, amountInWei }) {
|
|
275
|
+
const { packed, bytes } = encodePath({ tokens, fees });
|
|
276
|
+
const padded = packed.padEnd(Math.ceil(bytes / 32) * 64, "0");
|
|
277
|
+
return DEX_METHOD.quoteExactInput.hex + word("0x40") + uintWord(amountInWei) + uintWord(bytes) + padded;
|
|
278
|
+
}
|
|
279
|
+
const wordToAddress = (w) => (w === undefined || w === null ? null : "0x" + w.toString(16).padStart(40, "0"));
|
|
280
|
+
const ZERO_ADDR = "0x" + "0".repeat(40);
|
|
281
|
+
|
|
282
|
+
// Ступень комиссии в процентах: 500 -> "0.05%". Это ЕДИНСТВЕННАЯ величина, которая раньше бралась из
|
|
283
|
+
// мока, и теперь она считается из самой ступени, а не берётся константой.
|
|
284
|
+
// Арифметика: fee измеряется в сотых долях базисной точки, 1 000 000 = 100%. Значит проценты = fee/10 000,
|
|
285
|
+
// то есть 500 -> 0.05%. Здесь сначала стояло ЕЩЁ деление на 100, и ступень 500 подписывалась как
|
|
286
|
+
// "0.0005%" - то есть комиссия занижалась в сто раз (поймано прогоном, а не на глаз).
|
|
287
|
+
export function feeTierPct(fee) {
|
|
288
|
+
const n = Number(fee);
|
|
289
|
+
if (!Number.isFinite(n)) return null;
|
|
290
|
+
return n / 10_000;
|
|
291
|
+
}
|
|
292
|
+
export function feeTierLabel(fee) {
|
|
293
|
+
const pct = feeTierPct(fee);
|
|
294
|
+
if (pct === null) return String(fee);
|
|
295
|
+
return String(Number(pct.toFixed(4))) + "%";
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
// ---------------------------------------------------------------------------
|
|
299
|
+
// Чтения
|
|
300
|
+
// ---------------------------------------------------------------------------
|
|
301
|
+
|
|
302
|
+
// Адрес пула у фабрики. null - пула нет (это ОТВЕТ, а не ошибка: значит менять не на что).
|
|
303
|
+
export async function poolAddress(chain, { tokenIn, tokenOut, fee }, opts = {}) {
|
|
304
|
+
const u = chain && chain.uniswap;
|
|
305
|
+
if (!u || !u.factory) return { ok: false, why: "no verified Uniswap factory for this network" };
|
|
306
|
+
const res = await rpc(chain, "eth_call", [{ to: u.factory, data: encodeGetPool({ tokenA: tokenIn, tokenB: tokenOut, fee }) }, "latest"], opts);
|
|
307
|
+
if (!res.ok) return { ok: false, why: res.why };
|
|
308
|
+
const words = decodeWords(res.result);
|
|
309
|
+
const pool = wordToAddress(words[0]);
|
|
310
|
+
if (!pool || pool === ZERO_ADDR) return { ok: true, pool: null };
|
|
311
|
+
return { ok: true, pool };
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
// Состояние пула: активная ликвидность и запасы обеих сторон. Нужно для ЧЕСТНОГО ответа "ликвидности
|
|
315
|
+
// нет": котировка пустого пула отвечает нулём, и без этого чтения мы не отличим "пула нет" от "пул есть,
|
|
316
|
+
// но пустой". Читается только в диагностическом пути (когда котировки нет или она неправдоподобна).
|
|
317
|
+
export async function poolState(chain, pool, { tokenIn, tokenOut }, opts = {}) {
|
|
318
|
+
if (!pool) return null;
|
|
319
|
+
const [liq, balIn, balOut] = await Promise.all([
|
|
320
|
+
rpc(chain, "eth_call", [{ to: pool, data: DEX_METHOD.liquidity.hex }, "latest"], opts),
|
|
321
|
+
rpc(chain, "eth_call", [{ to: tokenIn, data: DEX_METHOD.balanceOf.hex + addrWord(pool) }, "latest"], opts),
|
|
322
|
+
rpc(chain, "eth_call", [{ to: tokenOut, data: DEX_METHOD.balanceOf.hex + addrWord(pool) }, "latest"], opts),
|
|
323
|
+
]);
|
|
324
|
+
const one = (r) => (r.ok ? decodeWords(r.result)[0] ?? null : null);
|
|
325
|
+
return {
|
|
326
|
+
liquidity: liq.ok ? (BigInt(decodeWords(liq.result)[0] ?? 0n)).toString() : null,
|
|
327
|
+
reserveInWei: balIn.ok && one(balIn) !== null ? one(balIn).toString() : null,
|
|
328
|
+
reserveOutWei: balOut.ok && one(balOut) !== null ? one(balOut).toString() : null,
|
|
329
|
+
};
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
// ОДНА КОТИРОВКА ОДНОЙ СТУПЕНИ. Откат котировки - это НАЗВАННЫЙ отказ, а не исключение.
|
|
333
|
+
export async function quoteTier(chain, { tokenIn, tokenOut, amountInWei, fee }, opts = {}) {
|
|
334
|
+
const u = chain && chain.uniswap;
|
|
335
|
+
if (!u || !u.quoter) return { fee, ok: false, why: "no verified QuoterV2 for this network" };
|
|
336
|
+
const data = encodeQuoteExactInputSingle({ tokenIn, tokenOut, amountInWei, fee });
|
|
337
|
+
const res = await rpc(chain, "eth_call", [{ to: u.quoter, data }, "latest"], opts);
|
|
338
|
+
if (!res.ok) return { fee, ok: false, why: res.why };
|
|
339
|
+
const words = decodeWords(res.result);
|
|
340
|
+
if (words.length < 4) return { fee, ok: false, why: "quoter answered " + words.length + " words, expected 4" };
|
|
341
|
+
const amountOut = words[0];
|
|
342
|
+
if (amountOut <= 0n) return { fee, ok: false, why: "pool cannot serve this size" };
|
|
343
|
+
return {
|
|
344
|
+
fee,
|
|
345
|
+
ok: true,
|
|
346
|
+
amountOutWei: amountOut.toString(),
|
|
347
|
+
gasEstimate: words[3].toString(),
|
|
348
|
+
sqrtPriceX96After: words[1].toString(),
|
|
349
|
+
initializedTicksCrossed: words[2].toString(),
|
|
350
|
+
};
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
// Разбор ответа quoteExactInput. Ответ ДРУГОЙ, чем у одиночной формы: (uint256 amountOut,
|
|
354
|
+
// uint160[] sqrtPriceX96AfterList, uint32[] initializedTicksCrossedList, uint256 gasEstimate) - то есть
|
|
355
|
+
// на каждый шаг пути по элементу в двух массивах. Смещения читаются из самого ответа, а не
|
|
356
|
+
// предполагаются: длина массивов равна числу ног пути, и это можно СВЕРИТЬ, а не поверить.
|
|
357
|
+
export function decodeQuoteExactInput(hex) {
|
|
358
|
+
const words = decodeWords(hex);
|
|
359
|
+
if (words.length < 4) return null;
|
|
360
|
+
const at = (w) => Number(w) / 32;
|
|
361
|
+
const readArr = (w) => {
|
|
362
|
+
const i = at(w);
|
|
363
|
+
if (!Number.isInteger(i) || i < 0 || i >= words.length) return [];
|
|
364
|
+
const n = Number(words[i]);
|
|
365
|
+
if (!Number.isInteger(n) || n <= 0 || i + 1 + n > words.length) return [];
|
|
366
|
+
return words.slice(i + 1, i + 1 + n).map(String);
|
|
367
|
+
};
|
|
368
|
+
return {
|
|
369
|
+
amountOutWei: words[0],
|
|
370
|
+
sqrtPriceX96After: readArr(words[1]),
|
|
371
|
+
initializedTicksCrossed: readArr(words[2]),
|
|
372
|
+
gasEstimate: words[3],
|
|
373
|
+
};
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
// КОТИРОВКА ВСЕГО ПУТИ ОДНИМ ВЫЗОВОМ. Здесь НЕТ цепочки "котировка шага 1, потом шаг 2 от её выхода":
|
|
377
|
+
// такая цепочка считает оба шага по одному и тому же состоянию пулов и даёт оптимистично неверное число.
|
|
378
|
+
export async function quotePath(chain, { route, amountInWei }, opts = {}) {
|
|
379
|
+
const u = chain && chain.uniswap;
|
|
380
|
+
if (!u || !u.quoter) return { ...route, ok: false, why: "no verified QuoterV2 for this network" };
|
|
381
|
+
const data = encodeQuoteExactInput({ tokens: route.tokens, fees: route.fees, amountInWei });
|
|
382
|
+
const res = await rpc(chain, "eth_call", [{ to: u.quoter, data }, "latest"], opts);
|
|
383
|
+
if (!res.ok) return { ...route, ok: false, why: res.why };
|
|
384
|
+
const out = decodeQuoteExactInput(res.result);
|
|
385
|
+
if (!out) return { ...route, ok: false, why: "quoter answered a shape that is not quoteExactInput" };
|
|
386
|
+
if (out.amountOutWei <= 0n) return { ...route, ok: false, why: "pool cannot serve this size" };
|
|
387
|
+
return {
|
|
388
|
+
...route,
|
|
389
|
+
ok: true,
|
|
390
|
+
amountOutWei: out.amountOutWei.toString(),
|
|
391
|
+
gasEstimate: out.gasEstimate.toString(),
|
|
392
|
+
sqrtPriceX96After: out.sqrtPriceX96After,
|
|
393
|
+
initializedTicksCrossed: out.initializedTicksCrossed,
|
|
394
|
+
hops: route.fees.length,
|
|
395
|
+
// ЧИСЛО ШАГОВ ОТВЕТА СВЕРЯЕТСЯ С ЧИСЛОМ НОГ ПУТИ: если контракт вернул не по элементу на шаг, то это
|
|
396
|
+
// не тот ответ, который мы умеем читать, и лучше сказать это словами, чем показать число наугад.
|
|
397
|
+
shapeOk: out.sqrtPriceX96After.length === route.fees.length,
|
|
398
|
+
};
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
// ПОДПИСЬ ОБЪЯВЛЕННОГО МАРШРУТА: одна строка, по которой видно, ИЗ ЧЕГО он собран. Нужна для ключа
|
|
402
|
+
// кеша: если объявление поменяли (другая ступень, другой хаб), старый ответ на новый вопрос не годится.
|
|
403
|
+
export function routeSignature(route) {
|
|
404
|
+
if (!route || !Array.isArray(route.steps) || !route.steps.length) return route && route.unwrap ? "unwrap" : "none";
|
|
405
|
+
return route.steps.map((s) => String(s.to).toUpperCase() + ":" + Number(s.fee) + (s.pool ? ":" + String(s.pool).toLowerCase() : "")).join("+");
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
// ---------------------------------------------------------------------------
|
|
409
|
+
// ПРОВЕРКА ЦЕНЫ: СДЕЛКА НЕВЕРНАЯ ИЛИ НЕТ.
|
|
410
|
+
//
|
|
411
|
+
// Это НЕ оценка ликвидности - её, как и раньше, делает человек, и приговоров о глубине пулов здесь нет.
|
|
412
|
+
// Это проверка того же рода, что покрытие суммы: если цена не та, сделка не сойдётся, и она не должна
|
|
413
|
+
// начинаться. Признак неверной цены берётся ДВУМЯ способами, и ни одному из них не нужен фид на каждый
|
|
414
|
+
// токен (иначе USDe/USDS/PyUSD без своего фида сломали бы проверку вовсе):
|
|
415
|
+
//
|
|
416
|
+
// 1) ПОСЛЕДНИЙ ШАГ В НАТИВ. Из котировки выводится НЕЯВНАЯ цена натива в долларах (сколько входит в
|
|
417
|
+
// долларах / сколько натива выходит) и сравнивается с эталоном цены натива - тем самым Chainlink,
|
|
418
|
+
// который страница уже читает (evm/prices.js). Это работает для любого входа: у стейбла цена в
|
|
419
|
+
// долларах известна из реестра, у остальных маршрут всё равно заканчивается нативом;
|
|
420
|
+
// 2) ШАГ МЕЖДУ ДВУМЯ СТЕЙБЛАМИ (USDe→USDC, USDS→USDC, PyUSD→USDC): цена обязана быть около 1:1, и это
|
|
421
|
+
// проверяется САМОЙ КОТИРОВКОЙ, без оракула вообще. Абсурд вида "100 USDe дают 0.5 USDC" ловится
|
|
422
|
+
// именно здесь.
|
|
423
|
+
//
|
|
424
|
+
// ПОРОГИ ПРИХОДЯТ ДАННЫМИ (реестр: DEX_PRICE_GATE), а не лежат здесь числами: менять их - правка данных.
|
|
425
|
+
// ПРИЗНАК СЕТИ tolerated (реестр: dexPriceGate === "tolerated") ОТМЕНЯЕТ БЛОКИРОВКУ, НО НЕ ФАКТ: цена
|
|
426
|
+
// остаётся негодной и названа таковой (bad), а интерфейс обязан её показать. Так тестнет не встаёт на
|
|
427
|
+
// цене, которую мы не выбирали, и при этом не притворяется, что цена рыночная.
|
|
428
|
+
//
|
|
429
|
+
// Функция ЧИСТАЯ: ни одного вызова в сеть, только числа ноги и эталон. Поэтому её можно прогнать на
|
|
430
|
+
// любых числах без сети - чем и доказывается, что защита краснеет (tools/check-dex-gate.mjs).
|
|
431
|
+
// ---------------------------------------------------------------------------
|
|
432
|
+
export function priceGateVerdict({
|
|
433
|
+
amountInWei = null, amountOutWei = null, inDecimals = null, outDecimals = null,
|
|
434
|
+
tokenUsd = null, referenceUsd = null, stableSteps = [], tolerated = false, gates = null,
|
|
435
|
+
} = {}) {
|
|
436
|
+
const maxDev = gates && Number.isFinite(Number(gates.maxDeviationPct)) && Number(gates.maxDeviationPct) > 0 ? Number(gates.maxDeviationPct) : null;
|
|
437
|
+
const band = gates && Number.isFinite(Number(gates.stableBandPct)) && Number(gates.stableBandPct) >= 0 ? Number(gates.stableBandPct) : null;
|
|
438
|
+
const why = [];
|
|
439
|
+
|
|
440
|
+
// (1) ШАГИ МЕЖДУ СТЕЙБЛАМИ: около 1:1, без оракула.
|
|
441
|
+
const stable = (Array.isArray(stableSteps) ? stableSteps : []).map((s) => {
|
|
442
|
+
const row = {
|
|
443
|
+
from: s.from, to: s.to, fee: s.fee, feeLabel: feeTierLabel(s.fee),
|
|
444
|
+
inWei: s.inWei || null, outWei: s.outWei || null, exactAmount: Boolean(s.exact),
|
|
445
|
+
quoted: Boolean(s.ok), ok: false, ratio: null, deviationPct: null, why: null,
|
|
446
|
+
};
|
|
447
|
+
if (!s.ok) {
|
|
448
|
+
// ШАГ СТЕЙБЛ-СТЕЙБЛ НЕ ОТВЕТИЛ - И ЭТО НАЗВАНО, А НЕ ПРОПУЩЕНО: неизмеренная цена не проходит
|
|
449
|
+
// проверку, иначе "не проверили" выглядело бы как "цена в порядке".
|
|
450
|
+
row.why = "the 1:1 step could not be quoted (" + (s.why || "no answer") + ")";
|
|
451
|
+
why.push(row.why);
|
|
452
|
+
return row;
|
|
453
|
+
}
|
|
454
|
+
const inUnits = Number(s.inWei) / 10 ** Number(s.inDecimals);
|
|
455
|
+
const outUnits = Number(s.outWei) / 10 ** Number(s.outDecimals);
|
|
456
|
+
if (!(inUnits > 0) || !(outUnits > 0)) {
|
|
457
|
+
row.why = "the 1:1 step answered with a number that cannot be read";
|
|
458
|
+
why.push(row.why);
|
|
459
|
+
return row;
|
|
460
|
+
}
|
|
461
|
+
row.ratio = outUnits / inUnits;
|
|
462
|
+
row.deviationPct = Math.abs(1 - row.ratio) * 100;
|
|
463
|
+
if (band === null) { row.why = "the 1:1 band is not declared for this step, so this step cannot be checked"; why.push(row.why); return row; }
|
|
464
|
+
if (row.deviationPct > band) {
|
|
465
|
+
row.why = `${row.from}→${row.to} at ${row.feeLabel} pays ${row.ratio.toFixed(6)} ${row.to} per 1 ${row.from} - ${row.deviationPct.toFixed(2)}% off 1:1, and the band is ${band}%`;
|
|
466
|
+
why.push(row.why);
|
|
467
|
+
return row;
|
|
468
|
+
}
|
|
469
|
+
row.ok = true;
|
|
470
|
+
return row;
|
|
471
|
+
});
|
|
472
|
+
|
|
473
|
+
// (2) НЕЯВНАЯ ЦЕНА НАТИВА ПРОТИВ ЭТАЛОНА.
|
|
474
|
+
const inUnits = Number.isFinite(Number(inDecimals)) && Number(amountInWei) > 0 ? Number(amountInWei) / 10 ** Number(inDecimals) : null;
|
|
475
|
+
const outUnits = Number.isFinite(Number(outDecimals)) && Number(amountOutWei) > 0 ? Number(amountOutWei) / 10 ** Number(outDecimals) : null;
|
|
476
|
+
const valueUsd = inUnits !== null && Number(tokenUsd) > 0 ? inUnits * Number(tokenUsd) : null;
|
|
477
|
+
const impliedUsd = valueUsd !== null && outUnits !== null && outUnits > 0 ? valueUsd / outUnits : null;
|
|
478
|
+
const ref = Number(referenceUsd) > 0 ? Number(referenceUsd) : null;
|
|
479
|
+
const deviationPct = impliedUsd !== null && ref !== null ? Math.abs(impliedUsd - ref) / ref * 100 : null;
|
|
480
|
+
const nativeBad = deviationPct !== null && maxDev !== null && deviationPct > maxDev;
|
|
481
|
+
if (nativeBad) {
|
|
482
|
+
why.push(`the route implies the native coin at $${impliedUsd.toFixed(2)} while the reference price is $${ref.toFixed(2)} - ${deviationPct.toFixed(1)}% apart, and the limit is ${maxDev}%: this is not the market price`);
|
|
483
|
+
} else if (deviationPct === null) {
|
|
484
|
+
// НЕ ПРОВЕРЯЛИ - ЭТО НЕ "ВСЁ ХОРОШО". Причину называем ту, по которой проверить не вышло.
|
|
485
|
+
why.push(ref === null
|
|
486
|
+
? "the reference price of the native coin is not known, so the price cannot be checked"
|
|
487
|
+
: "the price of the token being paid is not known, so the price cannot be checked");
|
|
488
|
+
} else if (maxDev === null) {
|
|
489
|
+
why.push("the price limit is not declared for this route, so the price cannot be checked");
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
// ПРОВЕРКА СОСТОЯЛАСЬ - значит НАТИВНАЯ ЧАСТЬ состоялась (эталон есть, лимит объявлен). Шаги
|
|
493
|
+
// стейбл-стейбл не входят сюда намеренно: неизмеренный или вышедший из полосы шаг - это уже bad
|
|
494
|
+
// (см. выше), а не "не проверено".
|
|
495
|
+
const checked = deviationPct !== null && maxDev !== null;
|
|
496
|
+
const bad = nativeBad || stable.some((r) => !r.ok);
|
|
497
|
+
// БЛОКИРУЕТ ЛИ: негодная цена - да (кроме сети с признаком tolerated); непроверенная цена - тоже да,
|
|
498
|
+
// и по той же логике, по которой блокирует неизвестная требуемая сумма: "не проверили" нельзя
|
|
499
|
+
// показывать как "проверили и всё хорошо". На тестнете не блокирует ни то, ни другое.
|
|
500
|
+
const blocks = tolerated ? false : (bad || !checked);
|
|
501
|
+
return {
|
|
502
|
+
checked, bad, tolerated: Boolean(tolerated), blocks,
|
|
503
|
+
why: why.length ? why.join("; ") : null,
|
|
504
|
+
impliedUsd, referenceUsd: ref, deviationPct,
|
|
505
|
+
maxDeviationPct: maxDev, stableBandPct: band,
|
|
506
|
+
tokenUsd: Number(tokenUsd) > 0 ? Number(tokenUsd) : null,
|
|
507
|
+
stable,
|
|
508
|
+
};
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
// ---------------------------------------------------------------------------
|
|
512
|
+
// ПОЧЕМУ МАРШРУТА НЕТ - СЛОВАМИ, И ЭТИ СЛОВА - ИЗ ДАННЫХ. Причина лежит в самом объявлении (route.why):
|
|
513
|
+
// её писал человек, объявляя маршрут, и она подтверждена замером (tools/check-uniswap-quote.mjs - раздел
|
|
514
|
+
// "объявленные маршруты"). Одна и та же формулировка в интерфейсе и в отчёте: разойдясь, они начнут
|
|
515
|
+
// объяснять человеку одно, а оператору другое.
|
|
516
|
+
// Маршрута в реестре НЕТ ВОВСЕ (токен есть, объявления нет) - это ТОЖЕ честный ответ, а не "не нашли":
|
|
517
|
+
// значит, обход не объявляли, и подпись по нему не начинается.
|
|
518
|
+
export function noRouteWords({ paySymbol, wrappedSymbol, chainName, route = null }) {
|
|
519
|
+
// ТЕМПЛЕЙТ, А НЕ СКЛЕЙКА СТРОК: текст тот же, но подстроки `from "` в исходнике больше нет (склейка
|
|
520
|
+
// случайно читалась проверкой голых импортов пакета SDK как спецификатор). Правило то же, слова те же.
|
|
521
|
+
const head = `no route from ${paySymbol} to ${wrappedSymbol} on ${chainName}`;
|
|
522
|
+
const why = route && route.why ? String(route.why) : null;
|
|
523
|
+
// В ВИДИМОМ ТЕКСТЕ НЕТ НИ "registry", НИ "DEX_ROUTES": это имена НАШЕГО устройства, а не то, что
|
|
524
|
+
// человеку нужно понять. Смысл тот же: маршрут не ищут перебором, его объявляет человек.
|
|
525
|
+
return why ? head + ": " + why : head + ": no route for " + paySymbol + " is declared - routes are declared by hand, not searched";
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
|
|
529
|
+
// ---------------------------------------------------------------------------
|
|
530
|
+
// ВЕРДИКТ НОГИ: МОЖНО ЛИ ПО НЕЙ ПОДПИСЫВАТЬ (док 45). Две защиты ОДНОЙ СЕМЬИ, и обе БЛОКИРУЮТ подпись:
|
|
531
|
+
// * ПОКРЫТИЕ СУММЫ - маршрут обязан дать не меньше, чем требует ордер (сумма эскроу);
|
|
532
|
+
// * ЦЕНА - цена маршрута обязана быть рыночной (priceGateVerdict): неверная цена означает то же самое,
|
|
533
|
+
// что и недостача, - сделка не сойдётся.
|
|
534
|
+
// Отличается от наблюдений именно этим: "не покрывает" и "цена не та" - такие же ФАКТЫ, как "пула нет",
|
|
535
|
+
// и они обязаны останавливать человека ДО подписи, а не показываться строкой в разбивке цены. Отсюда
|
|
536
|
+
// правило: функция возвращает либо { blocked: false }, либо { blocked: true, kind, reason } - и
|
|
537
|
+
// вызывающий ОБЯЗАН считаться с blocked.
|
|
538
|
+
// Приговоров о ликвидности здесь нет: "не покрывает на 0.4%" - это число, а не оценка качества пула.
|
|
539
|
+
//
|
|
540
|
+
// kind: "unquoted" - котировки ещё нет вовсе (спрашивать нечего, ждать нельзя: блокируем)
|
|
541
|
+
// "no-route" - маршрута нет: причины лежат в leg.why (объявление реестра + отказ цепи)
|
|
542
|
+
// "price-unchecked" - цену проверить НЕЧЕМ (нет эталона цены натива): блокируем - "не
|
|
543
|
+
// проверили" нельзя показывать как "проверили и всё хорошо"
|
|
544
|
+
// "bad-price" - цена не рыночная: числа в verdict.price (блокирует на боевой сети)
|
|
545
|
+
// "unchecked" - требуемая сумма неизвестна (нет курса XMR/<натив>, чтобы её посчитать)
|
|
546
|
+
// "short" - маршрут есть, но выход МЕНЬШЕ требуемой суммы: вот насколько
|
|
547
|
+
// "bad-price-tolerated" - цена негодная, но у сети признак dexPriceGate: подпись НЕ блокируется,
|
|
548
|
+
// а негодность НАЗВАНА (тестнет: проверяем поток, а не рынок)
|
|
549
|
+
// "covers" - всё сходится: подпись разрешена (прочие защиты страницы в силе)
|
|
550
|
+
//
|
|
551
|
+
// ЧИСЛА ПОКРЫТИЯ ВОЗВРАЩАЮТСЯ ВСЕГДА, КОГДА ОНИ ИЗВЕСТНЫ (needWei/getsWei/shortfallWei/covers) - даже
|
|
552
|
+
// когда подпись заперта ценой: человеку должно быть видно, ИЗ ЧЕГО взялся вердикт, а не только слово
|
|
553
|
+
// "заблокировано".
|
|
554
|
+
//
|
|
555
|
+
// МИНИМУМ НА ВЫХОДЕ БУДУЩЕГО СВОПА БЕРЁТСЯ ОТСЮДА ЖЕ - из требуемой суммы эскроу, а не "на глазок"
|
|
556
|
+
// (docs 45). Поэтому недостача невозможна по построению, а излишек остаётся пользователю: НИКАКОЙ
|
|
557
|
+
// автоматической конвертации остатка в коде нет и не должно появиться - фондирование вносит ровно сумму
|
|
558
|
+
// ордера, всё прочее остаётся у человека.
|
|
559
|
+
export function dexLegVerdict(leg, requiredOutWei = null) {
|
|
560
|
+
if (leg && leg.unwrap) return { blocked: false, kind: "unwrap", reason: null, price: null };
|
|
561
|
+
if (!leg) return { blocked: true, kind: "unquoted", reason: "the swap leg has not been quoted yet", price: null };
|
|
562
|
+
if (!leg.ok) return { blocked: true, kind: "no-route", reason: leg.why || "no route", price: null };
|
|
563
|
+
// ЦЕНА ПРИХОДИТ ИЗ САМОЙ НОГИ: её считает quoteDexOut тем же ответом цепи, что и выход. Нога без
|
|
564
|
+
// проверки цены (её собрал кто-то руками) эту защиту не подтверждает - проверить её здесь нечем,
|
|
565
|
+
// поэтому и вердикта о цене у такой ноги нет.
|
|
566
|
+
const price = leg.price || null;
|
|
567
|
+
const priceBad = Boolean(price && price.bad);
|
|
568
|
+
const priceUnchecked = Boolean(price && price.checked === false);
|
|
569
|
+
const priceBlocks = Boolean(price && price.blocks && (priceBad || priceUnchecked));
|
|
570
|
+
// ТРЕБУЕМАЯ СУММА ПРИХОДИТ СЮДА АРГУМЕНТОМ, А НЕ БЕРЁТСЯ ИЗ САМОЙ НОГИ: котировка живёт в кеше 20 с, а
|
|
571
|
+
// размер ордера человек может поменять в любой момент. Возьми мы требуемую сумму из кешированного
|
|
572
|
+
// ответа - вердикт отставал бы от того, что человек видит в форме, и это ровно тот случай, когда
|
|
573
|
+
// защита "есть, но не срабатывает".
|
|
574
|
+
const need = requiredOutWei !== null && requiredOutWei !== undefined && Number(requiredOutWei) > 0 ? BigInt(requiredOutWei) : null;
|
|
575
|
+
const gets = BigInt(leg.amountOutWei);
|
|
576
|
+
const coverage = need === null ? null : {
|
|
577
|
+
needWei: need.toString(), getsWei: gets.toString(), covers: gets >= need,
|
|
578
|
+
shortfallWei: (gets >= need ? 0n : need - gets).toString(),
|
|
579
|
+
};
|
|
580
|
+
const base = {
|
|
581
|
+
...(coverage || {}),
|
|
582
|
+
price,
|
|
583
|
+
routeLabel: leg.routeLabel || null,
|
|
584
|
+
decimals: leg.wrapped ? leg.wrapped.decimals : null,
|
|
585
|
+
wrappedSymbol: leg.wrapped ? leg.wrapped.symbol : null,
|
|
586
|
+
};
|
|
587
|
+
// ПОРЯДОК ПРИЧИН: сначала "подписывать нельзя вообще" (маршрута нет - выше; цена непроверена; цена не
|
|
588
|
+
// та), потом "суммы не хватает", потом "всё сошлось". Он важен: при неверной цене вопрос о покрытии
|
|
589
|
+
// числам уже не верится.
|
|
590
|
+
if (priceBlocks) return { blocked: true, ...base, kind: priceBad ? "bad-price" : "price-unchecked", reason: price.why };
|
|
591
|
+
if (coverage === null) {
|
|
592
|
+
return { blocked: true, ...base, kind: "unchecked", reason: "the required escrow amount is not known yet, so coverage cannot be checked" };
|
|
593
|
+
}
|
|
594
|
+
if (!coverage.covers) return { blocked: true, ...base, kind: "short", reason: "the declared route returns less than the order needs" };
|
|
595
|
+
if (priceBad) return { blocked: false, ...base, kind: "bad-price-tolerated", reason: price.why };
|
|
596
|
+
return { blocked: false, ...base, kind: "covers", reason: null };
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
// ---------------------------------------------------------------------------
|
|
600
|
+
// Главный вход для страницы: сколько натива даст оплата этим токеном ПО ОБЪЯВЛЕННОМУ МАРШРУТУ.
|
|
601
|
+
//
|
|
602
|
+
// token - токен оплаты из реестра (адрес, decimals и цена)
|
|
603
|
+
// amountWei - сумма оплаты в его минимальных единицах
|
|
604
|
+
// requiredOutWei - СКОЛЬКО НАТИВА ТРЕБУЕТ ОРДЕР (сумма эскроу на этот размер входа). Это НЕ
|
|
605
|
+
// наблюдение, а условие выполнимости: по нему считается вердикт "покрывает /
|
|
606
|
+
// не покрывает", и "не покрывает" блокирует подпись (см. dexLegVerdict).
|
|
607
|
+
// route - ОБЪЯВЛЕННЫЙ маршрут из реестра (DEX_ROUTES[chainId][symbol]). Выбора здесь нет
|
|
608
|
+
// и быть не должно: что объявлено - то и котируется, ОДНИМ вызовом. Объявления
|
|
609
|
+
// нет или в нём пусто - это честное "маршрута нет" (слова - из route.why), и
|
|
610
|
+
// подпись по нему не начинается.
|
|
611
|
+
// tokens - токены этой сети: по символу из шага берутся адрес и decimals
|
|
612
|
+
// referenceNativeUsd - ЭТАЛОН цены натива в долларах (Chainlink у страницы). Без него проверка цены
|
|
613
|
+
// не состоится, и это не повод её пропустить (см. priceGateVerdict).
|
|
614
|
+
// gates - пороги проверки цены из реестра (DEX_PRICE_GATE)
|
|
615
|
+
//
|
|
616
|
+
// ХАБОВ ЗДЕСЬ БОЛЬШЕ НЕТ: обход через USDC/USDT - это тоже объявленный маршрут, а не поиск в рантайме.
|
|
617
|
+
//
|
|
618
|
+
// Возвращает ЛИБО { ok: true, ... } с числами из цепи, ЛИБО { ok: false, why } словами. Числа и
|
|
619
|
+
// "почему нет" вместе не возвращаются: показать человеку и то и другое - значит предложить ему
|
|
620
|
+
// догадаться, какому из двух ответов верить.
|
|
621
|
+
// ---------------------------------------------------------------------------
|
|
622
|
+
export async function quoteDexOut(chain, { token, amountWei, requiredOutWei = null, route = null, tokens = [], referenceNativeUsd = null, gates = null } = {}, opts = {}) {
|
|
623
|
+
const u = chain && chain.uniswap;
|
|
624
|
+
if (!token || !token.address) return { ok: false, why: token && token.native ? "paying the native coin needs no DEX leg" : "no token address" };
|
|
625
|
+
if (!u || !u.quoter || !u.wrapped) return { ok: false, why: "no verified Uniswap addresses for " + (chain && chain.name ? chain.name : "this network") };
|
|
626
|
+
const amount = BigInt(amountWei || 0);
|
|
627
|
+
if (amount <= 0n) return { ok: false, why: "amount not entered" };
|
|
628
|
+
const wrapped = u.wrapped;
|
|
629
|
+
const chainName = (chain && chain.name) || (chain && chain.id) || "this network";
|
|
630
|
+
if (String(token.address).toLowerCase() === String(wrapped.address).toLowerCase()) {
|
|
631
|
+
// Обёрнутый нативный токен меняется на натив НЕ в пуле: это withdraw у WETH9, один к одному.
|
|
632
|
+
return { ok: false, why: "this is the wrapped native coin: no pool needed, it unwraps 1:1", unwrap: true };
|
|
633
|
+
}
|
|
634
|
+
const declared = route || null;
|
|
635
|
+
// ПРИЗНАК СЕТИ: негодная цена на тестнете не блокирует (реестр: dexPriceGate у сети). Отсутствие
|
|
636
|
+
// признака - блокирует, и это умолчание: молчаливое "сойдёт" на боевой сети недопустимо.
|
|
637
|
+
const tolerated = String((chain && chain.dexPriceGate) || "") === "tolerated";
|
|
638
|
+
// ОБЪЯВЛЕНИЕ, КОТОРЫМ ПОЛЬЗУЕМСЯ, ВОЗВРАЩАЕТСЯ В ОТВЕТЕ ЦЕЛИКОМ (со ступенями, пулами и датой замера):
|
|
639
|
+
// интерфейс и отчёт обязаны называть не только ЧТО получилось, но и ПО ЧЕМУ это объявлено.
|
|
640
|
+
const declaration = declared
|
|
641
|
+
? {
|
|
642
|
+
steps: (Array.isArray(declared.steps) ? declared.steps : []).map((s) => ({ to: s.to, fee: s.fee, pool: s.pool || null, verified: s.verified || null })),
|
|
643
|
+
why: declared.why || null, unwrap: Boolean(declared.unwrap),
|
|
644
|
+
}
|
|
645
|
+
: null;
|
|
646
|
+
// (1) МАРШРУТА НЕТ - ЭТО ОТВЕТ ИЗ ДАННЫХ, А НЕ ОШИБКА. Пустой steps значит "объявленного маршрута нет":
|
|
647
|
+
// почему его нет - в route.why, и эти слова уходят и в интерфейс, и в отчёт (одни и те же).
|
|
648
|
+
if (!declared || !Array.isArray(declared.steps) || !declared.steps.length) {
|
|
649
|
+
if (declared && declared.unwrap) return { ok: false, why: "this is the wrapped native coin: no pool needed, it unwraps 1:1", unwrap: true };
|
|
650
|
+
return { ok: false, why: noRouteWords({ paySymbol: token.symbol, wrappedSymbol: wrapped.symbol, chainName, route: declared }), declared: true, declaration, steps: [] };
|
|
651
|
+
}
|
|
652
|
+
// (2) ПУТЬ ИЗ ОБЪЯВЛЕННЫХ ШАГОВ. Не собрать объявленное (шаг называет токен, которого в этой сети нет) -
|
|
653
|
+
// это ошибка В ДАННЫХ, и называть её надо иначе, чем "маршрута нет": по ней видно, что правки ждёт реестр.
|
|
654
|
+
const path = declaredRoutePath({ token, tokens, wrapped, route: declared });
|
|
655
|
+
if (!path || path.error) {
|
|
656
|
+
return {
|
|
657
|
+
ok: false, declared: true, dataError: true, declaration,
|
|
658
|
+
why: "the declared route for " + token.symbol + " on " + chainName + " cannot be built: " + ((path && path.error) || "unknown reason"),
|
|
659
|
+
};
|
|
660
|
+
}
|
|
661
|
+
const byAddr = new Map((Array.isArray(tokens) ? tokens : []).filter((t) => t && t.address).map((t) => [String(t.address).toLowerCase(), t]));
|
|
662
|
+
const dec = (addr) => {
|
|
663
|
+
if (String(addr).toLowerCase() === String(token.address).toLowerCase()) return token.decimals;
|
|
664
|
+
if (String(addr).toLowerCase() === String(wrapped.address).toLowerCase()) return wrapped.decimals;
|
|
665
|
+
const h = byAddr.get(String(addr).toLowerCase());
|
|
666
|
+
return h && Number.isFinite(Number(h.decimals)) ? Number(h.decimals) : null;
|
|
667
|
+
};
|
|
668
|
+
// (3) КЕШ НА 20 С ПО СЕТИ, ТОКЕНУ, СУММЕ И ПОДПИСИ ОБЪЯВЛЕНИЯ. Сменив объявление, мы спрашиваем ДРУГОЙ
|
|
669
|
+
// маршрут, и старый ответ на новый вопрос не годится. Требуемая сумма в ключ НЕ входит: она меняется от
|
|
670
|
+
// размера ордера, а покрытие и цена считаются ниже - каждый раз, из свежих чисел.
|
|
671
|
+
const key = [chain.id, token.symbol, amount.toString(), routeSignature(declared)].join("|");
|
|
672
|
+
let base = cached(key);
|
|
673
|
+
if (base === undefined) {
|
|
674
|
+
// ОДИН ВЫЗОВ НА ВЕСЬ ПУТЬ. Цепочки "котировка шага 1, потом шаг 2 от её выхода" здесь нет и быть не
|
|
675
|
+
// должно: такая цепочка считает оба шага по одному состоянию пулов и даёт оптимистично неверное число.
|
|
676
|
+
const quote = await quotePath(chain, { route: { tokens: path.tokens, fees: path.fees, symbols: path.symbols, via: null }, amountInWei: amount }, opts);
|
|
677
|
+
if (!quote.ok) return put(key, { ok: false, why: quote.why, declared: true });
|
|
678
|
+
base = { ok: true, quote };
|
|
679
|
+
put(key, base);
|
|
680
|
+
}
|
|
681
|
+
if (!base.ok) return { ...base, declaration };
|
|
682
|
+
const q = base.quote;
|
|
683
|
+
// ПУЛЫ МАРШРУТА ЧИТАЮТСЯ ВСЕГДА, КОГДА КОТИРОВКА ЕСТЬ - по одной записи на КАЖДУЮ ногу. Это ФАКТ,
|
|
684
|
+
// который человеку нужен рядом с числом: "маршрут ответил столько-то" само по себе не говорит, много
|
|
685
|
+
// ли лежит в пулах на его пути. Запасы в wei БЕЗ decimals человеку не читаются, поэтому decimals
|
|
686
|
+
// едут рядом - подставлять 18 "по похожести" значило бы однажды показать 6-значный токен как 18-значный.
|
|
687
|
+
if (base.hops === undefined) {
|
|
688
|
+
const hops = [];
|
|
689
|
+
for (let i = 0; i < path.fees.length; i++) {
|
|
690
|
+
const fee = path.fees[i];
|
|
691
|
+
const from = path.tokens[i], to = path.tokens[i + 1];
|
|
692
|
+
const declaredStep = (declared.steps && declared.steps[i]) || {};
|
|
693
|
+
const poolRes = await poolAddress(chain, { tokenIn: from, tokenOut: to, fee }, opts);
|
|
694
|
+
const pool = poolRes.ok ? poolRes.pool : null;
|
|
695
|
+
const declaredPool = declaredStep.pool || null;
|
|
696
|
+
hops.push({
|
|
697
|
+
step: i + 1, from: path.symbols[i], to: path.symbols[i + 1], fee, feeLabel: feeTierLabel(fee),
|
|
698
|
+
decimals: [dec(from), dec(to)],
|
|
699
|
+
pool,
|
|
700
|
+
// СВЕРКА ОБЪЯВЛЕННОГО АДРЕСА ПУЛА С ЦЕПЬЮ. Если фабрика отвечает другим пулом, объявление
|
|
701
|
+
// устарело: в отчёте инструмента это провал, здесь - факт рядом с числом (решает человек).
|
|
702
|
+
declaredPool,
|
|
703
|
+
poolMatchesDeclared: pool && declaredPool ? String(pool).toLowerCase() === String(declaredPool).toLowerCase() : null,
|
|
704
|
+
verified: declaredStep.verified || null,
|
|
705
|
+
// СОСТОЯНИЕ ПУЛА ЗДЕСЬ БОЛЬШЕ НЕ ЧИТАЕТСЯ. Три вызова eth_call на каждую ногу (liquidity(),
|
|
706
|
+
// balanceOf(tokenIn), balanceOf(tokenOut)) собирались ради одной строки в интерфейсе - liquidity,
|
|
707
|
+
// резервы пула, - и та убрана: после ссылки на пул человек смотрит глубину и состояние в обозревателе.
|
|
708
|
+
// Сама функция poolState остаётся: её зовёт отчёт инструмента (tools/check-uniswap-quote.mjs), и там
|
|
709
|
+
// эти числа нужны оператору. Здесь же они не нужны никому, а страница спрашивает их на каждом обновлении.
|
|
710
|
+
});
|
|
711
|
+
}
|
|
712
|
+
base.hops = hops;
|
|
713
|
+
}
|
|
714
|
+
const hops = base.hops;
|
|
715
|
+
// ШАГИ МЕЖДУ ДВУМЯ СТЕЙБЛАМИ ПРОВЕРЯЮТСЯ САМОЙ КОТИРОВКОЙ (около 1:1, без оракула вовсе). Сумма шага -
|
|
716
|
+
// сумма маршрута: у объявленных маршрутов такой шаг идёт ПЕРВЫМ, поэтому для него это ровно его сумма
|
|
717
|
+
// (и это отмечено полем exact у каждой строки).
|
|
718
|
+
if (base.stableSteps === undefined) {
|
|
719
|
+
const stables = (gates && Array.isArray(gates.stables) ? gates.stables : []).map((s) => String(s).toUpperCase());
|
|
720
|
+
const steps = [];
|
|
721
|
+
for (let i = 0; i < path.fees.length; i++) {
|
|
722
|
+
const fromSym = String(path.symbols[i]).toUpperCase(), toSym = String(path.symbols[i + 1]).toUpperCase();
|
|
723
|
+
if (!stables.includes(fromSym) || !stables.includes(toSym)) continue;
|
|
724
|
+
const stepQuote = await quoteTier(chain, { tokenIn: path.tokens[i], tokenOut: path.tokens[i + 1], amountInWei: amount, fee: path.fees[i] }, opts);
|
|
725
|
+
steps.push({
|
|
726
|
+
from: path.symbols[i], to: path.symbols[i + 1], fee: path.fees[i],
|
|
727
|
+
inWei: amount.toString(), outWei: stepQuote.ok ? stepQuote.amountOutWei : null,
|
|
728
|
+
inDecimals: dec(path.tokens[i]), outDecimals: dec(path.tokens[i + 1]),
|
|
729
|
+
ok: Boolean(stepQuote.ok), why: stepQuote.ok ? null : stepQuote.why, exact: i === 0,
|
|
730
|
+
});
|
|
731
|
+
}
|
|
732
|
+
base.stableSteps = steps;
|
|
733
|
+
}
|
|
734
|
+
// ПРОВЕРКА ЦЕНЫ. Считается КАЖДЫЙ РАЗ, а не кешируется вместе с котировкой: эталон цены натива
|
|
735
|
+
// подгружается отдельно и может появиться позже, чем ответ пула - ровно та же причина, по которой
|
|
736
|
+
// здесь не кешируется и вердикт покрытия.
|
|
737
|
+
const price = priceGateVerdict({
|
|
738
|
+
amountInWei: amount, amountOutWei: q.amountOutWei,
|
|
739
|
+
inDecimals: token.decimals, outDecimals: wrapped.decimals,
|
|
740
|
+
tokenUsd: Number(token.usd) > 0 ? Number(token.usd) : null,
|
|
741
|
+
referenceUsd: referenceNativeUsd,
|
|
742
|
+
stableSteps: base.stableSteps, tolerated, gates,
|
|
743
|
+
});
|
|
744
|
+
// ПРЕДПОЧТИТЕЛЬНАЯ ФОРМА: "получить РОВНО требуемую сумму, потратив не больше введённого" (док 45).
|
|
745
|
+
// Спрашивается тем же путём-победителем, но В ОБРАТНУЮ СТОРОНУ - контракт требует именно так. Ответ
|
|
746
|
+
// важен не только числом: он снимает вопрос о разнице сумм, потому что минимум на выходе становится
|
|
747
|
+
// равен требуемой сумме по построению. Отказ здесь - тоже ответ: значит ровно столько пул не отдаёт,
|
|
748
|
+
// и тогда остаётся обычная форма на вход с минимумом от требуемой суммы.
|
|
749
|
+
let exactOut = null;
|
|
750
|
+
if (requiredOutWei !== null && requiredOutWei !== undefined && Number(requiredOutWei) > 0) {
|
|
751
|
+
const eo = await quoteExactOutPath(chain, { route: q, amountOutWei: BigInt(requiredOutWei) }, opts);
|
|
752
|
+
exactOut = eo.ok
|
|
753
|
+
? {
|
|
754
|
+
ok: true, amountInWei: eo.amountInWei, gasEstimate: eo.gasEstimate,
|
|
755
|
+
// ХВАТИТ ЛИ ВВЕДЁННОГО: сравнивается сумма на входе, потому что у этой формы ответ - именно
|
|
756
|
+
// "сколько надо отдать". Излишек остаётся пользователю: никакой конвертации остатка в коде нет.
|
|
757
|
+
enough: BigInt(eo.amountInWei) <= amount,
|
|
758
|
+
enteredWei: amount.toString(),
|
|
759
|
+
spareInWei: (BigInt(eo.amountInWei) <= amount ? amount - BigInt(eo.amountInWei) : 0n).toString(),
|
|
760
|
+
shortInWei: (BigInt(eo.amountInWei) <= amount ? 0n : BigInt(eo.amountInWei) - amount).toString(),
|
|
761
|
+
}
|
|
762
|
+
: { ok: false, why: eo.why };
|
|
763
|
+
}
|
|
764
|
+
// ПОКРЫТИЕ СЧИТАЕТСЯ ЗДЕСЬ, А НЕ БЕРЁТСЯ ИЗ КЕША: пул и сумма входа кешируются на 20 с, а требуемая
|
|
765
|
+
// сумма приходит из ордера и может смениться (человек поправил размер XMR) - и тогда покрытие обязано
|
|
766
|
+
// пересчитаться, а не остаться прежним. Считается в целых wei.
|
|
767
|
+
const required = requiredOutWei !== null && requiredOutWei !== undefined && Number(requiredOutWei) > 0
|
|
768
|
+
? (() => {
|
|
769
|
+
const need = BigInt(requiredOutWei);
|
|
770
|
+
const gets = BigInt(q.amountOutWei);
|
|
771
|
+
return {
|
|
772
|
+
outWei: need.toString(),
|
|
773
|
+
getsWei: gets.toString(),
|
|
774
|
+
covers: gets >= need,
|
|
775
|
+
shortfallWei: (gets >= need ? 0n : need - gets).toString(),
|
|
776
|
+
};
|
|
777
|
+
})()
|
|
778
|
+
: null;
|
|
779
|
+
return {
|
|
780
|
+
ok: true,
|
|
781
|
+
leg: "pool",
|
|
782
|
+
venue: "Uniswap v3",
|
|
783
|
+
payToken: token.symbol,
|
|
784
|
+
wrapped,
|
|
785
|
+
// ОБЪЯВЛЕННЫЙ МАРШРУТ, КОТОРЫМ СПРОШЕНА ЦЕНА: ступени, их пулы и дата замера - целиком, как в реестре.
|
|
786
|
+
// Интерфейс обязан показывать не только ЧТО получилось, но и ПО ЧЕМУ это объявлено.
|
|
787
|
+
declaredRoute: declaration,
|
|
788
|
+
// ПУТЬ - ЭТО ШАГИ С КОМИССИЯМИ, а не одна ступень: у объявленного обхода через стейбл их две.
|
|
789
|
+
route: { symbols: q.symbols.slice(), fees: q.fees.slice(), pools: path.pools.slice(), via: null, hops: q.fees.length },
|
|
790
|
+
routeLabel: routeLabel(q),
|
|
791
|
+
steps: routeSteps(q),
|
|
792
|
+
fee: q.fees[q.fees.length - 1],
|
|
793
|
+
feeLabel: q.fees.map((f) => feeTierLabel(f)).join(" / "),
|
|
794
|
+
amountInWei: amount.toString(),
|
|
795
|
+
amountOutWei: q.amountOutWei,
|
|
796
|
+
gasEstimate: q.gasEstimate,
|
|
797
|
+
// ВЕРДИКТ ВЫПОЛНИМОСТИ: покрывает ли маршрут требуемую сумму ордера. null означает "требуемая сумма
|
|
798
|
+
// не передана" - и тогда подпись блокируется как непроверенная (см. dexLegVerdict), а не разрешается.
|
|
799
|
+
required,
|
|
800
|
+
// ПРОВЕРКА ЦЕНЫ: проверили ли, похожа ли цена на рыночную, и блокирует ли это подпись на этой сети.
|
|
801
|
+
price,
|
|
802
|
+
// ПРЕДПОЧТИТЕЛЬНАЯ ФОРМА (точный выход): сколько надо отдать, чтобы получить ровно требуемую сумму.
|
|
803
|
+
// Вопрос о разнице сумм при ней не встаёт - минимум на выходе равен требуемому по построению.
|
|
804
|
+
exactOut,
|
|
805
|
+
hopsDetail: hops,
|
|
806
|
+
// Одиночная ступень (прежняя форма ответа) - из первой ноги пути. Читать их как "пул всего маршрута"
|
|
807
|
+
// нельзя, и это сказано тут: у обхода через хаб ног две.
|
|
808
|
+
pool: hops.length ? hops[0].pool : null,
|
|
809
|
+
at: Date.now(),
|
|
810
|
+
};
|
|
811
|
+
}
|
|
812
|
+
|
|
813
|
+
// ---------------------------------------------------------------------------
|
|
814
|
+
// WETH -> ETH: unwrap у самого WETH9, один к одному, БЕЗ пула.
|
|
815
|
+
//
|
|
816
|
+
// Отсюда берётся описание строки "unwrap, 1:1". Оценка газа СЮДА НЕ ВХОДИТ: чтобы назвать газ unwrap,
|
|
817
|
+
// нужен счёт, который держит WETH и может платить за газ, а у страницы такого счёта нет (и отправлять
|
|
818
|
+
// ничего нельзя на этом шаге). Измеренный газ unwrap лежит в отчёте инструмента
|
|
819
|
+
// (tools/check-uniswap-quote.mjs: газ измеряется eth_estimateGas на настоящем держателе WETH, которого
|
|
820
|
+
// инструмент находит по журналам Transfer). Подставлять это число в интерфейс как "газ этой сделки"
|
|
821
|
+
// было бы подменой: оно измерено на чужом счёте и на другой сумме. Поэтому честно: gasEstimate === null.
|
|
822
|
+
// ---------------------------------------------------------------------------
|
|
823
|
+
export function unwrapLeg(chain) {
|
|
824
|
+
const u = chain && chain.uniswap;
|
|
825
|
+
if (!u || !u.wrapped) return { ok: false, why: "no verified wrapped native coin for this network" };
|
|
826
|
+
const w = u.wrapped;
|
|
827
|
+
return {
|
|
828
|
+
ok: true,
|
|
829
|
+
leg: "unwrap",
|
|
830
|
+
venue: w.name || w.symbol,
|
|
831
|
+
wrapped: w,
|
|
832
|
+
rate: 1, // один к одному по определению WETH9: withdraw(wad) отдаёт ровно wad нативного
|
|
833
|
+
gasEstimate: null,
|
|
834
|
+
why: null,
|
|
835
|
+
};
|
|
836
|
+
}
|