SettleLab
전체 코스
LESSON 03Labs and Capstone

Permit Checkout 랩

핵심1시간근거 3

학습 결과

  • permit 기반 checkout flow를 구현한다.
  • 서명 만료, nonce, domain을 테스트한다.

선행 조건

  • Permit, ERC-3009, 서명 결제
  • Mock 스테이블코인 랩

완료 기준

  • permit happy path가 동작한다.
  • negative test 5개가 있다.
  • 결제 상태와 서명 상태를 분리했다.

Permit Checkout 랩

도입

Permit checkout은 사용자가 별도의 approve transaction을 보내지 않고 결제를 시작하게 해준다. 사용자는 typed data에 서명하고, checkout contract는 그 서명을 이용해 allowance를 만든 뒤 transferFrom으로 자금을 가져온다.

여기서 가장 중요한 구분은 permit과 payment다. permit은 결제 완료가 아니라 allowance 승인이다. checkout contract가 permit 호출에 성공해도 transferFrom이 실패하면 결제는 완료되지 않는다. 반대로 결제가 완료된 invoice에 같은 permit을 다시 제출해도 invoice 상태 머신이 재결제를 막아야 한다.

학습 목표

  • permit 기반 checkout flow를 구현한다.
  • 서명 만료, nonce, domain을 테스트한다.

개념 설명

원장 흐름가로 스크롤 · 크게 보기 지원
Permit Checkout 랩 원장·책임 흐름이 시각화는 랩 산출물을 캡스톤 설계로 옮길 때 사용자 잔액, 내부 원장, 외부 정산이 어긋날 수 있는 지점이 어디인지를 보여주며, 'Permit Checkout 랩'에서 남겨야 할 설계 증거를 좁힌다.
FROM
랩 입력
TO
구현/검증
요청/권한

permit 기반 checkout flow를 구현한다.

FROM
구현/검증
TO
캡스톤 문서
상태 전이

랩 산출물이 캡스톤의 어떤 장과 테스트 증거로 재사용되는가

FROM
캡스톤 문서
TO
운영자/학습자
검증 로그

permit happy path가 동작한다.

크게 보기
FROM
랩 입력
TO
구현/검증
요청/권한

permit 기반 checkout flow를 구현한다.

FROM
구현/검증
TO
캡스톤 문서
상태 전이

랩 산출물이 캡스톤의 어떤 장과 테스트 증거로 재사용되는가

FROM
캡스톤 문서
TO
운영자/학습자
검증 로그

permit happy path가 동작한다.

대상 코드는 08-실습/mock-stablecoin-lab/src/PermitCheckout.sol이고, 테스트는 test/PermitCheckout.t.sol이다. contract는 invoice를 만들고, payWithPermit에서 token의 permit을 호출한 뒤 transferFrom으로 금액을 escrow한다. 이후 merchant가 settle 또는 refund를 실행한다.

표 자료가로 스크롤 · 크게 보기 지원
단계호출상태 변화실패해야 하는 조건
invoice 생성createInvoiceUnknown -> Created중복 invoice, zero address, zero amount
서명 승인token.permittoken allowance 생성expired signature, wrong spender, invalid domain, frozen owner
자금 이동transferFromcheckout contract가 token 보유allowance 부족, balance 부족, token paused/frozen
결제 확정payWithPermitCreated -> Paid이미 paid/settled/refunded인 invoice
정산settlePaid -> Settledmerchant가 아닌 caller, unpaid invoice
환불refundPaid -> Refundedmerchant가 아닌 caller, unpaid invoice
크게 보기
단계호출상태 변화실패해야 하는 조건
invoice 생성createInvoiceUnknown -> Created중복 invoice, zero address, zero amount
서명 승인token.permittoken allowance 생성expired signature, wrong spender, invalid domain, frozen owner
자금 이동transferFromcheckout contract가 token 보유allowance 부족, balance 부족, token paused/frozen
결제 확정payWithPermitCreated -> Paid이미 paid/settled/refunded인 invoice
정산settlePaid -> Settledmerchant가 아닌 caller, unpaid invoice
환불refundPaid -> Refundedmerchant가 아닌 caller, unpaid invoice

