diff --git a/docs/dashpay/KOTLIN_MIGRATION_LEFTOVERS.md b/docs/dashpay/KOTLIN_MIGRATION_LEFTOVERS.md index 7113bb97c58..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). @@ -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) 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/errors/DashSdkError.kt b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/errors/DashSdkError.kt index 29986168bc0..b36bfb63150 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 @@ -365,6 +365,25 @@ sealed class DashSdkError( cause, ) + /** + * `ErrorShieldedInviteAlreadyClaimed` (native code 43). 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 @@ -535,6 +554,13 @@ sealed class DashSdkError( // the deferred-token trio sits at 34-36 above. See // PlatformWalletFFIResultCode for the authoritative map.) 31 -> PlatformWallet.SigningKeyUnavailable(message, cause) + // ErrorShieldedInviteAlreadyClaimed. Allocated 43 — 37-40 are the + // v4.2-dev DPNS username-marketplace block, 41 the shield-capacity + // shortfall, 42 reserved; 43 matches the integration-branch + // allocation already shipped in QA AARs, so it is frozen. (It + // briefly held 32, which belongs to ErrorTransactionBuild — + // dashpay/platform#4247/#4256.) + 43 -> 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/main/kotlin/org/dashfoundation/dashsdk/ffi/FundingNative.kt b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/ffi/FundingNative.kt index d85f538d31d..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,55 @@ 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 + * *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/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/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/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 07d143c1c57..a7473664e2b 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 @@ -1568,6 +1568,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`. @@ -2325,6 +2384,68 @@ 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() } + // 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) + } +} + +/** + * 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/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 37169cc094c..b8f12a2a547 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,21 @@ class DashSdkErrorTest { // The message must warn against retrying, like the broadcast sibling. assertTrue(spendUnconfirmed.message!!.contains("do NOT retry")) + // Code 43, NOT 37 (v4.2-dev's ErrorDocumentNotForSale) and NOT 32 + // (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. 43 matches the integration-branch allocation + // already shipped in QA AARs, so it is frozen. + val inviteClaimed = + DashSdkError.fromNative(DashSDKException(offset + 43, "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 444573c5dbc..802f7724bde 100644 --- a/packages/rs-platform-wallet-ffi/src/error.rs +++ b/packages/rs-platform-wallet-ffi/src/error.rs @@ -242,6 +242,35 @@ pub enum PlatformWalletFFIResultCode { /// all of these to key repair / address correction rather than to an /// opaque wallet-operation failure. 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 43 — the allocation frontier after the v4.2-dev merge. This + /// variant briefly held 32 (collided with `ErrorTransactionBuild`, + /// dashpay/platform#4247/#4256, as an `E0081`), then 37 — which v4.2-dev + /// has since allocated to `ErrorDocumentNotForSale` (the 37-40 DPNS + /// username-marketplace block below), with 41 taken by + /// `ErrorShieldedInsufficientBalance` and 42 reserved. 43 matches the + /// integration-branch allocation already shipped in QA AARs, so the + /// number is FROZEN; renumbering it would silently corrupt every host + /// built against those artifacts. + ErrorShieldedInviteAlreadyClaimed = 43, // Codes 27-33 are claimed outside this PR and MUST NOT be reused here. // The deferred-token trio below therefore occupies the contiguous block @@ -580,6 +609,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 } @@ -710,7 +747,9 @@ impl From for PlatformWalletFFIResult { // mid-string and is deliberately not matched. _ => 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())) } } @@ -825,10 +864,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 21d98fac4db..7af14609de4 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}; @@ -838,6 +840,205 @@ 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). + // 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. + 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}"), + ), + // TERMINAL consumed-invitation verdict: route through the blanket + // `From` conversion so the typed code + // (`ErrorShieldedInviteAlreadyClaimed`, 43) 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}"), + ), + } +} + /// Preflight the maximum credits the cached state can shield from one Platform /// Payment account. /// @@ -1586,6 +1787,115 @@ 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). +/// +/// 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 +/// - `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); + + // `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. + // `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( + 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() +} + +/// 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); + + // 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). `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) { + 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 7d404f6985a..ff2720548b2 100644 --- a/packages/rs-platform-wallet/Cargo.toml +++ b/packages/rs-platform-wallet/Cargo.toml @@ -81,6 +81,15 @@ 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 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" + # 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 diff --git a/packages/rs-platform-wallet/src/error.rs b/packages/rs-platform-wallet/src/error.rs index 8349eb1df21..8fd882f4160 100644 --- a/packages/rs-platform-wallet/src/error.rs +++ b/packages/rs-platform-wallet/src/error.rs @@ -574,6 +574,86 @@ 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 }, + + /// 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 }, + + /// 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/platform_wallet.rs b/packages/rs-platform-wallet/src/wallet/platform_wallet.rs index b6d29e67c40..81ae58d795f 100644 --- a/packages/rs-platform-wallet/src/wallet/platform_wallet.rs +++ b/packages/rs-platform-wallet/src/wallet/platform_wallet.rs @@ -1541,6 +1541,107 @@ 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, + // 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, + 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(), + coordinator.foreign_claim_guards(), + coordinator.foreign_scan_checkpoints(), + self.wallet_id, + 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) + } + #[cfg(feature = "shielded")] async fn shielded_shield_plan_for_account( &self, diff --git a/packages/rs-platform-wallet/src/wallet/shielded/coordinator.rs b/packages/rs-platform-wallet/src/wallet/shielded/coordinator.rs index a938d3f210d..24a2431fe3f 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 @@ -741,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" ); } } @@ -985,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 @@ -1047,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 @@ -2661,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..876bd7d7c88 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,183 @@ 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 { + // 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( + "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 +1476,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/keys.rs b/packages/rs-platform-wallet/src/wallet/shielded/keys.rs index d3d0a23a83c..99b4417dc74 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 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 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, @@ -214,6 +283,113 @@ 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> { + // 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()) +} + +/// 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, 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()`. +/// - `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. +/// +/// 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<(zeroize::Zeroizing<[u8; 32]>, [u8; ORCHARD_RAW_ADDRESS_LEN]), PlatformWalletError> { + use rand::{rngs::OsRng, RngCore}; + + let mut rng = OsRng; + loop { + // `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)) { + // 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)); + } + } +} + #[cfg(test)] mod tests { use super::*; @@ -406,4 +582,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().expect("OS RNG available"); + 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().expect("OS RNG available"); + + // 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().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"); + 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().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, + "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-platform-wallet/src/wallet/shielded/operations.rs b/packages/rs-platform-wallet/src/wallet/shielded/operations.rs index 4783ce75f9a..10e1a63d0ac 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::version::PlatformVersion; @@ -1575,6 +1576,1164 @@ 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; repeated attempts are +/// 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`. +#[allow(clippy::too_many_arguments)] +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, + // 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)>, + 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. + // + // 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); + + 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); + + // 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(); + + // ---- 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; + + // ---- 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 + // 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); + 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, 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). + 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" + ); + + // 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 + // 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(); + + // 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). + // `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; + } + + // 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, + 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()))?; + // 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; + + // 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()))?; + + // 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, + admission, + ) + .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 + // 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. + // + // 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, + Some(identity_id), + false, + &format!("broadcast returned NullifierAlreadySpent: {e}"), + ) + .await; + } + 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" + ); + } + } + + // 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_affected_state::(sdk, None) + .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). 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, + Some(identity_id), + false, + &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). + // + // 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}" + ), + }); + } + } + } + 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) => { + // `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()); + } + 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)) +} + +/// 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() +} + +/// 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 +/// 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]) -> ClaimGuard { + 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. +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 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, + key: [u8; 32], + 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()))?; + let admitted = store + .write() + .await + .arm_redrive_under_claim( + id, + PendingRedrive { + activity_id: key, + anchor, + nullifiers: nullifiers.to_vec(), + 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 +/// 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; + } +} + +/// 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. + 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. +/// +/// # 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>, + 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; + } + }; + // 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(), + "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; + } + }; + 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, + 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 @@ -2481,6 +3640,395 @@ fn broadcast_definitely_failed(e: &dash_sdk::Error) -> bool { } } +/// 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 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 NullifierSpentStatus::Unspent; + } + match ShieldedNullifierStatuses::fetch(sdk, ShieldedNullifiersQuery(nullifiers.to_vec())).await + { + Ok(Some(statuses)) => classify_nullifier_statuses(&statuses.0, nullifiers), + Ok(None) => NullifierSpentStatus::Unknown, + Err(e) => { + warn!( + error = %e, + "IdentityCreateFromOneTimeKey: nullifier spent-status query failed; status unknown" + ); + NullifierSpentStatus::Unknown + } + } +} + +/// 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]> { + 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 + .into_iter() + .find(|key| { + key.purpose() == Purpose::AUTHENTICATION + && key.security_level() == SecurityLevel::MASTER + }) + .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 +/// `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 +} + +/// 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. +/// +/// 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 id derived from this claim's published nullifiers. +/// +/// Outcomes: +/// - **`Ok`** — a fetched identity cleared both bindings: this claim created it. +/// - **[`PlatformWalletError::ShieldedInviteAlreadyClaimed`]** — an identity was +/// 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`, +/// 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!( + ?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(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)); + } + // 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 + ), + }); + } + } + + // 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)); + } + // `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!( + 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}" + ), + }); + } + + 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!( + "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`]). /// @@ -2567,7 +4115,93 @@ fn deserialize_note(data: &[u8]) -> Option { let rseed_bytes: [u8; 32] = data[83..115].try_into().ok()?; let rseed = RandomSeed::from_bytes(rseed_bytes, &rho).into_option()?; - Note::from_parts(recipient, value, rho, rseed).into_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)] @@ -2710,6 +4344,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::*; @@ -3341,40 +5140,324 @@ mod select_recorded_spends_tests { // drive's recorded set is exactly the two block-boundary roots. let recorded: HashSet<[u8; 32]> = [root_block1, root_block2].into_iter().collect(); - let (spends, anchor) = - select_recorded_spends(&store, std::slice::from_ref(¬e), &recorded) - .expect("a prior recorded checkpoint covers the owned note"); + let (spends, anchor) = + select_recorded_spends(&store, std::slice::from_ref(¬e), &recorded) + .expect("a prior recorded checkpoint covers the owned note"); + + let _ = std::fs::remove_file(&path); + + assert_eq!(spends.len(), 1, "the single owned note is spendable"); + assert!( + recorded.contains(&anchor.to_bytes()), + "the selected anchor must be a Platform-recorded root" + ); + assert_eq!( + anchor.to_bytes(), + root_block2, + "must pick the shallowest recorded checkpoint (block 2 / depth 1), not a deeper one" + ); + assert_ne!( + anchor.to_bytes(), + root_depth0, + "must NOT use the mid-block depth-0 root drive never recorded" + ); + } + + /// Fully synced: the wallet's depth-0 root IS recorded, so the probe takes + /// the fast path and returns the depth-0 anchor without walking deeper. + #[test] + fn fully_synced_returns_depth0_anchor() { + let path = temp_tree_path("fastpath"); + let mut store = FileBackedShieldedStore::open_path(&path, 100).unwrap(); + + let note = real_note(0); + + // One block, checkpointed exactly on its boundary: depth 0 == a recorded + // anchor. + 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("depth-0 root is recorded"); + + let _ = std::fs::remove_file(&path); + + assert_eq!(spends.len(), 1); + assert_eq!( + anchor.to_bytes(), + root_depth0, + "the fully-synced fast path returns the depth-0 anchor" + ); + } + + /// No checkpoint root is recorded: the probe exhausts every depth and + /// returns the retryable `ShieldedNoRecordedAnchor` — nothing is broadcast. + #[test] + fn no_recorded_checkpoint_returns_retryable_error() { + let path = temp_tree_path("none"); + let mut store = FileBackedShieldedStore::open_path(&path, 100).unwrap(); + + let note = real_note(0); + + 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(); + + // Platform recorded none of this wallet's checkpoint roots. + let recorded: HashSet<[u8; 32]> = HashSet::new(); + + // `SpendableNote` isn't `Debug`, so match rather than `expect_err`. + let result = select_recorded_spends(&store, std::slice::from_ref(¬e), &recorded); + + let _ = std::fs::remove_file(&path); + + match result { + Err(PlatformWalletError::ShieldedNoRecordedAnchor(_)) => {} + Err(other) => { + panic!("expected ShieldedNoRecordedAnchor, got error: {other:?}") + } + Ok(_) => panic!("expected ShieldedNoRecordedAnchor, got Ok"), + } + } + + /// A selected note that post-dates the only recorded checkpoint. Depth 0 + /// (which contains the note) isn't recorded; at depth 1 the note is not yet + /// in the tree, so the probe's early-termination fires (a deeper checkpoint + /// is older still and can't contain it) and the retryable error is returned + /// — even though a recorded anchor exists, it doesn't cover this note. This + /// pins the walk's break arm and the value-selection trade-off (a mid-block + /// wallet whose selected note is newer than every recorded checkpoint waits + /// for the next sync rather than spending). + #[test] + fn note_newer_than_recorded_checkpoint_breaks_and_returns_retryable_error() { + let path = temp_tree_path("toonew"); + let mut store = FileBackedShieldedStore::open_path(&path, 100).unwrap(); + + // Block 1 = positions 0,1,2 (fillers), checkpointed on its boundary — + // the only root Platform recorded. + store.append_commitment(&filler_cmx(0xA0), 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_block1 = store.tree_anchor().unwrap(); + + // The owned note is appended AFTER block 1 (position 3) and checkpointed: + // present at depth 0, absent from the block-1 checkpoint (depth 1). + let note = real_note(3); + store.append_commitment(¬e.cmx, true).unwrap(); + store.checkpoint_tree(4).unwrap(); + + let recorded: HashSet<[u8; 32]> = [root_block1].into_iter().collect(); + + let result = select_recorded_spends(&store, std::slice::from_ref(¬e), &recorded); + + let _ = std::fs::remove_file(&path); + + match result { + Err(PlatformWalletError::ShieldedNoRecordedAnchor(_)) => {} + Err(other) => panic!("expected ShieldedNoRecordedAnchor, got error: {other:?}"), + Ok(_) => panic!("expected ShieldedNoRecordedAnchor, got Ok"), + } + } +} + +/// 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); - let _ = std::fs::remove_file(&path); + // `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" + ); - assert_eq!(spends.len(), 1, "the single owned note is spendable"); - assert!( - recorded.contains(&anchor.to_bytes()), - "the selected anchor must be a Platform-recorded root" + // 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!( - anchor.to_bytes(), - root_block2, - "must pick the shallowest recorded checkpoint (block 2 / depth 1), not a deeper one" + note.cmx, + ExtractedNoteCommitment::from(decoded.commitment()).to_bytes(), + "stored cmx must be the note's real commitment" ); - assert_ne!( - anchor.to_bytes(), - root_depth0, - "must NOT use the mid-block depth-0 root drive never recorded" + } + + /// 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:?}"), + } } - /// Fully synced: the wallet's depth-0 root IS recorded, so the probe takes - /// the fast path and returns the depth-0 anchor without walking deeper. + /// 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 fully_synced_returns_depth0_anchor() { - let path = temp_tree_path("fastpath"); + 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 = real_note(0); + let note = one_time_note(DENOMINATION, 0); - // One block, checkpointed exactly on its boundary: depth 0 == a recorded - // anchor. + // 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(); @@ -3385,86 +5468,595 @@ mod select_recorded_spends_tests { let (spends, anchor) = select_recorded_spends(&store, std::slice::from_ref(¬e), &recorded) - .expect("depth-0 root is recorded"); + .expect("the one-time key's note witnesses against the recorded anchor"); let _ = std::fs::remove_file(&path); - assert_eq!(spends.len(), 1); + 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 fully-synced fast path returns the depth-0 anchor" + "the spend is built against the Platform-recorded anchor" ); } +} - /// No checkpoint root is recorded: the probe exhausts every depth and - /// returns the retryable `ShieldedNoRecordedAnchor` — nothing is broadcast. +/// 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 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; + 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]; + /// 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]> { + 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 no_recorded_checkpoint_returns_retryable_error() { - let path = temp_tree_path("none"); - let mut store = FileBackedShieldedStore::open_path(&path, 100).unwrap(); + fn chargeable_unshield_fallback_identity_is_rejected() { + let expected_id = identity_id_from_nullifiers(&our_nullifiers()); - let note = real_note(0); + // 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()]); - 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(); + 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" + ); + } - // Platform recorded none of this wallet's checkpoint roots. - let recorded: HashSet<[u8; 32]> = HashSet::new(); + /// 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, + )], + ); - // `SpendableNote` isn't `Debug`, so match rather than `expect_err`. - let result = select_recorded_spends(&store, std::slice::from_ref(¬e), &recorded); + 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" + ); + } - let _ = std::fs::remove_file(&path); + /// 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![]); - match result { - Err(PlatformWalletError::ShieldedNoRecordedAnchor(_)) => {} - Err(other) => { - panic!("expected ShieldedNoRecordedAnchor, got error: {other:?}") + 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" + ); + } + + // ── 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()), } - Ok(_) => panic!("expected ShieldedNoRecordedAnchor, got Ok"), + .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, } } - /// A selected note that post-dates the only recorded checkpoint. Depth 0 - /// (which contains the note) isn't recorded; at depth 1 the note is not yet - /// in the tree, so the probe's early-termination fires (a deeper checkpoint - /// is older still and can't contain it) and the retryable error is returned - /// — even though a recorded anchor exists, it doesn't cover this note. This - /// pins the walk's break arm and the value-selection trade-off (a mid-block - /// wallet whose selected note is newer than every recorded checkpoint waits - /// for the next sync rather than spending). + 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 note_newer_than_recorded_checkpoint_breaks_and_returns_retryable_error() { - let path = temp_tree_path("toonew"); - let mut store = FileBackedShieldedStore::open_path(&path, 100).unwrap(); + 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; - // Block 1 = positions 0,1,2 (fillers), checkpointed on its boundary — - // the only root Platform recorded. - store.append_commitment(&filler_cmx(0xA0), 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_block1 = store.tree_anchor().unwrap(); + let submitted = vec![ + our_master_key(), + key_with_hash(1, Purpose::TRANSFER, SecurityLevel::CRITICAL, [0xC3; 20]), + ]; + let (_, st_bytes) = stored_claim_transition(&submitted, DENOMINATION); - // The owned note is appended AFTER block 1 (position 3) and checkpointed: - // present at depth 0, absent from the block-1 checkpoint (depth 1). - let note = real_note(3); - store.append_commitment(¬e.cmx, true).unwrap(); - store.checkpoint_tree(4).unwrap(); + 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 recorded: HashSet<[u8; 32]> = [root_block1].into_iter().collect(); + let derived: BTreeMap = transition + .public_keys() + .iter() + .map(|key_in_creation| { + let key: IdentityPublicKey = key_in_creation.into(); + (key.id(), key) + }) + .collect(); - let result = select_recorded_spends(&store, std::slice::from_ref(¬e), &recorded); + 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" + ); + } - let _ = std::fs::remove_file(&path); + /// 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); - match result { - Err(PlatformWalletError::ShieldedNoRecordedAnchor(_)) => {} - Err(other) => panic!("expected ShieldedNoRecordedAnchor, got error: {other:?}"), - Ok(_) => panic!("expected ShieldedNoRecordedAnchor, got Ok"), + 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); } } 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)] diff --git a/packages/rs-platform-wallet/src/wallet/shielded/sync.rs b/packages/rs-platform-wallet/src/wallet/shielded/sync.rs index 00f5b7db1d9..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; +use std::sync::{Arc, Mutex}; use dash_sdk::platform::shielded::{ sync_shielded_notes_stream, try_decrypt_note, try_recover_outgoing_note, @@ -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,281 @@ pub(crate) async fn balances_across( Ok(out) } +/// 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 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 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 +/// 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 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. + /// 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)>; + +/// 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 +/// 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; + +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. +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() +} + +/// 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). +/// +/// 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 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 a caller's +/// birth-height hint cannot seed the scan start. The rescan bound is instead a +/// 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 +/// 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, +) -> Result, PlatformWalletError> { + use grovedb_commitment_tree::PreparedIncomingViewingKey; + + let checkpoint_key = foreign_scan_checkpoint_key(fvk); + let (mut found, resume_position) = match checkpoints.load(&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 coordinator-owned 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() { + checkpoints.save( + 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, aligned_start, None); + futures::pin_mut!(stream); + + // 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 = 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. + checkpoints.save( + 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(); + 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; + } + } + + checkpoints.save( + checkpoint_key, + foreign_scan_checkpoint_below(scanned_through, &found), + ); + Ok(found) +} + /// One decrypted note discovered during a sync pass. #[derive(Clone)] struct DiscoveredNote { @@ -998,6 +1273,133 @@ 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" + ); + } + + /// 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_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 load. + assert!(cache.load(&key(0)).is_none()); + + // 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!( + 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… + 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 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 { + cache.save(key(i), cp(u64::from(i) * 2048)); + } + assert!( + cache.load(&key(0)).is_none(), + "least-recently-used entry must be evicted beyond the cap" + ); + for i in 1..n { + assert!( + 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 diff --git a/packages/rs-unified-sdk-jni/src/funding.rs b/packages/rs-unified-sdk-jni/src/funding.rs index f8dc82f050a..bbe64a5ecc7 100644 --- a/packages/rs-unified-sdk-jni/src/funding.rs +++ b/packages/rs-unified-sdk-jni/src/funding.rs @@ -151,19 +151,57 @@ fn read_id32(env: &mut JNIEnv, arr: &JByteArray, field: &str) -> Option<[u8; 32] Some(id) } -/// 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]> { +/// 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 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; } }; @@ -171,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; } @@ -345,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) { @@ -404,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) { @@ -783,6 +821,235 @@ 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(); + }; + // 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, "changeAddressRaw43") + 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. +/// +/// 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| { + // 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( + 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 = zeroize::Zeroizing::new([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| { + // 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]; + 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`. /// @@ -820,7 +1087,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 diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletResult.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletResult.swift index 8528fe091dd..8404ef4f0a5 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletResult.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletResult.swift @@ -141,6 +141,20 @@ public enum PlatformWalletResultCode: Int32, Sendable { /// amount plus input 0's retained fee reserve. Refresh the shield /// preflight and ask the user to confirm the new capacity. case errorShieldedInsufficientBalance = 41 + /// 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 43 — the allocation frontier after the v4.2-dev merge: + /// 37-40 are the DPNS username-marketplace block, 41 the shield-capacity + /// shortfall, 42 reserved. 43 matches `ErrorShieldedInviteAlreadyClaimed` + /// in packages/rs-platform-wallet-ffi/src/error.rs and the + /// integration-branch allocation already shipped in QA AARs, so it is + /// frozen. (It briefly held 32 — errorTransactionBuild, #4247/#4256 — + /// then 37, both now claimed.) + case errorShieldedInviteAlreadyClaimed = 43 /// The named thing does not exist. Besides the handle/lookup failures this /// has always covered, BOTH deferred-send paths report the /// wallet-was-REMOVED case here. @@ -238,6 +252,8 @@ public enum PlatformWalletResultCode: Int32, Sendable { self = .errorContestedNameNotTradable case PLATFORM_WALLET_FFI_RESULT_CODE_ERROR_SHIELDED_INSUFFICIENT_BALANCE: self = .errorShieldedInsufficientBalance + 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: @@ -418,6 +434,13 @@ public enum PlatformWalletError: LocalizedError { /// `endsAtMs == 0` means the vote's end time was unavailable — show it /// as unknown rather than as "ends at the epoch". case contestedNameNotTradable(label: String, endsAtMs: UInt64) + /// 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) /// The named thing does not exist. For the deferred payment calls this is /// the wallet-was-REMOVED case: the token's wallet (or the wallet a payment /// was just signed against) is no longer registered in the manager, so there @@ -452,6 +475,7 @@ public enum PlatformWalletError: LocalizedError { .staleReservationToken(let m), .reservationTokenConsumed(let m), .reservationWalletMismatch(let m), .notForSale(let m), + .shieldedInviteAlreadyClaimed(let m), .notFound(let m), .unknown(let m): return m // The three value-carrying marketplace rejections compose their @@ -561,6 +585,8 @@ public enum PlatformWalletError: LocalizedError { } else { self = .unknown(detail) } + case .errorShieldedInviteAlreadyClaimed: + self = .shieldedInviteAlreadyClaimed(detail) case .notFound: self = .notFound(detail) case .errorUnknown: self = .unknown(detail) }