Improving adoption with Ethereum-wallet-based Aztec wallets with clear signing

Aztec wallets demand domain-specific functionality from its wallet software. Namely, handling the protocol-level keys (including discovery, encryption, and decryption of logs), as well as the specific mechanisms defined for each class of account. Hence the need for Aztec-specific wallets, that take into account Aztec’s specific security and privacy requirements into account.
There currently exist a few Aztec-specific wallets, such as the Azguard wallet.

However, it’s useful to allow newcomers to try Aztec without the need to download specialized software. Namely, using wallets that work for widespread blockchains such as Ethereum. This also offloads secret handling, which allows for the use of hardware wallets, with the caveat that it only covers authorization secrets.

This post outlines the approach that we’re taking in the development of such an account contract for Ethereum wallets like Metamask or Rabby. By leveraging EIP-712, we can produce structured authorization requests that are compatible with such wallets while showing clear signing of what’s actually being authorized.

Work on a fully working version is still ongoing. We’ve currently benchmarked the entrypoint function for the most simple version of this, obtaining a gatecount on the order of a million.

It is assumed that the reader is familiar with how account contracts work on Aztec.

Approach overview

Contracts are defined by specifying

  • Contract code
  • If initialized, initialization data: constructor and its arguments
  • Nullifying and viewing keys

The contract code is going to contain the EIP-712 authorization mechanism for the connected Ethereum wallet’s ECDSA signing key, which is gonna be the only constructor argument.

Secret keys derivation

As per the nullifying and viewing keys, we want to define them to be:

  • Deterministically derivable from an Ethereum wallet, so that even if the user’s computer gets crushed by a raging meteorite, recovering the Ethereum wallet allows the recovery of the corresponding Aztec wallet.
  • Not derivable from publicly available data, such as the Ethereum address. Note that even the complete ECDSA public key can be ecrecover’d from any (payload, signature) pair, such as any Ethereum TX.

To this end, we use a personal_sign signature of a message that should explicitly describe its effects, the most important of which is allowing the holder of the signature to inspect all of the wallets state. Something like

Sign to allow this website to inspect your Aztec EVM-based wallet's state.

This signature can only be forged by someone holding the secret key, and thus works as a valid secret for the wallet. It is worth noting that any website can request such signature, and afterwards inspect the account contract’s state.

As far as we’re aware of, there aren’t currently any mitigations for this that don’t require website-scoped account contracts, which in turn make DApp interoperability awful.

Authorization

Standard account contract authorization is divided into two:

Both of those expose a single Field that acts as a commitment to what we’re gonna authorize.

For the entrypoint, this is a commitment to each function call, composed of:

  • Function selector
  • Arguments hash
  • Target_address
  • Whether the function call is public
  • Whether to hide the sender
  • Whether the call should be static

Contract developers leverage the assert_current_call_valid_authwit helper function and its public counterpart for authorization, which also compute a commitment to said fields.

These commitments are usually domain-separated to a specific chain ID and version as well. We’ll refer to said commitments as “Aztec message hashes”.

We can use these fields as commitment to the following human-readable data:

  • Function signature
  • Arguments list
  • The remaining members

For instance, a call to the standard token contract’s transfer_private_to_private function would expose the following values:

  • Function signature: transfer_private_to_private((Field),(Field),u256,Field)
  • Arguments: [0x123123..., 0x321321..., 100, 5233126]
  • Contract address: 0x0303456...
  • Is public: false
  • Hide message sender: false (this must be false for private calls)
  • Static call: false

To this end, we define EIP-712 structures that represent function calls (and batched function calls).

In the account contract, authorization is done through the following steps:

  1. Fetch the human-readable data and the EIP-712 signature via capsules, as well as any other data that may be required for the payload reconstruction steps below.
  2. Reconstruct the EIP-712 payload.
  3. Validate the received EIP-712 signature against the reconstructed payload and the stored ECDSA public key.
  4. Reconstruct the Aztec message hash.
  5. Ensure the reconstructed message hash equals the one the authorization function is meant to validate.

This way, we constrain the received payload to include a valid EIP-712 signature of a clear message for the action that the Aztec account contract is made to check.

Specifics

Throughout this section, we assume the reader knows the EIP-712 specification to a good degree. Feel free however to read on at your own peril.

We’ll start by specifying an implementation where the EIP-712 structs for entrypoint and function call authorizations are known at compile time. Throughout all of this analysis we will, however, restrict ourselves to functions whose arguments each fit in 32 bytes.

EIP-712 defines the payload as a commitment to an instance of a typed struct, as well as a domain separator. The domain separator separator is itself the following EIP-712 struct:

struct EIP712Domain {
    string name;
    string version;
    uint256 chainId;
    address verifyingContract;
    bytes32 salt;
}

