Skip to content
Open
9 changes: 9 additions & 0 deletions packages/rs-dapi-client/src/dapi_client.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
85 changes: 84 additions & 1 deletion packages/rs-platform-wallet-ffi/src/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Comment thread
lklimek marked this conversation as resolved.

NotFound = 98, // Used exclusively for all the Option that are retuned as errors
ErrorUnknown = 99,
Expand Down Expand Up @@ -156,7 +168,20 @@ impl<T> From<Option<T>> for PlatformWalletFFIResult {

impl From<PlatformWalletError> 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())
}
}

Expand Down Expand Up @@ -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<PlatformWalletError> = vec![
PlatformWalletError::NoSpendableInputs {
account_type: StandardAccountType::BIP44Account,
account_index: 0,
context: "wallet empty in test".to_string(),
},
PlatformWalletError::OnlyOutputAddressesFunded {
funded_outputs: Vec::<PlatformAddress>::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"
);
}
}
Comment thread
lklimek marked this conversation as resolved.

/// 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);
Comment thread
lklimek marked this conversation as resolved.
}
}
38 changes: 38 additions & 0 deletions packages/rs-platform-wallet/src/error.rs
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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<PlatformAddress>,
/// 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),

Expand Down
31 changes: 31 additions & 0 deletions packages/rs-platform-wallet/src/spv/runtime.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,20 @@ impl IdentityManager {
.sum::<usize>()
}

/// 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<Identifier> {
let mut out: Vec<Identifier> = 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()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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)

Expand All @@ -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
}
Expand All @@ -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)
}
Expand Down
Loading