EIP-7702: Set EOA account code for one transaction

The idea of this EIP is to let existing EOA accounts run “as if” they have code.
Mapping to different addresses doesn’t let you work with existing accounts, which defeats the purpose

1 Like

Yes, the signature should include the nonce - unless another revocation mechanism is used. If a user did happen to sign a rogue contract-code, there MUST be a way to disable it, even if the rogue contract doesn’t support such revocation.

Instead of providing the code in each transaction, we could instead provide an address, and CODECOPY the code from there.
Its true that small static proxies are only 44 bytes - but what if we want to use another proxy? adding yet another small static proxy just to reduce the TX size seems a bad solution.

Sounds a bit weird to run an initcode on each transaction…
Also, as per my previous comment, if we copy the code from another contract - we can’t provide an initcode, since it isn’t on-chain.

nonce is a bit restrictive but we do need a revocation method in the protocol. Either the maxNonce+bumpNonce(uint32) I proposed, or nonceManager that @derek proposed.

Both would serve that purpose. The trade off is that the former is less granular (revoke all the past sigs) but doesn’t increase the cost for normal transactions, whereas the latter is granular but adds gas cost to every transaction (accessing another cold account).

Another implication of nonceManager is cross-dependency between addresses. A single nonceManager could invalidate sigs from multiple accounts, so the mempool won’t be able to safely accommodate many transactions that share nonceManager even if the EOAs are different. This might introduce a censorship vector: if a user is known to use a certain nonceManager, an attacker can send a lot of transactions from other EOAs specifying the same nonceManager with low priority fee so they stick around in the mempool for a while. The user won’t be able to propagate a transaction with that nonceManager.

Not strictly necessary, unlike in 3074. With invokers we needed it because a different contract could be deployed to the same address on other chains. With 7702 the code itself can (and should) check chainid before doing anything else. It enables cross-chain transactions when the code supports them explicitly.

This is already the case in 4337 accounts. The protocol makes chainid enforced by default since EntryPoint calculates userOpHash with chainid, but there’s a way to explicitly “opt out”: The account’s validateUserOp receives the userOpHash and by default it checks a signature on it, but it doesn’t have to. I’ve seen a case where a cross-chain account ignores userOpHash for certain UserOp.calldata[0:4] (a chain-aware function), calculates a different hash that excludes chainid and validates that. The function itself receives a list of chainids and enforces them.

We could, but there’s a trade off here, and I lean on not making it an address. It would bring back the issue of potentially different implementations on different chains, e.g. a malicious version deployed at the same address on some future chain. If we make it an address then we’ll need to include chainid. I’d hate to do that after @SamWilsn and I finally agree on something :rofl:

I think so, but not for the commonly discussed reason. I’m less concerned about storage collision when the account is still EOA - the wallet should be aware of code it delegates to, just like a diamond proxy. The bigger reason is potential rugpulls when combined with (6) (setting permanent code). E.g. create a legit-looking Safe after injecting some shadow signers to the storage via 7702 transactions. Trusting an account that was deployed via (6) will require going through all the past transactions if they set any storage. If any was set, it may be impossible to know what it means for the new implementation, e.g. maybe it’s some keccak that maps to owners[attacker].

A safer way to handle storage is by calling a storage contract associated with the current implementation. The storage contract maintains per-EOA storage via a mapping, so it can be accessed during 4337 validation. If the account uses different implementations for different transactions, they use different storage contracts and therefore don’t interfere with each other. And if the code is set permanently at some point, the newly-set implementation already knows how to access the previously set slots it wrote to.

Then the code should just use TSTORE. Otherwise it may have unintended consequences. Consider an account that temporarily uses Safe (yes, Safe is my favorite example - shoutout @rmeissner :wink:). If it uses the current Safe implementation rather than one with an external storage contract like the one I described above, then it’ll increment its own nonce in storage but the nonce gets reset at the end of the transaction - resulting in a replay. If SSTORE behaves like TSTORE, the contract should know it. The protocol should keep Safe safe.

