Data Ownership Protocol
Private Transfers

Covered the Circom circuits underpinning DOP's private transfer and selective-disclosure system.
report id
0393
category
Identity & Cryptography
language
Circom
final report
January 14, 2025
lines of code
212
Content

Download audit report

1

Executive Summary

This document outlines the security review conducted by Nethermind Security for Circom circuits of Data Ownership Protocol (DOP). DOP implements a non-custodial wallet that allows for public and private transfers of ERC20 and ERC721 tokens. Moreover, the wallet allows to selectively share specific account information and transaction details while keeping other data private.

The reviewed Circom circuits consist of 212 lines of code. The DOP team has shared information on the functionality of audited circuits through code comments, whitepaper, smart contracts, and meetings, but a detailed system specification is missing. This specification is crucial for identifying design flaws and ensuring secure implementation. The review of the Circom circuits was conducted in the context of Solidity smart contracts at the commit 3bb9905. Those contracts are out of scope, but were necessary to understand how the circuits are used and identify some of the issues. The audit was performed using: (a) manual analysis of the codebase, and (b) writing test cases.

Disclaimer

During the analysis of the circuits implemented in Circom, we identified issues in the Solidity code of the associated smart contracts at the commit 3bb9905. Although some of these issues are reported in this document for the client's awareness, the audit scope is strictly limited to the Circom circuits, and the Solidity code has not been audited. Therefore, Nethermind Security cannot be held responsible for any issues arising from the Solidity code, as it falls outside the defined scope of this engagement.

Tests remarks

During the security review of circuit SelectiveTransparency, it was identified that the client did not implement specific tests to validate the behavior and integrity of this circuit. Conversely, Transfer circuit includes only a single test scenario, whereas it should have been tested in multiple scenarios. Furthermore, no scripts or documentation were provided to execute tests related to all circuits in the scope, compromising the ability to verify the correct functionality of the designed features in an automated and standardized way. We recommend that the DOP team develop tests for the circuit SelectiveTransparency and provide scripts to facilitate the execution and replication of these tests to ensure reliability and security.

Audit assessment
Audit TypeSecurity Review
Initial ReportDec 20, 2024
Final ReportJan 14, 2025
MethodsManual Review, Tests
Repositoryprotocol-circuits
Commit Hash - transfer.circom8b48e1d480
Commit Hash - merkle-proof-verifier.circom8b48e1d480
Commit Hash - nullifier-check.circom8b48e1d480
Commit Hash - selective-transparency.circom552f7ac0cc
Final Commit Hash68d18a3459
DocumentationCommunication, white paper, and NatSpec
Documentation AssessmentMedium
Test Suite AssessmentLow
Total findings
8
Fixed
7
Acknowledged
1
Mitigated
0
Unresolved
0

Findings by severity

  • Critical2
  • High1
  • Medium2
  • Low0
  • Informational2
  • Best Practices1

2

Audited Files

#CircuitLoCCommentsRatioBlankTotal
1src/library/nullifier-check.circom1600.0%420
2src/library/transfer.circom912426.4%20135
3src/library/merkle-proof-verifier.circom3213.1%740
4src/library/selective-transparency .circom731926.0%23115
Total2124420.8%54310

4

System Overview

The DOP protocol is a private fund management solution that allows users to send private transactions, viewable only by the recipient. The DOP wallet also supports public transactions and the disclosure of private data through selective disclosure actions. The wallet infrastructure consists of two parts: common on-chain contracts and off-chain infrastructure, which includes circuits. Users can participate in the protocol through DOPSmartWallet by depositing their funds into an on-chain public vault and requesting “encoding” of their funds. Once encoded, funds are transferred from the vault to the DOPSmartWallet and mixed with other users' funds. The deposited token and amount data are added as a commitment to a Merkle tree, and the user can then perform private transactions. A user's balance is represented similarly to a UTXO (Unspent Transaction Output) model, with the deposited or transferred token amounts added as a leaf = hash(npk || token || amount) to a Merkle tree, where npk is the user identification. As the Merkle tree is add-only, nullifiers are used to mark leaves that are already used during fund transfers.

