Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
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
44 changes: 26 additions & 18 deletions packages/rs-platform-wallet-ffi/ERROR_CODE_REGISTRY.md

Large diffs are not rendered by default.

128 changes: 124 additions & 4 deletions packages/rs-platform-wallet-ffi/src/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -504,6 +504,20 @@ pub enum PlatformWalletFFIResultCode {
/// height, and says the verdict is provisional.
ErrorAssetLockInputContested = 48,

// -----------------------------------------------------------------
// Embedded-persister failures (49-50), claimed from the allocation
// frontier in the error-code registry (#4318). 28, 30, 32 and 33 are
// reserved-not-free, so the frontier is the only allocation source.
// ErrorPersisterFatal was never contested and keeps 49.
// ErrorPersisterTransient was originally minted at 48 on 2026-08-27;
// merged #4356 took 48 for ErrorAssetLockInputContested on 2026-08-31,
// so it moved to 50 — see the registry's row 50 for the full account.
// -----------------------------------------------------------------
/// A persister operation failed permanently; callers must not retry.
ErrorPersisterFatal = 49,
/// A persister operation failed transiently; callers may retry.
ErrorPersisterTransient = 50,

/// The named thing does not exist.
///
/// Originally (and still mostly) the code for every `Option` returned as an
Expand All @@ -525,6 +539,31 @@ pub enum PlatformWalletFFIResultCode {
ErrorUnknown = 99,
}

fn persistence_result_code(
error: &platform_wallet::changeset::PersistenceError,
) -> PlatformWalletFFIResultCode {
if error.is_transient() {
PlatformWalletFFIResultCode::ErrorPersisterTransient
} else {
PlatformWalletFFIResultCode::ErrorPersisterFatal
}
}

fn platform_wallet_persister_result_code(
mut error: &PlatformWalletError,
) -> PlatformWalletFFIResultCode {
loop {
match error {
PlatformWalletError::PersisterLoad(error)
| PlatformWalletError::PersisterStore(error) => {
return persistence_result_code(error);
}
PlatformWalletError::PersisterRestore(inner) => error = inner,
_ => return PlatformWalletFFIResultCode::ErrorPersisterFatal,
}
}
}

/// Must be freed with ['platform_wallet_ffi_result_free']
#[repr(C)]
#[derive(Debug)]
Expand Down Expand Up @@ -815,6 +854,11 @@ impl From<PlatformWalletError> for PlatformWalletFFIResult {
PlatformWalletError::AssetLockInsufficientFunds { .. } => {
PlatformWalletFFIResultCode::ErrorAssetLockInsufficientFunds
}
PlatformWalletError::PersisterLoad(..)
| PlatformWalletError::PersisterStore(..)
| PlatformWalletError::PersisterRestore(..) => {
platform_wallet_persister_result_code(&error)
}
// A quiesce/drain barrier that did not complete within budget
// (clear/reset paths). The host must fail closed: keep its
// callback context alive and skip any paired persistence wipe.
Expand Down Expand Up @@ -1082,10 +1126,8 @@ impl From<dpp::platform_value::Error> for PlatformWalletFFIResult {

impl From<platform_wallet::changeset::PersistenceError> for PlatformWalletFFIResult {
fn from(e: platform_wallet::changeset::PersistenceError) -> Self {
Self::err(
PlatformWalletFFIResultCode::ErrorWalletOperation,
format!("persistence error: {e}"),
)
let code = persistence_result_code(&e);
Self::err(code, format!("persistence error: {e}"))
}
}

Expand All @@ -1110,6 +1152,19 @@ impl From<anyhow::Error> for PlatformWalletFFIResult {
#[cfg(test)]
mod tests {
use super::*;

#[test]
fn result_code_discriminants_remain_stable() {
assert_eq!(
PlatformWalletFFIResultCode::ErrorPersisterTransient as i32,
50
);
assert_eq!(PlatformWalletFFIResultCode::ErrorPersisterFatal as i32, 49);
assert_eq!(
PlatformWalletFFIResultCode::ErrorTransactionBroadcastRejected as i32,
26
);
}
use key_wallet::account::StandardAccountType;
use key_wallet::wallet::managed_wallet_info::transaction_building::AccountTypePreference;

Expand Down Expand Up @@ -1149,6 +1204,71 @@ mod tests {
assert!(!r.message.is_null());
}

fn persistence_error(
kind: platform_wallet::changeset::PersistenceErrorKind,
) -> platform_wallet::changeset::PersistenceError {
platform_wallet::changeset::PersistenceError::backend_with_kind(kind, "test failure")
}

#[test]
fn should_map_persistence_errors_by_retry_classification() {
use platform_wallet::changeset::{PersistenceError, PersistenceErrorKind};

let transient: PlatformWalletFFIResult =
persistence_error(PersistenceErrorKind::Transient).into();
assert_eq!(
transient.code,
PlatformWalletFFIResultCode::ErrorPersisterTransient
);

for error in [
persistence_error(PersistenceErrorKind::Fatal),
persistence_error(PersistenceErrorKind::Constraint),
PersistenceError::LockPoisoned,
] {
let result: PlatformWalletFFIResult = error.into();
assert_eq!(
result.code,
PlatformWalletFFIResultCode::ErrorPersisterFatal
);
}
}

#[test]
fn should_map_platform_wallet_persister_errors_by_retry_classification() {
use platform_wallet::changeset::PersistenceErrorKind;

let cases = [
(
PlatformWalletError::PersisterLoad(persistence_error(
PersistenceErrorKind::Transient,
)),
PlatformWalletFFIResultCode::ErrorPersisterTransient,
),
(
PlatformWalletError::PersisterStore(persistence_error(PersistenceErrorKind::Fatal)),
PlatformWalletFFIResultCode::ErrorPersisterFatal,
),
(
PlatformWalletError::PersisterRestore(Box::new(
PlatformWalletError::PersisterStore(persistence_error(
PersistenceErrorKind::Transient,
)),
)),
PlatformWalletFFIResultCode::ErrorPersisterTransient,
),
(
PlatformWalletError::PersisterRestore(Box::new(PlatformWalletError::WalletLocked)),
PlatformWalletFFIResultCode::ErrorPersisterFatal,
),
];

for (error, expected) in cases {
let result: PlatformWalletFFIResult = error.into();
assert_eq!(result.code, expected);
}
}

/// The three "can't-select-inputs" wallet variants (`NoSpendableInputs`,
/// `OnlyOutputAddressesFunded`, `OnlyDustInputs`) all map to the dedicated
/// `ErrorNoSelectableInputs` FFI code rather than flattening to
Expand Down
84 changes: 30 additions & 54 deletions packages/rs-platform-wallet-ffi/src/persistence.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@

use bincode::config;
use key_wallet::account::account_collection::AccountCollection;
use key_wallet::account::{Account, AccountType, BLSAccount, EdDSAAccount, StandardAccountType};
use key_wallet::account::{Account, AccountType, StandardAccountType};
use key_wallet::bip32::DerivationPath;
use key_wallet::bip32::ExtendedPubKey;
use key_wallet::derivation_bls_bip32::ExtendedBLSPubKey;
Expand All @@ -24,10 +24,10 @@ use std::str::FromStr;

use crate::types::{FFINetwork, Network};
use platform_wallet::changeset::{
AccountAddressPoolEntry, AccountRegistrationEntry, ClientStartState, ClientWalletStartState,
ListedCoreTxid, Merge, PersistenceCapabilities, PersistenceError, PlatformWalletChangeSet,
PlatformWalletPersistence, ProviderKeyAccountEntry, ProviderKeyExtendedPubKey,
PERSISTENCE_CAPABILITIES_VERSION,
rebuild_provider_key_account, AccountAddressPoolEntry, AccountRegistrationEntry,
ClientStartState, ClientWalletStartState, ListedCoreTxid, Merge, PersistenceCapabilities,
PersistenceError, PlatformWalletChangeSet, PlatformWalletPersistence, ProviderKeyAccountEntry,
ProviderKeyExtendedPubKey, PERSISTENCE_CAPABILITIES_VERSION,
};
use platform_wallet::wallet::platform_wallet::WalletId;
use platform_wallet::wallet::{PerAccountPlatformAddressState, PerWalletPlatformAddressState};
Expand Down Expand Up @@ -4499,20 +4499,18 @@ fn build_wallet_start_state(
unsafe { slice_from_raw(spec.account_xpub_bytes, spec.account_xpub_bytes_len) };

// Provider key-material accounts (BLS operator keys / EdDSA
// platform node keys) live in dedicated `Option` fields on the
// collection and carry a non-secp256k1 extended public key in
// the same `account_xpub_bytes` slot. Rebuild them watch-only
// via the type-specific `new` + insert methods rather than the
// ECDSA `Account::from_xpub` / `insert` path (which would fail
// to decode the bytes and reject the provider `AccountType`).
// Provider xpubs are stored raw (`bincode(xpub)`), exactly like the
// ECDSA accounts. The derivation scheme is NOT versioned here: this
// app is pre-release and the pre-#879 (secp256k1-hybrid) derivation
// never shipped to production. A wallet whose provider accounts were
// platform node keys) carry a non-secp256k1 extended public key in
// the same `account_xpub_bytes` slot; `account_type` discriminates
// the decode. The rebuild itself is the shared helper every backend's
// restore path uses (the SQLite backend calls it too). Provider
// xpubs are stored raw (`bincode(xpub)`), exactly like the ECDSA
// accounts. The derivation scheme is NOT versioned here: this app is
// pre-release and the pre-#879 (secp256k1-hybrid) derivation never
// shipped to production. A wallet whose provider accounts were
// persisted by a pre-#879 dev build will restore those (stale) xpubs
// and show stale operator / platform-node keys until it's deleted
// and re-imported — an accepted, transient dev-only state.
match account_type {
let provider_key = match account_type {
AccountType::ProviderOperatorKeys => {
let (bls_pubkey, _): (ExtendedBLSPubKey, usize) =
bincode::decode_from_slice(xpub_bytes, config::standard()).map_err(|e| {
Expand All @@ -4521,22 +4519,7 @@ fn build_wallet_start_state(
e
))
})?;
let bls_account = BLSAccount::new(
Some(entry.wallet_id.to_vec()),
account_type,
bls_pubkey,
network,
)
.map_err(|e| {
PersistenceError::backend(format!("BLSAccount::new failed: {:?}", e))
})?;
accounts.insert_bls_account(bls_account).map_err(|e| {
PersistenceError::backend(format!(
"AccountCollection::insert_bls_account failed: {}",
e
))
})?;
continue;
Some(ProviderKeyExtendedPubKey::Bls(bls_pubkey))
}
AccountType::ProviderPlatformKeys => {
let (ed_pubkey, _): (ExtendedEd25519PubKey, usize) =
Expand All @@ -4546,29 +4529,22 @@ fn build_wallet_start_state(
e
))
})?;
let eddsa_account = EdDSAAccount::new(
Some(entry.wallet_id.to_vec()),
account_type,
ed_pubkey,
network,
)
.map_err(|e| {
PersistenceError::backend(format!("EdDSAAccount::new failed: {:?}", e))
})?;
accounts.insert_eddsa_account(eddsa_account).map_err(|e| {
PersistenceError::backend(format!(
"AccountCollection::insert_eddsa_account failed: {}",
e
))
})?;
// The platform-node (Ed25519) pool is rehydrated from the
// persisted core-address rows like every other pool — see
// `restore_core_address_pools`. Those rows now carry the
// typed EdDSA key + `KeyTypeTagFFI::EdDSA`, so no dedicated
// batch side-channel is needed here.
continue;
Some(ProviderKeyExtendedPubKey::EdDSA(ed_pubkey))
}
_ => {}
_ => None,
};
if let Some(key) = provider_key {
rebuild_provider_key_account(
&mut accounts,
entry.wallet_id,
network,
account_type,
&key,
)
.map_err(|e| {
PersistenceError::backend(format!("provider key account rebuild failed: {e}"))
})?;
continue;
}

let (account_xpub, _): (ExtendedPubKey, usize) =
Expand Down
19 changes: 4 additions & 15 deletions packages/rs-platform-wallet/src/broadcaster.rs
Original file line number Diff line number Diff line change
Expand Up @@ -124,13 +124,6 @@ impl TransactionBroadcaster for DapiBroadcaster {
}
}

/// How long the SPV broadcast waits for a network-acceptance verdict before
/// reporting the outcome as unknown. Shorter than dash-spv's own default so a
/// user-facing send does not hang for a full minute. On live Dash networks
/// acceptance usually resolves in seconds via the InstantSend lock or the
/// withheld-peer echo, well inside this bound.
const SPV_ACCEPTANCE_TIMEOUT: Duration = Duration::from_secs(30);

/// The SPV broadcast channel: send through P2P peers and await dash-spv's
/// network-acceptance verdict (rust-dashcore#913).
#[async_trait]
Expand Down Expand Up @@ -188,11 +181,7 @@ impl SpvBroadcaster {
impl TransactionBroadcaster for SpvBroadcaster {
async fn broadcast(&self, transaction: &Transaction) -> Result<Txid, BroadcastError> {
let txid = transaction.txid();
match self
.spv
.broadcast_and_wait(transaction, Some(SPV_ACCEPTANCE_TIMEOUT))
.await
{
match self.spv.broadcast_and_wait(transaction, None).await {
Ok(BroadcastResult::Accepted { relayed_by }) => {
tracing::info!(
txid = %txid,
Expand All @@ -208,9 +197,9 @@ impl TransactionBroadcaster for SpvBroadcaster {
// later echo/IS-lock/confirmation or the reservation-TTL
// backstop reconciles the reservation.
Ok(BroadcastResult::Uncertain) => Err(BroadcastError::MaybeSent {
reason: format!(
"SPV broadcast saw no acceptance signal within {SPV_ACCEPTANCE_TIMEOUT:?}"
),
reason:
"SPV broadcast saw no acceptance signal before dash-spv's acceptance timeout"
.to_string(),
}),
// Provably never sent (per the SpvChannel error contract): no
// bytes reached the network, so the reservation is safe to
Expand Down
7 changes: 4 additions & 3 deletions packages/rs-platform-wallet/src/changeset/core_bridge.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1336,9 +1336,10 @@ fn derive_new_utxos(record: &TransactionRecord) -> Vec<Utxo> {
/// the script and the address as independent parameters and validates
/// neither.
///
/// Height and the confirmation flags describe the *previous* transaction and
/// aren't carried in `InputDetail`, so they remain defaulted on this synthetic
/// spent record (height 0, all flags false).
/// Height and the confirmation flags describe the *previous* output and
/// aren't carried in `InputDetail`, so they default (height 0, flags
/// false); `core_utxos` has no column for either, so those defaults never
/// become durable state.
fn derive_spent_utxos(record: &TransactionRecord) -> Vec<Utxo> {
record
.input_details
Expand Down
13 changes: 13 additions & 0 deletions packages/rs-platform-wallet/src/changeset/traits.rs
Original file line number Diff line number Diff line change
Expand Up @@ -266,6 +266,19 @@ pub trait PlatformWalletPersistence: Send + Sync {
/// wallet accessor (readers and writers) for its duration. Keep the
/// per-call work bounded; if the backend does inline I/O (see the type
/// doc), size it accordingly.
///
/// # Transient-failure retry contract
///
/// An implementation that returns a [`PersistenceError`] classified
/// [`PersistenceErrorKind::Transient`] from `store` **MUST** have already
/// buffered/preserved the changeset so that a subsequent bare
/// [`flush`](Self::flush) — with no re-supplied changeset — completes the
/// write (mirroring `flush`'s own transient contract). This is what lets a
/// caller retry a transient `store` failure via `flush` alone; re-calling
/// `store` with the same changeset would double-merge it. An
/// implementation that cannot preserve the changeset on failure MUST
/// classify that failure [`PersistenceErrorKind::Fatal`] (or
/// [`Constraint`](PersistenceErrorKind::Constraint)), never `Transient`.
fn store(
&self,
wallet_id: WalletId,
Expand Down
Loading
Loading