Multi-chain crypto payment and custody platform
Crypto deposits and payouts on EVM networks, Tron and TON, with keys in an isolated signer and every money movement run as a durable saga.
- Domain
- Fintech, crypto custody
- Our role
- Architecture, backend, cryptography, frontend, DevOps
- Year
- 2023-2026
We built a crypto payment and custody backend for a high-volume consumer platform, covering EVM networks, Tron and TON. It issues deposit addresses, detects incoming payments, sweeps funds into a hot wallet, executes withdrawals and notifies the business backend through signed webhooks; deposits, sweeps and withdrawals run as Temporal sagas, and access goes through Keycloak. Built in two generations, it keeps production keys in an isolated HD signer behind a narrow signing interface, with a threshold-ECDSA (MPC) signer in pilot.
Challenge
- Accept and pay out stablecoins and native coins on EVM networks, Tron and TON, each with its own address format, token standard and fee model, without an in-house crypto team.
- Give every end user a deposit address, detect payments reliably and report each one exactly once.
- Consolidate funds from thousands of deposit addresses cheaply, although those addresses hold no coins or resources to pay network fees.
- Never double-spend a withdrawal, even if a process crashes mid-broadcast.
- Let operators run the system without editing config files or touching keys, and make sure no single process, database or operator can move funds alone.
Solution
The first generation (2023-2024) was a set of containerised Python services: a multi-tenant gateway with per-customer API keys and callbacks, pluggable chain adapters for EVM, Tron and TON, parsers for ERC-20, TRC-20 and TON jetton transfers, and a callback queue with retries. It solved processing, but running it in production showed that custody needs stronger guarantees, so in the second generation (2026) we rewrote the system around three principles. Keys are isolated: the API and the scanner know only the extended public key, and the signer takes a digest and returns a signature. Every money movement is a Temporal saga with idempotent steps, so a crash at any step resumes safely. The chain is the source of truth for balances: the service stores transaction facts and statuses only, with no mutable balance columns. The result is four parts: the custody core, a Keycloak-protected REST API, a React operator console and the signer.
Architecture
What the system does
- Addresses from a public key
- Deposit addresses are derived from an extended public key (BIP-32/44) and mapped to end users through tenant references.
- Automatic sweeping
- Funds move to the hot wallet with one of three fee strategies: pay fees per sweep, delegate staked network resources, or rent resources from an external marketplace with automatic fallback.
- Withdrawals with limits
- Idempotency keys, per-transaction and rolling 24-hour limits, and a hot-wallet liquidity check that counts in-flight transactions.
- Webhook integration
- deposit.detected, sweep.done, withdrawal.confirmed and withdrawal.failed events are HMAC-signed and delivered with durable retries, plus a test-delivery endpoint.
- Operator console
- Addresses with live balances, deposits, sweeps, withdrawals, wallets, asset and callback settings, and the signer lock state. Viewer, operator and admin roles through Keycloak.
- Live runtime settings
- RPC providers, assets, limits and callbacks live in the database and apply within seconds; switching between testnet and mainnet takes one click.
Engineering decisions
- Locked-by-default signing: an administrator supplies the seed through the console, it is checked against the stored public key and held in memory only. Nothing secret reaches a file, an environment variable, the database or workflow history.
- Threshold-ECDSA signer in Rust (in pilot), built on the CGGMP protocol family: signing proceeds when a quorum answers, a misbehaving node is identified and excluded, and key shares are refreshed without changing the public key or addresses. Switching to it is a configuration change, not a code change.
- Crash-safe broadcasting: every sending step saves the transaction id before broadcasting, and on retry looks up the receipt instead of sending again.
- The scanner reads only finalised blocks and writes deposits and its cursor in one database transaction, and sagas start under deterministic ids, so a replayed block cannot create a duplicate.
- The default sweep strategy was chosen from testnet fee measurements: a predictable per-sweep fee instead of capital locked in staking, with the other strategies kept behind a switch for higher volumes.
Interface
Diagrams
Stack
- Python 3.12
- FastAPI
- SQLAlchemy 2 (async)
- PostgreSQL
- Temporal
- web3.py
- tronpy
- TON SDKs
- Rust
- tonic gRPC + mTLS
- CGGMP threshold ECDSA
- Keycloak
- React 19 + TypeScript
- Docker
What this shows
- Designing multi-chain custody (EVM, Tron, TON) with key material isolated behind a narrow, auditable signing interface.
- Applied cryptography: HD derivation and a threshold-ECDSA backend with key-share refresh and sealed storage.
- Exactly-once money movement built on saga orchestration and idempotent side effects.
- Evolving a production system: carrying the lessons of the first generation into a cleaner, safer second one without its accidental complexity.
Questions about this case
Where are the private keys kept?
In an isolated signing zone. In production that is an HD signer: it boots locked, an administrator supplies the seed, and the seed is held in memory only. The API and the scanner work with the public key, and only digests and signatures cross the boundary. A threshold-ECDSA signer, where each node holds only one key share, is in pilot.
What happens if a process crashes in the middle of a withdrawal?
The withdrawal is a Temporal saga, so it resumes from the same step. The transaction id is saved before broadcast, so a retry checks the receipt on chain instead of sending the transaction again.
How does the platform integrate with our backend?
Your backend calls the REST API with a Keycloak service account (client credentials) and receives events as HMAC-signed webhooks, retried until it returns a 2xx. Withdrawals take an idempotency key, so a repeated request cannot create a second payout.