Skip to content

fix(db): shielded database correctness and safety issues #796

@lklimek

Description

@lklimek

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

  • Commitment tree clears are network-scoped (both clear_network_data and clear_commitment_tree_tables)
  • Blob deserialization validates length before copy
  • ShieldedNoteRow includes is_spent field
  • SQL failures propagate instead of returning zero
  • Nullifier sync is atomic: DB-first, then memory mutation

🤖 Co-authored by Claudius the Magnificent AI Agent

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions