Introduction

On September 2nd, 2024, zkSecurity was engaged to conduct a security audit of the Hinkal Protocol’s Solidity smart contracts and Circom circuits. Over the course of one week, two dedicated consultants meticulously reviewed the Solidity smart contracts and Circom circuits, aiming to identify potential bugs and security vulnerabilities. The audit was conducted following a one-week pre-audit engagement between a zkSecurity consultant and the Hinkal team, focused on identifying security vulnerabilities and potential threats to the protocol.

During the audit, several observations and findings were identified, which have been communicated to the Hinkal Protocol team. The detailed findings and their implications are discussed in the subsequent sections of this report.

Scope

The audit, conducted by two consultants from zkSecurity, focused on a comprehensive review of the Solidity smart contracts and Circom circuits associated with the Hinkal Protocol. The assessment covered the following files:

./libs/circom/

  • AccessTokenChecker.circom
  • ConditionalOverflowPreventer.circom
  • ForceNotEqual.circom
  • ForceMaxAllowedTimestamp.circom
  • MerkleRootCalculator.circom
  • NullifierCalculator.circom
  • OriginalCommitmentCalculator.circom
  • OriginalOrStakeCommitmentCalculator.circom
  • OverflowPreventer.circom
  • PointCompressor.circom
  • ShouldFill.circom
  • Signature.circom
  • StakeCommitmentCalculator.circom
  • StakeInputChecker.circom
  • StakeProver.circom
  • StakeProverPermissionless.circom
  • StealthAddressCalculator.circom
  • StealthAddressCompressor.circom
  • SwapperM.circom
  • SwapperME.circom
  • receiver.circom

./hardhat/contracts/

  • CircomDataBuilder.sol
  • CrossChainAccessToken.sol
  • ERC20TokenRegistry.sol
  • Hinkal.sol
  • HinkalBase.sol
  • HinkalHelper.sol
  • HinkalRelayWrapper.sol
  • HinkalWrapper.sol
  • Merkle.sol
  • MerkleBase.sol
  • MerkleRemovable.sol
  • OwnerHinkal.sol
  • RelayStore.sol
  • Signer.sol
  • Transferer.sol
  • TransfererBase.sol
  • VerifierFacade.sol

external-actions/hinkal-staking/

  • HinkalStakeExternalAction.sol
  • HinkalStakeDataDecoder.sol
  • hToken.sol

xchain/connext/

  • ConnextAction.sol
  • ConnextRescueBuffer.sol

The commit we audited was 117e8ad.

Methodology

zkSecurity employed a comprehensive set of methodologies and heuristics to audit both the Solidity smart contracts and Circom circuits of the Hinkal Protocol. This section outlines the key approaches taken during the audit.

Firstly, we conducted an extensive review of the Solidity smart contracts, focusing on common vulnerabilities including, but not limited to, reentrancy, transaction-ordering and timestamp dependence, arithmetic errors, gas-related issues, access control weaknesses, and logic flaws. On the Circom side, our focus was on identifying under-constrained vulnerabilities due to insufficient constraints (such as variables assigned but not properly constrained and mismatches in arithmetic transformations), logic errors, incorrect use of external circuits, edge cases in modulo arithmetic, and out-of-circuit computations that are not properly constrained. Further, we tried to validate that all the private variables remained private and there were no information leakages.

Additionally, we analyzed the integration between Solidity and Circom, paying special attention to potential errors arising from incorrect assumptions made by the Solidity code about the Circom circuits and vice versa.

