diff --git a/packages/rs-platform-wallet-ffi/Cargo.toml b/packages/rs-platform-wallet-ffi/Cargo.toml index 9b8f9d279b1..1dca53e3ca4 100644 --- a/packages/rs-platform-wallet-ffi/Cargo.toml +++ b/packages/rs-platform-wallet-ffi/Cargo.toml @@ -10,7 +10,11 @@ description = "C FFI bindings for platform-wallet" crate-type = ["staticlib", "cdylib", "rlib"] [dependencies] -platform-wallet = { path = "../rs-platform-wallet" } +# `bls`/`eddsa` are required explicitly, not just inherited from +# `platform-wallet`'s default features: `persistence.rs` calls +# `rebuild_provider_key_account` and matches on `ProviderKeyExtendedPubKey`, +# both gated behind those features in `platform-wallet`. +platform-wallet = { path = "../rs-platform-wallet", features = ["bls", "eddsa"] } dpp = { path = "../rs-dpp" } dash-sdk = { path = "../rs-sdk", features = ["wallet"] } # Needed for `SignerHandle` + `VTableSigner` so the `*_with_signer` diff --git a/packages/rs-platform-wallet-ffi/src/persistence.rs b/packages/rs-platform-wallet-ffi/src/persistence.rs index 06096c1aa96..c3771611cbc 100644 --- a/packages/rs-platform-wallet-ffi/src/persistence.rs +++ b/packages/rs-platform-wallet-ffi/src/persistence.rs @@ -11,7 +11,7 @@ use bincode::config; use key_wallet::account::account_collection::AccountCollection; -use key_wallet::account::{Account, AccountType, BLSAccount, EdDSAAccount, StandardAccountType}; +use key_wallet::account::{Account, AccountType, StandardAccountType}; use key_wallet::bip32::DerivationPath; use key_wallet::bip32::ExtendedPubKey; use key_wallet::derivation_bls_bip32::ExtendedBLSPubKey; @@ -27,6 +27,9 @@ use parking_lot::Mutex; use std::str::FromStr; use crate::types::{FFINetwork, Network}; +use platform_wallet::changeset::provider_key_account::{ + rebuild_provider_key_account, ProviderAccountRebuildError, +}; use platform_wallet::changeset::{ AccountAddressPoolEntry, AccountRegistrationEntry, ClientStartState, ClientWalletStartState, ListedCoreTxid, PersistenceCapabilities, PersistenceError, PersistenceErrorKind, @@ -4928,6 +4931,23 @@ impl Drop for LoadGuard { } } +/// Map a provider-account rebuild failure to a load error naming the +/// curve-specific constructor or `AccountCollection` insert that failed. +fn provider_rebuild_error( + constructor: &str, + insert: &str, + error: ProviderAccountRebuildError, +) -> PersistenceError { + match error { + ProviderAccountRebuildError::Invalid(e) => { + PersistenceError::backend(format!("{constructor} failed: {e:?}")) + } + ProviderAccountRebuildError::Rejected(e) => { + PersistenceError::backend(format!("AccountCollection::{insert} failed: {e}")) + } + } +} + /// Reconstruct an external-signable [`Wallet`] + matching start-state /// bucket from a single `WalletRestoreEntryFFI`. The mnemonic / seed /// stays in the host's keychain; signing requests route back through @@ -4988,7 +5008,7 @@ fn build_wallet_start_state( // platform node keys) live in dedicated `Option` fields on the // collection and carry a non-secp256k1 extended public key in // the same `account_xpub_bytes` slot. Rebuild them watch-only - // via the type-specific `new` + insert methods rather than the + // via the shared `rebuild_provider_key_account` rather than the // ECDSA `Account::from_xpub` / `insert` path (which would fail // to decode the bytes and reject the provider `AccountType`). // Provider xpubs are stored raw (`bincode(xpub)`), exactly like the @@ -5014,21 +5034,14 @@ fn build_wallet_start_state( e )) })?; - let bls_account = BLSAccount::new( - Some(entry.wallet_id.to_vec()), - account_type, - bls_pubkey, + rebuild_provider_key_account( + &mut accounts, + entry.wallet_id, network, + account_type, + &ProviderKeyExtendedPubKey::Bls(bls_pubkey), ) - .map_err(|e| { - PersistenceError::backend(format!("BLSAccount::new failed: {:?}", e)) - })?; - accounts.insert_bls_account(bls_account).map_err(|e| { - PersistenceError::backend(format!( - "AccountCollection::insert_bls_account failed: {}", - e - )) - })?; + .map_err(|e| provider_rebuild_error("BLSAccount::new", "insert_bls_account", e))?; continue; } AccountType::ProviderPlatformKeys => { @@ -5039,20 +5052,15 @@ fn build_wallet_start_state( e )) })?; - let eddsa_account = EdDSAAccount::new( - Some(entry.wallet_id.to_vec()), - account_type, - ed_pubkey, + rebuild_provider_key_account( + &mut accounts, + entry.wallet_id, network, + account_type, + &ProviderKeyExtendedPubKey::EdDSA(ed_pubkey), ) .map_err(|e| { - PersistenceError::backend(format!("EdDSAAccount::new failed: {:?}", e)) - })?; - accounts.insert_eddsa_account(eddsa_account).map_err(|e| { - PersistenceError::backend(format!( - "AccountCollection::insert_eddsa_account failed: {}", - e - )) + provider_rebuild_error("EdDSAAccount::new", "insert_eddsa_account", e) })?; // The platform-node (Ed25519) pool is rehydrated from the // persisted core-address rows like every other pool — see @@ -8984,6 +8992,72 @@ mod tests { ); } + /// `build_wallet_start_state` rebuilds the BLS operator-key and EdDSA + /// platform-node-key accounts watch-only from their bincode-encoded specs. + #[test] + fn provider_key_accounts_survive_restore_round_trip() { + let wallet = Wallet::from_seed_bytes( + [0x42; 64], + Network::Testnet, + key_wallet::wallet::initialization::WalletAccountCreationOptions::Default, + ) + .expect("seeded wallet"); + let bls = wallet + .accounts + .bls_account_of_type(AccountType::ProviderOperatorKeys) + .expect("a Default-created wallet has a BLS provider account") + .bls_public_key + .clone(); + let eddsa = wallet + .accounts + .eddsa_account_of_type(AccountType::ProviderPlatformKeys) + .expect("a Default-created wallet has an EdDSA provider account") + .ed25519_public_key + .clone(); + let bls_bytes = bincode::encode_to_vec(&bls, config::standard()).expect("encode BLS xpub"); + let eddsa_bytes = + bincode::encode_to_vec(&eddsa, config::standard()).expect("encode EdDSA xpub"); + let specs = [ + build_account_spec_ffi(&AccountType::ProviderOperatorKeys, &bls_bytes), + build_account_spec_ffi(&AccountType::ProviderPlatformKeys, &eddsa_bytes), + ]; + let entry = WalletRestoreEntryFFI { + wallet_id: wallet.wallet_id, + accounts: specs.as_ptr(), + accounts_count: specs.len(), + ..Default::default() + }; + + let (state, _) = + build_wallet_start_state(&entry).expect("provider key accounts must restore"); + + let restored_bls = state + .wallet + .accounts + .bls_account_of_type(AccountType::ProviderOperatorKeys) + .expect("BLS provider account must be rebuilt"); + let restored_bls_bytes = + bincode::encode_to_vec(&restored_bls.bls_public_key, config::standard()) + .expect("encode restored BLS xpub"); + assert_eq!(restored_bls_bytes, bls_bytes); + assert_eq!( + restored_bls.parent_wallet_id.as_deref(), + Some(&wallet.wallet_id[..]) + ); + assert!(restored_bls.is_watch_only); + let restored_eddsa = state + .wallet + .accounts + .eddsa_account_of_type(AccountType::ProviderPlatformKeys) + .expect("EdDSA provider account must be rebuilt"); + assert_eq!(restored_eddsa.ed25519_public_key, eddsa); + assert_eq!( + restored_eddsa.parent_wallet_id.as_deref(), + Some(&wallet.wallet_id[..]) + ); + assert!(restored_eddsa.is_watch_only); + } + /// Helper: a minimum valid consensus-encodable transaction — /// version 1, one synthetic input, one zero-value output. The /// restoration helper only cares that the bytes round-trip diff --git a/packages/rs-platform-wallet-storage/Cargo.toml b/packages/rs-platform-wallet-storage/Cargo.toml index 87fc5a7977a..650676d74c6 100644 --- a/packages/rs-platform-wallet-storage/Cargo.toml +++ b/packages/rs-platform-wallet-storage/Cargo.toml @@ -30,8 +30,15 @@ hex = "0.4" # (dashpay writer). `dash-sdk` is here for the `AddressFunds` re-export # in `schema/platform_addrs.rs`. Storage declares only the features it uses # directly; `platform-wallet` adds `dash-sdk/wallet` to Cargo's unified set. +# `bls`/`eddsa` are required explicitly (not just inherited from +# `platform-wallet`'s default features) because `rebuild_provider_key_account` +# and `ProviderKeyExtendedPubKey`'s variants live behind those gates — a +# `platform-wallet` built with `default-features = false` must not silently +# break this crate's compile. platform-wallet = { path = "../rs-platform-wallet", features = [ "serde", + "bls", + "eddsa", ], optional = true } serde = { version = "1", features = ["derive"], optional = true } key-wallet = { workspace = true, optional = true } @@ -154,6 +161,10 @@ apple-native-keyring-store = { version = "=1.0.0", features = ["keychain"], opti windows-native-keyring-store = { version = "=1.0.0", optional = true } [dev-dependencies] +# `test-utils` reaches `provider_key_test_wallet`, shared with +# `platform-wallet`'s own `rebuild_provider_key_account` tests — see its use +# in `sqlite/provider_accounts.rs`'s test module. +platform-wallet = { path = "../rs-platform-wallet", features = ["test-utils"] } proptest = "1" assert_cmd = "2" static_assertions = "1" diff --git a/packages/rs-platform-wallet-storage/src/sqlite/provider_accounts.rs b/packages/rs-platform-wallet-storage/src/sqlite/provider_accounts.rs index c8419d570f3..ec6fb8bdc6e 100644 --- a/packages/rs-platform-wallet-storage/src/sqlite/provider_accounts.rs +++ b/packages/rs-platform-wallet-storage/src/sqlite/provider_accounts.rs @@ -1,54 +1,6 @@ -//! Provider account and public-key pool reconstruction for SQLite load. +//! Platform-node public-key pool reconstruction for SQLite load. use key_wallet::account::AccountType; -use platform_wallet::changeset::ProviderKeyExtendedPubKey; - -/// Why a provider key-material account could not be rebuilt into an -/// [`AccountCollection`](key_wallet::account::account_collection::AccountCollection). -#[derive(Debug, thiserror::Error)] -pub(super) enum ProviderAccountRebuildError { - /// The curve-specific account constructor rejected the key. - #[error("provider key account is invalid")] - Invalid(#[from] key_wallet::error::Error), - /// The collection refused the account — its `account_type` does not match - /// the curve (e.g. a BLS key offered as `ProviderPlatformKeys`). - #[error("account collection rejected the provider key account: {0}")] - Rejected(&'static str), -} - -/// Rebuild a watch-only provider account in its curve-specific collection slot. -pub(super) fn rebuild_provider_key_account( - accounts: &mut key_wallet::account::account_collection::AccountCollection, - wallet_id: [u8; 32], - network: key_wallet::Network, - account_type: AccountType, - extended_public_key: &ProviderKeyExtendedPubKey, -) -> Result<(), ProviderAccountRebuildError> { - match extended_public_key { - ProviderKeyExtendedPubKey::Bls(key) => { - let account = key_wallet::account::BLSAccount::new( - Some(wallet_id.to_vec()), - account_type, - key.clone(), - network, - )?; - accounts - .insert_bls_account(account) - .map_err(ProviderAccountRebuildError::Rejected) - } - ProviderKeyExtendedPubKey::EdDSA(key) => { - let account = key_wallet::account::EdDSAAccount::new( - Some(wallet_id.to_vec()), - account_type, - key.clone(), - network, - )?; - accounts - .insert_eddsa_account(account) - .map_err(ProviderAccountRebuildError::Rejected) - } - } -} /// Errors while inserting a pre-derived platform-node key into its managed pool. #[derive(Debug, thiserror::Error)] @@ -149,85 +101,12 @@ pub(super) fn insert_platform_node_pool_entry( mod tests { use super::*; use key_wallet::Network; + // Shared with `platform-wallet`'s own `rebuild_provider_key_account` tests + // via its `test-utils` feature (see this crate's `[dev-dependencies]`) — + // one fixture instead of two drifting copies. + use platform_wallet::changeset::provider_key_account::provider_key_test_wallet; use platform_wallet::wallet::provider_key_at_index::derive_platform_node_public_keys; - fn provider_key_test_wallet() -> key_wallet::wallet::Wallet { - key_wallet::wallet::Wallet::from_seed_bytes( - [0x42; 64], - Network::Testnet, - key_wallet::wallet::initialization::WalletAccountCreationOptions::Default, - ) - .expect("provider key test wallet") - } - - #[test] - fn rebuild_provider_key_account_restores_bls_and_eddsa() { - let wallet = provider_key_test_wallet(); - let bls_key = wallet - .accounts - .bls_account_of_type(AccountType::ProviderOperatorKeys) - .expect("BLS provider account") - .bls_public_key - .clone(); - let eddsa_key = wallet - .accounts - .eddsa_account_of_type(AccountType::ProviderPlatformKeys) - .expect("EdDSA provider account") - .ed25519_public_key - .clone(); - let mut accounts = key_wallet::account::account_collection::AccountCollection::new(); - let wallet_id = [0x24; 32]; - - rebuild_provider_key_account( - &mut accounts, - wallet_id, - Network::Testnet, - AccountType::ProviderOperatorKeys, - &ProviderKeyExtendedPubKey::Bls(bls_key), - ) - .expect("rebuild BLS provider account"); - rebuild_provider_key_account( - &mut accounts, - wallet_id, - Network::Testnet, - AccountType::ProviderPlatformKeys, - &ProviderKeyExtendedPubKey::EdDSA(eddsa_key), - ) - .expect("rebuild EdDSA provider account"); - - assert!(accounts - .bls_account_of_type(AccountType::ProviderOperatorKeys) - .is_some()); - assert!(accounts - .eddsa_account_of_type(AccountType::ProviderPlatformKeys) - .is_some()); - } - - #[test] - fn rebuild_provider_key_account_rejects_curve_account_type_mismatch() { - let wallet = provider_key_test_wallet(); - let bls_key = wallet - .accounts - .bls_account_of_type(AccountType::ProviderOperatorKeys) - .expect("BLS provider account") - .bls_public_key - .clone(); - let mut accounts = key_wallet::account::account_collection::AccountCollection::new(); - - let error = rebuild_provider_key_account( - &mut accounts, - [0x24; 32], - Network::Testnet, - AccountType::ProviderPlatformKeys, - &ProviderKeyExtendedPubKey::Bls(bls_key), - ) - .expect_err("BLS key must not rebuild as a platform-node account"); - - assert!(matches!(error, ProviderAccountRebuildError::Rejected(_))); - assert!(accounts - .eddsa_account_of_type(AccountType::ProviderPlatformKeys) - .is_none()); - } #[test] fn insert_used_platform_node_pool_entry_restores_used_bookkeeping() { use dashcore::hashes::Hash; diff --git a/packages/rs-platform-wallet-storage/src/sqlite/rehydrate.rs b/packages/rs-platform-wallet-storage/src/sqlite/rehydrate.rs index c0be88399ac..270e0a436d0 100644 --- a/packages/rs-platform-wallet-storage/src/sqlite/rehydrate.rs +++ b/packages/rs-platform-wallet-storage/src/sqlite/rehydrate.rs @@ -11,12 +11,12 @@ use key_wallet::wallet::managed_wallet_info::ManagedWalletInfo; use key_wallet::wallet::Wallet; use key_wallet::Network; +use platform_wallet::changeset::provider_key_account::{ + rebuild_provider_key_account, ProviderAccountRebuildError, +}; use platform_wallet::changeset::{AccountRegistrationEntry, CoreChangeSet}; -use crate::sqlite::provider_accounts::{ - insert_platform_node_pool_entry, rebuild_provider_key_account, PlatformNodePoolError, - ProviderAccountRebuildError, -}; +use crate::sqlite::provider_accounts::{insert_platform_node_pool_entry, PlatformNodePoolError}; use crate::sqlite::load_ctx::{LoadCtx, LoadSite, SiteCoords}; use crate::sqlite::schema::accounts::{self, AccountManifest}; @@ -949,6 +949,98 @@ mod tests { assert!(matches!(err, WalletStorageError::MissingAccount { .. })); } + fn provider_keys( + w: &Wallet, + ) -> ( + key_wallet::derivation_bls_bip32::ExtendedBLSPubKey, + key_wallet::derivation_slip10::ExtendedEd25519PubKey, + ) { + let bls = w + .accounts + .bls_account_of_type(AccountType::ProviderOperatorKeys) + .expect("Default-created wallet has a BLS provider account") + .bls_public_key + .clone(); + let eddsa = w + .accounts + .eddsa_account_of_type(AccountType::ProviderPlatformKeys) + .expect("Default-created wallet has an EdDSA provider account") + .ed25519_public_key + .clone(); + (bls, eddsa) + } + + #[test] + fn watch_only_rebuild_restores_provider_key_accounts() { + use platform_wallet::changeset::{ProviderKeyAccountEntry, ProviderKeyExtendedPubKey}; + + let w = Wallet::from_seed_bytes( + [3u8; 64], + Network::Testnet, + WalletAccountCreationOptions::Default, + ) + .unwrap(); + let id = w.compute_wallet_id(); + let (bls, eddsa) = provider_keys(&w); + let manifest = AccountManifest { + ecdsa: manifest_for(&w), + provider: vec![ + ProviderKeyAccountEntry { + account_type: AccountType::ProviderOperatorKeys, + extended_public_key: ProviderKeyExtendedPubKey::Bls(bls.clone()), + }, + ProviderKeyAccountEntry { + account_type: AccountType::ProviderPlatformKeys, + extended_public_key: ProviderKeyExtendedPubKey::EdDSA(eddsa.clone()), + }, + ], + }; + + let restored = build_wallet(Network::Testnet, id, &manifest).unwrap(); + + let restored_bls = restored + .accounts + .bls_account_of_type(AccountType::ProviderOperatorKeys) + .expect("BLS provider account must be rebuilt"); + assert_eq!(restored_bls.bls_public_key.to_bytes(), bls.to_bytes()); + assert_eq!(restored_bls.parent_wallet_id.as_deref(), Some(&id[..])); + assert!(restored_bls.is_watch_only); + let restored_eddsa = restored + .accounts + .eddsa_account_of_type(AccountType::ProviderPlatformKeys) + .expect("EdDSA provider account must be rebuilt"); + assert_eq!(restored_eddsa.ed25519_public_key, eddsa); + assert_eq!(restored_eddsa.parent_wallet_id.as_deref(), Some(&id[..])); + assert!(restored_eddsa.is_watch_only); + } + + #[test] + fn watch_only_rebuild_rejects_provider_curve_type_mismatch() { + use platform_wallet::changeset::{ProviderKeyAccountEntry, ProviderKeyExtendedPubKey}; + + let w = Wallet::from_seed_bytes( + [3u8; 64], + Network::Testnet, + WalletAccountCreationOptions::Default, + ) + .unwrap(); + let (bls, _) = provider_keys(&w); + let manifest = AccountManifest { + ecdsa: manifest_for(&w), + provider: vec![ProviderKeyAccountEntry { + account_type: AccountType::ProviderPlatformKeys, + extended_public_key: ProviderKeyExtendedPubKey::Bls(bls), + }], + }; + + let err = build_wallet(Network::Testnet, w.compute_wallet_id(), &manifest) + .expect_err("a BLS key must not rebuild as the platform-node account"); + assert!(matches!( + err, + WalletStorageError::ProviderKeyAccountEntryMismatch + )); + } + /// Regression: after restart-in-place the watch-only pools eagerly /// cover only `0..gap_limit`, but persisted UTXOs can sit at deeper /// derivation indices. Rehydration must extend each chain's pool to its diff --git a/packages/rs-platform-wallet/src/changeset/core_bridge.rs b/packages/rs-platform-wallet/src/changeset/core_bridge.rs index 3c7694d2e3f..35c44ecf149 100644 --- a/packages/rs-platform-wallet/src/changeset/core_bridge.rs +++ b/packages/rs-platform-wallet/src/changeset/core_bridge.rs @@ -1792,9 +1792,10 @@ fn derive_new_utxos(record: &TransactionRecord) -> Vec { /// the script and the address as independent parameters and validates /// neither. /// -/// Height and the confirmation flags describe the *previous* transaction and -/// aren't carried in `InputDetail`, so they remain defaulted on this synthetic -/// spent record (height 0, all flags false). +/// Height and the confirmation flags describe the *previous* output and +/// aren't carried in `InputDetail`, so they default (height 0, flags +/// false); `core_utxos` has no column for either, so those defaults never +/// become durable state. fn derive_spent_utxos(record: &TransactionRecord) -> Vec { record .input_details diff --git a/packages/rs-platform-wallet/src/changeset/mod.rs b/packages/rs-platform-wallet/src/changeset/mod.rs index 052b00da3a4..4125b8df568 100644 --- a/packages/rs-platform-wallet/src/changeset/mod.rs +++ b/packages/rs-platform-wallet/src/changeset/mod.rs @@ -18,6 +18,8 @@ pub mod identity_scan_state; pub mod merge; pub mod persistence_capabilities; pub mod platform_address_sync_start_state; +#[cfg(any(feature = "bls", feature = "eddsa"))] +pub mod provider_key_account; #[cfg(feature = "serde")] pub mod serde_adapters; #[cfg(feature = "shielded")] diff --git a/packages/rs-platform-wallet/src/changeset/provider_key_account.rs b/packages/rs-platform-wallet/src/changeset/provider_key_account.rs new file mode 100644 index 00000000000..44f934f868b --- /dev/null +++ b/packages/rs-platform-wallet/src/changeset/provider_key_account.rs @@ -0,0 +1,157 @@ +//! Watch-only rebuild of provider key-material accounts, shared by every +//! persistence backend's load path. + +use key_wallet::account::account_collection::AccountCollection; +use key_wallet::account::AccountType; +use key_wallet::Network; + +use crate::changeset::ProviderKeyExtendedPubKey; + +/// Why a provider key-material account could not be rebuilt into an +/// [`AccountCollection`]. +#[derive(Debug, thiserror::Error)] +pub enum ProviderAccountRebuildError { + /// The curve-specific account constructor rejected the key. + #[error("provider key account is invalid")] + Invalid(#[from] key_wallet::error::Error), + /// The collection refused the account — its `account_type` does not match + /// the curve (e.g. a BLS key offered as `ProviderPlatformKeys`). + #[error("account collection rejected the provider key account: {0}")] + Rejected(&'static str), +} + +/// Rebuild a watch-only provider account in its curve-specific collection slot. +/// +/// A BLS key becomes a `BLSAccount`, an EdDSA key an `EdDSAAccount`, both +/// parented to `wallet_id`; the account replaces whatever occupied that slot. +/// +/// # Errors +/// +/// [`ProviderAccountRebuildError::Rejected`] when `account_type` does not match +/// the key's curve (`ProviderOperatorKeys` ⇔ BLS, `ProviderPlatformKeys` ⇔ +/// EdDSA); [`ProviderAccountRebuildError::Invalid`] when the account +/// constructor rejects the key. +pub fn rebuild_provider_key_account( + accounts: &mut AccountCollection, + wallet_id: [u8; 32], + network: Network, + account_type: AccountType, + extended_public_key: &ProviderKeyExtendedPubKey, +) -> Result<(), ProviderAccountRebuildError> { + match extended_public_key { + #[cfg(feature = "bls")] + ProviderKeyExtendedPubKey::Bls(key) => { + let account = key_wallet::account::BLSAccount::new( + Some(wallet_id.to_vec()), + account_type, + key.clone(), + network, + )?; + accounts + .insert_bls_account(account) + .map_err(ProviderAccountRebuildError::Rejected) + } + #[cfg(feature = "eddsa")] + ProviderKeyExtendedPubKey::EdDSA(key) => { + let account = key_wallet::account::EdDSAAccount::new( + Some(wallet_id.to_vec()), + account_type, + key.clone(), + network, + )?; + accounts + .insert_eddsa_account(account) + .map_err(ProviderAccountRebuildError::Rejected) + } + } +} + +/// A wallet with both a BLS `ProviderOperatorKeys` account and an EdDSA +/// `ProviderPlatformKeys` account, for exercising [`rebuild_provider_key_account`]. +/// +/// Shared across crates (not just this module's own tests) so +/// `platform-wallet-storage`'s equivalent rebuild tests don't carry a second, +/// drifting copy — see `test-utils` in this crate's `Cargo.toml`. +#[cfg(any(test, feature = "test-utils"))] +pub fn provider_key_test_wallet() -> key_wallet::wallet::Wallet { + key_wallet::wallet::Wallet::from_seed_bytes( + [0x42; 64], + Network::Testnet, + key_wallet::wallet::initialization::WalletAccountCreationOptions::Default, + ) + .expect("provider key test wallet") +} + +#[cfg(all(test, feature = "bls", feature = "eddsa"))] +mod tests { + use super::*; + + #[test] + fn rebuild_provider_key_account_restores_bls_and_eddsa() { + let wallet = provider_key_test_wallet(); + let bls_key = wallet + .accounts + .bls_account_of_type(AccountType::ProviderOperatorKeys) + .expect("BLS provider account") + .bls_public_key + .clone(); + let eddsa_key = wallet + .accounts + .eddsa_account_of_type(AccountType::ProviderPlatformKeys) + .expect("EdDSA provider account") + .ed25519_public_key + .clone(); + let mut accounts = AccountCollection::new(); + let wallet_id = [0x24; 32]; + + rebuild_provider_key_account( + &mut accounts, + wallet_id, + Network::Testnet, + AccountType::ProviderOperatorKeys, + &ProviderKeyExtendedPubKey::Bls(bls_key), + ) + .expect("rebuild BLS provider account"); + rebuild_provider_key_account( + &mut accounts, + wallet_id, + Network::Testnet, + AccountType::ProviderPlatformKeys, + &ProviderKeyExtendedPubKey::EdDSA(eddsa_key), + ) + .expect("rebuild EdDSA provider account"); + + assert!(accounts + .bls_account_of_type(AccountType::ProviderOperatorKeys) + .is_some()); + assert!(accounts + .eddsa_account_of_type(AccountType::ProviderPlatformKeys) + .is_some()); + } + + #[test] + fn rebuild_provider_key_account_rejects_curve_account_type_mismatch() { + let wallet = provider_key_test_wallet(); + let bls_key = wallet + .accounts + .bls_account_of_type(AccountType::ProviderOperatorKeys) + .expect("BLS provider account") + .bls_public_key + .clone(); + let mut accounts = AccountCollection::new(); + + let error = rebuild_provider_key_account( + &mut accounts, + [0x24; 32], + Network::Testnet, + AccountType::ProviderPlatformKeys, + &ProviderKeyExtendedPubKey::Bls(bls_key), + ) + .expect_err("BLS key must not rebuild as a platform-node account"); + + assert!(matches!(error, ProviderAccountRebuildError::Rejected(_))); + assert!(accounts + .eddsa_account_of_type(AccountType::ProviderPlatformKeys) + .is_none()); + } +} diff --git a/packages/rs-platform-wallet/src/manager/wallet_lifecycle.rs b/packages/rs-platform-wallet/src/manager/wallet_lifecycle.rs index fe6da228e4d..bc211ce76cc 100644 --- a/packages/rs-platform-wallet/src/manager/wallet_lifecycle.rs +++ b/packages/rs-platform-wallet/src/manager/wallet_lifecycle.rs @@ -220,7 +220,7 @@ impl PlatformWalletManager

