From a43f5ee5fcd2b326cebf0e7a635c126f1fa0f491 Mon Sep 17 00:00:00 2001 From: Philipp Walter Date: Tue, 6 Oct 2026 13:42:16 +0200 Subject: [PATCH 1/2] fix: preserve rbf replacement activity fee rates --- bindings/ios/bitkitcore.swift | 29 ++ bindings/ios/bitkitcoreFFI.h | 22 ++ src/lib.rs | 40 +++ src/modules/activity/README.md | 11 + src/modules/activity/implementation.rs | 253 +++++++++++++++- src/modules/activity/mod.rs | 2 + src/modules/activity/rbf_tests.rs | 398 +++++++++++++++++++++++++ 7 files changed, 741 insertions(+), 14 deletions(-) create mode 100644 src/modules/activity/rbf_tests.rs diff --git a/bindings/ios/bitkitcore.swift b/bindings/ios/bitkitcore.swift index 09309b83..45f90a57 100644 --- a/bindings/ios/bitkitcore.swift +++ b/bindings/ios/bitkitcore.swift @@ -28476,6 +28476,19 @@ public func pubkySignUp(secretKeyHex: String, homeserverPublicKeyZ32: String, si errorHandler: FfiConverterTypePubkyError_lift ) } +/** + * Persist a successful outgoing RBF result, including its replacement fee rate, in one transaction. + * Call after the node has returned the replacement txid; this function does not broadcast anything. + */ +public func recordRbfBoost(walletId: String, originalActivityId: String, replacementTxId: String, feeRate: UInt64)throws {try rustCallWithError(FfiConverterTypeActivityError_lift) { + uniffi_bitkitcore_fn_func_record_rbf_boost( + FfiConverterString.lower(walletId), + FfiConverterString.lower(originalActivityId), + FfiConverterString.lower(replacementTxId), + FfiConverterUInt64.lower(feeRate),$0 + ) +} +} /** * Refresh all active CJIT entries in the database with latest data from the LSP */ @@ -29184,6 +29197,16 @@ public func upsertOnchainActivities(activities: [OnchainActivity])throws {try ) } } +/** + * Merge a sync/event snapshot without overwriting a known fee rate for the same wallet and txid. + * Generic upsert_activity/update_activity still accept explicit fee-rate corrections. + */ +public func upsertOnchainActivityPreservingFeeRate(activity: OnchainActivity)throws {try rustCallWithError(FfiConverterTypeActivityError_lift) { + uniffi_bitkitcore_fn_func_upsert_onchain_activity_preserving_fee_rate( + FfiConverterTypeOnchainActivity_lower(activity),$0 + ) +} +} public func upsertOrders(orders: [IBtOrder])async throws { return try await uniffiRustCallAsync( @@ -29741,6 +29764,9 @@ private let initializationResult: InitializationResult = { if (uniffi_bitkitcore_checksum_func_pubky_sign_up() != 61692) { return InitializationResult.apiChecksumMismatch } + if (uniffi_bitkitcore_checksum_func_record_rbf_boost() != 15433) { + return InitializationResult.apiChecksumMismatch + } if (uniffi_bitkitcore_checksum_func_refresh_active_cjit_entries() != 5324) { return InitializationResult.apiChecksumMismatch } @@ -29894,6 +29920,9 @@ private let initializationResult: InitializationResult = { if (uniffi_bitkitcore_checksum_func_upsert_onchain_activities() != 15461) { return InitializationResult.apiChecksumMismatch } + if (uniffi_bitkitcore_checksum_func_upsert_onchain_activity_preserving_fee_rate() != 13661) { + return InitializationResult.apiChecksumMismatch + } if (uniffi_bitkitcore_checksum_func_upsert_orders() != 45856) { return InitializationResult.apiChecksumMismatch } diff --git a/bindings/ios/bitkitcoreFFI.h b/bindings/ios/bitkitcoreFFI.h index 7ebda6aa..369e86ca 100644 --- a/bindings/ios/bitkitcoreFFI.h +++ b/bindings/ios/bitkitcoreFFI.h @@ -1462,6 +1462,11 @@ uint64_t uniffi_bitkitcore_fn_func_pubky_sign_in(RustBuffer secret_key_hex uint64_t uniffi_bitkitcore_fn_func_pubky_sign_up(RustBuffer secret_key_hex, RustBuffer homeserver_public_key_z32, RustBuffer signup_code ); #endif +#ifndef UNIFFI_FFIDEF_UNIFFI_BITKITCORE_FN_FUNC_RECORD_RBF_BOOST +#define UNIFFI_FFIDEF_UNIFFI_BITKITCORE_FN_FUNC_RECORD_RBF_BOOST +void uniffi_bitkitcore_fn_func_record_rbf_boost(RustBuffer wallet_id, RustBuffer original_activity_id, RustBuffer replacement_tx_id, uint64_t fee_rate, RustCallStatus *_Nonnull out_status +); +#endif #ifndef UNIFFI_FFIDEF_UNIFFI_BITKITCORE_FN_FUNC_REFRESH_ACTIVE_CJIT_ENTRIES #define UNIFFI_FFIDEF_UNIFFI_BITKITCORE_FN_FUNC_REFRESH_ACTIVE_CJIT_ENTRIES uint64_t uniffi_bitkitcore_fn_func_refresh_active_cjit_entries(void @@ -1729,6 +1734,11 @@ void uniffi_bitkitcore_fn_func_upsert_lightning_activities(RustBuffer activities void uniffi_bitkitcore_fn_func_upsert_onchain_activities(RustBuffer activities, RustCallStatus *_Nonnull out_status ); #endif +#ifndef UNIFFI_FFIDEF_UNIFFI_BITKITCORE_FN_FUNC_UPSERT_ONCHAIN_ACTIVITY_PRESERVING_FEE_RATE +#define UNIFFI_FFIDEF_UNIFFI_BITKITCORE_FN_FUNC_UPSERT_ONCHAIN_ACTIVITY_PRESERVING_FEE_RATE +void uniffi_bitkitcore_fn_func_upsert_onchain_activity_preserving_fee_rate(RustBuffer activity, RustCallStatus *_Nonnull out_status +); +#endif #ifndef UNIFFI_FFIDEF_UNIFFI_BITKITCORE_FN_FUNC_UPSERT_ORDERS #define UNIFFI_FFIDEF_UNIFFI_BITKITCORE_FN_FUNC_UPSERT_ORDERS uint64_t uniffi_bitkitcore_fn_func_upsert_orders(RustBuffer orders @@ -2938,6 +2948,12 @@ uint16_t uniffi_bitkitcore_checksum_func_pubky_sign_in(void #define UNIFFI_FFIDEF_UNIFFI_BITKITCORE_CHECKSUM_FUNC_PUBKY_SIGN_UP uint16_t uniffi_bitkitcore_checksum_func_pubky_sign_up(void +); +#endif +#ifndef UNIFFI_FFIDEF_UNIFFI_BITKITCORE_CHECKSUM_FUNC_RECORD_RBF_BOOST +#define UNIFFI_FFIDEF_UNIFFI_BITKITCORE_CHECKSUM_FUNC_RECORD_RBF_BOOST +uint16_t uniffi_bitkitcore_checksum_func_record_rbf_boost(void + ); #endif #ifndef UNIFFI_FFIDEF_UNIFFI_BITKITCORE_CHECKSUM_FUNC_REFRESH_ACTIVE_CJIT_ENTRIES @@ -3244,6 +3260,12 @@ uint16_t uniffi_bitkitcore_checksum_func_upsert_lightning_activities(void #define UNIFFI_FFIDEF_UNIFFI_BITKITCORE_CHECKSUM_FUNC_UPSERT_ONCHAIN_ACTIVITIES uint16_t uniffi_bitkitcore_checksum_func_upsert_onchain_activities(void +); +#endif +#ifndef UNIFFI_FFIDEF_UNIFFI_BITKITCORE_CHECKSUM_FUNC_UPSERT_ONCHAIN_ACTIVITY_PRESERVING_FEE_RATE +#define UNIFFI_FFIDEF_UNIFFI_BITKITCORE_CHECKSUM_FUNC_UPSERT_ONCHAIN_ACTIVITY_PRESERVING_FEE_RATE +uint16_t uniffi_bitkitcore_checksum_func_upsert_onchain_activity_preserving_fee_rate(void + ); #endif #ifndef UNIFFI_FFIDEF_UNIFFI_BITKITCORE_CHECKSUM_FUNC_UPSERT_ORDERS diff --git a/src/lib.rs b/src/lib.rs index fd356d8f..a5b60a1c 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -592,6 +592,46 @@ pub fn upsert_activity(activity: Activity) -> Result<(), ActivityError> { db.upsert_activity(&activity) } +/// Persist a successful outgoing RBF result, including its replacement fee rate, in one transaction. +/// Call after the node has returned the replacement txid; this function does not broadcast anything. +#[uniffi::export] +pub fn record_rbf_boost( + wallet_id: String, + original_activity_id: String, + replacement_tx_id: String, + fee_rate: u64, +) -> Result<(), ActivityError> { + let mut guard = get_activity_db()?; + let db = guard + .activity_db + .as_mut() + .ok_or(ActivityError::ConnectionError { + error_details: "Database not initialized. Call init_db first.".to_string(), + })?; + db.record_rbf_boost( + &wallet_id, + &original_activity_id, + &replacement_tx_id, + fee_rate, + ) +} + +/// Merge a sync/event snapshot without overwriting a known fee rate for the same wallet and txid. +/// Generic upsert_activity/update_activity still accept explicit fee-rate corrections. +#[uniffi::export] +pub fn upsert_onchain_activity_preserving_fee_rate( + activity: OnchainActivity, +) -> Result<(), ActivityError> { + let mut guard = get_activity_db()?; + let db = guard + .activity_db + .as_mut() + .ok_or(ActivityError::ConnectionError { + error_details: "Database not initialized. Call init_db first.".to_string(), + })?; + db.upsert_onchain_activity_preserving_fee_rate(&activity) +} + #[uniffi::export] pub fn insert_activity(activity: Activity) -> Result<(), ActivityError> { let mut guard = get_activity_db()?; diff --git a/src/modules/activity/README.md b/src/modules/activity/README.md index deb93611..dc77f751 100644 --- a/src/modules/activity/README.md +++ b/src/modules/activity/README.md @@ -51,6 +51,17 @@ fn update_activity(activity_id: String, activity: Activity) -> Result<(), Activi // Insert or update an activity fn upsert_activity(activity: Activity) -> Result<(), ActivityError> +// Record a successful RBF result atomically (does not broadcast a transaction) +fn record_rbf_boost( + wallet_id: String, + original_activity_id: String, + replacement_tx_id: String, + fee_rate: u64 +) -> Result<(), ActivityError> + +// Merge a sync/event snapshot without replacing a known rate for the same wallet and txid +fn upsert_onchain_activity_preserving_fee_rate(activity: OnchainActivity) -> Result<(), ActivityError> + // Get a specific activity by wallet ID and activity ID fn get_activity_by_id(wallet_id: String, activity_id: String) -> Result, ActivityError> diff --git a/src/modules/activity/implementation.rs b/src/modules/activity/implementation.rs index 784057ad..d92c6e96 100644 --- a/src/modules/activity/implementation.rs +++ b/src/modules/activity/implementation.rs @@ -3,7 +3,7 @@ use crate::activity::{ OnchainActivity, PaymentState, PaymentType, PreActivityMetadata, SortDirection, TransactionDetails, TxInput, TxOutput, }; -use rusqlite::{Connection, OptionalExtension}; +use rusqlite::{Connection, OptionalExtension, Transaction}; use serde_json; pub struct ActivityDB { @@ -742,6 +742,111 @@ impl ActivityDB { } } + /// Record a successful RBF result atomically. The replacement's rate is stored as pending + /// metadata until its activity arrives, or applied immediately if that activity already exists. + /// A removed/confirmed original is not resurrected or marked as pending again. + pub fn record_rbf_boost( + &mut self, + wallet_id: &str, + original_activity_id: &str, + replacement_tx_id: &str, + fee_rate: u64, + ) -> Result<(), ActivityError> { + let wallet_id = Self::normalize_wallet_id(wallet_id)?; + if original_activity_id.trim().is_empty() + || replacement_tx_id.trim().is_empty() + || fee_rate == 0 + || fee_rate > i64::MAX as u64 + { + return Err(ActivityError::InvalidActivity { + error_details: + "RBF requires non-blank identifiers and a positive SQLite-compatible fee rate" + .to_string(), + }); + } + let record_error = |e: rusqlite::Error| ActivityError::DataError { + error_details: format!("Failed to record RBF boost: {}", e), + }; + let tx = self.conn.transaction().map_err(record_error)?; + let original = tx + .query_row( + "SELECT o.tx_id, a.tx_type FROM onchain_activity o + JOIN activities a ON a.wallet_id = o.wallet_id AND a.id = o.id + WHERE o.wallet_id = ?1 AND o.id = ?2", + rusqlite::params![&wallet_id, original_activity_id], + |row| Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?)), + ) + .optional() + .map_err(record_error)?; + match original { + Some((original_tx_id, tx_type)) + if original_tx_id != replacement_tx_id && tx_type == "sent" => {} + _ => { + return Err(ActivityError::InvalidActivity { + error_details: + "RBF original must be a sent activity in this wallet with a different txid" + .to_string(), + }) + } + } + + tx.execute( + "INSERT INTO pre_activity_metadata ( + wallet_id, payment_id, tags, tx_id, is_receive, fee_rate, created_at + ) VALUES (?1, ?2, '[]', ?2, 0, ?3, strftime('%s', 'now')) + ON CONFLICT(wallet_id, payment_id) DO UPDATE SET + fee_rate = excluded.fee_rate, tx_id = excluded.tx_id, is_receive = 0", + rusqlite::params![&wallet_id, replacement_tx_id, fee_rate as i64], + ) + .map_err(record_error)?; + tx.execute( + "UPDATE onchain_activity SET is_boosted = 1, fee_rate = ?1 + WHERE wallet_id = ?2 AND id = ?3 AND does_exist = 1 AND confirmed = 0", + rusqlite::params![fee_rate as i64, &wallet_id, original_activity_id], + ) + .map_err(record_error)?; + tx.execute( + "UPDATE activities SET updated_at = strftime('%s', 'now') + WHERE wallet_id = ?1 AND id = ?2 AND id IN ( + SELECT id FROM onchain_activity WHERE wallet_id = ?1 AND id = ?2 AND does_exist = 1 AND confirmed = 0 + )", + rusqlite::params![&wallet_id, original_activity_id], + ).map_err(record_error)?; + + let replacement_id = tx + .query_row( + "SELECT id FROM onchain_activity WHERE wallet_id = ?1 AND tx_id = ?2", + rusqlite::params![&wallet_id, replacement_tx_id], + |row| row.get::<_, String>(0), + ) + .optional() + .map_err(record_error)?; + if let Some(replacement_id) = replacement_id { + let metadata = Self::get_pre_activity_metadata_in_connection( + &tx, + &wallet_id, + replacement_tx_id, + false, + )? + .ok_or(ActivityError::DataError { + error_details: "Missing replacement fee metadata during RBF recording".to_string(), + })?; + Self::transfer_pre_activity_metadata_in_transaction( + &tx, + &wallet_id, + replacement_tx_id, + &replacement_id, + false, + metadata, + )?; + tx.execute( + "UPDATE activities SET updated_at = strftime('%s', 'now') WHERE wallet_id = ?1 AND id = ?2", + rusqlite::params![&wallet_id, &replacement_id], + ).map_err(record_error)?; + } + tx.commit().map_err(record_error) + } + /// Inserts a new onchain activity into the database. pub fn insert_onchain_activity( &mut self, @@ -915,6 +1020,23 @@ impl ActivityDB { pub fn upsert_onchain_activities( &mut self, activities: &[OnchainActivity], + ) -> Result<(), ActivityError> { + self.upsert_onchain_activities_with_fee_rate_policy(activities, false) + } + + /// Merge a sync/event snapshot without replacing a fee rate already known for its txid. + /// Full upsert/update APIs retain their normal overwrite semantics for deliberate corrections. + pub fn upsert_onchain_activity_preserving_fee_rate( + &mut self, + activity: &OnchainActivity, + ) -> Result<(), ActivityError> { + self.upsert_onchain_activities_with_fee_rate_policy(std::slice::from_ref(activity), true) + } + + fn upsert_onchain_activities_with_fee_rate_policy( + &mut self, + activities: &[OnchainActivity], + preserve_fee_rate: bool, ) -> Result<(), ActivityError> { if activities.is_empty() { return Ok(()); @@ -966,7 +1088,7 @@ impl ActivityDB { confirmed = excluded.confirmed, value = excluded.value, fee = excluded.fee, - fee_rate = excluded.fee_rate, + fee_rate = CASE WHEN ?16 THEN onchain_activity.fee_rate ELSE excluded.fee_rate END, is_boosted = excluded.is_boosted, boost_tx_ids = excluded.boost_tx_ids, is_transfer = excluded.is_transfer, @@ -986,10 +1108,55 @@ impl ActivityDB { }); } let wallet_id = Self::normalize_wallet_id(&activity.wallet_id)?; + let activity_id = if preserve_fee_rate { + if activity.tx_id.trim().is_empty() { + return Err(ActivityError::InvalidActivity { + error_details: "Sync activity txid must not be blank".to_string(), + }); + } + tx.query_row( + "SELECT id FROM onchain_activity WHERE wallet_id = ?1 AND tx_id = ?2", + rusqlite::params![&wallet_id, &activity.tx_id], + |row| row.get::<_, String>(0), + ) + .optional() + .map_err(|e| ActivityError::RetrievalError { + error_details: format!("Failed to resolve sync activity: {}", e), + })? + .unwrap_or_else(|| activity.id.clone()) + } else { + activity.id.clone() + }; + + if preserve_fee_rate { + let existing = tx + .query_row( + "SELECT a.activity_type, o.tx_id FROM activities a + LEFT JOIN onchain_activity o ON a.wallet_id = o.wallet_id AND a.id = o.id + WHERE a.wallet_id = ?1 AND a.id = ?2", + rusqlite::params![&wallet_id, &activity_id], + |row| Ok((row.get::<_, String>(0)?, row.get::<_, Option>(1)?)), + ) + .optional() + .map_err(|e| ActivityError::RetrievalError { + error_details: format!( + "Failed to validate sync activity identity: {}", + e + ), + })?; + if existing.is_some_and(|(kind, txid)| { + kind != "onchain" || txid.as_deref() != Some(&activity.tx_id) + }) { + return Err(ActivityError::InvalidActivity { + error_details: "Sync activity id belongs to a different transaction" + .to_string(), + }); + } + } stmt_act .execute(( - &activity.id, + &activity_id, &wallet_id, Self::payment_type_to_string(&activity.tx_type), activity.timestamp, @@ -1004,7 +1171,7 @@ impl ActivityDB { stmt_onchain .execute(( &wallet_id, - &activity.id, + &activity_id, &activity.tx_id, &activity.address, activity.confirmed, @@ -1018,10 +1185,35 @@ impl ActivityDB { activity.confirm_timestamp, &activity.channel_id, &activity.transfer_tx_id, + preserve_fee_rate, )) .map_err(|e| ActivityError::InsertError { error_details: format!("Failed to upsert onchain_activity: {}", e), })?; + + if preserve_fee_rate { + let search_by_address = activity.tx_type == PaymentType::Received; + let search_key = if search_by_address { + &activity.address + } else { + &activity.tx_id + }; + if let Some(metadata) = Self::get_pre_activity_metadata_in_connection( + &tx, + &wallet_id, + search_key, + search_by_address, + )? { + Self::transfer_pre_activity_metadata_in_transaction( + &tx, + &wallet_id, + search_key, + &activity_id, + search_by_address, + metadata, + )?; + } + } } } @@ -1029,8 +1221,10 @@ impl ActivityDB { error_details: format!("Failed to commit transaction: {}", e), })?; - for activity in activities { - self.apply_pre_activity_metadata_for_onchain(activity, &activity.id); + if !preserve_fee_rate { + for activity in activities { + self.apply_pre_activity_metadata_for_onchain(activity, &activity.id); + } } Ok(()) @@ -2629,6 +2823,20 @@ impl ActivityDB { wallet_id: &str, search_key: &str, search_by_address: bool, + ) -> Result, ActivityError> { + Self::get_pre_activity_metadata_in_connection( + &self.conn, + wallet_id, + search_key, + search_by_address, + ) + } + + fn get_pre_activity_metadata_in_connection( + conn: &Connection, + wallet_id: &str, + search_key: &str, + search_by_address: bool, ) -> Result, ActivityError> { let wallet_id = Self::normalize_wallet_id(wallet_id)?; let sql = if search_by_address { @@ -2645,8 +2853,7 @@ impl ActivityDB { WHERE wallet_id = ?1 AND payment_id = ?2" }; - let mut stmt = self - .conn + let mut stmt = conn .prepare(sql) .map_err(|e| ActivityError::RetrievalError { error_details: format!("Failed to prepare statement: {}", e), @@ -2825,8 +3032,6 @@ impl ActivityDB { None => return Ok(Vec::new()), }; - let tags = metadata.tags; - let tx = self .conn .transaction() @@ -2834,6 +3039,30 @@ impl ActivityDB { error_details: format!("Failed to start transaction: {}", e), })?; + let tags = Self::transfer_pre_activity_metadata_in_transaction( + &tx, + &wallet_id, + search_key, + activity_id, + search_by_address, + metadata, + )?; + tx.commit().map_err(|e| ActivityError::DataError { + error_details: format!("Failed to commit transaction: {}", e), + })?; + Ok(tags) + } + + fn transfer_pre_activity_metadata_in_transaction( + tx: &Transaction<'_>, + wallet_id: &str, + search_key: &str, + activity_id: &str, + search_by_address: bool, + metadata: PreActivityMetadata, + ) -> Result, ActivityError> { + let tags = metadata.tags; + if let Some(address) = &metadata.address { if !address.is_empty() { tx.execute( @@ -2915,10 +3144,6 @@ impl ActivityDB { })?; } - tx.commit().map_err(|e| ActivityError::DataError { - error_details: format!("Failed to commit transaction: {}", e), - })?; - Ok(tags) } diff --git a/src/modules/activity/mod.rs b/src/modules/activity/mod.rs index 290c7b87..76e6bd4a 100644 --- a/src/modules/activity/mod.rs +++ b/src/modules/activity/mod.rs @@ -1,6 +1,8 @@ mod backup_migration; mod errors; mod implementation; +#[cfg(test)] +mod rbf_tests; mod tests; mod types; diff --git a/src/modules/activity/rbf_tests.rs b/src/modules/activity/rbf_tests.rs new file mode 100644 index 00000000..45de60fc --- /dev/null +++ b/src/modules/activity/rbf_tests.rs @@ -0,0 +1,398 @@ +use super::{ + Activity, ActivityDB, OnchainActivity, PaymentType, PreActivityMetadata, DEFAULT_WALLET_ID, +}; + +fn original() -> OnchainActivity { + OnchainActivity { + wallet_id: DEFAULT_WALLET_ID.into(), + id: "original-activity".into(), + tx_type: PaymentType::Sent, + tx_id: "original-tx".into(), + value: 10000, + fee: 100, + fee_rate: 1, + address: "recipient".into(), + confirmed: false, + timestamp: 100, + is_boosted: false, + boost_tx_ids: vec![], + is_transfer: false, + does_exist: true, + confirm_timestamp: None, + channel_id: None, + transfer_tx_id: None, + contact: None, + created_at: None, + updated_at: None, + seen_at: None, + } +} + +fn replacement() -> OnchainActivity { + OnchainActivity { + id: "replacement-activity".into(), + tx_id: "replacement-tx".into(), + fee: 2500, + ..original() + } +} + +fn setup() -> (ActivityDB, tempfile::TempDir) { + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().join("activity.sqlite"); + let mut db = ActivityDB::new(path.to_str().unwrap()).unwrap(); + db.insert_onchain_activity(&original()).unwrap(); + (db, directory) +} + +fn pending_metadata() -> PreActivityMetadata { + PreActivityMetadata { + wallet_id: DEFAULT_WALLET_ID.into(), + payment_id: "replacement-tx".into(), + tags: vec!["pending-tag".into()], + payment_hash: None, + tx_id: Some("replacement-tx".into()), + address: None, + is_receive: false, + fee_rate: 3, + is_transfer: false, + channel_id: None, + created_at: 100, + } +} + +fn record(db: &mut ActivityDB) { + db.record_rbf_boost(DEFAULT_WALLET_ID, "original-activity", "replacement-tx", 25) + .unwrap(); +} + +#[test] +fn replacement_fee_survives_creation_stale_sync_confirmation_and_reopen() { + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().join("activity.sqlite"); + let mut db = ActivityDB::new(path.to_str().unwrap()).unwrap(); + db.insert_onchain_activity(&original()).unwrap(); + record(&mut db); + assert_eq!( + db.get_pre_activity_metadata(DEFAULT_WALLET_ID, "replacement-tx", false) + .unwrap() + .unwrap() + .fee_rate, + 25 + ); + drop(db); + + let mut db = ActivityDB::new(path.to_str().unwrap()).unwrap(); + let mut stale = replacement(); + db.upsert_onchain_activity_preserving_fee_rate(&stale) + .unwrap(); + assert!(db + .get_pre_activity_metadata(DEFAULT_WALLET_ID, "replacement-tx", false) + .unwrap() + .is_none()); + stale.confirmed = true; + stale.confirm_timestamp = Some(200); + stale.id = "different-payment-id".into(); + db.upsert_onchain_activity_preserving_fee_rate(&stale) + .unwrap(); + let stored = db + .get_activity_by_tx_id(DEFAULT_WALLET_ID, "replacement-tx") + .unwrap() + .unwrap(); + assert_eq!(stored.id, "replacement-activity"); + assert_eq!(stored.fee_rate, 25); + assert_eq!(stored.fee, 2500); + assert!(stored.confirmed); + assert_eq!(stored.confirm_timestamp, Some(200)); + drop(db); + let db = ActivityDB::new(path.to_str().unwrap()).unwrap(); + assert_eq!( + db.get_activity_by_tx_id(DEFAULT_WALLET_ID, "replacement-tx") + .unwrap() + .unwrap() + .fee_rate, + 25 + ); +} + +#[test] +fn recording_after_replacement_arrives_preserves_other_fields_and_tags() { + let (mut db, _directory) = setup(); + let mut activity = replacement(); + activity.contact = Some("contact".into()); + activity.is_boosted = true; + activity.boost_tx_ids = vec!["original-tx".into()]; + db.insert_onchain_activity(&activity).unwrap(); + db.mark_activity_as_seen(DEFAULT_WALLET_ID, &activity.id, 123) + .unwrap(); + db.add_tags(DEFAULT_WALLET_ID, &activity.id, &["existing-tag".into()]) + .unwrap(); + db.add_pre_activity_metadata(&pending_metadata()).unwrap(); + record(&mut db); + let stored = db + .get_activity_by_tx_id(DEFAULT_WALLET_ID, "replacement-tx") + .unwrap() + .unwrap(); + assert_eq!(stored.fee_rate, 25); + assert_eq!(stored.fee, 2500); + assert_eq!(stored.contact, activity.contact); + assert_eq!(stored.seen_at, Some(123)); + assert_eq!(stored.boost_tx_ids, activity.boost_tx_ids); + assert!(stored.is_boosted); + let mut tags = db.get_tags(DEFAULT_WALLET_ID, &activity.id).unwrap(); + tags.sort(); + assert_eq!(tags, vec!["existing-tag", "pending-tag"]); + assert!(db + .get_pre_activity_metadata(DEFAULT_WALLET_ID, "replacement-tx", false) + .unwrap() + .is_none()); + db.upsert_onchain_activity_preserving_fee_rate(&replacement()) + .unwrap(); + assert_eq!( + db.get_activity_by_tx_id(DEFAULT_WALLET_ID, "replacement-tx") + .unwrap() + .unwrap() + .fee_rate, + 25 + ); +} + +#[test] +fn pending_tags_survive_recording_before_replacement_arrives() { + let (mut db, _directory) = setup(); + db.add_pre_activity_metadata(&pending_metadata()).unwrap(); + record(&mut db); + let metadata = db + .get_pre_activity_metadata(DEFAULT_WALLET_ID, "replacement-tx", false) + .unwrap() + .unwrap(); + assert_eq!(metadata.tags, vec!["pending-tag"]); + assert_eq!(metadata.fee_rate, 25); + db.upsert_onchain_activity_preserving_fee_rate(&replacement()) + .unwrap(); + assert_eq!( + db.get_tags(DEFAULT_WALLET_ID, "replacement-activity") + .unwrap(), + vec!["pending-tag"] + ); +} + +#[test] +fn removed_original_is_not_resurrected_by_late_boost_completion() { + let (mut db, _directory) = setup(); + let mut removed = original(); + removed.does_exist = false; + db.update_onchain_activity_by_id(&removed.id, &removed) + .unwrap(); + record(&mut db); + let stored = db + .get_activity_by_tx_id(DEFAULT_WALLET_ID, "original-tx") + .unwrap() + .unwrap(); + assert!(!stored.does_exist); + assert!(!stored.is_boosted); + db.upsert_onchain_activity_preserving_fee_rate(&replacement()) + .unwrap(); + assert_eq!( + db.get_activity_by_tx_id(DEFAULT_WALLET_ID, "replacement-tx") + .unwrap() + .unwrap() + .fee_rate, + 25 + ); +} + +#[test] +fn recording_failure_rolls_back_original_rate_pending_metadata_and_replacement() { + let (mut db, _directory) = setup(); + db.insert_onchain_activity(&replacement()).unwrap(); + db.add_pre_activity_metadata(&pending_metadata()).unwrap(); + db.conn + .execute_batch( + "CREATE TRIGGER fail_replacement_fee BEFORE UPDATE OF fee_rate ON onchain_activity + WHEN OLD.tx_id = 'replacement-tx' BEGIN SELECT RAISE(ABORT, 'forced write failure'); END;", + ) + .unwrap(); + assert!(db + .record_rbf_boost(DEFAULT_WALLET_ID, "original-activity", "replacement-tx", 25) + .is_err()); + let original = db + .get_activity_by_tx_id(DEFAULT_WALLET_ID, "original-tx") + .unwrap() + .unwrap(); + assert_eq!(original.fee_rate, 1); + assert!(!original.is_boosted); + assert_eq!( + db.get_activity_by_tx_id(DEFAULT_WALLET_ID, "replacement-tx") + .unwrap() + .unwrap() + .fee_rate, + 1 + ); + assert_eq!( + db.get_pre_activity_metadata(DEFAULT_WALLET_ID, "replacement-tx", false) + .unwrap() + .unwrap() + .fee_rate, + 3 + ); + assert!(db + .get_tags(DEFAULT_WALLET_ID, "replacement-activity") + .unwrap() + .is_empty()); +} + +#[test] +fn sync_metadata_failure_rolls_back_activity_creation_and_keeps_pending_rate() { + let (mut db, _directory) = setup(); + record(&mut db); + db.conn + .execute_batch( + "CREATE TRIGGER fail_metadata_delete BEFORE DELETE ON pre_activity_metadata + BEGIN SELECT RAISE(ABORT, 'forced delete failure'); END;", + ) + .unwrap(); + assert!(db + .upsert_onchain_activity_preserving_fee_rate(&replacement()) + .is_err()); + assert!(db + .get_activity_by_tx_id(DEFAULT_WALLET_ID, "replacement-tx") + .unwrap() + .is_none()); + assert_eq!( + db.get_pre_activity_metadata(DEFAULT_WALLET_ID, "replacement-tx", false) + .unwrap() + .unwrap() + .fee_rate, + 25 + ); + db.conn + .execute_batch("DROP TRIGGER fail_metadata_delete") + .unwrap(); + db.upsert_onchain_activity_preserving_fee_rate(&replacement()) + .unwrap(); + assert_eq!( + db.get_activity_by_tx_id(DEFAULT_WALLET_ID, "replacement-tx") + .unwrap() + .unwrap() + .fee_rate, + 25 + ); +} + +#[test] +fn recording_and_preserving_rates_are_wallet_scoped() { + let (mut db, _directory) = setup(); + let mut other_original = original(); + other_original.wallet_id = "hardware-wallet".into(); + let mut other_replacement = replacement(); + other_replacement.wallet_id = other_original.wallet_id.clone(); + other_replacement.fee_rate = 7; + db.insert_onchain_activity(&other_original).unwrap(); + db.insert_onchain_activity(&other_replacement).unwrap(); + record(&mut db); + db.upsert_onchain_activity_preserving_fee_rate(&replacement()) + .unwrap(); + assert_eq!( + db.get_activity_by_tx_id(DEFAULT_WALLET_ID, "replacement-tx") + .unwrap() + .unwrap() + .fee_rate, + 25 + ); + assert_eq!( + db.get_activity_by_tx_id("hardware-wallet", "replacement-tx") + .unwrap() + .unwrap() + .fee_rate, + 7 + ); + assert!( + !db.get_activity_by_tx_id("hardware-wallet", "original-tx") + .unwrap() + .unwrap() + .is_boosted + ); + assert!(db + .get_pre_activity_metadata("hardware-wallet", "replacement-tx", false) + .unwrap() + .is_none()); +} + +#[test] +fn full_updates_and_upserts_still_allow_deliberate_fee_corrections() { + let (mut db, _directory) = setup(); + db.insert_onchain_activity(&replacement()).unwrap(); + record(&mut db); + let mut correction = replacement(); + correction.fee_rate = 13; + db.upsert_activity(&Activity::Onchain(correction.clone())) + .unwrap(); + assert_eq!( + db.get_activity_by_tx_id(DEFAULT_WALLET_ID, "replacement-tx") + .unwrap() + .unwrap() + .fee_rate, + 13 + ); + correction.fee_rate = 7; + db.update_onchain_activity_by_id(&correction.id, &correction) + .unwrap(); + db.upsert_onchain_activity_preserving_fee_rate(&replacement()) + .unwrap(); + assert_eq!( + db.get_activity_by_tx_id(DEFAULT_WALLET_ID, "replacement-tx") + .unwrap() + .unwrap() + .fee_rate, + 7 + ); +} + +#[test] +fn invalid_record_requests_do_not_write_anything() { + let (mut db, _directory) = setup(); + for (wallet, original, replacement, rate) in [ + (DEFAULT_WALLET_ID, "original-activity", "replacement-tx", 0), + ( + DEFAULT_WALLET_ID, + "original-activity", + "replacement-tx", + u64::MAX, + ), + (" ", "original-activity", "replacement-tx", 25), + ("other-wallet", "original-activity", "replacement-tx", 25), + (DEFAULT_WALLET_ID, "missing", "replacement-tx", 25), + (DEFAULT_WALLET_ID, "original-activity", "original-tx", 25), + (DEFAULT_WALLET_ID, "original-activity", " ", 25), + ] { + assert!(db + .record_rbf_boost(wallet, original, replacement, rate) + .is_err()); + } + let original = db + .get_activity_by_tx_id(DEFAULT_WALLET_ID, "original-tx") + .unwrap() + .unwrap(); + assert!(!original.is_boosted); + assert_eq!(original.fee_rate, 1); + assert!(db.get_all_pre_activity_metadata().unwrap().is_empty()); +} + +#[test] +fn sync_id_collision_cannot_overwrite_another_transaction() { + let (mut db, _directory) = setup(); + let mut collision = replacement(); + collision.id = original().id; + assert!(db + .upsert_onchain_activity_preserving_fee_rate(&collision) + .is_err()); + assert!(db + .get_activity_by_tx_id(DEFAULT_WALLET_ID, "original-tx") + .unwrap() + .is_some()); + assert!(db + .get_activity_by_tx_id(DEFAULT_WALLET_ID, "replacement-tx") + .unwrap() + .is_none()); +} From 7fdb2d7a65a22266d156d96b30c57cf2210b3451 Mon Sep 17 00:00:00 2001 From: Philipp Walter Date: Tue, 6 Oct 2026 13:57:36 +0200 Subject: [PATCH 2/2] docs: explain original rate update during rbf --- src/modules/activity/implementation.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/src/modules/activity/implementation.rs b/src/modules/activity/implementation.rs index d92c6e96..c3691259 100644 --- a/src/modules/activity/implementation.rs +++ b/src/modules/activity/implementation.rs @@ -744,6 +744,7 @@ impl ActivityDB { /// Record a successful RBF result atomically. The replacement's rate is stored as pending /// metadata until its activity arrives, or applied immediately if that activity already exists. + /// Also updates the still-pending original's rate so its estimate reflects the boost until the replacement arrives. /// A removed/confirmed original is not resurrected or marked as pending again. pub fn record_rbf_boost( &mut self,