Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 7 additions & 1 deletion src/RefundProtocol.sol
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,8 @@ contract RefundProtocol is EIP712 {
revert CallerNotAllowed();
}

_settleDebt(msg.sender);

uint256 recipientBalance = balances[payment.to];

if (payment.amount > recipientBalance) {
Expand Down Expand Up @@ -298,6 +300,9 @@ contract RefundProtocol is EIP712 {
if (withdrawalAmount > payment.amount) {
revert InvalidWithdrawalAmount(paymentID, withdrawalAmount);
}
if (payment.withdrawnAmount + withdrawalAmount > payment.amount) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This guard looks like the right fix for #11. I think it should be locked down with a regression test though, because the failure mode is specifically about pooled recipient balance, not only a single payment balance.

A good test case:

  • create payment A: 100 USDC to receiver
  • create payment B: 100 USDC to the same receiver
  • call earlyWithdrawByArbiter([paymentA], [60], ...)
  • call it again for paymentA with a different valid salt/signature and another 60
  • assert the second call reverts with InvalidWithdrawalAmount

Without this guard, payment A reaches withdrawnAmount = 120 and consumes balance that belongs to payment B because balances[receiver] is shared. The code fix is correct; the regression test would prove the exact invariant this PR is restoring.

revert InvalidWithdrawalAmount(paymentID, withdrawalAmount);
}
if (payment.to != recipient) {
revert PaymentDoesNotBelongToRecipient();
}
Expand Down Expand Up @@ -368,10 +373,11 @@ contract RefundProtocol is EIP712 {
if (payment.refunded) {
revert PaymentRefunded(paymentID);
}
fiatToken.transfer(payment.refundTo, payment.amount);

payments[paymentID].refunded = true;

fiatToken.transfer(payment.refundTo, payment.amount);

emit Refund(paymentID, payment.refundTo, payment.amount);
}

Expand Down
39 changes: 39 additions & 0 deletions test/RefundProtocol.t.sol
Original file line number Diff line number Diff line change
Expand Up @@ -372,6 +372,45 @@ contract RefundProtocolTest is Test {
refundProtocol.earlyWithdrawByArbiter(paymentIDs, withdrawalAmounts, feeAmount, expiry, 0, receiver, v, r, s);
}

function testEarlyWithdrawByArbiterCannotOverWithdrawPaymentWithSharedRecipientBalance() public {
vm.prank(arbiter);
refundProtocol.setLockupSeconds(receiver, 3600);

vm.startPrank(user);
refundProtocol.pay(receiver, 100, refundTo);
refundProtocol.pay(receiver, 100, refundTo);
vm.stopPrank();

uint256[] memory paymentIDs = new uint256[](1);
paymentIDs[0] = 0;
uint256[] memory withdrawalAmounts = new uint256[](1);
withdrawalAmounts[0] = 60;
uint256 feeAmount = 0;

(uint8 v1, bytes32 r1, bytes32 s1) =
_generateEarlyWithdrawalSignature(paymentIDs, withdrawalAmounts, feeAmount, expiry, 0, receiverPrivateKey);

vm.prank(arbiter);
refundProtocol.earlyWithdrawByArbiter(
paymentIDs, withdrawalAmounts, feeAmount, expiry, 0, receiver, v1, r1, s1
);

assertEq(refundProtocol.balances(receiver), 140);
assertEq(usdc.balanceOf(receiver), 60);

(uint8 v2, bytes32 r2, bytes32 s2) =
_generateEarlyWithdrawalSignature(paymentIDs, withdrawalAmounts, feeAmount, expiry, 1, receiverPrivateKey);

vm.prank(arbiter);
vm.expectRevert(abi.encodeWithSelector(RefundProtocol.InvalidWithdrawalAmount.selector, 0, 60));
refundProtocol.earlyWithdrawByArbiter(
paymentIDs, withdrawalAmounts, feeAmount, expiry, 1, receiver, v2, r2, s2
);

assertEq(refundProtocol.balances(receiver), 140);
assertEq(usdc.balanceOf(receiver), 60);
}

function testEarlyWithdrawByArbiterInvalidFeeAmount() public {
vm.prank(arbiter);
refundProtocol.setLockupSeconds(receiver, 3600);
Expand Down