sayu.day
Blockchain

AA 지갑 설계 노트: Solidity 패턴과 가스 최적화

AA 지갑은 단일 개인키가 아니라 컨트랙트가 권한을 검증하는 구조입니다. Passkey 기반 Smart Account를 기준으로 proxy, factory, policy, paymaster, ERC-4337 validation, gas optimization을 정리합니다.

발행 2026년 5월 13일152,967

AA(Account Abstraction) 지갑은 EOA와 다르다.

EOA는 개인키 하나가 실행 권한이다. AA 지갑은 Smart Account가 실행 권한을 직접 검증한다. 그래서 설계할 때는 "누가 서명했는가"보다 "어떤 권한 경로를 통과했는가"를 먼저 봐야 한다.

주요 구성요소는 다음과 같다.

구성요소역할
Smart Account사용자 계정 컨트랙트. 실행, 복구, 정책 검증을 담당한다.
EntryPointERC-4337 UserOperation을 검증하고 실행한다.
FactorySmart Account proxy를 예측 가능한 주소에 배포한다.
Proxy사용자별 storage를 가진 계정 인스턴스다.
Verifierpasskey/P-256/WebAuthn proof를 검증한다.
Paymaster수수료 후원 조건을 검증한다.
BundlerUserOperation을 EntryPoint에 전송한다.

전체 구조는 다음과 같다.

권한 분리

AA 지갑에서 가장 먼저 나눌 것은 권한이다.

권한의미검증 근거
Account Session서비스에서 사용자를 식별한다.로그인, 세션, 앱 인증
Wallet Execution Authority지갑에서 특정 action을 실행한다.owner passkey proof
Recovery Authorityowner 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의 권한 검증 함수다.

여기서 확인할 내용은 다음과 같다.

  1. userOp.sender가 현재 account인지 확인한다.
  2. userOp.callData의 selector를 읽는다.
  3. selector에 맞는 권한 경로를 선택한다.
  4. owner proof, recovery proof, policy proof 중 필요한 것을 검증한다.
  5. 성공하면 EntryPoint가 이해하는 validationData를 반환한다.
  6. 실패하면 SIG_VALIDATION_FAILED를 반환한다.

selector 기준으로 나누면 구조가 단순해진다.

selector필요한 권한
executeowner proof
executeBatchowner proof
registerPasskeyowner proof
registerPolicyowner proof
recoverPasskeyrecovery proof
executePolicypolicy 조건 + executor signature
updateVerifierowner proof
upgradeToAndCallowner 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복구 권한
verifierpasskey 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에서 확인할 것:

  1. implementation에 코드가 있는지 확인한다.
  2. verifier에 코드가 있는지 확인한다.
  3. EntryPoint에 코드가 있는지 확인한다.
  4. owner credential과 recovery credential, nonce로 salt를 만든다.
  5. CREATE2로 proxy 주소를 계산한다.
  6. 이미 배포된 계정이면 기존 주소를 반환한다.
  7. 새 계정이면 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 검증 구조는 다음과 같다.

주의할 점:

  1. owner와 recovery에 같은 physical key를 등록하지 않는다.
  2. proof type만 다르고 public key가 같은 경우도 막는다.
  3. recovery 후에는 auth epoch를 올린다.
  4. policy에도 등록 당시 auth epoch를 저장한다.
  5. verifier가 revert하면 validation은 실패해야 한다.

WebAuthn 검증

WebAuthn proof에는 웹 보안 문맥이 들어간다.

온체인 verifier에서 확인할 수 있는 항목은 다음과 같다.

항목설명
P-256 signatureauthenticator가 challenge에 서명했는지 확인
challenge실행 문맥과 proof를 묶는 값
RP ID hash허용한 relying party인지 확인
origin허용한 웹 origin인지 확인
user verification사용자 검증 요구 여부
clientDataJSONchallenge, origin 등이 들어 있는 JSON
authenticatorDataRP ID hash와 flags 등이 들어 있는 bytes

clientDataJSON 처리는 비용이 크다. 전체 JSON을 memory로 복사해서 범용 parser처럼 처리하면 비싸다.

가벼운 방법은 필요한 top-level field만 calldata에서 읽는 것이다.

