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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 22 additions & 22 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

16 changes: 8 additions & 8 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -49,14 +49,14 @@ members = [
]

[workspace.dependencies]
dashcore = { git = "https://github.com/dashpay/rust-dashcore", rev = "eb889af13f667ed39c35e8e8a0830eeedf523476" }
dash-network-seeds = { git = "https://github.com/dashpay/rust-dashcore", rev = "eb889af13f667ed39c35e8e8a0830eeedf523476" }
dash-spv = { git = "https://github.com/dashpay/rust-dashcore", rev = "eb889af13f667ed39c35e8e8a0830eeedf523476" }
key-wallet = { git = "https://github.com/dashpay/rust-dashcore", rev = "eb889af13f667ed39c35e8e8a0830eeedf523476" }
key-wallet-ffi = { git = "https://github.com/dashpay/rust-dashcore", rev = "eb889af13f667ed39c35e8e8a0830eeedf523476" }
key-wallet-manager = { git = "https://github.com/dashpay/rust-dashcore", rev = "eb889af13f667ed39c35e8e8a0830eeedf523476" }
dash-network = { git = "https://github.com/dashpay/rust-dashcore", rev = "eb889af13f667ed39c35e8e8a0830eeedf523476" }
dashcore-rpc = { git = "https://github.com/dashpay/rust-dashcore", rev = "eb889af13f667ed39c35e8e8a0830eeedf523476" }
dashcore = { git = "https://github.com/dashpay/rust-dashcore", rev = "3d0d5dcd4ad64e2199a726651bca7f8ffac123e6" }
dash-network-seeds = { git = "https://github.com/dashpay/rust-dashcore", rev = "3d0d5dcd4ad64e2199a726651bca7f8ffac123e6" }
dash-spv = { git = "https://github.com/dashpay/rust-dashcore", rev = "3d0d5dcd4ad64e2199a726651bca7f8ffac123e6" }
key-wallet = { git = "https://github.com/dashpay/rust-dashcore", rev = "3d0d5dcd4ad64e2199a726651bca7f8ffac123e6" }
key-wallet-ffi = { git = "https://github.com/dashpay/rust-dashcore", rev = "3d0d5dcd4ad64e2199a726651bca7f8ffac123e6" }
key-wallet-manager = { git = "https://github.com/dashpay/rust-dashcore", rev = "3d0d5dcd4ad64e2199a726651bca7f8ffac123e6" }
dash-network = { git = "https://github.com/dashpay/rust-dashcore", rev = "3d0d5dcd4ad64e2199a726651bca7f8ffac123e6" }
dashcore-rpc = { git = "https://github.com/dashpay/rust-dashcore", rev = "3d0d5dcd4ad64e2199a726651bca7f8ffac123e6" }

# Optimize heavy crypto crates even in dev/test builds so that
# Halo 2 proof generation and verification run at near-release speed.
Expand Down
80 changes: 80 additions & 0 deletions packages/rs-platform-wallet/src/manager/wallet_lifecycle.rs
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,20 @@ impl<P: PlatformWalletPersistence + 'static> PlatformWalletManager<P> {
wallet: Wallet,
birth_height_override: Option<u32>,
) -> Result<Arc<PlatformWallet>, PlatformWalletError> {
// NOTE: the wallet id is NETWORK-SCOPED by construction.
// `Wallet::from_mnemonic` / `from_seed_bytes` now stamp a
// network-scoped id (key-wallet folds a domain-tagged,
// wire-stable network byte into the digest), so the same
// mnemonic yields a DISTINCT id per network. That makes every
// downstream `walletId`-keyed structure network-correct by
// construction — no per-network disambiguation needed in the
// persistence layer, and network-blind child tables (UTXOs,
// asset locks, platform addresses) can no longer cross-feed
// between a mnemonic's per-network wallets. The watch-only
// restore path (`Wallet::new_external_signable`) reuses the
// persisted id verbatim, so it stays self-consistent across
// launches.

// Birth height resolution: explicit override wins; otherwise
// fall back to SPV's confirmed header tip (default for fresh
// wallets — they only need to see funding from now on); 0 if
Expand Down Expand Up @@ -439,3 +453,69 @@ impl<P: PlatformWalletPersistence + 'static> PlatformWalletManager<P> {
Ok(removed)
}
}

