Skip to content
Merged
Show file tree
Hide file tree
Changes from 5 commits
Commits
Show all changes
14 commits
Select commit Hold shift + click to select a range
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
132 changes: 126 additions & 6 deletions packages/rs-platform-wallet-ffi/src/persistence.rs
Original file line number Diff line number Diff line change
Expand Up @@ -680,6 +680,38 @@ pub struct PersistenceCallbacks {
/// callbacks memory-safe. A context needing no cleanup takes a no-op
/// `release_fn`; `None` is valid only alongside a null `context`.
pub release_fn: Option<unsafe extern "C" fn(context: *mut c_void)>,
/// Enumerate the persisted Core txids that belong to `wallet_id`.
///
/// Appended at the END so the struct layout stays stable — a host
/// built against the previous vtable keeps working, it simply never
/// sets these two slots.
///
/// Used by DashPay sent-payment reconstruction to walk the local
/// transaction history without requiring the optional in-memory
/// `transactions()` map to retain finalized records.
///
/// Output contract:
/// - Set `*out_txids` to a contiguous buffer of `32 * *out_count`
/// bytes, one raw-wire txid per 32-byte chunk, and `*out_count`
/// to the number of txids returned.
/// - Set `*out_txids = null` and `*out_count = 0` when no rows
/// exist for the wallet.
/// - Return `0` on success; non-zero values are treated as backend
/// failures by the Rust side.
pub on_list_wallet_core_txids_fn: Option<
unsafe extern "C" fn(
context: *mut c_void,
wallet_id: *const u8,
out_txids: *mut *const u8,
out_count: *mut usize,
) -> i32,
>,
/// Paired free callback for the txid buffer returned by
/// [`Self::on_list_wallet_core_txids_fn`]. Rust invokes this with
/// the same pointer and txid count, exactly once per successful
/// hit.
pub on_list_wallet_core_txids_free_fn:
Option<unsafe extern "C" fn(context: *mut c_void, txids: *const u8, count: usize)>,
Comment thread
QuantumExplorer marked this conversation as resolved.
Outdated
}

// SAFETY: The context pointer is managed by the FFI caller who must ensure
Expand Down Expand Up @@ -711,6 +743,8 @@ impl Default for PersistenceCallbacks {
on_persist_contacts_fn: None,
on_get_core_tx_record_fn: None,
on_get_core_tx_record_free_fn: None,
on_list_wallet_core_txids_fn: None,
on_list_wallet_core_txids_free_fn: None,
#[cfg(feature = "shielded")]
on_persist_shielded_notes_fn: None,
#[cfg(feature = "shielded")]
Expand Down Expand Up @@ -2730,6 +2764,88 @@ impl PlatformWalletPersistence for FFIPersister {
label: String::new(),
}))
}

fn list_wallet_core_txids(
&self,
wallet_id: WalletId,
) -> Result<Vec<dashcore::Txid>, PersistenceError> {
use dashcore::hashes::Hash;

let Some(list_cb) = self.callbacks.on_list_wallet_core_txids_fn else {
return Ok(Vec::new());
};

let mut txids_ptr: *const u8 = std::ptr::null();
let mut count: usize = 0;

let rc = unsafe {
list_cb(
self.callbacks.context,
wallet_id.as_ptr(),
&mut txids_ptr,
&mut count,
)
};

struct TxidBytesGuard {
ptr: *const u8,
count: usize,
free_fn:
Option<unsafe extern "C" fn(context: *mut c_void, txids: *const u8, count: usize)>,
ctx: *mut c_void,
}
impl Drop for TxidBytesGuard {
fn drop(&mut self) {
if let (Some(free), false) = (self.free_fn, self.ptr.is_null()) {
unsafe { free(self.ctx, self.ptr, self.count) };
}
}
}
let _txid_guard = TxidBytesGuard {
ptr: txids_ptr,
count,
free_fn: self.callbacks.on_list_wallet_core_txids_free_fn,
ctx: self.callbacks.context,
};

if rc != 0 {
return Err(PersistenceError::backend(format!(
"on_list_wallet_core_txids_fn returned non-zero status {rc}"
)));
}
if txids_ptr.is_null() || count == 0 {
return Ok(Vec::new());
}

// Validate the byte length BEFORE building the slice: `from_raw_parts`
// requires it to fit in `isize::MAX`, and an implausible `count` from
// the host would otherwise be silently clamped into a slice that
// outruns the allocation.
let Some(byte_len) = count.checked_mul(32) else {
return Err(PersistenceError::backend(
"on_list_wallet_core_txids_fn reported a txid count whose byte length overflows",
));
};
if byte_len > isize::MAX as usize {
return Err(PersistenceError::backend(
"on_list_wallet_core_txids_fn reported a txid buffer larger than isize::MAX",
));
}

// SAFETY: the host guarantees `txids_ptr` points to `byte_len` valid
// bytes for the duration of the callback window — `_txid_guard` keeps
// that window open until this function returns — and `byte_len` is
// checked above to be a valid slice length.
let raw = unsafe { slice::from_raw_parts(txids_ptr, byte_len) };

let mut out = Vec::with_capacity(count);
for chunk in raw.chunks_exact(32) {
let mut bytes = [0u8; 32];
bytes.copy_from_slice(chunk);
out.push(dashcore::Txid::from_byte_array(bytes));
}
Ok(out)
}
}

