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

Guard against concurrent withdraws causing unexpected reverts #17

Merged
merged 5 commits into from Apr 4, 2024
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
54 changes: 31 additions & 23 deletions .gas-snapshot
@@ -1,27 +1,35 @@
PostOpTest:test_RevertsIfPostOpFailed(uint256,uint256,uint256) (runs: 256, μ: 94981, ~: 97095)
GetHashTest:test_returnsValidHash() (gas: 46531)
IsValidWithdrawalSignature:test_returnsFalseWithInvalidSignature() (gas: 53845)
IsValidWithdrawalSignature:test_returnsTrueWithValidSignature() (gas: 51016)
OwnerWithdrawTest:test_revertsIfNotOwner() (gas: 15305)
OwnerWithdrawTest:test_transfersERC20Successfully(uint256) (runs: 256, μ: 97271, ~: 99711)
OwnerWithdrawTest:test_transfersETHSuccessfully(uint256) (runs: 256, μ: 51418, ~: 53415)
PostOpTest:test_RevertsIfPostOpFailed(uint256,uint256,uint256) (runs: 256, μ: 97167, ~: 99371)
PostOpTest:test_entryPointAddStake(uint112,uint32) (runs: 256, μ: 56233, ~: 56233)
PostOpTest:test_entryPointDeposit(uint112) (runs: 256, μ: 32331, ~: 32962)
PostOpTest:test_entryPointUnlockStake() (gas: 54910)
PostOpTest:test_entryPointWithdraw(uint112) (runs: 256, μ: 63500, ~: 65300)
PostOpTest:test_entryPointWithdrawStake() (gas: 71825)
PostOpTest:test_paymasterPaysForOp() (gas: 208402)
PostOpTest:test_transfersExcess(uint256,uint256,uint256,uint256) (runs: 256, μ: 110216, ~: 111227)
PostOpTest:test_entryPointDeposit(uint112) (runs: 256, μ: 32516, ~: 32962)
PostOpTest:test_entryPointUnlockStake() (gas: 54888)
PostOpTest:test_entryPointWithdraw(uint112) (runs: 256, μ: 63200, ~: 65300)
PostOpTest:test_entryPointWithdrawStake() (gas: 71803)
PostOpTest:test_paymasterPaysForOp() (gas: 210663)
PostOpTest:test_transfersExcess(uint256,uint256,uint256,uint256) (runs: 256, μ: 112465, ~: 113488)
Simulate:test() (gas: 12114)
ValidatePaymasterUserOpTest:test_emitsCorrectly(address,uint256,uint256) (runs: 256, μ: 102564, ~: 103835)
ValidatePaymasterUserOpTest:test_recordsNonceUsed(uint256) (runs: 256, μ: 109936, ~: 111025)
ValidatePaymasterUserOpTest:test_returns1sIfWrongSignature() (gas: 94076)
ValidatePaymasterUserOpTest:test_returnsCorrectly() (gas: 88920)
ValidatePaymasterUserOpTest:test_emitsCorrectly(address,uint256,uint256) (runs: 256, μ: 104847, ~: 106080)
ValidatePaymasterUserOpTest:test_recordsNonceUsed(uint256) (runs: 256, μ: 112158, ~: 113247)
ValidatePaymasterUserOpTest:test_returns1sIfWrongSignature() (gas: 96298)
ValidatePaymasterUserOpTest:test_returnsCorrectly() (gas: 91185)
ValidatePaymasterUserOpTest:test_revertsIfMaxCostMoreThanRequested() (gas: 39141)
ValidatePaymasterUserOpTest:test_revertsIfNonceUsed() (gas: 101280)
ValidatePaymasterUserOpTest:test_revertsIfWithdrawAssetNotZero() (gas: 55974)
ValidatePaymasterUserOpTest:test_revertsIfNonceUsed() (gas: 103502)
ValidatePaymasterUserOpTest:test_revertsIfWithdrawAssetNotZero() (gas: 55952)
ValidatePaymasterUserOpTest:test_reverts_whenWithdrawExceedsMaxAllowed(uint256,uint256,uint256) (runs: 256, μ: 100811, ~: 100992)
ValidateTest:test_receive() (gas: 14687)
WithdrawGasExcess:test_RevertsIfNoExcess(uint256) (runs: 256, μ: 68914, ~: 69177)
WithdrawGasExcess:test_transferExcess(uint256,uint256,uint256) (runs: 256, μ: 102074, ~: 102074)
WithdrawTest:test_emitsCorrectly(address,uint256,uint256) (runs: 256, μ: 115868, ~: 119073)
WithdrawTest:test_emitsCorrectlyERC20Withdraw(address,uint256,uint256) (runs: 256, μ: 158052, ~: 160513)
WithdrawTest:test_recordsNonceUsed(uint256) (runs: 256, μ: 109078, ~: 110167)
WithdrawTest:test_revertsIfExpired(uint48,uint256) (runs: 256, μ: 59403, ~: 59535)
WithdrawTest:test_revertsIfNonceUsed() (gas: 93506)
WithdrawTest:test_revertsIfWrongSignature() (gas: 61536)
WithdrawTest:test_transfersERC20Successfully(uint256) (runs: 256, μ: 136385, ~: 138825)
WithdrawTest:test_transfersETHSuccessfully(uint256) (runs: 256, μ: 89398, ~: 91395)
WithdrawGasExcess:test_RevertsIfNoExcess(uint256) (runs: 256, μ: 71133, ~: 71396)
WithdrawGasExcess:test_transferExcess(uint256,uint256,uint256) (runs: 256, μ: 104279, ~: 104290)
WithdrawTest:test_emitsCorrectly(address,uint256,uint256) (runs: 256, μ: 118413, ~: 121368)
WithdrawTest:test_emitsCorrectlyERC20Withdraw(address,uint256,uint256) (runs: 256, μ: 162446, ~: 164773)
WithdrawTest:test_recordsNonceUsed(uint256) (runs: 256, μ: 111372, ~: 112461)
WithdrawTest:test_revertsIfExpired(uint48,uint256) (runs: 256, μ: 61664, ~: 61785)
WithdrawTest:test_revertsIfNonceUsed() (gas: 95844)
WithdrawTest:test_revertsIfWrongSignature() (gas: 63830)
WithdrawTest:test_reverts_whenWithdrawExceedsMaxAllowed(uint256,uint256,uint256) (runs: 256, μ: 85124, ~: 85096)
WithdrawTest:test_transfersERC20Successfully(uint256) (runs: 256, μ: 140626, ~: 143066)
WithdrawTest:test_transfersETHSuccessfully(uint256) (runs: 256, μ: 91692, ~: 93689)
2 changes: 1 addition & 1 deletion script/DeployMagicSpend.s.sol
Expand Up @@ -12,7 +12,7 @@ contract MagicSpendDeployScript is Script {
address signerAddress = 0x3E0cd4Dc43811888efa242Ab17118FcE0035EFF7;
uint256 deployerPrivateKey = vm.envUint("PRIVATE_KEY");
vm.startBroadcast(deployerPrivateKey);
MagicSpend c = new MagicSpend{salt: "0x1"}(vm.addr(deployerPrivateKey));
MagicSpend c = new MagicSpend{salt: "0x1"}(vm.addr(deployerPrivateKey), 20);
console2.log(address(c));
c.entryPointDeposit{value: 0.01 ether}(0.01 ether);
c.entryPointAddStake{value: 0x16345785d8a0000}(0x16345785d8a0000, 0x15180);
Expand Down
57 changes: 39 additions & 18 deletions src/MagicSpend.sol
Expand Up @@ -30,6 +30,10 @@ contract MagicSpend is Ownable, IPaymaster {
uint48 expiry;
}

/// @notice The maximum percentage of address.balance a single withdraw.
/// @dev Helps prevent withdraws in the same transaction leading to reverts and hurting paymaster reputation.
uint256 public maxWithdrawDenominator;
Copy link
Contributor

Choose a reason for hiding this comment

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

I think the naming of the var ...Denominator and the natspec description as percentage clash a bit. Perhaps re-word the natspec to The maximum fraction of address.balance... to explicitly describe the way this is used as a rate.

Copy link
Contributor

Choose a reason for hiding this comment

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

Agree naming/comment wording is a bit misleading.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

@stevieraykatz @xenoliss updated naming

Copy link
Contributor

Choose a reason for hiding this comment

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

I would rather have reworded the comment I guess because now maxWithdrawPercent is used in a denominator and it seems a bit weird.


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

Expand All @@ -44,6 +48,11 @@ contract MagicSpend is Ownable, IPaymaster {
/// @param nonce The request nonce.
event MagicSpendWithdrawal(address indexed account, address indexed asset, uint256 amount, uint256 nonce);

/// @notice Emitted when the `maxWithdrawDenominator` is set.
///
/// @param newDenominator The new maxWithdrawDenominator value.
event MaxWithdrawDenominatorSet(uint256 newDenominator);

/// @notice Thrown when the withdraw request signature is invalid.
///
/// @dev The withdraw request signature MUST be:
Expand Down Expand Up @@ -72,12 +81,11 @@ contract MagicSpend is Ownable, IPaymaster {
/// @param asset The requested asset.
error UnsupportedPaymasterAsset(address asset);

/// @notice Thrown during `UserOperation` validation when the current balance is insufficient to cover the
/// requested amount (exluding the `maxGasCost` set by the Entrypoint).
/// @notice Thrown if WithdrawRequest.amount exceeds address(this).balance / maxWithdrawDenominator.
///
/// @param requestedAmount The requested amount excluding gas.
/// @param balance The current contract balance.
error InsufficientBalance(uint256 requestedAmount, uint256 balance);
/// @param maxAllowed The current max allowed withdraw.
error WithdrawTooLarge(uint256 requestedAmount, uint256 maxAllowed);

/// @notice Thrown when trying to withdraw funds but nothing is available.
error NoExcess();
Expand All @@ -97,9 +105,10 @@ contract MagicSpend is Ownable, IPaymaster {

/// @notice Deploy the contract and set its initial owner.
///
/// @param _owner The initial owner of this contract.
constructor(address _owner) {
Ownable._initializeOwner(_owner);
/// @param owner_ The initial owner of this contract.
constructor(address owner_, uint256 maxWithdrawDenominator_) {
Ownable._initializeOwner(owner_);
_setMaxWithdrawDenominator(maxWithdrawDenominator_);
}

/// @notice Receive function allowing ETH to be deposited in this contract.
Expand Down Expand Up @@ -127,13 +136,6 @@ contract MagicSpend is Ownable, IPaymaster {
bool sigFailed = !isValidWithdrawSignature(userOp.sender, withdrawRequest);
validationData = (sigFailed ? 1 : 0) | (uint256(withdrawRequest.expiry) << 160);

// 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);
}

// NOTE: Do not include the gas part in withdrawable funds as it will be handled in `postOp()`.
_withdrawableETH[userOp.sender] += withdrawAmount - maxCost;
context = abi.encode(maxCost, userOp.sender);
Expand All @@ -144,10 +146,14 @@ contract MagicSpend is Ownable, IPaymaster {
external
onlyEntryPoint
{
// `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);
// `PostOpMode.postOpReverted` should never happen.
// The flow here can only revert if there are > maxWithdrawDenominator
// withdraws in the same transaction, which should be highly unlikely.
// If the ETH transfer fails, the entire bundle will revert due an issue in the EntryPoint
// https://github.com/eth-infinitism/account-abstraction/pull/293
if (mode == PostOpMode.postOpReverted) {
revert UnexpectedPostOpRevertedMode();
}

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

Expand Down Expand Up @@ -249,6 +255,10 @@ contract MagicSpend is Ownable, IPaymaster {
IEntryPoint(entryPoint()).withdrawStake(to);
}

function setMaxWithdrawDenominator(uint256 newDenominator) external onlyOwner {
_setMaxWithdrawDenominator(newDenominator);
}

/// @notice Returns whether the `withdrawRequest` signature is valid for the given `account`.
///
/// @dev Does not validate nonce or expiry.
Expand Down Expand Up @@ -305,6 +315,12 @@ contract MagicSpend is Ownable, IPaymaster {
return 0x5FF137D4b0FDCD49DcA30c7CF57E578a026d2789;
}

function _setMaxWithdrawDenominator(uint256 newDenominator) internal {
maxWithdrawDenominator = newDenominator;

emit MaxWithdrawDenominatorSet(newDenominator);
}
stevieraykatz marked this conversation as resolved.
Show resolved Hide resolved

/// @notice Validate the `withdrawRequest` against the given `account`.
///
/// @dev Runs all non-signature validation checks.
Expand All @@ -317,6 +333,11 @@ contract MagicSpend is Ownable, IPaymaster {
revert InvalidNonce(withdrawRequest.nonce);
}

uint256 maxAllowed = address(this).balance / maxWithdrawDenominator;
if (withdrawRequest.asset == address(0) && withdrawRequest.amount > maxAllowed) {
revert WithdrawTooLarge(withdrawRequest.amount, maxAllowed);
}

_nonceUsed[withdrawRequest.nonce][account] = true;

// This is emitted ahead of fund transfer, but allows a consolidated code path
Expand Down
2 changes: 1 addition & 1 deletion test/MagicSpend.t.sol
Expand Up @@ -10,7 +10,7 @@ contract MagicSpendTest is Test {
address withdrawer = address(0xb0b);
uint256 ownerPrivateKey = 0xa11ce;
address owner = vm.addr(ownerPrivateKey);
MagicSpend magic = new MagicSpend(owner);
MagicSpend magic = new MagicSpend(owner, 1);

// signature params
address asset;
Expand Down
2 changes: 1 addition & 1 deletion test/PostOp.t.sol
Expand Up @@ -32,7 +32,7 @@ contract PostOpTest is PaymasterMagicSpendBaseTest {
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);
assertEq(withdrawer.balance, expectedBalance);
}
Expand Down
28 changes: 28 additions & 0 deletions test/Validate.t.sol
Expand Up @@ -6,6 +6,8 @@ import {Test, console2} from "forge-std/Test.sol";
import "./MagicSpend.t.sol";

abstract contract ValidateTest is MagicSpendTest {
address invoker;

function test_recordsNonceUsed(uint256 nonce_) public {
nonce = nonce_;
assertFalse(magic.nonceUsed(withdrawer, nonce));
Expand Down Expand Up @@ -36,5 +38,31 @@ abstract contract ValidateTest is MagicSpendTest {
_validateInvokingCall();
}

function test_reverts_whenWithdrawExceedsMaxAllowed(
uint256 accountBalance,
uint256 withdraw,
uint256 maxWithdrawDenominator
) public virtual {
vm.assume(maxWithdrawDenominator > 0);
accountBalance = bound(accountBalance, 0, type(uint256).max - 1);
withdraw = bound(withdraw, accountBalance / maxWithdrawDenominator + 1, type(uint256).max);
amount = withdraw;

vm.stopPrank();
vm.startPrank(owner);
magic.setMaxWithdrawDenominator(maxWithdrawDenominator);
magic.ownerWithdraw(address(0), address(1), address(magic).balance);
vm.stopPrank();

vm.deal(address(magic), accountBalance);
vm.expectRevert(
abi.encodeWithSelector(
MagicSpend.WithdrawTooLarge.selector, withdraw, accountBalance / maxWithdrawDenominator
)
);
vm.startPrank(invoker);
_validateInvokingCall();
}

function _validateInvokingCall() internal virtual;
}
16 changes: 10 additions & 6 deletions test/ValidatePaymasterUserOp.t.sol
Expand Up @@ -8,6 +8,7 @@ contract ValidatePaymasterUserOpTest is PaymasterMagicSpendBaseTest, ValidateTes
function setUp() public override {
super.setUp();
vm.startPrank(magic.entryPoint());
invoker = magic.entryPoint();
}

function test_revertsIfMaxCostMoreThanRequested() public {
Expand All @@ -16,12 +17,6 @@ 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));
magic.validatePaymasterUserOp(_getUserOp(), userOpHash, maxCost);
}

function test_returnsCorrectly() public {
(bytes memory context, uint256 validationData) =
magic.validatePaymasterUserOp(_getUserOp(), userOpHash, maxCost);
Expand Down Expand Up @@ -51,6 +46,15 @@ contract ValidatePaymasterUserOpTest is PaymasterMagicSpendBaseTest, ValidateTes
super.test_emitsCorrectly(magic.entryPoint(), amount_, nonce_);
}

function test_reverts_whenWithdrawExceedsMaxAllowed(
uint256 accountBalance,
uint256 withdraw,
uint256 maxWithdrawDenominator
) public override {
maxCost = withdraw;
super.test_reverts_whenWithdrawExceedsMaxAllowed(accountBalance, withdraw, maxWithdrawDenominator);
}

function _validateInvokingCall() internal override {
magic.validatePaymasterUserOp(_getUserOp(), userOpHash, maxCost);
}
Expand Down
1 change: 1 addition & 0 deletions test/Withdraw.t.sol
Expand Up @@ -11,6 +11,7 @@ contract WithdrawTest is MagicSpendTest, ValidateTest {
function setUp() public override {
super.setUp();
vm.startPrank(withdrawer);
invoker = withdrawer;
}

function test_transfersETHSuccessfully(uint256 amount_) public {
Expand Down
2 changes: 1 addition & 1 deletion test/echidna/FuzzSetup.sol
Expand Up @@ -22,7 +22,7 @@ contract FuzzSetup is FuzzBase {
uint256 totalWithdrawn;

constructor() payable {
magic = new MagicSpend(OWNER);
magic = new MagicSpend(OWNER, 1);
address(magic).call{value: PAYMASTER_STARTING_BALANCE}("");
}

Expand Down