Skip to content
Open
Show file tree
Hide file tree
Changes from 36 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
12 changes: 10 additions & 2 deletions packages/rs-platform-wallet-ffi/ERROR_CODE_REGISTRY.md
Original file line number Diff line number Diff line change
Expand Up @@ -114,7 +114,7 @@ These are shipped ABI. Do not renumber.
| 98 | `NotFound` | Sentinel — `Option` returned as an error |
| 99 | `ErrorUnknown` | Sentinel — unmapped/flattened errors |

**Next allocatable integer: 61** — 27–60 are all claimed (27, 29, 31, 34–42
**Next allocatable integer: 72**: 27–71 are all claimed (27, 29, 31, 34–42
and 46 merged; 43–45 proposed by active #4313 at head `0302b188ab`; 47 and
48 proposed by active #4356 (47 renumbered from 42, 48 from 43 — see their
rows below); 49–54 proposed by active #4586 (the persister
Expand All @@ -123,7 +123,11 @@ shield debits and durable recovery errors; 58 proposed by #4799 for an unavailab
identity balance response; 59 proposed by #5322 for an SPV
teardown that needs a process restart; 60 proposed by the pooled Core-to-shielded
wallet drain for `ErrorAssetLockTooManyInputs` (Rust, Swift and Kotlin mirrors —
renumbered from 59 when #5322 took that number on `v5.1-dev`); 28, 30,
renumbered from 59 when #5322 took that number on `v5.1-dev`); 61–67 held
by open branches with no row here yet (61 `ErrorAssetLockRecipientMismatch`,
62 both `ErrorShieldedClaimBindingMismatch` and `ErrorIdentityRevisionMismatch`,
63–66 the invitation-claim preflight, 67 `ErrorShieldedTreeInconsistent`); 68
to 71 proposed for DashPay private contact requests; 28, 30,
32 and 33 reserved). **28, 30,
32 and 33 are RESERVED, not free**: 28 and 30 were vacated when the
reservation trio moved to 34–36; 32 and 33 lapsed when their in-repo owners
Expand Down Expand Up @@ -177,6 +181,10 @@ Fork-era numbers remain in the collision history, which is immutable record.
| 58 | `ErrorIdentityBalanceUnavailable` | #4799 | Proposed — Platform returned no balance for a managed identity. Retrying the read is safe; this is distinct from missing wallet ownership and must not trigger registration or funding |
| 59 | `ErrorSpvProcessRestartRequired` | #5322 | Proposed — SPV startup or teardown panicked; restart the host process. Not retryable, unlike 27, which SPV start, stop and storage clear now return while an SPV teardown is still running. Typed in Rust, Swift and Kotlin (the PR also types 27 on Kotlin) |
| 60 | `ErrorAssetLockTooManyInputs` | Core-to-shielded pooled Max | Proposed — an asset lock needs more inputs than one may carry (100 on Dash Core relay policy and Platform consensus): the full drain, or since #5712 a fixed amount held only in many small coins (identity registration, top-up, invitation, platform-address funding). Two meanings. On a build: nothing was broadcast, the reservation is released, no partial drain, and a rebuild after consolidating is safe. On the resume of a tracked `Built` / `Broadcast` lock over the cap (since #5714): it is not re-sent, but an earlier attempt may have reached peers; the row stays tracked and nothing is released. Rust, Swift and Kotlin retain the typed refusal. Renumbered from 59 after #5322 claimed it |
| 68 | `ErrorPrivateContactRequestNeedsPrivateReply` | DashPay private contact requests (FFI and Swift) | Proposed. The contact requests with that identity came or went privately (protocol version 15), so a `contactRequest` document to it, in reply or as a rotation, would publish the relationship: the public accept refuses a reply, and the public send refuses a request or a rotation. Nothing was sent; the host accepts with `platform_wallet_manager_accept_contact_request_replying_in_kind` (which picks the reply itself, so a host calling it does not route on 68) or `platform_wallet_manager_accept_private_contact_request`, or sends with `platform_wallet_manager_send_private_contact_request`. Taken above 61–67, which open branches already hold. Rust and Swift (raw case, `init(ffi:)` arm, typed `PlatformWalletError.privateContactRequestNeedsPrivateReply` and an `ErrorHandlingTests` pin) carry it; Kotlin falls through to `PlatformWallet.Generic`, which keeps the integer |
| 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 |

**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
40 changes: 40 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
123 changes: 106 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,83 @@ 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 {
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
Loading
Loading