@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,777 @@
1
+ // GENERATED FILE - a byte-for-byte copy of the engine module www/js/evm/session.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: состояние, балансы, смена сети и два способа подключения.
7
+
8
+ // ПОЧЕМУ ДВА КОННЕКТОРА. WalletConnect (Reown) нужен, когда пользователь пришёл с телефона или из
9
+ // кошелька без расширения: сессия живёт по QR. Браузерный кошелёк (MetaMask, Rabby, Coinbase...),
10
+ // если он установлен, подключается напрямую через EIP-1193 - это быстрее и без лишнего звена.
11
+ // Оба дают один и тот же интерфейс EIP-1193, поэтому состояние, балансы и смена сети у них общие:
12
+ // разница только в рукопожатии (см. www/js/evm/injected.js).
13
+
14
+ // Что здесь реально, а что нет - принципиально важно:
15
+ // РЕАЛЬНО: подключение (QR или браузерное расширение), адрес, chainId, балансы нативного токена и
16
+ // ERC-20 (значения приходят от кошелька пользователя через его провайдер, decimals - из
17
+ // конфига);
18
+ // НЕ РЕАЛЬНО: переводы. Контракт escrow пока не задеплоен, адрес escrow в демке - мок, поэтому
19
+ // отправка транзакции остаётся имитацией (см. evm/index.js). Реальные деньги демка не
20
+ // двигает и не должна.
21
+
22
+ // Модалку рисуем сами (ui/walletModal.js): у Reown она со inline-стилями, а CSP демки их запрещает.
23
+ // Провайдер WalletConnect инициализируется с showQrModal: false, ссылку сессии берём из события
24
+ // display_uri и кодируем в QR в data:-URL.
25
+
26
+ // Балансы кешируются в состоянии: UI спрашивает balanceOf() синхронно, а обновление идёт
27
+ // через refreshBalances() (подключение, смена сети, событие кошелька).
28
+ import { CHAINS, EVM, chainById, chainByChainId, tokensOf } from "../core/config.js";
29
+ import {
30
+ discoverWallets,
31
+ explainInjectedError,
32
+ handshake as injectedHandshake,
33
+ isUserRejection,
34
+ silentAccounts,
35
+ } from "./injected.js";
36
+ import { openWalletModal } from "../ui/walletModal.js";
37
+ import { toHuman } from "./amounts.js";
38
+ // ПРАВИЛО КОМИССИИ CLAIM - ИЗ ОБЩЕГО МОДУЛЯ (issue #127): та же формула, по которой нода считает подарок.
39
+ import { claimMaxFeePerGasWei } from "./claimGas.js";
40
+
41
+ const listeners = new Set();
42
+
43
+ const state = {
44
+ status: "idle", // idle | pairing | connected | error
45
+ kind: null, // walletconnect | injected - каким коннектором подключены
46
+ label: null, // имя кошелька для UI: "WalletConnect", "MetaMask", "Rabbit"...
47
+ address: null,
48
+ chainId: null, // числовой chainId сети, в которой сейчас кошелёк
49
+ balances: {}, // "42161:USDC" -> человекочитаемое число
50
+ error: null,
51
+ lastRefresh: 0,
52
+ };
53
+
54
+ let provider = null;
55
+ let modal = null;
56
+ let lib = null;
57
+
58
+ function emit() {
59
+ for (const fn of listeners) {
60
+ try {
61
+ fn(publicState());
62
+ } catch (e) {
63
+ console.warn("wallet listener failed", e);
64
+ }
65
+ }
66
+ }
67
+
68
+ function publicState() {
69
+ return { ...state, balances: { ...state.balances } };
70
+ }
71
+
72
+ export function subscribe(fn) {
73
+ listeners.add(fn);
74
+ return () => listeners.delete(fn);
75
+ }
76
+
77
+ export function status() {
78
+ return publicState();
79
+ }
80
+
81
+ export function isConnected() {
82
+ return state.status === "connected" && !!state.address;
83
+ }
84
+
85
+ export function address() {
86
+ return state.address;
87
+ }
88
+
89
+ export function chainIdOf() {
90
+ return state.chainId;
91
+ }
92
+
93
+ // ПРОВАЙДЕР КОШЕЛЬКА НАРУЖУ - ЯДРУ, А НЕ ЭКРАНАМ. Ядро (sdk/) получает кошелёк адаптером и окон не
94
+ // открывает: ему нужен тот же EIP-1193 провайдер, которым подписывает страница. Второй провайдер заводить
95
+ // нельзя - подпись ушла бы не тем ключом. Для экранов это имя ничего не значит: состояние кошелька они
96
+ // читают через status()/isConnected(), как и раньше.
97
+ export function currentProvider() {
98
+ return provider;
99
+ }
100
+
101
+ export function balanceOf(chainSlugOrId, symbol) {
102
+ const chain = chainById(chainSlugOrId);
103
+ const v = state.balances[`${chain.chainId}:${String(symbol).toUpperCase()}`];
104
+ return v === undefined ? 0 : v;
105
+ }
106
+
107
+ const hexChainId = (id) => "0x" + Number(id).toString(16);
108
+
109
+ async function loadLib() {
110
+ if (!lib) {
111
+ // Динамический импорт своего же файла: CSP script-src 'self' это разрешает,
112
+ // и 2 МБ бандла грузятся только когда пользователь реально жмёт Connect.
113
+ lib = await import("../../assets/vendors/walletconnect/wc-browser.js?v=13fe06e5");
114
+ }
115
+ return lib;
116
+ }
117
+
118
+ function rpcMap() {
119
+ const map = {};
120
+ for (const c of CHAINS) map[c.chainId] = c.rpcUrl;
121
+ return map;
122
+ }
123
+
124
+ function reset(status = "idle", error = null) {
125
+ state.status = status;
126
+ state.error = error;
127
+ state.kind = null;
128
+ state.label = null;
129
+ state.address = null;
130
+ state.chainId = null;
131
+ state.balances = {};
132
+ provider = null;
133
+ emit();
134
+ }
135
+
136
+ // --- общая часть обоих коннекторов ----------------------------------------------------------
137
+ //
138
+ // WalletConnect и браузерный кошелёк дают один и тот же интерфейс EIP-1193, поэтому события,
139
+ // состояние и чтение балансов у них общие. Различается только рукопожатие, поэтому здесь -
140
+ // обработчики, а в connect()/connectInjected() - как именно получен провайдер.
141
+
142
+ function wireProvider(p) {
143
+ p.on("chainChanged", (id) => {
144
+ state.chainId = Number(id);
145
+ state.error = null;
146
+ emit();
147
+ refreshBalances();
148
+ });
149
+ p.on("accountsChanged", (accounts) => {
150
+ const next = (accounts && accounts[0]) || null;
151
+ // Пустой список - пользователь отключил сайт в кошельке. Это не «подключено без адреса»,
152
+ // а именно отключение: иначе экран показывал бы пустой кошелёк как рабочий.
153
+ if (!next) {
154
+ if (modal) modal.close();
155
+ reset("idle", null);
156
+ return;
157
+ }
158
+ state.address = next;
159
+ emit();
160
+ refreshBalances();
161
+ });
162
+ // disconnect приходит от WalletConnect и от браузерного кошелька (например, при сбросе);
163
+ // session_delete - только WalletConnect. Оба означают одно: сессии больше нет.
164
+ p.on("disconnect", () => {
165
+ if (modal) modal.close();
166
+ reset("idle", null);
167
+ });
168
+ p.on("session_delete", () => {
169
+ if (modal) modal.close();
170
+ reset("idle", null);
171
+ });
172
+ }
173
+
174
+ // ВОЗВРАТ СТРАНИЦЫ ИЗ BFCACHE. Chrome (с 149) разрывает WebSocket, когда страница уходит в кэш
175
+ // "вперёд-назад", и делает это НАМЕРЕННО: в консоли это выглядит как "failed: Page entered Back-Forward
176
+ // Cache", хотя сеть ни при чём. Следствие: провайдер WalletConnect кэширован в переменной, его сокет уже
177
+ // закрыт, а повторы релея замерли вместе со страницей - и после возврата сессия ВЫГЛЯДИТ живой, а запросы
178
+ // к кошельку не доходят. Ни одноразовый restore(), ни события провайдера этого не замечают. Чиним тем же
179
+ // механизмом, на который restore() опирается: init() поднимает сессию из localStorage. disconnect() здесь
180
+ // НЕ вызываем - он удаляет сессию из хранилища, то есть сломал бы ровно то, что восстанавливаем.
181
+ let resumeInFlight = false;
182
+
183
+ export async function resumeConnection() {
184
+ if (resumeInFlight || state.status !== "connected" || state.kind !== "walletconnect" || !provider) return;
185
+ resumeInFlight = true;
186
+ const p = provider;
187
+ try {
188
+ await Promise.race([
189
+ p.request({ method: "eth_accounts" }),
190
+ new Promise((_, rej) => setTimeout(() => rej(new Error("релей не ответил за 4 с")), 4000)),
191
+ ]);
192
+ console.log("[wc] после возврата страницы соединение живо");
193
+ return;
194
+ } catch (e) {
195
+ console.log("[wc] после возврата страницы релей не отвечает (" + e.message + ") - поднимаю сессию заново");
196
+ } finally {
197
+ resumeInFlight = false;
198
+ }
199
+ provider = null; // у старого сокета браузер закрыл соединение, кэш смысла не имеет
200
+ try {
201
+ const want = state.chainId || (CHAINS.find((c) => c.escrow) || {}).chainId;
202
+ const fresh = await ensureProvider(want);
203
+ const accounts = await fresh.request({ method: "eth_accounts" });
204
+ if (!Array.isArray(accounts) || !accounts.length) {
205
+ console.log("[wc] сессии в хранилище не осталось - нужен новый QR");
206
+ reset("idle", null);
207
+ return;
208
+ }
209
+ state.address = accounts[0];
210
+ emit();
211
+ await refreshBalances();
212
+ console.log("[wc] сессия поднята заново: " + state.address);
213
+ } catch (e) {
214
+ console.log("[wc] поднять сессию не удалось: " + e.message);
215
+ reset("idle", null);
216
+ }
217
+ }
218
+
219
+ // Возврат страницы: pageshow (в том числе из bfcache) и появление вкладки. Дебаунс, чтобы переключение
220
+ // вкладок не дёргало релей на каждый чих.
221
+ let resumeTimer = null;
222
+ const scheduleResume = () => {
223
+ clearTimeout(resumeTimer);
224
+ resumeTimer = setTimeout(() => { resumeConnection(); }, 400);
225
+ };
226
+ // ПОДПИСКА НА СОБЫТИЯ ОКНА - ТОЛЬКО ТАМ, ГДЕ ОКНО ЕСТЬ. Раньше это стояло без проверки среды, и модуль
227
+ // падал при загрузке в Node (проверки репозитория) и в SDK, который обещает «без DOM»: падение приходило
228
+ // не в момент подписки, а на импорте файла - то есть ломало всё, что его подтягивает.
229
+ if (typeof window !== "undefined" && typeof document !== "undefined") {
230
+ window.addEventListener("pageshow", () => scheduleResume());
231
+ document.addEventListener("visibilitychange", () => { if (!document.hidden) scheduleResume(); });
232
+ }
233
+
234
+ async function ensureProvider(wantChainId) {
235
+ if (provider) return provider;
236
+ const { EthereumProvider } = await loadLib();
237
+ provider = await EthereumProvider.init({
238
+ projectId: EVM.projectId,
239
+ chains: [wantChainId],
240
+ // Только сети, где демка может сеттлиться: сеть без эскроу предлагать кошельку незачем (и
241
+ // проект WalletConnect может её не знать, что сломало бы подключение).
242
+ optionalChains: CHAINS.filter((c) => c.escrow).map((c) => c.chainId),
243
+ showQrModal: false,
244
+ rpcMap: rpcMap(),
245
+ // url в метаданных WalletConnect сверяет с фактическим origin страницы (проверка
246
+ // происхождения приложения). Если подставить боевой домен, локально и на тестовом
247
+ // домене сессия срывается - поэтому берём origin, а не константу.
248
+ metadata: { ...EVM.metadata, url: location.origin },
249
+ });
250
+
251
+ provider.on("display_uri", (uri) => showUri(uri));
252
+ wireProvider(provider);
253
+ // kind/label здесь НЕ выставляем: провайдер создан, но подключения ещё нет. Иначе после
254
+ // перезагрузки без сессии состояние было бы idle, а UI считал бы кошелёк подключённым
255
+ // (это ловится проверкой в браузере: status=idle, kind=walletconnect).
256
+ return provider;
257
+ }
258
+
259
+ async function showUri(uri) {
260
+ if (!modal || modal.closed.value) return;
261
+ try {
262
+ const { QRCode } = await loadLib();
263
+ const qrDataUrl = await QRCode.toDataURL(uri, {
264
+ margin: 1,
265
+ width: 440,
266
+ errorCorrectionLevel: "M",
267
+ color: { dark: "#0a0d13", light: "#ffffff" },
268
+ });
269
+ modal.update({ qrDataUrl, uri, status: "Waiting for the wallet to approve..." });
270
+ } catch (e) {
271
+ // QR не собрался - ссылку всё равно показываем, её можно скопировать.
272
+ modal.update({ uri, status: "Scan failed to render, copy the link instead", error: true });
273
+ console.warn("qr render failed", e);
274
+ }
275
+ }
276
+
277
+ // Восстановление сессии после перезагрузки страницы.
278
+ //
279
+ // WalletConnect держит сессию в localStorage, и EthereumProvider.init() поднимает её обратно.
280
+ // Но у нас провайдер создавался ЛЕНИВО - только при нажатии «Connect wallet», - поэтому после F5
281
+ // сессия лежала в хранилище, а страница о ней не знала: показывала «Connect wallet» и предлагала
282
+ // новый QR. Восстановление делаем явным шагом при загрузке: поднять провайдер и, если сессия есть,
283
+ // объявить её подключённой (адрес, сеть, балансы).
284
+ //
285
+ // Состояние читаем ПОСЛЕ init, а не по событию: при восстановлении событие может прийти раньше,
286
+ // чем страница успела подписаться на провайдера.
287
+ let restoreTried = false;
288
+
289
+ // ПОМЕТКА "ВЫШЕЛ ВРУЧНУЮ". WalletConnect держит сессию в localStorage, поэтому одного disconnect() мало:
290
+ // при следующей загрузке restore() поднимет её обратно, и выход окажется фиктивным. Пометку снимаем
291
+ // ТОЛЬКО при осознанном подключении.
292
+ const OPTOUT_KEY = "arrakis.wallet.optOut";
293
+ const isOptedOut = () => { try { return localStorage.getItem(OPTOUT_KEY) === "1"; } catch { return false; } };
294
+ const setOptOut = (on) => { try { on ? localStorage.setItem(OPTOUT_KEY, "1") : localStorage.removeItem(OPTOUT_KEY); } catch {} };
295
+
296
+ export async function restore() {
297
+ // Вышел вручную - не подхватываем: иначе кнопка "выйти" ничего не значит.
298
+ if (isOptedOut()) { restoreTried = true; console.log("[wc] восстановление пропущено: кошелёк отключён вручную"); return publicState(); }
299
+ if (restoreTried || state.status === "connected") return publicState();
300
+ restoreTried = true;
301
+ // Порядок: сначала сессия WalletConnect (лежит в localStorage), затем браузерный кошелёк.
302
+ // Второй шаг тихий: eth_accounts не показывает окно, поэтому загрузка страницы не превращается
303
+ // в «подключите кошелёк» для тех, кто уже подключил его раньше.
304
+ if (EVM.projectId) {
305
+ try {
306
+ const want = chainById(state.chainId || undefined);
307
+ const p = await ensureProvider(want.chainId);
308
+ const accounts = (p && p.accounts) || [];
309
+ // Пусто - сессии в хранилище нет (или она была отключена). Это нормальное состояние: ниже
310
+ // пробуем браузерный кошелёк и, если и его нет, страница предложит подключиться.
311
+ if (accounts.length) {
312
+ state.kind = "walletconnect";
313
+ state.label = "WalletConnect";
314
+ state.address = accounts[0];
315
+ state.chainId = Number(p.chainId) || want.chainId;
316
+ state.status = "connected";
317
+ state.error = null;
318
+ emit();
319
+ await refreshBalances();
320
+ console.info("WalletConnect: сессия восстановлена после перезагрузки", state.address);
321
+ }
322
+ } catch (e) {
323
+ console.warn("WalletConnect: восстановить сессию не удалось", e && e.message ? e.message : e);
324
+ }
325
+ }
326
+ if (!isConnected()) return restoreInjected();
327
+ return publicState();
328
+ }
329
+
330
+ // Тихая проверка браузерных кошельков: тот, кто уже разрешён сайту, возвращает адрес без окна.
331
+ export async function restoreInjected({ wallets } = {}) {
332
+ if (isConnected()) return publicState();
333
+ const list = wallets || (await discoverWallets());
334
+ for (const w of list) {
335
+ const accounts = await silentAccounts(w.provider);
336
+ if (!accounts.length) continue;
337
+ provider = w.provider;
338
+ state.kind = "injected";
339
+ state.label = w.name || "Browser wallet";
340
+ wireProvider(provider);
341
+ state.address = accounts[0];
342
+ try {
343
+ state.chainId = Number(await provider.request({ method: "eth_chainId" })) || null;
344
+ } catch {
345
+ state.chainId = null;
346
+ }
347
+ state.status = "connected";
348
+ state.error = null;
349
+ emit();
350
+ await refreshBalances();
351
+ console.info("Браузерный кошелёк восстановлен без запроса: " + state.address);
352
+ return publicState();
353
+ }
354
+ return publicState();
355
+ }
356
+
357
+ // Подключение браузерного кошелька: здесь кошелёк покажет окно разрешения.
358
+ export async function connectInjected({ wallet, chainSlug } = {}) {
359
+ setOptOut(false); // осознанное подключение снимает пометку
360
+ if (!wallet || !wallet.provider) {
361
+ throw new Error("Расширение кошелька не найдено в этом браузере");
362
+ }
363
+ if (isConnected() && state.kind === "injected" && state.label === (wallet.name || null)) {
364
+ return { address: state.address, chainId: state.chainId };
365
+ }
366
+ const want = chainById(chainSlug || state.chainId || undefined);
367
+ provider = wallet.provider;
368
+ state.kind = "injected";
369
+ state.label = wallet.name || "Browser wallet";
370
+ state.status = "pairing";
371
+ state.error = null;
372
+ emit();
373
+ wireProvider(provider);
374
+ try {
375
+ const res = await injectedHandshake(provider);
376
+ if (!res.address) throw new Error("Кошелёк не вернул ни одного адреса");
377
+ state.address = res.address;
378
+ state.chainId = res.chainId || want.chainId;
379
+ state.status = "connected";
380
+ state.error = null;
381
+ emit();
382
+ // Сеть приводим к выбранной в форме: пользователь мог подключиться в другой сети.
383
+ if (state.chainId !== want.chainId) await switchChain(want);
384
+ await refreshBalances();
385
+ return { address: state.address, chainId: state.chainId };
386
+ } catch (e) {
387
+ const label = state.label || wallet.name || "Кошелёк";
388
+ const message = explainInjectedError(e, label);
389
+ if (isUserRejection(e)) {
390
+ console.info("[wallet] отказ пользователя: " + message);
391
+ reset("idle", null);
392
+ return null;
393
+ }
394
+ console.warn("[wallet] подключение не состоялось: " + message, e);
395
+ reset("error", message);
396
+ throw new Error(message);
397
+ }
398
+ }
399
+
400
+ // Подключение. chainSlug - сеть, выбранная в форме; она же становится основной в сессии.
401
+ //
402
+ // WalletConnect здесь НЕ стартует сразу: сначала показывается выбор - браузерные кошельки, найденные
403
+ // по EIP-6963, и WalletConnect отдельной строкой. Так пользователю с расширением не создаётся сессия
404
+ // WalletConnect, которую он не просил, а с телефона не приходится искать расширение.
405
+ export async function connect({ chainSlug } = {}) {
406
+ if (isConnected()) return { address: state.address, chainId: state.chainId };
407
+ const want = chainById(chainSlug || state.chainId || undefined);
408
+
409
+ state.status = "pairing";
410
+ state.error = null;
411
+ emit();
412
+
413
+ const wallets = await discoverWallets();
414
+ // Журнал обнаружения: первое, что нужно при «кошелёк не открывается» - кого мы вообще нашли.
415
+ // У старых расширений (без EIP-6963) источник legacy: имя взято по флагам, и провайдер может
416
+ // принадлежать другому расширению - тогда окно открывает не тот кошелёк, на который нажали.
417
+ if (wallets.length) {
418
+ console.info(
419
+ "[wallet] обнаружены в браузере: " +
420
+ wallets.map((w) => w.name + " [" + w.source + (w.rdns ? " " + w.rdns : "") + "]").join(", ")
421
+ );
422
+ } else {
423
+ console.info("[wallet] браузерных кошельков не найдено (остаётся WalletConnect)");
424
+ }
425
+ modal = openWalletModal({
426
+ title: "Connect a wallet",
427
+ subtitle: `Requested network: ${want.name}. The QR below is a public WalletConnect link - nothing secret. This demo never moves real funds: there is no escrow contract on-chain yet.`,
428
+ hint: "Browser wallets are detected in this browser (EIP-6963). WalletConnect works with mobile wallets by QR.",
429
+ });
430
+
431
+ const choice = await new Promise((resolve) => {
432
+ // Отмена (крестик, клик по фону, Cancel) - это не ошибка приложения, а решение пользователя.
433
+ modal.setOnCancel(() => resolve({ type: "cancel" }));
434
+ const options = wallets.map((w) => ({
435
+ key: w.rdns || w.name,
436
+ name: w.name,
437
+ icon: w.icon,
438
+ // Честная пометка: кошелёк, найденный через window.ethereum, мы называем по его флагам, но
439
+ // подтвердить, что это именно он, не можем - поэтому и подпись соответствующая.
440
+ hint: w.source === "eip6963" ? null : "detected via window.ethereum",
441
+ onClick: () => resolve({ type: "injected", wallet: w }),
442
+ }));
443
+ if (EVM.projectId) {
444
+ options.push({
445
+ key: "walletconnect",
446
+ name: "WalletConnect",
447
+ icon: null,
448
+ hint: "QR code for a mobile wallet",
449
+ onClick: () => resolve({ type: "walletconnect" }),
450
+ });
451
+ }
452
+ modal.setChoices(options);
453
+ modal.update({
454
+ status: wallets.length
455
+ ? "Choose a browser wallet above, or scan the QR code below."
456
+ : "No browser wallet found in this browser - use the QR code below.",
457
+ });
458
+ });
459
+
460
+ if (choice.type === "cancel") {
461
+ reset("idle", null);
462
+ return null;
463
+ }
464
+
465
+ // --- браузерный кошелёк: окно разрешения показывает сам кошелёк ---
466
+ if (choice.type === "injected") {
467
+ try {
468
+ const res = await connectInjected({ wallet: choice.wallet, chainSlug: want.id });
469
+ if (modal) modal.close();
470
+ return res;
471
+ } catch (e) {
472
+ // Модалку НЕ закрываем: причина отказа должна остаться на экране. Иначе получается
473
+ // «нажал - ничего не произошло», и понять, что случилось, неоткуда (ловится проверкой).
474
+ const message = String((e && e.message) || e);
475
+ if (modal) {
476
+ modal.update({
477
+ error: message,
478
+ status: "Try again, or use WalletConnect below.",
479
+ });
480
+ }
481
+ throw e;
482
+ }
483
+ }
484
+
485
+ // --- WalletConnect: прежний путь, включая мгновенную отмену ---
486
+ if (!EVM.projectId) {
487
+ if (modal) modal.close();
488
+ reset("error", "no-project-id");
489
+ throw new Error("WalletConnect projectId is not configured");
490
+ }
491
+ modal.update({ status: "Preparing the session..." });
492
+ // Отмена должна НЕМЕДЛЕННО завершить подключение. Без этого await provider.connect()
493
+ // висит до таймаута сессии, кнопка "Connect wallet" остаётся disabled, и демка выглядит
494
+ // сломанной (баг поймался проверкой в браузере: после отмены кнопка не оживала).
495
+ let cancelReject = null;
496
+ const cancelled = new Promise((_, reject) => {
497
+ cancelReject = () => reject(new Error("Connection cancelled"));
498
+ });
499
+
500
+ modal.setOnCancel(() => {
501
+ // Пользователь закрыл модалку: сессию надо погасить, иначе она останется висеть.
502
+ const p = provider;
503
+ provider = null;
504
+ if (p) Promise.resolve(p.disconnect()).catch(() => {});
505
+ reset("idle", null);
506
+ if (cancelReject) cancelReject();
507
+ });
508
+
509
+ try {
510
+ const p = await ensureProvider(want.chainId);
511
+ await Promise.race([p.connect({ chains: [want.chainId] }), cancelled]);
512
+ state.kind = "walletconnect";
513
+ state.label = "WalletConnect";
514
+ state.address = (p.accounts && p.accounts[0]) || null;
515
+ state.chainId = Number(p.chainId);
516
+ state.status = "connected";
517
+ state.error = null;
518
+ // Именно close(), а не cancel(): подключение состоялось, гасить сессию не нужно.
519
+ if (modal) modal.close();
520
+ emit();
521
+ await refreshBalances();
522
+ return { address: state.address, chainId: state.chainId };
523
+ } catch (e) {
524
+ const message = e && e.message ? String(e.message) : String(e);
525
+ console.warn("WalletConnect connect failed:", message);
526
+ // Отказ пользователя - это не ошибка приложения.
527
+ const rejected = isUserRejection(e) || /cancell?ed/i.test(message);
528
+ if (modal) modal.close();
529
+ reset(rejected ? "idle" : "error", rejected ? null : message);
530
+ if (!rejected) throw e;
531
+ return null;
532
+ }
533
+ }
534
+
535
+ // ПОДПИСЬ СООБЩЕНИЯ кошельком (EIP-191). Это НЕ транзакция: она ничего не двигает, не стоит газа и не
536
+ // может быть предъявлена контракту - она доказывает ровно одно: владение адресом. На этом держится вход
537
+ // в свой список сделок, потому что подключение кошелька само по себе ничего не доказывает: сервер по нему
538
+ // не знает, кто на другом конце.
539
+ //
540
+ // Метод personal_sign поддержан собранным SDK - проверено поиском по бандлу, а не по памяти.
541
+ export async function signMessage(message) {
542
+ if (!provider || state.status !== "connected") {
543
+ throw new Error("кошелёк не подключён: подпись невозможна");
544
+ }
545
+ // СООБЩЕНИЕ ПЕРЕДАЁМ В HEX, А НЕ ТЕКСТОМ. Так требует EIP-191: первым аргументом personal_sign идут
546
+ // байты сообщения в hex. MetaMask принимает и текст (поэтому на живом кошельке это годами не всплывало),
547
+ // а узел отвечает отказом: "invalid value: string ..., expected a valid hex string". Проверено вызовом:
548
+ // текст - отказ -32602, hex - подпись. Порядок аргументов [данные, адрес] - как у MetaMask и WalletConnect.
549
+ // Если кошелёк ответит ошибкой, показываем её КАК ЕСТЬ: по тексту сразу видно причину.
550
+ const hex = "0x" + Array.from(new TextEncoder().encode(String(message)), (b) => b.toString(16).padStart(2, "0")).join("");
551
+ return await provider.request({ method: "personal_sign", params: [hex, state.address] });
552
+ }
553
+
554
+ // ПОДПИСЬ TYPED DATA КОШЕЛЬКОМ (EIP-712, eth_signTypedData_v4). ЭТО ТОЖЕ НЕ ТРАНЗАКЦИЯ: подпись не
555
+ // двигает деньги и не стоит газа, поэтому её может дать человек БЕЗ своего ETH. Ею вносящий разрешает
556
+ // фабрике записать его адрес в ордер (issue #97, часть A/C), а отправить транзакцию за него вправе кто
557
+ // угодно (путь createOrderAndFundByDepositor).
558
+ //
559
+ // ЧТО ИМЕННО ПОДПИСЫВАЕТСЯ, РЕШАЕТ ВЫЗЫВАЮЩИЙ: сюда приходит ГОТОВОЕ typed data (www/js/evm/depositor.js),
560
+ // а не строка. Собирать форму здесь значило бы завести вторую запись протокола рядом с настоящей.
561
+ export async function signTypedData(typedData) {
562
+ if (!provider || state.status !== "connected") {
563
+ throw new Error("кошелёк не подключён: подпись typed data невозможна");
564
+ }
565
+ if (!typedData || typeof typedData !== "object") throw new Error("signTypedData: не передано typed data");
566
+ // Порядок аргументов [адрес, JSON] - как требует EIP-712 и как принимают MetaMask и WalletConnect.
567
+ const signature = await provider.request({
568
+ method: "eth_signTypedData_v4",
569
+ params: [state.address, JSON.stringify(typedData)],
570
+ });
571
+ if (typeof signature !== "string" || !/^0x[0-9a-fA-F]{130}$/.test(signature)) {
572
+ throw new Error("кошелёк вернул не 65-байтовую подпись typed data: " + String(signature).slice(0, 20));
573
+ }
574
+ return signature;
575
+ }
576
+
577
+ export async function disconnect() {
578
+ setOptOut(true); // запоминаем решение: после перезагрузки кошелёк не подхватится
579
+ const p = provider;
580
+ const kind = state.kind;
581
+ if (!p) {
582
+ reset("idle", null);
583
+ return;
584
+ }
585
+ try {
586
+ if (kind === "injected") {
587
+ // У браузерного кошелька нет «сессии» на нашей стороне: мы можем только попросить отозвать
588
+ // разрешение (wallet_revokePermissions) и забыть адрес у себя. Честно: следующие
589
+ // eth_accounts могут снова вернуть адрес, если кошелёк помнит сайт.
590
+ if (typeof p.revokePermissions === "function") {
591
+ await p.request({ method: "wallet_revokePermissions", params: [{ eth_accounts: {} }] });
592
+ }
593
+ } else {
594
+ await p.disconnect();
595
+ }
596
+ } catch (e) {
597
+ console.warn("disconnect failed", e);
598
+ }
599
+ reset("idle", null);
600
+ }
601
+
602
+ // Смена сети в кошельке. Если сети у пользователя нет - предлагаем добавить её с нашим rpcUrl
603
+ // (это параметр кошелька, страница по этому адресу не ходит - CSP не при чём).
604
+ export async function switchChain(chainSlugOrId) {
605
+ const chain = typeof chainSlugOrId === "object" ? chainSlugOrId : chainById(chainSlugOrId);
606
+ if (!provider || !isConnected()) return false;
607
+ const hex = hexChainId(chain.chainId);
608
+ try {
609
+ await provider.request({ method: "wallet_switchEthereumChain", params: [{ chainId: hex }] });
610
+ } catch (e) {
611
+ const code = e && (e.code || (e.data && e.data.originalError && e.data.originalError.code));
612
+ const unknownChain = code === 4902 || /unrecognized chain|not added/i.test(String(e && e.message));
613
+ if (!unknownChain) {
614
+ if (/rejected|denied/i.test(String(e && e.message))) return false;
615
+ state.error = String((e && e.message) || e);
616
+ emit();
617
+ return false;
618
+ }
619
+ try {
620
+ await provider.request({
621
+ method: "wallet_addEthereumChain",
622
+ params: [{
623
+ chainId: hex,
624
+ chainName: chain.name,
625
+ nativeCurrency: { name: chain.native.symbol, symbol: chain.native.symbol, decimals: chain.native.decimals },
626
+ rpcUrls: [chain.rpcUrl],
627
+ blockExplorerUrls: [chain.explorerAddr],
628
+ }],
629
+ });
630
+ } catch (e2) {
631
+ if (!/rejected|denied/i.test(String(e2 && e2.message))) {
632
+ state.error = String((e2 && e2.message) || e2);
633
+ emit();
634
+ }
635
+ return false;
636
+ }
637
+ }
638
+ state.chainId = chain.chainId;
639
+ emit();
640
+ await refreshBalances();
641
+ return true;
642
+ }
643
+
644
+ // Чтение балансов через провайдер кошелька: нативный - eth_getBalance, ERC-20 - balanceOf
645
+ // через eth_call (селектор 0x70a08231), decimals берём из конфига (в них и была ловушка BSC).
646
+ // Отправить транзакцию из кошелька ПОЛЬЗОВАТЕЛЯ и вернуть её хеш.
647
+ //
648
+ // Метод EIP-1193, один для WalletConnect и для браузерных кошельков: и те, и другие отдают
649
+ // провайдера с request(). Подпись происходит в кошельке, наш код ключей не касается.
650
+ //
651
+ // Вызов возвращается не сразу: пользователь подтверждает транзакцию в своём кошельке, и до этого
652
+ // момента промис висит. Вызывающий код обязан показать это в интерфейсе, иначе шаг выглядит зависшим.
653
+ //
654
+ // value передаётся как hex-строка (wei). Для вызова payable-функции эскроу (lock) сумма уходит в value,
655
+ // у самой функции аргументов нет; для createOrder у фабрики value не нужен вовсе.
656
+ // Прочитать контракт: вызов view-функции без транзакции и без газа.
657
+ //
658
+ // Нужен, чтобы узнать адрес эскроу ДО его создания (фабрика.predict) и чтобы читать состояние
659
+ // (эскроу.status) при опросе. Ключей не требует, кошелёк не спрашивает - только провайдер.
660
+ export async function readContract({ to, data } = {}) {
661
+ if (!provider) throw new Error("кошелёк не подключён: читать контракт нечем");
662
+ if (!to) throw new Error("не указан адрес контракта");
663
+ if (!data) throw new Error("не указаны данные вызова");
664
+ const result = await provider.request({ method: "eth_call", params: [{ to, data }, "latest"] });
665
+ if (typeof result !== "string" || !result.startsWith("0x")) {
666
+ throw new Error("неожиданный ответ на вызов контракта: " + String(result).slice(0, 60));
667
+ }
668
+ return result;
669
+ }
670
+
671
+ // КВИТАНЦИЯ ТРАНЗАКЦИИ. Нужна там, где хеша мало: у создания ордера в её событиях лежит ФАКТИЧЕСКИЙ адрес
672
+ // эскроу. Предсказанный адрес - это надежда (он зависит от соли и кода фабрики), а событие - факт.
673
+ // Читается тем же провайдером, что и вызовы контракта: второго способа обращаться к цепи не заводим.
674
+ // КОД КОНТРАКТА (eth_getCode). Нужен проверке кода фабрики (#103): extcodehash - это keccak256 от этого
675
+ // кода, и по нему интерфейс сверяет фабрику со списком известных сборок. eth_call сюда не годится: код
676
+ // читается ДРУГИМ методом, поэтому и функция отдельная.
677
+ export async function readCode({ address } = {}) {
678
+ if (!provider) throw new Error("кошелёк не подключён: читать код контракта нечем");
679
+ if (!address || !/^0x[0-9a-fA-F]{40}$/.test(String(address))) throw new Error("не адрес для чтения кода: " + String(address));
680
+ return await provider.request({ method: "eth_getCode", params: [address, "latest"] });
681
+ }
682
+
683
+ export async function readReceipt({ hash } = {}) {
684
+ if (!provider) throw new Error("кошелёк не подключён: квитанцию читать нечем");
685
+ if (!hash) throw new Error("не указан хеш транзакции");
686
+ const r = await provider.request({ method: "eth_getTransactionReceipt", params: [hash] });
687
+ if (!r || typeof r !== "object") return null; // ещё не в блоке - это НЕ ошибка, а ожидание
688
+ return r;
689
+ }
690
+
691
+ export async function sendTransaction({ to, data, value = "0x0", gas } = {}) {
692
+ if (!provider) throw new Error("кошелёк не подключён: отправлять нечем");
693
+ if (!to) throw new Error("не указан адрес контракта");
694
+ if (!data) throw new Error("не указаны данные вызова");
695
+ const from = address();
696
+ if (!from) throw new Error("кошелёк не подключён: нет адреса отправителя");
697
+ const tx = { from, to, data, value: typeof value === "bigint" ? "0x" + value.toString(16) : value };
698
+ if (gas) tx.gas = typeof gas === "bigint" ? "0x" + gas.toString(16) : gas;
699
+ // КОМИССИЮ СЧИТАЕМ САМИ И ОДНИМ ПРАВИЛОМ. Формула (2 x baseFee + чаевые) вынесена в
700
+ // www/js/evm/claimGas.js - в тот же модуль, по которому нода считает ПОДАРОК на claim. Второй расчёт
701
+ // здесь разошёлся бы с подарком (issue #127). Пол чаевых - политика сети (Arbitrum: 0), не 1 gwei.
702
+ try {
703
+ const latest = await provider.request({ method: "eth_getBlockByNumber", params: ["latest", false] });
704
+ const baseFee = latest && latest.baseFeePerGas ? BigInt(latest.baseFeePerGas) : 0n;
705
+ let priorityFeeWei = 0n;
706
+ try {
707
+ // Чаевые берём из eth_gasPrice: этот метод есть у любого провайдера. eth_maxPriorityFeePerGas
708
+ // поддерживают не все, и MetaMask отвечает на него ошибкой -32601, которая лезет в консоль пользователя.
709
+ const gp = await provider.request({ method: "eth_gasPrice", params: [] });
710
+ if (typeof gp === "string" && /^0x[0-9a-fA-F]+$/.test(gp)) {
711
+ const diff = BigInt(gp) - baseFee;
712
+ if (diff > 0n) priorityFeeWei = diff; // разница gasPrice - baseFee и есть чаевые
713
+ }
714
+ } catch { /* не подсказал - чаевые остаются нулевыми */ }
715
+ const maxFee = claimMaxFeePerGasWei({ baseFeeWei: baseFee, priorityFeeWei });
716
+ // maxFee === null значит сеть без EIP-1559 (baseFee = 0): поля не выставляем, решает кошелёк.
717
+ if (maxFee !== null) {
718
+ tx.maxPriorityFeePerGas = "0x" + (maxFee - baseFee * 2n).toString(16);
719
+ tx.maxFeePerGas = "0x" + maxFee.toString(16);
720
+ }
721
+ } catch (feeErr) {
722
+ // Не смогли посчитать - отдаём решение кошельку, как раньше. Молча падать из-за этого нельзя.
723
+ console.warn("не удалось посчитать комиссию, решает кошелёк: " + ((feeErr && feeErr.message) || feeErr));
724
+ }
725
+ const hash = await provider.request({ method: "eth_sendTransaction", params: [tx] });
726
+ if (typeof hash !== "string" || !/^0x[0-9a-fA-F]{64}$/.test(hash)) {
727
+ throw new Error("кошелёк вернул не хеш транзакции: " + String(hash).slice(0, 60));
728
+ }
729
+ return hash;
730
+ }
731
+
732
+ export async function refreshBalances() {
733
+ if (!isConnected() || !provider) return;
734
+ const chain = chainByChainId(state.chainId);
735
+ if (!chain) {
736
+ state.error = `Wallet is on chain ${state.chainId}, which this demo does not support`;
737
+ emit();
738
+ return;
739
+ }
740
+ const addr = state.address;
741
+ const next = {};
742
+ const reads = [
743
+ [chain.native.symbol, null, chain.native.decimals, "eth_getBalance"],
744
+ ];
745
+ for (const t of chain.tokens) reads.push([t.symbol, t.address, t.decimals, null]);
746
+
747
+ const results = await Promise.all(
748
+ reads.map(async ([symbol, tokenAddress, decimals, method]) => {
749
+ try {
750
+ let raw;
751
+ if (method === "eth_getBalance") {
752
+ raw = await provider.request({ method, params: [addr, "latest"] });
753
+ } else {
754
+ const data = "0x70a08231" + addr.replace(/^0x/, "").toLowerCase().padStart(64, "0");
755
+ raw = await provider.request({ method: "eth_call", params: [{ to: tokenAddress, data }, "latest"] });
756
+ }
757
+ return [symbol, toHuman(BigInt(raw), decimals)];
758
+ } catch (e) {
759
+ console.warn(`balance read failed for ${symbol}`, e);
760
+ return [symbol, undefined];
761
+ }
762
+ }),
763
+ );
764
+
765
+ for (const [symbol, value] of results) {
766
+ if (value !== undefined) next[`${chain.chainId}:${String(symbol).toUpperCase()}`] = value;
767
+ }
768
+ state.balances = { ...state.balances, ...next };
769
+ state.lastRefresh = Date.now();
770
+ state.error = null;
771
+ emit();
772
+ }
773
+
774
+ export function tokensWithBalance(chainSlug) {
775
+ const chain = chainById(chainSlug);
776
+ return tokensOf(chain.id).map((t) => ({ ...t, balance: balanceOf(chain.id, t.symbol) }));
777
+ }