ERC-4337 대체 멤풀 기반 계정 추상화 (Account Abstraction Using Alt Mempool)

Final · 계정·지갑 · 2021-09-29 작성

스마트 컨트랙트 계정이 일반 계정(EOA) 없이도 자체 검증 규칙으로 동작을 보내고 수수료를 다른 주체가 대신 낼 수 있게 하는 표준입니다. 합의 계층을 바꾸지 않고 별도 멤풀과 EntryPoint 컨트랙트로 구현합니다.

왜 필요한가

이더리움에서 무언가를 실행하려면 개인키로 제어하는 특별한 계정(외부 소유 계정, EOA)에서 거래를 보내야 했고 거래 검증 규칙은 프로토콜이 고정해 둔 대로만 동작했습니다. 그래서 다중 서명이나 다른 서명 방식, 계정 복구 같은 기능을 쓰려고 스마트 컨트랙트 계정을 만들어도 결국 수수료를 낼 EOA가 따로 있어야 했습니다. 계정 추상화는 이더리움의 오랜 목표였지만 프로토콜을 바꾸는 제안은 여러 번 논의되고도 구현되지 않았습니다. 합의 계층 개발은 확장성에 집중하고 있어 당분간 프로토콜을 바꿀 기회가 없을 수도 있습니다. 또 다른 사람이 수수료를 대신 내거나 ERC-20 토큰으로 수수료를 내는 일, 여러 동작을 한 번에 묶는 일도 기본 거래 모델에서는 어려웠습니다.

구현해야 하는 것

심층 설명

UserOperation 구조와 PackedUserOperation

UserOperation은 사용자를 대신해 보낼 동작을 담은 구조체이며 혼동을 피하려고 거래(transaction)라고 부르지 않습니다. 거래처럼 nonce, callData, maxFeePerGas, maxPriorityFeePerGas, signature가 있습니다. 그 외에 sender(동작을 보내는 계정), factory와 factoryData(새 계정을 만들 팩토리 주소와 데이터, EIP-7702 계정이면 0x7702 플래그), callGasLimit(실행 호출 가스), verificationGasLimit(검증 단계 가스), preVerificationGas(번들러에게 추가로 내는 가스), paymaster, paymasterVerificationGasLimit, paymasterPostOpGasLimit, paymasterData(페이마스터용 필드)도 있습니다. signature의 해석은 프로토콜이 아니라 계정 컨트랙트 구현이 정합니다. 체인 간 또는 EntryPoint 버전 간 재전송을 막기 위해 signature는 반드시 chainid와 EntryPoint 주소에 의존해야 합니다. 온체인에서는 압축한 PackedUserOperation을 쓰며 initCode(factory 주소와 factoryData 연결 또는 EIP-7702 데이터), accountGasLimits(verificationGasLimit 16바이트와 callGasLimit 16바이트 연결), gasFees(maxPriorityFeePerGas 16바이트와 maxFeePerGas 16바이트 연결), paymasterAndData(페이마스터 필드 연결)로 묶입니다. EIP-7702 인가 튜플은 UserOperation과 함께 전달할 수 있지만 구조체 자체에는 포함되지 않습니다.

handleOps의 검증 루프와 실행 루프

