Back to Blog

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

Code Auditing
July 9, 2026
11 min read
Key Insights
  • 1 notable security incident on Ethereum is featured in this report, involving approximately $800K in losses.

  • Hinkal, a shielded-pool protocol, was drained for ~$800K through a double-spend attack: a flaw in the legacy note format likely allowed deriving multiple nullifiers from a single deposit.

  • For nullifier-based privacy protocols, solvency depends on circuit-level binding between notes and nullifiers, not solely on the contract verifying valid proofs.

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
~$1.6M Lost: Moke Token, LpdFi Exploits | BlockSec Weekly
Security Insights

~$1.6M Lost: Moke Token, LpdFi Exploits | BlockSec Weekly

During the week of August 3-9, 2026, 2 notable security incidents on BNB Chain resulted in approximately $1.6M in total losses, both from price manipulation. The highlighted LpdFi incident (~$697K) reused the same manipulable PancakeSwap pair reserves for both order valuation and interest redemption, letting the attacker inflate a position's principal and reshape the pool to redeem an oversized interest claim. Moke Token (~$906K) combined a manipulable spot price with duplicated LP dividend accounting to claim inflated MOKE and collect the resulting BNB dividends multiple times.

COLDCARD Incident: When a Wallet's "Random" Seed Wasn't Random
Security Insights

COLDCARD Incident: When a Wallet's "Random" Seed Wasn't Random

A silent build-and-integration bug in COLDCARD firmware routed Bitcoin seed generation onto a software RNG fallback, whose weak randomness left wallet seeds recoverable offline. Because the weakness is in the seed itself, a firmware update cannot undo it; verified sweeps reached 1,405 BTC (~$91M) by 7 August 2026, with private-channel estimates as high as 2,055 BTC.

~$88M Lost: COLDCARD & LULA Exploits | BlockSec Weekly
Security Insights

~$88M Lost: COLDCARD & LULA Exploits | BlockSec Weekly

During the week of July 27 to August 2, 2026, two notable security incidents caused roughly $88M in losses across Bitcoin and BNB Chain. The highlighted COLDCARD incident was a hardware-wallet firmware entropy failure: a build guard that checked whether an RNG configuration macro existed rather than whether it was enabled routed seed generation to a deterministic software fallback, enabling an attacker to recover affected seeds and sweep at least 1,370 BTC (~$88M) across a series of on-chain waves. The LULA token on BNB Chain lost ~$578K to a business-logic flaw where an attacker-reachable path could trigger its privileged `recycle()` function, pulling LULA out of a PancakeSwap V2 pair, resyncing its reserves to the manipulated balance, and draining its liquidity.

Best Security Auditor for Web3

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

BlockSec Audit