fix(token): resolve zero-value revert audit findings (L-01, L-02)#616
fix(token): resolve zero-value revert audit findings (L-01, L-02)#6160xisk wants to merge 2 commits into
Conversation
A zero-value transferFrom, _transfer, _mint or _burn of a token `id` that was never initialized reverted on the bare `_balances.lookup(id)` write in `_update`, instead of failing with an explicit assertion (or succeeding as a no-op). Guard both balance writes on `disclose(value > 0)`: a zero-value update leaves balances unchanged, so the source write is skipped and the recipient block no longer initializes the `id`. The guard must be disclosed because it gates a ledger write; `value` is already public on every balance write, so disclosing whether it is zero leaks nothing new. Adds tests covering zero-value transferFrom, _transfer, _mint and _burn on an uninitialized id. Previously blocked by the unconditional-downcast limitation (LFDT-Minokawa/compact#226), now resolved upstream. Refs: #425
`transferFrom(owner, to, 0)` reverted unless a prior approval had created the `_allowances` entry, because `_spendAllowance` asserted membership before reading the allowance. This diverged from `allowance`, which treats a missing entry as zero. Read the current allowance via `allowance` (missing entry -> 0) and guard the spend on `disclose(value > 0)`: a zero-value spend is now a no-op that neither reverts nor writes a spurious zero entry, and a non-zero spend without allowance still fails with "insufficient allowance". The guard must be disclosed because it gates a ledger write; a non-zero spend already discloses the updated allowance, so disclosing whether `value` is zero leaks nothing new. Adds tests for zero-value transferFrom without a pre-existing allowance and for zero-value _spendAllowance (no entry, and existing entry left unchanged). Previously blocked by the unconditional-downcast limitation (LFDT-Minokawa/compact#226), now resolved upstream. Refs: #426
|
Apologies for the duplicate attempts above — let me produce the correct, single final answer now. I need to use only the provided rangeIds. Let me redo this cleanly with only the exact IDs from The Walkthrough
ChangesZero-value operation guards and tests
Estimated code review effort🎯 2 (Simple) | ⏱️ ~10 minutes Poem
🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
Comment |
There was a problem hiding this comment.
🧹 Nitpick comments (1)
contracts/src/token/test/MultiToken.test.ts (1)
377-388: ⚡ Quick winAssert that
NONEXISTENT_IDremains uninitialized after zero-value ops.These tests verify zero balances, but not the stronger audit objective that zero-value paths do not initialize the
identry. Please add a direct storage-membership assertion (via simulator/private state) after each case.Also applies to: 933-939, 1171-1176, 1303-1307
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@contracts/src/token/test/MultiToken.test.ts` around lines 377 - 388, The test for "should allow transfer of 0 tokens for an uninitialized id" verifies that balances remain zero but does not directly assert that the NONEXISTENT_ID entry remains uninitialized in storage. Add storage-membership assertions (via the simulator's private state inspection) after the transferFrom call to directly verify that the NONEXISTENT_ID entry was not initialized in storage, confirming that zero-value operations do not create storage entries. Apply the same fix to all other zero-value transfer test cases referenced (the similar test blocks around lines 933-939, 1171-1176, and 1303-1307).
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Nitpick comments:
In `@contracts/src/token/test/MultiToken.test.ts`:
- Around line 377-388: The test for "should allow transfer of 0 tokens for an
uninitialized id" verifies that balances remain zero but does not directly
assert that the NONEXISTENT_ID entry remains uninitialized in storage. Add
storage-membership assertions (via the simulator's private state inspection)
after the transferFrom call to directly verify that the NONEXISTENT_ID entry was
not initialized in storage, confirming that zero-value operations do not create
storage entries. Apply the same fix to all other zero-value transfer test cases
referenced (the similar test blocks around lines 933-939, 1171-1176, and
1303-1307).
ℹ️ Review info
⚙️ Run configuration
Configuration used: Organization UI
Review profile: CHILL
Plan: Pro
Run ID: 6e480aee-3c5e-40d2-a771-181857c0c0ef
📒 Files selected for processing (4)
contracts/src/token/FungibleToken.compactcontracts/src/token/MultiToken.compactcontracts/src/token/test/FungibleToken.test.tscontracts/src/token/test/MultiToken.test.ts
Types of changes
What types of changes does your code introduce to OpenZeppelin Midnight Contracts?
Put an
xin the boxes that applyFixes #425
Fixes #426
Resolves the two audit findings (L-01, L-02) that were parked behind the
unconditional-downcast compiler limitation
LFDT-Minokawa/compact#226,
now resolved upstream. With conditional downcasts available, a zero-value
balance/allowance update can be guarded without the constraint-level failure
the audit comments described.
L-01 —
MultiToken._update(#425)A zero-value
transferFrom,_transfer,_mintor_burnof a tokenidthat was never initialized reverted on the bare
_balances.lookup(id)writeinside
_update, rather than failing with an explicit assertion or succeedingas a no-op.
Fix: guard both balance writes on
disclose(value > 0). A zero-valueupdate leaves balances unchanged, so the source write is skipped and the
recipient block no longer initializes the
id. A non-zero transfer/burn of anuninitialized id still fails with the explicit
"MultiToken: insufficient balance"assertion as before.L-02 —
FungibleToken._spendAllowance(#426)transferFrom(owner, to, 0)reverted unless a prior approval had created the_allowancesentry, because_spendAllowanceasserted membership beforereading the allowance. This diverged from
allowance, which treats a missingentry as zero.
Fix: read the current allowance via
allowance(missing entry →0) andguard the spend on
disclose(value > 0). A zero-value spend is now a no-op thatneither reverts nor writes a spurious zero entry; a non-zero spend without
allowance still fails with
"FungibleToken: insufficient allowance".On
disclose(value > 0)The guard gates a ledger write, so its boolean must be disclosed.
valueisalready public on every balance write (and a non-zero spend already discloses
the updated allowance), so disclosing whether
valueis zero leaks nothingnew.
Note on
@circuitInfoThe touched circuits' constraint counts shift by a few rows. The
@circuitInfoannotations are intentionally left unchanged here: the existing values do not
match the toolchain in this environment even for circuits this PR does not touch
(e.g.
balanceOf), so they are best regenerated wholesale with the canonicalrelease toolchain rather than edited piecemeal in this fix.
PR Checklist
Further comments
Both fixes compile to valid ZKIR (prover/verifier keys generate for every mock
circuit), confirming the conditional downcast + disclosure is provable. Added
tests cover: zero-value
transferFrom/_transfer/_mint/_burnon anuninitialized
id(MultiToken), and zero-valuetransferFrom/_spendAllowancewithout a pre-existing allowance plus an existing-allowance-unchanged case
(FungibleToken). All 554 token suite tests pass.
Summary by CodeRabbit
Bug Fixes
Tests