# Coldtill documentation Every page of https://coldtill.com/docs/ in one file. # Coldtill overview Coldtill lets a merchant accept crypto payments on their own server, without a payment company in the middle and without the server ever being able to spend the money it receives. The merchant runs the software, customers pay into deposit addresses, and funds are swept to wallets the merchant controls offline. The deposit addresses are smart contracts and program accounts that can only send to the merchant's cold wallet, so the rule is enforced on chain rather than by the server's good behaviour. ## The problem it solves A hosted payment gateway holds your money until it pays you out, can freeze it under its terms, and sees every customer. Running a plain wallet yourself avoids that, but then the server that takes orders also holds the keys, and a single breach empties it. Coldtill separates the two. The server that faces the internet tracks payments and talks to customers. The money goes to addresses that can only ever pay out to your **cold wallet**: a hardware wallet or a multisig that never touches the server. ## How a payment works 1. Your shop calls `POST /api/v1/payment` with a customer ID and a USD amount, and gets back a checkout URL. 2. The customer opens the hosted checkout, picks a coin and network, and sees a QR code and the exact amount. 3. The customer pays to their **permanent deposit address** on that chain. Each customer keeps the same address per chain family, so repeat customers can save it. 4. Coldtill's watcher sees the transaction, waits for the network's required confirmations, handles reorgs, and updates the payment: `OPEN`, `PARTIALLY_FILLED`, `FILLED` or `OVER_FILLED`. 5. Your shop receives a signed webhook and ships the order. 6. Later, the sweeper moves the funds from deposit addresses to your cold wallet in batches, when the value justifies the gas. ## Why the server can't steal the funds Each chain uses a different mechanism, but the effect is the same: the destination is fixed on-chain before any customer pays, and nothing the server does can change it. | Chain | Deposit address | Who can move funds, and where to | |---|---|---| | EVM (Ethereum, Base, Polygon, BNB Chain) | A CREATE2 contract address computed from your factory | Anyone can trigger a sweep, but the contract can only send to the cold wallet written into the factory when you deployed it from your own wallet | | Tron | A CREATE2 contract address computed from your Tron factory | The same as EVM: anyone can trigger a sweep, but only to the cold wallet fixed in your factory | | Solana | A program-derived address (PDA) of the Coldtill deposit program | Anyone can trigger a sweep, but the program only sends to the treasury fixed in your config account, which your wallet created | | Bitcoin | An address derived from your xpub (watch-only) | Only you, with the private key that never leaves your wallet. Coldtill never sweeps Bitcoin | The only private keys on the server are **hot wallets**. They pay gas for sweeps and send payouts you approve. They are encrypted with [age](https://age-encryption.org) under a passphrase, and only the two processes that sign (`sweeper` and `payouts`) can unlock them. Keep their balance small: the worst case is losing that float. ## What it supports - **Chains:** Ethereum, Base, Polygon and BNB Chain (native coin, USDC, USDT); Tron (TRX and USDT, with USDC available but off by default); Solana (SOL, USDC, USDT); Bitcoin (watch-only). Each install runs on testnets or mainnets. - **Projects:** one install serves one merchant with any number of projects (shops). Each project has its own deposit wallets, API keys, webhooks, branding, sweep settings and payout limits. - **Hosted checkout** with a QR code, confirmation progress and your branding: logo, colours, light or dark theme, a message, support links and a return button. - **Webhooks** signed with HMAC-SHA256, with timestamps for replay protection and a long retry schedule for final events. - **Payouts** from the hot wallet with an address book, limits, two-factor plus emailed-code approval, and idempotency keys. - **Auto-convert (optional):** customers pay in BTC, ETH, SOL and other coins, and a swap provider (NEAR Intents 1Click, or THORChain) delivers USDC or USDT straight to your cold wallet. - **Integrations:** a TypeScript SDK and a WooCommerce plugin. ## What it deliberately doesn't do - **Custody for others.** One install is one merchant. There is no multi-tenant "operator" mode. - **Fiat.** There is no off-ramp; auto-convert settles in stablecoins. - **Refunds from deposit addresses.** Refunds are payouts from the hot wallet, which you approve. - **Trust a swap provider's word.** A conversion is only credited when Coldtill's own nodes see the stablecoin arrive in your cold wallet. ## Where to go next - [Architecture](https://coldtill.com/docs/architecture.md) for how the processes and data fit together. - [Install and operations](https://coldtill.com/docs/install.md) to run it. - [Security](https://coldtill.com/docs/security.md) for the full security model and audit status. --- Source: https://coldtill.com/docs/overview/ · All Coldtill docs: https://coldtill.com/llms.txt # Guide: accept a payment from your shop This walks through the whole flow once: create an API key, create a payment, send the customer to checkout, and mark the order paid from a webhook. It takes about fifteen minutes on a server that has finished **Get started** in the dashboard (a cold wallet and at least one deposit wallet). The full reference is [`openapi.yaml`](https://coldtill.com/openapi.yaml), rendered at [coldtill.com/docs/api](https://coldtill.com/docs/api). ## 1. Create an API key and a webhook 1. In the dashboard, open **API keys → New key**. Copy the key (`ct_…`); it is shown once. Store it as `COLDTILL_API_KEY` in your shop's environment, never in the browser. 2. Open **Webhooks → Add endpoint** with your shop's URL, for example `https://shop.example.com/coldtill/webhook`. Copy the signing secret as `COLDTILL_WEBHOOK_SECRET`. ## 2. Create a payment when the customer checks out Use your own order number as `invoiceID` and a stable customer ID: the same customer always gets the same deposit addresses. ```bash curl -X POST https://pay.example.com/api/v1/payment \ -H "API-Key: $COLDTILL_API_KEY" -H 'Content-Type: application/json' \ -d '{"customerID":"42","customerEmail":"jane@example.com","amountInUSD":25.00,"invoiceID":"INV-1001"}' ``` ```ts // Node 18+, with the SDK: npm install @coldtill/sdk import { Coldtill } from '@coldtill/sdk' const ct = new Coldtill({ baseUrl: 'https://pay.example.com', apiKey: process.env.COLDTILL_API_KEY! }) const { url, reference_id } = await ct.createPayment({ customerID: '42', amountInUSD: 25, invoiceID: 'INV-1001' }) await saveReference('INV-1001', reference_id) return redirect(url) ``` ```php $res = wp_remote_post('https://pay.example.com/api/v1/payment', [ 'headers' => ['API-Key' => getenv('COLDTILL_API_KEY'), 'Content-Type' => 'application/json'], 'body' => wp_json_encode(['customerID' => '42', 'amountInUSD' => 25.00, 'invoiceID' => 'INV-1001']), 'timeout' => 20, ]); $payment = json_decode(wp_remote_retrieve_body($res), true); wp_redirect($payment['url']); ``` ```python import os, requests r = requests.post( "https://pay.example.com/api/v1/payment", headers={"API-Key": os.environ["COLDTILL_API_KEY"]}, json={"customerID": "42", "amountInUSD": "25.00", "invoiceID": "INV-1001"}, timeout=20, ) r.raise_for_status() payment = r.json() # {"host": ..., "reference_id": ..., "url": ...} ``` Save `reference_id` with the order, then redirect the customer to `url`. If the project has a [checkout domain](https://coldtill.com/docs/configuration.md#checkout-domains), the link uses it. A `409` means the customer's previous payment still has a deposit confirming; show "try again in a few minutes". ## 3. Mark the order paid from the webhook Coldtill posts to your endpoint as deposits arrive and confirm. Verify the signature on the **raw** body, then act on the signed `status`: ```ts import express from 'express' import { verifyWebhookRequest, isPaid } from '@coldtill/sdk' app.post('/coldtill/webhook', express.raw({ type: 'application/json' }), async (req, res) => { try { const ev = await verifyWebhookRequest(req.body, req.headers, process.env.COLDTILL_WEBHOOK_SECRET!) if (ev.event.startsWith('payment.') && isPaid(ev.data.status)) { await markOrderPaid(ev.data.invoice_id, ev.data.reference_id) // must be idempotent } res.sendStatus(200) } catch { res.sendStatus(401) } }) ``` PHP, Go and Python versions are in [Webhooks](https://coldtill.com/docs/webhooks.md#verifying-requests). Rules that avoid the usual mistakes: - Ship on `FILLED` or `OVER_FILLED` only. `PARTIALLY_FILLED` means the customer sent too little; the checkout page asks them for the rest. - Check that `reference_id` matches the one you saved for `invoice_id`, so an old payment can't mark a newer order. - Make `markOrderPaid` idempotent: the final event can be retried for up to two days. - Respond within 60 seconds. Do slow work (emails, fulfilment) after responding. ## 4. Handle the edges | What happened | Webhook status | What to do | |---|---|---| | Customer never paid | `CANCELLED` after the expiry (24 h by default) | Cancel the order or offer a new link | | Customer started again | `REPLACED` | Ignore; the newer payment carries on | | Paid 10% too much | `OVER_FILLED` | Ship; refund the difference with a payout if you like | | Paid late, after expiry | progress event with a new `filled_amount` | Decide by hand; the payment stays closed | ## 5. Test it On a testnet server (`COLDTILL_MODE=testnet`), pay with Base Sepolia USDC or Sepolia ETH from a faucet, and point the webhook at `http://localhost`. **Webhooks → Send test** sends a signed test event at any time. Then switch to a mainnet server and pay yourself $1 before going live. ## Refunds and supplier payments Payouts send from the hot wallet. Through the API they can only go to addresses in the project's address book, and amounts over the project's limits wait for an admin. See [Payouts](https://coldtill.com/docs/payouts.md) and the `createPayout` operation in the reference. --- Source: https://coldtill.com/docs/guides/accept-payments/ · All Coldtill docs: https://coldtill.com/llms.txt # Self-hosting guide Coldtill runs on a server you control. This guide covers the decisions around the install: what server to rent, how to keep the dashboard off the public internet, backups, updates, and a checklist to work through before you take real payments. The commands themselves are in [Install and operations](https://coldtill.com/docs/install.md). ## Choosing a server Coldtill is light. PostgreSQL and four small Go processes run comfortably on: | | Minimum | Recommended | |---|---|---| | CPU | 2 vCPU | 2–4 vCPU | | Memory | 2 GB | 4 GB | | Disk | 20 GB SSD | 40 GB SSD or more | | OS | Any Linux with Docker | Ubuntu 24.04 LTS or Debian 12 | Both `amd64` and `arm64` are supported. A small VPS from Hetzner, OVH, DigitalOcean, Vultr or Linode is plenty; you don't need a dedicated machine. Things that matter more than size: - **A provider firewall.** Pick a host that lets you filter traffic before it reaches the server (Hetzner Cloud Firewall, DigitalOcean Cloud Firewall, AWS security groups). See [Firewall](#firewall). - **Snapshots or backups** at the provider level, in addition to your own database dumps. - **A provider that allows crypto payment processing** in its terms. Most do; check before you sign up. - **Location.** Put the server close to your shop if you can. Coldtill doesn't need to be near any chain. Don't run Coldtill on the same machine as your shop or anything else exposed to the internet. A compromise of the shop shouldn't reach the hot wallets. ## Before you install 1. **Create a non-root user with SSH keys.** Disable password logins in `/etc/ssh/sshd_config` (`PasswordAuthentication no`, `PermitRootLogin prohibit-password`) and run `sudo systemctl reload ssh`. 2. **Turn on automatic security updates:** `sudo apt install unattended-upgrades` and `sudo dpkg-reconfigure -plow unattended-upgrades`. 3. **Pick the public hostname** customers will see on the checkout page, for example `pay.example.com`, and point a DNS `A` record at the server. We recommend proxying it through Cloudflare; see [Point the subdomain at the server](https://coldtill.com/docs/guides/domains-and-dashboard.md#point-the-subdomain-at-the-server). 4. **Set up email.** On mainnet Coldtill needs SMTP for payout approvals and update notices. Any transactional provider works (Postmark, Mailgun, Amazon SES, Resend, or your own mail server). Then follow [Install](https://coldtill.com/docs/install.md#install) and [First-run setup](https://coldtill.com/docs/install.md#first-run-setup). ## Keep the dashboard private The checkout page and the merchant API must be public. The login page and the dashboard should not be: they are how money gets moved, so the fewer people who can reach them, the better. Coldtill always needs two-factor codes for sensitive actions, but there's no reason to let the whole internet try passwords. Choose one of these. The strongest setup combines Tailscale for signing in with a Cloudflare rule that blocks the dashboard on every public name. [Guide: projects, domains and a private dashboard](https://coldtill.com/docs/guides/domains-and-dashboard.md) walks through it step by step, including DNS, certificates behind Cloudflare, and adding projects with their own domains. ### Option 1: a separate checkout domain (simplest) Give each project a [checkout domain](https://coldtill.com/docs/configuration.md#checkout-domains) such as `pay.example.com`, and keep the dashboard on a different name that you don't publish, such as `admin.example.com`. On the checkout domain Coldtill serves only the checkout, the merchant API and static files; the login page and `/api/dash` answer 404. Then restrict the dashboard name with one of the options below, or at least a Cloudflare WAF rule. ### Option 2: Cloudflare in front Put the hostname behind Cloudflare's proxy (orange cloud), set SSL/TLS to **Full (strict)**, and tell Caddy to trust Cloudflare's client-IP header, as described in [Behind Cloudflare](https://coldtill.com/docs/configuration.md#behind-cloudflare). Then add protection for the dashboard paths: - **Cloudflare Access** (Zero Trust, free for small teams): create a self-hosted application for the dashboard hostname, or for the paths `/login`, `/api/dash` and `/` on the main hostname, and allow only your team's email addresses. Leave `/pay/`, `/api/checkout/`, `/api/v1/`, `/assets/`, `/branding/` and `/healthz` open. - **Or a WAF custom rule** that blocks everything except your office IPs: ```text (http.host eq "pay.example.com" and not starts_with(http.request.uri.path, "/pay/") and not starts_with(http.request.uri.path, "/api/checkout/") and not starts_with(http.request.uri.path, "/api/v1/") and not starts_with(http.request.uri.path, "/assets/") and not starts_with(http.request.uri.path, "/branding/") and http.request.uri.path ne "/healthz" and http.request.uri.path ne "/favicon.svg" and not ip.src in {203.0.113.10}) ``` Action: **Block**. Replace `203.0.113.10` with your own addresses. Cloudflare only helps if attackers can't skip it, so also restrict ports 80 and 443 to Cloudflare's ranges at the [firewall](#firewall). ### Option 3: Tailscale (dashboard only on your private network) [Tailscale](https://tailscale.com) gives your laptop and the server a private network. You can serve the dashboard only there, and leave the public hostname for checkout and the API alone. 1. Install Tailscale on the server and your devices: `curl -fsSL https://tailscale.com/install.sh | sh`, then `sudo tailscale up`. 2. Add a private entrance to Caddy. Copy `deploy/Caddyfile` to `deploy/Caddyfile.local` and append: ```text http://:8081 { encode zstd gzip reverse_proxy api:8080 { header_up Host pay.example.com header_up Origin ^https://coldtill\.your-tailnet\.ts\.net$ https://pay.example.com header_up X-Forwarded-Proto https header_up X-Forwarded-For {client_ip} } } ``` Coldtill only accepts requests whose origin is its own site URL, so the entrance presents itself as the public name. The `Origin` line rewrites only requests that come from your tailnet address. Don't replace it with a fixed value: that would switch off the cross-site request check for the private entrance. 3. Publish the port on localhost only, in `deploy/docker-compose.override.yml`: ```yaml services: caddy: ports: - "127.0.0.1:8081:8081" volumes: - ./Caddyfile.local:/etc/caddy/Caddyfile:ro ``` 4. Start it: `docker compose up -d caddy`, then `sudo tailscale serve --bg http://127.0.0.1:8081`. The dashboard is now at `https://..ts.net`, with a certificate Tailscale manages. 5. Block the dashboard on the public name with a Cloudflare WAF rule (Option 2) or a checkout domain (Option 1). If Caddy and `tailscale serve` both want port 443, start Caddy first: run `sudo tailscale serve reset`, `docker compose up -d --force-recreate caddy`, then the `tailscale serve` command again. ## Firewall Allow only what you need: SSH (22) and HTTP/HTTPS (80, 443). Behind Cloudflare, allow 80 and 443 only from Cloudflare's ranges. **Docker bypasses ufw.** Ports published by Docker Compose are opened in iptables ahead of ufw's rules, so `ufw allow from to any port 443` does not stop anyone else reaching Caddy. Use one of these instead: - **Your provider's firewall** (recommended). It filters before traffic reaches the server, so Docker can't override it. Allow 22 from your addresses, and 80 and 443 from Cloudflare's ranges, or from anywhere if you aren't using Cloudflare. - **The `DOCKER-USER` iptables chain**, which Docker consults before its own rules. For example, to allow only Cloudflare to reach published ports: ```bash for r in $(curl -fsS https://www.cloudflare.com/ips-v4); do sudo iptables -I DOCKER-USER -i eth0 -s "$r" -p tcp -m multiport --dports 80,443 -j RETURN done sudo iptables -A DOCKER-USER -i eth0 -p tcp -m multiport --dports 80,443 -j DROP sudo apt install iptables-persistent && sudo netfilter-persistent save ``` Replace `eth0` with your public interface (`ip route get 1.1.1.1` shows it), and repeat with `ip6tables` for IPv6. Check from another machine: `curl -sk --resolve pay.example.com:443: https://pay.example.com/healthz` should time out when you're behind Cloudflare. ## Accounts and two-factor - Enable two-factor on the root account before anything else, and on every admin. - Give staff the lowest role that works: `developer` for integration work, `read_only` for support. - Store recovery details for the root account (password, two-factor seed) offline, with the cold wallet seed. ## Keys and wallets - **Cold wallets** hold everything swept. Use a hardware wallet or a multisig (Safe on EVM, Squads on Solana). Coldtill never needs their keys; it only needs their addresses. - **Hot wallets** only pay gas and approved payouts. Keep them topped up with small amounts, never a balance you can't afford to lose. - **The hot-wallet passphrase** is stored in `deploy/secrets/hot_passphrase` by default so sweeps survive a restart. If you'd rather type it on every start, see [Running without a stored passphrase](https://coldtill.com/docs/install.md#running-without-a-stored-passphrase). - **Check the first deposit address** the dashboard derives against your own wallet before sharing any payment link. Do the same after every change to deposit wallets. ## Nodes Coldtill trusts RPC nodes to report deposits. Public endpoints are rate limited and fine for testing only. For mainnet, use your own nodes or paid providers, and add at least two per network so failover works. See [Nodes and networks](https://coldtill.com/docs/install.md#nodes-and-networks). ## Backups Run the nightly database dump from [Backups](https://coldtill.com/docs/install.md#backups), and copy the dumps off the server: a backup on the same disk doesn't survive losing the server. For example, with [restic](https://restic.net) to any S3-compatible bucket: ```bash sudo apt install restic export RESTIC_REPOSITORY=s3:https://s3.example.com/coldtill-backups RESTIC_PASSWORD_FILE=$HOME/.restic-pass restic init restic backup $HOME/backups restic forget --keep-daily 14 --keep-monthly 6 --prune ``` Add the `restic backup` and `restic forget` lines to the same cron job as the dump. Keep a copy of `deploy/.env` and `deploy/secrets/` in encrypted offline storage. Without `.env`, two-factor and webhook secrets in the database can't be read. Test a restore on a spare server at least once. ## Updates The dashboard checks for new releases once a day and shows a banner to admins. Security fixes are flagged and emailed to admins. See [Updates](https://coldtill.com/docs/updates.md). To upgrade, take a database dump, then follow [Upgrades](https://coldtill.com/docs/install.md#upgrades). ## Monitoring - Point an uptime monitor (UptimeRobot, Better Stack, Healthchecks.io) at `https://pay.example.com/healthz`. - Alert on `"level":"ERROR"` in the logs, and on the messages listed in [Monitoring](https://coldtill.com/docs/install.md#monitoring). - Watch hot wallet balances. When they run dry, sweeps stop. - Subscribe to webhooks for `payout.needs-review` in your own tooling. ## Go-live checklist Work through this before switching a shop to mainnet. - [ ] SSH keys only; password login and root password login disabled. - [ ] Provider firewall (or `DOCKER-USER` rules) allows only 22 and 80/443, and 80/443 only from Cloudflare if you use it. - [ ] Dashboard reachable only through Cloudflare Access, Tailscale or your IP allow-list; the public name returns 404 or 403 for `/login`. - [ ] Cloudflare SSL/TLS set to **Full (strict)**, with `COLDTILL_TRUSTED_PROXIES` and `COLDTILL_CLIENT_IP_HEADER` set. - [ ] Two-factor enabled on every account; no unused admin accounts. - [ ] Mainnet install (`--mode mainnet`) with SMTP configured; a test payout approval email arrived. - [ ] Cold wallets are hardware or multisig; the first derived deposit address matches your wallet. - [ ] At least two paid or self-hosted RPC nodes per enabled network. - [ ] Payout limits and auto-approve amounts set low enough that a stolen API key can't drain much. - [ ] Nightly dumps copied off-site; `.env` and `secrets/` stored offline; a restore has been tested. - [ ] Uptime monitor on `/healthz`; log alerts on errors. - [ ] A small real payment on each network went through checkout, sweep and webhook end to end. - [ ] You've read [Security](https://coldtill.com/docs/security.md) and the mainnet checklist in Audit preparation. --- Source: https://coldtill.com/docs/self-hosting/ · All Coldtill docs: https://coldtill.com/llms.txt # Guide: projects, domains and a private dashboard One Coldtill server can take payments for several shops or businesses. Each one is a **project** with its own wallets, API keys, webhooks, branding and checkout address. This guide covers where to point your DNS, why Cloudflare's proxy is worth using, how to keep the login page off the public internet with Tailscale and Cloudflare, and how to add projects and give each one its own domain. ## How the addresses fit together A Coldtill server answers on three kinds of address: | Address | Example | What it serves | Who should reach it | |---|---|---|---| | **Site URL** (`COLDTILL_SITE_URL`) | `pay.example.com` | Checkout, merchant API, and the login page and dashboard | Customers and shops; the dashboard paths blocked by Cloudflare | | **Checkout domain** (one per project, optional) | `pay.shop-two.com` | Checkout and merchant API only. The login page and dashboard answer 404 | Customers and that project's shop | | **Private entrance** | `coldtill.your-tailnet.ts.net` | The dashboard | Only your own devices, over Tailscale | The site URL always serves the dashboard; there's no setting to switch it off there. You keep it private with the private entrance plus a Cloudflare rule that blocks the dashboard paths on every public name. A checkout domain blocks them by itself. A typical setup for two businesses on one server: | Name | Points at | Used for | |---|---|---| | `pay.example.com` | The server, through Cloudflare | Site URL; checkout and API for the first project | | `pay.shop-two.com` | The server, through Cloudflare | Checkout domain of the second project | | `coldtill.your-tailnet.ts.net` | The server, over Tailscale | Signing in to the dashboard | ## Point the subdomain at the server Use a subdomain such as `pay.` rather than your shop's main domain. The shop and the payment server are different machines, and a subdomain keeps them separate. 1. **Find the server's public IP address.** It's in your provider's control panel, or run this on the server: ```bash curl -4 https://ifconfig.me; echo curl -6 https://ifconfig.me; echo ``` 2. **Create the DNS records** where your domain's DNS is hosted: | Type | Name | Value | |---|---|---| | `A` | `pay` | The server's IPv4 address | | `AAAA` | `pay` | The server's IPv6 address, if it has one | 3. **Check it** from your own machine. It can take a few minutes: ```bash dig +short pay.example.com ``` Without Cloudflare's proxy this prints your server's address. With it, it prints Cloudflare addresses (`104.x`, `172.6x` or `188.114.x`), which is what you want. ### Use Cloudflare's proxy (recommended) Cloudflare's free plan can sit in front of the server. Customers connect to Cloudflare, and Cloudflare connects to your server. That gives you: - **A hidden server address.** People see Cloudflare's addresses, not your server's, so they can't attack the server directly once the [firewall](https://coldtill.com/docs/self-hosting.md#firewall) only lets Cloudflare in. - **Firewall rules in front of Coldtill**, so the login page can be blocked before a request reaches the server. - **DDoS protection and rate limiting** for the checkout and API. To set it up: 1. Create a free account at [cloudflare.com](https://dash.cloudflare.com), choose **Add a domain**, and enter your domain (`example.com`). 2. Cloudflare gives you two nameservers. Set them at the registrar where you bought the domain, replacing the old ones. Cloudflare copies your existing records during sign-up; check they're all there before you switch. 3. In **DNS → Records**, add the `A` (and `AAAA`) record from the table above with **Proxy status: Proxied** (the orange cloud). 4. In **SSL/TLS → Overview**, set the mode to **Full (strict)**. Don't use **Flexible**: it sends traffic between Cloudflare and your server unencrypted, and causes redirect loops with Coldtill. 5. On the server, tell Caddy to trust Cloudflare's visitor-IP header, as described in [Behind Cloudflare](https://coldtill.com/docs/configuration.md#behind-cloudflare). Without it, rate limits treat all visitors as one. 6. Give Caddy a certificate Cloudflare accepts, as described in the next section. 7. Restrict ports 80 and 443 to Cloudflare's addresses at the [firewall](https://coldtill.com/docs/self-hosting.md#firewall), so nobody can bypass Cloudflare by connecting to the server's IP. If a domain isn't on Cloudflare, an ordinary `A` record at any DNS host works too. Caddy gets a Let's Encrypt certificate automatically, and you skip the Cloudflare steps. You lose the protections above, so take extra care with the [firewall](https://coldtill.com/docs/self-hosting.md#firewall) and the private dashboard. ### Certificates behind Cloudflare With **Full (strict)**, Cloudflare checks that your server's certificate is valid for the name it's connecting to. Caddy's automatic Let's Encrypt certificates usually work behind the proxy. But Cloudflare security features (bot challenges, "Under Attack" mode, some firewall rules) can block Let's Encrypt's checks. The certificate then fails to renew, and visitors see **error 525** or **526**. The reliable option is a free **Cloudflare Origin Certificate**. It's valid for 15 years, and only Cloudflare trusts it, which is all you need behind the proxy. 1. In Cloudflare, open the domain, go to **SSL/TLS → Origin Server → Create Certificate**. Keep the default key type, list the hostnames (`pay.example.com`, or `*.example.com` to cover every subdomain), and create it. 2. Save both parts on the server in `deploy/certs/` (`example.com.pem` for the certificate, `example.com.key` for the private key), then run `chmod 600 deploy/certs/*.key`. Cloudflare only shows the key once. 3. Repeat for each domain in a different Cloudflare zone, for example `shop-two.com`. Each zone needs its own certificate. 4. Copy `deploy/Caddyfile` to `deploy/Caddyfile.local` and give each name its own site block that uses its certificate. Keep the global options block at the top, and move the shared settings into a snippet: ```text (coldtill) { encode zstd gzip request_body { max_size 4MB } header { -Server Strict-Transport-Security "max-age=31536000; includeSubDomains" } reverse_proxy api:8080 { header_up X-Forwarded-For {client_ip} } } pay.example.com { tls /etc/caddy/certs/example.com.pem /etc/caddy/certs/example.com.key import coldtill } pay.shop-two.com { tls /etc/caddy/certs/shop-two.com.pem /etc/caddy/certs/shop-two.com.key import coldtill } ``` 5. Mount both in `deploy/docker-compose.override.yml`: ```yaml services: caddy: volumes: - ./Caddyfile.local:/etc/caddy/Caddyfile:ro - ./certs:/etc/caddy/certs:ro ``` 6. Run `docker compose up -d caddy`, then check each name: `curl -sI https://pay.shop-two.com/healthz` should return `HTTP/2 200`. With your own site blocks, `COLDTILL_CADDY_ADDRESS` is no longer used. When you add a domain later, add a site block for it instead. ## Keep the dashboard private The checkout and merchant API have to be public. The login page and dashboard don't, and they are how money gets moved. Coldtill asks for two-factor codes for sensitive actions, but there's no reason to let the whole internet try passwords. The strongest setup uses both of the tools below. Tailscale is how you get in, and Cloudflare blocks the dashboard on every public name. ### Tailscale: sign in over your private network [Tailscale](https://tailscale.com) is a free private network between your devices. The dashboard gets an address that only your laptop and phone can open, and that address never goes through the public internet or Cloudflare. 1. **Install Tailscale on the server** and sign in: ```bash curl -fsSL https://tailscale.com/install.sh | sh sudo tailscale up ``` Install it on your laptop and phone too, and sign in with the same account. In the [admin console](https://login.tailscale.com/admin/dns), turn on **MagicDNS** and **HTTPS certificates**. 2. **Add a private entrance to Caddy.** Append this block to `deploy/Caddyfile.local` (copy `deploy/Caddyfile` to it first if it doesn't exist). Replace `pay.example.com` with your site URL's hostname, and `coldtill.your-tailnet.ts.net` with the server's Tailscale name (`tailscale status` shows it): ```text http://:8081 { encode zstd gzip request_body { max_size 4MB } reverse_proxy api:8080 { header_up Host pay.example.com header_up Origin ^https://coldtill\.your-tailnet\.ts\.net$ https://pay.example.com header_up X-Forwarded-Proto https header_up X-Forwarded-For {client_ip} } } ``` Coldtill only accepts dashboard requests that come from its own site URL. That protects against cross-site attacks, so the entrance presents itself as the site URL. The `Origin` line only rewrites requests that really come from your Tailscale address. Don't replace it with a fixed value, which would switch the cross-site check off for this entrance. 3. **Publish the port on localhost only**, in `deploy/docker-compose.override.yml`: ```yaml services: caddy: ports: - "127.0.0.1:8081:8081" volumes: - ./Caddyfile.local:/etc/caddy/Caddyfile:ro ``` Keep any volumes you already have there, such as `./certs`. 4. **Start it**, then hand the port to Tailscale: ```bash cd deploy && docker compose up -d caddy sudo tailscale serve --bg http://127.0.0.1:8081 ``` The dashboard is now at `https://coldtill.your-tailnet.ts.net`, with a certificate Tailscale manages. Open it from a device signed in to Tailscale. If Caddy and `tailscale serve` both want port 443 after a restart, start Caddy first. Run `sudo tailscale serve reset`, then `docker compose up -d --force-recreate caddy`, then the `tailscale serve` command again. If the `.ts.net` name doesn't resolve on your laptop ("DNS address could not be found"), Tailscale's DNS is off on that device. Run `sudo tailscale set --accept-dns=true`. ### Cloudflare: block the login page on every public name Once you sign in over Tailscale, nobody needs the dashboard on the public names. A Cloudflare firewall rule blocks it before requests reach the server, and leaves the checkout and API open. In Cloudflare, open the domain, go to **Security → WAF → Custom rules → Create rule**, choose **Edit expression**, and paste: ```text (http.host in {"pay.example.com"} and not starts_with(http.request.uri.path, "/pay/") and not starts_with(http.request.uri.path, "/api/checkout/") and not starts_with(http.request.uri.path, "/api/v1/") and not starts_with(http.request.uri.path, "/assets/") and not starts_with(http.request.uri.path, "/branding/") and http.request.uri.path ne "/healthz" and http.request.uri.path ne "/favicon.svg") ``` Set **Action** to **Block** and deploy. Those paths are exactly what a checkout domain serves, so customers and your shop are unaffected. Then check from a browser that isn't using Tailscale: - `https://pay.example.com/` and `https://pay.example.com/login` show Cloudflare's block page. - `https://pay.example.com/healthz` returns `ok`. - A payment link from your shop still opens the checkout. **Block it on the server too.** A Cloudflare rule only helps for traffic that goes through Cloudflare. To make the server itself refuse the dashboard on public names, add these two lines to each public site block in `Caddyfile.local` (or to the `(coldtill)` snippet), before `reverse_proxy`: ```text @dashboard not path /pay/* /api/checkout/* /api/v1/* /assets/* /branding/* /healthz /favicon.svg respond @dashboard 404 ``` Leave them out of the `:8081` private entrance, or you'll lock yourself out of the dashboard. Notes: - **Every public name needs covering.** Add each hostname to the `{…}` list, separated by spaces. A name in a different Cloudflare zone (`pay.shop-two.com`) needs the same rule in that zone. Checkout domains already return 404 for the dashboard, but adding them to the rule costs nothing. - **No Tailscale?** Add `and not ip.src in {203.0.113.10}` before the closing bracket, with your own IP addresses, to keep the dashboard reachable from your office only. Or use **Cloudflare Access** (Zero Trust, free for up to 50 users): create a self-hosted application for the site URL's hostname that only lets your team's email addresses in. Exclude the checkout paths listed above from it. - **Bot protection and your shop.** Cloudflare's **Bot Fight Mode** and high **Security Level** settings can challenge requests that don't come from a browser. If your shop's server gets `403` responses from `/api/v1/`, or webhooks to your shop fail, add a WAF rule with action **Skip** for paths starting with `/api/v1/`, or turn Bot Fight Mode off for the zone. Your own laptop can be challenged too if it connects through a VPN or a Tailscale exit node. Sign in over the private entrance instead. ## Add a project You need a root or admin account. 1. In the dashboard, open the project menu at the top of the sidebar and choose **+ New project…**. 2. Enter a name, for example the business or shop name, and click **Create**. The dashboard switches to the new project and opens its settings. 3. Set it up the same way as your first project. Every setting below belongs to the selected project: - **Project settings:** store name, logo, colours, support links and the **Return URL**, where customers go after paying. - **Deposit wallets:** where this project's payments go. You can reuse cold wallet addresses from another project, or give each business its own. - **API keys:** create a key for this project's shop. A key only works for its own project. - **Webhooks:** the URL on this project's shop that receives payment events, and its signing secret. - **Team:** who can see or manage this project. Some things are shared by the whole server and set up once by an admin: user accounts, **Hot wallets** (gas and payouts), and **Nodes and networks**. ## Give a project its own domain Without a checkout domain, a project's payment links open on the site URL. To use the business's own name instead, for example `pay.shop-two.com`: 1. **Point the name at the server.** Add an `A` record (and `AAAA` if you use IPv6) for `pay` in that domain's DNS, proxied through Cloudflare as described in [Point the subdomain at the server](#point-the-subdomain-at-the-server). Set SSL/TLS to **Full (strict)** in that zone too. 2. **Let Caddy answer for it.** Either add the name to `COLDTILL_CADDY_ADDRESS` in `deploy/.env`, comma separated (`COLDTILL_CADDY_ADDRESS=pay.example.com, pay.shop-two.com`), or add a site block with its Origin certificate if you use your own `Caddyfile.local` (see [Certificates behind Cloudflare](#certificates-behind-cloudflare)). Then run `cd deploy && docker compose up -d caddy`. 3. **Check the server answers:** `curl -s https://pay.shop-two.com/healthz` should print `ok`. 4. **Set it on the project.** With the project selected, open **Project settings → Checkout domain**, enter `pay.shop-two.com` without `https://`, and save with a two-factor code. 5. **Check the result:** - `https://pay.shop-two.com/login` returns 404. Login is never served on a checkout domain. - A new payment link for this project starts with `https://pay.shop-two.com/pay/`. Links sent earlier keep working on the old address. 6. **Update the shop.** Point the shop's API base URL at `https://pay.shop-two.com` (the merchant API works on the checkout domain too), and add the name to your Cloudflare block rule if you like. Each domain can belong to one project only, and the site URL can't be used as a checkout domain. ## Checklist - [ ] Every public name has proxied DNS records and SSL/TLS set to **Full (strict)**. - [ ] Caddy has a certificate Cloudflare accepts for every name. `curl -sI https:///healthz` returns 200. - [ ] `COLDTILL_TRUSTED_PROXIES` and `COLDTILL_CLIENT_IP_HEADER` are set for Cloudflare. - [ ] Ports 80 and 443 only accept Cloudflare's addresses (provider firewall or `DOCKER-USER`). - [ ] The dashboard opens on the Tailscale address. - [ ] `/login` is blocked or returns 404 on every public name. - [ ] Each project has its own API key, webhook, return URL and deposit wallets. - [ ] A test payment on each checkout domain went through checkout and the webhook reached the shop. --- Source: https://coldtill.com/docs/guides/domains-and-dashboard/ · All Coldtill docs: https://coldtill.com/llms.txt # Merchant API The merchant API is what your shop or backend calls: create a payment, send the customer to the hosted checkout, and get a signed webhook when it's paid. Payouts from the hot wallet use the same key. The machine-readable reference is [`openapi.yaml`](https://coldtill.com/openapi.yaml) (OpenAPI 3.1), rendered at [coldtill.com/docs/api](https://coldtill.com/docs/api). Import it into Postman, Insomnia or a client generator. New here? Start with the guide [Accept a payment from your shop](https://coldtill.com/docs/guides/accept-payments.md). - **Base URL:** your site URL, for example `https://pay.example.com`. - **Authentication:** an `API-Key` header with a project API key. Create keys under **API keys** (project admin, with two-factor). Keys are shown once and stored hashed. - **Format:** JSON request and response bodies. Amounts are decimal strings. - **Errors:** a non-2xx status with `{"error": "message"}` and sometimes a `code`. - **Rate limit:** 600 requests per minute per client IP. Exceeding it returns `429`. An API key belongs to one project. It can create payments and payouts, but payouts can only go to that project's address book, and large ones wait for an admin. See [Payouts](https://coldtill.com/docs/payouts.md). ## Payments ### Create a payment `POST /api/v1/payment` ```bash curl -X POST https://pay.example.com/api/v1/payment \ -H "API-Key: $COLDTILL_API_KEY" -H 'Content-Type: application/json' \ -d '{"customerID":"42","customerEmail":"jane@example.com","amountInUSD":25.00,"invoiceID":"INV-1001"}' ``` | Field | Required | Description | |---|---|---| | `customerID` | yes | Your ID for the customer. The same ID always gets the same deposit addresses | | `amountInUSD` | yes | Amount owed, greater than 0 and at most 10,000,000 | | `customerEmail` | no | Stored with the customer | | `invoiceID` | no | Your order reference, echoed in webhooks | | `network` | no | Restrict the checkout to one network code (see [Network codes](#network-codes)) | | `currency` | no | With `network`, restrict to one currency, for example `USDC` | | `expire` | no | RFC 3339 expiry time, at least a minute ahead. Defaults to the project's payment expiry (24 hours) | Response: ```json { "host": "https://pay.example.com", "reference_id": "fd09c7e5-d661-4355-9b04-7b3749b41e2e", "url": "https://pay.example.com/pay/fd09c7e5-d661-4355-9b04-7b3749b41e2e" } ``` Send the customer to `url`. A customer has at most one open payment: creating a new one replaces the previous one (`REPLACED`). If the previous payment has a deposit that is still confirming, the request fails with `409` so that deposit isn't stranded; retry after it confirms. ### Get a payment `GET /api/v1/payment/reference/{reference_id}` ```json { "invoiceID": "INV-1001", "customerID": "42", "amountInUSD": "25", "filledAmountInUSD": "25.00000000", "pendingAmountInUSD": "0", "paymentState": "FILLED", "referenceID": "fd09c7e5-…", "url": "https://pay.example.com/pay/fd09c7e5-…", "createdAt": "2026-10-10T15:58:23Z", "expiresAt": "2026-10-11T15:58:23Z", "deposits": [ { "network": "BASE", "currency": "USDC", "amount": "25", "amountInUSD": "25", "transactionHash": "0x…", "status": "confirmed", "confirmations": 12, "requiredConfirmations": 10 } ] } ``` Payment states: | State | Meaning | Final | |---|---|---| | `OPEN` | Nothing confirmed yet | no | | `PARTIALLY_FILLED` | Confirmed amount is below 99% of the amount owed | no | | `FILLED` | Confirmed amount is within 1% of the amount owed | yes | | `OVER_FILLED` | Confirmed amount is more than 1% above the amount owed | yes | | `CANCELLED` | Expired before being filled | yes | | `REPLACED` | Superseded by a newer payment for the same customer | yes | Ship the order on `FILLED` or `OVER_FILLED`. Prefer webhooks to polling. ### Payable currencies `GET /api/v1/blockchain-currency/reference/{reference_id}` lists what the customer can pay with for this payment, with the price used for each. ### Deposit address `POST /api/v1/deposit-address/reference/{reference_id}` with `{"blockchain_code": "BASE"}` returns the customer's permanent address for that network's family. Use it to build your own checkout instead of the hosted one. ### Ticker `GET /api/v1/ticker` (no API key) lists enabled networks and tokens with USD prices: `blockchainCode`, `currencyCode`, `tokenAddress`, `standard`, `walletPrecision`, `family`, `price`. ## Payouts ### Create a payout `POST /api/v1/withdrawal/merchant` with an `Idempotency-Key` header: ```bash curl -X POST https://pay.example.com/api/v1/withdrawal/merchant \ -H "API-Key: $COLDTILL_API_KEY" -H 'Content-Type: application/json' \ -H 'Idempotency-Key: refund-INV-1001' \ -d '{"blockchainCode":"BASE","currencyCode":"USDC","amount":"12.50","toAddress":"0xAbC…"}' ``` | Field | Description | |---|---| | `blockchainCode` | Network code: an EVM network or `SOL` | | `currencyCode` | `ETH`, `USDC`, `USDT`, `SOL`… as enabled on that network | | `amount` | Decimal amount in the currency, at most the token's decimals | | `toAddress` or `addressBookId` | Destination. Through the API it must be in the project's address book | Retrying with the same `Idempotency-Key` and the same parameters returns the original payout. Reusing a key with different parameters returns `409`. The response is the payout (see [Payout object](#payout-object)) with status `pending` or `pending-approval`. ### List and get payouts - `GET /api/v1/withdrawal/merchant?limit=50&offset=0` lists payouts created through the API. - `GET /api/v1/withdrawal/{id}/merchant` returns one payout. ### Payout object ```json { "id": "5d1c…", "status": "sent", "network": "BASE", "currency": "USDC", "tokenContract": "0x8335…", "amount": "12.5", "amountInUSD": "12.50", "toAddress": "0xAbC…", "transactionHash": "0x…", "blockNumber": 21234567, "idempotencyKey": "refund-INV-1001", "createdBy": "system", "createdAt": "…", "updatedAt": "…" } ``` `approvalReason` explains why a payout is held; `failureReason` explains a failure. Statuses are listed in [Payouts](https://coldtill.com/docs/payouts.md#statuses). ## Network codes | Code | Mainnet | Testnet mode | |---|---|---| | `ETH` | Ethereum | Sepolia | | `BASE` | Base | Base Sepolia | | `POLYGON` | Polygon | Amoy | | `BSC` | BNB Smart Chain | BSC testnet | | `TRON` | Tron | Nile | | `SOL` | Solana | Devnet | | `BTC` | Bitcoin | Signet | ## SDK `sdk/typescript` wraps these endpoints and verifies webhooks, with no dependencies: ```ts import { Coldtill, verifyWebhookRequest, isPaid } from '@coldtill/sdk' const kh = new Coldtill({ baseUrl: 'https://pay.example.com', apiKey: process.env.COLDTILL_API_KEY! }) const { url } = await kh.createPayment({ customerID: '42', amountInUSD: 25, invoiceID: 'INV-1001' }) ``` The WooCommerce plugin adds Coldtill as a payment method for USD stores. --- Source: https://coldtill.com/docs/api/ · All Coldtill docs: https://coldtill.com/llms.txt # Webhooks Coldtill sends an HTTP `POST` to your endpoints whenever a payment or payout changes. Add endpoints under **Webhooks** in the dashboard; each one gets its own signing secret, shown once. ## Delivery - **Method and body:** `POST` with a JSON body. - **Success:** any `2xx` response within 60 seconds. Redirects are not followed. - **Destination:** on mainnet, endpoints must be public `https` URLs. Private, loopback, link-local, carrier-grade NAT and other reserved addresses are refused when connecting, so DNS tricks don't help. - **Ordering:** not guaranteed. Use the payload's `status` and `timestamp`, and treat events as idempotent. Headers: | Header | Value | |---|---| | `X-Coldtill-Event` | Event name, for example `payment.filled` | | `X-Coldtill-Delivery` | Unique delivery ID. Retries of the same delivery reuse it | | `X-Coldtill-Timestamp` | Unix time in seconds when this attempt was signed | | `X-Coldtill-Signature-V2` | `sha256=` + hex HMAC-SHA256 of `.` with the endpoint secret | | `X-Coldtill-Signature` | `sha256=` + hex HMAC-SHA256 of the raw body (legacy, no replay protection) | | `User-Agent` | `Coldtill-Webhook/1` | Coldtill was called Keyhold before. Every header above is also sent under its old `X-Keyhold-` name with the same value, so receivers written before the rename keep working. New code should read `X-Coldtill-`. ## Retries | Kind | Schedule after the first attempt | |---|---| | Progress events | 2 s, 4 s. A later change sends a fresh event anyway | | Final events | 2 s, 4 s, 30 min, 1 h, 2 h, 4 h, 8 h, 24 h, 48 h, then marked failed | You can retry any delivery by hand under **Webhooks → Deliveries**. ## Verifying requests Always verify before trusting the body: 1. Read the **raw** body bytes, before any JSON parsing. 2. Check that `X-Coldtill-Timestamp` is within five minutes of your clock. 3. Compute `HMAC-SHA256(secret, timestamp + "." + rawBody)`, hex-encode it, prefix `sha256=`. 4. Compare with `X-Coldtill-Signature-V2` in constant time. 5. Optionally, store `X-Coldtill-Delivery` IDs you've processed and ignore repeats. `X-Coldtill-Event` and `X-Coldtill-Delivery` are not signed. Decide what to do from the signed body (its `status`, and `reference_id` for payments), not from the event header. The SDK derives the event from the body and rejects a request whose header disagrees. ### Node.js (SDK) ```ts import express from 'express' import { verifyWebhookRequest, isPaid } from '@coldtill/sdk' const app = express() app.post('/coldtill/webhook', express.raw({ type: 'application/json' }), async (req, res) => { try { const ev = await verifyWebhookRequest(req.body, req.headers, process.env.COLDTILL_WEBHOOK_SECRET!) if (ev.event.startsWith('payment.') && isPaid(ev.data.status)) { await markOrderPaid(ev.data.invoice_id, ev.data.reference_id) } res.sendStatus(200) } catch { res.sendStatus(401) } }) ``` ### PHP ```php $raw = file_get_contents('php://input'); $ts = $_SERVER['HTTP_X_COLDTILL_TIMESTAMP'] ?? ''; $sig = $_SERVER['HTTP_X_COLDTILL_SIGNATURE_V2'] ?? ''; $ok = ctype_digit($ts) && abs(time() - (int) $ts) <= 300 && hash_equals('sha256=' . hash_hmac('sha256', $ts . '.' . $raw, $secret), strtolower($sig)); if (!$ok) { http_response_code(401); exit; } $event = json_decode($raw, true); ``` ### Go ```go func verify(r *http.Request, body, secret []byte) bool { ts := r.Header.Get("X-Coldtill-Timestamp") n, err := strconv.ParseInt(ts, 10, 64) if err != nil || math.Abs(float64(time.Now().Unix()-n)) > 300 { return false } m := hmac.New(sha256.New, secret) m.Write([]byte(ts + ".")) m.Write(body) want := "sha256=" + hex.EncodeToString(m.Sum(nil)) return hmac.Equal([]byte(want), []byte(strings.ToLower(r.Header.Get("X-Coldtill-Signature-V2")))) } ``` ### Python ```python import hashlib, hmac, time def verify(headers, raw: bytes, secret: bytes) -> bool: ts = headers.get("X-Coldtill-Timestamp", "") if not ts.isdigit() or abs(time.time() - int(ts)) > 300: return False want = "sha256=" + hmac.new(secret, ts.encode() + b"." + raw, hashlib.sha256).hexdigest() return hmac.compare_digest(want, headers.get("X-Coldtill-Signature-V2", "").lower()) ``` ## Payment events Event names are `payment.` plus the lower-cased state: `payment.open`, `payment.partially_filled`, `payment.filled`, `payment.over_filled`, `payment.cancelled`, `payment.replaced`. ```json { "customer_id": "42", "invoice_id": "INV-1001", "reference_id": "fd09c7e5-d661-4355-9b04-7b3749b41e2e", "status": "FILLED", "amount": "25", "currency": "USD", "filled_amount": "25.00000000", "filled_amount_in_usd": "25.00000000", "timestamp": 1791647937, "payment_info": [ { "source_address": "0x…", "transaction_hash": "0x…", "destination_address": "0x…", "block_number": 21234567, "network": "BASE", "currency": "USDC", "amount": "25" } ], "confirmation_current": 0, "confirmation_required": 0 } ``` - **Progress events** (a deposit seen or gaining confirmations) include `confirmation_current` and `confirmation_required` for the deposit that triggered them. - **The final event** (`filled`, `over_filled`, `cancelled`, `replaced`) is sent once, when the payment closes. A late deposit on a closed payment sends a progress event with the new `filled_amount`. - **Auto-convert:** payments paid through a swap include a `conversion` array: ```json "conversion": [{ "provider": "near-intents", "from_currency": "BTC", "from_network": "BTC", "from_amount": "0.000253", "from_amount_in_usd": "15.18", "network": "BASE", "currency": "USDC", "amount": "15", "transaction_hashes": ["0x…"] }] ``` ## Payout events Event names are `payout.` plus the status: `payout.pending-approval`, `payout.pending`, `payout.initiated`, `payout.sent`, `payout.failed`, `payout.rejected`, `payout.cancelled`, `payout.needs-review`. The body is the [payout object](https://coldtill.com/docs/api.md#payout-object). `payout.needs-review` means Coldtill could not tell whether the transaction landed. Don't retry the payout until an admin has checked the hot wallet's history and resolved it. See [Payouts](https://coldtill.com/docs/payouts.md#needs-review). ## Testing **Webhooks → Send test** posts a signed test event to an endpoint. On testnet installs you can point endpoints at `http://localhost` receivers; mainnet requires public `https` URLs. --- Source: https://coldtill.com/docs/webhooks/ · All Coldtill docs: https://coldtill.com/llms.txt # Payouts Payouts send money from a project's hot wallet: refunds, supplier payments, withdrawals to an exchange. They work on the EVM networks and Solana. Bitcoin has no hot wallet in Coldtill, so Bitcoin payouts aren't supported. Tron payouts aren't supported yet; the Tron hot wallet only pays for sweeps. ## Setup 1. **Hot wallet** (global admin): under **Hot wallets**, generate or import an EVM key and a Solana key, then assign them to the project. Assigning needs a two-factor code. 2. **Fund it:** send the hot wallet enough native coin for gas, plus the tokens you plan to pay out. Keep the balance small; it is the only money a server compromise can reach. 3. **Limits** under **Project settings → Payout limits** (project admin, with two-factor): | Setting | Effect | |---|---| | Minimum payout (USD) | Smaller payouts are refused | | Auto-approve up to (USD) | Larger payouts wait for an admin. `0` holds every payout | | Hourly limit, daily limit (USD) | Payouts that would take the project's total over the limit wait for an admin. `0` means no limit | Limits are counted per project, across every user and API key, including payouts still in flight. 4. **Address book:** add destinations under **Payouts → Address book** (project admin, with two-factor). API payouts can only go to address-book entries, and entries can only be added in the dashboard. ## Creating a payout - **Dashboard:** **Payouts → New payout** (project admin, with two-factor). Any valid address is allowed, but limits still apply. - **API:** `POST /api/v1/withdrawal/merchant` with an `Idempotency-Key`. See [Merchant API](https://coldtill.com/docs/api.md#payouts). Coldtill validates the destination for the chain. EVM addresses with a wrong mixed-case checksum are refused. On Solana, sending to a token account (rather than a wallet) is refused, as are SOL transfers that would leave a new account below rent exemption. ## Approval A held payout shows the reason (for example "above the auto-approve amount"). To approve: 1. A project admin clicks **Approve** and enters a two-factor code. 2. Coldtill emails a six-digit code to that admin. It is valid for 10 minutes and allows five attempts. 3. The same admin enters the emailed code. Only the admin who started the approval can finish it. Either step can be abandoned. **Reject** ends the payout. A payout that hasn't been signed yet can be cancelled. ## Statuses | Status | Meaning | |---|---| | `pending-approval` | Held by a limit or the auto-approve amount | | `pending-otp-verification` | Approval started; waiting for the emailed code | | `pending` | Approved, queued for signing | | `signing` | Signed and stored; being broadcast | | `initiated` | Broadcast; waiting for confirmations | | `sent` | Confirmed on-chain | | `failed` | Definitely did not happen (reverted, or refused and unknown to every node) | | `rejected` | Rejected by an admin | | `cancelled` | Cancelled before signing | | `needs-review` | Coldtill can't tell whether it landed. See below | ## How sending works 1. The `payouts` process claims a pending payout and reserves a nonce (EVM) or a recent blockhash (Solana). 2. It signs the transaction and **stores it before broadcasting**, so a crash can never lead to a second, different transaction for the same payout. 3. It broadcasts and tracks the transaction: - **EVM:** a stuck transaction is re-signed with the same nonce, recipient and amount and doubled fees. Only one version can ever be mined. - **Solana:** if the blockhash expires before the transaction lands, Coldtill asks **every** node, with full history search, whether it ever landed. It re-signs only if none has seen it. 4. After the network's confirmations it becomes `sent` and a `payout.sent` webhook goes out. If a node refuses a broadcast outright (for example "insufficient funds"), the payout isn't failed straight away: with failover, an earlier node may have accepted it. Coldtill waits two minutes and fails it only if the transaction is still unknown. ## Needs review `needs-review` means the hot wallet's nonce was used, but Coldtill can't match the transaction that used it to this payout, even after 30 minutes. That can happen if someone used the hot wallet outside Coldtill, or if your nodes are badly out of sync. Coldtill won't guess, because a payout wrongly marked `failed` invites a retry that pays twice. To resolve one: 1. Open the hot wallet's address in a block explorer and look for a transfer of that amount to that address. 2. In **Payouts**, click **Resolve**, choose **It was sent** or **It was not sent**, add a note (for example the explorer link) and confirm with two-factor. The decision is recorded in the audit log and sends a final `payout.sent` or `payout.failed` webhook. ## Good practice - Keep the hot float to about a week of gas plus expected payouts. Top it up from your cold wallet. - Use a low auto-approve amount and tight daily limits; raise them only as volume requires. - Give `project_admin` only to people who should approve payouts. Admins can approve payouts they created themselves, which single-admin installs need. If you have several admins, agree that someone else approves. - Don't use the hot wallet from other software. Coldtill expects to be the only thing spending from it. --- Source: https://coldtill.com/docs/payouts/ · All Coldtill docs: https://coldtill.com/llms.txt # Auto-convert Auto-convert lets customers pay with coins you don't want to hold (BTC, ETH, SOL and others) while you receive a stablecoin (USDC or USDT) in your cold wallet. A third-party swap provider does the conversion; Coldtill never holds the funds and only credits a payment once its own nodes see the stablecoin arrive. It is off by default, and only available on mainnet installs. ## Turning it on Under **Project settings → Auto-convert to stablecoins** (project admin, with two-factor): 1. **Settle in:** pick USDC or USDT on Ethereum, Base, Polygon, BNB Chain or Solana. The project needs a deposit wallet on that network, because its cold wallet receives the stablecoin. 2. **Maximum slippage:** between 10 and 300 basis points (0.1% to 3%). 3. **THORChain fallback:** optional, off by default. See [Providers](#providers). ## What the customer sees 1. At checkout, every coin the project already takes into its own deposit wallets stays direct (for example USDT on Ethereum). NEAR Intents is only offered for coins that have no deposit wallet route. 2. The customer picks a coin and enters a **refund address** on that coin's chain. It is validated per chain. 3. Coldtill asks the provider for a quote that delivers exactly the amount still owed to your cold wallet. The quote is locked for 30 minutes (an hour for Bitcoin-like coins). 4. The checkout shows the provider's deposit address, the exact amount, the rate and the swap fee. 5. The customer pays; the checkout follows the swap through `awaiting_deposit`, `processing`, `delivering` and `completed`. If the swap fails or the customer sends too little, the provider refunds the refund address. Up to three active conversions are allowed per payment. ## How Coldtill checks a conversion Swap providers report progress, but Coldtill treats their reports as hints only. Before a payment is credited: - **The quote is checked:** the recipient must be your cold wallet, the destination asset must be your settlement asset, the guaranteed output must cover the amount owed and (for 1Click) no memo may be required. For THORChain, the memo's asset and recipient are parsed and checked too. - **The delivery is verified on-chain** by your own nodes. Coldtill looks up each delivery transaction the provider reports and sums transfers of the settlement token to your cold wallet that: - come from successful transactions in blocks still on the canonical chain; - were mined after the conversion was created (allowing five minutes of clock skew); - are not from your own deposit addresses or factories (a sweep is not a delivery). - **The transaction must be new:** a delivery transaction that is one of Coldtill's own sweeps or payouts, or that already credited another conversion, is refused. - **Confirmations:** the delivery needs the network's required confirmations (Solana: `finalized`). Anything that fails a check becomes `mismatch`, is logged as a warning (`conversion delivery mismatch`), and shows the reason under **Conversions**. The payment is not credited. Investigate with the transaction hashes shown; the provider's own explorer usually explains what happened. ## Providers - **NEAR Intents 1Click** (default): every token 1Click lists, which in October 2026 was over 200 tokens on more than 35 chains (BTC, ZEC, XRP, TON, SUI, ADA, ETH, SOL, memecoins and others). The list comes from 1Click live. Known stables stay pinned to their canonical contracts so a look-alike token is never offered. Deposits need no memo, so customers can pay from an exchange. Without `COLDTILL_ONECLICK_API_KEY`, 1Click adds a 0.2% fee. - **THORChain** (optional fallback): BTC, LTC, DOGE and BCH. Deposits need a memo, so the customer must pay from their own wallet, not an exchange. Refunds go to the sending address. Provider endpoints are configured with `COLDTILL_ONECLICK_URL`, `COLDTILL_THORNODE_URL` and `COLDTILL_MIDGARD_URL`. On mainnet the client never follows redirects and refuses private addresses. ## Risks While a swap is in progress, the provider holds the customer's coins. A provider that fails to deliver or refund costs the customer money, not you: Coldtill never credits a payment without an on-chain delivery. See R2 in the [threat model](https://coldtill.com/docs/threat-model.md#residual-risks). ## Testing In testnet mode auto-convert is hidden, because providers only run on mainnet. To test the flow end to end, point `COLDTILL_ONECLICK_URL` at a stub that implements the 1Click quote and status endpoints, and make real stablecoin transfers on a local chain (see Development). --- Source: https://coldtill.com/docs/auto-convert/ · All Coldtill docs: https://coldtill.com/llms.txt # Branding and project settings Each project has its own branding, shown on the hosted checkout and payment links. Edit it under **Project settings → Branding** (project admin). The card shows a live preview of the checkout as you type. ## Options | Setting | Notes | |---|---| | Store name | Up to 80 characters. Shown at the top of the checkout and in the page title | | Logo | Upload a PNG, JPEG or WebP image up to 2 MB, or give an `https://` image URL. See below | | Accent colour | A hex colour (`#22c55e`) or one of the swatches. Used for highlights and progress. Green and red always mean paid and failed | | Checkout theme | `dark` or `light` | | Message on checkout | Up to 280 characters of plain text, for example delivery times or a thank-you | | Support email, support URL | Shown as links at the foot of the checkout | | Return URL | Shown as a "Return to {store}" button once the payment is complete | | Payment link expiry | Hours before an unpaid payment is cancelled (default 24) | The return and support URLs must be `https://` (on testnet, `http://localhost` is also allowed for testing). ## Logos Uploading is the recommended option. The server: 1. accepts only PNG, JPEG and WebP. SVG is refused, because it can carry scripts; 2. checks the image's pixel dimensions before decoding it, so oversized images can't exhaust memory; 3. scales it to fit within 512 × 512 pixels and re-encodes it as PNG, which strips metadata such as GPS location from photos; 4. stores it in the database and serves it from your own domain at `/branding/{project}/logo-{hash}.png`. It is cached for a year, with the content hash changing on every new upload, and served with a restrictive content security policy. A logo URL works too, but the customer's browser loads it from that host, which then learns who visits your checkout. Uploading keeps that private. To remove a logo, click **Remove** under the logo, then save. ## Other project settings - **Sweeps:** - activation minimum: a deposit contract is only deployed once an address holds this much; - amount, address-count and time triggers. See [Architecture](https://coldtill.com/docs/architecture.md#sweeps). - **Payout limits:** minimum, auto-approve amount, hourly and daily limits. See [Payouts](https://coldtill.com/docs/payouts.md). - **Auto-convert:** settlement asset, slippage and the THORChain fallback. See [Auto-convert](https://coldtill.com/docs/auto-convert.md). - **Members:** add users to the project with a role. See [Users and roles](https://coldtill.com/docs/install.md#users-and-roles). ## Checkout behaviour The hosted checkout at `/pay/{reference_id}`: - lets the customer pick a network and currency, unless the payment was created for a specific one; - shows the address as text and as a QR code, the exact amount, and a countdown to expiry; - updates live as deposits arrive and gain confirmations; - offers auto-convert options when enabled; - once paid, shows a confirmation and the return button if you set a return URL. It sets a strict content security policy and can't be embedded in frames. Link to it rather than iframing it. --- Source: https://coldtill.com/docs/branding/ · All Coldtill docs: https://coldtill.com/llms.txt # Install and operations This guide covers installing Coldtill on a Linux server, the first-run setup, and day-to-day operations: backups, upgrades, monitoring and key rotation. ## Requirements - A Linux server with 2 GB of RAM or more. Any distribution with Docker works; these commands are for Ubuntu, Debian and Linux Mint. - A DNS name pointing at the server (for example `pay.example.com`), with ports 80 and 443 open. Caddy obtains the TLS certificate automatically. - For mainnet: an SMTP account for payout approval emails, and RPC endpoints you trust (see [Nodes](#nodes-and-networks)). Install Docker and the Compose plugin: ```bash sudo apt update sudo apt install docker.io docker-compose-v2 git sudo usermod -aG docker $USER # log out and back in afterwards ``` ## Install ```bash git clone git@github.com:rollounden/coldtill.git cd coldtill/deploy ./install.sh --mode testnet --site-url https://pay.example.com ``` `install.sh` accepts `--mode testnet|mainnet`, `--site-url URL` and `--no-build` (use a prebuilt image set in `COLDTILL_IMAGE`). It: 1. builds the `coldtill:local` image; 2. runs `coldtill init`, which asks for a hot-wallet passphrase (at least 12 characters) and writes: - `deploy/.env`: mode, site URL, the server secret, the database password and the age recipient; - `deploy/secrets/coldtill-identity.age`: the hot-wallet identity, encrypted under your passphrase; - `deploy/secrets/hot_passphrase`: the passphrase, so the sweeper and payouts can restart unattended; 3. starts PostgreSQL, the four Coldtill processes and Caddy with Docker Compose. Database migrations run automatically when the processes start. For a local trial without DNS, use `--site-url http://localhost:8080`. ### Running without a stored passphrase If you'd rather not keep the passphrase on disk, initialise with `coldtill init --store-passphrase=false` (or delete `deploy/secrets/hot_passphrase`) and supply it each time you start the stack: ```bash cd deploy read -rs COLDTILL_HOT_PASSPHRASE && export COLDTILL_HOT_PASSPHRASE docker compose up -d unset COLDTILL_HOT_PASSPHRASE ``` Only the `sweeper` and `payouts` containers receive it. If either container restarts, it can't unlock the hot wallets until you run this again. ## First-run setup Open the site URL and create the **root** account (the first account becomes root). Then, in order: 1. **Account:** enable two-factor authentication. Anything that moves or redirects money needs a fresh two-factor code, so most of the steps below require it. 2. **Nodes & networks:** enable the networks you want, set confirmations, and add RPC nodes. Public defaults are rate limited and fine only for testing. 3. **Projects:** rename the default project or add more. Each project is a shop with its own settings. 4. **Deposit wallets** (per project): - **Bitcoin:** paste a BIP84 account xpub (`zpub` on mainnet, `vpub` on signet). The browser derives the first address; confirm it matches your wallet's first receive address. - **EVM:** connect a browser wallet (your "master" account) and enter your cold wallet. Deploy the deposit factory on each EVM network from the same master account; the factory address is the same on every chain. - **Solana:** connect a wallet, enter your treasury (a hardware wallet or a Squads vault address), save, then create the config by signing in your wallet. The browser checks the transaction before signing. 5. **Hot wallets** (global admin): generate or import one EVM and one Solana key. Fund each with a little gas, then assign them to the project. 6. **Project settings:** branding, sweep thresholds, payout limits and (optional) auto-convert. See [Branding](https://coldtill.com/docs/branding.md), [Payouts](https://coldtill.com/docs/payouts.md) and [Auto-convert](https://coldtill.com/docs/auto-convert.md). 7. **API keys and webhooks:** create an API key (project admin, with two-factor) and a webhook endpoint for your shop. See [Merchant API](https://coldtill.com/docs/api.md) and [Webhooks](https://coldtill.com/docs/webhooks.md). ## Users and roles - **Root:** the first account. It can do everything, and is the only account that can disable admins. - **Admin:** global administrator. Manages nodes, networks, hot wallets and users. - **Member:** access only to the projects they are added to, with a project role: - `project_admin`: settings, deposit wallets, creating API keys, the address book, payouts and approvals; - `developer`: payments, webhooks, and revoking API keys, but not settings, payouts or creating keys; - `read_only`: view only. ## Nodes and networks Coldtill trusts RPC nodes to report deposits. A malicious node can't redirect funds, but it could report a deposit that never happened. For production: - run your own node, or use a paid provider (Alchemy, Infura, QuickNode, Helius, Blockstream) with an API key; - add more than one node per network, so failover works and decisions that ask every node (Solana payout re-signing, Bitcoin dropped deposits) have something to compare; - keep the default confirmation counts or raise them. Lowering them needs a two-factor code. ## Backups What to back up, and what losing it means: | Item | Where | If lost | |---|---|---| | Database | `pgdata` Docker volume | Payment history, settings, encrypted hot keys. Customer funds are safe on-chain, but you lose the records | | `deploy/.env` | Server | The server secret: sessions, two-factor secrets and webhook secrets become unreadable | | `deploy/secrets/` | Server | The hot-wallet identity. You'd need to re-import or replace hot wallets | | Cold wallet seed | Offline, with you | **Everything swept so far.** Coldtill can't help with this | Nightly database dump: ```bash cd ~/coldtill/deploy docker compose exec -T postgres pg_dump -U coldtill coldtill | gzip > ~/backups/coldtill-$(date +%F).sql.gz ``` Add it to cron with `crontab -e`: ```text 15 3 * * * cd $HOME/coldtill/deploy && docker compose exec -T postgres pg_dump -U coldtill coldtill | gzip > $HOME/backups/coldtill-$(date +\%F).sql.gz ``` Copy `deploy/.env` and `deploy/secrets/` to encrypted offline storage once, and again whenever they change. ### Restore ```bash cd ~/coldtill/deploy docker compose up -d postgres gunzip -c ~/backups/coldtill-2026-10-01.sql.gz | docker compose exec -T postgres psql -U coldtill coldtill docker compose up -d ``` Restore `.env` and `secrets/` from the same point in time as the dump. ## Upgrades ```bash cd ~/coldtill git pull cd deploy docker compose build docker compose up -d ``` Migrations run on start-up. Take a database dump first. Read the release notes before upgrading across major versions. Never run `make contracts` on a live install: rebuilding the EVM contracts changes the factory bytecode, and with it every deposit address. ## Monitoring - **Health:** `https://pay.example.com/healthz` returns `ok`. The `api` container has a Docker health check. - **Logs** are JSON, one line per event: ```bash docker compose logs -f --since 1h api watcher sweeper payouts docker compose logs sweeper | grep '"level":"WARN"' ``` - **Things to alert on:** - `"level":"ERROR"` from any process; - `conversion delivery mismatch`, `filled a nonce gap`, `sweep not possible`, `hot wallet … needs at least`; - payouts in `needs-review` (they also send a `payout.needs-review` webhook); - a network's "last block processed" in **Nodes & networks** falling behind. - **Hot wallet balances:** shown under **Hot wallets**. Top them up before they run dry, or sweeps stop. ## Rotating secrets - **Hot wallet:** add a new hot wallet, assign it to the project, move any remaining balance out of the old one with your own wallet, then delete the old one. - **API key:** create a new key, deploy it, then revoke the old one. - **Webhook secret:** create a new endpoint, switch your receiver, then delete the old endpoint. - **Hot-wallet passphrase:** re-run `coldtill init --force` in a scratch directory to create a new identity, then re-import the hot keys under the new identity. Plan this as a maintenance window. - **`COLDTILL_SECRET`:** changing it signs everyone out and makes stored two-factor and webhook secrets unreadable. Users must re-enrol two-factor and webhooks must be recreated. Only rotate it after a suspected compromise. ## Moving to mainnet Run a separate install with `--mode mainnet`. Mainnet requires an `https://` site URL and SMTP settings (`COLDTILL_SMTP_HOST`, `COLDTILL_SMTP_PORT`, `COLDTILL_SMTP_USERNAME`, `COLDTILL_SMTP_PASSWORD`, `COLDTILL_SMTP_FROM`). Before taking real payments, work through the mainnet checklist in Audit preparation and the advice in How to get Coldtill audited. --- Source: https://coldtill.com/docs/install/ · All Coldtill docs: https://coldtill.com/llms.txt # Configuration Coldtill is configured with environment variables. `coldtill init` writes the essentials to `deploy/.env`, which Docker Compose passes to every container; the compose file adds per-service values. Coldtill was called Keyhold before. Each `COLDTILL_` variable that isn't set falls back to its old `KEYHOLD_` name and logs a warning, so an existing `.env` keeps working until you rename it. API keys issued before the rename (`kh_…`) keep working too; new ones start `ct_`. ## Core | Variable | Default | Purpose | |---|---|---| | `COLDTILL_MODE` | `testnet` | `testnet` or `mainnet`. Decides which networks are seeded. Each install runs in one mode | | `COLDTILL_SITE_URL` | `http://localhost:8080` | Public URL. Used for checkout links, cookies and the CSRF origin check. Mainnet requires `https://` | | `COLDTILL_LISTEN` | `:8080` | Address the `api` process listens on | | `COLDTILL_DATABASE_URL` | local default | PostgreSQL connection URL | | `COLDTILL_SECRET` | none | 64 hex characters (32 bytes). Signs sessions, seals two-factor and webhook secrets, keys the payout approval codes. Required by `api` and `watcher`; the compose file blanks it for `sweeper` and `payouts` | | `COLDTILL_TRUST_PROXY` | `false` | Take the client IP from the rightmost `X-Forwarded-For` entry. The compose file sets it for `api`, behind Caddy. Leave it off if nothing in front of Coldtill sets that header | | `COLDTILL_SECURE_COOKIES` | `true` for `https://` site URLs | Mark session cookies `Secure` | ## Hot wallets | Variable | Default | Purpose | |---|---|---| | `COLDTILL_AGE_RECIPIENT` | none | Public half of the hot-wallet identity. The `api` uses it to encrypt keys you generate or import | | `COLDTILL_AGE_IDENTITY_FILE` | `/data/coldtill-identity.age` | `sweeper` and `payouts` only: the encrypted identity | | `COLDTILL_HOT_PASSPHRASE_FILE` | none | `sweeper` and `payouts` only: file holding the passphrase, for unattended restarts | | `COLDTILL_HOT_PASSPHRASE` | none | The passphrase itself. Takes precedence over the file. Use it to supply the passphrase at start-up instead of storing it | ## Editions Coldtill is built in three editions. The rate is compiled into the binary and enforced on chain for new deposit wallets. | Edition | Build | Sweep fee | Who | |---|---|---|---| | Pro | default (`EDITION=pro`) | 0% | Paid licence, production | | Standard | `-tags standard` | 1% | Cheaper paid tier | | Free | `-tags free` | 2% | Free to use | Fee-edition images need the FeeConfig addresses at build time: ``` make image-free FEE_CONFIG_EVM=0x… FEE_CONFIG_TRON=T… ``` Do not lock FeeConfig or the Solana program until the NFT-share / splitter recipient is final. `coldtill-fees lock-evm` refuses unless `COLDTILL_CONFIRM_FEE_LOCK=yes`. ## Solana | Variable | Default | Purpose | |---|---|---| | `COLDTILL_SOL_PROGRAM_ID` | `EuGW7oFaoXT2acbTyNUzhRjs4sY1W8iq7BPKkGWZ1bk8` | Deposit program. Change it only if you deploy your own build; the dashboard asks for extra confirmation when it differs | | `COLDTILL_SOL_GENESIS` | devnet or mainnet by mode | Pins the Solana cluster. Watching, sweeping and paying out refuse to run against a different cluster. `any` disables the check, for local validators only | ## Email Payout approval codes are emailed. On testnet without SMTP they are printed to the `api` log instead. On mainnet Coldtill starts without SMTP, but held payouts can't be approved until it is configured. | Variable | Default | Purpose | |---|---|---| | `COLDTILL_SMTP_HOST` | none | SMTP server | | `COLDTILL_SMTP_PORT` | `587` | SMTP port (STARTTLS) | | `COLDTILL_SMTP_USERNAME`, `COLDTILL_SMTP_PASSWORD` | none | SMTP credentials | | `COLDTILL_SMTP_FROM` | none | Sender address | ## Update checks | Variable | Default | Purpose | |---|---|---| | `COLDTILL_UPDATE_CHECK` | on | Once a day the API asks coldtill.com for the latest version and security advisories, sending only its version and edition. `off` turns it off | | `COLDTILL_UPDATE_FEED` | `https://coldtill.com/releases.json` | Release feed URL, for mirrors or testing. Format: [Updates and advisories](https://coldtill.com/docs/updates.md) | ## Auto-convert | Variable | Default | Purpose | |---|---|---| | `COLDTILL_ONECLICK_URL` | `https://1click.chaindefuser.com` | NEAR Intents 1Click API. Setting it in testnet mode turns auto-convert on against that endpoint, for testing with a stub | | `COLDTILL_ONECLICK_API_KEY` | none | Optional 1Click JWT. Without one, 1Click adds a 0.2% fee to every quote | | `COLDTILL_THORNODE_URL`, `COLDTILL_MIDGARD_URL` | Liquify public gateway | THORChain endpoints | On mainnet the provider HTTP client never follows redirects and refuses private and reserved addresses, so provider URLs must be public. ## Compose-only | Variable | Purpose | |---|---| | `POSTGRES_PASSWORD` | Database password; also embedded in `COLDTILL_DATABASE_URL` | | `COLDTILL_IMAGE` | Image to run (default `coldtill:local`) | | `COLDTILL_UID`, `COLDTILL_GID` | User the `sweeper` and `payouts` containers run as, so they can read `secrets/` | | `COLDTILL_CADDY_ADDRESS` | Site address for Caddy: a bare domain (`pay.example.com`) gets an automatic certificate. Comma-separate several names (`pay.example.com, pay.shop.com`) when projects have their own checkout domain | | `COLDTILL_HTTP_PORT`, `COLDTILL_HTTPS_PORT` | Host ports Caddy publishes (default 80 and 443) | | `COLDTILL_TRUSTED_PROXIES` | Space-separated ranges of proxies in front of Caddy whose client-IP header is believed (default: private ranges only) | | `COLDTILL_CLIENT_IP_HEADER` | Header those proxies put the visitor's IP in (default `X-Forwarded-For`) | ### Behind Cloudflare With Cloudflare's proxy (orange cloud) in front, every request reaches the server from a Cloudflare address. Tell Caddy to believe Cloudflare's `CF-Connecting-IP` header, but only from Cloudflare's own ranges, so rate limits apply per visitor and the header can't be forged by connecting to the server directly: ```bash cd deploy echo "COLDTILL_TRUSTED_PROXIES=$( { curl -fsS https://www.cloudflare.com/ips-v4; echo; curl -fsS https://www.cloudflare.com/ips-v6; } | xargs)" >> .env echo "COLDTILL_CLIENT_IP_HEADER=CF-Connecting-IP" >> .env docker compose up -d caddy ``` Set Cloudflare's SSL/TLS mode to **Full (strict)**. Cloudflare's ranges change rarely; re-run the first command if they do. ## Per-project settings Most behaviour is configured per project in the dashboard, not in the environment: - **Branding:** name, logo, colour, theme, checkout message, support and return links, payment expiry. See [Branding](https://coldtill.com/docs/branding.md). - **Sweeps:** activation minimum, sweep amount, address count and interval. - **Payouts:** minimum, auto-approve amount, hourly and daily limits. See [Payouts](https://coldtill.com/docs/payouts.md). - **Auto-convert:** settlement asset, maximum slippage, THORChain fallback. See [Auto-convert](https://coldtill.com/docs/auto-convert.md). Per network (global admin): enabled, confirmations, nodes, and which tokens are enabled. ## Checkout domains A project can send customers to its own hostname, such as `pay.shop.com`, instead of the dashboard's address. A server admin sets it under Project settings → Checkout domain (two-factor required). Before saving: 1. Point a DNS `A` record for the name at the server. 2. Add the name to `COLDTILL_CADDY_ADDRESS`, comma separated, and run `docker compose up -d caddy`. On a checkout domain Coldtill serves only `/pay/`, `/api/checkout/`, `/api/v1/`, static assets, branding images and `/healthz`. The login page, the dashboard and `/api/dash` answer 404 there, so the checkout name can't be used to phish admins. New payment links use the domain; links already sent keep working on the old address too. For DNS, Cloudflare certificates and a worked example with several projects, see [Guide: projects, domains and a private dashboard](https://coldtill.com/docs/guides/domains-and-dashboard.md). --- Source: https://coldtill.com/docs/configuration/ · All Coldtill docs: https://coldtill.com/llms.txt # Updates and advisories Coldtill tells server admins when to update. Nothing is installed automatically: you decide when to pull a new image, after reading what changed. ## What the server does A minute after the API starts, and then once a day, it fetches `https://coldtill.com/releases.json`. The request carries only a `User-Agent` such as `coldtill/1.4.0 (pro)`: no install ID, no domain, no counts. Then: - **New version.** Admins see a dismissible banner with a link to the changelog. Dismissing hides it until the next release. - **Security advisory affecting this version.** Admins see a red banner on every page that cannot be dismissed until the server runs a fixed version. Critical and high advisories are also emailed once to every server admin (when SMTP is set up). - Account → Software updates shows the version, edition, latest release, last check and any advisories, with a **Check now** button. A development build (version `dev`) gets no advisories, because its code can't be placed in a version range. Turn it off with `COLDTILL_UPDATE_CHECK=off`. Then watch [coldtill.com/security](https://coldtill.com/security) yourself. ## Update year and security fixes Standard and Pro include a year of updates from purchase. After that, the version you have keeps working forever. **Security fixes are always free**: when an advisory is published, the fixed release can be downloaded by every paid buyer whatever their update year. ## Feed format ```json { "latest": { "version": "1.4.0", "date": "2026-11-02", "url": "https://coldtill.com/changelog#1.4.0", "summary": "Checkout domains, energy rental for Tron sweeps" }, "advisories": [ { "id": "CT-2026-0001", "severity": "high", "title": "Webhook secret visible to read-only project members", "affected": ">=1.2.0, <1.3.2 || >=1.4.0, <1.4.1", "fixed": ["1.3.2", "1.4.1"], "url": "https://coldtill.com/security/CT-2026-0001", "published": "2026-11-05" } ] } ``` - `severity` is `critical`, `high`, `medium` or `low`. - `affected` is a version range: comma-separated conditions (`>=`, `>`, `<=`, `<`, `=`) that must all hold, with `||` between alternatives. A range the server can't parse counts as affected, so a typo over-warns rather than hiding a problem. - Versions compare as `major.minor.patch`. A build from `git describe` such as `v1.4.0-3-gabc1234` compares as `1.4.0`. --- Source: https://coldtill.com/docs/updates/ · All Coldtill docs: https://coldtill.com/llms.txt # Troubleshooting All commands are for Linux. Run `docker compose` commands from the `deploy/` directory. ## Reading the logs ```bash docker compose ps # are all containers up? docker compose logs -f --tail=200 api # or watcher, sweeper, payouts, caddy docker compose logs --since=1h watcher | grep -i -E 'warn|error' ``` Each log line is JSON with a `msg` and a `level`. Internal API errors are logged with the request path and a `request_id`; the client only sees `internal error`. ## Install and Docker - **Containers can't resolve DNS, or `npm ci` hangs during `docker build`.** This is common with Tailscale or a local DNS stub (`nameserver 100.100.100.100` or `127.0.0.53`), which containers can't reach. Give Docker real resolvers: ```bash echo '{ "dns": ["1.1.1.1", "9.9.9.9"] }' | sudo tee /etc/docker/daemon.json sudo systemctl restart docker ``` For a one-off build, pass `--network host` to `docker build` instead. - **npm hangs on hosts with broken IPv6:** `export NODE_OPTIONS="--dns-result-order=ipv4first --no-network-family-autoselection"`, or pass it to `docker build` as `--build-arg NODE_OPTIONS=...`. - **`go mod download` fails with permission errors under `~/go/pkg/mod`:** point the module cache somewhere writable, e.g. `go env -w GOMODCACHE=/var/tmp/$USER-gomodcache`. - **Published ports don't answer, but `docker compose exec caddy wget -qO- http://localhost/healthz` works:** the firewall is blocking host-to-container forwarding. Check `sudo iptables -L DOCKER-USER` and whether ufw or Tailscale rules drop forwarded traffic. - **`permission denied` on the Docker socket:** run `sudo usermod -aG docker $USER`, then log out and back in. - **Caddy can't get a certificate:** the DNS name must point at this server, and ports 80 and 443 must be reachable from the internet. Check `docker compose logs caddy`. ## Sign-in - **"Too many attempts":** login is limited to 10 attempts a minute per IP and per email. Wait a minute. - **Two-factor codes rejected:** check the server clock (`timedatectl`; it should say "System clock synchronized: yes"). Each code works once, so wait for the next code. After five wrong codes, checks are locked for 15 minutes. - **Lost two-factor device:** there is no reset button, on purpose. Someone with server access can clear it from the database, after which the user signs in with their password and enrols again: ```bash docker compose exec postgres psql -U coldtill coldtill -c \ "UPDATE users SET totp_enabled = false, totp_secret = NULL WHERE email = 'user@example.com'" ``` ## Deposits - **A deposit isn't showing up:** 1. Check the network under **Nodes & networks**: the last processed block should be close to the chain head. If not, the nodes are failing or rate limiting you. Add your own node. 2. Check that the token is enabled on that network, and that the customer used the right network. 3. Look for `watcher` warnings mentioning the address or transaction. - **A deposit is stuck "confirming":** it needs the network's confirmation count. On Bitcoin a block can take an hour or more. - **"evm watcher catching up after downtime" warning:** the watcher was down for more than 10,000 blocks. It catches up block by block; that can take a while, but nothing is skipped. - **A Bitcoin deposit was marked orphaned:** every node reported the transaction gone (replaced or dropped). The customer should pay again. ## Sweeps - **Nothing is being swept:** sweeps run when an address holds more than the activation minimum, then on the amount, address-count or time triggers. Check the hot wallet has gas, and look for `sweeper` errors. - **"insufficient funds" in the sweeper log:** top up the EVM or Solana hot wallet with native coin. - **A wallet is left unswept after a sweep:** the token's transfer failed inside the contract (for example a paused or blocklisted token). The funds stay at the deposit address, still locked to your cold wallet. - **A Solana sweep failed after 10 minutes of backoff:** see the error in the `sweeper` log; usually the hot wallet lacks SOL, or the RPC node is rate limiting. ## Payouts - **Stuck in `pending`:** is the `payouts` container running and did it unlock the hot wallet? Check `docker compose logs payouts` for passphrase errors. - **Stuck in `initiated`:** EVM payouts are re-signed with higher fees automatically. Check the hot wallet has enough native coin for the higher fee. - **`needs-review`:** see [Payouts → Needs review](https://coldtill.com/docs/payouts.md#needs-review). - **No approval email:** check the `COLDTILL_SMTP_*` settings and the `api` log. On testnet, codes are printed to the `api` log instead. ## Webhooks - **All deliveries fail:** on mainnet the endpoint must be a public `https` URL; private addresses are refused. Redirects aren't followed. Use **Send test** to check. - **Signature mismatch:** verify against the raw body bytes, before JSON parsing. Frameworks that parse the body first (for example Express with `express.json()`) change it. - **Timestamp outside the window:** your server clock is off. Enable time sync with `sudo timedatectl set-ntp true`. ## Auto-convert - **No swap options at checkout:** auto-convert is off for the project, the install is in testnet mode, or the provider couldn't quote that amount (very small amounts often can't be swapped). - **A conversion shows `mismatch`:** read the reason in **Conversions**. The payment wasn't credited. Check the delivery transaction on a block explorer and contact the provider if funds didn't arrive. ## Database ```bash docker compose exec postgres psql -U coldtill coldtill ``` Migrations run automatically at start-up. If the `api` container exits with a migration error, restore the last backup before retrying, and report the error. --- Source: https://coldtill.com/docs/troubleshooting/ · All Coldtill docs: https://coldtill.com/llms.txt # Security This guide explains how Coldtill protects money and what you, as the operator, need to do to keep it that way. The [threat model](https://coldtill.com/docs/threat-model.md) lists threats and mitigations in detail, and the October 2026 audit report lists what was found and fixed. ## What's at risk, and what isn't | Funds | Who can move them | |---|---| | Customer deposits (EVM, Solana) | Anyone can trigger a sweep, but it can only go to your cold wallet. The server can't change that | | Bitcoin deposits | Only you: they land directly in your own wallet, derived from your xpub | | Your cold wallets | Only you. Coldtill never sees those keys | | Hot wallets | The `sweeper` and `payouts` processes, after decrypting the key with your passphrase | A complete server compromise can, at worst, spend the hot wallets' balances and tamper with payment records (for example marking an unpaid order as paid). It cannot redirect deposits. Keep the hot float small, and cross-check large orders against the chain before shipping. ## Operator checklist Before going live: - [ ] Run on a dedicated server or VM, kept up to date (`sudo apt update && sudo apt upgrade`). - [ ] Use an `https://` site URL. Caddy obtains the certificate. - [ ] Only ports 80 and 443 are open (`sudo ufw allow 80,443/tcp && sudo ufw enable`). Postgres is not published. - [ ] Every user has two-factor authentication. Give `project_admin` only to people trusted to approve payouts. - [ ] Use your own or paid RPC nodes for each enabled network, ideally more than one provider. See [Nodes](#nodes). - [ ] Payout limits: a low auto-approve amount, plus hourly and daily limits. - [ ] The hot wallets hold about a week of gas and payouts, no more. - [ ] `deploy/.env` and `deploy/secrets/` are backed up offline, and the database is backed up daily. - [ ] SMTP works: approve a small test payout end to end. - [ ] Your webhook handler uses `X-Coldtill-Signature-V2` (or the SDK's `verifyWebhookRequest`). - [ ] For extra protection, run without a stored passphrase (see [Install](https://coldtill.com/docs/install.md#running-without-a-stored-passphrase)). ## Accounts and sessions - Passwords are hashed with Argon2id (64 MiB, 3 passes). At most four hashes run at once, so login floods can't exhaust memory. - Login is rate limited per IP address and per email: 10 attempts a minute. - Two-factor (TOTP) is needed to log in once enabled, and a fresh code is needed for sensitive actions. Each code works once. Five wrong codes lock two-factor checks for that user for 15 minutes; the lock is held in memory, so it resets if the `api` process restarts. - Sessions use `HttpOnly`, `Secure` and `SameSite=Strict` cookies. State-changing requests must come from the same origin and be JSON. Actions that need a fresh two-factor code include: - creating, approving or resolving payouts; - editing the address book or payout limits; - creating API keys; - adding RPC nodes or lowering confirmation counts; - assigning hot wallets, setting up deposit wallets; - changing auto-convert settings. ## Roles | Role | Can | |---|---| | `root` | Everything, including managing admins | | `admin` (global) | Manage users, hot wallets, nodes and networks, and all projects | | `member` (global) | Only the projects they're added to, with that project's role | | `project_admin` | Project settings, API keys, webhooks, payouts and approvals | | `developer` | Create payments and view everything in the project | | `read_only` | View only | ## Hot wallets - Keys are encrypted with [age](https://age-encryption.org). Only the `sweeper` and `payouts` containers get the identity file and passphrase; the `api` process can encrypt imported keys but never decrypt them. - Transactions are signed and stored before broadcast, so a crash can't produce a second payment. - Nonces are reserved under a database lock; gaps are filled automatically. - Payouts Coldtill can't account for become `needs-review` instead of guessing. See [Payouts](https://coldtill.com/docs/payouts.md#needs-review). ## Nodes Coldtill doesn't trust RPC nodes with keys, but it does trust them to report deposits honestly. A malicious node could report a deposit that never happened. Mitigations: - Use nodes you run or pay for, not the public defaults. Add several, from different providers. - Bitcoin deposits are only orphaned when **every** node agrees the transaction is gone. - EVM chain IDs are signed into every transaction, and the Solana genesis hash is pinned, so a node on the wrong network can't trick the signers. - For large orders, check the deposit on a block explorer before shipping. Node URLs often carry API keys. They're never shown in error messages and adding a node needs two-factor. ## Webhooks - Each endpoint has its own secret, sealed in the database with the server secret. - The v2 signature covers a timestamp, so receivers can reject replays. - Coldtill refuses to connect to private and reserved addresses on mainnet (checked at connect time, so DNS rebinding doesn't help) and doesn't follow redirects. ## Web security - Strict content security policy on the dashboard and checkout. Neither can be framed. - Uploaded logos are re-encoded as PNG and served with their own sandboxing policy. SVG is refused. - The checkout only shows data for its own payment and is rate limited. ## Updates and vulnerability reports - Server admins see a banner when a newer version exists, and a red one when a security advisory affects the running version. Critical and high advisories are also emailed. See [Updates and advisories](https://coldtill.com/docs/updates.md). - Advisories are published at [coldtill.com/security](https://coldtill.com/security). Security fixes are free to install even after your update year ends. - Upgrade as described in [Install](https://coldtill.com/docs/install.md#upgrades). - CI runs `govulncheck`, `gosec`, gitleaks and `npm audit` on every push. To run them yourself, see How to get Coldtill audited. - Report vulnerabilities privately to security@coldtill.com (see [coldtill.com/.well-known/security.txt](https://coldtill.com/.well-known/security.txt)), not in public issues. --- Source: https://coldtill.com/docs/security/ · All Coldtill docs: https://coldtill.com/llms.txt # 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. ```mermaid 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](https://coldtill.com/docs/security.md). ## 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. - `sweeps` and `sweep_addresses`: batched sweep transactions and the addresses they covered. - `hot_wallets`: encrypted keys. `project_hot_wallets` assigns them to projects. - `payouts` and `address_book`: outgoing payments and approved destinations. - `conversions`: auto-convert swaps, their quotes, provider status and verified delivery. - `webhook_endpoints` and `webhook_deliveries`: endpoints with sealed secrets, and the delivery queue. - `rpc_nodes` and `networks`: node pools and per-network settings (confirmations, enabled, last processed block). - `audit_log`: who did what, for every sensitive dashboard action. ## Payment lifecycle 1. **Create.** `POST /api/v1/payment` upserts the customer and replaces their previous open payment, unless that payment has a deposit still confirming, in which case the request is refused with `409`. 2. **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. 3. **Detect.** - **EVM:** the watcher scans blocks in bounded ranges for ERC-20 `Transfer` logs 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. 4. **Confirm.** Deposits become `confirmed` after 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 is `orphaned` and reversed. 5. **Fill.** Confirmed USD is compared with the amount owed, with a 1% tolerance: `PARTIALLY_FILLED`, `FILLED` or `OVER_FILLED`. Prices come from CoinGecko with Coinbase as fallback; stablecoins count as $1. 6. **Notify.** Every change queues a webhook. Closing a payment sends exactly one `final` event. 7. **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: 1. It lists deposit addresses holding at least the project's activation minimum in confirmed, unswept value. 2. 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. 3. **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. 4. **Solana:** a transaction with one `sweep_sol` or `sweep_token` instruction 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. 5. 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](https://coldtill.com/docs/payouts.md). ## 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. --- Source: https://coldtill.com/docs/architecture/ · All Coldtill docs: https://coldtill.com/llms.txt # Coldtill threat model ## 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. --- Source: https://coldtill.com/docs/threat-model/ · All Coldtill docs: https://coldtill.com/llms.txt