Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
0d132c2
feat(platform-wallet): reconstruct sent DashPay payments from tx history
jeanpierreroma Aug 5, 2026
188b486
fix(platform-wallet): address review on sent-payment reconstruction
jeanpierreroma Aug 5, 2026
6134f6a
Merge remote-tracking branch 'origin/v4.2-dev' into fix/dashpay-sent-…
jeanpierreroma Aug 5, 2026
ef9b0df
fix(platform-wallet): name the reconstruction's index types for clippy
jeanpierreroma Aug 5, 2026
4dfc454
fix(platform-wallet-ffi): re-pin the vtable layout after the txid append
jeanpierreroma Aug 5, 2026
e3b193c
fix(platform-wallet): harden sent-payment reconstruction against part…
QuantumExplorer Aug 5, 2026
54758c2
fix(platform-wallet): certify a scan only when history is settled
jeanpierreroma Aug 5, 2026
2d352b9
Merge branch 'v4.2-dev' into fix/dashpay-sent-payment-reconstruction
QuantumExplorer Aug 6, 2026
cb836fa
Merge branch 'v4.2-dev' into fix/dashpay-sent-payment-reconstruction
QuantumExplorer Aug 6, 2026
de9ea9b
Merge remote-tracking branch 'origin/v4.2-dev' into fix/dashpay-sent-…
QuantumExplorer Aug 6, 2026
af67c4e
fix(platform-wallet): certify the sweep against a scan height, not a …
jeanpierreroma Aug 6, 2026
e156123
Merge remote-tracking branch 'origin/fix/dashpay-sent-payment-reconst…
jeanpierreroma Aug 6, 2026
b55002f
fix(platform-wallet): certify reconstruction against the scanned tabl…
QuantumExplorer Aug 6, 2026
c36d0b8
feat(platform-wallet): persist DashPay payment history through the pe…
QuantumExplorer Aug 6, 2026
c3443b0
fix(platform-wallet): address payment-persistence review nits
QuantumExplorer Aug 6, 2026
94def51
Merge remote-tracking branch 'origin/v4.2-dev' into feat/dashpay-paym…
QuantumExplorer Aug 6, 2026
3fe84b3
fix(platform-wallet): bound the payment projection and keep deferred …
QuantumExplorer Aug 6, 2026
1c70311
fix(platform-wallet): declare the payments getter in the parity manif…
QuantumExplorer Aug 6, 2026
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
63 changes: 63 additions & 0 deletions docs/sdk/sdk-parity-manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -27,9 +27,11 @@
"dash_sdk_sign_async_completion": "packages/rs-sdk-ffi/src/signer.rs",
"dpns_name_array_free": "packages/rs-platform-wallet-ffi/src/dpns.rs",
"managed_identity_get_contested_dpns_names": "packages/rs-platform-wallet-ffi/src/dpns.rs",
"managed_identity_get_dashpay_payments": "packages/rs-platform-wallet-ffi/src/dashpay_payment.rs",
"on_load_shielded_viewing_keys_fn": "packages/rs-platform-wallet-ffi/src/persistence.rs",
"on_load_shielded_viewing_keys_free_fn": "packages/rs-platform-wallet-ffi/src/persistence.rs",
"on_persist_address_balances_fn": "packages/rs-platform-wallet-ffi/src/persistence.rs",
"on_persist_dashpay_payments_fn": "packages/rs-platform-wallet-ffi/src/persistence.rs",
"on_persist_shielded_viewing_keys_fn": "packages/rs-platform-wallet-ffi/src/persistence.rs",
"platform_address_wallet_addresses_with_balances": "packages/rs-platform-wallet-ffi/src/platform_addresses/wallet.rs",
"platform_wallet_claim_invitation": "packages/rs-platform-wallet-ffi/src/invitation.rs",
Expand Down Expand Up @@ -1035,6 +1037,67 @@
}
]
},
{
"id": "persistence.dashpay_payment_history",
"title": "DashPay payment history persists event-driven through the persister callback",
"area": "persistence",
"shared_apis": [
"managed_identity_get_dashpay_payments",
Comment thread
coderabbitai[bot] marked this conversation as resolved.
"on_persist_dashpay_payments_fn"
],
"required_persistence_capabilities": [
"atomic_changesets",
"wallet_restore"
],
"hosts": {
"swift": {
"sdk": "partial",
"example_app": "not-applicable",
"restart": "required",
"reason": "The on_persist_dashpay_payments_fn vtable slot lands PersistentDashpayPayment rows on every Rust store() round (live sends with memos, pending-to-confirmed sweep flips, reconstruction upserts), and the identity restore buffer's payments array feeds them back at load; the getter-backed refreshDashPayPayments path remains as a reconciler. Remains partial pending a process-death restart gate: the loadWalletList() round-trip test exercises the write-then-restore loop in-process against an in-memory container, which does not validate survival across a real app kill and relaunch."
},
"kotlin": {
"sdk": "not-applicable",
"example_app": "not-applicable",
"restart": "not_applicable",
"reason": "Android derives contact payment attribution from transaction history on reads and does not consume PaymentEntry rows (confirmed by the Android team during the sent-payment reconstruction review), so the JNI vtable deliberately leaves the slot None and there is nothing to persist or restore on this host."
}
},
"verification": [
{
"host": "swift",
"kind": "unit",
"file": "packages/swift-sdk/SwiftTests/SwiftDashSDKTests/DashPayPersistenceTests.swift",
"id": "testChangesetRoundPersistsSentEntryAndRestoreBufferRoundTripsIt",
"command": "swift test --package-path packages/swift-sdk --filter DashPayPaymentPersistenceTests",
"covers_restart": false
},
{
"host": "swift",
"kind": "unit",
"file": "packages/swift-sdk/SwiftTests/SwiftDashSDKTests/DashPayPersistenceTests.swift",
"id": "testChangesetRoundStatusFlipRepersistsTheSameRow",
"command": "swift test --package-path packages/swift-sdk --filter DashPayPaymentPersistenceTests",
"covers_restart": false
},
{
"host": "shared",
"kind": "unit",
"file": "packages/rs-platform-wallet-ffi/src/persistence.rs",
"id": "store_projects_dashpay_payments_overlay_only",
"command": "cargo test -p platform-wallet-ffi --lib store_projects_dashpay_payments_overlay_only",
"covers_restart": false
},
{
"host": "shared",
"kind": "unit",
"file": "packages/rs-platform-wallet/src/wallet/identity/state/managed_identity/identity_ops.rs",
"id": "record_dashpay_payment_emits_single_row_overlay",
"command": "cargo test -p platform-wallet record_dashpay_payment_emits_single_row_overlay",
"covers_restart": false
}
]
},
{
"id": "network.masternode_discovery",
"title": "Canonical masternode endpoint discovery",
Expand Down
130 changes: 116 additions & 14 deletions packages/rs-platform-wallet-ffi/src/dashpay_payment.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
//! FFI getter for per-contact DashPay payment history.
//! FFI surface for per-contact DashPay payment history: the persister
//! callback's row type ([`DashpayPaymentPersistEntryFFI`]) and an
//! on-demand getter over a live handle.
//!
//! Swift's `ContactDetailView` renders a payment list per contact
//! (`PaymentEntry` on the managed identity's `DashPayState.payments`, keyed by
Expand All @@ -8,29 +10,49 @@
//! [`crate::platform_wallet_get_managed_identity`]) as a flat array of
//! POD-plus-C-string rows.
//!
//! ## Why a getter, not a persister callback
//! ## Persistence: the callback is authoritative, the getter reconciles
//!
//! The `dashpay_payments` map is already part of the persisted
//! `ManagedIdentity` state (it round-trips through `IdentityEntry` and
//! the `dashpay_payments_overlay` changeset field), and the FFI already
//! hands the host a live `ManagedIdentity` handle from which DashPay
//! fields are read directly (e.g.
//! [`crate::established_contact_is_payment_channel_broken`]). A
//! getter therefore lands the smaller, lower-risk diff: no new
//! persister callback, no new SwiftData rehydration path. It mirrors the
//! handle-based array-return pattern already used by
//! [`ContactRequestHandleArray`](crate::dashpay::ContactRequestHandleArray)
//! and [`IdentifierArray`](crate::IdentifierArray).
//! Payment history persists event-driven through
//! `on_persist_dashpay_payments_fn` on the persister vtable, exactly
//! like contact requests and profiles: `record_dashpay_payment` — the
//! single writer for every payment mutation — rides the changed
//! `(owner, txid)` row on `dashpay_payments_overlay`, and every
//! `store()` round carrying that overlay projects it to the host.
//! Only the overlay is projected (never the full-map
//! `IdentityEntry.dashpay_payments` snapshots), so per-round work is
//! bounded by the delta rather than the accumulated history. This
//! closes the write half of the durability loop whose read half — the
//! `payments` array on `IdentityRestoreEntryFFI` — already rehydrates
//! the map at load. (An earlier revision shipped only the
//! [`managed_identity_get_dashpay_payments`] getter, on the rationale
//! that the map "already persists through the changeset" — which was
//! true of the desktop SQLite persister but never of FFI hosts, whose
//! vtable had no payments slot. A host-side `store()` returned Ok while
//! dropping every Sent entry + memo unless the app happened to call the
//! getter-backed refresh path first.)
//!
//! The getter remains as (a) the on-demand read Swift's
//! `refreshDashPayPayments` uses to reconcile persisted rows against
//! live state — belt-and-suspenders over the callback — and (b) the
//! per-contact history read for UI surfaces that want current in-memory
//! state without a persistence round-trip.
//!
//! ## Ownership
//!
//! Each [`DashpayPaymentFFI`] owns its `txid` and (optional) `memo`
//! C-strings. [`dashpay_payment_array_free`] releases every string
//! across the array and the array backing buffer itself.
//! [`DashpayPaymentPersistEntryFFI`] rows are Rust-owned for the
//! duration of the persist callback only (the caller keeps the backing
//! `CString`s alive across the call and drops them after — no paired
//! free function, matching the other persist-direction callbacks).

use std::collections::BTreeMap;
use std::ffi::CString;
use std::os::raw::c_char;

use platform_wallet::wallet::identity::{PaymentDirection, PaymentStatus};
use dpp::prelude::Identifier;
use platform_wallet::wallet::identity::{PaymentDirection, PaymentEntry, PaymentStatus};

use crate::error::*;
use crate::handle::*;
Expand Down Expand Up @@ -78,6 +100,86 @@ impl From<PaymentStatus> for DashpayPaymentStatusFFI {
}
}