4.1 Private and public transactions

The user must first generate the proof to initiate a transaction, providing the correct Merkle root (from the on-chain contract), commitments that prove the user holds funds, and nullifiers that verify the user hasn't spent those funds yet. The proof generation happens on the user's infrastructure (mobile device/laptop/PC), and all the keys are held and controlled only by the user. The circuit verifies that the provided leaves (hash(hash(hash(publicKey || nullifyingKey) || randomIn) || token || valueIn)) are in the Merkle tree with the corresponding merkleRoot and check if the provided nullifiers correspond to provided leaves. It also ensures that the total value of the tokens in the Merkle tree (valueIn) equals the total value of transfers (valueOut). This check is crucial since it ensures that the user has enough funds for the transfer. The operations of the circuit are presented in the Fig. 2. Note that the data is different depending on the token type. The ERC20 tokens are represented as token: actual address of a token, value: amount of tokens, whereas for ERC721 token: hash(token type || token address || token ID), value: always 1. After the proof is generated, it is supplied on-chain to DOPSmartWallet. The contract validates transaction parameters and marks provided nullifiers as used. If the transaction is private, there are no actual token transfers, but only the Merkle root is updated to a new one that contains the provided commitments that represent the recipient's right to tokens (note = hash(recipient_npk || token || amount) ). If the transaction is public, the Merkle root is not updated, and the tokens are transferred out of the contract to a specified address.

The flow of transfer action. The proof generated by the user is supplied on-chain for performing private transfers (updating Merkle tree root) or public transfers (sending tokens out of the wallet)
Fig. 2: The flow of transfer action. The proof generated by the user is supplied on-chain for performing private transfers (updating Merkle tree root) or public transfers (sending tokens out of the wallet).

4.2 Selective disclosure

Users can disclose the chosen part of their data. For example, users who hold 10 USDC, 50 USDC, and 30 WETH can prove and publish that they hold 10 USDC. The circuit flow is similar to the one in the transfer.circom circuit. The circuit ensures that provided token values corresponding to the user's npk exist in the tree with corresponding nullifiers and checks if the sum of those tokens equals the valueCheck, which is the value being disclosed. In the case of ERC721 tokens, the user holds a specific token ID, and the proved value in valueCheck is always equal to 1. The on-chain contract validates if the proof with corresponding public inputs is correct.

The flow of selective disclosure action. The proof generated by the user is supplied on-chain for check of correctness of published data
Fig. 3: The flow of selective disclosure action. The proof generated by the user is supplied on-chain for check of correctness of published data.

5

Risk Rating Methodology

The risk rating methodology used by Nethermind Security follows the principles established by the OWASP Foundation. The severity of each finding is determined by two factors: Likelihood and Impact.

Likelihood measures how likely the finding is to be uncovered and exploited by an attacker. This factor will be one of the following values:

  1. High: the issue is trivial to exploit and has no specific conditions that need to be met;
  2. Medium: the issue is moderately complex and may have some conditions that need to be met;
  3. Low: the issue is very complex and requires very specific conditions to be met.

When defining the likelihood of a finding, other factors are also considered. These can include but are not limited to motive, opportunity, exploit accessibility, ease of discovery, and ease of exploit.

Impact is a measure of the damage that may be caused if an attacker exploits the finding. This factor will be one of the following values:

  1. High: the issue can cause significant damage, such as loss of funds or the protocol entering an unrecoverable state;
  2. Medium: the issue can cause moderate damage, such as impacts that only affect a small group of users or only a particular part of the protocol;
  3. Low: the issue can cause little to no damage, such as bugs that are easily recoverable or cause unexpected interactions that cause minor inconveniences.

