Permit, ERC-3009, 서명 결제
도입
서명 결제의 목적은 "사용자가 버튼을 누를 때마다 직접 gas를 준비하고 transaction을 보내야 하는가"라는 문제를 줄이는 것이다. 하지만 서명은 결제를 마법처럼 안전하게 만들지 않는다. 어떤 메시지에 서명했는지, 누가 제출할 수 있는지, nonce가 어떻게 소모되는지, 제출 지연이나 replay가 어떻게 막히는지를 설계해야 한다.
permit과 ERC-3009는 같은 계열의 "서명 기반 UX 개선"처럼 보이지만 제품 의미가 다르다. permit은 allowance를 만드는 서명이다. 즉 spender가 나중에 transferFrom을 실행할 수 있도록 승인한다. ERC-3009는 특정 transfer 자체를 서명으로 승인한다. 즉 amount, recipient, 유효기간, nonce가 결제 요청에 더 직접적으로 묶인다.
이 차이를 모르면 checkout 상태머신이 틀어진다. permit이 성공해도 결제가 완료된 것이 아니고, ERC-3009 authorization이 만들어져도 relayer가 제출하기 전에는 토큰이 이동하지 않는다. 강의의 목표는 두 방식을 암기하는 것이 아니라, 실제 제품에서 어떤 상태와 실패 메시지를 만들어야 하는지 판단하는 것이다.
학습 목표
- permit과 transferWithAuthorization의 차이를 설명한다.
- 서명 결제의 replay, domain, nonce 위험을 점검한다.
- checkout 요구사항에 맞춰 permit, ERC-3009, smart account/session key 중 하나를 선택한다.
이 강의는 checkout 제품에서 어떤 서명 결제 모델을 고를지 판단하는 쪽에 초점을 둔다. EIP-712 domain, malleability, EIP-1271 같은 공격면 리뷰는 ECDSA, EIP-712, 서명 검증 리뷰에서 더 깊게 다룬다.
개념 설명
Permit, ERC-3009, 서명 결제를 제품 설계에 넣어도 되는가?
발행/상환과 결제 상태가 분리되는가
이벤트와 내부 원장이 대조되는가
페그·유동성 위험이 표시되는가
1. 한 줄 차이를 제품 상태로 번역한다
두 모델의 차이는 "서명으로 무엇이 바뀌는가"에서 시작한다.
| 구분 | ERC-2612 Permit | ERC-3009 Transfer With Authorization |
|---|---|---|
| 서명이 승인하는 것 | spender에게 allowance를 부여 | 특정 from -> to -> value transfer |
| 성공 후 바로 잔고가 이동하는가? | 아니다. 별도 transferFrom 필요 | 그렇다. 제출 성공 시 balance가 이동 |
| checkout 상태 | AllowanceAuthorized 후 TransferPending 필요 | AuthorizationSubmitted 후 Paid 가능 |
| nonce 모델 | owner별 순차 nonce | authorizer별 random bytes32 nonce |
| 주된 위험 | allowance 잔존, spender 권한, transferFrom 실패 | 제출 지연, authorization 재사용, 기간 설정 |
| 잘 맞는 제품 | vault deposit, spender contract 기반 흐름 | invoice, checkout, API request payment |
이 표에서 핵심은 permit이 결제가 아니라는 점이다. permit은 approve를 transaction 대신 signature로 만든 것이다. 실제 결제는 spender가 transferFrom을 실행해야 끝난다. 따라서 제품 상태도 permitSigned, permitSubmitted, allowanceSet, transferSubmitted, paid처럼 분리해야 한다.
2. Permit 흐름은 두 단계 결제다
Permit 기반 checkout의 실패 상태는 최소 두 개다. 첫째, permit 제출이 실패해서 allowance가 생기지 않는 상태다. 둘째, allowance는 생겼지만 transferFrom이 실패하는 상태다. 예를 들어 사용자의 잔고가 줄었거나, 계정이 freeze되었거나, spender가 잘못됐거나, deadline이 지났을 수 있다.
3. ERC-3009는 authorization과 transfer가 더 가깝다
ERC-3009에서는 사용자가 특정 transfer authorization에 서명한다. payload에는 from, to, value, validAfter, validBefore, nonce가 들어간다. Relayer가 transferWithAuthorization 또는 receiveWithAuthorization을 제출하면 토큰 컨트랙트는 signature와 authorization state를 확인하고, nonce를 used 처리한 뒤 balance를 이동한다.
receiveWithAuthorization은 recipient가 caller일 것을 요구하는 패턴이라 결제 수신자를 더 강하게 묶을 수 있다. Merchant checkout에서는 "누가 이 authorization을 제출해도 결과가 같은가"와 "수신자가 바뀔 수 없는가"를 분리해 봐야 한다.
| 단계 | ERC-3009 checkout에서 남길 상태 |
|---|---|
| authorization 생성 | amount, recipient, validAfter, validBefore, nonce 확정 |
| 사용자 서명 | EIP-712 domain과 message 검수 |
| relayer 제출 | 제출 tx hash, 제출자, gas payer 저장 |
| token 검증 | signature, 기간, nonce used 여부 확인 |
| balance 이동 | Transfer event와 invoice 매칭 |
| 중복 제출 | used nonce로 거절되어야 함 |
4. 어떤 방식을 선택할지 요구사항으로 판단한다
| 상황 | 우선 검토할 방식 | 이유 |
|---|---|---|
| vault deposit | Permit | 사용자가 spender contract에 allowance를 주고 contract가 deposit 처리 |
| 단발 merchant checkout | ERC-3009 또는 Permit + transferFrom | amount와 recipient를 invoice에 묶는 것이 중요 |
| API 호출당 결제 | ERC-3009 또는 x402 계열 | 요청 단위 authorization과 만료 시간이 중요 |
| subscription | smart account/session key | 반복 결제, 한도, 기간, revoke 정책이 필요 |
| 여러 token을 같은 spender에게 허용 | Permit2 또는 account abstraction 검토 | spender 권한과 token allowlist를 별도 관리 |
| 사용자가 native gas token이 없음 | relayer 또는 Paymaster | 서명 제출 비용을 누가 내는지 결정해야 함 |
선택 기준은 "트랜잭션 수를 줄인다" 하나가 아니다. 결제 단위가 invoice인지, 사용자 잔고가 이동하는 시점이 언제인지, relayer가 실패하면 어떤 상태를 보여줄지, allowance가 남아도 되는지까지 함께 판단해야 한다.
5. replay, domain, nonce를 체크리스트로 만든다
EIP-712 typed data는 사람이 읽을 수 있는 구조로 서명 내용을 보여주기 위한 기반이지만, UI가 부실하면 사용자는 여전히 무엇에 서명하는지 모른다.
| 검수 항목 | 확인할 이유 |
|---|---|
chainId | 다른 체인에서 같은 서명이 재사용되는 것을 막는다 |
verifyingContract | 다른 token contract 또는 proxy로 replay되는 것을 막는다 |
owner/from | 실제 자금을 내는 주소를 확인한다 |
spender/to | allowance를 받을 contract 또는 결제 수신자를 확인한다 |
value | decimals 변환 오류를 막는다 |
nonce | 한 번 쓴 서명이 재사용되지 않아야 한다 |
deadline / validBefore | 오래된 결제 요청이 나중에 제출되는 것을 막는다 |
validAfter | 너무 이른 제출 또는 예약 결제 조건을 표현한다 |
- EIP-712 domain separator에 chain ID와 verifying contract가 들어가는지 확인한다.
deadline또는validBefore를 제품 SLA에 맞게 짧게 둔다.owner != address(0)또는from != address(0)검사를 한다.- frozen 계정이
permit으로 allowance를 만들 수 없는지 정책을 정한다. - ERC-3009 nonce가 재사용 불가능한지 테스트한다.
receiveWithAuthorization이 caller와 recipient를 묶는 이유를 이해한다.
코드로 확인하기
위 설계 결정을 코드에서 확인한다. 상태, 서명, 원장 이동, 실패 처리가 어디에 놓이는지 따라 읽으면 앞의 모델이 실제 구현 경계로 내려온다.
컨트랙트ERC-2612 Permit typehash 와 nonce 모델
permit은 owner별 순차 nonce를 token contract가 직접 관리한다. 각 서명을 쓸 때마다 1씩 증가하므로 replay 자체는 자동으로 막히지만, 다른 contract/chain 으로의 replay는 domain separator가 막아야 한다.
bytes32 public constant PERMIT_TYPEHASH = keccak256( "Permit(address owner,address spender,uint256 value,uint256 nonce,uint256 deadline)");mapping(address => uint256) public nonces;function permit( address owner, address spender, uint256 value, uint256 deadline, uint8 v, bytes32 r, bytes32 s) external { if (block.timestamp > deadline) revert ExpiredSignature(deadline); bytes32 structHash = keccak256( abi.encode(PERMIT_TYPEHASH, owner, spender, value, nonces[owner]++, deadline) ); bytes32 digest = _hashTypedDataV4(structHash); address recovered = ECDSA.recover(digest, v, r, s); if (recovered != owner || recovered == address(0)) revert InvalidSignature(); _approve(owner, spender, value);}컨트랙트ERC-3009 transferWithAuthorization — random bytes32 nonce
ERC-3009은
authorizationState[from][nonce]boolean으로 사용 여부를 추적한다. 사용자가 random nonce를 생성해 서명하므로 ordering이 없다 — 동시에 여러 서명을 만들고 임의 순서로 제출 가능.
bytes32 public constant TRANSFER_WITH_AUTHORIZATION_TYPEHASH = keccak256( "TransferWithAuthorization(address from,address to,uint256 value,uint256 validAfter,uint256 validBefore,bytes32 nonce)");mapping(address authorizer => mapping(bytes32 nonce => bool used)) public authorizationState;function transferWithAuthorization( address from, address to, uint256 value, uint256 validAfter, uint256 validBefore, bytes32 nonce, uint8 v, bytes32 r, bytes32 s) external { if (block.timestamp <= validAfter) revert AuthorizationNotYetValid(validAfter); if (block.timestamp >= validBefore) revert AuthorizationExpired(validBefore); if (authorizationState[from][nonce]) revert AuthorizationAlreadyUsed(from, nonce); bytes32 structHash = keccak256( abi.encode(TRANSFER_WITH_AUTHORIZATION_TYPEHASH, from, to, value, validAfter, validBefore, nonce) ); bytes32 digest = _hashTypedDataV4(structHash); if (ECDSA.recover(digest, v, r, s) != from) revert InvalidSignature(); authorizationState[from][nonce] = true; _transfer(from, to, value);}클라이언트두 모델의 typed data 구성 비교
permit은 spender 중심, ERC-3009은 to 중심. 사용자 지갑 다이얼로그가 두 모델의 차이를 어떻게 표시하는지 확인해야 phishing 방어가 된다.
// Permit — allowance 위임const permitMessage = { owner: payer, spender: checkoutAddress, // ← spender 가 자금을 가져갈 권한 value: 25_000_000n, // 6 decimals (USDC) nonce: await token.read.nonces([payer]), deadline: BigInt(Math.floor(Date.now() / 1000) + 600)};// ERC-3009 — 특정 transfer 승인const authMessage = { from: payer, to: merchantAddress, // ← to 가 결제 수신자로 typed data 에 직접 박힌다 value: 25_000_000n, validAfter: 0n, validBefore: BigInt(Math.floor(Date.now() / 1000) + 600), nonce: crypto.getRandomValues(new Uint8Array(32))};백엔드permit 후 transferFrom 실패 상태를 분리한 결제 상태머신
permit 성공 ≠ 결제 성공. allowance가 생기고 transferFrom이 실패한 경우를 별도 상태로 두면 UX 와 운영 디버깅이 모두 명확해진다.
type CheckoutStatus = | "Created" | "PermitSubmitted" | "AllowanceSet" | "TransferSubmitted" | "TransferFailed" // ← allowance 는 남아 있지만 실제 결제 실패 | "Paid";export async function executePermitCheckout(invoice: Invoice, sig: Signature) { await db.checkout.update({ where: { id: invoice.id }, data: { status: "PermitSubmitted" } }); try { await contract.write.permit([invoice.payer, CHECKOUT, invoice.amount, sig.deadline, sig.v, sig.r, sig.s]); await db.checkout.update({ where: { id: invoice.id }, data: { status: "AllowanceSet" } }); } catch (err) { await db.checkout.update({ where: { id: invoice.id }, data: { status: "Created", lastError: serialize(err) } }); throw err; } try { await contract.write.transferFrom([invoice.payer, invoice.merchant, invoice.amount]); await db.checkout.update({ where: { id: invoice.id }, data: { status: "Paid" } }); } catch (err) { // 중요: allowance 는 남았으므로 운영자가 manual transferFrom 또는 refund 결정 가능 await db.checkout.update({ where: { id: invoice.id }, data: { status: "TransferFailed", lastError: serialize(err) } }); throw err; }}강의 포인트
| 관점 | 강의 중 확인할 질문 | 학습 후 남길 증거 |
|---|---|---|
| 모델 차이 | 서명이 allowance를 만드는가, transfer를 승인하는가? | permit과 ERC-3009의 상태 변화 비교표 |
| 제품 상태 | 결제 완료 전 중간 상태를 어떻게 표현하는가? | PermitSubmitted, AllowanceSet, TransferFailed 같은 상태 목록 |
| replay 방지 | domain, chain ID, verifying contract, nonce, deadline이 충분한가? | replay 방지 조건 4개 이상 |
| UI 검수 | 사용자가 무엇에 서명하는지 확인할 수 있는가? | 서명 요청 화면 체크리스트 |
| 방식 선택 | checkout 요구사항에 맞는 모델을 골랐는가? | 선택 사유와 포기한 방식의 리스크 |
실무 예시
백엔드[CLIENT] merchant checkout에서 25 USDC를 받는다고 가정한다. 사용자는 native gas token이 없고, 앱은 relayer를 운영한다.
| 설계안 | 장점 | 실패 상태 |
|---|---|---|
| Permit + transferFrom | spender contract가 여러 결제를 처리하기 쉽다 | allowance는 생겼지만 transferFrom 실패, allowance 잔존 |
| ERC-3009 | amount와 recipient가 결제 요청에 직접 묶인다 | relayer 제출 지연, authorization 만료, nonce 중복 |
| Smart account/session key | 반복 결제와 한도 정책을 표현하기 좋다 | session key 탈취, policy 누락, paymaster 장애 |
단발 checkout이라면 ERC-3009가 더 직접적일 수 있다. 하지만 사용 중인 stablecoin이 ERC-3009를 지원하지 않거나, 기존 spender contract가 이미 audit되어 있다면 Permit + transferFrom을 선택할 수 있다. 선택의 핵심은 "서명 후 어떤 상태를 저장하고 어떤 실패를 사용자에게 보여줄 것인가"다.
흔한 오해와 실패 시나리오
| 오해 | 실패 시나리오 | 바로잡는 방법 |
|---|---|---|
permit이 성공하면 결제가 끝났다고 본다 | allowance는 생겼지만 transferFrom이 실패해 주문 상태가 꼬인다 | allowance와 transfer 상태를 분리한다 |
| nonce만 있으면 replay가 완전히 막힌다고 본다 | 다른 chain이나 다른 verifying contract에서 재사용 위험을 놓친다 | EIP-712 domain에 chain ID와 verifying contract를 포함해 검수한다 |
| deadline을 길게 두면 UX가 좋아진다고 본다 | 오래된 결제 서명이 나중에 제출되어 support 이슈가 된다 | 제품 SLA에 맞는 짧은 유효기간과 만료 상태를 둔다 |
| 사용자가 서명 화면을 읽지 않아도 된다고 본다 | spender/recipient가 잘못된 서명을 승인할 수 있다 | amount, recipient, spender, chain, token을 UI에서 명확히 표시한다 |
| relayer 실패를 단순 네트워크 오류로 본다 | 결제 요청은 유효하지만 제출되지 않은 상태가 누락된다 | relayer 제출 상태와 retry 정책을 내부 원장에 저장한다 |
실습 과제
- 컨트랙트서명 모델 차이 설명하기: permit은 allowance를 만들고 ERC-3009는 특정 transfer를 승인한다는 차이를 transaction 수, 상태 변화, 실패 지점 기준으로 설명한다.
- 클라이언트서명 요청 UI 검수표 만들기: EIP-712 domain, chain ID, verifying contract, spender/recipient, amount, deadline/validBefore, nonce가 사용자에게 어떻게 보이거나 검증되는지 체크리스트로 작성한다.
- 백엔드checkout 방식 선택하기: merchant checkout, vault deposit, API 호출당 결제 중 하나를 고르고 permit, ERC-3009, smart account/session key 중 어떤 방식을 쓸지 선택 사유와 실패 상태를 작성한다.
완료 기준
- 두 서명 결제 모델의 트랜잭션 수 차이를 설명한다.
- replay 방지 조건을 4개 이상 적었다.
- 서명 요청 UI 검수 항목을 만들었다.
- permit 성공 후 transferFrom 실패 상태를 제품 상태로 표현했다.
근거 자료
- Permit ERC3009 서명결제: 01-스테이블코인/03-Permit-ERC3009-서명결제.md
- EIP-2612: Permit Extension for ERC-20: https://eips.ethereum.org/EIPS/eip-2612
- EIP-3009: Transfer With Authorization: https://eips.ethereum.org/EIPS/eip-3009
- EIP-712: Typed Structured Data Hashing and Signing: https://eips.ethereum.org/EIPS/eip-712