We also considered the following specific attack vectors and explored whether vulnerabilities could be identified within the protocol:

  • Double Spending: Can users exploit a flaw to double-spend a nullifier?
  • Slippage Exploits: Can an attacker exploit slippage risks associated with relayers?
  • Reentrancy Attacks: Are the contracts adequately protected against reentrancy attacks?
  • Access Token Theft: Can an attacker steal an access token? Is the access token securely bound to a user’s public key?
  • KYC Bypass: Is it possible for an attacker to acquire an access token without passing KYC?
  • Signature Vulnerabilities: Are there risks of signature malleability or replay attacks?
  • Cross-Chain Transfer Failures: In Connext xcall, could a cross-chain transfer silently fail while still triggering a fund transfer back to the user in ConnextAction.sol?
  • Fee Bypass: Can an attacker bypass the relayer fee or Hinkal commission?
  • Hook Contracts: The transactHook in Hinkal.sol allows user-deployed contracts to execute custom code via transactHook.beforeTransact() and transactHook.afterTransact(). Could this lead to malicious actions, especially when using both preHookContract and hookContract?
  • Circuit Constraints: Are the Circom circuits properly constrained to prevent under-constrained vulnerabilities?
  • ZKP Usage: Do the Solidity contracts correctly utilize the ZKPs generated by Circom? Are there any incorrect assumptions made based solely on the ZKPs?
  • Merkle Tree Issues: Are the Merkle Trees correctly constructed? Could funds be locked in the protocol due to incorrect updates?
  • Commitment Spending: Can commitments be duplicated?
  • Is the new feature of Hinkal, i.e., staking and bridging, working correctly?
  • Are the unspendable commitments created in SwapperME truly unspendable?
  • Does the bridging functionally work properly, and is the access control correctly applied?

Recommendations

Based on the findings of the audit, we recommend the following steps to enhance the security and robustness of the Hinkal Protocol.

While the codebase was found to be generally well-designed and aligned with security best practices across both Solidity and Circom, there is room for improvement in the area of documentation. Specifically, clearer documentation of the interactions between different components—namely, the client-side code (out-of-scope for this audit), the smart contracts responsible for verifying ZKPs and managing on-chain data through the three primary Merkle Trees (the main Hinkal Merkle Tree, Staking Merkle Tree, and AccessToken Merkle Tree), and the Circom code that generates and verifies ZKPs—would significantly enhance code readability and maintainability.

Similarly, the Circom circuits should include more detailed documentation regarding their intended behavior. For example, in the receiver.circom circuit, it appears that the circuit is designed to validate the provided private key and verify the access token. However, the private key is not actually validated against a corresponding public key, which could lead to serious bugs in the future.

We also observed that while the existing tests and integration tests cover basic scenarios, they could be improved by incorporating a wider range of values and edge cases. For instance, the deposit/withdraw/transfer tests could be expanded to ensure that the protocol correctly records deposits and handles unusual ERC20 tokens, such as fee-on-transfer and rebase tokens. If the protocol is intended to support these token types, more comprehensive tests should be included to verify proper functionality.

Further, we identify unnecessary complexity in the Circom circuit and we have recommended ways to make the Circom circuits simpler. Additionally, we identified that there are no tests for the Circom code and we suggest including both positive and negative case for the Circom circuit especially covering all the edge cases.

The Role of Hinkal’s Smart Contracts Owner

The Owner of Hinkal’s smart contracts holds significant capabilities, which introduces potential risks if proper security practices are not followed, or if the Owner’s address (or multisig wallet controlling the Owner role) is compromised. While this risk is acknowledged within Hinkal’s existing threat model, it is crucial to adhere to best practices mitigating the chance of contract control being hijacked.

We have identified a few scenarios where the Owner’s elevated permissions could lead to potential misuse:

  • Bridging: The Owner can modify parameters such as slippage, potentially causing bridging transactions to fail on the destination chain. This could be exploited to invoke the rescueAsset function, allowing the Owner to retrieve stuck funds before users have the chance to recover their assets.
  • Staking: The distribution of staking rewards is managed by a function that the Owner controls, meaning the Owner could assign rewards whenever he wants and whatever amounts he wants. We propose moving forward to decentralize that role or implement its logic on-chain.
  • Censorship: The Owner has the ability to censor users by blacklisting them. This can be done by removing their access token from the Merkle tree, preventing them from interacting with the protocol, even if they have passed the KYC process. The Owner could also deny users access by refusing to provide the necessary signatures.

