본문으로 건너뛰기

© 2026 Molayo

Zenn헤드라인2026. 06. 01. 10:57

자작 CTO 리뷰에서 "그 ✅는 거짓이다"라는 말을 들었다: AI 에이전트의 이중 결제를 방지하고, multi-chain을 체인으로 실증하기

요약

AI 에이전트의 이중 결제 문제를 방지하기 위한 멱등성(Idempotency) 설계와 검증 과정을 다룹니다. Claude를 활용한 CTO 리뷰를 통해 설계상의 허위 보안 주장을 바로잡고, 온체인 트랜잭션을 통해 multi-chain 대응을 실증하는 개발 기록입니다.

핵심 포인트

  • AI 에이전트의 의도(Intent) 수준 멱등성 확보의 중요성
  • Claude를 활용한 RFC 설계 리뷰 및 검증 프로세스
  • EIP-3009 및 HTTP Idempotency-Key를 넘어서는 계층적 접근
  • 온체인 데이터를 통한 설계의 실증적 검증

0. 도입부 후크

M5의 핵심은 "동일한 사용자 의도에 대한 이중 결제를 방지하는 idempotency (멱등성) 레이어"였습니다. 저는 이를 design-first 방식으로 진행하여, 먼저 RFC를 작성하고 이를 지난 M4에서 도구화한 web3-cto-review skill —— Claude에게 CTO급 외부 리뷰어 역할을 부여하는 장치 —— 에 통과시켰습니다. 제가 설계한 것을 제가 만든 리뷰 장치에 통과시킨 것입니다.

돌아온 가장 뼈아픈 지적 (C1)은 다음과 같았습니다.

§6.1의 closure는

를 과도하게 주장하고 있다. 위협 모델(threat model) 자체의 verdict 규율 (1 threat = 1 verdict) 및 과거에 직접 만든 1.8a/1.8b의 split 선례에 반한다. 이는 false-security claim (허위 보안 주장)이다.

직접 작성한 RFC의 **headline 주장 ("idempotency gap을 ✅로 닫는다")**을, 직접 만든 리뷰 장치로부터 "그것은 거짓이다"라고 부정당한 아침이었습니다. 그리고 그것은 올바른 지적이었습니다.

본 기사는 M5의 구현 기록이지만, 단순한 기능 해설이 아니라, **"✅ Mitigated(완화됨)라고 단언하기 전에, 설계는 리뷰에, 사실은 체인에 물어야 한다"**라는 하나의 축으로 작성합니다. idempotency 설계를 공개 리뷰에 부친 이야기와, multi-chain 대응을 read-only 온체인 체크와

**Kaia Kairos 상의 실전 tx (transaction)**로 실증한 이야기 —— 그 과정에서 자신의 보수적인 판단 버그까지 하나 잡아낸 이야기 —— 입니다.

1. 지난 회차로부터의 연결

kawasekit은 제가 build in public으로 진행하고 있는 솔로 OSS로, AI 에이전트가 일본 엔화 스테이블코인 (JPYC)으로 종량제 API 비용을 지불하기 위한 TypeScript SDK입니다.

