SettleLab
전체 코스
LESSON 04Labs and Capstone

ERC-3009 Escrow 랩

심화1시간근거 3

학습 결과

  • transferWithAuthorization 기반 escrow 결제 흐름을 설계한다.
  • authorization replay와 cancel path를 테스트한다.

선행 조건

  • Permit Checkout 랩

완료 기준

  • authorization lifecycle을 테스트했다.
  • escrow 상태 전이를 만들었다.
  • release/refund 정책을 기록했다.

ERC-3009 Escrow 랩

도입

ERC-3009 스타일 결제는 allowance를 남기지 않고 특정 금액을 특정 수신자에게 보내는 signed transfer에 가깝다. 사용자는 from, to, value, validAfter, validBefore, nonce에 서명하고, relayer가 그 authorization을 제출한다.

escrow 랩의 목표는 이 exact payment 성격을 서비스 상태 머신과 연결하는 것이다. 자금은 escrow에 들어왔지만 서비스 제공이 실패할 수 있고, relayer가 늦게 제출할 수 있으며, 같은 nonce를 다른 payment에 재사용하려는 시도가 있을 수 있다. 이 문제를 Paid, Released, Refunded 상태로 분리해 다룬다.

학습 목표

  • transferWithAuthorization 기반 escrow 결제 흐름을 설계한다.
  • authorization replay와 cancel path를 테스트한다.

개념 설명

상태머신가로 스크롤 · 크게 보기 지원
ERC-3009 Escrow 랩 상태머신이 시각화는 랩 산출물을 캡스톤 설계로 옮길 때 상태 전이가 어떤 증거와 실패 조건으로 움직이는지를 보여주며, 'ERC-3009 Escrow 랩'에서 남겨야 할 설계 증거를 좁힌다.
State 1

AuthorizationCreated

transferWithAuthorization 기반 escrow 결제 흐름을 설계한다.

from, to, value, valid window, nonce가 invoice와 묶인다.
State 2

EscrowLocked

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

authorization 재사용과 wrong recipient가 테스트에서 거절된다.
State 3

ServiceOutcomeKnown

실패 로그가 남는가

서비스 성공, 환불, 수동 검토가 서로 다른 ledger entry로 남는다.
State 4

CapstoneEvidence

Escrow 상태머신 구현

authorization lifecycle을 테스트했다.
크게 보기
State 1

AuthorizationCreated

transferWithAuthorization 기반 escrow 결제 흐름을 설계한다.

from, to, value, valid window, nonce가 invoice와 묶인다.
State 2

EscrowLocked

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

authorization 재사용과 wrong recipient가 테스트에서 거절된다.
State 3

ServiceOutcomeKnown

실패 로그가 남는가

서비스 성공, 환불, 수동 검토가 서로 다른 ledger entry로 남는다.
State 4

CapstoneEvidence

Escrow 상태머신 구현

authorization lifecycle을 테스트했다.

대상 코드는 08-실습/mock-stablecoin-lab/src/SignedPaymentEscrow.sol이고, 테스트는 test/SignedPaymentEscrow.t.sol이다. payment를 만들고, payWithAuthorization에서 token의 transferWithAuthorization을 호출해 escrow contract가 자금을 받는다. 이후 merchant가 release 또는 refund를 실행한다.

표 자료가로 스크롤 · 크게 보기 지원
단계호출상태 변화주의할 실패
payment 생성createPaymentUnknown -> Created중복 payment, zero address, zero amount
authorization 제출payWithAuthorizationCreated -> Paidused nonce, wrong recipient, expired authorization, not-yet-valid authorization
서비스 성공releasePaid -> Releasedmerchant가 아닌 caller, unpaid payment
서비스 실패refundPaid -> Refundedmerchant가 아닌 caller, already released/refunded
크게 보기
단계호출상태 변화주의할 실패
payment 생성createPaymentUnknown -> Created중복 payment, zero address, zero amount
authorization 제출payWithAuthorizationCreated -> Paidused nonce, wrong recipient, expired authorization, not-yet-valid authorization
서비스 성공releasePaid -> Releasedmerchant가 아닌 caller, unpaid payment
서비스 실패refundPaid -> Refundedmerchant가 아닌 caller, already released/refunded

상태 흐름은 permit checkout과 비슷해 보이지만, 핵심 리스크가 다르다.

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

Permit checkout에서는 allowance가 남는지와 spender가 맞는지가 중요했다. ERC-3009 escrow에서는 authorization nonce가 이미 사용됐는지, 제출 시점이 valid window 안인지, 서명의 to가 escrow contract인지가 핵심이다.

코드로 확인하기

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