/// Decode `count` contiguous 32-byte commitments / nullifiers from a
Expand Down Expand Up @@ -5758,21 +5874,25 @@ mod tests {
assert_eq!(ffi.bits, 0x81);
assert_eq!(std::mem::size_of::<PersistenceCapabilitiesFFI>(), 16);
// Capability negotiation is deliberately NOT appended to the legacy
// callback vtable. Pin the vtable size (invitations + the appended
// `release_fn` context destructor) and prove `release_fn` is the
// terminal field so old clients are never over-read past it.
// callback vtable. Pin the vtable size so a new slot has to be a
// deliberate, reviewed act, and prove the last-appended field really is
// terminal — growth is only safe while it happens at the end, where no
// previously-defined slot changes offset. The count moves with each
// append (invitations, then the `release_fn` context destructor, now
// the txid enumeration pair).
#[cfg(not(feature = "shielded"))]
assert_eq!(
std::mem::size_of::<PersistenceCallbacks>(),
22 * std::mem::size_of::<usize>()
24 * std::mem::size_of::<usize>()
);
#[cfg(feature = "shielded")]
assert_eq!(
std::mem::size_of::<PersistenceCallbacks>(),
38 * std::mem::size_of::<usize>()
40 * std::mem::size_of::<usize>()
);
assert_eq!(
std::mem::offset_of!(PersistenceCallbacks, release_fn) + std::mem::size_of::<usize>(),
std::mem::offset_of!(PersistenceCallbacks, on_list_wallet_core_txids_free_fn)
+ std::mem::size_of::<usize>(),
std::mem::size_of::<PersistenceCallbacks>()
);
assert_eq!(
Expand Down
12 changes: 12 additions & 0 deletions packages/rs-platform-wallet/src/changeset/traits.rs
Original file line number Diff line number Diff line change
Expand Up @@ -344,6 +344,18 @@ pub trait PlatformWalletPersistence: Send + Sync {
Ok(None)
}

/// Enumerate the persisted Core transaction ids that belong to
/// `wallet_id`.
///
/// Used by DashPay sent-payment reconstruction to walk the
/// wallet's locally persisted transaction history without relying
/// on the optional in-memory `transactions()` map. The default
/// implementation returns an empty set for backwards compatibility
/// with backends that don't index wallet-scoped tx history.
fn list_wallet_core_txids(&self, _wallet_id: WalletId) -> Result<Vec<Txid>, PersistenceError> {
Ok(Vec::new())
}

// TODO: `list_wallets` and `delete_wallet` are deferred contract
// candidates. They live as inherent methods on the SQLite backend
// today; they may return to this trait once a cross-backend contract
Expand Down
16 changes: 16 additions & 0 deletions packages/rs-platform-wallet/src/manager/dashpay_sync.rs
Original file line number Diff line number Diff line change
Expand Up @@ -464,6 +464,22 @@ impl DashPaySyncManager {
);
}

// Local-only: rebuild missing `Sent` entries from persisted
// wallet transaction history + the contact external-account
// address pools. Runs after the incoming reconcile so an
// existing received entry under the txid wins the dedup guard.
if let Err(e) = identity
.dashpay()
.reconcile_sent_payments_from_tx_history()
.await
{
tracing::warn!(
wallet_id = %hex::encode(wallet_id),
error = %e,
"DashPay sent-payment reconstruction failed"
);
}

// Local-only: DIP-15 §12.6 coreHeight backfill — lower SPV synced_height
// to re-scan for incoming payments that landed on a contact's receival
// address before it was watched (restore-from-seed / 2nd device /
Expand Down
Loading
Loading