{ // Snapshot per-account xpubs and address-pool entries BEFORE // the wallet / managed-info are moved into insert_wallet. The // persister sees everything needed to rebuild the wallet - // watch-only (via `Wallet::new_watch_only`) plus populate + // external-signable (via `Wallet::new_external_signable`) plus populate // SwiftData's address table on next launch. let account_specs: Vec<( key_wallet::account::AccountType, diff --git a/packages/rs-platform-wallet/src/util.rs b/packages/rs-platform-wallet/src/util.rs index 32dfde95325..8cce108d660 100644 --- a/packages/rs-platform-wallet/src/util.rs +++ b/packages/rs-platform-wallet/src/util.rs @@ -11,3 +11,13 @@ pub(crate) fn now_ms() -> u64 { .map(|d| d.as_millis() as u64) .unwrap_or(0) } + +/// Current wall-clock time in seconds since the Unix epoch. +/// +/// Pre-epoch reads return `0`, which upstream never expires. +pub(crate) fn now_secs() -> u64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_secs()) + .unwrap_or(0) +} diff --git a/packages/rs-platform-wallet/src/wallet/identity/network/contact_requests.rs b/packages/rs-platform-wallet/src/wallet/identity/network/contact_requests.rs index ab659089118..8f95fed9279 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/network/contact_requests.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/network/contact_requests.rs @@ -2877,10 +2877,7 @@ impl DashPayView<'_, B> { return 0; } - let now_secs = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .map(|d| d.as_secs()) - .unwrap_or(0); + let now_secs = crate::util::now_secs(); let mut cleared: Vec = Vec::new(); // Permanent verify failures to mark so the sync sweep's enqueue gate diff --git a/packages/rs-platform-wallet/src/wallet/identity/network/contacts.rs b/packages/rs-platform-wallet/src/wallet/identity/network/contacts.rs index a19288f4128..3e87fff63fe 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/network/contacts.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/network/contacts.rs @@ -5,6 +5,7 @@ use dpp::identity::Identity; use dpp::prelude::Identifier; use key_wallet::account::AccountType; use key_wallet::managed_account::managed_account_trait::ManagedAccountTrait; +use key_wallet::wallet::managed_wallet_info::managed_account_operations::ManagedAccountOperations; use super::*; use crate::broadcaster::TransactionBroadcaster; @@ -203,9 +204,9 @@ impl DashPayView<'_, B> { is_watch_only: false, }; - // DashPay accounts are funds-bearing; use the typed - // `insert_funds_bearing_account` API exposed by the post-split - // collection rather than wrapping in `OwnedManagedCoreAccount`. + // Build the initial funds-bearing state for persistence. The live + // insertion below goes through `ManagedAccountOperations` so upstream + // also invalidates the wallet's prior filter-scan generation. let managed = key_wallet::managed_account::ManagedCoreFundsAccount::from_account(&account); // Persist the registration BEFORE the in-memory inserts: a store @@ -239,9 +240,7 @@ impl DashPayView<'_, B> { "Failed to add contact account to wallet: {e}" )) })?; - info.core_wallet - .accounts - .insert_funds_bearing_account(managed) + info.add_managed_account(wallet, account_type) .map_err(|e| { PlatformWalletError::InvalidIdentityData(format!( "Failed to register contact account: {e}" @@ -534,8 +533,9 @@ impl DashPayView<'_, B> { is_watch_only: true, }; - // DashpayExternalAccount is funds-bearing; insert via the - // typed `insert_funds` API after the upstream split. + // Build the initial funds-bearing state for persistence. The live + // insertion below goes through `ManagedAccountOperations` so upstream + // also invalidates the wallet's prior filter-scan generation. let managed = key_wallet::managed_account::ManagedCoreFundsAccount::from_account(&account); // Persist the registration BEFORE the in-memory inserts (same @@ -574,10 +574,8 @@ impl DashPayView<'_, B> { ))) })?; - // (b) Insert ManagedCoreFundsAccount for address-pool tracking. - info.core_wallet - .accounts - .insert_funds_bearing_account(managed) + // (b) Insert the managed account and invalidate prior filter coverage. + info.add_managed_account(wallet, account_type) .map_err(|e| { Transient(PlatformWalletError::InvalidIdentityData(format!( "Failed to register external contact account: {}", diff --git a/packages/rs-platform-wallet/src/wallet/identity/network/payments.rs b/packages/rs-platform-wallet/src/wallet/identity/network/payments.rs index 89f982f0db5..4d688c76416 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/network/payments.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/network/payments.rs @@ -108,12 +108,10 @@ impl DashPayView<'_, B> { return Ok(None); }; + // A zero checkpoint already requests a scan from genesis; candidates are + // still processed so they are marked as covered and do not trigger a + // redundant funding-height rewind once that scan advances. let synced_height = info.core_wallet.synced_height(); - // 0 means "scan from genesis / not yet started" — already a full - // historical scan, nothing to backfill toward. - if synced_height == 0 { - return Ok(None); - } // (owner, contact) pairs that have a receival account — we can only // watch a contact's incoming addresses once its receival account exists. @@ -1689,6 +1687,7 @@ mod tests { use key_wallet::managed_account::managed_account_trait::ManagedAccountTrait; use key_wallet::mnemonic::Mnemonic; use key_wallet::wallet::initialization::WalletAccountCreationOptions; + use key_wallet::wallet::managed_wallet_info::wallet_info_interface::WalletInfoInterface; use key_wallet::Network; use crate::changeset::{ @@ -2343,6 +2342,18 @@ mod tests { .expect("register_contact_account"); } + { + let wallet = manager.get_wallet(&wallet_id).await.expect("wallet"); + let wm = wallet.identity().wallet_manager.read().await; + assert_eq!( + wm.get_wallet_info(&wallet_id) + .expect("info") + .account_generation(), + 1, + "registering a contact account must invalidate the prior filter-scan generation" + ); + } + { let stores = persister.stores.lock().unwrap(); let registered = stores.iter().any(|(_, cs)| { @@ -2952,8 +2963,16 @@ mod tests { "all candidates marked -> no re-trigger" ); - // A newly discovered, older-funded contact re-lowers exactly once... + // Adding another account now invalidates upstream filter coverage and + // rewinds directly to the wallet birth floor. Reconcile recognizes that + // this full-history scan already covers the contact and marks it without + // a second, shallower rewind. establish_receival_contact(&manager, &persister, wallet_id, owner, c_c, 50, 50).await; + assert_eq!( + synced_height(&manager, wallet_id).await, + 0, + "new account insertion rewinds filter coverage to the wallet birth floor" + ); assert_eq!( iw_wallet .identity() @@ -2961,10 +2980,10 @@ mod tests { .reconcile_dashpay_rescan() .await .expect("rescan 3"), - Some(50), - "a new older contact re-lowers to its funding height" + None, + "the already-scheduled full-history scan needs no second rewind" ); - // ...then settles. + // The contact was marked while the checkpoint was zero, so it settles. assert_eq!( iw_wallet .identity() @@ -2978,8 +2997,8 @@ mod tests { } /// `synced_height == 0` means "scan from genesis / not started" — already a - /// full historical scan, so the rescan is a no-op (the masking path the spec - /// warns about). + /// full historical scan. Reconcile leaves the height alone but marks the + /// contact so advancing that scan does not cause a redundant rewind. #[tokio::test] async fn rescan_is_a_noop_when_synced_height_is_zero() { let (manager, persister, wallet_id) = make_wallet().await; @@ -3001,6 +3020,19 @@ mod tests { "synced_height 0 -> no rescan" ); assert_eq!(synced_height(&manager, wallet_id).await, 0); + + set_synced_height(&manager, wallet_id, 200).await; + assert_eq!( + iw_wallet + .identity() + .dashpay() + .reconcile_dashpay_rescan() + .await + .expect("rescan after forward progress"), + None, + "genesis-covered contact must stay settled after the scan advances" + ); + assert_eq!(synced_height(&manager, wallet_id).await, 200); } /// A `Sent` payment must advance `Pending → Confirmed` once its @@ -5050,6 +5082,11 @@ mod tests { let wm = iw.wallet_manager.read().await; let info = wm.get_wallet_info(&wallet_id).expect("info"); + assert_eq!( + info.account_generation(), + 1, + "registering an external account must invalidate the prior filter-scan generation" + ); use key_wallet::account::account_collection::DashpayAccountKey; let key = DashpayAccountKey { index: 0, diff --git a/packages/rs-platform-wallet/src/wallet/platform_wallet.rs b/packages/rs-platform-wallet/src/wallet/platform_wallet.rs index 5072356c5c8..68b022e93ae 100644 --- a/packages/rs-platform-wallet/src/wallet/platform_wallet.rs +++ b/packages/rs-platform-wallet/src/wallet/platform_wallet.rs @@ -2310,23 +2310,24 @@ mod shield_input_selection_tests { #[test] fn regression_reports_max_from_usable_suffix_not_total_account_balance() { - // Real account snapshot: the leading address is below the reserve, so + // Account snapshot whose leading address cannot pay the fee, so // capacity must come from the usable suffix, not the account total. - assert!( - 197_264_780 <= reserve(), - "regression shape requires the leading address to stay below the reserve; \ - re-seed the balances if the versioned reserve drops under 197_264_780" - ); + // The leading balance is derived from the reserve — one credit below + // the strict `> reserve` viability threshold, the largest balance that + // must still be rejected as input 0 — so the shape holds whatever the + // versioned fee schedule does next. + let dust = reserve() - 1; + let usable = 3_623_849_220; let candidates = vec![ - (addr(1), 197_264_780), + (addr(1), dust), (addr(2), 2_000_000_000), (addr(3), 1_623_849_220), ]; let plan = plan(candidates).unwrap(); - let expected_max = 3_623_849_220 - reserve(); + let expected_max = usable - reserve(); - assert_eq!(plan.preflight.account_balance_credits, 3_821_114_000); - assert_eq!(plan.preflight.usable_balance_credits, 3_623_849_220); + assert_eq!(plan.preflight.account_balance_credits, dust + usable); + assert_eq!(plan.preflight.usable_balance_credits, usable); assert_eq!(plan.preflight.fee_reserve_credits, reserve()); assert_eq!(plan.preflight.max_shieldable_credits, expected_max); assert!(plan.preflight.can_shield); @@ -2336,11 +2337,13 @@ mod shield_input_selection_tests { assert!(!chosen.contains_key(&addr(1))); assert_eq!(chosen.values().sum::(), expected_max); + // `available` reports the usable suffix, never the account total — + // the whole point of the regression. let err = plan.select_inputs(expected_max + 1).unwrap_err(); assert!(matches!( err, PlatformWalletError::PlatformShieldCapacityExceeded { available, required } - if available == 3_623_849_220 && required == 3_623_849_221 + if available == usable && required == usable + 1 )); } diff --git a/packages/rs-platform-wallet/src/wallet/platform_wallet_traits.rs b/packages/rs-platform-wallet/src/wallet/platform_wallet_traits.rs index 02d052359de..2034b4bfc10 100644 --- a/packages/rs-platform-wallet/src/wallet/platform_wallet_traits.rs +++ b/packages/rs-platform-wallet/src/wallet/platform_wallet_traits.rs @@ -148,6 +148,10 @@ impl WalletInfoInterface for PlatformWalletInfo { self.core_wallet.synced_height() } + fn account_generation(&self) -> u64 { + self.core_wallet.account_generation() + } + fn update_last_processed_height(&mut self, current_height: u32) { self.core_wallet .update_last_processed_height(current_height); diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/KeyWallet/WalletManager.swift b/packages/swift-sdk/Sources/SwiftDashSDK/KeyWallet/WalletManager.swift index 540cf0adb8c..9363dbb02d4 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/KeyWallet/WalletManager.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/KeyWallet/WalletManager.swift @@ -651,6 +651,9 @@ public class WalletManager { /// Import a wallet from serialized bytes /// - Parameters: /// - walletBytes: The serialized wallet data + /// - birthHeight: Block height to start scanning from. Defaults to 0 + /// (genesis), a safe full rescan; pass the wallet's known birth + /// height to skip pre-birth blocks. /// - Returns: The wallet ID of the imported wallet public func importWallet(from walletBytes: Data, birthHeight: UInt32 = 0) throws -> Data { guard !walletBytes.isEmpty else { diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManager.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManager.swift index 5b8fb30f59d..8a57410f2a3 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManager.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManager.swift @@ -1583,6 +1583,10 @@ public class PlatformWalletManager: ObservableObject { for walletId in walletIds { guard walletId.count == 32 else { continue } + // A wallet Rust declined to register (corrupt/skipped row) is + // still listed by SwiftData; `get_wallet` returns NotFound for + // it, which the do/catch below logs to `lastError` and skips — + // one bad row never fails the whole restore. var walletHandle: Handle = NULL_HANDLE do { try walletId.withUnsafeBytes { idPtr in diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift index e5e67eb4ab1..dbda3c8fefe 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift @@ -6591,7 +6591,7 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { /// A wallet is "restorable" when it has at least one /// `PersistentAccount` row with non-empty /// `accountExtendedPubKeyBytes`. The Rust side reconstructs the - /// watch-only `Wallet` via `Wallet::new_watch_only(network, + /// external-signable `Wallet` via `Wallet::new_external_signable(network, /// wallet_id, accounts)`; accounts come directly from the spec /// array, wallet id from the top-level struct. /// diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/SendTransactionView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/SendTransactionView.swift index de48fdf33bb..b5f49500180 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/SendTransactionView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/SendTransactionView.swift @@ -265,26 +265,34 @@ struct SendTransactionView: View { // be the one that was last created. let managed = walletManager.wallet(for: wallet.walletId) let platformAddressWallet = try? managed?.platformAddressWallet() - // Pick the account that will FUND a platform → - // platform transfer. The Rust Auto selector - // resolves the source via - // `platform_payment_managed_account_at_index` - // (key class 0) and selects its inputs WITHIN - // that single account — it does not span - // accounts. `canSend` only gates on the - // aggregate platform balance, so with multiple - // key-class-0 Platform Payment accounts we must - // choose an account whose OWN balance covers the - // requested amount + fee; otherwise we'd enable a - // send Rust rejects. The selection is factored - // into the pure, unit-tested - // `PlatformPaymentAccountSelection` helper. + // Resolve the account that FUNDS the send. Two + // consumers read `senderAccountIndex`, in two + // DISTINCT account namespaces: // - // Only the platform → platform path needs this - // coverage-aware pick; every other flow ignores - // `senderAccountIndex`, so the prior - // "first key-class-0 positive balance, else 0" - // behaviour is preserved for them. + // • platform → platform: a key-class-0 Platform + // Payment account. The Rust Auto selector + // resolves the source via + // `platform_payment_managed_account_at_index` + // and selects inputs WITHIN that single account + // (it does not span accounts). `canSend` gates + // only on the aggregate platform balance, so we + // must pick an account whose OWN balance covers + // amount + fee, else Rust rejects the send — + // done by the unit-tested + // `PlatformPaymentAccountSelection` helper. + // + // • core → core: a BIP44 Core account index, fed + // into `CoreTransactionBuilder.setFunding( + // accountType: .bip44, ...)`. That namespace is + // SEPARATE from key-class Platform Payment + // accounts — a Platform-Payment index must never + // leak into it. The Core send UI has no account + // picker and funds the default BIP44 account, so + // resolve to account 0. + // + // Every other flow (shielded / platform → shielded + // / core → shielded) ignores this value and + // resolves its own funding, so 0 is harmless there. let senderAccountIndex: UInt32 if viewModel.detectedFlow == .platformToPlatform { guard let resolved = resolvePlatformSenderAccountIndex() else { @@ -293,10 +301,7 @@ struct SendTransactionView: View { } senderAccountIndex = resolved } else { - senderAccountIndex = addressBalances - .filter { $0.account?.keyClass == 0 } - .first(where: { $0.balance > 0 })? - .accountIndex ?? 0 + senderAccountIndex = 0 } // Input selection and surplus handling are owned // by the Rust Auto path (surplus stays on the diff --git a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/ErrorHandlingTests.swift b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/ErrorHandlingTests.swift index f629563cb73..ba027a7e19b 100644 --- a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/ErrorHandlingTests.swift +++ b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/ErrorHandlingTests.swift @@ -650,6 +650,13 @@ final class ErrorHandlingTests: XCTestCase { // MARK: - Core broadcast outcome mapping func testCoreBroadcastOutcomeMapping() throws { + XCTAssertEqual(PlatformWalletResultCode.errorTransactionBroadcastRejected.rawValue, 26) + XCTAssertEqual( + PlatformWalletResultCode( + ffi: PLATFORM_WALLET_FFI_RESULT_CODE_ERROR_TRANSACTION_BROADCAST_REJECTED + ), + .errorTransactionBroadcastRejected + ) XCTAssertEqual( try CoreTransactionBroadcastOutcome( resultCode: .success, diff --git a/packages/swift-sdk/run_tests.sh b/packages/swift-sdk/run_tests.sh index 47ca4b095d3..19e83a461e4 100755 --- a/packages/swift-sdk/run_tests.sh +++ b/packages/swift-sdk/run_tests.sh @@ -23,7 +23,10 @@ cd "$SCRIPT_DIR" || exit 1 # touches a developer's keychain configuration; the previous default and # search list are restored on exit. if [ -n "${CI:-}${GITHUB_ACTIONS:-}" ]; then - PREV_DEFAULT_KEYCHAIN="$(security default-keychain -d user | sed -E 's/^[[:space:]]*"?//;s/"?[[:space:]]*$//')" + # `security default-keychain -d user` exits non-zero on a runner with no + # user default keychain; tolerate it (the restore below skips an empty + # value) so `set -euo pipefail` doesn't abort the run before any build. + PREV_DEFAULT_KEYCHAIN="$(security default-keychain -d user 2>/dev/null | sed -E 's/^[[:space:]]*"?//;s/"?[[:space:]]*$//' || true)" PREV_USER_KEYCHAINS_OUTPUT="$(security list-keychains -d user)" PREV_USER_KEYCHAINS=() while IFS= read -r keychain_path; do @@ -48,7 +51,10 @@ if [ -n "${CI:-}${GITHUB_ACTIONS:-}" ]; then cleanup_status=0 trap - EXIT - if [ "${CI_DEFAULT_MAY_HAVE_CHANGED:-0}" -eq 1 ]; then + # An empty PREV_DEFAULT_KEYCHAIN means the runner had no user default to + # begin with, so there is nothing to restore and `security -s ""` would + # only fail the cleanup. + if [ "${CI_DEFAULT_MAY_HAVE_CHANGED:-0}" -eq 1 ] && [ -n "${PREV_DEFAULT_KEYCHAIN:-}" ]; then if ! security default-keychain -d user -s "$PREV_DEFAULT_KEYCHAIN"; then cleanup_status=1 fi