Веб‑кошелёк Monero на официальном monero‑wallet‑rpc: digest‑авторизация, двухфазная отправка и BigInt вместо float

в 14:15, , рубрики: BigInt, json-rpc, Monero, monero-wallet-rpc, node.js, self-hosted, TypeScript, XMR, безопасность, криптокошелек

Как‑то я захотел кошелёк 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_walletopen_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 и что показали первые живые транзакции.

Ссылки:

Проект мой, лицензия MIT, код открыт. Ещё раз: софт неаудитнутый — тестируйте на малых суммах и держите офлайн‑копию seed‑фразы.

Автор: Glority

Источник

* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js