#[cfg(test)]
mod scoped_wallet_id_tests {
use key_wallet::mnemonic::{Language, Mnemonic};
use key_wallet::wallet::initialization::WalletAccountCreationOptions;
use key_wallet::wallet::Wallet;
use key_wallet::Network;

// Canonical all-`abandon` BIP-39 test vector. Deterministic, so the
// ids below are reproducible across runs.
const TEST_MNEMONIC: &str = "abandon abandon abandon abandon abandon abandon \
abandon abandon abandon abandon abandon about";

fn wallet_id_for(network: Network) -> [u8; 32] {
let mnemonic =
Mnemonic::from_phrase(TEST_MNEMONIC, Language::English).expect("valid test mnemonic");
let wallet =
Wallet::from_mnemonic(mnemonic, network, WalletAccountCreationOptions::Default)
.expect("wallet construction");
// This is the id the manager keys on (insert_wallet returns it,
// the create FFI hands it to Swift) — exercises the same
// construction path `create_wallet_from_mnemonic` uses.
wallet.wallet_id
}

/// The same mnemonic must yield a DISTINCT wallet id on each network.
/// This is the property the whole per-network persistence model now
/// relies on (rust-dashcore #793: network-scoped id by default).
#[test]
fn same_mnemonic_yields_distinct_ids_per_network() {
let mainnet = wallet_id_for(Network::Mainnet);
let testnet = wallet_id_for(Network::Testnet);
let devnet = wallet_id_for(Network::Devnet);
let regtest = wallet_id_for(Network::Regtest);

let all = [mainnet, testnet, devnet, regtest];
for i in 0..all.len() {
for j in (i + 1)..all.len() {
assert_ne!(
all[i], all[j],
"wallet ids for two different networks must differ \
(index {i} vs {j}) — scoped-id regression"
);
}
}
}

/// Re-deriving the same (mnemonic, network) must be stable, otherwise
/// the watch-only restore path (which reuses the persisted id) would
/// drift across launches.
#[test]
fn same_mnemonic_same_network_is_stable() {
for network in [
Network::Mainnet,
Network::Testnet,
Network::Devnet,
Network::Regtest,
] {
assert_eq!(
wallet_id_for(network),
wallet_id_for(network),
"wallet id must be stable across re-derivation for {network:?}"
);
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -18,9 +18,14 @@ public final class PersistentWallet {
/// "is there a wallet on this chain yet" lookups) don't degrade
/// to a table scan.
#Index<PersistentWallet>([\.networkRaw])
#Unique<PersistentWallet>([\.walletId, \.networkRaw])

/// 32-byte wallet ID (SHA256 of root public key).
@Attribute(.unique) public var walletId: Data
/// 32-byte wallet ID (SHA256 of root public key). Not unique on
/// its own — the same seed yields the same `walletId` on every
/// network, so a wallet that exists on multiple chains has one
/// row per network. Uniqueness is the composite
/// `(walletId, networkRaw)` declared above.
public var walletId: Data
/// Network this wallet belongs to. `nil` means "not yet known" —
/// the row was created by a changeset before `persistWalletMetadata`
/// filled the network in. Views treat `nil` as unknown.
Expand Down Expand Up @@ -140,4 +145,14 @@ extension PersistentWallet {
public static func predicate(walletId: Data) -> Predicate<PersistentWallet> {
#Predicate<PersistentWallet> { $0.walletId == walletId }
}

public static func predicate(
walletId: Data,
network: Network
) -> Predicate<PersistentWallet> {
let networkRaw = network.rawValue
return #Predicate<PersistentWallet> {
$0.walletId == walletId && $0.networkRaw == networkRaw
}
}
Comment on lines +177 to +185
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

💬 Nitpick: predicate(walletId:network:) is over-specified now that walletId is globally unique

With the latest delta's invariant that walletId is network-scoped and globally unique on its own (the network byte is folded into the digest, and the #Unique constraint on the model is now [\.walletId]), the composite predicate $0.walletId == walletId && $0.networkRaw == networkRaw can never narrow the result set further than the walletId clause alone, but it CAN spuriously return empty for rows whose networkRaw is nil — the property's own doc-comment (lines 47–49) explicitly permits pre-persistWalletMetadata rows to have networkRaw == nil. This is a low-impact mismatch with the model rather than a correctness break, and is worth flagging only because the just-rewritten walletId doc-comment advertises that the network is already baked into walletId — keeping a sibling lookup helper that still requires a networkRaw match invites confusion at future call sites. Consider either dropping the network clause from this predicate or removing the helper in favor of predicate(walletId:).

source: ['claude']

}
Original file line number Diff line number Diff line change
Expand Up @@ -619,10 +619,24 @@ public class PlatformWalletManager: ObservableObject {

try persistenceHandler.deleteWalletData(walletId: walletId)

let storage = WalletStorage()
// Delete metadata first so the mnemonic remains available for retry.
try storage.deleteMetadata(for: walletId)
try storage.deleteMnemonic(for: walletId)
// The mnemonic + metadata blobs in the Keychain are keyed by
// `walletId`. With network-scoped wallet ids the same mnemonic
// maps to a DIFFERENT id per network, so a given id is owned by
// exactly one network's wallet and carries its own mnemonic
// copy — purging it can't orphan a sibling network (those live
// under their own distinct ids). The `walletRowCountAcrossNetworks
// == 0` check is therefore expected to be true right after
// `deleteWalletData` removes this id's lone row; it is retained
// as a defensive guard (and to stay correct should the id model
// ever change) so we never delete the phrase while any row for
// this exact id still exists.
let remaining = try persistenceHandler.walletRowCountAcrossNetworks(walletId: walletId)
if remaining == 0 {
let storage = WalletStorage()
// Delete metadata first so the mnemonic remains available for retry.
try storage.deleteMetadata(for: walletId)
try storage.deleteMnemonic(for: walletId)
}
}

// MARK: - Per-wallet lookup
Expand Down
Loading
Loading