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 9f19d1653a0..fe3bf5ded69 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 @@ -322,7 +322,10 @@ sealed class DashSdkError( ) : PlatformWallet( "$message (identity may already exist on-chain; hold the slot — do NOT retry)", cause, - ) + ) { + /** The native diagnostic, without the pool path's retry advice. */ + internal val diagnostic: String = message + } /** * `ErrorTransactionBroadcastUnconfirmed` (native code 20). A core @@ -520,6 +523,44 @@ 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 + } + /** * `ErrorPersisterLoadTransient` (native code 49). Reading persisted * wallet state failed on a store that reported the failure as @@ -636,6 +677,90 @@ sealed class DashSdkError( 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 + } + + /** + * `ErrorShieldedClaimUnconfirmed` (native code 61, + * dashpay/platform#4313). A panic was caught inside the + * one-time-key (shielded invitation) claim, so the outcome is + * AMBIGUOUS: the panic can strike after the Type-20 transition + * reached the wire, meaning the transition may already have + * executed and the identity may already exist on chain. No + * identity id is produced (unlike [ShieldedCreateUnconfirmed], + * whose contract delivers one). + * + * RETRYABLE ([isRetryable] `true`) — as a RESUME, not a fresh + * attempt, and not immediately. The claim's durable recovery + * record survives in the SDK (it is the only holder of the + * claim's padded identity id), and re-running the SAME + * invitation claim resumes it: the rerun recovers the identity + * the first attempt created rather than creating a second one. + * The host MUST preserve the local identity slot and retry after + * the claim lease expires — the panic unwound past the lease + * release, so an immediate attempt is refused as + * [ShieldedLifecycleBusy]. Never surface this as terminal and + * never release the slot: either mistake can strand an identity + * that already exists on chain. Before this code, the guard + * reported the generic native 99, which arrived here as a + * non-retryable [Generic] — exactly the slot-forfeiting + * misclassification this type exists to end (#4313 review + * finding 4bf998e99652). + */ + class ShieldedClaimUnconfirmed( + message: String, + cause: Throwable? = null, + /** + * The claim's identity id when the SDK knows it: set for an + * ordinary ambiguous broadcast or result wait (native code 17 on + * the claim path), null for a caught panic (code 61). Either way + * the recovery is the same: keep the slot and re-run the SAME + * claim, which resumes the persisted transition instead of + * creating a second identity. + */ + val identityId: ByteArray? = null, + ) : PlatformWallet(message, cause) { + override val isRetryable: Boolean get() = true + } + + /** + * `ErrorShieldedClaimBindingMismatch` (native code 62, + * dashpay/platform#4313). A RETRY of a one-time-key (shielded + * invitation) claim presented arguments — keys, identity slot, + * denomination or fallback address — that differ from the + * transition the earlier attempt already submitted. + * + * FAIL-CLOSED and ARGUMENT-CORRECTABLE. Nothing was rebroadcast and + * no proof was spent; the SDK kept the claim's durable recovery + * record. Repeating the SAME call fails the same way (hence the + * inherited [isRetryable] `false`), but re-running the claim with + * the ORIGINAL arguments resumes the record. Hosts MUST keep the + * identity slot the original attempt used and MUST NOT surface the + * invitation as spent or invalid. Before this code it arrived as the + * generic [WalletOperation], which hosts treat as a hard failure. + */ + class ShieldedClaimBindingMismatch(message: String, cause: Throwable? = null) : + PlatformWallet(message, cause) + /** * `ErrorSpvProcessRestartRequired` (native code 59). SPV startup or * teardown panicked. Not retryable: restart the app process. @@ -827,6 +952,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) // Persister failures, operation x retry classification. These are // exactly the "retry-semantics-bearing" codes this mapping exists // for: only the two transients are retryable, and a constraint is @@ -842,6 +984,18 @@ sealed class DashSdkError( 56 -> PlatformWallet.ShieldedRecoveryCorrupted(message, cause) 57 -> PlatformWallet.ShieldedRecoveryKeysRequired(message, cause) 59 -> PlatformWallet.SpvProcessRestartRequired(message, cause) // ErrorSpvProcessRestartRequired + // ErrorShieldedClaimUnconfirmed (#4313) — a panic caught inside + // the one-time-key claim: outcome ambiguous (the transition may + // already be on chain), retryable as a RESUME of the retained + // recovery record once the claim lease expires. Preserve the + // identity slot. 61 is from the registry frontier (first claimed + // as 48, which #4356's ErrorAssetLockInputContested holds on + // v5.1-dev). + 61 -> PlatformWallet.ShieldedClaimUnconfirmed(message, cause) + // ErrorShieldedClaimBindingMismatch (#4313) — a claim retry whose + // arguments differ from the submitted transition. Fail-closed and + // argument-correctable: rerun with the original arguments. + 62 -> PlatformWallet.ShieldedClaimBindingMismatch(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 fb82e03ccb3..3d7ffa0448e 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 @@ -205,6 +205,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 8a0f4fd93e6..9e8d61c404e 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 @@ -621,7 +621,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 318c3700ee1..1e80d7b3b65 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 @@ -1217,7 +1217,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) @@ -1272,7 +1272,7 @@ class WalletStorage( throw e } catch (e: KeyPermanentlyInvalidatedException) { throw e - } catch (e: GeneralSecurityException) { + } catch (_: GeneralSecurityException) { null } @@ -1572,7 +1572,7 @@ class WalletStorage( unaeProvesRecoverable } 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 5f074a73d5b..32782d48748 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 @@ -1924,6 +1924,66 @@ 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" } + // Sign check only (Long -> u64 across JNI). Denomination validity and + // the non-empty key set are Rust's rules and come back as native errors. + require(denomination >= 0) { "denomination must be non-negative, got $denomination" } + require(fallbackAddress.size == 21) { + "fallbackAddress must be 21 bytes, got ${fallbackAddress.size}" + } + 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, + ) + } + decodeShieldedClaimPayload(packed) + } + /** * Resume a stuck shielded fund-from-asset-lock from an already-tracked * lock — port of Swift's `shieldedResumeFundFromAssetLock`. @@ -2827,6 +2887,71 @@ 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 { + // Handle-less, so this may be the first SDK call in the process. + Sdk.initialize() + 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}" } + Sdk.initialize() + return mapNativeErrors { FundingNative.orchardAddressFromSpendingKey(spendingKey) } +} + /** * Per-wallet seedless-unlock status — Swift `DashPayUnlockStatus`. * Published on [PlatformWalletManager.dashPayUnlockStatus]; drives the @@ -2935,3 +3060,21 @@ internal fun decodeShieldedCreatePayload(packed: ByteArray): ByteArray { } return identityId } + +/** + * [decodeShieldedCreatePayload] for the one-time-key (invitation) CLAIM. Same + * tagged layout, different recovery contract: an ambiguous claim is resumed by + * re-running the SAME claim, which replays the persisted transition and + * finishes the registration. So tag 1 becomes the retryable + * [DashSdkError.PlatformWallet.ShieldedClaimUnconfirmed] (carrying the + * identity id), not the pool path's "do NOT retry" + * [DashSdkError.PlatformWallet.ShieldedCreateUnconfirmed]. + */ +internal fun decodeShieldedClaimPayload(packed: ByteArray): ByteArray = try { + decodeShieldedCreatePayload(packed) +} catch (e: DashSdkError.PlatformWallet.ShieldedCreateUnconfirmed) { + throw DashSdkError.PlatformWallet.ShieldedClaimUnconfirmed( + message = e.diagnostic, + identityId = e.identityId, + ) +} 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 fd09ae4051a..a59dd3a3fdb 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 @@ -129,6 +129,81 @@ 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, + ) + + // Code 61 (ErrorShieldedClaimUnconfirmed, dashpay/platform#4313): a + // panic caught inside the one-time-key claim — the outcome is + // ambiguous (the transition may already be on chain) and the SDK + // retains the recovery record, so a delayed rerun of the SAME claim + // RESUMES it. The retryability polarity is the entire contract: this + // code used to surface as native 99 → non-retryable Generic, which + // told hosts to release the identity slot the retained record needs. + val claimUnconfirmed = + DashSdkError.fromNative( + DashSDKException(offset + 61, "claim may or may not have been broadcast"), + ) + assertTrue(claimUnconfirmed is DashSdkError.PlatformWallet.ShieldedClaimUnconfirmed) + assertTrue( + "ShieldedClaimUnconfirmed is RETRYABLE — the rerun resumes the retained record", + claimUnconfirmed.isRetryable, + ) + + // Code 62 (ErrorShieldedClaimBindingMismatch, dashpay/platform#4313): + // a claim retry with arguments that differ from the submitted + // transition. Typed (not the generic WalletOperation it used to + // flatten to), and not retryable UNCHANGED — the correction is to + // rerun with the original arguments. + val bindingMismatch = + DashSdkError.fromNative( + DashSDKException(offset + 62, "identity_index 3 != recorded 1"), + ) + assertTrue(bindingMismatch is DashSdkError.PlatformWallet.ShieldedClaimBindingMismatch) + assertFalse( + "repeating the same mismatched call cannot succeed", + bindingMismatch.isRetryable, + ) + val broadcastUnconfirmed = DashSdkError.fromNative(DashSDKException(offset + 20, "ambiguous broadcast")) assertTrue( diff --git a/packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/wallet/OneTimeOrchardKeyTest.kt b/packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/wallet/OneTimeOrchardKeyTest.kt new file mode 100644 index 00000000000..69d9ade5b90 --- /dev/null +++ b/packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/wallet/OneTimeOrchardKeyTest.kt @@ -0,0 +1,37 @@ +package org.dashfoundation.dashsdk.wallet + +import org.junit.Assert.assertFalse +import org.junit.Assert.assertThrows +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * The one-time Orchard key helpers are handle-less and may be the first SDK + * call in a process. Without the native library they must fail inside + * [org.dashfoundation.dashsdk.Sdk.initialize] (the load step), not with an + * [UnsatisfiedLinkError] from the external fun itself. This host JVM has no + * `libdash_sdk_jni.so`, so the load is what fails here. + */ +class OneTimeOrchardKeyTest { + private fun assertFailsInLoad(nativeMethod: String, call: () -> Unit) { + val error = assertThrows(Throwable::class.java) { call() } + assertTrue( + "expected the library load to fail, got ${error::class.simpleName}: ${error.message}", + error is UnsatisfiedLinkError && error.message?.contains("dash_sdk_jni") == true, + ) + assertFalse( + "reached the external fun before loading the library", + error.message?.contains(nativeMethod) == true, + ) + } + + @Test + fun `generateOneTimeOrchardKey initializes the native library before entering JNI`() = + assertFailsInLoad("generateOneTimeOrchardKey") { generateOneTimeOrchardKey() } + + @Test + fun `orchardAddressFromSpendingKey initializes the native library before entering JNI`() = + assertFailsInLoad("orchardAddressFromSpendingKey") { + orchardAddressFromSpendingKey(ByteArray(32)) + } +} diff --git a/packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/wallet/ShieldedCreatePayloadTest.kt b/packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/wallet/ShieldedCreatePayloadTest.kt index 97cd6209589..abc9247edb2 100644 --- a/packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/wallet/ShieldedCreatePayloadTest.kt +++ b/packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/wallet/ShieldedCreatePayloadTest.kt @@ -59,6 +59,28 @@ class ShieldedCreatePayloadTest { ) } + /** + * The invitation CLAIM's ambiguous outcome is resumed by re-running the + * same claim, so it must decode to the RETRYABLE claim type carrying the + * id, never the pool path's "do NOT retry" (#4313 review). + */ + @Test + fun claimUnconfirmedIsRetryableResumeWithIdAndDiagnostic() { + val diagnostic = "proof wait timed out after 3 retries" + val packed = byteArrayOf(1) + id + diagnostic.encodeToByteArray() + val e = runCatching { decodeShieldedClaimPayload(packed) }.exceptionOrNull() + val unconfirmed = e as? DashSdkError.PlatformWallet.ShieldedClaimUnconfirmed + assertTrue("expected ShieldedClaimUnconfirmed, got $e", unconfirmed != null) + assertTrue(unconfirmed!!.isRetryable) + assertArrayEquals(id, unconfirmed.identityId) + assertEquals(diagnostic, unconfirmed.message) + } + + @Test + fun claimSuccessReturnsTheIdentityId() { + assertArrayEquals(id, decodeShieldedClaimPayload(byteArrayOf(0) + id)) + } + @Test fun shortPayloadIsRejected() { val e = runCatching { decodeShieldedCreatePayload(ByteArray(32)) }.exceptionOrNull() diff --git a/packages/rs-platform-wallet-ffi/ERROR_CODE_REGISTRY.md b/packages/rs-platform-wallet-ffi/ERROR_CODE_REGISTRY.md index 211a7d709ca..86cf0f06c02 100644 --- a/packages/rs-platform-wallet-ffi/ERROR_CODE_REGISTRY.md +++ b/packages/rs-platform-wallet-ffi/ERROR_CODE_REGISTRY.md @@ -114,7 +114,7 @@ These are shipped ABI. Do not renumber. | 98 | `NotFound` | Sentinel — `Option` returned as an error | | 99 | `ErrorUnknown` | Sentinel — unmapped/flattened errors | -**Next allocatable integer: 61** — 27–60 are all claimed (27, 29, 31, 34–42 +**Next allocatable integer: 63** — 27–62 are all claimed (27, 29, 31, 34–42 and 46 merged; 43–45 proposed by active #4313 at head `0302b188ab`; 47 and 48 proposed by active #4356 (47 renumbered from 42, 48 from 43 — see their rows below); 49–54 proposed by active #4586 (the persister @@ -123,8 +123,11 @@ shield debits and durable recovery errors; 58 proposed by #4799 for an unavailab identity balance response; 59 proposed by #5322 for an SPV teardown that needs a process restart; 60 proposed by the pooled Core-to-shielded wallet drain for `ErrorAssetLockTooManyInputs` (Rust, Swift and Kotlin mirrors — -renumbered from 59 when #5322 took that number on `v5.1-dev`); 28, 30, -32 and 33 reserved). **28, 30, +renumbered from 59 when #5322 took that number on `v5.1-dev`); 61 proposed by +active #4313 for `ErrorShieldedClaimUnconfirmed` (renumbered from 48 when the +branch merged `v5.1-dev`, where #4356's `ErrorAssetLockInputContested` holds 48); +62 proposed by active #4313 for `ErrorShieldedClaimBindingMismatch`; +28, 30, 32 and 33 reserved). **28, 30, 32 and 33 are RESERVED, not free**: 28 and 30 were vacated when the reservation trio moved to 34–36; 32 and 33 lapsed when their in-repo owners (#4310, #4311) closed without merging. All four are deliberately left @@ -150,8 +153,8 @@ merged table); three others — #4316, #4310 and #4311 — **closed without merging** (32 and 33 lapse to RESERVED; 29 passed to #4361, which **merged** on 2026-08-26 — it is in the merged table now, and 29 is ABI under rule 3); and #4313 (the shielded-invite claim) lost 37 to merged #4348, revived, and -now holds 43–45 from the frontier at head `0302b188ab` — see its three rows -below. +now holds 43–45 (from the frontier at head `0302b188ab`) plus 48 (claimed +2026-08-28, again from the frontier) — see its four rows below. Fork-era numbers remain in the collision history, which is immutable record. | Code | Name | Owning PR | Status | @@ -177,6 +180,8 @@ Fork-era numbers remain in the collision history, which is immutable record. | 58 | `ErrorIdentityBalanceUnavailable` | #4799 | Proposed — Platform returned no balance for a managed identity. Retrying the read is safe; this is distinct from missing wallet ownership and must not trigger registration or funding | | 59 | `ErrorSpvProcessRestartRequired` | #5322 | Proposed — SPV startup or teardown panicked; restart the host process. Not retryable, unlike 27, which SPV start, stop and storage clear now return while an SPV teardown is still running. Typed in Rust, Swift and Kotlin (the PR also types 27 on Kotlin) | | 60 | `ErrorAssetLockTooManyInputs` | Core-to-shielded pooled Max | Proposed — an asset lock needs more inputs than one may carry (100 on Dash Core relay policy and Platform consensus): the full drain, or since #5712 a fixed amount held only in many small coins (identity registration, top-up, invitation, platform-address funding). Two meanings. On a build: nothing was broadcast, the reservation is released, no partial drain, and a rebuild after consolidating is safe. On the resume of a tracked `Built` / `Broadcast` lock over the cap (since #5714): it is not re-sent, but an earlier attempt may have reached peers; the row stays tracked and nothing is released. Rust, Swift and Kotlin retain the typed refusal. Renumbered from 59 after #5322 claimed it | +| 61 | `ErrorShieldedClaimUnconfirmed` | #4313 | In review — renumbered from 48 on 2026-11-02 when the branch merged `v5.1-dev`: 48 is #4356's `ErrorAssetLockInputContested` there, and the frontier stood at 61. First claimed as 48 from the frontier 2026-08-28 (46 merged via #4465, 47 held by active #4356) for review finding 4bf998e99652. The AMBIGUOUS one-time-claim outcome: a panic caught inside the claim export can strike after the Type-20 transition reached the wire, so the transition may already have executed; `out_identity_id` is NOT written (unlike 17), and the retained `shielded_pending_spends` row — the only holder of the claim's padded identity id — makes a rerun a RESUME (`reserve_one_time_claim_key` finds it; `recover_executed_one_time_claim` recovers the declared id). RETRYABLE-as-resume after the claim lease expires; the host MUST preserve the identity slot. Replaces the guard's previous generic `ErrorUnknown` (99), which Kotlin exposed as a non-retryable `Generic` — the slot-forfeiting misclassification. **Rule 5 satisfied across all layers at 61**: the Rust discriminant with its raw-value test pin, Swift's `errorShieldedClaimUnconfirmed = 61` raw case *and* its `init(ffi:)` arm *and* the typed `PlatformWalletError.shieldedClaimUnconfirmed` case with its `init(code:message:)` arm and `errorDescription`, plus an `ErrorHandlingTests` pin of raw value 61; Kotlin's typed retryable `PlatformWallet.ShieldedClaimUnconfirmed` with the `61 ->` arm in `fromPlatformWalletNative` and a `DashSdkErrorTest` pin on 61 | +| 62 | `ErrorShieldedClaimBindingMismatch` | #4313 | In review — claimed from the frontier 2026-11-02 for the binding-mismatch review finding on `shielded_send.rs`. A one-time-key claim RETRY presented arguments (keys, identity slot, denomination or fallback address) that differ from the transition the earlier attempt submitted. FAIL-CLOSED and ARGUMENT-CORRECTABLE: nothing was rebroadcast, no proof spent, and the pending claim record is kept, so a rerun with the ORIGINAL arguments resumes it; repeating the same call fails the same way. Previously flattened to `ErrorWalletOperation` (6) by the claim entry point's catch-all. **Rule 5 satisfied in the claiming commit**: the Rust discriminant with its raw-value pin and the claim entry point's typed pass-through arm (both tested), Swift's `errorShieldedClaimBindingMismatch = 62` raw case, its `init(ffi:)` arm, the typed `PlatformWalletError.shieldedClaimBindingMismatch` case with its `init(code:message:)` arm and `errorDescription`, plus an `ErrorHandlingTests` pin; Kotlin's typed `PlatformWallet.ShieldedClaimBindingMismatch` (not retryable unchanged) with the `62 ->` arm and a `DashSdkErrorTest` pin | **Code 31 left this table on 2026-08-04.** `ErrorSigningKeyUnavailable` sat here as #4183's proposal until #4183 merged (`189a3abb1c`); it is now in the merged @@ -469,7 +474,7 @@ E0081 plus a duplicate Swift raw value in its own tree, since the 08-25 base merges imported merged 42) and at 43 (silent, against an active proposal). The full account, timeline included, is in **Non-conforming allocations** above. Both persister codes still owe fresh integers from the frontier note — -the single canonical source; 48 as of 2026-08-26 — and a registry row in the +the single canonical source; 49 as of 2026-08-28 — and a registry row in the same PR (rule 2). ### 26 — RESOLVED: #4196 restacked onto #4185 and is on 34 / 35 / 36 diff --git a/packages/rs-platform-wallet-ffi/src/error.rs b/packages/rs-platform-wallet-ffi/src/error.rs index 762a8e35668..0ac26e1b5dd 100644 --- a/packages/rs-platform-wallet-ffi/src/error.rs +++ b/packages/rs-platform-wallet-ffi/src/error.rs @@ -265,6 +265,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 @@ -622,6 +699,64 @@ pub enum PlatformWalletFFIResultCode { /// coins lets either lock fit. ErrorAssetLockTooManyInputs = 60, + /// A panic was caught inside the one-time-key (shielded invitation) + /// claim export, so the claim's outcome is AMBIGUOUS: the panic can + /// strike after the Type-20 transition reached the wire, meaning the + /// transition may already have executed and the new identity may + /// already exist on chain. `out_identity_id` is NOT written (the panic + /// destroyed the in-flight result, so there is no id to write — + /// unlike [`Self::ErrorShieldedBroadcastUnconfirmed`] (17), whose + /// contract writes it). + /// + /// RETRYABLE — as a RESUME, not a fresh attempt, and not immediately. + /// The claim's durable recovery record (the retained + /// `shielded_pending_spends` row, the only holder of the claim's + /// padded identity id) survives the unwind: + /// `reserve_one_time_claim_key` finds it on the next attempt and + /// `recover_executed_one_time_claim` recovers the identity the first + /// attempt created instead of creating a second one. The host MUST + /// therefore PRESERVE the local identity slot and re-run the SAME + /// invitation claim after the claim lease expires (the panic unwound + /// past the deterministic lease release, so admission and the + /// per-invitation reservation are reclaimed by expiry; an attempt + /// before then is refused as [`Self::ErrorShieldedLifecycleBusy`]). + /// Never surface this as terminal, and never release the slot — + /// either mistake can strand an identity that already exists on + /// chain, whose padded id lives nowhere but the retained row. + /// + /// Joins the ambiguous-outcome `...Unconfirmed` family (17, 18, 20, + /// 42), but with the opposite retry polarity: those forbid retry + /// because a retry would REBUILD and double-spend, while this one + /// requires a (delayed) retry because a rerun RESUMES the retained + /// record. Before this code existed, the guard reported the generic + /// [`Self::ErrorUnknown`] (99), which Kotlin exposes as a + /// non-retryable `Generic` — a host following the typed retry + /// contract would release the identity slot or decline the recovery + /// retry (#4313 review finding 4bf998e99652). + /// + /// Code 61 — first claimed as 48 from the registry frontier on + /// 2026-08-28, renumbered to the frontier when the branch merged + /// `v5.1-dev`, where #4356's `ErrorAssetLockInputContested` holds 48; + /// see ERROR_CODE_REGISTRY.md. + ErrorShieldedClaimUnconfirmed = 61, + + /// Maps `PlatformWalletError::ShieldedClaimBindingMismatch`: a retry of a + /// one-time-key (shielded invitation) claim presented arguments that do + /// not match the transition the earlier attempt already submitted + /// (different keys, identity slot, denomination or fallback address). + /// + /// FAIL-CLOSED and ARGUMENT-CORRECTABLE: nothing was rebroadcast and no + /// proof was spent, and the claim's durable recovery record was kept + /// intact. Retrying the SAME call fails the same way; re-running the + /// claim with the ORIGINAL arguments resumes the record. The host must + /// keep the identity slot the original attempt used and must not treat + /// the invitation as spent or invalid. Before this code the variant + /// flattened to [`Self::ErrorWalletOperation`] (6), which hosts read as a + /// non-retryable hard failure (#4313 review). + /// + /// Code 62 — the registry frontier after 61; see ERROR_CODE_REGISTRY.md. + ErrorShieldedClaimBindingMismatch = 62, + /// The named thing does not exist. /// /// Originally (and still mostly) the code for every `Option` returned as an @@ -919,6 +1054,36 @@ 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 + } + // Fail-closed and argument-correctable: nothing was rebroadcast and + // the pending claim record is kept for a retry with the original + // arguments. Flattened to `ErrorWalletOperation` (6) it reads as a + // hard failure and the host abandons a recoverable claim. + PlatformWalletError::ShieldedClaimBindingMismatch { .. } => { + PlatformWalletFFIResultCode::ErrorShieldedClaimBindingMismatch + } PlatformWalletError::ShieldedIdentityDebitPending { .. } => { PlatformWalletFFIResultCode::ErrorShieldedIdentityDebitPending } @@ -1170,7 +1335,10 @@ impl From for PlatformWalletFFIResult { // mid-string and is deliberately not matched. _ => PlatformWalletFFIResultCode::ErrorUnknown, }; - let result = 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. + let result = + PlatformWalletFFIResult::err(code, strip_signer_machine_prefix(&error.to_string())); // Platform's own verdict, for the variants that still hold the SDK // error it arrived in (`Sdk`, `TokenOperationFailed`). It rides // alongside the code chosen above rather than replacing it, so a host @@ -1295,10 +1463,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) @@ -2285,6 +2478,102 @@ 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 + ); + } + + /// This PR's fourth shielded-invite code, pinned like 43-45 above. 61 is + /// from the registry frontier (renumbered from 48, which #4356's + /// `ErrorAssetLockInputContested` holds on `v5.1-dev`) — moving it + /// silently reclassifies an ambiguous claim outcome on every host built + /// against the shipped numbering. + #[test] + fn shielded_claim_unconfirmed_code_is_pinned_at_61() { + assert_eq!( + PlatformWalletFFIResultCode::ErrorShieldedClaimUnconfirmed as i32, + 61 + ); + } + + /// The binding-mismatch refusal is argument-correctable, so it needs its + /// own code rather than the generic, non-retryable `ErrorWalletOperation` + /// (6). Pinned at 62 (the registry frontier after 61), and the message + /// must survive the conversion verbatim — it names what did not match. + #[test] + fn shielded_claim_binding_mismatch_maps_to_code_62() { + assert_eq!( + PlatformWalletFFIResultCode::ErrorShieldedClaimBindingMismatch as i32, + 62 + ); + let error = PlatformWalletError::ShieldedClaimBindingMismatch { + mismatch: "identity_index 3 != recorded 1".to_string(), + }; + let rendered = error.to_string(); + let result: PlatformWalletFFIResult = error.into(); + assert_eq!( + result.code, + PlatformWalletFFIResultCode::ErrorShieldedClaimBindingMismatch + ); + assert_eq!(message_of(&result), rendered); + } + + /// 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 dd0675fca9f..8ce4faa7b08 100644 --- a/packages/rs-platform-wallet-ffi/src/manager.rs +++ b/packages/rs-platform-wallet-ffi/src/manager.rs @@ -846,6 +846,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 @@ -853,13 +865,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), ), } } @@ -868,6 +891,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 232d8a9a9c8..910a76dccf2 100644 --- a/packages/rs-platform-wallet-ffi/src/shielded_send.rs +++ b/packages/rs-platform-wallet-ffi/src/shielded_send.rs @@ -43,6 +43,7 @@ use std::ffi::CStr; use std::os::raw::{c_char, c_void}; use dpp::address_funds::{OrchardAddress, PlatformAddress}; +use dpp::prelude::Identifier; use dpp::shielded::{ compute_minimum_shielded_fee, compute_shielded_identity_balance_write_fee, compute_shielded_identity_top_up_fee, compute_shielded_unshield_fee, @@ -51,7 +52,9 @@ use dpp::shielded::{ use dpp::state_transition::public_key_in_creation::IdentityPublicKeyInCreation; use dpp::ProtocolError; 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, @@ -785,6 +788,73 @@ pub(crate) fn catch_funding_panic( ) } +/// Post-panic guidance for the one-time-key invitation claim. Paired with +/// [`PlatformWalletFFIResultCode::ErrorShieldedClaimUnconfirmed`] in +/// [`catch_one_time_claim_panic`] — the prose restates, for humans, the +/// contract the typed code carries for machines. +/// +/// Unlike [`SPEND_PANIC_GUIDANCE`], this does NOT say "do not retry": a claim's +/// durable recovery record is retained until the created identity is durably +/// registered, and re-running the same claim is exactly how that record is +/// resumed — it recovers the identity the first attempt created rather than +/// creating a second one. What it must not do is retry IMMEDIATELY: the panic +/// unwound past the claim's deterministic lease release, so its admission and +/// its per-invitation reservation are reclaimed by expiry, and an attempt +/// before then is refused as busy. +const ONE_TIME_CLAIM_PANIC_GUIDANCE: &str = "The claim may or may not have been broadcast and \ + the new identity may already exist on chain — out_identity_id was NOT written, and the \ + identity slot must NOT be released. The claim's recovery record is retained: re-running \ + this same invitation claim once its lease expires resumes it and recovers the identity \ + rather than creating a second one."; + +/// [`catch_panic_to_code`] specialized for the one-time-key claim export +/// (#4313 review findings 945163f6ed5b, 4bf998e99652). +/// +/// The panic maps to the dedicated +/// [`PlatformWalletFFIResultCode::ErrorShieldedClaimUnconfirmed`] (61), +/// deliberately NOT to any of this export's other codes, none of which a panic +/// can honestly promise: +/// +/// * `ErrorShieldedBroadcastUnconfirmed` (17) — its ABI contract says +/// `out_identity_id` IS written on that code, and a panic destroyed the +/// result, so there is no id to write. +/// * `ErrorShieldedInviteAlreadyClaimed` (43) — TERMINAL. Reporting it would +/// tell the claimer the invitation can never be claimed again, which is the +/// single worst thing to say about an outcome nobody knows. +/// * `ErrorShieldedScanBudgetExhausted` (44) / `ErrorShieldedLifecycleBusy` +/// (45) — both promise that nothing was scanned, built, or broadcast. A +/// panic can strike after the broadcast. +/// * `ErrorWalletOperation` (6) and `ErrorShieldedBroadcastFailed` (16) — +/// definitive failures, which this is not. +/// * `ErrorUnknown` (99) — what this guard used to report. JNI offsets it to +/// 1099, Kotlin's `DashSdkError.fromPlatformWalletNative` has no mapping for +/// 99, so hosts saw a non-retryable `PlatformWallet.Generic` — and a host +/// following the typed retry contract would release the identity slot or +/// decline the recovery retry, even though the transition may already be on +/// chain and the retained row is the only source of its padded identity id. +/// The recovery instructions embedded in the message are not a +/// machine-readable replacement for that contract. +/// +/// Code 48's contract is exactly this outcome's: ambiguous (the transition may +/// already have executed), `out_identity_id` untouched, and retryable AS A +/// RESUME — the retained `shielded_pending_spends` row makes a rerun of the +/// same claim resume the first attempt (`reserve_one_time_claim_key` finds the +/// row; `recover_executed_one_time_claim` recovers the declared identity), so +/// the host must preserve the identity slot and retry after the claim lease +/// expires. [`ONE_TIME_CLAIM_PANIC_GUIDANCE`] restates that contract for +/// humans in the message. +fn catch_one_time_claim_panic( + operation: &str, + body: impl FnOnce() -> PlatformWalletFFIResult, +) -> PlatformWalletFFIResult { + catch_panic_to_code( + operation, + PlatformWalletFFIResultCode::ErrorShieldedClaimUnconfirmed, + ONE_TIME_CLAIM_PANIC_GUIDANCE, + body, + ) +} + /// Preserve the typed funding reports that hosts branch on across the FFI /// boundary while keeping every other funding failure on the existing generic /// error path. @@ -1082,6 +1152,309 @@ 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. A panic inside the claim is +/// caught by [`catch_one_time_claim_panic`] and reported as the ambiguous, +/// retryable-as-resume `ErrorShieldedClaimUnconfirmed` (61) with +/// `out_identity_id` left untouched — the host must preserve the identity +/// slot and re-run the same claim after the lease expires (the rerun resumes +/// the retained recovery record rather than creating a second identity). +#[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 { + // Guarded: this export calls `block_on_worker`, which re-panics when its + // Tokio task panics, so a panic in the transient scan, proof generation, + // signing, wallet bookkeeping, or SDK processing would otherwise reach this + // non-unwind `extern "C"` frame and ABORT the host process. The JNI layer's + // `support::guard` cannot help — it sits on the far side of this export + // (#4313 review finding 945163f6ed5b). See `catch_panic_to_code`. + catch_one_time_claim_panic("shielded identity-create-from-one-time-key", || { + shielded_identity_create_from_one_time_key_inner( + handle, + wallet_id_bytes, + one_time_sk_bytes, + has_funding_birth_height, + funding_birth_height, + change_address_raw43, + identity_index, + identity_pubkeys, + identity_pubkeys_count, + denomination, + send_to_address_on_creation_failure_bytes, + signer_identity_handle, + out_identity_id, + ) + }) +} + +/// Body of +/// [`platform_wallet_manager_shielded_identity_create_from_one_time_key`], as an +/// ordinary Rust function so a panic unwinds into +/// [`catch_one_time_claim_panic`] instead of across the C ABI. +/// +/// `out_identity_id` is written only on this function's own return paths, so a +/// panic anywhere inside it leaves the caller's buffer exactly as it was. +/// +/// # Safety +/// Identical contract to the export that calls it. +#[allow(clippy::too_many_arguments)] +unsafe fn shielded_identity_create_from_one_time_key_inner( + 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); + + // 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. + // + // * `ShieldedClaimBindingMismatch` → 62, ARGUMENT-CORRECTABLE — a + // retry presented arguments that differ from the transition the + // earlier attempt submitted. Nothing was rebroadcast and the pending + // claim record is kept, so a rerun with the ORIGINAL arguments + // resumes it; flattened to 6 the host would abandon the claim. + // * `ShieldedRecoveryCorrupted` → 56, REPAIR NEEDED — the invitation's + // claim record is damaged and was kept. Flattened to 6 the host + // could not tell it from an ordinary failure. + // + // The blanket conversion is the single source of truth for all five; + // this arm only keeps them from reaching the catch-all. + Err( + e @ (PlatformWalletError::ShieldedInviteAlreadyClaimed { .. } + | PlatformWalletError::ShieldedForeignScanBudgetExhausted { .. } + | PlatformWalletError::ShieldedLifecycleBusy { .. } + | PlatformWalletError::ShieldedClaimBindingMismatch { .. } + | PlatformWalletError::ShieldedRecoveryCorrupted { .. }), + ) => (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. /// @@ -2327,6 +2700,115 @@ pub(crate) 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::*; @@ -2423,6 +2905,7 @@ mod tests { nullifiers: vec![[10; 32]], st_bytes: vec![11; 32], attempts: 0, + identity_index: None, identity_nonce_finalized: false, identity_user_abandoned: true, }, @@ -2906,6 +3389,118 @@ mod tests { ); } + /// A panic inside the one-time-key CLAIM export must not unwind into the `extern "C"` frame + /// either (#4313 review finding 945163f6ed5b). That export calls `block_on_worker`, which + /// re-panics on a panicking Tokio task, so a panic in the transient scan, Halo 2 synthesis, + /// signing, or SDK processing reached the C ABI and aborted the host process — the JNI + /// `guard` is on the far side of the export and never saw it. + /// + /// The code must be the dedicated `ErrorShieldedClaimUnconfirmed` (61) — NOT the generic + /// `ErrorUnknown`, which Kotlin exposes as a non-retryable `Generic` so a typed host would + /// release the identity slot the retained recovery row needs (#4313 review finding + /// 4bf998e99652) — and the ambiguity contract must survive: a panic can strike after the + /// broadcast, so the message must neither claim the invitation is spent (the terminal 43) + /// nor that nothing happened. + #[test] + fn catch_one_time_claim_panic_keeps_the_claim_ambiguity_contract() { + let result = + catch_one_time_claim_panic("shielded identity-create-from-one-time-key", || { + panic!("halo2 synthesis panicked"); + }); + + assert_eq!( + result.code, + PlatformWalletFFIResultCode::ErrorShieldedClaimUnconfirmed, + "a claim panic must map to the dedicated ambiguous-claim code — every other code \ + this export uses promises something a panic cannot, and the generic ErrorUnknown \ + reaches Kotlin as a non-retryable Generic that forfeits the slot" + ); + assert_ne!( + result.code, + PlatformWalletFFIResultCode::ErrorUnknown, + "the pre-fix generic code must be gone — hosts have no typed mapping for it" + ); + assert_ne!( + result.code, + PlatformWalletFFIResultCode::ErrorShieldedInviteAlreadyClaimed, + "a panic must never be reported as the TERMINAL already-claimed outcome" + ); + let message = message_of(&result); + assert!( + message.contains("shielded identity-create-from-one-time-key panicked") + && message.contains("halo2 synthesis panicked"), + "the panic payload must survive into the FFI message: {message}" + ); + assert!( + message.contains("may or may not have been broadcast"), + "the message must preserve the ambiguity: {message}" + ); + assert!( + message.contains("out_identity_id was NOT written"), + "the message must tell the host its out param is untouched: {message}" + ); + assert!( + message.contains("recovery record is retained"), + "the message must point at the recovery path that makes this survivable: {message}" + ); + } + + /// The pre-fix shape, side by side with the fixed one, so the difference the guard makes is + /// pinned rather than asserted in prose (#4313 review finding 945163f6ed5b). + /// + /// `block_on_worker` `.expect`s on the Tokio `JoinError`, so a panicking claim task re-panics + /// in the export's own frame. UNGUARDED, that unwind leaves the body and reaches the caller — + /// which for a `#[no_mangle] extern "C"` function is a non-unwind C ABI frame, i.e. an + /// immediate `abort` of the host process, with no result and no Java exception. GUARDED, the + /// identical panic becomes a typed result the host can act on. + /// + /// The export's entire body is now the guard call, and + /// `shielded_identity_create_from_one_time_key_inner` is private with exactly that one caller, + /// so the wiring cannot be bypassed. + #[test] + fn an_unguarded_claim_body_lets_the_panic_escape_to_the_c_abi_frame() { + // Pre-fix: the body was invoked directly by the export. + let escaped = std::panic::catch_unwind(|| -> PlatformWalletFFIResult { + panic!("halo2 synthesis panicked"); + }); + assert!( + escaped.is_err(), + "RED: the panic leaves the body and reaches the export frame, where it aborts" + ); + + // Post-fix: the same panic is contained and typed. + let contained = + catch_one_time_claim_panic("shielded identity-create-from-one-time-key", || { + panic!("halo2 synthesis panicked"); + }); + assert_eq!( + contained.code, + PlatformWalletFFIResultCode::ErrorShieldedClaimUnconfirmed, + "GREEN: the guard converts the identical panic into a typed result the host receives" + ); + } + + /// The guard must be transparent to the ordinary outcomes — it wraps the whole export, so a + /// bug here would corrupt every non-panicking claim result, including the + /// `ErrorShieldedBroadcastUnconfirmed` path whose contract WRITES `out_identity_id`. + #[test] + fn catch_one_time_claim_panic_passes_results_through() { + let ok = catch_one_time_claim_panic("test", PlatformWalletFFIResult::ok); + assert_eq!(ok.code, PlatformWalletFFIResultCode::Success); + + let terminal = catch_one_time_claim_panic("test", || { + PlatformWalletFFIResult::err( + PlatformWalletFFIResultCode::ErrorShieldedInviteAlreadyClaimed, + "already claimed", + ) + }); + assert_eq!( + terminal.code, + PlatformWalletFFIResultCode::ErrorShieldedInviteAlreadyClaimed, + "the guard must not rewrite a real terminal outcome" + ); + } + /// `map_spend_result` pins the retry-relevant code split the three spend /// entry points depend on: /// - `ShieldedSpendUnconfirmed` → `ErrorShieldedSpendUnconfirmed` (host @@ -3257,6 +3852,123 @@ mod tests { ); } + /// 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. + /// `ShieldedClaimBindingMismatch` is fail-closed and argument-correctable: + /// nothing was rebroadcast and the claim record is kept for a retry with + /// the original arguments. Through the catch-all it became the generic, + /// non-retryable `ErrorWalletOperation` (6), so a host abandoned a + /// recoverable claim; it must reach the host as its own code 62, with no + /// identity id written. + #[test] + fn map_one_time_claim_result_passes_the_recovery_corrupted_code_through() { + let (identity_id, result) = + map_one_time_claim_result(Err(PlatformWalletError::ShieldedRecoveryCorrupted { + account_index: Some(u32::MAX), + reason: "one-time claim record: bad anchor".to_string(), + })); + assert_eq!( + result.code, + PlatformWalletFFIResultCode::ErrorShieldedRecoveryCorrupted, + "a damaged claim record must not flatten to ErrorWalletOperation (6)" + ); + assert!(identity_id.is_none()); + assert!(message_of(&result).contains("bad anchor")); + } + + #[test] + fn map_one_time_claim_result_passes_the_binding_mismatch_code_through() { + let (identity_id, result) = + map_one_time_claim_result(Err(PlatformWalletError::ShieldedClaimBindingMismatch { + mismatch: "identity_index 3 != recorded 1".to_string(), + })); + assert_eq!( + result.code, + PlatformWalletFFIResultCode::ErrorShieldedClaimBindingMismatch, + "a binding mismatch must not flatten to ErrorWalletOperation (6)" + ); + assert!( + identity_id.is_none(), + "nothing was rebroadcast, so no identity id may be written" + ); + assert!(message_of(&result).contains("identity_index 3 != recorded 1")); + } + + #[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 + ); + } + /// The asset-lock coin-selection shortfall must reach hosts as the /// dedicated `ErrorAssetLockInsufficientFunds` (29) through THIS entry /// point — `platform_wallet_manager_shielded_fund_from_asset_lock`, the diff --git a/packages/rs-platform-wallet-ffi/src/shielded_sync.rs b/packages/rs-platform-wallet-ffi/src/shielded_sync.rs index 1371dfaabd1..fc4bba48689 100644 --- a/packages/rs-platform-wallet-ffi/src/shielded_sync.rs +++ b/packages/rs-platform-wallet-ffi/src/shielded_sync.rs @@ -599,16 +599,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 map_shielded_error(e, "clear_shielded"); @@ -616,6 +611,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 // --------------------------------------------------------------------------- @@ -731,10 +750,16 @@ pub unsafe extern "C" fn platform_wallet_manager_shielded_sync_wallet( /// Recovery errors must remain typed when opening, binding or syncing durable state, /// just as they do when submitting or explicitly abandoning a payment. +/// +/// `ShieldedLifecycleBusy` (45) stays typed too: a bind whose wallet id still +/// has a deferred purge held off by an in-flight one-time-key claim is refused +/// with it, and flattened to `ErrorWalletOperation` (6) the host would treat a +/// retryable wait as a hard bind failure. fn map_shielded_error(error: PlatformWalletError, operation: &str) -> PlatformWalletFFIResult { match error { error @ PlatformWalletError::ShieldedRecoveryCorrupted { .. } - | error @ PlatformWalletError::ShieldedRecoveryKeysRequired { .. } => error.into(), + | error @ PlatformWalletError::ShieldedRecoveryKeysRequired { .. } + | error @ PlatformWalletError::ShieldedLifecycleBusy { .. } => error.into(), other => PlatformWalletFFIResult::err( PlatformWalletFFIResultCode::ErrorWalletOperation, format!("{operation} failed: {other}"), @@ -742,6 +767,49 @@ fn map_shielded_error(error: PlatformWalletError, operation: &str) -> PlatformWa } } +#[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()) + )); + } +} + #[cfg(test)] mod recovery_error_tests { use super::*; @@ -763,6 +831,12 @@ mod recovery_error_tests { }, PlatformWalletFFIResultCode::ErrorShieldedRecoveryKeysRequired, ), + ( + PlatformWalletError::ShieldedLifecycleBusy { + reason: "a one-time-key claim holds this wallet's deferred purge".into(), + }, + PlatformWalletFFIResultCode::ErrorShieldedLifecycleBusy, + ), ] { let message = error.to_string(); let result = map_shielded_error(error, "configure or bind"); diff --git a/packages/rs-platform-wallet/Cargo.toml b/packages/rs-platform-wallet/Cargo.toml index 7da2bb5ce6c..ae131d948fa 100644 --- a/packages/rs-platform-wallet/Cargo.toml +++ b/packages/rs-platform-wallet/Cargo.toml @@ -66,6 +66,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/README.md b/packages/rs-platform-wallet/README.md index 1fc475bed45..80407deda6b 100644 --- a/packages/rs-platform-wallet/README.md +++ b/packages/rs-platform-wallet/README.md @@ -146,6 +146,37 @@ The local shielded balance API introduces three Rust source compatibility change index is zero. An absent row is different from a recorded empty scan; do not infer presence merely from `last_synced_index > 0`. +## Invitation-claim store migration + +One-time-key invitation claims (`identity_create_from_one_time_key`) add Rust +source compatibility changes for custom `ShieldedStore` implementations, even +ones whose hosts never claim invitations: + +- New required methods, with no defaults: + - wallet-removal tombstones: `record_wallet_removal`, + `clear_wallet_removal`, `outstanding_wallet_removals`; + - claim admission: `begin_claim_admission`, `renew_claim_admission`, + `end_claim_admission`, `arm_redrive_under_claim`, + `reserve_one_time_claim_key`; + - destructive admission: `begin_destructive_admission`, + `end_destructive_admission`. +- Contracts the reference stores rely on: + - admission and reservation checks are atomic with the write they guard; + - a store shared by several coordinators (one SQLite file) enforces them + across instances, not only in memory; + - in a persistent store, removal tombstones and claim records are durable + once the call returns; + - `begin_destructive_admission` counts live claim leases and unresolved + one-time claim records (under `ONE_TIME_CLAIM_RECORDS_ACCOUNT`) in its + scope. +- `PendingRedrive` struct literals require `identity_index: Option`. + Use `None` for ordinary spend redrives; claim records carry the local + identity slot the claim registers at. + +[InMemoryShieldedStore](src/wallet/shielded/store.rs) (single instance) and +[FileBackedShieldedStore](src/wallet/shielded/file_store.rs) (cross-instance) +are the reference implementations. + ## Dependencies - `key-wallet`: Core wallet functionality diff --git a/packages/rs-platform-wallet/src/error.rs b/packages/rs-platform-wallet/src/error.rs index 322530e770b..c94b76c0cf5 100644 --- a/packages/rs-platform-wallet/src/error.rs +++ b/packages/rs-platform-wallet/src/error.rs @@ -1042,6 +1042,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 @@ -1076,6 +1093,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 02a91ece586..dcbcd9d54b8 100644 --- a/packages/rs-platform-wallet/src/manager/wallet_lifecycle.rs +++ b/packages/rs-platform-wallet/src/manager/wallet_lifecycle.rs @@ -863,6 +863,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, @@ -953,14 +968,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/identity/state/manager/lifecycle.rs b/packages/rs-platform-wallet/src/wallet/identity/state/manager/lifecycle.rs index 2efbf0f4345..48ecb26336a 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/state/manager/lifecycle.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/state/manager/lifecycle.rs @@ -34,15 +34,100 @@ impl IdentityManager { /// bucket. /// /// Persists the resulting changeset via `persister` and returns `()`. + /// + /// A persistence failure is logged and SWALLOWED here — the in-memory + /// insert stands and the next sync heals the host's row. Callers for whom + /// the persistence outcome is load-bearing must use + /// [`add_identity_persisted`](Self::add_identity_persisted) instead. pub fn add_identity( &mut self, identity: Identity, identity_index: u32, wallet_id: WalletId, persister: &WalletPersister, + ) -> Result<(), PlatformWalletError> { + if let Err(e) = self.add_identity_persisted(identity, identity_index, wallet_id, persister) + { + // Distinguish the two failure modes: a rejected insert is a real + // error for this caller too, whereas a persistence failure after a + // successful insert is the historical swallow. + match e { + PlatformWalletError::Persistence(_) => { + tracing::error!("Failed to persist changeset: {}", e); + } + other => return Err(other), + } + } + Ok(()) + } + + /// [`add_identity`](Self::add_identity), but the persister's failure is + /// PROPAGATED as [`PlatformWalletError::Persistence`] rather than logged + /// and dropped. + /// + /// The distinction matters wherever the host's durable acknowledgement is + /// what releases some other recovery state. The shielded one-time-key claim + /// is exactly that case: it retains the durable record holding the claim's + /// padded identity id until this registration is durable, and a swallowed + /// `persister.store` error would release it while the host had never + /// recorded the identity — after which no retry can reconstruct the id + /// (#4313 review finding 325ce9fa8f84). + /// + /// On a persistence error the in-memory insert still STANDS: it is not + /// rolled back, so the manager and the location index remain consistent and + /// the next sync can heal the host's row. The error reports only that the + /// host has not durably acknowledged the identity yet. + pub fn add_identity_persisted( + &mut self, + identity: Identity, + identity_index: u32, + wallet_id: WalletId, + persister: &WalletPersister, ) -> Result<(), PlatformWalletError> { let identity_id = identity.id(); self.add_identity_unstored(identity, identity_index, wallet_id)?; + self.store_identity_changeset(identity_id, persister) + } + + /// [`add_identity_persisted`](Self::add_identity_persisted), made + /// idempotent for a registration that already happened in memory at + /// exactly this wallet and slot: instead of refusing it with + /// [`PlatformWalletError::IdentityAlreadyExists`], the identity's + /// changeset is handed to the persister again. + /// + /// For the shielded one-time-key claim, whose recovery record is released + /// only once the identity is durably registered. If the persister rejected + /// the first attempt's changeset, the in-memory insert still stood; a + /// same-session retry then hit `IdentityAlreadyExists`, never retried + /// persistence, and could never release the record (#4313 review). An + /// identity found at any OTHER location still errors — that is a real + /// conflict, not a repeat of this registration. + pub fn add_or_repersist_identity( + &mut self, + identity: Identity, + identity_index: u32, + wallet_id: WalletId, + persister: &WalletPersister, + ) -> Result<(), PlatformWalletError> { + let identity_id = identity.id(); + match self.location_index().get(&identity_id).copied() { + None => self.add_identity_unstored(identity, identity_index, wallet_id)?, + Some(IdentityLocation::InWallet { + wallet_id: located_wallet, + registration_index, + }) if located_wallet == wallet_id && registration_index == identity_index => {} + Some(_) => return Err(PlatformWalletError::IdentityAlreadyExists(identity_id)), + } + self.store_identity_changeset(identity_id, persister) + } + + /// Hand `identity_id`'s current identity + keys changeset to `persister`, + /// propagating its failure as [`PlatformWalletError::Persistence`]. + fn store_identity_changeset( + &mut self, + identity_id: Identifier, + persister: &WalletPersister, + ) -> Result<(), PlatformWalletError> { let managed_identity = self .managed_identity_mut(&identity_id) .ok_or(PlatformWalletError::IdentityNotFound(identity_id))?; @@ -66,11 +151,12 @@ impl IdentityManager { ..Default::default() }; - if let Err(e) = persister.store(cs) { - tracing::error!("Failed to persist changeset: {}", e); - } - - Ok(()) + persister.store(cs).map_err(|e| { + PlatformWalletError::Persistence(format!( + "identity {identity_id} was inserted into the local manager but the host persister \ + rejected its changeset: {e}" + )) + }) } /// [`Self::add_identity`] in memory only: the caller stores the identity diff --git a/packages/rs-platform-wallet/src/wallet/platform_wallet.rs b/packages/rs-platform-wallet/src/wallet/platform_wallet.rs index 50a564b04df..77d1e62a047 100644 --- a/packages/rs-platform-wallet/src/wallet/platform_wallet.rs +++ b/packages/rs-platform-wallet/src/wallet/platform_wallet.rs @@ -1234,12 +1234,24 @@ impl PlatformWallet { /// paths check here first, before writing anything. #[cfg(feature = "shielded")] fn ensure_shielded_attached(&self) -> Result<(), PlatformWalletError> { + self.ensure_shielded_attached_for("shielded bind") + } + + /// [`ensure_shielded_attached`](Self::ensure_shielded_attached) for a + /// named operation, so a refusal says which one was refused. + /// + /// Used by the one-time-key claim, whose reason for checking is not the + /// bind paths' key-hygiene one: a claim that begins after a removal has + /// committed would arm a pending row and BROADCAST for a wallet the host + /// has already deleted (#4313 review finding 4a2c679745bb). + #[cfg(feature = "shielded")] + fn ensure_shielded_attached_for(&self, operation: &str) -> Result<(), PlatformWalletError> { if self .shielded_detached .load(std::sync::atomic::Ordering::Acquire) { return Err(PlatformWalletError::WalletNotFound(format!( - "{} was removed from the manager; shielded bind refused", + "{} was removed from the manager; {operation} refused", hex::encode(self.wallet_id) ))); } @@ -1672,6 +1684,225 @@ 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, + { + // Refuse a claim for a wallet the host has already removed + // (#4313 review finding 4a2c679745bb). An FFI caller can resolve and + // retain this wallet and its coordinator BEFORE a removal and only + // begin executing afterwards, so the entry point has to check rather + // than rely on being unreachable. + // + // This check alone is not sufficient — it can pass a moment before the + // removal commits — which is why the same flag is re-checked inside the + // operation once the store's claim admission is held. See the + // `detached` parameter of `operations::identity_create_from_one_time_key` + // for why that second check is the one that actually closes the race. + // This one keeps the common case cheap: nothing is derived, scanned, or + // reserved for a wallet that is already gone. + self.ensure_shielded_attached_for("shielded invitation claim")?; + + let outcome = super::shielded::operations::identity_create_from_one_time_key( + &self.sdk, + coordinator.store(), + coordinator.foreign_claim_guards(), + coordinator.foreign_scan_checkpoints(), + &self.shielded_detached, + 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?; + let super::shielded::operations::OneTimeClaimOutcome { + identity_id, + identity, + recovery_record, + } = outcome; + + self.register_one_time_claim_identity( + coordinator, + identity, + identity_index, + recovery_record, + ) + .await; + + Ok(identity_id) + } + + /// The local-registration tail of [`Self::identity_create_from_one_time_key`]: + /// register the claimed identity at `identity_index`, make that + /// registration durable, and only then release the claim's retained + /// recovery record. Any failure is logged and leaves the record in place + /// for a retry to resume (see the body for why each step is required). + #[cfg(feature = "shielded")] + pub(crate) async fn register_one_time_claim_identity( + &self, + coordinator: &Arc, + identity: dpp::identity::Identity, + identity_index: u32, + recovery_record: Option, + ) { + // Register the proof-verified identity in the local manager at its HD + // slot — the SAME tail as `shielded_identity_create_from_pool`, with one + // difference that matters: the claim's recovery record is still on disk, + // and whether it may be released depends on the outcome here + // (#4313 review finding 325ce9fa8f84). + // + // While it is on disk the record also keeps this wallet's clear and + // removal out: an armed claim record blocks destructive admission by + // itself (`ShieldedStore::begin_destructive_admission`), so releasing + // the claim lease and the invitation reservation before this handoff + // leaves no gap in which a purge could delete it. + // + // `add_or_repersist_identity`, not `add_identity`: the latter logs and + // swallows a `persister.store` failure, which is precisely the case + // that must NOT release the record — the identity would be absent from + // the host's durable state with nothing left to reconstruct its padded + // id from. It is also idempotent for an identity a previous attempt of + // this session already inserted at this slot: that attempt's store may + // have failed AFTER the insert, and refusing the repeat with + // `IdentityAlreadyExists` would leave the registration unpersistable + // and the record unreleasable for the rest of the session. A + // registration failure is still not surfaced as an error (the identity + // exists on chain and the next sync heals the local row); it only + // withholds the acknowledgement. + let stored = { + let mut wm = self.wallet_manager.write().await; + match wm.get_wallet_info_mut(&self.wallet_id) { + Some(info) => match info.identity_manager.add_or_repersist_identity( + identity, + identity_index, + self.wallet_id, + &self.persister, + ) { + Ok(()) => true, + Err(e) => { + tracing::warn!( + identity_index, + error = %e, + "IdentityCreateFromOneTimeKey broadcast succeeded but registering the \ + identity in the local manager failed; the on-chain identity exists, \ + the claim's recovery record is retained so a retry can recover it, \ + and the next sync will heal the local row" + ); + false + } + }, + None => { + tracing::warn!( + identity_index, + "IdentityCreateFromOneTimeKey broadcast succeeded but the wallet info was \ + not found in the manager; skipping local registration (the claim's \ + recovery record is retained; heals on next sync)" + ); + false + } + } + }; + + // A successful `store` is not yet durable: the persister contract lets + // it buffer until `flush`, and `store_commits_inline()` defaults to + // false. Releasing the record on `store` alone could therefore delete + // the only copy of the padded id while the identity was still in a + // buffer, and a crash would lose both. So the acknowledgement also + // needs the flush, unless the backend attests that `store` commits. + // + // A backend that persists nothing (`NoPlatformPersistence`) flushes + // successfully and so releases the record. That is deliberate: such a + // host has opted out of durability for every identity, nothing it does + // would ever acknowledge more, and a record kept for it would block + // the wallet's clear and removal for good. + // + // Flushed after the wallet-manager lock is dropped, so a slow backend + // write does not stall every other wallet accessor. + let registered = stored + && (self.persister.store_commits_inline() + || match self.persister.flush() { + Ok(()) => true, + Err(e) => { + tracing::warn!( + identity_index, + error = %e, + "IdentityCreateFromOneTimeKey: the identity's registration was stored \ + but the persister could not flush it; the claim's recovery record is \ + retained until a retry makes the registration durable" + ); + false + } + }); + + // The acknowledgement, and the ONLY thing that drops the record. It runs + // after the identity is in the manager and its changeset is durable in + // the host persister, so a process death anywhere before this point + // leaves a record a retry can resume — recovering the identity by its + // declared id instead of returning the terminal + // `ShieldedInviteAlreadyClaimed`. + // + // A retained record costs one stale row that the next claim of this + // invitation resolves; releasing it early costs the identity. + if let Some(record) = recovery_record { + if registered { + super::shielded::operations::acknowledge_one_time_claim_registration( + coordinator.store(), + record, + ) + .await; + } + } + } + #[cfg(feature = "shielded")] async fn shielded_shield_plan_for_account( &self, diff --git a/packages/rs-platform-wallet/src/wallet/shielded/balance_tests.rs b/packages/rs-platform-wallet/src/wallet/shielded/balance_tests.rs index 03adbe56175..72c8b6f9128 100644 --- a/packages/rs-platform-wallet/src/wallet/shielded/balance_tests.rs +++ b/packages/rs-platform-wallet/src/wallet/shielded/balance_tests.rs @@ -286,13 +286,15 @@ async fn local_balance_recovers_after_failed_clear_and_completed_scan() { 900 ); - // A real SQLite schema error makes FileBacked's purge fail. Its SQL-first - // ordering keeps the live subwallet ledger intact; the host keeps its rows. + // A real SQLite schema error makes FileBacked's store reset fail AFTER + // destructive admission (which reads `shielded_pending_spends`, so that + // table must stay intact). The tree reset runs before the subwallet purge, + // so the live subwallet ledger stays intact; the host keeps its rows. let conn = rusqlite::Connection::open(&path).unwrap(); - conn.execute_batch("ALTER TABLE shielded_pending_spends RENAME TO unavailable_pending_spends;") + conn.execute_batch("ALTER TABLE commitment_tree_cap RENAME TO unavailable_tree_cap;") .unwrap(); assert!(coordinator.clear().await.is_err()); - conn.execute_batch("ALTER TABLE unavailable_pending_spends RENAME TO shielded_pending_spends;") + conn.execute_batch("ALTER TABLE unavailable_tree_cap RENAME TO commitment_tree_cap;") .unwrap(); drop(conn); assert!(!coordinator.is_hydrated(wallet).await); @@ -363,6 +365,7 @@ async fn local_balance_restores_funds_and_durable_pending_reservations_offline() nullifiers: vec![[2; 32]], st_bytes: vec![7; 32], attempts: 0, + identity_index: None, identity_nonce_finalized: false, identity_user_abandoned: false, }, @@ -430,7 +433,10 @@ async fn local_balance_restores_funds_and_durable_pending_reservations_offline() "another wallet is not restored by this bind" ); drop(store); - coordinator.unregister_wallet(wallet).await; + coordinator + .unregister_wallet(wallet) + .await + .expect("unregister"); assert_eq!( coordinator.local_balance_snapshot(wallet).await.unwrap(), ShieldedLocalBalanceState::Unbound diff --git a/packages/rs-platform-wallet/src/wallet/shielded/coordinator.rs b/packages/rs-platform-wallet/src/wallet/shielded/coordinator.rs index 884f44d8dda..67c995d6615 100644 --- a/packages/rs-platform-wallet/src/wallet/shielded/coordinator.rs +++ b/packages/rs-platform-wallet/src/wallet/shielded/coordinator.rs @@ -102,6 +102,17 @@ use dpp::serialization::PlatformDeserializableTrusted; use dpp::state_transition::shield_from_identity_transition::accessors::ShieldFromIdentityTransitionAccessorsV0; use dpp::state_transition::StateTransition; +/// Result of one attempt at a wallet's deferred store purge. +#[derive(Debug)] +enum DeferredPurgeOutcome { + /// The purge ran and its durable tombstone is gone. + Done, + /// A live one-time-key claim for the wallet holds it off; retry later. + Busy, + /// The admission, the purge or the tombstone clear failed. + Failed(String), +} + /// Network-scoped shielded coordinator. /// /// See module docs for the architectural rationale. @@ -250,6 +261,66 @@ 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, + + /// Wallets that were removed but whose per-subwallet store state could + /// not be purged, because the fallible `purge_wallet` failed AFTER the + /// removal's commit point. + /// + /// Once `unregister_wallet_with` has run `on_admitted` — which sets the + /// wallet's irreversible `shielded_detached` flag — and cleared the + /// account, persister and hydration registrations, the removal is + /// decided. Reporting the later store I/O failure as an error made + /// `remove_wallet_with_teardown` abort with the wallet still in the + /// manager's map, so the host kept a live handle that could never bind + /// shielded state again and whose coordinator registrations were already + /// gone — and the FFI flattens that store error to the generic, + /// non-retryable code 6, so the host had no contract for finishing the + /// job either (#4313 review finding coordinator.rs:856). + /// + /// The removal now always completes and the unfinished purge is recorded + /// here instead, making the leftover work re-entrant: a later pass + /// ([`finish_deferred_purges`](Self::finish_deferred_purges), driven from + /// every [`sync`](Self::sync) pass) retries it, and a re-registration of + /// the same id finishes it first — and is refused while it cannot — so a + /// stale watermark can never be resurrected under a new bind. Purges are + /// idempotent, so a retry that races a completed one is harmless. + /// + /// This set is the in-memory FAST PATH over a durable tombstone, not the + /// record itself. `unregister_wallet_with` writes + /// [`ShieldedStore::record_wallet_removal`] before it attempts the purge + /// and drops it only once that purge succeeds, so the authority on "does + /// this wallet still have orphaned rows" lives in the store's + /// `shielded_wallet_removal` table and survives a process death. + /// + /// It has to. Keeping the deferral only here meant a newly opened + /// coordinator started with an EMPTY set and then reported success + /// without inspecting any surviving row, so a re-registration after a + /// restart could inherit this wallet's decrypted notes, pending claims, + /// activity, and a stale `last_synced_note_index` — which, because + /// restore advances watermarks monotonically, also made the re-added + /// wallet skip historical notes (#4313 review finding 5ca995a0d2aa). + /// + /// The set is seeded from the store in [`Self::new`] and is otherwise a + /// cheap membership check that spares the common (empty) case a SQLite + /// read; every path that acts on it reconciles against the durable table. + /// + /// [`ShieldedStore::record_wallet_removal`]: + /// super::store::ShieldedStore::record_wallet_removal + deferred_purges: RwLock>, + /// Counts completed [`clear`](Self::clear) calls, so a bind can tell /// that the host snapshot it loaded predates a wipe. /// @@ -392,6 +463,32 @@ impl NetworkShieldedCoordinator { db_path: PathBuf, store: FileBackedShieldedStore, ) -> Self { + // Seed the deferred-purge fast path from the store's durable removal + // tombstones, so a coordinator opened by a RELAUNCHED process starts + // knowing about the purges an earlier process left unfinished + // (#4313 review finding 5ca995a0d2aa). Best-effort: this constructor + // is infallible, and every consumer re-reads the durable table anyway, + // so a read failure here costs the fast path and nothing else. + let deferred_purges = match store.outstanding_wallet_removals() { + Ok(ids) => { + if !ids.is_empty() { + tracing::info!( + outstanding = ids.len(), + "Shielded store carries wallet removals whose purge did not complete; \ + they will be retried from the next sync pass or re-registration" + ); + } + ids.into_iter().collect() + } + Err(e) => { + tracing::warn!( + error = %e, + "Could not read shielded wallet-removal tombstones at open; deferred purges \ + will still be found by the durable read on each pass" + ); + std::collections::BTreeSet::new() + } + }; Self { sdk, network, @@ -404,10 +501,25 @@ 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(), + deferred_purges: RwLock::new(deferred_purges), 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 @@ -692,6 +804,51 @@ impl NetworkShieldedCoordinator { .count(); let shape_changed = !stale.is_empty() || registered_count != account_views.len(); + // Finish any purge an earlier removal of THIS id deferred, BEFORE the + // new registration is installed — and refuse the registration if it + // will not run. Deferred residue is not merely untidy here: the + // removed wallet's surviving notes, pending claims and its stale, + // advance-only `last_synced_note_index` would be inherited by the new + // binding, which then reports caught-up and never re-emits its notes + // (#4313 review findings coordinator.rs:856 and r3867666255). + // + // Membership is the in-memory set OR a durable tombstone. The durable + // read is what catches a tombstone a PEER coordinator or process wrote + // after this one opened; it is taken only when this coordinator has + // nothing registered for the id — the only state a removal leaves + // behind, and the case in which the guard check below already reads + // the store — so an identical re-bind keeps its store-lock-free path. + let tombstoned = if self.deferred_purges.read().await.contains(&wallet_id) { + true + } else if registered_count == 0 { + self.store + .read() + .await + .outstanding_wallet_removals() + .map_err(|e| PlatformWalletError::ShieldedStoreError(e.to_string()))? + .contains(&wallet_id) + } else { + false + }; + if tombstoned { + match self.finish_deferred_purge(wallet_id).await { + DeferredPurgeOutcome::Done => {} + DeferredPurgeOutcome::Busy => { + return Err(PlatformWalletError::ShieldedLifecycleBusy { + reason: "an earlier removal of this wallet still has shielded state to \ + purge, and a one-time-key claim holds it; retry the bind" + .to_string(), + }); + } + DeferredPurgeOutcome::Failed(reason) => { + return Err(PlatformWalletError::ShieldedStoreError(format!( + "an earlier removal of this wallet left shielded state the store could \ + not purge; refusing to bind over it: {reason}" + ))); + } + } + } + // Do not change the owner/key of an unresolved payment. Inspect durable // wallet-wide guards as well as `stale`: after restart the old account // may not yet be in the registry. An initial registration must cover @@ -879,7 +1036,65 @@ 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) { + /// + /// # Abort before the commit point, never after it + /// + /// The removal has exactly one commit point: the moment destructive + /// admission is secured. BEFORE it, this either proceeds 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. + /// + /// AFTER it, the removal always completes and this returns `Ok`. Once + /// `on_admitted` has set the wallet's irreversible `shielded_detached` + /// flag and the registries are gone, an error return would abort + /// `remove_wallet_with_teardown` with the wallet still in the manager's + /// map: a live handle that can never bind shielded state again, whose + /// coordinator registrations have already disappeared, reported through + /// an FFI code the host cannot retry (#4313 review finding + /// coordinator.rs:856). A store purge that fails there is therefore + /// deferred to [`Self::finish_deferred_purges`] rather than surfaced — + /// the wallet is unregistered either way, so the residue is inert. + /// + /// [`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 @@ -887,17 +1102,336 @@ 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) { - tracing::warn!( + + // DURABLE tombstone, written past the commit point and BEFORE the + // purge it guards (#4313 review finding 5ca995a0d2aa). + // + // Before the purge, not merely on its failure: a process death *during* + // the purge orphans exactly the same rows and leaves no failure to + // react to — SQLite rolls the delete back and the removal is simply + // forgotten. A tombstone that outlives a purge which actually succeeded + // costs one idempotent no-op purge on a later pass; a missing one costs + // the rows forever, and a re-registration of this id then inherits + // them. + // + // A failure to WRITE the tombstone cannot abort the removal either — + // everything above it is already committed — so it is logged and the + // in-memory deferral below still stands. That degrades to exactly the + // pre-fix behaviour for this one wallet, which is the best available + // when the store will not accept any write at all. + if let Err(e) = self.store.write().await.record_wallet_removal(wallet_id) { + tracing::error!( + wallet_id = %hex::encode(wallet_id), + error = %e, + "Failed to record the shielded removal tombstone; the removal stands, but an \ + unfinished purge will not survive a restart" + ); + } + + let purged = self.store.write().await.purge_wallet(wallet_id); + self.release_destructive_admission(admission).await; + if purged.is_ok() { + // The purge really did run, so the tombstone has done its job. + // Clearing is best-effort in the other direction: a leftover + // tombstone only makes a later pass re-run an idempotent purge + // over a wallet that has no rows left, and it stays deferred until + // that pass manages to drop it. + if let Err(e) = self.store.write().await.clear_wallet_removal(wallet_id) { + tracing::warn!( + wallet_id = %hex::encode(wallet_id), + error = %e, + "Purged the removed wallet's shielded state but could not clear its removal \ + tombstone; a later pass will retry the (now no-op) purge" + ); + self.deferred_purges.write().await.insert(wallet_id); + } + } + if let Err(e) = purged { + // Past the commit point, so this CANNOT abort the removal. The + // registries are already gone and `on_admitted` has set the + // wallet's irreversible detach flag; returning an error here made + // `remove_wallet_with_teardown` propagate before removing the + // wallet from either manager map, handing the host back a live + // wallet that can never bind shielded state again — and the FFI + // flattens the store error to non-retryable code 6, so nothing + // could finish the partial removal + // (#4313 review finding coordinator.rs:856). + // + // Instead the removal completes and the unfinished purge is + // deferred: `finish_deferred_purges` retries it from every sync + // pass, and a re-registration of this id finishes it before + // installing. No sync can run for the wallet in the meantime — + // its accounts and persister are unregistered — so the residue is + // inert, just not yet deleted. + self.deferred_purges.write().await.insert(wallet_id); + tracing::error!( wallet_id = %hex::encode(wallet_id), error = %e, - "Failed to purge per-subwallet store state on unregister" + "Failed to purge per-subwallet store state on unregister; the wallet removal \ + stands and the purge is deferred to a later pass" + ); + } + Ok(()) + } + + /// Retry every purge deferred by [`Self::unregister_wallet_with`], and + /// report how many are still outstanding. + /// + /// Each retry takes its own store-level destructive admission for that + /// wallet, so it cannot delete a pending-claim record another process has + /// in flight. Unlike [`Self::acquire_destructive_admission`] this is a + /// SINGLE non-blocking probe rather than a drain loop: the caller is a + /// background pass (or a bind holding the lifecycle mutex), and neither + /// may stall for the drain timeout on behalf of cleanup that is already + /// inert. A contended or failing attempt simply stays deferred. + /// + /// Idempotent, and safe to call concurrently with anything: a purge for a + /// wallet with no store rows is a no-op, and the id is removed from the + /// deferred set only once its purge has actually succeeded. + pub async fn finish_deferred_purges(&self) -> usize { + let outstanding = self.outstanding_deferred_purges().await; + if outstanding.is_empty() { + return 0; + } + // Every id in the union is attempted, durable-only ones included. A + // tombstone written by a peer coordinator after this one opened is in + // the durable half alone; gating the attempt on the in-memory set + // counted it as outstanding forever without ever purging it + // (#4313 review finding r3867666255). + for wallet_id in outstanding { + self.finish_deferred_purge(wallet_id).await; + } + self.outstanding_deferred_purges().await.len() + } + + /// The union of the in-memory deferred-purge set and the store's DURABLE + /// removal tombstones, ascending and deduplicated. + /// + /// The durable half is what makes this survive a restart: a coordinator + /// opened by a new process has an empty in-memory set, and reporting "none + /// outstanding" from that alone is what left orphaned rows to be inherited + /// by a re-registration (#4313 review finding 5ca995a0d2aa). The in-memory + /// half still matters for the one case the table cannot cover — a store so + /// broken that even the tombstone write failed. + async fn outstanding_deferred_purges(&self) -> std::collections::BTreeSet { + let mut outstanding = self.deferred_purges.read().await.clone(); + match self.store.read().await.outstanding_wallet_removals() { + Ok(ids) => outstanding.extend(ids), + Err(e) => tracing::warn!( + error = %e, + "Could not read shielded wallet-removal tombstones; falling back to the \ + in-memory deferred-purge set for this pass" + ), + } + outstanding + } + + /// One non-blocking attempt at `wallet_id`'s deferred purge, made + /// unconditionally: the caller has already decided — from the in-memory + /// set, the durable tombstones, or their union — that the id has one. + /// + /// Takes the store lock but no registry lock and never the `lifecycle` + /// mutex, so it composes with callers that already hold it. + async fn finish_deferred_purge(&self, wallet_id: WalletId) -> DeferredPurgeOutcome { + use super::store::{admission_now_ms, AdmissionToken, DESTRUCTIVE_BARRIER_MS}; + + let token = match AdmissionToken::generate() { + Ok(token) => token, + Err(e) => return DeferredPurgeOutcome::Failed(format!("admission token: {e}")), + }; + let admitted = { + let mut store = self.store.write().await; + match store.begin_destructive_admission( + Some(wallet_id), + token, + admission_now_ms(), + DESTRUCTIVE_BARRIER_MS, + ) { + // A live claim for this wallet: leave the record alone and + // stay deferred. Deleting it mid-broadcast is what makes a + // padded single-note claim's identity unrecoverable. + Ok(live) => live == 0, + Err(e) => { + tracing::debug!( + wallet_id = %hex::encode(wallet_id), + error = %e, + "Deferred shielded purge could not take admission; still deferred" + ); + return DeferredPurgeOutcome::Failed(format!("destructive admission: {e}")); + } + } + }; + if !admitted { + self.release_destructive_admission(token).await; + return DeferredPurgeOutcome::Busy; + } + + let purged = self.store.write().await.purge_wallet(wallet_id); + self.release_destructive_admission(token).await; + match purged { + Ok(()) => { + // Drop the DURABLE tombstone first, and only report the + // deferral finished if that succeeded. Clearing the in-memory + // set over a tombstone that is still on disk would report + // "nothing outstanding" while the next open still finds one — + // the fast path must never claim more than the durable record. + // + // Not `Done` either: a tombstone that outlives this call makes + // a later pass purge the wallet again, so a registration + // installed now would have its fresh state wiped from under it. + if let Err(e) = self.store.write().await.clear_wallet_removal(wallet_id) { + tracing::warn!( + wallet_id = %hex::encode(wallet_id), + error = %e, + "Deferred shielded purge completed but its removal tombstone could not \ + be cleared; the (now no-op) purge stays deferred" + ); + self.deferred_purges.write().await.insert(wallet_id); + return DeferredPurgeOutcome::Failed(format!( + "purged, but the removal tombstone could not be cleared: {e}" + )); + } + self.deferred_purges.write().await.remove(&wallet_id); + tracing::info!( + wallet_id = %hex::encode(wallet_id), + "Completed a shielded store purge deferred by an earlier wallet removal" + ); + DeferredPurgeOutcome::Done + } + Err(e) => { + tracing::warn!( + wallet_id = %hex::encode(wallet_id), + error = %e, + "Deferred shielded purge failed again; still deferred" + ); + // Record it in memory too: a durable-only tombstone whose + // purge fails stays tracked even if the tombstone table later + // becomes unreadable. + self.deferred_purges.write().await.insert(wallet_id); + DeferredPurgeOutcome::Failed(e.to_string()) + } + } + } + + /// 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 lease(s) or unresolved claim \ + record(s) remain after {}s; refusing to purge state a claim may still need. An unresolved \ + claim is released by re-running the same invitation claim to a terminal \ + outcome or a registered identity", + 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" ); } } @@ -1203,6 +1737,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 // coordinator registries if it fails. Clearing `accounts` / `persisters` // makes the coordinator forget every bound wallet (no syncs until @@ -1267,6 +1813,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. // Either reset may fail after changing part of its persistent state, // so the next identical bind must not fast-path over a potentially @@ -1289,6 +1840,34 @@ impl NetworkShieldedCoordinator { // told the clear failed, keeps its own state. self.accounts.write().await.clear(); self.persisters.write().await.clear(); + // `purge_all_subwallets` above covered every wallet, deferred ones + // included, so nothing is left for a later pass to finish + // (#4313 review finding coordinator.rs:856). Only on the success + // path: a failed clear leaves the residue, and the entries with it. + // + // The DURABLE tombstones go with them, and go FIRST: a tombstone that + // outlived the wipe would make every later pass re-run a no-op purge + // forever, and after a restart it would be the only outstanding entry + // (#4313 review finding 5ca995a0d2aa). One that cannot be dropped + // stays in the in-memory set so the retry is not lost either. + { + let outstanding = self.outstanding_deferred_purges().await; + let mut store = self.store.write().await; + let mut unfinished = std::collections::BTreeSet::new(); + for wallet_id in outstanding { + if let Err(e) = store.clear_wallet_removal(wallet_id) { + tracing::warn!( + wallet_id = %hex::encode(wallet_id), + error = %e, + "Cleared the shielded store but could not drop this wallet's removal \ + tombstone; a later pass will retry the (now no-op) purge" + ); + unfinished.insert(wallet_id); + } + } + drop(store); + *self.deferred_purges.write().await = unfinished; + } if let Ok(mut g) = self.last_caught_up_at.lock() { *g = None; } @@ -1364,6 +1943,23 @@ impl NetworkShieldedCoordinator { return Self::cooldown_skip_summary(self).await; } + // Finish any purge a removal deferred because the store failed after + // that removal's commit point (#4313 review finding + // coordinator.rs:856). This is the "later pass" that makes the + // deferral re-entrant rather than a leak; it is a cheap no-op set + // membership check in the overwhelmingly common empty case, and it + // runs before the registry snapshot so a purge and this pass's own + // writes cannot interleave over the same subwallet — the deferred + // wallet is unregistered, so it contributes nothing to `subwallets` + // either way. + let still_deferred = self.finish_deferred_purges().await; + if still_deferred > 0 { + tracing::warn!( + still_deferred, + "Shielded store purges from earlier wallet removals are still outstanding" + ); + } + // Snapshot the flat subwallet registry. This Vec is both // the IVK fan-out for sync_notes_across and the // identity-map for per-wallet summary demux below. @@ -2348,6 +2944,7 @@ mod tests { nullifiers: vec![], st_bytes: transition.serialize_to_bytes().unwrap(), attempts: 0, + identity_index: None, identity_nonce_finalized: true, identity_user_abandoned: false, }, @@ -2921,6 +3518,7 @@ mod tests { nullifiers: vec![], st_bytes: vec![0x33; 64], attempts: 0, + identity_index: None, identity_nonce_finalized: false, identity_user_abandoned: false, }; @@ -3297,6 +3895,7 @@ mod tests { nullifiers: vec![], st_bytes: vec![0x73; 64], attempts: 0, + identity_index: None, identity_nonce_finalized: false, identity_user_abandoned: false, }; @@ -3410,6 +4009,7 @@ mod tests { nullifiers: vec![], st_bytes: vec![0x73; 64], attempts: 0, + identity_index: None, identity_nonce_finalized: true, identity_user_abandoned: false, }, @@ -3494,7 +4094,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); @@ -3609,7 +4212,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" @@ -3797,6 +4403,940 @@ 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, + identity_nonce_finalized: false, + identity_user_abandoned: false, + }, + 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: it releases its lease AND resolves its record (an + // armed record blocks on its own — see + // `an_armed_claim_record_without_a_lease_blocks_clear_and_removal`), + // so the very next clear() drains immediately and wipes. + coordinator + .store() + .write() + .await + .end_claim_admission(lease) + .expect("release lease"); + coordinator + .store() + .write() + .await + .clear_redrive(id, &[0x5A; 32]) + .expect("resolve the record"); + 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); + } + + /// An ARMED claim record whose lease is gone — lapsed past + /// `CLAIM_LEASE_MS` by a clock jump, a suspension or failed renewals, or + /// displaced — must still hold off both `clear()` and wallet removal + /// (#4313 review thread r3839256998). Before the fix destructive admission + /// counted live leases only, so either operation counted zero claims and + /// deleted the only row holding a broadcast claim's padded identity id. + /// Resolving the record (a terminal outcome or the registration + /// acknowledgement) is what lets them through. + #[tokio::test(start_paused = true)] + async fn an_armed_claim_record_without_a_lease_blocks_clear_and_removal() { + let dir = temp_dir("armed_record_no_lease"); + 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; + // The lease is gone for good — exactly what a reap after a lapse does. + coordinator + .store() + .write() + .await + .end_claim_admission(lease) + .expect("drop the lease"); + + let cleared = coordinator.clear().await; + assert!( + matches!( + cleared, + Err(crate::error::PlatformWalletError::ShieldedLifecycleBusy { .. }) + ), + "clear() must refuse while an armed claim record is unresolved, got {cleared:?}" + ); + let removed = coordinator.unregister_wallet(wallet_id).await; + assert!( + matches!( + removed, + Err(crate::error::PlatformWalletError::ShieldedLifecycleBusy { .. }) + ), + "wallet removal must refuse too, got {removed:?}" + ); + assert_eq!( + coordinator.registered_subwallets().await.len(), + 1, + "a refused removal leaves the registration untouched" + ); + let records = coordinator + .store() + .read() + .await + .pending_redrives(id) + .expect("records"); + assert_eq!(records.len(), 1, "the record must survive both refusals"); + + // The way out: the claim resolves, and both operations go through. + coordinator + .store() + .write() + .await + .clear_redrive(id, &records[0].activity_id) + .expect("resolve the record"); + coordinator + .unregister_wallet(wallet_id) + .await + .expect("removal proceeds once the record is resolved"); + coordinator + .clear() + .await + .expect("clear proceeds once the record is resolved"); + + 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 — lease released AND record + // resolved; an armed record blocks on its own — completes the removal. + coordinator + .store() + .write() + .await + .end_claim_admission(lease) + .expect("release lease"); + let activity_id = coordinator + .store() + .read() + .await + .pending_redrives(id) + .expect("records")[0] + .activity_id; + coordinator + .store() + .write() + .await + .clear_redrive(id, &activity_id) + .expect("resolve the record"); + 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 store failure AFTER the removal's commit point must not abort the + /// removal (#4313 review finding coordinator.rs:856). + /// + /// By the time `purge_wallet` runs, `on_admitted` has set the wallet's + /// irreversible `shielded_detached` flag and the account, persister and + /// hydration registrations are gone. Returning the purge's I/O error there + /// made `remove_wallet_with_teardown` propagate BEFORE removing the wallet + /// from either manager map: the host was handed back a live wallet that + /// could never bind shielded state again, whose coordinator registrations + /// had already disappeared, reported through an FFI code (generic 6) it + /// cannot retry. + /// + /// So this pins the whole post-commit contract: the call returns `Ok`, the + /// hook ran, the registries are clear, the unfinished purge is REMEMBERED, + /// and a later pass actually completes it once the store recovers. + /// + /// `query_only` on the durable connection is what makes the post-commit + /// failure reachable at all — the branch is dead while the store is + /// healthy, and the pre-commit admission drain cannot produce it. + #[tokio::test(start_paused = true)] + async fn a_purge_failure_after_the_commit_point_completes_the_removal_and_defers_the_purge() { + use crate::wallet::shielded::store::PendingRedrive; + + let dir = temp_dir("unregister_purge_failure"); + let coordinator = coordinator_with_one_wallet(&dir).await; + let wallet_id: WalletId = [0x11; 32]; + let record_id = SubwalletId::new(wallet_id, 0); + + // Durable residue that the purge is supposed to delete. + coordinator + .store() + .write() + .await + .arm_redrive( + record_id, + PendingRedrive { + activity_id: [0x33; 32], + anchor: [0x0A; 32], + nullifiers: vec![[0x0D; 32]], + st_bytes: vec![0xAB; 32], + attempts: 0, + identity_index: Some(7), + identity_nonce_finalized: false, + identity_user_abandoned: false, + }, + ) + .expect("arm the residue"); + assert!( + !coordinator.registered_subwallets().await.is_empty(), + "precondition: the wallet is registered" + ); + + // Only the purge fails, and it runs after admission is already held — + // i.e. strictly past the removal's commit point. + coordinator + .store() + .read() + .await + .fail_purge_wallet_for_tests(); + + let mut hook_fired = false; + let removed = coordinator + .unregister_wallet_with(wallet_id, || hook_fired = true) + .await; + + // RED before the fix: `Err(ShieldedStoreError)`, with the wallet left + // registered in the manager by the propagating `?` in + // `remove_wallet_with_teardown`. + assert!( + removed.is_ok(), + "a purge failure past the commit point must NOT abort the removal — the detach flag \ + is already set and the registries are already gone, so the caller cannot be handed \ + back a live wallet; got {removed:?}" + ); + assert!( + hook_fired, + "the removal was committed, so the pre-teardown hook must have run" + ); + assert!( + coordinator.registered_subwallets().await.is_empty(), + "the committed removal drops the registrations" + ); + assert!( + coordinator.persisters.read().await.is_empty(), + "the committed removal drops the persister" + ); + + // The unfinished work is remembered, not lost. + assert!( + coordinator + .deferred_purges + .read() + .await + .contains(&wallet_id), + "the failed purge must be recorded for a later pass" + ); + assert!( + coordinator + .store() + .read() + .await + .has_pending_row_for_tests(wallet_id) + .expect("count rows"), + "precondition for the retry: the residue really did survive the failed purge" + ); + + // A later pass, once the store is writable again, finishes the job. + coordinator + .store() + .read() + .await + .allow_purge_wallet_for_tests(); + assert_eq!( + coordinator.finish_deferred_purges().await, + 0, + "the deferred purge must complete once the store accepts writes again" + ); + assert!( + !coordinator + .store() + .read() + .await + .has_pending_row_for_tests(wallet_id) + .expect("count rows"), + "the deferred pass must actually delete the residue" + ); + assert!( + coordinator.deferred_purges.read().await.is_empty(), + "a completed purge is forgotten" + ); + + // Idempotent: a second pass over an empty set is a no-op. + assert_eq!(coordinator.finish_deferred_purges().await, 0); + + let _ = std::fs::remove_dir_all(&dir); + } + + /// A deferred purge must survive the PROCESS, not just the coordinator + /// that deferred it (#4313 review finding 5ca995a0d2aa). + /// + /// The deferral used to live only in the coordinator's in-memory + /// `deferred_purges` set. A newly opened coordinator starts with that set + /// empty, so after a restart `finish_deferred_purges` reported "nothing + /// outstanding" without inspecting a single surviving row, and + /// the deferred-purge attempt — which `register_locked` relies on — + /// returned `true` on its membership check alone. Re-registering the same + /// wallet id then installed OVER the orphaned rows: the removed wallet's + /// pending claims and its stale `last_synced_note_index`, which restore + /// only ever advances, so the re-added wallet would skip the historical + /// notes the purge existed to force a rescan of. + /// + /// This drives the real restart: the first coordinator (and its store, and + /// therefore its SQLite connections and its `fail_purge_wallet` injection) + /// is DROPPED, and a second coordinator is opened over the same file the + /// way a relaunched process opens it. + #[tokio::test(start_paused = true)] + async fn a_deferred_purge_survives_a_process_restart() { + use crate::wallet::shielded::store::PendingRedrive; + + let dir = temp_dir("unregister_purge_restart"); + let db_path = dir.join("tree.sqlite"); + let wallet_id: WalletId = [0x11; 32]; + let record_id = SubwalletId::new(wallet_id, 0); + + // ---- First process: remove the wallet, and lose the purge. ---- + { + let coordinator = coordinator_with_one_wallet(&dir).await; + coordinator + .store() + .write() + .await + .arm_redrive( + record_id, + PendingRedrive { + activity_id: [0x55; 32], + anchor: [0x0A; 32], + nullifiers: vec![[0x0F; 32]], + st_bytes: vec![0xEF; 32], + attempts: 0, + identity_index: Some(9), + identity_nonce_finalized: false, + identity_user_abandoned: false, + }, + ) + .expect("arm the residue"); + coordinator + .store() + .read() + .await + .fail_purge_wallet_for_tests(); + coordinator + .unregister_wallet_with(wallet_id, || {}) + .await + .expect("the removal completes despite the purge failure"); + assert!( + coordinator + .deferred_purges + .read() + .await + .contains(&wallet_id), + "precondition: the first process deferred the purge" + ); + // Process death. Dropping the coordinator drops the store, its + // SQLite connections, and the in-memory deferral with them. + } + + // ---- Second process: a fresh coordinator over the same file. ---- + let store = FileBackedShieldedStore::open_path(&db_path, 100).expect("reopen file store"); + assert!( + store + .has_pending_row_for_tests(wallet_id) + .expect("count rows"), + "precondition: the orphaned rows really did survive the restart" + ); + let coordinator = NetworkShieldedCoordinator::new( + Arc::new(dash_sdk::Sdk::new_mock()), + dashcore::Network::Testnet, + db_path.clone(), + store, + ); + + // RED before the fix: the fresh set is empty, so this is 0 and the + // orphaned rows below survive untouched. + assert!( + coordinator + .deferred_purges + .read() + .await + .contains(&wallet_id), + "the relaunched coordinator must learn the unfinished purge from the store" + ); + assert_eq!( + coordinator.finish_deferred_purges().await, + 0, + "the relaunched coordinator must be able to finish it" + ); + assert!( + !coordinator + .store() + .read() + .await + .has_pending_row_for_tests(wallet_id) + .expect("count rows"), + "the orphaned rows must actually be deleted after the restart" + ); + assert!( + coordinator + .store() + .read() + .await + .outstanding_wallet_removals() + .expect("read tombstones") + .is_empty(), + "a completed purge drops its durable tombstone, so a THIRD process starts clean" + ); + + // And the tombstone is gone for good: a third open sees nothing. + drop(coordinator); + let reopened = FileBackedShieldedStore::open_path(&db_path, 100).expect("reopen again"); + assert!(reopened + .outstanding_wallet_removals() + .expect("read tombstones") + .is_empty()); + + let _ = std::fs::remove_dir_all(&dir); + } + + /// The restart sibling of + /// [`re_registering_a_wallet_finishes_its_deferred_purge_first`]: the + /// re-registration guard must ALSO hold when the deferral was inherited + /// from a previous process rather than observed in this one + /// (#4313 review finding 5ca995a0d2aa). + /// + /// This is the reviewer's exact harm — `register_locked` relies on + /// the deferred-purge attempt, which used to short-circuit to `true` on an + /// in-memory set that a relaunch had emptied. + #[tokio::test(start_paused = true)] + async fn re_registering_after_a_restart_still_finishes_the_inherited_purge() { + use crate::wallet::shielded::store::PendingRedrive; + + let dir = temp_dir("unregister_purge_restart_rebind"); + let db_path = dir.join("tree.sqlite"); + let wallet_id: WalletId = [0x11; 32]; + let record_id = SubwalletId::new(wallet_id, 0); + + { + let coordinator = coordinator_with_one_wallet(&dir).await; + coordinator + .store() + .write() + .await + .arm_redrive( + record_id, + PendingRedrive { + activity_id: [0x66; 32], + anchor: [0x0A; 32], + nullifiers: vec![[0x1F; 32]], + st_bytes: vec![0xBE; 32], + attempts: 0, + identity_index: Some(4), + identity_nonce_finalized: false, + identity_user_abandoned: false, + }, + ) + .expect("arm the residue"); + coordinator + .store() + .read() + .await + .fail_purge_wallet_for_tests(); + coordinator + .unregister_wallet_with(wallet_id, || {}) + .await + .expect("the removal completes despite the purge failure"); + } + + let store = FileBackedShieldedStore::open_path(&db_path, 100).expect("reopen file store"); + let coordinator = NetworkShieldedCoordinator::new( + Arc::new(dash_sdk::Sdk::new_mock()), + dashcore::Network::Testnet, + db_path, + store, + ); + + // The host re-adds the same wallet id after the relaunch. + let views = OrchardKeySet::from_seed(&[0x42u8; 64], dashcore::Network::Testnet, 0) + .expect("derive viewing keys") + .viewing_keys(); + let mut account_views = BTreeMap::new(); + account_views.insert(0u32, views); + coordinator + .register_wallet( + wallet_id, + account_views, + WalletPersister::new(wallet_id, Arc::new(NoPlatformPersistence)), + ) + .await + .expect("register"); + + // RED before the fix: the inherited residue is still there, now under + // a live registration. + assert!( + !coordinator + .store() + .read() + .await + .has_pending_row_for_tests(wallet_id) + .expect("count rows"), + "a re-registration after a restart must finish the inherited purge rather than \ + install over the removed wallet's rows" + ); + assert!( + coordinator.deferred_purges.read().await.is_empty(), + "and the deferral is settled once the purge has actually run" + ); + + let _ = std::fs::remove_dir_all(&dir); + } + + /// A deferred purge must be finished before the same wallet id is + /// RE-REGISTERED, or the stale `last_synced_note_index` the purge exists to + /// delete would make the fresh bind report caught-up and never re-emit its + /// notes (#4313 review finding coordinator.rs:856). + #[tokio::test(start_paused = true)] + async fn re_registering_a_wallet_finishes_its_deferred_purge_first() { + use crate::wallet::shielded::store::PendingRedrive; + + let dir = temp_dir("unregister_purge_rebind"); + let coordinator = coordinator_with_one_wallet(&dir).await; + let wallet_id: WalletId = [0x11; 32]; + let record_id = SubwalletId::new(wallet_id, 0); + + coordinator + .store() + .write() + .await + .arm_redrive( + record_id, + PendingRedrive { + activity_id: [0x44; 32], + anchor: [0x0A; 32], + nullifiers: vec![[0x0E; 32]], + st_bytes: vec![0xCD; 32], + attempts: 0, + identity_index: Some(3), + identity_nonce_finalized: false, + identity_user_abandoned: false, + }, + ) + .expect("arm the residue"); + + coordinator + .store() + .read() + .await + .fail_purge_wallet_for_tests(); + coordinator + .unregister_wallet_with(wallet_id, || {}) + .await + .expect("the removal completes despite the purge failure"); + assert!(coordinator + .deferred_purges + .read() + .await + .contains(&wallet_id)); + + // The host re-binds the same wallet id, and the store is healthy again. + coordinator + .store() + .read() + .await + .allow_purge_wallet_for_tests(); + let views = OrchardKeySet::from_seed(&[0x42u8; 64], dashcore::Network::Testnet, 0) + .expect("derive viewing keys") + .viewing_keys(); + let mut account_views = BTreeMap::new(); + account_views.insert(0u32, views); + coordinator + .register_wallet( + wallet_id, + account_views, + WalletPersister::new(wallet_id, Arc::new(NoPlatformPersistence)), + ) + .await + .expect("register"); + + assert!( + coordinator.deferred_purges.read().await.is_empty(), + "re-registration must finish the deferred purge rather than install over it" + ); + assert!( + !coordinator + .store() + .read() + .await + .has_pending_row_for_tests(wallet_id) + .expect("count rows"), + "the stale state must be gone before the new registration is live" + ); + + let _ = std::fs::remove_dir_all(&dir); + } + + /// Remove `wallet_id` through `peer` with its purge forced to fail, so the + /// only trace of the unfinished purge is the DURABLE tombstone (plus + /// `peer`'s own in-memory set, which no other coordinator can see), and + /// leave one residue row behind for the purge to delete. + async fn peer_removes_with_a_failed_purge( + peer: &NetworkShieldedCoordinator, + wallet_id: WalletId, + ) { + use crate::wallet::shielded::store::PendingRedrive; + + peer.store() + .write() + .await + .arm_redrive( + SubwalletId::new(wallet_id, 0), + PendingRedrive { + activity_id: [0x77; 32], + anchor: [0x0A; 32], + nullifiers: vec![[0x2F; 32]], + st_bytes: vec![0xAB; 32], + attempts: 0, + identity_index: Some(2), + identity_nonce_finalized: false, + identity_user_abandoned: false, + }, + ) + .expect("arm the residue"); + peer.store().read().await.fail_purge_wallet_for_tests(); + peer.unregister_wallet_with(wallet_id, || {}) + .await + .expect("the removal completes despite the purge failure"); + } + + /// A second coordinator over the same file, opened the way another + /// process (or another manager in this one) opens it. + fn open_peer_coordinator(db_path: &std::path::Path) -> NetworkShieldedCoordinator { + let store = FileBackedShieldedStore::open_path(db_path, 100).expect("open file store"); + NetworkShieldedCoordinator::new( + Arc::new(dash_sdk::Sdk::new_mock()), + dashcore::Network::Testnet, + db_path.to_path_buf(), + store, + ) + } + + /// A tombstone a PEER coordinator writes after this one opened lives only + /// in the durable table. `finish_deferred_purges` counted it in the + /// durable union but re-gated each attempt on the in-memory set, so it + /// reported the purge outstanding forever without ever running it + /// (#4313 review finding r3867666255). + #[tokio::test(start_paused = true)] + async fn a_peer_tombstone_written_after_open_is_purged_by_the_sync_pass() { + let dir = temp_dir("peer_tombstone_sync_pass"); + let db_path = dir.join("tree.sqlite"); + let wallet_id: WalletId = [0x11; 32]; + let peer = coordinator_with_one_wallet(&dir).await; + let this = open_peer_coordinator(&db_path); + + peer_removes_with_a_failed_purge(&peer, wallet_id).await; + assert!( + this.deferred_purges.read().await.is_empty(), + "precondition: this coordinator opened before the tombstone existed" + ); + + // RED before the fix: 1, and the residue survives. + assert_eq!( + this.finish_deferred_purges().await, + 0, + "a durable-only tombstone must be purged, not just counted" + ); + let store = this.store().read().await; + assert!(!store + .has_pending_row_for_tests(wallet_id) + .expect("count rows")); + assert!(store + .outstanding_wallet_removals() + .expect("read tombstones") + .is_empty()); + drop(store); + + let _ = std::fs::remove_dir_all(&dir); + } + + /// A registration must not be installed over a durable tombstone for the + /// same wallet: it used to skip a durable-only tombstone (the in-memory + /// gate) and, when a required purge failed, proceed anyway — binding a new + /// wallet over the removed one's notes, pending claims and its stale, + /// advance-only watermark (#4313 review finding r3867666255). Now it is + /// refused until the purge succeeds, and installs normally after. + #[tokio::test(start_paused = true)] + async fn registration_waits_for_a_durable_tombstone_to_be_purged() { + let dir = temp_dir("peer_tombstone_registration"); + let db_path = dir.join("tree.sqlite"); + let wallet_id: WalletId = [0x11; 32]; + let peer = coordinator_with_one_wallet(&dir).await; + let this = open_peer_coordinator(&db_path); + peer_removes_with_a_failed_purge(&peer, wallet_id).await; + + let views = || { + let views = OrchardKeySet::from_seed(&[0x42u8; 64], dashcore::Network::Testnet, 0) + .expect("derive viewing keys") + .viewing_keys(); + BTreeMap::from([(0u32, views)]) + }; + let persister = || WalletPersister::new(wallet_id, Arc::new(NoPlatformPersistence)); + + // This coordinator's purge fails too: the registration must refuse. + this.store().read().await.fail_purge_wallet_for_tests(); + let refused = this.register_wallet(wallet_id, views(), persister()).await; + assert!( + matches!(refused, Err(PlatformWalletError::ShieldedStoreError(_))), + "a registration over an unpurged tombstone must be refused, got {refused:?}" + ); + assert!( + this.registered_subwallets().await.is_empty(), + "nothing may be installed while the removed wallet's state survives" + ); + assert!(this + .store() + .read() + .await + .has_pending_row_for_tests(wallet_id) + .expect("count rows")); + + // Once the purge can run, the same registration succeeds over a clean store. + this.store().read().await.allow_purge_wallet_for_tests(); + this.register_wallet(wallet_id, views(), persister()) + .await + .expect("registration proceeds once the purge has run"); + let store = this.store().read().await; + assert!( + !store + .has_pending_row_for_tests(wallet_id) + .expect("count rows"), + "the removed wallet's rows must be gone before the new binding is live" + ); + assert!(store + .outstanding_wallet_removals() + .expect("read tombstones") + .is_empty()); + drop(store); + assert_eq!(this.registered_subwallets().await.len(), 1); + + 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, 0); + 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, + identity_nonce_finalized: false, + identity_user_abandoned: false, + }, + ) + .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); + } + /// A token cancelled before the pass starts: no network call (the /// mock SDK would otherwise fail every wallet), no store change and /// no caught-up cooldown stamp (dashpay/platform#5200). 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 0970e75d48a..d2e030b8fbe 100644 --- a/packages/rs-platform-wallet/src/wallet/shielded/file_store.rs +++ b/packages/rs-platform-wallet/src/wallet/shielded/file_store.rs @@ -15,16 +15,17 @@ use std::collections::BTreeMap; use std::error::Error as StdError; use std::fmt; use std::path::{Path, PathBuf}; -use std::sync::Mutex; +use std::sync::{Mutex, MutexGuard}; use dpp::serialization::{PlatformDeserializableTrusted, PlatformSerializable}; use dpp::state_transition::StateTransition; use grovedb_commitment_tree::{ClientPersistentCommitmentTree, Position, Retention}; +use rusqlite::{Connection, OptionalExtension}; use super::balance::{ShieldedBalanceSource, ShieldedLocalAccountBalance}; use super::store::{ - PendingRedrive, ShieldedNote, ShieldedOutgoingNote, ShieldedStore, StalePendingSpend, - SubwalletId, SubwalletState, + AdmissionToken, ClaimKeyReservation, ClaimKeyReservationOutcome, PendingRedrive, ShieldedNote, + ShieldedOutgoingNote, ShieldedStore, StalePendingSpend, SubwalletId, SubwalletState, }; use crate::error::PlatformWalletError; use crate::wallet::platform_wallet::WalletId; @@ -109,6 +110,10 @@ pub struct FileBackedShieldedStore { /// two-connection setup safe. `Mutex` for the same `Sync`-shim /// reason as `tree`. pending_conn: Mutex, + /// Test-only injection point for a failing [`ShieldedStore::purge_wallet`] + /// — see [`fail_purge_wallet_for_tests`](Self::fail_purge_wallet_for_tests). + #[cfg(test)] + fail_purge_wallet: std::sync::atomic::AtomicBool, } impl FileBackedShieldedStore { @@ -174,17 +179,22 @@ impl FileBackedShieldedStore { let conn = Self::open_tuned_connection(&path)?; let tree = ClientPersistentCommitmentTree::open(conn, max_checkpoints) .map_err(|e| FileShieldedStoreError::Storage(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, identity_nonce_finalized INTEGER NOT NULL DEFAULT 0, identity_user_abandoned INTEGER NOT NULL DEFAULT 0, PRIMARY KEY (wallet_id, account_index, activity_id) @@ -215,17 +225,245 @@ impl FileBackedShieldedStore { .map_err(|e| FileShieldedStoreError::Storage(format!("upgrade pending schema: {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::Storage(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::Storage(format!( + "create one_time_claim_reservation table: {e}" + )) + })?; + // Wallet-removal tombstones (#4313 review finding 5ca995a0d2aa). On the + // DURABLE connection, and for the same reason the claim record is: this + // row's whole job is to survive the process death that would otherwise + // lose the deferral, so a WAL frame that only reached the OS is not + // good enough. + // + // Unlike the two lease tables above there is no `expires_at` and these + // rows are never aged out at open. A tombstone is not a lease — it + // records that a removed wallet's state may still be on disk, which + // stays true until a purge makes it false. Only `clear_wallet_removal`, + // after a purge that actually succeeded, may delete one. + pending_conn + .execute( + "CREATE TABLE IF NOT EXISTS shielded_wallet_removal ( + wallet_id BLOB NOT NULL PRIMARY KEY, + recorded_at INTEGER NOT NULL + )", + [], + ) + .map_err(|e| { + FileShieldedStoreError::Storage(format!("create wallet_removal table: {e}")) + })?; let mut store = Self { tree: Mutex::new(tree), path, max_checkpoints, subwallets: BTreeMap::new(), pending_conn: Mutex::new(pending_conn), + #[cfg(test)] + fail_purge_wallet: std::sync::atomic::AtomicBool::new(false), }; store.rehydrate_pending_spends()?; 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::Storage(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::Storage(format!("prepare pending_spends column probe: {e}")) + })?; + stmt.exists([]).map_err(|e| { + FileShieldedStoreError::Storage(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::Storage(format!( + "add pending_spends.identity_index: {e}" + ))) + } + } + } + tx.commit().map_err(|e| { + FileShieldedStoreError::Storage(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") + ) + } + + /// Lock the durable pending/lifecycle connection, mapping mutex + /// poisoning into the store's typed error instead of panicking. + /// + /// Every caller here already returns [`FileShieldedStoreError`], and the + /// commitment-tree mutex has always mapped `PoisonError` this way. The + /// pending-connection sites used to `.expect(...)`, which meant that a + /// panic caught anywhere while this lock was held turned every later + /// claim, purge, migration, or admission call into a second panic rather + /// than a `ShieldedStore::Error` — and through `block_on_worker` that + /// secondary panic is re-raised into the host and can abort the process + /// (#4313 review finding file_store.rs:1175). + /// + /// A poisoned lock is reported, not recovered: the interrupted writer may + /// have left a claim-recovery row half-written, so failing closed is the + /// safe direction — callers surface a retryable store error and the row + /// stays on disk for the next open to rehydrate. + fn lock_pending_conn(&self) -> Result, FileShieldedStoreError> { + self.pending_conn.lock().map_err(|e| { + FileShieldedStoreError::Storage(format!("pending_conn mutex poisoned: {e}")) + }) + } + + /// Make [`ShieldedStore::purge_wallet`] fail, and ONLY it, until + /// [`allow_purge_wallet_for_tests`](Self::allow_purge_wallet_for_tests) + /// lifts it. + /// + /// The purge is the one fallible step that runs AFTER a removal's commit + /// point, so its failure branch is unreachable while the store is healthy + /// and cannot be produced by the pre-commit admission drain either. A + /// blunter seam (`PRAGMA query_only`) fails the admission write too, which + /// aborts the removal BEFORE the commit point and exercises the opposite + /// contract — hence the targeted flag. + #[cfg(test)] + pub(crate) fn fail_purge_wallet_for_tests(&self) { + self.fail_purge_wallet + .store(true, std::sync::atomic::Ordering::SeqCst); + } + + /// Undo [`fail_purge_wallet_for_tests`](Self::fail_purge_wallet_for_tests). + #[cfg(test)] + pub(crate) fn allow_purge_wallet_for_tests(&self) { + self.fail_purge_wallet + .store(false, std::sync::atomic::Ordering::SeqCst); + } + + /// Whether a durable pending row exists for `id` / `activity_id`, read + /// straight from SQLite rather than from the in-memory mirror. + #[cfg(test)] + pub(crate) fn has_pending_row_for_tests( + &self, + wallet_id: WalletId, + ) -> Result { + let conn = self.lock_pending_conn()?; + let count: i64 = conn + .query_row( + "SELECT COUNT(*) FROM shielded_pending_spends WHERE wallet_id = ?1", + rusqlite::params![wallet_id.as_slice()], + |row| row.get(0), + ) + .map_err(|e| FileShieldedStoreError::Storage(format!("count pending rows: {e}")))?; + Ok(count > 0) + } + /// 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 @@ -235,11 +473,17 @@ impl FileBackedShieldedStore { /// bytes prove it spends shielded notes can be retained and skipped safely: /// Platform's nullifier set still prevents those notes from being spent twice. fn rehydrate_pending_spends(&mut self) -> Result<(), FileShieldedStoreError> { - let conn = self.pending_conn.lock().expect("pending_conn mutex"); + // Field-level lock (not `lock_pending_conn`, which borrows all of `self`) + // so the guard can live across the `self.subwallets` hydration below; + // poisoning still maps to the store's typed error instead of a panic. + let conn = self.pending_conn.lock().map_err(|e| { + FileShieldedStoreError::Storage(format!("pending_conn mutex poisoned: {e}")) + })?; let mut stmt = conn .prepare( "SELECT wallet_id, account_index, activity_id, anchor, nullifiers, st_bytes, \ - attempts, identity_nonce_finalized, identity_user_abandoned FROM shielded_pending_spends", + attempts, identity_nonce_finalized, identity_user_abandoned, identity_index \ + FROM shielded_pending_spends", ) .map_err(|e| FileShieldedStoreError::Storage(format!("prepare rehydrate: {e}")))?; let rows = stmt @@ -260,6 +504,7 @@ impl FileBackedShieldedStore { row.get::<_, u32>(6)?, row.get::<_, i64>(7)?, row.get::<_, i64>(8)?, + row.get::<_, Option>(9)?, )) })(); Ok((classifiable_st_bytes, decoded)) @@ -284,6 +529,7 @@ impl FileBackedShieldedStore { attempts, identity_nonce_finalized, identity_user_abandoned, + identity_index, ) = match decoded { Ok(decoded) => decoded, Err(error) => { @@ -351,6 +597,7 @@ impl FileBackedShieldedStore { nullifiers, st_bytes, attempts, + identity_index, identity_nonce_finalized, identity_user_abandoned, }, @@ -371,16 +618,129 @@ impl FileBackedShieldedStore { return Err(error); } }; - let sw = self.subwallets.entry(id).or_default(); - for n in &redrive.nullifiers { - sw.mark_pending(n); - sw.set_pending_spend(n, redrive.anchor, redrive.activity_id); - } - sw.arm_redrive(redrive); + Self::hydrate_pending_row(self.subwallets.entry(id).or_default(), redrive); } 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 row whose anchor or nullifier columns cannot be decoded is reported + /// as [`FileShieldedStoreError::RecoveryCorrupted`] and left in place, + /// never as `None`. "No record" sends the caller down the fresh-claim + /// path, and the row's `st_bytes` may hold the only copy of an executed + /// claim's padded identity id: a fresh scan would find the note spent and + /// end at a false "already claimed" (#4313 review). Failing the claim + /// keeps the record for a later repair or explicit abandonment. + /// + /// [`reserve_one_time_claim_key`]: ShieldedStore::reserve_one_time_claim_key + 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, \ + identity_nonce_finalized, identity_user_abandoned \ + 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)?, + row.get::<_, i64>(5)?, + row.get::<_, i64>(6)?, + )) + }, + ) + .optional() + .map_err(|e| { + FileShieldedStoreError::Storage(format!("read pending claim record: {e}")) + })?; + let Some(( + anchor, + nullifiers, + st_bytes, + attempts, + identity_index, + identity_nonce_finalized, + identity_user_abandoned, + )) = row + else { + return Ok(None); + }; + // The identity-debit recovery flags never apply to a claim record — + // they belong to empty-nullifier ShieldFromIdentity guards — so they + // are not read back into it. A set flag here is damage in a column the + // claim does not use, and is NOT a reason to report "no record": + // that would make the claim rebuild and overwrite the only copy of a + // possibly-broadcast transition's padded identity id. The resume path + // re-validates the stored transition against the retry's arguments + // anyway, so resuming from `st_bytes` is safe. + if identity_nonce_finalized != 0 || identity_user_abandoned != 0 { + tracing::warn!( + "one-time claim record carries identity-debit recovery flags; ignoring them and \ + resuming from its stored transition" + ); + } + let corrupted = |reason: &str| FileShieldedStoreError::RecoveryCorrupted { + account_index: Some(id.account_index), + reason: format!("one-time claim record: {reason}"), + }; + let Ok(anchor) = <[u8; 32]>::try_from(anchor.as_slice()) else { + return Err(corrupted("bad anchor width")); + }; + if nullifiers.is_empty() || nullifiers.len() % 32 != 0 { + return Err(corrupted("bad nullifiers")); + } + Ok(Some(PendingRedrive { + activity_id: *activity_id, + anchor, + nullifiers: nullifiers.as_chunks::<32>().0.to_vec(), + st_bytes, + attempts, + identity_index, + identity_nonce_finalized: false, + identity_user_abandoned: false, + })) + } + + /// 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); + } + /// Identify only transition families whose funding comes from shielded /// note nullifiers. A complete outer transition decode is required so /// corrupt, unknown, or identity-funded bytes remain fail-closed. @@ -414,6 +774,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::Storage(format!("open sqlite: {e}")))?; // Pragmas must be applied before the schema is touched. They survive @@ -421,7 +832,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) @@ -436,6 +847,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::Storage(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 @@ -446,7 +885,7 @@ impl FileBackedShieldedStore { id: SubwalletId, activity_id: &[u8; 32], ) -> Result<(), FileShieldedStoreError> { - let conn = self.pending_conn.lock().expect("pending_conn mutex"); + let conn = self.lock_pending_conn()?; conn.execute( "DELETE FROM shielded_pending_spends \ WHERE wallet_id = ?1 AND account_index = ?2 AND activity_id = ?3", @@ -471,7 +910,7 @@ impl FileBackedShieldedStore { id: SubwalletId, nullifier: &[u8; 32], ) -> Result<(), FileShieldedStoreError> { - let conn = self.pending_conn.lock().expect("pending_conn mutex"); + let conn = self.lock_pending_conn()?; let mut stmt = conn .prepare( "SELECT activity_id, nullifiers FROM shielded_pending_spends \ @@ -622,12 +1061,13 @@ impl ShieldedStore for FileBackedShieldedStore { fn arm_redrive(&mut self, id: SubwalletId, redrive: PendingRedrive) -> Result<(), Self::Error> { { - let conn = self.pending_conn.lock().expect("pending_conn mutex"); + let conn = self.lock_pending_conn()?; 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, identity_nonce_finalized, identity_user_abandoned) \ - VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)", + (wallet_id, account_index, activity_id, anchor, nullifiers, st_bytes, attempts, \ + identity_index, identity_nonce_finalized, identity_user_abandoned) \ + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10)", rusqlite::params![ id.wallet_id.as_slice(), id.account_index, @@ -636,6 +1076,7 @@ impl ShieldedStore for FileBackedShieldedStore { nullifier_blob, redrive.st_bytes, redrive.attempts, + redrive.identity_index, redrive.identity_nonce_finalized, redrive.identity_user_abandoned, ], @@ -688,7 +1129,7 @@ impl ShieldedStore for FileBackedShieldedStore { return Ok(0); }; { - let conn = self.pending_conn.lock().expect("pending_conn mutex"); + let conn = self.lock_pending_conn()?; conn.execute( "UPDATE shielded_pending_spends SET attempts = ?4 \ WHERE wallet_id = ?1 AND account_index = ?2 AND activity_id = ?3", @@ -878,6 +1319,15 @@ impl ShieldedStore for FileBackedShieldedStore { } fn purge_wallet(&mut self, wallet_id: WalletId) -> Result<(), Self::Error> { + #[cfg(test)] + if self + .fail_purge_wallet + .load(std::sync::atomic::Ordering::SeqCst) + { + return Err(FileShieldedStoreError::Storage( + "purge pending spends for wallet: injected test failure".to_string(), + )); + } // The redrive table IS durable (unlike the rest of subwallet // state), so purging the in-memory map alone would leave this // wallet's rows to rehydrate stale reservations / rebroadcast @@ -886,7 +1336,7 @@ impl ShieldedStore for FileBackedShieldedStore { // store was touched (fail-atomic) rather than a memory purge // the caller can't distinguish from a no-op. { - let conn = self.pending_conn.lock().expect("pending_conn mutex"); + let conn = self.lock_pending_conn()?; conn.execute( "DELETE FROM shielded_pending_spends WHERE wallet_id = ?1", rusqlite::params![wallet_id.as_slice()], @@ -908,7 +1358,7 @@ impl ShieldedStore for FileBackedShieldedStore { // debit remains executable after its recipient account is re-bound, so // retain those rows and their minimal in-memory state. { - let conn = self.pending_conn.lock().expect("pending_conn mutex"); + let conn = self.lock_pending_conn()?; conn.execute( "DELETE FROM shielded_pending_spends \ WHERE wallet_id = ?1 AND account_index = ?2 \ @@ -939,7 +1389,7 @@ impl ShieldedStore for FileBackedShieldedStore { // purge; SQL first for the same fail-atomic reason as // `purge_wallet`. { - let conn = self.pending_conn.lock().expect("pending_conn mutex"); + let conn = self.lock_pending_conn()?; conn.execute("DELETE FROM shielded_pending_spends", []) .map_err(|e| { FileShieldedStoreError::Storage(format!("purge all pending spends: {e}")) @@ -949,6 +1399,76 @@ impl ShieldedStore for FileBackedShieldedStore { Ok(()) } + fn record_wallet_removal(&mut self, wallet_id: WalletId) -> Result<(), Self::Error> { + // `INSERT OR REPLACE`, not `INSERT`: re-recording a tombstone that is + // already there is the normal case for a wallet whose purge has failed + // more than once, and must not be an error. + // + // Deliberately NOT gated by the `fail_purge_wallet` test injection — + // this row exists precisely to outlive a failed (or interrupted) purge, + // so it has to be written by an operation that does not share the + // purge's fate (#4313 review finding 5ca995a0d2aa). + let conn = self.lock_pending_conn()?; + conn.execute( + "INSERT OR REPLACE INTO shielded_wallet_removal (wallet_id, recorded_at) \ + VALUES (?1, ?2)", + rusqlite::params![ + wallet_id.as_slice(), + super::store::admission_now_ms() as i64 + ], + ) + .map_err(|e| { + FileShieldedStoreError::Storage(format!("record wallet removal tombstone: {e}")) + })?; + Ok(()) + } + + fn clear_wallet_removal(&mut self, wallet_id: WalletId) -> Result<(), Self::Error> { + let conn = self.lock_pending_conn()?; + conn.execute( + "DELETE FROM shielded_wallet_removal WHERE wallet_id = ?1", + rusqlite::params![wallet_id.as_slice()], + ) + .map_err(|e| { + FileShieldedStoreError::Storage(format!("clear wallet removal tombstone: {e}")) + })?; + Ok(()) + } + + fn outstanding_wallet_removals(&self) -> Result, Self::Error> { + let conn = self.lock_pending_conn()?; + let mut stmt = conn + .prepare("SELECT wallet_id FROM shielded_wallet_removal ORDER BY wallet_id") + .map_err(|e| { + FileShieldedStoreError::Storage(format!( + "prepare wallet removal tombstone read: {e}" + )) + })?; + let rows = stmt + .query_map([], |row| row.get::<_, Vec>(0)) + .map_err(|e| { + FileShieldedStoreError::Storage(format!("read wallet removal tombstones: {e}")) + })?; + let mut out = Vec::new(); + for row in rows { + let raw = row + .map_err(|e| FileShieldedStoreError::Storage(format!("wallet removal row: {e}")))?; + match WalletId::try_from(raw.as_slice()) { + Ok(wallet_id) => out.push(wallet_id), + // A corrupt id cannot be purged (there is no wallet to scope + // the purge to), and returning `Err` for it would wedge every + // later pass behind a row that can never be resolved. Drop it + // from the result and say so, exactly as the pending-spends + // rehydrate does for its corrupt rows. + Err(_) => tracing::warn!( + width = raw.len(), + "ignoring corrupt shielded_wallet_removal row (bad wallet_id width)" + ), + } + } + Ok(out) + } + fn reset_commitment_tree(&mut self) -> Result<(), Self::Error> { // The `ClientPersistentCommitmentTree` wrapper owns its // `Connection` and exposes no public truncate (only the inner @@ -1009,64 +1529,576 @@ impl ShieldedStore for FileBackedShieldedStore { .map_err(|e| FileShieldedStoreError::Storage(format!("reopen commitment tree: {e}")))?; Ok(()) } -} -#[cfg(test)] -mod tests { - use super::*; + // ── 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.lock_pending_conn()?; + let tx = conn + .transaction_with_behavior(rusqlite::TransactionBehavior::Immediate) + .map_err(|e| FileShieldedStoreError::Storage(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::Storage(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::Storage(format!("insert claim lease: {e}")))?; + tx.commit() + .map_err(|e| FileShieldedStoreError::Storage(format!("commit claim admission: {e}")))?; + Ok(true) + } - /// 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!("shielded_tree_test_{tag}_{nanos}.sqlite")) + 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.lock_pending_conn()?; + let tx = conn + .transaction_with_behavior(rusqlite::TransactionBehavior::Immediate) + .map_err(|e| { + FileShieldedStoreError::Storage(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::Storage(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::Storage(format!("renew claim-key reservation: {e}")) + })?; + } + tx.commit().map_err(|e| { + FileShieldedStoreError::Storage(format!("commit claim lease renewal: {e}")) + })?; + Ok(updated > 0) } - /// A [`PendingRedrive`] survives a store reopen — record, attempt - /// counter, AND the note reservations its nullifiers carry — and is - /// deleted (durably) when one of its nullifiers resolves. - #[test] - fn redrive_roundtrip_rehydration_and_resolution() { - let path = temp_tree_path("redrive_roundtrip"); - let id = SubwalletId::new([7u8; 32], 0); - let redrive = PendingRedrive { - activity_id: [1u8; 32], - anchor: [2u8; 32], - nullifiers: vec![[3u8; 32], [4u8; 32]], - st_bytes: vec![0xAB; 96], - attempts: 0, - identity_nonce_finalized: false, - identity_user_abandoned: false, + 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.lock_pending_conn()?; + let tx = conn + .transaction_with_behavior(rusqlite::TransactionBehavior::Immediate) + .map_err(|e| { + FileShieldedStoreError::Storage(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::Storage(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::Storage(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::Storage(format!("read claim-key reservation: {e}")) + })?; + let holder = <[u8; 16]>::try_from(holder.as_slice()).map_err(|_| { + FileShieldedStoreError::Storage( + "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::Storage(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::Storage(format!("commit claim-key reservation: {e}")) + })?; + (reservation, pending) }; - { - let mut store = FileBackedShieldedStore::open_path(&path, 100).expect("open"); - store.arm_redrive(id, redrive.clone()).expect("arm"); - assert_eq!( - store - .bump_redrive_attempts(id, &redrive.activity_id) - .expect("bump"), - 1 - ); - } - { - // Reopen: record + attempts + reservations all rehydrated. - let store = FileBackedShieldedStore::open_path(&path, 100).expect("reopen"); - let got = store.pending_redrives(id).expect("pending_redrives"); - assert_eq!(got.len(), 1, "record survives reopen"); - assert_eq!(got[0].attempts, 1, "attempt counter persists"); - assert_eq!(got[0].st_bytes, redrive.st_bytes, "transition bytes intact"); - assert_eq!( - store.stale_pending_spends(id).expect("stale").len(), - 2, - "both nullifier reservations rehydrated from the record" - ); + // 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 { { - // Resolving one nullifier (release path) durably drops the row. - let mut store = FileBackedShieldedStore::open_path(&path, 100).expect("reopen 2"); - assert!(store.clear_pending(id, &[3u8; 32]).expect("clear")); + let mut conn = self.lock_pending_conn()?; + let tx = conn + .transaction_with_behavior(rusqlite::TransactionBehavior::Immediate) + .map_err(|e| { + FileShieldedStoreError::Storage(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::Storage(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::Storage(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::Storage(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::Storage(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::Storage(format!("restamp claim-key reservation: {e}")) + })?; + tx.commit().map_err(|e| { + FileShieldedStoreError::Storage(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.lock_pending_conn()?; + let tx = conn + .transaction_with_behavior(rusqlite::TransactionBehavior::Immediate) + .map_err(|e| { + FileShieldedStoreError::Storage(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::Storage(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::Storage(format!("release claim-key reservation: {e}")) + })?; + tx.commit().map_err(|e| { + FileShieldedStoreError::Storage(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.lock_pending_conn()?; + let tx = conn + .transaction_with_behavior(rusqlite::TransactionBehavior::Immediate) + .map_err(|e| { + FileShieldedStoreError::Storage(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::Storage(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::Storage(format!("count live claim leases: {e}")) + })?; + // Armed one-time-claim records block on their own, independent of any + // lease (see the trait docs). Counted from the DURABLE table in the + // same transaction, so a record a peer store armed after our open is + // seen too. + let armed_claim_records: i64 = tx + .query_row( + "SELECT COUNT(*) FROM shielded_pending_spends \ + WHERE account_index = ?1 AND (?2 IS NULL OR wallet_id = ?2)", + rusqlite::params![ + super::operations::ONE_TIME_CLAIM_RECORDS_ACCOUNT, + scope_bytes + ], + |row| row.get(0), + ) + .map_err(|e| { + FileShieldedStoreError::Storage(format!("count armed claim records: {e}")) + })?; + let live = live.max(0) + armed_claim_records.max(0); + tx.commit().map_err(|e| { + FileShieldedStoreError::Storage(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.lock_pending_conn()?; + conn.execute( + "DELETE FROM shielded_lifecycle_admission WHERE token = ?1 AND destructive = 1", + rusqlite::params![token.0.as_slice()], + ) + .map_err(|e| { + FileShieldedStoreError::Storage(format!("release destructive barrier: {e}")) + })?; + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// 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!("shielded_tree_test_{tag}_{nanos}.sqlite")) + } + + /// The file store gives the same answer as the in-memory one: an armed + /// one-time-claim record blocks destructive admission after its lease has + /// lapsed, it is read from the DURABLE table (so a second store on the same + /// file sees it too), and removing it releases the block (#4313 review + /// thread r3839256998). + #[test] + fn an_armed_claim_record_blocks_destructive_admission_without_a_lease() { + use super::super::operations::ONE_TIME_CLAIM_RECORDS_ACCOUNT; + use super::super::store::{AdmissionToken, CLAIM_LEASE_MS, DESTRUCTIVE_BARRIER_MS}; + + let path = temp_tree_path("armed_claim_blocks_purge"); + let wallet = [0x5D; 32]; + let id = SubwalletId::new(wallet, ONE_TIME_CLAIM_RECORDS_ACCOUNT); + let token = AdmissionToken([0x31; 16]); + let t0 = 1_000_000u64; + let mut store = FileBackedShieldedStore::open_path(&path, 100).expect("open"); + assert!(store + .begin_claim_admission(wallet, token, t0, CLAIM_LEASE_MS) + .expect("claim admission")); + let record = PendingRedrive { + activity_id: [0x4E; 32], + anchor: [0x01; 32], + nullifiers: vec![[0x02; 32]], + st_bytes: vec![0x03; 8], + attempts: 0, + identity_index: Some(1), + identity_nonce_finalized: false, + identity_user_abandoned: false, + }; + assert!(store + .arm_redrive_under_claim(id, record.clone(), token, t0, CLAIM_LEASE_MS) + .expect("arm")); + + let later = t0 + 10 * CLAIM_LEASE_MS; + let barrier = AdmissionToken([0x90; 16]); + assert_eq!( + store + .begin_destructive_admission(Some(wallet), barrier, later, DESTRUCTIVE_BARRIER_MS) + .expect("admission"), + 1, + "the lapsed lease is reaped, but the armed record must still block" + ); + store.end_destructive_admission(barrier).expect("release"); + + // A peer store on the same file sees the durable record as well. + { + let mut peer = FileBackedShieldedStore::open_path(&path, 100).expect("open peer"); + let peer_barrier = AdmissionToken([0x91; 16]); + assert_eq!( + peer.begin_destructive_admission(None, peer_barrier, later, DESTRUCTIVE_BARRIER_MS) + .expect("peer admission"), + 1 + ); + peer.end_destructive_admission(peer_barrier) + .expect("release"); + } + + store + .clear_redrive(id, &record.activity_id) + .expect("clear the record"); + assert_eq!( + store + .begin_destructive_admission(Some(wallet), barrier, later, DESTRUCTIVE_BARRIER_MS) + .expect("admission"), + 0, + "a resolved or acknowledged record no longer blocks" + ); + let _ = std::fs::remove_file(&path); + } + + /// A [`PendingRedrive`] survives a store reopen — record, attempt + /// counter, AND the note reservations its nullifiers carry — and is + /// deleted (durably) when one of its nullifiers resolves. + #[test] + fn redrive_roundtrip_rehydration_and_resolution() { + let path = temp_tree_path("redrive_roundtrip"); + let id = SubwalletId::new([7u8; 32], 0); + let redrive = PendingRedrive { + activity_id: [1u8; 32], + anchor: [2u8; 32], + nullifiers: vec![[3u8; 32], [4u8; 32]], + st_bytes: vec![0xAB; 96], + attempts: 0, + identity_index: None, + identity_nonce_finalized: false, + identity_user_abandoned: false, + }; + { + let mut store = FileBackedShieldedStore::open_path(&path, 100).expect("open"); + store.arm_redrive(id, redrive.clone()).expect("arm"); + assert_eq!( + store + .bump_redrive_attempts(id, &redrive.activity_id) + .expect("bump"), + 1 + ); + } + { + // Reopen: record + attempts + reservations all rehydrated. + let store = FileBackedShieldedStore::open_path(&path, 100).expect("reopen"); + let got = store.pending_redrives(id).expect("pending_redrives"); + assert_eq!(got.len(), 1, "record survives reopen"); + assert_eq!(got[0].attempts, 1, "attempt counter persists"); + assert_eq!(got[0].st_bytes, redrive.st_bytes, "transition bytes intact"); + assert_eq!( + store.stale_pending_spends(id).expect("stale").len(), + 2, + "both nullifier reservations rehydrated from the record" + ); + } + { + // Resolving one nullifier (release path) durably drops the row. + let mut store = FileBackedShieldedStore::open_path(&path, 100).expect("reopen 2"); + assert!(store.clear_pending(id, &[3u8; 32]).expect("clear")); assert!(store.pending_redrives(id).expect("redrives").is_empty()); } { @@ -1093,6 +2125,7 @@ mod tests { nullifiers: vec![], st_bytes: vec![0xAB; 96], attempts: 0, + identity_index: None, identity_nonce_finalized: false, identity_user_abandoned: false, }; @@ -1190,6 +2223,7 @@ mod tests { nullifiers: vec![], st_bytes: vec![4; 32], attempts: 0, + identity_index: None, identity_nonce_finalized: false, identity_user_abandoned: false, }, @@ -1253,6 +2287,7 @@ mod tests { nullifiers: vec![], st_bytes: vec![0x81; 32], attempts: 0, + identity_index: None, identity_nonce_finalized: false, identity_user_abandoned: false, }; @@ -1267,6 +2302,7 @@ mod tests { nullifiers: vec![[0x91; 32]], st_bytes: note_transition.serialize_to_bytes().unwrap(), attempts: 0, + identity_index: None, identity_nonce_finalized: false, identity_user_abandoned: false, }, @@ -1332,6 +2368,7 @@ mod tests { nullifiers: vec![], st_bytes: transition.serialize_to_bytes().unwrap(), attempts: 0, + identity_index: None, identity_nonce_finalized: false, identity_user_abandoned: false, }, @@ -1385,6 +2422,7 @@ mod tests { nullifiers: vec![[0xC2; 32]], st_bytes, attempts: 0, + identity_index: None, identity_nonce_finalized: false, identity_user_abandoned: false, }, @@ -1418,6 +2456,7 @@ mod tests { nullifiers: vec![], st_bytes: vec![0x34; 24], attempts: 0, + identity_index: None, identity_nonce_finalized: false, identity_user_abandoned: false, }; @@ -1445,6 +2484,7 @@ mod tests { nullifiers: vec![], st_bytes: vec![0x34; 24], attempts: 0, + identity_index: None, identity_nonce_finalized: false, identity_user_abandoned: false, }; @@ -1494,6 +2534,7 @@ mod tests { nullifiers: vec![], st_bytes: vec![0x37; 24], attempts: 5, + identity_index: None, identity_nonce_finalized: false, identity_user_abandoned: false, }; @@ -1503,6 +2544,7 @@ mod tests { nullifiers: vec![[0x6A; 32]], st_bytes: vec![0x7B; 24], attempts: 1, + identity_index: None, identity_nonce_finalized: false, identity_user_abandoned: false, }; @@ -1548,6 +2590,7 @@ mod tests { nullifiers: vec![[nf; 32]], st_bytes: vec![0xCD; 32], attempts: 0, + identity_index: None, identity_nonce_finalized: false, identity_user_abandoned: false, }; @@ -1625,6 +2668,7 @@ mod tests { nullifiers: vec![nf], st_bytes: vec![0xEF; 32], attempts: 0, + identity_index: None, identity_nonce_finalized: false, identity_user_abandoned: false, }, @@ -1974,4 +3018,940 @@ 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, + identity_nonce_finalized: false, + identity_user_abandoned: false, + } + } + + /// 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"), + 2, + "the purge must be told to wait, not cleared to delete (the live lease AND the \ + armed record each hold it off)" + ); + assert_eq!( + claimer.pending_redrives(id).expect("records").len(), + 1, + "the in-flight claim's recovery record must still be there" + ); + + // The lease alone going away does not admit the purge: the armed + // record still holds it off (#4313 review thread r3839256998). + 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"), + 1, + "an unresolved record blocks the purge without any lease" + ); + + // Claim resolved: the purge drains and the full wipe proceeds as before. + claimer + .clear_redrive(id, &[0x09; 32]) + .expect("resolve the record"); + 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); + } + + /// A claim record whose (unused) identity-debit flag columns are damaged + /// must still be read back, not reported as absent: "no record" makes the + /// claim rebuild and overwrite the only copy of the padded identity id. + #[test] + fn a_claim_record_with_identity_debit_flags_set_is_still_resumable() { + let path = temp_tree_path("claim_record_stray_flags"); + let wallet_id: WalletId = [0x53; 32]; + let id = SubwalletId::new(wallet_id, CLAIM_ACCOUNT); + let store = { + let mut store = FileBackedShieldedStore::open_path(&path, 8).expect("store"); + let mut record = admission_record(0x78); + record.identity_index = Some(4); + store.arm_redrive(id, record).expect("arm"); + store + }; + let conn = store.lock_pending_conn().expect("conn"); + conn.execute( + "UPDATE shielded_pending_spends SET identity_user_abandoned = 1", + [], + ) + .expect("damage the flag column"); + let read = FileBackedShieldedStore::read_pending_row(&conn, id, &[0x78; 32]) + .expect("read") + .expect("the record must still be returned"); + assert_eq!(read.identity_index, Some(4)); + assert!(!read.identity_user_abandoned && !read.identity_nonce_finalized); + drop(conn); + drop(store); + let _ = std::fs::remove_file(&path); + } + + /// A claim record whose anchor or nullifier columns are damaged must fail + /// the reservation as `RecoveryCorrupted`, not read as "no record": that + /// would start a fresh claim over a row whose `st_bytes` may hold the only + /// copy of an executed claim's padded identity id (#4313 review). The row + /// stays, and no reservation is taken. + #[test] + fn a_claim_record_with_damaged_metadata_is_an_error_not_an_absence() { + for (tag, damage) in [ + ( + "anchor", + "UPDATE shielded_pending_spends SET anchor = x'0102'", + ), + ( + "nullifiers", + "UPDATE shielded_pending_spends SET nullifiers = x'010203'", + ), + ] { + let path = temp_tree_path(&format!("claim_record_damaged_{tag}")); + let wallet_id: WalletId = [0x54; 32]; + let id = SubwalletId::new(wallet_id, CLAIM_ACCOUNT); + let claim_key = [0x79; 32]; + let mut store = FileBackedShieldedStore::open_path(&path, 8).expect("store"); + store.arm_redrive(id, admission_record(0x79)).expect("arm"); + store + .lock_pending_conn() + .expect("conn") + .execute(damage, []) + .expect("damage the row"); + + let reserved = store.reserve_one_time_claim_key( + id, + claim_key, + AdmissionToken::new(), + 1_000, + 60_000, + ); + assert!( + matches!( + reserved, + Err(FileShieldedStoreError::RecoveryCorrupted { + account_index: Some(CLAIM_ACCOUNT), + .. + }) + ), + "damaged {tag} must be an error, got {reserved:?}" + ); + let rows: i64 = store + .lock_pending_conn() + .expect("conn") + .query_row("SELECT COUNT(*) FROM shielded_pending_spends", [], |r| { + r.get(0) + }) + .expect("count"); + assert_eq!(rows, 1, "the damaged {tag} record must be kept"); + // The reservation rolled back with the transaction: a second + // claimant is not told the invitation is held. + assert!(matches!( + store.reserve_one_time_claim_key( + id, + claim_key, + AdmissionToken::new(), + 1_000, + 60_000 + ), + Err(FileShieldedStoreError::RecoveryCorrupted { .. }) + )); + drop(store); + 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..6b0f1aca27a 100644 --- a/packages/rs-platform-wallet/src/wallet/shielded/keys.rs +++ b/packages/rs-platform-wallet/src/wallet/shielded/keys.rs @@ -23,6 +23,135 @@ 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. +/// +/// # Safety +/// +/// The trait is `unsafe` because [`ScrubOnDrop`]'s `Drop` implementation +/// dereferences the bound into raw `write_volatile` writes over the whole +/// representation of `T`. An implementor must therefore guarantee that its +/// entire in-memory representation is plain data that may be overwritten +/// bytewise: +/// +/// - no owned indirections (heap pointers, `Box`/`Vec`/`String`, file +/// descriptors, handles) — byte-scrubbing one leaks or corrupts the owned +/// resource; +/// - no drop glue that must observe the live value (`needs_drop::()` is +/// `false`) — the guard defensively skips the scrub otherwise, so an +/// implementor with `Drop` silently gets no scrubbing; +/// - no validity invariant violated by the all-zero bit pattern, since the +/// value is left zeroed until its storage is released. +/// +/// Implementing this trait is an explicit statement that byte-zeroing a +/// value of the type destroys the secret and nothing else (#4313 review +/// finding keys.rs:76). +pub(crate) unsafe 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. +// +// SAFETY: both are `Copy`, fixed-size, pointer-free plain-data wrappers with +// no drop glue (asserted by `orchard_secret_types_have_no_drop_glue`), and +// neither carries a validity invariant that the all-zero bit pattern breaks: +// the zeroed value is never read back, only dropped. Byte-zeroing them +// therefore destroys the spend authority and releases nothing else. +unsafe impl ScrubbableSecret for SpendingKey {} +unsafe 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 +216,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 +348,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 +647,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 e5c92c28be6..b7f0b9fdbcd 100644 --- a/packages/rs-platform-wallet/src/wallet/shielded/mod.rs +++ b/packages/rs-platform-wallet/src/wallet/shielded/mod.rs @@ -66,7 +66,10 @@ pub use balance::{ }; 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 8a8f8a4b39b..06614ace20d 100644 --- a/packages/rs-platform-wallet/src/wallet/shielded/operations.rs +++ b/packages/rs-platform-wallet/src/wallet/shielded/operations.rs @@ -66,6 +66,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; @@ -74,7 +75,7 @@ use dpp::ProtocolError; use grovedb_commitment_tree::{Anchor, PaymentAddress}; use tokio::sync::RwLock; use tokio_util::sync::CancellationToken; -use tracing::{debug, info, trace, warn}; +use tracing::{debug, error, info, trace, warn}; /// Number of Orchard actions in a `Shield` (Type 15) bundle. /// @@ -879,6 +880,7 @@ async fn arm_identity_shield_redrive( nullifiers: vec![], st_bytes, attempts: 0, + identity_index: None, identity_nonce_finalized: false, identity_user_abandoned: false, }, @@ -2452,2376 +2454,6578 @@ where } } -/// Whether a failed identity-create should release the notes reserved for it. -/// -/// `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. +// ------------------------------------------------------------------------- +// IdentityCreateFromShieldedPool from a ONE-TIME Orchard key (Type 20, L2 +// invitations — the claim side) +// ------------------------------------------------------------------------- + +/// Convert a failed claim-key reservation into the claim's error. /// -/// 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 { .. }) +/// A damaged claim record stays typed as +/// [`PlatformWalletError::ShieldedRecoveryCorrupted`], so hosts get the +/// recovery-corruption code (56) instead of a generic persistence failure and +/// can tell "repair needed" from an ordinary failure. Anything else is a +/// persistence failure. +fn claim_reservation_error(e: E) -> PlatformWalletError { + match (&e as &dyn std::any::Any).downcast_ref::() { + Some(super::file_store::FileShieldedStoreError::RecoveryCorrupted { + account_index, + reason, + }) => PlatformWalletError::ShieldedRecoveryCorrupted { + account_index: *account_index, + reason: reason.clone(), + }, + _ => PlatformWalletError::Persistence(format!( + "one-time claim: could not reserve the invitation's claim-record key: {e}" + )), + } } -/// 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. +/// Create a brand-new Platform identity funded from a ONE-TIME Orchard spending +/// key (the L2-invitation *claim* side). /// -/// 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( +/// Unlike [`identity_create_from_shielded_pool`], the spend authority is NOT the +/// wallet's own [`OrchardKeySet`]; it is a foreign `one_time_sk` — the single-use +/// Orchard spending key an invitation was funded to. The op: +/// 1. derives the full-viewing / incoming-viewing / spend-authorizing keys from +/// `one_time_sk`, +/// 2. transiently scans the network for the note(s) that key owns (they are not +/// tracked in any subwallet store — see [`super::sync::scan_notes_for_foreign_key`]), +/// 3. selects notes covering exactly `denomination` (the exact-equality model — +/// the fee is metered FROM the denomination) and gates on +/// `denomination > predicted_fee`, +/// 4. witnesses the selected notes against a Platform-recorded anchor from the +/// shared (fully-marked) commitment tree — the SAME anchor probe the +/// pool-funded op uses (so a wallet that hasn't synced past the funding +/// position gets the retryable [`PlatformWalletError::ShieldedMerkleWitnessUnavailable`]), +/// 5. feeds the key-agnostic Type-20 builder with the one-time key's fvk/ask, and +/// 6. broadcasts + waits with the same fetch-by-derived-id fallback. +/// +/// The whole denomination leaves the pool; any spent value above it re-enters as +/// a single change note to `change_address` (the claimer's OWN default Orchard +/// address — over-funding is expected to be zero for a one-time invitation key, +/// but is handled). There is NO wallet-side note reservation to take or release: +/// the spent notes belong to the foreign key, not to any subwallet, so an +/// unconfirmed broadcast simply leaves the on-chain nullifiers as the +/// authoritative no-reuse guarantee. +/// +/// `funding_birth_height` is an advisory hint only (see +/// [`super::sync::scan_notes_for_foreign_key`] — the tree has no height→position +/// oracle, so it cannot seed the scan start today; repeated attempts are +/// bounded by the scan's coordinator-owned resume checkpoint instead). +/// +/// Returns the new identity's id, the proof-verified [`Identity`], and the +/// claim's retained recovery record. The caller registers that identity in its +/// local `IdentityManager` and, once that registration is DURABLE, passes the +/// record to [`acknowledge_one_time_claim_registration`] — which is what +/// finally drops it. Until then the record survives the native return, so a +/// caller that dies, cannot find its wallet entry, or fails to persist the +/// identity leaves a retry able to recover the exact identity id rather than +/// hitting the terminal [`PlatformWalletError::ShieldedInviteAlreadyClaimed`] +/// (#4313 review finding 325ce9fa8f84). +/// +/// `detached` is the claimer wallet's irreversible removal flag. It is re-read +/// once this claim holds store admission, so a claim that begins after a wallet +/// removal has committed is refused with +/// [`PlatformWalletError::WalletNotFound`] before anything is scanned, built or +/// broadcast (#4313 review finding 4a2c679745bb). +#[allow(clippy::too_many_arguments)] +pub async fn identity_create_from_one_time_key( sdk: &Arc, - identity_id: Identifier, -) -> Option { - use dash_sdk::platform::Fetch; + 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, + // The claimer wallet's irreversible `shielded_detached` flag, re-read once + // this claim holds store admission (#4313 review finding 4a2c679745bb). + // See the re-check below for why the caller's own pre-flight check cannot + // stand alone. + detached: &std::sync::atomic::AtomicBool, + // 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 +where + S: ShieldedStore, + P: OrchardProver, + IS: Signer, +{ + use grovedb_commitment_tree::{FullViewingKey, Scope, SpendAuthorizingKey, SpendingKey}; - 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" - ); - } + if public_keys.is_empty() { + return Err(PlatformWalletError::ShieldedBuildError( + "identity-create-from-one-time-key requires at least one public key".to_string(), + )); + } + // The wallet loads the identity's signing key at `MASTER_KEY_INDEX`, so + // that key must be the MASTER authentication key. Checked here, before + // anything is scanned or spent, because the bindings only marshal rows. + { + use dpp::identity::{Purpose, SecurityLevel}; + let master_ok = public_keys.iter().any(|(key, _)| { + key.id() == crate::MASTER_KEY_INDEX + && key.purpose() == Purpose::AUTHENTICATION + && key.security_level() == SecurityLevel::MASTER + }); + if !master_ok { + return Err(PlatformWalletError::ShieldedBuildError(format!( + "identity-create-from-one-time-key requires key ID {} to be MASTER + AUTHENTICATION", + crate::MASTER_KEY_INDEX + ))); } - // 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; + } + + // Derive the Orchard key material from the one-time spending key. `from_bytes` + // returns a `CtOption`; an invalid scalar means the caller handed us a + // non-key, which is a hard input error. + // + // KEY HYGIENE (#4204 finding 1ee08ba70627): orchard 0.14's `SpendingKey` + // and `SpendAuthorizingKey` are `Copy` types with no `Zeroize` support, so + // holding them as plain locals would leave complete spend-authority + // representations in this LONG-LIVED async frame across every network + // await below. Both are contained in [`super::keys::ScrubOnDrop`] guards + // (volatile-scrubbed on every exit path) and explicitly dropped at their + // final use: `sk` right after the derivations here, `ask` right after the + // bundle build. The `*one_time_sk` deref feeding `from_bytes` is the one + // unavoidable transient copy (orchard's API takes the array by value); the + // `Zeroizing` parameter itself scrubs the wallet-layer buffer on drop. + let sk = super::keys::ScrubOnDrop( + Option::::from(SpendingKey::from_bytes(*one_time_sk)).ok_or_else(|| { + PlatformWalletError::ShieldedKeyDerivation( + "one-time spending key is not a valid Orchard SpendingKey".to_string(), + ) + })?, + ); + let fvk = FullViewingKey::from(&*sk); + let ask = super::keys::ScrubOnDrop(SpendAuthorizingKey::from(&*sk)); + let ivk = fvk.to_ivk(Scope::External); + // The spending key's final use is behind us — scrub it before any network + // work; only the spend-auth key must survive to the bundle build. + drop(sk); + + let num_keys = public_keys.len(); + + // The invitee's re-derivable MASTER auth key hash: the unique, Platform-indexed + // handle we recover the created identity by if a claim turns out to have + // already executed (idempotent-retry recovery — see the spent-nullifier + // preflight and the broadcast handling below). Captured before `public_keys` is + // moved into the builder. `None` only if the caller submitted no master auth + // key (identity creation requires one, so this is defensive). + let master_key_hash = master_auth_public_key_hash(&public_keys); + + // Snapshot the submitted keys for the defensive empty-`public_keys` fill (the + // binding signature committed exactly these; same pattern as the pool op). + let submitted_public_keys: BTreeMap = public_keys + .iter() + .map(|(key, _)| (key.id(), key.clone())) + .collect(); + + // ---- Per-FVK single-flight (#4313 review finding 979bbc2fcb3c) ---- + // + // Serialize the COMPLETE claim lifecycle for this invitation key — + // pending-record lookup, transient scan, transition construction, atomic + // arming, broadcast, and finalization — before touching any shared state. + // Without it, two concurrent claims for the same key both see no pending + // record, build transitions with DIFFERENT padded identity ids, and the + // second `arm_one_time_claim_record` (INSERT-OR-REPLACE) overwrites the + // first's byte-exact recovery row while its broadcast may already be on + // the wire — stranding that identity forever. A parked second caller + // instead resumes the settled record when the guard lifts. The guard is + // an async mutex (held across every await below; released on drop, so a + // cancelled claim cannot wedge the key) owned by the SAME coordinator + // that owns the record store it protects. + let claim_record_key = one_time_claim_record_key(&fvk); + let lifecycle_entry = claim_guards.entry_for(claim_record_key); + let _lifecycle_guard = lifecycle_entry.lock().await; + + // ---- Store-level lifecycle admission (#4313 review finding cr-7e6c98b9) ---- + // + // The guard above is per-COORDINATOR: it serializes same-key claims that + // share this `NetworkShieldedCoordinator`, and nothing else. It cannot + // order this claim against `clear` / `unregister_wallet` / `remove_wallet`, + // which take the coordinator's `lifecycle` mutex (a claim takes neither), + // and it cannot reach a SECOND coordinator or process at all — those get + // their own `FileBackedShieldedStore` with its own SQLite connections to + // the same file. Without admission, such a purge deletes this claim's + // pending record while its transition is broadcasting, and the identity it + // creates is unrecoverable. + // + // So admission is taken where the contention actually is — the store. A + // destructive operation that already holds admission refuses this claim + // outright (nothing scanned, built or broadcast); one that starts later + // sees this lease and waits for it. Both directions are decided by a + // single atomic store step on each side, so there is no interleaving in + // which both proceed — see `store::LifecycleAdmission`. + // + // Released deterministically after the claim body below. A claim CANCELLED + // mid-flight (a dropped JNI call) cannot run an async release from `Drop`, + // so its lease is reclaimed by expiry instead — which errs toward "the + // purge waits", never toward "the record is deleted". + let admission = super::store::AdmissionToken::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(), + }); + } + + // ---- Removal fence, re-checked UNDER admission (#4313 review finding + // 4a2c679745bb) ---- + // + // The caller checks `shielded_detached` before it gets here, and that check + // cannot stand alone: an FFI caller can resolve and retain the wallet and + // coordinator, pass the pre-flight check, and only then have + // `unregister_wallet_with` commit the detach, purge the store, release its + // destructive barrier, and drop the wallet from the manager. The stale + // handle would then take a FRESH admission (the barrier is gone), arm a new + // pending row, and broadcast for a wallet the host has already removed — + // and its registration tail, finding no manager entry, would leave that row + // behind. + // + // Re-reading the flag HERE is what closes it, because the two admissions + // are mutually exclusive at the store and totally ordered by it: + // + // * If the removal got there first, it has already set `detached` (the flag + // is set by `on_admitted`, which runs after IT holds admission), so + // either our `begin_claim_admission` above was refused, or the removal + // has fully finished and this read sees `true`. + // * If we got here first, the removal cannot set the flag until it is + // admitted, and it cannot be admitted until we release. So a `false` read + // under our own lease stays true for the whole claim. + // + // Typed as `WalletNotFound`, deliberately NOT `ShieldedLifecycleBusy`: the + // wallet is gone for good, so this is terminal for this wallet and a retry + // can only fail again. It is equally not the invitation's terminal + // `ShieldedInviteAlreadyClaimed` — nothing was spent, and the invitation + // remains claimable by some other wallet. + if detached.load(std::sync::atomic::Ordering::Acquire) { + release_claim_admission(store, admission).await; + return Err(PlatformWalletError::WalletNotFound(format!( + "{} was removed from the manager while the invitation claim was starting; nothing \ + was scanned, built or broadcast", + hex::encode(wallet_id) + ))); + } + + // ---- 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. + claim_reservation_error(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; + let (identity_id, identity, recovery_record) = claim_result?; + Ok(OneTimeClaimOutcome { + identity_id, + identity, + recovery_record, + }) } -/// 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. +/// 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. /// -/// 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. +/// # Why it wraps the whole admitted body /// -/// 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, +/// 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>, - 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(), - )); + 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 — and note that this is + // NOT what protects the claim. Before a chargeable step the + // body re-proves ownership synchronously + // (`assert_claim_lease_before_chargeable_step`); after one, + // the ARMED RECORD itself holds off every destructive + // admission until it is resolved or acknowledged, with no + // lease involved (`ShieldedStore::begin_destructive_admission`, + // #4313 review thread r3839256998). A lapsed lease here + // therefore costs only the per-invitation reservation. + Ok(false) => warn!( + "one-time claim lease lapsed or was displaced mid-claim; the armed \ + claim record still blocks wallet clear and removal" + ), + Err(e) => warn!( + error = %e, + "could not renew the one-time claim lease mid-claim" + ), + } + } + } } +} - // 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(); +/// Re-prove, synchronously and immediately before a CHARGEABLE or otherwise +/// irreversible step, that this claim still owns its purge-protection lease. +/// +/// # Why the heartbeat is not enough +/// +/// [`under_renewed_claim_lease`] logs a failed renewal and lets the claim run +/// on. Once a transition has been armed that no longer preserves the recovery +/// invariant: [`ShieldedStore::renew_claim_admission`] deliberately refuses to +/// resurrect an expired lease, and destructive admission reaps expired leases +/// before counting live claims. A forward wall-clock adjustment, a run of +/// SQLite errors, or a long executor suspension can therefore leave a live +/// claim non-renewable — and a concurrent `clear` or `unregister_wallet` then +/// counts zero claims and deletes the pending row while the transition is on +/// the wire. For a padded single-note bundle that row is the ONLY handle to +/// the randomized identity id, so losing it strands the identity permanently +/// (#4313 review finding f58ed9d910d8). +/// +/// # The gate +/// +/// So ownership is proven positively at the last instant before it matters, +/// rather than assumed from a heartbeat that is allowed to fail. A renewal +/// that returns `Ok(true)` both proves the lease is live and re-stamps it (and +/// the claim-key reservation riding the same token), so the broadcast that +/// follows runs at the START of a full lease window rather than at whatever +/// remained of one a long proof build had already spent. +/// +/// Everything else fails CLOSED and retryably: +/// +/// - `Ok(false)` — the lease is definitively gone (expired or displaced); +/// - `Err(_)` — renewal can no longer PROVE ownership, which for this purpose +/// is the same thing. Treating a store error as "probably still ours" is +/// exactly the repeated-SQLite-error case the finding calls out. +/// +/// Refusing here is clean: the caller has not broadcast, so nothing is +/// consumed, nothing is charged, and no proof is burned. The armed record +/// stays on disk with its notes unspent, so the retry resumes it and +/// re-broadcasts the byte-identical transition. If instead the record IS +/// purged in the meantime, that too is harmless — nothing was ever on the +/// wire, so a fresh claim simply rebuilds. +async fn assert_claim_lease_before_chargeable_step( + store: &Arc>, + admission: super::store::AdmissionToken, + step: &str, +) -> Result<(), PlatformWalletError> { + let renewed = store.write().await.renew_claim_admission( + admission, + super::store::admission_now_ms(), + super::store::CLAIM_LEASE_MS, + ); + match renewed { + Ok(true) => Ok(()), + Ok(false) => { + error!( + step, + "one-time claim: the purge-protection lease is gone; refusing to proceed to a \ + chargeable step without it" + ); + Err(PlatformWalletError::ShieldedLifecycleBusy { + reason: format!( + "this claim's purge-protection lease lapsed or was displaced before {step}; \ + without it a concurrent wallet clear or removal could delete the recovery \ + record while the transition was in flight, so nothing was broadcast — retry \ + the claim" + ), + }) + } + Err(e) => { + error!( + step, + error = %e, + "one-time claim: could not prove the purge-protection lease is still held; \ + refusing to proceed to a chargeable step" + ); + Err(PlatformWalletError::ShieldedLifecycleBusy { + reason: format!( + "this claim's purge-protection lease could not be verified before {step} \ + ({e}); proceeding without proof of ownership risks losing the recovery \ + record mid-flight, so nothing was broadcast — retry the claim" + ), + }) + } + } +} - // 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) +/// 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" + ); + } } -/// 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. +/// The one-time-key claim body, running under a held store admission lease. /// -/// 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; +/// 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. +/// +/// `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>, + 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, Option), 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)" + ); + } - // 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::>()?; + // ---- 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, + admission, + master_key_hash, + submitted_public_keys.clone(), + denomination, + identity_index, + ) + .await + { + OneTimeClaimResume::Resolved(result) => { + // Same retention contract as the fresh-build tail below: a + // successful resume keeps its record until the caller + // acknowledges durable local registration + // (#4313 review finding 325ce9fa8f84). This branch had the + // identical premature-clear ordering. + let retained = finalize_one_time_claim_record( + store, + claim_records_id, + claim_record_key, + &result, + ) + .await; + let (identity_id, identity) = result?; + return Ok((identity_id, identity, retained)); + } + // 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 => {} + } + } - // Build every selected note's `SpendableNote` plus the shared anchor at a - // single checkpoint `depth`. + // ---- Losing claimant: RESUME above, or REFUSE here ---- // - // `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)" - ))); + // 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(), + }); + } + + // Transient scan: re-derive the one-time key's note(s) from the network. + let discovered = + super::sync::scan_notes_for_foreign_key(sdk, scan_checkpoints, &fvk, &ivk, denomination) + .await?; + if discovered.is_empty() { + // No note decrypts under this key — nothing was funded to it (or the + // wallet hasn't synced far enough to see it yet). + return Err(PlatformWalletError::ShieldedNoUnspentNotes); + } + + // Exact-equality selection over the transiently-scanned set: cover exactly + // `denomination`, gate on `denomination > predicted_fee`. Surfaces + // `ShieldedInsufficientBalance { available, required }` when the key's notes + // don't cover the denomination, mirroring the pool-funded neighbor. + let (selected_refs, total_input, predicted_fee) = + select_notes_for_denomination(&discovered, denomination, 2, num_keys, sdk.version())?; + let selected_notes: Vec = selected_refs.into_iter().cloned().collect(); + + info!( + denomination, + predicted_fee, + inputs = selected_notes.len(), + total_input, + keys = num_keys, + "IdentityCreateFromOneTimeKey" + ); + + // Idempotent-retry preflight (no persisted record for this key). If this one-time key's + // selected note(s) are ALREADY spent on chain, a byte-identical claim already + // executed — so we must NOT rebuild+rebroadcast (that would only earn a + // `NullifierAlreadySpent` rejection). Everything checked here is re-derived + // from the invite the invitee holds: the one-time key → its note(s) via the + // transient scan above, and each note's real nullifier (`ShieldedNote.nullifier`, + // stamped `note.nullifier(fvk)` during the scan). If spent, recover the + // previously-created identity by the invitee's own re-derivable MASTER auth key + // hash (`discover_inner`'s unique-hash probe) and return it as success. + let selected_nullifiers: Vec<[u8; 32]> = selected_notes.iter().map(|n| n.nullifier).collect(); + + // The id that an identity created by THIS claim must carry — the single + // handle that ties a recovered identity back to this claim's spend, and the + // reason a MASTER-key-hash hit alone is not evidence of a successful claim + // (see `recovered_identity_matches_claim`). + // + // Consensus derives the new identity id as `double_sha256` over the SORTED + // set of PUBLISHED action nullifiers (`derive_identity_id_from_actions`) and + // rejects a transition whose declared id differs, so this is a binding, not a + // guess. + // + // `None` for a single-spend claim: the builder pads to Orchard's 2-action + // minimum (`num_actions = spends.len().max(2)`) and the padding action's + // dummy nullifier is randomly generated per build, so it participates in the + // derivation but cannot be reproduced on a retry. With two or more real + // spends no padding is added and the published set is exactly + // `selected_nullifiers`. + let expected_identity_id = + (selected_notes.len() >= 2).then(|| identity_id_from_nullifiers(&selected_nullifiers)); + + // Idempotent-retry preflight. If this one-time key's selected note(s) are + // ALREADY spent on chain, this claim can never execute — rebuilding and + // rebroadcasting would only earn a `NullifierAlreadySpent` rejection and burn + // a Halo 2 proof. Hand off to the reconciler, which decides between "this + // claim created that identity" (both bindings verified), "the invitation is + // gone" (terminal), and "executed but not yet indexed" (retryable). + // `Unknown` proceeds here — that is safe pre-broadcast: the idempotent + // broadcast path reconciles via the `NullifierAlreadySpent` verdict, so a + // transient query failure only costs a harmless rebuild. + if nullifier_spent_status(sdk, &selected_nullifiers).await == NullifierSpentStatus::Spent { + // No record was armed on this path — nothing was built or broadcast — + // so there is none to retain or acknowledge. + let (identity_id, identity) = 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?; + return Ok((identity_id, identity, None)); + } + + // 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). + // + // But the heartbeat only LOGS a failed renewal, so it cannot be what + // authorizes the broadcast. Prove ownership synchronously here instead: the + // record above is armed and the transition is about to become chargeable + // and irreversible, and from this point on the record is the only handle + // that can recover a padded single-note claim's identity. If the lease + // cannot be re-proven, abort — nothing has been broadcast, the notes are + // unspent, and the retry resumes this very record + // (#4313 review finding f58ed9d910d8). + assert_claim_lease_before_chargeable_step(store, admission, "the claim broadcast").await?; + + let result = broadcast_and_confirm_one_time_claim( + sdk, + st, + identity_id, + expected_identity_id, + master_key_hash, + &selected_nullifiers, + submitted_public_keys, + denomination, + ) + .await; + // On success the record is RETAINED and handed to the caller, which drops + // it only once the identity is durably registered locally. Clearing it here + // — as this used to — lost the padded identity id whenever the process died, + // the wallet-manager entry was missing, or `persister.store` failed, between + // this line and the JNI return (#4313 review finding 325ce9fa8f84). + let retained = + finalize_one_time_claim_record(store, claim_records_id, claim_record_key, &result).await; + let (identity_id, identity) = result?; + Ok((identity_id, identity, retained)) +} + +/// 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; + + 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> { - // All amount-based spends reject zero before waiting on a concurrent sync's store lock. - ensure_positive_amount(amount)?; - - 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> { - // Reject zero or any other non-member before waiting on a concurrent sync's store lock. - ensure_allowed_denomination(denomination, sdk.version())?; + 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 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()))?; + 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), + identity_nonce_finalized: false, + identity_user_abandoned: false, + }, + 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( +/// Settle the pending-claim record for a claim that has reached an outcome. +/// +/// # A success does NOT clear the record +/// +/// It used to. That dropped the row the moment the transition resolved, which +/// is BEFORE control returns through `PlatformWallet::identity_create_from_one_time_key`, +/// where the identity is added to the local manager and handed to the host +/// persister. A process death in that gap — or a missing wallet-manager entry, +/// or a `persister.store` failure — lost the exact padded identity ID before +/// the JNI caller ever received it. The retry then re-scans, finds the notes +/// spent, and `recover_executed_one_time_claim` has no `expected_identity_id` +/// to bind against (a single-spend bundle's id embeds a randomly generated +/// dummy nullifier), so it returns the TERMINAL +/// [`PlatformWalletError::ShieldedInviteAlreadyClaimed`] and the identity is +/// stranded forever (#4313 review finding 325ce9fa8f84). +/// +/// So the record — the only durable copy of that id — is RETAINED across the +/// native return and the host persistence handoff, and is dropped only by +/// [`acknowledge_one_time_claim_registration`], once local registration has +/// been durably acknowledged. A retry that arrives before that acknowledgement +/// finds the record, sees the notes already spent, and recovers the identity by +/// its declared id instead of failing terminally — which is exactly the +/// reconciliation state the finding asks for, expressed in the machinery that +/// already exists rather than a second one beside it. +/// +/// Retaining is cheap and self-healing: the record is one row keyed by the +/// invitation, the resume path is idempotent, and the acknowledgement clears it +/// on the very next successful pass. +/// +/// # What IS cleared here +/// +/// Only the terminal [`PlatformWalletError::ShieldedInviteAlreadyClaimed`]: it +/// means the invitation produced no identity this wallet can claim, so no +/// future retry can get anything from the record. Every other error keeps it — +/// `ShieldedBroadcastUnconfirmed` (and unproven failures) are exactly the +/// outcomes whose retry must find the declared id again. +/// +/// Returns the retained record's coordinates when the caller is expected to +/// acknowledge it, i.e. on success. +async fn finalize_one_time_claim_record( 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" - ); + key: [u8; 32], + result: &Result<(Identifier, Identity), PlatformWalletError>, +) -> Option { + match result { + Ok(_) => Some(OneTimeClaimRecoveryRecord { + claim_records_id: id, + claim_record_key: key, + }), + Err(PlatformWalletError::ShieldedInviteAlreadyClaimed { .. }) => { + clear_one_time_claim_record(store, id, key).await; + None } + Err(_) => None, } } -/// 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`). +/// The retained recovery record of a SUCCESSFUL one-time-key claim: the durable +/// row that still holds the claim's transition, and with it the exact identity +/// id a padded single-note bundle cannot otherwise reproduce. /// -/// 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( +/// Handed back to the caller by [`identity_create_from_one_time_key`] so the +/// row outlives the native return and the host persistence handoff. The caller +/// registers the identity locally and, ONLY once that registration is durably +/// acknowledged, passes this to [`acknowledge_one_time_claim_registration`] +/// (#4313 review finding 325ce9fa8f84). +/// +/// Dropping this value without acknowledging is safe and is the intended +/// behaviour on a registration failure: the record simply survives, and the +/// next claim of the same invitation resumes it and recovers the identity. +#[derive(Debug, Clone, Copy)] +pub struct OneTimeClaimRecoveryRecord { + claim_records_id: SubwalletId, + claim_record_key: [u8; 32], +} + +#[cfg(test)] +impl OneTimeClaimRecoveryRecord { + /// The receipt for the record armed under `claim_record_key` in this + /// wallet's reserved claim-records subwallet, for tests that drive the + /// registration handoff without running a whole claim. + pub(crate) fn for_tests(wallet_id: WalletId, claim_record_key: [u8; 32]) -> Self { + Self { + claim_records_id: SubwalletId::new(wallet_id, ONE_TIME_CLAIM_RECORDS_ACCOUNT), + claim_record_key, + } + } +} + +/// A successful one-time-key claim: the created identity, plus the recovery +/// record that must be acknowledged once the identity is durably registered +/// locally. See [`OneTimeClaimRecoveryRecord`]. +#[derive(Debug)] +pub struct OneTimeClaimOutcome { + /// The new identity's id. + pub identity_id: Identifier, + /// The proof-verified identity, for local registration. + pub identity: Identity, + /// The retained recovery record, or `None` when this claim armed no record + /// (the pre-broadcast preflight recovered an already-executed claim without + /// ever building one). + pub recovery_record: Option, +} + +/// Drop a successful claim's retained recovery record, now that its identity is +/// durably registered in the caller's local state. +/// +/// This is the acknowledgement half of the retention introduced for +/// #4313 review finding 325ce9fa8f84: until it runs, a retry of the same +/// invitation resumes the record and recovers the identity by its declared id +/// rather than failing terminally. +/// +/// Call it ONLY after local registration is durable — after the identity is in +/// the manager AND its changeset reached the host persister. Calling it early +/// re-opens the exact window the retention closes; not calling it at all costs +/// one stale row that the next claim of this invitation resolves. +/// +/// Best-effort by construction: a failure to clear leaves a settled record that +/// the next attempt re-resolves to the same identity. +pub async fn acknowledge_one_time_claim_registration( store: &Arc>, - id: SubwalletId, - anchor: [u8; 32], - pending_entry: &Option, - notes: &[ShieldedNote], + record: OneTimeClaimRecoveryRecord, ) { - 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)" - ); + debug!( + claim_record_key = %hex::encode(record.claim_record_key), + "one-time claim: identity durably registered; releasing the retained recovery record" + ); + clear_one_time_claim_record(store, record.claim_records_id, record.claim_record_key).await; +} + +/// Describe the first way a resume attempt's arguments disagree with the +/// transition the earlier attempt submitted, or `None` when they agree. +/// +/// Every field compared here is one the resume would otherwise act on with the +/// caller's value while broadcasting the *stored* bytes — the exact mis-binding +/// #4313 review finding 195efdd4ae21 describes. The comparison is on the +/// derived-from-transition side, so it is a statement about what is on the wire, +/// not about what some parallel record claims. +/// +/// Key comparison is exact and whole-set: `IdentityPublicKey` compares by id, +/// purpose, security level, key type, read-only flag, contract bounds and key +/// data, so a retry that keeps the ids but swaps the key material — the case +/// that would register a foreign identity at this wallet's slot — is caught. +/// A retry that merely *reorders* the same keys is not a mismatch: both sides +/// are `BTreeMap`s keyed by key id. +#[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, - wait: SpendResultWait, -) -> Result { - broadcast_shielded_spend_with_redrive_with_metadata( - sdk, - store, - id, - pending_entry, - anchor, - notes, - state_transition, - operation, - wait, - ) - .await - .map(|(result, _)| result) -} - -#[allow(clippy::too_many_arguments)] -async fn broadcast_shielded_spend_with_redrive_with_metadata( - sdk: &Arc, - store: &Arc>, - id: SubwalletId, - pending_entry: &Option, - anchor: [u8; 32], - notes: &[ShieldedNote], - state_transition: &StateTransition, - operation: &'static str, - wait: SpendResultWait, -) -> Result<(StateTransitionProofResult, ResponseMetadata), PlatformWalletError> { - let result = broadcast_shielded_spend(sdk, state_transition, operation, wait).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, + admission: super::store::AdmissionToken, + master_key_hash: Option<[u8; 20]>, + submitted_public_keys: BTreeMap, + denomination: u64, + identity_index: u32, +) -> OneTimeClaimResume { + use dpp::serialization::PlatformDeserializableTrusted; + 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_trusted(&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, - identity_nonce_finalized: false, - identity_user_abandoned: false, + // 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, -} + 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, + ); + } -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, + // 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. + // + // Same chargeable-step gate as the fresh-build path: a re-broadcast is a + // resubmission that can execute and be charged for, so it may not proceed + // on a lease the heartbeat can no longer prove + // (#4313 review finding f58ed9d910d8). Aborting here is clean — the stored + // record and its unspent notes are untouched, and the next retry resumes + // exactly this record again. + if let Err(e) = + assert_claim_lease_before_chargeable_step(store, admission, "the claim re-broadcast").await + { + return OneTimeClaimResume::Resolved(Err(e)); } -} -/// 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) => { + 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) } -/// Re-broadcast every unresolved ShieldFromIdentity transition for `wallet_id`. -/// These records have no nullifiers or spending anchor, so they must not depend -/// on the note-spend stranded-release pass. Exact-byte retries remain safe even -/// after the ordinary spend attempt budget is exhausted: the fixed identity -/// nonce prevents the same transition from debiting twice, while continuing -/// retries lets a wallet recover after switching away from a failing endpoint. -pub(super) async fn redrive_pending_identity_shields( +/// Whether a failed identity-create should release the notes reserved for it. +/// +/// `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. +/// +/// 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>, - wallet_id: WalletId, - cancel: &CancellationToken, -) { - use dpp::serialization::PlatformDeserializableTrusted; - use dpp::state_transition::shield_from_identity_transition::accessors::ShieldFromIdentityTransitionAccessorsV0; + identity_id: Identifier, +) -> Option { + use dash_sdk::platform::Fetch; - let redrives = match store.read().await.pending_redrives_for_wallet(wallet_id) { - Ok(redrives) => redrives, - Err(e) => { - warn!(error = %e, "identity redrive: guard lookup failed"); - return; + 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" + ); + } } - }; - for (id, redrive) in redrives { - // A stop ends the pass between records: no new request starts - // once cancelled. A request already on the wire is left to - // finish on its own (its outcome is handled conservatively). - if cancel.is_cancelled() { - debug!("identity redrive: pass cancelled; remaining guards wait for the next pass"); - return; + // 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; } - if !redrive.nullifiers.is_empty() - || redrive.identity_nonce_finalized - || redrive.identity_user_abandoned - { - continue; - } - let state_transition = - match StateTransition::deserialize_from_bytes_trusted(&redrive.st_bytes) { - Ok(state_transition @ StateTransition::ShieldFromIdentity(_)) => state_transition, - _ => { - warn!("identity redrive: unreadable debit; retaining its retry guard"); - continue; + } + None +} + +// ------------------------------------------------------------------------- +// Internal helpers (free fns) +// ------------------------------------------------------------------------- + +/// 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)" + ))); } - }; - let StateTransition::ShieldFromIdentity(identity_transition) = &state_transition else { - continue; - }; - // The nonce read is the one wait here a stop can cut short: it - // is read-only, so dropping it mid-flight loses nothing. - let Some(fetched) = cancel - .run_until_cancelled(IdentityNonceFetcher::fetch( - sdk, - identity_transition.identity_id(), - )) - .await - else { - debug!("identity redrive: pass cancelled during the nonce read"); - return; - }; - if let Ok(fetched) = fetched { - let result = { - let mut store = store.write().await; - reconcile_identity_shield_nonce( - &mut *store, - id, - redrive.activity_id, - fetched.map(|fetcher| fetcher.0), - identity_transition.nonce(), - ) - }; - match result { - Ok(true) => continue, - Ok(false) => {} + 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) => { - warn!(error = %e, "identity redrive: could not persist nonce finality"); - continue; + 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, + }); } - // A host recovery decision may have archived this snapshot while the - // nonce request was in flight. Do not initiate a new broadcast from it. - // Already submitted network requests cannot be cancelled by this check. - let still_active = store - .read() - .await - .pending_redrives(id) - .map(|current| { - current.iter().any(|record| { - record.activity_id == redrive.activity_id - && record.st_bytes == redrive.st_bytes - && !record.identity_user_abandoned - && !record.identity_nonce_finalized - }) - }) - .unwrap_or(false); - if !still_active { - continue; + + // `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(), + )); } - // Last check before a broadcast starts; a broadcast already - // submitted cannot be cancelled by this check. - if cancel.is_cancelled() { - debug!("identity redrive: pass cancelled before the re-broadcast"); - return; + }; + 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, } - // Unused or unverified state permits only the original signed bytes. - // Even an apparently definitive rejection cannot release the guard: - // this request, or an earlier relay, may deliver the debit afterward. - if let Err(e) = state_transition.broadcast(sdk, None).await { - debug!(error = %e, "identity redrive: original payment remains pending"); + } + + 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> { + // All amount-based spends reject zero before waiting on a concurrent sync's store lock. + ensure_positive_amount(amount)?; + + 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> { + // Reject zero or any other non-member before waiting on a concurrent sync's store lock. + ensure_allowed_denomination(denomination, sdk.version())?; + + 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, + wait: SpendResultWait, +) -> Result { + broadcast_shielded_spend_with_redrive_with_metadata( + sdk, + store, + id, + pending_entry, + anchor, + notes, + state_transition, + operation, + wait, + ) + .await + .map(|(result, _)| result) +} + +#[allow(clippy::too_many_arguments)] +async fn broadcast_shielded_spend_with_redrive_with_metadata( + sdk: &Arc, + store: &Arc>, + id: SubwalletId, + pending_entry: &Option, + anchor: [u8; 32], + notes: &[ShieldedNote], + state_transition: &StateTransition, + operation: &'static str, + wait: SpendResultWait, +) -> Result<(StateTransitionProofResult, ResponseMetadata), PlatformWalletError> { + let result = broadcast_shielded_spend(sdk, state_transition, operation, wait).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, + identity_nonce_finalized: false, + identity_user_abandoned: false, + }; + 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 + } + } +} + +/// Re-broadcast every unresolved ShieldFromIdentity transition for `wallet_id`. +/// These records have no nullifiers or spending anchor, so they must not depend +/// on the note-spend stranded-release pass. Exact-byte retries remain safe even +/// after the ordinary spend attempt budget is exhausted: the fixed identity +/// nonce prevents the same transition from debiting twice, while continuing +/// retries lets a wallet recover after switching away from a failing endpoint. +pub(super) async fn redrive_pending_identity_shields( + sdk: &Arc, + store: &Arc>, + wallet_id: WalletId, + cancel: &CancellationToken, +) { + use dpp::serialization::PlatformDeserializableTrusted; + use dpp::state_transition::shield_from_identity_transition::accessors::ShieldFromIdentityTransitionAccessorsV0; + + let redrives = match store.read().await.pending_redrives_for_wallet(wallet_id) { + Ok(redrives) => redrives, + Err(e) => { + warn!(error = %e, "identity redrive: guard lookup failed"); + return; + } + }; + for (id, redrive) in redrives { + // A stop ends the pass between records: no new request starts + // once cancelled. A request already on the wire is left to + // finish on its own (its outcome is handled conservatively). + if cancel.is_cancelled() { + debug!("identity redrive: pass cancelled; remaining guards wait for the next pass"); + return; + } + if !redrive.nullifiers.is_empty() + || redrive.identity_nonce_finalized + || redrive.identity_user_abandoned + { + continue; + } + let state_transition = + match StateTransition::deserialize_from_bytes_trusted(&redrive.st_bytes) { + Ok(state_transition @ StateTransition::ShieldFromIdentity(_)) => state_transition, + _ => { + warn!("identity redrive: unreadable debit; retaining its retry guard"); + continue; + } + }; + let StateTransition::ShieldFromIdentity(identity_transition) = &state_transition else { + continue; + }; + // The nonce read is the one wait here a stop can cut short: it + // is read-only, so dropping it mid-flight loses nothing. + let Some(fetched) = cancel + .run_until_cancelled(IdentityNonceFetcher::fetch( + sdk, + identity_transition.identity_id(), + )) + .await + else { + debug!("identity redrive: pass cancelled during the nonce read"); + return; + }; + if let Ok(fetched) = fetched { + let result = { + let mut store = store.write().await; + reconcile_identity_shield_nonce( + &mut *store, + id, + redrive.activity_id, + fetched.map(|fetcher| fetcher.0), + identity_transition.nonce(), + ) + }; + match result { + Ok(true) => continue, + Ok(false) => {} + Err(e) => { + warn!(error = %e, "identity redrive: could not persist nonce finality"); + continue; + } + } + } + // A host recovery decision may have archived this snapshot while the + // nonce request was in flight. Do not initiate a new broadcast from it. + // Already submitted network requests cannot be cancelled by this check. + let still_active = store + .read() + .await + .pending_redrives(id) + .map(|current| { + current.iter().any(|record| { + record.activity_id == redrive.activity_id + && record.st_bytes == redrive.st_bytes + && !record.identity_user_abandoned + && !record.identity_nonce_finalized + }) + }) + .unwrap_or(false); + if !still_active { + continue; + } + // Last check before a broadcast starts; a broadcast already + // submitted cannot be cancelled by this check. + if cancel.is_cancelled() { + debug!("identity redrive: pass cancelled before the re-broadcast"); + return; + } + // Unused or unverified state permits only the original signed bytes. + // Even an apparently definitive rejection cannot release the guard: + // this request, or an earlier relay, may deliver the debit afterward. + if let Err(e) = state_transition.broadcast(sdk, None).await { + debug!(error = %e, "identity redrive: original payment remains pending"); + } + bump_redrive_attempts_logged(store, id, &redrive.activity_id).await; + } +} + +/// 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]>, + cancel: &CancellationToken, +) { + use dpp::serialization::PlatformDeserializableTrusted; + + 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 stop ends the pass between records: no new re-broadcast starts + // once cancelled. One already submitted is left to finish and is + // classified as usual — its outcome handling stays conservative. + if cancel.is_cancelled() { + debug!("redrive: pass cancelled; remaining records wait for the next pass"); + return; + } + // Empty-nullifier identity debits are wallet-wide and are driven by + // `redrive_pending_identity_shields`, independently of spending anchors. + if redrive.nullifiers.is_empty() { + continue; + } + // 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_trusted(&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" + ); + } + } + } +} + +/// 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 +/// proves nothing was delivered), or a wait-stage consensus rejection +/// (Platform ran the transition and rejected it on its merits — see +/// [`carries_consensus_rejection`]), means the spend never executed → +/// [`PlatformWalletError::ShieldedBroadcastFailed`], and the caller +/// releases the note reservations via [`cancel_pending`]; +/// - a no-verdict `broadcast()` failure (`AlreadyExists` proves the tx IS +/// in the mempool after a lost-ACK retry; timeouts merely allow it) +/// falls through to the result wait, which either proves execution or +/// classifies the residual ambiguity; +/// - any other wait failure (transport error, timeout, result-proof +/// fetch/verify error, …) is AMBIGUOUS — the relay accepted (or may +/// have accepted) the tx and it may well have executed → +/// [`PlatformWalletError::ShieldedSpendUnconfirmed`], and the caller +/// must leave the reservations in place (each spend flow's outer +/// match has a dedicated arm for this). +/// +/// Mirrors the staging in [`identity_create_from_shielded_pool`], minus +/// its fetch-by-derived-id fallback: a spend leaves no artifact as +/// cheaply queryable as an identity row, so ambiguity is surfaced +/// directly and reconciled by the next nullifier sync. The proven +/// result is discarded; only the confirmation matters. +/// How [`broadcast_shielded_spend`] waits for an already-broadcast spend's +/// result, mirroring the verifier's classification of the family. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum SpendResultWait { + /// The family is classified execution-proving (transfer, unshield, + /// withdrawal): a snapshot outcome is an error. + ExecutionProved, + /// The proof only authenticates the affected keys' state at the proof's + /// block (identity top-up: the spent nullifiers are shared with any + /// competing spend of the same notes and the credited identity's balance + /// is a snapshot). Accepted as that snapshot. + AffectedState, +} + +async fn broadcast_shielded_spend( + sdk: &Arc, + state_transition: &StateTransition, + operation: &'static str, + wait: SpendResultWait, +) -> Result<(StateTransitionProofResult, ResponseMetadata), PlatformWalletError> { + match state_transition.broadcast(sdk, None).await { + Ok(()) => {} + Err(e) if broadcast_definitely_failed(&e) => { + return Err(crate::error::promote_address_nonce_error(&e) + .unwrap_or_else(|| PlatformWalletError::ShieldedBroadcastFailed(e.to_string()))); + } + Err(e) => { + warn!( + operation, + error = %e, + "Shielded spend broadcast returned no verdict; the transition may have been \ + admitted — falling through to the result wait" + ); + } + } + + // The verifier's classification of the family decides the wait: a + // proof-binding family rejects a snapshot outcome, an affected-state family + // accepts it. Either way the proven result is returned so callers that + // credit a known identity can apply its proof-attested balance. + let waited = match wait { + SpendResultWait::ExecutionProved => { + state_transition + .wait_for_response_with_metadata::(sdk, None) + .await + } + SpendResultWait::AffectedState => { + state_transition + .wait_for_affected_state_with_metadata::(sdk, None) + .await + } + }; + waited.map_err(|wait_err| classify_spend_wait_failure(operation, &wait_err)) +} + +/// 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`]). +/// +/// Only a consensus verdict ([`carries_consensus_rejection`]) proves +/// Platform executed the transition and rejected it on its merits — the +/// serialized consensus error is the verdict. DAPI encodes its own +/// wait-side failures (timeouts, internal errors; see +/// `build_wait_for_state_transition_error_response` in rs-dapi) as +/// `StateTransitionBroadcastError`s with EMPTY consensus data, which +/// the SDK surfaces as `cause: None` — those are ambiguous, not +/// rejections, and must keep the note reservations. Erring this way is +/// the safe direction: misreading a rejection as ambiguous only delays +/// note release until the next sync or restart, while misreading a +/// timeout as a rejection re-frees notes whose nullifiers may already +/// be consumed on chain. +fn classify_spend_wait_failure( + operation: &'static str, + wait_err: &dash_sdk::Error, +) -> PlatformWalletError { + if carries_consensus_rejection(wait_err) { + crate::error::promote_address_nonce_error(wait_err) + .unwrap_or_else(|| PlatformWalletError::ShieldedBroadcastFailed(wait_err.to_string())) + } else { + warn!( + operation, + error = %wait_err, + "Shielded spend broadcast accepted but result confirmation failed; \ + leaving the note reservations in place" + ); + PlatformWalletError::ShieldedSpendUnconfirmed { + operation, + reason: wait_err.to_string(), + } + } +} + +/// Helper to clone selection results out from under the store lock. +trait SelectionResultOwned { + fn into_owned(self) -> (Vec, u64, u64); +} + +impl SelectionResultOwned for (Vec<&ShieldedNote>, u64, u64) { + fn into_owned(self) -> (Vec, u64, u64) { + let (refs, total, fee) = self; + let owned: Vec = refs.into_iter().cloned().collect(); + (owned, total, fee) + } +} + +/// Convert a `PaymentAddress` to an `OrchardAddress` for the DPP builder. +fn payment_address_to_orchard( + addr: &PaymentAddress, +) -> Result { + let raw = addr.to_raw_address_bytes(); + OrchardAddress::from_raw_bytes(&raw).map_err(|_| { + PlatformWalletError::ShieldedBuildError( + "Failed to convert PaymentAddress to OrchardAddress".to_string(), + ) + }) +} + +/// Deserialize an Orchard Note from 115 bytes. +/// +/// Format: `recipient(43) || value(8 LE) || rho(32) || rseed(32)`. +/// Must be kept in sync with `serialize_note()` in sync.rs. +fn deserialize_note(data: &[u8]) -> Option { + use grovedb_commitment_tree::{Note, NoteValue, RandomSeed, Rho}; + + const SERIALIZED_NOTE_LEN: usize = 43 + 8 + 32 + 32; + + if data.len() != SERIALIZED_NOTE_LEN { + return None; + } + + let recipient_bytes: [u8; 43] = data[0..43].try_into().ok()?; + let recipient = PaymentAddress::from_raw_address_bytes(&recipient_bytes).into_option()?; + + let value_bytes: [u8; 8] = data[43..51].try_into().ok()?; + let value = NoteValue::from_raw(u64::from_le_bytes(value_bytes)); + + let rho_bytes: [u8; 32] = data[51..83].try_into().ok()?; + let rho = Rho::from_bytes(&rho_bytes).into_option()?; + + let rseed_bytes: [u8; 32] = data[83..115].try_into().ok()?; + let rseed = RandomSeed::from_bytes(rseed_bytes, &rho).into_option()?; + + Note::from_parts(recipient, value, rho, rseed).into_option() +} + +#[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 shield_recipient_tests { + use super::*; + use crate::wallet::shielded::keys::OrchardKeySet; + use dashcore::Network; + + fn keyset(seed_byte: u8) -> OrchardKeySet { + OrchardKeySet::from_seed(&[seed_byte; 32], Network::Testnet, 0) + .expect("ZIP-32 derivation from a fixed seed should succeed") + } + + /// `None` = the self-shield: the default address, `Shield`/`In`, + /// no counterparty — exactly what the pre-recipient path produced. + #[test] + fn no_recipient_resolves_to_the_default_address_as_shield_in() { + let keys = keyset(0x42).viewing_keys(); + + let resolved = + resolve_shield_recipient(&keys, None).expect("self-shield must always resolve"); + + assert_eq!( + resolved.address.to_raw_bytes(), + keys.default_address.to_raw_address_bytes(), + "the self-shield note must go to the account's default address" + ); + assert_eq!(resolved.counterparty, None); + assert_eq!(resolved.kind, ShieldedActivityKind::Shield); + assert_eq!(resolved.direction, ShieldedDirection::In); + } + + /// A third-party address resolves to `Sent`/`Out` with the raw + /// 43-byte address as counterparty — the classification the scan + /// deriver produces for an OVK-recovered send to a non-own address, + /// so live and restored rows agree. + #[test] + fn external_recipient_resolves_as_sent_out_with_raw_counterparty() { + let keys = keyset(0x42).viewing_keys(); + let external = keyset(0x24).viewing_keys().default_address; + + let resolved = resolve_shield_recipient(&keys, Some(&external)) + .expect("a third-party recipient must resolve"); + + assert_eq!( + resolved.address.to_raw_bytes(), + external.to_raw_address_bytes(), + "the note must be built for the recipient's address" + ); + assert_eq!( + resolved.counterparty, + Some(external.to_raw_address_bytes().to_vec()), + "the activity row must carry the recipient as raw 43 bytes" + ); + assert_eq!(resolved.kind, ShieldedActivityKind::Sent); + assert_eq!(resolved.direction, ShieldedDirection::Out); + } + + /// The account's own default address is not a third party: a + /// `Sent`/`Out` live row for it would diverge from the self-pay row + /// a restore's scan derives, so it must be rejected up front. + #[test] + fn own_default_address_as_recipient_is_rejected() { + let keys = keyset(0x42).viewing_keys(); + let own = keys.default_address; + + let error = resolve_shield_recipient(&keys, Some(&own)) + .expect_err("the account's own address must be rejected"); + assert!( + error + .to_string() + .contains("belongs to this shielded account"), + "unexpected error: {error}" + ); + } + + /// Orchard addresses are diversified, so ownership cannot be a + /// fixed-address comparison: a non-default diversified index of the + /// SAME account must also be recognized (via the IVK) and rejected. + #[test] + fn own_diversified_address_as_recipient_is_rejected() { + let ks = keyset(0x42); + let diversified = ks.address_at(7); + let keys = ks.viewing_keys(); + assert_ne!( + diversified.to_raw_address_bytes(), + keys.default_address.to_raw_address_bytes(), + "test needs a non-default diversified address" + ); + + resolve_shield_recipient(&keys, Some(&diversified)) + .expect_err("an own diversified address must be rejected"); + } +} + +#[cfg(test)] +mod redrive_tests { + use super::*; + use crate::wallet::shielded::store::InMemoryShieldedStore; + use dash_sdk::error::StateTransitionBroadcastError; + use dpp::consensus::state::shielded::nullifier_already_spent_error::NullifierAlreadySpentError; + use dpp::consensus::state::state_error::StateError; + use dpp::consensus::ConsensusError; + use dpp::platform_value::BinaryData; + use dpp::serialization::PlatformSerializable; + use dpp::state_transition::shield_from_identity_transition::v0::ShieldFromIdentityTransitionV0; + + fn identity_shield_transition(identity_id: [u8; 32], nonce: u64) -> StateTransition { + ShieldFromIdentityTransitionV0 { + identity_id: identity_id.into(), + amount: 1_000, + actions: vec![], + anchor: [1; 32], + proof: vec![], + binding_signature: [0; 64], + nonce, + user_fee_increase: 0, + signature_public_key_id: 1, + signature: BinaryData::new(vec![]), + } + .into() + } + + #[tokio::test] + async fn should_release_only_explicitly_abandoned_guard_and_never_redrive_it() { + let wallet_id = [4; 32]; + let identity_id = [5; 32]; + let id = SubwalletId::new(wallet_id, 3); + let mut store = InMemoryShieldedStore::new(); + let archived = PendingRedrive { + activity_id: [6; 32], + anchor: [1; 32], + nullifiers: vec![], + st_bytes: identity_shield_transition(identity_id, 7) + .serialize_to_bytes() + .unwrap(), + attempts: 0, + identity_index: None, + identity_nonce_finalized: false, + identity_user_abandoned: true, + }; + store.arm_redrive(id, archived.clone()).unwrap(); + assert!(!has_unresolved_identity_shield(&store, wallet_id, identity_id).unwrap()); + let store = Arc::new(RwLock::new(store)); + // No mock nonce responses: accidentally retrying would reach an unconfigured SDK. + redrive_pending_identity_shields( + &Arc::new(dash_sdk::Sdk::new_mock()), + &store, + wallet_id, + &CancellationToken::new(), + ) + .await; + assert_eq!( + store.read().await.pending_redrives(id).unwrap(), + vec![archived.clone()] + ); + let mut active = archived; + active.activity_id = [7; 32]; + active.identity_user_abandoned = false; + store + .write() + .await + .arm_redrive(SubwalletId::new(wallet_id, 4), active) + .unwrap(); + assert!( + has_unresolved_identity_shield(&*store.read().await, wallet_id, identity_id).unwrap() + ); + } + + #[tokio::test] + async fn should_keep_delayed_identity_debit_guard_when_nonce_is_unused_then_finalized() { + use dpp::identity::identity_nonce::validate_identity_nonce_update; + let wallet_id = [4; 32]; + let identity_id = [5; 32]; + let id = SubwalletId::new(wallet_id, 3); + let activity_id = [6; 32]; + let transition = identity_shield_transition(identity_id, 7); + let signed_bytes = transition.serialize_to_bytes().unwrap(); + let mut store = InMemoryShieldedStore::new(); + store + .arm_redrive( + id, + PendingRedrive { + activity_id, + anchor: [1; 32], + nullifiers: vec![], + st_bytes: signed_bytes.clone(), + attempts: 0, + identity_index: None, + identity_nonce_finalized: false, + identity_user_abandoned: false, + }, + ) + .unwrap(); + // A relay returned rejection while retaining the signed debit. Its + // accompanying genuine nonce snapshot is still six. Seven can land later. + assert!(!reconcile_identity_shield_nonce(&mut store, id, activity_id, Some(6), 7).unwrap()); + assert!(validate_identity_nonce_update(6, 7, identity_id.into()).is_valid()); + assert!( + has_unresolved_identity_shield(&store, wallet_id, identity_id).unwrap(), + "fresh-nonce replacement must stay blocked" + ); + let pending = store.pending_redrives(id).unwrap(); + assert_eq!(pending[0].st_bytes, signed_bytes); + assert!( + !pending[0].identity_nonce_finalized, + "original bytes remain eligible for safe retry" + ); + // The original then executes. A new nonce would be valid too, which is + // why nonce finality must not remove the unresolved-payment guard. + assert!(validate_identity_nonce_update(7, 8, identity_id.into()).is_valid()); + assert!(reconcile_identity_shield_nonce(&mut store, id, activity_id, Some(7), 7).unwrap()); + assert!(has_unresolved_identity_shield(&store, wallet_id, identity_id).unwrap()); + assert!(store.pending_redrives(id).unwrap()[0].identity_nonce_finalized); + let store = Arc::new(RwLock::new(store)); + let sdk = Arc::new(dash_sdk::Sdk::new_mock()); + redrive_pending_identity_shields(&sdk, &store, wallet_id, &CancellationToken::new()).await; + assert_eq!( + store.read().await.pending_redrives(id).unwrap()[0].attempts, + 0, + "parked guard must perform no more broadcasts" + ); + // The confirmation path can retire the guard, and nonce observation + // racing behind it must not resurrect the resolved payment. + store.write().await.clear_redrive(id, &activity_id).unwrap(); + reconcile_identity_shield_nonce(&mut *store.write().await, id, activity_id, Some(7), 7) + .unwrap(); + assert!( + !has_unresolved_identity_shield(&*store.read().await, wallet_id, identity_id).unwrap() + ); + } + + #[tokio::test] + async fn should_abort_before_broadcast_when_pending_activity_is_missing() { + let store = Arc::new(RwLock::new(InMemoryShieldedStore::new())); + let id = SubwalletId::new([4; 32], 0); + let error = + arm_identity_shield_redrive(&store, id, &None, &identity_shield_transition([5; 32], 1)) + .await + .unwrap_err(); + assert!(matches!(error, PlatformWalletError::ShieldedBuildError(_))); + assert!(store.read().await.pending_redrives(id).unwrap().is_empty()); + } + + #[test] + fn unresolved_identity_debit_is_found_across_shielded_accounts() { + let wallet_id = [4; 32]; + let identity_id = [5; 32]; + let transition = identity_shield_transition(identity_id, 7); + let mut store = InMemoryShieldedStore::new(); + store + .arm_redrive( + SubwalletId::new(wallet_id, 3), + PendingRedrive { + activity_id: [6; 32], + anchor: [1; 32], + nullifiers: vec![], + st_bytes: transition + .serialize_to_bytes() + .expect("identity shield should serialize"), + attempts: 0, + identity_index: None, + identity_nonce_finalized: false, + identity_user_abandoned: false, + }, + ) + .expect("redrive should arm"); + + assert!( + has_unresolved_identity_shield(&store, wallet_id, identity_id).expect("guard lookup") + ); + assert!( + !has_unresolved_identity_shield(&store, wallet_id, [9; 32]).expect("guard lookup"), + "an unrelated identity must remain usable" + ); + } + + #[test] + fn corrupt_identity_debit_guard_fails_closed() { + let wallet_id = [7; 32]; + let mut store = InMemoryShieldedStore::new(); + store + .arm_redrive( + SubwalletId::new(wallet_id, 0), + PendingRedrive { + activity_id: [8; 32], + anchor: [1; 32], + nullifiers: vec![], + st_bytes: vec![0xde, 0xad], + attempts: 0, + identity_index: None, + identity_nonce_finalized: false, + identity_user_abandoned: false, + }, + ) + .expect("redrive should arm"); + + assert!( + has_unresolved_identity_shield(&store, wallet_id, [9; 32]).expect("guard lookup"), + "an unreadable debit marker must not permit a fresh identity nonce" + ); + } + + /// On a re-broadcast of our own byte-identical transition, + /// `NullifierAlreadySpent` means the ORIGINAL executed — the + /// classification must treat it as a success signal, distinct from + /// every other consensus verdict. + #[test] + fn nullifier_already_spent_is_a_success_signal() { + let cause = ConsensusError::StateError(StateError::NullifierAlreadySpentError( + NullifierAlreadySpentError::new([1u8; 32]), + )); + let err = dash_sdk::Error::StateTransitionBroadcastError(StateTransitionBroadcastError { + code: 1, + message: "state error".to_string(), + cause: Some(cause), + }); + assert!(is_nullifier_already_spent(&err)); + // ...and it still counts as a consensus-rejection shape, so arm + // ordering matters: the already-spent check must run first. + assert!(carries_consensus_rejection(&err)); + + let other = dash_sdk::Error::TimeoutReached( + std::time::Duration::from_secs(1), + "waiting".to_string(), + ); + assert!(!is_nullifier_already_spent(&other)); + } + + /// The re-broadcast outcome classifier, arm order included: + /// `NullifierAlreadySpent` is itself a consensus error, so the + /// `AlreadyExecuted` arm must win over `DefinitiveRejection`. + #[test] + fn redrive_broadcast_classification_matrix() { + assert_eq!( + classify_redrive_broadcast(&Ok(())), + RedriveBroadcastOutcome::Accepted + ); + + let already_spent = ConsensusError::StateError(StateError::NullifierAlreadySpentError( + NullifierAlreadySpentError::new([1u8; 32]), + )); + let already_spent_err = + dash_sdk::Error::StateTransitionBroadcastError(StateTransitionBroadcastError { + code: 1, + message: "state error".to_string(), + cause: Some(already_spent), + }); + assert_eq!( + classify_redrive_broadcast(&Err(already_spent_err)), + RedriveBroadcastOutcome::AlreadyExecuted, + "already-spent must classify as success BEFORE the generic rejection arm" + ); + + let other_rejection = ConsensusError::BasicError( + dpp::consensus::basic::BasicError::ProtocolVersionParsingError( + dpp::consensus::basic::decode::ProtocolVersionParsingError::new( + "bad version".to_string(), + ), + ), + ); + let rejection_err = + dash_sdk::Error::StateTransitionBroadcastError(StateTransitionBroadcastError { + code: 1, + message: "state error".to_string(), + cause: Some(other_rejection), + }); + assert_eq!( + classify_redrive_broadcast(&Err(rejection_err)), + RedriveBroadcastOutcome::DefinitiveRejection + ); + + let timeout = dash_sdk::Error::TimeoutReached( + std::time::Duration::from_secs(1), + "waiting".to_string(), + ); + assert_eq!( + classify_redrive_broadcast(&Err(timeout)), + RedriveBroadcastOutcome::Inconclusive + ); + } + + #[tokio::test] + async fn identity_redrive_ignores_spend_anchor_and_attempt_limit() { + let sdk = Arc::new(dash_sdk::SdkBuilder::new_mock().build().expect("mock sdk")); + let store = Arc::new(RwLock::new(InMemoryShieldedStore::new())); + let wallet_id = [0x45; 32]; + let previous_account = SubwalletId::new(wallet_id, 7); + let activity_id = [0x67; 32]; + let transition = identity_shield_transition([0x89; 32], 11); + store + .write() + .await + .arm_redrive( + previous_account, + PendingRedrive { + activity_id, + anchor: [0xAB; 32], + nullifiers: vec![], + st_bytes: transition.serialize_to_bytes().expect("serialize"), + attempts: MAX_REDRIVE_ATTEMPTS, + identity_index: None, + identity_nonce_finalized: false, + identity_user_abandoned: false, + }, + ) + .expect("arm identity guard"); + + redrive_pending_identity_shields(&sdk, &store, wallet_id, &CancellationToken::new()).await; + + let retained = store + .read() + .await + .pending_redrives(previous_account) + .expect("retained redrive"); + assert_eq!( + retained.len(), + 1, + "an endpoint error cannot clear the guard" + ); + assert_eq!( + retained[0].attempts, + MAX_REDRIVE_ATTEMPTS + 1, + "identity retries stay live beyond the note-spend attempt budget" + ); + } + + #[tokio::test] + async fn ordinary_redrive_pass_leaves_identity_guard_untouched() { + let sdk = Arc::new(dash_sdk::SdkBuilder::new_mock().build().expect("mock sdk")); + let store = Arc::new(RwLock::new(InMemoryShieldedStore::new())); + let wallet_id = [0x54; 32]; + let id = SubwalletId::new(wallet_id, 0); + let anchor = [0x76; 32]; + let activity_id = [0x98; 32]; + store + .write() + .await + .arm_redrive( + id, + PendingRedrive { + activity_id, + anchor, + nullifiers: vec![], + st_bytes: identity_shield_transition([0xBA; 32], 3) + .serialize_to_bytes() + .expect("serialize"), + attempts: 0, + identity_index: None, + identity_nonce_finalized: false, + identity_user_abandoned: false, + }, + ) + .expect("arm identity guard"); + let recorded = [anchor].into_iter().collect(); + + redrive_pending_spends( + &sdk, + &store, + None, + wallet_id, + id, + &recorded, + &CancellationToken::new(), + ) + .await; + + let retained = store.read().await.pending_redrives(id).expect("guard"); + assert_eq!(retained.len(), 1); + assert_eq!( + retained[0].attempts, 0, + "the anchor-gated spend pass must not consume identity retry state" + ); + } + + /// Decision paths that must NOT touch the network (the mock SDK has + /// no broadcast expectation, so any attempt would error into the + /// inconclusive arm and bump the counter): a pruned anchor belongs + /// to the release backstop, an exhausted budget stays parked, and a + /// corrupt stored transition is dropped so it can't wedge the pass. + #[tokio::test] + async fn redrive_skips_pruned_and_exhausted_and_drops_garbage() { + let sdk = Arc::new(dash_sdk::SdkBuilder::new_mock().build().expect("mock sdk")); + let store = Arc::new(RwLock::new(InMemoryShieldedStore::new())); + let wallet_id = [5u8; 32]; + let id = SubwalletId::new(wallet_id, 0); + + let rec = |activity: u8, anchor: u8, attempts: u32| PendingRedrive { + activity_id: [activity; 32], + anchor: [anchor; 32], + nullifiers: vec![[activity ^ 0xFF; 32]], + st_bytes: vec![0xDE, 0xAD], // never deserializes + attempts, + identity_index: None, + identity_nonce_finalized: false, + identity_user_abandoned: false, + }; + { + let mut guard = store.write().await; + // Pruned anchor (10 not in recorded set) → untouched. + guard.arm_redrive(id, rec(1, 10, 0)).unwrap(); + // Recorded anchor but attempts exhausted → untouched. + guard + .arm_redrive(id, rec(2, 11, MAX_REDRIVE_ATTEMPTS)) + .unwrap(); + // Recorded anchor, budget left, garbage bytes → dropped + // before any broadcast. + guard.arm_redrive(id, rec(3, 12, 0)).unwrap(); } - bump_redrive_attempts_logged(store, id, &redrive.activity_id).await; + let recorded: std::collections::HashSet<[u8; 32]> = + [[11u8; 32], [12u8; 32]].into_iter().collect(); + + redrive_pending_spends( + &sdk, + &store, + None, + wallet_id, + id, + &recorded, + &CancellationToken::new(), + ) + .await; + + let left = store.read().await.pending_redrives(id).unwrap(); + let ids: Vec<[u8; 32]> = left.iter().map(|r| r.activity_id).collect(); + assert!(ids.contains(&[1u8; 32]), "pruned-anchor record left alone"); + assert!(ids.contains(&[2u8; 32]), "exhausted record left alone"); + assert!( + !ids.contains(&[3u8; 32]), + "corrupt record dropped without a broadcast attempt" + ); + assert_eq!( + left.iter().map(|r| r.attempts).max(), + Some(MAX_REDRIVE_ATTEMPTS), + "no attempt counters were bumped — nothing touched the network" + ); } } -/// 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]>, - cancel: &CancellationToken, -) { - use dpp::serialization::PlatformDeserializableTrusted; +#[cfg(test)] +mod nullifier_status_and_claim_record_tests { + use super::*; + use crate::wallet::shielded::file_store::FileBackedShieldedStore; + use crate::wallet::shielded::store::InMemoryShieldedStore; + use dash_sdk::query_types::ShieldedNullifierStatus; - 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 stop ends the pass between records: no new re-broadcast starts - // once cancelled. One already submitted is left to finish and is - // classified as usual — its outcome handling stays conservative. - if cancel.is_cancelled() { - debug!("redrive: pass cancelled; remaining records wait for the next pass"); - return; - } - // Empty-nullifier identity debits are wallet-wide and are driven by - // `redrive_pending_identity_shields`, independently of spending anchors. - if redrive.nullifiers.is_empty() { - continue; - } - // 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_trusted(&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" - ); - } + fn status(nullifier: [u8; 32], is_spent: bool) -> ShieldedNullifierStatus { + ShieldedNullifierStatus { + nullifier, + is_spent, } } -} -/// 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 -/// proves nothing was delivered), or a wait-stage consensus rejection -/// (Platform ran the transition and rejected it on its merits — see -/// [`carries_consensus_rejection`]), means the spend never executed → -/// [`PlatformWalletError::ShieldedBroadcastFailed`], and the caller -/// releases the note reservations via [`cancel_pending`]; -/// - a no-verdict `broadcast()` failure (`AlreadyExists` proves the tx IS -/// in the mempool after a lost-ACK retry; timeouts merely allow it) -/// falls through to the result wait, which either proves execution or -/// classifies the residual ambiguity; -/// - any other wait failure (transport error, timeout, result-proof -/// fetch/verify error, …) is AMBIGUOUS — the relay accepted (or may -/// have accepted) the tx and it may well have executed → -/// [`PlatformWalletError::ShieldedSpendUnconfirmed`], and the caller -/// must leave the reservations in place (each spend flow's outer -/// match has a dedicated arm for this). -/// -/// Mirrors the staging in [`identity_create_from_shielded_pool`], minus -/// its fetch-by-derived-id fallback: a spend leaves no artifact as -/// cheaply queryable as an identity row, so ambiguity is surfaced -/// directly and reconciled by the next nullifier sync. The proven -/// result is discarded; only the confirmation matters. -/// How [`broadcast_shielded_spend`] waits for an already-broadcast spend's -/// result, mirroring the verifier's classification of the family. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -enum SpendResultWait { - /// The family is classified execution-proving (transfer, unshield, - /// withdrawal): a snapshot outcome is an error. - ExecutionProved, - /// The proof only authenticates the affected keys' state at the proof's - /// block (identity top-up: the spent nullifiers are shared with any - /// competing spend of the same notes and the credited identity's balance - /// is a snapshot). Accepted as that snapshot. - AffectedState, -} + /// 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 + ); + } -async fn broadcast_shielded_spend( - sdk: &Arc, - state_transition: &StateTransition, - operation: &'static str, - wait: SpendResultWait, -) -> Result<(StateTransitionProofResult, ResponseMetadata), PlatformWalletError> { - match state_transition.broadcast(sdk, None).await { - Ok(()) => {} - Err(e) if broadcast_definitely_failed(&e) => { - return Err(crate::error::promote_address_nonce_error(&e) - .unwrap_or_else(|| PlatformWalletError::ShieldedBroadcastFailed(e.to_string()))); - } - Err(e) => { - warn!( - operation, - error = %e, - "Shielded spend broadcast returned no verdict; the transition may have been \ - admitted — falling through to the result wait" - ); + /// 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, + identity_nonce_finalized: false, + identity_user_abandoned: false, + }, + ) + .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()); } - // The verifier's classification of the family decides the wait: a - // proof-binding family rejects a snapshot outcome, an affected-state family - // accepts it. Either way the proven result is returned so callers that - // credit a known identity can apply its proof-attested balance. - let waited = match wait { - SpendResultWait::ExecutionProved => { - state_transition - .wait_for_response_with_metadata::(sdk, None) + /// A SUCCESSFUL claim must RETAIN its recovery record until the caller + /// acknowledges durable local registration (#4313 review finding + /// 325ce9fa8f84). + /// + /// It used to clear on success, which dropped the row before control + /// returned through `PlatformWallet::identity_create_from_one_time_key` — + /// where the identity is added to the manager and handed to the persister. + /// A process death in that gap, a missing wallet-manager entry, or a + /// `persister.store` failure therefore lost the exact padded identity ID + /// before the JNI caller ever received it, and the retry could only return + /// the terminal `ShieldedInviteAlreadyClaimed`. + #[tokio::test] + async fn a_successful_claim_retains_its_record_until_registration_is_acknowledged() { + let store = Arc::new(RwLock::new(InMemoryShieldedStore::new())); + let id = SubwalletId::new([0x7Bu8; 32], ONE_TIME_CLAIM_RECORDS_ACCOUNT); + let key = [0xB6u8; 32]; + + store + .write() + .await + .arm_redrive( + id, + PendingRedrive { + activity_id: key, + anchor: [9u8; 32], + nullifiers: vec![[3u8; 32]], + st_bytes: vec![1, 2, 3], + attempts: 0, + identity_index: Some(4), + identity_nonce_finalized: false, + identity_user_abandoned: false, + }, + ) + .expect("arm must succeed"); + + let success: Result<(Identifier, Identity), PlatformWalletError> = Ok(( + Identifier::new([0xEE; 32]), + Identity::default_versioned(dpp::version::PlatformVersion::latest()) + .expect("default identity"), + )); + + // The claim resolves. This is the instant the native call returns to + // the host — and the row must still be there. + let receipt = finalize_one_time_claim_record(&store, id, key, &success).await; + assert!( + receipt.is_some(), + "a successful claim must hand back a record for the caller to acknowledge" + ); + assert!( + find_one_time_claim_record(&store, id, key) .await - } - SpendResultWait::AffectedState => { - state_transition - .wait_for_affected_state_with_metadata::(sdk, None) + .expect("lookup must succeed") + .is_some(), + "the recovery record must survive the native return — it is the only durable copy of \ + a padded single-note claim's identity id" + ); + + // Registration succeeded and is durable: only NOW is it released. + acknowledge_one_time_claim_registration(&store, receipt.expect("receipt")).await; + assert!( + find_one_time_claim_record(&store, id, key) + .await + .expect("lookup must succeed") + .is_none(), + "the acknowledgement is what finally drops the record" + ); + } + + /// The half that makes the retention worth having: a claim killed between + /// its transition succeeding and the host durably registering the identity + /// must be RECOVERABLE, not terminal (#4313 review finding 325ce9fa8f84). + /// + /// The claim resolves, the record is retained, and then the process dies + /// before acknowledging — modelled by dropping the receipt and reopening + /// the store from disk, which is exactly what a relaunch does. The retry + /// then finds the record, and with it the DECLARED identity id that a + /// padded single-note bundle cannot re-derive. That id is what + /// `recover_executed_one_time_claim` needs; without it, the retry's + /// `expected_identity_id` is `None` and the spent-note path returns the + /// terminal `ShieldedInviteAlreadyClaimed` before the master-key lookup is + /// ever attempted. + #[tokio::test] + async fn a_claim_killed_before_acknowledgement_is_recoverable_after_a_restart() { + use dpp::serialization::PlatformSerializable; + + let path = std::env::temp_dir().join(format!( + "shielded_claim_ack_{}_{}.sqlite", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .expect("clock") + .as_nanos() + )); + let wallet_id = [0x9Cu8; 32]; + let id = SubwalletId::new(wallet_id, ONE_TIME_CLAIM_RECORDS_ACCOUNT); + let key = [0xC7u8; 32]; + + // A claim whose transition carries a padded, non-re-derivable id: the + // single-spend case, where the record is the only handle to it. + let declared_id = Identifier::new([0x2B; 32]); + let st_bytes = { + 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: Vec::new(), + denomination: 10_000_000_000, + 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: declared_id, + } + .into(); + StateTransition::IdentityCreateFromShieldedPool(transition) + .serialize_to_bytes() + .expect("serialize") + }; + + { + let store: Arc> = Arc::new(RwLock::new( + FileBackedShieldedStore::open_path(&path, 100).expect("store opens"), + )); + store + .write() .await + .arm_redrive( + id, + PendingRedrive { + activity_id: key, + anchor: [0x0A; 32], + nullifiers: vec![[0x0F; 32]], + st_bytes, + attempts: 0, + identity_index: Some(4), + identity_nonce_finalized: false, + identity_user_abandoned: false, + }, + ) + .expect("arm must succeed"); + + let success: Result<(Identifier, Identity), PlatformWalletError> = Ok(( + declared_id, + Identity::default_versioned(dpp::version::PlatformVersion::latest()) + .expect("default identity"), + )); + let receipt = finalize_one_time_claim_record(&store, id, key, &success).await; + assert!(receipt.is_some(), "success yields a receipt"); + // The process dies HERE: the receipt is dropped without ever being + // acknowledged, so the identity never reached the host's durable + // state. + let _dropped_without_acknowledging = receipt; } - }; - waited.map_err(|wait_err| classify_spend_wait_failure(operation, &wait_err)) + + // Relaunch: a fresh store over the same file, as `open_path` does on + // every app start. + let reopened = FileBackedShieldedStore::open_path(&path, 100).expect("store reopens"); + let rehydrated = reopened.pending_redrives(id).expect("records readable"); + assert_eq!( + rehydrated.len(), + 1, + "RED before the fix: the success path cleared the row, so the relaunch found nothing \ + and the retry could only report the invitation as already claimed" + ); + + // And the row still carries the declared id — the value the retry needs + // and cannot otherwise reconstruct. + let recovered_id = { + use dpp::serialization::PlatformDeserializableTrusted; + use dpp::state_transition::state_transitions::shielded::identity_create_from_shielded_pool_transition::accessors::IdentityCreateFromShieldedPoolTransitionAccessorsV0; + match StateTransition::deserialize_from_bytes_trusted(&rehydrated[0].st_bytes) + .expect("stored transition deserializes") + { + StateTransition::IdentityCreateFromShieldedPool(t) => t.identity_id(), + other => panic!("unexpected stored transition {}", other.name()), + } + }; + assert_eq!( + recovered_id, declared_id, + "the retained record is what lets the retry bind a recovered identity to this claim \ + instead of returning terminal ShieldedInviteAlreadyClaimed" + ); + + drop(reopened); + let _ = std::fs::remove_file(&path); + } + + /// 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, + identity_nonce_finalized: false, + identity_user_abandoned: false, + }; + store + .write() + .await + .arm_redrive(id, record.clone()) + .expect("arm must succeed"); + + let outcome = resume_one_time_claim( + &sdk, + &store, + id, + &record, + // The corrupt record is rejected long before the + // pre-broadcast lease gate, so any token reaches the assertion. + crate::wallet::shielded::store::AdmissionToken::generate().expect("token"), + 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()); + } } -/// Classify a `wait_for_response` failure for an already-broadcast -/// shielded spend (see [`broadcast_shielded_spend`]). -/// -/// Only a consensus verdict ([`carries_consensus_rejection`]) proves -/// Platform executed the transition and rejected it on its merits — the -/// serialized consensus error is the verdict. DAPI encodes its own -/// wait-side failures (timeouts, internal errors; see -/// `build_wait_for_state_transition_error_response` in rs-dapi) as -/// `StateTransitionBroadcastError`s with EMPTY consensus data, which -/// the SDK surfaces as `cause: None` — those are ambiguous, not -/// rejections, and must keep the note reservations. Erring this way is -/// the safe direction: misreading a rejection as ambiguous only delays -/// note release until the next sync or restart, while misreading a -/// timeout as a rejection re-frees notes whose nullifiers may already -/// be consumed on chain. -fn classify_spend_wait_failure( - operation: &'static str, - wait_err: &dash_sdk::Error, -) -> PlatformWalletError { - if carries_consensus_rejection(wait_err) { - crate::error::promote_address_nonce_error(wait_err) - .unwrap_or_else(|| PlatformWalletError::ShieldedBroadcastFailed(wait_err.to_string())) - } else { - warn!( - operation, - error = %wait_err, - "Shielded spend broadcast accepted but result confirmation failed; \ - leaving the note reservations in place" +#[cfg(test)] +mod classify_spend_wait_failure_tests { + use super::*; + use dash_sdk::error::StateTransitionBroadcastError; + use dpp::consensus::basic::decode::ProtocolVersionParsingError; + use dpp::consensus::basic::BasicError; + use dpp::consensus::ConsensusError; + + fn broadcast_err(cause: Option) -> dash_sdk::Error { + dash_sdk::Error::StateTransitionBroadcastError(StateTransitionBroadcastError { + code: 13, + message: "context deadline exceeded".to_string(), + cause, + }) + } + + #[test] + fn consensus_cause_is_a_definitive_rejection() { + let cause = ConsensusError::BasicError(BasicError::ProtocolVersionParsingError( + ProtocolVersionParsingError::new("bad version".to_string()), + )); + let err = classify_spend_wait_failure("unshield", &broadcast_err(Some(cause))); + assert!( + matches!(err, PlatformWalletError::ShieldedBroadcastFailed(_)), + "a broadcast error carrying a consensus cause means Platform ran and \ + rejected the transition; the caller may release the reservation" ); - PlatformWalletError::ShieldedSpendUnconfirmed { - operation, - reason: wait_err.to_string(), - } } -} - -/// Helper to clone selection results out from under the store lock. -trait SelectionResultOwned { - fn into_owned(self) -> (Vec, u64, u64); -} -impl SelectionResultOwned for (Vec<&ShieldedNote>, u64, u64) { - fn into_owned(self) -> (Vec, u64, u64) { - let (refs, total, fee) = self; - let owned: Vec = refs.into_iter().cloned().collect(); - (owned, total, fee) + #[test] + fn causeless_broadcast_error_is_ambiguous() { + // DAPI maps its own wait failures (e.g. `DapiError::Timeout`) to a + // `StateTransitionBroadcastError` with EMPTY consensus data, which the + // SDK decodes as `cause: None`. The spend may still execute after the + // wait gave up, so this must NOT release the note reservations. + let err = classify_spend_wait_failure("unshield", &broadcast_err(None)); + assert!( + matches!( + err, + PlatformWalletError::ShieldedSpendUnconfirmed { + operation: "unshield", + .. + } + ), + "got {err:?}" + ); + } + + #[test] + fn transport_errors_are_ambiguous() { + let err = classify_spend_wait_failure( + "withdraw", + &dash_sdk::Error::TimeoutReached( + std::time::Duration::from_secs(80), + "waiting for response".to_string(), + ), + ); + assert!(matches!( + err, + PlatformWalletError::ShieldedSpendUnconfirmed { + operation: "withdraw", + .. + } + )); + } + + /// The shape a CheckTx rejection takes when it reaches the SDK from + /// `broadcast()`: DAPI re-attaches the serialized consensus error as + /// gRPC metadata and the dapi-client decodes it into + /// `Error::Protocol(ConsensusError)`. + fn consensus_metadata_rejection() -> dash_sdk::Error { + let cause = ConsensusError::BasicError(BasicError::ProtocolVersionParsingError( + ProtocolVersionParsingError::new("bad version".to_string()), + )); + dash_sdk::Error::Protocol(dpp::ProtocolError::ConsensusError(Box::new(cause))) + } + + /// A CheckTx consensus rejection surfacing from `broadcast()` IS + /// definitive: the transition was evaluated and refused, it sits in no + /// mempool, and the caller may release the reservations and retry. + #[test] + fn broadcast_consensus_rejection_is_definitive() { + assert!(broadcast_definitely_failed(&consensus_metadata_rejection())); + // The same verdict shape is definitive on the wait stage too. + let err = classify_spend_wait_failure("transfer", &consensus_metadata_rejection()); + assert!(matches!( + err, + PlatformWalletError::ShieldedBroadcastFailed(_) + )); + } + + fn grpc_err(code: dash_sdk::dapi_grpc::tonic::Code) -> dash_sdk::Error { + use dash_sdk::dapi_client::transport::TransportError; + use dash_sdk::dapi_client::DapiClientError; + dash_sdk::Error::DapiClientError(DapiClientError::Transport(TransportError::Grpc( + dash_sdk::dapi_grpc::tonic::Status::new(code, "boom"), + ))) + } + + /// Connection-establishment failures and non-consensus server + /// rejections of `broadcast()` ARE definitive: nothing was admitted to + /// a mempool. The offline case (connect refused → `Unavailable`) in + /// particular must release the notes immediately rather than strand + /// them until restart. + #[test] + fn broadcast_connection_failures_and_rejections_are_definitive() { + use dash_sdk::dapi_grpc::tonic::Code; + assert!(broadcast_definitely_failed(&grpc_err(Code::Unavailable))); + // Mempool full / malformed request: server verdicts refusing admission. + assert!(broadcast_definitely_failed(&grpc_err( + Code::ResourceExhausted + ))); + assert!(broadcast_definitely_failed(&grpc_err( + Code::InvalidArgument + ))); + } + + /// No-response shapes of the `broadcast()` call itself are NOT + /// definitive: the dapi-client retries across addresses, so the request + /// may have been delivered to a node whose ACK was lost. The caller + /// falls through to the result wait instead of releasing the + /// reservations (which would let a retry select other unreserved notes + /// and double-send if the original broadcast landed). + #[test] + fn broadcast_no_response_shapes_are_inconclusive() { + use dash_sdk::dapi_grpc::tonic::Code; + assert!(!broadcast_definitely_failed(&grpc_err( + Code::DeadlineExceeded + ))); + // DAPI maps its own tenderdash-side failures (which can postdate + // delivery) to Internal. + assert!(!broadcast_definitely_failed(&grpc_err(Code::Internal))); + assert!(!broadcast_definitely_failed( + &dash_sdk::Error::TimeoutReached( + std::time::Duration::from_secs(30), + "broadcast".to_string(), + ) + )); + assert!(!broadcast_definitely_failed(&dash_sdk::Error::Generic( + "transport error: connection reset".to_string() + ))); + // …including as the terminal error of an exhausted retry loop. + assert!(!broadcast_definitely_failed( + &dash_sdk::Error::NoAvailableAddressesToRetry(Box::new(grpc_err( + Code::DeadlineExceeded + ))) + )); } -} - -/// Convert a `PaymentAddress` to an `OrchardAddress` for the DPP builder. -fn payment_address_to_orchard( - addr: &PaymentAddress, -) -> Result { - let raw = addr.to_raw_address_bytes(); - OrchardAddress::from_raw_bytes(&raw).map_err(|_| { - PlatformWalletError::ShieldedBuildError( - "Failed to convert PaymentAddress to OrchardAddress".to_string(), - ) - }) -} -/// Deserialize an Orchard Note from 115 bytes. -/// -/// Format: `recipient(43) || value(8 LE) || rho(32) || rseed(32)`. -/// Must be kept in sync with `serialize_note()` in sync.rs. -fn deserialize_note(data: &[u8]) -> Option { - use grovedb_commitment_tree::{Note, NoteValue, RandomSeed, Rho}; + /// `AlreadyExists` from `broadcast()` proves the transition IS already + /// in the mempool or on chain (e.g. an internal dapi-client retry after + /// an ambiguous first delivery), so it must NOT be treated as a failure + /// — the spend is in flight and the result wait will confirm it. + #[test] + fn broadcast_already_exists_is_in_flight() { + assert!(!broadcast_definitely_failed( + &dash_sdk::Error::AlreadyExists("state transition already in mempool".to_string()) + )); + } - const SERIALIZED_NOTE_LEN: usize = 43 + 8 + 32 + 32; + /// A consensus verdict wrapped in the dapi-client's `NoAvailableAddressesToRetry` + /// retry envelope must still count as a rejection — `carries_consensus_rejection` + /// recurses through the envelope, in lockstep with `as_address_invalid_nonce`. + #[test] + fn wrapped_consensus_rejection_is_a_rejection() { + let wrapped = + dash_sdk::Error::NoAvailableAddressesToRetry(Box::new(consensus_metadata_rejection())); + assert!(carries_consensus_rejection(&wrapped)); + } - if data.len() != SERIALIZED_NOTE_LEN { - return None; + /// 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)); } - let recipient_bytes: [u8; 43] = data[0..43].try_into().ok()?; - let recipient = PaymentAddress::from_raw_address_bytes(&recipient_bytes).into_option()?; + /// 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 value_bytes: [u8; 8] = data[43..51].try_into().ok()?; - let value = NoteValue::from_raw(u64::from_le_bytes(value_bytes)); + 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)); - let rho_bytes: [u8; 32] = data[51..83].try_into().ok()?; - let rho = Rho::from_bytes(&rho_bytes).into_option()?; + 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 rseed_bytes: [u8; 32] = data[83..115].try_into().ok()?; - let rseed = RandomSeed::from_bytes(rseed_bytes, &rho).into_option()?; +#[cfg(test)] +mod shield_input_fetch_error_tests { + use super::*; + use dpp::consensus::state::address_funds::AddressNotEnoughFundsError; - Note::from_parts(recipient, value, rho, rseed).into_option() + #[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 shield_recipient_tests { +mod reserve_shield_fee_tests { use super::*; - use crate::wallet::shielded::keys::OrchardKeySet; - use dashcore::Network; + use dpp::version::LATEST_PLATFORM_VERSION; - fn keyset(seed_byte: u8) -> OrchardKeySet { - OrchardKeySet::from_seed(&[seed_byte; 32], Network::Testnet, 0) - .expect("ZIP-32 derivation from a fixed seed should succeed") + fn addr(b: u8) -> PlatformAddress { + PlatformAddress::P2pkh([b; 20]) } - /// `None` = the self-shield: the default address, `Shield`/`In`, - /// no counterparty — exactly what the pre-recipient path produced. #[test] - fn no_recipient_resolves_to_the_default_address_as_shield_in() { - let keys = keyset(0x42).viewing_keys(); + 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 resolved = - resolve_shield_recipient(&keys, None).expect("self-shield must always resolve"); + 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!( - resolved.address.to_raw_bytes(), - keys.default_address.to_raw_address_bytes(), - "the self-shield note must go to the account's default address" + out.get(&addr(2)), + Some(&5_000_000), + "other inputs untouched" ); - assert_eq!(resolved.counterparty, None); - assert_eq!(resolved.kind, ShieldedActivityKind::Shield); - assert_eq!(resolved.direction, ShieldedDirection::In); + // Σ claims grew by exactly `fee`, satisfying `Σ inputs >= amount + F`. + assert_eq!(out.values().sum::(), 6_000_000 + fee); } - /// A third-party address resolves to `Sent`/`Out` with the raw - /// 43-byte address as counterparty — the classification the scan - /// deriver produces for an OVK-recovered send to a non-own address, - /// so live and restored rows agree. #[test] - fn external_recipient_resolves_as_sent_out_with_raw_counterparty() { - let keys = keyset(0x42).viewing_keys(); - let external = keyset(0x24).viewing_keys().default_address; - - let resolved = resolve_shield_recipient(&keys, Some(&external)) - .expect("a third-party recipient must resolve"); + 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_eq!( - resolved.address.to_raw_bytes(), - external.to_raw_address_bytes(), - "the note must be built for the recipient's address" + 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_eq!( - resolved.counterparty, - Some(external.to_raw_address_bytes().to_vec()), - "the activity row must carry the recipient as raw 43 bytes" + 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" ); - assert_eq!(resolved.kind, ShieldedActivityKind::Sent); - assert_eq!(resolved.direction, ShieldedDirection::Out); } - /// The account's own default address is not a third party: a - /// `Sent`/`Out` live row for it would diverge from the self-pay row - /// a restore's scan derives, so it must be rejected up front. #[test] - fn own_default_address_as_recipient_is_rejected() { - let keys = keyset(0x42).viewing_keys(); - let own = keys.default_address; + 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::*; - let error = resolve_shield_recipient(&keys, Some(&own)) - .expect_err("the account's own address must be rejected"); + /// `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 - .to_string() - .contains("belongs to this shielded account"), - "unexpected error: {error}" + !error_releases_note_reservation(&e), + "ShieldedBroadcastUnconfirmed must retain the note reservation" ); } - /// Orchard addresses are diversified, so ownership cannot be a - /// fixed-address comparison: a non-default diversified index of the - /// SAME account must also be recognized (via the IVK) and rejected. + /// Every other failure is a definitive pre-execution / build / rejection failure — the spend + /// never happened, so the reservation must be released. #[test] - fn own_diversified_address_as_recipient_is_rejected() { - let ks = keyset(0x42); - let diversified = ks.address_at(7); - let keys = ks.viewing_keys(); - assert_ne!( - diversified.to_raw_address_bytes(), - keys.default_address.to_raw_address_bytes(), - "test needs a non-default diversified address" - ); - - resolve_shield_recipient(&keys, Some(&diversified)) - .expect_err("an own diversified address must be rejected"); + 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 redrive_tests { +mod record_activity_status_tests { use super::*; + use crate::wallet::shielded::activity::{ + ShieldedActivityEntry, ShieldedActivityKind, ShieldedDirection, + }; use crate::wallet::shielded::store::InMemoryShieldedStore; - use dash_sdk::error::StateTransitionBroadcastError; - use dpp::consensus::state::shielded::nullifier_already_spent_error::NullifierAlreadySpentError; - use dpp::consensus::state::state_error::StateError; - use dpp::consensus::ConsensusError; - use dpp::platform_value::BinaryData; - use dpp::serialization::PlatformSerializable; - use dpp::state_transition::shield_from_identity_transition::v0::ShieldFromIdentityTransitionV0; - fn identity_shield_transition(identity_id: [u8; 32], nonce: u64) -> StateTransition { - ShieldFromIdentityTransitionV0 { - identity_id: identity_id.into(), + 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, - actions: vec![], - anchor: [1; 32], - proof: vec![], - binding_signature: [0; 64], - nonce, - user_fee_increase: 0, - signature_public_key_id: 1, - signature: BinaryData::new(vec![]), + 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![], } - .into() } + /// 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 should_release_only_explicitly_abandoned_guard_and_never_redrive_it() { - let wallet_id = [4; 32]; - let identity_id = [5; 32]; - let id = SubwalletId::new(wallet_id, 3); - let mut store = InMemoryShieldedStore::new(); - let archived = PendingRedrive { - activity_id: [6; 32], - anchor: [1; 32], - nullifiers: vec![], - st_bytes: identity_shield_transition(identity_id, 7) - .serialize_to_bytes() - .unwrap(), - attempts: 0, - identity_nonce_finalized: false, - identity_user_abandoned: true, - }; - store.arm_redrive(id, archived.clone()).unwrap(); - assert!(!has_unresolved_identity_shield(&store, wallet_id, identity_id).unwrap()); - let store = Arc::new(RwLock::new(store)); - // No mock nonce responses: accidentally retrying would reach an unconfigured SDK. - redrive_pending_identity_shields( - &Arc::new(dash_sdk::Sdk::new_mock()), - &store, - wallet_id, - &CancellationToken::new(), - ) - .await; - assert_eq!( - store.read().await.pending_redrives(id).unwrap(), - vec![archived.clone()] - ); - let mut active = archived; - active.activity_id = [7; 32]; - active.identity_user_abandoned = false; + 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 - .arm_redrive(SubwalletId::new(wallet_id, 4), active) + .save_activity(id, &scan_confirmed) .unwrap(); - assert!( - has_unresolved_identity_shield(&*store.read().await, wallet_id, identity_id).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 should_keep_delayed_identity_debit_guard_when_nonce_is_unused_then_finalized() { - use dpp::identity::identity_nonce::validate_identity_nonce_update; - let wallet_id = [4; 32]; - let identity_id = [5; 32]; - let id = SubwalletId::new(wallet_id, 3); - let activity_id = [6; 32]; - let transition = identity_shield_transition(identity_id, 7); - let signed_bytes = transition.serialize_to_bytes().unwrap(); - let mut store = InMemoryShieldedStore::new(); - store - .arm_redrive( - id, - PendingRedrive { - activity_id, - anchor: [1; 32], - nullifiers: vec![], - st_bytes: signed_bytes.clone(), - attempts: 0, - identity_nonce_finalized: false, - identity_user_abandoned: false, - }, - ) - .unwrap(); - // A relay returned rejection while retaining the signed debit. Its - // accompanying genuine nonce snapshot is still six. Seven can land later. - assert!(!reconcile_identity_shield_nonce(&mut store, id, activity_id, Some(6), 7).unwrap()); - assert!(validate_identity_nonce_update(6, 7, identity_id.into()).is_valid()); - assert!( - has_unresolved_identity_shield(&store, wallet_id, identity_id).unwrap(), - "fresh-nonce replacement must stay blocked" - ); - let pending = store.pending_redrives(id).unwrap(); - assert_eq!(pending[0].st_bytes, signed_bytes); - assert!( - !pending[0].identity_nonce_finalized, - "original bytes remain eligible for safe retry" - ); - // The original then executes. A new nonce would be valid too, which is - // why nonce finality must not remove the unresolved-payment guard. - assert!(validate_identity_nonce_update(7, 8, identity_id.into()).is_valid()); - assert!(reconcile_identity_shield_nonce(&mut store, id, activity_id, Some(7), 7).unwrap()); - assert!(has_unresolved_identity_shield(&store, wallet_id, identity_id).unwrap()); - assert!(store.pending_redrives(id).unwrap()[0].identity_nonce_finalized); - let store = Arc::new(RwLock::new(store)); - let sdk = Arc::new(dash_sdk::Sdk::new_mock()); - redrive_pending_identity_shields(&sdk, &store, wallet_id, &CancellationToken::new()).await; - assert_eq!( - store.read().await.pending_redrives(id).unwrap()[0].attempts, - 0, - "parked guard must perform no more broadcasts" - ); - // The confirmation path can retire the guard, and nonce observation - // racing behind it must not resurrect the resolved payment. - store.write().await.clear_redrive(id, &activity_id).unwrap(); - reconcile_identity_shield_nonce(&mut *store.write().await, id, activity_id, Some(7), 7) - .unwrap(); - assert!( - !has_unresolved_identity_shield(&*store.read().await, wallet_id, identity_id).unwrap() - ); + 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)); + } + + /// 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(); + + record_activity_status_by_id( + &store, + None, + id.wallet_id, + id, + &pending.id, + ShieldedActivityStatus::Failed, + ) + .await; + + 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 should_abort_before_broadcast_when_pending_activity_is_missing() { + async fn status_flip_by_id_missing_entry_is_noop() { let store = Arc::new(RwLock::new(InMemoryShieldedStore::new())); - let id = SubwalletId::new([4; 32], 0); - let error = - arm_identity_shield_redrive(&store, id, &None, &identity_shield_transition([5; 32], 1)) - .await - .unwrap_err(); - assert!(matches!(error, PlatformWalletError::ShieldedBuildError(_))); - assert!(store.read().await.pending_redrives(id).unwrap().is_empty()); + 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()); } +} - #[test] - fn unresolved_identity_debit_is_found_across_shielded_accounts() { - let wallet_id = [4; 32]; - let identity_id = [5; 32]; - let transition = identity_shield_transition(identity_id, 7); - let mut store = InMemoryShieldedStore::new(); - store - .arm_redrive( - SubwalletId::new(wallet_id, 3), - PendingRedrive { - activity_id: [6; 32], - anchor: [1; 32], - nullifiers: vec![], - st_bytes: transition - .serialize_to_bytes() - .expect("identity shield should serialize"), - attempts: 0, - identity_nonce_finalized: false, - identity_user_abandoned: false, - }, - ) - .expect("redrive should arm"); +/// 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 select_recorded_spends_tests { + use super::*; + use crate::wallet::shielded::file_store::FileBackedShieldedStore; + use dashcore::Network; + use grovedb_commitment_tree::{ExtractedNoteCommitment, Note, NoteValue, RandomSeed, Rho}; - assert!( - has_unresolved_identity_shield(&store, wallet_id, identity_id).expect("guard lookup") - ); - assert!( - !has_unresolved_identity_shield(&store, wallet_id, [9; 32]).expect("guard lookup"), - "an unrelated identity must remain usable" - ); + /// 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")) } - #[test] - fn corrupt_identity_debit_guard_fails_closed() { - let wallet_id = [7; 32]; - let mut store = InMemoryShieldedStore::new(); - store - .arm_redrive( - SubwalletId::new(wallet_id, 0), - PendingRedrive { - activity_id: [8; 32], - anchor: [1; 32], - nullifiers: vec![], - st_bytes: vec![0xde, 0xad], - attempts: 0, - identity_nonce_finalized: false, - identity_user_abandoned: false, - }, - ) - .expect("redrive should arm"); + /// 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 + } - assert!( - has_unresolved_identity_shield(&store, wallet_id, [9; 32]).expect("guard lookup"), - "an unreadable debit marker must not permit a fresh identity nonce" - ); + /// 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; + + // 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, + } } - /// On a re-broadcast of our own byte-identical transition, - /// `NullifierAlreadySpent` means the ORIGINAL executed — the - /// classification must treat it as a success signal, distinct from - /// every other consensus verdict. + /// 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 nullifier_already_spent_is_a_success_signal() { - let cause = ConsensusError::StateError(StateError::NullifierAlreadySpentError( - NullifierAlreadySpentError::new([1u8; 32]), - )); - let err = dash_sdk::Error::StateTransitionBroadcastError(StateTransitionBroadcastError { - code: 1, - message: "state error".to_string(), - cause: Some(cause), - }); - assert!(is_nullifier_already_spent(&err)); - // ...and it still counts as a consensus-rejection shape, so arm - // ordering matters: the already-spent check must run first. - assert!(carries_consensus_rejection(&err)); + fn mid_block_selects_prior_recorded_checkpoint_not_depth0() { + let path = temp_tree_path("midblock"); + let mut store = FileBackedShieldedStore::open_path(&path, 100).unwrap(); - let other = dash_sdk::Error::TimeoutReached( - std::time::Duration::from_secs(1), - "waiting".to_string(), + // 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(); + + // drive's recorded set is exactly the two block-boundary roots. + let recorded: HashSet<[u8; 32]> = [root_block1, root_block2].into_iter().collect(); + + let (spends, anchor) = + select_recorded_spends(&store, std::slice::from_ref(¬e), &recorded) + .expect("a prior recorded checkpoint covers the owned note"); + + let _ = std::fs::remove_file(&path); + + assert_eq!(spends.len(), 1, "the single owned note is spendable"); + assert!( + recorded.contains(&anchor.to_bytes()), + "the selected anchor must be a Platform-recorded root" + ); + assert_eq!( + anchor.to_bytes(), + root_block2, + "must pick the shallowest recorded checkpoint (block 2 / depth 1), not a deeper one" + ); + assert_ne!( + anchor.to_bytes(), + root_depth0, + "must NOT use the mid-block depth-0 root drive never recorded" ); - assert!(!is_nullifier_already_spent(&other)); } - /// The re-broadcast outcome classifier, arm order included: - /// `NullifierAlreadySpent` is itself a consensus error, so the - /// `AlreadyExecuted` arm must win over `DefinitiveRejection`. + /// 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 redrive_broadcast_classification_matrix() { - assert_eq!( - classify_redrive_broadcast(&Ok(())), - RedriveBroadcastOutcome::Accepted - ); + fn fully_synced_returns_depth0_anchor() { + let path = temp_tree_path("fastpath"); + let mut store = FileBackedShieldedStore::open_path(&path, 100).unwrap(); - let already_spent = ConsensusError::StateError(StateError::NullifierAlreadySpentError( - NullifierAlreadySpentError::new([1u8; 32]), - )); - let already_spent_err = - dash_sdk::Error::StateTransitionBroadcastError(StateTransitionBroadcastError { - code: 1, - message: "state error".to_string(), - cause: Some(already_spent), - }); - assert_eq!( - classify_redrive_broadcast(&Err(already_spent_err)), - RedriveBroadcastOutcome::AlreadyExecuted, - "already-spent must classify as success BEFORE the generic rejection arm" - ); + let note = real_note(0); - let other_rejection = ConsensusError::BasicError( - dpp::consensus::basic::BasicError::ProtocolVersionParsingError( - dpp::consensus::basic::decode::ProtocolVersionParsingError::new( - "bad version".to_string(), - ), - ), - ); - let rejection_err = - dash_sdk::Error::StateTransitionBroadcastError(StateTransitionBroadcastError { - code: 1, - message: "state error".to_string(), - cause: Some(other_rejection), - }); - assert_eq!( - classify_redrive_broadcast(&Err(rejection_err)), - RedriveBroadcastOutcome::DefinitiveRejection - ); + // One block, checkpointed exactly on its boundary: depth 0 == a recorded + // anchor. + store.append_commitment(¬e.cmx, true).unwrap(); + store.append_commitment(&filler_cmx(0xA1), true).unwrap(); + store.append_commitment(&filler_cmx(0xA2), true).unwrap(); + store.checkpoint_tree(3).unwrap(); + let root_depth0 = store.tree_anchor().unwrap(); - let timeout = dash_sdk::Error::TimeoutReached( - std::time::Duration::from_secs(1), - "waiting".to_string(), - ); - assert_eq!( - classify_redrive_broadcast(&Err(timeout)), - RedriveBroadcastOutcome::Inconclusive - ); - } + let recorded: HashSet<[u8; 32]> = [root_depth0].into_iter().collect(); - #[tokio::test] - async fn identity_redrive_ignores_spend_anchor_and_attempt_limit() { - let sdk = Arc::new(dash_sdk::SdkBuilder::new_mock().build().expect("mock sdk")); - let store = Arc::new(RwLock::new(InMemoryShieldedStore::new())); - let wallet_id = [0x45; 32]; - let previous_account = SubwalletId::new(wallet_id, 7); - let activity_id = [0x67; 32]; - let transition = identity_shield_transition([0x89; 32], 11); - store - .write() - .await - .arm_redrive( - previous_account, - PendingRedrive { - activity_id, - anchor: [0xAB; 32], - nullifiers: vec![], - st_bytes: transition.serialize_to_bytes().expect("serialize"), - attempts: MAX_REDRIVE_ATTEMPTS, - identity_nonce_finalized: false, - identity_user_abandoned: false, - }, - ) - .expect("arm identity guard"); + let (spends, anchor) = + select_recorded_spends(&store, std::slice::from_ref(¬e), &recorded) + .expect("depth-0 root is recorded"); - redrive_pending_identity_shields(&sdk, &store, wallet_id, &CancellationToken::new()).await; + let _ = std::fs::remove_file(&path); - let retained = store - .read() - .await - .pending_redrives(previous_account) - .expect("retained redrive"); - assert_eq!( - retained.len(), - 1, - "an endpoint error cannot clear the guard" - ); + assert_eq!(spends.len(), 1); assert_eq!( - retained[0].attempts, - MAX_REDRIVE_ATTEMPTS + 1, - "identity retries stay live beyond the note-spend attempt budget" + anchor.to_bytes(), + root_depth0, + "the fully-synced fast path returns the depth-0 anchor" ); } - #[tokio::test] - async fn ordinary_redrive_pass_leaves_identity_guard_untouched() { - let sdk = Arc::new(dash_sdk::SdkBuilder::new_mock().build().expect("mock sdk")); - let store = Arc::new(RwLock::new(InMemoryShieldedStore::new())); - let wallet_id = [0x54; 32]; - let id = SubwalletId::new(wallet_id, 0); - let anchor = [0x76; 32]; - let activity_id = [0x98; 32]; - store - .write() - .await - .arm_redrive( - id, - PendingRedrive { - activity_id, - anchor, - nullifiers: vec![], - st_bytes: identity_shield_transition([0xBA; 32], 3) - .serialize_to_bytes() - .expect("serialize"), - attempts: 0, - identity_nonce_finalized: false, - identity_user_abandoned: false, - }, - ) - .expect("arm identity guard"); - let recorded = [anchor].into_iter().collect(); + /// No checkpoint root is recorded: the probe exhausts every depth and + /// returns the retryable `ShieldedNoRecordedAnchor` — nothing is broadcast. + #[test] + fn no_recorded_checkpoint_returns_retryable_error() { + let path = temp_tree_path("none"); + let mut store = FileBackedShieldedStore::open_path(&path, 100).unwrap(); - redrive_pending_spends( - &sdk, - &store, - None, - wallet_id, - id, - &recorded, - &CancellationToken::new(), - ) - .await; + let note = real_note(0); - let retained = store.read().await.pending_redrives(id).expect("guard"); - assert_eq!(retained.len(), 1); - assert_eq!( - retained[0].attempts, 0, - "the anchor-gated spend pass must not consume identity retry state" - ); - } + 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(); - /// Decision paths that must NOT touch the network (the mock SDK has - /// no broadcast expectation, so any attempt would error into the - /// inconclusive arm and bump the counter): a pruned anchor belongs - /// to the release backstop, an exhausted budget stays parked, and a - /// corrupt stored transition is dropped so it can't wedge the pass. - #[tokio::test] - async fn redrive_skips_pruned_and_exhausted_and_drops_garbage() { - let sdk = Arc::new(dash_sdk::SdkBuilder::new_mock().build().expect("mock sdk")); - let store = Arc::new(RwLock::new(InMemoryShieldedStore::new())); - let wallet_id = [5u8; 32]; - let id = SubwalletId::new(wallet_id, 0); + // Platform recorded none of this wallet's checkpoint roots. + let recorded: HashSet<[u8; 32]> = HashSet::new(); - let rec = |activity: u8, anchor: u8, attempts: u32| PendingRedrive { - activity_id: [activity; 32], - anchor: [anchor; 32], - nullifiers: vec![[activity ^ 0xFF; 32]], - st_bytes: vec![0xDE, 0xAD], // never deserializes - attempts, - identity_nonce_finalized: false, - identity_user_abandoned: false, - }; - { - let mut guard = store.write().await; - // Pruned anchor (10 not in recorded set) → untouched. - guard.arm_redrive(id, rec(1, 10, 0)).unwrap(); - // Recorded anchor but attempts exhausted → untouched. - guard - .arm_redrive(id, rec(2, 11, MAX_REDRIVE_ATTEMPTS)) - .unwrap(); - // Recorded anchor, budget left, garbage bytes → dropped - // before any broadcast. - guard.arm_redrive(id, rec(3, 12, 0)).unwrap(); + // `SpendableNote` isn't `Debug`, so match rather than `expect_err`. + let result = select_recorded_spends(&store, std::slice::from_ref(¬e), &recorded); + + let _ = std::fs::remove_file(&path); + + match result { + Err(PlatformWalletError::ShieldedNoRecordedAnchor(_)) => {} + Err(other) => { + panic!("expected ShieldedNoRecordedAnchor, got error: {other:?}") + } + Ok(_) => panic!("expected ShieldedNoRecordedAnchor, got Ok"), } - let recorded: std::collections::HashSet<[u8; 32]> = - [[11u8; 32], [12u8; 32]].into_iter().collect(); + } - redrive_pending_spends( - &sdk, - &store, - None, - wallet_id, - id, - &recorded, - &CancellationToken::new(), - ) - .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 left = store.read().await.pending_redrives(id).unwrap(); - let ids: Vec<[u8; 32]> = left.iter().map(|r| r.activity_id).collect(); - assert!(ids.contains(&[1u8; 32]), "pruned-anchor record left alone"); - assert!(ids.contains(&[2u8; 32]), "exhausted record left alone"); - assert!( - !ids.contains(&[3u8; 32]), - "corrupt record dropped without a broadcast attempt" - ); - assert_eq!( - left.iter().map(|r| r.attempts).max(), - Some(MAX_REDRIVE_ATTEMPTS), - "no attempt counters were bumped — nothing touched the network" - ); + // Block 1 = positions 0,1,2 (fillers), checkpointed on its boundary — + // the only root Platform recorded. + store.append_commitment(&filler_cmx(0xA0), true).unwrap(); + store.append_commitment(&filler_cmx(0xA1), true).unwrap(); + store.append_commitment(&filler_cmx(0xA2), true).unwrap(); + store.checkpoint_tree(3).unwrap(); + let root_block1 = store.tree_anchor().unwrap(); + + // The owned note is appended AFTER block 1 (position 3) and checkpointed: + // present at depth 0, absent from the block-1 checkpoint (depth 1). + let note = real_note(3); + store.append_commitment(¬e.cmx, true).unwrap(); + store.checkpoint_tree(4).unwrap(); + + let recorded: HashSet<[u8; 32]> = [root_block1].into_iter().collect(); + + let result = select_recorded_spends(&store, std::slice::from_ref(¬e), &recorded); + + let _ = std::fs::remove_file(&path); + + match result { + Err(PlatformWalletError::ShieldedNoRecordedAnchor(_)) => {} + Err(other) => panic!("expected ShieldedNoRecordedAnchor, got error: {other:?}"), + Ok(_) => panic!("expected ShieldedNoRecordedAnchor, got Ok"), + } } } +/// Unit tests for the ONE-TIME-key claim path +/// ([`identity_create_from_one_time_key`] / [`super::sync::scan_notes_for_foreign_key`]). +/// +/// The full op needs a live SDK note stream, so these cover the network-free +/// pieces the crate ADDS: deriving a note owned by a foreign one-time spending +/// key (the scan's per-note conversion — value / cmx / nullifier / serialization), +/// the exact-equality selection over the transiently-scanned set (exact / over / +/// under / no-note), and witnessing that foreign note against a Platform-recorded +/// anchor in the shared marked tree. The key-agnostic Type-20 BUILD with a +/// foreign key is proven by rs-dpp's own green builder tests +/// (`SpendingKey::from_bytes([..]) → fvk/ask → build … succeeds`). #[cfg(test)] -mod classify_spend_wait_failure_tests { +mod one_time_key_tests { use super::*; - use dash_sdk::error::StateTransitionBroadcastError; - use dpp::consensus::basic::decode::ProtocolVersionParsingError; - use dpp::consensus::basic::BasicError; - use dpp::consensus::ConsensusError; + use crate::wallet::shielded::file_store::FileBackedShieldedStore; + use dpp::version::PlatformVersion; + use grovedb_commitment_tree::{ + ExtractedNoteCommitment, FullViewingKey, Note, NoteValue, RandomSeed, Rho, Scope, + SpendingKey, + }; - fn broadcast_err(cause: Option) -> dash_sdk::Error { - dash_sdk::Error::StateTransitionBroadcastError(StateTransitionBroadcastError { - code: 13, - message: "context deadline exceeded".to_string(), - cause, - }) + /// Smallest member of the versioned exit-denomination set (0.1 DASH). + const DENOMINATION: u64 = 10_000_000_000; + + /// A fixed, valid one-time Orchard spending key for the tests. + const ONE_TIME_SK: [u8; 32] = [0x24; 32]; + + fn temp_tree_path(tag: &str) -> std::path::PathBuf { + let nanos = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos(); + std::env::temp_dir().join(format!("one_time_key_{tag}_{nanos}.sqlite")) } - #[test] - fn consensus_cause_is_a_definitive_rejection() { - let cause = ConsensusError::BasicError(BasicError::ProtocolVersionParsingError( - ProtocolVersionParsingError::new("bad version".to_string()), - )); - let err = classify_spend_wait_failure("unshield", &broadcast_err(Some(cause))); - assert!( - matches!(err, PlatformWalletError::ShieldedBroadcastFailed(_)), - "a broadcast error carrying a consensus cause means Platform ran and \ - rejected the transition; the caller may release the reservation" - ); + fn filler_cmx(b: u8) -> [u8; 32] { + let mut c = [0u8; 32]; + c[0] = b; + c + } + + /// The full-viewing key of the one-time spending key. + fn one_time_fvk() -> FullViewingKey { + let sk: SpendingKey = Option::from(SpendingKey::from_bytes(ONE_TIME_SK)) + .expect("fixed one-time SK is a valid Orchard SpendingKey"); + FullViewingKey::from(&sk) + } + + /// Build one real Orchard note OWNED BY the one-time key, shaped exactly as + /// [`super::sync::scan_notes_for_foreign_key`] would produce it: `cmx` is the + /// note's real commitment, `nullifier` is derived under the one-time key's + /// fvk, and `note_data` is the canonical 115-byte serialization. + fn one_time_note(value: u64, position: u64) -> ShieldedNote { + let fvk = one_time_fvk(); + let recipient = fvk.address_at(0u32, Scope::External); + + // rho / rseed must be canonical Pallas base-field elements — scan + // deterministically (mirrors the existing note builders in this file). + let rho = (1u16..=u16::MAX) + .find_map(|n| { + let mut b = [0u8; 32]; + b[0..2].copy_from_slice(&n.to_le_bytes()); + Rho::from_bytes(&b).into_option() + }) + .expect("a canonical rho exists"); + let rseed = (1u16..=u16::MAX) + .find_map(|m| { + let mut b = [0u8; 32]; + b[2..4].copy_from_slice(&m.to_le_bytes()); + RandomSeed::from_bytes(b, &rho).into_option() + }) + .expect("a canonical rseed exists"); + + let note = Note::from_parts(recipient, NoteValue::from_raw(value), rho, rseed) + .into_option() + .expect("valid note parts"); + let cmx = ExtractedNoteCommitment::from(note.commitment()).to_bytes(); + let nullifier = note.nullifier(&fvk).to_bytes(); + + let mut note_data = Vec::with_capacity(115); + note_data.extend_from_slice(¬e.recipient().to_raw_address_bytes()); + note_data.extend_from_slice(¬e.value().inner().to_le_bytes()); + note_data.extend_from_slice(¬e.rho().to_bytes()); + note_data.extend_from_slice(note.rseed().as_bytes()); + + ShieldedNote { + position, + cmx, + nullifier, + block_height: 1, + is_spent: false, + value, + note_data, + } } + /// The scan's per-note conversion is correct: a note owned by the one-time + /// key round-trips through the wallet's 115-byte serialization, and its + /// nullifier matches the one derived under that key's fvk (what the scan + /// stamps). This is the piece [`super::sync::scan_notes_for_foreign_key`] + /// runs on every discovered note. #[test] - fn causeless_broadcast_error_is_ambiguous() { - // DAPI maps its own wait failures (e.g. `DapiError::Timeout`) to a - // `StateTransitionBroadcastError` with EMPTY consensus data, which the - // SDK decodes as `cause: None`. The spend may still execute after the - // wait gave up, so this must NOT release the note reservations. - let err = classify_spend_wait_failure("unshield", &broadcast_err(None)); - assert!( - matches!( - err, - PlatformWalletError::ShieldedSpendUnconfirmed { - operation: "unshield", - .. - } - ), - "got {err:?}" + 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 transport_errors_are_ambiguous() { - let err = classify_spend_wait_failure( - "withdraw", - &dash_sdk::Error::TimeoutReached( - std::time::Duration::from_secs(80), - "waiting for response".to_string(), - ), + 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" ); - assert!(matches!( - err, - PlatformWalletError::ShieldedSpendUnconfirmed { - operation: "withdraw", - .. + + // 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:?}"), + } - /// The shape a CheckTx rejection takes when it reaches the SDK from - /// `broadcast()`: DAPI re-attaches the serialized consensus error as - /// gRPC metadata and the dapi-client decodes it into - /// `Error::Protocol(ConsensusError)`. - fn consensus_metadata_rejection() -> dash_sdk::Error { - let cause = ConsensusError::BasicError(BasicError::ProtocolVersionParsingError( - ProtocolVersionParsingError::new("bad version".to_string()), - )); - dash_sdk::Error::Protocol(dpp::ProtocolError::ConsensusError(Box::new(cause))) + // 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:?}"), + } } - /// A CheckTx consensus rejection surfacing from `broadcast()` IS - /// definitive: the transition was evaluated and refused, it sits in no - /// mempool, and the caller may release the reservations and retry. + /// 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 broadcast_consensus_rejection_is_definitive() { - assert!(broadcast_definitely_failed(&consensus_metadata_rejection())); - // The same verdict shape is definitive on the wait stage too. - let err = classify_spend_wait_failure("transfer", &consensus_metadata_rejection()); - assert!(matches!( - err, - PlatformWalletError::ShieldedBroadcastFailed(_) - )); - } + fn foreign_key_note_witnesses_against_recorded_anchor() { + let path = temp_tree_path("witness"); + let mut store = FileBackedShieldedStore::open_path(&path, 100).unwrap(); - fn grpc_err(code: dash_sdk::dapi_grpc::tonic::Code) -> dash_sdk::Error { - use dash_sdk::dapi_client::transport::TransportError; - use dash_sdk::dapi_client::DapiClientError; - dash_sdk::Error::DapiClientError(DapiClientError::Transport(TransportError::Grpc( - dash_sdk::dapi_grpc::tonic::Status::new(code, "boom"), - ))) + 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" + ); } +} - /// Connection-establishment failures and non-consensus server - /// rejections of `broadcast()` ARE definitive: nothing was admitted to - /// a mempool. The offline case (connect refused → `Unavailable`) in - /// particular must release the notes immediately rather than strand - /// them until restart. - #[test] - fn broadcast_connection_failures_and_rejections_are_definitive() { - use dash_sdk::dapi_grpc::tonic::Code; - assert!(broadcast_definitely_failed(&grpc_err(Code::Unavailable))); - // Mempool full / malformed request: server verdicts refusing admission. - assert!(broadcast_definitely_failed(&grpc_err( - Code::ResourceExhausted - ))); - assert!(broadcast_definitely_failed(&grpc_err( - Code::InvalidArgument - ))); +/// 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")) } - /// No-response shapes of the `broadcast()` call itself are NOT - /// definitive: the dapi-client retries across addresses, so the request - /// may have been delivered to a node whose ACK was lost. The caller - /// falls through to the result wait instead of releasing the - /// reservations (which would let a retry select other unreserved notes - /// and double-send if the original broadcast landed). - #[test] - fn broadcast_no_response_shapes_are_inconclusive() { - use dash_sdk::dapi_grpc::tonic::Code; - assert!(!broadcast_definitely_failed(&grpc_err( - Code::DeadlineExceeded - ))); - // DAPI maps its own tenderdash-side failures (which can postdate - // delivery) to Internal. - assert!(!broadcast_definitely_failed(&grpc_err(Code::Internal))); - assert!(!broadcast_definitely_failed( - &dash_sdk::Error::TimeoutReached( - std::time::Duration::from_secs(30), - "broadcast".to_string(), - ) - )); - assert!(!broadcast_definitely_failed(&dash_sdk::Error::Generic( - "transport error: connection reset".to_string() - ))); - // …including as the terminal error of an exhausted retry loop. - assert!(!broadcast_definitely_failed( - &dash_sdk::Error::NoAvailableAddressesToRetry(Box::new(grpc_err( - Code::DeadlineExceeded - ))) - )); + /// The two real note nullifiers this claim spends. + fn our_nullifiers() -> Vec<[u8; 32]> { + vec![[0x11; 32], [0x22; 32]] } - /// `AlreadyExists` from `broadcast()` proves the transition IS already - /// in the mempool or on chain (e.g. an internal dapi-client retry after - /// an ambiguous first delivery), so it must NOT be treated as a failure - /// — the spend is in flight and the result wait will confirm it. - #[test] - fn broadcast_already_exists_is_in_flight() { - assert!(!broadcast_definitely_failed( - &dash_sdk::Error::AlreadyExists("state transition already in mempool".to_string()) - )); + /// 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, + }) } - /// A consensus verdict wrapped in the dapi-client's `NoAvailableAddressesToRetry` - /// retry envelope must still count as a rejection — `carries_consensus_rejection` - /// recurses through the envelope, in lockstep with `as_address_invalid_nonce`. - #[test] - fn wrapped_consensus_rejection_is_a_rejection() { - let wrapped = - dash_sdk::Error::NoAvailableAddressesToRetry(Box::new(consensus_metadata_rejection())); - assert!(carries_consensus_rejection(&wrapped)); + 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") } - /// 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)); + /// The MASTER auth key this claim submits. + fn our_master_key() -> IdentityPublicKey { + key_with_hash( + 0, + Purpose::AUTHENTICATION, + SecurityLevel::MASTER, + OUR_MASTER_HASH, + ) } - /// 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`. + /// 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 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; + fn chargeable_unshield_fallback_identity_is_rejected() { + let expected_id = identity_id_from_nullifiers(&our_nullifiers()); - 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)); + // 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()]); - 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:?}"), - } + 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" + ); } -} - -#[cfg(test)] -mod shield_input_fetch_error_tests { - use super::*; - use dpp::consensus::state::address_funds::AddressNotEnoughFundsError; + /// 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 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, - )); + 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, + )], + ); - 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" + 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" ); } -} -#[cfg(test)] -mod reserve_shield_fee_tests { - use super::*; - use dpp::version::LATEST_PLATFORM_VERSION; + /// 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![]); - fn addr(b: u8) -> PlatformAddress { - PlatformAddress::P2pkh([b; 20]) + 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 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"); + fn unre_derivable_id_is_rejected() { + let identity = identity_with_keys(Identifier::from([0xEE; 32]), vec![our_master_key()]); - assert_eq!(out.get(&addr(1)), Some(&(1_000_000 + fee))); - assert_eq!( - out.get(&addr(2)), - Some(&5_000_000), - "other inputs untouched" + 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" ); - // Σ claims grew by exactly `fee`, satisfying `Σ inputs >= amount + F`. - assert_eq!(out.values().sum::(), 6_000_000 + fee); } + /// A missing MASTER auth key hash is not evidence either. #[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"); + 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!( - smallest_fee_inclusive_claim >= min_input_amount, - "adding the fee must lift even input 0's smallest positive base claim above the protocol minimum" + !recovered_identity_matches_claim(&identity, Some(expected_id), None), + "without a submitted master auth key hash the key binding cannot be established" ); - assert!( - reserve >= shield_fee, - "the retained input-0 headroom must cover the versioned shield fee" + } + + /// 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!( - 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" + !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 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 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 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(_))); + 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" + ); } -} -#[cfg(test)] -mod note_reservation_release_tests { - use super::*; + // ── 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) + } - /// `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. + /// 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), + identity_nonce_finalized: false, + identity_user_abandoned: false, + } + } + + 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 unconfirmed_broadcast_retains_reservation() { - let e = PlatformWalletError::ShieldedBroadcastUnconfirmed { - identity_id: Identifier::from([7u8; 32]), - reason: "result proof unavailable".to_string(), + fn claim_binding_is_recoverable_from_the_stored_transition() { + use dpp::serialization::PlatformDeserializableTrusted; + use dpp::state_transition::state_transitions::shielded::identity_create_from_shielded_pool_transition::accessors::IdentityCreateFromShieldedPoolTransitionAccessorsV0; + + let submitted = vec![ + our_master_key(), + key_with_hash(1, Purpose::TRANSFER, SecurityLevel::CRITICAL, [0xC3; 20]), + ]; + let (_, st_bytes) = stored_claim_transition(&submitted, DENOMINATION); + + let restored = StateTransition::deserialize_from_bytes_trusted(&st_bytes) + .expect("stored bytes deserialize"); + let StateTransition::IdentityCreateFromShieldedPool(transition) = &restored else { + panic!("stored record must carry a shielded identity-create transition"); }; - assert!( - !error_releases_note_reservation(&e), - "ShieldedBroadcastUnconfirmed must retain the note reservation" + + let derived: BTreeMap = transition + .public_keys() + .iter() + .map(|key_in_creation| { + let key: IdentityPublicKey = key_in_creation.into(); + (key.id(), key) + }) + .collect(); + + assert_eq!( + derived, + keys_map(&submitted), + "the submitted key set must be recoverable from the transition itself" + ); + assert_eq!(transition.denomination(), DENOMINATION); + assert_eq!( + master_auth_public_key_hash_of(derived.values()), + Some(OUR_MASTER_HASH), + "the recovery handle must be derivable from the transition, not supplied by the retry" ); } - /// Every other failure is a definitive pre-execution / build / rejection failure — the spend - /// never happened, so the reservation must be released. + /// MATCHING ARGS: a retry presenting exactly what the earlier attempt + /// submitted is a genuine resume and must pass the binding gate. #[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 matching_retry_arguments_resume() { + let submitted = vec![our_master_key()]; + let keys = keys_map(&submitted); + + assert_eq!( + one_time_claim_binding_mismatch( + &keys, + Some(OUR_MASTER_HASH), + DENOMINATION, + Some(IDENTITY_INDEX), + &keys, + Some(OUR_MASTER_HASH), + DENOMINATION, + IDENTITY_INDEX, + ), + None, + "identical arguments must not be treated as a mis-binding" + ); } -} -#[cfg(test)] -mod record_activity_status_tests { - use super::*; - use crate::wallet::shielded::activity::{ - ShieldedActivityEntry, ShieldedActivityKind, ShieldedDirection, - }; - use crate::wallet::shielded::store::InMemoryShieldedStore; + /// 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(), + ]); - fn sub() -> SubwalletId { - SubwalletId::new([0xCC; 32], 0) + 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 + ); } - /// 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![], - } + /// MISMATCHED ARGS, per field. Each of these is a way the pre-fix resume + /// would have acted on the caller's value while broadcasting the stored + /// bytes: a swapped key set registers a foreign identity at this wallet's + /// slot and backfills an empty proof result with keys that were never in the + /// transition; a swapped master hash makes idempotent recovery probe + /// Platform for someone else's identity; a swapped denomination misreports + /// the value that left the pool. + #[test] + fn mismatched_retry_arguments_are_refused_per_field() { + let stored = keys_map(&[our_master_key()]); + let swapped = keys_map(&[other_master_key()]); + + // Same key ids, different key material — ids alone would not catch it. + assert_eq!( + stored.keys().collect::>(), + swapped.keys().collect::>(), + "precondition: the swap keeps the key ids identical" + ); + + let key_mismatch = one_time_claim_binding_mismatch( + &stored, + Some(OUR_MASTER_HASH), + DENOMINATION, + 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" + ); } - /// 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). + /// 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 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)); + async fn mismatched_retry_refuses_without_broadcasting_or_clearing_the_record() { + let sdk = Arc::new(dash_sdk::SdkBuilder::new_mock().build().expect("mock sdk")); + let path = temp_store_path("resume_binding"); + let store = Arc::new(RwLock::new( + FileBackedShieldedStore::open_path(&path, 100).expect("store opens"), + )); + let claim_records_id = SubwalletId::new([0x77; 32], ONE_TIME_CLAIM_RECORDS_ACCOUNT); + + let (_, st_bytes) = stored_claim_transition(&[our_master_key()], DENOMINATION); + let record = stored_claim_record(st_bytes); store .write() .await - .save_activity(id, &scan_confirmed) - .unwrap(); + .arm_redrive(claim_records_id, record.clone()) + .expect("record arms"); - record_activity_status( + // The retry presents a DIFFERENT identity's keys — the mis-slot case. + let outcome = resume_one_time_claim( + &sdk, &store, - None, - id.wallet_id, - id, - &Some(pending), - ShieldedActivityStatus::Failed, - None, + claim_records_id, + &record, + // The binding mismatch is refused before the pre-broadcast + // lease gate is ever reached. + crate::wallet::shielded::store::AdmissionToken::generate().expect("token"), + Some(OTHER_MASTER_HASH), + keys_map(&[other_master_key()]), + DENOMINATION, + IDENTITY_INDEX, ) .await; - let stored = store + match outcome { + OneTimeClaimResume::Resolved(Err( + PlatformWalletError::ShieldedClaimBindingMismatch { mismatch }, + )) => assert!( + mismatch.contains("master authentication key hash") + || mismatch.contains("public key set"), + "the refusal must name the binding that failed, got: {mismatch}" + ), + other => panic!( + "a retry with a different identity's keys must be refused, got {}", + match other { + OneTimeClaimResume::RecordUnusable => "RecordUnusable".to_string(), + OneTimeClaimResume::Resolved(r) => format!("Resolved({r:?})"), + } + ), + } + + // Fail-closed: the record survives, so the ORIGINAL claim is still + // resumable. Clearing it here would strand a padded single-note claim + // forever — its declared id exists nowhere else. + let surviving = store .read() .await - .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)); + .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); } - /// 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(); + /// 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}" + ); + } - record_activity_status( - &store, - None, - id.wallet_id, - id, - &Some(pending), - ShieldedActivityStatus::Confirmed, - Some(900), - ) - .await; + /// 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 a_pre_migration_record_still_resumes_at_any_index() { + let keys = keys_map(&[our_master_key()]); - 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)); + 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" + ); } - /// 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. + /// 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 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(); + 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); - record_activity_status_by_id( + 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"); + + // 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, - None, - id.wallet_id, - id, - &pending.id, - ShieldedActivityStatus::Failed, + claim_records_id, + &record, + // As above: refused on the slot mismatch, before the gate. + crate::wallet::shielded::store::AdmissionToken::generate().expect("token"), + Some(OUR_MASTER_HASH), + keys_map(&[our_master_key()]), + DENOMINATION, + IDENTITY_INDEX + 1, ) .await; - let stored = store + 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 - .get_activity_by_entry_id(id, &pending.id) - .unwrap() - .expect("row must exist"); - assert_eq!(stored.status, ShieldedActivityStatus::Failed); + .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); } - /// A by-id flip for an entry that doesn't exist is a silent no-op - /// (nothing to flip), never a panic. + /// END TO END for #4313 review finding f58ed9d910d8: a resume whose + /// purge-protection lease is gone must refuse RETRYABLY and must not + /// re-broadcast. + /// + /// A re-broadcast is a chargeable resubmission, and continuing it without a + /// provable lease is what lets a concurrent clear or wallet removal count + /// zero live claims and delete the recovery record while the transition is + /// in flight. The SDK is a bare mock with no expectations registered, so a + /// re-broadcast would surface as a broadcast-shaped error rather than as + /// `ShieldedLifecycleBusy`. + /// + /// The record must SURVIVE: refusing is only safe because the retry can + /// still resume it. #[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( + async fn a_resume_without_a_provable_lease_refuses_instead_of_rebroadcasting() { + let sdk = Arc::new(dash_sdk::SdkBuilder::new_mock().build().expect("mock sdk")); + let path = temp_store_path("resume_lease_gate"); + 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); + + 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"); + + // A token that owns no lease — the state a reaped or displaced claim is + // left in. Every binding below MATCHES the stored transition, so the + // mismatch gate cannot be what refuses this. + let orphaned = crate::wallet::shielded::store::AdmissionToken([0x7F; 16]); + let outcome = resume_one_time_claim( + &sdk, &store, - None, - id.wallet_id, - id, - &[0xDE; 32], - ShieldedActivityStatus::Failed, + claim_records_id, + &record, + orphaned, + Some(OUR_MASTER_HASH), + keys_map(&[our_master_key()]), + DENOMINATION, + IDENTITY_INDEX, ) .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 select_recorded_spends_tests { - use super::*; - use crate::wallet::shielded::file_store::FileBackedShieldedStore; - use dashcore::Network; - use grovedb_commitment_tree::{ExtractedNoteCommitment, Note, NoteValue, RandomSeed, Rho}; - - /// 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")) - } - - /// 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; - - // 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, + match outcome { + OneTimeClaimResume::Resolved(Err(PlatformWalletError::ShieldedLifecycleBusy { + reason, + })) => assert!( + reason.contains("lease"), + "the refusal must name the lost lease, got: {reason}" + ), + other => panic!( + "a resume without a provable lease must refuse retryably, got {}", + match other { + OneTimeClaimResume::RecordUnusable => "RecordUnusable".to_string(), + OneTimeClaimResume::Resolved(r) => format!("Resolved({r:?})"), + } + ), } - } - - /// 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 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(); + let surviving = store + .read() + .await + .pending_redrives(claim_records_id) + .expect("records readable"); + assert_eq!( + surviving.len(), + 1, + "refusing is only safe because the record survives for the retry" + ); - // 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(); + drop(store); + let _ = std::fs::remove_file(&path); + } +} - // 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(); +#[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, + }; - // drive's recorded set is exactly the two block-boundary roots. - let recorded: HashSet<[u8; 32]> = [root_block1, root_block2].into_iter().collect(); + /// 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" + } - let (spends, anchor) = - select_recorded_spends(&store, std::slice::from_ref(¬e), &recorded) - .expect("a prior recorded checkpoint covers the owned note"); + /// 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")); - let _ = std::fs::remove_file(&path); + assert_eq!(resume_shaped_body().await, "resumed"); - assert_eq!(spends.len(), 1, "the single owned note is spendable"); + // Probe: is the lease still live at a point past its ORIGINAL expiry? + // Nothing re-stamped it, so no. assert!( - recorded.contains(&anchor.to_bytes()), - "the selected anchor must be a Platform-recorded root" + !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" ); + } + + /// 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 outcome = under_renewed_claim_lease(&store, token, resume_shaped_body()).await; assert_eq!( - anchor.to_bytes(), - root_block2, - "must pick the shallowest recorded checkpoint (block 2 / depth 1), not a deeper one" + outcome, "resumed", + "the helper must return the body's value" ); - assert_ne!( - anchor.to_bytes(), - root_depth0, - "must NOT use the mid-block depth-0 root drive never recorded" + + 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" ); } - /// 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(); + /// 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 note = real_note(0); + 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 + ); + } - // 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(); + under_renewed_claim_lease(&store, holder, resume_shaped_body()).await; - let recorded: HashSet<[u8; 32]> = [root_depth0].into_iter().collect(); + // 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" + ); + } - let (spends, anchor) = - select_recorded_spends(&store, std::slice::from_ref(¬e), &recorded) - .expect("depth-0 root is recorded"); + /// A live lease authorizes the chargeable step, and re-stamps itself while + /// doing so — the broadcast that follows therefore runs at the start of a + /// full lease window rather than at whatever a long proof build left of one. + #[tokio::test(start_paused = true)] + async fn a_live_lease_authorizes_the_chargeable_step_and_is_restamped() { + let store = Arc::new(RwLock::new(InMemoryShieldedStore::new())); + let wallet_id: WalletId = [0x51; 32]; + let holder = AdmissionToken([0x51; 16]); + let rival = AdmissionToken([0x52; 16]); + let t0 = admission_now_ms(); + assert!(store + .write() + .await + .begin_claim_admission(wallet_id, holder, t0, SHORT_LEASE_MS) + .expect("lease")); - let _ = std::fs::remove_file(&path); + assert_claim_lease_before_chargeable_step(&store, holder, "the claim broadcast") + .await + .expect("a live lease must authorize the broadcast"); - assert_eq!(spends.len(), 1); + // Re-stamped: past the ORIGINAL expiry the lease is still counted, so a + // destructive pass still sees a live claim. + let live = store + .write() + .await + .begin_destructive_admission( + Some(wallet_id), + rival, + t0 + SHORT_LEASE_MS + 1, + CLAIM_LEASE_MS, + ) + .expect("destructive admission"); assert_eq!( - anchor.to_bytes(), - root_depth0, - "the fully-synced fast path returns the depth-0 anchor" + live, 1, + "the gate must re-stamp the lease it proves, so the broadcast runs inside a fresh \ + protection window" ); } - /// No checkpoint root is recorded: the probe exhausts every depth and - /// returns the retryable `ShieldedNoRecordedAnchor` — nothing is broadcast. - #[test] - fn no_recorded_checkpoint_returns_retryable_error() { - let path = temp_tree_path("none"); - let mut store = FileBackedShieldedStore::open_path(&path, 100).unwrap(); + /// THE BUG (#4313 review finding f58ed9d910d8): the heartbeat only LOGGED a + /// failed renewal and let the claim run on. Once the lease is gone, + /// `renew_claim_admission` refuses to resurrect it and destructive + /// admission reaps it before counting live claims — so a concurrent clear + /// or wallet removal counts zero claims and deletes the pending row while + /// the transition is on the wire. For a padded single-note bundle that row + /// is the only handle to the randomized identity id. + /// + /// So a chargeable step must refuse, retryably, when the lease is gone. + #[tokio::test(start_paused = true)] + async fn a_lapsed_lease_refuses_the_chargeable_step() { + let store = Arc::new(RwLock::new(InMemoryShieldedStore::new())); + let wallet_id: WalletId = [0x53; 32]; + let holder = AdmissionToken([0x53; 16]); + let t0 = admission_now_ms(); + assert!(store + .write() + .await + .begin_claim_admission(wallet_id, holder, t0, SHORT_LEASE_MS) + .expect("lease")); - let note = real_note(0); + // The lease is displaced exactly as a purge's reap would displace it. + store + .write() + .await + .end_claim_admission(holder) + .expect("release the lease"); - 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 refused = + assert_claim_lease_before_chargeable_step(&store, holder, "the claim broadcast").await; + assert!( + matches!( + refused, + Err(PlatformWalletError::ShieldedLifecycleBusy { .. }) + ), + "a chargeable step must refuse — RETRYABLY — without a lease it can prove; got \ + {refused:?}" + ); + } - // Platform recorded none of this wallet's checkpoint roots. - let recorded: HashSet<[u8; 32]> = HashSet::new(); + /// The same refusal for a lease that merely EXPIRED on the wall clock, + /// which is the case a forward clock adjustment or a long executor + /// suspension produces: `renew_claim_admission` will not resurrect it, so + /// ownership can no longer be proven and the claim must not broadcast. + #[tokio::test] + async fn an_expired_lease_cannot_be_resurrected_to_authorize_a_broadcast() { + let store = Arc::new(RwLock::new(InMemoryShieldedStore::new())); + let wallet_id: WalletId = [0x54; 32]; + let holder = AdmissionToken([0x54; 16]); + let t0 = admission_now_ms(); + // A zero-length lease: `expires_at == t0`, already expired against any + // `now >= t0` the gate reads. Expressing the expiry through the STAMP + // rather than by advancing time is what makes this deterministic — + // `admission_now_ms` is wall-clock, which a paused runtime never moves, + // so a short-but-nonzero lease races the gate. + assert!(store + .write() + .await + .begin_claim_admission(wallet_id, holder, t0, 0) + .expect("lease")); + + // `renew_claim_admission`'s `expires_at > now` predicate deliberately + // refuses to resurrect it — the store-level behaviour this gate is + // built on, and the reason a lapsed claim cannot renew its way back + // into ownership. + let renewed = store + .write() + .await + .renew_claim_admission(holder, admission_now_ms(), CLAIM_LEASE_MS) + .expect("renew must not error"); + assert!( + !renewed, + "precondition: an expired lease is deliberately not resurrectable" + ); - // `SpendableNote` isn't `Debug`, so match rather than `expect_err`. - let result = select_recorded_spends(&store, std::slice::from_ref(¬e), &recorded); + let refused = + assert_claim_lease_before_chargeable_step(&store, holder, "the claim re-broadcast") + .await; + assert!( + matches!( + refused, + Err(PlatformWalletError::ShieldedLifecycleBusy { .. }) + ), + "an expired lease must not authorize a re-broadcast; got {refused:?}" + ); + } +} - let _ = std::fs::remove_file(&path); +/// The claim's removal fence (#4313 review finding 4a2c679745bb). +#[cfg(test)] +mod claim_removal_fence_tests { + use super::*; + use crate::wallet::shielded::keys::OrchardKeySet; + use crate::wallet::shielded::store::{ + admission_now_ms, AdmissionToken, InMemoryShieldedStore, DESTRUCTIVE_BARRIER_MS, + }; + use dpp::identity::identity_public_key::v0::IdentityPublicKeyV0; + use dpp::identity::{KeyType, Purpose, SecurityLevel}; + use dpp::platform_value::BinaryData; + use std::sync::atomic::AtomicBool; - match result { - Err(PlatformWalletError::ShieldedNoRecordedAnchor(_)) => {} - Err(other) => { - panic!("expected ShieldedNoRecordedAnchor, got error: {other:?}") - } - Ok(_) => panic!("expected ShieldedNoRecordedAnchor, got Ok"), + /// Smallest member of the versioned exit-denomination set (0.1 DASH). + const DENOMINATION: u64 = 10_000_000_000; + + /// A prover that cannot prove. Reaching it means the claim got past the + /// removal fence and started building a bundle, which is the whole failure + /// this test exists to catch — so it is `unreachable!`, not a stub. + #[derive(Debug)] + struct NeverProver; + + impl OrchardProver for NeverProver { + fn proving_key(&self) -> &grovedb_commitment_tree::ProvingKey { + unreachable!("a claim refused at the removal fence must never build a bundle") } } - /// 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(); + /// A signer that cannot sign, for the same reason. + #[derive(Debug)] + struct NeverSigner; + + #[async_trait::async_trait] + impl Signer for NeverSigner { + async fn sign( + &self, + _key: &IdentityPublicKey, + _data: &[u8], + ) -> Result { + unreachable!("a claim refused at the removal fence must never sign") + } - // 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(); + async fn sign_create_witness( + &self, + _key: &IdentityPublicKey, + _data: &[u8], + ) -> Result { + unreachable!("a claim refused at the removal fence must never sign") + } - // 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(); + fn can_sign_with(&self, _key: &IdentityPublicKey) -> bool { + true + } + } - let recorded: HashSet<[u8; 32]> = [root_block1].into_iter().collect(); + fn master_auth_key() -> (IdentityPublicKey, IdentityPublicKeyInCreation) { + let key = IdentityPublicKey::V0(IdentityPublicKeyV0 { + id: 0, + purpose: Purpose::AUTHENTICATION, + security_level: SecurityLevel::MASTER, + contract_bounds: None, + key_type: KeyType::ECDSA_HASH160, + read_only: false, + data: BinaryData::new(vec![0xA1; 20]), + disabled_at: None, + }); + let in_creation: IdentityPublicKeyInCreation = (&key).into(); + (key, in_creation) + } - let result = select_recorded_spends(&store, std::slice::from_ref(¬e), &recorded); + /// The claimer's own default Orchard address, for the change note. + fn change_address() -> OrchardAddress { + let keys = OrchardKeySet::from_seed(&[0x42u8; 64], dashcore::Network::Testnet, 0) + .expect("derive the claimer's key set"); + OrchardAddress::from_raw_bytes(&keys.default_address.to_raw_address_bytes()) + .expect("the derived default address is a valid Orchard address") + } - let _ = std::fs::remove_file(&path); + /// The claim operation owns the key-role rule the bindings no longer + /// check: key ID 0 must be MASTER + AUTHENTICATION, and a claim without it + /// is refused before anything is scanned or armed. + #[tokio::test] + async fn a_claim_whose_key_zero_is_not_master_auth_is_refused() { + let sdk = Arc::new(dash_sdk::Sdk::new_mock()); + let store = Arc::new(RwLock::new(InMemoryShieldedStore::new())); + let wallet_id: WalletId = [0x11; 32]; + + let (mut key, _) = master_auth_key(); + if let IdentityPublicKey::V0(ref mut v0) = key { + v0.security_level = SecurityLevel::HIGH; + } + let in_creation: IdentityPublicKeyInCreation = (&key).into(); + + let result = identity_create_from_one_time_key( + &sdk, + &store, + &ForeignClaimGuards::default(), + &super::super::sync::ForeignScanCheckpointCache::default(), + &AtomicBool::new(false), + wallet_id, + zeroize::Zeroizing::new([0x01u8; 32]), + None, + &change_address(), + 3, + vec![(key, in_creation)], + DENOMINATION, + PlatformAddress::P2pkh([0x07; 20]), + &NeverSigner, + &NeverProver, + ) + .await; + + assert!( + matches!(result, Err(PlatformWalletError::ShieldedBuildError(ref m)) if m.contains("MASTER + AUTHENTICATION")), + "got {result:?}" + ); + let record_id = SubwalletId::new(wallet_id, ONE_TIME_CLAIM_RECORDS_ACCOUNT); + assert!(store + .read() + .await + .pending_redrives(record_id) + .expect("records readable") + .is_empty()); + } + + /// A claim that begins AFTER its wallet's removal has committed must be + /// refused before it scans, builds, or broadcasts anything + /// (#4313 review finding 4a2c679745bb). + /// + /// The reviewer's sequence: an FFI caller resolves and retains the wallet + /// and coordinator, `unregister_wallet_with` then commits the detach, + /// purges the store, RELEASES its destructive barrier and drops the wallet + /// from the manager — and only then does the stale handle start executing. + /// With the barrier already released, the claim's own admission succeeds, + /// so admission alone does not refuse it; before the fix it went on to arm + /// a pending row and broadcast for a wallet the host had already removed, + /// and its registration tail — finding no manager entry — left that row + /// behind. + /// + /// This drives that state directly: `detached` is already `true` and no + /// destructive admission is held, exactly as after a completed removal. + #[tokio::test] + async fn a_claim_that_starts_after_a_committed_removal_is_refused() { + let sdk = Arc::new(dash_sdk::Sdk::new_mock()); + let store = Arc::new(RwLock::new(InMemoryShieldedStore::new())); + let guards = ForeignClaimGuards::default(); + let checkpoints = super::super::sync::ForeignScanCheckpointCache::default(); + let wallet_id: WalletId = [0x11; 32]; + + // The removal has fully committed AND released its barrier. + let detached = AtomicBool::new(true); + + let (_key, _in_creation) = master_auth_key(); + let result = identity_create_from_one_time_key( + &sdk, + &store, + &guards, + &checkpoints, + &detached, + wallet_id, + zeroize::Zeroizing::new([0x01u8; 32]), + None, + &change_address(), + 3, + vec![master_auth_key()], + DENOMINATION, + PlatformAddress::P2pkh([0x07; 20]), + &NeverSigner, + &NeverProver, + ) + .await; + // RED before the fix: the claim ran on, and the `unreachable!` prover / + // signer above (or a mock-SDK scan error) stood in for the broadcast it + // would have made for a removed wallet. match result { - Err(PlatformWalletError::ShieldedNoRecordedAnchor(_)) => {} - Err(other) => panic!("expected ShieldedNoRecordedAnchor, got error: {other:?}"), - Ok(_) => panic!("expected ShieldedNoRecordedAnchor, got Ok"), + Err(PlatformWalletError::WalletNotFound(reason)) => { + assert!( + reason.contains(&hex::encode(wallet_id)), + "the refusal must name the removed wallet, got: {reason}" + ); + } + other => panic!( + "a claim beginning after a committed removal must be refused as WalletNotFound, \ + got {other:?}" + ), } + + // Nothing was armed for the removed wallet — the point of refusing + // under admission rather than after the broadcast. + let record_id = SubwalletId::new(wallet_id, ONE_TIME_CLAIM_RECORDS_ACCOUNT); + assert!( + store + .read() + .await + .pending_redrives(record_id) + .expect("records readable") + .is_empty(), + "a refused claim must leave no pending record behind" + ); + + // And the admission the refusal took was RELEASED, not left to expire: + // a destructive operation on this wallet must see zero live claims. + // The early return is on the fence path, so this is the only test that + // exercises that path's release. + let token = AdmissionToken::generate().expect("token"); + let live = store + .write() + .await + .begin_destructive_admission( + Some(wallet_id), + token, + admission_now_ms(), + DESTRUCTIVE_BARRIER_MS, + ) + .expect("admission"); + assert_eq!( + live, 0, + "the refusal must release its claim lease before returning" + ); + } +} + +#[cfg(test)] +mod claim_reservation_error_tests { + use super::super::file_store::FileShieldedStoreError; + use super::*; + + #[test] + fn a_damaged_claim_record_stays_typed_as_recovery_corrupted() { + let error = claim_reservation_error(FileShieldedStoreError::RecoveryCorrupted { + account_index: Some(u32::MAX), + reason: "one-time claim record: bad anchor".to_string(), + }); + assert!(matches!( + error, + PlatformWalletError::ShieldedRecoveryCorrupted { + account_index: Some(u32::MAX), + ref reason, + } if reason.contains("bad anchor") + )); + } + + #[test] + fn other_reservation_failures_are_persistence_errors() { + let error = claim_reservation_error(FileShieldedStoreError::Storage("disk full".into())); + assert!( + matches!(error, PlatformWalletError::Persistence(ref m) if m.contains("disk full")) + ); } } diff --git a/packages/rs-platform-wallet/src/wallet/shielded/store.rs b/packages/rs-platform-wallet/src/wallet/shielded/store.rs index 8efd0acfdcb..154a7af1139 100644 --- a/packages/rs-platform-wallet/src/wallet/shielded/store.rs +++ b/packages/rs-platform-wallet/src/wallet/shielded/store.rs @@ -162,6 +162,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, /// A proven identity nonce has made this exact debit impossible to execute /// again. Stop broadcasting, but keep its unresolved-payment guard until /// the scan confirms the outputs. Always false for note-spend redrives. @@ -509,6 +521,49 @@ pub trait ShieldedStore: Send + Sync { /// untouched. Used by `NetworkShieldedCoordinator::clear()`. fn purge_all_subwallets(&mut self) -> Result<(), Self::Error>; + // ── Removal tombstones ───────────────────────────────────────────── + + /// Record that `wallet_id` has been removed and its + /// [`purge_wallet`](Self::purge_wallet) is NOT yet confirmed complete. + /// + /// Written by `NetworkShieldedCoordinator::unregister_wallet_with` past + /// its commit point and BEFORE it attempts the purge, then dropped by + /// [`clear_wallet_removal`](Self::clear_wallet_removal) once that purge + /// actually succeeds. + /// + /// # Why this has to be durable + /// + /// The coordinator remembers an unfinished purge in an in-memory + /// `deferred_purges` set, and a newly opened coordinator starts with that + /// set EMPTY. A process death between the removal and a successful purge + /// therefore lost the deferral entirely, leaving this wallet's rows — + /// decrypted notes, pending claims, activity, and a `last_synced_note_index` + /// that restore only ever advances — to be inherited by a later + /// re-registration of the same id, which would then report caught-up and + /// skip the historical notes the purge existed to force a rescan of + /// (#4313 review finding 5ca995a0d2aa). + /// + /// Written BEFORE the purge rather than only on its failure, because a + /// crash *during* the purge orphans exactly the same rows and leaves no + /// failure to react to. A tombstone that outlives a purge which actually + /// succeeded costs one idempotent no-op purge on a later pass; a missing + /// one costs the rows forever. Idempotent. + fn record_wallet_removal(&mut self, wallet_id: WalletId) -> Result<(), Self::Error>; + + /// Drop `wallet_id`'s removal tombstone. Called only once its purge has + /// completed, so an outstanding tombstone means "this wallet's state may + /// still be here" and nothing weaker. Idempotent, and a no-op for a wallet + /// that has none. + fn clear_wallet_removal(&mut self, wallet_id: WalletId) -> Result<(), Self::Error>; + + /// Every wallet id whose removal tombstone is still outstanding, ascending. + /// + /// The durable half of the coordinator's deferred-purge set: it is what a + /// freshly opened coordinator (a relaunched process) has instead of that + /// set, and what lets startup, every sync pass, and a re-registration find + /// orphaned state they never witnessed being orphaned. + fn outstanding_wallet_removals(&self) -> Result, Self::Error>; + /// Empty the shared commitment tree back to zero leaves. /// /// After this returns, [`Self::tree_size`] reports `0` and the @@ -526,6 +581,498 @@ 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 one-time-key claims still hold it off inside it: every LIVE claim + /// lease, PLUS every durable one-time-claim record armed in scope (a + /// `shielded_pending_spends` row under the reserved + /// `ONE_TIME_CLAIM_RECORDS_ACCOUNT` subwallet). + /// + /// # Why an armed record counts on its own + /// + /// The lease alone is a timing assumption: it is reaped at `expires_at`, + /// and [`Self::renew_claim_admission`] refuses to resurrect a lapsed one. + /// A clock jump, a suspended process or a run of renewal failures past + /// [`CLAIM_LEASE_MS`] therefore used to make a claim whose transition was + /// already on the wire invisible here, and a `clear` or wallet removal then + /// deleted the only row holding its padded identity id (#4313 review + /// thread r3839256998). The record itself is the thing that must not be + /// deleted, so the record itself blocks — for exactly as long as it + /// exists, independent of any lease. It leaves the store only on a + /// terminal claim outcome or once the created identity's registration has + /// been durably acknowledged, which is what releases the block. A record + /// whose claim process died is released the same way: re-running the same + /// invitation claim resumes it to one of those two ends. + /// + /// `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 ────────────────────────────────────────── @@ -856,6 +1403,26 @@ 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, + /// Outstanding wallet-removal tombstones — see + /// [`ShieldedStore::record_wallet_removal`]. + /// + /// This store has no file behind it, so "durable" degenerates to "as long + /// as the store object lives". That is the right shape for the trait + /// contract anyway: the tombstone outlives the coordinator that wrote it, + /// which is exactly what a test that rebuilds a coordinator over the same + /// store exercises. Only the file-backed store survives a real restart. + removal_tombstones: BTreeSet, } impl InMemoryShieldedStore { @@ -863,6 +1430,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 { @@ -1140,6 +1720,20 @@ impl ShieldedStore for InMemoryShieldedStore { Ok(()) } + fn record_wallet_removal(&mut self, wallet_id: WalletId) -> Result<(), Self::Error> { + self.removal_tombstones.insert(wallet_id); + Ok(()) + } + + fn clear_wallet_removal(&mut self, wallet_id: WalletId) -> Result<(), Self::Error> { + self.removal_tombstones.remove(&wallet_id); + Ok(()) + } + + fn outstanding_wallet_removals(&self) -> Result, Self::Error> { + Ok(self.removal_tombstones.iter().copied().collect()) + } + fn reset_commitment_tree(&mut self) -> Result<(), Self::Error> { // Flat-list backing: clearing the commitment / mark / // checkpoint vectors drops `tree_size()` to 0 and makes the @@ -1151,6 +1745,207 @@ 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), + }); + let live_leases = self + .admissions + .iter() + .filter(|a| !a.destructive && a.overlaps(scope)) + .count(); + // Armed one-time-claim records hold the purge off on their own, + // independent of any lease — see the trait docs. + let armed_claim_records: usize = self + .subwallets + .iter() + .filter(|(id, _)| { + id.account_index == super::operations::ONE_TIME_CLAIM_RECORDS_ACCOUNT + && scope.is_none_or(|wallet_id| wallet_id == id.wallet_id) + }) + .map(|(_, state)| state.pending_redrives().len()) + .sum(); + Ok(live_leases + armed_claim_records) + } + + fn end_destructive_admission(&mut self, token: AdmissionToken) -> Result<(), Self::Error> { + self.admissions + .retain(|a| !a.destructive || a.token != token); + Ok(()) + } } #[cfg(test)] @@ -1370,6 +2165,7 @@ mod tests { nullifiers: vec![n1, n2], st_bytes: vec![1, 2, 3], attempts: 0, + identity_index: None, identity_nonce_finalized: false, identity_user_abandoned: false, }; @@ -1664,6 +2460,269 @@ mod tests { ); } + // ---- 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" + ); + } + + /// An ARMED one-time-claim record holds off a purge by itself, after its + /// lease has lapsed and with no renewal possible (#4313 review thread + /// r3839256998). Before the fix only live leases were counted, so a clock + /// jump or a suspension past `CLAIM_LEASE_MS` let `clear` / wallet removal + /// count zero and delete the only row holding a broadcast claim's padded + /// identity id. Clearing the record — a terminal outcome, or the durable + /// registration acknowledgement — is what releases the block. + #[test] + fn an_armed_claim_record_blocks_a_purge_after_its_lease_lapses() { + let mut store = InMemoryShieldedStore::new(); + let wallet = [0xAB; 32]; + let id = SubwalletId::new( + wallet, + super::super::operations::ONE_TIME_CLAIM_RECORDS_ACCOUNT, + ); + let token = claim_token(0x21); + let t0 = 1_000_000u64; + assert!(store + .begin_claim_admission(wallet, token, t0, CLAIM_LEASE_MS) + .unwrap()); + let record = PendingRedrive { + activity_id: [0xC2; 32], + anchor: [0x01; 32], + nullifiers: vec![[0x02; 32]], + st_bytes: vec![0x03; 8], + attempts: 0, + identity_index: Some(0), + identity_nonce_finalized: false, + identity_user_abandoned: false, + }; + assert!(store + .arm_redrive_under_claim(id, record.clone(), token, t0, CLAIM_LEASE_MS) + .unwrap()); + + // Far past the lease, which cannot be renewed back. + let later = t0 + 10 * CLAIM_LEASE_MS; + assert!(!store + .renew_claim_admission(token, later, CLAIM_LEASE_MS) + .unwrap()); + for scope in [None, Some(wallet)] { + let blocking = store + .begin_destructive_admission( + scope, + claim_token(0x90), + later, + DESTRUCTIVE_BARRIER_MS, + ) + .unwrap(); + store.end_destructive_admission(claim_token(0x90)).unwrap(); + assert_eq!( + blocking, 1, + "an armed, unresolved claim record must block a purge with no live lease \ + (scope {scope:?})" + ); + } + // Scoped like the purge: another wallet's removal is not blocked. + assert_eq!( + store + .begin_destructive_admission( + Some([0x01; 32]), + claim_token(0x91), + later, + DESTRUCTIVE_BARRIER_MS + ) + .unwrap(), + 0 + ); + + // The way out: the record leaves on a terminal outcome or an + // acknowledged registration, and the purge is admitted again. + store.clear_redrive(id, &record.activity_id).unwrap(); + assert_eq!( + store + .begin_destructive_admission(None, claim_token(0x92), later, DESTRUCTIVE_BARRIER_MS) + .unwrap(), + 0 + ); + } + + /// 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, + identity_nonce_finalized: false, + identity_user_abandoned: false, + }; + 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" + ); + } + #[test] fn account_purge_retains_wallet_wide_identity_guard_only() { let wallet_id = [0x81; 32]; @@ -1675,6 +2734,7 @@ mod tests { nullifiers: vec![], st_bytes: vec![0x33; 16], attempts: 7, + identity_index: None, identity_nonce_finalized: false, identity_user_abandoned: false, }; @@ -1684,6 +2744,7 @@ mod tests { nullifiers: vec![[0x66; 32]], st_bytes: vec![0x77; 16], attempts: 1, + identity_index: None, identity_nonce_finalized: false, identity_user_abandoned: false, }; diff --git a/packages/rs-platform-wallet/src/wallet/shielded/sync.rs b/packages/rs-platform-wallet/src/wallet/shielded/sync.rs index 7d4cb149951..590823b7401 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::notes_sync::types::ShieldedChunkBatch; use dash_sdk::platform::shielded::{ @@ -40,7 +40,7 @@ use tokio_util::sync::CancellationToken; 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; @@ -55,6 +55,25 @@ 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; /// In-flight chunk fetches per note-sync stream; tests mock this many chunks. const MAX_CONCURRENT_CHUNK_FETCHES: usize = 16; @@ -924,6 +943,486 @@ 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. +/// +/// Memory is bounded on BOTH axes (#4313 review finding d6b7be21f4a4). The +/// cache holds at most [`FOREIGN_SCAN_CHECKPOINT_CAP`] entries, and each +/// entry retains at most [`FOREIGN_SCAN_CHECKPOINT_NOTE_BUDGET`] notes — +/// [`ForeignScanCheckpointCache::save`] bounds an over-budget checkpoint to +/// the budget's highest-value notes while its `resume_position` advances +/// normally (see [`bound_retained_notes`] for why that cannot change a +/// claim's outcome, and #4313 review finding df7d9c0f41ab for why refusing +/// the save whole stalled budget-exhausted retries forever). The notes +/// ride an `Arc<[ShieldedNote]>` so [`ForeignScanCheckpointCache::load`] +/// hands back a shared handle instead of deep-copying the vector — the old +/// `Vec` clone transiently doubled an entry's memory while the cached +/// original stayed live. +#[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` — bounded to the + /// [`FOREIGN_SCAN_CHECKPOINT_NOTE_BUDGET`] highest-value ones when more + /// decrypted than the budget (see [`bound_retained_notes`]). Positions + /// at/above `resume_position` are re-derived on resume, so buffer-chunk + /// notes are never carried here (no duplicates on rescan). Shared (never + /// mutated in place): `load` clones the handle, not the notes. + notes: Arc<[ShieldedNote]>, +} + +/// 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 (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; + +/// At most this many notes retained per checkpoint (#4313 review finding +/// d6b7be21f4a4). A WELL-FORMED one-time invitation key funds 1–2 notes, but +/// nothing enforces that on chain: the invitation's Orchard address is +/// published to the invitee, so an untrusted inviter can fund it with +/// arbitrarily many small notes, and without this bound every one of them — +/// each ~200 bytes of decrypted, serialized note — would be retained across +/// budgeted retries. 16 gives legitimate multi-note funding an order of +/// magnitude of headroom while capping the whole cache at +/// [`FOREIGN_SCAN_CHECKPOINT_CAP`] × 16 notes (a few tens of KB). +/// +/// Exceeding it retains the budget's HIGHEST-VALUE notes and still advances +/// the resume position — see [`ForeignScanCheckpointCache::save`] and +/// [`bound_retained_notes`] for why dropping only the lowest-value notes +/// cannot fail a genuinely funded claim (one bundle spends at most the +/// structural action cap's worth of notes, which this budget covers, and +/// selection is largest-value-first). Refusing the over-budget save whole +/// (this constant's original enforcement) froze the resume position: every +/// budget-exhausted retry rescanned the same window and a funding note +/// beyond it became permanently unreachable (#4313 review finding +/// df7d9c0f41ab). Correctness never depends on a checkpoint existing — a +/// missing entry merely costs a rescan the per-attempt +/// [`FOREIGN_SCAN_BATCH_BUDGET`] keeps bounded. +const FOREIGN_SCAN_CHECKPOINT_NOTE_BUDGET: usize = 16; + +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 — and the clone is CHEAP: the notes are an `Arc<[ShieldedNote]>`, + /// so the caller shares the cached allocation instead of deep-copying it + /// (the old `Vec` clone transiently doubled the entry's memory, #4313 + /// review finding d6b7be21f4a4). + 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. + /// + /// Bounded: a checkpoint retaining more than + /// [`FOREIGN_SCAN_CHECKPOINT_NOTE_BUDGET`] notes keeps only the budget's + /// HIGHEST-VALUE notes (via [`bound_retained_notes`]) — and its + /// `resume_position` still advances, so budget-exhausted retries always + /// make progress. Refusing the save whole (the previous repair of + /// finding d6b7be21f4a4) capped memory but froze the position: with an + /// over-budget note set below the resume point, EVERY save was refused, + /// each retry reloaded the same old checkpoint and rescanned the same + /// window, and a funding note beyond that window became permanently + /// unreachable (#4313 review finding df7d9c0f41ab). See + /// [`FOREIGN_SCAN_CHECKPOINT_NOTE_BUDGET`] for why dropping only the + /// lowest-value notes cannot change a claim's outcome. + fn save(&self, key: [u8; 32], checkpoint: ForeignScanCheckpoint) { + let checkpoint = bound_retained_notes(checkpoint); + 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)); + } +} + +/// Bound a checkpoint's retained notes to the +/// [`FOREIGN_SCAN_CHECKPOINT_NOTE_BUDGET`] HIGHEST-VALUE ones, keeping the +/// resume position intact. A within-budget checkpoint passes through +/// untouched (sharing its allocation — the common path allocates nothing). +/// +/// Dropping only the lowest-value notes is CLAIM-LOSSLESS: note selection is +/// largest-value-first (`note_selection::select_notes`), and one claim spends +/// its notes inside a single transition whose action count is structurally +/// capped at `max_shielded_transition_actions` — which the budget covers +/// (pinned by `checkpoint_note_budget_covers_a_full_claim_bundle`), and each +/// action spends at most one note. The k highest-value notes therefore +/// dominate every subset a bundle could spend: any denomination coverable +/// from the full set is coverable from the retained set, so — unlike the +/// blind truncation the d6b7be21f4a4 round rejected — this bound can never +/// fail a genuinely funded claim. Value the dropped dust represented is +/// value no single bundle could have spent anyway. +/// +/// Ties break toward the LOWER position, making the retained set a +/// deterministic function of the full set under a total order — so bounding +/// is merge-exact across passes: re-bounding a previously bounded set plus +/// new finds yields exactly what bounding the full set once would have. +fn bound_retained_notes(checkpoint: ForeignScanCheckpoint) -> ForeignScanCheckpoint { + if checkpoint.notes.len() <= FOREIGN_SCAN_CHECKPOINT_NOTE_BUDGET { + return checkpoint; + } + let mut notes: Vec = checkpoint.notes.iter().cloned().collect(); + notes.sort_by(|a, b| b.value.cmp(&a.value).then(a.position.cmp(&b.position))); + notes.truncate(FOREIGN_SCAN_CHECKPOINT_NOTE_BUDGET); + // Back to position order: carried notes are position-filtered against the + // aligned resume start on load, and the scan's returned set stays in + // discovery order. + notes.sort_by_key(|n| n.position); + ForeignScanCheckpoint { + resume_position: checkpoint.resume_position, + notes: notes.into(), + } +} + +/// 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; + + 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. +/// +/// `carried` is the note slice inherited from the loaded checkpoint (every +/// position strictly below the pass's aligned start, hence below +/// `scanned_through`); `new_found` is what THIS pass decrypted. When the pass +/// found nothing on an immutable chunk the carried `Arc` is reused as-is — +/// no allocation, and the cache keeps sharing the same slice the scan read. +fn foreign_scan_checkpoint_below( + scanned_through: u64, + carried: &Arc<[ShieldedNote]>, + new_found: &[ShieldedNote], +) -> ForeignScanCheckpoint { + let mut new_below = new_found + .iter() + .filter(|n| n.position < scanned_through) + .peekable(); + let notes: Arc<[ShieldedNote]> = if new_below.peek().is_none() { + Arc::clone(carried) + } else { + carried.iter().chain(new_below).cloned().collect() + }; + ForeignScanCheckpoint { + resume_position: scanned_through, + notes, + } +} + +/// 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. +/// +/// Retained MEMORY is bounded independently of the work bound +/// ([`FOREIGN_SCAN_CHECKPOINT_NOTE_BUDGET`], #4313 review finding +/// d6b7be21f4a4): an inviter who floods the published invitation address +/// with many small notes cannot pin them all in the checkpoint — the +/// checkpoint keeps the budget's highest-value notes (a set that dominates +/// anything one claim bundle could spend — see [`bound_retained_notes`]) +/// while resume progress still compounds across retries (#4313 review +/// finding df7d9c0f41ab). +/// +/// [`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 (carried, resume_position) = match checkpoints.load(&checkpoint_key) { + Some(cp) => (cp.notes, cp.resume_position), + None => (Arc::from(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, so the filtered copy is + // only ever materialized on that impossible path; the ordinary path keeps + // sharing the cache's own allocation). + let aligned_start = (resume_position / CHUNK_SIZE) * CHUNK_SIZE; + let carried: Arc<[ShieldedNote]> = if carried.iter().any(|n| n.position >= aligned_start) { + carried + .iter() + .filter(|n| n.position < aligned_start) + .cloned() + .collect() + } else { + carried + }; + let total: u64 = carried + .iter() + .fold(0u64, |acc, n| acc.saturating_add(n.value)); + + if aligned_start > 0 { + debug!( + aligned_start, + checkpointed_notes = carried.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 && !carried.is_empty() { + checkpoints.save( + checkpoint_key, + foreign_scan_checkpoint_below(aligned_start, &carried, &[]), + ); + return Ok(carried.to_vec()); + } + + 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, + carried, + 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. +/// +/// `carried` is the (shared, read-only) note slice from the loaded +/// checkpoint; this pass's own finds accumulate in a separate vector and the +/// two are only combined when a checkpoint is saved or the scan completes. +/// Every save is subject to the retained-note budget — see +/// [`ForeignScanCheckpointCache::save`]: an over-budget save is bounded to +/// the highest-value notes with its position intact, so retries always +/// resume past the window this attempt covered. +#[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, + carried: Arc<[ShieldedNote]>, + 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; + let mut new_found: Vec = Vec::new(); + 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, &carried, &new_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(); + new_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, &carried, &new_found), + ); + return Err(PlatformWalletError::ShieldedForeignScanBudgetExhausted { + scanned_through, + }); + } + } + + checkpoints.save( + checkpoint_key, + foreign_scan_checkpoint_below(scanned_through, &carried, &new_found), + ); + let mut found = carried.to_vec(); + found.append(&mut new_found); + Ok(found) +} + /// One decrypted note discovered during a sync pass. #[derive(Clone)] struct DiscoveredNote { @@ -1159,6 +1658,305 @@ 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. Carried + /// notes (inherited from the loaded checkpoint, always below the pass's + /// start) pass through ahead of this pass's finds. + #[test] + fn foreign_scan_checkpoint_below_drops_buffer_chunk_notes() { + let carried: std::sync::Arc<[super::ShieldedNote]> = vec![note_at(5, 100)].into(); + let new_found = vec![note_at(2047, 200), note_at(2048, 300)]; + + let cp = super::foreign_scan_checkpoint_below(2048, &carried, &new_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" + ); + } + + /// When a pass finds nothing new below the resume point, the built + /// checkpoint reuses the carried allocation instead of copying it — the + /// repeated no-hit rescans of an unfunded key allocate nothing per save. + #[test] + fn foreign_scan_checkpoint_below_reuses_the_carried_allocation_when_nothing_is_new() { + let carried: std::sync::Arc<[super::ShieldedNote]> = + vec![note_at(5, 100), note_at(7, 200)].into(); + // The only new note sits AT the resume position (buffer chunk) and is + // dropped, so nothing new survives the filter. + let new_found = vec![note_at(4096, 300)]; + + let cp = super::foreign_scan_checkpoint_below(4096, &carried, &new_found); + + assert_eq!(cp.resume_position, 4096); + assert!( + std::sync::Arc::ptr_eq(&cp.notes, &carried), + "no new below-resume note: the checkpoint must share the carried \ + allocation, not deep-copy it" + ); + } + + /// 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)].into(), + }; + + // 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" + ); + } + } + + /// The retained-note budget (#4313 review findings d6b7be21f4a4 + + /// df7d9c0f41ab): an untrusted inviter controls how many notes decrypt + /// under a published invitation key, so a checkpoint whose note set + /// exceeds [`super::FOREIGN_SCAN_CHECKPOINT_NOTE_BUDGET`] is bounded to + /// the BUDGET HIGHEST-VALUE notes — while its resume position advances + /// normally. (The earlier refuse-whole behavior capped memory but froze + /// the resume position: with an over-budget note set below the resume + /// point, every save was refused and budget-exhausted retries rescanned + /// the same window forever.) Dropping only the LOWEST-value notes is + /// claim-lossless: selection is largest-value-first and one claim bundle + /// can spend at most `max_shielded_transition_actions` (≤ the budget) + /// notes, so the retained set dominates every selectable subset. + #[test] + fn an_over_budget_save_retains_the_highest_value_notes_and_advances() { + let budget = super::FOREIGN_SCAN_CHECKPOINT_NOTE_BUDGET; + let cache = super::ForeignScanCheckpointCache::default(); + let key = [0xAA; 32]; + + // AT the budget: retained in full, untouched. + let at_budget: std::sync::Arc<[super::ShieldedNote]> = + (0..budget as u64).map(|i| note_at(i, i + 1)).collect(); + cache.save( + key, + super::ForeignScanCheckpoint { + resume_position: 2048, + notes: at_budget, + }, + ); + let bounded = cache.load(&key).expect("a within-budget save is retained"); + assert_eq!(bounded.notes.len(), budget); + + // OVER the budget (distinct values 1..=budget+4): the save keeps the + // budget highest-value notes, drops the dust, and ADVANCES the entry. + let over: std::sync::Arc<[super::ShieldedNote]> = + (0..budget as u64 + 4).map(|i| note_at(i, i + 1)).collect(); + cache.save( + key, + super::ForeignScanCheckpoint { + resume_position: 4096, + notes: over, + }, + ); + let after = cache.load(&key).expect("the bounded save must be stored"); + assert_eq!( + after.resume_position, 4096, + "a bounded save must advance the resume position" + ); + assert_eq!( + after.notes.len(), + budget, + "no cached entry may ever exceed the retained-note budget" + ); + assert!( + after.notes.iter().all(|n| n.value >= 5), + "the retained notes must be the highest-value ones (values 1..=4 dropped)" + ); + let positions: Vec = after.notes.iter().map(|n| n.position).collect(); + let mut sorted = positions.clone(); + sorted.sort_unstable(); + assert_eq!( + positions, sorted, + "the bounded set stays in position order for the resume-chain filter" + ); + + // EQUAL values: the tie breaks deterministically toward the LOWER + // position, so re-bounding a bounded set plus new finds equals + // bounding the full set once (iterative top-k is merge-exact under + // a total order). + let ties = [0xBB; 32]; + let tied: std::sync::Arc<[super::ShieldedNote]> = + (0..budget as u64 + 1).map(|i| note_at(i, 7)).collect(); + cache.save( + ties, + super::ForeignScanCheckpoint { + resume_position: 2048, + notes: tied, + }, + ); + let tied_after = cache.load(&ties).expect("bounded tie save is stored"); + assert_eq!(tied_after.notes.len(), budget); + assert!( + tied_after.notes.iter().all(|n| n.position < budget as u64), + "on equal value the lower position wins deterministically" + ); + + // Monotonicity is unchanged: a stale over-budget writer cannot + // rewind an advanced entry. + let stale: std::sync::Arc<[super::ShieldedNote]> = + (0..budget as u64 + 4).map(|i| note_at(i, i + 1)).collect(); + cache.save( + key, + super::ForeignScanCheckpoint { + resume_position: 2048, + notes: stale, + }, + ); + assert_eq!( + cache.load(&key).expect("entry survives").resume_position, + 4096, + "an older resume position must never replace a newer one" + ); + } + + /// The dominance argument behind bounded retention requires the budget + /// to cover every note one claim bundle could spend: a single + /// transition holds at most `max_shielded_transition_actions` actions, + /// and each action spends at most one note. If a version bump ever + /// raises that cap past the budget, retention could drop a note a + /// wider bundle could have spent — this pin forces the budget to be + /// revisited together with the cap. + #[test] + fn checkpoint_note_budget_covers_a_full_claim_bundle() { + let latest = dpp::version::PlatformVersion::latest(); + assert!( + super::FOREIGN_SCAN_CHECKPOINT_NOTE_BUDGET + >= latest.system_limits.max_shielded_transition_actions as usize, + "FOREIGN_SCAN_CHECKPOINT_NOTE_BUDGET ({}) must cover the structural \ + action cap ({}) or bounded retention could forget a spendable note", + super::FOREIGN_SCAN_CHECKPOINT_NOTE_BUDGET, + latest.system_limits.max_shielded_transition_actions + ); + } + + /// `load` must hand back a handle onto the CACHED allocation, not a deep + /// copy of it — the old `Vec` clone transiently doubled an entry's + /// memory while the cached original stayed live (#4313 review finding + /// d6b7be21f4a4). Two loads sharing one allocation pins the + /// representation. + #[test] + fn load_shares_the_cached_notes_allocation() { + let cache = super::ForeignScanCheckpointCache::default(); + let key = [0xCC; 32]; + cache.save( + key, + super::ForeignScanCheckpoint { + resume_position: 2048, + notes: vec![note_at(1, 42), note_at(2, 43)].into(), + }, + ); + + let first = cache.load(&key).expect("saved checkpoint"); + let second = cache.load(&key).expect("entry survives loads"); + assert!( + std::sync::Arc::ptr_eq(&first.notes, &second.notes), + "load must share the cached note allocation, not deep-copy it" + ); + } + + /// 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)].into(), + }, + ); + + 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 @@ -1174,6 +1972,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..2fdbbcf6f2f --- /dev/null +++ b/packages/rs-platform-wallet/src/wallet/shielded/sync/foreign_scan_budget_tests.rs @@ -0,0 +1,438 @@ +//! 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 std::sync::Arc; + +use dash_sdk::platform::shielded::notes_sync::types::{DecryptedNote, ShieldedChunkBatch}; +use dashcore::Network; +use drive_proof_verifier::types::ShieldedEncryptedNote; +use futures::stream; +use grovedb_commitment_tree::{ExtractedNoteCommitment, Note, NoteValue, RandomSeed, Rho}; + +use super::{ + foreign_scan_checkpoint_key, scan_foreign_stream_with_budget, ForeignScanCheckpointCache, + CHUNK_SIZE, FOREIGN_SCAN_CHECKPOINT_NOTE_BUDGET, +}; +use crate::error::PlatformWalletError; +use crate::wallet::shielded::keys::OrchardKeySet; + +/// Any deterministic keyset works — the FVK derives nullifiers for decrypted +/// notes, and the default address is the recipient the note builder uses. +fn keyset() -> OrchardKeySet { + OrchardKeySet::from_seed(&[0x42; 32], Network::Testnet, 0) + .expect("ZIP-32 derivation from a fixed seed should succeed") +} + +fn fvk() -> grovedb_commitment_tree::FullViewingKey { + keyset().full_viewing_key +} + +/// Build a REAL decrypted note at tree `position` worth `value` credits, to +/// the keyset's default address — the exact shape the production stream's +/// trial decryption yields, so the scan's nullifier/serialization path runs +/// on it unmodified. Canonical `rho`/`rseed` are found by deterministic +/// rejection sampling seeded from `position` (distinct positions get +/// distinct note material). +fn decrypted_note_at(keys: &OrchardKeySet, position: u64, value: u64) -> DecryptedNote { + let rho = (0u64..u64::MAX) + .find_map(|attempt| { + let mut b = [0u8; 32]; + b[0..8].copy_from_slice(&position.to_le_bytes()); + b[8..16].copy_from_slice(&attempt.to_le_bytes()); + Rho::from_bytes(&b).into_option() + }) + .expect("a canonical rho exists"); + let rseed = (0u64..u64::MAX) + .find_map(|attempt| { + let mut b = [0u8; 32]; + b[0..8].copy_from_slice(&attempt.to_le_bytes()); + b[16..24].copy_from_slice(&position.to_le_bytes()); + RandomSeed::from_bytes(b, &rho).into_option() + }) + .expect("a canonical rseed exists"); + let note = Note::from_parts(keys.default_address, NoteValue::from_raw(value), rho, rseed) + .into_option() + .expect("valid note parts"); + DecryptedNote { + position, + nullifier: note.nullifier(&keys.full_viewing_key).to_bytes(), + cmx: ExtractedNoteCommitment::from(note.commitment()).to_bytes(), + address: keys.default_address, + note, + } +} + +/// 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 { + full_batch_with(start_index, Vec::new()) +} + +/// A FULL chunk batch at `start_index` whose trial decryption yielded +/// `decrypted` (positions must lie inside the chunk). +fn full_batch_with(start_index: u64, decrypted: Vec) -> ShieldedChunkBatch { + ShieldedChunkBatch { + start_index, + notes: (0..CHUNK_SIZE).map(|_| wire_note()).collect(), + decrypted, + 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, + Arc::from(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, + Arc::from(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, + Arc::from(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, + Arc::from(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" + ); +} + +/// The #4313 memory bound (review finding d6b7be21f4a4): the per-attempt +/// batch budget bounds WORK, but an untrusted inviter controls how many +/// notes decrypt under a published invitation key, and every note found +/// below the resume position used to be RETAINED in the checkpoint across +/// budgeted retries. A many-note funding must not pin unbounded memory — +/// AND must not stall progress (review finding df7d9c0f41ab): whatever the +/// cache holds for the key after a flood stays within +/// [`FOREIGN_SCAN_CHECKPOINT_NOTE_BUDGET`], while the resume position still +/// records exactly how far the paused scan got. +#[tokio::test] +async fn a_note_flood_cannot_pin_unbounded_checkpoint_memory() { + let keys = keyset(); + let checkpoints = ForeignScanCheckpointCache::default(); + let key = foreign_scan_checkpoint_key(&keys.full_viewing_key); + + // A chunk carrying more dust notes than the retained-note budget, whose + // values never cover the requested amount, followed by enough chunks to + // trip the batch budget — the compounding-retry shape a malicious + // inviter forces. + let flood = FOREIGN_SCAN_CHECKPOINT_NOTE_BUDGET + 4; + let flood_notes: Vec = (0..flood) + .map(|i| decrypted_note_at(&keys, i as u64, 1)) + .collect(); + let batches: Vec = vec![ + Ok(full_batch_with(0, flood_notes)), + Ok(full_batch(CHUNK_SIZE)), + Ok(full_batch(2 * CHUNK_SIZE)), + ]; + let err = scan_foreign_stream_with_budget( + stream::iter(batches), + &checkpoints, + key, + &keys.full_viewing_key, + u64::MAX, // never covered — the scan runs to its batch budget + 0, + Arc::from(Vec::new()), + 0, + 2, + ) + .await + .expect_err("the budget must pause the scan before the value is covered"); + assert!(matches!( + err, + PlatformWalletError::ShieldedForeignScanBudgetExhausted { .. } + )); + + // THE BOUND — and the PROGRESS. Pre-fix, the pause checkpointed every + // flood note; the first repair refused the over-budget checkpoint whole, + // which capped memory but froze the resume position (every retry + // rescanned the same window forever). The save now retains the + // highest-value notes within the budget AND advances the position. + let cp = checkpoints + .load(&key) + .expect("a flooded pause must still checkpoint its progress"); + assert!( + cp.notes.len() <= FOREIGN_SCAN_CHECKPOINT_NOTE_BUDGET, + "a note flood must not be retained past the budget (got {})", + cp.notes.len() + ); + assert_eq!( + cp.resume_position, + 2 * CHUNK_SIZE, + "the flooded checkpoint must record the coverage the pause reached" + ); +} + +/// THE round-5 liveness guarantee (review finding df7d9c0f41ab): more dust +/// notes than the retained-note budget, followed by a funding note DEEPER +/// than the first attempt's batch-budget window. Refusing the over-budget +/// checkpoint whole (the first repair of d6b7be21f4a4) froze the resume +/// position here: every retry reloaded the same old checkpoint, rediscovered +/// the same over-budget prefix, had its save refused again, and exhausted +/// the same window — a genuinely funded invitation became permanently +/// unclaimable. The bounded save must instead ADVANCE the position while +/// retaining the highest-value notes, so attempts compound toward the deep +/// funding note and the claim completes. +#[tokio::test] +async fn a_note_flood_beyond_the_first_window_still_reaches_a_deep_funding_note() { + let keys = keyset(); + let checkpoints = ForeignScanCheckpointCache::default(); + let key = foreign_scan_checkpoint_key(&keys.full_viewing_key); + + // Dust with DISTINCT values 1..=20 (budget + 4): the retained set is + // then observable — the 16 most valuable notes (values 5..=20, summing + // to 200) survive, values 1..=4 are dropped. + let flood = FOREIGN_SCAN_CHECKPOINT_NOTE_BUDGET + 4; + let flood_notes: Vec = (0..flood) + .map(|i| decrypted_note_at(&keys, i as u64, i as u64 + 1)) + .collect(); + + // Attempt 1: the flood chunk, then a budget pause two chunks in. The + // funding note lies beyond this window. + let batches: Vec = vec![ + Ok(full_batch_with(0, flood_notes)), + Ok(full_batch(CHUNK_SIZE)), + Ok(full_batch(2 * CHUNK_SIZE)), + ]; + scan_foreign_stream_with_budget( + stream::iter(batches), + &checkpoints, + key, + &keys.full_viewing_key, + u64::MAX, + 0, + Arc::from(Vec::new()), + 0, + 2, + ) + .await + .expect_err("attempt 1 pauses at its batch budget"); + + // MONOTONIC PROGRESS: the flooded pause must have advanced the + // checkpoint past the chunks it covered — this is exactly what the + // refuse-whole behavior failed to do. + let cp = checkpoints + .load(&key) + .expect("the flooded pause must leave a resumable checkpoint"); + assert_eq!( + cp.resume_position, + 2 * CHUNK_SIZE, + "attempt 2 must resume past attempt 1's window, not repeat it" + ); + assert_eq!( + cp.notes.len(), + FOREIGN_SCAN_CHECKPOINT_NOTE_BUDGET, + "the checkpoint retains exactly the budget's worth of notes" + ); + assert!( + cp.notes.iter().all(|n| n.value >= 5), + "the retained notes must be the HIGHEST-value ones (dust 1..=4 dropped)" + ); + + // Attempt 2 — as the production caller would run it: resume from the + // checkpoint with its carried notes, and find the deep funding note in + // the first fresh chunk. The stop value (450) needs the funding note + // AND most of the carried value (200 + 300 = 500 ≥ 450), so a claim + // that forgot the retained notes — or never reached the funding note — + // fails the total. + let carried = cp.notes; + let carried_total: u64 = carried.iter().map(|n| n.value).sum(); + assert_eq!(carried_total, 200, "values 5..=20 sum to 200"); + let resume = cp.resume_position; + let stop_at_value = 450; + let batches2: Vec = vec![ + Ok(full_batch_with( + resume, + vec![decrypted_note_at(&keys, resume + 3, 300)], + )), + Ok(partial_batch(resume + CHUNK_SIZE)), + ]; + let found = scan_foreign_stream_with_budget( + stream::iter(batches2), + &checkpoints, + key, + &keys.full_viewing_key, + stop_at_value, + resume, + carried, + carried_total, + 8, + ) + .await + .expect("the resumed attempt completes the claim the pause deferred"); + + assert_eq!( + found.len(), + FOREIGN_SCAN_CHECKPOINT_NOTE_BUDGET + 1, + "the retained notes plus the funding note must all be selectable" + ); + let total: u64 = found.iter().map(|n| n.value).sum(); + assert_eq!( + total, 500, + "retained value (200) plus the funding note (300) — nothing forgotten" + ); + assert!( + found + .iter() + .any(|n| n.position == resume + 3 && n.value == 300), + "the deep funding note itself must be present" + ); +} 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 fb1d107e6fa..8f2b436abfd 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 @@ -526,6 +526,7 @@ async fn bind_persists_viewing_keys_and_restart_rebinds_seedlessly() { nullifiers: vec![[2; 32]], st_bytes: vec![9; 32], attempts: 0, + identity_index: None, identity_nonce_finalized: false, identity_user_abandoned: false, }, @@ -655,9 +656,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 @@ -1078,6 +1084,7 @@ async fn should_keep_wallet_and_coordinator_keys_when_guarded_registration_is_re nullifiers: vec![], st_bytes: vec![3; 64], attempts: 0, + identity_index: None, identity_nonce_finalized: false, identity_user_abandoned: false, }, @@ -1100,3 +1107,193 @@ async fn should_keep_wallet_and_coordinator_keys_when_guarded_registration_is_re 1 ); } + +/// The one-time-claim registration handoff releases the claim's recovery +/// record only once the identity's registration is DURABLE (#4313 review, +/// `platform_wallet.rs` registration tail). +mod one_time_claim_registration_tests { + use super::*; + use crate::wallet::shielded::operations::{ + OneTimeClaimRecoveryRecord, ONE_TIME_CLAIM_RECORDS_ACCOUNT, + }; + use dpp::identity::v0::IdentityV0; + use dpp::prelude::Identifier; + use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; + + /// A buffered persister (`store_commits_inline() == false`) whose `store` + /// and `flush` can each be made to fail, counting every call. + #[derive(Default)] + struct BufferedPersistence { + fail_next_store: AtomicBool, + fail_flush: AtomicBool, + stores: AtomicUsize, + flushes: AtomicUsize, + } + + impl PlatformWalletPersistence for BufferedPersistence { + fn store( + &self, + _wallet_id: WalletId, + _changeset: PlatformWalletChangeSet, + ) -> Result<(), PersistenceError> { + self.stores.fetch_add(1, Ordering::SeqCst); + if self.fail_next_store.swap(false, Ordering::SeqCst) { + return Err(PersistenceError::backend("injected store failure")); + } + Ok(()) + } + + fn flush(&self, _wallet_id: WalletId) -> Result<(), PersistenceError> { + self.flushes.fetch_add(1, Ordering::SeqCst); + if self.fail_flush.load(Ordering::SeqCst) { + return Err(PersistenceError::backend("injected flush failure")); + } + Ok(()) + } + + fn load(&self) -> Result { + Ok(ClientStartState::default()) + } + } + + const CLAIM_KEY: [u8; 32] = [0xC7; 32]; + + fn claimed_identity() -> dpp::identity::Identity { + dpp::identity::Identity::V0(IdentityV0 { + id: Identifier::from([0xD1; 32]), + public_keys: BTreeMap::new(), + balance: 0, + revision: 0, + }) + } + + /// Arm the record a successful claim leaves behind, and return its receipt. + async fn arm_retained_record( + coordinator: &NetworkShieldedCoordinator, + wallet_id: WalletId, + ) -> OneTimeClaimRecoveryRecord { + coordinator + .store() + .write() + .await + .arm_redrive( + SubwalletId::new(wallet_id, ONE_TIME_CLAIM_RECORDS_ACCOUNT), + PendingRedrive { + activity_id: CLAIM_KEY, + anchor: [0x01; 32], + nullifiers: vec![[0x02; 32]], + st_bytes: vec![0x03; 16], + attempts: 0, + identity_index: Some(0), + identity_nonce_finalized: false, + identity_user_abandoned: false, + }, + ) + .expect("arm the retained record"); + OneTimeClaimRecoveryRecord::for_tests(wallet_id, CLAIM_KEY) + } + + async fn record_retained( + coordinator: &NetworkShieldedCoordinator, + wallet_id: WalletId, + ) -> bool { + !coordinator + .store() + .read() + .await + .pending_redrives(SubwalletId::new(wallet_id, ONE_TIME_CLAIM_RECORDS_ACCOUNT)) + .expect("records") + .is_empty() + } + + /// RED before the fix: a successful `store` released the record, though a + /// buffered backend may hold the identity only in memory until `flush`. + /// A flush that fails must keep the record; a crash would otherwise lose + /// both the identity and the only copy of its padded id. + #[tokio::test] + async fn an_unflushed_registration_keeps_the_recovery_record() { + let persister = Arc::new(BufferedPersistence::default()); + persister.fail_flush.store(true, Ordering::SeqCst); + let wallet = platform_wallet_with(Arc::clone(&persister)).await; + let coordinator = coordinator_at(&temp_dir("claim_reg_unflushed")); + let receipt = arm_retained_record(&coordinator, wallet.wallet_id()).await; + + wallet + .register_one_time_claim_identity(&coordinator, claimed_identity(), 0, Some(receipt)) + .await; + + assert_eq!(persister.stores.load(Ordering::SeqCst), 1); + assert_eq!( + persister.flushes.load(Ordering::SeqCst), + 1, + "a buffered backend must be flushed before the record is released" + ); + assert!( + record_retained(&coordinator, wallet.wallet_id()).await, + "an identity that never left the persister's buffer must not release its record" + ); + + // Once the flush succeeds (a retry of the same claim), the record goes. + persister.fail_flush.store(false, Ordering::SeqCst); + wallet + .register_one_time_claim_identity(&coordinator, claimed_identity(), 0, Some(receipt)) + .await; + assert!(!record_retained(&coordinator, wallet.wallet_id()).await); + } + + /// RED before the fix: a `store` that failed AFTER the in-memory insert + /// left the identity in the manager, so the same-session retry hit + /// `IdentityAlreadyExists`, never retried persistence, and the record + /// could never be released. The retry must re-persist the existing + /// registration and then release the record. + #[tokio::test] + async fn a_retry_after_a_failed_store_repersists_and_releases_the_record() { + let persister = Arc::new(BufferedPersistence::default()); + persister.fail_next_store.store(true, Ordering::SeqCst); + let wallet = platform_wallet_with(Arc::clone(&persister)).await; + let coordinator = coordinator_at(&temp_dir("claim_reg_retry")); + let receipt = arm_retained_record(&coordinator, wallet.wallet_id()).await; + + wallet + .register_one_time_claim_identity(&coordinator, claimed_identity(), 0, Some(receipt)) + .await; + assert!( + record_retained(&coordinator, wallet.wallet_id()).await, + "a rejected store must keep the record" + ); + assert_eq!(persister.flushes.load(Ordering::SeqCst), 0); + + wallet + .register_one_time_claim_identity(&coordinator, claimed_identity(), 0, Some(receipt)) + .await; + assert_eq!( + persister.stores.load(Ordering::SeqCst), + 2, + "the retry must hand the already-inserted registration to the persister again" + ); + assert_eq!(persister.flushes.load(Ordering::SeqCst), 1); + assert!( + !record_retained(&coordinator, wallet.wallet_id()).await, + "a durable retry releases the record" + ); + } + + /// The idempotent re-persist is only for a repeat of THIS registration: an + /// identity already registered at a different slot is a real conflict and + /// must keep the record. + #[tokio::test] + async fn a_conflicting_slot_keeps_the_recovery_record() { + let persister = Arc::new(BufferedPersistence::default()); + let wallet = platform_wallet_with(Arc::clone(&persister)).await; + let coordinator = coordinator_at(&temp_dir("claim_reg_conflict")); + let receipt = arm_retained_record(&coordinator, wallet.wallet_id()).await; + + wallet + .register_one_time_claim_identity(&coordinator, claimed_identity(), 0, None) + .await; + wallet + .register_one_time_claim_identity(&coordinator, claimed_identity(), 5, Some(receipt)) + .await; + assert!(record_retained(&coordinator, wallet.wallet_id()).await); + } +} diff --git a/packages/rs-unified-sdk-jni/src/funding.rs b/packages/rs-unified-sdk-jni/src/funding.rs index 1c7bff45acc..b1da6794e5d 100644 --- a/packages/rs-unified-sdk-jni/src/funding.rs +++ b/packages/rs-unified-sdk-jni/src/funding.rs @@ -42,7 +42,7 @@ #![allow(clippy::missing_safety_doc)] #![cfg(feature = "shielded")] -use crate::pubkey_rows::decode_registration_pubkeys_blob; +use crate::pubkey_rows::{decode_registration_pubkeys_blob, decode_structural_pubkeys_blob}; use crate::support::{guard, take_pwffi_error, throw_sdk_exception, JVM}; use jni::objects::{GlobalRef, JByteArray, JClass, JObject, JString, JValue}; use jni::sys::{jboolean, jint, jlong, jobjectArray, JNI_FALSE, JNI_TRUE}; @@ -160,19 +160,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, "recipientRaw43 byte[] was 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, &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; } }; @@ -180,7 +218,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; } @@ -191,7 +229,9 @@ fn read_recipient43(env: &mut JNIEnv, arr: &JByteArray) -> Option<[u8; 43]> { /// Read an OPTIONAL byte array (e.g. the 21-byte surplus platform address). /// JVM null → `Ok(None)`. Returns `Err(())` (after throwing) on a JNI error. -fn read_opt_bytes(env: &mut JNIEnv, arr: &JByteArray) -> Result>, ()> { +/// `field` names the caller's parameter in the exception message (mirrors +/// `read_id32`). +fn read_opt_bytes(env: &mut JNIEnv, arr: &JByteArray, field: &str) -> Result>, ()> { if arr.is_null() { return Ok(None); } @@ -199,7 +239,7 @@ fn read_opt_bytes(env: &mut JNIEnv, arr: &JByteArray) -> Result>, Ok(b) => Ok(Some(b)), Err(_) => { let _ = env.exception_clear(); - throw_sdk_exception(env, 1, "surplusOutput byte[] was invalid"); + throw_sdk_exception(env, 1, &format!("{field} byte[] was invalid")); Err(()) } } @@ -354,10 +394,10 @@ 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) { + let surplus = match read_opt_bytes(env, &surplus_output, "surplusOutput") { Ok(v) => v, Err(()) => return, }; @@ -427,7 +467,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 result = unsafe { @@ -479,10 +519,10 @@ 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) { + let surplus = match read_opt_bytes(env, &surplus_output, "surplusOutput") { Ok(v) => v, Err(()) => return, }; @@ -964,7 +1004,7 @@ pub extern "system" fn Java_org_dashfoundation_dashsdk_ffi_FundingNative_shielde // 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) { + let fallback = match read_opt_bytes(env, &fallback_address, "fallbackAddress") { Ok(Some(v)) => v, Ok(None) => { throw_sdk_exception(env, 1, "fallbackAddress must not be null"); @@ -1039,6 +1079,238 @@ 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(); + } + // Sign check only: the Rust operation owns the denomination rules. + if denomination < 0 { + throw_sdk_exception(env, 1, "denomination must be non-negative"); + 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(); + }; + + // Structural decode only: the claim operation owns the key-count and + // key-role rules. + let Some(decoded) = decode_structural_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, "fallbackAddress") { + 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`. /// @@ -1076,7 +1348,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/rs-unified-sdk-jni/src/pubkey_rows.rs b/packages/rs-unified-sdk-jni/src/pubkey_rows.rs index 4079715a9af..9b70ea8d258 100644 --- a/packages/rs-unified-sdk-jni/src/pubkey_rows.rs +++ b/packages/rs-unified-sdk-jni/src/pubkey_rows.rs @@ -356,10 +356,11 @@ pub(crate) fn check_registration_invariants(rows: &[DecodedPubkeyRow]) -> Result Ok(()) } -/// Decode an identity-CREATE registration pubkeys BLOB (all four registration -/// JNI exports): the shared structural parse plus -/// [`check_registration_invariants`]. Throws + returns `None` on any violation. -pub(crate) fn decode_registration_pubkeys_blob( +/// Decode a pubkeys BLOB structurally only: a null blob or a malformed row +/// throws, but no key-count or key-role rule is applied. For entry points +/// whose Rust operation owns those rules (the one-time-key invitation claim). +/// Throws + returns `None` on a violation. +pub(crate) fn decode_structural_pubkeys_blob( env: &mut JNIEnv, arr: &JByteArray, ) -> Option> { @@ -367,13 +368,23 @@ pub(crate) fn decode_registration_pubkeys_blob( throw_sdk_exception(env, 1, "pubkeysBlob was null/invalid"); return None; }; - let rows = match parse_pubkey_rows(&bytes) { - Ok(rows) => rows, + match parse_pubkey_rows(&bytes) { + Ok(rows) => Some(rows), Err(msg) => { throw_sdk_exception(env, 1, &msg); - return None; + None } - }; + } +} + +/// Decode an identity-CREATE registration pubkeys BLOB (the registration JNI +/// exports): [`decode_structural_pubkeys_blob`] plus +/// [`check_registration_invariants`]. Throws + returns `None` on any violation. +pub(crate) fn decode_registration_pubkeys_blob( + env: &mut JNIEnv, + arr: &JByteArray, +) -> Option> { + let rows = decode_structural_pubkeys_blob(env, arr)?; if let Err(msg) = check_registration_invariants(&rows) { throw_sdk_exception(env, 1, &msg); return None; diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManager.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManager.swift index 63b20689d2c..b3649f51f31 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManager.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManager.swift @@ -2691,22 +2691,12 @@ public class PlatformWalletManager: ObservableObject { let identityIds = try persistenceHandler.identityIdsForWallet(walletId: walletId) - // Wipe Keychain BEFORE the SwiftData identity deletion runs. - // Order matters for retry-safety: if `deleteWalletData` - // commits identity rows and then throws partway, a retry - // would see `identityIdsForWallet == []` and the - // `deleteAllKeychainItems(forIdentityId:)` sweep below - // could no longer find the keys to purge. Doing the - // keychain side first leaves at worst stale SwiftData - // rows on a retry — repeating the wipe is harmless, and - // every keychain call here is idempotent (no-op on "not - // found"). Mnemonic / metadata stay in `WalletStorage` - // for now so a retry can still derive any missed key. - for identityId in identityIds { - try KeychainManager.shared.deleteAllKeychainItems(forIdentityId: identityId) - } - try KeychainManager.shared.deleteAllIdentityPrivateKeys(forWalletId: walletId) - + // Native removal FIRST. It can refuse before changing anything (an + // unresolved shielded invitation claim, or a store error taking + // destructive admission), and a refused removal must leave the + // wallet fully usable — so no signing keys may be gone yet + // (dashpay/platform#4313 review). Removing an already-removed + // wallet succeeds, so a retry after a later failure gets past here. try walletId.withUnsafeBytes { raw in guard let base = raw.baseAddress?.assumingMemoryBound(to: FFIByteTuple32.self) else { throw PlatformWalletError.nullPointer( @@ -2727,6 +2717,22 @@ public class PlatformWalletManager: ObservableObject { coreTxoReconcileEpoch.bump() coreTxoReconcileLastRunAt.removeValue(forKey: walletId) + // Wipe Keychain AFTER the native removal succeeded and BEFORE the + // SwiftData identity deletion runs. The second half matters for + // retry-safety: if `deleteWalletData` commits identity rows and + // then throws partway, a retry would see + // `identityIdsForWallet == []` and the + // `deleteAllKeychainItems(forIdentityId:)` sweep below could no + // longer find the keys to purge. Doing the keychain side first + // leaves at worst stale SwiftData rows on a retry — repeating the + // wipe is harmless, and every keychain call here is idempotent + // (no-op on "not found"). Mnemonic / metadata stay in + // `WalletStorage` for now so a retry can still derive any missed key. + for identityId in identityIds { + try KeychainManager.shared.deleteAllKeychainItems(forIdentityId: identityId) + } + try KeychainManager.shared.deleteAllIdentityPrivateKeys(forWalletId: walletId) + try persistenceHandler.deleteWalletData(walletId: walletId) // The mnemonic + metadata blobs in the Keychain are keyed by diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletResult.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletResult.swift index b8a2f107ad7..e6637001e7f 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletResult.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletResult.swift @@ -187,6 +187,88 @@ 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 + /// A panic was caught inside the one-time-key (shielded invitation) + /// claim, so the outcome is AMBIGUOUS: the panic can strike after the + /// Type-20 transition reached the wire, meaning the transition may + /// already have executed and the identity may already exist on chain. No + /// identity id is produced (unlike `errorShieldedBroadcastUnconfirmed`, + /// whose contract delivers one). + /// + /// RETRYABLE — as a RESUME, not a fresh attempt, and not immediately. + /// The claim's durable recovery record survives in the SDK (the only + /// holder of the claim's padded identity id), and re-running the SAME + /// invitation claim resumes it once the claim lease expires; an + /// immediate attempt is refused as `errorShieldedLifecycleBusy`. The + /// host MUST preserve the local identity slot — never surface this as + /// terminal and never release the slot, either mistake can strand an + /// identity that already exists on chain. + /// + /// Raw value 61 — the registry frontier when the branch merged + /// `v5.1-dev` (first claimed as 48, which `errorAssetLockInputContested` + /// holds there). MUST match `ErrorShieldedClaimUnconfirmed` in + /// packages/rs-platform-wallet-ffi/src/error.rs. + case errorShieldedClaimUnconfirmed = 61 + /// A RETRY of a one-time-key (shielded invitation) claim presented + /// arguments (keys, identity slot, denomination or fallback address) that + /// differ from the transition the earlier attempt already submitted. + /// + /// FAIL-CLOSED and ARGUMENT-CORRECTABLE: nothing was rebroadcast, no proof + /// was spent, and the SDK kept the claim's recovery record. Repeating the + /// same call fails the same way; re-running the claim with the ORIGINAL + /// arguments resumes it. Keep the identity slot the original attempt used, + /// and never surface the invitation as spent or invalid. + /// + /// Raw value 62 — the registry frontier after 61. MUST match + /// `ErrorShieldedClaimBindingMismatch` in + /// packages/rs-platform-wallet-ffi/src/error.rs. + case errorShieldedClaimBindingMismatch = 62 /// RESERVED — the Rust side has no code path that produces this today, so /// it does not currently cross the boundary. It is the TERMINAL form of /// the double-spend verdict: the tracked asset-lock transaction spends an @@ -368,6 +450,16 @@ 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_ERROR_SHIELDED_CLAIM_UNCONFIRMED: + self = .errorShieldedClaimUnconfirmed + case PLATFORM_WALLET_FFI_RESULT_CODE_ERROR_SHIELDED_CLAIM_BINDING_MISMATCH: + self = .errorShieldedClaimBindingMismatch case PLATFORM_WALLET_FFI_RESULT_CODE_ERROR_ASSET_LOCK_INPUT_CONFLICT: self = .errorAssetLockInputConflict case PLATFORM_WALLET_FFI_RESULT_CODE_ERROR_ASSET_LOCK_INPUT_CONTESTED: @@ -697,6 +789,43 @@ 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) + /// A panic was caught inside the one-time-key (shielded invitation) + /// claim, so the outcome is AMBIGUOUS — the transition may already be on + /// chain — and no identity id is produced. RETRYABLE as a RESUME: the + /// SDK retains the claim's durable recovery record (the only holder of + /// its padded identity id), so re-running the SAME claim after the claim + /// lease expires recovers the identity the first attempt created instead + /// of creating a second one. PRESERVE the local identity slot; never + /// surface this as terminal. Kotlin parity: + /// `DashSdkError.PlatformWallet.ShieldedClaimUnconfirmed`. + case shieldedClaimUnconfirmed(String) + /// A one-time-key claim retry whose arguments differ from the transition + /// the earlier attempt submitted. FAIL-CLOSED and ARGUMENT-CORRECTABLE: + /// nothing was rebroadcast and the recovery record is kept, so re-run the + /// claim with the ORIGINAL arguments; repeating this call fails the same + /// way. Keep the identity slot. Kotlin parity: + /// `DashSdkError.PlatformWallet.ShieldedClaimBindingMismatch`. + case shieldedClaimBindingMismatch(String) /// RESERVED, and never produced today: the TERMINAL double-spend verdict, /// which would additionally attest that the confirmed spender's block is /// on the finalized chain. The wallet cannot prove that (chainlock @@ -827,6 +956,9 @@ 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), + .shieldedClaimUnconfirmed(let m), .shieldedClaimBindingMismatch(let m), .assetLockInputConflict(let m), .assetLockInputContested(let m), .consensusRejection(_, let m), @@ -992,6 +1124,16 @@ public enum PlatformWalletError: LocalizedError { } else { self = .unknown(detail) } + case .errorShieldedInviteAlreadyClaimed: + self = .shieldedInviteAlreadyClaimed(detail) + case .errorShieldedScanBudgetExhausted: + self = .shieldedScanBudgetExhausted(detail) + case .errorShieldedLifecycleBusy: + self = .shieldedLifecycleBusy(detail) + case .errorShieldedClaimUnconfirmed: + self = .shieldedClaimUnconfirmed(detail) + case .errorShieldedClaimBindingMismatch: + self = .shieldedClaimBindingMismatch(detail) // Both double-spend codes carry the typed `Display` rendering, not a // JSON detail object: it already names the asset-lock outpoint, the // conflicting input, the confirmed spender's txid and that spender's diff --git a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/ErrorHandlingTests.swift b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/ErrorHandlingTests.swift index f18b44a9fce..26c5306711d 100644 --- a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/ErrorHandlingTests.swift +++ b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/ErrorHandlingTests.swift @@ -419,6 +419,105 @@ 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) + } + + /// The ambiguous-claim code added on dashpay/platform#4313 (review + /// finding 4bf998e99652): a panic caught inside the one-time-key claim. + /// The raw value is the ABI contract — a drifted number (or a missing + /// `init(ffi:)` arm falling to `.errorUnknown`) silently turns + /// "preserve the slot, resume later" into a generic failure, which is + /// the slot-forfeiting misclassification the code exists to end. + func testShieldedClaimUnconfirmedFFIResultMapping() { + XCTAssertEqual( + PlatformWalletResultCode( + ffi: PLATFORM_WALLET_FFI_RESULT_CODE_ERROR_SHIELDED_CLAIM_UNCONFIRMED + ), + .errorShieldedClaimUnconfirmed + ) + XCTAssertEqual(PlatformWalletResultCode.errorShieldedClaimUnconfirmed.rawValue, 61) + + let unconfirmed = PlatformWalletError( + code: .errorShieldedClaimUnconfirmed, + message: "the claim may or may not have been broadcast" + ) + guard case .shieldedClaimUnconfirmed(let message) = unconfirmed else { + return XCTFail("expected typed shieldedClaimUnconfirmed error") + } + XCTAssertEqual(message, "the claim may or may not have been broadcast") + XCTAssertEqual(unconfirmed.errorDescription, message) + } + + /// The argument-correctable claim refusal: a retry whose arguments differ + /// from the submitted transition. It used to reach hosts as the generic + /// `errorWalletOperation` (6); it must keep its own raw value and typed + /// case so a host reruns with the original arguments instead of giving up. + func testShieldedClaimBindingMismatchFFIResultMapping() { + XCTAssertEqual( + PlatformWalletResultCode( + ffi: PLATFORM_WALLET_FFI_RESULT_CODE_ERROR_SHIELDED_CLAIM_BINDING_MISMATCH + ), + .errorShieldedClaimBindingMismatch + ) + XCTAssertEqual(PlatformWalletResultCode.errorShieldedClaimBindingMismatch.rawValue, 62) + + let mismatch = PlatformWalletError( + code: .errorShieldedClaimBindingMismatch, + message: "identity_index 3 != recorded 1" + ) + guard case .shieldedClaimBindingMismatch(let message) = mismatch else { + return XCTFail("expected typed shieldedClaimBindingMismatch error") + } + XCTAssertEqual(message, "identity_index 3 != recorded 1") + XCTAssertEqual(mismatch.errorDescription, message) + } + func testShouldPreserveShieldedRecoveryErrorsFromFFI() { let corrupted = PlatformWalletResultCode( ffi: PLATFORM_WALLET_FFI_RESULT_CODE_ERROR_SHIELDED_RECOVERY_CORRUPTED