1편에서 30년간 잠들어 있던 HTTP 402가 깨어난 이야기를, 2편에서 그걸 떠받치는 허가형 블록체인 — Hyperledger Besu와 QBFT를 정리했습니다.
그런데 1편을 다시 읽어보면, 제가 슬쩍 미뤄둔 부분이 하나 있습니다. “AI 에이전트가 ETH 한 푼 없이 서명만 만들면 결제가 끝난다”고 적어놓고, 정작 그 서명이 어떤 모양인지, 누가 어떻게 검증하는지는 “3편에서 다루겠습니다” 하고 넘어갔거든요.. 솔직히 그때는 흐름만 따라갔지, 402 응답 한 줄 안에 정확히 뭐가 들어가는지까지는 몰랐습니다.
이번엔 그 흐름을 필드 하나, 함수 하나 단위로 다 뜯어봤습니다. 그리고 파고들면서 알게 된 건, x402의 “마법”이 사실 표준 하나가 아니라 네 개가 각자 한 조각씩 맡아서 맞물린 결과라는 점이었습니다.
먼저 그 네 조각만 정리하면 이렇습니다.
- x402 — HTTP 위에서 “얼마를, 어디로, 어떻게 내라”를 주고받는 메시지 규약
- EIP-712 — 그 결제 내용을 안전하게 서명하게 만드는 구조화 서명 형식
- EIP-3009 / Permit2 — ETH 없이 서명만으로 토큰을 움직이는 가스리스 전송
- Facilitator — 서명을 검증(
verify)하고 정산(settle)하되, 자금은 못 만지는 중개자
1편이 “x402가 왜 지금 살아났나”였다면, 이번 3편은 “그래서 코드 레벨에서 정확히 어떻게 도는가”입니다.
바로 본론으로 들어가겠습니다!!
402 응답 안에는 정확히 뭐가 들어있나
1편에서는 402 응답을 amount, currency, recipient 정도로 단순하게 보여줬습니다. 그런데 실제 x402의 402 응답에서 핵심은 accepts라는 배열입니다.
HTTP/1.1 402 Payment Required
Content-Type: application/json
{
"x402Version": 1,
"accepts": [
{
"scheme": "exact",
"network": "base",
"asset": "0x833589f...2913", // Base USDC 컨트랙트 주소
"maxAmountRequired": "5000",
"payTo": "0xabc...",
"resource": "/api/weather/seoul",
"maxTimeoutSeconds": 60
},
{
"scheme": "exact",
"network": "polygon",
"asset": "0xc2132D...8e8F", // Polygon USDT 컨트랙트 주소
"maxAmountRequired": "5000",
"payTo": "0xabc...",
"resource": "/api/weather/seoul",
"maxTimeoutSeconds": 60
}
]
}
accepts의 원소 하나가 “이 방법으로 내도 된다”는 하나의 결제 옵션입니다. 필드를 하나씩 보면:
scheme— 결제 “방식”.exact는 “정확히 이 금액을 내라”. 방식 자체를 필드로 빼둔 게 핵심인데, 나중에 “상한 안에서 알아서 내라” 같은 다른 scheme가 추가돼도 메시지 구조는 그대로 둘 수 있습니다.network— 어느 체인에서 (base, polygon, …)asset— 어떤 토큰인지 식별하는 토큰 컨트랙트 주소 (같은 USDC라도 체인마다 주소가 달라서, 심볼이 아니라 주소로 못박습니다. 위 예시의0x833589f...가 Base USDC입니다)maxAmountRequired— 얼마를 (토큰 최소 단위 정수로 표현. USDC는 6자리라5000= 0.005)payTo— 어디로maxTimeoutSeconds— 클라이언트가 결제를 완료하기까지 서버가 허용하는 최대 시간(초). 결제 권한 자체의 유효 구간은 서명 안의validAfter/validBefore가 따로 담당합니다
이게 왜 배열이냐가 1편보다 한 발 더 들어가는 지점입니다. 서버는 “받을 수 있는 모든 방법”을 한꺼번에 던지고, 클라이언트가 자기 지갑에 있는 토큰·네트워크에 맞는 옵션을 고릅니다.
| 구분 | 카드망 | x402 accepts |
|---|---|---|
| 결제 경로 | 단일 통화·단일 경로 | 다중 옵션 (체인·토큰별) |
| 선택 주체 | 가맹점이 받는 카드만 | 클라이언트가 골라서 결제 |
| 확장성 | 새 통화 = 새 계약 | 배열에 원소 추가 |
카드망이 “이 카드 되나요?”라면, x402는 “이 중에 네가 낼 수 있는 걸로 골라”입니다.
(여기 필드명은 개념 위주로 단순화했습니다. 실제 직렬화 키는 x402 V2 공식 사양을 따르는 게 정확합니다.)
X-PAYMENT 헤더는 어떻게 만들어지나
옵션을 골랐으면, 이제 결제 증거를 만들어서 헤더에 실어야 합니다. 1편에서는 X-Payment: 0x...(signed payment) 라고만 적었는데, 실제로는 단순한 서명 한 덩어리가 아닙니다. PaymentPayload라는 구조체를 통째로 base64 인코딩한 것입니다.
PaymentPayload (JSON)
{
"x402Version": 1,
"scheme": "exact",
"network": "base",
"payload": {
"signature": "0x...", // EIP-712로 서명한 값
"authorization": { // 서명한 '내용' 자체
"from": "0xMyWallet...",
"to": "0xAPIServer...",
"value": "5000",
"validAfter": "...",
"validBefore": "...",
"nonce": "0x9a8b..."
}
}
}
│ JSON 직렬화 → base64 인코딩
▼
X-PAYMENT: eyJ4NDAyVmVyc2lvbiI6MSwic2NoZW1lIjoi...
여기서 중요한 건, 헤더 안에 서명(signature)뿐 아니라 서명한 내용(authorization)이 통째로 같이 들어간다는 점입니다. 서버는 이 헤더를 디코딩하면 “누가, 어떤 옵션으로, 무엇에 서명했는지”를 그대로 복원할 수 있습니다.
그럼 그 안의 signature와 authorization이 정확히 뭔지가 다음 두 섹션의 주제입니다.
EIP-712 — 핵심은 가독성이 아니라 “도메인 분리”
1편에서 EIP-712를 “사람이 읽을 수 있는 서명”이라고 했는데, 직접 파보니 가독성만큼이나 중요한 또 하나의 축이 도메인 분리(domain separation) 였습니다. 이게 없으면 같은 서명이 엉뚱한 곳에서 재사용될 수 있거든요.
EIP-712 서명은 크게 두 조각을 해시해서 만듭니다.
- 도메인 구분자(domain separator) —
name,version,chainId,verifyingContract - 메시지 구조 해시(struct hash) — 결제 내용(
from,to,value, …)을 그 타입 정의와 함께 해시한 값 (keccak256(타입해시 ‖ 인코딩된 값))
최종 서명 대상은 이 둘을 이어붙인 다이제스트입니다.
digest = keccak256( 0x1901 ++ domainSeparator ++ structHash )
여기서 도메인 구분자에 chainId와 verifyingContract가 들어간다는 게 결정적입니다.
- Base의 USDC에 대한 서명은
chainId가 달라서 → Polygon에서 재사용 불가 - USDC 컨트랙트에 대한 서명은
verifyingContract가 달라서 → 다른 토큰 컨트랙트에서 재사용 불가
이게 결제에서 왜 중요하냐면 — AI 에이전트는 여러 체인·여러 토큰을 동시에 다루게 되거든요. 도메인 분리가 없으면 “Base USDC 0.005 결제”용 서명을 누가 가로채서 “Polygon USDC 0.005”로 재생할 수 있습니다.
서명 하나가 “특정 체인의, 특정 컨트랙트의, 특정 함수”에만 묶이는 것 — 이게 도메인 분리입니다.
EIP-3009 — transferWithAuthorization과 receiveWithAuthorization의 한 끗 차이
1편에서 EIP-3009의 transferWithAuthorization이 가스리스의 핵심이라고 했습니다. 서명만 만들고 가스는 남이 낸다는 거였죠. 그런데 EIP-3009를 실제로 들여다보면 함수가 두 개입니다.
// ① 누구나 제출 가능
function transferWithAuthorization(
address from, address to, uint256 value,
uint256 validAfter, uint256 validBefore, bytes32 nonce,
uint8 v, bytes32 r, bytes32 s
) external;
// ② 거의 동일 + 한 줄 더: require(to == msg.sender)
function receiveWithAuthorization(
address from, address to, uint256 value,
uint256 validAfter, uint256 validBefore, bytes32 nonce,
uint8 v, bytes32 r, bytes32 s
) external;
둘은 거의 똑같은데, receiveWithAuthorization에는 “호출자(msg.sender)가 수신자(to)와 같아야 한다”는 조건이 한 줄 더 있습니다. 이게 왜 있을까요?
transferWithAuthorization은 아무나 제출할 수 있다는 게 문제가 될 수 있습니다. 예를 들어 어떤 컨트랙트가 “입금받으면 그만큼 크레딧을 준다”는 로직을 가지고 있고, 그 안에서 transferWithAuthorization을 호출한다고 해봅시다. 공격자가 같은 서명으로 transferWithAuthorization을 먼저 직접 호출해버릴 수 있습니다. 그러면 토큰은 옮겨졌는데, 정작 컨트랙트가 부르려던 호출은 nonce 중복으로 revert — 돈은 빠져나갔는데 크레딧은 안 들어가는 상황이 생깁니다.
receiveWithAuthorization은 “수신자만 제출할 수 있게” 막아서 이 front-running을 차단합니다.
결제 흐름에서는 “서명을 만든 사람”과 “트랜잭션을 제출하는 사람”이 다릅니다(서명은 AI, 제출은 Facilitator). 그래서 어느 함수를 쓰느냐가 실제 보안에 영향을 줍니다. 표준 함수 이름 한 끗 차이지만, 직접 안 파보면 안 보이는 부분이었습니다..
그런데 대부분의 토큰은 EIP-3009를 지원하지 않습니다 — Permit2
여기서 현실을 하나 마주쳤습니다. EIP-3009는 우아한데, 토큰이 그 함수를 직접 구현해야 작동합니다. USDC는 구현해뒀지만, 세상의 대부분 ERC-20에는 transferWithAuthorization이 아예 없습니다.
그럼 EIP-3009가 없는 토큰은 가스리스 결제를 못 하나? 여기서 등장하는 게 Uniswap이 만든 Permit2입니다.
- 토큰별로 Permit2 컨트랙트에 딱 한 번
approve(이건 일반 트랜잭션, 가스 한 번 듦) - 그 다음부터는 Permit2에 대고 오프체인 서명만으로 토큰을 당겨올 수 있음
- 1회성 전송용
SignatureTransfer, 한도 부여용AllowanceTransfer두 모드 제공
핵심 차이는 적용 범위입니다. EIP-3009는 토큰이 직접 그 함수를 구현해야 하는 반면, Permit2는 어떤 ERC-20이든 쓸 수 있습니다 — 토큰마다 Permit2 컨트랙트에 한 번씩 approve해두면, 그 다음부터는 가스리스 서명으로 커버됩니다.
| 구분 | EIP-3009 | Permit2 |
|---|---|---|
| 지원 범위 | 토큰이 직접 구현한 경우만 (USDC 등) | 모든 ERC-20 |
| 사전 작업 | 없음 | Permit2에 1회 approve (가스 1번) |
| 이후 결제 | 서명만 (완전 가스리스) | 서명만 (가스리스) |
| 추가 신뢰 | 없음 | Permit2 컨트랙트 |
그래서 x402도 EIP-3009 경로와 Permit2 경로를 둘 다 둡니다. 1편에서 “EIP-3009로 결제 서명을 만든다”고 한 줄로 단순화했는데, 막상 파보니 토큰이 뭐냐에 따라 경로가 갈린다는 게 표면적으로 알 때와 직접 뜯어볼 때의 차이였습니다.
Facilitator의 두 얼굴 — /verify와 /settle
1편에서 Facilitator를 “비수탁 검증 중개자”라고 했습니다. 자금은 안 만지고 길목에만 선다는 거였죠. 그런데 Facilitator를 실제로 들여다보면 엔드포인트가 두 개라는 게 핵심입니다.
POST /verify— “이 서명, 진짜 유효해?”를 묻는다. 서명 검증, nonce 미사용 확인, 금액·만료 확인을 블록체인에 제출하지 않고 한다.POST /settle— “좋아, 이제 체인에 올려”를 시킨다. Facilitator가 실제로 transferWithAuthorization 트랜잭션을 브로드캐스트하고 가스를 낸다.
그런데 왜 굳이 둘로 나눴을까요?
verify는 오프체인이라 즉시·공짜입니다. 서버는 결제가 유효한지를 가스 한 푼 안 쓰고 1초 안에 확인할 수 있습니다. 그래서 서버는 verify로 먼저 확인하고 → 리소스를 줄지 결정하고 → settle로 정산합니다. 이 분리 덕분에 “검증은 공짜, 정산만 가스”가 됩니다. 초소액·초고빈도 결제에서 이게 의미가 큰데, 어차피 실패할 요청에까지 가스를 쓰지 않아도 되니까요.
그리고 1편에서 “왜 Facilitator를 안 믿어도 되나”를 짚었는데, verify/settle을 보면 더 명확해집니다. settle에서 Facilitator가 제출하는 건 결국 사용자가 서명한 그 authorization 그대로입니다. 금액·수신자·nonce가 서명 안에 박혀 있으니, Facilitator가 정산 단계에서 장난칠 여지가 없습니다.
“검증”과 “정산”을 쪼갠 게 단순한 API 설계가 아니라, 초소액 결제의 경제성을 만드는 구조였습니다.
전체를 다시 조립하기
이제 표준 네 개가 한 번의 결제에서 어떻게 맞물리는지 전체를 다시 그려보겠습니다. (마지막 ⑧~⑩ 체인 확정 부분이 2편에서 다룬 Besu·QBFT입니다.)
x402라는 표준 하나가 마법을 부린 게 아니라, 표준 네 개가 각자 한 조각씩 — 메시지 규약, 서명 안전성, 가스리스 전송, 검증·정산 분리 — 를 맡아서 맞물린 거였습니다.
1편에서 “깔끔하다”고만 느꼈던 흐름이, 사실 이렇게 여러 표준의 합주였다는 게 파보고 나서야 보였습니다..
3편을 마치며
정리하면 이번 글에서 얻은 통찰은 이렇습니다.
- x402는 결제를 “실행”하지 않는다 — 메시지 규약일 뿐 — 실제 자금 이동은 EIP 표준들이 한다. x402는 “HTTP 위에서 결제 협상을 어떻게 주고받을지”의 약속
- EIP-712의 핵심은 가독성이 아니라 도메인 분리 —
chainId·verifyingContract가 서명을 특정 체인·특정 컨트랙트에 묶어 크로스체인 재사용을 차단 transferWithAuthorization과receiveWithAuthorization는 “누가 제출하느냐”로 보안이 갈린다 — 함수 이름 한 끗 차이가 front-running 방어를 만든다- EIP-3009는 우아하지만 토큰이 직접 구현해야 한다 — Permit2가 그 빈틈을 메운다 — “EIP-3009로 서명한다”는 한 줄 뒤에 토큰별 경로 분기가 숨어 있었다
- Facilitator의
verify·settle분리가 초소액 결제의 경제성을 만든다 — 검증은 공짜, 정산만 가스. 실패할 요청에 가스를 안 쓰는 구조
특히 4번 — “EIP-3009로 서명한다”를 1편에서 한 줄로 적고 넘어갔는데, 막상 파보니 토큰이 그 함수를 안 가지고 있으면 Permit2로 우회해야 한다는 게.. 표준 이름만 알 때와 직접 뜯어볼 때의 차이를 가장 크게 느낀 부분이었습니다.
다음 4편은 좀 다른 이야기입니다. “ERC-4337(계정 추상화)을 가져다 쓰면 더 좋아지지 않을까” 하고 기대했다가, 허가형 체인에서는 그 표준의 절반이 의미가 사라지는 경험을 정리해보려 합니다. “표준이라고 다 좋은 게 아니다”라는, 직접 부딪혀봐야 보이는 이야기입니다!!
다음 글에서 이어가겠습니다!!
감사합니다!!