
AI 에이전트로부터 JPYC 전송하기: EIP-3009를 사용할 수 없었던 이야기와 Spending Policy 구현
요약
AI 에이전트가 가스비 없이 JPYC를 전송하고 지출 한도를 강제할 수 있는 기술적 구현 방법을 다룹니다. EIP-3009의 제약 사항으로 인해 Smart Account를 사용할 수 없는 문제를 분석하고, Spending Policy를 온체인에서 강제하는 방안을 제시합니다.
핵심 포인트
- AI 에이전트의 가스리스(gas-free) JPYC 전송 구현
- Smart Account 내 온체인 지출 정책(Spending Policy) 강제
- JPYC v2의 EIP-3009 구현 내 ERC-1271 미지원 문제 분석
- Validation phase에서의 거래 거부를 통한 보안 강화
지난 회차로부터의 연결
이번 M2는 **「실제로 JPYC를 전송하기」 + 「사용 가능한 금액에 상한 설정하기」**의 2가지 측면에 집중합니다.
M2의 목표
「AI 에이전트가 JPYC로, 가스비 없이 (gas-free), 하루 상한을 초과하지 않는 범위 내에서 결제를 실행한다」
기술적 맥락으로 번역하면, 3가지 요구사항으로 분해됩니다.
agent 경로: agent가 보유한 smart account로부터 JPYC를 전송할 수 있음 (JPYC.transfer()를 UserOp에 실어 Paymaster로 가스리스(gasless)화)
policy 강제: 「1회 거래당 상한」과 「1일당 횟수」를 smart account 스스로가 온체인(on-chain)에서 강제하며, 초과한 UserOp는 execution phase에 도달하기 전에 거부됨 (validation phase에서 revert)
EOA payer 경로 준비: M3에서 진행할 x402를 위해, EOA가 오프체인(off-chain)에서 EIP-3009를 서명하여 relayer에게 전달하는 경로의 서명 헬퍼(signature helper)를 SDK에 포함
이를 **로컬 (anvil + MockJPYC)**과 Polygon Amoy 양쪽 모두에서 구동할 수 있는 것을 완료 조건으로 설정했습니다.
첫 번째 함정: smart account에서 EIP-3009를 사용할 수 없다
처음에 작성하려고 했던 구현은 단순한 것이었습니다. 「JPYC는 EIP-3009 (transferWithAuthorization)를 구현하고 있다. 그러므로 agent의 smart account가 오프체인에서 authorization을 서명하고, bundler가 대행하여 submit하면, agent의 smart account로부터 가스비 없이(gasless) JPYC를 보낼 수 있다」 — 그렇게 생각했습니다.
EIP-3009는 바로 그 목적을 위해 태어난 사양입니다.
하지만, JPYC v2 (0xE7C3D8C9a439feDe00D2600032D5dB0Be71C3c29)의 구현을 읽어보면 치명적인 제약이 있음을 알 수 있습니다. proxy가 가리키는 Ethereum 상의 implementation contract는 verified 상태이므로, 브라우저에서 직접 소스를 읽을 수 있습니다: Blockscan의 JPYC v2 implementation (0xafAc...07d2e) (Polygon, Avalanche, Amoy도 동일한 소스를 per-chain으로 배포하고 있으므로, 검증 로직은 모든 체인에서 일치합니다).
EIP3009.sol의 검증 로직은 다음과 같이 작성되어 있습니다.
// EIP3009.sol:103-112 — JPYC v2 verified source (위 링크)
function _transferWithAuthorization(
address from,
...
검증은 EIP712.recover(..., v, r, s, data) == from
─ 순수한 ecrecover
로 복구한 주소가 from과 엄격하게 일치할 것을 요구합니다.
Circle이나 OpenZeppelin의 최신 ERC-20 (Permit2, ERC-3009의 Circle 계열)에는 SignatureChecker라는 폴백(fallback)이 있어, ecrecover가 실패하면 from을 컨트랙트 주소(contract address)로 간주하고 ERC-1271의 isValidSignature를 조회하는 처리가 들어갑니다. 이것이 있다면 smart account를 from으로 설정할 수 있습니다. 하지만 JPYC v2의 구현에는 이 폴백이 없습니다. 하위 계층인 ECRecover.recover도 address(0)를 차단할 뿐, ERC-1271로의 분기는 전혀 없습니다.
// ECRecover.sol:48-71 — JPYC v2 verified source
function recover(bytes32 digest, uint8 v, bytes32 r, bytes32 s) internal pure returns (address) {
// ... malleability check, v check ...
...
pure
함수에서, address(this).code.length도 staticcall도 없습니다. 컨트랙트 주소는 절대로 from이 될 수 없습니다.
즉, "agent의 smart account에 JPYC를 보유시키고, 그 smart account를 from으로 하는 transferWithAuthorization로 송금한다"라는 설계는 기술적으로 불가능하다는 사실이 여기서 밝혀졌습니다. EIP-3009는 EOA payer 전용 사양이었던 것입니다.
실전 검증: 정말로 Amoy 상에서 revert 되는가
코드를 읽고 "이것은 불가능하다"라고 확신하더라도, 작성자의 책임으로서 실제 JPYC 컨트랙트에서 실제로 revert 되는 것을 확인해야 합니다. scripts/05-verify-eip3009-smart-account.ts를 작성했습니다. 하는 일은 단순합니다:
- M1과 동일한 Kernel smart account를 파생 (
0xdbe30607...e07e) - owner EOA가
from = smart_account_address라고 주장하는 EIP-712 메시지에 서명 (kawasekit의signTransferWithAuthorization헬퍼는from = signer를 강제하므로, 여기서는 가드를 우회하여 viem의signTypedData를 직접 호출) - owner EOA로부터
JPYC.transferWithAuthorization(...)를 직접 전송 (viem의writeContract는 preflight 단계에서 revert를 포착하여 submit 전에 throw하므로,sendTransaction+ 수동 인코딩으로 raw 전송) - tx receipt의
status를 확인
결과:
Owner (EOA): 0x2D069B1C52e69cfB0A9e6D1dc1135a0eecFeA620
Smart account: 0xdbe30607B75EaAd70BC62fA38eb63a1af0bEE07e
JPYC contract: 0xE7C3D8C9a439feDe00D2600032D5dB0Be71C3c29
...
Polygonscan에서 revert reason을 디코딩하면 EIP3009: invalid signature가 보입니다: tx 0x4738fa84...
ecrecover가 복원한 것은 owner EOA의 주소 (0x2D069B...A620), from은 smart account (0xdbe306...e07e) ─ 당연히 일치하지 않으며, require(recovered == from)에서 70,661 gas를 소비하며 revert 되었습니다. 코드 리딩을 통한 주장이 실제 Amoy 상에서 재현됨을 확인할 수 있었습니다.
설계 판단: 왜 UserOp + transfer()로 전환했는가
"EIP-3009를 사용할 수 없다"는 것을 알게 된 시점에서, agent의 송금 경로로는 다음과 같은 선택지가 남습니다.
- (A): smart account가 자기 자신으로서
JPYC.transfer()를 UserOp를 통해 실행하는transfer를 호출한다. 서명은 smart account의 Validator가 ERC-4337 validation phase에서 수행하므로,ecrecover의from == signer제약에 얽매이지 않는다. - (B) EOA를 agent의 signer로 만든다: smart account를 포기하고 agent가 EOA를 보유한다. EIP-3009를 사용할 수 있는 대신, Paymaster나 정책 강제(policy enforcement)가 EOA 레벨에서는 작동하지 않는다. AA(Account Abstraction)의 이점을 모두 버리게 된다.
- (C) JPYC를 포크하여 ERC-1271 대응 버전을 만든다: 시간과 리스크, 그리고 라이선스 문제. M2의 범위에서 벗어난다.
(A)로 진행하는 것은 자명했습니다. 사실 EIP-3009를 구현한 시점에서 「스마트 계정 (smart account)에서도 보낼 수 있다」는 점이 암묵적인 전제로 퍼져 있지만, 이는 Circle USDC와 같은 ERC-1271 대응 버전으로 한정된 이야기이며, JPYC v2는 그렇지 않습니다. EIP-3009 조력 헬퍼 (helper) 자체는 SDK에 남겨두되, 이는 M3의 x402 (HTTP 402, 엔드 유저 EOA가 페이월 (paywall)을 지불하는 시나리오)에서 소비한다라고 재정의했습니다.
에이전트 (agent) 송금은 transferJpyc()라는 함수로, 내부에서 JPYC.transfer(to, amount)의 콜 데이터 (calldata)를 생성하고, 이를 Kernel의 UserOp로서 번들러 (bundler)에게 흘려보냅니다. 페이마스터 (Paymaster)가 가스비를 대신 지불하고, 검증자 (Validator)가 정책 위반을 감지하는 ─ 계정 추상화 (AA) 스택을 정직하게 쌓아 올리는 구조입니다.
구현 1: viem + ZeroDev로 JPYC.transfer()를 UserOp로 실행
에이전트 경로의 본체입니다. transferJpyc()는 다음과 같은 시그니처를 가지며, KernelAccountClient와 송금 파라미터를 받아 userOp의 해시 (hash)와 최종적인 tx의 해시를 반환합니다.
// src/client/transfer-jpyc.ts (발췌)
import type { KernelAccountClient } from "@zerodev/sdk";
import type { Address, Chain, Hex, Transport } from "viem";
...
설계 포인트는 세 가지가 있습니다.
1. KernelAccountClient를 「설비」로 받는다
transferJpyc()는 kernelClient를 생성하지 않습니다. 생성은 호출 측 (애플리케이션 계층)의 책임으로 두었습니다. 이는 M3에서 세션 키 (session key)나 다른 체인이 도입되었을 때, 전송 로직 자체는 불변으로 유지하기 위한 분리입니다.
2. ABI는 SDK 내에 고정
JPYC v2는 Ethereum / Polygon / Amoy / Avalanche에 동일한 주소 (0xE7C3D8C9...c29)로 배포되어 있으므로, src/tokens/jpyc.ts에 최소한의 ABI (ERC-20 + EIP-3009)를 as const로 임베딩해 두었습니다. viem의 타입 추론을 활용하여 encodeFunctionData의 args가 컴파일 시점에 검증됩니다.
3. 인코딩 (encoding)과 제출 (submit)의 경계
encodeFunctionData로 transfer(to, amount)의 calldata를 만들고, kernelClient.account.encodeCalls()가 Kernel의 내부 호출 (inner call) ABI로 재래핑(re-wrap)하며, 마지막으로 sendUserOperation을 통해 번들러에게 흘려보냅니다. 이 2단계 인코딩은 Kernel v3.1의 execute(Mode, bytes) 인터페이스에 얹기 위한 것입니다.
로컬 anvil + MockJPYC로 단위 테스트를 수행하면, 생성된 calldata가 0xa9059cbb (transfer selector)로 시작함을 단언 (assert)할 수 있습니다.
// test/jpyc-transfer-encoding.test.ts (발췌)
const data = encodeFunctionData({
abi: jpycAbi,
...
구현 2: Spending Policy를 Permission Validator로 통합
여기서부터가 M2-3의 핵심입니다. 「1일 상한」을 온체인 (on-chain)에서 강제하려면, Kernel의 Permission Validator에 정책을 기록 (inscribe)해야 합니다.
ZeroDev의 @zerodev/permissions 패키지에는 다음과 같은 정책 프리미티브 (policy primitive)가 있습니다.
toCallPolicy({ permissions: [...] })
— 어떤 contract의 어떤 함수를, 어떤 인자 조건으로 호출할 수 있는지 -
toRateLimitPolicy({ interval, count })
— 시간 창(time window)당 userOp 횟수 상한 -
toGasPolicy
/toTimestampPolicy
/toSignatureCallerPolicy
/toS sudoPolicy
하지만 여기에 첫 번째 함정이 있습니다: ZeroDev 5.5.x에는 toSpendingLimitPolicy (기간 내 누적액 tracker)가 존재하지 않습니다. "하루 1000 JPYC까지"와 같은 누적 금액 tracker (cumulative-amount tracker)를 on-chain에서 직접 표현할 수 있는 프리미티브 (primitive)가 없는 것입니다.
이는 설계 결정을 강요했습니다. callPolicy의 인자 조건으로 1tx당 value cap을 만들고, rateLimitPolicy로 하루 tx 횟수 상한을 만드는, 합성을 통해 daily limit의 상한(upper bound)을 표현하는 방식으로 방침을 전환했습니다.
// src/policy/daily-limit.ts (발췌)
import { CallPolicyVersion, ParamCondition, toCallPolicy, toRateLimitPolicy } from "@zerodev/permissions/policies";
import { jpycAbi } from "../tokens/jpyc";
...
예를 들어 maxPerTransfer = 100 JPYC, maxTransfersPerDay = 10라면, 실질적인 daily cap은 1000 JPYC가 됩니다. "누적액"을 직접 tracking 하고 있는 것은 아니므로, 엄밀히 말하면 1 tx로 한꺼번에 1000 JPYC를 보낼 수는 없지만 (100 JPYC 단위로 나누지 않으면 거절됨), 대신 하루 11회째의 1 JPYC 송금도 거절됩니다. daily ceiling의 상한이 확정되는 근사치일 뿐이지만, 에이전트 (agent)의 폭주 방지용으로는 충분합니다.
이를 smart account에 올리는 것이 createAgentSmartAccount()입니다.
// src/account/session-key.ts (발췌)
import { signerToEcdsaValidator } from "@zerodev/ecdsa-validator";
import { toPermissionValidator } from "@zerodev/permissions";
...
owner EOA는 sudo로서 영구적으로 존재하며, session key의 로테이션(rotation)이나 취소(revoke)를 수행할 수 있습니다. 일상적인 송금은 session key가 서명하고, UserOp의 validation phase에서 PermissionValidator가 caller / args / rate를 확인합니다. 위반 시 여기서 revert 되어 execution phase에 도달하지 않습니다. 즉, Paymaster가 스폰서한 gas도 소비되지 않습니다.
이것이 지난 기사에서 "validation phase에서 거절된다"라고 썼던 동작의 정체입니다.
구현 중 발견한 예상치 못한 함정
설계 결정 (D1~D7)을 분리한 후 구현에 들어갔기에 겉으로 보기에 M2는 차근차근 진행되었지만, 시간을 꽤 잡아먹은 사소한 포인트들이 있었습니다.
1. Kernel의 counterfactual address는 sudo validator만 확인한다
이것이 이번에 얻은 가장 큰 AA (Account Abstraction) 지식입니다.
로컬 anvil에서 MockJPYC를 대상으로 통합 테스트를 작성하고, Polygon Amoy에서 createAgentSmartAccount()의 출력 주소를 로그로 찍어본 결과, session key가 포함된 구성과 sudo만 있는 구성 (= M1 방식)의 smart account 주소가 완전히 동일했습니다.
M1 (sudo only): Smart account: 0xdbe30607B75EaAd70BC62fA38eb63a1af0bEE07e
M2 (sudo+regular): Smart account: 0xdbe30607B75EaAd70BC62fA38eb63a1af0bEE07e
처음에는 "regular validator가 반영되지 않은 것인가?"라고 의심했지만, 이는 사양(specification)대로의 동작이었습니다. Kernel v3.1의 counterfactual address는 factory + sudo validator data + index를 통해 CREATE2로 결정되기 때문에, regular plugin의 유무나 교체는 주소에 영향을 미치지 않습니다. regular validator는
lazy enable— 첫 번째 userOp가 들어왔을 때 smart account 내에서 enable 처리가 실행되며, 그 이후로는 영구적으로(permanent) 유효해집니다.
실선이 validation 경로, 점선이 counterfactual address 결정 경로입니다. regular plugin의 유무나 교체는 smart account의 주소에 영향을 미치지 않습니다.
이러한 특성은 session key의 로테이션(rotation)에 편리하여, session key만 교체하더라도 agent의 수취 주소가 바뀌지 않습니다. 반면, 다른 구성으로 의도했음에도 counterfactual 상에서 충돌을 일으키는 함정도 만듭니다. sudo는 같지만 regular가 다른 두 구성을 각각 별도의 counterfactual로 가지고 있다고 생각할 수 있지만, 실제로는 충돌합니다. M3에서 세션 키를 로테이션하는 경로를 만들 때, 이 부분을 오해하지 않도록 warning을 작성할 예정입니다.
2. ZeroDev에 cumulative spending limit가 없다
앞서 언급했듯이 toSpendingLimitPolicy는 존재하지 않으므로, 합성(composition)을 통해 daily cap을 표현할 수밖에 없었습니다. 이는 언뜻 단순한 "라이브러리의 누락"처럼 보이지만, 사실은 "기간 내 누적액 tracker"를 온체인(on-chain) validator에 부여할 경우, 스토리지(storage) 비용과 gas 비용이 선형적으로 증가한다는 점에 대한 설계적 판단으로 보입니다. Permit2나 Safe의 SpendingLimit 모듈도 마지막 리셋(reset) 시각과 누적액을 유지하는 방식을 채택하고 있지만, 트랜잭션(tx)마다 storage write가 발생합니다. callPolicy + rateLimitPolicy의 합성은 storage를 rate limit 카운터 하나만 사용함으로써, cap의 정밀도를 희생하는 대신 gas를 절약합니다.
실용적인 관점에서 agent에게는 "1 tx당 상한"과 "1일당 횟수"가 더 직관적으로 설정하기 쉬우므로, 결과적으로 합성이 UX 측면에서도 나쁘지 않다는 것이 M2의 판단입니다.
그리고 합성이라고 해서 온체인(on-chain) 강제성이 약한 것도 아니라는 점을 실전 검증을 통해 확인했습니다. policy maxPerTransfer = 10 JPYC를 설정한 smart account에서 100 JPYC를 보내려고 하면, bundler는 eth_sendUserOperation을 통해 simulation을 실행하고, 즉시 AA23 reverted 0x59d52e40을 반환합니다.
Details: "UserOperation reverted during simulation with reason: AA23 reverted 0x59d52e40"
AA23은 ERC-4337 표준에서 "validation 중에 revert 되었다"를 의미하는 에러 코드입니다. 0x59d52e40은 ZeroDev의 callPolicy가 "value > maxPerTransfer"인 경우를 의미합니다.
3. CI에서 anvil binary가 없다
prool에서 anvil을 프로그래밍 방식으로 실행하는 테스트를 작성한 후, GitHub Actions의 vitest가 beforeAll hook에서 30초 타임아웃이 발생하며 종료되었습니다. 원인은 러너(runner)에 anvil 바이너리가 없었기 때문입니다. Node 설정 이후에 foundry-rs/foundry-toolchain@v1을 추가하는 한 줄만 넣으면 해결됩니다.
로컬에는 Foundry가 설치되어 있어서 눈치채지 못했던, 전형적인 "로컬에서는 돌아가는" 케이스입니다. CI(Continuous Integration)에 올리기 전에 명시적으로 runner의 전제 조건을 의심해야 합니다.
4. forge fmt 버전 차이
kawasekit-contracts에서도 CI가 실패했습니다. forge fmt --check가 로컬(1.5.1)과 CI(latest stable)에서 빈 contract body의 줄바꿈 규칙이 서로 달라 발생하는, 미묘하고 짜증 나는 문제입니다. contract SimpleContract {}와 같은 canonical(표준) 한 줄 표기법으로 통일하여 회피했습니다. 로컬의 forge를 CI와 pinning(버전 고정)할 것인지, 아니면 edge case(예외 사례)를 피할 수 있는 코드 형태로 작성할 것인지의 선택입니다.
5. JPYC Amoy용 faucet이 공식적으로 제공되고 있다는 사실을 나중에 알게 됨
실제 JPYC는 onlyMinters로 mint(발행)가 제어되고 있어, 외부 개발자는 testnet 상에서 free mint를 할 수 없습니다. 당초에는 이를 "공개 faucet 없음"으로 성급하게 판단하여, M2의 Amoy E2E(End-to-End) 테스트를 로컬 MockJPYC로만 완결 지을 생각이었습니다.
하지만 M2 종료 후 확인해 보니, JPYC 공식 측에서 Polygon Amoy용 faucet을 제공하고 있다는 사실을 알게 되었습니다: JPYC Faucet (faucet.jpyc.co.jp). 이는 2026년 1월 19일에 JPYC 주식회사가 개발자용 도구로 공개한 것입니다.
즉, mint 권한을 가진 공식 faucet을 통해 외부 개발자도 testnet 잔액을 입수할 수 있는 것입니다. 이를 깨닫자마자 바로 100 JPYC를 획득하여, 본 기사의 모든 실전 검증(Test A~D)을 Amoy 상에서 완주할 수 있었습니다. **"공식 faucet을 놓치고 있었다"**는, 기술적으로는 어쩔 수 없지만 build in public(공개 개발) 관점에서는 가장 부끄러운 종류의 함정이었습니다. 문서를 잘 읽읍시다 (스스로에 대한 경계).
실기 검증: 4가지 시나리오를 Polygon Amoy에서 완주
설계 판단 → 구현 → 단위 테스트(Unit Test)까지 갖춰진 상태에서, Polygon Amoy 상에서 4가지 시나리오를 실전 검증했습니다. JPYC faucet에서 smart account로 100 JPYC를 투입한 뒤, 4회(Test A / B / C / D) 실행하여 각각 기대한 결과를 얻었습니다.
| Test | 검증 내용 | 기대되는 결과 | 실제 결과 |
|---|---|---|---|
| A | EIP-3009가 smart-account from을 거부함 | on-chain에서 revert | ✅ status=Reverted, gas 70,661 |
| B | 성공 tx | ✅ 1 JPYC가 이동함 | |
| C | session key + policy 내에서 송금 가능 (M2-3) | regular validator가 lazy enable 되어 성공 | ✅ 1 JPYC가 session key 서명으로 이동함 |
| D | 동일 policy로 over-cap (100 JPYC) 시도 | bundler가 simulation에서 reject | ✅ AA23 reverted 0x59d52e40, on-chain tx 없음, 잔액 변화 없음 |
Polygonscan의 증거 tx:
Polygonscan의 증거 tx:
| Test | tx hash | Polygonscan |
|---|---|---|
| A (revert) | 0x4738fa84c0724740b848da69f0f41232141dbd4f0faeac0239802d8a4520c0bc | link |
| B (transferJpyc) | 0x14071cfc612b49f5043f5f1d1dc4443f4a7dc5815b9edf02fb7f12a1e252adcf | link |
| C (session key) | 0xdc6e772fbccedbd3463a7fae23037d11f65e84c2db54c37be0fb8da806a06833 | link |
| D (policy reject) | (on-chain tx 없음 — bundler가 pre-flight에서 reject) | 아래 로그 참조 |
Test D의 bundler 응답은 다음과 같았습니다 (관련 행만 발췌, scripts/06-policy-violation.ts 실행 시):
$ pnpm m2:policy-violation
Owner (EOA): 0x2D069B1C52e69cfB0A9e6D1dc1135a0eecFeA620
Session key (EOA): 0x79FD03cc60f809229421a319420f7474636f55B4
...
ZeroDev의 bundler는 ERC-4337 사양에 따라 pre-flight simulation을 수행하며, validation phase에서 revert가 발생하면 eth_sendUserOperation을 400으로 거부합니다. AA23은 ERC-4337 표준 에러 코드로 'validation 중의 revert'를 의미하며, 0x59d52e40는 callPolicy가 'value > maxPerTransfer'를 감지했을 때 반환하는 내부 selector입니다. 즉, **
AI 자동 생성 콘텐츠
본 콘텐츠는 Zenn AI의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기