Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
235 changes: 235 additions & 0 deletions src/modules/activity/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -853,6 +853,241 @@ mod tests {
cleanup(&db_path);
}

fn json_has_wallet_id<T: serde::Serialize>(value: &T) -> bool {
serde_json::to_value(value)
.unwrap()
.get("wallet_id")
.is_some()
}

#[test]
fn test_default_wallet_activity_serializes_as_v1_payload() {
// Default-wallet records must omit `wallet_id` entirely, keeping the original
// (v1) JSON payload byte-identical. Round-tripping that v1 JSON must decode the
// field back to the default wallet.
let onchain = create_test_onchain_activity();
assert_eq!(onchain.wallet_id, DEFAULT_WALLET_ID);
let onchain_json = serde_json::to_string(&onchain).unwrap();
assert!(
!onchain_json.contains("wallet_id"),
"default-wallet onchain activity must not serialize wallet_id (v1 payload): {onchain_json}"
);
let decoded: OnchainActivity = serde_json::from_str(&onchain_json).unwrap();
assert_eq!(decoded.wallet_id, DEFAULT_WALLET_ID);

let lightning = create_test_lightning_activity();
assert_eq!(lightning.wallet_id, DEFAULT_WALLET_ID);
let lightning_json = serde_json::to_string(&lightning).unwrap();
assert!(
!lightning_json.contains("wallet_id"),
"default-wallet lightning activity must not serialize wallet_id (v1 payload): {lightning_json}"
);
let decoded: LightningActivity = serde_json::from_str(&lightning_json).unwrap();
assert_eq!(decoded.wallet_id, DEFAULT_WALLET_ID);
}

#[test]
fn test_old_v1_activity_json_without_wallet_id_decodes() {
// Old JSON authored before wallet_id existed (no wallet_id key) must still decode,
// defaulting to the built-in Bitkit wallet.
let onchain_v1 = r#"{
"id": "legacy_onchain",
"tx_type": "Sent",
"tx_id": "legacy_txid",
"value": 5000,
"fee": 50,
"fee_rate": 1,
"address": "bc1qlegacy",
"confirmed": true,
"timestamp": 1234567890,
"is_boosted": false,
"boost_tx_ids": [],
"is_transfer": false,
"does_exist": true,
"confirm_timestamp": null,
"channel_id": null,
"transfer_tx_id": null
}"#;
let decoded: OnchainActivity = serde_json::from_str(onchain_v1).unwrap();
assert_eq!(decoded.wallet_id, DEFAULT_WALLET_ID);
assert_eq!(decoded.tx_id, "legacy_txid");

let lightning_v1 = r#"{
"id": "legacy_lightning",
"tx_type": "Received",
"status": "Succeeded",
"value": 7000,
"fee": 3,
"invoice": "lightning:legacy",
"message": "legacy message",
"timestamp": 1234567990,
"preimage": null
}"#;
let decoded: LightningActivity = serde_json::from_str(lightning_v1).unwrap();
assert_eq!(decoded.wallet_id, DEFAULT_WALLET_ID);
assert_eq!(decoded.invoice, "lightning:legacy");
}

#[test]
fn test_hardware_wallet_activity_serializes_as_v2_payload() {
// Wallet-scoped (non-default) records keep wallet_id in the JSON (the v2 payload)
// and round-trip it back unchanged.
let wallet_id = crate::activity::derive_wallet_id(
"trezor".to_string(),
vec!["xpubA".to_string(), "xpubB".to_string()],
)
.unwrap();

let mut onchain = create_test_onchain_activity();
onchain.wallet_id = wallet_id.clone();
assert!(json_has_wallet_id(&onchain));
let decoded: OnchainActivity =
serde_json::from_str(&serde_json::to_string(&onchain).unwrap()).unwrap();
assert_eq!(decoded.wallet_id, wallet_id);

let mut lightning = create_test_lightning_activity();
lightning.wallet_id = wallet_id.clone();
assert!(json_has_wallet_id(&lightning));
let decoded: LightningActivity =
serde_json::from_str(&serde_json::to_string(&lightning).unwrap()).unwrap();
assert_eq!(decoded.wallet_id, wallet_id);
}

#[test]
fn test_wallet_scoped_metadata_models_follow_v1_v2_payload_rule() {
// Tags, pre-activity metadata and transaction details gained wallet_id in the same
// change; their v1 backup payloads must stay unchanged for the default wallet and
// only carry wallet_id when scoped to another wallet.
let scoped = "hardware-wallet-1";

let default_tags = ActivityTags {
wallet_id: DEFAULT_WALLET_ID.to_string(),
activity_id: "act1".to_string(),
tags: vec!["tag".to_string()],
};
assert!(!json_has_wallet_id(&default_tags));
let scoped_tags = ActivityTags {
wallet_id: scoped.to_string(),
..default_tags.clone()
};
assert!(json_has_wallet_id(&scoped_tags));

let default_meta = create_test_pre_activity_metadata(
"pay1".to_string(),
ActivityType::Onchain,
vec!["tag".to_string()],
);
assert!(!json_has_wallet_id(&default_meta));
let scoped_meta = PreActivityMetadata {
wallet_id: scoped.to_string(),
..default_meta.clone()
};
assert!(json_has_wallet_id(&scoped_meta));

let default_details = TransactionDetails {
wallet_id: DEFAULT_WALLET_ID.to_string(),
tx_id: "txid".to_string(),
amount_sats: 1000,
inputs: vec![],
outputs: vec![],
};
assert!(!json_has_wallet_id(&default_details));
let scoped_details = TransactionDetails {
wallet_id: scoped.to_string(),
..default_details.clone()
};
assert!(json_has_wallet_id(&scoped_details));
}