/// One DashPay payment-history row forwarded to the host by the
/// `on_persist_dashpay_payments_fn` persister callback.
///
/// Field set mirrors the load-side
/// [`PaymentRestoreEntryFFI`](crate::wallet_restore_types::PaymentRestoreEntryFFI)
/// — same raw `u8` direction/status discriminants, same
/// txid/memo C-string shape — plus the leading `owner_identity_id`,
/// because the persist callback is wallet-scoped while the restore
/// rows already ride inside a per-identity buffer. Keeping the write
/// and restore shapes field-for-field means a host handler and its
/// restore assembler agree by construction.
///
/// All pointers are Rust-owned and valid only for the callback window
/// — the host must copy before returning. Persist direction needs no
/// paired free function (Rust drops the backing `CString`s after the
/// call), matching the other `on_persist_*` callbacks.
#[repr(C)]
pub struct DashpayPaymentPersistEntryFFI {
/// The identity that owns this payment-history row (the
/// `ManagedIdentity` whose `dashpay_payments` map carries it).
pub owner_identity_id: [u8; 32],
/// The other identity in this payment. Whether they are the sender
/// or the receiver is encoded in `direction_raw`.
pub counterparty_id: [u8; 32],
/// Amount in duffs. Always positive; `direction_raw` carries the sign.
pub amount_duffs: u64,
/// `PaymentDirection` discriminant: 0=Sent, 1=Received.
pub direction_raw: u8,
/// `PaymentStatus` discriminant: 0=Pending, 1=Confirmed, 2=Failed.
pub status_raw: u8,
/// NUL-terminated transaction id (hex) — the `dashpay_payments`
/// map key. Always non-null (rows whose txid cannot form a
/// C-string are dropped at build time).
pub txid: *const c_char,
/// NUL-terminated sender memo, or null when the source `Option`
/// was `None`.
pub memo: *const c_char,
}

