Severity: High. From the 2026-07-29 cross-repo audit (functional/security/perf/style pass).
authorize (contracts/src/RAIL0.sol:275) and charge (:304) pull the buyer's funds with transferWithAuthorization:
IEIP3009(p.token).transferWithAuthorization(
p.payer, address(this), p.amount, 0, p.authorizationExpiry,
_authorizeNonce(paymentId, configHash), v, r, s
);
EIP-3009's transferWithAuthorization has no msg.sender restriction — receiveWithAuthorization exists in the EIP precisely to prevent third-party submission, and refund (:474) already uses it.
Failure scenario: the merchant broadcasts authorize; any mempool observer lifts (v, r, s) plus the deterministic nonce from the pending tx and calls token.transferWithAuthorization(payer, RAIL0, amount, 0, expiry, nonce, v, r, s) directly with a higher tip. The token moves amount from the buyer to the RAIL0 address and marks the nonce used forever; the merchant's authorize then reverts token-side, so no PaymentState is ever created. There is no sweep/rescue function and every payout path pays out only against a live _state[paymentId], so the balance is unrecoverable — pure griefing, the buyer loses the full amount, attacker cost is gas. Same applies to charge.
Suggested fix: switch both call sites to receiveWithAuthorization (the buyer signs the identical TransferWithAuthorization digest; to == msg.sender == RAIL0 then holds). Fund-moving change → version bump + full coordinated rollout (one version across all active chains). The README security model (README.md:288-300) should also mention mempool front-running explicitly.
Severity: High. From the 2026-07-29 cross-repo audit (functional/security/perf/style pass).
authorize(contracts/src/RAIL0.sol:275) andcharge(:304) pull the buyer's funds withtransferWithAuthorization:EIP-3009's
transferWithAuthorizationhas nomsg.senderrestriction —receiveWithAuthorizationexists in the EIP precisely to prevent third-party submission, andrefund(:474) already uses it.Failure scenario: the merchant broadcasts
authorize; any mempool observer lifts(v, r, s)plus the deterministic nonce from the pending tx and callstoken.transferWithAuthorization(payer, RAIL0, amount, 0, expiry, nonce, v, r, s)directly with a higher tip. The token movesamountfrom the buyer to the RAIL0 address and marks the nonce used forever; the merchant'sauthorizethen reverts token-side, so noPaymentStateis ever created. There is no sweep/rescue function and every payout path pays out only against a live_state[paymentId], so the balance is unrecoverable — pure griefing, the buyer loses the full amount, attacker cost is gas. Same applies tocharge.Suggested fix: switch both call sites to
receiveWithAuthorization(the buyer signs the identicalTransferWithAuthorizationdigest;to == msg.sender == RAIL0then holds). Fund-moving change → version bump + full coordinated rollout (one version across all active chains). The README security model (README.md:288-300) should also mention mempool front-running explicitly.