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..5094271ab7f 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,62 @@ 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 dpp::address_funds::PlatformAddress; + use key_wallet::account::StandardAccountType; + + let cases: Vec = vec![ + PlatformWalletError::NoSpendableInputs { + account_type: StandardAccountType::BIP44Account, + account_index: 0, + context: "wallet empty in test".to_string(), + }, + PlatformWalletError::OnlyOutputAddressesFunded { + funded_outputs: Vec::::new(), + min_input_amount: 1_000, + }, + PlatformWalletError::OnlyDustInputs { + sub_min_count: 3, + sub_min_aggregate: 500, + min_input_amount: 1_000, + }, + ]; + + for err in cases { + let rendered = err.to_string(); + let result: PlatformWalletFFIResult = err.into(); + assert_eq!( + result.code, + PlatformWalletFFIResultCode::ErrorNoSelectableInputs, + "variant should map to ErrorNoSelectableInputs (rendered: {rendered})" + ); + assert!(!result.message.is_null()); + let msg = unsafe { std::ffi::CStr::from_ptr(result.message) } + .to_string_lossy() + .into_owned(); + assert_eq!( + msg, rendered, + "Display payload must survive the FFI boundary 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 + /// 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 c7eda7449e5..71988e5aea4 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..7e75d696143 100644 --- a/packages/rs-platform-wallet/src/spv/runtime.rs +++ b/packages/rs-platform-wallet/src/spv/runtime.rs @@ -179,6 +179,37 @@ impl SpvRuntime { result } + /// Best-effort: fire the background `run()` task's cancel token if one + /// is registered. Teardown of the dash-spv client and its data-dir + /// lockfile still happens asynchronously inside the spawned task as it + /// unwinds to its `self.stop().await` epilogue — this method only wakes + /// the task. 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 wants to nudge the + /// SPV task toward shutdown without blocking the panicking thread. + /// + /// This method does **not** guarantee the dash-spv data-dir lock has + /// been released by the time it returns. Callers that need that + /// guarantee (e.g. before reinitializing on the same data directory) + /// must `await stop()` from an async context instead. + /// + /// Tolerates a poisoned `background_cancel` mutex — the panic-hook use + /// case is precisely when the lock may already be poisoned, so the + /// guard is recovered via `PoisonError::into_inner` rather than + /// panicking again. + pub fn cancel_background(&self) { + if let Some(token) = self + .background_cancel + .lock() + .unwrap_or_else(|p| p.into_inner()) + .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() diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletResult.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletResult.swift index 199d07bd5e3..1d974b71916 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletResult.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletResult.swift @@ -18,6 +18,8 @@ public enum PlatformWalletResultCode: Int32, Sendable { case errorInvalidIdentifier = 10 case errorMemoryAllocation = 11 case errorUtf8Conversion = 12 + case errorArithmeticOverflow = 13 + case errorNoSelectableInputs = 14 case notFound = 98 case errorUnknown = 99 @@ -49,6 +51,10 @@ public enum PlatformWalletResultCode: Int32, Sendable { self = .errorMemoryAllocation case PLATFORM_WALLET_FFI_RESULT_CODE_ERROR_UTF8_CONVERSION: self = .errorUtf8Conversion + case PLATFORM_WALLET_FFI_RESULT_CODE_ERROR_ARITHMETIC_OVERFLOW: + self = .errorArithmeticOverflow + case PLATFORM_WALLET_FFI_RESULT_CODE_ERROR_NO_SELECTABLE_INPUTS: + self = .errorNoSelectableInputs case PLATFORM_WALLET_FFI_RESULT_CODE_NOT_FOUND: self = .notFound case PLATFORM_WALLET_FFI_RESULT_CODE_ERROR_UNKNOWN: @@ -124,6 +130,8 @@ public enum PlatformWalletError: LocalizedError { case serialization(String) case deserialization(String) case memoryAllocation(String) + case arithmeticOverflow(String) + case noSelectableInputs(String) case notFound(String) case unknown(String) @@ -136,6 +144,7 @@ public enum PlatformWalletError: LocalizedError { .invalidIdentifier(let m), .invalidNetwork(let m), .walletOperation(let m), .identityNotFound(let m), .contactNotFound(let m), .utf8Conversion(let m), .serialization(let m), .deserialization(let m), .memoryAllocation(let m), + .arithmeticOverflow(let m), .noSelectableInputs(let m), .notFound(let m), .unknown(let m): return m } @@ -160,6 +169,8 @@ public enum PlatformWalletError: LocalizedError { case .errorInvalidIdentifier: self = .invalidIdentifier(detail) case .errorMemoryAllocation: self = .memoryAllocation(detail) case .errorUtf8Conversion: self = .utf8Conversion(detail) + case .errorArithmeticOverflow: self = .arithmeticOverflow(detail) + case .errorNoSelectableInputs: self = .noSelectableInputs(detail) case .notFound: self = .notFound(detail) case .errorUnknown: self = .unknown(detail) }