diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/shielded_withdrawal/tests.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/shielded_withdrawal/tests.rs index f40be3e4b7..841eadeed2 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/shielded_withdrawal/tests.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/shielded_withdrawal/tests.rs @@ -511,6 +511,234 @@ mod tests { ); } + /// Gold-standard reproduction of TestFlight report B ("shielded→Core + /// withdrawal never lands"). Identical to + /// `test_valid_shielded_withdrawal_proof_succeeds` — a **real** Orchard + /// proof, correct value balance, sufficient pool balance — EXCEPT the + /// anchor is **not** recorded in state (`insert_anchor_into_state` is + /// omitted). A legitimate spend is then refused with `InvalidAnchorError` + /// purely because its anchor isn't in the recorded set. + /// + /// This is exactly the wallet's failure mode: it builds the proof + /// against its depth-0 tree root, but the index-chunk sync leaves that + /// root mid-block, so Platform (which records one anchor per block) never + /// recorded it. See `platform-wallet`'s + /// `depth0_spend_anchor_mid_block_is_not_a_recorded_block_boundary_anchor`, + /// which proves the wallet produces such a non-recorded anchor. + #[test] + fn test_valid_proof_with_unrecorded_anchor_returns_invalid_anchor_error() { + let platform_version = PlatformVersion::latest(); + let platform = setup_platform(); + insert_dummy_encrypted_notes(&platform, 250); + let mut rng = StdRng::seed_from_u64(0); + let pk = get_proving_key(); + + // --- Create keys --- + let sk = SpendingKey::from_bytes([0u8; 32]).unwrap(); + let fvk = FullViewingKey::from(&sk); + let recipient = fvk.address_at(0u32, Scope::External); + let ask = SpendAuthorizingKey::from(&sk); + + // --- Create a spendable note with value 500M --- + let rho_bytes: [u8; 32] = { + let mut b = [0u8; 32]; + b[0] = 1; + b + }; + let rho = Rho::from_bytes(&rho_bytes).unwrap(); + let rseed = RandomSeed::from_bytes([42u8; 32], &rho).unwrap(); + let note = + Note::from_parts(recipient, NoteValue::from_raw(500_000_000), rho, rseed).unwrap(); + + // --- Build commitment tree and get anchor + merkle path --- + let cmx = ExtractedNoteCommitment::from(note.commitment()); + let mut tree = ClientMemoryCommitmentTree::new(100); + tree.append(cmx.to_bytes(), Retention::Marked).unwrap(); + tree.checkpoint(0u32).unwrap(); + let anchor = tree.anchor().unwrap(); + let merkle_path = tree.witness(Position::from(0u64), 0).unwrap().unwrap(); + + // --- Build bundle: spend 500M -> output 5K --- + let mut builder = Builder::::new(BundleType::DEFAULT, anchor); + builder.add_spend(fvk.clone(), note, merkle_path).unwrap(); + builder + .add_output(None, recipient, NoteValue::from_raw(5_000), [0u8; 36]) + .unwrap(); + let (unauthorized, _) = builder.build::(&mut rng).unwrap().unwrap(); + + let output_script = create_output_script(); + let unshielding_amount = 499_995_000u64; + let extra_sighash_data = dpp::shielded::shielded_withdrawal_extra_sighash_data_v0( + output_script.as_bytes(), + unshielding_amount, + 1, + Pooling::Never, + ); + let bundle_commitment: [u8; 32] = unauthorized.commitment().into(); + let sighash = compute_platform_sighash(&bundle_commitment, &extra_sighash_data); + let proven = unauthorized.create_proof(pk, &mut rng).unwrap(); + let bundle = proven.apply_signatures(rng, sighash, &[ask]).unwrap(); + + let (actions, value_balance, anchor_bytes, proof_bytes, binding_sig) = + serialize_authorized_bundle_i64(&bundle); + assert_eq!(value_balance, 499_995_000); + + // Pool balance passes; the anchor is DELIBERATELY not recorded + // (no `insert_anchor_into_state`) — the crux of the reproduction. + set_pool_total_balance(&platform, 500_000_000); + + let transition = create_shielded_withdrawal_transition( + actions, + value_balance as u64, + anchor_bytes, + proof_bytes, + binding_sig, + 1, + Pooling::Never, + output_script, + ); + + let processing_result = process_transition(&platform, transition, platform_version); + + // Real proof passes; anchor validation then rejects the unrecorded + // anchor. This is why the withdrawal never lands. + assert_matches!( + processing_result.execution_results().as_slice(), + [StateTransitionExecutionResult::UnpaidConsensusError( + ConsensusError::StateError(StateError::InvalidAnchorError(_)) + )] + ); + } + + /// Keystone acceptance test for the anchor-selection fix: a **real** + /// Orchard proof built against an OLDER recorded anchor — with the + /// commitment tree (and the recorded set) advanced past it — must + /// validate successfully. This is exactly the spend shape + /// `platform-wallet`'s `select_recorded_spends` produces after the + /// fix: witnesses taken at a deeper checkpoint whose root Platform + /// recorded, while newer commitments and a newer boundary anchor + /// already exist. `test_valid_shielded_withdrawal_proof_succeeds` + /// covers only the fresh-anchor (depth-0-recorded) case, and + /// `test_valid_proof_with_unrecorded_anchor_returns_invalid_anchor_error` + /// only the rejection side; this pins the acceptance side the whole + /// fix rests on. + #[test] + fn test_valid_proof_with_older_recorded_anchor_succeeds() { + let platform_version = PlatformVersion::latest(); + let platform = setup_platform(); + insert_dummy_encrypted_notes(&platform, 250); + let mut rng = StdRng::seed_from_u64(0); + let pk = get_proving_key(); + + let sk = SpendingKey::from_bytes([0u8; 32]).unwrap(); + let fvk = FullViewingKey::from(&sk); + let recipient = fvk.address_at(0u32, Scope::External); + let ask = SpendAuthorizingKey::from(&sk); + + let rho_bytes: [u8; 32] = { + let mut b = [0u8; 32]; + b[0] = 1; + b + }; + let rho = Rho::from_bytes(&rho_bytes).unwrap(); + let rseed = RandomSeed::from_bytes([42u8; 32], &rho).unwrap(); + let note = + Note::from_parts(recipient, NoteValue::from_raw(500_000_000), rho, rseed).unwrap(); + + // The spent note lands in "block 1", whose boundary root is + // recorded (anchor_old). Later commitments then arrive and a + // newer boundary is recorded (anchor_new). The spend is built at + // checkpoint depth 1 — the wallet fix's exact output when its + // depth-0 root is unrecorded. + let cmx = ExtractedNoteCommitment::from(note.commitment()); + let mut tree = ClientMemoryCommitmentTree::new(100); + tree.append(cmx.to_bytes(), Retention::Marked).unwrap(); + tree.checkpoint(0u32).unwrap(); // block-1 boundary + + for later in 2u8..=4 { + let mut b = [0u8; 32]; + b[0] = later; + let rho_later = Rho::from_bytes(&b).unwrap(); + let rseed_later = RandomSeed::from_bytes([42u8; 32], &rho_later).unwrap(); + let note_later = Note::from_parts( + recipient, + NoteValue::from_raw(1_000), + rho_later, + rseed_later, + ) + .unwrap(); + let cmx_later = ExtractedNoteCommitment::from(note_later.commitment()); + tree.append(cmx_later.to_bytes(), Retention::Ephemeral) + .unwrap(); + } + tree.checkpoint(1u32).unwrap(); // block-2 boundary + + let anchor_new = tree.anchor().unwrap(); + // Witness at checkpoint depth 1 → path rooted at the OLDER + // (block-1) recorded root, not the tip. + let merkle_path = tree.witness(Position::from(0u64), 1).unwrap().unwrap(); + let anchor_old = merkle_path.root(cmx); + assert_ne!( + anchor_old.to_bytes(), + anchor_new.to_bytes(), + "tree must have advanced past the spend's anchor for this test to be meaningful" + ); + + // --- Build bundle against the older anchor --- + let mut builder = Builder::::new(BundleType::DEFAULT, anchor_old); + builder.add_spend(fvk.clone(), note, merkle_path).unwrap(); + builder + .add_output(None, recipient, NoteValue::from_raw(5_000), [0u8; 36]) + .unwrap(); + let (unauthorized, _) = builder.build::(&mut rng).unwrap().unwrap(); + + let output_script = create_output_script(); + let unshielding_amount = 499_995_000u64; + let extra_sighash_data = dpp::shielded::shielded_withdrawal_extra_sighash_data_v0( + output_script.as_bytes(), + unshielding_amount, + 1, + Pooling::Never, + ); + let bundle_commitment: [u8; 32] = unauthorized.commitment().into(); + let sighash = compute_platform_sighash(&bundle_commitment, &extra_sighash_data); + let proven = unauthorized.create_proof(pk, &mut rng).unwrap(); + let bundle = proven.apply_signatures(rng, sighash, &[ask]).unwrap(); + + let (actions, value_balance, anchor_bytes, proof_bytes, binding_sig) = + serialize_authorized_bundle_i64(&bundle); + assert_eq!(value_balance, 499_995_000); + assert_eq!( + anchor_bytes, + anchor_old.to_bytes(), + "the bundle must carry the older anchor" + ); + + // The recorded set holds BOTH boundaries — like drive after two + // block-ends — and the spend references the older one. + insert_anchor_into_state(&platform, &anchor_bytes); + insert_anchor_into_state(&platform, &anchor_new.to_bytes()); + set_pool_total_balance(&platform, 500_000_000); + + let transition = create_shielded_withdrawal_transition( + actions, + value_balance as u64, + anchor_bytes, + proof_bytes, + binding_sig, + 1, + Pooling::Never, + output_script, + ); + + let processing_result = process_transition(&platform, transition, platform_version); + + assert_matches!( + processing_result.execution_results().as_slice(), + [StateTransitionExecutionResult::SuccessfulExecution { .. }] + ); + } + #[test] fn test_wrong_encrypted_note_size_returns_error() { let platform_version = PlatformVersion::latest(); diff --git a/packages/rs-platform-wallet-ffi/src/error.rs b/packages/rs-platform-wallet-ffi/src/error.rs index de1a6cb944..c18ef2d681 100644 --- a/packages/rs-platform-wallet-ffi/src/error.rs +++ b/packages/rs-platform-wallet-ffi/src/error.rs @@ -124,6 +124,16 @@ pub enum PlatformWalletFFIResultCode { /// must NOT auto-retry — a retry would select different unreserved notes /// and could double-send if the original spend landed. ErrorShieldedSpendUnconfirmed = 18, + /// Maps `PlatformWalletError::ShieldedNoRecordedAnchor`. The wallet could + /// not build the spend against any Platform-recorded anchor yet: its local + /// commitment tree is mid-block (an index-chunk sync routinely stops + /// between block boundaries) while Platform records an anchor only at each + /// block boundary. The transition was NOT broadcast and any note + /// reservations were released, so this is RETRYABLE — the host should wait + /// for the next shielded sync (which advances the tree onto a recorded + /// boundary) and try again. Distinct from `ErrorShieldedSpendUnconfirmed`, + /// where a spend WAS broadcast and must NOT be retried. + ErrorShieldedNoRecordedAnchor = 19, NotFound = 98, // Used exclusively for all the Option that are retuned as errors ErrorUnknown = 99, @@ -237,6 +247,9 @@ impl From for PlatformWalletFFIResult { PlatformWalletError::ShieldedSpendUnconfirmed { .. } => { PlatformWalletFFIResultCode::ErrorShieldedSpendUnconfirmed } + PlatformWalletError::ShieldedNoRecordedAnchor(..) => { + PlatformWalletFFIResultCode::ErrorShieldedNoRecordedAnchor + } _ => PlatformWalletFFIResultCode::ErrorUnknown, }; PlatformWalletFFIResult::err(code, error.to_string()) diff --git a/packages/rs-platform-wallet-ffi/src/shielded_send.rs b/packages/rs-platform-wallet-ffi/src/shielded_send.rs index a7c9a0283e..bd6fe5f224 100644 --- a/packages/rs-platform-wallet-ffi/src/shielded_send.rs +++ b/packages/rs-platform-wallet-ffi/src/shielded_send.rs @@ -448,6 +448,14 @@ fn map_spend_result( e.to_string(), ) } + // Retryable: the wallet couldn't build the spend against any + // Platform-recorded anchor yet (its commitment tree is mid-block after + // an index-chunk sync). Nothing was broadcast and the notes were + // released, so the host may retry after the next shielded sync. + Err(e @ PlatformWalletError::ShieldedNoRecordedAnchor(_)) => PlatformWalletFFIResult::err( + PlatformWalletFFIResultCode::ErrorShieldedNoRecordedAnchor, + format!("Wallet is still syncing to a confirmed state — try again shortly. ({e})"), + ), // Definitive failure: the transition was not executed and the notes // were released; the host may retry. Err(e @ PlatformWalletError::ShieldedBroadcastFailed(_)) => PlatformWalletFFIResult::err( @@ -628,6 +636,14 @@ pub unsafe extern "C" fn platform_wallet_manager_shielded_identity_create_from_p ), ) } + // Retryable: no Platform-recorded anchor covered the selected notes yet + // (the commitment tree is mid-block after an index-chunk sync). Nothing + // was broadcast and the notes were released, so the host may retry after + // the next shielded sync. + Err(e @ PlatformWalletError::ShieldedNoRecordedAnchor(_)) => PlatformWalletFFIResult::err( + PlatformWalletFFIResultCode::ErrorShieldedNoRecordedAnchor, + format!("Wallet is still syncing to a confirmed state — try again shortly. ({e})"), + ), // Definitive failure: the transition was not executed and the spent notes were released. Err(e @ PlatformWalletError::ShieldedBroadcastFailed(_)) => PlatformWalletFFIResult::err( PlatformWalletFFIResultCode::ErrorShieldedBroadcastFailed, @@ -1332,6 +1348,21 @@ mod tests { "broadcast-failed message must carry the wallet Display payload" ); + // No Platform-recorded anchor yet → its own retryable code, distinct + // from the "was broadcast, do NOT retry" unconfirmed code above. + let no_anchor: Result<(), PlatformWalletError> = Err( + PlatformWalletError::ShieldedNoRecordedAnchor("mid-block".to_string()), + ); + let result = map_spend_result(no_anchor, "shielded withdraw"); + assert_eq!( + result.code, + PlatformWalletFFIResultCode::ErrorShieldedNoRecordedAnchor + ); + assert!( + message_of(&result).contains("try again shortly"), + "no-recorded-anchor message must be the retryable guidance" + ); + let other: Result<(), PlatformWalletError> = Err(PlatformWalletError::ShieldedNoUnspentNotes); let result = map_spend_result(other, "shielded withdraw"); diff --git a/packages/rs-platform-wallet/src/error.rs b/packages/rs-platform-wallet/src/error.rs index c94cb7093d..c95c086ade 100644 --- a/packages/rs-platform-wallet/src/error.rs +++ b/packages/rs-platform-wallet/src/error.rs @@ -234,6 +234,19 @@ pub enum PlatformWalletError { #[error("Shielded Merkle witness unavailable: {0}")] ShieldedMerkleWitnessUnavailable(String), + /// No Platform-recorded anchor covers the notes selected for a shielded + /// spend, so the wallet cannot build a proof Platform will accept. + /// + /// Platform records one commitment-tree anchor per block, but an + /// index-chunk sync routinely leaves the wallet's tree mid-block, so the + /// current (depth-0) root is frequently a value Platform never recorded. + /// This variant is **retryable**: it is returned *before* any broadcast, + /// the note reservations are released by the caller's generic error path, + /// and the next shielded sync advances the tree onto a recorded boundary. + /// `0` carries a human-readable reason. + #[error("Shielded spend cannot use a Platform-recorded anchor: {0}")] + ShieldedNoRecordedAnchor(String), + #[error("Shielded key derivation failed: {0}")] ShieldedKeyDerivation(String), 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 741bc8bcfa..436b0513a5 100644 --- a/packages/rs-platform-wallet/src/wallet/shielded/file_store.rs +++ b/packages/rs-platform-wallet/src/wallet/shielded/file_store.rs @@ -283,19 +283,23 @@ impl ShieldedStore for FileBackedShieldedStore { .map_err(|e| FileShieldedStoreError(format!("read tree anchor: {e}"))) } - fn witness( + fn witness_at_depth( &self, position: u64, + depth: usize, ) -> Result, Self::Error> { let tree = self .tree .lock() .map_err(|e| FileShieldedStoreError(format!("tree mutex poisoned: {e}")))?; - // `checkpoint_depth = 0` = current tree state. The Halo 2 - // proof we're about to build uses `tree_anchor()` — also - // depth 0 — so the witness root must agree. - tree.witness(Position::from(position), 0) - .map_err(|e| FileShieldedStoreError(format!("witness({position}): {e}"))) + // `checkpoint_depth = 0` is the current tree state; deeper values + // reach older checkpoints so a spend can be built against a root + // Platform actually recorded (it records one anchor per block, while + // an index-chunk sync routinely leaves the tree mid-block). The proof + // uses whichever anchor this witness produces via `MerklePath::root`, + // so the anchor and the authentication path always agree. + tree.witness(Position::from(position), depth) + .map_err(|e| FileShieldedStoreError(format!("witness({position}, depth {depth}): {e}"))) } fn tree_size(&self) -> Result { @@ -555,4 +559,97 @@ mod tests { confirming reset cleared the SQLite tree tables" ); } + + /// Reproduces the shielded **withdrawal-never-lands** root cause (TestFlight + /// report B): the wallet builds a spend against its depth-0 (current) tree + /// root, but that root is a *Platform-recorded* anchor only when the tree + /// sits exactly on a block boundary. + /// + /// - The spend anchor is `witness(pos, 0).root(cmx)`, which equals + /// `tree_anchor()` (both depth-0; see the comment on `witness`). This test + /// asserts that equality directly. + /// - The wallet syncs commitments by index-chunk (`CHUNK_SIZE = 2048` in + /// `sync.rs`), **not** by block, so its tree routinely stops mid-block. + /// - drive records **one anchor per block** (`record_anchor_if_changed` at + /// block-processing-end) and `validate_anchor_exists` rejects any anchor + /// it never recorded (`InvalidAnchorError`). + /// + /// So a mid-block depth-0 anchor is rejected every attempt — repeatable, + /// never lands, funds untouched. The team already names this failure at the + /// `tree_size` test above ("Anchor not found in the recorded anchors"). + #[test] + fn depth0_spend_anchor_mid_block_is_not_a_recorded_block_boundary_anchor() { + use grovedb_commitment_tree::ExtractedNoteCommitment; + + let path = temp_tree_path("anchor_midblock"); + let mut store = FileBackedShieldedStore::open_path(&path, 100).unwrap(); + + let cmx = |b: u8| { + let mut c = [0u8; 32]; + c[0] = b; + c + }; + + // Two blocks of commitments. drive records ONE anchor per block, at + // block-processing-end (after ALL of that block's commitments): + // block 1 = commitments 1..=3 -> recorded anchor at tree size 3 + // block 2 = commitments 4..=6 -> recorded anchor at tree size 6 + for b in 1..=3u8 { + store.append_commitment(&cmx(b), true).unwrap(); + } + store.checkpoint_tree(3).unwrap(); + let recorded_after_block1 = store.tree_anchor().unwrap(); + + // The index-chunk sync appends block 2's commitments incrementally; a + // chunk/stream boundary that lands mid-block (the common case — a + // 2048-leaf chunk rarely ends on a block boundary) leaves the wallet at + // tree size 4, and it checkpoints there (sync.rs checkpoints at the + // post-append leaf count). Its depth-0 anchor is now the root at size 4 + // — a state drive never recorded. + store.append_commitment(&cmx(4), true).unwrap(); + store.checkpoint_tree(4).unwrap(); + let wallet_depth0_mid_block = store.tree_anchor().unwrap(); + + // The spend path uses exactly this anchor: `extract_spends_and_anchor` + // builds it as `witness(pos, 0).root(cmx)`. Pin that it equals the + // mid-block `tree_anchor()`. + let cmx0 = ExtractedNoteCommitment::from_bytes(&cmx(1)) + .into_option() + .expect("valid cmx"); + let spend_anchor = store + .witness(0) + .unwrap() + .expect("witness for marked position 0") + .root(cmx0) + .to_bytes(); + assert_eq!( + spend_anchor, wallet_depth0_mid_block, + "the spend anchor (depth-0 witness root) must equal the mid-block tree_anchor" + ); + + // Finish block 2. drive records the anchor at tree size 6. + store.append_commitment(&cmx(5), true).unwrap(); + store.append_commitment(&cmx(6), true).unwrap(); + store.checkpoint_tree(6).unwrap(); + let recorded_after_block2 = store.tree_anchor().unwrap(); + + let _ = std::fs::remove_file(&path); + + // drive's recorded anchor set is {block1, block2}. The wallet's mid-block + // spend anchor is neither -> `validate_anchor_exists` rejects it with + // InvalidAnchorError, and the withdrawal never lands. + assert_ne!( + wallet_depth0_mid_block, recorded_after_block1, + "mid-block spend anchor must differ from block 1's recorded anchor" + ); + assert_ne!( + wallet_depth0_mid_block, recorded_after_block2, + "mid-block spend anchor must differ from block 2's recorded anchor" + ); + assert_ne!( + recorded_after_block1, recorded_after_block2, + "the two block-boundary anchors differ (the tree grew), so drive's \ + recorded set is exactly these two and the mid-block anchor is outside it" + ); + } } diff --git a/packages/rs-platform-wallet/src/wallet/shielded/operations.rs b/packages/rs-platform-wallet/src/wallet/shielded/operations.rs index c376c99a78..ea21204591 100644 --- a/packages/rs-platform-wallet/src/wallet/shielded/operations.rs +++ b/packages/rs-platform-wallet/src/wallet/shielded/operations.rs @@ -33,9 +33,10 @@ use crate::error::PlatformWalletError; use crate::wallet::persister::WalletPersister; use crate::wallet::platform_wallet::WalletId; -use std::collections::BTreeMap; +use std::collections::{BTreeMap, HashSet}; use std::sync::Arc; +use dash_sdk::platform::fetch_current_no_parameters::FetchCurrent; use dash_sdk::platform::transition::broadcast::BroadcastStateTransition; use dash_sdk::platform::transition::identity_create_from_shielded_pool::IdentityCreateFromShieldedPool; use dpp::address_funds::{ @@ -662,7 +663,7 @@ pub async fn unshield( // it. A build failure leaves it `None` and records nothing. let mut pending_entry = None; let result = async { - let (spends, anchor) = extract_spends_and_anchor(store, &selected_notes).await?; + let (spends, anchor) = extract_spends_and_anchor(sdk, store, &selected_notes).await?; // The builder computes and returns the fee authoritatively; `exact_fee` (== the // minimum) was already used above for note reservation. @@ -824,7 +825,7 @@ pub async fn transfer( let mut pending_entry = None; let result = async { - let (spends, anchor) = extract_spends_and_anchor(store, &selected_notes).await?; + let (spends, anchor) = extract_spends_and_anchor(sdk, store, &selected_notes).await?; // The builder computes and returns the fee authoritatively; `exact_fee` (== the // minimum) was already used above for note reservation. @@ -974,7 +975,7 @@ pub async fn withdraw( let mut pending_entry = None; let result = async { - let (spends, anchor) = extract_spends_and_anchor(store, &selected_notes).await?; + let (spends, anchor) = extract_spends_and_anchor(sdk, store, &selected_notes).await?; // The builder computes and returns the fee authoritatively; `exact_fee` (== the // minimum) was already used above for note reservation. @@ -1163,7 +1164,7 @@ where // outer match; it lives here so the flip can see it. let mut pending_entry = None; let result = async { - let (spends, anchor) = extract_spends_and_anchor(store, &selected_notes).await?; + let (spends, anchor) = extract_spends_and_anchor(sdk, store, &selected_notes).await?; let build = build_identity_create_from_shielded_pool_transition( public_keys, @@ -1508,88 +1509,213 @@ fn default_orchard_address(keys: &OrchardKeySet) -> Result( + sdk: &Arc, store: &Arc>, notes: &[ShieldedNote], ) -> Result<(Vec, Anchor), PlatformWalletError> { - use grovedb_commitment_tree::ExtractedNoteCommitment; + // 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) +} - let mut spends = Vec::with_capacity(notes.len()); - let mut anchor: Option = None; - for note in notes { - let orchard_note = deserialize_note(¬e.note_data).ok_or_else(|| { - PlatformWalletError::ShieldedBuildError(format!( - "Failed to deserialize note at position {}", - note.position - )) - })?; - - let merkle_path = store - .witness(note.position) - .map_err(|e| PlatformWalletError::ShieldedMerkleWitnessUnavailable(e.to_string()))? - .ok_or_else(|| { - PlatformWalletError::ShieldedMerkleWitnessUnavailable(format!( - "no witness available for note at position {} (not marked, or pruned past this position)", - note.position - )) - })?; +/// 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; - // Compute the anchor this witness was generated against. - // All selected notes must share the same anchor — if not, - // the store handed us witnesses from different - // checkpoints, which the spend builder would reject - // downstream with `AnchorMismatch`. Surface the mismatch - // here so the host doesn't pay the ~30 s proof cost - // first. - let cmx = ExtractedNoteCommitment::from_bytes(¬e.cmx) - .into_option() - .ok_or_else(|| { + // 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!( - "invalid stored cmx for note at position {}", + "Failed to deserialize note at position {}", note.position )) })?; - 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 {})", - note.position - ))); + let cmx = ExtractedNoteCommitment::from_bytes(¬e.cmx) + .into_option() + .ok_or_else(|| { + PlatformWalletError::ShieldedBuildError(format!( + "invalid stored cmx for note at position {}", + note.position + )) + })?; + Ok((note.position, orchard_note, cmx)) + }) + .collect::>()?; + + // Build every selected note's `SpendableNote` plus the shared anchor at a + // single checkpoint `depth`. + // + // `strict` (depth 0 only): a missing/failed witness is a hard + // `ShieldedMerkleWitnessUnavailable` (the note is expected to be witnessable + // at the current tip). At depth > 0, `Ok(None)` means the note post-dates + // this older checkpoint, so the depth is unusable — return `Ok(None)` and + // let the caller stop probing deeper; a genuine store `Err` (poisoned mutex, + // IO, tree corruption) is logged — the probe would otherwise discard the + // message — and likewise treated as an unusable depth rather than aborting, + // so a transient read can't strand a spend a shallower depth already + // covered. An anchor disagreement across notes is always a hard error (the + // spend builder would reject it downstream). + let build_at_depth = |depth: usize, + strict: bool| + -> Result, Anchor)>, PlatformWalletError> { + let mut spends = Vec::with_capacity(prepared.len()); + let mut anchor: Option = None; + for (position, note, cmx) in &prepared { + let merkle_path = match store.witness_at_depth(*position, depth) { + Ok(Some(path)) => path, + Ok(None) if strict => { + return Err(PlatformWalletError::ShieldedMerkleWitnessUnavailable(format!( + "no witness available for note at position {position} (not marked, or pruned past this position)" + ))); + } + Err(e) if strict => { + return Err(PlatformWalletError::ShieldedMerkleWitnessUnavailable( + e.to_string(), + )); + } + // depth > 0: the note isn't witnessable at this older checkpoint + // (appended after it, or the depth doesn't exist). + Ok(None) => return Ok(None), + // depth > 0: a genuine store failure. Log it so the operator sees + // it (the anchor probe otherwise swallows the message), then treat + // the depth as unusable — never a mid-probe abort. + Err(e) => { + tracing::warn!( + position = *position, + depth, + error = %e, + "shielded anchor probe: witness_at_depth failed at depth > 0; skipping depth" + ); + return Ok(None); + } + }; + + // The anchor is derived from the witness path itself + // (`MerklePath::root(cmx)`); all selected notes must agree on it, or + // the store handed back witnesses from different checkpoints and the + // spend builder would reject the mismatch downstream. + let witness_anchor = merkle_path.root(*cmx); + match &anchor { + None => anchor = Some(witness_anchor), + Some(prev) if prev.to_bytes() != witness_anchor.to_bytes() => { + return Err(PlatformWalletError::ShieldedBuildError(format!( + "witness anchor mismatch across selected notes (position {position})" + ))); + } + _ => {} } - _ => {} + + spends.push(SpendableNote { + note: *note, + merkle_path, + }); } - spends.push(SpendableNote { - note: orchard_note, - merkle_path, - }); + // `notes` is non-empty (the caller checked), so `anchor` is set. + let anchor = anchor.ok_or_else(|| { + PlatformWalletError::ShieldedBuildError( + "no spendable notes selected — anchor undefined".to_string(), + ) + })?; + Ok(Some((spends, anchor))) + }; + + // Fast path: a fully-synced wallet's depth-0 root is a recorded anchor. + let (spends, anchor) = match build_at_depth(0, true)? { + Some(pair) => pair, + // Unreachable — a strict build returns `Some` or errors — but stay + // fund-safe (a clean error, never a panic) if that invariant breaks. + None => { + return Err(PlatformWalletError::ShieldedMerkleWitnessUnavailable( + "depth-0 witness probe returned no witness for a selected note".to_string(), + )); + } + }; + if recorded.contains(&anchor.to_bytes()) { + return Ok((spends, anchor)); } - let anchor = anchor.ok_or_else(|| { - PlatformWalletError::ShieldedBuildError( - "no spendable notes selected — anchor undefined".to_string(), - ) - })?; + // 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, + } + } - Ok((spends, anchor)) + 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 @@ -2309,3 +2435,256 @@ mod record_activity_status_tests { assert_eq!(stored.block_height, Some(900)); } } + +/// 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, + } + } + + /// 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(); + + // 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" + ); + } + + /// Fully synced: the wallet's depth-0 root IS recorded, so the probe takes + /// the fast path and returns the depth-0 anchor without walking deeper. + #[test] + fn fully_synced_returns_depth0_anchor() { + let path = temp_tree_path("fastpath"); + let mut store = FileBackedShieldedStore::open_path(&path, 100).unwrap(); + + let note = real_note(0); + + // One block, checkpointed exactly on its boundary: depth 0 == a recorded + // anchor. + store.append_commitment(¬e.cmx, true).unwrap(); + store.append_commitment(&filler_cmx(0xA1), true).unwrap(); + store.append_commitment(&filler_cmx(0xA2), true).unwrap(); + store.checkpoint_tree(3).unwrap(); + let root_depth0 = store.tree_anchor().unwrap(); + + let recorded: HashSet<[u8; 32]> = [root_depth0].into_iter().collect(); + + let (spends, anchor) = + select_recorded_spends(&store, std::slice::from_ref(¬e), &recorded) + .expect("depth-0 root is recorded"); + + let _ = std::fs::remove_file(&path); + + assert_eq!(spends.len(), 1); + assert_eq!( + anchor.to_bytes(), + root_depth0, + "the fully-synced fast path returns the depth-0 anchor" + ); + } + + /// No checkpoint root is recorded: the probe exhausts every depth and + /// returns the retryable `ShieldedNoRecordedAnchor` — nothing is broadcast. + #[test] + fn no_recorded_checkpoint_returns_retryable_error() { + let path = temp_tree_path("none"); + let mut store = FileBackedShieldedStore::open_path(&path, 100).unwrap(); + + let note = real_note(0); + + store.append_commitment(¬e.cmx, true).unwrap(); + store.append_commitment(&filler_cmx(0xA1), true).unwrap(); + store.append_commitment(&filler_cmx(0xA2), true).unwrap(); + store.checkpoint_tree(3).unwrap(); + + // Platform recorded none of this wallet's checkpoint roots. + let recorded: HashSet<[u8; 32]> = HashSet::new(); + + // `SpendableNote` isn't `Debug`, so match rather than `expect_err`. + let result = select_recorded_spends(&store, std::slice::from_ref(¬e), &recorded); + + let _ = std::fs::remove_file(&path); + + match result { + Err(PlatformWalletError::ShieldedNoRecordedAnchor(_)) => {} + Err(other) => { + panic!("expected ShieldedNoRecordedAnchor, got error: {other:?}") + } + Ok(_) => panic!("expected ShieldedNoRecordedAnchor, got Ok"), + } + } + + /// A selected note that post-dates the only recorded checkpoint. Depth 0 + /// (which contains the note) isn't recorded; at depth 1 the note is not yet + /// in the tree, so the probe's early-termination fires (a deeper checkpoint + /// is older still and can't contain it) and the retryable error is returned + /// — even though a recorded anchor exists, it doesn't cover this note. This + /// pins the walk's break arm and the value-selection trade-off (a mid-block + /// wallet whose selected note is newer than every recorded checkpoint waits + /// for the next sync rather than spending). + #[test] + fn note_newer_than_recorded_checkpoint_breaks_and_returns_retryable_error() { + let path = temp_tree_path("toonew"); + let mut store = FileBackedShieldedStore::open_path(&path, 100).unwrap(); + + // Block 1 = positions 0,1,2 (fillers), checkpointed on its boundary — + // the only root Platform recorded. + store.append_commitment(&filler_cmx(0xA0), true).unwrap(); + store.append_commitment(&filler_cmx(0xA1), true).unwrap(); + store.append_commitment(&filler_cmx(0xA2), true).unwrap(); + store.checkpoint_tree(3).unwrap(); + let root_block1 = store.tree_anchor().unwrap(); + + // The owned note is appended AFTER block 1 (position 3) and checkpointed: + // present at depth 0, absent from the block-1 checkpoint (depth 1). + let note = real_note(3); + store.append_commitment(¬e.cmx, true).unwrap(); + store.checkpoint_tree(4).unwrap(); + + let recorded: HashSet<[u8; 32]> = [root_block1].into_iter().collect(); + + let result = select_recorded_spends(&store, std::slice::from_ref(¬e), &recorded); + + let _ = std::fs::remove_file(&path); + + match result { + Err(PlatformWalletError::ShieldedNoRecordedAnchor(_)) => {} + Err(other) => panic!("expected ShieldedNoRecordedAnchor, got error: {other:?}"), + Ok(_) => panic!("expected ShieldedNoRecordedAnchor, got Ok"), + } + } +} diff --git a/packages/rs-platform-wallet/src/wallet/shielded/store.rs b/packages/rs-platform-wallet/src/wallet/shielded/store.rs index e57ca6d92c..d6f6d1801c 100644 --- a/packages/rs-platform-wallet/src/wallet/shielded/store.rs +++ b/packages/rs-platform-wallet/src/wallet/shielded/store.rs @@ -252,14 +252,36 @@ pub trait ShieldedStore: Send + Sync { /// Return the current tree root (Sinsemilla anchor, 32 bytes). fn tree_anchor(&self) -> Result<[u8; 32], Self::Error>; - /// Generate a Merkle authentication path for `position` - /// against the current tree state. Returns `Ok(None)` if no - /// witness is available (position not marked, or pruned). - fn witness( + /// Generate a Merkle authentication path for `position` as of the + /// checkpoint at `depth` (0 = current tree state, 1 = the previous + /// checkpoint, and so on). Returns `Ok(None)` when no witness is + /// available at that depth — the position is unmarked/pruned, the + /// requested checkpoint depth doesn't exist, or the position was + /// appended after that checkpoint. + /// + /// Building a spend against an older-but-recorded checkpoint is how the + /// wallet keeps its anchor consistent with a root Platform actually + /// recorded: Platform records one anchor per block while an index-chunk + /// sync routinely leaves the tree mid-block, so the depth-0 root is often + /// one Platform never recorded. + fn witness_at_depth( &self, position: u64, + depth: usize, ) -> Result, Self::Error>; + /// Generate a Merkle authentication path for `position` against the + /// current tree state. Returns `Ok(None)` if no witness is available + /// (position not marked, or pruned). + /// + /// Delegates to [`Self::witness_at_depth`] at depth 0. + fn witness( + &self, + position: u64, + ) -> Result, Self::Error> { + self.witness_at_depth(position, 0) + } + /// Number of leaves currently in the shared commitment tree /// (= highest appended position + 1, or 0 when empty). /// @@ -633,9 +655,10 @@ impl ShieldedStore for InMemoryShieldedStore { Ok(self.anchor) } - fn witness( + fn witness_at_depth( &self, _position: u64, + _depth: usize, ) -> Result, Self::Error> { Err(InMemoryStoreError( "Merkle witness not supported in in-memory store".into(), diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletResult.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletResult.swift index 2c311f91e9..45b570e091 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletResult.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletResult.swift @@ -39,6 +39,13 @@ public enum PlatformWalletResultCode: Int32, Sendable { /// outcome. Do NOT auto-retry — a retry would rebuild the bundle and /// could double-execute if the original landed. case errorShieldedSpendUnconfirmed = 18 + /// A shielded spend could not be built against a Platform-recorded anchor: + /// the wallet's commitment tree isn't synced to a checkpoint Platform has + /// recorded (an in-progress / interrupted sync leaves it mid-block). Nothing + /// was broadcast and the notes were released. This is retryable — let the + /// shielded sync reach a confirmed state and try again. Distinct from + /// `errorShieldedSpendUnconfirmed`, which must NOT be retried. + case errorShieldedNoRecordedAnchor = 19 case notFound = 98 case errorUnknown = 99 @@ -82,6 +89,8 @@ public enum PlatformWalletResultCode: Int32, Sendable { self = .errorShieldedBroadcastUnconfirmed case PLATFORM_WALLET_FFI_RESULT_CODE_ERROR_SHIELDED_SPEND_UNCONFIRMED: self = .errorShieldedSpendUnconfirmed + case PLATFORM_WALLET_FFI_RESULT_CODE_ERROR_SHIELDED_NO_RECORDED_ANCHOR: + self = .errorShieldedNoRecordedAnchor case PLATFORM_WALLET_FFI_RESULT_CODE_NOT_FOUND: self = .notFound case PLATFORM_WALLET_FFI_RESULT_CODE_ERROR_UNKNOWN: @@ -177,6 +186,13 @@ public enum PlatformWalletError: LocalizedError { /// notes reserved wallet-side (a shield reserves nothing) until the /// next sync reconciles the outcome. Do NOT auto-retry. case shieldedSpendUnconfirmed(String) + /// A shielded spend could not be built against a Platform-recorded anchor — + /// the wallet's commitment tree isn't synced to a checkpoint Platform has + /// recorded (an in-progress / interrupted sync leaves it mid-block). Nothing + /// was broadcast and the notes were released; retryable once the shielded + /// sync reaches a confirmed state. Distinct from `shieldedSpendUnconfirmed`, + /// which must NOT be retried. + case shieldedNoRecordedAnchor(String) case notFound(String) case unknown(String) @@ -192,6 +208,7 @@ public enum PlatformWalletError: LocalizedError { .arithmeticOverflow(let m), .noSelectableInputs(let m), .walletAlreadyExists(let m), .shieldedBroadcastFailed(let m), .shieldedBroadcastUnconfirmed(let m), .shieldedSpendUnconfirmed(let m), + .shieldedNoRecordedAnchor(let m), .notFound(let m), .unknown(let m): return m } @@ -222,6 +239,7 @@ public enum PlatformWalletError: LocalizedError { case .errorShieldedBroadcastFailed: self = .shieldedBroadcastFailed(detail) case .errorShieldedBroadcastUnconfirmed: self = .shieldedBroadcastUnconfirmed(detail) case .errorShieldedSpendUnconfirmed: self = .shieldedSpendUnconfirmed(detail) + case .errorShieldedNoRecordedAnchor: self = .shieldedNoRecordedAnchor(detail) case .notFound: self = .notFound(detail) case .errorUnknown: self = .unknown(detail) }