Severity: High. From the 2026-07-29 cross-repo audit.
The refund nonce is derived from the current refundable balance (contracts/src/RAIL0.sol:480, _refundNonce at :566-572):
_refundNonce(paymentId, _configHash[paymentId], st.refundableAmount)
// = keccak256(abi.encode(_REFUND_NONCE_PREFIX, paymentId, configHash, refundableAmount))
It includes neither the refund amount nor a counter — and refundableAmount is not monotonic: capture increases it (:391).
Failure scenario (verified against the code): authorize 100 → capture(50) (refundable = 50) → refund(50) (token permanently burns nonce N(…, 50); refundable = 0) → capture(50) (refundable = 50 again) → any subsequent refund derives the same N(…, 50) and the token reverts "authorization is used". The payee cannot vary the amount to escape (amount isn't in the nonce) and cannot change the state once capturableAmount is exhausted: that residual is permanently non-refundable, and an open dispute on it can never be resolved by the merchant.
RAIL0.sol:39-41 and README.md:157 assert nonce uniqueness "per partial refund", which only holds for a strictly-decreasing balance. No test exercises a revisited refundableAmount (test_Refund_Partial_Multiple uses 100 → 70 → 0, all distinct).
Suggested fix: include a per-payment refund counter (or counter + amount) in the nonce derivation. Fund-moving change → version bump + coordinated rollout. Until then the gateway can guard the sequence app-side (refuse a refund whose derived nonce was already consumed — authorizationState is even declared in IEIP3009).
Severity: High. From the 2026-07-29 cross-repo audit.
The refund nonce is derived from the current refundable balance (
contracts/src/RAIL0.sol:480,_refundNonceat:566-572):It includes neither the refund amount nor a counter — and
refundableAmountis not monotonic:captureincreases it (:391).Failure scenario (verified against the code): authorize 100 →
capture(50)(refundable = 50) →refund(50)(token permanently burns nonceN(…, 50); refundable = 0) →capture(50)(refundable = 50 again) → any subsequentrefundderives the sameN(…, 50)and the token reverts "authorization is used". The payee cannot vary the amount to escape (amount isn't in the nonce) and cannot change the state oncecapturableAmountis exhausted: that residual is permanently non-refundable, and an open dispute on it can never be resolved by the merchant.RAIL0.sol:39-41andREADME.md:157assert nonce uniqueness "per partial refund", which only holds for a strictly-decreasing balance. No test exercises a revisitedrefundableAmount(test_Refund_Partial_Multipleuses 100 → 70 → 0, all distinct).Suggested fix: include a per-payment refund counter (or counter + amount) in the nonce derivation. Fund-moving change → version bump + coordinated rollout. Until then the gateway can guard the sequence app-side (refuse a refund whose derived nonce was already consumed —
authorizationStateis even declared inIEIP3009).