Как‑то я захотел кошелёк 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 строк тестов, которые появились позже — и не зря.
Архитектура: почему браузер не говорит с RPC напрямую
браузер (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‑аутентификацию.
Грабли № 1. Digest‑авторизация, привязанная к TCP‑соединению
Первое, что ломается, если писать «как обычно»: 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, собранный заголовок и код ответа. Он экономит часы.
Грабли № 2. Отправка: сначала цена, потом подпись
Требование «показать точную комиссию до отправки» в кошельках обычно решается оценкой, а оценка врёт. У 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 на пути от поля ввода до сети.
Грабли № 3. Новый кошелёк, который решил пересканировать весь чейн
Самый дорогой по времени баг нашёлся случайно. Создаю новый кошелёк, всё хорошо, интерфейс показывает «синхронизировано, 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);
Теперь новый кошелёк стартует «с сегодняшнего дня» и синхронизируется за секунды, а не за сутки.
Грабли № 4. Медленный узел не должен задерживать интерфейс
Публичный узел отвечает по‑разному: от 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 мс, а данные подтягиваются на следующем опросе.
Грабли № 5. Деньги и float: запятая и «пыль»
Тесты я написал поздно, и они сразу нашли два бага в денежной логике.
Первый. Парсер сумм вырезал запятые «как разделители тысяч». На входе "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 значение просто пустое — «придумать курс» проект себе не позволяет.
Грабли № 6. Жизненный цикл сессии кошелька
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не требуются.
Практическая мелочь: Windows Defender и официальные бинарники
Во время тестов Defender удалил monero-wallet-rpc.exe — прямо из временной папки, с формулировкой про потенциально нежелательное ПО. Официальные бинарники Monero регулярно попадают под такие эвристики: внутри есть строки, характерные для майнеров. Лечится исключением папки проекта в «Защите от вирусов и угроз»; полезно и знать, что это ложное срабатывание, и не искать проблему в своём коде.
Что не сделано и что дальше
Честный список ограничений:
-
проект не проходил аудит — это главное ограничение, и относиться к нему надо серьёзно;
-
двухфазная отправка реализована и покрыта тестами (парсинг сумм, подготовка транзакции, обработка ошибок RPC), но живую транзакцию с реальной комиссией я ещё не отправлял — это следующий шаг проверки, и писать о нём я буду отдельно;
-
нет поддержки аппаратных кошельков (Ledger/Trezor);
-
в открытых задачах — Docker‑упаковка и локализация интерфейса.
Итоги
Самое ценное, что я вынес из этой задачи, — это набор «неочевидных» мест вокруг официального RPC, которые не описаны в туториалах: привязка digest‑nonce к соединению, точный порядок полей, tx_metadata вместо оценки комиссии, uint64 и JSON.stringify, restore_height при создании кошелька и «эксклюзивный» доступ к файлу кошелька. Всё это — про аккуратность, а не про криптографию: саму криптографию лучше оставить бинарникам Monero.
Если будет интересно, в следующих статьях могу разобрать, как устроен двухфазный процесс отправки с точки зрения UX и что показали первые живые транзакции.
Ссылки:
-
Лендинг со скриншотами: https://amlchecker.github.io/monero‑web‑wallet/
-
Заметки по внутреннему устройству: https://github.com/AMLChecker/monero‑web‑wallet/blob/main/docs/ARCHITECTURE.md
-
Последний релиз: https://github.com/AMLChecker/monero‑web‑wallet/releases
Проект мой, лицензия MIT, код открыт. Ещё раз: софт неаудитнутый — тестируйте на малых суммах и держите офлайн‑копию seed‑фразы.
Автор: Glority
