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.isFaucetEnabledtrue이면, 승인에 필요한 네이티브 토큰이 부족한 지갑이 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.