Meta Transactions
Meta transactions enable gasless user experiences by allowing relayers to pay transaction fees on behalf of users. This guide covers the IXFI Protocol's meta-transaction implementation, use cases, and integration patterns.
Overview
Meta transactions separate transaction execution from gas payment, allowing users to interact with smart contracts without holding native tokens for gas fees. This dramatically improves user experience, especially for new users and cross-chain operations.
Architecture
Core Components
Meta Transaction Structure
struct MetaTransaction {
address from; // User's address
address to; // Target contract address
uint256 value; // ETH value to send
bytes data; // Function call data
uint256 nonce; // User's nonce
uint256 gasLimit; // Gas limit for execution
uint256 gasPrice; // Gas price (can be 0 for gasless)
address gasToken; // Token to pay gas fees (address(0) for ETH)
uint256 gasTokenAmount; // Amount of gas tokens
uint256 deadline; // Expiration timestamp
bytes signature; // User's signature
}
Meta Transaction Gateway
contract MetaTxGateway is EIP712 {
using ECDSA for bytes32;
mapping(address => uint256) public nonces;
mapping(address => bool) public trustedRelayers;
mapping(address => uint256) public gasCredits;
bytes32 private constant META_TRANSACTION_TYPEHASH = keccak256(
"MetaTransaction(address from,address to,uint256 value,bytes data,uint256 nonce,uint256 gasLimit,uint256 gasPrice,address gasToken,uint256 gasTokenAmount,uint256 deadline)"
);
event MetaTransactionExecuted(
address indexed user,
address indexed relayer,
address indexed target,
bool success,
bytes returnData
);
constructor() EIP712("IXFI Meta Transaction", "1") {}
function executeMetaTransaction(
MetaTransaction memory metaTx
) external returns (bool success, bytes memory returnData) {
// Verify relayer authorization
require(trustedRelayers[msg.sender], "Unauthorized relayer");
// Verify signature and nonce
require(_verifyMetaTransaction(metaTx), "Invalid meta transaction");
// Check deadline
require(block.timestamp <= metaTx.deadline, "Transaction expired");
// Increment nonce
nonces[metaTx.from]++;
// Handle gas payment
_handleGasPayment(metaTx);
// Execute the transaction
(success, returnData) = _executeTransaction(metaTx);
emit MetaTransactionExecuted(
metaTx.from,
msg.sender,
metaTx.to,
success,
returnData
);
return (success, returnData);
}
function _verifyMetaTransaction(
MetaTransaction memory metaTx
) internal view returns (bool) {
bytes32 structHash = keccak256(abi.encode(
META_TRANSACTION_TYPEHASH,
metaTx.from,
metaTx.to,
metaTx.value,
keccak256(metaTx.data),
metaTx.nonce,
metaTx.gasLimit,
metaTx.gasPrice,
metaTx.gasToken,
metaTx.gasTokenAmount,
metaTx.deadline
));
bytes32 hash = _hashTypedDataV4(structHash);
address signer = hash.recover(metaTx.signature);
return signer == metaTx.from && metaTx.nonce == nonces[metaTx.from];
}
function _executeTransaction(
MetaTransaction memory metaTx
) internal returns (bool success, bytes memory returnData) {
// Set the meta-transaction context
_setMsgSender(metaTx.from);
try this.forwardCall{gas: metaTx.gasLimit}(
metaTx.to,
metaTx.value,
metaTx.data
) returns (bytes memory result) {
success = true;
returnData = result;
} catch Error(string memory reason) {
success = false;
returnData = bytes(reason);
} catch (bytes memory lowLevelData) {
success = false;
returnData = lowLevelData;
}
_clearMsgSender();
return (success, returnData);
}
}
Context Manager
contract MetaTxContext {
address private _msgSender;
bool private _msgSenderSet;
modifier onlyMetaTxGateway() {
require(msg.sender == metaTxGateway, "Only meta-tx gateway");
_;
}
function _setMsgSender(address sender) internal onlyMetaTxGateway {
_msgSender = sender;
_msgSenderSet = true;
}
function _clearMsgSender() internal onlyMetaTxGateway {
_msgSender = address(0);
_msgSenderSet = false;
}
function _msgSender() internal view returns (address) {
if (_msgSenderSet) {
return _msgSender;
}
return msg.sender;
}
function _msgData() internal view returns (bytes calldata) {
return msg.data;
}
}
Implementation Patterns
1. Gasless Token Transfers
contract GaslessToken is ERC20, MetaTxContext {
constructor() ERC20("Gasless Token", "GLT") {}
function transfer(address to, uint256 amount) public override returns (bool) {
address owner = _msgSender(); // Use meta-tx context
_transfer(owner, to, amount);
return true;
}
function transferFrom(address from, address to, uint256 amount) public override returns (bool) {
address spender = _msgSender(); // Use meta-tx context
_spendAllowance(from, spender, amount);
_transfer(from, to, amount);
return true;
}
function approve(address spender, uint256 amount) public override returns (bool) {
address owner = _msgSender(); // Use meta-tx context
_approve(owner, spender, amount);
return true;
}
}
2. Gasless NFT Minting
contract GaslessNFT is ERC721, MetaTxContext {
uint256 private _tokenIdCounter;
mapping(address => bool) public hasMinted;
constructor() ERC721("Gasless NFT", "GNFT") {}
function mint() external {
address user = _msgSender();
require(!hasMinted[user], "Already minted");
uint256 tokenId = _tokenIdCounter++;
_safeMint(user, tokenId);
hasMinted[user] = true;
}
function transfer(address to, uint256 tokenId) external {
address owner = _msgSender();
require(ownerOf(tokenId) == owner, "Not token owner");
_transfer(owner, to, tokenId);
}
}
3. Gasless Governance Voting
contract GaslessGovernance is MetaTxContext {
struct Proposal {
string description;
uint256 votesFor;
uint256 votesAgainst;
uint256 deadline;
bool executed;
mapping(address => bool) hasVoted;
}
mapping(uint256 => Proposal) public proposals;
uint256 public proposalCount;
IERC20 public votingToken;
function vote(uint256 proposalId, bool support) external {
address voter = _msgSender();
Proposal storage proposal = proposals[proposalId];
require(block.timestamp <= proposal.deadline, "Voting ended");
require(!proposal.hasVoted[voter], "Already voted");
require(votingToken.balanceOf(voter) > 0, "No voting power");
uint256 votingPower = votingToken.balanceOf(voter);
if (support) {
proposal.votesFor += votingPower;
} else {
proposal.votesAgainst += votingPower;
}
proposal.hasVoted[voter] = true;
emit VoteCast(proposalId, voter, support, votingPower);
}
}
4. Cross-Chain Gasless Operations
contract CrossChainGasless is MetaTxContext {
IIXFIGateway public gateway;
mapping(address => uint256) public gaslessCredits;
function crossChainCallGasless(
string memory destinationChain,
address targetContract,
bytes memory payload,
uint256 gasLimit
) external {
address user = _msgSender();
// Check gasless credits
require(gaslessCredits[user] >= gasLimit, "Insufficient gasless credits");
gaslessCredits[user] -= gasLimit;
// Prepare meta-transaction for destination chain
bytes memory metaTxPayload = abi.encode(
user,
targetContract,
payload,
gasLimit
);
gateway.callContract(
destinationChain,
address(this), // This contract on destination chain
metaTxPayload
);
emit CrossChainGaslessCall(user, destinationChain, targetContract);
}
function addGaslessCredits(address user, uint256 amount) external {
// Allow sponsors to add credits for users
IERC20(gasToken).transferFrom(msg.sender, address(this), amount);
gaslessCredits[user] += amount;
emit GaslessCreditsAdded(user, amount, msg.sender);
}
}
Client-Side Implementation
JavaScript SDK
class MetaTransactionManager {
constructor(provider, gatewayAddress, chainId) {
this.provider = provider;
this.gateway = new ethers.Contract(gatewayAddress, gatewayABI, provider);
this.chainId = chainId;
this.domain = {
name: "IXFI Meta Transaction",
version: "1",
chainId: chainId,
verifyingContract: gatewayAddress
};
}
async createMetaTransaction(userAddress, targetAddress, data, options = {}) {
const nonce = await this.gateway.nonces(userAddress);
const metaTx = {
from: userAddress,
to: targetAddress,
value: options.value || 0,
data: data,
nonce: nonce,
gasLimit: options.gasLimit || 250000,
gasPrice: options.gasPrice || 0,
gasToken: options.gasToken || ethers.ZeroAddress,
gasTokenAmount: options.gasTokenAmount || 0,
deadline: options.deadline || Math.floor(Date.now() / 1000) + 3600 // 1 hour
};
return metaTx;
}
async signMetaTransaction(signer, metaTx) {
const types = {
MetaTransaction: [
{ name: "from", type: "address" },
{ name: "to", type: "address" },
{ name: "value", type: "uint256" },
{ name: "data", type: "bytes" },
{ name: "nonce", type: "uint256" },
{ name: "gasLimit", type: "uint256" },
{ name: "gasPrice", type: "uint256" },
{ name: "gasToken", type: "address" },
{ name: "gasTokenAmount", type: "uint256" },
{ name: "deadline", type: "uint256" }
]
};
const signature = await signer.signTypedData(this.domain, types, metaTx);
return {
...metaTx,
signature: signature
};
}
async executeMetaTransaction(signedMetaTx, relayerSigner) {
const tx = await this.gateway.connect(relayerSigner).executeMetaTransaction(signedMetaTx);
return await tx.wait();
}
async estimateGas(metaTx) {
try {
const gasEstimate = await this.gateway.estimateGas.executeMetaTransaction(metaTx);
return gasEstimate;
} catch (error) {
console.error("Gas estimation failed:", error);
return BigInt(500000); // Fallback
}
}
}
React Hook
import { useState, useCallback } from 'react';
import { useWallet } from './useWallet';
export function useMetaTransactions(gatewayAddress, chainId) {
const { signer, address } = useWallet();
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState(null);
const metaTxManager = useMemo(() => {
if (!signer) return null;
return new MetaTransactionManager(signer.provider, gatewayAddress, chainId);
}, [signer, gatewayAddress, chainId]);
const executeGasless = useCallback(async (
targetContract,
functionName,
args = [],
options = {}
) => {
if (!metaTxManager || !signer || !address) {
throw new Error('Wallet not connected');
}
setIsLoading(true);
setError(null);
try {
// Encode function call
const iface = new ethers.Interface([`function ${functionName}`]);
const data = iface.encodeFunctionData(functionName, args);
// Create meta-transaction
const metaTx = await metaTxManager.createMetaTransaction(
address,
targetContract,
data,
options
);
// Sign meta-transaction
const signedMetaTx = await metaTxManager.signMetaTransaction(signer, metaTx);
// Submit to relayer service
const result = await submitToRelayer(signedMetaTx);
return result;
} catch (err) {
setError(err.message);
throw err;
} finally {
setIsLoading(false);
}
}, [metaTxManager, signer, address]);
return {
executeGasless,
isLoading,
error
};
}
async function submitToRelayer(signedMetaTx) {
const response = await fetch('/api/meta-transactions', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(signedMetaTx)
});
if (!response.ok) {
throw new Error('Failed to submit meta-transaction');
}
return await response.json();
}
Relayer Service
Node.js Relayer
class MetaTransactionRelayer {
constructor(config) {
this.provider = new ethers.JsonRpcProvider(config.rpc);
this.wallet = new ethers.Wallet(config.privateKey, this.provider);
this.gateway = new ethers.Contract(
config.gatewayAddress,
gatewayABI,
this.wallet
);
this.gasPrice = config.gasPrice || ethers.parseUnits('20', 'gwei');
this.maxGasLimit = config.maxGasLimit || 1000000;
}
async processMetaTransaction(signedMetaTx) {
try {
// Validate meta-transaction
const isValid = await this.validateMetaTransaction(signedMetaTx);
if (!isValid) {
throw new Error('Invalid meta-transaction');
}
// Check gas limits
if (signedMetaTx.gasLimit > this.maxGasLimit) {
throw new Error('Gas limit too high');
}
// Execute meta-transaction
const tx = await this.gateway.executeMetaTransaction(signedMetaTx, {
gasLimit: signedMetaTx.gasLimit + 50000, // Add buffer for gateway overhead
gasPrice: this.gasPrice
});
const receipt = await tx.wait();
return {
success: true,
txHash: receipt.hash,
gasUsed: receipt.gasUsed.toString(),
blockNumber: receipt.blockNumber
};
} catch (error) {
console.error('Meta-transaction execution failed:', error);
return {
success: false,
error: error.message
};
}
}
async validateMetaTransaction(metaTx) {
// Check signature
const domain = {
name: "IXFI Meta Transaction",
version: "1",
chainId: await this.provider.getNetwork().then(n => n.chainId),
verifyingContract: await this.gateway.getAddress()
};
const types = {
MetaTransaction: [
{ name: "from", type: "address" },
{ name: "to", type: "address" },
{ name: "value", type: "uint256" },
{ name: "data", type: "bytes" },
{ name: "nonce", type: "uint256" },
{ name: "gasLimit", type: "uint256" },
{ name: "gasPrice", type: "uint256" },
{ name: "gasToken", type: "address" },
{ name: "gasTokenAmount", type: "uint256" },
{ name: "deadline", type: "uint256" }
]
};
try {
const recoveredAddress = ethers.verifyTypedData(
domain,
types,
metaTx,
metaTx.signature
);
if (recoveredAddress.toLowerCase() !== metaTx.from.toLowerCase()) {
return false;
}
// Check nonce
const currentNonce = await this.gateway.nonces(metaTx.from);
if (metaTx.nonce !== currentNonce) {
return false;
}
// Check deadline
if (metaTx.deadline < Math.floor(Date.now() / 1000)) {
return false;
}
return true;
} catch (error) {
console.error('Signature validation failed:', error);
return false;
}
}
async estimateGas(metaTx) {
try {
const gasEstimate = await this.gateway.estimateGas.executeMetaTransaction(metaTx);
return gasEstimate;
} catch (error) {
console.error('Gas estimation failed:', error);
return null;
}
}
}
Express API Server
const express = require('express');
const app = express();
app.use(express.json());
const relayer = new MetaTransactionRelayer({
rpc: process.env.RPC_URL,
privateKey: process.env.RELAYER_PRIVATE_KEY,
gatewayAddress: process.env.GATEWAY_ADDRESS,
gasPrice: ethers.parseUnits('20', 'gwei'),
maxGasLimit: 1000000
});
// Queue for processing meta-transactions
const transactionQueue = [];
let isProcessing = false;
app.post('/api/meta-transactions', async (req, res) => {
try {
const signedMetaTx = req.body;
// Basic validation
if (!signedMetaTx.signature || !signedMetaTx.from) {
return res.status(400).json({ error: 'Invalid meta-transaction' });
}
// Add to processing queue
transactionQueue.push({
metaTx: signedMetaTx,
timestamp: Date.now(),
id: generateTxId()
});
// Start processing if not already running
if (!isProcessing) {
processQueue();
}
res.json({
success: true,
message: 'Meta-transaction queued for processing',
queuePosition: transactionQueue.length
});
} catch (error) {
res.status(500).json({ error: error.message });
}
});
app.get('/api/meta-transactions/:txId/status', (req, res) => {
// Return transaction status
const txId = req.params.txId;
// Implementation depends on your storage solution
res.json({ status: 'pending' });
});
async function processQueue() {
isProcessing = true;
while (transactionQueue.length > 0) {
const item = transactionQueue.shift();
try {
const result = await relayer.processMetaTransaction(item.metaTx);
console.log(`Processed meta-transaction ${item.id}:`, result);
// Store result in database or cache
// await storeResult(item.id, result);
} catch (error) {
console.error(`Failed to process meta-transaction ${item.id}:`, error);
}
// Small delay between transactions
await new Promise(resolve => setTimeout(resolve, 1000));
}
isProcessing = false;
}
function generateTxId() {
return Date.now().toString(36) + Math.random().toString(36).substr(2);
}
app.listen(3000, () => {
console.log('Meta-transaction relayer API listening on port 3000');
});
Gas Sponsorship Models
1. Application-Sponsored
contract AppSponsoredGasless {
mapping(address => uint256) public sponsorBudgets;
mapping(address => mapping(address => uint256)) public userLimits;
function sponsorUser(
address user,
uint256 gasLimit,
uint256 dailyLimit
) external payable {
require(msg.value > 0, "Must deposit ETH for sponsorship");
sponsorBudgets[msg.sender] += msg.value;
userLimits[msg.sender][user] = dailyLimit;
emit UserSponsored(msg.sender, user, gasLimit, dailyLimit);
}
function sponsoredExecution(
address sponsor,
MetaTransaction memory metaTx
) external {
require(sponsorBudgets[sponsor] >= metaTx.gasLimit * metaTx.gasPrice, "Insufficient sponsor budget");
require(userLimits[sponsor][metaTx.from] >= metaTx.gasLimit, "User limit exceeded");
// Deduct from sponsor budget
uint256 gasCost = metaTx.gasLimit * metaTx.gasPrice;
sponsorBudgets[sponsor] -= gasCost;
userLimits[sponsor][metaTx.from] -= metaTx.gasLimit;
// Execute transaction (internal batch processing)
// Note: Single transactions are processed as single-item batches
MetaTransaction[] memory batch = new MetaTransaction[](1);
batch[0] = metaTx;
_executeMetaTransactionBatch(batch);
emit SponsoredExecution(sponsor, metaTx.from, gasCost);
}
}
}
2. Token-Based Sponsorship
contract TokenSponsoredGasless {
IERC20 public sponsorshipToken;
uint256 public tokenPerGasRatio; // tokens per gas unit
mapping(address => uint256) public tokenCredits;
function depositTokensForGas(uint256 tokenAmount) external {
sponsorshipToken.transferFrom(msg.sender, address(this), tokenAmount);
uint256 gasCredits = tokenAmount / tokenPerGasRatio;
tokenCredits[msg.sender] += gasCredits;
emit TokensDepositedForGas(msg.sender, tokenAmount, gasCredits);
}
function executeWithTokenGas(MetaTransaction memory metaTx) external {
require(tokenCredits[metaTx.from] >= metaTx.gasLimit, "Insufficient token credits");
// Deduct token credits
tokenCredits[metaTx.from] -= metaTx.gasLimit;
// Execute transaction (as single-item batch)
MetaTransaction[] memory batch = new MetaTransaction[](1);
batch[0] = metaTx;
_executeMetaTransactionBatch(batch);
emit TokenGasUsed(metaTx.from, metaTx.gasLimit);
}
}
3. NFT Membership Gasless
contract MembershipGasless {
IERC721 public membershipNFT;
mapping(uint256 => uint256) public membershipGasLimits; // tokenId => daily gas limit
mapping(uint256 => uint256) public dailyGasUsed; // tokenId => gas used today
mapping(uint256 => uint256) public lastResetDay; // tokenId => last reset day
function executeAsMember(
uint256 membershipTokenId,
MetaTransaction memory metaTx
) external {
require(membershipNFT.ownerOf(membershipTokenId) == metaTx.from, "Not membership owner");
uint256 today = block.timestamp / 1 days;
// Reset daily usage if new day
if (lastResetDay[membershipTokenId] < today) {
dailyGasUsed[membershipTokenId] = 0;
lastResetDay[membershipTokenId] = today;
}
uint256 gasLimit = membershipGasLimits[membershipTokenId];
require(dailyGasUsed[membershipTokenId] + metaTx.gasLimit <= gasLimit, "Daily gas limit exceeded");
// Update gas usage
dailyGasUsed[membershipTokenId] += metaTx.gasLimit;
// Execute transaction (as single-item batch)
MetaTransaction[] memory batch = new MetaTransaction[](1);
batch[0] = metaTx;
_executeMetaTransactionBatch(batch);
emit MembershipGasUsed(membershipTokenId, metaTx.from, metaTx.gasLimit);
}
}
Security Considerations
Signature Replay Protection
contract ReplayProtection {
mapping(address => uint256) public nonces;
mapping(bytes32 => bool) public usedSignatures;
function _verifyAndUpdateNonce(
address user,
uint256 nonce,
bytes32 signatureHash
) internal {
require(nonce == nonces[user], "Invalid nonce");
require(!usedSignatures[signatureHash], "Signature already used");
nonces[user]++;
usedSignatures[signatureHash] = true;
}
}
Rate Limiting
contract RateLimiter {
mapping(address => uint256) public lastExecution;
mapping(address => uint256) public executionCount;
uint256 public constant MIN_INTERVAL = 1 seconds;
uint256 public constant MAX_PER_HOUR = 100;
modifier rateLimited(address user) {
require(block.timestamp >= lastExecution[user] + MIN_INTERVAL, "Too frequent");
uint256 currentHour = block.timestamp / 1 hours;
uint256 lastHour = lastExecution[user] / 1 hours;
if (currentHour > lastHour) {
executionCount[user] = 0;
}
require(executionCount[user] < MAX_PER_HOUR, "Hourly limit exceeded");
lastExecution[user] = block.timestamp;
executionCount[user]++;
_;
}
}
Best Practices
1. Security
Always validate signatures and nonces
Implement rate limiting and access controls
Use secure random nonce generation
Validate transaction deadlines
2. Gas Management
Set reasonable gas limits
Implement gas price oracles
Monitor relayer gas costs
Use gas estimation APIs
3. User Experience
Provide clear transaction status updates
Implement retry mechanisms for failed transactions
Cache user signatures when possible
Optimize transaction batching
4. Relayer Operations
Monitor relayer balance and health
Implement automatic top-up mechanisms
Use multiple relayers for redundancy
Track and optimize gas costs
Resources
Last updated