Skip to content
24 changes: 12 additions & 12 deletions Cargo.lock

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

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

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

dashcore = { git = "https://github.com/dashpay/rust-dashcore", rev = "93260bf39bac5d9d09e89bfb45e9ea3ff7fdcbcd" }
dash-network-seeds = { git = "https://github.com/dashpay/rust-dashcore", rev = "93260bf39bac5d9d09e89bfb45e9ea3ff7fdcbcd" }
dash-spv = { git = "https://github.com/dashpay/rust-dashcore", rev = "93260bf39bac5d9d09e89bfb45e9ea3ff7fdcbcd" }
key-wallet = { git = "https://github.com/dashpay/rust-dashcore", rev = "93260bf39bac5d9d09e89bfb45e9ea3ff7fdcbcd" }
key-wallet-ffi = { git = "https://github.com/dashpay/rust-dashcore", rev = "93260bf39bac5d9d09e89bfb45e9ea3ff7fdcbcd" }
key-wallet-manager = { git = "https://github.com/dashpay/rust-dashcore", rev = "93260bf39bac5d9d09e89bfb45e9ea3ff7fdcbcd" }
dash-network = { git = "https://github.com/dashpay/rust-dashcore", rev = "93260bf39bac5d9d09e89bfb45e9ea3ff7fdcbcd" }
dashcore-rpc = { git = "https://github.com/dashpay/rust-dashcore", rev = "93260bf39bac5d9d09e89bfb45e9ea3ff7fdcbcd" }
tokio-metrics = "0.5"

# Size-tuned profile for the iOS `rs-unified-sdk-ffi` staticlib, which
Expand Down
25 changes: 6 additions & 19 deletions packages/rs-platform-wallet-ffi/src/derivation.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,32 +6,19 @@ use std::str::FromStr;

use dashcore::secp256k1::Secp256k1;
use key_wallet::bip32::{DerivationPath, ExtendedPrivKey};
use key_wallet::mnemonic::{Language, Mnemonic};
use key_wallet::mnemonic::Mnemonic;
use zeroize::Zeroizing;

use crate::error::*;
use crate::types::{FFINetwork, Network};
use crate::{check_ptr, unwrap_result_or_return};

fn parse_mnemonic_any_language(phrase: &str) -> Result<Mnemonic, &'static str> {
const LANGUAGES: [Language; 10] = [
Language::English,
Language::Spanish,
Language::French,
Language::Italian,
Language::Japanese,
Language::Korean,
Language::ChineseSimplified,
Language::ChineseTraditional,
Language::Czech,
Language::Portuguese,
];
for lang in LANGUAGES {
if let Ok(m) = Mnemonic::from_phrase(phrase, lang) {
return Ok(m);
}
}
Err("phrase does not match any supported BIP-39 wordlist")
// Upstream's `from_phrase` IS the auto-detecting parse since
// rust-dashcore#981 — one path, English diagnostics preserved when
// nothing matches. This wrapper survives only to narrow the error to
// the `&'static str` its callers report.
Mnemonic::from_phrase(phrase).map_err(|_| "phrase does not match any supported BIP-39 wordlist")
}

