Skip to content

Commit 8a655b8

Browse files
fix(platform-wallet): act on swept transactions at the persistence seam
Bumps the rust-dashcore pin to dev and projects the `TransactionsSwept` event the bump brings with it. The two halves are one commit by construction: `WalletEvent` is not `#[non_exhaustive]` and platform has four exhaustive matches over it, so new-pin code cannot compile without the arms — and arms that did nothing would be worse than none, because upstream's removal is unconditional. The wallet drops the losing rows in memory; a store that keeps them replays them at the next load and re-creates the phantom balance the upstream fix exists to kill. The projection is one `SweepBatch` per event, and a sweep-only round is counted in `is_empty_no_records` so a round carrying nothing but a sweep still reaches the persister. The gate is what makes every intermediate host state safe. A backend that has not attested `CORE_SWEEP_REMOVAL` is not known to have applied the round's subtractive half, so its watermark is stripped BEFORE the store and the wallet faults exactly as it would on a rejection — reporting the height durable first and faulting after cannot retract a height a legacy backend already committed. Such a host freezes its sync watermark on the first sweep it meets instead of diverging: fail-closed, funds-safe, and unfrozen the moment its persister ships. A record arriving after a sweep of the same txid retracts that txid from the folded sweep, since persisters write records before replaying sweeps and would otherwise delete a row the wallet has brought back. The asset-lock half mirrors it: a sweep removes the tracked entry its funding transaction created, and `AssetLockChangeSet::merge` now cancels a folded tombstone against a reinstating upsert (and vice versa), so no store ever sees an upsert/tombstone pair for one outpoint whose outcome depends on which it applies first. The pin also carries rust-dashcore#981, which collapses BIP-39 parsing onto one auto-detecting path. Platform's four hand-rolled "try every wordlist" helpers are now that function, and the call sites drop their `Language` argument. It is unrelated to sweeps and rides here only because the sweep chain and the payload-finalization seam this branch's base already depends on both sit above it on dev. `spend_observer`'s two projections gain sweep arms that report no observed spend: a sweep's released outpoints are coins that came back free, and the inputs it kept spent are precisely the ones it does not name, so the held set cannot be derived from the event at all.
1 parent b7f2e47 commit 8a655b8

26 files changed

Lines changed: 1354 additions & 254 deletions

Cargo.lock

Lines changed: 12 additions & 12 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -53,14 +53,14 @@ members = [
5353
]
5454

5555
[workspace.dependencies]
56-
dashcore = { git = "https://github.com/dashpay/rust-dashcore", rev = "4db5c36701b8f38c4aea704badb81e3103ed701d" }
57-
dash-network-seeds = { git = "https://github.com/dashpay/rust-dashcore", rev = "4db5c36701b8f38c4aea704badb81e3103ed701d" }
58-
dash-spv = { git = "https://github.com/dashpay/rust-dashcore", rev = "4db5c36701b8f38c4aea704badb81e3103ed701d" }
59-
key-wallet = { git = "https://github.com/dashpay/rust-dashcore", rev = "4db5c36701b8f38c4aea704badb81e3103ed701d" }
60-
key-wallet-ffi = { git = "https://github.com/dashpay/rust-dashcore", rev = "4db5c36701b8f38c4aea704badb81e3103ed701d" }
61-
key-wallet-manager = { git = "https://github.com/dashpay/rust-dashcore", rev = "4db5c36701b8f38c4aea704badb81e3103ed701d" }
62-
dash-network = { git = "https://github.com/dashpay/rust-dashcore", rev = "4db5c36701b8f38c4aea704badb81e3103ed701d" }
63-
dashcore-rpc = { git = "https://github.com/dashpay/rust-dashcore", rev = "4db5c36701b8f38c4aea704badb81e3103ed701d" }
56+
dashcore = { git = "https://github.com/dashpay/rust-dashcore", rev = "21aaafed12847f3f8c99f181aadd4bacaa5428b1" }
57+
dash-network-seeds = { git = "https://github.com/dashpay/rust-dashcore", rev = "21aaafed12847f3f8c99f181aadd4bacaa5428b1" }
58+
dash-spv = { git = "https://github.com/dashpay/rust-dashcore", rev = "21aaafed12847f3f8c99f181aadd4bacaa5428b1" }
59+
key-wallet = { git = "https://github.com/dashpay/rust-dashcore", rev = "21aaafed12847f3f8c99f181aadd4bacaa5428b1" }
60+
key-wallet-ffi = { git = "https://github.com/dashpay/rust-dashcore", rev = "21aaafed12847f3f8c99f181aadd4bacaa5428b1" }
61+
key-wallet-manager = { git = "https://github.com/dashpay/rust-dashcore", rev = "21aaafed12847f3f8c99f181aadd4bacaa5428b1" }
62+
dash-network = { git = "https://github.com/dashpay/rust-dashcore", rev = "21aaafed12847f3f8c99f181aadd4bacaa5428b1" }
63+
dashcore-rpc = { git = "https://github.com/dashpay/rust-dashcore", rev = "21aaafed12847f3f8c99f181aadd4bacaa5428b1" }
6464

