Skip to content

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; optionally request one-time gas (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.

typescript
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, isFaucetEnabled, ...

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.

If payment.isFaucetEnabled is true, a wallet without enough native token for this approval can request a one-time grant before calling approve:

typescript
const apiBase = 'https://gateway.dev.solonetwork.io/api/v1';
const publicKey = 'pk_xxxxx';

if (payment.isFaucetEnabled) {
  const gasRes = await fetch(
    `${apiBase}/payments/${payment.paymentId}/gas-status?walletAddress=${userAddress}`,
    { headers: { 'x-public-key': publicKey } }
  );

  if (!gasRes.ok) {
    // FAUCET_DISABLED, NOT_FOUND, etc. — see API reference
    const err = await gasRes.json();
    throw new Error(err.code ?? 'GAS_STATUS_FAILED');
  }

  const gasStatus = await gasRes.json();

  if (gasStatus.canReceiveGas) {
    const grantRes = await fetch(`${apiBase}/payments/request-gas`, {
      method: 'POST',
      headers: {
        'x-public-key': publicKey,
        'Content-Type': 'application/json',
        // Origin is set automatically in the browser. Server-side callers must set it
        // when ALLOWED_WIDGET_ORIGIN is configured — see Authentication guide.
      },
      body: JSON.stringify({
        paymentId: payment.paymentId,
        walletAddress: userAddress,
      }),
    });

    if (!grantRes.ok) {
      // PAYMENT_ALREADY_GRANTED, ALREADY_GRANTED, GRANT_IN_PROGRESS, etc.
      const err = await grantRes.json();
      throw new Error(err.code ?? 'REQUEST_GAS_FAILED');
    }
    // Response returns after the grant tx is confirmed on-chain — safe to approve next.
  }
}

Each wallet can receive one grant per chain, and each payment can fund one grant. canReceiveGas is wallet-level only; handle PAYMENT_ALREADY_GRANTED from request-gas when another wallet already used this payment's grant. If faucet support is disabled or the wallet is ineligible, the payer must supply the approval gas normally. See API Reference for full error codes.

typescript
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 (using granted gas when eligible; otherwise user-funded gas)
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.

typescript
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

typescript
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

typescript
// 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)

typescript
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 CodeCauseResolution
INVALID_SIGNATUREInvalid signature formatEnsure signature is a hex string starting with 0x
INVALID_PAYMENT_STATUSPayment in terminal state (e.g. PAID, REFUNDED, EXPIRED etc.)Only send relay when status is CREATED; prevent duplicate requests
PAYMENT_EXPIREDPayment expiredCreate a new payment and retry
RELAYER_NOT_CONFIGUREDNo Relayer for this chainVerify supported chains
VALIDATION_ERRORInput validation failedVerify forwardRequest amount matches payment amount

Next Steps

Non-custodial Web3 payment infrastructure for ERC-20 checkout, sponsored gas, and wallet-to-wallet settlement.