Skip to content

가스리스 구현 ​

가스리스 결제를 구현하는 상세 가이드입니다.

위젯 사용 권장

가스리스 결제를 사용하는 가장 쉬운 방법은 @solo-pay/widget-js 또는 @solo-pay/widget-react SDK입니다. 위젯은 토큰 Approve 확인, Permit(EIP-2612) 감지, EIP-712 서명, 릴레이 제출을 자동으로 처리합니다.

빠른 설정은 위젯 연동 가이드를 참조하세요.

커스텀 구현 흐름 ​

위젯 대신 커스텀 흐름이 필요하면 아래 단계를 따르세요. 모든 단계는 클라이언트 사이드에서 REST API를 직접 사용합니다.

1. 결제 생성 (REST API — 클라이언트)
       ↓
2. 토큰 Approve 확인 및 선택적 1회 가스 요청 (프론트엔드)
       ↓
3. EIP-712 서명 요청 (프론트엔드)
       ↓
4. 가스리스 요청 제출 (REST API — 클라이언트)
       ↓
5. 상태 확인 (REST API — 클라이언트)

Step 1: 결제 생성 ​

x-public-key 헤더와 함께 POST /payments를 호출합니다. 브라우저에서 직접 호출할 수 있습니다.

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: paymentId, forwarderAddress, gatewayAddress, amount, deadline, isFaucetEnabled 등 포함

forwarderAddress 확인

해당 체인에서 가스리스를 사용하려면 payment.forwarderAddress가 반드시 있어야 합니다.

Step 2: 토큰 Approve ​

가스리스 결제에서도 릴레이어는 사용자가 먼저 PaymentGateway 컨트랙트에 토큰 사용 권한을 부여하는 approve 트랜잭션을 완료해야만 토큰을 전송할 수 있습니다.

payment.isFaucetEnabled이 true이면, 승인에 필요한 네이티브 토큰이 부족한 지갑이 approve 전에 1회 지원을 요청할 수 있습니다.

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 등 — API 레퍼런스 참고
    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이 자동 설정됩니다. 서버 측 호출 시
        // ALLOWED_WIDGET_ORIGIN이 설정되어 있으면 직접 설정해야 합니다 — 인증 가이드 참고.
      },
      body: JSON.stringify({
        paymentId: payment.paymentId,
        walletAddress: userAddress,
      }),
    });

    if (!grantRes.ok) {
      // PAYMENT_ALREADY_GRANTED, ALREADY_GRANTED, GRANT_IN_PROGRESS 등
      const err = await grantRes.json();
      throw new Error(err.code ?? 'REQUEST_GAS_FAILED');
    }
    // 응답은 지원 트랜잭션이 온체인 확정된 후 반환 — 바로 approve 가능.
  }
}

지갑은 체인별로 한 번만 지원받을 수 있고, 하나의 결제는 한 번의 지원만 제공할 수 있습니다. canReceiveGas는 지갑 단위 조건이며, 다른 지갑이 이미 이 결제의 지원을 사용한 경우 request-gas에서 PAYMENT_ALREADY_GRANTED를 처리하세요. Faucet이 비활성화되어 있거나 지원 조건을 충족하지 못하면 사용자가 승인 가스를 직접 준비해야 합니다. 전체 오류 코드는 API 레퍼런스를 참고하세요.

typescript
import { useWriteContract, useReadContract } from 'wagmi';

// 1. 기존 allowance 확인
const { data: allowance } = useReadContract({
  address: tokenAddress,
  abi: ERC20ABI,
  functionName: 'allowance',
  args: [userAddress, gatewayAddress],
});

// 2. 부족하면 Approve 전송 (지원 대상이면 받은 가스, 아니면 사용자 가스 사용)
if (allowance < BigInt(amount)) {
  await writeContract({
    address: tokenAddress,
    abi: ERC20ABI,
    functionName: 'approve',
    args: [gatewayAddress, amount],
  });
}

Permit(서명 Approve) 지원 토큰

USDC처럼 Permit(EIP-2612)를 지원하는 토큰은 approve 트랜잭션을 서명으로 대체할 수 있습니다. 공식 SoloPay 위젯(@solo-pay/widget-js 또는 @solo-pay/widget-react)을 사용하면 EIP-2612 지원을 자동 감지하여 1회 approve 트랜잭션을 건너뛰고, Permit을 서명만으로 완전 가스리스 처리합니다.

TIP

충분한 금액이 approve 되면 이후 모든 결제(Step 3)는 서명만으로 완전 가스리스가 가능합니다.

Step 3: EIP-712 서명 요청 ​

프론트엔드에서 사용자에게 서명을 요청합니다.

typescript
import { useSignTypedData } from 'wagmi';
import { encodeFunctionData } from 'viem';

const { signTypedDataAsync } = useSignTypedData();

// Forwarder에서 현재 nonce 조회
const nonce = await publicClient.readContract({
  address: forwarderAddress,
  abi: ERC2771ForwarderABI,
  functionName: 'nonces',
  args: [userAddress],
});

// Forward Request 구성 (PaymentGateway.pay — deadline은 API 응답에서 사용)
const forwardRequest = {
  from: userAddress,
  to: gatewayAddress,
  value: 0n,
  gas: 200000n,
  nonce,
  deadline: BigInt(Math.floor(Date.now() / 1000) + 3600), // 1시간
  data: encodeFunctionData({
    abi: PaymentGatewayABI,
    functionName: 'pay',
    args: [
      paymentId,
      tokenAddress,
      BigInt(amount),
      recipientAddress,
      merchantId,
      BigInt(deadline), // payment.deadline (API)
      permitData, // EIP-2612 permit, 또는 zero permit { deadline: 0, v: 0, r: '0x00...', s: '0x00...' }
    ],
  }),
};

// EIP-712 서명 — domain name/version은 릴레이 API가 사용하는 forwarder 컨트랙트와 일치해야 함 (예: SoloPay, SoloForwarder, ERC2771Forwarder)
const signature = await signTypedDataAsync({
  domain: {
    name: 'ERC2771Forwarder', // 배포된 forwarder와 일치해야 함; 릴레이 서버가 검증함
    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,
});

중요

서명은 트랜잭션이 아니므로 가스비가 부과되지 않습니다. pay 함수는 API 응답의 deadline이 필요합니다. EIP-2612를 사용하지 않을 때는 zero permit을 전달하세요.

Step 4: 가스리스 요청 제출 ​

엔드포인트: 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: 상태 확인 ​

typescript
// 릴레이 상태 (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'

// 결제 상태
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'

전체 예시 (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}>가스 없이 결제</button>;
}

에러 처리 ​

에러 코드원인해결 방법
INVALID_SIGNATURE잘못된 서명 형식서명이 0x로 시작하는 hex 문자열인지 확인
INVALID_PAYMENT_STATUS결제가 종료 상태(예: PAID, REFUNDED, EXPIRED 등)status가 CREATED일 때만 relay 전송; 중복 요청 방지
PAYMENT_EXPIRED결제 만료새 결제 생성 후 재시도
RELAYER_NOT_CONFIGURED해당 체인에 릴레이어 없음지원 체인 확인
VALIDATION_ERROR입력 검증 실패forwardRequest 금액이 결제 금액과 일치하는지 확인

다음 단계 ​

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