Coldtill Get Coldtill

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

  1. Internet to Caddy to api. The api process holds no private keys. It can create payouts, but it cannot sign them.
  2. api to Postgres to payouts and sweeper. These communicate only through the database. A compromised api can write rows, but signing processes re-check everything they need (see below).
  3. 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.
  4. 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 DepositFactory stores 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 treasury in their config, which is enforced by has_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 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 failed once it provably can't land; when Coldtill can't tell, it becomes needs-review for 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 payouts with 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 SELFDESTRUCT stops clearing code, clones persist. They are then flushed in place, which is still safe but less gas-efficient (covered by test_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 mismatch on the later ones, which an operator resolves by hand.
  • R9. In-memory 2FA lockout. The lockout resets if the api process restarts. Login rate limits still apply.