Workspace IndexDev Notes › ERC-8141

#31PoC

ERC-8141

Native account-abstraction explainer — Ethereum's protocol-level Frame Transactions.

Read-only explainer — no wallet needed.

Why

A protocol-native preview of what this project's application-layer AA demos (session keys, atomic batching) do today with smart contracts and delegation — EIP-8141 proposes moving those same properties into Ethereum's base transaction format itself.

How it works

Not a working demo by necessity: EIP-8141 defines a new transaction type where a single transaction carries a sequence of frames (a VERIFY frame for signature/fee authorization, then one or more EXECUTE frames) instead of one implicit call — but no client or RPC can send this transaction type yet, since it requires execution-layer support the network doesn't have. As of writing it's only "considered for inclusion" in a future fork, so this stays a diagram/explainer page rather than a live demo.

The ACDE readout, and the two kinds of "no" (2026-08-28)

The call was set to conclude between this proposal and EIP-8130, and as of writing no public readout confirms an outcome. What the developer timeline did in that gap is the useful part: the argument moved off the content and onto the weight. The recurring sentence was "the direction is right, but this is too heavy for the current stage" — and that is a schedule objection, not a technical one.

The distinction is worth holding onto because the two look identical from outside and demand opposite responses:

The objection What it actually says What you do next
"This is wrong" the design does not achieve the goal change the design
"This is not now" the design is right and too large to land here split the scope

Hearing the second and redesigning is wasted work; hearing the first and merely deferring is worse. The question to ask in the room is which one you are being given — and the tell is usually whether the objection survives when you make the proposal smaller.

Review clarification

The basic concept, from zero

Ethereum has two kinds of accounts, and only the dumb one can act. An EOA can start transactions, but its rules are frozen into the protocol: one ECDSA key, one sequential nonce, fees paid by the sender in ETH, one call per transaction — lose the key, lose the account. A contract account is fully programmable but cannot initiate anything. Account abstraction is the project of erasing that split: let the account that acts also be programmable — in who may sign, how fees are paid, and what one transaction may contain.

Today's AA is an emulation built one layer up: ERC-4337 rebuilt a transaction system out of contracts (UserOperations, bundlers, an EntryPoint), and EIP-7702 lets an EOA borrow contract code. It works, at the price of extra actors, extra gas, and extra trust surfaces. EIP-8141 proposes ending the emulation: a new base transaction type made of frames — one VERIFY frame ("here is the proof this is authorized, and how fees get paid") followed by one or more EXECUTE frames (the calls, atomically: all or nothing). The two jobs every transaction always had — prove it's allowed, then do things — become explicit, separate, programmable parts of the format itself.

The same action, three generations of code

Today, the node's checks are hard-coded and unchangeable:

assert(ecrecover(tx.sig) == tx.from);     // exactly one ECDSA key
assert(tx.nonce == account.nonce);        // exactly one nonce
assert(balance[tx.from] >= gas * price);  // sender pays, ETH only
call(tx.to, tx.data);                     // exactly one call

ERC-4337 replays those checks inside a contract — EntryPoint.handleOps loops over UserOperations calling account.validateUserOp and then account.execute, with a bundler carrying the batch and a paymaster optionally paying. Every hop is the emulation tax.

Under EIP-8141 the node itself runs the loop the EntryPoint used to fake:

tx = { type: FRAME_TX, frames: [
  { kind: VERIFY,  target: myAccount,
    input: { scheme: "p256-passkey", proof, feeToken: USDC } },
  { kind: EXECUTE, target: USDC, data: approve(dex, 100e6) },
  { kind: EXECUTE, target: dex,  data: swap(USDC, ETH, 100e6) },
]}

and the account's own verify code is where the features live — a session key, natively:

function verify(Proof p, Frame[] fs) external view returns (bool) {
    if (p.signer == owner) return checkSig(p);
    Session s = sessions[p.signer];              // a temporary key
    require(block.timestamp < s.expiry);         // ...that expires
    require(allCallsTo(fs, s.allowedContract));  // ...only this app
    require(totalValue(fs) <= s.perTxLimit);     // ...small amounts
    return checkSig(p);
}

