x402 결제 서버 랩
도입
x402는 HTTP 요청 흐름 안에서 결제 요구, 결제 payload 제출, 검증, settlement를 연결하는 모델이다. 클라이언트가 paid resource를 요청하면 서버는 HTTP 402로 결제 조건을 알려주고, 클라이언트는 결제 payload를 만들어 다시 요청한다. 검증과 settlement가 끝나면 서버는 원래 요청한 resource 접근을 허용한다.
이 랩은 실제 HTTP 서버 대신 Solidity mock server로 그 구조를 축소한다. X402PaymentServer는 paid resource를 등록하고, paymentRequirement로 402 응답에 들어갈 정보를 만들고, accessWithAuthorization에서 ERC-3009 authorization을 사용해 merchant에게 token을 보낸 뒤 receipt를 저장한다.
개념 강의인 x402 HTTP 스테이블코인 결제는 network, asset, recipient, amount를 어떤 요구사항으로 표현할지 다룬다. 이 랩은 그 요구사항을 code path, idempotency, receipt 저장으로 검증한다.
학습 목표
- HTTP 결제 요구와 settlement proof 저장 흐름을 구현한다.
- agent별 한도와 idempotency를 테스트한다.
개념 설명
x402 결제 서버 랩을 제품 설계에 넣어도 되는가?
랩 산출물이 캡스톤의 어떤 장과 테스트 증거로 재사용되는가
실패 로그가 남는가
운영 대시보드까지 닫히는가
대상 코드는 08-실습/mock-stablecoin-lab/src/X402PaymentServer.sol이고, 테스트는 test/X402PaymentServer.t.sol이다. 이 구현은 학습용이므로 실제 header encoding이나 facilitator API를 구현하지 않는다. 대신 x402에서 필요한 핵심 판단을 contract state로 드러낸다.
| x402 개념 | 랩의 대응 요소 | 확인할 질문 |
|---|---|---|
| paid resource | Resource와 resourceId | 어떤 endpoint가 얼마를 요구하는가 |
| 402 response | paymentRequirement 반환값 | payee, token, amount, network, scheme이 충분한가 |
| payment payload | ERC-3009 authorization | payer가 정확한 payee, amount, valid window에 서명했는가 |
| verification | token의 transferWithAuthorization | signature, recipient, amount, nonce가 맞는가 |
| settlement proof | Receipt와 ResourceAccessPaid event | 같은 payer와 nonce로 중복 접근을 막는가 |
공식 문서 기준으로 x402는 402 응답에서 결제 조건을 알리고, 클라이언트가 결제 payload를 포함해 재요청하며, 서버나 facilitator가 verify/settle을 처리한다. 이 랩의 SCHEME = "exact"는 고정 가격 endpoint를 가정한다. usage-based billing을 다룰 때는 최대 지불액과 실제 정산액을 분리하는 설계를 별도 문서로 남긴다.
코드로 확인하기
앞에서 만든 설계를 실습 코드로 연결한다. 예시는 그대로 외우는 대상이 아니라, 구현 파일에서 어떤 줄을 읽고 어떤 테스트를 붙일지 정하는 기준이다.
컨트랙트paymentRequirement — 402 응답 생성
클라이언트가 paid resource를 요청하면 서버가 반환할 페이로드를 컨트랙트가 그대로 만든다. HTTP status, payee, token, amount, network, scheme이 모두 한 곳에서 결정된다.
컨트랙트paymentRequirement 본문 파일:
08-실습/mock-stablecoin-lab/src/X402PaymentServer.sol(라인 76-98)402 응답 6개 필드가 어디서 오는지 본다.
function paymentRequirement(bytes32 resourceId) external view returns ( uint16 httpStatus, address payee, address token, uint256 amount, string memory network, string memory scheme ) { Resource storage resource = _resource(resourceId); return ( PAYMENT_REQUIRED, resource.payee, resource.token, resource.amount, resource.network, SCHEME ); }컨트랙트accessWithAuthorization — 결제 검증 + receipt 저장
ERC-3009 서명을 token에게 위임 검증하고, 성공하면
(resourceId, payer, nonce)로 receipt를 박는다. 같은 키로 두 번째 호출은ReceiptAlreadyExists로 차단된다.
컨트랙트accessWithAuthorization 본문 파일:
08-실습/mock-stablecoin-lab/src/X402PaymentServer.sol(라인 100-124)결제 정산과 receipt 인덱스의 동시 트랜잭션 처리.
function accessWithAuthorization( bytes32 resourceId, address payer, uint256 validAfter, uint256 validBefore, bytes32 nonce, uint8 v, bytes32 r, bytes32 s ) external { Resource storage resource = _resource(resourceId); if (payer == address(0)) revert InvalidAddress(); if (receipts[resourceId][payer][nonce].exists) { revert ReceiptAlreadyExists(resourceId, payer, nonce); } IX402AuthorizationToken(resource.token).transferWithAuthorization( payer, resource.payee, resource.amount, validAfter, validBefore, nonce, v, r, s ); receipts[resourceId][payer][nonce] = Receipt({ payer: payer, nonce: nonce, paidAt: block.timestamp, exists: true }); emit ResourceAccessPaid(resourceId, payer, nonce); }백엔드HTTP 402 응답 헬퍼 (Next.js / Node)
컨트랙트가 반환한 6개 필드를 그대로 표준 x402 응답 JSON으로 직렬화하는 백엔드 라우트.
import { NextResponse } from "next/server";export async function GET(_req: Request, ctx: { params: { resourceId: string } }) { const [httpStatus, payee, token, amount, network, scheme] = await x402Server.paymentRequirement(ctx.params.resourceId); if (httpStatus !== 402) { return NextResponse.json({ error: "resource not found" }, { status: 404 }); } return NextResponse.json( { x402Version: 1, accepts: [ { scheme, network, maxAmountRequired: amount.toString(), asset: token, payTo: payee, resource: ctx.params.resourceId } ] }, { status: 402, headers: { "Accept-Payment": "x402/1" } } );}클라이언트agent 결제 한도 정책
agent가 정책 외 결제를 자동 실행하지 않도록 spending limit·allowlist를 감싸는 클라이언트 측 가드.
type AgentPolicy = { endpointAllowlist: RegExp[]; perRequestCapUsd: number; dailyCapUsd: number; requireManualApprovalAboveUsd: number;};export function shouldAutoPay( policy: AgentPolicy, request: { url: string; amountUsd: number; todaySpentUsd: number }) { if (!policy.endpointAllowlist.some((re) => re.test(request.url))) return "blocked:allowlist"; if (request.amountUsd > policy.perRequestCapUsd) return "blocked:per-request"; if (request.todaySpentUsd + request.amountUsd > policy.dailyCapUsd) return "blocked:daily"; if (request.amountUsd > policy.requireManualApprovalAboveUsd) return "needs-approval"; return "auto-pay";}강의 포인트
| 관점 | 확인할 질문 | 증거로 남길 것 |
|---|---|---|
| requirement | resource별 token, amount, network, scheme이 명확한가 | payment requirement 예시 |
| authorization | payer가 merchant와 금액에 정확히 서명했는가 | signed payload 필드 표 |
| idempotency | 같은 resource, payer, nonce가 재사용될 때 서버 경계에서 막히는가 | receipt 재사용 실패 로그 |
| agent 한도 | agent가 하루 또는 요청당 얼마까지 결제할 수 있는가 | spending limit 정책 |
| settlement proof | 결제 완료 후 어떤 receipt를 사용자와 운영자가 조회하는가 | receipt schema |
실무 예시
백엔드[CLIENT] agent가 유료 API endpoint를 호출한다고 가정한다. 첫 요청에는 결제가 없기 때문에 서버가 payment requirement를 반환한다. agent는 지갑으로 authorization을 만들고 재요청한다. 서버는 authorization을 token contract로 실행해 merchant에게 지불하고 receipt를 저장한다.
| 실패 상황 | 사용자 또는 agent 안내 | 운영 증거 |
|---|---|---|
| missing resource | 이 endpoint는 결제 요구사항을 찾을 수 없다 | ResourceNotFound(resourceId) |
| wrong recipient | 서명된 payee가 등록된 merchant와 다르다 | InvalidSignature |
| wrong amount | 서명 금액이 resource price와 맞지 않다 | InvalidSignature 또는 amount mismatch |
| expired authorization | 결제 payload가 만료됐다 | AuthorizationExpired(validBefore) |
| reused nonce | 이미 처리된 결제 payload다 | ReceiptAlreadyExists(resourceId, payer, nonce) |
agent 결제에서는 "지불할 수 있다"와 "지불해도 된다"를 분리해야 한다. 지갑이 결제 가능하더라도 policy layer가 endpoint allowlist, per-request cap, daily cap, retry cap, manual approval threshold를 확인해야 한다.
흔한 오해와 실패 시나리오
| 오해 | 실제로 확인할 것 |
|---|---|
| HTTP 402만 반환하면 결제 서버가 완성됐다고 본다. | requirement, payment payload, verify, settle, receipt가 연결되어야 한다. |
| receipt 저장을 optional로 본다. | receipt가 없으면 재시도, 중복 청구, 고객 지원, agent audit을 설명할 수 없다. |
| agent는 사람이 아니므로 spending limit이 덜 중요하다고 본다. | agent는 반복 호출을 빠르게 만들 수 있으므로 한도와 idempotency가 더 중요하다. |
exact scheme과 usage-based billing을 같은 정책으로 처리한다. | 고정 가격과 최대 승인액 기반 정산은 다른 사용자 안내와 settlement 증거가 필요하다. |
| wrong recipient와 wrong amount를 단순 signature 실패로만 본다. | 운영 로그에는 어떤 requirement 필드가 payload와 달랐는지 남겨야 한다. |
실습 과제
- 백엔드[CONTRACT] x402 server flow 구현:
createResource,paymentRequirement,accessWithAuthorization,hasReceipt를 request-response 흐름으로 설명한다. - 클라이언트Agent 한도 설계: endpoint allowlist, per-request cap, daily cap, retry cap, manual approval threshold를 표로 작성한다.
- 백엔드idempotency 정책 작성:
resourceId + payer + nonce외에 request body hash나 payment identifier가 필요한 상황을 정리한다. - 인덱서[OPS] settlement proof 설계: receipt에 payer, resourceId, nonce, amount, token, network, paidAt, settlement tx를 어떻게 남길지 capstone 형식으로 쓴다.
완료 기준
- payment required response가 설계됐다.
- settlement proof를 저장한다.
- agent 한도 테스트가 있다.
근거 자료
- x402 결제 서버 랩: 08-실습/06-x402-결제-서버-랩.md
- x402 How It Works: https://docs.cdp.coinbase.com/x402/core-concepts/how-it-works
- x402 Quickstart for Sellers: https://docs.cdp.coinbase.com/x402/quickstart-for-sellers
- x402 FAQ: https://docs.cdp.coinbase.com/x402/support/faq