권한은 넘어가고
신원은 남습니다
에이전트 결제의 권한 레이어. 실명이 검증된 사람만 범위를 정해 지출 권한을 넘길 수 있고, 범위는 결제 순간마다 컨트랙트가 강제하며, 취소는 즉시 반영되고, 사고는 허락한 사람까지 역추적됩니다.
01요약
에이전트가 스스로 결제하는 경로는 이미 열려 있습니다. x402는 공개된 지 1년 남짓 만에 지난 30일에만 온체인 결제 7,541만 건이 지나갔고(x402.org 공표, 2026-08-07 조회), 표준을 관리하는 재단에는 한국 결제사 세 곳이 이름을 올렸습니다.
정작 막혀 있는 것은 결제가 아니라 권한을 넘기는 방법입니다. 지금 쓸 수 있는 방법은 두 가지뿐입니다. 개인키를 통째로 넘기거나, 수탁 서비스에 맡기는 것입니다. 앞의 방법으로는 한도도 수취인도 기한도 걸 수 없고, 뒤의 방법은 온체인 결제를 하려고 다시 중개인을 세우는 셈이 됩니다.
마패는 세 번째 방법을 제안합니다. 위임 메커니즘을 새로 만들지는 않았습니다. ERC-7710/7715와 x402를 그대로 쓰고, 거기에 어디에도 없던 조건 하나를 더합니다. 바로 위임한 사람이 누구인가입니다.
이미 배포된 위임 프레임워크들은 감사받은 지출 조건을 수십 종 갖추고 있습니다. MetaMask 하나만 해도 37종입니다. 그런데 위임을 신원에 거는 조건은 그중 하나도 없습니다. 조건으로 걸 만한 온체인 신원을 가진 체인이 없었기 때문입니다. 기와에는 도장이 있습니다.
| 컨트랙트 | 9 | GIWA Sepolia 배포 · 전수 소스 검증 |
| 테스트 | 177 | 포크·불변식·3언어 바이트 패리티 포함, 정적 분석 심각·중대 0 |
| 결제 조건 | 6 | 전부 라이브 증명. 그중 셋(신원 · 수취인 집합 · 회당)은 배포된 프레임워크가 표현하지 못하던 조건입니다 |
| 발급된 마패 | 89 | 사람이 서명해 발급한 지출 권한 |
| 온체인 거부 | 112 | 실패한 결제가 사유와 함께 체인에 남아 있습니다. 거부는 감출 것이 아니라 이 시스템이 내놓는 결과입니다 |
02문제
두 방법의 결함은 같습니다. 범위를 정할 수 없다는 것입니다. 개인키를 넘기면 에이전트가 곧 지갑이 되고, 수탁에 맡기면 온체인 결제를 하려고 다시 중개인을 세우게 됩니다.
이 시장이 얼마나 빠르게 커지고 있는지는 추정치가 아니라 실제 집계로 확인됩니다.
| x402 결제 (지난 30일) | 7,541만 건 | 거래액 2,424만 달러 · 구매자 9.4만 · 판매자 2.2만. x402.org가 공표하는 값, 2026-08-07 조회 |
| x402 재단 | 40개사 | Linux Foundation 산하, 2026-07-14 운영 개시. Premier에 Coinbase·Google·AWS·Visa·Mastercard·Stripe·Shopify·Cloudflare 등, General에 카카오페이·갈럭시아머니트리·헥토파이낸셜 |
물론 이 거래량의 상당 부분은 아직 테스트와 투기성 거래입니다. 하지만 그 사실은 오히려 논지를 분명하게 만듭니다. 한도도 신원도 확인하지 않는 결제 수단에는 그런 거래가 먼저 모이고, 실제 상거래는 책임질 주체가 생긴 뒤에야 들어오기 때문입니다. 마패가 만들려는 것이 그 층입니다.
2026년 8월 4일 Cloudflare가 발표한 Wallets는 이 문제를 인프라 사업자 쪽에서 봅니다. 그들이 적은 문제는 "에이전트에게는 API에 가입할 안정적인 식별자도, API 값을 낼 방법도 없다"입니다. 그래서 Account Wallet 아래에 Virtual Wallet을 두고, 자식 지갑의 한도를 부모가 정한 값으로 묶습니다. 결제는 x402로 나갑니다.
구조가 우리 재위임과 닮았다는 점이 흥미롭습니다. 다만 그 한도를 지키는 것이 그들의 인프라이고, 우리 쪽은 체인입니다. 부모를 넘으려는 자식이 어떻게 되는지를 우리는 세 단계 아래에서 실제 트랜잭션으로 보여 줄 수 있습니다(09절).
신원에 대해서는 그들이 직접 이렇게 적었습니다. "에이전트가 자신의 신원을 밝히는 것은
완전히 선택 사항"이며, 밝히더라도 research.example.cloudflare.pay 같은
이름입니다. 이름은 누가 발급했는지를 말해 주지 않습니다. 책임이 필요한
자리에 남는 이 공백이 이 문서가 다루려는 주제입니다.
덧붙이면, 이 글이 쓰인 시점에 Wallets는 아직 쓸 수 있는 상태가 아닙니다. 발표문 자체가 "곧 설정하고 사용할 수 있게 된다"고 적고 있고, 현재 열려 있는 것은 핸들 선점뿐입니다. 여기서 비교하는 것은 제품이 아니라 설계가 답하기로 한 질문입니다.
비수탁 방식 가운데 가장 가까운 것은 코인베이스의 Spend Permissions입니다. 잘 만들어져 널리 쓰이고 있으며, 무엇보다 지출 한도라는 개념이 무엇을 정하지 않은 채 남겨두는지 가장 빠르게 보여줍니다. 수취인을 받는 인자가 없습니다. 정확히 말하면 수취인이라는 개념 자체가 없습니다.
// SpendPermissionManager.spend()
_transferFrom(spendPermission.token, spendPermission.account,
spendPermission.spender, value);
자금은 spender에게 갑니다. 이것은 설계의 약점이 아니라 설계 그 자체입니다.
지출 권한이 답하는 질문은 이 계정에서 얼마가, 얼마나 자주 나갈 수 있는가이고,
체인의 관여는 spender가 자금을 받는 순간 끝납니다. 그 돈이 다음에 어디로 가는지, 누구의
권한으로 나갔는지는 체인이 보지 못하는 곳에서 정해집니다. 의도나 신원을 적어둘 자리로
extraData가 있지만, 매니저는 그 값을 해시에 넣을 뿐 읽지
않습니다. 거기에 무엇을 적든 그것을 지키게 하는 힘은 컨트랙트가 아니라 당사자
사이의 관행에서 나옵니다.
마패는 한도가 남겨둔 두 질문에 온체인에서 마저 답합니다. 어디로 갈 수 있는가, 그리고 누가 뒤에 서 있는가입니다.
한도만으로 부족한 이유는 사고가 난 다음에 드러납니다. 누가 이 권한을 줬는지가 남지 않으면 책임질 주체를 특정할 수 없고, 가맹점이 에이전트 결제를 받을 이유도 사라집니다. 이 마지막 조각을 가진 체인이 기와뿐입니다. 다른 곳에는 조건으로 걸 신원 자체가 없습니다.
규제가 지금 묻고 있는 것
먼저 분명히 해두겠습니다. 이 절의 어떤 문장도 규제 준수를 주장하지 않습니다. 마패는 인가받은 기관이 아니며, 어떤 보고 의무도 이행하지 않습니다. 말씀드리려는 것은 한 가지입니다. 지금 공개적으로 던져지고 있는 질문들에는 일관된 방향이 있고, 그 방향이 이 문서에서 설명하는 구조와 겹칩니다.
영국에서는 재무부가 2026년 7월 Modernising Payment Services Regulation 협의 문서를 냈습니다(PU 3657, 10월 6일까지 의견 접수). 3.35항에서 "PSRs는 AI가 나오기 전에 설계되었고 에이전트형 AI를 온전히 수용하지 못할 수 있습니다"고 적은 뒤, 두 가지 질문을 던집니다. 아래는 원문 그대로입니다.
Question 15 — 기존 결제서비스 규제는 에이전트 결제를 지원하기 위해 어떻게 바뀌어야 합니까? 예컨대 결제거래의 인증과 동의, 그리고 승인되지 않은 결제거래의 책임에 관한 조항을 손봐야 합니까?
인증과 동의, 그리고 승인한 적 없는 결제에 대한 책임입니다. 마패는 이 세 가지에 각각 대응합니다. 위임은 사람의 동의를 기계가 읽을 수 있는 범위로 옮겨 적은 것이고, 신원 조건은 그 결제를 정산되는 순간에 그 사람에게 묶어 줍니다. 범위를 벗어난 결제는 애초에 정산되지 않습니다.
한국에서는 개정 특정금융정보법이 2026년 8월 20일 시행되고, 금융위원회가 3월 30일 입법예고한 시행령·감독규정이 그 뒤를 단계적으로 따라옵니다. 여기서 날짜가 둘인데, 서로 같은 날이 아닙니다.
이번 8월에 세워지는 것은 울타리입니다. 누가 가상자산사업자로 영업할 수 있는지를 정하는 진입규제가 그날 시행됩니다. 하한선이 닫히는 것은 그다음입니다. 2027년 1월 1일부터 시행령 제10조의10·제10조의20에 따라, 지금은 사업자 간 100만원 이상 이전에만 적용되는 트래블룰이 그 아래로 확대되고, 받는 사업자에게도 의무가 생깁니다. 보내는 사업자로부터 정보를 받아야 하고, 오지 않으면 요구해야 하며, 그래도 오지 않으면 거래를 거절해야 합니다.
금융위가 밝힌 근거는 한 번쯤 곱씹어 볼 만한 수치입니다. 국내 사업자 간 이전거래의 약 60%가(2025년 하반기, 건수 기준) 100만원 미만이었습니다. 작고 잦은 거래인데, 이는 정확히 에이전트가 만들어 내는 결제의 성격이기도 합니다. 그리고 2027년 1월 1일부터 면제 대상에서 빠지는 거래이기도 합니다.
여기서 두 가지가 따라 나옵니다. 첫째는 방향입니다. 규제는 모든 이전이 보낸 사람을 식별한 채로 움직이는 쪽으로 가고 있고, 숨을 곳이던 하한선은 사라집니다.
둘째는 덜 눈에 띄지만 두 번 읽어 볼 만합니다. 보낸 사람을 확인할 수 없는 이전에
대해 규정이 처방하는 조치가 바로 거절입니다. 이것은
DojangVerifiedEnforcer가 하는 일의 비유가 아니라 같은 문장입니다. 하나는
기관이 집행하도록 쓰였고, 하나는 솔리디티로 쓰였다는 차이가 있을 뿐입니다.
그 두 문장이 만나는 자리가 기와입니다. 도장 증명을 발급하는 주체가 바로 그 규정이 규율하는 기관들이기 때문입니다. 신원을 다른 곳에서 들여와 다툴 필요가 없습니다. 결제가 정산되는 바로 그 체인에 이미 있습니다.
03아키텍처
조건을 서명
가스 0 · 트랜잭션 0
자율 결제
유효한지 조회
| 컨트랙트 | 역할 |
|---|---|
MapaeDelegationManager | ERC-7710 위임 실행. 서명·사슬·비활성 검사 후 조건을 평가하고, 통과해야만 계정에 실행을 위임 |
MapaeAccount | 자금과 위임 상태를 보유. owner는 immutable |
MapaeAccountFactory | CREATE2 배포 + 소유자 동의 서명(EIP-712) 검증 + 등록부 |
DojangVerifiedEnforcer | 기여물. 위임자의 실명 증명을 결제 조건으로 겁니다 |
AllowedPayeeEnforcer | 토큰 컨트랙트가 아니라 이체 호출 안의 수취인 주소를 검사 |
ERC20PeriodTransferEnforcerTimestampEnforcer | MetaMask 감사본을 고치지 않고 그대로 가져다 씀. 호환성의 실증입니다 |
PerPaymentLimitEnforcer | 결제 한 건의 금액을 제한합니다. 기간 예산을 조각으로 나누는 역할 |
VerifiedCodeEnforcer | 사람 확인 티어. 살아 있는 오프체인 확인이 있을 때만 통과 |
컨트랙트 셋이 나눠 가진 책임
세 컨트랙트는 서로 다른 질문에 답합니다. 섞으면 각각의 보증이 약해집니다.
| 매니저 | 계정 | 팩토리 | |
|---|---|---|---|
| 답하는 질문 | 이 권한이 이 실행을 허락하는가 | 실행을 해도 되는가 | 이 소유자를 주장해도 되는가 |
| 보유 상태 | disabledDelegations 하나 | 자금 | 등록부 |
| 불변 | 무상태 EIP-712 도메인 | owner, DELEGATION_MANAGER | DELEGATION_MANAGER |
매니저는 위임 사슬을 검사합니다. 배열 길이, 호출자가 사슬 끝의 대리인이
맞는지, 사슬에 실린 모든 서명(EOA는 ECDSA, 계정은 ERC-1271), 비활성 플래그,
authority 연결까지 전부 통과해야 조건 평가로 넘어갑니다. 보관하는 상태는
disabledDelegations 매핑 하나뿐이고 위임 자체는 어디에도 저장하지 않습니다.
발급이 트랜잭션 없는 서명이라는 모델은 그래야 성립합니다.
계정은 자금을 쥡니다. 실행 진입점이 둘이고 각각 다른 문지기가 있다:
executeFromExecutor는 매니저만, execute는 소유자만 부를 수 있습니다.
그리고 owner를 나중에 바꿀 수 없다는 사실이 신원 조건을 떠받치는 유일한
근거입니다. 바꿀 수 있다면 처음에는 검증된 사람을 가리켜 두었다가 나중에 갈아치우는 일이
가능해집니다.
팩토리는 CREATE2로 계정을 찍되, 소유자 본인의 EIP-712 동의 서명을 요구합니다. 타입해시는 다음과 같습니다.
CREATION_TYPEHASH = keccak256(
"MapaeAccountCreation(address owner,uint256 salt)"
)
이 서명이 없으면 누구나 남의 검증된 주소를 owner로 지정한 계정을 배포할 수
있습니다. 훔쳐 가는 것은 없지만 책임 사슬을 위조하는 일이고, 이 시스템이 보증하는
것이 바로 그 사슬입니다. 그래서 신원 조건은 owner()를 믿기 전에
isMapaeAccount()로 등록 여부를 먼저 봅니다. 순서가 뒤바뀌면 방어가 사라집니다.
조건이 불리는 순서
조건은 네 번 불립니다. 앞의 둘은 말단에서 뿌리로, 뒤의 둘은 뿌리에서 말단으로.
beforeAllHook — 사슬 전체가 한 번씩. 배치 단위의 사전 검사beforeHook — 실행 직전. 한도·수취인·신원이 여기서 판단됩니다afterHook → afterAllHook — 역순역순인 이유는 중첩입니다. 바깥 조건이 먼저 열고 나중에 닫아야, 안쪽 조건이 만든 상태 변화를 바깥이 관찰할 수 있습니다. 이 순서는 가정이 아니라 테스트로 고정되어 있습니다 — 배치와 사슬 양쪽에 대해서.
terms는 위임자가 서명하고 args는 사용 시점에
사용자가 냅니다. 위임 해시가 args를 제외하고 계산되기 때문에 가능한 분리입니다.
04신원과 자금의 분리
도장은 컨트랙트에 붙지 않습니다. 거래소는 KYC를 통과한 사람의 지갑 주소에 발급하지, 방금 배포된 컨트랙트에 발급하지 않습니다. 그래서 위임자 주소를 그대로 검증하도록 설계하면 실사용에서는 도장이 있을 수 없고, 데모는 자기 발급 attestation으로만 통과합니다 — 논지가 무너집니다.
그래서 주소를 둘로 나눴습니다. 자금과 위임 상태는 계정이, 신원은 그 계정의 소유자인
사람이 가집니다. 조건은 계정이 아니라 owner를 평가합니다.
이 분리는 위조 경로를 하나 엽니다. 아무나 컨트랙트를 배포해 owner()가 남의
검증된 주소를 가리키게 만들 수 있습니다. 그 자체로 훔쳐 가는 것은 없지만 책임 사슬을
위조하는 일이고, 이 시스템이 보증하는 것이 바로 그 사슬입니다. 두 겹으로 막습니다.
① 계정 생성에 소유자 본인의 EIP-712 동의 서명을 요구합니다
팩토리가 서명을 검증하고 등록합니다. 동의 없이는 그 주소를 owner로 하는 계정이 존재할 수 없습니다.
② 조건은 등록 여부를 먼저 확인한 뒤에만 owner()를 신뢰합니다
등록되지 않은 컨트랙트의 owner()는 공격자가 정하는 값입니다. 순서가 뒤바뀌면 방어가 무의미해집니다.
부수 효과로 리스크 하나가 소멸합니다 — 소유자 EOA가 도장을 직접 발급받으므로 "컨트랙트가 attestation을 받을 수 있는가"라는 가정 자체가 불필요해집니다.
05결제가 지나는 경로
redeemDelegations 호출beforeAll → before를 말단에서 뿌리 방향으로after → afterAll을 뿌리에서 말단 방향으로거부의 언어
거부는 이 시스템의 정상 출력이고, 그러면 거부가 무엇을 하라는 것인지도 출력의 일부여야 합니다. 규칙은 하나입니다 — 읽을 수 없거나 존재하지 않는 값이 판단 자리에 오면 통과가 아니라 고유한 사유를 가진 거부가 되어야 합니다.
거부는 세 층에서 나옵니다. 층이 다르면 해야 할 일도 다릅니다.
| 층 | 거부 | 뜻과 대응 |
|---|---|---|
| 매니저 조건 이전 | CannotUseADisabledDelegation | 사람이 껐습니다. 다시 켜달라고 부탁하는 것 외에 방법이 없습니다 |
InvalidDelegate | 이 권한은 다른 주소의 것입니다. 호출자가 사슬 끝의 대리인이 아닙니다 | |
InvalidEOASignature · InvalidERC1271Signature | 서명이 사슬과 맞지 않습니다. 컨텍스트가 손상됐거나 잘린 것입니다 | |
InvalidAuthority | 재위임 사슬의 고리가 끊겼습니다. 부모 해시가 자식이 주장하는 것과 다릅니다 | |
| 조건 정책 | NotDojangVerified | 위임한 사람의 신원이 지금 유효하지 않습니다 — 취소·만료·미발급이 전부 이 하나로 접힙니다 |
PerPaymentCapExceeded(amount, cap) | 기간 예산과 무관하게 한 번에 너무 큽니다. 쪼개면 통과할 수 있습니다 | |
PayeeNotAllowed(payee) | 서명된 목록 밖입니다. 사람이 새 권한을 발급해야 합니다 | |
transfer-amount-exceeded | 기간 한도 소진. 다음 주기까지 기다리면 회복됩니다 | |
expired-delegation · early-delegation | 시간 창 밖입니다 | |
| 계정 실행 형태 | UnsupportedCallType | 배치나 delegatecall을 보냈습니다. 단일 호출만 받습니다 |
UnsupportedExecType | 실패를 삼키는 실행입니다. 거부되는 이유는 §6 | |
DelegatedCallToManager | 위임받은 권한을 매니저에 겨눴습니다 | |
ExecutionFailed | 조건은 다 통과했는데 이체 자체가 실패했습니다 — 보통 잔고 부족입니다 |
InvalidTermsLength(uint256)는 길이를
되돌려주고 PerPaymentCapExceeded(uint256,uint256)는 시도한 금액과 한도를 함께
줍니다. 덕분에 정산자나 에이전트가 사유를 문자열에서 추측하지 않고 그대로 쓸 수
있습니다. 알아보지 못한 셀렉터는 원본 4바이트를 붙여 그대로 돌려줍니다 — 조건이 하나 늘 때마다
정산자 코드를 고쳐야 한다면 그건 결합입니다.
세 층의 순서도 정해져 있습니다. 매니저 검증이 조건보다 항상 먼저이므로, 도장이 죽고 위임도 꺼진 상태에서는 언제나 위임 쪽 에러가 나옵니다. 이건 테스트로 고정되어 있습니다 — 두 킬스위치를 함께 시험할 때 결과가 우연에 기대지 않도록.
06조건 — 무엇을 서명하나
조건은 {enforcer, terms, args}입니다. terms는 위임자가
서명하고, args는 사용 시점에 사용자가 냅니다. 위임 해시는
signature와 args를 제외하고 계산되므로, 사용자가 낸 값이 서명을
바꿀 수 없습니다.
terms는 전부 고정 오프셋으로 빈틈없이 패킹되며, 길이가 틀리면 잘라내는 게 아니라 revert합니다. 조건은 전부 논리곱입니다 — 한도의 마지막 1원까지 쓸 수 있고, 1원도 넘을 수 없습니다.
| 조건 | 길이 | 레이아웃 |
|---|---|---|
| 신원 | 52 | attesterId(32) ‖ principal(20) |
| 기간 한도 | 116 | token(20) ‖ periodAmount(32) ‖ periodDuration(32) ‖ startDate(32) |
| 수취인 | 20×N | 패킹된 주소 목록. 빈 목록은 전체 허용이 아니라 에러 |
| 기한 | 32 | afterThreshold(uint128) ‖ beforeThreshold(uint128) |
| 회당 한도 | 32 | maxPerPayment(uint256). 0은 거부 — 비어 있는 필드는 정책이 아니라 실수입니다 |
서명되는 것의 정확한 모양
타입해시 둘은 MetaMask 프레임워크와 바이트 단위로 같습니다. 호환성 주장이 문장이 아니라 상수인 지점입니다.
// signature 없음 — 서명은 payload가 확정된 뒤에 붙습니다
DELEGATION_TYPEHASH = keccak256(
"Delegation(address delegate,address delegator,bytes32 authority,"
"Caveat[] caveats,uint256 salt)Caveat(address enforcer,bytes terms)"
)
// args 없음 — 그래야 사용자가 사용 시점에 낼 수 있습니다
CAVEAT_TYPEHASH = keccak256("Caveat(address enforcer,bytes terms)")
두 생략이 각각 일을 합니다. signature가 해시에서 빠져 있어야 서명을 나중에
붙일 수 있고, args가 빠져 있어야 사용자가 낸 값이 서명을 바꾸지
못합니다. 이 배제는 퍼즈 테스트로 고정되어 있습니다 — 두 필드를 아무 값으로 흔들어도
해시가 움직이지 않아야 합니다.
위임 해시
keccak256(abi.encode(
DELEGATION_TYPEHASH, delegate, delegator, authority,
keccak256(concat(caveatHash[0..n])), // caveat 배열은 각각 해시 후 이어붙임
salt
))
두 상수도 값이 정해져 있습니다. ROOT_AUTHORITY는
bytes32(type(uint256).max) — 위임 사슬의 끝을 뜻하고, 0이 아닌 이유는 빈
바이트가 실수로 뿌리처럼 보이면 안 되기 때문입니다. ANY_DELEGATE는
0xa11로, 이 값이 delegate 자리에 오면 누구나 상환할 수 있습니다.
실행 인코딩 — ERC-7579
모드 워드는 bytes32 하나에 호출 종류와 실행 종류를 담습니다. 우리가 받는 것은
단 하나의 조합입니다.
| 값 | 의미 | 받나 |
|---|---|---|
CALLTYPE_SINGLE 0x00 | 단일 호출 | ✅ |
CALLTYPE_BATCH 0x01 | 배치 | 거부 — 금액 조건들이 단일 호출의 calldata를 인덱싱합니다 |
CALLTYPE_DELEGATECALL 0xFF | delegatecall | 거부 — 계정 스토리지를 다시 쓸 수 있습니다 |
EXECTYPE_DEFAULT 0x00 | 실패 시 revert | ✅ |
EXECTYPE_TRY 0x01 | 실패를 삼킴 | 거부 — 아래 |
EXECTYPE_TRY를 막는 이유가 미묘합니다. 조건이 통과해 한도를 소진해 놓고 정작
이체가 조용히 실패하면, 일어나지 않은 결제가 장부에 남고 한도는 그대로
깎입니다. 실패는 시끄러워야 합니다.
단일 실행 — 빈틈없이 패킹
target(20) ‖ value(32) ‖ callData
└ ERC-20 이체면 callData = 0xa9059cbb ‖ to(32) ‖ amount(32) = 68바이트
위 그림에서 보이듯 수취인 주소는 함수 선택자 4바이트 바로 뒤 32바이트에
들어 있습니다. AllowedPayeeEnforcer가 존재하는 이유가 여기 있습니다. 타겟 목록으로
거는 조건은 토큰 컨트랙트까지만 볼 뿐 그 안의 수취인을 보지 못합니다. 범용 calldata
조건은 이 자리에 닿기는 하지만 정확히 한 값으로만 고정할 수 있어서, 위임
하나에 수취인 하나가 됩니다.
우리 조건은 같은 자리를 주소 집합과 대조해 읽습니다. 그리고 이 32바이트
가운데 주소가 쓰지 않는 앞쪽 12바이트에 0이 아닌 값이 들어 있으면 거부합니다
(DirtyRecipientWord). 쓰이지 않는 자리에 값을 실어 검사를 비껴가려는 시도를
막기 위해서입니다.
07기여물 — 신원 조건
배포된 위임 프레임워크들은 거의 모든 지출 조건을 이미 표현합니다. 금액·기간·스트리밍· 타겟·메서드·calldata·시간 범위·호출 횟수. MetaMask 프레임워크 하나만 해도 감사된 조건이 37종입니다.
그중 위임을 신원에 거는 것은 없습니다 — 어떤 배포된 프레임워크에도, 어떤 체인에도. 조건으로 걸 만한 온체인 신원 자체가 없었기 때문입니다.
설계 결정 넷
① 에이전트가 아니라 위임자의 신원을 겁니다
감사인·거래상대·보험사가 묻는 것은 "이 소프트웨어가 검증됐나"가 아니라 "어떤 실명이 이 지출을 허락했는가"입니다.
② 발급자는 가정하지 않고 서명합니다
발급자 식별자가 서명된 terms 안에 있습니다. 업비트로 좁힌 위임은 자가 발급 attestation으로는 통과할 수 없습니다 — 라이브 트랜잭션으로 증명했습니다. 발급자가 배포 상수가 아니라 서명 시점의 선택이라, 도장 생태계가 자랄수록 컨트랙트 변경 없이 조건도 넓어집니다.
③ 유효한지 여부는 캐시하지 않고 쓰는 순간에 읽습니다
도장을 만든 쪽에서 직접 "증명은 쓰는 시점에 검증해야 하며, 인덱서는 유효성의 원본이 아닙니다"라고 밝혀 두었습니다. 그 설계를 그대로 따랐습니다. 덕분에 취소와 만료가 트랜잭션 없이 즉시 작동하는 킬스위치가 됩니다.
④ boolean 조회 먼저, uid 조회는 나중
증명 식별자를 돌려주는 함수는 검증되지 않은 주소에 대해 revert합니다. 반면 참·거짓만
묻는 조회는 미발급·만료·취소를 모두 false 하나로 모아 줍니다. 그래서
결제자가 받는 에러는 언제나 같은 하나이고, 그 에러만 보고도 무엇을
해야 하는지 알 수 있습니다.
⑤ 이식성은 주장이 아니라 테스트입니다
MetaMask가 직접 배포한 매니저(이더리움 세폴리아 v1.3.0) 위에서 우리 조건들이 돕니다 — 그들 컨트랙트가 서명을 검증하고, 사슬을 순회하고, 우리 훅을 부릅니다. 취소된 신원은 거기서도 똑같이 거부됩니다. EIP-712 도메인조차 가정하지 않고 그들 컨트랙트에서 읽습니다.
그리고 이식되지 않는 것이 요점입니다. 도장은 기와에만 있어서 세폴리아 에서는 신원 레지스트리를 목으로 세워야 합니다 — 기계는 이식되고, 의미는 이식되지 않습니다. "이 사람이 검증됐는가"를 묻는 조건은 답할 수 있는 체인에서만 값을 가집니다. 왜 기와여야 하는지가, 문장이 아니라 실행되는 테스트입니다.
이 컨트랙트는 무상태이고 실행 calldata를 파싱하지 않습니다. 그래서 어떤 호출 형태와도 조합되고, 훅을 직접 불러 오염시킬 저장 상태 자체가 없습니다.
두 번째 빈칸 — 수취인 조건
작업 중에 빈칸이 하나 더 드러났습니다. 타겟 목록으로 거는 조건은 토큰 컨트랙트까지만 검사하는데, ERC-20 결제에서 돈을 실제로 받는 주소는 그 호출 데이터 안에 따로 들어 있다(06절의 실행 인코딩 참고). 범용 calldata 조건이 그 자리에 닿기는 하지만 정확히 한 값으로만 고정됩니다. 그래서 "이 세 가맹점에만"이라는 정책을 표현하려면 위임을 세 개 만들거나 결제할 때마다 새로 서명받아야 합니다.
AllowedPayeeEnforcer는 같은 자리를 주소 집합과 대조해 읽습니다.
그 덕분에 같은 정책이 사람의 서명 한 번으로 끝납니다. 가맹점 목록이 몇 개로
늘어도 서명은 한 번입니다.
08킬스위치 — 2×2 직교
두 개의 독립적인 스위치가 있고, 각각 되돌릴 수 있습니다.
각 축을 다른 축이 온전한 상태에서 시험했습니다. 도장이 살아 있는 채로 위임만 끄고, 위임이 켜진 채로 도장만 취소합니다. 그래서 결과가 매니저의 체크 순서라는 가정에 의존하지 않습니다. 되돌리는 것도 각각 증명했습니다 — 다시 켜면 쓰던 한도 그대로 재개되지, 리셋되지 않습니다.
09재위임 — 좁히기만 가능
재위임은 ERC-7710의 일급 연산입니다. 에이전트는 자기가 받은 권한의 일부를 다른 에이전트에게 넘길 수 있습니다. 결제할 때 거쳐 온 모든 위임의 조건이 차례로 평가되므로, 뒤에 붙은 위임은 앞의 것을 좁힐 수만 있습니다.
세 단계로 실제로 만들어 두었습니다. 사람이 A에게 한 번 서명하고, A가 B에게, B가 다시 C에게 넘깁니다. 뒤의 두 번은 트랜잭션이 아니고 사람의 서명도 필요 없습니다. 위임은 서명이므로, 하위 에이전트는 체인에 아무것도 남기지 않고 생깁니다.
B는 C에게 사람이 정한 것의 다섯 배인 회당 ₩50,000을 써줄 수 있습니다. 그렇게
서명하는 것을 막는 장치는 없습니다. 다만 결제하는 순간 살아남지 못합니다.
PerPaymentCapExceeded(8000, 5000)가 그때 돌아오는 답입니다.
사실인 쪽은 방향이 다릅니다. 사람이 자기 신원을 취소하면 자기가 시작한 모든 위임이 한 번에 멈춥니다. 마패 컨트랙트를 건드리지도 않고 어떤 위임을 지목하지도 않는 트랜잭션 하나로, 자기가 서명한 적이 없어 목록으로 적을 수조차 없는 것까지 함께 멈춥니다. 기억나는 권한을 취소하는 일은 원래 쉽습니다. 위험한 것은 기억나지 않는 권한입니다.
전체 기록은 docs/DEMO.md에 있습니다. 세 단계 아래에서 이뤄진 결제, 앞의 한도를 넘으려다 거부된 결제, 신원 취소 하나로 맨 앞과 맨 끝이 함께 멈추는 장면, 그리고 재발급 뒤 사람이 이름조차 댈 수 없는 부분까지 되살아나는 장면이 차례로 남아 있습니다.
권한을 여러 장 발급하지 않고 이어붙이는 이유
금액대가 다른 권한을 여러 개 두고 싶을 때 가장 먼저 떠오르는 방법은 사람이 위임을 여러 장
서명하는 것입니다. 그런데 ERC20PeriodTransferEnforcer는 위임 해시마다
장부를 따로 둡니다. "하루 ₩50,000"을 두 장 서명하면 장부가 두 개가 되고, 에이전트는
하루에 ₩100,000을 씁니다. 아무것도 revert하지 않습니다 — 체인은 서명된 대로 했을 뿐입니다.
어긋난 곳은 서명된 내용과 사람이 서명했다고 믿는 내용 사이입니다.
이어붙이면 이 문제가 생기지 않습니다. 매니저는 각 위임의 조건에 그 위임 자신의 해시를 넘깁니다. 그래서 기간 한도를 맨 앞 위임에 걸어 두면, 어느 단계로 들어와 결제하든 장부는 그 하나를 씁니다. 새 컨트랙트가 필요한 일이 아니라 조건을 어디에 두느냐의 문제입니다.
답은 서명자에 있습니다. 맨 앞 위임을 사람 앞으로 발급하고 각 단계를 사람이 서명하면, 에이전트는 사람이 아니므로 단계를 스스로 만들 수 없습니다. 위험한 구성과 올바른 구성이 각각
test/integration/TierBudget.t.sol에 고정돼 있습니다.
10x402 정산
x402 v2의 exact 스킴은 EVM에서 세 가지 자산 이전 방식을 정의합니다. 그중 둘은 토큰 레이어에서 승인하며, 그 승인은 nonce 한 번으로 소진됩니다. 세 번째 erc7710은
위임 매니저를 시뮬레이션해서 검증하며, 하나의 승인으로 여러 번
정산할 수 있는 유일한 방식입니다.
마패는 이 세 번째 방식을 구현합니다. 정산자는 정책도 자금도 갖지 않습니다 — 모든 한도·수취인·기한·신원 검사가 온체인에서 돌고, 정산자의 키는 가스만 냅니다. 이것은 우회가 아니라 설계 목표입니다. 정책 판정이 전부 온체인에 있으므로 정산자는 정책을 알 필요가 없고, 알 수도 없습니다.
2026년 7월에 조사했을 때 기와에는 EIP-3009 토큰이 배포돼 있지 않았고, 그래서 다른 두 방식은 정산할 대상 자체가 없었습니다. 다만 그 부재는 작업 순서를 정했을 뿐이고, 선택을 떠받치는 것은 따로 있습니다.
EIP-3009 인가서에는 정책을 넣을 자리가 없습니다. 서명되는 메시지가 여섯 필드이고, 타입해시가 그것을 못 박습니다.
TransferWithAuthorization(address from,address to,uint256 value,
uint256 validAfter,uint256 validBefore,bytes32 nonce)
keccak256 = 0x7c7c6cdb67a18743f49ec6fa9b35f50d52ed05cbed4cc592e13b44501c1a2267
보내는 사람, 받는 사람, 금액, 시간창, 그리고 쓰면 소진되는 nonce. 이 세 가맹점에만, 한 번에 ₩10,000까지, 이 사람 신원이 살아 있는 동안만을 적을 필드가 없고, 하나 덧붙일 확장점도 없습니다. 메시지 자체가 이미 완전히 특정된 이체이기 때문입니다.
더 중요한 것은 뒷부분입니다. 복원된 서명자가 from과 같아야 하므로
서명하는 사람이 곧 지불인입니다. eip3009로 에이전트가 자율
결제를 하려면 에이전트가 지불인의 키를 쥐어야 합니다 — 마패가 없애려는
바로 그 구조입니다. 사람이 인가서를 여러 장 미리 서명해 둘 수는 있지만, 한 장마다 수취인과
금액이 이미 정해집니다. 그건 위임이 아니라 수표 묶음이고,
"이 가맹점들에서, 이만큼까지, 내 신원이 유효한 동안"에 답하지 못합니다.
erc7710은 그 서명을 둘로 나눕니다. 사람이 조건을 붙여 권한을 한 번 서명하고,
에이전트가 그 안에서 상환마다 서명합니다. 조건이 컨트랙트라는 것이 신원
같은 조건이 존재할 수 있는 유일한 이유입니다.
덧붙이면 eip3009는 그 방식을 위해 쓰인 토큰에만 정산하고,
erc7710은 이미 돌고 있는 아무 ERC-20에나 붙습니다. 누군가
오늘 규격에 맞는 토큰을 배포해도 위의 어느 것도 바뀌지 않습니다. 서명할 필드가 여전히
여섯 개이기 때문입니다.
라이브 증명에서는 같은 서명 payload로 정산이 두 번 일어나고, 세 번째는 브로드캐스트 전에 거부됩니다.
이 정산자는 공개로 돌고 있습니다 — x402.mapae.org. 정산은 영수증이 나오고 토큰의 Transfer 로그가 정확한 금액을 정확한 수취인에게 옮긴 것을 확인한 뒤에만 성공으로 답합니다. 확인 창 안에 영수증이 없으면 실패가 아니라 결과 불명으로 답합니다 — 브로드캐스트된 트랜잭션은 아무도 기다리지 않아도 살아 있고, 거기에 "실패"라고 답하는 것이 한 결제를 두 번 청구하게 만드는 경로입니다. 그렇게 미확정 상태로 남은 것은 디스크에 적히고, 다음 프로세스가 체인에 물어 해소합니다.
11에이전트 연동 — MCP
MCP는 AI 에이전트에 외부 도구를 꽂는 표준 규격이고, 마패는 그 규격의 서버로 npm에 올라가 있습니다. 설치는 한 줄입니다.
claude mcp add mapae -- npx mapae-mcp
설정 파일도, API 키도, 계정 가입도 없습니다. 그다음부터는 전부 자연어로 말하면 됩니다.
에이전트는 자기 신원을 가지고 태어납니다
첫 실행에서 서버는 에이전트 자신의 키를 로컬에서 만들어
~/.mapae/agent.key에 chmod 600으로 둡니다. 어떤 도구도 그 값을
돌려주지 않습니다. 이건 편의가 아니라 안전 쪽 선택입니다 — 사람이 다른 데서 키를 만들어
터미널에 붙여 넣는 것보다, 그 키가 살 기계에서 태어나는 편이 낫습니다. 그리고 이 키는
사람의 지갑이 아닙니다. 권한 0으로 태어나서, 사람이 서명해준 것만 가집니다.
MAPAE_PROFILE로 정체성을 나눌 수 있습니다 — work와
research가 각각 자기 키와 자기 권한을 가집니다. 이걸 도구로 만들지 않은 것은
의도적입니다. 정체성 전환은 사람의 설정 행위이고, 대화 도중 스스로 키를 바꿀 수 있는 모델은
설득당해 한쪽을 버릴 수 있습니다.
권한을 받는 한 바퀴
에이전트는 요청하고, 사람이 서명합니다. 이 순서는 협상 대상이 아닙니다.
request_permission으로 정책을 조립합니다 —
기간 한도, 회당 한도, 수취인 목록(이름까지)load_context가 받습니다pay로 씁니다request_permission → 사람에게 건넬 링크
{
"askTheHuman": "https://mapae.pages.dev/create?agentName=Research+Agent
&agent=0x8D62…6232&amount=30000&period=86400
&merchants=0x2476…4714,0x2909…37A9
&merchantNames=Lunch+counter,Data+API
&perTx=5000&validDays=14"
}
수취인 이름은 사람이 목록을 검토할 수 있게 있는 것이고, 서명되는 terms에는 들어가지 않습니다. 서명되는 것은 주소입니다. 그리고 회당 한도가 기간 한도보다 크면 거부합니다 — 기간 한도가 먼저 걸려서 절대 발동하지 않을 조건이기 때문입니다. 정책이 아니라 조립 실수입니다.
도구 여덟
| 도구 | 서명 | 하는 일 |
|---|---|---|
request_permission | 없음 | 정책을 링크로 조립 — 기간·회당 한도, 이름 붙은 복수 수취인 (ERC-7715 요청 경로) |
load_context | 없음 | 사람이 건넨 권한을 대화 안에서 장착. ~/.mapae에 남아 재시작에도 살아남습니다 |
list_permissions | 없음 | 보유한 마패와 조건, 그리고 지금 체인 상태 — 꺼졌나, 신원이 살아 있나, 얼마 남았나 |
agent_status | 없음 | 에이전트 주소·가스 잔량·마패별 여력 한 줄씩 |
check_budget | 없음 | 기간 잔여·회당 한도, 그리고 지금 가능한 최대 단일 결제액 |
simulate_payment | 없음 | 이 금액이 될지 미리 묻습니다. 아무것도 브로드캐스트하지 않고, 막는 조건과 지금 통과될 최대 금액을 돌려줍니다 |
pay | 에이전트 키 | 결제. 토큰과 허용 수취인은 서명된 정책에서 나옵니다 |
redelegate | 에이전트 키 | 더 좁힌 자식 권한을 하위 에이전트에 — 트랜잭션 없이 |
결제, 그리고 거부
pay는 금액만 받습니다. 수취인을 인자로 받지 않습니다 — 정책이 여러 곳을 허용할
때만 payee로 그 목록 안에서 고를 수 있고, 목록 밖 주소는 트랜잭션이
생기기도 전에 거부됩니다. 프롬프트 인젝션이 모델을 완전히 장악해도 할 수 있는 최악은
승인된 가맹점 중 어디에 낼지 고르는 것입니다.
성공 — 라이브 응답
{
"status": "PAID",
"amount": "₩700 mKRW",
"payee": "0x2476f2f5…4714",
"spentThisPeriod": "₩700 mKRW",
"remainingThisPeriod": "₩4,300 mKRW",
"tx": "0x18ee370c…e332",
"trace": "https://mapae.pages.dev/tx/0x18ee370c…e332"
}
거부 — 체인이 판단한 결과. 오류가 아닙니다
{
"status": "REFUSED",
"amount": "₩1,500 mKRW",
"reason": "PerPaymentCapExceeded(1500, 1000)",
"tx": "0x8091a929…3e5a",
"trace": "https://mapae.pages.dev/tx/0x8091a929…3e5a"
}
이 응답에는 그날 예산이 ₩4,000이나 남아 있었습니다. 거부한 것은 회당 한도이고, 사유는 enforcer의 커스텀 에러를 디코딩한 것입니다. 모델은 이걸 예외로 처리하지 말고 사람에게 설명해야 합니다. 거부는 시스템이 동작한 결과입니다.
결제 잔액은 영수증의 이벤트에서 읽습니다. 기와의 로드밸런싱된 공개 RPC는 몇 블록 전 상태를 답할 수 있지만, enforcer가 스스로 emit한 값은 낡을 수 없기 때문입니다.
거부가 옳더라도 가스는 듭니다. 그래서 simulate_payment가 있습니다 — 브로드캐스트
없이 같은 질문을 던집니다. 답은 두 겹입니다. 먼저 로컬에서 무엇이 막는지를 이름으로
말하고(회당 한도인지, 기간 잔액인지, 꺼진 위임인지, 죽은 신원인지), 그 다음 체인에
실제로 물어 사유를 받아옵니다. 위 거부와 같은 PerPaymentCapExceeded(1500, 1000)이
미리 나옵니다. 여기에 지금 통과될 최대 금액이 붙습니다 — 거부를 지시로 바꾸는 값입니다.
에이전트는 내려가며 찍어보는 대신 얼마를 부를지 알게 됩니다.
둘이 어긋나면 그것도 말합니다. 로컬 판단이 통과인데 체인이 거부하면 응답은 그 사실을 숨기지 않고 읽지 못한 조건이 정책에 더 있습니다고 적습니다. 정책은 온체인이 정본이고, 이 도구는 그것을 대신하지 않습니다.
거부는 트랜잭션을 쓸 값어치가 있나
기본값은 브로드캐스트합니다. 온체인에서 실패하고, 가스를 쓰고, 누구나 열어 enforcer의 커스텀 에러를 직접 읽을 수 있는 트랜잭션을 남깁니다. 의도한 것입니다 — 통제가 작동했음을 보여야 하는 쪽(감사인·거래상대·보험사)에게 이 프로세스의 로그에만 있는 거부는 거의 값이 없습니다. 의심받는 대상이 바로 그 프로세스이기 때문입니다.
MAPAE_REFUSAL_MODE=dry를 주면 pay가 먼저 확인하고 같은 거부를
tx: null로 돌려줍니다. 아무것도 쓰지 않습니다. 사유는 동일하고, 사라지는 것은
증거입니다.
둘 다 누군가에게는 맞는 답이라서, 코드에 박은 결정이 아니라 설정입니다. 기본이 증거 쪽으로 기운 이유는 기와에서 이 교환이 원리상 실재하되 실제로는 한쪽으로 크게 기울기 때문입니다 — 거부된 상환의 비용은 0.0000003 ETH 수준이고, 자기를 만든 프로세스보다 오래 남는 기록은 그보다 값이 나갑니다.
정산자 쪽은 반대로 서 있습니다. x402 /verify는 브로드캐스트 전에 거절합니다 —
정산자는 남의 가스를 태울 권한이 없고, 거기서의 거부는 결제 요청에 대한 응답이지 증거물이
아니기 때문입니다. 같은 시스템이 두 지점에서 다르게 행동하는 것은 우연이 아니라, 서 있는
자리가 다르기 때문입니다.
일부러 없는 것
가스는 에이전트가 냅니다. 기와의 ETH faucet은 캡차가 붙은 웹페이지라 프로그램으로 못 받고,
그래서 agent_status가 잔량과 대략 몇 번 더 결제할 수 있는지를 알려줍니다.
한 번 채워주면 수백 번이 돕니다. (x402 정산 경로를 쓰면 이 가스마저 필요 없습니다 — §10.)
12제품
익스플로러는 정적 SPA입니다. 서버가 없고 브라우저가 기와를 직접 읽습니다. 화면은 셋입니다.
발급
조건을 조합한 뒤 자기 언어로 된 한 문장으로 확인하고 서명합니다. 가스도 트랜잭션도 들지 않습니다. 화면에 보이는 문장과 실제로 서명되는 바이트가 같은 구조에서 생성되므로 둘이 어긋날 수 없습니다. 인코더와 디코더가 서로의 역함수라는 것을 테스트가 강제합니다.
권한 관리
발급한 것, 한도 대비 쓴 것, 그리고 끄는 스위치. 발급 사실은 브라우저가 기억하고, 일어난 일은 화면을 열 때마다 체인에서 새로 읽습니다. 둘이 어긋나면 체인이 이기고, UI는 어느 쪽 말인지를 밝힙니다.
추적
결제 해시를 넣으면 위임 사슬 전체를 조건과 함께 펼칩니다.
일반적인 블록 익스플로러와 다른 점이 둘 있습니다.
첫째, 거부를 일급으로 다룹니다. 거부된 결제는 이벤트를 남기지 않으므로, 호출 데이터에서 위임을 복원한 뒤 직전 블록 상태에서 다시 실행해 거부 사유를 되살립니다.
둘째, 시제를 정직하게 씁니다. 결제할 당시에는 유효했던 신원이 지금은 취소된 경우, 두 사실을 모두 보여줍니다. 게이트 이벤트가 증명하는 그때의 유효함과, 지금 체인을 읽어 확인한 취소 상태를 나란히 놓습니다.
13검증
테스트 177개 전부 통과. 층위별로 무엇을 증명하는지가 개수보다 중요합니다.
| 층 | 개수 | 증명하는 것 |
|---|---|---|
| 포크 테스트 | 15 | 실제 도장 배포본 상대로 — 진짜 업비트 KYC 주소가 통과하고, 발급자가 구별되고, 취소·만료가 즉시 닫힙니다. 이더리움 세폴리아에 배포된 MetaMask 매니저 상대로도 같은 위임이 그대로 도는지 확인합니다 |
| 인코딩 적합성 | 17 | 타입해시, 해시 제외 필드, ERC-7579 모드워드 — 리터럴 상수로 고정, 재계산 금지 |
| 위임 사슬 | 16 | 자식이 부모 한도를 넓힐 수 없음, 위조 접합 거부, 뿌리 차단이 하위까지 관통 |
| 매니저 API | 16 | 킬스위치 권한, 잘못된 배치, 훅 재진입 불가, 훅 순서 고정 |
| Enforcer · 계정 | 89 | 위조 경로, 실행 형태 거부, 팩토리 동의 바인딩 |
| 통합 | 19 | 전 구간 시나리오, 배치 원자성, 2×2 킬스위치 행렬, 그리고 여러 단계를 한 사람이 서명할 때 예산이 실제로 어떻게 나뉘는지 |
| 불변식 | 5 | 1000회 × 256콜. 독립 유령 원장 대비 — 신원이 죽으면 결제 없음, 한도 유지, 토큰 보존 |
| 3언어 바이트 패리티 | 37 | 같은 위임을 Solidity·TypeScript·Go가 각각 인코딩해 바이트 단위로 일치 |
| 정적 분석 | 0 / 0 | Slither 심각·중대 0. 예외 5건은 전부 근거 주석과 함께 |
| 소스 검증 | 9 / 9 | Blockscout API의 검증 플래그를 직접 읽습니다 |
각 층이 실제로 증명하는 것
테스트 개수는 신뢰의 근거가 아닙니다. 무엇을 반증하려 했는가가 근거입니다.
포크 테스트는 목을 쓰지 않습니다. 블록 31,909,542에 고정해 실제 DojangScroll 배포본을 상대로 돕니다. 그래서 "업비트 KYC를 통과한 진짜 주소가 게이트를 통과합니다"가 우리가 만든 목이 아니라 그 체인의 사실이 됩니다. 업비트와 아무 관계 없이도 재현됩니다.
불변식은 독립된 유령 원장을 옆에서 굴립니다. 컨트랙트가 자기 상태를 보고하는 것을 믿지 않고, 무작위 호출 사이사이 별도로 계산한 장부와 비교합니다. 그중 하나는 논지 자체입니다 — 신원이 죽어 있는 동안에는 어떤 결제도 성립하지 않습니다. 1,000회 × 256콜을 돌려 반례를 찾습니다.
3언어 바이트 패리티는 같은 위임을 Solidity가, TypeScript SDK가, Go facilitator가 각각 인코딩하게 하고 바이트가 같은지 봅니다. 한쪽만 맞으면 서로 다른 것을 서명하고 있어도 각자는 정상으로 보이기 때문입니다. 이 픽스처가 없었다면 그 부류의 버그는 라이브에서야 드러났을 것입니다.
인코딩 적합성은 상수를 리터럴로 박아두고 재계산하지 않습니다. 타입해시를 코드로 다시 계산해 비교하면, 코드가 틀렸을 때 테스트도 같이 틀립니다.
인덱서 — 캐시이지 진실이 아닙니다
익스플로러는 인덱서 없이도 옳습니다. index.mapae.org는 속도를 위한 것이고, 클라이언트는 2초 안에 답이 없으면 체인을 직접 읽는 쪽으로 넘어갑니다. 이 성질은 실제로 시험했습니다 — 연결은 받고 영원히 응답하지 않는 서버를 세워 두고도 홈 화면이 275ms에 렌더됐습니다.
인덱싱에서 가장 어려운 것이 하필 우리가 일급으로 다루는 것입니다. 거부된 결제에는 로그가 없습니다. revert된 트랜잭션은 이벤트를 남기지 않으므로 로그 인덱서는 구조적으로 그것을 볼 수 없습니다. 그래서 매니저를 계정으로 인덱싱해 트랜잭션 본문을 읽고, 시도된 의도(어느 권한으로, 어느 토큰을, 누구에게, 얼마)를 calldata에서 복원합니다.
ready를 함께
보내고 익스플로러는 준비되지 않았다고 말하는 인덱서의 숫자를 쓰지 않습니다 —
빠른 오답보다 느린 정답이 낫습니다. 이 판단을 안 넣었을 때 실제로 위임 4·결제 6이
표시됐습니다. 체인의 진실은 28·39였습니다.
검증이 실제로 잡은 버그
- 모드워드 패킹 오류가 실행 형태 가드 둘을 조용히 무력화하고 있었습니다. all-zero 테스트가 공허하게 통과하는 동안 숨어 있다가 비-zero 왕복에 걸렸습니다.
- 빌드 설정 하나가 아무것도 브로드캐스트하지 않으면서 배포 성공을 보고했습니다. 체인에서 코드를 되읽어 발견했습니다.
- 계정 생성 서명이 잘 만들어졌는데 온체인에서 거부됐습니다. 팩토리의 EIP-712 다이제스트가 아니라 EIP-191로 서명하고 있었습니다.
- 에이전트에게 남은 결제 횟수를 29배 부풀려 알려주고 있었습니다. 기와는 L2라 트랜잭션 비용의 97%가 L1 데이터 수수료인데, 계산이 L2 가스만 세고 있었습니다. 실제 정산 여덟 건의 청구액을 대조해 발견했습니다.
14만들지 않은 것
| 지갑 | 기와 월렛의 자리입니다. ERC-7715 요청 경로를 미리 구현해 두었기 때문에, 월렛이 표준을 켜는 순간 별도 통합 작업 없이 연결됩니다 |
| 스테이블코인 | 원화 스테이블이 나오면 주소만 바꿉니다. 테스트 토큰은 어떤 표준 확장도 일부러 구현하지 않은 맨몸 ERC-20이고, 그 위에서 전부 동작한 것이 자산 불가지론의 증명입니다 |
| 위임 표준 | 발명하지 않고 채택했습니다. 감사된 조건들이 무수정으로 우리 매니저에서 돌고, 우리 신원 조건도 그들 매니저에 꽂힙니다 |
| 온체인 레지스트리 | 발급 시점 저장을 강요하면 "발급은 오프체인 서명, 트랜잭션 없음"이라는 모델이 깨집니다. 게이트 이벤트 한 줄이 역추적 고리를 닫습니다 |
| ERC-8004 | 누구나 스스로 등록하는 방식은 책임 사슬에 아무것도 보태지 않습니다. 에이전트 신원은 기와 방식, 즉 검증된 코드 조건으로 다룹니다 |
| 진실을 쥔 백엔드 | 권한이 유효한지는 체인만 답합니다. 조회를 빠르게 할 자체 인덱서는 두되, 그 데이터베이스는 체인에서 언제든 통째로 다시 만들 수 있습니다. 캐시이지 원본이 아닙니다 |
15신뢰 가정과 위협 모델
이 시스템이 무엇을 신뢰하고 무엇을 신뢰하지 않는지를 명시합니다. 신뢰 경계를 적지 않은 보안 주장은 검증할 수 없습니다.
신뢰하는 것
| 대상 | 가정 |
|---|---|
| 도장 발급자 | 거래소가 실명 확인을 정확히 수행하고, 취소 사유가 생기면 취소합니다. 마패는 발급 정확성을 검증하지 않습니다 — 다만 어떤 발급자를 믿을지는 위임자가 서명으로 고릅니다 |
| EAS · DojangScroll | attestation의 만료·취소 상태를 정확히 반영합니다. 조건은 이 값을 매 결제마다 읽습니다 |
| 체인 | GIWA의 합의, 그리고 체인 재구성(reorg)이 뒤집히지 않는다는 가정. 이 문서의 모든 강제는 온체인 실행에 의존합니다 |
| 가져다 쓴 코드 | 기간 한도·기한 조건은 MetaMask 감사본을 고치지 않고 그대로 씁니다. 감사 결과를 신뢰하되, 실제 실행은 우리 테스트가 재확인합니다 |
신뢰하지 않는 것
| 대상 | 이유와 방어 |
|---|---|
| 에이전트 | 침해되었다고 가정합니다. 서명된 조건 밖으로는 지출할 수 없고, 수취인은 인자가 아니라 정책에서 나옵니다. 에이전트 키 유출의 최대 피해는 남은 한도까지, 허용된 수취인에게다 |
| 정산자 (x402) | 정책도 자금도 갖지 않습니다. 정산자가 악의적이어도 조건을 우회할 수 없습니다 — 판정이 전부 온체인입니다. 매니저 주소를 하나로 고정해, 공격자가 지정한 매니저로 수수료 지불자를 소모시키는 경로를 막습니다 |
| 프런트엔드 | 백엔드가 없고, 화면의 값은 브라우저가 체인에서 직접 읽습니다. 서명 문장과 서명 바이트가 같은 구조에서 생성되며 코덱이 서로의 역함수임을 테스트가 강제합니다 — 표시와 서명이 어긋나는 경로를 코드 수준에서 닫습니다 |
| 임의의 컨트랙트 | 등록되지 않은 컨트랙트의 owner()는 공격자가 정하는 값이므로, 조건은 팩토리 등록 여부를 먼저 확인합니다 |
| 재위임 하위 사슬 | 사슬의 모든 링크의 조건이 평가되므로 자식은 부모를 넓힐 수 없습니다. 뿌리 비활성화가 사슬 전체를 관통합니다 |
대리인이 탈취됐다고 가정하면
신뢰 경계는 "에이전트를 믿을 수 있는가"가 아니라 완전히 탈취됐을 때 최대 피해가 얼마인가로 정의됩니다. 그리고 이 질문에서 급소는 하나입니다.
아래는 탈취된 대리인이 할 수 있는 시도와, 그것을 거부하는 주체입니다. 각 행은 테스트로 실행되며, 조작만 제거한 같은 실행은 정상 정산됩니다 — 대조군이 없으면 거부가 조작 때문인지 다른 이유(예산 소진, 만료) 때문인지 구분되지 않습니다.
| 시도 | 거부하는 주체 | revert |
|---|---|---|
| 허용 목록 밖으로 송금 | AllowedPayeeEnforcer | PayeeNotAllowed |
| 기간 예산은 남았지만 한 번에 더 크게 | PerPaymentLimitEnforcer | PerPaymentCapExceeded |
이체 대신 approve로 무제한 인출권 확보 | AllowedPayeeEnforcer | InvalidMethod |
| 토큰이 아닌 다른 컨트랙트로 호출 전환 | ERC20PeriodTransferEnforcer | invalid-contract |
| 주소 워드의 남는 12바이트에 값 밀어넣기 | AllowedPayeeEnforcer | DirtyRecipientWord |
| delegatecall로 계정 스토리지 다시 쓰기 | MapaeAccount | UnsupportedCallType |
| 실패를 삼키는 실행으로 한도만 소진 | MapaeAccount | UnsupportedExecType |
| 사람이 내린 킬스위치를 되돌리기 | MapaeAccount | DelegatedCallToManager |
마지막 행은 우리가 찾은 실제 구멍입니다
계정이 곧 위임자이므로, 매니저에서 msg.sender == delegator로 열리는 함수는
모두 계정을 통해 닿습니다. enableDelegation이 그런 함수입니다. 그래서 대리인이 실행
대상을 매니저로 잡으면 — 토큰이 아니라 —
사람이 꺼둔 위임을 다시 켤 수 있었습니다.
가드가 없던 상태의 테스트 결과
[FAIL: a disabled delegation must stay disabled]
test_Delegate_CannotReEnableThroughTheAccount()
실제 노출 범위는 좁았습니다. 발급 화면은 기간 한도를 항상 붙이고, 그 조건이
target == token과 transfer 셀렉터를 강제하므로
제품으로 발급된 마패는 처음부터 안전했습니다. 닿을 수 있는 것은 SDK로 직접
조합한 "신원만" 위임이었습니다.
그래도 고쳤습니다. 시스템이 하는 보증이 "어떤 조건을 붙였느냐"에 기대면 안 되기
때문입니다. 위임 경로에서만 매니저를 겨냥한 실행을 거부합니다 — owner의
execute는 일부러 열어 뒀습니다. 매니저를 부르는 것이 사람이 킬스위치를 내리는
방법입니다.
이 발견은 다른 팀이 공개한 위협 모델을 우리 컨트랙트에 대고 돌려본 결과입니다. 배포된 프레임 워크를 공유하는 생태계에서는 남의 보안 문서가 곧 무료 감사 보고서가 됩니다.
알려진 한계
- 발급 시점의 신원은 보증하지 않습니다. 조건은 결제하는 순간에 유효한지만 읽을 뿐, 거래소가 잘못 발급한 attestation을 걸러내지 못합니다. 이는 도장 계층의 책임입니다.
- 가스는 에이전트가 냅니다. 잔액이 떨어지면 결제가 실패합니다. 대납(paymaster)은 기와에 번들러 인프라가 서면 검토합니다.
- 공개 RPC의 읽기 일관성. 로드밸런싱된 엔드포인트가 서로 다른 높이의 상태를 반환할 수 있습니다. 강제는 온체인이라 안전에는 영향이 없지만, 조회 UI는 재시도로 대응합니다.
16배포 주소
GIWA Sepolia (91342). 전부 소스 검증되어 있습니다.
MapaeDelegationManager | 0xfd0fCCCcF8071852783b5133b3CC47461f33e6Cd |
MapaeAccountFactory | 0x157aF4D7b3f52685c817d5558b3468caD9b61299 |
DojangVerifiedEnforcer | 0xb2906a5079702B82C2973423d8cf91e8B41e6371 |
AllowedPayeeEnforcer | 0x7eF0f193B721B1749d890F1e231C8074670f1bD1 |
ERC20PeriodTransferEnforcer | 0xE33ba891fa502A075D3E422258723eF4cB6AC892 |
TimestampEnforcer | 0x2911cB5D4aeBCa3e42FAaa5488b6e04df3C9cc02 |
PerPaymentLimitEnforcer | 0x8900b56d714b902b7AfdbeC4722a9b098C8993d8 |
VerifiedCodeEnforcer | 0x1C640E0A70b1E18B120bB20952e81Df8F6b8650e |
MockKRW | 0x8bd74916E3427B4eF8Bed3D2F49241056E5e4F2B |
17다음
감사된 조건 37종 전부 탑재
지금은 결제에 필요한 6종이 올라가 있습니다. 여섯 번째인 회당 한도까지 배포·검증을 마쳤고, 정산과 거부를 짝으로 라이브 증명했습니다. 표준을 그대로 따르기 때문에 남은 조건들은 고쳐 쓸 일이 아니라 검증할 일입니다. 누적 총액, 호출 횟수, 허용 함수 셀렉터, 결제를 목적에 묶는 주문 해시가 그 대상입니다. 구독이나 법인 경비 같은 시나리오 프리셋으로 조합을 제품화합니다.
기와 월렛 연동 · 도장 세분화
ERC-7715 요청 경로는 이미 구현되어 있어 월렛 쪽 스위치만 남았습니다. 거래소별·등급별 도장이 늘어날수록 걸 수 있는 조건도 코드 변경 없이 촘촘해집니다. 어느 발급자를 믿을지가 배포 시점의 상수가 아니라 서명할 때의 선택이기 때문입니다.
조회 인프라 — 가동 중
자체 인덱서가 index.mapae.org에서 돕니다. 로그만으로 전부 재구성되는 사본이라 언제든 버리고 다시 만들 수 있습니다. 익스플로러는 2초 안에 답이 없으면 체인을 직접 읽는 쪽으로 넘어가므로 인덱서가 멈춰도 제품은 계속 동작합니다. 남은 일은 실제 발급자가 찍은 도장 증명까지 인덱싱 범위를 넓히는 것입니다. 기와에 아직 없는 오프체인 조회 계층이 그것입니다.
EOA 직접 위임 — EIP-7702
GIWA에서 활성화되어 있음을 행동 테스트로 확인했습니다. 소유자 EOA가 직접 위임자가 되면 계정 컨트랙트 없이 검증된 주소 자체가 위임을 보유합니다. 조건은 이 형태를 이미 받아들입니다.
18참고 표준
| ERC-7710 | 스마트 컨트랙트 위임 — redeemDelegations 인터페이스와 조건 훅 |
| ERC-7715 | 지갑에 실행 권한을 요청하는 표면. 요청 경로를 구현해 두었습니다 |
| ERC-7579 | 모듈형 계정의 실행 인코딩 — 모드워드와 단일 실행 레이아웃 |
| EIP-712 · ERC-1271 | 구조화 데이터 서명, 컨트랙트 서명 검증 |
| EIP-7702 | EOA에 코드를 부여하는 경로. 기와에서 활성 확인, 로드맵 |
| x402 v2 | HTTP 402 결제 프로토콜. exact 스킴의 erc7710 방식을 구현 |
| EAS | Ethereum Attestation Service. 도장이 이 위에 서고, 만료·취소를 결제 시점에 읽습니다 |
| MetaMask delegation-framework | 구조·해싱의 바이트 호환 기준. 조건 2종은 감사본을 무수정 사용 (MIT AND Apache-2.0) |