SettleLab
전체 코스
LESSON 03Stablecoin Systems

Permit, ERC-3009, 서명 결제

핵심45분근거 4

학습 결과

  • permit과 transferWithAuthorization의 차이를 설명한다.
  • 서명 결제의 replay, domain, nonce 위험을 점검한다.
  • checkout 요구사항에 맞춰 permit, ERC-3009, smart account/session key 중 하나를 선택한다.

선행 조건

  • 발행, 소각, 준비자산, 상환

완료 기준

  • 두 서명 결제 모델의 트랜잭션 수 차이를 설명한다.
  • replay 방지 조건을 4개 이상 적었다.
  • 서명 요청 UI 검수 항목을 만들었다.

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, 서명 결제 판단 트리이 시각화는 stablecoin checkout과 정산 시스템에서 진행, 보류, 재설계 결정을 가르는 질문이 무엇인지를 보여주며, 'Permit, ERC-3009, 서명 결제'에서 남겨야 할 설계 증거를 좁힌다.
시작 질문

Permit, ERC-3009, 서명 결제를 제품 설계에 넣어도 되는가?

진행

발행/상환과 결제 상태가 분리되는가

서명 모델 차이 설명하기
보류

이벤트와 내부 원장이 대조되는가

가정과 실패 상태를 보강한다.
차단

페그·유동성 위험이 표시되는가

캡스톤 risk register에 차단 사유를 남긴다.
크게 보기
시작 질문

Permit, ERC-3009, 서명 결제를 제품 설계에 넣어도 되는가?

진행

발행/상환과 결제 상태가 분리되는가

서명 모델 차이 설명하기
보류

이벤트와 내부 원장이 대조되는가

가정과 실패 상태를 보강한다.
차단

페그·유동성 위험이 표시되는가

캡스톤 risk register에 차단 사유를 남긴다.

1. 한 줄 차이를 제품 상태로 번역한다

두 모델의 차이는 "서명으로 무엇이 바뀌는가"에서 시작한다.

표 자료가로 스크롤 · 크게 보기 지원
구분ERC-2612 PermitERC-3009 Transfer With Authorization
서명이 승인하는 것spender에게 allowance를 부여특정 from -> to -> value transfer
성공 후 바로 잔고가 이동하는가?아니다. 별도 transferFrom 필요그렇다. 제출 성공 시 balance가 이동
checkout 상태AllowanceAuthorizedTransferPending 필요AuthorizationSubmittedPaid 가능
nonce 모델owner별 순차 nonceauthorizer별 random bytes32 nonce
주된 위험allowance 잔존, spender 권한, transferFrom 실패제출 지연, authorization 재사용, 기간 설정
잘 맞는 제품vault deposit, spender contract 기반 흐름invoice, checkout, API request payment
크게 보기
구분ERC-2612 PermitERC-3009 Transfer With Authorization
서명이 승인하는 것spender에게 allowance를 부여특정 from -> to -> value transfer
성공 후 바로 잔고가 이동하는가?아니다. 별도 transferFrom 필요그렇다. 제출 성공 시 balance가 이동
checkout 상태AllowanceAuthorizedTransferPending 필요AuthorizationSubmittedPaid 가능
nonce 모델owner별 순차 nonceauthorizer별 random bytes32 nonce
주된 위험allowance 잔존, spender 권한, transferFrom 실패제출 지연, authorization 재사용, 기간 설정
잘 맞는 제품vault deposit, spender contract 기반 흐름invoice, checkout, API request payment