When defining the impact of a finding, other factors are also considered. These can include but are not limited to data and state integrity, loss of availability, financial loss, and reputation damage. After defining the likelihood and impact of an issue, the severity is determined according to the table below.

Impact / LikelihoodLowMediumHigh
HighMediumHighCritical
MediumLowMediumHigh
LowInfo / Best PracticesLowMedium
UndeterminedUndeterminedUndeterminedUndetermined

To address issues that do not fit a High, Medium or Low severity, Nethermind Security also uses three more finding severities: Informational, Best Practices, and Undetermined.

  1. Informational findings do not pose any risk to the application, but they carry some information that the audit team intends to pass to the client formally;
  2. Best Practice findings are used when some piece of code does not conform with smart contract development best practices;
  3. Undetermined findings are used when we cannot predict the impact or likelihood of the issue.

6

Issues

6.1 Burning users' funds with incorrect nullifiers

FileFix commitTypeStatus
transfer.circom68d18a3CriticalFixed

The transfer circuit, when receiving zero in the valueIn parameter, disables checks for nullifiers and the Merkle tree inclusion of leaves. However, this allows for creating a proof of transaction with nullifiers of another user. This transaction can be executed on-chain with the correct proof, and malicious nullifiers could be used to nullify another user's leaves, leading to the burning of the user's funds. The knowledge of nullifiers would not be difficult, as users post their nullifiers during selective disclosure.

Recommendation

Consider removing the isDummy mechanism. Alternatively, do not allow passing zero values on deposit/transfer and execute checks of nullifiers and Merkle tree inclusion for any value, including zero.

Resolution

The isDummy mechanism is removed.

6.2 Token which was not deposited can be transferred

FileFix commitTypeStatus
transfer.circom68d18a3CriticalFixed

When the valueIn provided to the circuit is 0, the circuit does not perform checks on the Merkle tree inclusion of notes or nullifiers' correctness. If both valueIn and valueOut are provided as zero, the sums would be equal. The malicious user can provide a token that was not deposited by them since the Merkle tree inclusion is not checked. This may lead to a few vulnerabilities during a transfer.

Scenario 1: The provided token may be an NFT token identifier: hash(tokenType: ERC721, tokenAddress, tokenSubID) that exists in a wallet and was deposited by another user, e.g., hash(ERC721, 0x123, ID: 1). In the case of valueIn equals to valueOut equals to 0 provided in the circuit, the malicious user can provide such token and the circuit would execute correctly.

The proved input note would look like: note equals to hash(holder_npk, token: hash(ERC721, 0x123, ID: 1), value: 0) - such note would not exist in the tree (all ERC721 tokens must have value = 1 during deposit) but since the Merkle tree inclusion is not checked the proof would be generated correctly. The malicious user could provide this token (deposited by another user) in commitmentsOut: hash(malicious_receiver_address, token: hash(ERC721, 0x123, ID: 1), value: 0).

During the on-chain verification, the invalid input note would be added to a tree, invalid nullifiers would be marked, and the transfer would be executed correctly, effectively allowing to steal the token from another user. Note that the note is not checked to have value == 1 during the public transfer.

Scenario 2: Another possible scenario is when a malicious user correctly deposited their NFT to the wallet and created a note: hash(ERC721, 0x123, ID: 1). Then they use the exploit described above where the valueIn == valueOut == 0 and provided commitmentsIn and nullifiers are incorrect and do not point to the correct note. The user transfers this NFT from the wallet, but the corresponding nullifier is unused. Then, the malicious user can use selective transparency to prove that they still hold the NFT in the wallet since the correct nullifier was not used.

The provided token may be an Ethereum address that does not hold any code. If such a transfer is public and called in a batch, then the ERC20 safe transfer would revert and the batch. This could lead to DOS of batches of transactions executed by a third party.

Recommendation

Consider removing the isDummy mechanism. Alternatively consider checking for Merkle root inclusion and nullifier correctness on every value, including zero. Additionally, consider asserting that the note.value equals 1 during the transfer of ERC721.

