From 4f41cdc5e504abcc5980d8b30252c84f2613ca19 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/26] 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 20dcda21499..e44fe354ed1 100644 --- a/packages/rs-platform-wallet/Cargo.toml +++ b/packages/rs-platform-wallet/Cargo.toml @@ -62,6 +62,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 @@ -116,7 +121,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 aea65051a01666f91fce1d906cb438bd9b9f9e5c 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/26] 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 9508ee05d0a2ea4dd31cadd982e997ad21804b0c 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/26] 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 c3277437d3f92f75128e540224ece95ae05df379 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/26] 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 fa6e919f48d21bc65aa703f294feda14b4e19f54 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/26] 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 fd4456940c103687f6e5e718dfddc0fcb6066622 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/26] 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 37466773ba11d46563b199757368e745d6268319 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/26] 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 15ff5d2a7c775f7a7c3fcf812b7565b5b9abc7d0 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/26] 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 b3abb044109..862049a26c2 100644 --- a/packages/rs-platform-wallet/src/error.rs +++ b/packages/rs-platform-wallet/src/error.rs @@ -362,6 +362,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 ed9e92578bb7819f5259177d2ed1d2f9c9fe4f4f 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/26] =?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 6676678cb6e..7b2188841b4 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 } @@ -425,7 +454,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())) } } @@ -540,10 +571,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 a38e5e8732caababe2cd731f5b41421917c8e8b4 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/26] 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 7b2188841b4..cc86278123b 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 5c31cf2090f5d7a6c8459833ef3efe4d379063e3 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/26] =?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 a5f4cd80c9b25f8b85d21c085b20cc4f0fff2541 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/26] 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 e44fe354ed1..dbf53233b86 100644 --- a/packages/rs-platform-wallet/Cargo.toml +++ b/packages/rs-platform-wallet/Cargo.toml @@ -62,10 +62,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 @@ -121,7 +124,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 0d818e497019fab65832797e8f30e16fa03ff5c8 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/26] 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 4390cd7a68a5331f5a3c64fd40cf13459d863c18 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/26] 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 --- Cargo.lock | 1 + packages/rs-platform-wallet/Cargo.toml | 18 +++++++++++------- 2 files changed, 12 insertions(+), 7 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index d3c69ffce11..18423b135a4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5209,6 +5209,7 @@ dependencies = [ "image", "key-wallet", "key-wallet-manager", + "log", "platform-encryption", "rand 0.8.6", "rayon", diff --git a/packages/rs-platform-wallet/Cargo.toml b/packages/rs-platform-wallet/Cargo.toml index dbf53233b86..a7b1e5d33b4 100644 --- a/packages/rs-platform-wallet/Cargo.toml +++ b/packages/rs-platform-wallet/Cargo.toml @@ -62,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 From f0b84ee565234240f1885a13a7bc2383a4a039b0 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Thu, 6 Aug 2026 12:44:27 +0700 Subject: [PATCH 15/26] fix(platform-wallet): drop the unused log dependency MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit cargo-machete rejects the direct log dependency: no source in rs-platform-wallet uses the log facade (breadcrumbs go through tracing; the JNI layer bridges tracing, not log). rand stays — OsRng/RngCore back generate_one_time_orchard_key and the contact-info ephemeral keys. Addresses #4313 review finding f3fd60d83554. Co-Authored-By: Claude Fable 5 --- Cargo.lock | 1 - packages/rs-platform-wallet/Cargo.toml | 17 +++++++---------- 2 files changed, 7 insertions(+), 11 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 18423b135a4..d3c69ffce11 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5209,7 +5209,6 @@ dependencies = [ "image", "key-wallet", "key-wallet-manager", - "log", "platform-encryption", "rand 0.8.6", "rayon", diff --git a/packages/rs-platform-wallet/Cargo.toml b/packages/rs-platform-wallet/Cargo.toml index a7b1e5d33b4..b12ea71eef3 100644 --- a/packages/rs-platform-wallet/Cargo.toml +++ b/packages/rs-platform-wallet/Cargo.toml @@ -62,17 +62,14 @@ 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 } -# 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. +# CSPRNG for this PR's added lib code: `rand`'s `OsRng`/`RngCore` back +# `shielded::keys::generate_one_time_orchard_key` (behind `shielded`) and the +# ephemeral keys in `identity::network::contact_info` (unconditional). base +# v4.2-dev keeps `rand` as a dev-dependency only, so this PR declares it as +# its own runtime dependency here rather than in the "Standard dependencies" +# block above, whose `rand` line base edited out — declaring it 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 From c6f4aa71dd69859a08790e977be189551bab8d5a Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Thu, 6 Aug 2026 12:44:28 +0700 Subject: [PATCH 16/26] fix(platform-wallet): durable pending-claim record + tri-state nullifier status for one-time claims MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two claim-lifecycle fixes for identity_create_from_one_time_key (#4313 review findings c0781f9d387f and 8d020115b274): Pending-claim record (persist-first, fail-closed). The claim now arms a persisted record — byte-exact transition, declared identity id, nullifiers, anchor — BEFORE broadcast, keyed deterministically by the one-time FVK under a reserved claim-records subwallet (ONE_TIME_CLAIM_RECORDS_ACCOUNT = u32::MAX, unreachable by the ZIP-32 hardened range and never visited by the spend-redrive sync pass). A retry after process death or JNI cancellation resumes from the record: spent notes reconcile against the DECLARED id (recoverable even for a padded single-note bundle, whose id embeds an unreproducible random dummy nullifier), unspent notes re-drive the byte-identical transition, and a definitively-rejected record with proven-unspent notes is cleared so a fresh build proceeds in the same call. Records clear on terminal outcomes (success / ShieldedInviteAlreadyClaimed) and survive Unconfirmed — the outcome whose retry needs them. Arming failure aborts before broadcast (Persistence error): nothing is consumed yet, and broadcasting without the record risks an unrecoverable ShieldedInviteAlreadyClaimed. Tri-state nullifier status. any_nullifier_spent_on_chain collapsed query errors, absent responses, and partial coverage to "unspent", letting an applied Type-20 chargeable fallback surface as ShieldedBroadcastFailed — documented to hosts as definitive non-execution and safe to retry. nullifier_spent_status now returns Spent/Unspent/Unknown; the consensus-verdict wait arm classifies ShieldedBroadcastFailed only on proven-Unspent, returns Unconfirmed on Unknown, and on proven-Spent hands the reconciler spend_finalized evidence so the nothing-found outcome is the terminal chargeable fallback / competing claim — correct even when the colliding unique key was not MASTER and no identity is findable under either probe. The pre-broadcast preflight still proceeds on Unknown (safe: the idempotent broadcast path reconciles via the NullifierAlreadySpent verdict). The broadcast/wait/classify tail is shared between the fresh and resume paths (broadcast_and_confirm_one_time_claim). Co-Authored-By: Claude Fable 5 --- .../src/wallet/platform_wallet.rs | 1 + .../src/wallet/shielded/operations.rs | 666 +++++++++++++++++- 2 files changed, 633 insertions(+), 34 deletions(-) diff --git a/packages/rs-platform-wallet/src/wallet/platform_wallet.rs b/packages/rs-platform-wallet/src/wallet/platform_wallet.rs index 9f89dfde8a3..2eb31c973ca 100644 --- a/packages/rs-platform-wallet/src/wallet/platform_wallet.rs +++ b/packages/rs-platform-wallet/src/wallet/platform_wallet.rs @@ -1371,6 +1371,7 @@ impl PlatformWallet { super::shielded::operations::identity_create_from_one_time_key( &self.sdk, coordinator.store(), + self.wallet_id, one_time_sk, funding_birth_height, &change_address, diff --git a/packages/rs-platform-wallet/src/wallet/shielded/operations.rs b/packages/rs-platform-wallet/src/wallet/shielded/operations.rs index 3b0c58c6030..93ad7278025 100644 --- a/packages/rs-platform-wallet/src/wallet/shielded/operations.rs +++ b/packages/rs-platform-wallet/src/wallet/shielded/operations.rs @@ -1579,6 +1579,9 @@ where pub async fn identity_create_from_one_time_key( sdk: &Arc, store: &Arc>, + // Claimer's wallet id — keys the durable pending-claim record (under the + // reserved `ONE_TIME_CLAIM_RECORDS_ACCOUNT` subwallet of this wallet). + wallet_id: WalletId, // 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]>, @@ -1652,6 +1655,52 @@ where // key (identity creation requires one, so this is defensive). let master_key_hash = master_auth_public_key_hash(&public_keys); + // 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(); + + // ---- Durable pending-claim resume (#4204 review finding c0781f9d387f) ---- + // + // A claim that broadcast but never confirmed (process death, JNI + // cancellation, lost result wait) left a persisted record carrying the + // byte-exact transition and its declared identity id. Consult it BEFORE + // the transient scan: the record's id survives even for a padded + // single-note bundle (whose id embeds a random dummy nullifier and is + // otherwise unrecoverable), so a retry can reconcile or re-drive the + // byte-identical transition instead of rebuilding one whose preflight + // would misread the spent notes as a foreign claim + // (`ShieldedInviteAlreadyClaimed`). + let claim_records_id = SubwalletId::new(wallet_id, ONE_TIME_CLAIM_RECORDS_ACCOUNT); + let claim_record_key = one_time_claim_record_key(&fvk); + if let Some(record) = + find_one_time_claim_record(store, claim_records_id, claim_record_key).await? + { + match resume_one_time_claim( + sdk, + store, + claim_records_id, + &record, + master_key_hash, + submitted_public_keys.clone(), + denomination, + ) + .await + { + OneTimeClaimResume::Resolved(result) => { + finalize_one_time_claim_record(store, claim_records_id, claim_record_key, &result) + .await; + return result; + } + // The stored transition is unusable (corrupt, or definitively + // rejected while its notes are provably unspent) — the record has + // been cleared; build a fresh claim below. + OneTimeClaimResume::RecordUnusable => {} + } + } + // 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() { @@ -1677,14 +1726,7 @@ where "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(); - - // Idempotent-retry preflight (no persisted record). If this one-time key's + // Idempotent-retry preflight (no persisted record for this key). 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 @@ -1720,11 +1762,15 @@ where // 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 { + // `Unknown` proceeds here — that is safe pre-broadcast: the idempotent + // broadcast path reconciles via the `NullifierAlreadySpent` verdict, so a + // transient query failure only costs a harmless rebuild. + if nullifier_spent_status(sdk, &selected_nullifiers).await == NullifierSpentStatus::Spent { return recover_executed_one_time_claim( sdk, master_key_hash, expected_identity_id, + false, "the selected note's nullifier is already spent on chain (pre-broadcast preflight)", ) .await; @@ -1733,6 +1779,7 @@ where // 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 anchor_bytes = anchor.to_bytes(); let build = build_identity_create_from_shielded_pool_transition( public_keys, @@ -1771,6 +1818,60 @@ where ) .map_err(|e| PlatformWalletError::ShieldedBuildError(e.to_string()))?; + // Persist the pending-claim record BEFORE the broadcast (#4204 review + // finding c0781f9d387f): once the transition leaves this process, the + // declared id — the only handle that recovers a padded single-note claim — + // must already be durable. Fail-closed: nothing has been consumed yet, so + // refusing to broadcast on a persistence failure is a clean, retryable + // stop; broadcasting without the record risks an unrecoverable + // `ShieldedInviteAlreadyClaimed` on the next attempt. + arm_one_time_claim_record( + store, + claim_records_id, + claim_record_key, + anchor_bytes, + &selected_nullifiers, + &st, + ) + .await?; + + let result = broadcast_and_confirm_one_time_claim( + sdk, + st, + identity_id, + expected_identity_id, + master_key_hash, + &selected_nullifiers, + submitted_public_keys, + denomination, + ) + .await; + finalize_one_time_claim_record(store, claim_records_id, claim_record_key, &result).await; + result +} + +/// Broadcast an assembled one-time-key claim transition and drive it to a +/// classified outcome: proven success, idempotent recovery of an +/// already-executed claim, terminal `ShieldedInviteAlreadyClaimed`, definitive +/// `ShieldedBroadcastFailed`, or retryable `ShieldedBroadcastUnconfirmed`. +/// +/// Shared by the fresh-build path and the pending-claim resume path +/// (`resume_one_time_claim`), which re-broadcasts the persisted byte-identical +/// transition. `identity_id` is the id the transition DECLARES; +/// `expected_identity_id` is the pre-build re-derivable id (`None` for a +/// padded single-note bundle on the fresh path; always `Some` on the resume +/// path, where the declared id was recovered from the record). +#[allow(clippy::too_many_arguments)] +async fn broadcast_and_confirm_one_time_claim( + sdk: &Arc, + st: StateTransition, + identity_id: Identifier, + expected_identity_id: Option, + master_key_hash: Option<[u8; 20]>, + claim_nullifiers: &[[u8; 32]], + submitted_public_keys: BTreeMap, + denomination: u64, +) -> Result<(Identifier, Identity), PlatformWalletError> { match st.broadcast(sdk, None).await { Ok(()) => {} // A `NullifierAlreadySpent` verdict is NOT a failure on this path: it is @@ -1793,6 +1894,7 @@ where sdk, master_key_hash, Some(identity_id), + false, &format!("broadcast returned NullifierAlreadySpent: {e}"), ) .await; @@ -1834,6 +1936,7 @@ where sdk, master_key_hash, Some(identity_id), + false, &format!("result wait returned NullifierAlreadySpent: {wait_err}"), ) .await; @@ -1856,19 +1959,48 @@ where // 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; + // + // The three spent-status outcomes diverge here and only `Unspent` + // may produce `ShieldedBroadcastFailed`: the host documents that + // code as definitive non-execution and safe to retry, so it + // requires PROOF the notes are unconsumed. `Unknown` (query + // failure / partial response) yields `ShieldedBroadcastUnconfirmed` + // instead — the armed pending-claim record lets a later retry + // reconcile with the exact id once the status is queryable. + match nullifier_spent_status(sdk, claim_nullifiers).await { + NullifierSpentStatus::Spent => { + // `spend_finalized = true`: this claim's own wait returned a + // definitive verdict AND the notes are proven consumed, so + // "no identity carries our bindings" is the terminal + // chargeable-fallback / competing-claim outcome — even when + // the colliding unique key was not MASTER and no identity is + // findable under either probe. + return recover_executed_one_time_claim( + sdk, + master_key_hash, + Some(identity_id), + true, + &format!( + "result wait returned an executed consensus verdict (the invitation \ + notes are spent — applied claim or chargeable fallback): {e}" + ), + ) + .await; + } + NullifierSpentStatus::Unspent => { + return Err(PlatformWalletError::ShieldedBroadcastFailed(e.to_string())); + } + NullifierSpentStatus::Unknown => { + return Err(PlatformWalletError::ShieldedBroadcastUnconfirmed { + identity_id, + reason: format!( + "consensus verdict received but the invitation notes' spent status \ + could not be established; not classifying as a definitive failure \ + (an applied chargeable fallback would be indistinguishable): {e}" + ), + }); + } } - return Err(PlatformWalletError::ShieldedBroadcastFailed(e.to_string())); } Err(wait_err) => { warn!( @@ -1971,6 +2103,232 @@ where Ok((identity.id(), identity)) } +/// The synthetic ZIP-32 account index that keys durable one-time-claim records +/// in the [`ShieldedStore`]. +/// +/// Claim records reuse the store's persisted [`PendingRedrive`] rows (byte-exact +/// transition + nullifiers + anchor), but live under this reserved subwallet so +/// the spend-redrive sync pass — which iterates REAL Orchard accounts — never +/// re-broadcasts or prunes them; their lifecycle is owned entirely by +/// [`identity_create_from_one_time_key`]. ZIP-32 account indices are hardened +/// (`< 2^31`), so `u32::MAX` cannot collide with a real subwallet. +pub(super) const ONE_TIME_CLAIM_RECORDS_ACCOUNT: u32 = u32::MAX; + +/// Deterministic record key for a one-time claim: every retry of the same +/// invitation re-derives the same key from the one-time FVK, which is exactly +/// what lets a retry find the record a crashed attempt left behind. Domain- +/// separated so it can never collide with an activity-entry id (sha256 of +/// visible output cmxs) sharing the `PendingRedrive.activity_id` keyspace. +fn one_time_claim_record_key(fvk: &grovedb_commitment_tree::FullViewingKey) -> [u8; 32] { + use dashcore::hashes::{sha256, Hash}; + + let mut preimage = Vec::with_capacity(96 + 33); + preimage.extend_from_slice(b"platform-wallet:one-time-claim:v1"); + preimage.extend_from_slice(&fvk.to_bytes()); + sha256::Hash::hash(&preimage).to_byte_array() +} + +/// Look up the persisted pending-claim record for `key`. Fail-closed on a +/// store read error: proceeding to a fresh build while a record might exist +/// is exactly the unrecoverable-padded-claim hazard the record prevents. +async fn find_one_time_claim_record( + store: &Arc>, + id: SubwalletId, + key: [u8; 32], +) -> Result, PlatformWalletError> { + store + .read() + .await + .pending_redrives(id) + .map(|records| records.into_iter().find(|r| r.activity_id == key)) + .map_err(|e| { + PlatformWalletError::Persistence(format!( + "pending one-time-claim record lookup failed; refusing to build a fresh claim \ + while an earlier attempt's record may exist: {e}" + )) + }) +} + +/// Persist the pending-claim record. Called BEFORE the broadcast; a failure +/// aborts the claim (fail-closed — see the call site). +async fn arm_one_time_claim_record( + store: &Arc>, + id: SubwalletId, + key: [u8; 32], + anchor: [u8; 32], + nullifiers: &[[u8; 32]], + st: &StateTransition, +) -> Result<(), PlatformWalletError> { + use dpp::serialization::PlatformSerializable; + + let st_bytes = st + .serialize_to_bytes() + .map_err(|e| PlatformWalletError::ShieldedBuildError(e.to_string()))?; + store + .write() + .await + .arm_redrive( + id, + PendingRedrive { + activity_id: key, + anchor, + nullifiers: nullifiers.to_vec(), + st_bytes, + attempts: 0, + }, + ) + .map_err(|e| { + PlatformWalletError::Persistence(format!( + "failed to persist the pending one-time-claim record before broadcast: {e}" + )) + }) +} + +/// Drop the pending-claim record. Best-effort: a failure only means the next +/// attempt resumes a settled record, which re-resolves to the same outcome. +async fn clear_one_time_claim_record( + store: &Arc>, + id: SubwalletId, + key: [u8; 32], +) { + if let Err(e) = store.write().await.clear_redrive(id, &key) { + warn!( + error = %e, + "one-time claim: failed to clear the pending-claim record" + ); + } +} + +/// Clear the pending-claim record when `result` settles the claim: a recovered +/// or confirmed identity (`Ok`) and the terminal `ShieldedInviteAlreadyClaimed` +/// both mean no future retry needs the record. Every other error keeps it — +/// `ShieldedBroadcastUnconfirmed` (and unproven failures) are exactly the +/// outcomes whose retry must find the declared id again. +async fn finalize_one_time_claim_record( + store: &Arc>, + id: SubwalletId, + key: [u8; 32], + result: &Result<(Identifier, Identity), PlatformWalletError>, +) { + if matches!( + result, + Ok(_) | Err(PlatformWalletError::ShieldedInviteAlreadyClaimed { .. }) + ) { + clear_one_time_claim_record(store, id, key).await; + } +} + +/// Outcome of attempting to resume a persisted pending claim. +enum OneTimeClaimResume { + /// The record drove the claim to an outcome — return it to the caller. + Resolved(Result<(Identifier, Identity), PlatformWalletError>), + /// The record cannot drive an outcome (corrupt, wrong transition type, or + /// definitively rejected with its notes proven unspent). It has been + /// cleared; the caller builds a fresh claim. + RecordUnusable, +} + +/// Resume a claim from its persisted record (#4204 review finding +/// c0781f9d387f): recover by the DECLARED id when the notes are already +/// consumed, otherwise re-broadcast the byte-identical stored transition — +/// never rebuild while the record is live, because a rebuilt padded bundle +/// derives a fresh random id and orphans the recorded one. +async fn resume_one_time_claim( + sdk: &Arc, + store: &Arc>, + claim_records_id: SubwalletId, + record: &PendingRedrive, + master_key_hash: Option<[u8; 20]>, + submitted_public_keys: BTreeMap, + denomination: u64, +) -> OneTimeClaimResume { + use dpp::serialization::PlatformDeserializable; + use dpp::state_transition::state_transitions::shielded::identity_create_from_shielded_pool_transition::accessors::IdentityCreateFromShieldedPoolTransitionAccessorsV0; + + let st = match StateTransition::deserialize_from_bytes(&record.st_bytes) { + Ok(st) => st, + Err(e) => { + warn!( + error = %e, + "one-time claim resume: stored transition failed to deserialize; dropping the \ + record and rebuilding" + ); + clear_one_time_claim_record(store, claim_records_id, record.activity_id).await; + return OneTimeClaimResume::RecordUnusable; + } + }; + let declared_id = match &st { + StateTransition::IdentityCreateFromShieldedPool(t) => t.identity_id(), + other => { + warn!( + transition = %other.name(), + "one-time claim resume: stored record does not carry a shielded identity-create \ + transition; dropping the record and rebuilding" + ); + clear_one_time_claim_record(store, claim_records_id, record.activity_id).await; + return OneTimeClaimResume::RecordUnusable; + } + }; + + info!( + declared_id = %declared_id, + nullifiers = record.nullifiers.len(), + "one-time claim: resuming from the persisted pending-claim record" + ); + + let status = nullifier_spent_status(sdk, &record.nullifiers).await; + if status == NullifierSpentStatus::Spent { + // The recorded claim (or a competitor) already consumed the notes. + // The DECLARED id — unrecoverable without the record for a padded + // bundle — lets the reconciler bind a created identity to this claim. + return OneTimeClaimResume::Resolved( + recover_executed_one_time_claim( + sdk, + master_key_hash, + Some(declared_id), + false, + "resume: the recorded pending claim's notes are already spent on chain", + ) + .await, + ); + } + + // Unspent or Unknown: re-drive the byte-identical transition through the + // same broadcast/confirm classification as a fresh claim. Byte-identical + // re-broadcast is fund-safe (identical nullifiers cannot double-spend) and + // preserves the recorded id. + let result = broadcast_and_confirm_one_time_claim( + sdk, + st, + declared_id, + Some(declared_id), + master_key_hash, + &record.nullifiers, + submitted_public_keys, + denomination, + ) + .await; + + if status == NullifierSpentStatus::Unspent { + if let Err(PlatformWalletError::ShieldedBroadcastFailed(reason)) = &result { + // Definitive rejection of the STORED transition while its notes + // are proven unconsumed (e.g. its anchor aged out of Platform's + // recorded set): this record can never land. Clear it and build a + // fresh claim in this same call. + warn!( + declared_id = %declared_id, + reason, + "one-time claim resume: stored transition is definitively rejected and its notes \ + are unspent; dropping the record and rebuilding" + ); + clear_one_time_claim_record(store, claim_records_id, record.activity_id).await; + return OneTimeClaimResume::RecordUnusable; + } + } + + OneTimeClaimResume::Resolved(result) +} + /// Whether a failed identity-create should release the notes reserved for it. /// /// `false` ONLY for [`PlatformWalletError::ShieldedBroadcastUnconfirmed`]: the broadcast was @@ -2877,33 +3235,79 @@ 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 +/// On-chain spent status of a claim's nullifier set, as far as a single +/// query can establish it. +/// +/// The three states matter because callers draw OPPOSITE conclusions from +/// them: `Spent` proves the invitation notes are consumed (something +/// executed), `Unspent` proves nothing has consumed them yet, and +/// `Unknown` proves NOTHING — a transport failure or an absent response +/// must never be read as either of the other two (#4204 review finding +/// 8d020115b274). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum NullifierSpentStatus { + /// At least one queried nullifier is proof-verified spent. + Spent, + /// The query succeeded and covered every queried nullifier; none is spent. + Unspent, + /// The query failed, returned no response, or covered only part of the + /// queried set — no conclusion can be drawn. + Unknown, +} + +/// Classify a successful nullifier-status response against the queried set. +/// +/// A response that omits some queried nullifiers proves nothing about the +/// omitted ones, so it downgrades an all-unspent answer to `Unknown`. +fn classify_nullifier_statuses( + statuses: &[dash_sdk::query_types::ShieldedNullifierStatus], + queried: &[[u8; 32]], +) -> NullifierSpentStatus { + if statuses.iter().any(|s| s.is_spent) { + return NullifierSpentStatus::Spent; + } + let covered = queried + .iter() + .all(|q| statuses.iter().any(|s| &s.nullifier == q)); + if covered { + NullifierSpentStatus::Unspent + } else { + NullifierSpentStatus::Unknown + } +} + +/// On-chain check: are `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 { +/// A query error or an absent response is [`NullifierSpentStatus::Unknown`], +/// never `Unspent`: the pre-broadcast preflight may treat unknown as +/// "proceed" (the idempotent broadcast path reconciles via the +/// `NullifierAlreadySpent` verdict, so that only costs a harmless rebuild), +/// but the post-verdict classification must NOT — declaring a definitive +/// non-execution on an unknown status would report an applied chargeable +/// fallback as retryable. +async fn nullifier_spent_status( + sdk: &Arc, + nullifiers: &[[u8; 32]], +) -> NullifierSpentStatus { use dash_sdk::platform::Fetch; use dash_sdk::query_types::{ShieldedNullifierStatuses, ShieldedNullifiersQuery}; if nullifiers.is_empty() { - return false; + return NullifierSpentStatus::Unspent; } match ShieldedNullifierStatuses::fetch(sdk, ShieldedNullifiersQuery(nullifiers.to_vec())).await { - Ok(Some(statuses)) => statuses.0.iter().any(|s| s.is_spent), - Ok(None) => false, + Ok(Some(statuses)) => classify_nullifier_statuses(&statuses.0, nullifiers), + Ok(None) => NullifierSpentStatus::Unknown, Err(e) => { warn!( error = %e, - "IdentityCreateFromOneTimeKey: nullifier spent-status query failed; treating as \ - unknown and proceeding to the idempotent broadcast path" + "IdentityCreateFromOneTimeKey: nullifier spent-status query failed; status unknown" ); - false + NullifierSpentStatus::Unknown } } } @@ -3063,10 +3467,22 @@ async fn fetch_identity_by_key_hash_with_retries( /// 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. +/// +/// `spend_finalized` — the caller holds POSITIVE evidence that this claim's own +/// broadcast reached a definitive consensus verdict AND the notes are proven +/// consumed. Under that evidence, "no identity carries this claim's bindings" +/// is not indexing lag: an applied Type-20 that returned an error verdict +/// created no identity (the chargeable `UnshieldAction` fallback), and the +/// colliding unique key need not be MASTER — a collision on any other submitted +/// unique key leaves NOTHING findable under the MASTER-hash probe or the +/// derived id. The nothing-found outcome is then the terminal +/// `ShieldedInviteAlreadyClaimed`, not `ShieldedBroadcastUnconfirmed` +/// (#4204 review finding 8d020115b274). async fn recover_executed_one_time_claim( sdk: &Arc, master_key_hash: Option<[u8; 20]>, expected_identity_id: Option, + spend_finalized: bool, evidence: &str, ) -> Result<(Identifier, Identity), PlatformWalletError> { warn!( @@ -3150,6 +3566,23 @@ async fn recover_executed_one_time_claim( }); } + if spend_finalized { + // Both probes came up empty under a definitive verdict + proven-spent + // notes: the spend finalized without creating an identity that carries + // this claim's bindings. That is the chargeable-`UnshieldAction` + // fallback (the collision may have been on any submitted unique key, + // not just MASTER) or a competing claim — terminal either way; the + // value, if any, went to the creation-failure address. + return Err(PlatformWalletError::ShieldedInviteAlreadyClaimed { + reason: format!( + "the claim's consensus verdict is definitive and the invitation notes are spent, \ + but no identity carries this claim's bindings; the spend was finalized as a \ + chargeable failure (or a competing claim) and created no identity for this \ + wallet: {evidence}" + ), + }); + } + Err(PlatformWalletError::ShieldedBroadcastUnconfirmed { identity_id: expected_id, reason: format!( @@ -3388,6 +3821,171 @@ mod redrive_tests { } } +#[cfg(test)] +mod nullifier_status_and_claim_record_tests { + use super::*; + use crate::wallet::shielded::store::InMemoryShieldedStore; + use dash_sdk::query_types::ShieldedNullifierStatus; + + fn status(nullifier: [u8; 32], is_spent: bool) -> ShieldedNullifierStatus { + ShieldedNullifierStatus { + nullifier, + is_spent, + } + } + + /// Any spent entry wins regardless of coverage: `Spent` is positive proof. + #[test] + fn classify_any_spent_is_spent() { + let queried = [[1u8; 32], [2u8; 32]]; + let statuses = vec![status([1u8; 32], false), status([2u8; 32], true)]; + assert_eq!( + classify_nullifier_statuses(&statuses, &queried), + NullifierSpentStatus::Spent + ); + } + + /// All queried nullifiers covered and none spent — proven unspent. + #[test] + fn classify_full_coverage_unspent_is_unspent() { + let queried = [[1u8; 32], [2u8; 32]]; + let statuses = vec![status([1u8; 32], false), status([2u8; 32], false)]; + assert_eq!( + classify_nullifier_statuses(&statuses, &queried), + NullifierSpentStatus::Unspent + ); + } + + /// A response that omits a queried nullifier proves nothing about it: + /// partial coverage must NOT read as `Unspent` — that is the path that + /// would misreport an applied chargeable fallback as a retryable + /// non-execution (#4204 review finding 8d020115b274). + #[test] + fn classify_partial_coverage_is_unknown() { + let queried = [[1u8; 32], [2u8; 32]]; + let statuses = vec![status([1u8; 32], false)]; + assert_eq!( + classify_nullifier_statuses(&statuses, &queried), + NullifierSpentStatus::Unknown + ); + assert_eq!( + classify_nullifier_statuses(&[], &queried), + NullifierSpentStatus::Unknown + ); + } + + /// The record key is deterministic per one-time key (a retry must find the + /// record a crashed attempt armed) and distinct across keys. + #[test] + fn claim_record_key_is_deterministic_and_distinct() { + use grovedb_commitment_tree::{FullViewingKey, SpendingKey}; + + let fvk = |b: u8| { + let sk = Option::::from(SpendingKey::from_bytes([b; 32])) + .expect("test byte pattern must be a valid spending key"); + FullViewingKey::from(&sk) + }; + let a = fvk(1); + let b = fvk(2); + assert_eq!(one_time_claim_record_key(&a), one_time_claim_record_key(&a)); + assert_ne!(one_time_claim_record_key(&a), one_time_claim_record_key(&b)); + } + + /// Arm → find → clear round-trip through the reserved claim-records + /// subwallet, and `finalize_one_time_claim_record`'s settlement rule: + /// terminal `ShieldedInviteAlreadyClaimed` clears the record, while + /// `ShieldedBroadcastUnconfirmed` — the outcome whose retry NEEDS the + /// record — keeps it (#4204 review finding c0781f9d387f). + #[tokio::test] + async fn claim_record_round_trip_and_finalize_rules() { + let store = Arc::new(RwLock::new(InMemoryShieldedStore::new())); + let wallet_id = [7u8; 32]; + let id = SubwalletId::new(wallet_id, ONE_TIME_CLAIM_RECORDS_ACCOUNT); + let key = [0xA5u8; 32]; + + assert!(find_one_time_claim_record(&store, id, key) + .await + .expect("lookup must succeed") + .is_none()); + + { + let mut guard = store.write().await; + guard + .arm_redrive( + id, + PendingRedrive { + activity_id: key, + anchor: [9u8; 32], + nullifiers: vec![[3u8; 32]], + st_bytes: vec![1, 2, 3], + attempts: 0, + }, + ) + .expect("arm must succeed"); + } + let found = find_one_time_claim_record(&store, id, key) + .await + .expect("lookup must succeed") + .expect("armed record must be found"); + assert_eq!(found.nullifiers, vec![[3u8; 32]]); + + // Unconfirmed keeps the record — its retry needs the declared id. + let unconfirmed: Result<(Identifier, Identity), PlatformWalletError> = + Err(PlatformWalletError::ShieldedBroadcastUnconfirmed { + identity_id: Identifier::new([1u8; 32]), + reason: "test".to_string(), + }); + finalize_one_time_claim_record(&store, id, key, &unconfirmed).await; + assert!(find_one_time_claim_record(&store, id, key) + .await + .expect("lookup must succeed") + .is_some()); + + // Terminal AlreadyClaimed settles it. + let terminal: Result<(Identifier, Identity), PlatformWalletError> = + Err(PlatformWalletError::ShieldedInviteAlreadyClaimed { + reason: "test".to_string(), + }); + finalize_one_time_claim_record(&store, id, key, &terminal).await; + assert!(find_one_time_claim_record(&store, id, key) + .await + .expect("lookup must succeed") + .is_none()); + } + + /// A corrupt stored transition must not wedge the claim: the resume path + /// drops the record (so the fresh build proceeds) without touching the + /// network (the mock SDK has no expectations — any fetch would error). + #[tokio::test] + async fn resume_drops_corrupt_record_and_rebuilds() { + let sdk = Arc::new(dash_sdk::SdkBuilder::new_mock().build().expect("mock sdk")); + let store = Arc::new(RwLock::new(InMemoryShieldedStore::new())); + let wallet_id = [8u8; 32]; + let id = SubwalletId::new(wallet_id, ONE_TIME_CLAIM_RECORDS_ACCOUNT); + let key = [0x5Au8; 32]; + let record = PendingRedrive { + activity_id: key, + anchor: [0u8; 32], + nullifiers: vec![[4u8; 32]], + st_bytes: vec![0xDE, 0xAD], // never deserializes + attempts: 0, + }; + store + .write() + .await + .arm_redrive(id, record.clone()) + .expect("arm must succeed"); + + let outcome = + resume_one_time_claim(&sdk, &store, id, &record, None, BTreeMap::new(), 100_000).await; + assert!(matches!(outcome, OneTimeClaimResume::RecordUnusable)); + assert!(find_one_time_claim_record(&store, id, key) + .await + .expect("lookup must succeed") + .is_none()); + } +} + #[cfg(test)] mod classify_spend_wait_failure_tests { use super::*; From a4234abd51db9ed68ceb4e44bb1df3ab01127cf9 Mon Sep 17 00:00:00 2001 From: bfoss765 Date: Wed, 12 Aug 2026 08:45:43 -0400 Subject: [PATCH 17/26] docs(platform-wallet-ffi): correct the 'always succeeds' claim on one-time key generation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The doc on platform_wallet_generate_one_time_orchard_key stated it always succeeds, but the function returns ErrorWalletOperation when the underlying generate_one_time_orchard_key fails (an OS entropy failure in try_fill_bytes). A caller trusting that line could skip the result check. State the real contract: re-rolling makes an INVALID key impossible, but the call itself can still fail — always check the result code. Addresses #4313 review thread at shielded_send.rs:1583 (CodeRabbit cr-comment 5b08c094f1096d57ab53741b). --- packages/rs-platform-wallet-ffi/src/shielded_send.rs | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/packages/rs-platform-wallet-ffi/src/shielded_send.rs b/packages/rs-platform-wallet-ffi/src/shielded_send.rs index 72d0d112f87..a44d8d690f3 100644 --- a/packages/rs-platform-wallet-ffi/src/shielded_send.rs +++ b/packages/rs-platform-wallet-ffi/src/shielded_send.rs @@ -1580,8 +1580,12 @@ fn resolve_wallet_and_coordinator( /// [`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). +/// The generator re-rolls until it draws a valid scalar, so an invalid key is +/// never returned — but the call itself can still fail: an OS entropy failure +/// in the underlying RNG surfaces as [`ErrorWalletOperation`] (never a panic +/// across the C ABI). Always check the result code. /// +/// [`ErrorWalletOperation`]: crate::error::PlatformWalletFFIResultCode::ErrorWalletOperation /// [`platform_wallet_manager_shielded_default_address`]: crate::platform_wallet_manager_shielded_default_address /// /// # Safety From 4e9cc52a761b773db08be47a308755fa950debe3 Mon Sep 17 00:00:00 2001 From: bfoss765 Date: Wed, 12 Aug 2026 08:46:28 -0400 Subject: [PATCH 18/26] fix(platform-wallet): truthful terminal reason when no master key hash is resolvable MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Handle 2 of recover_executed_one_time_claim reported every binding failure as 'belongs to another holder of the one-time key', but recovered_identity_matches_claim also fails closed when master_key_hash is None (no MASTER auth key submitted, or public_key_hash() errored for an unusual key type) — before it inspects any binding. In that case the key binding can never be established, which is not evidence of a competing holder. ShieldedInviteAlreadyClaimed is terminal, so this reason text is the only diagnostic the user gets for a permanently unclaimable invitation. Distinguish the None case: still terminal (a retry resubmits the same key set), but the reason now says ownership cannot be verified rather than misattributing the identity to another holder. The outcome doc gains the new cause. Addresses #4313 review thread at operations.rs:3567 (CodeRabbit cr-comment c96e9b63c7a921f8d43c57ac). --- .../src/wallet/shielded/operations.rs | 30 +++++++++++++++++-- 1 file changed, 27 insertions(+), 3 deletions(-) diff --git a/packages/rs-platform-wallet/src/wallet/shielded/operations.rs b/packages/rs-platform-wallet/src/wallet/shielded/operations.rs index 93ad7278025..3a08b211a90 100644 --- a/packages/rs-platform-wallet/src/wallet/shielded/operations.rs +++ b/packages/rs-platform-wallet/src/wallet/shielded/operations.rs @@ -3460,9 +3460,11 @@ async fn fetch_identity_by_key_hash_with_retries( /// 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. +/// fetched but failed a binding (chargeable fallback, a competing holder of +/// the same bearer key, or — when `master_key_hash` is `None` — a key binding +/// that can never be established for this claim), *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`, @@ -3549,6 +3551,28 @@ async fn recover_executed_one_time_claim( ); return Ok((identity.id(), identity)); } + // `recovered_identity_matches_claim` also fails closed when NO master + // auth key hash was resolvable from the submitted keys (`master_key_hash + // == None` — nothing was submitted, or `public_key_hash()` errored for + // an unusual key type). The key binding can then never be established + // for this claim, which is NOT evidence of a competing holder — report + // the real cause. Terminal either way: the note is spent, and a retry + // resubmits the same key set, so the hash stays unresolvable. + if master_key_hash.is_none() { + warn!( + derived_id = %expected_id, + "IdentityCreateFromOneTimeKey: an identity exists at this claim's derived id but \ + this claim carries no resolvable master auth key hash, so ownership can be \ + neither proven nor disproven" + ); + return Err(PlatformWalletError::ShieldedInviteAlreadyClaimed { + reason: format!( + "identity {expected_id} was created from this invitation's notes, but this \ + claim submitted no resolvable master authentication key hash, so its \ + ownership cannot be verified: {evidence}" + ), + }); + } // 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!( From 2dc71c9def9978026996abcff101f123d9177321 Mon Sep 17 00:00:00 2001 From: bfoss765 Date: Wed, 12 Aug 2026 08:46:56 -0400 Subject: [PATCH 19/26] fix(platform-wallet): bound repeated foreign-key scans with a process-local resume checkpoint MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit scan_notes_for_foreign_key (the L2-invitation claim path) restarted the proof-verified note stream at position zero on every call, with value coverage as the only early exit — so a syntactically valid but UNFUNDED invitation key (attacker-controlled input) forced a full-history download, verify, and trial-decrypt of the entire shielded pool on every attempt, repeatable at will (#4313 review finding d19c5cf84a9f). The tree exposes no height-to-position oracle (a chunk's block_height is the proof-tip height, not per-note inclusion height), so the invitation's birth-height hint cannot seed the scan start, and any budget that stops short of the tip would misreport a deep-but-valid invite as unfunded. Bound the REPEAT instead of the coverage: a process-local checkpoint keyed by sha256(domain-tag || one-time FVK) records how far the tree has been covered for each key plus the notes found on that covered prefix. The first scan for a key still covers the full history from position 0 (funds-safety: a resumed scan can never miss a note a from-zero scan would have found), and every later scan for the same key resumes past the immutable full chunks it already covered — one full-history scan per key per process, after which each retry pays only new tree growth plus the mutable buffer chunk. Mechanics: - The resume position advances past full chunks only, and is held AT a partial (buffer) chunk's start_index — the same resume rule the subwallet sync applies via ShieldedChunkBatch::is_partial — because that chunk can still receive notes. Buffer-chunk notes are never carried in the checkpoint, so the rescan cannot duplicate them. - The resume position is re-aligned DOWN to the on-chain MMR chunk boundary (CHUNK_SIZE) on use, so a resume can only over-scan, never skip. - Progress is checkpointed on every exit path, including a mid-scan stream error, so an interrupted retry resumes rather than restarting. - The map is LRU-bounded (8 keys); hostile key churn cannot pin memory, and an evicted key merely re-pays its own full scan. Deliberately process-local: no persisted state to invalidate. Native cancellation of the synchronous JNI scan remains a follow-up (it is a JNI-surface change); this closes the repeat-amplification path, complementing c6f4aa71dd (broadcast-claim retries already skip the transient rescan via the durable pending-claim record). Adds unit tests for the checkpoint carry/drop rule and the map's take/save/evict semantics. Addresses #4313 review thread at sync.rs:860 (codex finding d19c5cf84a9f). --- .../src/wallet/shielded/operations.rs | 9 +- .../src/wallet/shielded/sync.rs | 279 +++++++++++++++++- 2 files changed, 274 insertions(+), 14 deletions(-) diff --git a/packages/rs-platform-wallet/src/wallet/shielded/operations.rs b/packages/rs-platform-wallet/src/wallet/shielded/operations.rs index 3a08b211a90..c92d47de83c 100644 --- a/packages/rs-platform-wallet/src/wallet/shielded/operations.rs +++ b/packages/rs-platform-wallet/src/wallet/shielded/operations.rs @@ -1571,7 +1571,8 @@ where /// /// `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). +/// oracle, so it cannot seed the scan start today; repeated attempts are +/// bounded by the scan's process-local resume checkpoint instead). /// /// Returns the new identity's id and the proof-verified [`Identity`]; the caller /// registers that identity in its local `IdentityManager`. @@ -1636,8 +1637,10 @@ where // 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. + // transient scan cannot seed its start from a height; it bounds itself by + // value coverage plus a process-local resume checkpoint (one full-history + // scan per key per process — see `scan_notes_for_foreign_key`). Logged so + // the hint is observable and not silently dropped. if let Some(h) = funding_birth_height { debug!( funding_birth_height = h, diff --git a/packages/rs-platform-wallet/src/wallet/shielded/sync.rs b/packages/rs-platform-wallet/src/wallet/shielded/sync.rs index 47a05ed3bd2..2ac3cadfd3f 100644 --- a/packages/rs-platform-wallet/src/wallet/shielded/sync.rs +++ b/packages/rs-platform-wallet/src/wallet/shielded/sync.rs @@ -27,7 +27,7 @@ //! super::coordinator::NetworkShieldedCoordinator::sync use std::collections::BTreeMap; -use std::sync::Arc; +use std::sync::{Arc, Mutex, OnceLock}; use dash_sdk::platform::shielded::{ sync_shielded_notes_stream, try_decrypt_note, try_recover_outgoing_note, @@ -799,6 +799,111 @@ pub(crate) async fn balances_across( Ok(out) } +/// Process-local resume checkpoint for one foreign-key transient scan. +/// +/// [`scan_notes_for_foreign_key`] has no subwallet store to persist a sync +/// watermark into, so without a checkpoint every call restarts the +/// proof-verified note stream at position zero — and a syntactically valid but +/// UNFUNDED invitation key (attacker-controlled input) turns every retry into +/// a full-history rescan (#4313 review finding d19c5cf84a9f). The checkpoint +/// bounds the repeat: within one process, tree positions below +/// `resume_position` are streamed and trial-decrypted at most once per key, so +/// an unfunded key costs one full-history scan per process, after which each +/// retry only covers new tree growth plus the mutable buffer chunk. +/// +/// Funds-safety: the commitment tree is append-only and every full chunk is +/// immutable, so nothing below `resume_position` can change after it was +/// scanned; only the final (partial) buffer chunk can still receive notes, and +/// `resume_position` is never advanced past a partial chunk's `start_index` — +/// the same resume rule the subwallet sync applies (see +/// `ShieldedChunkBatch::is_partial`). A resumed scan therefore can never miss +/// a note a from-zero scan would have found. Deliberately process-local (no +/// persistence): a fresh process re-pays one full scan, which keeps this a +/// pure work bound with no stored state to invalidate. +struct ForeignScanCheckpoint { + /// First tree position the next scan must cover; every position strictly + /// below it has already been streamed and trial-decrypted for this key. + /// Always a full-chunk boundary (and re-aligned down on use). + resume_position: u64, + /// Notes that decrypted under the key at positions strictly below + /// `resume_position`. Positions at/above it are re-derived on resume, so + /// buffer-chunk notes are never carried here (no duplicates on rescan). + notes: Vec, +} + +/// Bounded, most-recently-used-last checkpoint list keyed by +/// [`foreign_scan_checkpoint_key`]. A `Vec` with linear search: the cap is +/// tiny, and eviction order (front = least recently used) falls out for free. +type ForeignScanCheckpoints = Vec<([u8; 32], ForeignScanCheckpoint)>; + +static FOREIGN_SCAN_CHECKPOINTS: OnceLock> = OnceLock::new(); + +/// At most this many foreign keys keep a checkpoint. One claim flow touches +/// one key, so this covers concurrent/retried claims while capping what +/// hostile key churn can pin in memory (a one-time key funds 1–2 notes, so +/// each entry is small; churn also cannot force rescans of OTHER keys — an +/// evicted key merely re-pays its own full scan). +const FOREIGN_SCAN_CHECKPOINT_CAP: usize = 8; + +/// Deterministic checkpoint key for a foreign one-time key. Domain-separated +/// from `one_time_claim_record_key` (operations.rs) so the two keyspaces can +/// never alias, and hashed so the raw FVK bytes are not retained in the map. +fn foreign_scan_checkpoint_key(fvk: &grovedb_commitment_tree::FullViewingKey) -> [u8; 32] { + use dashcore::hashes::{sha256, Hash}; + + let mut preimage = Vec::with_capacity(96 + 44); + preimage.extend_from_slice(b"platform-wallet:foreign-scan-checkpoint:v1"); + preimage.extend_from_slice(&fvk.to_bytes()); + sha256::Hash::hash(&preimage).to_byte_array() +} + +/// Remove and return the checkpoint for `key`, if present. Taking (rather +/// than cloning) keeps the entry single-owner while a scan is in flight; the +/// scan writes the advanced checkpoint back on every exit path. +fn take_foreign_scan_checkpoint(key: &[u8; 32]) -> Option { + let mut map = FOREIGN_SCAN_CHECKPOINTS + .get_or_init(Default::default) + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + map.iter() + .position(|(k, _)| k == key) + .map(|i| map.remove(i).1) +} + +/// Insert/replace the checkpoint for `key` as most recently used, evicting +/// the least recently used entry beyond [`FOREIGN_SCAN_CHECKPOINT_CAP`]. +fn save_foreign_scan_checkpoint(key: [u8; 32], checkpoint: ForeignScanCheckpoint) { + let mut map = FOREIGN_SCAN_CHECKPOINTS + .get_or_init(Default::default) + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + if let Some(i) = map.iter().position(|(k, _)| k == &key) { + map.remove(i); + } + while map.len() >= FOREIGN_SCAN_CHECKPOINT_CAP { + map.remove(0); + } + map.push((key, checkpoint)); +} + +/// Build the checkpoint to persist after covering the tree through +/// `scanned_through`: only notes on immutable, fully-consumed chunks +/// (position strictly below the resume point) are carried — notes inside the +/// mutable buffer chunk are re-derived on the next pass. +fn foreign_scan_checkpoint_below( + scanned_through: u64, + found: &[ShieldedNote], +) -> ForeignScanCheckpoint { + ForeignScanCheckpoint { + resume_position: scanned_through, + notes: found + .iter() + .filter(|n| n.position < scanned_through) + .cloned() + .collect(), + } +} + /// Transiently scan the shielded-note set for a FOREIGN Orchard key (the /// L2-invitation *claim* path). /// @@ -812,15 +917,21 @@ pub(crate) async fn balances_across( /// 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. +/// key's value never reaches `stop_at_value`, the tree is scanned to the tip +/// 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. +/// a per-note inclusion height — see [`ShieldedChunkBatch`]), so a caller's +/// birth-height hint cannot seed the scan start. The rescan bound is instead a +/// process-local [`ForeignScanCheckpoint`]: the first scan for a key covers the +/// full history from position 0 (never risking a missed note), and every later +/// scan for the SAME key resumes past the immutable chunks it already covered — +/// so a valid-but-unfunded invitation key costs one full-history scan per +/// process, not one per attempt (#4313 review finding d19c5cf84a9f). Progress +/// is checkpointed even when the stream errors mid-scan, so an interrupted +/// retry resumes rather than restarting. /// /// [`ShieldedChunkBatch`]: dash_sdk::platform::shielded::notes_sync::types::ShieldedChunkBatch pub(crate) async fn scan_notes_for_foreign_key( @@ -831,14 +942,71 @@ pub(crate) async fn scan_notes_for_foreign_key( ) -> Result, PlatformWalletError> { use grovedb_commitment_tree::PreparedIncomingViewingKey; + let checkpoint_key = foreign_scan_checkpoint_key(fvk); + let (mut found, resume_position) = match take_foreign_scan_checkpoint(&checkpoint_key) { + Some(cp) => (cp.notes, cp.resume_position), + None => (Vec::new(), 0), + }; + + // The stream start must sit on an on-chain MMR chunk boundary; align DOWN + // so a resume can only over-scan, never skip. Checkpointed notes at/above + // the aligned start would be re-found by the rescan below — drop them so + // they cannot duplicate (defensive: persisted resume positions are already + // chunk-aligned and their notes strictly below). + let aligned_start = (resume_position / CHUNK_SIZE) * CHUNK_SIZE; + found.retain(|n| n.position < aligned_start); + let mut total: u64 = found + .iter() + .fold(0u64, |acc, n| acc.saturating_add(n.value)); + + if aligned_start > 0 { + debug!( + aligned_start, + checkpointed_notes = found.len(), + checkpointed_value = total, + "Foreign-key scan resuming from process-local checkpoint" + ); + } + + // Checkpointed notes already cover the requested value: no network work. + // Safe because note contents at a scanned position are immutable + // (append-only tree) and spent-ness is not decided here — the caller's + // selection/preflight re-verifies nullifier status against the chain, + // exactly as it does for freshly scanned notes. + if total >= stop_at_value && !found.is_empty() { + save_foreign_scan_checkpoint( + checkpoint_key, + foreign_scan_checkpoint_below(aligned_start, &found), + ); + return Ok(found); + } + let prepared = PreparedIncomingViewingKey::new(ivk); - let stream = sync_shielded_notes_stream(sdk, &prepared, 0, None); + let stream = sync_shielded_notes_stream(sdk, &prepared, aligned_start, None); futures::pin_mut!(stream); - let mut found: Vec = Vec::new(); - let mut total: u64 = 0; + // How far this pass has FULLY covered the tree: advanced past the end of + // every immutable full chunk consumed, held AT a partial (buffer) chunk's + // `start_index` because that chunk may still receive notes. + let mut scanned_through = aligned_start; while let Some(batch) = stream.next().await { - let batch = batch.map_err(|e| PlatformWalletError::ShieldedSyncFailed(e.to_string()))?; + let batch = match batch { + Ok(batch) => batch, + Err(e) => { + // Persist partial progress: the retry that follows this error + // resumes here instead of re-paying the whole scan. + save_foreign_scan_checkpoint( + checkpoint_key, + foreign_scan_checkpoint_below(scanned_through, &found), + ); + return Err(PlatformWalletError::ShieldedSyncFailed(e.to_string())); + } + }; + scanned_through = if batch.is_partial { + batch.start_index + } else { + batch.start_index + batch.notes.len() as u64 + }; for dn in batch.decrypted { let value = dn.note.value().inner(); let nullifier = dn.note.nullifier(fvk).to_bytes(); @@ -858,6 +1026,11 @@ pub(crate) async fn scan_notes_for_foreign_key( break; } } + + save_foreign_scan_checkpoint( + checkpoint_key, + foreign_scan_checkpoint_below(scanned_through, &found), + ); Ok(found) } @@ -1060,6 +1233,90 @@ mod tests { assert!(store.get_unspent_notes(a).unwrap().is_empty()); assert_eq!(store.get_unspent_notes(b).unwrap().len(), 1); } + + /// Note at `position` worth `value` (checkpoint tests don't care about + /// nullifiers). + fn note_at(position: u64, value: u64) -> ShieldedNote { + ShieldedNote { + position, + cmx: [0x22; 32], + nullifier: [0x33; 32], + block_height: 10, + is_spent: false, + value, + note_data: vec![0u8; 115], + } + } + + /// The checkpoint carries only notes on immutable, fully-consumed chunks + /// (position strictly below the resume point); buffer-chunk notes are + /// dropped so the rescan of that chunk cannot duplicate them. + #[test] + fn foreign_scan_checkpoint_below_drops_buffer_chunk_notes() { + let found = vec![note_at(5, 100), note_at(2047, 200), note_at(2048, 300)]; + + let cp = super::foreign_scan_checkpoint_below(2048, &found); + + assert_eq!(cp.resume_position, 2048); + let positions: Vec = cp.notes.iter().map(|n| n.position).collect(); + assert_eq!( + positions, + vec![5, 2047], + "the note AT the resume position sits in the still-mutable buffer \ + chunk and must be re-derived next pass, not carried" + ); + } + + /// Process-local checkpoint map semantics: take removes, save replaces, + /// and the least-recently-saved entry is evicted beyond the cap. One test + /// function on purpose — the map is a process-global static, so keeping + /// every access sequential avoids cross-test interference. + #[test] + fn foreign_scan_checkpoint_map_take_save_and_evict() { + // Keys unique to this test (no other test touches the static: the + // scan itself needs a live Sdk and has no unit-test call sites). + let key = |i: u8| -> [u8; 32] { [0xE0 + i; 32] }; + let cp = |resume: u64| super::ForeignScanCheckpoint { + resume_position: resume, + notes: vec![note_at(1, 42)], + }; + + // Missing key: nothing to take. + assert!(super::take_foreign_scan_checkpoint(&key(0)).is_none()); + + // Round-trip: save then take returns the entry and REMOVES it. + super::save_foreign_scan_checkpoint(key(0), cp(2048)); + let got = super::take_foreign_scan_checkpoint(&key(0)).expect("saved checkpoint"); + assert_eq!(got.resume_position, 2048); + assert_eq!(got.notes.len(), 1); + assert!( + super::take_foreign_scan_checkpoint(&key(0)).is_none(), + "take must remove the entry (single-owner while a scan is in flight)" + ); + + // Save for an existing key replaces rather than duplicates. + super::save_foreign_scan_checkpoint(key(0), cp(2048)); + super::save_foreign_scan_checkpoint(key(0), cp(4096)); + let got = super::take_foreign_scan_checkpoint(&key(0)).expect("replaced checkpoint"); + assert_eq!(got.resume_position, 4096, "latest save must win"); + assert!(super::take_foreign_scan_checkpoint(&key(0)).is_none()); + + // Fill one past the cap: the oldest entry is evicted, the rest live. + let n = super::FOREIGN_SCAN_CHECKPOINT_CAP as u8 + 1; + for i in 0..n { + super::save_foreign_scan_checkpoint(key(i), cp(u64::from(i) * 2048)); + } + assert!( + super::take_foreign_scan_checkpoint(&key(0)).is_none(), + "least-recently-saved entry must be evicted beyond the cap" + ); + for i in 1..n { + assert!( + super::take_foreign_scan_checkpoint(&key(i)).is_some(), + "entry {i} must survive the eviction" + ); + } + } } /// OVK outgoing-note recovery: round-trip a real Orchard output From 1e785757995a36c1adf7d2b926dcce3a77c719d6 Mon Sep 17 00:00:00 2001 From: bfoss765 Date: Wed, 12 Aug 2026 08:53:08 -0400 Subject: [PATCH 20/26] fix(unified-sdk-jni): name the caller's parameter in read_recipient43 errors MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A null/wrong-length changeAddressRaw43 was reported as recipientRaw43 — a parameter that entry point does not have. Give the helper a field parameter, mirroring read_id32. Co-Authored-By: Claude Opus 4.8 --- packages/rs-unified-sdk-jni/src/funding.rs | 23 +++++++++++----------- 1 file changed, 12 insertions(+), 11 deletions(-) diff --git a/packages/rs-unified-sdk-jni/src/funding.rs b/packages/rs-unified-sdk-jni/src/funding.rs index a616d6874de..67e5313520d 100644 --- a/packages/rs-unified-sdk-jni/src/funding.rs +++ b/packages/rs-unified-sdk-jni/src/funding.rs @@ -188,19 +188,20 @@ fn read_key32_zeroizing( 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. -fn read_recipient43(env: &mut JNIEnv, arr: &JByteArray) -> Option<[u8; 43]> { +/// Read a required 43-byte raw Orchard address from a Java `byte[]` +/// (11-byte diversifier + 32-byte pk_d); throws + returns None on the +/// wrong length / a JNI error. `field` names the caller's parameter in +/// the exception message (mirrors `read_id32`). +fn read_recipient43(env: &mut JNIEnv, arr: &JByteArray, field: &str) -> Option<[u8; 43]> { if arr.is_null() { - throw_sdk_exception(env, 1, "recipientRaw43 byte[] was null"); + throw_sdk_exception(env, 1, &format!("{field} byte[] was null")); return None; } let bytes = match env.convert_byte_array(arr) { Ok(b) => b, Err(_) => { let _ = env.exception_clear(); - throw_sdk_exception(env, 1, "recipientRaw43 byte[] was invalid"); + throw_sdk_exception(env, 1, &format!("{field} byte[] was invalid")); return None; } }; @@ -208,7 +209,7 @@ fn read_recipient43(env: &mut JNIEnv, arr: &JByteArray) -> Option<[u8; 43]> { throw_sdk_exception( env, 1, - &format!("recipientRaw43 must be 43 bytes, got {}", bytes.len()), + &format!("{field} must be 43 bytes, got {}", bytes.len()), ); return None; } @@ -382,7 +383,7 @@ pub extern "system" fn Java_org_dashfoundation_dashsdk_ffi_FundingNative_shielde let Some(wid) = read_id32(env, &wallet_id, "walletId") else { return; }; - let Some(recipient) = read_recipient43(env, &recipient_raw43) else { + let Some(recipient) = read_recipient43(env, &recipient_raw43, "recipientRaw43") else { return; }; let surplus = match read_opt_bytes(env, &surplus_output) { @@ -441,7 +442,7 @@ pub extern "system" fn Java_org_dashfoundation_dashsdk_ffi_FundingNative_shielde let Some(txid) = read_id32(env, &out_point_txid, "outPointTxid") else { return; }; - let Some(recipient) = read_recipient43(env, &recipient_raw43) else { + let Some(recipient) = read_recipient43(env, &recipient_raw43, "recipientRaw43") else { return; }; let surplus = match read_opt_bytes(env, &surplus_output) { @@ -876,7 +877,7 @@ pub extern "system" fn Java_org_dashfoundation_dashsdk_ffi_FundingNative_shielde 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 { + let Some(change_raw) = read_recipient43(env, &change_address_raw43, "changeAddressRaw43") else { return ptr::null_mut(); }; @@ -1085,7 +1086,7 @@ pub extern "system" fn Java_org_dashfoundation_dashsdk_ffi_FundingNative_shielde let Some(wid) = read_id32(env, &wallet_id, "walletId") else { return; }; - let Some(recipient) = read_recipient43(env, &recipient_raw43) else { + let Some(recipient) = read_recipient43(env, &recipient_raw43, "recipientRaw43") else { return; }; // null / empty memo → null pointer (no memo). The CString owns the From 6668061061a1a7bf613aa459eb61599e51d0c85d Mon Sep 17 00:00:00 2001 From: bfoss765 Date: Wed, 12 Aug 2026 18:52:12 -0400 Subject: [PATCH 21/26] fix(platform-wallet): single-flight the one-time-key claim per FVK; scope scan checkpoints to the coordinator MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes the two #4313 review clusters on the claim path: - ForeignClaimGuards (coordinator-owned): every identity_create_from_one_time_key holds a per-FVK async mutex across the COMPLETE lifecycle — pending-record lookup, transient scan, transition construction, atomic arming, broadcast, finalization (finding 979bbc2fcb3c). Concurrent same-key claims serialize instead of racing arm_one_time_claim_record's INSERT-OR-REPLACE and overwriting each other's byte-exact recovery row. Weak-handle registry: cancellation releases on drop, dead keys prune on the next acquisition. - ForeignScanCheckpointCache replaces the process-global FOREIGN_SCAN_CHECKPOINTS static: owned by NetworkShieldedCoordinator (one network + one tree store), so a resume position can never leak across chains — including two devnets sharing Network::Devnet (findings 6118148e4547 / cr-4d2aa8ce). load() clones instead of removing and save() is monotonic, so a claim cancelled mid-scan leaves the previous checkpoint intact instead of destroying it (finding cr-4808dde4); no sync mutex guard is ever held across an await. Tests: guard identity/serialization/cancellation-release/prune; cache load-no-remove, monotonic save, LRU eviction, and the cross-instance isolation shape CodeRabbit requested. 865 platform-wallet lib tests green. Co-Authored-By: Claude Opus 4.8 --- .../src/wallet/platform_wallet.rs | 2 + .../src/wallet/shielded/coordinator.rs | 28 +++ .../src/wallet/shielded/operations.rs | 175 +++++++++++++- .../src/wallet/shielded/sync.rs | 223 ++++++++++++------ 4 files changed, 353 insertions(+), 75 deletions(-) diff --git a/packages/rs-platform-wallet/src/wallet/platform_wallet.rs b/packages/rs-platform-wallet/src/wallet/platform_wallet.rs index 43a1ecf6438..81ae58d795f 100644 --- a/packages/rs-platform-wallet/src/wallet/platform_wallet.rs +++ b/packages/rs-platform-wallet/src/wallet/platform_wallet.rs @@ -1592,6 +1592,8 @@ impl PlatformWallet { super::shielded::operations::identity_create_from_one_time_key( &self.sdk, coordinator.store(), + coordinator.foreign_claim_guards(), + coordinator.foreign_scan_checkpoints(), self.wallet_id, one_time_sk, funding_birth_height, diff --git a/packages/rs-platform-wallet/src/wallet/shielded/coordinator.rs b/packages/rs-platform-wallet/src/wallet/shielded/coordinator.rs index a938d3f210d..a83396e3e38 100644 --- a/packages/rs-platform-wallet/src/wallet/shielded/coordinator.rs +++ b/packages/rs-platform-wallet/src/wallet/shielded/coordinator.rs @@ -234,6 +234,20 @@ pub struct NetworkShieldedCoordinator { /// account set. hydrated: RwLock>, + /// Per-FVK single-flight guards for the one-time-key (L2-invitation) + /// claim lifecycle — see `operations::ForeignClaimGuards`. Owned here + /// because the coordinator also owns the durable pending-claim record + /// store the guard protects: everything that can race on one invitation + /// key races through this one instance. + foreign_claim_guards: super::operations::ForeignClaimGuards, + + /// Resume checkpoints for foreign-key transient scans — see + /// `sync::ForeignScanCheckpointCache`. Owned here (NOT process-global) + /// so a checkpoint can never leak between chains: one coordinator = one + /// network + one tree store, which also separates two devnets that share + /// `Network::Devnet` (#4313 review findings 6118148e4547 / cr-4d2aa8ce). + foreign_scan_checkpoints: super::sync::ForeignScanCheckpointCache, + /// Counts completed [`clear`](Self::clear) calls, so a bind can tell /// that the host snapshot it loaded predates a wipe. /// @@ -388,10 +402,24 @@ impl NetworkShieldedCoordinator { tree_progress_handler: std::sync::Mutex::new(None), lifecycle: tokio::sync::Mutex::new(()), hydrated: RwLock::new(std::collections::BTreeSet::new()), + foreign_claim_guards: Default::default(), + foreign_scan_checkpoints: Default::default(), clear_generation: std::sync::atomic::AtomicU64::new(0), } } + /// The coordinator-owned per-FVK single-flight guards for one-time-key + /// claims. See the field doc and `operations::ForeignClaimGuards`. + pub fn foreign_claim_guards(&self) -> &super::operations::ForeignClaimGuards { + &self.foreign_claim_guards + } + + /// The coordinator-owned foreign-scan resume checkpoints. See the field + /// doc and `sync::ForeignScanCheckpointCache`. + pub fn foreign_scan_checkpoints(&self) -> &super::sync::ForeignScanCheckpointCache { + &self.foreign_scan_checkpoints + } + /// Snapshot of the clear counter, to be taken **before** reading the /// host's persisted state and handed to /// [`ShieldedInstall::snapshot_predates_clear`] inside the install diff --git a/packages/rs-platform-wallet/src/wallet/shielded/operations.rs b/packages/rs-platform-wallet/src/wallet/shielded/operations.rs index b7dfb55a92d..d3a011e2b33 100644 --- a/packages/rs-platform-wallet/src/wallet/shielded/operations.rs +++ b/packages/rs-platform-wallet/src/wallet/shielded/operations.rs @@ -1612,7 +1612,7 @@ where /// `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; repeated attempts are -/// bounded by the scan's process-local resume checkpoint instead). +/// bounded by the scan's coordinator-owned resume checkpoint instead). /// /// Returns the new identity's id and the proof-verified [`Identity`]; the caller /// registers that identity in its local `IdentityManager`. @@ -1620,6 +1620,14 @@ where pub async fn identity_create_from_one_time_key( sdk: &Arc, store: &Arc>, + // Coordinator-owned per-FVK single-flight guards — see + // [`ForeignClaimGuards`]. Acquired for the WHOLE body, so concurrent + // same-key claims serialize instead of racing the durable record. + claim_guards: &ForeignClaimGuards, + // Coordinator-owned transient-scan resume checkpoints — see + // [`super::sync::ForeignScanCheckpointCache`] for the chain-isolation + // contract. + scan_checkpoints: &super::sync::ForeignScanCheckpointCache, // Claimer's wallet id — keys the durable pending-claim record (under the // reserved `ONE_TIME_CLAIM_RECORDS_ACCOUNT` subwallet of this wallet). wallet_id: WalletId, @@ -1678,8 +1686,9 @@ where // 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 cannot seed its start from a height; it bounds itself by - // value coverage plus a process-local resume checkpoint (one full-history - // scan per key per process — see `scan_notes_for_foreign_key`). Logged so + // value coverage plus a coordinator-owned resume checkpoint (one + // full-history scan per key per coordinator — see + // `scan_notes_for_foreign_key`). Logged so // the hint is observable and not silently dropped. if let Some(h) = funding_birth_height { debug!( @@ -1705,6 +1714,24 @@ where .map(|(key, _)| (key.id(), key.clone())) .collect(); + // ---- Per-FVK single-flight (#4313 review finding 979bbc2fcb3c) ---- + // + // Serialize the COMPLETE claim lifecycle for this invitation key — + // pending-record lookup, transient scan, transition construction, atomic + // arming, broadcast, and finalization — before touching any shared state. + // Without it, two concurrent claims for the same key both see no pending + // record, build transitions with DIFFERENT padded identity ids, and the + // second `arm_one_time_claim_record` (INSERT-OR-REPLACE) overwrites the + // first's byte-exact recovery row while its broadcast may already be on + // the wire — stranding that identity forever. A parked second caller + // instead resumes the settled record when the guard lifts. The guard is + // an async mutex (held across every await below; released on drop, so a + // cancelled claim cannot wedge the key) owned by the SAME coordinator + // that owns the record store it protects. + let claim_record_key = one_time_claim_record_key(&fvk); + let lifecycle_entry = claim_guards.entry_for(claim_record_key); + let _lifecycle_guard = lifecycle_entry.lock().await; + // ---- Durable pending-claim resume (#4204 review finding c0781f9d387f) ---- // // A claim that broadcast but never confirmed (process death, JNI @@ -1717,7 +1744,6 @@ where // would misread the spent notes as a foreign claim // (`ShieldedInviteAlreadyClaimed`). let claim_records_id = SubwalletId::new(wallet_id, ONE_TIME_CLAIM_RECORDS_ACCOUNT); - let claim_record_key = one_time_claim_record_key(&fvk); if let Some(record) = find_one_time_claim_record(store, claim_records_id, claim_record_key).await? { @@ -1745,7 +1771,9 @@ where } // 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?; + let discovered = + super::sync::scan_notes_for_foreign_key(sdk, scan_checkpoints, &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). @@ -2171,6 +2199,57 @@ fn one_time_claim_record_key(fvk: &grovedb_commitment_tree::FullViewingKey) -> [ sha256::Hash::hash(&preimage).to_byte_array() } +/// Per-FVK single-flight guards for the one-time-key claim lifecycle. +/// +/// Owned by `NetworkShieldedCoordinator` (the same owner as the durable +/// pending-claim record store the guard protects). Two concurrent +/// [`identity_create_from_one_time_key`] calls for the SAME foreign key would +/// otherwise both observe no pending record, build two transitions whose +/// padded single-note identity ids differ (random padding nullifier), and +/// race `arm_one_time_claim_record` — whose store implementation is an +/// INSERT-OR-REPLACE — so the loser's byte-exact recovery row is silently +/// overwritten and its identity becomes unrecoverable; either caller could +/// also finalize (clear) the shared row while the other is mid-broadcast +/// (#4313 review finding 979bbc2fcb3c / cr-4808dde4). The guard therefore +/// spans the COMPLETE lifecycle — pending-record lookup, transient scan, +/// transition construction, atomic arming, broadcast, and finalization — not +/// just the scan-checkpoint window: the second caller parks until the first +/// settles, then resumes that outcome through the persisted record instead of +/// double-spending the invitation. +/// +/// Mechanics: `entry_for` hands every same-key caller the SAME +/// `Arc>` (a live entry is always upgraded, never +/// replaced), whose async lock is cancellation-safe — dropping a parked or +/// mid-claim future releases it. The map holds only `Weak` handles, pruned on +/// every acquisition, so abandoned keys cost nothing and hostile key churn +/// cannot grow the map beyond the keys currently in flight. +#[derive(Default)] +pub struct ForeignClaimGuards { + entries: std::sync::Mutex>)>>, +} + +impl ForeignClaimGuards { + /// The shared lifecycle mutex for `key`. Callers `.lock().await` the + /// returned handle and hold the guard across the whole claim; the + /// internal registry lock is sync-only and released before any await. + fn entry_for(&self, key: [u8; 32]) -> Arc> { + let mut entries = self + .entries + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + entries.retain(|(_, weak)| weak.strong_count() > 0); + if let Some((_, weak)) = entries.iter().find(|(k, _)| *k == key) { + if let Some(existing) = weak.upgrade() { + return existing; + } + } + let fresh = Arc::new(tokio::sync::Mutex::new(())); + entries.retain(|(k, _)| *k != key); + entries.push((key, Arc::downgrade(&fresh))); + fresh + } +} + /// Look up the persisted pending-claim record for `key`. Fail-closed on a /// store read error: proceeding to a fresh build while a record might exist /// is exactly the unrecoverable-padded-claim hazard the record prevents. @@ -3748,6 +3827,92 @@ fn deserialize_note(data: &[u8]) -> Option { Note::from_parts(recipient, value, rho, rseed).into_option() } +#[cfg(test)] +mod foreign_claim_guard_tests { + use super::ForeignClaimGuards; + use std::sync::Arc; + + /// Two callers with the same key must share ONE lifecycle mutex — that + /// identity is what makes the claim single-flight (#4313 review finding + /// 979bbc2fcb3c); different keys must not contend. + #[test] + fn same_key_shares_one_mutex_and_keys_are_independent() { + let guards = ForeignClaimGuards::default(); + let a1 = guards.entry_for([1u8; 32]); + let a2 = guards.entry_for([1u8; 32]); + let b = guards.entry_for([2u8; 32]); + assert!( + Arc::ptr_eq(&a1, &a2), + "same-key callers must receive the SAME lifecycle mutex" + ); + assert!( + !Arc::ptr_eq(&a1, &b), + "distinct keys must receive distinct mutexes" + ); + } + + /// The complete-lifecycle serialization: while one claim holds the + /// guard (parked at an await, as across scan/proof/broadcast), a + /// second same-key claim cannot enter; it proceeds only after the + /// first releases — including release by CANCELLATION (future drop), + /// so an abandoned claim can never wedge its invitation key. + #[tokio::test] + async fn same_key_claims_serialize_and_cancellation_releases() { + let guards = Arc::new(ForeignClaimGuards::default()); + let key = [7u8; 32]; + + let entry = guards.entry_for(key); + let held = entry.lock().await; + // Second same-key claim: must NOT be able to enter while held. + let second = guards.entry_for(key); + assert!( + second.try_lock().is_err(), + "a concurrent same-key claim must park while the lifecycle guard is held" + ); + drop(held); + assert!( + second.try_lock().is_ok(), + "the parked claim must proceed once the holder settles" + ); + + // Cancellation-safety: drop a future that acquired the guard at an + // await point; the key must be immediately claimable again. + let entry2 = guards.entry_for(key); + let task = tokio::spawn(async move { + let _g = entry2.lock().await; + std::future::pending::<()>().await; // parked "mid-claim" forever + }); + tokio::task::yield_now().await; + task.abort(); + let _ = task.await; + assert!( + guards.entry_for(key).try_lock().is_ok(), + "an aborted (cancelled) claim must release the key on drop" + ); + } + + /// Abandoned keys cost nothing: once no claim holds a key's mutex, its + /// registry row is pruned on the next acquisition, so hostile key churn + /// cannot grow the map beyond the keys currently in flight. + #[test] + fn dead_entries_are_pruned() { + let guards = ForeignClaimGuards::default(); + for i in 0..64u8 { + let _ = guards.entry_for([i; 32]); // dropped immediately + } + let _live = guards.entry_for([0xFF; 32]); + let len = guards + .entries + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .len(); + assert_eq!( + len, 1, + "only keys with a live claimant may occupy the registry" + ); + } +} + #[cfg(test)] mod redrive_tests { use super::*; diff --git a/packages/rs-platform-wallet/src/wallet/shielded/sync.rs b/packages/rs-platform-wallet/src/wallet/shielded/sync.rs index 2ac3cadfd3f..032a8fd1941 100644 --- a/packages/rs-platform-wallet/src/wallet/shielded/sync.rs +++ b/packages/rs-platform-wallet/src/wallet/shielded/sync.rs @@ -27,7 +27,7 @@ //! super::coordinator::NetworkShieldedCoordinator::sync use std::collections::BTreeMap; -use std::sync::{Arc, Mutex, OnceLock}; +use std::sync::{Arc, Mutex}; use dash_sdk::platform::shielded::{ sync_shielded_notes_stream, try_decrypt_note, try_recover_outgoing_note, @@ -799,17 +799,17 @@ pub(crate) async fn balances_across( Ok(out) } -/// Process-local resume checkpoint for one foreign-key transient scan. +/// Resume checkpoint for one foreign-key transient scan. /// /// [`scan_notes_for_foreign_key`] has no subwallet store to persist a sync /// watermark into, so without a checkpoint every call restarts the /// proof-verified note stream at position zero — and a syntactically valid but /// UNFUNDED invitation key (attacker-controlled input) turns every retry into /// a full-history rescan (#4313 review finding d19c5cf84a9f). The checkpoint -/// bounds the repeat: within one process, tree positions below +/// bounds the repeat: within one cache, tree positions below /// `resume_position` are streamed and trial-decrypted at most once per key, so -/// an unfunded key costs one full-history scan per process, after which each -/// retry only covers new tree growth plus the mutable buffer chunk. +/// an unfunded key costs one full-history scan per cache lifetime, after which +/// each retry only covers new tree growth plus the mutable buffer chunk. /// /// Funds-safety: the commitment tree is append-only and every full chunk is /// immutable, so nothing below `resume_position` can change after it was @@ -817,9 +817,10 @@ pub(crate) async fn balances_across( /// `resume_position` is never advanced past a partial chunk's `start_index` — /// the same resume rule the subwallet sync applies (see /// `ShieldedChunkBatch::is_partial`). A resumed scan therefore can never miss -/// a note a from-zero scan would have found. Deliberately process-local (no +/// a note a from-zero scan would have found. Deliberately in-memory only (no /// persistence): a fresh process re-pays one full scan, which keeps this a /// pure work bound with no stored state to invalidate. +#[derive(Clone)] struct ForeignScanCheckpoint { /// First tree position the next scan must cover; every position strictly /// below it has already been streamed and trial-decrypted for this key. @@ -836,7 +837,30 @@ struct ForeignScanCheckpoint { /// tiny, and eviction order (front = least recently used) falls out for free. type ForeignScanCheckpoints = Vec<([u8; 32], ForeignScanCheckpoint)>; -static FOREIGN_SCAN_CHECKPOINTS: OnceLock> = OnceLock::new(); +/// Coordinator-owned cache of [`ForeignScanCheckpoint`]s. +/// +/// Owned by `NetworkShieldedCoordinator` — NOT process-global — so a +/// checkpoint can never leak across chains (#4313 review findings +/// 6118148e4547 / cr-4d2aa8ce): each coordinator is pinned to one network AND +/// one on-disk tree store, so two devnets that both answer to +/// `Network::Devnet` still get distinct caches, and a resume position +/// computed against one chain's tree can never skip a funded note at an +/// earlier position on another chain's tree. Dropping the coordinator drops +/// its cache — no allocation-address aliasing is possible. +/// +/// Concurrency: entries are read with [`load`](Self::load) (clone, NOT +/// remove) and written with [`save`](Self::save), which only advances a +/// key's `resume_position` monotonically. A caller cancelled between the two +/// therefore leaves the previous checkpoint intact instead of destroying it +/// (#4313 review finding cr-4808dde4: the old take-then-put-back scheme lost +/// the entry if the taker's future was dropped mid-scan). Same-key callers +/// are additionally serialized end-to-end by the claim-lifecycle guard +/// (`operations::ForeignClaimGuards`); the internal mutex is sync-only and +/// never held across an await. +#[derive(Default)] +pub struct ForeignScanCheckpointCache { + entries: Mutex, +} /// At most this many foreign keys keep a checkpoint. One claim flow touches /// one key, so this covers concurrent/retried claims while capping what @@ -845,6 +869,46 @@ static FOREIGN_SCAN_CHECKPOINTS: OnceLock> = OnceL /// evicted key merely re-pays its own full scan). const FOREIGN_SCAN_CHECKPOINT_CAP: usize = 8; +impl ForeignScanCheckpointCache { + /// Clone the checkpoint for `key`, if present, marking it most recently + /// used. The entry stays in the cache — see the type-level concurrency + /// note. + fn load(&self, key: &[u8; 32]) -> Option { + let mut map = self + .entries + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + map.iter().position(|(k, _)| k == key).map(|i| { + let entry = map.remove(i); + let checkpoint = entry.1.clone(); + map.push(entry); + checkpoint + }) + } + + /// Insert/replace the checkpoint for `key` as most recently used, + /// evicting the least recently used entry beyond + /// [`FOREIGN_SCAN_CHECKPOINT_CAP`]. Monotonic: an existing entry is only + /// replaced by one whose `resume_position` is at least as far along, so + /// no writer can rewind another's progress. + fn save(&self, key: [u8; 32], checkpoint: ForeignScanCheckpoint) { + let mut map = self + .entries + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + if let Some(i) = map.iter().position(|(k, _)| k == &key) { + if map[i].1.resume_position > checkpoint.resume_position { + return; + } + map.remove(i); + } + while map.len() >= FOREIGN_SCAN_CHECKPOINT_CAP { + map.remove(0); + } + map.push((key, checkpoint)); + } +} + /// Deterministic checkpoint key for a foreign one-time key. Domain-separated /// from `one_time_claim_record_key` (operations.rs) so the two keyspaces can /// never alias, and hashed so the raw FVK bytes are not retained in the map. @@ -857,35 +921,6 @@ fn foreign_scan_checkpoint_key(fvk: &grovedb_commitment_tree::FullViewingKey) -> sha256::Hash::hash(&preimage).to_byte_array() } -/// Remove and return the checkpoint for `key`, if present. Taking (rather -/// than cloning) keeps the entry single-owner while a scan is in flight; the -/// scan writes the advanced checkpoint back on every exit path. -fn take_foreign_scan_checkpoint(key: &[u8; 32]) -> Option { - let mut map = FOREIGN_SCAN_CHECKPOINTS - .get_or_init(Default::default) - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner); - map.iter() - .position(|(k, _)| k == key) - .map(|i| map.remove(i).1) -} - -/// Insert/replace the checkpoint for `key` as most recently used, evicting -/// the least recently used entry beyond [`FOREIGN_SCAN_CHECKPOINT_CAP`]. -fn save_foreign_scan_checkpoint(key: [u8; 32], checkpoint: ForeignScanCheckpoint) { - let mut map = FOREIGN_SCAN_CHECKPOINTS - .get_or_init(Default::default) - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner); - if let Some(i) = map.iter().position(|(k, _)| k == &key) { - map.remove(i); - } - while map.len() >= FOREIGN_SCAN_CHECKPOINT_CAP { - map.remove(0); - } - map.push((key, checkpoint)); -} - /// Build the checkpoint to persist after covering the tree through /// `scanned_through`: only notes on immutable, fully-consumed chunks /// (position strictly below the resume point) are carried — notes inside the @@ -925,17 +960,22 @@ fn foreign_scan_checkpoint_below( /// height→position oracle (a chunk's `block_height` is the proof-tip height, not /// a per-note inclusion height — see [`ShieldedChunkBatch`]), so a caller's /// birth-height hint cannot seed the scan start. The rescan bound is instead a -/// process-local [`ForeignScanCheckpoint`]: the first scan for a key covers the +/// coordinator-owned [`ForeignScanCheckpoint`] (in `checkpoints` — see +/// [`ForeignScanCheckpointCache`] for the chain-isolation and +/// cancellation-safety contract): the first scan for a key covers the /// full history from position 0 (never risking a missed note), and every later /// scan for the SAME key resumes past the immutable chunks it already covered — /// so a valid-but-unfunded invitation key costs one full-history scan per -/// process, not one per attempt (#4313 review finding d19c5cf84a9f). Progress -/// is checkpointed even when the stream errors mid-scan, so an interrupted -/// retry resumes rather than restarting. +/// coordinator, not one per attempt (#4313 review finding d19c5cf84a9f). +/// Progress is checkpointed even when the stream errors mid-scan, so an +/// interrupted retry resumes rather than restarting. Same-key calls are +/// serialized by the caller's per-FVK claim guard, so two scans never +/// interleave on one key. /// /// [`ShieldedChunkBatch`]: dash_sdk::platform::shielded::notes_sync::types::ShieldedChunkBatch pub(crate) async fn scan_notes_for_foreign_key( sdk: &Arc, + checkpoints: &ForeignScanCheckpointCache, fvk: &grovedb_commitment_tree::FullViewingKey, ivk: &grovedb_commitment_tree::IncomingViewingKey, stop_at_value: u64, @@ -943,7 +983,7 @@ pub(crate) async fn scan_notes_for_foreign_key( use grovedb_commitment_tree::PreparedIncomingViewingKey; let checkpoint_key = foreign_scan_checkpoint_key(fvk); - let (mut found, resume_position) = match take_foreign_scan_checkpoint(&checkpoint_key) { + let (mut found, resume_position) = match checkpoints.load(&checkpoint_key) { Some(cp) => (cp.notes, cp.resume_position), None => (Vec::new(), 0), }; @@ -964,7 +1004,7 @@ pub(crate) async fn scan_notes_for_foreign_key( aligned_start, checkpointed_notes = found.len(), checkpointed_value = total, - "Foreign-key scan resuming from process-local checkpoint" + "Foreign-key scan resuming from coordinator-owned checkpoint" ); } @@ -974,7 +1014,7 @@ pub(crate) async fn scan_notes_for_foreign_key( // selection/preflight re-verifies nullifier status against the chain, // exactly as it does for freshly scanned notes. if total >= stop_at_value && !found.is_empty() { - save_foreign_scan_checkpoint( + checkpoints.save( checkpoint_key, foreign_scan_checkpoint_below(aligned_start, &found), ); @@ -995,7 +1035,7 @@ pub(crate) async fn scan_notes_for_foreign_key( Err(e) => { // Persist partial progress: the retry that follows this error // resumes here instead of re-paying the whole scan. - save_foreign_scan_checkpoint( + checkpoints.save( checkpoint_key, foreign_scan_checkpoint_below(scanned_through, &found), ); @@ -1027,7 +1067,7 @@ pub(crate) async fn scan_notes_for_foreign_key( } } - save_foreign_scan_checkpoint( + checkpoints.save( checkpoint_key, foreign_scan_checkpoint_below(scanned_through, &found), ); @@ -1267,56 +1307,99 @@ mod tests { ); } - /// Process-local checkpoint map semantics: take removes, save replaces, - /// and the least-recently-saved entry is evicted beyond the cap. One test - /// function on purpose — the map is a process-global static, so keeping - /// every access sequential avoids cross-test interference. + /// Checkpoint cache semantics: load clones without removing, save + /// replaces monotonically, and the least-recently-used entry is evicted + /// beyond the cap. #[test] - fn foreign_scan_checkpoint_map_take_save_and_evict() { - // Keys unique to this test (no other test touches the static: the - // scan itself needs a live Sdk and has no unit-test call sites). + fn foreign_scan_checkpoint_cache_load_save_and_evict() { + let cache = super::ForeignScanCheckpointCache::default(); let key = |i: u8| -> [u8; 32] { [0xE0 + i; 32] }; let cp = |resume: u64| super::ForeignScanCheckpoint { resume_position: resume, notes: vec![note_at(1, 42)], }; - // Missing key: nothing to take. - assert!(super::take_foreign_scan_checkpoint(&key(0)).is_none()); + // Missing key: nothing to load. + assert!(cache.load(&key(0)).is_none()); - // Round-trip: save then take returns the entry and REMOVES it. - super::save_foreign_scan_checkpoint(key(0), cp(2048)); - let got = super::take_foreign_scan_checkpoint(&key(0)).expect("saved checkpoint"); + // Round-trip: save then load returns the entry WITHOUT removing it — + // a caller cancelled after a load must leave the checkpoint intact + // for the next attempt (review finding cr-4808dde4). + cache.save(key(0), cp(2048)); + let got = cache.load(&key(0)).expect("saved checkpoint"); assert_eq!(got.resume_position, 2048); assert_eq!(got.notes.len(), 1); assert!( - super::take_foreign_scan_checkpoint(&key(0)).is_none(), - "take must remove the entry (single-owner while a scan is in flight)" + cache.load(&key(0)).is_some(), + "load must NOT remove the entry (cancellation between load and \ + save would otherwise destroy the resume progress)" ); - // Save for an existing key replaces rather than duplicates. - super::save_foreign_scan_checkpoint(key(0), cp(2048)); - super::save_foreign_scan_checkpoint(key(0), cp(4096)); - let got = super::take_foreign_scan_checkpoint(&key(0)).expect("replaced checkpoint"); - assert_eq!(got.resume_position, 4096, "latest save must win"); - assert!(super::take_foreign_scan_checkpoint(&key(0)).is_none()); + // Save for an existing key replaces rather than duplicates… + cache.save(key(0), cp(4096)); + let got = cache.load(&key(0)).expect("replaced checkpoint"); + assert_eq!(got.resume_position, 4096, "farther save must win"); + // …but only monotonically: a stale writer cannot rewind progress. + cache.save(key(0), cp(2048)); + let got = cache.load(&key(0)).expect("checkpoint after stale save"); + assert_eq!( + got.resume_position, 4096, + "an older resume position must never replace a newer one" + ); - // Fill one past the cap: the oldest entry is evicted, the rest live. + // Fill one past the cap with fresh keys: the oldest entry is evicted, + // the rest live. let n = super::FOREIGN_SCAN_CHECKPOINT_CAP as u8 + 1; + let cache = super::ForeignScanCheckpointCache::default(); for i in 0..n { - super::save_foreign_scan_checkpoint(key(i), cp(u64::from(i) * 2048)); + cache.save(key(i), cp(u64::from(i) * 2048)); } assert!( - super::take_foreign_scan_checkpoint(&key(0)).is_none(), - "least-recently-saved entry must be evicted beyond the cap" + cache.load(&key(0)).is_none(), + "least-recently-used entry must be evicted beyond the cap" ); for i in 1..n { assert!( - super::take_foreign_scan_checkpoint(&key(i)).is_some(), + cache.load(&key(i)).is_some(), "entry {i} must survive the eviction" ); } } + + /// Chain isolation: the cache is an instance owned by ONE coordinator + /// (one network, one tree store), so the same foreign key checkpointed + /// through one coordinator must be invisible to another — a resume + /// position computed against one chain's tree can never skip a funded + /// note at an earlier position on a different chain (review findings + /// 6118148e4547 / cr-4d2aa8ce; covers two devnets that share + /// `Network::Devnet`). + #[test] + fn foreign_scan_checkpoints_do_not_cross_cache_instances() { + let mainnet_like = super::ForeignScanCheckpointCache::default(); + let devnet_like = super::ForeignScanCheckpointCache::default(); + let key = [0xAB; 32]; + + mainnet_like.save( + key, + super::ForeignScanCheckpoint { + resume_position: 4096, + notes: vec![note_at(1, 42)], + }, + ); + + assert!( + devnet_like.load(&key).is_none(), + "a checkpoint saved through one coordinator's cache must not be \ + visible through another's" + ); + assert_eq!( + mainnet_like + .load(&key) + .expect("own checkpoint stays visible") + .resume_position, + 4096 + ); + } } /// OVK outgoing-note recovery: round-trip a real Orchard output From 122ba12b26dc0bdf0acdc6f4b7b53218ff5c24c0 Mon Sep 17 00:00:00 2001 From: bfoss765 Date: Wed, 12 Aug 2026 19:12:28 -0400 Subject: [PATCH 22/26] style(unified-sdk-jni): rustfmt the read_recipient43 let-else cargo fmt --check --all gates the Rust workspace tests job; 1e78575799 left this line over-width, failing the job in 51s. Co-Authored-By: Claude Opus 4.8 --- packages/rs-unified-sdk-jni/src/funding.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/rs-unified-sdk-jni/src/funding.rs b/packages/rs-unified-sdk-jni/src/funding.rs index 67e5313520d..bbe64a5ecc7 100644 --- a/packages/rs-unified-sdk-jni/src/funding.rs +++ b/packages/rs-unified-sdk-jni/src/funding.rs @@ -877,7 +877,8 @@ pub extern "system" fn Java_org_dashfoundation_dashsdk_ffi_FundingNative_shielde 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, "changeAddressRaw43") else { + let Some(change_raw) = read_recipient43(env, &change_address_raw43, "changeAddressRaw43") + else { return ptr::null_mut(); }; From 67758abb29fb56d9289c246c2bbd86f63096ea70 Mon Sep 17 00:00:00 2001 From: bfoss765 Date: Thu, 13 Aug 2026 11:28:25 -0400 Subject: [PATCH 23/26] style(platform-wallet): factor the claim-guard registry row into type aliases cargo clippy --workspace -D warnings failed the Rust workspace tests job in 51s on clippy::type_complexity at the ForeignClaimGuards entries field (introduced by 6668061061). Name the guard handle and the registry row so the field reads as Vec; no behaviour change. --- .../src/wallet/shielded/operations.rs | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/packages/rs-platform-wallet/src/wallet/shielded/operations.rs b/packages/rs-platform-wallet/src/wallet/shielded/operations.rs index d3a011e2b33..3fa8cb88856 100644 --- a/packages/rs-platform-wallet/src/wallet/shielded/operations.rs +++ b/packages/rs-platform-wallet/src/wallet/shielded/operations.rs @@ -2199,6 +2199,14 @@ fn one_time_claim_record_key(fvk: &grovedb_commitment_tree::FullViewingKey) -> [ sha256::Hash::hash(&preimage).to_byte_array() } +/// The shared per-FVK lifecycle mutex handed to every same-key claimer. +type ClaimGuard = Arc>; + +/// One registry row: the guard key (see `one_time_claim_record_key`) paired +/// with a non-owning handle to its guard, so abandoned keys are pruned on the +/// next acquisition rather than pinning the mutex alive. +type ClaimGuardEntry = ([u8; 32], std::sync::Weak>); + /// Per-FVK single-flight guards for the one-time-key claim lifecycle. /// /// Owned by `NetworkShieldedCoordinator` (the same owner as the durable @@ -2225,14 +2233,14 @@ fn one_time_claim_record_key(fvk: &grovedb_commitment_tree::FullViewingKey) -> [ /// cannot grow the map beyond the keys currently in flight. #[derive(Default)] pub struct ForeignClaimGuards { - entries: std::sync::Mutex>)>>, + entries: std::sync::Mutex>, } impl ForeignClaimGuards { /// The shared lifecycle mutex for `key`. Callers `.lock().await` the /// returned handle and hold the guard across the whole claim; the /// internal registry lock is sync-only and released before any await. - fn entry_for(&self, key: [u8; 32]) -> Arc> { + fn entry_for(&self, key: [u8; 32]) -> ClaimGuard { let mut entries = self .entries .lock() From 847f0a1521925953d7b26972b41b4605798baf9e Mon Sep 17 00:00:00 2001 From: bfoss765 Date: Thu, 13 Aug 2026 12:31:31 -0400 Subject: [PATCH 24/26] fix(platform-wallet): bind a resumed one-time claim to its stored transition MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The durable pending-claim record is found by wallet id and one-time FVK alone, so nothing about the lookup says which identity the earlier attempt was creating. `resume_one_time_claim` nevertheless re-broadcast the STORED transition while handing recovery, the empty-proof-result backfill and the result classification this call's `master_key_hash`, `submitted_public_keys` and `denomination` — and the caller then registered the returned identity at this call's `identity_index`. A retry with different arguments could therefore classify the original identity as another holder's and clear the record (permanently stranding a padded single-note claim, whose declared id embeds a random dummy nullifier and exists nowhere else), backfill an empty proof result with keys that were never in the transition, or register the original identity at the wrong local HD slot (dashpay/platform#4313, finding 195efdd4ae21). The binding is now DERIVED from `record.st_bytes` rather than taken from the call: the transition's `public_keys` are exactly what the binding signature committed to and its `denomination` is the value that leaves the pool, so the transition is the authoritative statement of what was submitted. Deriving needs no record-schema change and — unlike a separately persisted copy — cannot drift from what actually went on the wire. The caller's arguments are demoted to assertions. Any disagreement in the key set (compared whole, by id AND content, so a same-ids key-material swap is caught), the MASTER auth key hash that idempotent recovery probes Platform with, or the denomination fails closed with the new `PlatformWalletError::ShieldedClaimBindingMismatch` — checked BEFORE the spent-nullifier probe and the re-broadcast, so a mismatched retry burns no proof, makes no chargeable resubmission, and leaves the record intact for a retry that presents the original arguments. Key ORDER is not a mismatch; both sides are `BTreeMap`s keyed by key id. `identity_index` is not in the transition and cannot be derived from it. It is bound transitively: the identity's keys are DIP-9-derived at that slot, so a retry naming a different slot presents different keys and is refused. The one uncovered case — a caller pairing slot i with keys derived at slot j — violates the same contract a FIRST attempt relies on and mis-slots identically, so the resume path is now no weaker than a fresh claim. This is stated explicitly in the fn docs rather than left implicit. Tests: the derive path (a serialize/deserialize round-trip reproduces the exact key set, denomination and master key hash), matching-args resume, key order not being a mismatch, per-field refusal, and an end-to-end refusal through `resume_one_time_claim` over a real `FileBackedShieldedStore` that asserts the pending record survives untouched and that no network work was reached. Co-Authored-By: Claude Opus 4.8 --- packages/rs-platform-wallet/src/error.rs | 31 ++ .../src/wallet/shielded/operations.rs | 455 +++++++++++++++++- 2 files changed, 482 insertions(+), 4 deletions(-) diff --git a/packages/rs-platform-wallet/src/error.rs b/packages/rs-platform-wallet/src/error.rs index 8624482c3a4..87b1422385a 100644 --- a/packages/rs-platform-wallet/src/error.rs +++ b/packages/rs-platform-wallet/src/error.rs @@ -605,6 +605,37 @@ pub enum PlatformWalletError { )] ShieldedInviteAlreadyClaimed { reason: String }, + /// A one-time-key (shielded invitation) claim was retried with arguments that do **not** match + /// the transition the earlier attempt actually submitted, so the retry was refused before + /// touching the network. + /// + /// The durable pending-claim record is keyed by wallet id and the invitation's full viewing + /// key alone — nothing in that key distinguishes *which* identity the original attempt was + /// creating. The record does, however, carry the byte-exact serialized transition, and that + /// transition is the authoritative statement of what was submitted: its `public_keys` are the + /// keys the binding signature committed to, and its `denomination` is the value that left the + /// pool. Resuming means re-broadcasting those exact bytes, so the identity that results belongs + /// to *those* keys — never to whatever keys the retry happened to pass in. + /// + /// A retry whose keys or denomination differ is therefore not a resume of the same claim; it is + /// a request to create a different identity from an invitation that is already committed + /// elsewhere. Honouring it would let the caller + /// + /// * classify the original identity as belonging to another holder and clear the record (making + /// a padded single-note claim permanently unrecoverable — its declared id embeds a random + /// dummy nullifier and exists nowhere else), + /// * backfill an empty proof result with keys that were never in the stored transition, or + /// * register the original identity at the retry's local HD slot. + /// + /// So the claim fails closed here instead: nothing is re-broadcast, no proof is burned, and the + /// record is left intact for a retry that presents the original arguments. + #[error( + "Shielded invitation claim retry does not match the transition the earlier attempt \ + submitted ({mismatch}); refusing to resume — retry with the original arguments, which \ + the pending claim record has preserved" + )] + ShieldedClaimBindingMismatch { mismatch: 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 3fa8cb88856..84c3089bfb4 100644 --- a/packages/rs-platform-wallet/src/wallet/shielded/operations.rs +++ b/packages/rs-platform-wallet/src/wallet/shielded/operations.rs @@ -2348,6 +2348,58 @@ async fn finalize_one_time_claim_record( } } +/// Describe the first way a resume attempt's arguments disagree with the +/// transition the earlier attempt submitted, or `None` when they agree. +/// +/// Every field compared here is one the resume would otherwise act on with the +/// caller's value while broadcasting the *stored* bytes — the exact mis-binding +/// #4313 review finding 195efdd4ae21 describes. The comparison is on the +/// derived-from-transition side, so it is a statement about what is on the wire, +/// not about what some parallel record claims. +/// +/// Key comparison is exact and whole-set: `IdentityPublicKey` compares by id, +/// purpose, security level, key type, read-only flag, contract bounds and key +/// data, so a retry that keeps the ids but swaps the key material — the case +/// that would register a foreign identity at this wallet's slot — is caught. +/// A retry that merely *reorders* the same keys is not a mismatch: both sides +/// are `BTreeMap`s keyed by key id. +fn one_time_claim_binding_mismatch( + stored_public_keys: &BTreeMap, + stored_master_key_hash: Option<[u8; 20]>, + stored_denomination: u64, + submitted_public_keys: &BTreeMap, + submitted_master_key_hash: Option<[u8; 20]>, + submitted_denomination: u64, +) -> Option { + if stored_denomination != submitted_denomination { + return Some(format!( + "denomination: stored transition spends {stored_denomination}, retry asked for \ + {submitted_denomination}" + )); + } + if stored_master_key_hash != submitted_master_key_hash { + // The MASTER auth key hash is the handle `recover_executed_one_time_claim` + // probes Platform with. Recovering under a hash that is not in the stored + // transition can only find someone else's identity. + return Some(format!( + "master authentication key hash: stored transition carries {}, retry presented {}", + stored_master_key_hash.map_or_else(|| "none".to_string(), hex::encode), + submitted_master_key_hash.map_or_else(|| "none".to_string(), hex::encode), + )); + } + if stored_public_keys != submitted_public_keys { + return Some(format!( + "public key set: stored transition carries {} key(s) (ids {:?}), retry presented {} \ + key(s) (ids {:?})", + stored_public_keys.len(), + stored_public_keys.keys().collect::>(), + submitted_public_keys.len(), + submitted_public_keys.keys().collect::>(), + )); + } + None +} + /// Outcome of attempting to resume a persisted pending claim. enum OneTimeClaimResume { /// The record drove the claim to an outcome — return it to the caller. @@ -2363,6 +2415,48 @@ enum OneTimeClaimResume { /// consumed, otherwise re-broadcast the byte-identical stored transition — /// never rebuild while the record is live, because a rebuilt padded bundle /// derives a fresh random id and orphans the recorded one. +/// +/// # The resumed claim is bound to the STORED transition, not to this call +/// +/// (#4313 review finding 195efdd4ae21.) The record is found by wallet id and +/// one-time FVK alone, so nothing about the lookup says *which* identity the +/// original attempt was creating. This call's `master_key_hash`, +/// `submitted_public_keys` and `denomination` are therefore treated as +/// **assertions to check**, never as inputs to act on: every one of them is +/// re-derived from `record.st_bytes` — the byte-exact transition the earlier +/// attempt actually put (or is about to put) on the wire — and the derived +/// values are what drive recovery, the empty-proof-result backfill and the +/// re-broadcast. +/// +/// Deriving rather than persisting the binding is deliberate: the transition +/// already carries it (`public_keys` are exactly what the binding signature +/// committed to; `denomination` is the value that leaves the pool), so a +/// derived binding needs no record-schema change and, more importantly, cannot +/// drift from what was submitted the way a separately-persisted copy could. +/// +/// If the caller's arguments disagree with the transition, this is **not** a +/// resume of the same claim — it is a request to create a different identity +/// from an invitation already committed elsewhere — and it fails closed with +/// [`PlatformWalletError::ShieldedClaimBindingMismatch`]: nothing is +/// re-broadcast (so no chargeable resubmission and no burned proof), and the +/// record is left intact for a retry that presents the original arguments. +/// +/// ## What this does and does not bind +/// +/// Bound: the submitted key set (by id and content), the MASTER authentication +/// key hash used for idempotent recovery, and the denomination. +/// +/// Not directly bound: the caller's `identity_index`, the local DIP-9 slot the +/// returned identity is registered at — it is a purely local placement and +/// appears nowhere in the transition, so there is nothing in `st_bytes` to +/// derive it from. It is bound *transitively*: the identity's keys are derived +/// from the wallet seed at that slot, so a retry naming a different slot +/// presents different keys and is refused above. That leaves exactly one +/// uncovered case — a caller that pairs slot `i` with keys derived at slot `j` +/// — which is a violation of the same caller contract that a *first* attempt +/// relies on and mis-slots identically. The resume path is therefore no weaker +/// than a fresh claim, which is the strongest guarantee available without +/// persisting the slot. async fn resume_one_time_claim( sdk: &Arc, store: &Arc>, @@ -2387,8 +2481,20 @@ async fn resume_one_time_claim( return OneTimeClaimResume::RecordUnusable; } }; - let declared_id = match &st { - StateTransition::IdentityCreateFromShieldedPool(t) => t.identity_id(), + // Everything the resume acts on comes from HERE — the stored transition — + // not from this call's arguments. See the fn docs. + let (declared_id, stored_public_keys, stored_denomination) = match &st { + StateTransition::IdentityCreateFromShieldedPool(t) => { + let keys: BTreeMap = t + .public_keys() + .iter() + .map(|key_in_creation| { + let key: IdentityPublicKey = key_in_creation.into(); + (key.id(), key) + }) + .collect(); + (t.identity_id(), keys, t.denomination()) + } other => { warn!( transition = %other.name(), @@ -2399,6 +2505,37 @@ async fn resume_one_time_claim( return OneTimeClaimResume::RecordUnusable; } }; + let stored_master_key_hash = master_auth_public_key_hash_of(stored_public_keys.values()); + + // Fail closed on any disagreement between what the caller asked for and + // what the earlier attempt committed. Checked BEFORE the spent-nullifier + // probe and the re-broadcast, so a mismatched retry costs nothing and + // changes nothing — in particular the record survives for a correct retry. + if let Some(mismatch) = one_time_claim_binding_mismatch( + &stored_public_keys, + stored_master_key_hash, + stored_denomination, + &submitted_public_keys, + master_key_hash, + denomination, + ) { + warn!( + declared_id = %declared_id, + mismatch, + "one-time claim resume: retry arguments do not match the stored transition; refusing \ + to resume rather than mis-binding the original identity" + ); + return OneTimeClaimResume::Resolved(Err( + PlatformWalletError::ShieldedClaimBindingMismatch { mismatch }, + )); + } + + // Past the gate the two agree, so the derived values are used from here on + // — the transition is the source of truth by construction, and reading them + // from it keeps that true even if the check above is ever relaxed. + let master_key_hash = stored_master_key_hash; + let submitted_public_keys = stored_public_keys; + let denomination = stored_denomination; info!( declared_id = %declared_id, @@ -3451,13 +3588,21 @@ async fn nullifier_spent_status( /// deterministically and needs no persisted record. fn master_auth_public_key_hash( public_keys: &[(IdentityPublicKey, IdentityPublicKeyInCreation)], +) -> Option<[u8; 20]> { + master_auth_public_key_hash_of(public_keys.iter().map(|(key, _)| key)) +} + +/// [`master_auth_public_key_hash`] over any borrowed key sequence — used by the +/// resume path, whose keys come out of the stored transition rather than out of +/// the caller's `(IdentityPublicKey, IdentityPublicKeyInCreation)` pairs. +fn master_auth_public_key_hash_of<'a>( + public_keys: impl IntoIterator, ) -> Option<[u8; 20]> { use dpp::identity::identity_public_key::methods::hash::IdentityPublicKeyHashMethodsV0; use dpp::identity::{Purpose, SecurityLevel}; public_keys - .iter() - .map(|(key, _)| key) + .into_iter() .find(|key| { key.purpose() == Purpose::AUTHENTICATION && key.security_level() == SecurityLevel::MASTER @@ -5219,6 +5364,7 @@ mod one_time_key_tests { #[cfg(test)] mod one_time_claim_evidence_tests { use super::*; + use crate::wallet::shielded::file_store::FileBackedShieldedStore; use dpp::identity::identity_public_key::v0::IdentityPublicKeyV0; use dpp::identity::{KeyType, Purpose, SecurityLevel}; use dpp::platform_value::BinaryData; @@ -5228,6 +5374,16 @@ mod one_time_claim_evidence_tests { 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]; + /// Smallest member of the versioned exit-denomination set (0.1 DASH). + const DENOMINATION: u64 = 10_000_000_000; + + fn temp_store_path(tag: &str) -> std::path::PathBuf { + let nanos = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .expect("clock is after the epoch") + .as_nanos(); + std::env::temp_dir().join(format!("one_time_claim_{tag}_{nanos}.sqlite")) + } /// The two real note nullifiers this claim spends. fn our_nullifiers() -> Vec<[u8; 32]> { @@ -5474,4 +5630,295 @@ mod one_time_claim_evidence_tests { "an identity created from a different nullifier set is not this claim's identity" ); } + + // ── Resumed-claim binding (#4313 review finding 195efdd4ae21) ────────── + // + // The pending-claim record is found by wallet id and one-time FVK alone, so + // the resume must take its binding from the STORED TRANSITION and refuse a + // retry whose arguments disagree — never act on the caller's values while + // re-broadcasting someone else's bytes. + + /// Serialize a shielded identity-create transition carrying exactly `keys` + /// and `denomination`, shaped as `arm_one_time_claim_record` stores it. + fn stored_claim_transition( + keys: &[IdentityPublicKey], + denomination: u64, + ) -> (StateTransition, Vec) { + use dpp::serialization::PlatformSerializable; + use dpp::state_transition::identity_create_from_shielded_pool_transition::v0::IdentityCreateFromShieldedPoolTransitionV0; + use dpp::state_transition::identity_create_from_shielded_pool_transition::IdentityCreateFromShieldedPoolTransition; + + let transition: IdentityCreateFromShieldedPoolTransition = + IdentityCreateFromShieldedPoolTransitionV0 { + public_keys: keys + .iter() + .map(|key| IdentityPublicKeyInCreation::from(key.clone())) + .collect(), + denomination, + actions: Vec::new(), + anchor: [0x07; 32], + proof: vec![0x08; 8], + binding_signature: [0x09; 64], + send_to_address_on_creation_failure: dpp::address_funds::PlatformAddress::P2pkh( + [0u8; 20], + ), + identity_id: identity_id_from_nullifiers(&our_nullifiers()), + } + .into(); + let st = StateTransition::IdentityCreateFromShieldedPool(transition); + let bytes = st.serialize_to_bytes().expect("transition serializes"); + (st, bytes) + } + + /// A pending-claim record over `st_bytes`, keyed like a real one. + fn stored_claim_record(st_bytes: Vec) -> PendingRedrive { + PendingRedrive { + activity_id: [0x5A; 32], + anchor: [0x07; 32], + nullifiers: our_nullifiers(), + st_bytes, + attempts: 0, + } + } + + fn keys_map(keys: &[IdentityPublicKey]) -> BTreeMap { + keys.iter().map(|key| (key.id(), key.clone())).collect() + } + + /// A second key set that differs from `our_master_key()` only in the key + /// MATERIAL — same id, purpose and security level. This is the dangerous + /// shape: ids alone still line up, so anything comparing only ids would + /// wave it through and register a foreign identity at this wallet's slot. + fn other_master_key() -> IdentityPublicKey { + key_with_hash( + 0, + Purpose::AUTHENTICATION, + SecurityLevel::MASTER, + OTHER_MASTER_HASH, + ) + } + + /// THE DERIVE PATH: everything the resume needs is recoverable from the + /// serialized transition, which is why no record-schema change is required. + /// A round-trip through `StateTransition` must reproduce the exact key set + /// (by id AND content), the denomination, and the MASTER auth key hash that + /// idempotent recovery probes Platform with. + #[test] + fn claim_binding_is_recoverable_from_the_stored_transition() { + use dpp::serialization::PlatformDeserializable; + use dpp::state_transition::state_transitions::shielded::identity_create_from_shielded_pool_transition::accessors::IdentityCreateFromShieldedPoolTransitionAccessorsV0; + + let submitted = vec![ + our_master_key(), + key_with_hash(1, Purpose::TRANSFER, SecurityLevel::CRITICAL, [0xC3; 20]), + ]; + let (_, st_bytes) = stored_claim_transition(&submitted, DENOMINATION); + + let restored = + StateTransition::deserialize_from_bytes(&st_bytes).expect("stored bytes deserialize"); + let StateTransition::IdentityCreateFromShieldedPool(transition) = &restored else { + panic!("stored record must carry a shielded identity-create transition"); + }; + + let derived: BTreeMap = transition + .public_keys() + .iter() + .map(|key_in_creation| { + let key: IdentityPublicKey = key_in_creation.into(); + (key.id(), key) + }) + .collect(); + + assert_eq!( + derived, + keys_map(&submitted), + "the submitted key set must be recoverable from the transition itself" + ); + assert_eq!(transition.denomination(), DENOMINATION); + assert_eq!( + master_auth_public_key_hash_of(derived.values()), + Some(OUR_MASTER_HASH), + "the recovery handle must be derivable from the transition, not supplied by the retry" + ); + } + + /// MATCHING ARGS: a retry presenting exactly what the earlier attempt + /// submitted is a genuine resume and must pass the binding gate. + #[test] + fn matching_retry_arguments_resume() { + let submitted = vec![our_master_key()]; + let keys = keys_map(&submitted); + + assert_eq!( + one_time_claim_binding_mismatch( + &keys, + Some(OUR_MASTER_HASH), + DENOMINATION, + &keys, + Some(OUR_MASTER_HASH), + DENOMINATION, + ), + None, + "identical arguments must not be treated as a mis-binding" + ); + } + + /// Key ORDER is not a mismatch: both sides are keyed by key id, so a caller + /// that assembles the same keys in a different order still resumes. + #[test] + fn key_order_is_not_a_binding_mismatch() { + let forward = keys_map(&[ + our_master_key(), + key_with_hash(1, Purpose::TRANSFER, SecurityLevel::CRITICAL, [0xC3; 20]), + ]); + let reversed = keys_map(&[ + key_with_hash(1, Purpose::TRANSFER, SecurityLevel::CRITICAL, [0xC3; 20]), + our_master_key(), + ]); + + assert_eq!( + one_time_claim_binding_mismatch( + &forward, + Some(OUR_MASTER_HASH), + DENOMINATION, + &reversed, + Some(OUR_MASTER_HASH), + DENOMINATION, + ), + None + ); + } + + /// MISMATCHED ARGS, per field. Each of these is a way the pre-fix resume + /// would have acted on the caller's value while broadcasting the stored + /// bytes: a swapped key set registers a foreign identity at this wallet's + /// slot and backfills an empty proof result with keys that were never in the + /// transition; a swapped master hash makes idempotent recovery probe + /// Platform for someone else's identity; a swapped denomination misreports + /// the value that left the pool. + #[test] + fn mismatched_retry_arguments_are_refused_per_field() { + let stored = keys_map(&[our_master_key()]); + let swapped = keys_map(&[other_master_key()]); + + // Same key ids, different key material — ids alone would not catch it. + assert_eq!( + stored.keys().collect::>(), + swapped.keys().collect::>(), + "precondition: the swap keeps the key ids identical" + ); + + let key_mismatch = one_time_claim_binding_mismatch( + &stored, + Some(OUR_MASTER_HASH), + DENOMINATION, + &swapped, + Some(OUR_MASTER_HASH), + DENOMINATION, + ); + assert!( + key_mismatch.is_some_and(|m| m.contains("public key set")), + "a swapped key set must be refused" + ); + + let hash_mismatch = one_time_claim_binding_mismatch( + &stored, + Some(OUR_MASTER_HASH), + DENOMINATION, + &stored, + Some(OTHER_MASTER_HASH), + DENOMINATION, + ); + assert!( + hash_mismatch.is_some_and(|m| m.contains("master authentication key hash")), + "a recovery handle that is not in the stored transition must be refused" + ); + + let denomination_mismatch = one_time_claim_binding_mismatch( + &stored, + Some(OUR_MASTER_HASH), + DENOMINATION, + &stored, + Some(OUR_MASTER_HASH), + DENOMINATION * 3, + ); + assert!( + denomination_mismatch.is_some_and(|m| m.contains("denomination")), + "a different denomination must be refused" + ); + } + + /// END TO END, and the property that matters most: a mismatched retry must + /// fail CLOSED — refused with `ShieldedClaimBindingMismatch` **before** any + /// network work, with the pending record left intact so the correct retry + /// can still resume. The SDK here is a bare mock with no expectations + /// registered: reaching the spent-nullifier probe or the re-broadcast would + /// surface as something other than this error. + #[tokio::test] + async fn mismatched_retry_refuses_without_broadcasting_or_clearing_the_record() { + let sdk = Arc::new(dash_sdk::SdkBuilder::new_mock().build().expect("mock sdk")); + let path = temp_store_path("resume_binding"); + let store = Arc::new(RwLock::new( + FileBackedShieldedStore::open_path(&path, 100).expect("store opens"), + )); + let claim_records_id = SubwalletId::new([0x77; 32], ONE_TIME_CLAIM_RECORDS_ACCOUNT); + + let (_, st_bytes) = stored_claim_transition(&[our_master_key()], DENOMINATION); + let record = stored_claim_record(st_bytes); + store + .write() + .await + .arm_redrive(claim_records_id, record.clone()) + .expect("record arms"); + + // The retry presents a DIFFERENT identity's keys — the mis-slot case. + let outcome = resume_one_time_claim( + &sdk, + &store, + claim_records_id, + &record, + Some(OTHER_MASTER_HASH), + keys_map(&[other_master_key()]), + DENOMINATION, + ) + .await; + + match outcome { + OneTimeClaimResume::Resolved(Err( + PlatformWalletError::ShieldedClaimBindingMismatch { mismatch }, + )) => assert!( + mismatch.contains("master authentication key hash") + || mismatch.contains("public key set"), + "the refusal must name the binding that failed, got: {mismatch}" + ), + other => panic!( + "a retry with a different identity's keys must be refused, got {}", + match other { + OneTimeClaimResume::RecordUnusable => "RecordUnusable".to_string(), + OneTimeClaimResume::Resolved(r) => format!("Resolved({r:?})"), + } + ), + } + + // Fail-closed: the record survives, so the ORIGINAL claim is still + // resumable. Clearing it here would strand a padded single-note claim + // forever — its declared id exists nowhere else. + let surviving = store + .read() + .await + .pending_redrives(claim_records_id) + .expect("records readable"); + assert_eq!( + surviving.len(), + 1, + "a refused retry must not clear the pending-claim record" + ); + assert_eq!( + surviving[0].st_bytes, record.st_bytes, + "the stored transition must be untouched" + ); + + drop(store); + let _ = std::fs::remove_file(&path); + } } From 41cf07a37a0ee62b447883cd94c2c9c0b608f7a4 Mon Sep 17 00:00:00 2001 From: bfoss765 Date: Thu, 13 Aug 2026 12:46:38 -0400 Subject: [PATCH 25/26] fix(platform-wallet): fence one-time claims against purge at the SQLite boundary MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `purge_wallet` runs `DELETE FROM shielded_pending_spends WHERE wallet_id = ?1` and `purge_all_subwallets` deletes unfiltered, so `clear` / `unregister_wallet` / `remove_wallet` could delete the pending-claim record of a one-time-key claim whose transition was still broadcasting — and for a padded single-note bundle that record is the ONLY handle to the created identity (its declared id embeds a per-build random dummy nullifier), so the identity is then unrecoverable. Nothing in the process could order the two. The destructive paths take the coordinator's `lifecycle` mutex; `identity_create_from_one_time_key` takes none of it, and could not usefully be made to: `FileBackedShieldedStore::open_path` opens independent SQLite connections to the same file, so two coordinators — or two processes — share the records but no in-process lock, and the per-FVK `ForeignClaimGuards` are owned by one coordinator. Excluding `ONE_TIME_CLAIM_RECORDS_ACCOUNT` from the purges was considered and rejected: it does not stop a purge racing an arming claim, and it breaks `remove_wallet`'s full-wipe contract. Admission therefore moves to the only thing the two sides actually share — the store — as five `ShieldedStore` methods over a `shielded_lifecycle_admission` table in the same SQLite file: * a claim takes a LEASE, refused if a barrier already covers its wallet; * a destructive operation installs a BARRIER, which blocks new leases and reports the leases already live in scope, then waits for that count to reach zero; * the claim arms its record UNDER its lease (`arm_redrive_under_claim`), which re-checks and re-stamps the lease in the same atomic step, leaving no gap between "still admitted" and "record written". Correctness: both entry points are single `BEGIN IMMEDIATE` transactions, and SQLite admits one writer at a time across every connection and every process on the file, so they are totally ordered — lease first means the barrier counts it and the purge waits; barrier first means the lease is refused. There is no interleaving in which both proceed. `InMemoryShieldedStore` holds the same table in memory, where the shared object is the store behind one `RwLock` and the write guard supplies the same order. No transaction is held across scanning, proof construction, broadcast, or a confirmation wait. Failure is closed in both directions. `clear()` propagates `ShieldedLifecycleBusy` — its contract is that the host wipes its own rows only on `Ok`, so reporting success over an untouched store would desynchronize them. `unregister_wallet` skips the purge and warns, matching its existing contract, which already tolerates a purge that did not happen. A claim refused admission has scanned, built and broadcast nothing. Leases and barriers carry expiries because a holder can die (process kill, cancelled JNI call) with no chance to release. Expiry is a liveness backstop only: it never removes a LIVE admission, so it cannot let a purge delete a record under a running claim — it only bounds how long a dead claim blocks wallet removal and a dead purge blocks claims. The claim body is split into `one_time_claim_admitted` purely so the lease is released on every exit, including the many `?` paths, without threading a release through each. Tests: the cross-INSTANCE cases are the ones that matter, and each opens two `FileBackedShieldedStore`s on one file — barrier in A refuses a claim in B, a live lease in A is counted by B's barrier, and an armed record survives a concurrent purge attempt and is only wiped once the claim releases (proving the fence is a fence, not an exemption). Plus: arming refuses and writes nothing without a lease (verified durably through a cold reopen), expired leases stop blocking, scope matches the operation, and coordinator-level `clear()` / `unregister_wallet` refusal-and-retry under a paused clock. Reverting either half of the fence — the barrier check or the drain wait — fails four of them. Co-Authored-By: Claude Opus 4.8 --- packages/rs-platform-wallet/src/error.rs | 18 + .../src/wallet/shielded/coordinator.rs | 352 ++++++++++- .../src/wallet/shielded/file_store.rs | 547 +++++++++++++++++- .../src/wallet/shielded/operations.rs | 176 +++++- .../src/wallet/shielded/store.rs | 329 +++++++++++ 5 files changed, 1398 insertions(+), 24 deletions(-) diff --git a/packages/rs-platform-wallet/src/error.rs b/packages/rs-platform-wallet/src/error.rs index 87b1422385a..8fd882f4160 100644 --- a/packages/rs-platform-wallet/src/error.rs +++ b/packages/rs-platform-wallet/src/error.rs @@ -636,6 +636,24 @@ pub enum PlatformWalletError { )] ShieldedClaimBindingMismatch { mismatch: String }, + /// A shielded lifecycle operation could not obtain admission at the store, so it was refused + /// rather than allowed to run concurrently with the operation that holds it. + /// + /// Two directions, both retryable: + /// + /// * A **one-time-key claim** refused because `clear` / `unregister_wallet` / `remove_wallet` + /// holds destructive admission over its wallet. Nothing was scanned, built or broadcast. + /// * A **destructive operation** refused because in-flight claims still hold admission and did + /// not drain within the wait. Nothing was purged — deleting a pending-claim record while its + /// transition is on the wire strands the created identity, so the purge fails closed and the + /// caller retries. + /// + /// Admission is taken at the store rather than on the coordinator because that is the only + /// state two coordinators — or two processes on the same SQLite file — actually share + /// (`dashpay/platform#4313`). + #[error("Shielded lifecycle operation refused: {reason}")] + ShieldedLifecycleBusy { reason: String }, + #[error("Shielded key derivation failed: {0}")] ShieldedKeyDerivation(String), diff --git a/packages/rs-platform-wallet/src/wallet/shielded/coordinator.rs b/packages/rs-platform-wallet/src/wallet/shielded/coordinator.rs index a83396e3e38..24a2431fe3f 100644 --- a/packages/rs-platform-wallet/src/wallet/shielded/coordinator.rs +++ b/packages/rs-platform-wallet/src/wallet/shielded/coordinator.rs @@ -769,11 +769,134 @@ impl NetworkShieldedCoordinator { .retain(|id, _| id.wallet_id != wallet_id); self.persisters.write().await.remove(&wallet_id); self.hydrated.write().await.remove(&wallet_id); - if let Err(e) = self.store.write().await.purge_wallet(wallet_id) { + + // The purge runs under STORE-level destructive admission (#4313): the + // `lifecycle` mutex above serializes this against `clear` and against + // bind installs, but a one-time-key claim never takes it — and could + // not be made to, since a second coordinator on the same SQLite file + // would hold a different mutex. Admission waits for claims that are + // already in flight and locks out new ones; on failure the purge is + // SKIPPED rather than forced, because deleting a pending-claim record + // while its transition is on the wire strands the identity it created. + // Skipping is consistent with this method's existing contract, which + // already tolerates (and warns about) a purge that did not happen. + match self + .acquire_destructive_admission(Some(wallet_id), "unregister_wallet") + .await + { + Ok(admission) => { + if let Err(e) = self.store.write().await.purge_wallet(wallet_id) { + tracing::warn!( + wallet_id = %hex::encode(wallet_id), + error = %e, + "Failed to purge per-subwallet store state on unregister" + ); + } + self.release_destructive_admission(admission).await; + } + Err(e) => { + tracing::warn!( + wallet_id = %hex::encode(wallet_id), + error = %e, + "Skipping the per-subwallet store purge on unregister: a one-time-key claim \ + still holds lifecycle admission and its pending-claim record must survive. \ + The registries are cleared regardless, so no sync runs; retry the removal to \ + purge the store state." + ); + } + } + } + + /// Take store-level destructive admission over `scope` and wait for + /// in-flight one-time-key claims to drain (#4313). + /// + /// `scope` is `None` for a whole-store operation and `Some(wallet_id)` for + /// one wallet. On `Ok` the caller holds the barrier — no NEW claim can be + /// admitted for that scope — and no claim is mid-flight inside it; it must + /// pass the token to [`Self::release_destructive_admission`] when done. + /// + /// # Why this is at the store and not here + /// + /// The coordinator's `lifecycle` mutex is coordinator-local, and + /// `FileBackedShieldedStore::open_path` opens independent SQLite + /// connections to the same file, so two coordinators (or two processes) + /// share the pending-claim records but not any in-process lock. The + /// admission table lives in that same SQLite file, and both this call and + /// the claim's own admission are single `BEGIN IMMEDIATE` transactions, so + /// SQLite's one-writer rule totally orders them: either the claim's lease + /// is committed and counted here (we wait), or this barrier is committed + /// first and the claim is refused. See `store::LifecycleAdmission`. + /// + /// # Failing closed + /// + /// If claims do not drain within + /// [`DESTRUCTIVE_DRAIN_TIMEOUT`](super::store::DESTRUCTIVE_DRAIN_TIMEOUT) + /// this returns [`PlatformWalletError::ShieldedLifecycleBusy`] and drops + /// the barrier. Refusing to purge is the safe direction: a retry costs a + /// user gesture, whereas deleting an armed record mid-broadcast makes a + /// padded single-note claim's identity unrecoverable forever. + /// + /// The store write lock is taken only for each individual admission call, + /// never across the sleep — a waiting purge must not block the very claims + /// it is waiting for. + async fn acquire_destructive_admission( + &self, + scope: Option, + operation: &str, + ) -> Result { + use super::store::{ + admission_now_ms, AdmissionToken, DESTRUCTIVE_BARRIER_MS, DESTRUCTIVE_DRAIN_POLL, + DESTRUCTIVE_DRAIN_TIMEOUT, + }; + + let token = AdmissionToken::new(); + // `tokio::time::Instant`, not `std::time::Instant`: the drain wait and + // the sleep below must run on the same clock, which also lets tests + // drive the whole wait deterministically under a paused runtime. + let started = tokio::time::Instant::now(); + loop { + // Re-taking the barrier each pass also REFRESHES its expiry, so a + // long drain cannot let the barrier lapse and admit a new claim. + let live = { + let mut store = self.store.write().await; + store + .begin_destructive_admission( + scope, + token, + admission_now_ms(), + DESTRUCTIVE_BARRIER_MS, + ) + .map_err(|e| { + crate::error::PlatformWalletError::ShieldedStoreError(format!( + "{operation}: could not take lifecycle admission: {e}" + )) + })? + }; + if live == 0 { + return Ok(token); + } + if started.elapsed() >= DESTRUCTIVE_DRAIN_TIMEOUT { + self.release_destructive_admission(token).await; + return Err(crate::error::PlatformWalletError::ShieldedLifecycleBusy { + reason: format!( + "{operation}: {live} one-time-key claim(s) still in flight after \ + {}s; refusing to purge state a live claim may still need", + DESTRUCTIVE_DRAIN_TIMEOUT.as_secs() + ), + }); + } + tokio::time::sleep(DESTRUCTIVE_DRAIN_POLL).await; + } + } + + /// Drop the destructive barrier taken by + /// [`Self::acquire_destructive_admission`]. Best-effort: the barrier also + /// expires on its own, so a failure here only delays new claims. + async fn release_destructive_admission(&self, token: super::store::AdmissionToken) { + if let Err(e) = self.store.write().await.end_destructive_admission(token) { tracing::warn!( - wallet_id = %hex::encode(wallet_id), error = %e, - "Failed to purge per-subwallet store state on unregister" + "Failed to release shielded lifecycle admission; it will expire on its own" ); } } @@ -1013,6 +1136,18 @@ impl NetworkShieldedCoordinator { // order. let _lifecycle = self.lifecycle.lock().await; + // Store-level destructive admission over EVERY wallet (#4313). The + // `lifecycle` mutex above excludes binds and `unregister_wallet`, but + // one-time-key claims never take it and a second coordinator on the + // same SQLite file would not share it anyway. This waits for in-flight + // claims and locks out new ones for the duration of the wipe. + // + // Unlike `unregister_wallet`, a failure here is PROPAGATED rather than + // logged: `clear()`'s contract is that the host only wipes its own + // per-wallet rows once this returns `Ok`, so reporting success while + // the store was left intact would desynchronize the two halves. + let admission = self.acquire_destructive_admission(None, "clear").await?; + // Reset the persistent store FIRST and bail before mutating any // in-memory state if it fails. Clearing `accounts` / `persisters` // makes the coordinator forget every bound wallet (no syncs until @@ -1075,6 +1210,11 @@ impl NetworkShieldedCoordinator { } } } + // The wipe is done; new claims may be admitted again. Released before + // the tail below so a failed clear does not hold the barrier for the + // rest of the call. + self.release_destructive_admission(admission).await; + // Hydration and snapshot validity go regardless of the outcome // above, because the subwallet purge runs FIRST: a failure in a // later step still leaves the per-subwallet notes and watermarks @@ -2689,4 +2829,210 @@ mod tests { let _ = std::fs::remove_dir_all(&dir); } + + // ── Lifecycle admission (#4313 review finding cr-7e6c98b9) ───────── + // + // `clear` / `unregister_wallet` hold the coordinator's `lifecycle` mutex; + // a one-time-key claim holds none of it, and a SECOND coordinator on the + // same SQLite file could not share it anyway. These tests drive the claim + // side through the store — exactly as `identity_create_from_one_time_key` + // does, and exactly as a second coordinator would — and assert the purge + // refuses rather than deleting the claim's recovery record. + + /// The claim side of a one-time-key claim that is in flight: take the + /// store lease and arm the recovery record under it, leaving both live. + async fn arm_an_in_flight_claim( + coordinator: &NetworkShieldedCoordinator, + wallet_id: WalletId, + ) -> (crate::wallet::shielded::store::AdmissionToken, SubwalletId) { + use crate::wallet::shielded::store::{ + admission_now_ms, AdmissionToken, PendingRedrive, CLAIM_LEASE_MS, + }; + + let id = SubwalletId::new(wallet_id, u32::MAX); + let lease = AdmissionToken::new(); + let mut store = coordinator.store().write().await; + assert!(store + .begin_claim_admission(wallet_id, lease, admission_now_ms(), CLAIM_LEASE_MS) + .expect("claim admission")); + assert!(store + .arm_redrive_under_claim( + id, + PendingRedrive { + activity_id: [0x5A; 32], + anchor: [0x0A; 32], + nullifiers: vec![[0x0B; 32]], + st_bytes: vec![0xCD; 64], + attempts: 0, + }, + lease, + admission_now_ms(), + CLAIM_LEASE_MS, + ) + .expect("arm under lease")); + (lease, id) + } + + /// `clear()` must FAIL rather than wipe a record an in-flight claim is + /// still depending on. Failing is the load-bearing direction: the host + /// only wipes its own per-wallet rows once `clear()` returns `Ok`, so a + /// silent skip would desynchronize the two halves — and deleting the + /// record would strand the identity the claim's transition creates. + /// + /// Time is paused, so the full drain wait elapses instantly; the lease's + /// own expiry is wall-clock and therefore does NOT advance, which is what + /// keeps the claim "live" for the whole wait. + #[tokio::test(start_paused = true)] + async fn clear_refuses_while_a_one_time_claim_holds_admission() { + let dir = temp_dir("clear_admission_busy"); + let coordinator = coordinator_with_one_wallet(&dir).await; + let wallet_id: WalletId = [0x11; 32]; + let (lease, id) = arm_an_in_flight_claim(&coordinator, wallet_id).await; + + let cleared = coordinator.clear().await; + assert!( + matches!( + cleared, + Err(crate::error::PlatformWalletError::ShieldedLifecycleBusy { .. }) + ), + "clear() must refuse while a claim holds admission, got {cleared:?}" + ); + assert_eq!( + coordinator + .store() + .read() + .await + .pending_redrives(id) + .expect("records") + .len(), + 1, + "the in-flight claim's recovery record must survive the refused clear" + ); + + // Claim finishes: the very next clear() drains immediately and wipes. + coordinator + .store() + .write() + .await + .end_claim_admission(lease) + .expect("release lease"); + coordinator + .clear() + .await + .expect("clear must succeed once the claim has released"); + assert!( + coordinator + .store() + .read() + .await + .pending_redrives(id) + .expect("records") + .is_empty(), + "an admitted clear is still a FULL wipe — no account is exempted from it" + ); + + let _ = std::fs::remove_dir_all(&dir); + } + + /// `unregister_wallet` returns `()` and has always tolerated a purge that + /// did not happen (it warns). A live claim is one more such case: the + /// registries are dropped either way — so no sync runs — but the store + /// purge is SKIPPED rather than forced, leaving the claim's recovery + /// record intact. + #[tokio::test(start_paused = true)] + async fn unregister_skips_the_purge_while_a_one_time_claim_holds_admission() { + let dir = temp_dir("unregister_admission_busy"); + let coordinator = coordinator_with_one_wallet(&dir).await; + let wallet_id: WalletId = [0x11; 32]; + let (lease, id) = arm_an_in_flight_claim(&coordinator, wallet_id).await; + + coordinator.unregister_wallet(wallet_id).await; + + assert!( + coordinator.registered_subwallets().await.is_empty(), + "the registries are dropped regardless, so no sync runs for a removed wallet" + ); + assert_eq!( + coordinator + .store() + .read() + .await + .pending_redrives(id) + .expect("records") + .len(), + 1, + "the in-flight claim's recovery record must survive the skipped purge" + ); + + // Retrying after the claim settles completes the purge. + coordinator + .store() + .write() + .await + .end_claim_admission(lease) + .expect("release lease"); + coordinator.unregister_wallet(wallet_id).await; + assert!( + coordinator + .store() + .read() + .await + .pending_redrives(id) + .expect("records") + .is_empty(), + "the retry must complete the full purge" + ); + + let _ = std::fs::remove_dir_all(&dir); + } + + /// A wallet-scoped purge must not be stalled by an UNRELATED wallet's + /// claim — the fence is scoped like the operation that takes it, so + /// removing wallet A while wallet B is mid-claim still works. + #[tokio::test(start_paused = true)] + async fn unregister_is_not_blocked_by_another_wallets_claim() { + let dir = temp_dir("unregister_admission_scope"); + let coordinator = coordinator_with_one_wallet(&dir).await; + let registered: WalletId = [0x11; 32]; + let other: WalletId = [0x99; 32]; + let (_lease, other_id) = arm_an_in_flight_claim(&coordinator, other).await; + + use crate::wallet::shielded::store::PendingRedrive; + + let purged_id = SubwalletId::new(registered, u32::MAX); + coordinator + .store() + .write() + .await + .arm_redrive( + purged_id, + PendingRedrive { + activity_id: [0x11; 32], + anchor: [0x0A; 32], + nullifiers: vec![[0x0C; 32]], + st_bytes: vec![0xEF; 32], + attempts: 0, + }, + ) + .expect("arm an unrelated record"); + + coordinator.unregister_wallet(registered).await; + + let store = coordinator.store().read().await; + assert!( + store + .pending_redrives(purged_id) + .expect("records") + .is_empty(), + "the removed wallet's rows must go — an unrelated wallet's claim must not stall it" + ); + assert_eq!( + store.pending_redrives(other_id).expect("records").len(), + 1, + "the other wallet's in-flight claim record is untouched" + ); + drop(store); + + let _ = std::fs::remove_dir_all(&dir); + } } diff --git a/packages/rs-platform-wallet/src/wallet/shielded/file_store.rs b/packages/rs-platform-wallet/src/wallet/shielded/file_store.rs index a91f95f0b9e..13bd8f56b79 100644 --- a/packages/rs-platform-wallet/src/wallet/shielded/file_store.rs +++ b/packages/rs-platform-wallet/src/wallet/shielded/file_store.rs @@ -20,8 +20,8 @@ use std::sync::Mutex; use grovedb_commitment_tree::{ClientPersistentCommitmentTree, Position, Retention}; use super::store::{ - PendingRedrive, ShieldedNote, ShieldedOutgoingNote, ShieldedStore, StalePendingSpend, - SubwalletId, SubwalletState, + AdmissionToken, PendingRedrive, ShieldedNote, ShieldedOutgoingNote, ShieldedStore, + StalePendingSpend, SubwalletId, SubwalletState, }; use crate::wallet::platform_wallet::WalletId; @@ -118,6 +118,30 @@ impl FileBackedShieldedStore { [], ) .map_err(|e| FileShieldedStoreError(format!("create pending_spends table: {e}")))?; + // Cross-instance / cross-PROCESS lifecycle admission (#4313). Lives in + // the same SQLite file as the records it protects — that file is the + // only thing two `FileBackedShieldedStore` instances (or two + // processes) opened on the same path actually share, and SQLite's + // one-writer-at-a-time rule is what makes the protocol's two entry + // points totally ordered. See `store::LifecycleAdmission`. + // + // Deliberately NOT rehydrated into memory and deliberately not wiped + // at open: rows are judged purely by `expires_at`, so a holder that + // died leaves an entry that simply ages out, and a LIVE holder in + // another process keeps its admission across our open. + pending_conn + .execute( + "CREATE TABLE IF NOT EXISTS shielded_lifecycle_admission ( + token BLOB NOT NULL PRIMARY KEY, + destructive INTEGER NOT NULL, + wallet_id BLOB, + expires_at INTEGER NOT NULL + )", + [], + ) + .map_err(|e| { + FileShieldedStoreError(format!("create lifecycle_admission table: {e}")) + })?; let mut store = Self { tree: Mutex::new(tree), path, @@ -225,6 +249,34 @@ impl FileBackedShieldedStore { Ok(conn) } + /// Unix millis as SQLite's native signed 64-bit integer. + /// + /// Saturating rather than wrapping: a caller that adds an absurd lease to + /// `now` must produce a far-future deadline, never a negative one that + /// would read as already expired and silently drop the fence. + fn as_sqlite_millis(millis: u64) -> i64 { + i64::try_from(millis).unwrap_or(i64::MAX) + } + + /// Drop every admission whose deadline has passed. + /// + /// Called at the top of both admission-taking transactions, so a holder + /// that died — process kill, cancelled coroutine — cannot block the other + /// side forever. This is a LIVENESS backstop only: it never removes a live + /// admission, so it cannot let a purge delete a record out from under a + /// claim that is still running. + fn reap_expired_admissions( + tx: &rusqlite::Transaction<'_>, + now_ms: u64, + ) -> Result<(), FileShieldedStoreError> { + tx.execute( + "DELETE FROM shielded_lifecycle_admission WHERE expires_at <= ?1", + rusqlite::params![Self::as_sqlite_millis(now_ms)], + ) + .map_err(|e| FileShieldedStoreError(format!("reap expired admissions: {e}")))?; + Ok(()) + } + /// Delete the single persisted redrive row for `id` keyed by /// `activity_id`. Used to mirror the exact in-memory drops /// [`SubwalletState::mark_spent`] reports, avoiding the @@ -735,6 +787,181 @@ impl ShieldedStore for FileBackedShieldedStore { .map_err(|e| FileShieldedStoreError(format!("reopen commitment tree: {e}")))?; Ok(()) } + + // ── Lifecycle admission ──────────────────────────────────────────── + // + // Every method below runs its whole check-and-write inside ONE + // `BEGIN IMMEDIATE` transaction. That is the entire correctness argument: + // SQLite admits a single write transaction at a time across every + // connection AND every process on the file, so `begin_claim_admission` and + // `begin_destructive_admission` are totally ordered even between two + // `FileBackedShieldedStore` instances that share nothing else. See + // `store::LifecycleAdmission` for both orders and why each is safe. + // + // `busy_timeout` (5 s, set in `open_tuned_connection`) absorbs contention; + // no transaction here spans an await, a scan, a proof or a broadcast. + + fn begin_claim_admission( + &mut self, + wallet_id: WalletId, + token: AdmissionToken, + now_ms: u64, + lease_ms: u64, + ) -> Result { + let mut conn = self.pending_conn.lock().expect("pending_conn mutex"); + let tx = conn + .transaction_with_behavior(rusqlite::TransactionBehavior::Immediate) + .map_err(|e| FileShieldedStoreError(format!("begin claim admission: {e}")))?; + Self::reap_expired_admissions(&tx, now_ms)?; + // A store-wide barrier (`wallet_id IS NULL`, from `clear`) covers every + // wallet; a scoped one covers only its own. + let blocked: i64 = tx + .query_row( + "SELECT COUNT(*) FROM shielded_lifecycle_admission \ + WHERE destructive = 1 AND (wallet_id IS NULL OR wallet_id = ?1)", + rusqlite::params![wallet_id.as_slice()], + |row| row.get(0), + ) + .map_err(|e| FileShieldedStoreError(format!("read destructive barriers: {e}")))?; + if blocked > 0 { + // Roll back explicitly: nothing was written, and the claim must + // see a clean refusal rather than a half-open admission. + return Ok(false); + } + tx.execute( + "INSERT OR REPLACE INTO shielded_lifecycle_admission \ + (token, destructive, wallet_id, expires_at) VALUES (?1, 0, ?2, ?3)", + rusqlite::params![ + token.0.as_slice(), + wallet_id.as_slice(), + Self::as_sqlite_millis(now_ms.saturating_add(lease_ms)), + ], + ) + .map_err(|e| FileShieldedStoreError(format!("insert claim lease: {e}")))?; + tx.commit() + .map_err(|e| FileShieldedStoreError(format!("commit claim admission: {e}")))?; + Ok(true) + } + + fn arm_redrive_under_claim( + &mut self, + id: SubwalletId, + redrive: PendingRedrive, + token: AdmissionToken, + now_ms: u64, + lease_ms: u64, + ) -> Result { + { + let mut conn = self.pending_conn.lock().expect("pending_conn mutex"); + let tx = conn + .transaction_with_behavior(rusqlite::TransactionBehavior::Immediate) + .map_err(|e| FileShieldedStoreError(format!("begin armed claim write: {e}")))?; + let live: i64 = tx + .query_row( + "SELECT COUNT(*) FROM shielded_lifecycle_admission \ + WHERE token = ?1 AND destructive = 0 AND expires_at > ?2", + rusqlite::params![token.0.as_slice(), Self::as_sqlite_millis(now_ms)], + |row| row.get(0), + ) + .map_err(|e| FileShieldedStoreError(format!("read claim lease: {e}")))?; + if live == 0 { + // Lease gone (expired, or released). Write NOTHING and let the + // caller fail closed — arming a record the store is no longer + // holding open for us is how an in-flight claim loses its only + // recovery handle. + return Ok(false); + } + let nullifier_blob: Vec = redrive.nullifiers.iter().flatten().copied().collect(); + tx.execute( + "INSERT OR REPLACE INTO shielded_pending_spends \ + (wallet_id, account_index, activity_id, anchor, nullifiers, st_bytes, attempts) \ + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)", + rusqlite::params![ + id.wallet_id.as_slice(), + id.account_index, + redrive.activity_id.as_slice(), + redrive.anchor.as_slice(), + nullifier_blob, + redrive.st_bytes, + redrive.attempts, + ], + ) + .map_err(|e| FileShieldedStoreError(format!("persist claim record: {e}")))?; + // Re-stamp in the SAME transaction, so the lease that admitted this + // write is the one that covers the broadcast which follows it. + tx.execute( + "UPDATE shielded_lifecycle_admission SET expires_at = ?2 WHERE token = ?1", + rusqlite::params![ + token.0.as_slice(), + Self::as_sqlite_millis(now_ms.saturating_add(lease_ms)), + ], + ) + .map_err(|e| FileShieldedStoreError(format!("restamp claim lease: {e}")))?; + tx.commit() + .map_err(|e| FileShieldedStoreError(format!("commit armed claim write: {e}")))?; + } + self.subwallets.entry(id).or_default().arm_redrive(redrive); + Ok(true) + } + + fn end_claim_admission(&mut self, token: AdmissionToken) -> Result<(), Self::Error> { + let conn = self.pending_conn.lock().expect("pending_conn mutex"); + conn.execute( + "DELETE FROM shielded_lifecycle_admission WHERE token = ?1 AND destructive = 0", + rusqlite::params![token.0.as_slice()], + ) + .map_err(|e| FileShieldedStoreError(format!("release claim lease: {e}")))?; + Ok(()) + } + + fn begin_destructive_admission( + &mut self, + scope: Option, + token: AdmissionToken, + now_ms: u64, + barrier_ms: u64, + ) -> Result { + let mut conn = self.pending_conn.lock().expect("pending_conn mutex"); + let tx = conn + .transaction_with_behavior(rusqlite::TransactionBehavior::Immediate) + .map_err(|e| FileShieldedStoreError(format!("begin destructive admission: {e}")))?; + Self::reap_expired_admissions(&tx, now_ms)?; + let scope_bytes = scope.map(|id| id.to_vec()); + // Barrier first, count second, one transaction: a claim is either + // refused by the barrier or counted here, never both and never neither. + tx.execute( + "INSERT OR REPLACE INTO shielded_lifecycle_admission \ + (token, destructive, wallet_id, expires_at) VALUES (?1, 1, ?2, ?3)", + rusqlite::params![ + token.0.as_slice(), + scope_bytes, + Self::as_sqlite_millis(now_ms.saturating_add(barrier_ms)), + ], + ) + .map_err(|e| FileShieldedStoreError(format!("insert destructive barrier: {e}")))?; + // `?1 IS NULL` makes a store-wide purge count every wallet's claims. + let live: i64 = tx + .query_row( + "SELECT COUNT(*) FROM shielded_lifecycle_admission \ + WHERE destructive = 0 AND (?1 IS NULL OR wallet_id = ?1)", + rusqlite::params![scope_bytes], + |row| row.get(0), + ) + .map_err(|e| FileShieldedStoreError(format!("count live claim leases: {e}")))?; + tx.commit() + .map_err(|e| FileShieldedStoreError(format!("commit destructive admission: {e}")))?; + Ok(live.max(0) as usize) + } + + fn end_destructive_admission(&mut self, token: AdmissionToken) -> Result<(), Self::Error> { + let conn = self.pending_conn.lock().expect("pending_conn mutex"); + conn.execute( + "DELETE FROM shielded_lifecycle_admission WHERE token = ?1 AND destructive = 1", + rusqlite::params![token.0.as_slice()], + ) + .map_err(|e| FileShieldedStoreError(format!("release destructive barrier: {e}")))?; + Ok(()) + } } #[cfg(test)] @@ -1247,4 +1474,320 @@ mod tests { recorded set is exactly these two and the mid-block anchor is outside it" ); } + + // ── Lifecycle admission (#4313) ──────────────────────────────────── + // + // The fence's whole point is that it works between store INSTANCES, which + // is what a coordinator-local `tokio::sync::Mutex` cannot do: every test + // below that matters opens two `FileBackedShieldedStore`s on the same file, + // exactly as two `NetworkShieldedCoordinator`s (or two processes) would. + + /// The reserved account claim records live under, mirrored here so these + /// tests exercise the real key space. + const CLAIM_ACCOUNT: u32 = u32::MAX; + + fn admission_record(activity: u8) -> PendingRedrive { + PendingRedrive { + activity_id: [activity; 32], + anchor: [0x0A; 32], + nullifiers: vec![[0x0B; 32]], + st_bytes: vec![0xCD; 64], + attempts: 0, + } + } + + /// A destructive barrier taken by one store instance REFUSES a claim + /// admitted through a different instance on the same file. + /// + /// This is the interleaving the coordinator-local guard cannot cover: the + /// two stores share the SQLite file and nothing else. + #[test] + fn a_barrier_in_one_store_instance_refuses_a_claim_in_another() { + let path = temp_tree_path("admission_barrier_blocks"); + let wallet_id: WalletId = [0x21; 32]; + let mut purger = FileBackedShieldedStore::open_path(&path, 8).expect("store a"); + let mut claimer = FileBackedShieldedStore::open_path(&path, 8).expect("store b"); + let now = 1_000_000; + + let barrier = AdmissionToken::new(); + assert_eq!( + purger + .begin_destructive_admission(Some(wallet_id), barrier, now, 60_000) + .expect("barrier"), + 0, + "no claim is in flight yet" + ); + + assert!( + !claimer + .begin_claim_admission(wallet_id, AdmissionToken::new(), now, 60_000) + .expect("claim admission"), + "a claim must be refused while another instance holds destructive admission" + ); + + // Releasing the barrier lets claims back in. + purger + .end_destructive_admission(barrier) + .expect("release barrier"); + assert!(claimer + .begin_claim_admission(wallet_id, AdmissionToken::new(), now, 60_000) + .expect("claim admission")); + + drop((purger, claimer)); + let _ = std::fs::remove_file(&path); + } + + /// The other order: a claim lease taken through one instance is COUNTED by + /// a destructive admission taken through another, so the purge waits + /// instead of deleting the record out from under an in-flight claim. + #[test] + fn a_live_claim_in_one_store_instance_is_counted_by_another() { + let path = temp_tree_path("admission_lease_counted"); + let wallet_id: WalletId = [0x22; 32]; + let mut claimer = FileBackedShieldedStore::open_path(&path, 8).expect("store a"); + let mut purger = FileBackedShieldedStore::open_path(&path, 8).expect("store b"); + let now = 1_000_000; + + let lease = AdmissionToken::new(); + assert!(claimer + .begin_claim_admission(wallet_id, lease, now, 60_000) + .expect("claim admission")); + + assert_eq!( + purger + .begin_destructive_admission(Some(wallet_id), AdmissionToken::new(), now, 60_000) + .expect("barrier"), + 1, + "the purge must see the other instance's in-flight claim and wait" + ); + + // Once the claim releases, the next poll drains. + claimer.end_claim_admission(lease).expect("release lease"); + assert_eq!( + purger + .begin_destructive_admission(Some(wallet_id), AdmissionToken::new(), now, 60_000) + .expect("barrier refresh"), + 0 + ); + + drop((claimer, purger)); + let _ = std::fs::remove_file(&path); + } + + /// Arming is admitted in the SAME step as the lease re-check, and refuses — + /// writing nothing — once the lease is gone. This is the gap a separate + /// "check, then write" would leave open for a purge to slot into. + #[test] + fn arming_refuses_and_writes_nothing_once_the_lease_is_gone() { + let path = temp_tree_path("admission_arm_refuses"); + let wallet_id: WalletId = [0x23; 32]; + let id = SubwalletId::new(wallet_id, CLAIM_ACCOUNT); + let mut store = FileBackedShieldedStore::open_path(&path, 8).expect("store"); + let now = 1_000_000; + + let lease = AdmissionToken::new(); + assert!(store + .begin_claim_admission(wallet_id, lease, now, 60_000) + .expect("claim admission")); + assert!( + store + .arm_redrive_under_claim(id, admission_record(0x01), lease, now, 60_000) + .expect("arm under a live lease"), + "a live lease must admit the record write" + ); + assert_eq!(store.pending_redrives(id).expect("records").len(), 1); + + // Lease released (or expired): a further arm must be refused outright. + store.end_claim_admission(lease).expect("release lease"); + assert!( + !store + .arm_redrive_under_claim(id, admission_record(0x02), lease, now, 60_000) + .expect("arm without a lease"), + "arming without a live lease must be refused" + ); + let records = store.pending_redrives(id).expect("records"); + assert_eq!( + records.len(), + 1, + "the refused arm must not have written anything" + ); + assert_eq!(records[0].activity_id, [0x01; 32]); + + // …and the refusal is durable, not just in-memory: a cold reopen sees + // only the admitted record. + drop(store); + let reopened = FileBackedShieldedStore::open_path(&path, 8).expect("reopen"); + assert_eq!(reopened.pending_redrives(id).expect("records").len(), 1); + drop(reopened); + let _ = std::fs::remove_file(&path); + } + + /// An expired lease is a LIVENESS backstop, not a hole: it never removes a + /// live claim, it only stops a holder that died from blocking wallet + /// removal forever. + #[test] + fn an_expired_lease_stops_blocking_the_purge() { + let path = temp_tree_path("admission_lease_expiry"); + let wallet_id: WalletId = [0x24; 32]; + let mut store = FileBackedShieldedStore::open_path(&path, 8).expect("store"); + let now = 1_000_000; + + // A lease that is already dead by the time the purge looks. + assert!(store + .begin_claim_admission(wallet_id, AdmissionToken::new(), now, 10) + .expect("claim admission")); + assert_eq!( + store + .begin_destructive_admission( + Some(wallet_id), + AdmissionToken::new(), + now + 5, + 60_000 + ) + .expect("barrier while the lease is live"), + 1, + "a lease that has not expired yet must still block" + ); + assert_eq!( + store + .begin_destructive_admission( + Some(wallet_id), + AdmissionToken::new(), + now + 5_000, + 60_000 + ) + .expect("barrier after the lease expired"), + 0, + "an expired lease must be reaped so wallet removal can proceed" + ); + + drop(store); + let _ = std::fs::remove_file(&path); + } + + /// Scope: `purge_wallet`'s barrier is wallet-scoped and must not refuse + /// another wallet's claim, while `clear`'s store-wide barrier refuses both. + #[test] + fn barrier_scope_matches_the_lifecycle_operation() { + let path = temp_tree_path("admission_scope"); + let mine: WalletId = [0x25; 32]; + let theirs: WalletId = [0x26; 32]; + let mut store = FileBackedShieldedStore::open_path(&path, 8).expect("store"); + let now = 1_000_000; + + let scoped = AdmissionToken::new(); + store + .begin_destructive_admission(Some(mine), scoped, now, 60_000) + .expect("scoped barrier"); + assert!( + !store + .begin_claim_admission(mine, AdmissionToken::new(), now, 60_000) + .expect("own-wallet claim"), + "a wallet-scoped barrier must refuse that wallet's claims" + ); + let other_lease = AdmissionToken::new(); + assert!( + store + .begin_claim_admission(theirs, other_lease, now, 60_000) + .expect("other-wallet claim"), + "a wallet-scoped barrier must not refuse an unrelated wallet's claim" + ); + store + .end_destructive_admission(scoped) + .expect("release scoped"); + store + .end_claim_admission(other_lease) + .expect("release other lease"); + + // Store-wide (`clear`) refuses everything… + let wide = AdmissionToken::new(); + store + .begin_destructive_admission(None, wide, now, 60_000) + .expect("store-wide barrier"); + assert!(!store + .begin_claim_admission(mine, AdmissionToken::new(), now, 60_000) + .expect("claim under a store-wide barrier")); + assert!(!store + .begin_claim_admission(theirs, AdmissionToken::new(), now, 60_000) + .expect("claim under a store-wide barrier")); + store.end_destructive_admission(wide).expect("release wide"); + + // …and counts every wallet's claims when deciding whether to wait. + assert!(store + .begin_claim_admission(mine, AdmissionToken::new(), now, 60_000) + .expect("claim")); + assert!(store + .begin_claim_admission(theirs, AdmissionToken::new(), now, 60_000) + .expect("claim")); + assert_eq!( + store + .begin_destructive_admission(None, AdmissionToken::new(), now, 60_000) + .expect("store-wide barrier"), + 2, + "clear() must wait for every wallet's in-flight claims" + ); + + drop(store); + let _ = std::fs::remove_file(&path); + } + + /// THE FINDING, end to end at the store: an armed claim record is NOT + /// deleted by a concurrent purge, because the purge cannot get past the + /// live lease — even though the purge runs through a different store + /// instance, which is precisely where the coordinator-local guard failed. + /// + /// The second half shows the fence is a fence and not a lock-out: once the + /// claim releases, the purge is admitted and the record goes with it, so + /// `remove_wallet`'s full-wipe contract is unchanged. + #[test] + fn a_purge_cannot_delete_a_record_while_the_claim_that_armed_it_is_live() { + let path = temp_tree_path("admission_end_to_end"); + let wallet_id: WalletId = [0x27; 32]; + let id = SubwalletId::new(wallet_id, CLAIM_ACCOUNT); + let mut claimer = FileBackedShieldedStore::open_path(&path, 8).expect("claimer store"); + let mut purger = FileBackedShieldedStore::open_path(&path, 8).expect("purger store"); + let now = 1_000_000; + + let lease = AdmissionToken::new(); + assert!(claimer + .begin_claim_admission(wallet_id, lease, now, 60_000) + .expect("claim admission")); + assert!(claimer + .arm_redrive_under_claim(id, admission_record(0x09), lease, now, 60_000) + .expect("arm")); + + // The purge's own admission tells it to wait — so it never calls + // `purge_wallet`, and the record survives. + assert_eq!( + purger + .begin_destructive_admission(Some(wallet_id), AdmissionToken::new(), now, 60_000) + .expect("barrier"), + 1, + "the purge must be told to wait, not cleared to delete" + ); + assert_eq!( + claimer.pending_redrives(id).expect("records").len(), + 1, + "the in-flight claim's recovery record must still be there" + ); + + // Claim done: the purge drains and the full wipe proceeds as before. + claimer.end_claim_admission(lease).expect("release lease"); + assert_eq!( + purger + .begin_destructive_admission(Some(wallet_id), AdmissionToken::new(), now, 60_000) + .expect("barrier refresh"), + 0 + ); + purger.purge_wallet(wallet_id).expect("purge"); + drop((claimer, purger)); + + let reopened = FileBackedShieldedStore::open_path(&path, 8).expect("reopen"); + assert!( + reopened.pending_redrives(id).expect("records").is_empty(), + "once admitted, the purge is still a FULL wipe — no reserved account is exempted" + ); + drop(reopened); + let _ = std::fs::remove_file(&path); + } } diff --git a/packages/rs-platform-wallet/src/wallet/shielded/operations.rs b/packages/rs-platform-wallet/src/wallet/shielded/operations.rs index 84c3089bfb4..10e1a63d0ac 100644 --- a/packages/rs-platform-wallet/src/wallet/shielded/operations.rs +++ b/packages/rs-platform-wallet/src/wallet/shielded/operations.rs @@ -1683,20 +1683,6 @@ where // 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 - // transient scan cannot seed its start from a height; it bounds itself by - // value coverage plus a coordinator-owned resume checkpoint (one - // full-history scan per key per coordinator — see - // `scan_notes_for_foreign_key`). 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(); // The invitee's re-derivable MASTER auth key hash: the unique, Platform-indexed @@ -1732,6 +1718,131 @@ where let lifecycle_entry = claim_guards.entry_for(claim_record_key); let _lifecycle_guard = lifecycle_entry.lock().await; + // ---- Store-level lifecycle admission (#4313 review finding cr-7e6c98b9) ---- + // + // The guard above is per-COORDINATOR: it serializes same-key claims that + // share this `NetworkShieldedCoordinator`, and nothing else. It cannot + // order this claim against `clear` / `unregister_wallet` / `remove_wallet`, + // which take the coordinator's `lifecycle` mutex (a claim takes neither), + // and it cannot reach a SECOND coordinator or process at all — those get + // their own `FileBackedShieldedStore` with its own SQLite connections to + // the same file. Without admission, such a purge deletes this claim's + // pending record while its transition is broadcasting, and the identity it + // creates is unrecoverable. + // + // So admission is taken where the contention actually is — the store. A + // destructive operation that already holds admission refuses this claim + // outright (nothing scanned, built or broadcast); one that starts later + // sees this lease and waits for it. Both directions are decided by a + // single atomic store step on each side, so there is no interleaving in + // which both proceed — see `store::LifecycleAdmission`. + // + // Released deterministically after the claim body below. A claim CANCELLED + // mid-flight (a dropped JNI call) cannot run an async release from `Drop`, + // so its lease is reclaimed by expiry instead — which errs toward "the + // purge waits", never toward "the record is deleted". + let admission = super::store::AdmissionToken::new(); + let admitted = store + .write() + .await + .begin_claim_admission( + wallet_id, + admission, + super::store::admission_now_ms(), + super::store::CLAIM_LEASE_MS, + ) + .map_err(|e| { + PlatformWalletError::Persistence(format!( + "one-time claim: could not take store lifecycle admission: {e}" + )) + })?; + if !admitted { + return Err(PlatformWalletError::ShieldedLifecycleBusy { + reason: "this wallet's shielded state is being cleared or removed; the invitation \ + claim was not started" + .to_string(), + }); + } + + let claim_result = one_time_claim_admitted( + sdk, + store, + scan_checkpoints, + wallet_id, + claim_record_key, + admission, + fvk, + ivk, + ask, + funding_birth_height, + change_address, + public_keys, + num_keys, + master_key_hash, + submitted_public_keys, + denomination, + send_to_address_on_creation_failure, + identity_signer, + prover, + ) + .await; + + if let Err(e) = store.write().await.end_claim_admission(admission) { + warn!( + error = %e, + "one-time claim: failed to release the store lifecycle admission; it expires on its own" + ); + } + claim_result +} + +/// The one-time-key claim body, running under a held store admission lease. +/// +/// Split out of [`identity_create_from_one_time_key`] purely so the lease is +/// released on EVERY exit — including the many `?` paths — without threading a +/// release through each of them (#4313). The caller owns acquire/release; this +/// function owns the claim. +#[allow(clippy::too_many_arguments)] +async fn one_time_claim_admitted( + sdk: &Arc, + store: &Arc>, + scan_checkpoints: &super::sync::ForeignScanCheckpointCache, + wallet_id: WalletId, + claim_record_key: [u8; 32], + admission: super::store::AdmissionToken, + fvk: grovedb_commitment_tree::FullViewingKey, + ivk: grovedb_commitment_tree::IncomingViewingKey, + ask: super::keys::ScrubOnDrop, + funding_birth_height: Option, + change_address: &OrchardAddress, + public_keys: Vec<(IdentityPublicKey, IdentityPublicKeyInCreation)>, + num_keys: usize, + master_key_hash: Option<[u8; 20]>, + submitted_public_keys: BTreeMap, + 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, +{ + // 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 cannot seed its start from a height; it bounds itself by + // value coverage plus a coordinator-owned resume checkpoint (one + // full-history scan per key per coordinator — see + // `scan_notes_for_foreign_key`). 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)" + ); + } + // ---- Durable pending-claim resume (#4204 review finding c0781f9d387f) ---- // // A claim that broadcast but never confirmed (process death, JNI @@ -1903,6 +2014,7 @@ where anchor_bytes, &selected_nullifiers, &st, + admission, ) .await?; @@ -2279,8 +2391,21 @@ async fn find_one_time_claim_record( }) } -/// Persist the pending-claim record. Called BEFORE the broadcast; a failure -/// aborts the claim (fail-closed — see the call site). +/// Persist the pending-claim record UNDER this claim's store-level admission +/// lease. Called BEFORE the broadcast; a failure aborts the claim (fail-closed +/// — see the call site). +/// +/// The lease re-check and the record write are one atomic store step +/// ([`ShieldedStore::arm_redrive_under_claim`]), which is what leaves no gap +/// between "still admitted" and "record written" for a concurrent +/// `clear`/`unregister_wallet` to slot into (#4313). The same step re-stamps +/// the lease, so the window that protects the freshly written record runs from +/// here — covering the broadcast and confirmation wait — rather than from the +/// start of a claim that may have spent minutes scanning. +/// +/// A lost lease is a hard stop, not a warning: nothing has been broadcast yet, +/// so refusing is clean and retryable, whereas broadcasting without the record +/// is how a padded single-note claim's identity becomes unrecoverable. async fn arm_one_time_claim_record( store: &Arc>, id: SubwalletId, @@ -2288,16 +2413,17 @@ async fn arm_one_time_claim_record( anchor: [u8; 32], nullifiers: &[[u8; 32]], st: &StateTransition, + admission: super::store::AdmissionToken, ) -> Result<(), PlatformWalletError> { use dpp::serialization::PlatformSerializable; let st_bytes = st .serialize_to_bytes() .map_err(|e| PlatformWalletError::ShieldedBuildError(e.to_string()))?; - store + let admitted = store .write() .await - .arm_redrive( + .arm_redrive_under_claim( id, PendingRedrive { activity_id: key, @@ -2306,12 +2432,24 @@ async fn arm_one_time_claim_record( st_bytes, attempts: 0, }, + admission, + super::store::admission_now_ms(), + super::store::CLAIM_LEASE_MS, ) .map_err(|e| { PlatformWalletError::Persistence(format!( "failed to persist the pending one-time-claim record before broadcast: {e}" )) - }) + })?; + if !admitted { + return Err(PlatformWalletError::ShieldedLifecycleBusy { + reason: "this claim's store admission lapsed before its recovery record could be \ + written (the wallet was cleared or removed, or the claim outran its lease); \ + nothing was broadcast — retry the claim" + .to_string(), + }); + } + Ok(()) } /// Drop the pending-claim record. Best-effort: a failure only means the next diff --git a/packages/rs-platform-wallet/src/wallet/shielded/store.rs b/packages/rs-platform-wallet/src/wallet/shielded/store.rs index e49aac7a445..ba50c3a2e76 100644 --- a/packages/rs-platform-wallet/src/wallet/shielded/store.rs +++ b/packages/rs-platform-wallet/src/wallet/shielded/store.rs @@ -473,6 +473,242 @@ pub trait ShieldedStore: Send + Sync { /// tree_size`) and the "Checked" progress bar stays pinned at /// the stale leaf count while "Downloaded" climbs from 0. fn reset_commitment_tree(&mut self) -> Result<(), Self::Error>; + + // ── Lifecycle admission (store-level, cross-instance) ────────────── + // + // See the [`LifecycleAdmission`] module docs for the protocol and its + // correctness argument. These five methods exist on the STORE, not on the + // coordinator, because the store is the only object two coordinators — + // or two processes — sharing the same backing state actually have in + // common (`dashpay/platform#4313`). + + /// Admit a one-time-key claim for `wallet_id`, or refuse it because a + /// destructive lifecycle operation holds admission over that scope. + /// + /// On `Ok(true)` a claim lease keyed by `token` is durable and live until + /// `now_ms + lease_ms`; the caller owns it until it calls + /// [`Self::end_claim_admission`]. On `Ok(false)` nothing was written and + /// the caller must not touch the claim record. + /// + /// Implementations MUST make the barrier check and the lease insert one + /// atomic step against every other admission operation on the same + /// underlying state. + fn begin_claim_admission( + &mut self, + wallet_id: WalletId, + token: AdmissionToken, + now_ms: u64, + lease_ms: u64, + ) -> Result; + + /// Arm `redrive` **only if** the claim lease `token` is still live, + /// re-stamping that lease to `now_ms + lease_ms` in the same atomic step. + /// + /// Returns `Ok(false)` — with nothing written — when the lease has expired + /// or was already released. Callers fail the claim closed on `false`: the + /// record is the only handle that recovers a padded single-note claim, so + /// broadcasting without it is unrecoverable. + /// + /// Arming under the lease rather than next to it is what closes the gap + /// between "the claim checked that it was admitted" and "the claim wrote + /// the record": the two are one transaction, so a destructive operation + /// cannot slot in between them. + fn arm_redrive_under_claim( + &mut self, + id: SubwalletId, + redrive: PendingRedrive, + token: AdmissionToken, + now_ms: u64, + lease_ms: u64, + ) -> Result; + + /// Release the claim lease `token`. Idempotent; unknown tokens are a + /// no-op (a lease that already expired was reaped). + fn end_claim_admission(&mut self, token: AdmissionToken) -> Result<(), Self::Error>; + + /// Take (or refresh) destructive admission over `scope` and report how + /// many claim leases are still live inside it. + /// + /// `scope` is `None` for a whole-store operation (`purge_all_subwallets`) + /// and `Some(wallet_id)` for a single wallet (`purge_wallet`). The barrier + /// is installed **and** the live-lease count taken in one atomic step, so + /// a claim is either counted here or refused by + /// [`Self::begin_claim_admission`] — never both, never neither. + /// + /// A non-zero count means the caller must wait and call again; it must not + /// purge. The barrier carries its own expiry so a crashed holder cannot + /// block claims forever, and refreshing it is exactly re-calling this. + fn begin_destructive_admission( + &mut self, + scope: Option, + token: AdmissionToken, + now_ms: u64, + barrier_ms: u64, + ) -> Result; + + /// Drop the destructive barrier `token`, whether the operation went ahead + /// or gave up. Idempotent. + fn end_destructive_admission(&mut self, token: AdmissionToken) -> Result<(), Self::Error>; +} + +/// How long a one-time-claim lease stays live without being re-stamped. +/// +/// It has to comfortably exceed the longest single phase a claim spends between +/// two store touches — the transient full-history scan of a cold wallet, or a +/// Halo 2 proof on a slow phone — because a lease that lapses mid-claim makes +/// the record arming fail closed (safe, but a wasted attempt). It also bounds +/// how long a claim that was CANCELLED without releasing (a dropped JNI call) +/// can block wallet removal, so it must not be unbounded either. Five minutes +/// sits well above both phases and well below a user's patience for "try +/// removing the wallet again". +/// +/// The lease is re-stamped when the record is armed +/// ([`ShieldedStore::arm_redrive_under_claim`]), so the window that actually +/// protects the durable record runs from the arm — not from the start of the +/// claim — and covers the broadcast and confirmation wait that follow it. +pub(crate) const CLAIM_LEASE_MS: u64 = 5 * 60 * 1_000; + +/// How long a destructive barrier survives without being refreshed. +/// +/// Only has to outlive one drain wait (it is refreshed on every poll), plus +/// margin. Kept short so a purge whose process died cannot keep refusing +/// claims for long. +pub(crate) const DESTRUCTIVE_BARRIER_MS: u64 = 60 * 1_000; + +/// How long a destructive lifecycle operation waits for in-flight claims to +/// drain before giving up. +/// +/// Giving up means REFUSING to purge, not purging anyway: deleting a record +/// under a live claim is the unrecoverable outcome this whole mechanism +/// exists to prevent, while a refused purge is a retry. +pub(crate) const DESTRUCTIVE_DRAIN_TIMEOUT: std::time::Duration = + std::time::Duration::from_secs(30); + +/// Poll interval while waiting for claim leases to drain. Each poll also +/// refreshes the barrier, so no new claim slips in during the wait. +pub(crate) const DESTRUCTIVE_DRAIN_POLL: std::time::Duration = + std::time::Duration::from_millis(250); + +/// Opaque owner token for one lifecycle admission — a claim lease or a +/// destructive barrier. +/// +/// 16 random bytes from the OS CSPRNG rather than a counter: admissions are +/// compared across independent store instances and, for the file-backed store, +/// across PROCESSES sharing one SQLite file, so a per-process counter could +/// collide and let one holder release another's admission. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub struct AdmissionToken(pub [u8; 16]); + +impl AdmissionToken { + /// A fresh token from the OS CSPRNG. + pub fn new() -> Self { + use rand::{rngs::OsRng, RngCore}; + + let mut bytes = [0u8; 16]; + OsRng.fill_bytes(&mut bytes); + Self(bytes) + } +} + +impl Default for AdmissionToken { + fn default() -> Self { + Self::new() + } +} + +/// Wall-clock milliseconds since the Unix epoch — the one clock every +/// admission lease and barrier is stamped and judged against. +/// +/// Wall clock rather than a monotonic instant because the leases are compared +/// across processes, which share no monotonic origin. Both holders read the +/// same system clock on the same machine, which is what the comparison needs. +pub fn admission_now_ms() -> u64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_millis() as u64) + .unwrap_or(0) +} + +/// One row of the lifecycle-admission table. +/// +/// # The protocol, and why it is at the store +/// +/// A one-time-key claim and a destructive lifecycle operation (`clear`, +/// `unregister_wallet`, and `remove_wallet` through it) both act on the same +/// durable pending-claim record. `clear` and `unregister_wallet` serialize +/// against each other on the coordinator's `lifecycle` mutex, but a claim never +/// takes it, and the per-FVK single-flight guards are owned by ONE +/// `NetworkShieldedCoordinator` — so a purge could delete an armed record while +/// its transition was still broadcasting. A coordinator-local `tokio` mutex +/// cannot fix that: `FileBackedShieldedStore::open_path` opens independent +/// SQLite connections to the same file, so two coordinators (or two processes) +/// share the state but not the mutex (`dashpay/platform#4313`). +/// +/// Admission therefore lives at the only thing they do share — the store: +/// +/// 1. A claim takes a **lease** ([`ShieldedStore::begin_claim_admission`]), +/// refused if a barrier already covers its wallet. +/// 2. A destructive operation installs a **barrier** +/// ([`ShieldedStore::begin_destructive_admission`]), which blocks new leases +/// and reports the leases already live in scope. It waits for that count to +/// reach zero and refuses to purge if it does not. +/// 3. The claim arms its record *under* its lease +/// ([`ShieldedStore::arm_redrive_under_claim`]), which re-checks and +/// re-stamps the lease in the same atomic step. +/// +/// # Why there is no residual race +/// +/// Step 1 and step 2 are each ONE atomic step against the shared state. They +/// therefore have a total order, and both orders are safe: +/// +/// * lease commits first → the barrier's count sees it → the purge waits. +/// * barrier commits first → the lease's check sees it → the claim is refused. +/// +/// For [`FileBackedShieldedStore`](super::file_store::FileBackedShieldedStore) +/// that atomicity is a `BEGIN IMMEDIATE` SQLite transaction: SQLite admits one +/// writer at a time across every connection **and every process** on the file, +/// so the total order holds exactly where a process-local mutex does not. For +/// [`InMemoryShieldedStore`] the shared object *is* the store, reached through +/// the same `RwLock`, so the write guard supplies the same total order. +/// +/// No admission call holds a write transaction across scanning, proof +/// construction, broadcast, or a confirmation wait: each is a handful of +/// statements, and the long phases run between them holding only the lease row. +/// +/// # Expiry +/// +/// Both kinds carry `expires_at`, because a holder can die (process kill, +/// cancelled coroutine) with no chance to release. Expiry is a liveness +/// backstop only — it never lets a purge delete a record under a *live* claim, +/// it only bounds how long a dead one can block wallet removal, and how long a +/// dead purge can block claims. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct LifecycleAdmission { + /// Owner token. + pub token: AdmissionToken, + /// `true` for a destructive barrier, `false` for a claim lease. + pub destructive: bool, + /// Scope: `None` is store-wide, `Some(id)` is one wallet. + pub wallet_id: Option, + /// Unix millis after which this admission is dead and reapable. + pub expires_at: u64, +} + +impl LifecycleAdmission { + /// Whether this admission's scope covers `wallet_id`. A store-wide entry + /// covers every wallet; a wallet-scoped one covers only its own. + pub fn covers(&self, wallet_id: WalletId) -> bool { + self.wallet_id.is_none_or(|scoped| scoped == wallet_id) + } + + /// Whether this admission's scope overlaps `scope` (the same containment + /// relation as [`Self::covers`], in whichever direction applies). + pub fn overlaps(&self, scope: Option) -> bool { + match (self.wallet_id, scope) { + (None, _) | (_, None) => true, + (Some(mine), Some(theirs)) => mine == theirs, + } + } } // ── Per-subwallet bookkeeping ────────────────────────────────────────── @@ -782,6 +1018,13 @@ pub struct InMemoryShieldedStore { checkpoints: Vec, /// Placeholder anchor; production stores compute the real Sinsemilla root. anchor: [u8; 32], + /// Live lifecycle admissions — see [`LifecycleAdmission`]. + /// + /// For this store the shared object two coordinators would contend over is + /// the store itself, reached through one `RwLock`, so holding the table + /// here gives the same total order between a claim lease and a destructive + /// barrier that the file store gets from SQLite's single-writer rule. + admissions: Vec, } impl InMemoryShieldedStore { @@ -1039,6 +1282,92 @@ impl ShieldedStore for InMemoryShieldedStore { self.anchor = [0u8; 32]; Ok(()) } + + // ── Lifecycle admission ──────────────────────────────────────────── + // + // Each of these is one uninterruptible `&mut self` step, and every caller + // reaches this store through the same `RwLock`, so the write guard + // supplies exactly the total order the protocol needs — see + // [`LifecycleAdmission`]. + + fn begin_claim_admission( + &mut self, + wallet_id: WalletId, + token: AdmissionToken, + now_ms: u64, + lease_ms: u64, + ) -> Result { + self.admissions.retain(|a| a.expires_at > now_ms); + if self + .admissions + .iter() + .any(|a| a.destructive && a.covers(wallet_id)) + { + return Ok(false); + } + self.admissions.retain(|a| a.token != token); + self.admissions.push(LifecycleAdmission { + token, + destructive: false, + wallet_id: Some(wallet_id), + expires_at: now_ms.saturating_add(lease_ms), + }); + Ok(true) + } + + fn arm_redrive_under_claim( + &mut self, + id: SubwalletId, + redrive: PendingRedrive, + token: AdmissionToken, + now_ms: u64, + lease_ms: u64, + ) -> Result { + let Some(lease) = self + .admissions + .iter_mut() + .find(|a| a.token == token && !a.destructive && a.expires_at > now_ms) + else { + return Ok(false); + }; + lease.expires_at = now_ms.saturating_add(lease_ms); + self.subwallets.entry(id).or_default().arm_redrive(redrive); + Ok(true) + } + + fn end_claim_admission(&mut self, token: AdmissionToken) -> Result<(), Self::Error> { + self.admissions + .retain(|a| a.destructive || a.token != token); + Ok(()) + } + + fn begin_destructive_admission( + &mut self, + scope: Option, + token: AdmissionToken, + now_ms: u64, + barrier_ms: u64, + ) -> Result { + self.admissions.retain(|a| a.expires_at > now_ms); + self.admissions.retain(|a| a.token != token); + self.admissions.push(LifecycleAdmission { + token, + destructive: true, + wallet_id: scope, + expires_at: now_ms.saturating_add(barrier_ms), + }); + Ok(self + .admissions + .iter() + .filter(|a| !a.destructive && a.overlaps(scope)) + .count()) + } + + fn end_destructive_admission(&mut self, token: AdmissionToken) -> Result<(), Self::Error> { + self.admissions + .retain(|a| !a.destructive || a.token != token); + Ok(()) + } } #[cfg(test)] From dd73cdb8cd8b10a33dc5f0ffc972b62be7cddd25 Mon Sep 17 00:00:00 2001 From: bfoss765 Date: Thu, 13 Aug 2026 12:52:20 -0400 Subject: [PATCH 26/26] docs(platform-wallet): correct the rollback note on a refused claim admission The refusal path returns without committing and relies on rusqlite's `Transaction` drop to roll back; the comment claimed an explicit rollback. Also spells out that the reap performed earlier in the same transaction is rolled back with it, which is harmless because the next admission call reaps again. Co-Authored-By: Claude Opus 4.8 --- .../rs-platform-wallet/src/wallet/shielded/file_store.rs | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/packages/rs-platform-wallet/src/wallet/shielded/file_store.rs b/packages/rs-platform-wallet/src/wallet/shielded/file_store.rs index 13bd8f56b79..876bd7d7c88 100644 --- a/packages/rs-platform-wallet/src/wallet/shielded/file_store.rs +++ b/packages/rs-platform-wallet/src/wallet/shielded/file_store.rs @@ -824,8 +824,10 @@ impl ShieldedStore for FileBackedShieldedStore { ) .map_err(|e| FileShieldedStoreError(format!("read destructive barriers: {e}")))?; if blocked > 0 { - // Roll back explicitly: nothing was written, and the claim must - // see a clean refusal rather than a half-open admission. + // Return WITHOUT committing: dropping the `Transaction` rolls it + // back, so a refused claim leaves no lease row and no half-open + // admission behind (the reap above is rolled back with it, which + // is harmless — the next admission call reaps again). return Ok(false); } tx.execute(