Bolt a pay page onto any domain. Card, Lightning, Bitcoin, stables, Ark, Cashu. Sweep in the background to whatever you actually hold.
Most internet sales are small. At that size a hot wallet on a VPS is a cost of doing business. You sweep on settle. You do not store life savings next to the shop.
The shop does one job: take the money the customer already holds, unlock the file, and convert in the background. The customer never sees the sweep. Price, format, how to pay.
Copy any block. Change the names. This is the shape running on this site.
Show the wallets people hold. Hide the plumbing.
Card · Lightning · Ark · Cashu · Bitcoin · Stables
| Tile | Customer pays | You receive | Then |
|---|---|---|---|
| Card | Stripe Checkout | USD | leave it |
| Lightning | QR only | Lightning | already home |
| Bitcoin | Silent Payments QR, bc1p fallback | UTXO in a bridge wallet | submarine swap to Lightning |
| Stables | network picker | USDT / USDC / the next brand | swap to BTC or LN |
| Ark | unique tark1 / ark1 QR | VTXO in Bark | send Lightning |
| Cashu | NUT-18 payment request | melt to Lightning | already Lightning |
Stables are one tile with a dropdown. The dropdown is the rails table in env. Add USDC on Base by adding a row. An altcoin is the same row with a different ticker.
hsm_secret, mail passwords, bank.Every crypto method is one row. Receive-only on the shop host. Spend keys stay on the VPS.
# rails.env
CARD=stripe
[email protected]
# Bitcoin
BTC_SP=sp1q…
BTC_FALLBACK=bc1p…
# Stables: id | kind | address | decimals | asset_or_contract | chain_id
# kind = evm | tron | liquid | other
STABLE_USDT_ETH=evm,0xYourEvm,6,0xdAC17F958D2ee523a2206206994597C13D831ec7,1
STABLE_USDT_POLYGON=evm,0xYourEvm,6,0xc2132D05D31c914a87C6611C10748AEb04B58e8F,137
STABLE_USDT_ARB=evm,0xYourEvm,6,0xFd086bC7CD5C481DCC9C85ebE478A1C0b69FCbb9,42161
STABLE_USDT_TRON=tron,TYourTron,6,TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t,0
STABLE_USDT_LIQUID=liquid,lq1qqYourLiquid,8,ce091c998b83c78bb71a632313ba3760f1763d9cfcffae02258ffa9865a37bd2,0
# Add a stable: copy a line, change id, contract, chain.
# STABLE_USDC_BASE=evm,0xYourEvm,6,0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913,8453
# An alt is the same row.
# ALT_LTC=other,ltc1q…,8,LTC,0
The pay page reads the table and draws the dropdown. The sweeper reads the same ids and asks the queue for a pair. When a government or a bank ships a coin on a chain you already watch, you add a line. You do not rewrite the shop.
pay.example.com Stripe Checkout only
example.com the shop
example.com {
encode gzip
handle /pay/* {
reverse_proxy 127.0.0.1:8080
}
file_server
}
pay.example.com {
reverse_proxy 127.0.0.1:8081
}
The pay host creates Stripe sessions. The shop host never sees the Stripe secret. They share an HMAC so a paid return can mint a cookie.
STRIPE_SECRET_KEY=sk_live_…
STRIPE_WEBHOOK_SECRET=whsec_…
PAY_HMAC=<32-byte hex>
Checkout Session: the product title only, product price, success URL https://example.com/pay/return?g={grant}. Webhook checkout.session.completed marks paid. The return URL is a second path so a closed webhook still unlocks.
If you are using a Lightning Address instead of your own node, show a QR. Do not print the Lightning Address in the page JSON.
LNURL-pay at https://{domain}/.well-known/lnurlp/{user} must 200 JSON or the sweep skips.
One seed, two receive paths.
sp1q… per checkout.bc1p. BIP-321 URI with sp= only. Do not put bc1 in the same URI.python3 -c "import os; print(os.urandom(32).hex())"
Paper it. VPS env holds the seed. Derive m/352h/0h/0h/0h/0 spend, m/352h/0h/0h/1h/0 scan, m/86h/0h/0h for bc1p. Fulfill at 0-conf. Sweep after 1 conf.
Pin the BTC-USD rate for the session (thirty minutes is enough). Do not add a unique-sats pad on a fresh address — the customer should see the same quote when they switch tiles.
One key per family, not per customer.
EVM. One secp256k1 key. Same 0x on Ethereum, Polygon, Arbitrum, Base, and the next EVM a state coin launches on.
python3 - <<'PY'
import os
from coincurve import PrivateKey
from Crypto.Hash import keccak
raw = os.urandom(32)
pub = PrivateKey(raw).public_key.format(compressed=False)[1:]
k = keccak.new(digest_bits=256); k.update(pub)
print("addr", "0x" + k.digest()[-20:].hex())
open("evm.hex","w").write(raw.hex()+"\n")
PY
TRC-20. Same curve, Tron address (T…).
Liquid. BIP32 coin type 1776 plus a blinding key. Confidential lq1qq…. Keep the wallet when Liquid is ugly.
Paper every hex. Mode 700 on the directory, 600 on the files. Never git. The shop env is receive-only. The VPS env has the spend hex.
Matching inbound: EVM Transfer logs, TronGrid TRC-20, Liquid Esplora by asset id. Confidential Liquid amounts need the blinding key to auto-match.
On paid: settle → hot wallet already has it → round-robin swap queue → dest. Cron every ten minutes is catch-up.
# queue.py — failed names stay in the list. Cursor on disk.
BOLTZ_V2 = {
"satsrouting": "https://api.satsrouting.exchange",
"zeus": "https://swaps.zeuslsp.com/api",
"boltz": "https://api.boltz.exchange",
}
def parse_queue(raw: str) -> list[str]:
return [p.strip().lower() for p in (raw or "").split(",") if p.strip()]
def walk(names, start):
n = len(names)
start = start % n
return [names[(start + i) % n] for i in range(n)]
def try_round_robin(names, state_path, fn):
state = json.loads(state_path.read_text()) if state_path.is_file() else {}
start = (int(state.get("cursor", -1)) + 1) % len(names)
errors = []
for i, name in enumerate(walk(names, start)):
try:
result = fn(name)
except Exception as exc:
errors.append(f"{name}: {exc}")
continue
state["cursor"] = (start + i) % len(names)
state_path.write_text(json.dumps(state))
return result, name
raise RuntimeError("all providers failed: " + "; ".join(errors))
SWAP_PROVIDERS=satsrouting,zeus,lightning-swap,fixedfloat,sideshift,boltz
TREASURY=lightning:[email protected]
A $1 sale is below many floors (SATS Routing 50k sats, SideShift ~$7.56 USDT, Tron fees). Batch until the floor, then one swap.
Boltz v2 submarine (chain → Lightning), used by SATS Routing and ZEUS:
POST {base}/v2/swap/submarine
{"invoice": "<bolt11>", "to": "BTC", "from": "BTC", "refundPublicKey": "<hex>"}
Resolve your Lightning Address to bolt11 first. Start the claim waiter before you pay a hold invoice.
Instant exchangers (FixedFloat, Lightning Swap, SideShift): they give a deposit address. Your hot wallet pays it. They pay your dest. Custodial for minutes. Fine for a dollar batch.
ERC-20 send from the VPS when a deposit address exists:
data = a9059cbb + padded to + padded amountto = the token contractvalue = 0v = recovery_id + chainId * 2 + 35Never log the privkey. Kick on settle with a daemon thread. Cron is the net.
Assume customers arrive with Bark and with a Cashu wallet.
Ark. Bark datadir. One tark1 / ark1 per checkout (bark address). Poll bark address lookup --address. Fulfill when received sats cover the invoice. Then bark send a Lightning invoice on the sink. Do not iframe a browser desk.
Cashu. NUT-18 creqA + CBOR. Amount in sats, HTTP POST callback. The customer's wallet melts to Lightning. You never run a mint on the shop box. v1 does not hold Cashu tokens. Melt is the autoswap.
A paid return mints a host-only cookie so the shop can unlock what was bought.
Set-Cookie: sale={token}; Path=/pay; HttpOnly; Secure; SameSite=Lax
[Service]
User=www-data
WorkingDirectory=/opt/pay
EnvironmentFile=/etc/pay.env
ExecStart=/usr/bin/python3 /opt/pay/server.py
Restart=on-failure
*/10 * * * * /opt/pay/sweep.sh >> /opt/pay/sweep.log 2>&1
On the pay screen: price, format, tiles. On the receipt: same. Which swap host, which mint, which exclusive window: those stay off the button.
Probe from the VPS: DNS, TLS, min amount, refund. A name in env that fails is skipped for that settle only.
| Id | What | Notes |
|---|---|---|
satsrouting | Boltz-shaped | GET /v2/swap/submarine 200. Min 50k / max 5M sats. BTC, LN, Liquid. |
zeus | ZEUS Swaps, Boltz v2 | POST /v2/swap/submarine. |
lightning-swap | lightning-swap.com /api/v2 | USDT TRC / SOL / ERC-20 in, bolt11 out. |
fixedfloat | ff.io /api/v2/create | Can pay BTCLN. Custodial for minutes. |
sideshift | USDT → BTC bitcoin | Quotes work. createShift may be false. No LN settle as of 2026-09. |
boltz | Original | Paused 2026-08-03. |
| Id | Who | Status |
|---|---|---|
blockstream | Blockstream Swaps | Invite beta. No public REST. BTC / LN / Liquid. |
atomiq | atomiq.exchange | REST live for smart-chain → BTC/LN. |
swaptopia | Swaptopia | You run Bark. Not an iframe. |
kaleidoswap | @kaleidoswap | RGB Lightning USDT, not Liquid. |
utexo | @utexocom | RGB BTC↔USDT with Lightning execution. |
Do not add Diamond Hands (shut), PeerSwap (peer channels), THORChain / Maya / Chainflip (not Lightning invoices), or Strike / Cash App / Wallet of Satoshi (accounts).
When someone posts that they are building a swap host, write the handle in the table. Do not call it until probed.
BTC_BRIDGE_SEED=<hex>
BTC_SWEEP_ENABLED=true
[email protected]
SWAP_PROVIDERS=satsrouting,zeus,lightning-swap,fixedfloat,sideshift,boltz
USDT_EVM_ADDRESS=0x…
USDT_EVM_PRIVKEY=<hex>
USDT_TRON_ADDRESS=T…
USDT_LIQUID_ADDRESS=lq1qq…
USDT_SWEEP_ENABLED=true
USDT_SWEEP_BTC_ADDRESS=bc1p…
Shop host copies the addresses only.
STABLE → your dest on at least one provider in the queue.kind=other and a scanner you already trust. The queue does not care what the ticker was, only whether a host will take it.