Introduction
On October 13th, 2025, FatSolutions engaged zkSecurity to perform a security audit of its Tongo protocol and the SHE homomorphic encryption library. The audit lasted two weeks and was conducted by two consultants.
During the engagement, the team was provided access to the codebase via two private repositories. Additionally, a private document outlining the Tongo protocol was shared.
The codebase was clean and thoroughly tested. Several observations and findings were identified and communicated to the FatSolutions team. These findings are detailed in the subsequent sections of this report.
Scope
The audit covered the following components:
Overview
SHE Overview
Starknet Homomorphic Encryption (SHE) is a low-level library that provides cryptographic primitives for ElGamal encryption and zero-knowledge proofs over the Starknet elliptic curve.
ElGamal Encryption
In Tongo, user balances are protected using additively homomorphic ElGamal encryption over the Starknet elliptic curve. A balance amount is encrypted under a public key as:
Where:
- is the Starknet curve generator
- is the public key associated with private key
- is the balance amount
- is a random blinding factor
To decrypt the ciphertext with a private key :
- Compute
- Find from , which can be computed efficiently since is bounded (e.g., )
Given two ciphertexts encrypting and , we can add these encrypted balances without decryption:
Similarly, subtraction yields:
This property allows confidential balance reconciliation during transactions while preserving privacy.
Proof of Exponent (POE)
A Proof of Exponent (POE) is a fundamental sigma protocol that allows a prover to demonstrate knowledge of a discrete logarithm, specifically a secret exponent such that . This is a core building block in many cryptographic systems, including in SHE protocol.
The protocol is shown in the following interactions:
-
Prover: Chooses a random and computes . Then sends to the verifier.
-
Verifier: Receives . Chooses a random challenge and sends it to the prover.
-
Prover: Receives challenge and computes . Then sends to the verifier.
-
Verifier: Receives . Accept the proof if ; otherwise reject the proof.
This verification holds since:
Additionally, POE can be extended to POE2 that proves and POEN that proves
Bit Proof
A bit proof allows a prover to demonstrate that a committed value is either 0 or 1 without revealing it. It is achieved by using sigma OR Proof, where two subproofs are combined, one real and one simulated.
The committed value is represented as Pedersen-style commitments of the form:
where:
- is a bit representing the committed value ()
- is a random blinding factor
- and are independent generators of the elliptic curve group
The protocol is shown in the following interactions:
-
Prover: Constructs and sends two transcripts where one is a simulated proof and the other is a real proof.
- If : set for random and , then for a random
- if : set for random and , then .
-
Verifier: Receives . Choose a random challenge and sends it to the verifier.
-
Prover: Constructs and sends to the verifier.
- If : computes and .
- If : computes and .
-
Verifier: Receives . Computes and accept the proof if the following equalities hold, otherwise rejects the proof:
Range Proof
Range proof allows prover to demonstrate that the committed value lies within a valid range (e.g., ), by using bit decomposition and proves that each bit is indeed a bit value with bit proof. Thus, any value can be written as:
where each .
In summary, a range proof in SHE is essentially a collection of bit proofs, each proving that one binary digit of the secret value is valid. Therefore, soundness of the range proof directly inherits from the security of the underlying bit proofs.
Same Encryption Proof
Same encryption proof shows that two ciphertexts under different public keys encrypt the same plaintext message. It is an essential building block in the SHE protocol within Tongo, ensuring that transferred balances are consistent across sender, receiver, and optional-auditor encryptions.
The statement to prove is as follows:
where:
- and are two public keys
- is the plaintext (e.g., transfer amount)
- , are the random blinding factors
The prover demonstrates knowledge of by composing multiple POE, such that:
Additionally, there is also a variant of the protocol where the prover don’t know one of the blinding factors. The prover compensates for the unknown random by proving knowledge of the secret key corresponding to one of the public keys. This variant is called Same Encryption Unknown Random in the implementation.
Tongo Overview
Tongo is a StarkNet-based protocol that wraps ERC20 tokens to provide enhanced privacy and security features. It leverages homomorphic encryption to hide transfer amounts and balances, ensuring confidentiality. The protocol supports operations such as funding, transferring, withdrawing tokens, and optional auditing.
Fund
The fund operation allows users to deposit ERC20 tokens into the Tongo protocol. The contract transfers tokens from the caller and adds the encrypted balance to the user’s account. The operation verifies the user’s signature to ensure authorization.
Transfer
The transfer operation enables users to send encrypted tokens to another account in Tongo. The transfer amount is hidden, and the recipient’s pending balance is updated. The operation uses two encrypted balances:
transferBalance: encrypts the amount for the receiver.
transferBalanceSelf: encrypts the amount for the sender.
The contract performs the following verifications:
- Ensures
transferBalance and transferBalanceSelf encrypt the same value (SameEncryption proof).
- Verifies the amount in
transferBalance is within the valid range (Range Proof).
- Ensures the sender’s balance remains within the valid range after the transfer (Range Proof).
- Deducts
transferBalanceSelf from the sender’s balance and adds transferBalance to the receiver’s pending balance.
Withdraw
The withdraw operation allows users to redeem their encrypted balances back into standard ERC20 tokens. The protocol verifies that the deducted balance is within the valid range to prevent underflows (Range Proof). Additionally, the ragequit operation enables users to withdraw their entire balance in a single transaction.
Pending Balance
To prevent potential DoS issues caused by balance changes during proof generation, Tongo separates user balances into two parts: balance and pending balance. Tokens received from other users are stored in the pending balance. Users must execute a rollover operation to merge the pending balance into the main balance. Withdrawals are only allowed from the main balance, ensuring that it remains stable unless explicitly authorized by the user.
The rollover operation consolidates an account’s pending balance into its main balance, finalizing incoming transfers and making them usable.
Auditor
The auditor functionality allows an optional third party to decrypt and verify encrypted balances. If an auditor is configured, the protocol maintains an audit_balance for each account, encrypted with the auditor’s public key. The auditor can use its private key to decrypt and verify balances. Whenever an account’s balance changes, the user must update the audit_balance. The SameEncryptionUnknownRandom Sigma Proof ensures that the audit_balance corresponds to the actual balance.
Below are listed the findings found during the engagement. High severity findings can be seen as
so-called
"priority 0" issues that need fixing (potentially urgently). Medium severity findings are most often
serious
findings that have less impact (or are harder to exploit) than high-severity findings. Low severity
findings
are most often exploitable in contrived scenarios, if at all, but still warrant reflection. Findings
marked
as informational are general comments that did not fit any of the other criteria.
Description. In order to verify withdraw and transfer operation, the verifier requires to check that two encryptions for two different keys are valid and that they are encrypting the same amount , which is implemented in SameEncryptionUnknownRandom::_verify that is shown in the following Cairo snippet.
pub fn _verify(
L1: NonZeroEcPoint,
R1: NonZeroEcPoint,
L2: NonZeroEcPoint,
R2: NonZeroEcPoint,
g: NonZeroEcPoint,
y1: NonZeroEcPoint,
y2: NonZeroEcPoint,
Ax: NonZeroEcPoint,
AL1: NonZeroEcPoint,
AL2: NonZeroEcPoint,
AR2: NonZeroEcPoint,
c: felt252,
sb: felt252,
sx: felt252,
sr2: felt252,
) -> Result<(), Errors> {
in_curve_order(c)?;
in_curve_order(sb)?;
in_curve_order(sx)?;
in_curve_order(sr2)?;
if poe::_verify(y1, g, Ax, c, sx).is_err() {
return Err(Errors::SameEnctyptionUnKnownRandomError);
}
if poe2::_verify(L1, g, R1, AL1, c, sb, sx).is_err() {
return Err(Errors::SameEnctyptionUnKnownRandomError);
}
if ElGamal::_verify(L2, R2, g, y2, AL2, AR2, c, sb, sr2).is_err() {
return Err(Errors::SameEnctyptionUnKnownRandomError);
}
Ok(())
}
Specifically, given inputs , commitments , and proofs with the challenge , it performs the following checks:
However, in the withdraw and transfer operation (verifyWithdraw and verifyTransfer) in the Typescript version, it verifies not through SameEncryptionUnknownRandom and instead defines the checks one-by-one via POE and POE2, which turns out to be incomplete, as shown in the following verifyWithdraw example:
export function verifyWithdraw(
inputs: InputsWithdraw,
proof: ProofOfWithdraw,
) {
const bit_size = inputs.bit_size;
const prefix = prefixWithdraw(
inputs.prefix_data,
inputs.y,
inputs.nonce,
inputs.amount,
inputs.to
);
const c = compute_challenge(prefix, [proof.A_x, proof.A_r, proof.A, proof.A_v]);
let res = poe._verify(inputs.y, g, proof.A_x, c, proof.sx);
if (res == false) { throw new Error("error in poe y"); }
const { R: R0 } = inputs.currentBalance;
let { L: L0 } = inputs.currentBalance;
L0 = L0.subtract(g.multiply(inputs.amount));
res = poe2._verify(L0, g, R0, proof.A, c, proof.sb, proof.sx);
if (res == false) { throw new Error("error in poe2 Y"); }
const V = verifyRangeProof(proof.range, bit_size, prefix);
if (V == false) { throw new Error("error in range for V"); }
res = poe2._verify(V, g, h, proof.A_v, c, proof.sb, proof.sr);
if (res == false) { throw new Error("error in poe2 V"); }
}
Which, in details only performs the following checks (excluding range proof):
As seen in the checks above, it omits the ElGamal blinding check that binds to the second ciphertext’s randomness via . As a result, is never linked to in the typescript version, allowing to be altered without being cryptographically tied to the claimed randomness.
Impact. This issue cause withdraw and transfer verification to be unsound with respect to the second ciphertext: a prover can pass verification while providing a mismatched or adversarial that is not the same-encryption of the amount under .
That said, the immediate impact is limited, since the current TypeScript implementation primarily functions as a prover rather than an authoritative verifier. Nevertheless, the underlying logical inconsistency still warrants correction to prevent future misuse, ensure consistency with the Cairo verifier, and maintain the intended proof soundness.
Recommendation. We suggest to use SameEncryptionUnknownRandom.verify() in the withdraw and transfer verification, as also implemented in the Cairo version. This ensures that all sub-checks are consistently enforced and that both ciphertexts are properly bound to the same encrypted value.
Client Response. Fixed in https://github.com/fatlabsxyz/tongo/pull/122/commits/c9ac11ca639ef0de8c163147af76ab0c7dbe437f.