Gasless Implementation
A detailed guide to implementing Gasless payments.
Use the Widget (Recommended)
The easiest way to use Gasless payments is via the @solo-pay/widget-js or @solo-pay/widget-react SDK. The widget automatically handles token Approve checks, Permit (EIP-2612) detection, EIP-712 signing, and relay submission.
See Widget Integration Guide for quick setup.
Custom Implementation Flow
If you need a custom flow instead of the widget, follow the steps below. All steps are client-side and use the REST API directly.
1. Create Payment (REST API — client-side)
↓
2. Token Approve check (frontend)
↓
3. Request EIP-712 Signature (frontend)
↓
4. Submit Gasless Request (REST API — client-side)
↓
5. Check Status (REST API — client-side)Step 1: Create Payment
Call POST /payments with the x-public-key header. This can be called directly from the browser.
const response = await fetch('https://gateway.dev.solonetwork.io/api/v1/payments', {
method: 'POST',
headers: {
'x-public-key': 'pk_xxxxx',
'Content-Type': 'application/json',
},
body: JSON.stringify({
orderId: 'order-001',
amount: 10.5,
tokenAddress: '0xE4C687167705Abf55d709395f92e254bdF5825a2',
successUrl: 'https://example.com/success',
failUrl: 'https://example.com/fail',
}),
});
const { data: payment } = await response.json();
// payment contains: paymentId, forwarderAddress, gatewayAddress, amount, deadline, ...Check forwarderAddress
payment.forwarderAddress must be present to use Gasless on that chain.
Step 2: Token Approve
Even for Gasless payments, the Relayer cannot transfer tokens unless the user has first completed an approve transaction granting the PaymentGateway contract permission to use the token.
import { useWriteContract, useReadContract } from 'wagmi';
// 1. Check existing allowance
const { data: allowance } = useReadContract({
address: tokenAddress,
abi: ERC20ABI,
functionName: 'allowance',
args: [userAddress, gatewayAddress],
});
// 2. If insufficient, send Approve transaction (user pays gas for this 1-time setup)
if (allowance < BigInt(amount)) {
await writeContract({
address: tokenAddress,
abi: ERC20ABI,
functionName: 'approve',
args: [gatewayAddress, amount],
});
}Permit (Signature Approval) Supported Tokens
Modern tokens like USDC support Permit (EIP-2612), which replaces the approve transaction with a simple signature. If you use the official SoloPay Widget (@solo-pay/widget-js or @solo-pay/widget-react), it will automatically detect EIP-2612 support and skip the 1-time approve transaction, handling the Permit entirely gas-free via signature.
TIP
Once a sufficient amount is approved, all subsequent purchases (Step 3) can be completely gasless via Signature only.
Step 3: Request EIP-712 Signature
Request a signature from the user on the frontend.
import { useSignTypedData } from 'wagmi';
import { encodeFunctionData } from 'viem';
const { signTypedDataAsync } = useSignTypedData();
// Fetch current nonce from Forwarder
const nonce = await publicClient.readContract({
address: forwarderAddress,
abi: ERC2771ForwarderABI,
functionName: 'nonces',
args: [userAddress],
});
// Build Forward Request (PaymentGateway.pay — deadline from API response)
const forwardRequest = {
from: userAddress,
to: gatewayAddress,
value: 0n,
gas: 200000n,
nonce,
deadline: BigInt(Math.floor(Date.now() / 1000) + 3600), // 1 hour
data: encodeFunctionData({
abi: PaymentGatewayABI,
functionName: 'pay',
args: [
paymentId,
tokenAddress,
BigInt(amount),
recipientAddress,
merchantId,
BigInt(deadline), // from payment.deadline (API)
permitData, // EIP-2612 permit, or zero permit { deadline: 0, v: 0, r: '0x00...', s: '0x00...' }
],
}),
};
// EIP-712 Sign — domain name/version must match the forwarder contract used by the relay API (e.g. SoloPay, SoloForwarder, or ERC2771Forwarder)
const signature = await signTypedDataAsync({
domain: {
name: 'ERC2771Forwarder', // Must match your deployed forwarder; relay server validates this
version: '1',
chainId: 80002, // Polygon Amoy
verifyingContract: forwarderAddress,
},
types: {
ForwardRequest: [
{ name: 'from', type: 'address' },
{ name: 'to', type: 'address' },
{ name: 'value', type: 'uint256' },
{ name: 'gas', type: 'uint256' },
{ name: 'nonce', type: 'uint256' },
{ name: 'deadline', type: 'uint48' },
{ name: 'data', type: 'bytes' },
],
},
primaryType: 'ForwardRequest',
message: forwardRequest,
});Important
Signing is NOT a transaction, so no gas fees are charged. The pay function requires deadline from the API response; use a zero permit when not using EIP-2612.
Step 4: Submit Gasless Request
Endpoint: POST /payments/:id/relay
const result = await fetch(
`https://gateway.dev.solonetwork.io/api/v1/payments/${payment.paymentId}/relay`,
{
method: 'POST',
headers: {
'x-public-key': 'pk_xxxxx',
'Content-Type': 'application/json',
},
body: JSON.stringify({
paymentId: payment.paymentId,
forwarderAddress: payment.forwarderAddress,
forwardRequest: {
from: forwardRequest.from,
to: forwardRequest.to,
value: '0',
gas: '200000',
nonce: forwardRequest.nonce.toString(),
deadline: forwardRequest.deadline.toString(),
data: forwardRequest.data,
signature,
},
}),
}
).then((r) => r.json());Step 5: Check Status
// Relay status (by paymentId)
const relayStatus = await fetch(
`https://gateway.dev.solonetwork.io/api/v1/payments/${paymentId}/relay`,
{ headers: { 'x-public-key': 'pk_xxxxx' } }
).then((r) => r.json());
// relayStatus.data.status: 'QUEUED' | 'SUBMITTED' | 'CONFIRMED' | 'FAILED'
// Payment status
const paymentStatus = await fetch(
`https://gateway.dev.solonetwork.io/api/v1/payments/${paymentId}`,
{
headers: { 'x-public-key': 'pk_xxxxx' },
}
).then((r) => r.json());
// paymentStatus.data.status: 'CREATED' | 'PAID' | 'REFUND_SUBMITTED' | 'REFUNDED' | 'INVALID' | 'EXPIRED' | 'FAILED'Full Example (React + wagmi)
function GaslessPayment({ payment }) {
const { address } = useAccount();
const publicClient = usePublicClient();
const { signTypedDataAsync } = useSignTypedData();
const { paymentId, forwarderAddress, gatewayAddress, amount, tokenAddress,
recipientAddress, merchantId, deadline, chainId } = payment;
const handleGaslessPayment = async () => {
const nonce = await publicClient.readContract({
address: forwarderAddress, abi: ERC2771ForwarderABI,
functionName: 'nonces', args: [address],
});
const payDeadline = BigInt(deadline);
const zeroPermit = { deadline: 0, v: 0, r: '0x0000000000000000000000000000000000000000000000000000000000000000' as const, s: '0x0000000000000000000000000000000000000000000000000000000000000000' as const };
const forwardRequest = {
from: address, to: gatewayAddress, value: 0n, gas: 200000n, nonce,
deadline: BigInt(Math.floor(Date.now() / 1000) + 3600),
data: encodeFunctionData({
abi: PaymentGatewayABI, functionName: 'pay',
args: [paymentId, tokenAddress, BigInt(amount), recipientAddress, merchantId, payDeadline, zeroPermit],
}),
};
const signature = await signTypedDataAsync({
domain: { name: 'ERC2771Forwarder', version: '1', chainId, verifyingContract: forwarderAddress },
types: {
ForwardRequest: [
{ name: 'from', type: 'address' }, { name: 'to', type: 'address' },
{ name: 'value', type: 'uint256' }, { name: 'gas', type: 'uint256' },
{ name: 'nonce', type: 'uint256' }, { name: 'deadline', type: 'uint48' },
{ name: 'data', type: 'bytes' },
],
},
primaryType: 'ForwardRequest', message: forwardRequest,
});
const result = await fetch(
`https://gateway.dev.solonetwork.io/api/v1/payments/${paymentId}/relay`,
{
method: 'POST',
headers: { 'x-public-key': 'pk_xxxxx', 'Content-Type': 'application/json' },
body: JSON.stringify({
paymentId, forwarderAddress,
forwardRequest: {
from: forwardRequest.from, to: forwardRequest.to,
value: '0', gas: '200000',
nonce: forwardRequest.nonce.toString(),
deadline: forwardRequest.deadline.toString(),
data: forwardRequest.data, signature,
},
}),
}
).then((r) => r.json());
return result;
};
return <button onClick={handleGaslessPayment}>Pay without gas</button>;
}Error Handling
| Error Code | Cause | Resolution |
|---|---|---|
INVALID_SIGNATURE | Invalid signature format | Ensure signature is a hex string starting with 0x |
INVALID_PAYMENT_STATUS | Payment in terminal state (e.g. PAID, REFUNDED, EXPIRED etc.) | Only send relay when status is CREATED; prevent duplicate requests |
PAYMENT_EXPIRED | Payment expired | Create a new payment and retry |
RELAYER_NOT_CONFIGURED | No Relayer for this chain | Verify supported chains |
VALIDATION_ERROR | Input validation failed | Verify forwardRequest amount matches payment amount |
Next Steps
- Webhooks - Receive payment completion notifications
- Error Codes - Full error list