Introduction

On February 26, 2024, Renegade tasked zkSecurity with auditing parts of its circuits and smart contracts. The specific code to review was shared via GitHub as public repositories. The audit lasted 3 weeks with 2 consultants.

Written specifications detailing the circuits, the smart contracts, and additional protocols (like the proof linking scheme) were also shared.

The documentation provided, and the code that zkSecurity looked at, was found to be of great quality. The Renegade team was very responsive and helpful in answering questions and providing additional context.

Scope

The scope of the audit included the following components.

Whitepaper: Circuit Specification. The circuit specifications for all proofs, including checks of “linked” wires across proofs. zkSecurity checked that nothing was under-constrained in the specification itself. The most up-to-date specification is Updated Circuit Specification.

Whitepaper: Linkable Proofs. The mathematical specification for “linkable proofs”. zkSecurity ensured that auxiliary proofs were sound and faithfully implemented cross-proof wire constraints. Location: Proof Linking PlonK

Circuit Spec Implementation. Implementation of the circuit specification. zkSecurity audited the gadgets and high-level circuits to ensure correct implementations of the specification. Specific Directories included:

  1. renegade/circuits/src/zk_circuits — All circuit definitions.
  2. renegade/circuits/src/zk_gadgets — ZK gadgetry used in the circuits (e.g. ElGamal, Poseidon, etc).
  3. renegade/circuit-types/src — Type definitions for circuits, including how we convert runtime types into witness types in the PlonK wire assignment.
  4. renegade/circuit-macros/src/circuit_type — Macro definitions that derive the traits in traits.rs.

SRS logic. SRS logic to handle verification keys. Specific Directories:

  1. renegade/circuit-types/src/srs.rs — SRS-related types.
  2. renegade-contracts/scripts/src/commands.rs — Script to handle verification keys.
  3. renegade-contracts/contracts-stylus/vkeys/prod — Verification key outputs.

Renegade smart contracts. Renegade-specific logic inside the Stylus smart contract. Includes Merkle tree implementation, nullifier set logic, storing of secret shares, and access control. Specific directories:

  1. renegade-contracts/contracts-stylus/src — Smart contracts.
  2. renegade-contracts/contracts-common/src — Types and serialization logic used both in the contracts and in surrounding testing / utilities.
  3. renegade-contracts/contracts-core/src/crypto — Implementation of ECDSA verification (using the EVM’s ecRecover precompile) and Poseidon 2 hashing (imported from the relayer codebase).

PlonK verifier. The core PlonK verifier itself inside Stylus smart contract, including “linkable” proof checks. Specific Directories:

  1. renegade-contracts/contracts-core/src — Core transcript and PlonK verification logic.

Recommendations

In addition to the findings discussed later in the report, we have the following strategic recommendations:

Audit integration. Ensure that integration with external systems is secure. While this audit specifically focused on the parts of Renegade described above, security of the entire system is only as strong as its weakest link. This includes the P2P network protocol between relayer nodes and price discovery system (since optimal execution price seems to be an important property of the system).

Fix high-severity issues. Ensure that the findings with high severity are properly fixed, as these findings can lead to critical issues. See Deposits Can Be Stolen By Frontrunners. Consider re-auditing the affected components.