Resolution

The isDummy mechanism is removed.

6.3 The feature user-controlled selective disclosure fails when there is no nullifier

FileFix commitTypeStatus
selective-transparency.circom6a97b0cHighFixed

The SelectiveTransparency circuit verifies that a user owns more than a specified threshold amount ( valueCheck) of a given token. In this design, the use of a nullifier is optional. The public inputs to this feature include:

merkleRoot, boundParamsHash, nullifiers[nInputs], valueCheck, token

As the valueCheck input must always be greater than zero, the proof cannot be generated if there is no nullifier for input notes. The scenarios and their implications are outlined below:

Scenario 1: nInputs > 0 and valueCheck > 0. In this case, the prover will fail due to the inability to generate the signature. The message hash to be signed is constructed as follows:

messageHash = hash(merkleRoot, boundParamsHash, nullifiers)

Here, nullifiers is a list of length nInputs. If the list does not contain nullifiers, the message generation for signing fails. Even if the message is signed with this list without any nullifier, the *Nullifier Check* step must be skipped because there is no nullifier to verify. For this step to succeed, the condition isDummy[i].out = 0 must hold for all indices in the range [0:nInputs). The process for handling nullifiers is defined as:

for (var i = 0; i < nInputs; i++) {
nullifiersHash[i] = NullifierCheck();
nullifiersHash[i].nullifyingKey <== nullifyingKey;
nullifiersHash[i].leafIndex <== leavesIndices[i];
nullifiersHash[i].nullifier <== nullifiers[i];
// @audit To disable the nullifier check for the whole nullifiers array, isDummy[i].out should always be zero
nullifiersHash[i].enabled <== 1-isDummy[i].out;
}

The isDummy component is set with valueIn[nInputs] private input to verify against zero values. Consequently, valueIn must be zero for all indices in the range [0:nInputs]. This results in a sumIn = 0, causing the verification sumIn === valueCheck to fail.

Scenario 2: nInputs = 0 and valueCheck > 0 This scenario also fails. With nInputs = 0, all inputs associated with it such as, nullifiers[nInputs] and private list inputs (e.g., valueIn[nInputs]) are empty. The message hash for signature generation simplifies to:

messageHash = hash(merkleRoot, boundParamsHash)

As nInputs = 0, nullifier and Merkle proof checks are skipped. The sumIn value remains zero, and the verification sumIn === valueCheck fails.

Scenario 3: nInputs = 0 and valueCheck = 0 This scenario passes. With nInputs = 0, all inputs associated with it, such as nullifiers[nInputs] and private list inputs (e.g., valueIn[nInputs]), are empty. The message hash for signature generation simplifies to:

messageHash = hash(merkleRoot, boundParamsHash)

As nInputs = 0, nullifier and Merkle proof checks are skipped. The sumIn value remains zero, and the verification sumIn === valueCheck passes. This allows a user to provide no nullifiers and generate a valid proof.

Recommendation

Ensure valueCheck is greater than zero.

6.4 The nullifiers are not checked during the selective-transparency

FileFix commitTypeStatus
selective-transparency.circom—MediumFixed

The selective-transparency circuit allows users to prove holding some amount of funds. However, the nullifiers are not checked on-chain if they have not been used before. In effect, it is proved that the user held the disclosed amount at some point in history, but it is not ensured that the user still holds those funds. Note that anyone can still check the nullifiers' status on-chain but expects to perform additional checks from the information receiver. As a result, users can lie that they hold some value even if it was already spent if the information recipient won't compare it with the on-chain state.

Recommendation

Consider adding on-chain logic to ensure that the nullifiers are not already used.

Resolution

Added the check on-chain to check if the nullifier is used or not. Commit: c9f5f2b

6.5 The strict equality check in selective transparency may lead to data leak

FileFix commitTypeStatus
selective-transparency.circomcc4b3e2MediumFixed