Then if you ever sign one that doesn’t implement revocation properly, you’re stuck with it forever. I think the two options above (maxNonce and nonceManager) solve this problem without blocking any use case. The contract can handle its own replay protection but the user still has revocation ability.

This time we agree on it :handshake:

Risky. See unintended consequences above. It storage is ephemeral, the contract should be aware.

and then we discussed the trade off: not being able to support callbacks because solidity contracts won’t call an address with zero codesize.

Safer but probably not what the authors intended, since the transacting address is no longer the EOA that holds the assets. E.g. to batch approve+transferFrom you’d have to first transfer your assets to this shadow address in a separate transaction, breaking the probably-most-popular use case for this EIP.

I’m not sure I understand the attack. The user controls the contract that will be in the account during the UserOp. This includes both validateUserOp and receive() (which indeed every implementation should have). How can the attacker run a UserOp that has the proper validateUserOp but not the receive()? Can you elaborate on the attack flow?

The same is true for ERC-4337 accounts. Any reasonable implementation should have it, unless it only calls contracts that are known not to callback.

But you’re highlighting an important topic: we’ll need a good best-practices document for implementations. Which callbacks to support, how to handle storage, etc. Might even make sense to include it as a section in the EIP, but @SamWilsn and @matt are the experts on what belongs in the EIP. Do these best practices belong in the EIP?

Just correcting a common misconception here: There are no “4337 entrypoints” (plural). EntryPoint is the implementation of the 4337 protocol itself, and not an entity in the 4337 ecosystem. It is just implemented as a singleton contract because it’s an ERC rather than EIP. And RIP-7560 has no EntryPoint contract because the logic is moved to the protocol.

If the wallet itself signs the code, it can also verify ABIs. And if we make it an address rather than code, it brings back the chainid issue above.

The key must become invalid. The account becomes a smart account. Hopefully an ERC-4337 smart account that you can send UserOps to via the AA mempool, but that’s up to the implementation.

For situations like UniswapV3 callbacks where the msg.sender is called, this solution is strictly superior to 3074, where they wouldn’t have code to handle the callback.

3 Likes

it’s more of an recipient issue than a sender issue,
“if recipient signed a code that does not have receive function, sender might face DoS if they try to send assets.”

1.“eoa A” sign this code, use it one time, valid v,r,s becomes propagated with 7702 tx type

contract Hello {
    function foo() external {
        emit Bar();
    }
}
  1. 4337 wallet B wants to send eth to “eoa A” through this code and creates userOp
function hello(address _to) external{
    _to.transfer(3);
}
  1. bundler picks up the userOp and make bundle
  2. attacker X snatches the bundle and send bundle tx with (v,r,s) obtained on step 1
  3. _to.transfer(3) fails because Hello function does not have receive() function, B pays for the userOp so X does not have any risk. A does not risk any asset, but they do have risk of not receiving eth from 4337 accounts.

this applies same to erc721/erc1155

1.“eoa A” sign this code, use it one time, valid v,r,s becomes propagated with 7702 tx type

contract Hello {
    function foo() external {
        emit Bar();
    }
}
  1. 4337 wallet B wants to send erc721/erc1155 through this code and creates userOp
function sendNFT(address _to) external{
    _to.safeTransferFrom(msg.sender, _to, 3);
}
  1. bundler picks up the userOp and make bundle
  2. attacker X snatches the bundle and send bundle tx with (v,r,s) obtained on step 1
  3. sendNFT fails because safeTransferFrom calls onERC721Received when the code size is larger than 0, but onERC721Received does not exist on contract Hello

The issue here is that contract code that has been signed once can be used indefinitely to DoS the receive of eth/erc721/erc1155 when sender account is 4337 account

1 Like

I understand what you mean. This could happen. But the issue is that EOA A signed something it shouldn’t have - a replayable that sets a bad implementation (with no receive()). If it took care to only sign implementations that follow the best practices (which we still need to write), it should be safe.

This EIP has other interesting implications. It makes counterfactual contracts cheap to use: they can be used on-chain without ever deploying them. Create the contract code, then generate a “nick’s method” signature for it to create the “EOA” address. Reuse on-chain whenever the contract is needed, never actually deploy it.

