React Gas Relayer Integration Example
Complete example showing how to implement gasless transactions in React using the Loyalteez Gas Relayer.
Overview
The Gas Relayer enables your users to interact with blockchain features without needing ETH for gas fees. Your brand pays all gas costs, creating a seamless Web2-like experience.
Installation
npm install @privy-io/react-auth ethers
Complete Implementation
1. Gas Relayer Hook
// src/hooks/useGasRelayer.ts
import { usePrivy } from '@privy-io/react-auth';
import { ethers } from 'ethers';
const GAS_RELAYER_URL = 'https://relayer.loyalteez.app/relay';
export interface RelayTransactionParams {
to: string;
data: string;
value?: string;
gasLimit?: number;
metadata?: {
action?: string;
permit?: {
owner: string;
spender: string;
value: string;
deadline: number;
v: number;
r: string;
s: string;
};
[key: string]: any;
};
}
export interface RelayTransactionResult {
success: boolean;
transactionHash?: string;
gasUsed?: string;
blockNumber?: number;
error?: string;
code?: string;
}
export function useGasRelayer() {
const { getAccessToken, user, authenticated } = usePrivy();
const relayTransaction = async (
params: RelayTransactionParams
): Promise<RelayTransactionResult> => {
if (!authenticated || !user?.wallet?.address) {
throw new Error('User not authenticated');
}
try {
// Get Privy access token
const token = await getAccessToken();
// Make relay request
const response = await fetch(GAS_RELAYER_URL, {
method: 'POST',
headers: {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
...params,
userAddress: user.wallet.address,
}),
});
const result = await response.json();
if (!response.ok) {
throw new Error(result.error || 'Transaction failed');
}
return result;
} catch (error) {
console.error('Gas relay error:', error);
throw error;
}
};
return { relayTransaction, userAddress: user?.wallet?.address };
}
2. Perk Claiming Component
// src/components/PerkClaimer.tsx
import { useState } from 'react';
import { ethers } from 'ethers';
import { useGasRelayer } from '../hooks/useGasRelayer';
const PERK_NFT_ADDRESS = import.meta.env.VITE_PERK_NFT_ADDRESS;
const PERK_NFT_ABI = [
'function claimPerk(uint256 perkId) external',
'function getPerkDetails(uint256 perkId) external view returns (string, string, uint256)',
];
interface PerkClaimerProps {
perkId: number;
name: string;
cost: string;
}
export function PerkClaimer({ perkId, name, cost }: PerkClaimerProps) {
const { relayTransaction } = useGasRelayer();
const [status, setStatus] = useState<'idle' | 'claiming' | 'success' | 'error'>('idle');
const [txHash, setTxHash] = useState<string | null>(null);
const [errorMessage, setErrorMessage] = useState<string | null>(null);
const handleClaim = async () => {
setStatus('claiming');
setErrorMessage(null);
try {
// Encode the claimPerk function call
const iface = new ethers.Interface(PERK_NFT_ABI);
const data = iface.encodeFunctionData('claimPerk', [perkId]);
// Relay the transaction
const result = await relayTransaction({
to: PERK_NFT_ADDRESS,
data,
metadata: {
action: 'claim_perk',
perkId: perkId.toString(),
},
});
if (result.success) {
setTxHash(result.transactionHash || null);
setStatus('success');
} else {
throw new Error(result.error || 'Transaction failed');
}
} catch (error: any) {
console.error('Claim error:', error);
setErrorMessage(error.message || 'Failed to claim perk');
setStatus('error');
}
};
return (
<div className="perk-claimer">
<h3>{name}</h3>
<p>Cost: {cost} LTZ</p>
{status === 'idle' && (
<button onClick={handleClaim} className="claim-button">
Claim Perk (No Gas Fees!)
</button>
)}
{status === 'claiming' && (
<div className="claiming">
<span className="spinner" />
<p>Processing transaction...</p>
</div>
)}
{status === 'success' && (
<div className="success">
<p>✅ Perk claimed successfully!</p>
{txHash && (
<a
href={`https://soneium.blockscout.com/tx/${txHash}`}
target="_blank"
rel="noopener noreferrer"
>
View on Explorer →
</a>
)}
<button onClick={() => setStatus('idle')} className="reset-button">
Claim Another
</button>
</div>
)}
{status === 'error' && (
<div className="error">
<p>❌ {errorMessage}</p>
<button onClick={() => setStatus('idle')} className="retry-button">
Try Again
</button>
</div>
)}
</div>
);
}
3. With EIP-2612 Permit (Gasless Approval)
// src/hooks/usePermit.ts
import { usePrivy } from '@privy-io/react-auth';
import { ethers } from 'ethers';
const LTZ_TOKEN_ADDRESS = import.meta.env.VITE_LOYALTEEZ_ADDRESS;
export function usePermit() {
const { user } = usePrivy();
const signPermit = async (params: {
spender: string;
value: string;
deadline: number;
}) => {
if (!user?.wallet?.address) {
throw new Error('No wallet connected');
}
// Get the EIP-712 domain
const domain = {
name: 'Loyalteez',
version: '1',
chainId: 1868, // Soneium Mainnet
verifyingContract: LTZ_TOKEN_ADDRESS,
};
// Define EIP-2612 Permit type
const types = {
Permit: [
{ name: 'owner', type: 'address' },
{ name: 'spender', type: 'address' },
{ name: 'value', type: 'uint256' },
{ name: 'nonce', type: 'uint256' },
{ name: 'deadline', type: 'uint256' },
],
};
// Get nonce from contract (simplified - you'd need to call the contract)
const nonce = 0; // TODO: Get actual nonce from contract
const message = {
owner: user.wallet.address,
spender: params.spender,
value: params.value,
nonce,
deadline: params.deadline,
};
// Sign the permit
const provider = new ethers.BrowserProvider(window.ethereum);
const signer = await provider.getSigner();
const signature = await signer.signTypedData(domain, types, message);
// Split signature
const sig = ethers.Signature.from(signature);
return {
owner: user.wallet.address,
spender: params.spender,
value: params.value,
deadline: params.deadline,
v: sig.v,
r: sig.r,
s: sig.s,
};
};
return { signPermit };
}
4. Perk Claimer with Gasless Approval
// src/components/PerkClaimerWithPermit.tsx
import { useState } from 'react';
import { ethers } from 'ethers';
import { useGasRelayer } from '../hooks/useGasRelayer';
import { usePermit } from '../hooks/usePermit';
const PERK_NFT_ADDRESS = import.meta.env.VITE_PERK_NFT_ADDRESS;
const LTZ_TOKEN_ADDRESS = import.meta.env.VITE_LOYALTEEZ_ADDRESS;
export function PerkClaimerWithPermit({ perkId, name, cost }: any) {
const { relayTransaction } = useGasRelayer();
const { signPermit } = usePermit();
const [status, setStatus] = useState('idle');
const handleClaimWithPermit = async () => {
setStatus('signing');
try {
// Step 1: Sign permit (gasless approval)
const deadline = Math.floor(Date.now() / 1000) + 3600; // 1 hour
const permit = await signPermit({
spender: PERK_NFT_ADDRESS,
value: ethers.parseUnits(cost, 18).toString(),
deadline,
});
setStatus('claiming');
// Step 2: Encode claim function
const iface = new ethers.Interface([
'function claimPerk(uint256 perkId) external',
]);
const data = iface.encodeFunctionData('claimPerk', [perkId]);
// Step 3: Relay transaction with permit
const result = await relayTransaction({
to: PERK_NFT_ADDRESS,
data,
metadata: {
action: 'claim_perk',
perkId: perkId.toString(),
permit, // Gas relayer will handle permit execution
},
});
if (result.success) {
setStatus('success');
}
} catch (error) {
console.error('Claim error:', error);
setStatus('error');
}
};
return (
<div>
<h3>{name}</h3>
{status === 'idle' && (
<button onClick={handleClaimWithPermit}>
Claim with Gasless Approval
</button>
)}
{status === 'signing' && <p>Please sign the approval...</p>}
{status === 'claiming' && <p>Claiming perk...</p>}
{status === 'success' && <p>✅ Perk claimed!</p>}
{status === 'error' && <p>❌ Claim failed</p>}
</div>
);
}
5. Error Handling Component
// src/components/GasRelayerError.tsx
import React from 'react';
interface GasRelayerErrorProps {
error: {
code?: string;
message: string;
};
onRetry?: () => void;
}
export function GasRelayerError({ error, onRetry }: GasRelayerErrorProps) {
const getErrorMessage = () => {
switch (error.code) {
case 'RATE_LIMIT_EXCEEDED':
return 'You\'ve made too many transactions. Please wait a few minutes and try again.';
case 'VALIDATION_FAILED':
return 'Transaction validation failed. Please check your parameters.';
case 'CONTRACT_NOT_WHITELISTED':
return 'This contract is not authorized for gasless transactions.';
case 'UNAUTHORIZED':
return 'Authentication failed. Please sign in again.';
default:
return error.message || 'Transaction failed. Please try again.';
}
};
return (
<div className="error-container">
<p className="error-message">{getErrorMessage()}</p>
{onRetry && (
<button onClick={onRetry} className="retry-button">
Try Again
</button>
)}
</div>
);
}
Full Example App
// src/App.tsx
import { PrivyProvider } from '@privy-io/react-auth';
import { PerkClaimer } from './components/PerkClaimer';
import { PerkClaimerWithPermit } from './components/PerkClaimerWithPermit';
function AppContent() {
const perks = [
{ perkId: 1, name: 'Free Coffee', cost: '100' },
{ perkId: 2, name: 'Premium Feature', cost: '500' },
{ perkId: 3, name: 'VIP Access', cost: '1000' },
];
return (
<div className="app">
<header>
<h1>Gasless Perk Claiming</h1>
<p>Claim perks without paying gas fees</p>
</header>
<section className="perks-section">
<h2>Standard Claims</h2>
<div className="perks-grid">
{perks.map((perk) => (
<PerkClaimer key={perk.perkId} {...perk} />
))}
</div>
<h2>Claims with Gasless Approval</h2>
<div className="perks-grid">
{perks.map((perk) => (
<PerkClaimerWithPermit key={perk.perkId} {...perk} />
))}
</div>
</section>
</div>
);
}
export default function App() {
return (
<PrivyProvider appId={import.meta.env.VITE_PRIVY_APP_ID}>
<AppContent />
</PrivyProvider>
);
}
Testing
// src/hooks/__tests__/useGasRelayer.test.tsx
import { renderHook, act } from '@testing-library/react';
import { useGasRelayer } from '../useGasRelayer';
// Mock Privy
jest.mock('@privy-io/react-auth', () => ({
usePrivy: () => ({
getAccessToken: jest.fn().mockResolvedValue('mock-token'),
user: { wallet: { address: '0x123' } },
authenticated: true,
}),
}));
// Mock fetch
global.fetch = jest.fn();
describe('useGasRelayer', () => {
beforeEach(() => {
jest.clearAllMocks();
});
it('successfully relays transaction', async () => {
(global.fetch as jest.Mock).mockResolvedValueOnce({
ok: true,
json: async () => ({
success: true,
transactionHash: '0xabc',
}),
});
const { result } = renderHook(() => useGasRelayer());
let txResult;
await act(async () => {
txResult = await result.current.relayTransaction({
to: '0x456',
data: '0xabcd',
});
});
expect(txResult).toEqual({
success: true,
transactionHash: '0xabc',
});
});
it('handles rate limit errors', async () => {
(global.fetch as jest.Mock).mockResolvedValueOnce({
ok: false,
json: async () => ({
error: 'Rate limit exceeded',
code: 'RATE_LIMIT_EXCEEDED',
}),
});
const { result } = renderHook(() => useGasRelayer());
await act(async () => {
await expect(
result.current.relayTransaction({
to: '0x456',
data: '0xabcd',
})
).rejects.toThrow('Rate limit exceeded');
});
});
});
Best Practices
1. Handle All Error Cases
try {
const result = await relayTransaction(params);
} catch (error: any) {
if (error.code === 'RATE_LIMIT_EXCEEDED') {
// Show cooldown message
} else if (error.code === 'VALIDATION_FAILED') {
// Show validation error
} else {
// Generic error
}
}
2. Show Transaction Status
const [txStatus, setTxStatus] = useState<'idle' | 'pending' | 'success' | 'error'>('idle');
// Update status throughout the flow
setTxStatus('pending');
// ... relay transaction
setTxStatus('success');
3. Provide Transaction Links
{txHash && (
<a href={`https://soneium.blockscout.com/tx/${txHash}`} target="_blank">
View on Block Explorer
</a>
)}
4. Implement Retry Logic
const retryTransaction = async (maxRetries = 3) => {
for (let i = 0; i < maxRetries; i++) {
try {
return await relayTransaction(params);
} catch (error) {
if (i === maxRetries - 1) throw error;
await new Promise(resolve => setTimeout(resolve, 1000 * (i + 1)));
}
}
};
Styling Example
/* src/styles/perk-claimer.css */
.perk-claimer {
border: 1px solid #e0e0e0;
border-radius: 8px;
padding: 20px;
max-width: 400px;
}
.claim-button {
background: #6C33EA;
color: white;
border: none;
padding: 12px 24px;
border-radius: 6px;
cursor: pointer;
font-size: 16px;
}
.claim-button:hover {
background: #5a2bc4;
}
.claiming {
display: flex;
align-items: center;
gap: 10px;
}
.spinner {
width: 20px;
height: 20px;
border: 3px solid #f3f3f3;
border-top: 3px solid #6C33EA;
border-radius: 50%;
animation: spin 1s linear infinite;
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
.success {
color: #4caf50;
}
.error {
color: #f44336;
}
Next Steps
- Gas Relayer API Reference - Complete API documentation
- Integration Guide - Detailed integration guide
- SDK Reference - SDK documentation
- React Example - Basic React integration
Support
- GitHub: github.com/Alpha4-Labs/loyalteez-examples
- Email: [email protected]
- Docs: docs.loyalteez.app