EIP-3074: AUTH and AUTHCALL opcodes

I don’t find the example you created to have low astonishment actually, since sometimes explicitness conflicts with composability.

it might easier to see if the language exposes authorization using syntactic scope:

def approve_erc20():
    extcall erc20.approve(...)

def sudo(...):
    with authorized(msg.sender, sig):
        approve_erc20()  # this looks like it should call erc20.approve(...) with authorized=msg.sender!

In this example, at least to me, the call inside sudo() to approve_erc20() reads like it should be authorized to msg.sender.

Yes, for sure. When you’re looking at sudo, it makes sense.

What is surprising is that approve_erc20 can ever approve for an account other than the contract itself. You cannot look at just approve_erc20 and know that. You have to read the entirety of the contract.


I don’t know what exactly to call what I’m objecting to. Maybe “non-locality”?

Consider the following “complete” contract:

contract SamContract {
    ERC20 myToken;

    function withdraw() public {
        myToken.transfer(msg.sender, 100);
    }
}

Clearly this contract allows the caller to withdraw 100 units of myToken.

Now consider this contract:

contract EvilContract {
    ERC20 myToken;

    function withdraw() public {
        myToken.transfer(msg.sender, 100);
    }

    function sudo(...) public {
        assembly {
            auth(...);
        }
        withdraw();
    }
}

Without changing the code of withdraw at all, the meaning of the function has changed. Now it takes tokens from the authority.

This non-local effect is completely invisible. That’s dangerous.

Sorry, this is just a strawman, as most useful programs have so-called non-local effects.

For example, this function also does something different depending on how it’s called

contract EvilContract {
    ERC20 myToken;

    function withdraw() public {
        myToken.transfer(msg.sender, 100);
    }

    function sudo(...) public {
        myToken = someOtherToken
        withdraw()
    }

Just to deconstruct the strawman now, I can now find/replace “authority” with “someOtherToken” in your objection:

Without changing the code of withdraw at all, the meaning of the function has changed. Now it takes someOtherToken instead of myToken.

This non-local effect is completely invisible. That’s dangerous.

Of course, I don’t think this means that we should get rid of all non-local effects from programming :). If anything, we should choose abstractions which encapsulate effect management, and there are tools in the modern programmers toolkit for dealing with these, ex. RAII and context managers are relevant here.

You can clearly see that the behaviour of withdraw depends on the value of myToken without reading the rest of the contract. That isn’t true in my example.

As originally written, this proposal specified a precompile with storage to track nonces. Since a precompile with storage is unprecedented, a revision moved replay protection into the invoker contract, necessitating a certain level of user trust in the invoker. Expanding on this idea of trusted invokers, the other signed fields were eventually eliminated, one by one, until only invoker and commit remained. To appease concerns about cross-chain replay attacks and irrevocable signatures, the chainId and nonce fields returned to the signed message.

followed by (later in the doc)

For this reason, AUTH requires the nonce in the message to be equal to the signer’s current nonce. This way, a single tx from the EOA will cause the nonce to increase, invalidating all outstanding authorizations.

So is it correct to say that nonce does get checked by the AUTH opcode now? I’m finding it difficult to pin-point what the current state of nonces is.

While writing this, I found another confusing sentence

If an invoker has implemented replay protection, as per the authors’ recommendation…

So does the invoker do replay protection, or the AUTH opcode?

The invoker has to implement it, AUTH doesn’t invalidate the nonce.
(Invokers can also not invalidate the nonce, so it can intentionally reuse your signature to do multiple AUTHCALLs base on other logic)

1 Like

That’s what it seems like to me.

But how do we explain this part?

For this reason, AUTH requires the nonce in the message to be equal to the signer’s current nonce. This way, a single tx from the EOA will cause the nonce to increase, invalidating all outstanding authorizations.

Specifically “AUTH requires the nonce in the message to be equal to…” implies that AUTH does invalidate the nonce, contrary to what you said. I think I might be misunderstanding something.

1 Like

I second this strongly. I think removing chainId and nonce can unlock lots of opportunities for wallet UX, and these 2 factors are considered “easy” in contract implementations:

  • chainId: All EIP712 has chainId, devs and auditors are very aware of the cross chain signature reuse attacks. The OpenZeppelin ERC712 template even consider rarer scenarios like chainId changes after hardfork. This is a well-studied area where we won’t make simple mistakes unless it’s intentional.

  • nonce: I understand the rationale is to make sure users can “always revoke delegation”. But similar to chainId, the chance of a team “forget” the function to invalidate nonce is very very low. Famous contracts like Uniswap’s Permit2 also have very efficient and flexible nonce system that people started to adopt widely and use as a template.

