From 5efb2bf361875d0a996c03d94441d4b86afcf3b7 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Tue, 28 Apr 2026 18:10:59 +0800 Subject: [PATCH] refactor(platform-wallet): adopt rust-dashcore wallet event-bus API MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Upstream rust-dashcore PR #696 deleted `WalletChangeSet` and `WalletPersistence` in favour of a `broadcast::Sender` bus where each event carries the post-change balance and records. Re-points `rust-dashcore` deps at the merge commit ea33cbc84179666c25515dfc817ce32210953037 and adapts platform-wallet: * New `CoreChangeSet` (platform-owned projection of `WalletEvent` data) replaces `WalletChangeSet` in `PlatformWalletChangeSet.core` — the persister/merge/apply surfaces stay identical, only the field type changed. * New `spawn_wallet_event_adapter` (replaces `CorePersistenceBridge`) drains the upstream broadcast and ships projected changesets through the platform persister. Decouples persistence from SPV's write lock — a slow Swift persister no longer stalls block processing. * `WalletInfoInterface` impl, `SpvRuntime` (3 generics, was 4), `BalanceUpdateHandler`, FFI conversion all updated to the new shapes. * SwiftData: `PersistentTransaction` cascade-deletes `PersistentUtxo`; `PersistentUtxo.txid` is now a computed property reading through the relationship. * Swift FFI: `AccountType.ffiValue` returns `FFIAccountKind` (upstream renamed the discriminant enum and reused `FFIAccountType` for a richer struct). * FFI manager constructor enters the shared tokio runtime so the event-adapter task spawn lands on it. TODO(events) markers in the diff flag follow-up work: * IS-lock standalone events not yet wired through the FFI. * `IdentityAuthenticationEcdsa`/`Bls` AccountType variants were removed upstream; FFI tags need new mapping once identity-key derivation moves off `AccountType`. Verified: `cargo check --workspace` clean, `cargo fmt --all` clean, iOS simulator framework + SwiftExampleApp build succeed. Co-Authored-By: Claude Opus 4.7 (1M context) --- Cargo.lock | 120 +------ Cargo.toml | 14 +- .../src/core_wallet_types.rs | 306 ++++++++++++------ .../rs-platform-wallet-ffi/src/manager.rs | 10 + .../rs-platform-wallet-ffi/src/persistence.rs | 41 ++- .../src/changeset/changeset.rs | 173 +++++++--- .../src/changeset/core_bridge.rs | 305 ++++++++++++++--- .../rs-platform-wallet/src/changeset/mod.rs | 11 +- .../rs-platform-wallet/src/manager/mod.rs | 52 ++- .../rs-platform-wallet/src/spv/runtime.rs | 19 +- .../rs-platform-wallet/src/wallet/apply.rs | 23 +- .../src/wallet/asset_lock/build.rs | 2 +- .../src/wallet/core/balance_handler.rs | 69 ++-- .../src/wallet/core/broadcast.rs | 2 +- .../src/wallet/core/wallet.rs | 8 +- .../src/wallet/identity/network/payments.rs | 4 +- .../src/wallet/platform_addresses/wallet.rs | 2 +- .../src/wallet/platform_wallet_traits.rs | 32 +- .../KeyWallet/KeyWalletTypes.swift | 15 +- .../Models/PersistentTransaction.swift | 9 + .../Persistence/Models/PersistentUtxo.swift | 30 +- .../PlatformWalletPersistenceHandler.swift | 20 +- 22 files changed, 880 insertions(+), 387 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index d0614b415f8..cad6c654044 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1358,12 +1358,6 @@ dependencies = [ "itertools 0.10.5", ] -[[package]] -name = "critical-section" -version = "1.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "790eea4361631c5e7d22598ecd5723ff611904e3344ce8720784c93e3d83d40b" - [[package]] name = "crossbeam-channel" version = "0.5.15" @@ -1574,7 +1568,7 @@ dependencies = [ [[package]] name = "dash-network" version = "0.42.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=4c8bec36628d9e002f09d815a9bff1d23ef13bd4#4c8bec36628d9e002f09d815a9bff1d23ef13bd4" +source = "git+https://github.com/dashpay/rust-dashcore?rev=ea33cbc84179666c25515dfc817ce32210953037#ea33cbc84179666c25515dfc817ce32210953037" dependencies = [ "bincode", "bincode_derive", @@ -1585,7 +1579,7 @@ dependencies = [ [[package]] name = "dash-network-seeds" version = "0.42.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=4c8bec36628d9e002f09d815a9bff1d23ef13bd4#4c8bec36628d9e002f09d815a9bff1d23ef13bd4" +source = "git+https://github.com/dashpay/rust-dashcore?rev=ea33cbc84179666c25515dfc817ce32210953037#ea33cbc84179666c25515dfc817ce32210953037" dependencies = [ "dash-network", ] @@ -1661,18 +1655,16 @@ dependencies = [ [[package]] name = "dash-spv" version = "0.42.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=4c8bec36628d9e002f09d815a9bff1d23ef13bd4#4c8bec36628d9e002f09d815a9bff1d23ef13bd4" +source = "git+https://github.com/dashpay/rust-dashcore?rev=ea33cbc84179666c25515dfc817ce32210953037#ea33cbc84179666c25515dfc817ce32210953037" dependencies = [ "async-trait", "chrono", "clap", - "dash-network", "dash-network-seeds", "dashcore", "dashcore_hashes", "futures", "hex", - "hickory-resolver", "key-wallet", "key-wallet-manager", "rand 0.8.5", @@ -1691,7 +1683,7 @@ dependencies = [ [[package]] name = "dash-spv-ffi" version = "0.42.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=4c8bec36628d9e002f09d815a9bff1d23ef13bd4#4c8bec36628d9e002f09d815a9bff1d23ef13bd4" +source = "git+https://github.com/dashpay/rust-dashcore?rev=ea33cbc84179666c25515dfc817ce32210953037#ea33cbc84179666c25515dfc817ce32210953037" dependencies = [ "cbindgen 0.29.2", "clap", @@ -1710,7 +1702,7 @@ dependencies = [ [[package]] name = "dashcore" version = "0.42.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=4c8bec36628d9e002f09d815a9bff1d23ef13bd4#4c8bec36628d9e002f09d815a9bff1d23ef13bd4" +source = "git+https://github.com/dashpay/rust-dashcore?rev=ea33cbc84179666c25515dfc817ce32210953037#ea33cbc84179666c25515dfc817ce32210953037" dependencies = [ "anyhow", "base64-compat", @@ -1736,12 +1728,12 @@ dependencies = [ [[package]] name = "dashcore-private" version = "0.42.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=4c8bec36628d9e002f09d815a9bff1d23ef13bd4#4c8bec36628d9e002f09d815a9bff1d23ef13bd4" +source = "git+https://github.com/dashpay/rust-dashcore?rev=ea33cbc84179666c25515dfc817ce32210953037#ea33cbc84179666c25515dfc817ce32210953037" [[package]] name = "dashcore-rpc" version = "0.42.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=4c8bec36628d9e002f09d815a9bff1d23ef13bd4#4c8bec36628d9e002f09d815a9bff1d23ef13bd4" +source = "git+https://github.com/dashpay/rust-dashcore?rev=ea33cbc84179666c25515dfc817ce32210953037#ea33cbc84179666c25515dfc817ce32210953037" dependencies = [ "dashcore-rpc-json", "hex", @@ -1754,7 +1746,7 @@ dependencies = [ [[package]] name = "dashcore-rpc-json" version = "0.42.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=4c8bec36628d9e002f09d815a9bff1d23ef13bd4#4c8bec36628d9e002f09d815a9bff1d23ef13bd4" +source = "git+https://github.com/dashpay/rust-dashcore?rev=ea33cbc84179666c25515dfc817ce32210953037#ea33cbc84179666c25515dfc817ce32210953037" dependencies = [ "bincode", "dashcore", @@ -1769,7 +1761,7 @@ dependencies = [ [[package]] name = "dashcore_hashes" version = "0.42.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=4c8bec36628d9e002f09d815a9bff1d23ef13bd4#4c8bec36628d9e002f09d815a9bff1d23ef13bd4" +source = "git+https://github.com/dashpay/rust-dashcore?rev=ea33cbc84179666c25515dfc817ce32210953037#ea33cbc84179666c25515dfc817ce32210953037" dependencies = [ "bincode", "dashcore-private", @@ -2239,18 +2231,6 @@ dependencies = [ "cfg-if", ] -[[package]] -name = "enum-as-inner" -version = "0.6.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a1e6a265c649f3f5979b601d26f1d05ada116434c87741c9493cb56218f76cbc" -dependencies = [ - "heck 0.5.0", - "proc-macro2", - "quote", - "syn 2.0.117", -] - [[package]] name = "enum-map" version = "2.7.3" @@ -3163,52 +3143,6 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3011d1213f159867b13cfd6ac92d2cd5f1345762c63be3554e84092d85a50bbd" -[[package]] -name = "hickory-proto" -version = "0.25.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8a6fe56c0038198998a6f217ca4e7ef3a5e51f46163bd6dd60b5c71ca6c6502" -dependencies = [ - "async-trait", - "cfg-if", - "data-encoding", - "enum-as-inner", - "futures-channel", - "futures-io", - "futures-util", - "idna", - "ipnet", - "once_cell", - "rand 0.9.2", - "ring", - "thiserror 2.0.18", - "tinyvec", - "tokio", - "tracing", - "url", -] - -[[package]] -name = "hickory-resolver" -version = "0.25.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc62a9a99b0bfb44d2ab95a7208ac952d31060efc16241c87eaf36406fecf87a" -dependencies = [ - "cfg-if", - "futures-util", - "hickory-proto", - "ipconfig", - "moka", - "once_cell", - "parking_lot", - "rand 0.9.2", - "resolv-conf", - "smallvec", - "thiserror 2.0.18", - "tokio", - "tracing", -] - [[package]] name = "hkdf" version = "0.12.4" @@ -3626,19 +3560,6 @@ dependencies = [ "serde", ] -[[package]] -name = "ipconfig" -version = "0.3.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4d40460c0ce33d6ce4b0630ad68ff63d6661961c48b6dba35e5a4d81cfb48222" -dependencies = [ - "socket2 0.6.3", - "widestring", - "windows-registry", - "windows-result", - "windows-sys 0.61.2", -] - [[package]] name = "ipnet" version = "2.12.0" @@ -3889,7 +3810,7 @@ dependencies = [ [[package]] name = "key-wallet" version = "0.42.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=4c8bec36628d9e002f09d815a9bff1d23ef13bd4#4c8bec36628d9e002f09d815a9bff1d23ef13bd4" +source = "git+https://github.com/dashpay/rust-dashcore?rev=ea33cbc84179666c25515dfc817ce32210953037#ea33cbc84179666c25515dfc817ce32210953037" dependencies = [ "aes", "async-trait", @@ -3917,7 +3838,7 @@ dependencies = [ [[package]] name = "key-wallet-ffi" version = "0.42.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=4c8bec36628d9e002f09d815a9bff1d23ef13bd4#4c8bec36628d9e002f09d815a9bff1d23ef13bd4" +source = "git+https://github.com/dashpay/rust-dashcore?rev=ea33cbc84179666c25515dfc817ce32210953037#ea33cbc84179666c25515dfc817ce32210953037" dependencies = [ "cbindgen 0.29.2", "dash-network", @@ -3933,7 +3854,7 @@ dependencies = [ [[package]] name = "key-wallet-manager" version = "0.42.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=4c8bec36628d9e002f09d815a9bff1d23ef13bd4#4c8bec36628d9e002f09d815a9bff1d23ef13bd4" +source = "git+https://github.com/dashpay/rust-dashcore?rev=ea33cbc84179666c25515dfc817ce32210953037#ea33cbc84179666c25515dfc817ce32210953037" dependencies = [ "async-trait", "bincode", @@ -3941,6 +3862,7 @@ dependencies = [ "key-wallet", "rayon", "tokio", + "tracing", "zeroize", ] @@ -4571,10 +4493,6 @@ name = "once_cell" version = "1.21.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" -dependencies = [ - "critical-section", - "portable-atomic", -] [[package]] name = "once_cell_polyfill" @@ -5779,12 +5697,6 @@ dependencies = [ "tracing", ] -[[package]] -name = "resolv-conf" -version = "0.7.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e061d1b48cb8d38042de4ae0a7a6401009d6143dc80d2e2d6f31f0bdd6470c7" - [[package]] name = "ring" version = "0.17.14" @@ -8412,12 +8324,6 @@ dependencies = [ "rustix 0.38.44", ] -[[package]] -name = "widestring" -version = "1.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72069c3113ab32ab29e5584db3c6ec55d416895e60715417b5b883a357c3e471" - [[package]] name = "winapi" version = "0.3.9" diff --git a/Cargo.toml b/Cargo.toml index d16f75cee67..5389a29c402 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -49,13 +49,13 @@ members = [ ] [workspace.dependencies] -dashcore = { git = "https://github.com/dashpay/rust-dashcore", rev = "4c8bec36628d9e002f09d815a9bff1d23ef13bd4" } -dash-spv = { git = "https://github.com/dashpay/rust-dashcore", rev = "4c8bec36628d9e002f09d815a9bff1d23ef13bd4" } -dash-spv-ffi = { git = "https://github.com/dashpay/rust-dashcore", rev = "4c8bec36628d9e002f09d815a9bff1d23ef13bd4" } -key-wallet = { git = "https://github.com/dashpay/rust-dashcore", rev = "4c8bec36628d9e002f09d815a9bff1d23ef13bd4" } -key-wallet-ffi = { git = "https://github.com/dashpay/rust-dashcore", rev = "4c8bec36628d9e002f09d815a9bff1d23ef13bd4" } -key-wallet-manager = { git = "https://github.com/dashpay/rust-dashcore", rev = "4c8bec36628d9e002f09d815a9bff1d23ef13bd4" } -dashcore-rpc = { git = "https://github.com/dashpay/rust-dashcore", rev = "4c8bec36628d9e002f09d815a9bff1d23ef13bd4" } +dashcore = { git = "https://github.com/dashpay/rust-dashcore", rev = "ea33cbc84179666c25515dfc817ce32210953037" } +dash-spv = { git = "https://github.com/dashpay/rust-dashcore", rev = "ea33cbc84179666c25515dfc817ce32210953037" } +dash-spv-ffi = { git = "https://github.com/dashpay/rust-dashcore", rev = "ea33cbc84179666c25515dfc817ce32210953037" } +key-wallet = { git = "https://github.com/dashpay/rust-dashcore", rev = "ea33cbc84179666c25515dfc817ce32210953037" } +key-wallet-ffi = { git = "https://github.com/dashpay/rust-dashcore", rev = "ea33cbc84179666c25515dfc817ce32210953037" } +key-wallet-manager = { git = "https://github.com/dashpay/rust-dashcore", rev = "ea33cbc84179666c25515dfc817ce32210953037" } +dashcore-rpc = { git = "https://github.com/dashpay/rust-dashcore", rev = "ea33cbc84179666c25515dfc817ce32210953037" } # Optimize heavy crypto crates even in dev/test builds so that # Halo 2 proof generation and verification run at near-release speed. diff --git a/packages/rs-platform-wallet-ffi/src/core_wallet_types.rs b/packages/rs-platform-wallet-ffi/src/core_wallet_types.rs index 397831bfdf6..c5f974d924e 100644 --- a/packages/rs-platform-wallet-ffi/src/core_wallet_types.rs +++ b/packages/rs-platform-wallet-ffi/src/core_wallet_types.rs @@ -128,18 +128,47 @@ pub struct WalletChangeSetFFI { // --------------------------------------------------------------------------- impl WalletChangeSetFFI { - pub fn from_changeset(cs: &key_wallet::changeset::WalletChangeSet) -> Self { - use key_wallet::managed_account::address_pool::AddressPoolType; + /// Convert a platform-wallet [`CoreChangeSet`] into the C-ABI struct + /// the Swift persister consumes. + /// + /// Buckets the changeset's flat `records` list by `record.account_type`, + /// then derives per-account UTXO deltas at the time of conversion + /// (using each record's `input_details` / `output_details`). The + /// changeset's own `spent_utxos` / `new_utxos` vecs aren't read here: + /// they're informationally redundant with the records themselves + /// (the records are authoritative), and re-deriving keeps the + /// per-account routing self-contained. + /// + /// `chain` carries `synced_height` from the changeset's + /// `synced_height` field; `block_hash` is omitted because + /// `WalletEvent::SyncHeightAdvanced` doesn't carry it (the upstream + /// event is just a height watermark). `balance` is left absent — + /// the new event-bus model derives balance from per-event balance + /// snapshots delivered through the `BalanceUpdateHandler`, not as + /// a delta on the persistence path. + /// + /// TODO(events): wire `instant_locks_for_non_final_records` through + /// the FFI surface. Today the Swift side learns about IS-lock state + /// only when a re-emitted `TransactionRecord` flows through `records` + /// with `context = InstantSend(..)`. The standalone IS-lock map is + /// dropped here. Acceptable as long as the event adapter re-emits + /// affected records (it currently does for `TransactionDetected` + /// and `BlockProcessed` but NOT for `TransactionInstantLocked`). + /// When the standalone IS-lock event needs to flow to Swift, add + /// a `BTreeMap`-shaped FFI field here and populate it. + pub fn from_changeset(cs: &platform_wallet::changeset::CoreChangeSet) -> Self { + use key_wallet::account::AccountType; use std::ffi::CString; - let (has_chain, chain) = match cs.chain { - Some(ref c) => ( + // Chain — only synced_height flows through SyncHeightAdvanced. + let (has_chain, chain) = match cs.synced_height { + Some(h) => ( true, ChainChangeSetFFI { - has_synced_height: c.synced_height.is_some(), - synced_height: c.synced_height.unwrap_or(0), - has_block_hash: c.block_hash.is_some(), - block_hash: c.block_hash.map(|h| *h.as_ref()).unwrap_or([0u8; 32]), + has_synced_height: true, + synced_height: h, + has_block_hash: false, + block_hash: [0u8; 32], }, ), None => ( @@ -153,84 +182,69 @@ impl WalletChangeSetFFI { ), }; - let (has_balance, balance) = match cs.balance { - Some(ref b) => ( - true, - BalanceChangeSetFFI { - confirmed_delta: b.confirmed_delta, - unconfirmed_delta: b.unconfirmed_delta, - immature_delta: b.immature_delta, - locked_delta: b.locked_delta, - }, - ), - None => ( - false, - BalanceChangeSetFFI { - confirmed_delta: 0, - unconfirmed_delta: 0, - immature_delta: 0, - locked_delta: 0, - }, - ), - }; + // Balance is no longer carried as a delta on the persistence + // path; the BalanceUpdateHandler keeps wallet atomics current + // from the post-event balance snapshot upstream embeds in each + // event variant. + let (has_balance, balance) = ( + false, + BalanceChangeSetFFI { + confirmed_delta: 0, + unconfirmed_delta: 0, + immature_delta: 0, + locked_delta: 0, + }, + ); + + // Bucket records by account_type. Record sequence is preserved + // within each bucket so the persister sees them in arrival + // order (matters for the `inserted` -> `updated` transition + // ordering inside a single BlockProcessed event). + // + // `AccountType` doesn't implement `Ord` upstream (the + // 256-bit `[u8; 32]` fields on the Dashpay variants would make + // a derived ordering arbitrary), so a `Vec<(key, bucket)>` + // with a linear "find or insert" walk is the path of least + // resistance. Wallets typically have well under a hundred + // accounts, so the linear search is cheap. + let mut by_account: Vec<( + AccountType, + Vec<&key_wallet::managed_account::transaction_record::TransactionRecord>, + )> = Vec::new(); + for rec in &cs.records { + if let Some(bucket) = by_account + .iter_mut() + .find(|(at, _)| at == &rec.account_type) + { + bucket.1.push(rec); + } else { + by_account.push((rec.account_type, vec![rec])); + } + } + + let mut ffi_accounts = Vec::with_capacity(by_account.len()); + for (account_type, recs) in by_account { + let type_name = CString::new(format!("{:?}", account_type)) + .unwrap_or_else(|_| CString::new("Unknown").unwrap()); + let account_index = account_index_of(&account_type); + + // Derive UTXO add/spend lists from this account's records. + // Each record carries its own input_details and + // output_details; we walk them once per record to project + // the UTXOs the persister should add or remove. + let mut utxos_added: Vec = Vec::new(); + let mut utxos_spent: Vec = Vec::new(); + for rec in &recs { + utxos_added.extend(record_new_utxos_ffi(rec)); + utxos_spent.extend(record_spent_outpoints_ffi(rec)); + } - let mut ffi_accounts = Vec::new(); - for (account_type, account_cs) in &cs.per_account { - let type_name_str = format!("{:?}", account_type); - let type_name = - CString::new(type_name_str).unwrap_or_else(|_| CString::new("Unknown").unwrap()); - let account_index = account_type.index().unwrap_or(0); - - // UTXOs added - let utxos_added: Vec = account_cs - .utxos_added - .values() - .map(|utxo| { - let addr = CString::new(utxo.address.to_string()) - .unwrap_or_else(|_| CString::new("").unwrap()); - let script = utxo.txout.script_pubkey.as_bytes().to_vec(); - let script_len = script.len(); - let script_ptr = vec_to_ptr_u8(script, script_len); - - UtxoEntryFFI { - outpoint: outpoint_to_ffi(&utxo.outpoint), - amount: utxo.txout.value, - address: addr.into_raw(), - script_pubkey: script_ptr, - script_pubkey_len: script_len, - height: utxo.height, - is_coinbase: utxo.is_coinbase, - is_confirmed: utxo.is_confirmed, - is_instantlocked: utxo.is_instantlocked, - is_locked: utxo.is_locked, - } - }) - .collect(); - - // UTXOs spent - let utxos_spent: Vec = - account_cs.utxos_spent.iter().map(outpoint_to_ffi).collect(); - - // UTXOs instant-locked - let utxos_il: Vec = account_cs - .utxos_instant_locked - .iter() - .map(outpoint_to_ffi) - .collect(); - - // Transactions - let transactions: Vec = account_cs - .transactions - .values() - .map(tx_record_to_ffi) - .collect(); - - let ext_hu = account_cs.highest_used.get(&AddressPoolType::External); - let int_hu = account_cs.highest_used.get(&AddressPoolType::Internal); + // Transactions for this account. + let transactions: Vec = + recs.into_iter().map(tx_record_to_ffi).collect(); let utxos_added_count = utxos_added.len(); let utxos_spent_count = utxos_spent.len(); - let utxos_il_count = utxos_il.len(); let transactions_count = transactions.len(); ffi_accounts.push(AccountChangeSetFFI { @@ -240,14 +254,21 @@ impl WalletChangeSetFFI { utxos_added_count, utxos_spent: vec_to_ptr(utxos_spent), utxos_spent_count, - utxos_instant_locked: vec_to_ptr(utxos_il), - utxos_instant_locked_count: utxos_il_count, + // IS-locked outpoints aren't carried as a separate + // bucket on the new path — see TODO above. + utxos_instant_locked: std::ptr::null_mut(), + utxos_instant_locked_count: 0, transactions: vec_to_ptr(transactions), transactions_count, - external_highest_used: ext_hu.map(|&v| v as i32).unwrap_or(-1), - has_external_highest_used: ext_hu.is_some(), - internal_highest_used: int_hu.map(|&v| v as i32).unwrap_or(-1), - has_internal_highest_used: int_hu.is_some(), + // Highest-used pool indices were a feature of the + // deleted upstream changeset's per-account bucket. + // The new event-bus model doesn't surface them; the + // persister can derive them from monitored addresses + // if needed. + external_highest_used: -1, + has_external_highest_used: false, + internal_highest_used: -1, + has_internal_highest_used: false, }); } @@ -263,15 +284,102 @@ impl WalletChangeSetFFI { } } -fn outpoint_to_ffi(op: &dashcore::OutPoint) -> OutPointFFI { - let mut txid = [0u8; 32]; - txid.copy_from_slice(op.txid.as_ref()); - OutPointFFI { - txid, - vout: op.vout, +/// Returns the account "index" the FFI surfaces in `account_index`. +/// +/// For variants with a natural index field (`Standard`, `CoinJoin`, +/// `IdentityTopUp`, `DashpayReceivingFunds`, `DashpayExternalAccount`, +/// `PlatformPayment`), returns that field. For singleton variants +/// (`IdentityRegistration`, `IdentityInvitation`, etc.), returns 0. +/// Matches the pre-event-bus behaviour where `AccountType::index()` +/// returned `Option` and singletons mapped to `None` → 0. +fn account_index_of(at: &key_wallet::account::AccountType) -> u32 { + use key_wallet::account::AccountType; + match at { + AccountType::Standard { index, .. } + | AccountType::CoinJoin { index } + | AccountType::DashpayReceivingFunds { index, .. } + | AccountType::DashpayExternalAccount { index, .. } => *index, + AccountType::IdentityTopUp { registration_index } => *registration_index, + AccountType::PlatformPayment { account, .. } => *account, + _ => 0, } } +/// Project the "ours" outputs of a `TransactionRecord` into FFI UTXO +/// entries. Mirrors `derive_new_utxos` in +/// `platform_wallet::changeset::core_bridge` but stops one layer +/// further down the stack so the FFI conversion stays self-contained. +fn record_new_utxos_ffi( + rec: &key_wallet::managed_account::transaction_record::TransactionRecord, +) -> Vec { + use key_wallet::managed_account::transaction_record::OutputRole; + use key_wallet::transaction_checking::TransactionContext; + use std::ffi::CString; + + let height = rec.context.block_info().map(|b| b.height()).unwrap_or(0); + let is_confirmed = matches!( + rec.context, + TransactionContext::InBlock(_) | TransactionContext::InChainLockedBlock(_) + ); + let is_instant = matches!(rec.context, TransactionContext::InstantSend(_)); + let is_coinbase = rec.transaction.is_coin_base(); + + rec.output_details + .iter() + .filter_map(|d| { + if !matches!(d.role, OutputRole::Received | OutputRole::Change) { + return None; + } + let txout = rec.transaction.output.get(d.index as usize)?; + let address_str = d + .address + .as_ref() + .map(|a| a.to_string()) + .unwrap_or_default(); + let address = CString::new(address_str).unwrap_or_else(|_| CString::new("").unwrap()); + let script_bytes = txout.script_pubkey.as_bytes().to_vec(); + let script_len = script_bytes.len(); + let script_ptr = vec_to_ptr_u8(script_bytes, script_len); + let mut txid = [0u8; 32]; + txid.copy_from_slice(rec.txid.as_ref()); + Some(UtxoEntryFFI { + outpoint: OutPointFFI { + txid, + vout: d.index, + }, + amount: txout.value, + address: address.into_raw(), + script_pubkey: script_ptr, + script_pubkey_len: script_len, + height, + is_coinbase, + is_confirmed, + is_instantlocked: is_instant, + is_locked: false, + }) + }) + .collect() +} + +/// Project the outpoints spent by a `TransactionRecord` (i.e. the +/// outpoints whose UTXO rows the persister should delete). +fn record_spent_outpoints_ffi( + rec: &key_wallet::managed_account::transaction_record::TransactionRecord, +) -> Vec { + rec.input_details + .iter() + .filter_map(|d| { + let input = rec.transaction.input.get(d.index as usize)?; + let mut txid = [0u8; 32]; + txid.copy_from_slice(input.previous_output.txid.as_ref()); + Some(OutPointFFI { + txid, + vout: input.previous_output.vout, + }) + }) + .collect() +} + fn tx_record_to_ffi( tr: &key_wallet::managed_account::transaction_record::TransactionRecord, ) -> TransactionRecordFFI { @@ -328,7 +436,15 @@ fn tx_record_to_ffi( fee: tr.fee.unwrap_or(0), has_fee: tr.fee.is_some(), label: label_str.into_raw(), - first_seen: tr.first_seen, + // `first_seen` was removed from upstream `TransactionRecord` in + // the event-bus refactor — there's no equivalent timestamp on + // the new type. The Swift persister still indexes by it, so we + // surface the block timestamp when the record is confirmed + // (a usable proxy for "first seen" in the in-block case) and 0 + // for mempool / instant-send records, which the Swift side can + // refresh from `Date.now()` on insert if it needs a real + // observation timestamp. + first_seen: blk_ts as u64, } } diff --git a/packages/rs-platform-wallet-ffi/src/manager.rs b/packages/rs-platform-wallet-ffi/src/manager.rs index aa31abb9adf..df43838fcb1 100644 --- a/packages/rs-platform-wallet-ffi/src/manager.rs +++ b/packages/rs-platform-wallet-ffi/src/manager.rs @@ -50,6 +50,16 @@ pub unsafe extern "C" fn platform_wallet_manager_create( let handler: Arc = Arc::new(FFIEventHandler::new(std::ptr::read(event_handler))); + // `PlatformWalletManager::new` spawns the wallet-event adapter + // task on construction (the subscriber that translates upstream + // `WalletEvent`s into `PlatformWalletChangeSet`s). `tokio::spawn` + // panics if no runtime is in scope, which is the default state on + // the FFI thread — Swift calls us synchronously, no reactor + // attached. Enter the FFI's shared runtime for the duration of + // the constructor so the spawn lands on it; the guard drops on + // return and leaves the spawned task running on that runtime. + let _runtime_guard = runtime().enter(); + let manager = PlatformWalletManager::new(sdk, persister, handler); let handle = PLATFORM_WALLET_MANAGER_STORAGE.insert(manager); *out_handle = handle; diff --git a/packages/rs-platform-wallet-ffi/src/persistence.rs b/packages/rs-platform-wallet-ffi/src/persistence.rs index 349ef9618cf..a83215c0293 100644 --- a/packages/rs-platform-wallet-ffi/src/persistence.rs +++ b/packages/rs-platform-wallet-ffi/src/persistence.rs @@ -849,15 +849,17 @@ fn build_account_spec_ffi(account_type: &AccountType, xpub_bytes: &[u8]) -> Acco spec.type_tag = AccountTypeTagFFI::PlatformPayment; spec.index = *account; spec.key_class = *key_class; - } - AccountType::IdentityAuthenticationEcdsa { identity_index } => { - spec.type_tag = AccountTypeTagFFI::IdentityAuthenticationEcdsa; - spec.index = *identity_index; - } - AccountType::IdentityAuthenticationBls { identity_index } => { - spec.type_tag = AccountTypeTagFFI::IdentityAuthenticationBls; - spec.index = *identity_index; - } + } // TODO(events): the `IdentityAuthenticationEcdsa` / + // `IdentityAuthenticationBls` upstream `AccountType` variants + // were removed when identity-key derivation moved off the + // wallet-account model. The FFI ABI still exposes the matching + // `AccountTypeTagFFI` tags for backwards compatibility, but + // there's no upstream variant to map them to right now. If a + // wallet record arrives with an identity-auth derivation path, + // the bridge surface needs new entry points for the new + // identity-key shape — until then no upstream `AccountType` + // value can produce these tags, so the match is exhaustive + // without explicit branches. } spec } @@ -1239,14 +1241,21 @@ fn account_type_from_spec(spec: &AccountSpecFFI) -> Result { - AccountType::IdentityAuthenticationEcdsa { - identity_index: spec.index, - } + // TODO(events): the upstream `AccountType::IdentityAuthentication*` + // variants were removed in the event-bus refactor. The FFI ABI + // still surfaces the tags for backwards compatibility, so a + // Swift caller passing one back through the load path needs a + // new mapping target. Until the identity-key derivation moves + // off `AccountType` entirely, fail loudly so we don't silently + // pretend a record is restorable when its derivation context + // is gone. + AccountTypeTagFFI::IdentityAuthenticationEcdsa + | AccountTypeTagFFI::IdentityAuthenticationBls => { + return Err(PersistenceError::Backend(format!( + "AccountTypeTagFFI {:?} is no longer mappable to a key-wallet AccountType after the upstream event-bus refactor (TODO(events))", + spec.type_tag + ))); } - AccountTypeTagFFI::IdentityAuthenticationBls => AccountType::IdentityAuthenticationBls { - identity_index: spec.index, - }, }) } diff --git a/packages/rs-platform-wallet/src/changeset/changeset.rs b/packages/rs-platform-wallet/src/changeset/changeset.rs index 930bfab5285..fedb524b0b0 100644 --- a/packages/rs-platform-wallet/src/changeset/changeset.rs +++ b/packages/rs-platform-wallet/src/changeset/changeset.rs @@ -6,27 +6,30 @@ //! //! # Shape //! -//! `PlatformWalletChangeSet` embeds [`key_wallet::changeset::WalletChangeSet`] -//! verbatim in its `core` field — that sub-changeset carries every -//! core-wallet delta (chain, accounts, UTXOs, transactions, balance) in the -//! BDK-style per-account bucketing defined by key-wallet. Platform-specific -//! state that doesn't exist in key-wallet lives in dedicated sub-changesets: -//! identities, contacts, platform addresses, asset locks, and token balances. +//! `PlatformWalletChangeSet` carries a [`CoreChangeSet`] in its `core` +//! field — a platform-owned projection of the data that upstream's +//! `WalletEvent` bus delivers (transaction records + UTXO deltas + heights +//! + InstantSend locks). Platform-specific state that doesn't exist in +//! key-wallet lives in dedicated sub-changesets: identities, contacts, +//! platform addresses, asset locks, and token balances. //! -//! Earlier revisions of this file defined its own `ChainChangeSet`, -//! `TransactionChangeSet`, `UtxoChangeSet`, and `AccountChangeSet`. Those -//! were stand-ins from before key-wallet had its own changeset module and -//! used lossy flattened entries (e.g. `BTreeMap` for UTXOs, -//! losing address/script/is_coinbase/confirmation state). They are all -//! deleted; the `core` field replaces them with native `key-wallet` types. +//! Earlier revisions of this file used `key_wallet::changeset::WalletChangeSet` +//! verbatim in the `core` field. That upstream type was deleted in favour +//! of an event-bus model (see PR #696 in rust-dashcore). Platform-wallet +//! subscribes to the event bus, projects each event into a `CoreChangeSet`, +//! and routes it through this changeset's `core` slot — keeping the +//! per-domain merge / apply shape downstream consumers already know. use std::collections::{BTreeMap, BTreeSet}; use dashcore::blockdata::transaction::{OutPoint, Transaction}; +use dashcore::ephemerealdata::instant_lock::InstantLock; +use dashcore::Txid; use dash_sdk::platform::address_sync::AddressFunds; use dpp::prelude::AssetLockProof; -use key_wallet::PlatformP2PKHAddress; +use key_wallet::managed_account::transaction_record::TransactionRecord; +use key_wallet::{PlatformP2PKHAddress, Utxo}; use crate::wallet::platform_wallet::WalletId; @@ -46,23 +49,115 @@ use crate::wallet::identity::state::managed_identity::{ use crate::wallet::identity::{ContactRequest, DashPayProfile, EstablishedContact, PaymentEntry}; // --------------------------------------------------------------------------- -// Bridge: key_wallet::changeset::WalletChangeSet -> platform-wallet Merge +// Core wallet changeset — projection of upstream `WalletEvent` data // --------------------------------------------------------------------------- -// -// platform-wallet has its own `Merge` trait that is semantically -// richer than key-wallet's (recursive merge on `BTreeMap`), -// so we can't just import key-wallet's trait wholesale. This one-off -// impl delegates to the key-wallet `Merge` implementation that ships -// with `WalletChangeSet` so that -// `Option` satisfies -// `crate::changeset::merge::Merge` via the blanket impl. -impl Merge for key_wallet::changeset::WalletChangeSet { + +/// Platform-owned projection of the core-wallet deltas that upstream's +/// `WalletEvent` bus delivers. +/// +/// Built by the platform-wallet event adapter from `WalletEvent` variants +/// emitted by `WalletManager`. Every field is purely additive — the +/// merge implementation uses last-write-wins for the height watermarks +/// (monotonic-max), `extend` for the records / utxos vecs, and +/// last-write-wins for the IS-lock map. +/// +/// # Why a projection instead of the upstream type +/// +/// Upstream `key_wallet::changeset::WalletChangeSet` was deleted in favour +/// of `WalletEvent`. Forking that deleted type would re-introduce the +/// merge-ordering hazards the upstream PR removed. This projection +/// captures exactly what the persister needs (records to write, UTXOs to +/// add/remove, height checkpoints, IS-lock updates) without inheriting +/// the merge complexity of the deleted upstream type. +/// +/// Not `PartialEq` — `TransactionRecord` upstream is `Debug + Clone` only, +/// so structural equality on `records` would require us to fork the +/// upstream type. Tests that need to inspect a changeset's contents +/// reach into individual fields directly. +#[derive(Debug, Clone, Default)] +pub struct CoreChangeSet { + /// Transaction records produced by this batch. + /// + /// Includes records first stored (`TransactionDetected`, + /// `BlockProcessed.inserted`), records whose context advanced + /// (`BlockProcessed.updated` — e.g. a mempool tx that just confirmed), + /// and coinbase records that crossed the maturity threshold + /// (`BlockProcessed.matured`). All persisted; the persister's + /// `txid` uniqueness constraint handles dedup on replay. + pub records: Vec, + + /// UTXOs to remove — outpoints that records in this batch spent. + /// The full `Utxo` is carried (not just `OutPoint`) so a persister + /// audit trail / spent-output history can keep the original metadata + /// without a follow-up read. + pub spent_utxos: Vec, + + /// UTXOs to add — outputs created by records in this batch that pay + /// to one of our addresses (i.e. `OutputRole::Received` or + /// `OutputRole::Change` per the upstream `TransactionRecord`). + pub new_utxos: Vec, + + /// InstantSend locks observed for records that are NOT yet in a + /// chain-locked block (i.e. records still in `Mempool`, + /// `InstantSend`, or `InBlock` context — anything `InChainLockedBlock` + /// is excluded since chain-lock finality already supersedes IS). + /// + /// Populated from `WalletEvent::TransactionInstantLocked`. The + /// persister applies these by looking up the matching record and + /// updating its `context` to `TransactionContext::InstantSend(..)`. + /// Chain-locked records skip this map entirely — by the time a + /// transaction is chain-locked it's final, and IS-lock state is + /// no longer informative. + pub instant_locks_for_non_final_records: BTreeMap, + + /// From `WalletEvent::BlockProcessed.height` — advance the wallet's + /// `last_processed_height` to this value. Monotonic-max on merge. + pub last_processed_height: Option, + + /// From `WalletEvent::SyncHeightAdvanced.height` — advance the + /// durable filter-batch sync checkpoint to this value. Monotonic-max + /// on merge. + pub synced_height: Option, +} + +impl Merge for CoreChangeSet { fn merge(&mut self, other: Self) { - ::merge(self, other) + // Records / utxo deltas: append-only. The event adapter never + // produces duplicates within a single batch (each event covers + // a distinct moment); cross-batch dedup is the persister's + // responsibility (txid uniqueness for records, outpoint + // uniqueness for utxos). + self.records.extend(other.records); + self.spent_utxos.extend(other.spent_utxos); + self.new_utxos.extend(other.new_utxos); + + // IS-lock map: last-write-wins per txid. A second IS-lock for + // the same txid (e.g. a follow-up event re-confirming the lock) + // overwrites — the lock object itself is canonical. + self.instant_locks_for_non_final_records + .extend(other.instant_locks_for_non_final_records); + + // Height watermarks: monotonic-max. A later changeset can only + // advance the watermark, never roll it back. `None` means + // "no update in this batch". + if let Some(h) = other.last_processed_height { + self.last_processed_height = Some( + self.last_processed_height + .map_or(h, |existing| existing.max(h)), + ); + } + if let Some(h) = other.synced_height { + self.synced_height = Some(self.synced_height.map_or(h, |existing| existing.max(h))); + } } fn is_empty(&self) -> bool { - ::is_empty(self) + self.records.is_empty() + && self.spent_utxos.is_empty() + && self.new_utxos.is_empty() + && self.instant_locks_for_non_final_records.is_empty() + && self.last_processed_height.is_none() + && self.synced_height.is_none() } } @@ -629,20 +724,23 @@ impl Merge for TokenBalanceChangeSet { /// Delta of all wallet state changes from a single operation. /// -/// `core` carries the full `key_wallet::changeset::WalletChangeSet` — chain, -/// balance, account_keys, and per-account buckets (UTXOs, transactions, -/// addresses used, highest-used index). Platform-specific deltas (identities, -/// contacts, platform addresses, asset locks, token balances) live in -/// dedicated sub-changesets. +/// `core` carries a [`CoreChangeSet`] — the platform-owned projection of +/// `WalletEvent` data delivered by upstream's event bus (records, UTXO +/// deltas, height checkpoints, IS-lock updates). Platform-specific deltas +/// (identities, contacts, platform addresses, asset locks, token balances) +/// live in dedicated sub-changesets. /// /// Composed of optional sub-changesets — `None` means no change in that /// area. Use [`Merge::merge`] to combine multiple deltas before persisting. -#[derive(Debug, Clone, Default, PartialEq)] +/// +/// Not `PartialEq` because [`CoreChangeSet`] isn't (its `records` carry +/// `TransactionRecord`, which is `Debug + Clone` only upstream). +#[derive(Debug, Clone, Default)] pub struct PlatformWalletChangeSet { - /// Core wallet state from key-wallet: chain, balance, account keys, - /// and per-account buckets (UTXOs, transactions, addresses used, - /// highest-used index). - pub core: Option, + /// Core-wallet deltas projected from upstream `WalletEvent`s: + /// transaction records, UTXO add/remove, height checkpoints, IS-lock + /// updates for non-final records. + pub core: Option, /// Identity changes (registered, updated). pub identities: Option, /// Identity key changes (public keys + private-key storage) keyed @@ -728,9 +826,8 @@ impl From for PlatformWalletChangeSet { impl Merge for PlatformWalletChangeSet { fn merge(&mut self, other: Self) { - // `key_wallet::changeset::WalletChangeSet` implements `Merge` - // itself; delegate via the `Option: Merge` blanket impl from - // this crate's merge module. + // `CoreChangeSet` implements `Merge`; delegate via the + // `Option: Merge` blanket impl from this crate's merge module. self.core.merge(other.core); self.identities.merge(other.identities); self.identity_keys.merge(other.identity_keys); diff --git a/packages/rs-platform-wallet/src/changeset/core_bridge.rs b/packages/rs-platform-wallet/src/changeset/core_bridge.rs index c7e80752c8c..ddfcfe956e0 100644 --- a/packages/rs-platform-wallet/src/changeset/core_bridge.rs +++ b/packages/rs-platform-wallet/src/changeset/core_bridge.rs @@ -1,51 +1,284 @@ -//! Bridge from [`key_wallet_manager::WalletPersistence`] to [`PlatformWalletPersistence`]. +//! Adapter that turns upstream `WalletEvent`s into `PlatformWalletChangeSet`s. //! -//! During SPV block processing, `WalletManager::process_block` accumulates -//! core-wallet changesets (UTXOs, transactions, synced height) and calls -//! `WalletPersistence::store` for each wallet. `CorePersistenceBridge` -//! wraps those calls by embedding the `WalletChangeSet` inside a -//! `PlatformWalletChangeSet { core: Some(cs), .. }` and forwarding to the -//! platform persister, so core-wallet state is persisted through the same -//! pipeline as all other platform wallet state. +//! Upstream `key_wallet_manager` no longer carries a `WalletPersistence` +//! callback — each `WalletManager` exposes a `broadcast::Sender` +//! and consumers subscribe at startup. [`WalletEventAdapter`] is the +//! platform-wallet-side subscriber: a tokio task that drains the event +//! stream, projects each event into a [`CoreChangeSet`], wraps it in a +//! [`PlatformWalletChangeSet`], and forwards to the platform persister. +//! +//! # Why a single subscriber, not per-wallet +//! +//! `WalletManager::subscribe_events` returns a `broadcast::Receiver` that +//! sees every event for every wallet. The adapter routes by `wallet_id` +//! at projection time — there's no need to spawn a task per wallet. +//! +//! # Lifetime +//! +//! [`spawn_wallet_event_adapter`] returns a [`JoinHandle`]. The caller +//! (typically `PlatformWalletManager`) keeps it for the manager's +//! lifetime; on shutdown, fire the [`CancellationToken`] to make the +//! task exit cleanly. use std::sync::Arc; -use key_wallet::changeset::WalletChangeSet; -use key_wallet_manager::{WalletId, WalletPersistence}; +use dashcore::blockdata::transaction::{txout::TxOut, OutPoint}; +use dashcore::ScriptBuf; +use key_wallet::managed_account::transaction_record::{OutputRole, TransactionRecord}; +use key_wallet::transaction_checking::TransactionContext; +use key_wallet::Utxo; +use key_wallet_manager::{WalletEvent, WalletId, WalletManager}; +use tokio::sync::broadcast::error::RecvError; +use tokio::sync::RwLock; +use tokio::task::JoinHandle; +use tokio_util::sync::CancellationToken; -use crate::changeset::changeset::PlatformWalletChangeSet; +use crate::changeset::changeset::{CoreChangeSet, PlatformWalletChangeSet}; use crate::changeset::traits::PlatformWalletPersistence; +use crate::wallet::platform_wallet::PlatformWalletInfo; -/// Bridges [`WalletPersistence`] (dashcore) to [`PlatformWalletPersistence`] (platform-wallet). +/// Spawn the wallet-event subscriber task. /// -/// Wrap a `PlatformWalletPersistence` implementor with this type and pass it to -/// [`key_wallet_manager::WalletManager::new_with_persister`] so that core-wallet -/// changesets produced by SPV block processing are routed through the same -/// persistence pipeline as platform-wallet state. -pub struct CorePersistenceBridge { - inner: Arc, +/// Subscribes to `wallet_manager.subscribe_events()` from inside the +/// spawned task (so the call-site doesn't need to be on a tokio +/// runtime), then loops dispatching events to the persister via +/// [`PlatformWalletPersistence::store`]. Exits when `cancel` fires +/// or the upstream broadcast channel closes. +pub fn spawn_wallet_event_adapter( + wallet_manager: Arc>>, + persister: Arc, + cancel: CancellationToken, +) -> JoinHandle<()> { + tokio::spawn(async move { + let mut receiver = { + let guard = wallet_manager.read().await; + guard.subscribe_events() + }; + tracing::debug!("WalletEventAdapter task started"); + + loop { + tokio::select! { + recv = receiver.recv() => { + match recv { + Ok(event) => { + let wallet_id = event.wallet_id(); + // For events that need to consult per-wallet + // state (today only `TransactionInstantLocked`, + // which checks finality before recording the IS + // lock), grab a brief read lock on the manager. + let core = build_core_changeset(&wallet_manager, &event).await; + if core.is_empty_no_records() { + // SyncHeightAdvanced for an unknown wallet, + // empty BlockProcessed, etc. — nothing to + // persist. Skip the round-trip. + continue; + } + let cs = PlatformWalletChangeSet { + core: Some(core), + ..PlatformWalletChangeSet::default() + }; + if let Err(e) = persister.store(wallet_id, cs) { + tracing::warn!( + wallet_id = %hex::encode(wallet_id), + error = %e, + "Persister rejected core changeset; state will be re-emitted on next sync round" + ); + } + } + Err(RecvError::Closed) if cancel.is_cancelled() => break, + Err(RecvError::Closed) => { + tracing::error!("WalletEvent broadcast closed unexpectedly"); + break; + } + Err(RecvError::Lagged(n)) => { + tracing::warn!( + missed = n, + "WalletEventAdapter lagged on broadcast channel; some events were dropped" + ); + } + } + } + _ = cancel.cancelled() => break, + } + } + tracing::debug!("WalletEventAdapter task exiting"); + }) } -impl CorePersistenceBridge { - pub fn new(inner: Arc) -> Self { - Self { inner } +/// Project an upstream [`WalletEvent`] into a [`CoreChangeSet`] suitable +/// for atomic persistence. +async fn build_core_changeset( + wallet_manager: &Arc>>, + event: &WalletEvent, +) -> CoreChangeSet { + match event { + WalletEvent::TransactionDetected { record, .. } => { + let mut cs = CoreChangeSet::default(); + // Derive UTXO deltas BEFORE moving the record into `records` + // so we still have the per-record borrows. + cs.new_utxos = derive_new_utxos(record); + cs.spent_utxos = derive_spent_utxos(record); + cs.records.push((**record).clone()); + cs + } + WalletEvent::TransactionInstantLocked { + wallet_id, + txid, + instant_lock, + .. + } => { + // IS-lock is informative only for non-final records. If the + // wallet has already chain-locked this txid, drop the lock — + // chain-lock supersedes IS finality. + if is_chain_locked(wallet_manager, wallet_id, txid).await { + return CoreChangeSet::default(); + } + let mut cs = CoreChangeSet::default(); + cs.instant_locks_for_non_final_records + .insert(*txid, instant_lock.clone()); + cs + } + WalletEvent::BlockProcessed { + height, + inserted, + updated, + matured, + .. + } => { + let mut cs = CoreChangeSet::default(); + // Inserted records bring fresh UTXOs and may consume previous ones. + for r in inserted { + cs.new_utxos.extend(derive_new_utxos(r)); + cs.spent_utxos.extend(derive_spent_utxos(r)); + } + // Updated records (re-confirmation, IS-lock applied to a known + // mempool tx, etc.) don't usually change UTXO topology — the + // record's content does change though, so re-emit it. + // Matured coinbase records likewise: no UTXO topology change, + // just a status update for the persister. + cs.records.extend(inserted.iter().cloned()); + cs.records.extend(updated.iter().cloned()); + cs.records.extend(matured.iter().cloned()); + cs.last_processed_height = Some(*height); + cs + } + WalletEvent::SyncHeightAdvanced { height, .. } => CoreChangeSet { + synced_height: Some(*height), + ..CoreChangeSet::default() + }, } } -impl WalletPersistence for CorePersistenceBridge { - fn store( - &self, - wallet_id: WalletId, - cs: WalletChangeSet, - ) -> Result<(), Box> { - let platform_cs = PlatformWalletChangeSet { - core: Some(cs), - ..PlatformWalletChangeSet::default() - }; - // Upstream `key_wallet_manager::WalletPersistence` still - // uses the `Box` error type; our inner trait - // returns `PersistenceError`. Box it on the way out so the - // bridge still conforms. - self.inner.store(wallet_id, platform_cs).map_err(Into::into) +/// Returns `true` when the wallet's stored record for `txid` is in a +/// chain-locked block. Used to gate IS-lock projection. +async fn is_chain_locked( + wallet_manager: &Arc>>, + wallet_id: &WalletId, + txid: &dashcore::Txid, +) -> bool { + let guard = wallet_manager.read().await; + let Some(info) = guard.get_wallet_info(wallet_id) else { + return false; + }; + for account in info.core_wallet.accounts.all_accounts() { + if let Some(record) = account.transactions.get(txid) { + return matches!(record.context, TransactionContext::InChainLockedBlock(_)); + } + } + false +} + +/// Derive the "ours" UTXOs created by a transaction's outputs. +/// +/// Walks `record.output_details`, keeps entries with role `Received` or +/// `Change`, and reconstructs a full `Utxo` from the corresponding +/// `transaction.output[index]` plus the record's confirmation context. +fn derive_new_utxos(record: &TransactionRecord) -> Vec { + let height = record.context.block_info().map(|b| b.height()).unwrap_or(0); + let is_confirmed = matches!( + record.context, + TransactionContext::InBlock(_) | TransactionContext::InChainLockedBlock(_) + ); + let is_instant = matches!(record.context, TransactionContext::InstantSend(_)); + let is_coinbase = record.transaction.is_coin_base(); + + record + .output_details + .iter() + .filter_map(|detail| { + if !matches!(detail.role, OutputRole::Received | OutputRole::Change) { + return None; + } + let txout = record + .transaction + .output + .get(detail.index as usize)? + .clone(); + let address = detail.address.clone()?; + Some(Utxo { + outpoint: OutPoint { + txid: record.txid, + vout: detail.index, + }, + txout, + address, + height, + is_coinbase, + is_confirmed, + is_instantlocked: is_instant, + is_locked: false, + }) + }) + .collect() +} + +/// Derive the "ours" UTXOs spent by a transaction's inputs. +/// +/// Walks `record.input_details` (the entries keyed to inputs that spent +/// our outpoints) and synthesizes a `Utxo` per entry using the data we +/// have: the outpoint from `transaction.input[index].previous_output`, +/// the value and address from `InputDetail`. The script_pubkey, height, +/// and confirmation flags belong to the *previous* transaction's +/// output and aren't carried in `InputDetail`; they're filled with +/// defaults (`ScriptBuf::default()`, height 0, all flags false). The +/// persister deletes by `outpoint` so the missing fields are +/// informational only — they never affect correctness of the spent-set +/// removal, only the audit-trail richness on the way out. +fn derive_spent_utxos(record: &TransactionRecord) -> Vec { + record + .input_details + .iter() + .filter_map(|detail| { + let input = record.transaction.input.get(detail.index as usize)?; + Some(Utxo { + outpoint: input.previous_output, + txout: TxOut { + value: detail.value, + script_pubkey: ScriptBuf::default(), + }, + address: detail.address.clone(), + height: 0, + is_coinbase: false, + is_confirmed: false, + is_instantlocked: false, + is_locked: false, + }) + }) + .collect() +} + +impl CoreChangeSet { + /// Cheap "should we bother round-tripping the persister" check used + /// by the adapter to drop empty events without locking. Skips the + /// `is_empty()` walk over `instant_locks_for_non_final_records` + /// since that map is rarely populated and `Vec::is_empty` short- + /// circuits on the common case. + fn is_empty_no_records(&self) -> bool { + self.records.is_empty() + && self.spent_utxos.is_empty() + && self.new_utxos.is_empty() + && self.instant_locks_for_non_final_records.is_empty() + && self.last_processed_height.is_none() + && self.synced_height.is_none() } } diff --git a/packages/rs-platform-wallet/src/changeset/mod.rs b/packages/rs-platform-wallet/src/changeset/mod.rs index 1e6ba2fb586..9cc2f559a51 100644 --- a/packages/rs-platform-wallet/src/changeset/mod.rs +++ b/packages/rs-platform-wallet/src/changeset/mod.rs @@ -19,14 +19,15 @@ pub mod platform_address_sync_start_state; pub mod traits; pub use changeset::{ - AssetLockChangeSet, AssetLockEntry, ContactChangeSet, ContactRequestEntry, IdentityChangeSet, - IdentityEntry, IdentityKeyDerivationIndices, IdentityKeyEntry, IdentityKeysChangeSet, - PlatformAddressBalanceEntry, PlatformAddressChangeSet, PlatformWalletChangeSet, - ReceivedContactRequestKey, SentContactRequestKey, TokenBalanceChangeSet, + AssetLockChangeSet, AssetLockEntry, ContactChangeSet, ContactRequestEntry, CoreChangeSet, + IdentityChangeSet, IdentityEntry, IdentityKeyDerivationIndices, IdentityKeyEntry, + IdentityKeysChangeSet, PlatformAddressBalanceEntry, PlatformAddressChangeSet, + PlatformWalletChangeSet, ReceivedContactRequestKey, SentContactRequestKey, + TokenBalanceChangeSet, }; pub use client_start_state::ClientStartState; pub use client_wallet_start_state::ClientWalletStartState; -pub use core_bridge::CorePersistenceBridge; +pub use core_bridge::spawn_wallet_event_adapter; pub use identity_manager_start_state::IdentityManagerStartState; pub use merge::Merge; pub use platform_address_sync_start_state::PlatformAddressSyncStartState; diff --git a/packages/rs-platform-wallet/src/manager/mod.rs b/packages/rs-platform-wallet/src/manager/mod.rs index 55520284a54..446830cc99d 100644 --- a/packages/rs-platform-wallet/src/manager/mod.rs +++ b/packages/rs-platform-wallet/src/manager/mod.rs @@ -7,10 +7,12 @@ mod wallet_lifecycle; use std::sync::Arc; use tokio::sync::{Notify, RwLock}; +use tokio::task::JoinHandle; +use tokio_util::sync::CancellationToken; use key_wallet_manager::WalletManager; -use crate::changeset::{CorePersistenceBridge, PlatformWalletPersistence}; +use crate::changeset::{spawn_wallet_event_adapter, PlatformWalletPersistence}; use crate::events::{PlatformEventHandler, PlatformEventManager}; use crate::platform_address_sync::PlatformAddressSyncManager; use crate::spv::SpvRuntime; @@ -39,6 +41,11 @@ pub struct PlatformWalletManager { /// Not auto-started — call `start` after wallets are registered. pub(super) platform_address_sync: Arc, pub(super) persister: Arc

