From 4719b1d36a44d03906f4e146d3156dd0cb7fa8fa Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Wed, 11 Feb 2026 13:55:53 +0100 Subject: [PATCH 1/8] fix: return error instead of panicking on corrupted database blobs (#560) - Replace `unreachable!()` in `get_scheduled_votes` with warning log and default to false for unexpected `executed` column values - Change `QualifiedIdentity::from_bytes()` to return `Result` instead of panicking via `.expect()` - Propagate deserialization errors as `rusqlite::Error` in all 6 callers so corrupted database is surfaced to the user rather than silently ignored or crashing the app - Add `CorruptedBlobError` newtype in database module to eliminate repeated `FromSqlConversionFailure` boilerplate Closes #560 Co-Authored-By: Claude Opus 4.6 --- src/database/identities.rs | 101 +++++++++++++++++----------- src/database/mod.rs | 16 +++++ src/database/scheduled_votes.rs | 8 ++- src/database/wallet.rs | 3 +- src/model/qualified_identity/mod.rs | 6 +- 5 files changed, 90 insertions(+), 44 deletions(-) diff --git a/src/database/identities.rs b/src/database/identities.rs index 524075f4f..8ff02ca1f 100644 --- a/src/database/identities.rs +++ b/src/database/identities.rs @@ -173,7 +173,14 @@ impl Database { // Handle NULL status values from older database entries by defaulting to Active (2) let status: Option = row.get(3)?; - let mut identity: QualifiedIdentity = QualifiedIdentity::from_bytes(&data); + Ok((data, alias, wallet_index, status)) + })?; + + let mut identities = Vec::new(); + for row in identity_iter { + let (data, alias, wallet_index, status) = row?; + let mut identity = QualifiedIdentity::from_bytes(&data) + .map_err(super::CorruptedBlobError)?; identity.alias = alias; identity.wallet_index = wallet_index; @@ -199,11 +206,9 @@ impl Database { // Assign the top_ups to the identity identity.top_ups = top_ups; - Ok(identity) - })?; - - let identities: rusqlite::Result> = identity_iter.collect(); - identities + identities.push(identity); + } + Ok(identities) } #[allow(dead_code)] // May be used for filtering identities that belong to specific wallets @@ -231,7 +236,14 @@ impl Database { let alias: Option = row.get(1)?; let wallet_index: Option = row.get(2)?; - let mut identity: QualifiedIdentity = QualifiedIdentity::from_bytes(&data); + Ok((data, alias, wallet_index)) + })?; + + let mut identities = Vec::new(); + for row in identity_iter { + let (data, alias, wallet_index) = row?; + let mut identity = QualifiedIdentity::from_bytes(&data) + .map_err(super::CorruptedBlobError)?; identity.alias = alias; identity.wallet_index = wallet_index; identity.network = app_context.network; @@ -255,11 +267,9 @@ impl Database { // Assign the top_ups to the identity identity.top_ups = top_ups; - Ok(identity) - })?; - - let identities: rusqlite::Result> = identity_iter.collect(); - identities + identities.push(identity); + } + Ok(identities) } pub fn get_identity_by_id( @@ -282,12 +292,19 @@ impl Database { conn.prepare("SELECT top_up_index, amount FROM top_up WHERE identity_id = ?")?; // Iterate over each identity - let identity_iter = stmt.query_map(params![identifier.to_buffer(), network], |row| { - let data: Vec = row.get(0)?; - let alias: Option = row.get(1)?; - let wallet_index: Option = row.get(2)?; + let mut identity_iter = + stmt.query_map(params![identifier.to_buffer(), network], |row| { + let data: Vec = row.get(0)?; + let alias: Option = row.get(1)?; + let wallet_index: Option = row.get(2)?; + + Ok((data, alias, wallet_index)) + })?; - let mut identity: QualifiedIdentity = QualifiedIdentity::from_bytes(&data); + if let Some(row) = identity_iter.next() { + let (data, alias, wallet_index) = row?; + let mut identity = QualifiedIdentity::from_bytes(&data) + .map_err(super::CorruptedBlobError)?; identity.alias = alias; identity.wallet_index = wallet_index; identity.network = app_context.network; @@ -311,11 +328,10 @@ impl Database { // Assign the top_ups to the identity identity.top_ups = top_ups; - Ok(identity) - })?; - - let identities: rusqlite::Result> = identity_iter.collect(); - Ok(identities?.into_iter().next()) + Ok(Some(identity)) + } else { + Ok(None) + } } pub fn get_local_voting_identities( @@ -330,14 +346,18 @@ impl Database { )?; let identity_iter = stmt.query_map(params![network], |row| { let data: Vec = row.get(0)?; - let mut identity: QualifiedIdentity = QualifiedIdentity::from_bytes(&data); - identity.network = app_context.network; - - Ok(identity) + Ok(data) })?; - let identities: rusqlite::Result> = identity_iter.collect(); - identities + let mut identities = Vec::new(); + for row in identity_iter { + let data = row?; + let mut identity = QualifiedIdentity::from_bytes(&data) + .map_err(super::CorruptedBlobError)?; + identity.network = app_context.network; + identities.push(identity); + } + Ok(identities) } /// Retrieves all local user identities along with their associated wallet IDs. @@ -354,18 +374,21 @@ impl Database { let mut stmt = conn.prepare( "SELECT data,wallet FROM identity WHERE is_local = 1 AND network = ? AND identity_type = 'User' AND data IS NOT NULL", )?; - let identities: Result)>, rusqlite::Error> = - stmt.query_map(params![network], |row| { - let data: Vec = row.get(0)?; - let wallet_id: Option = row.get(1)?; - let mut identity: QualifiedIdentity = QualifiedIdentity::from_bytes(&data); - identity.network = app_context.network; - - Ok((identity, wallet_id)) - })? - .collect(); + let row_iter = stmt.query_map(params![network], |row| { + let data: Vec = row.get(0)?; + let wallet_id: Option = row.get(1)?; + Ok((data, wallet_id)) + })?; - identities + let mut identities = Vec::new(); + for row in row_iter { + let (data, wallet_id) = row?; + let mut identity = QualifiedIdentity::from_bytes(&data) + .map_err(super::CorruptedBlobError)?; + identity.network = app_context.network; + identities.push((identity, wallet_id)); + } + Ok(identities) } /// Deletes a local qualified identity with the given identifier from the database. diff --git a/src/database/mod.rs b/src/database/mod.rs index c719d0bb7..ee9fb9b2b 100644 --- a/src/database/mod.rs +++ b/src/database/mod.rs @@ -20,6 +20,22 @@ use dash_sdk::dpp::dashcore::Network; use rusqlite::{Connection, Params}; use std::sync::Mutex; +/// Error indicating a corrupted data blob in the database. +/// +/// Converts into `rusqlite::Error::FromSqlConversionFailure` so it can +/// be propagated with `?` from any function returning `rusqlite::Result`. +pub(crate) struct CorruptedBlobError(pub String); + +impl From for rusqlite::Error { + fn from(e: CorruptedBlobError) -> Self { + rusqlite::Error::FromSqlConversionFailure( + 0, + rusqlite::types::Type::Blob, + Box::new(std::io::Error::new(std::io::ErrorKind::InvalidData, e.0)), + ) + } +} + #[derive(Debug)] pub struct Database { conn: Mutex, diff --git a/src/database/scheduled_votes.rs b/src/database/scheduled_votes.rs index 51c841d18..932cd2cd3 100644 --- a/src/database/scheduled_votes.rs +++ b/src/database/scheduled_votes.rs @@ -155,7 +155,13 @@ impl Database { let executed_successfully: bool = match row.get(4)? { 0 => false, 1 => true, - _ => unreachable!(), + other => { + tracing::warn!( + "Unexpected value {} for executed column in scheduled_votes, defaulting to false", + other + ); + false + } }; let vote_choice = match vote_choice_string.as_str() { diff --git a/src/database/wallet.rs b/src/database/wallet.rs index 92226981d..94cca107a 100644 --- a/src/database/wallet.rs +++ b/src/database/wallet.rs @@ -814,7 +814,8 @@ impl Database { let (identity_data, wallet_seed_hash_array, wallet_index) = row?; if let Some(wallet) = wallets_map.get_mut(&wallet_seed_hash_array) { - let mut identity: QualifiedIdentity = QualifiedIdentity::from_bytes(&identity_data); + let mut identity = QualifiedIdentity::from_bytes(&identity_data) + .map_err(super::CorruptedBlobError)?; identity.wallet_index = Some(wallet_index); identity.network = *network; diff --git a/src/model/qualified_identity/mod.rs b/src/model/qualified_identity/mod.rs index 510d10f0d..2166f0b68 100644 --- a/src/model/qualified_identity/mod.rs +++ b/src/model/qualified_identity/mod.rs @@ -528,10 +528,10 @@ impl QualifiedIdentity { } /// Deserializes a QualifiedIdentity from a vector of bytes. - pub fn from_bytes(bytes: &[u8]) -> Self { + pub fn from_bytes(bytes: &[u8]) -> Result { bincode::decode_from_slice(bytes, bincode::config::standard()) - .expect("Failed to decode QualifiedIdentity") - .0 + .map(|(identity, _)| identity) + .map_err(|e| format!("Failed to decode QualifiedIdentity: {}", e)) } pub fn display_string(&self) -> String { From d3dad7f0852edb8939ddee553bcab405008fb35a Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Wed, 11 Feb 2026 14:08:20 +0100 Subject: [PATCH 2/8] chore: impl thiserror --- src/database/mod.rs | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/src/database/mod.rs b/src/database/mod.rs index ee9fb9b2b..560e816b3 100644 --- a/src/database/mod.rs +++ b/src/database/mod.rs @@ -24,15 +24,13 @@ use std::sync::Mutex; /// /// Converts into `rusqlite::Error::FromSqlConversionFailure` so it can /// be propagated with `?` from any function returning `rusqlite::Result`. +#[derive(Debug, thiserror::Error)] +#[error("corrupted data detected: {0}")] pub(crate) struct CorruptedBlobError(pub String); impl From for rusqlite::Error { fn from(e: CorruptedBlobError) -> Self { - rusqlite::Error::FromSqlConversionFailure( - 0, - rusqlite::types::Type::Blob, - Box::new(std::io::Error::new(std::io::ErrorKind::InvalidData, e.0)), - ) + rusqlite::Error::FromSqlConversionFailure(0, rusqlite::types::Type::Blob, Box::new(e)) } } From daf322a073cf11c686c33ad417ad66b1371a088c Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Wed, 11 Feb 2026 14:09:38 +0100 Subject: [PATCH 3/8] chore: fmt --- src/database/identities.rs | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/src/database/identities.rs b/src/database/identities.rs index 8ff02ca1f..b6ec7e55c 100644 --- a/src/database/identities.rs +++ b/src/database/identities.rs @@ -179,8 +179,8 @@ impl Database { let mut identities = Vec::new(); for row in identity_iter { let (data, alias, wallet_index, status) = row?; - let mut identity = QualifiedIdentity::from_bytes(&data) - .map_err(super::CorruptedBlobError)?; + let mut identity = + QualifiedIdentity::from_bytes(&data).map_err(super::CorruptedBlobError)?; identity.alias = alias; identity.wallet_index = wallet_index; @@ -242,8 +242,8 @@ impl Database { let mut identities = Vec::new(); for row in identity_iter { let (data, alias, wallet_index) = row?; - let mut identity = QualifiedIdentity::from_bytes(&data) - .map_err(super::CorruptedBlobError)?; + let mut identity = + QualifiedIdentity::from_bytes(&data).map_err(super::CorruptedBlobError)?; identity.alias = alias; identity.wallet_index = wallet_index; identity.network = app_context.network; @@ -303,8 +303,8 @@ impl Database { if let Some(row) = identity_iter.next() { let (data, alias, wallet_index) = row?; - let mut identity = QualifiedIdentity::from_bytes(&data) - .map_err(super::CorruptedBlobError)?; + let mut identity = + QualifiedIdentity::from_bytes(&data).map_err(super::CorruptedBlobError)?; identity.alias = alias; identity.wallet_index = wallet_index; identity.network = app_context.network; @@ -352,8 +352,8 @@ impl Database { let mut identities = Vec::new(); for row in identity_iter { let data = row?; - let mut identity = QualifiedIdentity::from_bytes(&data) - .map_err(super::CorruptedBlobError)?; + let mut identity = + QualifiedIdentity::from_bytes(&data).map_err(super::CorruptedBlobError)?; identity.network = app_context.network; identities.push(identity); } @@ -383,8 +383,8 @@ impl Database { let mut identities = Vec::new(); for row in row_iter { let (data, wallet_id) = row?; - let mut identity = QualifiedIdentity::from_bytes(&data) - .map_err(super::CorruptedBlobError)?; + let mut identity = + QualifiedIdentity::from_bytes(&data).map_err(super::CorruptedBlobError)?; identity.network = app_context.network; identities.push((identity, wallet_id)); } From cecac523f23080fade4dce2effa0c6166a71faf4 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Thu, 12 Feb 2026 11:40:51 +0100 Subject: [PATCH 4/8] build: add Claude Code GitHub workflow and settings (#552) Cherry-pick from v1.0-dev to enable @claude mentions in PRs. Co-Authored-By: Claude Opus 4.6 --- .claude/hooks/session-start.sh | 118 +++++++++++++++++++++++++++++++++ .claude/settings.json | 16 +++++ .github/workflows/claude.yml | 37 +++++++++++ CLAUDE.md | 3 + 4 files changed, 174 insertions(+) create mode 100755 .claude/hooks/session-start.sh create mode 100644 .claude/settings.json create mode 100644 .github/workflows/claude.yml diff --git a/.claude/hooks/session-start.sh b/.claude/hooks/session-start.sh new file mode 100755 index 000000000..25890ab43 --- /dev/null +++ b/.claude/hooks/session-start.sh @@ -0,0 +1,118 @@ +#!/bin/bash +set -euo pipefail + +# Only run in Claude Code remote environments +if [ "${CLAUDE_CODE_REMOTE:-}" != "true" ]; then + exit 0 +fi + +# Use sudo only if not already root +SUDO="" +if [ "$(id -u)" -ne 0 ]; then + SUDO="sudo" +fi + +# Install protoc (Protocol Buffers compiler) if not present +# Version aligned with CI workflows (.github/workflows/clippy.yml, tests.yml) +if ! command -v protoc &>/dev/null; then + PROTOC_VERSION="25.2" + PROTOC_ZIP="protoc-${PROTOC_VERSION}-linux-x86_64.zip" + curl -fsSL "https://github.com/protocolbuffers/protobuf/releases/download/v${PROTOC_VERSION}/${PROTOC_ZIP}" -o "/tmp/${PROTOC_ZIP}" + $SUDO unzip -o "/tmp/${PROTOC_ZIP}" -d /usr/local + rm "/tmp/${PROTOC_ZIP}" +fi + +# Install Rust components (clippy, rustfmt) if not present +rustup component add clippy rustfmt 2>/dev/null || true + +# Install system dependencies if not already present +# Matches CI: build-essential, pkg-config, clang, cmake, libsqlite3-dev, libzmq3-dev +PACKAGES_TO_INSTALL="" +for pkg in build-essential pkg-config clang cmake libsqlite3-dev libzmq3-dev unzip; do + if ! dpkg -s "$pkg" &>/dev/null; then + PACKAGES_TO_INSTALL="$PACKAGES_TO_INSTALL $pkg" + fi +done + +if [ -n "$PACKAGES_TO_INSTALL" ]; then + $SUDO apt-get update -qq || true + $SUDO apt-get install -y -qq $PACKAGES_TO_INSTALL +fi + +# Pre-download Tenderdash proto sources to avoid network fetch during cargo build. +# +# The tenderdash-proto build.rs (from rs-tenderdash-abci) downloads proto sources +# from GitHub during compilation. In sandboxed environments, ureq/rustls can't +# verify TLS certificates, causing the download to fail. +# +# Workaround: Pre-download and extract the sources, then configure Cargo env vars +# so the build script finds them without needing network access. +# +# The build script checks: +# 1. TENDERDASH_DIR - path to pre-extracted tenderdash sources +# 2. CARGO_TARGET_DIR - used as the zip archive cache directory +# +# Even with TENDERDASH_DIR set, a clean build has no state file, so the build +# script re-downloads. By also placing the zip in CARGO_TARGET_DIR (the cache dir), +# the build script finds the cached archive and skips the network download. +# +# Version must match DEFAULT_VERSION in rs-tenderdash-abci/proto/build.rs +TENDERDASH_VERSION="v1.5.3" +TENDERDASH_DIR="/tmp/tenderdash-${TENDERDASH_VERSION}" +TENDERDASH_CACHE_DIR="/tmp/tenderdash-cache" +TENDERDASH_ZIP="${TENDERDASH_CACHE_DIR}/tenderdash-${TENDERDASH_VERSION}.zip" + +if [ ! -d "${TENDERDASH_DIR}/proto" ] || [ ! -f "${TENDERDASH_ZIP}" ]; then + echo "[session-start] Downloading Tenderdash ${TENDERDASH_VERSION} proto sources..." + mkdir -p "${TENDERDASH_CACHE_DIR}" + DOWNLOAD_ZIP="/tmp/tenderdash-download-${TENDERDASH_VERSION}.zip" + curl -fsSL "https://github.com/dashpay/tenderdash/archive/${TENDERDASH_VERSION}.zip" -o "${DOWNLOAD_ZIP}" + + # Keep a copy in the cache dir for the build script to find + cp "${DOWNLOAD_ZIP}" "${TENDERDASH_ZIP}" + + # Extract to a known location for TENDERDASH_DIR + if [ ! -d "${TENDERDASH_DIR}/proto" ]; then + TMPDIR=$(mktemp -d) + unzip -q "${DOWNLOAD_ZIP}" -d "${TMPDIR}" + # The archive extracts to a subdirectory like tenderdash-v1.5.3/ + EXTRACTED_DIR=$(find "${TMPDIR}" -maxdepth 1 -type d -name "tenderdash-*" | head -1) + if [ -z "${EXTRACTED_DIR}" ]; then + echo "[session-start] ERROR: Could not find extracted tenderdash directory" + rm -rf "${TMPDIR}" "${DOWNLOAD_ZIP}" + exit 1 + fi + rm -rf "${TENDERDASH_DIR}" + mv "${EXTRACTED_DIR}" "${TENDERDASH_DIR}" + rm -rf "${TMPDIR}" + fi + rm -f "${DOWNLOAD_ZIP}" + echo "[session-start] Tenderdash proto sources installed to ${TENDERDASH_DIR}" +fi + +# Set env vars in global Cargo config so build scripts pick them up. +# - TENDERDASH_DIR: tells the proto-compiler where pre-extracted sources are +# - CARGO_TARGET_DIR: tells the proto-compiler where to find the cached zip archive +# (Note: Cargo's [env] table does NOT affect Cargo's own target dir behavior, +# per Cargo docs. It only sets env vars for build scripts and rustc.) +# This avoids modifying the project's .cargo/config.toml. +CARGO_CONFIG_DIR="${HOME}/.cargo" +CARGO_CONFIG_FILE="${CARGO_CONFIG_DIR}/config.toml" +mkdir -p "${CARGO_CONFIG_DIR}" + +if ! grep -q "TENDERDASH_DIR" "${CARGO_CONFIG_FILE}" 2>/dev/null; then + # If the file already has an [env] section, append keys under it. + # Otherwise, add a new [env] section. Avoids duplicate TOML table headers. + if grep -q '^\[env\]' "${CARGO_CONFIG_FILE}" 2>/dev/null; then + # Insert the keys right after the existing [env] line + sed -i '/^\[env\]/a TENDERDASH_DIR = "'"${TENDERDASH_DIR}"'"\nCARGO_TARGET_DIR = "'"${TENDERDASH_CACHE_DIR}"'"' "${CARGO_CONFIG_FILE}" + else + cat >> "${CARGO_CONFIG_FILE}" < Date: Thu, 12 Feb 2026 13:08:29 +0100 Subject: [PATCH 5/8] chore: fix after merge --- src/database/identities.rs | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/src/database/identities.rs b/src/database/identities.rs index 3fcdbc300..26f1c5769 100644 --- a/src/database/identities.rs +++ b/src/database/identities.rs @@ -148,8 +148,8 @@ impl Database { let wallet_index: Option = row.get(2)?; let status: Option = row.get(3)?; - let mut identity = QualifiedIdentity::from_bytes(&data) - .map_err(super::CorruptedBlobError)?; + let mut identity = + QualifiedIdentity::from_bytes(&data).map_err(super::CorruptedBlobError)?; identity.alias = alias; identity.wallet_index = wallet_index; identity.status = IdentityStatus::from_u8(status.unwrap_or(2)); @@ -208,8 +208,8 @@ impl Database { let wallet_index: Option = row.get(2)?; let status: Option = row.get(3)?; - let mut identity = QualifiedIdentity::from_bytes(&data) - .map_err(super::CorruptedBlobError)?; + let mut identity = + QualifiedIdentity::from_bytes(&data).map_err(super::CorruptedBlobError)?; identity.alias = alias; identity.wallet_index = wallet_index; identity.status = IdentityStatus::from_u8(status.unwrap_or(2)); @@ -263,8 +263,8 @@ impl Database { let wallet_index: Option = row.get(2)?; let status: Option = row.get(3)?; - let mut qi = QualifiedIdentity::from_bytes(&data) - .map_err(super::CorruptedBlobError)?; + let mut qi = + QualifiedIdentity::from_bytes(&data).map_err(super::CorruptedBlobError)?; qi.alias = alias; qi.wallet_index = wallet_index; qi.status = IdentityStatus::from_u8(status.unwrap_or(2)); @@ -300,8 +300,8 @@ impl Database { )?; let identity_iter = stmt.query_map(params![network], |row| { let data: Vec = row.get(0)?; - let mut identity = QualifiedIdentity::from_bytes(&data) - .map_err(super::CorruptedBlobError)?; + let mut identity = + QualifiedIdentity::from_bytes(&data).map_err(super::CorruptedBlobError)?; identity.network = app_context.network; Ok(identity) @@ -329,8 +329,8 @@ impl Database { stmt.query_map(params![network], |row| { let data: Vec = row.get(0)?; let wallet_id: Option = row.get(1)?; - let mut identity = QualifiedIdentity::from_bytes(&data) - .map_err(super::CorruptedBlobError)?; + let mut identity = + QualifiedIdentity::from_bytes(&data).map_err(super::CorruptedBlobError)?; identity.network = app_context.network; Ok((identity, wallet_id)) From 2625b98fad6f438810c434d0ebe1808c05c57139 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Thu, 12 Feb 2026 13:11:43 +0100 Subject: [PATCH 6/8] fix: skip corrupted identity blobs in get_wallets instead of aborting Co-Authored-By: Claude Opus 4.6 --- src/database/wallet.rs | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/database/wallet.rs b/src/database/wallet.rs index 94cca107a..6544ce67f 100644 --- a/src/database/wallet.rs +++ b/src/database/wallet.rs @@ -814,8 +814,11 @@ impl Database { let (identity_data, wallet_seed_hash_array, wallet_index) = row?; if let Some(wallet) = wallets_map.get_mut(&wallet_seed_hash_array) { - let mut identity = QualifiedIdentity::from_bytes(&identity_data) - .map_err(super::CorruptedBlobError)?; + let Ok(mut identity) = QualifiedIdentity::from_bytes(&identity_data) + .inspect_err(|e| tracing::warn!(wallet_index, error = %e, "skipping corrupted identity blob")) + else { + continue; + }; identity.wallet_index = Some(wallet_index); identity.network = *network; From 03c5aa0bf3e842b9f8a888fd434cc772f2cac3a4 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Thu, 12 Feb 2026 14:08:16 +0100 Subject: [PATCH 7/8] chore: fail on corrupted identity --- src/database/wallet.rs | 19 +++++++++++++------ 1 file changed, 13 insertions(+), 6 deletions(-) diff --git a/src/database/wallet.rs b/src/database/wallet.rs index 6544ce67f..a1e15b301 100644 --- a/src/database/wallet.rs +++ b/src/database/wallet.rs @@ -1,4 +1,4 @@ -use crate::database::Database; +use crate::database::{CorruptedBlobError, Database}; use crate::model::qualified_identity::QualifiedIdentity; use crate::model::wallet::{ AddressInfo, ClosedKeyItem, DerivationPathReference, DerivationPathType, OpenWalletSeed, @@ -814,11 +814,18 @@ impl Database { let (identity_data, wallet_seed_hash_array, wallet_index) = row?; if let Some(wallet) = wallets_map.get_mut(&wallet_seed_hash_array) { - let Ok(mut identity) = QualifiedIdentity::from_bytes(&identity_data) - .inspect_err(|e| tracing::warn!(wallet_index, error = %e, "skipping corrupted identity blob")) - else { - continue; - }; + let mut identity = QualifiedIdentity::from_bytes(&identity_data).map_err(|e| { + tracing::warn!(wallet_index, error = %e, "found corrupted identity blob"); + rusqlite::Error::FromSqlConversionFailure( + 1, + rusqlite::types::Type::Blob, + CorruptedBlobError(format!( + "Failed to deserialize identity for wallet_index {}: {}", + wallet_index, e + )) + .into(), + ) + })?; identity.wallet_index = Some(wallet_index); identity.network = *network; From b9e1270e942992fbfbe5774ae8f32b9bb3630ee2 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Tue, 17 Feb 2026 09:39:17 +0100 Subject: [PATCH 8/8] doc: document error handling in the db --- src/database/identities.rs | 18 ++++++++++++++++++ src/database/mod.rs | 5 +++++ src/database/wallet.rs | 5 +++++ src/model/qualified_identity/mod.rs | 8 +++++++- 4 files changed, 35 insertions(+), 1 deletion(-) diff --git a/src/database/identities.rs b/src/database/identities.rs index 26f1c5769..6cb2a25fe 100644 --- a/src/database/identities.rs +++ b/src/database/identities.rs @@ -114,6 +114,11 @@ impl Database { Ok(()) } + /// Returns all local identities for the current network. + /// + /// Stops on the first corrupted identity blob and returns an error. + /// This is intentional — identities hold private keys and balance data, + /// so skipping a corrupted entry could cause loss of funds. pub fn get_local_qualified_identities( &self, app_context: &AppContext, @@ -174,6 +179,9 @@ impl Database { Ok(identities) } + /// Stops on the first corrupted identity blob and returns an error. + /// This is intentional — identities hold private keys and balance data, + /// so skipping a corrupted entry could cause loss of funds. #[allow(dead_code)] // May be used for filtering identities that belong to specific wallets pub fn get_local_qualified_identities_in_wallets( &self, @@ -234,6 +242,9 @@ impl Database { Ok(identities) } + /// Returns an error if the stored identity blob is corrupted. + /// This is intentional — identities hold private keys and balance data, + /// so ignoring corruption could cause loss of funds. pub fn get_identity_by_id( &self, identifier: &Identifier, @@ -288,6 +299,9 @@ impl Database { Ok(identity) } + /// Stops on the first corrupted identity blob and returns an error. + /// This is intentional — identities hold private keys and balance data, + /// so skipping a corrupted entry could cause loss of funds. pub fn get_local_voting_identities( &self, app_context: &AppContext, @@ -313,6 +327,10 @@ impl Database { /// Retrieves all local user identities along with their associated wallet IDs. /// + /// Stops on the first corrupted identity blob and returns an error. + /// This is intentional — identities hold private keys and balance data, + /// so skipping a corrupted entry could cause loss of funds. + /// /// Caller should insert wallet references into associated_wallets before using the identities. #[allow(clippy::let_and_return)] pub fn get_local_user_identities( diff --git a/src/database/mod.rs b/src/database/mod.rs index 560e816b3..758d204ce 100644 --- a/src/database/mod.rs +++ b/src/database/mod.rs @@ -24,6 +24,11 @@ use std::sync::Mutex; /// /// Converts into `rusqlite::Error::FromSqlConversionFailure` so it can /// be propagated with `?` from any function returning `rusqlite::Result`. +/// +/// When a corrupted blob is encountered, processing stops immediately +/// (fail-fast) rather than skipping the row. This is intentional: identity +/// blobs contain private keys and balance data, so silently ignoring +/// corruption could result in loss of funds. #[derive(Debug, thiserror::Error)] #[error("corrupted data detected: {0}")] pub(crate) struct CorruptedBlobError(pub String); diff --git a/src/database/wallet.rs b/src/database/wallet.rs index a1e15b301..77afe5f1d 100644 --- a/src/database/wallet.rs +++ b/src/database/wallet.rs @@ -409,6 +409,11 @@ impl Database { } /// Retrieve all wallets for a specific network, including their addresses, balances, and known addresses. + /// + /// Stops on the first corrupted identity blob and returns an error for + /// the entire call. This is intentional — identities hold private keys + /// and balance data, so skipping a corrupted entry could cause loss of + /// funds. pub fn get_wallets(&self, network: &Network) -> rusqlite::Result> { let network_str = network.to_string(); let conn = self.conn.lock().unwrap(); diff --git a/src/model/qualified_identity/mod.rs b/src/model/qualified_identity/mod.rs index 2166f0b68..fbb913144 100644 --- a/src/model/qualified_identity/mod.rs +++ b/src/model/qualified_identity/mod.rs @@ -527,7 +527,13 @@ impl QualifiedIdentity { .expect("Failed to encode QualifiedIdentity") } - /// Deserializes a QualifiedIdentity from a vector of bytes. + /// Deserializes a `QualifiedIdentity` from a vector of bytes. + /// + /// Returns an error if the blob is corrupted or cannot be decoded. + /// Callers must stop processing on the first deserialization error rather + /// than skipping corrupted entries, because identities hold private keys + /// and balance information — silently ignoring a corrupted identity could + /// lead to loss of funds. pub fn from_bytes(bytes: &[u8]) -> Result { bincode::decode_from_slice(bytes, bincode::config::standard()) .map(|(identity, _)| identity)