diff --git a/contracts/contracts/core/GasTankDepositor.sol b/contracts/contracts/core/GasTankDepositor.sol new file mode 100644 index 000000000..e9ebff241 --- /dev/null +++ b/contracts/contracts/core/GasTankDepositor.sol @@ -0,0 +1,76 @@ +// SPDX-License-Identifier: BSL 1.1 +pragma solidity 0.8.26; + +import {Errors} from "../utils/Errors.sol"; + +/// @title GasTankDepositor +/// @notice Coordinates on-demand ETH Transfers to the RPC Service for EOA custodial gas tanks. +/// @dev This contract implicitly trusts the RPC_SERVICE address. +contract GasTankDepositor { + address public immutable RPC_SERVICE; + uint256 public immutable MAXIMUM_DEPOSIT; + address public immutable DELEGATION_ADDR; + + event GasTankFunded(address indexed smartAccount, address indexed caller, uint256 indexed amount); + + error FailedToFundGasTank(address rpcProvider, uint256 transferAmount); + error RPCServiceNotSet(address provider); + error NotRPCService(address caller); + error InsufficientFunds(uint256 currentBalance, uint256 requiredBalance); + error NotThisEOA(address msgSender, address thisAddress); + error MaximumDepositNotMet(uint256 amountToTransfer, uint256 maximumDeposit); + + modifier onlyThisEOA() { + require(msg.sender == address(this), NotThisEOA(msg.sender, address(this))); + _; + } + + modifier onlyRPCService() { + require(msg.sender == RPC_SERVICE, NotRPCService(msg.sender)); + _; + } + + /// @dev Writes the variables into the contract bytecode. + /// @dev No storage is used in this contract. + constructor(address rpcService, uint256 _maxDeposit) { + require(rpcService != address(0), RPCServiceNotSet(rpcService)); + require(_maxDeposit > 0, MaximumDepositNotMet(0, _maxDeposit)); + RPC_SERVICE = rpcService; + MAXIMUM_DEPOSIT = _maxDeposit; + DELEGATION_ADDR = address(this); + } + + receive() external payable { + if (address(this) == DELEGATION_ADDR) { + revert Errors.InvalidReceive(); + } + } + + fallback() external payable { + revert Errors.InvalidFallback(); + } + + /// @notice Transfers ETH from the EOA's balance to the Gas RPC Service. + /// @param _amount The amount to fund the gas tank with. + /// @dev Only the EOA can call this function. + function fundGasTank(uint256 _amount) external onlyThisEOA { + _fundGasTank(_amount); + } + + /// @notice Transfers the maximum deposit amount of ETH from the EOA's balance to the Gas RPC Service. + /// @dev Only the RPC Service can call this function. + function fundGasTank() external onlyRPCService { + _fundGasTank(MAXIMUM_DEPOSIT); + } + + function _fundGasTank(uint256 _amountToTransfer) internal { + require(address(this).balance >= _amountToTransfer, InsufficientFunds(address(this).balance, _amountToTransfer)); + + (bool success,) = RPC_SERVICE.call{value: _amountToTransfer}(""); + if (!success) { + revert FailedToFundGasTank(RPC_SERVICE, _amountToTransfer); + } + + emit GasTankFunded(address(this), msg.sender, _amountToTransfer); + } +} diff --git a/contracts/doc/GasTankDepositor.md b/contracts/doc/GasTankDepositor.md new file mode 100644 index 000000000..6425d0e03 --- /dev/null +++ b/contracts/doc/GasTankDepositor.md @@ -0,0 +1,50 @@ +# GasTankDepositor Contract Documentation + +## Overview + +The `GasTankDepositor` contract coordinates on-demand ETH transfers from user EOAs to an RPC service-managed EOA for custodial gas tank management. This contract enables automatic gas tank top-ups using the ERC-7702 standard, allowing users to delegate smart contract functionality to their EOA addresses without requiring a contract wallet. + +## Purpose + +The contract facilitates a custodial gas tank system where: +- Users deposit ETH that is transferred to an RPC service-managed EOA +- The RPC service maintains an off-chain ledger tracking each user's custodial balance +- When users transact with the FAST RPC service, their off-chain ledger balance is debited to cover transaction costs +- The RPC service can automatically top up user accounts when balances run low + +## Architecture + +### Key Components + +1. **RPC Service EOA**: A single EOA address managed by the RPC service that receives all gas tank deposits +2. **Off-Chain Ledger**: The RPC service maintains a ledger tracking each user's custodial balance +3. **ERC-7702 Delegation**: Users delegate their EOA to the `GasTankDepositor` contract, enabling smart contract functionality on their EOA address +4. **Minimum Deposit**: Immutable minimum amount that must be transferred in each top-up operation + +### How It Works + +1. **User Authorization** (One-time setup): + - User adds FastRPC network to their wallet. + - User authorizes the `GasTankDepositor` contract using ERC-7702 + - User sends a network transaction to attach the delegation + - After delegation, the user's EOA can execute contract functions as if it were a smart contract + +2. **Initial Funding**: + - User just needs to perform a transaction once the FastRPC network is added to their wallet and EOA has the `MAXIMUM_DEPOSIT` + - Amount must be >= `MAXIMUM_DEPOSIT` + - ETH is transferred from user's EOA to the RPC service EOA + - RPC service updates off-chain ledger to reflect the deposit + +3. **Automatic Top-Ups**: + - When a user's off-chain ledger balance drops below threshold, RPC service calls `fundGasTank()` + - This always transfers exactly `MAXIMUM_DEPOSIT` amount + - Transfer occurs directly from user's EOA balance (if sufficient funds available) + - No user interaction required - fully automated + +4. **Off-Chain Ledger Operations**: + - RPC service tracks user balances in off-chain ledger + - When users transact with FAST RPC service, ledger debits their account + - When balance is low, RPC service triggers automatic top-up + - All transfers go to the single RPC service EOA + + diff --git a/contracts/test/core/GasTankDepositorTest.sol b/contracts/test/core/GasTankDepositorTest.sol new file mode 100644 index 000000000..e23964079 --- /dev/null +++ b/contracts/test/core/GasTankDepositorTest.sol @@ -0,0 +1,196 @@ +// SPDX-License-Identifier: BSL 1.1 +pragma solidity 0.8.26; + +import "forge-std/Test.sol"; +import {GasTankDepositor} from "../../contracts/core/GasTankDepositor.sol"; +import {Errors} from "../../contracts/utils/Errors.sol"; + +contract GasTankDepositorTest is Test { + uint256 public constant ALICE_PK = uint256(0xA11CE); + uint256 public constant BOB_PK = uint256(0xB0B); + uint256 public constant RPC_SERVICE_PK = uint256(0x1234567890); + uint256 public constant MAXIMUM_DEPOSIT = 0.01 ether; + address public constant ZERO_ADDRESS = address(0); + bytes32 public constant EMPTY_CODEHASH = keccak256(""); + + address public alice = payable(vm.addr(ALICE_PK)); + address public bob = payable(vm.addr(BOB_PK)); + address public rpcService = payable(vm.addr(RPC_SERVICE_PK)); + + GasTankDepositor private _gasTankDepositorImpl; + + function setUp() public { + vm.deal(alice, 10 ether); + vm.deal(bob, 10 ether); + vm.deal(rpcService, 10 ether); + _gasTankDepositorImpl = new GasTankDepositor(rpcService, MAXIMUM_DEPOSIT); + } + + //=======================TESTS FOR CONSTRUCTOR======================= + + function testConstructorRevertsWhenRpcServiceIsZeroAddress() public { + vm.expectRevert(abi.encodeWithSelector(GasTankDepositor.RPCServiceNotSet.selector, ZERO_ADDRESS)); + new GasTankDepositor(ZERO_ADDRESS, MAXIMUM_DEPOSIT); + } + + function testConstructorRevertsWhenMaximumDepositIsZero() public { + vm.expectRevert(abi.encodeWithSelector(GasTankDepositor.MaximumDepositNotMet.selector, 0, 0)); + new GasTankDepositor(rpcService, 0); + } + + function testConstructorSetsVariables() public view { + assertEq(_gasTankDepositorImpl.RPC_SERVICE(), rpcService); + assertEq(_gasTankDepositorImpl.MAXIMUM_DEPOSIT(), MAXIMUM_DEPOSIT); + assertEq(_gasTankDepositorImpl.DELEGATION_ADDR(), address(_gasTankDepositorImpl)); + } + + //=======================TESTS======================= + + function testSetsDelegationCodeAtAddress() public { + // Initial code is empty + assertEq(alice.code.length, 0); + + // Set delegation as the GasTankDepositor + _signAndAttachDelegation(address(_gasTankDepositorImpl), ALICE_PK); + + assertEq(alice.codehash, _delegateCodeHash(address(_gasTankDepositorImpl))); + assertEq(alice.code.length, 23); + } + + function testRemovesDelegationCodeAtAddress() public { + // Set delegation as the GasTankDepositor + _signAndAttachDelegation(address(_gasTankDepositorImpl), ALICE_PK); + assertEq(alice.codehash, _delegateCodeHash(address(_gasTankDepositorImpl))); + assertEq(alice.code.length, 23); + + // Remove delegation + _signAndAttachDelegation(ZERO_ADDRESS, ALICE_PK); + assertEq(alice.codehash, EMPTY_CODEHASH); + assertEq(alice.code.length, 0); + } + + //=======================TESTS FOR RECEIVE AND FALLBACK======================= + + function testFallbackRevert() public { + bytes memory badData = abi.encodeWithSelector(bytes4(keccak256("invalidFunction()"))); + vm.prank(alice); + (bool success,) = address(_gasTankDepositorImpl).call{value: 1 ether}(badData); + assertFalse(success); + } + + function testFundsSentDirectlyToDelegateAddress() public { + vm.prank(bob); + (bool success, bytes memory data) = address(_gasTankDepositorImpl).call{value: 1 ether}(""); + assertFalse(success); + bytes4 selector; + assembly { + selector := mload(add(data, 0x20)) + } + assertEq(selector, Errors.InvalidReceive.selector); + } + + function testFundsSentDirectlyToEOAAddressWithDelegation() public { + _delegate(); + + uint256 beforeBalance = alice.balance; + vm.prank(bob); + (bool success,) = alice.call{value: 1 ether}(""); + assertTrue(success); + uint256 afterBalance = alice.balance; + assertEq(afterBalance, beforeBalance + 1 ether, "balance not increased"); + } + + function testFundsSentDirectlyToEOAAddressWithoutDelegation() public { + uint256 beforeBalance = alice.balance; + vm.prank(bob); + (bool success,) = address(alice).call{value: 1 ether}(""); + assertTrue(success); + uint256 afterBalance = alice.balance; + assertEq(afterBalance, beforeBalance + 1 ether, "balance not increased"); + } + //=======================TESTS FOR FUNDING THE GAS TANK======================= + + function testRpcServiceFundsMaximumDeposit() public { + _delegate(); + + uint256 rpcBalanceBefore = rpcService.balance; + _expectGasTankFunded(rpcService, MAXIMUM_DEPOSIT); + + vm.prank(rpcService); + GasTankDepositor(payable(alice)).fundGasTank(); + + assertEq(rpcService.balance, rpcBalanceBefore + MAXIMUM_DEPOSIT, "rpc balance not increased"); + } + + function testRpcServiceFundRevertsWhenCallerNotRpcService() public { + _delegate(); + + vm.prank(alice); + vm.expectRevert(abi.encodeWithSelector(GasTankDepositor.NotRPCService.selector, alice)); + GasTankDepositor(payable(alice)).fundGasTank(); + } + + function testRpcServiceFundRevertsWhenInsufficientBalance() public { + vm.deal(alice, MAXIMUM_DEPOSIT - 1); + _delegate(); + + vm.prank(rpcService); + vm.expectRevert( + abi.encodeWithSelector(GasTankDepositor.InsufficientFunds.selector, MAXIMUM_DEPOSIT - 1, MAXIMUM_DEPOSIT) + ); + GasTankDepositor(payable(alice)).fundGasTank(); + } + + function testEOAFundsGasTank() public { + uint256 amount = 1 ether; + _delegate(); + + uint256 rpcBalanceBefore = rpcService.balance; + _expectGasTankFunded(alice, amount); + + vm.prank(alice); + GasTankDepositor(payable(alice)).fundGasTank(amount); + + assertEq(rpcService.balance, rpcBalanceBefore + amount, "rpc balance not increased"); + } + + function testEOAFundRevertsWhenCallerNotEOA() public { + _delegate(); + + vm.prank(rpcService); + vm.expectRevert(abi.encodeWithSelector(GasTankDepositor.NotThisEOA.selector, rpcService, alice)); + GasTankDepositor(payable(alice)).fundGasTank(MAXIMUM_DEPOSIT); + } + + function testEOAFundRevertsWhenInsufficientBalance() public { + vm.deal(alice, MAXIMUM_DEPOSIT - 1); + _delegate(); + + vm.prank(alice); + vm.expectRevert( + abi.encodeWithSelector(GasTankDepositor.InsufficientFunds.selector, MAXIMUM_DEPOSIT - 1, MAXIMUM_DEPOSIT) + ); + GasTankDepositor(payable(alice)).fundGasTank(MAXIMUM_DEPOSIT); + } + + //=======================HELPERS======================= + + function _delegate() internal { + _signAndAttachDelegation(address(_gasTankDepositorImpl), ALICE_PK); + } + + function _expectGasTankFunded(address caller, uint256 amount) internal { + vm.expectEmit(true, true, true, true); + emit GasTankDepositor.GasTankFunded(alice, caller, amount); + } + + function _signAndAttachDelegation(address contractAddress, uint256 pk) internal { + vm.prank(alice); + vm.signAndAttachDelegation(contractAddress, pk); + vm.stopPrank(); + } + + function _delegateCodeHash(address contractAddress) internal pure returns (bytes32) { + return keccak256(abi.encodePacked(hex"ef0100", contractAddress)); + } +}