The selective-transparency circuit does a strict equality check at the end, checking if the sumIn equals the public input valueCheck. This may lead to data leakage since if the user provided only one input note, it must be exactly equal to the valueCheck, revealing the exact note held in the tree.

Recommendation

Consider changing the strict equality check to a greater or equal one to ensure that data in the note is not revealed.

Resolution

Fixed the equality check from equal to greater or equal.

6.6 Centralization Risk

FileFix commitTypeStatus
verifier.sol—InfoAcknowledged

The verification keys are set by the owner of the Verifier.sol contract, and it is possible to change the owner due to OwnableUpgradeable. Therefore, the new or original owner could maliciously change the values of each verifying key, thus preventing the circuits from verifying.

Recommendation

Create a function that allows only adding to the list of verifying keys.

Resolution

We need the possibility for removing old keys if a vulnerability is found in a circuit so we can override a verification key for a certain number of inputs and outputs.

6.7 Not all public inputs are verified in the signature check

FileFix commitTypeStatus
selective-transparency.circomcc4b3e2InfoFixed

The SelectiveTransparency computes the hash over public inputs to generate the signed message. These signals are listed below:

template SelectiveTransparency(nInputs, nOutputs, MerkleTreeDepth) {
/* ========== PUBLIC SIGNALS ========== */

signal input merkleRoot;
signal input boundParamsHash;
signal input nullifiers[nInputs];
signal input valueCheck;
signal input token;
...
}

However, the computed hash does not contain valueCheck and token:

 // 1. Compute hash over public signals to get the signed message
var size = nInputs + 2;
component messageHash = Poseidon(size);
messageHash.inputs[0] <== merkleRoot;
messageHash.inputs[1] <== boundParamsHash;

for (var i = 0; i < nInputs; i++) {
messageHash.inputs[i+2] <== nullifiers[i];
}
// @audit valueCheck and token are not hashed

// 2. Verify EDDSA signature over hash of public inputs
component eddsaVerifier = EdDSAPoseidonVerifier();
eddsaVerifier.enabled <== 1;
eddsaVerifier.Ax <== publicKey[0];
eddsaVerifier.Ay <== publicKey[1];
eddsaVerifier.R8x <== signature[0];
eddsaVerifier.R8y <== signature[1];
eddsaVerifier.S <== signature[2];
eddsaVerifier.M <== messageHash.out;

If the message signed contains both valueCheck and token, the signature verification step will fail.

Recommendation

Add valueCheck and token to messageHash.

Resolution

All public inputs are added to the signature now.

6.8 nOutputs is not needed in SelectiveTransparency

FileFix commitTypeStatus
selective-transparency.circombc12700Best PracticesFixed

The nOutputs is not used in the selective transparency logic but is passed as an argument for the circuit.

