Back to Blog

~$800K Lost: Hinkal Double-Spend | BlockSec Weekly

Code Auditing
July 9, 2026
11 min read
Key Insights

During the past week (2026/06/29 - 2026/07/05), the following notable security incident is featured, involving approximately $800K in losses on Ethereum.

Date Incident Type Estimated Loss
2026/07/02 Hinkal Business Logic Flaw ~$800K
  • Hinkal: A double-spend attack on a shielded-pool protocol, likely exploiting a legacy note format flaw that allowed the same deposit to produce multiple valid nullifiers.

Best Security Auditor for Web3

Validate design, code, and business logic before launch

Weekly Highlight: Hinkal

In this incident, a double-spend in a shielded pool was likely enabled by a legacy note format flaw. Solvency in nullifier-based privacy protocols depends on circuit-level nullifier binding, not just on the contract accepting valid proofs.

On July 2, 2026, Hinkal, a shielded-pool protocol on Ethereum, was drained for approximately $800K in assets through a double-spend attack [1]. The likely root cause is a flaw in the legacy note format, where a single note is not tightly bound to a unique nullifier, allowing one deposit to be spent multiple times. The project has not open-sourced its circuit implementation, and the team has not yet published a detailed technical breakdown. The analysis below is based on public documentation, de-obfuscated client code, and on-chain observations.

Background

Protocol Overview

Hinkal is a shielded-pool protocol. Balances are stored as notes, represented by commitments in an on-chain Merkle tree rather than plain account balances.

To spend a note, the user posts a zk proof and a nullifier. The contract records each nullifier and rejects any repeat. The rule that one note can only produce one nullifier is not enforced by the contract; it is delegated to the circuit.

The diagram below shows the deposit and withdrawal flow:

     Note format (client)          |       On-chain path
                                   |
  +--------------------------+     |     +-------------------------------+
  | New format (default):    |     |     | transact() [with proof]       |
  | nk directly in Poseidon6 |     |     | all ops: deposit / transfer / |
  | -> 1 commit : 1 null     |     |     |           withdrawal          |
  +--------------------------+     |     +-------------------------------+
                               --> | -->
  +--------------------------+     |     
  | Legacy format:           |     |     +-------------------------------+
  | nk only via product e*nk |     |     | prooflessDeposit() [no proof] |
  | -> may permit multiple   |     |     | deposit only                  |
  |    nullifiers per commit |     |     +-------------------------------+
  +--------------------------+     |   
                                   |     For deposits, both paths 
                                   |         -> note enters Merkle tree

Note Formats

The actual circuit implementation has not been open-sourced. The following analysis is based on Hinkal's public circuit design documentation [2] and the de-obfuscated client prover code [3].

The documentation and client code suggest two ways to build a note's stealthAddress, selected by an isNewStyle flag:

  • legacy: H0 = e*Base8, H1 = (e*nk)*Base8, stealthAddress = Poseidon3(signs, H0y, H1y)

  • new: H1 = nk*H0, stealthAddress = Poseidon6(signs, H0y, H1y, spk0, spk1, nk)

Poseidon2, Poseidon3, Poseidon6 are all instances of the Poseidon hash function; the suffix denotes the number of inputs.

Here e is a random number, nk is the secret nullifying key, and Base8 is a fixed point. Since H0 and H1 are points on the Baby Jubjub elliptic curve, each has an x-coordinate and a y-coordinate. The signs value packs the sign bits of their x-coordinates H0x and H1x (2*L(H0x) + L(H1x)). The legacy stealthAddress includes nk only through the product e*nk, not the spending key (spk0, spk1) directly, while the new format puts nk, spk0, and spk1 all into Poseidon6.

The nullifier is computed directly from nk: nullifier = Poseidon2(commitment, Poseidon2(nk, commitment)).

The relevant functions, de-obfuscated from zkProofWorkerNode.js (S = Poseidon, Base8 = fixed generator, e = randomization, t = nk):

// legacy
static getRandomizedStealthPairOld = (e, t) => {
  const a  = e * (t % B) % B;            // a = e*nk
  const H0 = mulPointEscalar(Base8, e);  // H0 = e*Base8
  const H1 = mulPointEscalar(Base8, a);  // H1 = (e*nk)*Base8 (nk only via the product)
  return { H0, H1 };
};

// new
static getRandomizedStealthPair = (e, t) => {
  const a  = t % B;                      // a = nk
  const H0 = mulPointEscalar(Base8, e);  // H0 = e*Base8
  const H1 = mulPointEscalar(H0, a);     // H1 = nk*H0 (nk bound to the note point)
  return { H0, H1 };
};

// nullifier is derived from nk directly
getNullifier() {
  const c   = this.getCommitment();
  const sig = S(this.nullifyingKey, c);  // Poseidon2(nk, commitment)
  this.nullifier = S(c, sig);            // Poseidon2(commitment, sig)
}

This isNewStyle flag is stored in the top bit of a field called extraRandomization in the note's stealthAddressStructure. The client code packs it as extraRandomization = (isNewStyle << 255) | H0x, and the contract splits it back with getPointSign(H) = H / 2**255 and getPointY(H) = H % 2**255 when decoding the packed value. The code comment reads "strip the isNewStyle flag (bit 255) so the circuit gets the clean H0x coordinate". Therefore, the top bit can be fetched via getPointSign(extraRandomization) to determine the note type.

