Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
51 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
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
2 changes: 1 addition & 1 deletion packages/rs-platform-wallet-ffi/ERROR_CODE_REGISTRY.md
Original file line number Diff line number Diff line change
Expand Up @@ -185,7 +185,7 @@ Fork-era numbers remain in the collision history, which is immutable record.
| 69 | `ErrorShieldedBuildRequired` | DashPay private contact requests (FFI, Swift and Kotlin) | Proposed. The operation needs the shielded pool and the library was built without the `shielded` feature. Today only `platform_wallet_manager_accept_contact_request_replying_in_kind`, exported in every build, returns it, for a request that came privately: its private reply is paid from the pool. Nothing was sent; a public or adopted reply works on such a build. Not retryable on the same build. No existing code meant "shielded unsupported" (`ErrorWalletOperation` with a not-configured message is a runtime state of a shielded build), so it takes the next free number. Swift (raw case, `init(ffi:)` arm, typed `PlatformWalletError.shieldedBuildRequired`, `ErrorHandlingTests` pin) and Kotlin (`DashSdkError.PlatformWallet.ShieldedBuildRequired`, `DashSdkErrorTest` pin) type it |
| 70 | `ErrorProtocolVersionNotActive` | DashPay private contact requests (FFI, Swift and Kotlin) | Proposed. The SDK refused a request without sending it (`dash_sdk::Error::ProtocolVersionNotActive`), because the network is proven at a protocol version below the one that serves it or the SDK is pinned below it: for example the private contact request sync on a network before protocol version 15. Expected on such a network, so a host may stay quiet about it; it lifts when the network upgrades. Mapped by the blanket conversion for every operation that propagates the SDK error. A refusal that may lift sooner (`is_transient_protocol_version_refusal`: the network's version could not be confirmed) keeps the catch-all `ErrorUnknown`, as before. Previously both fell to `ErrorUnknown`. Swift (`PlatformWalletError.protocolVersionNotActive`) and Kotlin (`DashSdkError.PlatformWallet.ProtocolVersionNotActive`) type it |
| 71 | `ErrorPrivateContactRequestPending` | DashPay private contact requests (FFI, Swift and Kotlin) | Proposed. Maps `PlatformWalletError::PrivateContactRequestPending`: a private contact request to that identity is in flight (another private send or reply reserved it before sealing), or was paid for and a short read-back sync did not find the entry in the store yet; or the identity's private store could not be read to its end before its first private send or accept since load, which the wallet reads first so that a reply paid for in an earlier process is adopted rather than paid for again. Nothing new was paid; a later private contact request sync, or a retry, settles it. Pending, not failed: the host must not submit it again. Returned by the private send, the private accept and the in-kind accept; the shielded spend contract (`map_spend_result`) keeps the code rather than flattening it to `ErrorWalletOperation`. Swift (`PlatformWalletError.privateContactRequestPending`, `ErrorHandlingTests` pin) and Kotlin (`DashSdkError.PlatformWallet.PrivateContactRequestPending`, `DashSdkErrorTest` pin) type it, and both example apps keep the submission pending |
| 72 | `ErrorPrivateContactPaymentsNotRestored` | DashPay private contact requests (FFI, Swift and Kotlin) | Proposed. Maps `PlatformWalletError::PrivateContactPaymentsNotRestored`: wallet readiness, not one contact's pending request (71). The wallet was loaded after binding a shielded account in an earlier process, or the identity was loaded with its private scan state marking a private payment that may be unresolved, so its unconfirmed private payments may hold pairs no reservation shows yet. The public send, the public accept and the in-kind accept are refused with nothing signed or broadcast until they are restored: configuring the shielded store restores them with no keys, as binding the wallet's shielded account does; a private sync does not, and a build without the `shielded` feature never does. `platform_wallet_get_private_contact_readiness` reads the state, so a host shows it rather than inferring it. Swift and Kotlin must type it, as for 71, and treat it as held until the readiness reads restored |
| 72 | `ErrorPrivateContactPaymentsNotRestored` | DashPay private contact requests (FFI, Swift and Kotlin) | Proposed. Maps `PlatformWalletError::PrivateContactPaymentsNotRestored`: wallet readiness, not one contact's pending request (71). The wallet was loaded after binding a shielded account in an earlier process, or the identity was loaded with its private scan state marking a private payment that may be unresolved, so its unconfirmed private payments may hold pairs no reservation shows yet. The public send, the public accept and the in-kind accept are refused with nothing signed or broadcast until they are restored: configuring the shielded store restores them with no keys, as binding the wallet's shielded account does; a private sync does not, and a build without the `shielded` feature never does. `platform_wallet_get_private_contact_readiness` reads the state, so a host shows it rather than inferring it. Swift types it (`PlatformWalletError.privateContactPaymentsNotRestored`, `ErrorHandlingTests` pin) and binds the readiness (`ManagedPlatformWallet.privateContactReadiness`); SwiftExampleApp shows "restoring private payments" apart from a pending request (71) until the readiness reads restored. Kotlin must type it, as for 71 |

**Code 31 left this table on 2026-08-04.** `ErrorSigningKeyUnavailable` sat here
as #4183's proposal until #4183 merged (`189a3abb1c`); it is now in the merged
Expand Down
78 changes: 78 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,51 @@ 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
/// Whether a private contact request the identity paid for may still
/// be unresolved. Store it and return it as it came: loaded set, it
/// holds the identity's public contact requests until its private
/// payments are restored from the shielded store.
pub private_payments_unresolved: bool,
}

/// 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 +313,39 @@ 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]
// 72 private_payments_unresolved bool
// 73..=79 (tail padding to 8)
//
// Total size = 80, alignment = 8 (from the u64).
const _: [u8; 80] = [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);
const _: () = assert!(
std::mem::offset_of!(
PrivateContactRequestsPositionFFI,
private_payments_unresolved
) == 72
);

// 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 @@ -1755,6 +1767,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
Loading
Loading