강의에서는 아래 흐름으로 읽는다.

흐름도가로 스크롤 · 크게 보기 지원
강의 흐름도상태, 책임, 검증 지점을 순서대로 읽기 위한 다이어그램이다.
크게 보기

이 구조에서 서명은 사용자의 의도 증거이고, invoice 상태는 서비스의 회계 증거다. 두 증거가 섞이면 운영자가 "서명은 성공했지만 자금 이동은 실패한" 상태를 설명하지 못한다.

코드로 확인하기

앞에서 만든 설계를 실습 코드로 연결한다. 예시는 그대로 외우는 대상이 아니라, 구현 파일에서 어떤 줄을 읽고 어떤 테스트를 붙일지 정하는 기준이다.

컨트랙트Invoice 상태 enum과 저장소

Status enum이 invoice의 5단계(Unknown/Created/Paid/Settled/Refunded)를 정의한다. 결제·정산·환불 전이가 모두 이 enum을 기준으로 분기된다.

컨트랙트PermitCheckout — enum/struct/event/error 파일: 08-실습/mock-stablecoin-lab/src/PermitCheckout.sol (라인 16-52)

결제 상태와 검증 신호를 한눈에 본다.

CODE SURFACEsolidity
contract PermitCheckout {    enum Status {        Unknown,        Created,        Paid,        Settled,        Refunded    }    struct Invoice {        address payer;        address merchant;        address token;        uint256 amount;        Status status;    }    mapping(bytes32 invoiceId => Invoice invoice) public invoices;    event InvoiceCreated(        bytes32 indexed invoiceId,        address indexed payer,        address indexed merchant,        address token,        uint256 amount    );    event InvoicePaid(bytes32 indexed invoiceId);    event InvoiceSettled(bytes32 indexed invoiceId);    event InvoiceRefunded(bytes32 indexed invoiceId);    error InvoiceAlreadyExists(bytes32 invoiceId);    error InvoiceNotCreated(bytes32 invoiceId);    error InvoiceNotPaid(bytes32 invoiceId);    error InvalidAddress();    error InvalidAmount();    error OnlyMerchant(address caller);    error TokenCallFailed();

컨트랙트payWithPermit — 서명 + transferFrom 두 단계

permit 호출과 transferFrom이 한 트랜잭션 안에 묶여 있지만 두 호출은 별개의 실패 지점을 갖는다. permit이 성공해도 transferFrom이 실패하면 status는 그대로 Created로 남아야 한다.

컨트랙트payWithPermit 본문 파일: 08-실습/mock-stablecoin-lab/src/PermitCheckout.sol (라인 78-91)

서명 적용과 자금 이동의 순서, 그리고 상태 전이 위치를 확인한다.

CODE SURFACEsolidity
    function payWithPermit(bytes32 invoiceId, uint256 deadline, uint8 v, bytes32 r, bytes32 s)        external    {        Invoice storage invoice = invoices[invoiceId];        if (invoice.status != Status.Created) revert InvoiceNotCreated(invoiceId);        IPermitToken(invoice.token).permit(            invoice.payer, address(this), invoice.amount, deadline, v, r, s        );        _safeTransferFrom(invoice.token, invoice.payer, address(this), invoice.amount);        invoice.status = Status.Paid;        emit InvoicePaid(invoiceId);    }

컨트랙트settle / refund — 분기 권한

settlerefund는 모두 status==Paid를 요구하고 merchant만 호출할 수 있다. 둘 중 한 번 호출되면 다른 경로는 모두 막혀야 한다.

컨트랙트settle/refund 본문 파일: 08-실습/mock-stablecoin-lab/src/PermitCheckout.sol (라인 93-113)

merchant 권한과 status 전이 정합성을 검증한다.

CODE SURFACEsolidity
    function settle(bytes32 invoiceId) external {        Invoice storage invoice = invoices[invoiceId];        if (invoice.status != Status.Paid) revert InvoiceNotPaid(invoiceId);        if (msg.sender != invoice.merchant) revert OnlyMerchant(msg.sender);        invoice.status = Status.Settled;        _safeTransfer(invoice.token, invoice.merchant, invoice.amount);        emit InvoiceSettled(invoiceId);    }    function refund(bytes32 invoiceId) external {        Invoice storage invoice = invoices[invoiceId];        if (invoice.status != Status.Paid) revert InvoiceNotPaid(invoiceId);        if (msg.sender != invoice.merchant) revert OnlyMerchant(msg.sender);        invoice.status = Status.Refunded;        _safeTransfer(invoice.token, invoice.payer, invoice.amount);        emit InvoiceRefunded(invoiceId);    }

클라이언트EIP-712 typed data 생성 예시

사용자 지갑이 어떤 객체에 서명하는지 클라이언트 코드로 본다. value, deadline, nonce, domain이 token 컨트랙트가 검증할 값과 정확히 일치해야 한다.

CODE SURFACEtypescript
const typedData = {  domain: {    name: "MockStablecoin",    version: "1",    chainId: chainId,    verifyingContract: tokenAddress  },  types: {    Permit: [      { name: "owner", type: "address" },      { name: "spender", type: "address" },      { name: "value", type: "uint256" },      { name: "nonce", type: "uint256" },      { name: "deadline", type: "uint256" }    ]  },  primaryType: "Permit" as const,  message: {    owner: payer,    spender: checkoutAddress,    value: invoiceAmount,    nonce: currentNonce,    deadline: BigInt(Math.floor(Date.now() / 1000) + 600)  }};const signature = await walletClient.signTypedData(typedData);const { r, s, v } = parseSignature(signature);

강의 포인트

표 자료가로 스크롤 · 크게 보기 지원
관점확인할 질문증거로 남길 것
서명 범위owner, spender, value, nonce, deadline, domain이 결제 의도와 맞는가permit typed data 표
상태 머신permit 성공과 transferFrom 성공을 별도 단계로 설명할 수 있는가invoice 상태 전이표
negative testexpired permit, wrong spender, frozen payer, duplicate payment를 막는가실패 테스트 5개 이상
정산결제 후 merchant가 settle/refund 중 하나만 실행할 수 있는가settle/refund 결과 비교
운영사용자에게 "서명 실패"와 "결제 실패"를 다르게 안내할 수 있는가사용자 메시지 초안
크게 보기
관점확인할 질문증거로 남길 것
서명 범위owner, spender, value, nonce, deadline, domain이 결제 의도와 맞는가permit typed data 표
상태 머신permit 성공과 transferFrom 성공을 별도 단계로 설명할 수 있는가invoice 상태 전이표
negative testexpired permit, wrong spender, frozen payer, duplicate payment를 막는가실패 테스트 5개 이상
정산결제 후 merchant가 settle/refund 중 하나만 실행할 수 있는가settle/refund 결과 비교
운영사용자에게 "서명 실패"와 "결제 실패"를 다르게 안내할 수 있는가사용자 메시지 초안

실무 예시

클라이언트사용자가 25 USDC 상당의 invoice를 결제한다고 가정한다. frontend는 Permit typed data를 보여주고 사용자는 서명한다. [BACKEND] backend 또는 relayer는 payWithPermit(invoiceId, deadline, v, r, s)를 제출한다.

표 자료가로 스크롤 · 크게 보기 지원
상황사용자 안내운영자가 확인할 증거
deadline 지남결제 서명이 만료되어 다시 서명해야 한다ExpiredSignature(deadline)
wrong spender이 서명은 checkout contract용이 아니다InvalidSignature와 spender 필드
payer frozen계정 제한으로 결제가 처리되지 않았다FrozenAccount(payer)
invoice already paid이미 처리된 invoice다invoice status가 Paid 이상
refund 완료결제 금액이 payer에게 반환됐다InvoiceRefunded event와 token balance
크게 보기
상황사용자 안내운영자가 확인할 증거
deadline 지남결제 서명이 만료되어 다시 서명해야 한다ExpiredSignature(deadline)
wrong spender이 서명은 checkout contract용이 아니다InvalidSignature와 spender 필드
payer frozen계정 제한으로 결제가 처리되지 않았다FrozenAccount(payer)
invoice already paid이미 처리된 invoice다invoice status가 Paid 이상
refund 완료결제 금액이 payer에게 반환됐다InvoiceRefunded event와 token balance

실습에서 좋은 제출물은 함수 호출 성공 화면이 아니라 이 표를 테스트와 로그로 채운 문서다.

흔한 오해와 실패 시나리오

표 자료가로 스크롤 · 크게 보기 지원
오해실제로 확인할 것
permit을 결제라고 본다.permit은 allowance 생성이고, 결제는 token transfer와 invoice 상태 업데이트까지 끝나야 한다.
spender 검증을 대충 본다.spender가 checkout contract가 아니면 서명이 다른 contract에서 악용될 수 있다.
nonce 테스트를 happy path에서만 본다.재사용된 permit이 invoice 상태와 token nonce 양쪽에서 막히는지 확인해야 한다.
refund와 settlement를 UI 버튼 문제로만 본다.contract 상태가 Paid일 때만 둘 중 하나로 이동해야 하며 merchant 권한도 확인해야 한다.
domain 변경을 고려하지 않는다.chainId 또는 verifying contract가 바뀌면 기존 서명은 무효가 되어야 한다.
크게 보기
오해실제로 확인할 것
permit을 결제라고 본다.permit은 allowance 생성이고, 결제는 token transfer와 invoice 상태 업데이트까지 끝나야 한다.
spender 검증을 대충 본다.spender가 checkout contract가 아니면 서명이 다른 contract에서 악용될 수 있다.
nonce 테스트를 happy path에서만 본다.재사용된 permit이 invoice 상태와 token nonce 양쪽에서 막히는지 확인해야 한다.
refund와 settlement를 UI 버튼 문제로만 본다.contract 상태가 Paid일 때만 둘 중 하나로 이동해야 하며 merchant 권한도 확인해야 한다.
domain 변경을 고려하지 않는다.chainId 또는 verifying contract가 바뀌면 기존 서명은 무효가 되어야 한다.

실습 과제

  1. 컨트랙트Permit checkout 구현 읽기: createInvoice, payWithPermit, settle, refund를 상태 전이표로 옮긴다.
  2. 컨트랙트실패 케이스 기록: expired deadline, wrong spender, frozen payer, duplicate payment, non-merchant settle/refund를 테스트명과 expected revert로 정리한다.
  3. 클라이언트[OPS] 서명/결제 분리 문서화: permit 성공, transfer 실패, invoice 미변경 상태가 발생하면 UI와 운영 로그에 무엇을 남길지 쓴다.
  4. 컨트랙트capstone 연결: final checkout 설계에서 permit 기반 결제를 언제 쓰고 ERC-3009 기반 결제를 언제 쓸지 비교한다.

완료 기준

  1. permit happy path가 동작한다.
  2. negative test 5개가 있다.
  3. 결제 상태와 서명 상태를 분리했다.

근거 자료

Final checkpoint

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

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

  • permit happy path가 동작한다.
  • negative test 5개가 있다.
  • 결제 상태와 서명 상태를 분리했다.

학습 자료 근거

Permit Checkout 랩
이 LMS 레슨의 개념, 예시, 과제 구성을 잡는 데 사용한 근거 문서.
내부 참고 문서
EIP-2612: Permit Extension for ERC-20
https://eips.ethereum.org/EIPS/eip-2612
EIP-712: Typed Structured Data Hashing and Signing
https://eips.ethereum.org/EIPS/eip-712