I think adding these restrictions because “it could have bugs”, is the same as arguing “let’s ban smart wallets cause it COULD also allow replay attack, or forget the signature validation so anyone can steal your money”.
So, these restrictions are more for restricting “what’s the worst case a malicious invoker can do”, but IMO the protection doesn’t save users a lot from the absolute worst case, and not worth sacrificing the best UX we can achieve with this EIP.

3 Likes

@CalabashSquash See the author’s comment here, I had the similar confusion!

AUTH does check the nonce, but it doesn’t invalidate the nonce: the purpose of having the nonce check is to make sure users can always send a “normal tx” and invalidate all outstanding EIP3074 signatures.

Right, I think I get it now, thanks!

So this means there is an EOA nonce check, but also potential for the SC to handle its own, application-specific, nonce management on top of this?

yes, how i understand it is that nonce mentioned here is not used for replay protection as we usually think of when we see the word “nonce”, it is more like a circuit breaker to make sure EOA owners can always revoke, so the absolute worst case of permanently losing EOA control is mitigated.

It’s up for the invoker to implement replay protection, allowing more flexibility for a signature more than once with invokers’ logic.

I have a stupid question:
For AUTH, why is the commit in the memory and not taken from the stack?

I envision a typical use case like:

  • The signature is passed in as an argument and can be easily copied to memory with CALLDATACOPY (e.g. that is what happens when using a bytes memory in solidity)
  • The commit is computed based on the desired parameters (application nonce, gas, application calldata …)
  • Now the commit is on the stack and the signature is in memory
  • Now in many cases an unnecessary memory copying and packing needs to take place. In solidity developers will presumably do something like abi.encodePacked(signature, commit) so that they can have them consecutively in memory
  • If instead the commit was simply taken from the stack, it would be a lot easier

Sorry, if I missed this argument previously.

It’s fairly easy to construct an example where the behavior isn’t obviously dependent on the literal text of the withdraw() function. For example, calling from a nonreentrant-protected function vs not-protected, or modifying the value of myToken using pointer aliasing (e.g. sstore in assembly).

You’re absolutely right, and this is a constant source of bugs.

And you want to introduce more edge cases like this?

It might be more productive, @charles-cooper, to come up with something that’s mutually acceptable?

I don’t know if vyper has decorators, but annotating the function in some way to show that it can be called from within an auth might make me happy.

This snippet should compile fine:

@impersonates
def approve_erc20():
    extcall erc20.approve(...)

def sudo(...):
    with authorized(msg.sender, sig):
        approve_erc20()

While functions without the @impersonates decorator would generate an error if used inside an authorized block or function annotated with @impersonates.

I mean these are things I have of course thought about and are indeed potential ways to expose AUTH to the user in Vyper. I guess the point I’m driving towards is that context managers and correct abstraction of scope can be addressed in the language or library design. The lack of “authorized” staticcall on the other hand, cannot.

As a workaround to depending on the authorising EOAs nonce, what if during AUTH we instead allow users to specific another “management” key owned by the same user and depend on the nonce of this management key for AUTH validation?

Let’s say we modify the AUTH message to the following:
keccak256(MAGIC || chainId || managementEoa || managementEoaNonce || invokerAddress || commit)
And the assumption is that the management EOA is owned by the same user, and is not used for doing general transactions and only to invalidate AUTH if the user wishes to.

This allows the main authorising EOA to continue doing transactions without invalidating AUTH, and still allow users to invalidate it by doing a transaction with the management EOA.

1 Like

This is a very promising idea! I think the managementEoa can be a smart contract as well, since smart contracts have nonces too. So I would change it to:

keccak256(MAGIC || chainId || nonceManagerAddress || nonceManagerNonce || invokerAddress || commit)

In fact, I’d imagine that the nonceManagerAddress being a smart contract would be more useful, since then the EOA can just “own” the smart contract as opposed to having to guard a separate key.

The nonce manager can even be deployed lazily — its address can be computed deterministically from a well-known factory. That way, EOA users can use nonce managers without paying for the cost of deployment, and a lot of EOA users probably won’t ever deploy any nonce managers if they don’t ever have to resort to global revocation.

3 Likes

Agreed, owning a Smart Contract to manage this would be much easier.

1 Like

One significant tradeoff of having nonceManagerAddress be a smart contract is it makes the implementation of this opcode more complicated.

How about nonceManagerAddress being able to be either an EOA or a smart contract, in both cases it checks the nonce of the account like today’s implementation. And when it’s a smart contract, it’ll use the CREATE opcode in order to increase its nonce?

More thoughts:

  1. It can also be used by smart contracts as a “nonce oracle” - till now contracts did not have access to the nonce of accounts. Not sure what are the implications of this.
  2. It will change the gas pricing of the opcode because it will be accessing another address which might be cold.