/// Flatten a `dashpay_payments_overlay` into persist-callback rows.
///
/// Returns the row array plus the `CString` storage backing every
/// `txid` / `memo` pointer — the caller must keep the storage alive
/// until the callback returns. Rows whose txid contains an interior
/// NUL are dropped (unreachable for hex txids; defensive rather than
/// panicking); a memo with an interior NUL degrades to null, matching
/// [`cstring_or_null`]'s contract on the getter side.
pub(crate) fn build_payment_persist_entries(
overlay: &BTreeMap<Identifier, BTreeMap<String, PaymentEntry>>,
) -> (Vec<DashpayPaymentPersistEntryFFI>, Vec<CString>) {
let mut storage: Vec<CString> = Vec::new();
let mut rows: Vec<DashpayPaymentPersistEntryFFI> = Vec::new();
for (owner_id, payments) in overlay {
for (txid, entry) in payments {
let Ok(txid_c) = CString::new(txid.as_str()) else {
continue;
};
storage.push(txid_c);
let txid_ptr = storage.last().expect("pushed txid CString above").as_ptr();
let memo_ptr = match entry.memo.as_deref().map(CString::new) {
Some(Ok(memo_c)) => {
storage.push(memo_c);
storage.last().expect("pushed memo CString above").as_ptr()
}
_ => std::ptr::null(),
};
rows.push(DashpayPaymentPersistEntryFFI {
owner_identity_id: owner_id.to_buffer(),
counterparty_id: entry.counterparty_id.to_buffer(),
amount_duffs: entry.amount_duffs,
direction_raw: DashpayPaymentDirectionFFI::from(entry.direction) as u8,
status_raw: DashpayPaymentStatusFFI::from(entry.status) as u8,
txid: txid_ptr,
memo: memo_ptr,
});
}
}
(rows, storage)
}

/// Flat C mirror of one [`PaymentEntry`](platform_wallet::wallet::identity::PaymentEntry)
/// row on a [`ManagedIdentity`](platform_wallet::ManagedIdentity).
///
Expand Down
6 changes: 4 additions & 2 deletions packages/rs-platform-wallet-ffi/src/identity_persistence.rs
Original file line number Diff line number Diff line change
Expand Up @@ -35,8 +35,10 @@ use platform_wallet::{DashPayProfile, IdentityStatus};
/// [`IdentityKeyEntryFFI`] alongside their derivation breadcrumb via
/// a separate callback. Fields that don't map onto the Swift schema
/// (block times, contested DPNS names, DashPay payments) are skipped;
/// DashPay payment overlays already ride on the dedicated
/// `dashpay_payments_overlay` surface on the parent changeset.
/// DashPay payment rows travel on the dedicated
/// `on_persist_dashpay_payments_fn` callback (which flattens the
/// entry's `dashpay_payments` map together with any
/// `dashpay_payments_overlay` on the parent changeset).
///
/// User-visible label is no longer carried — `ManagedIdentity` doesn't
/// have one, and Swift owns the `PersistentIdentity.alias` column
Expand Down
Loading