Make America Plasma great again!

4 Likes

You’ve convinced me.

It’s a difficult question. Best practices should evolve over time, but EIPs all eventually have to become immutable. I’d put a list of problems these contracts need to be aware of in Security Considerations, but it’ll never be exhaustive.

Once the initcode finishes executing, the EOA will have deployed code, and will be able to receive callbacks.

How is this better than the signed bytecode calling into the nonceManager early in its execution?

Self-destruct in one of these EOAs should follow the rules in EIP-6780 (basically just send all ether without executing any bytecode.) I don’t see any reason to prohibit it.

1 Like

Pull request to prohibit SSTORE: Add EIP: Set EOA account code for one transaction by SamWilsn · Pull Request #8528 · ethereum/EIPs · GitHub

Following a discussion with @SamWilsn , no, that would break integration with 4337 or any other system that separates validation/execution. The 7702 data becomes a part of the propagated UserOp and affects validation. If bundles execute this initcode before calling handleOps, and initcode reverts, then the UserOp may become invalid. E.g. require(block.timestamp<something) in initcode would enable mass invalidation and DoS the AA mempool.

If we add initcode, it would have to be pure. It would need to be simulated, ERC-7562 rules would have to be applied, it wouldn’t be able to call other contracts, use opcodes like GAS, etc. It becomes a part of the validation code, that happens to run outside the 4337 validation function so we can’t apply gas limits, etc. Making this mempool-compatible would be tricky and probably not worth the effort.

It’s better because the bytecode might not call it. Revocation becomes an implementation feature rather than a protocol feature. If you ever sign an unrevocable implementation (back when you were using a bad wallet before you switched to your current one), it has access to your account forever.

And your nonceManager might not work. If you can verify a nonceManager, you can verify your contract_code.

I agree that’s a risk. And I pointed out another one above. maxNonce seems safer and cheaper (albeit less granular). I don’t think we need that granularity - just set it to 1000000 and most likely you’ll never have to think about it again unless a bug is discovered in something you signed.

1 Like

How is this better than the signed bytecode calling into the nonceManager early in its execution?

To be clear, my preference is to leave nonce out of signature altogether. I’m simply bringing up nonceManager as a compromise if people really want nonce inside the signature. In that case, I think nonceManager is the best proposal given the reasons I listed above.

1 Like

What if we signed over the code_hash in addition to the contract address? This could be a nice compromise between the entire code and just the address, assuming the hash isn’t too difficult to check.

How should native token(ETH) fallback and receive functionality work in EIP-7702 contract_code? Should it be allowed to break the flow of transaction as eventually it’s an EOA?

rlp([chain_id, nonce, max_priority_fee_per_gas, max_fee_per_gas, gas_limit, destination, data, access_list, [[contract_code, y_parity, r, s], ...], signature_y_parity, signature_r, signature_s])

I’m curious why we do not include a specific amount field in this proposal, considering it permits interactions with any destination accounts but doesn’t directly allow transferring native tokens when initiating the transaction. Could the intended transfer functionality still be achieved through self-invoking
and including CALL opcodes in the bytecode? If so, it seems the destination field might be unnecessary.

My suggestion for code is to specify contract address to copy from, but sign the code.
This way we save repeatedly copying the code in each transaction, while still preventing cross-chain risks.
The benefits:

  • smaller per-transaction calldata
  • clearer message that it is a shared implementation, not user-specific
  • easier for external tools (e.g. look up same implementations, view published/verified source code)
4 Likes

Some unintended consequence of eip-7702:

Consider I have an EOA and eip-7702 to call a service that uses an eip-1363 token, and my account implements onTransferReceived() logic.

If I trigger a transaction that makes the service call this callback, it gets executed.
But if some other tokens are sent in another transaction, the callback doesn’t get executed.
Moreover, the caller of the transation may decide if he wants my callback to get executed, by including my contract_code and signature in his transaction, or not.

The moral is that contract_code developers should be wary of the fact that they can’t control if and when such callbacks are called.

4 Likes