Writing
cTrip Architecture
cTrip — Multi-Chain Crypto Payment Gateway

cTrip — Building a Multi-Chain Crypto Payment Gateway with FastAPI

I built cTrip, a production-style cryptocurrency payment gateway. It accepts native and ERC-20 payments across EVM chains, detects them within seconds, confirms them against block depth, and sweeps funds to your wallet — all automatically.

The full documentation lives at ctrip-docs.readthedocs.io.

The Problem

Accepting crypto payments directly is painful. A merchant needs a way to:

  • Give each customer a unique deposit address per payment
  • Detect incoming transfers reliably (no polling a wallet and hoping)
  • Wait for confirmations so a payment can't be double-spent
  • Sweep funds into a single admin wallet
  • Get webhook notifications on every state change so their backend stays in sync

Doing this by hand means writing a block scanner, a state machine, a retry system, and a key-management scheme — all at once. cTrip packages that entire job into a single deployable system.

What It Does

  • Accepts native and ERC-20 payments on any EVM chain (Sepolia, BSC, Ethereum, Anvil, ...)
  • Generates a deterministically-derived deposit address per payment — no BIP-44 mnemonics
  • Detects transfers by scanning new blocks every 10 seconds
  • Moves payments through pending detected confirmed settled (or expired)
  • Delivers HMAC-SHA256-signed webhooks with exponential-backoff retries
  • Exposes a JSON admin API, analytics, and a server-rendered admin console
  • Runs fully in Docker Compose — API, PostgreSQL, Redis, and worker

Architecture

Client App / Frontend  <-->  FastAPI Server (Port 8000)
                              
                              ├── API Routers ──> PostgreSQL / SQLite
                              ├── Redis Broker ────────┐
                              └── WalletKeyManager      
                                                       
                                          ┌─────────────────────┐
                                               ARQ Worker       
                                            Block Scanner (10s) 
                                            Lifecycle Cron (15s)
                                            Sweeper             
                                            Webhook Dispatcher  
                                          └──────────┬──────────┘
                                                     
                                          BlockchainService / EVMClient
                                                     
                                             EVM Networks (RPC)

The system splits into clean layers:

  • API layer — FastAPI routers handling auth, payments, admin operations, and analytics
  • Wallet layerWalletKeyManager deriving deterministic addresses via HKDF
  • Blockchain layer — async EVMClient with automatic RPC failover across multiple endpoints
  • Scanner — block polling, transaction/log matching, and detection
  • Workers — a single ARQ process running cron-driven lifecycle, sweeping, and webhooks
  • Storage — PostgreSQL (production) or SQLite (development) plus Redis for cursors and queues

Payment Lifecycle

Every payment is a small state machine, driven by the scanner and the worker crons:

pending ──▶ detected ──▶ confirmed ──▶ settled
               
   └────expired─┴─────▶ (terminal)
  1. pending → detected — the scanner finds an on-chain transfer matching a pending payment (recipient address + value >= amount_raw) and records the event.
  2. detected → confirmed — the lifecycle cron checks block depth every 15 seconds and promotes payments past the confirmation threshold.
  3. confirmed → settled — the sweeper derives the payment's private key, moves funds to the admin wallet, and marks the payment settled.
  4. pending/detected → expired — payments past their expiry window are closed out in a single bulk update.

Every transition is appended to a payment_state_changes audit table, which feeds the analytics endpoints.

The Block Scanner

The scanner is the heart of detection. Instead of polling each payment individually or subscribing to WebSockets, it maintains one Redis cursor per chain — the last block it has enqueued — and only scans chains that have pending, unexpired payments. Idle networks cost nothing.

Every 10 seconds, the scan_orchestrator cron:

  1. Finds active scan targets (chains with pending payments)
  2. Reads the per-chain cursor from Redis
  3. Enqueues up to 500 new blocks per tick, then advances the cursor
  4. Dispatches per-block jobs for native transactions and per-range jobs for ERC-20 Transfer logs

The cursor advances when jobs are enqueued, not completed, so ARQ retries keep the bounded ranges gap-tolerant — a restart resumes scanning exactly where it left off.

