Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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: 8 additions & 4 deletions docs/dashpay/SPEC.md
Original file line number Diff line number Diff line change
Expand Up @@ -585,10 +585,14 @@ Ordered so the test seam exists before the TDD-gated tasks that need it.
document), held as a new `IdentityWallet` field defaulting to an
`Arc<Sdk>`-backed impl so public construction and FFI are untouched.
(`send_payment` already takes an injected `broadcaster: B`.)
**DONE (2026-06-10):** `DashPaySdkWriter` trait in `network/sdk_writer.rs` —
Send-boxed `#[async_trait]` (NOT `?Send`: the FFI drives the write paths via
`block_on_worker`, which requires `Send` futures; the `!Send` read/sync path
runs on the sync manager's dedicated thread and bypasses the seam).
**DONE (2026-06-10; revised 2026-07-04):** originally shipped as a
Send-boxed `#[async_trait]` `DashPaySdkWriter` trait; the trait was later
removed as an unused seam (no test ever injected it — the sync/establish
tests mock the fetch half via `SdkBuilder::new_mock` instead).
`network/sdk_writer.rs` now ships a concrete `SdkWriter` held as an
`Arc<Sdk>`-backed `IdentityWallet` field: it still erases the
7-type-param `send_contact_request` / `PutDocument` generics behind two
concrete methods, it just isn't swappable.
2. **G12: fold DashPay sync into the recurring loop.** Per G12: inject the wallets
map and iterate wallets calling `dashpay_sync()` — do **not** drive off the
token registry; log-and-continue error semantics; keep the on-demand FFI entry
Expand Down
102 changes: 93 additions & 9 deletions packages/rs-platform-wallet-ffi/src/contact_persistence.rs
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,17 @@ pub struct ContactRequestFFI {
/// surfaced) and is null on the outgoing and pending rows. Released by
/// [`free_contact_requests_ffi`].
pub contact_account_label: *const std::os::raw::c_char,
/// Heap-allocated copy of `EstablishedContact::accepted_accounts`
/// (DIP-15 rotated-account acceptances), or `null` when empty. Like
/// [`Self::payment_channel_broken`]/[`Self::alias`]/[`Self::note`] this is a
/// property of the relationship, so it is replicated onto BOTH the outgoing
/// and incoming established rows; always `null` for pending
/// `sent_requests` / `incoming_requests` rows. Released by
/// [`free_contact_requests_ffi`].
pub accepted_accounts: *const u32,
/// Number of `u32` entries in [`Self::accepted_accounts`]; `0` when the
/// pointer is null.
pub accepted_accounts_len: usize,
}

/// Composite identifier for [`ContactChangeSet::removed_sent`] and
Expand Down Expand Up @@ -215,9 +226,11 @@ pub struct ContactIgnoredSenderFFI {
// 168 is_hidden bool
// 169..=175 (padding to 8)
// 176..=183 contact_account_label *const c_char
// 184..=191 accepted_accounts *const u32
// 192..=199 accepted_accounts_len usize
//
// Total size = 184, alignment = 8 (from u64 / pointer fields).
const _: [u8; 184] = [0u8; std::mem::size_of::<ContactRequestFFI>()];
// Total size = 200, alignment = 8 (from u64 / pointer fields).
const _: [u8; 200] = [0u8; std::mem::size_of::<ContactRequestFFI>()];
const _: [u8; 8] = [0u8; std::mem::align_of::<ContactRequestFFI>()];

// Expected `ContactRequestRemovalFFI` layout: 64 bytes, alignment 1.
Expand Down Expand Up @@ -274,7 +287,16 @@ impl ContactRequestFFI {
request: &platform_wallet::ContactRequest,
) -> Self {
Self::from_parts(
owner_id, contact_id, true, request, false, None, None, false, None,
owner_id,
contact_id,
true,
request,
false,
None,
None,
false,
None,
&[],
)
}

Expand All @@ -286,14 +308,23 @@ impl ContactRequestFFI {
request: &platform_wallet::ContactRequest,
) -> Self {
Self::from_parts(
owner_id, contact_id, false, request, false, None, None, false, None,
owner_id,
contact_id,
false,
request,
false,
None,
None,
false,
None,
&[],
)
}

/// Build the **outgoing** row of an established contact, stamping
/// the relationship's `payment_channel_broken` flag and the
/// owner-private metadata (alias / note / hidden — contactInfo,
/// M3) onto the row.
/// the relationship's `payment_channel_broken` flag, the owner-private
/// metadata (alias / note / hidden — contactInfo, M3), and the DIP-15
/// `accepted_accounts` onto the row.
///
/// Used by the persister's `established` projection (one outgoing +
/// one incoming row per entry), where these are properties of the
Expand All @@ -307,6 +338,7 @@ impl ContactRequestFFI {
alias: Option<&str>,
note: Option<&str>,
is_hidden: bool,
accepted_accounts: &[u32],
) -> Self {
Self::from_parts(
owner_id,
Expand All @@ -320,6 +352,7 @@ impl ContactRequestFFI {
// The outgoing row never carries the contact's account label —
// it is direction-specific (incoming-only).
None,
accepted_accounts,
)
}

Expand All @@ -337,6 +370,7 @@ impl ContactRequestFFI {
note: Option<&str>,
is_hidden: bool,
contact_account_label: Option<&str>,
accepted_accounts: &[u32],
) -> Self {
Self::from_parts(
owner_id,
Expand All @@ -348,6 +382,7 @@ impl ContactRequestFFI {
note,
is_hidden,
contact_account_label,
accepted_accounts,
)
}

Expand All @@ -362,6 +397,7 @@ impl ContactRequestFFI {
note: Option<&str>,
is_hidden: bool,
contact_account_label: Option<&str>,
accepted_accounts: &[u32],
) -> Self {
let (encrypted_public_key, encrypted_public_key_len) =
allocate_byte_buffer(&request.encrypted_public_key);
Expand All @@ -375,6 +411,7 @@ impl ContactRequestFFI {
Some(bytes) => allocate_byte_buffer(bytes),
None => (ptr::null(), 0),
};
let (accepted_accounts, accepted_accounts_len) = allocate_u32_buffer(accepted_accounts);
Self {
owner_id,
contact_id,
Expand All @@ -395,6 +432,8 @@ impl ContactRequestFFI {
note: allocate_c_string(note),
is_hidden,
contact_account_label: allocate_c_string(contact_account_label),
accepted_accounts,
accepted_accounts_len,
}
}
}
Expand Down Expand Up @@ -434,6 +473,18 @@ fn allocate_byte_buffer(bytes: &[u8]) -> (*const u8, usize) {
(Box::into_raw(boxed) as *const u8, len)
}

/// `u32` sibling of [`allocate_byte_buffer`] for
/// [`ContactRequestFFI::accepted_accounts`]. Empty slices return `(null, 0)`;
/// released by [`free_u32_buffer`].
fn allocate_u32_buffer(values: &[u32]) -> (*const u32, usize) {
if values.is_empty() {
return (ptr::null(), 0);
}
let boxed: Box<[u32]> = values.to_vec().into_boxed_slice();
let len = boxed.len();
(Box::into_raw(boxed) as *const u32, len)
}

// ---------------------------------------------------------------------------
// Destructors
// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -472,6 +523,10 @@ pub unsafe fn free_contact_requests_ffi(entries: *mut ContactRequestFFI, count:
free_c_string(&mut entry.alias);
free_c_string(&mut entry.note);
free_c_string(&mut entry.contact_account_label);
free_u32_buffer(
&mut entry.accepted_accounts,
&mut entry.accepted_accounts_len,
);
}
}

Expand All @@ -486,6 +541,18 @@ fn free_byte_buffer(slot: &mut *const u8, len_slot: &mut usize) {
*len_slot = 0;
}

/// `u32` sibling of [`free_byte_buffer`] for
/// [`ContactRequestFFI::accepted_accounts`]. Idempotent on null / zero-length
/// slots.
fn free_u32_buffer(slot: &mut *const u32, len_slot: &mut usize) {
if !slot.is_null() && *len_slot > 0 {
let slice = unsafe { std::slice::from_raw_parts_mut(*slot as *mut u32, *len_slot) };
let _ = unsafe { Box::from_raw(slice as *mut [u32]) };
}
*slot = ptr::null();
*len_slot = 0;
}

// ---------------------------------------------------------------------------
// Callback signature
// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -624,6 +691,7 @@ mod tests {
Some("ally"),
Some("a note"),
true,
&[],
);
let mut inc = ContactRequestFFI::from_established_incoming(
owner,
Expand All @@ -634,6 +702,7 @@ mod tests {
Some("a note"),
true,
None,
&[],
);
assert!(out.is_outgoing);
assert!(!inc.is_outgoing);
Expand All @@ -649,7 +718,14 @@ mod tests {

// Healthy relationship without metadata: flag clear, strings null.
let mut healthy = ContactRequestFFI::from_established_outgoing(
owner, contact, &request, false, None, None, false,
owner,
contact,
&request,
false,
None,
None,
false,
&[],
);
assert!(!healthy.payment_channel_broken);
assert!(healthy.alias.is_null());
Expand Down Expand Up @@ -678,7 +754,14 @@ mod tests {
let contact = [4u8; 32];

let mut out = ContactRequestFFI::from_established_outgoing(
owner, contact, &request, false, None, None, false,
owner,
contact,
&request,
false,
None,
None,
false,
&[],
);
let mut inc = ContactRequestFFI::from_established_incoming(
owner,
Expand All @@ -689,6 +772,7 @@ mod tests {
None,
false,
Some("Main wallet"),
&[],
);

assert!(
Expand Down
3 changes: 1 addition & 2 deletions packages/rs-platform-wallet-ffi/src/dashpay.rs
Original file line number Diff line number Diff line change
Expand Up @@ -620,10 +620,9 @@ impl platform_wallet::ContactCryptoProvider for ResolverContactCryptoProvider {
&self,
path: &key_wallet::bip32::DerivationPath,
peer: &dashcore::secp256k1::PublicKey,
) -> Result<[u8; 32], platform_wallet::PlatformWalletError> {
) -> Result<zeroize::Zeroizing<[u8; 32]>, platform_wallet::PlatformWalletError> {
self.signer
.ecdh_shared_secret(path, peer)
.map(|z| *z)
.map_err(|e| platform_wallet::PlatformWalletError::InvalidIdentityData(e.to_string()))
}

Expand Down
13 changes: 10 additions & 3 deletions packages/rs-platform-wallet-ffi/src/dashpay_profile.rs
Original file line number Diff line number Diff line change
Expand Up @@ -206,14 +206,21 @@ pub unsafe extern "C" fn platform_wallet_get_dashpay_profile(

let id = unwrap_result_or_return!(unsafe { read_identifier(identity_id) });

// Clone only the `Option<DashPayProfile>` field, not the whole
// `ManagedIdentity` (which carries the full Identity plus the
// established/sent/incoming BTreeMaps and payment history). The two
// unwraps preserve the caller contract unchanged: a missing wallet or
// identity is a NotFound error, a present identity with no profile is a
// successful read with `has_profile == false`.
let option = PLATFORM_WALLET_STORAGE.with_item(wallet_handle, |wallet| {
let wm = wallet.wallet_manager().blocking_read();
let info = wm.get_wallet_info(&wallet.wallet_id())?;
info.identity_manager.managed_identity(&id).cloned()
let managed = info.identity_manager.managed_identity(&id)?;
Some(managed.dashpay_profile.clone())
});
let inner = unwrap_option_or_return!(option);
let managed = unwrap_option_or_return!(inner);
match managed.dashpay_profile {
let profile = unwrap_option_or_return!(inner);
match profile {
Some(profile) => unsafe {
*out_profile = DashPayProfileFFI::from_profile(&profile);
*out_has_profile = true;
Expand Down
23 changes: 23 additions & 0 deletions packages/rs-platform-wallet-ffi/src/persistence.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1082,6 +1082,7 @@ impl PlatformWalletPersistence for FFIPersister {
established.alias.as_deref(),
established.note.as_deref(),
established.is_hidden,
&established.accepted_accounts,
));
upserts.push(ContactRequestFFI::from_established_incoming(
key.owner_id.to_buffer(),
Expand All @@ -1094,6 +1095,7 @@ impl PlatformWalletPersistence for FFIPersister {
// Direction-specific: the contact's account label
// rides only the incoming row.
established.contact_account_label.as_deref(),
&established.accepted_accounts,
));
}
let removed_sent: Vec<ContactRequestRemovalFFI> = contacts_cs
Expand Down Expand Up @@ -3973,6 +3975,7 @@ unsafe fn apply_contact_rows(
note: Option<String>,
is_hidden: bool,
contact_account_label: Option<String>,
accepted_accounts: Vec<u32>,
}

let opt_string = |ptr: *const std::os::raw::c_char| -> Option<String> {
Expand All @@ -3989,6 +3992,13 @@ unsafe fn apply_contact_rows(
Some(slice::from_raw_parts(ptr, len).to_vec())
}
};
let u32s = |ptr: *const u32, len: usize| -> Vec<u32> {
if ptr.is_null() || len == 0 {
Vec::new()
} else {
slice::from_raw_parts(ptr, len).to_vec()
}
};

let mut by_contact: BTreeMap<[u8; 32], PairAccumulator> = BTreeMap::new();
for row in rows {
Expand Down Expand Up @@ -4033,6 +4043,11 @@ unsafe fn apply_contact_rows(
if acc.note.is_none() {
acc.note = opt_string(row.note);
}
// Relationship-level, replicated onto both rows — take the first
// non-empty projection.
if acc.accepted_accounts.is_empty() {
acc.accepted_accounts = u32s(row.accepted_accounts, row.accepted_accounts_len);
}
}

for (contact_id_bytes, acc) in by_contact {
Expand All @@ -4045,6 +4060,7 @@ unsafe fn apply_contact_rows(
contact.is_hidden = acc.is_hidden;
contact.payment_channel_broken = acc.payment_channel_broken;
contact.contact_account_label = acc.contact_account_label;
contact.accepted_accounts = acc.accepted_accounts;
managed.established_contacts.insert(contact_id, contact);
}
(Some(outgoing), None) => {
Expand Down Expand Up @@ -4969,6 +4985,7 @@ mod tests {
Some("ally"),
Some("a note"),
true,
&[7, 42],
),
ContactRequestFFI::from_established_incoming(
owner.to_buffer(),
Expand All @@ -4979,6 +4996,7 @@ mod tests {
Some("a note"),
true,
Some("Main wallet"),
&[7, 42],
),
];

Expand Down Expand Up @@ -5037,6 +5055,11 @@ mod tests {
Some("Main wallet"),
"contact_account_label must restore from the incoming row only"
);
assert_eq!(
e.accepted_accounts,
vec![7, 42],
"accepted_accounts must round-trip through the FFI rows (matching the SQLite backend)"
);
// Key indices restored without a swap (incoming sender=9, recipient=10).
assert_eq!(
(
Expand Down
Loading
Loading