AA 지갑 설계 노트: Solidity 패턴과 가스 최적화
AA 지갑은 단일 개인키가 아니라 컨트랙트가 권한을 검증하는 구조입니다. Passkey 기반 Smart Account를 기준으로 proxy, factory, policy, paymaster, ERC-4337 validation, gas optimization을 정리합니다.
AA(Account Abstraction) 지갑은 EOA와 다르다.
EOA는 개인키 하나가 실행 권한이다. AA 지갑은 Smart Account가 실행 권한을 직접 검증한다. 그래서 설계할 때는 "누가 서명했는가"보다 "어떤 권한 경로를 통과했는가"를 먼저 봐야 한다.
주요 구성요소는 다음과 같다.
| 구성요소 | 역할 |
|---|---|
| Smart Account | 사용자 계정 컨트랙트. 실행, 복구, 정책 검증을 담당한다. |
| EntryPoint | ERC-4337 UserOperation을 검증하고 실행한다. |
| Factory | Smart Account proxy를 예측 가능한 주소에 배포한다. |
| Proxy | 사용자별 storage를 가진 계정 인스턴스다. |
| Verifier | passkey/P-256/WebAuthn proof를 검증한다. |
| Paymaster | 수수료 후원 조건을 검증한다. |
| Bundler | UserOperation을 EntryPoint에 전송한다. |
전체 구조는 다음과 같다.
권한 분리
AA 지갑에서 가장 먼저 나눌 것은 권한이다.
| 권한 | 의미 | 검증 근거 |
|---|---|---|
| Account Session | 서비스에서 사용자를 식별한다. | 로그인, 세션, 앱 인증 |
| Wallet Execution Authority | 지갑에서 특정 action을 실행한다. | owner passkey proof |
| Recovery Authority | owner credential을 교체한다. | recovery passkey proof 또는 recovery proof |
| Automation Scope | 반복 실행을 제한적으로 허용한다. | on-chain policy |
| Gas Sponsorship | 가스비를 대신 낸다. | paymaster signature |
이 권한들은 서로 대체되면 안 된다.
예를 들어 account session이 있다고 해서 ERC20 transfer를 실행할 수 있으면 안 된다. Paymaster signature가 있다고 해서 owner approval을 통과한 것으로 보면 안 된다. Recovery proof가 있다고 해서 일반 결제를 실행할 수 있어도 안 된다.
Smart Account가 직접 검증해야 하는 것은 wallet authority다. 서버나 SDK는 UserOperation을 만들고 전송할 수 있지만, approval proof를 대신 만들 수 있으면 안 된다.
권한 관계는 다음처럼 분리된다.
ERC-4337 검증 흐름
ERC-4337의 기본 흐름은 다음과 같다.
User action
-> UserOperation
-> Bundler
-> EntryPoint.handleOps
-> SmartAccount.validateUserOp
-> SmartAccount.execute
실제 호출 관계는 다음과 같다.
validateUserOp는 Smart Account의 권한 검증 함수다.
여기서 확인할 내용은 다음과 같다.
userOp.sender가 현재 account인지 확인한다.userOp.callData의 selector를 읽는다.- selector에 맞는 권한 경로를 선택한다.
- owner proof, recovery proof, policy proof 중 필요한 것을 검증한다.
- 성공하면 EntryPoint가 이해하는
validationData를 반환한다. - 실패하면
SIG_VALIDATION_FAILED를 반환한다.
selector 기준으로 나누면 구조가 단순해진다.
| selector | 필요한 권한 |
|---|---|
execute | owner proof |
executeBatch | owner proof |
registerPasskey | owner proof |
registerPolicy | owner proof |
recoverPasskey | recovery proof |
executePolicy | policy 조건 + executor signature |
updateVerifier | owner proof |
upgradeToAndCall | owner proof |
실패 처리도 구분하는 편이 좋다.
validateUserOp에서는 malformed proof나 잘못된 권한을 SIG_VALIDATION_FAILED로 처리한다. 실제 실행 함수에서는 custom error로 revert한다. 이렇게 하면 EntryPoint validation 흐름과 디버깅용 실패 이유를 둘 다 유지할 수 있다.
검증 분기는 다음처럼 볼 수 있다.
Action과 UserOperation
사용자가 승인하는 것은 보통 raw calldata가 아니다.
사용자는 이런 의미 단위를 승인한다.
10 USDC를 merchant에게 보낸다.
새 passkey를 등록한다.
월 1회 최대 10 USDC 자동결제를 허용한다.
ERC-4337에서 실제 실행 단위는 UserOperation이다. 따라서 application layer의 Action과 실행 포맷인 UserOperation을 분리해서 다루는 것이 좋다.
예시:
type Action = {
wallet: string
chainId: number
entryPoint: string
actionType: string
target: string
method: string
calldataHash: string
value: string
feePolicyHash: string
previewHash: string
nonce: string
expiry: number
}
중요한 필드는 previewHash다.
사용자가 본 화면과 실제 실행 payload가 다르면 안 된다. 수신자, 금액, target, calldata, fee policy, expiry가 바뀌면 action hash도 바뀌어야 한다.
Action과 UserOperation의 관계는 다음과 같다.
Proxy 패턴
AA 지갑은 사용자마다 account가 필요하다.
사용자마다 전체 implementation을 배포하면 비용이 크다. 일반적으로는 implementation을 하나 배포하고, 사용자별로 proxy를 배포한다.
구조:
SmartAccount implementation
-> ERC1967 proxy
-> user-specific storage
배포 구조는 다음과 같다.
proxy storage에 들어가는 값은 다음과 같다.
| 상태 | 설명 |
|---|---|
| owner credential | 일반 실행 권한 |
| recovery credential | 복구 권한 |
| verifier | passkey proof 검증 컨트랙트 |
| EntryPoint | 신뢰하는 ERC-4337 EntryPoint |
| policy mapping | 자동결제 등 반복 실행 정책 |
| auth epoch | 복구 이후 기존 권한 무효화에 사용 |
implementation 컨트랙트는 직접 초기화되지 않아야 한다.
constructor() {
_disableInitializers();
}
업그레이드를 허용한다면 upgradeToAndCall도 owner proof를 요구해야 한다. upgrade 권한은 지갑의 검증 로직 전체를 바꿀 수 있기 때문이다.
Factory와 CREATE2
Factory는 Smart Account proxy를 배포한다.
AA 지갑에서는 CREATE2가 유용하다. 배포 전에도 address를 계산할 수 있기 때문이다.
Factory에서 확인할 것:
- implementation에 코드가 있는지 확인한다.
- verifier에 코드가 있는지 확인한다.
- EntryPoint에 코드가 있는지 확인한다.
- owner credential과 recovery credential, nonce로 salt를 만든다.
- CREATE2로 proxy 주소를 계산한다.
- 이미 배포된 계정이면 기존 주소를 반환한다.
- 새 계정이면 proxy를 배포하고 초기화한다.
salt 예시:
keccak256(
abi.encode(
ownerProofType,
ownerQx,
ownerQy,
recoveryProofType,
recoveryQx,
recoveryQy,
nonce
)
)
주소를 예측할 수 있어도 그 주소의 owner가 증명되는 것은 아니다. 주소 조회는 discovery이고, 권한 판단은 Smart Account validation에서 해야 한다.
Factory 배포 흐름은 다음과 같다.
Passkey Verifier
Passkey 기반 지갑에서는 verifier를 Smart Account와 분리하는 편이 좋다.
Smart Account
-> credential 등록 상태와 권한 판단
Verifier
-> P-256/WebAuthn proof 검증
credential은 hash로 식별할 수 있다.
credentialHash = keccak256(domain, proofType, qx, qy)
여기서 qx, qy는 P-256 public key 좌표다.
Smart Account에는 credential index를 같이 저장할 수 있다. proof에 전체 credential 정보를 매번 넣는 대신 index를 넣으면 calldata를 줄일 수 있다.
Passkey 검증 구조는 다음과 같다.
주의할 점:
- owner와 recovery에 같은 physical key를 등록하지 않는다.
- proof type만 다르고 public key가 같은 경우도 막는다.
- recovery 후에는 auth epoch를 올린다.
- policy에도 등록 당시 auth epoch를 저장한다.
- verifier가 revert하면 validation은 실패해야 한다.
WebAuthn 검증
WebAuthn proof에는 웹 보안 문맥이 들어간다.
온체인 verifier에서 확인할 수 있는 항목은 다음과 같다.
| 항목 | 설명 |
|---|---|
| P-256 signature | authenticator가 challenge에 서명했는지 확인 |
| challenge | 실행 문맥과 proof를 묶는 값 |
| RP ID hash | 허용한 relying party인지 확인 |
| origin | 허용한 웹 origin인지 확인 |
| user verification | 사용자 검증 요구 여부 |
| clientDataJSON | challenge, origin 등이 들어 있는 JSON |
| authenticatorData | RP ID hash와 flags 등이 들어 있는 bytes |
clientDataJSON 처리는 비용이 크다. 전체 JSON을 memory로 복사해서 범용 parser처럼 처리하면 비싸다.
가벼운 방법은 필요한 top-level field만 calldata에서 읽는 것이다.
예시 정책:
- top-level
origin만 읽는다. - origin string은 escape sequence를 허용하지 않는다.
crossOrigin: true는 거부한다.topOrigin이 있으면 거부한다.- JSON 전체 길이에 상한을 둔다.
- authenticatorData 길이에도 상한을 둔다.
이 방식은 범용 JSON parser가 아니다. 허용할 입력을 좁히는 방식이다.
WebAuthn verifier 내부 흐름은 다음과 같다.
EIP-1271
Passkey private key는 일반적으로 export할 수 없다.
그래서 private key 보유를 seed phrase처럼 보여주기 어렵다. 대신 EIP-1271로 Smart Account가 어떤 proof를 유효한 signature로 인정하는지 확인할 수 있다.
흐름:
1. verifier가 독립 challenge를 만든다.
2. 사용자가 passkey로 challenge에 대한 proof를 만든다.
3. verifier가 SmartAccount.isValidSignature(hash, proof)를 호출한다.
4. Smart Account가 등록된 credential과 verifier로 proof를 검증한다.
5. 성공하면 EIP-1271 magic value를 반환한다.
이 방식으로 확인할 수 있는 것:
- 서버 DB가 아니라 Smart Account가 credential을 기준으로 검증한다.
- 사용자가 독립 challenge에 대한 approval proof를 만들 수 있다.
- proof가 없으면 같은 검증을 통과할 수 없다.
EIP-1271 검증 흐름은 다음과 같다.
자동결제 Policy
자동결제는 서버가 나중에 대신 서명하는 구조가 아니어야 한다.
더 안전한 구조는 on-chain policy다.
1. 사용자가 자동결제 조건을 확인한다.
2. owner passkey proof로 policy를 등록한다.
3. 실행 시점에는 executor가 UserOperation을 전송한다.
4. Smart Account가 policy 조건을 매번 검증한다.
예시 struct:
struct RecurringPolicy {
address target;
bytes4 selector;
uint48 interval;
address recipient;
uint48 validUntil;
uint48 lastExecutedAt;
address allowedExecutor;
uint64 authEpoch;
uint128 valueCap;
}
검증 조건:
| 필드 | 검증 내용 |
|---|---|
target | 허용된 token contract인지 확인 |
selector | 허용된 method인지 확인 |
recipient | 승인된 수취인인지 확인 |
valueCap | 금액 한도를 넘지 않는지 확인 |
interval | 실행 주기를 만족하는지 확인 |
validUntil | policy가 만료되지 않았는지 확인 |
allowedExecutor | 실행자가 허용된 주소인지 확인 |
authEpoch | 복구 이후 낡은 policy가 아닌지 확인 |
이 구조에서는 executor가 owner가 아니다. executor는 policy 범위 안에서만 실행을 트리거할 수 있다.
정책 등록 흐름은 다음과 같다.
정책 실행 흐름은 다음과 같다.
Paymaster
Paymaster는 수수료 후원자다. 지갑 실행 권한자가 아니다.
분리:
SmartAccount.validateUserOp
-> owner proof, recovery proof, policy 검증
Paymaster.validatePaymasterUserOp
-> sponsor policy, max cost, validity window 검증
Paymaster signature에 묶을 수 있는 값:
account
nonce
initCodeHash
callDataHash
accountGasLimits
paymasterGasLimits
preVerificationGas
gasFees
maxCost
validUntil
validAfter
이 signature의 의미는 "이 UserOperation의 gas를 조건부로 후원한다"이다. "지갑 실행을 승인한다"가 아니다.
Paymaster와 Smart Account의 책임은 다음처럼 분리된다.
가스 최적화
가스 최적화는 hot path부터 보는 것이 좋다.
AA 지갑에서 hot path는 보통 다음이다.
validateUserOp- passkey verifier
- policy execution
- paymaster validation
- calldata decoding
적용할 수 있는 최적화:
| 방법 | 설명 |
|---|---|
| proxy 사용 | 사용자별 전체 implementation 배포를 피한다. |
| CREATE2 factory | 주소 예측과 기존 계정 재사용을 지원한다. |
| custom error | revert string보다 비용이 작다. |
| constant/typehash | selector, typehash 등 고정값을 상수화한다. |
| immutable | verifier, EntryPoint 등 배포 후 바뀌지 않는 값을 고정한다. |
| credential index | mapping key를 매번 계산하지 않고 index로 접근한다. |
| packed proof | 고정 길이 proof의 ABI overhead를 줄인다. |
| calldata slice | dynamic bytes를 memory copy 없이 읽는다. |
| local cache | 같은 storage field를 반복 조회하지 않는다. |
| selector dispatch | 필요한 검증 경로만 실행한다. |
| validationData | validUntil, validAfter를 EntryPoint에 반환한다. |
packed proof 예시:
credentialIndex: 32 bytes
validUntil: 6 bytes
r: 32 bytes
s: 32 bytes
일반 ABI encoding보다 짧다. 대신 length check와 bounds check가 반드시 필요하다.
calldata decoding도 같은 원칙이다. 예를 들어 ERC20 transfer(address,uint256)만 허용한다면 calldata 길이는 68 bytes다.
확인할 것:
- selector가
transfer인지 확인한다. - 길이가 68 bytes인지 확인한다.
- recipient word가
uint160범위를 넘지 않는지 확인한다. - amount가 0이 아닌지 확인한다.
가스 최적화 대상은 다음 경로에 집중한다.
Assembly 사용 범위
assembly는 좁은 곳에만 쓰는 것이 좋다.
적합한 경우:
- calldata에서 selector 읽기
- 고정 위치의 packed field 읽기
- ERC20 transfer calldata에서 recipient, amount 읽기
- 외부 call 실패 시 revert reason 전달
- calldata substring hash 계산
피해야 할 경우:
- 권한 로직 전체를 assembly로 작성
- bounds check 없이 calldataload 사용
- 동적 bytes offset을 직접 믿는 코드
- 실패를 조용히 무시하는 외부 call
외부 call은 실패 reason을 전달하는 편이 좋다.
if (!ok) {
if (resultLength > 0) {
assembly {
returndatacopy(0, 0, resultLength)
revert(0, resultLength)
}
}
revert TargetCallFailed();
}
Recovery
Recovery 권한은 실행 권한과 분리해야 한다.
복구 흐름은 다음과 같다.
기준:
Recovery proof
-> owner credential 교체 가능
-> 일반 결제 실행 불가
-> policy 등록 불가
-> upgrade 실행 불가
복구 함수는 새 owner credential을 등록하고 auth epoch를 올린다.
효과:
- 기존 owner credential이 비활성화된다.
- 기존 policy가 stale 상태가 된다.
- 새 owner credential만 이후 실행 권한을 가진다.
recovery passphrase를 쓰는 경우에도 역할을 제한해야 한다.
허용:
encrypted recovery backup을 사용자 기기에서 복호화
금지:
wallet approval 생성
owner signature 대체
서버 단독 복구
테스트할 것
AA 지갑 테스트는 실패 케이스가 중요하다.
| 테스트 | 확인 내용 |
|---|---|
| proof 없는 UserOperation 실패 | 서버가 payload만으로 지갑을 움직일 수 없음 |
| malformed proof 실패 | 깨진 입력이 승인으로 해석되지 않음 |
| verifier revert 실패 | 외부 verifier 오류가 fail closed로 처리됨 |
| wrong origin 실패 | WebAuthn origin boundary 확인 |
| wrong RP ID hash 실패 | WebAuthn RP boundary 확인 |
| owner/recovery 같은 key 거부 | 권한 분리 확인 |
| implementation 직접 initialize 실패 | proxy 초기화 취약점 방지 |
| non-EntryPoint execute 실패 | 실행 경로 제한 |
| policy recipient 위반 실패 | 자동결제 수취인 제한 |
| policy cap 위반 실패 | 금액 한도 제한 |
| policy interval 위반 실패 | 반복 실행 제한 |
| recovery 후 old policy 실패 | auth epoch 적용 확인 |
| paymaster digest 변경 실패 | sponsor signature binding 확인 |
체크리스트
AA 지갑 Solidity 설계에서 확인할 항목:
- account session과 wallet approval이 분리되어 있는가?
- selector별 authority가 명확한가?
- owner proof 없이 실행 가능한 경로가 있는가?
- recovery proof가 일반 실행 권한으로 쓰이지 않는가?
- policy execution이 target, recipient, cap, interval, expiry를 검증하는가?
- Paymaster signature가 wallet approval로 해석되지 않는가?
- verifier 교체와 implementation upgrade가 owner proof를 요구하는가?
- CREATE2 address는 예측 가능하지만 권한 판단에 쓰이지 않는가?
- WebAuthn verifier가 RP ID, origin, challenge를 검증하는가?
- gas 최적화가 입력 검증을 약하게 만들지 않았는가?
요약하면 AA 지갑의 Solidity 설계는 권한 분리 문제다.
Proxy와 factory는 배포 구조를 만든다. Verifier는 proof 검증을 담당한다. Policy는 자동화를 제한한다. Paymaster는 gas sponsorship만 담당한다. Smart Account는 이 경계를 모아서 최종 실행 가능 여부를 판단한다.
다음 읽기
이 생각이 이어지는 방향
읽은 뒤의 대화
읽은 뒤의 생각을 이어갑니다
질문, 반론, 조용한 후속 메모를 이 글 아래에 남길 수 있습니다.