Who it makes happy

Who Today's pain With frame transactions
Users Seed phrase or bust; approve-then-swap leaves a dangling approval; need ETH first Passkey as the native key; approve+swap atomic; fees in tokens or sponsored
Wallet builders Run or rent bundler + paymaster infrastructure Those services largely disappear; the chain validates directly
dApp/game/agent builders Session keys need a smart-wallet stack per provider Bounded delegation is a base-layer feature
The protocol A parallel 4337 mempool; EntryPoint as a shared choke point One canonical mempool, no single trusted contract

Concrete situations it improves: the dangling-approve accident (two EXECUTE frames make approval-without-swap impossible); onboarding without ETH (a sponsor pays inside VERIFY); lost-key recovery as account logic instead of a custodian's feature; the post-quantum migration (signature schemes become account code to upgrade, not protocol constants to fork); and agent commerce (an AI agent holding a session key with expiry, an allowed contract, and a per-transaction limit is delegation with a chosen blast radius).

The honest cost — and most of why ACDE said "too heavy": the VERIFY frame is arbitrary code deciding mempool validity before anyone has paid, so it must be strictly gas-bounded and cheaply re-checkable, or invalid transactions become a free DoS vector.

"Then ERC-4337 could be sunsetted?" (jay, 2026-09-03)

