Workshop: Using Noir for Building an Anonymization Module

5 July, 2024
article image
Case Study

Contents

§1. Introduction

This workshop serves as a hands-on continuation of our theoretical exploration of the Noir DSL. In it, we detailed its key features and explored a straightforward example that clearly demonstrates Noir’s advantages over Circom. We highly recommend reviewing this before diving into the current workshop.

In this session, we will demonstrate how the Safe Anonymization Module (SAM) for Safe Wallet works in practice, enabling the anonymization of transaction data sent from individual Wallet addresses.

SAM is implemented in two parts: @sam-contracts (on-chain components that manage the interaction of Wallet participants with the blockchain via smart contracts) and @sam-circuits (off-chain components responsible for anonymizing participant votes and generating ZKP). However, in this workshop, we will not focus too much on the on-chain components, as our goal is to explore the ZKP generation process.

Here are the objectives we have set for this workshop:

  1. Understand how SAM circuits work in practice and how to generate ZKP using them.
  2. Learn how to send regular transactions on the Safe network (without SAM).
  3. Learn how to send anonymous transactions on the Safe network (with SAM).
  4. Analyze how Noir facilitates the tasks set before SAM.

§2. SAM explained

To practically demonstrate Noir’s capabilities, we rewrote the Safe Anonymization Module from Circom to Noir. You can find the specification and source code in our repository. Let’s explore the main differences.

Multisig wallets, transactions of which we aim to anonymize, are called Safe Wallets. Each wallet has a pool of owners who vote on certain transactions with its funds. The protocol cannot hide the fact that a transaction is signed and an action from its logic is carried out. However, it’s possible to obscure who exactly voted “FOR” the action being taken in the transaction. Our Safe Anonymization Module (SAM) solves exactly this problem.

Since no checks are performed within the Wallet itself, we must independently validate the transaction body - for this, we’ll use a ZK proof, the circuits for which we will write in Noir.

The initial implementation of this module was written in Circom, but as an experiment, we decided to rewrite it in Noir - this is a good practical case that clearly shows most of Noir’s advantages.

You can find implementations in both DSLs in our repository. In this article, we will not delve into the implementation on Circom: you can compare for yourself how much more complex it is relative to Noir.

Solution Overview

To understand why the solution looks this way and not otherwise, we need to dive into the theoretical premises that led us to choose one technology over another.

The module aims to achieve the Signature check: We will use a Merkle tree, the leaves of which consist of the address of the participants. The root of the Merkle tree is stored in the module contract. Candidates, to vote for the approval of the transaction, must prove ownership of a valid signature of the message and demonstrate the presence of their address in the Merkle tree.

Now, let’s go through the main parts of the solution itself:

  • Multi-user support: Since SAM circuits are built using a Merkle Tree, they support operation with a large number of users simultaneously. In our case, the Merkle Tree has 5 levels, which determines the maximum number of users as 2^5 = 32.
  • Choice of elliptic curves: The study of the compatibility of various elliptic curves with ZK infrastructure concluded that Spartan-ecdsa offers improved calculations in the desired field but has limitations in proof size and on-chain verification. The BLS12-381 curve, which offered a higher level of security but was incompatible with existing cryptographic libraries, was also considered. Therefore, in our Circom implementation, we decided to use BN254, which is also used in the standard implementation on Noir.
  • Proving system: Initially, our solution intended to use Groth16 as the primary proving system. However, the implementation on Noir allowed us to use several proving systems for the same implementation and with the same performance (or better in some cases). Black Box functions actually work!
  • Transaction sending method: We considered a large number of options, including those using Account Abstraction. However, according to the functional requirements of the solution, Gelato SyncFee is much more suitable - it’s easier to integrate and more stable.
  • Proof storage method: We chose the Safe Transaction Service, which is good because it ensures good integration with Safe Infrastructure and the ecosystem as a whole.
  • Stack: We wrote circuits in Circom and Noir, used Solidity for smart contracts, and Node.js for the backend.

Now let’s look at the overall flow of SAM initialization and user connection:

Untitled

  1. To create and initialize SAM for a specific Wallet, the user sends a standard transaction to Safe.
  2. After processing the transaction, Safe, through the deployProxy function, reaches out to the SAM Factory, which then deploys and sets up the proxy implementation.
  3. To interact with the implementation after setup, the user activates the current implementation of SAM by sending a transaction to Safe.

