Skip to content
Draft
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
35 changes: 34 additions & 1 deletion src/context/wallet_lifecycle.rs
Original file line number Diff line number Diff line change
Expand Up @@ -297,8 +297,41 @@ impl AppContext {

fn queue_spv_wallet_load(self: &Arc<Self>, seed_hash: WalletSeedHash, seed_bytes: [u8; 64]) {
let spv = Arc::clone(&self.spv_manager);
// Compute the BIP44 receive pool target *before* spawning so the
// target is bound to the current DB state. The pool is extended
// atomically inside `load_wallet_from_seed` while it still holds the
// WalletManager write lock, closing the race where the SPV filter
// scan could observe the imported wallet before lookahead addresses
// were registered.
//
// The target combines:
// - the highest BIP44 receive index already persisted in DB (covers
// indices generated in prior sessions via `GenerateReceiveAddress`
// and the initial bootstrap), and
// - a lookahead slack so a wallet with a single historical UTXO at
// an index beyond the default gap limit (e.g. a reference testnet
// wallet with historical UTXOs at indices 32-40) is discovered in
// a single SPV scan instead of requiring repeated app restarts.
const LOOKAHEAD_SLACK: u32 = 100;
let min_receive_index = self
.db
.max_bip44_receive_index(&seed_hash)
.map_err(|e| {
tracing::warn!(
seed = %hex::encode(seed_hash),
error = %e,
"Failed to query max BIP44 address index from database; using lookahead slack only"
);
})
.ok()
.flatten()
.unwrap_or(0)
.saturating_add(LOOKAHEAD_SLACK);
self.subtasks.spawn_sync("spv_wallet_load", async move {
if let Err(error) = spv.load_wallet_from_seed(seed_hash, seed_bytes).await {
if let Err(error) = spv
.load_wallet_from_seed(seed_hash, seed_bytes, min_receive_index)
.await
{
tracing::error!(seed = %hex::encode(seed_hash), %error, "Failed to load SPV wallet from seed");
}
});
Expand Down
125 changes: 125 additions & 0 deletions src/database/wallet.rs
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,30 @@ impl Database {
tx.commit()
}

/// Get the highest BIP44 external (receive) address index stored for a wallet.
/// Returns `None` if no BIP44 receive addresses are stored.
///
/// BIP44 external addresses have derivation paths like `m/44'/X'/0'/0/N`
/// where N is the address index. This method parses the last path component
/// from all matching rows.
pub fn max_bip44_receive_index(&self, seed_hash: &[u8; 32]) -> rusqlite::Result<Option<u32>> {
let conn = self.conn.lock().unwrap();
let mut stmt = conn.prepare(
"SELECT derivation_path FROM wallet_addresses
WHERE seed_hash = ? AND derivation_path LIKE 'm/44''/%''/0''/0/%'",
)?;
let rows = stmt.query_map([seed_hash.as_slice()], |row| row.get::<_, String>(0))?;
let mut max_index: Option<u32> = None;
for path in rows.flatten() {
if let Some(last) = path.rsplit('/').next()
&& let Ok(idx) = last.parse::<u32>()
{
max_index = Some(max_index.map_or(idx, |m: u32| m.max(idx)));
}
}
Ok(max_index)
}

/// Update the Dash Core wallet name for an HD wallet.
///
/// Returns `Ok(true)` if exactly one row was updated, `Ok(false)` if no
Expand Down Expand Up @@ -1884,4 +1908,105 @@ mod tests {
.expect("Failed to query total_received");
assert_eq!(total_received, 10_000_000);
}

/// Insert a dummy wallet row for test purposes.
fn insert_test_wallet(db: &Database, seed_hash: &[u8; 32], network: Network) {
let network_str = network.to_string();
let conn = db.conn.lock().unwrap();
conn.execute(
"INSERT INTO wallet (seed_hash, encrypted_seed, salt, nonce, master_ecdsa_bip44_account_0_epk, uses_password, network)
VALUES (?, ?, ?, ?, ?, 0, ?)",
rusqlite::params![
seed_hash.as_slice(),
vec![0u8; 64],
vec![0u8; 16],
vec![0u8; 12],
vec![0u8; 78],
network_str,
],
)
.expect("Failed to insert test wallet");
}

/// Derive a valid test address by walking a deterministic BIP32 chain.
fn derive_test_address(network: Network, bump: u32) -> Address {
let seed = [42u8; 64];
let secp = Secp256k1::new();
let master = ExtendedPrivKey::new_master(network, &seed).expect("master key");
let path = DerivationPath::from(vec![ChildNumber::Normal { index: bump }]);
let derived = master.derive_priv(&secp, &path).expect("derive");
let pubkey = dash_sdk::dpp::dashcore::PublicKey::new(
ExtendedPubKey::from_priv(&secp, &derived).public_key,
);
Address::p2pkh(&pubkey, network)
}

fn insert_bip44_receive_row(db: &Database, seed_hash: &[u8; 32], network: Network, index: u32) {
let address = derive_test_address(network, index);
let path = format!("m/44'/1'/0'/0/{index}");
let derivation_path = DerivationPath::from_str(&path).unwrap();
db.add_address_if_not_exists(
seed_hash,
&address,
&network,
&derivation_path,
DerivationPathReference::BIP44,
DerivationPathType::CLEAR_FUNDS,
None,
)
.expect("add_address_if_not_exists");
}

#[test]
fn test_max_bip44_receive_index_none_when_empty() {
let db = create_test_database().expect("create db");
let seed_hash = create_test_seed_hash();
assert_eq!(
db.max_bip44_receive_index(&seed_hash).expect("query"),
None,
"no rows should yield None"
);
}

#[test]
fn test_max_bip44_receive_index_returns_highest() {
let db = create_test_database().expect("create db");
let seed_hash = create_test_seed_hash();
insert_test_wallet(&db, &seed_hash, Network::Testnet);
for index in [0, 1, 5, 31, 15] {
insert_bip44_receive_row(&db, &seed_hash, Network::Testnet, index);
}
assert_eq!(
db.max_bip44_receive_index(&seed_hash).expect("query"),
Some(31),
"should return the largest stored index"
);
}

#[test]
fn test_max_bip44_receive_index_ignores_change_chain() {
let db = create_test_database().expect("create db");
let seed_hash = create_test_seed_hash();
insert_test_wallet(&db, &seed_hash, Network::Testnet);
insert_bip44_receive_row(&db, &seed_hash, Network::Testnet, 0);
insert_bip44_receive_row(&db, &seed_hash, Network::Testnet, 10);
// Also inject a change-chain (m/.../1/99) row that should NOT count.
let address = derive_test_address(Network::Testnet, 999_999);
let derivation_path = DerivationPath::from_str("m/44'/1'/0'/1/99").unwrap();
db.add_address_if_not_exists(
&seed_hash,
&address,
&Network::Testnet,
&derivation_path,
DerivationPathReference::BIP44,
DerivationPathType::CLEAR_FUNDS,
None,
)
.expect("add change address");
assert_eq!(
db.max_bip44_receive_index(&seed_hash).expect("query"),
Some(10),
"change-chain (m/.../1/*) rows must not influence the receive-chain max"
);
}
}
95 changes: 94 additions & 1 deletion src/spv/manager.rs
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ use dash_sdk::dash_spv::sync::SyncState;
use dash_sdk::dash_spv::types::ValidationMode;
use dash_sdk::dash_spv::{ClientConfig, DashSpvClient, Hash, LLMQType, QuorumHash};
use dash_sdk::dpp::dashcore::{Address, InstantLock, Network, Transaction, Txid};
use dash_sdk::dpp::key_wallet::bip32::{DerivationPath, ExtendedPrivKey};
use dash_sdk::dpp::key_wallet::bip32::{ChildNumber, DerivationPath, ExtendedPrivKey};
use dash_sdk::dpp::key_wallet::wallet::initialization::WalletAccountCreationOptions;
use dash_sdk::dpp::key_wallet::wallet::managed_wallet_info::{
ManagedWalletInfo, transaction_building::AccountTypePreference,
Expand Down Expand Up @@ -923,6 +923,7 @@ impl SpvManager {
&self,
seed_hash: WalletSeedHash,
mut seed_bytes: [u8; 64],
min_bip44_receive_index: u32,
) -> Result<WalletId, String> {
let existing_wallet_id = {
let map = self.det_wallets.read().map_err(|e| e.to_string())?;
Expand All @@ -935,6 +936,21 @@ impl SpvManager {
if let Some(wallet) = wm.get_wallet(&wallet_id)
&& wallet.can_sign()
{
// Still extend the pool atomically under the write lock so the
// monitored address set reflects the DB-known max index before
// the SPV filter scan observes the wallet.
if let Err(e) = Self::extend_bip44_receive_pool_locked(
&mut wm,
wallet_id,
min_bip44_receive_index,
) {
tracing::warn!(
seed = %hex::encode(seed_hash),
target = min_bip44_receive_index,
error = %e,
"Failed to extend BIP44 receive pool on already-loaded wallet"
);
}
seed_bytes.zeroize();
return Ok(wallet_id);
}
Expand Down Expand Up @@ -972,6 +988,23 @@ impl SpvManager {
}
};

// Extend the BIP44 receive pool BEFORE releasing the write lock. This
// guarantees the monitored address set is populated before any other
// observer (notably `run_spv_loop`'s wait-for-wallets loop) sees
// `wallet_count` increment. Without this, the SPV filter scan can
// start with only the default gap-limit pool and miss historical UTXOs
// at higher indices — requiring multiple app restarts to converge.
if let Err(e) =
Self::extend_bip44_receive_pool_locked(&mut wm, wallet_id, min_bip44_receive_index)
{
tracing::warn!(
seed = %hex::encode(seed_hash),
target = min_bip44_receive_index,
error = %e,
"Failed to extend BIP44 receive pool during wallet import"
);
}

drop(wm);

let mut map = self.det_wallets.write().map_err(|e| e.to_string())?;
Expand All @@ -980,6 +1013,66 @@ impl SpvManager {
Ok(wallet_id)
}

/// Extend the BIP44 receive address pool for `wallet_id` so at least
/// `target_index` is covered. Must be called with the caller already
/// holding the `self.wallet` write lock.
///
/// Each iteration marks the generated address as used, which advances
/// the pool's `highest_used` and triggers gap-limit refill. The caller
/// supplies a target that includes desired lookahead slack.
fn extend_bip44_receive_pool_locked(
wm: &mut WalletManager<ManagedWalletInfo>,
wallet_id: WalletId,
target_index: u32,
) -> Result<u32, String> {
const MAX_ITERATIONS: u32 = 2_000;
let mut generated = 0u32;
loop {
let result = wm
.get_receive_address(&wallet_id, 0, AccountTypePreference::BIP44, true)
.map_err(|e| format!("get_receive_address failed: {e}"))?;
let address = result
.address
.ok_or_else(|| "wallet manager returned no address".to_string())?;
let info = wm
.get_wallet_info(&wallet_id)
.ok_or_else(|| "wallet info missing".to_string())?;
let path = info
.accounts()
.standard_bip44_accounts
.get(&0)
.and_then(|acc| acc.get_address_info(&address))
.map(|meta| meta.path.clone())
.ok_or_else(|| "address metadata unavailable".to_string())?;
let current_index = path
.as_ref()
.last()
.and_then(|c| match c {
ChildNumber::Normal { index } => Some(*index),
_ => None,
})
.unwrap_or(0);
generated += 1;
if current_index >= target_index {
break;
}
if generated >= MAX_ITERATIONS {
return Err(format!(
"address pool extension exceeded safety limit of {MAX_ITERATIONS}"
));
}
}
if generated > 0 {
tracing::info!(
wallet = %hex::encode(wallet_id),
generated,
target_index,
"Extended SPV BIP44 receive pool"
);
}
Ok(generated)
}

pub async fn next_bip44_receive_address(
&self,
seed_hash: WalletSeedHash,
Expand Down