A Payment Gateway That Never Touches the Money

By Pablo Fernandez14 min read

There is one moment in every payment system where the money belongs to nobody in particular. It has left the buyer and not yet reached the merchant, and for that instant it sits in an account the payment company controls. That instant is the whole business. It is where the fee is skimmed, where the fraud rules run, and where, if something goes wrong, the money can be frozen, clawed back, subpoenaed, or simply gone the morning someone drains the hot wallet. Custody is not a feature of a payment processor. It is the risk.

Paybit is a crypto payment gateway I have been building around a single, stubborn constraint: that moment should not exist. The money should never land in an account I control, not even for a second. This post is about what a payment system has to look like once you take that seriously, and about the one chain, Bitcoin, where keeping the promise meant giving up the neat trick that makes it work everywhere else.

Most "crypto gateways" are banks with extra steps#

The usual way to accept crypto is custodial, whatever the marketing says. You point the buyer at an address the processor owns, the processor credits your account when the coins arrive, and then, on its own schedule, it pays you out. For the length of that round trip the processor is holding your money. It can delay the payout, freeze it if a compliance flag trips, lose it to a breach, or make you prove who you are before you can withdraw. You have swapped a bank for a startup, and kept every downside of the middle.

So the doubt is specific. A gateway does real work: it mints an invoice, it watches the chain, it tells the merchant the exact instant an order is paid, it takes its cut, it handles refunds and subscriptions and receipts. Can it do all of that without ever being the account in the middle? Can the fee come out without the principal ever stopping to rest somewhere I control?

Here is the thing itself#

Before the how, the what. This is a working mockup of the checkout, running entirely in your browser. Pick a chain, press the button, and watch it move through the three states a real payment goes through. The state names in the top corner are the real ones. Notice the last line of the receipt.

Paybit checkout
requires_payment_method
0.00161ETH
order-4172 · $5.00

Interactive mockup. The QR is illustrative and no real payment is made; the states mirror the real intent lifecycle.

It walks from requires_payment_method (an invoice exists, nothing has been paid) to processing (a payment was seen on-chain and is gathering confirmations) to succeeded (settled). On Base and Solana the receipt shows the fee coming out; on Bitcoin the merchant keeps the whole amount and the fee is billed later. Every path ends the same way: Paybit held 0.00. The rest of this post is the machinery behind that one line.

The split happens on the chain, not in an account#

On the chains that can run code, the trick is to make the split part of the buyer's own transaction. Paybit deploys one small smart contract per chain, a payment router. The buyer does not send money to Paybit and wait; the buyer calls the contract, and the contract forwards the merchant's share and Paybit's fee to their two separate addresses in the same transaction, before it returns.

<>PaymentProcessor.sol
Code
function payNative(address merchant, bytes32 orderId) external payable nonReentrant {
    Merchant storage m = _merchants[merchant];
    if (!m.exists || m.withdrawTo == address(0)) revert UnknownMerchant();
    if (msg.value == 0) revert ZeroAmount();
 
    uint256 fee = (msg.value * m.feeBps) / 10000;  // the platform's cut
    uint256 net = msg.value - fee;                 // everything else is the merchant's
 
    (bool okNet, ) = m.withdrawTo.call{value: net}("");    // net -> merchant
    if (!okNet) revert TransferFailed();
    if (fee > 0) {
        (bool okFee, ) = feeAddress.call{value: fee}("");  // fee -> Paybit
        if (!okFee) revert TransferFailed();
    }
 
    emit Payment(merchant, orderId, address(0), msg.sender, msg.value, fee);
}

The money touches the contract only as a value in memory. There is no line that keeps any of it, and, more to the point, there is no way to. The contract has no receive and no fallback function, the two hooks that let a contract accept and hold a stray transfer, so it cannot accumulate a balance even by accident. Whatever comes in is forwarded and gone in the same breath.

the buyer signs ONE transaction ┌────────────────────────┐splitter contract │ (balance: always 0) │ └────────────────────────┘ │ │ net │ │ fee ▼ ▼┌───────────────────┐ ┌───────────────────┐│ merchant wallet │ │ Paybit's fee │└───────────────────┘ └───────────────────┘
Every payment is one transaction that fans out to the merchant and to Paybit at once. The contract is only ever a pass-through, so its balance is zero before and after.