Now let’s look at the overall architecture of the implemented solution. It illustrates the situation when one of the Safe participants proposes to take some action with assets. In this scenario, we have three starting points from which users can begin:

  • Option 1: The starting point of the scenario where the user creates a proof using Safe Wallet frontend client.
  • Option 2: The starting point of the scenario where the user sends the proof which he have created to the backend - Safe Transaction Service. Safe Transaction Service is a sudmodule that keeps track of transactions sent via Safe contracts. It indexes these transactions using events (L2 chains) and tracing (L1 chains) mechanisms.
  • Option 3: The starting point of the scenario in which the user directly sends the prepared package of proofs to SAM.

Untitled

Let’s provide some comments on this scheme to simplify its understanding:

  1. When a user interacts with SAM through the frontend (Option 1), a proof is generated based on their inputs, which Safe automatically sends to the Safe Tx Service. There, proofs are validated, and checks are made to ensure the necessary number of participants voted for or against. If both checks are passed, the data is packaged into a UserOperation structure, which characterizes the operation being performed by the user. This operation is sent to a separate mempool and contains parameters such as sender, to, calldata, maxFeePerGas, maxPriorityFee, signature, nonce, and additional elements like EntryPoint, Bundler, and Aggregator. The use of the UserOperation structure is part of the ERC-4337 standard, which integrated native Account Abstraction into Ethereum.
  2. When the user sends a ready proof directly to the Safe Tx Service backend (Option 2), the system needs to validate it, then also package all the received data into UserOperation and send it to Safe.
  3. When the user directly sends a package of proofs to SAM (Option 3), the only thing left for the system to do is to check that the proof is valid and the minimum number of votes has been reached. Then, Safe executes the logic that the safe participants voted for.

Such an architecture offers several important advantages!

  1. User Accessibility. Users can easily and affordably deploy their own SAM module. The proxy’s size is significantly smaller than the SAM implementation, making it more cost-effective for a user to deploy a proxy instead of the implementation.
  2. Upgradeability. SAM is intended to be an upgradable contract, allowing SafeDAO to update SAM as needed.
  3. Unique Addresses. If the SAM Proxy Factory deploys contracts using CREATE2, users in different networks can have the same SAM proxy server address. This provides convenience for users and simplifies the use of the module across different networks.

Review of code

Now, let’s look at how we were practically able to implement the tasks set out!

To solve the task, we will also use the Merkle Root of all the participants’ addresses of the Safe, which is generated for each Safe module.

Our final ZK Proof must confirm two things:

  1. The participant’s address indeed belongs to the Merkle Root (= the participant has the right to vote in Safe).
  2. The participant indeed votes “FOR” the execution of the transaction.

Additionally, to prevent generating multiple valid proofs for one voting address, we need to provide each participant with their unique commitment, which can be accounted for only once per voting.

Let’s look at the implementation of the main function we ended up with. The logic of this particular function will ultimately be reflected in the final ZK proof.

See full code of this function in our github repository!

2x.png

Let’s first go through the inputs of main:

  • With the public key, we verify that this specific address signed this specific signature sig.
  • In this case, the parameter msg_hash acts as the commitment that helps us avoid double-voting. Practically, it’s just a hash of all parameters involved in executing the Safe’s transaction.
  • The input root is the Merkle Root, which allows the verification of all Safe participants. For verification, we will also use the inputs path_indexes (represents the addresses to the left and right of the one we need to check) and hash_path (means the array up to the root of those values that you must hash with this leaf in such positions to obtain the Merkle Root).

Now let’s go through the flow of main:

  1. Using the public key, we verify that the passed signature is valid.
  2. To check if the user indeed belongs to the Merkle Tree, we derive their Ethereum address from the public key and represent it in the Field data type.
  3. This address is then hashed using the MiMC Sponge hash function. Although Noir natively supports Pedersen and Poseidon hash functions, we chose this one since we already used it in our Circom implementation.
  4. After hashing, we use the resulting hash to retrieve the user’s leaf from the Merkle Tree (user_leaf Field).
  5. This leaf is then passed to the compute_merkle_root function. The function recalculates the root according to all data received from the user.
  6. The program compares the calculated root with the root that was provided by the user as an input (input root).
  7. Next, the function calculates the commitment[0] using the fn compute_commitment function. This is what can then be verified in any proving system you choose. For example, in our Circom implementation, we used Groth16 as the proving system.

