diff --git a/docs/sdk/sdk-parity-manifest.json b/docs/sdk/sdk-parity-manifest.json index d6de8e61082..d1aa778b140 100644 --- a/docs/sdk/sdk-parity-manifest.json +++ b/docs/sdk/sdk-parity-manifest.json @@ -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", @@ -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", + "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", diff --git a/packages/rs-platform-wallet-ffi/src/dashpay_payment.rs b/packages/rs-platform-wallet-ffi/src/dashpay_payment.rs index 0121af9fac2..60a31b340b8 100644 --- a/packages/rs-platform-wallet-ffi/src/dashpay_payment.rs +++ b/packages/rs-platform-wallet-ffi/src/dashpay_payment.rs @@ -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 @@ -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::*; @@ -78,6 +100,86 @@ impl From 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>, +) -> (Vec, Vec) { + let mut storage: Vec = Vec::new(); + let mut rows: Vec = 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). /// diff --git a/packages/rs-platform-wallet-ffi/src/identity_persistence.rs b/packages/rs-platform-wallet-ffi/src/identity_persistence.rs index c1fc96100cf..2d0c526e034 100644 --- a/packages/rs-platform-wallet-ffi/src/identity_persistence.rs +++ b/packages/rs-platform-wallet-ffi/src/identity_persistence.rs @@ -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 diff --git a/packages/rs-platform-wallet-ffi/src/persistence.rs b/packages/rs-platform-wallet-ffi/src/persistence.rs index 1a19ea3b32a..4bed9e757b4 100644 --- a/packages/rs-platform-wallet-ffi/src/persistence.rs +++ b/packages/rs-platform-wallet-ffi/src/persistence.rs @@ -44,6 +44,7 @@ use crate::contact_persistence::{ }; use crate::core_address_types::{AddressPoolTypeTagFFI, CoreAddressEntryFFI, KeyTypeTagFFI}; use crate::core_wallet_types::{free_wallet_changeset_ffi, WalletChangeSetFFI}; +use crate::dashpay_payment::{build_payment_persist_entries, DashpayPaymentPersistEntryFFI}; use crate::identity_persistence::{ free_identity_entry_ffi, free_identity_key_entry_ffi, IdentityEntryFFI, IdentityKeyEntryFFI, IdentityKeyRemovalFFI, @@ -737,6 +738,37 @@ pub struct PersistenceCallbacks { count: usize, ), >, + /// Forwards DashPay payment-history rows — the changeset's + /// `dashpay_payments_overlay`, which `record_dashpay_payment` (the + /// single writer for every payment mutation) populates with exactly + /// the changed `(owner, txid)` row(s) — to the host. Per-call work + /// is therefore bounded by the delta, never the identity's + /// accumulated history; the full-map snapshots riding + /// `changeset.identities` are deliberately not projected. Appended + /// at the END so the struct layout stays stable — a host built + /// against the previous vtable keeps working, it simply never sets + /// this slot. + /// + /// Rows are upserts only: the Rust-side map is append-only history + /// keyed by txid, so status flips (Pending → Confirmed / Failed) + /// re-emit the same `(owner, txid)` row and there is never a + /// tombstone array. Pointers inside each entry are Rust-owned for + /// the callback window; no paired free function (Rust drops the + /// backing strings after the call). + /// + /// Returns 0 on success. A non-zero return flips the round's + /// `success` flag to `false` so [`Self::on_changeset_end_fn`] + /// receives the rollback signal — load-bearing here, because a + /// dropped Sent entry + memo has no on-chain recovery (see + /// `record_dashpay_payment`'s rollback contract). + pub on_persist_dashpay_payments_fn: Option< + unsafe extern "C" fn( + context: *mut c_void, + wallet_id: *const u8, + entries: *const DashpayPaymentPersistEntryFFI, + count: usize, + ) -> i32, + >, } // SAFETY: The context pointer is managed by the FFI caller who must ensure @@ -770,6 +802,7 @@ impl Default for PersistenceCallbacks { on_get_core_tx_record_free_fn: None, on_list_wallet_core_txids_fn: None, on_list_wallet_core_txids_free_fn: None, + on_persist_dashpay_payments_fn: None, #[cfg(feature = "shielded")] on_persist_shielded_notes_fn: None, #[cfg(feature = "shielded")] @@ -1307,6 +1340,46 @@ impl PlatformWalletPersistence for FFIPersister { } } + // Send DashPay payment-history rows — the `dashpay_payments_overlay` + // ONLY. `record_dashpay_payment`, the single writer for every + // payment mutation (live sends, confirm-sweep flips, reconstruction + // upserts), emits exactly the changed `(owner, txid)` row on the + // overlay, so per-round work here is bounded by the delta. The + // full-map `IdentityEntry.dashpay_payments` snapshots riding + // `changeset.identities` are deliberately NOT projected: replaying + // an identity's complete history on every snapshot (including + // unrelated scalar mutations) is unbounded per-call work as history + // grows, against the persistence trait's bounded-work guidance — + // history bootstrap is the host restore buffer's job, and the + // getter-backed reconciler covers gaps. Fires AFTER the identities + // callback so a brand-new owner's `PersistentIdentity` row is + // already staged in the same round when the host resolves the + // payment's owner link. + if let Some(ref overlay) = changeset.dashpay_payments_overlay { + if let Some(cb) = self.callbacks.on_persist_dashpay_payments_fn { + let (entries, _string_storage) = build_payment_persist_entries(overlay); + if !entries.is_empty() { + let result = unsafe { + cb( + self.callbacks.context, + wallet_id.as_ptr(), + entries.as_ptr(), + entries.len(), + ) + }; + drop(entries); + drop(_string_storage); + if result != 0 { + eprintln!( + "DashPay payment persistence callback returned error code {}", + result + ); + round_success = false; + } + } + } + } + // Send identity-keys changeset — per-key upserts + // `(identity_id, key_id)` removals. Maps onto Swift's // `PersistentPublicKey` rows. @@ -5971,20 +6044,20 @@ mod tests { // 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). + // append (invitations, then the `release_fn` context destructor, the + // txid enumeration pair, now the DashPay payment persist slot). #[cfg(not(feature = "shielded"))] assert_eq!( std::mem::size_of::(), - 24 * std::mem::size_of::() + 25 * std::mem::size_of::() ); #[cfg(feature = "shielded")] assert_eq!( std::mem::size_of::(), - 40 * std::mem::size_of::() + 41 * std::mem::size_of::() ); assert_eq!( - std::mem::offset_of!(PersistenceCallbacks, on_list_wallet_core_txids_free_fn) + std::mem::offset_of!(PersistenceCallbacks, on_persist_dashpay_payments_fn) + std::mem::size_of::(), std::mem::size_of::() ); @@ -6034,6 +6107,170 @@ mod tests { ); } + /// A store round carrying a `dashpay_payments_overlay` — the + /// single-row delta `record_dashpay_payment` emits for every + /// payment mutation — must project exactly those rows through + /// `on_persist_dashpay_payments_fn`, and a round carrying only the + /// full-map `IdentityEntry.dashpay_payments` snapshot must NOT + /// fire the callback at all. The first half is the write half of + /// the relaunch-durability loop (without it a live send's Sent + /// entry + memo exist only in memory while `store()` returns Ok); + /// the second half pins the bounded-work contract — replaying an + /// identity's complete history on every snapshot is unbounded + /// per-call work as history grows. + #[test] + fn store_projects_dashpay_payments_overlay_only() { + use platform_wallet::changeset::IdentityChangeSet; + use platform_wallet::wallet::identity::{PaymentEntry, PaymentStatus}; + use std::sync::atomic::{AtomicUsize, Ordering}; + + type CollectedRow = ([u8; 32], String, u64, u8, u8, Option); + + #[derive(Default)] + struct PaymentSink { + rows: std::sync::Mutex>, + calls: AtomicUsize, + } + unsafe extern "C" fn collect_payments( + ctx: *mut c_void, + _wallet_id: *const u8, + entries: *const DashpayPaymentPersistEntryFFI, + count: usize, + ) -> i32 { + let sink = &*(ctx as *const PaymentSink); + sink.calls.fetch_add(1, Ordering::SeqCst); + let slice = std::slice::from_raw_parts(entries, count); + let mut rows = sink.rows.lock().expect("sink lock"); + for e in slice { + let txid = std::ffi::CStr::from_ptr(e.txid) + .to_str() + .expect("txid utf8") + .to_string(); + let memo = if e.memo.is_null() { + None + } else { + Some( + std::ffi::CStr::from_ptr(e.memo) + .to_str() + .expect("memo utf8") + .to_string(), + ) + }; + rows.push(( + e.owner_identity_id, + txid, + e.amount_duffs, + e.direction_raw, + e.status_raw, + memo, + )); + } + 0 + } + + // An identity snapshot whose payments map carries a row that is + // NOT in the overlay — the projection must ignore the snapshot + // entirely. + let identity = dpp::identity::Identity::V0(dpp::identity::v0::IdentityV0::default()); + let mut managed = platform_wallet::ManagedIdentity::new(identity, 0); + let historical_txid = "cc".repeat(32); + managed.dashpay_payments_mut().insert( + historical_txid.clone(), + PaymentEntry::new_sent(dpp::prelude::Identifier::from([6u8; 32]), 999, None), + ); + let owner_id = managed.id(); + let mut id_cs = IdentityChangeSet::default(); + id_cs.identities.insert( + owner_id, + platform_wallet::changeset::IdentityEntry::from_managed(&managed), + ); + + // The overlay delta: one Confirmed Sent row with a memo for the + // snapshot's owner, one Received row for a second owner. + let sent_txid = "aa".repeat(32); + let mut confirmed = PaymentEntry::new_sent( + dpp::prelude::Identifier::from([7u8; 32]), + 12_000, + Some("lunch".into()), + ); + confirmed.status = PaymentStatus::Confirmed; + let other_owner = dpp::prelude::Identifier::from([9u8; 32]); + let received_txid = "bb".repeat(32); + let mut overlay: std::collections::BTreeMap< + dpp::prelude::Identifier, + std::collections::BTreeMap, + > = Default::default(); + overlay + .entry(owner_id) + .or_default() + .insert(sent_txid.clone(), confirmed); + overlay.entry(other_owner).or_default().insert( + received_txid.clone(), + PaymentEntry::new_received(dpp::prelude::Identifier::from([8u8; 32]), 7_500, None), + ); + + let changeset = PlatformWalletChangeSet { + identities: Some(id_cs.clone()), + dashpay_payments_overlay: Some(overlay), + ..Default::default() + }; + + let sink = std::sync::Arc::new(PaymentSink::default()); + let callbacks = PersistenceCallbacks { + context: std::sync::Arc::as_ptr(&sink) as *mut c_void, + on_persist_dashpay_payments_fn: Some(collect_payments), + ..PersistenceCallbacks::default() + }; + let persister = FFIPersister::new(callbacks); + persister + .store([1u8; 32], changeset) + .expect("payment round must succeed"); + + assert_eq!(sink.calls.load(Ordering::SeqCst), 1); + let mut rows = sink.rows.lock().expect("sink lock").clone(); + rows.sort(); + assert_eq!( + rows.len(), + 2, + "exactly the overlay's delta rows — the snapshot's historical row must not ride along" + ); + // BTreeMap order: default-id owner ([0; 32]) before [9; 32]. + let (owner, txid, amount, direction, status, memo) = &rows[0]; + assert_eq!(*owner, owner_id.to_buffer()); + assert_eq!(*txid, sent_txid); + assert_eq!(*amount, 12_000); + assert_eq!(*direction, 0, "Sent discriminant"); + assert_eq!(*status, 1, "Confirmed discriminant"); + assert_eq!(memo.as_deref(), Some("lunch")); + let (owner, txid, amount, direction, status, memo) = &rows[1]; + assert_eq!(*owner, [9u8; 32]); + assert_eq!(*txid, received_txid); + assert_eq!(*amount, 7_500); + assert_eq!(*direction, 1, "Received discriminant"); + assert_eq!(*status, 1, "Received entries record as Confirmed"); + assert!(memo.is_none()); + + // A snapshot-only round (payments in the identity map, no + // overlay) must NOT fire the callback — the bounded-work pin: + // identity snapshots must not replay payment history. + persister + .store( + [1u8; 32], + PlatformWalletChangeSet { + identities: Some(id_cs), + ..Default::default() + }, + ) + .expect("snapshot-only round must succeed"); + assert_eq!(sink.calls.load(Ordering::SeqCst), 1); + + // A payments-free round must not fire the callback at all. + persister + .store([1u8; 32], PlatformWalletChangeSet::default()) + .expect("empty round must succeed"); + assert_eq!(sink.calls.load(Ordering::SeqCst), 1); + } + #[cfg(feature = "shielded")] #[test] fn shielded_viewing_key_capability_requires_complete_callback_triplet() { diff --git a/packages/rs-platform-wallet/src/wallet/identity/state/managed_identity/identity_ops.rs b/packages/rs-platform-wallet/src/wallet/identity/state/managed_identity/identity_ops.rs index 50a615aa7eb..933f3aad5c6 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/state/managed_identity/identity_ops.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/state/managed_identity/identity_ops.rs @@ -157,9 +157,23 @@ impl ManagedIdentity { // A failed overwrite restores the previous entry rather than deleting // it. Either way the persist result is returned, not swallowed: the // user-initiated send path (`send_payment`) surfaces it in the UI. - let previous = self.dashpay.payments.insert(tx_id.clone(), entry); - let cs = self.snapshot_changeset(); - if let Err(e) = persister.store(cs.into()) { + let previous = self.dashpay.payments.insert(tx_id.clone(), entry.clone()); + let mut cs: crate::changeset::PlatformWalletChangeSet = self.snapshot_changeset().into(); + // Ride the changed row on `dashpay_payments_overlay` as well: the + // identity snapshot above carries the FULL payments map (blob-style + // persisters overwrite the whole entry), so a delta-style persister + // projecting the snapshot would replay the identity's complete + // history on every recorded payment — unbounded per-call work as + // history grows, against the `PlatformWalletPersistence` bounded- + // work guidance. The single-row overlay is the bounded carrier + // those persisters (the FFI vtable) project instead; every payment + // mutation funnels through this method, so the overlay sees every + // write. + cs.dashpay_payments_overlay = Some(BTreeMap::from([( + self.id(), + BTreeMap::from([(tx_id.clone(), entry)]), + )])); + if let Err(e) = persister.store(cs) { match previous { Some(prev) => { self.dashpay.payments.insert(tx_id, prev); @@ -640,6 +654,94 @@ mod tests { ); } + /// Recording a payment emits BOTH carriers with the right granularity: + /// the identity snapshot keeps the full payments map (blob-style + /// persisters overwrite the whole entry) while + /// `dashpay_payments_overlay` carries EXACTLY the one changed + /// `(owner, txid)` row — never the accumulated history. Delta-style + /// persisters (the FFI vtable) project only the overlay, so this + /// single-row shape is what keeps per-store work bounded as history + /// grows: N recorded payments must emit N overlay rows total, not + /// 1 + 2 + … + N. + #[test] + fn record_dashpay_payment_emits_single_row_overlay() { + let owner_id = Identifier::from([1u8; 32]); + let identity = Identity::V0(IdentityV0 { + id: owner_id, + public_keys: BTreeMap::new(), + balance: 0, + revision: 0, + }); + let mut managed = ManagedIdentity::new(identity, 0); + let capturing = std::sync::Arc::new(CapturingPersister::default()); + let p = WalletPersister::new([0xAB; 32], capturing.clone() as _); + let alice = Identifier::from([0xAA; 32]); + + managed + .record_dashpay_payment( + "tx1".into(), + PaymentEntry::new_sent(alice, 100, Some("rent".into())), + &p, + ) + .expect("record tx1"); + managed + .record_dashpay_payment( + "tx2".into(), + PaymentEntry::new_received(alice, 250, None), + &p, + ) + .expect("record tx2"); + + let stores = capturing.stores.lock().unwrap(); + assert_eq!(stores.len(), 2); + + // Second store: the snapshot map has both rows, the overlay only + // the newly recorded one. + let cs = &stores[1]; + let snapshot = cs + .identities + .as_ref() + .expect("identity snapshot rides along") + .identities + .get(&owner_id) + .expect("owner entry"); + assert_eq!( + snapshot.dashpay_payments.len(), + 2, + "full map on the blob carrier" + ); + let overlay = cs + .dashpay_payments_overlay + .as_ref() + .expect("overlay must carry the delta") + .get(&owner_id) + .expect("owner overlay"); + assert_eq!( + overlay.len(), + 1, + "the overlay must carry only the changed row, not the history" + ); + let row = overlay.get("tx2").expect("the just-recorded txid"); + assert_eq!(row.amount_duffs, 250); + + // A status flip re-emits the same single (owner, txid) row. + let mut confirmed = PaymentEntry::new_sent(alice, 100, Some("rent".into())); + confirmed.status = crate::wallet::identity::PaymentStatus::Confirmed; + drop(stores); + managed + .record_dashpay_payment("tx1".into(), confirmed.clone(), &p) + .expect("flip tx1"); + let stores = capturing.stores.lock().unwrap(); + let overlay = stores[2] + .dashpay_payments_overlay + .as_ref() + .expect("overlay on the flip") + .get(&owner_id) + .expect("owner overlay"); + assert_eq!(overlay.len(), 1); + assert_eq!(overlay.get("tx1"), Some(&confirmed)); + } + /// A failed payment persist must NOT strand the entry in memory — else a /// caller's `contains_key` retry guard skips the next sweep's re-attempt /// and the Sent entry + memo (no on-chain recovery) is permanently lost. diff --git a/packages/rs-unified-sdk-jni/src/persistence.rs b/packages/rs-unified-sdk-jni/src/persistence.rs index 6c3ec086df4..638217d6db4 100644 --- a/packages/rs-unified-sdk-jni/src/persistence.rs +++ b/packages/rs-unified-sdk-jni/src/persistence.rs @@ -116,8 +116,9 @@ unsafe impl Sync for KotlinPersistenceCtx {} // ShieldedActivityData org/dashfoundation/dashsdk/ffi/ShieldedActivityData // CoreTxRecordData org/dashfoundation/dashsdk/ffi/CoreTxRecordData -/// Assemble the full 32-slot vtable. `context` is the boxed -/// [`KotlinPersistenceCtx`] pointer. +/// Assemble the full persistence vtable (every slot named, wired or an +/// explicit `None`). `context` is the boxed [`KotlinPersistenceCtx`] +/// pointer. pub(crate) fn build_vtable(context: *mut c_void) -> PersistenceCallbacks { PersistenceCallbacks { context, @@ -180,6 +181,12 @@ pub(crate) fn build_vtable(context: *mut c_void) -> PersistenceCallbacks { // pre-restore contact payments) rather than misreporting. on_list_wallet_core_txids_fn: None, on_list_wallet_core_txids_free_fn: None, + // Android derives contact attribution from transaction history on + // reads and doesn't consume `PaymentEntry` rows, so there is + // nothing to land these in. `None` keeps the Rust-side payment + // recording in-memory-only on Android — same behaviour as before + // the slot existed. + on_persist_dashpay_payments_fn: None, release_fn: Some(release_persistence_ctx), } } diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift index 31f5271697b..a665f6d1e9f 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift @@ -121,6 +121,24 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { /// Confined to `serialQueue` like all other mutable handler state. private var deferredBackfills: [(walletId: Data, items: [KeychainManager.IdentityPrivateKeyMetadata])] = [] + /// DashPay payment rows the persister callback could not stage + /// because the owner `PersistentIdentity` row wasn't resolvable + /// mid-round. Normally the owner is visible — the identities + /// callback fires before the payments callback in the same Rust + /// `store()` round, and `FetchDescriptor` sees the round's pending + /// inserts — so this only holds rows whose owner is in neither the + /// current round (yet) nor the store. Drained by `endChangeset` + /// BEFORE the round's single `save()`, so parked rows commit + /// atomically with everything else; a group whose owner is still + /// unresolvable at that point fails the whole round (rollback + + /// failure reported to Rust) rather than committing a lossy + /// persist. Cleared without staging on a failed round: the Rust + /// side rolled its in-memory entries back too, so persisting them + /// later would fabricate history. Never survives a round either + /// way, so it cannot grow across rounds. Confined to `serialQueue` + /// like all other mutable handler state. + private var deferredPaymentUpserts: [(ownerIdentityId: Data, payments: [DashPayPayment])] = [] + public init(modelContainer: ModelContainer, network: Network? = nil) { self.modelContainer = modelContainer self.network = network @@ -1307,6 +1325,7 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { cb.on_get_core_tx_record_free_fn = getCoreTxRecordFreeCallback cb.on_list_wallet_core_txids_fn = listWalletCoreTxidsCallback cb.on_list_wallet_core_txids_free_fn = listWalletCoreTxidsFreeCallback + cb.on_persist_dashpay_payments_fn = persistDashpayPaymentsCallback return cb } @@ -1358,6 +1377,32 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { self.drainDeferredBackfills() } if success { + // Stage payment groups parked on a mid-round missing owner + // BEFORE the round's single save — by now every identity + // insert the round staged is visible to the fetch. A group + // whose owner is STILL unresolvable fails the whole round: + // committing the rest while dropping payment rows would + // report success for a lossy persist, and Rust would keep + // in-memory payment state that never reached disk — the + // exact invariant `record_dashpay_payment`'s rollback + // protects. No second save after the commit: the round + // stays one atomic transaction. + let parked = deferredPaymentUpserts + deferredPaymentUpserts.removeAll() + for entry in parked where !stageDashpayPaymentUpserts( + ownerIdentityId: entry.ownerIdentityId, + payments: entry.payments + ) { + print( + "⚠️ endChangeset: no PersistentIdentity for owner " + + "\(entry.ownerIdentityId.prefix(8).toHexString())… after the " + + "round's identity applies; failing the round so Rust rolls " + + "back \(entry.payments.count) payment row(s) instead of " + + "losing them" + ) + backgroundContext.rollback() + return false + } do { try backgroundContext.save() return true @@ -1374,6 +1419,10 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { } } else { backgroundContext.rollback() + // Parked rows from the failed round die with it: the Rust + // side rolled its in-memory entries back too, so persisting + // them later would fabricate history. + deferredPaymentUpserts.removeAll() return false } } @@ -2399,19 +2448,18 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { // MARK: - DashPay payment-history persistence - /// Upsert DashPay payment-history rows for one owner identity. - /// - /// NOT a persister-callback path — the Rust persister doesn't - /// project payment history. Called by + /// Upsert DashPay payment-history rows for one owner identity — + /// the reconciler half of the payment durability loop. Called by /// `PlatformWalletManager.refreshDashPayPayments` after reading /// the `managed_identity_get_dashpay_payments` getter, so the UI - /// can `@Query` `PersistentDashpayPayment` rows reactively. + /// can `@Query` `PersistentDashpayPayment` rows reactively. The + /// authoritative event-driven half is the + /// `on_persist_dashpay_payments_fn` persister callback + /// (`persistDashpayPayments(walletId:entriesByOwner:)` below); + /// this refresh path reconciles anything the callback era predates + /// or a parked-row drop lost. /// - /// Upsert-only: the Rust `dashpay_payments` map is append-only - /// history (keyed by txid), so a refresh never has to delete - /// rows; cascade from the owner identity handles wallet wipes. - /// Rows are keyed `(networkRaw, ownerIdentityId, txid)`. Skips - /// silently when the owner identity row doesn't exist yet — + /// Skips silently when the owner identity row doesn't exist yet — /// the next refresh after the identity flush replays it. /// /// Saves immediately when no changeset round is open — same @@ -2422,64 +2470,10 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { payments: [DashPayPayment] ) { onQueue { - let ownerId = ownerIdentityId - let ownerDescriptor = FetchDescriptor( - predicate: #Predicate { $0.identityId == ownerId } - ) - guard let owner = try? backgroundContext.fetch(ownerDescriptor).first else { - return - } - let networkRaw = owner.networkRaw - - for payment in payments { - guard !payment.txid.isEmpty else { continue } - let txid = payment.txid - let descriptor = FetchDescriptor( - predicate: #Predicate { - $0.networkRaw == networkRaw - && $0.ownerIdentityId == ownerId - && $0.txid == txid - } - ) - if let existing = try? backgroundContext.fetch(descriptor).first { - // Refresh in place only when a field actually changed. - // The FFI snapshot is authoritative, and `status` is the - // field that moves (Pending → Confirmed / Failed). A - // no-op rewrite would still dirty the row and re-fire - // every `@Query` observer on each refresh pass — and the - // recurring DashPay-sync falling edge calls this even on - // a quiescent channel, so skipping unchanged rows keeps - // an open payment list from re-rendering every sync. - let changed = existing.counterpartyIdentityId != payment.counterpartyId - || existing.amountDuffs != payment.amountDuffs - || existing.directionRaw != payment.direction.rawValue - || existing.statusRaw != payment.status.rawValue - || existing.memo != payment.memo - || existing.owner !== owner - if changed { - existing.counterpartyIdentityId = payment.counterpartyId - existing.amountDuffs = payment.amountDuffs - existing.directionRaw = payment.direction.rawValue - existing.statusRaw = payment.status.rawValue - existing.memo = payment.memo - if existing.owner !== owner { - existing.owner = owner - } - existing.lastUpdated = Date() - } - } else { - let row = PersistentDashpayPayment( - owner: owner, - counterpartyIdentityId: payment.counterpartyId, - amountDuffs: payment.amountDuffs, - direction: payment.direction, - status: payment.status, - txid: payment.txid, - memo: payment.memo - ) - backgroundContext.insert(row) - } - } + guard stageDashpayPaymentUpserts( + ownerIdentityId: ownerIdentityId, + payments: payments + ) else { return } // Same guard as the other app-facing writers // (`setWalletName`, …): a refresh landing while a Rust // persister round is open must ride that round's @@ -2500,6 +2494,118 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { } } + /// Apply one `on_persist_dashpay_payments_fn` persister-callback + /// batch — payment rows flattened out of a Rust `store()` round, + /// grouped by owner identity. This is the event-driven write half + /// of the payment durability loop: it fires on every round whose + /// changeset carries payment rows (a live `send_payment`, a + /// pending→confirmed sweep flip, a reconstruction upsert), so + /// Sent entries + memos are durable without any UI surface ever + /// appearing. + /// + /// Runs mid-round: rows are staged on `backgroundContext` and ride + /// the round's `endChangeset` commit/rollback. A group whose owner + /// `PersistentIdentity` row isn't resolvable — not even as a + /// pending insert staged by this round's identities callback, + /// which fires first — is parked on `deferredPaymentUpserts`; + /// `endChangeset` stages parked groups before the round's single + /// save and fails the round if an owner is still unresolvable (see + /// that field's doc). + func persistDashpayPayments( + walletId: Data, + entriesByOwner: [Data: [DashPayPayment]] + ) { + onQueue { + _ = walletId + for (ownerId, payments) in entriesByOwner { + if !stageDashpayPaymentUpserts(ownerIdentityId: ownerId, payments: payments) { + deferredPaymentUpserts.append((ownerId, payments)) + } + } + // No save here even outside a round: the Rust store() round + // that invoked this callback brackets it with begin/end, so + // `inChangeset` is set in practice; if a host ever fires it + // without a bracket, autosave/next round flushes the stage. + } + } + + /// Stage upserts for one owner's payment rows on + /// `backgroundContext` — shared core of the persister callback, + /// the post-commit replay, and the refresh reconciler. No + /// `save()`; each caller owns its own commit point. Returns + /// `false` (nothing staged) when the owner `PersistentIdentity` + /// row doesn't exist — not even as a pending insert in the open + /// round. Must run on `serialQueue`. + /// + /// Upsert-only: the Rust `dashpay_payments` map is append-only + /// history (keyed by txid), so this never has to delete rows; + /// cascade from the owner identity handles wallet wipes. Rows are + /// keyed `(networkRaw, ownerIdentityId, txid)`. + private func stageDashpayPaymentUpserts( + ownerIdentityId: Data, + payments: [DashPayPayment] + ) -> Bool { + let ownerId = ownerIdentityId + let ownerDescriptor = FetchDescriptor( + predicate: #Predicate { $0.identityId == ownerId } + ) + guard let owner = try? backgroundContext.fetch(ownerDescriptor).first else { + return false + } + let networkRaw = owner.networkRaw + + for payment in payments { + guard !payment.txid.isEmpty else { continue } + let txid = payment.txid + let descriptor = FetchDescriptor( + predicate: #Predicate { + $0.networkRaw == networkRaw + && $0.ownerIdentityId == ownerId + && $0.txid == txid + } + ) + if let existing = try? backgroundContext.fetch(descriptor).first { + // Refresh in place only when a field actually changed. + // The FFI snapshot is authoritative, and `status` is the + // field that moves (Pending → Confirmed / Failed). A + // no-op rewrite would still dirty the row and re-fire + // every `@Query` observer on each refresh pass — and the + // recurring DashPay-sync falling edge calls this even on + // a quiescent channel, so skipping unchanged rows keeps + // an open payment list from re-rendering every sync. + let changed = existing.counterpartyIdentityId != payment.counterpartyId + || existing.amountDuffs != payment.amountDuffs + || existing.directionRaw != payment.direction.rawValue + || existing.statusRaw != payment.status.rawValue + || existing.memo != payment.memo + || existing.owner !== owner + if changed { + existing.counterpartyIdentityId = payment.counterpartyId + existing.amountDuffs = payment.amountDuffs + existing.directionRaw = payment.direction.rawValue + existing.statusRaw = payment.status.rawValue + existing.memo = payment.memo + if existing.owner !== owner { + existing.owner = owner + } + existing.lastUpdated = Date() + } + } else { + let row = PersistentDashpayPayment( + owner: owner, + counterpartyIdentityId: payment.counterpartyId, + amountDuffs: payment.amountDuffs, + direction: payment.direction, + status: payment.status, + txid: payment.txid, + memo: payment.memo + ) + backgroundContext.insert(row) + } + } + return true + } + // MARK: - Identity key derivation-path helpers /// Resolve the wallet's network and format the DIP-9 identity-auth path @@ -7667,3 +7773,59 @@ private func listWalletCoreTxidsFreeCallback( } _ = context } + +/// C shim for `on_persist_dashpay_payments_fn`. Copies every +/// `DashpayPaymentPersistEntryFFI` row into a Swift-owned +/// `DashPayPayment` (grouped by owner identity) before invoking the +/// handler, so the Rust side can drop its backing strings the moment +/// we return. Rows without a txid pointer are skipped defensively — +/// the Rust builder documents `txid` as always non-null. +/// +/// Always returns 0: a missing owner identity parks the group on +/// `deferredPaymentUpserts` — staged before the round's single save, +/// with a still-unresolvable owner failing the round — and a commit +/// failure is reported through the round's `on_changeset_end_fn` +/// return, so per-batch failure signaling here would be redundant. +private func persistDashpayPaymentsCallback( + context: UnsafeMutableRawPointer?, + walletIdPtr: UnsafePointer?, + entriesPtr: UnsafePointer?, + count: UInt +) -> Int32 { + guard let context = context, + let walletIdPtr = walletIdPtr else { + return 0 + } + let handler = Unmanaged + .fromOpaque(context) + .takeUnretainedValue() + let walletId = Data(bytes: walletIdPtr, count: 32) + + var entriesByOwner: [Data: [DashPayPayment]] = [:] + if count > 0, let entriesPtr = entriesPtr { + for i in 0..( + predicate: #Predicate { $0.identityId == target } + ) + let owner = try XCTUnwrap(try context.fetch(ownerDescriptor).first) + owner.wallet = wallet + try context.save() + + // The persister round: begin → payments batch → end. + handler.beginChangeset(walletId: walletId) + handler.persistDashpayPayments( + walletId: walletId, + entriesByOwner: [ownerId: [makePayment(status: .pending, memo: "rent + utilities")]] + ) + // Mid-round: staged, not committed. + XCTAssertEqual( + try fetchPaymentRows().count, 0, + "the callback must ride the round's atomic commit, not flush early" + ) + handler.endChangeset(walletId: walletId, success: true) + + let rows = try fetchPaymentRows() + XCTAssertEqual(rows.count, 1) + let row = try XCTUnwrap(rows.first) + XCTAssertEqual(row.memo, "rent + utilities") + XCTAssertEqual(row.status, .pending) + XCTAssertEqual(row.direction, .sent) + XCTAssertEqual(row.ownerIdentityId, ownerId) + XCTAssertEqual(row.txid, txid) + + // Cold-start restore: the row must ride the identity restore + // buffer's payments array back into Rust. + let (entries, count, errored) = handler.loadWalletList() + XCTAssertFalse(errored) + XCTAssertEqual(count, 1) + let entriesPtr = try XCTUnwrap(entries) + defer { handler.loadWalletListFree(entries: UnsafeRawPointer(entriesPtr)) } + + var restored: [(txid: String, memo: String?)] = [] + let walletEntry = entriesPtr[0] + for iIdx in 0.. 0 else { + continue + } + for pIdx in 0..