sayu.day

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

AA(Account Abstraction) 지갑은 EOA와 다릅니다. EOA는 개인키 하나가 곧 실행 권한이지만, AA 지갑에서는 Smart Account가 실행 권한을 직접 검증합니다. 그래서 설계 단계에서는 "누가 서명했는가"보다 "어떤 권한 경로를 통과했는가"를 먼저 살펴봐야 합니다. 이 글은 Passkey 기반 Smart Account를 기준으로 proxy, factory, verifier, paymaster 구성과 ERC-4337 검증 흐름, 가스 최적화 패턴을 정리합니다.

핵심 구성요소

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

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

전체 구조는 다음과 같습니다.

flowchart LR
  User["사용자"] --> SDK["App / SDK"]
  SDK --> Backend["Backend<br/>Action 생성 / 수수료 계산"]
  Backend --> Bundler["Bundler"]
  Bundler --> EntryPoint["EntryPoint"]
  EntryPoint --> Account["Smart Account"]
  Account --> Verifier["Passkey Verifier"]
  EntryPoint --> Paymaster["Paymaster"]
  Account --> Target["Target Contract<br/>ERC20 / App"]

  Verifier --> Account
  Paymaster --> 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를 대신 만들 수는 없습니다.

권한 관계는 다음처럼 분리됩니다.

flowchart TD
  Session["Account Session<br/>로그인 / 사용자 식별"]
  Owner["Wallet Execution<br/>owner passkey proof"]
  Recovery["Recovery<br/>recovery proof"]
  Policy["Automation<br/>on-chain policy"]
  Sponsor["Gas Sponsorship<br/>paymaster signature"]
  Account["Smart Account"]
  Paymaster["Paymaster"]
  EntryPoint["EntryPoint"]

  Session -.-> Account
  Owner --> Account
  Recovery --> Account
  Policy --> Account
  Sponsor --> Paymaster
  Paymaster --> EntryPoint
  EntryPoint --> Account

ERC-4337 검증 흐름

ERC-4337의 기본 흐름은 다음과 같습니다.

User action
-> UserOperation
-> Bundler
-> EntryPoint.handleOps
-> SmartAccount.validateUserOp
-> SmartAccount.execute

실제 호출 관계는 다음과 같습니다.

sequenceDiagram
  actor U as User
  participant SDK as App / SDK
  participant B as Backend
  participant BU as Bundler
  participant EP as EntryPoint
  participant SA as Smart Account
  participant V as Verifier
  participant T as Target

  U->>SDK: action 확인
  SDK->>SDK: passkey proof 생성
  SDK->>B: proof 전달
  B->>B: UserOperation 구성
  B->>BU: UserOperation 전송
  BU->>EP: handleOps
  EP->>SA: validateUserOp
  SA->>V: proof 검증
  V-->>SA: ok
  EP->>SA: execute
  SA->>T: call

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 필요한 권한
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 흐름과 디버깅용 실패 이유를 모두 유지할 수 있습니다.

검증 분기는 다음처럼 볼 수 있습니다.

flowchart TD
  Start["validateUserOp"] --> Sender{"sender == account?"}
  Sender -- "no" --> Fail["SIG_VALIDATION_FAILED"]
  Sender -- "yes" --> Selector["callData selector 읽기"]
  Selector --> Kind{"selector"}
  Kind -- "execute / batch / register / upgrade" --> Owner["owner proof 검증"]
  Kind -- "recoverPasskey" --> Recovery["recovery proof 검증"]
  Kind -- "executePolicy" --> Policy["policy + executor 검증"]
  Kind -- "unknown" --> Fail
  Owner --> Result{"ok?"}
  Recovery --> Result
  Policy --> Result
  Result -- "yes" --> Success["validationData 반환"]
  Result -- "no" --> Fail

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의 관계는 다음과 같습니다.

flowchart LR
  Preview["사용자 화면<br/>recipient / amount / fee / expiry"]
  Action["Canonical Action"]
  Hash["actionHash / previewHash"]
  Proof["passkey proof"]
  Compiler["Action Compiler"]
  UserOp["ERC-4337 UserOperation"]
  Account["Smart Account validation"]

  Preview --> Action
  Action --> Hash
  Hash --> Proof
  Action --> Compiler
  Compiler --> UserOp
  Proof --> UserOp
  UserOp --> Account