지난 회차 (#4)에서 M4를 마쳤을 때, docs/THREAT_MODEL.md에는 두 개의 명명된 fund-correctness gap이 GA (0.1.0을 npm의 latest 태그로 승격)의 technical gate로서 남아 있었습니다:

  • §6.1 reasoning-step idempotency gap —— 동일 의도의 이중 결제
  • amount ceiling ( —— signer가 임의 금액에 서명해 버리는 비대칭성 maxAmountPerSign / threat 1.14)

M5의 작업은 이 두 가지를 닫는 것이었습니다. 그리고 흥미롭게도, 첫 번째 headline은 외부에서 왔습니다. 지난 #3 (x402 기사)에 대한 외부 피드백입니다.

2. 문제: call-level idempotency는 intent-level idempotency가 아니다

x402 / EIP-3009가 보장하는 멱등성은 4개 층으로 나뉩니다 (M5에서 THREAT_MODEL.md의 "three levels"라는 기술이 사실은 4줄이었던 버그도 수정했습니다):

단위M4 시점
EIP-3009 nonce1개의 서명 authorization✅ 32-byte random, token contract에서 replay 안전 (viem nonceManager)
1개의 blockchain tx✅ M3에서 도입 (병렬 settle의 race 방지)HTTP Idempotency-Key
1개의 HTTP request❌ 미대응agent reasoning step
1개의 LLM 의도 (tool call)❌ 미대응 = 핵심 과제

하위 2개 층이 없기 때문에, 다음과 같은 시나리오에서 이중 결제가 발생합니다: ① transient retry (일시적 재시도), ② LLM의 "Regenerate" (재생성), ③ pause-resume (일시 정지 및 재개), ④ multi-agent fan-out (멀티 에이전트 확산), ⑤ network duplicate (네트워크 중복).

여기서 중요한 점은, M3에서 도입한 viem nonceManager는 이를 해결하지 못한다는 것입니다.

nonceManager가 직렬화하는 것은 facilitator EOA의 tx nonce —— 병렬 writeContract...

가 동일한 account nonce를 읽어 충돌하는 것을 방지할 뿐입니다 (tx-level, threat 2.2). "동일한 서명된 payload의 이중 settle"을 차단하는 것은 token contract의 authorizationState이지, nonceManager가 아닙니다. 둘 다 tx 단위의 이야기이며, 의도(intent) 단위가 아닙니다. LLM이 동일한 의도에 대해 다른 authorization을 재서명한다면 (fresh random nonce), 그것은 별개의 payload 및 별개의 nonce로서 둘 다 settle 됩니다. tx 단위의 멱등성(idempotency) ≠ 의도 단위의 멱등성.

3. 설계를 공개적으로 진행하기: 외부 피드백 → RFC → 자체 리뷰 장치

3.1 외부 전문가가 설계를 이 정도로 구체화해 주었다

이 격차(gap)는 #3에 대한 피드백 (X 상)에서 지적되었습니다. 심지어 두 라운드에 걸쳐 진행되었습니다:

  • Round 1: "tool_call_id를 전파(propagation)하면 된다"
  • Round 2: "아니, tool_call_id propagation은 단일 harness 내에서만 완결된다. Mastra → OpenAI → 자체 제작 agent와 같은 다층 오케스트레이션(orchestration)에서는 각 프레임워크의 명명 규칙이 독립적이어서, harness 경계가 멱등성 보장이 가장 취약한 계층이 된다. x402 프로토콜 레벨에서 normalized key를 강제하는 authority가 필요하다"

이 Round 2가 설계 방침을 결정했습니다. kawasekit의 역할은 "harness의 ID를 전파하는 propagator"가 아니라 "harness에 관계없이 key를 정규화하는 authority (= normalizer)"라는 것입니다. SDK로서의 책무가 한 단계 무거워지는 전환점입니다.

3.2 RFC 작성 (design-first)

M5의 가장 큰 조각이기에, 즉시 구현하지 않고 docs/rfc/m5-1-reasoning-step-idempotency.md를 작성했습니다. 구조 맵(현행 코드를 병렬 리더로 파악)을 통해 밝혀진 결정적인 제약이 설계를 구속했습니다:

SDK는 LLM의 의도를 볼 수 없습니다. intent (tool id + args)가 보이는 곳은 agent harness의 tool.execute 경계뿐입니다. wrapFetch / server / facilitator에 도달할 즈음에는 wire-format의 paymentRequirements만 남아 있습니다.

따라서 RFC는 수정을 2개의 half × 3개의 layer로 분해했습니다:

  • Half A (server, default-on): server 측의 dedup store. 동일 재전송을 cached response로 replay 하여, verify→settle 사이의 TOCTOU(Time-of-Check to Time-of-Use)를 차단합니다. harness 협조가 불필요합니다.
  • Half B (client, opt-in): idempotency key로부터 EIP-3009 nonce를 결정론적(deterministic)으로 유도. 재서명된 동일 의도라도 동일한 nonce를 사용하여 → token contract의 authorizationState가 on-chain에서 이중 결제를 거부합니다.
  • Layer 3 (optional): shared store (Redis)를 통해 Half A의 response replay를 cross-replica로 확장합니다.

3.3 자체 리뷰 장치에 돌렸더니 ✅를 부정당했다

RFC를 web3-cto-review skill (M4에서 도구화)에 적용한 결과가 서두의 C1입니다. 무엇이 문제였을까요:

저는 §6.1을 ✅ Mitigated로 종결한다고 적었습니다. 하지만 THREAT_MODEL.md의 §0은 "1개 위협 = 1개 판결(verdict)"이며, 심지어 과거의 제가 threat 1.8을 1.8a (✅) / 1.8b (🟡)로 분할했던 이유는 "판결을 하나로 유지하기 위해서"였습니다. 게다가 citation discipline은 "

는 SDK 코드가 공격을 방지하는 것. "out-of-scope에 의존한다면 file:line을 명시하라". 솔직한 closure(결론)는 다음과 같았습니다:

신규 threat 1.8c (동일 재전송):✅ Mitigated

—— Half A의 SDK 강제 분할. single-process / shared-store scope를 명시.

1.8b (재서명·동일 의도) / 5.5:🟡 → ⚠️ Operator responsibility (with SDK affordance)

——. SDK는 기구 (key builder + 유도 nonce)를 출하하지만, maxAmountPerSign (1.14)와 완전히 같은 형태라 의도를 확인할 수 없으므로 harness가 key를 배선하지 않으면 방지할 수 없다. 따라서 ✅가 아니라 ⚠️입니다.

GA gate는 "fund-correctness gap의 closure"이지 "단순한 "가 아닙니다 —— 1.14가 affordance를 출하하면서도 ⚠️ 상태인 것과 같습니다. 기구를 출하하고 on-chain backstop을 갖추면 gap은 닫힙니다. 를 주장하는 것은, 과거에 threat 2.2를 "JSDoc만으로 ✅로 처리했다"며 강등시켰던 것과 같은 실수였습니다.

리뷰는 또 다른 중요한 지적 (H2)도 해주었습니다: 당초 안은 유도 nonce에 per-client secret을 섞고 있었는데, 이는 "모든 signer가 동일한 secret을 공유하지 않으면 별도의 nonce → 무언의 이중 결제"로 이어지는 위험한 전제를 숨기고 있었다는 것입니다. 수정 사항: nonce는 secret 없는 keccak256(key ‖ from ‖ verifyingContract ‖ chainId). fund-correctness는 "공유 secret의 배포"가 아니라 "공유 conversationId (1 run에 내재됨)"에만 의존하도록 했습니다. secret은 wire 헤더의 HMAC에서만 선택적으로 사용됩니다. 교훈:

자신의 설계에서 가장 자신 있는 주장일수록 리뷰를 통과할 가치가 있다. 는 바람이 아니라, 규율(discipline)을 견뎌낸 결론에만 붙는다.

4. 구현: 2 halves / 3 layers + amount ceiling

설계가 확정되자 구현은 순조로웠습니다.

(deriveAuthorizationNonce src/tokens/eip3009.ts): generateAuthorizationNonce의 형제입니다. chainId를 preimage에 포함하므로, JPYC가 동일한 주소의 Polygon / Avalanche / Kaia / Ethereum에서도 별도의 nonce(cross-chain replay 안전)를 가집니다.

server gate (createX402Handler, default-on): verify 이후 · settle 이전Idempotency-Key 헤더(있다면, 없다면 nonce)를 (network, payTo, asset)로 namespace 하여 dedup(중복 제거)합니다. in_flight는 lease(임대) 방식으로 처리하며, crash된 holder가 key를 영구적으로 점유하지 않도록 TTL로 reclaim(회수)합니다. in-memory 기본값은 bounded LRU + "multi-replica 환경에서는 효과가 없다"는 시작 시 경고를 포함합니다.

Redis adapter (kawasekit/idempotency/redis): cross-replica를 위한 Layer 3입니다. client-agnostic하게 만들었습니다 —— operator가 자신의 ioredis/node-redis에 eval shim을 씌우기만 하면, kawasekit은 Redis에 의존하지 않습니다(supply-chain 리뷰 불필요). race-free한 beginRedis의 Lua(done 체크 + EVAL을 통한 서버 사이드 atomic SET NX lease)를 사용합니다.

그리고 maxAmountPerSign (또 다른 gate, 소규모 코드):

createX402PaymentSigner

에 optional한 value ceiling을 추가하여, amount > ceiling일 때 sign()이 throw되도록 했습니다. 1.4(asset을 pin)와 대칭적으로 「어떤 토큰이」도 「얼마나」도 primitive하게 제한할 수 있도록 했습니다. threat 1.14는 「미래의 affordance」에서 「출하된 affordance」로의 변화입니다 (verdict는 ⚠️로 유지되며, 1.4와 동일한 형태).

이로써 GA fund-correctness gate가 모두 closed되었습니다. 테스트는 idempotency로 45건, ceiling으로 5건 —— 총 301건 (이전 #4의 251건에서 +50건)으로 증가했으며, 4단계 게이트 (typecheck / lint / vitest / build)는 모두 green입니다.

5. 주장을 체인으로 실증하기: multi-chain과 "testnet에서 확인했나요?"

idempotency와 병행하여, JPYC가 live 상태인 4개의 mainnet (Polygon / Kaia / Avalanche / Ethereum) + testnet 체인 설정을 추가했습니다. 여기서 설계와 실증에 관한 두 가지 이야기가 있습니다.

5.1 confirmation depth는 "Polygon의 4"를 복사해서는 안 된다

facilitator의 confirmation default는 M4에서 mainnet=4 / testnet=1이라는 **이진 값 (binary value)**이었습니다. 이는 Polygon 전용 값입니다. M5-4에서 작성한 finality recipe에 따라, finality는 체인마다 다릅니다:

chainfinality적절한 confirmations
Polygon PoSprobabilistic4
AvalancheSnowman deterministic1-2
KaiaIBFT 즉시1
Ethereumepoch finality32

이진 값 그대로 두면 Ethereum을 4회로 under-confirm (≈48s, 미확정 상태)하게 되어, threat 2.8 (settle reorg)이 재발합니다. 따라서 confirmation default를 per-chain의 config-as-data (KawaseChain.defaultConfirmations + blockTimeMs)로 옮기고, facilitator가 이를 읽도록 했습니다. 또한 receiptTimeoutMs는 depth에 따라 auto-size (max(60s, 15s + conf × blockTime × 1.5)) 되도록 설정했습니다. 이에 따라 Polygon은 60s로 유지되지만, Ethereum의 32-conf는 ~10min이 되어 기본 설정에서 timeout이 발생하지 않습니다. 이는 "chain config는 code branch가 아니라 data여야 한다"는 원칙을 finality에도 적용한 형태입니다.

5.2 "testnet에서 확인했었나요"라는 질문으로 잡아낸 나의 버그

새로운 체인을 추가할 때, 저는 Avalanche Fuji와 Sepolia의 JPYC를 확인되지 않았다는 이유로 보수적으로 isLive: false (= getJpycAddress가 throw됨)로 설정했습니다.

하지만 빌드가 완료된 후, "실전 검증(live verification)은 했습니까?"라는 질문에 답하기 위해 read-only 온체인 체크 (eth_getCode + name() / symbol()0xE7C3…에 대해 실행)를 돌려보았더니 다음과 같은 결과가 나왔습니다:

kaia / kairos / avalanche : name="JPY Coin" symbol="JPYC" ✅
fuji / sepolia : name="JPY Coin" symbol="JPYC" ✅ ← 나는 isLive:false로 설정했었음

Fuji / Sepolia에서도 JPYC는 live 상태였습니다. 저의 "보수적인 판단"은 사실 검증되지 않은 주장이었던 셈입니다. read-only 체크 (funds 불필요)를 통해 배포의 실재성과 identity를 확인하고, 총 8개 체인을 isLive: true로 수정했습니다.

교훈: "확인되지 않았으니 false로 두자"는 안전해 보이지만, 사실을 확인하지 않고 주장한다는 점에서 의 과잉 주장과 같은 잘못을 범하는 것입니다. 체인에 물어보면 3초 만에 알 수 있는 일입니다.

5.3 Kairos 실탄 settlement

config가 올바르더라도 "실제로 settle이 통과되는가"는 별개의 문제입니다. 그래서 Kaia Kairos testnet에서 실탄을 쐈습니다 (scripts/14-kairos-x402-self-settle.ts). 사전에 de-risk로서 Kairos의 JPYC bytecode를 Amoy (M3에서 실탄 실적 있음)와 비교하여, keccak이 byte 단위로 동일(0xed040fe5…904e)함을 확인했습니다. 그 후:

402 → client가 EIP-3009 서명 → createX402Handler verify
→ createSelfFacilitator가 transferWithAuthorization를 Kairos에 broadcast → 200
JPYC 0.01이 payer → recipient (별도 주소)로 실제 이동 / facilitator는 KAIA gas를 지불
...

x402 풀 플로우 (full flow) + idempotency layer (default-on store 경고도 발생함) + per-chain finality (Kaia=1)가 진짜 Kaia 네트워크 위에서 작동했습니다. Amoy→Polygon의 testnet→mainnet 검증 패턴이 Kairos→Kaia에서도 성립했습니다.

5.4 또 다른 "주장 vs 실태": Kaia의 smart-account는 Kernel이 없다

이 또한 체인에 물어보았습니다:

  • EntryPoint v0.7 (0x0000…da032): Kaia / Kairos 양쪽 모두에 배포 완료 ✅ → ERC-4337 v0.7 기반은 존재함.
  • ZeroDev Kernel v3.1 factory: createKernelAccount가 Kaia / Kairos에서 제로 주소(zero address)를 반환 ❌ (Polygon에서는 실제 counterfactual address가 나옴). factory가 미배포 상태임.

즉, "Pimlico를 향하는 것만"으로 구성된 최소안은 성립하지 않습니다 —— Kaia에 Kernel이 없기 때문입니다. Kaia는 AA(Account Abstraction) 자체는 가능하지만 (EntryPoint가 있으므로), kawasekit이 사용하는 Kernel이 아직 없습니다. 따라서 Phase 2는 외부 전제 조건 (Kernel-on-Kaia)을 기다리며 M6 이후로 미루고, x402 EOA-payer path (Kairos 실탄으로 실증 완료)만 선행하는 것으로 정리했습니다. smart-account를 Polygon-only로 설정했던 현재의 스코프는 옳았던 것입니다.

6. 결과: GA fund-correctness gate는 둘 다 closed, 단 GA는 아직

M5에서 출하한 것 (모두 main에 push 완료, 0.1.0-beta.3로 npm 공개):

  • reasoning-step idempotency (§6.1 closed, layered) + maxAmountPerSignGA fund-correctness gate 둘 다 closed
  • multi-chain (Kaia/Avalanche/Ethereum) + per-chain finality + Kairos 실탄 검증
  • Redis adapter + Mastra agent 예시의 idempotency 배선
  • finality tuning recipe

솔직히 말씀드리면, 0.1.0 GA (npm latest)는 아직입니다. latest

는 placeholder로 남겨둡니다. 남은 것은 「1주일간의 beta soak 동안 flaky(불안정성)가 없음」을 확인하고 수동으로 dist-tag add를 하는 마지막 관문뿐입니다. fund-correctness (자금 정확성) 메커니즘은 갖춰졌지만, latest라고 자처하는 것은 soak를 거친 후에 —— 여기서도 「단언하기 전에 확인한다」는 원칙을 지킵니다.

7. 차기 예고: ⚠️를 ✅로 만들러 간다 —— policy-gated signer와 co-signer (M6)

M5에서 척추 (idempotency + GA gate)와 multi-chain은 갖춰졌습니다. 직근의 운영 태스크는 GA promote —— beta soak (1주일간 flaky 없음)를 거쳐 0.1.0을 npm latest로 올리는 것입니다. 이것은 본 기사의 「단언하기 전에 확인한다」의 마지막 관문입니다.

그리고 M6의 핵심은 사실 이 기사의 ⚠️의 연장선입니다.

§6.1을 가 아니라 ⚠️ Operator responsibility로 남겨둔 이유를 기억해 보십시오. **local signer의 정책 강제는 advisory (권고 사항, 우회 가능)**이기 때문이었습니다. 키를 한 곳에서 쥐고 있는 한, 정책은 「부탁」에 불과합니다. maxAmountPerSign (threat 1.14)이 ⚠️ 상태인 것도 동일한 비대칭성 때문입니다.

M6는 여기에 **「정책으로 gate 된 서명 (policy-gated signer)」이라는 seam (이음새)**을 넣습니다. 서명을 「정책으로 gate 된 작업」으로서 타입 (type)으로 묶고, enforcement level (강제 수준: advisory → cryptographic → hardware)을 타입으로 요구할 수 있도록 합니다. 그 cryptographic level의 참조 구현이 바로 **2-party MPC의 co-signer (협조 서명)**입니다. 키를 분할하고, 정책을 백엔드 측에서 암호학적으로 (cryptographically) 강제합니다. 어떤 단일 당사자도 정책을 벗어난 서명을 만들 수 없는 구조입니다.

즉, M5에서 솔직하게 ⚠️라고 적었던 것을 M6에서 구조적으로 ✅로 만들러 가는 것입니다. 축은 동일합니다 —— MPC도 「작동함」을 testnet의 실전 settle을 통해 확인한 후에야 ✅라고 적을 것입니다.

(Kaia Phase 2 = Kernel-on-Kaia를 기다리는 smart-account path, Avalanche / Ethereum의 실전, 첫 실사용 및 커뮤니티는 M6와 병행 또는 그 이후에 진행됩니다.)

M5를 통하면서 가장 효과적이었던 도구는 역시 **「자신의 주장을 설계는 리뷰 장치에, 사실은 체인에 묻는다」**라는 습관이었습니다. 솔로로 build in public을 하다 보면, 자신의 를 의심해 줄 타인이 없습니다. 그래서 장치와 체인에 의심하게 만듭니다. 다음 회차에서도 그 이야기를 이어가겠습니다.

Discussion

AI 자동 생성 콘텐츠

본 콘텐츠는 Zenn AI의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.

원문 바로가기
0

댓글

0