Coldtill threat model
Paste into Claude Code, Codex, Cursor or your favourite coding agent. View Markdown
What we protect
| Asset | Where it lives | Impact if compromised |
|---|---|---|
| Customer deposits | EVM CREATE2 addresses, Solana PDAs, merchant's Bitcoin wallet | Loss of merchant revenue |
| Merchant cold wallets / treasury | Fixed in the deployed factory and program config | Total loss if redirected |
| Hot wallets (EVM, Solana) | Postgres, age-encrypted; unlocked only in sweeper and payouts |
Loss of the hot balance (gas float plus payout float) |
Server secret (COLDTILL_SECRET) |
.env |
Session forgery, decryption of TOTP and webhook secrets |
| Admin accounts | Argon2id hashes and TOTP secrets in Postgres | Payouts within limits, configuration changes |
| API keys | SHA-256 hashes in Postgres | Creating payments; payouts only to address-book entries |
| Payment state and webhooks | Postgres and merchant endpoint | Merchant ships goods for unpaid orders |
Trust boundaries
- Internet to Caddy to
api. Theapiprocess holds no private keys. It can create payouts, but it cannot sign them. apito Postgres topayoutsandsweeper. These communicate only through the database. A compromisedapican write rows, but signing processes re-check everything they need (see below).- Coldtill to RPC nodes. Nodes are untrusted for anything that moves money. Chain IDs are signed into EVM transactions, and Solana genesis hashes are pinned.
- Coldtill to the merchant's browser wallet. Factory deployment and Solana config initialisation are signed in the merchant's wallet. The server never sees those keys.
Key design decisions
- Funds cannot be redirected, even by a fully compromised server.
- The EVM
DepositFactorystores the cold wallet as an immutable, and deposit addresses are CREATE2 clones of an implementation that can only flush to it. - Solana deposit PDAs can only be swept to the
treasuryin their config, which is enforced byhas_one, and there is no instruction that changes it. - Bitcoin addresses come from the merchant's xpub, so the server never holds a Bitcoin key.
- The EVM
- The hot key's blast radius is its own balance. Keep the float small: roughly a week of gas plus expected payouts.
- Payout controls (all server-side):
- per-project auto-approve amount, a minimum, and hourly and daily limits counted across the whole project (every user and API key, in-flight payouts included);
- an address book that can only be edited in the dashboard with 2FA;
- admin approval needs 2FA plus a one-time code emailed to that admin, at most 5 attempts, valid for 10 minutes and bound to the payout;
- idempotency keys;
- signing and persisting happen before broadcast, so a crash cannot double-send;
- fee bumps reuse the same nonce, recipient and amount;
- a payout is only marked
failedonce it provably can't land; when Coldtill can't tell, it becomesneeds-reviewfor a human to resolve with 2FA.
- Nonce safety. The sweeper and payouts processes share hot wallets. Nonces are reserved under a Postgres advisory lock on the wallet address, inside the transaction that stores the signed transaction. If a lower nonce is abandoned while a higher one is in flight, the gap is filled with a zero-value self-transfer.
- Address checks.
- EVM addresses are EIP-55 validated. A lowercase address is accepted, but a wrong mixed-case checksum is rejected.
- Solana payouts to token accounts are refused (funds would be stranded), and native transfers below rent-exemption to new accounts are refused.
- Deposit addresses are re-derived from the xpub, factory or program, and cross-checked when wallets are set up.
Threats and mitigations
| # | Threat | Mitigation |
|---|---|---|
| T1 | Attacker gains code execution in api |
No private keys in that container (secrets are only mounted into sweeper and payouts). Payouts it creates are still subject to limits, approval and the address book, which the payouts process enforces from the DB rows. Residual: it can approve rows directly in SQL; see R1. |
| T2 | Database compromise (read) | Passwords are Argon2id. TOTP and webhook secrets are sealed with the server secret, which is not in the DB. API keys are hashed. Hot keys are age-encrypted under a passphrase that is not in the DB. |
| T3 | Database compromise (write) | Can create or approve payout rows (R1). Cannot change where sweeps go. |
| T4 | Stolen admin password | TOTP is required for login. Sensitive actions need a fresh code, and each code is single-use (replay protection, RFC 6238 §5.2). Five wrong codes lock 2FA checks for 15 minutes. Creating API keys, adding RPC nodes and lowering confirmations need a fresh code too. |
| T5 | Stolen API key | Can create payments. Can create payouts only to address-book entries, within limits; above them, payouts wait for admin approval. |
| T6 | Malicious or faulty RPC node | It could hide deposits (failover across nodes and a last-processed-block view expose this) or fake them. Confirmations, block hashes and reorg handling come from the node that answers. Bitcoin deposits are only orphaned when every node reports the transaction missing; Solana payouts are only re-signed when every node reports the original never landed. Node URLs are redacted from errors. Residual: R5. |
| T7 | Chain reorg | Deposits are only final after the configured confirmations. Block hashes are re-checked and orphaned deposits are reversed. |
| T8 | Price feed manipulation | CoinGecko with Coinbase fallback, and stablecoins are fixed at $1. Quotes expire with the payment. Prices only affect USD accounting and limits, never where funds go. |
| T9 | Webhook spoofing, replay or SSRF | Webhooks are HMAC-SHA256 signed; the v2 signature covers a timestamp so receivers reject replays, and the SDK takes the event from the signed body. On mainnet, endpoints must be public https URLs, private and reserved ranges are refused at connect time (defeating DNS rebinding), and redirects are not followed. |
| T10 | XSS or clickjacking in the dashboard or checkout | React escaping, a strict CSP (script-src 'self', connect-src 'self', frame-ancestors 'none'; checkout uses default-src 'none'), and X-Frame-Options: DENY. |
| T11 | CSRF | SameSite=Strict session cookies. State-changing dashboard requests must be JSON and same-origin (Origin check). |
| T12 | Brute force or resource exhaustion | Rate limits on login (per IP and per normalised email), API and public checkout endpoints. Argon2id passwords with at most four concurrent hashes and bounded parameters. TOTP lockout. Only the rightmost X-Forwarded-For hop is trusted. |
| T13 | Testnet install pointed at mainnet nodes | EVM chain IDs are signed into transactions. Solana genesis hashes are verified before watching, sweeping or paying out. |
| T14 | Double payout | Idempotency keys (a unique index). FOR UPDATE SKIP LOCKED claims. Transactions are signed and stored before broadcast. Solana re-signs only after the blockhash has expired and every node confirms the original never landed. Uncertain EVM outcomes become needs-review, not failed. |
| T15 | Supply chain | govulncheck, gosec and npm audit in CI. Distroless, non-root image. Pinned contract compiler (0.8.28). |
| T16 | Hot key leak via backups | Backups of deploy/secrets/ hold the age identity, encrypted under the passphrase. hot_passphrase exists only for unattended restarts: omit it (coldtill init --store-passphrase=false) and pass the passphrase at start-up if your threat model requires it. |
| T17 | Swap provider lies about a conversion, or a compromised provider endpoint returns a hostile quote | Quotes are rejected unless the recipient is the project's cold wallet, the destination is the configured settlement asset, the guaranteed output covers the amount owed, and (1Click) no memo is required. A payment is credited only from a settlement-token transfer to the cold wallet that Coldtill's own nodes see with the required confirmations (Solana: finalized). Transfers from Coldtill's own addresses (sweeps, payouts) and transfers mined before the conversion was created don't count. Each delivery transaction can credit one conversion. Anything else becomes mismatch and is logged. Refund and deposit addresses are validated. |
| T18 | Privilege escalation inside the dashboard | Project admins can't attach hot wallets (global admin only) or disable admins (root only). Creating API keys needs project_admin plus 2FA. |
| T19 | Hostile uploaded logo | PNG, JPEG and WebP only (no SVG), dimensions checked before decoding, re-encoded to PNG at most 512 px, served from a content-addressed path with a sandboxing CSP. |
Residual risks
- R1. DB write access can approve payouts. Approval state lives in Postgres, which the payouts process trusts. Mitigations:
- keep the hot float small;
- keep Postgres private (it is not exposed by the compose file);
- consider a separate DB role for
payoutswith read access to configuration.
- R2. Swap providers (auto-convert) hold customer funds for the duration of a swap. A provider that fails to deliver or refund loses the customer's money, not the merchant's: the payment is never credited without an on-chain delivery. Auto-convert is off by default, and THORChain is a separate opt-in.
- R3. Bitcoin deposits depend on the merchant's xpub being correct. Coldtill checks the first derived address against the merchant's own wallet during setup.
- R4. EIP-6780 and future EVM changes. If
SELFDESTRUCTstops clearing code, clones persist. They are then flushed in place, which is still safe but less gas-efficient (covered bytest_persistedCloneIsFlushedInPlace). - R5. Deposits trust the answering node. Coldtill doesn't require several nodes to agree before crediting a deposit, so a malicious node could report a fake one. Use your own or paid nodes from several providers, and check large orders on a block explorer before shipping.
- R6. Admins can approve their own payouts. Single-admin installs need this. Teams should agree that someone else approves.
- R7. Underpayment tolerance. A payment within 1% of the amount counts as filled, to absorb wallet rounding and fees.
- R8. Batched swap deliveries. A provider that delivers several conversions in one transaction gets a
mismatchon the later ones, which an operator resolves by hand. - R9. In-memory 2FA lockout. The lockout resets if the
apiprocess restarts. Login rate limits still apply.