The problem Bitcoin's architecture creates
Bitcoin doesn't have smart contracts. If you want to take a fee out of every customer payment to a merchant, you can't do it the way you would on Ethereum — there's no receive() function on a contract that splits the value as it arrives. Every output of every Bitcoin transaction goes to exactly one address, and you can't make that address run code that splits it further.
This forces a particular architecture for any Bitcoin payment processor that wants to be non-custodial. You can't have the customer pay the merchant directly (because then how does the processor take its fee). You can't have the customer pay the processor and the processor forward (because then the processor has custody). The only available pattern is: customer pays a unique address the processor controls briefly, the processor immediately sweeps that address with a transaction that has two outputs (one to the merchant, one to the processor's fee wallet).
The window between "customer payment confirms" and "sweep transaction confirms" is when the processor technically has custody. Architecturally this is the minimum custody window any non-custodial Bitcoin processor can achieve. Practically it's ~30 seconds (or less, with Child-Pays-For-Parent), and it's bounded by automatic logic — the processor's master key can sweep, but it can't aggregate or hold balances.
BIP-84: how one master key produces infinite addresses
The first piece of the architecture is generating addresses. We don't want to generate them randomly because then we'd have to store every one of them, and we'd need to back up every key individually. Instead we use Bitcoin's Hierarchical Deterministic (HD) wallet standard, specifically BIP-84 (the native-segwit variant that produces bc1q… addresses).
BIP-84 works like this: you generate a 12-word mnemonic phrase. That phrase deterministically produces a 512-bit seed. That seed deterministically produces an "extended private key" (zprv). The zprv deterministically produces an "extended public key" (zpub). From the zpub, you can derive an arbitrary number of receive addresses by walking a derivation path — typically m/84'/0'/0'/0/N where N is the address index.
from bip_utils import Bip84, Bip84Coins
# Generated once during setup, stored in /etc/sovrn/env
SOVRN_BTC_XPUB = "zpub6sYgz5JFAM3PNYTDPpTfqV..."
def derive_receive_address(index: int) -> str:
"""Derive a fresh bc1q address at index N from our master zpub."""
b = Bip84.FromExtendedKey(SOVRN_BTC_XPUB, Bip84Coins.BITCOIN)
return b.AddressIndex(index).PublicKey().ToAddress()
# Index 0 -> bc1q68x6psp3c8dcak7jmh3zv7ke92zztl8sfx95np
# Index 1 -> bc1qsxlht3hw6luhd6xpjcz55ddl3cwnwzsf0f3y4n
# Index 2 -> bc1q5es6d4lrqg6cxew0wa4ec9apc784pzymt9gvqe
# ... and so on, 2^31 unique addresses
The crucial property: the zpub is public. You can ship it in your codebase, post it on GitHub, write it on a billboard. It can derive addresses but it cannot sign transactions to spend the funds at those addresses. Only the zprv (private extended key) can sign. The zprv is what we keep in /etc/sovrn/env, file-mode 600, never displayed in logs, never transmitted off the server.
This separation is the security model: even if an attacker compromised our static frontend code and grabbed the zpub, they couldn't move any funds. They could only watch which addresses we use, which is information that's public on the blockchain anyway.
Allocating an address for a specific payment
When a customer initiates a Bitcoin payment, we need to allocate one of those derived addresses to them, in a way that's deterministic and doesn't reuse addresses across payments (address reuse is a privacy leak and a tracking gift to chain analysts).
The simplest scheme: maintain a counter. Every new payment gets the next index. We persist the index in the database so that if the server restarts, we don't reuse:
def allocate_payment_address(db, merchant_id, payment_id):
# Atomically reserve the next index
row = db.execute(
"SELECT COALESCE(MAX(derivation_index), -1) + 1 AS next "
"FROM payment_addresses WHERE chain = 'bitcoin'"
).fetchone()
idx = row['next']
addr = derive_receive_address(idx)
db.execute(
"INSERT INTO payment_addresses "
"(payment_id, merchant_id, chain, address, derivation_index, created_at) "
"VALUES (?, ?, 'bitcoin', ?, ?, ?)",
(payment_id, merchant_id, addr, idx, time.time())
)
return addr
The customer is told to send X BTC to that address. We show them a QR code with the bitcoin: URI bitcoin:bc1q…?amount=0.001 which their wallet parses to populate the amount automatically.
Watching the mempool
The next piece is detecting when the customer actually pays. We run a watcher process that polls each pending payment's address against a Bitcoin block explorer's API (Sovrn uses mempool.space, which has a generous public API):
def check_payment(payment):
"""Returns True if the address has received the expected amount."""
addr = payment['wallet_address']
expected_sat = int(payment['crypto_amount'] * 1e8)
r = requests.get(f"https://mempool.space/api/address/{addr}").json()
chain = r['chain_stats']
mempool = r['mempool_stats']
received_sat = (chain['funded_txo_sum'] - chain['spent_txo_sum']) \
+ (mempool['funded_txo_sum'] - mempool['spent_txo_sum'])
return received_sat >= int(expected_sat * 0.99) # 1% tolerance
We detect the payment the moment it enters the mempool — typically 5–15 seconds after the customer's wallet broadcasts it. We don't wait for a confirmation to fire the "received" webhook because most merchants want to fulfill orders fast, and the mempool detection plus Replace-By-Fee tracking is enough confidence for most use cases.
For higher-value orders, the merchant can configure the webhook to wait for confirmed (one block confirmation, ~10 minutes) instead of received.
Building the atomic sweep transaction
The interesting bit. The moment we detect the payment, we build a Bitcoin transaction that spends the customer's payment (the UTXO at the derived address) and creates two outputs: one to the merchant's payout address, one to Sovrn's fee wallet. The transaction signs with the derived private key for that specific index.
from bitcoin.core import CMutableTransaction, CMutableTxIn, CMutableTxOut, COutPoint, lx
from bitcoin.core.script import SignatureHash, SIGHASH_ALL, SIGVERSION_WITNESS_V0
from bitcoin.wallet import CBitcoinAddress, CBitcoinSecret
def build_sweep_tx(receive_address, derivation_index, merchant_address, fee_bps=50):
# 1. List UTXOs at the receive address (what the customer paid)
utxos = mempool_space_utxos(receive_address)
total_sat = sum(u['value'] for u in utxos)
# 2. Compute the network fee + split
network_fee = estimate_fee(num_inputs=len(utxos), num_outputs=2)
spendable = total_sat - network_fee
sovrn_sat = (spendable * fee_bps) // 10_000 # 50 bps = 0.5%
merchant_sat = spendable - sovrn_sat
# 3. Build inputs (referencing customer's UTXOs)
tx_inputs = [
CMutableTxIn(COutPoint(lx(u['txid']), u['vout']))
for u in utxos
]
# 4. Build outputs (merchant + Sovrn)
tx_outputs = [
CMutableTxOut(merchant_sat, CBitcoinAddress(merchant_address).to_scriptPubKey()),
CMutableTxOut(sovrn_sat, CBitcoinAddress(SOVRN_FEE_ADDR).to_scriptPubKey()),
]
tx = CMutableTransaction(tx_inputs, tx_outputs)
# 5. Sign each input with the derived private key (BIP-143 segwit signing)
priv_key_bytes = derive_private_key(derivation_index)
secret = CBitcoinSecret.from_secret_bytes(priv_key_bytes, compressed=True)
pubkey_hash = ripemd160(sha256(bytes(secret.pub)))
script_code = build_p2wpkh_script_code(pubkey_hash)
for i, u in enumerate(utxos):
sighash = SignatureHash(script_code, tx, i, SIGHASH_ALL,
amount=u['value'], sigversion=SIGVERSION_WITNESS_V0)
sig = secret.sign(sighash) + bytes([SIGHASH_ALL])
tx.wit.vtxinwit[i] = CTxInWitness(CScriptWitness([sig, bytes(secret.pub)]))
return tx
Once the transaction is built and signed, we broadcast it to the network via mempool.space's POST endpoint:
def broadcast_sweep(tx):
raw_hex = tx.serialize().hex()
r = requests.post("https://mempool.space/api/tx", data=raw_hex)
return r.text.strip() # the tx hash
Bitcoin's mempool propagates the new transaction across the network. Miners include it in the next block (or the one after, depending on fees). Within ~10 minutes the sweep transaction confirms, and the funds are now atomically split between the merchant and the processor. The intermediate derived address is empty forever.
The custody window, quantified
This architecture has a brief window where the processor has effective custody: the time between "customer's payment broadcasts" and "processor's sweep tx broadcasts." That window is bounded by:
- How fast the processor's watcher polls (Sovrn: every 15 seconds)
- How fast the watcher builds + signs + broadcasts the sweep (sub-second)
In practice this means the worst-case window is ~20 seconds. The processor's master key technically controls the funds during this window. An attacker who compromised the server during that 20-second window could redirect the sweep to themselves.
This is the inherent cost of non-custodial Bitcoin processing in 2026, until someone deploys an L2 with proper smart contracts that change the calculation. We mitigate it by:
- Hot-key isolation — the BTC zprv lives only on the production sweep machine, not in any other system component
- File-mode 600 with root ownership, no logging
- Regular key rotation (quarterly) — old derivation indexes get marked "drain only" and new payments use a new master key
- Monitoring for unexpected sweep targets — if a sweep ever goes to an address that isn't the merchant's payout, alerting fires immediately
How the merchant integrates this
From the merchant's perspective, this entire pipeline is invisible. They make one POST to /api/v1/payments when a customer initiates checkout, redirect the customer to the returned checkout_url, and listen for a webhook fire when the payment settles. The HD-derivation, address allocation, mempool watching, sweep transaction construction, signing, and broadcasting all happen server-side at Sovrn.
curl -X POST https://sovrn.ventures/api/v1/payments \
-H "X-API-Key: sk_live_xxx" \
-H "Content-Type: application/json" \
-d '{
"amount": 184.20,
"currency": "USD",
"chain": "bitcoin",
"token": "BTC",
"order_id": "1847"
}'
Response includes the derived address, the QR-ready URI, and the expected amount in BTC. Show this to the customer. The webhook fires when the sweep confirms; the merchant credits the order and ships the product.
If you want to verify the architecture yourself
Everything described here is verifiable on-chain. The Sovrn BTC fee wallet is public — you can watch it accumulate as payments flow. The sweep transactions are public — you can inspect them and confirm they have exactly two outputs (merchant + fee wallet) and were signed by the expected derivation. The factory contract on Optimism that handles the EVM rail is open-source.
This kind of verifiability is the whole point of non-custodial. The merchant doesn't have to trust us; they trust the protocol.
Paste your bc1q payout address. Get an API key. The next BTC payment lands directly in your wallet.