Skip to main content

React Gas Relayer Integration Example

Complete example showing how to implement gasless transactions in React using the Resonance 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.rsnc.network/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} RSNC</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 RSNC_TOKEN_ADDRESS = import.meta.env.VITE_RESONANCE_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: 'Resonance',
version: '1',
chainId: 1868, // Soneium Mainnet
verifyingContract: RSNC_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 RSNC_TOKEN_ADDRESS = import.meta.env.VITE_RESONANCE_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');
{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​

Support​