컨트랙트payWithAuthorization — 서명 위임 후 자금 보관

escrow contract는 token의 transferWithAuthorization을 그대로 위임 호출한다. token 측 검증(서명·기간·nonce)이 실패하면 escrow의 status도 그대로 Created에 남는다.

컨트랙트SignedPaymentEscrow.payWithAuthorization 파일: 08-실습/mock-stablecoin-lab/src/SignedPaymentEscrow.sol (라인 80-98)

서명 검증은 token이, payment 상태는 escrow가 책임진다는 경계를 본다.

CODE SURFACEsolidity
    function payWithAuthorization(        bytes32 paymentId,        uint256 validAfter,        uint256 validBefore,        bytes32 nonce,        uint8 v,        bytes32 r,        bytes32 s    ) external {        Payment storage payment = payments[paymentId];        if (payment.status != Status.Created) revert PaymentNotCreated(paymentId);        ITransferWithAuthorizationToken(payment.token).transferWithAuthorization(            payment.payer, address(this), payment.amount, validAfter, validBefore, nonce, v, r, s        );        payment.status = Status.Paid;        emit PaymentReceived(paymentId, nonce);    }

컨트랙트release / refund — 양자택일 정산

둘 다 Paid 상태만 전이 가능, merchant 권한 필요. 한 번 release되면 refund 경로는 자동으로 막힌다.

컨트랙트release/refund 본문 파일: 08-실습/mock-stablecoin-lab/src/SignedPaymentEscrow.sol (라인 100-120)

이중 정산을 막는 단순 분기 구조.

CODE SURFACEsolidity
    function release(bytes32 paymentId) external {        Payment storage payment = payments[paymentId];        if (payment.status != Status.Paid) revert PaymentNotPaid(paymentId);        if (msg.sender != payment.merchant) revert OnlyMerchant(msg.sender);        payment.status = Status.Released;        _safeTransfer(payment.token, payment.merchant, payment.amount);        emit PaymentReleased(paymentId);    }    function refund(bytes32 paymentId) external {        Payment storage payment = payments[paymentId];        if (payment.status != Status.Paid) revert PaymentNotPaid(paymentId);        if (msg.sender != payment.merchant) revert OnlyMerchant(msg.sender);        payment.status = Status.Refunded;        _safeTransfer(payment.token, payment.payer, payment.amount);        emit PaymentRefunded(paymentId);    }

클라이언트ERC-3009 typed data 구성

permit과 달리 to가 typed data에 직접 박혀 있어, 다른 contract로 자금을 보내는 서명을 만들 수 없다.

CODE SURFACEtypescript
const typedData = {  domain: {    name: "MockStablecoin",    version: "1",    chainId,    verifyingContract: tokenAddress  },  types: {    TransferWithAuthorization: [      { name: "from", type: "address" },      { name: "to", type: "address" },      { name: "value", type: "uint256" },      { name: "validAfter", type: "uint256" },      { name: "validBefore", type: "uint256" },      { name: "nonce", type: "bytes32" }    ]  },  primaryType: "TransferWithAuthorization" as const,  message: {    from: payer,    to: escrowAddress,    value: paymentAmount,    validAfter: 0n,    validBefore: BigInt(Math.floor(Date.now() / 1000) + 600),    nonce: crypto.getRandomValues(new Uint8Array(32))  }};

강의 포인트

표 자료가로 스크롤 · 크게 보기 지원
관점확인할 질문증거로 남길 것
authorization 범위from, to, value, validAfter, validBefore, nonce가 payment와 맞는가signed payload 점검표
replay 방지같은 payer와 nonce가 두 번째 payment에 재사용될 때 막히는가AuthorizationAlreadyUsed 테스트
recipient 고정merchant나 다른 contract를 to로 서명한 payload가 escrow에서 실패하는가wrong recipient 실패 로그
시간 조건not-yet-valid와 expired를 별도 실패로 설명할 수 있는가valid window 표
서비스 결과payment 이후 release와 refund 중 하나만 가능하게 설계했는가상태 전이 다이어그램
크게 보기
관점확인할 질문증거로 남길 것
authorization 범위from, to, value, validAfter, validBefore, nonce가 payment와 맞는가signed payload 점검표
replay 방지같은 payer와 nonce가 두 번째 payment에 재사용될 때 막히는가AuthorizationAlreadyUsed 테스트
recipient 고정merchant나 다른 contract를 to로 서명한 payload가 escrow에서 실패하는가wrong recipient 실패 로그
시간 조건not-yet-valid와 expired를 별도 실패로 설명할 수 있는가valid window 표
서비스 결과payment 이후 release와 refund 중 하나만 가능하게 설계했는가상태 전이 다이어그램

