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,Poseidon6are 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
USDCviaprooflessDeposit()in the transaction 0xfbedf0...8c2f11. TheextraRandomizationof 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 100USDClegacy note, each time with the same Merkle root but a fresh nullifier, withdrawing 100USDCper call. This is the double-spend: the same note was spent many times, accumulating approximately 25,000USDC. -
Step 3: The attacker consolidated all 25,000
USDCinto a single legacy note via anotherprooflessDeposit()call in the transaction 0xbf7252...d50008. By consolidating into a larger note, each subsequent double-spend withdrawal would yield 25,000USDCinstead of 100. -
Step 4: The attacker repeated the same process against the 25,000
USDCnote, callingtransact()with a fresh nullifier each time. After the deposit, the attacker never supplied more assets, yet withdrew far more than the 25,000USDCdeposited.
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.
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)