template SelectiveTransparency(nInputs, nOutputs, MerkleTreeDepth) {

Recommendation

Remove the nOutputs

Resolution

Removed.

7

Documentation Evaluation

Software documentation refers to the written or visual information that describes the functionality, architecture, design, and implementation of software. It provides a comprehensive overview of the software system and helps users, developers, and stakeholders understand how the software works, how to use it, and how to maintain it. Software documentation can take different forms, such as user manuals, system manuals, technical specifications, requirements documents, design documents, and code comments. Software documentation is critical in software development, enabling effective communication between developers, testers, users, and other stakeholders. It helps to ensure that everyone involved in the development process has a shared understanding of the software system and its functionality. Moreover, software documentation can improve software maintenance by providing a clear and complete understanding of the software system, making it easier for developers to maintain, modify, and update the software over time. Smart contracts can use various types of software documentation. Some of the most common types include:

  • Technical whitepaper: A technical whitepaper is a comprehensive document describing the smart contract’s design and technical details. It includes information about the purpose of the contract, its architecture, its components, and how they interact with each other;
  • User manual: A user manual is a document that provides information about how to use the smart contract. It includes step-by-step instructions on how to perform various tasks and explains the different features and functionalities of the contract;
  • Code documentation: Code documentation is a document that provides details about the code of the smart contract. It includes information about the functions, variables, and classes used in the code, as well as explanations of how they work;
  • API documentation: API documentation is a document that provides information about the API (Application Programming Interface) of the smart contract. It includes details about the methods, parameters, and responses that can be used to interact with the contract;
  • Testing documentation: Testing documentation is a document that provides information about how the smart contract was tested. It includes details about the test cases that were used, the results of the tests, and any issues that were identified during testing;
  • Audit documentation: Audit documentation includes reports, notes, and other materials related to the security audit of the smart contract. This type of documentation is critical in ensuring that the smart contract is secure and free from vulnerabilities.

These types of documentation are essential for smart contract development and maintenance. They help ensure that the contract is properly designed, implemented, and tested, and they provide a reference for developers who need to modify or maintain the contract in the future.

8

Remarks & Test Suite

The DOP team has provided code comments explaining the inputs and flow within the Transfer and SelectiveTransparency circuits. Their functionality is outlined in whitepaper, Solidity smart contracts with the commit hash 3bb990, and clarifications during meetings. However, there is no comprehensive specification of the system requirements that clearly outlines how the protocol should operate and the specific role of verifications within the circuits. This specification is crucial for identifying design flaws and validating the implementation of these requirements from a security point of view.

Test suite
3Tests passed1Failed

The complete test output is in the full report.

As auditors, we recommend that any changes or updates made to the audited codebase undergo a re-audit or security review to address potential vulnerabilities or risks introduced by the modifications. By conducting a re-audit or security review of the modified codebase, you can significantly enhance the overall security of your system and reduce the likelihood of exploitation. However, we do not possess the authority or right to impose obligations or restrictions on our clients regarding codebase updates, modifications, or subsequent audits. Accordingly, the decision to seek a re-audit or security review lies solely with you.

This report is based on the scope of materials and documentation provided by you to Nethermind in order that Nethermind could conduct the security review outlined in 1. Executive Summary and 2. Audited Files. The results set out in this report may not be complete nor inclusive of all vulnerabilities. Nethermind has provided the review and this report on an as-is, where-is, and as-available basis. You agree that your access and/or use, including but not limited to any associated services, products, protocols, platforms, content, and materials, will be at your sole risk. Blockchain technology remains under development and is subject to unknown risks and flaws. The review does not extend to the compiler layer, or any other areas beyond the programming language, or other programming aspects that could present security risks. This report does not indicate the endorsement of any particular project or team, nor guarantee its security. No third party should rely on this report in any way, including for the purpose of making any decisions to buy or sell a product, service or any other asset. To the fullest extent permitted by law, Nethermind disclaims any liability in connection with this report, its content, and any related services and products and your use thereof, including, without limitation, the implied warranties of merchantability, fitness for a particular purpose, and non-infringement. Nethermind does not warrant, endorse, guarantee, or assume responsibility for any product or service advertised or offered by a third party through the product, any open source or third-party software, code, libraries, materials, or information linked to, called by, referenced by or accessible through the report, its content, and the related services and products, any hyperlinked websites, any websites or mobile applications appearing on any advertising, and Nethermind will not be a party to or in any way be responsible for monitoring any transaction between you and any third-party providers of products or services. As with the purchase or use of a product or service through any medium or in any environment, you should use your best judgment and exercise caution where appropriate. FOR AVOIDANCE OF DOUBT, THE REPORT, ITS CONTENT, ACCESS, AND/OR USAGE THEREOF, INCLUDING ANY ASSOCIATED SERVICES OR MATERIALS, SHALL NOT BE CONSIDERED OR RELIED UPON AS ANY FORM OF FINANCIAL, INVESTMENT, TAX, LEGAL, REGULATORY, OR OTHER ADVICE.