Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
ef7cdf2
chore(deps): bump rust-dashcore to dev head 78d10022
lklimek Jul 1, 2026
27d235f
fix(dpp): implement extended_public_key on shielded FixedKeySigner te…
lklimek Jul 1, 2026
36e2fed
chore(deps): bump rust-dashcore to PR #833 (Zeroize for ExtendedPrivKey)
lklimek Jul 1, 2026
3be6885
refactor(rs-sdk-ffi): wrap ExtendedPrivKey in Zeroizing, drop manual …
lklimek Jul 1, 2026
b175d50
refactor(rs-sdk-ffi): confine ExtendedPrivKey to resolve_and_derive v…
lklimek Jul 1, 2026
5a2b2b5
test(rs-sdk-ffi): make extended_public_key metadata asserts load-bear…
lklimek Jul 1, 2026
56612fd
chore(deps): bump rust-dashcore to a8c57fe (drop Copy, zeroize Extend…
lklimek Jul 1, 2026
374df98
refactor(rs-sdk-ffi): rely on ExtendedPrivKey Drop, drop redundant Ze…
lklimek Jul 1, 2026
35e5c46
Merge remote-tracking branch 'origin/v4.1-dev' into chore/bump-rust-d…
lklimek Jul 2, 2026
1aac44a
fix(deps): stage regenerated Cargo.lock for rust-dashcore a8a0968 bump
lklimek Jul 2, 2026
defa249
build: sync Cargo.lock to rust-dashcore a8a0968 (0.45.0)
lklimek Jul 2, 2026
f669691
docs(rs-sdk-ffi): correct ExtendedPrivKey zeroization comments for a8…
lklimek Jul 2, 2026
43b57af
Merge commit 'f669691f7a22f50f4fab7a056036a53ebf781b9b' into chore/bu…
lklimek Jul 2, 2026
0259bed
chore(deps): bump rust-dashcore to afcff156
lklimek Jul 2, 2026
0582e3c
feat(rs-sdk-ffi): export extended pubkey via ExtendedPubKeySigner
lklimek Jul 2, 2026
f9c49ed
fix(platform-wallet): release UTXO reservation when broadcast fails
lklimek Jul 2, 2026
eb54b37
fix(platform-wallet): refine broadcast-failure reservation release
lklimek Jul 3, 2026
c81f9d2
Merge branch 'v4.1-dev' into fix/core-wallet-release-reservation-on-b…
lklimek Jul 3, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions packages/rs-platform-wallet/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,10 @@ required-features = ["shielded"]
# `tests/shielded_decrypt_bench.rs` to assemble per-chunk wire
# fixtures and decode the `ShieldedEncryptedNote` wire type.
drive-proof-verifier = { path = "../rs-drive-proof-verifier" }
# `test-utils` exposes key-wallet's `TestWalletContext` + `Address::dummy`
# (via `dashcore/test-utils`) for building funded, signable test wallets.
# Dev-only, so the production build stays on the leaner default features.
key-wallet = { workspace = true, features = ["test-utils"] }
rand = "0.8"
# Drives the parallel decrypt benchmark in `shielded_decrypt_bench.rs`.
rayon = "1.10"
Expand Down
2 changes: 2 additions & 0 deletions packages/rs-platform-wallet/src/broadcaster.rs
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@ use crate::spv::SpvRuntime;
/// Implementations may use DAPI (gRPC), SPV (P2P peers), or Core RPC.
#[async_trait]
pub trait TransactionBroadcaster: Send + Sync {
/// Contract: `Err` must mean the transaction was **not** accepted by the
/// network, so callers may safely release its reserved inputs and retry.
Comment on lines +23 to +24

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Align the trait contract with the conservative release semantics.

The docs say every Err is safe to retry, but send_to_addresses intentionally keeps reservations for ambiguous/non-pre-send errors. Please document the two error classes explicitly so broadcaster implementations do not collapse post-submit uncertainty into a “safe retry” error.

Suggested wording
-    /// Contract: `Err` must mean the transaction was **not** accepted by the
-    /// network, so callers may safely release its reserved inputs and retry.
+    /// Contract: errors classified as pre-send must mean the transaction was
+    /// **not** accepted by the network, so callers may release reserved inputs
+    /// and retry. If acceptance is ambiguous/possible, implementations must
+    /// surface an error variant callers treat conservatively and must not
+    /// release.
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
/// Contract: `Err` must mean the transaction was **not** accepted by the
/// network, so callers may safely release its reserved inputs and retry.
/// Contract: errors classified as pre-send must mean the transaction was
/// **not** accepted by the network, so callers may release reserved inputs
/// and retry. If acceptance is ambiguous/possible, implementations must
/// surface an error variant callers treat conservatively and must not
/// release.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/rs-platform-wallet/src/broadcaster.rs` around lines 23 - 24, Update
the trait-level docs in the broadcaster contract so they no longer imply every
Err is safe to retry; instead, explicitly distinguish between pre-send failures
where the transaction was not accepted and callers may release reservations, and
ambiguous or post-submit failures where reservations must be kept. Use the
send_to_addresses semantics as the reference point, and make sure broadcaster
implementers understand not to collapse uncertain errors into the safe-retry
class.

async fn broadcast(&self, transaction: &Transaction) -> Result<Txid, PlatformWalletError>;
}

Expand Down
332 changes: 331 additions & 1 deletion packages/rs-platform-wallet/src/wallet/core/broadcast.rs
Original file line number Diff line number Diff line change
@@ -1,11 +1,53 @@
use dashcore::{Address as DashAddress, Transaction};
use key_wallet::account::account_type::StandardAccountType;
use key_wallet::managed_account::managed_account_trait::ManagedAccountTrait;
use key_wallet::managed_account::ManagedCoreFundsAccount;
use key_wallet::signer::Signer;

use crate::broadcaster::TransactionBroadcaster;
use crate::wallet::platform_wallet::PlatformWalletInfo;
use crate::{CoreWallet, PlatformWalletError};

/// Funds account for `account_type`/`account_index`, or `None` if it is not
/// present.
fn funds_account(
info: &PlatformWalletInfo,
account_type: StandardAccountType,
account_index: u32,
) -> Option<&ManagedCoreFundsAccount> {
match account_type {
StandardAccountType::BIP44Account => info
.core_wallet
.accounts
.standard_bip44_accounts
.get(&account_index),
StandardAccountType::BIP32Account => info
.core_wallet
.accounts
.standard_bip32_accounts
.get(&account_index),
}
}

/// Whether a failed broadcast is unambiguously *pre-send* — the transaction was
/// rejected by the submission endpoint or the local transport and never
/// accepted by the network — so its reserved inputs are safe to release for an
/// immediate retry.
///
/// Backed by the [`TransactionBroadcaster::broadcast`] contract that `Err`
/// means "not accepted by the network". Only the variants a broadcaster emits
/// for a rejected submission are listed; anything else — notably the
/// `*Unconfirmed` results that signal the transaction *may* already have reached
/// the network, and any unforeseen future variant — is treated conservatively
/// as *possibly accepted*, keeping the reservation for the TTL backstop to
/// reclaim rather than risking a double-spend on retry.
fn broadcast_failure_is_pre_send(error: &PlatformWalletError) -> bool {
matches!(
error,
PlatformWalletError::TransactionBroadcast(_) | PlatformWalletError::SpvError(_)
)
}

impl<B: TransactionBroadcaster + ?Sized> CoreWallet<B> {
/// Broadcast a signed transaction to the network.
///
Expand Down Expand Up @@ -142,7 +184,295 @@ impl<B: TransactionBroadcaster + ?Sized> CoreWallet<B> {
tx
};

self.broadcast_transaction(&tx).await?;
if let Err(e) = self.broadcast_transaction(&tx).await {
// Release the abandoned build's reserved inputs so an immediate retry
// can reselect them, but only when the failure is unambiguously
// pre-send (see `broadcast_failure_is_pre_send`). `release_reservation`
// takes `&self` and the manager map is untouched, so a read lock
// suffices — this cleanup does not serialize concurrent sends.
if broadcast_failure_is_pre_send(&e) {
let wm = self.wallet_manager.read().await;
match wm.get_wallet_and_info(&self.wallet_id) {
Some((_, info)) => match funds_account(info, account_type, account_index) {
Some(managed_account) => managed_account.release_reservation(&tx),
None => tracing::warn!(
wallet_id = %hex::encode(self.wallet_id),
?account_type,
account_index,
"could not release UTXO reservation after failed broadcast: \
funds account not found"
),
},
None => tracing::warn!(
wallet_id = %hex::encode(self.wallet_id),
"could not release UTXO reservation after failed broadcast: \
wallet not found"
),
}
}
return Err(e);
}
Comment thread
lklimek marked this conversation as resolved.
Comment thread
lklimek marked this conversation as resolved.
Ok(tx)
}
}