Which can optionally be stripped of some fields.

The EIP-712 domain

This domain refers to the one the wallet is doing a signature on, and is EVM-specific (as evidenced by address verifyingContract, and EIP-712’s remark that “The user-agent should refuse signing if it does not match the currently active chain.”). We propose using this as a domain separator for the Aztec rollup contract that the message is meant to. Something like

EIP712Domain {
    name: "Aztec",
    version: "devnet.20251212-ultimate.patch.1-v3",
    chainId: 1,
    verifyingContract: 0x5772156...,
}

Here, chainId refers to the L1 where Aztec is being sequenced. But the remaining fields are bound to the specific Aztec version that the message is purported to, so this domain-separator already acts as a domain-separator for different Aztec versions.

We’re not gonna implement a similar domain to refer to the applications that are being called, as they will not have name and version in their code. Instead, contract address will have to suffice.

Representing function calls

struct FunctionCallAuthorization {
    bytes32 contract;
    string functionSignature;
    uint256[] arguments;
    bool isPublic;
}

:white_exclamation_mark:We’re limiting the arguments types to uint256 here for simplicity

Representing entrypoint calls

struct EntrypointAuthorization {
    AccountData accountData;
    FunctionCall[ACCOUNT_MAX_CALLS] functionCalls;
    TxMetadata txMetadata;
}

struct AccountData {
    bytes32 address;
    string walletName;
    string version;
}

struct FunctionCall {
    bytes32 contract;
    string functionSignature;
    uint256[] arguments;
    bool isPublic;
    bool hideMessageSender;
    bool isStatic;
}

struct TxMetadata {
    uint8 feePaymentMethod;
    bool cancellable;
    uint256 txNonce;
}

The constant ACCOUNT_MAX_CALLS is already defined for standard accounts and currently takes the value of 5.

Fetching the structs’ contents

The EIP-712 domain is a deploy-time constant, and thus needs no fetching. Instead, we’ll compute what we need from it and define those values at either deploy-time or compile-time. The same applies for AccountData.{walletName, version}.

Everything else will be fetched from capsules as arrays of u8. For variable-size members such as FunctionCall.{arguments, functionSignature}, we’ll fetch a compile-time bound array (e.g. [u8; MAX_ARGS * 4]), and an extra u8 value with the run-time known size.

EIP-712 payload reconstruction

First, let’s go through a quick recap of how EIP-712 works.

Every struct instance is committed to via hashStruct defined as follows

hashStruct(s) = keccak256(typeHash(s) || encodeData(s))
typeHash(s) = keccak256(encodeType(typeOf(s)))

encodeType is a concise description of a struct’s shape. For example,

struct Mail {
    address from;
    address to;
    string contents;
};

