Skip to content
Open
Show file tree
Hide file tree
Changes from 45 commits
Commits
Show all changes
59 commits
Select commit Hold shift + click to select a range
6735575
feat(rs-sdk-ffi): scan, read and seal private contact requests in the…
QuantumExplorer Nov 8, 2026
65ebad2
feat(platform-wallet): expose DashPay private contact requests over t…
QuantumExplorer Nov 8, 2026
cc555f8
feat(swift-sdk): sync, send and accept DashPay private contact requests
QuantumExplorer Nov 8, 2026
ef3a1a5
feat(swift-example-app): send contact requests privately and accept t…
QuantumExplorer Nov 8, 2026
6165a10
Merge branch 'feat/platform-wallet-private-contact-requests' into fea…
QuantumExplorer Nov 8, 2026
29adfca
fix(rs-sdk-ffi): seal private contact requests with the identity sign…
QuantumExplorer Nov 8, 2026
05e2bde
fix(platform-wallet): take the identity signer in the private contact…
QuantumExplorer Nov 8, 2026
c83c19f
fix(swift-sdk): sign private contact requests with the identity signer
QuantumExplorer Nov 8, 2026
ff3791c
fix(swift-example-app): sign private contact requests with the Keycha…
QuantumExplorer Nov 8, 2026
6787f32
Merge branch 'feat/platform-wallet-private-contact-requests' into fea…
QuantumExplorer Nov 8, 2026
7e644eb
Merge branch 'feat/platform-wallet-private-contact-requests' into fea…
QuantumExplorer Nov 8, 2026
bd66fb9
feat(platform-wallet): accept a contact request replying in kind over…
QuantumExplorer Nov 8, 2026
9f01069
feat(swift-sdk): accept a contact request replying in kind
QuantumExplorer Nov 8, 2026
1c07538
refactor(swift-example-app): let the wallet choose how an accepted re…
QuantumExplorer Nov 8, 2026
f00c508
Merge branch 'feat/platform-wallet-private-contact-requests' into fea…
QuantumExplorer Nov 8, 2026
5358ce1
fix(rs-sdk-ffi): build the resolver signer's scan keys with the SDK's…
QuantumExplorer Nov 8, 2026
0510a5b
fix(platform-wallet): pay an in-kind accept's private reply only when…
QuantumExplorer Nov 8, 2026
0e210ed
fix(swift-sdk): count private contact calls as native operations and …
QuantumExplorer Nov 8, 2026
28ddeb8
fix(swift-example-app): keep unconfirmed private submissions pending …
QuantumExplorer Nov 8, 2026
5db1224
Merge branch 'feat/platform-wallet-private-contact-requests' into fea…
QuantumExplorer Nov 8, 2026
9191009
fix(platform-wallet): give a pending private contact request its own …
QuantumExplorer Nov 8, 2026
00487b0
feat(swift-sdk): type a pending private contact request and read it p…
QuantumExplorer Nov 8, 2026
a69a153
fix(swift-example-app): read a pending private reply from the wallet
QuantumExplorer Nov 8, 2026
745eb92
Merge branch 'feat/platform-wallet-private-contact-requests' into fea…
QuantumExplorer Nov 8, 2026
083e9b7
Merge branch 'feat/platform-wallet-private-contact-requests' into fea…
QuantumExplorer Nov 8, 2026
d53ca90
Merge branch 'feat/platform-wallet-private-contact-requests' into fea…
QuantumExplorer Nov 8, 2026
e024423
docs(platform-wallet): name the unread private store as a pending cas…
QuantumExplorer Nov 8, 2026
8a098b2
fix(swift-example-app): keep "Send privately" through Add Contact's c…
QuantumExplorer Nov 8, 2026
f2d2885
Merge branch 'feat/platform-wallet-private-contact-requests' into fea…
QuantumExplorer Nov 8, 2026
bec9a22
Merge branch 'feat/platform-wallet-private-contact-requests' into fea…
QuantumExplorer Nov 8, 2026
9b24f50
Merge remote-tracking branch 'origin/feat/platform-wallet-private-con…
QuantumExplorer Nov 8, 2026
7ac0059
Merge remote-tracking branch 'origin/feat/platform-wallet-private-con…
QuantumExplorer Nov 9, 2026
9640e70
fix(platform-wallet-ffi): keep a wallet removed before a private paym…
QuantumExplorer Nov 9, 2026
df84f92
feat(platform-wallet-ffi,swift-sdk): persist the private contact requ…
QuantumExplorer Nov 9, 2026
62169d3
fix(swift-example-app): keep a failed pending read apart from nothing…
QuantumExplorer Nov 9, 2026
342162b
feat(swift-example-app): show the private contact request scan state …
QuantumExplorer Nov 9, 2026
41e6048
Merge remote-tracking branch 'origin/feat/platform-wallet-private-con…
QuantumExplorer Nov 9, 2026
5166952
test(platform-wallet-ffi): pin the layouts of the private contact req…
QuantumExplorer Nov 9, 2026
26bdbf7
fix(platform-wallet-ffi): require contact persistence for the private…
QuantumExplorer Nov 9, 2026
17ffe98
fix(platform-wallet-ffi): keep protocol-version refusals typed throug…
QuantumExplorer Nov 9, 2026
0f884bc
fix(platform-wallet-ffi): report a seed mismatch from the private sto…
QuantumExplorer Nov 9, 2026
7e93663
fix(swift-sdk): fail a scan-state load on a stored row that cannot cr…
QuantumExplorer Nov 9, 2026
490128a
docs(swift-sdk): describe the restored private scan state and pending…
QuantumExplorer Nov 9, 2026
289829a
fix(swift-example-app): configure the shielded store right after the …
QuantumExplorer Nov 9, 2026
edc9dd1
fix(swift-example-app): keep a code 71 refusal visible until a privat…
QuantumExplorer Nov 9, 2026
e027e23
Merge remote-tracking branch 'origin/feat/platform-wallet-private-con…
QuantumExplorer Nov 9, 2026
0b28e29
feat(platform-wallet-ffi): carry the unresolved private payment marke…
QuantumExplorer Nov 9, 2026
f75b945
feat(swift-sdk): store the unresolved private payment marker with the…
QuantumExplorer Nov 9, 2026
afbe1d6
feat(swift-sdk): type code 72 and read the wallet's private contact r…
QuantumExplorer Nov 9, 2026
7405033
fix(swift-example-app): show restoring private payments from the wall…
QuantumExplorer Nov 9, 2026
27f1a69
feat(platform-wallet-ffi): compute a host's effective persistence cap…
QuantumExplorer Nov 9, 2026
8738c02
Merge remote-tracking branch 'origin/feat/platform-wallet-private-con…
QuantumExplorer Nov 9, 2026
5007b7b
fix(platform-wallet-ffi): keep the private scan state only under the …
QuantumExplorer Nov 9, 2026
530b5c7
test(platform-wallet-ffi): run the accept replying in kind for a mana…
QuantumExplorer Nov 9, 2026
5af46f3
fix(swift-sdk): fail the round when a contact request cannot be stored
QuantumExplorer Nov 9, 2026
63e1ca4
feat(swift-sdk): type code 73 and show it as a storage configuration …
QuantumExplorer Nov 9, 2026
2aefc16
Merge remote-tracking branch 'origin/feat/platform-wallet-private-con…
QuantumExplorer Nov 9, 2026
1de0d4b
Merge remote-tracking branch 'origin/feat/platform-wallet-private-con…
QuantumExplorer Nov 9, 2026
47468fa
docs(platform-wallet-ffi): the readiness getter consumes rejection no…
QuantumExplorer Nov 9, 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
65 changes: 65 additions & 0 deletions packages/rs-platform-wallet-ffi/src/contact_persistence.rs
Original file line number Diff line number Diff line change
Expand Up @@ -214,6 +214,46 @@ pub struct ContactIgnoredSenderFFI {
pub is_ignored: bool,
}

