-
Notifications
You must be signed in to change notification settings - Fork 517
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
TokenPaymaster (0.7, Adjusted) (#660)
* TokenPaymaster (AA 0.7, Modified) * TokenPaymaster (0.7, Adjusted) Uniswap + Oracle based single ERC20 Paymaster for runtime sponsorship, compatible with sdks * tests * [L-1] TokenPaymaster doesn’t support tokens with decimal > 18 * [M-2] Paymaster is vulnerable to a sandwich attack when refilling the deposit on EntryPoint * prettier --------- Co-authored-by: Yash <[email protected]>
- Loading branch information
1 parent
b1a6148
commit cf51bfa
Showing
13 changed files
with
1,000 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,10 @@ | ||
// SPDX-License-Identifier: MIT | ||
pragma solidity ^0.8.23; | ||
|
||
interface IOracle { | ||
function decimals() external view returns (uint8); | ||
function latestRoundData() | ||
external | ||
view | ||
returns (uint80 roundId, int256 answer, uint256 startedAt, uint256 updatedAt, uint80 answeredInRound); | ||
} |
151 changes: 151 additions & 0 deletions
151
contracts/prebuilts/account/token-paymaster/BasePaymaster.sol
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,151 @@ | ||
// SPDX-License-Identifier: GPL-3.0 | ||
pragma solidity ^0.8.23; | ||
|
||
/* solhint-disable reason-string */ | ||
|
||
import "@openzeppelin/contracts/access/Ownable.sol"; | ||
import "@openzeppelin/contracts/utils/introspection/IERC165.sol"; | ||
import "../interfaces/IPaymaster.sol"; | ||
import "../interfaces/IEntryPoint.sol"; | ||
import "../utils/UserOperationLib.sol"; | ||
/** | ||
* Helper class for creating a paymaster. | ||
* provides helper methods for staking. | ||
* Validates that the postOp is called only by the entryPoint. | ||
*/ | ||
abstract contract BasePaymaster is IPaymaster, Ownable { | ||
IEntryPoint public immutable entryPoint; | ||
|
||
uint256 internal constant PAYMASTER_VALIDATION_GAS_OFFSET = UserOperationLib.PAYMASTER_VALIDATION_GAS_OFFSET; | ||
uint256 internal constant PAYMASTER_POSTOP_GAS_OFFSET = UserOperationLib.PAYMASTER_POSTOP_GAS_OFFSET; | ||
uint256 internal constant PAYMASTER_DATA_OFFSET = UserOperationLib.PAYMASTER_DATA_OFFSET; | ||
|
||
constructor(IEntryPoint _entryPoint) Ownable() { | ||
_validateEntryPointInterface(_entryPoint); | ||
entryPoint = _entryPoint; | ||
} | ||
|
||
//sanity check: make sure this EntryPoint was compiled against the same | ||
// IEntryPoint of this paymaster | ||
function _validateEntryPointInterface(IEntryPoint _entryPoint) internal virtual { | ||
require( | ||
IERC165(address(_entryPoint)).supportsInterface(type(IEntryPoint).interfaceId), | ||
"IEntryPoint interface mismatch" | ||
); | ||
} | ||
|
||
/// @inheritdoc IPaymaster | ||
function validatePaymasterUserOp( | ||
PackedUserOperation calldata userOp, | ||
bytes32 userOpHash, | ||
uint256 maxCost | ||
) external override returns (bytes memory context, uint256 validationData) { | ||
_requireFromEntryPoint(); | ||
return _validatePaymasterUserOp(userOp, userOpHash, maxCost); | ||
} | ||
|
||
/** | ||
* Validate a user operation. | ||
* @param userOp - The user operation. | ||
* @param userOpHash - The hash of the user operation. | ||
* @param maxCost - The maximum cost of the user operation. | ||
*/ | ||
function _validatePaymasterUserOp( | ||
PackedUserOperation calldata userOp, | ||
bytes32 userOpHash, | ||
uint256 maxCost | ||
) internal virtual returns (bytes memory context, uint256 validationData); | ||
|
||
/// @inheritdoc IPaymaster | ||
function postOp( | ||
PostOpMode mode, | ||
bytes calldata context, | ||
uint256 actualGasCost, | ||
uint256 actualUserOpFeePerGas | ||
) external override { | ||
_requireFromEntryPoint(); | ||
_postOp(mode, context, actualGasCost, actualUserOpFeePerGas); | ||
} | ||
|
||
/** | ||
* Post-operation handler. | ||
* (verified to be called only through the entryPoint) | ||
* @dev If subclass returns a non-empty context from validatePaymasterUserOp, | ||
* it must also implement this method. | ||
* @param mode - Enum with the following options: | ||
* opSucceeded - User operation succeeded. | ||
* opReverted - User op reverted. The paymaster still has to pay for gas. | ||
* postOpReverted - never passed in a call to postOp(). | ||
* @param context - The context value returned by validatePaymasterUserOp | ||
* @param actualGasCost - Actual gas used so far (without this postOp call). | ||
* @param actualUserOpFeePerGas - the gas price this UserOp pays. This value is based on the UserOp's maxFeePerGas | ||
* and maxPriorityFee (and basefee) | ||
* It is not the same as tx.gasprice, which is what the bundler pays. | ||
*/ | ||
function _postOp( | ||
PostOpMode mode, | ||
bytes calldata context, | ||
uint256 actualGasCost, | ||
uint256 actualUserOpFeePerGas | ||
) internal virtual { | ||
(mode, context, actualGasCost, actualUserOpFeePerGas); // unused params | ||
// subclass must override this method if validatePaymasterUserOp returns a context | ||
revert("must override"); | ||
} | ||
|
||
/** | ||
* Add a deposit for this paymaster, used for paying for transaction fees. | ||
*/ | ||
function deposit() public payable { | ||
entryPoint.depositTo{ value: msg.value }(address(this)); | ||
} | ||
|
||
/** | ||
* Withdraw value from the deposit. | ||
* @param withdrawAddress - Target to send to. | ||
* @param amount - Amount to withdraw. | ||
*/ | ||
function withdrawTo(address payable withdrawAddress, uint256 amount) public onlyOwner { | ||
entryPoint.withdrawTo(withdrawAddress, amount); | ||
} | ||
|
||
/** | ||
* Add stake for this paymaster. | ||
* This method can also carry eth value to add to the current stake. | ||
* @param unstakeDelaySec - The unstake delay for this paymaster. Can only be increased. | ||
*/ | ||
function addStake(uint32 unstakeDelaySec) external payable onlyOwner { | ||
entryPoint.addStake{ value: msg.value }(unstakeDelaySec); | ||
} | ||
|
||
/** | ||
* Return current paymaster's deposit on the entryPoint. | ||
*/ | ||
function getDeposit() public view returns (uint256) { | ||
return entryPoint.balanceOf(address(this)); | ||
} | ||
|
||
/** | ||
* Unlock the stake, in order to withdraw it. | ||
* The paymaster can't serve requests once unlocked, until it calls addStake again | ||
*/ | ||
function unlockStake() external onlyOwner { | ||
entryPoint.unlockStake(); | ||
} | ||
|
||
/** | ||
* Withdraw the entire paymaster's stake. | ||
* stake must be unlocked first (and then wait for the unstakeDelay to be over) | ||
* @param withdrawAddress - The address to send withdrawn value. | ||
*/ | ||
function withdrawStake(address payable withdrawAddress) external onlyOwner { | ||
entryPoint.withdrawStake(withdrawAddress); | ||
} | ||
|
||
/** | ||
* Validate the call is made from a valid entrypoint | ||
*/ | ||
function _requireFromEntryPoint() internal virtual { | ||
require(msg.sender == address(entryPoint), "Sender not EntryPoint"); | ||
} | ||
} |
204 changes: 204 additions & 0 deletions
204
contracts/prebuilts/account/token-paymaster/TokenPaymaster.sol
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,204 @@ | ||
// SPDX-License-Identifier: GPL-3.0 | ||
pragma solidity ^0.8.23; | ||
|
||
// Import the required libraries and contracts | ||
import "@openzeppelin/contracts/token/ERC20/extensions/IERC20Metadata.sol"; | ||
import "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; | ||
import "@openzeppelin/contracts/utils/cryptography/EIP712.sol"; | ||
|
||
import "../interfaces/IEntryPoint.sol"; | ||
import "./BasePaymaster.sol"; | ||
import "../utils/Helpers.sol"; | ||
import "../utils/UniswapHelper.sol"; | ||
import "../utils/OracleHelper.sol"; | ||
|
||
/// @title Sample ERC-20 Token Paymaster for ERC-4337 | ||
/// This Paymaster covers gas fees in exchange for ERC20 tokens charged using allowance pre-issued by ERC-4337 accounts. | ||
/// The contract refunds excess tokens if the actual gas cost is lower than the initially provided amount. | ||
/// The token price cannot be queried in the validation code due to storage access restrictions of ERC-4337. | ||
/// The price is cached inside the contract and is updated in the 'postOp' stage if the change is >10%. | ||
/// It is theoretically possible the token has depreciated so much since the last 'postOp' the refund becomes negative. | ||
/// The contract reverts the inner user transaction in that case but keeps the charge. | ||
/// The contract also allows honest clients to prepay tokens at a higher price to avoid getting reverted. | ||
/// It also allows updating price configuration and withdrawing tokens by the contract owner. | ||
/// The contract uses an Oracle to fetch the latest token prices. | ||
/// @dev Inherits from BasePaymaster. | ||
contract TokenPaymaster is BasePaymaster, UniswapHelper, OracleHelper { | ||
using UserOperationLib for PackedUserOperation; | ||
|
||
struct TokenPaymasterConfig { | ||
/// @notice The price markup percentage applied to the token price (1e26 = 100%). Ranges from 1e26 to 2e26 | ||
uint256 priceMarkup; | ||
/// @notice Exchange tokens to native currency if the EntryPoint balance of this Paymaster falls below this value | ||
uint128 minEntryPointBalance; | ||
/// @notice Estimated gas cost for refunding tokens after the transaction is completed | ||
uint48 refundPostopCost; | ||
/// @notice Transactions are only valid as long as the cached price is not older than this value | ||
uint48 priceMaxAge; | ||
} | ||
|
||
event ConfigUpdated(TokenPaymasterConfig tokenPaymasterConfig); | ||
|
||
event UserOperationSponsored( | ||
address indexed user, | ||
uint256 actualTokenCharge, | ||
uint256 actualGasCost, | ||
uint256 actualTokenPriceWithMarkup | ||
); | ||
|
||
event Received(address indexed sender, uint256 value); | ||
|
||
/// @notice All 'price' variables are multiplied by this value to avoid rounding up | ||
uint256 private constant PRICE_DENOMINATOR = 1e26; | ||
|
||
TokenPaymasterConfig public tokenPaymasterConfig; | ||
|
||
uint256 private immutable _tokenDecimals; | ||
|
||
/// @notice Initializes the TokenPaymaster contract with the given parameters. | ||
/// @param _token The ERC20 token used for transaction fee payments. | ||
/// @param _entryPoint The EntryPoint contract used in the Account Abstraction infrastructure. | ||
/// @param _wrappedNative The ERC-20 token that wraps the native asset for current chain. | ||
/// @param _uniswap The Uniswap V3 SwapRouter contract. | ||
/// @param _tokenPaymasterConfig The configuration for the Token Paymaster. | ||
/// @param _oracleHelperConfig The configuration for the Oracle Helper. | ||
/// @param _uniswapHelperConfig The configuration for the Uniswap Helper. | ||
/// @param _owner The address that will be set as the owner of the contract. | ||
constructor( | ||
IERC20Metadata _token, | ||
IEntryPoint _entryPoint, | ||
IERC20 _wrappedNative, | ||
ISwapRouter _uniswap, | ||
TokenPaymasterConfig memory _tokenPaymasterConfig, | ||
OracleHelperConfig memory _oracleHelperConfig, | ||
UniswapHelperConfig memory _uniswapHelperConfig, | ||
address _owner | ||
) | ||
BasePaymaster(_entryPoint) | ||
OracleHelper(_oracleHelperConfig) | ||
UniswapHelper(_token, _wrappedNative, _uniswap, _uniswapHelperConfig) | ||
{ | ||
_tokenDecimals = _token.decimals(); | ||
require(_tokenDecimals <= 18, "TPM: token not supported"); | ||
|
||
setTokenPaymasterConfig(_tokenPaymasterConfig); | ||
transferOwnership(_owner); | ||
} | ||
|
||
/// @notice Updates the configuration for the Token Paymaster. | ||
/// @param _tokenPaymasterConfig The new configuration struct. | ||
function setTokenPaymasterConfig(TokenPaymasterConfig memory _tokenPaymasterConfig) public onlyOwner { | ||
require(_tokenPaymasterConfig.priceMarkup <= 2 * PRICE_DENOMINATOR, "TPM: price markup too high"); | ||
require(_tokenPaymasterConfig.priceMarkup >= PRICE_DENOMINATOR, "TPM: price markup too low"); | ||
tokenPaymasterConfig = _tokenPaymasterConfig; | ||
emit ConfigUpdated(_tokenPaymasterConfig); | ||
} | ||
|
||
function setUniswapConfiguration(UniswapHelperConfig memory _uniswapHelperConfig) external onlyOwner { | ||
_setUniswapHelperConfiguration(_uniswapHelperConfig); | ||
} | ||
|
||
/// @notice Allows the contract owner to withdraw a specified amount of tokens from the contract. | ||
/// @param to The address to transfer the tokens to. | ||
/// @param amount The amount of tokens to transfer. | ||
function withdrawToken(address to, uint256 amount) external onlyOwner { | ||
SafeERC20.safeTransfer(token, to, amount); | ||
} | ||
|
||
/// @notice Validates a paymaster user operation and calculates the required token amount for the transaction. | ||
/// @param userOp The user operation data. | ||
/// @param requiredPreFund The maximum cost (in native token) the paymaster has to prefund. | ||
/// @return context The context containing the token amount and user sender address (if applicable). | ||
/// @return validationResult A uint256 value indicating the result of the validation (always 0 in this implementation). | ||
function _validatePaymasterUserOp( | ||
PackedUserOperation calldata userOp, | ||
bytes32, | ||
uint256 requiredPreFund | ||
) internal override returns (bytes memory context, uint256 validationResult) { | ||
unchecked { | ||
uint256 priceMarkup = tokenPaymasterConfig.priceMarkup; | ||
uint256 dataLength = userOp.paymasterAndData.length - PAYMASTER_DATA_OFFSET; | ||
require(dataLength == 0 || dataLength == 32, "TPM: invalid data length"); | ||
uint256 maxFeePerGas = userOp.unpackMaxFeePerGas(); | ||
uint256 refundPostopCost = tokenPaymasterConfig.refundPostopCost; | ||
require(refundPostopCost < userOp.unpackPostOpGasLimit(), "TPM: postOpGasLimit too low"); | ||
uint256 preChargeNative = requiredPreFund + (refundPostopCost * maxFeePerGas); | ||
// note: as price is in native-asset-per-token and we want more tokens increasing it means dividing it by markup | ||
uint256 cachedPriceWithMarkup = (cachedPrice * PRICE_DENOMINATOR) / priceMarkup; | ||
if (dataLength == 32) { | ||
uint256 clientSuppliedPrice = uint256( | ||
bytes32(userOp.paymasterAndData[PAYMASTER_DATA_OFFSET:PAYMASTER_DATA_OFFSET + 32]) | ||
); | ||
if (clientSuppliedPrice < cachedPriceWithMarkup) { | ||
// note: smaller number means 'more native asset per token' | ||
cachedPriceWithMarkup = clientSuppliedPrice; | ||
} | ||
} | ||
uint256 tokenAmount = weiToToken(preChargeNative, _tokenDecimals, cachedPriceWithMarkup); | ||
SafeERC20.safeTransferFrom(token, userOp.sender, address(this), tokenAmount); | ||
context = abi.encode(tokenAmount, userOp.sender); | ||
validationResult = _packValidationData( | ||
false, | ||
uint48(cachedPriceTimestamp + tokenPaymasterConfig.priceMaxAge), | ||
0 | ||
); | ||
} | ||
} | ||
|
||
/// @notice Performs post-operation tasks, such as updating the token price and refunding excess tokens. | ||
/// @dev This function is called after a user operation has been executed or reverted. | ||
/// @param context The context containing the token amount and user sender address. | ||
/// @param actualGasCost The actual gas cost of the transaction. | ||
/// @param actualUserOpFeePerGas - the gas price this UserOp pays. This value is based on the UserOp's maxFeePerGas | ||
// and maxPriorityFee (and basefee) | ||
// It is not the same as tx.gasprice, which is what the bundler pays. | ||
function _postOp( | ||
PostOpMode, | ||
bytes calldata context, | ||
uint256 actualGasCost, | ||
uint256 actualUserOpFeePerGas | ||
) internal override { | ||
unchecked { | ||
uint256 priceMarkup = tokenPaymasterConfig.priceMarkup; | ||
(uint256 preCharge, address userOpSender) = abi.decode(context, (uint256, address)); | ||
uint256 _cachedPrice = updateCachedPrice(false); | ||
// note: as price is in native-asset-per-token and we want more tokens increasing it means dividing it by markup | ||
uint256 cachedPriceWithMarkup = (_cachedPrice * PRICE_DENOMINATOR) / priceMarkup; | ||
// Refund tokens based on actual gas cost | ||
uint256 actualChargeNative = actualGasCost + tokenPaymasterConfig.refundPostopCost * actualUserOpFeePerGas; | ||
uint256 actualTokenNeeded = weiToToken(actualChargeNative, _tokenDecimals, cachedPriceWithMarkup); | ||
|
||
if (preCharge > actualTokenNeeded) { | ||
// If the initially provided token amount is greater than the actual amount needed, refund the difference | ||
SafeERC20.safeTransfer(token, userOpSender, preCharge - actualTokenNeeded); | ||
} else if (preCharge < actualTokenNeeded) { | ||
// Attempt to cover Paymaster's gas expenses by withdrawing the 'overdraft' from the client | ||
// If the transfer reverts also revert the 'postOp' to remove the incentive to cheat | ||
SafeERC20.safeTransferFrom(token, userOpSender, address(this), actualTokenNeeded - preCharge); | ||
} | ||
|
||
emit UserOperationSponsored(userOpSender, actualTokenNeeded, actualGasCost, cachedPriceWithMarkup); | ||
refillEntryPointDeposit(_cachedPrice); | ||
} | ||
} | ||
|
||
/// @notice If necessary this function uses this Paymaster's token balance to refill the deposit on EntryPoint | ||
/// @param _cachedPrice the token price that will be used to calculate the swap amount. | ||
function refillEntryPointDeposit(uint256 _cachedPrice) private { | ||
uint256 currentEntryPointBalance = entryPoint.balanceOf(address(this)); | ||
if (currentEntryPointBalance < tokenPaymasterConfig.minEntryPointBalance) { | ||
uint256 swappedWeth = _maybeSwapTokenToWeth(token, _cachedPrice); | ||
unwrapWeth(swappedWeth); | ||
entryPoint.depositTo{ value: address(this).balance }(address(this)); | ||
} | ||
} | ||
|
||
receive() external payable { | ||
emit Received(msg.sender, msg.value); | ||
} | ||
|
||
function withdrawEth(address payable recipient, uint256 amount) external onlyOwner { | ||
(bool success, ) = recipient.call{ value: amount }(""); | ||
require(success, "withdraw failed"); | ||
} | ||
} |
Oops, something went wrong.