As of 3 Nov 2026, Hinkal has 2 audit reports by 2 security firms on Trustblock; the latest is by Zokyo, dated 20 Feb 2024. Those reports list no open critical findings.
Record last updated . It holds audits, findings and contracts up to Nov 2026, and incidents up to Oct 2026. Trustblock does not review code.
Newest first
| Audit | Security firm | Date | Scope | Critical findings | Report |
|---|---|---|---|---|---|
| Hinkal | Security firm: Zokyo | Date: | Scope: Not recorded (see the report) | No open critical findings | Open · IPFS |
| Hinkal Protocol | Security firm: Quantstamp | Date: | Scope: Files named in the report | No open critical findings | Open · certificate.quantstamp.com |
Statuses are the security firm’s own, from each report.
| Severity | Open | Acknowledged | Fixed |
|---|---|---|---|
| Critical | 0 | 0 | 0 |
| High | 0 | 0 | 4 |
| Medium | 1 | 2 | 5 |
| Low | 0 | 3 | 32 |
The `insert()` and `insertMany()` functions of `Merkle` fail to update the top root in the Merkle tree if all of the leaves in the left subtree are filled, i.e., the entire tree is half-filled. For example, `insertOne()` saves gas by caching node values if they are right-side nodes and only writes values to storage for left-side nodes. The implementation can be seen below: ``` function insertOne( uint256 currentNodeIndex, uint256 twoPower, uint256 prevHash ) internal { for (uint256 i = 0; i <= twoPower; i++) { if (currentNodeIndex % 2 == 0) { tree[i] = prevHash; // Left side - value stored if (i != twoPower) prevHash = hash(prevHash, 0); } else { prevHash = hash(tree[i], prevHash); // Right side - value cached } currentNodeIndex /= 2; } } ``` Once the tree is halfway full, `twoPower` will be large enough such that `currentNodeIndex == 1` for the root. However, a `currentNodeIndex` of `1` will be cached locally to `prevHash` rather than written to storage. The commitment will be accepted from the user, but they will not be able to later prove its existence, making the funds irredeemable. In fact, funds will eventually become irredeemable for all users. The `tree[twoPower]` value will still be zero, and it will be stored as a valid root in the `root[]` mapping. Once the `root[]` mapping contains nothing but zero roots, all commitments would become un-nullifiable, resulting in all funds being stuck in the protocol.
Open the reportThere are two Merkle Tree implementations: `Merkle` and `MerkleRemovable`. Both inherit from `MerkleBase` and make sure that no more leaves can be inserted than the maximum number of leaves in a tree of `LEVELS` levels. The `insert()` function performs the above check with the following guard: ``` require(newIndex != uint256(2) ** LEVELS, "Tree is full."); ``` This has two consequences: 1. The last leaf can never be added to the tree, as `newIndex == uint256(2) ** LEVELS`. 2. The guard does not revert for `newIndex > uint256(2) ** LEVELS`. Once the tree limit is exceeded, a new level of depth under the previous leaves is created to which new leaves are appended. These newly inserted leaves would overwrite their parent node (the previous leaves containing commitments) when propagating the update toward the root. For example, if the `( 2 ** LEVELS ) + 1` element were to (incorrectly) be inserted into the tree, its parent node would suddenly be the leaf of the first commitment ever inserted, as `( ( 2 ** LEVELS ) + 1 ) / 2 = MINIMUM_INDEX`. This element would get overwritten when calculating root updates, invalidating its potentially un-nullified, previously contained commitment. Therefore, subsequent insertions via `insertMany()` (and `insert()` once the tree has overflowed) would continue to overwrite leaves and invalidate their stored commitments.
Open the reportWe were unable to run the test suite with the instructions provided. A deeper look into the test files revealed further concerns. Many of the contracts were being initialized with outdated constructors. The tests for the protocol's core functionality, the merkle tree and Hinkal transactions and swaps, were being skipped entirely.
Open the reportCommitments are intended to be nullified once spent to prevent users from reusing commitments in future transactions. However, it is possible to insert commitments without nullifying previous commitments. This is done by exploiting the construction of the input array to the Circom Verifiers. The duplicated commitments can later be withdrawn. With enough duplicated commitments, the entire protocol can be drained.
Open the reportAfter completing the transactions, `_transact()` loops through the exchanged tokens to compare the changes in the token balance seen to the changes in the token balance expected by the provided proof. It then checks to confirm that the contract has not distributed more tokens than expected: ``` require( balanceDif >= circomData.amountChanges[i], "Inbalance in token detected" ); ``` This creates an imbalance where the contract can keep more tokens than expected. Though this might be in place due to unforeseen transfer amounts from integrations with external protocols, this still allows for incomplete withdrawals.
Open the reportWhen calling a `transact()` function on the `Hinkal` contract, a parameter `encryptedOutputs` is given and logged in the `NewCommitment()` event. According to the documentation and code comments, `encryptedOutputs` are _"Output UTXOs encrypted with encryption key of the user making call"_. Hinkal users fetch the event logs and decrypt the `encryptedOutputs` using the private key from their shielded addresses to retrieve their UTXOs. However, since no integrity check of `encryptedOutputs` exists in the Solidity contracts or Circom circuits, a malicious relayer or a front-runner can arbitrarily change its value to alter the decrypted output to some random bytes. The only way they could find the matching commitments, in this case, would be by brute-forcing the `blindings` value _for each leaf in the Merkle tree added since making the request_. This may not be possible should the tree reach a large enough size. As a result, the user will not be able to retrieve their UTXOs correctly, which may lead to loss of funds. An attacker does not gain anything from tampering `encryptedOutputs`. As relayers are whitelisted and maintained by the client on launch, the risk is further mitigated. However, the ability of a relayer to significantly sabotage the protocol is nonetheless significant, and the vulnerability still exists for front-runners.
Open the reportRelayer fees are only enforced on-chain for token withdrawals. For external protocol interactions and internal transfers, the relayer earns fees based on the difference between unlocked value from nullified commitments and locked value from newly created commitments. The fee is therefore implicit and especially for external protocol interactions not trivial to calculate. The client clarified that off-chain price oracles are used to calculate the fees and the appropriate `amountChanged` values for the tokens involved in the trades. However, there is little on-chain protection for end users in these settings. If the price source returns incorrect values or the front-end gets compromised, relayers could profit greatly through favorable `amountChanged` values.
Open the reportWhen a token withdrawal is submitted to Hinkal through a relayer, relayers can receive a fee depending on the token transferred and the amount transferred. However, the formula to calculate these fees in `_internalTransact()` is incorrect due to the usage of parenthesis: ``` relayFee = uint256(-circomData.amountChanges[i]) * (relayPercentage / 10000); ``` Since the allowed range of values for the variable `relayPercentage` is `[0:500]`, the amount will always be multiplied by `0`, resulting in the `relayFee` taking the value `0`.
Open the reportThe function `recoverSigner()` uses the precompiled `ecrecover()` without adding checks to prevent its underlying risks. It makes it vulnerable to two common issues: 1. The `address(0)` is returned as the default error value. 2. Signature malleability. If the owner of the contract is renounced for any reason, then all calls to `addToken()` will pass `require(signer == owner())`, thereby allowing anyone to acquire an access token. This is because the `recoverSigner()` function will return `0` if the signer address cannot be recovered. Regarding signature malleability, it is a characteristic of the ECDSA (Elliptic Curve Digital Signature Algorithm) cryptographic algorithm used in Ethereum and Solidity. It refers to the ability to produce different valid signatures for the same message by modifying the signature parameters. This can occur due to certain mathematical properties of the ECDSA algorithm. In the context of Solidity, signature malleability can have security implications, particularly when signatures are used for authentication, verification, or as proof of authorization. Exploiting signature malleability can potentially lead to unintended behaviors or vulnerabilities in smart contracts. One common form of signature malleability in Solidity's ECDSA is the "s-value malleability". In an ECDSA signature, there are three components: the "r", "s" and the "v". The "s" value is the one susceptible to malleability. Any modification to the "s" value while keeping the signature valid can result in a different but still valid signature.
Open the reportThe report gives no description here.
Open the reportThe report gives no description here.
Open the reportThe report gives no description here.
Open the reportThe report gives no description here.
Open the reportThe report gives no description here.
Open the reportThe report gives no description here.
Open the reportThe report gives no description here.
Open the reportThe report gives no description here.
Open the reportThe report gives no description here.
Open the reportThe `Transferer.multiTransferFrom()` function calls `transferERC20TokenFromOrCheckETH()` in a loop to transfer ERC-20 tokens from the caller or check an exact value of ETH is provided in the function call by `msg.value`. However, since `msg.value` is preserved in the entire function call, an attacker can provide multiple `address(0)` (represented as ETH) in the `erc20TokenAddresses` array and pass the checks but provide less `msg.value` than needed. Contracts using the `transferERC20TokenFromOrCheckETH()` may result in incorrect internal accounting or a shortage of funds. Though the function is not used in the current system, if it is improperly used in a future version of the system, it could lead to the contract being drained from its native tokens.
Open the reportIn order to validate the proper deployment and initialization of the contracts, it is a good practice to emit events. Also, any important state transitions can be logged, which is beneficial for monitoring the contract, and also tracking eventual bugs or hacks. Below we present a non-exhaustive list of events that could be emitted to improve application management: * `AccessToken.withdraw()` * `ERC20TokenRegistry.changeState()` * `ERC20TokenRegistry.addERCToken()` * `ERC20TokenRegistry.removeToken()` * `ERC20TokenRegistry.setTokenLimit()` * `Merkle.insert()` * `Merkle.insertMany()` * `MerkleRemovable.findAndRemove()` * `MerkleRemovable.insert()` * `MerkleRemovable.cleanRoots()`
Open the reportAccording to [Circom's documentation](https://docs.circom.io/circom-language/basic-operators/#arithmetic-operators), the arithmetic operations in the circuits are calculated modulo `CIRCOM_P`. The swapper circuits (`SwapperM`, `SwapperMO`, and `SwapperToken`) calculate the input amount and output amount sums for each token and check whether the difference is the expected amount change. Because the addition operations work modulo `CIRCOM_P`, it is possible for the result to overflow when adding up the sum of input or output amounts. As a result, an attacker could forge some output commitments with a large token amount but whose sum results in a small number. They can then drain the funds in the protocol with the forged commitments. This attack does not exist for the currently implemented Swapper circuits, but it may be introduced with new circuits if they use vulnerable constructor arguments.
Open the reportContracts inherit the contract `Ownable`, where the owner role can be transferred to another address simply by calling the function `transferOwnership()`. This function immediately transfers a high-level privilege to new addresses in a single transaction, which can be risky from a security perspective, as providing a faulty address may lock out that role from future calls. A more secure pattern for such privilege transfers is to require the new pending addresses to issue an `acceptAdmin()` function call before finalizing the transfer. Note that this pattern is common even with the use of timelocks, such as the Compound Timelock contract.
Open the reportThe deletion of an access token via the `AccessToken.blacklistAccessKey()` function removes a leaf node from the `MerkleRemovable` tree, requiring to update of the values for all parent nodes, including the root. As a necessary measure, all the cached roots need to get removed to assure that the blacklisted access key can no longer use the protocol. However, this will cause all pending Hinkal transactions by legitimate users to fail, as the `HinkalHelper.performHinkalChecks()` will revert, as the `rootHashAccessToken` those transactions used to construct the proofs are no longer valid.
Open the reportThe purpose of the contract `Hinkal` is to receive deposits from external actors. Tokens should be deposited into the contract by whitelisted actors via a specific flow and the execution of the function `Hinkal._transact()`. However, it is also possible to send these tokens via a direct token transfer. If the logic of the contract is not clear for an external user of the system, they may deposit tokens via a direct transfer. In that case, the amount would not be accounted for by the contract and remain stuck in the contract.
Open the reportWhen a transaction is submitted to Hinkal, the function `HinkalHelper.relayerIsValid()` requires that the relayer is whitelisted. However, the function checks the value against `tx.origin`. This has two consequences: 1. A relayer cannot be a smart contract. 2. If the transactions initiated by a given relayer are not restricted to relaying Hinkal transactions, the relayer can initiate a transaction that contains the execution of a malicious contract. In this case, the malicious contract can trigger an external call to `Hinkal.transact()` with the address of the relayer in `circomData`. The impact is limited here because the rest of the data in `circomData` should be accepted by the system and may likely result in a reverted transaction.
Open the reportThe system supports transfers of `ERC20` and `ERC721` tokens. Several contracts distinguish if the expected token to be transferred is an `ERC20` or `ERC721` based on the parameter `tokenId`: * `== 0` will be considered as an `ERC20` transfer. * `> 0` will be considered as an `ERC721` transfer of that specific `tokenId`. However, as mentioned in the `ERC721` [standard](https://eips.ethereum.org/EIPS/eip-721), a `tokenId` of value `0` would be valid: > Every NFT is identified by a unique uint256 ID inside the ERC721 smart contract. This identifying number SHALL NOT change for the life of the contract. The pair (contract address, uint256 tokenId) will then be a globally unique and fully qualified identifier for a specific asset on an Ethereum chain. While some ERC-721 smart contracts may find it convenient to start with ID 0 and simply increment by one for each new NFT, callers SHALL NOT assume that ID numbers have any specific pattern to them, and MUST treat the ID as a “black box”. As a result, any attempt to operate with an `ERC721` token having the id `0` may revert or result in an unexpected output since the token will be incorrectly considered as an `ERC20` instead of an `ERC721`. Impacted functions include: * `CircomDataBuilder.formInputForCircom()` where the function `formInputNormal()` will be called instead of the function `formInputNft()`. * `Hinkal._internalTransact()` where the function `transferERC20TokenOrETH()` will be called to pay a relayer fee and will most likely revert. * `Transferer.getBalancesForArray()` and `Transferer.getBalancesForArrayMemory()`, where the function `getERC20OrETHBalance()` will be called instead of `getNftBalance()`. * `Transferer.transferTokenFrom()`, where the function `transferERC20TokenFromOrCheckETH()` will be called instead of `transferNftFrom()`. * `Transferer.transferToken()`, where the function `transferERC20TokenOrETH()` will be called instead of `transferNftFrom()`. * `Transferer.approveToken()`, where the function `unsafeApproveERC20Token()` will be called instead of `approveERC721Token()`.
Open the reportThe Hinkal protocol implements a whitelist mechanism for users, where users have to obtain an access token via the KYC process and add the token to the registry before using the protocol. Access keys are calculated by `hash(shieldedPrivateKey, publicKey)` and are stored as the leaves of the `AccessToken` Merkle tree. The `AccessTokenChecker` circuit is to ensure that (1) a user's access key is in the Merkle tree and (2) they know some `X` and `Y` such that `hash(X, Y)` equals to their access key. Since the entire `AccessToken` Merkle tree is public on-chain, an attacker can generate a ZK proof to prove an internal node of the Merkle tree is in the tree by setting `X` and `Y` to its left and right child. The condition of `hash(X, Y)` being the internal node holds naturally. The attacker does not have to obtain an access token in the first place to generate a valid proof. Notice that this issue may not currently impact the protocol given that the swapper circuits calculate a user's `publicKey` as the hash of their `shieldedPrivateKey`. However, the design of the `AccessTokenChecker` circuit poses a security risk and could be vulnerable if used separately.
Open the reportIn addition to native tokens, the protocol also enables arbitrary ERC-20 tokens to be interacted with in the protocol. The protocol includes an `ERC20TokenRegistry` contract that can theoretically maintain a list of whitelisted ERC-20 tokens, but the auditors were informed that this contract will be disabled and not used. Enabling the support for arbitrary ERC-20 tokens is always a challenge. Malicious tokens can break assumptions about standard functions such as `balanceOf()` and others. It could grant certain addresses infinite approval to transfer tokens back from Hinkal, make them non-withdrawable from the protocol, and many other things. For such malicious token implementations, the UTXOs within Hinkal, or in other words the commitments in the Merkle tree, would not necessarily match the balance of Hinkal. While these tokens most likely would not be of any value, we still wanted to raise this as an informational issue.
Open the reportIt is important to validate inputs, even if they only come from trusted addresses, to avoid human error: * `AccessToken.constructor()`: Set a maximum limit for the value of `_mintingFee`. * `AccessToken.setMintingFee()`: Set a maximum limit for the value of `_mintingFee`. * `ERC20TokenRegistry.contructor()`: Validate that `intialTokenSet[]` doesn't contain the zero address. * `ERC20TokenRegistry.addERCToken()`: Validate that `erc20Token` is not the zero address. * `Hinkal.registerExternalAction()`: Validate that `externalActionId` and `externalActionAddress` are both non-zero. * `Hinkal.transactWithExternalActionAndHook()`: Validate that `circomData.externalAddress` is not the zero address. * `HinkalHelper.checkTokenRegistry()`: Validate that the input arrays are the same length. * `HinkalHelper.performHinkalChecks()`: * Validate that `circomData.erc20TokenAddresses.length == circomData.amountChanges.length == circomData.tokenIds.length`. * Validate that for all `i` with `circomData.tokenIds[i] != 0`that `amountChanged[i]` is equal to one or minus one. * `RelayStore.addOrSetRelay()`: * Validate that the `_relayAddress` is not the zero address. * Validate that the `_url` is non-empty. * `Transferer.multiTransfer()`: Validate that the input arrays are the same length. * `Transferer.multiTransferFrom()`: Validate that the input arrays are the same length. * `Transferer.getBalancesForArrayMemory()`: Validate that the input arrays are the same length. * `Transferer.getBalancesForArray()`: Validate that the input arrays are the same length. * `Transferer`: All transfer functions should require transferring a non-zero amount. * `Merkle.insertMany()`: Validate that the input array `leaves` is non-empty. Otherwise, it could result in a copy of the value `roots[rootIndex]` in `roots[(rootIndex+1) % MAX_ROOT_NUMBER ]`. This could ultimately become a way to limit partially DoS the system by making sure that the history of the last `MAX_ROOT_NUMBER` valid roots contains the same value. * `MerkleBase.constructor()`: * Validate that `poseidon` is a non-zero address. * Valdiate that `LEVELS` is initialized to be less than 128. * `MerkleBase.rootHashExists()`: Validate that `_root` is non-zero. * `VerifierFacade.registerVerifier()`: * Validate that `verifierMap[verifierId] == 0` before the update. * Validate that `verifierAddress` and `verifierId` are both non-zero. <br> **Circom Circuits:** There are no guarantees that the input values to parameters like addresses are valid. For example, if the `SwapperToken` circuit is used and the wrong `nftPosition` is specified, some NFTs may be treated as fungible tokens. Update `P` and check that variables for all fields have valid integer interpretations.
Open the reportHinkal transactions can optionally be executed with: 1. A pre-transaction hook which calls a contract at the address `circomData.preHookContract` with the calldata `circomData.hookMetadata`. 2. A post-transaction hook which calls a contract at the address `circomData.hookContract` with the calldata `circomData.hookMetadata`. If a user wants to execute both a pre-transaction hook and a post-transaction hook in the same transaction, the hooks will have to use the same `circomData.hookMetadata`. As a result, specific customization will be required on each of these contracts to parse the received calldata and cover both cases: * The contract is called without the other hook. * The contract is called in addition to the other hook.
Open the reportIf the owner renounces their ownership, all ownable contracts will be left without an owner. Consequently, any function guarded by the `onlyOwner` modifier will no longer be able to be executed. This is especially important to note for the `AccessToken` contract, as an `owner` of `address(0)` would allow anyone to add an access token.
Open the reportThe `RelayStore.removeRelay()` function iterates the `relayStore` array to find a specified relayer address and removes it from the array. However, due to a coding error, the functions may pop excessive elements from the array, therefore removing other relayer addresses. Additionally, the current design faces gas concerns, as updates and removals require the traversal of potentially the entire array. With a sufficiently high number of relayers, block gas limit could be reached, resulting in relayers added very late to be unremovable or updateable.
Open the reportThe `Merkle` contract inherits from the `MerkleBase` and enables immutably inserting commitments very similarly to Tornado Cash. However, the `getRootHash()` function inherited from `MerkleBase` seems to have been designed only with the `MerkleRemovable` contract in mind, which stores all the elements of the tree on-chain in the `tree` mapping, compared to `MerkleBase`, which only uses the `tree` mapping as a caching mechanism to keep track of the right-most, non-zero, left element of each level. While `tree[1]` is intended to be the root element in `MerkleBase` and `MerkleRemovable`, it is the right-most, non-zero, left element of the first level, i.e. the level right above the leaves, in the `Merkle` contract. This index stores the hash of the two most recently added leaves to the tree. An attacker can make the tree seem to observers to have a particular root hash by adding two commitments in a transaction. Ultimately, `Merkle.getRootHash()` will not return the root hash. This breaks any current or future integrations that might use the function.
Open the reportThe system can remain without any relayer if the last relayer is removed via the function `removeRelay()`. Transactions would temporarily remain unrelayable until a new valid relayer is added via `addOrSetRelay()`.
Open the reportThe code relies on function `SafeERC20.safeApprove()`. The function shall not be used instead of `approve()` since: 1. It can still be front-run. 2. It uses additional gas. 3. It does not work with tokens that are not ERC-20 compliant. For more information see [this issue](https://github.com/OpenZeppelin/openzeppelin-contracts/issues/2219).
Open the reportThe `AccessToken` contract contains a registering and blacklisting functionality that remains unclear. According to a message from the client, the `register()` function seems to have been used at one point in time to create a link public between an EOA and a hashed address. This link is however only emitted via events and can technically be called from any address by other users. A call to the `register()` function has no on-chain consequences. We did not receive clarification on the purpose of the blacklist, as it seems to have no on-chain consequences other than preventing access token signature submissions by a particular address, but the same signature can be submitted from another address without any issues.
Open the reportSolidity contract files can be explicitly licensed using the `SPDX-License-Identifier` tag at the beginning of the contract. While contracts can be licensed with a wide variety of license types and constraints, the contracts in the scope of this audit are tagged as `UNLICENSED`, allowing unconstrained reproduction, modification, and use of these contracts.
Open the reportEvery Solidity file specifies in the header a version number of the format `pragma solidity (^)0.8.*`. The caret (`^`) before the version number implies an unlocked pragma, meaning that the compiler will use the specified version _and above_, hence the term "unlocked".
Open the reportThe `MerkleBase.logarithm2()` function is not a standard implementation of the underlying math. More battle-tested implementations should be used instead.
Open the reportRebasing tokens could be problematic for the protocol, as users might not be able to consume the entire value a commitment should technically hold, leading to strange errors. If the balance of a token inside Hinkal drops without a Hinkal transaction (e.g. due to a rebase or a confiscation burn), the users will be able to withdraw their balances on a first-come first-served basis, as long as the protocol has some balance left. When the tokens run out, the users will not be able to withdraw. Users should be discouraged from using rebasing tokens in the protocol.
Open the reportSome tokens, such as USDC, have a blacklisting feature. If the protocol is ever blacklisted from interacting with a token, all user deposits would freeze for that token, as users would be unable to withdraw it and interact with it from external protocols.
Open the reportThe reports name a repository or files. Open a report to see its scope.
Is this your project? Show this record on your own website. The label states what the record holds, links to this page and updates by itself. It is free and needs no sign-in.
Automatic: the three strongest facts of this project’s own record (its last audit and two more), highlighted. They update with the record. Picking a fact turns Automatic off.
<head><script src="https://widget.trustblock.run/project-widget.min.js" defer></script></head><body><divdata-tb-project-widgetdata-tb-project-slug="hinkal"></div></body>
Paste the code where the label should appear. It uses no API credit and updates by itself.
Something wrong on this page? A missing report, a wrong date, a finding that was fixed since. Tell us and we check it against its source.
Tell us