Mapae docs

2. 결제 흐름

Mapae는 회귀 가능한 두 경로를 병렬 유지한다.

EIP-3009 직접 결제

에이전트 → 리소스 요청
        ← 402 Payment Required (금액·수취인·asset·EIP-712 도메인)
        → 한도 확인 후 EIP-3009 authorization 서명 (오프체인)
facilitator → 서명 검증 → GIWA에 정산 트랜잭션 브로드캐스트
        ← 리소스 + 영수증

지불자는 가스를 내지 않는다. 트랜잭션을 브로드캐스트하는 것은 facilitator의 릴레이어 서명자이며, 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)

이 문서에서 권한(permission)과 위임(delegation)은 같은 서명 아티팩트를 가리킨다 — ERC-7715와 ERC-7710의 표기 차이다. parent caveat는 60초 주기 한도와 만료창(기본 30분, 데모에서는 PERMISSION_TTL_SECONDS로 연장)을 온체인으로 강제한다. Vendor 프로필은 ERC-20 transfer calldata의 수취인 위치도 고정한다. Manager→Child 재위임에서는 child의 개별 한도와 manager의 합산 한도가 동시에 적용된다.

아래 시퀀스는 같은 위임 하나에 대한 세 경로 — 정상 정산, 주기 한도 초과 거절, 만료 거절 — 를 보여준다. 거절의 판정 주체는 백엔드가 아니라 온체인 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 /delegated/deliverable/inv-002
    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 inv-002 (재시도)
    Seller->>Fac: /verify → simulate
    Fac->>DM: simulate redeemDelegations
    DM-->>Fac: revert ERC20PeriodTransferEnforcer:transfer-amount-exceeded
    Fac-->>Seller: isValid = false
    Seller-->>Agent: 403 — 정산 없음, 자금 불변
    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의 /verifysimulate.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을 통과한다. HybridDeleGatorowner()와 비교하고 owner는 CREATE2 initcode에 박혀 있기 때문이다. 위 표의 0x1626ba7e가 라이브 체인에서 그 사실을 답한 값이다.

요청 본문은 {permissionContext} 하나다. owner는 서명에서 복원하고, 계정은 CREATE2(복원된 owner)이며 permission이 지목한 delegator와 일치해야 한다. 호출자에게 owner나 salt를 받으면 누구든 우리가 돈 내고 배포할 주소를 지명할 수 있게 된다 — 이 구조에서는 키 없이 풀 수 없는 고정점을 호출자가 풀어야 한다. 서명은 오프라인에서 canonical 형식(low-s, v ∈ {27,28})까지 검사한다. viem은 OZ ECDSA가 revert하는 서명도 수락하므로, 이 검사가 없으면 모든 grant가 영원히 revert하는 계정을 돈 내고 배포하게 된다.

계정 단위 중복방지는 예산이 아니라 신원이다 — 키페어는 오프라인에서 공짜이므로, 그리핑의 실제 상한은 IP당 rate limit, 일일 가스 예산(BOOTSTRAP_DAILY_WEI), 그리고 일부러 작게 유지하는 스폰서 잔액이다. 스폰서에는 위임 권한이 없어 payer 자금·한도·정산에는 닿지 못한다. 검증은 bun run test:e2e:bootstrap — GIWA fork에서 15케이스(킬 스위치·승인 불일치·relayer 공유 거부·타인 서명·high-s· 배포·late binding·가스 회계·faucet·중복·동시성·rate limit·예산 소진·체인 실패 누출 가드) 15/15.

에이전트 자동화 (MCP)

결제 루프는 packages/delegation/src/payment-client.tspayForDelegatedResource 하나로 수렴하며, 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 등으로 원인을 가리킨다.

온체인 pre-flight. 서명 전에 enforcer의 회계를 직접 읽어, 성공할 수 없는 결제를 미리 거른다. 한도는 어차피 온체인이 강제하므로 이 단계의 목적은 안전이 아니라 사유의 정확도다 — 판매자까지 갔다가 403을 받는 대신 payment of 2500000 exceeds 2000000 left in this period처럼 원인을 말한다. 성공할 수 없는 결제에 leaf를 서명하지 않는 부수 효과도 있다(leaf는 bearer authorization이다).

pre-flight 판정(judgePreflight)은 순수 함수로 분리되어 있고, 체인 읽기는 콜백으로 주입된다. 상태 조회는 부모 permission의 모든 링크에 대해 readDelegationStatus로 수행한다 — root만 보면 재위임된 child의 더 좁은 한도를 놓친다. 판정 규칙 두 가지가 테스트로 고정되어 있다: 비활성 사유가 한도보다 우선한다(어떤 금액으로도 쓸 수 없는 permission을 LIMIT_EXCEEDED로 보고하면 운영자가 원인이 아닌 한도를 조정하게 된다), 그리고 한도는 체인의 최솟값이지 root의 값이 아니다.

런타임 동작 두 가지:

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.disableDelegationonlyEntryPointOrSelf라 owner EOA가 직접 호출할 수 없고 EntryPoint UserOperation이어야 한다. 두 분기 모두 수트가 실행한다 — self 분기는 impersonation으로 결과(회수 후 disabledDelegations가 참, 동일 결제가 PERMISSION_INACTIVE로 거절)를 증명하고, EntryPoint 분기는 실제 owner 키로 서명한 UserOperation을 handleOps로 태운다. 이 UserOperation의 callDatabuildRevocationCall(...).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으로 유지된다. 단 withdrawTodeposits[msg.sender]를 읽으므로 relayer가 회수할 수 없는 편도 비용이다. 로컬에서 회수 경로를 완주하는 절차는 회수 런북에 있다.

Framework 킬 스위치. 회수가 위임 하나를 끊는다면 DelegationManager.pause()는 프레임워크 전체를 멈춘다(onlyOwner — 예치금이 필요 없는 평범한 EOA 트랜잭션). 방어는 두 겹이다: facilitator의 verifyFrameworkOperationalState가 매 요청마다 paused를 확인해 정산 전에 거절하고, 온체인에서는 redeemDelegations에 걸린 whenNotPaused(DelegationManager.sol:132)가 게이트 우회조차 revert시킨다. fork에서 owner를 impersonate해 pause()를 실행하면 결제가 PAYMENT_REJECTED 403으로 거절되고 /healthok=falseDelegationManager is not operationally active 사유를 보고하는 것까지 수트가 확인한다.

재현

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   # 온보딩 서비스 15케이스
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 checktest: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를 띄우는 네 곳 모두 이 경로를 사용한다.