Skip to content

Commit cf51bfa

Browse files
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]>
1 parent b1a6148 commit cf51bfa

File tree

13 files changed

+1000
-0
lines changed

13 files changed

+1000
-0
lines changed

.gitmodules

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,3 +46,9 @@
4646
[submodule "lib/openzeppelin-contracts"]
4747
path = lib/openzeppelin-contracts
4848
url = https://github.com/OpenZeppelin/openzeppelin-contracts
49+
[submodule "lib/v3-periphery"]
50+
path = lib/v3-periphery
51+
url = https://github.com/uniswap/v3-periphery
52+
[submodule "lib/v3-core"]
53+
path = lib/v3-core
54+
url = https://github.com/uniswap/v3-core
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
// SPDX-License-Identifier: MIT
2+
pragma solidity ^0.8.23;
3+
4+
interface IOracle {
5+
function decimals() external view returns (uint8);
6+
function latestRoundData()
7+
external
8+
view
9+
returns (uint80 roundId, int256 answer, uint256 startedAt, uint256 updatedAt, uint80 answeredInRound);
10+
}
Lines changed: 151 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,151 @@
1+
// SPDX-License-Identifier: GPL-3.0
2+
pragma solidity ^0.8.23;
3+
4+
/* solhint-disable reason-string */
5+
6+
import "@openzeppelin/contracts/access/Ownable.sol";
7+
import "@openzeppelin/contracts/utils/introspection/IERC165.sol";
8+
import "../interfaces/IPaymaster.sol";
9+
import "../interfaces/IEntryPoint.sol";
10+
import "../utils/UserOperationLib.sol";
11+
/**
12+
* Helper class for creating a paymaster.
13+
* provides helper methods for staking.
14+
* Validates that the postOp is called only by the entryPoint.
15+
*/
16+
abstract contract BasePaymaster is IPaymaster, Ownable {
17+
IEntryPoint public immutable entryPoint;
18+
19+
uint256 internal constant PAYMASTER_VALIDATION_GAS_OFFSET = UserOperationLib.PAYMASTER_VALIDATION_GAS_OFFSET;
20+
uint256 internal constant PAYMASTER_POSTOP_GAS_OFFSET = UserOperationLib.PAYMASTER_POSTOP_GAS_OFFSET;
21+
uint256 internal constant PAYMASTER_DATA_OFFSET = UserOperationLib.PAYMASTER_DATA_OFFSET;
22+
23+
constructor(IEntryPoint _entryPoint) Ownable() {
24+
_validateEntryPointInterface(_entryPoint);
25+
entryPoint = _entryPoint;
26+
}
27+
28+
//sanity check: make sure this EntryPoint was compiled against the same
29+
// IEntryPoint of this paymaster
30+
function _validateEntryPointInterface(IEntryPoint _entryPoint) internal virtual {
31+
require(
32+
IERC165(address(_entryPoint)).supportsInterface(type(IEntryPoint).interfaceId),
33+
"IEntryPoint interface mismatch"
34+
);
35+
}
36+
37+
/// @inheritdoc IPaymaster
38+
function validatePaymasterUserOp(
39+
PackedUserOperation calldata userOp,
40+
bytes32 userOpHash,
41+
uint256 maxCost
42+
) external override returns (bytes memory context, uint256 validationData) {
43+
_requireFromEntryPoint();
44+
return _validatePaymasterUserOp(userOp, userOpHash, maxCost);
45+
}
46+
47+
/**
48+
* Validate a user operation.
49+
* @param userOp - The user operation.
50+
* @param userOpHash - The hash of the user operation.
51+
* @param maxCost - The maximum cost of the user operation.
52+
*/
53+
function _validatePaymasterUserOp(
54+
PackedUserOperation calldata userOp,
55+
bytes32 userOpHash,
56+
uint256 maxCost
57+
) internal virtual returns (bytes memory context, uint256 validationData);
58+
59+
/// @inheritdoc IPaymaster
60+
function postOp(
61+
PostOpMode mode,
62+
bytes calldata context,
63+
uint256 actualGasCost,
64+
uint256 actualUserOpFeePerGas
65+
) external override {
66+
_requireFromEntryPoint();
67+
_postOp(mode, context, actualGasCost, actualUserOpFeePerGas);
68+
}
69+
70+
/**
71+
* Post-operation handler.
72+
* (verified to be called only through the entryPoint)
73+
* @dev If subclass returns a non-empty context from validatePaymasterUserOp,
74+
* it must also implement this method.
75+
* @param mode - Enum with the following options:
76+
* opSucceeded - User operation succeeded.
77+
* opReverted - User op reverted. The paymaster still has to pay for gas.
78+
* postOpReverted - never passed in a call to postOp().
79+
* @param context - The context value returned by validatePaymasterUserOp
80+
* @param actualGasCost - Actual gas used so far (without this postOp call).
81+
* @param actualUserOpFeePerGas - the gas price this UserOp pays. This value is based on the UserOp's maxFeePerGas
82+
* and maxPriorityFee (and basefee)
83+
* It is not the same as tx.gasprice, which is what the bundler pays.
84+
*/
85+
function _postOp(
86+
PostOpMode mode,
87+
bytes calldata context,
88+
uint256 actualGasCost,
89+
uint256 actualUserOpFeePerGas
90+
) internal virtual {
91+
(mode, context, actualGasCost, actualUserOpFeePerGas); // unused params
92+
// subclass must override this method if validatePaymasterUserOp returns a context
93+
revert("must override");
94+
}
95+
96+
/**
97+
* Add a deposit for this paymaster, used for paying for transaction fees.
98+
*/
99+
function deposit() public payable {
100+
entryPoint.depositTo{ value: msg.value }(address(this));
101+
}
102+
103+
/**
104+
* Withdraw value from the deposit.
105+
* @param withdrawAddress - Target to send to.
106+
* @param amount - Amount to withdraw.
107+
*/
108+
function withdrawTo(address payable withdrawAddress, uint256 amount) public onlyOwner {
109+
entryPoint.withdrawTo(withdrawAddress, amount);
110+
}
111+
112+
/**
113+
* Add stake for this paymaster.
114+
* This method can also carry eth value to add to the current stake.
115+
* @param unstakeDelaySec - The unstake delay for this paymaster. Can only be increased.
116+
*/
117+
function addStake(uint32 unstakeDelaySec) external payable onlyOwner {
118+
entryPoint.addStake{ value: msg.value }(unstakeDelaySec);
119+
}
120+
121+
/**
122+
* Return current paymaster's deposit on the entryPoint.
123+
*/
124+
function getDeposit() public view returns (uint256) {
125+
return entryPoint.balanceOf(address(this));
126+
}
127+
128+
/**
129+
* Unlock the stake, in order to withdraw it.
130+
* The paymaster can't serve requests once unlocked, until it calls addStake again
131+
*/
132+
function unlockStake() external onlyOwner {
133+
entryPoint.unlockStake();
134+
}
135+
136+
/**
137+
* Withdraw the entire paymaster's stake.
138+
* stake must be unlocked first (and then wait for the unstakeDelay to be over)
139+
* @param withdrawAddress - The address to send withdrawn value.
140+
*/
141+
function withdrawStake(address payable withdrawAddress) external onlyOwner {
142+
entryPoint.withdrawStake(withdrawAddress);
143+
}
144+
145+
/**
146+
* Validate the call is made from a valid entrypoint
147+
*/
148+
function _requireFromEntryPoint() internal virtual {
149+
require(msg.sender == address(entryPoint), "Sender not EntryPoint");
150+
}
151+
}
Lines changed: 204 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,204 @@
1+
// SPDX-License-Identifier: GPL-3.0
2+
pragma solidity ^0.8.23;
3+
4+
// Import the required libraries and contracts
5+
import "@openzeppelin/contracts/token/ERC20/extensions/IERC20Metadata.sol";
6+
import "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol";
7+
import "@openzeppelin/contracts/utils/cryptography/EIP712.sol";
8+
9+
import "../interfaces/IEntryPoint.sol";
10+
import "./BasePaymaster.sol";
11+
import "../utils/Helpers.sol";
12+
import "../utils/UniswapHelper.sol";
13+
import "../utils/OracleHelper.sol";
14+
15+
/// @title Sample ERC-20 Token Paymaster for ERC-4337
16+
/// This Paymaster covers gas fees in exchange for ERC20 tokens charged using allowance pre-issued by ERC-4337 accounts.
17+
/// The contract refunds excess tokens if the actual gas cost is lower than the initially provided amount.
18+
/// The token price cannot be queried in the validation code due to storage access restrictions of ERC-4337.
19+
/// The price is cached inside the contract and is updated in the 'postOp' stage if the change is >10%.
20+
/// It is theoretically possible the token has depreciated so much since the last 'postOp' the refund becomes negative.
21+
/// The contract reverts the inner user transaction in that case but keeps the charge.
22+
/// The contract also allows honest clients to prepay tokens at a higher price to avoid getting reverted.
23+
/// It also allows updating price configuration and withdrawing tokens by the contract owner.
24+
/// The contract uses an Oracle to fetch the latest token prices.
25+
/// @dev Inherits from BasePaymaster.
26+
contract TokenPaymaster is BasePaymaster, UniswapHelper, OracleHelper {
27+
using UserOperationLib for PackedUserOperation;
28+
29+
struct TokenPaymasterConfig {
30+
/// @notice The price markup percentage applied to the token price (1e26 = 100%). Ranges from 1e26 to 2e26
31+
uint256 priceMarkup;
32+
/// @notice Exchange tokens to native currency if the EntryPoint balance of this Paymaster falls below this value
33+
uint128 minEntryPointBalance;
34+
/// @notice Estimated gas cost for refunding tokens after the transaction is completed
35+
uint48 refundPostopCost;
36+
/// @notice Transactions are only valid as long as the cached price is not older than this value
37+
uint48 priceMaxAge;
38+
}
39+
40+
event ConfigUpdated(TokenPaymasterConfig tokenPaymasterConfig);
41+
42+
event UserOperationSponsored(
43+
address indexed user,
44+
uint256 actualTokenCharge,
45+
uint256 actualGasCost,
46+
uint256 actualTokenPriceWithMarkup
47+
);
48+
49+
event Received(address indexed sender, uint256 value);
50+
51+
/// @notice All 'price' variables are multiplied by this value to avoid rounding up
52+
uint256 private constant PRICE_DENOMINATOR = 1e26;
53+
54+
TokenPaymasterConfig public tokenPaymasterConfig;
55+
56+
uint256 private immutable _tokenDecimals;
57+
58+
/// @notice Initializes the TokenPaymaster contract with the given parameters.
59+
/// @param _token The ERC20 token used for transaction fee payments.
60+
/// @param _entryPoint The EntryPoint contract used in the Account Abstraction infrastructure.
61+
/// @param _wrappedNative The ERC-20 token that wraps the native asset for current chain.
62+
/// @param _uniswap The Uniswap V3 SwapRouter contract.
63+
/// @param _tokenPaymasterConfig The configuration for the Token Paymaster.
64+
/// @param _oracleHelperConfig The configuration for the Oracle Helper.
65+
/// @param _uniswapHelperConfig The configuration for the Uniswap Helper.
66+
/// @param _owner The address that will be set as the owner of the contract.
67+
constructor(
68+
IERC20Metadata _token,
69+
IEntryPoint _entryPoint,
70+
IERC20 _wrappedNative,
71+
ISwapRouter _uniswap,
72+
TokenPaymasterConfig memory _tokenPaymasterConfig,
73+
OracleHelperConfig memory _oracleHelperConfig,
74+
UniswapHelperConfig memory _uniswapHelperConfig,
75+
address _owner
76+
)
77+
BasePaymaster(_entryPoint)
78+
OracleHelper(_oracleHelperConfig)
79+
UniswapHelper(_token, _wrappedNative, _uniswap, _uniswapHelperConfig)
80+
{
81+
_tokenDecimals = _token.decimals();
82+
require(_tokenDecimals <= 18, "TPM: token not supported");
83+
84+
setTokenPaymasterConfig(_tokenPaymasterConfig);
85+
transferOwnership(_owner);
86+
}
87+
88+
/// @notice Updates the configuration for the Token Paymaster.
89+
/// @param _tokenPaymasterConfig The new configuration struct.
90+
function setTokenPaymasterConfig(TokenPaymasterConfig memory _tokenPaymasterConfig) public onlyOwner {
91+
require(_tokenPaymasterConfig.priceMarkup <= 2 * PRICE_DENOMINATOR, "TPM: price markup too high");
92+
require(_tokenPaymasterConfig.priceMarkup >= PRICE_DENOMINATOR, "TPM: price markup too low");
93+
tokenPaymasterConfig = _tokenPaymasterConfig;
94+
emit ConfigUpdated(_tokenPaymasterConfig);
95+
}
96+
97+
function setUniswapConfiguration(UniswapHelperConfig memory _uniswapHelperConfig) external onlyOwner {
98+
_setUniswapHelperConfiguration(_uniswapHelperConfig);
99+
}
100+
101+
/// @notice Allows the contract owner to withdraw a specified amount of tokens from the contract.
102+
/// @param to The address to transfer the tokens to.
103+
/// @param amount The amount of tokens to transfer.
104+
function withdrawToken(address to, uint256 amount) external onlyOwner {
105+
SafeERC20.safeTransfer(token, to, amount);
106+
}
107+
108+
/// @notice Validates a paymaster user operation and calculates the required token amount for the transaction.
109+
/// @param userOp The user operation data.
110+
/// @param requiredPreFund The maximum cost (in native token) the paymaster has to prefund.
111+
/// @return context The context containing the token amount and user sender address (if applicable).
112+
/// @return validationResult A uint256 value indicating the result of the validation (always 0 in this implementation).
113+
function _validatePaymasterUserOp(
114+
PackedUserOperation calldata userOp,
115+
bytes32,
116+
uint256 requiredPreFund
117+
) internal override returns (bytes memory context, uint256 validationResult) {
118+
unchecked {
119+
uint256 priceMarkup = tokenPaymasterConfig.priceMarkup;
120+
uint256 dataLength = userOp.paymasterAndData.length - PAYMASTER_DATA_OFFSET;
121+
require(dataLength == 0 || dataLength == 32, "TPM: invalid data length");
122+
uint256 maxFeePerGas = userOp.unpackMaxFeePerGas();
123+
uint256 refundPostopCost = tokenPaymasterConfig.refundPostopCost;
124+
require(refundPostopCost < userOp.unpackPostOpGasLimit(), "TPM: postOpGasLimit too low");
125+
uint256 preChargeNative = requiredPreFund + (refundPostopCost * maxFeePerGas);
126+
// note: as price is in native-asset-per-token and we want more tokens increasing it means dividing it by markup
127+
uint256 cachedPriceWithMarkup = (cachedPrice * PRICE_DENOMINATOR) / priceMarkup;
128+
if (dataLength == 32) {
129+
uint256 clientSuppliedPrice = uint256(
130+
bytes32(userOp.paymasterAndData[PAYMASTER_DATA_OFFSET:PAYMASTER_DATA_OFFSET + 32])
131+
);
132+
if (clientSuppliedPrice < cachedPriceWithMarkup) {
133+
// note: smaller number means 'more native asset per token'
134+
cachedPriceWithMarkup = clientSuppliedPrice;
135+
}
136+
}
137+
uint256 tokenAmount = weiToToken(preChargeNative, _tokenDecimals, cachedPriceWithMarkup);
138+
SafeERC20.safeTransferFrom(token, userOp.sender, address(this), tokenAmount);
139+
context = abi.encode(tokenAmount, userOp.sender);
140+
validationResult = _packValidationData(
141+
false,
142+
uint48(cachedPriceTimestamp + tokenPaymasterConfig.priceMaxAge),
143+
0
144+
);
145+
}
146+
}
147+
148+
/// @notice Performs post-operation tasks, such as updating the token price and refunding excess tokens.
149+
/// @dev This function is called after a user operation has been executed or reverted.
150+
/// @param context The context containing the token amount and user sender address.
151+
/// @param actualGasCost The actual gas cost of the transaction.
152+
/// @param actualUserOpFeePerGas - the gas price this UserOp pays. This value is based on the UserOp's maxFeePerGas
153+
// and maxPriorityFee (and basefee)
154+
// It is not the same as tx.gasprice, which is what the bundler pays.
155+
function _postOp(
156+
PostOpMode,
157+
bytes calldata context,
158+
uint256 actualGasCost,
159+
uint256 actualUserOpFeePerGas
160+
) internal override {
161+
unchecked {
162+
uint256 priceMarkup = tokenPaymasterConfig.priceMarkup;
163+
(uint256 preCharge, address userOpSender) = abi.decode(context, (uint256, address));
164+
uint256 _cachedPrice = updateCachedPrice(false);
165+
// note: as price is in native-asset-per-token and we want more tokens increasing it means dividing it by markup
166+
uint256 cachedPriceWithMarkup = (_cachedPrice * PRICE_DENOMINATOR) / priceMarkup;
167+
// Refund tokens based on actual gas cost
168+
uint256 actualChargeNative = actualGasCost + tokenPaymasterConfig.refundPostopCost * actualUserOpFeePerGas;
169+
uint256 actualTokenNeeded = weiToToken(actualChargeNative, _tokenDecimals, cachedPriceWithMarkup);
170+
171+
if (preCharge > actualTokenNeeded) {
172+
// If the initially provided token amount is greater than the actual amount needed, refund the difference
173+
SafeERC20.safeTransfer(token, userOpSender, preCharge - actualTokenNeeded);
174+
} else if (preCharge < actualTokenNeeded) {
175+
// Attempt to cover Paymaster's gas expenses by withdrawing the 'overdraft' from the client
176+
// If the transfer reverts also revert the 'postOp' to remove the incentive to cheat
177+
SafeERC20.safeTransferFrom(token, userOpSender, address(this), actualTokenNeeded - preCharge);
178+
}
179+
180+
emit UserOperationSponsored(userOpSender, actualTokenNeeded, actualGasCost, cachedPriceWithMarkup);
181+
refillEntryPointDeposit(_cachedPrice);
182+
}
183+
}
184+
185+
/// @notice If necessary this function uses this Paymaster's token balance to refill the deposit on EntryPoint
186+
/// @param _cachedPrice the token price that will be used to calculate the swap amount.
187+
function refillEntryPointDeposit(uint256 _cachedPrice) private {
188+
uint256 currentEntryPointBalance = entryPoint.balanceOf(address(this));
189+
if (currentEntryPointBalance < tokenPaymasterConfig.minEntryPointBalance) {
190+
uint256 swappedWeth = _maybeSwapTokenToWeth(token, _cachedPrice);
191+
unwrapWeth(swappedWeth);
192+
entryPoint.depositTo{ value: address(this).balance }(address(this));
193+
}
194+
}
195+
196+
receive() external payable {
197+
emit Received(msg.sender, msg.value);
198+
}
199+
200+
function withdrawEth(address payable recipient, uint256 amount) external onlyOwner {
201+
(bool success, ) = recipient.call{ value: amount }("");
202+
require(success, "withdraw failed");
203+
}
204+
}

0 commit comments

Comments
 (0)