M-02 Missing Running Total Balance Check Can Lock Balances Permanently
Severity: Medium
Source: Midnight Foundation #2 — OpenZeppelin Compact Contracts Audit, Release 0.3.0-alpha.1 (2026-08-18)
Balances are held as ElGamal ciphertexts and grow by homomorphic addition, incoming value being folded into the pending pool by the credit path and later merged into the spendable balance by sweep. The only ceiling applied anywhere is a per-operation check on each individual value, which is the full unsigned 128-bit maximum. Both growth operations reduce to component-wise curve addition, the credit path reaching it through a helper that adds a fresh encryption of the incoming amount and sweep calling it directly on the two stored ciphertexts. Because the amount sits in the exponent, that addition accumulates in the scalar field of the subgroup rather than in the argument type, so a stored ciphertext can come to represent a quantity above the unsigned 128-bit range while remaining perfectly well formed.
No running total is held anywhere for a check to apply to. The accumulated quantity exists only as the exponent of a curve point, which no participant in a credit can read, since a sender holds neither the recipient's encryption secret nor their current plaintext. The quantity that would need bounding is therefore not available to be bounded at the point where it grows.
-
Credits accumulate into one account, each within the per-operation bound but summing past the unsigned 128-bit range.
-
The stored ciphertext remains valid and continues to decrypt, since the sum is taken in the scalar field of the subgroup, which is far larger.
-
The holder attempts a debit, an approval, or an escrow spend, each of which requires them to state the current plaintext and have it checked against the stored ciphertext.
-
No value of the witness type decrypts to the stored quantity, so the assertion cannot be satisfied by any claim and every one of those paths fails.
The balance stays intact on chain with no operation able to move it, affecting the debit path, approve and the escrow spend alike. The restriction is enforced in the constraint system and not merely by the compiler, the emitted representation carrying an explicit 128 bit range constraint on that witness, so a prover willing to submit an out of range claim is refused as well. No privileged role is needed to arrive there, since ordinary transfers between users accumulate into one recipient's pending pool, and the amounts required are large rather than unusual in kind.
Consider treating a bounded total supply as the invariant to enforce, either by requiring a supply layer whose existing overflow check on the tracked total is made mandatory rather than optional, with the trade-off of mint and burn amounts being publicly determined through total supply delta, or by lowering the per-operation ceiling far enough that no plausible sequence of credits reaches the scalar field, and by documenting that a deployment without a bounded total can reach an unrecoverable balance. Consider carefully before instead range checking the plaintext as pending is merged into spendable, because the pending pool is written by third parties: a sender able to push a recipient past the ceiling would then make that recipient's sweep unsatisfiable and block their incoming value indefinitely, which relocates the failure onto the party being paid and defeats the purpose of separating pending from spendable. The check cannot be moved earlier into the credit path either, since a sender has no way to learn the recipient's current plaintext.
M-02 Missing Running Total Balance Check Can Lock Balances Permanently
Severity: Medium
Source: Midnight Foundation #2 — OpenZeppelin Compact Contracts Audit, Release 0.3.0-alpha.1 (2026-08-18)
Balances are held as ElGamal ciphertexts and grow by homomorphic addition, incoming value being folded into the pending pool by the credit path and later merged into the spendable balance by
sweep. The only ceiling applied anywhere is a per-operation check on each individual value, which is the full unsigned 128-bit maximum. Both growth operations reduce to component-wise curve addition, the credit path reaching it through a helper that adds a fresh encryption of the incoming amount andsweepcalling it directly on the two stored ciphertexts. Because the amount sits in the exponent, that addition accumulates in the scalar field of the subgroup rather than in the argument type, so a stored ciphertext can come to represent a quantity above the unsigned 128-bit range while remaining perfectly well formed.No running total is held anywhere for a check to apply to. The accumulated quantity exists only as the exponent of a curve point, which no participant in a credit can read, since a sender holds neither the recipient's encryption secret nor their current plaintext. The quantity that would need bounding is therefore not available to be bounded at the point where it grows.
Credits accumulate into one account, each within the per-operation bound but summing past the unsigned 128-bit range.
The stored ciphertext remains valid and continues to decrypt, since the sum is taken in the scalar field of the subgroup, which is far larger.
The holder attempts a debit, an approval, or an escrow spend, each of which requires them to state the current plaintext and have it checked against the stored ciphertext.
No value of the witness type decrypts to the stored quantity, so the assertion cannot be satisfied by any claim and every one of those paths fails.
The balance stays intact on chain with no operation able to move it, affecting the debit path,
approveand the escrow spend alike. The restriction is enforced in the constraint system and not merely by the compiler, the emitted representation carrying an explicit 128 bit range constraint on that witness, so a prover willing to submit an out of range claim is refused as well. No privileged role is needed to arrive there, since ordinary transfers between users accumulate into one recipient's pending pool, and the amounts required are large rather than unusual in kind.Consider treating a bounded total supply as the invariant to enforce, either by requiring a supply layer whose existing overflow check on the tracked total is made mandatory rather than optional, with the trade-off of mint and burn amounts being publicly determined through total supply delta, or by lowering the per-operation ceiling far enough that no plausible sequence of credits reaches the scalar field, and by documenting that a deployment without a bounded total can reach an unrecoverable balance. Consider carefully before instead range checking the plaintext as pending is merged into spendable, because the pending pool is written by third parties: a sender able to push a recipient past the ceiling would then make that recipient's
sweepunsatisfiable and block their incoming value indefinitely, which relocates the failure onto the party being paid and defeats the purpose of separating pending from spendable. The check cannot be moved earlier into the credit path either, since a sender has no way to learn the recipient's current plaintext.