Proxy 패턴

AA 지갑은 사용자마다 account가 필요합니다.

사용자마다 전체 implementation을 배포하면 비용이 큽니다. 일반적으로는 implementation을 하나 배포하고, 사용자별로 proxy를 배포합니다.

구조:

SmartAccount implementation
-> ERC1967 proxy
-> user-specific storage

배포 구조는 다음과 같습니다.

flowchart TD
  Impl["SmartAccount Implementation<br/>공유 로직"]
  ProxyA["Proxy A<br/>user A storage"]
  ProxyB["Proxy B<br/>user B storage"]
  ProxyC["Proxy C<br/>user C storage"]

  ProxyA --> Impl
  ProxyB --> Impl
  ProxyC --> Impl

  ProxyA --> StorageA["owner / recovery / policy / epoch"]
  ProxyB --> StorageB["owner / recovery / policy / epoch"]
  ProxyC --> StorageC["owner / recovery / policy / epoch"]

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에서 확인할 것:

  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 배포 흐름은 다음과 같습니다.

flowchart TD
  Input["owner key + recovery key + nonce"]
  Salt["CREATE2 salt"]
  InitCode["Proxy init code<br/>implementation + initialize args"]
  Address["counterfactual address"]
  Exists{"code exists?"}
  Return["기존 account 반환"]
  Deploy["CREATE2 deploy"]
  Init["initialize<br/>owner / recovery / verifier / EntryPoint"]

  Input --> Salt
  Input --> InitCode
  Salt --> Address
  InitCode --> Address
  Address --> Exists
  Exists -- "yes" --> Return
  Exists -- "no" --> Deploy
  Deploy --> Init

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 검증 구조는 다음과 같습니다.

flowchart LR
  Proof["proof<br/>credentialIndex + validUntil + signature"]
  Account["Smart Account"]
  Store["credential storage<br/>index -> qx, qy, proofType, epoch"]
  Hash["credentialHash"]
  Challenge["EIP-712 challenge<br/>userOpHash / validUntil"]
  Verifier["Passkey Verifier"]
  P256["P-256 / WebAuthn verify"]

  Proof --> Account
  Account --> Store
  Store --> Hash
  Account --> Challenge
  Hash --> Verifier
  Challenge --> Verifier
  Proof --> Verifier
  Verifier --> P256
  P256 --> Account

주의할 점:

  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 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에서 읽는 것입니다.

예시 정책:

  1. top-level origin만 읽습니다.
  2. origin string은 escape sequence를 허용하지 않습니다.
  3. crossOrigin: true는 거부합니다.
  4. topOrigin이 있으면 거부합니다.
  5. JSON 전체 길이에 상한을 둡니다.
  6. authenticatorData 길이에도 상한을 둡니다.

이 방식은 범용 JSON parser가 아닙니다. 허용할 입력을 좁히는 방식입니다.

WebAuthn verifier 내부 흐름은 다음과 같습니다.

flowchart TD
  Proof["WebAuthn proof"]
  Decode["tryDecodeAuth"]
  Bounds["길이 제한 확인"]
  Rp["authenticatorData<br/>RP ID hash 확인"]
  Origin["clientDataJSON<br/>origin hash 확인"]
  Challenge["challenge binding 확인"]
  P256["P-256 signature verify"]
  Ok["true"]
  Fail["false"]

  Proof --> Decode
  Decode --> Bounds
  Bounds --> Rp
  Rp --> Origin
  Origin --> Challenge
  Challenge --> P256
  P256 --> Ok
  Decode -- "decode fail" --> Fail
  Bounds -- "too long / too short" --> Fail
  Rp -- "wrong RP" --> Fail
  Origin -- "wrong origin" --> Fail

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 검증 흐름은 다음과 같습니다.

