- PVSM.RU - https://www.pvsm.ru -
Как‑то я захотел кошелёк Monero с нормальным веб‑интерфейсом, но без компромиссов по ключам: чтобы он работал на моей машине, ходил в мой узел и не притворялся кастодиальным сервисом. Писать криптографию самому — плохая идея, поэтому вся работа с ключами осталась за официальным monero-wallet-rpc, а на мне была «всего лишь» оболочка: бэкенд, интерфейс и куча инфраструктурной логики, которая внезапно оказалась самой интересной частью задачи.
В статье — восемь грабель, которые я собрал по дороге: от HTTP Digest, который зависит от TCP‑соединения, до JSON.stringify, который молча ломает точность денежной суммы. Это не реклама проекта, а разбор технических находок; код открыт, ссылки в конце. Сразу оговорюсь: софт неаудитнутый, это хобби‑проект, и относиться к нему надо соответственно.
Что хотелось получить:
кошелёк Monero с современным тёмным интерфейсом, который живёт на 127.0.0.1;
ключи и подпись — только через официальные бинарники Monero, никакой самодельной криптографии;
браузер не должен знать ни одного RPC‑пароля и уметь разговаривать с monero-wallet-rpc напрямую;
перед отправкой пользователь обязан увидеть точную комиссию;
если узел или RPC недоступны, интерфейс должен честно это показать, а не рисовать выдуманный баланс.
Стек получился скучным и предсказуемым: Node.js + Express + TypeScript на бэкенде, React + Vite + Tailwind на фронтенде. Криптографии в проекте нет вообще: 2307 строк бэкенда (без тестов), 3057 строк фронтенда и 377 строк тестов, которые появились позже — и не зря.
браузер (React) → бэкенд 127.0.0.1:18082 → monero-wallet-rpc 127.0.0.1:18083 → monerod / публичный узел
без ключей без ключей в браузере официальный бинарник полная валидация
Соблазн вызвать monero-wallet-rpc прямо из браузера есть, но он разбивается о три вещи: CORS, RPC‑пароль в JS и то, что любой сайт в соседней вкладке тоже попробует что‑нибудь вызвать. Поэтому бэкенд — единственный, кто знает RPC‑логин, а сам RPC слушает только 127.0.0.1 и требует digest‑аутентификацию.
Первое, что ломается, если писать «как обычно»: fetch с Basic‑авторизацией возвращает 401. Смотрим заголовки — сервер отдаёт digest:
WWW-authenticate: Digest qop="auth",algorithm=MD5,realm="monero-rpc",nonce="...",stale=false
WWW-authenticate: Digest qop="auth",algorithm=MD5-sess,realm="monero-rpc",nonce="...",stale=false
Заголовков два, Node склеивает их через запятую, а нам нужен только первый — иначе парсер подхватит algorithm=MD5-sess и ответ не сойдётся.
Дальше начинается самое неприятное. fetch/undici держит пул соединений, а nonce в epee (HTTP‑слой Monero) привязан к соединению, на котором он выдан. Практический эффект: с одним и тем же корректным паролем запросы уходят то 200, то 401 — в зависимости от того, попал ли retry в тот же сокет. Диагностика выглядит как «иногда работает», что хуже, чем «не работает совсем».
Решение — отказаться от fetch в пользу node:http и закрепить одно keep‑alive соединение:
private readonly agent = new http.Agent({ keepAlive: true, maxSockets: 1 });
И собирать заголовок ровно так, как ждёт сервер: строгий порядок полей и без параметра algorithm (с ним epee отвечает 401 даже при математически верном ответе).
const parts = [
`username="${user}"`,
`realm="${challenge.realm}"`,
`nonce="${challenge.nonce}"`,
`uri="${uri}"`,
];
if (challenge.qop) parts.push(`qop=${challenge.qop}`, `nc=${nc}`, `cnonce="${cnonce}"`);
parts.push(`response="${response}"`);
return `Digest ${parts.join(', ')}`;
Мораль: прежде чем писать «нормальный» HTTP‑клиент, стоит потратить полчаса на маленький скрипт‑диагност (node scripts/doctor.mjs в репозитории), который печатает challenge, собранный заголовок и код ответа. Он экономит часы.
Требование «показать точную комиссию до отправки» в кошельках обычно решается оценкой, а оценка врёт. У monero-wallet-rpc есть более честный путь: собрать транзакцию, но не рассылать её.
transfer { do_not_relay: true, get_tx_key: true } → точная комиссия + tx_metadata
relay_tx { hex: tx_metadata } → рассылка после подтверждения
tx_metadata остаётся на бэкенде, в браузер уходит только сумма, комиссия и итог. Если пользователь закрывает диалог — подготовленная транзакция просто выбрасывается, в сеть ничего не уходит.
Отдельная засада — как передать сумму. В RPC она в атомарных единицах и это uint64: до 18 446 744 073 709 551 615. Это больше, чем Number.MAX_SAFE_INTEGER (9 007 199 254 740 991), то есть JSON.stringify может испортить значение. Поэтому тело запроса для transfer собирается строками, без промежуточного Number:
const body =
`{"jsonrpc":"2.0","id":"0","method":"transfer","params":{` +
`"destinations":[{"amount":${params.amountAtomic},"address":${JSON.stringify(params.address)}}],` +
`"account_index":${params.accountIndex},"priority":${params.priority},` +
`"get_tx_key":true,"do_not_relay":${params.doNotRelay}}}`;
amountAtomic — строка из цифр, которую мы получили из BigInt. Ни одного parseFloat на пути от поля ввода до сети.
Самый дорогой по времени баг нашёлся случайно. Создаю новый кошелёк, всё хорошо, интерфейс показывает «синхронизировано, 100%». Перезапускаю monero-wallet-rpc — и кошелёк начинает качать блоки с самого начала, потому что у него restore_height = 0. С публичного узла это сотни гигабайт и часы работы, а выглядит как «кошелёк завис на 0.00%».
Причина: create_wallet вызывался без параметра restore_height. Исправление в три строки — взять текущую высоту демона и передать её при создании:
let restoreHeight: number | undefined;
const daemon = await getDaemonStatus();
if (daemon.online && daemon.height) restoreHeight = daemon.height;
await this.rpc.createWallet(name, password, 'English', restoreHeight);
Теперь новый кошелёк стартует «с сегодняшнего дня» и синхронизируется за секунды, а не за сутки.
Публичный узел отвечает по‑разному: от 200 мс до 8 секунд. Первая версия интерфейса честно ждала статус узла и открывалась через 10 секунд — это выглядит как сломанное приложение.
Что помогло:
get_info у monero-wallet-rpc нет (в версии 0.18.5.1 метод отсутствует), поэтому высота сети берётся напрямую у демона — и то, и другое проверяется параллельно, а не последовательно;
статус узла отдаётся по схеме stale‑while‑revalidate: кэш возвращается сразу, обновление уходит в фон;
для цены — тот же подход:
snapshot(): PriceQuote {
if (this.source === 'none') return this.pending();
const cached = this.cache;
const stale = !cached || !cached.updatedAt || Date.now() - cached.updatedAt >= CACHE_MS;
if (stale) void this.quote().catch(() => undefined);
return cached ?? this.pending();
}
чтения (balance, address, transactions) кэшируются на 3–5 секунд, одновременные запросы склеиваются: wallet RPC обрабатывает запросы последовательно, и пара открытых вкладок легко создаёт очередь, которая тормозит всё.
Результат: первый ответ /api/wallet/info — около 60 мс, а данные подтягиваются на следующем опросе.
Тесты я написал поздно, и они сразу нашли два бага в денежной логике.
Первый. Парсер сумм вырезал запятые «как разделители тысяч». На входе "1,5" (человек имел в виду 1,5 XMR) получалось «15» — ошибка в десять раз, и заметить её в интерфейсе почти невозможно. Теперь запятая без точки — десятичный разделитель, а с точкой — разделитель тысяч:
if (raw.includes(',')) {
raw = raw.includes('.') ? raw.replace(/,/g, '') : raw.replace(',', '.');
}
Второй. Форматтер «минимум шесть знаков» показывал пыль как 0.000000: сумма 0,000000000001 XMR визуально исчезала. Пришлось оставлять столько знаков, сколько нужно, чтобы значение не пропало.
Арифметика везде на BigInt и строках, включая fiat‑оценку:
const numerator = atomicValue * priceScaled * 100n;
const denominator = 1_000_000_000_000n * scale;
const cents = (numerator + denominator / 2n) / denominator;
Курс берётся у биржи (Kraken XMR/USDT, CoinGecko XMR/USD) только если пользователь его включил, кэшируется на минуту, и при недоступности API значение просто пустое — «придумать курс» проект себе не позволяет.
monero-wallet-rpc держит один открытый кошелёк и блокирует файл. Из этого следуют неочевидные вещи:
открыть тот же кошелёк вторым процессом нельзя — получите «is opened by another wallet program»;
чтобы показать seed, приходится делать close_wallet → open_wallet(password) → query_key(mnemonic); при неверном пароле сессия сознательно остаётся закрытой, а интерфейс возвращает на экран выбора кошелька;
если процесс бэкенда упал или был убит, кошелёк остаётся «занятым». Теперь при завершении бэкенд сам делает close_wallet, иначе следующий запуск не может открыть тот же файл.
Ещё один нюанс: во время open/close RPC честно отвечает «кошелёк не открыт», и параллельный опрос интерфейса воспринимал это как «сессия потеряна» и выбрасывал пользователя на стартовый экран. Пришлось ввести «эксклюзивный» режим: пока идёт перезагрузка кошелька, состояние сессии не сбрасывается.
Кошелёк — это чужие деньги, поэтому проверки добавлялись не «потом», а сразу:
monero-wallet-rpc слушает только 127.0.0.1, digest‑авторизация включена, --disable-rpc-login не используется нигде;
логин и пароль RPC генерируются заново при каждом запуске: даже если что‑то утечёт из командной строки процесса, после перезапуска это значение мертво;
пароль кошелька не пишется ни в localStorage, ни в логи, ни в файлы — только в память на время вызова;
seed показывается лишь при создании кошелька или при явном экспорте с повторной проверкой пароля, и никогда не попадает в лог;
бэкенд отклоняет запросы с не‑loopback Host/Origin — это защита от «злой сайт в соседней вкладке» и от DNS‑rebinding;
суммы и комиссии — только BigInt и строки.
Здесь я позволил себе не экономить:
53 теста на встроенном раннере Node (node --test) — без новых зависимостей: парсинг и форматирование XMR, нормализация get_transfers, таблица ошибок RPC, digest‑заголовок;
CI (GitHub Actions): сборка бэкенда → тесты → сборка фронтенда → npm audit для двух пакетов;
Dependabot с осознанным ограничением: минорные и патч‑версии приходят одной сгруппированной пачкой раз в неделю, мажоры — только вручную. Первый же его запуск открыл 11 PR с мажорными апгрейдами (Tailwind 3→4, plugin‑react 4→6, TypeScript 5→7), часть из них ломала сборку — для кошелька это недопустимо;
защита ветки main: обязательный чек build, запрет force‑push и удаления;
лаунчеры для Windows (Start.bat/Stop.bat) и для Linux/macOS (start.sh/stop.sh): проверки, случайный RPC‑логин, loopback‑привязка, ожидание готовности сервисов, PID‑файлы. Скрипты используют только bash и node — ни lsof, ни ss, ни curl не требуются.
Во время тестов Defender удалил monero-wallet-rpc.exe — прямо из временной папки, с формулировкой про потенциально нежелательное ПО. Официальные бинарники Monero регулярно попадают под такие эвристики: внутри есть строки, характерные для майнеров. Лечится исключением папки проекта в «Защите от вирусов и угроз»; полезно и знать, что это ложное срабатывание, и не искать проблему в своём коде.
Честный список ограничений:
проект не проходил аудит — это главное ограничение, и относиться к нему надо серьёзно;
двухфазная отправка реализована и покрыта тестами (парсинг сумм, подготовка транзакции, обработка ошибок RPC), но живую транзакцию с реальной комиссией я ещё не отправлял — это следующий шаг проверки, и писать о нём я буду отдельно;
нет поддержки аппаратных кошельков (Ledger/Trezor);
в открытых задачах — Docker‑упаковка и локализация интерфейса.
Самое ценное, что я вынес из этой задачи, — это набор «неочевидных» мест вокруг официального RPC, которые не описаны в туториалах: привязка digest‑nonce к соединению, точный порядок полей, tx_metadata вместо оценки комиссии, uint64 и JSON.stringify, restore_height при создании кошелька и «эксклюзивный» доступ к файлу кошелька. Всё это — про аккуратность, а не про криптографию: саму криптографию лучше оставить бинарникам Monero.
Если будет интересно, в следующих статьях могу разобрать, как устроен двухфазный процесс отправки с точки зрения UX и что показали первые живые транзакции.
Ссылки:
Исходники: https://github.com/AMLChecker/monero‑web‑wallet [1]
Лендинг со скриншотами: https://amlchecker.github.io/monero‑web‑wallet/ [2]
Заметки по внутреннему устройству: https://github.com/AMLChecker/monero‑web‑wallet/blob/main/docs/ARCHITECTURE.md [3]
Последний релиз: https://github.com/AMLChecker/monero‑web‑wallet/releases [4]
Проект мой, лицензия MIT, код открыт. Ещё раз: софт неаудитнутый — тестируйте на малых суммах и держите офлайн‑копию seed‑фразы.
Автор: Glority
Источник [5]
Сайт-источник PVSM.RU: https://www.pvsm.ru
Путь до страницы источника: https://www.pvsm.ru/node-js/458611
Ссылки в тексте:
[1] https://github.com/AMLChecker/monero‑web‑wallet: https://github.com/AMLChecker/monero-web-wallet
[2] https://amlchecker.github.io/monero‑web‑wallet/: https://amlchecker.github.io/monero-web-wallet/
[3] https://github.com/AMLChecker/monero‑web‑wallet/blob/main/docs/ARCHITECTURE.md: https://github.com/AMLChecker/monero-web-wallet/blob/main/docs/ARCHITECTURE.md
[4] https://github.com/AMLChecker/monero‑web‑wallet/releases: https://github.com/AMLChecker/monero-web-wallet/releases
[5] Источник: https://habr.com/ru/articles/1085792/?utm_source=habrahabr&utm_medium=rss&utm_campaign=1085792
Нажмите здесь для печати.