To address these concerns, we propose the following solutions:

  • Censorship Mitigation: Implement a trustless mechanism for managing KYC through zkEmail or a similar system. In this model, the KYC provider sends the user an email with their address and a certificate confirming their KYC status. The user then submits this proof to the protocol to claim their access token. If the user is later blacklisted, the Owner must provide a ZKP (Zero-Knowledge Proof) of an email from the KYC provider verifying the user’s blacklisted status.
  • Bridging Security: Before allowing the rescueAsset function to execute, enforce a requirement to attempt the recovery of any locked transaction IDs. This ensures that users are given the opportunity to reclaim their assets before the Owner can intervene.
  • Elevated Power Oversight: For operations that grant the Owner elevated privileges, such as unlocking funds, we recommend establishing a security council. This council would be composed of multiple trusted parties, distributing control across a wider group and reducing the risk of a single point of failure.

By implementing these measures, Hinkal can further enhance the security and trustlessness of the protocol, ensuring that the chances of Owner’s powers being exploited are limited.

Basic Concepts and Background

In this section, we will analyze the architecture of the Hinkal protocol and explain some basic concepts and their implementation.

The Hinkal Protocol

The Hinkal Protocol is a privacy-focused protocol that enables users to perform various DeFi interactions, such as deposits, withdrawals, swaps, bridging, and staking, without on-chain traceability. Although transfers are currently disabled, Hinkal is designed to support transfers with a few changes in the code. Note that DeFi interactions are implemented using a set of whitelisted external actions, which are enabled by the contract owner and interact with reputable DeFi protocols.

Hinkal achieves regulatory compliance by requiring users and addresses to undergo a KYC process before interacting with the protocol. Once approved, users generate an access token that is stored in an on-chain, removable Merkle tree, and then they can deposit funds into a shielded address. All subsequent transactions within the Hinkal ecosystem are private, leveraging a large anonymity pool.

Users initiate deposits, swaps, or withdrawals with integrated DEXs through a commitment and nullifier protocol. All operations are based on a UTXO model and are recorded as commitments in an on-chain Merkle tree. Users can then generate a Zero-Knowledge Proof (ZKP) that proves the existence of their commitment within the tree, allowing them to withdraw or swap funds without revealing which specific commitment they own. Nullifiers prevent double-spending by ensuring each commitment is used only once. The ZKPs are generated off-chain, and users can leverage approved relayers to further enhance privacy by using “clean” addresses to receive withdrawals. Note that the output UTXOs and commitments for swaps have to be generated on-chain because the exact token prices are not known beforehand.

Additionally, Hinkal supports cross-chain bridging using Connext, allowing users to bridge tokens or Ether to Hinkal on other blockchains. These cross-chain transactions maintain privacy by involving only shielded addresses that cannot be traced back to the original depositors. To simplify cross-chain interactions, Hinkal supports cross-chain access tokens, eliminating the need for users to repeat the KYC process on each chain.

Hinkal also offers a staking protocol, allowing users to stake tokens in exchange for wrapped hTokens and earn rewards. This staking mechanism benefits the protocol by increasing the size of its anonymity pool, further enhancing user privacy.

Each of these features is described in detail in the following sections.

Merkle Tree and Removable Merkle Tree Implementations

The Merkle tree data structure is a fundamental piece of the protocol: it provides a mechanism to commit to a vector of elements and provide proofs of membership, which are verifiable only by knowing the root hash of the tree. In the protocol, a variant of the standard Merkle tree construction is implemented to handle dynamic insertions and deletions. The tree is assumed to be filled from left to right, with leaf insertions being done at the end of the leaves list.

The interface MerkleBase.sol provides a common interface for Merkle trees data structures. One notable function is rootHashExists(), which is used extensively in the protocol. The Merkle tree data structure keeps the last MAX_ROOT_NUMBER roots (by default this is set to 25) to avoid denial of service by frontrunners, trying to constantly change the Merkle tree root before any transaction is made, invalidating the original transaction.