EntryPoint는 UserOperation 묶음을 실행하는 단일 컨트랙트이며 핵심 함수는 handleOps입니다. 검증 루프와 실행 루프 두 개를 돌아야 합니다. 검증 루프에서는 각 UserOperation마다 sender 계정이 없으면 initCode로 만들고(sender가 없는데 initCode가 비어 있거나 sender 주소에 컨트랙트를 만들지 못하면 실패, sender가 이미 있으면 initCode는 무시), 검증·실행 가스 한도와 현재 가스 값으로 sender가 낼 최대 수수료와 예치금에 더해야 할 금액을 계산한 뒤 sender의 validateUserOp를 호출합니다. 어느 validateUserOp가 실패하면 최소 그 UserOperation의 실행은 건너뛰어야 하고 전체를 되돌릴 수도 있습니다. 이어서 계정 예치금이 최대 비용을 감당하는지 확인합니다. 실행 루프에서는 계정을 callData로 호출합니다. callData가 IAccountExecute.executeUserOp의 메서드 시그니처로 시작하면 executeUserOp(userOp,userOpHash)로 인코딩해 호출합니다. 호출 뒤 미리 청구한 비용 중 남은 만큼 예치금을 환불하되, callGasLimit과 paymasterPostOpGasLimit 중 쓰지 않은 가스가 40000(PENALTY_GAS_THRESHOLD) 이상이면 그 미사용량에 10%(UNUSED_GAS_PENALTY_PERCENT) 벌금을 매깁니다. 번들의 가스 공간을 예약만 하고 쓰지 않아 다른 UserOperation을 막는 일을 방지하기 위한 장치입니다. 모든 호출이 끝나면 모은 수수료를 번들러가 지정한 beneficiary 주소로 지급합니다.

계정이 구현할 validateUserOp와 반환값

스마트 컨트랙트 계정은 IAccount의 validateUserOp를 구현해야 합니다. userOpHash는 signature를 제외한 userOp, entryPoint, chainId를 묶은 해시이며 EIP-712 타입 메시지 해시로 계산합니다. 계정은 호출자가 신뢰하는 EntryPoint인지 반드시 확인하고 signature가 userOpHash의 유효한 서명인지 검증해야 합니다. 서명 불일치 시에는 되돌리지 말고 SIG_VALIDATION_FAILED(1)를 반환하는 것이 권장되며 그 외 오류는 반드시 되돌려야 합니다. 가스 추정이 가능하도록 1을 반환할 때도 정상 흐름을 끝까지 수행하는 것이 좋습니다. EntryPoint에 최소 missingAccountFunds만큼 지불해야 하며 예치금이 충분하면 0일 수 있고 더 낸 금액은 withdrawTo로 회수할 수 있습니다. 반환값에는 aggregator/authorizer(유효하면 0, 서명 실패면 1, 그 외에는 애그리게이터 컨트랙트 주소), validUntil(6바이트 타임스탬프, 0이면 무기한), validAfter(6바이트 타임스탬프)를 묶어 담습니다. 블록 번호로 범위를 표현하려면 두 값 모두 최상위 비트를 1로 두며 타임스탬프와 블록 번호를 한 범위에서 섞을 수 없습니다. 선택적으로 IAccountExecute의 executeUserOp를 구현하면 EntryPoint가 callData를 직접 실행하는 대신 이 함수를 호출합니다. 대부분의 계정은 프록시나 EIP-7702로 업그레이드될 것이므로, 구현을 바꿀 때 두 컨트랙트의 저장소 레이아웃이 충돌하지 않도록 반드시 보장해야 합니다. 흔한 해법은 ERC-7201의 다이아몬드 저장소입니다.

반추상화 논스: 192비트 키와 64비트 순번

이더리움 프로토콜의 순차 nonce는 재전송 방지와 순서 결정에 쓰이지만 계정이 자기만의 순서 규칙을 정하기엔 제약이 큽니다. 이 표준은 uint256 nonce 하나를 192비트 key와 64비트 sequence 두 값으로 나눠 다룹니다. 이 값은 EntryPoint에 저장되며 getNonce(sender, key)로 읽습니다. key마다 sequence가 EntryPoint에서 검증되고 검증에 실패하면 그 UserOperation은 무효가 되어 번들이 되돌려집니다. sequence는 sender별로 순차적으로 단조 증가하며 새 key는 언제든 임의 값으로 도입할 수 있고 그 sequence는 0부터 시작합니다. 이렇게 하면 UserOperation 해시의 온체인 유일성을 보장하면서 계정이 key 필드에 원하는 논리를 얹을 수 있습니다. 번들러의 검증은 getNonce 호출로 시작하는 것이 권장되며 같은 sender의 UserOperation을 여럿 받으려면 멤풀에 이미 들어온 key와 sequence 쌍을 추적해야 합니다. 고전적인 순차 nonce를 강제하려면 검증 함수에서 require(userOp.nonce<type(uint64).max)를 수행합니다. 관리용 작업 채널을 따로 두려면 callData 앞 4바이트가 ADMIN_METHODSIG일 때 key가 ADMIN_KEY인지, 아니면 key가 0인지 확인하는 식으로 구현합니다.

