diff --git a/packages/rs-dapi-client/src/dapi_client.rs b/packages/rs-dapi-client/src/dapi_client.rs index 1b9f07558f4..5c20d46dc01 100644 --- a/packages/rs-dapi-client/src/dapi_client.rs +++ b/packages/rs-dapi-client/src/dapi_client.rs @@ -575,6 +575,15 @@ impl DapiRequestExecutor for DapiClient { }); }; + // Rec 3 — explicit trace event so the resolved DAPI endpoint + // appears in flat plain-text log output (not just the span context). + tracing::trace!( + target: "dapi_client::dispatch", + ?address, + method = request.method_name(), + request_type = request.request_name(), + "dispatching request to DAPI endpoint" + ); tracing::trace!( ?request, "calling {} with {} request", diff --git a/packages/rs-platform-wallet-ffi/src/error.rs b/packages/rs-platform-wallet-ffi/src/error.rs index adbe771c8c0..e74449a3ffa 100644 --- a/packages/rs-platform-wallet-ffi/src/error.rs +++ b/packages/rs-platform-wallet-ffi/src/error.rs @@ -76,6 +76,18 @@ pub enum PlatformWalletFFIResultCode { ErrorInvalidIdentifier = 10, ErrorMemoryAllocation = 11, ErrorUtf8Conversion = 12, + /// Reserved code — currently unused. Kept to preserve numeric ABI for + /// downstream consumers that compiled against this enum. + ErrorArithmeticOverflow = 13, + /// Auto-select had no candidate inputs. Covers all three "can't-select-inputs" + /// wallet variants: `NoSpendableInputs` (account has nothing spendable), + /// `OnlyOutputAddressesFunded` (every funded address is also a destination), + /// and `OnlyDustInputs` (every funded address is below `min_input_amount`). + /// The typed Display rendering survives via the result message so callers + /// can distinguish the underlying cause. Caller must rotate to a fresh + /// receive address, consolidate sub-min balances, or fall back to + /// `InputSelection::Explicit`. + ErrorNoSelectableInputs = 14, NotFound = 98, // Used exclusively for all the Option that are retuned as errors ErrorUnknown = 99, @@ -156,7 +168,20 @@ impl From> for PlatformWalletFFIResult { impl From for PlatformWalletFFIResult { fn from(error: PlatformWalletError) -> Self { - PlatformWalletFFIResult::err(PlatformWalletFFIResultCode::ErrorUnknown, error.to_string()) + // Map the typed wallet error variants explicitly so they + // don't flatten to ErrorUnknown at the FFI boundary. The + // catch-all ErrorUnknown remains for variants the FFI hasn't + // assigned a dedicated code yet — those still carry the + // typed Display rendering as the message. + let code = match &error { + PlatformWalletError::NoSpendableInputs { .. } + | PlatformWalletError::OnlyOutputAddressesFunded { .. } + | PlatformWalletError::OnlyDustInputs { .. } => { + PlatformWalletFFIResultCode::ErrorNoSelectableInputs + } + _ => PlatformWalletFFIResultCode::ErrorUnknown, + }; + PlatformWalletFFIResult::err(code, error.to_string()) } } @@ -376,4 +401,46 @@ mod tests { ); assert!(!r.message.is_null()); } + + /// The three "can't-select-inputs" wallet variants (`NoSpendableInputs`, + /// `OnlyOutputAddressesFunded`, `OnlyDustInputs`) all map to the dedicated + /// `ErrorNoSelectableInputs` FFI code rather than flattening to + /// `ErrorUnknown`, and the typed Display rendering survives across the + /// boundary so callers can distinguish the underlying cause from the + /// message string. + #[test] + fn no_selectable_inputs_maps_to_dedicated_code() { + use key_wallet::account::StandardAccountType; + let err = PlatformWalletError::NoSpendableInputs { + account_type: StandardAccountType::BIP44Account, + account_index: 0, + context: "wallet empty in test".to_string(), + }; + let rendered = err.to_string(); + let result: PlatformWalletFFIResult = err.into(); + assert_eq!( + result.code, + PlatformWalletFFIResultCode::ErrorNoSelectableInputs + ); + assert!(!result.message.is_null()); + let msg = unsafe { std::ffi::CStr::from_ptr(result.message) } + .to_string_lossy() + .into_owned(); + assert_eq!(msg, rendered); + assert!( + msg.contains("no spendable inputs"), + "Display payload must survive: {msg}" + ); + } + + /// 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 + /// stays the only `ErrorUnknown` source. + #[test] + fn unmapped_variants_fall_through_to_unknown() { + let err = PlatformWalletError::AddressOperation("explicit fallthrough".to_string()); + let result: PlatformWalletFFIResult = err.into(); + assert_eq!(result.code, PlatformWalletFFIResultCode::ErrorUnknown); + } } diff --git a/packages/rs-platform-wallet/src/error.rs b/packages/rs-platform-wallet/src/error.rs index 006e9b01331..a7ac761ab93 100644 --- a/packages/rs-platform-wallet/src/error.rs +++ b/packages/rs-platform-wallet/src/error.rs @@ -1,4 +1,7 @@ +use dpp::address_funds::PlatformAddress; +use dpp::fee::Credits; use dpp::identifier::Identifier; +use key_wallet::account::StandardAccountType; use key_wallet::Network; /// Errors that can occur in platform wallet operations @@ -60,6 +63,41 @@ pub enum PlatformWalletError { #[error("Transaction building failed: {0}")] TransactionBuild(String), + #[error("no spendable inputs available on {account_type} account {account_index}: {context}")] + NoSpendableInputs { + account_type: StandardAccountType, + account_index: u32, + context: String, + }, + + #[error( + "no selectable inputs: only funded addresses appear as destinations \ + (funded_outputs={funded_outputs:?}, min_input_amount={min_input_amount}); \ + rotate to a fresh receive address, consolidate funds, or use \ + InputSelection::Explicit" + )] + OnlyOutputAddressesFunded { + /// Funded addresses dropped by the input-equals-output filter. + funded_outputs: Vec, + /// Per-input minimum from the active platform version. + min_input_amount: Credits, + }, + + #[error( + "no selectable inputs: every funded address is below the per-input \ + minimum (sub_min_count={sub_min_count}, sub_min_aggregate={sub_min_aggregate} \ + credits, min_input_amount={min_input_amount}); consolidate funds or use \ + InputSelection::Explicit" + )] + OnlyDustInputs { + /// Number of addresses with a positive balance below `min_input_amount`. + sub_min_count: usize, + /// Aggregate of those sub-minimum balances. + sub_min_aggregate: Credits, + /// Per-input minimum from the active platform version. + min_input_amount: Credits, + }, + #[error("Asset lock proof waiting failed: {0}")] AssetLockProofWait(String), diff --git a/packages/rs-platform-wallet/src/spv/runtime.rs b/packages/rs-platform-wallet/src/spv/runtime.rs index d0c56e48b7a..813d3f62e78 100644 --- a/packages/rs-platform-wallet/src/spv/runtime.rs +++ b/packages/rs-platform-wallet/src/spv/runtime.rs @@ -179,6 +179,28 @@ impl SpvRuntime { result } + /// Synchronously fire the background `run()` task's cancellation + /// token, if any. The actual storage/lockfile teardown still + /// happens asynchronously inside the spawned task as it unwinds + /// to its `self.stop().await` epilogue — this method just wakes + /// it. Idempotent: subsequent calls (and a follow-up [`stop`]) + /// see `None` and return immediately. + /// + /// Designed for sync contexts where awaiting [`stop`] isn't + /// possible — for example a `std::panic::set_hook` callback that + /// needs to release the dash-spv data-dir lock before the next + /// init attempt without blocking the panicking thread. + pub fn cancel_background(&self) { + if let Some(token) = self + .background_cancel + .lock() + .expect("background_cancel poisoned") + .take() + { + token.cancel(); + } + } + /// Stop SPV sync gracefully. /// /// If a `run()` task was spawned via [`spawn_in_background`], its diff --git a/packages/rs-platform-wallet/src/wallet/identity/state/manager/accessors.rs b/packages/rs-platform-wallet/src/wallet/identity/state/manager/accessors.rs index cfe81e52560..4e430588bb2 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/state/manager/accessors.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/state/manager/accessors.rs @@ -104,6 +104,20 @@ impl IdentityManager { .sum::() } + /// Snapshot of every managed identity's `Identifier` across both + /// buckets. Order is unspecified — callers that need a stable + /// order should sort the returned `Vec`. + pub fn identity_ids(&self) -> Vec { + let mut out: Vec = Vec::with_capacity(self.identity_count()); + out.extend(self.out_of_wallet_identities.keys().copied()); + for inner in self.wallet_identities.values() { + for managed in inner.values() { + out.push(managed.identity.id()); + } + } + out + } + /// `true` iff both buckets are empty. pub fn is_empty(&self) -> bool { self.out_of_wallet_identities.is_empty() && self.wallet_identities.is_empty()