EIP-86 → ERC-4337 → EIP-3074 → EIP-7702 → EIP-8141. 이더리움이 9년에 걸쳐 Account Abstraction에 도달하는 계보를 완전 해부한다. 프레임 모드, APPROVE 오퍼코드, 원자적 배치, 2차원 가스, mempool 모델까지 — 개발자가 실제로 써야 할 모든 것.

⚠️ EIP-8141은 2026년 9월 기준 활발히 논의·수정 중인 드래프트입니다. opcode 번호, 스택 순서, 가스 모델이 PR 병합에 따라 계속 바뀌고 있습니다. 이 글은 현재까지 병합된 사양(current-spec)을 기준으로 작성했습니다.
이더리움의 Account Abstraction(AA)은 하나의 아이디어가 여러 번의 실패와 절충을 거쳐 완성되는 여정이었다. 각 시도가 왜 탄생했고, 왜 불완전했는지 먼저 이해해야 EIP-8141이 무엇을 해결하는지 보인다.
Vitalik이 2016년 제안한 EIP-86은 "모든 계정은 컨트랙트다"라는 비전을 처음으로 공식화했다. null 서명(chain_id, 0, 0)이 담긴 트랜잭션은 NULL_SENDER 주소로 가고, 사용자는 커스텀 검증 로직을 가진 컨트랙트에 ETH를 보유한다. 트랜잭션 발신자(origin)와 서명 방식을 프로토콜에서 분리하자는 핵심 아이디어였다.
🚧 왜 실패했나: 너무 급진적이었다. 합의 레이어 변경이 필요했고, 안전한 mempool 전파를 위한 검증 프레임워크가 없었다. CREATE2가 이 EIP에서 처음 등장했지만 별도로(EIP-1014) 먼저 배포됐다.
ERC-4337의 전략은 "프로토콜을 건드리지 말자"였다. UserOperation이라는 pseudo-트랜잭션을 alt mempool에 모아서, Bundler가 묶어 EntryPoint 컨트랙트로 submit한다. Paymaster가 가스를 스폰서하고, Aggregator가 서명을 묶는다. 핵심 컴포넌트:
🚧 한계: Bundler 중앙화 위험(검열 가능), 별도 alt mempool 인프라, EntryPoint가 msg.sender로 끼어들어 기존 컨트랙트 호환성 문제, 여전히 ECDSA 의존(bundler 트랜잭션은 EOA가 서명).
EIP-3074는 두 개의 오퍼코드로 EOA에게 권한 위임 기능을 부여하려 했다. AUTH(0xf6): EOA의 ECDSA 서명을 검증하고 authorized 컨텍스트 변수를 설정. AUTHCALL(0xf7): msg.sender를 authorized 주소로 설정하며 CALL. 즉 "invoker 컨트랙트가 EOA 대신 호출할 수 있게" 된다.
AUTH [authority, offset, length] → success
AUTHCALL [gas, addr, value, ...] → (success, output)🚧 왜 대체됐나: 임의 invoker 컨트랙트에 트랜잭션 범위 위임 → 악의적 invoker 위험. 위임이 트랜잭션 단위로만 유효해서 지속적 스마트 계정으로의 마이그레이션에 부적합. EIP-7702의 영구 위임 방식이 더 안전하고 설계 목표에 맞는다고 판단해 철회.
EIP-7702는 트랜잭션에 authorization_list를 추가해서 EOA의 코드를 23바이트 위임 포인터로 설정한다. 모든 CALL이 이 포인터를 따라가므로 EOA가 실질적으로 스마트 계정처럼 동작한다. Pectra 하드포크(2025년 5월)로 메인넷 활성화.
// authorization tuple [chain_id, address, nonce, y_parity, r, s] // resulting code in EOA storage 0xef0100 || address // 23-byte delegation indicator // signing message (magic prefix 0x05) keccak256(0x05 || rlp([chain_id, address, nonce])) // to revoke: sign authorization to address(0)
🚧 여전한 한계: authorization_list 서명은 무조건 ECDSA(secp256k1). 스마트 계정이 직접 트랜잭션을 originate하는 프로토콜 레벨 AA는 없음 — Bundler가 여전히 필요. EIP-8141은 이 부분을 해결하기 위해 EIP-7702를 의존성으로 포함하며 그 위에 쌓인다.
| 제안 | 방식 | 핵심 한계 |
|---|---|---|
| EIP-86 (2016) | 최초 AA 아이디어 | 합의 레이어 변경 필요, mempool 미설계 |
| ERC-4337 (2021) | UserOp + Bundler + EntryPoint | 번들러 의존, alt mempool, ECDSA 종속 |
| EIP-3074 (철회) | AUTH/AUTHCALL 오퍼코드 | 임의 invoker 위험, 트랜잭션 범위 위임 |
| EIP-7702 (Pectra) | EOA에 위임 코드 설정 | 여전히 ECDSA, 프로토콜 AA 미지원 |
| EIP-8141 (Draft) | 프레임 기반 네이티브 프로토콜 AA | 아직 드래프트, 경쟁 제안 다수 존재 |
EIP-8141(Frame Transaction)은 이더리움에 새로운 트랜잭션 타입(0x06)을 추가해서, 계정 추상화를 프로토콜 레벨에서 네이티브로 지원하는 제안이다. 저자는 Vitalik Buterin을 포함해 lightclient, Felix Lange, Yoav Weiss, Alex Forshtat 등 10명. 상태는 Draft(Standards Track, Core).
Frame Transaction(타입 0x06) 하나는 최대 64개의 프레임 배열로 구성된다. 각 프레임에 역할(모드)을 부여해서 검증, 가스 지불, 실행을 명확히 분리한다. 프로토콜이 이 역할 분리를 직접 이해하기 때문에 EntryPoint 같은 중계 컨트랙트가 필요 없다.
// Frame Transaction RLP structure [chain_id, nonce, sender, frames, signatures, fees, blob_versioned_hashes] frames = [[mode, flags, target, limits, value, data], ...] limits = [execution_gas, state_gas] // 2D gas budgets // Signature entries signatures = [[scheme, signer, msg, signature], ...] // Fee fields fees = [max_priority_fee_per_gas, max_fee_per_gas, max_fee_per_blob_gas]
| 모드 | 이름 | caller | 역할 |
|---|---|---|---|
| 0 | DEFAULT | ENTRY_POINT (0xaa) | 계정 배포, 후처리(post-op). 가스 정산 환급 등 |
| 1 | VERIFY | ENTRY_POINT (0xaa) | 읽기 전용 검증(STATICCALL). 서명 확인, 가스 지불자 승인. 반드시 APPROVE 호출해야 함 |
| 2 | SENDER | tx.sender | 실제 사용자 작업 실행(전송, 스왑 등). sender_approved가 먼저 true여야 함. msg.sender = tx.sender 보장. |
💡 SENDER 모드의 핵심 장점: msg.sender = tx.sender가 보장되므로, 기존 컨트랙트의 token approve, NFT 소유권, access control 로직을 수정할 필요가 없다. ERC-4337은 EntryPoint가 중간에 끼어서 msg.sender 처리가 복잡했는데, EIP-8141에서는 이 문제가 사라진다.
계정 코드가 "나는 어떤 프레임 안에 있고, 무엇을 검증해야 하는지" 알려면 새로운 EVM 인트로스펙션 오퍼코드가 필요하다. EIP-8141은 7개를 추가한다.
| Opcode | 가스 | 역할 |
|---|---|---|
| APPROVE (0xaa) | — | 핵심 오퍼코드. VERIFY 프레임을 종료하며 승인 플래그 설정. scope: 0x1(결제), 0x2(실행), 0x3(둘 다) |
| TXPARAM (0xb0) | 2 | tx 파라미터(sender, nonce, max cost 등) 조회. ORIGIN을 대체하는 AA 친화 버전 |
| FRAMEDATALOAD (0xb1) | 3 | 현재 프레임 data 필드에서 32바이트 읽기 |
| FRAMEDATACOPY (0xb2) | 3+copy | 프레임 데이터를 메모리로 대량 복사 |
| FRAMEPARAM (0xb3) | 2 | 현재 프레임 메타데이터(모드, 플래그, target 주소 등) 조회 |
| SIGPARAM (0xb4) | 2 | 서명 리스트 메타데이터 및 ARBITRARY 서명 길이 조회 |
| SIGDATACOPY (0xb5) | 3+copy | ARBITRARY 서명 바이트를 커스텀 검증 로직으로 복사 |
APPROVE는 VERIFY 프레임 안에서 계정 코드가 "이 트랜잭션을 검증했고, 진행해도 좋다"고 프로토콜에 알리는 유일한 방법이다. 스택 입력: [offset, length, scope].
// scope values APPROVE_PAYMENT = 0x1 // gas payment only APPROVE_EXECUTION = 0x2 // execution only APPROVE_EXECUTION_AND_PAYMENT = 0x3 // both // Rules: // 1. Only callable if ADDRESS == resolved_target (self-call only) // 2. Once an approval bit is set, it cannot be set again // 3. APPROVE_PAYMENT effects: // - nonce++ // - deduct max_cost from payer balance // - payer = this frame's target // 4. APPROVE_EXECUTION effects: // - sender_approved = true // - (only valid if frame.target == tx.sender)
"누가 서명을 검증했는가"와 "누가 가스비를 냈는가"가 scope로 명확히 분리된다. 두 개의 별도 VERIFY 프레임으로 처리하면, 사용자는 서명만 하고 스폰서가 가스를 낸다는 패턴이 자연스럽게 만들어진다.
| 체계 | 값 | 인코딩 | 실행 가스 |
|---|---|---|---|
| ARBITRARY | 0x0 | 임의 바이트 — EVM이 검증 | 100 |
| SECP256K1 | 0x1 | v(1) || r(32) || s(32) | 2,800 |
| P256 (NIST) | 0x2 | r || s || qx || qy (32 each) | 6,700 |
💡 ARBITRARY scheme의 의미: SIGDATACOPY + SIGPARAM을 활용해 BLS, Schnorr, 격자 기반 양자내성암호 서명까지 EVM 바이트코드에서 직접 검증할 수 있다. 별도 프리컴파일 추가 없이도 어떤 서명 체계든 구현 가능. P256 signer 주소는 keccak256(qx || qy)[12:]로 도출.
노드가 "검증 프리픽스"가 4가지 허용 패턴 중 하나인지 확인. 안 맞으면 공개 mempool 거부 → private 채널로 가야 함.
초반 VERIFY 프레임들 실행. 서명 검사, 정책 확인, Paymaster 로직. payer와 sender_approved 결정.
승인 완료 후 나머지 프레임 실행. SENDER 프레임에서 실제 토큰 전송, 스왑 등 처리. 원자적 배치 단위로 전부 성공 or 전부 되돌림.
사용된 가스 계산 → payer에게 환급. 프레임별 영수증 기록. 상태 0x1(성공), 0x2(스킵/배치 실패)로 기록.
assert tx.nonce == state[tx.sender].nonce
sender_approved = False
payer = None
for frame in tx.frames:
resolved_target = tx.sender if frame.target is None else frame.target
if frame.mode == DEFAULT:
call(caller=ENTRY_POINT, target=resolved_target, ...)
elif frame.mode == VERIFY:
# STATICCALL — APPROVE opcode is the only way to set approval
staticcall(caller=ENTRY_POINT, target=resolved_target, ...)
elif frame.mode == SENDER:
assert sender_approved # must have APPROVE_EXECUTION first
call(caller=tx.sender, target=resolved_target, value=frame.value, ...)
assert payer is not None # someone must have called APPROVE_PAYMENT
settle_gas_and_refund(payer)기존 메타마스크 같은 일반 지갑(코드 없는 EOA)도 별도 배포나 EIP-7702 위임 없이 Frame Transaction을 즉시 사용할 수 있다. 프로토콜이 "기본 코드(default code)"를 내장해서 자동 처리하기 때문이다.
ETH 값 전송을 완료하고 빈 데이터로 반환. 새로 생성된 EOA로 ETH를 보내는 케이스도 자연스럽게 처리된다.
실용 포인트: 서명자(sender)는 인덱스 0 서명으로 본인 인증, 가스 스폰서는 인덱스 1 서명으로 결제 승인. 컨트랙트 배포 없이 EOA-대-EOA 가스 대납 가능.
| Frame | Mode | Target | 내용 |
|---|---|---|---|
| 0 | VERIFY | sender | 서명 검증 → APPROVE(0x3) — 실행+결제 동시 승인 |
| 1 | SENDER | target | 실제 calldata 실행 |
| Frame | Mode | Target | 내용 |
|---|---|---|---|
| 0 | VERIFY | sender | 서명 검증 → APPROVE(0x2) 실행 승인만 |
| 1 | VERIFY | paymaster | 스폰서 데이터 검증 → APPROVE(0x1) 결제 승인 |
| 2 | SENDER | target | 사용자 호출 실행 |
| 3 | DEFAULT | paymaster | post-op: 가스 환급 정산 |
사용자는 ETH를 한 푼도 보유하지 않아도 됨.
| Frame | Mode | 원자배치 | Target | 내용 |
|---|---|---|---|---|
| 0 | VERIFY | - | sender | 서명 → APPROVE(0x3) |
| 1 | SENDER | 배치 시작 | ERC-20 | approve(DEX, amount) |
| 2 | SENDER | 배치 종료 | DEX | swap(...) |
스왑 실패 시 approve도 함께 롤백. "두 번 서명" UX 문제 해결.
| Frame | Mode | Target | 내용 |
|---|---|---|---|
| 0 | DEFAULT | deployer | initcode + salt로 계정 배포 (CREATE2) |
| 1 | VERIFY | sender | 서명 검증 → APPROVE(0x3) |
| 2 | SENDER | target | 사용자 호출 |
카운터팩추얼 주소로 미리 알려진 스마트 계정을 처음 사용할 때 배포와 실행을 한 번에 처리.
| Frame | Mode | Target | 내용 |
|---|---|---|---|
| 0 | VERIFY | sender | 서명 검증 (인덱스 0) |
| 1 | VERIFY | sponsor | 스폰서 서명 메타데이터 + 다음 ERC-20 프레임 확인 → APPROVE(0x1) |
| 2 | SENDER | USDC | transfer(sponsor, fee) |
| 3 | SENDER | target | 사용자 호출 |
| 4 | DEFAULT | sponsor | (선택) 후처리 정산 |
⚠️ 신뢰 모델 주의: 공개 mempool에서는 스폰서가 사용자의 USDC 잔액을 온체인에서 직접 확인하지 않는다. 사용자가 트랜잭션 포함 전 잔액을 비우는 프론트러닝 위험이 있다. 잔액 실검증이 필요하면 "확장 티어(expansive tier)"나 private mempool을 써야 한다.
flags의 2번 비트를 세팅하면 그 프레임은 배치에 속한다. 연속된 플래그 세팅 프레임들 + 뒤에 나오는 플래그 없는 프레임 하나가 하나의 배치 단위가 된다.
Frame 0: batch_flag=1 ─┐ Frame 1: batch_flag=0 ─┘ Batch 1 Frame 2: batch_flag=1 ─┐ Frame 3: batch_flag=1 │ Batch 2 Frame 4: batch_flag=0 ─┘
각 프레임은 execution 가스와 state 가스를 별도로 지정한다. 두 예산은 서로 빌려 쓸 수 없다. 한쪽이 부족하면 다른 쪽 여유가 있어도 그 프레임은 실패한다.
연산, 데이터 처리, 접근(access) 비용. 복잡한 DeFi 로직에 많이 소비됨.
영속적 상태 증가 비용(스토리지 슬롯 신규 생성, 계정 생성 등). 상태 팽창을 별도로 제어.
frame_tx_intrinsic_gas = (
12_000 # base cost
+ 475 * len(tx.frames) # per-frame cost
+ frame_data_cost # calldata
+ signature_data_cost # sig bytes
+ signature_verification_cost # SECP256K1: 2800, P256: 6700, ARBITRARY: 100
+ value_transfer_cost
)
# Warm/cold state initial values:
# - tx.sender, coinbase, active precompiles → warm
# - frame targets → NOT auto-warmed (important for gas estimates!)
# - payer → warmed only when APPROVE touches it지갑 개발자 주의: 가스 추정기를 만들 때 프레임 타겟이 자동 워밍업되지 않는다는 점을 반드시 반영해야 한다. 언더/오버 프로비저닝 모두 문제가 된다.
핵심 혼동 포인트: EIP-8141은 사실상 두 개의 스펙이 하나로 합쳐진 것이다. 온체인에서 유효한 것(실행 모델)과 공개 P2P 네트워크로 전파 가능한 것(멤풀 모델)은 다르다.
"검증과 결제는 완전히 프로그래머블" — 어떤 계정 코드든 원하는 서명 방식을 검증하고 원하는 지불자를 승인할 수 있다.
공개 mempool로 전파되려면 4가지 허용 패턴 중 하나여야 함. 안 맞으면 → private 채널(빌더 직송).
[self_verify] # basic self-relay [deploy] → [self_verify] # self-relay with account deployment [only_verify] → [pay] # canonical paymaster basic [deploy] → [only_verify] → [pay] # canonical paymaster + deployment
| 항목 | ERC-4337 | EIP-7702 | EIP-8141 |
|---|---|---|---|
| 프로토콜 변경 | ❌ 없음 | ✅ 있음 (Pectra) | ✅ 있음 (예정) |
| Mempool | alt mempool (별도) | 공개 mempool | 공개 mempool |
| 검열 저항성 | Bundler 의존 | — | 프로토콜 네이티브 |
| 가스 오버헤드 | 높음 (EntryPoint 경유) | 낮음 | 프레임당 475 가스 |
| 서명 체계 | ECDSA only | ECDSA only | SECP256K1 + P256 + ARBITRARY |
| msg.sender | EntryPoint (복잡) | tx.sender | tx.sender (SENDER frame) |
| 원자적 배치 | 컨트랙트 로직으로만 | 컨트랙트 로직으로만 | 프로토콜 기본값 |
| 가스 스폰서십 | Paymaster contract | 7702 위임 필요 | VERIFY 프레임에서 APPROVE_PAYMENT |
| 포스트 퀀텀 | ❌ | ❌ | ✅ (ARBITRARY scheme) |
| 현재 상태 | Final (ERC) | Final (Pectra) | Draft |
지난 몇 달간 20회 이상의 피드백 라운드를 거치며 opcode 번호, 스택 순서, 가스 모델(1차원 → 2차원)까지 여러 번 바뀌었다. 메인넷 반영 시점과 최종 형태는 계속 지켜봐야 한다.
배치·서명·ETH 스폰서십은 기본값으로 커버된다. 권한 관리·세션키·패스키 인증·소셜 리커버리·신뢰 없는 ERC-20 가스결제는 여전히 별도 표준화 영역이다.
온체인에서 유효한 것과 공개 P2P 네트워크로 브로드캐스트 가능한 것은 다른 이야기다. 복잡한 검증 로직 = 빌더 직송 필요 가능.
EIP-8175, EIP-8130, EIP-8202, EIP-8223, EIP-8224 등 대안들도 활발히 논의 중이다. EIP-8141이 "최종 확정된 표준"이 아니라 여러 설계안 중 가장 활발히 논의되는 하나라는 것을 기억하라.
실제로 Frame Transaction을 구성하고 서명해서 노드에 전송하기까지의 전체 흐름을 코드로 살펴보자. EIP-8141이 확정되면 viem/ethers 같은 라이브러리가 이를 추상화하겠지만, 내부 구조를 이해하는 게 핵심이다.
import { RLP } from '@ethereumjs/rlp';
import { keccak256, hexToBytes, bytesToHex } from 'viem';
// Transaction type byte
const TX_TYPE_FRAME = 0x06;
// Frame modes
const FRAME_MODE_DEFAULT = 0;
const FRAME_MODE_VERIFY = 1;
const FRAME_MODE_SENDER = 2;
// APPROVE scope constants
const APPROVE_PAYMENT = 0x1;
const APPROVE_EXECUTION = 0x2;
const APPROVE_EXECUTION_AND_PAYMENT = 0x3;
// Signature schemes
const SIG_ARBITRARY = 0x0;
const SIG_SECP256K1 = 0x1;
const SIG_P256 = 0x2;
interface Frame {
mode: number;
flags: bigint; // 256-bit flags field
target: string | null; // address or null (= tx.sender)
executionGas: bigint;
stateGas: bigint;
value: bigint;
data: Uint8Array;
}
interface FrameSignature {
scheme: number;
signer: string; // address (20 bytes)
message: Uint8Array;
signature: Uint8Array;
}
interface FrameTx {
chainId: bigint;
nonce: bigint;
sender: string;
frames: Frame[];
signatures: FrameSignature[];
maxPriorityFeePerGas: bigint;
maxFeePerGas: bigint;
maxFeePerBlobGas: bigint;
blobVersionedHashes: Uint8Array[];
}
function encodeFrame(f: Frame): Uint8Array[] {
return [
RLP.encode(f.mode),
RLP.encode(f.flags),
f.target ? hexToBytes(f.target as `0x${string}`) : new Uint8Array(0),
RLP.encode([f.executionGas, f.stateGas]),
RLP.encode(f.value),
f.data,
];
}
function encodeSignature(s: FrameSignature): Uint8Array[] {
return [
RLP.encode(s.scheme),
hexToBytes(s.signer as `0x${string}`),
s.message,
s.signature,
];
}
export function encodeFrameTx(tx: FrameTx): Uint8Array {
const encoded = RLP.encode([
tx.chainId,
tx.nonce,
hexToBytes(tx.sender as `0x${string}`),
tx.frames.map(encodeFrame),
tx.signatures.map(encodeSignature),
[tx.maxPriorityFeePerGas, tx.maxFeePerGas, tx.maxFeePerBlobGas],
tx.blobVersionedHashes,
]);
// Prepend transaction type byte 0x06
const result = new Uint8Array(1 + encoded.length);
result[0] = TX_TYPE_FRAME;
result.set(encoded, 1);
return result;
}
// Signing hash: keccak256(0x06 || rlp(payload))
export function frameTxSigningHash(tx: FrameTx): `0x${string}` {
return keccak256(encodeFrameTx(tx));
}import { privateKeyToAccount } from 'viem/accounts';
import { createWalletClient, http, parseEther } from 'viem';
import { mainnet } from 'viem/chains';
const account = privateKeyToAccount('0x...');
// Simple ETH transfer as Frame Transaction
// Frame 0: VERIFY (self-pay, self-authorize execution)
// Frame 1: SENDER (actual transfer)
const tx: FrameTx = {
chainId: 1n,
nonce: 0n,
sender: account.address,
frames: [
{
mode: FRAME_MODE_VERIFY,
flags: BigInt(APPROVE_EXECUTION_AND_PAYMENT), // scope 0x3
target: null, // target = tx.sender (self)
executionGas: 50_000n,
stateGas: 0n,
value: 0n,
data: new Uint8Array(0),
},
{
mode: FRAME_MODE_SENDER,
flags: 0n,
target: '0xRecipientAddress',
executionGas: 21_000n,
stateGas: 0n,
value: parseEther('0.1'),
data: new Uint8Array(0),
},
],
signatures: [
{
scheme: SIG_SECP256K1,
signer: account.address,
message: new Uint8Array(0), // auto-computed by protocol for self-verify
signature: new Uint8Array(0), // filled after signing
},
],
maxPriorityFeePerGas: 1_000_000_000n,
maxFeePerGas: 30_000_000_000n,
maxFeePerBlobGas: 0n,
blobVersionedHashes: [],
};
// Sign the transaction
const hash = frameTxSigningHash(tx);
const sig = await account.sign({ hash });
// Insert signature (v=1/0, r, s — 65 bytes)
tx.signatures[0].signature = hexToBytes(sig);
const rawTx = encodeFrameTx(tx);
// Submit: eth_sendRawTransaction
const txHash = await client.request({
method: 'eth_sendRawTransaction',
params: [bytesToHex(rawTx)],
});// Paymaster pattern:
// Frame 0: VERIFY (userAccount → APPROVE_EXECUTION only)
// Frame 1: VERIFY (paymasterContract → APPROVE_PAYMENT only)
// Frame 2: SENDER (actual user action)
const userTx: FrameTx = {
chainId: 1n,
nonce: 0n,
sender: userAddress,
frames: [
{
mode: FRAME_MODE_VERIFY,
flags: BigInt(APPROVE_EXECUTION), // scope 0x2 (execution only)
target: null, // userAddress
executionGas: 50_000n,
stateGas: 0n,
value: 0n,
data: new Uint8Array(0),
},
{
mode: FRAME_MODE_VERIFY,
flags: BigInt(APPROVE_PAYMENT), // scope 0x1 (payment only)
target: PAYMASTER_ADDRESS,
executionGas: 80_000n,
stateGas: 0n,
value: 0n,
// paymasterData: ABI-encoded validation params
data: encodePaymasterData({ userAddress, expiry: BigInt(Date.now() / 1000 + 3600) }),
},
{
mode: FRAME_MODE_SENDER,
flags: 0n,
target: UNISWAP_ROUTER,
executionGas: 200_000n,
stateGas: 50_000n,
value: 0n,
data: encodeSwapData(...),
},
],
signatures: [
// sig[0]: user's SECP256K1 signature (for VERIFY frame 0)
{ scheme: SIG_SECP256K1, signer: userAddress, message: new Uint8Array(0), signature: userSig },
// sig[1]: paymaster's SECP256K1 signature (for VERIFY frame 1)
{ scheme: SIG_SECP256K1, signer: paymasterSigner, message: new Uint8Array(0), signature: paymasterSig },
],
maxPriorityFeePerGas: 1_000_000_000n,
maxFeePerGas: 30_000_000_000n,
maxFeePerBlobGas: 0n,
blobVersionedHashes: [],
};
// NOTE: In this pattern, payer = PAYMASTER_ADDRESS (set when APPROVE_PAYMENT is called)
// Gas deducted from paymaster contract's ETH balance, not user's💡 서명 순서 주의: 기본 코드(default code)는 실행 승인이 포함된 VERIFY 프레임이면 signatures[0]을, 결제 전용이면 signatures[1]을 사용한다. 스마트 계정은 SIGPARAM 오퍼코드로 직접 인덱스를 지정한다.
EIP-8141 스마트 계정의 핵심은 VERIFY 프레임에서 올바르게 APPROVE를 호출하는 것이다. 이 섹션에서는 최소 기능 스마트 계정부터 ERC-7579 모듈식 계정까지 단계별로 구현해본다.
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.30;
// EIP-8141 opcodes as assembly constants
// APPROVE = 0xaa
// TXPARAM = 0xb0
// FRAMEPARAM = 0xb3
// SIGPARAM = 0xb4
contract MinimalSmartAccount {
// ── Storage ──────────────────────────────────────────────────────
address public owner;
constructor(address _owner) {
owner = _owner;
}
// ── VERIFY frame entry point ──────────────────────────────────────
// Called by ENTRY_POINT (0xaa) as STATICCALL
// Must call APPROVE to signal validation success
fallback() external {
// Read which frame mode we're in via FRAMEPARAM
uint8 frameMode;
assembly {
// FRAMEPARAM(0) returns frame mode
mstore(0x00, 0)
let ok := staticcall(gas(), 0xb3, 0x00, 0x20, 0x00, 0x20)
frameMode := mload(0x00)
}
if (frameMode == 1) {
// VERIFY frame: validate and APPROVE
_verify();
}
// SENDER frame: execution is handled by SENDER mode caller
// DEFAULT frame: post-op hooks can go here
}
function _verify() internal view {
// 1. Read the signing hash from TXPARAM
// TXPARAM(4) = signing_hash (what the protocol computes over the tx)
bytes32 signingHash;
assembly {
mstore(0x00, 4)
staticcall(gas(), 0xb0, 0x00, 0x20, 0x00, 0x20)
signingHash := mload(0x00)
}
// 2. Read SECP256K1 signature from signatures list
// SIGPARAM(0) = number of signatures
// For SECP256K1: signature is (v, r, s) = 65 bytes
uint8 v;
bytes32 r;
bytes32 s;
assembly {
// SIGPARAM(sigIndex=0, field=0) → scheme
// For brevity, read SECP256K1 sig at index 0
// Real impl: read SIGPARAM to get scheme, then SIGDATACOPY
let ptr := mload(0x40)
// v = 27 or 28
mstore(ptr, 0) // sig index = 0
mstore(add(ptr, 0x20), 1) // field = 1 (sig data start)
// SIGDATACOPY(destOffset, sigIndex, srcOffset, length)
// Stack: [sigIndex, destOffset, srcOffset, length]
// For secp256k1: v=1byte at offset 0, r=32 at offset 1, s=32 at offset 33
}
// NOTE: in practice use SIGDATACOPY via assembly to copy bytes
// Simplified here: assume sig loaded via library
// 3. ecrecover and check owner
address recovered = ecrecover(signingHash, v, r, s);
require(recovered == owner, "Invalid signature");
// 4. APPROVE — tell the protocol this frame is validated
// APPROVE(offset=0, length=0, scope=0x3)
// scope 0x3 = APPROVE_EXECUTION_AND_PAYMENT
uint8 scope = 0x3; // or read from frame flags
assembly {
// Stack: [offset, length, scope]
// APPROVE opcode = 0xaa
mstore(0x00, 0) // offset
mstore(0x20, 0) // length
mstore(0x40, scope) // scope
// Call APPROVE via assembly
// Note: APPROVE is a new opcode, not a precompile call
// In real EIP-8141 EVM it's a native opcode
pop(call(gas(), 0xaa, 0, 0, 0x60, 0, 0))
}
}
}// Two separate VERIFY frames pattern:
// Frame 0: userAccount verifies sig → APPROVE(scope=0x2) execution only
// Frame 1: paymasterContract verifies policy → APPROVE(scope=0x1) payment only
contract UserSmartAccount {
address public owner;
fallback() external {
uint8 frameMode = _getFrameMode();
if (frameMode != 1) return; // Only handle VERIFY frames
// Read allowed scope from frame flags
// flags & 0x3 = scope bits
uint256 flags = _getFrameFlags();
uint8 allowedScope = uint8(flags & 0x3);
require(allowedScope != 0, "No scope allowed");
// Verify signature
bytes32 sigHash = _getTxSigningHash();
require(_verifyOwnerSig(sigHash), "Bad sig");
// APPROVE only execution (not payment — paymaster handles that)
_approve(APPROVE_EXECUTION); // scope = 0x2
}
function _approve(uint8 scope) internal {
assembly {
// APPROVE opcode 0xaa
// Arguments pushed to stack: offset=0, length=0, scope
let ok := call(gas(), 0xaa, 0, 0, 0, 0, 0)
// The actual EIP-8141 APPROVE opcode takes scope from frame flags
// or from the call arguments depending on final spec
}
}
// Helper: read frame mode via FRAMEPARAM opcode
function _getFrameMode() internal view returns (uint8 mode) {
assembly {
mstore(0x00, 0) // param index 0 = mode
staticcall(gas(), 0xb3, 0x00, 0x20, 0x00, 0x20)
mode := and(mload(0x00), 0xff)
}
}
function _getFrameFlags() internal view returns (uint256 flags) {
assembly {
mstore(0x00, 1) // param index 1 = flags
staticcall(gas(), 0xb3, 0x00, 0x20, 0x00, 0x20)
flags := mload(0x00)
}
}
function _getTxSigningHash() internal view returns (bytes32 h) {
assembly {
mstore(0x00, 4) // TXPARAM index 4 = signing_hash
staticcall(gas(), 0xb0, 0x00, 0x20, 0x00, 0x20)
h := mload(0x00)
}
}
function _verifyOwnerSig(bytes32 hash) internal view returns (bool) {
// In practice: SIGDATACOPY to load sig bytes, then ecrecover
// Simplified: assume library call
return true; // placeholder
}
uint8 constant APPROVE_EXECUTION = 0x2;
}| 인덱스 | 반환값 | 용도 |
|---|---|---|
| 0 | tx.sender | 사용자 주소 확인 |
| 1 | tx.nonce | 논스 검증 (재사용 방지) |
| 2 | tx.max_cost | Paymaster가 지불 가능한지 확인 |
| 3 | tx.chain_id | 체인 분리 보장 |
| 4 | signing_hash | 서명 검증용 해시 |
| 5 | frame_count | 총 프레임 수 |
| 6 | current_frame_idx | 현재 실행 중인 프레임 인덱스 |
EIP-8141의 mempool 허용 패턴 중 [only_verify → pay]가 공식 Paymaster 패턴이다. Paymaster 컨트랙트는 VERIFY 프레임에서 APPROVE_PAYMENT를 호출한다. 아래는 최소 기능 Paymaster 전체 구현이다.
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.30;
/// @title MinimalPaymaster
/// @notice EIP-8141 Canonical Paymaster implementation
/// @dev Receives VERIFY frames with scope=APPROVE_PAYMENT (0x1)
/// Validates user policy and calls APPROVE to cover tx costs
contract MinimalPaymaster {
// ── Constants ──────────────────────────────────────────────────────
uint8 constant FRAME_MODE_VERIFY = 1;
uint8 constant APPROVE_PAYMENT = 0x1;
// EIP-8141 special address for ENTRY_POINT
address constant ENTRY_POINT = address(0xaa);
// ── Storage ────────────────────────────────────────────────────────
address public admin;
// user address → whether they are whitelisted for free gas
mapping(address => bool) public whitelist;
// user address → USDC allowance for gas payment
mapping(address => uint256) public usdcGasBalance;
// accumulated protocol fee in USDC
uint256 public feePool;
event GasSponsored(address indexed user, uint256 maxCost);
event GasPaidInUsdc(address indexed user, uint256 usdcAmount);
constructor() {
admin = msg.sender;
}
// ── Deposit / Admin ────────────────────────────────────────────────
receive() external payable {} // Accept ETH to fund gas
function depositUsdcFor(address user, uint256 amount) external {
// Pull USDC from caller (requires ERC-20 approve first)
IERC20(USDC_ADDRESS).transferFrom(msg.sender, address(this), amount);
usdcGasBalance[user] += amount;
}
function setWhitelist(address user, bool allowed) external {
require(msg.sender == admin, "Not admin");
whitelist[user] = allowed;
}
// ── Main VERIFY frame handler ──────────────────────────────────────
fallback() external {
// Only handle VERIFY frames
require(_frameMode() == FRAME_MODE_VERIFY, "Not VERIFY frame");
// Read tx params via TXPARAM
address txSender = _txParam(0);
uint256 maxCost = _txParam(2); // max ETH cost of entire tx
// ── Policy check: whitelist OR USDC prepayment ──────────────
if (whitelist[txSender]) {
// Free sponsorship for whitelisted users
emit GasSponsored(txSender, maxCost);
_approve(APPROVE_PAYMENT);
} else {
// Check USDC gas balance covers cost (using price oracle)
uint256 usdcRequired = _ethToUsdc(maxCost);
require(usdcGasBalance[txSender] >= usdcRequired, "Insufficient USDC gas balance");
// Deduct USDC upfront (debit during VERIFY, refund in post-op if needed)
usdcGasBalance[txSender] -= usdcRequired;
feePool += usdcRequired;
emit GasPaidInUsdc(txSender, usdcRequired);
_approve(APPROVE_PAYMENT);
}
// If we reach here without APPROVE, tx is rejected
}
// ── EIP-8141 Opcode wrappers ────────────────────────────────────────
function _approve(uint8 scope) internal {
assembly {
// APPROVE opcode 0xaa: sets payer = address(this), nonce++, deducts max_cost
pop(call(gas(), 0xaa, 0, 0, 0, 0, 0))
}
}
function _frameMode() internal view returns (uint8 mode) {
assembly {
mstore(0, 0)
staticcall(gas(), 0xb3, 0, 0x20, 0, 0x20)
mode := and(mload(0), 0xff)
}
}
function _txParam(uint256 idx) internal view returns (address val) {
assembly {
mstore(0, idx)
staticcall(gas(), 0xb0, 0, 0x20, 0, 0x20)
val := mload(0)
}
}
function _ethToUsdc(uint256 ethAmount) internal view returns (uint256) {
// Simplified: use a price oracle (Chainlink ETH/USD)
// Real impl: IPriceFeed(ORACLE).latestRoundData()
uint256 ethPriceInUsdc = 3000 * 1e6; // $3000 per ETH, 6 decimals
return (ethAmount * ethPriceInUsdc) / 1e18;
}
address constant USDC_ADDRESS = 0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48; // mainnet USDC
}
interface IERC20 {
function transferFrom(address, address, uint256) external returns (bool);
function transfer(address, uint256) external returns (bool);
}⚠️ Paymaster 보안 주의사항: VERIFY 프레임은 STATICCALL이라 상태 변경이 금지된다. usdcGasBalance 차감은 실제 구현에서 EVM 상태 변경이 필요해 구조적으로 다르게 설계해야 할 수 있다. 스펙 확정 후 재검토 필요.
ARBITRARY scheme(0x0)의 핵심은 SIGDATACOPY + SIGPARAM으로 서명 바이트를 EVM 메모리로 불러온 뒤 원하는 방식으로 검증하는 것이다. P256 scheme(0x2)은 프로토콜이 내장 검증을 수행하지만 signer 주소 도출 방식을 반드시 이해해야 한다.
// P256 signer address derivation:
// When signature scheme = P256 (0x2), signature encoding is:
// r(32) || s(32) || qx(32) || qy(32) = 128 bytes
//
// Protocol derives signer address as:
// keccak256(qx || qy)[12:] ← last 20 bytes of keccak hash
//
// This is analogous to ECDSA but uses P256 public key components.
contract P256SmartAccount {
// The P256 "signer address" stored at construction
// = keccak256(pubKeyX || pubKeyY)[12:]
address public p256Signer;
uint8 constant FRAME_MODE_VERIFY = 1;
constructor(bytes32 pubKeyX, bytes32 pubKeyY) {
// Compute P256 signer address from public key
p256Signer = address(uint160(uint256(keccak256(abi.encodePacked(pubKeyX, pubKeyY)))));
}
fallback() external {
if (_frameMode() != FRAME_MODE_VERIFY) return;
// For P256 scheme, the protocol itself verifies the signature.
// The VERIFY frame's job is just to confirm the signer matches owner.
// Use SIGPARAM to read the signer address that the protocol resolved.
address resolvedSigner;
assembly {
// SIGPARAM(sigIndex=0, field=2) = signer address resolved by protocol
mstore(0x00, 0) // sig index 0
mstore(0x20, 2) // field 2 = resolved signer
staticcall(gas(), 0xb4, 0x00, 0x40, 0x00, 0x20)
resolvedSigner := mload(0x00)
}
require(resolvedSigner == p256Signer, "P256: wrong signer");
// Additional checks: nonce, expiry, scope
_approve(0x3); // APPROVE_EXECUTION_AND_PAYMENT
}
function _approve(uint8 scope) internal {
assembly { pop(call(gas(), 0xaa, 0, 0, 0, 0, 0)) }
}
function _frameMode() internal view returns (uint8 m) {
assembly {
mstore(0, 0)
staticcall(gas(), 0xb3, 0, 0x20, 0, 0x20)
m := and(mload(0), 0xff)
}
}
}
// ── ARBITRARY scheme: BLS / Custom Signature ──────────────────────────
// Use SIGDATACOPY to pull arbitrary sig bytes and verify with EVM bytecode
contract ArbitrarySignatureAccount {
// For example: a simple 2-of-2 multi-sig with BLS aggregation
bytes32 public blsPublicKeyHash; // hash of the aggregate BLS public key
fallback() external {
if (_frameMode() != 1) return;
// 1. Get ARBITRARY sig length via SIGPARAM
uint256 sigLen;
assembly {
mstore(0x00, 0) // sig index 0
mstore(0x20, 3) // field 3 = signature byte length (for ARBITRARY)
staticcall(gas(), 0xb4, 0x00, 0x40, 0x00, 0x20)
sigLen := mload(0x00)
}
// 2. Copy signature bytes to memory via SIGDATACOPY
bytes memory sigBytes = new bytes(sigLen);
assembly {
// SIGDATACOPY(sigIndex=0, destOffset=sigBytes+32, srcOffset=0, length=sigLen)
mstore(0x00, 0) // sig index
mstore(0x20, add(sigBytes, 32)) // dest
mstore(0x40, 0) // src offset
mstore(0x60, sigLen) // length
staticcall(gas(), 0xb5, 0x00, 0x80, 0x00, 0x00)
}
// 3. Verify BLS signature in EVM bytecode
// (In practice: call a BLS precompile or EIP-2537 G1/G2 operations)
bytes32 sigHash = _getTxSigningHash();
require(_verifyBLS(sigHash, sigBytes), "BLS: invalid signature");
_approve(0x3);
}
function _verifyBLS(bytes32 msgHash, bytes memory sig) internal view returns (bool) {
// EIP-2537 BLS precompile calls would go here
// Placeholder implementation
return sig.length > 0;
}
function _getTxSigningHash() internal view returns (bytes32 h) {
assembly {
mstore(0, 4)
staticcall(gas(), 0xb0, 0, 0x20, 0, 0x20)
h := mload(0)
}
}
function _frameMode() internal view returns (uint8 m) {
assembly {
mstore(0, 0)
staticcall(gas(), 0xb3, 0, 0x20, 0, 0x20)
m := and(mload(0), 0xff)
}
}
function _approve(uint8 scope) internal {
assembly { pop(call(gas(), 0xaa, 0, 0, 0, 0, 0)) }
}
}// WebAuthn passkey authentication → EIP-8141 P256 signature
import { startAuthentication } from '@simplewebauthn/browser';
import { keccak256, hexToBytes, bytesToHex, concatBytes } from 'viem';
interface P256Signature {
r: Uint8Array; // 32 bytes
s: Uint8Array; // 32 bytes
qx: Uint8Array; // 32 bytes (public key X)
qy: Uint8Array; // 32 bytes (public key Y)
}
async function signFrameTxWithPasskey(
txHash: `0x${string}`,
credentialId: string,
): Promise<P256Signature> {
// Convert tx hash to WebAuthn challenge
const challenge = Buffer.from(hexToBytes(txHash)).toString('base64url');
// Request WebAuthn authentication
const assertion = await startAuthentication({
optionsJSON: {
challenge,
rpId: window.location.hostname,
allowCredentials: [{ id: credentialId, type: 'public-key' }],
userVerification: 'required',
},
});
// Parse the CBOR-encoded signature from WebAuthn response
// assertion.response.signature contains DER-encoded P256 sig
const derSig = Buffer.from(assertion.response.signature, 'base64url');
// Parse DER signature → (r, s)
const { r, s } = parseDerP256Signature(derSig);
// qx, qy come from the stored credential public key (registered at account creation)
const { qx, qy } = await getStoredPublicKey(credentialId);
return { r, s, qx, qy };
}
// Encode as EIP-8141 P256 signature bytes: r||s||qx||qy = 128 bytes
function encodeP256Signature(sig: P256Signature): Uint8Array {
return concatBytes([sig.r, sig.s, sig.qx, sig.qy]); // 128 bytes total
}
// Derive the P256 "signer address" (matches what protocol computes)
function deriveP256SignerAddress(qx: Uint8Array, qy: Uint8Array): `0x${string}` {
const combined = concatBytes([qx, qy]); // 64 bytes
const hash = keccak256(combined);
// Take last 20 bytes
return `0x${hash.slice(-40)}` as `0x${string}`;
}EIP-8141은 EIP-8037(2D 가스)에 의존한다. 각 프레임은 execution_gas와 state_gas 두 개의 독립적인 가스 예산을 가진다. 이 둘은 서로 교환 불가능하다. 올바른 가스 추정을 위해 각 프레임의 두 가스 버킷을 따로 계산해야 한다.
| 가스 타입 | 측정 대상 | 가격 결정 요인 | 메모리풀 상한 |
|---|---|---|---|
| execution_gas | CPU / 계산 비용 (ADD, MUL, CALL 등) | 블록 수요 / 혼잡도 | 100,000 |
| state_gas | 스토리지 I/O (SLOAD, SSTORE 등) | 상태 팽창 압력 | 500,000 |
import { createPublicClient, http } from 'viem';
import { mainnet } from 'viem/chains';
interface FrameGasEstimate {
frameIndex: number;
executionGas: bigint;
stateGas: bigint;
}
interface TxGasEstimate {
frames: FrameGasEstimate[];
totalExecutionGas: bigint;
totalStateGas: bigint;
// max_cost in ETH = (totalExecutionGas * max_fee_per_gas)
// + (totalStateGas * max_state_fee_per_gas) [EIP-8037]
maxCostWei: bigint;
}
// Estimate 2D gas for a Frame Transaction
// EIP-8141 nodes will expose eth_estimateFrameTxGas (hypothetical)
async function estimateFrameTxGas(
tx: FrameTx,
rpcUrl: string,
): Promise<TxGasEstimate> {
const client = createPublicClient({ transport: http(rpcUrl) });
// In practice: call eth_estimateFrameTxGas (EIP-8141 new RPC method)
// For now, simulate by estimating each frame separately
const estimates: FrameGasEstimate[] = [];
let totalExec = 0n;
let totalState = 0n;
for (let i = 0; i < tx.frames.length; i++) {
const frame = tx.frames[i];
// Classify opcodes into execution vs state costs
// State gas: SLOAD=2100, SSTORE=20000 (warm), etc.
// Execution gas: everything else
// Simplified heuristic (replace with actual simulation):
const exec = await estimateExecutionGas(client, frame);
const state = await estimateStateGas(client, frame);
estimates.push({ frameIndex: i, executionGas: exec, stateGas: state });
totalExec += exec;
totalState += state;
}
// Mempool constraints check
// Validation prefix frames (VERIFY frames) must fit within limits
const verifyFrames = tx.frames.filter(f => f.mode === 1);
const verifyExecTotal = verifyFrames.reduce((sum, _, i) => sum + estimates.filter(e => tx.frames[e.frameIndex].mode === 1)[i]?.executionGas ?? 0n, 0n);
if (verifyExecTotal > 100_000n) {
console.warn(`WARNING: VERIFY frames exec gas ${verifyExecTotal} exceeds mempool limit 100,000 — tx will not propagate via public mempool`);
}
// Compute max_cost
// max_cost = exec_gas * max_fee_per_gas + state_gas * max_state_fee_per_gas
const maxFeePerGas = tx.maxFeePerGas;
const maxStateFeePerGas = 1_000_000n; // example: 0.001 gwei per state gas unit
const maxCostWei = totalExec * maxFeePerGas + totalState * maxStateFeePerGas;
return { frames: estimates, totalExecutionGas: totalExec, totalStateGas: totalState, maxCostWei };
}
async function estimateExecutionGas(client: any, frame: Frame): Promise<bigint> {
// Simulate with eth_call for the frame target
// State gas ops are excluded from execution gas in 2D model
return 21_000n; // baseline + estimated compute
}
async function estimateStateGas(client: any, frame: Frame): Promise<bigint> {
// Count storage operations in frame.data (ABI decode calldata, trace SLOADs/SSTOREs)
// SLOAD = 2100 state gas, SSTORE (set) = 20000 state gas, etc.
return 5_000n; // example
}
// Usage
const estimate = await estimateFrameTxGas(myFrameTx, 'https://eth-mainnet.rpc.url');
console.log('Total exec gas:', estimate.totalExecutionGas);
console.log('Total state gas:', estimate.totalStateGas);
console.log('Max cost:', estimate.maxCostWei, 'wei');
// Set limits in frames with safety margin
for (let i = 0; i < myFrameTx.frames.length; i++) {
myFrameTx.frames[i].executionGas = estimate.frames[i].executionGas * 120n / 100n; // 20% buffer
myFrameTx.frames[i].stateGas = estimate.frames[i].stateGas * 120n / 100n;
}ERC-4337 스마트 계정을 운용하고 있다면 EIP-8141이 활성화될 때 어떤 부분을 바꿔야 하는지 미리 파악해두는 게 중요하다. 개념 매핑부터 코드 비교까지 살펴본다.
| ERC-4337 | EIP-8141 | 변경 사항 |
|---|---|---|
| UserOperation | FrameTx (type 0x06) | RLP 인코딩 구조 완전히 다름. alt mempool 불필요 |
| Bundler | 제거됨 | 공개 mempool에 직접 전송 가능 |
| EntryPoint.handleOps() | 프로토콜 내장 처리 | 별도 컨트랙트 호출 불필요 |
| validateUserOp() | fallback() VERIFY 분기 | IAccount 인터페이스 대신 FRAMEPARAM 체크 |
| IPaymaster.validatePaymasterUserOp() | fallback() VERIFY (APPROVE_PAYMENT) | paymaster 컨트랙트 구조는 유사하나 EntryPoint 없음 |
| postOp() | DEFAULT mode 프레임 | postOp 로직은 DEFAULT 프레임으로 이동 |
| callData() | SENDER frame data | SENDER 프레임 data 필드에 callData 포함 |
| nonce | tx.nonce (TXPARAM) | 논스 관리 프로토콜로 이관. 키드 논스는 EIP-8250 |
// ERC-4337: IAccount interface
contract MyAccount is IAccount {
function validateUserOp(
UserOperation calldata userOp,
bytes32 userOpHash,
uint256 missingAccountFunds
) external returns (uint256 validationData) {
// Check caller is EntryPoint
require(msg.sender == address(entryPoint));
// Verify signature
bytes32 hash = userOpHash.toEthSignedMessageHash();
if (owner != hash.recover(userOp.signature))
return SIG_VALIDATION_FAILED;
// Pay missing funds to EntryPoint
if (missingAccountFunds != 0)
payable(msg.sender).call{value: missingAccountFunds}("");
return 0; // success
}
function execute(address dest, uint256 value, bytes calldata func)
external onlyEntryPoint {
dest.call{value: value}(func);
}
}// EIP-8141: VERIFY frame pattern
contract MyAccount {
address public owner;
fallback() external {
// No EntryPoint check needed —
// protocol guarantees caller = ENTRY_POINT (0xaa)
// for VERIFY frames
uint8 mode = _frameMode();
if (mode != 1) return; // not VERIFY
// Get signing hash via TXPARAM
bytes32 hash = _txParam(4);
// Verify owner signature directly
// (no IAccount interface, no missingFunds)
require(
owner == ECDSA.recover(hash, _getSecp256k1Sig()),
"Bad sig"
);
// APPROVE — signal protocol to proceed
// No ETH transfer needed!
_approve(0x3);
}
// No execute() needed: SENDER frames call target directly
// msg.sender = tx.sender guaranteed by protocol
}✅ 코드 감소 포인트: ① EntryPoint 주소 하드코딩 제거 ② missingAccountFunds ETH 전송 로직 제거 ③ execute() 함수 제거 (SENDER 프레임이 직접 target 호출) ④ IAccount 인터페이스 구현 불필요. 전체 계약 코드가 40~60% 줄어드는 효과.
EIP-8141을 배웠으니 이제 자연스러운 질문이 생긴다. "그럼 기존 EIP-3009나 ERC-4337은 언제 쓰고, EIP-8141은 언제 써야 하나?" 특히 USDC 결제·송금이 핵심인 PayFi 서비스라면 이 선택이 인프라 복잡도를 결정한다. 정답은 의외로 단순하다.
EIP-3009는 Circle USDC가 기본 지원하는 서명 기반 전송 표준이다. 사용자가 오프체인 서명(EIP-712)만 하면, 릴레이어가 그 서명을 담아 트랜잭션을 제출한다. 사용자는 ETH가 전혀 없어도 USDC를 전송할 수 있다.
// EIP-3009 핵심 함수 (USDC 컨트랙트에 이미 내장됨)
function transferWithAuthorization(
address from, // 보내는 사람
address to, // 받는 사람
uint256 value, // 금액
uint256 validAfter, // 유효 시작 시간
uint256 validBefore,// 유효 만료 시간
bytes32 nonce, // 재사용 방지 nonce
uint8 v, bytes32 r, bytes32 s // EIP-712 서명
) external;
// 릴레이어가 이 함수를 대신 호출 → 가스는 릴레이어 부담
// 사용자 ETH 잔고: 0 이어도 됨| 항목 | EIP-3009 릴레이어 | ERC-4337 Paymaster | EIP-8141 (Draft) |
|---|---|---|---|
| 작동 방식 | 사용자 서명 → 릴레이어 제출 | UserOp → Bundler → EntryPoint | Frame Tx → 공개 mempool 직접 |
| 커버 범위 | USDC 전송만 | 모든 컨트랙트 호출 | 모든 컨트랙트 호출 |
| 인프라 복잡도 | 낮음 (릴레이어 서버) | 높음 (스마트 컨트랙트 + 번들러) | 없음 (프로토콜 내장) |
| 지금 쓸 수 있나 | ✅ 지금 당장 | ✅ 지금 당장 | ❌ 드래프트 (2027년 이후) |
| ETH 불필요 | ✅ 릴레이어가 대납 | ✅ Paymaster가 대납 | ✅ APPROVE_PAYMENT 프레임 |
| USDC로 가스 정산 | ❌ 릴레이어 ETH 선납 | ✅ Token Paymaster | ✅ VERIFY 프레임 |
| 배치 트랜잭션 | ❌ | ✅ | ✅ 프로토콜 기본값 |
| 서명 체계 | ECDSA (secp256k1) | ECDSA (secp256k1) | ECDSA + P256 + ARBITRARY |
| 번들러 의존 | ❌ | ✅ 의존 | ❌ 제거됨 |
USDC 결제·송금이 서비스의 핵심 액션인 경우. ERC-4337의 복잡한 스택을 굳이 다 쌓을 필요 없다.
USDC 외 임의의 컨트랙트 호출까지 가스리스로 지원해야 할 때. 배치, 세션키, Token PM이 필요한 경우.
ERC-4337가 하는 모든 것을 번들러·EntryPoint 없이. P256/ARBITRARY 서명, 포스트 퀀텀, 2D 가스까지.
// 판단 기준 한 줄 요약
EIP-8141은 address(0x8141)에 만료 검증 프리컴파일을 추가한다. VERIFY 프레임 안에서 이 주소를 호출하면 Unix 타임스탬프를 검증하고 block.timestamp를 초과했을 때 revert한다. 세션 키, 임시 위임, 시간 제한 서명에 필수적인 원시 기능이다.
// EXPIRY_VERIFIER precompile at address(0x8141)
// Input: 8 bytes = uint64 Unix timestamp
// Output: nothing (reverts if expired)
// Gas: 6 gas
// ── Solidity usage ────────────────────────────────────────────────────
contract ExpiringSmartAccount {
address constant EXPIRY_VERIFIER = address(0x8141);
fallback() external {
if (_frameMode() != 1) return;
// 1. Read expiry from frame calldata (first 8 bytes)
uint64 expiry;
assembly {
// FRAMEDATALOAD reads 32 bytes from frame data at offset 0
mstore(0, 0)
staticcall(gas(), 0xb1, 0, 0x20, 0, 0x20)
// expiry is in the first 8 bytes (big-endian)
expiry := shr(192, mload(0)) // shift right 192 bits to get uint64
}
// 2. Call EXPIRY_VERIFIER precompile
// Input: 8-byte expiry timestamp
bytes memory input = abi.encodePacked(expiry);
(bool ok,) = EXPIRY_VERIFIER.staticcall(input);
require(ok, "Transaction expired");
// 3. Verify signature
bytes32 hash = _txParam(4);
require(_verifyOwnerSig(hash), "Bad sig");
// 4. Approve
_approve(0x3);
}
// ── TypeScript: building expiring transaction ─────────────────────
// On the client side, encode expiry in frame calldata:
// const expiry = BigInt(Math.floor(Date.now() / 1000) + 3600); // 1 hour
// const frameData = new Uint8Array(8);
// new DataView(frameData.buffer).setBigUint64(0, expiry, false); // big-endian
function _txParam(uint256 idx) internal view returns (bytes32 h) {
assembly {
mstore(0, idx)
staticcall(gas(), 0xb0, 0, 0x20, 0, 0x20)
h := mload(0)
}
}
function _frameMode() internal view returns (uint8 m) {
assembly {
mstore(0, 0)
staticcall(gas(), 0xb3, 0, 0x20, 0, 0x20)
m := and(mload(0), 0xff)
}
}
function _approve(uint8 scope) internal {
assembly { pop(call(gas(), 0xaa, 0, 0, 0, 0, 0)) }
}
function _verifyOwnerSig(bytes32) internal view returns (bool) {
return true; // placeholder
}
}💡 실전 패턴 — 시간 제한 세션 서명: 사용자가 "1시간 동안 이 DApp이 나 대신 스왑할 수 있다"는 서명을 오프체인에 생성한다. 서명 데이터에 expiry를 포함시키고, VERIFY 프레임 calldata에 전달. EXPIRY_VERIFIER가 만료를 온체인에서 강제한다. 사용자 재서명 없이 세션이 자동 만료된다.
세션 키는 "특정 DApp이 제한된 범위 안에서 사용자 대신 행동할 수 있는 임시 키"다. EIP-8141에서는 VERIFY 프레임이 세션 키 정책을 완전히 EVM 레벨에서 집행할 수 있어 별도 위임 컨트랙트가 필요 없다.
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.30;
/// @notice Session key policy smart account
/// Allows a game/DApp to execute limited actions on behalf of the user
contract SessionKeyAccount {
address public owner;
address constant EXPIRY_VERIFIER = address(0x8141);
struct SessionKey {
address key; // temporary key address
uint64 expiry; // Unix timestamp
address allowedTarget; // only this contract can be called in SENDER frames
uint256 maxValuePerTx; // max ETH per transaction
bool active;
}
// owner-authorized session keys
mapping(address => SessionKey) public sessions;
// ── Owner functions ───────────────────────────────────────────────
function authorizeSessionKey(
address sessionKey,
uint64 expiry,
address allowedTarget,
uint256 maxValue
) external {
require(msg.sender == address(this), "Must call via SENDER frame");
sessions[sessionKey] = SessionKey({
key: sessionKey,
expiry: expiry,
allowedTarget: allowedTarget,
maxValuePerTx: maxValue,
active: true
});
}
function revokeSessionKey(address sessionKey) external {
require(msg.sender == address(this), "Must call via SENDER frame");
sessions[sessionKey].active = false;
}
// ── VERIFY frame handler ─────────────────────────────────────────
fallback() external {
if (_frameMode() != 1) return;
bytes32 sigHash = bytes32(_txParam(4));
// Try owner signature first (full permission)
if (_tryOwnerVerify(sigHash)) {
_approve(0x3);
return;
}
// Try session key verification (limited permission)
_sessionKeyVerify(sigHash);
}
function _tryOwnerVerify(bytes32 hash) internal view returns (bool) {
// SECP256K1 sig at index 0
address recovered = _ecrecoverFromSig(hash, 0);
return recovered == owner;
}
function _sessionKeyVerify(bytes32 hash) internal {
// Session key sig at index 0 (or index 1 if owner sig index is 0)
address sessionKeyAddr = _ecrecoverFromSig(hash, 0);
SessionKey storage session = sessions[sessionKeyAddr];
require(session.active, "Session key not active");
// Check expiry via EXPIRY_VERIFIER precompile
bytes memory expiryInput = abi.encodePacked(session.expiry);
(bool ok,) = EXPIRY_VERIFIER.staticcall(expiryInput);
require(ok, "Session key expired");
// Check frame target matches allowed target
// Read frame target via FRAMEPARAM
address frameTarget;
assembly {
mstore(0, 2) // FRAMEPARAM index 2 = target address
staticcall(gas(), 0xb3, 0, 0x20, 0, 0x20)
frameTarget := mload(0)
}
// For VERIFY frames target = self; check next SENDER frame target
// (In practice: read next frame via FRAMEPARAM)
// Check max value
uint256 txValue;
assembly {
mstore(0, 2) // TXPARAM index 2 = max_cost (simplified; use frame value)
staticcall(gas(), 0xb0, 0, 0x20, 0, 0x20)
txValue := mload(0)
}
require(txValue <= session.maxValuePerTx, "Exceeds session value limit");
// Session key can only approve execution (not payment — owner pays gas)
_approve(0x2); // APPROVE_EXECUTION only
}
function _ecrecoverFromSig(bytes32 hash, uint256 sigIdx) internal view returns (address) {
// Use SIGDATACOPY to get v, r, s from signature at sigIdx
// Simplified: return address(0) as placeholder
return address(0);
}
function _txParam(uint256 idx) internal view returns (uint256 v) {
assembly {
mstore(0, idx)
staticcall(gas(), 0xb0, 0, 0x20, 0, 0x20)
v := mload(0)
}
}
function _frameMode() internal view returns (uint8 m) {
assembly {
mstore(0, 0)
staticcall(gas(), 0xb3, 0, 0x20, 0, 0x20)
m := and(mload(0), 0xff)
}
}
function _approve(uint8 scope) internal {
assembly { pop(call(gas(), 0xaa, 0, 0, 0, 0, 0)) }
}
}EIP-8141이 메인넷에 반영되기 전에는 EVM fork 모드 + 새 오퍼코드 모킹으로 테스트해야 한다. EIP-8141 지원 EVM(ethrex 등)이 출시되면 이 섹션을 업데이트한다.
// test/SmartAccount.t.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.30;
import "forge-std/Test.sol";
import "../src/MinimalSmartAccount.sol";
contract SmartAccountTest is Test {
MinimalSmartAccount account;
address owner;
uint256 ownerPk;
function setUp() public {
(owner, ownerPk) = makeAddrAndKey("owner");
account = new MinimalSmartAccount(owner);
vm.deal(address(account), 10 ether);
}
// ── Mock EIP-8141 opcodes via vm.etch + cheatcodes ──────────────
// Until a real EIP-8141 EVM exists, we mock:
// 1. FRAMEPARAM (0xb3) precompile behavior
// 2. TXPARAM (0xb0) precompile behavior
// 3. APPROVE (0xaa) call behavior
function _mockVerifyFrame(bytes32 sigHash) internal {
// Deploy a mock "FRAMEPARAM" contract at 0xb3
// Returns mode=1 (VERIFY) for param index 0
bytes memory frameParamCode = abi.encodePacked(
hex"6000351460085760005260206000f35b60015260206000f3"
// simplified: if(input==0) return 1 (VERIFY mode)
);
vm.etch(address(0xb3), frameParamCode);
// Deploy mock "TXPARAM" at 0xb0
// Returns sigHash for param index 4
bytes memory txParamCode = abi.encodePacked(
hex"600435146010576020526020600020f35b",
sigHash,
hex"60005260206000f3"
);
vm.etch(address(0xb0), txParamCode);
// Deploy mock "APPROVE" at 0xaa — just succeeds
vm.etch(address(0xaa), hex"600160005260206000f3");
}
function test_verifyWithOwnerSig() public {
// Build signing hash (what protocol would compute)
bytes32 txHash = keccak256(abi.encodePacked(
"FrameTx:", block.chainid, address(account), uint256(0)
));
// Mock EIP-8141 opcodes
_mockVerifyFrame(txHash);
// Sign with owner key
(uint8 v, bytes32 r, bytes32 s) = vm.sign(ownerPk, txHash);
// Simulate ENTRY_POINT calling account.fallback() in VERIFY mode
// In real EIP-8141 EVM, this is protocol-driven
vm.prank(address(0xaa)); // ENTRY_POINT = 0xaa
(bool success,) = address(account).call(
abi.encodePacked(v, r, s) // pass sig in calldata (simplified)
);
assertTrue(success, "VERIFY frame should succeed with owner sig");
}
function test_verifyWithWrongSig() public {
(, uint256 wrongPk) = makeAddrAndKey("wrong");
bytes32 txHash = keccak256("test");
_mockVerifyFrame(txHash);
(uint8 v, bytes32 r, bytes32 s) = vm.sign(wrongPk, txHash);
vm.prank(address(0xaa));
(bool success,) = address(account).call(abi.encodePacked(v, r, s));
assertFalse(success, "Should reject wrong signature");
}
// ── Integration: Full Frame Transaction flow ──────────────────────
function test_fullFrameTxFlow() public {
// 1. Simulate VERIFY frame approval
bytes32 sigHash = keccak256("full_flow_test");
_mockVerifyFrame(sigHash);
(uint8 v, bytes32 r, bytes32 s) = vm.sign(ownerPk, sigHash);
vm.prank(address(0xaa));
(bool verifyOk,) = address(account).call(abi.encodePacked(v, r, s));
assertTrue(verifyOk);
// 2. Simulate SENDER frame execution
address recipient = makeAddr("recipient");
uint256 startBalance = recipient.balance;
vm.prank(address(account)); // msg.sender = tx.sender in SENDER frame
(bool sendOk,) = recipient.call{value: 0.1 ether}("");
assertTrue(sendOk);
assertEq(recipient.balance, startBalance + 0.1 ether);
}
// ── Gas measurement ───────────────────────────────────────────────
function test_verifyGasCost() public {
bytes32 sigHash = keccak256("gas_test");
_mockVerifyFrame(sigHash);
(uint8 v, bytes32 r, bytes32 s) = vm.sign(ownerPk, sigHash);
uint256 gasBefore = gasleft();
vm.prank(address(0xaa));
address(account).call(abi.encodePacked(v, r, s));
uint256 gasUsed = gasBefore - gasleft();
// VERIFY frame should stay well under 100,000 gas (mempool limit)
assertLt(gasUsed, 100_000, "VERIFY frame exceeds mempool gas limit");
console.log("VERIFY frame gas used:", gasUsed);
}
}결론: 중장기적으로 의미 있고, 단기적으로 행동 불필요. 단 한 가지 포인트는 지금 주목할 만하다.
EIP-8141의 주요 셀링포인트 중 하나가 "가스를 USDC 등 토큰으로 낼 수 있다"인데, Walits는 EIP-3009 + 릴레이어로 이미 이를 구현해 서비스 중이다. EIP-8141이 Base에 올라오면 오히려 릴레이어 인프라를 걷어내고 프로토콜에 위임할 수 있어 인프라가 단순해지는 방향이다. 위협이 아니라 구조 개선 기회.
EIP-8141은 ECDSA 외 서명 체계(P256, ARBITRARY)를 프로토콜이 직접 수용한다. Walits MPC는 현재 CGGMP21(secp256k1 기반)을 운용 중이며, CGGMP24는 로드맵 준비 단계다. 향후 서명 알고리즘 다양화 시 프로토콜 레벨 지원이 생긴다는 뜻으로, 지금 당장은 아니지만 MPC 인프라 로드맵에서 고려할 요소다.
팩트체크 수정: 원문의 "CGGMP24 + FROST 현재 운용"은 부정확. IR 자료 기준 현재 알고리즘은 CGGMP21이며, CGGMP24는 준비 중, FROST는 공개 로드맵에 미포함.
현재 PayFi에서 "예치 + 전략 선택"이 트랜잭션 여러 번인데, EIP-8141의 원자적 배치가 프로토콜 기본값이 되면 한 트랜잭션으로 묶어서 처리 가능하다. 실패 케이스가 줄고 UX도 개선된다. 단, EIP-8141이 Base에 활성화되는 시점이 전제 조건이다.
x402는 AI 에이전트가 ETH 없이 USDC만으로 API를 즉시 결제하는 프로토콜(Base 기본 레일)이다. EIP-8141이 활성화되면 에이전트 지갑이 ETH 잔고 없이도 USDC 가스 결제를 프로토콜 레벨에서 처리할 수 있어, Walits가 타겟하는 에이전트 지갑 포지셔닝이 더 강화된다. x402 + EIP-8141 조합은 "ETH 없는 에이전트 자율 결제" 시나리오를 인프라 없이 실현한다.
EIP-8141은 드래프트고, Base에 올라오려면 Ethereum 메인넷 확정 → 하드포크 → Base 포팅 순서라 2027년 이전 현실화는 어렵다.
💡 단, 지금 주목할 포인트 하나: "코드 없는 EOA에 default code 제공" — 일반 MPC EOA가 별도 컨트랙트 배포 없이 AA 기능을 쓸 수 있게 된다는 부분은 Walits 지갑 구조와 직결된다. 스펙이 확정되는 시점에 한 번 깊게 검토할 가치가 있다.
Walits는 현재 EIP-3009 릴레이어로 ETH 없이 USDC 전송을 지원하며, EIP-7702 기반 스마트 계정 전환을 준비하고 있다. EIP-8141이 메인넷에 반영되면, Walits의 Base USDC 인프라는 Frame Transaction의 SENDER 프레임과 ARBITRARY 서명 체계를 그대로 활용할 수 있는 구조다.