compute.png

§3. SAM in practise

Nargo instead of Cargo

During the practical implementation of our solution, we also managed to test the Nargo tool. Instead of the standard package manager Cargo, which is used in Rust, Noir uses Nargo.

With > nargo, you can start new projects, compile, execute, prove, verify, test, generate Solidity contracts, and do pretty much all that is available in Noir.

Untitled

As we’ve discussed, developers have the option to change the final proving system into which the ACVM will compile the Noir program. This is done through the Backend command, as highlighted in the screenshot above.

Speaking of on-chain verification of ZK-proofs, it’s also worth mentioning that Noir offers the ability to generate a smart contract that, once deployed, will verify your proofs - very convenient! In Nargo codegen-verifier command is used to generate this smart contract.

Moreover, the Noir team has recently implemented a new library, Barretenberg.js, which allows for proving and verifying circuits directly in the browser.

SAM setup

To understand how the ZK-part of SAM functions, let’s clone the @sam-circuits repository into our local development environment and switch to the directory containing the Noir implementation:

# 1. Clone SAM repository
> git clone https://github.com/oxor-io/sam-circuits

# 2. Change the path, needed for correct work of Nargo
> cd sam-circuits/circuits/noir/main

Now let’s compile our circuits and run the tests using the following commands:

# 1. Compile SAM circuits
> nargo compile

# 2. Checks the contraints system of a circuit
> nargo check

# 3. Run tests for circuits
> nargo test

The last command will give us this result:

Untitled

As you can see, 15 tests were run, and one of them crashed (it’s just a bug in the compiler that will be fixed in future versions, so don’t worry about it).

However, what does testing actually involve? How do we check our circuit’s performance with specific values? It might seem like magic, but by running the last command, we’ve already checked everything! There’s no need to write additional scripts in JS or use external libraries—all is integrated into Noir and Nargo.

Let’s dive into how this magic happened!

Testing SAM

To test programs written in Noir, we don’t need to run anything on a testnet, use JS libraries, or anything like that. Noir’s testing system is quite streamlined: tests are written directly in the circuit file (with the addition of an #[test] or #[test(should_fail)] attribute) and allow immediate functionality testing of the program. It’s straightforward and fuss-free.

Untitled

As you can see, for the tests to function, we can simply set default inputs for fn main, from which the program will generate the proof. This is much more convenient than writing additional scripts and integrations!

Conclusion

So, what conclusions can we draw?

  1. Thanks to Noir’s capabilities, the ZK-part of the Safe Anonymization Module can operate on multiple proving systems and can optimize the program code for each of them if necessary.
  2. Our team saved a lot of time on testing and debugging the solution, thanks to the convenient built-in testing system used by Nargo.
  3. With the codegen-verifier feature, we significantly reduced the time spent on writing on-chain infrastructure for our circuits. However, it’s important to note that there are still many aspects of this function that can be improved.
  4. The built-in check function allowed us to quickly verify and correct any errors in the constraint system embedded in our circuits during development.

References

Safe Anonymization Module

  • [Link] - [#research] - “Safe Anonymization Module | 1.M”
  • [Link] - [#research] - “Safe Anonymization Module: proposal”

Aztec & Noir DSL

  • [Link] - [#report] - “2023 Crypto Developer Report”
  • [Link] - [#research] - “Aztec: Ethereum’s Privacy-focused ZK-rollup”
  • [Link] - [#research] - “Aztec’s ZK-ZK-Rollup, looking behind the cryptocurtain”
  • [Link] - [#research] - “Introducing Noir: The Universal Language of Zero-Knowledge”
  • [Link] - [#documentation] - “The Noir Language”
  • [Link] - [#documentation] - “Noir Playground”

Noir in practise

  • [Link] - [#repository] - @AztecProtocol/aztec-packages/noir
  • [Link] - [#repository] - @AztecProtocol/aztec-starter
  • [Link] - [#repository] - @AztecProtocol/awesome-aztec
  • [Link] - [#repository] - @noir-lang/noir-examples
  • [Link] - [#repository] - @noir-lang/awesome-noir
  • [Link] - [#repository] - @noir-lang/noir-starter
  • [Link] - [#repository] - @noir-lang/noir
Telegram
Case Study

Contents

Telegram

Have a question?

Have a question?

Stay Connected with OXORIO

We're here to help and guide you through any inquiries you might have about blockchain security and audits.