That is not a claim, it is a test. The contract suite includes one, testContractNeverHoldsFunds, that sends a payment and then asserts the contract's balance is exactly zero afterwards. It is a small assertion, but it is the whole promise in one line: the router is a pass-through, and the code proves it every time the tests run.

Nobody in the flow can point the money somewhere else#

A pass-through is only safe if the destinations cannot be tampered with. The interesting attacks here are not clever math, they are "can I make the money go to me". So the addresses are never taken from the person paying. The merchant's payout address is set by the merchant, once, when they register; Paybit cannot change it. The fee wallet and the fee rate are set by the platform and bounded by a hard cap written into the contract, so a merchant cannot quietly zero their own fee, and the platform cannot raise the ceiling past the cap without deploying a new contract that merchants have to opt into. The buyer supplies only which merchant they are paying and how much. Everything else the contract reads from its own storage.

who supplies what, on every payment:payer ──▶ merchant + amount only (can't name a destination)merchant ──▶ own withdraw address (Paybit can't redirect it)Paybit ──▶ fee wallet + fee, capped (merchant can't zero it) the contract reads both destinations from its OWN storage
The only thing the payer controls is the amount and which merchant. Both destinations come from the contract's own storage, so no input from the person paying can redirect a cent.

Two more guards earn their keep. A reentrancy lock wraps every function that moves money, so a malicious token that tries to call back into the contract mid-transfer just makes the whole transaction revert. And for ERC-20 tokens the contract measures how much actually arrived at each address rather than trusting the number it was asked to move, so the oddballs (tokens that skim a fee on every transfer, tokens like USDT that do not return the success flag the standard says they should) are accounted for at their real delivered value instead of an imagined one. The fee, by the way, is quoted in basis points, hundredths of a percent, which is why the split is a multiply and a divide rather than anything cleverer.

Bitcoin has no contracts#

Here is where the neat trick runs out, and I would rather say so than paper over it. Bitcoin has no smart contracts to split a payment atomically. If you want to accept BTC and still be non-custodial, you cannot route it through a contract, and you certainly cannot route it through a wallet Paybit controls, because then Paybit is holding Bitcoin and the whole promise is broken.

The way out is to stop holding keys at all. A merchant gives Paybit an extended public key, an xpub. The important word is public: an xpub can derive an unlimited run of fresh receiving addresses, but it cannot spend from any of them. For each invoice, Paybit derives the next address in the sequence (BIP32, the path m/0/i) and shows it to the buyer. The buyer pays that address directly. Because the address was derived from the merchant's own xpub, it is the merchant's own wallet: the coins land where only the merchant can spend them, and Paybit only ever had a public key that watches.

merchant's xpub (an extended PUBLIC key: it can watch, it cannot spend) │ Paybit derives a fresh address per invoice (BIP32 m/0/i)┌──────────┬──────────┬──────────┬──────────┐│ m/0/0 │ m/0/1 │ m/0/2 │ m/0/3 │└──────────┴──────────┴──────────┴──────────┘ │ │ │ │ ▼ ▼ ▼ ▼ every derived address is in the merchant's OWN wallet the payer sends there directly; Paybit never holds a key
On Bitcoin Paybit holds no keys. It derives a fresh address per invoice from the merchant's public xpub, so the buyer pays the merchant's own wallet directly and Paybit only ever watches.

The cost of that purity is that there is no fee to skim on the way through, because there is no way through: the money never passes Paybit. So on Bitcoin the platform fee stops being an on-chain split and becomes an ordinary bill, tracked in an off-chain ledger and settled separately, the way a SaaS subscription would be. It is the honest shape of the problem. You can have a Bitcoin payment that never touches the processor, or you can have the processor take a cut of it on-chain, but on a chain with no contracts you cannot have both. I chose never touching it.

The same fork in the road runs through every chain Paybit supports, and it is worth seeing all at once:

Chain familyHow the buyer paysWhere the money landsHow Paybit's fee is taken
Ethereum, Base, BNBone transaction to the splitter contractthe merchant's address, same transactionsplit on-chain, same transaction
Tronthe same, the TVM runs the same contractthe merchant's address, same transactionsplit on-chain, same transaction
Solanaone program instructionthe merchant's wallet, same instructionsplit on-chain, same instruction
Bitcoina plain transfer to a derived addressthe merchant's own wallet, directlyoff-chain ledger, billed later
Lightningpaying a BOLT11 invoicethe merchant's nodeoff-chain ledger, billed later

Watching without holding#

If Paybit never holds the money, it also never gets the tidy signal a custodian gets ("funds arrived in my account"). It has to earn that signal by watching the chain from the outside. A background daemon runs one watcher per chain, and each watcher does the same unglamorous loop: read new blocks, look for a payment that matches an open order, and count how deep it is buried before calling it done.

"Matches an open order" is doing real work. A watcher only credits a payment when the event's merchant, the token, and the order id all line up with an invoice it is expecting. Without the token check, a buyer could settle a hundred-dollar order by sending a hundred units of some worthless token; without the merchant and order-id check, anyone who knew an order id could try to claim it. And before any of that, the watcher refuses to even talk to a chain whose node reports the wrong network id, so a forked, test, or hostile endpoint cannot feed it events it would credit as real.

created detected confirmed │ │ │ ▼ ▼ ▼requires_payment_method ──▶ processing ──┬──▶ succeeded (amount ok) │ │ │ expires, still unpaid └──▶ requires_action (underpaid)canceleda reorg that drops the tx ──▶ processing returns to requires_payment_method
The life of a payment intent. It is created empty, flips to processing the moment a matching payment is seen, and only settles once it is buried deep enough. Underpayment and reorgs each have an honest exit.

Depth matters because a fresh transaction can be undone. A block can be orphaned in a reorganization, and a payment that looked final a second ago can vanish. So the watcher does not trust the block it first saw the payment in; on every pass it re-checks the transaction against the current chain, recomputes its depth from wherever the chain now says it lives, and only settles once it is buried under enough confirmations (twelve on Ethereum, a couple on Bitcoin, configurable per chain). If the transaction disappears from under it, the intent quietly rolls back to awaiting payment and the watcher rewinds to look for it again. Zero-confirmation payments are never accepted, because a zero-confirmation payment is just a suggestion.

One decision I keep coming back to: Paybit never auto-refunds an overpayment or an underpayment. It cannot, honestly, because the money already went straight to the merchant. An overpayment settles and the extra sits with the merchant; an underpayment is flagged for the merchant to reconcile rather than silently accepted. The split already happened on the buyer's transaction, and the fee was taken on the amount that actually moved, so there is nothing for Paybit to give back. It is the merchant's money and the merchant's call.

The money math, kept honest#

An invoice is priced in dollars but paid in crypto, so somewhere a price has to be trusted, and a price is the softest part of any of this. A single feed can lag, spike, or lie. So Paybit does not trust one: it cross-checks several independent price sources, takes the median, and refuses to move on a wild jump that only one unconfirmed source is reporting, keeping the last good price until a second source agrees. All of it runs in integer arithmetic, never floating point, because money and floats are old enemies. And the amount check that decides "paid in full" fails closed: a missing or unparseable expected amount can never accidentally settle an order, it can only fail to.

Verify, do not trust#

"We never hold your money" is exactly the kind of thing every custodian also says. The only version of that sentence worth anything is one you can check. So the parts of Paybit that carry the trust are public, split out into an open-core protocol repository: the contracts in full, the complete database schema, and the threat model. The parts that are just the business, the pricing engine, the billing, the checkout flow, stay private. Publishing the trust boundary gives away nothing about how the business runs, and it lets anyone confirm the two claims that matter.

PRIVATE core (the business) PUBLIC protocol (the trust proof)┌─────────────────────────────┐ ┌─────────────────────────────┐│ price engine │ │ the splitter contracts ││ billing / subscriptions │ │ the full database schema ││ checkout, orchestration │ │ threat model + non-custody │└─────────────────────────────┘ └─────────────────────────────┘ reproduce the deployed bytecode, read the schema, and verify non-custody + no-KYC for yourself
Open-core. The parts that prove the trust properties are public; the parts that are merely the business are not. You can verify non-custody and no-KYC without seeing anything commercial.

The first claim, non-custody, you check by reading the contracts and then confirming the contract deployed on-chain is really that source. A script compiles the source with Foundry and diffs the result against the live bytecode:

$_verify-bytecode.sh
Bash
forge build --silent                          # compile the contract from source
LOCAL=$(jq -r '.deployedBytecode.object' out/PaymentProcessor.sol/PaymentProcessor.json)
ONCHAIN=$(cast code "$ADDRESS" --rpc-url "$RPC")
# strip the trailing metadata hash, then compare the two.
# if they match, the code running at that address IS the source you just read.

The second claim, that Paybit asks to know as little about you as possible, you check by reading the schema. There is no identity table, no document store, no kyc_status, no date_of_birth. The strongest personal field anywhere is an optional customer email a merchant may attach to send a receipt, and it defaults to empty and is never verified. You do not have to believe a privacy promise you can grep for. The one place this bottoms out in trust rather than proof is logging inside the private backend, and the honest answer there is that you would trust an auditor's report on it, not my word, which is the right amount of trust to ask for.

The honest limits#

None of this is free, and a few of the seams are worth naming plainly.

  • The Bitcoin fee is a promise, not a mechanism. Because the money never passes Paybit, nothing on-chain forces a merchant to pay the off-chain fee they owe. That is the flip side of non-custody: I gave up the leverage of holding the money, and billing is now an ordinary business problem, with ordinary business remedies, not a cryptographic guarantee.
  • The platform still sets the fee. The rate is bounded by the contract and can never touch the principal, so it is a fairness question, not a safety one, but it is a trust assumption and I would rather label it than hide it.
  • A merchant can lie to their own customer. A merchant could mark a refund "paid" without really sending it. That misleads only their own buyer; because Paybit holds nothing, no third party can lose funds this way. The blast radius is one relationship, not the platform.
  • Confirmations cost time. Watching from the outside and waiting for depth means settlement is as slow as the chain plus the poll interval. There is no instant "approved" the way a card gives you, because the finality is real rather than promised.
  • There are no chargebacks, ever. Non-custodial and irreversible are the same coin. If you need the power to reach back into a settled payment and undo it, this is the wrong tool, on purpose.

If you want fiat settlement, reversibility, and a processor that will "sort it out" when something goes sideways, a custodian is genuinely the better choice. Paybit is for the opposite preference.

The trade#

Strip it all back and Paybit is one trade, made once and then defended everywhere. It gives up custody, and with custody it gives up every power custody buys: the ability to hold, to reverse, to freeze, to reassure a merchant that the money is safe with us. In exchange it can say the one thing almost no processor can say honestly: it cannot lose your money, cannot freeze it, and cannot be compelled to hand it over, because at no point does it ever have it.

Everything above, the contract that forwards in a single transaction, the addresses read only from storage, the xpub that watches without spending, the watchers counting confirmations from the outside, the public schema you can grep for what is missing, is just the machinery required to keep a promise of absence. The hard part was never taking the money. It was building all of this so it never has to.

References

  1. EIP-20: the ERC-20 token standard
  2. BIP-32: hierarchical deterministic wallets (xpub derivation)
  3. Foundry, the toolchain used to build and verify the contracts
  4. The Lightning Network
  5. Stripe's PaymentIntents, whose lifecycle the API mirrors

Related articles

Everything Around the Charge

The request that takes a payment is four lines. Everything that makes it safe to build a business on, so a retry never double-charges and a dropped webhook never loses an order, is the actual work. How I shaped Paybit's crypto payments API on the pattern Stripe proved.

Avatars from a Username

A follow-up to the Vasarely piece. How I generate deterministic avatars from a username, first as ordered-dithered identicons, then as halftone dot fields where the dot size does the drawing.

How I Turned a VPS into a Platform

A design decision, not a demo. Why I encoded a whole VPS as one Go CLI, pfrctl, instead of renting a PaaS or carrying a heavy self-hosted panel, and where that trade-off pays off.