Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
110 changes: 110 additions & 0 deletions packages/rs-platform-wallet-ffi/src/shielded_send.rs
Original file line number Diff line number Diff line change
Expand Up @@ -978,6 +978,116 @@ pub unsafe extern "C" fn platform_wallet_manager_shielded_shield(
map_spend_result(result, "shielded shield")
}

/// Shield: spend credits from a Platform Payment account into a
/// THIRD-PARTY shielded pool — the Type 15 shield with the note
/// assigned to `recipient_raw_43` (the recipient's raw 43-byte
/// Orchard payment address, same shape
/// `platform_wallet_manager_shielded_transfer` takes) instead of the
/// wallet's own default address.
///
/// Input selection, fees, and error shapes are identical to
/// [`platform_wallet_manager_shielded_shield`]; the wallet still needs
/// a bound shielded sub-wallet at `shielded_account` because the send
/// is OVK-encrypted to (and its activity recorded under) that account.
///
/// `memo_text` is an optional NUL-terminated UTF-8 string attached to
/// the recipient's note — same rules as
/// `platform_wallet_manager_shielded_transfer`: `null` or empty means
/// no memo; a non-empty memo's UTF-8 byte length must be ≤ 32.
///
/// `signer_address_handle` is a `*mut SignerHandle` produced by
/// `dash_sdk_signer_create_with_ctx` (typically Swift's
/// `KeychainSigner.handle`). The caller retains ownership; this
/// function does not destroy the handle.
///
/// # Safety
/// - `wallet_id_bytes` must point to 32 readable bytes.
/// - `recipient_raw_43` must point to 43 readable bytes.
/// - `memo_text`, when non-null, must be a valid NUL-terminated UTF-8
/// C string for the duration of the call.
/// - `signer_address_handle` must be a valid, non-destroyed
/// `*const SignerHandle` that outlives this call and points at a
/// `VTableSigner` with the callback variant (the native variant
/// doesn't satisfy `Signer<PlatformAddress>`).
#[no_mangle]
pub unsafe extern "C" fn platform_wallet_manager_shielded_shield_to_recipient(
handle: Handle,
wallet_id_bytes: *const u8,
shielded_account: u32,
payment_account: u32,
recipient_raw_43: *const u8,
amount: u64,
memo_text: *const c_char,
signer_address_handle: *const SignerHandle,
) -> PlatformWalletFFIResult {
check_ptr!(wallet_id_bytes);
check_ptr!(recipient_raw_43);
check_ptr!(signer_address_handle);

let mut wallet_id = [0u8; 32];
std::ptr::copy_nonoverlapping(wallet_id_bytes, wallet_id.as_mut_ptr(), 32);
let mut recipient = [0u8; 43];
std::ptr::copy_nonoverlapping(recipient_raw_43, recipient.as_mut_ptr(), 43);

// Decode the optional memo string before resolving the wallet so a
// malformed memo fails fast without touching wallet state.
let memo_str = if memo_text.is_null() {
None
} else {
match CStr::from_ptr(memo_text).to_str() {
Ok(s) => Some(s),
Err(e) => {
return PlatformWalletFFIResult::err(
PlatformWalletFFIResultCode::ErrorUtf8Conversion,
format!("memo_text is not valid UTF-8: {e}"),
);
}
}
};
let memo = match encode_memo_text(memo_str) {
Ok(m) => m,
Err(result) => return result,
};

// Shield writes its live activity entry to the coordinator's shared
// in-memory store, so resolve the coordinator alongside the wallet
// (same resolver the transfer / unshield / withdraw spends use).
let (wallet, coordinator) = match resolve_wallet_and_coordinator(handle, &wallet_id) {
Ok(p) => p,
Err(result) => return result,
};

// Signer pointer round-trip through `usize` — same rationale as
// `platform_wallet_manager_shielded_shield`.
let signer_addr = signer_address_handle as usize;

// Run the proof on a worker thread (8 MB stack). Halo 2 circuit
// synthesis recurses past the ~512 KB iOS dispatch-thread stack
// and crashes with EXC_BAD_ACCESS at the first
// `synthesize(... measure(pass))` call when polled on the
// calling thread.
let result = block_on_worker(async move {
// SAFETY: re-materialize the borrow under the caller's
// documented lifetime contract; valid for the duration of
// this synchronously-awaited task.
let address_signer: &VTableSigner = &*(signer_addr as *const VTableSigner);
let prover = CachedOrchardProver::new();
wallet
.shielded_shield_from_account_to_recipient(
&coordinator,
shielded_account,
payment_account,
&recipient,
amount,
memo,
address_signer,
&prover,
)
.await
});
map_spend_result(result, "shielded shield to recipient")
}

