ERC-4337: Account Abstraction Using Alt Mempool
ERC-4337 공식 명세서
해당 글은 위 링크를 참고하여 작성되었습니다.
이더리움에서 계정 추상화(Account Abstraction, AA) 를 오래전부터 하고 싶어 했는데,
그동안의 제안들은 다 프로토콜(합의 레벨)을 바꿔야 해서 실제로 적용이 안 됐었음.
이 제안을 통해 합의 레벨은 안 건드리고
스마트 컨트랙트 계정만으로도 지갑을 쓸 수 있게 하는 일종의 “우회 구현” 방식
→ 사용자가 더 이상 EOA + 컨트랙트 계정을 같이 쓸 필요 없이,
컨트랙트 계정 하나만을 기본 계정으로 쓰게 하는 것이 핵심.
UserOperation
유저 대신 실행될 트랜잭션을 설명하는 구조체
일반 트랜잭션과 비슷하지만 추가 필드들이 더 있고, 특히 signature의 의미와 검증 방식은 프로토콜이 아니라 각 스마트 컨트랙트 계정이 정의한다. → 그래서 “트랜잭션(tx)” 대신 UserOperation(UO) 이라는 별도 개념으로 정의.
일반 트랜잭션과 겹치는 것: sender, nonce, callData, maxFeePerGas, maxPriorityFeePerGas, signature 등.
추가로 들어가는 것들
initCode: 계정이 아직 없을 때, 이 코드를 통해 지갑을 처음 배포 (팩토리 호출 + 컨스트럭터 파라미터 포함).callGasLimit / verificationGasLimit / preVerificationGas: 검증·실행에 각각 얼마만큼의 가스를 허용할지 명시.
paymasterAndData: 가스 대납자(Paymaster) 주소 + 그에게 필요한 추가 데이터.
UserOperation의 필드
계정 관련: sender, nonce, initCode
실행 관련: callData, callGasLimit
검증 관련: `verificationGasLimit, preVerificationGas
수수료 관련: maxFeePerGas, maxPriorityFeePerGas
스폰서 관련: paymasterAndData
서명: signature
Sender: UserOperation을 보내는 스마트 컨트랙트 계정.
EntryPoint : 여러 UserOperation 번들을 한 번에 처리하는 싱글톤 컨트랙트.
싱글톤(singleton) 컨트랙트 : 네트워크 전체에서 딱 하나만 존재하도록 설계된 컨트랙트
번들러가 보낸 handleOps(ops[], beneficiary)를 받아
모든 UserOperation 검증 → 통과한 것만 실행
가스 회계(누가 얼마 썼고, 누구의 deposit/Paymaster에서 빼 올지 계산)
Bundler : UserOperation들을 받아 유효성 검증을 하고, entryPoint.handleOps() 트랜잭션 하나로 묶은 뒤, 블록에 포함시키는 역할을 하는 노드.
UO를 받으면 로컬 규칙 + simulateValidation으로 검증 시뮬레이션.
통과한 UO들을 묶어서 handleOps로 EntryPoint 호출.
이 handleOps 트랜잭션의 가스를 번들러가 먼저 지불.
실행이 끝난 뒤 EntryPoint가 Sender/Paymaster의 deposit에서 번들러로 환급
Paymaster : Sender 대신 가스를 내주는 컨트랙트.
특정 조건(예: “이 토큰을 X개 이상 들고 있으면 가스 대납”)에서 사용자의 가스를 대신 내줌.
혹은 사용자가 ERC-20 토큰으로 가스비를 지불하게 하고, 본인은 ETH로 정산.
EntryPoint와의 인터페이스에서 validatePaymasterUserOp 같은 함수로 이 UO의 가스를 책임질지, 얼마나 책임질지를 명시함
Factory: 아직 Sender 컨트랙트가 없을 때, 새 계정을 배포해 주는 계정 생성 담당 컨트랙트.
initCode 안에서 “Factory 주소 + Factory에 넘길 데이터”를 넣어두고,
EntryPoint가 처음 UO를 처리할 때 이 코드를 실행해 계정을 배포.
덕분에 계정은 처음 쓰는 순간에만 배포되고, 그 전까지는 counterfactual address로만 존재.
counterfactual address : “아직 컨트랙트가 배포되기 전인데도,
나중에 배포될 컨트랙트의 주소를 미리 확정해 놓는 방식으로 얻은 주소”
Aggregator (Authorizer Contract) : 여러 UserOperation이 하나의 검증 결과를 공유하도록 해주는 서명 집계(aggregation) 컨트랙트.
BLS 등 집계 가능한 서명을 쓸 때 효율을 위해 사용.
설계 자체는 표준에서 “외부 스펙으로 분리”된 느낌이라 세부는 스펙 바깥
Canonical UserOperation mempool: 번들러들이 공통 규칙에 따라 유효하다고 보는 UserOperation만 교환하는 탈중앙·퍼미션리스 P2P 메인풀.
이때 “검증 코드에 어떤 규칙을 적용할지”에 대한 전체 스펙은 별도.
Alternative UserOperation mempool : 위의 “공통 규칙”이 아닌, 다른 유효성 규칙에 따라 UserOperation을 공유하는 별도의 메인풀.
Deposit : Sender나 Paymaster가 미리 EntryPoint 컨트랙트에 넣어 둔 예치금.
앞으로 발생할 UserOperation의 가스 비용을 결제하는 데 사용.
유저가 지갑(스마트 계정)에서 UserOperation(UO)을 만든다.
이 UO는 대체 메인풀(alt mempool) 로 브로드캐스트된다.
번들러가 이 메인풀에서 UO들을 모아서,
EntryPoint.simulateValidation으로 시뮬레이션 → 유효성·가스·수수료 확인
유효한 UO들만 모아 EntryPoint.handleOps(ops[]) 호출
EntryPoint 컨트랙트가
먼저 “검증 단계(Validation phase)”에서 각 계정의 validateUserOp를 호출해 서명/nonce/가스 지불 가능 여부 체크
이후 “실행 단계(Execution phase)”에서 실제 callData를 실행
실행 과정에서 번들러가 먼저 가스를 내고,
나중에 Sender의 deposit 또는 Paymaster에서 환급받음
번들러가 EntryPoint.handleOps(ops[], beneficiary)를 호출하는 순간, EntryPoint는 내부에서 대략 다음 세 단계를 순서대로 수행한다.
검증 단계(Validation phase): 각 UserOperation에 대해
validateUserOp 호출validatePaymasterUserOp 호출(있다면)실행 단계(Execution phase): 검증을 통과한 UO들만 다시 순회하면서
execute, executeBatch 등)를 호출해 callData 수행후처리 단계(PostOp): Paymaster가 있는 UO에 대해 postOp() 호출
실제 가스 비용을 Sender 또는 Paymaster의 deposit에서 차감하고,
나머지 예치금(refund)을 돌려주며, beneficiary에게 가스비를 지급
1.1 UserOperation 해시 계산 및 계정 배포
EntryPoint는 ops[] 배열을 앞에서부터 순서대로 순회하면서, 각 UO에 대해 다음을 실행한다.
userOpHash 계산 :
계정이 없으면 배포 (initCode): sender 주소에 아직 코드가 없고(지갑이 배포되지 않았고), UO의 initCode가 비어 있지 않다면,
initCode 안에 들어 있는 Factory 주소 + 호출 데이터를 사용해 CREATE2 방식으로 지갑 컨트랙트를 배포한다.
배포에 실패하면 그 UO는 검증 실패로 간주되며, 실행 단계까지 가지 못한다.
1.2 예치금(prefund) 계산
이제 EntryPoint는 “이 UO가 최대로 쓸 수 있는 가스 비용”을 계산하고, 그만큼의 비용을 미리 확보할 수 있는지 확인한다.
대략적으로 maxCost는 다음을 포함한다
verificationGasLimit + callGasLimitpreVerificationGasmaxFeePerGas / maxPriorityFeePerGas지갑이 직접 가스를 낸다면:
sender의 deposit가 maxCost 이상인지 확인
Paymaster가 있으면:
Paymaster의 deposit + stake가 maxCost를 커버할 수 있는지 확인
예치금이 부족하면 이 UO는 검증 단계에서 바로 거부된다.
1.3 지갑의 validateUserOp 호출
다음으로, EntryPoint는 지갑(스마트 계정)에 대해 아래와 같이 검증을 수행한다.
function validateUserOp(
PackedUserOperation calldata userOp,
bytes32 userOpHash,
uint256 missingAccountFunds
) external returns (uint256 validationData);
지갑 컨트랙트는 이 함수에서 보통 다음을 수행한다.
EntryPoint 검증 : require(msg.sender == entryPoint)
다른 누군가가 함부로 validateUserOp를 호출하지 못하게 막는다.
서명 검증 : userOpHash에 대해 userOp.signature가 유효한지 확인
유효하지 않으면 SIG_VALIDATION_FAILED 비트를 켜서 validationData에 인코딩하거나, 직접 revert함
nonce 검증 : userOp.nonce가 계정의 현재 상태와 맞는지 확인
중복 실행, 재실행 등 방지
예치금 보충 (prefund): missingAccountFunds 만큼 EntryPoint에 이더를 전송해서, 이 UO가 쓰게 될 가스 비용 상한을 맞춘다.
유효 시간 범위 반환
서명·nonce·예치금이 모두 괜찮아야 이 UO는 지갑 레벨에서 검증 통과 상태가 된다.
1.4 Paymaster의 validatePaymasterUserOp (있는 경우)
UO의 paymasterAndData가 비어 있지 않으면, EntryPoint는 Paymaster에게도 검증 기회를 준다.
function validatePaymasterUserOp(
UserOperation calldata userOp,
bytes32 userOpHash,
uint256 maxCost
) external returns (bytes memory context, uint256 validationData);
Paymaster는 여기서:
sender가 EntryPoint인지 확인deposit로 maxCost를 감당할 수 있는지 확인validationData로 서명/시간 범위 등 인코딩검증에 실패하면 revert하거나, validationData로 실패를 표시해 해당 UO는 스킵된다.
1.5 검증 실패 처리
validateUserOp나 validatePaymasterUserOp 중 하나라도 revert하거나,
validationData가 “서명 실패/시간 범위 밖”으로 판정되면 그 UO는 검증 실패로 표시된다.
검증 루프가 끝나면, EntryPoint는 어떤 UO를 실행하고, 누가 얼마까지 가스를 내줄 수 있는지를 완전히 알고 있는 상태가 된다.
검증을 통과한 UO들에 대해서만, EntryPoint는 다시 순서대로 실행 루프를 돈다.
2.1 지갑 실행 함수 호출
EntryPoint는 각 UO에 대해 지갑을 call 하여 실제 callData를 실행하게 만든다.
지갑은 이 안에서 토큰 전송, 컨트랙트 호출, 여러 호출의 배치 실행 등
UO가 의도한 작업을 실제로 수행한다.
2.2 실행 실패(revert) 처리
실행 중 지갑 쪽 호출이 revert되더라도, 번들러가 이미 ETH로 가스를 냈기 때문에,
이때까지 소비된 가스 비용은 여전히 Sender 또는 Paymaster의 deposit에서 차감된다.
즉, 실행에 성공했는지 여부와 가스비 청구 여부는 별개
2.3 실제 가스 비용 계산
Execution까지 포함한 전체 과정에서, EntryPoint는 각 UO에 대해 실제 사용된 가스를 측정하고, 다음과 같이 비용을 계산한다.
actualGas : 검증 + 실행에 실제로 소비된 가스량
actualUserOpFeePerGas :
min(userOp.maxFeePerGas, baseFee + userOp.maxPriorityFeePerGas)
actualGasCost = actualGas * actualUserOpFeePerGas
이 값이 바로 해당 UO가 실제로 부담해야 하는 비용이며,
나머지(prefund − actualGasCost)는 나중에 refund 된다.
실행이 끝난 후, EntryPoint는 각 UO에 대해 “누가 얼마를 냈는지”를 정산하고, Paymaster가 있다면 postOp를 호출해서 후속 작업을 수행하게 한다.
3.1 Paymaster가 없는 경우
Paymaster가 없다면, 가스비는 지갑(Sender)의 deposit에서 바로 차감된다.
senderDeposit -= actualGasCost
만약 기초 예치금(prefund)이 actualGasCost보다 많았다면, 차이만큼은 다시 sender의 deposit으로 환불
번들러/beneficiary는
beneficiary.balance += actualGasCost 만큼 ETH를 받는다.
이 경우에는 별도의 postOp가 없고, 정산은 EntryPoint 내부에서만 끝난다.
3.2 Paymaster가 있는 경우: postOp 호출
Paymaster가 이 UO의 가스를 대납하기로 했다면, 검증 단계에서 반환된 context를 이용해 후처리가 진행된다.
function postOp(
PostOpMode mode,
bytes calldata context,
uint256 actualGasCost,
uint256 actualUserOpFeePerGas
) external;
여기서 mode (PostOpMode)는 보통 다음 세 값 중 하나다.
opSucceeded : UO 실행이 성공한 경우opReverted : UO 실행이 revert된 경우 (그래도 가스비는 내야 함)postOpReverted : opSucceeded 후에 한 번 호출된 postOp가 revert되어, EntryPoint가 유저의 트랜잭션을 강제로 되돌리고 2차 정리 호출을 하는 경우context : validatePaymasterUserOp에서 인코딩해 준 값 (예: sender 주소, 토큰 정보)
actualGasCost, actualUserOpFeePerGas : 방금 그 UO에 대해 실제로 청구될 가스비와 1가스당 단가
Paymaster는 postOp 안에서 보통 다음과 같은 일을 한다.
Paymaster)에게 전송해 가스비를 토큰으로 정산하거나,3.3 postOp 실패 시 처리
postOp가 revert되면, 이는 Paymaster 구현 문제나 악의적 행위로 간주된다.
구현에 따라 전체 handleOps를 revert하거나, 해당 Paymaster의 평판을 떨어뜨리고, throttling / ban 대상으로 처리할 수 있다.
postOpReverted 모드는
유저의 UO는 성공했지만, postOp(opSucceeded)가 실패한 상황을 처리하기 위한 내부 모드로, EntryPoint가 UO를 일부러 revert 처리한 뒤 2차 postOp를 호출하는 시나리오에 사용될 수 있다.
3.4 최종 상태
handleOps()가 모두 끝나면, 각 UO에 대해 다음이 보장된다.
성공한 UO: 지갑 상태가 변경되었고,
Sender 또는 Paymaster의 deposit에서 실제 가스비가 빠져나가고, beneficiary(번들러)가 ETH로 보상을 받음.
실패한 UO: 지갑 상태는 원래대로 롤백되지만,
검증 및 실행에 쓰인 가스비는 여전히 부담해야 함 (Paymaster 또는 Sender가 예치금으로 지불)
Paymaster가 개입한 경우: postOp에서 context 기반 후처리가 수행되고, 필요하다면 사용자로부터 토큰 등으로 추가 정산이 이뤄진다
ERC-4337 구조 덕분에 서명 스킴, 복구 방식, 가스 지불 수단, 온보딩 UX를 모두 스마트 컨트랙트 레벨에서 프로그래머블하게 바꾸면서도, 합의 레벨 변경 없이 트랜잭션은 여전히 하나의 EVM 호출이라는 이더리움의 기본 모델을 유지할 수 있다.