#[cfg(test)]
mod tests {
use std::collections::BTreeMap;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;

use async_trait::async_trait;
use dashcore::secp256k1::{ecdsa, Message, PublicKey, Secp256k1};
use dashcore::{Address as DashAddress, Network, Transaction, Txid};
use key_wallet::account::account_type::StandardAccountType;
use key_wallet::signer::{Signer, SignerMethod};
use key_wallet::test_utils::TestWalletContext;
use key_wallet::{DerivationPath, Wallet};
use key_wallet_manager::WalletManager;
use tokio::sync::RwLock;

use crate::broadcaster::TransactionBroadcaster;
use crate::wallet::core::{CoreWallet, WalletBalance};
use crate::wallet::identity::IdentityManager;
use crate::wallet::platform_wallet::PlatformWalletInfo;
use crate::PlatformWalletError;

/// Broadcaster that fails its first call and succeeds afterwards, to model
/// a transient broadcast error followed by a user retry.
struct FailFirstBroadcaster {
failed_once: AtomicBool,
}

impl FailFirstBroadcaster {
fn new() -> Self {
Self {
failed_once: AtomicBool::new(false),
}
}
}

#[async_trait]
impl TransactionBroadcaster for FailFirstBroadcaster {
async fn broadcast(&self, transaction: &Transaction) -> Result<Txid, PlatformWalletError> {
if self.failed_once.swap(true, Ordering::SeqCst) {
Ok(transaction.txid())
} else {
Err(PlatformWalletError::TransactionBroadcast(
"simulated broadcast failure".to_string(),
))
}
}
}

/// Broadcaster that always fails with an *ambiguous* result — the network
/// may already have accepted the transaction — so its inputs must NOT be
/// released on failure.
struct AlwaysUnconfirmedBroadcaster;

#[async_trait]
impl TransactionBroadcaster for AlwaysUnconfirmedBroadcaster {
async fn broadcast(&self, _transaction: &Transaction) -> Result<Txid, PlatformWalletError> {
Err(PlatformWalletError::ShieldedSpendUnconfirmed {
operation: "transfer",
reason: "simulated ambiguous broadcast".to_string(),
})
}
}

/// Soft signer that derives keys straight from a test wallet's seed. Stands
/// in for the FFI keychain-backed signer used in production.
struct WalletSigner {
wallet: Wallet,
}

#[async_trait]
impl Signer for WalletSigner {
type Error = String;

fn supported_methods(&self) -> &[SignerMethod] {
&[SignerMethod::Digest]
}

async fn sign_ecdsa(
&self,
path: &DerivationPath,
sighash: [u8; 32],
) -> Result<(ecdsa::Signature, PublicKey), Self::Error> {
let secp = Secp256k1::new();
let key = self
.wallet
.derive_private_key(path)
.map_err(|e| e.to_string())?;
let message = Message::from_digest(sighash);
Ok((
secp.sign_ecdsa(&message, &key),
PublicKey::from_secret_key(&secp, &key),
))
}

async fn public_key(&self, path: &DerivationPath) -> Result<PublicKey, Self::Error> {
let secp = Secp256k1::new();
let key = self
.wallet
.derive_private_key(path)
.map_err(|e| e.to_string())?;
Ok(PublicKey::from_secret_key(&secp, &key))
}
}

/// Builds a testnet `CoreWallet` whose `account_type`/index-0 account holds a
/// single spendable UTXO (10_000_000 duffs) — the whole balance rides on that
/// one input, so a leaked reservation strands it. Returns the wallet, a soft
/// signer over its seed, and a 1_000_000-duff payment to a dummy recipient.
async fn funded_core_wallet<B: TransactionBroadcaster>(
account_type: StandardAccountType,
broadcaster: Arc<B>,
) -> (CoreWallet<B>, WalletSigner, Vec<(DashAddress, u64)>) {
use key_wallet::transaction_checking::{TransactionContext, WalletTransactionChecker};

let mut ctx = TestWalletContext::new_random();

let receive_address = match account_type {
StandardAccountType::BIP44Account => {
let xpub = ctx
.wallet
.accounts
.standard_bip44_accounts
.get(&0)
.expect("bip44 account")
.account_xpub;
ctx.managed_wallet
.first_bip44_managed_account_mut()
.expect("bip44 managed account")
.next_receive_address(Some(&xpub), true)
.expect("bip44 receive address")
}
StandardAccountType::BIP32Account => {
let xpub = ctx
.wallet
.accounts
.standard_bip32_accounts
.get(&0)
.expect("bip32 account")
.account_xpub;
ctx.managed_wallet
.first_bip32_managed_account_mut()
.expect("bip32 managed account")
.next_receive_address(Some(&xpub), true)
.expect("bip32 receive address")
}
};

let funding_tx = Transaction::dummy(&receive_address, 0..1, &[10_000_000]);
let result = ctx
.managed_wallet
.check_core_transaction(
&funding_tx,
TransactionContext::Mempool,
&mut ctx.wallet,
true,
true,
)
.await;
assert!(
result.is_relevant,
"funding tx should be relevant to {account_type:?}"
);
assert!(result.is_new_transaction);

let signer = WalletSigner {
wallet: ctx.wallet.clone(),
};

let balance = Arc::new(WalletBalance::new());
let info = PlatformWalletInfo {
core_wallet: ctx.managed_wallet,
balance: Arc::clone(&balance),
identity_manager: IdentityManager::new(),
tracked_asset_locks: BTreeMap::new(),
};

let mut wm = WalletManager::<PlatformWalletInfo>::new(Network::Testnet);
let wallet_id = wm.insert_wallet(ctx.wallet, info).expect("insert wallet");
let wallet_manager = Arc::new(RwLock::new(wm));

let sdk = Arc::new(dash_sdk::SdkBuilder::new_mock().build().expect("mock sdk"));
let core = CoreWallet::new(sdk, wallet_manager, wallet_id, broadcaster, balance);

let recipient = DashAddress::dummy(Network::Testnet, 42);
let outputs = vec![(recipient, 1_000_000u64)];

(core, signer, outputs)
}

/// A pre-send broadcast failure must release the UTXO reservation taken while
/// building the transaction, so an immediate retry can reselect those inputs
/// instead of failing with spurious insufficient funds until the TTL backstop.
/// Covers both funds-account arms of the release path.
#[tokio::test]
async fn send_to_addresses_releases_reservation_on_broadcast_failure() {
for account_type in [
StandardAccountType::BIP44Account,
StandardAccountType::BIP32Account,
] {
let broadcaster = Arc::new(FailFirstBroadcaster::new());
let (core, signer, outputs) = funded_core_wallet(account_type, broadcaster).await;

// First attempt: build + sign succeed, broadcast fails.
let first = core
.send_to_addresses(account_type, 0, outputs.clone(), &signer)
.await;
assert!(
matches!(first, Err(PlatformWalletError::TransactionBroadcast(_))),
"first send should surface the broadcast failure for {account_type:?}, got {first:?}"
);

// Immediate retry: only succeeds if the failed broadcast released the
// reservation. With the leak, coin selection sees no spendable UTXO and
// this fails with a build error instead.
let second = core
.send_to_addresses(account_type, 0, outputs, &signer)
.await;
assert!(
second.is_ok(),
"retry after a failed broadcast should succeed once the reservation \
is released for {account_type:?}, got {second:?}"
);
}
}

/// An *ambiguous* broadcast failure — the network may already have accepted
/// the transaction — must NOT release the reservation: retrying would risk a
/// double-spend. The reservation is kept, so an immediate retry fails at the
/// build stage (no spendable UTXO) rather than reaching broadcast again.
#[tokio::test]
async fn send_to_addresses_keeps_reservation_on_ambiguous_broadcast_failure() {
for account_type in [
StandardAccountType::BIP44Account,
StandardAccountType::BIP32Account,
] {
let broadcaster = Arc::new(AlwaysUnconfirmedBroadcaster);
let (core, signer, outputs) = funded_core_wallet(account_type, broadcaster).await;

let first = core
.send_to_addresses(account_type, 0, outputs.clone(), &signer)
.await;
assert!(
matches!(first, Err(PlatformWalletError::ShieldedSpendUnconfirmed { .. })),
"first send should surface the ambiguous failure for {account_type:?}, got {first:?}"
);

// Reservation kept: the retry cannot reselect the reserved input and
// fails while building, never reaching the broadcaster again.
let second = core
.send_to_addresses(account_type, 0, outputs, &signer)
.await;
assert!(
matches!(second, Err(PlatformWalletError::TransactionBuild(_))),
"retry after an ambiguous failure must fail at build with the reservation \
kept for {account_type:?}, got {second:?}"
);
}
}
}
Loading