Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 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
3145402
fix(platform-wallet): type broadcast errors and share the reservation…
QuantumExplorer Jul 3, 2026
12ab17f
fix(platform-wallet): untrack rejected asset locks and carry the unco…
QuantumExplorer Jul 4, 2026
25ea9d2
fix(platform-wallet): close the rejected-asset-lock race and pin both…
QuantumExplorer Jul 4, 2026
5df222b
fix(platform-wallet): gate the rejected-asset-lock release on the Bui…
QuantumExplorer Jul 4, 2026
04ab5cd
fix(platform-wallet): reserve FFI code 19 for the v4.0-dev shielded-a…
QuantumExplorer Jul 4, 2026
79caf9b
test(platform-wallet): pin the SPV broadcast Rejected/MaybeSent class…
QuantumExplorer Jul 4, 2026
1a9b6c0
Merge branch 'v4.1-dev' into fix/core-wallet-release-reservation-on-b…
QuantumExplorer Jul 4, 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
38 changes: 38 additions & 0 deletions packages/rs-platform-wallet-ffi/src/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,17 @@ pub enum PlatformWalletFFIResultCode {
/// boundary) and try again. Distinct from `ErrorShieldedSpendUnconfirmed`,
/// where a spend WAS broadcast and must NOT be retried.
ErrorShieldedNoRecordedAnchor = 19,
/// Maps `PlatformWalletError::TransactionBroadcastUnconfirmed`. A core
/// transaction broadcast (send-to-addresses, DashPay payment, or
/// asset-lock funding) failed with an AMBIGUOUS outcome — the transaction
/// may already be on the network (transport timeout after delivery,
/// partial peer send, or an internal multi-node retry whose earlier
/// attempt may have delivered). The wallet intentionally KEEPS the spent
/// inputs' UTXO reservation, so an immediate retry fails at input
/// selection instead of double-spending; the reservation TTL or a sync
/// observing the transaction reconciles the outcome. The host must NOT
/// auto-retry. Shielded sibling: [`Self::ErrorShieldedSpendUnconfirmed`].
ErrorTransactionBroadcastUnconfirmed = 20,

NotFound = 98, // Used exclusively for all the Option that are retuned as errors
ErrorUnknown = 99,
Expand Down Expand Up @@ -268,6 +279,12 @@ impl From<PlatformWalletError> for PlatformWalletFFIResult {
PlatformWalletError::ShieldedNoRecordedAnchor(..) => {
PlatformWalletFFIResultCode::ErrorShieldedNoRecordedAnchor
}
// The core-transaction sibling of the shielded pair above: the
// do-not-retry signal must survive the boundary as a typed code
// so hosts can distinguish it from a definitive rejection.
PlatformWalletError::TransactionBroadcastUnconfirmed(..) => {
PlatformWalletFFIResultCode::ErrorTransactionBroadcastUnconfirmed
}
_ => PlatformWalletFFIResultCode::ErrorUnknown,
};
PlatformWalletFFIResult::err(code, error.to_string())
Expand Down Expand Up @@ -626,6 +643,27 @@ mod tests {
assert_eq!(msg, rendered, "Display payload must survive verbatim");
}

/// The ambiguous core-broadcast outcome keeps its typed code across the
/// boundary — flattening it to `ErrorUnknown` would erase the
/// do-not-retry signal the variant exists to carry.
#[test]
fn transaction_broadcast_unconfirmed_maps_to_dedicated_code() {
let unconfirmed = PlatformWalletError::TransactionBroadcastUnconfirmed(
"gRPC deadline exceeded after delivery".to_string(),
);
let rendered = unconfirmed.to_string();
let result: PlatformWalletFFIResult = unconfirmed.into();
assert_eq!(
result.code,
PlatformWalletFFIResultCode::ErrorTransactionBroadcastUnconfirmed,
"TransactionBroadcastUnconfirmed should map to its dedicated code (rendered: {rendered})"
);
let msg = unsafe { std::ffi::CStr::from_ptr(result.message) }
.to_string_lossy()
.into_owned();
assert_eq!(msg, rendered, "Display payload must survive verbatim");
}

/// Other wallet-error variants without a dedicated FFI arm still
/// fall through to `ErrorUnknown` while carrying the typed
/// Display rendering as the message. Pin this so the catch-all
Expand Down
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
68 changes: 63 additions & 5 deletions packages/rs-platform-wallet/src/broadcaster.rs
Original file line number Diff line number Diff line change
Expand Up @@ -15,12 +15,59 @@ use dashcore::{Transaction, Txid};
use crate::error::PlatformWalletError;
use crate::spv::SpvRuntime;

/// A failed broadcast, classified by whether the transaction could have
/// reached the network.
///
/// The classification decides whether the transaction's reserved inputs are
/// safe to release for an immediate retry (see
/// `wallet::reservations::broadcast_releasing_on_rejection`). Only the
/// broadcaster implementation has the transport knowledge to make this call,
/// so the distinction is part of the error type rather than a convention on
/// message strings.
#[derive(Debug, thiserror::Error)]
pub enum BroadcastError {
/// The transaction was definitively **not** handed to the network: the
/// failure happened before any peer or endpoint received the bytes.
/// Reserved inputs are safe to release and the send may be retried
/// immediately.
#[error("transaction broadcast rejected before reaching the network: {reason}")]
Rejected { reason: String },

/// The outcome is unknown — the transaction **may** already be on the
/// network (transport timeout after delivery, partial peer send, or an
/// internal multi-node retry whose earlier attempt may have succeeded).
/// Reserved inputs must be kept; the reservation-TTL backstop or a later
/// sync reconciles the outcome.
#[error("transaction broadcast outcome unknown — it may already be on the network: {reason}")]
MaybeSent { reason: String },
}

impl From<BroadcastError> for PlatformWalletError {
fn from(error: BroadcastError) -> Self {
match error {
BroadcastError::Rejected { reason } => {
PlatformWalletError::TransactionBroadcast(reason)
}
BroadcastError::MaybeSent { reason } => {
PlatformWalletError::TransactionBroadcastUnconfirmed(reason)
}
}
}
}

/// Broadcasts a signed transaction to the Dash network.
///
/// Implementations may use DAPI (gRPC), SPV (P2P peers), or Core RPC.
#[async_trait]
pub trait TransactionBroadcaster: Send + Sync {
async fn broadcast(&self, transaction: &Transaction) -> Result<Txid, PlatformWalletError>;
/// Contract: implementations must classify every failure as
/// [`BroadcastError::Rejected`] **only** when the transaction provably
/// never reached the network (no bytes handed to any peer or endpoint).
/// Anything uncertain — timeouts after delivery, partial sends, retried
/// submissions — must be [`BroadcastError::MaybeSent`], so callers keep
/// the transaction's input reservation instead of releasing it into a
/// potential double-spend.
async fn broadcast(&self, transaction: &Transaction) -> Result<Txid, BroadcastError>;
}

/// Broadcasts transactions via Platform's DAPI gRPC endpoint.
Expand All @@ -38,7 +85,7 @@ impl DapiBroadcaster {

#[async_trait]
impl TransactionBroadcaster for DapiBroadcaster {
async fn broadcast(&self, transaction: &Transaction) -> Result<Txid, PlatformWalletError> {
async fn broadcast(&self, transaction: &Transaction) -> Result<Txid, BroadcastError> {
use dash_sdk::dapi_client::{DapiRequestExecutor, IntoInner, RequestSettings};
use dash_sdk::dapi_grpc::core::v0::BroadcastTransactionRequest;
use dashcore::consensus;
Expand All @@ -51,13 +98,24 @@ impl TransactionBroadcaster for DapiBroadcaster {
bypass_limits: false,
};

// Every DAPI failure is classified `MaybeSent`: `sdk.execute` retries
// across nodes internally (RequestSettings::default()), so the error
// surfaced here is only the *last* attempt's — an earlier attempt may
// have delivered the transaction even though the response was lost
// (the classic shape being a node that accepts the tx while its gRPC
// response times out, followed by a retry that fails differently).
// Distinguishing a genuinely pre-send rejection would require
// disabling the internal retries and inspecting transport errors;
// until then the conservative classification keeps reserved inputs
// safe from double-spends at the cost of holding them for the
// reservation TTL.
let _response = self
.sdk
.execute(request, RequestSettings::default())
.await
.into_inner()
.map_err(|e| {
PlatformWalletError::TransactionBroadcast(format!("DAPI broadcast failed: {}", e))
.map_err(|e| BroadcastError::MaybeSent {
reason: format!("DAPI broadcast failed: {}", e),
})?;

Ok(transaction.txid())
Expand All @@ -79,7 +137,7 @@ impl SpvBroadcaster {

#[async_trait]
impl TransactionBroadcaster for SpvBroadcaster {
async fn broadcast(&self, transaction: &Transaction) -> Result<Txid, PlatformWalletError> {
async fn broadcast(&self, transaction: &Transaction) -> Result<Txid, BroadcastError> {
self.spv.broadcast_transaction(transaction).await?;
Ok(transaction.txid())
}
Expand Down
16 changes: 16 additions & 0 deletions packages/rs-platform-wallet/src/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,22 @@ pub enum PlatformWalletError {
#[error("Transaction broadcast failed: {0}")]
TransactionBroadcast(String),

/// A core transaction broadcast failed with an **ambiguous** outcome — the
/// transaction may already have reached the network (transport timeout
/// after delivery, partial peer send, or an internal multi-node retry
/// whose earlier attempt may have succeeded). The spent inputs'
/// reservation is intentionally kept, so an immediate retry fails at
/// input selection instead of double-spending; the reservation-TTL
/// backstop (or a sync observing the transaction) reconciles the outcome.
///
/// The shielded sibling is [`Self::ShieldedSpendUnconfirmed`].
#[error(
"Transaction broadcast outcome unknown — it may already be on the \
network; its inputs stay reserved until a sync or the reservation \
TTL reconciles the outcome: {0}"
)]
TransactionBroadcastUnconfirmed(String),

#[error("Transaction building failed: {0}")]
TransactionBuild(String),

Expand Down
2 changes: 2 additions & 0 deletions packages/rs-platform-wallet/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@ pub mod error;
pub mod events;
pub mod manager;
pub mod spv;
#[cfg(test)]
pub(crate) mod test_support;
pub mod wallet;

pub use error::PlatformWalletError;
Expand Down
122 changes: 117 additions & 5 deletions packages/rs-platform-wallet/src/spv/runtime.rs
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ use dash_spv::{ClientConfig, DashSpvClient, EventHandler, Hash};

use key_wallet_manager::WalletManager;

use crate::broadcaster::BroadcastError;
use crate::error::PlatformWalletError;
use crate::events::PlatformEventManager;
use crate::wallet::platform_wallet::PlatformWalletInfo;
Expand All @@ -32,6 +33,31 @@ pub struct SpvRuntime {
client: RwLock<Option<SpvClient>>,
task: Mutex<Option<JoinHandle<()>>>,
}
/// Classify a dash-spv broadcast failure per the
/// [`TransactionBroadcaster::broadcast`] contract.
///
/// dash-spv's `broadcast_transaction` raises
/// `NetworkError::NotConnected` from its zero-connected-peers check
/// *before* handing the transaction to any peer, so it is the only
/// error safe to classify [`BroadcastError::Rejected`]; anything else
/// may follow a partial peer send and must stay
/// [`BroadcastError::MaybeSent`]. Pinned by the tests below so a
/// dash-spv semantic change is caught at this crate's boundary.
///
/// [`TransactionBroadcaster::broadcast`]: crate::broadcaster::TransactionBroadcaster::broadcast
fn classify_spv_broadcast_error(error: dash_spv::error::SpvError) -> BroadcastError {
use dash_spv::error::{NetworkError, SpvError};

match error {
SpvError::Network(NetworkError::NotConnected) => BroadcastError::Rejected {
reason: "SPV broadcast failed: no connected peers".to_string(),
},
other => BroadcastError::MaybeSent {
reason: format!("SPV broadcast failed: {}", other),
},
}
}

// TODO: We want it better
impl SpvRuntime {
/// Create a new SPV runtime.
Expand Down Expand Up @@ -92,19 +118,27 @@ impl SpvRuntime {
}

/// Broadcast a transaction to all connected SPV peers.
///
/// Failures are classified per the [`TransactionBroadcaster::broadcast`]
/// contract: an unstarted client and dash-spv's zero-peer
/// `NetworkError::NotConnected` both fire before any bytes leave the
/// process, so they are [`BroadcastError::Rejected`]; any later failure
/// may follow a partial peer send and is [`BroadcastError::MaybeSent`].
///
/// [`TransactionBroadcaster::broadcast`]: crate::broadcaster::TransactionBroadcaster::broadcast
pub(crate) async fn broadcast_transaction(
&self,
tx: &Transaction,
) -> Result<(), PlatformWalletError> {
) -> Result<(), BroadcastError> {
let client_guard = self.client.read().await;
let client = client_guard.as_ref().ok_or(PlatformWalletError::SpvError(
"SPV Client not started".to_string(),
))?;
let client = client_guard.as_ref().ok_or(BroadcastError::Rejected {
reason: "SPV client not started".to_string(),
})?;

client
.broadcast_transaction(tx)
.await
.map_err(|e| PlatformWalletError::SpvError(e.to_string()))?;
.map_err(classify_spv_broadcast_error)?;

Ok(())
}
Comment thread
QuantumExplorer marked this conversation as resolved.
Expand Down Expand Up @@ -285,3 +319,81 @@ impl std::fmt::Debug for SpvRuntime {
.finish()
}
}

#[cfg(test)]
mod tests {
use std::sync::Arc;

use dash_spv::error::{NetworkError, SpvError};
use dashcore::Network;
use key_wallet_manager::WalletManager;
use tokio::sync::RwLock;

use super::{classify_spv_broadcast_error, SpvRuntime};
use crate::broadcaster::BroadcastError;
use crate::events::PlatformEventManager;
use crate::wallet::platform_wallet::PlatformWalletInfo;

/// A minimal valid transaction — the unstarted-client arm never
/// inspects it.
fn dummy_tx() -> dashcore::Transaction {
dashcore::Transaction {
version: 2,
lock_time: 0,
input: vec![],
output: vec![],
special_transaction_payload: None,
}
}

/// An unstarted SPV client fails before any bytes leave the process,
/// so the failure must classify `Rejected` (safe to release the
/// transaction's input reservation).
#[tokio::test]
async fn broadcast_on_unstarted_client_is_rejected() {
let wallet_manager = Arc::new(RwLock::new(WalletManager::<PlatformWalletInfo>::new(
Network::Testnet,
)));
let runtime = SpvRuntime::new(wallet_manager, Arc::new(PlatformEventManager::new(vec![])));

let result = runtime.broadcast_transaction(&dummy_tx()).await;
assert!(
matches!(result, Err(BroadcastError::Rejected { .. })),
"unstarted client must classify Rejected, got {result:?}"
);
}

/// dash-spv raises `NetworkError::NotConnected` from its
/// zero-connected-peers check before handing the transaction to any
/// peer, so it is the one client error safe to classify `Rejected`.
/// If dash-spv ever starts raising `NotConnected` after a partial
/// send, this pin must be revisited — releasing on a post-send
/// failure reopens the double-spend-on-retry window.
#[test]
fn not_connected_classifies_rejected() {
let result = classify_spv_broadcast_error(SpvError::Network(NetworkError::NotConnected));
assert!(
matches!(result, BroadcastError::Rejected { .. }),
"NotConnected must classify Rejected, got {result:?}"
);
}

/// Every other dash-spv error may follow a partial peer send and must
/// stay `MaybeSent`, keeping the reservation for the TTL backstop.
#[test]
fn any_other_spv_error_classifies_maybe_sent() {
for error in [
SpvError::Network(NetworkError::Timeout),
SpvError::Network(NetworkError::PeerDisconnected),
SpvError::Network(NetworkError::ConnectionFailed("reset by peer".to_string())),
SpvError::Config("bad config".to_string()),
] {
let rendered = error.to_string();
let result = classify_spv_broadcast_error(error);
assert!(
matches!(result, BroadcastError::MaybeSent { .. }),
"{rendered} must classify MaybeSent, got {result:?}"
);
}
}
}
Loading
Loading