// SPDX-License-Identifier: MIT pragma solidity ^0.8.23; import "./paymaster/PaymasterBase.sol"; interface IERC20 { function transfer(address to, uint256 amount) external returns (bool); function transferFrom(address from, address to, uint256 amount) external returns (bool); function approve(address spender, uint256 amount) external returns (bool); function balanceOf(address) external view returns (uint256); function decimals() external view returns (uint8); } interface IAereOracle { /// Returns (price scaled 1e8, ts, contributors). Same interface as AereLending used. function getPrice(bytes32 symbol) external view returns (uint128 price, uint64 timestamp, uint256 contributors); } /** * @title AereTokenPaymasterV2 * @notice Bug-fix redeploy of AereTokenPaymaster (V1 at * 0xEb6e2Eb24e597C85392DdCD68a1F9b654FffdcB2). * * Pay AERE gas with any whitelisted ERC-20. * * ── Fix over V1 (MEDIUM) ────────────────────────────────────────────────── * V1.withdrawToken used IERC20(token).transferFrom(address(this), to, amount), * which requires the paymaster to hold an ERC-20 allowance for itself. No such * self-allowance is ever set, so on standard (OZ) ERC-20s the call reverts with * "ERC20: insufficient allowance" and every token the paymaster collects as gas * revenue is permanently stuck. V2.withdrawToken uses transfer(to, amount) — the * correct primitive when moving the contract's OWN balance — so the owner * (Foundation) can sweep collected tokens. * * Everything else (oracle pricing, markup, validate hook, token config) is * byte-for-byte identical to V1. * * How it works: * 1. User submits a UserOperation with paymasterData = (token, maxTokenCost). * 2. We read the token's USD price from AereOracle and AERE/USD price. * 3. We compute the AERE cost of the op, convert to token, add markup, charge. * 4. We pull tokens from the user via transferFrom (requires prior approve). */ contract AereTokenPaymasterV2 is PaymasterBase { IAereOracle public oracle; bytes32 public constant AERE_USD = keccak256("AERE/USD"); struct TokenConfig { bool enabled; bytes32 priceKey; // e.g. keccak256("USDT/USD") - returned by AereOracle uint8 decimals; } mapping(address => TokenConfig) public tokens; /// Markup over fair price, in basis points. 500 = 5%. uint256 public markupBps = 500; /// Fallback AERE/USD price (1e8 scale). Used while oracle has no AERE/USD feed. uint256 public fallbackAereUsd1e8 = 10_000_000; // $0.10 event TokenSet(address indexed token, bool enabled, bytes32 priceKey, uint8 decimals); event ConfigChanged(uint256 markupBps); event Paid(address indexed sender, address indexed token, uint256 tokenAmount); event Withdrawn(address indexed token, address indexed to, uint256 amount); constructor(IEntryPoint _ep, IAereOracle _oracle) PaymasterBase(_ep) { oracle = _oracle; } // ───────────────────── Admin ───────────────────── function setToken(address token, bytes32 priceKey, uint8 dec, bool enabled) external onlyOwner { tokens[token] = TokenConfig(enabled, priceKey, dec); emit TokenSet(token, enabled, priceKey, dec); } function setMarkup(uint256 _markupBps) external onlyOwner { require(_markupBps <= 3000, "TokenPM: markup too high"); markupBps = _markupBps; emit ConfigChanged(_markupBps); } function setFallbackAereUsd(uint256 price1e8) external onlyOwner { fallbackAereUsd1e8 = price1e8; } /// @notice Sweep collected ERC-20 gas revenue to `to`. FIX: transfer (own /// balance), not transferFrom(address(this)) which V1 used and which /// requires a self-allowance that never exists. function withdrawToken(address token, address to, uint256 amount) external onlyOwner { require(IERC20(token).transfer(to, amount), "TokenPM: withdraw failed"); emit Withdrawn(token, to, amount); } // ───────────────────── Pricing ───────────────────── function _aereUsd1e8() internal view returns (uint256) { try oracle.getPrice(AERE_USD) returns (uint128 p, uint64, uint256) { if (p > 0) return uint256(p); } catch {} return fallbackAereUsd1e8; } function quoteTokenAmount(address token, uint256 aereWei) public view returns (uint256 tokenAmount) { TokenConfig memory cfg = tokens[token]; require(cfg.enabled, "TokenPM: token disabled"); (uint128 tokPrice, , ) = oracle.getPrice(cfg.priceKey); require(tokPrice > 0, "TokenPM: oracle missing"); // USD value of the AERE: aereWei * AERE/USD (1e8) / 1e18 // Token amount = USD / (token/USD * 10^(18 - tokenDecimals)) // Combine: tokenAmount = aereWei * aereUsd / tokPrice (both prices share 1e8 scale → cancels) // Adjust to token decimals: result is in 1e18 units of USD-AERE; divide by 10^(18-dec) to get token native units. uint256 usdAmount = (aereWei * _aereUsd1e8()) / 1e8; // 1e18 USD-scaled uint256 amount1e18 = (usdAmount * 1e8) / uint256(tokPrice); // 1e18 token-scaled if (cfg.decimals < 18) { tokenAmount = amount1e18 / (10 ** (18 - cfg.decimals)); } else { tokenAmount = amount1e18 * (10 ** (cfg.decimals - 18)); } // Apply markup tokenAmount = (tokenAmount * (10_000 + markupBps)) / 10_000; } // ───────────────────── Paymaster hook ───────────────────── function _validatePaymasterUserOp( PackedUserOperation calldata userOp, bytes32 /*userOpHash*/, uint256 maxCost ) internal override returns (bytes memory context, uint256 validationData) { // paymasterAndData layout: // bytes 0..20 - paymaster address // bytes 20..36 - paymaster verification gas limit + post-op gas limit (32 bytes packed) // bytes 52..72 - token address (20 bytes) // bytes 72..104 - maxTokenCost (32 bytes) require(userOp.paymasterAndData.length >= 104, "TokenPM: bad paymasterData"); address token = address(bytes20(userOp.paymasterAndData[52:72])); uint256 maxTokenCost = uint256(bytes32(userOp.paymasterAndData[72:104])); uint256 tokenCost = quoteTokenAmount(token, maxCost); require(tokenCost <= maxTokenCost, "TokenPM: token cost exceeds max"); // Pull tokens up-front. User must have pre-approved this contract. require(IERC20(token).transferFrom(userOp.sender, address(this), tokenCost), "TokenPM: pull failed"); emit Paid(userOp.sender, token, tokenCost); return ("", 0); } }