The Merkle.sol contract provides an implementation of MerkleBase with a write-only dynamic Merkle tree. As such, there is no need to hold the entire tree in memory, and the contract just holds a mapping tree that holds the “right frontier” of node values. The root is computed recursively as such: the hash of one generic internal node is computed as follows - Poseidon(left, right) if the node has both a left and right children in the tree - Poseidon(left, 0) if the node does not have a right child. In this case, the hash is “bubbled up” the tree, hashing with zero.

The MerkleRemovable.sol contract provides another implementation for MerkleBase, but is this case also the deletion operation is supported in the tree. This means that it is no longer possible to store only the right frontier, but the full tree mapping has to be stored with all internal nodes. The tree is stored using a binary-heap style mapping to a vector. In particular - the root is stored at key 0 - given a node i: - its left child is stored at key 2*i - its right child is stored at key 2*i + 1

Leaves are inserted from left to right, starting from key MINIMUM_INDEX, which is the index of the leftmost leaf of the tree. To perform an insertion, the tree is updated from the inserted leaf upwards until it reaches a sufficient depth to store all the nodes, computed as the next integer of the logarithm in base 2 of the number of leaves present.

The diagram below illustrates an example of an insertion operation in this data structure. removable_merkle

Access Tokens and User Authentication for Compliance

To ensure regulatory compliance, the Hinkal Protocol employs a KYC process to verify that only compliant users can interact with the protocol. While deposits are restricted to users who have undergone KYC, withdrawals can be directed to any recipient, regardless of KYC status.

The process begins with the user interacting with an authorized KYC provider to obtain a KYC certificate linked to their address. The user then submits the certificate, along with an access token, to the Hinkal server. The access token is derived by hashing the user’s shieldedPrivateKey and combining it with their PublicKey (which itself is a hash of the shieldedPrivateKey).

The Hinkal server communicates with the KYC provider to validate the certificate’s authenticity. Importantly, mapping an access token to the user’s real-world identity requires cooperation between both the KYC provider and the Hinkal server, adding an extra layer of privacy that could be circumvented if required. This scenario can happen if regulatory authorities require from both the Hinkal server and the KYC provider to reveal the info they store in order to remain compliant. Even in this case, compliant users won’t be aaffected.

Once validation is successful, the Hinkal server signs a message containing the access token, the chain ID, the address of the Hinkal contract responsible for authentication, and a nonce to prevent replay attacks. The user can then interact with the CrossChainAccessToken contract by submitting this signed message, along with the signature data and minting fees. On-chain verification ensures the signature is valid, and it subsequently adds the access token to the Merkle tree.

The CrossChainAccessToken contract supports cross-chain functionality, allowing users to bridge their access tokens across different blockchains. If a user’s access token becomes invalid due to non-compliance, the contract owner has the authority to remove it from the Merkle tree.

After this process is complete, every time the user wants to interact with the Hinkal Protocol, they must provide a zero-knowledge proof (ZKP) that they possess the shieldedPrivateKey corresponding to an access token in the Merkle tree. Since only the root hash of the Merkle tree is given as public input, the access token used in the zero-knowledge proof remains concealed, ensuring privacy.

The diagram below illustrates the process of creating and adding access tokens to the Hinkal Protocol. AccessToken processes

Shielded Addresses

Stealth addresses are a key feature across the Hinkal protocol, primarily used to obscure the link between depositors and their funds. Each stealth address is typically used only once, ensuring privacy by breaking the connection between the depositor and the flow of the depositor’s transactions. The computation of stealth addresses happens off-chain and is derived from a user’s stealth private key (aka shieldedPrivateKey). The algorithm for generating these addresses can be found in StealthAddressCalculator.circom as well as in the protocol’s TypeScript code.