/// One identity's read position in DashPay's private contact request store
/// ([`ContactChangeSet::private_contact_requests_positions`]), for the
/// private contact request scan state callbacks on
/// [`PersistenceCallbacksExtension`](crate::persistence::PersistenceCallbacksExtension).
///
/// Flat POD. The host stores one row per `(wallet_id, identity_id)`,
/// replacing the previous one, and hands it back unchanged at load: a lower
/// `position` than the stored one is a rewind, not a stale write.
///
/// [`ContactChangeSet::private_contact_requests_positions`]: platform_wallet::changeset::ContactChangeSet::private_contact_requests_positions
#[repr(C)]
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct PrivateContactRequestsPositionFFI {
/// The wallet-owned identity reading the store.
pub identity_id: [u8; 32],
/// The store position the identity reads from next.
pub position: u64,
/// The fingerprint of the scanning keys `position` holds for. Opaque
/// to the host: store it and return it as it came.
pub key_fingerprint: [u8; 32],
Comment thread
QuantumExplorer marked this conversation as resolved.
Resolved
}

/// The shielded address `contact_id`'s tracked private request to
/// `owner_id` carries ([`ContactChangeSet::private_shielded_addresses`]).
///
/// Flat POD. The host stores one row per `(wallet_id, owner_id,
/// contact_id)`, replacing the previous one.
///
/// [`ContactChangeSet::private_shielded_addresses`]: platform_wallet::changeset::ContactChangeSet::private_shielded_addresses
#[repr(C)]
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct PrivateShieldedAddressFFI {
/// The wallet-owned identity the request is to.
pub owner_id: [u8; 32],
/// The contact who sent the request.
pub contact_id: [u8; 32],
/// The raw 43-byte Orchard payment address.
pub address: [u8; 43],
}

