Разбираем реализацию модуля пополнения баланса криптовалютой в веб-продукте: выбор стека, нюансы конкретных сетей и то, что происходит на сервере после того, как пользователь нажал "оплатить".
Зачем вообще усложнять
Казалось бы, принять платёж в крипте — это "дать пользователю адрес, он переведёт, мы проверим хеш в блокчейне". Для MVP этого достаточно. Но как только в продукте появляется возможность оплатить прямо из подключённого кошелька, без копирования адреса, — всплывает проблема на двух фронтах. На клиенте: у каждой сети свой протокол подписания транзакций и свой формат адреса. На сервере: нужно отдельно решить, как убедиться, что платёж пришёл именно от этого пользователя. Разберём обе стороны вместе.
1. Инструменты: один интерфейс поверх несовместимых протоколов
Первое архитектурное решение — не писать свою интеграцию под каждый кошелёк (MetaMask, TronLink, Tonkeeper, Trust Wallet…), а взять один слой абстракции поверх всех сетей.
Reown AppKit — готовый UI для подключения кошелька: показывает пользователю правильный список кошельков под нужную сеть и сам ведёт его через подключение. Он закрывает сразу две экосистемы через общее модальное окно: @reown/appkit-adapter-wagmi — для EVM-сетей (Ethereum, BNB Smart Chain, через связку wagmi + viem), @reown/appkit-adapter-tron — для TRON. (Исторически AppKit называлась Web3Modal, а компания Reown — WalletConnect Inc., автор протокола WalletConnect v2; для понимания статьи это не важно.)
Для TON отдельная история: там нет единого стандарта WalletConnect, экосистема живёт на своём протоколе — TonConnect (@tonconnect/ui), подключается напрямую, в обход Reown.
Разница протоколов спрятана за одним общим контрактом на фронте — у каждой сети своя реализация одного и того же набора действий: проверить доступность, подключиться, отключиться, отдать адрес, отправить платёж. Выше этого — реестр коннекторов, который по сети и токену отдаёт нужную реализацию:
graph TD
A[UI-слой] -->|сеть + токен| B{Реестр коннекторов}
B -->|ERC20 / BEP20| C[EVM-коннектор
wagmi + viem]
B -->|TRC20| D[TRON-коннектор
tronweb + Reown provider]
B -->|TON + USDT jetton| E[TON-коннектор
TonConnect UI]
C --> H[(Ethereum / BSC RPC node)]
D --> I[(TRON full node)]
E --> J[(TON — через кошелёк пользователя)]
Готовой "одной библиотеки на всё" не существует: WalletConnect v2 — это транспортный протокол (сессии, подпись запросов), а не универсальный блокчейн-SDK. Сборка самой транзакции всё равно остаётся на стороне конкретной сети — там у каждой экосистемы свои правила, см. раздел 4.
2. Сети и токены: как устроен каталог
Платёжный модуль поддерживает 4 сети и 2 стейблкоина:
| Сеть | Стандарт токена | USDT | USDC |
|---|---|---|---|
| Ethereum | ERC-20 | ✅ | ✅ |
| BNB Smart Chain | BEP-20 (BSC-аналог ERC-20) | ✅ | ✅ (выпускает сама биржа Binance под залог 1:1, не сам эмитент Circle) |
| TRON | TRC-20 | ✅ | ⚠️ контракт не подтверждён офиц. документацией Circle — оплата из кошелька для этой пары скрыта |
| TON | jetton (TEP-74, аналог ERC-20 для TON) | ✅ (jetton) | ⚠️ скрыта — см. раздел 4 |
Decimals и адреса контрактов нельзя угадывать. У каждого токена есть decimals — число знаков после запятой, которое переводит сумму в минимальную единицу сети (wei, sun, nanoTON). Можно ожидать, что у одного и того же токена decimals всегда одинаковые — но нет: USDT и USDC на Ethereum и TRON считаются в 6 знаков, а на BNB Smart Chain — в 18 (это версия, которую выпускает сама биржа Binance под залог 1:1, а не версия эмитента — см. таблицу выше). Если просто взять "везде 6" по умолчанию, сумма платежа окажется на порядки больше или меньше нужной — поэтому decimals всегда берутся для конкретной пары "сеть + токен", а не по общему правилу.
Та же логика — для адреса смарт-контракта: отправка на неверный контракт означает потерю средств без возможности отмены. Поэтому адрес каждого контракта стоит проверять вручную по официальному блок-эксплореру сети (Etherscan, BscScan, Tronscan) и, где возможно, по документации эмитента — а не брать на веру из первого найденного источника. Там, где такого подтверждения нет — как с USDC на TRON, которую сам эмитент токена (Circle) официально не подтверждает как поддерживаемую сеть, — оплата из кошелька для этой пары отключена, а не угадана: остаётся только ручной перевод, где адрес проверяет сам пользователь через свой кошелёк.
То же правило — для курса и комиссии сети: это тоже финансовые данные, которые нельзя получать "на глаз". USD-эквивалент баланса считается по курсам крупной биржи, а не собственным прайс-фидом. Комиссия сети (gas) для EVM — живая оценка с фоллбэком на фиксированное значение, если оценка недоступна; для TRON и TON — сразу фиксированный фоллбэк, так как газ там считается иначе.
3. Как устроен платёж: от кнопки до зачисления
Крупные custodial-платформы — кастодиальные биржи и кошельки — используют не персональный on-chain адрес на каждого пользователя, а один общий сервисный адрес на сеть (omnibus-кошелёк), через который проходят платежи всех пользователей сразу. Это стандартный паттерн для custodial-приёма платежей.
Причина — цена альтернативы. Если у каждого пользователя свой личный адрес, деньги сначала оказываются именно там, а не в общем резерве платформы — и их нужно перевести туда отдельной on-chain транзакцией (такой перевод называют sweep), только после этого ими можно пользоваться. Каждый такой перевод — это лишняя комиссия сети и лишняя операция на каждый депозит. Omnibus-адрес убирает именно этот шаг: платёж сразу попадает туда, где он нужен, без промежуточного перевода с личного адреса пользователя.
Это не значит, что все средства платформы лежат в одном вечно-онлайн кошельке. Omnibus-адрес — это hot wallet с ограниченным остатком: через него проходят входящие платежи и небольшие текущие выводы, а всё, что превышает рабочий лимит, регулярно уводится в холодное хранение (cold storage) — на ключи, которые не находятся в сети постоянно. Именно поэтому дальше в статье hot keys обсуждаются отдельно и с собственными мерами защиты (раздел 5) — это ключи от ограниченного рабочего остатка, а не от всех средств платформы.
У EVM/TRON часть площадок всё же использует персональные deposit-адреса — чтобы платёж сразу было видно по адресу, без отдельного учёта в базе данных. Это осознанный компромисс в другую сторону, не универсально "правильный" вариант.
Плата за это — баланс пользователя теперь существует не как запись в блокчейне, а как запись в базе данных платформы. Такую запись называют ledger: в ней общий баланс, из которого отдельно выделена замороженная часть и доступный для операций остаток.
Здесь стоит разделить две вещи. Сама оплата — настоящий Web3-протокол: пользователь подключает свой кошелёк, подписывает транзакцию сам, и она реально уходит в блокчейн (подробнее в разделах 1 и 4). А ledger на сервере — это просто учёт после этого момента, точно такой же, как у любой биржи или платёжного сервиса, принимающего крипту. Платёж на входе — настоящая on-chain операция. То, что происходит с деньгами дальше внутри системы, — уже внутренняя бухгалтерия.
И раз у общего адреса нет привязки "один адрес — один пользователь", главный вопрос сервера — не "пришли ли деньги", а "какому именно депозиту принадлежит этот платёж на общий адрес". Закрыть депозит можно двумя способами.
Ручной перевод
sequenceDiagram
actor U as Пользователь
participant FE as Frontend
participant BE as Backend
participant BC as Блокчейн
U->>FE: Выбрать токен + сеть + сумму
FE->>BE: Создать депозит
BE-->>FE: Адрес + memo (для TON)
FE->>U: Показать адрес + QR-код
U->>BC: Перевод из внешнего кошелька
BC-->>U: Хеш транзакции
U->>FE: Вставить хеш вручную
FE->>BE: Привязать хеш к депозиту
BE->>BC: Проверка транзакции по хешу
loop пока в обработке
FE->>BE: Запросить статус
BE-->>FE: Текущие подтверждения
end
BE-->>FE: Статус: зачислено
FE->>U: Баланс обновлён
Оплата из кошелька
sequenceDiagram
actor U as Пользователь
participant FE as Frontend
participant W as Внешний кошелёк
participant BC as Блокчейн
participant BE as Backend
FE->>W: Подключиться — открыть модалку подключения
U->>W: Выбрать кошелёк, подтвердить сессию
W-->>FE: адрес подключённого кошелька
FE->>U: Шит подтверждения (адрес + оплатить/отмена)
U->>FE: Оплатить
FE->>W: Отправить платёж — собрать и подписать tx
U->>W: Подтвердить в кошельке
W->>BC: Broadcast транзакции
BC-->>FE: txHash
FE->>BE: Привязать хеш к депозиту
Note over FE,BE: дальше — тот же поллинг статуса, что и в ручном пути
Оплата из кошелька схлопывается в ручной перевод на последнем шаге: как только кошелёк вернул хеш, дальше работает тот же экран подтверждения и тот же бэкенд-контракт. На диаграмме этот шаг обобщён — для TON сеть не возвращает хеш напрямую, и его приходится вычислять на клиенте (см. раздел 4).
Подтверждение приходит не мгновенно и не само — сервер сам, по таймеру, идёт проверять сеть (это называется polling): фоновый воркер регулярно разбирает депозиты в статусе "в обработке" и ищет подтверждение. Для EVM и TRON это проверка по хешу, который явно прислал фронт. Для TON есть ещё один путь: каждому депозиту на создании присваивается уникальный memo (комментарий к платежу), и воркер дополнительным проходом сам читает последние jetton-переводы на платформенный адрес, ища среди них перевод с этим memo. Найденный перевод проверяется точно так же, как и любой другой, — по сумме и адресу получателя. Разница только в том, как воркер его находит: не по хешу, который прислал пользователь, а по memo — так депозит на TON можно закрыть, даже если хеш так и не был передан.
stateDiagram-v2
state "Создан" as s0
state "В обработке" as s1
state "Зачислено" as s2
state "Отклонена" as s3
state "Истёк" as s4
[*] --> s0
s0 --> s1: hash/memo совпал,
начато подтверждение в сети
s1 --> s2: достигнут порог подтверждений
s1 --> s3: сеть отклонила транзакцию
s0 --> s4: истёк TTL депозита
s2 --> [*]
s3 --> [*]
s4 --> [*]
Подтверждение не приходит сразу. Транзакции нужно какое-то время, чтобы стать практически необратимой — это называют finality. В одних сетях чем больше блоков прошло после транзакции, тем ниже риск, что её отменят. В других, как в современном Ethereum, время ожидания фиксированное. В любом случае сервер не проверяет статус один раз — он проверяет его снова и снова, пока не получит окончательный ответ: подтверждение или отказ.
4. Специфика каждой сети
EVM (Ethereum / BNB Smart Chain)
Это самая простая в реализации сеть — готовые библиотеки (wagmi и viem) уже умеют почти всё нужное сами. Коннектор сам переключает сеть в кошельке, если она не совпадает с нужной. Перед отправкой он заранее проверяет баланс пользователя — чтобы показать понятную ошибку сразу, а не дожидаться отказа транзакции в сети. Сам перевод — это обычный вызов перевода токена на его контракте, то же самое, что делает любой кошелёк вручную. Главная ловушка здесь не в коде, а в данных: у USDT/USDC на BSC — 18 decimals вместо привычных 6.
TRON
TRON подключается через то же модальное окно Reown, что и EVM-сети, — выбор кошелька устроен одинаково. Но саму транзакцию собирает и отправляет в сеть не универсальный адаптер, а собственный клиент на tronweb. На момент разработки адаптер под TRON умел подключать кошелёк, но не умел правильно собрать вызов смарт-контракта — только простой перевод TRX, а не перевод токена. Кошелёк в этой схеме используется для одного шага — подписи уже готовой транзакции.
flowchart LR
A[tronweb: собрать transfer] --> B[Кошелёк: подписать]
B --> C[tronweb: отправить в сеть]
C --> D[(TRON)]
На бэкенде для TRON — тоже две разные роли, не задвоение: подтверждение входящих депозитов идёт через индексатор (готовые, распарсенные переводы с адресом/суммой), а broadcast исходящих выводов — через полноценный full-node API, который умеет собрать и отправить вызов контракта. Через индексатор транзакцию не отправить, через ноду неудобно читать историю — поэтому один сервис закрывает чтение, другой запись.
Практический вывод по вендорским SDK: версия и реальные возможности адаптера — это то, что проверяется отдельно и заранее. "Поддерживает сеть X" и "умеет собрать и отправить перевод токена в этой сети" — два разных утверждения.
TON
TON устроен иначе, чем EVM и TRON. Платёж здесь — не прямой перевод от A к B, а асинхронное сообщение (формат такого сообщения называется BOC). У токена USDT на TON нет единого "общего" контракта — вместо этого у каждого держателя свой личный смарт-контракт-кошелёк именно для этого токена: его называют jetton wallet, а сам стандарт — TEP-74. Платёж технически уходит на jetton wallet отправителя, а не на адрес получателя напрямую — он сам пересылает сумму дальше.
flowchart LR
U[Кошелёк пользователя] -->|sendTransaction| JW[Jetton Wallet
отправителя]
JW -->|TEP-74 transfer| DJW[Jetton Wallet
депозитного адреса]
DJW --> DEP[Депозитный адрес]
Отсюда три практических решения:
- Jetton wallet отправителя резолвится заранее через запрос к бэкенду — именно его адрес, а не депозитный, получает сообщение.
- Оплата из кошелька для USDC на TON не включена: у заметной доли кошельков jetton wallet для USDC не существует до первого входящего перевода, платёж упал бы с ошибкой баланса ещё до отправки.
- Хеш транзакции сеть не возвращает напрямую — его вычисляет сам клиент из подписанного сообщения. Индексатору, через который бэкенд ищет транзакцию, нужно время, чтобы её увидеть, поэтому итоговый статус проверяется через повторные попытки, а не одним запросом.
5. Надёжность денег
Деньги — единственное место в системе, где недоделанный edge case оборачивается не плохим UX, а реальными потерями. Дальше — три типа защит: от задвоения одного и того же платежа, от неточного совпадения суммы и от компрометации ключей, которыми система распоряжается деньгами.
Хеш нельзя принять на слово — сервер проверяет его сам. Пользователь — вручную или через оплату из кошелька — присылает хеш транзакции, но зачесть его можно только после того, как сервер сам сходил в блокчейн и убедился: это перевод нужного токена, на наш адрес, на нужную (с допуском) сумму — и этот хеш ещё не привязан к другому депозиту. Без любой из этих проверок пользователь мог бы прислать хеш чужого перевода (между двумя третьими лицами) или повторно отправить уже использованный хеш и получить начисление без реального платежа. Идемпотентность — это конкретно последняя из них: один и тот же хеш нельзя привязать к депозиту дважды.
Этого всё равно недостаточно, если на сетях без memo (EVM, TRON) у двух разных пользователей совпадут и токен, и сумма депозита — хеш у них разный, но по какому депозиту его зачесть, неочевидно. Решают это по-разному: часть платформ для таких сетей уходит от общего адреса к персональному депозитному адресу на пользователя (см. выше) — тогда коллизии просто не возникает по конструкции. Другой вариант, который используют платёжные гейтвеи без персональных адресов, — присваивать каждому депозиту уникальную сумму (несколько дополнительных знаков после запятой), чтобы совпадение по сумме у двух депозитов стало практически невозможным.
Для вывода работает похожий, но более тонкий паттерн — claim-before-broadcast: воркер фиксирует намерение отправить транзакцию в БД до фактического broadcast'а, и только потом шлёт её в сеть. Сам claim — атомарная операция (условное обновление записи, которое проходит только у одного воркера): если инстансов воркера несколько, это исключает ситуацию, где два из них одновременно возьмут один и тот же вывод и отправят его в сеть дважды.
sequenceDiagram
participant W as Воркер
participant DB as Ledger (DB)
participant N as Сеть
W->>DB: зафиксировать намерение отправить
W->>N: подписать + broadcast
alt сеть ответила успехом
N-->>W: txHash
W->>DB: сохранить реальный hash
else воркер упал между claim и ответом
Note over W,DB: НЕ повторяем broadcast автоматически —
алерт на реконсиляцию по эксплореру
end
loop пока не подтверждено
W->>N: проверить статус
end
N-->>W: confirmed → статус "отправлено"
Если воркер падает между "записали намерение" и "получили ответ сети", автоматический retry опаснее остановки: если транзакция всё-таки ушла, слепой повторный broadcast означает повторный вывод реальных денег. Поэтому система не чинит это сама — такой случай уходит отдельным алертом на реконсиляцию по блок-эксплореру, и статус продолжается только после неё. Для денег это осознанно надёжнее, чем "само починилось, но потенциально задвоило платёж".
Сверка суммы — с допуском, не точным равенством. Токен приходит на адрес ровно в том количестве base units, которое указано в вызове transfer — комиссия сети платится отдельно в нативной монете и не вычитается из переведённой суммы токена. Но при ручном переводе сумму заново вводит сам пользователь в своём внешнем кошельке — и она может чуть отличаться от запрошенной, на доли цента, из-за округления в интерфейсе кошелька. Жёсткое сравнение на точное равенство отклонило бы такой легитимный платёж, поэтому сверка суммы всегда делается с небольшим допуском.
Hot keys живут в памяти, не в образе. Приватные ключи от hot wallet (того самого ограниченного рабочего остатка, см. раздел 3) не зашиты в Docker-образ — их можно инжектить в runtime через защищённый административный канал. Ключи живут только в памяти процесса и никогда не попадают в лог; если на какой-то сети ключа нет, система явно сигнализирует об этом, а не тихо роняет каждую попытку вывода без объяснения.
UX-слой поверх этого. Preflight-проверка баланса перед подписью показывает понятную ошибку "недостаточно средств" вместо нечитаемого сообщения из ноды сети. TTL на депозит закрывает весь флоу по истечении срока, а не оставляет его висеть на экране. "Пользователь закрыл модалку подключения" и "реальная сетевая ошибка" размечены как разные события — у них разная обработка.
6. Что взять с собой, если строите похожее
- Используйте единый интерфейс поверх сетей (подключиться / получить адрес / отправить платёж) — это единственный способ не завести дублирующийся UI-слой под каждую сеть.
- Не верьте описанию пакета "поддерживает сеть X" — отдельно проверяйте, что адаптер умеет в части сборки и отправки транзакции, а не только подключения кошелька.
- Проверяйте decimals и адреса контрактов по официальным источникам для каждой пары сеть/токен — предположение по аналогии с другой сетью стоит реальных денег.
- Закладывайте retry/backoff сразу там, где у сети нет мгновенного и однозначного хеша транзакции (как у TON) — не откладывайте до первых жалоб.
- Сверяйте сумму с небольшим допуском — тогда polling на сервере для custodial-приёма платежей работает надёжно.
- Относитесь к остановке и алерту на реконсиляцию как к правильному дизайну, а не к недоработке — там, где потеря идемпотентности означает реальные деньги (broadcast вывода).
Заключение
Принять крипто-платёж выглядит простой задачей ровно до момента, когда в сценарии появляется подключённый кошелёк и настоящие деньги. Дальше начинаются детали: у каждой сети свой протокол подписи и свой формат адреса, у каждого токена — свои decimals, а на сервере нужно отдельно решить, кому принадлежит платёж на общий адрес, и не отдать его дважды.
Ни один из этих пунктов сам по себе не выглядит сложным. Но именно их сумма отделяет рабочий прототип на happy path от решения, которое можно спокойно выдержать под реальным продакшн-трафиком — где кошельки подводят, сети задерживают подтверждение, а пользователи рано или поздно присылают не то, что от них ждали.
Глоссарий терминов, использованных в статье
- ERC-20 / BEP-20 / TRC-20 — стандарты токенов для Ethereum, BNB Chain и TRON; единый интерфейс
transfer,balanceOfи т.д. - TEP-74 (jetton) — стандарт токенов в TON с архитектурой "персональный смарт-контракт на каждого держателя" вместо единого реестра балансов.
- Decimals / base units — число знаков после запятой токена; минимальная неделимая единица сети (
wei,sun,nanoTON). - Block confirmations / finality — момент, после которого транзакция считается практически необратимой; достигается либо вероятностно (чем больше блоков легло поверх транзакции, тем ниже шанс, что цепь переписывается), либо за фиксированное время, в зависимости от сети.
- Block explorer — публичный сервис просмотра блокчейна (Etherscan, BscScan, Tronscan, Tonviewer).
- WalletConnect v2 — протокол установления зашифрованной сессии между приложением и кошельком без передачи приватных ключей.
- BOC (Bag of Cells) — бинарный формат сериализации данных в TON.
- RPC / full node — узел блокчейна, принимающий запросы на чтение состояния и отправку транзакций.
- Preflight check — проверка условий (например, баланса) до попытки отправить транзакцию в сеть.
- Idempotency — свойство операции, при котором повторное её применение с тем же ключом не создаёт дублирующего эффекта.
- Custodial / non-custodial wallet — кошелёк, приватным ключом которого управляет платформа (custodial) либо сам пользователь (non-custodial, MetaMask/TronLink/Tonkeeper).
- Ledger — учёт средств как записи в базе данных (общий баланс / замороженная часть / доступный остаток), а не как отдельного on-chain адреса на пользователя.
- Polling vs webhook — периодический опрос состояния против получения уведомления по событию.
- Claim-before-broadcast — паттерн распределённых систем: сначала зафиксировать намерение выполнить необратимое действие, затем выполнить его, чтобы при сбое система могла обнаружить незавершённую операцию и не повторить её бездумно.