예시 정책:

  1. top-level origin만 읽는다.
  2. origin string은 escape sequence를 허용하지 않는다.
  3. crossOrigin: true는 거부한다.
  4. topOrigin이 있으면 거부한다.
  5. JSON 전체 길이에 상한을 둔다.
  6. 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를 반환한다.

이 방식으로 확인할 수 있는 것:

  1. 서버 DB가 아니라 Smart Account가 credential을 기준으로 검증한다.
  2. 사용자가 독립 challenge에 대한 approval proof를 만들 수 있다.
  3. 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실행 주기를 만족하는지 확인
validUntilpolicy가 만료되지 않았는지 확인
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는 보통 다음이다.

  1. validateUserOp
  2. passkey verifier
  3. policy execution
  4. paymaster validation
  5. calldata decoding

적용할 수 있는 최적화:

방법설명
proxy 사용사용자별 전체 implementation 배포를 피한다.
CREATE2 factory주소 예측과 기존 계정 재사용을 지원한다.
custom errorrevert string보다 비용이 작다.
constant/typehashselector, typehash 등 고정값을 상수화한다.
immutableverifier, EntryPoint 등 배포 후 바뀌지 않는 값을 고정한다.
credential indexmapping key를 매번 계산하지 않고 index로 접근한다.
packed proof고정 길이 proof의 ABI overhead를 줄인다.
calldata slicedynamic bytes를 memory copy 없이 읽는다.
local cache같은 storage field를 반복 조회하지 않는다.
selector dispatch필요한 검증 경로만 실행한다.
validationDatavalidUntil, 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다.

확인할 것:

  1. selector가 transfer인지 확인한다.
  2. 길이가 68 bytes인지 확인한다.
  3. recipient word가 uint160 범위를 넘지 않는지 확인한다.
  4. amount가 0이 아닌지 확인한다.

가스 최적화 대상은 다음 경로에 집중한다.

Assembly 사용 범위

assembly는 좁은 곳에만 쓰는 것이 좋다.

적합한 경우:

  1. calldata에서 selector 읽기
  2. 고정 위치의 packed field 읽기
  3. ERC20 transfer calldata에서 recipient, amount 읽기
  4. 외부 call 실패 시 revert reason 전달
  5. calldata substring hash 계산

피해야 할 경우:

  1. 권한 로직 전체를 assembly로 작성
  2. bounds check 없이 calldataload 사용
  3. 동적 bytes offset을 직접 믿는 코드
  4. 실패를 조용히 무시하는 외부 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를 올린다.

효과:

  1. 기존 owner credential이 비활성화된다.
  2. 기존 policy가 stale 상태가 된다.
  3. 새 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 설계에서 확인할 항목:

  1. account session과 wallet approval이 분리되어 있는가?
  2. selector별 authority가 명확한가?
  3. owner proof 없이 실행 가능한 경로가 있는가?
  4. recovery proof가 일반 실행 권한으로 쓰이지 않는가?
  5. policy execution이 target, recipient, cap, interval, expiry를 검증하는가?
  6. Paymaster signature가 wallet approval로 해석되지 않는가?
  7. verifier 교체와 implementation upgrade가 owner proof를 요구하는가?
  8. CREATE2 address는 예측 가능하지만 권한 판단에 쓰이지 않는가?
  9. WebAuthn verifier가 RP ID, origin, challenge를 검증하는가?
  10. gas 최적화가 입력 검증을 약하게 만들지 않았는가?

요약하면 AA 지갑의 Solidity 설계는 권한 분리 문제다.

Proxy와 factory는 배포 구조를 만든다. Verifier는 proof 검증을 담당한다. Policy는 자동화를 제한한다. Paymaster는 gas sponsorship만 담당한다. Smart Account는 이 경계를 모아서 최종 실행 가능 여부를 판단한다.

다음 읽기

이 생각이 이어지는 방향

Blockchain 더 보기
공유

읽은 뒤의 대화

읽은 뒤의 생각을 이어갑니다

질문, 반론, 조용한 후속 메모를 이 글 아래에 남길 수 있습니다.

sayu.day는 생각과 작업의 흔적을 천천히 정리하는 개인 출판물입니다.
직접 겪고 검토한 내용, 다시 읽을 만한 아이디어, 작업하며 남긴 메모를 모읍니다.
시간이 지난 글은 현재의 판단과 다를 수 있어 업데이트 맥락을 함께 남깁니다.

© 2026 sayu.day