In essence, a stealth address is computed using the user’s private key (i.e., the shieldedPrivateKey) and a public randomization factor, utilizing elliptic curve operations. In addition to their role in creating/spending UTXO commitments (discussed in the next section), stealth addresses are also used by the protocol’s UI to query blockchain events. This allows users to track the assets they own by using the viewing key of the stealth address to identify relevant events emitted on the blockchain.

ZKP UTXOs, Commitments, and Nullifiers

The Hinkal protocol operates on a UTXO (Unspent Transaction Output) model, where each UTXO is structured as follows:

  • erc20Address: The address of the token the UTXO represents.
  • amount: The token amount stored in the UTXO.
  • stealthAddress: The stealth address that owns the UTXO.
  • timestamp: The time the UTXO was created.
  • tokenId: An internal token ID used by Hinkal contracts.

For each UTXO, a cryptographic commitment is created either off-chain or on-chain (for some outgoing UTXOs, such as in swaps). This commitment is essentially a Poseidon hash of the UTXO’s attributes, including the amount, erc20TokenAddress, the public key of the stealth address, and the timestamp.

The nullifier for a commitment is another Poseidon hash, calculated from the nullifier signature and the commitment. The signature itself is the Poseidon hash of the shieldedPrivateKey and the commitment. The nullifier ensures that each UTXO can only be spent once.

When users wish to spend a UTXO, they must generate a zero-knowledge proof (ZKP) demonstrating that they possess the shieldedPrivateKey corresponding to a commitment stored in Hinkal’s Merkle tree. They must also provide the correct nullifier for the commitment. The Circom and Solidity codebases enforce checks/constraints to ensure the correctness of the spent amounts. Additionally, in Solidity, nullifiers are logged in a data structure to prevent double-spending on commitments.

Depositing and withdrawing in Hinkal

Two of the main operations in the Hinkal Protocol are depositing and withdrawing funds. To interact with Hinkal, users must first create and mint an access token. Once this is completed, users generate a Zero-Knowledge Proof (ZKP) off-chain and submit it on-chain through the Hinkal contract using the transact function. The contract performs various checks, including validating the proof, verifying the provided root hashes of both the Hinkal and Access Token Merkle trees, and ensuring that balances across the protocol are accurate.

During this process, the contract nullifies any relevant commitments by tracking the provided nullifiers, ensuring they cannot be reused. It also inserts any newly produced commitments into Hinkal’s Merkle tree. For deposits, the user’s funds are transferred to the Hinkal contract. In the case of withdrawals, the contract pays any fees to the relayer (if used) before transferring the remaining funds to the designated recipient. Additionally, a calldata hash is submitted alongside the ZKP to prevent frontrunning attacks, ensuring that transaction details—such as the relayer—cannot be altered maliciously.

The figures below illustrate the deposit and withdrawal processes in detail.

Deposits

Deposits in Hinkal.

Withdrawals

Withdrawals in Hinkal.

Swapping in Hinkal

The swapping process in Hinkal is similar to deposits and withdrawals, with a few key differences. Instead of using SwapperM1x2x1.circom, users will instantiate the SwapperM circuit with multiple tokens. Additionally, instead of calling the _internalTransact() function, the protocol calls _internalRunExternalAction(), which forwards the swap operation to ExternalActionSwap:runAction() (out of scope for this audit). This function executes the swap and returns the output UTXOs, which cannot be computed off-chain due to the variability of real-time token prices.

Using these UTXOs, the output commitments are then generated on-chain and inserted into Hinkal’s Merkle tree. Unlike deposits and withdrawals, where output commitments are created off-chain during ZKP generation, swap-related commitments are computed directly on-chain after the swap is executed.

Bridging in Hinkal

Cross-chain bridging allows users to transfer funds between instances of the Hinkal contract across the chains where it is deployed. First, the user needs to produce two zero-knowledge proofs: - The first one proves that the user has some funds locked in the source chain in form of UTXOs, and provides nullifier checks for that UTXOs. This proof is produced using one of the swapperM* circuits, like any other external action. - The second one is a proof for ownership of the destination stealth address on the destination chain. This proof is produced using the receiver circuit.

