feat(validation): require a minimum refund window between the expiries - #53
Conversation
authorizationExpiry == refundExpiry was accepted — the ordering check uses `>`, not
`>=` — which collapses the refund/dispute window to zero: both become unreachable the
instant the authorization ends, so a payment could be created that is refundable in
name only.
_validatePayment now requires refundExpiry - authorizationExpiry >=
MIN_REFUND_WINDOW, a new public constant set to 1 days.
Days, not hours, as the metric: the window has to survive a buyer noticing a problem,
a merchant responding and a transaction confirming, and a window measured in hours is
one the buyer can lose to a weekend. There is a test asserting a 6-hour window is
refused even though it is non-zero.
One day is a FLOOR against a collapsed window, not a recommendation — the README's
guidance stays 14–30 days aligned with consumer-protection practice. Chosen so it
cannot reject what integrators already produce: the gateway's defaults are a 7-day
authorization and a 30-day refund window, a 23-day gap. The whole existing test
corpus passes unchanged, which is the evidence that the floor is not disruptive.
The subtraction cannot underflow: the ordering check immediately above already
guarantees refundExpiry >= authorizationExpiry.
Gas: +328 on authorize/charge (151,848 → 152,176 measured on
test_Authorize_Success), and nothing on any other entrypoint — the check lives only
on the creation path.
BREAKING for any caller that relied on equal expiries. Two things this PR does NOT
do, deliberately, because they are release decisions across six live deployments:
- VERSION is left at 1.3.0. It must be bumped before deploying, since it is part
of the EIP-712 domain and every configHash depends on it.
- the gateway's Payment#validate is not mirrored, so a hand-built payment with too
tight a window would fail on-chain rather than as a 422. Worth a follow-up.
Replaces test_Validation_AcceptsExpiriesEqual, which existed to lock in the old
behaviour. New tests cover: equal expiries rejected, the boundary on both sides
(exactly MIN_REFUND_WINDOW accepted, one second under refused), an hours-long window
refused, and charge rejecting the same shape — a validation added to only one
entrypoint being the classic miss.
Suite: 117 tests pass. forge fmt --check clean; forge lint reports the same 9
pre-existing block-timestamp warnings.
Closes #41
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Note on the base and on issue closureThis PR now targets
That is the intended outcome, not an oversight. #41 describes behaviour that is live on all six 1.3.0 deployments: they still accept equal expiries. Closing it when the fix lands on a version branch would mark as resolved something still true on chain. It closes when Why Still outstanding, unchanged from the description: |
authorizationExpiry == refundExpirywas accepted — the ordering check uses>, not>=— which collapses the refund/dispute window to zero: both become unreachable the instant the authorization ends, so a payment could be created that is refundable in name only._validatePaymentnow requiresrefundExpiry - authorizationExpiry >= MIN_REFUND_WINDOW, a new public constant.Days, not hours
MIN_REFUND_WINDOW = 1 days. The window has to survive a buyer noticing a problem, a merchant responding, and a transaction confirming — a window measured in hours is one the buyer can lose to a weekend.test_Validation_RejectsAnHoursLongRefundWindowasserts a 6-hour window is refused even though it is non-zero, so the unit is pinned, not just commented.One day is a floor against a collapsed window, not a recommendation: the README's guidance stays 14–30 days aligned with consumer-protection practice.
Chosen so it cannot reject real traffic
The gateway's defaults are a 7-day authorization and a 30-day refund window — a 23-day gap, comfortably clear of the floor. The strongest evidence is that the entire existing test corpus passes unchanged; only the test that existed to lock in the old behaviour had to go.
The subtraction cannot underflow: the ordering check immediately above already guarantees
refundExpiry >= authorizationExpiry.Gas
+328 on
authorize/charge(151,848 → 152,176, measured ontest_Authorize_Success), and nothing on any other entrypoint — the check lives only on the creation path.Two things this PR deliberately does NOT do
Both are release decisions across six live deployments, not code changes to slip in:
VERSIONis left at1.3.0. It must be bumped before deploying — it is part of the EIP-712 domain, so everyconfigHashdepends on it.Payment#validateis not mirrored. A hand-built payment with too tight a window would fail on-chain rather than as a clean 422. Worth a follow-up in rail0-gateway.This is BREAKING for any caller that relied on equal expiries.
Tests
test_Validation_AcceptsExpiriesEqualis replaced — it existed to lock in the old behaviour. New coverage:RejectsExpiriesEqualRefundWindowBoundaryMIN_REFUND_WINDOWaccepted, one second under refused — so the constant is the real gateRejectsAnHoursLongRefundWindowChargeRejectsACollapsedRefundWindowchargeshares_validatePayment; a validation added to one entrypoint only is the classic missSuite: 117 tests pass.
forge fmt --checkclean;forge lintreports the same 9 pre-existingblock-timestampwarnings.Closes #41
🤖 Generated with Claude Code