Right direction, wrong tense. 4337 was always the stopgap — enshrinement was the stated endgame, and 8141's VERIFY/EXECUTE split is essentially validateUserOp/execute promoted into the transaction format. So 4337 doesn't get killed; it gets absorbed — the concepts survive as the contracts empty out. But the sunset is a decade-scale decay, for four reasons: nothing on Ethereum is ever removed (the EntryPoint is immortal, and millions of deployed and counterfactual smart-account addresses don't migrate themselves); the native-AA competition is unsettled (8141 vs 8130 vs Tempo — a workaround with users beats a proposal without a fork date); L2s break the symmetry (4337 works identically on every EVM chain today, while native AA lands chain by chain — cross-chain wallets may be its last stronghold); and the bundler/paymaster vendors will pivot to the new rails rather than defend the old ones, accelerating the very sunset that obsoletes them. As strategy — don't build a moat out of 4337 plumbing — the sentence is already true. As a forecast — traffic to zero — add ten years and an asterisk.

Progress (2026-09-08)

Vitalik's 9/6 Frames update reported movement: on the 8/27 call the proposal went CFI → SFI (considered → scheduled for inclusion), slotted for Hegotá (targeted 2027 Q2) alongside FOCIL. SFI is an assignment, not a completion — the tell to watch after the 9/10 devnet-priority list is whether a devnet actually attaches to 8141. Two framing notes. The headline 'pay gas in stablecoins' is a consequence, not the design: the design separates the three jobs that today share one signer — authorization, gas payment, execution — and stablecoin fees are what fall out once gas payment is its own strand inside VERIFY. And the competition is asymmetric on timing: rival EIP-8130 targets a Base deployment in September, so the L2 implementation becomes real before the L1 standard does — a workaround with users, again, outrunning a proposal with a fork date.

The same runtime-vs-spec point the invariant card makes applies to the objection here: liquid-issuance-not-authorization is why an enshrined VERIFY frame must be strictly gas-bounded and cheaply re-checkable — arbitrary validity code that runs before anyone pays is a premise the network cannot afford to trust unconditionally.

Related code

# ERC-8141 — a transaction carries a sequence of frames instead of one implicit call:
# a VERIFY frame (signature/fee authorization) followed by one or more EXECUTE frames.
# Simulates the frame structure and runs it in sequence, aborting if VERIFY fails.

from dataclasses import dataclass, field
from typing import Callable


@dataclass
class Frame:
    kind: str  # "VERIFY" or "EXECUTE"
    description: str
    run: Callable[[dict], bool]


@dataclass
class FrameTransaction:
    frames: list[Frame] = field(default_factory=list)

    def execute(self, context: dict) -> bool:
        for frame in self.frames:
            print(f"  [{frame.kind}] {frame.description} ...", end=" ")
            ok = frame.run(context)
            print("OK" if ok else "FAILED")
            if frame.kind == "VERIFY" and not ok:
                print("  -> VERIFY frame failed: transaction aborted before any EXECUTE frame runs.")
                return False
            if frame.kind == "EXECUTE" and not ok:
                print("  -> EXECUTE frame failed: transaction reverts.")
                return False
        return True


def verify_signature_and_fee(ctx: dict) -> bool:
    return ctx.get("signature_valid", False) and ctx.get("balance", 0) >= ctx.get("max_fee", 0)


def execute_transfer(ctx: dict) -> bool:
    ctx["balance"] -= ctx["transfer_amount"]
    return ctx["balance"] >= 0


def execute_approve(ctx: dict) -> bool:
    ctx["allowance"] = ctx.get("allowance", 0) + ctx["approve_amount"]
    return True


def build_transaction() -> FrameTransaction:
    return FrameTransaction(frames=[
        Frame("VERIFY", "signature + fee authorization", verify_signature_and_fee),
        Frame("EXECUTE", "transfer 50 tokens", execute_transfer),
        Frame("EXECUTE", "approve spender for 20 tokens", execute_approve),
    ])


if __name__ == "__main__":
    print("EIP-8141 Frame Transaction — VERIFY then EXECUTE, EXECUTE, ...\n")

    print("Case 1: valid signature, sufficient balance")
    ctx = {"signature_valid": True, "balance": 100, "max_fee": 5, "transfer_amount": 50, "approve_amount": 20}
    ok = build_transaction().execute(ctx)
    print(f"Transaction succeeded: {ok}, final state: {ctx}\n")

    print("Case 2: invalid signature — VERIFY frame blocks all EXECUTE frames")
    ctx = {"signature_valid": False, "balance": 100, "max_fee": 5, "transfer_amount": 50, "approve_amount": 20}
    ok = build_transaction().execute(ctx)
    print(f"Transaction succeeded: {ok}")

← All Dev Notes · Workspace Index · Top ↑

ERC-8141

네이티브 계정 추상화 설명 페이지 — 이더리움 프로토콜 레벨 Frame Transactions.

읽기 전용 설명 페이지 — 지갑 불필요.

이 프로젝트의 애플리케이션 레벨 AA 데모(세션 키, 원자적 배치)가 스마트 컨트랙트와 위임으로 지금 하고 있는 일을, 프로토콜 네이티브 수준에서 미리 보여줍니다 — EIP-8141은 같은 속성을 이더리움의 기본 트랜잭션 포맷 자체로 옮기자는 제안입니다.

동작 방식

구조상 실제 동작하는 데모가 될 수 없습니다: EIP-8141은 트랜잭션 하나가 암묵적 호출 한 번이 아니라 프레임의 시퀀스(서명·수수료 인가를 담당하는 VERIFY 프레임, 이어지는 하나 이상의 EXECUTE 프레임)를 담는 새 트랜잭션 타입을 정의하지만, 아직 어떤 클라이언트나 RPC도 이 타입을 보낼 수 없습니다 — 네트워크에 없는 실행 계층 지원이 필요하기 때문입니다. 이 글을 쓰는 시점 기준 향후 포크에 "포함 검토 중"인 단계라, 라이브 데모가 아니라 다이어그램·설명 페이지로 남습니다.

ACDE 리드아웃, 그리고 두 종류의 "아니오" (2026-08-28)

이 제안과 EIP-8130 사이의 결론이 예정돼 있었고, 이 글을 쓰는 시점까지 공개된 리드아웃은 확인되지 않습니다. 그 공백에서 개발자 타임라인이 향한 곳이 쓸모 있는 부분입니다 — 논쟁이 내용이 아니라 무게로 옮겨갔습니다. 반복된 문장은 "방향은 맞는데 지금 단계에서 너무 무겁다" 였고, 이건 기술 반대가 아니라 일정 반대입니다.

이 구분은 붙들 값이 있습니다 — 밖에서 보면 똑같이 생겼는데 요구하는 대응이 정반대이기 때문입니다:

반대 실제로 하는 말 다음에 할 일
"이건 틀렸다" 설계가 목적을 달성하지 못한다 설계를 바꾼다
"이건 지금이 아니다" 설계는 맞고, 여기 넣기엔 크다 범위를 쪼갠다

두 번째를 듣고 재설계하면 헛일이고, 첫 번째를 듣고 미루기만 하면 더 나쁩니다. 회의실에서 물어야 할 것은 지금 받은 게 어느 쪽인가이고, 판별법은 대개 제안을 작게 만들었을 때도 그 반대가 살아남는가입니다.

검토 후 보완

기본 개념, 처음부터

이더리움에는 계정이 두 종류인데, 행동할 수 있는 쪽이 멍청합니다. EOA 는 트랜잭션을 시작할 수 있지만 규칙이 프로토콜에 얼어붙어 있습니다: ECDSA 키 하나, 순차 논스 하나, 수수료는 본인이 ETH 로, 트랜잭션당 호출 하나 — 키를 잃으면 계정을 잃습니다. 컨트랙트 계정은 완전히 프로그래머블하지만 아무것도 시작하지 못합니다. 계정 추상화(AA) 는 이 분리를 지우는 프로젝트입니다: 행동하는 계정이 프로그래머블하게 — 누가 서명하는지, 수수료를 어떻게 내는지, 트랜잭션 하나에 무엇이 담기는지를.

오늘의 AA 는 한 층 위에 지은 에뮬레이션입니다: ERC-4337 은 컨트랙트로 트랜잭션 시스템을 재건축했고(UserOperation, 번들러, EntryPoint), EIP-7702 는 EOA 가 컨트랙트 코드를 빌리게 합니다. 작동하지만 — 추가 행위자, 추가 가스, 추가 신뢰 표면이 대가입니다. EIP-8141 은 에뮬레이션을 끝내자는 제안입니다: 프레임으로 이루어진 새 베이스 트랜잭션 타입 — VERIFY 프레임 하나("인가 증명은 이것, 수수료는 이렇게") 뒤에 EXECUTE 프레임 하나 이상(호출들, 원자적으로: 전부 아니면 전무). 트랜잭션이 늘 갖고 있던 두 가지 일 — 허용됐음을 증명하기, 그다음 실행하기 — 이 포맷 자체의 명시적이고 분리된, 프로그래밍 가능한 부품이 됩니다.

같은 동작, 코드 세 세대

오늘 노드의 검사는 하드코딩이라 바꿀 수 없습니다:

assert(ecrecover(tx.sig) == tx.from);     // ECDSA 키 정확히 하나
assert(tx.nonce == account.nonce);        // 논스 정확히 하나
assert(balance[tx.from] >= gas * price);  // 본인이, ETH 로만
call(tx.to, tx.data);                     // 호출 정확히 하나

ERC-4337 은 이 검사를 컨트랙트 안에서 재연합니다 — EntryPoint.handleOps 가 UserOperation 들을 돌며 account.validateUserOp 그리고 account.execute 를 호출하고, 번들러가 배치를 나르고 페이마스터가 선택적으로 지불합니다. 홉 하나하나가 에뮬레이션 세금입니다.

EIP-8141 에서는 EntryPoint 가 흉내 내던 루프를 노드 자신이 돕니다:

tx = { type: FRAME_TX, frames: [
  { kind: VERIFY,  target: 내계정,
    input: { scheme: "p256-passkey", proof, feeToken: USDC } },
  { kind: EXECUTE, target: USDC, data: approve(dex, 100e6) },
  { kind: EXECUTE, target: dex,  data: swap(USDC, ETH, 100e6) },
]}

기능이 사는 곳은 계정 자신의 verify 코드입니다 — 네이티브 세션 키:

function verify(Proof p, Frame[] fs) external view returns (bool) {
    if (p.signer == owner) return checkSig(p);
    Session s = sessions[p.signer];              // 임시 키
    require(block.timestamp < s.expiry);         // …만료되고
    require(allCallsTo(fs, s.allowedContract));  // …이 앱만
    require(totalValue(fs) <= s.perTxLimit);     // …소액만
    return checkSig(p);
}

누가 행복해지나

누가 오늘의 고통 프레임 트랜잭션에서는
사용자 시드 문구 아니면 끝; 승인→스왑이 열린 승인을 남김; 먼저 ETH 필요 패스키가 네이티브 키; 승인+스왑 원자적; 수수료는 토큰이나 스폰서가
지갑 개발자 번들러+페이마스터 인프라 운영/임대 그 서비스들이 대부분 사라짐; 체인이 직접 검증
dApp/게임/에이전트 개발자 세션 키에 제공자별 스마트 지갑 스택 유계 위임이 베이스 레이어 기능
프로토콜 병렬 4337 멤풀; 공유 관문 EntryPoint 정본 멤풀 하나, 신뢰할 단일 컨트랙트 없음

구체적으로 개선되는 상황: 열린 승인 사고(EXECUTE 프레임 둘이면 스왑 없는 승인이 존재 불가능); ETH 없는 온보딩(VERIFY 안에서 스폰서가 지불); 키 분실 복구가 수탁자의 기능이 아니라 계정 로직이 되는 것; 포스트퀀텀 마이그레이션(서명 스킴이 포크할 프로토콜 상수가 아니라 업그레이드할 계정 코드가 됨); 에이전트 커머스(만료·허용 컨트랙트·건당 한도가 붙은 세션 키를 쥔 AI 에이전트 = 내가 고른 폭발 반경을 가진 위임).

정직한 비용 — ACDE 가 "너무 무겁다"고 한 이유의 대부분: VERIFY 프레임은 아무도 돈을 내기 전에 멤풀 유효성을 결정하는 임의 코드라서, 엄격한 가스 상한과 값싼 재검사가 없으면 무효 트랜잭션이 공짜 DoS 벡터가 됩니다.

"그럼 ERC-4337 은 일몰될 수 있겠네?" (jay, 2026-09-03)

방향은 맞고 시제가 틀렸습니다. 4337 은 처음부터 임시방편이었고 — 인슈라인먼트가 공공연한 최종 목표였으며, 8141 의 VERIFY/EXECUTE 분리는 본질적으로 validateUserOp/execute 의 포맷 승격입니다. 그러니 4337 은 죽는 게 아니라 흡수됩니다 — 컨트랙트는 비어 가도 개념은 살아남습니다. 하지만 일몰은 10년 단위의 감쇠입니다. 이유 넷: 이더리움에서는 아무것도 제거되지 않고(EntryPoint 는 불멸, 배포됐거나 counterfactual 인 수백만 스마트 계정 주소는 스스로 이주하지 않음); 네이티브 AA 경쟁이 미결이며(8141 대 8130 대 Tempo — 사용자를 가진 임시방편이 포크 날짜 없는 제안을 이김); L2 가 대칭을 깨고(4337 은 오늘 모든 EVM 체인에서 동일하게 작동하지만 네이티브 AA 는 체인마다 따로 — 크로스체인 지갑이 마지막 요새); 번들러/페이마스터 벤더들이 옛 레일을 지키는 대신 새 레일로 피벗해 자기를 낡게 만드는 일몰을 가속할 것이기 때문입니다. 전략으로서 — 4337 배관을 해자로 삼지 마라 — 는 이미 참입니다. 예보로서 — 트래픽 0 — 는 10년과 별표 하나를 붙이십시오.

진행 상황 (2026-09-08)

비탈릭의 9/6 Frames 업데이트가 진전을 알렸습니다: 8/27 콜에서 제안이 CFI → SFI(포함 검토 → 포함 예정)로 승격, FOCIL 과 나란히 Hegotá(2027 Q2 목표)에 배정됐습니다. SFI 는 배정이지 완료가 아닙니다 — 9/10 데브넷 선호목록 이후 볼 판별점은 8141 에 데브넷이 실제로 붙는가입니다. 프레이밍 둘. 헤드라인 '스테이블코인으로 가스 지불'은 설계가 아니라 결과입니다: 설계는 오늘 한 서명자에 묶인 세 역할 — 인가·가스 지불·실행 — 을 분리하는 것이고, 스테이블코인 수수료는 가스 지불이 VERIFY 안의 독립된 가닥이 되면 따라 나옵니다. 그리고 경쟁은 타이밍이 비대칭입니다: 경쟁안 EIP-8130 은 9월 중 Base 배포를 목표로 해서, L2 구현이 L1 표준보다 먼저 실물이 됩니다 — 사용자를 가진 임시방편이 또 한 번 포크 날짜 있는 제안을 앞지릅니다.

불변식 카드가 말하는 런타임 대 명세 논점이 여기 반대에도 적용됩니다: liquid-issuance-not-authorization 가, 인슈라인된 VERIFY 프레임이 왜 엄격히 가스 상한이 걸리고 값싸게 재검사돼야 하는지의 이유입니다 — 아무도 돈 내기 전에 도는 임의 유효성 코드는, 네트워크가 무조건 믿어 줄 수 없는 전제입니다.

관련 코드

# ERC-8141 — a transaction carries a sequence of frames instead of one implicit call:
# a VERIFY frame (signature/fee authorization) followed by one or more EXECUTE frames.
# Simulates the frame structure and runs it in sequence, aborting if VERIFY fails.

from dataclasses import dataclass, field
from typing import Callable


@dataclass
class Frame:
    kind: str  # "VERIFY" or "EXECUTE"
    description: str
    run: Callable[[dict], bool]


@dataclass
class FrameTransaction:
    frames: list[Frame] = field(default_factory=list)

    def execute(self, context: dict) -> bool:
        for frame in self.frames:
            print(f"  [{frame.kind}] {frame.description} ...", end=" ")
            ok = frame.run(context)
            print("OK" if ok else "FAILED")
            if frame.kind == "VERIFY" and not ok:
                print("  -> VERIFY frame failed: transaction aborted before any EXECUTE frame runs.")
                return False
            if frame.kind == "EXECUTE" and not ok:
                print("  -> EXECUTE frame failed: transaction reverts.")
                return False
        return True


def verify_signature_and_fee(ctx: dict) -> bool:
    return ctx.get("signature_valid", False) and ctx.get("balance", 0) >= ctx.get("max_fee", 0)


def execute_transfer(ctx: dict) -> bool:
    ctx["balance"] -= ctx["transfer_amount"]
    return ctx["balance"] >= 0


def execute_approve(ctx: dict) -> bool:
    ctx["allowance"] = ctx.get("allowance", 0) + ctx["approve_amount"]
    return True


def build_transaction() -> FrameTransaction:
    return FrameTransaction(frames=[
        Frame("VERIFY", "signature + fee authorization", verify_signature_and_fee),
        Frame("EXECUTE", "transfer 50 tokens", execute_transfer),
        Frame("EXECUTE", "approve spender for 20 tokens", execute_approve),
    ])


if __name__ == "__main__":
    print("EIP-8141 Frame Transaction — VERIFY then EXECUTE, EXECUTE, ...\n")

    print("Case 1: valid signature, sufficient balance")
    ctx = {"signature_valid": True, "balance": 100, "max_fee": 5, "transfer_amount": 50, "approve_amount": 20}
    ok = build_transaction().execute(ctx)
    print(f"Transaction succeeded: {ok}, final state: {ctx}\n")

    print("Case 2: invalid signature — VERIFY frame blocks all EXECUTE frames")
    ctx = {"signature_valid": False, "balance": 100, "max_fee": 5, "transfer_amount": 50, "approve_amount": 20}
    ok = build_transaction().execute(ctx)
    print(f"Transaction succeeded: {ok}")

← 전체 개발 노트 · 워크스페이스 인덱스 · 맨 위 ↑