PayXor
Back to Blog

The fee comes out of the signed amount

pay reads feeBasisPoints, sends that share of q.amount to feeReceiver, and sends the remainder to the payee. Initialize stores 100, which is one percent.

November 5, 2026•PayXor Team

The landing page shows 1% per payment, taken out of the payment, with the rest going to the payee. That 1% is not added on top of the price. initialize stores 100 basis points. pay reads the stored value, takes it out of the signed q.amount, and pulls the token twice.

A frosted-glass card on a dark navy field, split by a thin gold line into a wide cyan pane and a narrow gold edge.

The signed amount is the gross

The Quote struct has no fee field. The EIP-712 type string is:

Quote(bytes32 appId,bytes32 productId,uint8 mode,address token,uint256 amount,address payee,bytes32 entitlementId,uint64 duration,uint64 expiresAt,uint256 quoteId,address payer,uint64 deadline,uint256 chainId)

amount is the gross the payer's balance has to cover. The signature binds that number. Backend-Authoritative Pricing is why the payer cannot edit it. A receipt with custom amounts is the one product where the gross is not the stored price. The fee is still a share of whatever was signed. See Let a receipt take the amount the buyer names.

What pay transfers

After the signature check, pay marks the quote id used and calls calculateFeeAndNet:

unchecked {
    fee = (amount * $.feeBasisPoints) / BASIS_POINTS_DIVISOR;
    netAmount = amount - fee;
}

BASIS_POINTS_DIVISOR is 10000. One hundred basis points is 1%. Ten thousand basis points is 100%.

pay then pulls q.token from q.payer twice. The fee goes to the stored fee receiver. The net goes to q.payee:

if (fee > 0) {
    IERC20(q.token).safeTransferFrom(q.payer, cachedFeeReceiver, fee);
}

IERC20(q.token).safeTransferFrom(q.payer, q.payee, netAmount);

The first transfer is skipped when the fee is zero. The second transfer always runs. The two amounts add back to q.amount, so the allowance has to cover the gross once. It does not have to cover the gross plus a fee. useTokenPayment sets needsApproval when the allowance is below the price, or below the custom amount when you pass one, and approve() grants the maximum uint256. See From wallet connect to entitlement, in two React hooks.

A frosted-glass wallet on a dark navy field sending a wide cyan stream into a large tile and a narrow gold stream into a smaller tile.

A price of one USDC is 1000000 at 6 decimals. At 100 basis points the fee is 10000 and the payee receives 990000. A price of ten USDC is 10000000, the fee is 100000, and the payee receives 9900000. The fee-management test sets the fee to 500 basis points and pays 100 USDC, which is 100000000. The payee balance equals the net, 95000000. The fee receiver balance equals the fee, 5000000. In that fixture the fee receiver is the owner account.

Both pulls sit in the same transaction. A token that returns false, or that reverts, rolls the call back through safeTransferFrom. The quote id is not left marked, and no entitlement is written.

The fee lives in contract storage

A long frosted-glass bar on a dark navy field, with a short cyan glow at the left end and a gold cap at the right end.

feeBasisPoints() and feeReceiver() read FeesStorage. initialize calls _initializeFees with the fee receiver address and 100. The fee receiver is its own argument. It is the owner only when the deployer passes the same address for both, which the test fixture does. A zero fee receiver reverts with InvalidFeeReceiver during initialize and during setFeeReceiver.

setFee is onlyOwner. A value above 10000 reverts with FeeExceedsMaximum. 0 and 10000 are accepted. A caller who is not the owner reverts with OwnableUnauthorizedAccount. The call emits FeeUpdated with the previous stored fee and the new value.

setFeeReceiver is onlyOwner and emits FeeReceiverUpdated. The two recipients are independent. When they are the same address, that address receives both transfers.

pay reads the stored basis points when it runs. A quote signed at 100 basis points still splits at the value stored at pay. The signature cannot freeze the fee, because the fee is not in the hash.

calculateFee, calculateNetAmount, and calculateFeeAndNet are public views of that same storage. A preview matches the next pay until setFee changes the stored value.

The quote row records 100 basis points

A sealed frosted-glass card holding one cyan disc, separated by a glass pane from that disc split into a large cyan piece and a gold sliver.

POST /api/quote computes the split with a constant of 100 and stores it on the quote row. The signed quote is unchanged:

const FEE_BASIS_POINTS = 100;
const feeAmount = (totalAmount * BigInt(FEE_BASIS_POINTS)) / BigInt(10000);
const netAmount = totalAmount - feeAmount;

amount and total_amount are the gross. fee_amount and net_amount are the split. The JSON response is the quote and sigBackend. It does not add a fee on top of quote.amount. The payee on that quote is the app receiver_address.

The admin stats route sums fee_amount for fees and uses net_amount for net revenue, and it only counts quotes whose status is used. get_stats on @payxor/mcp-admin reads that route. See From a question to a signed quote, in two MCP servers. Those figures are the split written when the quote was issued. The token movement is the split pay computes from storage.

While storage stays at 100, the row and the transfers match, including the floor. If the owner changes feeBasisPoints, the row still shows the 100-basis-point split and the transfers follow the new value.

Integer division leaves the remainder with the payee

A frosted-glass tray on a dark navy field holding whole cyan discs, with a few gold specks left in the same tray.

The division floors. The fee is that floored product. The net is amount - fee, so the remainder stays with the payee.

An amount of 50 at 100 basis points has a fee of 0 and a net of 50. The fee transfer is skipped. The payee receives the whole 50. An amount of 199 has a fee of 1 and a net of 198.

At 0 basis points the fee transfer is skipped and the payee receives q.amount. The zero-fee payment test expects that payee balance and an unchanged fee-receiver balance. At 10000 basis points the fee equals the amount and the net is 0. setFee(10000) is accepted. The payee transfer still runs, with a zero amount.

The Paid event still carries the gross

The split runs before _processPaymentMode. Session, feature, pass, and receipt all move tokens the same way. The mode only decides what is stored after the transfers. A session is a timestamp the contract keeps, A feature is a boolean on its entitlement, and A pass is a boolean the contract keeps are those writes.

Paid emits q.amount, not the net. validUntil is still 0 except for a session. The receiptHash is hashed from that same gross. Let a receipt take the amount the buyer names shows the hash inputs.

Create the app from the dashboard. The price you set is what the wallet pays. Read feeBasisPoints on the contract for that chain when you need the share the payee will receive.