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를 테스트한다.
개념 설명
AuthorizationCreated
transferWithAuthorization 기반 escrow 결제 흐름을 설계한다.
EscrowLocked
랩 산출물이 캡스톤의 어떤 장과 테스트 증거로 재사용되는가
ServiceOutcomeKnown
실패 로그가 남는가
CapstoneEvidence
Escrow 상태머신 구현
대상 코드는 08-실습/mock-stablecoin-lab/src/SignedPaymentEscrow.sol이고, 테스트는 test/SignedPaymentEscrow.t.sol이다. payment를 만들고, payWithAuthorization에서 token의 transferWithAuthorization을 호출해 escrow contract가 자금을 받는다. 이후 merchant가 release 또는 refund를 실행한다.
| 단계 | 호출 | 상태 변화 | 주의할 실패 |
|---|---|---|---|
| payment 생성 | createPayment | Unknown -> Created | 중복 payment, zero address, zero amount |
| authorization 제출 | payWithAuthorization | Created -> Paid | used nonce, wrong recipient, expired authorization, not-yet-valid authorization |
| 서비스 성공 | release | Paid -> Released | merchant가 아닌 caller, unpaid payment |
| 서비스 실패 | refund | Paid -> Refunded | merchant가 아닌 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가 책임진다는 경계를 본다.
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)이중 정산을 막는 단순 분기 구조.
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로 자금을 보내는 서명을 만들 수 없다.
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 중 하나만 가능하게 설계했는가 | 상태 전이 다이어그램 |
실무 예시
운영merchant가 디지털 상품을 제공한다고 가정한다. 사용자는 paymentId에 해당하는 금액을 escrow contract로 보내는 authorization에 서명한다. relayer가 authorization을 제출하면 escrow가 자금을 보관한다. 상품 제공이 성공하면 merchant가 release하고, 실패하면 refund한다.
| 상황 | contract가 해야 할 일 | 사용자/운영 증거 |
|---|---|---|
| relayer가 같은 nonce를 다시 제출 | token에서 replay를 차단한다 | used nonce와 failed paymentId |
| 서명 수신자가 merchant | escrow 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 이후 둘 중 하나로만 이동해야 이중 지급을 막을 수 있다. |
실습 과제
- 컨트랙트Escrow 상태머신 구현:
Created,Paid,Released,Refunded상태 전이를 표와 diagram으로 작성한다. - 컨트랙트Replay 방지 검수: 같은 nonce를 두 payment에 쓰는 테스트를 읽고, token authorizationState가 어떤 기준으로 재사용을 막는지 설명한다.
- 컨트랙트valid window 테스트 확장: expired뿐 아니라 not-yet-valid authorization 케이스를 추가한다면 어떤 revert가 기대되는지 적는다.
- 운영release/refund 정책 작성: service failure, merchant dispute, timeout 상황에서 누가 어떤 권한으로 release/refund를 실행해야 하는지 capstone 정책으로 남긴다.
완료 기준
- authorization lifecycle을 테스트했다.
- escrow 상태 전이를 만들었다.
- release/refund 정책을 기록했다.
근거 자료
- ERC3009 Escrow 랩: 08-실습/03-ERC3009-Escrow-랩.md
- 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