Frame Transactions
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:
| Mode | Meaning |
|---|---|
VERIFY | validate the transaction (static: no state changes; may APPROVE execution) |
SENDER | execute as the transaction sender, after approval |
DEFAULT | execute 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.