Specify more parts of the system. Consider writing a specification of the P2P network protocol for discovery of matching orders across different relayers and finding (and, perhaps, proving) the theoretically-optimal midpoint price (as per https://docs.renegade.fi).

Overview of Renegade

Renegade is a dark pool trading platform that allows users to trade ERC-20 assets without revealing any information about their trades and balances (except when depositing to or withdrawing from Renegade). Renegade is a decentralized system built on Arbitrum Stylus, an Ethereum Layer 2.

Renegade’s network node software and smart contracts are written in Rust: Arbitrum Stylus enables smart contracts compiled to Wasm which is a great compile target for Rust.

Architecturally, Renegade is built on top of a Zcash-like shielded pool built within a smart contract. Users can create and “update” their wallets to deposit or withdraw balances, as well as create and cancel orders.

The matching engine in charge of matching orders is a multi-party computation (MPC) involving two wallets, and producing a zero-knowledge proof if and only if the match is successful.

As the matching engine of Renegade requires a wallet owner to be online to run synchronous protocols with other nodes, agents called relayers can be used to delegate the matching and settlement of orders. In exchange for this service, relayers collect fees for each successful match.

Next, we introduce the lowest primitive used to build Renegade: a wallet.

Wallets

A wallet is a secret-shared data structure hosting a number of fields:

  • 5 asset balances, which all have to be different with regard to the asset held
  • 5 active orders, which can be matched against other wallets’ orders
  • 2 public keys: pk_root, allows the user to authenticate themselves, and pk_match, allows the wallet’s associated relayer to authenticate themselves
  • the match_fee rate that the wallet owner is willing to pay for the relayer’s service
  • the managing_cluster encryption key — involved in relayer fee settlement
  • a blinder used to blind shares of the wallet (more on that later)

wallet

Such wallets are interpreted as a number of field elements, and these are passed as blinded additive secret shares to the Renegade smart contract.

By secret shares we mean that each value wi that make up a wallet are split (shared) into two values wi,1,wi,2 such that wi=wi,1+wi,2. Of these two secret shares, one is public and one is private. The public share is blinded (see below) for it to be safe to be publicly passed to the smart contract. It’s referred to as the blinded public share (or simply public share). The private share is only known to the wallet owner (and its relayer).

Splitting the values of a wallet between public and private shares allows for several interesting properties:

  1. The MPCs can be done much more efficiently as some of the inputs are already secret-shared.
  2. The values of a wallet can be updated by updating the public shares in the same way: additions and subtractions transfer to the underlying values.

Blinding and Reconstructing a Wallet

Note that knowing the public share of a value along with the plaintext value would allow someone to recompute its private share. As such, wallet values are actually split into three shares, the third share being the blinder. The blinder value is itself shared into two additive shares, which is OK as it is supposedly random.

As an implementation detail, note that the blinder is part of the wallet and is thus secret-shared in the same way that the wallet is (but obviously, without being itself blinded). As such, the unblinding and blinding operations often have to treat the blinder field as an edge case:

/// Unblinds the wallet, but does not unblind the blinder itself
pub fn unblind_shares<C: Circuit<ScalarField>>(
    self,
    blinder: Variable,
    circuit: &mut C,
) -> WalletShareVar<MAX_BALANCES, MAX_ORDERS> {
    let prev_blinder = self.blinder;
    let mut unblinded = self.unblind(blinder, circuit);
    unblinded.blinder = prev_blinder;

    unblinded
}

To recover the whole wallet from private_share and blinded_public_share, the following algorithm is used:

  1. Recover the blinder: blinder = blinded_public_share.blinder + private_share.blinder.
  2. Unblind the public share: public_share = blinded_public_share.unblind_shares(blinder).
  3. Recover the wallet: wallet = private_share + public_share.

We illustrate the shares and the blinding process in the following diagram:

wallet shares

Wallet Operations

There are two user-initiated wallet updates:

  • Wallet Create. Create a wallet.
  • Wallet Update. Update a wallet, which includes updating keys, balances, and orders.

with the latter one letting you do most of the user updates. This includes change of keys, balance, and orders. We illustrate, as an example, the balance updates enforced in the different flows:

wallet updates

Other wallet operations are performed by relayers. The main one is the process match settle which allows a relayer to finalize a match between two orders.

In addition, there are a number of fee functions:

  • ValidRelayerFeeSettlement is used when a wallet that has an unpaid fee balance is managed by the relayer getting the match fee.
  • ValidOfflineFeeSettlement is used when the match fee recipient relayer may be offline or unresponsive. An encrypted note is created with the fee amount and the unpaid fee balance is set to 0.
  • ValidFeeRedemption is the other side of ValidOfflineFeeSettlement: the fee from the note is redeemed and added to the balance of the recipient wallet.

Note that each operation is often split into two parts:

  • an off-chain part implemented as a circuit, producing a ZK proof of some statement
  • an on-chain part implemented as a smart contract, verifying the ZK proof and working with on-chain persistent state

The most complex operation is the match settlement, which we explain in further detail in the next section.

Order Matching

Order matching is the core of the system and, not surprisingly, the most complicated operation. Final order matching and settlement ZK statement comes as a bundle of linked proofs of statements about orders from two parties (ValidReblind and ValidCommitments — from each party, ValidMatchSettle — for the whole trade).

match

A ValidMatchSettle proof is either produced by a single relayer (in case when both parties’ wallets are managed by the same relayer) or collaboratively by two relayers using MPC.

ValidReblind and ValidCommitments do not interact with smart contracts individually — only as a linked proof bundle with ValidMatchSettle.

witnesses

A (greatly) simplified view of how this works is:

  1. First, ValidReblind proof is produced for a wallet ready for trading — populated with orders to be matched and has the corresponding assets in its balances.
  2. Second, ValidCommitments proof is produced for each order in the wallet, just before looking for matches. As a result, we now have a re-blinded wallet and a set of commitments, ready to engage in order matching.
  3. ValidReblind and ValidCommitments proofs are linked by values of some witness fields (namely, reblinded_wallet_private_shares, reblinded_wallet_public_shares).
  4. The match is made by relayer nodes that, at this point, have a bundle of ValidReblind and ValidCommitments proofs (with their respective public inputs) for each order.
  5. The MPC is performed and, if successful, results in ValidMatchSettle proof, linked with ValidCommitments from the two parties with updated balances from executed matching orders.
  6. The proof bundle is communicated to the smart contract and verified as a whole, and the on-chain state is updated for both participating wallets (as described below, in On-Chain Persistent State).

Proof Linking

Some of the proofs in the proof bundle mentioned above are “linked” in order to ensure that they reuse some of their private inputs. The following diagram illustrates what is being linked between the different witnesses:

proof linking

In order to prove that some of the same variables were used between two different witness polynomials a and b, the following identity is proven for some quotient polynomial q:

a(x)b(x)=ZS(x)·q(x)

and ZS(x)=iS(xgi) — vanishing polynomial for the set S of the indices of the variables that are being linked.

This is equivalent to proving that polynomial q(x) satisfying the above identity exists.

Note that as different circuits might be on different domains, values of a larger domain have to be aligned to match the values of the smaller domain (as only some of the rows of a larger domain align with the rows of a smaller domain, due to the 2-adicity of the field used).

The verifier has access to the commitments [a], [b], and then [q], but can’t produce a commitment to [ZS] as it involves multiple multiplications with [x].

To work around that, the verifier demands to check the equation at some random value η:

a(η)b(η)=ZS(η)·q(η)

which allows us to “linearize” the equation and compute ZS(η) first.

So once the verifier gets [a],[b], and [q] they sample η. Then they produce ZS(η) and compute the commitment of

f(x)=a(x)b(x)ZS(η)·q(x)

they can do that by computing

[f]=[a][b]ZS(η)·[q]

The only thing left to for the prover is to show, using KZG, that f(η)=0, this implies showing that there exists a a polynomial q, such that

f(x)0=(xη)·q(x)

or, in terms of commitments,

[f]=(xη)·[q]

The last identity is the same as x·[q]=[f]+η·[q], which we can check in a pairing

e([q]1,[x]2)=e([f]1+η·[q]1,[1]2)

which ultimately looks like

e([q]1,[x]2)·e([f]1η·[q]1,[1]2)=e([1]1,[1]2)

On-Chain Persistent State

The most important parts of the on-chain persistent state are:

  • the nullifier set — stored as part of DarkpoolCore contract
  • the current Merkle root — a part of Merkle contract
  • the Merkle root history — a part of Merkle contract

The snippet below contains the discussed fields:

/// The set of wallet nullifiers, representing a mapping from a nullifier
/// (which is a Bn254 scalar field element serialized into 32 bytes) to a
/// boolean indicating whether or not the nullifier is spent
nullifier_set: StorageMap<U256, StorageBool>,
// TRUNCATED...
/// The current root of the Merkle tree
pub root: StorageU256,
/// The set of historic roots of the Merkle tree
pub root_history: StorageMap<U256, StorageBool>,

Any operation, resulting in new or updated wallets, involves these interactions with the on-chain persistent states:

  • Old wallets’ Merkle roots are checked (that they exist in the Merkle root history).
  • Old wallet nullifiers are also checked (that they don’t yet exist) and inserted into the nullifier set.
  • New / updated wallet commitments are computed and inserted into the Merkle tree.

We illustrate below the Merkle tree used to store wallets (as well as encrypted notes for fees!):

merkle