Summary
Several shielded database operations have correctness issues: global deletes affecting wrong networks, panics on malformed data, swallowed errors masking real failures, and missing fields.
Found during CodeRabbit review of PR #793 (zk branch).
Issues
1. clear_network_data() deletes commitment tree data for ALL networks (src/database/mod.rs:197)
The commitment tree DELETEs have no WHERE network = ?1 clause. Switching/clearing testnet data wipes mainnet commitment tree state too.
Current code (after PR #791 partial fix — error handling improved, scoping still wrong):
if let Err(e) = self.clear_commitment_tree_tables() {
tracing::warn!("Failed to clear commitment tree tables: {e}");
}
Fix: Add WHERE network = ?1 to each DELETE in clear_commitment_tree_tables(), passing the network parameter. Requires the commitment tree tables to have a network column (verify schema in src/database/initialization.rs). If schema doesn't have it, add a migration.
2. clear_commitment_tree_tables() is global (src/database/shielded.rs:178)
Same issue as above but from the shielded module's own clear function. PR #791 improved error handling (checks table existence, propagates errors) but still no network/wallet filter.
Fix: Same approach — add WHERE network = ?1 filter. Both clear_network_data() and clear_commitment_tree_tables() should be consistent.
3. copy_from_slice panics on malformed blobs (src/database/shielded.rs:94)
Both query mappers for cmx and nullifier call arr.copy_from_slice(&bytes) expecting exactly 32 bytes. A malformed DB row causes an unrecoverable panic.
Affected locations:
get_shielded_notes() mapper (~line 82, 88)
get_all_shielded_notes() mapper (~line 121, 127)
Fix: Validate bytes.len() == 32 before copying. On mismatch, return rusqlite::Error::FromSqlConversionFailure.
4. get_all_shielded_notes() doesn't expose is_spent (src/database/shielded.rs:136)
The shielded_notes table has an is_spent column (added in schema), but get_all_shielded_notes() doesn't SELECT it and ShieldedNoteRow doesn't have the field. Callers can't distinguish spent from unspent notes.
Fix: Add is_spent to the SELECT, add is_spent: bool to ShieldedNoteRow, populate from row.get(...).
5. .unwrap_or(0) masks SQL failures as zero balance (src/database/shielded.rs:292)
COALESCE(SUM(...), 0) already handles the legitimate zero-balance case. The unwrap_or(0) hides real DB/query/migration errors, making them appear as zero balance.
Fix: Replace with ? propagation. Cast i64 result to u64 and return Ok(result as u64).
6. Nullifier sync mutates state before DB writes succeed (src/backend_task/shielded/nullifiers.rs:77)
The loop mutates shielded_state (marks notes spent, advances checkpoints) even when DB writes (mark_shielded_note_spent, set_nullifier_sync_info) fail. This creates in-memory/on-disk inconsistency that persists until restart.
Fix: Collect matching notes first, perform all DB writes, and only mutate in-memory state after all DB calls succeed. Return Err(TaskError) on DB failure.
Acceptance Criteria
🤖 Co-authored by Claudius the Magnificent AI Agent
Summary
Several shielded database operations have correctness issues: global deletes affecting wrong networks, panics on malformed data, swallowed errors masking real failures, and missing fields.
Found during CodeRabbit review of PR #793 (zk branch).
Issues
1.
clear_network_data()deletes commitment tree data for ALL networks (src/database/mod.rs:197)The commitment tree DELETEs have no
WHERE network = ?1clause. Switching/clearing testnet data wipes mainnet commitment tree state too.Current code (after PR #791 partial fix — error handling improved, scoping still wrong):
Fix: Add
WHERE network = ?1to each DELETE inclear_commitment_tree_tables(), passing the network parameter. Requires the commitment tree tables to have anetworkcolumn (verify schema insrc/database/initialization.rs). If schema doesn't have it, add a migration.2.
clear_commitment_tree_tables()is global (src/database/shielded.rs:178)Same issue as above but from the shielded module's own clear function. PR #791 improved error handling (checks table existence, propagates errors) but still no network/wallet filter.
Fix: Same approach — add
WHERE network = ?1filter. Bothclear_network_data()andclear_commitment_tree_tables()should be consistent.3.
copy_from_slicepanics on malformed blobs (src/database/shielded.rs:94)Both query mappers for
cmxandnullifiercallarr.copy_from_slice(&bytes)expecting exactly 32 bytes. A malformed DB row causes an unrecoverable panic.Affected locations:
get_shielded_notes()mapper (~line 82, 88)get_all_shielded_notes()mapper (~line 121, 127)Fix: Validate
bytes.len() == 32before copying. On mismatch, returnrusqlite::Error::FromSqlConversionFailure.4.
get_all_shielded_notes()doesn't exposeis_spent(src/database/shielded.rs:136)The
shielded_notestable has anis_spentcolumn (added in schema), butget_all_shielded_notes()doesn't SELECT it andShieldedNoteRowdoesn't have the field. Callers can't distinguish spent from unspent notes.Fix: Add
is_spentto the SELECT, addis_spent: booltoShieldedNoteRow, populate fromrow.get(...).5.
.unwrap_or(0)masks SQL failures as zero balance (src/database/shielded.rs:292)COALESCE(SUM(...), 0)already handles the legitimate zero-balance case. Theunwrap_or(0)hides real DB/query/migration errors, making them appear as zero balance.Fix: Replace with
?propagation. Casti64result tou64and returnOk(result as u64).6. Nullifier sync mutates state before DB writes succeed (
src/backend_task/shielded/nullifiers.rs:77)The loop mutates
shielded_state(marks notes spent, advances checkpoints) even when DB writes (mark_shielded_note_spent,set_nullifier_sync_info) fail. This creates in-memory/on-disk inconsistency that persists until restart.Fix: Collect matching notes first, perform all DB writes, and only mutate in-memory state after all DB calls succeed. Return
Err(TaskError)on DB failure.Acceptance Criteria
clear_network_dataandclear_commitment_tree_tables)ShieldedNoteRowincludesis_spentfield🤖 Co-authored by Claudius the Magnificent AI Agent