2. 결제 흐름
Mapae의 결제 경로는 ERC-7710 위임 결제다. 첫 회귀 경로였던 EIP-3009 직접 결제는 앱(판매자·에이전트)을 지우고 원시 요소만 남겼다.
EIP-3009 직접 결제 — 남은 것
contracts/의 MockUSDC는 transferWithAuthorization을 구현하고, packages/shared는
그 authorization의 타입·EIP-712 도메인·정산 오류 모델(SettlementError)을 들고
있으며, facilitator/는 x402-rs 컨테이너 설정이다. 이 경로를 내고 받던 두 앱은
호스티드 상점이 들어오면서 지웠다 — 위임 경로가 같은 402 → 서명 → 정산 루프를
더 좁은 권한으로 닫기 때문이다.
남긴 성질 하나가 위임 경로의 출발점이다: authorization에 from·to·value가
서명으로 고정되어 있어, 이를 브로드캐스트하는 릴레이어는 브로드캐스터 이상의
권한을 갖지 못한다. 지불자는 가스를 내지 않는다.
ERC-7710 위임 결제
account owner wallet → HybridDeleGator owner account
(계정이 아직 없으면: 배포 전 서명 → account-bootstrap이 대납 배포)
→ erc20PeriodTransfer parent delegation
agent → 402 수신
→ amount/payTo/facilitator가 고정된 결제별 leaf 서명
seller → ERC-7710 facilitator /verify → /settle
facilitator → DelegationManager.redeemDelegations
→ mUSDC.transfer(payTo, amount)
마패의 권한은 MetaMask 형식의 서명된 위임 체인이다. ERC-7710은 그 권한을
상환하는 redeemDelegations 인터페이스이고, ERC-7715는 지갑에 권한을 요청하는
wallet_requestExecutionPermissions API다. 둘은 별개이며 현재 둘 다 Draft다.
마패는 ERC-7715 호출 대신 owner의 직접 EIP-712 서명을 사용한다. 결제는 facilitator
EOA가 실행하고, ERC-4337 EntryPoint v0.7은 회수 경로에서 사용한다. EIP-7702 계정
전환과 ERC-6492 배포 전 검증은 제품 경로에 없다. parent caveat는 60초 주기 한도와
만료창(기본 30분, 데모에서는 PERMISSION_TTL_SECONDS로 연장)을 온체인으로
강제한다. Vendor 프로필은 ERC-20
transfer calldata의 수취인 위치도 고정한다. Manager→Child 재위임에서는
child의 개별 한도와 manager의 합산 한도가 동시에 적용된다.
부모 위임의 caveat은 넷이다 — 네이티브 값 0 고정(ValueLteEnforcer)과 기간
상한(ERC20PeriodTransferEnforcer)은 scope가 붙이고, 유효
창(TimestampEnforcer)과 평생 총액(ERC20TransferAmountEnforcer)은 정책이
붙인다. 고정 벤더 정책은 여기에 수취인 핀(AllowedCalldataEnforcer)이 더해져
다섯이다. 기간 상한은 속도이지 예산이 아니어서 기간이 갱신될 때마다 같은 금액을
다시 쓸 수 있고(60초·30분 창이면 기간 상한의 30배), 총액은 그 갱신을 따라가지
않는다 — ERC20TransferAmountEnforcer가 위임 해시 하나당 되돌아가지 않는
칸(spentMap(delegationManager, delegationHash))을 들기 때문이다. 건당 상한은
부모에 없다: 리프가 이미 오퍼 금액에 묶여 있다.
오퍼의 extra. ERC-7710 오퍼의 extra는 두 칸을 항상 싣고, 퍼실리테이터가
광고했을 때 두 칸을 더 싣는다. 항상 있는 것은 assetTransferMethod: "erc7710"
(exact-EVM의 공식 전송 방식)과 paymentFlow: "upfront"다. 뒤엣것은 스펙 §6.1이
요구하는 선언이다 — 흐름이 기본값 authorization이 아니면 반드시 선언해야 하고,
Mapae는 /verify와 /settle이 모두 성공한 뒤에 자원을 준다. 선언이 없으면 스펙을
읽는 클라이언트는 authorization(선제공·후정산)을 가정하고 받을 시점을 잘못 계산한다.
조건부인 두 칸은 facilitatorAddresses(신뢰 게이트 — 에이전트는 자기 허용 목록과
겹치지 않는 오퍼를 거절한다)와 delegationManager(GIWA의 manager는 어느
레지스트리에도 없어 대역 내 광고가 제3자 통합의 유일한 통로다)이며, 미들웨어가
/supported에서 읽은 것을 그대로 복사한다. 우리가 만드는 오퍼는 /supported의
kind까지 예외 없이 흐름을 선언한다.
읽는 쪽은 기본 흐름을 세 모습으로 받아들인다. 결제할 수 있는 흐름은 둘 — 이 레일의
upfront와 스펙 기본값 authorization — 이고, 선언의 부재와 null은 그 기본값을
뜻한다(참조 구현 @x402/core 2.27.0의 스키마는 선택 칸의 null을 .nullish()로 부재와
같이 접는다). authorization은 판매자가 먼저 자원을 주고 나중에 정산하는 흐름이라 정산
위험을 판매자가 스스로 지고, 우리 에이전트가 서명하는 일회용 leaf의 손실 가능성을 바꾸지
않는다. 명시했는지 여부로 결제와 거절을 가르면 판정 기준이 흐름이 아니라 "필드를
적었는지"가 되어, 같은 흐름을 성실히 선언한 판매자만 죽는다. 부재는 실제 카운터파티의
모습이기도 하다 — @metamask/x402 1.0.0의 supportedKind 흐름은 /supported의
extra에서 facilitatorAddresses만 복사하므로, 그 경로로 만들어진 제3자 오퍼에는
선언이 아예 없다(컨포먼스 테스트가 고정한 측정값). 반면 escrow는 나중 청구라는 다른
흐름이고 이 클라이언트가 돌려주는 결과가 그 사후 정산을 설명하지 못하므로, 알 수 없는
값과 함께 거절한다.
퍼실리테이터도 같은 규칙을 쓴다. 이 레일은 /verify 다음에 /settle이 붙어 그
자리에서 redeem하므로 escrow를 수행할 코드가 없고, 그런 오퍼는 오퍼와 에코가 서로
일치해도 invalid_payment_requirements로 떨어진다 — 보호가 클라이언트 한쪽에만 있으면
판매자가 "나중 청구"라고 선언한 결제를 우리가 그 자리에서 정산해 버린다. paymentFlow는
에코 비교의 대상이기도 하다 — 우리 판매자의 오퍼는 항상 값을 싣기 때문에, 그 칸을 빼거나
바꿔 에코한 payload는 판매자가 내건 조건과 다른 것을 승낙한 셈이고 invalid_payload가
된다.
최신 참조 스택 연동. 런타임은 Smart Accounts Kit 2.0.0·Delegation ABIs 2.0.0을 사용한다. 배포된 Framework 1.3.0 구성의 ID·원본 패키지 버전·integrity는 배포 당시 증거로 유지하고, 설치된 bytecode가 그 구성과 일치하는지는 별도로 검사한다. SDK 업데이트는 컨트랙트 재배포나 기존 권한 재서명을 뜻하지 않는다.
@metamask/x402 1.0.0과 @x402/core·@x402/evm 2.27.0의 전체 HTTP 연동은
참조 연동 가이드와 실행되는 예제로 검증한다. 서버는 erc7710의
paymentFlows를 upfront로 명시하고, 클라이언트는 GIWA 토큰과 최소 단위의 건당 상한을
spendControls.allowedAssets에 명시한다. 참조 서버의 upfront는 /verify를 따로
호출하지 않고 /settle을 먼저 호출한다 — 마패 facilitator는 그 경로에서도 독립적으로
검증·시뮬레이션한다. 미확정 해시의 재조회는 같은 payload로 한 번뿐이며 새 leaf를
발행하지 않는다. 이 예제는 호스팅 seller의 미들웨어를 교체하지 않는다.
402의 extensions는 봉투다. 스펙의 extensions는 확장 이름 → {info, schema}
맵이다 — info는 확장 자신이 선언하는 내용이고, schema는 클라이언트가 페이로드에
에코할 형태를 기술하는 JSON Schema다. 호스팅 상점은 mapae 항목 하나를 싣고 판매자와
매니페스트 주소를 그 info 아래에 둔다. schema는 없다 — 에코를 요구하지 않기
때문이다. 결제 페이로드에도 같은 이름의 칸이 있다. 스펙은 클라이언트가 자기가
사용한 확장만 에코하게 하고, 그 칸의 첫 생산자가 아래의 payment-identifier다.
참조 구현은 그 규정보다
느슨하다 — @x402/core 2.27.0의 mergeExtensions는 클라이언트가 보탤 것이 없으면 판매자가
선언한 맵을 그대로 페이로드에 되싣는다. 그래서 참조 스택 구매자가 호스팅 상점에 결제하면
mapae 항목이 페이로드에 실려 돌아오는데, 퍼실리테이터의 검증기는 자기가 이름 붙인 칸만
비교하고 객체의 키를 열거하지 않으므로 그 칸은 거절되지 않고 무시된다. 이름이 같아
헷갈리는 /supported의 extensions는 다른 것이다 — 퍼실리테이터가 지원하는 확장의
목록이고, 오늘은 빈 배열이다.
payment-identifier는 멱등성 키지 인증이 아니다. 판매자가 페이월에 바인딩 저장소를
주면 402가 이 확장을 광고하고, 클라이언트는 결제마다 새 id(pay_ + 16바이트 hex)를
페이로드 봉투에 싣는다. 판매자는 정산 앞에서 그 id를 요청 지문(scheme·network·asset·
금액·payTo·자원 URL·메서드를 sha256으로 접은 것)과 결제 intent에 묶는다. 자원 URL은
오리진과 쿼리까지다 — 경로만 접으면 /report?ticker=A로 낸 헤더가 ?ticker=B에서 저장된
영수증으로 통과해 정산 한 번에 자원 둘이 나간다. 사양이 "tenant·merchant·route 단위로 키를
좁히라"고 적은 자리이기도 하다. 이것이 필요한 이유는 재시도가 새 리프로 서명되기
때문이다 — intent가 달라지므로 퍼실리테이터의 intent 장부도, 상점의 주문 표도 두 시도를
같은 결제로 보지 못한다. 식별자는 그 둘을 잇는 유일한 이름이다. 다만 그 이름은 서명되지
않는다: 중간자가 바꿔 칠 수 있으므로 멱등성 힌트일 뿐 인증이 아니고, 안전 판정은 언제나
서명에서 파생된 intent와 함께 내린다. 바인딩을 주지 않은 판매자는 확장을 광고하지도,
실려 온 id를 읽지도 않는다 — 지킬 수 없는 약속은 하지 않는다.
판정은 넷이다. 처음 보는 id는 지금 경로대로 정산한다. 같은 요청·같은 리프인데 이미
정산된 id는 다시 정산하지 않고 저장된 지불자·해시로 답한다(성공 영수증의 replayed가
이 호출이 정산하지 않았음을 말한다). 지문이 다르면 409 payment_identifier_conflict —
이 요청으로는 아무것도 청구되지 않았으니 새 id로 내면 된다. 지문은 같고 리프만 다른데 그
id가 이미 정산돼 있으면 409 payment_identifier_settled이고, 이쪽은 저장된 지불자와 해시를
영수증에 싣는다: 첫 시도가 settlement_pending으로 끝난 구매자가 다시 서명해 오는 자리라,
"아무것도 청구되지 않았다"로 읽히면 그 구매자가 두 번 낸다. 구매자의 클라이언트도 그래서
앞의 낱말은 PAYMENT_REJECTED로, 뒤의 낱말은 SETTLEMENT_UNKNOWN으로 읽는다.
호스팅 상점에서 표가 둘인 것도 질문이 둘이기 때문이다: orders는 "무엇이 팔렸는가"에 돈이
옮겨진 뒤에 답하고, payment_identifiers는 "이것이 같은 결제인가"에 첫 시도부터 답한다.
그 행은 퍼실리테이터를 부르기 전에 쓰이므로 아무도 검증하지 않은 시도까지 한 행씩
남기는데, 그래서 원장의 거절 행과 같은 정리를 문다 — 정산되지 않은 행만, 시간과 개수 두
상한으로. 정산된 행은 재생 가드라 건드리지 않는다. 이 표가 들어오면서 저장소 스키마가
7이 되었고, 이 빌드는 옛 파일을 마이그레이션하지 않고 거절한다: 운영자는 기존 sqlite
파일을 옆으로 치우고 다시 시드해야 한다(판매자·상품·주문·티켓이 새로 만들어진다).
아래 시퀀스는 같은 위임 하나에 대한 세 경로 — 정상 정산, 주기 한도 초과 거절, 만료 거절 — 를 보여준다. 거절의 판정 주체는 백엔드가 아니라 온체인 caveat이다.
sequenceDiagram
autonumber
actor Owner as Owner 지갑
participant SA as HybridDeleGator<br/>스마트계정 (payer)
participant Agent as delegated-agent<br/>세션키
participant Seller as delegated-seller
participant Fac as facilitator<br/>(relayer, 가스대납)
participant DM as DelegationManager<br/>+ caveat enforcers
participant USDC as MockUSDC
Note over Owner,SA: 사전 1회 — 루트 위임 오프라인 서명<br/>계정 배포 전 서명도 유효 (아래 '스폰서드 온보딩')
Owner->>SA: eth_signTypedData_v4 → ERC-1271 0x1626ba7e
Note right of SA: 3 mUSDC / 60s cap · 만료창 · permission.json
rect rgb(232,245,233)
Note over Agent,USDC: ① 정상 경로 — 누적 2.5 ≤ 3.0
Agent->>Seller: GET /s/demo-cafe/croissant
Seller-->>Agent: 402 (amount 2.5, erc7710)
Agent->>Agent: 결제별 leaf 서명 (세션키)
Agent->>Seller: Payment-Signature (leaf context)
Seller->>Fac: /verify → simulate redeemDelegations
Fac-->>Seller: isValid
Seller->>Fac: /settle
Fac->>DM: redeemDelegations (relayer 가스대납)
DM->>USDC: transfer(payTo, 2.5)
DM-->>Fac: OK
Fac-->>Seller: tx 0x71d71442…
Seller-->>Agent: 200 + 티켓 (payer 가스 0)
end
rect rgb(255,235,235)
Note over Agent,DM: ② 한도 초과 — 같은 주기 재시도, 누적 5.0 > 3.0
Agent->>Seller: GET /s/demo-cafe/croissant (재시도)
Seller->>Fac: /verify → simulate
Fac->>DM: simulate redeemDelegations
DM-->>Fac: revert ERC20PeriodTransferEnforcer:transfer-amount-exceeded
Fac-->>Seller: isValid = false
Seller-->>Agent: 402 + 오퍼 재발행 — 정산 없음, 자금 불변
end
rect rgb(255,244,229)
Note over Agent,DM: ③ 만료 — 유효창 경과 후
Fac->>DM: simulate redeemDelegations
DM-->>Fac: revert TimestampEnforcer:expired-delegation
Fac-->>Seller: isValid = false
end
정산 증거 — GIWA Sepolia (2026-07-24 ~ 2026-08-04)
증거 수준을 구분해 표기한다. 채굴됨은 GIWA에 블록으로 들어가 익스플로러에서
열리는 트랜잭션이고, 시뮬레이션은 GIWA의 현재 상태를 상대로 한 eth_call이다
— 판정은 배포된 enforcer 바이트코드가 실제 주기 카운터를 읽어 내리지만, 블록에
들어간 것은 없다.
| 경로 | 결과 | 증거 수준 | 증거 |
|---|---|---|---|
| Framework 배포 | 38-unit + 2단계 ownership + owner 스마트계정 | 채굴됨 | manager 0xF2F782Fa…F40C, owner account 0xA4e4d00E…DDF382 |
| 정상 정산 (inv-001, 1 mUSDC) | 성공, payer 가스 0 | 채굴됨 | tx 0xe897fe55…a97d, block 31555419 |
| 정상 정산 (inv-002, 2.5 mUSDC) | 성공 | 채굴됨 | tx 0x71d71442…6ce4, block 31558282 |
| 주기 한도 초과 (누적 5.0 > 3.0) | 거절, 자금 불변 | 시뮬레이션 | revert ERC20PeriodTransferEnforcer:transfer-amount-exceeded |
| 만료 (유효창 경과) | 거절 | 시뮬레이션 | revert TimestampEnforcer:expired-delegation |
| 스폰서드 온보딩 — 계정 배포 | 배포 전 서명에서 복원한 owner로 CREATE2 배포, 새 사용자 가스 0 | 채굴됨 | account 0x15286FE9…3301, tx 0xed21ac71…9902 |
| 스폰서드 온보딩 — mUSDC 플로트 | 3 mUSDC 민팅 | 채굴됨 | tx 0x9d14588b…baa0 |
| 배포 전 서명의 사후 수락 (late binding) | 라이브 isValidSignature = 0x1626ba7e |
시뮬레이션 | account 0x15286FE9…3301 |
거절 두 건에 트랜잭션 해시가 없는 것은 설계의 결과다. facilitator의 /verify가
simulate.redeemDelegations로 먼저 걸러내므로, revert가 예정된 트랜잭션에는
가스를 쓰지 않는다. 동일한 2.5 mUSDC 결제가 잔량이 있을 때는 정산되고 누적이
cap을 넘으면 거절된다 — 한도는 애플리케이션 코드의 약속이 아니라 배포된
enforcer가 강제하는 상태다.
스폰서드 온보딩 (계정 부트스트랩)
새 사용자는 아직 존재하지 않는 payer 스마트계정에 대해 root 위임을 서명하고,
apps/account-bootstrap이 그 계정을 스폰서 가스로 배포한다. 위임을 만들기 위해
GIWA ETH를 들 필요가 있는 사람은 아무도 없다.
설계를 결정한 실측 두 가지. 첫째, 정산 시점 배포는 불가능하다 —
DelegationManager는 어떤 실행보다 먼저 서명 루프를 돌고, 코드 없는 delegator는
EOA 분기로 빠져 ECDSA.recover가 계정이 아닌 owner를 돌려주므로
InvalidEOASignature로 끝난다. Framework 어디에도 ERC-6492는 없다. 둘째,
late binding은 성립한다 — 코드 없는 계정을 상대로 만든 서명이 배포 뒤
ERC-1271을 통과한다. HybridDeleGator가 owner()와 비교하고 owner는 CREATE2
initcode에 박혀 있기 때문이다. 위 표의 0x1626ba7e가 라이브 체인에서 그 사실을
답한 값이다.
요청 본문은 {permissionContext} 하나다. owner는 서명에서 복원하고, 계정은
CREATE2(복원된 owner)이며 permission이 지목한 delegator와 일치해야 한다.
호출자에게 owner나 salt를 받으면 누구든 우리가 돈 내고 배포할 주소를 지명할 수
있게 된다 — 이 구조에서는 키 없이 풀 수 없는 고정점을 호출자가 풀어야 한다.
서명은 오프라인에서 canonical 형식(low-s, v ∈ {27,28})까지 검사한다. viem은
OZ ECDSA가 revert하는 서명도 수락하므로, 이 검사가 없으면 모든 grant가 영원히
revert하는 계정을 돈 내고 배포하게 된다.
계정 단위 중복방지는 예산이 아니라 신원이다 — 키페어는 오프라인에서 공짜이므로,
그리핑의 실제 상한은 계정당 24시간 1회의 faucet 창, 일일 가스 예산
(BOOTSTRAP_DAILY_WEI), 그리고 일부러 작게 유지하는 스폰서 잔액이다. IP당
시간 제한(BOOTSTRAP_RATE_PER_HOUR, 기본 30, IPv6는 /64 단위)은 그 위에 얹은
과속방지턱이다 — IP는 공유되고 키는 공짜라 그리퍼를 막지는 못하지만, 상한이
없으면 기계 한 대가 한 주소에서 새 키페어를 계속 보내 하루 예산을 한 시간 안에
비울 수 있었다. faucet은 잔액이 1000 tUSDC(테스트넷, 실제 돈 아님) 미만인
계정을 목표까지 채운다(packages/delegation/src/faucet-policy.ts). 스폰서에는
위임 권한이 없어 payer 자금·한도·정산에는 닿지 못한다. 검증은
bun run test:e2e:bootstrap — GIWA fork에서 16케이스(킬 스위치·승인 불일치·
relayer 공유 거부·타인 서명·high-s·배포·late binding·가스 회계·faucet 목표
보충·중복·동시성·faucet 24시간 창·예산 소진·체인 실패 누출 가드·IP당 시간
제한과 무주소 면제) 16/16.
에이전트 자동화 (MCP)
결제 루프는 packages/delegation/src/payment-client.ts의
payForDelegatedResource 하나로 수렴하며, CLI 에이전트와 MCP 서버가 같은
구현을 공유한다. 구현이 두 벌이면 어긋난다.
apps/agent-mcp가 노출하는 tool은 둘이다.
| tool | 역할 |
|---|---|
mapae_pay_for_resource |
402 수신 → caveat 안에서 leaf 서명 → 재요청 → 리소스 |
mapae_status |
세션키·엔드포인트·배포 검증 여부 (키·permission context는 반환하지 않음) |
서버를 MCP 클라이언트에 등록하는 절차와 환경 변수는 MCP 연결 가이드에 있다.
이 경로는 GIWA Sepolia에서 완주했다. MCP tool 호출 한 번이 사람 개입 없이 결제를
정산했고, 트랜잭션
0x533c…9964c
(block 31634935)에서 payer −1 mUSDC, vendor +1 mUSDC, payer의 ETH 지출은 0이다.
이 경로의 증거 수준은 로컬 fork가 아니라 GIWA 채굴이다. 같은 트랜잭션이
§3의 타임아웃 사례이기도 하다 — 온체인 정산은 성공했고, 보고 경로의 타임아웃
예산은 이후 재설계되었다.
실패는 이유로 반환된다. 코어는 예외 대신 판별된 결과를 돌려주며
SELLER_OFFER_INVALID·FACILITATOR_UNTRUSTED·MANAGER_MISMATCH·LIMIT_EXCEEDED·
PERMISSION_INACTIVE·SIGNING_FAILED·PAYMENT_REJECTED 등으로 원인을 가리킨다.
오퍼 선택과 영수증. 402의 accepts는 판매자가 나열한 순서대로 전부 훑고,
exact·GIWA·ERC-7710이면서 신뢰하는 facilitator와 겹치고 검증된
DelegationManager와 어긋나지 않는 첫 항목으로 결제한다 — 지갑용 EIP-3009를
앞에, 위임 에이전트용 ERC-7710을 뒤에 둔 판매자도 그대로 결제된다. 통과하는
항목이 없으면 ERC-7710 레일에 올라온 첫 후보의 사유를 돌려주고, 그런 후보조차
없으면 SELLER_OFFER_INVALID다. 2xx의 Payment-Response 헤더는 이 결제의
영수증일 때만 읽는다 — success: true, 같은 네트워크, leaf를 서명한 payer —
그때 transaction은 헤더 값이고(스펙대로 ""는 "해시 없음"), 헤더가 없거나
자기 모순이면 본문의 receipt.transaction으로 물러난다. 헤더 안의 판매자
문자열은 결과에 싣지 않는다. 자원은 content-type이 JSON일 때만 파싱하고 그
밖에는 문자열 그대로 돌려주며, 어느 쪽이든 bearer 값은 가린다. 결과에 실리는
content-type은 미디어 타입뿐이다 — 파라미터는 버린다. Content-Type도 판매자가
쓴 문자열이고 MCP 도구 출력까지 가므로, 거기에 주차된 bearer 값이 따라오지
못하게 한다.
온체인 pre-flight. 서명 전에 enforcer의 회계를 직접 읽어, 성공할 수 없는
결제를 미리 거른다. 한도는 어차피 온체인이 강제하므로 이 단계의 목적은 안전이
아니라 사유의 정확도다 — 판매자까지 갔다가 402를 받는 대신
payment of 2500000 exceeds 2000000 left in this period처럼 원인을 말한다.
성공할 수 없는 결제에 leaf를 서명하지 않는 부수 효과도 있다(leaf는 bearer
authorization이다).
pre-flight 판정(judgePreflight)은 순수 함수로 분리되어 있고, 체인 읽기는
콜백으로 주입된다. 상태 조회는 부모 permission의 모든 링크에 대해
readDelegationStatus로 수행한다 — root만 보면 재위임된 child의 더 좁은 한도를
놓친다. 판정 규칙 두 가지가 테스트로 고정되어 있다: 비활성 사유가 한도보다
우선한다(어떤 금액으로도 쓸 수 없는 permission을 LIMIT_EXCEEDED로 보고하면
운영자가 원인이 아닌 한도를 조정하게 된다), 그리고 한도는 체인의 최솟값이지
root의 값이 아니다. 최솟값은 링크 사이에서만 취하지 않는다 — 링크마다 기간
잔량과 평생 총액 잔량 중 작은 쪽을 먼저 취하고(tightestRemaining), 그 값들의
최솟값이 한도다. 거절 사유는 둘을 구분해 적는다: 기간이면 기다리면 열리고,
총액이면 새 grant를 서명해야 한다.
지출 한도의 세 층. 온체인 caveat이 그중 두 층이다 — 기간
상한(ERC20PeriodTransferEnforcer)이 속도를, 평생
총액(ERC20TransferAmountEnforcer)이 이 위임이 통틀어 내보낼 수 있는 예산을
정한다. 기간 상한 한 칸은 며칠치를 담을 수 있고, 총액은 그 예산이 한 세션에 전부
나가는 것을 막지 않는다 — 각 결제가 개별적으로는 전부 합법이기 때문에, 잘못 든
자원 경로 하나가 그렇게 쓰는 것을 체인은 여전히 막지 않는다. 그래서 에이전트
런타임이 그 위에 세 한도를 env로 얹는다(createAgentSpendGate): 호출당 상한
AGENT_MAX_PAYMENT_MUSDC, 세션 누적 AGENT_SESSION_BUDGET_MUSDC, 수취처
허용목록 AGENT_ALLOWED_PAY_TO. 미설정은 그 한도가 없다는 뜻이고 코드가 임의의
기본값을 넣지 않는다 — 셋을 다 비우면 한도는 온체인 caveat 둘이며 그것이 원래
동작이다. 최종 한도는 어느 경우에도 체인이다.
강제는 leaf 서명 직전 한 곳이다. provider를 감싼 게이트 안이고, 판정과 예약이 같은 동기 블록에 있어 그 사이에 대기가 없다 — 그래서 동시 호출도 같은 예산을 나눠 쓴다. 판정과 누적을 두 호출로 나누면 그 틈이 생기고, 그때 세션 한도는 동시 호출 수에 비례해 무력해진다(예산 1.0에 5개 호출을 동시에 넣어 다섯 건 모두 서명된 측정값 5.0 tUSDC). provider를 감싸는 형태인 것은 새 호출 경로가 강제를 잊을 수 없게 하려는 것이다 — 판정을 부르는 별도 메서드였다면 그것을 부르지 않는 경로도 컴파일된다.
누적의 기준은 청구가 아니라 서명이다. 서명된 leaf는 bearer 권한이므로 판매자가 자원을 주지 못했더라도 facilitator는 그것을 청구할 수 있다 — 정산 성공만 세면 실패한 왕복마다 예산이 되살아나 한도가 한도가 아니게 된다. 되돌리는 경우는 하나뿐이다: 서명 자체가 던진 경우. 존재하지 않는 leaf는 청구될 수 없다.
거절의 이름은 두 가지다. 결제 루프가 서명 전에 물어보는 선판정(judge)은 상태를
바꾸지 않고, 그 거절이 읽을 수 있는 코드 SPEND_POLICY_REFUSED가 된다. 예약을
선판정에서 하지 않는 이유는 선판정→서명이 한 쌍이라는 보장이 없어서다 —
apps/payment-scheduler의 실행기는 자기 provider 안에서 스케줄 조건을 먼저 보고
거절할 수 있고, 선판정이 예약했다면 그 거절마다 예산이 한 조각씩 영구히 사라진다.
그래서 선판정을 통과한 뒤 다른 호출이 예산을 먼저 가져간 결제는 서명 직전에 걸려
SIGNING_FAILED로 보고되고, detail이 어느 한도였는지 이름으로 말한다 — 강제가
늦은 것이 아니라 보고가 한 단계 거친 것이다. 세션은 런타임 인스턴스의 수명, 즉 MCP
서버 프로세스의 수명이며, 재시작을 넘겨 남아야 하는 예산은 apps/payment-scheduler가
DB에 들고 있다. 변수별 판정과 운영 규칙은
MCP 연결 가이드 §3.1에 있다.
런타임 동작 두 가지:
- 런타임 로딩은 lazy이며 성공만 캐시한다. 부팅 시점의 env·네트워크 실패가 프로세스를 죽이는 대신 tool 결과로 사유가 반환되고, 환경을 고치면 재시작 없이 복구된다.
- stdout은 JSON-RPC 채널이다. 로깅은 전부 stderr로 나간다.
Studio (지갑 모듈)
두 화면 모두 데이터를 체인에서 직접 읽는다.
| 화면 | 출처 |
|---|---|
| 위임·한도 | ERC20PeriodTransferEnforcer.getAvailableAmount (남은 주기 잔액), caveat terms (한도·유효창), DelegationManager.disabledDelegations (회수 여부) |
| 영수증 | TransferredInPeriod 이벤트 |
캡을 소모한 정산은 반드시 이 이벤트를 남기므로 영수증에 별도 원장이 필요 없다. 남은 잔액을 오프체인에서 자체 집계하지 않는 이유는 그것이 두 번째 진실이 되어 실제로 강제하는 쪽과 어긋날 수 있기 때문이다.
유효창 해석에는 TimestampEnforcer의 0 값 의미가 반영되어 있다 — enforcer는
유효창의 각 절반을 > 0일 때만 검사하므로, term의 0은 1970이 아니라
무제한이다.
영수증 조회 창. 조회는 fromBlock을 필수 인자로 받는다. GIWA는
eth_getLogs 10만 블록 초과를 거절하므로 무제한 기본값은 실패하거나 잘린 이력을
완전한 것처럼 반환하게 된다. 기본 창은 50,000 블록이며, GIWA의 블록 생성이 약
1초에 1개(31634888→31634935 구간 측정)이므로 하루가 되지 않는다. 그래서 화면
헤더와 빈 목록 문구가 창이 열린 시각을 함께 표시하고, 그 시각은 가정한
블록타임이 아니라 fromBlock 블록의 타임스탬프를 체인에서 읽어 쓴다. 노드가 그
블록을 주지 못하면(pruned) 문구는 블록 수 표기로 후퇴하고 화면은 유지된다.
fromBlock === 0이면 "전체 이력"으로 표기한다. 창은 50,000
블록으로 고정이며, Studio는 페이징하지 않고 패널에 그렇게 적혀 있다.
회수의 경계. DeleGatorCore.disableDelegation은 onlyEntryPointOrSelf라
owner EOA가 직접 호출할 수 없고 EntryPoint UserOperation이어야 한다. 두 분기
모두 수트가 실행한다 — self 분기는 impersonation으로 결과(회수 후
disabledDelegations가 참, 동일 결제가 PERMISSION_INACTIVE로 거절)를
증명하고, EntryPoint 분기는 실제 owner 키로 서명한 UserOperation을
handleOps로 태운다. 이 UserOperation의 callData는
buildRevocationCall(...).data 그대로이며 execute()로 감싸지 않는다 — 감싸면
EntryPoint → execute → self 호출이 되어 이미 덮은 self 분기로 되돌아간다.
각 의존 요소에는 대조군이 붙는다.
| 대조군 | 증명 대상 | 실제 결과 |
|---|---|---|
revocation-userop |
정상 경로 | 성공 — UserOperationEvent.success == true, disabledDelegations 참 |
revocation-userop-unfunded |
예치금이 실제 게이트다 | FailedOp(0,AA21 didn't pay prefund) |
revocation-userop-wrong-signer |
계정이 owner()를 검증한다 |
FailedOp(0,AA24 signature error) |
revocation-userop-tampered-field |
서명된 entryPoint 필드가 유효하다 |
FailedOp(0,AA24 signature error) |
revocation-submitter |
JSON 와이어 제출이 검증기를 거쳐 회수된다 | 성공 — 검증된 struct가 서명된 struct와 9필드 동일 |
revocation-submitter-foreign-sender |
타 계정 회수는 체인 읽기 전에 거절 | sender is not the account this submitter serves |
제출 엔드포인트 (apps/revocation-submitter). handleOps는 누구나 호출할
수 있고 릴레이어가 가스를 선지급하므로, 받은 것을 그대로 전달하는 서비스는 타인
자금으로 구동되는 범용 UserOperation 릴레이가 된다. validateRevocationSubmission이
이를 한 계정의 한 연산으로 좁힌다 — sender 허용목록, 루트의
delegator == sender, initCode·paymasterAndData 빈 값 강제, 가스 4종 상한,
그리고 callData의 재인코딩 바이트 일치. 마지막 검사가 decode가 아닌 이유는
decode가 뒤에 덧붙은 바이트를 통과시키기 때문이다.
서명은 의도적으로 오프라인에서 검증하지 않는다. 계정이 HybridDeleGator라
ERC-1271로 검증되므로 오프라인 ecrecover는 계정과 조용히 어긋날 수 있다.
서명의 권위는 브로드캐스트 전 시뮬레이션에서 EntryPoint가 돌려주는 AA24다.
judgeSubmissionReadiness는 체인 상태에서 판정 가능한 거절 사유를 구분해
반환한다 — prefund_short(payer는 설계상 ETH 0이라 예치금이 유일한 재원이며,
가장 흔한 상태), fee_below_basefee(EntryPoint는
min(maxFeePerGas, baseFee+priority)로 보전하는데 릴레이어의 트랜잭션은
baseFee 아래로 포함되지 못하므로, 그대로 태우면 성공하면서 운영자만 손해를
본다), base_fee_unreadable(base fee를 읽지 못한 상태 — 재서명이 아니라 재시도
대상이므로 앞의 것과 사유를 분리한다), relayer_unfunded.
성공 판정은 receipt status가 아니라 UserOperationEvent.success를 직접
확인한다. EntryPoint는 내부 호출의 revert를 UserOperationRevertReason으로
흡수하고 트랜잭션 자체는 성공시키므로(EntryPoint.sol:340-353), receipt만 보면
disableDelegation이 revert해도 성공으로 읽힌다.
서비스 기동 검증 (bun run test:e2e:revoke). 검증기와 온체인 강제는 단위
테스트와 반례 수트가 덮지만, 프로세스 자체의 기동 — env 파싱, 배포 아티팩트
읽기, 부팅 시 릴레이어 대조, /health, single-flight, simulate→broadcast — 은
별도 e2e가 GIWA fork 위에 서비스를 실제로 띄워 왕복한다. 케이스 수는 수트가
스스로 세어 PASS — N cases (ABC…)로 출력한다.
이 수트의 두 설계가 비자명하다. 첫째, 리플레이 방어는 두 케이스로 나뉜다.
같은 바디의 재전송을 막는 첫 방어선은 예치금 게이트인데, 그 상태에서 nonce는
실행된 적이 없다. 그래서 예치금을 다시 채워 게이트를 치운 뒤 동일 바디를
재전송하고, 남은 유일한 방어선인 EntryPoint nonce가
AA25 invalid account nonce로 끊는 것까지 확인한다. 둘째, 성공 케이스는
릴레이어의 수지를 검증한다. GIWA에서 잘 알려진 Anvil 개발 주소들은 EIP-7702
designator가 붙어 있고 그 대상은 유입 잔액을 전액 이체하는 스위퍼다.
EntryPoint._compensate가 beneficiary에게 call{value:…}로 지급하므로 그런
주소를 beneficiary로 쓰면 handleOps 한 번에 릴레이어가 비워진다(fork 측정:
1 ETH → 0.00024 ETH, 트랜잭션 비용 0.00017 ETH). 수트는 beneficiary 주소에
코드가 없음을 시작 시점에 강제한다.
브라우저 레그도 응답을 직접 확인한다. 브라우저 클라이언트(로컬 dev :5173)와 제출기(:8082)는 출처가
다르고 요청이 content-type: application/json을 실으므로 브라우저는 preflight를
먼저 보낸다 — preflight가 실패하면 POST는 나가지 않는다. 수트는 허용된 출처의
preflight가 204인지, 낯선 출처가 403인지, Origin 없는 요청(서버 사이드
호출)이 그대로 동작하는지를 각각 검사한다.
Studio 회수 버튼 (apps/web/src/dapp/RevokeButton.tsx). 지갑 연결 → owner() 대조 → nonce 읽기 → 빌드
→ signTypedData → 제출 엔드포인트 POST. 설계 결정 세 가지: (1) 서명 전에
연결 지갑을 계정의 owner()와 대조한다(HybridDeleGator.sol:233) — 다른
지갑의 서명은 EntryPoint에서 AA24로 나타나 nonce·가스 문제와 구별되지 않는다.
(2) nonce를 클릭 시점에 읽고 한 번에 빌드한다 — 빌드와 서명 사이에 값이
다시 읽히면 digest가 낡고 역시 AA24가 된다. buildRevocationUserOperation이
순수 함수인 이유다. (3) 와이어 바디는 제출 엔드포인트가 검증에 쓰는 것과 같은
모듈의 buildRevocationSubmissionBody가 만든다 — 인코더와 디코더가 갈라지지
않도록 라운드트립 테스트가 바이트 단위 재현을 고정한다.
버튼이 잠기는 사유는 각각 다른 문구를 표시한다 — 회수 엔드포인트 미설정, 이미 회수됨, 지갑 미연결, 체인 불일치, 소유자 아님. 예치금 부족은 잠금 사유가 아니다 — 공개 경로에서는 스폰서가 회수 시점에 예치를 채우기 때문이고, 그게 스폰서드 모드의 존재 이유다. 소유자 불일치는 가장 먼저 알린다 — 지갑은 화면 앞의 사람이 바꿀 수 있는 유일한 요소이기 때문이다.
검증되지 않은 구간: 지갑 확장이 서명 요청 구조체(9개 필드)를 사람이 읽을 수 있게 렌더링하는지는 실제 지갑을 띄워야 확인된다. 자동화가 덮지 못하는 유일한 구간으로 남아 있다.
자력(핀 모드) 회수의 재원 상태 — EntryPoint 예치금, 1회 필요액
(revocationPrefund(DEFAULT_REVOCATION_GAS)), 부족분 — 는 제출 엔드포인트의
/health가 답한다. 과거 D6 콘솔이 이 값들을 화면에 상시 표시했고(0일 때도 —
가스리스가 핵심 주장인 이상, 값이 0이 아닐 때만 나타나는 행은 불변식 준수를
확인할 수 없는 행이다), 그 원칙은 Studio의 상태 표시에 승계되어 있다.
킬 스위치의 가스 재원. 결제는 EntryPoint를 거치지 않는다 — relayer가
redeemDelegations를 직접 호출하므로 payer의 zero-ETH 불변식은 결제에 대해
유지된다. 회수만은 EntryPoint를 피할 수 없고, EntryPoint는 계정의 native 잔액이
아니라 예치금(StakeManager.deposits)에서 가스를 걷는다.
DeleGatorCore._payPrefund(:559-566)는 실패한 송금을 흡수하므로 예치금이
없으면 계정이 아니라 EntryPoint가 AA21로 거절한다 — AA23이 아니다. EntryPoint.depositTo(address)는 접근
제어 없는 public payable이라 relayer가 타 계정의 예치금을 채울 수 있고, 이때
payer의 native 잔액은 0으로 유지된다. 단 withdrawTo는 deposits[msg.sender]를
읽으므로 relayer가 회수할 수 없는 편도 비용이다. 로컬에서 회수 경로를 완주하는
절차는 회수 런북에 있다.
Framework 킬 스위치. 회수가 위임 하나를 끊는다면
DelegationManager.pause()는 프레임워크 전체를 멈춘다(onlyOwner — 예치금이
필요 없는 평범한 EOA 트랜잭션). 방어는 두 겹이다: facilitator의
verifyFrameworkOperationalState가 매 요청마다 paused를 확인해 정산 전에
거절하고, 온체인에서는 redeemDelegations에 걸린
whenNotPaused(DelegationManager.sol:132)가 게이트 우회조차 revert시킨다.
fork에서 owner를 impersonate해 pause()를 실행하면 /health가 ok=false,
frameworkError=framework_paused, frameworkPaused=true를 보고하고, 결제는
판정이 아니라 준비 안 됨(/verify 503 facilitator_not_ready)으로 돌려보내져
에이전트가 SELLER_UNAVAILABLE(자금 불변, 나중에 재시도)을 받는 것까지 수트가
확인한다. 같은 답이 /settle에도 있다: 브로드캐스트 전 단계(시뮬레이션·가스
견적·수수료 조회)에서 RPC가 끊기면 거절이 아니라 200 facilitator_not_ready로
답하고 원장 행을 남기지 않는다 — 판정도, 청구도 없었기 때문이다. 브로드캐스트
뒤의 실패는 해시를 실은 settlement_pending이다.
재현
bun run check # 키·네트워크 없이 전 계층 회귀
cd apps/delegation-lab
bun run test:negative # caveat 케이스 — 기본 타깃은 일회용 체인
SUITE_TARGET=fork bun run test:negative # 같은 케이스를 GIWA fork 위에서
bun run test:e2e:mcp # 결제 완주 → 한도 초과 pre-flight 거절 → pause → 회수
bun run test:e2e:revoke # 제출 엔드포인트를 실제로 띄워 왕복
SUITE_FORK_BLOCK=<최근 블록> bun run test:e2e:bootstrap # 온보딩 서비스 16케이스
bun run preflight:giwa # GIWA 헤드 상태 읽기 전용 GO/NO-GO
test:negative의 기본 타깃은 일회용 체인이다. GIWA fork 타깃은
SUITE_TARGET=fork로 별도 실행해야 하며, 한 줄이 두 타깃을 모두 돌지 않는다.
네 수트 모두 통과 판정과 함께 케이스 수를 스스로 세어 출력한다
(N/N cases passed, PASS — N cases (ABC…), GO — N개 조건 전부 충족).
실행 요건은 명령마다 다르다. bun run check와 test:negative는 키·네트워크·
배포 아티팩트 없이 깨끗한 클론에서 돈다 — test:negative는 일회용 Anvil에
38유닛 Framework를 직접 배포해 검사한다. 반면 test:e2e:mcp는 owner가 서명한
root permission 아티팩트를 요구하므로, 배포된 계정을 소유한 지갑 없이 맨
클론에서는 돌지 않는다. test:e2e:bootstrap은 GIWA fork에 계정을 새로 배포하므로
어떤 캐시에도 없는 상태를 읽는다 — 최근 블록을 SUITE_FORK_BLOCK으로 넘겨야
한다(GIWA는 오래된 상태를 prune한다).
test:e2e:mcp는 자식 프로세스가 loopback RPC에 고정되지 않으면 시작하지 않고,
종료 후 실제 GIWA relayer nonce를 다시 읽어 아무것도 브로드캐스트되지 않았음을
확인한다.
fork 소스의 자격증명은 argv에 노출되지 않는다. 사설 GIWA 엔드포인트는 URL
경로에 API 키를 담으므로 URL 전체가 크리덴셜이고, argv는 ps로 노출된다.
anvil --fork-url에는 환경변수 별칭이 없으므로
apps/delegation-lab/fork-source-proxy.ts가 키를 메모리에 보관하고 anvil에는
키 없는 http://127.0.0.1:<임시포트>를 전달한다. fork를 띄우는 네 곳 모두 이
경로를 사용한다.