Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

fix: keep track of pending withdrawals #16

Closed
wants to merge 6 commits into from
Closed
Show file tree
Hide file tree
Changes from 2 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
48 changes: 35 additions & 13 deletions src/MagicSpend.sol
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,9 @@ contract MagicSpend is Ownable, IPaymaster {
uint48 expiry;
}

/// @notice Track the pending ETH withdrwals.
uint256 internal _pendingWithdrawals;

/// @notice Track the ETH available to be withdrawn per user.
mapping(address user => uint256 amount) internal _withdrawableETH;

Expand Down Expand Up @@ -75,9 +78,9 @@ contract MagicSpend is Ownable, IPaymaster {
/// @notice Thrown during `UserOperation` validation when the current balance is insufficient to cover the
/// requested amount (exluding the `maxGasCost` set by the Entrypoint).
///
/// @param requestedAmount The requested amount excluding gas.
/// @param balance The current contract balance.
error InsufficientBalance(uint256 requestedAmount, uint256 balance);
/// @param requestedAmount The requested amount excluding gas.
/// @param availableBalance The current contract balance.
error InsufficientAvailableBalance(uint256 requestedAmount, uint256 availableBalance);

/// @notice Thrown when trying to withdraw funds but nothing is available.
error NoExcess();
Expand All @@ -100,6 +103,9 @@ contract MagicSpend is Ownable, IPaymaster {
/// @param _owner The initial owner of this contract.
constructor(address _owner) {
Ownable._initializeOwner(_owner);

// Initialize it to 1 to save gas and avoid resetting it to 0.
_pendingWithdrawals = 1;
}

/// @notice Receive function allowing ETH to be deposited in this contract.
Expand Down Expand Up @@ -130,11 +136,18 @@ contract MagicSpend is Ownable, IPaymaster {
// Ensure at validation that the contract has enough balance to cover the requested funds.
// NOTE: This check is necessary to enforce that the contract will be able to transfer the remaining funds
// when `postOp()` is called back after the `UserOperation` has been executed.
if (address(this).balance < withdrawAmount) {
revert InsufficientBalance(withdrawAmount, address(this).balance);
uint256 newPendingWithdrawals = _pendingWithdrawals + withdrawAmount;

if ((newPendingWithdrawals - 1) > address(this).balance) {
Copy link
Contributor

Choose a reason for hiding this comment

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

not sure it's worth the gas / visual complexity of subtracting the 1 wei :)

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Do you recommend removing it entirely?

The point of this PR was to track the amounts to avoid our paymaster reputation being decreased in case where the bundler would include userops that revert at execution due to insufficient balance. If I remove this I wonder if someone could not use this off-by-one issue intentionally.

Copy link
Contributor

Choose a reason for hiding this comment

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

hmm I don't see a way to exploit this. In this case we are OVER estimating pending withdraws? So the failure case there is a withdraw that gets rejected in validation but could have succeeded?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Oh right my bad!

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Not implemented in this PR, will reconsider it for later.

uint256 availableBalance = address(this).balance - (_pendingWithdrawals - 1);
revert InsufficientAvailableBalance(withdrawAmount, availableBalance);
}

// NOTE: Do not include the gas part in withdrawable funds as it will be handled in `postOp()`.
// Register the new pending withdrawals.
_pendingWithdrawals = newPendingWithdrawals;

// NOTE: Do not include the gas part in withdrawable funds because it is considered already consumed at this point.
// `postOp()` will refund the amout of gas that was not consumed.
_withdrawableETH[userOp.sender] += withdrawAmount - maxCost;
context = abi.encode(maxCost, userOp.sender);
}
Expand All @@ -147,18 +160,27 @@ contract MagicSpend is Ownable, IPaymaster {
// `PostOpMode.postOpReverted` should be impossible.
// Only possible cause would be if this contract does not own enough ETH to transfer
// but this is checked at the validation step.
assert(mode != PostOpMode.postOpReverted);
if (mode == PostOpMode.postOpReverted) {
revert UnexpectedPostOpRevertedMode();
}

(uint256 maxGasCost, address account) = abi.decode(context, (uint256, address));

// Compute the total remaining funds available for the user accout.
// NOTE: Take into account the user operation gas that was not consumed.
uint256 withdrawable = _withdrawableETH[account] + (maxGasCost - actualGasCost);
// Compute the total remaining funds the user can withdraw.
// NOTE: Take into account the gas consumed cost.
uint256 withdrawableAfterGasRefund = _withdrawableETH[account] + (maxGasCost - actualGasCost);

// Send the all remaining funds to the user accout.
// Update storage.
if (_pendingWithdrawals > 1) {
_pendingWithdrawals = 1;
}
delete _withdrawableETH[account];
if (withdrawable > 0) {
SafeTransferLib.forceSafeTransferETH(account, withdrawable, SafeTransferLib.GAS_STIPEND_NO_STORAGE_WRITES);

// Send the all remaining funds to the user accout.
if (withdrawableAfterGasRefund > 0) {
SafeTransferLib.forceSafeTransferETH(
account, withdrawableAfterGasRefund, SafeTransferLib.GAS_STIPEND_NO_STORAGE_WRITES
);
}
}

Expand Down
16 changes: 13 additions & 3 deletions test/PostOp.t.sol
Original file line number Diff line number Diff line change
Expand Up @@ -11,29 +11,39 @@ contract PostOpTest is PaymasterMagicSpendBaseTest {

function test_transfersExcess(uint256 mode, uint256 amount_, uint256 maxCost_, uint256 actualCost) public {
mode = bound(mode, 0, 1);
amount_ = bound(amount_, 0, type(uint256).max - 1);
maxCost_ = bound(maxCost_, 0, amount_);
actualCost = bound(actualCost, 0, maxCost_);

maxCost = maxCost_;
amount = amount_;

assertEq(withdrawer.balance, 0);
vm.deal(address(magic), amount);
(bytes memory context,) = magic.validatePaymasterUserOp(_getUserOp(), userOpHash, maxCost_);
uint256 expectedBalance = amount - actualCost;

magic.postOp(IPaymaster.PostOpMode(mode), context, actualCost);

uint256 expectedBalance = amount - actualCost;
assertEq(withdrawer.balance, expectedBalance);
}

function test_RevertsIfPostOpFailed(uint256 amount_, uint256 maxCost_, uint256 actualCost) public {
amount_ = bound(amount_, 0, type(uint256).max - 1);
maxCost_ = bound(maxCost_, 0, amount_);
actualCost = bound(actualCost, 0, maxCost_);

amount = amount_;

assertEq(withdrawer.balance, 0);
vm.deal(address(magic), amount);

(bytes memory context,) = magic.validatePaymasterUserOp(_getUserOp(), userOpHash, maxCost_);
uint256 expectedBalance = 0;
vm.expectRevert();

vm.expectRevert(MagicSpend.UnexpectedPostOpRevertedMode.selector);
magic.postOp(IPaymaster.PostOpMode.postOpReverted, context, actualCost);

uint256 expectedBalance = 0;
assertEq(withdrawer.balance, expectedBalance);
}
}
2 changes: 2 additions & 0 deletions test/Validate.t.sol
Original file line number Diff line number Diff line change
Expand Up @@ -18,10 +18,12 @@ abstract contract ValidateTest is MagicSpendTest {
// avoid precompiles
vm.assume(withdrawer_ > address(0x10000));
vm.assume(withdrawer_ != address(vm));

asset = address(0);
withdrawer = withdrawer_;
amount = amount_;
nonce = nonce_;

vm.deal(address(magic), amount);
vm.expectEmit(true, true, true, true);
emit MagicSpend.MagicSpendWithdrawal(withdrawer, asset, amount, nonce);
Expand Down
23 changes: 20 additions & 3 deletions test/ValidatePaymasterUserOp.t.sol
Original file line number Diff line number Diff line change
Expand Up @@ -16,9 +16,25 @@ contract ValidatePaymasterUserOpTest is PaymasterMagicSpendBaseTest, ValidateTes
magic.validatePaymasterUserOp(_getUserOp(), userOpHash, maxCost);
}

function test_revertsIfWithdrawalExceedsBalance() public {
vm.deal(address(magic), 0);
vm.expectRevert(abi.encodeWithSelector(MagicSpend.InsufficientBalance.selector, amount, 0));
function test_revertsInsufficientAvailableBalance(
uint256 initialBalance,
uint256 pendingWithdrawals,
uint256 withdrawAmount
) public {
initialBalance = bound(initialBalance, 0, type(uint128).max - 2);
pendingWithdrawals = bound(pendingWithdrawals, 0, initialBalance);
uint256 availableBalance = initialBalance - pendingWithdrawals;
withdrawAmount = bound(withdrawAmount, availableBalance + 1, type(uint128).max - 1);

maxCost = withdrawAmount;
amount = withdrawAmount;

vm.deal(address(magic), initialBalance);
vm.store(address(magic), bytes32(uint256(0)), bytes32(pendingWithdrawals + 1));
Copy link
Contributor

Choose a reason for hiding this comment

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

might be better to test multiple calls to validate so that we ensure we are setting / updating correctly

Copy link
Contributor Author

Choose a reason for hiding this comment

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

The best approach might be to have a test that simulates the bundler (validation + execution) and keep those unit tests focused on ~1 use case.

Copy link
Contributor

Choose a reason for hiding this comment

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

We have integration tests here if you want to write new one https://github.com/coinbase/magic-spend/blob/main/test/Integration.t.sol

I think we could have here test_reverts_whenBalanceTooLow1Withdraw test_reverts_whenBalanceTooLow2Withdraws

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I replaced the current test implementation to perform two successive validations that exhaust (and exceed) the available balance.


vm.expectRevert(
abi.encodeWithSelector(MagicSpend.InsufficientAvailableBalance.selector, amount, availableBalance)
);
magic.validatePaymasterUserOp(_getUserOp(), userOpHash, maxCost);
}

Expand Down Expand Up @@ -47,6 +63,7 @@ contract ValidatePaymasterUserOpTest is PaymasterMagicSpendBaseTest, ValidateTes
}

function test_emitsCorrectly(address, uint256 amount_, uint256 nonce_) public override {
amount_ = bound(amount_, 0, type(uint256).max - 1);
maxCost = amount_;
super.test_emitsCorrectly(magic.entryPoint(), amount_, nonce_);
}
Expand Down
9 changes: 7 additions & 2 deletions test/WithdrawGasExcess.t.sol
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,12 @@ contract WithdrawGasExcess is PaymasterMagicSpendBaseTest {
}

function test_transferExcess(uint256 amount_, uint256 maxCost_, uint256 actual) public {
vm.assume(maxCost_ < amount_);
vm.assume(actual <= maxCost_);
maxCost_ = bound(maxCost_, 0, type(uint256).max - 2);
actual = bound(actual, 0, maxCost_);
amount_ = bound(amount_, maxCost_ + 1, type(uint256).max - 1);

amount = amount_;

vm.deal(address(magic), amount);
magic.validatePaymasterUserOp(_getUserOp(), userOpHash, maxCost_);

Expand All @@ -24,7 +27,9 @@ contract WithdrawGasExcess is PaymasterMagicSpendBaseTest {
}

function test_RevertsIfNoExcess(uint256 maxCost_) public {
maxCost_ = bound(maxCost_, 0, type(uint256).max - 1);
amount = maxCost_;

vm.deal(address(magic), amount);
magic.validatePaymasterUserOp(_getUserOp(), userOpHash, maxCost_);

Expand Down
Loading