실무 예시

운영merchant가 디지털 상품을 제공한다고 가정한다. 사용자는 paymentId에 해당하는 금액을 escrow contract로 보내는 authorization에 서명한다. relayer가 authorization을 제출하면 escrow가 자금을 보관한다. 상품 제공이 성공하면 merchant가 release하고, 실패하면 refund한다.

표 자료가로 스크롤 · 크게 보기 지원
상황contract가 해야 할 일사용자/운영 증거
relayer가 같은 nonce를 다시 제출token에서 replay를 차단한다used nonce와 failed paymentId
서명 수신자가 merchantescrow payment는 실패해야 한다InvalidSignature
authorization 만료자금 이동 없이 payment는 Created에 남는다AuthorizationExpired(validBefore)
서비스 실패merchant가 refund 실행PaymentRefunded event와 payer balance
서비스 성공merchant가 release 실행PaymentReleased event와 merchant balance
크게 보기
상황contract가 해야 할 일사용자/운영 증거
relayer가 같은 nonce를 다시 제출token에서 replay를 차단한다used nonce와 failed paymentId
서명 수신자가 merchantescrow payment는 실패해야 한다InvalidSignature
authorization 만료자금 이동 없이 payment는 Created에 남는다AuthorizationExpired(validBefore)
서비스 실패merchant가 refund 실행PaymentRefunded event와 payer balance
서비스 성공merchant가 release 실행PaymentReleased event와 merchant balance

좋은 설계 문서는 "자금 이동"과 "서비스 제공"을 같은 transaction으로 과장하지 않는다. 이 랩은 둘 사이의 간격을 escrow 상태로 드러내는 연습이다.

흔한 오해와 실패 시나리오

표 자료가로 스크롤 · 크게 보기 지원
오해실제로 확인할 것
ERC-3009가 escrow 문제를 자동 해결한다고 본다.ERC-3009는 signed transfer를 제공할 뿐, service success와 refund 정책은 별도 상태 머신이 필요하다.
nonce를 invoiceId처럼 사용하면 충분하다고 본다.token의 authorizationState 기준으로 payer+nonce 재사용 여부를 확인해야 한다.
relayer가 늦어도 문제가 없다고 본다.valid window 밖 제출은 실패하며, 사용자는 새 authorization이 필요할 수 있다.
recipient 검증을 settlement 단계로 미룬다.signed payload의 to가 escrow contract가 아니면 자금 보관 흐름 자체가 깨진다.
release와 refund를 둘 다 가능하게 둔다.Paid 이후 둘 중 하나로만 이동해야 이중 지급을 막을 수 있다.
크게 보기
오해실제로 확인할 것
ERC-3009가 escrow 문제를 자동 해결한다고 본다.ERC-3009는 signed transfer를 제공할 뿐, service success와 refund 정책은 별도 상태 머신이 필요하다.
nonce를 invoiceId처럼 사용하면 충분하다고 본다.token의 authorizationState 기준으로 payer+nonce 재사용 여부를 확인해야 한다.
relayer가 늦어도 문제가 없다고 본다.valid window 밖 제출은 실패하며, 사용자는 새 authorization이 필요할 수 있다.
recipient 검증을 settlement 단계로 미룬다.signed payload의 to가 escrow contract가 아니면 자금 보관 흐름 자체가 깨진다.
release와 refund를 둘 다 가능하게 둔다.Paid 이후 둘 중 하나로만 이동해야 이중 지급을 막을 수 있다.

실습 과제

  1. 컨트랙트Escrow 상태머신 구현: Created, Paid, Released, Refunded 상태 전이를 표와 diagram으로 작성한다.
  2. 컨트랙트Replay 방지 검수: 같은 nonce를 두 payment에 쓰는 테스트를 읽고, token authorizationState가 어떤 기준으로 재사용을 막는지 설명한다.
  3. 컨트랙트valid window 테스트 확장: expired뿐 아니라 not-yet-valid authorization 케이스를 추가한다면 어떤 revert가 기대되는지 적는다.
  4. 운영release/refund 정책 작성: service failure, merchant dispute, timeout 상황에서 누가 어떤 권한으로 release/refund를 실행해야 하는지 capstone 정책으로 남긴다.

완료 기준

  1. authorization lifecycle을 테스트했다.
  2. escrow 상태 전이를 만들었다.
  3. release/refund 정책을 기록했다.

근거 자료

Final checkpoint

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

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

  • authorization lifecycle을 테스트했다.
  • escrow 상태 전이를 만들었다.
  • release/refund 정책을 기록했다.

학습 자료 근거

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