6565
tokio-metrics = "0.5"
6666

packages/rs-platform-wallet-ffi/src/derivation.rs

Lines changed: 6 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -6,32 +6,19 @@ use std::str::FromStr;
66

77
use dashcore::secp256k1::Secp256k1;
88
use key_wallet::bip32::{DerivationPath, ExtendedPrivKey};
9-
use key_wallet::mnemonic::{Language, Mnemonic};
9+
use key_wallet::mnemonic::Mnemonic;
1010
use zeroize::Zeroizing;
1111

1212
use crate::error::*;
1313
use crate::types::{FFINetwork, Network};
1414
use crate::{check_ptr, unwrap_result_or_return};
1515

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

3724
/// Derive a 32-byte ECDSA private key at a BIP-32 derivation path from

packages/rs-platform-wallet-ffi/src/identity_keys_from_mnemonic.rs

Lines changed: 6 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ use key_wallet::bip32::{ChildNumber, DerivationPath, ExtendedPrivKey, ExtendedPu
88
use key_wallet::dip9::{
99
IDENTITY_AUTHENTICATION_PATH_MAINNET, IDENTITY_AUTHENTICATION_PATH_TESTNET,
1010
};
11-
use key_wallet::mnemonic::{Language, Mnemonic};
11+
use key_wallet::mnemonic::Mnemonic;
1212
use zeroize::Zeroizing;
1313

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

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

7865
/// Resolve a wallet's BIP-39 mnemonic via a Swift-owned

packages/rs-platform-wallet-ffi/src/persistence.rs

Lines changed: 1 addition & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -7769,7 +7769,7 @@ mod tests {
77697769
use key_wallet::account::{Account, AccountType, StandardAccountType};
77707770
use key_wallet::bip32::{ExtendedPrivKey, ExtendedPubKey};
77717771
use key_wallet::managed_account::managed_account_trait::ManagedAccountTrait;
7772-
use key_wallet::mnemonic::{Language, Mnemonic};
7772+
use key_wallet::mnemonic::Mnemonic;
77737773
use key_wallet::wallet::Wallet;
77747774

77757775
/// Regression: restored pool addresses must be tagged with the
@@ -7918,7 +7918,6 @@ mod tests {
79187918
// `account_collection_test.rs` uses.
79197919
let mnemonic = Mnemonic::from_phrase(
79207920
"abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about",
7921-
Language::English,
79227921
)
79237922
.expect("static BIP-39 vector must parse");
79247923
let seed = mnemonic.to_seed("");
@@ -7952,7 +7951,6 @@ mod tests {
79527951
fn test_managed_wallet_info_with_account(account_type: AccountType) -> ManagedWalletInfo {
79537952
let mnemonic = Mnemonic::from_phrase(
79547953
"abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about",
7955-
Language::English,
79567954
)
79577955
.expect("static BIP-39 vector must parse");
79587956
let seed = mnemonic.to_seed("");
@@ -8068,7 +8066,6 @@ mod tests {
80688066
fn test_managed_wallet_info_with_provider_owner() -> ManagedWalletInfo {
80698067
let mnemonic = Mnemonic::from_phrase(
80708068
"abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about",
8071-
Language::English,
80728069
)
80738070
.expect("static BIP-39 vector must parse");
80748071
let seed = mnemonic.to_seed("");
@@ -8511,7 +8508,6 @@ mod tests {
85118508
fn account_xpub_survives_persist_restore_round_trip() {
85128509
let mnemonic = Mnemonic::from_phrase(
85138510
"abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about",
8514-
Language::English,
85158511
)
85168512
.expect("static BIP-39 vector must parse");
85178513
let seed = mnemonic.to_seed("");

packages/rs-platform-wallet/src/changeset/changeset.rs

Lines changed: 83 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -570,6 +570,29 @@ fn context_rank(context: &key_wallet::transaction_checking::TransactionContext)
570570

571571
impl Merge for CoreChangeSet {
572572
fn merge(&mut self, other: Self) {
573+
// A record arriving after a sweep that removed the same transaction
574+
// reinstates it, and every persister writes records before replaying
575+
// sweeps — so without this the sweep would delete a row the wallet
576+
// has since brought back. Reachable through IS-lock precedence: an
577+
// unconfirmed transaction is swept when an IS-locked conflict lands,
578+
// then returns chainlocked and sweeps that conflict in turn.
579+
//
580+
// The release set stays as it is. It is the aggregate for every loser
581+
// in the batch, so dropping it when one of them is reinstated would
582+
// discard coins freed by the losers that are still going. Entries
583+
// belonging to the reinstated transaction are inert on every backend:
584+
// each scopes its release to the remaining losers' own inputs, or
585+
// withholds any outpoint a surviving record claims — and the
586+
// reinstating record is exactly such a claim.
587+
if !other.records.is_empty() && !self.sweeps.is_empty() {
588+
let reinstated: std::collections::HashSet<Txid> =
589+
other.records.iter().map(|record| record.txid).collect();
590+
for batch in &mut self.sweeps {
591+
batch.txids.retain(|txid| !reinstated.contains(txid));
592+
}
593+
self.sweeps.retain(|batch| !batch.txids.is_empty());
594+
}
595+
573596
// Records: coalesce by txid, NEWEST-WINS (dashpay/platform#4387).
574597
//
575598
// The event bridge already folded each event's per-account
@@ -1329,32 +1352,47 @@ impl Merge for AssetLockChangeSet {
13291352
// swift-sdk `persistAssetLocks`), making the store order of
13301353
// racing snapshots immaterial.
13311354
for (out_point, entry) in other.asset_locks {
1332-
if entry.status == AssetLockStatus::Consumed {
1333-
// A Consumed write supersedes any earlier-folded
1334-
// tombstone for the outpoint — Consumed rows are
1335-
// deliberately retained for historical lookup (see the
1336-
// variant doc), so the terminal write wins over a stale
1337-
// removal exactly as it wins over a stale status.
1338-
self.removed.remove(&out_point);
1339-
} else if let Some(existing) = self.asset_locks.get(&out_point) {
1340-
if existing.status == AssetLockStatus::Consumed {
1341-
continue;
1355+
if entry.status != AssetLockStatus::Consumed {
1356+
if let Some(existing) = self.asset_locks.get(&out_point) {
1357+
if existing.status == AssetLockStatus::Consumed {
1358+
continue;
1359+
}
13421360
}
13431361
}
1362+
// Every ACCEPTED upsert supersedes an earlier-folded tombstone
1363+
// for its outpoint, not just a Consumed one. Sweeps are a
1364+
// removal producer now (`remove_tracked_asset_locks_for_swept`),
1365+
// and a swept funding transaction can return chainlocked in the
1366+
// same folded drain — the reinstating record re-inserts the
1367+
// entry through reconstruction at a non-Consumed status, and
1368+
// letting the sweep's tombstone ride along would have the store
1369+
// delete the row it just reinstated (SQLite applies upserts
1370+
// before removals) while the in-memory wallet keeps it. This is
1371+
// the asset-lock mirror of `CoreChangeSet::merge`'s
1372+
// reinstated-txid retraction. For Consumed the same line also
1373+
// covers the historical rule: the terminal write wins over a
1374+
// stale removal exactly as it wins over a stale status.
1375+
self.removed.remove(&out_point);
13441376
self.asset_locks.insert(out_point, entry);
13451377
}
1346-
// Tombstones folded after a Consumed upsert are dropped for the
1347-
// same reason. The only removal emitter (`untrack_asset_lock`)
1348-
// fires exclusively for Built rows whose broadcast was
1349-
// definitively rejected, so a Consumed/removed pair for one
1350-
// outpoint has no legitimate producer — this is defense in
1351-
// depth matching the upsert guard.
1378+
// Tombstones folded after a Consumed upsert are dropped — Consumed
1379+
// rows are deliberately retained for historical lookup (see the
1380+
// variant doc). Any other pending upsert is dropped WITH the
1381+
// tombstone landing: a removal is upstream's newer word for the
1382+
// outpoint (a lock tracked and then swept, or a Built row rejected
1383+
// at broadcast, inside one fold), and carrying the dead upsert
1384+
// alongside the tombstone would make every store's correctness
1385+
// depend on applying upserts before removals. Together with the
1386+
// retraction above this keeps the invariant every backend relies
1387+
// on: a merged changeset never carries both an upsert and a
1388+
// tombstone for the same outpoint.
13521389
for out_point in other.removed {
13531390
let consumed = self
13541391
.asset_locks
13551392
.get(&out_point)
13561393
.is_some_and(|entry| entry.status == AssetLockStatus::Consumed);
13571394
if !consumed {
1395+
self.asset_locks.remove(&out_point);
13581396
self.removed.insert(out_point);
13591397
}
13601398
}
@@ -2329,10 +2367,38 @@ mod tests {
23292367
folded.asset_locks[&outpoint].status,
23302368
AssetLockStatus::Consumed
23312369
);
2332-
// …and a legitimate removal (rejected Built row) still folds.
2370+
// …and a legitimate removal (rejected Built row, or a sweep of the
2371+
// funding tx) still folds — taking the now-dead upsert with it, so
2372+
// no store ever sees an upsert/tombstone pair whose outcome would
2373+
// hinge on which it applies first.
23332374
let mut folded = cs_with(AssetLockStatus::Built);
23342375
folded.merge(removal());
23352376
assert!(folded.removed.contains(&outpoint));
2377+
assert!(
2378+
!folded.asset_locks.contains_key(&outpoint),
2379+
"a tombstone folding in must not leave the dead upsert beside it"
2380+
);
2381+
2382+
// The coalesced sweep-then-chainlocked-reinstatement fold: the
2383+
// sweep removes the tracked entry and contributes a tombstone, then
2384+
// the reinstating record re-inserts through reconstruction at a
2385+
// non-Consumed status — in the SAME drain. The accepted upsert must
2386+
// cancel the earlier tombstone (the asset-lock mirror of
2387+
// `CoreChangeSet::merge`'s reinstated-txid retraction); otherwise
2388+
// SQLite — upserts before removals — deletes the row it just
2389+
// reinstated while the in-memory wallet keeps it, and the durable
2390+
// tracked lock is gone after restart even though its funding
2391+
// transaction survived.
2392+
let mut folded = removal();
2393+
folded.merge(cs_with(AssetLockStatus::RecoveredFromChain));
2394+
assert!(
2395+
folded.removed.is_empty(),
2396+
"a reinstating reconstruction must cancel the folded sweep tombstone"
2397+
);
2398+
assert_eq!(
2399+
folded.asset_locks[&outpoint].status,
2400+
AssetLockStatus::RecoveredFromChain
2401+
);
23362402
}
23372403

23382404
#[test]

0 commit comments

Comments
 (0)