From 6670195c54a7a04678c2549fc1452473d2625c81 Mon Sep 17 00:00:00 2001 From: bfoss765 <38437574+bfoss765@users.noreply.github.com> Date: Wed, 22 Jul 2026 18:25:00 -0400 Subject: [PATCH 01/14] feat(kotlin-sdk): upstream one-time Orchard key shielded-invite API from b2 line Co-Authored-By: Claude Fable 5 --- .../dashsdk/ffi/FundingNative.kt | 21 ++ .../dashsdk/wallet/PlatformWalletManager.kt | 56 ++++++ .../src/shielded_send.rs | 85 +++++++- packages/rs-platform-wallet/Cargo.toml | 7 +- .../src/wallet/shielded/keys.rs | 183 ++++++++++++++++++ .../src/wallet/shielded/mod.rs | 5 +- packages/rs-unified-sdk-jni/src/funding.rs | 69 +++++++ 7 files changed, 423 insertions(+), 3 deletions(-) diff --git a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/ffi/FundingNative.kt b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/ffi/FundingNative.kt index d85f538d31d..767219373e4 100644 --- a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/ffi/FundingNative.kt +++ b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/ffi/FundingNative.kt @@ -141,6 +141,27 @@ internal object FundingNative { signerAddressHandle: Long, ): ByteArray + /** + * Generate a fresh one-time Orchard spending key + its default payment + * address (bridges `platform_wallet_generate_one_time_orchard_key`) — the + * *inviter* side of an L2 shielded invitation. Handle-less: a one-time key + * is process-local Orchard crypto, not bound to any wallet. + * + * Returns a single 75-byte blob: bytes `[0, 32)` are the 32-byte one-time + * spending key and bytes `[32, 75)` are the 43-byte raw default Orchard + * address to fund. The inviter funds a note to the address; a claimer given + * the spending key spends it via [shieldedIdentityCreateFromOneTimeKey]. + */ + external fun generateOneTimeOrchardKey(): ByteArray + + /** + * Derive the default 43-byte raw Orchard address from a 32-byte one-time + * spending key (bridges `platform_wallet_orchard_address_from_spending_key`) + * — the RNG-free counterpart of [generateOneTimeOrchardKey]. Handle-less; + * throws if [spendingKey] is not a valid Orchard spending key. + */ + external fun orchardAddressFromSpendingKey(spendingKey: ByteArray): ByteArray + // ── Shielded outgoing spends (types 16/17/19) ───────────────────── // // Manager-handle calls like the funding submits above; each signs with diff --git a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/wallet/PlatformWalletManager.kt b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/wallet/PlatformWalletManager.kt index 940a9b79639..6076fe279e6 100644 --- a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/wallet/PlatformWalletManager.kt +++ b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/wallet/PlatformWalletManager.kt @@ -2227,6 +2227,62 @@ class PlatformWalletManager( } } +/** + * A freshly generated one-time Orchard key for an L2 shielded invitation — + * the *inviter* side. Returned by [generateOneTimeOrchardKey]. + * + * The inviter funds an Orchard note to [address]; a claimer handed + * [spendingKey] re-derives its viewing keys and spends that note via + * [PlatformWalletManager.shieldedIdentityCreateFromOneTimeKey]. All Orchard + * key material is generated in Rust — the app only ever sees these bytes. + */ +data class OneTimeOrchardKey( + /** The 32-byte one-time Orchard spending key (the claimer's spend authority). */ + val spendingKey: ByteArray, + /** The 43-byte raw default Orchard payment address the inviter funds. */ + val address: ByteArray, +) { + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other !is OneTimeOrchardKey) return false + return spendingKey.contentEquals(other.spendingKey) && + address.contentEquals(other.address) + } + + override fun hashCode(): Int = 31 * spendingKey.contentHashCode() + address.contentHashCode() +} + +/** + * Generate a fresh one-time Orchard spending key together with the default + * Orchard address it funds — the *inviter* side of an L2 shielded invitation. + * + * Handle-less (process-local Orchard crypto). The inviter funds a note to the + * returned [OneTimeOrchardKey.address]; the claimer, handed + * [OneTimeOrchardKey.spendingKey], spends it. The spending key is exactly the + * 32-byte value [PlatformWalletManager.shieldedIdentityCreateFromOneTimeKey] + * accepts. + */ +fun generateOneTimeOrchardKey(): OneTimeOrchardKey { + val blob = mapNativeErrors { FundingNative.generateOneTimeOrchardKey() } + require(blob.size == 75) { "expected a 75-byte sk||address blob, got ${blob.size}" } + return OneTimeOrchardKey( + spendingKey = blob.copyOfRange(0, 32), + address = blob.copyOfRange(32, 75), + ) +} + +/** + * Derive the default 43-byte raw Orchard payment address from a 32-byte + * one-time Orchard [spendingKey] — the RNG-free counterpart of + * [generateOneTimeOrchardKey], for round-trip validation and recomputing the + * recipient an inviter must fund for a given key. Handle-less; throws if + * [spendingKey] is not a valid Orchard spending key. + */ +fun orchardAddressFromSpendingKey(spendingKey: ByteArray): ByteArray { + require(spendingKey.size == 32) { "spendingKey must be 32 bytes, got ${spendingKey.size}" } + return mapNativeErrors { FundingNative.orchardAddressFromSpendingKey(spendingKey) } +} + /** * Per-wallet seedless-unlock status — Swift `DashPayUnlockStatus`. * Published on [PlatformWalletManager.dashPayUnlockStatus]; drives the diff --git a/packages/rs-platform-wallet-ffi/src/shielded_send.rs b/packages/rs-platform-wallet-ffi/src/shielded_send.rs index 789995526a7..810041a5a4b 100644 --- a/packages/rs-platform-wallet-ffi/src/shielded_send.rs +++ b/packages/rs-platform-wallet-ffi/src/shielded_send.rs @@ -50,7 +50,9 @@ use dpp::shielded::{ }; use dpp::state_transition::public_key_in_creation::IdentityPublicKeyInCreation; use platform_wallet::wallet::asset_lock::AssetLockFunding; -use platform_wallet::wallet::shielded::CachedOrchardProver; +use platform_wallet::wallet::shielded::{ + generate_one_time_orchard_key, orchard_address_from_spending_key, CachedOrchardProver, +}; use platform_wallet::PlatformWalletError; use rs_sdk_ffi::{MnemonicResolverCoreSigner, MnemonicResolverHandle, SignerHandle, VTableSigner}; @@ -1360,6 +1362,87 @@ fn resolve_wallet_and_coordinator( Ok((wallet, coordinator)) } +// --------------------------------------------------------------------------- +// One-time Orchard key generation (inviter side of L2 shielded invitations) +// --------------------------------------------------------------------------- + +/// Generate a fresh one-time Orchard spending key and its default payment +/// address — the *inviter* side of an L2 shielded invitation. +/// +/// Handle-less: a one-time key is process-local Orchard crypto, not bound +/// to any wallet. Writes the 32-byte spending key to `out_sk_32` and the 43 +/// raw bytes of its default Orchard address (11-byte diversifier + 32-byte +/// `pk_d`, the same encoding +/// [`platform_wallet_manager_shielded_default_address`] returns) to +/// `out_address_43`. +/// +/// The inviter funds a note to `out_address_43`; a claimer handed the 32 +/// bytes in `out_sk_32` spends it via +/// [`platform_wallet_manager_shielded_identity_create_from_one_time_key`] +/// (which accepts exactly these spending-key bytes). +/// +/// Always succeeds (the generator re-rolls until it draws a valid scalar). +/// +/// [`platform_wallet_manager_shielded_default_address`]: crate::platform_wallet_manager_shielded_default_address +/// +/// # Safety +/// - `out_sk_32` must point at 32 writable bytes. +/// - `out_address_43` must point at 43 writable bytes. +#[no_mangle] +pub unsafe extern "C" fn platform_wallet_generate_one_time_orchard_key( + out_sk_32: *mut u8, + out_address_43: *mut u8, +) -> PlatformWalletFFIResult { + check_ptr!(out_sk_32); + check_ptr!(out_address_43); + + let (sk, address) = generate_one_time_orchard_key(); + std::ptr::copy_nonoverlapping(sk.as_ptr(), out_sk_32, 32); + std::ptr::copy_nonoverlapping(address.as_ptr(), out_address_43, 43); + PlatformWalletFFIResult::ok() +} + +/// Derive the default raw Orchard payment address (43 bytes) from a 32-byte +/// Orchard spending key — the RNG-free counterpart of +/// [`platform_wallet_generate_one_time_orchard_key`]. +/// +/// Handle-less. On success the 43 raw address bytes (11-byte diversifier + +/// 32-byte `pk_d`) are written to `out_address_43`. Returns +/// [`ErrorInvalidParameter`] if `sk_bytes_32` is not a valid Orchard +/// `SpendingKey` scalar. Used for round-trip validation and to recompute +/// the recipient an inviter must fund for a given one-time key. +/// +/// [`ErrorInvalidParameter`]: crate::error::PlatformWalletFFIResultCode::ErrorInvalidParameter +/// +/// # Safety +/// - `sk_bytes_32` must point at 32 readable bytes. +/// - `out_address_43` must point at 43 writable bytes. +#[no_mangle] +pub unsafe extern "C" fn platform_wallet_orchard_address_from_spending_key( + sk_bytes_32: *const u8, + out_address_43: *mut u8, +) -> PlatformWalletFFIResult { + check_ptr!(sk_bytes_32); + check_ptr!(out_address_43); + + let mut sk = [0u8; 32]; + std::ptr::copy_nonoverlapping(sk_bytes_32, sk.as_mut_ptr(), 32); + + match orchard_address_from_spending_key(sk) { + Ok(address) => { + std::ptr::copy_nonoverlapping(address.as_ptr(), out_address_43, 43); + PlatformWalletFFIResult::ok() + } + // An invalid scalar is a bad caller-supplied key, not an internal + // fault — surface it as an invalid parameter (the typed + // `ShieldedKeyDerivation` message is preserved verbatim). + Err(e) => PlatformWalletFFIResult::err( + PlatformWalletFFIResultCode::ErrorInvalidParameter, + e.to_string(), + ), + } +} + #[cfg(test)] mod tests { use super::*; diff --git a/packages/rs-platform-wallet/Cargo.toml b/packages/rs-platform-wallet/Cargo.toml index 6c7e86a5b8e..c881953af2f 100644 --- a/packages/rs-platform-wallet/Cargo.toml +++ b/packages/rs-platform-wallet/Cargo.toml @@ -69,6 +69,11 @@ zip32 = { version = "0.2.0", default-features = false, optional = true } # Same version as `dash-sdk` so the lockfile resolves a single copy. futures = { version = "0.3.30", optional = true } +# OS CSPRNG (`OsRng`) for one-time Orchard key generation +# (`shielded::keys::generate_one_time_orchard_key`, the inviter side of L2 +# shielded invitations). Same `rand` major the dev-deps / benches already use. +rand = { version = "0.8", optional = true } + # Networked, opt-in example binaries. Each one performs real network I/O # against a live devnet, so they are examples (compiled, never run by # `cargo test`) rather than `#[ignore]`d tests. They are crate-gated on the @@ -122,7 +127,7 @@ default = ["bls", "eddsa"] test-utils = ["key-wallet/test-utils"] bls = ["key-wallet/bls", "key-wallet-manager/bls"] eddsa = ["key-wallet/eddsa", "key-wallet-manager/eddsa"] -shielded = ["dep:grovedb-commitment-tree", "dep:rusqlite", "dep:zip32", "dep:futures", "dash-sdk/shielded", "dpp/shielded-client"] +shielded = ["dep:grovedb-commitment-tree", "dep:rusqlite", "dep:zip32", "dep:futures", "dep:rand", "dash-sdk/shielded", "dpp/shielded-client"] # Opt-in serde derives on the changeset types in `src/changeset/` plus # the per-identity / DashPay scalar types those changesets carry. # Activates `key-wallet/serde` (which transitively activates diff --git a/packages/rs-platform-wallet/src/wallet/shielded/keys.rs b/packages/rs-platform-wallet/src/wallet/shielded/keys.rs index d3d0a23a83c..154eaa3b9ec 100644 --- a/packages/rs-platform-wallet/src/wallet/shielded/keys.rs +++ b/packages/rs-platform-wallet/src/wallet/shielded/keys.rs @@ -214,6 +214,80 @@ impl AccountViewingKeys { } } +/// Length in bytes of a raw Orchard payment address: an 11-byte +/// diversifier concatenated with a 32-byte `pk_d`. This is the encoding +/// [`PaymentAddress::to_raw_address_bytes`] produces and the one +/// `platform_wallet_manager_shielded_default_address` / +/// `identity_create_from_one_time_key` speak. +pub const ORCHARD_RAW_ADDRESS_LEN: usize = 43; + +/// Derive the default raw Orchard payment address (diversifier index 0, +/// external scope) from a 32-byte Orchard spending key. +/// +/// This is the standalone, RNG-free deriver behind +/// [`generate_one_time_orchard_key`]. It runs the exact SK → FVK → +/// default-address pipeline that [`OrchardKeySet::from_seed`] uses +/// (`FullViewingKey::from(&sk)` then `address_at(0, External)`), and +/// returns the same 43-byte raw encoding +/// (`super::operations::identity_create_from_one_time_key` derives its +/// scan key from `SpendingKey::from_bytes(sk)` identically). The *inviter* +/// side of an L2 shielded invitation calls this to compute the Orchard +/// recipient it must fund a note to for a given one-time spending key; it +/// is also the cheap round-trip check for [`generate_one_time_orchard_key`]. +/// +/// # Errors +/// +/// Returns [`PlatformWalletError::ShieldedKeyDerivation`] when `sk_bytes` +/// is not a valid Orchard `SpendingKey` scalar — the same validity gate +/// `identity_create_from_one_time_key` applies to a claimed key. +pub fn orchard_address_from_spending_key( + sk_bytes: [u8; 32], +) -> Result<[u8; ORCHARD_RAW_ADDRESS_LEN], PlatformWalletError> { + let sk: SpendingKey = Option::from(SpendingKey::from_bytes(sk_bytes)).ok_or_else(|| { + PlatformWalletError::ShieldedKeyDerivation( + "spending key is not a valid Orchard SpendingKey".to_string(), + ) + })?; + let fvk = FullViewingKey::from(&sk); + Ok(fvk.address_at(0u32, Scope::External).to_raw_address_bytes()) +} + +/// Generate a fresh one-time Orchard spending key together with its default +/// raw payment address. +/// +/// Returns `(spending_key_32, default_address_43)`: +/// - `spending_key_32` — a uniformly random, valid 32-byte Orchard +/// `SpendingKey` scalar. These are exactly the bytes +/// `identity_create_from_one_time_key` accepts as its one-time key: both +/// sides round-trip through `SpendingKey::from_bytes`, which stores the +/// scalar bytes verbatim, so `spending_key_32 == sk.to_bytes()`. +/// - `default_address_43` — the address +/// [`orchard_address_from_spending_key`] derives for that key (raw +/// 11-byte diversifier ‖ 32-byte `pk_d`). +/// +/// This keeps all Orchard key material in Rust: the *inviter* funds a note +/// to `default_address_43`, and a *claimer* handed `spending_key_32` +/// re-derives the viewing keys and spends it. +/// +/// The scalar is drawn from the OS CSPRNG ([`OsRng`](rand::rngs::OsRng)) +/// and re-rolled until it is a valid Orchard key — an invalid draw is +/// negligibly rare and the same acceptance loop the `orchard` crate's own +/// dummy-key generator runs. +pub fn generate_one_time_orchard_key() -> ([u8; 32], [u8; ORCHARD_RAW_ADDRESS_LEN]) { + use rand::{rngs::OsRng, RngCore}; + + let mut rng = OsRng; + loop { + let mut sk_bytes = [0u8; 32]; + rng.fill_bytes(&mut sk_bytes); + if let Some(sk) = Option::::from(SpendingKey::from_bytes(sk_bytes)) { + let fvk = FullViewingKey::from(&sk); + let address = fvk.address_at(0u32, Scope::External).to_raw_address_bytes(); + return (sk_bytes, address); + } + } +} + #[cfg(test)] mod tests { use super::*; @@ -406,4 +480,113 @@ mod tests { "non-canonical FVK bytes must be rejected" ); } + + /// Round-trip: a freshly generated one-time key's returned address is + /// exactly what [`orchard_address_from_spending_key`] re-derives from the + /// returned spending key. This is the invariant the inviter/claimer split + /// relies on — the inviter funds the returned address; the claimer, given + /// only the spending key, must re-derive the same recipient. + #[test] + fn one_time_key_generate_roundtrips_to_its_address() { + let (sk, address) = generate_one_time_orchard_key(); + let rederived = orchard_address_from_spending_key(sk) + .expect("a freshly generated sk is a valid Orchard SpendingKey"); + assert_eq!( + address, rederived, + "generated address must equal the deriver's output for the same sk" + ); + } + + /// Ownership: a real Orchard note sent to the generated address is + /// recognized by the generated key's incoming viewing key (the claimer + /// discovers it on scan) and its nullifier derives cleanly under that + /// key's full viewing key (the claimer can spend it). Mirrors the + /// note-shaping the foreign-key scan in `operations.rs` performs. + #[test] + fn generated_key_owns_a_note_sent_to_its_address() { + use grovedb_commitment_tree::{ + ExtractedNoteCommitment, FullViewingKey, Note, NoteValue, RandomSeed, Rho, Scope, + SpendingKey, + }; + + let (sk_bytes, address_bytes) = generate_one_time_orchard_key(); + + // Re-derive exactly the viewing keys a claimer would hold. + let sk: SpendingKey = Option::from(SpendingKey::from_bytes(sk_bytes)) + .expect("generated sk is a valid Orchard SpendingKey"); + let fvk = FullViewingKey::from(&sk); + let ivk = fvk.to_ivk(Scope::External); + let recipient = fvk.address_at(0u32, Scope::External); + + // The generated raw address is precisely this recipient. + assert_eq!( + recipient.to_raw_address_bytes(), + address_bytes, + "the generated address is the key's default payment address" + ); + + // The claimer's IVK owns (recognizes) that address. + assert!( + ivk.diversifier_index(&recipient).is_some(), + "the generated key's ivk must own the generated address" + ); + + // Build a real note to the address (canonical rho / rseed, exactly as + // the foreign-key scan reconstructs one) and confirm it is well-formed + // and spendable under the generated fvk: the nullifier derives without + // panicking, which is the quantity the claimer's scan stamps. + let rho = (1u16..=u16::MAX) + .find_map(|n| { + let mut b = [0u8; 32]; + b[0..2].copy_from_slice(&n.to_le_bytes()); + Rho::from_bytes(&b).into_option() + }) + .expect("a canonical rho exists"); + let rseed = (1u16..=u16::MAX) + .find_map(|m| { + let mut b = [0u8; 32]; + b[2..4].copy_from_slice(&m.to_le_bytes()); + RandomSeed::from_bytes(b, &rho).into_option() + }) + .expect("a canonical rseed exists"); + let note = Note::from_parts(recipient, NoteValue::from_raw(10_000_000_000), rho, rseed) + .into_option() + .expect("valid note parts"); + + let _cmx = ExtractedNoteCommitment::from(note.commitment()).to_bytes(); + let _nullifier = note.nullifier(&fvk).to_bytes(); + assert_eq!( + note.recipient().to_raw_address_bytes(), + address_bytes, + "the note's recipient is the generated address" + ); + } + + /// Determinism: the deriver is a pure function of the spending key — + /// same sk in, same address out — and it agrees with what the generator + /// returned. + #[test] + fn address_from_spending_key_is_deterministic() { + let (sk, address) = generate_one_time_orchard_key(); + let a = orchard_address_from_spending_key(sk).expect("valid sk"); + let b = orchard_address_from_spending_key(sk).expect("valid sk"); + assert_eq!(a, b, "same sk must derive the same address"); + assert_eq!( + a, address, + "the deriver agrees with the generator for the generated sk" + ); + } + + /// Two generations draw distinct keys (the OS CSPRNG is not seeded to a + /// fixed value). A collision here would be a catastrophic RNG failure. + #[test] + fn generate_produces_distinct_keys() { + let (sk_a, addr_a) = generate_one_time_orchard_key(); + let (sk_b, addr_b) = generate_one_time_orchard_key(); + assert_ne!(sk_a, sk_b, "distinct draws must differ"); + assert_ne!( + addr_a, addr_b, + "distinct keys must derive distinct addresses" + ); + } } diff --git a/packages/rs-platform-wallet/src/wallet/shielded/mod.rs b/packages/rs-platform-wallet/src/wallet/shielded/mod.rs index 7685f44b883..b71c2b97c16 100644 --- a/packages/rs-platform-wallet/src/wallet/shielded/mod.rs +++ b/packages/rs-platform-wallet/src/wallet/shielded/mod.rs @@ -53,7 +53,10 @@ pub use activity::{ }; pub use coordinator::NetworkShieldedCoordinator; pub use file_store::{FileBackedShieldedStore, FileShieldedStoreError}; -pub use keys::{AccountViewingKeys, OrchardKeySet}; +pub use keys::{ + generate_one_time_orchard_key, orchard_address_from_spending_key, AccountViewingKeys, + OrchardKeySet, ORCHARD_RAW_ADDRESS_LEN, +}; pub use prover::CachedOrchardProver; pub use seed_pool::{SeedPoolOutcome, SeedPoolProgress, DEFAULT_SEED_POOL_TARGET_NOTES}; pub use store::{ diff --git a/packages/rs-unified-sdk-jni/src/funding.rs b/packages/rs-unified-sdk-jni/src/funding.rs index f8dc82f050a..432cf5ee937 100644 --- a/packages/rs-unified-sdk-jni/src/funding.rs +++ b/packages/rs-unified-sdk-jni/src/funding.rs @@ -783,6 +783,75 @@ pub extern "system" fn Java_org_dashfoundation_dashsdk_ffi_FundingNative_shielde }) } +/// Generate a fresh one-time Orchard spending key + its default payment +/// address (bridges `platform_wallet_generate_one_time_orchard_key`) — the +/// *inviter* side of an L2 shielded invitation. +/// +/// Handle-less: a one-time key is process-local Orchard crypto, not bound to +/// any wallet. Returns a single 75-byte array carrying both halves: +/// `bytes[0..32]` is the 32-byte one-time spending key, `bytes[32..75]` is +/// the 43-byte raw default Orchard address the inviter funds a note to. The +/// Kotlin wrapper splits the blob back into the two arrays. +#[no_mangle] +pub extern "system" fn Java_org_dashfoundation_dashsdk_ffi_FundingNative_generateOneTimeOrchardKey( + mut env: JNIEnv, + _class: JClass, +) -> jni::sys::jbyteArray { + guard(&mut env, ptr::null_mut(), |env| { + let mut sk = [0u8; 32]; + let mut addr = [0u8; 43]; + let result = unsafe { + platform_wallet_ffi::platform_wallet_generate_one_time_orchard_key( + sk.as_mut_ptr(), + addr.as_mut_ptr(), + ) + }; + if take_pwffi_error(env, result) { + return ptr::null_mut(); + } + // sk ‖ addr — a 75-byte blob the Kotlin side slices into (sk32, addr43). + let mut out = [0u8; 75]; + out[..32].copy_from_slice(&sk); + out[32..].copy_from_slice(&addr); + env.byte_array_from_slice(&out) + .map(|a| a.into_raw()) + .unwrap_or(ptr::null_mut()) + }) +} + +/// Derive the default 43-byte raw Orchard address from a 32-byte one-time +/// spending key (bridges `platform_wallet_orchard_address_from_spending_key`). +/// +/// Handle-less, RNG-free counterpart of +/// [`Java_..._generateOneTimeOrchardKey`]. Returns the 43-byte address; +/// throws an `SdkException` (invalid-parameter) if `spendingKey` is not a +/// valid Orchard spending key. +#[no_mangle] +pub extern "system" fn Java_org_dashfoundation_dashsdk_ffi_FundingNative_orchardAddressFromSpendingKey( + mut env: JNIEnv, + _class: JClass, + spending_key: JByteArray, +) -> jni::sys::jbyteArray { + guard(&mut env, ptr::null_mut(), |env| { + let Some(sk) = read_id32(env, &spending_key, "spendingKey") else { + return ptr::null_mut(); + }; + let mut addr = [0u8; 43]; + let result = unsafe { + platform_wallet_ffi::platform_wallet_orchard_address_from_spending_key( + sk.as_ptr(), + addr.as_mut_ptr(), + ) + }; + if take_pwffi_error(env, result) { + return ptr::null_mut(); + } + env.byte_array_from_slice(&addr) + .map(|a| a.into_raw()) + .unwrap_or(ptr::null_mut()) + }) +} + /// Shielded → shielded transfer (Type 16) — bridges /// `platform_wallet_manager_shielded_transfer`. /// From 6d266f01275d1949587f8c32edd63d6c0bbec70a Mon Sep 17 00:00:00 2001 From: bfoss765 <38437574+bfoss765@users.noreply.github.com> Date: Wed, 22 Jul 2026 18:55:57 -0400 Subject: [PATCH 02/14] feat(kotlin-sdk): add shielded-invite claim side (identity_create_from_one_time_key), reconciled to base identity API MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ports the L2-invitation CLAIM side from the b2 line (8008dc78b8): Verbatim grafts (byte-for-byte from b2, deps all present in base): - operations.rs: free fn identity_create_from_one_time_key (note-scan + Halo2 proof) and its supporting note-scan helper scan_notes_for_foreign_key (sync.rs), plus the one_time_key_tests module. - platform_wallet.rs: PlatformWalletManager::identity_create_from_one_time_key. - shielded_send.rs (FFI): platform_wallet_manager_shielded_identity_create_from_one_time_key (base's FFI-layer decode_identity_pubkeys/IdentityPubkeyFFI matches b2). Reconciled to base's API (NOT byte-for-byte): - funding.rs (JNI): decode_pubkeys_blob + hand-built IdentityPubkeyFFI literal (b2) -> decode_registration_pubkeys_blob + row.to_ffi() (base), plus base's tagged-payload return with ErrorShieldedBroadcastUnconfirmed handling. - Kotlin: IdentityKeyPreview.encodeForRegistration + withContext + raw return (b2) -> List via IdentityPubkeyCodec.encode + teardownGate.op + decodeShieldedCreatePayload (base), mirroring the tested inviter side. Pubkey-decode semantics preserved: identical key_id / pubkey bytes / order / count; role/read_only/contract-bounds source shifts from Rust-derived (b2) to caller-stamped blob (base) — base's authoritative pipeline-wide convention, already adopted by the tested inviter side. Co-Authored-By: Claude Fable 5 --- .../dashsdk/ffi/FundingNative.kt | 28 ++ .../dashsdk/wallet/PlatformWalletManager.kt | 59 +++ .../src/shielded_send.rs | 188 +++++++ .../src/wallet/platform_wallet.rs | 96 ++++ .../src/wallet/shielded/operations.rs | 468 ++++++++++++++++++ .../src/wallet/shielded/sync.rs | 64 ++- packages/rs-unified-sdk-jni/src/funding.rs | 146 ++++++ 7 files changed, 1048 insertions(+), 1 deletion(-) diff --git a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/ffi/FundingNative.kt b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/ffi/FundingNative.kt index 767219373e4..c6444f786a9 100644 --- a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/ffi/FundingNative.kt +++ b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/ffi/FundingNative.kt @@ -141,6 +141,34 @@ internal object FundingNative { signerAddressHandle: Long, ): ByteArray + /** + * Create an identity funded from a ONE-TIME Orchard key, Type 20 (bridges + * `platform_wallet_manager_shielded_identity_create_from_one_time_key`) — + * the L2-invitation *claim* side. Like [shieldedIdentityCreateFromPool], + * but the Orchard spend authority is the invitation's single-use 32-byte + * spending key [oneTimeSk] rather than the wallet's own bound pool. The + * wallet derives the key's viewing keys, transiently scans the network for + * the note(s) funded to it, and spends them. [changeAddressRaw43] is the + * claimer's OWN 43-byte default Orchard address that receives any + * over-funding change note (zero for a well-formed invitation). + * [fundingBirthHeight] is an advisory hint: a negative value means "no + * hint". [pubkeysBlob] / [denomination] / [fallbackAddress] / + * [identityIndex] / [signerAddressHandle] match the pool variant. Blocks + * for the ~30s Halo 2 proof; returns the new 32-byte identity id. + */ + external fun shieldedIdentityCreateFromOneTimeKey( + managerHandle: Long, + walletId: ByteArray, + oneTimeSk: ByteArray, + fundingBirthHeight: Int, + changeAddressRaw43: ByteArray, + identityIndex: Int, + pubkeysBlob: ByteArray, + denomination: Long, + fallbackAddress: ByteArray, + signerAddressHandle: Long, + ): ByteArray + /** * Generate a fresh one-time Orchard spending key + its default payment * address (bridges `platform_wallet_generate_one_time_orchard_key`) — the diff --git a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/wallet/PlatformWalletManager.kt b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/wallet/PlatformWalletManager.kt index 6076fe279e6..e1c4369a03f 100644 --- a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/wallet/PlatformWalletManager.kt +++ b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/wallet/PlatformWalletManager.kt @@ -1512,6 +1512,65 @@ class PlatformWalletManager( decodeShieldedCreatePayload(packed) } + /** + * Create an identity funded from a ONE-TIME Orchard key (Type 20) — the + * L2-invitation *claim* side. Like [shieldedIdentityCreateFromPool], but the + * Orchard spend authority is the invitation's single-use 32-byte spending + * key [oneTimeSk] rather than the wallet's own bound pool: the wallet + * derives that key's viewing keys, transiently scans the network for the + * note(s) funded to it, and spends a note of the fixed exit [denomination] + * to fund a new identity at [identityIndex]. [changeAddressRaw43] is the + * claimer's OWN 43-byte default Orchard address that receives any + * over-funding change note (zero for a well-formed invitation). + * [fundingBirthHeight] is an advisory scan hint; pass `null` when unknown. + * [keys] are the rich registration rows (built via + * `RegistrationKeys.buildRegistrationRows`), encoded to the same blob every + * registration path uses; each row's private half must already be + * persisted. [fallbackAddress] is the REQUIRED 21-byte PlatformAddress that + * receives the value (minus a penalty) if creation fails a stateful check. + * Signed by the Keystore identity signer ([signerHandle]). Blocks for the + * ~30s Halo 2 proof. + * + * @return the new 32-byte identity id. + */ + suspend fun shieldedIdentityCreateFromOneTimeKey( + walletId: ByteArray, + oneTimeSk: ByteArray, + changeAddressRaw43: ByteArray, + identityIndex: Int, + keys: List, + denomination: Long, + fallbackAddress: ByteArray, + fundingBirthHeight: Int? = null, + ): ByteArray = teardownGate.op { + require(oneTimeSk.size == 32) { "oneTimeSk must be 32 bytes, got ${oneTimeSk.size}" } + require(changeAddressRaw43.size == 43) { + "changeAddressRaw43 must be 43 bytes, got ${changeAddressRaw43.size}" + } + require(identityIndex >= 0) { "identityIndex must be non-negative, got $identityIndex" } + require(denomination > 0) { "denomination must be positive, got $denomination" } + require(fallbackAddress.size == 21) { + "fallbackAddress must be 21 bytes, got ${fallbackAddress.size}" + } + require(keys.isNotEmpty()) { "keys must not be empty" } + val packed = mapNativeErrors { + FundingNative.shieldedIdentityCreateFromOneTimeKey( + managerHandle, + walletId, + oneTimeSk, + // A negative birth-height signals "no hint" across JNI. + fundingBirthHeight ?: -1, + changeAddressRaw43, + identityIndex, + org.dashfoundation.dashsdk.identity.IdentityPubkeyCodec.encode(keys), + denomination, + fallbackAddress, + signerHandle, + ) + } + decodeShieldedCreatePayload(packed) + } + /** * Resume a stuck shielded fund-from-asset-lock from an already-tracked * lock — port of Swift's `shieldedResumeFundFromAssetLock`. diff --git a/packages/rs-platform-wallet-ffi/src/shielded_send.rs b/packages/rs-platform-wallet-ffi/src/shielded_send.rs index 810041a5a4b..9d26c084349 100644 --- a/packages/rs-platform-wallet-ffi/src/shielded_send.rs +++ b/packages/rs-platform-wallet-ffi/src/shielded_send.rs @@ -811,6 +811,194 @@ pub unsafe extern "C" fn platform_wallet_manager_shielded_identity_create_from_p } } +/// Sibling of [`platform_wallet_manager_shielded_identity_create_from_pool`], but +/// the Orchard spend authority is a foreign one-time spending key rather than the +/// wallet's own bound `OrchardKeySet`: +/// - `one_time_sk_bytes` — the invitation's single-use 32-byte Orchard spending +/// key. The wallet derives its fvk / ivk / ask, transiently scans the network +/// for the note(s) funded to it, and spends them. +/// - `change_address_raw43` — the claimer's OWN default Orchard address (43 raw +/// bytes: 11-byte diversifier + 32-byte pk_d) that receives any over-funding +/// change note. For a one-time invitation key the change is expected to be +/// zero, but over-funding is handled. +/// - `has_funding_birth_height` / `funding_birth_height` — an advisory birth-height +/// hint (`false` → `None`, following the wallet-create birth-height override +/// convention). The shielded tree has no height→note-index oracle, so the hint +/// cannot seed the scan start today; the scan is value-bounded. +/// +/// Everything else matches the pool sibling: `identity_pubkeys` / +/// `identity_pubkeys_count` (same [`IdentityPubkeyFFI`] rows), `denomination` (a +/// member of the versioned exit set), `send_to_address_on_creation_failure_bytes` +/// (REQUIRED 21-byte `PlatformAddress` fallback bound into the sighash), +/// `identity_index` (the local registration slot), and `signer_identity_handle` +/// (the identity PoP signer). Blocks for the ~30 s Halo 2 proof. +/// +/// On success the 32-byte new identity id is written to `out_identity_id`. As with +/// the pool sibling, `out_identity_id` is ALSO written on the +/// [`ErrorShieldedBroadcastUnconfirmed`] result code (the broadcast was accepted +/// but its execution result couldn't be confirmed — the identity may exist on +/// chain). On every other error code `out_identity_id` is left untouched. +/// +/// [`ErrorShieldedBroadcastUnconfirmed`]: crate::error::PlatformWalletFFIResultCode::ErrorShieldedBroadcastUnconfirmed +/// +/// # Safety +/// - `wallet_id_bytes` must point to 32 readable bytes. +/// - `one_time_sk_bytes` must point to exactly 32 readable bytes. +/// - `change_address_raw43` must point to exactly 43 readable bytes. +/// - `identity_pubkeys` must point to `identity_pubkeys_count` contiguous +/// [`IdentityPubkeyFFI`] rows that outlive this call. +/// - `send_to_address_on_creation_failure_bytes` must point to exactly 21 +/// readable bytes for the duration of this call. +/// - `signer_identity_handle` must be a valid, non-destroyed `*mut SignerHandle` +/// (a `VTableSigner` with the callback variant) that outlives this call. +/// - `out_identity_id` must point to 32 writable bytes. Written on `Success` AND +/// on `ErrorShieldedBroadcastUnconfirmed` only. +#[no_mangle] +#[allow(clippy::too_many_arguments)] +pub unsafe extern "C" fn platform_wallet_manager_shielded_identity_create_from_one_time_key( + handle: Handle, + wallet_id_bytes: *const u8, + one_time_sk_bytes: *const u8, + has_funding_birth_height: bool, + funding_birth_height: u32, + change_address_raw43: *const u8, + identity_index: u32, + identity_pubkeys: *const IdentityPubkeyFFI, + identity_pubkeys_count: usize, + denomination: u64, + send_to_address_on_creation_failure_bytes: *const u8, + signer_identity_handle: *mut SignerHandle, + out_identity_id: *mut [u8; 32], +) -> PlatformWalletFFIResult { + check_ptr!(wallet_id_bytes); + check_ptr!(one_time_sk_bytes); + check_ptr!(change_address_raw43); + check_ptr!(identity_pubkeys); + check_ptr!(send_to_address_on_creation_failure_bytes); + check_ptr!(signer_identity_handle); + check_ptr!(out_identity_id); + if identity_pubkeys_count == 0 { + return PlatformWalletFFIResult::err( + PlatformWalletFFIResultCode::ErrorInvalidParameter, + "`identity_pubkeys_count` must be >= 1", + ); + } + + // REQUIRED 21-byte fallback PlatformAddress (bound into the sighash). + let send_to_address_on_creation_failure = match parse_required_platform_address( + send_to_address_on_creation_failure_bytes, + "send_to_address_on_creation_failure_bytes", + ) { + Ok(addr) => addr, + Err(result) => return result, + }; + + // Copy the one-time spending key (32 bytes; the caller's safety contract + // guarantees the length — no companion length arg crosses the C ABI). + let mut one_time_sk = [0u8; 32]; + std::ptr::copy_nonoverlapping(one_time_sk_bytes, one_time_sk.as_mut_ptr(), 32); + + // Decode the claimer's own 43-byte default Orchard change address. + let mut change_raw = [0u8; 43]; + std::ptr::copy_nonoverlapping(change_address_raw43, change_raw.as_mut_ptr(), 43); + let change_address = match OrchardAddress::from_raw_bytes(&change_raw) { + Ok(a) => a, + Err(_) => { + return PlatformWalletFFIResult::err( + PlatformWalletFFIResultCode::ErrorInvalidParameter, + "change_address_raw43 is not a valid 43-byte Orchard address", + ); + } + }; + + let funding_birth_height = if has_funding_birth_height { + Some(funding_birth_height) + } else { + None + }; + + let mut wallet_id = [0u8; 32]; + std::ptr::copy_nonoverlapping(wallet_id_bytes, wallet_id.as_mut_ptr(), 32); + + let keys_map = match decode_identity_pubkeys(identity_pubkeys, identity_pubkeys_count) { + Ok(m) => m, + Err(result) => return result, + }; + let public_keys: Vec<( + dpp::identity::IdentityPublicKey, + IdentityPublicKeyInCreation, + )> = keys_map + .into_values() + .map(|k| { + let in_creation: IdentityPublicKeyInCreation = (&k).into(); + (k, in_creation) + }) + .collect(); + + let (wallet, coordinator) = match resolve_wallet_and_coordinator(handle, &wallet_id) { + Ok(p) => p, + Err(result) => return result, + }; + + let signer_identity_addr = signer_identity_handle as usize; + + // Run the proof on a worker thread (8 MB stack) — Halo 2 synthesis recurses + // past the iOS dispatch-thread stack. + 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 identity_signer: &VTableSigner = &*(signer_identity_addr as *const VTableSigner); + let prover = CachedOrchardProver::new(); + let r = wallet + .identity_create_from_one_time_key( + &coordinator, + one_time_sk, + funding_birth_height, + change_address, + identity_index, + public_keys, + denomination, + send_to_address_on_creation_failure, + identity_signer, + &prover, + ) + .await; + poke_sync_on_unconfirmed(&r, handle); + r + }); + + match result { + Ok(identity_id) => { + *out_identity_id = identity_id.to_buffer(); + PlatformWalletFFIResult::ok() + } + Err(PlatformWalletError::ShieldedBroadcastUnconfirmed { + identity_id, + ref reason, + }) => { + *out_identity_id = identity_id.to_buffer(); + PlatformWalletFFIResult::err( + PlatformWalletFFIResultCode::ErrorShieldedBroadcastUnconfirmed, + format!( + "shielded identity-create-from-one-time-key broadcast unconfirmed (identity {identity_id} may exist on chain): {reason}" + ), + ) + } + Err(e @ PlatformWalletError::ShieldedNoRecordedAnchor(_)) => PlatformWalletFFIResult::err( + PlatformWalletFFIResultCode::ErrorShieldedNoRecordedAnchor, + format!("Wallet is still syncing to a confirmed state — try again shortly. ({e})"), + ), + Err(e @ PlatformWalletError::ShieldedBroadcastFailed(_)) => PlatformWalletFFIResult::err( + PlatformWalletFFIResultCode::ErrorShieldedBroadcastFailed, + format!("shielded identity-create-from-one-time-key failed: {e}"), + ), + Err(e) => PlatformWalletFFIResult::err( + PlatformWalletFFIResultCode::ErrorWalletOperation, + format!("shielded identity-create-from-one-time-key failed: {e}"), + ), + } +} + /// Shield: spend credits from a Platform Payment account into /// the bound shielded sub-wallet's pool. /// diff --git a/packages/rs-platform-wallet/src/wallet/platform_wallet.rs b/packages/rs-platform-wallet/src/wallet/platform_wallet.rs index cfb310ea597..410d91ff355 100644 --- a/packages/rs-platform-wallet/src/wallet/platform_wallet.rs +++ b/packages/rs-platform-wallet/src/wallet/platform_wallet.rs @@ -1320,6 +1320,102 @@ impl PlatformWallet { Ok(identity_id) } + /// Create a brand-new Platform identity funded from a ONE-TIME Orchard + /// spending key — the L2-invitation *claim* side. + /// + /// Unlike [`Self::shielded_identity_create_from_pool`], the Orchard spend + /// authority is a foreign `one_time_sk` (the invitation's single-use + /// spending key), NOT this wallet's own `OrchardKeySet`. The operation + /// derives the fvk / ivk / ask from that key, transiently scans the network + /// for the note(s) it funds, witnesses them against the shared commitment + /// tree, and drives the same key-agnostic Type-20 builder. Any spent value + /// above `denomination` re-enters the pool as a change note to + /// `change_address` — the claimer's OWN default Orchard address (43 raw + /// bytes) — which the claimer's normal sync later discovers. + /// + /// `funding_birth_height` is an advisory hint (the shielded tree has no + /// height→note-index oracle, so it cannot seed the scan start today). + /// + /// `identity_index` is the DIP-9 registration slot the new identity occupies + /// in the local `IdentityManager`; on a successful broadcast the + /// proof-verified identity is registered there (mirroring + /// [`Self::shielded_identity_create_from_pool`]) so the host persister emits + /// the identity row. A failed registration after a successful broadcast is + /// logged and swallowed — the identity already exists on chain and the next + /// sync heals the local row. Returns the new identity's id. + #[cfg(feature = "shielded")] + #[allow(clippy::too_many_arguments)] + pub async fn identity_create_from_one_time_key( + &self, + coordinator: &Arc, + one_time_sk: [u8; 32], + funding_birth_height: Option, + change_address: dpp::address_funds::OrchardAddress, + identity_index: u32, + public_keys: Vec<( + dpp::identity::IdentityPublicKey, + dpp::state_transition::public_key_in_creation::IdentityPublicKeyInCreation, + )>, + denomination: u64, + send_to_address_on_creation_failure: dpp::address_funds::PlatformAddress, + identity_signer: &IS, + prover: P, + ) -> Result + where + P: dpp::shielded::builder::OrchardProver, + IS: dpp::identity::signer::Signer + Send + Sync, + { + let (identity_id, identity) = + super::shielded::operations::identity_create_from_one_time_key( + &self.sdk, + coordinator.store(), + one_time_sk, + funding_birth_height, + &change_address, + public_keys, + denomination, + send_to_address_on_creation_failure, + identity_signer, + &prover, + ) + .await?; + + // Register the proof-verified identity in the local manager at its HD + // slot — the SAME tail as `shielded_identity_create_from_pool`. The + // broadcast already succeeded; a registration failure here is logged and + // swallowed (the identity exists on chain; the next sync heals the row). + { + let mut wm = self.wallet_manager.write().await; + match wm.get_wallet_info_mut(&self.wallet_id) { + Some(info) => { + if let Err(e) = info.identity_manager.add_identity( + identity, + identity_index, + self.wallet_id, + &self.persister, + ) { + tracing::warn!( + identity_index, + error = %e, + "IdentityCreateFromOneTimeKey broadcast succeeded but registering the \ + identity in the local manager failed; the on-chain identity exists and \ + the next sync will heal the local row" + ); + } + } + None => { + tracing::warn!( + identity_index, + "IdentityCreateFromOneTimeKey broadcast succeeded but the wallet info was \ + not found in the manager; skipping local registration (heals on next sync)" + ); + } + } + } + + Ok(identity_id) + } + /// Shield credits from a Platform Payment account into the /// wallet's shielded pool, with the resulting note assigned /// to `shielded_account`'s default Orchard address. diff --git a/packages/rs-platform-wallet/src/wallet/shielded/operations.rs b/packages/rs-platform-wallet/src/wallet/shielded/operations.rs index a79ed4e2d16..b73bb0cc106 100644 --- a/packages/rs-platform-wallet/src/wallet/shielded/operations.rs +++ b/packages/rs-platform-wallet/src/wallet/shielded/operations.rs @@ -1535,6 +1535,252 @@ where } } +// ------------------------------------------------------------------------- +// IdentityCreateFromShieldedPool from a ONE-TIME Orchard key (Type 20, L2 +// invitations — the claim side) +// ------------------------------------------------------------------------- + +/// Create a brand-new Platform identity funded from a ONE-TIME Orchard spending +/// key (the L2-invitation *claim* side). +/// +/// Unlike [`identity_create_from_shielded_pool`], the spend authority is NOT the +/// wallet's own [`OrchardKeySet`]; it is a foreign `one_time_sk` — the single-use +/// Orchard spending key an invitation was funded to. The op: +/// 1. derives the full-viewing / incoming-viewing / spend-authorizing keys from +/// `one_time_sk`, +/// 2. transiently scans the network for the note(s) that key owns (they are not +/// tracked in any subwallet store — see [`super::sync::scan_notes_for_foreign_key`]), +/// 3. selects notes covering exactly `denomination` (the exact-equality model — +/// the fee is metered FROM the denomination) and gates on +/// `denomination > predicted_fee`, +/// 4. witnesses the selected notes against a Platform-recorded anchor from the +/// shared (fully-marked) commitment tree — the SAME anchor probe the +/// pool-funded op uses (so a wallet that hasn't synced past the funding +/// position gets the retryable [`PlatformWalletError::ShieldedMerkleWitnessUnavailable`]), +/// 5. feeds the key-agnostic Type-20 builder with the one-time key's fvk/ask, and +/// 6. broadcasts + waits with the same fetch-by-derived-id fallback. +/// +/// The whole denomination leaves the pool; any spent value above it re-enters as +/// a single change note to `change_address` (the claimer's OWN default Orchard +/// address — over-funding is expected to be zero for a one-time invitation key, +/// but is handled). There is NO wallet-side note reservation to take or release: +/// the spent notes belong to the foreign key, not to any subwallet, so an +/// unconfirmed broadcast simply leaves the on-chain nullifiers as the +/// authoritative no-reuse guarantee. +/// +/// `funding_birth_height` is an advisory hint only (see +/// [`super::sync::scan_notes_for_foreign_key`] — the tree has no height→position +/// oracle, so it cannot seed the scan start today). +/// +/// Returns the new identity's id and the proof-verified [`Identity`]; the caller +/// registers that identity in its local `IdentityManager`. +#[allow(clippy::too_many_arguments)] +pub async fn identity_create_from_one_time_key( + sdk: &Arc, + store: &Arc>, + one_time_sk: [u8; 32], + funding_birth_height: Option, + change_address: &OrchardAddress, + public_keys: Vec<(IdentityPublicKey, IdentityPublicKeyInCreation)>, + denomination: u64, + send_to_address_on_creation_failure: PlatformAddress, + identity_signer: &IS, + prover: &P, +) -> Result<(Identifier, Identity), PlatformWalletError> +where + S: ShieldedStore, + P: OrchardProver, + IS: Signer, +{ + use grovedb_commitment_tree::{FullViewingKey, Scope, SpendAuthorizingKey, SpendingKey}; + + if public_keys.is_empty() { + return Err(PlatformWalletError::ShieldedBuildError( + "identity-create-from-one-time-key requires at least one public key".to_string(), + )); + } + + // Derive the Orchard key material from the one-time spending key. `from_bytes` + // returns a `CtOption`; an invalid scalar means the caller handed us a + // non-key, which is a hard input error. + let sk: SpendingKey = Option::from(SpendingKey::from_bytes(one_time_sk)).ok_or_else(|| { + PlatformWalletError::ShieldedKeyDerivation( + "one-time spending key is not a valid Orchard SpendingKey".to_string(), + ) + })?; + let fvk = FullViewingKey::from(&sk); + let ask = SpendAuthorizingKey::from(&sk); + let ivk = fvk.to_ivk(Scope::External); + + // Advisory only: the shielded tree has no height→note-index oracle (a chunk's + // block_height is the proof-tip height, not per-note inclusion height), so the + // transient scan always starts at position 0 and bounds itself by value + // coverage. Logged so the hint is observable and not silently dropped. + if let Some(h) = funding_birth_height { + debug!( + funding_birth_height = h, + "identity_create_from_one_time_key: birth-height hint (advisory; scan is value-bounded)" + ); + } + + let num_keys = public_keys.len(); + + // Transient scan: re-derive the one-time key's note(s) from the network. + let discovered = super::sync::scan_notes_for_foreign_key(sdk, &fvk, &ivk, denomination).await?; + if discovered.is_empty() { + // No note decrypts under this key — nothing was funded to it (or the + // wallet hasn't synced far enough to see it yet). + return Err(PlatformWalletError::ShieldedNoUnspentNotes); + } + + // Exact-equality selection over the transiently-scanned set: cover exactly + // `denomination`, gate on `denomination > predicted_fee`. Surfaces + // `ShieldedInsufficientBalance { available, required }` when the key's notes + // don't cover the denomination, mirroring the pool-funded neighbor. + let (selected_refs, total_input, predicted_fee) = + select_notes_for_denomination(&discovered, denomination, 2, num_keys, sdk.version())?; + let selected_notes: Vec = selected_refs.into_iter().cloned().collect(); + + info!( + denomination, + predicted_fee, + inputs = selected_notes.len(), + total_input, + keys = num_keys, + "IdentityCreateFromOneTimeKey" + ); + + // Snapshot the submitted keys for the defensive empty-`public_keys` fill (the + // binding signature committed exactly these; same pattern as the pool op). + let submitted_public_keys: BTreeMap = public_keys + .iter() + .map(|(key, _)| (key.id(), key.clone())) + .collect(); + + // Witness the selected notes against a Platform-recorded anchor from the + // shared, fully-marked commitment tree (identical probe to the pool op). + let (spends, anchor) = extract_spends_and_anchor(sdk, store, &selected_notes).await?; + + let build = build_identity_create_from_shielded_pool_transition( + public_keys, + denomination, + send_to_address_on_creation_failure, + spends, + change_address, + &fvk, + &ask, + anchor, + prover, + identity_signer, + [0u8; 36], + sdk.version(), + ) + .await + .map_err(|e| PlatformWalletError::ShieldedBuildError(e.to_string()))?; + + let identity_id = build.identity_id; + + // Re-assemble the transition from the PoP-signed keys + bundle params + // (preserving the per-key signatures) and broadcast. The broadcast/wait + // classification mirrors `identity_create_from_shielded_pool` verbatim, minus + // the note-reservation bookkeeping (there is no subwallet reservation to + // release — the spent notes belong to the foreign one-time key). + let st = sdk + .identity_create_from_shielded_pool_transition( + build.public_keys, + denomination, + send_to_address_on_creation_failure, + build.bundle, + ) + .map_err(|e| PlatformWalletError::ShieldedBuildError(e.to_string()))?; + + match st.broadcast(sdk, None).await { + Ok(()) => {} + Err(e) if broadcast_definitely_failed(&e) => { + return Err(PlatformWalletError::ShieldedBroadcastFailed(e.to_string())); + } + Err(e) => { + warn!( + derived_id = %identity_id, + error = %e, + "IdentityCreateFromOneTimeKey: broadcast returned no verdict; the transition may \ + have been admitted — falling through to the result wait" + ); + } + } + + let proof_result = match st + .wait_for_response::(sdk, None) + .await + { + Ok(result) => result, + Err(dash_sdk::Error::StateTransitionBroadcastError(e)) if e.cause.is_some() => { + return Err(PlatformWalletError::ShieldedBroadcastFailed(e.to_string())); + } + Err(wait_err) => { + warn!( + derived_id = %identity_id, + error = %wait_err, + "IdentityCreateFromOneTimeKey: broadcast accepted but result confirmation failed; \ + falling back to fetching the identity by its derived id" + ); + match fetch_identity_with_retries(sdk, identity_id).await { + Some(mut identity) => { + info!( + derived_id = %identity_id, + "IdentityCreateFromOneTimeKey: result confirmation failed but the identity \ + was found on chain by its derived id; treating as success" + ); + if identity.public_keys().is_empty() { + identity.set_public_keys(submitted_public_keys.clone()); + } + return Ok((identity.id(), identity)); + } + None => { + return Err(PlatformWalletError::ShieldedBroadcastUnconfirmed { + identity_id, + reason: wait_err.to_string(), + }); + } + } + } + }; + + let identity = match proof_result { + StateTransitionProofResult::VerifiedIdentityWithShieldedNullifiers(mut identity, _n) => { + if identity.id() != identity_id { + warn!( + derived_id = %identity_id, + verified_id = %identity.id(), + "IdentityCreateFromOneTimeKey: derived id differs from proof-verified id; using \ + the proof-verified id" + ); + } + if identity.public_keys().is_empty() { + identity.set_public_keys(submitted_public_keys); + } + identity + } + other => { + warn!( + derived_id = %identity_id, + result = %other, + "IdentityCreateFromOneTimeKey: unexpected proof-result variant; synthesizing the \ + identity from the derived id + submitted keys so the local row still lands" + ); + Identity::new_with_id_and_keys(identity_id, submitted_public_keys, sdk.version()) + .map_err(|e| PlatformWalletError::ShieldedBuildError(e.to_string()))? + } + }; + + info!( + denomination, + identity_id = %identity.id(), + "IdentityCreateFromOneTimeKey broadcast succeeded" + ); + Ok((identity.id(), identity)) +} + /// Whether a failed identity-create should release the notes reserved for it. /// /// `false` ONLY for [`PlatformWalletError::ShieldedBroadcastUnconfirmed`]: the broadcast was @@ -3369,3 +3615,225 @@ mod select_recorded_spends_tests { } } } + +/// Unit tests for the ONE-TIME-key claim path +/// ([`identity_create_from_one_time_key`] / [`super::sync::scan_notes_for_foreign_key`]). +/// +/// The full op needs a live SDK note stream, so these cover the network-free +/// pieces the crate ADDS: deriving a note owned by a foreign one-time spending +/// key (the scan's per-note conversion — value / cmx / nullifier / serialization), +/// the exact-equality selection over the transiently-scanned set (exact / over / +/// under / no-note), and witnessing that foreign note against a Platform-recorded +/// anchor in the shared marked tree. The key-agnostic Type-20 BUILD with a +/// foreign key is proven by rs-dpp's own green builder tests +/// (`SpendingKey::from_bytes([..]) → fvk/ask → build … succeeds`). +#[cfg(test)] +mod one_time_key_tests { + use super::*; + use crate::wallet::shielded::file_store::FileBackedShieldedStore; + use dpp::version::PlatformVersion; + use grovedb_commitment_tree::{ + ExtractedNoteCommitment, FullViewingKey, Note, NoteValue, RandomSeed, Rho, Scope, + SpendingKey, + }; + + /// Smallest member of the versioned exit-denomination set (0.1 DASH). + const DENOMINATION: u64 = 10_000_000_000; + + /// A fixed, valid one-time Orchard spending key for the tests. + const ONE_TIME_SK: [u8; 32] = [0x24; 32]; + + fn temp_tree_path(tag: &str) -> std::path::PathBuf { + let nanos = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos(); + std::env::temp_dir().join(format!("one_time_key_{tag}_{nanos}.sqlite")) + } + + fn filler_cmx(b: u8) -> [u8; 32] { + let mut c = [0u8; 32]; + c[0] = b; + c + } + + /// The full-viewing key of the one-time spending key. + fn one_time_fvk() -> FullViewingKey { + let sk: SpendingKey = Option::from(SpendingKey::from_bytes(ONE_TIME_SK)) + .expect("fixed one-time SK is a valid Orchard SpendingKey"); + FullViewingKey::from(&sk) + } + + /// Build one real Orchard note OWNED BY the one-time key, shaped exactly as + /// [`super::sync::scan_notes_for_foreign_key`] would produce it: `cmx` is the + /// note's real commitment, `nullifier` is derived under the one-time key's + /// fvk, and `note_data` is the canonical 115-byte serialization. + fn one_time_note(value: u64, position: u64) -> ShieldedNote { + let fvk = one_time_fvk(); + let recipient = fvk.address_at(0u32, Scope::External); + + // rho / rseed must be canonical Pallas base-field elements — scan + // deterministically (mirrors the existing note builders in this file). + let rho = (1u16..=u16::MAX) + .find_map(|n| { + let mut b = [0u8; 32]; + b[0..2].copy_from_slice(&n.to_le_bytes()); + Rho::from_bytes(&b).into_option() + }) + .expect("a canonical rho exists"); + let rseed = (1u16..=u16::MAX) + .find_map(|m| { + let mut b = [0u8; 32]; + b[2..4].copy_from_slice(&m.to_le_bytes()); + RandomSeed::from_bytes(b, &rho).into_option() + }) + .expect("a canonical rseed exists"); + + let note = Note::from_parts(recipient, NoteValue::from_raw(value), rho, rseed) + .into_option() + .expect("valid note parts"); + let cmx = ExtractedNoteCommitment::from(note.commitment()).to_bytes(); + let nullifier = note.nullifier(&fvk).to_bytes(); + + let mut note_data = Vec::with_capacity(115); + note_data.extend_from_slice(¬e.recipient().to_raw_address_bytes()); + note_data.extend_from_slice(¬e.value().inner().to_le_bytes()); + note_data.extend_from_slice(¬e.rho().to_bytes()); + note_data.extend_from_slice(note.rseed().as_bytes()); + + ShieldedNote { + position, + cmx, + nullifier, + block_height: 1, + is_spent: false, + value, + note_data, + } + } + + /// The scan's per-note conversion is correct: a note owned by the one-time + /// key round-trips through the wallet's 115-byte serialization, and its + /// nullifier matches the one derived under that key's fvk (what the scan + /// stamps). This is the piece [`super::sync::scan_notes_for_foreign_key`] + /// runs on every discovered note. + #[test] + fn foreign_key_note_roundtrips_and_nullifier_matches() { + let note = one_time_note(DENOMINATION, 0); + + // `note_data` deserializes back to an equal note. + let decoded = deserialize_note(¬e.note_data).expect("serialized note is valid"); + assert_eq!( + decoded.value().inner(), + DENOMINATION, + "value survives round-trip" + ); + + // The stamped nullifier is exactly the one the one-time key's fvk derives. + let fvk = one_time_fvk(); + assert_eq!( + note.nullifier, + decoded.nullifier(&fvk).to_bytes(), + "stamped nullifier must match the fvk-derived nullifier" + ); + + // The stored cmx is the note's real extracted commitment. + assert_eq!( + note.cmx, + ExtractedNoteCommitment::from(decoded.commitment()).to_bytes(), + "stored cmx must be the note's real commitment" + ); + } + + /// Exact-equality selection over the transiently-scanned set: exact funding + /// (zero change), over-funding (change = excess routed to change_address), + /// under-funding (typed `ShieldedInsufficientBalance`), and no-note (empty → + /// `ShieldedNoUnspentNotes`, the op's fail-fast on an unfunded key). + #[test] + fn select_for_claim_exact_over_under_and_no_note() { + let version = PlatformVersion::latest(); + + // Exact: one note equal to the denomination → zero change. + let exact = vec![one_time_note(DENOMINATION, 0)]; + let (sel, total, fee) = + select_notes_for_denomination(&exact, DENOMINATION, 2, 1, version).expect("exact"); + assert_eq!(sel.len(), 1); + assert_eq!(total, DENOMINATION); + assert_eq!(total - DENOMINATION, 0, "exact funding leaves zero change"); + assert!(fee < DENOMINATION, "fee must leave a positive balance"); + + // Over-funded: the excess above the denomination becomes the change note. + let excess = 7_000_000_000u64; + let over = vec![one_time_note(DENOMINATION + excess, 0)]; + let (sel, total, _) = + select_notes_for_denomination(&over, DENOMINATION, 2, 1, version).expect("over"); + assert_eq!(sel.len(), 1); + assert_eq!( + total - DENOMINATION, + excess, + "over-funding routes the excess to change_address" + ); + + // Under-funded: a single note below the denomination. + let under = vec![one_time_note(DENOMINATION - 1, 0)]; + match select_notes_for_denomination(&under, DENOMINATION, 2, 1, version) { + Err(PlatformWalletError::ShieldedInsufficientBalance { + available, + required, + }) => { + assert_eq!(available, DENOMINATION - 1); + assert_eq!(required, DENOMINATION); + } + other => panic!("expected ShieldedInsufficientBalance, got {other:?}"), + } + + // No note found for the key: empty set → ShieldedNoUnspentNotes (the same + // error the op raises on `discovered.is_empty()`). + match select_notes_for_denomination(&[], DENOMINATION, 2, 1, version) { + Err(PlatformWalletError::ShieldedNoUnspentNotes) => {} + other => panic!("expected ShieldedNoUnspentNotes, got {other:?}"), + } + } + + /// The witness half: a note owned by the one-time key, appended to the shared + /// fully-marked tree, is witnessable and produces a `SpendableNote` against a + /// Platform-recorded anchor — the same probe the op runs before the build. + #[test] + fn foreign_key_note_witnesses_against_recorded_anchor() { + let path = temp_tree_path("witness"); + let mut store = FileBackedShieldedStore::open_path(&path, 100).unwrap(); + + let note = one_time_note(DENOMINATION, 0); + + // One block, checkpointed on its boundary: depth-0 root is recorded. + store.append_commitment(¬e.cmx, true).unwrap(); + store.append_commitment(&filler_cmx(0xA1), true).unwrap(); + store.append_commitment(&filler_cmx(0xA2), true).unwrap(); + store.checkpoint_tree(3).unwrap(); + let root_depth0 = store.tree_anchor().unwrap(); + + let recorded: HashSet<[u8; 32]> = [root_depth0].into_iter().collect(); + + let (spends, anchor) = + select_recorded_spends(&store, std::slice::from_ref(¬e), &recorded) + .expect("the one-time key's note witnesses against the recorded anchor"); + + let _ = std::fs::remove_file(&path); + + assert_eq!( + spends.len(), + 1, + "the one-time key's single note is spendable" + ); + assert_eq!( + spends[0].note.value().inner(), + DENOMINATION, + "the witnessed SpendableNote carries the funded value" + ); + assert_eq!( + anchor.to_bytes(), + root_depth0, + "the spend is built against the Platform-recorded anchor" + ); + } +} diff --git a/packages/rs-platform-wallet/src/wallet/shielded/sync.rs b/packages/rs-platform-wallet/src/wallet/shielded/sync.rs index 00f5b7db1d9..47a05ed3bd2 100644 --- a/packages/rs-platform-wallet/src/wallet/shielded/sync.rs +++ b/packages/rs-platform-wallet/src/wallet/shielded/sync.rs @@ -38,7 +38,7 @@ use tokio::sync::RwLock; use tracing::{debug, info}; use super::keys::AccountViewingKeys; -use super::store::{ShieldedStore, SubwalletId}; +use super::store::{ShieldedNote, ShieldedStore, SubwalletId}; use crate::changeset::ShieldedChangeSet; use crate::error::PlatformWalletError; @@ -799,6 +799,68 @@ pub(crate) async fn balances_across( Ok(out) } +/// Transiently scan the shielded-note set for a FOREIGN Orchard key (the +/// L2-invitation *claim* path). +/// +/// Streams the on-chain encrypted notes with `ivk` as the driver key and +/// collects every note that decrypts under it into a store [`ShieldedNote`] +/// (position, cmx, per-`fvk` nullifier, value, and the 115-byte serialized +/// note). Unlike the regular sync path this touches NO store: the notes belong +/// to a one-time invitation spending key that is not tracked in any subwallet, +/// so they are re-derived from the network on demand and never persisted here. +/// +/// The scan stops early as soon as the accumulated value reaches +/// `stop_at_value` — a one-time invitation key holds exactly its funding, so +/// there is no reason to keep streaming past the note(s) that fund it. If the +/// key's value never reaches `stop_at_value`, the whole tree is scanned and +/// whatever was found is returned; the caller's note selection then surfaces +/// the typed insufficient-value error. +/// +/// Note: shielded notes are indexed by tree POSITION and this tree exposes no +/// height→position oracle (a chunk's `block_height` is the proof-tip height, not +/// a per-note inclusion height — see [`ShieldedChunkBatch`]), so the scan always +/// starts at position 0. A caller's birth-height hint therefore cannot seed the +/// start today; the value-coverage early-stop above is the effective bound. +/// +/// [`ShieldedChunkBatch`]: dash_sdk::platform::shielded::notes_sync::types::ShieldedChunkBatch +pub(crate) async fn scan_notes_for_foreign_key( + sdk: &Arc, + fvk: &grovedb_commitment_tree::FullViewingKey, + ivk: &grovedb_commitment_tree::IncomingViewingKey, + stop_at_value: u64, +) -> Result, PlatformWalletError> { + use grovedb_commitment_tree::PreparedIncomingViewingKey; + + let prepared = PreparedIncomingViewingKey::new(ivk); + let stream = sync_shielded_notes_stream(sdk, &prepared, 0, None); + futures::pin_mut!(stream); + + let mut found: Vec = Vec::new(); + let mut total: u64 = 0; + while let Some(batch) = stream.next().await { + let batch = batch.map_err(|e| PlatformWalletError::ShieldedSyncFailed(e.to_string()))?; + for dn in batch.decrypted { + let value = dn.note.value().inner(); + let nullifier = dn.note.nullifier(fvk).to_bytes(); + found.push(ShieldedNote { + position: dn.position, + cmx: dn.cmx, + nullifier, + block_height: batch.block_height, + is_spent: false, + value, + note_data: serialize_note(&dn.note), + }); + total = total.saturating_add(value); + } + // A one-time key holds exactly its funding — stop once it's covered. + if total >= stop_at_value { + break; + } + } + Ok(found) +} + /// One decrypted note discovered during a sync pass. #[derive(Clone)] struct DiscoveredNote { diff --git a/packages/rs-unified-sdk-jni/src/funding.rs b/packages/rs-unified-sdk-jni/src/funding.rs index 432cf5ee937..77341f54089 100644 --- a/packages/rs-unified-sdk-jni/src/funding.rs +++ b/packages/rs-unified-sdk-jni/src/funding.rs @@ -783,6 +783,152 @@ pub extern "system" fn Java_org_dashfoundation_dashsdk_ffi_FundingNative_shielde }) } +/// Create an identity funded from a ONE-TIME Orchard key, Type 20 (bridges +/// `platform_wallet_manager_shielded_identity_create_from_one_time_key`) — the +/// L2-invitation *claim* side. +/// +/// Sibling of [`Java_..._shieldedIdentityCreateFromPool`], but the Orchard spend +/// authority is a foreign `one_time_sk` (32 bytes) rather than the wallet's own +/// bound pool. `change_address_raw43` is the claimer's OWN 43-byte default Orchard +/// address (receives any over-funding change note). `funding_birth_height` is an +/// advisory hint: a negative value means "no hint" (`None`); a non-negative value +/// is passed through as `Some(u32)`. Everything else — `pubkeys_blob` (the SAME +/// shared rich registration key-row blob ID-08 uses, built by `IdentityPubkeyCodec` +/// and decoded by `decode_registration_pubkeys_blob`), `denomination`, +/// `fallback_address`, `identity_index`, `signer_handle` — matches the pool +/// sibling. Blocks for the ~30 s Halo 2 proof; returns the tagged create payload +/// (`[0|1] || identity_id || diagnostic_utf8`, written on success AND on the +/// unconfirmed-broadcast fallback) exactly like the pool sibling. +#[no_mangle] +#[allow(clippy::too_many_arguments)] +pub extern "system" fn Java_org_dashfoundation_dashsdk_ffi_FundingNative_shieldedIdentityCreateFromOneTimeKey( + mut env: JNIEnv, + _class: JClass, + manager_handle: jlong, + wallet_id: JByteArray, + one_time_sk: JByteArray, + funding_birth_height: jint, + change_address_raw43: JByteArray, + identity_index: jint, + pubkeys_blob: JByteArray, + denomination: jlong, + fallback_address: JByteArray, + signer_handle: jlong, +) -> jni::sys::jbyteArray { + guard(&mut env, ptr::null_mut(), |env| { + if identity_index < 0 { + throw_sdk_exception(env, 1, "identityIndex must be non-negative"); + return ptr::null_mut(); + } + if denomination <= 0 { + throw_sdk_exception(env, 1, "denomination must be positive"); + return ptr::null_mut(); + } + if signer_handle == 0 { + throw_sdk_exception(env, 1, "signerHandle must be non-null"); + return ptr::null_mut(); + } + let Some(wid) = read_id32(env, &wallet_id, "walletId") else { + return ptr::null_mut(); + }; + let Some(sk) = read_id32(env, &one_time_sk, "oneTimeSk") else { + return ptr::null_mut(); + }; + let Some(change_raw) = read_recipient43(env, &change_address_raw43) else { + return ptr::null_mut(); + }; + + let Some(decoded) = decode_registration_pubkeys_blob(env, &pubkeys_blob) else { + return ptr::null_mut(); + }; + + // The 21-byte fallback PlatformAddress (1 variant tag + 20 hash), + // REQUIRED for Type-20 — validated exactly here. + let fallback = match read_opt_bytes(env, &fallback_address) { + Ok(Some(v)) => v, + Ok(None) => { + throw_sdk_exception(env, 1, "fallbackAddress must not be null"); + return ptr::null_mut(); + } + Err(()) => return ptr::null_mut(), + }; + if fallback.len() != 21 { + throw_sdk_exception( + env, + 1, + &format!("fallbackAddress must be 21 bytes, got {}", fallback.len()), + ); + return ptr::null_mut(); + } + + // A negative birth-height means "no hint" (`None`); non-negative is a + // `Some(u32)` advisory value. + let (has_birth, birth_val): (bool, u32) = if funding_birth_height < 0 { + (false, 0) + } else { + (true, funding_birth_height as u32) + }; + + // Same rich rows as ID-01 / ID-08 — the caller stamps each key's DPP + // role and any contract bounds; this path just marshals them. + let ffi_rows: Vec = decoded.iter().map(|row| row.to_ffi()).collect(); + + let mut out_id = [0u8; 32]; + let result = unsafe { + platform_wallet_ffi::platform_wallet_manager_shielded_identity_create_from_one_time_key( + manager_handle as Handle, + wid.as_ptr(), + sk.as_ptr(), + has_birth, + birth_val, + change_raw.as_ptr(), + identity_index as u32, + ffi_rows.as_ptr(), + ffi_rows.len(), + denomination as u64, + fallback.as_ptr(), + signer_handle as *mut SignerHandle, + &mut out_id as *mut [u8; 32], + ) + }; + // `decoded` / `ffi_rows` / `fallback` / `sk` / `change_raw` own the + // pointed-to buffers through the blocking FFI call above. + // + // ErrorShieldedBroadcastUnconfirmed (17) is NOT routed through + // take_pwffi_error: the C ABI writes `out_id` on that outcome too — + // the identity may already be live on-chain, so the host must + // retain the id and hold its derivation slot instead of retrying + // into a duplicate. Return a tagged variable-length payload + // (`[0|1] || identity_id || diagnostic_utf8`) so Kotlin can surface + // a typed unconfirmed result without losing the id or native error. + let unconfirmed = result.code + == platform_wallet_ffi::error::PlatformWalletFFIResultCode::ErrorShieldedBroadcastUnconfirmed; + let mut diagnostic = Vec::new(); + if unconfirmed { + // Preserve the native diagnostic (the underlying DAPI / + // result-proof confirmation failure) before freeing — the + // registration controller surfaces it, and Swift keeps both + // fields. + let mut result = result; + if !result.message.is_null() { + diagnostic = unsafe { std::ffi::CStr::from_ptr(result.message) } + .to_bytes() + .to_vec(); + } + unsafe { platform_wallet_ffi::error::platform_wallet_ffi_result_free(&mut result) }; + } else if take_pwffi_error(env, result) { + return ptr::null_mut(); + } + let mut packed = Vec::with_capacity(33 + diagnostic.len()); + packed.push(u8::from(unconfirmed)); + packed.extend_from_slice(&out_id); + packed.extend_from_slice(&diagnostic); + env.byte_array_from_slice(&packed) + .map(|a| a.into_raw()) + .unwrap_or(ptr::null_mut()) + }) +} + /// Generate a fresh one-time Orchard spending key + its default payment /// address (bridges `platform_wallet_generate_one_time_orchard_key`) — the /// *inviter* side of an L2 shielded invitation. From 13289359b1dfbf64766d531e58bd226cbae11083 Mon Sep 17 00:00:00 2001 From: bfoss765 <38437574+bfoss765@users.noreply.github.com> Date: Fri, 24 Jul 2026 11:48:13 -0400 Subject: [PATCH 03/14] fix(shielded-invites): FFI RNG panic-safety + zeroize one-time spend key MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses reviewer thepastaclaw's blocking findings on PR #4204. Two of the four blockers are fixed here; the other two are structural and reported back for a decision rather than guessed (crypto/money path). Blocker #4 (FFI RNG abort) — shielded_send.rs / keys.rs: `generate_one_time_orchard_key` used `OsRng::fill_bytes`, which panics on an OS entropy-source failure. It is called from a `#[no_mangle] extern "C"` export, so that panic aborts the process across the C ABI before any JNI panic guard can convert it. Switch to `RngCore::try_fill_bytes`, return a typed `PlatformWalletError::ShieldedKeyDerivation`, and have the FFI export map it to `ErrorWalletOperation` instead of aborting. Test call sites and callers updated for the new `Result` return. Blocker #3 (bearer spend key hygiene) — funding.rs: `oneTimeSk` is bearer spend authority but was marshalled via the generic `read_id32`, leaving its intermediate JNI `Vec` and returned `[u8; 32]` unsanitized. Add a `read_key32_zeroizing` helper (mirroring `transactions::read_key32_zeroizing`): the returned key is `Zeroizing` and the intermediate JNI copy is scrubbed. `sk` derefs to `[u8; 32]`, so the downstream `sk.as_ptr()` FFI call is unchanged. NOT fixed here (reported for decision): Blocker #1 (affected-state wait): `wait_for_affected_state` does not exist in this head's SDK, and the pool-funded sibling still uses `wait_for_response` on this branch. The reviewer's fix is predicated on rebasing onto the v4.1-dev proof API (31c69cf793); it must be done in lockstep for both Type-20 paths. Blocker #2 (persist claim recovery record): the redrive mechanism is keyed by SubwalletId + activity entry and driven by the per-subwallet sync loop. Claim notes belong to a foreign one-time key tracked in no subwallet, so a correct fix needs a new subwallet-less pending-claim record + reconciliation path, not a reuse of `arm_redrive_record`. Co-Authored-By: Claude Fable 5 --- .../src/shielded_send.rs | 15 ++++++- .../src/wallet/shielded/keys.rs | 29 ++++++++---- packages/rs-unified-sdk-jni/src/funding.rs | 44 ++++++++++++++++++- 3 files changed, 78 insertions(+), 10 deletions(-) diff --git a/packages/rs-platform-wallet-ffi/src/shielded_send.rs b/packages/rs-platform-wallet-ffi/src/shielded_send.rs index 9d26c084349..f630db1cf45 100644 --- a/packages/rs-platform-wallet-ffi/src/shielded_send.rs +++ b/packages/rs-platform-wallet-ffi/src/shielded_send.rs @@ -1584,7 +1584,20 @@ pub unsafe extern "C" fn platform_wallet_generate_one_time_orchard_key( check_ptr!(out_sk_32); check_ptr!(out_address_43); - let (sk, address) = generate_one_time_orchard_key(); + // `generate_one_time_orchard_key` uses `try_fill_bytes`, so an OS entropy + // failure returns a typed error here rather than panicking. That matters: + // this is a `#[no_mangle] extern "C"` export, so a panic would abort the + // process across the C ABI before any JNI panic guard could convert it — + // an OS RNG failure must surface as a normal error, never a hard abort. + let (sk, address) = match generate_one_time_orchard_key() { + Ok(pair) => pair, + Err(e) => { + return PlatformWalletFFIResult::err( + PlatformWalletFFIResultCode::ErrorWalletOperation, + e.to_string(), + ); + } + }; std::ptr::copy_nonoverlapping(sk.as_ptr(), out_sk_32, 32); std::ptr::copy_nonoverlapping(address.as_ptr(), out_address_43, 43); PlatformWalletFFIResult::ok() diff --git a/packages/rs-platform-wallet/src/wallet/shielded/keys.rs b/packages/rs-platform-wallet/src/wallet/shielded/keys.rs index 154eaa3b9ec..9279e946a61 100644 --- a/packages/rs-platform-wallet/src/wallet/shielded/keys.rs +++ b/packages/rs-platform-wallet/src/wallet/shielded/keys.rs @@ -273,17 +273,30 @@ pub fn orchard_address_from_spending_key( /// and re-rolled until it is a valid Orchard key — an invalid draw is /// negligibly rare and the same acceptance loop the `orchard` crate's own /// dummy-key generator runs. -pub fn generate_one_time_orchard_key() -> ([u8; 32], [u8; ORCHARD_RAW_ADDRESS_LEN]) { +/// +/// Uses [`RngCore::try_fill_bytes`] rather than `fill_bytes`: the latter +/// *panics* when the OS entropy source fails. This function is called from a +/// `#[no_mangle] extern "C"` FFI export, where a panic cannot unwind across +/// the C ABI and would abort the whole process before the JNI panic guard can +/// run. Surfacing the entropy failure as a typed +/// [`PlatformWalletError::ShieldedKeyDerivation`] instead lets the FFI layer +/// return a normal error to the host. +pub fn generate_one_time_orchard_key( +) -> Result<([u8; 32], [u8; ORCHARD_RAW_ADDRESS_LEN]), PlatformWalletError> { use rand::{rngs::OsRng, RngCore}; let mut rng = OsRng; loop { let mut sk_bytes = [0u8; 32]; - rng.fill_bytes(&mut sk_bytes); + rng.try_fill_bytes(&mut sk_bytes).map_err(|e| { + PlatformWalletError::ShieldedKeyDerivation(format!( + "OS RNG entropy source failed while generating a one-time Orchard key: {e}" + )) + })?; if let Some(sk) = Option::::from(SpendingKey::from_bytes(sk_bytes)) { let fvk = FullViewingKey::from(&sk); let address = fvk.address_at(0u32, Scope::External).to_raw_address_bytes(); - return (sk_bytes, address); + return Ok((sk_bytes, address)); } } } @@ -488,7 +501,7 @@ mod tests { /// only the spending key, must re-derive the same recipient. #[test] fn one_time_key_generate_roundtrips_to_its_address() { - let (sk, address) = generate_one_time_orchard_key(); + let (sk, address) = generate_one_time_orchard_key().expect("OS RNG available"); let rederived = orchard_address_from_spending_key(sk) .expect("a freshly generated sk is a valid Orchard SpendingKey"); assert_eq!( @@ -509,7 +522,7 @@ mod tests { SpendingKey, }; - let (sk_bytes, address_bytes) = generate_one_time_orchard_key(); + let (sk_bytes, address_bytes) = generate_one_time_orchard_key().expect("OS RNG available"); // Re-derive exactly the viewing keys a claimer would hold. let sk: SpendingKey = Option::from(SpendingKey::from_bytes(sk_bytes)) @@ -567,7 +580,7 @@ mod tests { /// returned. #[test] fn address_from_spending_key_is_deterministic() { - let (sk, address) = generate_one_time_orchard_key(); + let (sk, address) = generate_one_time_orchard_key().expect("OS RNG available"); let a = orchard_address_from_spending_key(sk).expect("valid sk"); let b = orchard_address_from_spending_key(sk).expect("valid sk"); assert_eq!(a, b, "same sk must derive the same address"); @@ -581,8 +594,8 @@ mod tests { /// fixed value). A collision here would be a catastrophic RNG failure. #[test] fn generate_produces_distinct_keys() { - let (sk_a, addr_a) = generate_one_time_orchard_key(); - let (sk_b, addr_b) = generate_one_time_orchard_key(); + let (sk_a, addr_a) = generate_one_time_orchard_key().expect("OS RNG available"); + let (sk_b, addr_b) = generate_one_time_orchard_key().expect("OS RNG available"); assert_ne!(sk_a, sk_b, "distinct draws must differ"); assert_ne!( addr_a, addr_b, diff --git a/packages/rs-unified-sdk-jni/src/funding.rs b/packages/rs-unified-sdk-jni/src/funding.rs index 77341f54089..5f28e6a713f 100644 --- a/packages/rs-unified-sdk-jni/src/funding.rs +++ b/packages/rs-unified-sdk-jni/src/funding.rs @@ -151,6 +151,43 @@ fn read_id32(env: &mut JNIEnv, arr: &JByteArray, field: &str) -> Option<[u8; 32] Some(id) } +/// Secret-key sibling of [`read_id32`]: same 32-byte contract, but the +/// returned buffer is wrapped in [`zeroize::Zeroizing`] (scrubbed on drop) and +/// the intermediate JNI `Vec` copy is explicitly zeroized before it is +/// dropped. Use for private/bearer key material only — mirrors +/// `transactions::read_key32_zeroizing`. A one-time invitation spending key is +/// bearer spend authority, so it must not linger in unsanitized buffers. +fn read_key32_zeroizing( + env: &mut JNIEnv, + arr: &JByteArray, + field: &str, +) -> Option> { + use zeroize::Zeroize; + + if arr.is_null() { + throw_sdk_exception(env, 1, &format!("{field} byte[] was null")); + return None; + } + let mut bytes = match env.convert_byte_array(arr) { + Ok(b) => b, + Err(_) => { + let _ = env.exception_clear(); + throw_sdk_exception(env, 1, &format!("{field} byte[] was invalid")); + return None; + } + }; + if bytes.len() != 32 { + let len = bytes.len(); + bytes.zeroize(); + throw_sdk_exception(env, 1, &format!("{field} must be 32 bytes, got {len}")); + return None; + } + let mut key = zeroize::Zeroizing::new([0u8; 32]); + key.copy_from_slice(&bytes); + bytes.zeroize(); + Some(key) +} + /// Read a required 43-byte raw Orchard recipient address from a Java /// `byte[]` (11-byte diversifier + 32-byte pk_d); throws + returns None on /// the wrong length / a JNI error. @@ -831,7 +868,12 @@ pub extern "system" fn Java_org_dashfoundation_dashsdk_ffi_FundingNative_shielde let Some(wid) = read_id32(env, &wallet_id, "walletId") else { return ptr::null_mut(); }; - let Some(sk) = read_id32(env, &one_time_sk, "oneTimeSk") else { + // Bearer spend authority for a funded invitation: carry it through a + // `Zeroizing` buffer (scrubbed on drop) instead of the generic + // `read_id32`, whose intermediate JNI copy and returned array are left + // unsanitized. `sk` derefs to `[u8; 32]`, so `sk.as_ptr()` below is + // unchanged, and the secret is wiped when `sk` drops after the FFI call. + let Some(sk) = read_key32_zeroizing(env, &one_time_sk, "oneTimeSk") else { return ptr::null_mut(); }; let Some(change_raw) = read_recipient43(env, &change_address_raw43) else { From cd7308b2c5e9935348fd4dfb86a33639b0651489 Mon Sep 17 00:00:00 2001 From: bfoss765 <38437574+bfoss765@users.noreply.github.com> Date: Sat, 25 Jul 2026 10:53:47 -0400 Subject: [PATCH 04/14] fix(shielded-invites): zeroize the one-time bearer spending key end-to-end (#4204) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reviewer (thepastaclaw) key-hygiene blocker: the one-time Orchard bearer spending key was copied into several plain, unsanitized buffers on both the claim and generate paths. Claim path — carry the key through `Zeroizing` from the FFI copy down through the wallet layers instead of leaking a plain `[u8; 32]` at each hop: - rs-platform-wallet-ffi: the `[u8; 32]` claim-key copy is now `Zeroizing<[u8; 32]>` and moves (not copies) into the wallet layer. - platform-wallet `identity_create_from_one_time_key` (both the PlatformWallet method and the operations fn) now take `Zeroizing<[u8; 32]>`; the key is scrubbed on drop, dereferenced only at the single `SpendingKey::from_bytes` consumption point. Generate path — wipe the transient native and JVM copies after handoff: - rs-platform-wallet-ffi: explicitly zeroize the native `sk` after copying it into the caller's `out_sk_32`. - rs-unified-sdk-jni: hold the JNI `sk` and the combined 75-byte `out` blob in `Zeroizing` buffers so both scrub on drop, including early returns. - kotlin-sdk `generateOneTimeOrchardKey`: wipe the 75-byte JVM blob in a `finally` once the two owned arrays have been sliced out. Validated: cargo build + cargo test -p platform-wallet (493 pass) + cargo fmt; :sdk:compileDebugKotlin succeeds. Co-Authored-By: Claude Fable 5 --- .../dashsdk/wallet/PlatformWalletManager.kt | 16 +++++++++++----- .../rs-platform-wallet-ffi/src/shielded_send.rs | 10 ++++++++-- .../src/wallet/platform_wallet.rs | 4 +++- .../src/wallet/shielded/operations.rs | 6 ++++-- packages/rs-unified-sdk-jni/src/funding.rs | 11 +++++++---- 5 files changed, 33 insertions(+), 14 deletions(-) diff --git a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/wallet/PlatformWalletManager.kt b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/wallet/PlatformWalletManager.kt index e1c4369a03f..359d7c04e4d 100644 --- a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/wallet/PlatformWalletManager.kt +++ b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/wallet/PlatformWalletManager.kt @@ -2323,11 +2323,17 @@ data class OneTimeOrchardKey( */ fun generateOneTimeOrchardKey(): OneTimeOrchardKey { val blob = mapNativeErrors { FundingNative.generateOneTimeOrchardKey() } - require(blob.size == 75) { "expected a 75-byte sk||address blob, got ${blob.size}" } - return OneTimeOrchardKey( - spendingKey = blob.copyOfRange(0, 32), - address = blob.copyOfRange(32, 75), - ) + // The blob's first 32 bytes are bearer spend authority; wipe the transient + // JVM copy once the two owned arrays have been sliced out (#4204 key-hygiene). + try { + require(blob.size == 75) { "expected a 75-byte sk||address blob, got ${blob.size}" } + return OneTimeOrchardKey( + spendingKey = blob.copyOfRange(0, 32), + address = blob.copyOfRange(32, 75), + ) + } finally { + blob.fill(0) + } } /** diff --git a/packages/rs-platform-wallet-ffi/src/shielded_send.rs b/packages/rs-platform-wallet-ffi/src/shielded_send.rs index f630db1cf45..4b29e0f2cf8 100644 --- a/packages/rs-platform-wallet-ffi/src/shielded_send.rs +++ b/packages/rs-platform-wallet-ffi/src/shielded_send.rs @@ -895,7 +895,10 @@ pub unsafe extern "C" fn platform_wallet_manager_shielded_identity_create_from_o // Copy the one-time spending key (32 bytes; the caller's safety contract // guarantees the length — no companion length arg crosses the C ABI). - let mut one_time_sk = [0u8; 32]; + // Bearer spend authority: hold this FFI-layer copy in a `Zeroizing` buffer so + // it is scrubbed on drop. It is moved into the wallet layer, which likewise + // carries it in `Zeroizing` (#4204 key-hygiene). + let mut one_time_sk = zeroize::Zeroizing::new([0u8; 32]); std::ptr::copy_nonoverlapping(one_time_sk_bytes, one_time_sk.as_mut_ptr(), 32); // Decode the claimer's own 43-byte default Orchard change address. @@ -1589,7 +1592,7 @@ pub unsafe extern "C" fn platform_wallet_generate_one_time_orchard_key( // this is a `#[no_mangle] extern "C"` export, so a panic would abort the // process across the C ABI before any JNI panic guard could convert it — // an OS RNG failure must surface as a normal error, never a hard abort. - let (sk, address) = match generate_one_time_orchard_key() { + let (mut sk, address) = match generate_one_time_orchard_key() { Ok(pair) => pair, Err(e) => { return PlatformWalletFFIResult::err( @@ -1600,6 +1603,9 @@ pub unsafe extern "C" fn platform_wallet_generate_one_time_orchard_key( }; std::ptr::copy_nonoverlapping(sk.as_ptr(), out_sk_32, 32); std::ptr::copy_nonoverlapping(address.as_ptr(), out_address_43, 43); + // Wipe this native copy of the one-time spending key now that it has been + // handed to the caller's `out_sk_32` buffer (#4204 key-hygiene). + zeroize::Zeroize::zeroize(&mut sk); PlatformWalletFFIResult::ok() } diff --git a/packages/rs-platform-wallet/src/wallet/platform_wallet.rs b/packages/rs-platform-wallet/src/wallet/platform_wallet.rs index 410d91ff355..9f89dfde8a3 100644 --- a/packages/rs-platform-wallet/src/wallet/platform_wallet.rs +++ b/packages/rs-platform-wallet/src/wallet/platform_wallet.rs @@ -1348,7 +1348,9 @@ impl PlatformWallet { pub async fn identity_create_from_one_time_key( &self, coordinator: &Arc, - one_time_sk: [u8; 32], + // Bearer spend authority carried in a `Zeroizing` buffer so this layer's copy + // of the one-time spending key is scrubbed on drop (#4204 key-hygiene). + one_time_sk: zeroize::Zeroizing<[u8; 32]>, funding_birth_height: Option, change_address: dpp::address_funds::OrchardAddress, identity_index: u32, diff --git a/packages/rs-platform-wallet/src/wallet/shielded/operations.rs b/packages/rs-platform-wallet/src/wallet/shielded/operations.rs index b73bb0cc106..b6294327e58 100644 --- a/packages/rs-platform-wallet/src/wallet/shielded/operations.rs +++ b/packages/rs-platform-wallet/src/wallet/shielded/operations.rs @@ -1578,7 +1578,9 @@ where pub async fn identity_create_from_one_time_key( sdk: &Arc, store: &Arc>, - one_time_sk: [u8; 32], + // Bearer spend authority: carried in a `Zeroizing` buffer so every wallet-layer + // copy of the one-time spending key is scrubbed on drop (#4204 key-hygiene). + one_time_sk: zeroize::Zeroizing<[u8; 32]>, funding_birth_height: Option, change_address: &OrchardAddress, public_keys: Vec<(IdentityPublicKey, IdentityPublicKeyInCreation)>, @@ -1603,7 +1605,7 @@ where // Derive the Orchard key material from the one-time spending key. `from_bytes` // returns a `CtOption`; an invalid scalar means the caller handed us a // non-key, which is a hard input error. - let sk: SpendingKey = Option::from(SpendingKey::from_bytes(one_time_sk)).ok_or_else(|| { + let sk: SpendingKey = Option::from(SpendingKey::from_bytes(*one_time_sk)).ok_or_else(|| { PlatformWalletError::ShieldedKeyDerivation( "one-time spending key is not a valid Orchard SpendingKey".to_string(), ) diff --git a/packages/rs-unified-sdk-jni/src/funding.rs b/packages/rs-unified-sdk-jni/src/funding.rs index 5f28e6a713f..0917cacbd26 100644 --- a/packages/rs-unified-sdk-jni/src/funding.rs +++ b/packages/rs-unified-sdk-jni/src/funding.rs @@ -986,7 +986,10 @@ pub extern "system" fn Java_org_dashfoundation_dashsdk_ffi_FundingNative_generat _class: JClass, ) -> jni::sys::jbyteArray { guard(&mut env, ptr::null_mut(), |env| { - let mut sk = [0u8; 32]; + // Bearer spend authority: hold the native `sk` and the combined `out` + // blob (its first 32 bytes are the spending key) in `Zeroizing` buffers so + // both are scrubbed on drop, including any early return (#4204 key-hygiene). + let mut sk = zeroize::Zeroizing::new([0u8; 32]); let mut addr = [0u8; 43]; let result = unsafe { platform_wallet_ffi::platform_wallet_generate_one_time_orchard_key( @@ -998,10 +1001,10 @@ pub extern "system" fn Java_org_dashfoundation_dashsdk_ffi_FundingNative_generat return ptr::null_mut(); } // sk ‖ addr — a 75-byte blob the Kotlin side slices into (sk32, addr43). - let mut out = [0u8; 75]; - out[..32].copy_from_slice(&sk); + let mut out = zeroize::Zeroizing::new([0u8; 75]); + out[..32].copy_from_slice(&sk[..]); out[32..].copy_from_slice(&addr); - env.byte_array_from_slice(&out) + env.byte_array_from_slice(&out[..]) .map(|a| a.into_raw()) .unwrap_or(ptr::null_mut()) }) From 2348620d8b817a29e177fc5bd47d58f4e48bbc82 Mon Sep 17 00:00:00 2001 From: bfoss765 <38437574+bfoss765@users.noreply.github.com> Date: Sat, 25 Jul 2026 11:00:35 -0400 Subject: [PATCH 05/14] fix(shielded-invites): use wait_for_affected_state for the Type-20 claim (#4204) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reviewer (thepastaclaw) blocker: after rebasing onto v4.1-dev, the current proof contract (31c69cf793) marks IdentityCreateFromShieldedPool proofs as affected-state snapshots — they authenticate the resulting identity and spent nullifiers but cannot bind the complete Orchard request. That commit switched the pool-funded sibling to wait_for_affected_state; the strict wait_for_response now yields ExecutionNotProved for every valid proof. The one-time-key claim path (identity_create_from_one_time_key) was still on the strict wait_for_response, so every valid claim proof would enter the ambiguous fallback and risk being reported unconfirmed despite executing. Switch it to wait_for_affected_state, matching the pool-funded sibling (the sibling already adopted it via the v4.1-dev rebase). Validated on the rebased v4.1.0-rc.1 base: cargo build (platform-wallet + rs-unified-sdk-jni) + cargo test -p platform-wallet (493 pass) + cargo fmt. Co-Authored-By: Claude Fable 5 --- .../rs-platform-wallet/src/wallet/shielded/operations.rs | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/packages/rs-platform-wallet/src/wallet/shielded/operations.rs b/packages/rs-platform-wallet/src/wallet/shielded/operations.rs index b6294327e58..63904e33e54 100644 --- a/packages/rs-platform-wallet/src/wallet/shielded/operations.rs +++ b/packages/rs-platform-wallet/src/wallet/shielded/operations.rs @@ -1711,8 +1711,15 @@ where } } + // Wait for proven execution, mirroring the pool-funded sibling verbatim. A + // Type-20 IdentityCreateFromShieldedPool proof authenticates the spent + // nullifiers and resulting identity as an affected-state snapshot; it cannot + // bind the complete Orchard request, so the current proof contract marks it as + // affected-state. Use `wait_for_affected_state` — the strict `wait_for_response` + // would classify every valid claim proof as `ExecutionNotProved`, drop into the + // ambiguous fallback, and risk reporting a successful claim as unconfirmed. let proof_result = match st - .wait_for_response::(sdk, None) + .wait_for_affected_state::(sdk, None) .await { Ok(result) => result, From d94addc89efd5ea688e553ca296c631cb77f80a5 Mon Sep 17 00:00:00 2001 From: bfoss765 <38437574+bfoss765@users.noreply.github.com> Date: Tue, 28 Jul 2026 10:56:34 -0400 Subject: [PATCH 06/14] feat(platform-wallet): idempotent one-time-key claim recovery + anchored-note DAO queries (#4204) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Shielded-invite claim recovery: an IdentityCreateFromOneTimeKey claim that has already executed on chain (its note nullifier is spent / broadcast or wait returns NullifierAlreadySpent) is now reconciled to success instead of stranding the retry with a hard error. Recovery re-derives everything from the invite the invitee already holds — no persisted record: - master_auth_public_key_hash(): the invitee's re-derivable MASTER auth key hash, the unique Platform-indexed handle the identity is looked up by (discover_inner's unique-hash probe). - any_nullifier_spent_on_chain(): proof-verified ShieldedNullifierStatuses preflight; if the selected notes are already spent, recover by key hash before rebuilding/rebroadcasting. - NullifierAlreadySpent arms on both broadcast and wait paths route to recover_executed_one_time_claim(), which recovers by key hash, then by the deterministically-derived identity id (fetch_identity_with_retries), and otherwise surfaces ShieldedBroadcastUnconfirmed carrying the derived id. Preserves the newer #4204 key-hygiene base already in this branch: the one-time spending key is still carried in Zeroizing<[u8;32]> and wait_for_affected_state is unchanged (Type-20 proof is affected-state). ShieldedDao: adds minUnspentAnchoredBlockHeight() and getUnspentAnchoredNotesByWallet() — read-only queries over existing shielded_notes columns (no schema change) backing the shielded-username anchor-confirmation gate. Co-Authored-By: Claude Opus 4.8 --- .../dashsdk/persistence/dao/ShieldedDao.kt | 29 +++ .../src/wallet/shielded/operations.rs | 230 ++++++++++++++++++ 2 files changed, 259 insertions(+) diff --git a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/dao/ShieldedDao.kt b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/dao/ShieldedDao.kt index 9e7ed9141e6..ef024f020c5 100644 --- a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/dao/ShieldedDao.kt +++ b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/dao/ShieldedDao.kt @@ -53,6 +53,35 @@ interface ShieldedDao { @Query("SELECT * FROM shielded_notes WHERE walletId = :walletId AND isSpent = 0") fun observeUnspentNotesByWallet(walletId: ByteArray): Flow> + /** + * Shielded-username confirmation gate: the earliest-anchored unspent + * funding note's `blockHeight` for [walletId]. Only mined notes count + * (`blockHeight > 0` excludes mempool/height-0 rows); `MIN` yields the + * most-confirmed anchor. Returns null when the wallet has no anchored + * unspent note. Wallet scoping mirrors [observeUnspentNotesByWallet] + * (`walletId = :walletId AND isSpent = 0`). + */ + @Query( + "SELECT MIN(blockHeight) FROM shielded_notes " + + "WHERE walletId = :walletId AND isSpent = 0 AND blockHeight > 0" + ) + suspend fun minUnspentAnchoredBlockHeight(walletId: ByteArray): Long? + + /** + * Companion to [minUnspentAnchoredBlockHeight] for the gate's + * denomination-coverage check: every unspent, anchored (mined) note for + * [walletId], youngest anchor first (`blockHeight DESC`), so the app can + * decide whether an anchored note set covers the required amount and + * inspect each note's `value` / `blockHeight` / `createdAt`. Wallet + * scoping mirrors [observeUnspentNotesByWallet]. + */ + @Query( + "SELECT * FROM shielded_notes " + + "WHERE walletId = :walletId AND isSpent = 0 AND blockHeight > 0 " + + "ORDER BY blockHeight DESC" + ) + suspend fun getUnspentAnchoredNotesByWallet(walletId: ByteArray): List + @Upsert suspend fun upsertNote(note: ShieldedNoteEntity) diff --git a/packages/rs-platform-wallet/src/wallet/shielded/operations.rs b/packages/rs-platform-wallet/src/wallet/shielded/operations.rs index 63904e33e54..ae2e5fde1c2 100644 --- a/packages/rs-platform-wallet/src/wallet/shielded/operations.rs +++ b/packages/rs-platform-wallet/src/wallet/shielded/operations.rs @@ -1627,6 +1627,14 @@ where let num_keys = public_keys.len(); + // The invitee's re-derivable MASTER auth key hash: the unique, Platform-indexed + // handle we recover the created identity by if a claim turns out to have + // already executed (idempotent-retry recovery — see the spent-nullifier + // preflight and the broadcast handling below). Captured before `public_keys` is + // moved into the builder. `None` only if the caller submitted no master auth + // key (identity creation requires one, so this is defensive). + let master_key_hash = master_auth_public_key_hash(&public_keys); + // Transient scan: re-derive the one-time key's note(s) from the network. let discovered = super::sync::scan_notes_for_foreign_key(sdk, &fvk, &ivk, denomination).await?; if discovered.is_empty() { @@ -1659,6 +1667,44 @@ where .map(|(key, _)| (key.id(), key.clone())) .collect(); + // Idempotent-retry preflight (no persisted record). If this one-time key's + // selected note(s) are ALREADY spent on chain, a byte-identical claim already + // executed — so we must NOT rebuild+rebroadcast (that would only earn a + // `NullifierAlreadySpent` rejection). Everything checked here is re-derived + // from the invite the invitee holds: the one-time key → its note(s) via the + // transient scan above, and each note's real nullifier (`ShieldedNote.nullifier`, + // stamped `note.nullifier(fvk)` during the scan). If spent, recover the + // previously-created identity by the invitee's own re-derivable MASTER auth key + // hash (`discover_inner`'s unique-hash probe) and return it as success. + let selected_nullifiers: Vec<[u8; 32]> = selected_notes.iter().map(|n| n.nullifier).collect(); + if any_nullifier_spent_on_chain(sdk, &selected_nullifiers).await { + if let Some(key_hash) = master_key_hash { + if let Some(mut identity) = + fetch_identity_by_key_hash_with_retries(sdk, key_hash).await + { + info!( + identity_id = %identity.id(), + "IdentityCreateFromOneTimeKey: one-time key already spent on chain — recovered \ + the previously-created identity by its master auth key hash (idempotent retry; \ + skipped rebuild/rebroadcast)" + ); + if identity.public_keys().is_empty() { + identity.set_public_keys(submitted_public_keys.clone()); + } + return Ok((identity.id(), identity)); + } + } + // Spent, but not yet resolvable by key hash (indexing lag) — or no master + // key was present. Fall through to the normal build path; its broadcast + // returns `NullifierAlreadySpent`, which is handled below as + // executed-and-recover (that path additionally has the deterministically + // derived identity id as a recovery handle). + warn!( + "IdentityCreateFromOneTimeKey: one-time key note already spent on chain but identity \ + not yet recoverable by key hash; proceeding to the idempotent broadcast path" + ); + } + // Witness the selected notes against a Platform-recorded anchor from the // shared, fully-marked commitment tree (identical probe to the pool op). let (spends, anchor) = extract_spends_and_anchor(sdk, store, &selected_notes).await?; @@ -1698,6 +1744,21 @@ where match st.broadcast(sdk, None).await { Ok(()) => {} + // A `NullifierAlreadySpent` verdict is NOT a failure on this path: it is + // positive proof a byte-identical claim already executed (the note is + // consumed on chain). Recover the created identity instead of stranding + // the retry. Checked before the generic `broadcast_definitely_failed` arm, + // which would otherwise classify this consensus rejection as a hard failure. + Err(e) if is_nullifier_already_spent(&e) => { + return recover_executed_one_time_claim( + sdk, + master_key_hash, + identity_id, + &submitted_public_keys, + &e, + ) + .await; + } Err(e) if broadcast_definitely_failed(&e) => { return Err(PlatformWalletError::ShieldedBroadcastFailed(e.to_string())); } @@ -1723,6 +1784,21 @@ where .await { Ok(result) => result, + // Same idempotent recovery as the broadcast arm: a `NullifierAlreadySpent` + // verdict surfacing at wait time proves the claim executed, so recover the + // identity rather than reporting a broadcast failure. Ordered before the + // generic consensus-rejection arm below (which would classify it as a + // failure). + Err(wait_err) if is_nullifier_already_spent(&wait_err) => { + return recover_executed_one_time_claim( + sdk, + master_key_hash, + identity_id, + &submitted_public_keys, + &wait_err, + ) + .await; + } Err(dash_sdk::Error::StateTransitionBroadcastError(e)) if e.cause.is_some() => { return Err(PlatformWalletError::ShieldedBroadcastFailed(e.to_string())); } @@ -2696,6 +2772,160 @@ fn broadcast_definitely_failed(e: &dash_sdk::Error) -> bool { } } +/// Best-effort on-chain check: is any of `nullifiers` already recorded spent in +/// Platform's shielded nullifier set? Reuses the proof-verified +/// [`ShieldedNullifierStatuses`](dash_sdk::query_types::ShieldedNullifierStatuses) +/// fetch (query type [`ShieldedNullifiersQuery`](dash_sdk::query_types::ShieldedNullifiersQuery)). +/// +/// A query error (or an empty response) returns `false` — "unknown, proceed": +/// the normal build+broadcast path then reconciles via the +/// `NullifierAlreadySpent` broadcast verdict, so a transient query failure only +/// costs a (harmless, idempotent) rebuild, never a wrong answer. +async fn any_nullifier_spent_on_chain( + sdk: &Arc, + nullifiers: &[[u8; 32]], +) -> bool { + use dash_sdk::platform::Fetch; + use dash_sdk::query_types::{ShieldedNullifierStatuses, ShieldedNullifiersQuery}; + + if nullifiers.is_empty() { + return false; + } + match ShieldedNullifierStatuses::fetch(sdk, ShieldedNullifiersQuery(nullifiers.to_vec())).await { + Ok(Some(statuses)) => statuses.0.iter().any(|s| s.is_spent), + Ok(None) => false, + Err(e) => { + warn!( + error = %e, + "IdentityCreateFromOneTimeKey: nullifier spent-status query failed; treating as \ + unknown and proceeding to the idempotent broadcast path" + ); + false + } + } +} + +/// The 20-byte hash of the MASTER authentication key among `public_keys` +/// (`purpose = AUTHENTICATION`, `security_level = MASTER`). This is the unique, +/// Platform-indexed key hash an identity can be looked up by — the exact probe +/// [`IdentityWallet::discover_inner`] scans with +/// (`Identity::fetch(sdk, PublicKeyHash(..))`). The invitee re-derives these +/// same creation keys from its own seed on a retry, so this hash re-derives +/// deterministically and needs no persisted record. +fn master_auth_public_key_hash( + public_keys: &[(IdentityPublicKey, IdentityPublicKeyInCreation)], +) -> Option<[u8; 20]> { + use dpp::identity::identity_public_key::methods::hash::IdentityPublicKeyHashMethodsV0; + use dpp::identity::{Purpose, SecurityLevel}; + + public_keys + .iter() + .map(|(key, _)| key) + .find(|key| { + key.purpose() == Purpose::AUTHENTICATION + && key.security_level() == SecurityLevel::MASTER + }) + .and_then(|key| key.public_key_hash().ok()) +} + +/// Recover the identity a claim created by looking it up under its MASTER auth +/// key hash, with the same bounded retry cadence as +/// [`fetch_identity_with_retries`] to ride out DAPI indexing lag. Reuses +/// `discover_inner`'s unique-hash primitive (`Identity::fetch(sdk, +/// PublicKeyHash(..))`). +async fn fetch_identity_by_key_hash_with_retries( + sdk: &Arc, + key_hash: [u8; 20], +) -> Option { + use dash_sdk::platform::types::identity::PublicKeyHash; + use dash_sdk::platform::Fetch; + + for attempt in 0..IDENTITY_CREATE_FETCH_RETRIES { + match Identity::fetch(sdk, PublicKeyHash(key_hash)).await { + Ok(Some(identity)) => return Some(identity), + Ok(None) => { + trace!( + key_hash = %hex::encode(key_hash), + attempt, + "IdentityCreateFromOneTimeKey recovery: identity not found by key hash yet" + ); + } + Err(e) => { + trace!( + key_hash = %hex::encode(key_hash), + attempt, + error = %e, + "IdentityCreateFromOneTimeKey recovery: key-hash lookup errored; will retry" + ); + } + } + if attempt + 1 < IDENTITY_CREATE_FETCH_RETRIES { + tokio::time::sleep(IDENTITY_CREATE_FETCH_RETRY_DELAY).await; + } + } + None +} + +/// The one-time-key claim already executed on chain (its note's nullifier is +/// spent / the broadcast returned `NullifierAlreadySpent`). Recover the created +/// identity so a retry returns success instead of a stranding error. +/// +/// Recovery reuses two existing, re-derivable-from-the-invite handles, each with +/// bounded retries for DAPI indexing lag: +/// 1. the invitee's MASTER auth key hash (`discover_inner`'s unique-hash probe), +/// 2. the deterministically-derived identity id (`fetch_identity_with_retries`). +/// +/// If neither resolves yet, surface `ShieldedBroadcastUnconfirmed` carrying the +/// derived id — unchanged behavior for the app (which already writes that id out +/// and can retry), and a further retry reconciles once indexing catches up. +async fn recover_executed_one_time_claim( + sdk: &Arc, + master_key_hash: Option<[u8; 20]>, + identity_id: Identifier, + submitted_public_keys: &BTreeMap, + evidence: &dash_sdk::Error, +) -> Result<(Identifier, Identity), PlatformWalletError> { + warn!( + derived_id = %identity_id, + error = %evidence, + "IdentityCreateFromOneTimeKey: claim already executed on chain (nullifier spent); \ + recovering the previously-created identity instead of failing" + ); + + if let Some(key_hash) = master_key_hash { + if let Some(mut identity) = fetch_identity_by_key_hash_with_retries(sdk, key_hash).await { + info!( + identity_id = %identity.id(), + "IdentityCreateFromOneTimeKey: recovered the executed claim's identity by its \ + master auth key hash" + ); + if identity.public_keys().is_empty() { + identity.set_public_keys(submitted_public_keys.clone()); + } + return Ok((identity.id(), identity)); + } + } + + if let Some(mut identity) = fetch_identity_with_retries(sdk, identity_id).await { + info!( + derived_id = %identity_id, + "IdentityCreateFromOneTimeKey: recovered the executed claim's identity by its derived id" + ); + if identity.public_keys().is_empty() { + identity.set_public_keys(submitted_public_keys.clone()); + } + return Ok((identity.id(), identity)); + } + + Err(PlatformWalletError::ShieldedBroadcastUnconfirmed { + identity_id, + reason: format!( + "one-time-key claim executed (nullifier already spent) but the identity is not yet \ + resolvable by key hash or derived id: {evidence}" + ), + }) +} + /// Classify a `wait_for_response` failure for an already-broadcast /// shielded spend (see [`broadcast_shielded_spend`]). /// From fcd8efe18d0c579d8e52f8bb402e353bdaf319c4 Mon Sep 17 00:00:00 2001 From: bfoss765 <38437574+bfoss765@users.noreply.github.com> Date: Sun, 2 Aug 2026 22:46:20 -0400 Subject: [PATCH 07/14] docs(kotlin-sdk): clarify which PR accepted the KPIE emulator residual "same residual #4172 accepted" read ambiguously; say the residual was accepted in #4172. Co-Authored-By: Claude Opus 4.8 --- docs/dashpay/KOTLIN_MIGRATION_LEFTOVERS.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/dashpay/KOTLIN_MIGRATION_LEFTOVERS.md b/docs/dashpay/KOTLIN_MIGRATION_LEFTOVERS.md index 7113bb97c58..27c9ef97bee 100644 --- a/docs/dashpay/KOTLIN_MIGRATION_LEFTOVERS.md +++ b/docs/dashpay/KOTLIN_MIGRATION_LEFTOVERS.md @@ -71,7 +71,7 @@ when the four stacked PRs collapse into one. invalidation recovery (generation-checked alias deletion + re-derive via forced repair) is pinned at the unit tier through the fake Keystore seam; a REAL KPIE requires biometric re-enrollment mid-test, which CI's emulator - cannot do — same residual #4172 accepted. Exercise manually per the device + cannot do — the same residual accepted in #4172. Exercise manually per the device test plan when touching the invalidation path. ## Environment-bound (cannot be code-fixed here) From 061bff9102630143bcda26ea8de5677973cce937 Mon Sep 17 00:00:00 2001 From: bfoss765 <38437574+bfoss765@users.noreply.github.com> Date: Mon, 3 Aug 2026 10:01:10 -0400 Subject: [PATCH 08/14] fix(shielded-invites): bind claim recovery to evidence the claim created the identity (#4204) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A spent invitation nullifier proves only that *something* consumed the note. It does not prove that this claim's Type-20 transition created an identity, and recovery was treating "nullifier spent + an identity is findable under the submitted MASTER auth key hash" as a successful claim. Two real on-chain outcomes are reported as success by that rule: 1. The chargeable `UnshieldAction` fallback. When a submitted unique public-key hash is already registered, Type-20 finalizes the shielded spend as an `UnshieldTransitionAction` with `chargeable_failure: true` and creates NO identity, crediting the invitation value to the creation-failure address minus a penalty (rs-drive-abci .../identity_create_from_shielded_pool/state/ v0/mod.rs:62-128). A retry then saw the nullifier spent, fetched the *pre-existing* identity that owns the colliding key hash, and returned it as the claim's result. 2. A competing holder of the same bearer one-time key. The identity id is `double_sha256` over the SORTED published action nullifiers (`identity_id_from_nullifiers`) — derived from nullifiers only, never from identity keys. With two or more real spends no randomized padding action is added, so another holder of the same invite derives the SAME id under THEIR keys. The victim's retry fetched that foreign identity by the shared id and `platform_wallet.rs` registered it at the victim's identity index. Recovery is now gated on two independent bindings, both required (`recovered_identity_matches_claim`): - id binding — the identity's id equals the id derived from THIS claim's published nullifiers. Consensus re-derives and rejects a mismatch, so only a transition publishing exactly this nullifier set can carry that id. This is what rejects case 1. - key binding — the identity's ON-CHAIN key set carries this claim's submitted MASTER authentication key hash. This is what rejects case 2. The key binding is checked against the keys the fetch actually returned, so an identity fetched without public keys now fails closed instead of being topped up with locally-submitted keys that were never proven to exist on chain. Where the bindings cannot be established, recovery returns the new terminal `ShieldedInviteAlreadyClaimed` (FFI `ErrorShieldedInviteAlreadyClaimed` = 32) rather than a success or the retryable unconfirmed code. That includes the single-spend case: the builder pads a one-action bundle to Orchard's 2-action minimum (`num_actions = spends.len().max(2)`) and the padding action's RANDOM dummy nullifier participates in the id derivation, so the original id is not re-derivable on a retry and no candidate can be bound to the claim. Also: - The spent-nullifier preflight now hands off to the reconciler directly instead of falling through to rebuild+rebroadcast a transition that can only earn a `NullifierAlreadySpent` rejection (saves a Halo 2 proof build). - The generic wait-failure fallback applies the key binding too, but only when the bundle was NOT padded: a padded build's id embeds a locally generated dummy nullifier no other party can reproduce, so there the id alone is proof. Regression tests in `one_time_claim_evidence_tests` pin both attack scenarios plus the keyless-fetch, unre-derivable-id, absent-key-hash, wrong-purpose and different-nullifier-set cases. 7 of the 8 fail against the pre-fix rule (only the positive-acceptance case still passes), verified by reverting the predicate to the old accept-anything behavior. --- packages/rs-platform-wallet/src/error.rs | 31 + .../src/wallet/shielded/operations.rs | 581 +++++++++++++++--- 2 files changed, 542 insertions(+), 70 deletions(-) diff --git a/packages/rs-platform-wallet/src/error.rs b/packages/rs-platform-wallet/src/error.rs index 7495b74956a..25ce07145aa 100644 --- a/packages/rs-platform-wallet/src/error.rs +++ b/packages/rs-platform-wallet/src/error.rs @@ -377,6 +377,37 @@ pub enum PlatformWalletError { #[error("Shielded spend cannot use a Platform-recorded anchor: {0}")] ShieldedNoRecordedAnchor(String), + /// A one-time-key (shielded invitation) claim could not be completed: the invitation note's + /// nullifier is already spent on chain, and the wallet could **not** produce positive evidence + /// that *this* claim's Type-20 transition created an identity. + /// + /// This is a **terminal** outcome for the invitation — the note is consumed, so no retry can + /// spend it again — and it is deliberately distinct from + /// [`Self::ShieldedBroadcastUnconfirmed`] (retryable: executed, not yet resolvable) and from + /// success. It is returned instead of a success whenever the recovered identity fails either + /// ownership binding checked by `recovered_identity_matches_claim`, which covers two real + /// on-chain outcomes that a naive "nullifier spent + a key matches" test reports as success: + /// + /// 1. **Chargeable `UnshieldAction` fallback.** When a submitted unique public-key hash is + /// already registered, Type-20 finalizes the shielded spend as an `UnshieldTransitionAction` + /// with `chargeable_failure: true` and creates **no** identity, crediting the invitation + /// value to `send_to_address_on_creation_failure` minus a penalty. The nullifier is spent and + /// the *pre-existing* colliding identity is findable under the submitted MASTER auth key + /// hash, so key-hash existence alone would report a successful claim that never happened. + /// 2. **A competing holder of the same bearer key.** The identity id is derived from published + /// nullifiers only, never from identity keys, so when two or more real notes are spent (no + /// randomized padding action) another holder of the same one-time key produces the *same* + /// derived id under *their* keys. Returning that identity would register a foreign identity + /// at this wallet's identity index. + /// + /// `reason` carries which binding failed, for diagnostics. + #[error( + "Shielded invitation already claimed: its note is spent on chain but this wallet cannot \ + prove that this claim created an identity ({reason}); the invitation cannot be claimed \ + again" + )] + ShieldedInviteAlreadyClaimed { reason: String }, + #[error("Shielded key derivation failed: {0}")] ShieldedKeyDerivation(String), diff --git a/packages/rs-platform-wallet/src/wallet/shielded/operations.rs b/packages/rs-platform-wallet/src/wallet/shielded/operations.rs index ae2e5fde1c2..a0973ae9900 100644 --- a/packages/rs-platform-wallet/src/wallet/shielded/operations.rs +++ b/packages/rs-platform-wallet/src/wallet/shielded/operations.rs @@ -56,6 +56,7 @@ use dpp::shielded::builder::{ }; use dpp::shielded::compute_minimum_shielded_fee; use dpp::state_transition::proof_result::StateTransitionProofResult; +use dpp::state_transition::state_transitions::shielded::identity_create_from_shielded_pool_transition::identity_id_from_nullifiers; use dpp::state_transition::public_key_in_creation::IdentityPublicKeyInCreation; use dpp::state_transition::StateTransition; use dpp::withdrawal::Pooling; @@ -1677,32 +1678,40 @@ where // previously-created identity by the invitee's own re-derivable MASTER auth key // hash (`discover_inner`'s unique-hash probe) and return it as success. let selected_nullifiers: Vec<[u8; 32]> = selected_notes.iter().map(|n| n.nullifier).collect(); + + // The id that an identity created by THIS claim must carry — the single + // handle that ties a recovered identity back to this claim's spend, and the + // reason a MASTER-key-hash hit alone is not evidence of a successful claim + // (see `recovered_identity_matches_claim`). + // + // Consensus derives the new identity id as `double_sha256` over the SORTED + // set of PUBLISHED action nullifiers (`derive_identity_id_from_actions`) and + // rejects a transition whose declared id differs, so this is a binding, not a + // guess. + // + // `None` for a single-spend claim: the builder pads to Orchard's 2-action + // minimum (`num_actions = spends.len().max(2)`) and the padding action's + // dummy nullifier is randomly generated per build, so it participates in the + // derivation but cannot be reproduced on a retry. With two or more real + // spends no padding is added and the published set is exactly + // `selected_nullifiers`. + let expected_identity_id = + (selected_notes.len() >= 2).then(|| identity_id_from_nullifiers(&selected_nullifiers)); + + // Idempotent-retry preflight. If this one-time key's selected note(s) are + // ALREADY spent on chain, this claim can never execute — rebuilding and + // rebroadcasting would only earn a `NullifierAlreadySpent` rejection and burn + // a Halo 2 proof. Hand off to the reconciler, which decides between "this + // claim created that identity" (both bindings verified), "the invitation is + // gone" (terminal), and "executed but not yet indexed" (retryable). if any_nullifier_spent_on_chain(sdk, &selected_nullifiers).await { - if let Some(key_hash) = master_key_hash { - if let Some(mut identity) = - fetch_identity_by_key_hash_with_retries(sdk, key_hash).await - { - info!( - identity_id = %identity.id(), - "IdentityCreateFromOneTimeKey: one-time key already spent on chain — recovered \ - the previously-created identity by its master auth key hash (idempotent retry; \ - skipped rebuild/rebroadcast)" - ); - if identity.public_keys().is_empty() { - identity.set_public_keys(submitted_public_keys.clone()); - } - return Ok((identity.id(), identity)); - } - } - // Spent, but not yet resolvable by key hash (indexing lag) — or no master - // key was present. Fall through to the normal build path; its broadcast - // returns `NullifierAlreadySpent`, which is handled below as - // executed-and-recover (that path additionally has the deterministically - // derived identity id as a recovery handle). - warn!( - "IdentityCreateFromOneTimeKey: one-time key note already spent on chain but identity \ - not yet recoverable by key hash; proceeding to the idempotent broadcast path" - ); + return recover_executed_one_time_claim( + sdk, + master_key_hash, + expected_identity_id, + "the selected note's nullifier is already spent on chain (pre-broadcast preflight)", + ) + .await; } // Witness the selected notes against a Platform-recorded anchor from the @@ -1753,9 +1762,8 @@ where return recover_executed_one_time_claim( sdk, master_key_hash, - identity_id, - &submitted_public_keys, - &e, + expected_identity_id, + &format!("broadcast returned NullifierAlreadySpent: {e}"), ) .await; } @@ -1793,9 +1801,8 @@ where return recover_executed_one_time_claim( sdk, master_key_hash, - identity_id, - &submitted_public_keys, - &wait_err, + expected_identity_id, + &format!("result wait returned NullifierAlreadySpent: {wait_err}"), ) .await; } @@ -1811,11 +1818,48 @@ where ); match fetch_identity_with_retries(sdk, identity_id).await { Some(mut identity) => { + // `identity_id` is the id THIS build derived. Whether finding + // an identity under it proves this transition created it + // depends on whether the bundle was padded: + // + // - **Padded (single spend)** — the id embeds a locally + // generated random dummy nullifier that no other party can + // reproduce, so an identity at this id can only have come + // from this transition. The id alone is proof. + // - **Not padded (>= 2 spends)** — the id is derived from the + // invitation's real nullifiers alone, so any other holder of + // the same bearer one-time key derives the SAME id under + // their own keys. The on-chain MASTER auth key must be + // checked before this can be called ours. + if expected_identity_id.is_some() + && !recovered_identity_matches_claim( + &identity, + expected_identity_id, + master_key_hash, + ) + { + warn!( + derived_id = %identity_id, + "IdentityCreateFromOneTimeKey: an identity exists at this claim's \ + derived id but does not carry the submitted master auth key; another \ + holder of the same one-time key claimed the invitation first" + ); + return Err(PlatformWalletError::ShieldedInviteAlreadyClaimed { + reason: format!( + "identity {identity_id} was created from this invitation's notes \ + but does not carry the submitted master authentication key, so it \ + belongs to another holder of the one-time key: {wait_err}" + ), + }); + } info!( derived_id = %identity_id, "IdentityCreateFromOneTimeKey: result confirmation failed but the identity \ was found on chain by its derived id; treating as success" ); + // Only reached once the identity is proven to be this claim's, + // so back-filling the keys this transition itself submitted is + // a local-row convenience, not an unproven ownership claim. if identity.public_keys().is_empty() { identity.set_public_keys(submitted_public_keys.clone()); } @@ -2781,17 +2825,15 @@ fn broadcast_definitely_failed(e: &dash_sdk::Error) -> bool { /// the normal build+broadcast path then reconciles via the /// `NullifierAlreadySpent` broadcast verdict, so a transient query failure only /// costs a (harmless, idempotent) rebuild, never a wrong answer. -async fn any_nullifier_spent_on_chain( - sdk: &Arc, - nullifiers: &[[u8; 32]], -) -> bool { +async fn any_nullifier_spent_on_chain(sdk: &Arc, nullifiers: &[[u8; 32]]) -> bool { use dash_sdk::platform::Fetch; use dash_sdk::query_types::{ShieldedNullifierStatuses, ShieldedNullifiersQuery}; if nullifiers.is_empty() { return false; } - match ShieldedNullifierStatuses::fetch(sdk, ShieldedNullifiersQuery(nullifiers.to_vec())).await { + match ShieldedNullifierStatuses::fetch(sdk, ShieldedNullifiersQuery(nullifiers.to_vec())).await + { Ok(Some(statuses)) => statuses.0.iter().any(|s| s.is_spent), Ok(None) => false, Err(e) => { @@ -2828,6 +2870,75 @@ fn master_auth_public_key_hash( .and_then(|key| key.public_key_hash().ok()) } +/// Positive evidence that `identity` was created by **this** claim's Type-20 +/// transition. +/// +/// Two independent bindings must BOTH hold. Each one alone is satisfied by a +/// real on-chain outcome in which this claim did *not* create the identity, so +/// neither is sufficient on its own: +/// +/// 1. **Id binding** — `identity.id()` equals `expected_identity_id`, the id +/// derived from this claim's published spend nullifiers +/// (`identity_id_from_nullifiers`). Consensus re-derives the id the same way +/// and rejects any transition whose declared id differs (see +/// `derive_identity_id_from_actions` in the Type-20 state validation), so an +/// identity carrying this id can only have been created by a transition that +/// published exactly this claim's nullifier set. +/// +/// Without it, the MASTER-key-hash lookup accepts the **pre-existing** +/// identity that a chargeable `UnshieldAction` fallback collided with: when a +/// submitted unique key hash is already registered, Type-20 finalizes the +/// spend as an `UnshieldTransitionAction` (`chargeable_failure: true`) and +/// creates no identity, yet the nullifier is consumed and the colliding +/// identity *is* findable under our own key hash. +/// +/// 2. **Key binding** — the identity's **on-chain** key set contains this +/// claim's submitted MASTER authentication key hash. +/// +/// Without it, the derived-id lookup accepts an identity created by a +/// *different* holder of the same bearer one-time key: the id is derived from +/// nullifiers only, never from identity keys, so two holders racing the same +/// invitation derive the same id under different keys. +/// +/// The key binding is checked against the keys the fetch actually returned — an +/// identity that comes back without public keys fails closed rather than being +/// topped up with locally-submitted keys that were never proven to exist on +/// chain. +/// +/// `expected_identity_id == None` means the id is not re-derivable for this +/// claim, so binding 1 cannot be established and this returns `false`. That is +/// the single-spend case: `BundleType::DEFAULT` pads a one-action bundle to +/// Orchard's 2-action minimum and the padding action's **randomly generated** +/// dummy nullifier participates in the id derivation, so a retry cannot +/// reproduce the original id. +fn recovered_identity_matches_claim( + identity: &Identity, + expected_identity_id: Option, + master_key_hash: Option<[u8; 20]>, +) -> bool { + use dpp::identity::identity_public_key::methods::hash::IdentityPublicKeyHashMethodsV0; + use dpp::identity::{Purpose, SecurityLevel}; + + // Both handles must be available; a missing one is not evidence. + let (Some(expected_id), Some(expected_hash)) = (expected_identity_id, master_key_hash) else { + return false; + }; + + // Binding 1: the id must be the one derived from this claim's nullifiers. + if identity.id() != expected_id { + return false; + } + + // Binding 2: the on-chain key set must carry this claim's MASTER auth key. + identity.public_keys().values().any(|key| { + key.purpose() == Purpose::AUTHENTICATION + && key.security_level() == SecurityLevel::MASTER + && key + .public_key_hash() + .is_ok_and(|hash| hash == expected_hash) + }) +} + /// Recover the identity a claim created by looking it up under its MASTER auth /// key hash, with the same bounded retry cadence as /// [`fetch_identity_with_retries`] to ride out DAPI indexing lag. Reuses @@ -2866,59 +2977,120 @@ async fn fetch_identity_by_key_hash_with_retries( None } -/// The one-time-key claim already executed on chain (its note's nullifier is -/// spent / the broadcast returned `NullifierAlreadySpent`). Recover the created -/// identity so a retry returns success instead of a stranding error. +/// This one-time-key claim's note is already spent on chain (the spent-nullifier +/// preflight saw it, or the broadcast/wait returned `NullifierAlreadySpent`). +/// Decide what that actually means and return the matching outcome. +/// +/// A spent nullifier proves only that *something* consumed the invitation note — +/// **not** that this claim created an identity. Type-20 also consumes the note on +/// its chargeable `UnshieldAction` fallback, which creates no identity at all. +/// So every candidate identity found here must clear both ownership bindings in +/// [`recovered_identity_matches_claim`] before it can be reported as this +/// claim's result. /// -/// Recovery reuses two existing, re-derivable-from-the-invite handles, each with -/// bounded retries for DAPI indexing lag: +/// Two lookup handles are tried, each with bounded retries for DAPI indexing lag: /// 1. the invitee's MASTER auth key hash (`discover_inner`'s unique-hash probe), -/// 2. the deterministically-derived identity id (`fetch_identity_with_retries`). +/// 2. the id derived from this claim's published nullifiers. /// -/// If neither resolves yet, surface `ShieldedBroadcastUnconfirmed` carrying the -/// derived id — unchanged behavior for the app (which already writes that id out -/// and can retry), and a further retry reconciles once indexing catches up. +/// Outcomes: +/// - **`Ok`** — a fetched identity cleared both bindings: this claim created it. +/// - **[`PlatformWalletError::ShieldedInviteAlreadyClaimed`]** — an identity was +/// fetched but failed a binding (chargeable fallback, or a competing holder of +/// the same bearer key), *or* the id is not re-derivable so no binding can ever +/// be established. Terminal: the note is spent, so retrying cannot help. +/// - **[`PlatformWalletError::ShieldedBroadcastUnconfirmed`]** — nothing resolved +/// yet, but the id *is* re-derivable, so a later retry can still reconcile once +/// indexing catches up. Only reachable when `expected_identity_id` is `Some`, +/// so the carried id is always the one this claim's nullifiers derive. async fn recover_executed_one_time_claim( sdk: &Arc, master_key_hash: Option<[u8; 20]>, - identity_id: Identifier, - submitted_public_keys: &BTreeMap, - evidence: &dash_sdk::Error, + expected_identity_id: Option, + evidence: &str, ) -> Result<(Identifier, Identity), PlatformWalletError> { warn!( - derived_id = %identity_id, - error = %evidence, - "IdentityCreateFromOneTimeKey: claim already executed on chain (nullifier spent); \ - recovering the previously-created identity instead of failing" + ?expected_identity_id, + evidence, + "IdentityCreateFromOneTimeKey: invitation note already spent on chain; checking whether \ + this claim actually created an identity" ); + // The id is not re-derivable (single-spend bundle padded with a random dummy + // nullifier), so no candidate identity can ever be bound to this claim. + // Report the invitation as claimed rather than inventing a success. + let Some(expected_id) = expected_identity_id else { + return Err(PlatformWalletError::ShieldedInviteAlreadyClaimed { + reason: format!( + "the note was spent by an earlier transition whose identity id cannot be \ + re-derived (single-spend bundles are padded with a randomly generated dummy \ + nullifier that participates in the id derivation): {evidence}" + ), + }); + }; + + // Handle 1: the invitee's own MASTER auth key hash. if let Some(key_hash) = master_key_hash { - if let Some(mut identity) = fetch_identity_by_key_hash_with_retries(sdk, key_hash).await { - info!( - identity_id = %identity.id(), - "IdentityCreateFromOneTimeKey: recovered the executed claim's identity by its \ - master auth key hash" - ); - if identity.public_keys().is_empty() { - identity.set_public_keys(submitted_public_keys.clone()); + if let Some(identity) = fetch_identity_by_key_hash_with_retries(sdk, key_hash).await { + if recovered_identity_matches_claim(&identity, expected_identity_id, master_key_hash) { + info!( + identity_id = %identity.id(), + "IdentityCreateFromOneTimeKey: recovered this claim's identity by its master \ + auth key hash (id and key bindings both verified)" + ); + return Ok((identity.id(), identity)); } - return Ok((identity.id(), identity)); + // Found under our key hash but NOT created by this claim — the + // chargeable-`UnshieldAction` outcome: the spend was finalized, the + // value went to the fallback address, and this pre-existing identity + // merely owns the colliding key hash. + warn!( + found_id = %identity.id(), + expected_id = %expected_id, + "IdentityCreateFromOneTimeKey: an identity owns this claim's master auth key hash \ + but its id is not the one this claim's nullifiers derive; the spend was finalized \ + as a chargeable failure and created no identity" + ); + return Err(PlatformWalletError::ShieldedInviteAlreadyClaimed { + reason: format!( + "identity {} owns the submitted master auth key hash but was not created by \ + this claim (expected id {}); the shielded spend was finalized as a chargeable \ + failure and its value went to the creation-failure address: {evidence}", + identity.id(), + expected_id + ), + }); } } - if let Some(mut identity) = fetch_identity_with_retries(sdk, identity_id).await { - info!( - derived_id = %identity_id, - "IdentityCreateFromOneTimeKey: recovered the executed claim's identity by its derived id" - ); - if identity.public_keys().is_empty() { - identity.set_public_keys(submitted_public_keys.clone()); + // Handle 2: the id derived from this claim's published nullifiers. + if let Some(identity) = fetch_identity_with_retries(sdk, expected_id).await { + if recovered_identity_matches_claim(&identity, expected_identity_id, master_key_hash) { + info!( + derived_id = %expected_id, + "IdentityCreateFromOneTimeKey: recovered this claim's identity by its derived id \ + (id and key bindings both verified)" + ); + return Ok((identity.id(), identity)); } - return Ok((identity.id(), identity)); + // The id matches (same nullifier set) but the on-chain keys are not ours: + // another holder of the same bearer one-time key won the race. + warn!( + derived_id = %expected_id, + "IdentityCreateFromOneTimeKey: an identity exists at this claim's derived id but does \ + not carry the submitted master auth key; another holder of the same one-time key \ + claimed the invitation first" + ); + return Err(PlatformWalletError::ShieldedInviteAlreadyClaimed { + reason: format!( + "identity {expected_id} was created from this invitation's notes but does not \ + carry the submitted master authentication key, so it belongs to another holder \ + of the one-time key: {evidence}" + ), + }); } Err(PlatformWalletError::ShieldedBroadcastUnconfirmed { - identity_id, + identity_id: expected_id, reason: format!( "one-time-key claim executed (nullifier already spent) but the identity is not yet \ resolvable by key hash or derived id: {evidence}" @@ -4076,3 +4248,272 @@ mod one_time_key_tests { ); } } + +/// Regression tests for one-time-key (shielded invitation) claim RECOVERY +/// ownership evidence. +/// +/// A spent invitation nullifier proves only that *something* consumed the note. +/// It does **not** prove that this claim's Type-20 transition created an +/// identity, and these tests pin the two on-chain outcomes where the pre-fix +/// rule — "the nullifier is spent and an identity is findable under the +/// submitted MASTER auth key hash" — reported a successful claim that never +/// happened. +#[cfg(test)] +mod one_time_claim_evidence_tests { + use super::*; + use dpp::identity::identity_public_key::v0::IdentityPublicKeyV0; + use dpp::identity::{KeyType, Purpose, SecurityLevel}; + use dpp::platform_value::BinaryData; + use dpp::version::PlatformVersion; + + /// This claim's submitted MASTER auth key hash. + const OUR_MASTER_HASH: [u8; 20] = [0xA1; 20]; + /// Some other key's hash — used for the competing-claimant identity. + const OTHER_MASTER_HASH: [u8; 20] = [0xB2; 20]; + + /// The two real note nullifiers this claim spends. + fn our_nullifiers() -> Vec<[u8; 32]> { + vec![[0x11; 32], [0x22; 32]] + } + + /// An `ECDSA_HASH160` key whose `public_key_hash()` is exactly `hash` — + /// `KeyType::ECDSA_HASH160` returns its 20-byte `data` verbatim, so the test + /// controls the hash precisely without generating real key material. + fn key_with_hash( + id: u32, + purpose: Purpose, + security_level: SecurityLevel, + hash: [u8; 20], + ) -> IdentityPublicKey { + IdentityPublicKey::V0(IdentityPublicKeyV0 { + id, + purpose, + security_level, + contract_bounds: None, + key_type: KeyType::ECDSA_HASH160, + read_only: false, + data: BinaryData::new(hash.to_vec()), + disabled_at: None, + }) + } + + fn identity_with_keys(id: Identifier, keys: Vec) -> Identity { + let map: BTreeMap = keys.into_iter().map(|k| (k.id(), k)).collect(); + Identity::new_with_id_and_keys(id, map, PlatformVersion::latest()) + .expect("test identity builds") + } + + /// The MASTER auth key this claim submits. + fn our_master_key() -> IdentityPublicKey { + key_with_hash( + 0, + Purpose::AUTHENTICATION, + SecurityLevel::MASTER, + OUR_MASTER_HASH, + ) + } + + /// The **pre-fix** acceptance rule, encoded here as the behavior these tests + /// exist to reject. + /// + /// Before the fix, both recovery handles returned `Ok((identity.id(), + /// identity))` for *whatever* identity the lookup produced — the fetched + /// identity was never inspected. So the old rule accepted unconditionally + /// once a lookup succeeded, and every case below that asserts + /// `recovered_identity_matches_claim(..) == false` is a case the old code + /// returned as a successful claim. + fn pre_fix_rule_accepts(_identity: &Identity) -> bool { + true + } + + /// BLOCKER 1 — chargeable `UnshieldAction` fallback must not read as success. + /// + /// When a submitted unique public-key hash is already registered, Type-20 + /// finalizes the shielded spend as an `UnshieldTransitionAction` with + /// `chargeable_failure: true`: the nullifier IS consumed, the invitation + /// value goes to the creation-failure address, and **no identity is + /// created**. A retry then finds the *pre-existing* identity that owns the + /// colliding key hash. Its id is not the one this claim's nullifiers derive, + /// so the id binding must reject it. + #[test] + fn chargeable_unshield_fallback_identity_is_rejected() { + let expected_id = identity_id_from_nullifiers(&our_nullifiers()); + + // The pre-existing identity: it genuinely owns our MASTER key hash (that + // is exactly why the unique-key-hash collision fired), but it was created + // by some unrelated earlier transition, so it carries an unrelated id. + let pre_existing = identity_with_keys(Identifier::from([0xEE; 32]), vec![our_master_key()]); + + assert!( + pre_existing.id() != expected_id, + "precondition: the colliding identity is not the one this claim derives" + ); + assert!( + pre_fix_rule_accepts(&pre_existing), + "the pre-fix rule accepted this identity as a successful claim" + ); + assert!( + !recovered_identity_matches_claim( + &pre_existing, + Some(expected_id), + Some(OUR_MASTER_HASH) + ), + "an identity that merely owns the submitted master auth key hash must NOT be \ + reported as this claim's result: the spend was finalized as a chargeable failure \ + and created no identity" + ); + } + + /// BLOCKER 2 — a competing holder of the same bearer key must not read as + /// success. + /// + /// The identity id is derived from published nullifiers only, never from + /// identity keys. With two or more real spends no randomized padding action + /// is added, so another holder of the same one-time key spending the same + /// notes derives the SAME id under THEIR keys. The key binding must reject + /// it — otherwise the foreign identity is registered at this wallet's + /// caller-supplied identity index. + #[test] + fn competing_bearer_key_holder_identity_is_rejected() { + let expected_id = identity_id_from_nullifiers(&our_nullifiers()); + + // Same notes => same nullifiers => same derived id, but the winner + // registered their own master key. + let foreign = identity_with_keys( + expected_id, + vec![key_with_hash( + 0, + Purpose::AUTHENTICATION, + SecurityLevel::MASTER, + OTHER_MASTER_HASH, + )], + ); + + assert_eq!( + foreign.id(), + expected_id, + "precondition: the race winner's identity shares this claim's derived id" + ); + assert!( + pre_fix_rule_accepts(&foreign), + "the pre-fix rule accepted this identity as a successful claim" + ); + assert!( + !recovered_identity_matches_claim(&foreign, Some(expected_id), Some(OUR_MASTER_HASH)), + "an identity at this claim's derived id that does not carry the submitted master \ + auth key belongs to another holder of the one-time key and must NOT be returned" + ); + } + + /// A keyless fetch must fail closed rather than be topped up with the + /// locally-submitted keys — those were never proven to exist on chain. + #[test] + fn identity_fetched_without_public_keys_is_rejected() { + let expected_id = identity_id_from_nullifiers(&our_nullifiers()); + let keyless = identity_with_keys(expected_id, vec![]); + + assert!( + pre_fix_rule_accepts(&keyless), + "the pre-fix rule accepted this identity and then inserted the submitted keys locally" + ); + assert!( + !recovered_identity_matches_claim(&keyless, Some(expected_id), Some(OUR_MASTER_HASH)), + "an identity fetched without public keys cannot prove the key binding" + ); + } + + /// A single-spend claim's id is not re-derivable (the bundle is padded to + /// Orchard's 2-action minimum with a randomly generated dummy nullifier that + /// participates in the derivation), so no candidate can ever be bound to it. + #[test] + fn unre_derivable_id_is_rejected() { + let identity = identity_with_keys(Identifier::from([0xEE; 32]), vec![our_master_key()]); + + assert!( + !recovered_identity_matches_claim(&identity, None, Some(OUR_MASTER_HASH)), + "without a re-derivable id there is no evidence this claim created the identity" + ); + } + + /// A missing MASTER auth key hash is not evidence either. + #[test] + fn absent_master_key_hash_is_rejected() { + let expected_id = identity_id_from_nullifiers(&our_nullifiers()); + let identity = identity_with_keys(expected_id, vec![our_master_key()]); + + assert!( + !recovered_identity_matches_claim(&identity, Some(expected_id), None), + "without a submitted master auth key hash the key binding cannot be established" + ); + } + + /// A key with the right hash but the wrong purpose/security level does not + /// satisfy the key binding — the binding is specifically on the MASTER + /// AUTHENTICATION key, which is the uniquely Platform-indexed handle. + #[test] + fn non_master_key_with_matching_hash_is_rejected() { + let expected_id = identity_id_from_nullifiers(&our_nullifiers()); + let identity = identity_with_keys( + expected_id, + vec![ + key_with_hash( + 0, + Purpose::AUTHENTICATION, + SecurityLevel::HIGH, + OUR_MASTER_HASH, + ), + key_with_hash( + 1, + Purpose::TRANSFER, + SecurityLevel::CRITICAL, + OUR_MASTER_HASH, + ), + ], + ); + + assert!( + !recovered_identity_matches_claim(&identity, Some(expected_id), Some(OUR_MASTER_HASH)), + "only a MASTER AUTHENTICATION key satisfies the key binding" + ); + } + + /// The positive case: both bindings hold, so this claim provably created the + /// identity and recovery returns it. + #[test] + fn identity_with_matching_id_and_master_key_is_accepted() { + let expected_id = identity_id_from_nullifiers(&our_nullifiers()); + let ours = identity_with_keys( + expected_id, + vec![ + our_master_key(), + key_with_hash(1, Purpose::TRANSFER, SecurityLevel::CRITICAL, [0xC3; 20]), + ], + ); + + assert!( + recovered_identity_matches_claim(&ours, Some(expected_id), Some(OUR_MASTER_HASH)), + "an identity carrying this claim's derived id AND its submitted master auth key was \ + created by this claim" + ); + } + + /// The id binding is only meaningful because the derivation is over the + /// claim's own nullifier set: a different note selection derives a different + /// id, so it cannot be passed off as this claim's result. + #[test] + fn a_different_nullifier_set_derives_a_different_id() { + let ours = identity_id_from_nullifiers(&our_nullifiers()); + let theirs = identity_id_from_nullifiers(&[[0x11; 32], [0x33; 32]]); + + assert_ne!( + ours, theirs, + "the derived id is a function of the published nullifier set" + ); + + let identity = identity_with_keys(theirs, vec![our_master_key()]); + assert!( + !recovered_identity_matches_claim(&identity, Some(ours), Some(OUR_MASTER_HASH)), + "an identity created from a different nullifier set is not this claim's identity" + ); + } +} From df5357c6b87c0f377419b07cab316b9f7fe9c05d Mon Sep 17 00:00:00 2001 From: bfoss765 <38437574+bfoss765@users.noreply.github.com> Date: Mon, 3 Aug 2026 10:01:30 -0400 Subject: [PATCH 09/14] =?UTF-8?q?fix(kotlin-sdk,ffi):=20CodeRabbit=20revie?= =?UTF-8?q?w=20round=20=E2=80=94=20cancellation,=20key=20hygiene,=20messag?= =?UTF-8?q?e=20hygiene=20(#4204)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses the six open CodeRabbit threads. - `PlatformWalletPersistenceHandler.reconstructPendingIdentityKeysFromPersistence` wrapped a SUSPEND decryptability probe in `runCatching`, which catches `Throwable` and therefore swallowed `CancellationException`: a cancelled caller had the row misclassified as unusable and a spurious pending-repair entry published. Now rethrows cancellation and keeps `false` only for genuine probe failures, matching the convention this PR already established in `WalletStorage` ("NEVER swallow structured-concurrency cancellation"). CodeRabbit missed that `PlatformWalletManager` re-swallows one frame up in a bare `runCatching`; that site is fixed too, since fixing only the inner one would not have delivered the stated behavior. - Rename five unused `catch (e: ...)` bindings to `_` (detekt SwallowedException) in `WalletStorage` and `KeystoreManager`. Adjacent catches that `throw e` are deliberately untouched. - Carry the one-time bearer spending key through `Zeroizing` on the remaining generate/derive helpers: the JNI `orchardAddressFromSpendingKey` input now uses `read_key32_zeroizing` (matching `oneTimeSk`), and `generate_one_time_orchard_key` wraps its in-loop draw so REJECTED draws are scrubbed too and the accepted key travels out still wrapped — which also covers the FFI export's early-return paths that its explicit `zeroize()` missed (that call is now redundant and removed). Note `orchard_address_from_spending_key` takes the key BY VALUE, so the caller-frame `Zeroizing` in `platform_wallet_orchard_address_from_spending_key` scrubs that frame only; this is documented at the call site rather than overstated as eliminating the plaintext copy. - Strip the signer's internal machine prefix (`DASH_SDK_SIGNER_ERR_KEY_UNAVAILABLE_PREFIX`) from rendered messages on both conversion paths in `platform-wallet-ffi`. Both read the prefix to pick the typed code BEFORE stripping, so classification is unaffected, and the host-side fallback matcher keys on the human tail (`DashSdkError.MESSAGE_MARKER`), not the prefix. - Fix the markdownlint MD038 trailing-space-inside-code-span in `KOTLIN_MIGRATION_LEFTOVERS.md` and `KOTLIN_SWIFT_SHARED_PARITY_SPEC.md`. Also applies `cargo fmt` to the five pre-existing formatting violations in files this PR already owns, so `cargo fmt --check` passes clean. --- docs/dashpay/KOTLIN_MIGRATION_LEFTOVERS.md | 2 +- docs/sdk/KOTLIN_SWIFT_SHARED_PARITY_SPEC.md | 2 +- .../dashsdk/security/KeystoreManager.kt | 2 +- .../dashsdk/security/WalletStorage.kt | 8 +-- packages/rs-platform-wallet-ffi/src/error.rs | 60 ++++++++++++++++++- .../src/shielded_send.rs | 19 ++++-- .../src/wallet/shielded/keys.rs | 27 +++++---- packages/rs-unified-sdk-jni/src/funding.rs | 7 ++- 8 files changed, 101 insertions(+), 26 deletions(-) diff --git a/docs/dashpay/KOTLIN_MIGRATION_LEFTOVERS.md b/docs/dashpay/KOTLIN_MIGRATION_LEFTOVERS.md index 27c9ef97bee..ee36ec7e284 100644 --- a/docs/dashpay/KOTLIN_MIGRATION_LEFTOVERS.md +++ b/docs/dashpay/KOTLIN_MIGRATION_LEFTOVERS.md @@ -63,7 +63,7 @@ when the four stacked PRs collapse into one. mixed old-native/new-Kotlin builds, which the completion JNI arity change (3→4 args) makes unsupported outright; delete it (and `MESSAGE_MARKER`'s matcher role) in the next minor release. Accepted residual until rs-dpp grows a typed variant: the - Rust-internal segment rides the `signer_error:key_unavailable: ` prefix + Rust-internal segment rides the `signer_error:key_unavailable:` prefix through `ProtocolError::Generic` (typed at both ABI edges, one Rust-owned constant bridging the string segment). diff --git a/docs/sdk/KOTLIN_SWIFT_SHARED_PARITY_SPEC.md b/docs/sdk/KOTLIN_SWIFT_SHARED_PARITY_SPEC.md index 25aed864dd6..a163e6f50d9 100644 --- a/docs/sdk/KOTLIN_SWIFT_SHARED_PARITY_SPEC.md +++ b/docs/sdk/KOTLIN_SWIFT_SHARED_PARITY_SPEC.md @@ -672,7 +672,7 @@ Recorded in `sdk-parity-manifest.json`; rationale here: signer completion carries a typed `error_code` (rs-sdk-ffi `DashSDKSignerErrorCode`), restored as platform-wallet code 31 on both hosts. The Rust-internal segment rides the machine prefix - `signer_error:key_unavailable: ` through `ProtocolError::Generic` (a typed + `signer_error:key_unavailable:` through `ProtocolError::Generic` (a typed rs-dpp variant was rejected for serialization blast radius — accepted residual). The Kotlin `MESSAGE_MARKER` text sniff survives ONLY as a deprecated fallback for the #4191 merge-order transition (marker-based diff --git a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/security/KeystoreManager.kt b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/security/KeystoreManager.kt index e2efbc9e005..bc48bccc330 100644 --- a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/security/KeystoreManager.kt +++ b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/security/KeystoreManager.kt @@ -488,7 +488,7 @@ open class KeystoreManager( return try { decrypt(blob, KEYS_ALIAS_DEVICE_BOUND).fill(0) true - } catch (e: GeneralSecurityException) { + } catch (_: GeneralSecurityException) { false } } diff --git a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/security/WalletStorage.kt b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/security/WalletStorage.kt index 40de259fdd1..51953896c0b 100644 --- a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/security/WalletStorage.kt +++ b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/security/WalletStorage.kt @@ -587,7 +587,7 @@ class WalletStorage( // suppresses the biometric retry and the next write/repair // regenerates the alias. throw e - } catch (e: GeneralSecurityException) { + } catch (_: GeneralSecurityException) { // Rotation race / provider quirk: fall through to the // recovery ladder rather than failing the read outright. recoverEmptyIvRsaBlob(pubkeyHex, blob, encoded) @@ -638,7 +638,7 @@ class WalletStorage( throw e } catch (e: KeyPermanentlyInvalidatedException) { throw e - } catch (e: GeneralSecurityException) { + } catch (_: GeneralSecurityException) { null } @@ -894,9 +894,9 @@ class WalletStorage( } else { false } - } catch (e: UserNotAuthenticatedException) { + } catch (_: UserNotAuthenticatedException) { unaeProvesRecoverable - } catch (e: GeneralSecurityException) { + } catch (_: GeneralSecurityException) { false } diff --git a/packages/rs-platform-wallet-ffi/src/error.rs b/packages/rs-platform-wallet-ffi/src/error.rs index 95683cb131d..89a69b6be73 100644 --- a/packages/rs-platform-wallet-ffi/src/error.rs +++ b/packages/rs-platform-wallet-ffi/src/error.rs @@ -235,6 +235,27 @@ pub enum PlatformWalletFFIResultCode { /// wallet-operation failure. Not retryable as-is — the key must be /// (re-)derived first. ErrorSigningKeyUnavailable = 31, + /// Maps `PlatformWalletError::ShieldedInviteAlreadyClaimed`. A one-time-key + /// (shielded invitation) claim found the invitation note's nullifier already + /// spent on chain, and could NOT produce positive evidence that this claim's + /// Type-20 transition created an identity — either an identity owns the + /// submitted MASTER auth key hash but carries a different id than this + /// claim's nullifiers derive (the chargeable `UnshieldAction` fallback: the + /// spend was finalized, the value went to the creation-failure address and no + /// identity was created), or an identity exists at this claim's derived id + /// but under someone else's keys (another holder of the same bearer one-time + /// key won the race), or the id is not re-derivable at all. + /// + /// TERMINAL and NOT retryable — unlike + /// [`Self::ErrorShieldedBroadcastUnconfirmed`], which means "executed, not yet + /// resolvable, retry later". The note is consumed, so no retry can spend it + /// again. `out_identity_id` is NOT written: this wallet has no identity to + /// hold a slot for, and writing one would be the very false-ownership claim + /// this code exists to prevent. Hosts should surface the invitation as spent + /// rather than registering any identity. + /// + /// Code 32: 27-30 stay reserved for the in-flight branches noted above. + ErrorShieldedInviteAlreadyClaimed = 32, NotFound = 98, // Used exclusively for all the Option that are retuned as errors ErrorUnknown = 99, @@ -366,6 +387,14 @@ impl From for PlatformWalletFFIResult { PlatformWalletError::ShieldedSpendUnconfirmed { .. } => { PlatformWalletFFIResultCode::ErrorShieldedSpendUnconfirmed } + // Terminal, and deliberately NOT flattened into the retryable + // unconfirmed code: the invitation note is spent and this wallet + // could not prove its claim created an identity, so a host that + // retried (or registered an identity) would be acting on exactly the + // false-ownership signal this variant exists to replace. + PlatformWalletError::ShieldedInviteAlreadyClaimed { .. } => { + PlatformWalletFFIResultCode::ErrorShieldedInviteAlreadyClaimed + } PlatformWalletError::ShieldedNoRecordedAnchor(..) => { PlatformWalletFFIResultCode::ErrorShieldedNoRecordedAnchor } @@ -434,7 +463,9 @@ impl From for PlatformWalletFFIResult { } _ => PlatformWalletFFIResultCode::ErrorUnknown, }; - PlatformWalletFFIResult::err(code, error.to_string()) + // Classification above already consumed the machine prefix; strip it so + // the internal token does not reach user-visible host error text. + PlatformWalletFFIResult::err(code, strip_signer_machine_prefix(&error.to_string())) } } @@ -549,10 +580,35 @@ impl From for PlatformWalletFFIResult { } else { PlatformWalletFFIResultCode::ErrorWalletOperation }; - Self::err(code, format!("DPP protocol error: {msg}")) + Self::err( + code, + format!("DPP protocol error: {}", strip_signer_machine_prefix(&msg)), + ) } } +/// Remove the signer's internal machine prefix +/// ([`rs_sdk_ffi::DASH_SDK_SIGNER_ERR_KEY_UNAVAILABLE_PREFIX`]) from a rendered +/// error message. +/// +/// The prefix is a transport detail: it exists only so the typed +/// `SigningKeyUnavailable` completion code survives being flattened into +/// `ProtocolError::Generic`'s string (dashpay/platform#4060 finding 7). Once the +/// code has been restored it has done its job, and leaving it in place would +/// surface an internal token in user-visible Kotlin/Swift error text. +/// +/// Both call sites read the prefix to pick the code BEFORE calling this, so +/// stripping never costs classification. `replace` rather than `strip_prefix`: +/// on the catch-all `From` path the prefix sits mid-string +/// inside the nested `Sdk(Protocol(..))` `Display` rendering, not at position 0. +/// +/// The host-side fallback matcher keys on the human tail (`"no private key +/// stored for"`, `DashSdkError.MESSAGE_MARKER`), not on this prefix, so it is +/// unaffected. +fn strip_signer_machine_prefix(message: &str) -> String { + message.replace(rs_sdk_ffi::DASH_SDK_SIGNER_ERR_KEY_UNAVAILABLE_PREFIX, "") +} + impl From<&str> for PlatformWalletFFIResult { fn from(e: &str) -> Self { Self::err(PlatformWalletFFIResultCode::ErrorInvalidParameter, e) diff --git a/packages/rs-platform-wallet-ffi/src/shielded_send.rs b/packages/rs-platform-wallet-ffi/src/shielded_send.rs index 4b29e0f2cf8..5b0a3ce260e 100644 --- a/packages/rs-platform-wallet-ffi/src/shielded_send.rs +++ b/packages/rs-platform-wallet-ffi/src/shielded_send.rs @@ -1592,7 +1592,12 @@ pub unsafe extern "C" fn platform_wallet_generate_one_time_orchard_key( // this is a `#[no_mangle] extern "C"` export, so a panic would abort the // process across the C ABI before any JNI panic guard could convert it — // an OS RNG failure must surface as a normal error, never a hard abort. - let (mut sk, address) = match generate_one_time_orchard_key() { + // `sk` is a `Zeroizing<[u8; 32]>`: the generator now scrubs every draw it + // makes (including rejected ones) and hands the accepted key out still + // wrapped, so this native copy is wiped on drop once it has been handed to + // the caller's `out_sk_32` buffer — no explicit `zeroize()` needed, and the + // scrub also covers the early-return paths (#4204 key-hygiene). + let (sk, address) = match generate_one_time_orchard_key() { Ok(pair) => pair, Err(e) => { return PlatformWalletFFIResult::err( @@ -1603,9 +1608,6 @@ pub unsafe extern "C" fn platform_wallet_generate_one_time_orchard_key( }; std::ptr::copy_nonoverlapping(sk.as_ptr(), out_sk_32, 32); std::ptr::copy_nonoverlapping(address.as_ptr(), out_address_43, 43); - // Wipe this native copy of the one-time spending key now that it has been - // handed to the caller's `out_sk_32` buffer (#4204 key-hygiene). - zeroize::Zeroize::zeroize(&mut sk); PlatformWalletFFIResult::ok() } @@ -1632,10 +1634,15 @@ pub unsafe extern "C" fn platform_wallet_orchard_address_from_spending_key( check_ptr!(sk_bytes_32); check_ptr!(out_address_43); - let mut sk = [0u8; 32]; + // Carry the caller-supplied bearer spending key in `Zeroizing` so THIS + // frame's copy is scrubbed on drop, on every return path (#4204 key + // hygiene). Note `orchard_address_from_spending_key` takes the key BY + // VALUE, so the callee still makes its own transient copy — this only + // scrubs the caller frame. + let mut sk = zeroize::Zeroizing::new([0u8; 32]); std::ptr::copy_nonoverlapping(sk_bytes_32, sk.as_mut_ptr(), 32); - match orchard_address_from_spending_key(sk) { + match orchard_address_from_spending_key(*sk) { Ok(address) => { std::ptr::copy_nonoverlapping(address.as_ptr(), out_address_43, 43); PlatformWalletFFIResult::ok() diff --git a/packages/rs-platform-wallet/src/wallet/shielded/keys.rs b/packages/rs-platform-wallet/src/wallet/shielded/keys.rs index 9279e946a61..da20f2a32a0 100644 --- a/packages/rs-platform-wallet/src/wallet/shielded/keys.rs +++ b/packages/rs-platform-wallet/src/wallet/shielded/keys.rs @@ -257,7 +257,8 @@ pub fn orchard_address_from_spending_key( /// /// Returns `(spending_key_32, default_address_43)`: /// - `spending_key_32` — a uniformly random, valid 32-byte Orchard -/// `SpendingKey` scalar. These are exactly the bytes +/// `SpendingKey` scalar, wrapped in [`zeroize::Zeroizing`] so the bearer +/// secret is scrubbed when the caller drops it. These are exactly the bytes /// `identity_create_from_one_time_key` accepts as its one-time key: both /// sides round-trip through `SpendingKey::from_bytes`, which stores the /// scalar bytes verbatim, so `spending_key_32 == sk.to_bytes()`. @@ -282,18 +283,24 @@ pub fn orchard_address_from_spending_key( /// [`PlatformWalletError::ShieldedKeyDerivation`] instead lets the FFI layer /// return a normal error to the host. pub fn generate_one_time_orchard_key( -) -> Result<([u8; 32], [u8; ORCHARD_RAW_ADDRESS_LEN]), PlatformWalletError> { +) -> Result<(zeroize::Zeroizing<[u8; 32]>, [u8; ORCHARD_RAW_ADDRESS_LEN]), PlatformWalletError> { use rand::{rngs::OsRng, RngCore}; let mut rng = OsRng; loop { - let mut sk_bytes = [0u8; 32]; - rng.try_fill_bytes(&mut sk_bytes).map_err(|e| { + // `Zeroizing` inside the loop, not just on the accepted draw: the + // acceptance loop can REJECT a draw, and a rejected 32-byte scalar is + // still fresh CSPRNG key material. A plain `[u8; 32]` would drop at the + // end of the iteration unscrubbed, leaving discarded near-keys in the + // stack frame. Wrapping here scrubs every draw — rejected and accepted + // alike — and carries the accepted one out to the caller still wrapped. + let mut sk_bytes = zeroize::Zeroizing::new([0u8; 32]); + rng.try_fill_bytes(sk_bytes.as_mut_slice()).map_err(|e| { PlatformWalletError::ShieldedKeyDerivation(format!( "OS RNG entropy source failed while generating a one-time Orchard key: {e}" )) })?; - if let Some(sk) = Option::::from(SpendingKey::from_bytes(sk_bytes)) { + if let Some(sk) = Option::::from(SpendingKey::from_bytes(*sk_bytes)) { let fvk = FullViewingKey::from(&sk); let address = fvk.address_at(0u32, Scope::External).to_raw_address_bytes(); return Ok((sk_bytes, address)); @@ -502,7 +509,7 @@ mod tests { #[test] fn one_time_key_generate_roundtrips_to_its_address() { let (sk, address) = generate_one_time_orchard_key().expect("OS RNG available"); - let rederived = orchard_address_from_spending_key(sk) + let rederived = orchard_address_from_spending_key(*sk) .expect("a freshly generated sk is a valid Orchard SpendingKey"); assert_eq!( address, rederived, @@ -525,7 +532,7 @@ mod tests { let (sk_bytes, address_bytes) = generate_one_time_orchard_key().expect("OS RNG available"); // Re-derive exactly the viewing keys a claimer would hold. - let sk: SpendingKey = Option::from(SpendingKey::from_bytes(sk_bytes)) + let sk: SpendingKey = Option::from(SpendingKey::from_bytes(*sk_bytes)) .expect("generated sk is a valid Orchard SpendingKey"); let fvk = FullViewingKey::from(&sk); let ivk = fvk.to_ivk(Scope::External); @@ -581,8 +588,8 @@ mod tests { #[test] fn address_from_spending_key_is_deterministic() { let (sk, address) = generate_one_time_orchard_key().expect("OS RNG available"); - let a = orchard_address_from_spending_key(sk).expect("valid sk"); - let b = orchard_address_from_spending_key(sk).expect("valid sk"); + let a = orchard_address_from_spending_key(*sk).expect("valid sk"); + let b = orchard_address_from_spending_key(*sk).expect("valid sk"); assert_eq!(a, b, "same sk must derive the same address"); assert_eq!( a, address, @@ -596,7 +603,7 @@ mod tests { fn generate_produces_distinct_keys() { let (sk_a, addr_a) = generate_one_time_orchard_key().expect("OS RNG available"); let (sk_b, addr_b) = generate_one_time_orchard_key().expect("OS RNG available"); - assert_ne!(sk_a, sk_b, "distinct draws must differ"); + assert_ne!(*sk_a, *sk_b, "distinct draws must differ"); assert_ne!( addr_a, addr_b, "distinct keys must derive distinct addresses" diff --git a/packages/rs-unified-sdk-jni/src/funding.rs b/packages/rs-unified-sdk-jni/src/funding.rs index 0917cacbd26..a616d6874de 100644 --- a/packages/rs-unified-sdk-jni/src/funding.rs +++ b/packages/rs-unified-sdk-jni/src/funding.rs @@ -1024,7 +1024,12 @@ pub extern "system" fn Java_org_dashfoundation_dashsdk_ffi_FundingNative_orchard spending_key: JByteArray, ) -> jni::sys::jbyteArray { guard(&mut env, ptr::null_mut(), |env| { - let Some(sk) = read_id32(env, &spending_key, "spendingKey") else { + // Same bearer-secret treatment as `oneTimeSk` above: a one-time Orchard + // spending key is spend authority, so read it through the `Zeroizing` + // helper (scrubbed on drop, intermediate JNI copy wiped) rather than the + // generic `read_id32`. `sk` derefs to `[u8; 32]`, so `sk.as_ptr()` below + // is unchanged. + let Some(sk) = read_key32_zeroizing(env, &spending_key, "spendingKey") else { return ptr::null_mut(); }; let mut addr = [0u8; 43]; From 9464818e8209b41e1da18725103fea4602b7b5d6 Mon Sep 17 00:00:00 2001 From: bfoss765 <38437574+bfoss765@users.noreply.github.com> Date: Mon, 3 Aug 2026 14:24:20 -0400 Subject: [PATCH 10/14] fix(platform-wallet-ffi)!: move ErrorShieldedInviteAlreadyClaimed 32 -> 37 and mirror it (#4204) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 32 is allocated to `ErrorTransactionBuild` (dashpay/platform#4247, also carried by #4256) in ERROR_CODE_REGISTRY.md (#4261). This variant took 32 without a registry row, so the two collide as a hard `E0081: discriminant value 32 assigned more than once` the moment both land — reproduced on a real integration merge, not hypothetical. 27-36 are all claimed (27 ErrorShutdownIncomplete via the merged #4268; 29 #4184; 31 #4183; 32/33 37 is the allocation frontier. The code was also unmirrored on BOTH hosts, which is the more dangerous half: Swift is exhaustive, so it surfaced as .errorUnknown and lost its identity; Kotlin fell through to Generic(32), and in any tree carrying "shielded invite already claimed" as "reservation wallet mismatch". That matters on the claim-recovery path specifically — the error is raised from four sites in shielded/operations.rs, three inside the recovery function. Adds the typed Kotlin PlatformWallet.ShieldedInviteAlreadyClaimed (terminal, inherited isRetryable = false), the Swift enum case + init(ffi:) arm, a DashSdkErrorTest assertion pinning 37, and refreshes the stale Swift reservation comment the registry asked the next toucher to drop. Co-Authored-By: Claude Opus 4.8 --- .../dashsdk/errors/DashSdkError.kt | 23 ++++++++++++++++ .../dashsdk/errors/DashSdkErrorTest.kt | 13 ++++++++++ packages/rs-platform-wallet-ffi/src/error.rs | 11 ++++++-- .../PlatformWallet/PlatformWalletResult.swift | 26 ++++++++++++++++--- 4 files changed, 67 insertions(+), 6 deletions(-) diff --git a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/errors/DashSdkError.kt b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/errors/DashSdkError.kt index 9c57d763b10..97e05ac35df 100644 --- a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/errors/DashSdkError.kt +++ b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/errors/DashSdkError.kt @@ -239,6 +239,25 @@ sealed class DashSdkError( class NotFound(message: String, cause: Throwable? = null) : PlatformWallet(message, cause) + /** + * `ErrorShieldedInviteAlreadyClaimed` (native code 37). A one-time-key + * (shielded invitation) claim found the invitation note's nullifier + * already spent on chain, and could NOT produce positive evidence that + * this claim's Type-20 transition created an identity — the spend was + * finalized to the creation-failure address, or another holder of the + * same bearer one-time key won the race, or the id is not re-derivable. + * + * TERMINAL and NOT retryable (the inherited [isRetryable] `false`): + * the note is consumed, so no retry can spend it again. Distinct from + * [ShieldedCreateUnconfirmed], which means "executed, not yet + * resolvable, hold the slot". No identity id is produced — this wallet + * has no identity to hold a slot for, and claiming one would be the + * false-ownership assertion this code exists to prevent. Hosts should + * surface the invitation as spent rather than registering an identity. + */ + class ShieldedInviteAlreadyClaimed(message: String, cause: Throwable? = null) : + PlatformWallet(message, cause) + /** * Any other `PlatformWalletFFIResultCode` without a dedicated type. * Carries the platform-wallet [nativeCode] (already de-offset) and @@ -351,6 +370,10 @@ sealed class DashSdkError( // sniffing involved. (Codes 26-30 are reserved by sibling PRs // #4185 / #4184 — see PlatformWalletFFIResultCode.) 31 -> PlatformWallet.SigningKeyUnavailable(message, cause) + // ErrorShieldedInviteAlreadyClaimed. Allocated 37 (not 32, which + // belongs to ErrorTransactionBuild — dashpay/platform#4247/#4256); + // see packages/rs-platform-wallet-ffi/ERROR_CODE_REGISTRY.md. + 37 -> PlatformWallet.ShieldedInviteAlreadyClaimed(message, cause) else -> // @Deprecated fallback — see the code-6 arm; code 31 is the // real discriminator. diff --git a/packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/errors/DashSdkErrorTest.kt b/packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/errors/DashSdkErrorTest.kt index 8879712206c..fb432144c43 100644 --- a/packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/errors/DashSdkErrorTest.kt +++ b/packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/errors/DashSdkErrorTest.kt @@ -90,6 +90,19 @@ class DashSdkErrorTest { // The message must warn against retrying, like the broadcast sibling. assertTrue(spendUnconfirmed.message!!.contains("do NOT retry")) + // Code 37, NOT 32: 32 is ErrorTransactionBuild (dashpay/platform#4247, + // #4256). This assertion is the mirror's guard against the collision — + // if the Rust discriminant is ever moved back onto a claimed number, + // the host silently reclassifies an already-claimed invite as some + // other branch's error. See ERROR_CODE_REGISTRY.md (#4261). + val inviteClaimed = + DashSdkError.fromNative(DashSDKException(offset + 37, "nullifier already spent")) + assertTrue(inviteClaimed is DashSdkError.PlatformWallet.ShieldedInviteAlreadyClaimed) + assertFalse( + "ShieldedInviteAlreadyClaimed is TERMINAL — the note is consumed", + inviteClaimed.isRetryable, + ) + val broadcastUnconfirmed = DashSdkError.fromNative(DashSDKException(offset + 20, "ambiguous broadcast")) assertTrue( diff --git a/packages/rs-platform-wallet-ffi/src/error.rs b/packages/rs-platform-wallet-ffi/src/error.rs index 89a69b6be73..3234fea67c9 100644 --- a/packages/rs-platform-wallet-ffi/src/error.rs +++ b/packages/rs-platform-wallet-ffi/src/error.rs @@ -254,8 +254,15 @@ pub enum PlatformWalletFFIResultCode { /// this code exists to prevent. Hosts should surface the invitation as spent /// rather than registering any identity. /// - /// Code 32: 27-30 stay reserved for the in-flight branches noted above. - ErrorShieldedInviteAlreadyClaimed = 32, + /// Code 37 — the next free integer per the allocation frontier in + /// `ERROR_CODE_REGISTRY.md` (dashpay/platform#4261). This variant briefly + /// held 32, which is allocated to `ErrorTransactionBuild` + /// (dashpay/platform#4247, also carried by #4256); the two collided as an + /// `E0081` the moment both were merged. 27-36 are all claimed (27 + /// `ErrorShutdownIncomplete`, merged via #4268; 29 #4184; 31 #4183; 32/33 + /// #4247/#4256; 34-36 the #4185 deferred-token trio), and 28/30 are vacated + /// but RESERVED, so 37 is the only correct allocation. + ErrorShieldedInviteAlreadyClaimed = 37, NotFound = 98, // Used exclusively for all the Option that are retuned as errors ErrorUnknown = 99, diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletResult.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletResult.swift index a194918ad55..5902a620373 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletResult.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletResult.swift @@ -76,15 +76,31 @@ public enum PlatformWalletResultCode: Int32, Sendable { /// (Not returned by `destroy`: Rust owns the callback contexts, so a /// straggling worker is memory-safe and merely logged there.) case errorShutdownIncomplete = 27 - // Raw values 28-30 are NOT claimed here: 28 and 30 are reserved (vacated by - // the deferred-payment reservation-token trio on dashpay/platform#4185 / - // #4256 when it moved to 34-36) and 29 belongs to the asset-lock funding - // shortfall on dashpay/platform#4184. + // Raw values 26 (errorTransactionBroadcastRejected, v4.1-dev) and 27 + // (errorShutdownIncomplete, #4268, on v4.2-dev above) are taken. 28-36 + // are claimed by sibling branches and MUST NOT be reused here: 29 the + // asset-lock funding shortfall (#4184), 31 below (#4183), 32/33 + // errorTransactionBuild / errorTransactionSigning (#4247/#4256), 34-36 the + // deferred-payment reservation-token trio (#4185). 28 and 30 are vacated + // but RESERVED. These raw values MUST match `PlatformWalletFFIResultCode` + // in packages/rs-platform-wallet-ffi/src/error.rs — there is no + // compile-time check across the ABI. See ERROR_CODE_REGISTRY.md (#4261). /// A state transition could not be signed because the signer has no /// usable private key for the requested public key — restored from the /// structured signer completion code (dashpay/platform#4060 finding 7). /// Route to key repair; not retryable as-is. case errorSigningKeyUnavailable = 31 + /// A one-time-key (shielded invitation) claim found the invitation note's + /// nullifier already spent on chain, with no positive evidence that this + /// claim created an identity. TERMINAL and NOT retryable — the note is + /// consumed, so no retry can spend it again, and no identity id is + /// produced. Surface the invitation as spent. + /// + /// Raw value 37 is the allocation frontier from ERROR_CODE_REGISTRY.md + /// (dashpay/platform#4261): 32 belongs to `errorTransactionBuild` + /// (#4247/#4256), 34-36 to the #4185 deferred-token trio, and 28/30 are + /// vacated-but-reserved. + case errorShieldedInviteAlreadyClaimed = 37 case notFound = 98 case errorUnknown = 99 @@ -148,6 +164,8 @@ public enum PlatformWalletResultCode: Int32, Sendable { self = .errorShutdownIncomplete case PLATFORM_WALLET_FFI_RESULT_CODE_ERROR_SIGNING_KEY_UNAVAILABLE: self = .errorSigningKeyUnavailable + case PLATFORM_WALLET_FFI_RESULT_CODE_ERROR_SHIELDED_INVITE_ALREADY_CLAIMED: + self = .errorShieldedInviteAlreadyClaimed case PLATFORM_WALLET_FFI_RESULT_CODE_NOT_FOUND: self = .notFound case PLATFORM_WALLET_FFI_RESULT_CODE_ERROR_UNKNOWN: From b6051a40d277128b5e4b0582de189750cd09e0a6 Mon Sep 17 00:00:00 2001 From: bfoss765 <38437574+bfoss765@users.noreply.github.com> Date: Mon, 3 Aug 2026 19:34:55 -0400 Subject: [PATCH 11/14] =?UTF-8?q?fix(shielded-invites):=20review-gate=20ro?= =?UTF-8?q?und=20=E2=80=94=20post-build=20id=20reconcile,=20applied-fallba?= =?UTF-8?q?ck=20verdict,=20terminal=20code=20at=20the=20FFI,=20Swift=20mir?= =?UTF-8?q?ror,=20Orchard=20secret=20scrubbing=20(#4204)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Five blocking findings from the 2026-08-03 gate run, fixed on the rebased head: * a00cee018e73 — the two POST-BUILD `NullifierAlreadySpent` recovery arms (broadcast + result wait) now pass `Some(identity_id)` — the id THIS transition committed — instead of the pre-build `expected_identity_id`, which is deliberately None for a padded single-note bundle. The SDK's broadcast retries internally, so an accepted-then-lost-ack first request legitimately yields NullifierAlreadySpent on the wire retry; with None the reconciler declared our own successfully created identity permanently lost. `expected_identity_id` remains for the pre-build preflight, where the randomized padding id is genuinely unavailable. * 8d020115b274 — the wait-path consensus-verdict arm no longer converts an APPLIED chargeable fallback into ShieldedBroadcastFailed (code 16, documented as definitive non-execution and retryable): a duplicate unique-key hash makes Type 20 apply the chargeable UnshieldAction — nullifiers consumed, fallback address credited minus the penalty — and its PaidConsensusError reaches the wait as a populated cause. The arm now verifies the selected nullifiers first; consumed notes route to the reconciler for the terminal claimed/fallback verdict (recovered success when this claim created the identity, terminal ShieldedInviteAlreadyClaimed for the fallback / a competing holder). * 7be05fde0d09 — the live claim FFI export routes ShieldedInviteAlreadyClaimed through the blanket From conversion (code 37) before the catch-all, which was flattening it to the generic ErrorWalletOperation (6) and made the terminal consumed-invitation discriminator unreachable from the one API that produces it. * 00b4b4d41758 — the Swift mirror is complete and compiles: public `PlatformWalletError.shieldedInviteAlreadyClaimed(String)` case, errorDescription coverage, and the `.errorShieldedInviteAlreadyClaimed` arm in `init(result:)` (the exhaustive switch previously rejected the new enum case). Verified with swiftc -parse. * 1ee08ba70627 — Orchard spend-authority representations are no longer left unscrubbed: a `ScrubOnDrop` guard (volatile per-byte overwrite + fence on every exit path, gated on `needs_drop` absence with a tripwire test) contains the non-zeroizing `SpendingKey` / `SpendAuthorizingKey` in the one-time-key claim (sk dropped right after derivation, ask right after the bundle build — neither survives the network awaits), in `OrchardKeySet::from_seed`, in the one-time keygen acceptance loop, and in `orchard_address_from_spending_key`, which now also takes the scalar BY REFERENCE so callers' Zeroizing buffers are not repeated as plain arrays at the boundary. platform-wallet 672/672, platform-wallet-ffi 228/228, JNI + FFI cargo check clean. Co-Authored-By: Claude Opus 4.8 --- .../src/shielded_send.rs | 17 ++- .../src/wallet/shielded/keys.rs | 130 ++++++++++++++---- .../src/wallet/shielded/operations.rs | 81 +++++++++-- .../PlatformWallet/PlatformWalletResult.swift | 10 ++ 4 files changed, 200 insertions(+), 38 deletions(-) diff --git a/packages/rs-platform-wallet-ffi/src/shielded_send.rs b/packages/rs-platform-wallet-ffi/src/shielded_send.rs index 5b0a3ce260e..72d0d112f87 100644 --- a/packages/rs-platform-wallet-ffi/src/shielded_send.rs +++ b/packages/rs-platform-wallet-ffi/src/shielded_send.rs @@ -995,6 +995,14 @@ pub unsafe extern "C" fn platform_wallet_manager_shielded_identity_create_from_o PlatformWalletFFIResultCode::ErrorShieldedBroadcastFailed, format!("shielded identity-create-from-one-time-key failed: {e}"), ), + // TERMINAL consumed-invitation verdict: route through the blanket + // `From` conversion so the typed code + // (`ErrorShieldedInviteAlreadyClaimed`, 37) survives to the host — + // the catch-all below would flatten it to the generic + // `ErrorWalletOperation` (6), hiding the one discriminator that + // tells a claimer the invitation can never be claimed again + // (#4204 review finding 7be05fde0d09). + Err(e @ PlatformWalletError::ShieldedInviteAlreadyClaimed { .. }) => e.into(), Err(e) => PlatformWalletFFIResult::err( PlatformWalletFFIResultCode::ErrorWalletOperation, format!("shielded identity-create-from-one-time-key failed: {e}"), @@ -1636,13 +1644,14 @@ pub unsafe extern "C" fn platform_wallet_orchard_address_from_spending_key( // Carry the caller-supplied bearer spending key in `Zeroizing` so THIS // frame's copy is scrubbed on drop, on every return path (#4204 key - // hygiene). Note `orchard_address_from_spending_key` takes the key BY - // VALUE, so the callee still makes its own transient copy — this only - // scrubs the caller frame. + // hygiene). `orchard_address_from_spending_key` now takes the key BY + // REFERENCE and contains its own derived `SpendingKey` in a scrub-on-drop + // guard, so no unsanitized copy of the scalar is repeated at this + // boundary (#4204 finding 1ee08ba70627). let mut sk = zeroize::Zeroizing::new([0u8; 32]); std::ptr::copy_nonoverlapping(sk_bytes_32, sk.as_mut_ptr(), 32); - match orchard_address_from_spending_key(*sk) { + match orchard_address_from_spending_key(&sk) { Ok(address) => { std::ptr::copy_nonoverlapping(address.as_ptr(), out_address_43, 43); PlatformWalletFFIResult::ok() diff --git a/packages/rs-platform-wallet/src/wallet/shielded/keys.rs b/packages/rs-platform-wallet/src/wallet/shielded/keys.rs index da20f2a32a0..ac5dd7bc87b 100644 --- a/packages/rs-platform-wallet/src/wallet/shielded/keys.rs +++ b/packages/rs-platform-wallet/src/wallet/shielded/keys.rs @@ -23,6 +23,70 @@ use crate::error::PlatformWalletError; const DASH_COIN_TYPE_MAINNET: u32 = 5; const DASH_COIN_TYPE_TESTNET: u32 = 1; +/// Scrub-on-drop containment for an Orchard SECRET that provides no +/// `Zeroize` support — orchard 0.14's [`SpendingKey`] and +/// [`SpendAuthorizingKey`] are `Copy` types with neither a `Zeroize` impl +/// nor a scrubbing `Drop`, so a plain local holding one leaves the complete +/// spend-authority representation in its stack frame after use (#4204 +/// review finding 1ee08ba70627). +/// +/// The guard owns the value (`Deref` for use) and volatile-overwrites its +/// raw bytes on drop, then fences, so the scrub is not elided as a dead +/// store and runs on EVERY exit path (`?`, early return, panic-unwind). +/// Call sites additionally `drop()` the guard right after the secret's +/// final use so it never survives into long-lived async frames across +/// network awaits. +/// +/// The safety argument is the `needs_drop` gate below: scrubbing is only +/// performed for types with no drop glue (both Orchard key types qualify — +/// `SpendingKey` is `Copy`; `SpendAuthorizingKey` is a plain scalar wrapper +/// with no `Drop`), so overwriting the bytes in place cannot double-free or +/// corrupt owned indirections. A type WITH drop glue is left untouched +/// (its own `Drop` still runs normally) — that would be a silent no-scrub, +/// so the guard is only for the two key types named above. (What no bound +/// can rule out is the caller having made further copies — the guard +/// contains the representation it owns; avoiding stray copies is the call +/// site's job.) +pub(crate) struct ScrubOnDrop(pub(crate) T); + +impl Drop for ScrubOnDrop { + fn drop(&mut self) { + // Const-folded: for the Orchard key types this is `false` and the + // scrub always runs. Overwriting a value that still has drop glue + // to execute would be unsound — skip (see the type-level docs). + if core::mem::needs_drop::() { + return; + } + let ptr = &mut self.0 as *mut T as *mut u8; + for i in 0..core::mem::size_of::() { + // Volatile per-byte overwrite: not removable as a dead store. + unsafe { core::ptr::write_volatile(ptr.add(i), 0) }; + } + core::sync::atomic::compiler_fence(core::sync::atomic::Ordering::SeqCst); + } +} + +impl core::ops::Deref for ScrubOnDrop { + type Target = T; + fn deref(&self) -> &T { + &self.0 + } +} + +#[cfg(test)] +mod scrub_tests { + use super::*; + + /// Both Orchard secret types must stay scrubbable: drop glue appearing on + /// either (an orchard upgrade adding `Drop`) would silently disable the + /// scrub, and this is the tripwire that turns that into a test failure. + #[test] + fn orchard_secret_types_have_no_drop_glue() { + assert!(!core::mem::needs_drop::()); + assert!(!core::mem::needs_drop::()); + } +} + /// ZIP-32 derived Orchard key hierarchy. /// /// Contains the key material needed for shielded sync and address @@ -87,22 +151,27 @@ impl OrchardKeySet { )) })?; - let sk = SpendingKey::from_zip32_seed(seed, coin_type, account_id).map_err(|e| { - PlatformWalletError::ShieldedKeyDerivation(format!("ZIP-32 derivation failed: {}", e)) - })?; - - let fvk = FullViewingKey::from(&sk); - let ask = SpendAuthorizingKey::from(&sk); + let sk = ScrubOnDrop(SpendingKey::from_zip32_seed(seed, coin_type, account_id).map_err( + |e| { + PlatformWalletError::ShieldedKeyDerivation(format!( + "ZIP-32 derivation failed: {}", + e + )) + }, + )?); + + let fvk = FullViewingKey::from(&*sk); + let ask = SpendAuthorizingKey::from(&*sk); let ivk = fvk.to_ivk(Scope::External); let ovk = fvk.to_ovk(Scope::External); let default_address = fvk.address_at(0u32, Scope::External); - // `sk` falls out of scope here. The FVK / ASK / IVK / OVK - // already capture every quantity the wallet needs; spend - // authorization is re-derived transiently from the wallet - // seed via the host signer at sign time. (Orchard - // `SpendingKey` is `Copy`, so explicit zeroization of this - // local would require wrapping in `Zeroizing`; revisit when - // the spend signer lands.) + // The master spending key's final use is behind us: scrub its bytes + // NOW (the [`ScrubOnDrop`] guard volatile-zeroes them) rather than + // letting the representation ride the rest of this frame. The + // FVK / ASK / IVK / OVK already capture every quantity the wallet + // needs; spend authorization is re-derived transiently from the + // wallet seed via the host signer at sign time. + drop(sk); Ok(Self { full_viewing_key: fvk, @@ -241,14 +310,22 @@ pub const ORCHARD_RAW_ADDRESS_LEN: usize = 43; /// is not a valid Orchard `SpendingKey` scalar — the same validity gate /// `identity_create_from_one_time_key` applies to a claimed key. pub fn orchard_address_from_spending_key( - sk_bytes: [u8; 32], + sk_bytes: &[u8; 32], ) -> Result<[u8; ORCHARD_RAW_ADDRESS_LEN], PlatformWalletError> { - let sk: SpendingKey = Option::from(SpendingKey::from_bytes(sk_bytes)).ok_or_else(|| { - PlatformWalletError::ShieldedKeyDerivation( - "spending key is not a valid Orchard SpendingKey".to_string(), - ) - })?; - let fvk = FullViewingKey::from(&sk); + // By-reference parameter: the caller's (typically `Zeroizing`) buffer is + // not repeated as a plain by-value array at this boundary. The one + // unavoidable transient copy is the `from_bytes` argument itself + // (orchard's API takes the array by value); the RESULT is contained in a + // [`ScrubOnDrop`] guard so the non-zeroizing `SpendingKey` representation + // is volatile-scrubbed on every exit path (#4204 finding 1ee08ba70627). + let sk = ScrubOnDrop( + Option::::from(SpendingKey::from_bytes(*sk_bytes)).ok_or_else(|| { + PlatformWalletError::ShieldedKeyDerivation( + "spending key is not a valid Orchard SpendingKey".to_string(), + ) + })?, + ); + let fvk = FullViewingKey::from(&*sk); Ok(fvk.address_at(0u32, Scope::External).to_raw_address_bytes()) } @@ -301,7 +378,12 @@ pub fn generate_one_time_orchard_key( )) })?; if let Some(sk) = Option::::from(SpendingKey::from_bytes(*sk_bytes)) { - let fvk = FullViewingKey::from(&sk); + // Contain the accepted draw's non-zeroizing `SpendingKey` + // representation too — the byte buffer is already `Zeroizing`, + // but this derived form would otherwise die unscrubbed + // (#4204 finding 1ee08ba70627). + let sk = ScrubOnDrop(sk); + let fvk = FullViewingKey::from(&*sk); let address = fvk.address_at(0u32, Scope::External).to_raw_address_bytes(); return Ok((sk_bytes, address)); } @@ -509,7 +591,7 @@ mod tests { #[test] fn one_time_key_generate_roundtrips_to_its_address() { let (sk, address) = generate_one_time_orchard_key().expect("OS RNG available"); - let rederived = orchard_address_from_spending_key(*sk) + let rederived = orchard_address_from_spending_key(&sk) .expect("a freshly generated sk is a valid Orchard SpendingKey"); assert_eq!( address, rederived, @@ -588,8 +670,8 @@ mod tests { #[test] fn address_from_spending_key_is_deterministic() { let (sk, address) = generate_one_time_orchard_key().expect("OS RNG available"); - let a = orchard_address_from_spending_key(*sk).expect("valid sk"); - let b = orchard_address_from_spending_key(*sk).expect("valid sk"); + let a = orchard_address_from_spending_key(&sk).expect("valid sk"); + let b = orchard_address_from_spending_key(&sk).expect("valid sk"); assert_eq!(a, b, "same sk must derive the same address"); assert_eq!( a, address, diff --git a/packages/rs-platform-wallet/src/wallet/shielded/operations.rs b/packages/rs-platform-wallet/src/wallet/shielded/operations.rs index a0973ae9900..3b0c58c6030 100644 --- a/packages/rs-platform-wallet/src/wallet/shielded/operations.rs +++ b/packages/rs-platform-wallet/src/wallet/shielded/operations.rs @@ -1606,14 +1606,30 @@ where // Derive the Orchard key material from the one-time spending key. `from_bytes` // returns a `CtOption`; an invalid scalar means the caller handed us a // non-key, which is a hard input error. - let sk: SpendingKey = Option::from(SpendingKey::from_bytes(*one_time_sk)).ok_or_else(|| { - PlatformWalletError::ShieldedKeyDerivation( - "one-time spending key is not a valid Orchard SpendingKey".to_string(), - ) - })?; - let fvk = FullViewingKey::from(&sk); - let ask = SpendAuthorizingKey::from(&sk); + // + // KEY HYGIENE (#4204 finding 1ee08ba70627): orchard 0.14's `SpendingKey` + // and `SpendAuthorizingKey` are `Copy` types with no `Zeroize` support, so + // holding them as plain locals would leave complete spend-authority + // representations in this LONG-LIVED async frame across every network + // await below. Both are contained in [`super::keys::ScrubOnDrop`] guards + // (volatile-scrubbed on every exit path) and explicitly dropped at their + // final use: `sk` right after the derivations here, `ask` right after the + // bundle build. The `*one_time_sk` deref feeding `from_bytes` is the one + // unavoidable transient copy (orchard's API takes the array by value); the + // `Zeroizing` parameter itself scrubs the wallet-layer buffer on drop. + let sk = super::keys::ScrubOnDrop( + Option::::from(SpendingKey::from_bytes(*one_time_sk)).ok_or_else(|| { + PlatformWalletError::ShieldedKeyDerivation( + "one-time spending key is not a valid Orchard SpendingKey".to_string(), + ) + })?, + ); + let fvk = FullViewingKey::from(&*sk); + let ask = super::keys::ScrubOnDrop(SpendAuthorizingKey::from(&*sk)); let ivk = fvk.to_ivk(Scope::External); + // The spending key's final use is behind us — scrub it before any network + // work; only the spend-auth key must survive to the bundle build. + drop(sk); // Advisory only: the shielded tree has no height→note-index oracle (a chunk's // block_height is the proof-tip height, not per-note inclusion height), so the @@ -1734,6 +1750,10 @@ where ) .await .map_err(|e| PlatformWalletError::ShieldedBuildError(e.to_string()))?; + // The spend-auth key's final use (the bundle build + spend-auth + // signatures above) is behind us — scrub it before the broadcast and + // result wait keep this frame alive across the network. + drop(ask); let identity_id = build.identity_id; @@ -1758,11 +1778,21 @@ where // consumed on chain). Recover the created identity instead of stranding // the retry. Checked before the generic `broadcast_definitely_failed` arm, // which would otherwise classify this consensus rejection as a hard failure. + // + // POST-BUILD, the reconciler gets `Some(identity_id)` — the id THIS + // transition committed — never the pre-build `expected_identity_id` + // (which is deliberately `None` for a padded single-note bundle). The + // SDK's broadcast internally retries requests, so an accepted first + // request whose acknowledgement was lost legitimately produces + // `NullifierAlreadySpent` on the retry; with `None` the reconciler + // would declare our own successfully created identity permanently + // lost (`ShieldedInviteAlreadyClaimed`) instead of recovering it by + // its exact id (#4204 review finding a00cee018e73). Err(e) if is_nullifier_already_spent(&e) => { return recover_executed_one_time_claim( sdk, master_key_hash, - expected_identity_id, + Some(identity_id), &format!("broadcast returned NullifierAlreadySpent: {e}"), ) .await; @@ -1796,17 +1826,48 @@ where // verdict surfacing at wait time proves the claim executed, so recover the // identity rather than reporting a broadcast failure. Ordered before the // generic consensus-rejection arm below (which would classify it as a - // failure). + // failure). Same post-build rule as the broadcast arm: pass the id THIS + // transition committed, never the padding-lossy pre-build one (#4204 + // review finding a00cee018e73). Err(wait_err) if is_nullifier_already_spent(&wait_err) => { return recover_executed_one_time_claim( sdk, master_key_hash, - expected_identity_id, + Some(identity_id), &format!("result wait returned NullifierAlreadySpent: {wait_err}"), ) .await; } Err(dash_sdk::Error::StateTransitionBroadcastError(e)) if e.cause.is_some() => { + // A populated cause is a consensus verdict — but for Type 20 a + // verdict is NOT proof of non-execution: a duplicate unique-key + // hash makes Drive APPLY the chargeable `UnshieldAction` fallback + // (the invitation nullifiers are consumed, the fallback address is + // credited minus the penalty) and record a `PaidConsensusError`, + // which reaches this arm exactly like a plain rejection. Declaring + // `ShieldedBroadcastFailed` then would hand the host code 16 — + // documented as definitive non-execution and safe to retry — for + // an invitation that is already consumed, and every retry would + // burn a ~30s proof to earn `NullifierAlreadySpent`. Check the + // selected nullifiers first: consumed notes prove the transition + // (or its fallback) APPLIED, so hand off to the reconciler for + // the terminal claimed/fallback verdict — it distinguishes "this + // claim created the identity" (recovered as success) from the + // chargeable fallback / competing claim (terminal + // `ShieldedInviteAlreadyClaimed`) (#4204 review finding + // 8d020115b274). + if any_nullifier_spent_on_chain(sdk, &selected_nullifiers).await { + return recover_executed_one_time_claim( + sdk, + master_key_hash, + Some(identity_id), + &format!( + "result wait returned an executed consensus verdict (the invitation \ + notes are spent — applied claim or chargeable fallback): {e}" + ), + ) + .await; + } return Err(PlatformWalletError::ShieldedBroadcastFailed(e.to_string())); } Err(wait_err) => { diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletResult.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletResult.swift index 5902a620373..375bf2f1fd7 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletResult.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletResult.swift @@ -298,6 +298,13 @@ public enum PlatformWalletError: LocalizedError { /// (dashpay/platform#4060 finding 7); route to key repair. Kotlin /// parity: `DashSdkError.PlatformWallet.SigningKeyUnavailable`. case signingKeyUnavailable(String) + /// A one-time-key (shielded invitation) claim found the invitation + /// note's nullifier already spent on chain, with no positive evidence + /// that this claim created an identity. TERMINAL and NOT retryable — + /// the note is consumed, so no retry can spend it again, and no + /// identity id is produced. Surface the invitation as spent. Kotlin + /// parity: `DashSdkError.PlatformWallet.ShieldedInviteAlreadyClaimed`. + case shieldedInviteAlreadyClaimed(String) case notFound(String) case unknown(String) @@ -322,6 +329,7 @@ public enum PlatformWalletError: LocalizedError { .addressNonceMismatch(let m), .shutdownIncomplete(let m), .signingKeyUnavailable(let m), + .shieldedInviteAlreadyClaimed(let m), .notFound(let m), .unknown(let m): return m } @@ -367,6 +375,8 @@ public enum PlatformWalletError: LocalizedError { self = .shutdownIncomplete(detail) case .errorSigningKeyUnavailable: self = .signingKeyUnavailable(detail) + case .errorShieldedInviteAlreadyClaimed: + self = .shieldedInviteAlreadyClaimed(detail) case .notFound: self = .notFound(detail) case .errorUnknown: self = .unknown(detail) } From 806d198a037d26c890c7a3537ba0fc9f083e8abe Mon Sep 17 00:00:00 2001 From: bfoss765 <38437574+bfoss765@users.noreply.github.com> Date: Tue, 4 Aug 2026 11:14:18 -0400 Subject: [PATCH 12/14] fix(platform-wallet): drop this PR's duplicate optional `rand` dependency MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit dashpay/platform#4277 (merged into v4.2-dev) promoted `rand = "0.8"` from a dev-dependency to a mandatory entry in `[dependencies]`. This PR had added its own `rand = { version = "0.8", optional = true }` to the same table for the one-time Orchard key CSPRNG, and because the two lines sit in different parts of the table git merged both without a textual conflict — producing a manifest that cargo rejects outright: error: duplicate key --> packages/rs-platform-wallet/Cargo.toml:75:1 error: failed to load manifest for workspace member `.../packages/rs-platform-wallet` `cargo metadata` fails before any build starts, which is why the Kotlin SDK CI job died in the "Building rs-unified-sdk-jni" step rather than in the tests. `rand` is now unconditionally available, so this PR does not need to declare it at all: remove the optional duplicate and drop the now-invalid `dep:rand` from the `shielded` feature list (cargo rejects `dep:` on a non-optional dependency). `shielded::keys::generate_one_time_orchard_key` keeps using `OsRng` from the same crate at the same major version — no behaviour change. Verified with `cargo metadata`, `cargo check -p platform-wallet` (default and `--features shielded`) and `cargo check -p platform-wallet-ffi --features shielded`. Cargo.lock is unaffected. Co-Authored-By: Claude Opus 4.8 --- packages/rs-platform-wallet/Cargo.toml | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/packages/rs-platform-wallet/Cargo.toml b/packages/rs-platform-wallet/Cargo.toml index c881953af2f..261ad9c8c23 100644 --- a/packages/rs-platform-wallet/Cargo.toml +++ b/packages/rs-platform-wallet/Cargo.toml @@ -69,10 +69,13 @@ zip32 = { version = "0.2.0", default-features = false, optional = true } # Same version as `dash-sdk` so the lockfile resolves a single copy. futures = { version = "0.3.30", optional = true } -# OS CSPRNG (`OsRng`) for one-time Orchard key generation -# (`shielded::keys::generate_one_time_orchard_key`, the inviter side of L2 -# shielded invitations). Same `rand` major the dev-deps / benches already use. -rand = { version = "0.8", optional = true } +# NOTE: the OS CSPRNG (`OsRng`) this crate uses for one-time Orchard key +# generation (`shielded::keys::generate_one_time_orchard_key`, the inviter +# side of L2 shielded invitations) comes from the unconditional `rand = "0.8"` +# in the "Standard dependencies" block above. dashpay/platform#4277 promoted +# `rand` from a dev-dependency to a mandatory one, so this PR no longer +# declares its own optional copy (a second `rand` key in `[dependencies]` is a +# duplicate-key manifest error) and `shielded` no longer lists `dep:rand`. # Networked, opt-in example binaries. Each one performs real network I/O # against a live devnet, so they are examples (compiled, never run by @@ -127,7 +130,7 @@ default = ["bls", "eddsa"] test-utils = ["key-wallet/test-utils"] bls = ["key-wallet/bls", "key-wallet-manager/bls"] eddsa = ["key-wallet/eddsa", "key-wallet-manager/eddsa"] -shielded = ["dep:grovedb-commitment-tree", "dep:rusqlite", "dep:zip32", "dep:futures", "dep:rand", "dash-sdk/shielded", "dpp/shielded-client"] +shielded = ["dep:grovedb-commitment-tree", "dep:rusqlite", "dep:zip32", "dep:futures", "dash-sdk/shielded", "dpp/shielded-client"] # Opt-in serde derives on the changeset types in `src/changeset/` plus # the per-identity / DashPay scalar types those changesets carry. # Activates `key-wallet/serde` (which transitively activates From 6f785bf0a6a6358e8cd02d3903201208aefd0c68 Mon Sep 17 00:00:00 2001 From: bfoss765 <38437574+bfoss765@users.noreply.github.com> Date: Wed, 5 Aug 2026 08:19:06 -0400 Subject: [PATCH 13/14] style(shielded-invites): rustfmt keys.rs ScrubOnDrop wrapping (#4204) The Orchard-secret `ScrubOnDrop(...)` wrapping added in the review-gate round left `keys.rs` with a `cargo fmt --check --all` drift (the `SpendingKey::from_zip32_seed(..).map_err(..)` argument was not re-wrapped to rustfmt's default layout). Purely cosmetic re-wrap; no behavior change. Restores a clean `cargo fmt --check --all` so the Formatting & Linting CI step passes. Co-Authored-By: Claude Opus 4.8 --- packages/rs-platform-wallet/src/wallet/shielded/keys.rs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/rs-platform-wallet/src/wallet/shielded/keys.rs b/packages/rs-platform-wallet/src/wallet/shielded/keys.rs index ac5dd7bc87b..99b4417dc74 100644 --- a/packages/rs-platform-wallet/src/wallet/shielded/keys.rs +++ b/packages/rs-platform-wallet/src/wallet/shielded/keys.rs @@ -151,14 +151,14 @@ impl OrchardKeySet { )) })?; - let sk = ScrubOnDrop(SpendingKey::from_zip32_seed(seed, coin_type, account_id).map_err( - |e| { + let sk = ScrubOnDrop( + SpendingKey::from_zip32_seed(seed, coin_type, account_id).map_err(|e| { PlatformWalletError::ShieldedKeyDerivation(format!( "ZIP-32 derivation failed: {}", e )) - }, - )?); + })?, + ); let fvk = FullViewingKey::from(&*sk); let ask = SpendAuthorizingKey::from(&*sk); From 10514c366b25c234c67a2ada1459ca43fc9fbc62 Mon Sep 17 00:00:00 2001 From: bfoss765 <38437574+bfoss765@users.noreply.github.com> Date: Wed, 5 Aug 2026 08:40:46 -0400 Subject: [PATCH 14/14] fix(platform-wallet): declare rand + log as PR-owned deps (survives base merge) (#4204) The Kotlin SDK native-build CI (which compiles `refs/pull/4204/merge`, i.e. this PR merged into v4.2-dev) failed with: error[E0432]: unresolved import `rand` (shielded/keys.rs) Root cause: v4.2-dev advanced to remove `rand` from `[dependencies]` (it is now dev-only) and to drop `log` from `[dependencies]` entirely. Commit 806d198a03 had removed this PR's own `rand` declaration on the (now-false) premise that base provides `rand` unconditionally. The head still built because its merge-base copy of those lines was present, but the 3-way merge into the advanced base deletes them, leaving the PR's added lib code with no `rand`/`log`: * `shielded::keys::generate_one_time_orchard_key` uses `rand::OsRng` (shielded) * `identity::network::encrypted_document` uses `rand::OsRng` and the `log` facade (`log::debug!`/`log::warn!`) unconditionally Fix: declare `rand = "0.8"` and `log = "0.4"` as this PR's own `[dependencies]` inside the PR-authored comment block (a head-only region base does not have, so it survives the merge), and align the "Standard dependencies" `rand`/`log` lines to base's edited form so those regions merge without conflict or duplicate keys. Manifest-only; no code or feature-gate change. Verified by reproducing the exact CI merge locally (merge head into v4.2-dev tip 5bbd7c9c24) and building platform-wallet + platform-wallet-ffi with `shielded`. Co-Authored-By: Claude Opus 4.8 --- packages/rs-platform-wallet/Cargo.toml | 27 ++++++++++++-------------- 1 file changed, 12 insertions(+), 15 deletions(-) diff --git a/packages/rs-platform-wallet/Cargo.toml b/packages/rs-platform-wallet/Cargo.toml index 261ad9c8c23..a4244e64283 100644 --- a/packages/rs-platform-wallet/Cargo.toml +++ b/packages/rs-platform-wallet/Cargo.toml @@ -24,7 +24,6 @@ dashcore = { workspace = true } thiserror = "1.0" async-trait = "0.1" arc-swap = "1" -rand = "0.8" # Collections bimap = "0.6" @@ -34,14 +33,8 @@ tokio = { version = "1", features = ["sync", "rt", "time", "macros"] } tokio-util = { version = "0.7.12" } dash-async = { path = "../rs-dash-async" } -# Logging. `log` sits alongside `tracing` for on-device (Android) -# diagnostics: the JNI layer installs `android_logger` as the global `log` -# logger (logcat tag `DashSDK`), while the only `tracing` subscriber the -# Kotlin SDK installs (`dash_sdk_enable_logging`) writes to stdout, which -# Android discards — so breadcrumbs that must be visible in logcat are -# emitted through BOTH facades. See `network/encrypted_document.rs`. +# Logging tracing = "0.1" -log = "0.4" # Encoding hex = "0.4" @@ -69,13 +62,17 @@ zip32 = { version = "0.2.0", default-features = false, optional = true } # Same version as `dash-sdk` so the lockfile resolves a single copy. futures = { version = "0.3.30", optional = true } -# NOTE: the OS CSPRNG (`OsRng`) this crate uses for one-time Orchard key -# generation (`shielded::keys::generate_one_time_orchard_key`, the inviter -# side of L2 shielded invitations) comes from the unconditional `rand = "0.8"` -# in the "Standard dependencies" block above. dashpay/platform#4277 promoted -# `rand` from a dev-dependency to a mandatory one, so this PR no longer -# declares its own optional copy (a second `rand` key in `[dependencies]` is a -# duplicate-key manifest error) and `shielded` no longer lists `dep:rand`. +# CSPRNG + `log` facade for this PR's added lib code. `rand`'s `OsRng`/`RngCore` +# back `shielded::keys::generate_one_time_orchard_key` (behind `shielded`) and +# `identity::network::encrypted_document` (unconditional); that module also +# dual-logs through the `log` facade so breadcrumbs reach Android logcat (the +# JNI layer installs `android_logger` as the global `log` logger). base +# v4.2-dev keeps `rand` as a dev-dependency only and does not depend on `log`, +# so this PR declares BOTH as its own runtime dependencies here rather than in +# the "Standard dependencies" block above, whose `rand`/`log` lines base edited +# out — declaring them there would be dropped (or conflict) on merge into base. +rand = "0.8" +log = "0.4" # Networked, opt-in example binaries. Each one performs real network I/O # against a live devnet, so they are examples (compiled, never run by