On this page
The decision that shaped everything else
P2Flux is our own payments product: USDC payments and subscriptions on Base, with official PHP and JavaScript SDKs and a WooCommerce plugin on WordPress.org. It sits next to our client projects on our work page, and the product, documentation and contract source are public at p2flux.com.
The first decision was that P2Flux never holds the merchant’s money. There is no P2Flux balance, no payout day and nothing to withdraw. The buyer’s wallet pays the merchant’s wallet directly, and our fee is split out inside that same transaction.
The familiar alternative is to collect funds into a platform account, keep a ledger and pay out on a schedule. It works, but it makes the platform responsible for money that is not its own, with the reconciliation, security and compliance work that comes with that. We wanted a product where an outage could delay a payment but never leave funds anywhere except the merchant’s wallet.
Direct settlement in one transaction
A one-time payment goes through a small splitter contract. The buyer’s transaction moves USDC from their wallet to the merchant’s address and moves the 1% fee to our fee wallet in the same call. The contract holds no balance between calls. Either the whole payment happens or none of it does.
your server POST /v1/payments -> signed intent + reference
buyer wallet one transaction -> USDC to merchant, fee split out
your server POST /v1/payments/verify -> valid: true, three confirmations deep
your system marks the order paidThree lessons came out of this part.
- A transfer is not a payment. Anyone can send USDC to an address. A shop needs to know that this order was paid, in full, to the right place. Every payment carries a reference of 32 random bytes, and verification checks the transaction receipt against the signed intent: recipient, token, amount, the exact fee and the confirmation depth.
- The contract is the idempotency layer. It records each settlement by an id derived from token, recipient, amount and reference, and refuses a second one. That makes the most dangerous integration bug easy to explain: if a browser callback is lost, do not create a second intent. A new intent has a new reference, and the buyer can genuinely pay twice.
- “Not yet provable” is not “failed”. Until the transaction is deep enough, verification answers that the payment is still confirming. A missing receipt also means “keep waiting”, because telling a customer who has just paid that their payment does not exist is the worse mistake.
Refunds are a second payment
A settled blockchain payment cannot be reversed. In a non-custodial system a refund is a new transfer from the merchant’s wallet back to the payer, so the merchant sends it and pays its network fee in ETH. P2Flux prepares the terms from the original settlement and verifies the result, but never touches the money and charges no refund fee.
The trade-off is that nothing central stops a second refund of the same payment. P2Flux stores no orders and keeps no refund ledger, so your integration records refunds against the order and checks before it sends another.
Subscriptions from one wallet approval
Crypto has no card on file. A wallet pushes payments; it does not accept pulls from a merchant. The common workaround is to ask the customer to come back and pay every period, which is a reminder email, not a subscription.
The standard tool for pulls is the ERC-20 allowance: the owner calls approve, and the approved spender can later call transferFrom up to that amount. On its own it is too blunt for billing. An allowance says how much a contract may move, not to whom, how often or for what. So a P2Flux subscription has two layers.
| ERC-20 allowance | Signed authorisation | |
|---|---|---|
| What it is | An on-chain approve to the recurring contract | An EIP-712 message signed in the wallet |
| Costs gas | Yes, one transaction | No, a signature is not a transaction |
| Controls | Whether the contract can move USDC at all | Recipient, amount, period, start, end and a gas ceiling |
| How the customer stops it | Set it to zero: stops every subscription in that token | Call revoke(): stops this one subscription |
The authorisation is an EIP-712 typed message. The customer signs it once, and the recurring contract checks the signature again on every charge. The full definitions are in the public P2Flux contracts repository.
{
"RecurringAuthorization": [
{ "name": "payer", "type": "address" },
{ "name": "recipient", "type": "address" },
{ "name": "token", "type": "address" },
{ "name": "amount", "type": "uint256" },
{ "name": "period", "type": "uint48" },
{ "name": "start", "type": "uint48" },
{ "name": "end", "type": "uint48" },
{ "name": "salt", "type": "bytes32" },
{ "name": "maxGasReimbursement", "type": "uint256" }
]
}The contract enforces those terms exactly. The amount cannot go up, the period cannot get shorter and the recipient cannot change. A second charge inside one period reverts, and so does a charge outside the signed start and end. “One wallet approval” is the precise wording: setup is one on-chain approval plus one free signature, and every renewal after that runs without the customer.
What subscriptions taught us
- The period is the idempotency key. The contract allows one charge per period, so a retry after a timeout returns “already charged” instead of charging twice. There is no separate key for the merchant to manage.
- The merchant owns the schedule. P2Flux has no scheduler and no subscription database. Your billing system decides when a renewal is due and asks for the charge; our relayer submits it. In a Laravel app that is a scheduled job next to the rest of your billing logic, and retries, grace periods and dunning stay business decisions.
- Failures need different answers. Each charge result comes with an action: retry later for gas or network conditions, ask the customer when their balance or allowance is short, stop billing when the subscription is revoked or expired.
- Not every wallet is the same kind of account. A contract wallet validates signatures through ERC-1271, so its answer can change if it rotates owners. The contract asks again on every charge and treats a “no” as withdrawn consent. EIP-7702 added a trap: an ordinary account that delegates to code now has code. OpenZeppelin’s
SignatureCheckersends any account with code to ERC-1271 and never tries the plain signature, which would break a working subscription the moment its owner upgraded their wallet. Our contract detects the 7702 delegation marker and still accepts the account’s own key. - An unlimited allowance is a choice to make openly. Unlimited is the default, because a bounded allowance on an open-ended subscription runs out one day. Merchants can bound it to the end date or to a number of periods, at the cost of an extra wallet prompt later. Either way, the signed authorisation limits what can actually be taken.
Gas paid in USDC
On Base, network fees are paid in ETH. A buyer who holds only USDC cannot send a transaction at all, and a checkout that tells them to go and buy ETH first will lose them. The fix is to separate the person who signs from the person who sends.
There are two common patterns. ERC-4337 account abstraction lets a paymaster contract pay for a smart account’s operations. Token-level signatures work with the ordinary wallets people already have: the buyer signs an authorisation, a relayer submits the transaction and pays the ETH, and the contract collects the repayment in the token. P2Flux uses the second.
For a one-time payment, the buyer signs an EIP-3009 ReceiveWithAuthorization for the exact total, which USDC supports. The receive variant only executes when the caller is the payee, so nobody can copy the signature from a pending transaction and call the token directly. For a USDC-only customer starting a subscription, an EIP-2612 permit sets the allowance and an EIP-3009 authorisation pays the network cost, in one call.
Renewals work differently. The relayer submits the charge and pays the ETH up front, and the cost, converted at the Chainlink ETH/USD rate, is added on top of that period’s amount. Two ceilings apply: the maximum the customer signed and a hard cap of 0.05 USDC in the contract. If the quote is above either, nothing is sent and the charge waits for cheaper conditions.
- Put the price inside the signature. The buyer’s network fee is a quote that expires after a couple of minutes, and the expiry is part of what they sign. A stale quote cannot be executed by anyone, including us.
- Keep gas money apart from revenue. Our fee goes to a fee wallet. Network fees and reimbursements go to a separate gas treasury, so the cost of running transactions never mixes with profit.
- Sponsorship is an abuse surface. Paying gas for strangers means paying for their failed transactions too. We limit what can cost us unpaid gas per wallet: repeated reverts, failing simulations and more than one unresolved sponsored transaction at a time are refused with a retry time. Successful payments are not limited.
- Make it opt-in and discoverable. An integration requests USDC gas per payment and can ask a capabilities endpoint which networks and operations support it. Unsupported cases fail before the buyer sees anything.
When the platform never holds the money, an outage can delay a payment. It cannot put the funds anywhere except the merchant’s wallet.
A checklist if you are building payments
- Decide who holds the money first. It changes your data model, your support process and your risk more than any other choice.
- Keep one source of truth. P2Flux stores nothing: payment intents and subscription capabilities are signed tokens that carry their own contents, and every status is read from the chain.
- Let the contract enforce idempotency. One settlement per reference, one charge per period.
- Give the customer the off switch. Cancellation should be something they can do from their own wallet, without asking anyone.
- Write down what the integrator owns. The renewal schedule, refund records and order data belong to the merchant’s system. Say so in the docs.
Summary
Non-custodial payments move the hard problems rather than remove them. There is no payout ledger to run, but verification, idempotency, refunds and gas need careful design, and the integrator owns more of the lifecycle.
If you are adding payments, subscriptions or a wallet flow to your product and want a team that has shipped one of its own, send us a short brief.
Frequently asked questions
What does non-custodial payment mean?
It means the payment provider never holds the money. With P2Flux the buyer’s wallet pays the merchant’s wallet directly and the provider fee is split out in the same transaction, so there is no provider balance and no payout step.
How do crypto subscriptions work without a card on file?
The customer grants an ERC-20 allowance to a recurring contract and signs an EIP-712 authorisation that fixes the amount, recipient and billing period. The contract then allows one charge per period until the customer revokes the subscription or removes the allowance.
Can customers pay gas fees in USDC instead of ETH?
Yes, when the token supports signed authorisations such as EIP-3009 or EIP-2612. The customer signs instead of sending a transaction, a relayer submits it and pays the ETH, and the customer repays that cost in USDC inside the same transaction.


