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..d110542c185 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,66 @@ 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) + + /** + * `ErrorShieldedScanBudgetExhausted` (native code 44, + * dashpay/platform#4306). The one-time-key claim's transient note + * scan paused at its per-attempt work budget before finding the + * invitation's funding note. Progress is checkpointed in the SDK, so + * retrying RESUMES the scan where it stopped — attempts compound + * until the note is found or the tree is genuinely exhausted. + * + * RETRYABLE ([isRetryable] `true`) and cheap to retry: nothing was + * spent, built, or broadcast. The opposite pole from + * [ShieldedInviteAlreadyClaimed] — hosts MUST render this as "still + * searching — try again", never as an invalid, unfunded, or + * already-claimed invitation. + */ + class ShieldedScanBudgetExhausted(message: String, cause: Throwable? = null) : + PlatformWallet(message, cause) { + override val isRetryable: Boolean get() = true + } + + /** + * `ErrorShieldedLifecycleBusy` (native code 45, + * dashpay/platform#4313). A shielded lifecycle operation was refused + * admission at the store instead of being allowed to run concurrently + * with the operation that holds it. Two directions, one code: + * + * * a one-time-key claim refused because a Clear / wallet removal + * holds destructive admission over its wallet, or because another + * claimant already holds this invitation's claim-record key; + * * a Clear / wallet removal refused because in-flight claims did not + * drain within its wait. + * + * RETRYABLE ([isRetryable] `true`) and nothing was consumed in either + * direction: the claim scanned, built and broadcast nothing, and the + * purge deleted nothing. Hosts MUST render this as "busy — try again" + * and MUST NOT surface it as an invalid or already-claimed invitation. + */ + class ShieldedLifecycleBusy(message: String, cause: Throwable? = null) : + PlatformWallet(message, cause) { + override val isRetryable: Boolean get() = true + } + /** * Any other `PlatformWalletFFIResultCode` without a dedicated type. * Carries the platform-wallet [nativeCode] (already de-offset) and @@ -535,6 +595,23 @@ 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) + // ErrorShieldedScanBudgetExhausted (#4306) — retryable-and-cheap: + // the claim scan paused at its per-attempt budget with progress + // checkpointed; a retry resumes, it never restarts. + 44 -> PlatformWallet.ShieldedScanBudgetExhausted(message, cause) + // ErrorShieldedLifecycleBusy (#4313) — retryable, and nothing was + // consumed: a claim refused admission by a concurrent Clear / + // wallet removal or by another claimant holding the same + // invitation's claim-record key, or a Clear that refused to purge + // while claims are still in flight. + 45 -> PlatformWallet.ShieldedLifecycleBusy(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..4eba4872f58 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,49 @@ 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, + ) + + // Code 44 (ErrorShieldedScanBudgetExhausted, dashpay/platform#4306): + // the claim scan paused at its per-attempt budget. The retryability + // polarity is the entire contract — a host that saw this as terminal + // would strand a funded claim whose note sits deep in the tree. + val scanBudget = + DashSdkError.fromNative(DashSDKException(offset + 44, "scan paused at 262144")) + assertTrue(scanBudget is DashSdkError.PlatformWallet.ShieldedScanBudgetExhausted) + assertTrue( + "ShieldedScanBudgetExhausted is RETRYABLE — progress is checkpointed", + scanBudget.isRetryable, + ) + + // Code 45 (ErrorShieldedLifecycleBusy, dashpay/platform#4313): the + // claim was refused admission at the store — a concurrent Clear / + // wallet removal holds it, or another claimant already holds this + // invitation's claim-record key. Nothing was scanned, built or + // broadcast, so the polarity matches 44's: a host that read this as + // terminal would fail an invitation that is merely contended. + val lifecycleBusy = + DashSdkError.fromNative( + DashSDKException(offset + 45, "shielded state is being cleared or removed"), + ) + assertTrue(lifecycleBusy is DashSdkError.PlatformWallet.ShieldedLifecycleBusy) + assertTrue( + "ShieldedLifecycleBusy is RETRYABLE — nothing was consumed", + lifecycleBusy.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..238797acd85 100644 --- a/packages/rs-platform-wallet-ffi/src/error.rs +++ b/packages/rs-platform-wallet-ffi/src/error.rs @@ -242,6 +242,83 @@ 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, + + /// Maps `PlatformWalletError::ShieldedForeignScanBudgetExhausted` + /// (dashpay/platform#4306). The one-time-key claim's transient note scan + /// consumed its per-attempt work budget before finding the invitation's + /// funding note; progress is checkpointed, so a retry RESUMES rather than + /// restarts. + /// + /// RETRYABLE, and cheap to retry — the opposite pole from + /// [`Self::ErrorShieldedInviteAlreadyClaimed`] (43): nothing was spent, + /// built, or broadcast, the scan simply has not looked far enough yet. + /// Hosts MUST render this as "still searching — try again", never as an + /// invalid, unfunded, or already-claimed invitation: treating it as + /// terminal strands a genuinely funded claim whose note sits deep in the + /// tree. + /// + /// Code 44 — the next frontier past the frozen 43 above; add it to + /// ERROR_CODE_REGISTRY.md (dashpay/platform#4318) when that registry + /// lands. + ErrorShieldedScanBudgetExhausted = 44, + + /// Maps `PlatformWalletError::ShieldedLifecycleBusy`. A shielded lifecycle + /// operation was refused admission at the store rather than allowed to run + /// concurrently with the operation that holds it. Two directions, both + /// reaching this one code: + /// + /// * a one-time-key claim refused because `clear` / `unregister_wallet` / + /// `remove_wallet` holds destructive admission over its wallet, or + /// because another claimant already holds this invitation's claim-record + /// key; + /// * a destructive operation refused because in-flight claims did not + /// drain within its wait. + /// + /// RETRYABLE in both directions, and nothing was consumed: the claim + /// direction scanned, built and broadcast nothing, and the destructive + /// direction purged nothing. The refusal is the safe outcome of a + /// contended lifecycle, not a failure of the operation itself, so hosts + /// MUST render it as "busy — try again" and MUST NOT surface it as an + /// invalid or already-claimed invitation. Without a code of its own it + /// flattened to the generic `ErrorWalletOperation` (6), which hosts + /// classify as non-retryable — the same defect + /// [`Self::ErrorShieldedScanBudgetExhausted`] (44) fixes for the paused + /// scan. + /// + /// Code 45 — the next free integer past 44 above, taken from the frontier + /// rather than from a vacated gap (28, 30, 32 and 33 are RESERVED, not + /// reissuable). Swift mirror and the ERROR_CODE_REGISTRY.md row + /// (dashpay/platform#4318) are follow-ups. + ErrorShieldedLifecycleBusy = 45, // 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 +657,29 @@ 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 + } + // Retryable-and-cheap: the claim's transient scan paused at its + // per-attempt budget with progress checkpointed (#4306). Kept + // typed so hosts render "still searching — retry" instead of + // collapsing it into an unknown/terminal failure. + PlatformWalletError::ShieldedForeignScanBudgetExhausted { .. } => { + PlatformWalletFFIResultCode::ErrorShieldedScanBudgetExhausted + } + // Retryable in both of its directions (a refused claim, or a + // refused purge) and consuming nothing in either. Typed for the + // same reason as the scan-budget code above: flattened to the + // generic `ErrorWalletOperation` a contended lifecycle reads as a + // hard failure, and the host stops instead of retrying. + PlatformWalletError::ShieldedLifecycleBusy { .. } => { + PlatformWalletFFIResultCode::ErrorShieldedLifecycleBusy + } PlatformWalletError::ShieldedNoRecordedAnchor(..) => { PlatformWalletFFIResultCode::ErrorShieldedNoRecordedAnchor } @@ -710,7 +810,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 +927,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) @@ -1584,6 +1711,67 @@ mod tests { ); } + /// This PR's three shielded-invite codes, pinned for the same reason the + /// marketplace block above is: the numeric values are the ABI contract with + /// the Swift/Kotlin mirrors and nothing checks them across the boundary at + /// compile time. + /// + /// 43 is FROZEN (it matches the integration-branch allocation already + /// shipped in QA AARs); 44 and 45 were taken from the registry frontier, + /// never from a vacated gap. + #[test] + fn shielded_invite_codes_are_pinned_at_43_through_45() { + assert_eq!( + PlatformWalletFFIResultCode::ErrorShieldedInviteAlreadyClaimed as i32, + 43 + ); + assert_eq!( + PlatformWalletFFIResultCode::ErrorShieldedScanBudgetExhausted as i32, + 44 + ); + assert_eq!( + PlatformWalletFFIResultCode::ErrorShieldedLifecycleBusy as i32, + 45 + ); + } + + /// A contended shielded lifecycle is RETRYABLE and consumed nothing, in + /// both of its directions. Without a typed code it flattened to the generic + /// `ErrorWalletOperation` (6), which hosts classify as non-retryable — + /// so a claim refused for a few seconds by a concurrent purge, or by + /// another claimant holding the same invitation's claim-record key, + /// surfaced as a hard failure of the invitation itself. + #[test] + fn shielded_lifecycle_busy_maps_to_its_own_retryable_code() { + let claim_refused = PlatformWalletError::ShieldedLifecycleBusy { + reason: "this wallet's shielded state is being cleared or removed".to_string(), + }; + let rendered = claim_refused.to_string(); + let result: PlatformWalletFFIResult = claim_refused.into(); + assert_eq!( + result.code, + PlatformWalletFFIResultCode::ErrorShieldedLifecycleBusy, + "a refused lifecycle admission must not flatten into ErrorWalletOperation" + ); + assert_eq!( + message_of(&result), + rendered, + "Display payload must survive verbatim" + ); + + // The other direction — a purge that could not drain in-flight claims — + // rides the same code, because hosts handle both identically: wait and + // retry. + let purge_refused: PlatformWalletFFIResult = PlatformWalletError::ShieldedLifecycleBusy { + reason: "clear_shielded: 1 one-time-key claim(s) still in flight".to_string(), + } + .into(); + assert_eq!( + purge_refused.code, + PlatformWalletFFIResultCode::ErrorShieldedLifecycleBusy + ); + } + /// `MessageSigningFailed` is intentionally unmapped: its causes are /// internal invariant breaks, which should read as a bug rather than as a /// key-repair prompt, so it falls through to ErrorUnknown carrying the diff --git a/packages/rs-platform-wallet-ffi/src/manager.rs b/packages/rs-platform-wallet-ffi/src/manager.rs index 3cfcebb4957..e40eafe3902 100644 --- a/packages/rs-platform-wallet-ffi/src/manager.rs +++ b/packages/rs-platform-wallet-ffi/src/manager.rs @@ -730,6 +730,18 @@ pub unsafe extern "C" fn platform_wallet_manager_remove_wallet( )) }); let result = unwrap_option_or_return!(option); + remove_wallet_ffi_result(result, wallet_id_value) +} + +/// Marshal `remove_wallet_and_tear_down_generation`'s outcome into the result +/// the host sees. +/// +/// Split out of the `extern "C"` shim above purely so the classification is +/// reachable from a unit test — the shim itself needs a live manager handle. +fn remove_wallet_ffi_result( + result: Result<(), platform_wallet::PlatformWalletError>, + wallet_id: [u8; 32], +) -> PlatformWalletFFIResult { match result { Ok(()) => PlatformWalletFFIResult::ok(), // Idempotency: a wallet that's already gone is the success @@ -737,13 +749,24 @@ pub unsafe extern "C" fn platform_wallet_manager_remove_wallet( Err(platform_wallet::PlatformWalletError::WalletNotFound(_)) => { PlatformWalletFFIResult::ok() } + // ...except a removal refused because a one-time-key claim still holds + // the shielded store's destructive admission. That refusal is RETRYABLE + // and changed nothing — the wallet is still fully registered — so it + // has to reach the host as code 45 rather than flattening into the + // generic wallet-operation error below. Making the removal fallible + // (#4313 review finding coordinator.rs:805) is what put this outcome on + // this path at all. + // + // Flattened to 6 a host reads it as a hard failure and never retries, + // and the wallet then stays registered with its shielded state on disk + // — the exact stuck state the fallible removal exists to make + // recoverable. + Err(e @ platform_wallet::PlatformWalletError::ShieldedLifecycleBusy { .. }) => { + PlatformWalletFFIResult::from(e) + } Err(e) => PlatformWalletFFIResult::err( PlatformWalletFFIResultCode::ErrorWalletOperation, - format!( - "Failed to remove wallet {}: {}", - hex::encode(wallet_id_value), - e - ), + format!("Failed to remove wallet {}: {}", hex::encode(wallet_id), e), ), } } @@ -752,6 +775,58 @@ pub unsafe extern "C" fn platform_wallet_manager_remove_wallet( mod tests { use super::*; + /// `remove_wallet` must keep the retryable shielded-lifecycle refusal as + /// its own code. The removal became fallible in #4313 (review finding + /// coordinator.rs:805) so a purge that cannot be admitted aborts instead of + /// half-completing — but that only helps if the host can tell "busy, retry" + /// apart from "removal failed", which it does by code alone. + #[test] + fn remove_wallet_preserves_the_retryable_lifecycle_busy_code() { + let wallet_id = [0x7A; 32]; + + let mut busy = remove_wallet_ffi_result( + Err( + platform_wallet::PlatformWalletError::ShieldedLifecycleBusy { + reason: "a one-time-key claim is still in flight".to_string(), + }, + ), + wallet_id, + ); + assert_eq!( + busy.code as i32, + PlatformWalletFFIResultCode::ErrorShieldedLifecycleBusy as i32, + "a contended removal must surface as 45, not as the generic wallet-operation code" + ); + unsafe { crate::error::platform_wallet_ffi_result_free(&mut busy) }; + + // A wallet that is already gone is still the success state. + let mut missing = remove_wallet_ffi_result( + Err(platform_wallet::PlatformWalletError::WalletNotFound( + hex::encode(wallet_id), + )), + wallet_id, + ); + assert_eq!( + missing.code as i32, + PlatformWalletFFIResultCode::Success as i32, + "removing an absent wallet stays idempotent" + ); + unsafe { crate::error::platform_wallet_ffi_result_free(&mut missing) }; + + // And an unrelated failure still takes the generic mapping. + let mut other = remove_wallet_ffi_result( + Err(platform_wallet::PlatformWalletError::ShieldedStoreError( + "disk gone".to_string(), + )), + wallet_id, + ); + assert_eq!( + other.code as i32, + PlatformWalletFFIResultCode::ErrorWalletOperation as i32 + ); + unsafe { crate::error::platform_wallet_ffi_result_free(&mut other) }; + } + unsafe extern "C" fn begin_changeset(_context: *mut c_void, _wallet_id: *const u8) -> i32 { 0 } diff --git a/packages/rs-platform-wallet-ffi/src/shielded_send.rs b/packages/rs-platform-wallet-ffi/src/shielded_send.rs index 21d98fac4db..f4aac61f69c 100644 --- a/packages/rs-platform-wallet-ffi/src/shielded_send.rs +++ b/packages/rs-platform-wallet-ffi/src/shielded_send.rs @@ -44,13 +44,16 @@ use std::os::raw::c_char; use dashcore::hashes::Hash; use dpp::address_funds::{OrchardAddress, PlatformAddress}; +use dpp::prelude::Identifier; use dpp::shielded::{ compute_minimum_shielded_fee, compute_shielded_unshield_fee, compute_shielded_withdrawal_fee, ShieldedMemo, }; 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 +841,248 @@ 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 + }); + + let (identity_id_to_write, ffi_result) = map_one_time_claim_result(result); + if let Some(identity_id) = identity_id_to_write { + *out_identity_id = identity_id.to_buffer(); + } + ffi_result +} + +/// Classify a one-time-key claim outcome into its FFI result, plus the identity +/// id (if any) the entry point must write to `out_identity_id`. +/// +/// Split out of +/// [`platform_wallet_manager_shielded_identity_create_from_one_time_key`] so the +/// code split below is reachable from a unit test without a live manager handle +/// — the same shape `map_spend_result` uses for the spend entry points. The +/// `Some(id)` return is the ONLY channel that writes `out_identity_id`, so the +/// "written on Success and on `ErrorShieldedBroadcastUnconfirmed` only" contract +/// in that function's safety docs is decided here and nowhere else. +fn map_one_time_claim_result( + result: Result, +) -> (Option, PlatformWalletFFIResult) { + match result { + Ok(identity_id) => (Some(identity_id), PlatformWalletFFIResult::ok()), + Err(PlatformWalletError::ShieldedBroadcastUnconfirmed { + identity_id, + ref reason, + }) => ( + Some(identity_id), + 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(_)) => ( + None, + PlatformWalletFFIResult::err( + PlatformWalletFFIResultCode::ErrorShieldedNoRecordedAnchor, + format!("Wallet is still syncing to a confirmed state — try again shortly. ({e})"), + ), + ), + Err(e @ PlatformWalletError::ShieldedBroadcastFailed(_)) => ( + None, + PlatformWalletFFIResult::err( + PlatformWalletFFIResultCode::ErrorShieldedBroadcastFailed, + format!("shielded identity-create-from-one-time-key failed: {e}"), + ), + ), + // Every variant that owns a typed code goes through the blanket + // `From` conversion, because the catch-all below + // would flatten it to the generic `ErrorWalletOperation` (6) and destroy + // the retry-semantics discriminator the host classifies on: + // + // * `ShieldedInviteAlreadyClaimed` → 43, TERMINAL — the one signal that + // tells a claimer the invitation can never be claimed again + // (#4204 review finding 7be05fde0d09). + // * `ShieldedForeignScanBudgetExhausted` → 44, RETRYABLE and cheap — + // the scan simply paused at its per-attempt budget with progress + // checkpointed. Flattened to 6 it reads as a hard failure, which + // strands a genuinely funded claim whose note sits deep in the tree + // (#4313 review finding, this entry point). + // * `ShieldedLifecycleBusy` → 45, RETRYABLE — the claim was refused + // admission at the store (a purge holds it, or another claimant owns + // this invitation's claim-record key). Nothing was scanned, built or + // broadcast, so the host should simply retry. + // + // The blanket conversion is the single source of truth for all three; + // this arm only keeps them from reaching the catch-all. + Err( + e @ (PlatformWalletError::ShieldedInviteAlreadyClaimed { .. } + | PlatformWalletError::ShieldedForeignScanBudgetExhausted { .. } + | PlatformWalletError::ShieldedLifecycleBusy { .. }), + ) => (None, e.into()), + Err(e) => ( + None, + 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 +1831,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::*; @@ -1882,4 +2236,81 @@ mod tests { PlatformWalletFFIResultCode::Success ); } + + /// The one-time-key claim entry point must let every typed retry-semantics + /// code through — not just the terminal one. + /// + /// `ShieldedForeignScanBudgetExhausted` has a blanket conversion to code 44 + /// (`ErrorShieldedScanBudgetExhausted`), which Kotlin maps to the RETRYABLE + /// `ShieldedScanBudgetExhausted`. This entry point used to reach it only via + /// the catch-all, flattening it to `ErrorWalletOperation` (6) — a + /// non-retryable generic — so the host rendered a paused scan as a failed + /// claim and stranded a funded invitation whose note sits deep in the tree. + /// The polarity is the whole contract, so it is pinned here at the boundary + /// the host actually calls, not only at the blanket conversion. + #[test] + fn map_one_time_claim_result_pins_the_retryable_scan_budget_code() { + let (identity_id, result) = map_one_time_claim_result(Err( + PlatformWalletError::ShieldedForeignScanBudgetExhausted { + scanned_through: 262_144, + }, + )); + assert_eq!( + result.code, + PlatformWalletFFIResultCode::ErrorShieldedScanBudgetExhausted, + "a budget-paused claim scan must reach the host as 44, never as the \ + generic ErrorWalletOperation (6)" + ); + assert!( + identity_id.is_none(), + "nothing was built or broadcast, so no identity id may be written" + ); + assert!( + message_of(&result).contains("262144"), + "the checkpointed scan position must survive in the message" + ); + } + + /// The neighbours of the arm above, pinned in the same test so a future + /// edit cannot silently re-flatten one of them. + #[test] + fn map_one_time_claim_result_pins_the_terminal_and_unconfirmed_codes() { + let claimed = + map_one_time_claim_result(Err(PlatformWalletError::ShieldedInviteAlreadyClaimed { + reason: "nullifier already spent".to_string(), + })); + assert_eq!( + claimed.1.code, + PlatformWalletFFIResultCode::ErrorShieldedInviteAlreadyClaimed + ); + assert!( + claimed.0.is_none(), + "a consumed invitation must NOT write an identity id — that is the \ + false-ownership claim code 43 exists to prevent" + ); + + // The one code that DOES write `out_identity_id`. + let expected = Identifier::from([7u8; 32]); + let unconfirmed = + map_one_time_claim_result(Err(PlatformWalletError::ShieldedBroadcastUnconfirmed { + identity_id: expected, + reason: "result proof fetch failed".to_string(), + })); + assert_eq!( + unconfirmed.1.code, + PlatformWalletFFIResultCode::ErrorShieldedBroadcastUnconfirmed + ); + assert_eq!( + unconfirmed.0, + Some(expected), + "the unconfirmed code must hand back the derived id so the host can hold the slot" + ); + + // Anything without a typed code still flattens, deliberately. + let generic = map_one_time_claim_result(Err(PlatformWalletError::ShieldedNoUnspentNotes)).1; + assert_eq!( + generic.code, + PlatformWalletFFIResultCode::ErrorWalletOperation + ); + } } diff --git a/packages/rs-platform-wallet-ffi/src/shielded_sync.rs b/packages/rs-platform-wallet-ffi/src/shielded_sync.rs index 8b41f702b8e..caac7173d83 100644 --- a/packages/rs-platform-wallet-ffi/src/shielded_sync.rs +++ b/packages/rs-platform-wallet-ffi/src/shielded_sync.rs @@ -436,16 +436,11 @@ pub unsafe extern "C" fn platform_wallet_manager_shielded_clear( }); let result = unwrap_option_or_return!(option); if let Err(e) = result { - // A drain that did not complete is NOT an ordinary store failure: - // it means callback-capable work may still be running, which the - // host must be able to tell apart (it keeps its callback context - // alive rather than just retrying the wipe). Route that one case - // through the typed conversion and keep the generic mapping for - // every other failure. - if matches!( - e, - platform_wallet::PlatformWalletError::ShutdownIncomplete(_) - ) { + // Some Clear failures carry a code of their own that the host has to + // act on differently from "the wipe failed"; everything else is a + // generic store failure. Route the typed ones through `From` and keep + // the generic mapping for the rest. + if clear_failure_is_typed(&e) { return PlatformWalletFFIResult::from(e); } return PlatformWalletFFIResult::err( @@ -456,6 +451,30 @@ pub unsafe extern "C" fn platform_wallet_manager_shielded_clear( PlatformWalletFFIResult::ok() } +/// Whether a `clear_shielded` failure must keep its own FFI code instead of +/// flattening to the generic `ErrorWalletOperation` (6). +/// +/// Two cases, and a host has to do something different for each: +/// +/// * `ShutdownIncomplete` (27) — a drain that did not complete, so +/// callback-capable work may still be running and the host must keep its +/// callback context alive rather than simply retrying the wipe. +/// * `ShieldedLifecycleBusy` (45) — an in-flight one-time-key claim refused +/// the destructive admission Clear needs. Nothing was purged and the +/// operation is RETRYABLE; flattened to 6 the host classifies it as a hard +/// failure and shows a wipe error for what is really "busy, try again in a +/// moment" (#4313 round-2 review, clear-path pass-through). +/// +/// Hosts classify by code, so a code that never arrives is a code that does +/// not exist — hence the pin in `clear_path_tests` below. +fn clear_failure_is_typed(e: &platform_wallet::PlatformWalletError) -> bool { + matches!( + e, + platform_wallet::PlatformWalletError::ShutdownIncomplete(_) + | platform_wallet::PlatformWalletError::ShieldedLifecycleBusy { .. } + ) +} + // --------------------------------------------------------------------------- // Default Orchard payment address // --------------------------------------------------------------------------- @@ -568,3 +587,46 @@ pub unsafe extern "C" fn platform_wallet_manager_shielded_sync_wallet( ), } } + +#[cfg(test)] +mod clear_path_tests { + use super::*; + + /// The `clear` entry point must pass BOTH typed lifecycle failures through + /// with their own codes. `ShieldedLifecycleBusy` used to flatten to + /// `ErrorWalletOperation` (6) here — precisely the defect code 45 exists to + /// fix — because only `ShutdownIncomplete` was listed. + #[test] + fn clear_path_preserves_typed_lifecycle_codes() { + let busy = platform_wallet::PlatformWalletError::ShieldedLifecycleBusy { + reason: "a one-time-key claim is still in flight".to_string(), + }; + assert!( + clear_failure_is_typed(&busy), + "a contended lifecycle must not flatten to the generic wallet-operation code" + ); + let mut result = PlatformWalletFFIResult::from(busy); + assert_eq!( + result.code as i32, + PlatformWalletFFIResultCode::ErrorShieldedLifecycleBusy as i32, + "the retryable busy refusal must reach the host as 45" + ); + unsafe { crate::error::platform_wallet_ffi_result_free(&mut result) }; + + let incomplete = + platform_wallet::PlatformWalletError::ShutdownIncomplete("drain timed out".to_string()); + assert!(clear_failure_is_typed(&incomplete)); + let mut result = PlatformWalletFFIResult::from(incomplete); + assert_eq!( + result.code as i32, + PlatformWalletFFIResultCode::ErrorShutdownIncomplete as i32, + "the pre-existing drain pass-through must keep working" + ); + unsafe { crate::error::platform_wallet_ffi_result_free(&mut result) }; + + // A genuine store failure still takes the generic mapping. + assert!(!clear_failure_is_typed( + &platform_wallet::PlatformWalletError::ShieldedStoreError("disk gone".to_string()) + )); + } +} 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..ec7dd641bf2 100644 --- a/packages/rs-platform-wallet/src/error.rs +++ b/packages/rs-platform-wallet/src/error.rs @@ -542,6 +542,23 @@ pub enum PlatformWalletError { #[error("Shielded sync failed: {0}")] ShieldedSyncFailed(String), + /// The foreign-key (one-time-invitation) note scan consumed its + /// per-attempt work budget before covering the requested value + /// (dashpay/platform#4306). RETRYABLE, and the retry is CHEAP: progress + /// was checkpointed at tree position `scanned_through`, so the next + /// attempt resumes there instead of restarting — attempts compound until + /// the note is found or the tree is genuinely exhausted. + /// + /// Hosts MUST render this as "still searching — retry", never as an + /// invalid, already-claimed, or unfunded invitation: the scan has simply + /// not looked far enough yet, and treating it as terminal would strand a + /// genuinely funded claim whose note sits deep in the tree. + #[error( + "shielded foreign-key scan paused at tree position {scanned_through} after \ + exhausting its per-attempt budget; progress is checkpointed — retry to continue" + )] + ShieldedForeignScanBudgetExhausted { scanned_through: u64 }, + /// A background sync pass did not drain within its quiesce budget, so /// the operation that required a "no more persister stores" barrier /// (manager shutdown, `clear_shielded`, a sync-state reset) aborted @@ -574,6 +591,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/manager/wallet_lifecycle.rs b/packages/rs-platform-wallet/src/manager/wallet_lifecycle.rs index c9eafee286b..5f484dd1288 100644 --- a/packages/rs-platform-wallet/src/manager/wallet_lifecycle.rs +++ b/packages/rs-platform-wallet/src/manager/wallet_lifecycle.rs @@ -677,6 +677,21 @@ impl PlatformWalletManager

{ /// or through a registration/load rollback for an insert that could not have /// happened while G1 occupied the id — so while the gate is held and before /// the removal below, the inner entry is still G1 by construction. + /// + /// ## Refusal + /// + /// The removal can now be REFUSED, with + /// [`PlatformWalletError::ShieldedLifecycleBusy`], when an in-flight + /// one-time-key claim still holds the shielded store's destructive + /// admission. The refusal happens before the first mutation, so the wallet + /// is left exactly as it was found — registered, bindable, and removable by + /// a retry once the claim settles. Previously the shielded unregister could + /// not fail, so this returned success while the wallet's decrypted notes, + /// watermarks and pending-claim record all stayed on disk, and the retry + /// the situation called for answered `WalletNotFound` + /// (#4313 review finding coordinator.rs:805). + /// + /// [`PlatformWalletError::ShieldedLifecycleBusy`]: crate::error::PlatformWalletError::ShieldedLifecycleBusy pub async fn remove_wallet_with_teardown( &self, wallet_id: &WalletId, @@ -767,14 +782,32 @@ impl PlatformWalletManager

{ // exactly the state this call exists to drop. The flag is read // inside the coordinator's install transaction, which the // unregister also takes, so the two cannot interleave. - // Unconditional: a removal with no coordinator yet has nothing - // to unregister, but the handle must still be barred from - // binding onto one a later `configure_shielded` installs. - #[cfg(feature = "shielded")] - removed.mark_shielded_detached(); + // + // Which is why, when a coordinator IS installed, the mark is handed to + // `unregister_wallet_with` rather than done here: that call can now + // ABORT — a one-time-key claim holding destructive admission makes the + // whole removal fail with a retryable + // `ShieldedLifecycleBusy` (#4313 review finding coordinator.rs:805) — + // and a wallet that survives the abort must not be left permanently + // unbindable. The closure runs inside the coordinator's critical + // section, after admission is secured and before any registry is + // cleared, so the ordering above is preserved exactly while an abort + // leaves the flag untouched. + // + // The abort must also come BEFORE the id-keyed teardown below and the + // inner-manager removal: propagating with `?` here is what leaves the + // wallet fully intact for the retry the error asks for. #[cfg(feature = "shielded")] - if let Some(coordinator) = self.shielded_coordinator().await { - coordinator.unregister_wallet(*wallet_id).await; + match self.shielded_coordinator().await { + Some(coordinator) => { + coordinator + .unregister_wallet_with(*wallet_id, || removed.mark_shielded_detached()) + .await?; + } + // No coordinator yet: nothing to unregister and nothing that can + // refuse, but the handle must still be barred from binding onto one + // a later `configure_shielded` installs. + None => removed.mark_shielded_detached(), } for identity_id in &owned_identity_ids { diff --git a/packages/rs-platform-wallet/src/wallet/platform_wallet.rs b/packages/rs-platform-wallet/src/wallet/platform_wallet.rs index b6d29e67c40..ae9907ead8f 100644 --- a/packages/rs-platform-wallet/src/wallet/platform_wallet.rs +++ b/packages/rs-platform-wallet/src/wallet/platform_wallet.rs @@ -1541,6 +1541,108 @@ 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, + identity_index, + 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..79aa52d128a 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 @@ -727,7 +755,53 @@ impl NetworkShieldedCoordinator { /// re-bind of the same wallet would resume from the stale /// `last_synced_note_index` and silently skip re-emitting its /// notes to the host. - pub async fn unregister_wallet(&self, wallet_id: WalletId) { + /// + /// # All-or-nothing + /// + /// This either completes the whole removal or changes NOTHING, returning + /// [`PlatformWalletError::ShieldedLifecycleBusy`]. It used to clear the + /// registries first and merely log when the purge could not be admitted, + /// which reported success to `remove_wallet_with_teardown` while the + /// wallet's decrypted notes, watermarks, activity rows and pending-claim + /// record were all still on disk — and, because the manager had by then + /// dropped the wallet, the advertised "retry the removal" answered + /// `WalletNotFound` and could never finish the job + /// (#4313 review finding coordinator.rs:805). Failing before the first + /// mutation is what makes the retry real. + /// + /// [`PlatformWalletError::ShieldedLifecycleBusy`]: crate::error::PlatformWalletError::ShieldedLifecycleBusy + pub async fn unregister_wallet( + &self, + wallet_id: WalletId, + ) -> Result<(), crate::error::PlatformWalletError> { + self.unregister_wallet_with(wallet_id, || {}).await + } + + /// [`Self::unregister_wallet`], plus `on_admitted` run at the one instant + /// where the removal is committed but nothing has been torn down yet: + /// destructive admission is held, the `lifecycle` mutex is held, and no + /// registry has been touched. + /// + /// That instant is exactly where `PlatformWallet::mark_shielded_detached` + /// has to go. The flag must be set BEFORE the registries are cleared (an + /// in-flight bind would otherwise land its registration after the purge and + /// resurrect the state this call exists to drop) but must NOT be set when + /// the removal aborts (the wallet survives, and a permanently + /// unbindable survivor is its own bug). Running it inside the critical + /// section satisfies both without a rollback: binds take the same + /// `lifecycle` mutex, so none can interleave, and an abort returns before + /// the closure is ever reached. + /// + /// `on_admitted` is synchronous by design — it runs with locks held, and + /// the only caller sets an `AtomicBool`. + pub async fn unregister_wallet_with( + &self, + wallet_id: WalletId, + on_admitted: F, + ) -> Result<(), crate::error::PlatformWalletError> + where + F: FnOnce(), + { // Same lifecycle serialization as an install transaction — // without it a concurrent register could interleave between the // two map mutations and end up with visible accounts whose @@ -735,18 +809,146 @@ impl NetworkShieldedCoordinator { // restore could repopulate (and re-mark hydrated) the state // purged below after the purge ran. let _lifecycle = self.lifecycle.lock().await; + + // STORE-level destructive admission, taken BEFORE any registration is + // cleared (#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 already in flight and locks out new ones. + // + // On failure we abort with the claim's own retryable error and leave + // the coordinator untouched, exactly as `clear()` does. Forcing the + // purge would strand the identity an in-flight claim's transition + // creates; doing it half-way — the previous behaviour — reported a + // removal that had not happened. + let admission = self + .acquire_destructive_admission(Some(wallet_id), "unregister_wallet") + .await?; + + // Committed: past this point every step is best-effort cleanup, never + // a reason to abort. The caller's pre-teardown hook runs here. + on_admitted(); + self.accounts .write() .await .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) { + + let purged = self.store.write().await.purge_wallet(wallet_id); + self.release_destructive_admission(admission).await; + if let Err(e) = purged { + // The registries ARE gone, so no sync can run for this wallet and + // the host's removal is honoured. A store-level failure here is a + // genuine I/O fault rather than a contended lifecycle, so it is + // reported as one — not as the retryable busy error above. tracing::warn!( wallet_id = %hex::encode(wallet_id), error = %e, "Failed to purge per-subwallet store state on unregister" ); + return Err(crate::error::PlatformWalletError::ShieldedStoreError( + format!( + "unregister_wallet: purge_wallet failed after the registries were cleared: {e}" + ), + )); + } + Ok(()) + } + + /// 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::generate()?; + // `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!( + error = %e, + "Failed to release shielded lifecycle admission; it will expire on its own" + ); } } @@ -985,6 +1187,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 +1261,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 @@ -2359,7 +2578,10 @@ mod tests { // And unregister clears it too. coordinator.mark_hydrated(wallet_id, true).await; - coordinator.unregister_wallet(wallet_id).await; + coordinator + .unregister_wallet(wallet_id) + .await + .expect("unregister"); assert!(!coordinator.is_hydrated(wallet_id).await); let _ = std::fs::remove_dir_all(&dir); @@ -2474,7 +2696,10 @@ mod tests { ); drop(install); - remover.await.expect("unregister task"); + remover + .await + .expect("unregister task") + .expect("unregister must succeed once the install transaction commits"); assert!( coordinator.registered_subwallets().await.is_empty(), "the queued unregister runs as soon as the transaction commits" @@ -2661,4 +2886,252 @@ 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, + identity_index: None, + }, + 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` must REFUSE — all or nothing — while a one-time-key + /// claim holds destructive admission (#4313 review finding + /// coordinator.rs:805). + /// + /// The old behaviour cleared the registries and merely logged the skipped + /// purge. That reported a successful removal to + /// `remove_wallet_with_teardown` while the wallet's decrypted notes, + /// watermarks, activity rows and pending-claim record all stayed on disk — + /// and since the manager had by then dropped the wallet, the "retry the + /// removal" the log advised answered `WalletNotFound` forever. So this + /// asserts the three things that make the retry real: a typed retryable + /// error, registrations untouched, and the pre-teardown hook never fired. + #[tokio::test(start_paused = true)] + async fn unregister_refuses_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; + + let registered_before = coordinator.registered_subwallets().await; + assert!( + !registered_before.is_empty(), + "precondition: the wallet is registered" + ); + + let mut hook_fired = false; + let refused = coordinator + .unregister_wallet_with(wallet_id, || hook_fired = true) + .await; + assert!( + matches!( + refused, + Err(crate::error::PlatformWalletError::ShieldedLifecycleBusy { .. }) + ), + "unregister must refuse while a claim holds admission, got {refused:?}" + ); + assert!( + !hook_fired, + "an aborted removal must not run the pre-teardown hook — the wallet survives, \ + and marking it detached would leave it permanently unbindable" + ); + assert_eq!( + coordinator.registered_subwallets().await, + registered_before, + "a refused removal must leave every registration exactly as it found it" + ); + assert_eq!( + coordinator + .store() + .read() + .await + .pending_redrives(id) + .expect("records") + .len(), + 1, + "the in-flight claim's recovery record must survive the refused removal" + ); + + // Retrying after the claim settles completes the whole removal. + coordinator + .store() + .write() + .await + .end_claim_admission(lease) + .expect("release lease"); + let mut retry_hook_fired = false; + coordinator + .unregister_wallet_with(wallet_id, || retry_hook_fired = true) + .await + .expect("the retry must succeed once the claim has released"); + assert!(retry_hook_fired, "the committed removal runs the hook"); + assert!( + coordinator.registered_subwallets().await.is_empty(), + "the retry drops the registrations it refused to touch before" + ); + 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, + identity_index: None, + }, + ) + .expect("arm an unrelated record"); + + coordinator + .unregister_wallet(registered) + .await + .expect("an unrelated wallet's claim must not refuse this removal"); + + 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..886efc43434 100644 --- a/packages/rs-platform-wallet/src/wallet/shielded/file_store.rs +++ b/packages/rs-platform-wallet/src/wallet/shielded/file_store.rs @@ -18,10 +18,11 @@ use std::path::{Path, PathBuf}; use std::sync::Mutex; use grovedb_commitment_tree::{ClientPersistentCommitmentTree, Position, Retention}; +use rusqlite::{Connection, OptionalExtension}; use super::store::{ - PendingRedrive, ShieldedNote, ShieldedOutgoingNote, ShieldedStore, StalePendingSpend, - SubwalletId, SubwalletState, + AdmissionToken, ClaimKeyReservation, ClaimKeyReservationOutcome, PendingRedrive, ShieldedNote, + ShieldedOutgoingNote, ShieldedStore, StalePendingSpend, SubwalletId, SubwalletState, }; use crate::wallet::platform_wallet::WalletId; @@ -102,22 +103,78 @@ impl FileBackedShieldedStore { let conn = Self::open_tuned_connection(&path)?; let tree = ClientPersistentCommitmentTree::open(conn, max_checkpoints) .map_err(|e| FileShieldedStoreError(format!("open commitment tree: {e}")))?; - let pending_conn = Self::open_tuned_connection(&path)?; + // `open_durable_connection`, NOT `open_tuned_connection`: this + // connection owns the unreconstructable claim-recovery row, so it runs + // at `synchronous=FULL` (#4313 review finding file_store.rs:107). The + // tree connection above keeps NORMAL — see both doc comments. + let mut pending_conn = Self::open_durable_connection(&path)?; pending_conn .execute( "CREATE TABLE IF NOT EXISTS shielded_pending_spends ( - wallet_id BLOB NOT NULL, - account_index INTEGER NOT NULL, - activity_id BLOB NOT NULL, - anchor BLOB NOT NULL, - nullifiers BLOB NOT NULL, - st_bytes BLOB NOT NULL, - attempts INTEGER NOT NULL DEFAULT 0, + wallet_id BLOB NOT NULL, + account_index INTEGER NOT NULL, + activity_id BLOB NOT NULL, + anchor BLOB NOT NULL, + nullifiers BLOB NOT NULL, + st_bytes BLOB NOT NULL, + attempts INTEGER NOT NULL DEFAULT 0, + identity_index INTEGER, PRIMARY KEY (wallet_id, account_index, activity_id) )", [], ) .map_err(|e| FileShieldedStoreError(format!("create pending_spends table: {e}")))?; + Self::add_pending_spends_identity_index(&mut pending_conn)?; + // 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}")) + })?; + // One-time claim-key reservations (#4313 review finding cr-9d0e1a44). + // The lifecycle admission above is per-WALLET and orders a claim + // against a purge; it admits BOTH claims of the same invitation. This + // table is per-INVITATION: the PRIMARY KEY is what turns + // `INSERT ... ON CONFLICT DO NOTHING` into real mutual exclusion + // between two coordinators — or two processes — on one file, which is + // exactly where the coordinator's per-FVK mutex has no reach. + // + // Same lifetime rules as the admission table: rows are judged purely by + // `expires_at` and never wiped at open, so a live holder in another + // process keeps its reservation across our open while a dead one ages + // out. + pending_conn + .execute( + "CREATE TABLE IF NOT EXISTS shielded_one_time_claim_reservation ( + wallet_id BLOB NOT NULL, + claim_record_key BLOB NOT NULL, + token BLOB NOT NULL, + expires_at INTEGER NOT NULL, + PRIMARY KEY (wallet_id, claim_record_key) + )", + [], + ) + .map_err(|e| { + FileShieldedStoreError(format!("create one_time_claim_reservation table: {e}")) + })?; let mut store = Self { tree: Mutex::new(tree), path, @@ -129,6 +186,90 @@ impl FileBackedShieldedStore { Ok(store) } + /// Add `shielded_pending_spends.identity_index` to a database created + /// before that column existed (#4313 review finding 5d4d6efa). + /// + /// This store versions its schema by `CREATE TABLE IF NOT EXISTS` rather + /// than by `user_version`, so the matching idempotent form for a new column + /// is "read `PRAGMA table_info` and add it if absent". The column is + /// NULLABLE with no default: an existing claim record genuinely does not + /// know which slot its attempt targeted, and `NULL` says exactly that — + /// far better than back-filling a guess a resume would then enforce. + /// + /// # Racing opens + /// + /// Probe-then-ALTER is only idempotent if the two are ONE step. Two + /// processes (or two `FileBackedShieldedStore` instances) opening the same + /// path concurrently would otherwise both read "absent" and both ALTER, + /// and the loser's `open_path` would fail outright with + /// `duplicate column name` (#4313 review finding file_store.rs:206). Two + /// independent guards close that: + /// + /// 1. `BEGIN IMMEDIATE` takes the write lock BEFORE the probe, so SQLite's + /// one-writer rule totally orders the probe+ALTER pairs against each + /// other — the second one to run sees the column and does nothing. + /// 2. A `duplicate column name` failure is tolerated as benign anyway. + /// The post-condition this function owes its caller is "the column + /// exists", and that error says it does. Belt and braces, because the + /// cost of being wrong is a store that will not open at all. + fn add_pending_spends_identity_index( + conn: &mut Connection, + ) -> Result<(), FileShieldedStoreError> { + let tx = conn + .transaction_with_behavior(rusqlite::TransactionBehavior::Immediate) + .map_err(|e| FileShieldedStoreError(format!("begin pending_spends migration: {e}")))?; + let present = { + let mut stmt = tx + .prepare("SELECT 1 FROM pragma_table_info('shielded_pending_spends') WHERE name = 'identity_index'") + .map_err(|e| { + FileShieldedStoreError(format!("prepare pending_spends column probe: {e}")) + })?; + stmt.exists([]) + .map_err(|e| FileShieldedStoreError(format!("probe pending_spends columns: {e}")))? + }; + if !present { + match tx.execute( + "ALTER TABLE shielded_pending_spends ADD COLUMN identity_index INTEGER", + [], + ) { + Ok(_) => {} + Err(e) if Self::is_duplicate_column(&e) => { + // Guard 2 above: another opener won the race and the + // column is already there, which is exactly the state + // this function exists to reach. + tracing::debug!( + "shielded_pending_spends.identity_index already added by a concurrent \ + opener; treating as migrated" + ); + } + Err(e) => { + return Err(FileShieldedStoreError(format!( + "add pending_spends.identity_index: {e}" + ))) + } + } + } + tx.commit() + .map_err(|e| FileShieldedStoreError(format!("commit pending_spends migration: {e}")))?; + Ok(()) + } + + /// Whether `e` is SQLite's `duplicate column name` rejection of an + /// `ALTER TABLE ... ADD COLUMN` — i.e. "the column you asked for is + /// already there". + /// + /// Matched on the message rather than on a code: SQLite reports it as a + /// bare `SQLITE_ERROR` with no distinguishing extended code, so the text + /// is the only discriminator available. Deliberately narrow — every other + /// `SQLITE_ERROR` still fails the open. + fn is_duplicate_column(e: &rusqlite::Error) -> bool { + matches!( + e, + rusqlite::Error::SqliteFailure(_, Some(msg)) + if msg.to_ascii_lowercase().contains("duplicate column name") + ) + } + /// Reload every persisted [`PendingRedrive`] into the in-memory /// per-subwallet state, re-arming both the redrive record and the /// note reservations its nullifiers carry — an unconfirmed @@ -140,7 +281,7 @@ impl FileBackedShieldedStore { let mut stmt = conn .prepare( "SELECT wallet_id, account_index, activity_id, anchor, nullifiers, st_bytes, \ - attempts FROM shielded_pending_spends", + attempts, identity_index FROM shielded_pending_spends", ) .map_err(|e| FileShieldedStoreError(format!("prepare rehydrate: {e}")))?; let rows = stmt @@ -153,12 +294,21 @@ impl FileBackedShieldedStore { row.get::<_, Vec>(4)?, row.get::<_, Vec>(5)?, row.get::<_, u32>(6)?, + row.get::<_, Option>(7)?, )) }) .map_err(|e| FileShieldedStoreError(format!("query rehydrate: {e}")))?; for row in rows { - let (wallet_id, account_index, activity_id, anchor, nullifiers, st_bytes, attempts) = - row.map_err(|e| FileShieldedStoreError(format!("read rehydrate row: {e}")))?; + let ( + wallet_id, + account_index, + activity_id, + anchor, + nullifiers, + st_bytes, + attempts, + identity_index, + ) = row.map_err(|e| FileShieldedStoreError(format!("read rehydrate row: {e}")))?; let (Ok(wallet_id), Ok(activity_id), Ok(anchor)) = ( <[u8; 32]>::try_from(wallet_id.as_slice()), <[u8; 32]>::try_from(activity_id.as_slice()), @@ -176,22 +326,108 @@ impl FileBackedShieldedStore { .map(|c| <[u8; 32]>::try_from(c).expect("chunks_exact(32)")) .collect(); let id = SubwalletId::new(wallet_id, account_index); - let sw = self.subwallets.entry(id).or_default(); - for n in &nullifiers { - sw.mark_pending(n); - sw.set_pending_spend(n, anchor, activity_id); - } - sw.arm_redrive(PendingRedrive { - activity_id, - anchor, - nullifiers, - st_bytes, - attempts, - }); + Self::hydrate_pending_row( + self.subwallets.entry(id).or_default(), + PendingRedrive { + activity_id, + anchor, + nullifiers, + st_bytes, + attempts, + identity_index, + }, + ); } Ok(()) } + /// Read ONE `shielded_pending_spends` row — the record armed under + /// `activity_id` in subwallet `id` — straight from SQLite. + /// + /// Takes a `&Connection` (a `&Transaction` derefs to one) precisely so the + /// caller chooses the transaction it runs in: + /// [`reserve_one_time_claim_key`] calls it inside the reservation's + /// `BEGIN IMMEDIATE`, which is what makes "who owns this invitation" and + /// "what record already exists for it" a single atomic answer + /// (#4313 review finding r3767229122). + /// + /// A corrupt row reads as `None` with a warning, matching + /// [`rehydrate_pending_spends`]: a row that cannot be decoded cannot be + /// resumed either, and failing the whole claim on it would be worse than + /// rebuilding. + /// + /// [`reserve_one_time_claim_key`]: ShieldedStore::reserve_one_time_claim_key + /// [`rehydrate_pending_spends`]: Self::rehydrate_pending_spends + fn read_pending_row( + conn: &Connection, + id: SubwalletId, + activity_id: &[u8; 32], + ) -> Result, FileShieldedStoreError> { + let row = conn + .query_row( + "SELECT anchor, nullifiers, st_bytes, attempts, identity_index \ + FROM shielded_pending_spends \ + WHERE wallet_id = ?1 AND account_index = ?2 AND activity_id = ?3", + rusqlite::params![ + id.wallet_id.as_slice(), + id.account_index, + activity_id.as_slice() + ], + |row| { + Ok(( + row.get::<_, Vec>(0)?, + row.get::<_, Vec>(1)?, + row.get::<_, Vec>(2)?, + row.get::<_, u32>(3)?, + row.get::<_, Option>(4)?, + )) + }, + ) + .optional() + .map_err(|e| FileShieldedStoreError(format!("read pending claim record: {e}")))?; + let Some((anchor, nullifiers, st_bytes, attempts, identity_index)) = row else { + return Ok(None); + }; + let Ok(anchor) = <[u8; 32]>::try_from(anchor.as_slice()) else { + tracing::warn!("ignoring corrupt shielded_pending_spends row (bad anchor width)"); + return Ok(None); + }; + if nullifiers.is_empty() || nullifiers.len() % 32 != 0 { + tracing::warn!("ignoring corrupt shielded_pending_spends row (bad nullifiers)"); + return Ok(None); + } + Ok(Some(PendingRedrive { + activity_id: *activity_id, + anchor, + nullifiers: nullifiers + .chunks_exact(32) + .map(|c| <[u8; 32]>::try_from(c).expect("chunks_exact(32)")) + .collect(), + st_bytes, + attempts, + identity_index, + })) + } + + /// Fold a durable `shielded_pending_spends` row into the in-memory mirror: + /// re-arm the redrive record AND the note reservations its nullifiers + /// carry, so an unconfirmed broadcast keeps its notes excluded from + /// selection for as long as the record lives. + /// + /// Shared by [`rehydrate_pending_spends`] (store open) and by + /// [`reserve_one_time_claim_key`] (a row a PEER store armed after our + /// open), so both reach the identical in-memory shape. + /// + /// [`rehydrate_pending_spends`]: Self::rehydrate_pending_spends + /// [`reserve_one_time_claim_key`]: ShieldedStore::reserve_one_time_claim_key + fn hydrate_pending_row(sw: &mut SubwalletState, record: PendingRedrive) { + for n in &record.nullifiers { + sw.mark_pending(n); + sw.set_pending_spend(n, record.anchor, record.activity_id); + } + sw.arm_redrive(record); + } + /// Open a `rusqlite::Connection` on `path` with the same WAL / /// `synchronous=NORMAL` / `temp_store=MEMORY` PRAGMAs the cold-sync /// append path depends on (see [`open_path`] for the rationale). @@ -203,6 +439,57 @@ impl FileBackedShieldedStore { /// [`open_path`]: Self::open_path /// [`reset_commitment_tree`]: ShieldedStore::reset_commitment_tree fn open_tuned_connection(path: &Path) -> Result { + Self::open_connection_with_sync(path, "NORMAL") + } + + /// Open the RECOVERY connection — the one owning `shielded_pending_spends` + /// and the admission tables — with `synchronous=FULL` rather than the + /// commitment tree connection's `NORMAL`. + /// + /// # Why this connection alone pays for FULL + /// + /// Under WAL, `synchronous=NORMAL` does not fsync at commit: the commit + /// returns as soon as the frames reach the OS, so a host crash or power + /// loss can discard a transaction that already reported success. That is + /// the right trade for the commitment tree, where no row is user money — + /// every commitment is chain-side authenticated and rebuildable by + /// re-running sync from `last_synced_note_index` (see [`open_path`]). + /// + /// It is the WRONG trade for the row this connection writes. + /// [`arm_redrive_under_claim`] persists a one-time-claim record whose + /// `st_bytes` carry the RANDOMIZED padded identity id of a transition + /// that is broadcast immediately afterwards: the padding action's dummy + /// nullifier is generated fresh at build time and participates in the + /// consensus id derivation, so that id exists nowhere else and is NOT + /// re-derivable from the invitation. Losing the row after the broadcast + /// therefore strands an identity that exists on chain, permanently and + /// unreconstructably (#4313 review finding file_store.rs:107). FULL + /// closes the window by fsync'ing before the commit returns. + /// + /// The cost lands where it is affordable: a handful of writes per claim + /// (arm, lease renew, release) rather than the tree's millions of + /// `append_commitment` calls. `synchronous` is per-CONNECTION, so the + /// tree connection keeps NORMAL; `journal_mode=WAL` is per-database and + /// shared by both. + /// + /// [`open_path`]: Self::open_path + /// [`arm_redrive_under_claim`]: ShieldedStore::arm_redrive_under_claim + fn open_durable_connection( + path: &Path, + ) -> Result { + Self::open_connection_with_sync(path, "FULL") + } + + /// Shared body of [`open_tuned_connection`] and + /// [`open_durable_connection`] — identical WAL / `temp_store` / busy-timeout + /// setup, with the caller choosing the `synchronous` level its data needs. + /// + /// [`open_tuned_connection`]: Self::open_tuned_connection + /// [`open_durable_connection`]: Self::open_durable_connection + fn open_connection_with_sync( + path: &Path, + synchronous: &str, + ) -> Result { let conn = rusqlite::Connection::open(path) .map_err(|e| FileShieldedStoreError(format!("open sqlite: {e}")))?; // Pragmas must be applied before the schema is touched. They survive @@ -210,7 +497,7 @@ impl FileBackedShieldedStore { // subsequent reopen on the same file until explicitly changed. for (k, v) in [ ("journal_mode", "WAL"), - ("synchronous", "NORMAL"), + ("synchronous", synchronous), ("temp_store", "MEMORY"), ] { conn.pragma_update(None, k, v) @@ -225,6 +512,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 @@ -399,8 +714,9 @@ impl ShieldedStore for FileBackedShieldedStore { let nullifier_blob: Vec = redrive.nullifiers.iter().flatten().copied().collect(); conn.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)", + (wallet_id, account_index, activity_id, anchor, nullifiers, st_bytes, attempts, \ + identity_index) \ + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)", rusqlite::params![ id.wallet_id.as_slice(), id.account_index, @@ -409,6 +725,7 @@ impl ShieldedStore for FileBackedShieldedStore { nullifier_blob, redrive.st_bytes, redrive.attempts, + redrive.identity_index, ], ) .map_err(|e| FileShieldedStoreError(format!("persist redrive: {e}")))?; @@ -735,6 +1052,397 @@ 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 renew_claim_admission( + &mut self, + token: AdmissionToken, + now_ms: u64, + lease_ms: u64, + ) -> Result { + // IMMEDIATE, like every other lease write: SQLite's one-writer rule is + // what totally orders this against a purge taking its barrier, so the + // renewal either lands before the barrier or loses to it — never + // half-applies. UPDATE ... WHERE expires_at > now deliberately refuses + // to resurrect a lapsed lease; see the trait docs. + 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 lease renewal: {e}")))?; + let updated = tx + .execute( + "UPDATE shielded_lifecycle_admission SET expires_at = ?1 \ + WHERE token = ?2 AND destructive = 0 AND expires_at > ?3", + rusqlite::params![ + Self::as_sqlite_millis(now_ms.saturating_add(lease_ms)), + token.0.as_slice(), + Self::as_sqlite_millis(now_ms) + ], + ) + .map_err(|e| FileShieldedStoreError(format!("renew claim lease: {e}")))?; + if updated > 0 { + // Keep the claim-key reservation in lockstep with the lease that + // owns it, in the SAME transaction — a long claim must not lose its + // invitation to expiry while its lease is being kept alive. + tx.execute( + "UPDATE shielded_one_time_claim_reservation SET expires_at = ?1 WHERE token = ?2", + rusqlite::params![ + Self::as_sqlite_millis(now_ms.saturating_add(lease_ms)), + token.0.as_slice(), + ], + ) + .map_err(|e| FileShieldedStoreError(format!("renew claim-key reservation: {e}")))?; + } + tx.commit() + .map_err(|e| FileShieldedStoreError(format!("commit claim lease renewal: {e}")))?; + Ok(updated > 0) + } + + fn reserve_one_time_claim_key( + &mut self, + claim_records_id: SubwalletId, + claim_record_key: [u8; 32], + token: AdmissionToken, + now_ms: u64, + lease_ms: u64, + ) -> Result { + let wallet_id = claim_records_id.wallet_id; + // BEGIN IMMEDIATE, like every other admission write. SQLite admits one + // writer at a time across every connection AND every process on the + // file, so the reap + insert-if-absent + read-back + pending-row read + // below is one totally ordered step even between two + // `FileBackedShieldedStore` instances that share nothing but the path. + // That total order is what makes "exactly one caller sees `Acquired`" + // true rather than probable — and what lets the pending row come back + // with it (#4313 review finding r3767229122). + let (reservation, pending) = { + 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-key reservation: {e}")))?; + // Reap first, so a claimant that died without releasing cannot hold + // an invitation hostage past its lease. + tx.execute( + "DELETE FROM shielded_one_time_claim_reservation WHERE expires_at <= ?1", + rusqlite::params![Self::as_sqlite_millis(now_ms)], + ) + .map_err(|e| FileShieldedStoreError(format!("reap claim-key reservations: {e}")))?; + // ON CONFLICT DO NOTHING, never OR REPLACE: losing this insert must + // leave the winner's row byte-for-byte untouched. The rowcount + // decides the outcome, and the read-back below reports the durable + // truth either way. + let inserted = tx + .execute( + "INSERT INTO shielded_one_time_claim_reservation \ + (wallet_id, claim_record_key, token, expires_at) VALUES (?1, ?2, ?3, ?4) \ + ON CONFLICT (wallet_id, claim_record_key) DO NOTHING", + rusqlite::params![ + wallet_id.as_slice(), + claim_record_key.as_slice(), + token.0.as_slice(), + Self::as_sqlite_millis(now_ms.saturating_add(lease_ms)), + ], + ) + .map_err(|e| { + FileShieldedStoreError(format!("insert claim-key reservation: {e}")) + })?; + let reservation = if inserted > 0 { + ClaimKeyReservation::Acquired + } else { + // Query the DURABLE row rather than assuming the conflict was + // someone else's: our own token re-entering is idempotent (and + // re-stamps), anyone else's is a genuine loss. + let (holder, expires_at): (Vec, i64) = tx + .query_row( + "SELECT token, expires_at FROM shielded_one_time_claim_reservation \ + WHERE wallet_id = ?1 AND claim_record_key = ?2", + rusqlite::params![wallet_id.as_slice(), claim_record_key.as_slice()], + |row| Ok((row.get(0)?, row.get(1)?)), + ) + .map_err(|e| { + FileShieldedStoreError(format!("read claim-key reservation: {e}")) + })?; + let holder = <[u8; 16]>::try_from(holder.as_slice()).map_err(|_| { + FileShieldedStoreError( + "corrupt claim-key reservation row (bad token width)".to_string(), + ) + })?; + if holder == token.0 { + tx.execute( + "UPDATE shielded_one_time_claim_reservation SET expires_at = ?1 \ + WHERE wallet_id = ?2 AND claim_record_key = ?3", + rusqlite::params![ + Self::as_sqlite_millis(now_ms.saturating_add(lease_ms)), + wallet_id.as_slice(), + claim_record_key.as_slice(), + ], + ) + .map_err(|e| { + FileShieldedStoreError(format!("re-stamp claim-key reservation: {e}")) + })?; + ClaimKeyReservation::Acquired + } else { + ClaimKeyReservation::Held { + holder: AdmissionToken(holder), + expires_at: expires_at.max(0) as u64, + } + } + }; + // The pending-claim row, read from SQLITE in this same transaction + // (#4313 review finding r3767229122). It deliberately does NOT come + // from `self.subwallets`: that mirror is hydrated once at store + // open, so a row a PEER store armed after our open is invisible in + // it. A claimant that trusted the mirror would see "no record", + // build a second transition with a different padded identity id, + // and `arm_redrive_under_claim` would replace the peer's only + // recovery handle for an identity already on the wire. + let pending = Self::read_pending_row(&tx, claim_records_id, &claim_record_key)?; + tx.commit().map_err(|e| { + FileShieldedStoreError(format!("commit claim-key reservation: {e}")) + })?; + (reservation, pending) + }; + // Fold the durable row into this instance's mirror so every later + // in-memory read (`pending_redrives`, `bump_redrive_attempts`, + // `clear_redrive`) agrees with disk for the rest of this claim. Without + // it the resume path would arm and clear against a map that never knew + // the record existed. + if let Some(record) = pending.clone() { + Self::hydrate_pending_row(self.subwallets.entry(claim_records_id).or_default(), record); + } + Ok(ClaimKeyReservationOutcome { + reservation, + pending, + }) + } + + 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}")))?; + // The claim-key gate, in the SAME transaction as the write it + // guards: a live reservation for this exact record key under a + // DIFFERENT token means another claimant owns this invitation, and + // the `INSERT OR REPLACE` below would overwrite its byte-exact + // recovery row. Refusing here is what makes that clobber + // structurally impossible rather than merely unreachable + // (#4313 review finding cr-9d0e1a44). Ordinary spend redrives take + // no reservation, so the count is 0 and the gate is a no-op. + let foreign_hold: i64 = tx + .query_row( + "SELECT COUNT(*) FROM shielded_one_time_claim_reservation \ + WHERE wallet_id = ?1 AND claim_record_key = ?2 AND expires_at > ?3 \ + AND token != ?4", + rusqlite::params![ + id.wallet_id.as_slice(), + redrive.activity_id.as_slice(), + Self::as_sqlite_millis(now_ms), + token.0.as_slice(), + ], + |row| row.get(0), + ) + .map_err(|e| FileShieldedStoreError(format!("read claim-key reservation: {e}")))?; + if foreign_hold > 0 { + return Ok(false); + } + 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, \ + identity_index) \ + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)", + 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, + redrive.identity_index, + ], + ) + .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}")))?; + // The reservation rides the same re-stamp, for the same reason: the + // window that protects the record it guards must run from here. + tx.execute( + "UPDATE shielded_one_time_claim_reservation 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-key reservation: {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> { + // Lease and claim-key reservation drop together, in one transaction: + // releasing the lease while leaving the key reserved would block the + // next claimant of this invitation for a full lease period for no + // reason, and releasing the key first would let a second claimant in + // while this one still holds the lease. + 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 lease release: {e}")))?; + tx.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}")))?; + tx.execute( + "DELETE FROM shielded_one_time_claim_reservation WHERE token = ?1", + rusqlite::params![token.0.as_slice()], + ) + .map_err(|e| FileShieldedStoreError(format!("release claim-key reservation: {e}")))?; + tx.commit() + .map_err(|e| FileShieldedStoreError(format!("commit claim lease release: {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)] @@ -763,6 +1471,7 @@ mod tests { nullifiers: vec![[3u8; 32], [4u8; 32]], st_bytes: vec![0xAB; 96], attempts: 0, + identity_index: None, }; { let mut store = FileBackedShieldedStore::open_path(&path, 100).expect("open"); @@ -824,6 +1533,7 @@ mod tests { nullifiers: vec![[nf; 32]], st_bytes: vec![0xCD; 32], attempts: 0, + identity_index: None, }; // purge_wallet is scoped: it drops A's rows, keeps B's. @@ -899,6 +1609,7 @@ mod tests { nullifiers: vec![nf], st_bytes: vec![0xEF; 32], attempts: 0, + identity_index: None, }, ) .expect("arm"); @@ -1247,4 +1958,822 @@ 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, + identity_index: None, + } + } + + /// 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); + } + + // ── Claim-record identity slot (#4313 5d4d6efa) ──────────────────── + + /// A database created before `shielded_pending_spends.identity_index` + /// existed must open, gain the column, and keep its rows — with the slot + /// reading `None` rather than a back-filled guess a resume would then + /// enforce. This store versions its schema by `CREATE TABLE IF NOT EXISTS`, + /// so a `PRAGMA table_info` probe plus `ALTER TABLE` is the matching + /// idempotent form; the second open below proves it does not re-run. + #[test] + fn a_pre_migration_database_gains_the_identity_index_column() { + let path = temp_tree_path("pending_spends_migration"); + let id = SubwalletId::new([0x51; 32], CLAIM_ACCOUNT); + + // Build the OLD schema by hand — no identity_index column — and seed a + // record through it, exactly as a shipped build would have left it. + { + let conn = Connection::open(&path).expect("raw open"); + conn.execute( + "CREATE TABLE shielded_pending_spends ( + wallet_id BLOB NOT NULL, + account_index INTEGER NOT NULL, + activity_id BLOB NOT NULL, + anchor BLOB NOT NULL, + nullifiers BLOB NOT NULL, + st_bytes BLOB NOT NULL, + attempts INTEGER NOT NULL DEFAULT 0, + PRIMARY KEY (wallet_id, account_index, activity_id) + )", + [], + ) + .expect("old table"); + conn.execute( + "INSERT INTO shielded_pending_spends \ + (wallet_id, account_index, activity_id, anchor, nullifiers, st_bytes, attempts) \ + VALUES (?1, ?2, ?3, ?4, ?5, ?6, 0)", + rusqlite::params![ + id.wallet_id.as_slice(), + id.account_index, + [0x99u8; 32].as_slice(), + [0x0Au8; 32].as_slice(), + [0x0Bu8; 32].as_slice(), + vec![0xCDu8; 64], + ], + ) + .expect("old row"); + } + + let store = FileBackedShieldedStore::open_path(&path, 8).expect("migrating open"); + let records = store.pending_redrives(id).expect("records"); + assert_eq!(records.len(), 1, "the pre-migration row must survive"); + assert_eq!(records[0].activity_id, [0x99; 32]); + assert_eq!( + records[0].identity_index, None, + "a record that predates the column knows no slot, and must say so" + ); + drop(store); + + // Idempotent: opening again must not try to add the column twice. + let reopened = FileBackedShieldedStore::open_path(&path, 8).expect("second open"); + assert_eq!(reopened.pending_redrives(id).expect("records").len(), 1); + drop(reopened); + let _ = std::fs::remove_file(&path); + } + + /// A claim record's slot must round-trip through SQLite, not merely live in + /// the in-memory mirror: recovering it after a process restart is the only + /// reason to persist it at all. + #[test] + fn a_claim_records_identity_index_survives_a_reopen() { + let path = temp_tree_path("pending_spends_slot_roundtrip"); + let wallet_id: WalletId = [0x52; 32]; + let id = SubwalletId::new(wallet_id, CLAIM_ACCOUNT); + let mut store = FileBackedShieldedStore::open_path(&path, 8).expect("store"); + + let mut record = admission_record(0x77); + record.identity_index = Some(9); + store.arm_redrive(id, record).expect("arm"); + drop(store); + + let reopened = FileBackedShieldedStore::open_path(&path, 8).expect("reopen"); + let records = reopened.pending_redrives(id).expect("records"); + assert_eq!(records.len(), 1); + assert_eq!(records[0].identity_index, Some(9)); + drop(reopened); + let _ = std::fs::remove_file(&path); + } + + // ── Per-invitation claim-key reservation (#4313 cr-9d0e1a44) ─────── + // + // A claim LEASE is per-wallet and admits both claimants of one invitation. + // These tests cover the reservation that is per-INVITATION, and they open + // two store instances on the same file for the same reason the tests above + // do: that is precisely the interleaving the coordinator's per-FVK mutex + // cannot see. + + /// THE BUG: two coordinators (or two processes) on one SQLite file both got + /// admitted for the same invitation, built transitions with DIFFERENT + /// padded identity ids, and the second `arm_redrive_under_claim` — + /// `INSERT OR REPLACE` — overwrote the first's byte-exact recovery row + /// while the first's transition was already on the wire, stranding that + /// identity forever. + /// + /// Exactly one claimant may acquire the key; the loser is told who holds + /// it, and the storage layer refuses its arm outright so the winner's row + /// survives byte-for-byte. + #[test] + fn two_store_instances_cannot_both_claim_one_invitation() { + let path = temp_tree_path("claim_key_race"); + let wallet_id: WalletId = [0x31; 32]; + let claim_key = [0xC1; 32]; + let id = SubwalletId::new(wallet_id, CLAIM_ACCOUNT); + let mut first = FileBackedShieldedStore::open_path(&path, 8).expect("store a"); + let mut second = FileBackedShieldedStore::open_path(&path, 8).expect("store b"); + let now = 1_000_000; + + // Both are admitted by the per-WALLET lease — that is the point: the + // lease is not, and was never, mutual exclusion between claimants. + let winner = AdmissionToken::new(); + let loser = AdmissionToken::new(); + assert!(first + .begin_claim_admission(wallet_id, winner, now, 60_000) + .expect("first lease")); + assert!( + second + .begin_claim_admission(wallet_id, loser, now, 60_000) + .expect("second lease"), + "the per-wallet lease admits both claimants; only the claim-key \ + reservation separates them" + ); + + // The key, however, admits exactly one. + assert_eq!( + first + .reserve_one_time_claim_key(id, claim_key, winner, now, 60_000) + .expect("first reservation") + .reservation, + ClaimKeyReservation::Acquired + ); + assert_eq!( + second + .reserve_one_time_claim_key(id, claim_key, loser, now, 60_000) + .expect("second reservation") + .reservation, + ClaimKeyReservation::Held { + holder: winner, + expires_at: now + 60_000, + }, + "the loser must be handed the DURABLE row, not a fresh one of its own" + ); + + // The winner arms its byte-exact recovery record. + let mut winning_record = admission_record(0xC1); + winning_record.activity_id = claim_key; + winning_record.st_bytes = vec![0xAA; 96]; + assert!(first + .arm_redrive_under_claim(id, winning_record.clone(), winner, now, 60_000) + .expect("winner arms")); + + // The loser's arm — the INSERT OR REPLACE that used to clobber — is + // refused at the storage layer, even though its OWN lease is live. + let mut losing_record = admission_record(0xC2); + losing_record.activity_id = claim_key; + losing_record.st_bytes = vec![0xBB; 96]; + assert!( + !second + .arm_redrive_under_claim(id, losing_record, loser, now, 60_000) + .expect("loser arms"), + "a claimant that does not hold the key must not be able to write this record" + ); + + // The winner's record is intact, byte-for-byte, on a cold reopen. + drop((first, second)); + let reopened = FileBackedShieldedStore::open_path(&path, 8).expect("reopen"); + let records = reopened.pending_redrives(id).expect("records"); + assert_eq!( + records.len(), + 1, + "exactly one claim record for one invitation" + ); + assert_eq!(records[0].activity_id, claim_key); + assert_eq!( + records[0].st_bytes, + vec![0xAA; 96], + "the winner's byte-exact transition must survive the loser's attempt" + ); + drop(reopened); + let _ = std::fs::remove_file(&path); + } + + /// The reservation is bound to its lease for its whole life: released with + /// it, re-stamped with it, and otherwise reaped by expiry so a claimant + /// that died cannot hold an invitation hostage. + #[test] + fn a_claim_key_reservation_lives_and_dies_with_its_lease() { + let path = temp_tree_path("claim_key_lifetime"); + let wallet_id: WalletId = [0x32; 32]; + let claim_key = [0xC3; 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 first = AdmissionToken::new(); + assert!(store + .begin_claim_admission(wallet_id, first, now, 60_000) + .expect("lease")); + assert_eq!( + store + .reserve_one_time_claim_key(id, claim_key, first, now, 60_000) + .expect("reserve") + .reservation, + ClaimKeyReservation::Acquired + ); + // Re-entry by the SAME token is idempotent, never a self-lockout. + assert_eq!( + store + .reserve_one_time_claim_key(id, claim_key, first, now + 1, 60_000) + .expect("re-enter") + .reservation, + ClaimKeyReservation::Acquired + ); + + // Renewing the lease carries the reservation with it, so a long claim + // cannot lose its invitation to expiry while its lease is kept alive. + assert!(store + .renew_claim_admission(first, now + 30_000, 60_000) + .expect("renew")); + let second = AdmissionToken::new(); + assert!(store + .begin_claim_admission(wallet_id, second, now + 70_000, 60_000) + .expect("second lease")); + assert!( + !store + .reserve_one_time_claim_key(id, claim_key, second, now + 70_000, 60_000) + .expect("contend after renewal") + .is_acquired(), + "the renewed reservation must still be held past the ORIGINAL expiry" + ); + + // Releasing the lease releases the key in the same step — the next + // claimant of this invitation must not wait out a full lease period. + store.end_claim_admission(first).expect("release"); + assert_eq!( + store + .reserve_one_time_claim_key(id, claim_key, second, now + 70_000, 60_000) + .expect("reserve after release") + .reservation, + ClaimKeyReservation::Acquired + ); + + // And a holder that dies without releasing ages out rather than + // stranding the invitation forever. + let third = AdmissionToken::new(); + assert_eq!( + store + .reserve_one_time_claim_key(id, claim_key, third, now + 200_000, 60_000) + .expect("reserve after expiry") + .reservation, + ClaimKeyReservation::Acquired, + "an expired reservation must be reaped" + ); + + drop(store); + let _ = std::fs::remove_file(&path); + } + + /// The claim-key gate must not touch ordinary spend redrives, which never + /// take a reservation: a different invitation's live hold is irrelevant to + /// them, and to each other. + #[test] + fn the_claim_key_gate_leaves_unreserved_redrives_alone() { + let path = temp_tree_path("claim_key_gate_scope"); + let wallet_id: WalletId = [0x33; 32]; + let id = SubwalletId::new(wallet_id, 0); + let mut store = FileBackedShieldedStore::open_path(&path, 8).expect("store"); + let now = 1_000_000; + + let holder = AdmissionToken::new(); + let other = AdmissionToken::new(); + assert!(store + .begin_claim_admission(wallet_id, holder, now, 60_000) + .expect("holder lease")); + assert!(store + .begin_claim_admission(wallet_id, other, now, 60_000) + .expect("other lease")); + store + .reserve_one_time_claim_key( + SubwalletId::new(wallet_id, CLAIM_ACCOUNT), + [0xC4; 32], + holder, + now, + 60_000, + ) + .expect("reserve one invitation"); + + // A redrive under a DIFFERENT activity id is unaffected by that hold. + assert!( + store + .arm_redrive_under_claim(id, admission_record(0xD1), other, now, 60_000) + .expect("unrelated redrive"), + "an unreserved activity id must still arm normally" + ); + + drop(store); + let _ = std::fs::remove_file(&path); + } + + /// A pending-claim row armed by store A must come back to store B the + /// moment B acquires the released reservation — read from SQLITE inside + /// the reservation's own transaction, never from B's startup-hydrated + /// mirror (#4313 review finding r3767229122). + /// + /// This is the exact sequence the reviewer described. A arms a claim, + /// returns `ShieldedBroadcastUnconfirmed` (record kept, reservation + /// released) and B takes the freed key. Before the fix B's mirror — loaded + /// once when B opened, which was BEFORE A armed anything — reported no + /// record, so B built a SECOND transition with a different padded identity + /// id and replaced A's row. If A's transition had executed, its randomized + /// id was then unrecoverable forever. + #[test] + fn a_peer_stores_pending_claim_row_comes_back_with_the_reservation() { + let path = temp_tree_path("claim_row_handover"); + let wallet_id: WalletId = [0x34; 32]; + let claim_key = [0xC7; 32]; + let id = SubwalletId::new(wallet_id, CLAIM_ACCOUNT); + let mut a = FileBackedShieldedStore::open_path(&path, 8).expect("store a"); + // B opens BEFORE A arms anything: its mirror is hydrated now and never + // again, which is precisely the staleness this test exists to defeat. + let mut b = FileBackedShieldedStore::open_path(&path, 8).expect("store b"); + let now = 1_000_000; + + // ---- A: lease, reserve, arm, release ---- + let a_token = AdmissionToken::new(); + assert!(a + .begin_claim_admission(wallet_id, a_token, now, 60_000) + .expect("a lease")); + let a_out = a + .reserve_one_time_claim_key(id, claim_key, a_token, now, 60_000) + .expect("a reserve"); + assert_eq!(a_out.reservation, ClaimKeyReservation::Acquired); + assert!( + a_out.pending.is_none(), + "a fresh invitation has no record to resume" + ); + + let mut record = admission_record(0xC7); + record.activity_id = claim_key; + record.st_bytes = vec![0xA7; 128]; + record.identity_index = Some(9); + assert!(a + .arm_redrive_under_claim(id, record.clone(), a_token, now, 60_000) + .expect("a arms")); + // The ShieldedBroadcastUnconfirmed shape: the RECORD is deliberately + // kept (its retry needs the declared id) while the lease and its + // reservation are released. + a.end_claim_admission(a_token).expect("a releases"); + + // ---- The precondition: B's mirror is blind to A's row ---- + assert!( + b.pending_redrives(id).expect("b mirror").is_empty(), + "precondition: B's startup-hydrated mirror cannot see A's row — if this \ + ever starts passing by itself the mirror changed, and the assertion \ + below is no longer testing what it claims" + ); + + // ---- B: acquires the freed key and MUST be handed A's row ---- + let b_token = AdmissionToken::new(); + assert!(b + .begin_claim_admission(wallet_id, b_token, now + 1, 60_000) + .expect("b lease")); + let b_out = b + .reserve_one_time_claim_key(id, claim_key, b_token, now + 1, 60_000) + .expect("b reserve"); + assert_eq!( + b_out.reservation, + ClaimKeyReservation::Acquired, + "A released, so the key is B's to take" + ); + let resumed = b_out + .pending + .expect("B must RESUME A's durable row, not find None and arm a fresh one"); + assert_eq!( + resumed.st_bytes, + vec![0xA7; 128], + "A's byte-exact transition — the only handle on its padded identity id — \ + must survive into B's resume" + ); + assert_eq!(resumed.identity_index, Some(9)); + assert_eq!(resumed.activity_id, claim_key); + assert_eq!(resumed.anchor, record.anchor); + assert_eq!(resumed.nullifiers, record.nullifiers); + + // The handover also folds the row into B's mirror, so the rest of B's + // claim (attempt bumps, finalize/clear) agrees with disk. + assert_eq!( + b.pending_redrives(id).expect("b mirror after").len(), + 1, + "the resumed row must be visible to B's own later reads" + ); + + drop((a, b)); + let _ = std::fs::remove_file(&path); + } + + /// The recovery connection runs at `synchronous=FULL`; the commitment + /// tree's stays at `NORMAL` (#4313 review finding file_store.rs:107). + /// + /// The asymmetry is the point. A claim record's `st_bytes` carry a + /// randomized padded identity id that exists nowhere else, and it is + /// broadcast immediately after the commit — so a commit that returns + /// before the WAL is on disk can lose an identity permanently. Every row + /// in the tree, by contrast, is chain-authenticated and rebuildable by + /// re-running sync, and fsync'ing per `append_commitment` is what made a + /// 1M-leaf build take minutes instead of seconds. + #[test] + fn the_recovery_connection_is_fsync_durable_and_the_tree_connection_is_not() { + let path = temp_tree_path("pending_conn_sync_level"); + let store = FileBackedShieldedStore::open_path(&path, 8).expect("store"); + + // 2 == FULL in SQLite's `synchronous` encoding (0 OFF, 1 NORMAL, + // 2 FULL, 3 EXTRA). + let recovery: i32 = store + .pending_conn + .lock() + .expect("pending_conn mutex") + .pragma_query_value(None, "synchronous", |row| row.get(0)) + .expect("read recovery synchronous"); + assert_eq!( + recovery, 2, + "the claim-recovery connection must be synchronous=FULL: its row is \ + unreconstructable once the transition it describes is on the wire" + ); + + let tree = FileBackedShieldedStore::open_tuned_connection(&path).expect("tree conn"); + let tree_level: i32 = tree + .pragma_query_value(None, "synchronous", |row| row.get(0)) + .expect("read tree synchronous"); + assert_eq!( + tree_level, 1, + "the commitment-tree connection must stay NORMAL — paying an fsync per \ + appended cmx is the cost this split exists to avoid" + ); + + drop(tree); + drop(store); + let _ = std::fs::remove_file(&path); + } + + /// Two openers racing the `identity_index` migration must both succeed + /// (#4313 review finding file_store.rs:206). + /// + /// Probe-then-ALTER used to be two steps, so two processes opening one file + /// could both read "absent", and the loser's `open_path` failed outright + /// with `duplicate column name`. Both guards are pinned here: the + /// sequential double-open (which the `BEGIN IMMEDIATE` orders), and the + /// classification of the duplicate-column rejection as benign. + #[test] + fn the_identity_index_migration_tolerates_a_racing_opener() { + let path = temp_tree_path("identity_index_migration_race"); + + // A pre-#4313 database: the old table shape, with no identity_index. + { + let conn = rusqlite::Connection::open(&path).expect("legacy conn"); + conn.execute( + "CREATE TABLE shielded_pending_spends ( + wallet_id BLOB NOT NULL, + account_index INTEGER NOT NULL, + activity_id BLOB NOT NULL, + anchor BLOB NOT NULL, + nullifiers BLOB NOT NULL, + st_bytes BLOB NOT NULL, + attempts INTEGER NOT NULL DEFAULT 0, + PRIMARY KEY (wallet_id, account_index, activity_id) + )", + [], + ) + .expect("legacy table"); + } + + let first = FileBackedShieldedStore::open_path(&path, 8).expect("first open migrates"); + let second = FileBackedShieldedStore::open_path(&path, 8) + .expect("a second open must not fail on the column the first one added"); + drop((first, second)); + + // The tolerated error is genuinely recognised — the belt to the + // BEGIN IMMEDIATE braces. Matched on SQLite's real message rather than + // a hand-written string. + let conn = rusqlite::Connection::open(&path).expect("probe conn"); + let err = conn + .execute( + "ALTER TABLE shielded_pending_spends ADD COLUMN identity_index INTEGER", + [], + ) + .expect_err("the column exists by now, so this must be rejected"); + assert!( + FileBackedShieldedStore::is_duplicate_column(&err), + "the duplicate-column rejection must be classified benign, got: {err}" + ); + drop(conn); + 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..ed0804000ec 100644 --- a/packages/rs-platform-wallet/src/wallet/shielded/keys.rs +++ b/packages/rs-platform-wallet/src/wallet/shielded/keys.rs @@ -23,6 +23,108 @@ 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 has two halves. The first is the +/// [`ScrubbableSecret`] bound: the guard is instantiable ONLY over the two +/// audited Orchard key types, each of which is a fixed-size plain-old-data +/// representation with no owned indirections and no drop glue, so +/// overwriting its bytes in place cannot double-free, leave a dangling +/// pointer, or skip a destructor that had to run (#4313 review finding +/// keys.rs:64 — a blanket `impl` made the guard silently applicable to +/// any type, including ones for which byte-scrubbing is unsound). The +/// second is the `needs_drop` gate below, kept as defence in depth against +/// an orchard upgrade that adds `Drop` to one of them: the scrub is skipped +/// rather than made unsound, and `orchard_secret_types_have_no_drop_glue` +/// turns that silent no-scrub into a test failure. +/// +/// (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); + +/// Marker for the secret types [`ScrubOnDrop`] is audited to byte-scrub. +/// +/// Implemented for exactly two types — orchard 0.14's [`SpendingKey`] and +/// [`SpendAuthorizingKey`] — and deliberately NOT blanket-implemented. It is +/// the bound that keeps the guard's `write_volatile` loop sound: an +/// implementor must be a fixed-size value whose entire representation is +/// plain data (no heap pointers, no file descriptors, no `Drop`), so zeroing +/// it in place destroys the secret and nothing else. +/// +/// Adding an impl is therefore an explicit audit step, not an accident of +/// generic inference. The trait is crate-private, so no downstream crate can +/// widen it at all. +pub(crate) trait ScrubbableSecret {} + +// The complete audited set. `SpendingKey` is a `Copy` 32-byte array wrapper; +// `SpendAuthorizingKey` is a `Copy` scalar wrapper. Neither has a `Zeroize` +// impl nor a scrubbing `Drop`, which is why the guard exists at all. +impl ScrubbableSecret for SpendingKey {} +impl ScrubbableSecret for SpendAuthorizingKey {} + +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::()); + } + + /// The guard is instantiable over the audited types — and, because + /// [`ScrubbableSecret`] has no blanket impl, over nothing else. A type + /// with owned indirections (`Vec`, say) fails to compile here rather + /// than being byte-scrubbed into a leak or a double free + /// (#4313 review finding keys.rs:64). + #[test] + fn only_audited_secret_types_are_scrubbable() { + fn assert_scrubbable() {} + assert_scrubbable::(); + assert_scrubbable::(); + } +} + /// ZIP-32 derived Orchard key hierarchy. /// /// Contains the key material needed for shielded sync and address @@ -87,22 +189,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 +321,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 +620,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..d7dc1c559f6 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,791 +1576,2222 @@ where } } -/// Whether a failed identity-create should release the notes reserved for it. +// ------------------------------------------------------------------------- +// 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). /// -/// `false` ONLY for [`PlatformWalletError::ShieldedBroadcastUnconfirmed`]: the broadcast was -/// accepted and the transition may have executed, so the reservation must be retained. Releasing it -/// now would invite double-spend attempts against notes that may already be consumed on chain — the -/// very hazard that variant exists to prevent. `pending_nullifiers` is in-memory only (see -/// `SubwalletState`, "never persisted; the next sync after a crash reconciles") and `mark_spent` -/// during nullifier sync clears matching reservations, so if the transition actually executed the -/// next sync promotes these notes to spent; if it truly never landed, an app restart drops the -/// in-memory reservation and frees them. +/// 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. /// -/// Everything else is a definitive pre-execution / build / rejection failure: the spend never -/// happened, so the reservation must be released. -fn error_releases_note_reservation(e: &PlatformWalletError) -> bool { - !matches!(e, PlatformWalletError::ShieldedBroadcastUnconfirmed { .. }) -} +/// 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, + identity_index: u32, + 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}; -/// Number of times [`identity_create_from_shielded_pool`] re-fetches the new identity by its -/// derived id after a post-broadcast result-confirmation failure, before declaring the broadcast -/// unconfirmed. -const IDENTITY_CREATE_FETCH_RETRIES: usize = 4; + if public_keys.is_empty() { + return Err(PlatformWalletError::ShieldedBuildError( + "identity-create-from-one-time-key requires at least one public key".to_string(), + )); + } -/// Fixed backoff between identity fetch attempts. Four attempts ~3 s apart (~9 s of fetch window -/// total) is enough to ride out routine DAPI indexing / replica lag for a freshly-included identity -/// without wedging the caller's UI for minutes. -const IDENTITY_CREATE_FETCH_RETRY_DELAY: std::time::Duration = std::time::Duration::from_secs(3); + // 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); -/// Fetch an identity by id with a few fixed-interval retries. -/// -/// Used only on the ambiguous post-broadcast path: the result-proof fetch failed, so we don't know -/// whether the transition executed. The identity id is derived deterministically from the spent -/// notes' nullifiers and committed in the transition sighash, so a successful fetch is positive -/// proof the transition landed. Returns `Some(identity)` on the first hit, or `None` if every -/// attempt comes back empty or errors (transport hiccup, not-yet-indexed, …) — the caller then -/// surfaces `ShieldedBroadcastUnconfirmed` rather than a hard failure. -async fn fetch_identity_with_retries( - sdk: &Arc, - identity_id: Identifier, -) -> Option { - use dash_sdk::platform::Fetch; + let num_keys = public_keys.len(); - for attempt in 0..IDENTITY_CREATE_FETCH_RETRIES { - match Identity::fetch(sdk, identity_id).await { - Ok(Some(identity)) => return Some(identity), - Ok(None) => { - trace!( - %identity_id, - attempt, - "IdentityCreateFromShieldedPool confirmation fetch: not found yet" - ); - } - Err(e) => { - trace!( - %identity_id, - attempt, - error = %e, - "IdentityCreateFromShieldedPool confirmation fetch errored; will retry" - ); - } - } - // Skip the trailing sleep after the final attempt — nothing follows it. - if attempt + 1 < IDENTITY_CREATE_FETCH_RETRIES { - tokio::time::sleep(IDENTITY_CREATE_FETCH_RETRY_DELAY).await; + // 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::generate()?; + 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(), + }); + } + + // ---- Durable per-INVITATION reservation (#4313 review finding cr-9d0e1a44) ---- + // + // The lease above is per-WALLET: it orders this claim against a purge and + // nothing else, so it admits BOTH claims of the same invitation. The + // per-FVK mutex above serializes same-key claims that share THIS + // coordinator; a second coordinator, or a second process, opens its own + // SQLite connections to the same file and shares no lock at all. Both then + // found no pending record, built transitions with DIFFERENT padded identity + // ids, and the loser's `arm_redrive_under_claim` — an INSERT OR REPLACE — + // silently overwrote the winner's byte-exact recovery row while the + // winner's transition was already on the wire. + // + // So the invitation's record key is reserved durably, by an insert that + // cannot overwrite a live row, and the DURABLE row is read back to decide + // who owns it. The in-process guard stays as the fast path — it parks a + // same-coordinator second caller before it ever reaches the store — and + // this is the backstop for the two cases that guard cannot see. + // + // The SAME atomic step also returns the durable pending-claim record, if + // one already exists (#4313 review finding r3767229122). It has to. The + // record lookup used to run separately, through `pending_redrives`, which + // in the file-backed store reads an in-memory mirror hydrated once at + // store OPEN — so a record a PEER store armed after that open was + // invisible. This claim would then see "no record", build a SECOND + // transition with a different padded identity id, and replace the peer's + // only recovery row while the peer's transition was already on the wire, + // stranding the identity that transition creates. Reading the row inside + // the reservation's transaction closes the gap by construction: whoever + // settles the reservation settles what already exists under it, together. + let claim_records_id = SubwalletId::new(wallet_id, ONE_TIME_CLAIM_RECORDS_ACCOUNT); + let reservation = store + .write() + .await + .reserve_one_time_claim_key( + claim_records_id, + claim_record_key, + admission, + super::store::admission_now_ms(), + super::store::CLAIM_LEASE_MS, + ) + .map_err(|e| { + // Fail closed. Proceeding without knowing who owns the key is + // exactly the clobber this reservation exists to prevent. + PlatformWalletError::Persistence(format!( + "one-time claim: could not reserve the invitation's claim-record key: {e}" + )) + }); + let reservation = match reservation { + Ok(r) => r, + Err(e) => { + release_claim_admission(store, admission).await; + return Err(e); } + }; + if let super::store::ClaimKeyReservation::Held { holder, expires_at } = reservation.reservation + { + debug!( + holder = %hex::encode(holder.0), + expires_at, + resumable_record = reservation.pending.is_some(), + "one-time claim: another claimant holds this invitation's claim-record key" + ); } - None -} -// ------------------------------------------------------------------------- -// Internal helpers (free fns) -// ------------------------------------------------------------------------- + // The heartbeat wraps the COMPLETE admitted claim body — pending-record + // lookup, resume, transient scan, build, arm, broadcast and confirmation + // wait alike (#4313 review finding 8de8d05a). See + // `under_renewed_claim_lease` for why it cannot sit any deeper. + let claim_result = under_renewed_claim_lease( + store, + admission, + one_time_claim_admitted( + sdk, + store, + scan_checkpoints, + claim_records_id, + claim_record_key, + admission, + reservation.is_acquired(), + reservation.pending, + fvk, + ivk, + ask, + funding_birth_height, + change_address, + identity_index, + public_keys, + num_keys, + master_key_hash, + submitted_public_keys, + denomination, + send_to_address_on_creation_failure, + identity_signer, + prover, + ), + ) + .await; -/// Convert `keys`'s default `PaymentAddress` to an `OrchardAddress`. -fn default_orchard_address( - keys: &AccountViewingKeys, -) -> Result { - payment_address_to_orchard(&keys.default_address) + release_claim_admission(store, admission).await; + claim_result } -/// Checkpoint depths probed for a Platform-recorded anchor. Kept equal to the -/// commitment tree's `max_checkpoints` retention (the store is opened with -/// `100` — see `PlatformWalletManager`) so the probe reaches every checkpoint -/// the tree still holds and no further: deeper checkpoints are pruned, so -/// probing past this bound is wasted work. Coupled by convention — if that -/// retention changes, update this in lockstep. -const MAX_ANCHOR_PROBE_DEPTH: usize = 100; +/// Drive `body` to completion while re-stamping the claim lease `admission` on +/// a [`CLAIM_LEASE_RENEW_INTERVAL`](super::store::CLAIM_LEASE_RENEW_INTERVAL) +/// timer, so the protected window is tied to how long the claim actually runs +/// rather than to a fixed guess from whenever the lease was last stamped. +/// +/// # Why it wraps the whole admitted body +/// +/// The heartbeat originally wrapped only the fresh-build path's broadcast. That +/// left the RESUME path — pending-record lookup, nullifier queries, repeated +/// identity recovery, re-broadcast of the stored transition and an unbounded +/// confirmation wait — running under the INITIAL lease alone, because it +/// returns before the fresh-build path is ever reached (#4313 review finding +/// 8de8d05a). Resume is if anything the slower of the two: it is the path a +/// claim takes precisely because the previous attempt could not resolve +/// quickly. +/// +/// A lease that lapses mid-claim is reaped, at which point a concurrent purge +/// counts zero live claims and deletes the very record the in-flight claim +/// needs to recover — so the window has to cover every phase between taking the +/// lease and releasing it. Hoisting it to the single call site around +/// [`one_time_claim_admitted`] covers both paths by construction, and there is +/// no deeper place that could: the two paths only converge here. +/// +/// The claim-key reservation taken under the same token rides along, because +/// [`ShieldedStore::renew_claim_admission`] re-stamps it in the same step. +/// +/// Cancellation-safe: dropping the returned future drops `body` with it, and +/// the lease is then reclaimed by expiry. +async fn under_renewed_claim_lease( + store: &Arc>, + admission: super::store::AdmissionToken, + body: F, +) -> T +where + S: ShieldedStore, + F: std::future::Future, +{ + tokio::pin!(body); + loop { + tokio::select! { + // Bias the body so a renewal tick can never starve the outcome we + // are actually waiting for. + biased; + outcome = &mut body => break outcome, + _ = tokio::time::sleep(super::store::CLAIM_LEASE_RENEW_INTERVAL) => { + let renewed = store + .write() + .await + .renew_claim_admission( + admission, + super::store::admission_now_ms(), + super::store::CLAIM_LEASE_MS, + ); + match renewed { + Ok(true) => {} + // A transition may already be on the wire; aborting cannot + // un-send it and would only lose the outcome + // classification. Carry on, loudly. + Ok(false) => warn!( + "one-time claim lease lapsed or was displaced mid-claim; a \ + concurrent wallet removal may purge this claim's recovery record" + ), + Err(e) => warn!( + error = %e, + "could not renew the one-time claim lease mid-claim" + ), + } + } + } + } +} -/// Extract `SpendableNote` structs with Merkle witnesses and an anchor -/// Platform has recorded. +/// Release a claim's store admission — its lifecycle lease AND the claim-key +/// reservation taken under the same token. Best-effort: both carry an expiry, +/// so a failure here only delays the next claimant of this invitation. +async fn release_claim_admission( + store: &Arc>, + admission: super::store::AdmissionToken, +) { + 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" + ); + } +} + +/// The one-time-key claim body, running under a held store admission lease. /// -/// A shielded spend's proof is accepted only if its anchor is a -/// commitment-tree root Platform recorded (`validate_anchor_exists`). -/// Platform records one anchor per block, but an index-chunk sync routinely -/// leaves the wallet's tree mid-block, so the depth-0 (current) root is -/// frequently a value Platform never recorded — building against it -/// unconditionally is what made such spends fail and never land. +/// 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. /// -/// This fetches Platform's recorded anchor set (outside the store lock), then -/// selects the shallowest checkpoint depth whose root is in that set — depth 0 -/// being the fully-synced fast path — witnessing every note at that same depth -/// so the anchor and the authentication paths agree (the builder derives the -/// anchor from the witnesses via `MerklePath::root`, so a per-note disagreement -/// would surface downstream as `AnchorMismatch`). When no probed depth has a -/// recorded root it returns the retryable -/// [`PlatformWalletError::ShieldedNoRecordedAnchor`] rather than broadcasting a -/// spend Platform is guaranteed to reject. -async fn extract_spends_and_anchor( +/// `owns_claim_key` is whether this caller won the durable per-invitation +/// reservation. A caller that did NOT is forbidden to build, broadcast or arm +/// anything: it may only RESUME the record the owner left, or refuse. See the +/// branch below. +/// +/// `pending_record` is that record, read from DURABLE state in the same atomic +/// step that settled the reservation. It is passed in rather than looked up +/// here on purpose: the file-backed store's `pending_redrives` reads a mirror +/// hydrated at store open, which cannot see a row a peer store armed later, and +/// a claim that trusted it would build a second transition over a live one +/// (#4313 review finding r3767229122). +#[allow(clippy::too_many_arguments)] +async fn one_time_claim_admitted( sdk: &Arc, store: &Arc>, - notes: &[ShieldedNote], -) -> Result<(Vec, Anchor), PlatformWalletError> { - // Nothing selected — fail before the network round-trip. - if notes.is_empty() { - return Err(PlatformWalletError::ShieldedBuildError( - "no spendable notes selected — anchor undefined".to_string(), - )); + scan_checkpoints: &super::sync::ForeignScanCheckpointCache, + claim_records_id: SubwalletId, + claim_record_key: [u8; 32], + admission: super::store::AdmissionToken, + owns_claim_key: bool, + pending_record: Option, + fvk: grovedb_commitment_tree::FullViewingKey, + ivk: grovedb_commitment_tree::IncomingViewingKey, + ask: super::keys::ScrubOnDrop, + funding_birth_height: Option, + change_address: &OrchardAddress, + identity_index: u32, + 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)" + ); } - // Fetch the recorded anchor set OUTSIDE the store lock so the network - // round-trip doesn't serialize with other store users, and so the lock is - // held only for the mutually-consistent depth/witness probe below. - let dash_sdk::query_types::ShieldedAnchors(recorded_anchors) = - dash_sdk::query_types::ShieldedAnchors::fetch_current(sdk).await?; - let recorded: HashSet<[u8; 32]> = recorded_anchors.into_iter().collect(); + // ---- Durable pending-claim resume (#4204 review finding c0781f9d387f) ---- + // + // A claim that broadcast but never confirmed (process death, JNI + // cancellation, lost result wait) left a persisted record carrying the + // byte-exact transition and its declared identity id. Consult it BEFORE + // the transient scan: the record's id survives even for a padded + // single-note bundle (whose id embeds a random dummy nullifier and is + // otherwise unrecoverable), so a retry can reconcile or re-drive the + // byte-identical transition instead of rebuilding one whose preflight + // would misread the spent notes as a foreign claim + // (`ShieldedInviteAlreadyClaimed`). + // + // The record arrives from the caller, read out of DURABLE state in the same + // transaction that settled the claim-key reservation — never from the + // store's startup-hydrated mirror (#4313 review finding r3767229122). + if let Some(record) = pending_record { + match resume_one_time_claim( + sdk, + store, + claim_records_id, + &record, + master_key_hash, + submitted_public_keys.clone(), + denomination, + identity_index, + ) + .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 => {} + } + } - // Hold a single read lock across the whole probe so the checkpoint depths - // and the per-note witnesses stay mutually consistent: a concurrent sync - // checkpointing mid-probe would otherwise shift the depth indices out from - // under us. - let store = store.read().await; - select_recorded_spends(&*store, notes, &recorded) -} + // ---- Losing claimant: RESUME above, or REFUSE here ---- + // + // Everything past this point builds a fresh transition and arms a fresh + // record under `claim_record_key`. Only the holder of the durable + // per-invitation reservation may do that (#4313 review finding + // cr-9d0e1a44). A claimant that lost the reservation reaches here in + // exactly two states, and neither may proceed: + // + // * the owner has already armed its record — then the resume above ran and + // returned, so we are not here at all (or the record was unusable and the + // owner will re-arm it, which is still not ours to do); + // * the owner is admitted but has not armed yet — building here is + // precisely the race: two transitions with different padded identity ids, + // and whichever arms second overwrites the other's only recovery handle. + // + // So refuse, retryably. Nothing was scanned, built or broadcast; the note + // is untouched; a retry a moment later either finds the owner's record and + // resumes it, or finds the key free and proceeds as the owner. The storage + // layer refuses the same case independently — `arm_redrive_under_claim` + // will not write under a foreign reservation — so this branch is the clean + // error, not the safety property. + if !owns_claim_key { + return Err(PlatformWalletError::ShieldedLifecycleBusy { + reason: "another claimant currently holds this invitation's claim record; nothing \ + was scanned, built or broadcast — retry shortly, and the retry will either \ + resume that claim's outcome or take the invitation over if it lapsed" + .to_string(), + }); + } -/// Pick the shallowest checkpoint depth whose tree root is in `recorded`, -/// witnessing every note at that depth. Pure (no SDK, no async), so the depth -/// walk can be unit-tested against a real commitment tree. -/// -/// Depth 0 is the current tree state (the fully-synced fast path). Deeper -/// checkpoints are older and hold strictly fewer positions, so the probe stops -/// as soon as a selected note is no longer witnessable at a depth — no deeper -/// checkpoint could contain it. Returns -/// [`PlatformWalletError::ShieldedNoRecordedAnchor`] when no probed depth has a -/// recorded root (a clean, retryable outcome — nothing is broadcast). -fn select_recorded_spends( - store: &S, - notes: &[ShieldedNote], - recorded: &HashSet<[u8; 32]>, -) -> Result<(Vec, Anchor), PlatformWalletError> { - use grovedb_commitment_tree::ExtractedNoteCommitment; + // 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); + } - // Deserialize each note and decode its commitment ONCE — both are - // independent of the checkpoint depth, so hoisting them out of the probe - // keeps the depth walk cheap (each probed depth only re-witnesses). - let prepared: Vec<(u64, grovedb_commitment_tree::Note, ExtractedNoteCommitment)> = notes - .iter() - .map(|note| { - let orchard_note = deserialize_note(¬e.note_data).ok_or_else(|| { - PlatformWalletError::ShieldedBuildError(format!( - "Failed to deserialize note at position {}", - note.position - )) - })?; - let cmx = ExtractedNoteCommitment::from_bytes(¬e.cmx) - .into_option() - .ok_or_else(|| { - PlatformWalletError::ShieldedBuildError(format!( - "invalid stored cmx for note at position {}", - note.position - )) - })?; - Ok((note.position, orchard_note, cmx)) - }) - .collect::>()?; + // 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(); - // Build every selected note's `SpendableNote` plus the shared anchor at a - // single checkpoint `depth`. + 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`). // - // `strict` (depth 0 only): a missing/failed witness is a hard - // `ShieldedMerkleWitnessUnavailable` (the note is expected to be witnessable - // at the current tip). At depth > 0, `Ok(None)` means the note post-dates - // this older checkpoint, so the depth is unusable — return `Ok(None)` and - // let the caller stop probing deeper; a genuine store `Err` (poisoned mutex, - // IO, tree corruption) is logged — the probe would otherwise discard the - // message — and likewise treated as an unusable depth rather than aborting, - // so a transient read can't strand a spend a shallower depth already - // covered. An anchor disagreement across notes is always a hard error (the - // spend builder would reject it downstream). - let build_at_depth = |depth: usize, - strict: bool| - -> Result, Anchor)>, PlatformWalletError> { - let mut spends = Vec::with_capacity(prepared.len()); - let mut anchor: Option = None; - for (position, note, cmx) in &prepared { - let merkle_path = match store.witness_at_depth(*position, depth) { - Ok(Some(path)) => path, - Ok(None) if strict => { - return Err(PlatformWalletError::ShieldedMerkleWitnessUnavailable(format!( - "no witness available for note at position {position} (not marked, or pruned past this position)" - ))); + // 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, + identity_index, + ) + .await?; + + // The renewal heartbeat that holds the lease open across this broadcast and + // its confirmation wait runs in the CALLER, around the whole admitted claim + // body — see `under_renewed_claim_lease`. It used to wrap only this + // broadcast, which left the resume path (returning above) covered by the + // initial lease alone (#4313 review finding 8de8d05a). + 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; } - Err(e) if strict => { - return Err(PlatformWalletError::ShieldedMerkleWitnessUnavailable( - e.to_string(), - )); + NullifierSpentStatus::Unspent => { + return Err(PlatformWalletError::ShieldedBroadcastFailed(e.to_string())); } - // depth > 0: the note isn't witnessable at this older checkpoint - // (appended after it, or the depth doesn't exist). - Ok(None) => return Ok(None), - // depth > 0: a genuine store failure. Log it so the operator sees - // it (the anchor probe otherwise swallows the message), then treat - // the depth as unusable — never a mid-probe abort. - Err(e) => { - tracing::warn!( - position = *position, - depth, - error = %e, - "shielded anchor probe: witness_at_depth failed at depth > 0; skipping depth" + 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" ); - return Ok(None); + // 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)); } - }; - - // The anchor is derived from the witness path itself - // (`MerklePath::root(cmx)`); all selected notes must agree on it, or - // the store handed back witnesses from different checkpoints and the - // spend builder would reject the mismatch downstream. - let witness_anchor = merkle_path.root(*cmx); - match &anchor { - None => anchor = Some(witness_anchor), - Some(prev) if prev.to_bytes() != witness_anchor.to_bytes() => { - return Err(PlatformWalletError::ShieldedBuildError(format!( - "witness anchor mismatch across selected notes (position {position})" - ))); + None => { + return Err(PlatformWalletError::ShieldedBroadcastUnconfirmed { + identity_id, + reason: wait_err.to_string(), + }); } - _ => {} } - - spends.push(SpendableNote { - note: *note, - merkle_path, - }); } - - // `notes` is non-empty (the caller checked), so `anchor` is set. - let anchor = anchor.ok_or_else(|| { - PlatformWalletError::ShieldedBuildError( - "no spendable notes selected — anchor undefined".to_string(), - ) - })?; - Ok(Some((spends, anchor))) }; - // Fast path: a fully-synced wallet's depth-0 root is a recorded anchor. - let (spends, anchor) = match build_at_depth(0, true)? { - Some(pair) => pair, - // Unreachable — a strict build returns `Some` or errors — but stay - // fund-safe (a clean error, never a panic) if that invariant breaks. - None => { - return Err(PlatformWalletError::ShieldedMerkleWitnessUnavailable( - "depth-0 witness probe returned no witness for a selected note".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()))? } }; - if recorded.contains(&anchor.to_bytes()) { - return Ok((spends, anchor)); - } - // Otherwise walk older checkpoints newest→oldest for the shallowest - // recorded root. - for depth in 1..MAX_ANCHOR_PROBE_DEPTH { - match build_at_depth(depth, false)? { - Some((spends, anchor)) if recorded.contains(&anchor.to_bytes()) => { - return Ok((spends, anchor)); - } - // A root exists at this depth but Platform didn't record it — try an - // older checkpoint. - Some(_) => continue, - // A selected note isn't witnessable this deep; every deeper - // checkpoint is older still, so none can cover it either. - None => break, - } - } + info!( + denomination, + identity_id = %identity.id(), + "IdentityCreateFromOneTimeKey broadcast succeeded" + ); + Ok((identity.id(), identity)) +} - Err(PlatformWalletError::ShieldedNoRecordedAnchor( - "no recorded anchor covers the selected notes; wait for the next shielded sync".to_string(), - )) +/// 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() } -/// Mark the selected notes as spent for `id`. Also queues a -/// shielded changeset on the persister so the spent flag reaches -/// durable storage immediately rather than waiting for the next -/// note scan to rediscover the spend (scan-based spend detection). -/// Also drops any matching pending reservation so the -/// confirmed-spent state and the in-flight-spend state can't -/// disagree. -async fn mark_notes_spent( - store: &Arc>, - persister: Option<&WalletPersister>, - wallet_id: WalletId, - id: SubwalletId, - notes: &[ShieldedNote], -) -> Result<(), PlatformWalletError> { - let mut changeset = ShieldedChangeSet::default(); - { - let mut store = store.write().await; - for note in notes { - if store - .mark_spent(id, ¬e.nullifier) - .map_err(|e| PlatformWalletError::ShieldedStoreError(e.to_string()))? - { - changeset.record_nullifier_spent(id, note.nullifier); +/// 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 } - queue_shielded_changeset(persister, wallet_id, changeset); - Ok(()) } -/// Select unspent notes and reserve them against an in-flight -/// spend in one write-locked critical section. +/// Look up the pending-claim record for `key` through +/// [`ShieldedStore::pending_redrives`]. /// -/// Combining selection and reservation under a single write lock -/// is the only thing that prevents two overlapping spend calls -/// from picking the same notes: with separate read-then-write -/// phases, the second caller would observe the same -/// `unspent_notes()` between the first caller's read and write -/// and proceed to build a duplicate proof that's only rejected -/// ~30 s later at broadcast time. +/// **Test-only.** The production claim path no longer reads the record this +/// way: `pending_redrives` is served from the file-backed store's +/// startup-hydrated mirror, which cannot see a row a peer store armed after +/// our open, and a claim that trusted it would build a second transition over +/// a live one (#4313 review finding r3767229122). The real lookup now comes +/// back from [`ShieldedStore::reserve_one_time_claim_key`], read out of +/// durable state in the same transaction that settles the reservation. /// -/// The reservation is in-memory only — see -/// [`ShieldedStore::mark_pending`] for the crash-recovery note. -/// Callers must pair this with [`finalize_pending`] (on -/// broadcast success) or [`cancel_pending`] (on failure) so the -/// reservation is always released. -async fn reserve_unspent_notes( - sdk: &Arc, +/// It survives here because the arm/clear/finalize round-trip tests drive the +/// [`InMemoryShieldedStore`], whose map IS its durable state — so for THEM the +/// two reads are the same read. +/// +/// [`InMemoryShieldedStore`]: super::store::InMemoryShieldedStore +#[cfg(test)] +async fn find_one_time_claim_record( store: &Arc>, id: SubwalletId, - amount: u64, - outputs: usize, - fee_kind: ShieldedFeeKind, -) -> Result<(Vec, u64, u64), PlatformWalletError> { - let mut store = store.write().await; - let unspent = store - .get_unspent_notes(id) - .map_err(|e| PlatformWalletError::ShieldedStoreError(e.to_string()))?; - let (selected, total_input, exact_fee) = - select_notes_with_fee(&unspent, amount, outputs, fee_kind, sdk.version())?.into_owned(); - for note in &selected { - store - .mark_pending(id, ¬e.nullifier) - .map_err(|e| PlatformWalletError::ShieldedStoreError(e.to_string()))?; - } - Ok((selected, total_input, exact_fee)) + 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}" + )) + }) } -/// Exact-equality sibling of [`reserve_unspent_notes`] for -/// `IdentityCreateFromShieldedPool`: select + reserve notes covering exactly `denomination` -/// (the fee is metered FROM the denomination, not added to the target) in one write-locked -/// critical section, gating on `denomination > predicted_fee` via -/// [`select_notes_for_denomination`]. Returns the selected notes, total input value, and the -/// predicted fee. Callers must pair this with [`finalize_pending`] / [`cancel_pending`]. -async fn reserve_unspent_notes_for_denomination( - sdk: &Arc, +/// 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 (and the claim-key reservation riding the same token), so the +/// record is written into a freshly extended window rather than into whatever +/// remained of one a long scan had already spent. Keeping that window open past +/// this point is the heartbeat's job — see [`under_renewed_claim_lease`]. +/// +/// 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. +/// +/// `identity_index` is persisted with the record because it is the ONE part of +/// the claim's binding the transition cannot witness — a purely local DIP-9 +/// slot that appears nowhere in `st_bytes` (#4313 review finding 5d4d6efa). +/// Everything else a resume checks is re-derived from the stored bytes; see +/// [`resume_one_time_claim`]. +#[allow(clippy::too_many_arguments)] +async fn arm_one_time_claim_record( store: &Arc>, id: SubwalletId, - denomination: u64, - min_actions: usize, - num_keys: usize, -) -> Result<(Vec, u64, u64), PlatformWalletError> { - let mut store = store.write().await; - let unspent = store - .get_unspent_notes(id) - .map_err(|e| PlatformWalletError::ShieldedStoreError(e.to_string()))?; - let (selected, total_input, predicted_fee) = select_notes_for_denomination( - &unspent, - denomination, - min_actions, - num_keys, - sdk.version(), - )? - .into_owned(); - for note in &selected { - store - .mark_pending(id, ¬e.nullifier) - .map_err(|e| PlatformWalletError::ShieldedStoreError(e.to_string()))?; + key: [u8; 32], + anchor: [u8; 32], + nullifiers: &[[u8; 32]], + st: &StateTransition, + admission: super::store::AdmissionToken, + identity_index: u32, +) -> 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, + identity_index: Some(identity_index), + }, + 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((selected, total_input, predicted_fee)) + Ok(()) } -/// Promote a successful broadcast: mark the notes spent (which -/// also clears any matching pending reservation, see -/// [`SubwalletState::mark_spent`]) and queue the changeset for -/// the host persister. -async fn finalize_pending( +/// 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>, - persister: Option<&WalletPersister>, - wallet_id: WalletId, id: SubwalletId, - notes: &[ShieldedNote], -) -> Result<(), PlatformWalletError> { - mark_notes_spent(store, persister, wallet_id, id, notes).await + 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" + ); + } } -/// Roll back a reservation when the broadcast / wait fails. -/// Best-effort and doesn't surface its own errors — the caller -/// is already returning the broadcast error. -async fn cancel_pending( +/// 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, - notes: &[ShieldedNote], + key: [u8; 32], + result: &Result<(Identifier, Identity), PlatformWalletError>, ) { - let mut store = store.write().await; - for note in notes { - if let Err(e) = store.clear_pending(id, ¬e.nullifier) { - tracing::warn!( - error = %e, - "cancel_pending: clear_pending failed; the next note scan will reconcile" - ); - } + if matches!( + result, + Ok(_) | Err(PlatformWalletError::ShieldedInviteAlreadyClaimed { .. }) + ) { + clear_one_time_claim_record(store, id, key).await; } } -/// Record the recorded `anchor` the spend was built against and the -/// linked activity entry on every selected note's reservation, so a -/// spend that ends broadcast-accepted-but-unconfirmed can be released -/// on a later sync once that anchor is pruned from Platform's recorded -/// set (see `NetworkShieldedCoordinator::release_stranded_spends`). +/// Describe the first way a resume attempt's arguments disagree with the +/// transition the earlier attempt submitted, or `None` when they agree. /// -/// No-op when no activity entry was recorded (an output-less bundle — -/// unreachable for our own builders, which always carry a visible -/// output). Best-effort: a store write failure only means the -/// reservation won't self-release on a pruned anchor (it still frees -/// on the next restart), so it must never abort a spend about to -/// broadcast. A success (`finalize_pending`) or a definite failure -/// (`cancel_pending`) removes the entry, so only an ambiguous -/// unconfirmed outcome leaves it carrying the anchor. -async fn arm_pending_release( - store: &Arc>, - id: SubwalletId, - anchor: [u8; 32], - pending_entry: &Option, - notes: &[ShieldedNote], -) { - let Some(entry) = pending_entry else { - return; - }; - let mut store = store.write().await; - for note in notes { - if let Err(e) = store.set_pending_spend(id, ¬e.nullifier, anchor, entry.id) { - warn!( - error = %e, - "set_pending_spend failed; this reservation won't self-release on a pruned \ - anchor (it still frees on the next restart)" - ); +/// 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. +#[allow(clippy::too_many_arguments)] +fn one_time_claim_binding_mismatch( + stored_public_keys: &BTreeMap, + stored_master_key_hash: Option<[u8; 20]>, + stored_denomination: u64, + stored_identity_index: Option, + submitted_public_keys: &BTreeMap, + submitted_master_key_hash: Option<[u8; 20]>, + submitted_denomination: u64, + submitted_identity_index: u32, +) -> Option { + // The one field compared against the RECORD rather than against the + // transition, because the transition cannot witness it: the DIP-9 slot is a + // purely local placement (#4313 review finding 5d4d6efa). Checked first — + // it is the cheapest comparison, and a slot mismatch is the case whose + // consequence is least visible: `IdentityManager::add_identity` rejects a + // duplicate identity id but inserts into an OCCUPIED slot without + // complaint, so a retry that presents the original keys at a different + // index would silently displace whatever the wallet tracked there. + // + // `None` means the record predates the column; there is nothing to compare, + // and that record keeps exactly the transitive binding it was written + // under. + if let Some(stored) = stored_identity_index { + if stored != submitted_identity_index { + return Some(format!( + "identity index: the earlier attempt was registering at local slot {stored}, \ + retry asked for slot {submitted_identity_index}" + )); } } + 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 } -/// Maximum sync-time re-broadcast attempts for a -/// broadcast-accepted-but-unconfirmed spend before the re-drive stops -/// and the anchor-prune release backstop owns the reservation. -pub(super) const MAX_REDRIVE_ATTEMPTS: u32 = 3; +/// 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, +} -/// Broadcast a built shielded spend and, on the AMBIGUOUS outcome only -/// (`ShieldedSpendUnconfirmed` — accepted broadcast, failed result -/// wait), persist a [`PendingRedrive`] so the sync-time re-drive can -/// resolve the ambiguity actively: the next scan detects a landing via -/// the nullifiers; otherwise the byte-identical transition is -/// re-broadcast up to [`MAX_REDRIVE_ATTEMPTS`] times (fund-safe — -/// identical nullifiers cannot double-spend); only if every attempt -/// stays silent does the anchor-prune release backstop take over. +/// 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 wherever the +/// transition can witness it (`public_keys` are exactly what the binding +/// signature committed to; `denomination` is the value that leaves the pool): +/// a derived binding 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 binds +/// +/// Derived from `record.st_bytes`: the submitted key set (by id and content), +/// the MASTER authentication key hash used for idempotent recovery, and the +/// denomination. +/// +/// Read from the record itself: `identity_index`, the local DIP-9 slot the +/// returned identity is registered at (#4313 review finding 5d4d6efa). It is +/// the one part of the binding the transition CANNOT witness — a purely local +/// placement that appears nowhere in `st_bytes` — so it is persisted with the +/// claim precisely because there is nothing to derive it from. +/// +/// It used to be left to a *transitive* argument: the identity's keys are +/// derived from the wallet seed at that slot, so a retry naming a different +/// slot ought to present different keys and be refused by the key check. That +/// argument leaves the caller free to pair slot `i` with keys derived at slot +/// `j`, and the consequence is not symmetric with a first attempt's: a retry +/// that presents the ORIGINAL keys with a different index reaches +/// `IdentityManager::add_identity`, which rejects a duplicate identity id but +/// inserts into an occupied slot without complaint — silently displacing +/// whatever identity the wallet already tracked there. Persisting the slot and +/// comparing it closes that directly. +/// +/// A record written before the column existed carries `None`; there is nothing +/// to compare it against, so the check is skipped and that record keeps exactly +/// the transitive binding it was written under. #[allow(clippy::too_many_arguments)] -async fn broadcast_shielded_spend_with_redrive( +async fn resume_one_time_claim( sdk: &Arc, store: &Arc>, - id: SubwalletId, - pending_entry: &Option, - anchor: [u8; 32], - notes: &[ShieldedNote], - state_transition: &StateTransition, - operation: &'static str, -) -> Result<(), PlatformWalletError> { - let result = broadcast_shielded_spend(sdk, state_transition, operation).await; - if matches!( - &result, - Err(PlatformWalletError::ShieldedSpendUnconfirmed { .. }) - ) { - arm_redrive_record( - store, - id, - pending_entry, - anchor, - notes, - state_transition, - operation, - ) - .await; - } - result -} - -/// Persist the re-drivable record for an ambiguous spend. Best-effort: -/// a failure here only demotes the resolution path to the anchor-prune -/// backstop (plus restart-loss of the reservation), never fails the -/// spend call itself — the ambiguity already happened. -async fn arm_redrive_record( - store: &Arc>, - id: SubwalletId, - pending_entry: &Option, - anchor: [u8; 32], - notes: &[ShieldedNote], - state_transition: &StateTransition, - operation: &'static str, -) { - use dpp::serialization::PlatformSerializable; + claim_records_id: SubwalletId, + record: &PendingRedrive, + master_key_hash: Option<[u8; 20]>, + submitted_public_keys: BTreeMap, + denomination: u64, + identity_index: u32, +) -> OneTimeClaimResume { + use dpp::serialization::PlatformDeserializable; + use dpp::state_transition::state_transitions::shielded::identity_create_from_shielded_pool_transition::accessors::IdentityCreateFromShieldedPoolTransitionAccessorsV0; - let Some(entry) = pending_entry else { - return; - }; - let st_bytes = match state_transition.serialize_to_bytes() { - Ok(b) => b, + let st = match StateTransition::deserialize_from_bytes(&record.st_bytes) { + Ok(st) => st, Err(e) => { warn!( - operation, error = %e, - "failed to serialize the unconfirmed transition; re-drive disabled for this \ - spend (prune backstop still applies)" + "one-time claim resume: stored transition failed to deserialize; dropping the \ + record and rebuilding" ); - return; + clear_one_time_claim_record(store, claim_records_id, record.activity_id).await; + return OneTimeClaimResume::RecordUnusable; } }; - let redrive = PendingRedrive { - activity_id: entry.id, - anchor, - nullifiers: notes.iter().map(|n| n.nullifier).collect(), - st_bytes, - attempts: 0, + // 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; + } }; - if let Err(e) = store.write().await.arm_redrive(id, redrive) { + 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, + record.identity_index, + &submitted_public_keys, + master_key_hash, + denomination, + identity_index, + ) { warn!( - operation, - error = %e, - "failed to persist the redrive record; re-drive disabled for this spend (prune \ - backstop still applies)" + 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 }, + )); } -} -/// Whether an SDK error is Platform's `NullifierAlreadySpentError` — -/// on a RE-broadcast of our own byte-identical transition this means -/// the ORIGINAL broadcast executed: the nullifiers are consumed by the -/// very spend being re-driven, so it is a success signal (the next scan -/// confirms the notes spent), never a failure. -fn is_nullifier_already_spent(e: &dash_sdk::Error) -> bool { - use dpp::consensus::state::state_error::StateError; - use dpp::consensus::ConsensusError; + // 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; - let consensus: Option<&ConsensusError> = match e { - dash_sdk::Error::Protocol(dpp::ProtocolError::ConsensusError(c)) => Some(c), - dash_sdk::Error::StateTransitionBroadcastError(b) => b.cause.as_ref(), - _ => None, - }; - matches!( - consensus, - Some(ConsensusError::StateError( - StateError::NullifierAlreadySpentError(_) - )) - ) -} + info!( + declared_id = %declared_id, + nullifiers = record.nullifiers.len(), + "one-time claim: resuming from the persisted pending-claim record" + ); -/// Pure outcome classification for one re-broadcast attempt. Extracted -/// from the redrive loop so the arm ORDER — `AlreadyExecuted` must win -/// over the generic consensus-rejection check, since -/// `NullifierAlreadySpent` is itself a consensus error — is pinned by -/// unit tests without needing a broadcast-mockable network seam. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -enum RedriveBroadcastOutcome { - /// Relay accepted the re-broadcast; the next scan detects a landing. - Accepted, - /// `NullifierAlreadySpent`: the ORIGINAL broadcast executed — a - /// success signal, never a failure. - AlreadyExecuted, - /// Any other consensus verdict: the transition can never execute. - DefinitiveRejection, - /// Transport noise / `AlreadyExists` / anything non-definitive. - Inconclusive, -} - -fn classify_redrive_broadcast(result: &Result<(), dash_sdk::Error>) -> RedriveBroadcastOutcome { - match result { - Ok(()) => RedriveBroadcastOutcome::Accepted, - Err(e) if is_nullifier_already_spent(e) => RedriveBroadcastOutcome::AlreadyExecuted, - Err(e) if carries_consensus_rejection(e) => RedriveBroadcastOutcome::DefinitiveRejection, - Err(_) => RedriveBroadcastOutcome::Inconclusive, + 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, + ); } -} -/// Bump a redrive attempt counter, logging (rather than discarding) a -/// persistence failure. On `Err` the durable counter did not advance and -/// the file store's persist-first ordering leaves memory untouched, so -/// the same attempt slot is retried on the next pass — the log line is -/// what makes that visible. -async fn bump_redrive_attempts_logged( - store: &Arc>, - id: SubwalletId, - activity_id: &[u8; 32], -) -> u32 { - match store.write().await.bump_redrive_attempts(id, activity_id) { - Ok(attempts) => attempts, - Err(e) => { + // 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!( - error = %e, - "redrive: failed to persist the attempt counter; the attempt will be \ - retried on the next pass" + declared_id = %declared_id, + reason, + "one-time claim resume: stored transition is definitively rejected and its notes \ + are unspent; dropping the record and rebuilding" ); - 0 + clear_one_time_claim_record(store, claim_records_id, record.activity_id).await; + return OneTimeClaimResume::RecordUnusable; } } + + OneTimeClaimResume::Resolved(result) } -/// Sync-time re-drive for `id`'s armed unconfirmed spends: for each -/// [`PendingRedrive`] whose anchor is still in Platform's `recorded` -/// set and whose attempt budget remains, re-broadcast the stored -/// byte-identical transition (relay-ACK only — the landing itself is -/// detected by the NEXT scan's nullifier reconcile) and classify: +/// Whether a failed identity-create should release the notes reserved for it. /// -/// - accepted / inconclusive → count the attempt; wait for the next scan; -/// - `NullifierAlreadySpent` → the original executed; the next scan -/// confirms — touch nothing; -/// - any other consensus verdict → provably dead NOW: release the -/// reservation and flip the activity row to Failed, hours before the -/// prune backstop would; -/// - pruned anchor / exhausted attempts → leave it to the -/// prune-backstop release pass. +/// `false` ONLY for [`PlatformWalletError::ShieldedBroadcastUnconfirmed`]: the broadcast was +/// accepted and the transition may have executed, so the reservation must be retained. Releasing it +/// now would invite double-spend attempts against notes that may already be consumed on chain — the +/// very hazard that variant exists to prevent. `pending_nullifiers` is in-memory only (see +/// `SubwalletState`, "never persisted; the next sync after a crash reconciles") and `mark_spent` +/// during nullifier sync clears matching reservations, so if the transition actually executed the +/// next sync promotes these notes to spent; if it truly never landed, an app restart drops the +/// in-memory reservation and frees them. /// -/// Runs after the scan's spent-note reconcile (a landed spend's record -/// was already dropped by the `mark_spent` hook, so anything still -/// armed here is genuinely unresolved). -pub(super) async fn redrive_pending_spends( +/// Everything else is a definitive pre-execution / build / rejection failure: the spend never +/// happened, so the reservation must be released. +fn error_releases_note_reservation(e: &PlatformWalletError) -> bool { + !matches!(e, PlatformWalletError::ShieldedBroadcastUnconfirmed { .. }) +} + +/// Number of times [`identity_create_from_shielded_pool`] re-fetches the new identity by its +/// derived id after a post-broadcast result-confirmation failure, before declaring the broadcast +/// unconfirmed. +const IDENTITY_CREATE_FETCH_RETRIES: usize = 4; + +/// Fixed backoff between identity fetch attempts. Four attempts ~3 s apart (~9 s of fetch window +/// total) is enough to ride out routine DAPI indexing / replica lag for a freshly-included identity +/// without wedging the caller's UI for minutes. +const IDENTITY_CREATE_FETCH_RETRY_DELAY: std::time::Duration = std::time::Duration::from_secs(3); + +/// Fetch an identity by id with a few fixed-interval retries. +/// +/// Used only on the ambiguous post-broadcast path: the result-proof fetch failed, so we don't know +/// whether the transition executed. The identity id is derived deterministically from the spent +/// notes' nullifiers and committed in the transition sighash, so a successful fetch is positive +/// proof the transition landed. Returns `Some(identity)` on the first hit, or `None` if every +/// attempt comes back empty or errors (transport hiccup, not-yet-indexed, …) — the caller then +/// surfaces `ShieldedBroadcastUnconfirmed` rather than a hard failure. +async fn fetch_identity_with_retries( sdk: &Arc, - store: &Arc>, - persister: Option<&WalletPersister>, - wallet_id: WalletId, - id: SubwalletId, - recorded: &std::collections::HashSet<[u8; 32]>, -) { - use dpp::serialization::PlatformDeserializable; + identity_id: Identifier, +) -> Option { + use dash_sdk::platform::Fetch; - let redrives = match store.read().await.pending_redrives(id) { - Ok(r) => r, - Err(e) => { - warn!( - error = %e, - "redrive: pending_redrives failed; skipping subwallet" - ); - return; - } - }; - for redrive in redrives { - // A pruned anchor is the release pass's call, not ours; an - // exhausted budget means we've said our three pieces. - if !recorded.contains(&redrive.anchor) || redrive.attempts >= MAX_REDRIVE_ATTEMPTS { - continue; - } - let st = match StateTransition::deserialize_from_bytes(&redrive.st_bytes) { - Ok(st) => st, - Err(e) => { - warn!( - error = %e, - "redrive: stored transition failed to deserialize; dropping the record \ - (the prune backstop still frees the notes)" - ); - if let Err(e) = store.write().await.clear_redrive(id, &redrive.activity_id) { - warn!(error = %e, "redrive: clear_redrive failed"); - } - continue; - } - }; - let broadcast_result = st.broadcast(sdk, None).await; - let err_display = broadcast_result - .as_ref() - .err() - .map(|e| e.to_string()) - .unwrap_or_default(); - match classify_redrive_broadcast(&broadcast_result) { - RedriveBroadcastOutcome::Accepted => { - let attempts = bump_redrive_attempts_logged(store, id, &redrive.activity_id).await; - info!( - attempts, - max = MAX_REDRIVE_ATTEMPTS, - "redrive: re-broadcast accepted; the next scan detects the landing" - ); - } - RedriveBroadcastOutcome::AlreadyExecuted => { - // Success signal — but still consume an attempt: the scan - // normally confirms the landing and drops the record, and - // if it lags, this arm must not re-broadcast unboundedly - // on every pass. The cap parks the record for the scan / - // prune passes to settle. - let attempts = bump_redrive_attempts_logged(store, id, &redrive.activity_id).await; - info!( - attempts, - max = MAX_REDRIVE_ATTEMPTS, - "redrive: transition already executed on-chain; the next scan confirms \ - the notes spent" - ); - } - RedriveBroadcastOutcome::DefinitiveRejection => { - warn!( - error = %err_display, - "redrive: definitive consensus rejection; the spend can never execute — \ - releasing the reservation" + for attempt in 0..IDENTITY_CREATE_FETCH_RETRIES { + match Identity::fetch(sdk, identity_id).await { + Ok(Some(identity)) => return Some(identity), + Ok(None) => { + trace!( + %identity_id, + attempt, + "IdentityCreateFromShieldedPool confirmation fetch: not found yet" ); - { - let mut guard = store.write().await; - for n in &redrive.nullifiers { - // Also drops the redrive record via the - // clear_pending hook. - if let Err(e) = guard.clear_pending(id, n) { - warn!(error = %e, "redrive: clear_pending failed"); - } - } - } - record_activity_status_by_id( - store, - persister, - wallet_id, - id, - &redrive.activity_id, - ShieldedActivityStatus::Failed, - ) - .await; } - RedriveBroadcastOutcome::Inconclusive => { - // `AlreadyExists` (still in a mempool after a lost-ACK - // retry) or transport noise: inconclusive; counts toward - // the cap. - let attempts = bump_redrive_attempts_logged(store, id, &redrive.activity_id).await; - debug!( - attempts, - max = MAX_REDRIVE_ATTEMPTS, - error = %err_display, - "redrive: re-broadcast inconclusive" + Err(e) => { + trace!( + %identity_id, + attempt, + error = %e, + "IdentityCreateFromShieldedPool confirmation fetch errored; will retry" ); } } + // Skip the trailing sleep after the final attempt — nothing follows it. + if attempt + 1 < IDENTITY_CREATE_FETCH_RETRIES { + tokio::time::sleep(IDENTITY_CREATE_FETCH_RETRY_DELAY).await; + } } + None } -/// Whether an SDK error carries Platform's own consensus verdict on the -/// transition. Two shapes qualify: -/// -/// - `Error::Protocol(ProtocolError::ConsensusError(_))` — DAPI attached the -/// serialized consensus error as gRPC metadata -/// (`dash-serialized-consensus-error-bin`), which the dapi-client decodes -/// on any failed request. This is how a CheckTx rejection of the -/// transition surfaces from `broadcast()` (rs-dapi's -/// `map_broadcast_error` decodes the consensus error from Tenderdash's -/// `info` field and `TenderdashStatus` re-attaches it as metadata); -/// - a `StateTransitionBroadcastError` whose `cause` deserialized from -/// non-empty consensus `data` — the wait-stream error envelope for a -/// transition Platform executed and rejected on its merits. -/// -/// Recurses through a `NoAvailableAddressesToRetry` envelope, mirroring -/// [`crate::error::as_address_invalid_nonce`]. -/// -/// Only these prove the transition was evaluated and REJECTED. Everything -/// else — transport errors, timeouts, `AlreadyExists` (which proves the -/// opposite: the transition is already in the mempool or on chain), -/// DAPI-internal failures, cause-less broadcast envelopes (the shape DAPI -/// uses for its own wait-side timeouts) — leaves the outcome unknown. -fn carries_consensus_rejection(err: &dash_sdk::Error) -> bool { - match err { - dash_sdk::Error::Protocol(dpp::ProtocolError::ConsensusError(_)) => true, - dash_sdk::Error::StateTransitionBroadcastError(e) => e.cause.is_some(), - dash_sdk::Error::NoAvailableAddressesToRetry(inner) => carries_consensus_rejection(inner), - _ => false, - } -} +// ------------------------------------------------------------------------- +// Internal helpers (free fns) +// ------------------------------------------------------------------------- -/// Broadcast a built shielded spend transition (unshield / transfer / -/// withdraw) and wait for proven execution, staging the two SDK calls -/// separately so the caller's reservation rollback only runs when the -/// spend DEFINITIVELY did not happen: +/// Convert `keys`'s default `PaymentAddress` to an `OrchardAddress`. +fn default_orchard_address( + keys: &AccountViewingKeys, +) -> Result { + payment_address_to_orchard(&keys.default_address) +} + +/// Checkpoint depths probed for a Platform-recorded anchor. Kept equal to the +/// commitment tree's `max_checkpoints` retention (the store is opened with +/// `100` — see `PlatformWalletManager`) so the probe reaches every checkpoint +/// the tree still holds and no further: deeper checkpoints are pruned, so +/// probing past this bound is wasted work. Coupled by convention — if that +/// retention changes, update this in lockstep. +const MAX_ANCHOR_PROBE_DEPTH: usize = 100; + +/// Extract `SpendableNote` structs with Merkle witnesses and an anchor +/// Platform has recorded. +/// +/// A shielded spend's proof is accepted only if its anchor is a +/// commitment-tree root Platform recorded (`validate_anchor_exists`). +/// Platform records one anchor per block, but an index-chunk sync routinely +/// leaves the wallet's tree mid-block, so the depth-0 (current) root is +/// frequently a value Platform never recorded — building against it +/// unconditionally is what made such spends fail and never land. +/// +/// This fetches Platform's recorded anchor set (outside the store lock), then +/// selects the shallowest checkpoint depth whose root is in that set — depth 0 +/// being the fully-synced fast path — witnessing every note at that same depth +/// so the anchor and the authentication paths agree (the builder derives the +/// anchor from the witnesses via `MerklePath::root`, so a per-note disagreement +/// would surface downstream as `AnchorMismatch`). When no probed depth has a +/// recorded root it returns the retryable +/// [`PlatformWalletError::ShieldedNoRecordedAnchor`] rather than broadcasting a +/// spend Platform is guaranteed to reject. +async fn extract_spends_and_anchor( + sdk: &Arc, + store: &Arc>, + notes: &[ShieldedNote], +) -> Result<(Vec, Anchor), PlatformWalletError> { + // Nothing selected — fail before the network round-trip. + if notes.is_empty() { + return Err(PlatformWalletError::ShieldedBuildError( + "no spendable notes selected — anchor undefined".to_string(), + )); + } + + // Fetch the recorded anchor set OUTSIDE the store lock so the network + // round-trip doesn't serialize with other store users, and so the lock is + // held only for the mutually-consistent depth/witness probe below. + let dash_sdk::query_types::ShieldedAnchors(recorded_anchors) = + dash_sdk::query_types::ShieldedAnchors::fetch_current(sdk).await?; + let recorded: HashSet<[u8; 32]> = recorded_anchors.into_iter().collect(); + + // Hold a single read lock across the whole probe so the checkpoint depths + // and the per-note witnesses stay mutually consistent: a concurrent sync + // checkpointing mid-probe would otherwise shift the depth indices out from + // under us. + let store = store.read().await; + select_recorded_spends(&*store, notes, &recorded) +} + +/// Pick the shallowest checkpoint depth whose tree root is in `recorded`, +/// witnessing every note at that depth. Pure (no SDK, no async), so the depth +/// walk can be unit-tested against a real commitment tree. +/// +/// Depth 0 is the current tree state (the fully-synced fast path). Deeper +/// checkpoints are older and hold strictly fewer positions, so the probe stops +/// as soon as a selected note is no longer witnessable at a depth — no deeper +/// checkpoint could contain it. Returns +/// [`PlatformWalletError::ShieldedNoRecordedAnchor`] when no probed depth has a +/// recorded root (a clean, retryable outcome — nothing is broadcast). +fn select_recorded_spends( + store: &S, + notes: &[ShieldedNote], + recorded: &HashSet<[u8; 32]>, +) -> Result<(Vec, Anchor), PlatformWalletError> { + use grovedb_commitment_tree::ExtractedNoteCommitment; + + // Deserialize each note and decode its commitment ONCE — both are + // independent of the checkpoint depth, so hoisting them out of the probe + // keeps the depth walk cheap (each probed depth only re-witnesses). + let prepared: Vec<(u64, grovedb_commitment_tree::Note, ExtractedNoteCommitment)> = notes + .iter() + .map(|note| { + let orchard_note = deserialize_note(¬e.note_data).ok_or_else(|| { + PlatformWalletError::ShieldedBuildError(format!( + "Failed to deserialize note at position {}", + note.position + )) + })?; + let cmx = ExtractedNoteCommitment::from_bytes(¬e.cmx) + .into_option() + .ok_or_else(|| { + PlatformWalletError::ShieldedBuildError(format!( + "invalid stored cmx for note at position {}", + note.position + )) + })?; + Ok((note.position, orchard_note, cmx)) + }) + .collect::>()?; + + // Build every selected note's `SpendableNote` plus the shared anchor at a + // single checkpoint `depth`. + // + // `strict` (depth 0 only): a missing/failed witness is a hard + // `ShieldedMerkleWitnessUnavailable` (the note is expected to be witnessable + // at the current tip). At depth > 0, `Ok(None)` means the note post-dates + // this older checkpoint, so the depth is unusable — return `Ok(None)` and + // let the caller stop probing deeper; a genuine store `Err` (poisoned mutex, + // IO, tree corruption) is logged — the probe would otherwise discard the + // message — and likewise treated as an unusable depth rather than aborting, + // so a transient read can't strand a spend a shallower depth already + // covered. An anchor disagreement across notes is always a hard error (the + // spend builder would reject it downstream). + let build_at_depth = |depth: usize, + strict: bool| + -> Result, Anchor)>, PlatformWalletError> { + let mut spends = Vec::with_capacity(prepared.len()); + let mut anchor: Option = None; + for (position, note, cmx) in &prepared { + let merkle_path = match store.witness_at_depth(*position, depth) { + Ok(Some(path)) => path, + Ok(None) if strict => { + return Err(PlatformWalletError::ShieldedMerkleWitnessUnavailable(format!( + "no witness available for note at position {position} (not marked, or pruned past this position)" + ))); + } + Err(e) if strict => { + return Err(PlatformWalletError::ShieldedMerkleWitnessUnavailable( + e.to_string(), + )); + } + // depth > 0: the note isn't witnessable at this older checkpoint + // (appended after it, or the depth doesn't exist). + Ok(None) => return Ok(None), + // depth > 0: a genuine store failure. Log it so the operator sees + // it (the anchor probe otherwise swallows the message), then treat + // the depth as unusable — never a mid-probe abort. + Err(e) => { + tracing::warn!( + position = *position, + depth, + error = %e, + "shielded anchor probe: witness_at_depth failed at depth > 0; skipping depth" + ); + return Ok(None); + } + }; + + // The anchor is derived from the witness path itself + // (`MerklePath::root(cmx)`); all selected notes must agree on it, or + // the store handed back witnesses from different checkpoints and the + // spend builder would reject the mismatch downstream. + let witness_anchor = merkle_path.root(*cmx); + match &anchor { + None => anchor = Some(witness_anchor), + Some(prev) if prev.to_bytes() != witness_anchor.to_bytes() => { + return Err(PlatformWalletError::ShieldedBuildError(format!( + "witness anchor mismatch across selected notes (position {position})" + ))); + } + _ => {} + } + + spends.push(SpendableNote { + note: *note, + merkle_path, + }); + } + + // `notes` is non-empty (the caller checked), so `anchor` is set. + let anchor = anchor.ok_or_else(|| { + PlatformWalletError::ShieldedBuildError( + "no spendable notes selected — anchor undefined".to_string(), + ) + })?; + Ok(Some((spends, anchor))) + }; + + // Fast path: a fully-synced wallet's depth-0 root is a recorded anchor. + let (spends, anchor) = match build_at_depth(0, true)? { + Some(pair) => pair, + // Unreachable — a strict build returns `Some` or errors — but stay + // fund-safe (a clean error, never a panic) if that invariant breaks. + None => { + return Err(PlatformWalletError::ShieldedMerkleWitnessUnavailable( + "depth-0 witness probe returned no witness for a selected note".to_string(), + )); + } + }; + if recorded.contains(&anchor.to_bytes()) { + return Ok((spends, anchor)); + } + + // Otherwise walk older checkpoints newest→oldest for the shallowest + // recorded root. + for depth in 1..MAX_ANCHOR_PROBE_DEPTH { + match build_at_depth(depth, false)? { + Some((spends, anchor)) if recorded.contains(&anchor.to_bytes()) => { + return Ok((spends, anchor)); + } + // A root exists at this depth but Platform didn't record it — try an + // older checkpoint. + Some(_) => continue, + // A selected note isn't witnessable this deep; every deeper + // checkpoint is older still, so none can cover it either. + None => break, + } + } + + Err(PlatformWalletError::ShieldedNoRecordedAnchor( + "no recorded anchor covers the selected notes; wait for the next shielded sync".to_string(), + )) +} + +/// Mark the selected notes as spent for `id`. Also queues a +/// shielded changeset on the persister so the spent flag reaches +/// durable storage immediately rather than waiting for the next +/// note scan to rediscover the spend (scan-based spend detection). +/// Also drops any matching pending reservation so the +/// confirmed-spent state and the in-flight-spend state can't +/// disagree. +async fn mark_notes_spent( + store: &Arc>, + persister: Option<&WalletPersister>, + wallet_id: WalletId, + id: SubwalletId, + notes: &[ShieldedNote], +) -> Result<(), PlatformWalletError> { + let mut changeset = ShieldedChangeSet::default(); + { + let mut store = store.write().await; + for note in notes { + if store + .mark_spent(id, ¬e.nullifier) + .map_err(|e| PlatformWalletError::ShieldedStoreError(e.to_string()))? + { + changeset.record_nullifier_spent(id, note.nullifier); + } + } + } + queue_shielded_changeset(persister, wallet_id, changeset); + Ok(()) +} + +/// Select unspent notes and reserve them against an in-flight +/// spend in one write-locked critical section. +/// +/// Combining selection and reservation under a single write lock +/// is the only thing that prevents two overlapping spend calls +/// from picking the same notes: with separate read-then-write +/// phases, the second caller would observe the same +/// `unspent_notes()` between the first caller's read and write +/// and proceed to build a duplicate proof that's only rejected +/// ~30 s later at broadcast time. +/// +/// The reservation is in-memory only — see +/// [`ShieldedStore::mark_pending`] for the crash-recovery note. +/// Callers must pair this with [`finalize_pending`] (on +/// broadcast success) or [`cancel_pending`] (on failure) so the +/// reservation is always released. +async fn reserve_unspent_notes( + sdk: &Arc, + store: &Arc>, + id: SubwalletId, + amount: u64, + outputs: usize, + fee_kind: ShieldedFeeKind, +) -> Result<(Vec, u64, u64), PlatformWalletError> { + let mut store = store.write().await; + let unspent = store + .get_unspent_notes(id) + .map_err(|e| PlatformWalletError::ShieldedStoreError(e.to_string()))?; + let (selected, total_input, exact_fee) = + select_notes_with_fee(&unspent, amount, outputs, fee_kind, sdk.version())?.into_owned(); + for note in &selected { + store + .mark_pending(id, ¬e.nullifier) + .map_err(|e| PlatformWalletError::ShieldedStoreError(e.to_string()))?; + } + Ok((selected, total_input, exact_fee)) +} + +/// Exact-equality sibling of [`reserve_unspent_notes`] for +/// `IdentityCreateFromShieldedPool`: select + reserve notes covering exactly `denomination` +/// (the fee is metered FROM the denomination, not added to the target) in one write-locked +/// critical section, gating on `denomination > predicted_fee` via +/// [`select_notes_for_denomination`]. Returns the selected notes, total input value, and the +/// predicted fee. Callers must pair this with [`finalize_pending`] / [`cancel_pending`]. +async fn reserve_unspent_notes_for_denomination( + sdk: &Arc, + store: &Arc>, + id: SubwalletId, + denomination: u64, + min_actions: usize, + num_keys: usize, +) -> Result<(Vec, u64, u64), PlatformWalletError> { + let mut store = store.write().await; + let unspent = store + .get_unspent_notes(id) + .map_err(|e| PlatformWalletError::ShieldedStoreError(e.to_string()))?; + let (selected, total_input, predicted_fee) = select_notes_for_denomination( + &unspent, + denomination, + min_actions, + num_keys, + sdk.version(), + )? + .into_owned(); + for note in &selected { + store + .mark_pending(id, ¬e.nullifier) + .map_err(|e| PlatformWalletError::ShieldedStoreError(e.to_string()))?; + } + Ok((selected, total_input, predicted_fee)) +} + +/// Promote a successful broadcast: mark the notes spent (which +/// also clears any matching pending reservation, see +/// [`SubwalletState::mark_spent`]) and queue the changeset for +/// the host persister. +async fn finalize_pending( + store: &Arc>, + persister: Option<&WalletPersister>, + wallet_id: WalletId, + id: SubwalletId, + notes: &[ShieldedNote], +) -> Result<(), PlatformWalletError> { + mark_notes_spent(store, persister, wallet_id, id, notes).await +} + +/// Roll back a reservation when the broadcast / wait fails. +/// Best-effort and doesn't surface its own errors — the caller +/// is already returning the broadcast error. +async fn cancel_pending( + store: &Arc>, + id: SubwalletId, + notes: &[ShieldedNote], +) { + let mut store = store.write().await; + for note in notes { + if let Err(e) = store.clear_pending(id, ¬e.nullifier) { + tracing::warn!( + error = %e, + "cancel_pending: clear_pending failed; the next note scan will reconcile" + ); + } + } +} + +/// Record the recorded `anchor` the spend was built against and the +/// linked activity entry on every selected note's reservation, so a +/// spend that ends broadcast-accepted-but-unconfirmed can be released +/// on a later sync once that anchor is pruned from Platform's recorded +/// set (see `NetworkShieldedCoordinator::release_stranded_spends`). +/// +/// No-op when no activity entry was recorded (an output-less bundle — +/// unreachable for our own builders, which always carry a visible +/// output). Best-effort: a store write failure only means the +/// reservation won't self-release on a pruned anchor (it still frees +/// on the next restart), so it must never abort a spend about to +/// broadcast. A success (`finalize_pending`) or a definite failure +/// (`cancel_pending`) removes the entry, so only an ambiguous +/// unconfirmed outcome leaves it carrying the anchor. +async fn arm_pending_release( + store: &Arc>, + id: SubwalletId, + anchor: [u8; 32], + pending_entry: &Option, + notes: &[ShieldedNote], +) { + let Some(entry) = pending_entry else { + return; + }; + let mut store = store.write().await; + for note in notes { + if let Err(e) = store.set_pending_spend(id, ¬e.nullifier, anchor, entry.id) { + warn!( + error = %e, + "set_pending_spend failed; this reservation won't self-release on a pruned \ + anchor (it still frees on the next restart)" + ); + } + } +} + +/// Maximum sync-time re-broadcast attempts for a +/// broadcast-accepted-but-unconfirmed spend before the re-drive stops +/// and the anchor-prune release backstop owns the reservation. +pub(super) const MAX_REDRIVE_ATTEMPTS: u32 = 3; + +/// Broadcast a built shielded spend and, on the AMBIGUOUS outcome only +/// (`ShieldedSpendUnconfirmed` — accepted broadcast, failed result +/// wait), persist a [`PendingRedrive`] so the sync-time re-drive can +/// resolve the ambiguity actively: the next scan detects a landing via +/// the nullifiers; otherwise the byte-identical transition is +/// re-broadcast up to [`MAX_REDRIVE_ATTEMPTS`] times (fund-safe — +/// identical nullifiers cannot double-spend); only if every attempt +/// stays silent does the anchor-prune release backstop take over. +#[allow(clippy::too_many_arguments)] +async fn broadcast_shielded_spend_with_redrive( + sdk: &Arc, + store: &Arc>, + id: SubwalletId, + pending_entry: &Option, + anchor: [u8; 32], + notes: &[ShieldedNote], + state_transition: &StateTransition, + operation: &'static str, +) -> Result<(), PlatformWalletError> { + let result = broadcast_shielded_spend(sdk, state_transition, operation).await; + if matches!( + &result, + Err(PlatformWalletError::ShieldedSpendUnconfirmed { .. }) + ) { + arm_redrive_record( + store, + id, + pending_entry, + anchor, + notes, + state_transition, + operation, + ) + .await; + } + result +} + +/// Persist the re-drivable record for an ambiguous spend. Best-effort: +/// a failure here only demotes the resolution path to the anchor-prune +/// backstop (plus restart-loss of the reservation), never fails the +/// spend call itself — the ambiguity already happened. +async fn arm_redrive_record( + store: &Arc>, + id: SubwalletId, + pending_entry: &Option, + anchor: [u8; 32], + notes: &[ShieldedNote], + state_transition: &StateTransition, + operation: &'static str, +) { + use dpp::serialization::PlatformSerializable; + + let Some(entry) = pending_entry else { + return; + }; + let st_bytes = match state_transition.serialize_to_bytes() { + Ok(b) => b, + Err(e) => { + warn!( + operation, + error = %e, + "failed to serialize the unconfirmed transition; re-drive disabled for this \ + spend (prune backstop still applies)" + ); + return; + } + }; + let redrive = PendingRedrive { + activity_id: entry.id, + anchor, + nullifiers: notes.iter().map(|n| n.nullifier).collect(), + st_bytes, + attempts: 0, + identity_index: None, + }; + if let Err(e) = store.write().await.arm_redrive(id, redrive) { + warn!( + operation, + error = %e, + "failed to persist the redrive record; re-drive disabled for this spend (prune \ + backstop still applies)" + ); + } +} + +/// Whether an SDK error is Platform's `NullifierAlreadySpentError` — +/// on a RE-broadcast of our own byte-identical transition this means +/// the ORIGINAL broadcast executed: the nullifiers are consumed by the +/// very spend being re-driven, so it is a success signal (the next scan +/// confirms the notes spent), never a failure. +fn is_nullifier_already_spent(e: &dash_sdk::Error) -> bool { + use dpp::consensus::state::state_error::StateError; + use dpp::consensus::ConsensusError; + + let consensus: Option<&ConsensusError> = match e { + dash_sdk::Error::Protocol(dpp::ProtocolError::ConsensusError(c)) => Some(c), + dash_sdk::Error::StateTransitionBroadcastError(b) => b.cause.as_ref(), + _ => None, + }; + matches!( + consensus, + Some(ConsensusError::StateError( + StateError::NullifierAlreadySpentError(_) + )) + ) +} + +/// Pure outcome classification for one re-broadcast attempt. Extracted +/// from the redrive loop so the arm ORDER — `AlreadyExecuted` must win +/// over the generic consensus-rejection check, since +/// `NullifierAlreadySpent` is itself a consensus error — is pinned by +/// unit tests without needing a broadcast-mockable network seam. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum RedriveBroadcastOutcome { + /// Relay accepted the re-broadcast; the next scan detects a landing. + Accepted, + /// `NullifierAlreadySpent`: the ORIGINAL broadcast executed — a + /// success signal, never a failure. + AlreadyExecuted, + /// Any other consensus verdict: the transition can never execute. + DefinitiveRejection, + /// Transport noise / `AlreadyExists` / anything non-definitive. + Inconclusive, +} + +fn classify_redrive_broadcast(result: &Result<(), dash_sdk::Error>) -> RedriveBroadcastOutcome { + match result { + Ok(()) => RedriveBroadcastOutcome::Accepted, + Err(e) if is_nullifier_already_spent(e) => RedriveBroadcastOutcome::AlreadyExecuted, + Err(e) if carries_consensus_rejection(e) => RedriveBroadcastOutcome::DefinitiveRejection, + Err(_) => RedriveBroadcastOutcome::Inconclusive, + } +} + +/// Bump a redrive attempt counter, logging (rather than discarding) a +/// persistence failure. On `Err` the durable counter did not advance and +/// the file store's persist-first ordering leaves memory untouched, so +/// the same attempt slot is retried on the next pass — the log line is +/// what makes that visible. +async fn bump_redrive_attempts_logged( + store: &Arc>, + id: SubwalletId, + activity_id: &[u8; 32], +) -> u32 { + match store.write().await.bump_redrive_attempts(id, activity_id) { + Ok(attempts) => attempts, + Err(e) => { + warn!( + error = %e, + "redrive: failed to persist the attempt counter; the attempt will be \ + retried on the next pass" + ); + 0 + } + } +} + +/// Sync-time re-drive for `id`'s armed unconfirmed spends: for each +/// [`PendingRedrive`] whose anchor is still in Platform's `recorded` +/// set and whose attempt budget remains, re-broadcast the stored +/// byte-identical transition (relay-ACK only — the landing itself is +/// detected by the NEXT scan's nullifier reconcile) and classify: +/// +/// - accepted / inconclusive → count the attempt; wait for the next scan; +/// - `NullifierAlreadySpent` → the original executed; the next scan +/// confirms — touch nothing; +/// - any other consensus verdict → provably dead NOW: release the +/// reservation and flip the activity row to Failed, hours before the +/// prune backstop would; +/// - pruned anchor / exhausted attempts → leave it to the +/// prune-backstop release pass. +/// +/// Runs after the scan's spent-note reconcile (a landed spend's record +/// was already dropped by the `mark_spent` hook, so anything still +/// armed here is genuinely unresolved). +pub(super) async fn redrive_pending_spends( + sdk: &Arc, + store: &Arc>, + persister: Option<&WalletPersister>, + wallet_id: WalletId, + id: SubwalletId, + recorded: &std::collections::HashSet<[u8; 32]>, +) { + use dpp::serialization::PlatformDeserializable; + + let redrives = match store.read().await.pending_redrives(id) { + Ok(r) => r, + Err(e) => { + warn!( + error = %e, + "redrive: pending_redrives failed; skipping subwallet" + ); + return; + } + }; + for redrive in redrives { + // A pruned anchor is the release pass's call, not ours; an + // exhausted budget means we've said our three pieces. + if !recorded.contains(&redrive.anchor) || redrive.attempts >= MAX_REDRIVE_ATTEMPTS { + continue; + } + let st = match StateTransition::deserialize_from_bytes(&redrive.st_bytes) { + Ok(st) => st, + Err(e) => { + warn!( + error = %e, + "redrive: stored transition failed to deserialize; dropping the record \ + (the prune backstop still frees the notes)" + ); + if let Err(e) = store.write().await.clear_redrive(id, &redrive.activity_id) { + warn!(error = %e, "redrive: clear_redrive failed"); + } + continue; + } + }; + let broadcast_result = st.broadcast(sdk, None).await; + let err_display = broadcast_result + .as_ref() + .err() + .map(|e| e.to_string()) + .unwrap_or_default(); + match classify_redrive_broadcast(&broadcast_result) { + RedriveBroadcastOutcome::Accepted => { + let attempts = bump_redrive_attempts_logged(store, id, &redrive.activity_id).await; + info!( + attempts, + max = MAX_REDRIVE_ATTEMPTS, + "redrive: re-broadcast accepted; the next scan detects the landing" + ); + } + RedriveBroadcastOutcome::AlreadyExecuted => { + // Success signal — but still consume an attempt: the scan + // normally confirms the landing and drops the record, and + // if it lags, this arm must not re-broadcast unboundedly + // on every pass. The cap parks the record for the scan / + // prune passes to settle. + let attempts = bump_redrive_attempts_logged(store, id, &redrive.activity_id).await; + info!( + attempts, + max = MAX_REDRIVE_ATTEMPTS, + "redrive: transition already executed on-chain; the next scan confirms \ + the notes spent" + ); + } + RedriveBroadcastOutcome::DefinitiveRejection => { + warn!( + error = %err_display, + "redrive: definitive consensus rejection; the spend can never execute — \ + releasing the reservation" + ); + { + let mut guard = store.write().await; + for n in &redrive.nullifiers { + // Also drops the redrive record via the + // clear_pending hook. + if let Err(e) = guard.clear_pending(id, n) { + warn!(error = %e, "redrive: clear_pending failed"); + } + } + } + record_activity_status_by_id( + store, + persister, + wallet_id, + id, + &redrive.activity_id, + ShieldedActivityStatus::Failed, + ) + .await; + } + RedriveBroadcastOutcome::Inconclusive => { + // `AlreadyExists` (still in a mempool after a lost-ACK + // retry) or transport noise: inconclusive; counts toward + // the cap. + let attempts = bump_redrive_attempts_logged(store, id, &redrive.activity_id).await; + debug!( + attempts, + max = MAX_REDRIVE_ATTEMPTS, + error = %err_display, + "redrive: re-broadcast inconclusive" + ); + } + } + } +} + +/// Whether an SDK error carries Platform's own consensus verdict on the +/// transition. Two shapes qualify: +/// +/// - `Error::Protocol(ProtocolError::ConsensusError(_))` — DAPI attached the +/// serialized consensus error as gRPC metadata +/// (`dash-serialized-consensus-error-bin`), which the dapi-client decodes +/// on any failed request. This is how a CheckTx rejection of the +/// transition surfaces from `broadcast()` (rs-dapi's +/// `map_broadcast_error` decodes the consensus error from Tenderdash's +/// `info` field and `TenderdashStatus` re-attaches it as metadata); +/// - a `StateTransitionBroadcastError` whose `cause` deserialized from +/// non-empty consensus `data` — the wait-stream error envelope for a +/// transition Platform executed and rejected on its merits. +/// +/// Recurses through a `NoAvailableAddressesToRetry` envelope, mirroring +/// [`crate::error::as_address_invalid_nonce`]. +/// +/// Only these prove the transition was evaluated and REJECTED. Everything +/// else — transport errors, timeouts, `AlreadyExists` (which proves the +/// opposite: the transition is already in the mempool or on chain), +/// DAPI-internal failures, cause-less broadcast envelopes (the shape DAPI +/// uses for its own wait-side timeouts) — leaves the outcome unknown. +fn carries_consensus_rejection(err: &dash_sdk::Error) -> bool { + match err { + dash_sdk::Error::Protocol(dpp::ProtocolError::ConsensusError(_)) => true, + dash_sdk::Error::StateTransitionBroadcastError(e) => e.cause.is_some(), + dash_sdk::Error::NoAvailableAddressesToRetry(inner) => carries_consensus_rejection(inner), + _ => false, + } +} + +/// Broadcast a built shielded spend transition (unshield / transfer / +/// withdraw) and wait for proven execution, staging the two SDK calls +/// separately so the caller's reservation rollback only runs when the +/// spend DEFINITIVELY did not happen: /// /// - a definitive `broadcast()` failure ([`broadcast_definitely_failed`]: /// a consensus-verdict CheckTx rejection, or a transport failure that @@ -2481,6 +3913,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`]). /// @@ -2570,6 +4391,92 @@ fn deserialize_note(data: &[u8]) -> Option { Note::from_parts(recipient, value, rho, rseed).into_option() } +#[cfg(test)] +mod foreign_claim_guard_tests { + use super::ForeignClaimGuards; + use std::sync::Arc; + + /// Two callers with the same key must share ONE lifecycle mutex — that + /// identity is what makes the claim single-flight (#4313 review finding + /// 979bbc2fcb3c); different keys must not contend. + #[test] + fn same_key_shares_one_mutex_and_keys_are_independent() { + let guards = ForeignClaimGuards::default(); + let a1 = guards.entry_for([1u8; 32]); + let a2 = guards.entry_for([1u8; 32]); + let b = guards.entry_for([2u8; 32]); + assert!( + Arc::ptr_eq(&a1, &a2), + "same-key callers must receive the SAME lifecycle mutex" + ); + assert!( + !Arc::ptr_eq(&a1, &b), + "distinct keys must receive distinct mutexes" + ); + } + + /// The complete-lifecycle serialization: while one claim holds the + /// guard (parked at an await, as across scan/proof/broadcast), a + /// second same-key claim cannot enter; it proceeds only after the + /// first releases — including release by CANCELLATION (future drop), + /// so an abandoned claim can never wedge its invitation key. + #[tokio::test] + async fn same_key_claims_serialize_and_cancellation_releases() { + let guards = Arc::new(ForeignClaimGuards::default()); + let key = [7u8; 32]; + + let entry = guards.entry_for(key); + let held = entry.lock().await; + // Second same-key claim: must NOT be able to enter while held. + let second = guards.entry_for(key); + assert!( + second.try_lock().is_err(), + "a concurrent same-key claim must park while the lifecycle guard is held" + ); + drop(held); + assert!( + second.try_lock().is_ok(), + "the parked claim must proceed once the holder settles" + ); + + // Cancellation-safety: drop a future that acquired the guard at an + // await point; the key must be immediately claimable again. + let entry2 = guards.entry_for(key); + let task = tokio::spawn(async move { + let _g = entry2.lock().await; + std::future::pending::<()>().await; // parked "mid-claim" forever + }); + tokio::task::yield_now().await; + task.abort(); + let _ = task.await; + assert!( + guards.entry_for(key).try_lock().is_ok(), + "an aborted (cancelled) claim must release the key on drop" + ); + } + + /// Abandoned keys cost nothing: once no claim holds a key's mutex, its + /// registry row is pruned on the next acquisition, so hostile key churn + /// cannot grow the map beyond the keys currently in flight. + #[test] + fn dead_entries_are_pruned() { + let guards = ForeignClaimGuards::default(); + for i in 0..64u8 { + let _ = guards.entry_for([i; 32]); // dropped immediately + } + let _live = guards.entry_for([0xFF; 32]); + let len = guards + .entries + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .len(); + assert_eq!( + len, 1, + "only keys with a live claimant may occupy the registry" + ); + } +} + #[cfg(test)] mod redrive_tests { use super::*; @@ -2676,6 +4583,7 @@ mod redrive_tests { nullifiers: vec![[activity ^ 0xFF; 32]], st_bytes: vec![0xDE, 0xAD], // never deserializes attempts, + identity_index: None, }; { let mut guard = store.write().await; @@ -2710,6 +4618,174 @@ 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, + identity_index: None, + }, + ) + .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, + identity_index: None, + }; + 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, 0) + .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::*; @@ -2880,389 +4956,653 @@ mod classify_spend_wait_failure_tests { assert!(carries_consensus_rejection(&wrapped)); } - /// A transport error wrapped in the retry envelope carries no consensus - /// verdict, so it remains ambiguous — the recursion must not misread it. - #[test] - fn wrapped_transport_error_is_not_a_rejection() { - use dash_sdk::dapi_grpc::tonic::Code; - let wrapped = dash_sdk::Error::NoAvailableAddressesToRetry(Box::new(grpc_err( - Code::DeadlineExceeded, - ))); - assert!(!carries_consensus_rejection(&wrapped)); + /// A transport error wrapped in the retry envelope carries no consensus + /// verdict, so it remains ambiguous — the recursion must not misread it. + #[test] + fn wrapped_transport_error_is_not_a_rejection() { + use dash_sdk::dapi_grpc::tonic::Code; + let wrapped = dash_sdk::Error::NoAvailableAddressesToRetry(Box::new(grpc_err( + Code::DeadlineExceeded, + ))); + assert!(!carries_consensus_rejection(&wrapped)); + } + + /// A nonce rejection wrapped in the retry envelope must reach + /// `promote_address_nonce_error` and surface as the typed + /// `AddressNonceMismatch`, not fall through to `ShieldedSpendUnconfirmed`. + #[test] + fn wrapped_nonce_rejection_promotes_to_typed_mismatch() { + use dpp::address_funds::PlatformAddress; + use dpp::consensus::state::address_funds::AddressInvalidNonceError; + use dpp::consensus::state::state_error::StateError; + + let address = PlatformAddress::P2pkh([9u8; 20]); + let cause = ConsensusError::StateError(StateError::AddressInvalidNonceError( + AddressInvalidNonceError::new(address, 7, 8), + )); + let inner = dash_sdk::Error::Protocol(dpp::ProtocolError::ConsensusError(Box::new(cause))); + let wrapped = dash_sdk::Error::NoAvailableAddressesToRetry(Box::new(inner)); + + match classify_spend_wait_failure("withdraw", &wrapped) { + PlatformWalletError::AddressNonceMismatch { + address: got, + provided_nonce, + expected_nonce, + } => { + assert_eq!(got, address); + assert_eq!(provided_nonce, 7); + assert_eq!(expected_nonce, 8); + } + other => panic!("expected AddressNonceMismatch, got {other:?}"), + } + } +} + +#[cfg(test)] +mod shield_input_fetch_error_tests { + use super::*; + use dpp::consensus::state::address_funds::AddressNotEnoughFundsError; + + #[test] + fn live_address_shortfall_maps_to_typed_shield_capacity_error() { + let sdk_error = dash_sdk::Error::from(AddressNotEnoughFundsError::new( + PlatformAddress::P2pkh([7; 20]), + 3_623_849_220, + 3_623_849_221, + )); + + let mapped = map_shield_input_fetch_error(&sdk_error); + assert!(matches!( + &mapped, + PlatformWalletError::PlatformShieldCapacityExceeded { available, required } + if *available == 3_623_849_220 && *required == 3_623_849_221 + )); + assert_eq!( + mapped.to_string(), + "Platform shield capacity exceeded: available 3623849220, required 3623849221" + ); + } +} + +#[cfg(test)] +mod reserve_shield_fee_tests { + use super::*; + use dpp::version::LATEST_PLATFORM_VERSION; + + fn addr(b: u8) -> PlatformAddress { + PlatformAddress::P2pkh([b; 20]) + } + + #[test] + fn loads_fee_onto_smallest_key_input() { + // Input 0 is the BTreeMap-smallest address (addr(1)); the fee must + // land there, matching the `DeductFromInput(0)` fee strategy. + let mut inputs = BTreeMap::new(); + inputs.insert(addr(2), 5_000_000u64); + inputs.insert(addr(1), 1_000_000u64); + + let fee = 123_097_600u64; + let out = reserve_shield_fee_on_input_0(inputs, fee).expect("non-empty inputs"); + + assert_eq!(out.get(&addr(1)), Some(&(1_000_000 + fee))); + assert_eq!( + out.get(&addr(2)), + Some(&5_000_000), + "other inputs untouched" + ); + // Σ claims grew by exactly `fee`, satisfying `Σ inputs >= amount + F`. + assert_eq!(out.values().sum::(), 6_000_000 + fee); + } + + #[test] + fn versioned_fee_keeps_input_zero_valid_and_reserve_tracks_the_fee() { + let min_input_amount = LATEST_PLATFORM_VERSION + .dpp + .state_transitions + .address_funds + .min_input_amount; + let shield_fee = compute_minimum_shielded_fee(SHIELD_NUM_ACTIONS, LATEST_PLATFORM_VERSION) + .expect("latest shield fee must be computable"); + let reserve = shield_fee_reserve_credits(LATEST_PLATFORM_VERSION) + .expect("latest shield fee reserve must be computable"); + let smallest_fee_inclusive_claim = shield_fee + .checked_add(1) + .expect("latest shield fee plus one credit must fit"); + + assert!( + smallest_fee_inclusive_claim >= min_input_amount, + "adding the fee must lift even input 0's smallest positive base claim above the protocol minimum" + ); + assert!( + reserve >= shield_fee, + "the retained input-0 headroom must cover the versioned shield fee" + ); + assert!( + reserve <= shield_fee.saturating_mul(4), + "the reserve must stay a small multiple of the versioned fee — an oversized \ + reserve silently understates preflight capacity and strands the excess \ + below the input-0 viability threshold after a Max shield" + ); + } + + #[test] + fn errors_on_empty_inputs() { + let inputs: BTreeMap = BTreeMap::new(); + let err = reserve_shield_fee_on_input_0(inputs, 1).expect_err("empty must reject"); + assert!(matches!(err, PlatformWalletError::ShieldedBuildError(_))); + } + + #[test] + fn errors_on_claim_plus_fee_overflow() { + let mut inputs = BTreeMap::new(); + inputs.insert(addr(1), u64::MAX); + let err = reserve_shield_fee_on_input_0(inputs, 1).expect_err("overflow must reject"); + assert!(matches!(err, PlatformWalletError::ShieldedBuildError(_))); + } +} + +#[cfg(test)] +mod note_reservation_release_tests { + use super::*; + + /// `ShieldedBroadcastUnconfirmed` is the one failure that must NOT release the reservation: the + /// broadcast was accepted and the transition may have executed, so freeing the notes invites a + /// double-spend against notes that may already be consumed on chain. The next nullifier sync + /// reconciles them. + #[test] + fn unconfirmed_broadcast_retains_reservation() { + let e = PlatformWalletError::ShieldedBroadcastUnconfirmed { + identity_id: Identifier::from([7u8; 32]), + reason: "result proof unavailable".to_string(), + }; + assert!( + !error_releases_note_reservation(&e), + "ShieldedBroadcastUnconfirmed must retain the note reservation" + ); + } + + /// Every other failure is a definitive pre-execution / build / rejection failure — the spend + /// never happened, so the reservation must be released. + #[test] + fn definitive_failures_release_reservation() { + let releasing: Vec = vec![ + PlatformWalletError::ShieldedBroadcastFailed("rejected on merits".to_string()), + PlatformWalletError::ShieldedBuildError("note selection failed".to_string()), + PlatformWalletError::ShieldedStoreError("store write failed".to_string()), + ]; + for e in &releasing { + assert!( + error_releases_note_reservation(e), + "{e:?} must release the note reservation" + ); + } + } +} + +#[cfg(test)] +mod record_activity_status_tests { + use super::*; + use crate::wallet::shielded::activity::{ + ShieldedActivityEntry, ShieldedActivityKind, ShieldedDirection, + }; + use crate::wallet::shielded::store::InMemoryShieldedStore; + + fn sub() -> SubwalletId { + SubwalletId::new([0xCC; 32], 0) + } + + /// The Pending entry a live recorder captures before broadcast. + fn captured_pending() -> ShieldedActivityEntry { + ShieldedActivityEntry { + id: [0xAA; 32], + kind: ShieldedActivityKind::Shield, + direction: ShieldedDirection::In, + amount: 1_000, + fee: Some(10), + counterparty: None, + memo: None, + block_height: None, + status: ShieldedActivityStatus::Pending, + created_at_ms: 1, + min_note_position: None, + note_cmxs: vec![[0x01; 32]], + spent_nullifiers: vec![], + } + } + + /// A scan pass that confirmed the row at a real height between the + /// broadcast and the result-wait must win over the post-wait flip: + /// the stale captured entry must not overwrite the stored + /// `Confirmed`-with-height row (neither downgrading it to `Failed` + /// nor erasing the scan-learned height). + #[tokio::test] + async fn flip_does_not_clobber_scan_confirmed_row() { + let store = Arc::new(RwLock::new(InMemoryShieldedStore::new())); + let id = sub(); + let pending = captured_pending(); + let scan_confirmed = with_status(&pending, ShieldedActivityStatus::Confirmed, Some(777)); + store + .write() + .await + .save_activity(id, &scan_confirmed) + .unwrap(); + + record_activity_status( + &store, + None, + id.wallet_id, + id, + &Some(pending), + ShieldedActivityStatus::Failed, + None, + ) + .await; + + let stored = store + .read() + .await + .get_activity_by_entry_id(id, &[0xAA; 32]) + .unwrap() + .expect("row must still exist"); + assert_eq!(stored.status, ShieldedActivityStatus::Confirmed); + assert_eq!(stored.block_height, Some(777)); + } + + /// No concurrent scan: the flip applies to the stored Pending row + /// (and falls back to the captured entry when the store has none), + /// writing the new status into the in-memory store. + #[tokio::test] + async fn flip_applies_when_row_is_still_pending() { + let store = Arc::new(RwLock::new(InMemoryShieldedStore::new())); + let id = sub(); + let pending = captured_pending(); + store.write().await.save_activity(id, &pending).unwrap(); + + record_activity_status( + &store, + None, + id.wallet_id, + id, + &Some(pending), + ShieldedActivityStatus::Confirmed, + Some(900), + ) + .await; + + let stored = store + .read() + .await + .get_activity_by_entry_id(id, &[0xAA; 32]) + .unwrap() + .expect("row must exist"); + assert_eq!(stored.status, ShieldedActivityStatus::Confirmed); + assert_eq!(stored.block_height, Some(900)); } - /// A nonce rejection wrapped in the retry envelope must reach - /// `promote_address_nonce_error` and surface as the typed - /// `AddressNonceMismatch`, not fall through to `ShieldedSpendUnconfirmed`. - #[test] - fn wrapped_nonce_rejection_promotes_to_typed_mismatch() { - use dpp::address_funds::PlatformAddress; - use dpp::consensus::state::address_funds::AddressInvalidNonceError; - use dpp::consensus::state::state_error::StateError; + /// The by-id flip the sync reconcile uses: it knows only the + /// reservation's stored `activity_id`, so it looks the row up and flips + /// it — a released stranded spend moves Pending → Failed. + #[tokio::test] + async fn status_flip_by_id_flips_pending_to_failed() { + let store = Arc::new(RwLock::new(InMemoryShieldedStore::new())); + let id = sub(); + let pending = captured_pending(); + store.write().await.save_activity(id, &pending).unwrap(); - let address = PlatformAddress::P2pkh([9u8; 20]); - let cause = ConsensusError::StateError(StateError::AddressInvalidNonceError( - AddressInvalidNonceError::new(address, 7, 8), - )); - let inner = dash_sdk::Error::Protocol(dpp::ProtocolError::ConsensusError(Box::new(cause))); - let wrapped = dash_sdk::Error::NoAvailableAddressesToRetry(Box::new(inner)); + record_activity_status_by_id( + &store, + None, + id.wallet_id, + id, + &pending.id, + ShieldedActivityStatus::Failed, + ) + .await; - match classify_spend_wait_failure("withdraw", &wrapped) { - PlatformWalletError::AddressNonceMismatch { - address: got, - provided_nonce, - expected_nonce, - } => { - assert_eq!(got, address); - assert_eq!(provided_nonce, 7); - assert_eq!(expected_nonce, 8); - } - other => panic!("expected AddressNonceMismatch, got {other:?}"), - } + let stored = store + .read() + .await + .get_activity_by_entry_id(id, &pending.id) + .unwrap() + .expect("row must exist"); + assert_eq!(stored.status, ShieldedActivityStatus::Failed); + } + + /// A by-id flip for an entry that doesn't exist is a silent no-op + /// (nothing to flip), never a panic. + #[tokio::test] + async fn status_flip_by_id_missing_entry_is_noop() { + let store = Arc::new(RwLock::new(InMemoryShieldedStore::new())); + let id = sub(); + record_activity_status_by_id( + &store, + None, + id.wallet_id, + id, + &[0xDE; 32], + ShieldedActivityStatus::Failed, + ) + .await; + assert!(store + .read() + .await + .get_activity_by_entry_id(id, &[0xDE; 32]) + .unwrap() + .is_none()); } } +/// Unit tests for the pure anchor-selection probe ([`select_recorded_spends`]) +/// against a real SQLite-backed commitment tree — no SDK, no network. +/// +/// These pin the fix for the shielded-withdrawal "never lands" root cause: +/// the wallet must build a spend against a Platform-recorded anchor, not the +/// bleeding-edge depth-0 root a mid-block index-chunk sync leaves behind. They +/// reuse the block-boundary tree shape from the `file_store` reproduction test. #[cfg(test)] -mod shield_input_fetch_error_tests { +mod select_recorded_spends_tests { use super::*; - use dpp::consensus::state::address_funds::AddressNotEnoughFundsError; + use crate::wallet::shielded::file_store::FileBackedShieldedStore; + use dashcore::Network; + use grovedb_commitment_tree::{ExtractedNoteCommitment, Note, NoteValue, RandomSeed, Rho}; - #[test] - fn live_address_shortfall_maps_to_typed_shield_capacity_error() { - let sdk_error = dash_sdk::Error::from(AddressNotEnoughFundsError::new( - PlatformAddress::P2pkh([7; 20]), - 3_623_849_220, - 3_623_849_221, - )); + /// Unique temp path for a test tree (no `tempfile` dev-dep). + 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!("select_recorded_spends_{tag}_{nanos}.sqlite")) + } - let mapped = map_shield_input_fetch_error(&sdk_error); - assert!(matches!( - &mapped, - PlatformWalletError::PlatformShieldCapacityExceeded { available, required } - if *available == 3_623_849_220 && *required == 3_623_849_221 - )); - assert_eq!( - mapped.to_string(), - "Platform shield capacity exceeded: available 3623849220, required 3623849221" - ); + /// A filler leaf commitment for non-owned positions. Any canonical 32-byte + /// field element works — the probe only needs the tree to grow between + /// blocks so successive checkpoint roots differ. + fn filler_cmx(b: u8) -> [u8; 32] { + let mut c = [0u8; 32]; + c[0] = b; + c } -} -#[cfg(test)] -mod reserve_shield_fee_tests { - use super::*; - use dpp::version::LATEST_PLATFORM_VERSION; + /// Build one real, spendable Orchard note owned by a fixed test seed and + /// return the wallet's `ShieldedNote` view of it. + /// + /// `note_data` is the real serialized note so `deserialize_note` accepts + /// it, and `cmx` is the note's real extracted commitment so that appending + /// `cmx` as the leaf at `position` makes `witness(position, d).root(cmx)` + /// reproduce the tree's anchor at depth `d`. + fn real_note(position: u64) -> ShieldedNote { + let keys = OrchardKeySet::from_seed(&[0x42; 32], Network::Testnet, 0) + .expect("ZIP-32 derivation from a fixed seed"); + let recipient = keys.default_address; - fn addr(b: u8) -> PlatformAddress { - PlatformAddress::P2pkh([b; 20]) + // rho and rseed must be canonical Pallas base-field elements; not every + // 32-byte pattern is, so scan deterministically for a valid pair drawn + // from disjoint byte regions (mirroring the sync tests' note builders). + 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 value = NoteValue::from_raw(100_000); + let note = Note::from_parts(recipient, value, rho, rseed) + .into_option() + .expect("valid note parts"); + let cmx = ExtractedNoteCommitment::from(note.commitment()).to_bytes(); + + // `recipient(43) || value(8 LE) || rho(32) || rseed(32)` — the exact + // format `deserialize_note` expects. + 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: [0x07; 32], + block_height: 1, + is_spent: false, + value: 100_000, + note_data, + } } + /// Mid-block: the wallet's depth-0 root is not recorded, but a prior + /// block-boundary checkpoint is — the probe must select that older recorded + /// anchor (the shallowest one), never the mid-block depth-0 root Platform + /// never recorded. #[test] - fn loads_fee_onto_smallest_key_input() { - // Input 0 is the BTreeMap-smallest address (addr(1)); the fee must - // land there, matching the `DeductFromInput(0)` fee strategy. - let mut inputs = BTreeMap::new(); - inputs.insert(addr(2), 5_000_000u64); - inputs.insert(addr(1), 1_000_000u64); + fn mid_block_selects_prior_recorded_checkpoint_not_depth0() { + let path = temp_tree_path("midblock"); + let mut store = FileBackedShieldedStore::open_path(&path, 100).unwrap(); + + // The owned note lives at position 0, present since block 1. + let note = real_note(0); + + // Block 1 = positions 0,1,2 (leaf 0 is the owned note's cmx). drive + // records ONE anchor per block, at block-processing-end. + 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_block1 = store.tree_anchor().unwrap(); + + // Block 2 = positions 3,4,5. Its block-end root is the second recorded + // anchor. + store.append_commitment(&filler_cmx(0xB1), true).unwrap(); + store.append_commitment(&filler_cmx(0xB2), true).unwrap(); + store.append_commitment(&filler_cmx(0xB3), true).unwrap(); + store.checkpoint_tree(6).unwrap(); + let root_block2 = store.tree_anchor().unwrap(); + + // Mid-block: the index-chunk sync appends one more commitment (position + // 6) and checkpoints there. The depth-0 root is now a state drive never + // recorded. + store.append_commitment(&filler_cmx(0xC1), true).unwrap(); + store.checkpoint_tree(7).unwrap(); + let root_depth0 = store.tree_anchor().unwrap(); - let fee = 123_097_600u64; - let out = reserve_shield_fee_on_input_0(inputs, fee).expect("non-empty inputs"); + // drive's recorded set is exactly the two block-boundary roots. + let recorded: HashSet<[u8; 32]> = [root_block1, root_block2].into_iter().collect(); - assert_eq!(out.get(&addr(1)), Some(&(1_000_000 + fee))); - assert_eq!( - out.get(&addr(2)), - Some(&5_000_000), - "other inputs untouched" - ); - // Σ claims grew by exactly `fee`, satisfying `Σ inputs >= amount + F`. - assert_eq!(out.values().sum::(), 6_000_000 + fee); - } + let (spends, anchor) = + select_recorded_spends(&store, std::slice::from_ref(¬e), &recorded) + .expect("a prior recorded checkpoint covers the owned note"); - #[test] - fn versioned_fee_keeps_input_zero_valid_and_reserve_tracks_the_fee() { - let min_input_amount = LATEST_PLATFORM_VERSION - .dpp - .state_transitions - .address_funds - .min_input_amount; - let shield_fee = compute_minimum_shielded_fee(SHIELD_NUM_ACTIONS, LATEST_PLATFORM_VERSION) - .expect("latest shield fee must be computable"); - let reserve = shield_fee_reserve_credits(LATEST_PLATFORM_VERSION) - .expect("latest shield fee reserve must be computable"); - let smallest_fee_inclusive_claim = shield_fee - .checked_add(1) - .expect("latest shield fee plus one credit must fit"); + let _ = std::fs::remove_file(&path); + assert_eq!(spends.len(), 1, "the single owned note is spendable"); assert!( - smallest_fee_inclusive_claim >= min_input_amount, - "adding the fee must lift even input 0's smallest positive base claim above the protocol minimum" + recorded.contains(&anchor.to_bytes()), + "the selected anchor must be a Platform-recorded root" ); - assert!( - reserve >= shield_fee, - "the retained input-0 headroom must cover the versioned shield fee" + assert_eq!( + anchor.to_bytes(), + root_block2, + "must pick the shallowest recorded checkpoint (block 2 / depth 1), not a deeper one" ); - assert!( - reserve <= shield_fee.saturating_mul(4), - "the reserve must stay a small multiple of the versioned fee — an oversized \ - reserve silently understates preflight capacity and strands the excess \ - below the input-0 viability threshold after a Max shield" + 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 errors_on_empty_inputs() { - let inputs: BTreeMap = BTreeMap::new(); - let err = reserve_shield_fee_on_input_0(inputs, 1).expect_err("empty must reject"); - assert!(matches!(err, PlatformWalletError::ShieldedBuildError(_))); - } + fn fully_synced_returns_depth0_anchor() { + let path = temp_tree_path("fastpath"); + let mut store = FileBackedShieldedStore::open_path(&path, 100).unwrap(); - #[test] - fn errors_on_claim_plus_fee_overflow() { - let mut inputs = BTreeMap::new(); - inputs.insert(addr(1), u64::MAX); - let err = reserve_shield_fee_on_input_0(inputs, 1).expect_err("overflow must reject"); - assert!(matches!(err, PlatformWalletError::ShieldedBuildError(_))); - } -} + let note = real_note(0); -#[cfg(test)] -mod note_reservation_release_tests { - use super::*; + // 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(); - /// `ShieldedBroadcastUnconfirmed` is the one failure that must NOT release the reservation: the - /// broadcast was accepted and the transition may have executed, so freeing the notes invites a - /// double-spend against notes that may already be consumed on chain. The next nullifier sync - /// reconciles them. - #[test] - fn unconfirmed_broadcast_retains_reservation() { - let e = PlatformWalletError::ShieldedBroadcastUnconfirmed { - identity_id: Identifier::from([7u8; 32]), - reason: "result proof unavailable".to_string(), - }; - assert!( - !error_releases_note_reservation(&e), - "ShieldedBroadcastUnconfirmed must retain the note reservation" + 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" ); } - /// Every other failure is a definitive pre-execution / build / rejection failure — the spend - /// never happened, so the reservation must be released. + /// No checkpoint root is recorded: the probe exhausts every depth and + /// returns the retryable `ShieldedNoRecordedAnchor` — nothing is broadcast. #[test] - fn definitive_failures_release_reservation() { - let releasing: Vec = vec![ - PlatformWalletError::ShieldedBroadcastFailed("rejected on merits".to_string()), - PlatformWalletError::ShieldedBuildError("note selection failed".to_string()), - PlatformWalletError::ShieldedStoreError("store write failed".to_string()), - ]; - for e in &releasing { - assert!( - error_releases_note_reservation(e), - "{e:?} must release the note reservation" - ); - } - } -} + fn no_recorded_checkpoint_returns_retryable_error() { + let path = temp_tree_path("none"); + let mut store = FileBackedShieldedStore::open_path(&path, 100).unwrap(); -#[cfg(test)] -mod record_activity_status_tests { - use super::*; - use crate::wallet::shielded::activity::{ - ShieldedActivityEntry, ShieldedActivityKind, ShieldedDirection, - }; - use crate::wallet::shielded::store::InMemoryShieldedStore; + let note = real_note(0); - fn sub() -> SubwalletId { - SubwalletId::new([0xCC; 32], 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(); - /// The Pending entry a live recorder captures before broadcast. - fn captured_pending() -> ShieldedActivityEntry { - ShieldedActivityEntry { - id: [0xAA; 32], - kind: ShieldedActivityKind::Shield, - direction: ShieldedDirection::In, - amount: 1_000, - fee: Some(10), - counterparty: None, - memo: None, - block_height: None, - status: ShieldedActivityStatus::Pending, - created_at_ms: 1, - min_note_position: None, - note_cmxs: vec![[0x01; 32]], - spent_nullifiers: vec![], - } - } + // Platform recorded none of this wallet's checkpoint roots. + let recorded: HashSet<[u8; 32]> = HashSet::new(); - /// A scan pass that confirmed the row at a real height between the - /// broadcast and the result-wait must win over the post-wait flip: - /// the stale captured entry must not overwrite the stored - /// `Confirmed`-with-height row (neither downgrading it to `Failed` - /// nor erasing the scan-learned height). - #[tokio::test] - async fn flip_does_not_clobber_scan_confirmed_row() { - let store = Arc::new(RwLock::new(InMemoryShieldedStore::new())); - let id = sub(); - let pending = captured_pending(); - let scan_confirmed = with_status(&pending, ShieldedActivityStatus::Confirmed, Some(777)); - store - .write() - .await - .save_activity(id, &scan_confirmed) - .unwrap(); + // `SpendableNote` isn't `Debug`, so match rather than `expect_err`. + let result = select_recorded_spends(&store, std::slice::from_ref(¬e), &recorded); - record_activity_status( - &store, - None, - id.wallet_id, - id, - &Some(pending), - ShieldedActivityStatus::Failed, - None, - ) - .await; + let _ = std::fs::remove_file(&path); - let stored = store - .read() - .await - .get_activity_by_entry_id(id, &[0xAA; 32]) - .unwrap() - .expect("row must still exist"); - assert_eq!(stored.status, ShieldedActivityStatus::Confirmed); - assert_eq!(stored.block_height, Some(777)); + match result { + Err(PlatformWalletError::ShieldedNoRecordedAnchor(_)) => {} + Err(other) => { + panic!("expected ShieldedNoRecordedAnchor, got error: {other:?}") + } + Ok(_) => panic!("expected ShieldedNoRecordedAnchor, got Ok"), + } } - /// No concurrent scan: the flip applies to the stored Pending row - /// (and falls back to the captured entry when the store has none), - /// writing the new status into the in-memory store. - #[tokio::test] - async fn flip_applies_when_row_is_still_pending() { - let store = Arc::new(RwLock::new(InMemoryShieldedStore::new())); - let id = sub(); - let pending = captured_pending(); - store.write().await.save_activity(id, &pending).unwrap(); - - record_activity_status( - &store, - None, - id.wallet_id, - id, - &Some(pending), - ShieldedActivityStatus::Confirmed, - Some(900), - ) - .await; + /// 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(); - let stored = store - .read() - .await - .get_activity_by_entry_id(id, &[0xAA; 32]) - .unwrap() - .expect("row must exist"); - assert_eq!(stored.status, ShieldedActivityStatus::Confirmed); - assert_eq!(stored.block_height, Some(900)); - } + // 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 by-id flip the sync reconcile uses: it knows only the - /// reservation's stored `activity_id`, so it looks the row up and flips - /// it — a released stranded spend moves Pending → Failed. - #[tokio::test] - async fn status_flip_by_id_flips_pending_to_failed() { - let store = Arc::new(RwLock::new(InMemoryShieldedStore::new())); - let id = sub(); - let pending = captured_pending(); - store.write().await.save_activity(id, &pending).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(); - record_activity_status_by_id( - &store, - None, - id.wallet_id, - id, - &pending.id, - ShieldedActivityStatus::Failed, - ) - .await; + let recorded: HashSet<[u8; 32]> = [root_block1].into_iter().collect(); - let stored = store - .read() - .await - .get_activity_by_entry_id(id, &pending.id) - .unwrap() - .expect("row must exist"); - assert_eq!(stored.status, ShieldedActivityStatus::Failed); - } + let result = select_recorded_spends(&store, std::slice::from_ref(¬e), &recorded); - /// A by-id flip for an entry that doesn't exist is a silent no-op - /// (nothing to flip), never a panic. - #[tokio::test] - async fn status_flip_by_id_missing_entry_is_noop() { - let store = Arc::new(RwLock::new(InMemoryShieldedStore::new())); - let id = sub(); - record_activity_status_by_id( - &store, - None, - id.wallet_id, - id, - &[0xDE; 32], - ShieldedActivityStatus::Failed, - ) - .await; - assert!(store - .read() - .await - .get_activity_by_entry_id(id, &[0xDE; 32]) - .unwrap() - .is_none()); + 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 pure anchor-selection probe ([`select_recorded_spends`]) -/// against a real SQLite-backed commitment tree — no SDK, no network. +/// Unit tests for the ONE-TIME-key claim path +/// ([`identity_create_from_one_time_key`] / [`super::sync::scan_notes_for_foreign_key`]). /// -/// These pin the fix for the shielded-withdrawal "never lands" root cause: -/// the wallet must build a spend against a Platform-recorded anchor, not the -/// bleeding-edge depth-0 root a mid-block index-chunk sync leaves behind. They -/// reuse the block-boundary tree shape from the `file_store` reproduction test. +/// 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 select_recorded_spends_tests { +mod one_time_key_tests { use super::*; use crate::wallet::shielded::file_store::FileBackedShieldedStore; - use dashcore::Network; - use grovedb_commitment_tree::{ExtractedNoteCommitment, Note, NoteValue, RandomSeed, Rho}; + 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]; - /// Unique temp path for a test tree (no `tempfile` dev-dep). 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!("select_recorded_spends_{tag}_{nanos}.sqlite")) + std::env::temp_dir().join(format!("one_time_key_{tag}_{nanos}.sqlite")) } - /// A filler leaf commitment for non-owned positions. Any canonical 32-byte - /// field element works — the probe only needs the tree to grow between - /// blocks so successive checkpoint roots differ. fn filler_cmx(b: u8) -> [u8; 32] { let mut c = [0u8; 32]; c[0] = b; c } - /// Build one real, spendable Orchard note owned by a fixed test seed and - /// return the wallet's `ShieldedNote` view of it. - /// - /// `note_data` is the real serialized note so `deserialize_note` accepts - /// it, and `cmx` is the note's real extracted commitment so that appending - /// `cmx` as the leaf at `position` makes `witness(position, d).root(cmx)` - /// reproduce the tree's anchor at depth `d`. - fn real_note(position: u64) -> ShieldedNote { - let keys = OrchardKeySet::from_seed(&[0x42; 32], Network::Testnet, 0) - .expect("ZIP-32 derivation from a fixed seed"); - let recipient = keys.default_address; + /// 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) + } - // rho and rseed must be canonical Pallas base-field elements; not every - // 32-byte pattern is, so scan deterministically for a valid pair drawn - // from disjoint byte regions (mirroring the sync tests' note builders). + /// 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]; @@ -3278,193 +5618,1029 @@ mod select_recorded_spends_tests { }) .expect("a canonical rseed exists"); - let value = NoteValue::from_raw(100_000); - let note = Note::from_parts(recipient, value, rho, rseed) + 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(); - // `recipient(43) || value(8 LE) || rho(32) || rseed(32)` — the exact - // format `deserialize_note` expects. 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: [0x07; 32], - block_height: 1, - is_spent: false, - value: 100_000, - note_data, + ShieldedNote { + position, + cmx, + nullifier, + block_height: 1, + is_spent: false, + value, + note_data, + } + } + + /// The scan's per-note conversion is correct: a note owned by the one-time + /// key round-trips through the wallet's 115-byte serialization, and its + /// nullifier matches the one derived under that key's fvk (what the scan + /// stamps). This is the piece [`super::sync::scan_notes_for_foreign_key`] + /// runs on every discovered note. + #[test] + fn foreign_key_note_roundtrips_and_nullifier_matches() { + let note = one_time_note(DENOMINATION, 0); + + // `note_data` deserializes back to an equal note. + let decoded = deserialize_note(¬e.note_data).expect("serialized note is valid"); + assert_eq!( + decoded.value().inner(), + DENOMINATION, + "value survives round-trip" + ); + + // The stamped nullifier is exactly the one the one-time key's fvk derives. + let fvk = one_time_fvk(); + assert_eq!( + note.nullifier, + decoded.nullifier(&fvk).to_bytes(), + "stamped nullifier must match the fvk-derived nullifier" + ); + + // The stored cmx is the note's real extracted commitment. + assert_eq!( + note.cmx, + ExtractedNoteCommitment::from(decoded.commitment()).to_bytes(), + "stored cmx must be the note's real commitment" + ); + } + + /// Exact-equality selection over the transiently-scanned set: exact funding + /// (zero change), over-funding (change = excess routed to change_address), + /// under-funding (typed `ShieldedInsufficientBalance`), and no-note (empty → + /// `ShieldedNoUnspentNotes`, the op's fail-fast on an unfunded key). + #[test] + fn select_for_claim_exact_over_under_and_no_note() { + let version = PlatformVersion::latest(); + + // Exact: one note equal to the denomination → zero change. + let exact = vec![one_time_note(DENOMINATION, 0)]; + let (sel, total, fee) = + select_notes_for_denomination(&exact, DENOMINATION, 2, 1, version).expect("exact"); + assert_eq!(sel.len(), 1); + assert_eq!(total, DENOMINATION); + assert_eq!(total - DENOMINATION, 0, "exact funding leaves zero change"); + assert!(fee < DENOMINATION, "fee must leave a positive balance"); + + // Over-funded: the excess above the denomination becomes the change note. + let excess = 7_000_000_000u64; + let over = vec![one_time_note(DENOMINATION + excess, 0)]; + let (sel, total, _) = + select_notes_for_denomination(&over, DENOMINATION, 2, 1, version).expect("over"); + assert_eq!(sel.len(), 1); + assert_eq!( + total - DENOMINATION, + excess, + "over-funding routes the excess to change_address" + ); + + // Under-funded: a single note below the denomination. + let under = vec![one_time_note(DENOMINATION - 1, 0)]; + match select_notes_for_denomination(&under, DENOMINATION, 2, 1, version) { + Err(PlatformWalletError::ShieldedInsufficientBalance { + available, + required, + }) => { + assert_eq!(available, DENOMINATION - 1); + assert_eq!(required, DENOMINATION); + } + other => panic!("expected ShieldedInsufficientBalance, got {other:?}"), + } + + // No note found for the key: empty set → ShieldedNoUnspentNotes (the same + // error the op raises on `discovered.is_empty()`). + match select_notes_for_denomination(&[], DENOMINATION, 2, 1, version) { + Err(PlatformWalletError::ShieldedNoUnspentNotes) => {} + other => panic!("expected ShieldedNoUnspentNotes, got {other:?}"), + } + } + + /// The witness half: a note owned by the one-time key, appended to the shared + /// fully-marked tree, is witnessable and produces a `SpendableNote` against a + /// Platform-recorded anchor — the same probe the op runs before the build. + #[test] + fn foreign_key_note_witnesses_against_recorded_anchor() { + let path = temp_tree_path("witness"); + let mut store = FileBackedShieldedStore::open_path(&path, 100).unwrap(); + + let note = one_time_note(DENOMINATION, 0); + + // One block, checkpointed on its boundary: depth-0 root is recorded. + store.append_commitment(¬e.cmx, true).unwrap(); + store.append_commitment(&filler_cmx(0xA1), true).unwrap(); + store.append_commitment(&filler_cmx(0xA2), true).unwrap(); + store.checkpoint_tree(3).unwrap(); + let root_depth0 = store.tree_anchor().unwrap(); + + let recorded: HashSet<[u8; 32]> = [root_depth0].into_iter().collect(); + + let (spends, anchor) = + select_recorded_spends(&store, std::slice::from_ref(¬e), &recorded) + .expect("the one-time key's note witnesses against the recorded anchor"); + + let _ = std::fs::remove_file(&path); + + assert_eq!( + spends.len(), + 1, + "the one-time key's single note is spendable" + ); + assert_eq!( + spends[0].note.value().inner(), + DENOMINATION, + "the witnessed SpendableNote carries the funded value" + ); + assert_eq!( + anchor.to_bytes(), + root_depth0, + "the spend is built against the Platform-recorded anchor" + ); + } +} + +/// 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; + /// The local DIP-9 slot the original attempt registered at. + const IDENTITY_INDEX: u32 = 3; + + 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 chargeable_unshield_fallback_identity_is_rejected() { + let expected_id = identity_id_from_nullifiers(&our_nullifiers()); + + // The pre-existing identity: it genuinely owns our MASTER key hash (that + // is exactly why the unique-key-hash collision fired), but it was created + // by some unrelated earlier transition, so it carries an unrelated id. + let pre_existing = identity_with_keys(Identifier::from([0xEE; 32]), vec![our_master_key()]); + + assert!( + pre_existing.id() != expected_id, + "precondition: the colliding identity is not the one this claim derives" + ); + assert!( + pre_fix_rule_accepts(&pre_existing), + "the pre-fix rule accepted this identity as a successful claim" + ); + assert!( + !recovered_identity_matches_claim( + &pre_existing, + Some(expected_id), + Some(OUR_MASTER_HASH) + ), + "an identity that merely owns the submitted master auth key hash must NOT be \ + reported as this claim's result: the spend was finalized as a chargeable failure \ + and created no identity" + ); + } + + /// BLOCKER 2 — a competing holder of the same bearer key must not read as + /// success. + /// + /// The identity id is derived from published nullifiers only, never from + /// identity keys. With two or more real spends no randomized padding action + /// is added, so another holder of the same one-time key spending the same + /// notes derives the SAME id under THEIR keys. The key binding must reject + /// it — otherwise the foreign identity is registered at this wallet's + /// caller-supplied identity index. + #[test] + fn competing_bearer_key_holder_identity_is_rejected() { + let expected_id = identity_id_from_nullifiers(&our_nullifiers()); + + // Same notes => same nullifiers => same derived id, but the winner + // registered their own master key. + let foreign = identity_with_keys( + expected_id, + vec![key_with_hash( + 0, + Purpose::AUTHENTICATION, + SecurityLevel::MASTER, + OTHER_MASTER_HASH, + )], + ); + + assert_eq!( + foreign.id(), + expected_id, + "precondition: the race winner's identity shares this claim's derived id" + ); + assert!( + pre_fix_rule_accepts(&foreign), + "the pre-fix rule accepted this identity as a successful claim" + ); + assert!( + !recovered_identity_matches_claim(&foreign, Some(expected_id), Some(OUR_MASTER_HASH)), + "an identity at this claim's derived id that does not carry the submitted master \ + auth key belongs to another holder of the one-time key and must NOT be returned" + ); + } + + /// A keyless fetch must fail closed rather than be topped up with the + /// locally-submitted keys — those were never proven to exist on chain. + #[test] + fn identity_fetched_without_public_keys_is_rejected() { + let expected_id = identity_id_from_nullifiers(&our_nullifiers()); + let keyless = identity_with_keys(expected_id, vec![]); + + assert!( + pre_fix_rule_accepts(&keyless), + "the pre-fix rule accepted this identity and then inserted the submitted keys locally" + ); + assert!( + !recovered_identity_matches_claim(&keyless, Some(expected_id), Some(OUR_MASTER_HASH)), + "an identity fetched without public keys cannot prove the key binding" + ); + } + + /// A single-spend claim's id is not re-derivable (the bundle is padded to + /// Orchard's 2-action minimum with a randomly generated dummy nullifier that + /// participates in the derivation), so no candidate can ever be bound to it. + #[test] + fn unre_derivable_id_is_rejected() { + let identity = identity_with_keys(Identifier::from([0xEE; 32]), vec![our_master_key()]); + + assert!( + !recovered_identity_matches_claim(&identity, None, Some(OUR_MASTER_HASH)), + "without a re-derivable id there is no evidence this claim created the identity" + ); + } + + /// A missing MASTER auth key hash is not evidence either. + #[test] + fn absent_master_key_hash_is_rejected() { + let expected_id = identity_id_from_nullifiers(&our_nullifiers()); + let identity = identity_with_keys(expected_id, vec![our_master_key()]); + + assert!( + !recovered_identity_matches_claim(&identity, Some(expected_id), None), + "without a submitted master auth key hash the key binding cannot be established" + ); + } + + /// A key with the right hash but the wrong purpose/security level does not + /// satisfy the key binding — the binding is specifically on the MASTER + /// AUTHENTICATION key, which is the uniquely Platform-indexed handle. + #[test] + fn non_master_key_with_matching_hash_is_rejected() { + let expected_id = identity_id_from_nullifiers(&our_nullifiers()); + let identity = identity_with_keys( + expected_id, + vec![ + key_with_hash( + 0, + Purpose::AUTHENTICATION, + SecurityLevel::HIGH, + OUR_MASTER_HASH, + ), + key_with_hash( + 1, + Purpose::TRANSFER, + SecurityLevel::CRITICAL, + OUR_MASTER_HASH, + ), + ], + ); + + assert!( + !recovered_identity_matches_claim(&identity, Some(expected_id), Some(OUR_MASTER_HASH)), + "only a MASTER AUTHENTICATION key satisfies the key binding" + ); + } + + /// The positive case: both bindings hold, so this claim provably created the + /// identity and recovery returns it. + #[test] + fn identity_with_matching_id_and_master_key_is_accepted() { + let expected_id = identity_id_from_nullifiers(&our_nullifiers()); + let ours = identity_with_keys( + expected_id, + vec![ + our_master_key(), + key_with_hash(1, Purpose::TRANSFER, SecurityLevel::CRITICAL, [0xC3; 20]), + ], + ); + + assert!( + recovered_identity_matches_claim(&ours, Some(expected_id), Some(OUR_MASTER_HASH)), + "an identity carrying this claim's derived id AND its submitted master auth key was \ + created by this claim" + ); + } + + /// The id binding is only meaningful because the derivation is over the + /// claim's own nullifier set: a different note selection derives a different + /// id, so it cannot be passed off as this claim's result. + #[test] + fn a_different_nullifier_set_derives_a_different_id() { + let ours = identity_id_from_nullifiers(&our_nullifiers()); + let theirs = identity_id_from_nullifiers(&[[0x11; 32], [0x33; 32]]); + + assert_ne!( + ours, theirs, + "the derived id is a function of the published nullifier set" + ); + + let identity = identity_with_keys(theirs, vec![our_master_key()]); + assert!( + !recovered_identity_matches_claim(&identity, Some(ours), Some(OUR_MASTER_HASH)), + "an identity created from a different nullifier set is not this claim's identity" + ); + } + + // ── Resumed-claim binding (#4313 review finding 195efdd4ae21) ────────── + // + // The pending-claim record is found by wallet id and one-time FVK alone, so + // the resume must take its binding from the STORED TRANSITION and refuse a + // retry whose arguments disagree — never act on the caller's values while + // re-broadcasting someone else's bytes. + + /// Serialize a shielded identity-create transition carrying exactly `keys` + /// and `denomination`, shaped as `arm_one_time_claim_record` stores it. + fn stored_claim_transition( + keys: &[IdentityPublicKey], + denomination: u64, + ) -> (StateTransition, Vec) { + use dpp::serialization::PlatformSerializable; + use dpp::state_transition::identity_create_from_shielded_pool_transition::v0::IdentityCreateFromShieldedPoolTransitionV0; + use dpp::state_transition::identity_create_from_shielded_pool_transition::IdentityCreateFromShieldedPoolTransition; + + let transition: IdentityCreateFromShieldedPoolTransition = + IdentityCreateFromShieldedPoolTransitionV0 { + public_keys: keys + .iter() + .map(|key| IdentityPublicKeyInCreation::from(key.clone())) + .collect(), + denomination, + actions: Vec::new(), + anchor: [0x07; 32], + proof: vec![0x08; 8], + binding_signature: [0x09; 64], + send_to_address_on_creation_failure: dpp::address_funds::PlatformAddress::P2pkh( + [0u8; 20], + ), + identity_id: identity_id_from_nullifiers(&our_nullifiers()), + } + .into(); + let st = StateTransition::IdentityCreateFromShieldedPool(transition); + let bytes = st.serialize_to_bytes().expect("transition serializes"); + (st, bytes) + } + + /// A pending-claim record over `st_bytes`, keyed like a real one. + fn stored_claim_record(st_bytes: Vec) -> PendingRedrive { + PendingRedrive { + activity_id: [0x5A; 32], + anchor: [0x07; 32], + nullifiers: our_nullifiers(), + st_bytes, + attempts: 0, + identity_index: Some(IDENTITY_INDEX), } } - /// Mid-block: the wallet's depth-0 root is not recorded, but a prior - /// block-boundary checkpoint is — the probe must select that older recorded - /// anchor (the shallowest one), never the mid-block depth-0 root Platform - /// never recorded. + 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 mid_block_selects_prior_recorded_checkpoint_not_depth0() { - let path = temp_tree_path("midblock"); - 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; - // The owned note lives at position 0, present since block 1. - let note = real_note(0); + let submitted = vec![ + our_master_key(), + key_with_hash(1, Purpose::TRANSFER, SecurityLevel::CRITICAL, [0xC3; 20]), + ]; + let (_, st_bytes) = stored_claim_transition(&submitted, DENOMINATION); - // Block 1 = positions 0,1,2 (leaf 0 is the owned note's cmx). drive - // records ONE anchor per block, at block-processing-end. - 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_block1 = store.tree_anchor().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"); + }; - // Block 2 = positions 3,4,5. Its block-end root is the second recorded - // anchor. - store.append_commitment(&filler_cmx(0xB1), true).unwrap(); - store.append_commitment(&filler_cmx(0xB2), true).unwrap(); - store.append_commitment(&filler_cmx(0xB3), true).unwrap(); - store.checkpoint_tree(6).unwrap(); - let root_block2 = store.tree_anchor().unwrap(); + let derived: BTreeMap = transition + .public_keys() + .iter() + .map(|key_in_creation| { + let key: IdentityPublicKey = key_in_creation.into(); + (key.id(), key) + }) + .collect(); - // Mid-block: the index-chunk sync appends one more commitment (position - // 6) and checkpoints there. The depth-0 root is now a state drive never - // recorded. - store.append_commitment(&filler_cmx(0xC1), true).unwrap(); - store.checkpoint_tree(7).unwrap(); - let root_depth0 = store.tree_anchor().unwrap(); + 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" + ); + } - // drive's recorded set is exactly the two block-boundary roots. - let recorded: HashSet<[u8; 32]> = [root_block1, root_block2].into_iter().collect(); + /// 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); - let (spends, anchor) = - select_recorded_spends(&store, std::slice::from_ref(¬e), &recorded) - .expect("a prior recorded checkpoint covers the owned note"); + assert_eq!( + one_time_claim_binding_mismatch( + &keys, + Some(OUR_MASTER_HASH), + DENOMINATION, + Some(IDENTITY_INDEX), + &keys, + Some(OUR_MASTER_HASH), + DENOMINATION, + IDENTITY_INDEX, + ), + None, + "identical arguments must not be treated as a mis-binding" + ); + } - let _ = std::fs::remove_file(&path); + /// 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!(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!( + one_time_claim_binding_mismatch( + &forward, + Some(OUR_MASTER_HASH), + DENOMINATION, + Some(IDENTITY_INDEX), + &reversed, + Some(OUR_MASTER_HASH), + DENOMINATION, + IDENTITY_INDEX, + ), + 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!( - anchor.to_bytes(), - root_block2, - "must pick the shallowest recorded checkpoint (block 2 / depth 1), not a deeper one" + stored.keys().collect::>(), + swapped.keys().collect::>(), + "precondition: the swap keeps the key ids identical" ); - assert_ne!( - anchor.to_bytes(), - root_depth0, - "must NOT use the mid-block depth-0 root drive never recorded" + + let key_mismatch = one_time_claim_binding_mismatch( + &stored, + Some(OUR_MASTER_HASH), + DENOMINATION, + Some(IDENTITY_INDEX), + &swapped, + Some(OUR_MASTER_HASH), + DENOMINATION, + IDENTITY_INDEX, + ); + 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, + Some(IDENTITY_INDEX), + &stored, + Some(OTHER_MASTER_HASH), + DENOMINATION, + IDENTITY_INDEX, + ); + 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, + Some(IDENTITY_INDEX), + &stored, + Some(OUR_MASTER_HASH), + DENOMINATION * 3, + IDENTITY_INDEX, + ); + assert!( + denomination_mismatch.is_some_and(|m| m.contains("denomination")), + "a different denomination must be refused" ); } - /// 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(); + /// 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 note = real_note(0); + 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"); - // 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(); + // 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, + IDENTITY_INDEX, + ) + .await; - let recorded: HashSet<[u8; 32]> = [root_depth0].into_iter().collect(); + 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:?})"), + } + ), + } - let (spends, anchor) = - select_recorded_spends(&store, std::slice::from_ref(¬e), &recorded) - .expect("depth-0 root is recorded"); + // 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); + } - assert_eq!(spends.len(), 1); - assert_eq!( - anchor.to_bytes(), - root_depth0, - "the fully-synced fast path returns the depth-0 anchor" + /// THE BUG (#4313 review finding 5d4d6efa): `identity_index` was bound only + /// TRANSITIVELY — "a different slot means different keys, so the key check + /// catches it". A retry that presents the ORIGINAL keys with a different + /// slot breaks that chain, and its consequence is not symmetric with a + /// first attempt's: `IdentityManager::add_identity` rejects a duplicate + /// identity id but inserts into an OCCUPIED slot without complaint, so the + /// retry silently displaces whatever identity the wallet tracked there. + /// + /// The record now carries the slot, so the mismatch is caught with + /// everything else byte-identical — exactly the case the transitive + /// argument could not cover. + #[test] + fn a_resume_at_a_different_identity_index_is_refused() { + let keys = keys_map(&[our_master_key()]); + + let mismatch = one_time_claim_binding_mismatch( + &keys, + Some(OUR_MASTER_HASH), + DENOMINATION, + Some(IDENTITY_INDEX), + // Everything else is identical — only the slot moved. + &keys, + Some(OUR_MASTER_HASH), + DENOMINATION, + IDENTITY_INDEX + 1, + ); + let mismatch = mismatch.expect("a slot mismatch must be refused"); + assert!( + mismatch.contains("identity index"), + "the refusal must name the slot, got: {mismatch}" + ); + assert!( + mismatch.contains(&IDENTITY_INDEX.to_string()) + && mismatch.contains(&(IDENTITY_INDEX + 1).to_string()), + "the refusal must carry both slots, got: {mismatch}" ); } - /// No checkpoint root is recorded: the probe exhausts every depth and - /// returns the retryable `ShieldedNoRecordedAnchor` — nothing is broadcast. + /// A record written before the column existed carries `None`. There is + /// nothing to compare it against, so it keeps exactly the transitive + /// binding it was written under rather than being refused outright — an + /// upgrade must not strand a claim that is mid-flight across 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 a_pre_migration_record_still_resumes_at_any_index() { + let keys = keys_map(&[our_master_key()]); - let note = real_note(0); + assert_eq!( + one_time_claim_binding_mismatch( + &keys, + Some(OUR_MASTER_HASH), + DENOMINATION, + None, + &keys, + Some(OUR_MASTER_HASH), + DENOMINATION, + IDENTITY_INDEX + 7, + ), + None, + "a record with no persisted slot must not be refused on the slot" + ); + } - 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(); + /// END TO END on the real file store: arm a claim at slot N, attempt to + /// resume it at N+1, and get the typed refusal BEFORE any network work — + /// with the record left intact for a correct retry. The SDK is a bare mock + /// with no expectations registered, so reaching the spent-nullifier probe + /// or the re-broadcast would surface as something other than this error. + #[tokio::test] + async fn resuming_at_a_mismatched_identity_index_fails_closed() { + let sdk = Arc::new(dash_sdk::SdkBuilder::new_mock().build().expect("mock sdk")); + let path = temp_store_path("resume_slot_binding"); + let store = Arc::new(RwLock::new( + FileBackedShieldedStore::open_path(&path, 100).expect("store opens"), + )); + let claim_records_id = SubwalletId::new([0x78; 32], ONE_TIME_CLAIM_RECORDS_ACCOUNT); - // Platform recorded none of this wallet's checkpoint roots. - let recorded: HashSet<[u8; 32]> = HashSet::new(); + let (_, st_bytes) = stored_claim_transition(&[our_master_key()], DENOMINATION); + let record = stored_claim_record(st_bytes); + assert_eq!( + record.identity_index, + Some(IDENTITY_INDEX), + "precondition: the armed record carries the slot" + ); + store + .write() + .await + .arm_redrive(claim_records_id, record.clone()) + .expect("record arms"); - // `SpendableNote` isn't `Debug`, so match rather than `expect_err`. - let result = select_recorded_spends(&store, std::slice::from_ref(¬e), &recorded); + // The retry presents the ORIGINAL keys, hash and denomination — only + // the slot differs. Nothing but the persisted index can catch this. + let outcome = resume_one_time_claim( + &sdk, + &store, + claim_records_id, + &record, + Some(OUR_MASTER_HASH), + keys_map(&[our_master_key()]), + DENOMINATION, + IDENTITY_INDEX + 1, + ) + .await; + + match outcome { + OneTimeClaimResume::Resolved(Err( + PlatformWalletError::ShieldedClaimBindingMismatch { mismatch }, + )) => assert!( + mismatch.contains("identity index"), + "the refusal must name the slot, got: {mismatch}" + ), + other => panic!( + "a retry at a different slot must be refused, got {}", + match other { + OneTimeClaimResume::RecordUnusable => "RecordUnusable".to_string(), + OneTimeClaimResume::Resolved(r) => format!("Resolved({r:?})"), + } + ), + } + // Fail-closed: the record survives for a retry that names slot N. + let surviving = store + .read() + .await + .pending_redrives(claim_records_id) + .expect("records readable"); + assert_eq!(surviving.len(), 1); + assert_eq!(surviving[0].identity_index, Some(IDENTITY_INDEX)); + + // …and the slot is DURABLE, not just in-memory: a cold reopen still + // carries it, which is the whole point of the schema change. + drop(store); + let reopened = FileBackedShieldedStore::open_path(&path, 100).expect("reopen"); + let rehydrated = reopened + .pending_redrives(claim_records_id) + .expect("records readable"); + assert_eq!(rehydrated.len(), 1); + assert_eq!( + rehydrated[0].identity_index, + Some(IDENTITY_INDEX), + "the persisted slot must survive a process restart" + ); + drop(reopened); 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"), +#[cfg(test)] +mod claim_lease_heartbeat_tests { + use super::*; + use crate::wallet::shielded::store::{ + admission_now_ms, AdmissionToken, InMemoryShieldedStore, CLAIM_LEASE_MS, + CLAIM_LEASE_RENEW_INTERVAL, + }; + + /// How long the initial lease is stamped for in these tests: long enough + /// that the first heartbeat tick still finds it live (wall-clock time + /// barely advances under a paused runtime), short enough that the probe + /// below can tell "renewed" from "not renewed". + const SHORT_LEASE_MS: u64 = 5_000; + + /// A body shaped like the RESUME path: it does real awaiting and then + /// returns its own outcome, without ever reaching the fresh-build + /// broadcast the heartbeat used to wrap. + async fn resume_shaped_body() -> &'static str { + for _ in 0..3 { + tokio::time::sleep(CLAIM_LEASE_RENEW_INTERVAL).await; } + "resumed" } - /// 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(); + /// THE BUG (#4313 review finding 8de8d05a): the heartbeat wrapped only the + /// fresh-build broadcast, so a claim that took the RESUME path — nullifier + /// queries, repeated identity recovery, re-broadcast, an unbounded + /// confirmation wait — ran under the initial lease alone. Outrun it and the + /// lease is reaped, at which point a concurrent purge counts zero live + /// claims and deletes the record the claim needs. + /// + /// Control half: run the same body bare and watch the lease lapse. + #[tokio::test(start_paused = true)] + async fn a_resume_shaped_body_run_bare_lets_its_lease_lapse() { + let store = Arc::new(RwLock::new(InMemoryShieldedStore::new())); + let wallet_id: WalletId = [0x41; 32]; + let token = AdmissionToken([0x41; 16]); + let t0 = admission_now_ms(); + assert!(store + .write() + .await + .begin_claim_admission(wallet_id, token, t0, SHORT_LEASE_MS) + .expect("lease")); - // 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(); + assert_eq!(resume_shaped_body().await, "resumed"); - // 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(); + // Probe: is the lease still live at a point past its ORIGINAL expiry? + // Nothing re-stamped it, so no. + assert!( + !store + .write() + .await + .renew_claim_admission(token, t0 + SHORT_LEASE_MS + 1, CLAIM_LEASE_MS) + .expect("probe"), + "without a heartbeat the resume path's lease lapses — this is the bug" + ); + } - let recorded: HashSet<[u8; 32]> = [root_block1].into_iter().collect(); + /// The fix: the heartbeat wraps the COMPLETE admitted claim body, so the + /// resume path is covered by exactly the same renewal the fresh-build path + /// gets. Same body, same clock, opposite outcome. + #[tokio::test(start_paused = true)] + async fn the_heartbeat_keeps_a_resume_shaped_body_s_lease_live() { + let store = Arc::new(RwLock::new(InMemoryShieldedStore::new())); + let wallet_id: WalletId = [0x42; 32]; + let token = AdmissionToken([0x42; 16]); + let t0 = admission_now_ms(); + assert!(store + .write() + .await + .begin_claim_admission(wallet_id, token, t0, SHORT_LEASE_MS) + .expect("lease")); - let result = select_recorded_spends(&store, std::slice::from_ref(¬e), &recorded); + let outcome = under_renewed_claim_lease(&store, token, resume_shaped_body()).await; + assert_eq!( + outcome, "resumed", + "the helper must return the body's value" + ); - let _ = std::fs::remove_file(&path); + assert!( + store + .write() + .await + .renew_claim_admission(token, t0 + SHORT_LEASE_MS + 1, CLAIM_LEASE_MS) + .expect("probe"), + "the heartbeat must have re-stamped the lease past its original expiry" + ); + } - match result { - Err(PlatformWalletError::ShieldedNoRecordedAnchor(_)) => {} - Err(other) => panic!("expected ShieldedNoRecordedAnchor, got error: {other:?}"), - Ok(_) => panic!("expected ShieldedNoRecordedAnchor, got Ok"), + /// The claim-key reservation rides the same token, so the heartbeat holds + /// the invitation too — a long resume must not lose its claim key to expiry + /// while its lease is being kept alive. + #[tokio::test(start_paused = true)] + async fn the_heartbeat_also_holds_the_claim_key_reservation() { + use crate::wallet::shielded::store::ClaimKeyReservation; + + let store = Arc::new(RwLock::new(InMemoryShieldedStore::new())); + let wallet_id: WalletId = [0x43; 32]; + let claim_key = [0xC5; 32]; + let claim_records_id = SubwalletId::new(wallet_id, ONE_TIME_CLAIM_RECORDS_ACCOUNT); + let holder = AdmissionToken([0x43; 16]); + let rival = AdmissionToken([0x44; 16]); + let t0 = admission_now_ms(); + { + let mut guard = store.write().await; + assert!(guard + .begin_claim_admission(wallet_id, holder, t0, SHORT_LEASE_MS) + .expect("lease")); + assert_eq!( + guard + .reserve_one_time_claim_key( + claim_records_id, + claim_key, + holder, + t0, + SHORT_LEASE_MS + ) + .expect("reserve") + .reservation, + ClaimKeyReservation::Acquired + ); } + + under_renewed_claim_lease(&store, holder, resume_shaped_body()).await; + + // Past the ORIGINAL reservation expiry, a rival must still be refused. + let contended = { + let mut guard = store.write().await; + assert!(guard + .begin_claim_admission(wallet_id, rival, t0 + SHORT_LEASE_MS + 1, CLAIM_LEASE_MS) + .expect("rival lease")); + guard + .reserve_one_time_claim_key( + claim_records_id, + claim_key, + rival, + t0 + SHORT_LEASE_MS + 1, + CLAIM_LEASE_MS, + ) + .expect("rival reserve") + }; + assert!( + !contended.is_acquired(), + "the heartbeat must carry the claim-key reservation past its original expiry" + ); } } diff --git a/packages/rs-platform-wallet/src/wallet/shielded/store.rs b/packages/rs-platform-wallet/src/wallet/shielded/store.rs index e49aac7a445..cd823296722 100644 --- a/packages/rs-platform-wallet/src/wallet/shielded/store.rs +++ b/packages/rs-platform-wallet/src/wallet/shielded/store.rs @@ -159,6 +159,18 @@ pub struct PendingRedrive { pub st_bytes: Vec, /// Re-broadcast attempts made so far. pub attempts: u32, + /// For a one-time-key CLAIM record: the local DIP-9 identity slot the + /// original attempt was creating the identity at. `None` for every + /// ordinary spend redrive (which registers no identity), and for claim + /// records written before this field existed. + /// + /// Persisted rather than derived because it appears NOWHERE in + /// `st_bytes` — it is a purely local placement, so the transition cannot + /// witness it and a resume has nothing else to check a retry's slot + /// against. Without it a retry could present the original keys with a + /// different index and `IdentityManager::add_identity` would insert into + /// an occupied slot without complaint (#4313 review finding 5d4d6efa). + pub identity_index: Option, } /// The result of [`SubwalletState::mark_spent`]. @@ -473,6 +485,479 @@ 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. + /// + /// # The claim-key gate + /// + /// The same step ALSO refuses — `Ok(false)`, nothing written — when a live + /// [claim-key reservation](Self::reserve_one_time_claim_key) covers + /// `(id.wallet_id, redrive.activity_id)` under a *different* token. That is + /// what makes the `INSERT OR REPLACE` below structurally unable to clobber + /// another claimant's record rather than merely unlikely to + /// (#4313 review finding cr-9d0e1a44): a second claimant that somehow + /// reached this call without owning the key is stopped at the storage + /// layer, in the same transaction that would have overwritten the row. + /// + /// Ordinary spend redrives are unaffected — they never take a claim-key + /// reservation, so no live row covers their activity id and the gate is a + /// no-op for them. + fn arm_redrive_under_claim( + &mut self, + id: SubwalletId, + redrive: PendingRedrive, + token: AdmissionToken, + now_ms: u64, + lease_ms: u64, + ) -> Result; + + /// Reserve this invitation's claim-record key for the holder of claim lease + /// `token`, atomically, and report who owns it afterwards. + /// + /// # Why this exists + /// + /// The claim lease taken by [`Self::begin_claim_admission`] is per-WALLET: + /// it orders a claim against a destructive purge, and nothing else. Two + /// claims of the SAME invitation are both admitted by it. In-process they + /// are serialized by the coordinator's per-FVK single-flight mutex — but + /// that mutex is per-coordinator, and two coordinators (or two processes) + /// open independent SQLite connections to one file and share no lock at + /// all. Both then found no pending record, built transitions with + /// DIFFERENT padded identity ids, and the second `arm_redrive_under_claim` + /// — an `INSERT OR REPLACE` — silently overwrote the first's byte-exact + /// recovery row while its transition was already on the wire, stranding + /// that identity forever (#4313 review finding cr-9d0e1a44). + /// + /// So the key itself is reserved where the contention actually is: one + /// durable row per `(wallet_id, claim_record_key)`, claimed by an + /// insert that cannot overwrite an existing row. + /// + /// # Contract + /// + /// Implementations MUST make the insert-if-absent, the read-back of the + /// durable reservation row, AND the read of the durable pending-claim + /// record ONE atomic step against every other admission operation on the + /// same underlying state, and MUST NOT overwrite a live row. The returned + /// [`ClaimKeyReservationOutcome::reservation`] is derived from the durable + /// row as it stands after the attempt, so exactly one concurrent caller + /// can see [`ClaimKeyReservation::Acquired`]: + /// + /// * [`ClaimKeyReservation::Acquired`] — this `token` owns the key. Also + /// returned when `token` already owned it (re-entry is idempotent and + /// re-stamps the row). + /// * [`ClaimKeyReservation::Held`] — a different, live claimant owns it. + /// The caller MUST NOT build, broadcast or arm anything: it either + /// RESUMES the durable pending-claim record that claimant left, or + /// refuses with [`PlatformWalletError::ShieldedLifecycleBusy`]. + /// + /// [`ClaimKeyReservationOutcome::pending`] carries the pending-claim record + /// armed under `claim_record_key` in `claim_records_id`, read from DURABLE + /// state in that same step — never from a mirror populated at store open. + /// A caller that receives `Some` resumes it; arming a fresh record over it + /// is what strands an already-broadcast identity + /// (#4313 review finding r3767229122, and see the type's docs). + /// + /// `claim_records_id` is the reserved subwallet the claim records live + /// under, so implementations can address the row exactly; the reservation + /// itself is keyed by `(claim_records_id.wallet_id, claim_record_key)` — + /// an invitation is contended per WALLET, not per account. + /// + /// The reservation is bound to `token` for its whole life: it is re-stamped + /// by [`Self::renew_claim_admission`] and by [`Self::arm_redrive_under_claim`] + /// alongside the lease, released by [`Self::end_claim_admission`], and + /// otherwise reaped by expiry — so a claimant that died without releasing + /// cannot hold an invitation hostage beyond [`CLAIM_LEASE_MS`]. + /// + /// [`PlatformWalletError::ShieldedLifecycleBusy`]: crate::error::PlatformWalletError::ShieldedLifecycleBusy + fn reserve_one_time_claim_key( + &mut self, + claim_records_id: SubwalletId, + claim_record_key: [u8; 32], + token: AdmissionToken, + now_ms: u64, + lease_ms: u64, + ) -> Result; + + /// Re-stamp a live claim lease to `now_ms + lease_ms`, WITHOUT touching the + /// pending record. + /// + /// [`Self::arm_redrive_under_claim`] stamps the lease once, so the window + /// protecting a claim ran for a fixed [`CLAIM_LEASE_MS`] from the arm — not + /// for as long as the claim actually took. A broadcast plus confirmation + /// that outran it (a slow or retrying DAPI node is enough) let the row be + /// reaped, at which point a purge counted zero live claims and destroyed + /// state the in-flight claim still needed (#4313 review finding 161a517fce36). + /// + /// Returns `false` when the lease is gone — already lapsed and reaped, or + /// displaced by a destructive barrier. The caller cannot un-send a + /// transition, so a `false` is a loud diagnostic, not an abort. + /// + /// Any [claim-key reservation](Self::reserve_one_time_claim_key) held under + /// the same `token` is re-stamped with it, so the key stays reserved for + /// exactly as long as the lease that took it. + fn renew_claim_admission( + &mut self, + token: AdmissionToken, + now_ms: u64, + lease_ms: u64, + ) -> Result; + + /// Release the claim lease `token`, together with any + /// [claim-key reservation](Self::reserve_one_time_claim_key) it holds. + /// 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`]) and, independently of that, on +/// a [`CLAIM_LEASE_RENEW_INTERVAL`] heartbeat that the wallet layer runs around +/// the COMPLETE admitted claim — fresh build and pending-record resume alike. +/// So this constant bounds the gap between two renewals, not the length of a +/// claim: the protected window follows the work. +pub(crate) const CLAIM_LEASE_MS: u64 = 5 * 60 * 1_000; + +/// How often an in-flight claim re-stamps its lease +/// ([`ShieldedStore::renew_claim_admission`]). +/// +/// A third of [`CLAIM_LEASE_MS`]: two consecutive renewals may be missed — a +/// stalled executor, a long blocking store write — before the lease can lapse, +/// while the tick stays far cheaper than the network phases it runs alongside. +/// Renewing is a single indexed UPDATE, so the cost is noise next to a +/// broadcast. +pub(crate) const CLAIM_LEASE_RENEW_INTERVAL: std::time::Duration = + std::time::Duration::from_millis(CLAIM_LEASE_MS / 3); + +/// 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. + /// + /// Uses [`RngCore::try_fill_bytes`](rand::RngCore::try_fill_bytes) rather + /// than `fill_bytes`: the latter *panics* when the OS entropy source + /// fails. Both production callers are reached from `#[no_mangle] extern + /// "C"` exports (the claim path through + /// `platform_wallet_manager_shielded_identity_create_from_one_time_key`, + /// the barrier path through the destructive lifecycle exports), and a + /// panic inside those futures is re-raised by `block_on_worker`'s + /// `expect("tokio worker panicked")` where it cannot unwind across the C + /// ABI — aborting the host process before the JNI panic guard can run. + /// Same reasoning, and same remedy, as + /// [`generate_one_time_orchard_key`](super::keys::generate_one_time_orchard_key). + /// + /// Reported as [`PlatformWalletError::Persistence`] because an admission + /// token is only ever minted to enter the store's admission protocol: the + /// caller is already mapping that step's failures to `Persistence`, so the + /// host sees one error class for "the admission could not be taken" + /// regardless of which half failed. There is deliberately no infallible + /// `Default`/`new` on this type outside tests — an infallible path would + /// reintroduce exactly the panic this returns instead. + pub fn generate() -> Result { + use rand::{rngs::OsRng, RngCore}; + + let mut bytes = [0u8; 16]; + OsRng.try_fill_bytes(&mut bytes).map_err(|e| { + crate::error::PlatformWalletError::Persistence(format!( + "OS RNG entropy source failed while minting a lifecycle admission token: {e}" + )) + })?; + Ok(Self(bytes)) + } + + /// Infallible token for tests only — panics on entropy failure, which is + /// why it is not available to production code (see [`generate`](Self::generate)). + #[cfg(test)] + pub(crate) fn new() -> Self { + Self::generate().expect("OS RNG entropy source failed in a test") + } +} + +/// 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, + } + } +} + +/// One durable claim-key reservation: the row +/// [`ShieldedStore::reserve_one_time_claim_key`] competes for. +/// +/// Separate from [`LifecycleAdmission`] because it answers a different +/// question. A lifecycle admission asks "may a claim run against this wallet at +/// all?" and is contended between claims and purges; this asks "which claimant +/// owns THIS invitation?" and is contended between claims of the same +/// invitation. A claim needs both, and they expire together — the reservation +/// carries its owner's `token` precisely so the lease's renew/release path can +/// keep it in lockstep. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct ClaimKeyLease { + /// The wallet the claim runs under. + pub wallet_id: WalletId, + /// The invitation's deterministic claim-record key (derived from its + /// one-time full viewing key). + pub claim_record_key: [u8; 32], + /// The claim lease that owns this reservation. + pub token: AdmissionToken, + /// Unix millis after which this reservation is dead and reapable. + pub expires_at: u64, +} + +/// The outcome of [`ShieldedStore::reserve_one_time_claim_key`], read back from +/// the durable row after the insert-if-absent attempt. +/// +/// Exactly one of any set of concurrent callers can observe +/// [`Self::Acquired`]; every other one observes [`Self::Held`] naming the +/// winner. That asymmetry is the whole point — a losing claimant must RESUME +/// the winner's durable pending-claim record or refuse, never replace it. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ClaimKeyReservation { + /// The requesting token owns the key. Also returned on idempotent + /// re-entry by the token that already owned it. + Acquired, + /// A different, still-live claimant owns the key. + Held { + /// The claim lease named on the durable row. + holder: AdmissionToken, + /// When that claimant's hold lapses, if it never releases. + expires_at: u64, + }, +} + +impl ClaimKeyReservation { + /// Whether the requesting token came away owning the key. + pub fn is_acquired(&self) -> bool { + matches!(self, Self::Acquired) + } +} + +/// Everything [`ShieldedStore::reserve_one_time_claim_key`] settles in its one +/// atomic step: who owns the invitation's record key, AND the durable +/// pending-claim row that already exists under it (if any). +/// +/// # Why the row rides along +/// +/// The claim path used to read the pending record separately, through +/// [`ShieldedStore::pending_redrives`]. In the file-backed store that reads an +/// in-memory mirror hydrated once at store OPEN, so a second store instance — +/// a second coordinator, or a second process — could not see a row a peer had +/// armed after that open. The consequence was not a stale read but a lost +/// identity: store A arms a claim, returns `ShieldedBroadcastUnconfirmed` and +/// releases its reservation; store B then acquires the freed reservation, +/// sees an empty mirror, builds a DIFFERENT padded transition and REPLACES +/// A's row via [`ShieldedStore::arm_redrive_under_claim`]. If A's transition +/// executes, its randomized identity id is gone for good +/// (#4313 review finding r3767229122). +/// +/// So the row is read from the durable state in the SAME transaction that +/// settles the reservation, and handed back here. A caller that receives +/// `Some` RESUMES that record; it must never arm a fresh one over it. +/// Implementations MUST source `pending` from durable state, never from a +/// startup-hydrated mirror. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ClaimKeyReservationOutcome { + /// Who owns the invitation's claim-record key after this attempt. + pub reservation: ClaimKeyReservation, + /// The durable pending-claim record already armed under + /// `claim_record_key`, read in the same atomic step. `None` when no + /// earlier attempt left one. + pub pending: Option, +} + +impl ClaimKeyReservationOutcome { + /// Whether the requesting token came away owning the key. + pub fn is_acquired(&self) -> bool { + self.reservation.is_acquired() + } } // ── Per-subwallet bookkeeping ────────────────────────────────────────── @@ -782,6 +1267,17 @@ 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, + /// Live one-time claim-key reservations — see [`ClaimKeyReservation`]. + /// The same total-order argument as `admissions` applies: the `&mut self` + /// step under the shared `RwLock` is the atomicity the protocol needs. + claim_key_reservations: Vec, } impl InMemoryShieldedStore { @@ -789,6 +1285,19 @@ impl InMemoryShieldedStore { pub fn new() -> Self { Self::default() } + + /// Push every claim-key reservation owned by `token` out to `expires_at`. + /// Free function over the field so it can run while a `&mut` borrow of + /// `admissions` is live. + fn restamp_claim_key_reservations( + reservations: &mut [ClaimKeyLease], + token: AdmissionToken, + expires_at: u64, + ) { + for reservation in reservations.iter_mut().filter(|r| r.token == token) { + reservation.expires_at = expires_at; + } + } } impl ShieldedStore for InMemoryShieldedStore { @@ -1039,6 +1548,195 @@ 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 { + // The claim-key gate, checked BEFORE the lease so a claimant that lost + // the key can never reach the `arm_redrive` overwrite below — see the + // trait docs. A live row under a different token means someone else + // owns this invitation. + if self.claim_key_reservations.iter().any(|r| { + r.wallet_id == id.wallet_id + && r.claim_record_key == redrive.activity_id + && r.expires_at > now_ms + && r.token != token + }) { + return Ok(false); + } + 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::restamp_claim_key_reservations( + &mut self.claim_key_reservations, + token, + now_ms.saturating_add(lease_ms), + ); + self.subwallets.entry(id).or_default().arm_redrive(redrive); + Ok(true) + } + + fn reserve_one_time_claim_key( + &mut self, + claim_records_id: SubwalletId, + claim_record_key: [u8; 32], + token: AdmissionToken, + now_ms: u64, + lease_ms: u64, + ) -> Result { + let wallet_id = claim_records_id.wallet_id; + // Reap first, so a dead claimant's row cannot hold an invitation + // hostage past its lease. One `&mut self` step covers reap, insert and + // read-back, which is the atomicity the trait requires. + self.claim_key_reservations + .retain(|r| r.expires_at > now_ms); + let expires_at = now_ms.saturating_add(lease_ms); + let reservation = match self + .claim_key_reservations + .iter_mut() + .find(|r| r.wallet_id == wallet_id && r.claim_record_key == claim_record_key) + { + // Someone else's live row: read it back and report the holder. The + // row is NOT touched — that is the whole guarantee. + Some(existing) if existing.token != token => ClaimKeyReservation::Held { + holder: existing.token, + expires_at: existing.expires_at, + }, + // Our own row: idempotent re-entry, re-stamped. + Some(existing) => { + existing.expires_at = expires_at; + ClaimKeyReservation::Acquired + } + None => { + self.claim_key_reservations.push(ClaimKeyLease { + wallet_id, + claim_record_key, + token, + expires_at, + }); + ClaimKeyReservation::Acquired + } + }; + // Read the pending-claim record in the SAME `&mut self` step. This + // store keeps no separate durable tier — the map IS the durable state — + // so reading it here is the atomicity the trait asks for, and the + // file-backed store's SQLite read is the equivalent + // (#4313 review finding r3767229122). + let pending = self + .subwallets + .get(&claim_records_id) + .and_then(|sw| sw.redrives.get(&claim_record_key).cloned()); + Ok(ClaimKeyReservationOutcome { + reservation, + pending, + }) + } + + fn renew_claim_admission( + &mut self, + token: AdmissionToken, + now_ms: u64, + lease_ms: u64, + ) -> Result { + // Same predicate arm_redrive_under_claim uses: a live, non-destructive + // lease under this exact token. Deliberately does NOT resurrect an + // expired one — a lapsed lease may already have been counted as absent + // by a purge, and silently reviving it would hide that. + 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); + // Keep the claim-key reservation in lockstep with the lease that owns + // it, so a long claim does not lose its invitation to expiry while its + // lease is being kept alive. + Self::restamp_claim_key_reservations( + &mut self.claim_key_reservations, + token, + now_ms.saturating_add(lease_ms), + ); + Ok(true) + } + + fn end_claim_admission(&mut self, token: AdmissionToken) -> Result<(), Self::Error> { + self.admissions + .retain(|a| a.destructive || a.token != token); + self.claim_key_reservations.retain(|r| r.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)] @@ -1124,6 +1822,7 @@ mod tests { nullifiers: vec![n1, n2], st_bytes: vec![1, 2, 3], attempts: 0, + identity_index: None, }; // Landing path: mark_spent on one nullifier drops the record. @@ -1415,4 +2114,186 @@ mod tests { "a reservation with no recorded anchor must not surface as stale" ); } + + // ---- claim-lease renewal (#4313 review finding 161a517fce36) ---- + + fn claim_token(b: u8) -> AdmissionToken { + AdmissionToken([b; 16]) + } + + /// THE BUG: without renewal a claim that outruns CLAIM_LEASE_MS is reaped, + /// so a purge sees zero live claims and proceeds to destroy state the + /// in-flight claim still needs. This is the negative control — it pins the + /// behaviour the renewal exists to prevent. + #[test] + fn an_unrenewed_lease_lapses_and_stops_holding_off_a_purge() { + let mut store = InMemoryShieldedStore::new(); + let wallet = [0xAA; 32]; + let token = claim_token(0x01); + let t0 = 1_000_000u64; + + assert!(store + .begin_claim_admission(wallet, token, t0, CLAIM_LEASE_MS) + .unwrap()); + // Still inside the lease: a purge must WAIT. + let live = store + .begin_destructive_admission(None, claim_token(0x99), t0 + 1, DESTRUCTIVE_BARRIER_MS) + .unwrap(); + assert_eq!(live, 1, "a live claim must hold off a purge"); + store.end_destructive_admission(claim_token(0x99)).unwrap(); + + // One millisecond past the lease, with no renewal, the claim is invisible. + let live_after = store + .begin_destructive_admission( + None, + claim_token(0x98), + t0 + CLAIM_LEASE_MS + 1, + DESTRUCTIVE_BARRIER_MS, + ) + .unwrap(); + assert_eq!( + live_after, 0, + "an unrenewed lease lapses — this is exactly what the heartbeat prevents" + ); + } + + /// THE FIX: renewing keeps the claim counted well past the original lease. + #[test] + fn a_renewed_lease_keeps_holding_off_a_purge_past_the_original_window() { + let mut store = InMemoryShieldedStore::new(); + let wallet = [0xAA; 32]; + let token = claim_token(0x02); + let t0 = 1_000_000u64; + + assert!(store + .begin_claim_admission(wallet, token, t0, CLAIM_LEASE_MS) + .unwrap()); + + // Three ticks at the heartbeat interval, as the claim path does. + let tick = CLAIM_LEASE_MS / 3; + let mut now = t0; + for _ in 0..3 { + now += tick; + assert!( + store + .renew_claim_admission(token, now, CLAIM_LEASE_MS) + .unwrap(), + "renewal must succeed while the lease is live" + ); + } + + // Past the ORIGINAL expiry, the claim is still counted. + assert!(now > t0 + CLAIM_LEASE_MS - tick); + let live = store + .begin_destructive_admission(None, claim_token(0x97), now + 1, DESTRUCTIVE_BARRIER_MS) + .unwrap(); + assert_eq!(live, 1, "a renewed claim must still hold off a purge"); + } + + /// Renewal must not RESURRECT a lease that already lapsed — a purge may + /// already have counted it absent and acted on that. + #[test] + fn renewal_refuses_to_resurrect_a_lapsed_lease() { + let mut store = InMemoryShieldedStore::new(); + let token = claim_token(0x03); + let t0 = 1_000_000u64; + assert!(store + .begin_claim_admission([0xAA; 32], token, t0, CLAIM_LEASE_MS) + .unwrap()); + assert!( + !store + .renew_claim_admission(token, t0 + CLAIM_LEASE_MS + 1, CLAIM_LEASE_MS) + .unwrap(), + "a lapsed lease must not come back" + ); + } + + /// An unknown token renews nothing, rather than minting a lease. + #[test] + fn renewing_an_unknown_token_is_false_not_a_new_lease() { + let mut store = InMemoryShieldedStore::new(); + assert!(!store + .renew_claim_admission(claim_token(0x04), 1_000_000, CLAIM_LEASE_MS) + .unwrap()); + } + + /// The in-memory store must give the SAME answer the file store's + /// `INSERT ... ON CONFLICT DO NOTHING` gives — the wallet layer branches on + /// this outcome and cannot tell the two backends apart (#4313 + /// cr-9d0e1a44). + #[test] + fn only_one_claimant_acquires_an_invitations_claim_key() { + let mut store = InMemoryShieldedStore::new(); + let wallet: WalletId = [0xAA; 32]; + let key = [0xC1; 32]; + let id = SubwalletId::new(wallet, u32::MAX); + let winner = claim_token(0x11); + let loser = claim_token(0x12); + let t0 = 1_000_000u64; + + // Both hold a per-wallet lease — that is not, and never was, mutual + // exclusion between two claimants of one invitation. + assert!(store + .begin_claim_admission(wallet, winner, t0, CLAIM_LEASE_MS) + .unwrap()); + assert!(store + .begin_claim_admission(wallet, loser, t0, CLAIM_LEASE_MS) + .unwrap()); + + assert_eq!( + store + .reserve_one_time_claim_key(id, key, winner, t0, CLAIM_LEASE_MS) + .unwrap() + .reservation, + ClaimKeyReservation::Acquired + ); + assert_eq!( + store + .reserve_one_time_claim_key(id, key, loser, t0, CLAIM_LEASE_MS) + .unwrap() + .reservation, + ClaimKeyReservation::Held { + holder: winner, + expires_at: t0 + CLAIM_LEASE_MS, + } + ); + + // The gate refuses the loser's record write, so the winner's row cannot + // be replaced. + let record = |b: u8| PendingRedrive { + activity_id: key, + anchor: [b; 32], + nullifiers: vec![[b; 32]], + st_bytes: vec![b; 8], + attempts: 0, + identity_index: None, + }; + assert!(store + .arm_redrive_under_claim(id, record(0xAA), winner, t0, CLAIM_LEASE_MS) + .unwrap()); + assert!( + !store + .arm_redrive_under_claim(id, record(0xBB), loser, t0, CLAIM_LEASE_MS) + .unwrap(), + "a claimant without the key must not be able to write this record" + ); + let records = store.pending_redrives(id).unwrap(); + assert_eq!(records.len(), 1); + assert_eq!(records[0].st_bytes, vec![0xAA; 8]); + + // Release hands the invitation straight over; no lease-length wait — + // and the winner's durable record comes back WITH the reservation, so + // the new owner resumes it instead of arming a second transition over + // it (#4313 review finding r3767229122). + store.end_claim_admission(winner).unwrap(); + let handover = store + .reserve_one_time_claim_key(id, key, loser, t0, CLAIM_LEASE_MS) + .unwrap(); + assert_eq!(handover.reservation, ClaimKeyReservation::Acquired); + assert_eq!( + handover.pending.as_ref().map(|r| r.st_bytes.clone()), + Some(vec![0xAA; 8]), + "acquiring a released key must hand back the record already armed under it" + ); + } } diff --git a/packages/rs-platform-wallet/src/wallet/shielded/sync.rs b/packages/rs-platform-wallet/src/wallet/shielded/sync.rs index 00f5b7db1d9..f9fe6fe8ba1 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; @@ -53,6 +53,26 @@ use crate::error::PlatformWalletError; /// here and there together if the chunk power ever changes. const CHUNK_SIZE: u64 = 2048; +/// Stream batches one foreign-key scan ATTEMPT may consume before pausing +/// with the retryable +/// [`PlatformWalletError::ShieldedForeignScanBudgetExhausted`]. +/// +/// The bound that closes dashpay/platform#4306: a syntactically valid but +/// never-funded invitation key can otherwise drive a genesis-to-tip scan — +/// "no note yet" and "note further ahead" are indistinguishable mid-stream — +/// so a hostile link costs an unbounded trial-decryption walk per attempt. +/// With the budget, each attempt costs at most `FOREIGN_SCAN_BATCH_BUDGET` +/// batches (a batch is at least one 2048-note MMR chunk, so ≥ ~260k trial +/// decryptions — generously past any realistic honest claim) and progress is +/// checkpointed, so attempts COMPOUND instead of restarting: a genuinely +/// deep note is still reached across retries, while the hostile case pays +/// bounded work per attempt no matter what the link claims. +/// +/// The invite's birth-height hint cannot replace this: heights don't map to +/// tree positions here (see [`scan_notes_for_foreign_key`]) — and the hint +/// arrives in the attacker-controlled link anyway. +pub(crate) const FOREIGN_SCAN_BATCH_BUDGET: u64 = 128; + /// Result of one note-sync pass. #[derive(Debug, Clone, Default)] pub struct SyncNotesResult { @@ -799,6 +819,349 @@ 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 bounded work per attempt, +/// never a restart (#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. +/// +/// Each ATTEMPT is additionally budgeted at [`FOREIGN_SCAN_BATCH_BUDGET`] +/// stream batches (#4306): exhausting the budget before the value is covered +/// checkpoints the position reached and returns the retryable +/// [`PlatformWalletError::ShieldedForeignScanBudgetExhausted`] instead of +/// scanning on — hosts render it as "still searching, retry", never as an +/// invalid or unfunded invitation. +/// +/// [`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 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); + scan_foreign_stream_with_budget( + stream, + checkpoints, + checkpoint_key, + fvk, + stop_at_value, + aligned_start, + found, + total, + FOREIGN_SCAN_BATCH_BUDGET, + ) + .await +} + +/// The budgeted consumption loop of [`scan_notes_for_foreign_key`], generic +/// over the batch stream so the budget/checkpoint behavior is unit-testable +/// without a network (`sync_shielded_notes_stream` is the sole production +/// stream). +/// +/// Consumes at most `batch_budget` batches per call. Exhausting the budget +/// before the value is covered checkpoints `scanned_through` and returns the +/// RETRYABLE [`PlatformWalletError::ShieldedForeignScanBudgetExhausted`] — the +/// next attempt resumes from the checkpoint, so attempts compound (see +/// [`FOREIGN_SCAN_BATCH_BUDGET`] for the #4306 threat model). A partial +/// (buffer) batch is end-of-stream, so it never trips the budget — an +/// exhausted-tree scan returns `Ok` with whatever was found, exactly as +/// before. +#[allow(clippy::too_many_arguments)] +async fn scan_foreign_stream_with_budget( + stream: St, + checkpoints: &ForeignScanCheckpointCache, + checkpoint_key: [u8; 32], + fvk: &grovedb_commitment_tree::FullViewingKey, + stop_at_value: u64, + aligned_start: u64, + mut found: Vec, + mut total: u64, + batch_budget: u64, +) -> Result, PlatformWalletError> +where + St: futures::Stream< + Item = Result, + >, + E: std::fmt::Display, +{ + 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; + let mut batches_consumed: u64 = 0; + 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())); + } + }; + let batch_is_partial = batch.is_partial; + 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; + } + // Per-attempt work bound (#4306). Checked AFTER the value test so a + // budget's final batch covering the value still completes normally, + // and never on a partial batch — that is end-of-stream, where the + // ordinary exhausted-tree return below is the right outcome. + batches_consumed += 1; + if !batch_is_partial && batches_consumed >= batch_budget { + checkpoints.save( + checkpoint_key, + foreign_scan_checkpoint_below(scanned_through, &found), + ); + return Err(PlatformWalletError::ShieldedForeignScanBudgetExhausted { + scanned_through, + }); + } + } + + 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 +1361,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 @@ -1013,6 +1503,11 @@ mod tests { #[cfg(test)] mod ovk_recovery_tests; +/// Budget/checkpoint behavior of the foreign-key claim scan (#4306): +/// per-attempt bound, retryable pause, resume-not-restart. +#[cfg(test)] +mod foreign_scan_budget_tests; + /// Round-trip guard for the Type 15 client pair: the shield builder's /// serialized actions must trial-decrypt under the same keyset's IVK /// (the chain stores them verbatim, so this covers the full path). diff --git a/packages/rs-platform-wallet/src/wallet/shielded/sync/foreign_scan_budget_tests.rs b/packages/rs-platform-wallet/src/wallet/shielded/sync/foreign_scan_budget_tests.rs new file mode 100644 index 00000000000..039e522d2a7 --- /dev/null +++ b/packages/rs-platform-wallet/src/wallet/shielded/sync/foreign_scan_budget_tests.rs @@ -0,0 +1,209 @@ +//! Budget/checkpoint behavior of the foreign-key (one-time-invitation) +//! note scan — dashpay/platform#4306. +//! +//! Exercises the extracted, stream-generic +//! [`super::scan_foreign_stream_with_budget`] with synthetic +//! [`ShieldedChunkBatch`]es, the same way the Part-A tests exercise +//! `apply_scanned_nullifier_spends` instead of the full network path: +//! `sync_shielded_notes_stream` is the sole production stream, and nothing +//! here depends on how it fetches. + +use dash_sdk::platform::shielded::notes_sync::types::ShieldedChunkBatch; +use dashcore::Network; +use drive_proof_verifier::types::ShieldedEncryptedNote; +use futures::stream; + +use super::{ + foreign_scan_checkpoint_key, scan_foreign_stream_with_budget, ForeignScanCheckpointCache, + CHUNK_SIZE, +}; +use crate::error::PlatformWalletError; +use crate::wallet::shielded::keys::OrchardKeySet; + +/// Any deterministic keyset works — the FVK is only consulted to derive +/// nullifiers for decrypted notes, and these batches carry none. +fn fvk() -> grovedb_commitment_tree::FullViewingKey { + OrchardKeySet::from_seed(&[0x42; 32], Network::Testnet, 0) + .expect("ZIP-32 derivation from a fixed seed should succeed") + .full_viewing_key +} + +/// A wire note whose contents never matter — the driver IVK decrypted +/// nothing, so only `notes.len()` (the chunk's coverage) is read. +fn wire_note() -> ShieldedEncryptedNote { + ShieldedEncryptedNote { + cmx: vec![0u8; 32], + nullifier: vec![0u8; 32], + cv_net: vec![0u8; 32], + encrypted_note: vec![0u8; 216], + } +} + +/// A FULL chunk batch at `start_index` covering exactly [`CHUNK_SIZE`] +/// notes, none of which decrypted under the driver key. +fn full_batch(start_index: u64) -> ShieldedChunkBatch { + ShieldedChunkBatch { + start_index, + notes: (0..CHUNK_SIZE).map(|_| wire_note()).collect(), + decrypted: Vec::new(), + block_height: 7, + is_partial: false, + total_count: 0, + } +} + +/// The final (buffer) chunk — a short read signalling end-of-stream. +fn partial_batch(start_index: u64) -> ShieldedChunkBatch { + ShieldedChunkBatch { + start_index, + notes: vec![wire_note()], + decrypted: Vec::new(), + block_height: 7, + is_partial: true, + total_count: 0, + } +} + +type BatchResult = Result; + +/// THE #4306 guarantee: a valid-but-unfunded key cannot drive an unbounded +/// walk. The scan pauses at its per-attempt batch budget with the RETRYABLE +/// typed error, the checkpoint records exactly how far it got, and the next +/// attempt resumes from that position instead of restarting — so attempts +/// compound toward a genuinely deep note while each stays bounded. +#[tokio::test] +async fn budget_exhaustion_pauses_with_checkpoint_and_the_retry_resumes() { + let fvk = fvk(); + let checkpoints = ForeignScanCheckpointCache::default(); + let key = foreign_scan_checkpoint_key(&fvk); + + // Attempt 1: four full chunks available, budget of two. + let batches: Vec = (0..4).map(|i| Ok(full_batch(i * CHUNK_SIZE))).collect(); + let err = scan_foreign_stream_with_budget( + stream::iter(batches), + &checkpoints, + key, + &fvk, + u64::MAX, // value never covered — the unfunded-key shape + 0, + Vec::new(), + 0, + 2, + ) + .await + .expect_err("exhausting the budget before the value must pause, not scan on"); + + // Two full chunks were consumed, so coverage reached 2 × CHUNK_SIZE. + let paused_at = match err { + PlatformWalletError::ShieldedForeignScanBudgetExhausted { scanned_through } => { + scanned_through + } + other => panic!("expected ShieldedForeignScanBudgetExhausted, got {other:?}"), + }; + assert_eq!(paused_at, 2 * CHUNK_SIZE); + assert_eq!( + checkpoints + .load(&key) + .expect("pause must checkpoint its progress") + .resume_position, + 2 * CHUNK_SIZE, + "the checkpoint and the error must agree on how far the scan got" + ); + + // Attempt 2 — as the production caller would run it: resume from the + // checkpoint, feed the REMAINING chunks, and let the tree end. The + // exhausted tree is the ordinary Ok(found) return, not a budget pause. + let resume = checkpoints.load(&key).unwrap().resume_position; + let remaining: Vec = vec![ + Ok(full_batch(resume)), + Ok(full_batch(resume + CHUNK_SIZE)), + Ok(partial_batch(resume + 2 * CHUNK_SIZE)), + ]; + let found = scan_foreign_stream_with_budget( + stream::iter(remaining), + &checkpoints, + key, + &fvk, + u64::MAX, + resume, + Vec::new(), + 0, + // Budget 3: the scan cannot know a NEXT batch is the last, so ending + // within budget means strictly fewer FULL batches than the budget — + // two fulls under a budget of two would pause again (correctly; the + // next attempt's first batch would be the partial). + 3, + ) + .await + .expect("an exhausted tree returns Ok with whatever was found"); + assert!(found.is_empty(), "nothing decrypted — the key is unfunded"); + + // The buffer chunk may still receive notes, so the checkpoint holds AT + // its start — the next attempt rescans only the mutable chunk. + assert_eq!( + checkpoints.load(&key).unwrap().resume_position, + resume + 2 * CHUNK_SIZE + ); +} + +/// A partial batch is end-of-stream: it must never trip the budget, or a +/// one-chunk tree scanned with a one-batch budget would loop "retry" forever +/// without ever reaching the ordinary exhausted-tree return. +#[tokio::test] +async fn the_final_partial_batch_never_trips_the_budget() { + let fvk = fvk(); + let checkpoints = ForeignScanCheckpointCache::default(); + let key = foreign_scan_checkpoint_key(&fvk); + + let batches: Vec = vec![Ok(partial_batch(0))]; + let found = scan_foreign_stream_with_budget( + stream::iter(batches), + &checkpoints, + key, + &fvk, + u64::MAX, + 0, + Vec::new(), + 0, + 1, // tightest possible budget + ) + .await + .expect("a stream that ENDS within budget is the ordinary exhausted-tree return"); + assert!(found.is_empty()); + assert_eq!(checkpoints.load(&key).unwrap().resume_position, 0); +} + +/// A mid-stream error still checkpoints the progress made before it — the +/// pre-existing contract, re-pinned here because the budget refactor moved +/// the loop into the stream-generic helper. +#[tokio::test] +async fn a_stream_error_checkpoints_partial_progress() { + let fvk = fvk(); + let checkpoints = ForeignScanCheckpointCache::default(); + let key = foreign_scan_checkpoint_key(&fvk); + + let batches: Vec> = vec![ + Ok(full_batch(0)), + Err("connection reset".to_string()), + Ok(full_batch(CHUNK_SIZE)), + ]; + let err = scan_foreign_stream_with_budget( + stream::iter(batches), + &checkpoints, + key, + &fvk, + u64::MAX, + 0, + Vec::new(), + 0, + 16, + ) + .await + .expect_err("the stream error must surface"); + assert!(matches!(err, PlatformWalletError::ShieldedSyncFailed(_))); + assert_eq!( + checkpoints.load(&key).unwrap().resume_position, + CHUNK_SIZE, + "the retry after a stream error resumes past the chunk already covered" + ); +} diff --git a/packages/rs-platform-wallet/src/wallet/shielded/viewing_key_bind_tests.rs b/packages/rs-platform-wallet/src/wallet/shielded/viewing_key_bind_tests.rs index 600c5fbbbde..eb74bb5fb92 100644 --- a/packages/rs-platform-wallet/src/wallet/shielded/viewing_key_bind_tests.rs +++ b/packages/rs-platform-wallet/src/wallet/shielded/viewing_key_bind_tests.rs @@ -365,9 +365,14 @@ async fn bind_after_wallet_removal_is_refused() { .expect("first bind succeeds"); assert_eq!(coordinator.registered_subwallets().await.len(), 1); - // What `PlatformWalletManager::remove_wallet` does to this handle. - wallet.mark_shielded_detached(); - coordinator.unregister_wallet(wallet.wallet_id()).await; + // What `PlatformWalletManager::remove_wallet` does to this handle: the + // detach mark runs inside the coordinator's critical section, after + // destructive admission is secured and before any registry is cleared + // (#4313 review finding coordinator.rs:805). + coordinator + .unregister_wallet_with(wallet.wallet_id(), || wallet.mark_shielded_detached()) + .await + .expect("no claim holds admission in this test"); let persisted_before = persister.stored_count(); let err = wallet 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..b3c2d5ff2d9 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletResult.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletResult.swift @@ -141,6 +141,53 @@ 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 + /// A one-time-key (shielded invitation) claim's transient note scan hit + /// its per-attempt work budget before finding the invitation's funding + /// note. Progress is checkpointed, so a retry RESUMES rather than + /// restarts. + /// + /// RETRYABLE and cheap to retry — the opposite pole from + /// `errorShieldedInviteAlreadyClaimed` (43). Nothing was spent, built or + /// broadcast; the scan simply has not looked far enough yet. Render it as + /// "still searching — try again", NEVER as an invalid, unfunded or + /// already-claimed invitation: treating it as terminal strands a genuinely + /// funded claim whose note sits deep in the tree. + /// + /// Raw value 44 — the frontier past the frozen 43. MUST match + /// `ErrorShieldedScanBudgetExhausted` in + /// packages/rs-platform-wallet-ffi/src/error.rs. + case errorShieldedScanBudgetExhausted = 44 + /// A shielded lifecycle operation was refused admission at the store + /// rather than allowed to run concurrently with whatever holds it. Two + /// directions reach this one code: a one-time-key claim refused because a + /// clear / wallet-removal holds destructive admission over its wallet (or + /// another claimant already holds this invitation's claim-record key), and + /// a destructive operation refused because in-flight claims did not drain + /// within its wait. + /// + /// RETRYABLE in both directions, and nothing was consumed: the claim + /// direction scanned, built and broadcast nothing; the destructive + /// direction purged nothing. Render it as "busy — try again", never as an + /// invalid or already-claimed invitation and never as a failed wipe. + /// + /// Raw value 45 — the next free integer past 44 (28, 30, 32 and 33 are + /// RESERVED, not reissuable). MUST match `ErrorShieldedLifecycleBusy` in + /// packages/rs-platform-wallet-ffi/src/error.rs. + case errorShieldedLifecycleBusy = 45 /// 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 +285,12 @@ 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_ERROR_SHIELDED_SCAN_BUDGET_EXHAUSTED: + self = .errorShieldedScanBudgetExhausted + case PLATFORM_WALLET_FFI_RESULT_CODE_ERROR_SHIELDED_LIFECYCLE_BUSY: + self = .errorShieldedLifecycleBusy case PLATFORM_WALLET_FFI_RESULT_CODE_NOT_FOUND: self = .notFound case PLATFORM_WALLET_FFI_RESULT_CODE_ERROR_UNKNOWN: @@ -418,6 +471,26 @@ 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) + /// A one-time-key (shielded invitation) claim's transient note scan + /// consumed its per-attempt work budget before finding the funding note. + /// Progress is checkpointed, so a retry RESUMES the scan rather than + /// restarting it. RETRYABLE and cheap to retry — nothing was spent, built + /// or broadcast. Surface it as "still searching", never as an invalid or + /// already-claimed invitation. + case shieldedScanBudgetExhausted(String) + /// A shielded lifecycle operation was refused admission at the store: a + /// claim contending with a clear / wallet-removal (or with another + /// claimant of the same invitation), or a destructive operation whose + /// in-flight claims did not drain in time. RETRYABLE — nothing was + /// consumed, purged or broadcast. Surface it as "busy — try again". + case shieldedLifecycleBusy(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 +525,8 @@ public enum PlatformWalletError: LocalizedError { .staleReservationToken(let m), .reservationTokenConsumed(let m), .reservationWalletMismatch(let m), .notForSale(let m), + .shieldedInviteAlreadyClaimed(let m), + .shieldedScanBudgetExhausted(let m), .shieldedLifecycleBusy(let m), .notFound(let m), .unknown(let m): return m // The three value-carrying marketplace rejections compose their @@ -561,6 +636,12 @@ public enum PlatformWalletError: LocalizedError { } else { self = .unknown(detail) } + case .errorShieldedInviteAlreadyClaimed: + self = .shieldedInviteAlreadyClaimed(detail) + case .errorShieldedScanBudgetExhausted: + self = .shieldedScanBudgetExhausted(detail) + case .errorShieldedLifecycleBusy: + self = .shieldedLifecycleBusy(detail) case .notFound: self = .notFound(detail) case .errorUnknown: self = .unknown(detail) } diff --git a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/ErrorHandlingTests.swift b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/ErrorHandlingTests.swift index 799617ae93c..c1123ed03bf 100644 --- a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/ErrorHandlingTests.swift +++ b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/ErrorHandlingTests.swift @@ -56,6 +56,55 @@ final class ErrorHandlingTests: XCTestCase { ) } + /// The two shielded-lifecycle codes added on dashpay/platform#4313 must + /// cross the ABI with their own raw values. There is no compile-time check + /// between `PlatformWalletFFIResultCode` (Rust) and + /// `PlatformWalletResultCode` (Swift), so a drifted number silently + /// reclassifies a retryable refusal as something else — which is the exact + /// failure both codes were introduced to end. + func testShieldedLifecycleFFIResultMappings() { + XCTAssertEqual( + PlatformWalletResultCode( + ffi: PLATFORM_WALLET_FFI_RESULT_CODE_ERROR_SHIELDED_SCAN_BUDGET_EXHAUSTED + ), + .errorShieldedScanBudgetExhausted + ) + XCTAssertEqual(PlatformWalletResultCode.errorShieldedScanBudgetExhausted.rawValue, 44) + + XCTAssertEqual( + PlatformWalletResultCode( + ffi: PLATFORM_WALLET_FFI_RESULT_CODE_ERROR_SHIELDED_LIFECYCLE_BUSY + ), + .errorShieldedLifecycleBusy + ) + XCTAssertEqual(PlatformWalletResultCode.errorShieldedLifecycleBusy.rawValue, 45) + + // Both must reach the typed error family, not `.unknown` — hosts branch + // on the case, and both are RETRYABLE. + let budget = PlatformWalletError( + code: .errorShieldedScanBudgetExhausted, + message: "scanned 250000 positions without finding the funding note" + ) + guard case .shieldedScanBudgetExhausted(let budgetMessage) = budget else { + return XCTFail("expected typed shieldedScanBudgetExhausted error") + } + XCTAssertEqual( + budgetMessage, + "scanned 250000 positions without finding the funding note" + ) + XCTAssertEqual(budget.errorDescription, budgetMessage) + + let busy = PlatformWalletError( + code: .errorShieldedLifecycleBusy, + message: "a one-time-key claim is still in flight" + ) + guard case .shieldedLifecycleBusy(let busyMessage) = busy else { + return XCTFail("expected typed shieldedLifecycleBusy error") + } + XCTAssertEqual(busyMessage, "a one-time-key claim is still in flight") + XCTAssertEqual(busy.errorDescription, busyMessage) + } + func testShieldedInsufficientBalanceFFIResultMapping() { XCTAssertEqual( PlatformWalletResultCode(