|
| 1 | +# Paymaster Contract for Sponsored USDC Transfers |
| 2 | + |
| 3 | +!!! info |
| 4 | + NOTE: Like all of crypto, payments require handling sensitive data. The following examples are demonstrations of integrations but should not be used in production. In production, sensitive information such as API keys, private-key wallets, etc., should be put in a secrets manager or vault. |
| 5 | + |
| 6 | +## Overview |
| 7 | + |
| 8 | +A paymaster contract allows you to sponsor gas fees for users when they transfer USDC, enabling gasless transactions. This implementation uses EIP-4337 Account Abstraction to sponsor USDC transfers on Polygon. |
| 9 | + |
| 10 | +## Prerequisites |
| 11 | + |
| 12 | +- Understanding of EIP-4337 Account Abstraction |
| 13 | +- Familiarity with Solidity and smart contracts |
| 14 | +- Access to a Polygon node |
| 15 | +- USDC contract address on Polygon: `0x3c499c542cEF5E3811e1192ce70d8cC03d5c3359` |
| 16 | + |
| 17 | +## Paymaster Contract Implementation |
| 18 | + |
| 19 | +```solidity |
| 20 | +// SPDX-License-Identifier: MIT |
| 21 | +pragma solidity ^0.8.19; |
| 22 | +
|
| 23 | +import "@account-abstraction/contracts/core/BasePaymaster.sol"; |
| 24 | +import "@account-abstraction/contracts/interfaces/IEntryPoint.sol"; |
| 25 | +import "@openzeppelin/contracts/token/ERC20/IERC20.sol"; |
| 26 | +import "@openzeppelin/contracts/access/Ownable.sol"; |
| 27 | +
|
| 28 | +contract USDCPaymaster is BasePaymaster, Ownable { |
| 29 | + IERC20 public immutable usdc; |
| 30 | + |
| 31 | + // Mapping to track sponsored accounts |
| 32 | + mapping(address => bool) public sponsoredAccounts; |
| 33 | + |
| 34 | + // Maximum gas cost we're willing to sponsor |
| 35 | + uint256 public maxGasCost = 0.01 ether; // 0.01 MATIC |
| 36 | + |
| 37 | + // Events |
| 38 | + event UserOperationSponsored(address indexed account, uint256 gasCost); |
| 39 | + event AccountSponsored(address indexed account, bool sponsored); |
| 40 | + event MaxGasCostUpdated(uint256 newMaxGasCost); |
| 41 | + |
| 42 | + constructor( |
| 43 | + IEntryPoint _entryPoint, |
| 44 | + address _usdc, |
| 45 | + address _owner |
| 46 | + ) BasePaymaster(_entryPoint) { |
| 47 | + usdc = IERC20(_usdc); |
| 48 | + _transferOwnership(_owner); |
| 49 | + } |
| 50 | + |
| 51 | + /** |
| 52 | + * @dev Validates if a user operation should be sponsored |
| 53 | + * @param userOp The user operation to validate |
| 54 | + * @param userOpHash Hash of the user operation |
| 55 | + * @param maxCost Maximum cost of the operation |
| 56 | + * @return context Validation context |
| 57 | + * @return validationResult Validation result |
| 58 | + */ |
| 59 | + function _validatePaymasterUserOp( |
| 60 | + UserOperation calldata userOp, |
| 61 | + bytes32 userOpHash, |
| 62 | + uint256 maxCost |
| 63 | + ) internal view override returns (bytes memory context, uint256 validationResult) { |
| 64 | + // Check if the operation is within our gas cost limit |
| 65 | + require(maxCost <= maxGasCost, "USDCPaymaster: gas cost too high"); |
| 66 | + |
| 67 | + // Decode the calldata to ensure it's a USDC transfer |
| 68 | + bytes4 selector = bytes4(userOp.callData[:4]); |
| 69 | + |
| 70 | + // Check if it's a direct USDC transfer or approve+transfer |
| 71 | + bool isValidUSDCOperation = _isValidUSDCOperation(userOp.callData, userOp.sender); |
| 72 | + require(isValidUSDCOperation, "USDCPaymaster: not a valid USDC operation"); |
| 73 | + |
| 74 | + // Check if account is sponsored or if it's a first-time user with USDC balance |
| 75 | + address account = userOp.sender; |
| 76 | + if (!sponsoredAccounts[account]) { |
| 77 | + uint256 usdcBalance = usdc.balanceOf(account); |
| 78 | + require(usdcBalance > 0, "USDCPaymaster: no USDC balance"); |
| 79 | + } |
| 80 | + |
| 81 | + // Return validation success |
| 82 | + return (abi.encode(account, maxCost), 0); |
| 83 | + } |
| 84 | + |
| 85 | + /** |
| 86 | + * @dev Validates if the operation is a valid USDC transfer |
| 87 | + * @param callData The call data from the user operation |
| 88 | + * @param sender The sender of the operation |
| 89 | + * @return true if it's a valid USDC operation |
| 90 | + */ |
| 91 | + function _isValidUSDCOperation(bytes calldata callData, address sender) internal view returns (bool) { |
| 92 | + // Check if it's a direct call to USDC contract |
| 93 | + if (callData.length >= 68) { // 4 bytes selector + 32 bytes address + 32 bytes amount |
| 94 | + bytes4 selector = bytes4(callData[:4]); |
| 95 | + |
| 96 | + // Check for transfer(address,uint256) or transferFrom(address,address,uint256) |
| 97 | + if (selector == IERC20.transfer.selector || selector == IERC20.transferFrom.selector) { |
| 98 | + return true; |
| 99 | + } |
| 100 | + |
| 101 | + // Check for approve(address,uint256) - common pattern before transfer |
| 102 | + if (selector == IERC20.approve.selector) { |
| 103 | + return true; |
| 104 | + } |
| 105 | + } |
| 106 | + |
| 107 | + // Check if it's a multicall that includes USDC operations |
| 108 | + return _containsUSDCOperation(callData); |
| 109 | + } |
| 110 | + |
| 111 | + /** |
| 112 | + * @dev Checks if multicall contains USDC operations |
| 113 | + * @param callData The call data to analyze |
| 114 | + * @return true if USDC operations are found |
| 115 | + */ |
| 116 | + function _containsUSDCOperation(bytes calldata callData) internal pure returns (bool) { |
| 117 | + // Basic implementation - could be enhanced for complex multicalls |
| 118 | + // Look for USDC contract address in the calldata |
| 119 | + bytes32 usdcAddress = bytes32(uint256(uint160(0x3c499c542cEF5E3811e1192ce70d8cC03d5c3359))); |
| 120 | + |
| 121 | + for (uint256 i = 0; i < callData.length - 32; i++) { |
| 122 | + if (bytes32(callData[i:i+32]) == usdcAddress) { |
| 123 | + return true; |
| 124 | + } |
| 125 | + } |
| 126 | + |
| 127 | + return false; |
| 128 | + } |
| 129 | + |
| 130 | + /** |
| 131 | + * @dev Called after the user operation is executed |
| 132 | + * @param context The context returned from validation |
| 133 | + * @param actualGasCost The actual gas cost of the operation |
| 134 | + */ |
| 135 | + function _postOp( |
| 136 | + PostOpMode mode, |
| 137 | + bytes calldata context, |
| 138 | + uint256 actualGasCost |
| 139 | + ) internal override { |
| 140 | + if (mode == PostOpMode.opSucceeded) { |
| 141 | + (address account, uint256 maxCost) = abi.decode(context, (address, uint256)); |
| 142 | + emit UserOperationSponsored(account, actualGasCost); |
| 143 | + } |
| 144 | + } |
| 145 | + |
| 146 | + // Admin functions |
| 147 | + |
| 148 | + /** |
| 149 | + * @dev Add or remove an account from sponsorship |
| 150 | + * @param account The account to sponsor/unsponsor |
| 151 | + * @param sponsored Whether to sponsor this account |
| 152 | + */ |
| 153 | + function setSponsoredAccount(address account, bool sponsored) external onlyOwner { |
| 154 | + sponsoredAccounts[account] = sponsored; |
| 155 | + emit AccountSponsored(account, sponsored); |
| 156 | + } |
| 157 | + |
| 158 | + /** |
| 159 | + * @dev Update maximum gas cost willing to sponsor |
| 160 | + * @param newMaxGasCost New maximum gas cost |
| 161 | + */ |
| 162 | + function setMaxGasCost(uint256 newMaxGasCost) external onlyOwner { |
| 163 | + maxGasCost = newMaxGasCost; |
| 164 | + emit MaxGasCostUpdated(newMaxGasCost); |
| 165 | + } |
| 166 | + |
| 167 | + /** |
| 168 | + * @dev Withdraw contract balance to owner |
| 169 | + */ |
| 170 | + function withdraw() external onlyOwner { |
| 171 | + uint256 balance = address(this).balance; |
| 172 | + require(balance > 0, "USDCPaymaster: no balance to withdraw"); |
| 173 | + |
| 174 | + (bool success, ) = payable(owner()).call{value: balance}(""); |
| 175 | + require(success, "USDCPaymaster: withdrawal failed"); |
| 176 | + } |
| 177 | + |
| 178 | + /** |
| 179 | + * @dev Add stake to the EntryPoint |
| 180 | + * @param unstakeDelaySec Unstake delay in seconds |
| 181 | + */ |
| 182 | + function addStake(uint32 unstakeDelaySec) external payable onlyOwner { |
| 183 | + entryPoint.addStake{value: msg.value}(unstakeDelaySec); |
| 184 | + } |
| 185 | + |
| 186 | + /** |
| 187 | + * @dev Unlock stake from the EntryPoint |
| 188 | + */ |
| 189 | + function unlockStake() external onlyOwner { |
| 190 | + entryPoint.unlockStake(); |
| 191 | + } |
| 192 | + |
| 193 | + /** |
| 194 | + * @dev Withdraw stake from the EntryPoint |
| 195 | + * @param withdrawAddress Address to withdraw to |
| 196 | + */ |
| 197 | + function withdrawStake(address payable withdrawAddress) external onlyOwner { |
| 198 | + entryPoint.withdrawStake(withdrawAddress); |
| 199 | + } |
| 200 | + |
| 201 | + /** |
| 202 | + * @dev Deposit funds to the EntryPoint for gas sponsorship |
| 203 | + */ |
| 204 | + function deposit() public payable { |
| 205 | + entryPoint.depositTo{value: msg.value}(address(this)); |
| 206 | + } |
| 207 | + |
| 208 | + /** |
| 209 | + * @dev Get deposit balance |
| 210 | + */ |
| 211 | + function getDeposit() public view returns (uint256) { |
| 212 | + return entryPoint.balanceOf(address(this)); |
| 213 | + } |
| 214 | + |
| 215 | + receive() external payable { |
| 216 | + deposit(); |
| 217 | + } |
| 218 | +} |
| 219 | +``` |
| 220 | + |
| 221 | +## Integration Example |
| 222 | + |
| 223 | +Here's how to integrate the paymaster with a client application: |
| 224 | + |
| 225 | +```typescript |
| 226 | +import { createPublicClient, createWalletClient, http, parseUnits } from "viem"; |
| 227 | +import { polygon } from "viem/chains"; |
| 228 | +import { privateKeyToAccount } from "viem/accounts"; |
| 229 | + |
| 230 | +// Contract addresses |
| 231 | +const USDC = "0x3c499c542cEF5E3811e1192ce70d8cC03d5c3359"; |
| 232 | +const PAYMASTER_ADDRESS = "0xYourPaymasterAddress..."; // Deploy the contract above |
| 233 | +const ENTRY_POINT = "0x5FF137D4b0FDCD49DcA30c7CF57E578a026d2789"; // Standard EntryPoint |
| 234 | + |
| 235 | +const erc20Abi = [ |
| 236 | + { type: "function", name: "transfer", stateMutability: "nonpayable", inputs: [{ type: "address" }, { type: "uint256" }], outputs: [{ type: "bool" }] }, |
| 237 | +]; |
| 238 | + |
| 239 | +const account = privateKeyToAccount(process.env.USER_PRIVATE_KEY as `0x${string}`); |
| 240 | +const publicClient = createPublicClient({ |
| 241 | + chain: polygon, |
| 242 | + transport: http(process.env.POLYGON_RPC_URL) |
| 243 | +}); |
| 244 | + |
| 245 | +async function sponsoredUSDCTransfer( |
| 246 | + recipient: `0x${string}`, |
| 247 | + amount: string |
| 248 | +) { |
| 249 | + // 1. Prepare the USDC transfer call data |
| 250 | + const transferCallData = encodeFunctionData({ |
| 251 | + abi: erc20Abi, |
| 252 | + functionName: "transfer", |
| 253 | + args: [recipient, parseUnits(amount, 6)] // USDC has 6 decimals |
| 254 | + }); |
| 255 | + |
| 256 | + // 2. Create user operation with paymaster |
| 257 | + const userOp = { |
| 258 | + sender: account.address, |
| 259 | + nonce: await getNonce(account.address), // Get from EntryPoint |
| 260 | + initCode: "0x", // Empty if account already deployed |
| 261 | + callData: transferCallData, |
| 262 | + callGasLimit: 100000n, |
| 263 | + verificationGasLimit: 150000n, |
| 264 | + preVerificationGas: 21000n, |
| 265 | + maxFeePerGas: parseUnits("20", "gwei"), |
| 266 | + maxPriorityFeePerGas: parseUnits("2", "gwei"), |
| 267 | + paymasterAndData: PAYMASTER_ADDRESS, // This enables gas sponsorship |
| 268 | + signature: "0x" // Will be populated after signing |
| 269 | + }; |
| 270 | + |
| 271 | + // 3. Sign and submit the user operation |
| 272 | + // This would typically involve signing the userOpHash and submitting to a bundler |
| 273 | + console.log("User operation prepared for sponsored USDC transfer:", userOp); |
| 274 | + |
| 275 | + // Note: Full implementation would require bundler integration |
| 276 | + // and proper user operation signing according to EIP-4337 |
| 277 | +} |
| 278 | + |
| 279 | +// Helper function to get nonce from EntryPoint |
| 280 | +async function getNonce(address: `0x${string}`): Promise<bigint> { |
| 281 | + // Implementation depends on your account abstraction setup |
| 282 | + return 0n; |
| 283 | +} |
| 284 | +``` |
| 285 | + |
| 286 | +## Deployment Script |
| 287 | + |
| 288 | +```solidity |
| 289 | +// deploy.sol |
| 290 | +pragma solidity ^0.8.19; |
| 291 | +
|
| 292 | +import "forge-std/Script.sol"; |
| 293 | +import "../src/USDCPaymaster.sol"; |
| 294 | +
|
| 295 | +contract DeployUSDCPaymaster is Script { |
| 296 | + function run() external { |
| 297 | + uint256 deployerPrivateKey = vm.envUint("PRIVATE_KEY"); |
| 298 | + address entryPoint = 0x5FF137D4b0FDCD49DcA30c7CF57E578a026d2789; // Standard EntryPoint |
| 299 | + address usdc = 0x3c499c542cEF5E3811e1192ce70d8cC03d5c3359; // USDC on Polygon |
| 300 | + |
| 301 | + vm.startBroadcast(deployerPrivateKey); |
| 302 | + |
| 303 | + USDCPaymaster paymaster = new USDCPaymaster( |
| 304 | + IEntryPoint(entryPoint), |
| 305 | + usdc, |
| 306 | + msg.sender |
| 307 | + ); |
| 308 | + |
| 309 | + // Add initial stake and deposit |
| 310 | + paymaster.addStake{value: 1 ether}(86400); // 1 day unstake delay |
| 311 | + paymaster.deposit{value: 5 ether}(); // 5 MATIC for gas sponsorship |
| 312 | + |
| 313 | + vm.stopBroadcast(); |
| 314 | + |
| 315 | + console.log("USDCPaymaster deployed at:", address(paymaster)); |
| 316 | + } |
| 317 | +} |
| 318 | +``` |
| 319 | + |
| 320 | +## Key Features |
| 321 | + |
| 322 | +1. **Gas Sponsorship**: Sponsors gas fees for USDC transfers |
| 323 | +2. **Access Control**: Only sponsored accounts or accounts with USDC balance can use |
| 324 | +3. **Gas Limits**: Configurable maximum gas cost per operation |
| 325 | +4. **Security**: Validates that operations are legitimate USDC transfers |
| 326 | +5. **Admin Functions**: Owner can manage sponsored accounts and withdraw funds |
| 327 | + |
| 328 | +## Security Considerations |
| 329 | + |
| 330 | +- Validate all user operations to prevent abuse |
| 331 | +- Set reasonable gas limits to control costs |
| 332 | +- Monitor for suspicious patterns |
| 333 | +- Keep paymaster funded but not over-funded |
| 334 | +- Regular security audits for production use |
| 335 | + |
| 336 | +## Testing |
| 337 | + |
| 338 | +Before production deployment: |
| 339 | + |
| 340 | +1. Test on Polygon testnet (Amoy) |
| 341 | +2. Verify gas estimation accuracy |
| 342 | +3. Test edge cases and error conditions |
| 343 | +4. Monitor gas consumption patterns |
| 344 | +5. Implement proper monitoring and alerting |
0 commit comments