#[test]
fn test_mixed_v1_v2_lookup_and_search_is_wallet_scoped() {
// A v1 (default-wallet) and v2 (hardware-wallet) activity sharing the same raw id
// must remain distinct: wallet-scoped lookup, list and search each return only the
// matching record, never a mixed or duplicated v1/v2 pair.
let (mut db, db_path) = setup();
let wallet_id = "hardware-wallet-1";
let shared_id = "shared_raw_id";

let mut v1 = create_test_onchain_activity();
v1.id = shared_id.to_string();
v1.tx_id = "v1_txid".to_string();
v1.address = "bc1qv1default".to_string();
v1.value = 10_000;

let mut v2 = create_test_onchain_activity();
v2.wallet_id = wallet_id.to_string();
v2.id = shared_id.to_string();
v2.tx_id = "v2_txid".to_string();
v2.address = "bc1qv2hardware".to_string();
v2.value = 20_000;

db.insert_onchain_activity(&v1).unwrap();
db.insert_onchain_activity(&v2).unwrap();

let default_activity = db
.get_activity_by_id(DEFAULT_WALLET_ID, shared_id)
.unwrap()
.unwrap();
assert_eq!(default_activity.get_wallet_id(), DEFAULT_WALLET_ID);

let scoped_activity = db
.get_activity_by_id(wallet_id, shared_id)
.unwrap()
.unwrap();
assert_eq!(scoped_activity.get_wallet_id(), wallet_id);

// Wallet-scoped list returns exactly one record for each wallet, not the mixed pair.
let default_list = db
.get_activities(
Some(DEFAULT_WALLET_ID),
Some(ActivityFilter::Onchain),
None,
None,
None,
None,
None,
None,
None,
)
.unwrap();
assert_eq!(default_list.len(), 1);
assert_eq!(default_list[0].get_wallet_id(), DEFAULT_WALLET_ID);

let scoped_list = db
.get_activities(
Some(wallet_id),
Some(ActivityFilter::Onchain),
None,
None,
None,
None,
None,
None,
None,
)
.unwrap();
assert_eq!(scoped_list.len(), 1);
assert_eq!(scoped_list[0].get_wallet_id(), wallet_id);

// Search stays wallet-scoped: the hardware address is invisible to the default wallet.
let scoped_search = db
.get_activities(
Some(DEFAULT_WALLET_ID),
None,
None,
None,
Some("bc1qv2hardware".to_string()),
None,
None,
None,
None,
)
.unwrap();
assert!(scoped_search.is_empty());

cleanup(&db_path);
}

#[test]
fn test_delete_activities_by_wallet_id_cleans_scoped_data() {
let (mut db, db_path) = setup();
Expand Down
33 changes: 28 additions & 5 deletions src/modules/activity/types.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,14 @@ fn default_wallet_id() -> String {
DEFAULT_WALLET_ID.to_string()
}

/// Serde skip predicate: default-wallet records omit `wallet_id` from JSON, keeping the
/// original (v1) payload byte-identical. Records scoped to a non-default wallet keep the
/// field present (the v2 payload). Presence of `wallet_id` is therefore the version
/// discriminator. (`skip_serializing_if` passes `&String`; deref coercion calls this `&str` fn.)
fn is_default_wallet_id(wallet_id: &str) -> bool {
wallet_id == DEFAULT_WALLET_ID
}

/// Deterministically derive a stable `wallet_id` for a hardware (watch-only) wallet
/// from its set of account extended public keys.
///
Expand Down Expand Up @@ -143,7 +151,10 @@ pub enum PaymentState {

#[derive(Debug, Serialize, Deserialize, Clone, uniffi::Record)]
pub struct OnchainActivity {
#[serde(default = "default_wallet_id")]
#[serde(
default = "default_wallet_id",
skip_serializing_if = "is_default_wallet_id"
)]
pub wallet_id: String,
pub id: String,
pub tx_type: PaymentType,
Expand Down Expand Up @@ -173,7 +184,10 @@ pub struct OnchainActivity {

#[derive(Debug, Serialize, Deserialize, Clone, uniffi::Record)]
pub struct LightningActivity {
#[serde(default = "default_wallet_id")]
#[serde(
default = "default_wallet_id",
skip_serializing_if = "is_default_wallet_id"
)]
pub wallet_id: String,
pub id: String,
pub tx_type: PaymentType,
Expand Down Expand Up @@ -214,15 +228,21 @@ pub struct ClosedChannelDetails {

#[derive(Debug, Clone, uniffi::Record, Serialize, Deserialize)]
pub struct ActivityTags {
#[serde(default = "default_wallet_id")]
#[serde(
default = "default_wallet_id",
skip_serializing_if = "is_default_wallet_id"
)]
pub wallet_id: String,
pub activity_id: String,
pub tags: Vec<String>,
}

#[derive(Debug, Clone, uniffi::Record, Serialize, Deserialize)]
pub struct PreActivityMetadata {
#[serde(default = "default_wallet_id")]
#[serde(
default = "default_wallet_id",
skip_serializing_if = "is_default_wallet_id"
)]
pub wallet_id: String,
pub payment_id: String,
pub tags: Vec<String>,
Expand Down Expand Up @@ -273,7 +293,10 @@ pub struct TxOutput {
/// Details about an onchain transaction.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, uniffi::Record)]
pub struct TransactionDetails {
#[serde(default = "default_wallet_id")]
#[serde(
default = "default_wallet_id",
skip_serializing_if = "is_default_wallet_id"
)]
pub wallet_id: String,
/// The transaction ID.
pub tx_id: String,
Expand Down