@ninsei-labs/sdk 0.39.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (81) hide show
  1. package/CHANGELOG.md +866 -0
  2. package/LICENSE +21 -0
  3. package/README.md +187 -0
  4. package/engine/atomic/dleq2.js +330 -0
  5. package/engine/atomic/halfenc.js +205 -0
  6. package/engine/atomic/halves.js +119 -0
  7. package/engine/atomic/order-client.js +57 -0
  8. package/engine/atomic/order.js +132 -0
  9. package/engine/atomic/orderContext.js +70 -0
  10. package/engine/atomic/orderHalfCommit.js +29 -0
  11. package/engine/core/chainSource.js +306 -0
  12. package/engine/core/clock.js +40 -0
  13. package/engine/core/config.js +885 -0
  14. package/engine/core/finality.js +93 -0
  15. package/engine/core/format.js +168 -0
  16. package/engine/core/preSignGuards.js +495 -0
  17. package/engine/core/rfqSource.js +193 -0
  18. package/engine/core/sellWindow.js +43 -0
  19. package/engine/core/store.js +100 -0
  20. package/engine/core/swap.js +1153 -0
  21. package/engine/evm/amounts.js +131 -0
  22. package/engine/evm/auth.js +156 -0
  23. package/engine/evm/claimGas.js +87 -0
  24. package/engine/evm/depositor.js +127 -0
  25. package/engine/evm/dex.js +836 -0
  26. package/engine/evm/escrow.js +208 -0
  27. package/engine/evm/factory.js +149 -0
  28. package/engine/evm/fees.js +90 -0
  29. package/engine/evm/funding.js +309 -0
  30. package/engine/evm/gasReserve.js +88 -0
  31. package/engine/evm/index.js +123 -0
  32. package/engine/evm/injected.js +175 -0
  33. package/engine/evm/permit.js +125 -0
  34. package/engine/evm/prices.js +179 -0
  35. package/engine/evm/session.js +777 -0
  36. package/engine/mock/chain.js +98 -0
  37. package/engine/monero/address.js +265 -0
  38. package/engine/monero/halvesSweep.js +68 -0
  39. package/engine/monero/node.js +110 -0
  40. package/engine/monero/scan.js +325 -0
  41. package/engine/monero/swapKeys.js +69 -0
  42. package/engine/monero/unlock.js +46 -0
  43. package/engine/monero/wallet.js +168 -0
  44. package/engine/recovery/recoveryFile.js +363 -0
  45. package/engine/recovery/restore.js +176 -0
  46. package/engine/sdk/bridge.js +609 -0
  47. package/engine/ui/dom.js +90 -0
  48. package/engine/ui/walletModal.js +132 -0
  49. package/index.d.ts +1417 -0
  50. package/package.json +74 -0
  51. package/src/actions.mjs +72 -0
  52. package/src/adapters/evm-wallet.mjs +126 -0
  53. package/src/adapters/index.mjs +21 -0
  54. package/src/adapters/local-storage.mjs +32 -0
  55. package/src/adapters/monero-wallet.mjs +185 -0
  56. package/src/adapters/monero.mjs +51 -0
  57. package/src/adapters/node-test-evm-wallet.mjs +345 -0
  58. package/src/codes.mjs +188 -0
  59. package/src/config.mjs +277 -0
  60. package/src/engine.mjs +53 -0
  61. package/src/errors.mjs +24 -0
  62. package/src/http.mjs +72 -0
  63. package/src/index.mjs +256 -0
  64. package/src/legs/evm.mjs +118 -0
  65. package/src/legs/index.mjs +50 -0
  66. package/src/levelsWalkSpec.mjs +183 -0
  67. package/src/lock.mjs +140 -0
  68. package/src/order.mjs +212 -0
  69. package/src/preflight.mjs +137 -0
  70. package/src/primitives.mjs +47 -0
  71. package/src/quoteEip712.mjs +35 -0
  72. package/src/quoteEip712Spec.mjs +214 -0
  73. package/src/quotes.mjs +465 -0
  74. package/src/record.mjs +65 -0
  75. package/src/recovery.mjs +40 -0
  76. package/src/sim.mjs +37 -0
  77. package/src/swap-flow.mjs +492 -0
  78. package/src/swaps.mjs +935 -0
  79. package/src/sweep.mjs +222 -0
  80. package/src/wallet.mjs +164 -0
  81. package/src/xmr-arrival.mjs +289 -0