/// Derive a 32-byte ECDSA private key at a BIP-32 derivation path from
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ use key_wallet::bip32::{ChildNumber, DerivationPath, ExtendedPrivKey, ExtendedPu
use key_wallet::dip9::{
IDENTITY_AUTHENTICATION_PATH_MAINNET, IDENTITY_AUTHENTICATION_PATH_TESTNET,
};
use key_wallet::mnemonic::{Language, Mnemonic};
use key_wallet::mnemonic::Mnemonic;
use zeroize::Zeroizing;

use crate::error::*;
Expand Down Expand Up @@ -55,24 +55,11 @@ pub(crate) unsafe fn zeroize_and_free_row(row: &mut IdentityKeyPreviewFFI) {

/// Parse a BIP-39 mnemonic against every supported wordlist.
pub(crate) fn parse_mnemonic_any_language(phrase: &str) -> Result<Mnemonic, &'static str> {
const LANGUAGES: [Language; 10] = [
Language::English,
Language::Spanish,
Language::French,
Language::Italian,
Language::Japanese,
Language::Korean,
Language::ChineseSimplified,
Language::ChineseTraditional,
Language::Czech,
Language::Portuguese,
];
for lang in LANGUAGES {
if let Ok(m) = Mnemonic::from_phrase(phrase, lang) {
return Ok(m);
}
}
Err("phrase does not match any supported BIP-39 wordlist")
// Upstream's `from_phrase` IS the auto-detecting parse since
// rust-dashcore#981 — one path, English diagnostics preserved when
// nothing matches. This wrapper survives only to narrow the error to
// the `&'static str` its callers report.
Mnemonic::from_phrase(phrase).map_err(|_| "phrase does not match any supported BIP-39 wordlist")
}

/// Resolve a wallet's BIP-39 mnemonic via a Swift-owned
Expand Down
6 changes: 1 addition & 5 deletions packages/rs-platform-wallet-ffi/src/persistence.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7774,7 +7774,7 @@ mod tests {
use key_wallet::account::{Account, AccountType, StandardAccountType};
use key_wallet::bip32::{ExtendedPrivKey, ExtendedPubKey};
use key_wallet::managed_account::managed_account_trait::ManagedAccountTrait;
use key_wallet::mnemonic::{Language, Mnemonic};
use key_wallet::mnemonic::Mnemonic;
use key_wallet::wallet::Wallet;

/// Regression: restored pool addresses must be tagged with the
Expand Down Expand Up @@ -7923,7 +7923,6 @@ mod tests {
// `account_collection_test.rs` uses.
let mnemonic = Mnemonic::from_phrase(
"abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about",
Language::English,
)
.expect("static BIP-39 vector must parse");
let seed = mnemonic.to_seed("");
Expand Down Expand Up @@ -7957,7 +7956,6 @@ mod tests {
fn test_managed_wallet_info_with_account(account_type: AccountType) -> ManagedWalletInfo {
let mnemonic = Mnemonic::from_phrase(
"abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about",
Language::English,
)
.expect("static BIP-39 vector must parse");
let seed = mnemonic.to_seed("");
Expand Down Expand Up @@ -8073,7 +8071,6 @@ mod tests {
fn test_managed_wallet_info_with_provider_owner() -> ManagedWalletInfo {
let mnemonic = Mnemonic::from_phrase(
"abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about",
Language::English,
)
.expect("static BIP-39 vector must parse");
let seed = mnemonic.to_seed("");
Expand Down Expand Up @@ -8516,7 +8513,6 @@ mod tests {
fn account_xpub_survives_persist_restore_round_trip() {
let mnemonic = Mnemonic::from_phrase(
"abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about",
Language::English,
)
.expect("static BIP-39 vector must parse");
let seed = mnemonic.to_seed("");
Expand Down
100 changes: 83 additions & 17 deletions packages/rs-platform-wallet/src/changeset/changeset.rs
Original file line number Diff line number Diff line change
Expand Up @@ -578,6 +578,29 @@ fn context_rank(context: &key_wallet::transaction_checking::TransactionContext)

impl Merge for CoreChangeSet {
fn merge(&mut self, other: Self) {
// A record arriving after a sweep that removed the same transaction
// reinstates it, and every persister writes records before replaying
// sweeps — so without this the sweep would delete a row the wallet
// has since brought back. Reachable through IS-lock precedence: an
// unconfirmed transaction is swept when an IS-locked conflict lands,
// then returns chainlocked and sweeps that conflict in turn.
//
// The release set stays as it is. It is the aggregate for every loser
// in the batch, so dropping it when one of them is reinstated would
// discard coins freed by the losers that are still going. Entries
// belonging to the reinstated transaction are inert on every backend:
// each scopes its release to the remaining losers' own inputs, or
// withholds any outpoint a surviving record claims — and the
// reinstating record is exactly such a claim.
if !other.records.is_empty() && !self.sweeps.is_empty() {
let reinstated: std::collections::HashSet<Txid> =
other.records.iter().map(|record| record.txid).collect();
for batch in &mut self.sweeps {
batch.txids.retain(|txid| !reinstated.contains(txid));
}
self.sweeps.retain(|batch| !batch.txids.is_empty());
}

// Records: coalesce by txid, NEWEST-WINS.
//
// The event bridge already folded each event's per-account
Expand Down Expand Up @@ -1337,32 +1360,47 @@ impl Merge for AssetLockChangeSet {
// swift-sdk `persistAssetLocks`), making the store order of
// racing snapshots immaterial.
for (out_point, entry) in other.asset_locks {
if entry.status == AssetLockStatus::Consumed {
// A Consumed write supersedes any earlier-folded
// tombstone for the outpoint — Consumed rows are
// deliberately retained for historical lookup (see the
// variant doc), so the terminal write wins over a stale
// removal exactly as it wins over a stale status.
self.removed.remove(&out_point);
} else if let Some(existing) = self.asset_locks.get(&out_point) {
if existing.status == AssetLockStatus::Consumed {
continue;
if entry.status != AssetLockStatus::Consumed {
if let Some(existing) = self.asset_locks.get(&out_point) {
if existing.status == AssetLockStatus::Consumed {
continue;
}
}
}
// Every ACCEPTED upsert supersedes an earlier-folded tombstone
// for its outpoint, not just a Consumed one. Sweeps are a
// removal producer now (`remove_tracked_asset_locks_for_swept`),
// and a swept funding transaction can return chainlocked in the
// same folded drain — the reinstating record re-inserts the
// entry through reconstruction at a non-Consumed status, and
// letting the sweep's tombstone ride along would have the store
// delete the row it just reinstated (SQLite applies upserts
// before removals) while the in-memory wallet keeps it. This is
// the asset-lock mirror of `CoreChangeSet::merge`'s
// reinstated-txid retraction. For Consumed the same line also
// covers the historical rule: the terminal write wins over a
// stale removal exactly as it wins over a stale status.
self.removed.remove(&out_point);
self.asset_locks.insert(out_point, entry);
}
// Tombstones folded after a Consumed upsert are dropped for the
// same reason. The only removal emitter (`untrack_asset_lock`)
// fires exclusively for Built rows whose broadcast was
// definitively rejected, so a Consumed/removed pair for one
// outpoint has no legitimate producer — this is defense in
// depth matching the upsert guard.
// Tombstones folded after a Consumed upsert are dropped — Consumed
// rows are deliberately retained for historical lookup (see the
// variant doc). Any other pending upsert is dropped WITH the
// tombstone landing: a removal is upstream's newer word for the
// outpoint (a lock tracked and then swept, or a Built row rejected
// at broadcast, inside one fold), and carrying the dead upsert
// alongside the tombstone would make every store's correctness
// depend on applying upserts before removals. Together with the
// retraction above this keeps the invariant every backend relies
// on: a merged changeset never carries both an upsert and a
// tombstone for the same outpoint.
for out_point in other.removed {
let consumed = self
.asset_locks
.get(&out_point)
.is_some_and(|entry| entry.status == AssetLockStatus::Consumed);
if !consumed {
self.asset_locks.remove(&out_point);
self.removed.insert(out_point);
}
}
Expand Down Expand Up @@ -2373,10 +2411,38 @@ mod tests {
folded.asset_locks[&outpoint].status,
AssetLockStatus::Consumed
);
// …and a legitimate removal (rejected Built row) still folds.
// …and a legitimate removal (rejected Built row, or a sweep of the
// funding tx) still folds — taking the now-dead upsert with it, so
// no store ever sees an upsert/tombstone pair whose outcome would
// hinge on which it applies first.
let mut folded = cs_with(AssetLockStatus::Built);
folded.merge(removal());
assert!(folded.removed.contains(&outpoint));
assert!(
!folded.asset_locks.contains_key(&outpoint),
"a tombstone folding in must not leave the dead upsert beside it"
);

// The coalesced sweep-then-chainlocked-reinstatement fold: the
// sweep removes the tracked entry and contributes a tombstone, then
// the reinstating record re-inserts through reconstruction at a
// non-Consumed status — in the SAME drain. The accepted upsert must
// cancel the earlier tombstone (the asset-lock mirror of
// `CoreChangeSet::merge`'s reinstated-txid retraction); otherwise
// SQLite — upserts before removals — deletes the row it just
// reinstated while the in-memory wallet keeps it, and the durable
// tracked lock is gone after restart even though its funding
// transaction survived.
let mut folded = removal();
folded.merge(cs_with(AssetLockStatus::RecoveredFromChain));
assert!(
folded.removed.is_empty(),
"a reinstating reconstruction must cancel the folded sweep tombstone"
);
assert_eq!(
folded.asset_locks[&outpoint].status,
AssetLockStatus::RecoveredFromChain
);
}

#[test]
Expand Down
Loading
Loading