/// Fund the shielded pool from a Core L1 asset lock, orchestrated
/// through the wallet's `AssetLockManager` (build → IS-or-CL →
/// submit → consume). The asset-lock-proof signature is produced
Expand Down
86 changes: 86 additions & 0 deletions packages/rs-platform-wallet/src/wallet/platform_wallet.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1653,6 +1653,90 @@ impl PlatformWallet {
signer: &S,
prover: P,
) -> Result<(), PlatformWalletError>
where
S: dpp::identity::signer::Signer<dpp::address_funds::PlatformAddress> + Send + Sync,
P: dpp::shielded::builder::OrchardProver,
{
self.shielded_shield_from_account_impl(
coordinator,
shielded_account,
payment_account,
None,
amount,
[0u8; 36], // empty memo
signer,
prover,
)
.await
}

/// Shield credits from a Platform Payment account into a THIRD-PARTY
/// shielded pool: the resulting note is assigned to
/// `recipient_raw_43` (a raw 43-byte Orchard payment address — the
/// same shape [`shielded_transfer_to`](Self::shielded_transfer_to)
/// takes) instead of the wallet's own default address. Input
/// selection, fees, and error shapes are identical to
/// [`shielded_shield_from_account`](Self::shielded_shield_from_account);
/// the wallet still needs a bound shielded sub-wallet at
/// `shielded_account` because the send is OVK-encrypted to (and its
/// activity recorded under) that account — which is how the scan
/// later recovers it as outgoing history.
///
/// `memo` is the 36-byte on-chain `DashMemo` encoding attached to
/// the recipient's note (all-zero = no memo).
#[cfg(feature = "shielded")]
#[allow(clippy::too_many_arguments)]
pub async fn shielded_shield_from_account_to_recipient<S, P>(
&self,
coordinator: &Arc<crate::wallet::shielded::NetworkShieldedCoordinator>,
shielded_account: u32,
payment_account: u32,
recipient_raw_43: &[u8; 43],
amount: u64,
memo: [u8; 36],
signer: &S,
prover: P,
) -> Result<(), PlatformWalletError>
where
S: dpp::identity::signer::Signer<dpp::address_funds::PlatformAddress> + Send + Sync,
P: dpp::shielded::builder::OrchardProver,
{
let recipient = Option::<grovedb_commitment_tree::PaymentAddress>::from(
grovedb_commitment_tree::PaymentAddress::from_raw_address_bytes(recipient_raw_43),
)
.ok_or_else(|| {
PlatformWalletError::ShieldedBuildError(
"invalid Orchard payment address bytes".to_string(),
)
})?;
self.shielded_shield_from_account_impl(
coordinator,
shielded_account,
payment_account,
Some(recipient),
amount,
memo,
signer,
prover,
)
.await
}

/// Shared body of the two shield entry points above; `recipient`
/// `None` = the wallet's own default Orchard address.
#[cfg(feature = "shielded")]
#[allow(clippy::too_many_arguments)]
async fn shielded_shield_from_account_impl<S, P>(
&self,
coordinator: &Arc<crate::wallet::shielded::NetworkShieldedCoordinator>,
shielded_account: u32,
payment_account: u32,
recipient: Option<grovedb_commitment_tree::PaymentAddress>,
amount: u64,
memo: [u8; 36],
signer: &S,
prover: P,
) -> Result<(), PlatformWalletError>
where
S: dpp::identity::signer::Signer<dpp::address_funds::PlatformAddress> + Send + Sync,
P: dpp::shielded::builder::OrchardProver,
Expand Down Expand Up @@ -1708,8 +1792,10 @@ impl PlatformWallet {
self.wallet_id,
&keyset,
shielded_account,
recipient.as_ref(),
inputs,
amount,
memo,
signer,
&prover,
)
Expand Down
55 changes: 41 additions & 14 deletions packages/rs-platform-wallet/src/wallet/shielded/operations.rs
Original file line number Diff line number Diff line change
Expand Up @@ -447,8 +447,14 @@ fn reserve_shield_fee_on_input_0(
}

