diff --git a/packages/rs-platform-wallet-storage/src/sqlite/load_ctx.rs b/packages/rs-platform-wallet-storage/src/sqlite/load_ctx.rs index 025b15007ef..fe6b29d0c1f 100644 --- a/packages/rs-platform-wallet-storage/src/sqlite/load_ctx.rs +++ b/packages/rs-platform-wallet-storage/src/sqlite/load_ctx.rs @@ -40,7 +40,13 @@ use crate::sqlite::error::WalletStorageError; /// for the same site — the two are deliberately separate: a UI layer wants /// the prose, a log consumer wants the tag, and neither should have to /// derive one from the other. +/// +/// `#[non_exhaustive]`: a new kind of inconsistency gets its own variant, so +/// a match outside this crate needs a wildcard arm and keeps compiling when +/// one is added. Inside the crate [`as_str`](Self::as_str) and +/// [`explanation`](Self::explanation) still match every variant by name. #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +#[non_exhaustive] pub enum LoadSite { /// `core_sync_state.last_applied_chain_lock` failed to decode. ChainLockBlob, @@ -72,6 +78,16 @@ pub enum LoadSite { UndecodableAddressScript, /// One used address resolves to two different owning accounts. UsedAddressOwnerConflict, + /// A stored address-pool row sits further above the rest of its pool + /// than the load will derive to check it, so it is not restored. A used + /// or reserved one-time funding key in that state fails the wallet + /// instead. + PoolRowBeyondDerivationBudget, + /// A stored address reservation was taken back by no restored pool: its + /// row matches no account of the wallet, or was left out above. Counted + /// per reservation. A one-time funding key in that state fails the + /// wallet instead. + UnrestoredPoolReservation, /// An `identity_keys` row was unreadable or contradicted its columns. IdentityKeyRow, /// A `contacts` row was unreadable or contradicted its columns. @@ -122,6 +138,8 @@ impl LoadSite { Self::UnresolvedUtxoAddress => "unresolved_utxo_address", Self::UndecodableAddressScript => "undecodable_address_script", Self::UsedAddressOwnerConflict => "used_address_owner_conflict", + Self::PoolRowBeyondDerivationBudget => "pool_row_beyond_derivation_budget", + Self::UnrestoredPoolReservation => "unrestored_pool_reservation", Self::MissingIdentityOwner => "missing_identity_owner", Self::UnownedIdentityHasRegistrationIndex => "unowned_identity_has_registration_index", Self::IdentityIndexCollision => "identity_index_collision", @@ -160,16 +178,22 @@ impl LoadSite { Self::WalletRehydration => { "recovery mode: dropping one wallet that could not be rebuilt, keeping the rest of the file" } - // The only two sites `note_degraded` ever reaches (see its - // callers) — never-fatal in either policy, so their prose - // describes an accepted-as-is degradation rather than a - // tolerated inconsistency. + // The sites `note_degraded` reaches (see its callers) — + // never-fatal in either policy, so their prose describes an + // accepted-as-is degradation rather than a tolerated + // inconsistency. Self::OrphanedUtxoOwner => { "load degraded: routing addresses from an unavailable owner to the first funds account" } Self::UnresolvedUtxoAddress => { "load degraded: deferring addresses that did not resolve against the account xpub" } + Self::PoolRowBeyondDerivationBudget => { + "load degraded: leaving out address-pool rows too far above their pool to be checked" + } + Self::UnrestoredPoolReservation => { + "load degraded: stored address reservations that no restored pool took back" + } // Sites whose site tag plus the logged error already say // everything a reader needs, so they share the generic line. Self::ChainLockBlob @@ -305,6 +329,10 @@ pub(crate) struct SiteCoords<'a> { pub detail: Option<&'a dyn fmt::Debug>, } +/// The per-site counts of a [`LoadCtx`] at one moment; see +/// [`LoadCtx::counts_checkpoint`]. +pub(crate) struct CountsCheckpoint(BTreeMap); + /// Per-`load()` policy + counters, created on the loading thread's stack. /// /// Not stored on the persister (which keeps only the resulting @@ -438,6 +466,22 @@ impl LoadCtx { ) } + /// The per-site counts as they stand, to come back to with + /// [`rewind_counts`](Self::rewind_counts). + pub(crate) fn counts_checkpoint(&self) -> CountsCheckpoint { + CountsCheckpoint(self.counts.borrow().clone()) + } + + /// Forget what was counted since `checkpoint`. + /// + /// For a reader whose result is thrown away and built again: the second + /// pass meets the same rows, and what the first tolerated is not in the + /// state the load returns, so keeping both would report every incident + /// twice. Only the counts go back; the log keeps both passes. + pub(crate) fn rewind_counts(&self, checkpoint: CountsCheckpoint) { + *self.counts.borrow_mut() = checkpoint.0; + } + /// Attribute a whole-wallet loss to the wallet it belongs to. /// /// Separate from [`tolerate_at`](Self::tolerate_at), which counts the diff --git a/packages/rs-platform-wallet-storage/src/sqlite/persister.rs b/packages/rs-platform-wallet-storage/src/sqlite/persister.rs index cdfb33f9a25..7b46cef2c55 100644 --- a/packages/rs-platform-wallet-storage/src/sqlite/persister.rs +++ b/packages/rs-platform-wallet-storage/src/sqlite/persister.rs @@ -1310,15 +1310,13 @@ impl PlatformWalletPersistence for SqlitePersister { // transaction. The current schema also has lossless token balances, // invitations, account pools, tracked asset locks, and // deferred-contact-crypto queue rows. - // Do NOT attest WALLET_RESTORE (and therefore not provider restore): - // token balances and the DashPay overlay have no load readers, so a - // full restore remains lossy. Shielded viewing keys are native when - // compiled in; notes, nullifiers, and sync state use ShieldedStore. - // WALLET_DRAIN folds in WALLET_RESTORE, so it stays unattested too. + // WALLET_RESTORE covers the Core snapshot; auxiliary token, profile, + // invitation, and deferred-contact readback remains separate. // Conflict cleanup can remove asset-lock operation evidence. Until it // preserves that evidence, do not attest ASSET_LOCK_OPERATION_IDS: // absence cannot safely mean NotSent, so wallet draining stays disabled. let capabilities = PersistenceCapabilities::ATOMIC_CHANGESETS + .union(PersistenceCapabilities::WALLET_RESTORE) .union(PersistenceCapabilities::INVITATIONS) .union(PersistenceCapabilities::ASSET_LOCK_FUNDING_INDICES) .union(PersistenceCapabilities::UNSIGNED_TOKEN_STORAGE) @@ -1695,10 +1693,15 @@ fn load_one_wallet( let tx = rusqlite::Transaction::new_unchecked(conn, rusqlite::TransactionBehavior::Immediate) .map_err(WalletStorageError::from) .map_err(PersistenceError::from)?; + let counted_before = ctx.counts_checkpoint(); let (mut state, sweeps) = load_wallet_snapshot(&tx, wallet_id, ctx)?; if !sweeps.is_empty() { schema::core_state::apply_replay_sweeps(&tx, &wallet_id, sweeps) .map_err(PersistenceError::from)?; + // The snapshot above is thrown away, and what it tolerated with it: + // the rebuild meets the same rows and counts them for the state that + // is returned. + ctx.rewind_counts(counted_before); // Rebuild from the repaired projection, including released materialized inputs. let (repaired, remaining) = load_wallet_snapshot(&tx, wallet_id, ctx)?; if !remaining.is_empty() { @@ -1864,12 +1867,7 @@ fn load_wallet_snapshot( key_wallet::account::account_collection::AccountCollection::new(), ) } else { - build_wallet(network, wallet_id, &account_manifest).map_err(|e| { - PersistenceError::backend(format!( - "watch-only wallet rebuild failed for {}: {e}", - hex::encode(wallet_id) - )) - })? + build_wallet(network, wallet_id, &account_manifest).map_err(PersistenceError::from)? }; // TODO(insert-wallet-id-recompute): confirm whether key_wallet's // insert_wallet recomputes wallet_id — see PR's existing Deferred @@ -1881,9 +1879,23 @@ fn load_wallet_snapshot( &wallet, birth_height, ); + // Pools come back first, so the used-address projection below marks the + // restored entries too: a stored row can still say "available" for an + // address that has since been funded. The platform-node reader stays + // ahead of `restore_pools` because it rewrites its entries' state from + // `used` alone, and `restore_pools` overlays their reservations. + if account_manifest + .provider + .iter() + .any(|entry| entry.account_type == key_wallet::account::AccountType::ProviderPlatformKeys) + { + restore_provider_platform_node_pool(&mut wallet_info, conn, &wallet_id, network, ctx) + .map_err(PersistenceError::from)?; + } + schema::core_pool::restore_pools(conn, &wallet_id, &wallet, &mut wallet_info, ctx) + .map_err(PersistenceError::from)?; // Provider key-material accounts hold no funds, so only the ECDSA - // half feeds the UTXO/balance projection here. The platform-node - // pre-derived-key pool is restored separately below. + // half feeds the UTXO/balance projection here. apply_persisted_core_state( &mut wallet_info, &account_manifest.ecdsa, @@ -1892,25 +1904,7 @@ fn load_wallet_snapshot( &used_core_addresses, ctx, ) - .map_err(|e| { - PersistenceError::backend(format!( - "core-state rehydration failed for {}: {e}", - hex::encode(wallet_id) - )) - })?; - if account_manifest - .provider - .iter() - .any(|entry| entry.account_type == key_wallet::account::AccountType::ProviderPlatformKeys) - { - restore_provider_platform_node_pool(&mut wallet_info, conn, &wallet_id, network, ctx) - .map_err(|e| { - PersistenceError::backend(format!( - "platform-node pool rehydration failed for {}: {e}", - hex::encode(wallet_id) - )) - })?; - } + .map_err(PersistenceError::from)?; let mut wallet = wallet; let sweeps = restore_recorded_transactions( &mut wallet_info, @@ -1965,7 +1959,17 @@ fn record_wallet_degradation( wallet_id, cause: original.to_string(), }, - )?; + ) + // Strict: the caller propagates its original error, and a typed cause + // rarely names the wallet it came from, so this is where it is named. + .inspect_err(|_| { + tracing::error!( + wallet_id = %hex::encode(wallet_id), + error_kind = cause, + error = %original, + "strict load: a wallet could not be rebuilt, so the load fails" + ); + })?; ctx.note_wallet_degraded(wallet_id, cause); Ok(()) } diff --git a/packages/rs-platform-wallet-storage/src/sqlite/rehydrate.rs b/packages/rs-platform-wallet-storage/src/sqlite/rehydrate.rs index 25c4bc8cbaa..6b0e00b676f 100644 --- a/packages/rs-platform-wallet-storage/src/sqlite/rehydrate.rs +++ b/packages/rs-platform-wallet-storage/src/sqlite/rehydrate.rs @@ -112,8 +112,8 @@ pub(crate) fn restore_provider_platform_node_pool( return Ok(()); } - // TODO(#4188): `reserved_at` is persisted but deliberately not consumed here; - // restoring it requires widening `provider_accounts::insert_platform_node_pool_entry`. + // `reserved_at` is not consumed here: `core_pool::restore_pools` runs next + // and overlays each row's reservation state on the entries inserted below. for (index, script_bytes, public_key, used) in entries { let PublicKeyType::EdDSA(public_key) = public_key else { return Err(WalletStorageError::blob_decode( @@ -228,6 +228,11 @@ pub(crate) fn restore_provider_platform_node_pool( /// - **Address-pool used-state**: every `used_pool_addresses` entry is /// re-marked used (in union with the unspent-UTXO addresses), so an /// address whose funds were since spent is not re-handed-out as fresh. +/// An entry owned by a keys-only account of `wallet_info` (identity, +/// asset-lock, provider) is skipped: no funds account can derive it, and +/// the SQLite loader has already restored it on its own pool through +/// `core_pool::restore_pools`. A caller that has not leaves that pool as +/// it was; routing the entry to a funds account never marked it there. /// - **InstantSend locks**: every `instant_locks_for_non_final_records` /// entry is replayed through `mark_instant_send_utxos` after the UTXO /// restore, so instant-locked funds come back instant-locked instead of @@ -334,6 +339,18 @@ pub fn apply_persisted_core_state( .filter(|u| !spent_outpoints.contains(&u.outpoint)) .collect(); + // Owners of the keys-only accounts this wallet rebuilt. A used address of + // theirs is already back on its own pool, and no funds account can derive + // it. An owner missing from the wallet is not here, so it still takes the + // orphan path below. + let keys_owners: Vec = wallet_info + .accounts + .all_accounts() + .into_iter() + .filter_map(|account| account.as_keys()) + .map(|account| owning_account_for(&account.managed_account_type().to_account_type())) + .collect(); + let mut funding = wallet_info.accounts.all_funding_accounts_mut(); if !unspent.is_empty() && funding.is_empty() { return Err(WalletStorageError::MissingAccount { wallet_id }); @@ -370,18 +387,22 @@ pub fn apply_persisted_core_state( // one absent from this wallet, falls back to the first account. An // empty map marks only the unspent-UTXO addresses. for (addr, owner) in used_pool_addresses { + if owner + .as_ref() + .is_some_and(|owner| keys_owners.contains(owner)) + { + continue; + } let target = route_to_funds_account(&account_keys, owner.as_ref(), &mut orphaned_owners); per_account_addrs[target].push(addr.clone()); } - // Degraded in BOTH policies, never fatal. An owner absent from the - // funds accounts is not necessarily corruption: provider accounts are - // first-class in the schema yet sit on a non-secp256k1 curve, so they - // are not `ManagedCoreFundsAccount` and a used provider-owned address - // has no funds account to route to. Telling that apart needs an - // upstream `key-wallet` enumerator over every account kind; until then, - // failing here would brick a masternode-operator wallet. + // Degraded in BOTH policies, never fatal. Used addresses of the + // keys-only accounts this wallet rebuilt were skipped above, so an + // owner left here is one the wallet did not rebuild, or a UTXO whose + // pool row names a keys-only account. Neither costs funds: the wallet + // total is exact, and the per-account view re-warms on the next sync. if !orphaned_owners.is_empty() { ctx.note_degraded( LoadSite::OrphanedUtxoOwner, @@ -1145,11 +1166,16 @@ fn route_to_funds_account( /// all carry index 0 and differ only by the identity pair). fn owning_account_of(account: &ManagedCoreFundsAccount) -> OwningAccount { use key_wallet::managed_account::managed_account_trait::ManagedAccountTrait; - let at = account.managed_account_type().to_account_type(); - let (user_identity_id, friend_identity_id) = accounts::account_dashpay_ids(&at); + owning_account_for(&account.managed_account_type().to_account_type()) +} + +/// Owning-account identity of any account type, on the discriminators +/// [`owning_account_of`] describes. +fn owning_account_for(at: &AccountType) -> OwningAccount { + let (user_identity_id, friend_identity_id) = accounts::account_dashpay_ids(at); OwningAccount { - account_type: accounts::account_type_db_label(&at).to_string(), - account_index: accounts::account_index(&at), + account_type: accounts::account_type_db_label(at).to_string(), + account_index: accounts::account_index(at), user_identity_id, friend_identity_id, } @@ -1162,7 +1188,7 @@ fn owning_account_of(account: &ManagedCoreFundsAccount) -> OwningAccount { /// rescan to re-warm — generous enough to cover any realistic per-account /// derivation depth. The common (single funds account) path terminates at /// the true high-water mark well before this and never reaches the cap. -const MAX_REHYDRATION_DERIVATION_INDEX: u32 = 10_000; +pub(crate) const MAX_REHYDRATION_DERIVATION_INDEX: u32 = 10_000; /// Soft threshold past which a single chain's discovery scan is treated as /// abnormally deep and worth a `tracing::warn!`. Real funds chains anchor @@ -1423,8 +1449,15 @@ fn extend_pools_for_restored_addresses( // reaches 50 and derives idx 45). A single mark-then-refill pass // would leave that address in the pool with `used = false`, handing // a previously-used address back out as fresh. Terminates: each - // round marks at least one new address from the finite restored set - // (`mark_used` returns `true` only on an unused→used flip). + // round after the first marks at least one new address from the + // finite restored set (`mark_used` returns `true` only on an + // unused→used flip). + // + // A pool restored from its stored rows enters with those entries + // already used, so no flip announces them. Its window is refilled + // once regardless, or the addresses past the stored rows would stay + // underived: unobserved, and a funded one among them never marked. + let mut restored_used = pool.highest_used.is_some(); loop { let mut marked_any = false; for addr in restored_addresses { @@ -1432,7 +1465,8 @@ fn extend_pools_for_restored_addresses( marked_any = true; } } - if !marked_any { + let refill_restored = std::mem::take(&mut restored_used); + if !marked_any && !refill_restored { break; } // Refill the gap window past the deepest used index (needs the @@ -2165,10 +2199,9 @@ mod tests { } } - /// A used address whose owning account is not one of this wallet's funds - /// accounts — what a masternode-operator wallet looks like, since provider - /// accounts sit on a non-secp256k1 curve and are not funds accounts at all. - /// Degraded in every policy, fatal in none (dashpay/platform#3968). + /// A used address whose owning account this wallet did not rebuild at + /// all — its registration is gone while its pool row stayed. Degraded in + /// every policy, fatal in none (dashpay/platform#3968). #[test] fn rehydration_orphaned_used_address_owner_is_degraded_not_fatal() { use key_wallet::wallet::managed_wallet_info::ManagedWalletInfo; @@ -2184,13 +2217,13 @@ mod tests { let manifest = manifest_for(&wallet); let mut wallet_info = ManagedWalletInfo::from_wallet(&wallet, 1); - // An owner this wallet has no funds account for. + // An owner this wallet has no account of any kind for. let mut used: HashMap> = HashMap::new(); used.insert( first_external_address(&wallet_info, &manifest), Some(OwningAccount { - account_type: "provider_platform".to_string(), - account_index: 0, + account_type: "identity_topup".to_string(), + account_index: 7, user_identity_id: [0u8; 32], friend_identity_id: [0u8; 32], }), @@ -2222,6 +2255,78 @@ mod tests { ); } + /// A used address of a keys-only account the wallet did rebuild — a + /// masternode operator's provider platform key — is back on its own pool + /// already and no funds account can derive it, so it is neither routed + /// to one nor reported. + #[test] + fn rehydration_used_address_of_a_rebuilt_keys_account_is_not_reported() { + use key_wallet::wallet::managed_wallet_info::ManagedWalletInfo; + use key_wallet::Address; + use std::collections::HashMap; + + let wallet = Wallet::from_seed_bytes( + [15u8; 64], + Network::Testnet, + WalletAccountCreationOptions::Default, + ) + .unwrap(); + let manifest = manifest_for(&wallet); + let mut wallet_info = ManagedWalletInfo::from_wallet(&wallet, 1); + assert!( + wallet_info.accounts.provider_platform_keys.is_some(), + "precondition: the wallet rebuilds a provider platform key account" + ); + let address = first_external_address(&wallet_info, &manifest); + + let mut used: HashMap> = HashMap::new(); + used.insert( + address.clone(), + Some(OwningAccount { + account_type: "provider_platform".to_string(), + account_index: 0, + user_identity_id: [0u8; 32], + friend_identity_id: [0u8; 32], + }), + ); + + let core = platform_wallet::changeset::CoreChangeSet { + last_processed_height: Some(1), + synced_height: Some(1), + ..Default::default() + }; + let ctx = LoadCtx::strict(); + apply_persisted_core_state( + &mut wallet_info, + &manifest, + &core, + &Default::default(), + &used, + &ctx, + ) + .unwrap(); + + assert!( + !ctx.degradation().degraded, + "a rebuilt owner is not an orphan: {:?}", + ctx.degradation().by_site + ); + let funds = wallet_info.accounts.all_funding_accounts(); + assert!( + funds.iter().all( + |account| account + .managed_account_type() + .address_pools() + .iter() + .all(|pool| !pool + .address_index + .get(&address) + .is_some_and(|index| pool.used_indices.contains(index))) + ), + "the keys account's address must not be marked used on a funds account" + ); + } + /// Two unroutable owners are two incidents. Every existing test at this /// site seeds exactly one address, which cannot tell "count the rows" /// apart from "count the times the reader decided to tolerate them" — @@ -2242,8 +2347,8 @@ mod tests { let mut wallet_info = ManagedWalletInfo::from_wallet(&wallet, 1); let orphan_owner = OwningAccount { - account_type: "provider_platform".to_string(), - account_index: 0, + account_type: "identity_topup".to_string(), + account_index: 7, user_identity_id: [0u8; 32], friend_identity_id: [0u8; 32], }; diff --git a/packages/rs-platform-wallet-storage/src/sqlite/schema/core_pool.rs b/packages/rs-platform-wallet-storage/src/sqlite/schema/core_pool.rs index f5b3d4056d3..401756f9161 100644 --- a/packages/rs-platform-wallet-storage/src/sqlite/schema/core_pool.rs +++ b/packages/rs-platform-wallet-storage/src/sqlite/schema/core_pool.rs @@ -16,12 +16,16 @@ use rusqlite::{params, Connection, Transaction}; use platform_wallet::changeset::AccountAddressPoolEntry; use platform_wallet::wallet::platform_wallet::WalletId; -use key_wallet::managed_account::address_pool::{AddressPoolType, PublicKeyType}; +use key_wallet::managed_account::address_pool::{ + AddressPool, AddressPoolType, AddressState, KeySource, PublicKeyType, +}; use crate::sqlite::error::WalletStorageError; -use crate::sqlite::load_ctx::{LoadCtx, LoadSite}; +use crate::sqlite::load_ctx::{LoadCtx, LoadSite, SiteCoords}; +use crate::sqlite::rehydrate::MAX_REHYDRATION_DERIVATION_INDEX; use crate::sqlite::schema::accounts; use crate::sqlite::schema::blob; +use crate::sqlite::util::safe_cast::{i64_to_u32, i64_to_u64}; /// Stored `pool_type` discriminant. Kept in the primary key so an External /// and an Internal pool never collide at the same `address_index`. @@ -201,9 +205,10 @@ pub fn apply_pools( Ok(()) } -// TODO(#4188): `reserved_at` is persisted but deliberately not consumed here; -// restoring it requires widening `provider_accounts::insert_platform_node_pool_entry`. /// One restored typed-pool row: `(address_index, script_bytes, public_key, used)`. +/// +/// `reserved_at` is not part of the row: [`restore_pools`] restores it for +/// every pool, this one included. pub type TypedPoolEntry = (u32, Vec, PublicKeyType, bool); /// Load typed public-key rows for one account pool, ordered by address index. @@ -483,6 +488,535 @@ pub fn load_used_addresses_with_ctx( Ok(out) } +/// Public key material that re-derives `account_type`'s pool entries, if the +/// watch-only `wallet` holds any. +fn public_key_source( + wallet: &key_wallet::wallet::Wallet, + account_type: key_wallet::account::AccountType, +) -> Option { + if let Some(account) = wallet.accounts.account_of_type(account_type) { + return Some(KeySource::Public(account.account_xpub)); + } + wallet + .accounts + .bls_account_of_type(account_type) + .map(|account| KeySource::BLSPublic(account.bls_public_key.clone())) +} + +/// How many indices a stored pool may be missing below its highest row and +/// still be derived all the way through. +/// +/// Checking a row means deriving every index below it, and that work has to +/// be bounded: one damaged `address_index` must not buy billions of +/// derivations. Depth is not what marks a damaged index, though. The writer +/// stores a pool row for row however far the wallet has really grown it, so +/// a deep pool brings one row for every index it asks to have derived, and a +/// wallet that issued twenty thousand funding keys is as healthy as one that +/// issued twenty. What marks damage is sparseness: far more indices below a +/// row than there are rows. The derivation budget is therefore the pool's +/// own row count plus this allowance for indices no row covers — the same +/// allowance the discovery walk gives an account, so a partial snapshot it +/// could have resolved is restored here too. +const UNSTORED_INDEX_ALLOWANCE: u32 = MAX_REHYDRATION_DERIVATION_INDEX; + +/// Derive `pool` from its account's public key towards `highest_stored`, so +/// the stored rows meet entries this wallet derived itself. +/// +/// At most `stored_rows` + [`UNSTORED_INDEX_ALLOWANCE`] indices are derived. +/// A row left above that has no derived entry, and the overlay skips it. +/// +/// Hardened pools are left as they are; see [`restore_pools`]. +fn derive_towards( + pool: &mut AddressPool, + key_source: Option<&KeySource>, + highest_stored: u32, + stored_rows: u32, +) -> Result<(), WalletStorageError> { + if pool.pool_type == AddressPoolType::AbsentHardened { + return Ok(()); + } + let derived = pool + .highest_generated + .map_or(0, |highest| highest.saturating_add(1)); + if highest_stored < derived { + return Ok(()); + } + // Without key material nothing is derived, and the overlay refuses the + // rows that are left without a derived entry. + let Some(key_source) = key_source else { + return Ok(()); + }; + let missing = highest_stored - derived + 1; + let budget = stored_rows.saturating_add(UNSTORED_INDEX_ALLOWANCE); + pool.generate_addresses(missing.min(budget), key_source, true) + .map_err(|e| WalletStorageError::AccountRecordInvalid { e })?; + Ok(()) +} + +/// Labels of the accounts whose keys each fund one asset lock. +/// +/// Such a key leaves the wallet — an invitation exports it — and never shows +/// up as a coin, so the `used` flag or reservation on its pool row is the +/// only record that it is gone. The set is the one the wallet snapshots after +/// an asset-lock build (`asset_lock_account_pool_entries` in +/// `rs-platform-wallet`'s `wallet/asset_lock/build.rs`), which this crate +/// cannot name from there; `one_time_funding_labels_match_the_asset_lock_writer` +/// holds the two together. +const ONE_TIME_FUNDING_LABELS: [&str; 6] = [ + "identity_registration", + "identity_topup", + "identity_topup_unbound", + "identity_invitation", + "asset_lock_address_topup", + "asset_lock_shielded_topup", +]; + +const ONE_TIME_ROW_UNMATCHED: &str = "one-time funding key row matches no restored account"; +const ONE_TIME_ROW_NOT_RESTORED: &str = "one-time funding key row could not be restored"; + +/// Refuse a stored pool row. +/// +/// The error says only what is wrong with the row, and under `Strict` +/// nothing else records where the row sits, so its coordinates go to the log +/// here. Neither its address nor its key is logged. +fn refuse_pool_row( + wallet_id: &WalletId, + account_type: &dyn std::fmt::Debug, + pool_type: &dyn std::fmt::Debug, + address_index: Option, + reason: &'static str, +) -> WalletStorageError { + tracing::error!( + wallet_id = %hex::encode(wallet_id), + account_type = ?account_type, + pool_type = ?pool_type, + address_index = ?address_index, + reason, + "core pool row refused; its wallet does not load" + ); + WalletStorageError::blob_decode(reason) +} + +/// Restore every persisted Core pool, including handed-out and pre-derived keys. +/// +/// A restored entry is an address the wallet goes on to hand out, so a row is +/// not taken on trust where it can be checked. Each pool is first derived, +/// from the account's public key in `wallet`, through its highest stored +/// index; a row then overlays an entry this wallet derived itself and must +/// name the same address and key. +/// +/// That derivation is budgeted by the pool's own row count (see +/// [`UNSTORED_INDEX_ALLOWANCE`]), so a deep pool the wallet really grew +/// restores in full, while an index with almost nothing stored below it +/// cannot force unbounded work. A row above the budget is not restored: it +/// is counted as [`LoadSite::PoolRowBeyondDerivationBudget`] under either +/// policy and the wallet loads without it, the way the discovery walk defers +/// an address it cannot reach. +/// +/// One-time funding keys ([`ONE_TIME_FUNDING_LABELS`]) get no such leniency. +/// A row of theirs that is used or reserved and that no pool restored here +/// takes back — its coordinates match no account, it lies above the budget, +/// or its script cannot be read — fails the wallet: loading without it would +/// hand the same key out again. A row that is gone altogether leaves nothing +/// to notice, and a wallet that rebuilds no account is not asked. +/// +/// `AbsentHardened` pools are the exception. A hardened child cannot be +/// derived from public key material, and a watch-only load holds nothing +/// else, so their rows — the pre-derived platform-node keys — are trusted as +/// stored, as [`restore_provider_platform_node_pool`] already trusts them. +/// +/// [`restore_provider_platform_node_pool`]: crate::sqlite::rehydrate::restore_provider_platform_node_pool +pub(crate) fn restore_pools( + conn: &Connection, + wallet_id: &WalletId, + wallet: &key_wallet::wallet::Wallet, + wallet_info: &mut key_wallet::wallet::ManagedWalletInfo, + ctx: &LoadCtx, +) -> Result<(), WalletStorageError> { + let accounts = wallet_info.accounts.all_accounts_mut(); + // No account, no pool: there is nothing to restore into, and nothing a + // stored reservation could be handed out from, so the store is not asked. + if accounts.is_empty() { + return Ok(()); + } + // Prepared once, and cached like every repeated-shape statement in this + // module: every account and pool below binds the same three shapes. + let mut stored_pool_types = conn.prepare_cached( + "SELECT DISTINCT pool_type FROM core_address_pool \ + WHERE wallet_id = ?1 AND account_type = ?2 AND account_index = ?3 \ + AND key_class = ?4 AND user_identity_id = ?5 AND friend_identity_id = ?6", + )?; + let mut highest_stored_index = conn.prepare_cached( + "SELECT MAX(address_index), COUNT(*) FROM core_address_pool \ + WHERE wallet_id = ?1 AND account_type = ?2 AND account_index = ?3 \ + AND key_class = ?4 AND user_identity_id = ?5 AND friend_identity_id = ?6 \ + AND pool_type = ?7", + )?; + let mut stored_rows = conn.prepare_cached( + "SELECT address_index, length(script), script, length(public_key), public_key, \ + key_type, used, reserved_at FROM core_address_pool \ + WHERE wallet_id = ?1 AND account_type = ?2 AND account_index = ?3 \ + AND key_class = ?4 AND user_identity_id = ?5 AND friend_identity_id = ?6 \ + AND pool_type = ?7 ORDER BY address_index", + )?; + let mut restored_reservations: i64 = 0; + // Coordinates of every one-time funding pool restored below. + let mut one_time_pools = std::collections::HashSet::new(); + for mut account in accounts { + let account_type = account.managed_account_type().to_account_type(); + let label = accounts::account_type_db_label(&account_type); + let one_time = ONE_TIME_FUNDING_LABELS.contains(&label); + let (user, friend) = accounts::account_dashpay_ids(&account_type); + let key_source = public_key_source(wallet, account_type); + let account_index = i64::from(accounts::account_index(&account_type)); + let key_class = i64::from(accounts::account_key_class(&account_type)); + // A row filed under a pool this account does not have is read by no + // pool below, so whatever it reserves would be dropped by a load that + // reports success. + let pool_types: Vec = account + .managed_account_type() + .address_pools() + .iter() + .map(|pool| pool_type_to_i64(pool.pool_type)) + .collect(); + let mut stored = stored_pool_types.query(params![ + wallet_id.as_slice(), + label, + account_index, + key_class, + user.as_slice(), + friend.as_slice(), + ])?; + while let Some(row) = stored.next()? { + let stored_pool_type: i64 = row.get(0)?; + if !pool_types.contains(&stored_pool_type) { + return Err(refuse_pool_row( + wallet_id, + &account_type, + &stored_pool_type, + None, + "core pool row names a pool its account does not have", + )); + } + } + for pool in account.managed_account_type_mut().address_pools_mut() { + let pool_rows = params![ + wallet_id.as_slice(), + label, + account_index, + key_class, + user.as_slice(), + friend.as_slice(), + pool_type_to_i64(pool.pool_type) + ]; + if one_time { + one_time_pools.insert(( + label.to_string(), + account_index, + key_class, + user.to_vec(), + friend.to_vec(), + pool_type_to_i64(pool.pool_type), + )); + } + let (highest_stored, stored): (Option, i64) = + highest_stored_index.query_row(pool_rows, |row| Ok((row.get(0)?, row.get(1)?)))?; + if let Some(highest_stored) = highest_stored { + derive_towards( + pool, + key_source.as_ref(), + i64_to_u32("core_address_pool.address_index", highest_stored)?, + // Saturating: a larger count only widens the budget. + u32::try_from(stored).unwrap_or(u32::MAX), + )?; + } + let mut beyond_budget = 0usize; + let mut rows = stored_rows.query(pool_rows)?; + while let Some(row) = rows.next()? { + let index = i64_to_u32("core_address_pool.address_index", row.get(0)?)?; + blob::check_size(row.get(1)?)?; + let script = dashcore::ScriptBuf::from_bytes(row.get(2)?); + let address = match dashcore::Address::from_script(&script, pool.network) { + Ok(address) => address, + Err(source) => { + // No earlier reader accounts for a one-time funding key. + if one_time + && (row.get::<_, bool>(6)? || row.get::<_, Option>(7)?.is_some()) + { + return Err(refuse_pool_row( + wallet_id, + &account_type, + &pool.pool_type, + Some(index), + ONE_TIME_ROW_NOT_RESTORED, + )); + } + // Earlier readers already account for used and typed platform rows. + if row.get::<_, bool>(6)? + || (account_type + == key_wallet::account::AccountType::ProviderPlatformKeys + && (row.get::<_, Option>(3)?.is_some() + || row.get::<_, Option>(5)?.is_some())) + { + continue; + } + ctx.tolerate_at( + LoadSite::UndecodableAddressScript, + SiteCoords { + wallet_id: Some(*wallet_id), + account_type: &account_type, + affected: 1, + detail: Some(&pool.pool_type), + }, + WalletStorageError::from(source), + )?; + continue; + } + }; + let key_len: Option = row.get(3)?; + let key_type: Option = row.get(5)?; + let public_key = match (key_len, key_type) { + (None, None) => None, + (Some(len), Some(kind)) => { + let expected_kind = match account_type { + key_wallet::account::AccountType::ProviderOperatorKeys => KEY_TYPE_BLS, + key_wallet::account::AccountType::ProviderPlatformKeys => { + KEY_TYPE_EDDSA + } + _ => KEY_TYPE_ECDSA, + }; + if kind != expected_kind { + return Err(refuse_pool_row( + wallet_id, + &account_type, + &pool.pool_type, + Some(index), + "core pool key type does not match its account", + )); + } + let expected = match kind { + KEY_TYPE_ECDSA => ECDSA_PUBLIC_KEY_LEN, + KEY_TYPE_EDDSA => EDDSA_PUBLIC_KEY_LEN, + KEY_TYPE_BLS => BLS_PUBLIC_KEY_LEN, + _ => { + return Err(refuse_pool_row( + wallet_id, + &account_type, + &pool.pool_type, + Some(index), + "core_address_pool.key_type is outside 0..=2", + )) + } + }; + blob::check_fixed_width( + len, + expected, + "core_address_pool.public_key has the wrong length for key_type", + )?; + let bytes = row.get(4)?; + Some(match kind { + KEY_TYPE_ECDSA => PublicKeyType::ECDSA(bytes), + KEY_TYPE_EDDSA => PublicKeyType::EdDSA(bytes), + _ => PublicKeyType::BLS(bytes), + }) + } + _ => { + return Err(refuse_pool_row( + wallet_id, + &account_type, + &pool.pool_type, + Some(index), + "core_address_pool.public_key and key_type nullability differ", + )) + } + }; + let used: bool = row.get(6)?; + let reserved: Option = row.get(7)?; + let reserved = reserved + .map(|at| i64_to_u64("core_address_pool.reserved_at", at)) + .transpose()?; + let child = if pool.pool_type == AddressPoolType::AbsentHardened { + key_wallet::bip32::ChildNumber::from_hardened_idx(index) + } else { + key_wallet::bip32::ChildNumber::from_normal_idx(index) + } + .map_err(|source| WalletStorageError::AccountRecordInvalid { + e: key_wallet::error::Error::Bip32(source), + })?; + let mut path = pool.base_path.clone(); + path.push(child); + let previous = pool.addresses.get(&index); + if previous.is_none() && pool.pool_type != AddressPoolType::AbsentHardened { + // The pool was derived as far as its budget reaches, so a + // row above its end is one the budget left out. + if key_source.is_some() && pool.highest_generated.is_some_and(|end| index > end) + { + if one_time && (used || reserved.is_some()) { + return Err(refuse_pool_row( + wallet_id, + &account_type, + &pool.pool_type, + Some(index), + ONE_TIME_ROW_NOT_RESTORED, + )); + } + beyond_budget += 1; + continue; + } + return Err(refuse_pool_row( + wallet_id, + &account_type, + &pool.pool_type, + Some(index), + "core pool row has no derived entry to be checked against", + )); + } + if previous.is_some_and(|info| { + info.address != address + || public_key + .as_ref() + .zip(info.public_key.as_ref()) + .is_some_and(|(a, b)| a != b) + }) { + return Err(refuse_pool_row( + wallet_id, + &account_type, + &pool.pool_type, + Some(index), + "core pool row conflicts with its derived address or key", + )); + } + if pool + .address_index + .get(&address) + .is_some_and(|other| *other != index) + { + return Err(refuse_pool_row( + wallet_id, + &account_type, + &pool.pool_type, + Some(index), + "core pool address has multiple derivation indices", + )); + } + let stored_reservation = !used && reserved.is_some(); + // Chain-derived usage takes precedence over an older pool snapshot. + let used = used || previous.is_some_and(|info| info.is_used()); + let state = if used { + AddressState::Used + } else if let Some(at) = reserved { + AddressState::Reserved { at } + } else { + AddressState::Available + }; + let info = key_wallet::AddressInfo { + address, + script_pubkey: script, + public_key: public_key + .or_else(|| previous.and_then(|info| info.public_key.clone())), + index, + path, + state, + tx_count: previous.map_or(0, |info| info.tx_count), + total_received: previous.map_or(0, |info| info.total_received), + total_sent: previous.map_or(0, |info| info.total_sent), + balance: previous.map_or(0, |info| info.balance), + label: previous.and_then(|info| info.label.clone()), + metadata: previous.map_or_else(Default::default, |info| info.metadata.clone()), + }; + if let Some(previous) = pool.addresses.remove(&index) { + pool.address_index.remove(&previous.address); + pool.script_pubkey_index.remove(&previous.script_pubkey); + } + pool.address_index.insert(info.address.clone(), index); + pool.script_pubkey_index + .insert(info.script_pubkey.clone(), index); + pool.addresses.insert(index, info); + restored_reservations += i64::from(stored_reservation); + pool.highest_generated = + Some(pool.highest_generated.map_or(index, |old| old.max(index))); + if used { + pool.used_indices.insert(index); + pool.highest_used = Some( + pool.highest_used + .map_or(index, |highest| highest.max(index)), + ); + } + } + if beyond_budget > 0 { + ctx.note_degraded( + LoadSite::PoolRowBeyondDerivationBudget, + SiteCoords { + wallet_id: Some(*wallet_id), + account_type: &account_type, + affected: beyond_budget, + detail: Some(&pool.pool_type), + }, + "stored pool rows sit further above the rest of their pool than the \ + derivation budget reaches, so they cannot be checked and are not restored", + ); + } + } + } + // Rows are read account by account, so a row whose coordinates match no + // pool restored above was read by nobody. For a one-time funding key that + // is used or reserved, loading on would hand the key out a second time. + let [l0, l1, l2, l3, l4, l5] = ONE_TIME_FUNDING_LABELS; + let mut one_time_rows = conn.prepare_cached( + "SELECT account_type, account_index, key_class, user_identity_id, friend_identity_id, \ + pool_type, address_index FROM core_address_pool \ + WHERE wallet_id = ?1 AND account_type IN (?2, ?3, ?4, ?5, ?6, ?7) \ + AND (used = 1 OR reserved_at IS NOT NULL)", + )?; + let mut rows = one_time_rows.query(params![wallet_id.as_slice(), l0, l1, l2, l3, l4, l5])?; + while let Some(row) = rows.next()? { + let pool: (String, i64, i64, Vec, Vec, i64) = ( + row.get(0)?, + row.get(1)?, + row.get(2)?, + row.get(3)?, + row.get(4)?, + row.get(5)?, + ); + if !one_time_pools.contains(&pool) { + // The identity ids of the row are left out of the log. + return Err(refuse_pool_row( + wallet_id, + &format_args!("{}[{}], key class {}", pool.0, pool.1, pool.2), + &pool.5, + u32::try_from(row.get::<_, i64>(6)?).ok(), + ONE_TIME_ROW_UNMATCHED, + )); + } + } + // A reservation of any other account can go unread the same way. Whether + // its row is damaged or belongs to an account this wallet no longer + // rebuilds cannot be told from the row, and handing its address out again + // costs privacy, not funds, so it is reported under either policy instead + // of failing the wallet. + let stored_reservations: i64 = conn + .prepare_cached( + "SELECT COUNT(*) FROM core_address_pool \ + WHERE wallet_id = ?1 AND used = 0 AND reserved_at IS NOT NULL", + )? + .query_row(params![wallet_id.as_slice()], |row| row.get(0))?; + let unrestored = stored_reservations.saturating_sub(restored_reservations); + if unrestored > 0 { + ctx.note_degraded( + LoadSite::UnrestoredPoolReservation, + SiteCoords { + wallet_id: Some(*wallet_id), + account_type: &"wallet", + affected: usize::try_from(unrestored).unwrap_or(usize::MAX), + detail: None, + }, + "stored address reservations were taken back by no pool this wallet restored, \ + so those addresses can be handed out again", + ); + } + Ok(()) +} + #[cfg(test)] mod tests { use super::*; @@ -696,6 +1230,267 @@ mod tests { assert_eq!(rows, 0, "the guard must refuse before binding the row"); } + fn with_pool_of( + info: &mut key_wallet::wallet::ManagedWalletInfo, + address: &dashcore::Address, + read: impl Fn(&mut AddressPool, u32) -> R, + ) -> R { + for mut account in info.accounts.all_accounts_mut() { + for pool in account.managed_account_type_mut().address_pools_mut() { + if let Some(index) = pool.address_index.get(address).copied() { + return read(pool, index); + } + } + } + panic!("the address must belong to a pool"); + } + + /// A wallet whose BIP44 external pool is stored exactly as derived, and + /// the first receive address of that pool. + fn wallet_with_stored_external_pool( + conn: &mut rusqlite::Connection, + ) -> ( + key_wallet::wallet::Wallet, + key_wallet::wallet::ManagedWalletInfo, + dashcore::Address, + ) { + use key_wallet::account::{AccountType, StandardAccountType}; + use key_wallet::wallet::initialization::WalletAccountCreationOptions; + use key_wallet::wallet::{ManagedWalletInfo, Wallet}; + + let wallet = Wallet::new_random( + dashcore::Network::Testnet, + WalletAccountCreationOptions::Default, + ) + .unwrap(); + conn.execute( + "INSERT INTO wallets (wallet_id, network, birth_height) VALUES (?1, 'testnet', 0)", + params![wallet.wallet_id.as_slice()], + ) + .unwrap(); + let mut info = ManagedWalletInfo::from_wallet(&wallet, 0); + let xpub = wallet.accounts.standard_bip44_accounts[&0].account_xpub; + let address = info + .accounts + .standard_bip44_accounts + .get_mut(&0) + .unwrap() + .next_receive_address(Some(&xpub), true) + .unwrap(); + let stored = with_pool_of(&mut info, &address, |pool, _| AccountAddressPoolEntry { + account_type: AccountType::Standard { + index: 0, + standard_account_type: StandardAccountType::BIP44Account, + }, + pool_type: pool.pool_type, + addresses: pool.addresses.values().cloned().collect(), + }); + let tx = conn.transaction().unwrap(); + apply_pools(&tx, &wallet.wallet_id, std::slice::from_ref(&stored)).unwrap(); + tx.commit().unwrap(); + (wallet, info, address) + } + + /// `restore_pools` overlays a persisted row on the entry the load + /// projection already built, and a repaired wallet is rebuilt twice in + /// one load. The accounting that entry carries must come through + /// unchanged: neither reset by the overlay nor counted a second time. + #[test] + fn restore_pools_keeps_existing_address_accounting_exactly_once() { + let mut conn = migrated_conn(); + let (wallet, mut info, address) = wallet_with_stored_external_pool(&mut conn); + with_pool_of(&mut info, &address, |pool, index| { + let entry = pool.addresses.get_mut(&index).unwrap(); + entry.update_stats(700, 200); + entry.balance = 500; + }); + + let ctx = LoadCtx::strict(); + for round in 1..=2 { + restore_pools(&conn, &wallet.wallet_id, &wallet, &mut info, &ctx).unwrap(); + let accounting = with_pool_of(&mut info, &address, |pool, index| { + let entry = &pool.addresses[&index]; + ( + entry.tx_count, + entry.total_received, + entry.total_sent, + entry.balance, + ) + }); + assert_eq!(accounting, (1, 700, 200, 500), "restore round {round}"); + } + } + + /// Checking a stored row means deriving every index below it. An index + /// with almost nothing stored below it is left out instead: the pool is + /// derived as far as its budget reaches and no further, the row is + /// counted, and the rest of the pool is restored. The index used here + /// would take billions of derivations if the budget did not hold. + #[test] + fn restore_pools_skips_a_row_the_derivation_budget_cannot_reach() { + let mut conn = migrated_conn(); + let (wallet, mut info, address) = wallet_with_stored_external_pool(&mut conn); + let derived_end = with_pool_of(&mut info, &address, |pool, _| pool.highest_generated) + .expect("the stored pool is derived"); + let stored_rows: u32 = conn + .query_row("SELECT COUNT(*) FROM core_address_pool", [], |row| { + row.get(0) + }) + .unwrap(); + let unreachable = u32::try_from(i32::MAX).unwrap(); + let moved = conn + .execute( + "UPDATE core_address_pool SET address_index = ?1 \ + WHERE address_index = (SELECT MAX(address_index) FROM core_address_pool)", + params![i64::from(unreachable)], + ) + .unwrap(); + assert_eq!(moved, 1); + + let ctx = LoadCtx::strict(); + restore_pools(&conn, &wallet.wallet_id, &wallet, &mut info, &ctx) + .expect("an unreachable row must not fail the wallet"); + assert_eq!( + ctx.degradation() + .by_site + .get(&LoadSite::PoolRowBeyondDerivationBudget), + Some(&1), + "the row left out must be counted" + ); + with_pool_of(&mut info, &address, |pool, index| { + assert!( + !pool.addresses.contains_key(&unreachable), + "a row that could not be checked must not be restored" + ); + assert_eq!(pool.addresses[&index].address, address); + assert_eq!( + pool.highest_generated, + Some(derived_end + stored_rows + UNSTORED_INDEX_ALLOWANCE), + "nothing may be derived beyond the budget" + ); + }); + } + + /// The typed error names what is wrong; the log has to name where, without + /// the address or the key. + #[test] + #[tracing_test::traced_test] + fn restore_pools_logs_where_a_refused_row_sits() { + const INDEX: u32 = 3; + let mut conn = migrated_conn(); + let (wallet, mut info, address) = wallet_with_stored_external_pool(&mut conn); + let derived = with_pool_of(&mut info, &address, |pool, _| { + pool.addresses[&INDEX].address.clone() + }); + let foreign = format!("76a914{}88ac", "42".repeat(20)); + let rewritten = conn + .execute( + &format!( + "UPDATE core_address_pool SET script = X'{foreign}' WHERE address_index = ?1" + ), + params![INDEX], + ) + .unwrap(); + assert_eq!(rewritten, 1); + + let error = restore_pools( + &conn, + &wallet.wallet_id, + &wallet, + &mut info, + &LoadCtx::strict(), + ) + .expect_err("a row that contradicts its derivation is refused"); + assert!( + matches!( + error, + WalletStorageError::BlobDecode { reason } + if reason == "core pool row conflicts with its derived address or key" + ), + "unexpected error: {error:?}" + ); + for coordinate in [ + hex::encode(wallet.wallet_id), + "BIP44Account".to_string(), + "pool_type=External".to_string(), + format!("address_index=Some({INDEX})"), + "core pool row conflicts with its derived address or key".to_string(), + ] { + assert!( + logs_contain(&coordinate), + "missing from the log: {coordinate}" + ); + } + for withheld in [derived.to_string(), foreign, "42".repeat(20)] { + assert!(!logs_contain(&withheld), "must not be logged: {withheld}"); + } + } + + /// The asset-lock writer in `rs-platform-wallet` decides which accounts + /// hold one-time funding keys; that crate does not export its list, so + /// it is read from the source. A variant added there fails here until the + /// label list follows. + #[test] + fn one_time_funding_labels_match_the_asset_lock_writer() { + use key_wallet::account::AccountType; + use std::collections::BTreeSet; + + let source = std::fs::read_to_string(concat!( + env!("CARGO_MANIFEST_DIR"), + "/../rs-platform-wallet/src/wallet/asset_lock/build.rs" + )) + .expect("the asset-lock writer's source"); + let writer = source + .split_once("fn asset_lock_account_pool_entries") + .and_then(|(_, body)| body.split_once("matches!(")) + .and_then(|(_, filter)| filter.split_once(".flat_map(")) + .map(|(filter, _)| filter) + .expect("the writer's account filter"); + let named_by_writer: BTreeSet<&str> = writer + .split("AccountType::") + .skip(1) + .map(|rest| { + rest.split(|c: char| !c.is_alphanumeric()) + .next() + .unwrap_or_default() + }) + .collect(); + + let accounts = [ + AccountType::IdentityRegistration, + AccountType::IdentityTopUp { + registration_index: 0, + }, + AccountType::IdentityTopUpNotBoundToIdentity, + AccountType::IdentityInvitation, + AccountType::AssetLockAddressTopUp, + AccountType::AssetLockShieldedAddressTopUp, + ]; + let named_here: BTreeSet = accounts + .iter() + .map(|account| format!("{account:?}")) + .map(|debug| { + debug + .split([' ', '{']) + .next() + .unwrap_or_default() + .to_string() + }) + .collect(); + assert_eq!( + named_by_writer, + named_here.iter().map(String::as_str).collect(), + "the asset-lock writer snapshots a different set of accounts" + ); + assert_eq!( + accounts + .iter() + .map(accounts::account_type_db_label) + .collect::>(), + ONE_TIME_FUNDING_LABELS.into_iter().collect(), + ); + } + #[test] fn integer_discriminants_match_migration_domains() { use std::collections::BTreeSet; diff --git a/packages/rs-platform-wallet-storage/tests/integration/main.rs b/packages/rs-platform-wallet-storage/tests/integration/main.rs index 51e5fcf4fe5..fbeee39066e 100644 --- a/packages/rs-platform-wallet-storage/tests/integration/main.rs +++ b/packages/rs-platform-wallet-storage/tests/integration/main.rs @@ -87,3 +87,5 @@ mod sqlite_v008_migration; mod sqlite_version_bump; mod sqlite_wallet_db_identity; mod tracked_masternodes_roundtrip; + +mod sqlite_wallet_restore; diff --git a/packages/rs-platform-wallet-storage/tests/integration/sqlite_persist_roundtrip.rs b/packages/rs-platform-wallet-storage/tests/integration/sqlite_persist_roundtrip.rs index 7e2ce7943dc..65adb91737e 100644 --- a/packages/rs-platform-wallet-storage/tests/integration/sqlite_persist_roundtrip.rs +++ b/packages/rs-platform-wallet-storage/tests/integration/sqlite_persist_roundtrip.rs @@ -491,7 +491,7 @@ async fn tc010b_recovered_from_chain_lock_roundtrip() { ), "SQLite must support reconciliation of the asset-lock rows it restores" ); - assert!(!persister + assert!(persister .persistence_capabilities() .contains(platform_wallet::changeset::PersistenceCapabilities::WALLET_RESTORE)); let w = wid(0xFB); diff --git a/packages/rs-platform-wallet-storage/tests/integration/sqlite_shielded_viewing_keys.rs b/packages/rs-platform-wallet-storage/tests/integration/sqlite_shielded_viewing_keys.rs index 1cb9a50f3e1..72ad8006989 100644 --- a/packages/rs-platform-wallet-storage/tests/integration/sqlite_shielded_viewing_keys.rs +++ b/packages/rs-platform-wallet-storage/tests/integration/sqlite_shielded_viewing_keys.rs @@ -84,17 +84,19 @@ fn sqlite_advertises_shielded_viewing_key_capability() { } #[test] -fn sqlite_does_not_attest_wallet_drain_without_wallet_restore() { +fn should_not_attest_wallet_drain_without_durable_operation_evidence() { let (persister, _tmp, _path) = fresh_persister(); let capabilities = persister.persistence_capabilities(); assert!(!capabilities.contains(PersistenceCapabilities::WALLET_DRAIN)); assert!(capabilities .missing(PersistenceCapabilities::WALLET_DRAIN) .names() - .contains(&"wallet_restore")); - // Native asset-lock persistence remains available independently of the - // unsupported all-wallet shielded recovery contract. + .contains(&"asset_lock_operation_ids")); + // Core restoration and status reconciliation remain available without + // claiming that absent operation evidence proves a drain was never sent. assert!(capabilities.contains(PersistenceCapabilities::TRACKED_ASSET_LOCKS)); + assert!(capabilities.contains(PersistenceCapabilities::WALLET_RESTORE)); + assert!(capabilities.contains(PersistenceCapabilities::ASSET_LOCK_RECONCILIATION)); assert!(!capabilities.contains(PersistenceCapabilities::ASSET_LOCK_OPERATION_IDS)); } diff --git a/packages/rs-platform-wallet-storage/tests/integration/sqlite_wallet_restore.rs b/packages/rs-platform-wallet-storage/tests/integration/sqlite_wallet_restore.rs new file mode 100644 index 00000000000..c9068383afc --- /dev/null +++ b/packages/rs-platform-wallet-storage/tests/integration/sqlite_wallet_restore.rs @@ -0,0 +1,2023 @@ +//! Core snapshot restoration through SQLite reopen and real manager hydration. + +use crate::common; + +use std::sync::Arc; + +use dashcore::{hashes::Hash, BlockHash, Network, OutPoint, Transaction, TxIn, TxOut, Txid}; +#[cfg(feature = "shielded")] +use key_wallet::account::StandardAccountType; +use key_wallet::{ + account::AccountType, + managed_account::address_pool::{AddressState, KeySource}, + transaction_checking::{BlockInfo, TransactionContext, WalletTransactionChecker}, + wallet::{initialization::WalletAccountCreationOptions, ManagedWalletInfo, Wallet}, +}; +use platform_wallet::{ + changeset::{ + AccountAddressPoolEntry, AccountRegistrationEntry, CoreChangeSet, PersistenceCapabilities, + PlatformWalletChangeSet, PlatformWalletPersistence, ProviderKeyAccountEntry, + ProviderKeyExtendedPubKey, WalletMetadataEntry, + }, + events::EventHandler, + PlatformEventHandler, PlatformWalletManager, +}; +#[cfg(feature = "shielded")] +use platform_wallet::{ + test_support::funded_wallet_manager, + wallet::{ + asset_lock::{AssetLockFunding, AssetLockOperationContext}, + shielded::CachedOrchardProver, + }, + PlatformWalletError, +}; +use platform_wallet_storage::{SqlitePersister, SqlitePersisterConfig}; + +struct NoopHandler; +impl EventHandler for NoopHandler {} +impl PlatformEventHandler for NoopHandler {} + +fn registration(wallet: &Wallet, info: &ManagedWalletInfo) -> PlatformWalletChangeSet { + let mut provider = Vec::new(); + if let Some(account) = wallet + .accounts + .bls_account_of_type(AccountType::ProviderOperatorKeys) + { + provider.push(ProviderKeyAccountEntry { + account_type: AccountType::ProviderOperatorKeys, + extended_public_key: ProviderKeyExtendedPubKey::Bls(account.bls_public_key.clone()), + }); + } + if let Some(account) = wallet + .accounts + .eddsa_account_of_type(AccountType::ProviderPlatformKeys) + { + provider.push(ProviderKeyAccountEntry { + account_type: AccountType::ProviderPlatformKeys, + extended_public_key: ProviderKeyExtendedPubKey::EdDSA( + account.ed25519_public_key.clone(), + ), + }); + } + let mut pools = Vec::new(); + for account in info.accounts.all_accounts() { + let managed = account.managed_account_type(); + for pool in managed.address_pools() { + if !pool.addresses.is_empty() { + pools.push(AccountAddressPoolEntry { + account_type: managed.to_account_type(), + pool_type: pool.pool_type, + addresses: pool.addresses.values().cloned().collect(), + }); + } + } + } + PlatformWalletChangeSet { + wallet_metadata: Some(WalletMetadataEntry { + network: Network::Testnet, + wallet_group_id: [0; 32], + birth_height: 1, + }), + account_registrations: wallet + .accounts + .all_accounts() + .into_iter() + .map(|account| AccountRegistrationEntry { + account_type: account.account_type, + account_xpub: account.account_xpub, + }) + .collect(), + provider_key_account_registrations: provider, + account_address_pools: pools, + ..Default::default() + } +} + +async fn manager(persister: SqlitePersister) -> PlatformWalletManager { + let manager = PlatformWalletManager::new( + Arc::new( + dash_sdk::SdkBuilder::new_mock() + .with_network(Network::Testnet) + .build() + .unwrap(), + ), + Arc::new(persister), + Arc::new(NoopHandler), + ); + manager + .load_from_persistor() + .await + .expect("manager hydration"); + manager +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn should_restore_funding_indices_and_reserved_addresses_before_handing_out_keys() { + let mut wallet = + Wallet::new_random(Network::Testnet, WalletAccountCreationOptions::Default).unwrap(); + wallet + .add_account( + AccountType::IdentityTopUp { + registration_index: 7, + }, + None, + ) + .unwrap(); + let mut info = ManagedWalletInfo::from_wallet(&wallet, 1); + let platform_keys = + platform_wallet::wallet::provider_key_at_index::derive_platform_node_public_keys( + &wallet, + Network::Testnet, + 3, + ) + .unwrap(); + platform_wallet::wallet::provider_key_at_index::populate_platform_node_pool( + &mut info, + &platform_keys, + Network::Testnet, + ) + .unwrap(); + let mut expected = Vec::new(); + for mut account in info.accounts.all_accounts_mut() { + let account_type = account.managed_account_type().to_account_type(); + let source = public_key_source(&wallet, account_type); + for pool in account.managed_account_type_mut().address_pools_mut() { + let reserved_index = if let Some(source) = &source { + pool.generate_addresses(40, source, true).unwrap(); + for index in 0..40 { + assert!(pool.mark_index_used(index)); + } + let reserved = pool.next_unused_and_reserve(source, 12345).unwrap(); + pool.address_index[&reserved] + } else { + if pool.addresses.is_empty() { + continue; + } + assert!(pool.mark_index_used(0)); + pool.addresses.get_mut(&1).unwrap().state = AddressState::Reserved { at: 12345 }; + 1 + }; + expected.push(( + account_type, + pool.pool_type, + pool.addresses.clone(), + reserved_index, + pool.highest_used, + )); + } + } + for required in [ + AccountType::IdentityRegistration, + AccountType::IdentityTopUp { + registration_index: 7, + }, + AccountType::IdentityTopUpNotBoundToIdentity, + AccountType::IdentityInvitation, + AccountType::AssetLockAddressTopUp, + AccountType::AssetLockShieldedAddressTopUp, + AccountType::ProviderPlatformKeys, + AccountType::ProviderOperatorKeys, + ] { + assert!( + expected + .iter() + .any(|(account, _, _, _, _)| *account == required), + "missing required role {required:?}" + ); + } + let (persister, _tmp, path) = common::fresh_persister(); + persister + .store(wallet.wallet_id, registration(&wallet, &info)) + .unwrap(); + drop(persister); + let manager = manager(SqlitePersister::open(SqlitePersisterConfig::new(path)).unwrap()).await; + let wm = manager.wallet_manager_arc(); + let mut wm = wm.write().await; + let restored = wm.get_wallet_info_mut(&wallet.wallet_id).unwrap(); + for (account_type, pool_type, addresses, reserved_index, highest_used) in expected { + let source = public_key_source(&wallet, account_type); + let mut accounts = restored.core_wallet.accounts.all_accounts_mut(); + let account = accounts + .iter_mut() + .find(|account| account.managed_account_type().to_account_type() == account_type) + .unwrap(); + let mut pools = account.managed_account_type_mut().address_pools_mut(); + let pool = pools + .iter_mut() + .find(|pool| pool.pool_type == pool_type) + .unwrap(); + for (index, expected) in addresses { + let actual = pool + .addresses + .get(&index) + .expect("persisted beyond-gap address"); + assert_eq!( + actual.state, expected.state, + "{account_type:?} {pool_type:?} #{index}" + ); + assert_eq!(actual.address, expected.address); + assert_eq!(actual.path, expected.path); + assert_eq!(actual.public_key, expected.public_key); + } + assert_eq!( + pool.addresses[&reserved_index].state, + AddressState::Reserved { at: 12345 } + ); + assert_eq!( + pool.highest_used, highest_used, + "{account_type:?} {pool_type:?}" + ); + if let Some(source) = source { + let next = pool.next_unused(&source, true).unwrap(); + assert!( + pool.address_index[&next] > reserved_index, + "must not reuse funded or handed-out keys" + ); + } + } + drop(wm); + assert!(manager.shutdown().await.all_clean()); +} + +fn transaction(input: OutPoint, outputs: Vec) -> Transaction { + Transaction { + version: 1, + lock_time: 0, + input: vec![TxIn { + previous_output: input, + ..Default::default() + }], + output: outputs, + special_transaction_payload: None, + } +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn should_restore_pending_sends_in_dependency_order_without_resending_incoming() { + let mut wallet = + Wallet::new_random(Network::Testnet, WalletAccountCreationOptions::Default).unwrap(); + let mut info = ManagedWalletInfo::from_wallet(&wallet, 1); + let xpub = wallet.accounts.standard_bip44_accounts[&0].account_xpub; + let address = info + .accounts + .standard_bip44_accounts + .get_mut(&0) + .unwrap() + .next_receive_address(Some(&xpub), true) + .unwrap(); + let output = |value| TxOut { + value, + script_pubkey: address.script_pubkey(), + }; + let funding = transaction( + OutPoint::new(Txid::from_byte_array([1; 32]), 0), + vec![output(100_000)], + ); + // The loader drops pending spends of wallet coins that carry no valid + // owner signature, so the wallet's own sends are signed as it would sign them. + let parent = common::authorize( + &wallet, + &info, + &address, + transaction(OutPoint::new(funding.txid(), 0), vec![output(90_000)]), + ); + let child = common::authorize( + &wallet, + &info, + &address, + transaction(OutPoint::new(parent.txid(), 0), vec![output(80_000)]), + ); + let incoming = transaction( + OutPoint::new(Txid::from_byte_array([2; 32]), 0), + vec![output(5_000)], + ); + let (persister, _tmp, path) = common::fresh_persister(); + persister + .store(wallet.wallet_id, registration(&wallet, &info)) + .unwrap(); + for (tx, context) in [ + ( + &funding, + TransactionContext::InBlock(BlockInfo::new( + 100, + BlockHash::from_byte_array([3; 32]), + 1, + )), + ), + (&parent, TransactionContext::Mempool), + (&child, TransactionContext::Mempool), + (&incoming, TransactionContext::Mempool), + ] { + let result = info + .check_core_transaction(tx, context, &mut wallet, true, true) + .await; + assert!(result.is_relevant); + persister + .store( + wallet.wallet_id, + PlatformWalletChangeSet { + core: Some(CoreChangeSet { + records: result.new_records, + new_utxos: info.accounts.standard_bip44_accounts[&0] + .utxos + .values() + .cloned() + .collect(), + synced_height: Some(100), + last_processed_height: Some(100), + ..Default::default() + }), + ..Default::default() + }, + ) + .unwrap(); + } + drop(persister); + let persister = SqlitePersister::open(SqlitePersisterConfig::new(path)).unwrap(); + let start = persister.load().unwrap(); + let pending = &start.wallets[&wallet.wallet_id].unconfirmed_outgoing_txs; + assert!( + pending.is_empty(), + "restoration must not schedule transactions for rebroadcast" + ); + let manager = manager(persister).await; + let wm = manager.wallet_manager_arc(); + let wm = wm.read().await; + let restored = &wm.get_wallet_info(&wallet.wallet_id).unwrap().core_wallet; + let utxos = &restored.accounts.standard_bip44_accounts[&0].utxos; + assert!(!utxos.contains_key(&OutPoint::new(funding.txid(), 0))); + assert!(!utxos.contains_key(&OutPoint::new(parent.txid(), 0))); + assert!(utxos.contains_key(&OutPoint::new(child.txid(), 0))); + assert!(utxos.contains_key(&OutPoint::new(incoming.txid(), 0))); + assert_eq!(utxos.values().map(|utxo| utxo.value()).sum::(), 85_000); + use key_wallet::managed_account::managed_account_trait::ManagedAccountTrait; + let before = info.accounts.standard_bip44_accounts[&0] + .managed_account_type() + .address_pools()[0] + .addresses + .values() + .find(|entry| entry.address == address) + .unwrap(); + let after = restored.accounts.standard_bip44_accounts[&0] + .managed_account_type() + .address_pools()[0] + .addresses + .values() + .find(|entry| entry.address == address) + .unwrap(); + assert_eq!( + ( + after.tx_count, + after.total_received, + after.total_sent, + after.balance + ), + ( + before.tx_count, + before.total_received, + before.total_sent, + before.balance + ) + ); + drop(wm); + assert!(manager.shutdown().await.all_clean()); +} + +#[test] +fn should_advertise_full_core_wallet_restore() { + let (persister, _tmp, _path) = common::fresh_persister(); + assert!(persister + .persistence_capabilities() + .contains(PersistenceCapabilities::WALLET_RESTORE)); +} + +#[cfg(feature = "shielded")] +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn should_refuse_wallet_drain_without_durable_operation_evidence() { + let (source, wallet_id, _, signer) = + funded_wallet_manager(StandardAccountType::BIP44Account).await; + let (persister, tmp, _) = common::fresh_persister(); + let snapshot = { + let source = source.read().await; + let wallet = source.get_wallet(&wallet_id).unwrap(); + let info = &source.get_wallet_info(&wallet_id).unwrap().core_wallet; + let mut snapshot = registration(wallet, info); + snapshot.core = Some(CoreChangeSet { + new_utxos: info.accounts.standard_bip44_accounts[&0] + .utxos + .values() + .cloned() + .collect(), + ..Default::default() + }); + snapshot + }; + persister.store(wallet_id, snapshot).unwrap(); + let persister = Arc::new(persister); + let manager = PlatformWalletManager::new( + Arc::new( + dash_sdk::SdkBuilder::new_mock() + .with_network(Network::Testnet) + .build() + .unwrap(), + ), + Arc::clone(&persister), + Arc::new(NoopHandler), + ); + manager.load_from_persistor().await.unwrap(); + manager + .configure_shielded(tmp.path().join("shielded.db")) + .await + .unwrap(); + let coordinator = manager.shielded_coordinator().await.unwrap(); + let wallet = manager.get_wallet(&wallet_id).await.unwrap(); + let changes_before = persister.lock_conn_for_test().total_changes(); + let assert_missing_evidence = |error| { + assert!( + matches!(error, PlatformWalletError::Persistence(message) if message.contains("asset_lock_operation_ids")) + ); + }; + assert_missing_evidence( + wallet + .asset_locks() + .wallet_drain_operation_status([7; 16]) + .await + .unwrap_err(), + ); + assert_missing_evidence( + wallet + .shielded_fund_from_asset_lock( + &coordinator, + AssetLockFunding::DrainWalletBalance { + account_index: 0, + minimum_lock_duffs: None, + operation: Arc::new(AssetLockOperationContext::new([7; 16])), + }, + Vec::new(), + &signer, + &CachedOrchardProver::new(), + None, + 0, + None, + None, + ) + .await + .unwrap_err(), + ); + assert_eq!( + persister.lock_conn_for_test().total_changes(), + changes_before + ); + assert!(wallet.asset_locks().list_tracked_locks().await.is_empty()); + assert!(manager.shutdown().await.all_clean()); +} + +/// A wallet is loaded inside its own `BEGIN IMMEDIATE` transaction, so a +/// writer on another connection cannot change the rows mid-load. +/// +/// Timing is deterministic, not slept for: SQLite calls the authorizer while +/// it compiles a statement, so the write is attempted from inside the load's +/// own `core_utxos` read. The busy rejection is the proof that the load +/// transaction is open at that instant — in WAL mode a second connection is +/// refused the write lock only while another connection holds it, and the +/// persister holds it nowhere else during `load()`. +#[test] +fn should_reject_a_concurrent_writer_while_a_wallet_load_transaction_is_open() { + use rusqlite::hooks::{AuthAction, Authorization}; + use std::sync::Mutex; + + const CONCURRENT_WRITE: &str = "UPDATE core_sync_state SET synced_height = 200"; + + let (persister, _tmp, path) = common::fresh_persister(); + let wallet = + Wallet::new_random(Network::Testnet, WalletAccountCreationOptions::Default).unwrap(); + let info = ManagedWalletInfo::from_wallet(&wallet, 1); + let mut cs = registration(&wallet, &info); + cs.core = Some(CoreChangeSet { + synced_height: Some(100), + ..Default::default() + }); + persister.store(wallet.wallet_id, cs).unwrap(); + let synced_height = |persister: &SqlitePersister| { + persister.load().unwrap().wallets[&wallet.wallet_id] + .wallet_info + .metadata + .synced_height + }; + + let writer = rusqlite::Connection::open(&path).unwrap(); + // Fail at once instead of waiting out the default busy handler. + writer.busy_timeout(std::time::Duration::ZERO).unwrap(); + // The authorizer swallows panics, so the outcome is asserted outside it. + let attempt: Arc>>> = Arc::new(Mutex::new(None)); + let attempt_callback = Arc::clone(&attempt); + persister + .lock_conn_for_test() + .authorizer(Some(move |ctx: rusqlite::hooks::AuthContext<'_>| { + if matches!( + ctx.action, + AuthAction::Read { + table_name: "core_utxos", + .. + } + ) { + let mut attempt = attempt_callback.lock().unwrap(); + if attempt.is_none() { + *attempt = Some(writer.execute(CONCURRENT_WRITE, [])); + } + } + Authorization::Allow + })) + .unwrap(); + + assert_eq!( + synced_height(&persister), + 100, + "one wallet load must see one snapshot" + ); + { + // Scoped: the authorizer takes this lock again on the next load. + let attempt = attempt.lock().unwrap(); + let attempt = attempt + .as_ref() + .expect("the load must read core_utxos, which triggers the write"); + assert!( + matches!( + attempt, + Err(rusqlite::Error::SqliteFailure(error, _)) + if error.code == rusqlite::ErrorCode::DatabaseBusy + ), + "a write during a wallet's load transaction must be refused as busy, got {attempt:?}" + ); + } + + // Control: the same statement lands once the load has released its lock, + // and the next load reads it — so the refusal above was the load's lock, + // not an inert statement. + let late_writer = rusqlite::Connection::open(&path).unwrap(); + assert_eq!(late_writer.execute(CONCURRENT_WRITE, []).unwrap(), 1); + assert_eq!(synced_height(&persister), 200); +} + +/// A failure while rebuilding a wallet reaches the caller as the storage +/// error that caused it: Strict returns it typed, and Recovery names it as +/// the dropped wallet's cause rather than an anonymous `backend`. +/// A store whose only wallet has platform-node pool rows carrying a +/// well-formed BLS key, on the account that only ever holds EdDSA keys. +fn store_with_wrong_curve_platform_node_keys() -> (tempfile::TempDir, std::path::PathBuf, [u8; 32]) +{ + let (persister, tmp, path) = common::fresh_persister(); + let wallet = + Wallet::new_random(Network::Testnet, WalletAccountCreationOptions::Default).unwrap(); + let mut info = ManagedWalletInfo::from_wallet(&wallet, 1); + let platform_keys = + platform_wallet::wallet::provider_key_at_index::derive_platform_node_public_keys( + &wallet, + Network::Testnet, + 3, + ) + .unwrap(); + platform_wallet::wallet::provider_key_at_index::populate_platform_node_pool( + &mut info, + &platform_keys, + Network::Testnet, + ) + .unwrap(); + persister + .store(wallet.wallet_id, registration(&wallet, &info)) + .unwrap(); + let rewritten = persister + .lock_conn_for_test() + .execute( + "UPDATE core_address_pool SET key_type = 2, public_key = zeroblob(48) \ + WHERE account_type = 'provider_platform'", + [], + ) + .unwrap(); + assert_eq!(rewritten, platform_keys.len()); + (tmp, path, wallet.wallet_id) +} + +/// A typed cause rarely names its wallet, and under Strict the load returns +/// that cause as it is: the log is where the wallet is named. +#[test] +#[tracing_test::traced_test] +fn should_log_which_wallet_a_strict_load_could_not_rebuild() { + let (_tmp, path, wallet_id) = store_with_wrong_curve_platform_node_keys(); + let wallet = hex::encode(wallet_id); + assert!(!logs_contain(&wallet), "the fixture must not name it first"); + + let error = SqlitePersister::open(SqlitePersisterConfig::new(&path)) + .unwrap() + .load() + .expect_err("a wrong-curve key must fail"); + assert!( + !error.to_string().contains(&wallet), + "the error itself does not name the wallet: {error}" + ); + for field in [wallet.as_str(), "error_kind=\"blob_decode\""] { + assert!(logs_contain(field), "missing from the log: {field}"); + } +} + +#[test] +fn should_attribute_a_platform_node_pool_failure_to_its_typed_cause() { + use platform_wallet::changeset::PersistenceError; + use platform_wallet_storage::{LoadPolicy, WalletStorageError}; + + let (_tmp, path, wallet_id) = store_with_wrong_curve_platform_node_keys(); + + let strict = SqlitePersister::open(SqlitePersisterConfig::new(&path)).unwrap(); + let error = strict.load().expect_err("a wrong-curve key must fail"); + let PersistenceError::Backend { source, .. } = error else { + panic!("expected a backend error, got {error:?}") + }; + assert!( + matches!( + source.downcast_ref::(), + Some(WalletStorageError::BlobDecode { reason }) + if *reason == "provider platform pool row does not carry an EdDSA public key" + ), + "the platform-node pool reader's own error must survive: {source:?}" + ); + drop(strict); + + let recovery = SqlitePersister::open( + SqlitePersisterConfig::new(&path).with_load_policy(LoadPolicy::Recovery), + ) + .unwrap(); + let state = recovery.load().expect("recovery isolates the wallet"); + assert!(!state.wallets.contains_key(&wallet_id)); + assert_eq!( + recovery + .last_load_degradation() + .wallets_degraded + .get(&wallet_id) + .copied(), + Some("blob_decode") + ); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn should_reject_malformed_pool_keys_with_typed_source_before_manager_hydration() { + use platform_wallet::changeset::PersistenceError; + use platform_wallet_storage::WalletStorageError; + + let (persister, _tmp, path) = common::fresh_persister(); + let wallet = + Wallet::new_random(Network::Testnet, WalletAccountCreationOptions::Default).unwrap(); + let info = ManagedWalletInfo::from_wallet(&wallet, 1); + persister + .store(wallet.wallet_id, registration(&wallet, &info)) + .unwrap(); + persister.lock_conn_for_test().execute( + "UPDATE core_address_pool SET public_key = X'00' WHERE account_type = 'identity_invitation'", + [], + ).unwrap(); + drop(persister); + let persister = SqlitePersister::open(SqlitePersisterConfig::new(path)).unwrap(); + let error = persister + .load() + .expect_err("invalid key must fail the full snapshot"); + let PersistenceError::Backend { source, .. } = error else { + panic!("typed backend error") + }; + assert!(source.downcast_ref::().is_some()); + let manager = PlatformWalletManager::new( + Arc::new( + dash_sdk::SdkBuilder::new_mock() + .with_network(Network::Testnet) + .build() + .unwrap(), + ), + Arc::new(persister), + Arc::new(NoopHandler), + ); + assert!(manager.load_from_persistor().await.is_err()); + assert!(manager + .wallet_manager_arc() + .read() + .await + .get_wallet_info(&wallet.wallet_id) + .is_none()); + assert!(manager.shutdown().await.all_clean()); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn should_restore_provider_key_transaction_history_and_positions() { + use dashcore::blockdata::transaction::special_transaction::{ + provider_registration::{ProviderMasternodeType, ProviderRegistrationPayload}, + provider_update_registrar::ProviderUpdateRegistrarPayload, + TransactionPayload, + }; + use dashcore::sml::masternode_list_entry::MasternodeNetInfo; + use key_wallet::managed_account::address_pool::PublicKeyType; + use key_wallet::managed_account::managed_account_trait::ManagedAccountTrait; + + let mut wallet = + Wallet::new_random(Network::Testnet, WalletAccountCreationOptions::Default).unwrap(); + let mut info = ManagedWalletInfo::from_wallet(&wallet, 1); + for role in [ + AccountType::ProviderOwnerKeys, + AccountType::ProviderVotingKeys, + ] { + let source = KeySource::Public(wallet.accounts.account_of_type(role).unwrap().account_xpub); + let mut accounts = info.accounts.all_accounts_mut(); + let account = accounts + .iter_mut() + .find(|account| account.managed_account_type().to_account_type() == role) + .unwrap(); + for pool in account.managed_account_type_mut().address_pools_mut() { + pool.generate_addresses(45, &source, true).unwrap(); + } + } + let hash_at = |role| { + let accounts = info.accounts.all_accounts(); + let account = accounts + .iter() + .find(|account| account.managed_account_type().to_account_type() == role) + .unwrap(); + let address = &account.managed_account_type().address_pools()[0].addresses[&40].address; + let dashcore::address::Payload::PubkeyHash(hash) = address.payload() else { + panic!("P2PKH") + }; + *hash + }; + let owner = hash_at(AccountType::ProviderOwnerKeys); + let voting = hash_at(AccountType::ProviderVotingKeys); + let operator_pool = info + .accounts + .provider_operator_keys + .as_ref() + .unwrap() + .managed_account_type() + .address_pools()[0]; + let Some(PublicKeyType::BLS(operator)) = &operator_pool.addresses[&0].public_key else { + panic!("BLS key") + }; + let operator = dashcore::bls_sig_utils::BLSPublicKey::from( + <[u8; 48]>::try_from(operator.as_slice()).unwrap(), + ); + let special = |payload| Transaction { + version: 3, + lock_time: 0, + input: vec![], + output: vec![], + special_transaction_payload: Some(payload), + }; + let registration_tx = special(TransactionPayload::ProviderRegistrationPayloadType( + ProviderRegistrationPayload { + version: 2, + masternode_type: ProviderMasternodeType::Regular, + masternode_mode: 0, + collateral_outpoint: OutPoint::null(), + service_address: MasternodeNetInfo::Legacy("127.0.0.1:19999".parse().unwrap()), + owner_key_hash: owner, + operator_public_key: operator, + voting_key_hash: voting, + operator_reward: 0, + script_payout: dashcore::ScriptBuf::new(), + inputs_hash: [0; 32].into(), + signature: vec![], + platform_node_id: None, + platform_p2p_port: None, + platform_http_port: None, + // Version 2 predates DIP-0026 payouts and collateral sharing. + payouts: None, + shared_collateral: None, + }, + )); + let pro_tx_hash = registration_tx.txid(); + let registrar = special(TransactionPayload::ProviderUpdateRegistrarPayloadType( + ProviderUpdateRegistrarPayload { + version: 2, + pro_tx_hash, + provider_mode: 0, + operator_public_key: operator, + voting_key_hash: voting, + script_payout: dashcore::ScriptBuf::new(), + inputs_hash: [0; 32].into(), + payload_sig: vec![], + payouts: None, + }, + )); + let (persister, _tmp, path) = common::fresh_persister(); + persister + .store(wallet.wallet_id, registration(&wallet, &info)) + .unwrap(); + let mut expected = Vec::new(); + for (position, tx) in [registration_tx, registrar].into_iter().enumerate() { + let context = TransactionContext::InBlock(BlockInfo::new( + 100, + BlockHash::from_byte_array([7; 32]), + position as u32, + )); + let result = info + .check_core_transaction(&tx, context, &mut wallet, true, true) + .await; + assert!( + result.is_relevant, + "provider payload #{position} must match owned keys" + ); + assert!(!result.new_records.is_empty()); + expected.extend(result.new_records.clone()); + persister + .store( + wallet.wallet_id, + PlatformWalletChangeSet { + core: Some(CoreChangeSet { + records: result.new_records, + ..Default::default() + }), + ..Default::default() + }, + ) + .unwrap(); + } + drop(persister); + let manager = manager(SqlitePersister::open(SqlitePersisterConfig::new(path)).unwrap()).await; + let wm = manager.wallet_manager_arc(); + let wm = wm.read().await; + let restored = &wm.get_wallet_info(&wallet.wallet_id).unwrap().core_wallet; + for expected in expected { + let accounts = restored.accounts.all_accounts(); + let account = accounts + .iter() + .find(|account| { + account.managed_account_type().to_account_type() == expected.account_type + }) + .unwrap(); + let actual = account + .transactions() + .get(&expected.txid) + .expect("provider history"); + assert_eq!(actual.transaction, expected.transaction); + assert_eq!(actual.context, expected.context); + } + drop(wm); + assert!(manager.shutdown().await.all_clean()); +} + +fn with_pool( + info: &mut ManagedWalletInfo, + account_type: AccountType, + pool_type: key_wallet::managed_account::address_pool::AddressPoolType, + edit: impl FnOnce(&mut key_wallet::managed_account::address_pool::AddressPool) -> R, +) -> R { + let mut accounts = info.accounts.all_accounts_mut(); + let account = accounts + .iter_mut() + .find(|account| account.managed_account_type().to_account_type() == account_type) + .expect("account"); + let mut pools = account.managed_account_type_mut().address_pools_mut(); + let pool = pools + .iter_mut() + .find(|pool| pool.pool_type == pool_type) + .expect("pool"); + edit(pool) +} + +/// The public key an account's non-hardened pool derives from: an ECDSA +/// xpub, or the operator account's BLS key. Platform-node keys are hardened +/// and have none. +fn public_key_source(wallet: &Wallet, account_type: AccountType) -> Option { + if let Some(account) = wallet.accounts.account_of_type(account_type) { + return Some(KeySource::Public(account.account_xpub)); + } + wallet + .accounts + .bls_account_of_type(account_type) + .map(|account| KeySource::BLSPublic(account.bls_public_key.clone())) +} + +fn account_key_source(wallet: &Wallet, account_type: AccountType) -> KeySource { + public_key_source(wallet, account_type).expect("account keys") +} + +/// Persists a wallet shaped by `prepare` next to a healthy sibling, runs the +/// statement `prepare` returns against the first wallet's rows (`?1` is its +/// id), and asserts the load's verdict: Strict returns the typed storage +/// error, Recovery drops that wallet alone and names the cause. +fn assert_pool_corruption_is_typed_and_isolated( + prepare: impl FnOnce(&Wallet, &mut ManagedWalletInfo) -> String, + reason: &str, +) { + use platform_wallet_storage::WalletStorageError; + + assert_wallet_corruption_is_isolated( + Wallet::new_random(Network::Testnet, WalletAccountCreationOptions::Default).unwrap(), + prepare, + |error| matches!(error, WalletStorageError::BlobDecode { reason: actual } if *actual == reason), + reason, + ); +} + +/// `prepare` edits the sick wallet before it is stored and returns the SQL +/// that damages exactly one of its rows (`?1` is the wallet id). Strict must +/// then fail with an error `strict` accepts; Recovery must drop that wallet +/// alone, as `blob_decode`, and keep the healthy one beside it. +fn assert_wallet_corruption_is_isolated( + sick: Wallet, + prepare: impl FnOnce(&Wallet, &mut ManagedWalletInfo) -> String, + strict: impl Fn(&platform_wallet_storage::WalletStorageError) -> bool, + expectation: &str, +) { + use platform_wallet::changeset::PersistenceError; + use platform_wallet_storage::{LoadPolicy, WalletStorageError}; + + let (persister, _tmp, path) = common::fresh_persister(); + let mut sick_info = ManagedWalletInfo::from_wallet(&sick, 1); + let corruption = prepare(&sick, &mut sick_info); + persister + .store(sick.wallet_id, registration(&sick, &sick_info)) + .unwrap(); + let healthy = + Wallet::new_random(Network::Testnet, WalletAccountCreationOptions::Default).unwrap(); + persister + .store( + healthy.wallet_id, + registration(&healthy, &ManagedWalletInfo::from_wallet(&healthy, 1)), + ) + .unwrap(); + let rewritten = persister + .lock_conn_for_test() + .execute(&corruption, [sick.wallet_id.as_slice()]) + .unwrap(); + assert_eq!( + rewritten, 1, + "{expectation}: the fixture must rewrite exactly one row" + ); + drop(persister); + + let opened = SqlitePersister::open(SqlitePersisterConfig::new(&path)).unwrap(); + let error = match opened.load() { + Ok(_) => panic!("Strict must refuse the row: {expectation}"), + Err(error) => error, + }; + let PersistenceError::Backend { source, .. } = error else { + panic!("expected a backend error, got {error:?}") + }; + assert!( + source + .downcast_ref::() + .is_some_and(&strict), + "expected `{expectation}`, got {source:?}" + ); + drop(opened); + + let recovery = SqlitePersister::open( + SqlitePersisterConfig::new(&path).with_load_policy(LoadPolicy::Recovery), + ) + .unwrap(); + let state = recovery.load().expect("recovery isolates the wallet"); + assert!(state.wallets.contains_key(&healthy.wallet_id)); + assert!( + !state.wallets.contains_key(&sick.wallet_id), + "Recovery must not return the wallet: {expectation}" + ); + let degradation = recovery.last_load_degradation(); + assert_eq!(degradation.wallets_degraded.len(), 1); + assert_eq!( + degradation.wallets_degraded.get(&sick.wallet_id).copied(), + Some("blob_decode"), + "{expectation}" + ); +} + +/// Past the freshly derived window there is no derived entry to compare a +/// persisted row with, yet the row's address is what the wallet would hand +/// out. It has to match the account's own derivation there too. +#[test] +fn should_reject_a_pool_row_beyond_the_derived_window_that_its_account_cannot_derive() { + use key_wallet::managed_account::address_pool::AddressPoolType; + + assert_pool_corruption_is_typed_and_isolated( + |wallet, info| { + let account_type = AccountType::IdentityInvitation; + let source = account_key_source(wallet, account_type); + let deep = with_pool(info, account_type, AddressPoolType::Absent, |pool| { + pool.generate_addresses(40, &source, true).unwrap(); + pool.highest_generated.unwrap() + }); + // A well-formed P2PKH script and a correctly sized key that are + // not this account's child at that index. + format!( + "UPDATE core_address_pool \ + SET script = X'76a914{}88ac', public_key = X'02{}' \ + WHERE wallet_id = ?1 AND account_type = 'identity_invitation' \ + AND address_index = {deep}", + "42".repeat(20), + "11".repeat(32), + ) + }, + "core pool row conflicts with its derived address or key", + ); +} + +/// Pool snapshots are not rewritten when an address is funded, so a row can +/// say "available" while `core_utxos` holds a coin on it. Past the freshly +/// derived window nothing anchors that address until the persisted pool is +/// restored, and the reuse guard has to reach it there. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn should_not_hand_out_a_funded_address_restored_beyond_the_derived_window() { + use key_wallet::account::StandardAccountType; + use key_wallet::managed_account::address_pool::AddressPoolType; + + let account_type = AccountType::Standard { + index: 0, + standard_account_type: StandardAccountType::BIP44Account, + }; + let wallet = + Wallet::new_random(Network::Testnet, WalletAccountCreationOptions::Default).unwrap(); + let mut info = ManagedWalletInfo::from_wallet(&wallet, 1); + let source = account_key_source(&wallet, account_type); + let (funded, funded_index) = + with_pool(&mut info, account_type, AddressPoolType::External, |pool| { + // Hand out, without funding, more addresses than one gap window. + for _ in 0..pool.gap_limit + 10 { + pool.next_unused_and_reserve(&source, 12345).unwrap(); + } + let funded = pool.next_unused(&source, true).unwrap(); + let index = pool.address_index[&funded]; + assert_eq!(index, pool.gap_limit + 10); + (funded, index) + }); + let (persister, _tmp, path) = common::fresh_persister(); + persister + .store(wallet.wallet_id, registration(&wallet, &info)) + .unwrap(); + // A coin with no transaction record: history replay cannot mark it. + persister + .store( + wallet.wallet_id, + PlatformWalletChangeSet { + core: Some(CoreChangeSet { + new_utxos: vec![key_wallet::Utxo::new( + OutPoint::new(Txid::from_byte_array([9; 32]), 0), + TxOut { + value: 50_000, + script_pubkey: funded.script_pubkey(), + }, + funded.clone(), + 100, + false, + )], + synced_height: Some(100), + last_processed_height: Some(100), + ..Default::default() + }), + ..Default::default() + }, + ) + .unwrap(); + drop(persister); + + let manager = manager(SqlitePersister::open(SqlitePersisterConfig::new(path)).unwrap()).await; + let wm = manager.wallet_manager_arc(); + let mut wm = wm.write().await; + let restored = &mut wm + .get_wallet_info_mut(&wallet.wallet_id) + .unwrap() + .core_wallet; + with_pool(restored, account_type, AddressPoolType::External, |pool| { + assert_eq!( + pool.addresses[&funded_index].state, + AddressState::Used, + "a funded address must come back used" + ); + assert_ne!( + pool.next_unused(&source, true).unwrap(), + funded, + "a funded address must not be handed out again" + ); + }); + drop(wm); + assert!(manager.shutdown().await.all_clean()); +} + +/// A row filed under a pool its account does not have is read by no pool, so +/// the reservation it carries would vanish from a load that reports success. +#[test] +fn should_reject_a_pool_row_filed_under_a_pool_its_account_does_not_have() { + use key_wallet::managed_account::address_pool::AddressPoolType; + + assert_pool_corruption_is_typed_and_isolated( + |wallet, info| { + let account_type = AccountType::IdentityInvitation; + let source = account_key_source(wallet, account_type); + with_pool(info, account_type, AddressPoolType::Absent, |pool| { + pool.next_unused_and_reserve(&source, 12345).unwrap(); + }); + "UPDATE core_address_pool SET pool_type = 0 \ + WHERE wallet_id = ?1 AND account_type = 'identity_invitation' \ + AND reserved_at IS NOT NULL" + .to_string() + }, + "core pool row names a pool its account does not have", + ); +} + +/// Two contacts share an account label, index and key class; only the +/// identity pair tells their stored pools apart. They are given different +/// states at the same indices, so a row read under the wrong contact shows. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn should_restore_each_dashpay_contact_pool_under_its_own_identity_pair() { + use key_wallet::managed_account::address_pool::AddressPoolType; + + let contact = |friend: u8| AccountType::DashpayReceivingFunds { + index: 0, + user_identity_id: [0x21; 32], + friend_identity_id: [friend; 32], + }; + let mut wallet = + Wallet::new_random(Network::Testnet, WalletAccountCreationOptions::Default).unwrap(); + for friend in [0x31, 0x32] { + wallet.add_account(contact(friend), None).unwrap(); + } + let mut info = ManagedWalletInfo::from_wallet(&wallet, 1); + let mut expected = Vec::new(); + for (friend, funded, reserved_at) in [(0x31, true, 111), (0x32, false, 222)] { + let account_type = contact(friend); + let source = account_key_source(&wallet, account_type); + let addresses = with_pool(&mut info, account_type, AddressPoolType::Absent, |pool| { + if funded { + let first = pool.next_unused(&source, true).unwrap(); + assert!(pool.mark_used(&first)); + } + pool.next_unused_and_reserve(&source, reserved_at).unwrap(); + pool.addresses.clone() + }); + expected.push((account_type, addresses)); + } + let state_at = |contact: usize, index: u32| expected[contact].1[&index].state; + assert_eq!(state_at(0, 0), AddressState::Used); + assert_eq!(state_at(0, 1), AddressState::Reserved { at: 111 }); + assert_eq!(state_at(1, 0), AddressState::Reserved { at: 222 }); + assert_eq!(state_at(1, 1), AddressState::Available); + assert_ne!(expected[0].1[&0].address, expected[1].1[&0].address); + + let (persister, _tmp, path) = common::fresh_persister(); + persister + .store(wallet.wallet_id, registration(&wallet, &info)) + .unwrap(); + drop(persister); + let manager = manager(SqlitePersister::open(SqlitePersisterConfig::new(path)).unwrap()).await; + let wm = manager.wallet_manager_arc(); + let mut wm = wm.write().await; + let restored = &mut wm + .get_wallet_info_mut(&wallet.wallet_id) + .unwrap() + .core_wallet; + for (account_type, addresses) in expected { + with_pool(restored, account_type, AddressPoolType::Absent, |pool| { + for (index, expected) in &addresses { + let actual = pool.addresses.get(index).expect("stored contact address"); + assert_eq!(actual.state, expected.state, "{account_type:?} #{index}"); + assert_eq!( + actual.address, expected.address, + "{account_type:?} #{index}" + ); + assert_eq!(actual.path, expected.path, "{account_type:?} #{index}"); + assert_eq!(actual.public_key, expected.public_key); + } + }); + } + drop(wm); + assert!(manager.shutdown().await.all_clean()); +} + +/// A stored key of another curve than its account's. +#[test] +fn should_reject_a_pool_row_whose_key_type_is_not_its_accounts() { + assert_pool_corruption_is_typed_and_isolated( + |_, _| { + "UPDATE core_address_pool SET key_type = 2, public_key = zeroblob(48) \ + WHERE wallet_id = ?1 AND account_type = 'identity_invitation' AND address_index = 0" + .to_string() + }, + "core pool key type does not match its account", + ); +} + +/// A stored address the account derives differently at that index. +#[test] +fn should_reject_a_pool_row_whose_address_is_not_the_derived_one() { + assert_pool_corruption_is_typed_and_isolated( + |_, _| { + format!( + "UPDATE core_address_pool SET script = X'76a914{}88ac' \ + WHERE wallet_id = ?1 AND account_type = 'identity_invitation' \ + AND address_index = 0", + "42".repeat(20), + ) + }, + "core pool row conflicts with its derived address or key", + ); +} + +/// A stored key the account derives differently at that index, under the +/// right address. +#[test] +fn should_reject_a_pool_row_whose_key_is_not_the_derived_one() { + assert_pool_corruption_is_typed_and_isolated( + |_, _| { + format!( + "UPDATE core_address_pool SET public_key = X'02{}' \ + WHERE wallet_id = ?1 AND account_type = 'identity_invitation' \ + AND address_index = 0", + "11".repeat(32), + ) + }, + "core pool row conflicts with its derived address or key", + ); +} + +/// The platform-node pool is hardened, so its rows cannot be re-derived and +/// are trusted as stored. One address filed at two indices is the +/// contradiction that can still be caught there. +#[test] +fn should_reject_a_platform_node_address_stored_at_two_indices() { + assert_pool_corruption_is_typed_and_isolated( + |wallet, info| { + let keys = + platform_wallet::wallet::provider_key_at_index::derive_platform_node_public_keys( + wallet, + Network::Testnet, + 3, + ) + .unwrap(); + platform_wallet::wallet::provider_key_at_index::populate_platform_node_pool( + info, + &keys, + Network::Testnet, + ) + .unwrap(); + "UPDATE core_address_pool SET script = ( \ + SELECT script FROM core_address_pool \ + WHERE wallet_id = ?1 AND account_type = 'provider_platform' \ + AND address_index = 0) \ + WHERE wallet_id = ?1 AND account_type = 'provider_platform' AND address_index = 1" + .to_string() + }, + "core pool address has multiple derivation indices", + ); +} + +/// A pool the wallet has really grown past the discovery ceiling is stored +/// row for row by the writer, and has to load again. +#[test] +fn should_restore_a_correctly_derived_pool_deeper_than_the_discovery_ceiling() { + use key_wallet::managed_account::address_pool::AddressPoolType; + + const DEEPEST: u32 = 10_001; + let account_type = AccountType::IdentityInvitation; + let wallet = + Wallet::new_random(Network::Testnet, WalletAccountCreationOptions::Default).unwrap(); + let mut info = ManagedWalletInfo::from_wallet(&wallet, 1); + let source = account_key_source(&wallet, account_type); + let reserved = with_pool(&mut info, account_type, AddressPoolType::Absent, |pool| { + let derived = pool.highest_generated.map_or(0, |highest| highest + 1); + pool.generate_addresses(DEEPEST + 1 - derived, &source, true) + .unwrap(); + assert_eq!(pool.highest_generated, Some(DEEPEST)); + let entry = pool.addresses.get_mut(&DEEPEST).unwrap(); + entry.state = AddressState::Reserved { at: 12345 }; + entry.clone() + }); + let (persister, _tmp, path) = common::fresh_persister(); + persister + .store(wallet.wallet_id, registration(&wallet, &info)) + .unwrap(); + drop(persister); + + let mut state = SqlitePersister::open(SqlitePersisterConfig::new(path)) + .unwrap() + .load() + .expect("a pool the writer accepted must load"); + let restored = &mut state + .wallets + .get_mut(&wallet.wallet_id) + .expect("wallet") + .wallet_info; + with_pool(restored, account_type, AddressPoolType::Absent, |pool| { + let actual = &pool.addresses[&DEEPEST]; + assert_eq!(actual.state, AddressState::Reserved { at: 12345 }); + assert_eq!(actual.address, reserved.address); + assert_eq!(actual.path, reserved.path); + }); +} + +/// A reserved row whose account coordinates match no account of the wallet +/// is read by no pool. Outside the one-time funding accounts the row cannot +/// say whether it is damaged or belongs to an account the wallet no longer +/// rebuilds, so the load keeps the wallet and reports the reservation it +/// could not take back. +#[test] +fn should_report_a_reservation_that_no_restored_pool_takes_back() { + use key_wallet::managed_account::address_pool::AddressPoolType; + use platform_wallet_storage::{LoadPolicy, LoadSite}; + + let (persister, _tmp, path) = common::fresh_persister(); + let wallet = + Wallet::new_random(Network::Testnet, WalletAccountCreationOptions::Default).unwrap(); + let mut info = ManagedWalletInfo::from_wallet(&wallet, 1); + let mut reserved = Vec::new(); + for account_type in [ + AccountType::ProviderOwnerKeys, + AccountType::ProviderVotingKeys, + ] { + let source = account_key_source(&wallet, account_type); + reserved.push(with_pool( + &mut info, + account_type, + AddressPoolType::Absent, + |pool| { + let address = pool.next_unused_and_reserve(&source, 12345).unwrap(); + pool.address_index[&address] + }, + )); + } + persister + .store(wallet.wallet_id, registration(&wallet, &info)) + .unwrap(); + persister.load().unwrap(); + assert_eq!( + persister + .last_load_degradation() + .by_site + .get(&LoadSite::UnrestoredPoolReservation), + None, + "every reservation of an intact store is taken back" + ); + // A provider key account's key class is always zero. + let rewritten = persister + .lock_conn_for_test() + .execute( + "UPDATE core_address_pool SET key_class = 1 \ + WHERE wallet_id = ?1 AND account_type = 'provider_owner' \ + AND reserved_at IS NOT NULL", + [wallet.wallet_id.as_slice()], + ) + .unwrap(); + assert_eq!(rewritten, 1); + drop(persister); + + for policy in [LoadPolicy::Strict, LoadPolicy::Recovery] { + let persister = + SqlitePersister::open(SqlitePersisterConfig::new(&path).with_load_policy(policy)) + .unwrap(); + let mut state = persister + .load() + .expect("an unmatched reservation must not fail the wallet"); + assert_eq!( + persister + .last_load_degradation() + .by_site + .get(&LoadSite::UnrestoredPoolReservation), + Some(&1), + "{policy:?}: the lost reservation is counted, the restored one is not" + ); + let restored = &mut state + .wallets + .get_mut(&wallet.wallet_id) + .expect("the wallet still loads") + .wallet_info; + let state_of = |restored: &mut ManagedWalletInfo, account_type, index| { + with_pool(restored, account_type, AddressPoolType::Absent, |pool| { + pool.addresses[&index].state + }) + }; + assert_eq!( + state_of(restored, AccountType::ProviderOwnerKeys, reserved[0]), + AddressState::Available, + "{policy:?}: this is the loss the counter reports" + ); + assert_eq!( + state_of(restored, AccountType::ProviderVotingKeys, reserved[1]), + AddressState::Reserved { at: 12345 }, + ); + } +} + +/// The saved pool's own `Used` rows have to drive the gap refill the load +/// runs for used addresses: a coin past the saved window, with no transaction +/// record and no pool row, is only derived by that refill. +#[test] +fn should_refill_the_gap_window_past_a_used_address_restored_from_its_pool_row() { + use key_wallet::account::StandardAccountType; + use key_wallet::managed_account::address_pool::AddressPoolType; + + const USED: u32 = 20; + let account_type = AccountType::Standard { + index: 0, + standard_account_type: StandardAccountType::BIP44Account, + }; + let wallet = + Wallet::new_random(Network::Testnet, WalletAccountCreationOptions::Default).unwrap(); + let mut info = ManagedWalletInfo::from_wallet(&wallet, 1); + let source = account_key_source(&wallet, account_type); + let (funded, funded_index, gap) = + with_pool(&mut info, account_type, AddressPoolType::External, |pool| { + let gap = pool.gap_limit; + assert_eq!(pool.highest_generated, Some(gap - 1)); + assert!(USED < gap); + // Past the saved window and past the discovery horizon, but inside + // the gap window of the used address. + let funded_index = gap + 15; + assert!(funded_index <= USED + gap); + let mut deeper = pool.clone(); + deeper + .generate_addresses(funded_index + 1 - gap, &source, true) + .unwrap(); + let funded = deeper.addresses[&funded_index].address.clone(); + // Saved as used, through the initial window only. + assert!(pool.mark_index_used(USED)); + (funded, funded_index, gap) + }); + let (persister, _tmp, path) = common::fresh_persister(); + persister + .store(wallet.wallet_id, registration(&wallet, &info)) + .unwrap(); + // A coin with no transaction record: history replay cannot mark it. + persister + .store( + wallet.wallet_id, + PlatformWalletChangeSet { + core: Some(CoreChangeSet { + new_utxos: vec![key_wallet::Utxo::new( + OutPoint::new(Txid::from_byte_array([9; 32]), 0), + TxOut { + value: 50_000, + script_pubkey: funded.script_pubkey(), + }, + funded.clone(), + 100, + false, + )], + synced_height: Some(100), + last_processed_height: Some(100), + ..Default::default() + }), + ..Default::default() + }, + ) + .unwrap(); + drop(persister); + + let mut state = SqlitePersister::open(SqlitePersisterConfig::new(path)) + .unwrap() + .load() + .unwrap(); + let restored = &mut state + .wallets + .get_mut(&wallet.wallet_id) + .expect("wallet") + .wallet_info; + assert_eq!(restored.balance.total(), 50_000); + with_pool(restored, account_type, AddressPoolType::External, |pool| { + assert!( + pool.highest_generated >= Some(USED + gap), + "the window past the used address must be derived, got {:?}", + pool.highest_generated + ); + assert_eq!( + pool.addresses[&funded_index].state, + AddressState::Used, + "a funded address must come back used" + ); + assert_ne!( + pool.next_unused(&source, true).unwrap(), + funded, + "a funded address must not be handed out again" + ); + }); +} + +/// Startup repair rebuilds the wallet a second time. What the load reports +/// is what the wallet it returns is missing, not that twice — for the pool +/// sites and for a site of the used-address projection alike. +#[tokio::test] +async fn should_count_an_unrestored_reservation_once_when_startup_repair_rebuilds_the_wallet() { + use key_wallet::managed_account::address_pool::AddressPoolType; + use platform_wallet_storage::LoadSite; + + let mut wallet = + Wallet::new_random(Network::Testnet, WalletAccountCreationOptions::Default).unwrap(); + let mut info = ManagedWalletInfo::from_wallet(&wallet, 0); + let owner_keys = AccountType::ProviderOwnerKeys; + let source = account_key_source(&wallet, owner_keys); + with_pool(&mut info, owner_keys, AddressPoolType::Absent, |pool| { + pool.next_unused_and_reserve(&source, 12345).unwrap(); + }); + let (persister, _tmp, path) = common::fresh_persister(); + persister + .store(wallet.wallet_id, registration(&wallet, &info)) + .unwrap(); + + // Two unconfirmed spends of one coin, and an instant lock that settles + // them: the load removes the loser and rebuilds the wallet from the result. + let xpub = wallet.accounts.standard_bip44_accounts[&0].account_xpub; + let address = info + .accounts + .standard_bip44_accounts + .get_mut(&0) + .unwrap() + .next_receive_address(Some(&xpub), true) + .unwrap(); + let funding = transaction( + OutPoint::new(Txid::from_byte_array([71; 32]), 0), + [100_000, 20_000] + .into_iter() + .map(|value| TxOut { + value, + script_pubkey: address.script_pubkey(), + }) + .collect(), + ); + let coins: Vec<_> = funding + .output + .iter() + .enumerate() + .map(|(vout, output)| { + key_wallet::Utxo::new( + OutPoint::new(funding.txid(), vout as u32), + output.clone(), + address.clone(), + 100, + false, + ) + }) + .collect(); + let mut loser = transaction( + coins[0].outpoint, + vec![TxOut { + value: 119_000, + script_pubkey: address.script_pubkey(), + }], + ); + loser.input.push(TxIn { + previous_output: coins[1].outpoint, + ..Default::default() + }); + let winner = transaction( + coins[0].outpoint, + vec![TxOut { + value: 99_000, + script_pubkey: dashcore::ScriptBuf::new(), + }], + ); + let confirmed = TransactionContext::InBlock(BlockInfo::new( + 100, + BlockHash::from_byte_array([100; 32]), + 100, + )); + let mut records = info + .check_core_transaction(&funding, confirmed, &mut wallet, true, true) + .await + .new_records; + let mut competing = info.clone(); + for (view, spend) in [(&mut info, &loser), (&mut competing, &winner)] { + records.extend( + view.check_core_transaction( + spend, + TransactionContext::Mempool, + &mut wallet, + true, + true, + ) + .await + .new_records, + ); + } + assert_eq!(records.len(), 3); + let foreign = dashcore::Address::new( + Network::Testnet, + dashcore::address::Payload::PubkeyHash(dashcore::PubkeyHash::from_byte_array([0xF0; 20])), + ); + persister + .store( + wallet.wallet_id, + PlatformWalletChangeSet { + core: Some(CoreChangeSet { + records, + new_utxos: coins + .iter() + .cloned() + .chain([ + key_wallet::Utxo::new( + OutPoint::new(loser.txid(), 0), + loser.output[0].clone(), + address, + 0, + false, + ), + // A coin on an address no account derives. + key_wallet::Utxo::new( + OutPoint::new(Txid::from_byte_array([0xF1; 32]), 0), + TxOut { + value: 7_000, + script_pubkey: foreign.script_pubkey(), + }, + foreign.clone(), + 100, + false, + ), + ]) + .collect(), + spent_utxos: coins.clone(), + instant_locks_for_non_final_records: [( + winner.txid(), + dashcore::ephemerealdata::instant_lock::InstantLock { + inputs: vec![coins[0].outpoint], + txid: winner.txid(), + ..Default::default() + }, + )] + .into_iter() + .collect(), + ..Default::default() + }), + ..Default::default() + }, + ) + .unwrap(); + // A provider key account's key class is always zero. + let rewritten = persister + .lock_conn_for_test() + .execute( + "UPDATE core_address_pool SET key_class = 1 \ + WHERE account_type = 'provider_owner' AND reserved_at IS NOT NULL", + [], + ) + .unwrap(); + assert_eq!(rewritten, 1); + drop(persister); + + let persister = SqlitePersister::open(SqlitePersisterConfig::new(path)).unwrap(); + persister.load().unwrap(); + assert!( + persister + .get_core_tx_record(wallet.wallet_id, &loser.txid()) + .unwrap() + .is_none(), + "the fixture must have gone through startup repair" + ); + let degradation = persister.last_load_degradation(); + for site in [ + LoadSite::UnrestoredPoolReservation, + LoadSite::UnresolvedUtxoAddress, + ] { + assert_eq!( + degradation.by_site.get(&site), + Some(&1), + "{site}: one incident is one count, however often the wallet was rebuilt" + ); + } +} + +/// The accounts whose keys fund one asset lock each, with the label their +/// pool rows are stored under. The top-up account is the one with an account +/// index of its own. +fn one_time_funding_accounts() -> [(AccountType, &'static str); 6] { + [ + (AccountType::IdentityRegistration, "identity_registration"), + ( + AccountType::IdentityTopUp { + registration_index: 3, + }, + "identity_topup", + ), + ( + AccountType::IdentityTopUpNotBoundToIdentity, + "identity_topup_unbound", + ), + (AccountType::IdentityInvitation, "identity_invitation"), + ( + AccountType::AssetLockAddressTopUp, + "asset_lock_address_topup", + ), + ( + AccountType::AssetLockShieldedAddressTopUp, + "asset_lock_shielded_topup", + ), + ] +} + +/// A wallet holding `account_type`, which a default wallet lacks only for the +/// on-demand top-up account. +fn wallet_with_funding_account(account_type: AccountType) -> Wallet { + let mut wallet = + Wallet::new_random(Network::Testnet, WalletAccountCreationOptions::Default).unwrap(); + if matches!(account_type, AccountType::IdentityTopUp { .. }) { + wallet.add_account(account_type, None).unwrap(); + } + wallet +} + +/// Burn the first two one-time keys of `account_type` the way the asset-lock +/// builder does, or burn the first and only reserve the second. +fn burn_two_funding_keys(info: &mut ManagedWalletInfo, account_type: AccountType, reserve: bool) { + use key_wallet::managed_account::address_pool::AddressPoolType; + + with_pool(info, account_type, AddressPoolType::Absent, |pool| { + assert!(pool.mark_index_used(0)); + if reserve { + pool.addresses.get_mut(&1).unwrap().state = AddressState::Reserved { at: 12345 }; + } else { + assert!(pool.mark_index_used(1)); + } + }); +} + +/// The index the asset-lock builder would fund its next credit output from. +fn next_funding_index(info: &mut ManagedWalletInfo, account_type: AccountType) -> u32 { + let mut accounts = info.accounts.all_accounts_mut(); + let account = accounts + .iter_mut() + .find(|account| account.managed_account_type().to_account_type() == account_type) + .expect("funding account"); + let pool = account + .managed_account_type_mut() + .address_pools_mut() + .into_iter() + .next() + .expect("funding pool"); + pool.next_unused_with_info(&KeySource::NoKeySource, false) + .expect("an unused funding key") + .index +} + +/// Damage the row of a burned (or only reserved) one-time funding key with +/// `damage`, for every such account, and expect the wallet not to load. +fn assert_damaged_one_time_funding_row_fails_the_wallet( + damage: &str, + reserve: bool, + only: impl Fn(&AccountType) -> bool, + strict: impl Fn(&platform_wallet_storage::WalletStorageError) -> bool + Copy, +) { + for (account_type, label) in one_time_funding_accounts() { + if !only(&account_type) { + continue; + } + assert_wallet_corruption_is_isolated( + wallet_with_funding_account(account_type), + |_, info| { + burn_two_funding_keys(info, account_type, reserve); + format!( + "UPDATE core_address_pool SET {damage} \ + WHERE wallet_id = ?1 AND account_type = '{label}' AND address_index = 1" + ) + }, + strict, + &format!("{label}: {damage} (reserved: {reserve})"), + ); + } +} + +fn is_blob_decode( + expected: &'static str, +) -> impl Fn(&platform_wallet_storage::WalletStorageError) -> bool + Copy { + move |error| { + matches!( + error, + platform_wallet_storage::WalletStorageError::BlobDecode { reason } if *reason == expected + ) + } +} + +/// A one-time funding key is handed to someone else: an invitation exports +/// it. Its pool row is the only record that it is gone, so a wallet that +/// cannot take such a row back must not load, or the same key funds the next +/// asset lock too. Here the row's account coordinates match no account. +#[test] +fn should_refuse_a_wallet_whose_one_time_funding_key_row_matches_no_account() { + let unmatched = is_blob_decode("one-time funding key row matches no restored account"); + for reserve in [false, true] { + assert_damaged_one_time_funding_row_fails_the_wallet( + "key_class = 1", + reserve, + |_| true, + unmatched, + ); + } + // The top-up account is the one whose account index tells accounts apart. + assert_damaged_one_time_funding_row_fails_the_wallet( + "account_index = account_index + 1", + false, + |account| matches!(account, AccountType::IdentityTopUp { .. }), + unmatched, + ); +} + +/// The row sits under the right account, but further above the rest of its +/// pool than the load derives, so nothing can check it. +#[test] +fn should_refuse_a_wallet_whose_one_time_funding_key_row_lies_beyond_the_derivation_budget() { + assert_damaged_one_time_funding_row_fails_the_wallet( + "address_index = 2147483647", + false, + |_| true, + is_blob_decode("one-time funding key row could not be restored"), + ); +} + +/// The row's script is not an address. On a used row that already fails +/// Strict in the used-address reader; Recovery tolerates it there, and must +/// still not return the wallet. +#[test] +fn should_refuse_a_wallet_whose_one_time_funding_key_row_has_an_unreadable_script() { + assert_damaged_one_time_funding_row_fails_the_wallet( + "script = X'6a'", + false, + |_| true, + |error| { + matches!( + error, + platform_wallet_storage::WalletStorageError::AddressDecode { .. } + ) + }, + ); + // A reserved row is not read by the used-address reader at all. + assert_damaged_one_time_funding_row_fails_the_wallet( + "script = X'6a'", + true, + |_| true, + is_blob_decode("one-time funding key row could not be restored"), + ); +} + +/// The control for the refusals above: burned and reserved one-time keys of +/// an intact store come back as they were, and the next key is a new one. +#[test] +fn should_load_intact_one_time_funding_key_rows_and_hand_out_the_next_key() { + use key_wallet::managed_account::address_pool::AddressPoolType; + use platform_wallet_storage::LoadPolicy; + + for (account_type, label) in one_time_funding_accounts() { + for reserve in [false, true] { + let (persister, _tmp, path) = common::fresh_persister(); + let wallet = wallet_with_funding_account(account_type); + let mut info = ManagedWalletInfo::from_wallet(&wallet, 1); + burn_two_funding_keys(&mut info, account_type, reserve); + persister + .store(wallet.wallet_id, registration(&wallet, &info)) + .unwrap(); + drop(persister); + + for policy in [LoadPolicy::Strict, LoadPolicy::Recovery] { + let mut state = SqlitePersister::open( + SqlitePersisterConfig::new(&path).with_load_policy(policy), + ) + .unwrap() + .load() + .unwrap_or_else(|error| panic!("{label}, {policy:?}: {error}")); + let restored = &mut state + .wallets + .get_mut(&wallet.wallet_id) + .unwrap_or_else(|| panic!("{label}, {policy:?}: wallet dropped")) + .wallet_info; + let second = if reserve { + AddressState::Reserved { at: 12345 } + } else { + AddressState::Used + }; + with_pool(restored, account_type, AddressPoolType::Absent, |pool| { + assert_eq!(pool.addresses[&0].state, AddressState::Used, "{label}"); + assert_eq!(pool.addresses[&1].state, second, "{label}"); + }); + assert_eq!(next_funding_index(restored, account_type), 2, "{label}"); + } + } + } +} + +/// A burned one-time key is restored on its own pool, so an intact store +/// loads clean: its used address is not also routed to a funds account it +/// does not belong to, where it would be counted as an orphaned owner and an +/// address that account's key cannot derive. +#[test] +fn should_load_burned_one_time_funding_keys_without_reporting_degradation() { + use platform_wallet_storage::LoadPolicy; + + for (account_type, label) in one_time_funding_accounts() { + let (persister, _tmp, path) = common::fresh_persister(); + let wallet = wallet_with_funding_account(account_type); + let mut info = ManagedWalletInfo::from_wallet(&wallet, 1); + burn_two_funding_keys(&mut info, account_type, false); + persister + .store(wallet.wallet_id, registration(&wallet, &info)) + .unwrap(); + drop(persister); + + for policy in [LoadPolicy::Strict, LoadPolicy::Recovery] { + let persister = + SqlitePersister::open(SqlitePersisterConfig::new(&path).with_load_policy(policy)) + .unwrap(); + persister + .load() + .unwrap_or_else(|error| panic!("{label}, {policy:?}: {error}")); + let degradation = persister.last_load_degradation(); + assert!( + !degradation.degraded, + "{label}, {policy:?}: an intact store reported {:?}", + degradation.by_site + ); + } + } +} + +/// The limit of that guarantee. The row is the only record that a key was +/// burned: once the row itself is gone nothing in the store says so, the +/// wallet loads, and the key is handed out again. +#[test] +fn should_hand_out_again_a_one_time_funding_key_whose_row_was_deleted() { + let account_type = AccountType::IdentityInvitation; + let (persister, _tmp, path) = common::fresh_persister(); + let wallet = wallet_with_funding_account(account_type); + let mut info = ManagedWalletInfo::from_wallet(&wallet, 1); + burn_two_funding_keys(&mut info, account_type, false); + persister + .store(wallet.wallet_id, registration(&wallet, &info)) + .unwrap(); + let deleted = persister + .lock_conn_for_test() + .execute( + "DELETE FROM core_address_pool \ + WHERE account_type = 'identity_invitation' AND address_index = 1", + [], + ) + .unwrap(); + assert_eq!(deleted, 1); + drop(persister); + + let persister = SqlitePersister::open(SqlitePersisterConfig::new(path)).unwrap(); + let mut state = persister + .load() + .expect("nothing left in the store contradicts the wallet"); + let restored = &mut state + .wallets + .get_mut(&wallet.wallet_id) + .unwrap() + .wallet_info; + assert_eq!(next_funding_index(restored, account_type), 1); +} + +/// `v4.2` registered both Standard variants under the label `standard`. That +/// label lives in the account registration; the pool rows of such a store +/// carry the split label, so the account gets its pools back. +#[test] +fn should_restore_pools_of_a_standard_account_registered_under_the_legacy_label() { + use key_wallet::account::StandardAccountType; + use key_wallet::managed_account::address_pool::AddressPoolType; + + let account_type = AccountType::Standard { + index: 0, + standard_account_type: StandardAccountType::BIP44Account, + }; + let (persister, _tmp, path) = common::fresh_persister(); + let wallet = + Wallet::new_random(Network::Testnet, WalletAccountCreationOptions::Default).unwrap(); + let mut info = ManagedWalletInfo::from_wallet(&wallet, 1); + let source = account_key_source(&wallet, account_type); + let reserved = with_pool(&mut info, account_type, AddressPoolType::External, |pool| { + pool.next_unused_and_reserve(&source, 12345).unwrap() + }); + persister + .store(wallet.wallet_id, registration(&wallet, &info)) + .unwrap(); + { + let conn = persister.lock_conn_for_test(); + let relabelled = conn + .execute( + "UPDATE account_registrations SET account_type = 'standard' \ + WHERE account_type = 'standard_bip44'", + [], + ) + .unwrap(); + assert_eq!(relabelled, 1); + let legacy_pool_rows: i64 = conn + .query_row( + "SELECT COUNT(*) FROM core_address_pool WHERE account_type = 'standard'", + [], + |row| row.get(0), + ) + .unwrap(); + assert_eq!(legacy_pool_rows, 0, "no writer files a pool row under it"); + } + drop(persister); + + let persister = SqlitePersister::open(SqlitePersisterConfig::new(path)).unwrap(); + let mut state = persister.load().expect("a v4.2-labelled account loads"); + assert!(!persister.last_load_degradation().degraded); + let restored = &mut state + .wallets + .get_mut(&wallet.wallet_id) + .unwrap() + .wallet_info; + with_pool(restored, account_type, AddressPoolType::External, |pool| { + assert_eq!( + pool.addresses[&pool.address_index[&reserved]].state, + AddressState::Reserved { at: 12345 } + ); + }); +} diff --git a/packages/rs-platform-wallet/src/changeset/traits.rs b/packages/rs-platform-wallet/src/changeset/traits.rs index 11e5d2c58a7..84b5651151a 100644 --- a/packages/rs-platform-wallet/src/changeset/traits.rs +++ b/packages/rs-platform-wallet/src/changeset/traits.rs @@ -539,11 +539,10 @@ pub trait PlatformWalletPersistence: Send + Sync { /// /// `PlatformWalletInfo::dpns_name_states` — the map the sync pass /// diffs against — is **session-scoped**: the load path initializes - /// it EMPTY on every process start and nothing rehydrates it (the - /// SQLite backend deliberately does not attest - /// [`PersistenceCapabilities::WALLET_RESTORE`](crate::changeset::PersistenceCapabilities), - /// and `ClientStartState::wallets` is still unimplemented). The - /// durable copy is the host mirror this trait feeds — Swift + /// it EMPTY on every process start and nothing rehydrates it + /// ([`PersistenceCapabilities::WALLET_RESTORE`](crate::changeset::PersistenceCapabilities) + /// attests the Core wallet snapshot, which does not include this + /// map). The durable copy is the host mirror this trait feeds — Swift /// `PersistentDPNSName`, the Android Room `dpns_names` table. /// /// Marketplace rows are keyed by `document_id`, but a departure is diff --git a/packages/rs-platform-wallet/src/wallet/identity/network/dpns_marketplace.rs b/packages/rs-platform-wallet/src/wallet/identity/network/dpns_marketplace.rs index a448db29490..3430e5b3676 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/network/dpns_marketplace.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/network/dpns_marketplace.rs @@ -862,9 +862,9 @@ impl IdentityWallet { /// and is repopulated by the first /// [`sync_dpns_marketplace`](Self::sync_dpns_marketplace) pass; the /// wallet load path does not rehydrate it. That mirrors the - /// invitations store — `SqlitePersister` does not attest - /// `WALLET_RESTORE` and `load()` still reports - /// `ClientStartState::wallets` in `LOAD_UNIMPLEMENTED`. The durable + /// invitations store — `SqlitePersister`'s `WALLET_RESTORE` attests + /// the Core wallet snapshot only, and its `load()` rebuilds neither + /// this map nor invitations. The durable /// copy a host should render after a restart is the persister mirror /// (Swift `PersistentDPNSName`), which the changeset feeds; treat an /// empty return here as "not synced yet", never as "no names".