// Compile-time guards. Pin the expected layouts so any reshape on
// the Rust side fails the cargo build before it can ship a dylib
// the Swift side will mis-parse at runtime.
Expand Down Expand Up @@ -268,6 +308,31 @@ const _: [u8; 1] = [0u8; std::mem::align_of::<ContactRequestRemovalFFI>()];
const _: [u8; 65] = [0u8; std::mem::size_of::<ContactIgnoredSenderFFI>()];
const _: [u8; 1] = [0u8; std::mem::align_of::<ContactIgnoredSenderFFI>()];

// Expected `PrivateContactRequestsPositionFFI` layout on 64-bit targets:
//
// 0..=31 identity_id [u8; 32]
// 32..=39 position u64
// 40..=71 key_fingerprint [u8; 32]
//
// Total size = 72, alignment = 8 (from the u64).
const _: [u8; 72] = [0u8; std::mem::size_of::<PrivateContactRequestsPositionFFI>()];
const _: [u8; 8] = [0u8; std::mem::align_of::<PrivateContactRequestsPositionFFI>()];
const _: () = assert!(std::mem::offset_of!(PrivateContactRequestsPositionFFI, position) == 32);
const _: () =
assert!(std::mem::offset_of!(PrivateContactRequestsPositionFFI, key_fingerprint) == 40);

// Expected `PrivateShieldedAddressFFI` layout on all targets:
//
// 0..=31 owner_id [u8; 32]
// 32..=63 contact_id [u8; 32]
// 64..=106 address [u8; 43]
//
// Total size = 107, alignment = 1 (all-byte fields).
const _: [u8; 107] = [0u8; std::mem::size_of::<PrivateShieldedAddressFFI>()];
const _: [u8; 1] = [0u8; std::mem::align_of::<PrivateShieldedAddressFFI>()];
const _: () = assert!(std::mem::offset_of!(PrivateShieldedAddressFFI, contact_id) == 32);
const _: () = assert!(std::mem::offset_of!(PrivateShieldedAddressFFI, address) == 64);