@@ -0,0 +1,175 @@
1
+ // GENERATED FILE - a byte-for-byte copy of the engine module www/js/evm/injected.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
+ // Браузерные кошельки: обнаружение по EIP-6963 и подключение по EIP-1193.
7
+ //
8
+ // ПОЧЕМУ EIP-6963, А НЕ window.ethereum.isMetaMask. При двух установленных расширениях
9
+ // window.ethereum - это «кто успел последним», и угадывание по флагам даёт чужие имена и иконки:
10
+ // пользователь видит «MetaMask», а подключается Rabby. EIP-6963 решает это стандартно: кошелёк
11
+ // объявляет себя событием `eip6963:announceProvider` с именем, иконкой и своим провайдером, а
12
+ // страница просит объявиться событием `eip6963:requestProvider`. Так список кошельков настоящий,
13
+ // и он же обновляется, если пользователь поставил расширение при открытой вкладке.
14
+ //
15
+ // window.ethereum остаётся фолбэком для старых кошельков, которые 6963 ещё не умеют. В этом
16
+ // случае честное имя - «Browser wallet»: назвать его MetaMask мы не имеем права, не проверив.
17
+ //
18
+ // Здесь НЕТ состояния: этот модуль только находит провайдеров и делает рукопожатие. Состояние,
19
+ // балансы и смена сети живут в evm/session.js - общие для обоих коннекторов.
20
+
21
+ const ANNOUNCE = "eip6963:announceProvider";
22
+ const REQUEST = "eip6963:requestProvider";
23
+
24
+ // Коды EIP-1193, которые важны на экране: отказ пользователя - это не ошибка приложения.
25
+ export const USER_REJECTED = 4001;
26
+
27
+ export function isUserRejection(error) {
28
+ const code = error && (error.code || (error.data && error.data.originalError && error.data.originalError.code));
29
+ if (code === USER_REJECTED) return true;
30
+ return /rejected|denied|cancell?ed|closed by user/i.test(String((error && error.message) || error || ""));
31
+ }
32
+
33
+ // Человеческое имя для фолбэка: флаги - единственное, что есть у старых кошельков.
34
+ function fallbackName(eth) {
35
+ if (!eth) return null;
36
+ if (eth.isMetaMask) return "MetaMask";
37
+ if (eth.isRabby) return "Rabby";
38
+ if (eth.isCoinbaseWallet) return "Coinbase Wallet";
39
+ if (eth.isBraveWallet) return "Brave Wallet";
40
+ if (eth.isFrame) return "Frame";
41
+ return "Browser wallet";
42
+ }
43
+
44
+ // Собрать список кошельков: сначала объявившиеся по EIP-6963, затем фолбэк window.ethereum.
45
+ // timeoutMs нужен потому, что announceProvider - событие: без паузы список будет пустым даже при
46
+ // установленном кошельке (у расширения свой темп инициализации).
47
+ export function discoverWallets({ win = globalThis, timeoutMs = 250 } = {}) {
48
+ const found = [];
49
+ const seen = new Set();
50
+ const add = (entry) => {
51
+ const key = entry.rdns || entry.uuid || entry.name;
52
+ if (seen.has(key)) return;
53
+ seen.add(key);
54
+ found.push(entry);
55
+ };
56
+
57
+ const onAnnounce = (event) => {
58
+ const detail = event && event.detail;
59
+ if (!detail || !detail.provider || !detail.info) return;
60
+ add({
61
+ uuid: detail.info.uuid || null,
62
+ name: detail.info.name || "Browser wallet",
63
+ icon: detail.info.icon || null,
64
+ rdns: detail.info.rdns || null,
65
+ provider: detail.provider,
66
+ source: "eip6963",
67
+ });
68
+ };
69
+
70
+ if (win && typeof win.addEventListener === "function") {
71
+ win.addEventListener(ANNOUNCE, onAnnounce);
72
+ if (typeof win.dispatchEvent === "function") {
73
+ // Просим объявиться; ответы придут асинхронно, поэтому ждём короткую паузу.
74
+ win.dispatchEvent(new Event(REQUEST));
75
+ }
76
+ }
77
+
78
+ return new Promise((resolve) => {
79
+ const finish = () => {
80
+ if (win && typeof win.removeEventListener === "function") win.removeEventListener(ANNOUNCE, onAnnounce);
81
+ // Фолбэк: старый кошелёк, который 6963 не умеет, но window.ethereum выставил.
82
+ // providers (массив) бывает, когда установлено несколько кошельков без поддержки 6963.
83
+ const legacy = win && win.ethereum;
84
+ if (!found.length && legacy) {
85
+ const list = Array.isArray(legacy.providers) && legacy.providers.length ? legacy.providers : [legacy];
86
+ for (const p of list) {
87
+ add({ name: fallbackName(p), icon: null, rdns: null, provider: p, source: "legacy" });
88
+ }
89
+ }
90
+ resolve(found);
91
+ };
92
+ if (timeoutMs > 0 && typeof setTimeout === "function") setTimeout(finish, timeoutMs);
93
+ else finish();
94
+ });
95
+ }
96
+
97
+ // Подписка на «кошелёк появился». Нужна, чтобы список в модалке не был снимком на момент открытия:
98
+ // пользователь может поставить расширение, не перезагружая страницу.
99
+ //
100
+ // Работаем именно с самим объявлением, а не с повторным обнаружением: событие EIP-6963 несёт и имя,
101
+ // и провайдер кошелька, а события не переигрываются - кошелёк, объявившийся до нашей подписки, при
102
+ // повторном запросе ответит снова, но снимок всё равно был бы потерян. Так список только дополняется.
103
+ export function watchWallets(fn, { win = globalThis } = {}) {
104
+ if (!win || typeof win.addEventListener !== "function") return () => {};
105
+ const known = new Map();
106
+ const publish = () => fn([...known.values()]);
107
+ const onAnnounce = (event) => {
108
+ const detail = event && event.detail;
109
+ if (!detail || !detail.provider || !detail.info) return;
110
+ const key = detail.info.rdns || detail.info.uuid || detail.info.name;
111
+ if (!key || known.has(key)) return;
112
+ known.set(key, {
113
+ uuid: detail.info.uuid || null,
114
+ name: detail.info.name || "Browser wallet",
115
+ icon: detail.info.icon || null,
116
+ rdns: detail.info.rdns || null,
117
+ provider: detail.provider,
118
+ source: "eip6963",
119
+ });
120
+ publish();
121
+ };
122
+ win.addEventListener(ANNOUNCE, onAnnounce);
123
+ // Просим объявиться: кошельки, которые уже установлены, ответят прямо сейчас и попадут в список.
124
+ // Пустой список наружу не отдаём - иначе UI очищался бы просто потому, что кошельков нет.
125
+ if (typeof win.dispatchEvent === "function") win.dispatchEvent(new Event(REQUEST));
126
+ return () => win.removeEventListener(ANNOUNCE, onAnnounce);
127
+ }
128
+
129
+ // Рукопожатие EIP-1193: просим аккаунты (здесь кошелёк спросит разрешение) и узнаём сеть.
130
+ export async function handshake(provider) {
131
+ if (!provider || typeof provider.request !== "function") {
132
+ throw new Error("Этот кошелёк не умеет EIP-1193 (нет request)");
133
+ }
134
+ const accounts = await provider.request({ method: "eth_requestAccounts" });
135
+ const address = Array.isArray(accounts) ? accounts[0] : null;
136
+ const chainIdHex = await provider.request({ method: "eth_chainId" });
137
+ return { address: address || null, chainId: Number(chainIdHex) || null };
138
+ }
139
+
140
+ // Тихая проверка «сайт уже разрешён этому кошельку» - для восстановления после перезагрузки.
141
+ // eth_accounts НЕ показывает окно кошелька: пустой ответ значит «разрешения нет», и это нормальное
142
+ // состояние, а не ошибка. Именно поэтому нельзя звать здесь eth_requestAccounts: страница при
143
+ // загрузке не должна выкидывать окно подключения.
144
+ export async function silentAccounts(provider) {
145
+ if (!provider || typeof provider.request !== "function") return [];
146
+ try {
147
+ const accounts = await provider.request({ method: "eth_accounts" });
148
+ return Array.isArray(accounts) ? accounts : [];
149
+ } catch {
150
+ return [];
151
+ }
152
+ }
153
+
154
+ // Почему кошелёк мог не открыться - переводим код ошибки в понятную причину.
155
+ //
156
+ // -32002 - самая частая причина «нажал, а окно не появилось»: у кошелька уже есть открытый
157
+ // запрос (окно висит на другом экране/вкладке, или предыдущая попытка не завершена).
158
+ export function explainInjectedError(e, label = "Кошелёк") {
159
+ const code = e && (e.code || (e.data && e.data.originalError && e.data.originalError.code));
160
+ const message = String((e && e.message) || e);
161
+ if (code === -32002 || /already pending|already processing/i.test(message)) {
162
+ return (
163
+ label +
164
+ ": в кошельке уже есть незавершённый запрос, поэтому новое окно не открылось. " +
165
+ "Открой расширение, заверши или отмени тот запрос и нажми снова."
166
+ );
167
+ }
168
+ if (/no provider|not connected|provider is disconnected|undefined is not a function/i.test(message)) {
169
+ return label + ": расширение не отвечает (браузер мог выгрузить его или страница открыта не там, где оно работает).";
170
+ }
171
+ if (/user rejected|user denied|rejected by user/i.test(message) || code === 4001) {
172
+ return label + ": запрос отклонён в кошельке.";
173
+ }
174
+ return label + ": " + message;
175
+ }
@@ -0,0 +1,125 @@
1
+ // GENERATED FILE - a byte-for-byte copy of the engine module www/js/evm/permit.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
+ // РАЗРЕШЕНИЕ НА ТОКЕН ДЛЯ ПОКУПКИ БЕЗ СВОЕГО ETH (issue #97, часть C): что именно умеет токен и чего не хватает.
7
+ //
8
+ // ЧТО ЗДЕСЬ И ЧЕГО НЕТ. Здесь ТОЛЬКО селекторы, сборка calldata и ЧЕСТНЫЙ вердикт о том, хватает ли протоколу
9
+ // разрешения по подписи. Ни одного вывода «у USDC permit есть» из памяти тут не записано: такой вывод -
10
+ // догадка, а догадка об учётной функции токена стоит денег. Возможность ПРОВЕРЯЕТСЯ чтением цепи (есть ли у
11
+ // адреса код, отвечает ли DOMAIN_SEPARATOR словом, отвечает ли nonces(owner)), и вердикт умеет говорить
12
+ // «неизвестно» - третьим значением, а не одним из двух.
13
+ //
14
+ // ЧТО УЖЕ ПРОВЕРЕНО ПРО НАШУ НОГУ ОБМЕНА (docs/48, www/js/evm/dexExec.js): разрешение нужно РОУТЕРУ обычным
15
+ // approve. Роутер этой сборки Permit2 НЕ ЗНАЕТ (`permit2()` у него откатывает, перенос оплачивает
16
+ // TransferHelper.safeTransferFrom от msg.sender). Значит Permit2 в реестре лежит для БУДУЩЕГО пути
17
+ // финансирования, а не потому, что этот своп его вызывает.
18
+ //
19
+ // ПОЧЕМУ ОДНОЙ ТРАНЗАКЦИИ ЗДЕСЬ НЕТ, И ЭТО НЕ ЗАБЫВЧИВОСТЬ. Своп платит ТОТ, КОГО роутер видит `msg.sender`.
20
+ // Пользователь без ETH не может отправить ни approve, ни своп, ни внесение. Внести за него и списать USDC по
21
+ // его подписи может только КОНТРАКТ-исполнитель (EOA чужую подпись на списание предъявить не может). Такого
22
+ // контракта в репозитории нет и план 61 его не называет, поэтому `usdcWithoutEthVerdict` ОТКАЗЫВАЕТСЯ назвать
23
+ // путь исполняемым и говорит, чего не хватает. Собирать calldata «на всякий случай» - значит выдать догадку
24
+ // за согласие (ровно то, что док 48 называет ловушкой подставленного получателя).
25
+ //
26
+ // ПОДПИСЬ ВНОСЯЩЕГО (www/js/evm/depositor.js) - ДРУГОЕ РАЗРЕШЕНИЕ. Оно даёт релейщику право ОТПРАВИТЬ
27
+ // транзакцию создания ордера, но не право списать токен. Одно другое не заменяет: списание USDC требует
28
+ // отдельного разрешения ПО ТОКЕНУ (approve или permit), и оно адресуется тому, кто тянет токен.
29
+
30
+ // Селекторы ERC-20 и EIP-2612. Рядом с каждым - строка подписи, из которой он посчитан: проверка
31
+ // (tools/check-usdc-without-eth.mjs) пересчитывает keccak256 от этих строк, поэтому селектор не может
32
+ // разойтись с подписью молча.
33
+ export const ERC20_METHOD = {
34
+ approve: { hex: "0x095ea7b3", signature: "approve(address,uint256)" },
35
+ allowance: { hex: "0xdd62ed3e", signature: "allowance(address,address)" },
36
+ balanceOf: { hex: "0x70a08231", signature: "balanceOf(address)" },
37
+ };
38
+ export const EIP2612_METHOD = {
39
+ permit: { hex: "0xd505accf", signature: "permit(address,address,uint256,uint256,uint8,bytes32,bytes32)" },
40
+ nonces: { hex: "0x7ecebe00", signature: "nonces(address)" },
41
+ DOMAIN_SEPARATOR: { hex: "0x3644e515", signature: "DOMAIN_SEPARATOR()" },
42
+ };
43
+
44
+ const hexBody = (s) => String(s === undefined || s === null ? "" : s).replace(/^0x/, "");
45
+ const isAddr = (a) => /^0x[0-9a-fA-F]{40}$/.test(String(a || ""));
46
+ const addrWord = (a) => hexBody(a).toLowerCase().padStart(64, "0");
47
+ const uintWord = (v) => BigInt(v).toString(16).padStart(64, "0");
48
+ const wordBytes = (hex) => {
49
+ const b = hexBody(hex);
50
+ // ПУСТОЙ ОТВЕТ - ЭТО «НЕ ПРОЧИТАЛИ», А НЕ «НОЛЬ БАЙТ». Так eth_call отвечает по адресу без кода:
51
+ // показать это как «нет домена» значило бы принять незнание за отказ токена.
52
+ if (b.length === 0) return null;
53
+ return b.length % 2 ? null : b.length / 2;
54
+ };
55
+
56
+ // calldata для разрешения по подписи (EIP-2612). Подпись - r || s || v (65 байт); v - в {27,28}.
57
+ export function encodePermit({ owner, spender, value, deadline, signature }) {
58
+ if (!isAddr(owner) || !isAddr(spender)) throw new Error("permit: owner и spender обязаны быть адресами");
59
+ const hex = hexBody(signature);
60
+ if (hex.length !== 130) throw new Error("permit: подпись обязана быть 65 байт, а пришло " + hex.length / 2);
61
+ let v = parseInt(hex.slice(128, 130), 16);
62
+ if (v < 27) v += 27;
63
+ if (v !== 27 && v !== 28) throw new Error("permit: v вне {27,28}: " + v);
64
+ return EIP2612_METHOD.permit.hex +
65
+ addrWord(owner) + addrWord(spender) + uintWord(value) + uintWord(deadline) +
66
+ uintWord(v) + hex.slice(0, 64) + hex.slice(64, 128);
67
+ }
68
+
69
+ export const encodeNonces = (owner) => EIP2612_METHOD.nonces.hex + addrWord(owner);
70
+ export const encodeDomainSeparator = () => EIP2612_METHOD.DOMAIN_SEPARATOR.hex;
71
+ export const encodeApprove = ({ spender, amountWei }) => ERC20_METHOD.approve.hex + addrWord(spender) + uintWord(amountWei);
72
+
73
+ /**
74
+ * Умеет ли ЭТОТ адрес разрешение по подписи (EIP-2612). Ответ - ТРИ состояния, и это не педантизм:
75
+ * «не проверено» обязано отличаться от «нет».
76
+ * supported === true - код есть, DOMAIN_SEPARATOR отвечает словом, nonces(owner) отвечает словом,
77
+ * и в байткоде присутствует селектор permit;
78
+ * supported === false - код есть, но одного из признаков НЕТ (тогда разрешение по подписи недоступно);
79
+ * supported === null - прочитать не удалось (нет кода/пустой ответ): это НЕ «нет permit», это «не знаем».
80
+ */
81
+ export function permitSupportVerdict({ code = null, domainSeparator = null, nonces = null, owner = null } = {}) {
82
+ const codeHex = hexBody(code);
83
+ if (codeHex.length === 0) return { supported: null, kind: "no-code", reason: "по адресу токена нет кода - это не токен, и разрешение читать не у чего" };
84
+ if (!isAddr(owner)) return { supported: null, kind: "no-owner", reason: "владелец не назван - nonces(owner) прочитать нечем" };
85
+ const dsBytes = wordBytes(domainSeparator);
86
+ if (dsBytes === null) return { supported: null, kind: "domain-unread", reason: "DOMAIN_SEPARATOR не отвечает 32 байтами: читать не удалось" };
87
+ if (dsBytes !== 32) return { supported: false, kind: "domain-empty", reason: "DOMAIN_SEPARATOR вернул " + dsBytes + " байт вместо 32: EIP-712-домен не заявлен" };
88
+ const nonceBytes = wordBytes(nonces);
89
+ if (nonceBytes === null) return { supported: null, kind: "nonces-unread", reason: "nonces(owner) не отвечает: читать не удалось" };
90
+ if (nonceBytes !== 32) return { supported: false, kind: "nonces-empty", reason: "nonces(owner) вернул " + nonceBytes + " байт вместо 32: EIP-2612-счётчика нет" };
91
+ if (!codeHex.toLowerCase().includes(EIP2612_METHOD.permit.hex.slice(2))) {
92
+ return { supported: false, kind: "permit-selector-absent", reason: "в байткоде токена нет селектора permit: разрешения по подписи токен не предоставляет" };
93
+ }
94
+ return { supported: true, kind: "eip2612", reason: "токен заявляет EIP-712-домен, счётчик nonces и селектор permit" };
95
+ }
96
+
97
+ /**
98
+ * Можно ли провести покупку за токен БЕЗ своего ETH ОДНОЙ транзакцией. Вердикт говорит ИМЕННО то, чего не
99
+ * хватает, и НЕ выдаёт calldata, пока путь не исполним полностью.
100
+ * @param {{ orchestrator?: string|null, permitSupported?: boolean|null, token?: string|null }} args
101
+ */
102
+ export function usdcWithoutEthVerdict({ orchestrator = null, permitSupported = null, token = null } = {}) {
103
+ // ПЕРВЫЙ И ГЛАВНЫЙ ОТКАЗ, и он не про токен. Своп платит msg.sender (роутер), а чужую подпись на списание
104
+ // может предъявить только КОНТРАКТ. Без исполнителя однотранзакционный путь не существует ВООБЩЕ - и это
105
+ // не зависит от того, есть ли у токена permit.
106
+ if (!isAddr(orchestrator)) {
107
+ return {
108
+ ok: false, blocked: true, kind: "no-orchestrator",
109
+ reason: "нет контракта-исполнителя: списать токен по подписи пользователя и внести натив в эскроу одной транзакцией может только контракт, а в этой сборке его нет. Подпись вносящего без него даёт релейщику право ОТПРАВИТЬ создание ордера, но не право списать токен",
110
+ };
111
+ }
112
+ if (permitSupported === false) {
113
+ return {
114
+ ok: false, blocked: true, kind: "token-no-permit",
115
+ reason: "у токена нет разрешения по подписи (EIP-2612): разрешение придётся выдавать отдельным approve, то есть отдельной транзакцией - одной транзакцией путь не собирается",
116
+ };
117
+ }
118
+ if (permitSupported !== true) {
119
+ return {
120
+ ok: false, blocked: true, kind: "permit-unproven",
121
+ reason: "неизвестно, умеет ли токен разрешение по подписи: «не проверено» - это не «можно». Возможность обязана быть прочитана у цепи до сборки пути",
122
+ };
123
+ }
124
+ return { ok: true, blocked: false, kind: "one-tx-possible", reason: "исполнитель назван, токен заявляет EIP-2612: путь одной транзакцией собирается" };
125
+ }
@@ -0,0 +1,179 @@
1
+ // GENERATED FILE - a byte-for-byte copy of the engine module www/js/evm/prices.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
+ // Живые рыночные числа для EVM-ноги: цена нейтива (Chainlink on-chain) и стоимость газа (RPC).
7
+ //
8
+ // ПОЧЕМУ ON-CHAIN, А НЕ API. Цена берётся вызовом контракта через тот же RPC, что уже разрешён
9
+ // в CSP (`connect-src`), поэтому:
10
+ // - не появляется ни одного нового внешнего домена (и ни одного нового доверенного сервиса);
11
+ // - нет ключей, лимитов и «мы упали, потому что у CoinGecko 429»;
12
+ // - значение нельзя перепутать с ценой другой сети: адрес фида свой у каждой цепи.
13
+ // Адреса фидов НЕ выдуманы: каждый подтверждён вызовом в сети - описание `description()` должно
14
+ // совпасть с ожидаемой парой, а ответ - попасть в разумный коридор и быть свежим. Проверка живёт
15
+ // в tools/check-prices.mjs (работает на заглушке RPC, без сети).
16
+ //
17
+ // ЧЕГО ЗДЕСЬ НЕТ: если фида для сети нет или он молчит - возвращаем null, и вызывающий код обязан
18
+ // показать, что число демонстрационное, а не подставить вчерашнюю константу молча.
19
+
20
+ const SELECTOR = {
21
+ description: "0x7284e416", // description()
22
+ latestRoundData: "0xfeaf968c", // latestRoundData() -> (roundId, answer, startedAt, updatedAt, answeredInRound)
23
+ };
24
+
25
+ const CACHE_MS = 60_000; // цена: минута - компромисс между свежестью и числом запросов
26
+ const GAS_CACHE_MS = 30_000; // газ меняется чаще, но и он не обязан бегать на каждый ввод символа
27
+ const MAX_AGE_MS = 24 * 60 * 60 * 1000; // старше суток фид считаем несвежим и не показываем как живой
28
+
29
+ const cache = new Map(); // ключ: chainId + вид запроса
30
+
31
+ function cached(key, ttlMs) {
32
+ const hit = cache.get(key);
33
+ if (hit && Date.now() - hit.at < ttlMs) return hit.value;
34
+ return undefined;
35
+ }
36
+
37
+ function put(key, value) {
38
+ cache.set(key, { at: Date.now(), value });
39
+ return value;
40
+ }
41
+
42
+ async function rpc(chain, method, params = []) {
43
+ const res = await fetch(chain.rpcUrl, {
44
+ method: "POST",
45
+ headers: { "content-type": "application/json" },
46
+ body: JSON.stringify({ jsonrpc: "2.0", id: 1, method, params }),
47
+ });
48
+ if (!res.ok) throw new Error(method + ": HTTP " + res.status);
49
+ const j = await res.json();
50
+ if (j.error) throw new Error(method + ": " + (j.error.message || "ошибка RPC"));
51
+ return j.result;
52
+ }
53
+
54
+ const word = (data, i) => BigInt("0x" + String(data).slice(2).slice(i * 64, (i + 1) * 64));
55
+
56
+ // Декодирование строки ABI: [offset][length][bytes]. Нужно, чтобы отличить фид ETH/USD от любого
57
+ // другого, который кто-то подставил в конфиг. TextDecoder, а не Buffer: этот код живёт в браузере.
58
+ function decodeString(data) {
59
+ const s = String(data || "");
60
+ if (s.length < 2 + 128) return "";
61
+ const len = Number(BigInt("0x" + s.slice(2 + 64, 2 + 128)));
62
+ const body = s.slice(2 + 128, 2 + 128 + len * 2);
63
+ const bytes = new Uint8Array(body.length / 2);
64
+ for (let i = 0; i < bytes.length; i++) bytes[i] = parseInt(body.slice(i * 2, i * 2 + 2), 16);
65
+ return new TextDecoder().decode(bytes);
66
+ }
67
+
68
+ // Цена нейтива в USD. null - если фида нет, он молчит или значения не проходят проверку.
69
+ export async function nativeUsd(chain, { force = false } = {}) {
70
+ const feed = chain && chain.priceFeed;
71
+ if (!feed || !feed.address) return null;
72
+ const key = chain.id + ":usd";
73
+ if (!force) {
74
+ const hit = cached(key, CACHE_MS);
75
+ if (hit !== undefined) return hit;
76
+ }
77
+ try {
78
+ const [descHex, roundHex] = await Promise.all([
79
+ rpc(chain, "eth_call", [{ to: feed.address, data: SELECTOR.description }, "latest"]),
80
+ rpc(chain, "eth_call", [{ to: feed.address, data: SELECTOR.latestRoundData }, "latest"]),
81
+ ]);
82
+ const desc = decodeString(descHex);
83
+ const answer = word(roundHex, 1);
84
+ const updatedAt = Number(word(roundHex, 3)) * 1000;
85
+ const decimals = Number(feed.decimals || 8);
86
+ const value = Number(answer) / 10 ** decimals;
87
+ const fresh = updatedAt > 0 && Date.now() - updatedAt < MAX_AGE_MS;
88
+ const sane = value >= (feed.min || 0.01) && value <= (feed.max || 1e7);
89
+ if (desc !== feed.pair || !fresh || !sane) {
90
+ return put(key, null); // null, а не «примерно столько»: молчание честнее выдумки
91
+ }
92
+ return put(key, { usd: value, updatedAt, source: "chainlink", pair: desc });
93
+ } catch {
94
+ return put(key, null);
95
+ }
96
+ }
97
+
98
+ // Стоимость газа в USD: оценка лимита × цена газа × цена нейтива.
99
+ // На Arbitrum в цену газа уже входит L1-составляющая (так устроен их gas price), поэтому
100
+ // отдельный вызов NodeInterface не нужен - обычная оценка даёт сопоставимую с кошельками цифру.
101
+ export async function gasUsd(chain, { from = null, payToken = null, amountWei = null } = {}) {
102
+ const key = chain.id + ":gas";
103
+ const hit = cached(key, GAS_CACHE_MS);
104
+ if (hit !== undefined) return hit;
105
+ const price = await nativeUsd(chain);
106
+ if (!price) return put(key, null);
107
+ try {
108
+ const gasPrice = BigInt(await rpc(chain, "eth_gasPrice"));
109
+ let gasLimit = null;
110
+ let measured = false;
111
+ // Оцениваем ту транзакцию, которую человек и подпишет: перевод оплачиваемого токена.
112
+ // Эскроу ещё не задеплоен, поэтому это честная нижняя оценка, а не «газ свопа».
113
+ try {
114
+ // Без аккаунта eth_estimateGas подставляет НУЛЕВОЙ адрес, и перевод токена справедливо
115
+ // падает с "ERC20: transfer from the zero address". Поэтому без from не оцениваем вовсе:
116
+ // берём типовой лимит и честно помечаем, что он не измерен (в интерфейсе это видно).
117
+ // ВАЖНО: признак "перевод токена" и признак "есть аккаунт" - РАЗНЫЕ условия. Раньше они
118
+ // были слиты в одну ветку, и без аккаунта перевод токена получал лимит нативного перевода
119
+ // (21000 вместо 65000) - поймал tools/check-prices.mjs.
120
+ const tokenTransfer = Boolean(payToken && !payToken.native);
121
+ if (tokenTransfer && from) {
122
+ // transfer(address,uint256) = 0xa9059cbb
123
+ const to = String(from || "").replace(/^0x/, "").padStart(64, "0");
124
+ const value = BigInt(amountWei || 1).toString(16).padStart(64, "0");
125
+ gasLimit = BigInt(await rpc(chain, "eth_estimateGas", [{ from, to: payToken.address, data: "0xa9059cbb" + to + value }]));
126
+ measured = true;
127
+ } else if (from) {
128
+ gasLimit = BigInt(await rpc(chain, "eth_estimateGas", [{ from, to: from, value: "0x1" }]));
129
+ measured = true;
130
+ } else {
131
+ // Ни аккаунта, ни измерения: типовой лимит и честный признак «не измерено».
132
+ gasLimit = BigInt(chain.gasLimitFallback || (tokenTransfer ? 65000 : 21000));
133
+ }
134
+ } catch {
135
+ gasLimit = BigInt(chain.gasLimitFallback || (tokenTransfer ? 65000 : 21000));
136
+ }
137
+ const wei = gasPrice * gasLimit;
138
+ const usd = (Number(wei) / 1e18) * price.usd;
139
+ return put(key, { usd, gasLimit: gasLimit.toString(), gasPrice: gasPrice.toString(), native: price, source: "rpc", measured });
140
+ } catch {
141
+ return put(key, null);
142
+ }
143
+ }
144
+
145
+ // Одна точка обновления: подтянуть цену и газ для сети и вернуть то, чем можно подписать интерфейс.
146
+ export async function refreshChainPrices(chain, opts = {}) {
147
+ if (!chain || !chain.rpcUrl) return { nativeUsd: null, gasUsd: null, source: "none" };
148
+ const [price, gas] = await Promise.all([nativeUsd(chain), gasUsd(chain, opts)]);
149
+ // Цена нейтива - общая для всех расчётов сети (балансы, «≈ $», оценка газа), поэтому пишем её
150
+ // в конфиг сети: иначе одна и та же цифра жила бы в двух местах и разъезжалась.
151
+ if (price && Number.isFinite(price.usd)) chain.native.usd = price.usd;
152
+ return {
153
+ nativeUsd: price ? price.usd : null,
154
+ nativeUpdatedAt: price ? price.updatedAt : null,
155
+ gasUsd: gas ? gas.usd : null,
156
+ // ЦЕНА ГАЗА В ВЕЙ - НАРУЖУ, А НЕ ТОЛЬКО СТОИМОСТЬ ОЦЕНКИ. Из неё считается запас газа на действия ордера
157
+ // (www/js/evm/gasReserve.js): отметка готовности и возврат - другие транзакции, у них свои пределы, и
158
+ // «стоимость одной оценки» их не заменяет. Нет газа у цепи - null, и вызывающий обязан сказать, что запас
159
+ // не измерен, а не подставить ноль.
160
+ gasPriceWei: gas ? gas.gasPrice : null,
161
+ gasSource: gas ? gas.source : null,
162
+ gasMeasured: gas ? Boolean(gas.measured) : false,
163
+ source: price ? "chainlink" : "demo",
164
+ };
165
+ }
166
+
167
+ // Коротко и в одну строку, но про ОБЕ половины: строка называется «Gas & price», значит и газ,
168
+ // и цена должны быть в значении. Живая цена видна по самому тексту (Chainlink), демо - по «demo values».
169
+ export function priceLabel(info) {
170
+ if (!info || !info.source || info.source === "none") return "demo values";
171
+ if (info.source !== "chainlink") return "demo values";
172
+ const when = info.nativeUpdatedAt ? new Date(info.nativeUpdatedAt).toISOString().slice(11, 16) : "?";
173
+ const gas = info.gasMeasured ? "gas by RPC" : "gas estimate";
174
+ return gas + ", price by Chainlink at " + when + " UTC";
175
+ }
176
+
177
+ export function resetPriceCache() {
178
+ cache.clear();
179
+ }