Matching is straightforward: for native transfers, the to address must match a pending payment and the value must be >= amount_raw. For ERC-20, the decoded Transfer event recipient must match a pending payment for that token contract. Detections are recorded in payment_events with a unique (tx_hash, log_index) constraint, making re-scans idempotent.

Guarded State Transitions

One design detail I care about: payments move state via guarded bulk updatesUPDATE ... WHERE status='pending' ... RETURNING. Because the update atomically claims the row only if it is still in the expected status, concurrent workers can never revert a payment's state or double-promote it. No row-level locks, no read-modify-write races.

Deterministic Deposit Addresses

Every payment gets its own deposit address, derived with HKDF (RFC 5869) from the payment ID plus two server secrets — no mnemonic, no BIP-44 path.

WALLET_SECRET_A + WALLET_SECRET_B
            
              HKDF extract-and-expand (info = payment_id:key_version:recovery_key)
    Deterministic 32-byte seed
            
              SHA-256
    Ethereum private key  ──▶  checksummed deposit address

This gives several useful properties:

  • Deterministic — the same payment ID always yields the same address
  • Re-derivable — the sweeper can recover the private key at any time
  • No address reuse — each payment gets a fresh on-chain address
  • Rotatable — increment the key_version to rotate a wallet

Only the address is ever exposed to clients; the private key stays derivable only from server secrets.

Background Workers

All background processing runs in a single ARQ worker backed by Redis, which keeps deployment simple and scales horizontally by running more worker processes.

The cron schedule:

CronCadencePurpose
scan_orchestratorevery 10sScan new blocks on chains with pending payments
listen_for_paymentsevery 15sConfirm detected payments; expire stale ones
prune_stale_cursorshourlyDrop Redis cursors for inactive chains
retry_failed_webhooksevery 5 minRetry failed deliveries with backoff

The same worker hosts the task functions for scanning, lifecycle, sweeping, and webhook delivery, so the API just enqueues jobs through a thin WorkerClient — the admin console's "scan now" and "sweep now" buttons are exactly that.

Webhooks with HMAC Signing

Every state change can notify your backend asynchronously:

EventFired when
payment.confirmedA payment passes the confirmation depth
payment.expiredA payment passes expires_at
payment.sweptFunds are swept to the admin wallet

Payloads are signed with HMAC-SHA256 using WEBHOOK_SECRET, delivered in the X-Webhook-Signature header. Deliveries are recorded in a webhook_attempts table, and failures retry with exponential backoff — 1 min, 5 min, 15 min, 1 hour, 3 hours — before being marked failed and available for manual re-send via the admin API.

API Design

Two auth schemes keep merchant and admin concerns separate:

  • Merchant API keys (X-Api-Key: ck_...) for the payments API — stored as SHA-256 hashes, raw key shown once
  • Admin JWT (Authorization: Bearer <token>) for admin and analytics — access tokens last 30 minutes, refresh tokens 7 days

Creating a payment is a single request:

curl -X POST http://localhost:8000/api/v1/payments/ \
  -H "Content-Type: application/json" \
  -H "X-Api-Key: $API_KEY" \
  -d '{"chain_id": 11155111, "amount": 1000000000000000000}'

The response returns the deposit address, the payment id, and status: "pending". From there the scanner, workers, and webhooks do the rest.

Deployment

A single docker-compose up --build brings up the full stack: PostgreSQL 15 with a persistent volume, Redis 7, the API (running migrations then uvicorn on port 8000), and the worker. On first boot the app seeds the default admin user, a default merchant, and the chains table from chains.yaml.

A handy detail: RPC hosts named localhost in chains.yaml are automatically rewritten to host.docker.internal inside the container, so a local Anvil node just works.

Reflections

The most interesting engineering decisions were the ones that keep a production system honest under load:

  • Scanning by cursor, not by payment — detection cost scales with chains, not with payments
  • Guarded bulk transitions — concurrency safety without pessimistic locking
  • HKDF-derived addresses — no pre-provisioning, no address reuse, recoverable keys
  • Single worker, cron-driven — the whole background pipeline is one process you can scale out

If you're building something that needs to accept crypto payments — or you just want to study how a real gateway is structured — the full documentation is worth a read.

Documentation

Read the complete docs at ctrip-docs.readthedocs.io — covering the API reference, payment lifecycle, block scanner, background workers, database schema, and configuration guides.