sequenceDiagram
  participant Third as Third-party verifier
  actor U as User
  participant SDK as App / SDK
  participant SA as Smart Account
  participant V as Passkey Verifier

  Third->>SDK: challenge 전달
  SDK->>U: passkey 승인 요청
  U->>SDK: 사용자 검증
  SDK-->>Third: proof 반환
  Third->>SA: isValidSignature(hash, proof)
  SA->>V: registered credential로 proof 검증
  V-->>SA: ok
  SA-->>Third: EIP-1271 magic value

자동결제 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 범위 안에서만 실행을 트리거할 수 있습니다.

정책 등록 흐름은 다음과 같습니다.

sequenceDiagram
  actor U as User
  participant SDK as App / SDK
  participant B as Backend
  participant EP as EntryPoint
  participant SA as Smart Account
  participant V as Verifier

  U->>SDK: 자동결제 조건 확인
  SDK->>SDK: policy action hash 생성
  SDK->>SDK: owner passkey proof 생성
  SDK->>B: policy 등록 요청 + proof
  B->>EP: UserOperation 전송
  EP->>SA: validateUserOp
  SA->>V: owner proof 검증
  V-->>SA: ok
  EP->>SA: registerPolicy
  SA->>SA: policy 저장

정책 실행 흐름은 다음과 같습니다.

sequenceDiagram
  participant EX as Executor
  participant B as Backend
  participant PM as Paymaster
  participant EP as EntryPoint
  participant SA as Smart Account
  participant Token as ERC20

  EX->>B: 실행 시점 도래
  B->>PM: paymasterAndData 요청
  PM-->>B: sponsor data 반환
  B->>EP: executePolicy UserOperation
  EP->>PM: validatePaymasterUserOp
  PM-->>EP: gas sponsorship ok
  EP->>SA: validateUserOp
  SA->>SA: policy + executor signature 검증
  EP->>SA: executePolicy
  SA->>Token: transfer(recipient, amount)

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의 책임은 다음처럼 분리됩니다.

flowchart TD
  UserOp["UserOperation"]
  AccountValidation["SmartAccount.validateUserOp"]
  PaymasterValidation["Paymaster.validatePaymasterUserOp"]
  Owner["owner / recovery / policy 권한"]
  Sponsor["sponsor policy / maxCost / validity"]
  Execute["SmartAccount.execute"]
  Gas["gas sponsorship"]

  UserOp --> AccountValidation
  UserOp --> PaymasterValidation
  AccountValidation --> Owner
  PaymasterValidation --> Sponsor
  Owner --> Execute
  Sponsor --> Gas

가스 최적화

가스 최적화는 hot path부터 보는 것이 좋습니다.

AA 지갑에서 hot path는 보통 다음과 같습니다.

  1. validateUserOp
  2. passkey verifier
  3. policy execution
  4. paymaster validation
  5. 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입니다.

확인할 것:

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

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

flowchart LR
  UserOp["UserOperation calldata"]
  Decode["selector / proof decode"]
  Account["validateUserOp"]
  Verifier["passkey verifier"]
  Policy["policy validation"]
  Execute["execute / transfer"]

  UserOp --> Decode
  Decode --> Account
  Account --> Verifier
  Account --> Policy
  Policy --> Execute

  Decode -. "calldata slice" .-> Save1["memory copy 감소"]
  Verifier -. "origin scan" .-> Save2["string copy 감소"]
  Policy -. "local cache" .-> Save3["storage read 감소"]

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 권한은 실행 권한과 분리해야 합니다.

복구 흐름은 다음과 같습니다.

sequenceDiagram
  actor U as User
  participant SDK as App / SDK
  participant EP as EntryPoint
  participant SA as Smart Account
  participant V as Verifier

  U->>SDK: 새 passkey 생성
  SDK->>SDK: recovery proof 생성
  SDK->>EP: recoverPasskey UserOperation
  EP->>SA: validateUserOp
  SA->>V: recovery credential proof 검증
  V-->>SA: ok
  EP->>SA: recoverPasskey
  SA->>SA: 새 owner credential 저장
  SA->>SA: authEpoch 증가
  SA->>SA: 기존 policy stale 처리

기준:

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는 이 경계를 모두 모아 최종 실행 가능 여부를 판단합니다. 이 원칙만 지키면 어떤 인증 수단을 추가하더라도 권한 경계는 흔들리지 않습니다.