encodeType(Mail) == "Mail(address from,address to,string contents)"`.

Meanwhile, encodeData(s) is defined as the keccak256 of the concatenation of the following encoding of the members of s:

  • for a struct: its hashStruct
  • for an atomic type (e.g. uint32, bytes8, etc): a non-surprising 32-bytes representation of it
  • for bytes and string: the keccak256 of its contents
  • for type[]: the keccak256 of the concatenation of the encodings for each of the array’s items

Finally, the EIP-712 payload is "\\x19\\x01 || domainSeparator || hashStruct(message), where domainSeparator is the hashStruct of an instance of the aforementioned EIP712Domain struct.

Now let’s go back to our specific use case.

The domainSeparator is gonna be a deploy-time constant that depends on the specific version of the rollup. Each struct’s typeHash is a compile-time constant that will be hardcoded.

For arguments whose length is known at run-time, we leverage keccak256’s hash function’s message_size argument to produce the required subarray.

Aztec message hash reconstruction

:white_exclamation_mark:We implicitly assume the u8 arrays are converted to their corresponding Aztec types for this section.

Entrypoint

In the case of the entrypoint function, rather than reconstructing the message hash that’s usually fed to the authorization function, we can instead check many of the values directly. This means unlike the standard entrypoint function, our entrypoint function won’t perform any call to is_valid_impl.

As a remainder, entrypoint is defined as:

pub fn entrypoint(self, app_payload: AppPayload, fee_payment_method: u8, cancellable: bool) { ... }

#[derive(Serialize)]
pub struct AppPayload {
    function_calls: [FunctionCall; ACCOUNT_MAX_CALLS],
    pub tx_nonce: Field,
}

#[derive(Serialize)]
pub struct FunctionCall {
    pub args_hash: Field,
    pub function_selector: FunctionSelector,
    pub target_address: AztecAddress,
    pub is_public: bool,
    pub hide_msg_sender: bool,
    pub is_static: bool,
}

Thus, the following checks must be performed from the fetched values:

  • AccountData’s address must match context.msg_sender().
  • TxMetadata’s members must match the entrypoint’s fee_payment_method and cancellable arguments, as well as AppPayload’s tx_nonce.
  • For each FunctionCall instance, we must:
    • Reconstruct args_hash from the arguments and their size
    • Reconstruct function_selector from the function signature
    • Check that every remaining component from the entrypoint’s AppPayload matches what’s fed from the oracle

The reconstruction of args_hash and function_selector should be done the same way that Aztec does it:

  • The function_selector is computed here as the poseidon2 hash of the signature (expressed as bytes).
  • The args_hash is computed differently according to is_public:
    • If true, this is actually the calldata’s poseidon2, where calldata is an array with the function selector followed by the arguments. And the hash is domain-separated to public function calls
    • If false, this is the poseidon2 hash of the arguments’ hash, domain-separated to private function calls

As with EIP-712, computing function selectors and arguments hashes requires doing a hash for an array of run-time length. As with the keccak256 crate, Noir’s poseidon crate’s hash function allows the slicing of its input. However, unlike with EIP-712, the way those commitments are computed may change in further versions of Aztec.

There are two ways to deal with this:

  • Use Aztec’s functions as black boxes, to avoid code repetition. This would be the tidiest option. However, these functions don’t currently support run-time known length for the hashes they perform.
  • Copy-paste those functions and add the message_size parameter that the hash functions take. This is maintenance-heavier, as the development team should remember to update those functions if the ones they’re copying ever change.

Specific actions

In the case of specific actions, which end up calling verify_private_authwit, our account contract receives an inner_hash from some calling contract. This inner_hash has been computed by assert_current_call_valid_authwit as the poseidon2 hash of [msg_sender, function_selector, args_hash], domain-separated to inner message hashes.

Allowing argument types other than uint256

We want to loosen the type of the arguments member of both the FunctionCall and the FunctionCallAuthorization structs. Let’s first approach the later, which involves just one function call.

We’ll replace the uint256[] type with an Arguments struct. We could potentially leave this later struct unconstrained, by taking as oracle input a string for the return value of encodeType(FuncionCallAuthorization), and only constraining the first part (which is gonna be the definition of FunctionCallAuthorization without the definition of Authorization.

This would have the advantage that a DApp that uses a token could e.g. define

struct Arguments {
    bytes32 from;
    bytes32 to;
    uint256 value;
    uint256 _nonce;
}

And it would work for this, but there are two caveats.

  1. It wouldn’t be performant to do it for the EntrypointAuthorization struct, because its dependencies must be sorted in alphabetical order, and so the Arguments1 (and so forth) structs would go in the middle of the string. We’d have to use string concatenation at run-time. And not doing so could be confusing for the user, because they’d see clear messages for other account’s authorizations but not for theirs.
  2. Most importantly, it lends itself to phishing attacks. By switching the position of from and to in the same example, a phishing attack could convince a user that they’re getting a reward from the sender, while in reality they’d be getting rugged.

Instead, we propose whitelisting a set of structs, where every member is sequentially named as argument1, argument2, and so forth, and their type could be one of uint256, int256, and bytes32. The contract would expect the typeHash corresponding to one of the whitelisted structs, along of a proof of inclusion to the whitelist.

This can be quite performant because the encoding of the contents of such a struct is precisely the same, given that the encoding of said struct would just be typeHash || keccak(arg1 || arg2 || ...).

In order to implement the whitelist, there should be a Merkle tree where the leaves represent the typeHash of every whitelisted possibility. It suffices to constrain the typeHash of the Arguments struct, since finding a second typeHash for the encoding of FunctionCallAuthorization that yields the same payload would be akin to finding a hash’s second preimage.

There are (3^{n+1}-1)/2 different possible such structs that allow for up to n arguments, so a binary tree of size ceil((n+1) log(3)/log(2)) - 1 would suffice to store the whitelist. For the case of up to 8 arguments, this is a depth of 13, which is reasonably small. Constructing the tree would be non-trivial for too big of an n.

In the case of EntrypointAuthorization, each function call should have its own Arguments struct, sequentially named as Arguments1, Arguments2, and so forth. Storing a whitelist for the whole EntrypointAuthorization would be impractical anyways, so we’d have to take one typeHash and its corresponding merkle proof for each of the function calls.

Appendix 1: alleviating privacy exposure

As mentioned in [#secret-keys-derivation], any website can request the signature that’s used for the viewing and nullifying secrets. A potential alleviation to this would be using WebAuthn, whose signatures are domain-scoped.

This would, however, require one account per website, which is arguably bad UX.

This siloing could in turn be mitigated by having a “central” account that uses the ECDSA signature for secret generation, and adding a “transfer” function that requires signing a sufficiently clear message.

But this wouldn’t be enough make DApp interoperability UX reasonable.

Appendix 2: Benchmarks

Development of the whole EIP-712 account contract is ongoing. We’ve benchmarked the entrypoint with this PoC, along with other account contract’s entrypoint. All of them consume the same amount of gas. Regarding the gates of the entrypoint function, the following results were found

Account contract Entrypoint gates
EIP-712 1 076 959
ECDSA 121 844
Schnorr 54 441

In the current PoC, there’s an 8x and a 19x increase in gatecount with respect to the conventional ECDSA and Schnorr accounts respectively, which is a lot.

3 Likes

great article!

on webauthn, as far as i know, it doesn’t give you the same signature over the same message deterministically, as nocne is randomly generated by webauthn internally. Workarounds might be new webauthn extensions but these are still not well-supported by major browsers.

refs :

This is a great writeup! We’ve been exploring EIP-712 based Aztec account governance as well at nyx.money, because we too believe bringing your own wallet is the best way to draw existing Ethereum users in to Aztec!

A couple of quite interesting points I noticed in this design:

  • Using personal_sign signatures to derive privacy keys, leveraging the deterministic nature of Ethereum signatures. This achieves the goal of consolidating all of Aztec account governance with the EVM wallet. However, I wonder if there’s any precedence in using Ethereum message signing as a key derivation scheme? The biggest mental leap for me is treating message signatures as permanent (not time-bound) secrets. You already mentioned the possibility of phishing attacks (all it takes is signing one message (not tx) from a malicious website to permanently leak your Aztec account’s privacy). I also wonder if most EVM wallet providers follow the same mental model of treating message signatures as secrets (we know tx signatures are not secrets) so that they don’t unintentionally leak them to their own backends or 3rd parties.
    In the nyx prototype, we chose the path of generating random account secrets on client-side, and encrypt them using WebAuthn credentials before syncing to the nyx backend. This has the obvious downside of fragmenting the Aztec account governance: your privacy is governed by WebAuthn credentials, while tx authorization is done by EVM wallet. It also relies on the nyx backend for encrypted storage and is domain-bound to the nyx app (which is not a problem for nyx, but limits the account’s portability).
  • Using capsules to pass FunctionCall members’ preimages needed for message reconstruction. This seems quite neat as it could potentially minimize the interface change. We went down the path of forking the entrypoint and authwit, which resulted in a lot of code forking in both Noir and typescript SDK. Curious if you know any downsides of using capsules in this case.

Another thing that puzzles us is, while we have observed the similar gate count jump with our account contract implementation, we don’t seem to observe significant jump in tx proving times on both devnet and local (with proving enabled). We’re looking to do more profiling in this area, but I’m curious if you have any insights. See @shiqicao 's post for more accurate observations: Improving adoption with Ethereum-wallet-based Aztec wallets with clear signing - #5 by shiqicao

yes, that’s how PrivacyPools currently derive their secrets, in the “action” field they’re declaring “Derive Seed”, tho i agree that it should have a bigger warning saying “You’re signing your keys derivation for X protocol” (that wasn’t developed by us btw) in plain text, but yes, the User should always be aware of what they’re signing and where.

I don’t know about “most wallets” nowadays, but we do have a signing derivation scheme standard that avoids leaking which address of your enclave is actually signing that derivation, so 3rd parties shouldn’t be able to even request you that signature to begin with. (writeup & Kohaku implementation A(L329,L829,850) & B)

About using both Passkey and Wallet, it does seem like a good tradeoff, but something to take into account is that because these “wallets” aren’t native, we’ll need to pass the viewing keys to the app in order to detect messages, as well as the nullifier keys to generate the proof when spending keys, so malicious apps always have this vector of attack. In contrast, Aztec specific wallets do the note discovery natively, without ever requiring to share the keys to begin with.

Interesting, perhaps the gate-count isn’t the actual proving time bottleneck, we’ll run benchmarks on our end, we’re polishing a GH workflow to track proving times across PRs.

We’ll take a look into it.

Thanks for your responses! I hope the information here results helpful!

1 Like

We(nyx.money) also observed very strong positive correlation between the number of gates and the execution duration. We measured 3 cases, run 10 times for each case.

Case 1, signing tx hash with domain info (141,958 gates)
Case 2, signing 2 function detials but one hash for all args (255,835 gates)
Case 3, signing 5 function details but one hash for all args (490,144 gates)

Gates Average Duration Standard Deviation Sample Size (n)
141,958 ~11,270 - 11,430 ~491 - 761 21
255,835 ~13,569 ~237 10
490,144 ~15,642 ~459 10