Skip to content

Frame Transactions

Native rotation with EIP-8141

src/FrameAccount.sol is a variant of the NiceTry account that expresses the same ephemeral-key rule natively, using EIP-8141 frame transactions instead of ERC-4337. It authenticates with the same ForsVerifier and enforces the same invariant (every authorized transaction must rotate the signer) but does 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 authorize execution without forcing rotation, breaking the ephemeral-key invariant.

So the frame account splits the two and makes 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 enforces it

The VERIFY frame arrives as a fallback whose calldata is 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 recovers the signer over _txSigHash() and checks it against owner, then requires 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 reverts with a specific error (FrameAccountMissingRotationFrame, FrameAccountRotationFrameWrongMode, …WrongTarget, …NonZeroValue, …Atomic, …WrongDataLength, …WrongSelector, …ZeroOwner). Only when all pass does the account approve execution and payment.

This preserves 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 isn't trusting client discipline; it refuses to approve a transaction that doesn't schedule rotation next.

Status: deliberately abstract

FrameAccount is an abstract contract. The frame-introspection hooks are left unimplemented:

function _txSigHash()                 internal view virtual returns (bytes32);
function _frameCount()                internal view virtual returns (uint256);
function _currentFrameIndex()         internal view virtual returns (uint256);
function _frameTarget(uint256)        internal view virtual returns (address);
function _frameMode(uint256)          internal view virtual returns (uint256);
function _frameValue(uint256)         internal view virtual returns (uint256);
function _frameAtomicBatch(uint256)   internal view virtual returns (bool);
function _frameDataLength(uint256)    internal view virtual returns (uint256);
function _frameDataLoad(uint256,uint256) internal view virtual returns (bytes32);
function _approveExecutionAndPayment() internal virtual;

These correspond to the draft EIP-8141 opcodes (TXPARAM, FRAMEPARAM, FRAMEDATALOAD, APPROVE; constants enumerated in src/frame/FrameTransactionLib.sol). Current solc inline assembly does not expose these draft opcodes, so a production deployment needs a frame-aware runtime/proxy layer to wire them up. The account logic is complete and tested against mocked hooks; only the opcode bridge is pending.

Background: docs/nicetry-frame-transactions-article.md, docs/frame-rotation-validation.md, and the ethresear.ch writeup.