Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
76 changes: 76 additions & 0 deletions contracts/contracts/core/GasTankDepositor.sol
Original file line number Diff line number Diff line change
@@ -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);
}
}
50 changes: 50 additions & 0 deletions contracts/doc/GasTankDepositor.md
Original file line number Diff line number Diff line change
@@ -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


196 changes: 196 additions & 0 deletions contracts/test/core/GasTankDepositorTest.sol
Original file line number Diff line number Diff line change
@@ -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));
}
}
Loading