Skip to content

Frame Transactions

Archived EIP-8141 account model

FrameAccount was a variant of the NiceTry account that expressed the same ephemeral-key rule natively, using EIP-8141 frame transactions instead of ERC-4337. It authenticated with the same ForsVerifier and enforced the same invariant (every authorised transaction must rotate the signer) but did so without an EntryPoint or UserOperation.

What frame transactions are

EIP-8141 introduces a transaction type built from a sequence of frames, each with its own mode, target, value, gas limit, and calldata:

tx = { sender, nonce, frames: [ [mode, flags, target, gas, value, data], ... ], fee fields }

The modes relevant here:

ModeMeaning
VERIFYvalidate the transaction (static: no state changes; may APPROVE execution)
SENDERexecute as the transaction sender, after approval
DEFAULTexecute through the frame entry context

The point for NiceTry: account validation becomes native. A smart account defines its own validation in a VERIFY frame and inspects later frames through introspection opcodes (TXPARAM, FRAMEPARAM, FRAMEDATALOAD). No ERC-4337 validateUserOp is required.

Why rotation must be a separate frame

In ERC-4337, rotation can happen inside validateUserOp because validation may write state. A VERIFY frame cannot: it is static. If the account merely checked the FORS signature and called APPROVE, it would authorise execution without forcing rotation, breaking the ephemeral-key invariant.

So the frame account split the two and made rotation a validation requirement:

Frame 0: VERIFY  -> check FORS signature, then require Frame 1 to be the rotation
Frame 1: SENDER  -> call rotateOwner(nextOwner)
Frame 2+: SENDER -> user actions (run only after rotation)

The security invariant in one line:

No frame transaction is approved unless signer rotation is already scheduled as the very next frame.

How FrameAccount enforced it

The VERIFY frame arrived as a fallback whose calldata was the FORS signature blob:

fallback() external {
    _validateFrameSignature(msg.data);   // recover signer, require next frame rotates
    _approveExecutionAndPayment();
}
 
function rotateOwner(address nextOwner) external {
    _requireSelf();                      // only callable by the account itself, via the SENDER frame
    _rotateOwner(nextOwner);
}

_validateFrameSignature recovered the signer over _txSigHash() and checked it against owner, then required the next frame to be exactly a self-call to rotateOwner:

mode      == SENDER
target    == address(this)
value     == 0
atomic    == false                       (not an atomic-batch frame)
dataLength == 36                         (4-byte selector + 32-byte address)
selector  == rotateOwner(address)
nextOwner != address(0)

Each failed check reverted with a specific error (FrameAccountMissingRotationFrame, FrameAccountRotationFrameWrongMode, …WrongTarget, …NonZeroValue, …Atomic, …WrongDataLength, …WrongSelector, …ZeroOwner). Only when all passed did the account approve execution and payment.

This preserved the key property of the ERC-4337 design: even if a later user frame fails, rotation has already happened. The exposed FORS key is never left as the active key for a future transaction. The account did not trust client discipline; it refused to approve a transaction that did not schedule rotation next.

Why it stayed abstract

FrameAccount was an abstract contract. The frame-introspection hooks (_txSigHash, _frameCount, _currentFrameIndex, _frameTarget, _frameMode, _frameValue, _frameAtomicBatch, _frameDataLength, _frameDataLoad, _approveExecutionAndPayment) were left unimplemented. They correspond to the draft EIP-8141 opcodes (TXPARAM, FRAMEPARAM, FRAMEDATALOAD, APPROVE), which current solc inline assembly does not expose. The account logic was complete and tested against mocked hooks; only the opcode bridge was missing, and a production deployment would have needed a frame-aware runtime or proxy layer.

The frame account was also not a generic paymaster: its validation path was bound to rotating its own owner, so it could self-pay when it was the sender but could not sponsor other users' frame transactions.

Background: the repo's docs/nicetry-frame-transactions-article.md and the ethresear.ch writeup.