The following snippet from CircomDataBuilder.sol:formBasicInput() shows this unpacking:

// CircomDataBuilder.sol:formBasicInput()
    ...
    // strip the isNewStyle flag (bit 255) so the circuit gets the clean H0x coordinate
    input[index++] = getPointY(
        circomData.stealthAddressStructure.extraRandomization
    ); // = H0x
    input[index++] = circomData.stealthAddressStructure.H0; // = H0y
    ...

In the deposit-building client code, this isNewStyle flag is set to true by default, so normal user flows appear to never create a legacy note.

// hinkalDeposit for self
  // Self output UTXO
  //@hinkal/common/common/src/functions/pre-transaction/outputUtxoProcessing.mjs
	let m = [new r({
		amount: e(d + o, 0n),
		erc20TokenAddress: f,
		mintAddress: p,
		nullifyingKey: i.getShieldedPrivateKey(),
		timeStamp: s,
		spendingPublicKey: i.getSpendingKeyPair().pubSpendingBJJPoint,
		isNewStyle: !0 // equivalent to isNewStyle: true
	})];

// hinkalDeposit for other
  // @hinkal/common/common/src/data-structures/Hinkal/hinkalDeposit.mjs
    w = h.map((e, t) => [new s({
		amount: o[t],
		erc20TokenAddress: e,
		H0: [BigInt(_), BigInt(y)],
		stealthAddress: g,
		encryptionKey: b,
		isNewStyle: !0 // equivalent to isNewStyle: true
	})])

A legacy-style deposit with the flag set to 0 is therefore not something produced by a normal user flow.

Deposit and Withdrawal Paths

transact() is the universal proof-checked entry point for all operations: deposits, private transfers, and withdrawals. The client generates a zk proof off-chain, and transact() verifies it, checks the Merkle root, then writes nullifiers and commitments.

prooflessDeposit() is a separate on-chain function that bypasses transact() entirely. It accepts tokens and builds the commitment directly from caller-specified data, without requiring a proof.

    function prooflessDeposit(
        address[] calldata erc20Addresses,
        uint256[] calldata amounts,
        uint256[] calldata tokenIds,
        StealthAddressStructure[] calldata stealthAddressStructures
    ) public payable nonReentrant {
        hinkalHelper.performProoflessDepositChecks(
            erc20Addresses,
            amounts,
            tokenIds,
            stealthAddressStructures
        );

        bytes memory returnData = address(hinkalInLogic).functionDelegateCall(
            abi.encodeWithSelector(
                hinkalInLogic.handleProoflessDeposit.selector,
                erc20Addresses,
                amounts,
                tokenIds,
                stealthAddressStructures
            )
        );

        UTXO[] memory utxoSet = abi.decode(returnData, (UTXO[]));
        uint256 length = utxoSet.length;

        OnChainCommitment[] memory commitmentArray = new OnChainCommitment[](
            length
        );

        for (uint256 i = 0; i < length; i++) {
            commitmentArray[i] = createCommitment(utxoSet[i]);
        }

        insertCommitments(
            new uint256[][](0),
            new bytes[][](0),
            commitmentArray,
            new bool[](0)
        );
    }

For ERC-20 notes, a commitment is derived from amount, token, stealthAddress, and timestamp. The stealthAddress comes directly from the caller.

      commitment = hash4(
          utxo.amount,
          uint256(uint160(utxo.erc20Address)),
          utxo.stealthAddressStructure.stealthAddress,
          utxo.timeStamp
      );

Vulnerability Analysis

This analysis is an inference from on-chain behavior and the de-obfuscated client code. The actual circuit implementation has not been open-sourced, and the root cause is to be confirmed.

The affected contract is Hinkal (0x25e5...a826).

The likely defect. As described in the Background, the legacy format includes nk only through the product e*nk, while the nullifier is derived from nk directly. If the legacy spend circuit does not bind nk uniquely to the commitment, then a different (e, nk) pair can preserve the same product e*nk (and thus the same commitment) while changing nk, producing a fresh nullifier for the same leaf each time. The new format puts nk directly into Poseidon6, which would pin one nk per commitment. To the Hinkal contract, every withdrawal looks valid: the proof passes, the root exists, and each nullifier is new, so insertNullifiers() accepts it. The contract cannot link a nullifier to a leaf, so it settles each spend as a normal withdrawal. The bug is not in the on-chain checks but likely in what the legacy proof circuit is allowed to prove.

prooflessDeposit() and the attack surface. The prooflessDeposit() function delegates to performProoflessDepositChecks(), but no format validation is observable in the on-chain behavior: the function does not appear to reject legacy-format notes, nor does the contract use its return value. This allows a legacy note to enter the Merkle tree without any format restriction, opening the attack surface for the defect described above.

Attack Analysis

