diff --git a/.gitmodules b/.gitmodules index 083a93f87..43557e2ee 100644 --- a/.gitmodules +++ b/.gitmodules @@ -46,3 +46,9 @@ [submodule "lib/openzeppelin-contracts"] path = lib/openzeppelin-contracts url = https://github.com/OpenZeppelin/openzeppelin-contracts +[submodule "lib/v3-periphery"] + path = lib/v3-periphery + url = https://github.com/uniswap/v3-periphery +[submodule "lib/v3-core"] + path = lib/v3-core + url = https://github.com/uniswap/v3-core diff --git a/contracts/prebuilts/account/interfaces/IOracle.sol b/contracts/prebuilts/account/interfaces/IOracle.sol new file mode 100644 index 000000000..bef4f2d3c --- /dev/null +++ b/contracts/prebuilts/account/interfaces/IOracle.sol @@ -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); +} diff --git a/contracts/prebuilts/account/token-paymaster/BasePaymaster.sol b/contracts/prebuilts/account/token-paymaster/BasePaymaster.sol new file mode 100644 index 000000000..a1b337ab3 --- /dev/null +++ b/contracts/prebuilts/account/token-paymaster/BasePaymaster.sol @@ -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"); + } +} diff --git a/contracts/prebuilts/account/token-paymaster/TokenPaymaster.sol b/contracts/prebuilts/account/token-paymaster/TokenPaymaster.sol new file mode 100644 index 000000000..f2705f6f4 --- /dev/null +++ b/contracts/prebuilts/account/token-paymaster/TokenPaymaster.sol @@ -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"); + } +} diff --git a/contracts/prebuilts/account/utils/OracleHelper.sol b/contracts/prebuilts/account/utils/OracleHelper.sol new file mode 100644 index 000000000..b7fa334d1 --- /dev/null +++ b/contracts/prebuilts/account/utils/OracleHelper.sol @@ -0,0 +1,154 @@ +// SPDX-License-Identifier: GPL-3.0 +pragma solidity ^0.8.23; + +/* solhint-disable not-rely-on-time */ + +import "../interfaces/IOracle.sol"; + +/// @title Helper functions for dealing with various forms of price feed oracles. +/// @notice Maintains a price cache and updates the current price if needed. +/// In the best case scenario we have a direct oracle from the token to the native asset. +/// Also support tokens that have no direct price oracle to the native asset. +/// Sometimes oracles provide the price in the opposite direction of what we need in the moment. +abstract contract OracleHelper { + event TokenPriceUpdated(uint256 currentPrice, uint256 previousPrice, uint256 cachedPriceTimestamp); + + uint256 private constant PRICE_DENOMINATOR = 1e26; + + struct OracleHelperConfig { + /// @notice The price cache will be returned without even fetching the oracles for this number of seconds + uint48 cacheTimeToLive; + /// @notice The maximum acceptable age of the price oracle round + uint48 maxOracleRoundAge; + /// @notice The Oracle contract used to fetch the latest token prices + IOracle tokenOracle; + /// @notice The Oracle contract used to fetch the latest native asset prices. Only needed if tokenToNativeOracle flag is not set. + IOracle nativeOracle; + /// @notice If 'true' we will fetch price directly from tokenOracle + /// @notice If 'false' we will use nativeOracle to establish a token price through a shared third currency + bool tokenToNativeOracle; + /// @notice 'false' if price is bridging-asset-per-token (or native-asset-per-token), 'true' if price is tokens-per-bridging-asset + bool tokenOracleReverse; + /// @notice 'false' if price is bridging-asset-per-native-asset, 'true' if price is native-asset-per-bridging-asset + bool nativeOracleReverse; + /// @notice The price update threshold percentage from PRICE_DENOMINATOR that triggers a price update (1e26 = 100%) + uint256 priceUpdateThreshold; + } + + /// @notice The cached token price from the Oracle, always in (native-asset-per-token) * PRICE_DENOMINATOR format + uint256 public cachedPrice; + + /// @notice The timestamp of a block when the cached price was updated + uint48 public cachedPriceTimestamp; + + OracleHelperConfig private oracleHelperConfig; + + /// @notice The "10^(tokenOracle.decimals)" value used for the price calculation + uint128 private tokenOracleDecimalPower; + + /// @notice The "10^(nativeOracle.decimals)" value used for the price calculation + uint128 private nativeOracleDecimalPower; + + constructor(OracleHelperConfig memory _oracleHelperConfig) { + cachedPrice = type(uint256).max; // initialize the storage slot to invalid value + _setOracleConfiguration(_oracleHelperConfig); + } + + function _setOracleConfiguration(OracleHelperConfig memory _oracleHelperConfig) private { + oracleHelperConfig = _oracleHelperConfig; + require(_oracleHelperConfig.priceUpdateThreshold <= PRICE_DENOMINATOR, "TPM: update threshold too high"); + tokenOracleDecimalPower = uint128(10 ** oracleHelperConfig.tokenOracle.decimals()); + if (oracleHelperConfig.tokenToNativeOracle) { + require(address(oracleHelperConfig.nativeOracle) == address(0), "TPM: native oracle must be zero"); + nativeOracleDecimalPower = 1; + } else { + nativeOracleDecimalPower = uint128(10 ** oracleHelperConfig.nativeOracle.decimals()); + } + } + + /// @notice Updates the token price by fetching the latest price from the Oracle. + /// @param force true to force cache update, even if called after short time or the change is lower than the update threshold. + /// @return newPrice the new cached token price + function updateCachedPrice(bool force) public returns (uint256) { + uint256 cacheTimeToLive = oracleHelperConfig.cacheTimeToLive; + uint256 cacheAge = block.timestamp - cachedPriceTimestamp; + if (!force && cacheAge <= cacheTimeToLive) { + return cachedPrice; + } + uint256 priceUpdateThreshold = oracleHelperConfig.priceUpdateThreshold; + IOracle tokenOracle = oracleHelperConfig.tokenOracle; + IOracle nativeOracle = oracleHelperConfig.nativeOracle; + + uint256 _cachedPrice = cachedPrice; + uint256 tokenPrice = fetchPrice(tokenOracle); + uint256 nativeAssetPrice = 1; + // If the 'TokenOracle' returns the price in the native asset units there is no need to fetch native asset price + if (!oracleHelperConfig.tokenToNativeOracle) { + nativeAssetPrice = fetchPrice(nativeOracle); + } + uint256 newPrice = calculatePrice( + tokenPrice, + nativeAssetPrice, + oracleHelperConfig.tokenOracleReverse, + oracleHelperConfig.nativeOracleReverse + ); + uint256 priceRatio = (PRICE_DENOMINATOR * newPrice) / _cachedPrice; + bool updateRequired = force || + priceRatio > PRICE_DENOMINATOR + priceUpdateThreshold || + priceRatio < PRICE_DENOMINATOR - priceUpdateThreshold; + if (!updateRequired) { + return _cachedPrice; + } + cachedPrice = newPrice; + cachedPriceTimestamp = uint48(block.timestamp); + emit TokenPriceUpdated(newPrice, _cachedPrice, cachedPriceTimestamp); + return newPrice; + } + + /** + * Calculate the effective price of the selected token denominated in native asset. + * + * @param tokenPrice - the price of the token relative to a native asset or a bridging asset like the U.S. dollar. + * @param nativeAssetPrice - the price of the native asset relative to a bridging asset or 1 if no bridging needed. + * @param tokenOracleReverse - flag indicating direction of the "tokenPrice". + * @param nativeOracleReverse - flag indicating direction of the "nativeAssetPrice". + * @return the native-asset-per-token price multiplied by the PRICE_DENOMINATOR constant. + */ + function calculatePrice( + uint256 tokenPrice, + uint256 nativeAssetPrice, + bool tokenOracleReverse, + bool nativeOracleReverse + ) private view returns (uint256) { + // tokenPrice is normalized as bridging-asset-per-token + if (tokenOracleReverse) { + // inverting tokenPrice that was tokens-per-bridging-asset (or tokens-per-native-asset) + tokenPrice = (PRICE_DENOMINATOR * tokenOracleDecimalPower) / tokenPrice; + } else { + // tokenPrice already bridging-asset-per-token (or native-asset-per-token) + tokenPrice = (PRICE_DENOMINATOR * tokenPrice) / tokenOracleDecimalPower; + } + + if (nativeOracleReverse) { + // multiplying by nativeAssetPrice that is native-asset-per-bridging-asset + // => result = (bridging-asset / token) * (native-asset / bridging-asset) = native-asset / token + return (nativeAssetPrice * tokenPrice) / nativeOracleDecimalPower; + } else { + // dividing by nativeAssetPrice that is bridging-asset-per-native-asset + // => result = (bridging-asset / token) / (bridging-asset / native-asset) = native-asset / token + return (tokenPrice * nativeOracleDecimalPower) / nativeAssetPrice; + } + } + + /// @notice Fetches the latest price from the given Oracle. + /// @dev This function is used to get the latest price from the tokenOracle or nativeOracle. + /// @param _oracle The Oracle contract to fetch the price from. + /// @return price The latest price fetched from the Oracle. + function fetchPrice(IOracle _oracle) internal view returns (uint256 price) { + (uint80 roundId, int256 answer, , uint256 updatedAt, uint80 answeredInRound) = _oracle.latestRoundData(); + require(answer > 0, "TPM: Chainlink price <= 0"); + require(updatedAt >= block.timestamp - oracleHelperConfig.maxOracleRoundAge, "TPM: Incomplete round"); + require(answeredInRound >= roundId, "TPM: Stale price"); + price = uint256(answer); + } +} diff --git a/contracts/prebuilts/account/utils/UniswapHelper.sol b/contracts/prebuilts/account/utils/UniswapHelper.sol new file mode 100644 index 000000000..39b541621 --- /dev/null +++ b/contracts/prebuilts/account/utils/UniswapHelper.sol @@ -0,0 +1,116 @@ +// SPDX-License-Identifier: GPL-3.0 +pragma solidity ^0.8.23; + +/* solhint-disable not-rely-on-time */ + +import "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import "@openzeppelin/contracts/token/ERC20/extensions/IERC20Metadata.sol"; + +import "@uniswap/v3-periphery/contracts/interfaces/ISwapRouter.sol"; +import "@uniswap/v3-periphery/contracts/interfaces/IPeripheryPayments.sol"; + +abstract contract UniswapHelper { + event UniswapReverted(address tokenIn, address tokenOut, uint256 amountIn, uint256 amountOutMin); + + uint256 private constant PRICE_DENOMINATOR = 1e26; + + struct UniswapHelperConfig { + /// @notice Minimum native asset amount to receive from a single swap + uint256 minSwapAmount; + uint24 uniswapPoolFee; + uint8 slippage; + } + + /// @notice The Uniswap V3 SwapRouter contract + ISwapRouter public immutable uniswap; + + /// @notice The ERC20 token used for transaction fee payments + IERC20Metadata public immutable token; + + /// @notice The ERC-20 token that wraps the native asset for current chain + IERC20 public immutable wrappedNative; + + UniswapHelperConfig private uniswapHelperConfig; + + constructor( + IERC20Metadata _token, + IERC20 _wrappedNative, + ISwapRouter _uniswap, + UniswapHelperConfig memory _uniswapHelperConfig + ) { + _token.approve(address(_uniswap), type(uint256).max); + token = _token; + wrappedNative = _wrappedNative; + uniswap = _uniswap; + _setUniswapHelperConfiguration(_uniswapHelperConfig); + } + + function _setUniswapHelperConfiguration(UniswapHelperConfig memory _uniswapHelperConfig) internal { + uniswapHelperConfig = _uniswapHelperConfig; + } + + function _maybeSwapTokenToWeth(IERC20Metadata tokenIn, uint256 quote) internal returns (uint256) { + uint256 tokenBalance = tokenIn.balanceOf(address(this)); + uint256 tokenDecimals = tokenIn.decimals(); + + uint256 amountOutMin = addSlippage( + tokenToWei(tokenBalance, tokenDecimals, quote), + uniswapHelperConfig.slippage + ); + + if (amountOutMin < uniswapHelperConfig.minSwapAmount) { + return 0; + } + // note: calling 'swapToToken' but destination token is Wrapped Ether + return + swapToToken( + address(tokenIn), + address(wrappedNative), + tokenBalance, + amountOutMin, + uniswapHelperConfig.uniswapPoolFee + ); + } + + function addSlippage(uint256 amount, uint8 slippage) private pure returns (uint256) { + return (amount * (1000 - slippage)) / 1000; + } + + function tokenToWei(uint256 amount, uint256 decimals, uint256 price) public pure returns (uint256) { + return (amount * price * (10 ** (18 - decimals))) / PRICE_DENOMINATOR; + } + + function weiToToken(uint256 amount, uint256 decimals, uint256 price) public pure returns (uint256) { + return (amount * PRICE_DENOMINATOR) / (price * (10 ** (18 - decimals))); + } + + function unwrapWeth(uint256 amount) internal { + IPeripheryPayments(address(uniswap)).unwrapWETH9(amount, address(this)); + } + + // swap ERC-20 tokens at market price + function swapToToken( + address tokenIn, + address tokenOut, + uint256 amountIn, + uint256 amountOutMin, + uint24 fee + ) internal returns (uint256 amountOut) { + ISwapRouter.ExactInputSingleParams memory params = ISwapRouter.ExactInputSingleParams( + tokenIn, //tokenIn + tokenOut, //tokenOut + fee, + address(uniswap), + block.timestamp, //deadline + amountIn, + amountOutMin, + 0 + ); + try uniswap.exactInputSingle(params) returns (uint256 _amountOut) { + amountOut = _amountOut; + } catch { + emit UniswapReverted(tokenIn, tokenOut, amountIn, amountOutMin); + amountOut = 0; + } + } +} diff --git a/foundry.toml b/foundry.toml index 55e866609..13a9e8619 100644 --- a/foundry.toml +++ b/foundry.toml @@ -29,6 +29,8 @@ optimizer = true optimizer_runs = 20 out = 'artifacts_forge' remappings = [ + '@uniswap/v3-core/contracts=lib/v3-core/contracts', + '@uniswap/v3-periphery/contracts=lib/v3-periphery/contracts', '@chainlink/=lib/chainlink/', '@openzeppelin/contracts=lib/openzeppelin-contracts/contracts', '@openzeppelin/contracts-upgradeable/=lib/openzeppelin-contracts-upgradeable/contracts/', diff --git a/lib/v3-core b/lib/v3-core new file mode 160000 index 000000000..e3589b192 --- /dev/null +++ b/lib/v3-core @@ -0,0 +1 @@ +Subproject commit e3589b192d0be27e100cd0daaf6c97204fdb1899 diff --git a/lib/v3-periphery b/lib/v3-periphery new file mode 160000 index 000000000..80f26c86c --- /dev/null +++ b/lib/v3-periphery @@ -0,0 +1 @@ +Subproject commit 80f26c86c57b8a5e4b913f42844d4c8bd274d058 diff --git a/src/test/mocks/MockERC20CustomDecimals.sol b/src/test/mocks/MockERC20CustomDecimals.sol new file mode 100644 index 000000000..65ad6a20a --- /dev/null +++ b/src/test/mocks/MockERC20CustomDecimals.sol @@ -0,0 +1,30 @@ +// SPDX-License-Identifier: GPL-3.0 +pragma solidity ^0.8.23; + +import "@openzeppelin/contracts/token/ERC20/ERC20.sol"; +import "@openzeppelin/contracts/token/ERC20/presets/ERC20PresetMinterPauser.sol"; +import "@openzeppelin/contracts/token/ERC20/extensions/draft-ERC20Permit.sol"; + +contract MockERC20CustomDecimals is ERC20PresetMinterPauser, ERC20Permit { + uint8 private immutable _decimals; + + constructor(uint8 decimals_) ERC20PresetMinterPauser("Mock Coin", "MOCK") ERC20Permit("Mock Coin") { + _decimals = decimals_; + } + + function mint(address to, uint256 amount) public override(ERC20PresetMinterPauser) { + _mint(to, amount); + } + + function decimals() public view override returns (uint8) { + return _decimals; + } + + function _beforeTokenTransfer( + address from, + address to, + uint256 amount + ) internal override(ERC20PresetMinterPauser, ERC20) { + super._beforeTokenTransfer(from, to, amount); + } +} diff --git a/src/test/mocks/TestOracle2.sol b/src/test/mocks/TestOracle2.sol new file mode 100644 index 000000000..77c180c5b --- /dev/null +++ b/src/test/mocks/TestOracle2.sol @@ -0,0 +1,44 @@ +// SPDX-License-Identifier: GPL-3.0 +pragma solidity ^0.8.23; + +// source: https://github.com/eth-infinitism/account-abstraction/blob/develop/contracts/test/TestOracle2.sol + +interface IOracle { + function decimals() external view returns (uint8); + function latestRoundData() + external + view + returns (uint80 roundId, int256 answer, uint256 startedAt, uint256 updatedAt, uint80 answeredInRound); +} + +contract TestOracle2 is IOracle { + int256 public price; + uint8 private _decimals_; + + constructor(int256 _price, uint8 _decimals) { + price = _price; + _decimals_ = _decimals; + } + + function setPrice(int256 _price) external { + price = _price; + } + + function setDecimals(uint8 _decimals) external { + _decimals_ = _decimals; + } + + function decimals() external view override returns (uint8) { + return _decimals_; + } + + function latestRoundData() + external + view + override + returns (uint80 roundId, int256 answer, uint256 startedAt, uint256 updatedAt, uint80 answeredInRound) + { + // solhint-disable-next-line not-rely-on-time + return (73786976294838215802, price, 1680509051, block.timestamp, 73786976294838215802); + } +} diff --git a/src/test/mocks/TestUniswap.sol b/src/test/mocks/TestUniswap.sol new file mode 100644 index 000000000..4edfef65a --- /dev/null +++ b/src/test/mocks/TestUniswap.sol @@ -0,0 +1,52 @@ +// SPDX-License-Identifier: GPL-3.0 +pragma solidity ^0.8.0; + +// source: https://github.com/eth-infinitism/account-abstraction/blob/develop/contracts/test/TestUniswap.sol + +import "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import "@uniswap/v3-periphery/contracts/interfaces/ISwapRouter.sol"; + +import "./WETH9.sol"; + +/// @notice Very basic simulation of what Uniswap does with the swaps for the unit tests on the TokenPaymaster +/// @dev Do not use to test any actual Uniswap interaction logic as this is way too simplistic +contract TestUniswap { + WETH9 public weth; + + constructor(WETH9 _weth) { + weth = _weth; + } + + event StubUniswapExchangeEvent(uint256 amountIn, uint256 amountOut, address tokenIn, address tokenOut); + + function exactOutputSingle(ISwapRouter.ExactOutputSingleParams calldata params) external returns (uint256) { + uint256 amountIn = params.amountInMaximum - 5; + emit StubUniswapExchangeEvent(amountIn, params.amountOut, params.tokenIn, params.tokenOut); + IERC20(params.tokenIn).transferFrom(msg.sender, address(this), amountIn); + IERC20(params.tokenOut).transfer(params.recipient, params.amountOut); + return amountIn; + } + + function exactInputSingle(ISwapRouter.ExactInputSingleParams calldata params) external returns (uint256) { + uint256 amountOut = params.amountOutMinimum + 5; + emit StubUniswapExchangeEvent(params.amountIn, amountOut, params.tokenIn, params.tokenOut); + IERC20(params.tokenIn).transferFrom(msg.sender, address(this), params.amountIn); + IERC20(params.tokenOut).transfer(params.recipient, amountOut); + return amountOut; + } + + /// @notice Simplified code copied from here: + /// https://github.com/Uniswap/v3-periphery/blob/main/contracts/base/PeripheryPayments.sol#L19 + function unwrapWETH9(uint256 amountMinimum, address recipient) public payable { + uint256 balanceWETH9 = weth.balanceOf(address(this)); + require(balanceWETH9 >= amountMinimum, "Insufficient WETH9"); + + if (balanceWETH9 > 0) { + weth.withdraw(balanceWETH9); + payable(recipient).transfer(balanceWETH9); + } + } + + // solhint-disable-next-line no-empty-blocks + receive() external payable {} +} diff --git a/src/test/smart-wallet/token-paymaster/TokenPaymaster.t.sol b/src/test/smart-wallet/token-paymaster/TokenPaymaster.t.sol new file mode 100644 index 000000000..fed3bf3d5 --- /dev/null +++ b/src/test/smart-wallet/token-paymaster/TokenPaymaster.t.sol @@ -0,0 +1,229 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.0; + +// Test utils +import "../../utils/BaseTest.sol"; +import { MockERC20CustomDecimals } from "../../mocks/MockERC20CustomDecimals.sol"; +import { TestUniswap } from "../../mocks/TestUniswap.sol"; +import { TestOracle2 } from "../../mocks/TestOracle2.sol"; +import { ECDSA } from "@openzeppelin/contracts/utils/cryptography/ECDSA.sol"; + +// Account Abstraction setup for smart wallets. +import { EntryPoint, IEntryPoint } from "contracts/prebuilts/account/utils/EntryPoint.sol"; +import { PackedUserOperation } from "contracts/prebuilts/account/interfaces/PackedUserOperation.sol"; + +// Target +import { IAccountPermissions } from "contracts/extension/interface/IAccountPermissions.sol"; +import { AccountFactory } from "contracts/prebuilts/account/non-upgradeable/AccountFactory.sol"; +import { Account as SimpleAccount } from "contracts/prebuilts/account/non-upgradeable/Account.sol"; +import { TokenPaymaster, IERC20Metadata } from "contracts/prebuilts/account/token-paymaster/TokenPaymaster.sol"; +import { OracleHelper, IOracle } from "contracts/prebuilts/account/utils/OracleHelper.sol"; +import { UniswapHelper, ISwapRouter } from "contracts/prebuilts/account/utils/UniswapHelper.sol"; + +/// @dev This is a dummy contract to test contract interactions with Account. +contract Number { + uint256 public num; + + function setNum(uint256 _num) public { + num = _num; + } + + function doubleNum() public { + num *= 2; + } + + function incrementNum() public { + num += 1; + } +} + +contract TokenPaymasterTest is BaseTest { + EntryPoint private entrypoint; + AccountFactory private accountFactory; + SimpleAccount private account; + MockERC20CustomDecimals private token; + TestUniswap private testUniswap; + TestOracle2 private nativeAssetOracle; + TestOracle2 private tokenOracle; + TokenPaymaster private paymaster; + + Number private numberContract; + + int256 initialPriceToken = 100000000; // USD per TOK + int256 initialPriceEther = 500000000; // USD per ETH + + uint256 priceDenominator = 10 ** 26; + uint128 minEntryPointBalance = 1e17; + + address payable private beneficiary = payable(address(0x45654)); + + uint256 private accountAdminPKey = 100; + address private accountAdmin; + + uint256 private accountSignerPKey = 200; + address private accountSigner; + + uint256 private nonSignerPKey = 300; + address private nonSigner; + + uint256 private paymasterOwnerPKey = 400; + address private paymasterOwner; + address private paymasterAddress; + + function setUp() public override { + super.setUp(); + + // Setup signers. + accountAdmin = vm.addr(accountAdminPKey); + vm.deal(accountAdmin, 100 ether); + + accountSigner = vm.addr(accountSignerPKey); + nonSigner = vm.addr(nonSignerPKey); + paymasterOwner = vm.addr(paymasterOwnerPKey); + + // Setup contracts + entrypoint = new EntryPoint(); + testUniswap = new TestUniswap(weth); + accountFactory = new AccountFactory(deployer, IEntryPoint(payable(address(entrypoint)))); + account = SimpleAccount(payable(accountFactory.createAccount(accountAdmin, bytes("")))); + token = new MockERC20CustomDecimals(6); + nativeAssetOracle = new TestOracle2(initialPriceEther, 8); + tokenOracle = new TestOracle2(initialPriceToken, 8); + numberContract = new Number(); + + weth.deposit{ value: 1 ether }(); + weth.transfer(address(testUniswap), 1 ether); + + TokenPaymaster.TokenPaymasterConfig memory tokenPaymasterConfig = TokenPaymaster.TokenPaymasterConfig({ + priceMarkup: (priceDenominator * 15) / 10, // +50% + minEntryPointBalance: minEntryPointBalance, + refundPostopCost: 40000, + priceMaxAge: 86400 + }); + + OracleHelper.OracleHelperConfig memory oracleHelperConfig = OracleHelper.OracleHelperConfig({ + cacheTimeToLive: 0, + maxOracleRoundAge: 0, + nativeOracle: IOracle(address(nativeAssetOracle)), + nativeOracleReverse: false, + priceUpdateThreshold: (priceDenominator * 12) / 100, // 20% + tokenOracle: IOracle(address(tokenOracle)), + tokenOracleReverse: false, + tokenToNativeOracle: false + }); + + UniswapHelper.UniswapHelperConfig memory uniswapHelperConfig = UniswapHelper.UniswapHelperConfig({ + minSwapAmount: 1, + slippage: 5, + uniswapPoolFee: 3 + }); + + paymaster = new TokenPaymaster( + IERC20Metadata(address(token)), + entrypoint, + weth, + ISwapRouter(address(testUniswap)), + tokenPaymasterConfig, + oracleHelperConfig, + uniswapHelperConfig, + paymasterOwner + ); + paymasterAddress = address(paymaster); + + token.mint(paymasterOwner, 10_000 ether); + vm.deal(paymasterOwner, 10_000 ether); + + vm.startPrank(paymasterOwner); + token.transfer(address(paymaster), 100); + paymaster.updateCachedPrice(true); + entrypoint.depositTo{ value: 1000 ether }(address(paymaster)); + paymaster.addStake{ value: 2 ether }(1); + vm.stopPrank(); + } + + // test utils + function _packPaymasterStaticFields( + address paymaster, + uint128 validationGasLimit, + uint128 postOpGasLimit + ) internal pure returns (bytes memory) { + return abi.encodePacked(bytes20(paymaster), bytes16(validationGasLimit), bytes16(postOpGasLimit)); + } + + function _setupUserOpWithSenderAndPaymaster( + bytes memory _initCode, + bytes memory _callDataForEntrypoint, + address _sender, + address _paymaster, + uint128 _paymasterVerificationGasLimit, + uint128 _paymasterPostOpGasLimit + ) internal returns (PackedUserOperation[] memory ops) { + uint256 nonce = entrypoint.getNonce(_sender, 0); + PackedUserOperation memory op; + + { + uint128 verificationGasLimit = 500_000; + uint128 callGasLimit = 500_000; + bytes32 packedAccountGasLimits = (bytes32(uint256(verificationGasLimit)) << 128) | + bytes32(uint256(callGasLimit)); + bytes32 packedGasLimits = (bytes32(uint256(1e9)) << 128) | bytes32(uint256(1e9)); + + // Get user op fields + op = PackedUserOperation({ + sender: _sender, + nonce: nonce, + initCode: _initCode, + callData: _callDataForEntrypoint, + accountGasLimits: packedAccountGasLimits, + preVerificationGas: 500_000, + gasFees: packedGasLimits, + paymasterAndData: _packPaymasterStaticFields( + _paymaster, + _paymasterVerificationGasLimit, + _paymasterPostOpGasLimit + ), + signature: bytes("") + }); + } + + // Sign UserOp + bytes32 opHash = EntryPoint(entrypoint).getUserOpHash(op); + bytes32 msgHash = ECDSA.toEthSignedMessageHash(opHash); + + (uint8 v, bytes32 r, bytes32 s) = vm.sign(accountAdminPKey, msgHash); + bytes memory userOpSignature = abi.encodePacked(r, s, v); + + address recoveredSigner = ECDSA.recover(msgHash, v, r, s); + address expectedSigner = vm.addr(accountAdminPKey); + assertEq(recoveredSigner, expectedSigner); + + op.signature = userOpSignature; + + // Store UserOp + ops = new PackedUserOperation[](1); + ops[0] = op; + } + + // Should be able to sponsor the UserOp while charging correct amount of ERC-20 tokens + function test_validatePaymasterUserOp_correctERC20() public { + token.mint(address(account), 1 ether); + vm.prank(address(account)); + token.approve(address(paymaster), type(uint256).max); + + PackedUserOperation[] memory ops = _setupUserOpWithSenderAndPaymaster( + bytes(""), + abi.encodeWithSignature( + "execute(address,uint256,bytes)", + address(numberContract), + 0, + abi.encodeWithSignature("setNum(uint256)", 42) + ), + address(account), + address(paymaster), + 3e5, + 3e5 + ); + + entrypoint.handleOps(ops, beneficiary); + } +}