Skip to content
Open
Show file tree
Hide file tree
Changes from 5 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
58 changes: 57 additions & 1 deletion packages/rs-platform-wallet-ffi/src/persistence.rs
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,8 @@ use crate::wallet_registration_persistence::AccountAddressPoolFFI;
use crate::wallet_restore_types::{
AccountSpecFFI, AccountTypeTagFFI, ContactProfileRestoreEntryFFI, IdentityKeyRestoreFFI,
IdentityRestoreEntryFFI, LoadWalletListFreeFn, PaymentRestoreEntryFFI,
ProviderSpecialTxRestoreEntryFFI, StandardAccountTypeTagFFI, UnresolvedAssetLockTxRecordFFI,
ProviderSpecialTxRestoreEntryFFI, StandardAccountTypeTagFFI, UnconfirmedOutgoingTxRecordFFI,
UnresolvedAssetLockTxRecordFFI,
UtxoRestoreEntryFFI, WalletRestoreEntryFFI,
};
use dpp::address_funds::PlatformAddress;
Expand Down Expand Up @@ -5482,11 +5483,66 @@ fn build_wallet_start_state(
// was interrupted by an app kill can resume from the latest
// status without rebroadcasting.
let unused_asset_locks = build_unused_asset_locks(entry)?;

// Decode the sends the host still holds as unconfirmed. Decode
// only: applying the spend needs `check_core_transaction`, which is
// async and wants the `Wallet` and the `ManagedWalletInfo`
// together, so the replay happens at the async boundary in
// `manager::load::load_from_persistor`. See
// `ClientWalletStartState::unconfirmed_outgoing_txs`.
//
// Sorted by the host's `first_seen` so a parent send is replayed
// before a child that spends its change; a child applied first
// would find its input still absent and be dropped as irrelevant.
let unconfirmed_outgoing_txs = {
use dashcore::consensus::Decodable;
let recs: &[UnconfirmedOutgoingTxRecordFFI] = if entry
.unconfirmed_outgoing_tx_records
.is_null()
|| entry.unconfirmed_outgoing_tx_records_count == 0
{
&[]
} else {
unsafe {
slice::from_raw_parts(
entry.unconfirmed_outgoing_tx_records,
entry.unconfirmed_outgoing_tx_records_count,
)
}
};
let mut decoded: Vec<(u64, dashcore::blockdata::transaction::Transaction)> =
Vec::with_capacity(recs.len());
let mut dropped_decode = 0usize;
for rec in recs {
if rec.tx_bytes.is_null() || rec.tx_bytes_len == 0 {
dropped_decode += 1;
continue;
}
let bytes = unsafe { slice::from_raw_parts(rec.tx_bytes, rec.tx_bytes_len) };
match dashcore::blockdata::transaction::Transaction::consensus_decode(
&mut &bytes[..],
) {
Ok(tx) => decoded.push((rec.first_seen, tx)),
Err(_) => dropped_decode += 1,
Comment thread
romchornyi marked this conversation as resolved.
Outdated
}
}
if dropped_decode > 0 {
tracing::warn!(
wallet_id = %hex::encode(entry.wallet_id),
dropped_decode,
"load: unconfirmed outgoing tx records failed to decode"
);
}
decoded.sort_by_key(|(first_seen, _)| *first_seen);
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
decoded.into_iter().map(|(_, tx)| tx).collect::<Vec<_>>()
};

let wallet_state = ClientWalletStartState {
wallet,
wallet_info,
identity_manager,
unused_asset_locks,
unconfirmed_outgoing_txs,
};

let platform_address_state = if per_account.is_empty()
Expand Down
27 changes: 27 additions & 0 deletions packages/rs-platform-wallet-ffi/src/wallet_restore_types.rs
Original file line number Diff line number Diff line change
Expand Up @@ -573,6 +573,21 @@ pub struct ProviderSpecialTxRestoreEntryFFI {
pub first_seen: u64,
}

/// One outgoing transaction the host still holds as unconfirmed,
/// replayed at load so its spend effect survives a restart.
#[repr(C)]
pub struct UnconfirmedOutgoingTxRecordFFI {
/// Consensus-encoded transaction body, the same wire format
/// `dashcore::consensus::encode::serialize` produces. Swift-owned
/// for the callback window; freed by `LoadWalletListFreeFn`.
pub tx_bytes: *mut u8,
pub tx_bytes_len: usize,
/// Host's `firstSeen` for the row, in seconds. The load path
/// replays in ascending order so a parent send is applied before a
/// child that spends its change.
pub first_seen: u64,
}

/// Per-wallet entry returned by `on_load_wallet_list_fn`.
///
/// `accounts` points to a contiguous array of length `accounts_count`.
Expand Down Expand Up @@ -644,6 +659,16 @@ pub struct WalletRestoreEntryFFI {
/// unresolved asset locks.
pub unresolved_asset_lock_tx_records: *const UnresolvedAssetLockTxRecordFFI,
pub unresolved_asset_lock_tx_records_count: usize,
/// Outgoing transactions the host still holds as unconfirmed
/// (mempool context, no block height), oldest `first_seen` first.
///
/// Replayed at load through the ordinary mempool check so their
/// spend effect is restored — see
/// [`UnconfirmedOutgoingTxRecordFFI`]. `null` / `0` when the wallet
/// has none. Each entry's `tx_bytes` buffer is Swift-owned and
/// freed by `LoadWalletListFreeFn`.
pub unconfirmed_outgoing_tx_records: *const UnconfirmedOutgoingTxRecordFFI,
pub unconfirmed_outgoing_tx_records_count: usize,
Comment on lines +678 to +679

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 Blocking: Preserve the wallet restore FFI layout or version the ABI

WalletRestoreEntryFFI is a #[repr(C)] struct shared across the FFI boundary, but the new unconfirmed_outgoing_tx_records fields were inserted before the existing provider, address-pool, and chain-lock fields. This changes both the offsets and the size/array stride of the callback structure. A host compiled against the previous layout will have its provider pointer and subsequent fields read at the wrong Rust offsets, and the new Rust code can read beyond the old allocation. Initializing the new fields to null and zero only protects hosts rebuilt against the new definition; it does not make an older binary pass null/zero for fields that did not exist. Preserve the legacy callback structure and add replay data through a separately versioned callback/API, or add an explicit structure size/version handshake and refuse to read fields beyond the supplied size. Add a legacy-layout compatibility test.

source: gpt-6-astra (phase2-reviewer: general, ffi-engineer)

/// Persisted provider special transactions (ProRegTx / ProUpServTx /
/// ProUpRegTx / ProUpRevTx) re-staged onto the wallet's provider-key
/// accounts so rust-dashcore #876 retention keeps them resident and
Expand Down Expand Up @@ -702,6 +727,8 @@ impl Default for WalletRestoreEntryFFI {
tracked_asset_locks_count: 0,
unresolved_asset_lock_tx_records: std::ptr::null(),
unresolved_asset_lock_tx_records_count: 0,
unconfirmed_outgoing_tx_records: std::ptr::null(),
unconfirmed_outgoing_tx_records_count: 0,
provider_special_txs: std::ptr::null(),
provider_special_txs_count: 0,
core_address_pools: std::ptr::null(),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ use std::collections::BTreeMap;

use crate::changeset::identity_manager_start_state::IdentityManagerStartState;
use crate::wallet::asset_lock::tracked::TrackedAssetLock;
use dashcore::OutPoint;
use dashcore::{OutPoint, Transaction};
use key_wallet::wallet::ManagedWalletInfo;
use key_wallet::Wallet;

Expand All @@ -33,4 +33,31 @@ pub struct ClientWalletStartState {
/// Asset locks that have not yet been consumed by an identity
/// registration / top-up, keyed by account index → outpoint.
pub unused_asset_locks: BTreeMap<u32, BTreeMap<OutPoint, TrackedAssetLock>>,
/// Outgoing transactions the host still has as unconfirmed, in
/// `first_seen` order (a parent send precedes a child that spends
/// its change).
///
/// These are decoded here but deliberately NOT applied here: the
/// spend has to travel through the normal
/// `check_core_transaction(.., Mempool, ..)` path so `update_utxos`
/// runs — dropping the input from `utxos` and recording it in
/// `spent_outpoints`. That call is async and needs the `Wallet` and
/// the `ManagedWalletInfo` together, so the replay happens at the
/// async boundary in
/// [`load_from_persistor`](crate::manager::load), not while this
/// snapshot is being built.
///
/// # Why the replay exists
///
/// The spend effect of an unconfirmed outgoing transaction is never
/// persisted: `isSpent` deliberately stays `false` on the input row
/// until the spending transaction reaches a block, because a
/// mempool-only sighting is reversible by eviction. A running app is
/// still correct — the effect lives in memory. Across a restart it
/// used to be recovered only by re-observing the transaction on the
/// network, which is impossible for one that never got there: the
/// input came back as spendable and the balance re-counted it,
/// permanently. Replaying the record at load restores exactly the
/// state the live process held.
pub unconfirmed_outgoing_txs: Vec<Transaction>,
}
Loading
Loading