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
13 changes: 13 additions & 0 deletions android/app/src/main/java/org/bitcoinppl/cove_core/cove.kt
Original file line number Diff line number Diff line change
Expand Up @@ -31368,6 +31368,16 @@ data class InternalOnlyMetadata (
*/
var `performedFullScanAt`: kotlin.ULong?
,
/**
* True only for wallets whose key material was generated by Cove itself
* (never imported/typed/scanned in). A freshly generated seed cannot have
* on-chain history, so wallets marked here can skip the initial full scan.
*
* Must only be set at creation time by `WalletMetadata::new_cove_created_wallet`,
* never inferred from other state (e.g. `discovery_state`).
*/
var `generatedInApp`: kotlin.Boolean
,
var `storeType`: StoreType

){
Expand All @@ -31389,6 +31399,7 @@ public object FfiConverterTypeInternalOnlyMetadata: FfiConverterRustBuffer<Inter
FfiConverterOptionalDuration.read(buf),
FfiConverterOptionalTypeBlockSizeLast.read(buf),
FfiConverterOptionalULong.read(buf),
FfiConverterBoolean.read(buf),
FfiConverterTypeStoreType.read(buf),
)
}
Expand All @@ -31398,6 +31409,7 @@ public object FfiConverterTypeInternalOnlyMetadata: FfiConverterRustBuffer<Inter
FfiConverterOptionalDuration.allocationSize(value.`lastScanFinished`) +
FfiConverterOptionalTypeBlockSizeLast.allocationSize(value.`lastHeightFetched`) +
FfiConverterOptionalULong.allocationSize(value.`performedFullScanAt`) +
FfiConverterBoolean.allocationSize(value.`generatedInApp`) +
FfiConverterTypeStoreType.allocationSize(value.`storeType`)
)

Expand All @@ -31406,6 +31418,7 @@ public object FfiConverterTypeInternalOnlyMetadata: FfiConverterRustBuffer<Inter
FfiConverterOptionalDuration.write(value.`lastScanFinished`, buf)
FfiConverterOptionalTypeBlockSizeLast.write(value.`lastHeightFetched`, buf)
FfiConverterOptionalULong.write(value.`performedFullScanAt`, buf)
FfiConverterBoolean.write(value.`generatedInApp`, buf)
FfiConverterTypeStoreType.write(value.`storeType`, buf)
}
}
Expand Down
22 changes: 21 additions & 1 deletion ios/CoveCore/Sources/CoveCore/generated/cove.swift
Original file line number Diff line number Diff line change
Expand Up @@ -16216,6 +16216,15 @@ public struct InternalOnlyMetadata: Equatable, Hashable {
* This is the time that a full scan was completed, this should only happen once
*/
public var performedFullScanAt: UInt64?
/**
* True only for wallets whose key material was generated by Cove itself
* (never imported/typed/scanned in). A freshly generated seed cannot have
* on-chain history, so wallets marked here can skip the initial full scan.
*
* Must only be set at creation time by `WalletMetadata::new_cove_created_wallet`,
* never inferred from other state (e.g. `discovery_state`).
*/
public var generatedInApp: Bool
public var storeType: StoreType

// Default memberwise initializers are never public by default, so we
Expand All @@ -16226,11 +16235,20 @@ public struct InternalOnlyMetadata: Equatable, Hashable {
*/lastScanFinished: TimeInterval?, lastHeightFetched: BlockSizeLast?,
/**
* This is the time that a full scan was completed, this should only happen once
*/performedFullScanAt: UInt64?, storeType: StoreType) {
*/performedFullScanAt: UInt64?,
/**
* True only for wallets whose key material was generated by Cove itself
* (never imported/typed/scanned in). A freshly generated seed cannot have
* on-chain history, so wallets marked here can skip the initial full scan.
*
* Must only be set at creation time by `WalletMetadata::new_cove_created_wallet`,
* never inferred from other state (e.g. `discovery_state`).
*/generatedInApp: Bool, storeType: StoreType) {
self.addressIndex = addressIndex
self.lastScanFinished = lastScanFinished
self.lastHeightFetched = lastHeightFetched
self.performedFullScanAt = performedFullScanAt
self.generatedInApp = generatedInApp
self.storeType = storeType
}

Expand All @@ -16254,6 +16272,7 @@ public struct FfiConverterTypeInternalOnlyMetadata: FfiConverterRustBuffer {
lastScanFinished: FfiConverterOptionDuration.read(from: &buf),
lastHeightFetched: FfiConverterOptionTypeBlockSizeLast.read(from: &buf),
performedFullScanAt: FfiConverterOptionUInt64.read(from: &buf),
generatedInApp: FfiConverterBool.read(from: &buf),
storeType: FfiConverterTypeStoreType.read(from: &buf)
)
}
Expand All @@ -16263,6 +16282,7 @@ public struct FfiConverterTypeInternalOnlyMetadata: FfiConverterRustBuffer {
FfiConverterOptionDuration.write(value.lastScanFinished, into: &buf)
FfiConverterOptionTypeBlockSizeLast.write(value.lastHeightFetched, into: &buf)
FfiConverterOptionUInt64.write(value.performedFullScanAt, into: &buf)
FfiConverterBool.write(value.generatedInApp, into: &buf)
FfiConverterTypeStoreType.write(value.storeType, into: &buf)
}
}
Expand Down
36 changes: 30 additions & 6 deletions rust/src/manager/wallet_manager/actor.rs
Original file line number Diff line number Diff line change
Expand Up @@ -320,7 +320,7 @@ impl WalletActor {

// perform that scanning in a background task
let addr = self.addr.clone();
match initial_scan_route(completed_initial_scan) {
match initial_scan_route(completed_initial_scan, self.wallet_generated_in_app()) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Ledger state never completes

When a generated wallet completes its initial incremental scan, performed_full_scan_at remains unset, so the shared ledger state stays InitialScanIncomplete; this leaves the balance provisional and the UI loading or scanning indefinitely, while manager-level spend readiness can still reject the wallet.

Knowledge Base Used: Wallet Manager and Wallet State

InitialScanRoute::Full => send!(addr.perform_full_scan()),
InitialScanRoute::Incremental => send!(addr.perform_incremental_scan(progress_start)),
}
Expand Down Expand Up @@ -872,8 +872,12 @@ impl WalletActor {
self.wallet.metadata.internal.performed_full_scan_at.is_some()
}

fn wallet_generated_in_app(&self) -> bool {
self.wallet.metadata.internal.generated_in_app
}

fn ensure_ledger_ready_for_spend(&self) -> Result<(), Error> {
ledger_ready_for_spend(self.completed_initial_scan())
ledger_ready_for_spend(self.completed_initial_scan() || self.wallet_generated_in_app())
}
}

