Architecture
Coldtill is one static Go binary with the React dashboard embedded. It runs as four processes that share a PostgreSQL database and never call each other directly.
flowchart LR
customer[Customer browser] --> caddy[Caddy TLS]
shop[Merchant backend] --> caddy
caddy --> api
api --> pg[(PostgreSQL)]
watcher --> pg
sweeper --> pg
payouts --> pg
watcher --> rpc[RPC nodes]
sweeper --> rpc
payouts --> rpc
watcher --> hooks[Merchant webhooks]
api --> swap[Swap providers]
| Process | Responsibilities | Holds a hot key | Holds COLDTILL_SECRET |
|---|---|---|---|
api |
Merchant API, dashboard, hosted checkout, conversion quotes | no | yes |
watcher |
Chain watchers, confirmations and reorgs, payment expiry, conversion tracking, webhook delivery | no | yes |
sweeper |
Deploys EVM deposit contracts when needed, sweeps EVM and Solana deposits to cold wallets | yes | no |
payouts |
Signs, broadcasts, fee-bumps and confirms approved payouts | yes | no |
The split matters for security: the process exposed to the internet (api) holds no private key, and the
processes that hold keys accept no network connections. See Security.
Source layout
| Path | Contents |
|---|---|
cmd/coldtill |
Entry point, init wizard, process wiring |
internal/api |
HTTP handlers: merchant API, dashboard API, checkout, branding assets |
internal/payment |
Payment lifecycle: create, attribute deposits, fill status, expiry, webhook events |
internal/chains/evm, sol, btc |
Per-chain clients, watchers and sweepers |
internal/rpcpool |
Node pools with preferred node, failover and health checks |
internal/payout |
Payout creation, limits, approval and the signing loop |
internal/convert |
Auto-convert: providers, quotes, tracking, on-chain delivery checks |
internal/webhook |
Signing, queueing and delivery with retries |
internal/branding |
Logo upload normalisation |
internal/hotwallet, internal/secrets |
age-encrypted hot keys, sealed secrets |
internal/netguard |
Outbound HTTP that refuses private addresses |
internal/store |
Migrations, sqlc queries and generated code |
contracts/ |
Foundry project: DepositFactory, DepositWallet, tests and fuzzing |
solana/ |
Anchor deposit program |
web/ |
React dashboard and checkout (Vite) |
sdk/typescript, plugins/woocommerce |
Integrations |
deploy/ |
Docker Compose, Caddyfile, install script |
Data model
The main tables, simplified:
projects: settings, branding, sweep thresholds, payout limits, auto-convert options.customers: one row per merchant customer ID within a project.customer_addresses: each customer's permanent deposit address per chain family, with its derivation index.payments: amount in USD, status, filled and pending USD, expiry. A customer has at most one open payment.deposits: every incoming transfer, with block number, confirmations and status (pending,confirmed,orphaned).deposit_wallets: per project and family, the cold wallet, factory or program config, and per-network deployment state.sweepsandsweep_addresses: batched sweep transactions and the addresses they covered.hot_wallets: encrypted keys.project_hot_walletsassigns them to projects.payoutsandaddress_book: outgoing payments and approved destinations.conversions: auto-convert swaps, their quotes, provider status and verified delivery.webhook_endpointsandwebhook_deliveries: endpoints with sealed secrets, and the delivery queue.rpc_nodesandnetworks: node pools and per-network settings (confirmations, enabled, last processed block).audit_log: who did what, for every sensitive dashboard action.
Payment lifecycle
- Create.
POST /api/v1/paymentupserts the customer and replaces their previous open payment, unless that payment has a deposit still confirming, in which case the request is refused with409. - Address. When the customer picks a chain at checkout, Coldtill assigns their permanent address for that family, deriving it if needed. EVM addresses are counterfactual: the contract is only deployed when the first sweep needs it.
- Detect.
- EVM: the watcher scans blocks in bounded ranges for ERC-20
Transferlogs to watched addresses, and periodically reconciles native balances. - Solana: the watcher polls signatures for each deposit address and reads token balance changes on the canonical token account.
- Bitcoin: the watcher polls Esplora for each watched address.
- EVM: the watcher scans blocks in bounded ranges for ERC-20
- Confirm. Deposits become
confirmedafter the network's configured confirmations, with block hashes re-checked. A deposit whose block disappears in a reorg goes back to pending; one whose transaction vanishes isorphanedand reversed. - Fill. Confirmed USD is compared with the amount owed, with a 1% tolerance:
PARTIALLY_FILLED,FILLEDorOVER_FILLED. Prices come from CoinGecko with Coinbase as fallback; stablecoins count as $1. - Notify. Every change queues a webhook. Closing a payment sends exactly one
finalevent. - Expire. Open payments past their expiry (24 hours by default) become
CANCELLED. Late deposits still count toward the filled amount, but don't reopen the payment.
Sweeps
The sweeper runs per project and network:
- It lists deposit addresses holding at least the project's activation minimum in confirmed, unswept value.
- It sweeps when any trigger fires: the total reaches the sweep amount ($100 by default), enough addresses hold funds (20), or the oldest unswept deposit has waited the sweep interval (24 hours). Gas must also be a small enough share of the value, and a failed sweep backs off for 10 minutes.
- EVM: a single transaction to your factory deploys any missing deposit contracts and flushes each one.
Tokens that fail to transfer emit
FlushFailed; those addresses stay unswept and are retried later. - Solana: a transaction with one
sweep_solorsweep_tokeninstruction per account, sized to fit the 1232-byte limit. The canonical token account is always swept; at most three other token accounts worth at least $1 each are included, so dust accounts can't block a sweep. - Every sweep is signed and stored before it's broadcast, then tracked until it's confirmed, replaced or failed.
Payouts
Payouts are created by the dashboard or the API, checked against limits and the address book, held for
approval if needed, then picked up by the payouts process. It signs, stores, broadcasts, fee-bumps and
confirms. See Payouts.
Nonces and shared hot wallets
The sweeper and payouts processes can share an EVM hot wallet. Nonces are reserved under a Postgres advisory lock per wallet and network, inside the same transaction that stores the signed transaction. If a transaction fails permanently while a later nonce is already in flight, Coldtill fills the gap with a zero-value self-transfer so the later transaction can still be mined.
Node pools
Each network has a pool of RPC nodes (Esplora for Bitcoin). The preferred node is tried first, and a failing node is skipped for a cool-down period. A health loop probes every node so the dashboard can show status and the last processed block. URLs are redacted in errors, since providers often embed API keys in them. Decisions where one node's word isn't enough ("this payout never landed", "this Bitcoin deposit was dropped") ask every node.