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:
- Authorization for the entrypoint
- Authorization for specific actions
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 befalsefor 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:
- 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.
- Reconstruct the EIP-712 payload.
- Validate the received EIP-712 signature against the reconstructed payload and the stored ECDSA public key.
- Reconstruct the Aztec message hash.
- 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;
}
We’re limiting the arguments types to
uint256here 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
bytesandstring: thekeccak256of its contents - for
type[]: thekeccak256of 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
We implicitly assume the
u8arrays 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’saddressmust matchcontext.msg_sender().TxMetadata’s members must match theentrypoint’sfee_payment_methodandcancellablearguments, as well asAppPayload’stx_nonce.- For each
FunctionCallinstance, we must:- Reconstruct
args_hashfrom the arguments and their size - Reconstruct
function_selectorfrom the function signature - Check that every remaining component from the
entrypoint’sAppPayloadmatches what’s fed from the oracle
- Reconstruct
The reconstruction of args_hash and function_selector should be done the same way that Aztec does it:
- The
function_selectoris computed here as theposeidon2hash of the signature (expressed as bytes). - The
args_hashis computed differently according tois_public:- If
true, this is actually thecalldata’sposeidon2, wherecalldatais an array with the function selector followed by the arguments. And the hash is domain-separated to public function calls - If
false, this is theposeidon2hash of the arguments’ hash, domain-separated to private function calls
- If
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_sizeparameter 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.
- It wouldn’t be performant to do it for the
EntrypointAuthorizationstruct, because its dependencies must be sorted in alphabetical order, and so theArguments1(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. - Most importantly, it lends itself to phishing attacks. By switching the position of
fromandtoin 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.