· 32분 읽기
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의 권한 검증 함수입니다.
여기서 확인할 내용은 다음과 같습니다.
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 흐름과 디버깅용 실패 이유를 모두 유지할 수 있습니다.
검증 분기는 다음처럼 볼 수 있습니다.
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에서 확인할 것:
- 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 배포 흐름은 다음과 같습니다.
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
주의할 점:
- 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 내부 흐름은 다음과 같습니다.
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를 반환한다.
이 방식으로 확인할 수 있는 것:
- 서버 DB가 아니라 Smart Account가 credential을 기준으로 검증합니다.
- 사용자가 독립 challenge에 대한 approval proof를 만들 수 있습니다.
- 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는 보통 다음과 같습니다.
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이 아닌지 확인합니다.
가스 최적화 대상은 다음 경로에 집중합니다.
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는 좁은 곳에만 쓰는 것이 좋습니다.
적합한 경우:
- 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 권한은 실행 권한과 분리해야 합니다.
복구 흐름은 다음과 같습니다.
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를 올립니다.
효과:
- 기존 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는 이 경계를 모두 모아 최종 실행 가능 여부를 판단합니다. 이 원칙만 지키면 어떤 인증 수단을 추가하더라도 권한 경계는 흔들리지 않습니다.