Skip to main content
The customer receives a QR code and a one-time address. They can pay from any wallet, any exchange withdrawal, or a hardware wallet with no dApp support. The keeper deploys the forwarder at that address and sweeps 100% of the balance into the merchant’s pool. This is the rail that makes the product work for real crypto users, and it is the rail that costs money to run.

Properties

The forwarder

InvoiceForwarder sits at a CREATE2 address salted on paymentId. Its init code commits the destination pool, so the address itself proves where the money can go. It is sweep-only. There is no refund(), no refundTo, no refundDeadline, no Refunded event, and no window logic. Inbound funds move 100% into the pool as soon as the deposit confirms.
The forwarder stays callable after its first sweep, and this is deliberate. An earlier refunded[token] latch was removed precisely because it could strand late funds, and late payments to already-settled addresses are an expected event — exchange withdrawal latency and re-pasted addresses both produce them. A one-shot forwarder turns every late payment into permanent unrecoverable loss.
That constraint is why a cheaper constructor-sweep design was rejected. A zero-code contract still has a nonce of 1, and CREATE2 refuses to deploy over a non-zero nonce, so the address could never be reused.

Flow

1

Quote and derive

create-pool-deposit returns the forwarder address, derived from the verified pool, plus a binding bundle. The client re-derives both and refuses to display the address on mismatch.
2

Customer pays

Any transfer method. The address has no code at this point; it is just an address that will later have code.
3

Indexer observes

pool-indexer-poll watches the address. Statuses on pool_deposit_intents cover the non-happy paths: waiting, confirming, underpaid, overpaid, wrong_token, paid, swept, expired, expired_paid_late, held_sanctioned.
4

Keeper deploys and sweeps

pool-sweep-keeper deploys the forwarder and sweeps. These are separate transactions today and should be merged into one, which saves the 21,000-gas intrinsic cost of the second.
5

Capture

Value may be delivered only once the sweep confirms. Never on first-seen.

Cross-network and currency-agnostic acceptance

The rail accepts value, not a specific token on a specific chain. A customer who pays the right value in a different enabled stablecoin, or on a different enabled chain, is detected and reconciled rather than rejected. Wrong-token deposits get an explicit status and surface to the merchant rather than vanishing.

Late payments

Expiry is a quote lock only. The counterfactual address never expires on-chain and accepts funds forever. A payment that confirms is never rejected because a timer elapsed — the chain is the source of truth. Two known gaps, both tracked:
WATCH_STATUSES in pool-indexer-poll omits swept and settled. Once an intent terminalises, its forwarder stops being watched forever, so a payment arriving afterwards is never observed — no event, no status change, no webhook. The funds are not lost, because the forwarder is deliberately re-callable, but nothing surfaces them.
The second gap is policy rather than a bug: a post-expiry arrival is currently honoured at the expired quote’s amount with no time limit. The locked decision is to re-quote at the current rate and re-check the tolerance band instead.

Minimums

Because the sponsored gas is a fixed cost per invoice while revenue is a percentage, small payments lose money. A per-chain floor exists in _shared/railEconomics.ts.
The shipped floor values are wrong by 62–127×. The file assumes 230,000 gas for deploy plus sweep against a measured 495,529, and it sets the floor equal to the gas cost rather than dividing by the take rate. A 0.30Ethereumorderearns0.30 Ethereum order earns 0.00225 against $0.139 of gas.
The corrected model computes everything live at quote time and, when an amount clears the connect floor but not the deposit floor, offers the connect rail rather than refusing the chain. Once the merchant bears the cost, the binding constraint becomes their configured tolerance rather than the platform’s break-even. See Who pays what.

A third rail, designed

Per-customer addresses — one forwarder per customer rather than per invoice — are designed as an opt-in third rail. They amortise the deploy across many deposits and shrink the indexer’s watch set considerably. The trade-off is why they cannot be the default: attribution degrades from exact to customer plus amount plus time window. The address identifies who, not which order. That suits balance top-ups, subscriptions, and marketplaces; it is wrong for one-shot e-commerce. They also invert the cloning arithmetic. See Clone decisions.