The following analysis is based on the historical transactions of the affected Hinkal contract. The attacker targeted multiple asset types (USDC, ETH, etc.); the USDC flow is used here as an example.

  • Step 1: The attacker deposited 100 USDC via prooflessDeposit() in the transaction 0xfbedf0...8c2f11. The extraRandomization of this deposit has its top bit set to 0 (getPointSign = 0), indicating the note uses the legacy format.

  • Step 2: The attacker called transact() repeatedly against this 100 USDC legacy note, each time with the same Merkle root but a fresh nullifier, withdrawing 100 USDC per call. This is the double-spend: the same note was spent many times, accumulating approximately 25,000 USDC.

  • Step 3: The attacker consolidated all 25,000 USDC into a single legacy note via another prooflessDeposit() call in the transaction 0xbf7252...d50008. By consolidating into a larger note, each subsequent double-spend withdrawal would yield 25,000 USDC instead of 100.

  • Step 4: The attacker repeated the same process against the 25,000 USDC note, calling transact() with a fresh nullifier each time. After the deposit, the attacker never supplied more assets, yet withdrew far more than the 25,000 USDC deposited.

In total, approximately $800K in assets was drained across all transact() invocations.

Conclusion

The Hinkal contract accepted individually valid proofs, yet a single note was most likely redeemed many times due to a flaw in the legacy note format. The contract's on-chain checks (proof verification, nullifier uniqueness) all passed, but they could not detect that the same note was producing multiple valid nullifiers. The team has since paused all Hinkal smart contracts across all chains and committed to reimbursing all affected users in full [1]. For Hinkal specifically, mitigations include disabling the legacy note format path entirely, enforcing note-format validation in prooflessDeposit() (e.g., rejecting notes with isNewStyle == 0), and conducting a dedicated circuit audit to confirm one-to-one nullifier binding.

For nullifier-based privacy protocols in general, the invariant is the same: each note must map to exactly one nullifier through a single well-defined derivation. That binding must be enforced at the circuit level, since the contract can only check that a nullifier has not been seen before, not that it is the only valid nullifier for a given note.

Get Started with Phalcon Explorer

Dive into Transactions to Act Wisely

Try now for free

References

[1] Hinkal Protocol post-incident update: https://x.com/hinkal_protocol/status/2073136163880149417

[2] Circuit design documentation: https://hinkal-team.gitbook.io/hinkal/technical-description/circuits/swapper-m#id-3.-correct-nullifiers-per-input

[3] Client-side code: https://github.com/Hinkal-Protocol/Hinkal-API-Enclave ](https://github.com/Hinkal-Protocol/Hinkal-API-Enclave)

Get Started with Phalcon Security

Detect every threat, alert what matters, and block attacks.

Try now for free
Sign up for the latest updates
Newsletter - June 2026
Security Insights

Newsletter - June 2026

This monthly report covers the three largest security incidents in June 2026, totaling approximately $22M in confirmed losses. A sophisticated honeypot attack drained ~$15M from JaredFromSubway's MEV bot by exploiting unchecked token allowances. Two legacy Aztec rollup deployments lost ~$4.35M through proof-settlement boundary gaps. SecondFi's Ed25519 implementation flaw exposed wallet private keys, resulting in ~$2.4M drained from 374 wallets. All three incidents share a common pattern: security guarantees that appeared intact on the surface but were never actually enforced.

~$4.1M Lost: Taiko, SecondFi Exploits | BlockSec Weekly
Security Insights

~$4.1M Lost: Taiko, SecondFi Exploits | BlockSec Weekly

This weekly blockchain security report covers two notable incidents from June 22-28, 2026, with approximately $4.1M in confirmed losses across Ethereum and Cardano. The Taiko bridge exploit combined an exposed SGX enclave signing key with an incomplete attestation policy that failed to reject debug enclaves, allowing the attacker to register a malicious prover and forge L2 state proofs on Ethereum. The SecondFi wallet vulnerability stemmed from a cryptographic implementation flaw in Ed25519 nonce derivation that removed the secret input, enabling offline private key recovery from public Cardano transaction data.

~$18M Lost: jaredFromSubway, Aztec & More | BlockSec Weekly
Security Insights

~$18M Lost: jaredFromSubway, Aztec & More | BlockSec Weekly

This weekly blockchain security report covers June 15 to June 21, 2026, with 3 notable incidents across Ethereum and BNB Chain totaling approximately $18.3M in losses. Two incidents are analyzed in detail. Based on on-chain analysis, the highlighted jaredFromSubway incident reveals a reversed approval attack pattern: unlike traditional exploits where attackers abuse vulnerabilities in trusted DeFi contracts to drain user-approved assets, this MEV bot proactively approved its own assets to untrusted third-party contracts for arbitrage. The attacker constructed fake wrapper tokens and swap pools that emitted real events but never consumed the granted allowances, with reported total losses of ~$15M. The report also covers Aztec's second exploit in three days, where a missing equality constraint between two witnesses for `old_data_root` in the escape hatch ZK circuit allowed the attacker to prove ownership of fabricated notes against a fake Merkle tree while passing on-chain root validation.

Best Security Auditor for Web3

Validate design, code, and business logic before launch. Aligned with the highest industry security standards.

BlockSec Audit