EIP-7702: Set EOA account code for one transaction

Might have a small DoS vector that can make a <address>.transfer()(i know this is anti-pattern, but still some contracts use this) being reverted when recipient has signed contract code that does not have receive() function

This will be up to tx.origin to post the contract code or not, but if .transfer() is being done on the execution phase of 4337, then attacker can snatch the bundle and post the bundle with recipient’s contract code, thus making the validation valid but revert on execution phase, ending up sender paying the gas but it does not achieve the behavior that sender paid for,

Also, if we look into the erc721.safeTransferFrom(), if recipient has signed the contract code before and if safeTransferFrom() is done through 4337, execution of erc721.safeTransferFrom() might fail if the recipient’s code does not have onERC721Received

2 Likes

this sounds good but how would that work if the entryPoint wants to call account.validateUserOp()?

As a 3074 advocate, my first gut check is this is a really nice solution. I’d argue this is better for 5003 and 7377, which have irreversible side-effects and I would argue could lead us to another Parity wallet dilemma.

If this gets pushed through, then I’d strongly urge a standardization around which contracts are used (similar to 4337 entrypoints and 3074 invokers).

  1. Should the contract_code instead be an address? This would save calldata, though only a little bit of calldata because ERC-1167 proxies only take 44 bytes.

Personally from a safety perspective I think this actually makes more sense, since you can have very easily verifiable ABIs/Code to sanity check against your local wallet.


I want to point out one thing from line 27:

at the least because eventually quantum computers will break the ECDSA that EOAs use)

(happy to move this conversation elsewhere)
I don’t think this is an overly sound argument. If ECDSA breaks we’re doomed beyond belief, and realistically before we start using this as a counter-point we need to actually have a plan (or the research) showing how were moving towards quantum safe curves ASAP. I’d assume most smart accounts are using ECDSA as a majority set of the signers, so that doesn’t save us either.

2 Likes

If the code is not reset, would the private key controlling the account become ineffective (similar to 7377)? If so, how would one initiate transactions from that smart account? Always with the new transaction type?

I proposed something similar in 2020; the comments from then may provide some useful food for thought: "Rich transactions" via EVM bytecode execution from externally owned accounts

3 Likes

Regarding whether to sign over the account’s nonce:

  • As I argued in the 3074 call, signing over the account’s nonce would invalidate most of the popular use cases of AA that we are seeing in the 4337 ecosystem today, particularly alternative singers (e.g. attaching a passkey to your EOA) and transaction delegation (creating a session key for a DApp).

  • @yoavw had a proposal for signing over a user-picked maxNonce instead. I think this proposal is impractical from a UX perspective, since it’s not clear how the user is supposed to pick a sensible maxNonce. However, I like how it basically gives the user a way to opt out of in-protocol revocation. So the user could just set maxNonce to 2^64 if they don’t want to accidentally revoke the signature, and then they’d rely exclusively on the contract code for revocation.

  • I do think there’s another proposal worth considering: nonceManager. This was first described here and here. Basically, we let the user pick a nonceManager, and it’s the nonceManager’s nonce that we sign over.

Notably, the nonceManager can be a counterfactually deployed smart account owned by the user’s EOA, so that when the user does want to revoke, they’d send a transaction with the smart account. And they don’t actually have to pay gas to deploy the smart account unless they want to revoke, since the account could be counterfactually deployed.

Personally, I think it’s OK to just ignore nonce altogether in the sig, but if people are not comfortable with that (for the same reason that made people add nonce to 3074), then I think either the maxNonce proposal or the nonceManager proposal could work, though I prefer nonceManager since, unlike the maxNonce proposal:

  • It gives the user a way to selectively revoke signatures without accidentally revoking anything. With maxNonce, you always have to revoke everything within the range.
  • Unlike the maxNonce proposal, there’s no UX issue of picking a max nonce.
4 Likes

I support this EIP wholeheartedly. This is incredibly useful, as it looks like it essentially supports atomic bundling / composability of user-ops in protocol. This allows a lot of block-building policy protocols to really improve user-experience and support things like just-in-time data without changing the state trie in-between transactions.