Expand Down Expand Up @@ -928,8 +932,8 @@ enum InitialScanRoute {
Incremental,
}

fn initial_scan_route(completed_initial_scan: bool) -> InitialScanRoute {
if completed_initial_scan {
fn initial_scan_route(completed_initial_scan: bool, generated_in_app: bool) -> InitialScanRoute {
if completed_initial_scan || generated_in_app {
return InitialScanRoute::Incremental;
}

Expand Down Expand Up @@ -2958,18 +2962,38 @@ mod tests {

#[test]
fn incomplete_scan_routes_to_full_scan_even_with_last_scan_finished() {
assert_eq!(initial_scan_route(false), InitialScanRoute::Full);
assert_eq!(initial_scan_route(false, false), InitialScanRoute::Full);
assert!(should_skip_recent_scan(Some(UNIX_EPOCH.elapsed().unwrap()), false));
}

#[test]
fn recent_scan_skip_applies_only_after_readiness_is_complete() {
assert_eq!(initial_scan_route(true), InitialScanRoute::Incremental);
assert_eq!(initial_scan_route(true, false), InitialScanRoute::Incremental);
assert!(should_skip_recent_scan(Some(UNIX_EPOCH.elapsed().unwrap()), false));
assert!(!should_skip_recent_scan(Some(UNIX_EPOCH.elapsed().unwrap()), true));
assert!(!should_skip_recent_scan(None, false));
}

#[test]
fn generated_in_app_wallet_routes_to_incremental_even_when_never_full_scanned() {
assert_eq!(initial_scan_route(false, true), InitialScanRoute::Incremental);
}

#[test]
fn imported_wallet_metadata_still_routes_to_full_scan() {
// constructor -> generated_in_app coverage lives in wallet::metadata::tests;
// here we only need the routing decision for a non-generated wallet.
let generated_in_app = false;
assert_eq!(initial_scan_route(false, generated_in_app), InitialScanRoute::Full);
}

#[test]
fn spend_guard_allows_generated_in_app_wallet_before_any_full_scan() {
let completed_initial_scan = false;
let generated_in_app = true;
assert_eq!(ledger_ready_for_spend(completed_initial_scan || generated_in_app), Ok(()));
}

#[test]
fn full_scan_updates_initial_metadata_for_full_range_scans() {
assert!(full_scan_updates_initial_metadata(FullScanType::Full));
Expand Down
90 changes: 90 additions & 0 deletions rust/src/wallet/metadata.rs
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,15 @@ pub struct InternalOnlyMetadata {
/// This is the time that a full scan was completed, this should only happen once
pub performed_full_scan_at: Option<u64>,

#[serde(default)]
/// True only for wallets whose key material was generated by Cove itself
/// (never imported/typed/scanned in). A freshly generated seed cannot have
/// on-chain history, so wallets marked here can skip the initial full scan.
///
/// Must only be set at creation time by `WalletMetadata::new_cove_created_wallet`,
/// never inferred from other state (e.g. `discovery_state`).
pub generated_in_app: bool,
Comment on lines +82 to +89

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Freshness marker survives reconstruction

When a generated wallet is restored from backup or its BDK store is recreated during an address-type switch, generated_in_app remains true even though local scan state is cleared. Startup then uses the 30-address incremental gap instead of the initial 150-address full scan, omitting historical transactions, balance, or spendable outputs beyond that gap.

Knowledge Base Used: Wallet Manager and Wallet State


// the type of store used for the wallet
#[serde(default = "file_store_default")]
pub store_type: StoreType,
Expand Down Expand Up @@ -281,6 +290,7 @@ impl WalletMetadata {
) -> Self {
let mut me = Self::new(name, fingerprint);
me.set_creation_birthday();
me.internal.generated_in_app = true;
me
}

Expand Down Expand Up @@ -541,6 +551,84 @@ impl HardwareWalletMetadata {
#[cfg(test)]
mod tests {
use super::*;
use std::{collections::HashMap, sync::Once};

use cove_device::keychain::{Keychain, KeychainAccess, KeychainError};
use parking_lot::Mutex as PMutex;

#[derive(Debug, Default)]
struct TestKeychain(PMutex<HashMap<String, String>>);

impl KeychainAccess for TestKeychain {
fn save(&self, key: String, value: String) -> Result<(), KeychainError> {
self.0.lock().insert(key, value);
Ok(())
}

fn get(&self, key: String) -> Option<String> {
self.0.lock().get(&key).cloned()
}

fn delete(&self, key: String) -> bool {
self.0.lock().remove(&key).is_some()
}
}

fn setup_test_env() {
static INIT: Once = Once::new();
INIT.call_once(|| {
Keychain::new(Box::<TestKeychain>::default());
});

crate::test_support::ensure_tokio_runtime();
crate::database::test_support::init_test_database();
}

#[test]
fn only_cove_created_wallet_is_marked_generated_in_app() {
let _guard = crate::test_support::global_state_test_lock().blocking_lock();
setup_test_env();

assert!(
WalletMetadata::new_cove_created_wallet("test", Some(Fingerprint::default()))
.internal
.generated_in_app
);

assert!(
!WalletMetadata::new_imported_from_mnemonic(
"test",
Network::Bitcoin,
Fingerprint::default()
)
.internal
.generated_in_app
);

assert!(
!WalletMetadata::new_for_hardware(WalletId::preview_new_random(), "test", None)
.internal
.generated_in_app
);

assert!(!WalletMetadata::new("test", None::<Arc<Fingerprint>>).internal.generated_in_app);
assert!(!WalletMetadata::preview_new().internal.generated_in_app);
}

#[test]
fn missing_generated_in_app_deserializes_to_false() {
let _guard = crate::test_support::global_state_test_lock().blocking_lock();
setup_test_env();

let metadata =
WalletMetadata::new_cove_created_wallet("test", Some(Fingerprint::default()));
let mut value = serde_json::to_value(metadata).unwrap();
value.get_mut("internal").unwrap().as_object_mut().unwrap().remove("generated_in_app");

let deserialized: WalletMetadata = serde_json::from_value(value).unwrap();

assert!(!deserialized.internal.generated_in_app);
}

#[test]
fn missing_birthday_deserializes_to_none() {
Expand Down Expand Up @@ -646,6 +734,7 @@ mod tests {
last_seen: Duration::from_secs(20),
}),
performed_full_scan_at: Some(30),
generated_in_app: true,
store_type: StoreType::FileStore,
};

Expand All @@ -656,6 +745,7 @@ mod tests {
assert_eq!(metadata.last_height_fetched, None);
assert_eq!(metadata.performed_full_scan_at, None);
assert_eq!(metadata.store_type, StoreType::FileStore);
assert!(metadata.generated_in_app, "generated_in_app is identity, not scan state");
}

#[test]
Expand Down