diff --git a/packages/rs-dpp/src/shielded/memo.rs b/packages/rs-dpp/src/shielded/memo.rs new file mode 100644 index 00000000000..babd71f3e6e --- /dev/null +++ b/packages/rs-dpp/src/shielded/memo.rs @@ -0,0 +1,255 @@ +//! Structured 36-byte shielded note memo (`DashMemo`). +//! +//! Every shielded output carries a fixed 36-byte memo +//! ([`MEMO_SIZE`]). The dashpay Orchard fork pins the memo to 36 +//! bytes (Zcash's is 512) by making the memo size a type parameter; +//! the on-chain note ciphertext reserves exactly those bytes. This +//! type imposes a small self-describing structure on those bytes so +//! the wallet can attach an optional text note to a transfer and +//! recover it on the receive side: +//! +//! * bytes `[0..4]` β€” `kind` as a little-endian `u32`. +//! * bytes `[4..36]` β€” a 32-byte payload whose meaning depends on +//! `kind`. +//! +//! Two kinds are defined today; unknown kinds round-trip verbatim as +//! [`ShieldedMemo::Other`] so a future writer's memo is never +//! corrupted by an older reader. + +use crate::ProtocolError; + +/// Total memo length in bytes (`DashMemo = [u8; 36]`). +pub const MEMO_SIZE: usize = 36; + +/// Length of the payload that follows the 4-byte kind tag. +pub const MEMO_PAYLOAD_SIZE: usize = MEMO_SIZE - 4; + +/// `kind` tag for an empty memo (the all-zero 36 bytes today's senders write). +const KIND_EMPTY: u32 = 0; +/// `kind` tag for a UTF-8 text memo (zero-padded payload). +const KIND_TEXT: u32 = 1; + +/// A decoded shielded note memo. +/// +/// `to_bytes`/`from_bytes` are exact inverses for every variant: a +/// memo written by [`to_bytes`](Self::to_bytes) decodes back to an +/// equal value, and any 36 bytes decode to *some* variant (unknown +/// kinds and malformed text fall back to [`Self::Other`]), so decoding +/// is total and never loses bytes. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ShieldedMemo { + /// No memo: the all-zero 36 bytes. Today's `[0u8; 36]` memos decode to this. + Empty, + /// A UTF-8 text memo whose byte length is at most [`MEMO_PAYLOAD_SIZE`]. + Text(String), + /// Any memo whose `kind` this version does not interpret (or a + /// structurally-invalid known kind). Retained verbatim so it + /// round-trips unchanged. + Other { + /// The raw 4-byte `kind` tag. + kind: u32, + /// The raw 32-byte payload. + payload: [u8; MEMO_PAYLOAD_SIZE], + }, +} + +impl ShieldedMemo { + /// Build a text memo, validating that `s` is at most + /// [`MEMO_PAYLOAD_SIZE`] bytes when UTF-8 encoded. + pub fn text(s: impl Into) -> Result { + let s = s.into(); + if s.len() > MEMO_PAYLOAD_SIZE { + return Err(ProtocolError::Generic(format!( + "shielded text memo is {} bytes, exceeds the {MEMO_PAYLOAD_SIZE}-byte limit", + s.len() + ))); + } + Ok(ShieldedMemo::Text(s)) + } + + /// Encode to the on-chain 36-byte memo layout. + pub fn to_bytes(&self) -> [u8; MEMO_SIZE] { + let mut out = [0u8; MEMO_SIZE]; + let (kind, payload): (u32, [u8; MEMO_PAYLOAD_SIZE]) = match self { + ShieldedMemo::Empty => (KIND_EMPTY, [0u8; MEMO_PAYLOAD_SIZE]), + ShieldedMemo::Text(s) => { + // `text()` is the only constructor and enforces the length bound; a + // directly-built `Text` longer than the payload is truncated rather than + // panicking (the surplus simply cannot fit the fixed-size memo). + let mut payload = [0u8; MEMO_PAYLOAD_SIZE]; + let bytes = s.as_bytes(); + let n = bytes.len().min(MEMO_PAYLOAD_SIZE); + payload[..n].copy_from_slice(&bytes[..n]); + (KIND_TEXT, payload) + } + ShieldedMemo::Other { kind, payload } => (*kind, *payload), + }; + out[0..4].copy_from_slice(&kind.to_le_bytes()); + out[4..MEMO_SIZE].copy_from_slice(&payload); + out + } + + /// Decode from the on-chain 36-byte memo layout. + /// + /// Decoding is total: a `KIND_TEXT` memo whose payload is not valid + /// UTF-8, or a `KIND_EMPTY` memo with a non-zero payload, falls back + /// to [`Self::Other`] so the raw bytes are preserved rather than lost. + pub fn from_bytes(bytes: &[u8; MEMO_SIZE]) -> Self { + let kind = u32::from_le_bytes([bytes[0], bytes[1], bytes[2], bytes[3]]); + let mut payload = [0u8; MEMO_PAYLOAD_SIZE]; + payload.copy_from_slice(&bytes[4..MEMO_SIZE]); + + match kind { + KIND_EMPTY => { + if payload == [0u8; MEMO_PAYLOAD_SIZE] { + ShieldedMemo::Empty + } else { + // kind 0 is defined as the all-zero memo; a non-zero payload under + // kind 0 is not something we wrote, so keep it verbatim. + ShieldedMemo::Other { kind, payload } + } + } + KIND_TEXT => { + // Trailing zero padding is not part of the text; trim it before decoding. + let end = payload.iter().rposition(|&b| b != 0).map_or(0, |i| i + 1); + match std::str::from_utf8(&payload[..end]) { + Ok(s) => ShieldedMemo::Text(s.to_string()), + Err(_) => ShieldedMemo::Other { kind, payload }, + } + } + _ => ShieldedMemo::Other { kind, payload }, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn empty_round_trips() { + let memo = ShieldedMemo::Empty; + let bytes = memo.to_bytes(); + assert_eq!(bytes, [0u8; MEMO_SIZE], "empty memo must be all-zero bytes"); + assert_eq!(ShieldedMemo::from_bytes(&bytes), ShieldedMemo::Empty); + } + + #[test] + fn legacy_all_zero_memo_decodes_as_empty() { + // Every memo written before this type existed was `[0u8; 36]`. + assert_eq!( + ShieldedMemo::from_bytes(&[0u8; MEMO_SIZE]), + ShieldedMemo::Empty + ); + } + + #[test] + fn text_round_trips() { + let memo = ShieldedMemo::text("thanks for lunch").expect("within limit"); + let bytes = memo.to_bytes(); + assert_eq!( + ShieldedMemo::from_bytes(&bytes), + ShieldedMemo::Text("thanks for lunch".to_string()) + ); + } + + #[test] + fn empty_string_text_round_trips() { + let memo = ShieldedMemo::text("").expect("empty string is within limit"); + let bytes = memo.to_bytes(); + // An empty text payload is all-zero, but the kind tag is 1, so it stays Text(""). + assert_eq!( + ShieldedMemo::from_bytes(&bytes), + ShieldedMemo::Text(String::new()) + ); + } + + #[test] + fn multibyte_utf8_exactly_at_limit_round_trips() { + // 8 Γ— πŸ• (U+1F355) = 8 Γ— 4 = 32 bytes, exactly MEMO_PAYLOAD_SIZE. + let s = "πŸ•".repeat(8); + assert_eq!( + s.len(), + MEMO_PAYLOAD_SIZE, + "fixture must be exactly 32 bytes" + ); + let memo = ShieldedMemo::text(s.clone()).expect("32 bytes is within limit"); + let bytes = memo.to_bytes(); + assert_eq!(ShieldedMemo::from_bytes(&bytes), ShieldedMemo::Text(s)); + } + + #[test] + fn over_limit_text_is_rejected() { + // 33 ASCII bytes β€” one over the 32-byte payload. + let s = "a".repeat(MEMO_PAYLOAD_SIZE + 1); + assert!( + ShieldedMemo::text(s).is_err(), + "a 33-byte text memo must be rejected" + ); + } + + #[test] + fn multibyte_over_limit_is_rejected() { + // 9 Γ— πŸ• = 36 bytes > 32. + let s = "πŸ•".repeat(9); + assert!(s.len() > MEMO_PAYLOAD_SIZE); + assert!( + ShieldedMemo::text(s).is_err(), + "a 36-byte multibyte memo must be rejected" + ); + } + + #[test] + fn unknown_kind_round_trips() { + let payload = [7u8; MEMO_PAYLOAD_SIZE]; + let memo = ShieldedMemo::Other { kind: 42, payload }; + let bytes = memo.to_bytes(); + assert_eq!( + u32::from_le_bytes([bytes[0], bytes[1], bytes[2], bytes[3]]), + 42 + ); + assert_eq!(ShieldedMemo::from_bytes(&bytes), memo); + } + + #[test] + fn kind_text_with_invalid_utf8_falls_back_to_other() { + let mut bytes = [0u8; MEMO_SIZE]; + bytes[0..4].copy_from_slice(&KIND_TEXT.to_le_bytes()); + // 0xFF is never a valid UTF-8 byte. + bytes[4] = 0xFF; + match ShieldedMemo::from_bytes(&bytes) { + ShieldedMemo::Other { kind, payload } => { + assert_eq!(kind, KIND_TEXT); + assert_eq!(payload[0], 0xFF); + } + other => panic!("expected Other for invalid UTF-8, got {other:?}"), + } + } + + #[test] + fn kind_empty_with_nonzero_payload_falls_back_to_other() { + let mut bytes = [0u8; MEMO_SIZE]; + // kind 0 but a non-zero payload β€” not something we wrote. + bytes[4] = 0x01; + match ShieldedMemo::from_bytes(&bytes) { + ShieldedMemo::Other { kind, payload } => { + assert_eq!(kind, KIND_EMPTY); + assert_eq!(payload[0], 0x01); + } + other => panic!("expected Other for non-zero kind-0 payload, got {other:?}"), + } + } + + #[test] + fn text_with_embedded_then_trailing_zeros_trims_only_trailing() { + // Build a memo whose payload has an interior zero followed by data, + // to confirm we trim trailing zeros only (rposition of last non-zero). + let memo = ShieldedMemo::text("ab").expect("within limit"); + let bytes = memo.to_bytes(); + // payload = [b'a', b'b', 0, 0, ...]; decoding trims to "ab". + assert_eq!( + ShieldedMemo::from_bytes(&bytes), + ShieldedMemo::Text("ab".to_string()) + ); + } +} diff --git a/packages/rs-dpp/src/shielded/mod.rs b/packages/rs-dpp/src/shielded/mod.rs index e30e4623663..5659371d54c 100644 --- a/packages/rs-dpp/src/shielded/mod.rs +++ b/packages/rs-dpp/src/shielded/mod.rs @@ -2,8 +2,11 @@ pub mod builder; mod compute_minimum_shielded_fee; +pub mod memo; mod sighash; +pub use memo::{ShieldedMemo, MEMO_PAYLOAD_SIZE, MEMO_SIZE}; + use bincode::{Decode, Encode}; #[cfg(feature = "serde-conversion")] use serde::{Deserialize, Serialize}; diff --git a/packages/rs-platform-wallet-ffi/src/shielded_send.rs b/packages/rs-platform-wallet-ffi/src/shielded_send.rs index 5c520a38575..fa39d0a057a 100644 --- a/packages/rs-platform-wallet-ffi/src/shielded_send.rs +++ b/packages/rs-platform-wallet-ffi/src/shielded_send.rs @@ -44,6 +44,7 @@ use std::os::raw::c_char; use dashcore::hashes::Hash; use dpp::address_funds::{OrchardAddress, PlatformAddress}; +use dpp::shielded::ShieldedMemo; use dpp::state_transition::public_key_in_creation::IdentityPublicKeyInCreation; use platform_wallet::wallet::asset_lock::AssetLockFunding; use platform_wallet::wallet::shielded::CachedOrchardProver; @@ -150,6 +151,33 @@ pub unsafe extern "C" fn platform_wallet_shielded_prover_is_ready() -> bool { CachedOrchardProver::new().is_ready() } +/// Encode an optional host-supplied memo string into the on-chain +/// 36-byte `DashMemo` layout via [`ShieldedMemo`]. +/// +/// Rules (the encoding decision lives here on the Rust side, not in +/// the Swift caller): +/// - `None` or an empty string β†’ `ShieldedMemo::Empty` β†’ all-zero +/// 36 bytes (identical to today's hardcoded `[0u8; 36]`). +/// - Otherwise a UTF-8 text memo whose byte length must be ≀ +/// [`MEMO_PAYLOAD_SIZE`]; over-length is rejected with +/// `ErrorInvalidParameter`. +/// +/// Factored out as a pure function so the textβ†’bytes rules are unit +/// testable without a live wallet handle. +fn encode_memo_text(memo_text: Option<&str>) -> Result<[u8; 36], PlatformWalletFFIResult> { + match memo_text { + None | Some("") => Ok(ShieldedMemo::Empty.to_bytes()), + Some(text) => ShieldedMemo::text(text) + .map(|memo| memo.to_bytes()) + .map_err(|e| { + PlatformWalletFFIResult::err( + PlatformWalletFFIResultCode::ErrorInvalidParameter, + e.to_string(), + ) + }), + } +} + /// Send a shielded β†’ shielded transfer. /// /// Spends notes from `wallet_id`'s shielded balance and creates a @@ -158,11 +186,20 @@ pub unsafe extern "C" fn platform_wallet_shielded_prover_is_ready() -> bool { /// shielded sub-wallet, no spendable notes, or insufficient /// shielded balance to cover `amount + estimated_fee`. /// +/// `memo_text` is an optional NUL-terminated UTF-8 string attached +/// to the recipient's note. `null` or an empty string means no memo +/// (the all-zero 36-byte memo). A non-empty memo's UTF-8 byte length +/// must be ≀ 32; longer memos are rejected with +/// `ErrorInvalidParameter`. The 36-byte `DashMemo` encoding is done +/// on the Rust side. +/// /// # Safety /// - `wallet_id_bytes` must point to 32 readable bytes. /// - `recipient_raw_43` must point to 43 readable bytes (the /// recipient's raw Orchard payment address β€” same shape /// `platform_wallet_manager_shielded_default_address` returns). +/// - `memo_text`, when non-null, must be a valid NUL-terminated UTF-8 +/// C string for the duration of the call. #[no_mangle] pub unsafe extern "C" fn platform_wallet_manager_shielded_transfer( handle: Handle, @@ -170,6 +207,7 @@ pub unsafe extern "C" fn platform_wallet_manager_shielded_transfer( account: u32, recipient_raw_43: *const u8, amount: u64, + memo_text: *const c_char, ) -> PlatformWalletFFIResult { check_ptr!(wallet_id_bytes); check_ptr!(recipient_raw_43); @@ -179,6 +217,26 @@ pub unsafe extern "C" fn platform_wallet_manager_shielded_transfer( 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, + }; + let (wallet, coordinator) = match resolve_wallet_and_coordinator(handle, &wallet_id) { Ok(p) => p, Err(result) => return result, @@ -192,7 +250,7 @@ pub unsafe extern "C" fn platform_wallet_manager_shielded_transfer( let result = block_on_worker(async move { let prover = CachedOrchardProver::new(); wallet - .shielded_transfer_to(&coordinator, account, &recipient, amount, &prover) + .shielded_transfer_to(&coordinator, account, &recipient, amount, memo, &prover) .await }); if let Err(e) = result { @@ -885,3 +943,55 @@ fn resolve_wallet_and_coordinator( })?; Ok((wallet, coordinator)) } + +#[cfg(test)] +mod tests { + use super::*; + use dpp::shielded::MEMO_PAYLOAD_SIZE; + + #[test] + fn encode_memo_text_none_is_empty() { + let bytes = encode_memo_text(None).expect("None must encode"); + assert_eq!(bytes, [0u8; 36], "None must produce the all-zero memo"); + assert_eq!(ShieldedMemo::from_bytes(&bytes), ShieldedMemo::Empty); + } + + #[test] + fn encode_memo_text_empty_string_is_empty() { + let bytes = encode_memo_text(Some("")).expect("empty string must encode"); + assert_eq!( + bytes, [0u8; 36], + "an empty string must produce the all-zero memo, not a kind-1 text memo" + ); + assert_eq!(ShieldedMemo::from_bytes(&bytes), ShieldedMemo::Empty); + } + + #[test] + fn encode_memo_text_roundtrips_text() { + let bytes = encode_memo_text(Some("thanks for lunch")).expect("text must encode"); + assert_eq!( + ShieldedMemo::from_bytes(&bytes), + ShieldedMemo::Text("thanks for lunch".to_string()) + ); + } + + #[test] + fn encode_memo_text_max_length_multibyte_is_accepted() { + // 8 Γ— πŸ• = 32 bytes, exactly the payload limit. + let s = "πŸ•".repeat(8); + assert_eq!(s.len(), MEMO_PAYLOAD_SIZE); + let bytes = encode_memo_text(Some(&s)).expect("a 32-byte memo must be accepted"); + assert_eq!(ShieldedMemo::from_bytes(&bytes), ShieldedMemo::Text(s)); + } + + #[test] + fn encode_memo_text_over_limit_is_rejected() { + let s = "a".repeat(MEMO_PAYLOAD_SIZE + 1); + let err = encode_memo_text(Some(&s)).expect_err("a 33-byte memo must be rejected"); + assert_eq!( + err.code, + PlatformWalletFFIResultCode::ErrorInvalidParameter, + "over-length memo must surface as an invalid-parameter error" + ); + } +} diff --git a/packages/rs-platform-wallet/src/wallet/platform_wallet.rs b/packages/rs-platform-wallet/src/wallet/platform_wallet.rs index f99aa8e4bac..c88c89571bb 100644 --- a/packages/rs-platform-wallet/src/wallet/platform_wallet.rs +++ b/packages/rs-platform-wallet/src/wallet/platform_wallet.rs @@ -570,6 +570,7 @@ impl PlatformWallet { account: u32, recipient_raw_43: &[u8; 43], amount: u64, + memo: [u8; 36], prover: P, ) -> Result<(), PlatformWalletError> { let guard = self.shielded_keys.read().await; @@ -598,6 +599,7 @@ impl PlatformWallet { account, &recipient, amount, + memo, &prover, ) .await diff --git a/packages/rs-platform-wallet/src/wallet/shielded/operations.rs b/packages/rs-platform-wallet/src/wallet/shielded/operations.rs index b3c8c26f1c4..89d3f117283 100644 --- a/packages/rs-platform-wallet/src/wallet/shielded/operations.rs +++ b/packages/rs-platform-wallet/src/wallet/shielded/operations.rs @@ -465,6 +465,7 @@ pub async fn transfer( account: u32, to_address: &PaymentAddress, amount: u64, + memo: [u8; 36], prover: &P, ) -> Result<(), PlatformWalletError> { let recipient_addr = payment_address_to_orchard(to_address)?; @@ -499,7 +500,7 @@ pub async fn transfer( &keys.spend_auth_key, anchor, prover, - [0u8; 36], + memo, sdk.version(), ) .map_err(|e| PlatformWalletError::ShieldedBuildError(e.to_string()))?; diff --git a/packages/rs-platform-wallet/src/wallet/shielded/sync.rs b/packages/rs-platform-wallet/src/wallet/shielded/sync.rs index 9f488b6360a..cae5d8f31b2 100644 --- a/packages/rs-platform-wallet/src/wallet/shielded/sync.rs +++ b/packages/rs-platform-wallet/src/wallet/shielded/sync.rs @@ -1004,3 +1004,9 @@ mod ovk_recovery_tests; /// (the chain stores them verbatim, so this covers the full path). #[cfg(test)] mod shield_decrypt_tests; + +/// Round-trip guard for the shielded note memo: a `ShieldedMemo` attached +/// to an output survives encryption and comes back out of both the IVK +/// full-decryption and the OVK send-history recovery primitives. +#[cfg(test)] +mod memo_roundtrip_tests; diff --git a/packages/rs-platform-wallet/src/wallet/shielded/sync/memo_roundtrip_tests.rs b/packages/rs-platform-wallet/src/wallet/shielded/sync/memo_roundtrip_tests.rs new file mode 100644 index 00000000000..1c7bc9954e9 --- /dev/null +++ b/packages/rs-platform-wallet/src/wallet/shielded/sync/memo_roundtrip_tests.rs @@ -0,0 +1,282 @@ +//! Round-trip: a [`ShieldedMemo`] attached to a shielded output must +//! survive encryption and come back out of both client-side recovery +//! primitives β€” byte-for-byte, and back to its decoded text. +//! +//! Two complementary halves, mirroring the two scan-side primitives: +//! +//! * IVK side β€” build a real Type 15 Shield transition (exactly as +//! `operations::shield` does: `OrchardKeySet::from_seed` β†’ +//! `build_shield_transition` with the `&&prover` double-ref) carrying +//! a text memo, then assert the FULL incoming decryption +//! (`try_decrypt_note_with_memo`) under the recipient's IVK recovers +//! the note AND the exact memo bytes. The shield builder's serialized +//! actions are stored verbatim on-chain, so this is the real client +//! round trip with the chain cut out of the middle. +//! +//! * OVK side β€” the shield/transfer builders encrypt `out_ciphertext` +//! with `ovk = None` (a per-output random OVK), so a transition's own +//! outputs are *not* recoverable under the wallet's OVK. The wallet's +//! send-history recovery (`try_recover_outgoing_note`) instead opens +//! notes a wallet encrypted with its OWN OVK. We construct such a note +//! directly (the same `OrchardNoteEncryption::new(Some(ovk), …)` shape +//! the OVK-recovery tests use) carrying the same memo bytes and assert +//! OVK recovery returns them. Building the note this way (rather than +//! via the transition builder) is required: there is no builder path +//! that emits an own-OVK-recoverable output. + +use dash_sdk::platform::shielded::{try_decrypt_note_with_memo, try_recover_outgoing_note}; +use dashcore::Network; +use dpp::address_funds::{ + AddressFundsFeeStrategyStep, AddressWitness, OrchardAddress, PlatformAddress, +}; +use dpp::identity::signer::Signer; +use dpp::platform_value::BinaryData; +use dpp::shielded::builder::build_shield_transition; +use dpp::shielded::ShieldedMemo; +use dpp::state_transition::shield_transition::ShieldTransition; +use dpp::state_transition::StateTransition; +use dpp::version::PlatformVersion; +use dpp::ProtocolError; +use drive_proof_verifier::types::ShieldedEncryptedNote; +use grovedb_commitment_tree::{ + DashMemo, Domain, ExtractedNoteCommitment, Note, NoteValue, Nullifier, OrchardDomain, + OutgoingViewingKey, PaymentAddress as Address, RandomSeed, Rho, ValueCommitTrapdoor, + ValueCommitment, +}; +use rand::rngs::OsRng; +use rand::RngCore; +use std::collections::BTreeMap; + +use crate::wallet::shielded::keys::OrchardKeySet; +use crate::wallet::shielded::prover::CachedOrchardProver; + +/// The Orchard note encryptor with Dash's 36-byte memo. +type OrchardNoteEncryption = grovedb_commitment_tree::OrchardNoteEncryption; + +/// Fake transparent-side signer (see `shield_decrypt_tests::DummySigner`): +/// the input witnesses sign the transparent inputs, not the Orchard +/// bundle, so a dummy 65-byte signature suffices for note encryption. +#[derive(Debug)] +struct DummySigner; + +#[async_trait::async_trait] +impl Signer for DummySigner { + async fn sign( + &self, + _key: &PlatformAddress, + _data: &[u8], + ) -> Result { + Ok(BinaryData::new(vec![0u8; 65])) + } + + async fn sign_create_witness( + &self, + _key: &PlatformAddress, + _data: &[u8], + ) -> Result { + Ok(AddressWitness::P2pkh { + signature: BinaryData::new(vec![0u8; 65]), + }) + } + + fn can_sign_with(&self, _key: &PlatformAddress) -> bool { + true + } +} + +/// The memo text under test. Verified ≀ 32 bytes at the top of each test +/// (the πŸ• is 4 bytes, so "thanks for lunch πŸ•" is 17 + 4 = 21 bytes). +const MEMO_TEXT: &str = "thanks for lunch πŸ•"; + +/// Build a real Orchard output encrypted to `recipient` with `ovk` +/// set (the wallet's own OVK), returning the on-chain +/// `ShieldedEncryptedNote` wire form. Mirrors `ovk_recovery_tests:: +/// make_outgoing_wire_note`. +fn make_own_ovk_wire_note( + recipient: Address, + ovk: OutgoingViewingKey, + value_credits: u64, + memo: [u8; 36], +) -> ShieldedEncryptedNote { + let mut rng = OsRng; + + // In Orchard the output note's rho == the spent note's nullifier + // (the same 32-byte Pallas-base element). Draw one valid element and + // use it for both the wire `nullifier` field and the note's `rho`. + let (nf, rho) = loop { + let mut b = [0u8; 32]; + rng.fill_bytes(&mut b); + if let (Some(nf), Some(rho)) = ( + Nullifier::from_bytes(&b).into_option(), + Rho::from_bytes(&b).into_option(), + ) { + break (nf, rho); + } + }; + let rseed = loop { + let mut b = [0u8; 32]; + rng.fill_bytes(&mut b); + if let Some(rseed) = RandomSeed::from_bytes(b, &rho).into_option() { + break rseed; + } + }; + let value = NoteValue::from_raw(value_credits); + let note = Note::from_parts(recipient, value, rho, rseed) + .into_option() + .expect("valid note parts"); + let cmx = ExtractedNoteCommitment::from(note.commitment()); + + // A consistent value commitment point β€” the SAME `cv` feeds both the + // outgoing-ciphertext encryption and (stored as `cv_net`) the + // recovery's `derive_ock`. + let rcv = loop { + let mut b = [0u8; 32]; + rng.fill_bytes(&mut b); + if let Some(rcv) = ValueCommitTrapdoor::from_bytes(b).into_option() { + break rcv; + } + }; + let cv = ValueCommitment::derive(value - NoteValue::from_raw(0), rcv); + + let ne = OrchardNoteEncryption::new(Some(ovk), note, memo); + let epk = OrchardDomain::::epk_bytes(ne.epk()); + let enc = ne.encrypt_note_plaintext(); + let out = ne.encrypt_outgoing_plaintext(&cv, &cmx, &mut rng); + + // Assemble the 216-byte wire `encrypted_note`: + // epk(32) || enc_ciphertext(104) || out_ciphertext(80) + let mut encrypted_note = Vec::with_capacity(216); + encrypted_note.extend_from_slice(&epk.0); + encrypted_note.extend_from_slice(enc.as_ref()); + encrypted_note.extend_from_slice(&out); + assert_eq!(encrypted_note.len(), 216, "wire note must be 216 bytes"); + + ShieldedEncryptedNote { + cmx: cmx.to_bytes().to_vec(), + nullifier: nf.to_bytes().to_vec(), + cv_net: cv.to_bytes().to_vec(), + encrypted_note, + } +} + +/// IVK side: a memo attached to a Type 15 Shield output is recovered, +/// byte-for-byte and back to its text, by the full incoming-decryption +/// primitive under the recipient's IVK. +#[tokio::test] +async fn shield_memo_round_trips_through_ivk_decryption() { + let memo_bytes = ShieldedMemo::text(MEMO_TEXT) + .expect("memo must be within the 32-byte limit") + .to_bytes(); + + let seed = [0x42u8; 32]; + let keys = OrchardKeySet::from_seed(&seed, Network::Testnet, 0) + .expect("ZIP-32 derivation from a fixed seed should succeed"); + + // Recipient = the wallet's own default address, as `operations::shield` + // derives via `default_orchard_address`. + let recipient = OrchardAddress::from_raw_bytes(&keys.default_address.to_raw_address_bytes()) + .expect("default address must convert to OrchardAddress"); + + let amount: u64 = 200_000_000_000; // 2 DASH in credits + let mut inputs = BTreeMap::new(); + inputs.insert( + PlatformAddress::P2pkh([0xAB; 20]), + (0u32, 500_000_000_000u64), + ); + + // `OrchardProver` is impl'd for `&CachedOrchardProver`, so the + // builder's `&P` is a double reference β€” the same shape + // `operations::shield` passes. + let prover = CachedOrchardProver::new(); + let st = build_shield_transition( + &recipient, + amount, + inputs, + vec![AddressFundsFeeStrategyStep::DeductFromInput(0)], + &DummySigner, + 0, + &&prover, + memo_bytes, + PlatformVersion::latest(), + ) + .await + .expect("shield transition build should succeed"); + + let StateTransition::Shield(ShieldTransition::V0(v0)) = st else { + panic!("expected a Shield state transition"); + }; + + // Output-only bundles pad to exactly 2 actions; exactly one (the real + // recipient output) decrypts under our IVK. + let ivk = keys.prepared_ivk(); + let recovered: Vec<(u64, [u8; 36])> = v0 + .actions + .iter() + .filter_map(|a| { + let wire = ShieldedEncryptedNote { + cmx: a.cmx.to_vec(), + nullifier: a.nullifier.to_vec(), + cv_net: a.cv_net.to_vec(), + encrypted_note: a.encrypted_note.clone(), + }; + try_decrypt_note_with_memo(&ivk, &wire) + .map(|(note, _addr, memo)| (note.value().inner(), memo)) + }) + .collect(); + + assert_eq!( + recovered.len(), + 1, + "exactly one action (the real recipient output) must decrypt with its memo" + ); + let (value, memo) = recovered[0]; + assert_eq!( + value, amount, + "decrypted note value must equal the shielded amount" + ); + assert_eq!( + memo, memo_bytes, + "the recovered memo bytes must equal the bytes we encrypted" + ); + assert_eq!( + ShieldedMemo::from_bytes(&memo), + ShieldedMemo::Text(MEMO_TEXT.to_string()), + "the recovered memo must decode back to the original text" + ); +} + +/// OVK side: a memo attached to a note the wallet encrypted with its OWN +/// OVK is recovered, byte-for-byte and back to its text, by the +/// send-history recovery primitive under the sender's OVK. +#[test] +fn shield_memo_round_trips_through_ovk_recovery() { + let memo_bytes = ShieldedMemo::text(MEMO_TEXT) + .expect("memo must be within the 32-byte limit") + .to_bytes(); + + let sender = OrchardKeySet::from_seed(&[0x42u8; 32], Network::Testnet, 0) + .expect("ZIP-32 derivation from a fixed seed should succeed"); + let recipient_addr = sender.address_at(7); + let value = 123_456u64; + + let wire = make_own_ovk_wire_note( + recipient_addr, + sender.outgoing_viewing_key.clone(), + value, + memo_bytes, + ); + + let (note, _recipient, recovered_memo) = + try_recover_outgoing_note(&sender.outgoing_viewing_key, &wire) + .expect("the wallet's own OVK must recover the note it sent"); + assert_eq!(note.value().inner(), value, "recovered value mismatch"); + assert_eq!( + recovered_memo, memo_bytes, + "the OVK-recovered memo bytes must equal the bytes we encrypted" + ); + assert_eq!( + ShieldedMemo::from_bytes(&recovered_memo), + ShieldedMemo::Text(MEMO_TEXT.to_string()), + "the OVK-recovered memo must decode back to the original text" + ); +} diff --git a/packages/rs-sdk/src/platform/shielded/mod.rs b/packages/rs-sdk/src/platform/shielded/mod.rs index b2a2d8c6a36..35f4808ba86 100644 --- a/packages/rs-sdk/src/platform/shielded/mod.rs +++ b/packages/rs-sdk/src/platform/shielded/mod.rs @@ -3,6 +3,8 @@ //! This module provides: //! - [`try_decrypt_note`]: compact trial decryption on a single encrypted note //! (incoming, under the IVK) +//! - [`try_decrypt_note_with_memo`]: full incoming trial decryption that also +//! recovers the note's memo (under the IVK) //! - [`try_recover_outgoing_note`]: OVK recovery of a sent note (recipient, //! value, memo) under the wallet's Outgoing Viewing Key //! - [`sync_shielded_notes`]: end-to-end sync that fetches encrypted notes from @@ -10,7 +12,9 @@ pub mod notes_sync; -pub use notes_sync::decrypt::{try_decrypt_note, try_recover_outgoing_note, DASH_MEMO_SIZE}; +pub use notes_sync::decrypt::{ + try_decrypt_note, try_decrypt_note_with_memo, try_recover_outgoing_note, DASH_MEMO_SIZE, +}; pub use notes_sync::sync_shielded_notes::{sync_shielded_notes, sync_shielded_notes_stream}; pub use notes_sync::types::{ DecryptedNote, ShieldedChunkBatch, ShieldedSyncConfig, ShieldedSyncResult, diff --git a/packages/rs-sdk/src/platform/shielded/notes_sync/decrypt.rs b/packages/rs-sdk/src/platform/shielded/notes_sync/decrypt.rs index 43c2ac11350..2a049892198 100644 --- a/packages/rs-sdk/src/platform/shielded/notes_sync/decrypt.rs +++ b/packages/rs-sdk/src/platform/shielded/notes_sync/decrypt.rs @@ -17,10 +17,10 @@ use drive_proof_verifier::types::ShieldedEncryptedNote; use grovedb_commitment_tree::{ - try_compact_note_decryption, try_output_recovery_with_ovk, CompactAction, DashMemo, - EphemeralKeyBytes, ExtractedNoteCommitment, Note, NoteBytes, NoteBytesData, Nullifier, - OrchardDomain, OutgoingViewingKey, PaymentAddress, PreparedIncomingViewingKey, ValueCommitment, - COMPACT_NOTE_SIZE, + try_compact_note_decryption, try_note_decryption, try_output_recovery_with_ovk, CompactAction, + DashMemo, EphemeralKeyBytes, ExtractedNoteCommitment, Note, NoteBytes, NoteBytesData, + Nullifier, OrchardDomain, OutgoingViewingKey, PaymentAddress, PreparedIncomingViewingKey, + ValueCommitment, COMPACT_NOTE_SIZE, }; /// Minimum length of the `encrypted_note` field for compact trial decryption. @@ -198,3 +198,56 @@ pub fn try_recover_outgoing_note( try_output_recovery_with_ovk(&domain, ovk, &output, &cv, &out_bytes) } + +/// Attempt FULL incoming trial decryption on a [`ShieldedEncryptedNote`], +/// recovering the memo alongside the note. +/// +/// The sibling [`try_decrypt_note`] uses the 52-byte *compact* prefix +/// of `enc_ciphertext` and so cannot recover the memo (the memo lives +/// past the compact region). This variant decrypts the FULL 104-byte +/// `enc_ciphertext` via `try_note_decryption`, yielding the +/// `(note, recipient, memo)` the sender encrypted to the wallet's +/// incoming viewing key. It reuses the same [`RecoverableOutput`] the +/// OVK path uses (it exposes the full ciphertext) and reconstructs the +/// domain from the stored nullifier β€” the output note's `rho` equals +/// the spent note's nullifier in Orchard, so +/// `OrchardDomain::for_nullifier` builds the same domain a full +/// `Action` would. +/// +/// Returns `Some((note, address, memo))` if the note decrypts under the +/// given incoming viewing key, or `None` if it does not belong to the +/// viewer (including dummy/padding notes or any malformed field). The +/// returned `memo` is the raw [`DASH_MEMO_SIZE`]-byte Dash memo. +pub fn try_decrypt_note_with_memo( + ivk: &PreparedIncomingViewingKey, + encrypted_note: &ShieldedEncryptedNote, +) -> Option<(Note, PaymentAddress, [u8; DASH_MEMO_SIZE])> { + let data = &encrypted_note.encrypted_note; + if data.len() < FULL_ENCRYPTED_NOTE_LEN { + return None; + } + + // cmx (32 bytes) from the dedicated field. + let cmx_bytes: [u8; 32] = encrypted_note.cmx.as_slice().try_into().ok()?; + let cmx = ExtractedNoteCommitment::from_bytes(&cmx_bytes).into_option()?; + + // Nullifier (32 bytes) drives the domain's `rho` (== the spent + // note's nullifier), same as the OVK path above. + let nf_bytes: [u8; 32] = encrypted_note.nullifier.as_slice().try_into().ok()?; + let nf = Nullifier::from_bytes(&nf_bytes).into_option()?; + + // epk [0..32] and the FULL enc_ciphertext [32..136] (104 bytes) β€” + // unlike the compact path, the memo lives inside this region. + let epk_bytes: [u8; 32] = data[0..32].try_into().ok()?; + let enc_bytes: [u8; ENC_CIPHERTEXT_SIZE] = + data[32..32 + ENC_CIPHERTEXT_SIZE].try_into().ok()?; + + let output = RecoverableOutput { + cmx, + epk: EphemeralKeyBytes(epk_bytes), + enc: NoteBytesData(enc_bytes), + }; + let domain = OrchardDomain::::for_nullifier(nf); + + try_note_decryption(&domain, ivk, &output) +} diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManagerShieldedSync.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManagerShieldedSync.swift index b578a24263a..56c87148d42 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManagerShieldedSync.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManagerShieldedSync.swift @@ -429,11 +429,18 @@ extension PlatformWalletManager { /// Amount is in credits (1 DASH = 1e11). Heavy CPU work runs /// on a detached task so the caller's actor isn't blocked /// through the proof build. + /// + /// `memo` is an optional UTF-8 text note attached to the + /// recipient's note. `nil` (or an empty string) means no memo; + /// a non-empty memo's UTF-8 byte length must be at most 32 or + /// Rust rejects it. The 36-byte on-chain encoding is done on the + /// Rust side. public func shieldedTransfer( walletId: Data, account: UInt32 = 0, recipientRaw43: Data, - amount: UInt64 + amount: UInt64, + memo: String? = nil ) async throws { guard isConfigured, handle != NULL_HANDLE else { throw PlatformWalletError.invalidHandle( @@ -466,9 +473,19 @@ extension PlatformWalletManager { "recipient baseAddress is nil" ) } - try platform_wallet_manager_shielded_transfer( - handle, widPtr, account, recipientPtr, amount - ).check() + // `nil` / empty β†’ null pointer (no memo); otherwise + // pass the text as a C string. Rust validates the + // 32-byte limit and does the 36-byte encoding. + let send: (UnsafePointer?) throws -> Void = { memoCStr in + try platform_wallet_manager_shielded_transfer( + handle, widPtr, account, recipientPtr, amount, memoCStr + ).check() + } + if let memo, !memo.isEmpty { + try memo.withCString { try send($0) } + } else { + try send(nil) + } } } }.value diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/ViewModels/SendViewModel.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/ViewModels/SendViewModel.swift index b727a7f2dfa..272cd13dfbf 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/ViewModels/SendViewModel.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/ViewModels/SendViewModel.swift @@ -79,6 +79,10 @@ class SendViewModel: ObservableObject { didSet { detectAddressType() } } @Published var amountString = "" + /// Optional UTF-8 memo for a shielded β†’ shielded transfer. Only + /// surfaced when the recipient is an Orchard address; Rust caps it + /// at 32 UTF-8 bytes and does the 36-byte encoding. + @Published var memoText = "" @Published var detectedAddressType: DashAddressType = .unknown @Published var selectedSource: FundSource = .core @Published var detectedFlow: SendFlow? @@ -116,8 +120,30 @@ class SendViewModel: ObservableObject { /// (Core uses duffs; Platform / shielded use credits). var amountDuffs: UInt64? { amount } + /// Maximum UTF-8 byte length of a shielded memo (the 32-byte + /// payload of the 36-byte `DashMemo`; the other 4 bytes are the + /// kind tag). Mirrors `dpp::shielded::MEMO_PAYLOAD_SIZE`. + static let memoByteLimit = 32 + + /// UTF-8 byte length of the trimmed memo β€” what Rust validates + /// against the 32-byte limit, not the character count. + var memoByteCount: Int { + memoText.trimmingCharacters(in: .whitespacesAndNewlines).utf8.count + } + + /// Whether the memo exceeds the 32-byte payload limit. Blocks Send. + var isMemoOverLimit: Bool { + memoByteCount > Self.memoByteLimit + } + var canSend: Bool { guard let flow = detectedFlow, !isSending else { return false } + // An over-limit memo would be rejected by Rust; block here so + // the user sees the red counter rather than a backend error. + // Only the shielded β†’ shielded path carries a memo, so a stale + // over-limit memo must not block the other flows (where the + // field is hidden and the text is ignored). + if flow == .shieldedToShielded && isMemoOverLimit { return false } // Gate on the scaled integer for the *active* flow's unit, not // just non-nil. A sub-unit amount (e.g. "0.000000001" in DASH) // parses to 0 once scaled; sending that reaches the backend as a @@ -347,11 +373,13 @@ class SendViewModel: ObservableObject { error = "Recipient is not a shielded address" return } + let trimmedMemo = memoText.trimmingCharacters(in: .whitespacesAndNewlines) try await walletManager.shieldedTransfer( walletId: wallet.walletId, account: 0, recipientRaw43: recipientRaw, - amount: amountCredits + amount: amountCredits, + memo: trimmedMemo.isEmpty ? nil : trimmedMemo ) successMessage = "Shielded transfer complete" diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/SendTransactionView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/SendTransactionView.swift index a5c90cbeb92..b62888c6e5d 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/SendTransactionView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/SendTransactionView.swift @@ -112,6 +112,27 @@ struct SendTransactionView: View { } } + // Memo (shielded β†’ shielded only). The on-chain note + // carries an optional 32-byte UTF-8 memo. Gate on the + // flow, not the recipient type: an Orchard recipient + // with a Platform source is the self-shield path, which + // has no memo parameter β€” showing the field there would + // silently drop the text. Count UTF-8 bytes (not + // characters) so the limit matches Rust. + if viewModel.detectedFlow == .shieldedToShielded { + Section("Memo (optional)") { + TextField("Note for the recipient", text: $viewModel.memoText) + .textInputAutocapitalization(.sentences) + .autocorrectionDisabled() + HStack { + Spacer() + Text("\(viewModel.memoByteCount)/\(SendViewModel.memoByteLimit) bytes") + .font(.caption) + .foregroundColor(viewModel.isMemoOverLimit ? .red : .secondary) + } + } + } + // Fund Source if !sources.isEmpty { Section("Send From") {