가스리스 구현
가스리스 결제를 구현하는 상세 가이드입니다.
위젯 사용 권장
가스리스 결제를 사용하는 가장 쉬운 방법은 @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를 호출합니다. 브라우저에서 직접 호출할 수 있습니다.
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회 지원을 요청할 수 있습니다.
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 레퍼런스를 참고하세요.
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 서명 요청
프론트엔드에서 사용자에게 서명을 요청합니다.
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
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: 상태 확인
// 릴레이 상태 (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)
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 금액이 결제 금액과 일치하는지 확인 |
다음 단계
- Webhook 설정 - 결제 완료 알림 수신
- 에러 코드 - 전체 에러 목록