TLDR: With the introduction of auth witnesses, we now have an easy path to simulating simulations in a wallet, so we can check what authorisations are required by a tx with an initial simulation, validate those with the user, and then proceed with the actual simulation and proof generation.
About auth witnesses
Auth witnesses, originally devised by @LHerskind here, are a mechanism for account contracts to request authorisation for actions from the oracle. Account contracts now expose an is_valid
function, that receives a message hash that represents an action. Within is_valid
, account contracts are expected to request a corresponding auth witness from the oracle, which is typically a signature over the message hash, and validate it according to its own validation rules. For instance, an ECDSA contract does something like this:
fn is_valid(context: &mut PrivateContext, payload_hash: Field) -> pub bool {
// Load public key from storage
let storage = Storage::init(Context::private(context));
let public_key = storage.public_key.get_note();
// Get the auth witness from the oracle
let signature: [Field; 64] = get_auth_witness(payload_hash);
// Verify payload signature using Ethereum's signing scheme
let hashed_message: [u8; 32] = std::hash::sha256(payload_hash.to_be_bytes(32));
let verification = std::ecdsa_secp256k1::verify_signature(public_key, signature, hashed_message);
assert(verification == true);
}
This mechanism is used for authorising function calls done through the account contract (ie the EntrypointPayload
), but also for authorising any actions requested by another contract in a transaction. For instance, this is used in token contracts as a replacement for ERC20-like approvals. If a token contract needs to run a transfer_from
, it just asks the sender whether that transfer is_valid
or not.
Auth witnesses are only valid in private execution contexts. For public functions, we rely on setting in the account’s public storage that a message hash is authorised, so calls to is_valid_public
can just check storage.
Note that we could change public functions to also rely on auth witnesses if we make the sequencer aware of them, and broadcast them along with a tx, but this requires a protocol change.
All this means that a wallet needs to generate all private auth witnesses prior to locally executing a transaction, and needs to set all public approvals prior to calling the public functions that will rely on them.
So how does a wallet know what actions will need to be authorised?
Collecting authorisation requests
Given that all authorisations are now condensed in a single function in the account contract, a wallet can initially run an execution request in a modified version of the account contract where is_valid
just returns true and records all requests it received. This would be effectively simulating the execution simulation, and the wallet would end up with the list of all actions (entrypoint included) that need to be authorised by the user.
This means that the flow for sending a tx would now be:
- Dapp tells the wallet
call token.transfer(...)
on behalf of the user - Wallet packages the call in an
EntrypointPayload
- Wallet simulates the
entrypoint
function using the modified account that accepts all actions - Given the list of actions to be authorised, both private and public, the wallet asks the user to confirm
- After the user agrees, the wallet produces all auth witnesses and sets public authorisations (as part of the transaction), and runs the simulation with the actual account contract
- Wallet produces a proof out of the actual simulation and broadcasts the tx
Getting authorisation preimages
A complication with the scheme above is that the is_valid
function receives a hash of the action to authorise. And we cannot show the user a hash, they need to see the preimage to understand what they are signing.
I can think of a few options around this:
- We modify
is_valid
so that it receives the raw payload and not the hash. I’m not sure if this could be an issue when it comes to large payloads, especially in public-land. This may also not work if the hash is calculated in advance in a prior tx (eg as it happens in some multisig or timelock contracts, where the hash of the action is stored, but the actual action isn’t). - We instrument all calls to hash functions so that we store all hash operations, and can retrieve the preimages for a given value. This feels brittle, and it fails if a contract happens to tweak the hash (eg by XORing it with another value) in any way. It also fails in the case of pre-stored hashes.
- We require any calling contracts to execute an oracle call where they emit the preimage right before calling into
is_valid
. This oracle call would be fully unconstrained, and would be used just to provide a hint to the wallet on what info to display to the user. This would require making oracle calls from public functions, that are only executed in local simulations but discarded when run by a sequencer. If a hint is not provided for a givenis_valid
call, wallets can outright refuse to run the tx, or tell their users they are approving an operation blindly.
Making sense of preimages
Assuming we can get our hands on authorisation preimages, next step is to actually make sense of them. Each application would package an authorisation payload however it makes sense to them, but we need wallets to be able to translate this into something user-friendlish.
One option is to use EIP712, where we encode the structure and domain separator of the data into its hash. Or we can use an events-like signature for these preimage hints, where contracts declare in their ABI the structure of their auth requests (as they declare the structure of their emitted events), and wallets use this for deserialising the requests and presenting them to their users.
Altered auth request hashes in public-land
Another problem is that the auth payload may change when being executed by the sequencer. If wallets collect auth request hashes during a local simulation of the public part of a tx, the execution path may change when it is run by the sequencer (since the public state of the chain may be different by that time). This means that if an auth request depends on a value that may change, the resulting auth request hash will change, and the is_valid
check would fail. This can happen for instance when authorising a token transfer, where the amount to be transferred depends on the state of the chain.
Supporting this may require to include in the public function call the exact values used to construct the auth witness. So, in the token transfer example, the public function could be called with a “up to X tokens are authorised”, so the contract assembles the auth request using the exact X value. However, this requires devs to be aware of this limitation and makes the flow more complex overall.
Prototyping this in the Sandbox
It’d be good to start prototyping this on top of the Sandbox. Today, the role of a Wallet is split across the client and the sandbox: the CLI or aztec.js takes care of assembling and signing txs, while the Sandbox runs the RPC Server (or Private Execution Environment?).
If we continue with this approach, we would need to implement this new authorisation flow on the client, where auth witnesses can be generate, and where we can reach out to the user to ask for confirmations. However, this would require extending the RPC Server interface so the client can request simulations with a modified account contract, and collect the is_valid
requests somehow.
Alternatively, we can break up the Sandbox into Wallet and Node, more closely resembling how a testnet would look like, and have this implemented into the Wallet itself. But this is a much larger architecture change for local development.