페이마스터 확장: 대납, postOp, 스테이크와 예치금

paymasterAndData가 비어 있지 않으면 EntryPoint는 다른 흐름을 탑니다. 검증 루프에서 validateUserOp에 더해, 페이마스터가 EntryPoint에 충분한 ETH를 예치했는지 확인하고 validatePaymasterUserOp를 호출해 대납 의사를 확인합니다. 이때 계정의 validateUserOp는 missingAccountFunds를 0으로 받습니다. validatePaymasterUserOp가 비어 있지 않은 context를 반환하면 실행 호출 뒤 postOp를 호출하고 비어 있으면 호출하지 않습니다. postOp의 mode는 opSucceeded 또는 opReverted이며 실행이 되돌려져도 페이마스터는 가스를 내야 합니다. paymasterAndData는 paymasterAddress 20바이트, paymasterVerificationGasLimit 16바이트, postOpGasLimit 16바이트, paymasterData를 이어 붙인 비표준 인코딩입니다. 뒤에 선택적으로 paymasterSignature, 그 길이 2바이트인 paymasterSignatureLength, 매직 값 PAYMASTER_SIG_MAGIC(0x22e325a297439656)을 붙일 수 있습니다. signature와 paymasterSignature는 UserOperation 해시에 영향을 주지 않으므로 sender와 페이마스터가 병렬로 서명할 수 있습니다. EntryPoint는 addStake, unlockStake, withdrawStake로 스테이크를, balanceOf, depositTo, receive, withdrawTo로 예치금을 관리하는 API를 제공해야 합니다. 가스 비용이 차감되는 예치금과 잠기는 스테이크는 별개입니다. 스테이크는 삭감되지 않고 시빌 공격 방지에만 쓰이며 unstakeDelay 뒤 언제든 회수할 수 있습니다.

번들러의 기본 점검, 시뮬레이션, 오류 코드

UserOperation은 eth_sendUserOperation으로 번들러에 도착해 멤풀 진입 전 1차, 번들 구성 때 2차, 온체인 제출 전 번들 전체 3차 검증을 거칩니다. 기본 점검에서는 sender가 기존 컨트랙트이거나 initCode가 비어 있지 않아야 하며 둘 다는 안 됩니다. verificationGasLimit과 paymasterVerificationGasLimit은 MAX_VERIFICATION_GAS(500000) 미만이어야 하고 preVerificationGas는 직렬화 calldata 비용에 PRE_VERIFICATION_OVERHEAD_GAS(50000)를 더한 값을 감당해야 합니다. paymasterAndData는 비어 있거나 코드가 있고 예치금이 충분하며 차단되지 않은 페이마스터 주소로 시작해야 합니다. 한 sender는 멤풀과 한 번들에 UserOperation 하나만 둘 수 있고 스테이크한 sender는 예외입니다. 시뮬레이션은 handleOps를 view 호출해 계정 생성, validateUserOp, 페이마스터 검증까지만 수행하며 되돌려지면 버립니다. validAfter<=block.timestamp를 만족해야 하고 곧 만료될 것도 버리며 반환된 시간 범위를 읽으려면 트레이싱이 필요합니다. 실패 원인 추적을 위해 EntryPoint는 SignatureValidationFailed(), FailedOp(), FailedOpWithRevert() 오류로만 되돌리고 메시지는 AA## 코드로 시작합니다. AA1은 sender 생성, AA2는 sender 검증(validateUserOp), AA3은 페이마스터 검증(validatePaymasterUserOp) 중 오류입니다.