/// Shield credits from transparent platform addresses into the
/// shielded pool, with the resulting note assigned to `account`'s
/// default Orchard payment address derived from `keys`.
/// shielded pool. `recipient` selects the note's Orchard payment
/// address: `None` assigns it to `account`'s default address derived
/// from `keys` (the internal shield-to-self); `Some` pays a
/// third-party address — the note funds THAT wallet's pool and never
/// becomes spendable here. Either way the output is encrypted under
/// our own OVK, so the scan recovers the send from chain data and the
/// live and scan-derived activity ids line up (same convention as
/// [`transfer`], which also does not special-case a self recipient).
#[allow(clippy::too_many_arguments)]
pub async fn shield<S: ShieldedStore, Sig: Signer<PlatformAddress>, P: OrchardProver>(
sdk: &Arc<dash_sdk::Sdk>,
Expand All @@ -457,12 +463,20 @@ pub async fn shield<S: ShieldedStore, Sig: Signer<PlatformAddress>, P: OrchardPr
wallet_id: WalletId,
keys: &AccountViewingKeys,
account: u32,
recipient: Option<&PaymentAddress>,
inputs: BTreeMap<PlatformAddress, Credits>,
amount: u64,
memo: [u8; 36],
signer: &Sig,
prover: &P,
) -> Result<(), PlatformWalletError> {
let recipient_addr = default_orchard_address(keys)?;
let (recipient_addr, external_counterparty) = match recipient {
Some(payment_address) => (
payment_address_to_orchard(payment_address)?,
Some(payment_address.to_raw_address_bytes().to_vec()),
),
None => (default_orchard_address(keys)?, None),
};

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Suggestion: Do not infer an external recipient from Option::Some

A valid PaymentAddress passed through the new recipient API can belong to the selected shielded account, including a diversified address. This branch classifies every Some value as Sent/Out, while restoration tests ownership with incoming_viewing_key.diversifier_index and classifies an own output as incoming or a self-transfer. That makes live and restored activity semantics diverge for an input the public raw-address API currently accepts. Enforce the method's documented third-party invariant by rejecting addresses recognized by the source account and directing callers to the self-shield API.

Suggested change
let (recipient_addr, external_counterparty) = match recipient {
Some(payment_address) => (
payment_address_to_orchard(payment_address)?,
Some(payment_address.to_raw_address_bytes().to_vec()),
),
None => (default_orchard_address(keys)?, None),
};
let (recipient_addr, external_counterparty) = match recipient {
Some(payment_address) => {
if keys
.incoming_viewing_key
.diversifier_index(payment_address)
.is_some()
{
return Err(PlatformWalletError::ShieldedBuildError(
"recipient belongs to the source shielded account; use shield-to-self"
.to_string(),
));
}
(
payment_address_to_orchard(payment_address)?,
Some(payment_address.to_raw_address_bytes().to_vec()),
)
}
None => (default_orchard_address(keys)?, None),
};

source: ['codex']

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done in 766d4e4: recipient resolution now lives in a pure resolve_shield_recipient helper that rejects a Some address the account's own IVK recognizes (diversifier_index, the same test the scan's is_own_orchard_recipient uses — so diversified own addresses are caught too), directing callers to the self-shield entry point. The third-party contract is now documented at the wallet method, the FFI export, and the Swift method.

let id = SubwalletId::new(wallet_id, account);

// Reserve the flat shielded fee `F` on top of `amount` in the input
Expand Down Expand Up @@ -515,7 +529,12 @@ pub async fn shield<S: ShieldedStore, Sig: Signer<PlatformAddress>, P: OrchardPr
let fee_strategy: AddressFundsFeeStrategy =
vec![AddressFundsFeeStrategyStep::DeductFromInput(0)];

info!(account, credits = amount, "Shield: building proof");
info!(
account,
credits = amount,
external = external_counterparty.is_some(),
"Shield: building proof"
);

let claimed_inputs = inputs_with_nonce.clone();

Expand All @@ -527,7 +546,7 @@ pub async fn shield<S: ShieldedStore, Sig: Signer<PlatformAddress>, P: OrchardPr
signer,
0, // user_fee_increase
prover,
[0u8; 36], // empty memo
memo,
// Encrypt the output under the account's own OVK so the wallet's
// shielded sync can recover this send (recipient, value, memo)
// from chain data alone.
Expand All @@ -540,24 +559,32 @@ pub async fn shield<S: ShieldedStore, Sig: Signer<PlatformAddress>, P: OrchardPr
trace!("Shield credits: state transition built, broadcasting...");
let network = sdk.network;

// Live activity: Shield is `direction in`, amount = the note value
// entering the pool, fee = the flat shielded fee reserved above. The
// visible output cmx is the recipient note (own address, OVK-keyed),
// which the scan later sees as an outgoing note recovered to self —
// the ids line up.
// Live activity. Shield-to-self is `Shield`/`direction in` (the note
// value enters our pool); an external recipient is `Sent`/`direction
// out` with the recipient's raw 43-byte Orchard address as
// counterparty — the exact classification the scan deriver produces
// for an OVK-recovered send to a non-own address, so a restore
// derives the same row. Fee = the flat shielded fee reserved above.
// The visible output cmx is the recipient note (OVK-keyed either
// way), so the live and scan ids line up.
let (kind, direction) = if external_counterparty.is_some() {
(ShieldedActivityKind::Sent, ShieldedDirection::Out)
} else {
(ShieldedActivityKind::Shield, ShieldedDirection::In)
};
let pending_entry = record_pending_activity(
store,
persister,
wallet_id,
id,
keys,
LiveEntryParams {
kind: ShieldedActivityKind::Shield,
direction: ShieldedDirection::In,
kind,
direction,
amount,
fee: Some(fee),
counterparty: None,
memo: None,
counterparty: external_counterparty,
memo: non_zero_memo(&memo),
actions: shielded_actions(&state_transition),
spent_notes: &[],
},
Expand Down
Loading
Loading