, + /// Cancellation token + join handle for the wallet-event adapter + /// task. Held so [`shutdown`] can stop it cleanly when the manager + /// is torn down. + pub(super) event_adapter_cancel: CancellationToken, + pub(super) event_adapter_join: tokio::sync::Mutex>>, } impl PlatformWalletManager

{ @@ -52,19 +59,27 @@ impl PlatformWalletManager

{ persister: Arc

, app_handler: Arc, ) -> Self { - // `PlatformWallet` / `CorePersistenceBridge` / `WalletPersister` - // still take `Arc`; coerce once - // here and pass clones along instead of re-erasing at every - // call site. + // `PlatformWallet` / `WalletPersister` and the new wallet-event + // adapter all consume `Arc`; + // coerce once here and pass clones along instead of re-erasing + // at every call site. let dyn_persister: Arc = Arc::clone(&persister) as _; - let core_bridge = Arc::new(CorePersistenceBridge::new(Arc::clone(&dyn_persister))); - let wallet_manager = Arc::new(RwLock::new(WalletManager::new_with_persister( - sdk.network, - core_bridge, - ))); + let wallet_manager = Arc::new(RwLock::new(WalletManager::new(sdk.network))); let wallets = Arc::new(RwLock::new(std::collections::BTreeMap::new())); let lock_notify = Arc::new(Notify::new()); + // Spawn the wallet-event adapter that translates upstream + // `WalletEvent`s into `CoreChangeSet`s and forwards them to + // the persister. Replaces the old `CorePersistenceBridge` + // pattern (upstream `WalletPersistence` callback was deleted + // in favour of an event bus — see rust-dashcore PR #696). + let event_adapter_cancel = CancellationToken::new(); + let event_adapter_join = spawn_wallet_event_adapter( + Arc::clone(&wallet_manager), + Arc::clone(&dyn_persister), + event_adapter_cancel.clone(), + ); + // Build handler list: app handler + internal handlers. // BalanceUpdateHandler holds a clone of the wallets map (a // separate lock from wallet_manager) so it can look up @@ -95,6 +110,23 @@ impl PlatformWalletManager

{ spv, platform_address_sync, persister, + event_adapter_cancel, + event_adapter_join: tokio::sync::Mutex::new(Some(event_adapter_join)), + } + } + + /// Stop the wallet-event adapter task and wait for it to exit. + /// + /// Idempotent. After this returns, no further `WalletEvent`s will + /// be projected to the persister. Call before dropping the manager + /// when a clean shutdown is required (e.g. on app termination); a + /// dirty drop simply leaks the task until the runtime exits. + pub async fn shutdown(&self) { + self.event_adapter_cancel.cancel(); + if let Some(handle) = self.event_adapter_join.lock().await.take() { + if let Err(e) = handle.await { + tracing::warn!(error = ?e, "Wallet event adapter task join error"); + } } } } diff --git a/packages/rs-platform-wallet/src/spv/runtime.rs b/packages/rs-platform-wallet/src/spv/runtime.rs index da7eac1a85a..eecb0e58607 100644 --- a/packages/rs-platform-wallet/src/spv/runtime.rs +++ b/packages/rs-platform-wallet/src/spv/runtime.rs @@ -11,7 +11,7 @@ use tokio_util::sync::CancellationToken; use dash_spv::network::PeerNetworkManager; use dash_spv::storage::DiskStorageManager; use dash_spv::sync::SyncProgress; -use dash_spv::{ClientConfig, DashSpvClient, Hash}; +use dash_spv::{ClientConfig, DashSpvClient, EventHandler, Hash}; use key_wallet_manager::WalletManager; @@ -19,12 +19,8 @@ use crate::error::PlatformWalletError; use crate::events::PlatformEventManager; use crate::wallet::platform_wallet::PlatformWalletInfo; -type SpvClient = DashSpvClient< - WalletManager, - PeerNetworkManager, - DiskStorageManager, - PlatformEventManager, ->; +type SpvClient = + DashSpvClient, PeerNetworkManager, DiskStorageManager>; /// SPV client runtime — owns the `DashSpvClient` and drives sync. /// @@ -70,12 +66,19 @@ impl SpvRuntime { .await .map_err(|e| PlatformWalletError::SpvError(e.to_string()))?; + // PlatformEventManager implements `EventHandler`; pass it as the + // sole entry in the SPV client's handler vec. Additional dyn + // handlers can be added here if other components need to observe + // raw SPV events directly (today everything routes through the + // platform event manager's own handler list). + let event_handlers: Vec> = + vec![Arc::clone(&self.event_manager) as Arc]; let spv_client = DashSpvClient::new( config, network_manager, storage_manager, Arc::clone(&self.wallet_manager), - Arc::clone(&self.event_manager), + event_handlers, ) .await .map_err(|e| PlatformWalletError::SpvError(e.to_string()))?; diff --git a/packages/rs-platform-wallet/src/wallet/apply.rs b/packages/rs-platform-wallet/src/wallet/apply.rs index cd2d7540c7d..57b23d7b39c 100644 --- a/packages/rs-platform-wallet/src/wallet/apply.rs +++ b/packages/rs-platform-wallet/src/wallet/apply.rs @@ -95,17 +95,18 @@ impl PlatformWalletInfo { dashpay_payments_overlay, } = cs; - // 1. Core wallet state — chain, accounts, UTXOs, transactions, - // addresses used, highest_used. Must run first so the per- - // account buckets exist before anything platform-side - // references them. The core changeset is moved by value - // into key-wallet's apply path; key-wallet itself drains - // the per-account buckets without clones. - if let Some(core) = core { - self.core_wallet - .apply_changeset(wallet, core) - .map_err(|e| ApplyError::CoreApply(e.to_string()))?; - } + // 1. Core wallet state. In the new event-bus model, a + // `CoreChangeSet` flows OUT (event adapter → persister) but + // is never replayed back IN through this apply path — + // upstream `key_wallet`'s `process_block` keeps the + // in-memory `ManagedWalletInfo` up to date at runtime, and + // boot-time state restoration goes through + // `ClientStartState` (the persister's `load()` payload), + // not through changeset replay. The core field on `cs` is + // therefore informational here and intentionally not + // applied; we drop it explicitly so future readers don't + // expect a re-application path that no longer exists. + drop(core); // 2. Identities. if let Some(id_cs) = identities { diff --git a/packages/rs-platform-wallet/src/wallet/asset_lock/build.rs b/packages/rs-platform-wallet/src/wallet/asset_lock/build.rs index aebc87f170b..1fadb3fb290 100644 --- a/packages/rs-platform-wallet/src/wallet/asset_lock/build.rs +++ b/packages/rs-platform-wallet/src/wallet/asset_lock/build.rs @@ -241,7 +241,7 @@ impl AssetLockManager { // address is NOT marked as used yet — that happens inside the // builder after a successful transaction build. managed_account - .next_address(account_xpub.as_ref()) + .next_address(account_xpub.as_ref(), false) .map_err(|e| { PlatformWalletError::AssetLockTransaction(format!( "Failed to get next funding address: {}", diff --git a/packages/rs-platform-wallet/src/wallet/core/balance_handler.rs b/packages/rs-platform-wallet/src/wallet/core/balance_handler.rs index 5894bb9d385..fdf9120add2 100644 --- a/packages/rs-platform-wallet/src/wallet/core/balance_handler.rs +++ b/packages/rs-platform-wallet/src/wallet/core/balance_handler.rs @@ -1,5 +1,5 @@ //! Event handler that updates lock-free `WalletBalance` atomics -//! when `WalletEvent::BalanceUpdated` fires. +//! when an upstream `WalletEvent` carries a fresh balance snapshot. use std::collections::BTreeMap; use std::sync::Arc; @@ -11,18 +11,24 @@ use crate::events::{PlatformEventHandler, WalletEvent}; use crate::wallet::platform_wallet::WalletId; use crate::wallet::PlatformWallet; -/// Updates `PlatformWallet`'s lock-free `WalletBalance` atomics on -/// `BalanceUpdated` events. +/// Updates `PlatformWallet`'s lock-free `WalletBalance` atomics when an +/// upstream `WalletEvent` carries a balance snapshot. /// -/// Registered in `PlatformWalletManager` handler list. The handler +/// Upstream's atomic event design embeds the post-change `WalletCoreBalance` +/// in every variant that mutates balance — `TransactionDetected`, +/// `TransactionInstantLocked`, and `BlockProcessed`. `SyncHeightAdvanced` +/// alone has no balance (it's a checkpoint marker, not a state change). +/// This handler routes each balance-bearing event into the per-wallet +/// `WalletBalance` atomics so SwiftUI subscribers see the new totals. +/// +/// Registered in `PlatformWalletManager`'s handler list. The handler /// holds an `Arc` clone of the manager's `wallets` map (a *separate* /// lock from the heavily-contended `wallet_manager` SPV write lock). /// SPV holds the wallet-manager write lock for the entire duration of /// block processing — looking the balance up through *that* lock would /// silently lose every event during initial sync. The wallets map is /// only written by manager lifecycle methods (`create_wallet_from_*`, -/// `remove_wallet`), so a `try_read()` here essentially never -/// contends. +/// `remove_wallet`), so a `try_read()` here essentially never contends. pub struct BalanceUpdateHandler { wallets: Arc>>>, } @@ -35,28 +41,37 @@ impl BalanceUpdateHandler { impl EventHandler for BalanceUpdateHandler { fn on_wallet_event(&self, event: &WalletEvent) { - if let WalletEvent::BalanceUpdated { - wallet_id, - confirmed, - unconfirmed, - immature, - locked, - } = event - { - // try_read on the wallets map (NOT the wallet_manager - // SPV-contended lock). The map is only written by manager - // lifecycle methods, so this almost never contends. - let Ok(wallets) = self.wallets.try_read() else { - tracing::debug!( - wallet = %hex::encode(wallet_id), - "BalanceUpdated dropped: wallets-map lock contended" - ); - return; - }; - if let Some(pw) = wallets.get(wallet_id) { - pw.balance() - .set(*confirmed, *unconfirmed, *immature, *locked); + let (wallet_id, balance) = match event { + WalletEvent::TransactionDetected { + wallet_id, balance, .. + } + | WalletEvent::TransactionInstantLocked { + wallet_id, balance, .. } + | WalletEvent::BlockProcessed { + wallet_id, balance, .. + } => (wallet_id, balance), + // No balance on SyncHeightAdvanced — checkpoint advance only. + WalletEvent::SyncHeightAdvanced { .. } => return, + }; + + // try_read on the wallets map (NOT the wallet_manager + // SPV-contended lock). The map is only written by manager + // lifecycle methods, so this almost never contends. + let Ok(wallets) = self.wallets.try_read() else { + tracing::debug!( + wallet = %hex::encode(wallet_id), + "Wallet balance update dropped: wallets-map lock contended" + ); + return; + }; + if let Some(pw) = wallets.get(wallet_id) { + pw.balance().set( + balance.confirmed(), + balance.unconfirmed(), + balance.immature(), + balance.locked(), + ); } } } diff --git a/packages/rs-platform-wallet/src/wallet/core/broadcast.rs b/packages/rs-platform-wallet/src/wallet/core/broadcast.rs index b81bb988909..10578a7a682 100644 --- a/packages/rs-platform-wallet/src/wallet/core/broadcast.rs +++ b/packages/rs-platform-wallet/src/wallet/core/broadcast.rs @@ -104,7 +104,7 @@ impl CoreWallet { })?; let change_addr = change_account - .next_change_address(Some(&xpub)) + .next_change_address(Some(&xpub), true) .map_err(|e| PlatformWalletError::TransactionBuild(e.to_string()))?; builder = builder.set_change_address(change_addr); diff --git a/packages/rs-platform-wallet/src/wallet/core/wallet.rs b/packages/rs-platform-wallet/src/wallet/core/wallet.rs index 8b87bda2483..5a29db29002 100644 --- a/packages/rs-platform-wallet/src/wallet/core/wallet.rs +++ b/packages/rs-platform-wallet/src/wallet/core/wallet.rs @@ -92,7 +92,7 @@ impl CoreWallet { })?; account - .next_receive_address(Some(&xpub)) + .next_receive_address(Some(&xpub), true) .map_err(|e| PlatformWalletError::AddressOperation(e.to_string())) } @@ -133,7 +133,7 @@ impl CoreWallet { })?; account - .next_receive_address(Some(&xpub)) + .next_receive_address(Some(&xpub), true) .map_err(|e| PlatformWalletError::AddressOperation(e.to_string())) } @@ -174,7 +174,7 @@ impl CoreWallet { })?; account - .next_change_address(Some(&xpub)) + .next_change_address(Some(&xpub), true) .map_err(|e| PlatformWalletError::AddressOperation(e.to_string())) } @@ -215,7 +215,7 @@ impl CoreWallet { })?; account - .next_change_address(Some(&xpub)) + .next_change_address(Some(&xpub), true) .map_err(|e| PlatformWalletError::AddressOperation(e.to_string())) } 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 79bc249f8d4..5186cd5fc61 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/network/payments.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/network/payments.rs @@ -165,7 +165,7 @@ impl IdentityWallet { })?; let payment_address = external_account - .next_address(Some(&contact_xpub)) + .next_address(Some(&contact_xpub), true) .map_err(|e| PlatformWalletError::TransactionBuild(e.to_string()))?; // --- 2. Build the transaction from BIP-44 account 0 UTXOs. --- @@ -208,7 +208,7 @@ impl IdentityWallet { "BIP-44 managed account 0 not found for change".to_string(), ) })? - .next_change_address(Some(&bip44_xpub)) + .next_change_address(Some(&bip44_xpub), true) .map_err(|e| PlatformWalletError::TransactionBuild(e.to_string()))?; let mut builder = TransactionBuilder::new(); diff --git a/packages/rs-platform-wallet/src/wallet/platform_addresses/wallet.rs b/packages/rs-platform-wallet/src/wallet/platform_addresses/wallet.rs index 2b9ad447eeb..7c618aaf0d5 100644 --- a/packages/rs-platform-wallet/src/wallet/platform_addresses/wallet.rs +++ b/packages/rs-platform-wallet/src/wallet/platform_addresses/wallet.rs @@ -218,7 +218,7 @@ impl PlatformAddressWallet { let address = managed_account .addresses - .next_unused(&key_source) + .next_unused(&key_source, true) .map_err(|e| PlatformWalletError::AddressSync(e.to_string()))?; PlatformAddress::try_from(address).map_err(|e| { 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 103c9a46bb6..f769637dea0 100644 --- a/packages/rs-platform-wallet/src/wallet/platform_wallet_traits.rs +++ b/packages/rs-platform-wallet/src/wallet/platform_wallet_traits.rs @@ -108,12 +108,16 @@ impl WalletInfoInterface for PlatformWalletInfo { self.core_wallet.utxos() } + fn get_spendable_utxos(&self) -> BTreeSet<&Utxo> { + self.core_wallet.get_spendable_utxos() + } + fn balance(&self) -> WalletCoreBalance { self.core_wallet.balance() } - fn update_balance(&mut self) -> key_wallet::changeset::WalletChangeSet { - self.core_wallet.update_balance() + fn update_balance(&mut self) { + self.core_wallet.update_balance(); } fn transaction_history(&self) -> Vec<&TransactionRecord> { @@ -132,19 +136,33 @@ impl WalletInfoInterface for PlatformWalletInfo { self.core_wallet.immature_transactions() } + fn last_processed_height(&self) -> CoreBlockHeight { + self.core_wallet.last_processed_height() + } + fn synced_height(&self) -> CoreBlockHeight { self.core_wallet.synced_height() } + fn update_last_processed_height(&mut self, current_height: u32) { + self.core_wallet + .update_last_processed_height(current_height); + } + fn update_synced_height(&mut self, current_height: u32) { self.core_wallet.update_synced_height(current_height); } - fn mark_instant_send_utxos( - &mut self, - txid: &Txid, - lock: &InstantLock, - ) -> (bool, key_wallet::changeset::WalletChangeSet) { + fn matured_coinbase_records( + &self, + old_height: CoreBlockHeight, + new_height: CoreBlockHeight, + ) -> Vec { + self.core_wallet + .matured_coinbase_records(old_height, new_height) + } + + fn mark_instant_send_utxos(&mut self, txid: &Txid, lock: &InstantLock) -> bool { self.core_wallet.mark_instant_send_utxos(txid, lock) } diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/KeyWallet/KeyWalletTypes.swift b/packages/swift-sdk/Sources/SwiftDashSDK/KeyWallet/KeyWalletTypes.swift index 0d386c834cb..6813934fd5b 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/KeyWallet/KeyWalletTypes.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/KeyWallet/KeyWalletTypes.swift @@ -46,11 +46,20 @@ public enum AccountType: UInt32 { case providerOperatorKeys = 9 case providerPlatformKeys = 10 - var ffiValue: FFIAccountType { - FFIAccountType(rawValue: self.rawValue) + /// Convert to the upstream FFI discriminant enum. + /// + /// Upstream renamed the FFI account discriminant from `FFIAccountType` + /// (the old enum) to `FFIAccountKind` (the new enum) and reused the + /// `FFIAccountType` name for a richer struct that bundles the + /// discriminant with index / Dashpay-pointer / key-class fields. + /// The Swift `AccountType` enum here only models the discriminant + /// case — Dashpay and PlatformPayment variants need richer + /// construction paths and aren't surfaced through this type today. + var ffiValue: FFIAccountKind { + FFIAccountKind(rawValue: self.rawValue) } - init(ffiType: FFIAccountType) { + init(ffiType: FFIAccountKind) { self = AccountType(rawValue: ffiType.rawValue) ?? .standardBIP44 } } diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentTransaction.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentTransaction.swift index 62093418ede..a4cd12cef56 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentTransaction.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentTransaction.swift @@ -59,6 +59,15 @@ public final class PersistentTransaction { /// Parent account. public var account: PersistentAccount? + /// UTXOs created by this transaction's outputs. + /// + /// Cascade-deletes the matching `PersistentUtxo` rows when the + /// transaction is removed — UTXOs cannot meaningfully exist + /// without their containing transaction (the outpoint, script, + /// amount, and address are all derived from it). + @Relationship(deleteRule: .cascade, inverse: \PersistentUtxo.transaction) + public var utxos: [PersistentUtxo] = [] + public init( txid: String, walletId: Data = Data(), diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentUtxo.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentUtxo.swift index c2f9472d4d3..7f0f6846f5f 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentUtxo.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentUtxo.swift @@ -4,13 +4,15 @@ import SwiftData /// SwiftData model for persisting an unspent transaction output. /// /// Represents a single UTXO that can be spent by the wallet. -/// Linked to its parent account for cascade deletion. +/// Linked to its containing transaction (cascade-delete) and its +/// parent account. @Model public final class PersistentUtxo { - /// Outpoint: txid hex + ":" + vout index (unique identifier). + /// Outpoint: `txid hex + ":" + vout index` (unique identifier). + /// Stored explicitly so SwiftData predicate fetches can hit a + /// single column without traversing the `transaction` relationship. + /// Always equals `"\(transaction.txid):\(vout)"`. @Attribute(.unique) public var outpoint: String - /// Transaction ID (32-byte hash as hex). - public var txid: String /// Output index within the transaction. public var vout: UInt32 /// Value in duffs. @@ -35,19 +37,25 @@ public final class PersistentUtxo { public var createdAt: Date public var lastUpdated: Date + /// Containing transaction. Cascade-deleted from the parent side + /// (see `PersistentTransaction.utxos`). Optional only because the + /// underlying SwiftData inverse must allow nil during the brief + /// window between row insert and relationship attachment; in + /// steady state every UTXO has a non-nil `transaction`. + public var transaction: PersistentTransaction? + /// Parent account. public var account: PersistentAccount? public init( - txid: String, + transaction: PersistentTransaction, vout: UInt32, amount: UInt64, address: String, scriptPubKey: Data = Data(), height: UInt32 = 0 ) { - self.outpoint = "\(txid):\(vout)" - self.txid = txid + self.outpoint = "\(transaction.txid):\(vout)" self.vout = vout self.amount = amount self.address = address @@ -60,6 +68,14 @@ public final class PersistentUtxo { self.isSpent = false self.createdAt = Date() self.lastUpdated = Date() + self.transaction = transaction + } + + /// Convenience accessor for the containing transaction's txid. + /// Returns the empty string if the relationship isn't attached + /// (which should only happen briefly during construction). + public var txid: String { + transaction?.txid ?? "" } public var formattedAmount: String { diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift index 6bad559e33c..72a086d56e9 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift @@ -351,13 +351,31 @@ public class PlatformWalletPersistenceHandler { if let existing = try? backgroundContext.fetch(descriptor).first { record = existing } else { + // Look up the containing transaction. Upstream sends the + // transaction record before its UTXOs in the same flush, + // so it should already be in the context. If not, create + // a stub keyed by txid so the cascade-delete invariant + // (UTXO cannot exist without its transaction) holds; the + // real record will overwrite the stub when it arrives. + let txDescriptor = FetchDescriptor( + predicate: #Predicate { $0.txid == txidHex } + ) + let parentTx: PersistentTransaction + if let existingTx = try? backgroundContext.fetch(txDescriptor).first { + parentTx = existingTx + } else { + parentTx = PersistentTransaction(txid: txidHex) + parentTx.account = account + backgroundContext.insert(parentTx) + } + let script: Data = { guard let p = utxo.script_pubkey, utxo.script_pubkey_len > 0 else { return Data() } return Data(bytes: p, count: Int(utxo.script_pubkey_len)) }() let addressStr = utxo.address.map { String(cString: $0) } ?? "" record = PersistentUtxo( - txid: txidHex, + transaction: parentTx, vout: utxo.outpoint.vout, amount: utxo.amount, address: addressStr,