Clarifying in the EIP when the TSTORE/TLOAD is shared during tx execution would be great. Setting it globally for the transaction, rather than per user-op, avoids the need to rely on account-code presence to persist data between user-ops. The 3074 comparison hints at it, but it is not entirely clear.

4 Likes

I am generally in favor of this approach to gradually introducing AA features to EOAs. I have one point I’d like to make, and a suggested implementation path.

I think it is important for this proposal to support both:

  1. Re-using the signature from an EOA across multiple transactions, using the contract code to enforce signature checks and uniqueness.
  2. Allow EOAs to revoke existing signatures over code.

Point 1 is critical for AA use cases around alternative transaction validity conditions, which includes other cryptographic schemes like passkeys, multisig capabilities, and permissions. Point 2 is critical to allow for “upgrading” account’s code implementation, without simultaneously allowing the old code to also be assumed. It is to prevent the “perpetual signature” risk that was identified with EIP-3074.

My proposal for how to accomplish this is to introduce a new, parallel nonce field for accounts, distinct from the existing transaction nonce. Name TBD, calling it “code assumption nonce” for now.

EIP-7702 transactions could require the EOA signature to be over the “code assumption nonce”, rather than their transaction nonce, to start an EOA assuming code. Of course, each transaction using this format would still increment the respective account nonces, ensuring unique transaction hashes.

Multiple outstanding signatures could be placed over the same “code delegation nonce”, allowing different pieces of code to be assumed in different transactions.

Accounts could opt to invalidate outstanding “code delegation nonces” by signing a 7702 signature over a magic value, such as empty code or code only containing 0xFE (invalid opcode), indicating they intend to increment the nonce and invalidate all outstanding 7702-enabling signatures.

I see there are two other proposals for how to accomplish this (or something similar), and I want to address why I don’t think they are the best choice forward.

  • One idea is to sign over a max nonce. I believe this provides a clumsy user experience for any form of AA-based transaction automation, forcing the user to guess how many times contract automation would happen, and having to either sign very far in advance or regularly re-sign to bump the nonce. Additionally, it would require more control over how to bump nonces to invalidate signatures, likely requiring the protocol to allow bumping the nonce by more than one at a time. This introduces unneeded complexity into nonce management that the EVM must bear, rather than contracts themselves.
  • Another idea is to fully delegate nonce control to an external contract (the “nonce manager”), and introduce either code or an address to the contract into the 7702 transaction type as a field. I believe this to be too far in the other direction, enshrining too much application-specific logic into the transaction type than what is needed, thus making it unnecessarily complex. A “code assumption nonce” still allows for implementation-dependent nonce control, such as to track nonces for session keys or secondary signers to the account, within the code the EOA is assuming.

By keeping 7702 code assumption tracked in a separate nonce from individual transaction nonces, we can provide both customizable nonce behavior, and the ability for EOAs to manually exit and/or upgrade their code assumption behavior.

EDIT: I previously misunderstood how the max nonce field would work. If the EOA nonce is not incremented when used to assume code in a transaction while not being tx.origin, then using max nonce fields for the purposes of allowing alternate txn validity conditions is sufficient and doesn’t have the UX pitfalls I previously imagined. I’m keeping the proposed idea up here for posterity.

3 Likes

Instead of setting a constant for the code size costs, it should be equal to the calldata cost plus the eip-3860 gas adjustment used for create txs, to account for the jumpdest validation.

I think another valid question is if selfdestruct opcode should be prohibited. If not, would it mean a gas refund or incur more gas? Also that mean that the signers eth balance is drained to the selfdestruct argument?

  1. Taking the bytecode would be better imo as it doesn’t require the contract (if an address is required) to already be deployed in a previous tx. In most cases, a proxy to an existing 4337 code would be used but flexibility would be good too and not needing the code to be predeploted to an address beforehand is helpful

  2. No, storage opcodes should not be prohibited. It (all written storage slots) should however be cleared at the end of the transaction and its gas costs be the same as TLOAD and TSTORE since they’d then behave similarly.

  3. Yes. Init code rather than runtime code would be helpful for more specific and custom instances

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