impl ContactIgnoredSenderFFI {
/// Project an `(owner, sender)` ignore key onto its flat C mirror.
/// `is_ignored` distinguishes an ignore (persist the row) from an
Expand Down
124 changes: 107 additions & 17 deletions packages/rs-platform-wallet-ffi/src/dashpay.rs
Original file line number Diff line number Diff line change
Expand Up @@ -190,7 +190,7 @@ impl ContactRequestHandleArray {
}

/// Copy a slice of contact requests into the global handle storage.
fn from_requests(requests: Vec<ContactRequest>) -> Self {
pub(crate) fn from_requests(requests: Vec<ContactRequest>) -> Self {
if requests.is_empty() {
return Self::empty();
}
Expand Down Expand Up @@ -325,22 +325,9 @@ pub unsafe extern "C" fn platform_wallet_send_contact_request_with_signer(
} else {
Some(unwrap_result_or_return!(CStr::from_ptr(account_label).to_str()).to_string())
};
let proof: Option<Vec<u8>> = if auto_accept_proof.is_null() || auto_accept_proof_len == 0 {
None
} else {
// Defense-in-depth: bound the copy at the DIP-15 auto-accept proof
// ceiling (102 bytes) BEFORE allocating, so a malformed binding or a
// hostile (ptr, len) pair can't force an oversized allocation. The SDK
// re-validates the exact 38..=102 range after this.
if auto_accept_proof_len > 102 {
return crate::error::PlatformWalletFFIResult::err(
crate::error::PlatformWalletFFIResultCode::ErrorInvalidParameter,
format!(
"auto_accept_proof length {auto_accept_proof_len} exceeds the DIP-15 maximum of 102 bytes"
),
);
}
Some(std::slice::from_raw_parts(auto_accept_proof, auto_accept_proof_len).to_vec())
let proof = match read_auto_accept_proof(auto_accept_proof, auto_accept_proof_len) {
Ok(proof) => proof,
Err(result) => return result,
};

let signer_addr = signer_handle as usize;
Expand Down Expand Up @@ -380,6 +367,31 @@ pub unsafe extern "C" fn platform_wallet_send_contact_request_with_signer(
PlatformWalletFFIResult::ok()
}

/// Copy an optional auto-accept proof from `(ptr, len)`; `(null, 0)` omits it.
///
/// Defense-in-depth: the copy is bounded at the DIP-15 auto-accept proof
/// ceiling (102 bytes) BEFORE allocating, so a malformed binding or a hostile
/// (ptr, len) pair can't force an oversized allocation. The SDK re-validates
/// the exact 38..=102 range after this.
///
/// # Safety
/// A non-null `ptr` must point to `len` readable bytes.
pub(crate) unsafe fn read_auto_accept_proof(
ptr: *const u8,
len: usize,
) -> Result<Option<Vec<u8>>, PlatformWalletFFIResult> {
if ptr.is_null() || len == 0 {
return Ok(None);
}
if len > 102 {
return Err(PlatformWalletFFIResult::err(
PlatformWalletFFIResultCode::ErrorInvalidParameter,
format!("auto_accept_proof length {len} exceeds the DIP-15 maximum of 102 bytes"),
));
}
Ok(Some(std::slice::from_raw_parts(ptr, len).to_vec()))
}

/// Send a contact request from a scanned DIP-15 auto-accept QR
/// (`dash:?du=<username>&dapk=<key_blob>`). Resolves the QR's username to the
/// owner's identity, decodes the handed auto-accept key, signs the proof over
Expand Down Expand Up @@ -1752,6 +1764,84 @@ pub unsafe extern "C" fn platform_wallet_drainable_contact_crypto_count(
mod tests {
use super::*;

mod private_store_scan_errors {
use super::*;
use platform_wallet::ContactCryptoProvider;
use rs_sdk_ffi::{
dash_sdk_mnemonic_resolver_create, dash_sdk_mnemonic_resolver_destroy,
mnemonic_resolver_result,
};
use std::ffi::c_void;
use std::str::FromStr;

unsafe extern "C" fn no_mnemonic(
_ctx: *const c_void,
_wallet_id_bytes: *const u8,
_out_buf: *mut c_char,
_out_capacity: usize,
_out_len: *mut usize,
) -> i32 {
mnemonic_resolver_result::NOT_FOUND
}

unsafe extern "C" fn noop_destroy(_ctx: *mut c_void) {}

/// A scan of the private store whose resolver holds no mnemonic for the
/// wallet reaches the host as the key-unavailable outcome (code 31), as
/// a signer's missing key does, so the host routes it to key repair
/// instead of reading a generic failure.
#[test]
fn should_report_a_missing_mnemonic_while_scanning_as_key_unavailable() {
let resolver = unsafe {
dash_sdk_mnemonic_resolver_create(std::ptr::null_mut(), no_mnemonic, noop_destroy)
};
let provider = unsafe {
resolver_contact_crypto_provider(resolver, [0u8; 32], key_wallet::Network::Testnet)
};
let keys = PrivateDocumentKeyPaths {
recipients: vec![(
Identifier::from([0xB0; 32]),
vec![(
2,
key_wallet::bip32::DerivationPath::from_str("m/9'/1'/5'/0'/0'/1'/2'")
.expect("valid path"),
)],
)],
senders: Vec::new(),
};
let page = PrivateDocumentEntries {
chunk_power: None,
entries: Vec::new(),
total_count: 0,
};
let error = runtime()
.block_on(provider.scan_private_document_page(
&keys,
Identifier::from([0xD0; 32]),
"privateContactRequest",
0,
&page,
))
.expect_err("no mnemonic, no keys");
let result = PlatformWalletFFIResult::from(error);
assert_eq!(
result.code,
PlatformWalletFFIResultCode::ErrorSigningKeyUnavailable
);

// Another resolver failure keeps the code it had.
let other = PlatformWalletFFIResult::from(private_document_signer_error(
MnemonicResolverSignerError::BufferTooSmall,
));
assert_ne!(
other.code,
PlatformWalletFFIResultCode::ErrorSigningKeyUnavailable
);

unsafe { dash_sdk_mnemonic_resolver_destroy(resolver) };
}
}

/// A test wallet's mnemonic, counting how often the resolver reads it.
unsafe extern "C" fn counting_resolve(
ctx: *const std::ffi::c_void,
Expand Down
2 changes: 2 additions & 0 deletions packages/rs-platform-wallet-ffi/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,7 @@ pub mod platform_address_sync;
pub mod platform_address_types;
pub mod platform_addresses;
pub mod platform_wallet_info;
pub mod private_contact_request;
pub mod provider_key_at_index;
mod runtime;
pub mod secp256k1_primitives;
Expand Down Expand Up @@ -156,6 +157,7 @@ pub use platform_address_sync::*;
pub use platform_address_types::*;
pub use platform_addresses::*;
pub use platform_wallet_info::*;
pub use private_contact_request::*;
pub use provider_key_at_index::*;
pub use secp256k1_primitives::*;
#[cfg(feature = "shielded")]
Expand Down
91 changes: 85 additions & 6 deletions packages/rs-platform-wallet-ffi/src/manager.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,12 +8,14 @@ use crate::event_handler::{
};
use crate::handle::*;
use crate::persistence::{
FFIPersister, FreeTrackedMasternodesFn, LoadAssetLockOperationIdFn,
LoadIdentityBalanceBlockTimeFn, LoadTrackedMasternodesFn, PersistAssetLockOperationIdFn,
PersistDpnsNameStatesFn, PersistIdentityBalanceBlockTimeFn, PersistTrackedMasternodesFn,
PersistWalletChangesetChainLockHeightFn, PersistWalletChangesetSweepsFn,
PersistWalletChangesetUtxoVerdictsFn, PersistWalletDashPayBackfillFn, PersistenceCallbacks,
PersistenceCallbacksExtension, PersistenceCapabilitiesFFI, PersistenceExtensionCallbacks,
FFIPersister, FreePrivateContactScanStateFn, FreeTrackedMasternodesFn,
LoadAssetLockOperationIdFn, LoadIdentityBalanceBlockTimeFn, LoadPrivateContactScanStateFn,
LoadTrackedMasternodesFn, PersistAssetLockOperationIdFn, PersistDpnsNameStatesFn,
PersistIdentityBalanceBlockTimeFn, PersistPrivateContactScanStateFn,
PersistTrackedMasternodesFn, PersistWalletChangesetChainLockHeightFn,
PersistWalletChangesetSweepsFn, PersistWalletChangesetUtxoVerdictsFn,
PersistWalletDashPayBackfillFn, PersistenceCallbacks, PersistenceCallbacksExtension,
PersistenceCapabilitiesFFI, PersistenceExtensionCallbacks,
PLATFORM_WALLET_PERSISTENCE_CALLBACKS_EXTENSION_VERSION,
};
use crate::runtime::runtime;
Expand Down Expand Up @@ -290,6 +292,18 @@ unsafe fn persistence_extension_callbacks(
on_load_asset_lock_operation_id_fn,
LoadAssetLockOperationIdFn
),
persist_private_contact_scan_state: slot!(
on_persist_private_contact_scan_state_fn,
PersistPrivateContactScanStateFn
),
load_private_contact_scan_state: slot!(
on_load_private_contact_scan_state_fn,
LoadPrivateContactScanStateFn
),
load_private_contact_scan_state_free: slot!(
on_load_private_contact_scan_state_free_fn,
FreePrivateContactScanStateFn
),
}
}

Expand Down Expand Up @@ -867,6 +881,9 @@ pub unsafe extern "C" fn platform_wallet_manager_remove_wallet(
#[cfg(test)]
mod tests {
use super::*;
use crate::contact_persistence::{
ContactRequestRemovalFFI, PrivateContactRequestsPositionFFI, PrivateShieldedAddressFFI,
};

unsafe extern "C" fn begin_changeset(_context: *mut c_void, _wallet_id: *const u8) -> i32 {
0
Expand Down Expand Up @@ -1372,6 +1389,68 @@ mod tests {
assert!(parsed.load_identity_balance_block_time.is_none());
}

#[test]
fn should_size_gate_private_contact_scan_state_slots_independently() {
#[allow(clippy::too_many_arguments)]
unsafe extern "C" fn persist(
_: *mut c_void,
_: *const u8,
_: *const PrivateContactRequestsPositionFFI,
_: usize,
_: *const PrivateShieldedAddressFFI,
_: usize,
_: *const ContactRequestRemovalFFI,
_: usize,
_: *const [u8; 32],
_: usize,
) -> i32 {
0
}
unsafe extern "C" fn load(
_: *mut c_void,
_: *const u8,
_: *mut *const PrivateContactRequestsPositionFFI,
_: *mut usize,
_: *mut *const PrivateShieldedAddressFFI,
_: *mut usize,
) -> i32 {
0
}
unsafe extern "C" fn free(
_: *mut c_void,
_: *const PrivateContactRequestsPositionFFI,
_: usize,
_: *const PrivateShieldedAddressFFI,
_: usize,
) {
}
let mut ext = PersistenceCallbacksExtension {
on_persist_private_contact_scan_state_fn: Some(persist),
on_load_private_contact_scan_state_fn: Some(load),
on_load_private_contact_scan_state_free_fn: Some(free),
..Default::default()
};
let parsed = unsafe { persistence_extension_callbacks(&ext) };
assert!(parsed.persist_private_contact_scan_state.is_some());
assert!(parsed.load_private_contact_scan_state.is_some());
assert!(parsed.load_private_contact_scan_state_free.is_some());
ext.struct_size = std::mem::offset_of!(
PersistenceCallbacksExtension,
on_load_private_contact_scan_state_free_fn
);
let parsed = unsafe { persistence_extension_callbacks(&ext) };
assert!(parsed.load_private_contact_scan_state.is_some());
assert!(parsed.load_private_contact_scan_state_free.is_none());
ext.struct_size = std::mem::offset_of!(
PersistenceCallbacksExtension,
on_persist_private_contact_scan_state_fn
);
let parsed = unsafe { persistence_extension_callbacks(&ext) };
assert!(parsed.persist_private_contact_scan_state.is_none());
assert!(parsed.load_private_contact_scan_state.is_none());
assert!(parsed.load_private_contact_scan_state_free.is_none());
}

#[test]
fn should_size_gate_asset_lock_operation_id_slots_independently() {
unsafe extern "C" fn persist(
Expand Down
Loading
Loading