A Rust-based hot wallet service for EVM-compatible blockchains that monitors deposits and automatically sweeps funds to a treasury address. Built with performance, safety, and reliability in mind.
- 🔍 Real-time Monitoring: Dual-mode blockchain monitoring with WebSocket subscriptions and HTTP polling fallback
- 💸 Automatic Sweeping: Automatically sweeps detected deposits to a configured treasury address
- 🚰 Faucet Integration: Built-in faucet for funding new addresses with existential deposits
- 🔐 HD Wallet Support: BIP-39 mnemonic-based hierarchical deterministic wallet for generating unique addresses
- 📡 REST API: Simple API for registering users and generating deposit addresses
- 🪝 Per-Account Webhooks: Custom webhook URLs per user for deposit detection and sweep notifications
- 🗄️ Embedded Database: Uses
redbfor efficient, embedded storage - 🪙 ERC-20 Support: Monitors and sweeps both native ETH and ERC-20 token deposits
- 🧪 Well-Tested: Comprehensive unit and E2E tests with mocked providers
- 🚀 CI/CD Ready: GitHub Actions workflow for formatting, linting, and testing
The service consists of four main components:
Monitors the blockchain for incoming transactions to registered addresses:
- WebSocket Mode: Real-time block subscriptions for instant deposit detection
- HTTP Polling Mode: Fallback polling mechanism with configurable intervals
- Native ETH & ERC-20: Detects both native token and ERC-20 token transfers
- Smart Filtering: Automatically ignores deposits from the faucet address to prevent sweeping existential deposits
- Tracks last processed block to handle restarts gracefully
- Records detected deposits in the database with token metadata
Processes detected deposits and transfers funds to the treasury:
- Retrieves pending deposits from the database
- Derives private keys for each deposit address
- Native ETH: Calculates gas costs and transfers maximum available balance
- ERC-20 Tokens: Sweeps ERC-20 tokens (requires native balance for gas)
- Sends webhook notifications on successful sweeps
- Marks deposits as swept in the database
Automatically funds newly registered addresses with an existential deposit:
- Uses a separate mnemonic for security isolation
- Sends configurable amount to new addresses upon registration
- Ensures addresses have sufficient balance for future transactions
- Faucet deposits are automatically excluded from sweeping
HTTP API for user management and address generation:
POST /register- Register a new user with a webhook URL and receive a unique deposit address- Deterministic address derivation using hash-based indexing
- Automatic funding via faucet upon registration
- Per-account webhook configuration for custom notification endpoints
- Thread-safe database access
- Rust 1.70+ (install via rustup)
- Access to an EVM-compatible blockchain node (HTTP or WebSocket)
# Clone the repository
git clone <repository-url>
cd emvhot
# Build the project
cargo build --release
# Run tests
cargo testThe service is configured via environment variables. Create a .env file or set these variables:
| Variable | Description | Example |
|---|---|---|
MNEMONIC |
BIP-39 mnemonic phrase for HD wallet (used to derive user deposit addresses) | test test test test test test test test test test test junk |
FAUCET_MNEMONIC |
BIP-39 mnemonic phrase for faucet wallet (used to fund new addresses) | another twelve word phrase for faucet |
FAUCET_ADDRESS |
Ethereum address of the faucet (derived from FAUCET_MNEMONIC at index 0) |
0xf39fd6e51aad88f6f4ce6ab8827279cfffb92266 |
TREASURY_ADDRESS |
Ethereum address where funds will be swept | 0x742d35Cc6634C0532925a3b844Bc9e7595f0bEb |
RPC_URL or WS_URL |
Blockchain node endpoint (use WS for real-time, RPC for polling) | https://eth-mainnet.g.alchemy.com/v2/... or wss://eth-mainnet.g.alchemy.com/v2/... |
| Variable | Description | Default |
|---|---|---|
DATABASE_URL |
Path to the database file | sqlite:wallet.db |
PORT |
API server port | 3000 |
POLL_INTERVAL |
Block polling interval in seconds (HTTP mode only) | 10 |
BLOCK_OFFSET_FROM_HEAD |
Number of blocks to stay behind chain head for confirmation safety | 20 |
EXISTENTIAL_DEPOSIT |
Amount in wei to fund new addresses with | 10000000000000000 (0.01 ETH) |
# Database
DATABASE_URL=sqlite:wallet.db
# Blockchain Connection (choose one)
RPC_URL=https://polygon-mainnet.g.alchemy.com/v2/YOUR_API_KEY
# For WebSocket (comment out RPC_URL if using WS):
# WS_URL=wss://polygon-mainnet.g.alchemy.com/v2/YOUR_API_KEY
# Hot Wallet Configuration
# This mnemonic is used to derive deposit addresses for users
MNEMONIC=your twelve word mnemonic phrase goes here for hot wallet
# Faucet Configuration
# This mnemonic is for the faucet that funds new addresses with existential deposit
FAUCET_MNEMONIC=another twelve word mnemonic phrase for faucet wallet funding
# Faucet Address (derived from FAUCET_MNEMONIC at index 0)
# This address is used to identify and skip faucet deposits from being swept
# To get this address: derive it from your FAUCET_MNEMONIC using BIP39/BIP44 at path m/44'/60'/0'/0/0
FAUCET_ADDRESS=0xf39fd6e51aad88f6f4ce6ab8827279cfffb92266
# Existential Deposit (in wei)
# Default: 10000000000000000 (0.01 ETH on Ethereum)
# Adjust based on network: lower for testnets, consider gas costs
EXISTENTIAL_DEPOSIT=10000000000000000
# Treasury address where funds are swept to
TREASURY_ADDRESS=0x742d35Cc6634C0532925a3b844Bc9e7595f0bEb
# API Server Port
PORT=3000
# Polling interval in seconds (for monitoring new blocks)
POLL_INTERVAL=10
# Block Offset from Head (number of blocks behind current head for confirmation safety)
BLOCK_OFFSET_FROM_HEAD=20# With .env file
cargo run --release
# Or with environment variables
MNEMONIC="..." \
TREASURY_ADDRESS="0x..." \
WEBHOOK_URL="https://..." \
RPC_URL="https://..." \
cargo run --releaseUse the API to register users with their webhook URL and get unique deposit addresses:
curl -X POST http://localhost:3000/register \
-H "Content-Type: application/json" \
-d '{
"id": "user_123",
"webhook_url": "https://api.example.com/webhooks/user_123"
}'Response:
{
"address": "0xf39fd6e51aad88f6f4ce6ab8827279cfffb92266",
"funding_tx": "0xabc123..." // Optional: transaction hash of faucet funding
}Note: Upon registration, the address is automatically funded with the configured existential deposit from the faucet. This ensures the address has enough balance for gas fees when sweeping deposits.
Important: Each user registers with their own webhook_url. This allows per-user notification endpoints for deposit detection and sweep events.
The service sends webhook notifications to the per-account webhook_url for deposit events. Each webhook includes a unique id field for idempotency and deduplication.
- Native ETH deposits:
id= transaction hash (e.g.,"0xabc123...") - ERC20 deposits:
id= transaction hash + log index (e.g.,"0xabc123...:0")
This ensures unique identification even when multiple ERC20 transfers occur in the same transaction.
When a deposit is first detected on the blockchain, a POST request is sent to the account's webhook URL:
Native ETH Deposit Detected:
{
"id": "0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef",
"event": "deposit_detected",
"account_id": "user_123",
"tx_hash": "0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef",
"amount": "1000000000000000000",
"token_type": "native"
}ERC-20 Token Deposit Detected:
{
"id": "0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef:0",
"event": "deposit_detected",
"account_id": "user_123",
"tx_hash": "0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef",
"amount": "1000000",
"token_type": "erc20",
"token_symbol": "USDC",
"token_address": "0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48",
"token_decimals": 6
}Converting ERC-20 amounts to human-readable format:
// For USDC with 6 decimals and amount "1000000"
const humanReadable = amount / Math.pow(10, token_decimals);
// Result: 1.0 USDCWhen a deposit is successfully swept to the treasury, a POST request is sent to the account's webhook URL:
Native ETH Deposit Swept:
{
"id": "0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef",
"event": "deposit_swept",
"account_id": "user_123",
"original_tx_hash": "0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef",
"amount": "1000000000000000000",
"token_type": "native"
}ERC-20 Token Deposit Swept:
{
"id": "0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef:0",
"event": "deposit_swept",
"account_id": "user_123",
"original_tx_hash": "0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef:0",
"amount": "1000000",
"token_type": "erc20",
"token_symbol": "USDC",
"token_address": "0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48",
"token_decimals": 6
}When a newly registered address is funded with an existential deposit:
Faucet Funding Success:
{
"event": "faucet_funding",
"account_id": "user_123",
"address": "0x742d35Cc6634C0532925a3b844Bc9e7595f0bEb",
"success": true,
"tx_hash": "0xabc..."
}Faucet Funding Failure:
{
"event": "faucet_funding",
"account_id": "user_123",
"address": "0x742d35Cc6634C0532925a3b844Bc9e7595f0bEb",
"success": false,
"error": "Insufficient faucet balance"
}- Idempotency: Use the
idfield as an idempotency key to prevent duplicate processing - Deduplication: Store processed webhook IDs to avoid reprocessing the same event
- Validation: Verify
account_idbelongs to your system - Acknowledgment: Return HTTP 2xx status code to confirm receipt
- Async Processing: Process webhooks asynchronously to avoid timeouts
Example Webhook Handler (Node.js/Express):
app.post('/webhook', async (req, res) => {
const { id, event, account_id, token_type } = req.body;
// Check for duplicate using id as idempotency key
const exists = await db.findWebhookById(id);
if (exists) {
console.log(`Duplicate webhook ignored: ${id}`);
return res.status(200).send('OK');
}
// Store and process webhook
await db.storeWebhook({ id, ...req.body });
switch (event) {
case 'deposit_detected':
await handleDepositDetected(req.body);
break;
case 'deposit_swept':
await handleDepositSwept(req.body);
break;
case 'faucet_funding':
await handleFaucetFunding(req.body);
break;
}
res.status(200).send('OK');
});For complete webhook specifications, see WEBHOOK_SPEC.md.
The FAUCET_ADDRESS must match the address derived from your FAUCET_MNEMONIC at index 0. Here's how to get it:
Using a tool like cast (from Foundry):
cast wallet address --mnemonic "your faucet mnemonic phrase here" --mnemonic-index 0Using ethers.js:
const { Wallet } = require('ethers');
const mnemonic = "your faucet mnemonic phrase here";
const wallet = Wallet.fromMnemonic(mnemonic, "m/44'/60'/0'/0/0");
console.log(wallet.address);Using a BIP39 tool:
- Path:
m/44'/60'/0'/0/0(Ethereum standard) - Index: 0
"Deposit from faucet is being swept"
- Verify that
FAUCET_ADDRESSmatches the actual address derived fromFAUCET_MNEMONICat index 0 - Check logs to see which address the faucet is using
- Addresses are case-insensitive but should be in checksummed format
"Faucet has insufficient balance"
- Ensure the faucet address has enough native currency to fund new addresses
- Each registration requires at least
EXISTENTIAL_DEPOSITamount
"ERC-20 sweep fails with insufficient gas"
- Addresses need native balance (ETH/MATIC/etc.) to pay for ERC-20 transfer gas
- Consider increasing
EXISTENTIAL_DEPOSITif you expect ERC-20 deposits
This project includes comprehensive RPC debugging capabilities to help you troubleshoot blockchain interactions.
Use the provided helper scripts:
# Standard debugging (recommended for most cases)
./run-debug.sh
# Maximum verbosity (shows full request/response bodies)
./run-debug-verbose.sh
# Run the debugging example
RUST_LOG=alloy=debug cargo run --example rpc_debug_exampleSet the RUST_LOG environment variable to control logging verbosity:
# See all RPC calls with detailed timing
RUST_LOG=alloy_transport_http=debug,alloy_rpc_client=debug cargo run
# Maximum verbosity (includes request/response bodies)
RUST_LOG=alloy=trace cargo run
# Debug specific components
RUST_LOG=evm_hot_wallet::monitor=debug,alloy=debug cargo runWhen RPC debugging is enabled, you'll see output like:
[DEBUG alloy_transport_http] sending request: method=eth_getBlockNumber
[TRACE alloy_transport_http] request body: {"jsonrpc":"2.0","method":"eth_getBlockNumber","params":[],"id":1}
[TRACE alloy_transport_http] response body: {"jsonrlog_rpc_call":"2.0","id":1,"result":"0x1234567"}
[DEBUG alloy_transport_http] received response: status=200 duration=45ms
error- Only errorswarn- Warnings and errorsinfo- General information (default)debug- Detailed debugging information, RPC method names and timingtrace- Maximum verbosity (includes full request/response JSON bodies)
Debug block monitoring issues:
RUST_LOG=evm_hot_wallet::monitor=debug,alloy_provider=debug cargo runDebug transaction issues:
RUST_LOG=evm_hot_wallet::sweeper=debug,alloy_transport_http=trace cargo runDebug token transfer detection:
RUST_LOG=evm_hot_wallet::monitor=debug,alloy_rpc_client=debug cargo runSave logs to a file:
RUST_LOG=alloy=debug cargo run 2>&1 | tee debug.logFor more detailed information, see DEBUG_RPC.md.
# Run all tests
cargo test
# Run with output
cargo test -- --nocapture
# Run specific test
cargo test test_monitor_db_operationsThe project uses standard Rust tooling:
# Format code
cargo fmt
# Run linter
cargo clippy -- -D warnings
# Check compilation
cargo checkGitHub Actions automatically runs on every push/PR:
- ✅ Format check (
cargo fmt --check) - ✅ Compilation check (
cargo check) - ✅ Linting (
cargo clippy) - ✅ Tests (
cargo test)
emvhot/
├── src/
│ ├── main.rs # Entry point, service orchestration
│ ├── api.rs # REST API server
│ ├── config.rs # Configuration management
│ ├── db.rs # Database layer (redb)
│ ├── monitor.rs # Blockchain monitoring service
│ ├── sweeper.rs # Fund sweeping service
│ ├── wallet.rs # HD wallet implementation
│ ├── traits.rs # Shared service trait
│ ├── tests.rs # Unit tests
│ └── e2e_tests.rs # End-to-end tests
├── .github/
│ └── workflows/
│ └── ci.yml # CI/CD pipeline
├── Cargo.toml # Dependencies
└── README.md
Key dependencies:
- alloy: Ethereum library for transaction handling and providers
- axum: Web framework for the REST API
- redb: Embedded key-value database
- tokio: Async runtime
- tracing: Logging and diagnostics
See Cargo.toml for the complete list.
- Never commit your
.envfile - It contains sensitive mnemonic phrases - Separate mnemonics for security - Use different mnemonics for the hot wallet and faucet
- Use environment-specific mnemonics - Don't use production mnemonics in development
- Secure your webhook endpoint - Validate webhook signatures in production
- Monitor gas prices - The sweeper uses on-chain gas prices which may be high during congestion
- Database backups - Regularly backup your database to prevent data loss
- Hot wallet risks - This is a hot wallet service; funds are only as secure as the server
- Faucet funding - Ensure the faucet address is properly funded to support new user registrations
- Correct FAUCET_ADDRESS - Double-check that
FAUCET_ADDRESSmatches the address derived fromFAUCET_MNEMONICat index 0
- User calls
POST /registerwith their account ID and webhook URL - System derives a deterministic address using hash-based indexing
- Faucet automatically sends existential deposit to the new address
- Address and webhook URL are registered in the database
- Address is ready to receive deposits with custom webhook notifications
- Monitor watches the blockchain for transactions to registered addresses
- When a deposit is detected, Monitor checks if it's from the faucet:
- If yes: Skip recording (prevents sweeping existential deposits)
- If no:
- Record the deposit in the database
- Send "deposit_detected" webhook to the account's webhook URL
- Sweeper processes recorded deposits:
- Derives the private key for the deposit address
- Calculates gas costs
- Transfers funds to the treasury address
- Sends "deposit_swept" webhook to the account's webhook URL
- Deposit is marked as "swept" in the database
- Monitor detects ERC-20
Transferevents to registered addresses - Automatically fetches and caches token metadata (symbol, decimals, name)
- Webhooks include
token_decimalsfor easy amount conversion - Sweeper transfers ERC-20 tokens using the native balance for gas
- Unique identification with
tx_hash:log_indexformat for multiple transfers in same transaction
- Copy the environment template:
cp env.docker.example .env
# Or use: make setup- Edit
.envwith your configuration:
# Set your RPC endpoint, mnemonics, addresses, etc.
nano .env- Build and start the service:
docker-compose up -d
# Or use: make up- View logs:
docker-compose logs -f evm-hot-wallet
# Or use: make logs- Check health:
curl http://localhost:3000/health
# Or use: make health- Stop the service:
docker-compose down
# Or use: make downA Makefile is provided for convenience:
make help # Show all available commands
make setup # Create .env from template
make up # Start the service
make logs # View logs
make health # Check service health
make backup # Backup database
make restart # Restart service
make down # Stop service
make rebuild # Rebuild and restart
make prod-check # Verify production configurationBuild the image manually:
docker build -t evm-hot-wallet .Run the container:
docker run -d \
--name evm-hot-wallet \
-p 3000:3000 \
-v wallet-data:/app/data \
--env-file .env \
evm-hot-walletCheck container health:
docker ps
docker logs evm-hot-walletBackup the database:
docker cp evm-hot-wallet:/app/data/wallet.db ./backup-wallet.db- Persistent Storage: Database is stored in a Docker volume (
wallet-data) to persist across container restarts - Environment Variables: All configuration is loaded from
.envfile - Network: Service runs on port 3000 by default (configurable)
- Security:
- Never commit
.envfile with real secrets - Use Docker secrets or environment variable injection for production
- Consider using a secrets management service (HashiCorp Vault, AWS Secrets Manager, etc.)
- Never commit
- Monitoring: Add health checks and monitoring solutions (Prometheus, Grafana)
- Scaling: For high availability, consider running multiple instances with a shared database
The docker-compose.yml includes a health check. You can also manually check:
curl http://localhost:3000/healthNote: You may need to implement a /health endpoint in the API if it doesn't exist.
- Support for ERC-20 token sweeping
- Faucet integration for funding new addresses
- Smart filtering to prevent sweeping existential deposits
- Docker deployment
- Per-account webhook URLs
- Unique webhook identifiers (
idfield) - Token decimals in ERC-20 webhooks
- Automatic token metadata caching
- Webhook signature verification (HMAC)
- Configurable gas price strategies
- Multi-chain support
- Admin dashboard
- Prometheus metrics
- Health check endpoint
Contributions are welcome! Please ensure:
- All tests pass:
cargo test - Code is formatted:
cargo fmt - No clippy warnings:
cargo clippy -- -D warnings - Add tests for new features
[Your License Here]
For issues and questions, please open an issue on GitHub.