From 51c019874d453f2fca03e0fb4a7988272ba080ee Mon Sep 17 00:00:00 2001 From: coreyphillips Date: Wed, 26 Aug 2026 13:55:22 -0400 Subject: [PATCH 1/2] feat: add wallet-scoped activity tag and metadata reads get_all_activities_tags() and get_all_pre_activity_metadata() take no wallet id, so an app backing up a single wallet scope had to fetch every scope and filter client-side. That filter is the app's entire backup-scoping policy and is easy to drop in review, which silently changes which records leave the device. Add get_activities_tags(wallet_id) and get_pre_activity_metadata_list(wallet_id), where None returns every scope. The unscoped getters are unchanged and delegate to the new ones. --- CHANGELOG.md | 2 + README.md | 8 ++ src/lib.rs | 28 ++++++ src/modules/activity/README.md | 5 + src/modules/activity/implementation.rs | 73 +++++++++++--- src/modules/activity/tests.rs | 133 +++++++++++++++++++++++++ 6 files changed, 234 insertions(+), 15 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3c871842..deeeadb8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,8 @@ ## Unreleased +- Add `get_activities_tags(wallet_id: Option)` and `get_pre_activity_metadata_list(wallet_id: Option)`, wallet-scoped reads of the two tag-backup tables where `None` returns every scope. Apps backing up a single wallet scope (for example a `trezor:{hash}` hardware wallet) can now name that scope in the call instead of fetching every scope and filtering client-side, so which records leave the device is visible in the FFI call. The unscoped `get_all_activities_tags()` and `get_all_pre_activity_metadata()` are unchanged and now delegate to the scoped versions. + - The Android AAR now ships targeted R8 consumer keep rules for the UniFFI/JNA FFI surface, so consuming apps can enable R8 full mode without extra keep rules for this library. - Add a generic hardware-wallet catalog with Foundation Passport support, multipart UR QR encoding and decoding, Passport single-signature account export parsing, and signed PSBT finalization across the UniFFI bindings. - Swap status updates now reconcile against Boltz's REST status whenever a swap is (re)subscribed, both on `boltz_start_swap_updates` and when `boltz_create_reverse_swap` adds a swap to a running stream. A confirmed reverse-swap lockup is therefore caught up and auto-claimed even when its live WebSocket event was missed (for example because the updates stream was down while the lockup confirmed), instead of the swap silently stalling until a manual claim. No FFI signature change. diff --git a/README.md b/README.md index 7df8a2e7..2f03553c 100644 --- a/README.md +++ b/README.md @@ -180,6 +180,10 @@ ```rust fn get_all_unique_tags() -> Result, ActivityError> ``` + - [get_activities_tags](src/modules/activity/README.md#usage-examples): Get activity tags for one wallet scope, or every scope when `wallet_id` is `None` + ```rust + fn get_activities_tags(wallet_id: Option) -> Result, ActivityError> + ``` - [get_activities_by_tag](src/modules/activity/README.md#usage-examples): Get activities with a specific tag and optional wallet scope ```rust fn get_activities_by_tag( @@ -205,6 +209,10 @@ search_by_address: bool ) -> Result, ActivityError> ``` + - [get_pre_activity_metadata_list](src/modules/activity/README.md#usage-examples): Get pending metadata for one wallet scope, or every scope when `wallet_id` is `None` + ```rust + fn get_pre_activity_metadata_list(wallet_id: Option) -> Result, ActivityError> + ``` - [add_pre_activity_metadata_tags](src/modules/activity/README.md#usage-examples): Add tags to pending metadata ```rust fn add_pre_activity_metadata_tags(wallet_id: String, payment_id: String, tags: Vec) -> Result<(), ActivityError> diff --git a/src/lib.rs b/src/lib.rs index 0fddfce5..9efa138d 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -752,6 +752,19 @@ pub fn get_all_activities_tags() -> Result, ActivityError> { db.get_all_activities_tags() } +/// Activity tags for a single wallet scope, or every scope when `wallet_id` is `None`. +#[uniffi::export] +pub fn get_activities_tags(wallet_id: Option) -> Result, ActivityError> { + let guard = get_activity_db()?; + let db = guard + .activity_db + .as_ref() + .ok_or(ActivityError::ConnectionError { + error_details: "Database not initialized. Call init_db first.".to_string(), + })?; + db.get_activities_tags(wallet_id.as_deref()) +} + #[uniffi::export] pub fn upsert_tags(activity_tags: Vec) -> Result<(), ActivityError> { let mut guard = get_activity_db()?; @@ -882,6 +895,21 @@ pub fn get_all_pre_activity_metadata() -> Result, Activ db.get_all_pre_activity_metadata() } +/// Pre-activity metadata for a single wallet scope, or every scope when `wallet_id` is `None`. +#[uniffi::export] +pub fn get_pre_activity_metadata_list( + wallet_id: Option, +) -> Result, ActivityError> { + let guard = get_activity_db()?; + let db = guard + .activity_db + .as_ref() + .ok_or(ActivityError::ConnectionError { + error_details: "Database not initialized. Call init_db first.".to_string(), + })?; + db.get_pre_activity_metadata_list(wallet_id.as_deref()) +} + #[uniffi::export] pub fn upsert_closed_channel(channel: ClosedChannelDetails) -> Result<(), ActivityError> { let mut guard = get_activity_db()?; diff --git a/src/modules/activity/README.md b/src/modules/activity/README.md index 7c3ad6df..deb93611 100644 --- a/src/modules/activity/README.md +++ b/src/modules/activity/README.md @@ -71,6 +71,9 @@ fn add_tags(wallet_id: String, activity_id: String, tags: Vec) -> Result fn remove_tags(wallet_id: String, activity_id: String, tags: Vec) -> Result<(), ActivityError> fn get_tags(wallet_id: String, activity_id: String) -> Result, ActivityError> fn get_all_unique_tags() -> Result, ActivityError> +fn get_all_activities_tags() -> Result, ActivityError> +// Wallet-scoped tag read; `None` returns every wallet scope +fn get_activities_tags(wallet_id: Option) -> Result, ActivityError> // Pre-activity metadata fn add_pre_activity_metadata(pre_activity_metadata: PreActivityMetadata) -> Result<(), ActivityError> @@ -85,6 +88,8 @@ fn get_pre_activity_metadata( search_by_address: bool ) -> Result, ActivityError> fn get_all_pre_activity_metadata() -> Result, ActivityError> +// Wallet-scoped metadata read; `None` returns every wallet scope +fn get_pre_activity_metadata_list(wallet_id: Option) -> Result, ActivityError> // Transaction details fn upsert_transaction_details(details_list: Vec) -> Result<(), ActivityError> diff --git a/src/modules/activity/implementation.rs b/src/modules/activity/implementation.rs index f85cdb62..a7b95698 100644 --- a/src/modules/activity/implementation.rs +++ b/src/modules/activity/implementation.rs @@ -2149,21 +2149,42 @@ impl ActivityDB { Ok(tags) } - /// Get all activity tags for backup + /// Get all activity tags for backup, across every wallet scope pub fn get_all_activities_tags(&self) -> Result, ActivityError> { + self.get_activities_tags(None) + } + + /// Get activity tags for backup, scoped to `wallet_id` when it is `Some` + /// and covering every wallet scope when it is `None`. + pub fn get_activities_tags( + &self, + wallet_id: Option<&str>, + ) -> Result, ActivityError> { + let wallet_id = wallet_id.map(Self::normalize_wallet_id).transpose()?; + let wallet_filter = if wallet_id.is_some() { + " WHERE wallet_id = ?1" + } else { + "" + }; + let sql = format!( + "SELECT wallet_id, activity_id, tag + FROM activity_tags{} + ORDER BY wallet_id, activity_id, tag", + wallet_filter + ); + let mut stmt = self .conn - .prepare( - "SELECT wallet_id, activity_id, tag - FROM activity_tags - ORDER BY wallet_id, activity_id, tag", - ) + .prepare(&sql) .map_err(|e| ActivityError::RetrievalError { error_details: format!("Failed to prepare statement: {}", e), })?; + let params: Vec<&String> = wallet_id.iter().collect(); let rows: Vec<(String, String, String)> = stmt - .query_map([], |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?))) + .query_map(rusqlite::params_from_iter(params), |row| { + Ok((row.get(0)?, row.get(1)?, row.get(2)?)) + }) .map_err(|e| ActivityError::RetrievalError { error_details: format!("Failed to execute query: {}", e), })? @@ -2653,15 +2674,37 @@ impl ActivityDB { } } - /// Get all pre-activity metadata for backup - #[allow(clippy::type_complexity)] + /// Get all pre-activity metadata for backup, across every wallet scope pub fn get_all_pre_activity_metadata(&self) -> Result, ActivityError> { - let mut stmt = self.conn.prepare( - "SELECT wallet_id, payment_id, tags, payment_hash, tx_id, address, is_receive, fee_rate, is_transfer, channel_id, created_at FROM pre_activity_metadata ORDER BY wallet_id, payment_id" - ).map_err(|e| ActivityError::RetrievalError { - error_details: format!("Failed to prepare statement: {}", e), - })?; + self.get_pre_activity_metadata_list(None) + } + /// Get pre-activity metadata for backup, scoped to `wallet_id` when it is + /// `Some` and covering every wallet scope when it is `None`. + #[allow(clippy::type_complexity)] + pub fn get_pre_activity_metadata_list( + &self, + wallet_id: Option<&str>, + ) -> Result, ActivityError> { + let wallet_id = wallet_id.map(Self::normalize_wallet_id).transpose()?; + let wallet_filter = if wallet_id.is_some() { + " WHERE wallet_id = ?1" + } else { + "" + }; + let sql = format!( + "SELECT wallet_id, payment_id, tags, payment_hash, tx_id, address, is_receive, fee_rate, is_transfer, channel_id, created_at FROM pre_activity_metadata{} ORDER BY wallet_id, payment_id", + wallet_filter + ); + + let mut stmt = self + .conn + .prepare(&sql) + .map_err(|e| ActivityError::RetrievalError { + error_details: format!("Failed to prepare statement: {}", e), + })?; + + let params: Vec<&String> = wallet_id.iter().collect(); let rows: Vec<( String, String, @@ -2675,7 +2718,7 @@ impl ActivityDB { Option, i64, )> = stmt - .query_map([], |row| { + .query_map(rusqlite::params_from_iter(params), |row| { Ok(( row.get(0)?, row.get(1)?, diff --git a/src/modules/activity/tests.rs b/src/modules/activity/tests.rs index f9a1ff5f..d9474241 100644 --- a/src/modules/activity/tests.rs +++ b/src/modules/activity/tests.rs @@ -3357,6 +3357,71 @@ mod tests { cleanup(&db_path); } + #[test] + fn test_get_activities_tags_wallet_scoped() { + let (mut db, db_path) = setup(); + let hardware_wallet_id = format!("trezor:{}", "a".repeat(64)); + + let mut main = create_test_onchain_activity(); + main.id = "main_activity".to_string(); + main.tx_id = "main_scoped_tags_txid".to_string(); + + let mut hardware = create_test_onchain_activity(); + hardware.wallet_id = hardware_wallet_id.clone(); + hardware.id = "hardware_activity".to_string(); + hardware.tx_id = "hardware_scoped_tags_txid".to_string(); + + db.insert_onchain_activity(&main).unwrap(); + db.insert_onchain_activity(&hardware).unwrap(); + + db.add_tags(DEFAULT_WALLET_ID, &main.id, &["main".to_string()]) + .unwrap(); + db.add_tags(&hardware_wallet_id, &hardware.id, &["hardware".to_string()]) + .unwrap(); + + let default_scope = db.get_activities_tags(Some(DEFAULT_WALLET_ID)).unwrap(); + assert_eq!(default_scope.len(), 1); + assert_eq!(default_scope[0].wallet_id, DEFAULT_WALLET_ID); + assert_eq!(default_scope[0].activity_id, main.id); + assert_eq!(default_scope[0].tags, vec!["main".to_string()]); + + let hardware_scope = db.get_activities_tags(Some(&hardware_wallet_id)).unwrap(); + assert_eq!(hardware_scope.len(), 1); + assert_eq!(hardware_scope[0].wallet_id, hardware_wallet_id); + assert_eq!(hardware_scope[0].activity_id, hardware.id); + assert_eq!(hardware_scope[0].tags, vec!["hardware".to_string()]); + + // None means every scope, matching the legacy unscoped getter. + let scope_ids = |tags: &[ActivityTags]| { + tags.iter() + .map(|entry| (entry.wallet_id.clone(), entry.activity_id.clone())) + .collect::>() + }; + let all_scopes = db.get_activities_tags(None).unwrap(); + assert_eq!(all_scopes.len(), 2); + assert_eq!( + scope_ids(&all_scopes), + scope_ids(&db.get_all_activities_tags().unwrap()) + ); + + // An unknown scope leaks nothing. + assert!(db + .get_activities_tags(Some("ledger:unknown")) + .unwrap() + .is_empty()); + + cleanup(&db_path); + } + + #[test] + fn test_get_activities_tags_rejects_blank_wallet_id() { + let (db, db_path) = setup(); + + assert!(db.get_activities_tags(Some(" ")).is_err()); + + cleanup(&db_path); + } + #[test] fn test_get_all_activities_tags_empty() { let (db, db_path) = setup(); @@ -5400,6 +5465,74 @@ mod tests { cleanup(&db_path); } + #[test] + fn test_get_pre_activity_metadata_list_wallet_scoped() { + let (mut db, db_path) = setup(); + let hardware_wallet_id = format!("trezor:{}", "b".repeat(64)); + + db.add_pre_activity_metadata(&create_test_pre_activity_metadata( + "bc1qmain".to_string(), + ActivityType::Onchain, + vec!["main".to_string()], + )) + .unwrap(); + + let mut hardware = create_test_pre_activity_metadata( + "bc1qhardware".to_string(), + ActivityType::Onchain, + vec!["hardware".to_string()], + ); + hardware.wallet_id = hardware_wallet_id.clone(); + db.add_pre_activity_metadata(&hardware).unwrap(); + + let default_scope = db + .get_pre_activity_metadata_list(Some(DEFAULT_WALLET_ID)) + .unwrap(); + assert_eq!(default_scope.len(), 1); + assert_eq!(default_scope[0].wallet_id, DEFAULT_WALLET_ID); + assert_eq!(default_scope[0].payment_id, "bc1qmain"); + assert_eq!(default_scope[0].tags, vec!["main".to_string()]); + + let hardware_scope = db + .get_pre_activity_metadata_list(Some(&hardware_wallet_id)) + .unwrap(); + assert_eq!(hardware_scope.len(), 1); + assert_eq!(hardware_scope[0].wallet_id, hardware_wallet_id); + assert_eq!(hardware_scope[0].payment_id, "bc1qhardware"); + assert_eq!(hardware_scope[0].tags, vec!["hardware".to_string()]); + + // None means every scope, matching the legacy unscoped getter. + let scope_ids = |metadata: &[PreActivityMetadata]| { + metadata + .iter() + .map(|entry| (entry.wallet_id.clone(), entry.payment_id.clone())) + .collect::>() + }; + let all_scopes = db.get_pre_activity_metadata_list(None).unwrap(); + assert_eq!(all_scopes.len(), 2); + assert_eq!( + scope_ids(&all_scopes), + scope_ids(&db.get_all_pre_activity_metadata().unwrap()) + ); + + // An unknown scope leaks nothing. + assert!(db + .get_pre_activity_metadata_list(Some("ledger:unknown")) + .unwrap() + .is_empty()); + + cleanup(&db_path); + } + + #[test] + fn test_get_pre_activity_metadata_list_rejects_blank_wallet_id() { + let (db, db_path) = setup(); + + assert!(db.get_pre_activity_metadata_list(Some(" ")).is_err()); + + cleanup(&db_path); + } + #[test] fn test_get_all_pre_activity_metadata_empty() { let (db, db_path) = setup(); From 6a9769d1873d6f45814dde0e22d72286c8df8425 Mon Sep 17 00:00:00 2001 From: coreyphillips Date: Thu, 27 Aug 2026 21:14:36 -0400 Subject: [PATCH 2/2] test: cover exported wallet-scoped getters end to end Exercise the exported get_activities_tags and get_pre_activity_metadata_list through init_db and the global database, asserting Some(wallet_id) and None semantics for both, so a wrapper that dropped wallet_id would fail the test. --- src/lib.rs | 138 +++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 138 insertions(+) diff --git a/src/lib.rs b/src/lib.rs index 9efa138d..0b4cfab4 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -3039,3 +3039,141 @@ pub async fn boltz_stop_swap_updates() { .spawn(async move { boltz::stop_swap_updates().await }) .await; } + +#[cfg(test)] +mod tests { + use super::*; + + /// Drives the exported entry points through the global database, so a + /// wrapper that dropped `wallet_id` or fell back to an unscoped getter + /// would fail here even while the `ActivityDB` tests pass. + #[test] + fn test_exported_scoped_getters_preserve_wallet_id() { + let base_path = + std::env::temp_dir().join(format!("bitkitcore_lib_test_{}", rand::random::())); + std::fs::create_dir_all(&base_path).unwrap(); + init_db(base_path.to_string_lossy().into_owned()).unwrap(); + + let hardware_wallet_id = format!("trezor:{}", "c".repeat(64)); + + let mut activity = OnchainActivity { + wallet_id: DEFAULT_WALLET_ID.to_string(), + id: "main_activity".to_string(), + tx_type: PaymentType::Sent, + tx_id: "main_txid".to_string(), + value: 50000, + fee: 500, + fee_rate: 1, + address: "bc1q...".to_string(), + confirmed: true, + timestamp: 1234567890, + is_boosted: false, + boost_tx_ids: vec![], + is_transfer: false, + does_exist: true, + confirm_timestamp: Some(1234568890), + channel_id: None, + transfer_tx_id: None, + contact: None, + created_at: None, + updated_at: None, + seen_at: None, + }; + insert_activity(Activity::Onchain(activity.clone())).unwrap(); + activity.wallet_id = hardware_wallet_id.clone(); + activity.id = "hardware_activity".to_string(); + activity.tx_id = "hardware_txid".to_string(); + insert_activity(Activity::Onchain(activity)).unwrap(); + + upsert_tags(vec![ + ActivityTags { + wallet_id: DEFAULT_WALLET_ID.to_string(), + activity_id: "main_activity".to_string(), + tags: vec!["main".to_string()], + }, + ActivityTags { + wallet_id: hardware_wallet_id.clone(), + activity_id: "hardware_activity".to_string(), + tags: vec!["hardware".to_string()], + }, + ]) + .unwrap(); + + let default_tags = get_activities_tags(Some(DEFAULT_WALLET_ID.to_string())).unwrap(); + assert_eq!(default_tags.len(), 1); + assert_eq!(default_tags[0].wallet_id, DEFAULT_WALLET_ID); + assert_eq!(default_tags[0].tags, vec!["main".to_string()]); + + let hardware_tags = get_activities_tags(Some(hardware_wallet_id.clone())).unwrap(); + assert_eq!(hardware_tags.len(), 1); + assert_eq!(hardware_tags[0].wallet_id, hardware_wallet_id); + assert_eq!(hardware_tags[0].tags, vec!["hardware".to_string()]); + + let tag_scopes = |tags: &[ActivityTags]| { + tags.iter() + .map(|entry| (entry.wallet_id.clone(), entry.activity_id.clone())) + .collect::>() + }; + let all_tags = get_activities_tags(None).unwrap(); + assert_eq!(all_tags.len(), 2); + assert_eq!( + tag_scopes(&all_tags), + tag_scopes(&get_all_activities_tags().unwrap()) + ); + + let mut hardware_metadata = PreActivityMetadata { + wallet_id: DEFAULT_WALLET_ID.to_string(), + payment_id: "bc1qmain".to_string(), + tags: vec!["main".to_string()], + payment_hash: None, + tx_id: None, + address: None, + is_receive: false, + fee_rate: 0, + is_transfer: false, + channel_id: None, + created_at: 0, + }; + add_pre_activity_metadata(hardware_metadata.clone()).unwrap(); + hardware_metadata.wallet_id = hardware_wallet_id.clone(); + hardware_metadata.payment_id = "bc1qhardware".to_string(); + hardware_metadata.tags = vec!["hardware".to_string()]; + add_pre_activity_metadata(hardware_metadata).unwrap(); + + let default_metadata = + get_pre_activity_metadata_list(Some(DEFAULT_WALLET_ID.to_string())).unwrap(); + assert_eq!(default_metadata.len(), 1); + assert_eq!(default_metadata[0].wallet_id, DEFAULT_WALLET_ID); + assert_eq!(default_metadata[0].payment_id, "bc1qmain"); + + let hardware_metadata = + get_pre_activity_metadata_list(Some(hardware_wallet_id.clone())).unwrap(); + assert_eq!(hardware_metadata.len(), 1); + assert_eq!(hardware_metadata[0].wallet_id, hardware_wallet_id); + assert_eq!(hardware_metadata[0].payment_id, "bc1qhardware"); + + let metadata_scopes = |metadata: &[PreActivityMetadata]| { + metadata + .iter() + .map(|entry| (entry.wallet_id.clone(), entry.payment_id.clone())) + .collect::>() + }; + let all_metadata = get_pre_activity_metadata_list(None).unwrap(); + assert_eq!(all_metadata.len(), 2); + assert_eq!( + metadata_scopes(&all_metadata), + metadata_scopes(&get_all_pre_activity_metadata().unwrap()) + ); + + assert!(get_activities_tags(Some("ledger:unknown".to_string())) + .unwrap() + .is_empty()); + assert!( + get_pre_activity_metadata_list(Some("ledger:unknown".to_string())) + .unwrap() + .is_empty() + ); + + std::fs::remove_dir_all(&base_path).ok(); + } +}