번들링 규칙, 차단, 임시 저장소

번들링은 번들러가 여러 UserOperation을 모아 하나의 거래로 만드는 과정입니다. 같은 번들의 다른 UserOperation sender 주소에 접근하거나, 다른 UserOperation의 검증이 팩토리로 만든 주소에 접근하는 UserOperation은 반드시 제외해야 하고 페이마스터별로 예치금이 모든 UserOperation을 감당하는지 추적해야 합니다. 번들을 만든 뒤에는 debug_traceCall을 최대 가스로 돌려 opcode·저장소 접근 규칙과 전체 handleOps 거래를 검증하고 소비된 가스를 실제 거래에 씁니다. 되돌려지면 트레이스에서 EntryPoint가 마지막으로 CALL한 엔티티를 원인으로 보고(FailedOp라고 가정하지 않음) 해당 UserOperation을 번들과 멤풀에서 제거합니다. 원인이 팩토리나 페이마스터이고 sender가 스테이크하지 않았으면 그 팩토리·페이마스터를 차단하고 sender가 스테이크했으면 대신 sender를 차단합니다. 이를 debug_traceCall이 성공할 때까지 반복합니다. 여러 UserOperation을 무효화하는 전역 엔티티(페이마스터·팩토리)를 제한하거나 일정 기간 차단하는 평판 시스템이 전제이며 시빌 공격을 막기 위해 스테이크를 요구합니다. 스테이크는 온체인에서 강제되지 않고 각 번들러가 시뮬레이션 때 확인하며 스테이크한 엔티티는 저장소 사용 제한이 완화됩니다. EIP-1153 임시 저장소를 쓰는 컨트랙트는 서로 무관한 sender들의 UserOperation이 같은 거래에 들어올 수 있으므로, 민감한 정보나 접근 제어에 쓴 임시 저장소를 직접 정리해야 합니다.

계정 최초 생성과 EIP-7702 지원

EOA처럼 사용자가 주소를 먼저 만들고 바로 자금을 받을 수 있어야 한다는 것이 설계 목표입니다. 계정 생성은 팩토리가 맡고 initCode는 20바이트 factory 주소와 그 뒤 calldata로 해석됩니다. 팩토리는 생성 순서가 주소에 영향을 주지 않도록 CREATE 0xF0이 아니라 CREATE2 0xF5를 쓰는 것이 기대되며 이미 만들어진 계정이어도 주소를 반환해야 합니다. 지갑은 entryPoint.getSenderAddress()를 정적 호출해 생성 전 주소를 얻습니다. initCode는 EntryPoint가 직접 호출하지 않으며 팩토리의 createAccount() 호출은 반드시 entryPoint.senderCreator()에서 온 것인지 확인해야 합니다. 생성 주소는 최초 서명에 의존해야 하고 전역 저장소에 접근하는 팩토리는 스테이크해야 합니다. EIP-7702가 켜진 네트워크에서는 eth_sendUserOperation이 eip7702Auth 매개변수를 추가로 받고 번들러는 이를 authorizationList에 담아 SET_CODE_TX_TYPE 거래로 번들을 실행합니다. factory가 0x7702이면 sender는 EIP-7702 위임이 있는 EOA여야 합니다. initCode가 0x7702 뒤 0 18바이트로 시작하면 해시 계산 때 앞 20바이트를 계정의 EIP-7702 위임 주소로 바꾸고 20바이트를 넘는 나머지는 계정 자체의 초기화 함수 호출에 씁니다. 이 초기화 함수도 entryPoint.senderCreator()에서만 호출되도록 해야 하며 EntryPoint는 초기화 여부를 알 수 없어 여러 번 호출될 수 있으므로 한 번만 허용하는 것이 좋습니다. EIP-7702 인가 비용 PER_EMPTY_ACCOUNT_COST(25000)는 EntryPoint가 볼 수 없으므로 preVerificationGas에 포함해야 합니다.

쓸 때 주의할 점

ERC 원문 · ← ERCs