The relayer will initiate a normal external action on the source chain, and the main contract Hinkal.sol will verify ownership of funds, nullify UTXOs, and call the external action in the ConnextAction.sol contract (which implements the IExternalAction interface) using the runAction() function. The relevant data for the external action is stored in externalActionMetadata, which is then cast into an ActionData object.

struct ActionData {
    bool isReceive;
    bool unwrapNative;
    uint32 destination;
    uint slippage;
    uint rootHashHinkal;
    uint rootHashAccessToken;
    InternalProof internalProof;
}

In particular this structure contains the internalProof which is the receiver zero-knowledge proof which will be sent cross-chain and will be verified on the destination chain. The ConnextAction.sol contract acts as the external action contract for both the bridge send and receive transactions. The two flows are distinguished by the isReceive flag in the action data structure.

The ConnextAction.sol contract will, at this point, send a cross-chain invocation using a third-party bridging service called connext. Invocation is done by calling the connext.xcall() function, which will invoke the xReceive() function on the connextRescueBuffer.sol contract deployed on the destination chain.

According to the connext documentation developers should use defensive strategies when implementing the IXReceive interface. Indeed, if a cross-chain receive transaction fails, then funds could get stuck on the receiver contract. The connextRescueBuffer.sol aims to address this issue by providing a safe buffer for funds. The Hinkal owner can re-try the failed transaction with a different callData (i.e., calling rescue) or directly withdraw them (i.e., calling rescueAsset). The rescue buffer keeps a mapping between the transferId provided by Connext and some information about the funds, in particular the amount, asset, and the original call data payload. The contract then calls the transferCall() function: if it succeeds then the mapping is removed, otherwise it is kept and a FailedTransfer event is emitted.

At this point, the rescue buffer contract calls the handleReceive() function in the ConnextAxtion.sol contract (on the destination chain), also transferring the funds. This function basically prepares some input arguments and calls back into the normal Hinkal.sol:transact() transaction flow. The action ID is set to RECEIVE_ONLY_ACTION_ID, and the external action address is set to this (the ConnextAction.sol contract address). Crucially, the inner proof is also passed to the Hinkal contract as the main ZKP to be verified.

The Hinkal contract performs verification of the zero-knowledge proof using the receiver.circom circuit, performs all the standard checks for the transact flow, and calls back the ConnextAction.sol contract as an external action. This call will generate the UTXO and will transfer back the funds from ConnextAction.sol contract to Hinkal.sol contract in the destination chain. The bridge of funds will be completed after Hinkal adds the out commitments in the new chain that are now spendable. Notice that if at any point the reception flow fails (e.g., because the inner proof is not valid), then the funds would still be locked in the rescue buffer.

Below is a diagram of the bridging flow. bridging

Staking in Hinkal

Hinkal’s staking feature is designed to reward users who contribute to the protocol’s anonymity pool. Staking and deposit operations are managed through the HinkalStakeExternalAction contract, utilizing the SwapperME1x2x1.circom circuit. During a deposit, two commitments are generated: one for the standard Hinkal Merkle tree and another for the Hinkal staking Merkle tree. The first is a regular commitment, as previously described, while the second is an unspendable commitment placed in the staking Merkle tree, which cannot be minted.

Similarly, when users stake their assets, they generate an unspendable deposit commitment in the Hinkal Merkle tree and a spendable commitment in the staking Merkle tree. These commitments are indistinguishable from external observers, enhancing the overall anonymity set of the Hinkal protocol.

Upon staking, users can mint hTokens (wrapped tokens) by spending their staking commitments. These hTokens can be used in DeFi protocols like any other token. Additionally, the Hinkal owner has the ability to distribute rewards to hToken holders. Finally, users can unstake their hTokens, converting them back into regular tokens and transferring them to any desired address.

The figures below illustrate all the operations users can perform within the HinkalStakeExternalAction contract.

Deposit and Staking

Minting

Minting