Overview
A payment router is a deceptively simple object. It accepts a single incoming transaction and distributes the value across multiple recipients — sellers, a protocol fee, sometimes a referrer or an affiliate — inside one atomic call. There is no vault, no yield, no oracle, no governance. On paper it is the least intimidating contract an auditor will ever open.
That simplicity is exactly what makes it unforgiving. A lending protocol can pause. A vault can be upgraded behind a proxy. A DAO can vote to migrate. A payment router that is immutable and ownerless — which is the only honest way to build one, because a payment rail with an admin key is a custodian wearing a costume — has none of those escape hatches. Every wei that enters must leave in the same transaction, to the correct parties, or the whole thing must revert. There is no third outcome, and there is no one to call when it goes wrong.
This article maps the vulnerability surface of on-chain payment routers and works through the design decisions that close each hole. The running example is Shaka Deal, an immutable ETH payment router deployed on Ethereum mainnet whose contract is small enough to reason about in full — roughly 170 lines — and whose source is verified on-chain. It routes the total amount of a transaction to every party at once and settles an optional referrer and affiliate in the same call. Shaka Deal is used here because it makes the trade-offs explicit rather than hiding them behind size, and because a public, verified contract is the only kind worth dissecting in the open.
The Threat Model of a Router Is Not the Threat Model of a Vault
Most audit checklists are written for contracts that hold value over time. They ask about accounting drift, share-price manipulation, oracle staleness, initializer protection, proxy storage collisions, and admin key custody. An immutable router that never holds funds fails to trigger almost every item on that list — not because it is safe, but because its risks live somewhere else entirely.
The questions that actually matter for a router are four:
- Custody — does the contract ever hold a balance an attacker could reach?
- Value conservation — does the sum of every outgoing transfer exactly equal what came in, under every rounding and failure path?
- Payer authority — can the party sending the money change who gets paid, or how much?
- Liveness — can any single party permanently prevent a legitimate payment from settling?
None of these have a “just upgrade it” backstop on an immutable contract. Each has to be answered definitively, in the deployed bytecode, forever. The rest of this article is those four questions, in order, with the mechanics of getting each one wrong and right.
1. Custody: The Balance That Should Never Exist
The strongest property a payment router can have is that its own balance is always zero. If the contract never retains value between transactions, an entire category of attacks — balance-drain reentrancy, accounting bugs that leak stored funds, forced-balance logic errors — cannot apply, because there is nothing accumulated to steal.
Achieving this is a matter of discipline in a single function: every wei that arrives in pay() must be forwarded within the same call. Shaka Deal enforces exact payment and forwards the entire msg.value in one pass:
uint256 total = deal.total;
uint256 shakaFee = (total * FEE_BPS) / BPS_BASE;
uint256 referralFee = (total * REFERRAL_BPS) / BPS_BASE;
uint256 grandTotal = total + shakaFee + referralFee;
require(msg.value == grandTotal, "ShakaDeal: incorrect ETH amount");
There is no >=, no accepted overpayment, no change path, no dust remainder. The caller sends precisely grandTotal or the transaction reverts. This matters more than it looks: an overpayment path is a stored-balance path, and a stored balance is a target. By refusing anything but the exact amount, the contract removes the possibility of accumulating a reachable balance through generosity or miscalculation.
The complementary half of the custody guarantee is refusing unsolicited ETH. A router with a permissive receive() invites exactly the stored balance it works so hard to avoid:
receive() external payable {
revert("ShakaDeal: use pay()");
}
Direct sends bounce. The only way value enters the contract is through pay(), and the only thing pay() does with value is forward it. Force-feeding via selfdestruct or a coinbase reward remains possible — it always is, on every contract — but with no owner and no sweep function, any ETH pushed in that way is simply locked forever, harming no one but the sender. That is an accepted, understood invariant of an immutable no-owner design, not a bug to be patched.
Once “the contract never holds a balance to protect” holds, reentrancy stops being about theft. Shaka Deal still applies the discipline — checks-effects-interactions, with the state flag set before any external call, plus OpenZeppelin’s ReentrancyGuard:
deal.paid = true; // CEI — state change before external calls
But the blast radius is bounded by architecture, not by vigilance. A recipient that re-enters pay() hits the guard. A recipient that re-enters cancel() mid-payment hits the already-set paid flag and reverts. There is no stored pool to drain because there is no stored pool at all. This is the single highest-leverage decision in router design: make zero-custody a hard invariant, and most of the classic attack surface evaporates before you write a single guard.
2. Value Conservation: The Arithmetic the Payer Can Verify
A router that divides one payment into many owes the payer an arithmetic guarantee: the sum of every outgoing transfer equals the incoming amount, exactly, with nothing created, nothing lost, and nothing stranded in the contract. This is the invariant a fuzzer should hammer above all others, and it is where naive implementations quietly fail.
The failure mode is rounding. Fees computed by integer division round down, and if each share is computed independently and then summed, the rounding residual has to live somewhere. Compute the fee, compute the referrer share, compute the affiliate share, add them up, and you may find the total is one or two wei short of — or over — what the payer sent. On a contract that must forward msg.value exactly, a one-wei discrepancy is not cosmetic: it is either a failed transfer or a wei permanently trapped.
The robust pattern is to never compute the last share independently. Compute every bounded share by division, then derive the final share by subtraction, so the arithmetic closes by construction. Shaka Deal does this for its own fee, which is the residual after the referrer and affiliate have been paid out of the fee pool:
uint256 feePool = shakaFee + referralFee; // the 2%
uint256 referrerAmount = 0;
uint256 affiliateAmount = 0;
// ... referrer and affiliate paid here, each setting its amount ...
// Shaka gets the rest — by subtraction, so no wei is ever lost or overspent.
uint256 shakaAmount = feePool - referrerAmount - affiliateAmount;
_sendETH(SLOT_FEE, FEE_WALLET, shakaAmount);
Because shakaAmount is defined as whatever remains, the sum of the fee-pool payouts is identically equal to feePool, regardless of how the individual divisions rounded. The recipients are paid their exact cached amounts, whose sum is the deal total. Add the fee pool and you have grandTotal, which is exactly msg.value. Conservation holds not because the developer checked every rounding case by hand, but because the structure makes any other outcome impossible.
There is a subtlety worth stating explicitly for anyone auditing this class: underflow is impossible in that subtraction only if the bounded shares are provably smaller than the pool they come out of. Here the affiliate share is 0.2% of total and the referrer share is 1% of total, both drawn from a 2% pool — so referrerAmount + affiliateAmount can never exceed feePool. If a router computes its subtracted share from a pool that does not strictly dominate the summed parts, the “safe” subtraction becomes a panic revert on some inputs. The pattern is only safe when the bound is real.
For very small totals the fees round to zero entirely — below 100 wei, every ⌊total * bps / 10000⌋ is zero, and the deal settles fee-free. This is economically irrelevant, since gas costs dwarf any total that small, but a thorough audit notes it rather than discovering it in production. Conservation still holds: zero fees plus exact recipient amounts still sum to msg.value.
3. Payer Authority: Who Decides Who Gets Paid
The party sending money to a router is not necessarily the party who defined the deal. In Shaka Deal, a seller (or their agent) creates the deal; a buyer pays it. This split between creation and payment is where a whole class of manipulation lives, and it is the reason the router’s parameters must be immutable between those two moments.
The attack to defend against is the payer redirecting value. If the buyer could pass the referrer or affiliate address — or the recipient list, or the amounts — as arguments to pay(), they could route the seller’s referral bonus to themselves, or nominate their own wallet as the affiliate, or worst of all, alter who receives the principal. The defense is structural: everything about the payout is fixed at creation and stored, and pay() takes nothing but the deal identifier.
function pay(bytes32 dealId) external payable nonReentrant {
Deal storage deal = deals[dealId];
// every payout parameter is read from storage, none from calldata
The recipients, the amounts, the referrer, the affiliate, and the reference are all read from the Deal struct written at creation. The payer’s only inputs are which deal and how much — and the amount is checked against the stored grandTotal, so even that is not a free parameter. A buyer cannot smuggle a different affiliate into the call because there is no affiliate parameter in the call. The payer’s authority ends at “pay this exact deal or don’t.”
This is also what makes the affiliate model trustworthy rather than gameable. The affiliate is resolved once, at creation, from whatever referral link was used, and frozen into the struct. There is no runtime path — not for the payer, not for anyone — to change it afterward. Attribution that cannot be rewritten at payment time is attribution you can build a payout program on.
4. Liveness: The Griefing Surface of a Multi-Party Transfer
The subtlest risk in a router is not theft — the custody and conservation properties handle that — but denial of settlement. A payment that fans out to several external addresses makes several external calls, and every external call is a place where a hostile or broken recipient can try to sabotage the transaction. The design question is: when a transfer fails, what should happen to the rest of the payment?
The wrong answer is to treat every payee identically. If a single misbehaving party can always revert the whole deal, then any recipient — or anyone who can become a recipient — holds a veto over settlement. If instead the contract ignores every failure and pushes on, it will route money past the very party that was supposed to receive it. Neither blanket policy is correct. The right design is deliberate asymmetry: different classes of payee get different failure semantics, chosen according to what a failure of that class actually means.
Shaka Deal draws the line in a specific and defensible place.
Core parties: failure must revert
The principal recipients and the referrer are paid with full gas, and any failure reverts the entire payment:
function _sendETH(bytes32 slot, address to, uint256 amount) internal {
if (amount == 0) return;
(bool ok, ) = to.call{value: amount}("");
if (!ok) revert TransferFailed(slot, to, amount);
}
The reasoning: if a core recipient’s transfer fails, the deal is fundamentally wrong — a bad address, a contract wallet whose receive() reverts, a party that cannot be paid. Settling anyway would mean routing the seller’s money somewhere other than the seller. Refusing to settle, and refunding the payer in full via the revert, is strictly safer than paying the wrong party. The referrer is treated with the same severity on purpose: the referral share belongs to the deal creator, and the protocol never quietly pockets it by letting a failed referral transfer slide. Fail loud, refund fully, settle nothing.
The affiliate: failure must not revert
The affiliate is the one payee whose failure is handled differently, and understanding why is the crux of good router design. The affiliate is a party the payer never chose and the seller may not control — a third party earning a small share for having grown the network. If a hostile affiliate contract could revert on receipt, it could block otherwise-valid payments; if it could burn unbounded gas, it could grief every deal it was attached to. So the affiliate transfer is both non-blocking and gas-capped:
function _trySendAffiliate(address to, uint256 amount) internal returns (bool) {
if (amount == 0) return true;
(bool ok, ) = to.call{value: amount, gas: AFFILIATE_GAS}("");
return ok;
}
And at the call site, failure simply reroutes the share and lets the deal settle:
if (deal.affiliate != address(0)) {
affiliateAmount = (total * AFFILIATE_BPS) / BPS_BASE;
if (!_trySendAffiliate(deal.affiliate, affiliateAmount)) {
affiliateAmount = 0; // reverted to Shaka's share
}
}
The gas cap (AFFILIATE_GAS = 50_000) is generous enough for an externally-owned account or a simple smart wallet, but low enough that a contract designed to consume gas cannot drag down the transaction. On failure, the 0.2% falls back into the fee residual — recovered by the same subtraction that guarantees conservation — and the deal settles normally. This is the only case in the entire contract where a fee payee’s share is absorbed, and it is absorbed precisely so that the payee least entitled to a veto cannot exercise one.
The asymmetry is the whole point. Core parties get atomic all-or-nothing because paying the wrong principal is unacceptable. The optional affiliate gets best-effort delivery because a griefing affiliate must never be able to block a legitimate payment. An auditor reviewing any router should interrogate exactly this: for every external call, if this callee is adversarial, what can it do — and is the failure policy for that call the correct one for what a failure means.
Diagnosability is a liveness property too
There is a second, quieter liveness concern that a minimal router surfaces, and it is the kind of finding that dominates when the severe categories are structurally absent. When an atomic payment reverts because one recipient rejected the transfer, the payer needs to know which address blocked it — otherwise the deal is permanently unpayable with no way to diagnose the cause. A generic revert string turns a fixable data-entry mistake into an opaque dead end.
Shaka Deal encodes the failing step into a custom error:
error TransferFailed(bytes32 slot, address recipient, uint256 amount);
bytes32 private constant SLOT_FEE = "FEE";
bytes32 private constant SLOT_REFERRAL = "REFERRAL";
The slot names the exact stage that failed — "FEE", "REFERRAL", or the recipient index bytes32(i) — and the error carries the offending address and amount. A failed payment now reverts with a decodable reason that identifies the party at fault, while still refunding the payer in full. No funds move; the humans can see why. On an immutable contract with no remediation path, making failures legible is not a nicety — it is the difference between a deal a front-end can help the user fix and a deal that is silently, permanently stuck.
The Cost of Immutability
Every property above is sharpened by the decision that has run underneath this entire article: no owner, no upgrade path, no pause. It is worth being honest about what that decision costs, because the cost is real and a serious review names it rather than pretending immutability is free.
An immutable router cannot fix a mistake. If the fee wallet’s key is lost, fees route into an address no one controls, forever. If a party supplies a valid-but-wrong recipient address, the payment to it is irreversible. There is no function to sweep mistakenly locked ETH, no way to correct a deal after creation other than the creator cancelling an unpaid one, and no administrative recourse of any kind once value has moved.
function cancel(bytes32 dealId) external {
Deal storage deal = deals[dealId];
require(deal.creator != address(0), "ShakaDeal: deal does not exist");
require(msg.sender == deal.creator, "ShakaDeal: not creator");
require(!deal.paid, "ShakaDeal: already paid");
require(!deal.cancelled, "ShakaDeal: already cancelled");
deal.cancelled = true;
emit DealCancelled(dealId, msg.sender);
}
cancel() is the entire remediation surface, and notice how tightly it is scoped: creator only, unpaid only, irreversible, and it moves no funds — it flips a flag. Even the one corrective action the contract offers is constrained so that it cannot become an attack. A creator cancelling can front-run an in-flight payment, but the payer is made whole by the resulting revert, so the worst case is a wasted quote, not a lost fund.
The reason to accept these costs is that the alternative is worse. An upgradeable payment router with an admin key is not a payment router; it is a custodian that can rewrite the rules between the moment you agree to a deal and the moment you pay it. The immutability is what lets a buyer trust that the deal they were quoted is the deal they will pay — that no owner reached in and changed the recipients, the fee, or the split after the fact. For a payment rail, that guarantee is the product. The absence of an owner is not a missing feature; it is the feature.
This is the trade every immutable-router design makes, and it is why the four properties matter so much. When there is no one to fix a mistake, the code has to be right the first time. Custody has to be zero because there is no owner to rescue trapped funds. Conservation has to hold by construction because there is no migration to true up a rounding drift. Payer authority has to be locked because there is no governance to reverse a manipulated payout. Liveness has to be engineered because there is no admin to unstick a griefed deal. Immutability does not remove the need for these guarantees — it removes every fallback if you get them wrong.
An Audit Checklist for Payment Routers
Distilled from the above, the properties a reviewer should establish for any on-chain payment router:
- Zero custody. Confirm the contract’s balance is provably zero after every call. Verify exact-payment enforcement (no
>=, no change path) and a revertingreceive(). Fuzz for any input that leaves a residual balance. - Conservation by construction. Confirm the final share is derived by subtraction from a pool that provably dominates the summed sub-shares, so underflow is impossible and the outputs sum to
msg.valueunder all rounding. Make this the primary invariant test. - Locked payer authority. Confirm every payout parameter — recipients, amounts, referrer, affiliate — is fixed at creation and read from storage, never taken from the payment call. There must be no runtime path for the payer to redirect value.
- Asymmetric failure handling. Confirm core recipients and any share that belongs to a deal party revert on failure (no partial payout, payer refunded). Confirm any optional, payer-unchosen party is non-blocking and gas-capped so it cannot veto or grief settlement.
- Legible failures. Confirm a failed transfer reverts with a decodable reason identifying the exact party at fault, so a stuck deal can be diagnosed and re-created rather than silently abandoned.
- Named immutability costs. Confirm the review explicitly documents every irreversible failure mode — lost fee-wallet key, wrong-but-valid address, permanently locked force-sent ETH — as accepted invariants rather than undiscovered risks.
- Bounded external calls. Confirm the recipient set is capped so the number of external calls per transaction is bounded, and collision-resistant deal identifiers prevent cross-deployment or front-running reuse.
A router that satisfies all seven is not a router with zero findings — it is a router whose remaining findings are about ergonomics and documentation rather than fund safety. On a contract this exposed, that is the correct place for the findings to be.
Conclusion
The reason a payment router is worth this much scrutiny despite its size is that size and risk are inversely related here. A large protocol distributes its risk across many components, any one of which can be patched. A minimal, immutable router concentrates all of its risk into one atomic function that can never be changed. Every line is load-bearing, and the absence of a safety net means the guarantees have to be structural — built into the shape of the code, not bolted on as checks.
Shaka Deal is a useful reference precisely because it makes those structural choices legible: zero custody by exact-payment and a reverting fallback, conservation by subtraction from a dominating pool, payer authority locked by reading every parameter from storage, and liveness protected by an asymmetry that reverts for core parties and gracefully degrades for a griefing affiliate. None of these are clever. All of them are deliberate. That is what auditing a payment router comes down to — not finding the exotic bug, but confirming that each of the four properties is guaranteed by construction rather than by hope, on a contract that will run exactly as written for as long as the chain exists.
For teams building or reviewing this class of contract, the discipline transfers directly. And for anyone who wants to see the pattern deployed and verified in the wild rather than described in the abstract, the Shaka Deal contract is on Ethereum mainnet, source-verified, and small enough to read start to finish in the time it took to read this article.