Abstract
This proposal extends EIP-8130 to enable gas payments using ERC-20 tokens. It defines the token payment format for EIP-8130's fee_token field, a Token Payment Registry for allowlisting tokens, and an optional native payer system contract for chain-operated gas abstraction with oracle-based pricing.
Motivation
EIP-8130 provides account abstraction with ETH-based gas sponsorship. However, many users hold tokens (USDC, USDT, etc.) but no ETH, creating friction for onboarding and transaction submission. Token gas payments enable:
- Gasless UX: Users transact with tokens they already hold
- Stablecoin payments: Predictable transaction costs in USD-denominated tokens
- Competitive market: Permissioned payers compete on exchange rates off-chain
- Chain flexibility: Native payer option for chain-operated gas abstraction with oracle-based pricing
- Zero token changes: Works with existing ERC-20 tokens using standard transfer logicβno upgrades or storage migrations required
This proposal uses EIP-8130's fee_token field to add token payment capability without modifying the base transaction type.
Specification
Constants
| Name | Value | Comment |
|---|---|---|
TOKEN_PAYMENT_REGISTRY | TBD | Token Payment Registry system contract address |
TOKEN_TRANSFER_COST | 13000 | Token escrow/refund gas (see breakdown below) |
DEACTIVATION_DELAY | TBD | Blocks before token deactivation takes effect |
PAUSE_COST | TBD | ETH required to invoke emergency pause |
NATIVE_PAYER | TBD | (Optional) Native gas AMM payer system contract address |
Fee Token Format
Token payments use the EIP-8130 fee_token field with packed byte encoding:
| Length | Format | Description |
|---|---|---|
| 0 bytes | 0x (empty) | ETH payment only |
| 52 bytes | token (20) || max_exchange_rate (32) | Token gas payment |
fee_token = token_address || max_exchange_rate
= address (20 bytes) || uint256 (32 bytes)
= 52 bytes total| Field | Type | Size | Description |
|---|---|---|---|
token_address | address | 20 bytes | ERC-20 token contract address |
max_exchange_rate | uint256 | 32 bytes | Maximum tokens per ETH the sender will accept, in token's smallest unit (e.g., 2000 USDC/ETH = 2000 Γ 10^6) |
The max_exchange_rate is denominated in the token's native decimals per ETH, making it oracle-decimal-agnostic. The sender does NOT commit to a specific payer in fee_token β payer commitment uses EIP-8130's extended payer modes (see Payer Modes with Token Payments).
When fee_token is non-empty, the transaction uses token-based gas payment instead of ETH.
Token Payment Registry
Tokens must be registered in the Token Payment Registry system contract at TOKEN_PAYMENT_REGISTRY before use for gas payment. Similar to EIP-8130's account configuration precompile, this is a protocol-level system contract with persistent storage. The registry stores token metadata required for protocol-level balance reads and transfers.
The registry defines a single protocol-level interface that all wallets and payers integrate with. Its governance model for write access (token registration, pause, blocklist) is implementation-defined β chain-operated, permissionless, or hybrid β and can be extended by the deployer. The protocol reads from the registry identically regardless of governance model.
Token Configuration Storage
All storage keys use byte concatenation (|| denotes concatenation of raw bytes, addresses as 20 bytes, strings as UTF-8):
Base slot: keccak256(token_address || TOKEN_PAYMENT_REGISTRY || "config")
- base_slot + 0: balance_slot_index (uint256)
- base_slot + 1: packed config (uint256)
- byte 0: token_decimals (uint8)
- byte 1: active (bool)
- byte 2: paused (bool)
- bytes 3-31: reserved
- base_slot + 2: deactivation_block (uint256) β 0 if no pending deactivation| Field | Description |
|---|---|
balance_slot_index | Storage slot index for token's balance mapping. User balance at: keccak256(user_address || balance_slot_index) in the token contract |
token_decimals | Token decimal places (e.g., 6 for USDC, 18 for most tokens) |
active | Whether token is currently accepted for gas payments |
paused | Whether token is globally paused (emergency, instant) |
deactivation_block | Block number at which token becomes inactive. Set by requestDeactivation, takes effect after DEACTIVATION_DELAY blocks |
Blocklist
Blocklist state is managed directly in the TOKEN_PAYMENT_REGISTRY rather than read from individual token contracts. This decouples the protocol from token storage internals and provides a single, predictable location for compliance checks:
Blocklist slot: keccak256(token_address || account_address || TOKEN_PAYMENT_REGISTRY || "blocklist")
Value: bool (true = blocked)Token issuers or designated admins call setBlocked(token, account, blocked) on the registry to maintain the blocklist. For tokens like USDC, an admin or keeper bot monitors the token's native Blacklisted events and mirrors them to the registry. The number of blocklisted addresses is typically small (hundreds for major stablecoins β OFAC sanctions, stolen funds, etc.), making this approach practical.
Deactivation and Emergency Pause
Token removal and emergency pause use separate mechanisms:
Deactivation (planned removal, timelocked): requestDeactivation(token) sets deactivation_block = current_block + DEACTIVATION_DELAY. The token remains active until that block, giving pending transactions time to clear. cancelDeactivation(token) resets the countdown. Activation via activateToken(token) takes effect immediately β enabling a token does not invalidate transactions.
Emergency pause (instant, costs ETH): setTokenPaused(token, true) takes effect immediately and requires msg.value >= PAUSE_COST. The ETH cost discourages frivolous or spam pauses while preserving the ability to respond to genuine emergencies (e.g., a token exploit). Unpausing is free and immediate.
Payer Modes with Token Payments
This extension leverages EIP-8130's payer marker in the sender signature hash to add token payment capability. EIP-8130 defines the marker as 0x80 (self-pay) or 0x00 (any sponsor). This EIP extends the marker to accept a 20-byte payer address, enabling the sender to commit to a specific payer without modifying fee_token:
| Sender Signs (marker) | payer_auth (wire) | Gas Payer | Token Recipient | Description |
|---|---|---|---|---|
0x80 (empty) | MUST be empty | from (ETH only) | β | Self-pay; fee_token MUST be empty |
0x00 | 65-byte K1 signature | ecrecover(sig) | Payer | Any permissioned payer; payer signs tx |
payer_address (20 bytes) | 65-byte sig (must match) | Committed payer | Payer | Specific permissioned payer |
NATIVE_PAYER (20 bytes) | MUST be empty | NATIVE_PAYER | NATIVE_PAYER | Native payer (chain-operated) |
The RLP encoding naturally disambiguates: 0 bytes = self-pay, 1 byte (0x00) = any sponsor, 20 bytes = specific payer address.
Permissioned payers sign each transaction, explicitly agreeing to the max_exchange_rate. They can use either 0x00 (any willing signer) or a 20-byte address (specific signer). The NATIVE_PAYER address is used directly as the marker for native payer mode. No permissionless (unsigned) third-party payers are supported β see Security Considerations for rationale.
Updated sender signature hash (extends EIP-8130 Signature Payload):
// Self-pay (from pays ETH):
keccak256(AA_TX_TYPE || rlp([..., fee_token, 0x80]))
// Any permissioned sponsor:
keccak256(AA_TX_TYPE || rlp([..., fee_token, 0x00]))
// Specific payer (permissioned or native):
keccak256(AA_TX_TYPE || rlp([..., fee_token, payer_address]))Native Payer
The optional NATIVE_PAYER system contract provides chain-operated gas abstraction with oracle-based pricing. This is the only mechanism for unsigned (non-permissioned) token gas payments.
Storage
Per-token oracle config:
Token slot: keccak256(token_address || NATIVE_PAYER || "oracle")
- token_slot + 0: oracle_address (address)
- token_slot + 1: oracle_slot (bytes32)
- token_slot + 2: oracle_decimals (uint8)Chain operators configure accepted tokens and their oracle feeds via the NATIVE_PAYER contract. Only tokens registered in the Token Payment Registry AND configured in the native payer can be used with native payer mode.
Oracle-Based Pricing
The native payer reads exchange rates from chain-operator-configured oracles. The oracle value represents tokens per ETH, scaled by 10^oracle_decimals. The protocol normalizes the oracle value to the token's native units per ETH for comparison with max_exchange_rate:
oracle_value = SLOAD(native_payer.oracle_address, native_payer.oracle_slot)
exchange_rate = oracle_value * 10^token_decimals / 10^oracle_decimals
token_cost = ceil(gas_cost_wei * exchange_rate / 10^18)Both exchange_rate and max_exchange_rate are in the same units: token's smallest unit per ETH. Validation requires exchange_rate <= max_exchange_rate.
Example: USDC payment for 0.001 ETH gas cost (1e15 wei), ETH at $2000:
oracle_value= 2000 Γ 10^8 (Chainlink 8-decimal standard)exchange_rate= 2000e8 Γ 10^6 / 10^8 = 2000e6 (= 2000 USDC per ETH in USDC units)max_exchange_rate= 2100e6 (sender accepts up to 2100 USDC/ETH)token_cost= 1e15 Γ 2000e6 / 1e18 = 2e6 = 2 USDC
If oracle returns 0, token_cost is 0 (chain operator assumes this risk).
Oracle Maintenance
Oracle maintenance is the chain operator's responsibility. Common approaches:
| Oracle Type | Description |
|---|---|
| Chainlink feeds | Decentralized price updates via established infrastructure |
| Internal feed | Chain operator runs an updater service |
| DEX-integrated | Oracle contract queries configured on-chain liquidity pool |
The oracle rate is read once and locked at the start of each AA transaction (see Exchange Rate Resolution). Stale prices are the chain operator's risk.
Behavior
- Uses the Token Payment Registry for token allowlisting and metadata
- Chain operators define supported tokens and oracle feeds
- Referenced via the sender's payer marker: sender signs
NATIVE_PAYERaddress as the 20-byte marker,payer_authis empty - Chain operators may implement custom pricing logic (AMMs, fixed rates, or custom curves) within the native payer contract
Token Transfer Flow
Token transfers use a two-sided escrow/refund model. The exchange rate is resolved and locked once at the start of transaction execution β it does not change during the transaction.
Exchange Rate Resolution
The exchange rate is determined by payer type and locked at the start of AA transaction execution:
| Payer Type | Exchange Rate Source | Meaning of max_exchange_rate |
|---|---|---|
| Permissioned (65-byte sig) | max_exchange_rate directly | Mutually agreed rate (both parties signed over it) |
Native (NATIVE_PAYER) | Chain-operator oracle | Cap β oracle rate MUST NOT exceed max_exchange_rate |
max_exchange_rate serves the same role for token pricing that max_fee_per_gas serves for ETH gas pricing β it is the sender's maximum willingness-to-pay. For native payer transactions, nodes SHOULD reject transactions where max_exchange_rate < current_oracle_rate, just as nodes reject transactions where max_fee_per_gas < base_fee. Wallets SHOULD set max_exchange_rate with a buffer above the current oracle rate (e.g., 10%) to avoid premature invalidation during normal price movement.
Pre-Execution Escrow
- Parse
fee_tokento extracttoken_addressandmax_exchange_rate - Resolve exchange rate (locked for the duration of this tx):
- Permissioned payer:
exchange_rate = max_exchange_rate(mutually agreed) - Native payer:
exchange_rate = oracle_rate. Iforacle_rate > max_exchange_rate, transaction fails
- Permissioned payer:
- Verify token is not paused (registry
pausedflag) - Verify sender is not blocklisted (registry blocklist)
- Compute
max_token_cost = ceil(gas_limit Γ max_fee_per_gas Γ exchange_rate / 10^18) - Token escrow: Deduct
max_token_costfrom sender's token balance (sender tokens on hold) - ETH escrow: Deduct
gas_limit Γ max_fee_per_gasfrom payer's ETH balance (payer ETH on hold, per EIP-8130)
Post-Execution Settlement
After EVM execution completes (regardless of revert):
- Compute
actual_gas_cost = gas_used Γ effective_fee_per_gas - Compute
actual_token_cost = ceil(actual_gas_cost Γ exchange_rate / 10^18) - Token settlement: Transfer
actual_token_costfrom escrow to payer; refundmax_token_cost - actual_token_costback to sender - ETH settlement: Refund
(gas_limit Γ max_fee_per_gas) - actual_gas_costback to payer (per EIP-8130)
All token operations occur outside EVM execution via direct storage manipulation.
Intrinsic Gas
When fee_token is non-empty, add to EIP-8130 intrinsic gas:
intrinsic_gas = EIP8130_intrinsic_gas + TOKEN_TRANSFER_COSTTOKEN_TRANSFER_COST (~13,000 gas) covers the protocol's direct storage operations for the escrow/refund flow:
| Operation | Gas | Notes |
|---|---|---|
| Sender balance read (cold SLOAD) | 2,100 | From token contract storage |
| Sender balance write (warm SSTORE) | 2,900 | Non-zero β non-zero |
| Payer balance read (cold SLOAD) | 2,100 | From token contract storage |
| Payer balance write (warm SSTORE) | 2,900 | Non-zero β non-zero |
| Oracle read (cold SLOAD) | 2,100 | From oracle contract storage (native payer only; 0 for permissioned) |
| Blocklist + pause check | ~200 | Registry storage (warm β same contract as token config reads) |
| Post-execution refund (2Γ warm SSTORE) | ~800 | Both balance slots already warm |
| Total | ~13,000 |
Validation Flow Updates
Extend EIP-8130 mempool acceptance:
- (EIP-8130 steps 1-6: signature, nonce, etc.)
- If
fee_tokenis non-empty (52 bytes):- Parse
fee_token: extracttoken_address,max_exchange_rate - Verify token is registered and active in Token Payment Registry
- Verify token is not paused (registry
pausedflag) - Verify sender is not blocklisted (registry blocklist)
- Resolve payer from sender's signed marker and
payer_auth:- Marker = empty (
0x80): REJECT (self-pay not valid with token payments) - Marker =
0x00: recover payer via ecrecover frompayer_auth(any permissioned payer) - Marker = 20-byte address with 65-byte
payer_auth: specific permissioned payer (ecrecover must match marker) - Marker =
NATIVE_PAYER:payer_authMUST be empty; verify native payer has oracle config for token
- Marker = empty (
- For native payer: read oracle exchange rate; REJECT if
max_exchange_rate < current_oracle_rate - For permissioned payer: use
max_exchange_ratedirectly (mutually agreed rate) - Compute
max_token_cost = ceil(gas_limit Γ max_fee_per_gas Γ exchange_rate / 10^18) - Verify sender token balance β₯
max_token_cost
- Parse
- (EIP-8130 remaining steps: nonce, balance, expiry)
Nodes SHOULD treat max_exchange_rate the same way they treat max_fee_per_gas: reject transactions where the cap is below the current rate. For native payer transactions, this means max_exchange_rate >= current_oracle_rate. Transactions that were valid but become invalid due to oracle rate increases are evicted from the mempool, the same as transactions evicted when base_fee rises above max_fee_per_gas.
Block Execution Updates
Extend EIP-8130 block execution:
- Exchange rate lock (if
fee_tokennon-empty): Read and lock exchange rate for this tx - Token escrow (if
fee_tokennon-empty): Deductmax_token_costfrom sender - ETH escrow: Deduct
gas_limit Γ max_fee_per_gasfrom payer (per EIP-8130) - Process authorization_list (EIP-7702)
- Account initialization (if applicable)
- Apply key changes (if applicable)
- Deliver calldata to sender via self-call
- Token settlement (if
fee_tokennon-empty): Transferactual_token_costto payer, refund excess to sender - ETH settlement: Refund unused ETH gas to payer (per EIP-8130)
Steps 1β6 and 8β9 are protocol-level direct state operations (no EVM execution). Step 7 is the single EVM execution frame. Token escrow (step 2) ensures the sender cannot move tokens during execution; settlement (step 8) transfers the actual cost. If step 7 reverts, token and ETH settlement still complete (consistent with EIP-8130's revert semantics).
Transaction Context Extension
Extend EIP-8130's IAccountConfig with:
// Transaction context (only valid during AA transaction execution)
function getGasPaymentInfo() external view returns (address token, uint256 amount);Returns the token address and amount transferred for gas payment. Returns (address(0), 0) for ETH-only payments.
Rationale
Why max_exchange_rate Instead of max_amount?
The sender specifies a maximum exchange rate (tokens per ETH) rather than an absolute token amount:
- Scales naturally: The actual token cost scales with gas used β no over-escrow beyond what the gas limit requires
- Rate-centric: Users think in terms of "what rate am I paying?" not "what absolute amount might be deducted"
- Oracle-comparable: Directly comparable to the oracle's exchange rate without additional conversions
- Simpler escrow:
max_token_cost = gas_limit Γ max_fee_per_gas Γ max_exchange_rate / 10^18β deterministic from tx fields max_fee_per_gasanalogy:max_exchange_rateis to the oracle rate whatmax_fee_per_gasis tobase_feeβ a sender-specified ceiling that determines validity. Wallets set both with headroom above the current value; transactions become invalid when the underlying rate exceeds the cap. This is a familiar pattern for mempool nodes and wallet implementations.
Why Payer Commitment via Signature Marker?
EIP-8130 already includes a payer marker in the sender's signature hash (0x80 for self-pay, 0x00 for any sponsor). Extending this to accept a 20-byte payer address lets the sender commit to a specific payer without modifying fee_token. This prevents payer attachment attacks where an attacker replaces the intended payer with one offering worse rates. The sender's max_exchange_rate provides a hard cap, but the payer marker provides the primary protection by binding the sender's signature to a specific payer.
Why No Permissionless Third-Party Payers?
Oracle-based pricing without a per-transaction signature creates a mass invalidation attack surface. A permissionless payer registers an oracle, and the mempool fills with transactions referencing that oracle. If the oracle price spikes (maliciously or organically), all pending transactions where oracle_rate > max_exchange_rate become invalid simultaneously. Worse, an attacker can register multiple Sybil payer identities pointing to the same oracle, circumventing per-payer pending limits and amplifying the blast radius.
Oracle-based pricing is instead scoped to the native payer β a chain-operated system contract where the chain operator controls the oracle feed. This provides a single trusted operator with aligned incentives, no Sybil risk, and a simple invalidation model. Competitive pricing is achieved through permissioned payers who sign each transaction, agreeing to a specific max_exchange_rate β no oracle dependency in the mempool.
Why Direct Storage Reads for Balances?
Reading token balances directly from storage slots rather than calling ERC-20 methods:
- No token upgrades: Existing tokens with standard
mapping(address => uint256)balance storage work without modificationβjust register the balance slot index - No storage migrations: Tokens don't need to move data or conform to new interfaces
- Atomic execution: Balance reads and transfers happen at protocol level, before and after EVM execution (escrow/refund model)
Blocklist and pause state are managed in the TOKEN_PAYMENT_REGISTRY rather than read from token contracts. This decouples the protocol from token storage internalsβeach token's blocklist implementation is different (high-bit flags, separate mappings, struct fields), but the registry provides a single, predictable interface. Designated admins mirror token issuer blocklist events to the registry.
Backwards Compatibility
This proposal extends EIP-8130. Nodes not supporting this EIP will reject transactions with non-empty fee_token as specified in EIP-8130.
Reference Implementation
ITokenPaymentRegistry
interface ITokenPaymentRegistry {
event TokenRegistered(address indexed token, uint256 balanceSlotIndex, uint8 tokenDecimals);
event TokenActivated(address indexed token);
event DeactivationRequested(address indexed token, uint256 effectiveBlock);
event DeactivationCancelled(address indexed token);
event TokenPaused(address indexed token, bool paused);
event BlocklistUpdated(address indexed token, address indexed account, bool blocked);
event BlocklistAdminUpdated(address indexed token, address indexed admin, bool authorized);
// Token registration β idempotent, calling on an already-registered token updates its config
function registerToken(address token, uint256 balanceSlotIndex, uint8 tokenDecimals) external;
// Activation (immediate) and deactivation (timelocked by DEACTIVATION_DELAY)
function activateToken(address token) external;
function requestDeactivation(address token) external;
function cancelDeactivation(address token) external;
// Emergency pause β pausing requires msg.value >= PAUSE_COST
function setTokenPaused(address token, bool paused) external payable;
// Blocklist management (token admin or designated blocklist admin)
function setBlocklistAdmin(address token, address admin, bool authorized) external;
function setBlocked(address token, address account, bool blocked) external;
// View functions β isTokenActive returns false if paused or past deactivation_block
function isTokenActive(address token) external view returns (bool);
function isBlocked(address token, address account) external view returns (bool);
function getTokenConfig(address token) external view returns (
uint256 balanceSlotIndex, uint8 tokenDecimals, bool active, bool paused, uint256 deactivationBlock
);
}INativePayer
interface INativePayer {
event OracleConfigured(address indexed token, address oracleAddress, bytes32 oracleSlot, uint8 oracleDecimals);
event OracleRemoved(address indexed token);
// Oracle configuration (chain operator only)
function configureOracle(address token, address oracleAddress, bytes32 oracleSlot, uint8 oracleDecimals) external;
function removeOracle(address token) external;
// View functions
function getOracleConfig(address token) external view returns (address oracleAddress, bytes32 oracleSlot, uint8 oracleDecimals);
function getExchangeRate(address token) external view returns (uint256);
}Security Considerations
Token Spend Protection: Token gas payments occur before wallet code executes, bypassing wallet-level spend limits. Mitigations:
max_exchange_ratecap: Hard limit on the exchange rate per transaction; combined withmax_fee_per_gasandgas_limit, this bounds maximum token spendrequiresSponsorpolicy (EIP-8130): Accounts can enable therequiresSponsorflag in their gas payment policy, which disables ALL self-paid gas (ETH and tokens). When enabled, transactions must have a sponsor signature inpayer_auth. This completely eliminates the gas spend attack vector and is strongly recommended for accounts using EVM-layer multisig.- Blocklist checks: Protocol reads the registry-managed blocklist before escrow, ensuring blocklisted accounts cannot spend tokens on gas
Escrow/Griefing Prevention: The two-step escrow model (deduct max_token_cost pre-execution, refund excess post-execution) prevents the sender from transferring tokens away during EVM execution. Without escrow, the sender could drain their token balance in calldata execution, causing the post-execution token deduction to fail while the payer still pays ETH gas.
Oracle Risks: Oracle-based pricing is restricted to the chain-operated native payer. The oracle rate is read once and locked at the start of AA transaction execution. If another transaction in the same block updates the oracle, the new rate is used β but the sender's max_exchange_rate cap protects against spikes. Stale oracle prices are the chain operator's risk.
Payer Security: Permissioned payers sign each transaction, explicitly agreeing to the max_exchange_rate. When the sender uses 0x00 (any sponsor), only permissioned payers who sign can attach. The native payer requires the sender to sign NATIVE_PAYER as the 20-byte marker. In all cases, max_exchange_rate provides a hard cap on token cost, preventing payer attachment attacks with worse rates.
Mass Invalidation Prevention: Multiple layers protect against mass mempool invalidation:
- No permissionless third-party payers: Oracle-based payers where anyone can register create a Sybil-amplified invalidation vector. Oracle pricing is restricted to the chain-operated native payer.
- Timelocked deactivation:
requestDeactivationstarts aDEACTIVATION_DELAYcountdown, giving pending transactions time to clear. Mempool nodes can stop accepting new transactions for a token with a pending deactivation. - Paid emergency pause:
setTokenPausedrequires a non-zero ETH payment (PAUSE_COST), making frivolous or spam pauses economically costly. Emergency pauses may still invalidate pending transactions, but the ETH cost bounds abuse. - Permissioned payer rate lock: Permissioned payers sign each transaction with an agreed
max_exchange_rate, fixing the rate at signature time with no oracle dependency in the mempool.
Balance Manipulation: Protocol reads token balances directly from storage slots. Tokens with non-standard balance storage (rebasing, fee-on-transfer) may behave unexpectedly. Only register well-understood tokens.
Balance Slot Changes: If a registered token upgrades and changes its balance storage slot, the registry configuration becomes stale. The admin can call registerToken again to update the config atomically (no deactivation gap). However, tokens used for gas payment should have immutable balance storage layouts β frequent slot changes indicate an unstable token that should not be registered.
Blocklist Sync: The registry-managed blocklist may lag behind a token issuer's native blocklist by a few blocks. In the worst case, a newly-blacklisted address could spend a small amount of tokens on gas before the registry is updated. This is an acceptable tradeoff for the simplicity of not coupling to each token's internal storage layout. Designated admins should monitor token blocklist events and mirror updates promptly.
Copyright
Copyright and related rights waived via CC0.
