// SPDX-License-Identifier: MIT pragma solidity ^0.8.23; import "./paymaster/PaymasterBase.sol"; /** * @dev CORRECTED interfaces to the REAL deployed staking contracts on chain 2800. * * AereStaking (0xAbDb01d9A4f41792129b2654Fb6DDB9689360DEc) does NOT expose * balanceOf(address). A delegator's stake is stored per (validator, delegator) * in the public `delegations` mapping, so a delegator's TOTAL delegated amount * is the sum over every validator of delegations[validator][delegator].amount. * A user that is itself an active validator also has validators[user].selfStake. * There is no single-argument aggregate getter on the real contract, so we read * the real public getters and aggregate (bounded). */ interface IAereStaking { // public getter for `mapping(address => Validator) public validators` function validators(address v) external view returns ( bool active, uint256 selfStake, uint256 totalDelegated, uint256 commissionBps, uint256 lastRewardBlock, uint256 accumulatedRewards ); // public getter for `mapping(address => mapping(address => Delegation)) public delegations` function delegations(address validator, address delegator) external view returns ( uint256 amount, uint256 stakedAt, uint256 lastClaimBlock ); // public getter for `address[] public validatorList` function validatorList(uint256 index) external view returns (address); function validatorCount() external view returns (uint256); } /** * @dev CORRECTED getLock field order to match the REAL AereLockedStaking * (0x21108c28A849b05aE6b7a3a5bc435C9Bc897E7Ad), whose Lock struct is: * struct Lock { uint256 amount; uint64 startedAt; uint64 unlockAt; uint8 tier; bool withdrawn; } * getLock returns the struct in THIS order (amount first). The V1 paymaster * declared (uint8 tier, uint256 amount, ...) which mis-decodes the first word * (amount = 1e21) as a uint8, failing the ABI validity check and reverting — * a revert NOT swallowed by the surrounding try/catch, bricking any real locker. */ interface IAereLockedStaking { function lockCount(address user) external view returns (uint256); function getLock(address user, uint256 lockId) external view returns ( uint256 amount, uint64 startedAt, uint64 unlockAt, uint8 tier, bool withdrawn ); } /** * @title AereStakeQuotaPaymasterV2 * @notice Bug-fix redeploy of AereStakeQuotaPaymaster (V1 at * 0xD16C86D792444c2667A26dB62f01b90FC0DaB87b, non-functional). * * "Stake AERE, get free transactions" — Tron's killer UX, brought to EVM. * * Reads the user's effective AERE stake (delegated AereStaking + own validator * self-stake + active AereLockedStaking locks) and allocates a daily free-tx * quota: * * quotaPerDay = stakedAERE * txPerKiloAere / 1000 * * stake 1,000 AERE → 20 txs/day (1 free tx per 50 AERE staked, default) * stake 10,000 AERE → 200 txs/day * * Quota resets per UTC day. Unused quota does NOT roll over. * * ── Fixes over V1 ──────────────────────────────────────────────────────── * F1 (HIGH): V1 called IAereStaking.balanceOf(address), which does not exist * on the real AereStaking → the delegated read was always caught and * contributed 0. V2 sums the real delegations across validatorList * plus the user's own validator self-stake. * F2 (HIGH): V1's IAereLockedStaking.getLock field order (tier first) did not * match the real struct (amount first) → decoding a real lock hard- * reverted (not swallowed by try/catch), so ANY user with a real lock * could never be sponsored. V2 matches the real field order exactly. * F3 (MED): the lock loop was unbounded. V2 bounds both the validator scan and * the lock scan (MAX_*_SCANNED) so a single UserOp validation cannot * be griefed into out-of-gas. * * Quota logic, per-op gas cap, daily reset and try/catch tolerance are otherwise * identical to V1. */ contract AereStakeQuotaPaymasterV2 is PaymasterBase { IAereStaking public immutable staking; IAereLockedStaking public immutable lockedStaking; /// Bound the per-validation loops so validation can never be griefed OOG. uint256 public constant MAX_LOCKS_SCANNED = 100; uint256 public constant MAX_VALIDATORS_SCANNED = 100; /// How many sponsored UserOps per 1000 AERE staked, per day. /// Default 20 = 1 free tx per 50 AERE staked. Owner-tunable. uint256 public txPerKiloAerePerDay = 20; /// Max gas the paymaster will cover per single UserOp. uint256 public maxSponsoredGasWei = 0.01 ether; /// (sender, day) => sponsoredCount mapping(bytes32 => uint256) public usedQuota; event Sponsored(address indexed sender, uint256 dayQuota, uint256 used); event ConfigChanged(uint256 txPerKilo, uint256 maxGasWei); constructor(IEntryPoint _ep, IAereStaking _staking, IAereLockedStaking _locked) PaymasterBase(_ep) { staking = _staking; lockedStaking = _locked; } // ───────────────────── Admin ───────────────────── function setQuotaParams(uint256 _txPerKilo, uint256 _maxGasWei) external onlyOwner { txPerKiloAerePerDay = _txPerKilo; maxSponsoredGasWei = _maxGasWei; emit ConfigChanged(_txPerKilo, _maxGasWei); } // ───────────────────── Quota views ───────────────────── /// Effective AERE stake of a user = /// delegated (sum over validators) + own validator self-stake + active locks. /// Every external read is wrapped in try/catch so a misbehaving/paused source /// degrades that user's quota to 0 rather than bricking validation. Both loops /// are bounded by MAX_*_SCANNED. function stakedOf(address user) public view returns (uint256 total) { // 1. Own validator self-stake (if the user is an active validator). try staking.validators(user) returns ( bool active, uint256 selfStake, uint256, uint256, uint256, uint256 ) { if (active) total += selfStake; } catch {} // 2. Delegated stake, summed across the validator set (bounded). try staking.validatorCount() returns (uint256 vn) { uint256 vcap = vn < MAX_VALIDATORS_SCANNED ? vn : MAX_VALIDATORS_SCANNED; for (uint256 i = 0; i < vcap; i++) { try staking.validatorList(i) returns (address v) { try staking.delegations(v, user) returns (uint256 amount, uint256, uint256) { total += amount; } catch {} } catch {} } } catch {} // 3. Active locked stake (bounded). try lockedStaking.lockCount(user) returns (uint256 n) { uint256 lcap = n < MAX_LOCKS_SCANNED ? n : MAX_LOCKS_SCANNED; for (uint256 i = 0; i < lcap; i++) { try lockedStaking.getLock(user, i) returns ( uint256 amount, uint64 /*startedAt*/, uint64 unlockAt, uint8 /*tier*/, bool withdrawn ) { if (!withdrawn && block.timestamp < unlockAt) { total += amount; } } catch {} } } catch {} } function dailyQuotaOf(address user) public view returns (uint256) { return (stakedOf(user) * txPerKiloAerePerDay) / 1000 ether; } function quotaRemaining(address user) external view returns (uint256 remaining, uint256 dailyQuota) { dailyQuota = dailyQuotaOf(user); uint256 today = block.timestamp / 1 days; bytes32 k = keccak256(abi.encodePacked(user, today)); uint256 used = usedQuota[k]; remaining = used >= dailyQuota ? 0 : dailyQuota - used; } // ───────────────────── Paymaster hook ───────────────────── function _validatePaymasterUserOp( PackedUserOperation calldata userOp, bytes32 /*userOpHash*/, uint256 maxCost ) internal override returns (bytes memory context, uint256 validationData) { require(maxCost <= maxSponsoredGasWei, "StakePM: tx too expensive"); address sender = userOp.sender; uint256 dailyQuota = dailyQuotaOf(sender); require(dailyQuota > 0, "StakePM: stake more AERE"); uint256 today = block.timestamp / 1 days; bytes32 k = keccak256(abi.encodePacked(sender, today)); uint256 used = usedQuota[k]; require(used < dailyQuota, "StakePM: daily quota exhausted"); usedQuota[k] = used + 1; emit Sponsored(sender, dailyQuota, used + 1); return ("", 0); } }