회수(revocation) 런북
킬 스위치를 로컬에서 완주시키고 검증하는 절차. 설계 근거와 반례표는 기술 노트 §2 Studio·회수에 있고, 여기서는 중복하지 않는다.
GIWA 활성화 절차(예치금 arming, 서비스 배포, 키 보관)는 이 문서에 없다 — 운영 비밀값이 필요하므로 내부 런북에서 다룬다.
0. 한 줄 요약
소유자가 UserOperation 하나에 서명하고, 제출 엔드포인트가 그것을 EntryPoint에
실어 disableDelegation을 태운다. 그 뒤로 같은 위임을 쓰는 모든 결제가 끊긴다.
소유자의 지갑은 가스를 내지 않는다. 지불 계정도 ETH를 들지 않는다. 회수 비용은 EntryPoint에 미리 넣어둔 예치금에서 나간다.
예치금을 누가 채우느냐가 보장의 등급을 가른다
같은 엔드포인트에 두 갈래가 있고, 둘은 편의성이 아니라 보장의 세기가 다르다.
- 계정이 예치금을 미리 채워둔 경우 — 검열 불가.
handleOps에는 접근 제어가 없고 EntryPoint는 실행한 사람에게 예치금에서 비용을 지급한다. 즉 채워진 예치금은 누구나 가져갈 수 있는 현상금이고, 우리 서비스가 죽어 있거나 회수를 거부해도 제3자가 대신 실어 보내면 회수는 성사된다. 우리 릴레이어는 특권이 아니라 여러 운반자 중 하나다. - 스폰서가 회수 시점에 채우는 경우(공개
/revoke) — 검열 불가가 아니다. 예치금이 0인 계정은 우리가 채워주기 전까지 실어 줄 사람이 없다. 서비스가 꺼져 있거나, 예산이 소진됐거나, 운영자가 거부하면 회수는 일어나지 않고 온체인에 흔적도 남지 않는다.
두 번째를 그럼에도 만든 이유는, 그 사용자들에게 대안이 회수 없음이기 때문이다. 스폰서드 온보딩으로 들어온 계정은 설계상 GIWA ETH를 한 푼도 들지 않는다. 첫 번째 갈래로 올라서는 방법은 하나뿐이다 — 소유자가 자기 계정의 EntryPoint 예치금을 직접 채우는 것. 실제 가치를 태우는 위임이라면 그렇게 하는 편이 맞고, 문서는 그렇게 권해야 한다.
1. 사전 조건
anvil |
Foundry 설치본 (foundryup) |
| 네트워크 | GIWA fork를 뜨기 위한 아웃바운드 RPC 접근 |
| 포트 | 8547(anvil), 8183(제출 엔드포인트) 비어 있을 것 |
| 비밀값 | 없음. 계정·소유자·에이전트·릴레이어 키를 전부 그 자리에서 파생한다 |
공개 GIWA RPC는 rate limit이 있다. 사설 엔드포인트가 있으면
apps/delegation-lab/.env의 GIWA_FORK_SOURCE_RPC_URL로 넘기고 SUITE_CUPS를
올린다. 값은 인자가 아니라 환경변수로만 전달한다 — 제공자 키가 URL 경로에
들어 있는 경우가 많아 ps와 셸 히스토리에 그대로 남는다.
2. 로컬 완주
cd apps/delegation-lab
bun run test:e2e:revoke
apps/revocation-submitter를 실제 프로세스로 띄워 아래 표의 케이스를 순서대로
왕복한다. 수트가 통과한 케이스 글자를 세어 마지막 줄에
PASS — N cases (ABC…)로 출력하며, 이 표가 그 글자의 정본이다.
유닛 테스트가 검증기를, 반례 수트가 온체인 강제를 각각 덮지만, 서비스 자체가
부팅되는지 — env 파싱, 배포 아티팩트 읽기, 시작 시 릴레이어 확인, /health,
single-flight, simulate→broadcast, UserOperationEvent.success 판정 — 는 이
명령만이 실행한다.
| 케이스 | 증명 대상 | 기대 응답 |
|---|---|---|
| A 예치금 없음 | 예치금 게이트가 쓰기 전에 답한다 | 409 prefund_short + 부족분 |
| B 남의 계정 | 체인을 읽기도 전에 거절 | 400 invalid_submission |
| C 정상 | 실제로 회수된다 | 200 + tx, disabledDelegations 참 |
| D 재요청 | 예치금이 실제로 소모됐다 | 409 prefund_short |
| E 재충전 후 재요청 | 리플레이를 막는 건 nonce다 | 502 + AA25 invalid account nonce |
| F 브라우저 preflight | 회수 버튼이 브라우저에서 닿을 수 있다 | 204 + 정확한 allow-origin·content-type |
| G 낯선 출처 preflight | 허용 목록에 없는 출처는 거절 | 403, allow-origin 없음 |
| H Origin 없는 요청 | CORS 가드가 스크립트를 깨지 않았다 | 200 |
| I 스폰서드 /health | 모드·스폰서·예산을 밝히고 payer는 없다 | mode: "sponsored" + 예산 |
| J 스폰서드 회수 | ETH 0·예치금 0 계정이 회수되고 스폰서가 낸다 | 200 + tx, 스폰서 지출·잔여 예치 측정 |
| K 비소유자 서명 | 계정의 ERC-1271이 예치 전에 거절한다 | 403 invalid_account_signature, 스폰서 nonce 불변 |
| L 완료 후 재제출 | 끝난 회수는 한 푼도 쓰지 않고 거절 | 409 already_revoked, 추가 예치 없음 |
| P 수수료 300 wei | 수수료 하한이 릴레이어 손실을 막는다 | 400 invalid_submission, 스폰서 nonce 불변 |
| M 예산 1 wei | 일일 예산이 실제 상한이다 | 503 budget_exhausted, 위임은 그대로 |
| N 닫힌 응답 본문 | 공개 모드는 detail을 싣지 않는다 |
400 invalid_submission, detail 없음 |
| O rate 1/시간 | 두 번째 요청부터 끊긴다 | 429 rate_limited |
| Q facilitator 서명자 공유 | 정산 키를 물려받은 기동은 아예 뜨지 않는다 | 부팅 실패(0이 아닌 종료 코드) |
| R 옛 변수명 | 폐기된 RELAYER_ADDRESS 철자가 보이면 뜨지 않는다 |
부팅 실패(0이 아닌 종료 코드) |
A–H는 핀 모드(단일 payer, loopback 운영용)로, I–R은 같은 바이너리를
PAYER_ACCOUNT_ADDRESS 없이 재기동한 스폰서드 모드(공개 터널용)로 돈다.
스폰서드 케이스가 새 계정을 쓰는 이유: 첫 계정의 예치금은 E가 재충전해 두어
부족분이 0이고, 그 상태로는 이 모드의 존재 이유인 예치 대납 레그가 아예 돌지
않는다.
D와 E가 분리된 이유가 이 수트에서 가장 비자명하다. D만 있으면 "리플레이가
막혔다"고 말할 수 없다 — D를 막은 것은 체인 앞단의 예치금 게이트이고, nonce는
실행된 적이 없다. E는 예치금을 다시 채워 그 게이트를 치운 뒤 같은 바디를 그대로
다시 보낸다. 그러면 남는 방어선은 EntryPoint의 nonce 하나뿐이고, 그 nonce가
실제로 AA25로 끊는다.
케이스 C는 릴레이어의 수지까지 확인한다. 트랜잭션이 성공했다는 것만으로는 릴레이어가 보전됐다는 뜻이 아니라서다(§4).
K가 스폰서드 모드의 축이다. EntryPoint는 서명보다 예치금을 먼저 검사하므로
(AA21이 AA24보다 앞), 예치금 없는 계정에서는 시뮬레이션이 서명 판정에
도달하지 못한다 — 먼저 예치하면 쓰레기 서명 하나하나가 실제 depositTo를
소모한다. 그래서 스폰서드 경로는 예치 전에 계정 자신의
getPackedUserOperationTypedDataHash + isValidSignature 두 번의 eth_call로
서명을 묻는다. 판정 주체는 여전히 체인이고, 달라진 것은 시점뿐이다.
J가 측정하는 잔여 예치(leftover) 는 이 설계의 비용 상한이다. EntryPoint는
안 쓴 선납분을 요청자 계정의 예치금으로 환급하므로, 스폰서드 프로파일
(SPONSORED_REVOCATION_GAS, 기본 프로파일의 1% 수수료)이 그 선물의 상한을
정한다 — 요청당 최대 0.000007 ETH, 일일 예산이 총량을 다시 묶는다.
P는 같은 수수료가 하한이기도 한 이유다. EntryPoint의 릴레이어 보전은
min(서명된 maxFeePerGas, tip + baseFee)인데 릴레이어 자신의 트랜잭션은
baseFee + tip으로 나간다. GIWA 실측으로 base fee는 267 wei, 권장 tip은
1,000,000 wei — 세 자릿수 차이다. 하한이 없으면 base fee 바로 위(예: 300 wei)로
서명한 요청이 fee_below_basefee를 통과해 낸 것의 1/3700만 보전받고, 수수료가
낮으면 선납금도 작아 일일 예산조차 그것을 거의 세지 않는다. 싸게 공격할수록
유일한 상한이 덜 묶는 구조라, 하한은 예산이 아니라 오퍼레이션 쪽에 둔다.
릴레이어의 handleOps 브로드캐스트도 그 오퍼레이션의 maxFeePerGas로 상한을
잡아 보전액이 지출의 상한이 되게 한다.
3. 실패를 읽는 법
| 증상 | 원인 | 조치 |
|---|---|---|
409 prefund_short |
예치금 부족. payer는 설계상 ETH 0이라 기본 상태다 | EntryPoint.depositTo(payer)로 arming |
409 fee_below_basefee |
서명된 maxFeePerGas가 현재 base fee 아래 |
다시 빌드·서명. 그대로 태우면 성공하면서 운영자만 잃는다 |
409 relayer_unfunded |
릴레이어 잔액 부족 | 릴레이어 충전 |
400 invalid_submission |
검증기가 거절. 메시지가 필드를 지목한다 | 바디를 buildRevocationSubmissionBody로 다시 생성 |
502 + AA24 signature error |
서명자가 계정의 owner()가 아니거나 digest가 낡음 |
연결 지갑 확인. nonce를 다시 읽고 한 번에 빌드·서명 |
502 + AA25 invalid account nonce |
이미 쓴 UserOperation | 정상 동작. 이미 회수됐는지 확인 |
403 invalid_account_signature |
(스폰서드) 계정의 ERC-1271이 서명을 거절 — 예치 전에 끊은 것 | 소유자 지갑으로 다시 서명 |
409 already_revoked |
(스폰서드) 이미 끝난 회수의 재제출 | 정상 동작. 할 일 없음 |
409 sender_busy |
(스폰서드) 같은 계정의 다른 회수가 진행 중 | 잠시 후 재시도 |
429 rate_limited |
(스폰서드) IP 또는 계정 단위 빈도 초과 | 잠시 후 재시도 |
503 budget_exhausted / sponsor_unfunded |
(스폰서드) 일일 예산 소진 또는 스폰서 잔액 부족 | 운영자가 예산·잔액 확인 |
스폰서드 모드의 응답 본문은 닫힌 enum이다 — detail.message가 없다. 핀
모드는 loopback에서 소유자 자신에게 답하므로 검증 메시지를 싣지만, 공개 모드에서
viem 에러 문자열은 전송 URL(경로 키 포함)을 통째로 품을 수 있어 본문에 싣지
않는다. 자세한 원인은 서비스 stderr에만 남는다.
AA24가 특히 헷갈린다 — nonce나 가스 문제처럼 읽히지만 대개 다른 지갑으로
서명했거나 빌드와 서명 사이에 값이 다시 읽힌 경우다. Studio가 서명 전에
owner()를 대조하는 이유가 이것이다.
4. 함정 — 잘 알려진 Anvil 키를 릴레이어로 쓰지 말 것
GIWA에서 0xf39Fd6e5…92266과 0x70997970…c79C8은 EIP-7702 designator
(0xef0100…)를 달고 있고, 그 대상은 들어온 잔액을 즉시 전액 전송하는 스위퍼다.
서명자로 쓰면 서명 검증이 ERC-1271로 새는 것으로 드러나 금방 눈에 띈다. 문제는
릴레이어로 쓸 때다. EntryPoint._compensate가 beneficiary에게
beneficiary.call{value: …}로 지급하므로 스위퍼 코드가 실행되고, handleOps
한 번에 릴레이어가 빈다.
측정값(fork): 릴레이어 1 ETH → 0.00024 ETH, 같은 블록의 트랜잭션 비용은
0.00017 ETH. debug_traceTransaction의 중첩 CALL에서 잔액이 빠져나갔다.
증상이 수수료 추정 버그처럼 보이고, 실제 실패는 몇 케이스 뒤 "insufficient
funds"로 나타나 엉뚱한 곳을 보게 만든다.
그래서 이 수트는 릴레이어 키까지 파생해서 쓰고, 시작 시 릴레이어 주소에 코드가 없는지 확인하며, 케이스 C에서 가스 대비 잔액 변화를 검증한다.
5. Studio에서
cd apps/web && bun run dev
Studio의 회수 버튼은 스폰서드 엔드포인트(VITE_REVOCATION_SUBMITTER_PUBLIC_URL)를
탄다. 값은 경로 없는 origin이어야 한다 — 클라이언트가 /revoke를 스스로
붙이므로, /revoke가 이미 붙은 값은 런타임에 /revoke/revoke 404가 된다. 지금은
가드가 그런 값을 이름으로 거부한다. 로컬에서 완주하려면 스폰서드 모드 제출기를
loopback에 띄우고 http://127.0.0.1:8082를 넣으면 된다.
엔드포인트가 없으면 버튼은 비활성 상태로 남는다. 보낼 곳 없이 지갑 서명을 받는 것은 버튼이 없는 것보다 나쁘다 — 그 서명은 위임을 끄는 bearer 권한이기 때문이다. 버튼이 잠기는 상태는 각각 다른 문장을 보여준다 — 엔드포인트 미설정, 이미 회수됨, 지갑 미연결(이때는 연결 버튼이 된다), 네트워크 다름, 소유자 아님. 예치금 부족은 이 목록에 없다 — 예치금을 채우는 일이 바로 스폰서가 하는 일이다.
VITE_REVOCATION_SUBMITTER_URL(핀 모드 제출기)은 별도 변수로 남아 있고 상태
표시에만 쓰인다. 이 값은 loopback만 허용하며 빌드 시점에 강제된다 — 핀 모드
제출 엔드포인트에는 애플리케이션 인증이 없고 릴레이어 키를 들고 있다.
브라우저 레그 — cross-origin preflight
로컬에서 Studio는 :5173, 제출기는 :8082다. 포트가 다르면 다른 출처이고, 회수
요청이 content-type: application/json을 실어 보내므로 브라우저가 preflight를
먼저 보낸다. 그 preflight가 실패하면 POST는 아예 나가지 않는다 — 버튼은 살아
있어 보이고 아무 일도 일어나지 않으며, 페이지에도 서버 로그에도 흔적이 남지 않는다.
제출기는 REVOCATION_CONSOLE_ORIGINS의 출처만 답한다. 기본값은 vite(5173)와
vite preview(4173)를 loopback 두 표기로 덮는다. Studio를 다른 포트에서 띄웠다면
이 값을 같이 바꿔야 한다.
*는 거부한다. 이 서비스는 애플리케이션 인증이 없고 자금이 든 릴레이어 키를 들고
있다. 와일드카드는 운영자가 열어 둔 아무 페이지에나 이 서비스의 가스를 쓸 길을 준다.
validateRevocationSubmission이 소유자 서명이 아닌 것을 이미 거절하므로 위조 경로는
아니지만, 재전송으로 가스를 태우는 경로는 열린다.
서버 사이드 fetch는 CORS를 강제하지 않으므로, 스크립트 검증만으로는 이 간극이
드러나지 않는다. revocation-submitter-e2e.ts의 케이스 F/G가 preflight 응답을,
케이스 H가 Origin 없는 요청을 직접 확인해 그 간극을 덮는다.
6. 백스톱
| 범위 | 수단 | 접근 제어 |
|---|---|---|
| 위임 하나 | disableDelegation (본 문서) |
onlyEntryPointOrSelf |
| 프레임워크 전체 | DelegationManager.pause() |
onlyOwner — 평범한 EOA 트랜잭션 |
pause()는 예치금이 필요 없다. 예치금 arming을 못 한 상태에서의 백스톱이다.
게이트(verifyFrameworkOperationalState)와 온체인(whenNotPaused) 양쪽이 막는다.
지갑 레그
회수 경로에서 자동화가 덮지 못하는 곳은 정확히 하나였다 — 사람이 지갑 승인
화면을 보고 승인하는 구간. revocation-submitter-e2e.ts는 전 케이스를
완주하지만 서명은 viem LocalAccount가 만들고, 지갑 확장이 하는 일(사람에게
렌더링, domain.chainId 강제, 계정 전환, 사용자 거절)은 그 경로에 없다.
이 레그는 GIWA fork 위 지갑 랩으로 먼저 검증했고(당시 도구는 콘솔과 함께
폐기됐다), 2026-08-04 라이브 GIWA에서 실제 소유자 지갑으로 완주됐다 — 스폰서드
/revoke를 통해 payer의 위임이 DisabledDelegation으로 비활성화됐고, 소유자
지갑의 GIWA ETH는 처음부터 끝까지 0이었다. fork로 충분했던 이유는 서명이
오프라인 EIP-712이고 fork도 chain id 91342라, 지갑이 서명하는 다이제스트가
라이브의 것과 바이트 단위로 같기 때문이다 — 라이브가 추가로 준 것은 채굴된
트랜잭션과 익스플로러 링크뿐이었고, 실측값은 fork e2e 케이스 J와 wei 단위까지
일치했다.