이 표에서 핵심은 permit이 결제가 아니라는 점이다. permitapprove를 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로 거절되어야 함
크게 보기
단계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 depositPermit사용자가 spender contract에 allowance를 주고 contract가 deposit 처리
단발 merchant checkoutERC-3009 또는 Permit + transferFromamount와 recipient를 invoice에 묶는 것이 중요
API 호출당 결제ERC-3009 또는 x402 계열요청 단위 authorization과 만료 시간이 중요
subscriptionsmart account/session key반복 결제, 한도, 기간, revoke 정책이 필요
여러 token을 같은 spender에게 허용Permit2 또는 account abstraction 검토spender 권한과 token allowlist를 별도 관리
사용자가 native gas token이 없음relayer 또는 Paymaster서명 제출 비용을 누가 내는지 결정해야 함
크게 보기
상황우선 검토할 방식이유
vault depositPermit사용자가 spender contract에 allowance를 주고 contract가 deposit 처리
단발 merchant checkoutERC-3009 또는 Permit + transferFromamount와 recipient를 invoice에 묶는 것이 중요
API 호출당 결제ERC-3009 또는 x402 계열요청 단위 authorization과 만료 시간이 중요
subscriptionsmart 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/toallowance를 받을 contract 또는 결제 수신자를 확인한다
valuedecimals 변환 오류를 막는다
nonce한 번 쓴 서명이 재사용되지 않아야 한다
deadline / validBefore오래된 결제 요청이 나중에 제출되는 것을 막는다
validAfter너무 이른 제출 또는 예약 결제 조건을 표현한다
크게 보기
검수 항목확인할 이유
chainId다른 체인에서 같은 서명이 재사용되는 것을 막는다
verifyingContract다른 token contract 또는 proxy로 replay되는 것을 막는다
owner/from실제 자금을 내는 주소를 확인한다
spender/toallowance를 받을 contract 또는 결제 수신자를 확인한다
valuedecimals 변환 오류를 막는다
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가 막아야 한다.

CODE SURFACEsolidity
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이 없다 — 동시에 여러 서명을 만들고 임의 순서로 제출 가능.

CODE SURFACEsolidity
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 방어가 된다.

CODE SURFACEtypescript
// 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 와 운영 디버깅이 모두 명확해진다.

CODE SURFACEtypescript
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 요구사항에 맞는 모델을 골랐는가?선택 사유와 포기한 방식의 리스크
크게 보기
관점강의 중 확인할 질문학습 후 남길 증거
모델 차이서명이 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 + transferFromspender contract가 여러 결제를 처리하기 쉽다allowance는 생겼지만 transferFrom 실패, allowance 잔존
ERC-3009amount와 recipient가 결제 요청에 직접 묶인다relayer 제출 지연, authorization 만료, nonce 중복
Smart account/session key반복 결제와 한도 정책을 표현하기 좋다session key 탈취, policy 누락, paymaster 장애
크게 보기
설계안장점실패 상태
Permit + transferFromspender contract가 여러 결제를 처리하기 쉽다allowance는 생겼지만 transferFrom 실패, allowance 잔존
ERC-3009amount와 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는 생겼지만 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 정책을 내부 원장에 저장한다

실습 과제

  1. 컨트랙트서명 모델 차이 설명하기: permit은 allowance를 만들고 ERC-3009는 특정 transfer를 승인한다는 차이를 transaction 수, 상태 변화, 실패 지점 기준으로 설명한다.
  2. 클라이언트서명 요청 UI 검수표 만들기: EIP-712 domain, chain ID, verifying contract, spender/recipient, amount, deadline/validBefore, nonce가 사용자에게 어떻게 보이거나 검증되는지 체크리스트로 작성한다.
  3. 백엔드checkout 방식 선택하기: merchant checkout, vault deposit, API 호출당 결제 중 하나를 고르고 permit, ERC-3009, smart account/session key 중 어떤 방식을 쓸지 선택 사유와 실패 상태를 작성한다.

완료 기준

  1. 두 서명 결제 모델의 트랜잭션 수 차이를 설명한다.
  2. replay 방지 조건을 4개 이상 적었다.
  3. 서명 요청 UI 검수 항목을 만들었다.
  4. permit 성공 후 transferFrom 실패 상태를 제품 상태로 표현했다.

근거 자료

Final checkpoint

읽기를 마쳤다면 여기서 기록한다

아래 버튼은 읽기 진도를 저장한다. 체크리스트, 과제, 랩 산출물은 위 Workbook에서 따로 관리한다.

  • 두 서명 결제 모델의 트랜잭션 수 차이를 설명한다.
  • replay 방지 조건을 4개 이상 적었다.
  • 서명 요청 UI 검수 항목을 만들었다.

학습 자료 근거

Permit ERC3009 서명결제
이 LMS 레슨의 개념, 예시, 과제 구성을 잡는 데 사용한 근거 문서.
내부 참고 문서
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