Architecture
How the Turmite binary, its two roles and PostgreSQL work together, and how a payment moves through them.
Turmite is one Rust binary. It runs in two roles, api and worker, that share one PostgreSQL database. The roles never call each other. Caddy terminates TLS in front of the api role.
Overview
customer browser --+
+--> Caddy (TLS) --> api --+
your shop server --+ |
+--> PostgreSQL
|
worker ----+
|
+--> RPC nodes, webhook endpoints,
Trocador, SMTP, GitHub
The api role answers HTTP requests. The worker role does everything that is slow, periodic or needs a private key. PostgreSQL is the only state they share.
Both roles make outbound requests:
| Target | api |
worker |
|---|---|---|
| RPC nodes | Address checks, deposit addresses on Bitcoin, Monero and EVM, node probes every 120 seconds | Chain watching, sweeps, payouts, address pool, node probes every 30 seconds |
| CoinGecko, Coinbase | Prices for quotes and payout limits | Prices for EVM sweep batches |
| Trocador | Starts swaps. Fetches a swap when Trocador reports a change | Polls open swaps every 10 seconds. Refreshes the coin list once a day |
| SMTP server | Payout approval codes | Security advisory emails |
| GitHub | Check now on the Updates page | The daily update check |
| Webhook endpoints | None | Every delivery |
| Logo URLs | One download when an admin saves a logo URL | None |
Roles
Start a role with turmite serve --role all|api|worker. The default is all, which runs both roles in one process. The Docker Compose stack runs api and worker as separate containers.
| Role | Does | Holds a private key | Opens a port |
|---|---|---|---|
api |
Merchant API, checkout pages, dashboard. Gives each payment a deposit address. Encrypts imported or generated hot keys to TURMITE_HOT_RECIPIENT |
No | Yes, TURMITE_LISTEN (default 0.0.0.0:8080) |
worker |
Chain watchers, confirmations, expiry, sweeps, payouts, webhook delivery, address pool refill, swap status, update checks | Yes. It is the only process that can decrypt hot keys and the deposit seed | No |
The worker loads the hot-wallet identity at start. It does not start when TURMITE_HOT_IDENTITY_FILE is set and no passphrase is given.
| Loop in the worker | Interval |
|---|---|
| Check every open payment, then send due webhooks | 5 seconds |
| Sign and track payouts | 5 seconds |
| Poll open swaps | 10 seconds |
| Refill the address pool | 30 seconds |
| EVM batch sweeps | 30 seconds |
| Probe the RPC nodes | 30 seconds |
| Delete expired sessions | 1 hour |
| Update check | 1 minute after start, then once a day |
Run exactly one worker per install, also during an upgrade. Each api process keeps its rate limits and its two-factor lockout in its own memory.
See Security model for why the split matters.
Source layout
| Path | Contents |
|---|---|
crates/turmite/src/main.rs |
CLI entry point: serve, init, migrate, user reset2fa, version, healthcheck |
crates/turmite/src/init.rs |
turmite init: .env and secrets/ |
crates/turmite/src/http/ |
Merchant API, payout API, checkout, dashboard, static assets, middleware |
crates/turmite/src/db/ |
PostgreSQL access, one file per area |
crates/turmite/src/chains/ |
One driver per chain family, the node registry and failover, the EVM factory, the network catalog (catalog.rs) |
crates/turmite/src/worker/ |
The background loops: chain watching, confirmations, sweeps, EVM batch sweeps, address pool refill |
crates/turmite/src/payouts/ |
Payout limits, approval codes, the signer loop, one module per chain family |
crates/turmite/src/exchange/ |
The Trocador client and swaps |
crates/turmite/src/webhooks.rs |
Webhook queue, signatures and delivery |
crates/turmite/src/auth/ |
Passwords, two-factor codes, payout API tokens, rate limits |
crates/turmite/src/crypto.rs |
Sealing with TURMITE_SECRET, age encryption |
crates/turmite/src/hot.rs |
Hot wallets, encrypted with age |
crates/turmite/src/netguard.rs |
Outbound HTTP that refuses private addresses on mainnet |
crates/turmite/src/reducer.rs |
Payment status from the deposits |
crates/turmite/src/rates.rs |
Exchange rates: CoinGecko first, Coinbase as fallback, cached for 60 seconds |
crates/turmite/src/updates.rs |
Update checks |
crates/turmite/src/branding.rs |
Logos, colours, return hosts |
crates/turmite/src/mail.rs |
Email over SMTP |
crates/turmite/templates/ |
Server-rendered HTML for the dashboard and the checkout |
crates/turmite/assets/ |
Static files embedded in the binary, served at /assets/ |
crates/turmite/migrations/ |
SQL migrations |
contracts/ |
Foundry project: EVM DepositFactory and DepositWallet |
sdk/typescript/ |
@turmite/sdk |
plugins/woocommerce/ |
WooCommerce gateway plugin |
deploy/ |
Docker Compose, Caddyfile, install.sh, Kubernetes manifest, local testnet stack |
scripts/ |
backup.sh, restore.sh, e2e.sh |
openapi.yaml |
OpenAPI 3.1 description of the merchant API and webhook payloads |
Data model
The main tables, simplified:
| Table | Holds |
|---|---|
settings |
Install settings: the deposit seed (age-encrypted to TURMITE_HOT_RECIPIENT), the update check state, the nodes in use |
users, sessions |
Dashboard users with a global role, and sessions stored as SHA-256 hashes |
projects, project_members |
One row per shop: name, sealed IPN secret, signature header, payment and invoice windows, branding, return URL and hosts, checkout domain, exchange settings with the sealed Trocador key. Members and their project role |
api_keys |
Project API keys, stored as SHA-256 hashes with the last four characters |
audit_log |
Every sign-in and sensitive action |
asset, network, asset_network |
Coins, networks (confirmations, enabled, last block) and tickers with decimals and token contracts |
nodes |
RPC nodes with sealed URLs, a display URL without credentials, a priority and health |
project_wallets |
Per project and network: address and memo, Bitcoin account key and check addresses, sealed Monero view key and restore height, EVM factory |
deposit_addresses, network_address_counter, family_address_counter |
Deposit addresses with their derivation index, the address pool, and the next index per network and per family |
evm_deposits, evm_scans, evm_txs |
The factory and cold wallet of each EVM deposit address, its watch state, and the deploy and flush transactions |
invoices, payments |
Invoices and payments with amounts, status, settlement, deposit address and expiry |
payment_transactions |
Every incoming transfer, with transaction, block and confirmations |
sweeps |
Transfers from deposit addresses to the cold wallet |
hot_wallets, project_hot_wallets |
age-encrypted hot keys, and the hot wallet of each project per family |
payout_settings, payout_addresses |
Payout limits and the address book of each project |
payout_batches, payouts, payout_attempts, payout_approval_codes |
Payout requests, their signed transactions, and the emailed approval codes as keyed hashes |
webhook_endpoints, webhook_deliveries |
Endpoints with sealed secrets, and the delivery queue |
exchange_coins, exchange_trades |
The coins Trocador lists, and the swaps customers started |
Payment lifecycle
- Your shop calls
POST /v1/payment, or the customer picks a coin on an invoice page. - The
apirole gives the payment a deposit address. See Address pool. - The checkout page shows the amount, the address and a QR code. The status is
waiting. - The worker watches the deposit address through the network's RPC nodes. When it sees a transfer, the status becomes
confirming. - When the transfers with the network's required confirmations cover at least 99 % of the amount, the status becomes
confirmed. In the same pass the worker credits the funds and the status becomesfinished. - When all transfers are confirmed and cover less than 99 %, the status is
partially_paid. A top-up can still finish the payment. - A payment with no deposit before its expiry becomes
expired. The worker watches the address for one more day, so a late deposit still counts. A payment that is still underpaid one day after its expiry becomesexpired. - On networks with a sweep, the worker then moves the funds to the cold wallet. The sweep does not change the payment status. Turmite never sets
sending. - Each status change queues a webhook delivery.
With the exchange option, the customer pays an exchange through Trocador. The api role starts the swap. The exchange sends the payment coin to the same deposit address, and the steps from 4 continue as above. See Pay with any coin.
See Payment statuses for every status.
Address pool
The api role derives a deposit address itself when it needs no private key for it. On Tron, Solana and TON the address comes from a key of the install's deposit seed. Only the worker holds that seed, so the worker keeps a pool of 25 free addresses per network, and the api role takes one from the pool. When the pool is empty, the API answers 503 with Retry-After: 30. With --role all, the one process derives every address directly.
| Family | Deposit address |
|---|---|
| Bitcoin | Next BIP84 receive address of the project's account key, from a watch-only wallet turmite-p<project_id> on bitcoind |
| Monero | New subaddress of the project's view-only wallet in monero-wallet-rpc. Turmite checks the view key against the address itself |
| EVM | CREATE2 address computed from the project's factory and an install-wide salt. The contract is deployed at the first sweep |
| Tron, Solana, TON | Next key from the install's deposit seed, from the pool. The private key stays with the worker |
Sweeps
| Family | Sweep |
|---|---|
| Bitcoin, Monero | None. Funds arrive in your own wallet |
| EVM | The worker sends one flush per project, network and factory from the project's EVM hot wallet. It deploys the factory first when the chain has no code at its address. The factory deploys missing deposit contracts, and they send to the cold wallet fixed in the factory. The hot wallet pays the gas |
| Tron | The worker signs a transfer with the deposit key. For TRC-20 the hot wallet first sends the TRX that the transfer burns. A TRX deposit pays its own fee |
| Solana | The worker signs a transfer with the deposit key. A SOL deposit pays its own fee. For SPL tokens the hot wallet pays the fee and creates the cold wallet's token account if needed. The empty deposit token account is closed and its rent goes to the hot wallet |
| TON | The first sweep deploys the wallet v4r2 contract at the deposit address and sends the whole balance. A settlement memo goes as a text comment |
An EVM batch goes out when gas is at most 2 % of its value, when it is worth 1000 USD or more, when it has 150 deposits, or when its oldest payment waited 6 hours. It never goes out while gas is more than 20 % of its value. A failed EVM flush is tried again after one hour.
On Tron, Solana and TON the worker sweeps each payment on its own, after it credits the payment. A sweep that fails before the broadcast is tried again on the next pass. The worker then checks each broadcast sweep on chain until it is final. A sweep that failed on chain, or that the chain does not have 10 minutes after the broadcast, gives its amount back, and the worker sends it again: at once after each of the first 3 failures, then once per hour. A sweep that can still land keeps its amount, so the worker never sends the same funds twice. After the watch window of a payment ends, the worker still checks its open sweeps and sweeps funds that stay at the deposit address, every 60 seconds.
Only the worker signs sweeps. Without a hot wallet, or without gas in it, EVM, TRC-20 and SPL sweeps stop and the funds stay at the deposit addresses.
Nodes and failover
Each network has a list of RPC nodes, edited under Server → Networks. Node URLs are sealed with TURMITE_SECRET. The dashboard and the audit log show them without credentials.
At start, each process connects to the first node in list order that answers. The worker probes every enabled node every 30 seconds, the api every 120 seconds. When the node in use fails two probes in a row, or falls too far behind the best node, the process moves the network to the next healthy node. After two good probes of a node higher in the list, it moves back. The worker records each move in the audit log as node.failover. Both processes connect again when the node list changes.
The Networks page shows each node's status, height, lag, last check and last error, the node in use, and the last block of each network. See Networks and nodes.
Webhook delivery
Each event becomes a row in webhook_deliveries. The worker sends due rows, signs each attempt and records the response. Failed attempts are retried on a fixed schedule. After the last attempt, the delivery is dead. See Webhooks.
Payouts
The api role stores a payout request and checks it against the limits and the address book. The worker signs approved payouts with the hot wallet key, stores each signed transaction, then broadcasts it and tracks it to a final status. EVM payouts and EVM sweeps from the same hot wallet share one nonce lock. See Payouts.
Update checks
A minute after start and then once a day, the worker reads the latest release and the security advisories of loruorg/turmite on GitHub. The result is stored in settings. The dashboard shows a banner to admins when a newer release or an advisory exists. The check is on by default. Turn it off under Server → Updates.
Migrations
turmite migrate applies the SQL migrations and exits. turmite serve applies them at start too, in every role.