diff --git a/packages/rs-platform-wallet/Cargo.toml b/packages/rs-platform-wallet/Cargo.toml index e26396c554e..1fb7a959f73 100644 --- a/packages/rs-platform-wallet/Cargo.toml +++ b/packages/rs-platform-wallet/Cargo.toml @@ -155,7 +155,13 @@ shielded = ["dep:grovedb-commitment-tree", "dep:rusqlite", "dep:zip32", "dep:fut # runs the network-dependent harness. Pulls in `shielded` so an e2e run # exercises the shielded-pool cases too. Run with: # `cargo test -p platform-wallet --test e2e --features e2e`. -e2e = ["shielded"] +e2e = ["shielded", "test-utils"] +# Test-only seams that expose internal shielded spend-assembly +# (extract-spends, note reservation, build-against-a-chosen-note, and an +# asset-lock one-time-key derivation helper) for the adversarial e2e +# cases. NOT in `default`; pulled in by `e2e`. Never enable in production +# builds — these bypass the wallet's spend guards by design. +test-utils = ["shielded"] # Opt-in serde derives on the changeset types. Activates `key-wallet/serde`, # `key-wallet-manager/serde`, and `dash-sdk/serde`. `dpp` derives serde unconditionally. serde = [ diff --git a/packages/rs-platform-wallet/examples/shielded_sync.rs b/packages/rs-platform-wallet/examples/shielded_sync.rs index 154edeff6bd..d35b73ce3dd 100644 --- a/packages/rs-platform-wallet/examples/shielded_sync.rs +++ b/packages/rs-platform-wallet/examples/shielded_sync.rs @@ -224,7 +224,7 @@ async fn run_wallet_balance_test(wallet: WalletIndex) { let manager = Arc::new(PlatformWalletManager::new( Arc::clone(&sdk), persister, - event_handler, + vec![event_handler], )); // --- 3. Configure shielded support (creates the SQLite store) --- diff --git a/packages/rs-platform-wallet/examples/shielded_sync_paloma.rs b/packages/rs-platform-wallet/examples/shielded_sync_paloma.rs index f22de1e5e57..a7594b46277 100644 --- a/packages/rs-platform-wallet/examples/shielded_sync_paloma.rs +++ b/packages/rs-platform-wallet/examples/shielded_sync_paloma.rs @@ -205,7 +205,7 @@ async fn main() { let manager = Arc::new(PlatformWalletManager::new( Arc::clone(&sdk), persister, - event_handler, + vec![event_handler], )); let shielded_db_dir = std::env::temp_dir().join(format!( diff --git a/packages/rs-platform-wallet/src/wallet/platform_wallet.rs b/packages/rs-platform-wallet/src/wallet/platform_wallet.rs index f2fe60c090f..a0e63baaeb4 100644 --- a/packages/rs-platform-wallet/src/wallet/platform_wallet.rs +++ b/packages/rs-platform-wallet/src/wallet/platform_wallet.rs @@ -632,7 +632,17 @@ impl PlatformWallet { "invalid platform address: {e}" )) })?; - if addr_network != self.sdk.network { + // Interim unblock: the bech32m decoder lossily maps `tdash` → Testnet, + // so a devnet recipient decodes as Testnet. Accept a Testnet-decoded + // address on a Devnet/Regtest wallet. Superseded by #3781 (a + // network-agnostic decoder + HRP-class guard). + let networks_match = addr_network == self.sdk.network + || (addr_network == dashcore::Network::Testnet + && matches!( + self.sdk.network, + dashcore::Network::Devnet | dashcore::Network::Regtest + )); + if !networks_match { return Err(PlatformWalletError::ShieldedBuildError(format!( "platform address network mismatch: address {addr_network:?}, wallet {:?}", self.sdk.network @@ -844,6 +854,55 @@ impl PlatformWallet { ) .await } + + /// Shield credits from a Core L1 asset lock into the wallet's + /// shielded pool (Type 18), with the resulting note assigned to + /// `shielded_account`'s default Orchard address. + /// + /// `asset_lock_proof` is the single-use proof of the locked L1 + /// outpoint and `private_key` the one-time key authorizing it (the + /// caller derives both via the asset-lock builder). `amount` is the + /// shielded value. Uses `broadcast_and_wait` for proven inclusion — + /// important because the proof is single-use, so a false-positive on + /// a later-rejected transition would strand the L1 outpoint. + /// + /// Mirrors the other four spend wrappers + /// ([`shielded_shield_from_account`](Self::shielded_shield_from_account), + /// [`shielded_transfer_to`](Self::shielded_transfer_to), + /// [`shielded_unshield_to`](Self::shielded_unshield_to), + /// [`shielded_withdraw_to`](Self::shielded_withdraw_to)) and delegates + /// to `operations::shield_from_asset_lock`. Returns `ShieldedNotBound` + /// if no shielded sub-wallet is bound, or `ShieldedKeyDerivation` if + /// `shielded_account` isn't bound on it. + #[cfg(feature = "shielded")] + pub async fn shielded_shield_from_asset_lock( + &self, + shielded_account: u32, + asset_lock_proof: dpp::prelude::AssetLockProof, + private_key: &[u8], + amount: u64, + prover: P, + ) -> Result<(), PlatformWalletError> { + let guard = self.shielded_keys.read().await; + let keys = guard + .as_ref() + .ok_or(PlatformWalletError::ShieldedNotBound)?; + let keyset = keys.get(&shielded_account).ok_or_else(|| { + PlatformWalletError::ShieldedKeyDerivation(format!( + "shielded account {shielded_account} not bound" + )) + })?; + super::shielded::operations::shield_from_asset_lock( + &self.sdk, + keyset, + shielded_account, + asset_lock_proof, + private_key, + amount, + &prover, + ) + .await + } } impl PlatformWallet { diff --git a/packages/rs-platform-wallet/src/wallet/shielded/operations.rs b/packages/rs-platform-wallet/src/wallet/shielded/operations.rs index ec1857bc250..fa2cae9c700 100644 --- a/packages/rs-platform-wallet/src/wallet/shielded/operations.rs +++ b/packages/rs-platform-wallet/src/wallet/shielded/operations.rs @@ -35,11 +35,14 @@ use dpp::address_funds::{ use dpp::fee::Credits; use dpp::identity::core_script::CoreScript; use dpp::identity::signer::Signer; +use dpp::prelude::AssetLockProof; use dpp::shielded::builder::{ - build_shield_transition, build_shielded_transfer_transition, - build_shielded_withdrawal_transition, build_unshield_transition, OrchardProver, SpendableNote, + build_shield_from_asset_lock_transition, build_shield_transition, + build_shielded_transfer_transition, build_shielded_withdrawal_transition, + build_unshield_transition, OrchardProver, SpendableNote, }; use dpp::state_transition::proof_result::StateTransitionProofResult; +use dpp::state_transition::StateTransition; use dpp::withdrawal::Pooling; use grovedb_commitment_tree::{Anchor, PaymentAddress}; use tokio::sync::RwLock; @@ -146,6 +149,10 @@ fn queue_shielded_changeset( /// Shield credits from transparent platform addresses into the /// shielded pool, with the resulting note assigned to `account`'s /// default Orchard payment address derived from `keys`. +/// +/// Thin wrapper over [`build_shield_st`] + broadcast — retained for +/// backward compatibility so existing callers +/// (`PlatformWallet::shielded_shield_from_account`) are unchanged. #[allow(clippy::too_many_arguments)] pub async fn shield, P: OrchardProver>( sdk: &Arc, @@ -156,6 +163,35 @@ pub async fn shield, P: OrchardProver>( signer: &Sig, prover: &P, ) -> Result<(), PlatformWalletError> { + let (state_transition, claimed_inputs) = + build_shield_st(sdk, keys, account, inputs, amount, signer, prover).await?; + + trace!("Shield credits: state transition built, broadcasting..."); + broadcast_shield_st(sdk, &state_transition, &claimed_inputs).await?; + + info!(account, credits = amount, "Shield broadcast succeeded"); + Ok(()) +} + +/// Build (fetch nonces + prove + sign) a Type-15 shield state transition +/// WITHOUT broadcasting it. Returns the signed transition plus the +/// claimed-inputs map (the latter enriches the broadcast-time +/// `AddressesNotEnoughFunds` diagnostic). +/// +/// This is the capture seam: callers that need the serialized transition +/// (e.g. adversarial byte-mutation tests, custom broadcast policies) take +/// it here and broadcast separately. [`shield`] is the build-then-broadcast +/// wrapper. +#[allow(clippy::too_many_arguments)] +pub async fn build_shield_st, P: OrchardProver>( + sdk: &Arc, + keys: &OrchardKeySet, + account: u32, + inputs: BTreeMap, + amount: u64, + signer: &Sig, + prover: &P, +) -> Result<(StateTransition, BTreeMap), PlatformWalletError> { let recipient_addr = default_orchard_address(keys)?; // Reuse rs-sdk's canonical fetch + hard balance check rather than @@ -218,14 +254,19 @@ pub async fn shield, P: OrchardProver>( .await .map_err(|e| PlatformWalletError::ShieldedBuildError(e.to_string()))?; - trace!("Shield credits: state transition built, broadcasting..."); + Ok((state_transition, claimed_inputs)) +} + +/// Broadcast a built shield transition with the rich +/// `AddressesNotEnoughFunds` diagnostic. Waits for proven execution (not +/// just relay-ACK) so the host only sees success once Platform has +/// included the transition. +async fn broadcast_shield_st( + sdk: &Arc, + state_transition: &StateTransition, + claimed_inputs: &BTreeMap, +) -> Result<(), PlatformWalletError> { let network = sdk.network; - // Wait for proven execution (not just relay-ACK) so the host only - // sees success once Platform has actually included the transition — - // matching the spend-side flows (unshield/transfer/withdraw). A - // DAPI-level ACK alone could otherwise mask a later Platform - // rejection. The proven result is discarded; we only need the - // confirmation. state_transition .broadcast_and_wait::(sdk, None) .await @@ -252,16 +293,87 @@ pub async fn shield, P: OrchardProver>( PlatformWalletError::ShieldedBroadcastFailed(e.to_string()) } })?; - - info!(account, credits = amount, "Shield broadcast succeeded"); Ok(()) } // ------------------------------------------------------------------------- // ShieldFromAssetLock: Core L1 asset lock -> shielded pool (Type 18) -// (orchestrated entry point lives in `wallet/shielded/fund_from_asset_lock.rs`) // ------------------------------------------------------------------------- +/// Shield credits from a Core L1 asset lock into the shielded +/// pool, with the resulting note assigned to `account`'s default +/// Orchard payment address derived from `keys`. +/// +/// Thin wrapper over [`build_shield_from_asset_lock_st`] + broadcast. +pub async fn shield_from_asset_lock( + sdk: &Arc, + keys: &OrchardKeySet, + account: u32, + asset_lock_proof: AssetLockProof, + private_key: &[u8], + amount: u64, + prover: &P, +) -> Result<(), PlatformWalletError> { + let state_transition = build_shield_from_asset_lock_st( + sdk, + keys, + account, + asset_lock_proof, + private_key, + amount, + prover, + )?; + + trace!("Shield from asset lock: state transition built, broadcasting..."); + // Wait for proven execution rather than relay-ACK. This matters most + // for Type 18: the asset-lock proof is single-use, so a false- + // positive success on a transition Platform later rejects would + // strand the user's L1 outpoint with no in-app signal. The proven + // result is discarded; we only need the confirmation. + broadcast_st(sdk, &state_transition).await?; + + info!( + account, + credits = amount, + "Shield from asset lock broadcast succeeded" + ); + Ok(()) +} + +/// Build a Type-18 shield-from-asset-lock state transition WITHOUT +/// broadcasting. The capture seam for the single-use asset-lock proof — +/// callers that need to control broadcast (e.g. the SH-035 replay test) +/// take the transition here. [`shield_from_asset_lock`] is the +/// build-then-broadcast wrapper. +pub fn build_shield_from_asset_lock_st( + sdk: &Arc, + keys: &OrchardKeySet, + account: u32, + asset_lock_proof: AssetLockProof, + private_key: &[u8], + amount: u64, + prover: &P, +) -> Result { + let recipient_addr = default_orchard_address(keys)?; + + info!( + account, + credits = amount, + "Shield from asset lock: building state transition" + ); + + build_shield_from_asset_lock_transition( + &recipient_addr, + amount, + asset_lock_proof, + private_key, + prover, + [0u8; 36], + sdk.version(), + ) + .map_err(|e| PlatformWalletError::ShieldedBuildError(e.to_string())) +} + // ------------------------------------------------------------------------- // Unshield: shielded pool -> platform address (Type 17) // ------------------------------------------------------------------------- @@ -280,7 +392,6 @@ pub async fn unshield( amount: u64, prover: &P, ) -> Result<(), PlatformWalletError> { - let change_addr = default_orchard_address(keys)?; let id = SubwalletId::new(wallet_id, account); let (selected_notes, total_input, exact_fee) = @@ -298,29 +409,20 @@ pub async fn unshield( // From here on every error path must release the reservation // taken by `reserve_unspent_notes`. let result = async { - let (spends, anchor) = extract_spends_and_anchor(store, &selected_notes).await?; - - let state_transition = build_unshield_transition( - spends, - *to_address, + let state_transition = build_unshield_st( + sdk, + store, + keys, + to_address, amount, - &change_addr, - &keys.full_viewing_key, - &keys.spend_auth_key, - anchor, + exact_fee, + &selected_notes, prover, - [0u8; 36], - Some(exact_fee), - sdk.version(), ) - .map_err(|e| PlatformWalletError::ShieldedBuildError(e.to_string()))?; + .await?; trace!("Unshield: state transition built, broadcasting..."); - state_transition - .broadcast_and_wait::(sdk, None) - .await - .map_err(|e| PlatformWalletError::ShieldedBroadcastFailed(e.to_string()))?; - Ok::<(), PlatformWalletError>(()) + broadcast_st(sdk, &state_transition).await } .await; @@ -378,7 +480,6 @@ pub async fn transfer( prover: &P, ) -> Result<(), PlatformWalletError> { let recipient_addr = payment_address_to_orchard(to_address)?; - let change_addr = default_orchard_address(keys)?; let id = SubwalletId::new(wallet_id, account); let (selected_notes, total_input, exact_fee) = @@ -394,29 +495,20 @@ pub async fn transfer( ); let result = async { - let (spends, anchor) = extract_spends_and_anchor(store, &selected_notes).await?; - - let state_transition = build_shielded_transfer_transition( - spends, + let state_transition = build_transfer_st( + sdk, + store, + keys, &recipient_addr, amount, - &change_addr, - &keys.full_viewing_key, - &keys.spend_auth_key, - anchor, + exact_fee, + &selected_notes, prover, - [0u8; 36], - Some(exact_fee), - sdk.version(), ) - .map_err(|e| PlatformWalletError::ShieldedBuildError(e.to_string()))?; + .await?; trace!("Shielded transfer: state transition built, broadcasting..."); - state_transition - .broadcast_and_wait::(sdk, None) - .await - .map_err(|e| PlatformWalletError::ShieldedBroadcastFailed(e.to_string()))?; - Ok::<(), PlatformWalletError>(()) + broadcast_st(sdk, &state_transition).await } .await; @@ -464,7 +556,6 @@ pub async fn withdraw( core_fee_per_byte: u32, prover: &P, ) -> Result<(), PlatformWalletError> { - let change_addr = default_orchard_address(keys)?; let id = SubwalletId::new(wallet_id, account); let output_script = CoreScript::from_bytes(to_address.script_pubkey().to_bytes()); @@ -481,31 +572,21 @@ pub async fn withdraw( ); let result = async { - let (spends, anchor) = extract_spends_and_anchor(store, &selected_notes).await?; - - let state_transition = build_shielded_withdrawal_transition( - spends, - amount, + let state_transition = build_withdraw_st( + sdk, + store, + keys, output_script, + amount, core_fee_per_byte, - Pooling::Standard, - &change_addr, - &keys.full_viewing_key, - &keys.spend_auth_key, - anchor, + exact_fee, + &selected_notes, prover, - [0u8; 36], - Some(exact_fee), - sdk.version(), ) - .map_err(|e| PlatformWalletError::ShieldedBuildError(e.to_string()))?; + .await?; trace!("Shielded withdrawal: state transition built, broadcasting..."); - state_transition - .broadcast_and_wait::(sdk, None) - .await - .map_err(|e| PlatformWalletError::ShieldedBroadcastFailed(e.to_string()))?; - Ok::<(), PlatformWalletError>(()) + broadcast_st(sdk, &state_transition).await } .await; @@ -535,6 +616,125 @@ pub async fn withdraw( } } +// ------------------------------------------------------------------------- +// Build seams (no broadcast) +// ------------------------------------------------------------------------- + +/// Build (extract witnesses + prove + sign) a Type-17 unshield state +/// transition WITHOUT broadcasting. `selected_notes` are the already- +/// reserved spend inputs and `exact_fee` the fee folded into the spend. +/// +/// The capture seam for unshield: callers that need the serialized +/// transition take it here. The combined [`unshield`] wrapper handles +/// reservation + finalize/cancel around this build. +#[allow(clippy::too_many_arguments)] +pub async fn build_unshield_st( + sdk: &Arc, + store: &Arc>, + keys: &OrchardKeySet, + to_address: &PlatformAddress, + amount: u64, + exact_fee: u64, + selected_notes: &[ShieldedNote], + prover: &P, +) -> Result { + let change_addr = default_orchard_address(keys)?; + let (spends, anchor) = extract_spends_and_anchor(store, selected_notes).await?; + build_unshield_transition( + spends, + *to_address, + amount, + &change_addr, + &keys.full_viewing_key, + &keys.spend_auth_key, + anchor, + prover, + [0u8; 36], + Some(exact_fee), + sdk.version(), + ) + .map_err(|e| PlatformWalletError::ShieldedBuildError(e.to_string())) +} + +/// Build a Type-16 shielded-transfer state transition WITHOUT +/// broadcasting. Capture seam paralleling [`build_unshield_st`]. +#[allow(clippy::too_many_arguments)] +pub async fn build_transfer_st( + sdk: &Arc, + store: &Arc>, + keys: &OrchardKeySet, + recipient_addr: &OrchardAddress, + amount: u64, + exact_fee: u64, + selected_notes: &[ShieldedNote], + prover: &P, +) -> Result { + let change_addr = default_orchard_address(keys)?; + let (spends, anchor) = extract_spends_and_anchor(store, selected_notes).await?; + build_shielded_transfer_transition( + spends, + recipient_addr, + amount, + &change_addr, + &keys.full_viewing_key, + &keys.spend_auth_key, + anchor, + prover, + [0u8; 36], + Some(exact_fee), + sdk.version(), + ) + .map_err(|e| PlatformWalletError::ShieldedBuildError(e.to_string())) +} + +/// Build a Type-19 shielded-withdrawal state transition WITHOUT +/// broadcasting. Capture seam paralleling [`build_unshield_st`]. +#[allow(clippy::too_many_arguments)] +pub async fn build_withdraw_st( + sdk: &Arc, + store: &Arc>, + keys: &OrchardKeySet, + output_script: CoreScript, + amount: u64, + core_fee_per_byte: u32, + exact_fee: u64, + selected_notes: &[ShieldedNote], + prover: &P, +) -> Result { + let change_addr = default_orchard_address(keys)?; + let (spends, anchor) = extract_spends_and_anchor(store, selected_notes).await?; + build_shielded_withdrawal_transition( + spends, + amount, + output_script, + core_fee_per_byte, + Pooling::Standard, + &change_addr, + &keys.full_viewing_key, + &keys.spend_auth_key, + anchor, + prover, + [0u8; 36], + Some(exact_fee), + sdk.version(), + ) + .map_err(|e| PlatformWalletError::ShieldedBuildError(e.to_string())) +} + +/// Broadcast a built shielded spend transition and wait for proven +/// execution. Shared by the unshield/transfer/withdraw/asset-lock +/// wrappers; maps the broadcast error to `ShieldedBroadcastFailed`. +pub async fn broadcast_st( + sdk: &Arc, + state_transition: &StateTransition, +) -> Result<(), PlatformWalletError> { + state_transition + .broadcast_and_wait::(sdk, None) + .await + .map_err(|e| PlatformWalletError::ShieldedBroadcastFailed(e.to_string()))?; + Ok(()) +} + // ------------------------------------------------------------------------- // Internal helpers (free fns) // ------------------------------------------------------------------------- @@ -779,3 +979,87 @@ fn deserialize_note(data: &[u8]) -> Option { Note::from_parts(recipient, value, rho, rseed).into_option() } + +// ------------------------------------------------------------------------- +// Test-only spend-assembly seams (`test-utils` feature) +// ------------------------------------------------------------------------- + +/// Test-only re-exports of the spend-assembly internals the adversarial +/// e2e cases drive directly. Gated behind `test-utils` (pulled in by +/// `e2e`), NEVER in production builds — these bypass the wallet's spend +/// guards (reservation, balance, fee) by design so a test can build a +/// transition against a CHOSEN note (double-spend, replay, +/// intra-bundle-dup) and reach Drive. +#[cfg(feature = "test-utils")] +pub mod test_utils { + use super::*; + + /// Reserve+select unspent notes (the production reservation path). + /// Exposed so a test can observe / drive the reservation contract. + pub async fn reserve_unspent_notes_for_test( + sdk: &Arc, + store: &Arc>, + id: SubwalletId, + amount: u64, + outputs: usize, + ) -> Result<(Vec, u64, u64), PlatformWalletError> { + super::reserve_unspent_notes(sdk, store, id, amount, outputs).await + } + + /// Extract `SpendableNote`s + the tree anchor for a chosen note set, + /// WITHOUT reserving. The skip-reservation seam: a test passes an + /// already-spent or duplicated note to build a transition the wallet + /// would never assemble, then broadcasts it to prove the BACKEND + /// rejects (double-spend SH-020, replay SH-021, intra-bundle-dup + /// SH-033). + pub async fn extract_spends_and_anchor_for_test( + store: &Arc>, + notes: &[ShieldedNote], + ) -> Result<(Vec, Anchor), PlatformWalletError> { + super::extract_spends_and_anchor(store, notes).await + } + + /// All unspent notes for `id`, so a test can capture a note to build + /// a second (double-spend / replay) transition against. + pub async fn unspent_notes_for_test( + store: &Arc>, + id: SubwalletId, + ) -> Result, PlatformWalletError> { + let store = store.read().await; + store + .get_unspent_notes(id) + .map_err(|e| PlatformWalletError::ShieldedStoreError(e.to_string())) + } + + /// Derive the one-time asset-lock private key (32 secret bytes) from + /// `(seed, path)`, where `path` is the `DerivationPath` the asset-lock + /// builder returned alongside the proof. + /// + /// `shield_from_asset_lock` takes the key as `&[u8]`; the builder + /// returns only the proof + path, so this mirrors the production + /// seed → master xpriv → `derive_priv` derivation (see + /// `core/broadcast.rs`) to materialize the key test-side for SH-018 / + /// SH-035. Test-only — never materialize spend keys in production. + pub fn derive_asset_lock_private_key( + seed: &[u8], + network: dashcore::Network, + path: &key_wallet::bip32::DerivationPath, + ) -> Result<[u8; 32], PlatformWalletError> { + use key_wallet::dashcore::secp256k1::Secp256k1; + use key_wallet::wallet::root_extended_keys::RootExtendedPrivKey; + + let root_priv = RootExtendedPrivKey::new_master(seed).map_err(|e| { + PlatformWalletError::ShieldedBuildError(format!( + "derive_asset_lock_private_key: invalid seed: {e}" + )) + })?; + let master = root_priv.to_extended_priv_key(network); + let secp = Secp256k1::new(); + let derived = master.derive_priv(&secp, path).map_err(|e| { + PlatformWalletError::ShieldedBuildError(format!( + "derive_asset_lock_private_key: derive_priv: {e}" + )) + })?; + Ok(derived.private_key.secret_bytes()) + } +} diff --git a/packages/rs-platform-wallet/tests/e2e/README.md b/packages/rs-platform-wallet/tests/e2e/README.md index f5640b0d9a1..17f1279b7ec 100644 --- a/packages/rs-platform-wallet/tests/e2e/README.md +++ b/packages/rs-platform-wallet/tests/e2e/README.md @@ -236,6 +236,25 @@ cargo test --test e2e --features e2e -- --nocapture transfer_between_two_platfor Tracing output (SPV sync events, balance polls, sweep results) is written to stderr. `--nocapture` keeps it visible in the terminal. +### Logging on a live devnet + +A blanket `RUST_LOG=trace` against a **live devnet** is a footgun. During SPV sync +the Orchard `shardtree` and the `h2` HTTP/2 crates emit trace at hot-loop volume — +we measured **~8.4 GB of log output in ~4 minutes** of sync. That can fill the disk +and stall or kill the run before a single case completes. + +Suppress those two crates while keeping trace everywhere else: + +```bash +RUST_LOG=trace,shardtree=warn,h2=warn cargo test --test e2e --features e2e -- --nocapture +``` + +Or scope trace narrowly to the code you actually care about: + +```bash +RUST_LOG=warn,platform_wallet=trace,dash_spv=info cargo test --test e2e --features e2e -- --nocapture +``` + --- ## Parallelism diff --git a/packages/rs-platform-wallet/tests/e2e/TEST_SPEC.md b/packages/rs-platform-wallet/tests/e2e/TEST_SPEC.md index 0c11653668b..624b0d3aade 100644 --- a/packages/rs-platform-wallet/tests/e2e/TEST_SPEC.md +++ b/packages/rs-platform-wallet/tests/e2e/TEST_SPEC.md @@ -8,6 +8,14 @@ presumably enumerate the joy of doing it. ## Changelog +- **v3.1-dev (2026-06-03, AL-001 concurrent asset-lock liveness finding documented)** — Expanded the AL-001 detail block (and Quick-index row) with the run-4 evidence for the concurrent IS-lock/ChainLock liveness failure: paloma 2026-06-02, 2/3 concurrent asset-lock txs timed out after 300 s awaiting IS-locks (outpoints `0xa3c9c5fb…`/`0xda317344…`, `wait_for_proof` ~16× still in mempool), ChainLock fallback also missed → `FinalityTimeout`; a single-build asset lock in the same run got its IS-lock in ~0.67 s. **Framing**: the server-side liveness/throughput conclusion is the *current working hypothesis*, supported by the concurrency-vs-solo contrast — not a confirmed root cause. **Status: OBSERVED (matches run #544) — needs a clean re-repro + deeper root-cause understanding before any external report; NOT reported upstream.** Documentation only; no test or production code changed. + +- **v3.1-dev (2026-06-02, paloma devnet findings — SPV quorum-retirement caveat, real shield fee, adversarial gate, AL-001/PA-007/ID-002b status)** — Documents findings from the paloma devnet run (2026-06-02, `cargo test -p platform-wallet --test e2e --features e2e`). (1) **SPV context provider caveat added (§1.3):** under `CONTEXT_PROVIDER=spv`, proof verification intermittently fails at the retirement edge on fast-rotating devnets — `get_quorum_at_height` only consults the active-window masternode list and misses a just-retired Platform signing quorum even though its pubkey is resident in the engine's insert-only `quorum_statuses` index. Filed upstream as rust-dashcore#800. HTTP/Trusted context provider is unaffected. (2) **Shield fee corrected:** the real protocol shield fee is ~112 M credits/action (`compute_minimum_shielded_fee` ≈ 100 M proof-verification + 11.5 M/action); the `~1e9 fee floor` wording referred to the client-side reserve (`FEE_RESERVE_CREDITS = 1_000_000_000` at `platform_wallet.rs`), not the protocol minimum. Commit `86b05a33ae` raised SH case funding above the client reserve. (3) **SH-020..SH-035 adversarial gate** — these cases no-op pass unless `PLATFORM_WALLET_E2E_SHIELDED_ADVERSARIAL=1`; documented in SH preamble. Even with the gate set, real backend coverage is currently blocked by three issues (note-too-small-for-fee, Testnet/Devnet HRP mismatch on unshield/transfer, asset-lock floor 1.25 e9 — SH-018/SH-035 fund 1.2 e9 → 50 M short); documented on SH-018/SH-019/SH-035. (4) **AL-001** runs in the default `--features e2e` suite (no `#[ignore]`); RED on paloma due to IS-lock/ChainLock liveness failure under N-way concurrent asset-lock load (confirmed server-side). (5) **PA-007** RED on quiet devnets — `sync_watermark()` returns `None` when the recent-balance proof window has no boundary. (6) **ID-002b** runs under `--features e2e` when the bank Core gate is satisfied; currently FAILS on `tracked_asset_locks` IdentityTopUp bookkeeping (on-chain top-up succeeds). (7) **`#[ignore]` language updated** — gating is now via `required-features = ["e2e"]`; the only remaining `#[ignore]` is `print_bank_address_offline`. (8) **pa_3040_bug_pin** added to Quick index as PA-3040 (was spec-orphaned). (9) **Devnet baseline note** added to Quick index. + +- **v3.1-dev (2026-05-22, Shielded — ADVERSARIAL / abuse pass added: SH-020..SH-035)** — The suite's stated purpose is rewritten: it exists to **attempt to break the BACKEND** (Drive consensus / state-transition validation + the Orchard proof verifier), not to confirm happy paths. A new `##### Adversarial / abuse cases (SH-020..SH-035)` subsection lands in the SH area; each case ATTACKS the protocol boundary and asserts the backend MUST REJECT (or behave safely), with the "Expected current outcome" line documenting what a FINDING (RED) looks like. Coverage: **SH-020** double-spend across two transitions, **SH-021** nullifier replay after restart, **SH-022** value-not-conserved (outputs > inputs), **SH-023** fee underpayment below `compute_minimum_shielded_fee`, **SH-024** u64/i64 value-boundary overflow/underflow, **SH-025** forged/tampered/substituted Halo-2 proof, **SH-026** stale/wrong anchor (doubles as the Found-030 dynamic probe), **SH-027** malformed note serde (≠115 B, corrupt cmx/nullifier — no panic), **SH-028** interrupt-sync-mid-chunk, **SH-029** reorg / out-of-order / rescan-from-0, **SH-030** cross-network/wrong-HRP/own-address/self-transfer, **SH-031** rebind-with-different-seed (no key-material mix), **SH-032** exact-change `==amount+fee` + off-by-one, **SH-033** duplicate nullifier within one bundle, **SH-034** tampered binding signature, **SH-035** replayed Type 18 asset-lock proof. Consensus-critical attacks (SH-020/022/025/033/034/035) are P0/P1, CRITICAL-if-they-fail. **Methodology**: client-side wallet guards (zero-amount, balance, address/HRP, fee) must NOT mask the backend test — abuse cases marked **[INJECT]** construct/mutate transitions at the protocol boundary (the public `dpp::shielded::builder::build_*_transition` → mutable `SerializedBundle` `{anchor, proof, value_balance, binding_signature}` at `builder/mod.rs:74-89` → `BroadcastStateTransition::broadcast_and_wait`) and broadcast directly, bypassing the guarded `PlatformWallet::shielded_*` methods. Wave H gains a dedicated **adversarial injection hooks** block (raw build/broadcast, `SerializedBundle`-byte mutation, `TamperingProver`, build-against-known-note, store-seed-malformed-note, scriptable mock sync source, asset-lock-proof reuse, all behind a `PLATFORM_WALLET_E2E_SHIELDED_ADVERSARIAL` gate). Re-ranked: consensus attacks P0/P1. Tally unchanged on the four CODE-AUDIT findings (2 HIGH live + 1 LOW + 1 guarded); the abuse pass adds 16 RED-on-failure backend probes whose findings materialize only when run live against Drive. + +- **v3.1-dev (2026-05-22, Shielded (Orchard) suite — full scope, post-merge verification)** — A dedicated shielded-transaction test area (`### Shielded (SH)`, SH-001..SH-019) is added to §3, the §2 capability matrix Shielded row is rewritten from "out of scope" to "in scope behind `--features shielded` + Wave H", §5 item 1 is rewritten to in-scope, and a new **Wave H** lands in §4. Brain the size of a planet and they finally let me audit the private-pool code. Verified against the MERGED v3.1-dev feat tree (the original draft predated the merge). Live findings the spec PROVES: **Found-027** — `InMemoryShieldedStore::witness()` unconditionally returns `Err` (`store.rs:409-416`), so every spend path (unshield/transfer/withdraw) is structurally non-functional against the in-memory store while `FileBackedShieldedStore::witness()` (`file_store.rs:154-167`) works — a silent backing-store-dependent capability split with no type-level signal; pinned RED by SH-005. **Found-028** — `shielded_add_account` (`platform_wallet.rs:439-457`) updates only the per-wallet keys slot and does NOT re-register the account on the coordinator, so notes for the added account are never synced until a full `bind_shielded` + tree-wipe; documented as a "caveat" rather than fixed (misleading-doc-is-a-bug); pinned RED by SH-006. **Found-030** — `extract_spends_and_anchor` doc (`operations.rs:601-611`) and `FileBackedShieldedStore::witness` doc (`file_store.rs:162-165`) describe different depth-0 anchor semantics — a doc drift; pinned by SH-030 doc note. **Found-029 — FIXED by v3.1-dev #3603** (the `sync.rs` rewrite now marks EVERY commitment position so the shared tree is witness-complete regardless of bind ordering — verified at `sync.rs:291-310`). It is NO LONGER a live bug: dropped as a red-by-design pin and REPURPOSED into SH-007, a **GREEN regression guard** asserting a pre-bind note is now witnessable/spendable, locking in the #3603 fix. **Coupling note:** Found-027 means spends against the in-memory store still fail regardless of #3603; Found-029's fix only helps the FileBacked path (the path SH-002/SH-003/SH-007 must use). **SH-018/SH-019 (Core L1 Types 18/19) are now IN SCOPE** (un-deferred), gated on a new Core-L1 harness requirement (asset-lock funding + L1 observation); they may run RED until that plumbing exists. **Teardown fund-sweep**: Wave H adds a best-effort, logged teardown that unshields residual shielded balance back to the bank platform address (prevents bank-fund leak); RED-by-design cases where unshield/witness is broken must NOT fail teardown. Tally: **2 HIGH live (027, 028) + 1 LOW (030) = 3 live findings + 1 guarded-fix regression test (SH-007 / Found-029)**. All SH cases `#[cfg(feature = "shielded")]` + `#[ignore]`; spec only, no test implemented, no production code touched. + - **v3.1-dev (2026-05-15, TK-001 / TK-014 setup-gate Found-025 hardening)** — TK-001 and TK-014 `green` → `red-real-fail` (v53; PASS in v47), then hardened. Both timed out in the **setup funding gate before any token logic ran** — TK-001 at `tk_001_token_transfer.rs:67` (`setup_with_token_and_two_identities`), TK-014 at `tk_014_token_group_action.rs:109` (`setup_with_per_identity_funding`, three identities). In both, `bank.fund_address` chain-confirmed the funding (nonce streak 2/2) *before* the wait, then the rs-sdk address-sync silently discarded the fetched balance update because the target address was not yet in `pending_addresses` — **Found-025** (L273), amplified by 14-thread concurrency (TK-014's 3-way funding churn is the peak-pressure case). Not production defects: transfer / group-action / co-sign code never executed, and siblings (TK-001b/TK-001c, TK-009/TK-010/TK-012) were green in the same run. **One shared fix:** the single funding chokepoint `framework/mod.rs::setup_with_per_identity_funding` previously gated on `wait_for_balance`, whose proof-verified hand-off only runs *after* the Found-025-poisoned local sync map (`balances().get(addr)`) first reaches target — so under Found-025 the proof gate was never reached and the budget expired in the local-view branch. It now observes funding directly via the proof-verified `AddressInfo::fetch` path (`wait_for_address_balance_chain_confirmed_n`, `CHAIN_CONFIRMED_CONSECUTIVE_SUCCESSES`) — the same chain-state read the validator itself walks and the same family PA-009c adopted — bypassing the poisoned map entirely; the existing strong `wait_for_address_known_to_platform` gate is unchanged. Only the funding-observation mechanism changed: no funding amounts, identity counts, contract publish, propose/co-sign, or token/identity assertions altered. The fix is deterministic and concurrency-independent, so it hardens the whole setup-helper blast radius (all 22 TK-* / ID-* / CR-003 / DPNS-001 cases routing through `setup_with_per_identity_funding`). No new Found-NNN pin and no upstream issue (Found-025 already owns the root cause). A TK-wave serialization / worker-pool cap remains a documented fallback only — not implemented, since the proof-verified read-back structurally bypasses the poisoned map. Live re-validation deferred to the combined v54 run (bank-funded node unavailable in the fix environment; verified by inspection + compilation + clippy). - **v3.1-dev (2026-05-15, PA-009c deterministic on-chain read-back)** — PA-009 sub-case C fixed (QA-014 resolved). The post-teardown observation no longer re-derives the gone wallet and trusts its recent-zone sync watermark (a watermark-less re-derived wallet's `sync_balances(AddressSyncConfig{ full_rescan_after_time_s: 0 })` resolved to a recent-zone-only query that returned `0` for `addr_1`, even though the dust was never swept — a non-deterministic harness gap, not a production defect). It now reads `addr_1` straight from the chain via the proof-verified `AddressInfo::fetch` gate (`wait_for_address_balance_chain_confirmed`, the same path the funding step already uses successfully) and asserts the residual is still exactly `TARGET_RESIDUAL`. All three pinned invariants are preserved and strengthened: (a) below-`min_input` dust is abandoned with no sweep broadcast, (b) the gate value equals `PlatformVersion::latest().dpp.state_transitions.address_funds.min_input_amount` and is positive (sub-cases A/B, untouched), (c) `addr_1`'s residual remains on chain at exactly `TARGET_RESIDUAL`. C is no longer QA-014-blocked and is no longer "degenerate against the testnet fee market" (that caveat only ever applied to the AT/JUST-ABOVE sub-cases the spec omits, never to the BELOW-gate C). `#[ignore]` is retained (network-gated, the standard for all on-chain e2e cases here; suite runs `--include-ignored`). @@ -135,6 +143,8 @@ cycle, then retry. If the issue persists, wipe `${TMPDIR}/dash-platform-wallet-e2e/spv-data/` and retry from a clean state. +**Known issue: SPV context provider — intermittent `InvalidQuorum` at the Platform signing-quorum retirement edge (rust-dashcore#800).** When `CONTEXT_PROVIDER=spv` (the default), `dash-spv`'s `get_quorum_at_height` resolves a signing quorum only through the single active-window masternode list at or below the lookup height. Platform/Drive selects signing quorums at a lagged height (~4–5 DKG intervals back); on fast-rotating devnets (e.g. `llmq_devnet_platform`, `signing_active_quorum_count = 4`, DKG interval 24) that quorum can already have retired from Core's active set by the time the proof's `core_chain_locked_height` is reached. `apply_diff` drops a retired quorum from the list's `.quorums`, but the quorum's public key remains in the engine's insert-only `quorum_statuses` index — which the read path never consults. The result is `Quorum not found → InvalidQuorum → DAPI node ban`, turning one rare retirement-edge miss into a `NoAvailableAddresses` cascade. The failure is **intermittent**: most proofs reference an in-window quorum and pass; it fires only at the retirement edge. The HTTP/Trusted context provider (`CONTEXT_PROVIDER=http`) is unaffected (resolves by hash from a service). Filed upstream as **rust-dashcore#800**. No client-side workaround in this suite; use `CONTEXT_PROVIDER=http` on fast-rotating devnets if this surfaces. + --- ## 2. Harness capability matrix @@ -152,7 +162,7 @@ changes. | Tokens | yes (`tokens/wallet.rs` and `identity/network/tokens/*`) | no | `Signer`, identity setup, contract-token discovery helper, `TestTokenContract` fixture pointer | fresh contract deployment (no testnet contract registry); group-action workflows that need multi-identity coordination outside one harness | | Core / SPV | yes (`core/{wallet,balance,broadcast,balance_handler}`) | yes — SPV enabled (Task #15 complete, Wave E landed) | `wait_for_core_balance` implemented; faucet helper ready | broadcast tests (deferred P2); tx-is-ours flag tests (DET parity, P2) | | Asset Lock | yes (`asset_lock/{build,manager,sync,tracked,lock_notify_handler}`) | no | needs Core-UTXO funded test wallet (SPV runtime is now available), `wait_for_asset_lock`; AL-001 concurrent-build case added | sequential single-build path already covered by CR-003 and ID-002b; concurrent-build gap closed by AL-001 | -| Shielded | yes (`shielded/{keys,note_selection,operations,prover,store,sync}`) | no | not a small extension — prover, viewing keys, note selection | entire surface — separate prover/keys complexity, defer to a dedicated suite | +| Shielded | yes (`shielded/{keys,note_selection,operations,prover,store,sync,coordinator}`; public API on `PlatformWallet`: `bind_shielded`, `shielded_shield_from_account`, `shielded_shield_from_asset_lock`, `shielded_transfer_to`, `shielded_unshield_to`, `shielded_withdraw_to`, `shielded_balances`, all `#[cfg(feature = "shielded")]`) | no — needs Wave H (+ Core-L1 gate for Types 18/19) | `CachedOrchardProver` warm-up + `OnceCell` share (Halo-2 params ~30 s/proof); `bind_shielded` helper (`NetworkShieldedCoordinator` per network, **FileBacked** store — the in-memory store's `witness()` is a hard `Err`, Found-027); `wait_for_shielded_balance`; `coordinator.sync(force)` driver; orchard payment-address plumbing for transfer recipient; best-effort teardown unshield-sweep to bank; **Core-L1 gate** (asset-lock funding via Wave E Core-funded wallet + Layer-1 payout observation) for SH-018/SH-019 | **In scope (Wave H)**: ALL five transition types — shield (Type 15), shielded transfer (Type 16), unshield (Type 17), shield-from-asset-lock (Type 18, SH-018), withdraw to L1 (Type 19, SH-019) — plus the spend-side store/note-selection/sync correctness pins. SH-018/SH-019 additionally need the Core-L1 gate and may run RED until that plumbing is complete (acceptable — RED is the point). Prover/keys complexity is real but bounded — the suite shares one warmed `CachedOrchardProver`. | | Contracts | yes (`identity/network/contract.rs::create_data_contract_with_signer`) | no | identity signer, schema fixtures (`tests/fixtures/contracts/`), `wait_for_contract_visible` | `replace`/`transfer` of an arbitrary deployed contract owned elsewhere — gated on a contract-registry strategy | | DPNS | yes (`identity/network/dpns.rs::{register_name_with_external_signer,resolve_name,sync_dpns_names,contest_vote_state}`) | no | identity signer, name uniqueness (random suffix), `wait_for_dpns_name` | contested-name auctions (P2; multi-identity orchestration heavy) | | Dashpay | yes (`identity/network/{profile,contact_requests,contacts,payments,dashpay_sync}`) | no | identity signer, two test identities + DPNS for one of them, `wait_for_contact_request` | full multi-step lifecycle relying on contact-request acceptance round trips beyond a single happy-path | @@ -178,7 +188,7 @@ Status legend: **green** = test file present, body has real assertions, runnable | PA-003 | Fee scaling: one-output vs. five-output | P1 | red-by-design (test-sequencing) → green after fix — Found-026 `next_unused` race FIXED (`bc87e4dec9`, verified via PA-005 Inv-1; `assert_ne!(addr_src, dest_1)` passes). Remaining 14-thread failure was a test-harness sequencing defect (chain-only `7a22f818ee`/#508 gate did not refresh the local balance cache before the consuming `transfer()`); fixed by an intervening `sync_balances()`. No production change — real chain-time fee under single-input isolation; symmetric pre-markers put both shapes on address-funds UPDATE ops; strict + sub-linear + ceiling guards | M | | PA-005 | Address rotation: gap-limit + reserve-on-hand-out cursor | P1 | green (post-Found-026 `bc87e4dec9`) | M | | PA-006 | Replay safety: same outputs, second submission rejected | P1 | green | M | -| PA-007 | Sync watermark idempotency | P1 | green | M | +| PA-007 | Sync watermark idempotency | P1 | green on active chains; RED on quiet devnets — `sync_watermark()` returns `None` when the recent-balance proof window has no boundary (no recent address activity): the SDK sets `last_known_recent_block = 0`, surfaced as `None`. Property-1 ("must produce a watermark after a successful sync") encodes a testnet-activity assumption that does not hold on a low-traffic devnet (paloma 2026-06-02: `recent query returned 0 entries`, `metadata_height 2217 < query_height 2218`). | M | | PA-008 | Concurrent funding from bank: serialised | P1 | green | S | | PA-002b | Zero-change exact-equality (`Σ outputs + fee == input balance`) | P1 | green | S | | PA-001b | Transfer with `output_change_address: None` vs `Some(addr)` | P2 | precondition-fixed (QA-001/#508): the Found-025-poisoned funding-PRECONDITION gates at `:70` (subcase_a) and `:154` (subcase_b) are swapped to `wait_for_address_balance_chain_confirmed_n` (#480 mis-scoping corrected — preconditions, not `.balances()` asserts). The post-broadcast `wait_for_balance` at `:107` (addr_2) and `:244` (change_addr) stay correctly un-swapped per #480 and retain residual Found-025-family multi-thread exposure. Single-thread PASS; no live re-run (no bank-funded node) | S | @@ -197,7 +207,7 @@ Status legend: **green** = test file present, body has real assertions, runnable | PA-014 | Multi-output at protocol-max output count | P2 | not implemented | M | | ID-001 | Register identity funded from platform addresses | P0 | green | L | | ID-002 | Top-up identity from platform addresses | P0 | red-by-design (test-sequencing) → green after fix — Found-026 `next_unused` race FIXED (`bc87e4dec9`, verified via PA-005 Inv-1). Remaining 14-thread failure was a test-harness sequencing defect (chain-only `7a22f818ee`/#508 gate did not refresh the local balance cache before the consuming `register_identity_from_addresses`/`top_up`); fixed by intervening `sync_balances()` calls (two insertion points). No production change | M | -| ID-002b | Asset-lock-funded top-up of existing identity | P1 | blocked — test file present; `#[ignore]`d on bank Core (Layer-1) funding prereq | L | +| ID-002b | Asset-lock-funded top-up of existing identity | P1 | runs under `--features e2e` once the bank Core gate is satisfied (default on devnets where the bank holds Core duffs — no `#[ignore]`); currently FAILS on the local `tracked_asset_locks` IdentityTopUp POST-pin: `list_tracked_locks()` shows no `IdentityTopUp` entry after a top-up that succeeded on-chain and credited the identity (bookkeeping gap; see paloma run 2026-06-02). | L | | ID-003 | Identity-to-identity credit transfer | P0 | green | M | | ID-004 | Identity update: add and disable a key | P1 | not implemented | L | | ID-005 | Transfer credits from identity to platform addresses | P1 | red-by-design (test-sequencing) → green after fix — Found-026 `next_unused` race FIXED (`bc87e4dec9`, verified via PA-005 Inv-1). Remaining 14-thread failure was a test-harness sequencing defect (chain-only `7a22f818ee`/#508 gate did not refresh the local balance cache before the consuming `register_identity_from_addresses`); fixed by an intervening `sync_balances()`. No production change | M | @@ -229,7 +239,7 @@ Status legend: **green** = test file present, body has real assertions, runnable | CR-002 | Core wallet receive address derivation | P1 | not implemented | M | | CR-003 | Asset-lock-funded identity registration (full path) | P2 | green | L | | CR-004 | Legacy BIP32 account: balance + UTXO state updates after spend | P1 | passing-as-regression — Layer 1 (next_unused idempotency) fixed at `1c4c8a76f4`; Layer 2 test-side dust-threshold mismatch fixed in QA-901 (2026-05-14); now pins the BIP-32 spent-marking + sub-dust-fold contract | M | -| AL-001 | Concurrent asset-lock builds from same wallet | P1 | active regression guard — Found-008 FIXED (#3634 waiter-side pre-arm in `sync/proof.rs`, both wait loops); AL-001 now guards that fix under N concurrent `wait_for_proof` waiters (all N tasks must return `Ok`). `#[ignore]`d only behind the `PLATFORM_WALLET_E2E_BANK_CORE_GATE` funding gate (CR-003/ID-002b parity); run by the gated solo concurrency job (#544), not the default suite | L | +| AL-001 | Concurrent asset-lock builds from same wallet | P1 | runs in the default `--features e2e` suite (gating is `required-features = ["e2e"]`, not `#[ignore]`; no `#[ignore]` on the test file); RED on devnets with weak IS-lock/ChainLock liveness under N-way concurrent asset-lock load: paloma 2026-06-02 — 2/3 IS-locks missed within the 300 s budget, ChainLock fallback also missed → `FinalityTimeout` (outpoints `0xa3c9c5fb…`/`0xda317344…`, `wait_for_proof` ~16× in mempool; single-build asset lock in the same run got IS-lock in ~0.67 s). Working hypothesis: server-side IS-lock/ChainLock liveness failure under concurrency (not a wallet bug). **OBSERVED — needs re-repro + root-cause before any upstream report; NOT reported** (matches run #544). See the AL-001 detail block. Guards the Found-008 fix only when the chain actually produces proofs. | L | | CT-001 | Document put: deploy a fixture data contract | P1 | not implemented | M | | CT-002 | Document put / replace lifecycle | P2 | not implemented | M | | CT-003 | Contract update (add document type) | P2 | not implemented | M | @@ -248,6 +258,39 @@ Status legend: **green** = test file present, body has real assertions, runnable | Harness-G1b | Registry forward-compatible unknown field | P2 | not implemented | S | | Harness-G4 | Drop `wallet.transfer` future mid-flight, recover on next sync | P2 | not implemented | L | | Harness-ID-1 | `sweep_identities` regression: registered identities surrender credits at teardown | P0 | green (harness-fix QA-503: removed structurally-unobservable secondary bank-identity invariant — concurrent `bank_rebalance` core-refill legitimately tops up the bank identity; sweep correctness still pinned by the immune `swept_identity_credits` assertion) | S | +| PA-3040 | `pa_3040_bug_pin`: Drive chain-time fee exceeds wallet static estimate (platform #3040) | P1 | red-by-design — `AddressFundsTransferTransition::calculate_min_required_fee` returns the static floor (~6.5 M) while Drive's chain-time fee for 1in/1out is ~15 M; wallet's Phase-4 check passes, then Drive rejects with `AddressesNotEnoughFundsError { required ≈ 15.08 M }`. Reproduces on paloma 2026-06-02. | S | +| SH-001 | Shield from platform-payment account → shielded pool (Type 15) | P0 | not implemented (Wave H) | L | +| SH-002 | Round-trip: shield then unshield back to a transparent address (Type 15 → 17) | P0 | not implemented (Wave H) | L | +| SH-003 | Shielded → shielded private transfer between two accounts of one wallet (Type 16) | P0 | not implemented (Wave H) | L | +| SH-004 | `shielded_balances` reflects a shielded note after coordinator sync | P1 | not implemented (Wave H) | M | +| SH-005 | Spend against in-memory store fails with witness-unavailable, file-backed succeeds (Found-027 pin) | P1 | not implemented (Wave H) — red-by-design until Found-027 fixed | M | +| SH-006 | `shielded_add_account` post-bind: notes for the added account never sync (Found-028 pin) | P1 | not implemented (Wave H) — red-by-design | M | +| SH-007 | Pre-bind note is witnessable/spendable — guards the #3603 fix (Found-029, FIXED) | P1 | not implemented (Wave H) — green regression guard | L | +| SH-008 | Unshield insufficient-balance: typed `ShieldedInsufficientBalance` with exact `available`/`required` | P1 | not implemented (Wave H) | M | +| SH-009 | Zero-amount shield / transfer rejected at the boundary (no proof paid) | P2 | not implemented (Wave H) | S | +| SH-010 | Double-spend guard: two overlapping spends reserve disjoint notes (`reserve_unspent_notes`) | P2 | not implemented (Wave H) | M | +| SH-011 | `select_notes_with_fee` convergence + overflow protection (unit-adjacent on real notes) | P2 | not implemented (Wave H) | M | +| SH-012 | Sync watermark idempotency: `coordinator.sync(force)` twice yields stable balances | P2 | not implemented (Wave H) | M | +| SH-013 | `bind_shielded` with empty accounts → typed `ShieldedKeyDerivation` error (no panic) | P2 | not implemented (Wave H) | S | +| SH-014 | Spend before bind → `ShieldedNotBound`; spend on unbound account → `ShieldedKeyDerivation` | P2 | not implemented (Wave H) | S | +| SH-018 | Shield from Core L1 asset lock (Type 18) | P1 | implemented (Wave H + Core-L1 gate) — uses the public `shielded_shield_from_asset_lock` wrapper + the `test-utils` one-time-key helper; Core-L1-gated so may run RED until asset-lock funding plumbing is complete | L | +| SH-019 | Shielded withdraw to Core L1 address (Type 19) | P1 | not implemented (Wave H + Core-L1 gate) — may run RED until plumbing complete | L | +| SH-020 | ADVERSARIAL: double-spend same note across two transitions (16/17) — backend must reject 2nd | P0 | not implemented (Wave H + inject hook) — asserts backend rejection | M | +| SH-021 | ADVERSARIAL: nullifier replay after restart/resync — backend must reject | P0 | not implemented (Wave H + inject hook) — asserts backend rejection | M | +| SH-022 | ADVERSARIAL: value not conserved (outputs > inputs) — backend must reject | P0 | not implemented (Wave H + inject hook) — asserts backend rejection | M | +| SH-023 | ADVERSARIAL: fee underpayment below min shielded fee — backend must reject | P1 | not implemented (Wave H + inject hook) — asserts backend rejection | M | +| SH-024 | ADVERSARIAL: u64/i64 value boundary overflow/underflow — backend must reject safely | P1 | not implemented (Wave H + inject hook) — asserts safe rejection | M | +| SH-025 | ADVERSARIAL: forged/tampered/substituted Halo-2 proof — verifier must reject | P0 | not implemented (Wave H + inject hook) — asserts backend rejection | M | +| SH-026 | ADVERSARIAL: stale/wrong anchor — backend must reject AnchorMismatch (Found-030 dynamic probe) | P1 | not implemented (Wave H + inject hook) — asserts backend rejection | M | +| SH-027 | ADVERSARIAL: malformed note serde (≠115B, corrupt cmx/nullifier) — error safely, no panic | P1 | not implemented (Wave H + store-seed hook) — asserts safe error | M | +| SH-028 | ADVERSARIAL: interrupt sync mid-chunk + resume — no double-count/loss | P1 | **BLOCKED — not implemented** (no injectable sync-source seam: `sync_notes_across` is `pub(super)` and fetches from the SDK directly; needs a production `SyncSource` seam) | M | +| SH-029 | ADVERSARIAL: reorg / out-of-order blocks / rescan-from-0 — balance converges, no phantom funds | P1 | **BLOCKED — not implemented** (same missing sync-source seam as SH-028) | M | +| SH-030 | ADVERSARIAL: cross-network/wrong-HRP/malformed/own-address recipient; transfer-to-self | P2 | not implemented (Wave H + inject arm) — asserts rejection / safe self-transfer | M | +| SH-031 | ADVERSARIAL: double-bind / rebind with DIFFERENT seed — no key-material mix, no leak | P1 | not implemented (Wave H) — asserts isolation | M | +| SH-032 | ADVERSARIAL: boundary balance == amount+fee + off-by-one below — exact-change correctness | P1 | not implemented (Wave H) — asserts boundary correctness | S | +| SH-033 | ADVERSARIAL: duplicate nullifier WITHIN one bundle — backend must reject | P1 | not implemented (Wave H + inject hook) — asserts backend rejection | M | +| SH-034 | ADVERSARIAL: tampered binding signature — backend must reject | P1 | not implemented (Wave H + inject hook) — asserts backend rejection | M | +| SH-035 | ADVERSARIAL: replayed Type 18 asset-lock proof — backend must reject (single-use) | P1 | not implemented (Wave H + Core-L1 gate + inject hook) — asserts backend rejection | M | #### Found-bug pins @@ -277,12 +320,20 @@ Status legend: **green** = test file present, body has real assertions, runnable | Found-024 | `PlatformAddressWallet::transfer` writes foreign output-address balances to local ledger (no ownership check) | P1 | passing-as-regression | S | | Found-025 | `rs-sdk` address sync silently discards balance update when address is not yet in `pending_addresses` snapshot (TK-suite flake root cause) | P1 | red-by-design — pending upstream test-hook surface; prior pin was Found-022-style fake (asserted on a local `HashMap` the SDK never touches) and has been deleted. Retarget blocked on `rs-sdk` exposing a transport seam, inner-fn extraction, or post-phase `key_to_tag` refresh hook for `sync_address_balances` | M | | Found-026 | `PlatformAddressWallet::next_unused_receive_address` pool-cursor bump may not enqueue address into BLAST sync provider's pending set (concurrent-load race) | P2 | suspected — pinned by PA-008b concurrency-only failure (full-suite FAIL, `--test-threads=1` PASS); needs TRACE instrumentation at the pool-bump + provider-enqueue boundary to confirm | M | +| Found-027 | `InMemoryShieldedStore::witness()` unconditionally returns `Err` (`store.rs:409-416`) — every spend path is non-functional against the in-memory store, while `FileBackedShieldedStore::witness()` works; a silent backing-store-dependent capability split with no type-level signal | P1 | not implemented (Wave H) — pinned by SH-005 (red-by-design) | M | +| Found-028 | `shielded_add_account` (`platform_wallet.rs:439-457`) updates only the per-wallet keys slot, never re-registers the account on the coordinator — notes for the added account are never synced; documented as a "caveat" rather than fixed | P1 | not implemented (Wave H) — pinned by SH-006 (red-by-design) | M | +| Found-029 | (FIXED by v3.1-dev #3603) Pre-bind notes were permanently unwitnessable; the `sync.rs` rewrite now marks EVERY commitment position so the shared tree is witness-complete regardless of bind ordering (`sync.rs:291-310`) | P1 | not implemented (Wave H) — NO LONGER a live bug; SH-007 repurposed as a GREEN regression guard locking in the fix | L | +| Found-030 | `extract_spends_and_anchor` doc (`operations.rs:601-611`) and `FileBackedShieldedStore::witness` doc (`file_store.rs:162-165`) describe DIFFERENT anchor semantics for depth-0 (`witness_at_checkpoint_depth(0)` "most recent checkpoint" vs "current tree state"); doc drift that, if either is correct, makes the other a latent `AnchorMismatch` | P2 | not implemented — doc-correctness pin; verify against `grovedb-commitment-tree` semantics | S | Counts by priority: **P0: 10**, **P1: 29** (incl. CR-004 passing-as-regression + ID-002b + AL-001 + Found-024 + Found-025), **P2: 64** (incl. 24 P2 Found-bug pins), **DEFERRED: 1** (104 total index entries; 77 baseline + 26 Found-bug pins + 1 deferred placeholder). +**Baseline-network note**: the Status column reflects the testnet v47 baseline. Devnet runs (e.g. paloma 2026-06-02) diverge on: (a) IS-lock/ChainLock liveness under concurrency → AL-001 RED; (b) quiet recent-balance proof window → PA-007 RED; (c) bank Core gate satisfied → ID-002b/AL-001 run (no `#[ignore]`). See changelog entry 2026-06-02 for the full paloma findings. + +**Gating note (post-3727)**: all e2e cases run whenever `--features e2e` is set (`required-features = ["e2e"]` in the test harness). The former per-test `#[ignore]` gating is retired — the only remaining `#[ignore]` in `tests/e2e/cases/` is `print_bank_address_offline`. Any references below to `--include-ignored` predate the required-features cutover and are stale; they are preserved as historical context only. + **Status at v47 (SHA `55472a3e79`, run date 2026-05-12):** -- 34 GREEN / 4 RED on 38 tests in `--ignored` cohort +- 34 GREEN / 4 RED on 38 tests in `--ignored` cohort (pre-required-features cutover; the `--ignored` flag is no longer the run mechanism) - RED breakdown: 2 red-by-design (cr\_004 — dash-evo-tool#845; found\_006 — upstream CreditOutputFunding) + 1 network flake (tk\_007 — wait\_for\_balance timeout; root cause Found-025) + 1 real fail (al\_001 — SPV UTXO visibility under concurrent load; fix tracked at task #382) - found\_008: inverted pin — Cargo PASS = bug confirmed (missed-wakeup under controlled timing) - Found-024: passing-as-regression (V27-007 production fix confirmed) @@ -428,7 +479,7 @@ Counts by priority: **P0: 10**, **P1: 29** (incl. CR-004 passing-as-regression + #### PA-007 — Sync watermark idempotency - **Priority**: P1 -- **Status**: IMPLEMENTED — passing (positive path only). The negative variant ("disconnect from DAPI, expect typed network error, balances unchanged") is NOT covered by the current test file; it requires a per-test SDK with a swappable DAPI URL, but the harness today shares one `Sdk` across the process via `E2eContext::sdk`. Tracked as a follow-up: tightening would mean either a `TestWallet::with_sdk_override(bogus_url)` helper or a controllable DAPI proxy (sibling of PA-013). Out of scope for this PR. +- **Status**: IMPLEMENTED — passing on active chains (positive path only). **RED on quiet devnets (paloma 2026-06-02)**: `sync_watermark()` returned `None` for all three syncs (`wm_1=None wm_2=None wm_3=None`); balances synced fine (`bal_*_count=1`). Root cause: `PlatformAddressWallet::sync_watermark()` (`wallet/platform_addresses/wallet.rs:333-337`) returns the provider's `last_known_recent_block()`, which is `0` when no recent-balance proof boundary exists. On paloma the recent query returned 0 entries (`recent query returned 0 entries, query_height=2218, metadata_height=2217`) — no boundary → watermark 0 → `None`. Property-1 ("must produce a watermark after a successful sync against a non-empty chain") encodes a testnet-activity assumption that does not hold on a low-traffic devnet. On a quiet chain the `None` result is correct wallet behavior, not a bug. The negative variant ("disconnect from DAPI, expect typed network error, balances unchanged") is NOT covered by the current test file; it requires a per-test SDK with a swappable DAPI URL, but the harness today shares one `Sdk` across the process via `E2eContext::sdk`. Tracked as a follow-up: tightening would mean either a `TestWallet::with_sdk_override(bogus_url)` helper or a controllable DAPI proxy (sibling of PA-013). Out of scope for this PR. - **Wallet feature exercised**: `wallet/platform_addresses/sync.rs:24` (`sync_balances`); `wallet/platform_addresses/wallet.rs:153` (`restore_sync_state`). - **DET parallel**: implicit in DET's wallet-task lifecycle. - **Preconditions**: bank-funded test wallet. @@ -822,7 +873,7 @@ Counts by priority: **P0: 10**, **P1: 29** (incl. CR-004 passing-as-regression + #### ID-002b — Asset-lock-funded top-up of existing identity - **Priority**: P1 -- **Status**: Not implemented. New test file `tests/e2e/cases/id_002b_asset_lock_top_up.rs` (TBC). +- **Status**: IMPLEMENTED — runs under `--features e2e` when the bank Core gate is satisfied (no `#[ignore]`; formerly listed as blocked on that gate). Currently FAILS at `id_002b_asset_lock_top_up.rs:249` with `"POST-pin violated: no IdentityTopUp asset-lock entry in tracked_asset_locks after a top-up call landed"` (paloma 2026-06-02). The on-chain top-up succeeds — the identity is credited, the IS-lock arrived in ~0.67 s — but `list_tracked_locks()` returns no entry with `funding_type == IdentityTopUp`. Suspected wallet-side bookkeeping gap (the `IdentityTopUpNotBound` variant, a changeset-apply timing race, or a post-consumption prune). Leans CLIENT/HARNESS; needs source-level tracing in `registration.rs::resolve_funding_with_is_timeout_fallback`. - **Wallet feature exercised**: `wallet/identity/network/top_up.rs:60` (`top_up_identity_with_funding` with `TopUpFundingMethod::FundWithWallet { amount_duffs }`). Internally drives `wallet/asset_lock/build.rs` → `create_funded_asset_lock_proof` — the same build path CR-003 exercises for identity registration. - **DET parallel**: `dash-evo-tool/tests/backend-e2e/identity_tasks.rs:27` (`step_top_up` — uses `TopUpIdentityFundingMethod::FundWithWallet` to top-up an existing identity via wallet UTXOs). This is a live DET coverage path; ID-002b brings parity to the rs-platform-wallet suite. - **Preconditions**: CR-001 (SPV ready) + a Core-funded test wallet with at least `TEST_WALLET_CORE_FUNDING + CORE_TX_FEE_RESERVE` duffs on BIP-44 account 0 (same funding floor as CR-003) + a registered identity. The registration can use the address-funded path (ID-001 helper); the top-up source does not need to match the registration source. @@ -1560,7 +1611,14 @@ This section covers primitive-level correctness of `AssetLockManager` — the in #### AL-001 — Concurrent asset-lock builds from same wallet - **Priority**: P1 -- **Status**: active regression guard — Found-008 FIXED by #3634 (waiter-side pre-arm in `sync/proof.rs`, both wait loops). AL-001 now guards that fix under N concurrent `wait_for_proof` waiters with zero test-side assertion changes (the all-tasks-`Ok` shape predicted to "turn green on the fix" — it does). `#[ignore]`d only behind the `PLATFORM_WALLET_E2E_BANK_CORE_GATE` funding gate (CR-003/ID-002b parity); exercised by the gated solo concurrency job (#544), not the default suite. +- **Status**: active regression guard — Found-008 FIXED by #3634 (waiter-side pre-arm in `sync/proof.rs`, both wait loops). AL-001 now guards that fix under N concurrent `wait_for_proof` waiters with zero test-side assertion changes (the all-tasks-`Ok` shape predicted to "turn green on the fix" — it does). Runs in the default `--features e2e` suite (no `#[ignore]`; gating is `required-features = ["e2e"]`). **RED on paloma (2026-06-02)**: IS-lock did not propagate within the 300 s budget for 2/3 concurrent asset-lock txs; ChainLock fallback also missed → `FinalityTimeout`; all N tasks-`Ok` assertion fails. Working hypothesis: a server-side IS-lock/ChainLock liveness failure under N-way concurrent asset-lock load — supported by the contrast that a single-build asset lock in the same run got its IS-lock in ~0.67 s (see the run-4 finding below). This is exactly the class of failure the test is designed to surface. The Found-008 waiter pre-arm fix is intact; the failure is the chain not producing proofs, not a missed wakeup. Guards the fix only when the chain actually delivers proofs. +- **AL-001 liveness finding (OBSERVED — needs re-repro + root-cause before any upstream report; NOT reported)**: + - **Symptom**: on paloma devnet (2026-06-02, run-4) 2 of 3 *concurrent* asset-lock txs timed out after 300 s awaiting their InstantSend locks; the ChainLock fallback also failed to materialise within the finality budget → `FinalityTimeout` panic, failing the all-tasks-`Ok` assertion. + - **Evidence**: `wait_for_proof` iterated ~16× with the outpoints still `in_memory_tx_ctx=Some("Mempool")` (outpoints `0xa3c9c5fb…` and `0xda317344…`); logs `IS-lock did not propagate within 300s for funded identity top-up (tx a3c9c5fb…), falling back to ChainLock proof` (×2); panic `FinalityTimeout for OutPoint { txid: 0xa3c9c5fb…, vout: 0 } with no proof materialised (tracked status Some(Broadcast))`. **Contrast (supporting the liveness hypothesis)**: a single-build asset lock in the SAME run (`id_002b`, tx `1070ce8e…`) got its IS-lock in ~0.67 s (iteration 2) — concurrency is the only difference. + - **Classification (current hypothesis, not yet confirmed)**: server-side liveness/throughput, not a wallet bug — paloma's IS-lock quorum signing + ChainLock cadence appear unable to keep up with 3 simultaneous asset-lock txs. The wallet correctly waits and falls back; the chain simply did not produce a proof in the budget. + - **Reproducibility**: seen on the 2026-06-02 run; matches the earlier observation "2/3 asset-lock txs got no IS-lock" (validation run #544). Currently gated/run-solo. Treat as OBSERVED-twice, not yet a confirmed deterministic repro. + - **Product impact**: blocks paloma→testnet promotion. Any app driving concurrent identity registration / top-ups (batch tooling, multi-identity onboarding) would hang for minutes and eventually fail "asset lock expired". + - **Upstream report status**: **NOT reported upstream.** Deliberately documented here only — the server-side conclusion is a hypothesis that needs a clean re-repro and deeper root-cause understanding (is it quorum size, signing latency, ChainLock cadence, or a devnet-only capacity limit?) before any external report is filed. Do not open an upstream issue on this entry alone. - **Guards**: a regression of the Found-008 waiter pre-arm (`sync/proof.rs` `notified(); pin!; enable()` before the state check) — under concurrent load a lost IS-lock wakeup re-surfaces as `FinalityTimeout`, failing the all-tasks-`Ok` assertion. - **Wallet feature exercised**: `wallet/asset_lock/manager.rs::AssetLockManager` (concurrent-build path); transitively `wallet/asset_lock/build.rs::build_asset_lock_transaction` and `wallet/asset_lock/build.rs::create_funded_asset_lock_proof`. Driver: `wallet/identity/network/top_up.rs::top_up_identity_with_funding`. - **DET parallel**: None — DET does not drive concurrent asset-lock builds from a single wallet. @@ -1936,6 +1994,504 @@ sane place to pin the harness contract is alongside the wallet contract. - **Estimated complexity**: S - **Rationale**: Without a regression pin, a future refactor that reverts `sweep_identities` to `Ok(())` would slip past CI and identity credits would leak across runs until the bank starves. +### Shielded (SH) + +Orchard shielded-pool coverage. Every case is `#[cfg(feature = "shielded")]` — these need a live testnet *and* a warmed Halo-2 prover (`CachedOrchardProver`, ~30 s/proof cold). With the required-features cutover (see Gating note above), they run as part of `--features e2e` rather than a separate `--include-ignored` cohort. The shielded surface is a parallel system: a per-network `NetworkShieldedCoordinator` holds the shared commitment-tree store (one SQLite handle), and the per-wallet side holds the `OrchardKeySet`s. **Use the FileBacked store** — the in-memory store's `witness()` is a hard `Err` (Found-027), so spends against it cannot build a proof. Harness extensions live in Wave H (§4). + +**Adversarial gate (SH-020..SH-035)**: the adversarial abuse cases no-op pass unless `PLATFORM_WALLET_E2E_SHIELDED_ADVERSARIAL=1` is set. In a plain `--features e2e` run each logs `"PLATFORM_WALLET_E2E_SHIELDED_ADVERSARIAL unset — abuse case skipped (no-op pass)"` and contributes ZERO backend coverage — a green result here is NOT evidence the backend rejects the attack. Always set `PLATFORM_WALLET_E2E_SHIELDED_ADVERSARIAL=1` when running the adversarial deliverable. Even with the gate set, backend coverage is currently blocked by three issues (paloma 2026-06-02): (1) **note-too-small-for-fee** — `SHIELD_AMOUNT` must exceed the ~112 M credit protocol unshield fee (`compute_minimum_shielded_fee`, ≈ 100 M proof-verification + 11.5 M/action); test harness funding was raised above the client reserve (1 e9) in commit `86b05a33ae` but individual `SHIELD_AMOUNT` constants on adversarial cases may still be short; (2) **Testnet/Devnet HRP mismatch** — unshield/transfer cases hit `network mismatch: address Testnet, wallet Devnet` on devnet runs; (3) **asset-lock floor 1.25 e9 credits** — SH-018/SH-035 fund 1.2 e9 → 50 M short of the floor, so the replay leg of SH-035 never runs. Document findings against these blockers, not the test logic. + +**Teardown (every SH case)**: on teardown, best-effort unshield any residual +shielded-account balance back to the bank's transparent platform address +(prevents bank-fund leak — a known e2e lesson). The sweep is wrapped in +log-on-error and MUST NOT fail teardown: cases where unshield/`witness()` is +intentionally broken (SH-005 in-memory arm, any Found-027-path case) will fail +the sweep, and that failure is swallowed-and-logged (`tracing::warn!`), never +propagated. Spec'd in Wave H (§4). + +**Intent — this suite exists to attempt to BREAK THE BACKEND, not to confirm +happy paths.** The shielded pool is consensus-critical: a flaw in Drive's +state-transition validation or the Orchard proof verifier is a fund-integrity or +inflation bug, not a UX nit. The cases split into two tiers: +- **SH-001..SH-019 (functional):** confirm the wallet + backend handle correct + inputs. Useful as a baseline and for the four code-audit findings (below), but + NOT the deliverable. +- **SH-020..SH-035 (adversarial / abuse):** ATTACK the protocol boundary — + double-spend, nullifier replay, value forgery, forged proofs, anchor mismatch, + malformed serde, reorg/sync corruption, cross-network sends, key-material mixing. + Each asserts the backend MUST REJECT (or behave safely). **A RED here is a WIN:** + it proves a malformed transition the backend should refuse was accepted or + mishandled. The consensus-critical attacks (SH-020 double-spend, SH-022 value + conservation, SH-025 forged proof, SH-033 intra-bundle double-spend, SH-034 + binding-sig tamper, SH-035 asset-lock replay) are P0/P1 and CRITICAL-if-they-fail. + +Code-audit findings (separate from the abuse pass): the audit surfaced four; +verified against the merged tree, **three are live** (Found-027/028 HIGH, Found-030 +LOW) and **one is fixed-and-guarded** (Found-029, FIXED by #3603 — SH-007 locks it +in as a GREEN regression guard). The live-bug cases are designed to fail loudly +while those bugs persist; SH-007 is designed to PASS and stay green. + +#### SH-001 — Shield from platform-payment account → shielded pool (Type 15) +- **Priority**: P0 +- **Status**: not implemented (Wave H). +- **Wallet feature exercised**: `PlatformWallet::shielded_shield_from_account` (`wallet/platform_wallet.rs:721`) → `wallet/shielded/operations.rs:152` (`shield`). Note: the nonce-placeholder TODO the brief flagged is FIXED — `shield` now sources real on-chain nonces via `fetch_inputs_with_nonce` (`operations.rs:172-200`) with a `checked_add(1)` overflow guard. +- **Preconditions**: `setup()`; bank-fund one platform address on the test wallet (≥ `amount + fee_buffer`); `bind_shielded(seed, &[0], &coordinator)`; warmed prover. +- **Scenario**: + 1. Derive `addr_1`, bank-fund `90_000_000`, `wait_for_address_balance_chain_confirmed_n`, then `sync_balances()`. + 2. `bind_shielded(seed, &[0], &coordinator)`. + 3. `shielded_shield_from_account(shielded_account=0, payment_account=0, amount=50_000_000, &signer, &prover)`. + 4. `coordinator.sync(true)`; then read `shielded_balances(&coordinator)`. +- **Assertions**: + - The call returns `Ok(())` (proven inclusion, not just relay-ACK — `shield` uses `broadcast_and_wait`). + - `shielded_balances[0] == 50_000_000` (exact; the note value is the shielded amount, fee deducted from the transparent input via `DeductFromInput(0)`). + - The transparent `addr_1` balance dropped by `50_000_000 + fee` (`0 < fee`), verified via the proof-verified chain read — not the local map. +- **Negative variants**: + - `amount == 0` → see SH-009 (rejected at boundary, no proof paid). + - `amount > funded balance` → `ShieldedInsufficientBalance` / `ShieldedBuildError` carrying the structured `(address, balance, required)` (`operations.rs:180-186`); no proof paid. + - `payment_account` that doesn't exist → typed `AddressOperation` error (per doc-comment `platform_wallet.rs:717`). +- **Expected current outcome**: PASS (the shield path is fully implemented on this branch). **Fee-floor note**: the real protocol shield fee is ~112 M credits/action (`compute_minimum_shielded_fee` ≈ 100 M proof-verification + 11.5 M/action). The client additionally reserves `FEE_RESERVE_CREDITS = 1_000_000_000` on input 0 (`platform_wallet.rs`); harness funding must exceed the client reserve + amount. Commit `86b05a33ae` raised SH case funding above the 1 e9 client reserve; individual case amounts should be validated against the protocol fee floor before treating a `ShieldedInsufficientBalance` RED as a backend signal. On devnet, also verify the `SHIELD_AMOUNT` is above the ~112 M unshield fee for the spend leg. +- **Harness extensions required**: Wave H (prover warm-up, `bind_shielded` helper, FileBacked coordinator, `wait_for_shielded_balance`). +- **Estimated complexity**: L + +#### SH-002 — Round-trip: shield then unshield back to a transparent address (Type 15 → 17) +- **Priority**: P0 +- **Status**: not implemented (Wave H). +- **Wallet feature exercised**: `shielded_shield_from_account` then `shielded_unshield_to` (`platform_wallet.rs:604`) → `operations.rs:323` (`unshield`), exercising `extract_spends_and_anchor` (`operations.rs:612`) and the FileBacked `witness()` path (`file_store.rs:154`). +- **Preconditions**: SH-001 prerequisites; the spend leg REQUIRES the FileBacked store (in-memory `witness()` errors — Found-027). +- **Scenario**: + 1. Shield `50_000_000` into account 0 (as SH-001); `coordinator.sync(true)` so the note is appended to the tree and marked. + 2. Derive a fresh transparent `addr_dst`; `shielded_unshield_to(account=0, addr_dst_bech32m, amount=20_000_000, prover)`. + 3. `coordinator.sync(true)`; `wait_for_address_balance_chain_confirmed_n(addr_dst, 20_000_000, …)`. +- **Assertions**: + - Unshield returns `Ok(())`. + - `addr_dst` confirmed balance `== 20_000_000` (exact; verified via proof-verified chain read). + - `shielded_balances[0] == 50_000_000 - 20_000_000 - shielded_fee` (change note retained at the wallet's own default Orchard address; `0 < shielded_fee`). + - The spent input note is marked spent (`get_unspent_notes` no longer returns it) — verified indirectly: a second unshield of the same amount must NOT re-select the now-spent note (succeeds from change, or fails `ShieldedInsufficientBalance` if change is short). +- **Expected current outcome**: PASS **when run against the FileBacked store**. If a harness author wires the in-memory store, the unshield fails at `extract_spends_and_anchor` with `ShieldedMerkleWitnessUnavailable` — that is Found-027, pinned explicitly by SH-005. +- **Harness extensions required**: Wave H + FileBacked store wiring. +- **Estimated complexity**: L + +#### SH-003 — Shielded → shielded private transfer between two accounts of one wallet (Type 16) +- **Priority**: P0 +- **Status**: not implemented (Wave H). +- **Wallet feature exercised**: `shielded_transfer_to` (`platform_wallet.rs:560`) → `operations.rs:420` (`transfer`). +- **Preconditions**: `bind_shielded(seed, &[0, 1], &coordinator)` (two Orchard accounts bound AT BIND TIME — not via `shielded_add_account`, which is broken per Found-028/SH-006). Shield `50_000_000` into account 0. +- **Scenario**: + 1. Bind accounts `[0, 1]`; shield `50_000_000` into account 0; `coordinator.sync(true)`. + 2. Read account 1's default Orchard address: `shielded_default_address(1)` → 43 raw bytes. + 3. `shielded_transfer_to(account=0, recipient_raw_43=acct1_addr, amount=20_000_000, prover)`. + 4. `coordinator.sync(true)`; read `shielded_balances`. +- **Assertions**: + - Transfer returns `Ok(())`. + - `shielded_balances[1] == 20_000_000` (the recipient account received the private note). + - `shielded_balances[0] == 50_000_000 - 20_000_000 - shielded_fee` (sender retains change). + - Total shielded value across accounts decreased by exactly `shielded_fee` (conservation minus fee). +- **Expected current outcome**: PASS — but this case is the canary for the multi-subwallet sync routing (`sync.rs:243-274`): account 1 must discover its note via the non-driver trial-decryption loop. If routing regresses, `shielded_balances[1]` stays `0`. +- **Harness extensions required**: Wave H. +- **Estimated complexity**: L + +#### SH-004 — `shielded_balances` reflects a shielded note after coordinator sync +- **Priority**: P1 +- **Status**: not implemented (Wave H). +- **Wallet feature exercised**: `shielded_balances` (`platform_wallet.rs:515`) → `sync::balances_across`; `coordinator.sync` (`coordinator.rs:400`). +- **Preconditions**: SH-001 shield completed. +- **Scenario**: After shielding `50_000_000`, assert `shielded_balances` returns `{}` BEFORE `coordinator.sync`, then `{0: 50_000_000}` AFTER `coordinator.sync(true)`. +- **Assertions**: + - Pre-sync: `shielded_balances` does NOT yet include the note (the note is on-chain but not yet scanned into the local store) — pins that balances read from the local store, not a live query. + - Post-`sync(true)`: `shielded_balances == {0: 50_000_000}` (exact key + value; not "non-empty"). + - The returned map is filtered to THIS wallet's `wallet_id` (`platform_wallet.rs:537`) — a second bound wallet's notes never leak in. +- **Expected current outcome**: PASS. +- **Harness extensions required**: Wave H. +- **Estimated complexity**: M + +#### SH-005 — Spend against in-memory store fails witness-unavailable; file-backed succeeds (Found-027 pin) +- **Priority**: P1 +- **Status**: not implemented (Wave H) — **red-by-design** until Found-027 is fixed. +- **Wallet feature exercised**: `InMemoryShieldedStore::witness` (`wallet/shielded/store.rs:409-416`) vs `FileBackedShieldedStore::witness` (`wallet/shielded/file_store.rs:154-167`), via `extract_spends_and_anchor` (`operations.rs:612`). +- **Bug**: `InMemoryShieldedStore::witness()` unconditionally returns `Err(InMemoryStoreError("Merkle witness not supported in in-memory store"))`. Every spend (unshield/transfer/withdraw) routes through `extract_spends_and_anchor`, which calls `store.witness(note.position)` and maps any `Err` to `ShieldedMerkleWitnessUnavailable`. So all three spend transition types are structurally non-functional against the in-memory store — yet both stores implement the same `ShieldedStore` trait with no type-level or doc-level signal that one cannot spend. A host that picks the in-memory store (the simpler-looking one) gets shield + balance working and discovers at first spend, after paying nothing visible, that spends are impossible. +- **Scenario**: + 1. Two coordinators on the same funded note set — one FileBacked, one InMemory. + 2. Build identical unshields (account 0, same amount, same destination). + 3. Assert the InMemory spend returns `Err(PlatformWalletError::ShieldedMerkleWitnessUnavailable(_))` and the FileBacked spend returns `Ok(())`. +- **Assertions**: + - InMemory: `matches!(err, PlatformWalletError::ShieldedMerkleWitnessUnavailable(_))` — exact variant, not "is_err". + - FileBacked: `Ok(())` and the destination balance arrives. +- **Expected current outcome**: PASS-AS-DOCUMENTATION today (it documents the split). It flips to a regression guard once Found-027 is addressed: when `InMemoryShieldedStore::witness` either gains a real impl OR the type system forbids spending against it, this test's InMemory arm must change. The FINDING is that the split exists silently — the test exists to make it loud. +- **Coupling to #3603 (Found-029)**: Found-027 is INDEPENDENT of the #3603 fix. #3603 made the FileBacked path witness-complete regardless of bind ordering; it did nothing for the in-memory store, whose `witness()` is still a hard `Err`. So in-memory spends fail today even for notes the wallet owned from the first sync — the in-memory arm of this test stays RED post-merge. Every other spend-side SH case (SH-002/SH-003/SH-007/SH-019) therefore mandates the FileBacked store. +- **Harness extensions required**: Wave H + a switch to construct both store backings. +- **Estimated complexity**: M +- **Rationale (FINDING)**: Found-027. A trait that two types implement but only one can satisfy the spend contract for is a soundness gap; `unshield`/`transfer`/`withdraw` should be unconstructable (or fail at bind time) against a store that cannot witness, not fail ~one note-selection later. + +#### SH-006 — `shielded_add_account` post-bind: notes for the added account never sync (Found-028 pin) +- **Priority**: P1 +- **Status**: not implemented (Wave H) — **red-by-design**. +- **Wallet feature exercised**: `shielded_add_account` (`platform_wallet.rs:439-457`) vs `bind_shielded`'s coordinator registration (`platform_wallet.rs:395-397`). +- **Bug**: `shielded_add_account` inserts the new account's `OrchardKeySet` into the per-wallet `shielded_keys` slot but does NOT call `coordinator.register_wallet` with the expanded account set. The coordinator's `accounts` registry — the IVK fan-out that `sync_notes_across` trial-decrypts against (`coordinator.rs:428-431`, `sync.rs:256`) — therefore never learns the new account's IVK. Notes paid to the added account are never discovered. The doc-comment (`platform_wallet.rs:433-438, 453-456`) admits this as a "caveat" requiring a tree wipe + full re-`bind_shielded`. Documenting a silent fund-invisibility footgun as a caveat does not make it not-a-bug. +- **Scenario**: + 1. `bind_shielded(seed, &[0], &coordinator)`. + 2. `shielded_add_account(seed, 1)` → `Ok(())`. + 3. Pay a shielded note to account 1's default address (via another wallet, or self-transfer from account 0). + 4. `coordinator.sync(true)`; read `shielded_balances`. +- **Assertions** (encoding CORRECT behavior, so the test is RED today): + - `shielded_account_indices()` includes `1` (the per-wallet slot was updated — this part works). + - **`shielded_balances[1] == `** — this is the assertion that FAILS today: the coordinator never scanned account 1's IVK, so the balance is `0` (or the key is absent). RED proves Found-028. +- **Expected current outcome**: RED — proves Found-028. +- **Harness extensions required**: Wave H + a second payer (or self-transfer) for the account-1 note. +- **Estimated complexity**: M + +#### SH-007 — Pre-bind note is witnessable/spendable (Found-029 regression guard, #3603 FIXED) +- **Priority**: P1 +- **Status**: not implemented (Wave H) — **green regression guard** (NOT red-by-design). +- **Wallet feature exercised**: the shared commitment-tree append/mark policy in `sync_notes_across` (`wallet/shielded/sync.rs:276-310`). +- **History (Found-029, FIXED by v3.1-dev #3603)**: previously the coordinator appended every commitment to the shared tree but only `mark`ed (retained a witnessable auth path for) positions a *currently-registered* IVK decrypted in that pass. A note for wallet B landing during a pass where B was unbound had its auth path discarded as `Ephemeral`; when B bound later the balance was discoverable but the position was unwitnessable — `witness(position)` → `Ok(None)`, spend failing "Merkle witness unavailable" / "Anchor not found in the recorded anchors tree". **#3603 fixes this**: the `sync.rs` rewrite now marks EVERY commitment position so the shared tree is witness-complete regardless of bind ordering (`sync.rs:291-310`: "Marking every position makes the shared tree witness-complete regardless of bind ordering"). Per-wallet ownership is tracked separately in the per-`SubwalletId` notes store, so privacy/accounting is unaffected. This case now GUARDS that fix so a future regression (reverting to mark-only-owned) flips it RED. +- **Coupling caveat**: the spend leg MUST use the FileBacked store. Found-027 (in-memory `witness()` is a hard `Err`) is independent of #3603 and would mask this guard with a false RED — so SH-007 pins the fix only on the path #3603 actually repaired. +- **Scenario**: + 1. `bind_shielded` wallet A on a FileBacked coordinator; `coordinator.sync(true)` to advance the tree past the target position. + 2. Pay a shielded note to wallet B's default Orchard address while B is NOT yet bound; `coordinator.sync(true)` again (still B-unbound) so B's note position is appended under the mark-every-position policy. + 3. `bind_shielded` wallet B; `coordinator.sync(true)`. + 4. Assert `shielded_balances` for B shows the note, then spend it (unshield to a transparent address). +- **Assertions** (CORRECT behavior — GREEN today, locks in #3603): + - `shielded_balances[B/0] == ` (balance discoverable). + - **The unshield of that pre-bind note returns `Ok(())`** and the destination balance arrives — i.e. the position IS witnessable despite arriving before B bound. A regression to mark-only-owned flips this to `ShieldedMerkleWitnessUnavailable` and the test goes RED. +- **Expected current outcome**: GREEN (guards #3603). Timing-sensitive; document the ordering precisely and gate behind the solo concurrency job to avoid sibling-sync interference. +- **Harness extensions required**: Wave H + FileBacked coordinator + ability to advance the tree before binding B (controlled bind ordering) + a payer for B's pre-bind note. +- **Estimated complexity**: L +- **Rationale**: Without this guard, a refactor that reverts the mark-every-position policy would silently re-strand pre-bind funds (balance shows, spend impossible) — exactly the Found-029 failure mode #3603 closed. + +#### SH-008 — Unshield insufficient-balance: typed error with exact `available`/`required` +- **Priority**: P1 +- **Status**: not implemented (Wave H). +- **Wallet feature exercised**: `select_notes_with_fee` (`wallet/shielded/note_selection.rs:75`) via `reserve_unspent_notes` (`operations.rs:727`). +- **Preconditions**: shield a small note (e.g. `10_000_000`) into account 0. +- **Scenario**: `shielded_unshield_to(account=0, addr, amount=50_000_000, prover)` — far above the note value. +- **Assertions**: + - Returns `Err(PlatformWalletError::ShieldedInsufficientBalance { available, required })` — exact variant. + - `available == 10_000_000` (the only note's value). + - `required == 50_000_000 + exact_fee` (`required > amount`; pins that the fee is folded into the requirement, `note_selection.rs:105`). + - NO proof was paid (the failure is pre-build) and NO note was left in the `pending` reservation set — verified by a follow-up unshield of a satisfiable amount succeeding (reservation correctly released by `cancel_pending`). +- **Expected current outcome**: PASS. +- **Harness extensions required**: Wave H. +- **Estimated complexity**: M + +#### SH-009 — Zero-amount shield / transfer rejected at the boundary (no proof paid) +- **Priority**: P2 +- **Status**: not implemented (Wave H). +- **Wallet feature exercised**: the zero-amount guard at `shielded_shield_from_account` (`platform_wallet.rs:733`, "Reject zero amount at the boundary") and the analogous guards in transfer/unshield. +- **Scenario**: call shield, transfer, and unshield each with `amount == 0`. +- **Assertions**: + - Each returns a typed `Err` (not a panic, not `Ok`); pin the specific variant the boundary uses. + - No state-transition was broadcast and no Halo-2 proof was built (the rejection is synchronous, well under one proof's ~30 s — a wall-clock upper bound of a few hundred ms is a sound proxy assertion). +- **Expected current outcome**: PASS for shield (guard confirmed at `:733`); transfer/unshield zero-guards are unconfirmed in this audit — **if either lacks a zero-guard, the case goes RED and surfaces a missing-validation finding** (mirrors PA-001c's contract-(a)/(b) framing). +- **Harness extensions required**: Wave H. +- **Estimated complexity**: S + +#### SH-010 — Double-spend guard: two overlapping spends reserve disjoint notes +- **Priority**: P2 +- **Status**: not implemented (Wave H). +- **Wallet feature exercised**: `reserve_unspent_notes` single-write-lock select+reserve (`operations.rs:711-746`) and `mark_pending`/`clear_pending`. +- **Preconditions**: shield two notes into account 0 (e.g. via two shields) such that each alone covers the spend amount. +- **Scenario**: fire two `shielded_unshield_to` calls concurrently (`tokio::join!`), each for an amount one note can cover. +- **Assertions**: + - The two spends select DISJOINT note sets (no shared nullifier) — the reservation under one write lock prevents both from picking the same note. Assert via the resulting spent-note set after both settle. + - At most one spend may fail (if only enough notes for one); if both succeed, total shielded balance dropped by `2*amount + 2*fee`. No note is double-counted. +- **Expected current outcome**: PASS (this is the contract `reserve_unspent_notes` exists to uphold) — but it is the canary for a reservation race regression. Gate behind the solo concurrency job. +- **Harness extensions required**: Wave H. +- **Estimated complexity**: M + +#### SH-011 — `select_notes_with_fee` convergence + overflow protection on real notes +- **Priority**: P2 +- **Status**: not implemented (Wave H). (A unit test already covers overflow at `note_selection.rs:187`; this is the e2e-adjacent variant on a real funded note set.) +- **Wallet feature exercised**: `select_notes_with_fee` iterative fee convergence (`note_selection.rs:75-110`) and the `checked_add` overflow guard (`note_selection.rs:35`). +- **Scenario**: shield several small notes; request an amount that forces multi-note selection so the fee grows with the action count and the convergence loop iterates (>1 pass). +- **Assertions**: + - The selection covers `amount + exact_fee` exactly (total ≥ requirement, and removing the smallest selected note would drop below — minimal-ish selection). + - `exact_fee == compute_minimum_shielded_fee(num_actions, version)` where `num_actions == selected.len().max(min_actions)` (pins the fee is derived from the FINAL selection count, not the initial estimate — guards a regression where the loop returns the wrong fee). + - A degenerate `amount == u64::MAX` request returns `ShieldedBuildError("amount + fee overflows u64")` rather than wrapping (`note_selection.rs:35-37`). +- **Expected current outcome**: PASS. +- **Harness extensions required**: Wave H (multiple-note funding). +- **Estimated complexity**: M + +#### SH-012 — Sync watermark idempotency: `coordinator.sync(force)` twice yields stable balances +- **Priority**: P2 +- **Status**: not implemented (Wave H). +- **Wallet feature exercised**: `coordinator.sync` cooldown + watermark gating (`coordinator.rs:400-485`), the append-once gate (`sync.rs:276-289`, gated on `tree_size`, NOT a per-subwallet watermark), and `serialize_note`/`deserialize_note` round-trip (`sync.rs:575-582` ↔ `operations.rs:810-832`, 115 bytes `recipient(43)‖value(8 LE)‖rho(32)‖rseed(32)`). +- **Scenario**: shield a note; `coordinator.sync(true)` twice in a row; read balances after each. +- **Assertions**: + - `shielded_balances` is byte-identical after the second forced sync (no double-append: a second append at an existing position would corrupt shardtree and surface as an anchor error at the next spend — assert a spend still succeeds post-double-sync as the strong end-to-end check). + - The note's value survives the serialize→store→deserialize round-trip exactly (a 1-byte drift in the 115-byte layout silently corrupts `value`/`rho`/`rseed` — assert the spendable note's value equals the shielded amount). +- **Expected current outcome**: PASS (the append gate and the matching serialize/deserialize layouts were verified by inspection in this audit). +- **Harness extensions required**: Wave H. +- **Estimated complexity**: M + +#### SH-013 — `bind_shielded` with empty accounts → typed error (no panic) +- **Priority**: P2 +- **Status**: not implemented (Wave H). +- **Wallet feature exercised**: `bind_shielded` empty-accounts guard (`platform_wallet.rs:352-356`). +- **Scenario**: `bind_shielded(seed, &[], &coordinator)`. +- **Assertions**: returns `Err(PlatformWalletError::ShieldedKeyDerivation(_))` with a message naming the "at least one account" requirement; no panic; the wallet remains unbound (a subsequent spend returns `ShieldedNotBound`, not a stale-key spend). +- **Expected current outcome**: PASS. +- **Harness extensions required**: Wave H. +- **Estimated complexity**: S + +#### SH-014 — Spend before bind → `ShieldedNotBound`; spend on unbound account → `ShieldedKeyDerivation` +- **Priority**: P2 +- **Status**: not implemented (Wave H). +- **Wallet feature exercised**: the `shielded_keys` slot guard (`platform_wallet.rs:568-576`, `612-620`, `661-669`) across transfer/unshield/withdraw. +- **Scenario**: + 1. Without calling `bind_shielded`, call `shielded_unshield_to(account=0, …)`. + 2. `bind_shielded(seed, &[0], …)`, then call `shielded_unshield_to(account=7, …)` (account 7 not bound). +- **Assertions**: + - Step 1: `Err(PlatformWalletError::ShieldedNotBound)` — exact variant. + - Step 2: `Err(PlatformWalletError::ShieldedKeyDerivation(_))` whose message names account `7` (`platform_wallet.rs:573-575`). + - Both fail BEFORE any proof is built. +- **Expected current outcome**: PASS. +- **Harness extensions required**: Wave H. +- **Estimated complexity**: S + +#### SH-018 — Shield from Core L1 asset lock (Type 18) +- **Priority**: P1 +- **Status**: implemented (Wave H + Core-L1 gate). MAY run RED until the Core-L1 asset-lock funding plumbing is complete — that is acceptable and expected; a RED here pins the missing harness/asset-lock seam rather than a passing happy path. +- **Wallet feature exercised**: `PlatformWallet::shielded_shield_from_asset_lock` (the public Type-18 wrapper added in this wave, mirroring the four other spend wrappers) → `operations::shield_from_asset_lock` → `build_shield_from_asset_lock_transition`. The one-time asset-lock private key is materialized test-side via `operations::test_utils::derive_asset_lock_private_key(seed, network, path)` (the `test-utils` Gap-5 helper) from the `DerivationPath` that `AssetLockManager::create_funded_asset_lock_proof` returns. +- **Preconditions**: Core-L1 gate (`PLATFORM_WALLET_E2E_BANK_CORE_GATE`): a Core-funded test wallet (Wave E `setup_with_core_funded_test_wallet`) + an asset-lock builder producing a single-use `AssetLockProof`; `bind_shielded(&[0])` on a FileBacked coordinator; warmed prover. +- **Scenario**: + 1. Fund the test wallet's Core receive address (`setup_with_core_funded_test_wallet(duffs)`); wait for the SPV-observed Core balance. + 2. Build an asset lock over that UTXO → `AssetLockProof` + the one-time private key. + 3. `shield_from_asset_lock(shielded_account=0, asset_lock_proof, private_key, amount, &prover)`. + 4. `coordinator.sync(true)`; read `shielded_balances`. +- **Assertions**: + - The call returns `Ok(())` — proven inclusion (`shield_from_asset_lock` uses `broadcast_and_wait`, `operations.rs:303`), important because the asset-lock proof is single-use: a false-positive on a later-rejected transition would strand the L1 outpoint. + - `shielded_balances[0] == amount` (exact). + - Re-submitting the SAME asset-lock proof a second time fails with a typed error (single-use enforcement) — no double-shield. +- **Expected current outcome**: PASS if the Core-L1 gate is wired; otherwise RED on the missing asset-lock funding seam (the RED documents the gate, not a production defect in the shield path itself). **Devnet blocker (paloma 2026-06-02)**: the asset-lock floor is 1.25 e9 credits; SH-018 currently funds 1.2 e9 → 50 M short, so the case fails before shielding. Raise `SHIELD_AMOUNT` above 1.25 e9 before treating a RED here as a backend signal. The SH-035 replay leg shares this funding gap and never runs until it is resolved. +- **Harness extensions required**: Wave H + Core-L1 gate (asset-lock builder + Core-funded wallet) + optional public `shielded_shield_from_asset_lock` wrapper. +- **Estimated complexity**: L + +#### SH-019 — Shielded withdraw to Core L1 address (Type 19) +- **Priority**: P1 +- **Status**: not implemented (Wave H + Core-L1 gate). The shielded SPEND half is exercisable now (same path as SH-002/SH-003); the L1-arrival assertion needs Layer-1 observation and MAY run RED until that lands. +- **Wallet feature exercised**: `PlatformWallet::shielded_withdraw_to` (`platform_wallet.rs:652`) → `wallet/shielded/operations.rs:506` (`withdraw`) → `build_shielded_withdrawal_transition`. +- **Preconditions**: shield `≥ amount + fee` into account 0 on a FileBacked coordinator (the spend needs `witness()` — Found-027 means in-memory cannot withdraw); a Core L1 address to observe; Layer-1 observation seam (SPV is enabled per Wave E, but observing the withdrawal payout tx is the gated piece, shared with §5 item 2). +- **Scenario**: + 1. Shield `50_000_000` into account 0; `coordinator.sync(true)`. + 2. `shielded_withdraw_to(account=0, to_core_address, amount=20_000_000, core_fee_per_byte, prover)`. + 3. `coordinator.sync(true)`; assert the shielded side; then (gated) observe the L1 payout. +- **Assertions**: + - Withdraw returns `Ok(())`. + - `shielded_balances[0] == 50_000_000 - 20_000_000 - shielded_fee` (change note retained; shielded side fully assertable WITHOUT the L1 gate — this half is GREEN-capable). + - **(Core-L1 gated)** the Core L1 address receives the withdrawal payout (amount minus L1 fee); this assertion is what MAY run RED until Layer-1 observation is wired. + - The spent note is marked spent (a second identical withdraw does not re-select it). +- **Expected current outcome**: shielded-side assertions PASS; the L1-arrival assertion PASS if the Layer-1 observation seam exists, else RED (documents the gate). Split the test so the shielded-side guard is not blocked by the L1 gate (assert shielded side unconditionally, gate only the L1 read behind `PLATFORM_WALLET_E2E_BANK_CORE_GATE`). **Devnet blocker (paloma 2026-06-02)**: unshield/transfer to a Core L1 address surfaces `network mismatch: address Testnet, wallet Devnet` — the `to_core_address` passed must match the wallet's configured network (`Network::Devnet`). Verify harness address derivation uses the devnet HRP; a Testnet bech32 address passed to a devnet wallet triggers this error before reaching Drive. On devnet the `withdrawals contract not available` rejection from Drive is also possible (devnet env gap, not a wallet bug — see SH-019 note in paloma run 2026-06-02). +- **Harness extensions required**: Wave H + Core-L1 gate (Layer-1 payout observation, shared with §5 item 2 transparent withdrawal design). +- **Estimated complexity**: L + +#### Adversarial / abuse cases (SH-020..SH-035) + +**This is the deliverable.** The cases above (SH-001..SH-019) largely confirm the +wallet WORKS. These cases try to BREAK THE BACKEND — Drive's consensus and +state-transition validation, and the Orchard proof verifier. A RED test here is a +WIN: it means a malformed/adversarial transition the backend MUST reject was +accepted or mishandled. Every case below asserts **backend rejection (or safe +behavior)**; the "Expected current outcome" line states what a FINDING looks like. + +**Critical methodology — bypass client-side guards.** The wallet's public spend +API validates client-side (zero-amount guards, balance checks, address parsing, +network HRP). Those guards would mask the backend test by failing the call before +it reaches Drive. To genuinely test the backend, the adversarial transition MUST +be constructed at the protocol boundary and broadcast directly, NOT through the +guarded wallet method. The injection seam: the `dpp::shielded::builder::build_*_transition` +functions (`packages/rs-dpp/src/shielded/builder/{unshield,shielded_transfer,shield,shielded_withdrawal,shield_from_asset_lock}.rs`) +produce a state transition from a `SerializedBundle` (`builder/mod.rs:74-89` — `anchor`, +`proof`, `value_balance`, `binding_signature` all public and mutable) which is then +handed to `BroadcastStateTransition::broadcast_and_wait` (`operations.rs:232/304/371/467/556`). +Wave H adds **adversarial injection hooks** (below) that (a) build a valid transition +then mutate the serialized bytes / `SerializedBundle` fields before broadcast, (b) +swap in a tampering/mock prover, or (c) feed the dpp builder out-of-range inputs the +wallet wrapper would reject. Cases needing such a hook are marked **[INJECT]**. + +**Correct-rejection assertion shape**: assert the broadcast returns a typed +consensus/state error (e.g. `ShieldedNullifierAlreadySpent`, `ShieldedInvalidProof`, +`AnchorMismatch`, `ShieldedValueNotConserved`, or the DPP `ConsensusError` variant the +protocol defines) — NOT a generic "is_err". Where the exact variant is unknown to this +audit, the case names the EXPECTED variant and flags that a different error (or `Ok`) is +itself a finding (the backend rejected for the wrong reason, or did not reject). + +##### SH-020 — Double-spend: same note in two concurrent transitions [INJECT] +- **Priority**: P0 (consensus-critical). +- **Attack**: build two distinct, individually-valid spend transitions (Type 16 transfer and/or Type 17 unshield) that both spend the SAME shielded note (same nullifier), and broadcast both — concurrently and, in a second arm, sequentially within one block window. The wallet's `reserve_unspent_notes` (`operations.rs:711-746`) would normally prevent two local spends from selecting the same note; this case BYPASSES that by building the second transition directly against the same `SpendableNote` (the local reservation is a client convenience, not the consensus guarantee). +- **Transition type**: 16 / 17. +- **Injection point**: build both via `build_unshield_transition` / `build_shielded_transfer_transition` against the same selected note + witness; broadcast both. **[INJECT]** — second build must skip the local reservation. +- **Correct backend behavior**: exactly ONE transition is accepted; the second is rejected because its Orchard nullifier is already in Drive's spent-nullifier set. The accepted+rejected split must be deterministic (not "both rejected", not "both accepted"). +- **Assertions**: first broadcast `Ok`; second broadcast `Err` with a nullifier-already-spent / double-spend consensus error; the shielded balance reflects exactly ONE spend (no double-debit, no fund creation). +- **Expected current outcome**: the test asserts correct rejection. **FINDING (RED) if** the backend accepts both (double-spend — CRITICAL fund-integrity break), accepts neither (liveness bug), or accepts one but the balance is wrong. +- **Harness extensions**: Wave H + adversarial injection hook (build-against-same-note) + solo concurrency job. +- **Severity if it fails**: CRITICAL. + +##### SH-021 — Nullifier replay after restart / resync [INJECT] +- **Priority**: P0 (consensus-critical). +- **Attack**: spend a note (Type 17), let it confirm, then resubmit a transition spending the SAME already-spent note — after a simulated process restart + resync (so the local pending/spent state is reloaded from the persister, not just in-memory). Models an attacker replaying a captured transition. +- **Transition type**: 17 (and 16 arm). +- **Injection point**: capture the first transition's bytes (or rebuild against the now-spent note via the injection hook), restart the coordinator/store from persisted state, rebroadcast. **[INJECT]** to rebuild against a known-spent note. +- **Correct backend behavior**: rejected — the nullifier is permanently in Drive's spent set regardless of client state; replay across restart MUST NOT succeed. +- **Assertions**: replay broadcast returns a nullifier-already-spent consensus error; balance unchanged by the replay; no second debit. +- **Expected current outcome**: asserts rejection. **FINDING (RED) if** the replay is accepted (double-spend via replay) or if the local resync re-marks the note unspent and the wallet then re-selects it (client-side fund-loss / double-build). +- **Harness extensions**: Wave H + persister restart hook + injection hook. +- **Severity if it fails**: CRITICAL. + +##### SH-022 — Value not conserved: outputs exceed inputs [INJECT] +- **Priority**: P0 (consensus-critical). +- **Attack**: construct a transfer/unshield whose declared outputs (recipient + change) exceed the spent note value — i.e. mint value out of nothing. Set the `SerializedBundle.value_balance` (`builder/mod.rs:79`) inconsistent with the actual spend, or pass an `amount` larger than the note to the dpp builder directly. +- **Transition type**: 16 / 17. +- **Injection point**: dpp builder with output > input, or mutate `value_balance` post-build. **[INJECT]** — the wallet's `select_notes_with_fee` would reject insufficient input client-side; bypass it. +- **Correct backend behavior**: rejected. Orchard's value-balance check + Drive's credit accounting must refuse a bundle where shielded inputs < outputs + fee. The Halo-2 proof binds `value_balance`; a mismatch must fail proof verification or the consensus value check. +- **Assertions**: broadcast returns a value-conservation / invalid-proof consensus error; no credits created; total shielded+transparent supply unchanged. +- **Expected current outcome**: asserts rejection. **FINDING (RED) if** accepted — that is value forgery (CRITICAL: unlimited inflation of the shielded pool). +- **Harness extensions**: Wave H + injection hook (value_balance / amount tamper). +- **Severity if it fails**: CRITICAL. + +##### SH-023 — Fee underpayment below `compute_minimum_shielded_fee` [INJECT] +- **Priority**: P1. +- **Attack**: build a spend declaring a fee BELOW `compute_minimum_shielded_fee(num_actions, version)` (`note_selection.rs:81/87`) — pass an `Some(exact_fee)` that is too small to `build_unshield_transition`'s fee param, or zero. The wallet computes the correct fee; bypass it. +- **Transition type**: 16 / 17 / 19. +- **Injection point**: dpp builder with an under-floor fee. **[INJECT]**. +- **Correct backend behavior**: rejected with an insufficient-fee / below-minimum consensus error; Drive must enforce the same floor `compute_minimum_shielded_fee` derives. +- **Assertions**: broadcast `Err` insufficient-fee; no inclusion. +- **Expected current outcome**: asserts rejection. **FINDING (RED) if** an under-floor fee is accepted (fee-market bypass / spam vector) — note the client floor and the backend floor MUST agree; a divergence is itself a finding. +- **Harness extensions**: Wave H + injection hook. +- **Severity if it fails**: HIGH. + +##### SH-024 — u64 value boundary: overflow / underflow at amount edges [INJECT] +- **Priority**: P1. +- **Attack**: drive the spend at `amount == u64::MAX`, `amount + fee` wrapping past `u64::MAX`, and `value_balance` at `i64::MIN`/`i64::MAX`. The wallet has a `checked_add` guard at `note_selection.rs:35`; bypass it and feed the raw boundary value to the dpp builder / `value_balance`. +- **Transition type**: 16 / 17. +- **Injection point**: dpp builder + `value_balance` field at boundary. **[INJECT]**. +- **Correct backend behavior**: rejected with a typed validation error (no wraparound, no panic in the validator, no negative-value-as-huge-positive). The arithmetic must be checked on the BACKEND, not only client-side. +- **Assertions**: broadcast `Err` typed; the validator process does not panic/abort; balance/supply unchanged. +- **Expected current outcome**: asserts safe rejection. **FINDING (RED) if** the backend wraps, panics, or accepts a boundary value that the client guard alone was catching (backend missing the check ⇒ a client without the guard, or a direct gRPC submitter, breaks it). +- **Harness extensions**: Wave H + injection hook. +- **Severity if it fails**: HIGH. + +##### SH-025 — Forged / tampered Halo-2 proof [INJECT] +- **Priority**: P0 (consensus-critical). +- **Attack**: build a valid transition, then flip bytes in `SerializedBundle.proof` (`builder/mod.rs:85`) — single-bit flip, truncation, all-zeros, and a proof copied from a DIFFERENT valid transition (proof-substitution). Broadcast. +- **Transition type**: 16 / 17 (proof present on all spends). +- **Injection point**: mutate `proof` bytes post-build before broadcast. **[INJECT]** — also covered by a "tampering prover" hook that emits a wrong proof. +- **Correct backend behavior**: rejected by Orchard proof verification at validation; the proof is bound to the public inputs (anchor, nullifiers, value_balance, cmx), so any mutation or substitution must fail. +- **Assertions**: broadcast `Err` invalid-proof consensus error for every mutation variant; no inclusion. +- **Expected current outcome**: asserts rejection. **FINDING (RED) if** ANY tampered/substituted proof is accepted — that is a total break of shielded soundness (CRITICAL). +- **Harness extensions**: Wave H + injection hook (proof-byte mutation + tampering-prover). +- **Severity if it fails**: CRITICAL. + +##### SH-026 — Anchor mismatch: spend against a stale / wrong checkpoint anchor [INJECT] (Found-030 dynamic probe) +- **Priority**: P1. +- **Attack**: build a spend whose `SerializedBundle.anchor` (`builder/mod.rs:84`) is a VALID-but-stale tree root (an earlier checkpoint) or an outright wrong/random 32 bytes, while the witness paths authenticate against the current root. This directly exercises the depth-0 anchor semantics that **Found-030** flagged as doc-ambiguous (`operations.rs:601-611` "most recent checkpoint" vs `file_store.rs:162-165` "current tree state"). +- **Transition type**: 16 / 17. +- **Injection point**: override `anchor` post-build, or pass a stale `Anchor` to the dpp builder. **[INJECT]**. +- **Correct backend behavior**: rejected with `AnchorMismatch` (or "Anchor not found in the recorded anchors tree") — Drive accepts only anchors it has recorded; a wrong/stale-beyond-window anchor must fail. +- **Assertions**: broadcast `Err` anchor-mismatch; no inclusion. Sub-arm: a STALE-but-still-in-window anchor (if the protocol accepts a bounded history) is accepted — pin which side of the Found-030 ambiguity is true. **This case is the dynamic probe that resolves Found-030**: whichever anchor depth the backend actually accepts tells us which doc-comment is correct and which is the latent bug. +- **Expected current outcome**: asserts rejection of wrong/over-stale anchors. **FINDING (RED) if** a wrong anchor is accepted (soundness break), OR the observed accepted-anchor-window contradicts BOTH doc-comments (Found-030 is worse than a doc drift — the behavior is undocumented). +- **Harness extensions**: Wave H + injection hook (anchor override) + a tree-checkpoint advancer to manufacture a stale anchor. +- **Severity if it fails**: HIGH. + +##### SH-027 — Malformed note serde: note_data ≠ 115 bytes, corrupted cmx/nullifier +- **Priority**: P1. +- **Attack**: feed the store / `deserialize_note` (`operations.rs:810-832`, strict `SERIALIZED_NOTE_LEN = 115`) a truncated (114 B), oversized (116 B), empty, and bit-corrupted `note_data`; and a corrupted `cmx` / `nullifier` on a stored note. Drive this through the spend path that calls `extract_spends_and_anchor` → `deserialize_note`. +- **Transition type**: 16 / 17 (spend-side deserialization). +- **Injection point**: seed the store with a malformed `ShieldedNote.note_data` / `cmx` via a store-injection hook. **[INJECT]** (store seeding). +- **Correct backend/wallet behavior**: error SAFELY — `deserialize_note` returns `None` → `ShieldedBuildError` (`operations.rs:623-628`); NO panic, NO silent acceptance of a truncated note as a valid one, NO out-of-bounds slice. The 115-byte layout (`recipient43‖value8‖rho32‖rseed32`) must round-trip exactly with `serialize_note` (`sync.rs:575-582`); a length drift is silent corruption. +- **Assertions**: every malformed length/content returns a typed error, never a panic; a corrupted `cmx` fails at `ExtractedNoteCommitment::from_bytes` (`operations.rs:647-654`) not silently; no partial/garbage note enters a built bundle. +- **Expected current outcome**: asserts safe errors. **FINDING (RED) if** any malformed input panics (DoS), is silently truncated/padded, or produces a bundle (corruption ⇒ unspendable funds or wrong cmx). +- **Harness extensions**: Wave H + store-seeding injection hook. +- **Severity if it fails**: HIGH (panic = validator/host DoS; silent corruption = fund loss). + +##### SH-028 — Sync robustness: interrupt mid-chunk, resume, no double-count [INJECT] +- **Priority**: P1. +- **Status**: **BLOCKED — not implemented.** No injectable sync-source seam exists: `sync_notes_across` is `pub(super)` and fetches from the SDK directly, with no cancellation point between fetch and store-write. Driving this attack requires a production `SyncSource` seam (a trait the coordinator fetches through, with a test impl). Intentionally NOT built in this wave — flagged as a production gap. Removed from `cases/`. +- **Attack**: interrupt `sync_notes_across` (`sync.rs:169-340`) mid-chunk (cancel the future between fetch and append), then resume; assert the append-once gate (`sync.rs:276-289`, gated on `tree_size` not a watermark) prevents double-append. Combine with a forced `coordinator.sync(true)` storm. +- **Transition type**: n/a (sync layer). +- **Injection point**: cancellation hook between fetch and store-write; or a store wrapper that drops a write. **[INJECT]**. +- **Correct behavior**: no commitment appended twice (a double-append corrupts shardtree → "Anchor not found"); no note lost; balance consistent after resume; watermark monotonic. +- **Assertions**: post-resume, `tree_size` equals the count of distinct positions; a spend still builds a valid witness (proves no shardtree corruption); balance equals the pre-interrupt expected value. +- **Expected current outcome**: asserts consistency. **FINDING (RED) if** a note is double-counted, lost, or the tree is corrupted (spend fails witness post-resume). +- **Harness extensions**: Wave H + sync-cancellation hook (analogous to Wave F's broadcast/proof-fetch cancellation hook, Harness-G4). +- **Severity if it fails**: HIGH. + +##### SH-029 — Simulated reorg / out-of-order blocks / rescan-from-0 [INJECT] +- **Priority**: P1. +- **Status**: **BLOCKED — not implemented.** Same missing sync-source seam as SH-028 (`sync_notes_across` fetches from the SDK directly; no scriptable mock sync source). Intentionally NOT built — flagged as a production gap. Removed from `cases/`. +- **Attack**: (a) feed the sync notes whose positions arrive out of order; (b) simulate a reorg that rolls back recently-appended commitments then re-appends a different set; (c) force `next_start_index == 0` rescan-from-0 (the warned-about path at `sync.rs:235-241`) and assert it does not double-count already-stored notes. +- **Transition type**: n/a (sync layer). +- **Injection point**: a mock SDK-sync source that returns scripted (reordered / rolled-back / from-zero) note chunks. **[INJECT]**. +- **Correct behavior**: balances converge to the canonical chain state; rolled-back commitments are not retained as spendable; rescan-from-0 is idempotent (the `tree_size` gate skips re-append); no nullifier double-derived. +- **Assertions**: after each scripted scenario, `shielded_balances` equals the canonical expected value; no duplicate notes; a spend builds correctly. +- **Expected current outcome**: asserts convergence. **FINDING (RED) if** a reorg leaves orphaned-as-spendable notes (phantom funds), rescan-from-0 double-counts, or out-of-order positions corrupt the tree. +- **Harness extensions**: Wave H + scriptable mock sync source. +- **Severity if it fails**: HIGH. + +##### SH-030 — Cross-network / wrong-HRP recipient; malformed / own-address; transfer-to-self +- **Priority**: P2. +- **Attack**: unshield/withdraw/transfer to: (a) a recipient address with the WRONG network HRP (mainnet `dash1…` on testnet, and vice versa); (b) a malformed bech32m / base58 address; (c) the spender's OWN shielded/transparent address (transfer-to-self); (d) a syntactically-valid address of the wrong type (Core address where a platform address is expected). +- **Transition type**: 16 / 17 / 19. +- **Injection point**: mostly expressible via the public API (it parses + checks network at `platform_wallet.rs:621-633`), so this case ALSO asserts the client guard fires; an **[INJECT]** arm bypasses the client network check to confirm the BACKEND independently rejects a cross-network recipient (client guard must not be the only line of defense). +- **Correct behavior**: wrong-HRP and malformed addresses rejected with a typed parse/network-mismatch error (client AND backend); transfer-to-self either cleanly succeeds with correct accounting (value conserved minus fee, no phantom credit) or is rejected — pin whichever the protocol defines, assert no value creation either way. +- **Assertions**: each malformed/cross-network input → typed error, no broadcast; transfer-to-self → exact value conservation (no net mint). +- **Expected current outcome**: asserts rejection / safe self-transfer. **FINDING (RED) if** a cross-network recipient is accepted by the backend (funds sent to a wrong-network address = loss), or transfer-to-self mints/loses value. +- **Harness extensions**: Wave H + injection hook for the backend-only network arm. +- **Severity if it fails**: HIGH (cross-network acceptance = fund loss). + +##### SH-031 — Double-bind / rebind with a DIFFERENT seed +- **Priority**: P1. +- **Attack**: `bind_shielded(seed_A, &[0])`, sync some notes, then `bind_shielded(seed_B, &[0])` with a DIFFERENT seed on the same wallet/coordinator. The rebind path unregisters+reregisters (`platform_wallet.rs:381-397`) and the doc claims "replace-not-merge"; verify it does not mix key material or leave seed-A notes spendable/visible under seed-B. +- **Transition type**: n/a (key management). +- **Injection point**: public API (`bind_shielded` twice with different seeds). +- **Correct behavior**: after rebind to seed_B, seed_A's notes are NOT visible/spendable under seed_B's keys (different IVK ⇒ no decryption); the store's per-`SubwalletId` state for the old binding is purged or isolated (the doc-comment at `platform_wallet.rs:381-390` claims unregister purges stale watermarks / orphaned accounts / pending reservations); no panic; no cross-seed nullifier confusion. +- **Assertions**: `shielded_balances` under seed_B does not include seed_A's note values; a spend under seed_B cannot select a seed_A note; rebinding back to seed_A (if supported) re-discovers its notes cleanly. +- **Expected current outcome**: asserts isolation. **FINDING (RED) if** seed-A notes leak into seed-B's balance (privacy/accounting break), or stale pending reservations from binding A make binding B skip spendable notes (the exact stale-state class the rebind doc claims to prevent — verify it actually does), or the store corrupts. +- **Harness extensions**: Wave H (two seeds; no new hook — public API). +- **Severity if it fails**: HIGH. + +##### SH-032 — Boundary: balance exactly `== amount + fee`, and off-by-one below +- **Priority**: P1. +- **Attack**: fund a single note to EXACTLY `amount + compute_minimum_shielded_fee(1, version)`; spend `amount`. Then off-by-one: fund `amount + fee - 1` and attempt the same spend. +- **Transition type**: 17 (unshield, single-note exact-change). +- **Injection point**: public API (exact funding via a precise shield), so this is a non-INJECT correctness case — but the spend must reach the backend so the BACKEND's fee/value check is exercised, not just the client's. +- **Correct behavior**: exact case succeeds, leaves ZERO change (no dust note created), value conserved exactly; off-by-one-below case is rejected (client `ShieldedInsufficientBalance` AND, via an [INJECT] arm, the backend value/fee check) — no spend that underpays the fee by 1. +- **Assertions**: exact: `Ok`, post-balance `== 0`, recipient `== amount`, fee `== expected`; off-by-one: `Err` insufficient (client) and rejected (backend arm). +- **Expected current outcome**: asserts exact-change correctness + boundary rejection. **FINDING (RED) if** the exact case creates a phantom change note, over/under-charges the fee, or the off-by-one is accepted by the backend. +- **Harness extensions**: Wave H + optional [INJECT] for the backend off-by-one arm. +- **Severity if it fails**: MEDIUM. + +##### SH-033 — Duplicate nullifier WITHIN a single bundle [INJECT] +- **Priority**: P1. +- **Attack**: construct one transition whose Orchard bundle spends the same note twice (two actions, identical nullifier) — an intra-transition double-spend. +- **Transition type**: 16 / 17. +- **Injection point**: dpp builder with a duplicated `SpendableNote`. **[INJECT]**. +- **Correct backend behavior**: rejected — duplicate nullifiers within one bundle must fail validation before any state write. +- **Assertions**: broadcast `Err` duplicate-nullifier / invalid-bundle; no partial application. +- **Expected current outcome**: asserts rejection. **FINDING (RED) if** accepted (double-spend within one tx). +- **Harness extensions**: Wave H + injection hook. +- **Severity if it fails**: CRITICAL. + +##### SH-034 — Tampered binding signature [INJECT] +- **Priority**: P1. +- **Attack**: flip bytes in `SerializedBundle.binding_signature` (`builder/mod.rs:88`, 64 bytes); broadcast. +- **Transition type**: 16 / 17. +- **Injection point**: mutate `binding_signature` post-build. **[INJECT]**. +- **Correct backend behavior**: rejected — the binding signature commits to the value balance; a tampered signature must fail Orchard bundle verification. +- **Assertions**: broadcast `Err` invalid-signature/bundle; no inclusion. +- **Expected current outcome**: asserts rejection. **FINDING (RED) if** accepted (value-balance binding bypass). +- **Harness extensions**: Wave H + injection hook. +- **Severity if it fails**: CRITICAL. + +##### SH-035 — Replayed Type 18 asset-lock proof (single-use enforcement) [INJECT] +- **Priority**: P1 (Core-L1 gated). +- **Attack**: shield-from-asset-lock (Type 18) with a valid `AssetLockProof`, then resubmit the SAME asset-lock proof in a second Type 18 transition. (Extends SH-018's single-use note into a dedicated abuse case.) +- **Transition type**: 18. +- **Injection point**: reuse the captured `AssetLockProof`. **[INJECT]** + Core-L1 gate. +- **Correct backend behavior**: rejected — an asset-lock outpoint is single-use; the second consumption must fail (already-used / outpoint-spent consensus error). +- **Assertions**: first `Ok`, second `Err` asset-lock-already-used; only one shielded note created. +- **Expected current outcome**: asserts rejection. **FINDING (RED) if** the proof is consumed twice (double-shield from one L1 lock = value forgery). +- **Harness extensions**: Wave H + Core-L1 gate + asset-lock-proof reuse hook. +- **Severity if it fails**: CRITICAL. + ### Found-bug pins (Found-NNN) Bug-pin cases discovered during a QA-mindset audit of `packages/rs-platform-wallet/src/`. @@ -2533,6 +3089,36 @@ order. Each wave unlocks the cases listed. **Recommended build order**: Wave A first (highest leverage — unblocks 25+ cases), then Wave F's cheap helpers (estimate-fee, transfer-with-inputs, registry status, FUNDING_MUTEX hook) which unblock most P2 PA cases, then Wave C, then Wave B as ID-003/DP-002 land. Wave G unlocks the entire TK column once Wave A is in place; the SDK-wrapper helpers in Wave G (helpers 6–10 and 14–19, previously tracked as Gap-T1..T6) land together with Wave G, not as follow-up wallet PRs. Wave F's expensive items (test DAPI proxy, cancellation hook) and Wave E are independent and can run in parallel with the others once a champion is assigned. Wave D is superseded by Wave G. Wave E is complete (Task #15 closed; CR-003 has flipped PASS, see §3 CR-003 Status). +### Wave H — Shielded (Orchard) harness extensions + +Unlocks the `### Shielded (SH)` area. Every helper is `#[cfg(feature = "shielded")]`; +the SH cases compile only under `--features shielded`. The prover is the cost +center — `CachedOrchardProver` warm-up loads Halo-2 parameters once (~seconds) and +each proof is ~30 s, so the suite shares ONE warmed instance and runs SH cases in +the gated `--include-ignored` cohort, never the default tier. + +- **`shielded_prover()` — process-wide warmed `CachedOrchardProver`** behind a `OnceCell` (mirrors the Wave G default-contract `OnceCell` and the bank singleton). Warm it once in the first SH case; all SH cases borrow `&CachedOrchardProver`. (`OrchardProver` is impl'd on the reference type — see `platform_wallet.rs:553-558`.) +- **`SetupGuard::bind_shielded(accounts: &[u32]) -> Arc`** — derives the seed (already held by `TestWallet`), constructs a per-test **FileBacked** coordinator (the in-memory store cannot witness — Found-027), calls `PlatformWallet::bind_shielded`, and returns the coordinator so the test can drive `sync(true)`. MUST use a fresh per-test SQLite path under the workdir (the commitment tree is network-shared but tests need isolation; document the cross-test sharing model or give each test its own DB file). +- **`wait_for_shielded_balance(wallet, &coordinator, account, expected, timeout)`** in `framework/wait.rs` — polls `shielded_balances` after `coordinator.sync(true)` until `== expected` or timeout; mirrors the PA `wait_for_balance` shape. Drives a `sync(true)` each poll (the cooldown gate at `coordinator.rs:405-423` is bypassed by `force=true`). +- **`shielded_default_address_43(wallet, account) -> [u8; 43]`** thin wrapper over `shielded_default_address` for the SH-003 transfer-recipient plumbing. +- **Store-backing switch** for SH-005: a helper that constructs both an InMemory and a FileBacked coordinator over the same funded note set so the witness-availability split is observable in one test. +- **Second-payer / self-transfer helper** for SH-006 and SH-007 (a note paid to an account/wallet that is not the synced driver). Likely composes `shielded_transfer_to` from a sibling account, or `register_extra_identity`-style a second bound wallet. +- **Controlled bind-ordering hook** for SH-007 — advance one coordinator's tree (`sync(true)`) before binding the second wallet; needs either two coordinators or a bind-after-append sequence. (SH-007 now guards the #3603 fix — assert the pre-bind note IS spendable — so this hook drives a GREEN regression guard, not a RED pin.) +- **Teardown shielded fund-sweep (bank-leak prevention)** — on `SetupGuard`/SH-case teardown, unshield any residual shielded-account balance back to the **bank's transparent platform address** (the same sink the PA sweep uses), so credits funded into the shielded pool are recovered rather than stranded run-over-run. **MUST be best-effort and logged**: wrap the unshield in a `try`/log-on-error, and NEVER let a sweep failure fail teardown. Critically, the RED-by-design cases (SH-005 in-memory arm, and any case where `witness()`/unshield is intentionally broken) WILL fail the sweep — that failure must be swallowed-and-logged (`tracing::warn!`), not propagated, exactly as `cancel_pending` (`operations.rs:765-779`) and the PA identity-sweep floor already do. Rationale: a known e2e lesson — un-swept funding silently starves the bank across a long suite. Mirrors `cleanup::sweep_identities` (best-effort, below-floor balances left for the next-run orphan sweep). +- **Core-L1 gate (for SH-018 / SH-019)** — gated behind `PLATFORM_WALLET_E2E_BANK_CORE_GATE` (parity with ID-002b / CR-003 / AL-001). Provides: (a) a Core-funded test wallet via Wave E `setup_with_core_funded_test_wallet(duffs)` + an **asset-lock builder** producing a single-use `AssetLockProof` (for Type 18, SH-018); and (b) a **Layer-1 payout observation** seam to confirm the withdrawal tx landed on Core (for Type 19, SH-019 — shared design with §5 item 2 transparent withdrawal). Until both exist, SH-018 and the L1-arrival half of SH-019 run RED — acceptable, the RED documents the missing seam. SH-019's shielded-side assertions stay GREEN-capable independent of this gate. +- **Adversarial injection hooks (for SH-020..SH-035 — the abuse pass).** The whole point of the abuse cases is to reach the BACKEND with transitions the wallet's client-side guards would normally reject, so the wallet's validation must NOT mask the backend test. These hooks construct/mutate transitions at the protocol boundary and broadcast them directly via `BroadcastStateTransition`, bypassing the guarded `PlatformWallet::shielded_*` methods: + - **`build_raw_shielded_transition(kind, spends, outputs, anchor, value_balance, fee, proof_override, …) -> StateTransition`** — a thin test wrapper over the public `dpp::shielded::builder::build_*_transition` functions (`packages/rs-dpp/src/shielded/builder/`) that lets the test pass out-of-range / inconsistent inputs the wallet wrapper forbids (output > input for SH-022, under-floor fee for SH-023, `u64`/`i64` boundary for SH-024, duplicate `SpendableNote` for SH-033, stale/random `anchor` for SH-026). + - **`broadcast_raw(sdk, state_transition) -> Result<…>`** — broadcast an arbitrary (possibly invalid) state transition directly, returning the typed backend error so the test can assert the exact rejection variant. The seam already exists at `operations.rs:232/304/371/467/556`; expose it test-side. + - **`mutate_serialized_bundle(st, field, bytes)`** — flip/truncate/zero bytes in the serialized `SerializedBundle` fields (`builder/mod.rs:74-89`): `proof` (SH-025), `binding_signature` (SH-034), `anchor` (SH-026), `value_balance` (SH-022/SH-024). Operates on the built transition's bytes pre-broadcast. + - **`TamperingProver`** — an `OrchardProver` impl (the trait is just `proving_key()`, `builder/mod.rs:58-61`) paired with a post-hoc proof-corrupting wrapper, for the proof-substitution arm of SH-025 (emit a proof from a different transition). + - **`build_against_note(note, witness)` / skip-reservation build** — build a spend directly against a chosen `SpendableNote` WITHOUT going through `reserve_unspent_notes` (`operations.rs:711-746`), for the double-spend SH-020 and replay SH-021 (rebuild against an already-spent note). + - **`seed_malformed_note(store, note_data, cmx, nullifier)`** — inject a `ShieldedNote` with non-115-byte `note_data` / corrupted `cmx` into the store, for the serde-abuse SH-027. + - **Scriptable mock sync source** — a sync provider returning scripted note chunks (out-of-order, rolled-back/reorg, from-index-0), for SH-028/SH-029; pairs with a **sync-cancellation hook** (analogous to Wave F's broadcast/proof-fetch cancellation hook) to interrupt mid-chunk. + - **`reuse_asset_lock_proof(proof)`** — resubmit a captured single-use `AssetLockProof`, for SH-035 (Core-L1 gated). + - **`PLATFORM_WALLET_E2E_SHIELDED_ADVERSARIAL` gate** — the abuse cases run only under this env gate (plus `--features shielded --include-ignored`) so a stray malformed-transition broadcast can't pollute a normal run; the gate also signals "these are EXPECTED to attempt-and-be-rejected", so a backend acceptance is logged as a finding rather than a flake. +- **Unlocks**: SH-001..SH-035. SH-001..SH-014 need only the core Wave H helpers; SH-018 needs the Core-L1 asset-lock builder; SH-019 needs the Core-L1 Layer-1 observation seam; **SH-020..SH-035 (abuse pass) need the adversarial injection hooks above** (SH-035 also needs the Core-L1 gate). +- **Cost**: prover warm-up + `bind_shielded` helper + `wait_for_shielded_balance` + the best-effort teardown sweep are the cheap core (~180 LoC) and unblock SH-001..SH-004, SH-007, SH-008..SH-014. The store-backing switch (SH-005), second-payer (SH-006/SH-007), and bind-ordering hook (SH-007) are incremental. SH-018/SH-019 add the Core-L1 gate. The adversarial injection hooks (~250-400 LoC: raw-build/broadcast + bundle-byte mutation + tampering prover + scriptable sync source) unblock the entire abuse pass and are the single highest-leverage harness investment, since the abuse pass is where backend FINDINGS are won. **Highest-value deliverables**: the consensus-critical abuse cases (SH-020 double-spend, SH-022 value conservation, SH-025 forged proof, SH-033 intra-bundle double-spend, SH-034 binding-sig tamper — all CRITICAL-if-they-fail), then the two live Found pins (SH-005/Found-027, SH-006/Found-028), the #3603 regression guard (SH-007/Found-029), and the Found-030 dynamic probe (SH-026). + ### Framework notes (post-V20) **`bank.fund_address` — chain-confirmed-nonce wait (PR #3609 / upstream issue #3611)** @@ -2558,7 +3144,7 @@ the spec but each would simplify a test if filed as a follow-up issue: Explicit list of what this suite WILL NOT cover, with reasons. Each entry prevents future scope creep arguments. -1. **Shielded transfers** — entire `wallet/shielded/` surface. Reason: prover, viewing-key derivation, and note-selection are a parallel system; coverage belongs in a dedicated suite. Re-evaluate when shielded ships to mainnet. +1. **Shielded transfers** — IN SCOPE as of 2026-05-22 (see `### Shielded (SH)` in §3 and Wave H in §4). The prover / viewing-key / note-selection complexity is real but bounded — the suite shares one warmed `CachedOrchardProver` and gates every SH case behind `--features shielded --include-ignored`. **In scope (all five transition types)**: shield (Type 15, SH-001), shield→unshield round-trip (Type 15→17, SH-002), shielded private transfer (Type 16, SH-003), shield-from-asset-lock (Type 18, SH-018), withdraw to L1 (Type 19, SH-019), plus the spend-side store/note-selection/sync correctness + bug pins (SH-004..SH-014, Found-027/028/030 live + Found-029 fixed-and-guarded). SH-018 and the L1-arrival half of SH-019 are gated behind the Core-L1 harness requirement (Wave H) and MAY run RED until that plumbing is complete — acceptable, since a RED documents the missing seam. Teardown unshields residual shielded balance back to the bank platform address (best-effort + logged) to prevent bank-fund leak. 2. **Credit withdrawals** (`wallet/identity/network/withdrawal.rs`, `wallet/platform_addresses/withdrawal.rs`) — withdrawal verification requires Layer-1 observation of the withdrawal tx. SPV is now enabled (Task #15 complete) but withdrawal coverage is deferred pending a dedicated test design — the flow is more complex than a simple SPV read and DET currently owns the canonical coverage. 3. **Operator-pre-funded testnet token contracts** — the original Wave D plan (env-config + operator-provided contract id) is superseded. The suite deploys a fresh token contract per CI run via Wave G; no operator-side registry is required and no testnet contract id is consumed from config. diff --git a/packages/rs-platform-wallet/tests/e2e/cases/mod.rs b/packages/rs-platform-wallet/tests/e2e/cases/mod.rs index aed0f810d1f..522186a1995 100644 --- a/packages/rs-platform-wallet/tests/e2e/cases/mod.rs +++ b/packages/rs-platform-wallet/tests/e2e/cases/mod.rs @@ -51,6 +51,69 @@ pub mod pa_009_min_input_amount; pub mod pa_3040_bug_pin; pub mod print_bank_address; pub mod print_bank_address_offline; +// Shielded (Orchard) cases (Wave H — see TEST_SPEC.md ### Shielded (SH)) +#[cfg(feature = "shielded")] +pub mod sh_001_shield_from_account; +#[cfg(feature = "shielded")] +pub mod sh_002_shield_unshield_round_trip; +#[cfg(feature = "shielded")] +pub mod sh_003_shielded_transfer; +#[cfg(feature = "shielded")] +pub mod sh_004_balance_after_sync; +#[cfg(feature = "shielded")] +pub mod sh_005_inmemory_witness_split; +#[cfg(feature = "shielded")] +pub mod sh_006_add_account_never_syncs; +#[cfg(feature = "shielded")] +pub mod sh_007_pre_bind_note_witnessable; +#[cfg(feature = "shielded")] +pub mod sh_008_unshield_insufficient_balance; +#[cfg(feature = "shielded")] +pub mod sh_009_zero_amount_rejected; +#[cfg(feature = "shielded")] +pub mod sh_010_double_spend_reservation; +#[cfg(feature = "shielded")] +pub mod sh_011_note_selection_convergence; +#[cfg(feature = "shielded")] +pub mod sh_012_sync_watermark_idempotency; +#[cfg(feature = "shielded")] +pub mod sh_013_bind_empty_accounts; +#[cfg(feature = "shielded")] +pub mod sh_014_spend_before_bind; +#[cfg(feature = "shielded")] +pub mod sh_018_shield_from_asset_lock; +#[cfg(feature = "shielded")] +pub mod sh_019_shielded_withdraw_l1; +// Shielded adversarial / abuse cases (Wave H follow-up — SH-020..SH-035) +#[cfg(feature = "shielded")] +pub mod sh_020_double_spend_two_transitions; +#[cfg(feature = "shielded")] +pub mod sh_021_nullifier_replay_after_restart; +#[cfg(feature = "shielded")] +pub mod sh_022_value_not_conserved; +#[cfg(feature = "shielded")] +pub mod sh_023_fee_underpayment; +#[cfg(feature = "shielded")] +pub mod sh_024_value_boundary_overflow; +#[cfg(feature = "shielded")] +pub mod sh_025_forged_proof; +#[cfg(feature = "shielded")] +pub mod sh_026_anchor_mismatch; +#[cfg(feature = "shielded")] +pub mod sh_027_malformed_note_serde; +// SH-028 / SH-029 BLOCKED — no injectable sync-source seam (see TEST_SPEC.md). +#[cfg(feature = "shielded")] +pub mod sh_030_cross_network_recipient; +#[cfg(feature = "shielded")] +pub mod sh_031_rebind_different_seed; +#[cfg(feature = "shielded")] +pub mod sh_032_exact_change_boundary; +#[cfg(feature = "shielded")] +pub mod sh_033_duplicate_nullifier_in_bundle; +#[cfg(feature = "shielded")] +pub mod sh_034_tampered_binding_signature; +#[cfg(feature = "shielded")] +pub mod sh_035_replayed_asset_lock_proof; // Token tests (Wave 2 — per TEST_SPEC.md ### Tokens (TK)) pub mod tk_001_token_transfer; pub mod tk_001b_token_transfer_zero; diff --git a/packages/rs-platform-wallet/tests/e2e/cases/sh_001_shield_from_account.rs b/packages/rs-platform-wallet/tests/e2e/cases/sh_001_shield_from_account.rs new file mode 100644 index 00000000000..2ce47b46c0f --- /dev/null +++ b/packages/rs-platform-wallet/tests/e2e/cases/sh_001_shield_from_account.rs @@ -0,0 +1,119 @@ +//! SH-001 — Shield from a platform-payment account into the Orchard +//! shielded pool (Type 15). +//! Spec: `tests/e2e/TEST_SPEC.md` §3 "### Shielded (SH)" → SH-001. +//! Priority: P0. +//! +//! Bank-funds one transparent platform address, binds Orchard account 0 +//! on a per-test FileBacked coordinator, then shields half the balance +//! into the shielded pool and asserts the shielded balance reflects the +//! exact amount after a sync. +//! +//! Expected outcome: PASS — the shield path is fully implemented on this +//! branch (`shield` sources real on-chain nonces via +//! `fetch_inputs_with_nonce` with a `checked_add(1)` overflow guard). +//! +//! Gated behind the `e2e` cargo feature (which pulls in `shielded`); the +//! prover warm-up is ~30 s on the first SH case in the process. + +use std::time::Duration; + +use crate::framework::prelude::*; +use crate::framework::shielded::{ + bind_shielded, shielded_prover, teardown_sweep_shielded, wait_for_shielded_balance, +}; +use crate::framework::wait::{ + wait_for_address_balance_chain_confirmed_n, CHAIN_CONFIRMED_CONSECUTIVE_SUCCESSES, +}; + +/// Credits the bank delivers to the funding address. Sized to cover the +/// shielded amount plus the shield transition's `DeductFromInput(0)` fee +/// headroom (the wallet reserves 1e9 credits on input 0). +const FUNDING_CREDITS: u64 = 1_200_000_000; + +/// Credits shielded into the pool. The note value is exactly this — the +/// fee comes off the transparent input via `DeductFromInput(0)`. +const SHIELD_AMOUNT: u64 = 50_000_000; + +/// Per-step deadline for balance observations. +const STEP_TIMEOUT: Duration = Duration::from_secs(60); + +#[tokio_shared_rt::test(shared, flavor = "multi_thread", worker_threads = 12)] +async fn sh_001_shield_from_account() { + let _ = tracing_subscriber::fmt() + .with_env_filter( + tracing_subscriber::EnvFilter::try_from_default_env() + .unwrap_or_else(|_| "info,platform_wallet=debug".into()), + ) + .with_test_writer() + .try_init(); + + let s = setup().await.expect("e2e setup failed"); + + let addr_1 = s + .test_wallet + .next_unused_address() + .await + .expect("derive addr_1"); + + s.ctx + .bank() + .fund_address(&addr_1, FUNDING_CREDITS) + .await + .expect("bank.fund_address"); + + wait_for_address_balance_chain_confirmed_n( + s.ctx.sdk(), + &addr_1, + FUNDING_CREDITS, + CHAIN_CONFIRMED_CONSECUTIVE_SUCCESSES, + STEP_TIMEOUT, + ) + .await + .expect("addr_1 funding never observed"); + + // Refresh the wallet's local balance map so the shield input + // selection sees the funded address. + s.test_wallet + .sync_balances() + .await + .expect("pre-shield sync"); + + // Bind Orchard account 0 on a fresh FileBacked coordinator and warm + // the shared prover. + let handle = bind_shielded(&s.test_wallet, &[0], &s.ctx.workdir) + .await + .expect("bind_shielded"); + let prover = shielded_prover(); + + // Type 15 — shield from the transparent payment account 0 into + // Orchard account 0. `broadcast_and_wait` proves inclusion. + s.test_wallet + .platform_wallet() + .shielded_shield_from_account(0, 0, SHIELD_AMOUNT, s.test_wallet.address_signer(), prover) + .await + .expect("shield_from_account"); + + // The note is on-chain but not scanned until sync; poll until the + // shielded balance reaches the shielded amount exactly. + let shielded = + wait_for_shielded_balance(&s.test_wallet, &handle, 0, SHIELD_AMOUNT, STEP_TIMEOUT) + .await + .expect("shielded balance never reached SHIELD_AMOUNT"); + + assert_eq!( + shielded, SHIELD_AMOUNT, + "shielded_balances[0] must equal the shielded amount exactly \ + (note value = shielded amount, fee deducted from the transparent input); \ + observed {shielded}" + ); + + // Best-effort teardown sweep: drain the residual shielded balance + // back to the bank, then the standard transparent teardown. + let bank_addr = s + .ctx + .bank() + .primary_receive_address() + .to_bech32m_string(s.ctx.bank().network()); + teardown_sweep_shielded(&s.test_wallet, &handle, &bank_addr).await; + s.teardown().await.expect("teardown"); +} diff --git a/packages/rs-platform-wallet/tests/e2e/cases/sh_002_shield_unshield_round_trip.rs b/packages/rs-platform-wallet/tests/e2e/cases/sh_002_shield_unshield_round_trip.rs new file mode 100644 index 00000000000..479b8dde8bb --- /dev/null +++ b/packages/rs-platform-wallet/tests/e2e/cases/sh_002_shield_unshield_round_trip.rs @@ -0,0 +1,138 @@ +//! SH-002 — Round-trip: shield then unshield back to a transparent +//! address (Type 15 → 17). +//! Spec: `tests/e2e/TEST_SPEC.md` §3 "### Shielded (SH)" → SH-002. +//! Priority: P0. +//! +//! Shields into Orchard account 0, then unshields part of it to a fresh +//! transparent address. The spend leg REQUIRES the FileBacked store +//! (the in-memory `witness()` is a hard `Err` — Found-027, pinned by +//! SH-005); the harness `bind_shielded` always uses FileBacked. +//! +//! Expected outcome: PASS against the FileBacked store. + +use std::time::Duration; + +use crate::framework::prelude::*; +use crate::framework::shielded::{ + bind_shielded, shielded_prover, teardown_sweep_shielded, wait_for_shielded_balance, +}; +use crate::framework::wait::{ + wait_for_address_balance_chain_confirmed_n, CHAIN_CONFIRMED_CONSECUTIVE_SUCCESSES, +}; + +const FUNDING_CREDITS: u64 = 2_220_000_000; +const SHIELD_AMOUNT: u64 = 1_120_000_000; +const UNSHIELD_AMOUNT: u64 = 20_000_000; +const STEP_TIMEOUT: Duration = Duration::from_secs(60); + +#[tokio_shared_rt::test(shared, flavor = "multi_thread", worker_threads = 12)] +async fn sh_002_shield_unshield_round_trip() { + let _ = tracing_subscriber::fmt() + .with_env_filter( + tracing_subscriber::EnvFilter::try_from_default_env() + .unwrap_or_else(|_| "info,platform_wallet=debug".into()), + ) + .with_test_writer() + .try_init(); + + let s = setup().await.expect("e2e setup failed"); + let prover = shielded_prover(); + + let addr_1 = s + .test_wallet + .next_unused_address() + .await + .expect("derive addr_1"); + s.ctx + .bank() + .fund_address(&addr_1, FUNDING_CREDITS) + .await + .expect("bank.fund_address"); + wait_for_address_balance_chain_confirmed_n( + s.ctx.sdk(), + &addr_1, + FUNDING_CREDITS, + CHAIN_CONFIRMED_CONSECUTIVE_SUCCESSES, + STEP_TIMEOUT, + ) + .await + .expect("addr_1 funding never observed"); + s.test_wallet + .sync_balances() + .await + .expect("pre-shield sync"); + + let handle = bind_shielded(&s.test_wallet, &[0], &s.ctx.workdir) + .await + .expect("bind_shielded"); + + // Shield leg. + s.test_wallet + .platform_wallet() + .shielded_shield_from_account(0, 0, SHIELD_AMOUNT, s.test_wallet.address_signer(), prover) + .await + .expect("shield_from_account"); + wait_for_shielded_balance(&s.test_wallet, &handle, 0, SHIELD_AMOUNT, STEP_TIMEOUT) + .await + .expect("shielded balance never reached SHIELD_AMOUNT"); + + // Unshield leg to a fresh transparent address. + let addr_dst = s + .test_wallet + .next_unused_address() + .await + .expect("derive addr_dst"); + let addr_dst_bech32m = addr_dst.to_bech32m_string(s.ctx.bank().network()); + s.test_wallet + .platform_wallet() + .shielded_unshield_to( + &handle.coordinator, + 0, + &addr_dst_bech32m, + UNSHIELD_AMOUNT, + prover, + ) + .await + .expect("shielded_unshield_to"); + + // The unshielded credits land on the transparent address. + wait_for_address_balance_chain_confirmed_n( + s.ctx.sdk(), + &addr_dst, + UNSHIELD_AMOUNT, + CHAIN_CONFIRMED_CONSECUTIVE_SUCCESSES, + STEP_TIMEOUT, + ) + .await + .expect("addr_dst unshield never observed"); + + // The shielded account retains the change note (minus the shielded + // fee). Re-scan and read the residual; assert it dropped by at least + // the unshield amount and is strictly below the pre-unshield balance. + handle.sync().await; + let residual = handle + .balances(&s.test_wallet) + .await + .expect("post-unshield shielded_balances") + .get(&0) + .copied() + .unwrap_or(0); + let max_change = SHIELD_AMOUNT - UNSHIELD_AMOUNT; + assert!( + residual < max_change, + "shielded change must be below SHIELD_AMOUNT - UNSHIELD_AMOUNT ({max_change}) \ + after the shielded fee; observed {residual}" + ); + assert!( + residual > 0, + "shielded change note must be retained (observed {residual})" + ); + + let bank_addr = s + .ctx + .bank() + .primary_receive_address() + .to_bech32m_string(s.ctx.bank().network()); + teardown_sweep_shielded(&s.test_wallet, &handle, &bank_addr).await; + s.teardown().await.expect("teardown"); +} diff --git a/packages/rs-platform-wallet/tests/e2e/cases/sh_003_shielded_transfer.rs b/packages/rs-platform-wallet/tests/e2e/cases/sh_003_shielded_transfer.rs new file mode 100644 index 00000000000..24fa5a0807a --- /dev/null +++ b/packages/rs-platform-wallet/tests/e2e/cases/sh_003_shielded_transfer.rs @@ -0,0 +1,127 @@ +//! SH-003 — Shielded → shielded private transfer between two accounts of +//! one wallet (Type 16). +//! Spec: `tests/e2e/TEST_SPEC.md` §3 "### Shielded (SH)" → SH-003. +//! Priority: P0. +//! +//! Binds Orchard accounts [0, 1] AT BIND TIME (not via +//! `shielded_add_account`, which is broken — Found-028/SH-006), shields +//! into account 0, then privately transfers to account 1's default +//! Orchard address. +//! +//! Canary for multi-subwallet sync routing: account 1 must discover its +//! note via the non-driver trial-decryption loop. If routing regresses, +//! `shielded_balances[1]` stays 0. +//! +//! Expected outcome: PASS. + +use std::time::Duration; + +use crate::framework::prelude::*; +use crate::framework::shielded::{ + bind_shielded, shielded_default_address_43, shielded_prover, teardown_sweep_shielded, + wait_for_shielded_balance, +}; +use crate::framework::wait::{ + wait_for_address_balance_chain_confirmed_n, CHAIN_CONFIRMED_CONSECUTIVE_SUCCESSES, +}; + +const FUNDING_CREDITS: u64 = 2_220_000_000; +const SHIELD_AMOUNT: u64 = 1_120_000_000; +const TRANSFER_AMOUNT: u64 = 20_000_000; +const STEP_TIMEOUT: Duration = Duration::from_secs(60); + +#[tokio_shared_rt::test(shared, flavor = "multi_thread", worker_threads = 12)] +async fn sh_003_shielded_transfer() { + let _ = tracing_subscriber::fmt() + .with_env_filter( + tracing_subscriber::EnvFilter::try_from_default_env() + .unwrap_or_else(|_| "info,platform_wallet=debug".into()), + ) + .with_test_writer() + .try_init(); + + let s = setup().await.expect("e2e setup failed"); + let prover = shielded_prover(); + + let addr_1 = s + .test_wallet + .next_unused_address() + .await + .expect("derive addr_1"); + s.ctx + .bank() + .fund_address(&addr_1, FUNDING_CREDITS) + .await + .expect("bank.fund_address"); + wait_for_address_balance_chain_confirmed_n( + s.ctx.sdk(), + &addr_1, + FUNDING_CREDITS, + CHAIN_CONFIRMED_CONSECUTIVE_SUCCESSES, + STEP_TIMEOUT, + ) + .await + .expect("addr_1 funding never observed"); + s.test_wallet + .sync_balances() + .await + .expect("pre-shield sync"); + + // Bind both Orchard accounts at bind time. + let handle = bind_shielded(&s.test_wallet, &[0, 1], &s.ctx.workdir) + .await + .expect("bind_shielded"); + + s.test_wallet + .platform_wallet() + .shielded_shield_from_account(0, 0, SHIELD_AMOUNT, s.test_wallet.address_signer(), prover) + .await + .expect("shield_from_account"); + wait_for_shielded_balance(&s.test_wallet, &handle, 0, SHIELD_AMOUNT, STEP_TIMEOUT) + .await + .expect("account 0 shielded balance never reached SHIELD_AMOUNT"); + + // Private transfer to account 1's default Orchard address. + let acct1_addr = shielded_default_address_43(&s.test_wallet, 1) + .await + .expect("account 1 default Orchard address"); + s.test_wallet + .platform_wallet() + .shielded_transfer_to(&handle.coordinator, 0, &acct1_addr, TRANSFER_AMOUNT, prover) + .await + .expect("shielded_transfer_to"); + + // Account 1 receives the private note (multi-subwallet sync routing). + let acct1 = + wait_for_shielded_balance(&s.test_wallet, &handle, 1, TRANSFER_AMOUNT, STEP_TIMEOUT) + .await + .expect("account 1 never received the private note"); + assert_eq!( + acct1, TRANSFER_AMOUNT, + "shielded_balances[1] must equal the transfer amount exactly; observed {acct1}" + ); + + // Sender retains the change (minus the shielded fee). + handle.sync().await; + let acct0 = handle + .balances(&s.test_wallet) + .await + .expect("post-transfer shielded_balances") + .get(&0) + .copied() + .unwrap_or(0); + let max_change = SHIELD_AMOUNT - TRANSFER_AMOUNT; + assert!( + acct0 < max_change && acct0 > 0, + "sender change must be below SHIELD_AMOUNT - TRANSFER_AMOUNT ({max_change}) after fee \ + and strictly positive; observed {acct0}" + ); + + let bank_addr = s + .ctx + .bank() + .primary_receive_address() + .to_bech32m_string(s.ctx.bank().network()); + teardown_sweep_shielded(&s.test_wallet, &handle, &bank_addr).await; + s.teardown().await.expect("teardown"); +} diff --git a/packages/rs-platform-wallet/tests/e2e/cases/sh_004_balance_after_sync.rs b/packages/rs-platform-wallet/tests/e2e/cases/sh_004_balance_after_sync.rs new file mode 100644 index 00000000000..57849cd0399 --- /dev/null +++ b/packages/rs-platform-wallet/tests/e2e/cases/sh_004_balance_after_sync.rs @@ -0,0 +1,118 @@ +//! SH-004 — `shielded_balances` reflects a shielded note only after a +//! coordinator sync. +//! Spec: `tests/e2e/TEST_SPEC.md` §3 "### Shielded (SH)" → SH-004. +//! Priority: P1. +//! +//! Pins that balances read from the LOCAL store, not a live chain query: +//! before `coordinator.sync` the on-chain note is invisible; after a +//! forced sync it appears exactly. Also confirms the map is filtered to +//! this wallet's id (a second bound wallet's notes never leak in — here +//! we only assert the single-account exact-value shape). +//! +//! Expected outcome: PASS. + +use std::time::Duration; + +use crate::framework::prelude::*; +use crate::framework::shielded::{bind_shielded, shielded_prover, teardown_sweep_shielded}; +use crate::framework::wait::{ + wait_for_address_balance_chain_confirmed_n, CHAIN_CONFIRMED_CONSECUTIVE_SUCCESSES, +}; + +const FUNDING_CREDITS: u64 = 1_200_000_000; +const SHIELD_AMOUNT: u64 = 50_000_000; +const STEP_TIMEOUT: Duration = Duration::from_secs(60); + +#[tokio_shared_rt::test(shared, flavor = "multi_thread", worker_threads = 12)] +async fn sh_004_balance_after_sync() { + let _ = tracing_subscriber::fmt() + .with_env_filter( + tracing_subscriber::EnvFilter::try_from_default_env() + .unwrap_or_else(|_| "info,platform_wallet=debug".into()), + ) + .with_test_writer() + .try_init(); + + let s = setup().await.expect("e2e setup failed"); + let prover = shielded_prover(); + + let addr_1 = s + .test_wallet + .next_unused_address() + .await + .expect("derive addr_1"); + s.ctx + .bank() + .fund_address(&addr_1, FUNDING_CREDITS) + .await + .expect("bank.fund_address"); + wait_for_address_balance_chain_confirmed_n( + s.ctx.sdk(), + &addr_1, + FUNDING_CREDITS, + CHAIN_CONFIRMED_CONSECUTIVE_SUCCESSES, + STEP_TIMEOUT, + ) + .await + .expect("addr_1 funding never observed"); + s.test_wallet + .sync_balances() + .await + .expect("pre-shield sync"); + + let handle = bind_shielded(&s.test_wallet, &[0], &s.ctx.workdir) + .await + .expect("bind_shielded"); + + s.test_wallet + .platform_wallet() + .shielded_shield_from_account(0, 0, SHIELD_AMOUNT, s.test_wallet.address_signer(), prover) + .await + .expect("shield_from_account"); + + // BEFORE any sync: the note is on-chain but not scanned into the + // local store, so the balance map must not yet include it. + let pre = handle + .balances(&s.test_wallet) + .await + .expect("pre-sync shielded_balances"); + assert_eq!( + pre.get(&0).copied().unwrap_or(0), + 0, + "shielded_balances must read from the local store: account 0 must be absent / 0 \ + before coordinator.sync; observed {:?}", + pre.get(&0) + ); + + // Drive forced syncs until the note is scanned in, then assert the + // exact value (not just "non-empty"). + let deadline = std::time::Instant::now() + STEP_TIMEOUT; + let post = loop { + handle.sync().await; + let bal = handle + .balances(&s.test_wallet) + .await + .expect("post-sync shielded_balances"); + if bal.get(&0).copied().unwrap_or(0) >= SHIELD_AMOUNT { + break bal; + } + assert!( + std::time::Instant::now() < deadline, + "shielded note never scanned into the local store within {STEP_TIMEOUT:?}" + ); + tokio::time::sleep(Duration::from_millis(500)).await; + }; + assert_eq!( + post.get(&0).copied(), + Some(SHIELD_AMOUNT), + "post-sync shielded_balances must equal {{0: {SHIELD_AMOUNT}}} exactly; observed {post:?}" + ); + + let bank_addr = s + .ctx + .bank() + .primary_receive_address() + .to_bech32m_string(s.ctx.bank().network()); + teardown_sweep_shielded(&s.test_wallet, &handle, &bank_addr).await; + s.teardown().await.expect("teardown"); +} diff --git a/packages/rs-platform-wallet/tests/e2e/cases/sh_005_inmemory_witness_split.rs b/packages/rs-platform-wallet/tests/e2e/cases/sh_005_inmemory_witness_split.rs new file mode 100644 index 00000000000..14220562337 --- /dev/null +++ b/packages/rs-platform-wallet/tests/e2e/cases/sh_005_inmemory_witness_split.rs @@ -0,0 +1,189 @@ +//! SH-005 — Spend against the in-memory store fails witness-unavailable; +//! the file-backed store succeeds (Found-027 pin). +//! Spec: `tests/e2e/TEST_SPEC.md` §3 "### Shielded (SH)" → SH-005. +//! Priority: P1. **RED-by-design** until Found-027 is fixed. +//! +//! `InMemoryShieldedStore::witness()` unconditionally returns `Err`, so +//! every spend (unshield/transfer/withdraw) is structurally +//! non-functional against it, while `FileBackedShieldedStore::witness()` +//! works — a silent backing-store-dependent capability split with no +//! type-level signal. Both implement the same `ShieldedStore` trait. +//! +//! This test seeds the SAME funded note into both stores and builds +//! identical unshields: +//! * InMemory arm asserts `ShieldedMerkleWitnessUnavailable` (exact +//! variant) — this documents the split. +//! * FileBacked arm asserts `Ok(())`. +//! +//! The InMemory arm flips to a regression guard once Found-027 is +//! addressed (witness gains a real impl, or the type system forbids +//! spending against a store that cannot witness). + +use std::time::Duration; + +use platform_wallet::error::PlatformWalletError; +use platform_wallet::wallet::shielded::{operations, OrchardKeySet, SubwalletId}; + +use crate::framework::prelude::*; +use crate::framework::shielded::{ + bind_shielded, in_memory_store, shielded_prover, teardown_sweep_shielded, + wait_for_shielded_balance, +}; +use crate::framework::wait::{ + wait_for_address_balance_chain_confirmed_n, CHAIN_CONFIRMED_CONSECUTIVE_SUCCESSES, +}; + +const FUNDING_CREDITS: u64 = 2_220_000_000; +const SHIELD_AMOUNT: u64 = 1_120_000_000; +const UNSHIELD_AMOUNT: u64 = 20_000_000; +const STEP_TIMEOUT: Duration = Duration::from_secs(60); + +#[tokio_shared_rt::test(shared, flavor = "multi_thread", worker_threads = 12)] +async fn sh_005_inmemory_witness_split() { + let _ = tracing_subscriber::fmt() + .with_env_filter( + tracing_subscriber::EnvFilter::try_from_default_env() + .unwrap_or_else(|_| "info,platform_wallet=debug".into()), + ) + .with_test_writer() + .try_init(); + + let s = setup().await.expect("e2e setup failed"); + let prover = shielded_prover(); + + let addr_1 = s + .test_wallet + .next_unused_address() + .await + .expect("derive addr_1"); + s.ctx + .bank() + .fund_address(&addr_1, FUNDING_CREDITS) + .await + .expect("bank.fund_address"); + wait_for_address_balance_chain_confirmed_n( + s.ctx.sdk(), + &addr_1, + FUNDING_CREDITS, + CHAIN_CONFIRMED_CONSECUTIVE_SUCCESSES, + STEP_TIMEOUT, + ) + .await + .expect("addr_1 funding never observed"); + s.test_wallet + .sync_balances() + .await + .expect("pre-shield sync"); + + // FileBacked coordinator: shield + sync so the note is in the + // commitment tree and witnessable. + let handle = bind_shielded(&s.test_wallet, &[0], &s.ctx.workdir) + .await + .expect("bind_shielded"); + s.test_wallet + .platform_wallet() + .shielded_shield_from_account(0, 0, SHIELD_AMOUNT, s.test_wallet.address_signer(), prover) + .await + .expect("shield_from_account"); + wait_for_shielded_balance(&s.test_wallet, &handle, 0, SHIELD_AMOUNT, STEP_TIMEOUT) + .await + .expect("shielded balance never reached SHIELD_AMOUNT"); + + let pw = s.test_wallet.platform_wallet(); + let wallet_id = pw.wallet_id(); + let id = SubwalletId::new(wallet_id, 0); + let keyset = OrchardKeySet::from_seed(&s.test_wallet.seed_bytes(), pw.sdk().network, 0) + .expect("derive OrchardKeySet for account 0"); + + // Copy the synced note out of the FileBacked store into a fresh + // InMemory store, so note SELECTION succeeds on both — the only + // difference is whether `witness()` can produce an auth path. + let synced_notes = { + use platform_wallet::wallet::shielded::ShieldedStore; + let store = handle.coordinator.store().read().await; + store + .get_unspent_notes(id) + .expect("get_unspent_notes from FileBacked store") + }; + assert!( + !synced_notes.is_empty(), + "FileBacked store must hold the synced note before the split test" + ); + + let inmem = in_memory_store(); + { + use platform_wallet::wallet::shielded::ShieldedStore; + let mut store = inmem.write().await; + for note in &synced_notes { + store + .save_note(id, note) + .expect("seed InMemory store with note"); + store + .append_commitment(¬e.cmx, true) + .expect("append commitment to InMemory store"); + } + } + + // Destination address for both arms. + let addr_dst = s + .test_wallet + .next_unused_address() + .await + .expect("derive addr_dst"); + + // InMemory arm: note selection succeeds, but `witness()` is a hard + // Err → mapped to `ShieldedMerkleWitnessUnavailable`. This is the + // Found-027 pin. + let inmem_result = operations::unshield( + &pw.sdk_arc(), + &inmem, + None, + wallet_id, + &keyset, + 0, + &addr_dst, + UNSHIELD_AMOUNT, + &prover, + ) + .await; + assert!( + matches!( + inmem_result, + Err(PlatformWalletError::ShieldedMerkleWitnessUnavailable(_)) + ), + "InMemory spend must fail with ShieldedMerkleWitnessUnavailable (Found-027); \ + observed {inmem_result:?}" + ); + + // FileBacked arm: the same unshield succeeds and the destination + // balance arrives. + let addr_dst_bech32m = addr_dst.to_bech32m_string(s.ctx.bank().network()); + s.test_wallet + .platform_wallet() + .shielded_unshield_to( + &handle.coordinator, + 0, + &addr_dst_bech32m, + UNSHIELD_AMOUNT, + prover, + ) + .await + .expect("FileBacked unshield must succeed"); + wait_for_address_balance_chain_confirmed_n( + s.ctx.sdk(), + &addr_dst, + UNSHIELD_AMOUNT, + CHAIN_CONFIRMED_CONSECUTIVE_SUCCESSES, + STEP_TIMEOUT, + ) + .await + .expect("FileBacked unshield destination never observed"); + + let bank_addr = s + .ctx + .bank() + .primary_receive_address() + .to_bech32m_string(s.ctx.bank().network()); + teardown_sweep_shielded(&s.test_wallet, &handle, &bank_addr).await; + s.teardown().await.expect("teardown"); +} diff --git a/packages/rs-platform-wallet/tests/e2e/cases/sh_006_add_account_never_syncs.rs b/packages/rs-platform-wallet/tests/e2e/cases/sh_006_add_account_never_syncs.rs new file mode 100644 index 00000000000..75868049fc2 --- /dev/null +++ b/packages/rs-platform-wallet/tests/e2e/cases/sh_006_add_account_never_syncs.rs @@ -0,0 +1,134 @@ +//! SH-006 — `shielded_add_account` post-bind: notes for the added +//! account never sync (Found-028 pin). +//! Spec: `tests/e2e/TEST_SPEC.md` §3 "### Shielded (SH)" → SH-006. +//! Priority: P1. **RED-by-design.** +//! +//! `shielded_add_account` inserts the new account's `OrchardKeySet` into +//! the per-wallet keys slot but does NOT call `coordinator.register_wallet` +//! with the expanded account set, so the coordinator's IVK fan-out never +//! learns the new account's IVK and notes paid to it are never +//! discovered. The doc-comment admits this as a "caveat" — documenting a +//! silent fund-invisibility footgun does not make it not-a-bug. +//! +//! This test binds account 0, adds account 1 via `shielded_add_account`, +//! pays a private note to account 1 (self-transfer from account 0), then +//! asserts CORRECT behaviour: account 1's balance reflects the note. That +//! assertion FAILS today (the coordinator never scanned account 1's IVK), +//! which is the Found-028 finding. + +use std::time::Duration; + +use crate::framework::prelude::*; +use crate::framework::shielded::{ + bind_shielded, shielded_default_address_43, shielded_prover, teardown_sweep_shielded, + wait_for_shielded_balance, +}; +use crate::framework::wait::{ + wait_for_address_balance_chain_confirmed_n, CHAIN_CONFIRMED_CONSECUTIVE_SUCCESSES, +}; + +const FUNDING_CREDITS: u64 = 2_220_000_000; +const SHIELD_AMOUNT: u64 = 1_120_000_000; +const TRANSFER_AMOUNT: u64 = 20_000_000; +const STEP_TIMEOUT: Duration = Duration::from_secs(60); + +#[tokio_shared_rt::test(shared, flavor = "multi_thread", worker_threads = 12)] +async fn sh_006_add_account_never_syncs() { + let _ = tracing_subscriber::fmt() + .with_env_filter( + tracing_subscriber::EnvFilter::try_from_default_env() + .unwrap_or_else(|_| "info,platform_wallet=debug".into()), + ) + .with_test_writer() + .try_init(); + + let s = setup().await.expect("e2e setup failed"); + let prover = shielded_prover(); + + let addr_1 = s + .test_wallet + .next_unused_address() + .await + .expect("derive addr_1"); + s.ctx + .bank() + .fund_address(&addr_1, FUNDING_CREDITS) + .await + .expect("bank.fund_address"); + wait_for_address_balance_chain_confirmed_n( + s.ctx.sdk(), + &addr_1, + FUNDING_CREDITS, + CHAIN_CONFIRMED_CONSECUTIVE_SUCCESSES, + STEP_TIMEOUT, + ) + .await + .expect("addr_1 funding never observed"); + s.test_wallet + .sync_balances() + .await + .expect("pre-shield sync"); + + // Bind ONLY account 0, then add account 1 post-bind. + let handle = bind_shielded(&s.test_wallet, &[0], &s.ctx.workdir) + .await + .expect("bind_shielded"); + s.test_wallet + .platform_wallet() + .shielded_add_account(&s.test_wallet.seed_bytes(), 1) + .await + .expect("shielded_add_account"); + + // The per-wallet slot was updated — this part works. + let indices = s + .test_wallet + .platform_wallet() + .shielded_account_indices() + .await; + assert!( + indices.contains(&1), + "shielded_account_indices must include the added account 1; observed {indices:?}" + ); + + // Shield into account 0, then pay a private note to account 1. + s.test_wallet + .platform_wallet() + .shielded_shield_from_account(0, 0, SHIELD_AMOUNT, s.test_wallet.address_signer(), prover) + .await + .expect("shield_from_account"); + wait_for_shielded_balance(&s.test_wallet, &handle, 0, SHIELD_AMOUNT, STEP_TIMEOUT) + .await + .expect("account 0 shielded balance never reached SHIELD_AMOUNT"); + + let acct1_addr = shielded_default_address_43(&s.test_wallet, 1) + .await + .expect("account 1 default Orchard address"); + s.test_wallet + .platform_wallet() + .shielded_transfer_to(&handle.coordinator, 0, &acct1_addr, TRANSFER_AMOUNT, prover) + .await + .expect("shielded_transfer_to account 1"); + + // CORRECT behaviour: account 1 should reflect the note. This wait + // FAILS today (Found-028 — the coordinator never scanned account 1's + // IVK), making the case RED-by-design. + let acct1 = + wait_for_shielded_balance(&s.test_wallet, &handle, 1, TRANSFER_AMOUNT, STEP_TIMEOUT) + .await + .expect( + "Found-028: account 1's note was never synced — shielded_add_account does not \ + re-register on the coordinator. This assertion is RED-by-design and pins the bug.", + ); + assert_eq!( + acct1, TRANSFER_AMOUNT, + "shielded_balances[1] must equal the note value (Found-028 pin); observed {acct1}" + ); + + let bank_addr = s + .ctx + .bank() + .primary_receive_address() + .to_bech32m_string(s.ctx.bank().network()); + teardown_sweep_shielded(&s.test_wallet, &handle, &bank_addr).await; + s.teardown().await.expect("teardown"); +} diff --git a/packages/rs-platform-wallet/tests/e2e/cases/sh_007_pre_bind_note_witnessable.rs b/packages/rs-platform-wallet/tests/e2e/cases/sh_007_pre_bind_note_witnessable.rs new file mode 100644 index 00000000000..6d7f41cf5e0 --- /dev/null +++ b/packages/rs-platform-wallet/tests/e2e/cases/sh_007_pre_bind_note_witnessable.rs @@ -0,0 +1,183 @@ +//! SH-007 — A pre-bind note is witnessable/spendable (Found-029 +//! regression guard, #3603 FIXED). +//! Spec: `tests/e2e/TEST_SPEC.md` §3 "### Shielded (SH)" → SH-007. +//! Priority: P1. **GREEN regression guard** (NOT red-by-design). +//! +//! Before #3603 the coordinator marked only positions a currently- +//! registered IVK decrypted, so a note for wallet B landing while B was +//! unbound had its auth path discarded — B's later bind discovered the +//! balance but the position was unwitnessable. #3603's `sync.rs` rewrite +//! marks EVERY commitment position so the shared tree is witness-complete +//! regardless of bind ordering. This case guards that fix: a regression +//! to mark-only-owned flips the spend to `ShieldedMerkleWitnessUnavailable` +//! and the test goes RED. +//! +//! Coupling: the spend leg MUST use the FileBacked store (Found-027 is +//! independent of #3603 and would mask this guard with a false RED). The +//! harness `bind_shielded` always uses FileBacked. + +use std::sync::Arc; +use std::time::Duration; + +use platform_wallet::wallet::shielded::OrchardKeySet; + +use crate::framework::prelude::*; +use crate::framework::shielded::{ + new_file_backed_coordinator, shielded_prover, teardown_sweep_shielded, + wait_for_shielded_balance, ShieldedHandle, +}; +use crate::framework::wait::{ + wait_for_address_balance_chain_confirmed_n, CHAIN_CONFIRMED_CONSECUTIVE_SUCCESSES, +}; + +const FUNDING_CREDITS: u64 = 2_220_000_000; +const SHIELD_AMOUNT: u64 = 1_120_000_000; +const NOTE_TO_B: u64 = 20_000_000; +const B_UNSHIELD: u64 = 8_000_000; +const STEP_TIMEOUT: Duration = Duration::from_secs(60); + +#[tokio_shared_rt::test(shared, flavor = "multi_thread", worker_threads = 12)] +async fn sh_007_pre_bind_note_witnessable() { + let _ = tracing_subscriber::fmt() + .with_env_filter( + tracing_subscriber::EnvFilter::try_from_default_env() + .unwrap_or_else(|_| "info,platform_wallet=debug".into()), + ) + .with_test_writer() + .try_init(); + + // Two wallets sharing ONE FileBacked coordinator: A is the sync + // driver, B receives a note before binding. + let a = setup().await.expect("setup wallet A"); + let b = setup().await.expect("setup wallet B"); + let prover = shielded_prover(); + + // Single shared coordinator (built off A's manager/SDK). + let coordinator = new_file_backed_coordinator(&a.test_wallet, &a.ctx.workdir) + .await + .expect("shared coordinator"); + + // Bind A on the shared coordinator. + a.test_wallet + .platform_wallet() + .bind_shielded(&a.test_wallet.seed_bytes(), &[0], &coordinator) + .await + .expect("bind A"); + let a_handle = ShieldedHandle { + coordinator: Arc::clone(&coordinator), + accounts: vec![0], + }; + + // Fund + shield into A so A has a spendable note to pay B with. + let addr_1 = a + .test_wallet + .next_unused_address() + .await + .expect("derive addr_1"); + a.ctx + .bank() + .fund_address(&addr_1, FUNDING_CREDITS) + .await + .expect("bank.fund_address"); + wait_for_address_balance_chain_confirmed_n( + a.ctx.sdk(), + &addr_1, + FUNDING_CREDITS, + CHAIN_CONFIRMED_CONSECUTIVE_SUCCESSES, + STEP_TIMEOUT, + ) + .await + .expect("addr_1 funding never observed"); + a.test_wallet + .sync_balances() + .await + .expect("pre-shield sync"); + a.test_wallet + .platform_wallet() + .shielded_shield_from_account(0, 0, SHIELD_AMOUNT, a.test_wallet.address_signer(), prover) + .await + .expect("A shield_from_account"); + wait_for_shielded_balance(&a.test_wallet, &a_handle, 0, SHIELD_AMOUNT, STEP_TIMEOUT) + .await + .expect("A shielded balance never reached SHIELD_AMOUNT"); + + // Derive B's default Orchard address WITHOUT binding B (so its note + // lands while B is unbound — the pre-bind condition #3603 fixes). + let b_keyset = OrchardKeySet::from_seed( + &b.test_wallet.seed_bytes(), + b.test_wallet.platform_wallet().sdk().network, + 0, + ) + .expect("derive B OrchardKeySet"); + let b_addr_43 = b_keyset.default_address.to_raw_address_bytes(); + + // A pays a private note to B while B is UNBOUND, then A drives a sync + // (still B-unbound) so B's position is appended under the + // mark-every-position policy. + a.test_wallet + .platform_wallet() + .shielded_transfer_to(&coordinator, 0, &b_addr_43, NOTE_TO_B, prover) + .await + .expect("A → B private transfer"); + let _ = coordinator.sync(true).await; + + // NOW bind B on the same coordinator and sync. + b.test_wallet + .platform_wallet() + .bind_shielded(&b.test_wallet.seed_bytes(), &[0], &coordinator) + .await + .expect("bind B"); + let b_handle = ShieldedHandle { + coordinator: Arc::clone(&coordinator), + accounts: vec![0], + }; + + // B's balance is discoverable. + let b_bal = wait_for_shielded_balance(&b.test_wallet, &b_handle, 0, NOTE_TO_B, STEP_TIMEOUT) + .await + .expect("B never discovered its pre-bind note"); + assert_eq!( + b_bal, NOTE_TO_B, + "B's pre-bind note balance must equal the note value; observed {b_bal}" + ); + + // GREEN guard: the pre-bind note IS witnessable, so B can spend it. A + // regression to mark-only-owned flips this to + // ShieldedMerkleWitnessUnavailable and the test goes RED. + let b_dst = b + .test_wallet + .next_unused_address() + .await + .expect("derive B dst"); + let b_dst_bech32m = b_dst.to_bech32m_string(b.ctx.bank().network()); + b.test_wallet + .platform_wallet() + .shielded_unshield_to(&coordinator, 0, &b_dst_bech32m, B_UNSHIELD, prover) + .await + .unwrap_or_else(|e| { + panic!( + "SH-007: B's pre-bind note unshield failed: {e}. If this is a \ + ShieldedMerkleWitnessUnavailable / anchor error, the \ + mark-every-position witness policy (#3603, Found-029) regressed." + ) + }); + wait_for_address_balance_chain_confirmed_n( + b.ctx.sdk(), + &b_dst, + B_UNSHIELD, + CHAIN_CONFIRMED_CONSECUTIVE_SUCCESSES, + STEP_TIMEOUT, + ) + .await + .expect("B unshield destination never observed"); + + let bank_addr = a + .ctx + .bank() + .primary_receive_address() + .to_bech32m_string(a.ctx.bank().network()); + teardown_sweep_shielded(&b.test_wallet, &b_handle, &bank_addr).await; + teardown_sweep_shielded(&a.test_wallet, &a_handle, &bank_addr).await; + b.teardown().await.expect("teardown B"); + a.teardown().await.expect("teardown A"); +} diff --git a/packages/rs-platform-wallet/tests/e2e/cases/sh_008_unshield_insufficient_balance.rs b/packages/rs-platform-wallet/tests/e2e/cases/sh_008_unshield_insufficient_balance.rs new file mode 100644 index 00000000000..127225cd1d4 --- /dev/null +++ b/packages/rs-platform-wallet/tests/e2e/cases/sh_008_unshield_insufficient_balance.rs @@ -0,0 +1,153 @@ +//! SH-008 — Unshield insufficient-balance: typed error with exact +//! `available`/`required`. +//! Spec: `tests/e2e/TEST_SPEC.md` §3 "### Shielded (SH)" → SH-008. +//! Priority: P1. +//! +//! Shields a small note, then requests an unshield far above it. The +//! failure is pre-build (no proof paid) and carries the structured +//! `(available, required)` with the fee folded into `required`. A +//! follow-up satisfiable unshield must succeed, proving the reservation +//! was released by `cancel_pending`. +//! +//! Expected outcome: PASS. + +use std::time::Duration; + +use platform_wallet::error::PlatformWalletError; + +use crate::framework::prelude::*; +use crate::framework::shielded::{ + bind_shielded, shielded_prover, teardown_sweep_shielded, wait_for_shielded_balance, +}; +use crate::framework::wait::{ + wait_for_address_balance_chain_confirmed_n, CHAIN_CONFIRMED_CONSECUTIVE_SUCCESSES, +}; + +// SHIELD_AMOUNT must cover the SATISFIABLE unshield plus the shielded fee +// (~1e9, folded into the spend's requirement); the OVERDRAW stays well +// above the shielded balance so it still trips ShieldedInsufficientBalance. +const FUNDING_CREDITS: u64 = 2_220_000_000; +const SHIELD_AMOUNT: u64 = 1_120_000_000; +const OVERDRAW_AMOUNT: u64 = 2_000_000_000; +const SATISFIABLE_AMOUNT: u64 = 3_000_000; +const STEP_TIMEOUT: Duration = Duration::from_secs(60); + +#[tokio_shared_rt::test(shared, flavor = "multi_thread", worker_threads = 12)] +async fn sh_008_unshield_insufficient_balance() { + let _ = tracing_subscriber::fmt() + .with_env_filter( + tracing_subscriber::EnvFilter::try_from_default_env() + .unwrap_or_else(|_| "info,platform_wallet=debug".into()), + ) + .with_test_writer() + .try_init(); + + let s = setup().await.expect("e2e setup failed"); + let prover = shielded_prover(); + + let addr_1 = s + .test_wallet + .next_unused_address() + .await + .expect("derive addr_1"); + s.ctx + .bank() + .fund_address(&addr_1, FUNDING_CREDITS) + .await + .expect("bank.fund_address"); + wait_for_address_balance_chain_confirmed_n( + s.ctx.sdk(), + &addr_1, + FUNDING_CREDITS, + CHAIN_CONFIRMED_CONSECUTIVE_SUCCESSES, + STEP_TIMEOUT, + ) + .await + .expect("addr_1 funding never observed"); + s.test_wallet + .sync_balances() + .await + .expect("pre-shield sync"); + + let handle = bind_shielded(&s.test_wallet, &[0], &s.ctx.workdir) + .await + .expect("bind_shielded"); + s.test_wallet + .platform_wallet() + .shielded_shield_from_account(0, 0, SHIELD_AMOUNT, s.test_wallet.address_signer(), prover) + .await + .expect("shield_from_account"); + wait_for_shielded_balance(&s.test_wallet, &handle, 0, SHIELD_AMOUNT, STEP_TIMEOUT) + .await + .expect("shielded balance never reached SHIELD_AMOUNT"); + + let addr_dst = s + .test_wallet + .next_unused_address() + .await + .expect("derive addr_dst"); + let addr_dst_bech32m = addr_dst.to_bech32m_string(s.ctx.bank().network()); + + // Overdraw: far above the only note's value → typed error, no proof. + let result = s + .test_wallet + .platform_wallet() + .shielded_unshield_to( + &handle.coordinator, + 0, + &addr_dst_bech32m, + OVERDRAW_AMOUNT, + prover, + ) + .await; + match result { + Err(PlatformWalletError::ShieldedInsufficientBalance { + available, + required, + }) => { + assert_eq!( + available, SHIELD_AMOUNT, + "available must equal the only note's value ({SHIELD_AMOUNT}); observed {available}" + ); + assert!( + required > OVERDRAW_AMOUNT, + "required must fold the fee into the requirement (required > amount); \ + required={required} amount={OVERDRAW_AMOUNT}" + ); + } + other => panic!( + "expected ShieldedInsufficientBalance {{ available, required }}; observed {other:?}" + ), + } + + // Follow-up satisfiable unshield must succeed — proves the + // reservation taken during the failed attempt was released. + s.test_wallet + .platform_wallet() + .shielded_unshield_to( + &handle.coordinator, + 0, + &addr_dst_bech32m, + SATISFIABLE_AMOUNT, + prover, + ) + .await + .expect("satisfiable unshield after release must succeed"); + wait_for_address_balance_chain_confirmed_n( + s.ctx.sdk(), + &addr_dst, + SATISFIABLE_AMOUNT, + CHAIN_CONFIRMED_CONSECUTIVE_SUCCESSES, + STEP_TIMEOUT, + ) + .await + .expect("satisfiable unshield destination never observed"); + + let bank_addr = s + .ctx + .bank() + .primary_receive_address() + .to_bech32m_string(s.ctx.bank().network()); + teardown_sweep_shielded(&s.test_wallet, &handle, &bank_addr).await; + s.teardown().await.expect("teardown"); +} diff --git a/packages/rs-platform-wallet/tests/e2e/cases/sh_009_zero_amount_rejected.rs b/packages/rs-platform-wallet/tests/e2e/cases/sh_009_zero_amount_rejected.rs new file mode 100644 index 00000000000..aa0cfe73e3c --- /dev/null +++ b/packages/rs-platform-wallet/tests/e2e/cases/sh_009_zero_amount_rejected.rs @@ -0,0 +1,99 @@ +//! SH-009 — Zero-amount shield / transfer / unshield rejected at the +//! boundary (no proof paid). +//! Spec: `tests/e2e/TEST_SPEC.md` §3 "### Shielded (SH)" → SH-009. +//! Priority: P2. +//! +//! Each call with `amount == 0` must return a typed `Err` (not a panic, +//! not `Ok`) synchronously — well under one ~30 s proof. The shield +//! zero-guard is confirmed in production (`platform_wallet.rs:733`); the +//! transfer/unshield guards are unconfirmed in the audit — **if either +//! lacks a zero-guard, this case goes RED and surfaces a +//! missing-validation finding** (mirrors PA-001c's contract framing). + +use std::time::{Duration, Instant}; + +use crate::framework::prelude::*; +use crate::framework::shielded::{bind_shielded, shielded_default_address_43, shielded_prover}; + +/// Generous upper bound: a synchronous boundary rejection must return far +/// below one Halo-2 proof (~30 s). A few seconds covers lock acquisition +/// and address parsing without admitting a proof build. +const REJECT_CEILING: Duration = Duration::from_secs(5); + +#[tokio_shared_rt::test(shared, flavor = "multi_thread", worker_threads = 12)] +async fn sh_009_zero_amount_rejected() { + let _ = tracing_subscriber::fmt() + .with_env_filter( + tracing_subscriber::EnvFilter::try_from_default_env() + .unwrap_or_else(|_| "info,platform_wallet=debug".into()), + ) + .with_test_writer() + .try_init(); + + let s = setup().await.expect("e2e setup failed"); + let prover = shielded_prover(); + + let handle = bind_shielded(&s.test_wallet, &[0, 1], &s.ctx.workdir) + .await + .expect("bind_shielded"); + let pw = s.test_wallet.platform_wallet(); + + // Shield with amount == 0. + let t0 = Instant::now(); + let shield = pw + .shielded_shield_from_account(0, 0, 0, s.test_wallet.address_signer(), prover) + .await; + assert!( + shield.is_err(), + "zero-amount shield must be rejected with a typed Err; observed {shield:?}" + ); + assert!( + t0.elapsed() < REJECT_CEILING, + "zero-amount shield must reject synchronously (no proof build); took {:?}", + t0.elapsed() + ); + + // Transfer with amount == 0 to account 1's address. + let acct1_addr = shielded_default_address_43(&s.test_wallet, 1) + .await + .expect("account 1 default Orchard address"); + let t1 = Instant::now(); + let transfer = pw + .shielded_transfer_to(&handle.coordinator, 0, &acct1_addr, 0, prover) + .await; + assert!( + transfer.is_err(), + "zero-amount transfer must be rejected with a typed Err (RED if no guard exists); \ + observed {transfer:?}" + ); + assert!( + t1.elapsed() < REJECT_CEILING, + "zero-amount transfer must reject synchronously; took {:?}", + t1.elapsed() + ); + + // Unshield with amount == 0 to a transparent address. + let addr_dst = s + .test_wallet + .next_unused_address() + .await + .expect("derive addr_dst"); + let addr_dst_bech32m = addr_dst.to_bech32m_string(s.ctx.bank().network()); + let t2 = Instant::now(); + let unshield = pw + .shielded_unshield_to(&handle.coordinator, 0, &addr_dst_bech32m, 0, prover) + .await; + assert!( + unshield.is_err(), + "zero-amount unshield must be rejected with a typed Err (RED if no guard exists); \ + observed {unshield:?}" + ); + assert!( + t2.elapsed() < REJECT_CEILING, + "zero-amount unshield must reject synchronously; took {:?}", + t2.elapsed() + ); + + // No funds were ever shielded, so the teardown sweep is a no-op. + s.teardown().await.expect("teardown"); +} diff --git a/packages/rs-platform-wallet/tests/e2e/cases/sh_010_double_spend_reservation.rs b/packages/rs-platform-wallet/tests/e2e/cases/sh_010_double_spend_reservation.rs new file mode 100644 index 00000000000..9fdbabe8410 --- /dev/null +++ b/packages/rs-platform-wallet/tests/e2e/cases/sh_010_double_spend_reservation.rs @@ -0,0 +1,136 @@ +//! SH-010 — Double-spend guard: two overlapping spends reserve disjoint +//! notes (`reserve_unspent_notes`). +//! Spec: `tests/e2e/TEST_SPEC.md` §3 "### Shielded (SH)" → SH-010. +//! Priority: P2. +//! +//! Shields two notes into account 0, then fires two concurrent unshields +//! each coverable by one note. The single-write-lock select+reserve must +//! hand them disjoint notes — no shared nullifier, no double-count. If +//! both succeed, the shielded balance dropped by `2*amount + 2*fee`. +//! +//! Expected outcome: PASS — this is the contract `reserve_unspent_notes` +//! exists to uphold; the canary for a reservation-race regression. + +use std::time::Duration; + +use crate::framework::prelude::*; +use crate::framework::shielded::{ + bind_shielded, shielded_prover, teardown_sweep_shielded, wait_for_shielded_balance, +}; +use crate::framework::wait::{ + wait_for_address_balance_chain_confirmed_n, CHAIN_CONFIRMED_CONSECUTIVE_SUCCESSES, +}; + +// Each note must independently cover one UNSHIELD_EACH plus the shielded +// fee (~1e9), since the two concurrent unshields take disjoint single notes. +const FUNDING_CREDITS: u64 = 2_210_000_000; +const SHIELD_EACH: u64 = 1_110_000_000; +const UNSHIELD_EACH: u64 = 10_000_000; +const STEP_TIMEOUT: Duration = Duration::from_secs(60); + +#[tokio_shared_rt::test(shared, flavor = "multi_thread", worker_threads = 12)] +async fn sh_010_double_spend_reservation() { + let _ = tracing_subscriber::fmt() + .with_env_filter( + tracing_subscriber::EnvFilter::try_from_default_env() + .unwrap_or_else(|_| "info,platform_wallet=debug".into()), + ) + .with_test_writer() + .try_init(); + + let s = setup().await.expect("e2e setup failed"); + let prover = shielded_prover(); + + // Two separate fundings → two shields → two distinct notes. + for _ in 0..2 { + let addr = s + .test_wallet + .next_unused_address() + .await + .expect("derive funding addr"); + s.ctx + .bank() + .fund_address(&addr, FUNDING_CREDITS) + .await + .expect("bank.fund_address"); + wait_for_address_balance_chain_confirmed_n( + s.ctx.sdk(), + &addr, + FUNDING_CREDITS, + CHAIN_CONFIRMED_CONSECUTIVE_SUCCESSES, + STEP_TIMEOUT, + ) + .await + .expect("funding never observed"); + s.test_wallet + .sync_balances() + .await + .expect("pre-shield sync"); + } + + let handle = bind_shielded(&s.test_wallet, &[0], &s.ctx.workdir) + .await + .expect("bind_shielded"); + for _ in 0..2 { + s.test_wallet + .platform_wallet() + .shielded_shield_from_account(0, 0, SHIELD_EACH, s.test_wallet.address_signer(), prover) + .await + .expect("shield_from_account"); + } + wait_for_shielded_balance(&s.test_wallet, &handle, 0, SHIELD_EACH * 2, STEP_TIMEOUT) + .await + .expect("shielded balance never reached 2 notes"); + + let before = handle + .balances(&s.test_wallet) + .await + .expect("pre-spend shielded_balances") + .get(&0) + .copied() + .unwrap_or(0); + + // Two destinations, two concurrent unshields. + let dst_a = s.test_wallet.next_unused_address().await.expect("dst_a"); + let dst_b = s.test_wallet.next_unused_address().await.expect("dst_b"); + let dst_a_b32 = dst_a.to_bech32m_string(s.ctx.bank().network()); + let dst_b_b32 = dst_b.to_bech32m_string(s.ctx.bank().network()); + let pw = s.test_wallet.platform_wallet(); + + let (ra, rb) = tokio::join!( + pw.shielded_unshield_to(&handle.coordinator, 0, &dst_a_b32, UNSHIELD_EACH, prover), + pw.shielded_unshield_to(&handle.coordinator, 0, &dst_b_b32, UNSHIELD_EACH, prover), + ); + + // At most one may fail (if only one note were spendable); if both + // succeed they MUST have reserved disjoint notes — verified via the + // post-spend balance drop being at least 2*amount (no double-count). + let succeeded = [ra.is_ok(), rb.is_ok()].iter().filter(|ok| **ok).count(); + assert!( + succeeded >= 1, + "at least one concurrent unshield must succeed; ra={ra:?} rb={rb:?}" + ); + + handle.sync().await; + let after = handle + .balances(&s.test_wallet) + .await + .expect("post-spend shielded_balances") + .get(&0) + .copied() + .unwrap_or(0); + let dropped = before.saturating_sub(after); + assert!( + dropped >= UNSHIELD_EACH * (succeeded as u64), + "shielded balance must drop by at least {UNSHIELD_EACH} per successful spend \ + (disjoint notes, no double-count); before={before} after={after} succeeded={succeeded}" + ); + + let bank_addr = s + .ctx + .bank() + .primary_receive_address() + .to_bech32m_string(s.ctx.bank().network()); + teardown_sweep_shielded(&s.test_wallet, &handle, &bank_addr).await; + s.teardown().await.expect("teardown"); +} diff --git a/packages/rs-platform-wallet/tests/e2e/cases/sh_011_note_selection_convergence.rs b/packages/rs-platform-wallet/tests/e2e/cases/sh_011_note_selection_convergence.rs new file mode 100644 index 00000000000..9e6a200aee8 --- /dev/null +++ b/packages/rs-platform-wallet/tests/e2e/cases/sh_011_note_selection_convergence.rs @@ -0,0 +1,167 @@ +//! SH-011 — `select_notes_with_fee` convergence + overflow protection on +//! a real funded note set. +//! Spec: `tests/e2e/TEST_SPEC.md` §3 "### Shielded (SH)" → SH-011. +//! Priority: P2. (A unit test covers overflow at `note_selection.rs:187`; +//! this is the e2e-adjacent variant on a real funded note set.) +//! +//! Shields several small notes, then unshields an amount that forces +//! multi-note selection so the fee grows with the action count and the +//! convergence loop iterates. Also probes the `checked_add` overflow +//! guard with a degenerate `u64::MAX` request. +//! +//! Expected outcome: PASS. + +use std::time::Duration; + +use platform_wallet::error::PlatformWalletError; + +use crate::framework::prelude::*; +use crate::framework::shielded::{ + bind_shielded, shielded_prover, teardown_sweep_shielded, wait_for_shielded_balance, +}; +use crate::framework::wait::{ + wait_for_address_balance_chain_confirmed_n, CHAIN_CONFIRMED_CONSECUTIVE_SUCCESSES, +}; + +const FUNDING_CREDITS: u64 = 1_700_000_000; +const SHIELD_EACH: u64 = 600_000_000; +const NUM_NOTES: u64 = 3; +/// Above any single note (600M) yet `+ fee` below the 3-note sum (1.8e9) — +/// the raw amount alone forces multi-note selection (fee-independent), so the +/// convergence loop iterates (>1 pass) regardless of the exact shielded fee. +const MULTI_NOTE_UNSHIELD: u64 = 650_000_000; +const STEP_TIMEOUT: Duration = Duration::from_secs(60); + +#[tokio_shared_rt::test(shared, flavor = "multi_thread", worker_threads = 12)] +async fn sh_011_note_selection_convergence() { + let _ = tracing_subscriber::fmt() + .with_env_filter( + tracing_subscriber::EnvFilter::try_from_default_env() + .unwrap_or_else(|_| "info,platform_wallet=debug".into()), + ) + .with_test_writer() + .try_init(); + + let s = setup().await.expect("e2e setup failed"); + let prover = shielded_prover(); + + for _ in 0..NUM_NOTES { + let addr = s + .test_wallet + .next_unused_address() + .await + .expect("derive funding addr"); + s.ctx + .bank() + .fund_address(&addr, FUNDING_CREDITS) + .await + .expect("bank.fund_address"); + wait_for_address_balance_chain_confirmed_n( + s.ctx.sdk(), + &addr, + FUNDING_CREDITS, + CHAIN_CONFIRMED_CONSECUTIVE_SUCCESSES, + STEP_TIMEOUT, + ) + .await + .expect("funding never observed"); + s.test_wallet + .sync_balances() + .await + .expect("pre-shield sync"); + } + + let handle = bind_shielded(&s.test_wallet, &[0], &s.ctx.workdir) + .await + .expect("bind_shielded"); + for _ in 0..NUM_NOTES { + s.test_wallet + .platform_wallet() + .shielded_shield_from_account(0, 0, SHIELD_EACH, s.test_wallet.address_signer(), prover) + .await + .expect("shield_from_account"); + } + wait_for_shielded_balance( + &s.test_wallet, + &handle, + 0, + SHIELD_EACH * NUM_NOTES, + STEP_TIMEOUT, + ) + .await + .expect("shielded balance never reached all notes"); + + let pw = s.test_wallet.platform_wallet(); + let addr_dst = s + .test_wallet + .next_unused_address() + .await + .expect("derive addr_dst"); + let addr_dst_bech32m = addr_dst.to_bech32m_string(s.ctx.bank().network()); + + // Overflow arm: a degenerate u64::MAX request must hit the + // `checked_add` guard rather than wrapping. + let overflow = pw + .shielded_unshield_to(&handle.coordinator, 0, &addr_dst_bech32m, u64::MAX, prover) + .await; + match overflow { + Err(PlatformWalletError::ShieldedBuildError(msg)) => assert!( + msg.contains("overflow"), + "u64::MAX request must surface an overflow build error; observed {msg:?}" + ), + Err(PlatformWalletError::ShieldedInsufficientBalance { .. }) => { + // Acceptable: the requirement overflow guard may live behind + // the balance check depending on the version; either way it + // did NOT wrap. The overflow build error is the tighter pin. + } + other => panic!("u64::MAX request must not wrap; observed {other:?}"), + } + + // Convergence arm: multi-note selection succeeds and the balance + // drops by at least the requested amount. + let before = handle + .balances(&s.test_wallet) + .await + .expect("pre-spend shielded_balances") + .get(&0) + .copied() + .unwrap_or(0); + pw.shielded_unshield_to( + &handle.coordinator, + 0, + &addr_dst_bech32m, + MULTI_NOTE_UNSHIELD, + prover, + ) + .await + .expect("multi-note unshield must succeed"); + wait_for_address_balance_chain_confirmed_n( + s.ctx.sdk(), + &addr_dst, + MULTI_NOTE_UNSHIELD, + CHAIN_CONFIRMED_CONSECUTIVE_SUCCESSES, + STEP_TIMEOUT, + ) + .await + .expect("multi-note unshield destination never observed"); + handle.sync().await; + let after = handle + .balances(&s.test_wallet) + .await + .expect("post-spend shielded_balances") + .get(&0) + .copied() + .unwrap_or(0); + assert!( + before.saturating_sub(after) >= MULTI_NOTE_UNSHIELD, + "shielded balance must drop by at least the unshield amount; before={before} after={after}" + ); + + let bank_addr = s + .ctx + .bank() + .primary_receive_address() + .to_bech32m_string(s.ctx.bank().network()); + teardown_sweep_shielded(&s.test_wallet, &handle, &bank_addr).await; + s.teardown().await.expect("teardown"); +} diff --git a/packages/rs-platform-wallet/tests/e2e/cases/sh_012_sync_watermark_idempotency.rs b/packages/rs-platform-wallet/tests/e2e/cases/sh_012_sync_watermark_idempotency.rs new file mode 100644 index 00000000000..8c2656526cd --- /dev/null +++ b/packages/rs-platform-wallet/tests/e2e/cases/sh_012_sync_watermark_idempotency.rs @@ -0,0 +1,139 @@ +//! SH-012 — Sync watermark idempotency: `coordinator.sync(force)` twice +//! yields stable balances. +//! Spec: `tests/e2e/TEST_SPEC.md` §3 "### Shielded (SH)" → SH-012. +//! Priority: P2. +//! +//! Shields a note, forces two syncs in a row, and asserts the shielded +//! balance is identical after each (no double-append — a second append at +//! an existing position would corrupt shardtree and surface as an anchor +//! error at the next spend). The strong end-to-end check: a spend still +//! succeeds post-double-sync, and the spendable note's value survived the +//! 115-byte serialize→store→deserialize round-trip exactly. +//! +//! Expected outcome: PASS. + +use std::time::Duration; + +use crate::framework::prelude::*; +use crate::framework::shielded::{ + bind_shielded, shielded_prover, teardown_sweep_shielded, wait_for_shielded_balance, +}; +use crate::framework::wait::{ + wait_for_address_balance_chain_confirmed_n, CHAIN_CONFIRMED_CONSECUTIVE_SUCCESSES, +}; + +const FUNDING_CREDITS: u64 = 2_220_000_000; +const SHIELD_AMOUNT: u64 = 1_120_000_000; +const UNSHIELD_AMOUNT: u64 = 15_000_000; +const STEP_TIMEOUT: Duration = Duration::from_secs(60); + +#[tokio_shared_rt::test(shared, flavor = "multi_thread", worker_threads = 12)] +async fn sh_012_sync_watermark_idempotency() { + let _ = tracing_subscriber::fmt() + .with_env_filter( + tracing_subscriber::EnvFilter::try_from_default_env() + .unwrap_or_else(|_| "info,platform_wallet=debug".into()), + ) + .with_test_writer() + .try_init(); + + let s = setup().await.expect("e2e setup failed"); + let prover = shielded_prover(); + + let addr_1 = s + .test_wallet + .next_unused_address() + .await + .expect("derive addr_1"); + s.ctx + .bank() + .fund_address(&addr_1, FUNDING_CREDITS) + .await + .expect("bank.fund_address"); + wait_for_address_balance_chain_confirmed_n( + s.ctx.sdk(), + &addr_1, + FUNDING_CREDITS, + CHAIN_CONFIRMED_CONSECUTIVE_SUCCESSES, + STEP_TIMEOUT, + ) + .await + .expect("addr_1 funding never observed"); + s.test_wallet + .sync_balances() + .await + .expect("pre-shield sync"); + + let handle = bind_shielded(&s.test_wallet, &[0], &s.ctx.workdir) + .await + .expect("bind_shielded"); + s.test_wallet + .platform_wallet() + .shielded_shield_from_account(0, 0, SHIELD_AMOUNT, s.test_wallet.address_signer(), prover) + .await + .expect("shield_from_account"); + wait_for_shielded_balance(&s.test_wallet, &handle, 0, SHIELD_AMOUNT, STEP_TIMEOUT) + .await + .expect("shielded balance never reached SHIELD_AMOUNT"); + + // Two forced syncs in a row; balances must be byte-identical. + handle.sync().await; + let first = handle + .balances(&s.test_wallet) + .await + .expect("balances after first forced sync"); + handle.sync().await; + let second = handle + .balances(&s.test_wallet) + .await + .expect("balances after second forced sync"); + assert_eq!( + first, second, + "shielded_balances must be identical after a second forced sync (no double-append); \ + first={first:?} second={second:?}" + ); + assert_eq!( + second.get(&0).copied(), + Some(SHIELD_AMOUNT), + "the note value must survive the serialize→store→deserialize round-trip exactly; \ + observed {second:?}" + ); + + // Strong end-to-end check: a spend still succeeds after the + // double-sync (a double-append would corrupt shardtree and surface + // here as an anchor / witness error). + let addr_dst = s + .test_wallet + .next_unused_address() + .await + .expect("derive addr_dst"); + let addr_dst_bech32m = addr_dst.to_bech32m_string(s.ctx.bank().network()); + s.test_wallet + .platform_wallet() + .shielded_unshield_to( + &handle.coordinator, + 0, + &addr_dst_bech32m, + UNSHIELD_AMOUNT, + prover, + ) + .await + .expect("spend after double-sync must succeed (no shardtree corruption)"); + wait_for_address_balance_chain_confirmed_n( + s.ctx.sdk(), + &addr_dst, + UNSHIELD_AMOUNT, + CHAIN_CONFIRMED_CONSECUTIVE_SUCCESSES, + STEP_TIMEOUT, + ) + .await + .expect("post-double-sync unshield destination never observed"); + + let bank_addr = s + .ctx + .bank() + .primary_receive_address() + .to_bech32m_string(s.ctx.bank().network()); + teardown_sweep_shielded(&s.test_wallet, &handle, &bank_addr).await; + s.teardown().await.expect("teardown"); +} diff --git a/packages/rs-platform-wallet/tests/e2e/cases/sh_013_bind_empty_accounts.rs b/packages/rs-platform-wallet/tests/e2e/cases/sh_013_bind_empty_accounts.rs new file mode 100644 index 00000000000..26cabc90afa --- /dev/null +++ b/packages/rs-platform-wallet/tests/e2e/cases/sh_013_bind_empty_accounts.rs @@ -0,0 +1,67 @@ +//! SH-013 — `bind_shielded` with empty accounts → typed error (no panic). +//! Spec: `tests/e2e/TEST_SPEC.md` §3 "### Shielded (SH)" → SH-013. +//! Priority: P2. +//! +//! `bind_shielded(seed, &[], coordinator)` must return +//! `ShieldedKeyDerivation` naming the "at least one account" requirement, +//! not panic, and leave the wallet unbound (a subsequent spend returns +//! `ShieldedNotBound`). +//! +//! Expected outcome: PASS. + +use platform_wallet::error::PlatformWalletError; + +use crate::framework::prelude::*; +use crate::framework::shielded::{new_file_backed_coordinator, shielded_prover}; + +#[tokio_shared_rt::test(shared, flavor = "multi_thread", worker_threads = 12)] +async fn sh_013_bind_empty_accounts() { + let _ = tracing_subscriber::fmt() + .with_env_filter( + tracing_subscriber::EnvFilter::try_from_default_env() + .unwrap_or_else(|_| "info,platform_wallet=debug".into()), + ) + .with_test_writer() + .try_init(); + + let s = setup().await.expect("e2e setup failed"); + let prover = shielded_prover(); + + let coordinator = new_file_backed_coordinator(&s.test_wallet, &s.ctx.workdir) + .await + .expect("coordinator"); + + let result = s + .test_wallet + .platform_wallet() + .bind_shielded(&s.test_wallet.seed_bytes(), &[], &coordinator) + .await; + match result { + Err(PlatformWalletError::ShieldedKeyDerivation(msg)) => { + assert!( + msg.contains("at least one account"), + "error must name the 'at least one account' requirement; observed {msg:?}" + ); + } + other => panic!("expected ShieldedKeyDerivation; observed {other:?}"), + } + + // The wallet must remain unbound: a spend returns ShieldedNotBound. + let addr_dst = s + .test_wallet + .next_unused_address() + .await + .expect("derive addr_dst"); + let addr_dst_bech32m = addr_dst.to_bech32m_string(s.ctx.bank().network()); + let spend = s + .test_wallet + .platform_wallet() + .shielded_unshield_to(&coordinator, 0, &addr_dst_bech32m, 1_000_000, prover) + .await; + assert!( + matches!(spend, Err(PlatformWalletError::ShieldedNotBound)), + "spend on an unbound wallet must return ShieldedNotBound; observed {spend:?}" + ); + + s.teardown().await.expect("teardown"); +} diff --git a/packages/rs-platform-wallet/tests/e2e/cases/sh_014_spend_before_bind.rs b/packages/rs-platform-wallet/tests/e2e/cases/sh_014_spend_before_bind.rs new file mode 100644 index 00000000000..d9516295956 --- /dev/null +++ b/packages/rs-platform-wallet/tests/e2e/cases/sh_014_spend_before_bind.rs @@ -0,0 +1,76 @@ +//! SH-014 — Spend before bind → `ShieldedNotBound`; spend on an unbound +//! account → `ShieldedKeyDerivation`. +//! Spec: `tests/e2e/TEST_SPEC.md` §3 "### Shielded (SH)" → SH-014. +//! Priority: P2. +//! +//! Both failures must fire BEFORE any proof is built. +//! +//! Expected outcome: PASS. + +use platform_wallet::error::PlatformWalletError; + +use crate::framework::prelude::*; +use crate::framework::shielded::{bind_shielded, new_file_backed_coordinator, shielded_prover}; + +const UNBOUND_ACCOUNT: u32 = 7; + +#[tokio_shared_rt::test(shared, flavor = "multi_thread", worker_threads = 12)] +async fn sh_014_spend_before_bind() { + let _ = tracing_subscriber::fmt() + .with_env_filter( + tracing_subscriber::EnvFilter::try_from_default_env() + .unwrap_or_else(|_| "info,platform_wallet=debug".into()), + ) + .with_test_writer() + .try_init(); + + let s = setup().await.expect("e2e setup failed"); + let prover = shielded_prover(); + + let addr_dst = s + .test_wallet + .next_unused_address() + .await + .expect("derive addr_dst"); + let addr_dst_bech32m = addr_dst.to_bech32m_string(s.ctx.bank().network()); + + // Step 1: spend WITHOUT binding → ShieldedNotBound. + let coordinator = new_file_backed_coordinator(&s.test_wallet, &s.ctx.workdir) + .await + .expect("coordinator"); + let before_bind = s + .test_wallet + .platform_wallet() + .shielded_unshield_to(&coordinator, 0, &addr_dst_bech32m, 1_000_000, prover) + .await; + assert!( + matches!(before_bind, Err(PlatformWalletError::ShieldedNotBound)), + "spend before bind must return ShieldedNotBound; observed {before_bind:?}" + ); + + // Step 2: bind only account 0, then spend on the unbound account 7 → + // ShieldedKeyDerivation naming account 7. + let handle = bind_shielded(&s.test_wallet, &[0], &s.ctx.workdir) + .await + .expect("bind_shielded"); + let unbound = s + .test_wallet + .platform_wallet() + .shielded_unshield_to( + &handle.coordinator, + UNBOUND_ACCOUNT, + &addr_dst_bech32m, + 1_000_000, + prover, + ) + .await; + match unbound { + Err(PlatformWalletError::ShieldedKeyDerivation(msg)) => assert!( + msg.contains(&UNBOUND_ACCOUNT.to_string()), + "error must name the unbound account {UNBOUND_ACCOUNT}; observed {msg:?}" + ), + other => panic!("expected ShieldedKeyDerivation naming account 7; observed {other:?}"), + } + + s.teardown().await.expect("teardown"); +} diff --git a/packages/rs-platform-wallet/tests/e2e/cases/sh_018_shield_from_asset_lock.rs b/packages/rs-platform-wallet/tests/e2e/cases/sh_018_shield_from_asset_lock.rs new file mode 100644 index 00000000000..f34204f29e7 --- /dev/null +++ b/packages/rs-platform-wallet/tests/e2e/cases/sh_018_shield_from_asset_lock.rs @@ -0,0 +1,121 @@ +//! SH-018 — Shield from a Core L1 asset lock (Type 18). +//! Spec: `tests/e2e/TEST_SPEC.md` §3 "### Shielded (SH)" → SH-018. +//! Priority: P1. (Wave H + Core-L1 gate.) MAY run RED until the Core-L1 +//! asset-lock funding plumbing is complete — that is acceptable; a RED +//! here documents the Core-L1 gate, not a defect in the shield path. +//! +//! Uses the public `PlatformWallet::shielded_shield_from_asset_lock` +//! wrapper (Gap-4) + the `test-utils` one-time-key derivation helper +//! (Gap-5) + `AssetLockManager::create_funded_asset_lock_proof`: +//! 1. Fund the test wallet's Core (L1) account. +//! 2. Build an asset-lock proof over that UTXO (shielded funding type). +//! 3. Derive the one-time private key from (seed, path). +//! 4. `shielded_shield_from_asset_lock(account, proof, key, amount)`. +//! 5. Sync + assert the shielded balance reflects the amount. +//! +//! Do NOT weaken the assertions: if the Core-L1 funding seam isn't wired, +//! the proof-build (step 2) errors and the test goes RED documenting it. + +#![cfg(feature = "shielded")] + +use std::time::Duration; + +use platform_wallet::wallet::shielded::operations::test_utils::derive_asset_lock_private_key; +use platform_wallet::AssetLockFundingType; + +use crate::framework::prelude::*; +use crate::framework::shielded::{ + bind_shielded, shielded_prover, teardown_sweep_shielded, wait_for_shielded_balance, +}; +use crate::framework::signer::SeedBackedCoreSigner; + +/// Core (Layer-1) duffs to fund the test wallet with (gated behind +/// `PLATFORM_WALLET_E2E_BANK_CORE_GATE`). Must cover the asset lock plus +/// its L1 tx fee. +const TEST_WALLET_CORE_FUNDING: u64 = 1_400_000; +/// Duffs locked into the asset lock (the shielded note value, modulo the +/// duff→credit conversion the protocol applies). 1.2M duffs = 1.2e9 credits +/// — above Drive's 100k-duff asset-lock floor AND the ~1e9 shielded fee, so +/// the shield-from-asset-lock reaches the backend instead of bouncing. +const ASSET_LOCK_DUFFS: u64 = 1_200_000; +const SHIELDED_ACCOUNT: u32 = 0; +const STEP_TIMEOUT: Duration = Duration::from_secs(60); + +#[tokio_shared_rt::test(shared, flavor = "multi_thread", worker_threads = 12)] +async fn sh_018_shield_from_asset_lock() { + let _ = tracing_subscriber::fmt() + .with_env_filter( + tracing_subscriber::EnvFilter::try_from_default_env() + .unwrap_or_else(|_| "info,platform_wallet=debug".into()), + ) + .with_test_writer() + .try_init(); + + // Core-L1 gate: panics (RED) if SPV / Core funding isn't available, + // documenting the gate. Mirrors CR-003. + let s = crate::framework::setup_with_core_funded_test_wallet(TEST_WALLET_CORE_FUNDING) + .await + .expect("setup_with_core_funded_test_wallet (Core-L1 gate)"); + + let network = s.test_wallet.platform_wallet().sdk().network; + let seed_bytes = s.test_wallet.seed_bytes(); + let prover = shielded_prover(); + let handle = bind_shielded(&s.test_wallet, &[SHIELDED_ACCOUNT], &s.ctx.workdir) + .await + .expect("bind_shielded"); + + // Build an asset-lock proof over the funded Core UTXO, for shielded + // funding. Returns (proof, derivation_path, outpoint). + let core_signer = SeedBackedCoreSigner::new(seed_bytes, network); + let (proof, path, _outpoint) = s + .test_wallet + .platform_wallet() + .asset_locks() + .create_funded_asset_lock_proof( + ASSET_LOCK_DUFFS, + 0, + AssetLockFundingType::AssetLockShieldedAddressTopUp, + SHIELDED_ACCOUNT, + &core_signer, + ) + .await + .expect( + "create_funded_asset_lock_proof (Core-L1 asset-lock seam — RED here documents the \ + gate, not a shield-path defect)", + ); + + // Derive the one-time asset-lock private key from (seed, path). + let one_time_key = derive_asset_lock_private_key(&seed_bytes, network, &path) + .expect("derive one-time asset-lock private key"); + + // Shield from the asset lock via the public wrapper (Type 18). + let credits = dpp::balances::credits::CREDITS_PER_DUFF * ASSET_LOCK_DUFFS; + s.test_wallet + .platform_wallet() + .shielded_shield_from_asset_lock(SHIELDED_ACCOUNT, proof, &one_time_key, credits, prover) + .await + .expect("shielded_shield_from_asset_lock"); + + let shielded = wait_for_shielded_balance( + &s.test_wallet, + &handle, + SHIELDED_ACCOUNT, + credits, + STEP_TIMEOUT, + ) + .await + .expect("shielded balance never reached the asset-lock amount"); + assert_eq!( + shielded, credits, + "shielded_balances[{SHIELDED_ACCOUNT}] must equal the asset-lock credits exactly; \ + observed {shielded}" + ); + + let bank_addr = s + .ctx + .bank() + .primary_receive_address() + .to_bech32m_string(s.ctx.bank().network()); + teardown_sweep_shielded(&s.test_wallet, &handle, &bank_addr).await; + s.teardown().await.expect("teardown"); +} diff --git a/packages/rs-platform-wallet/tests/e2e/cases/sh_019_shielded_withdraw_l1.rs b/packages/rs-platform-wallet/tests/e2e/cases/sh_019_shielded_withdraw_l1.rs new file mode 100644 index 00000000000..7ef62a4dd41 --- /dev/null +++ b/packages/rs-platform-wallet/tests/e2e/cases/sh_019_shielded_withdraw_l1.rs @@ -0,0 +1,172 @@ +//! SH-019 — Shielded withdraw to a Core L1 address (Type 19). +//! Spec: `tests/e2e/TEST_SPEC.md` §3 "### Shielded (SH)" → SH-019. +//! Priority: P1. (Wave H + Core-L1 gate.) +//! +//! The shielded SPEND half is exercisable now (same path as SH-002): we +//! shield a note, withdraw part of it to a Core L1 address, and assert +//! the shielded-side bookkeeping unconditionally (this half is +//! GREEN-capable). The L1-arrival assertion needs Layer-1 payout +//! observation and is gated behind `PLATFORM_WALLET_E2E_BANK_CORE_GATE`; +//! until that observation seam is wired it MAY run RED — documenting the +//! gate, not a production defect in the shield path. +//! +//! NOTE (flagged gap): there is no harness Layer-1 payout-observation +//! seam yet (shared with §5 item 2 transparent withdrawal). The L1-read +//! arm below is therefore left as a documented TODO rather than a live +//! assertion — wiring it is the Core-L1 follow-up. + +use std::time::Duration; + +use crate::framework::prelude::*; +use crate::framework::shielded::{ + bind_shielded, shielded_prover, teardown_sweep_shielded, wait_for_shielded_balance, +}; +use crate::framework::wait::{ + wait_for_address_balance_chain_confirmed_n, CHAIN_CONFIRMED_CONSECUTIVE_SUCCESSES, +}; + +const FUNDING_CREDITS: u64 = 2_220_000_000; +const SHIELD_AMOUNT: u64 = 1_120_000_000; +const WITHDRAW_AMOUNT: u64 = 20_000_000; +const CORE_FEE_PER_BYTE: u32 = 1; +const STEP_TIMEOUT: Duration = Duration::from_secs(60); + +#[tokio_shared_rt::test(shared, flavor = "multi_thread", worker_threads = 12)] +async fn sh_019_shielded_withdraw_l1() { + let _ = tracing_subscriber::fmt() + .with_env_filter( + tracing_subscriber::EnvFilter::try_from_default_env() + .unwrap_or_else(|_| "info,platform_wallet=debug".into()), + ) + .with_test_writer() + .try_init(); + + let s = setup().await.expect("e2e setup failed"); + let prover = shielded_prover(); + + let addr_1 = s + .test_wallet + .next_unused_address() + .await + .expect("derive addr_1"); + s.ctx + .bank() + .fund_address(&addr_1, FUNDING_CREDITS) + .await + .expect("bank.fund_address"); + wait_for_address_balance_chain_confirmed_n( + s.ctx.sdk(), + &addr_1, + FUNDING_CREDITS, + CHAIN_CONFIRMED_CONSECUTIVE_SUCCESSES, + STEP_TIMEOUT, + ) + .await + .expect("addr_1 funding never observed"); + s.test_wallet + .sync_balances() + .await + .expect("pre-shield sync"); + + let handle = bind_shielded(&s.test_wallet, &[0], &s.ctx.workdir) + .await + .expect("bind_shielded"); + s.test_wallet + .platform_wallet() + .shielded_shield_from_account(0, 0, SHIELD_AMOUNT, s.test_wallet.address_signer(), prover) + .await + .expect("shield_from_account"); + wait_for_shielded_balance(&s.test_wallet, &handle, 0, SHIELD_AMOUNT, STEP_TIMEOUT) + .await + .expect("shielded balance never reached SHIELD_AMOUNT"); + + // Withdraw to a Core L1 address — the bank's Core receive address is + // a real, network-valid Base58Check string available without extra + // funding. + let to_core = s + .ctx + .bank() + .primary_core_receive_address() + .await + .expect("derive bank Core receive address") + .to_string(); + + let before = handle + .balances(&s.test_wallet) + .await + .expect("pre-withdraw shielded_balances") + .get(&0) + .copied() + .unwrap_or(0); + + s.test_wallet + .platform_wallet() + .shielded_withdraw_to( + &handle.coordinator, + 0, + &to_core, + WITHDRAW_AMOUNT, + CORE_FEE_PER_BYTE, + prover, + ) + .await + .expect("shielded_withdraw_to (shielded spend half must succeed)"); + + // Shielded-side assertions (GREEN-capable, no L1 gate): the change + // note is retained and the balance dropped by at least the withdraw + // amount. + handle.sync().await; + let after = handle + .balances(&s.test_wallet) + .await + .expect("post-withdraw shielded_balances") + .get(&0) + .copied() + .unwrap_or(0); + assert!( + before.saturating_sub(after) >= WITHDRAW_AMOUNT, + "shielded balance must drop by at least the withdraw amount; before={before} after={after}" + ); + assert!( + after > 0, + "shielded change note must be retained after a partial withdraw; observed {after}" + ); + + // The spent note must be marked spent — a second identical withdraw + // must not re-select it (it either spends the change or fails + // insufficient-balance, never re-spends the consumed note). + let second = s + .test_wallet + .platform_wallet() + .shielded_withdraw_to( + &handle.coordinator, + 0, + &to_core, + WITHDRAW_AMOUNT, + CORE_FEE_PER_BYTE, + prover, + ) + .await; + // Either it succeeds from the remaining change or it fails on + // insufficient balance — both prove the original note was consumed + // exactly once. A panic / double-spend would be the regression. + tracing::info!( + target: "platform_wallet::e2e::cases::sh_019", + ?second, + "second withdraw outcome (must not re-spend the consumed note)" + ); + + // TODO(Core-L1 follow-up): observe the L1 payout on `to_core` once + // the Layer-1 payout-observation seam exists (shared with §5 item 2). + // Gated behind PLATFORM_WALLET_E2E_BANK_CORE_GATE. Until then the + // L1-arrival assertion is intentionally absent — the shielded-side + // assertions above are the GREEN-capable half. + + let bank_addr = s + .ctx + .bank() + .primary_receive_address() + .to_bech32m_string(s.ctx.bank().network()); + teardown_sweep_shielded(&s.test_wallet, &handle, &bank_addr).await; + s.teardown().await.expect("teardown"); +} diff --git a/packages/rs-platform-wallet/tests/e2e/cases/sh_020_double_spend_two_transitions.rs b/packages/rs-platform-wallet/tests/e2e/cases/sh_020_double_spend_two_transitions.rs new file mode 100644 index 00000000000..7d61f75f6a9 --- /dev/null +++ b/packages/rs-platform-wallet/tests/e2e/cases/sh_020_double_spend_two_transitions.rs @@ -0,0 +1,314 @@ +//! SH-020 — ADVERSARIAL: double-spend the same note across two +//! transitions (Type 17) — backend MUST reject the second [INJECT]. +//! Spec: `tests/e2e/TEST_SPEC.md` §3 → SH-020. Priority: P0 +//! (consensus-critical). CRITICAL-if-it-fails. +//! +//! Attack: build two distinct, individually-valid unshield transitions +//! that both spend the SAME shielded note (same nullifier), bypassing the +//! wallet's `reserve_unspent_notes` via the build-against-note seam, and +//! broadcast both. Exactly ONE must COMMIT; the second must be rejected +//! because its Orchard nullifier is already in Drive's spent set +//! (`NullifierAlreadySpentError`, code 40901). +//! +//! The verdict is read at CONSENSUS, not at `check_tx` (SD-002): both +//! transitions can pass mempool admission, so the case broadcasts both +//! and then waits for each one's COMMIT outcome. A transition counts as +//! committed only if it both passed `check_tx` AND `wait_commit_raw` +//! returned a verified proof result. +//! +//! RED if the backend commits both (double-spend — CRITICAL fund forgery) +//! or commits neither (liveness bug). + +#![cfg(feature = "shielded")] + +use std::time::Duration; + +use dpp::shielded::compute_minimum_shielded_fee; +use dpp::version::PlatformVersion; + +use crate::framework::prelude::*; +use crate::framework::shielded::{ + adversarial_enabled, bind_shielded, broadcast_raw, build_unshield_st_against_notes, + shielded_prover, teardown_sweep_shielded, unspent_notes, wait_commit_raw, + wait_for_shielded_balance, +}; +use crate::framework::wait::{ + wait_for_address_balance_chain_confirmed_n, CHAIN_CONFIRMED_CONSECUTIVE_SUCCESSES, +}; +use dash_sdk::platform::Fetch; +use dash_sdk::query_types::AddressInfo; +use dpp::address_funds::PlatformAddress; + +const FUNDING_CREDITS: u64 = 1_400_000_000; +const SHIELD_AMOUNT: u64 = 200_000_000; +const UNSHIELD_AMOUNT: u64 = 20_000_000; +const STEP_TIMEOUT: Duration = Duration::from_secs(60); +/// Consensus commit needs block production + proof — longer than the +/// per-step funding/sync gate. +const COMMIT_TIMEOUT: Duration = Duration::from_secs(120); + +#[tokio_shared_rt::test(shared, flavor = "multi_thread", worker_threads = 12)] +async fn sh_020_double_spend_two_transitions() { + let _ = tracing_subscriber::fmt() + .with_env_filter( + tracing_subscriber::EnvFilter::try_from_default_env() + .unwrap_or_else(|_| "info,platform_wallet=debug".into()), + ) + .with_test_writer() + .try_init(); + + if !adversarial_enabled() { + tracing::info!( + target: "platform_wallet::e2e::cases::sh_020", + "PLATFORM_WALLET_E2E_SHIELDED_ADVERSARIAL unset — abuse case skipped (no-op pass)" + ); + return; + } + + let s = setup().await.expect("e2e setup failed"); + let prover = shielded_prover(); + let handle = bind_shielded(&s.test_wallet, &[0], &s.ctx.workdir) + .await + .expect("bind_shielded"); + + let addr_1 = s + .test_wallet + .next_unused_address() + .await + .expect("derive addr_1"); + s.ctx + .bank() + .fund_address(&addr_1, FUNDING_CREDITS) + .await + .expect("bank.fund_address"); + wait_for_address_balance_chain_confirmed_n( + s.ctx.sdk(), + &addr_1, + FUNDING_CREDITS, + CHAIN_CONFIRMED_CONSECUTIVE_SUCCESSES, + STEP_TIMEOUT, + ) + .await + .expect("addr_1 funding never observed"); + s.test_wallet + .sync_balances() + .await + .expect("pre-shield sync"); + s.test_wallet + .platform_wallet() + .shielded_shield_from_account(0, 0, SHIELD_AMOUNT, s.test_wallet.address_signer(), prover) + .await + .expect("shield_from_account"); + wait_for_shielded_balance(&s.test_wallet, &handle, 0, SHIELD_AMOUNT, STEP_TIMEOUT) + .await + .expect("shielded balance never reached SHIELD_AMOUNT"); + + // Capture the single synced note; build TWO unshields against it. + let notes = unspent_notes(&s.test_wallet, &handle, 0) + .await + .expect("capture unspent notes"); + assert!( + !notes.is_empty(), + "expected one synced note to double-spend" + ); + let one_note = vec![notes[0].clone()]; + + let exact_fee = compute_minimum_shielded_fee(1, PlatformVersion::latest()); + let dst_a = s.test_wallet.next_unused_address().await.expect("dst_a"); + let dst_b = s.test_wallet.next_unused_address().await.expect("dst_b"); + + let st_a = build_unshield_st_against_notes( + &s.test_wallet, + &handle, + 0, + &dst_a, + UNSHIELD_AMOUNT, + exact_fee, + &one_note, + ) + .await + .expect("build first unshield against note"); + let st_b = build_unshield_st_against_notes( + &s.test_wallet, + &handle, + 0, + &dst_b, + UNSHIELD_AMOUNT, + exact_fee, + &one_note, + ) + .await + .expect("build second unshield against the SAME note"); + + // BEFORE state: both unshield destinations are fresh (0 credits). Read + // them via the proof-verified on-chain path so the verdict rests on a + // real before/after delta, not an assumption. + let before_a = fetch_credits(s.ctx.sdk(), &dst_a).await; + let before_b = fetch_credits(s.ctx.sdk(), &dst_b).await; + + // Broadcast BOTH first (check_tx / mempool admission) so the two + // same-nullifier spends are in flight before either is processed. + let bcast_a = broadcast_raw(s.ctx.sdk(), &st_a).await; + let bcast_b = broadcast_raw(s.ctx.sdk(), &st_b).await; + + // Drive each admitted spend to its consensus outcome (block inclusion / + // state apply), not just check_tx. The commit result is secondary + // evidence + the rejection reason; the authoritative verdict is the + // post-execution STATE delta below (SD-002). A check_tx-rejected spend + // never reaches consensus, so its broadcast error IS its verdict. + let commit_a = match &bcast_a { + Ok(()) => wait_commit_raw(s.ctx.sdk(), &st_a, COMMIT_TIMEOUT).await, + Err(e) => Err(crate::framework::FrameworkError::Sdk(format!( + "check_tx rejected before consensus: {e}" + ))), + }; + let commit_b = match &bcast_b { + Ok(()) => wait_commit_raw(s.ctx.sdk(), &st_b, COMMIT_TIMEOUT).await, + Err(e) => Err(crate::framework::FrameworkError::Sdk(format!( + "check_tx rejected before consensus: {e}" + ))), + }; + + // AFTER state — the AUTHORITATIVE verdict. Each unshield pays its value + // to a DISTINCT transparent address, so the on-chain economic effect of + // double-spending one note is unambiguous: BOTH dst_a AND dst_b get + // credited (~UNSHIELD_AMOUNT each) — one note's value materialised into + // two outputs. The commit waits above already blocked until execution; + // give the credited destination(s) a bounded settle on the proof-verified + // path so the read lands after state-apply, then point-read both. A leg + // that never credits simply times out (ignored) and reads back 0. + let settle = Duration::from_secs(30); + let _ = + wait_for_address_balance_chain_confirmed_n(s.ctx.sdk(), &dst_a, UNSHIELD_AMOUNT, 1, settle) + .await; + let _ = + wait_for_address_balance_chain_confirmed_n(s.ctx.sdk(), &dst_b, UNSHIELD_AMOUNT, 1, settle) + .await; + let after_a = fetch_credits(s.ctx.sdk(), &dst_a).await; + let after_b = fetch_credits(s.ctx.sdk(), &dst_b).await; + + // A destination is "credited" if its on-chain balance rose toward the + // unshield value (tolerate fee/rounding by gating at half the amount). + let credit_threshold = UNSHIELD_AMOUNT / 2; + let credited_a = after_a.saturating_sub(before_a) >= credit_threshold; + let credited_b = after_b.saturating_sub(before_b) >= credit_threshold; + let credited_count = [credited_a, credited_b].iter().filter(|c| **c).count(); + + // Authoritative trace: the STATE before/after AND the secondary + // check_tx/commit signals, so Marvin's trace shows the economic effect + // and the consensus rejection reason side by side. + tracing::info!( + target: "platform_wallet::e2e::cases::sh_020", + before_a, after_a, credited_a, + before_b, after_b, credited_b, + credited_count, + check_tx_a = bcast_a.is_ok(), + check_tx_b = bcast_b.is_ok(), + committed_a = commit_a.is_ok(), + committed_b = commit_b.is_ok(), + ?commit_a, + ?commit_b, + "SH-020 double-spend verdict: post-execution STATE delta (authoritative) + check_tx/commit (secondary)" + ); + + // VERDICT on STATE, not status flags. + if credited_count == 2 { + panic!( + "SH-020 FINDING (CRITICAL DOUBLE-SPEND): one Orchard note's value materialised \ + into TWO transparent outputs — fund forgery. dst_a {before_a}->{after_a}, \ + dst_b {before_b}->{after_b} (each ~{UNSHIELD_AMOUNT}). commit_a={commit_a:?} \ + commit_b={commit_b:?}" + ); + } + assert_eq!( + credited_count, + 1, + "SH-020 FINDING: exactly ONE same-note spend must materialise on chain; observed \ + {credited_count} credited (dst_a {before_a}->{after_a}, dst_b {before_b}->{after_b}). \ + Two = double-spend / fund forgery; zero = liveness bug (neither unshield's value \ + landed within {COMMIT_TIMEOUT:?}). check_tx[a={},b={}] commit_a={commit_a:?} \ + commit_b={commit_b:?}", + bcast_a.is_ok(), + bcast_b.is_ok(), + ); + + // Corroborate: the shielded note's value must have left the pool exactly + // ONCE. A double-spend would let the same note pay out twice; with one + // spend committed the residual change note is below SHIELD_AMOUNT. + handle.sync().await; + let residual = handle + .balances(&s.test_wallet) + .await + .map(|b| b.get(&0).copied().unwrap_or(0)) + .unwrap_or(0); + assert!( + residual < SHIELD_AMOUNT, + "SH-020: shielded balance must drop after the single committed spend; \ + observed residual {residual} >= SHIELD_AMOUNT {SHIELD_AMOUNT} (the note's value \ + did not leave the pool — investigate)" + ); + + // Secondary corroboration (BEST-EFFORT, NOT a hard assert): ideally the + // spend that did NOT materialise was rejected nullifier-already-spent + // (code 40901). But on devnet the rejected ST never commits, so its + // proof-verified `wait_commit_raw` readback times out — and under the + // rust-dashcore quorum-by-hash (retirement-edge) gap that timeout/error + // masks the real 40901 reason. Asserting on the error string there would + // false-RED even though the double-spend was correctly rejected (the + // authoritative `credited_count == 1` verdict above already proves that). + // So we only LOG the rejected leg's reason as evidence; the STATE delta + // is the verdict. + let rejected_err = if !credited_a { + format!("{commit_a:?}") + } else { + format!("{commit_b:?}") + }; + let err_s = rejected_err.to_lowercase(); + let nullifier_reason = err_s.contains("nullifier") + || err_s.contains("alreadyspent") + || err_s.contains("already spent"); + if nullifier_reason { + tracing::info!( + target: "platform_wallet::e2e::cases::sh_020", + "SH-020: rejected leg failed nullifier-already-spent (code 40901) as expected" + ); + } else { + tracing::warn!( + target: "platform_wallet::e2e::cases::sh_020", + rejected_err = %rejected_err, + "SH-020: rejected leg's reason is not recognizably nullifier-already-spent \ + (expected on devnet — the rejected ST never commits, and the rust-dashcore \ + quorum-by-hash gap can mask the 40901 reason behind a readback timeout). \ + The credited_count==1 STATE verdict above is authoritative." + ); + } + + let bank_addr = s + .ctx + .bank() + .primary_receive_address() + .to_bech32m_string(s.ctx.bank().network()); + teardown_sweep_shielded(&s.test_wallet, &handle, &bank_addr).await; + s.teardown().await.expect("teardown"); +} + +/// Proof-verified on-chain credit balance for `addr`, the authoritative +/// state read for the double-spend verdict. An address not yet on chain +/// (`Ok(None)`) reads as 0; a fetch error also reads as 0 and is logged — +/// a transient read failure must not be misread as "credited" (which would +/// only ever soften, never fabricate, a double-spend signal). +async fn fetch_credits(sdk: &dash_sdk::Sdk, addr: &PlatformAddress) -> u64 { + match AddressInfo::fetch(sdk, *addr).await { + Ok(Some(info)) => info.balance, + Ok(None) => 0, + Err(e) => { + tracing::warn!( + target: "platform_wallet::e2e::cases::sh_020", + addr = ?addr, + error = %e, + "fetch_credits: AddressInfo::fetch failed; treating as 0 credits" + ); + 0 + } + } +} diff --git a/packages/rs-platform-wallet/tests/e2e/cases/sh_021_nullifier_replay_after_restart.rs b/packages/rs-platform-wallet/tests/e2e/cases/sh_021_nullifier_replay_after_restart.rs new file mode 100644 index 00000000000..f34b21b9f2b --- /dev/null +++ b/packages/rs-platform-wallet/tests/e2e/cases/sh_021_nullifier_replay_after_restart.rs @@ -0,0 +1,154 @@ +//! SH-021 — ADVERSARIAL: nullifier replay after a confirmed spend — +//! backend MUST reject [INJECT]. +//! Spec: `tests/e2e/TEST_SPEC.md` §3 → SH-021. Priority: P0 +//! (consensus-critical). CRITICAL-if-it-fails. +//! +//! Attack: capture a note, spend it (confirmed), then rebuild a fresh +//! transition spending the SAME now-spent note (via the build-against-note +//! seam, which skips the local spent-state guard) and re-broadcast. The +//! nullifier is permanently in Drive's spent set, so the replay MUST fail +//! (`NullifierAlreadySpentError`, code 40901) regardless of client state. +//! +//! RED if the replay is accepted (double-spend via replay). + +#![cfg(feature = "shielded")] + +use std::time::Duration; + +use dpp::shielded::compute_minimum_shielded_fee; +use dpp::version::PlatformVersion; + +use crate::framework::prelude::*; +use crate::framework::shielded::{ + adversarial_enabled, bind_shielded, broadcast_raw, build_unshield_st_against_notes, + shielded_prover, teardown_sweep_shielded, unspent_notes, wait_for_shielded_balance, +}; +use crate::framework::wait::{ + wait_for_address_balance_chain_confirmed_n, CHAIN_CONFIRMED_CONSECUTIVE_SUCCESSES, +}; + +const FUNDING_CREDITS: u64 = 2_220_000_000; +const SHIELD_AMOUNT: u64 = 1_120_000_000; +const UNSHIELD_AMOUNT: u64 = 20_000_000; +const STEP_TIMEOUT: Duration = Duration::from_secs(60); + +#[tokio_shared_rt::test(shared, flavor = "multi_thread", worker_threads = 12)] +async fn sh_021_nullifier_replay_after_restart() { + let _ = tracing_subscriber::fmt() + .with_env_filter( + tracing_subscriber::EnvFilter::try_from_default_env() + .unwrap_or_else(|_| "info,platform_wallet=debug".into()), + ) + .with_test_writer() + .try_init(); + + if !adversarial_enabled() { + tracing::info!( + target: "platform_wallet::e2e::cases::sh_021", + "PLATFORM_WALLET_E2E_SHIELDED_ADVERSARIAL unset — abuse case skipped (no-op pass)" + ); + return; + } + + let s = setup().await.expect("e2e setup failed"); + let prover = shielded_prover(); + let handle = bind_shielded(&s.test_wallet, &[0], &s.ctx.workdir) + .await + .expect("bind_shielded"); + + let addr_1 = s + .test_wallet + .next_unused_address() + .await + .expect("derive addr_1"); + s.ctx + .bank() + .fund_address(&addr_1, FUNDING_CREDITS) + .await + .expect("bank.fund_address"); + wait_for_address_balance_chain_confirmed_n( + s.ctx.sdk(), + &addr_1, + FUNDING_CREDITS, + CHAIN_CONFIRMED_CONSECUTIVE_SUCCESSES, + STEP_TIMEOUT, + ) + .await + .expect("addr_1 funding never observed"); + s.test_wallet + .sync_balances() + .await + .expect("pre-shield sync"); + s.test_wallet + .platform_wallet() + .shielded_shield_from_account(0, 0, SHIELD_AMOUNT, s.test_wallet.address_signer(), prover) + .await + .expect("shield_from_account"); + wait_for_shielded_balance(&s.test_wallet, &handle, 0, SHIELD_AMOUNT, STEP_TIMEOUT) + .await + .expect("shielded balance never reached SHIELD_AMOUNT"); + + // Capture the note BEFORE spending so the replay can rebuild against + // it after it's confirmed-spent. + let notes = unspent_notes(&s.test_wallet, &handle, 0) + .await + .expect("capture unspent notes"); + assert!(!notes.is_empty(), "expected one synced note"); + let captured = vec![notes[0].clone()]; + + // First spend through the real wallet path (confirmed). + let dst = s.test_wallet.next_unused_address().await.expect("dst"); + let dst_b32 = dst.to_bech32m_string(s.ctx.bank().network()); + s.test_wallet + .platform_wallet() + .shielded_unshield_to(&handle.coordinator, 0, &dst_b32, UNSHIELD_AMOUNT, prover) + .await + .expect("first unshield must succeed"); + wait_for_address_balance_chain_confirmed_n( + s.ctx.sdk(), + &dst, + UNSHIELD_AMOUNT, + CHAIN_CONFIRMED_CONSECUTIVE_SUCCESSES, + STEP_TIMEOUT, + ) + .await + .expect("first unshield destination never observed"); + + // Replay: rebuild a fresh transition against the now-spent captured + // note and broadcast. The witness still resolves (the commitment is + // in the tree), but the nullifier is already spent on-chain. + let exact_fee = compute_minimum_shielded_fee(1, PlatformVersion::latest()); + let dst2 = s.test_wallet.next_unused_address().await.expect("dst2"); + let replay_st = build_unshield_st_against_notes( + &s.test_wallet, + &handle, + 0, + &dst2, + UNSHIELD_AMOUNT, + exact_fee, + &captured, + ) + .await + .expect("rebuild replay against spent note"); + let replay = broadcast_raw(s.ctx.sdk(), &replay_st).await; + assert!( + replay.is_err(), + "SH-021 FINDING (CRITICAL): replay of a confirmed-spent note was ACCEPTED — \ + double-spend via replay. result={replay:?}" + ); + let err_s = format!("{replay:?}").to_lowercase(); + assert!( + err_s.contains("nullifier") + || err_s.contains("alreadyspent") + || err_s.contains("already spent"), + "SH-021: replay must fail nullifier-already-spent (code 40901); observed {replay:?}" + ); + + let bank_addr = s + .ctx + .bank() + .primary_receive_address() + .to_bech32m_string(s.ctx.bank().network()); + teardown_sweep_shielded(&s.test_wallet, &handle, &bank_addr).await; + s.teardown().await.expect("teardown"); +} diff --git a/packages/rs-platform-wallet/tests/e2e/cases/sh_022_value_not_conserved.rs b/packages/rs-platform-wallet/tests/e2e/cases/sh_022_value_not_conserved.rs new file mode 100644 index 00000000000..eeedee207a6 --- /dev/null +++ b/packages/rs-platform-wallet/tests/e2e/cases/sh_022_value_not_conserved.rs @@ -0,0 +1,126 @@ +//! SH-022 — ADVERSARIAL: value not conserved (outputs > inputs) — +//! backend MUST reject [INJECT]. +//! Spec: `tests/e2e/TEST_SPEC.md` §3 → SH-022. Priority: P0 +//! (consensus-critical). CRITICAL-if-it-fails (value forgery / unlimited +//! shielded-pool inflation). +//! +//! Attack: capture a VALID Type-17 unshield (spending the funded note, +//! unshielding `UNSHIELD_AMOUNT`), then overwrite `unshielding_amount` to +//! exceed the spent note value — minting value from nothing — and +//! broadcast raw. +//! Orchard's value-balance check + Drive's credit accounting must refuse +//! a bundle where shielded inputs < outputs + fee. The Halo-2 proof binds +//! `value_balance`, so the mismatch must fail proof verification or the +//! consensus value check (`ShieldedInvalidValueBalanceError`, code 10822). +//! +//! RED if accepted — value forgery. + +#![cfg(feature = "shielded")] + +use std::time::Duration; + +use crate::framework::prelude::*; +use crate::framework::shielded::{ + adversarial_enabled, bind_shielded, broadcast_raw, capture_unshield_st, + mutate_serialized_bundle, shielded_prover, teardown_sweep_shielded, wait_for_shielded_balance, + BundleField, BundleMutation, +}; +use crate::framework::wait::{ + wait_for_address_balance_chain_confirmed_n, CHAIN_CONFIRMED_CONSECUTIVE_SUCCESSES, +}; + +const FUNDING_CREDITS: u64 = 1_400_000_000; +const SHIELD_AMOUNT: u64 = 200_000_000; +const UNSHIELD_AMOUNT: u64 = 20_000_000; +/// Far above the spent note's value (`SHIELD_AMOUNT`) — mints value from +/// nothing. +const FORGED_AMOUNT: u64 = 1_000_000_000; +const STEP_TIMEOUT: Duration = Duration::from_secs(60); + +#[tokio_shared_rt::test(shared, flavor = "multi_thread", worker_threads = 12)] +async fn sh_022_value_not_conserved() { + let _ = tracing_subscriber::fmt() + .with_env_filter( + tracing_subscriber::EnvFilter::try_from_default_env() + .unwrap_or_else(|_| "info,platform_wallet=debug".into()), + ) + .with_test_writer() + .try_init(); + + if !adversarial_enabled() { + tracing::info!( + target: "platform_wallet::e2e::cases::sh_022", + "PLATFORM_WALLET_E2E_SHIELDED_ADVERSARIAL unset — abuse case skipped (no-op pass)" + ); + return; + } + + let s = setup().await.expect("e2e setup failed"); + let prover = shielded_prover(); + let handle = bind_shielded(&s.test_wallet, &[0], &s.ctx.workdir) + .await + .expect("bind_shielded"); + + let addr_1 = s + .test_wallet + .next_unused_address() + .await + .expect("derive addr_1"); + s.ctx + .bank() + .fund_address(&addr_1, FUNDING_CREDITS) + .await + .expect("bank.fund_address"); + wait_for_address_balance_chain_confirmed_n( + s.ctx.sdk(), + &addr_1, + FUNDING_CREDITS, + CHAIN_CONFIRMED_CONSECUTIVE_SUCCESSES, + STEP_TIMEOUT, + ) + .await + .expect("addr_1 funding never observed"); + s.test_wallet + .sync_balances() + .await + .expect("pre-shield sync"); + s.test_wallet + .platform_wallet() + .shielded_shield_from_account(0, 0, SHIELD_AMOUNT, s.test_wallet.address_signer(), prover) + .await + .expect("shield_from_account"); + wait_for_shielded_balance(&s.test_wallet, &handle, 0, SHIELD_AMOUNT, STEP_TIMEOUT) + .await + .expect("shielded balance never reached SHIELD_AMOUNT"); + + let dst = s.test_wallet.next_unused_address().await.expect("dst"); + + // Capture a valid 20M unshield, then forge the declared amount to 1B. + let mut st = capture_unshield_st(&s.test_wallet, &handle, 0, &dst, UNSHIELD_AMOUNT) + .await + .expect("capture valid unshield ST"); + mutate_serialized_bundle( + &mut st, + BundleField::ValueBalance, + &BundleMutation::Overwrite(FORGED_AMOUNT.to_le_bytes().to_vec()), + ) + .expect("forge unshielding_amount"); + let result = broadcast_raw(s.ctx.sdk(), &st).await; + assert!( + result.is_err(), + "SH-022 FINDING (CRITICAL): backend ACCEPTED outputs > inputs (declared {FORGED_AMOUNT} \ + against a {SHIELD_AMOUNT} note) — value forgery / shielded-pool inflation. result={result:?}" + ); + tracing::info!( + target: "platform_wallet::e2e::cases::sh_022", + "value-not-conserved transition correctly rejected by backend" + ); + + let bank_addr = s + .ctx + .bank() + .primary_receive_address() + .to_bech32m_string(s.ctx.bank().network()); + teardown_sweep_shielded(&s.test_wallet, &handle, &bank_addr).await; + s.teardown().await.expect("teardown"); +} diff --git a/packages/rs-platform-wallet/tests/e2e/cases/sh_023_fee_underpayment.rs b/packages/rs-platform-wallet/tests/e2e/cases/sh_023_fee_underpayment.rs new file mode 100644 index 00000000000..98878ded195 --- /dev/null +++ b/packages/rs-platform-wallet/tests/e2e/cases/sh_023_fee_underpayment.rs @@ -0,0 +1,132 @@ +//! SH-023 — ADVERSARIAL: fee underpayment below `compute_minimum_shielded_fee` +//! — backend MUST reject [INJECT]. +//! Spec: `tests/e2e/TEST_SPEC.md` §3 → SH-023. Priority: P1. HIGH-if-fails. +//! +//! Attack: build a spend declaring a fee BELOW the minimum. This case +//! exercises the CLIENT floor (the `build_*_st` path delegates to the dpp +//! `build_unshield_transition`, which rejects `Some(f) if f < min_fee` +//! internally at `unshield.rs:60-65`), proving the wallet refuses to emit +//! an under-floor transition. +//! +//! # RESIDUAL PRODUCTION GAP (flagged, not fixed) +//! +//! The independent BACKEND-floor arm (confirm Drive ALSO rejects an +//! under-floor fee submitted by a client WITHOUT the guard) is not +//! reachable: the fee is folded into the spend's value math during build, +//! there is no post-build `fee` field on the `SerializedBundle` to mutate, +//! and the only assembly path (the dpp builder) enforces the floor. A +//! deeper raw-bundle seam (assemble from arbitrary value_balance + actions +//! bypassing the builder's fee math) would be required to drive the +//! backend-floor arm. Documented; the client-floor arm is asserted live. + +#![cfg(feature = "shielded")] + +use std::time::Duration; + +use crate::framework::prelude::*; +use crate::framework::shielded::{ + adversarial_enabled, bind_shielded, build_unshield_st_against_notes, shielded_prover, + teardown_sweep_shielded, unspent_notes, wait_for_shielded_balance, +}; +use crate::framework::wait::{ + wait_for_address_balance_chain_confirmed_n, CHAIN_CONFIRMED_CONSECUTIVE_SUCCESSES, +}; + +const FUNDING_CREDITS: u64 = 1_200_000_000; +const SHIELD_AMOUNT: u64 = 50_000_000; +const UNSHIELD_AMOUNT: u64 = 20_000_000; +const STEP_TIMEOUT: Duration = Duration::from_secs(60); + +#[tokio_shared_rt::test(shared, flavor = "multi_thread", worker_threads = 12)] +async fn sh_023_fee_underpayment() { + let _ = tracing_subscriber::fmt() + .with_env_filter( + tracing_subscriber::EnvFilter::try_from_default_env() + .unwrap_or_else(|_| "info,platform_wallet=debug".into()), + ) + .with_test_writer() + .try_init(); + + if !adversarial_enabled() { + tracing::info!( + target: "platform_wallet::e2e::cases::sh_023", + "PLATFORM_WALLET_E2E_SHIELDED_ADVERSARIAL unset — abuse case skipped (no-op pass)" + ); + return; + } + + let s = setup().await.expect("e2e setup failed"); + let prover = shielded_prover(); + let handle = bind_shielded(&s.test_wallet, &[0], &s.ctx.workdir) + .await + .expect("bind_shielded"); + + let addr_1 = s + .test_wallet + .next_unused_address() + .await + .expect("derive addr_1"); + s.ctx + .bank() + .fund_address(&addr_1, FUNDING_CREDITS) + .await + .expect("bank.fund_address"); + wait_for_address_balance_chain_confirmed_n( + s.ctx.sdk(), + &addr_1, + FUNDING_CREDITS, + CHAIN_CONFIRMED_CONSECUTIVE_SUCCESSES, + STEP_TIMEOUT, + ) + .await + .expect("addr_1 funding never observed"); + s.test_wallet + .sync_balances() + .await + .expect("pre-shield sync"); + s.test_wallet + .platform_wallet() + .shielded_shield_from_account(0, 0, SHIELD_AMOUNT, s.test_wallet.address_signer(), prover) + .await + .expect("shield_from_account"); + wait_for_shielded_balance(&s.test_wallet, &handle, 0, SHIELD_AMOUNT, STEP_TIMEOUT) + .await + .expect("shielded balance never reached SHIELD_AMOUNT"); + + let notes = unspent_notes(&s.test_wallet, &handle, 0) + .await + .expect("capture unspent notes"); + let one_note = vec![notes[0].clone()]; + let dst = s.test_wallet.next_unused_address().await.expect("dst"); + + // Declare a zero fee (well under the floor). The dpp builder must + // refuse to emit the transition. + let built = build_unshield_st_against_notes( + &s.test_wallet, + &handle, + 0, + &dst, + UNSHIELD_AMOUNT, + 0, + &one_note, + ) + .await; + assert!( + built.is_err(), + "SH-023: building an under-floor-fee unshield must be rejected (client fee floor); \ + observed Ok — the wallet emitted an under-floor transition" + ); + tracing::info!( + target: "platform_wallet::e2e::cases::sh_023", + "under-floor fee correctly rejected at build (client floor); backend-floor arm is a \ + documented residual gap (no post-build fee seam)" + ); + + let bank_addr = s + .ctx + .bank() + .primary_receive_address() + .to_bech32m_string(s.ctx.bank().network()); + teardown_sweep_shielded(&s.test_wallet, &handle, &bank_addr).await; + s.teardown().await.expect("teardown"); +} diff --git a/packages/rs-platform-wallet/tests/e2e/cases/sh_024_value_boundary_overflow.rs b/packages/rs-platform-wallet/tests/e2e/cases/sh_024_value_boundary_overflow.rs new file mode 100644 index 00000000000..7b52aec31e9 --- /dev/null +++ b/packages/rs-platform-wallet/tests/e2e/cases/sh_024_value_boundary_overflow.rs @@ -0,0 +1,120 @@ +//! SH-024 — ADVERSARIAL: u64 value-boundary overflow — backend MUST +//! reject safely [INJECT]. +//! Spec: `tests/e2e/TEST_SPEC.md` §3 → SH-024. Priority: P1. HIGH-if-fails. +//! +//! Attack: capture a VALID Type-17 unshield, overwrite `unshielding_amount` +//! to `u64::MAX` (and `u64::MAX - 1`), and broadcast raw. The arithmetic +//! must be checked on the BACKEND — no wraparound, no validator panic, no +//! boundary value silently accepted. The client `checked_add` guard alone +//! is not the line of defense; a direct gRPC submitter bypasses it. +//! +//! RED if the backend wraps, panics, or accepts a boundary value. + +#![cfg(feature = "shielded")] + +use std::time::Duration; + +use crate::framework::prelude::*; +use crate::framework::shielded::{ + adversarial_enabled, bind_shielded, broadcast_raw, capture_unshield_st, + mutate_serialized_bundle, shielded_prover, teardown_sweep_shielded, wait_for_shielded_balance, + BundleField, BundleMutation, +}; +use crate::framework::wait::{ + wait_for_address_balance_chain_confirmed_n, CHAIN_CONFIRMED_CONSECUTIVE_SUCCESSES, +}; + +const FUNDING_CREDITS: u64 = 1_400_000_000; +const SHIELD_AMOUNT: u64 = 200_000_000; +const UNSHIELD_AMOUNT: u64 = 20_000_000; +const STEP_TIMEOUT: Duration = Duration::from_secs(60); + +#[tokio_shared_rt::test(shared, flavor = "multi_thread", worker_threads = 12)] +async fn sh_024_value_boundary_overflow() { + let _ = tracing_subscriber::fmt() + .with_env_filter( + tracing_subscriber::EnvFilter::try_from_default_env() + .unwrap_or_else(|_| "info,platform_wallet=debug".into()), + ) + .with_test_writer() + .try_init(); + + if !adversarial_enabled() { + tracing::info!( + target: "platform_wallet::e2e::cases::sh_024", + "PLATFORM_WALLET_E2E_SHIELDED_ADVERSARIAL unset — abuse case skipped (no-op pass)" + ); + return; + } + + let s = setup().await.expect("e2e setup failed"); + let prover = shielded_prover(); + let handle = bind_shielded(&s.test_wallet, &[0], &s.ctx.workdir) + .await + .expect("bind_shielded"); + + let addr_1 = s + .test_wallet + .next_unused_address() + .await + .expect("derive addr_1"); + s.ctx + .bank() + .fund_address(&addr_1, FUNDING_CREDITS) + .await + .expect("bank.fund_address"); + wait_for_address_balance_chain_confirmed_n( + s.ctx.sdk(), + &addr_1, + FUNDING_CREDITS, + CHAIN_CONFIRMED_CONSECUTIVE_SUCCESSES, + STEP_TIMEOUT, + ) + .await + .expect("addr_1 funding never observed"); + s.test_wallet + .sync_balances() + .await + .expect("pre-shield sync"); + s.test_wallet + .platform_wallet() + .shielded_shield_from_account(0, 0, SHIELD_AMOUNT, s.test_wallet.address_signer(), prover) + .await + .expect("shield_from_account"); + wait_for_shielded_balance(&s.test_wallet, &handle, 0, SHIELD_AMOUNT, STEP_TIMEOUT) + .await + .expect("shielded balance never reached SHIELD_AMOUNT"); + + let dst = s.test_wallet.next_unused_address().await.expect("dst"); + + for boundary in [u64::MAX, u64::MAX - 1] { + let mut st = capture_unshield_st(&s.test_wallet, &handle, 0, &dst, UNSHIELD_AMOUNT) + .await + .expect("capture valid unshield ST"); + mutate_serialized_bundle( + &mut st, + BundleField::ValueBalance, + &BundleMutation::Overwrite(boundary.to_le_bytes().to_vec()), + ) + .expect("set boundary amount"); + let result = broadcast_raw(s.ctx.sdk(), &st).await; + assert!( + result.is_err(), + "SH-024 FINDING: backend ACCEPTED a boundary unshielding_amount ({boundary}) — \ + missing backend arithmetic check (wrap/overflow/accept). result={result:?}" + ); + tracing::info!( + target: "platform_wallet::e2e::cases::sh_024", + boundary, + "boundary amount correctly rejected by backend" + ); + } + + let bank_addr = s + .ctx + .bank() + .primary_receive_address() + .to_bech32m_string(s.ctx.bank().network()); + teardown_sweep_shielded(&s.test_wallet, &handle, &bank_addr).await; + s.teardown().await.expect("teardown"); +} diff --git a/packages/rs-platform-wallet/tests/e2e/cases/sh_025_forged_proof.rs b/packages/rs-platform-wallet/tests/e2e/cases/sh_025_forged_proof.rs new file mode 100644 index 00000000000..6b61e6e8d34 --- /dev/null +++ b/packages/rs-platform-wallet/tests/e2e/cases/sh_025_forged_proof.rs @@ -0,0 +1,120 @@ +//! SH-025 — ADVERSARIAL: forged/tampered Halo-2 proof — verifier MUST +//! reject [INJECT]. +//! Spec: `tests/e2e/TEST_SPEC.md` §3 → SH-025. Priority: P0 +//! (consensus-critical). CRITICAL-if-it-fails (total break of shielded +//! soundness). +//! +//! Attack: build a VALID Type-17 unshield via the production capture seam +//! (`operations::build_unshield_st`), then corrupt `SerializedBundle.proof` +//! (bit-flip, zero) and broadcast directly via `broadcast_raw`, bypassing +//! the guarded wallet method. The proof is bound to the public inputs +//! (anchor, nullifiers, value_balance, cmx), so any mutation must fail +//! Orchard proof verification at the backend. +//! +//! RED if the backend accepts a tampered proof. + +#![cfg(feature = "shielded")] + +use std::time::Duration; + +use crate::framework::prelude::*; +use crate::framework::shielded::{ + adversarial_enabled, bind_shielded, broadcast_raw, capture_unshield_st, + mutate_serialized_bundle, shielded_prover, teardown_sweep_shielded, wait_for_shielded_balance, + BundleField, BundleMutation, +}; +use crate::framework::wait::{ + wait_for_address_balance_chain_confirmed_n, CHAIN_CONFIRMED_CONSECUTIVE_SUCCESSES, +}; + +const FUNDING_CREDITS: u64 = 1_400_000_000; +const SHIELD_AMOUNT: u64 = 200_000_000; +const UNSHIELD_AMOUNT: u64 = 20_000_000; +const STEP_TIMEOUT: Duration = Duration::from_secs(60); + +#[tokio_shared_rt::test(shared, flavor = "multi_thread", worker_threads = 12)] +async fn sh_025_forged_proof() { + let _ = tracing_subscriber::fmt() + .with_env_filter( + tracing_subscriber::EnvFilter::try_from_default_env() + .unwrap_or_else(|_| "info,platform_wallet=debug".into()), + ) + .with_test_writer() + .try_init(); + + if !adversarial_enabled() { + tracing::info!( + target: "platform_wallet::e2e::cases::sh_025", + "PLATFORM_WALLET_E2E_SHIELDED_ADVERSARIAL unset — abuse case skipped (no-op pass)" + ); + return; + } + + let s = setup().await.expect("e2e setup failed"); + let prover = shielded_prover(); + let handle = bind_shielded(&s.test_wallet, &[0], &s.ctx.workdir) + .await + .expect("bind_shielded"); + + let addr_1 = s + .test_wallet + .next_unused_address() + .await + .expect("derive addr_1"); + s.ctx + .bank() + .fund_address(&addr_1, FUNDING_CREDITS) + .await + .expect("bank.fund_address"); + wait_for_address_balance_chain_confirmed_n( + s.ctx.sdk(), + &addr_1, + FUNDING_CREDITS, + CHAIN_CONFIRMED_CONSECUTIVE_SUCCESSES, + STEP_TIMEOUT, + ) + .await + .expect("addr_1 funding never observed"); + s.test_wallet + .sync_balances() + .await + .expect("pre-shield sync"); + s.test_wallet + .platform_wallet() + .shielded_shield_from_account(0, 0, SHIELD_AMOUNT, s.test_wallet.address_signer(), prover) + .await + .expect("shield_from_account"); + wait_for_shielded_balance(&s.test_wallet, &handle, 0, SHIELD_AMOUNT, STEP_TIMEOUT) + .await + .expect("shielded balance never reached SHIELD_AMOUNT"); + + let dst = s.test_wallet.next_unused_address().await.expect("dst"); + + // For each proof mutation: capture a fresh valid unshield, tamper the + // proof, broadcast raw. Each must be rejected by the backend. + for mutation in [BundleMutation::FlipByte(0), BundleMutation::Zero] { + let mut st = capture_unshield_st(&s.test_wallet, &handle, 0, &dst, UNSHIELD_AMOUNT) + .await + .expect("capture valid unshield ST"); + mutate_serialized_bundle(&mut st, BundleField::Proof, &mutation).expect("tamper proof"); + let result = broadcast_raw(s.ctx.sdk(), &st).await; + assert!( + result.is_err(), + "SH-025 FINDING (CRITICAL): backend ACCEPTED a tampered proof ({mutation:?}) — \ + total break of shielded soundness. result={result:?}" + ); + tracing::info!( + target: "platform_wallet::e2e::cases::sh_025", + ?mutation, + "tampered proof correctly rejected by backend" + ); + } + + let bank_addr = s + .ctx + .bank() + .primary_receive_address() + .to_bech32m_string(s.ctx.bank().network()); + teardown_sweep_shielded(&s.test_wallet, &handle, &bank_addr).await; + s.teardown().await.expect("teardown"); +} diff --git a/packages/rs-platform-wallet/tests/e2e/cases/sh_026_anchor_mismatch.rs b/packages/rs-platform-wallet/tests/e2e/cases/sh_026_anchor_mismatch.rs new file mode 100644 index 00000000000..cd78ea2954b --- /dev/null +++ b/packages/rs-platform-wallet/tests/e2e/cases/sh_026_anchor_mismatch.rs @@ -0,0 +1,121 @@ +//! SH-026 — ADVERSARIAL: wrong/random anchor — backend MUST reject +//! AnchorMismatch [INJECT] (Found-030 dynamic probe). +//! Spec: `tests/e2e/TEST_SPEC.md` §3 → SH-026. Priority: P1. HIGH-if-fails. +//! +//! Attack: capture a VALID Type-17 unshield, overwrite +//! `SerializedBundle.anchor` with random 32 bytes (a root Drive never +//! recorded) while the witness paths authenticate against the real root, +//! then broadcast raw. Drive accepts only anchors it has recorded, so a +//! wrong anchor must fail. +//! +//! Found-030 dynamic probe: whichever anchor the backend accepts resolves +//! the doc ambiguity between `operations.rs:601-611` ("most recent +//! checkpoint") and `file_store.rs:162-165` ("current tree state"). A +//! wrong-anchor acceptance is a soundness break (RED). + +#![cfg(feature = "shielded")] + +use std::time::Duration; + +use crate::framework::prelude::*; +use crate::framework::shielded::{ + adversarial_enabled, bind_shielded, broadcast_raw, capture_unshield_st, + mutate_serialized_bundle, shielded_prover, teardown_sweep_shielded, wait_for_shielded_balance, + BundleField, BundleMutation, +}; +use crate::framework::wait::{ + wait_for_address_balance_chain_confirmed_n, CHAIN_CONFIRMED_CONSECUTIVE_SUCCESSES, +}; + +const FUNDING_CREDITS: u64 = 1_400_000_000; +const SHIELD_AMOUNT: u64 = 200_000_000; +const UNSHIELD_AMOUNT: u64 = 20_000_000; +const STEP_TIMEOUT: Duration = Duration::from_secs(60); + +#[tokio_shared_rt::test(shared, flavor = "multi_thread", worker_threads = 12)] +async fn sh_026_anchor_mismatch() { + let _ = tracing_subscriber::fmt() + .with_env_filter( + tracing_subscriber::EnvFilter::try_from_default_env() + .unwrap_or_else(|_| "info,platform_wallet=debug".into()), + ) + .with_test_writer() + .try_init(); + + if !adversarial_enabled() { + tracing::info!( + target: "platform_wallet::e2e::cases::sh_026", + "PLATFORM_WALLET_E2E_SHIELDED_ADVERSARIAL unset — abuse case skipped (no-op pass)" + ); + return; + } + + let s = setup().await.expect("e2e setup failed"); + let prover = shielded_prover(); + let handle = bind_shielded(&s.test_wallet, &[0], &s.ctx.workdir) + .await + .expect("bind_shielded"); + + let addr_1 = s + .test_wallet + .next_unused_address() + .await + .expect("derive addr_1"); + s.ctx + .bank() + .fund_address(&addr_1, FUNDING_CREDITS) + .await + .expect("bank.fund_address"); + wait_for_address_balance_chain_confirmed_n( + s.ctx.sdk(), + &addr_1, + FUNDING_CREDITS, + CHAIN_CONFIRMED_CONSECUTIVE_SUCCESSES, + STEP_TIMEOUT, + ) + .await + .expect("addr_1 funding never observed"); + s.test_wallet + .sync_balances() + .await + .expect("pre-shield sync"); + s.test_wallet + .platform_wallet() + .shielded_shield_from_account(0, 0, SHIELD_AMOUNT, s.test_wallet.address_signer(), prover) + .await + .expect("shield_from_account"); + wait_for_shielded_balance(&s.test_wallet, &handle, 0, SHIELD_AMOUNT, STEP_TIMEOUT) + .await + .expect("shielded balance never reached SHIELD_AMOUNT"); + + let dst = s.test_wallet.next_unused_address().await.expect("dst"); + + // Overwrite the anchor with a root the chain never recorded. + let mut st = capture_unshield_st(&s.test_wallet, &handle, 0, &dst, UNSHIELD_AMOUNT) + .await + .expect("capture valid unshield ST"); + mutate_serialized_bundle( + &mut st, + BundleField::Anchor, + &BundleMutation::Overwrite(vec![0xAB; 32]), + ) + .expect("tamper anchor"); + let result = broadcast_raw(s.ctx.sdk(), &st).await; + assert!( + result.is_err(), + "SH-026 FINDING: backend ACCEPTED a wrong/random anchor — soundness break (and resolves \ + Found-030 against any documented depth). result={result:?}" + ); + tracing::info!( + target: "platform_wallet::e2e::cases::sh_026", + "wrong anchor correctly rejected by backend (Found-030 probe: rejected as expected)" + ); + + let bank_addr = s + .ctx + .bank() + .primary_receive_address() + .to_bech32m_string(s.ctx.bank().network()); + teardown_sweep_shielded(&s.test_wallet, &handle, &bank_addr).await; + s.teardown().await.expect("teardown"); +} diff --git a/packages/rs-platform-wallet/tests/e2e/cases/sh_027_malformed_note_serde.rs b/packages/rs-platform-wallet/tests/e2e/cases/sh_027_malformed_note_serde.rs new file mode 100644 index 00000000000..561d61b63cf --- /dev/null +++ b/packages/rs-platform-wallet/tests/e2e/cases/sh_027_malformed_note_serde.rs @@ -0,0 +1,129 @@ +//! SH-027 — ADVERSARIAL: malformed note serde (note_data ≠ 115 bytes, +//! corrupted cmx/nullifier) — error SAFELY, no panic. +//! Spec: `tests/e2e/TEST_SPEC.md` §3 → SH-027. Priority: P1. HIGH-if-fails +//! (panic = host DoS; silent corruption = fund loss). +//! +//! Attack: seed the store with a `ShieldedNote` whose `note_data` is +//! truncated (114 B), oversized (116 B), empty, and bit-corrupted, then +//! drive the spend path that calls `extract_spends_and_anchor` → +//! `deserialize_note` (strict `SERIALIZED_NOTE_LEN = 115`). +//! +//! Correct behavior: every malformed length returns a typed +//! `ShieldedBuildError` (`deserialize_note` returns `None`) — NEVER a +//! panic, NEVER a silently-truncated note in a built bundle. +//! +//! This case is ACHIEVABLE without a production-seam change: the +//! `ShieldedStore` trait (`save_note` + `append_commitment`) is public, +//! so `seed_malformed_note` injects the bad note and `operations::unshield` +//! drives the deserialize path against an in-memory store. + +#![cfg(feature = "shielded")] + +use platform_wallet::error::PlatformWalletError; +use platform_wallet::wallet::shielded::{operations, OrchardKeySet, SubwalletId}; + +use crate::framework::prelude::*; +use crate::framework::shielded::{ + adversarial_enabled, in_memory_store, seed_malformed_note, shielded_prover, +}; + +/// Malformed `note_data` lengths to probe. The valid layout is 115 bytes +/// (`recipient43 ‖ value8 ‖ rho32 ‖ rseed32`); each of these must error. +const BAD_LENGTHS: &[usize] = &[0, 1, 114, 116, 200]; + +#[tokio_shared_rt::test(shared, flavor = "multi_thread", worker_threads = 12)] +async fn sh_027_malformed_note_serde() { + let _ = tracing_subscriber::fmt() + .with_env_filter( + tracing_subscriber::EnvFilter::try_from_default_env() + .unwrap_or_else(|_| "info,platform_wallet=debug".into()), + ) + .with_test_writer() + .try_init(); + + if !adversarial_enabled() { + tracing::info!( + target: "platform_wallet::e2e::cases::sh_027", + "PLATFORM_WALLET_E2E_SHIELDED_ADVERSARIAL unset — abuse case skipped (no-op pass)" + ); + return; + } + + let s = setup().await.expect("e2e setup failed"); + let prover = shielded_prover(); + let pw = s.test_wallet.platform_wallet(); + let wallet_id = pw.wallet_id(); + let network = pw.sdk().network; + let keyset = + OrchardKeySet::from_seed(&s.test_wallet.seed_bytes(), network, 0).expect("derive keyset"); + let id = SubwalletId::new(wallet_id, 0); + + let addr_dst = s + .test_wallet + .next_unused_address() + .await + .expect("derive addr_dst"); + + for &len in BAD_LENGTHS { + // Fresh store per length so a prior malformed note can't mask the + // next. The note value is large so note-selection picks it and + // the deserialize path is reached. + let store = in_memory_store(); + seed_malformed_note( + &store, + id, + 50_000_000, + vec![0xABu8; len], + [0x11; 32], + [0x22; 32], + ) + .await + .expect("seed malformed note"); + + // Drive the spend path. `deserialize_note` runs inside + // `extract_spends_and_anchor` (per note, before witness), so a + // malformed note surfaces a typed `ShieldedBuildError`. An + // in-memory store ALSO has a hard-Err witness() (Found-027), so a + // 115-byte-but-otherwise-bad note can instead surface + // `ShieldedMerkleWitnessUnavailable` — both are acceptable typed + // errors. The forbidden outcomes are `Ok` (silent corruption) and + // a PANIC (host DoS). A panic propagates as a test failure naming + // this case, which is itself the RED finding. + let result = operations::unshield( + &pw.sdk_arc(), + &store, + None, + wallet_id, + &keyset, + 0, + &addr_dst, + 10_000_000, + &prover, + ) + .await; + + match result { + Err( + PlatformWalletError::ShieldedBuildError(_) + | PlatformWalletError::ShieldedMerkleWitnessUnavailable(_) + | PlatformWalletError::ShieldedInsufficientBalance { .. }, + ) => { + tracing::info!( + target: "platform_wallet::e2e::cases::sh_027", + len, + "malformed note ({len} B) rejected with a typed error (no panic)" + ); + } + Ok(()) => panic!( + "SH-027 FINDING: malformed {len}-byte note_data was accepted into a built bundle \ + (silent corruption)" + ), + Err(other) => panic!( + "SH-027: malformed {len}-byte note must surface a typed serde/witness error; \ + observed {other:?}" + ), + } + } + + s.teardown().await.expect("teardown"); +} diff --git a/packages/rs-platform-wallet/tests/e2e/cases/sh_030_cross_network_recipient.rs b/packages/rs-platform-wallet/tests/e2e/cases/sh_030_cross_network_recipient.rs new file mode 100644 index 00000000000..93cad221ea0 --- /dev/null +++ b/packages/rs-platform-wallet/tests/e2e/cases/sh_030_cross_network_recipient.rs @@ -0,0 +1,93 @@ +//! SH-030 — ADVERSARIAL: cross-network / wrong-HRP / malformed recipient; +//! transfer-to-self. +//! Spec: `tests/e2e/TEST_SPEC.md` §3 → SH-030. Priority: P2. HIGH-if-fails +//! (cross-network acceptance = fund loss). +//! +//! Attack: unshield to (a) a WRONG-network-HRP address, (b) a malformed +//! bech32m address, (c) a syntactically-valid wrong-type address. +//! +//! Correct behavior: wrong-HRP and malformed addresses rejected with a +//! typed parse/network-mismatch error CLIENT-side (the parse + network +//! check at `platform_wallet.rs:621-633`). This case asserts the client +//! guard fires — the achievable half, no production-seam change needed. +//! +//! # PRODUCTION GAP (flagged, not fixed) +//! +//! The BACKEND-only arm (confirm Drive ALSO rejects a cross-network +//! recipient when the client check is bypassed — client must not be the +//! only line of defense) needs the raw build/broadcast seam to skip the +//! client network check. Not public — see +//! `framework::shielded::ADVERSARIAL_SEAM_MISSING`. + +#![cfg(feature = "shielded")] + +use platform_wallet::error::PlatformWalletError; + +use crate::framework::prelude::*; +use crate::framework::shielded::{adversarial_enabled, bind_shielded, shielded_prover}; + +#[tokio_shared_rt::test(shared, flavor = "multi_thread", worker_threads = 12)] +async fn sh_030_cross_network_recipient() { + let _ = tracing_subscriber::fmt() + .with_env_filter( + tracing_subscriber::EnvFilter::try_from_default_env() + .unwrap_or_else(|_| "info,platform_wallet=debug".into()), + ) + .with_test_writer() + .try_init(); + + if !adversarial_enabled() { + tracing::info!( + target: "platform_wallet::e2e::cases::sh_030", + "PLATFORM_WALLET_E2E_SHIELDED_ADVERSARIAL unset — abuse case skipped (no-op pass)" + ); + return; + } + + let s = setup().await.expect("e2e setup failed"); + let prover = shielded_prover(); + let handle = bind_shielded(&s.test_wallet, &[0], &s.ctx.workdir) + .await + .expect("bind_shielded"); + let pw = s.test_wallet.platform_wallet(); + + // (a) Wrong-network HRP: a mainnet `dash1…` platform address on a + // testnet wallet must be rejected with a typed network-mismatch / + // parse error BEFORE any proof build. + let mainnet_hrp = "dash1qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqq"; + let wrong_net = pw + .shielded_unshield_to(&handle.coordinator, 0, mainnet_hrp, 1_000_000, prover) + .await; + assert!( + matches!(wrong_net, Err(PlatformWalletError::ShieldedBuildError(_))), + "wrong-network-HRP recipient must be rejected with a typed ShieldedBuildError; \ + observed {wrong_net:?}" + ); + + // (b) Malformed bech32m: garbage must not parse. + let malformed = "tdash1notavalidaddressxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"; + let bad = pw + .shielded_unshield_to(&handle.coordinator, 0, malformed, 1_000_000, prover) + .await; + assert!( + matches!(bad, Err(PlatformWalletError::ShieldedBuildError(_))), + "malformed recipient address must be rejected with a typed ShieldedBuildError; \ + observed {bad:?}" + ); + + // (c) Wrong-type address (a Core base58 address where a platform + // bech32m is expected) must also fail to parse as a platform address. + let core_typed = "yXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX"; + let wrong_type = pw + .shielded_unshield_to(&handle.coordinator, 0, core_typed, 1_000_000, prover) + .await; + assert!( + matches!(wrong_type, Err(PlatformWalletError::ShieldedBuildError(_))), + "wrong-type (Core) recipient must be rejected with a typed ShieldedBuildError; \ + observed {wrong_type:?}" + ); + + // None of the above built a proof or shielded any funds, so teardown + // is a no-op sweep. + s.teardown().await.expect("teardown"); +} diff --git a/packages/rs-platform-wallet/tests/e2e/cases/sh_031_rebind_different_seed.rs b/packages/rs-platform-wallet/tests/e2e/cases/sh_031_rebind_different_seed.rs new file mode 100644 index 00000000000..4a11fa6f555 --- /dev/null +++ b/packages/rs-platform-wallet/tests/e2e/cases/sh_031_rebind_different_seed.rs @@ -0,0 +1,134 @@ +//! SH-031 — ADVERSARIAL: double-bind / rebind with a DIFFERENT seed — no +//! key-material mix, no leak. +//! Spec: `tests/e2e/TEST_SPEC.md` §3 → SH-031. Priority: P1. HIGH-if-fails. +//! +//! Attack: `bind_shielded(seed_A, &[0])`, shield + sync some notes, then +//! `bind_shielded(seed_B, &[0])` with a DIFFERENT seed on the same +//! wallet/coordinator. The rebind path unregisters+reregisters and the +//! doc claims "replace-not-merge". +//! +//! Correct behavior: after rebind to seed_B, seed_A's notes are NOT +//! visible/spendable under seed_B's keys (different IVK ⇒ no decryption). +//! RED if seed-A notes leak into seed-B's balance (privacy/accounting +//! break) or stale pending reservations make seed-B skip spendable notes. +//! +//! Achievable through the public API (`bind_shielded` twice) — no +//! production-seam change needed. + +#![cfg(feature = "shielded")] + +use std::time::Duration; + +use crate::framework::prelude::*; +use crate::framework::shielded::{ + adversarial_enabled, bind_shielded, shielded_prover, teardown_sweep_shielded, + wait_for_shielded_balance, +}; +use crate::framework::wait::{ + wait_for_address_balance_chain_confirmed_n, CHAIN_CONFIRMED_CONSECUTIVE_SUCCESSES, +}; + +const FUNDING_CREDITS: u64 = 1_200_000_000; +const SHIELD_AMOUNT: u64 = 50_000_000; +const STEP_TIMEOUT: Duration = Duration::from_secs(60); + +#[tokio_shared_rt::test(shared, flavor = "multi_thread", worker_threads = 12)] +async fn sh_031_rebind_different_seed() { + let _ = tracing_subscriber::fmt() + .with_env_filter( + tracing_subscriber::EnvFilter::try_from_default_env() + .unwrap_or_else(|_| "info,platform_wallet=debug".into()), + ) + .with_test_writer() + .try_init(); + + if !adversarial_enabled() { + tracing::info!( + target: "platform_wallet::e2e::cases::sh_031", + "PLATFORM_WALLET_E2E_SHIELDED_ADVERSARIAL unset — abuse case skipped (no-op pass)" + ); + return; + } + + let s = setup().await.expect("e2e setup failed"); + let prover = shielded_prover(); + let pw = s.test_wallet.platform_wallet(); + + // Bind with seed_A (the wallet's real seed) and shield a note. + let handle = bind_shielded(&s.test_wallet, &[0], &s.ctx.workdir) + .await + .expect("bind seed_A"); + let addr_1 = s + .test_wallet + .next_unused_address() + .await + .expect("derive addr_1"); + s.ctx + .bank() + .fund_address(&addr_1, FUNDING_CREDITS) + .await + .expect("bank.fund_address"); + wait_for_address_balance_chain_confirmed_n( + s.ctx.sdk(), + &addr_1, + FUNDING_CREDITS, + CHAIN_CONFIRMED_CONSECUTIVE_SUCCESSES, + STEP_TIMEOUT, + ) + .await + .expect("addr_1 funding never observed"); + s.test_wallet + .sync_balances() + .await + .expect("pre-shield sync"); + pw.shielded_shield_from_account(0, 0, SHIELD_AMOUNT, s.test_wallet.address_signer(), prover) + .await + .expect("shield under seed_A"); + wait_for_shielded_balance(&s.test_wallet, &handle, 0, SHIELD_AMOUNT, STEP_TIMEOUT) + .await + .expect("seed_A note never synced"); + + // Rebind the SAME wallet/coordinator with a DIFFERENT seed. + let (seed_b, _hex) = crate::framework::wallet_factory::fresh_seed(); + pw.bind_shielded(&seed_b, &[0], &handle.coordinator) + .await + .expect("rebind seed_B"); + + // Under seed_B's IVK, seed_A's note must NOT be visible. Re-scan and + // assert account 0 reports 0 (no cross-seed decryption / leak). + handle.sync().await; + let under_b = handle + .balances(&s.test_wallet) + .await + .expect("balances under seed_B") + .get(&0) + .copied() + .unwrap_or(0); + assert_eq!( + under_b, 0, + "SH-031 FINDING: seed_A's note ({SHIELD_AMOUNT}) leaked into seed_B's balance \ + after rebind — key-material mix / privacy break. observed {under_b}" + ); + + // Rebind back to seed_A and confirm its note re-discovers cleanly + // (the rebind purge did not corrupt or strand it). + pw.bind_shielded(&s.test_wallet.seed_bytes(), &[0], &handle.coordinator) + .await + .expect("rebind back to seed_A"); + let restored = + wait_for_shielded_balance(&s.test_wallet, &handle, 0, SHIELD_AMOUNT, STEP_TIMEOUT) + .await + .expect("seed_A note not re-discovered after rebind-back (stale-state corruption)"); + assert_eq!( + restored, SHIELD_AMOUNT, + "rebind back to seed_A must re-discover its note exactly; observed {restored}" + ); + + let bank_addr = s + .ctx + .bank() + .primary_receive_address() + .to_bech32m_string(s.ctx.bank().network()); + teardown_sweep_shielded(&s.test_wallet, &handle, &bank_addr).await; + s.teardown().await.expect("teardown"); +} diff --git a/packages/rs-platform-wallet/tests/e2e/cases/sh_032_exact_change_boundary.rs b/packages/rs-platform-wallet/tests/e2e/cases/sh_032_exact_change_boundary.rs new file mode 100644 index 00000000000..2cdc55b8c23 --- /dev/null +++ b/packages/rs-platform-wallet/tests/e2e/cases/sh_032_exact_change_boundary.rs @@ -0,0 +1,217 @@ +//! SH-032 — ADVERSARIAL: boundary balance `== amount + fee` + off-by-one +//! below — exact-change correctness. +//! Spec: `tests/e2e/TEST_SPEC.md` §3 → SH-032. Priority: P1. MEDIUM-if-fails. +//! +//! Attack: fund a single note to EXACTLY `amount + compute_minimum_shielded_fee(1)`, +//! spend `amount` (exact change → ZERO change, value conserved); then +//! off-by-one: a note of `amount + fee - 1` must be rejected +//! (`ShieldedInsufficientBalance`). +//! +//! Achievable through the public API (precise shield + public +//! `compute_minimum_shielded_fee`) — the spend reaches the backend so the +//! BACKEND's fee/value check is exercised, not just the client's. The +//! backend off-by-one INJECT arm needs the raw seam (flagged elsewhere); +//! the client off-by-one arm is asserted here. + +#![cfg(feature = "shielded")] + +use std::time::Duration; + +use dpp::shielded::compute_minimum_shielded_fee; +use dpp::version::PlatformVersion; +use platform_wallet::error::PlatformWalletError; + +use crate::framework::prelude::*; +use crate::framework::shielded::{ + adversarial_enabled, bind_shielded, shielded_prover, teardown_sweep_shielded, + wait_for_shielded_balance, +}; +use crate::framework::wait::{ + wait_for_address_balance_chain_confirmed_n, CHAIN_CONFIRMED_CONSECUTIVE_SUCCESSES, +}; + +// The shield funds a single note of `UNSHIELD_AMOUNT + compute_minimum_shielded_fee(1)` +// (~1e9); funding must cover that note PLUS the shield's own fee, so ~2.3e9. +// UNSHIELD_AMOUNT stays modest — the boundary note size is derived from the +// REAL fee at runtime, so this case is already fee-floor-correct by construction. +const FUNDING_CREDITS: u64 = 2_300_000_000; +const UNSHIELD_AMOUNT: u64 = 20_000_000; +const STEP_TIMEOUT: Duration = Duration::from_secs(60); + +#[tokio_shared_rt::test(shared, flavor = "multi_thread", worker_threads = 12)] +async fn sh_032_exact_change_boundary() { + let _ = tracing_subscriber::fmt() + .with_env_filter( + tracing_subscriber::EnvFilter::try_from_default_env() + .unwrap_or_else(|_| "info,platform_wallet=debug".into()), + ) + .with_test_writer() + .try_init(); + + if !adversarial_enabled() { + tracing::info!( + target: "platform_wallet::e2e::cases::sh_032", + "PLATFORM_WALLET_E2E_SHIELDED_ADVERSARIAL unset — abuse case skipped (no-op pass)" + ); + return; + } + + // A single-spend unshield is 1 action; the exact fee the wallet folds + // into the requirement is `compute_minimum_shielded_fee(1)`. + let version = PlatformVersion::latest(); + let exact_fee = compute_minimum_shielded_fee(1, version); + let exact_note = UNSHIELD_AMOUNT + exact_fee; + + // ---- Exact-change arm ---- + let s = setup().await.expect("e2e setup failed"); + let prover = shielded_prover(); + let handle = bind_shielded(&s.test_wallet, &[0], &s.ctx.workdir) + .await + .expect("bind_shielded"); + let pw = s.test_wallet.platform_wallet(); + + let addr_1 = s + .test_wallet + .next_unused_address() + .await + .expect("derive addr_1"); + s.ctx + .bank() + .fund_address(&addr_1, FUNDING_CREDITS) + .await + .expect("bank.fund_address"); + wait_for_address_balance_chain_confirmed_n( + s.ctx.sdk(), + &addr_1, + FUNDING_CREDITS, + CHAIN_CONFIRMED_CONSECUTIVE_SUCCESSES, + STEP_TIMEOUT, + ) + .await + .expect("addr_1 funding never observed"); + s.test_wallet + .sync_balances() + .await + .expect("pre-shield sync"); + + // Shield EXACTLY amount+fee into one note. + pw.shielded_shield_from_account(0, 0, exact_note, s.test_wallet.address_signer(), prover) + .await + .expect("exact-note shield"); + wait_for_shielded_balance(&s.test_wallet, &handle, 0, exact_note, STEP_TIMEOUT) + .await + .expect("exact note never synced"); + + let addr_dst = s + .test_wallet + .next_unused_address() + .await + .expect("derive addr_dst"); + let addr_dst_bech32m = addr_dst.to_bech32m_string(s.ctx.bank().network()); + pw.shielded_unshield_to( + &handle.coordinator, + 0, + &addr_dst_bech32m, + UNSHIELD_AMOUNT, + prover, + ) + .await + .expect("exact-change unshield must succeed"); + wait_for_address_balance_chain_confirmed_n( + s.ctx.sdk(), + &addr_dst, + UNSHIELD_AMOUNT, + CHAIN_CONFIRMED_CONSECUTIVE_SUCCESSES, + STEP_TIMEOUT, + ) + .await + .expect("exact-change unshield destination never observed"); + + // ZERO change: the note was consumed exactly, no dust change note. + handle.sync().await; + let change = handle + .balances(&s.test_wallet) + .await + .expect("post-unshield shielded_balances") + .get(&0) + .copied() + .unwrap_or(0); + assert_eq!( + change, 0, + "SH-032 FINDING: exact-change unshield (note == amount+fee) left {change} change — \ + expected ZERO (no phantom dust note, fee == {exact_fee} exact)" + ); + + let bank_addr = s + .ctx + .bank() + .primary_receive_address() + .to_bech32m_string(s.ctx.bank().network()); + teardown_sweep_shielded(&s.test_wallet, &handle, &bank_addr).await; + s.teardown().await.expect("teardown exact arm"); + + // ---- Off-by-one-below arm (client rejection) ---- + let s2 = setup().await.expect("e2e setup (off-by-one arm)"); + let handle2 = bind_shielded(&s2.test_wallet, &[0], &s2.ctx.workdir) + .await + .expect("bind_shielded off-by-one"); + let pw2 = s2.test_wallet.platform_wallet(); + let under_note = exact_note - 1; + + let addr2 = s2 + .test_wallet + .next_unused_address() + .await + .expect("derive addr2"); + s2.ctx + .bank() + .fund_address(&addr2, FUNDING_CREDITS) + .await + .expect("bank.fund_address off-by-one"); + wait_for_address_balance_chain_confirmed_n( + s2.ctx.sdk(), + &addr2, + FUNDING_CREDITS, + CHAIN_CONFIRMED_CONSECUTIVE_SUCCESSES, + STEP_TIMEOUT, + ) + .await + .expect("addr2 funding never observed"); + s2.test_wallet + .sync_balances() + .await + .expect("pre-shield sync 2"); + pw2.shielded_shield_from_account(0, 0, under_note, s2.test_wallet.address_signer(), prover) + .await + .expect("under-note shield"); + wait_for_shielded_balance(&s2.test_wallet, &handle2, 0, under_note, STEP_TIMEOUT) + .await + .expect("under note never synced"); + + let addr_dst2 = s2 + .test_wallet + .next_unused_address() + .await + .expect("derive addr_dst2"); + let addr_dst2_bech32m = addr_dst2.to_bech32m_string(s2.ctx.bank().network()); + let off_by_one = pw2 + .shielded_unshield_to( + &handle2.coordinator, + 0, + &addr_dst2_bech32m, + UNSHIELD_AMOUNT, + prover, + ) + .await; + assert!( + matches!( + off_by_one, + Err(PlatformWalletError::ShieldedInsufficientBalance { .. }) + ), + "SH-032 FINDING: a note of amount+fee-1 ({under_note}) underpays the fee by 1 and must be \ + rejected with ShieldedInsufficientBalance; observed {off_by_one:?}" + ); + + teardown_sweep_shielded(&s2.test_wallet, &handle2, &bank_addr).await; + s2.teardown().await.expect("teardown off-by-one arm"); +} diff --git a/packages/rs-platform-wallet/tests/e2e/cases/sh_033_duplicate_nullifier_in_bundle.rs b/packages/rs-platform-wallet/tests/e2e/cases/sh_033_duplicate_nullifier_in_bundle.rs new file mode 100644 index 00000000000..30e1b7d988c --- /dev/null +++ b/packages/rs-platform-wallet/tests/e2e/cases/sh_033_duplicate_nullifier_in_bundle.rs @@ -0,0 +1,150 @@ +//! SH-033 — ADVERSARIAL: duplicate nullifier WITHIN one bundle — backend +//! MUST reject [INJECT]. +//! Spec: `tests/e2e/TEST_SPEC.md` §3 → SH-033. Priority: P1. +//! CRITICAL-if-it-fails (double-spend within one tx). +//! +//! Attack: build one Type-17 unshield whose Orchard bundle spends the +//! same note TWICE (two actions, identical nullifier) by passing +//! `[note, note]` to the build-against-note seam, then broadcast. A +//! duplicate nullifier within one bundle must fail validation before any +//! state write. +//! +//! The build itself may reject the duplicate (a client-side guard), in +//! which case the dup never reaches Drive — acceptable, since no state +//! write occurs. The FINDING (RED) is a SUCCESSFUL broadcast: the backend +//! accepted an intra-bundle double-spend. + +#![cfg(feature = "shielded")] + +use std::time::Duration; + +use dpp::shielded::compute_minimum_shielded_fee; +use dpp::version::PlatformVersion; + +use crate::framework::prelude::*; +use crate::framework::shielded::{ + adversarial_enabled, bind_shielded, broadcast_raw, build_unshield_st_against_notes, + shielded_prover, teardown_sweep_shielded, unspent_notes, wait_for_shielded_balance, +}; +use crate::framework::wait::{ + wait_for_address_balance_chain_confirmed_n, CHAIN_CONFIRMED_CONSECUTIVE_SUCCESSES, +}; + +const FUNDING_CREDITS: u64 = 1_400_000_000; +const SHIELD_AMOUNT: u64 = 200_000_000; +// Below 2× the note value (plus the 2-action fee) so the two duplicated +// spends "cover" it — the point is the duplicate nullifier, not +// insufficient value. +const UNSHIELD_AMOUNT: u64 = 60_000_000; +const STEP_TIMEOUT: Duration = Duration::from_secs(60); + +#[tokio_shared_rt::test(shared, flavor = "multi_thread", worker_threads = 12)] +async fn sh_033_duplicate_nullifier_in_bundle() { + let _ = tracing_subscriber::fmt() + .with_env_filter( + tracing_subscriber::EnvFilter::try_from_default_env() + .unwrap_or_else(|_| "info,platform_wallet=debug".into()), + ) + .with_test_writer() + .try_init(); + + if !adversarial_enabled() { + tracing::info!( + target: "platform_wallet::e2e::cases::sh_033", + "PLATFORM_WALLET_E2E_SHIELDED_ADVERSARIAL unset — abuse case skipped (no-op pass)" + ); + return; + } + + let s = setup().await.expect("e2e setup failed"); + let prover = shielded_prover(); + let handle = bind_shielded(&s.test_wallet, &[0], &s.ctx.workdir) + .await + .expect("bind_shielded"); + + let addr_1 = s + .test_wallet + .next_unused_address() + .await + .expect("derive addr_1"); + s.ctx + .bank() + .fund_address(&addr_1, FUNDING_CREDITS) + .await + .expect("bank.fund_address"); + wait_for_address_balance_chain_confirmed_n( + s.ctx.sdk(), + &addr_1, + FUNDING_CREDITS, + CHAIN_CONFIRMED_CONSECUTIVE_SUCCESSES, + STEP_TIMEOUT, + ) + .await + .expect("addr_1 funding never observed"); + s.test_wallet + .sync_balances() + .await + .expect("pre-shield sync"); + s.test_wallet + .platform_wallet() + .shielded_shield_from_account(0, 0, SHIELD_AMOUNT, s.test_wallet.address_signer(), prover) + .await + .expect("shield_from_account"); + wait_for_shielded_balance(&s.test_wallet, &handle, 0, SHIELD_AMOUNT, STEP_TIMEOUT) + .await + .expect("shielded balance never reached SHIELD_AMOUNT"); + + let notes = unspent_notes(&s.test_wallet, &handle, 0) + .await + .expect("capture unspent notes"); + assert!(!notes.is_empty(), "expected one synced note"); + // The SAME note twice — duplicate nullifier within one bundle. + let dup = vec![notes[0].clone(), notes[0].clone()]; + + let exact_fee = compute_minimum_shielded_fee(2, PlatformVersion::latest()); + let dst = s.test_wallet.next_unused_address().await.expect("dst"); + + let built = build_unshield_st_against_notes( + &s.test_wallet, + &handle, + 0, + &dst, + UNSHIELD_AMOUNT, + exact_fee, + &dup, + ) + .await; + + match built { + Ok(st) => { + let result = broadcast_raw(s.ctx.sdk(), &st).await; + assert!( + result.is_err(), + "SH-033 FINDING (CRITICAL): backend ACCEPTED a bundle with a duplicate nullifier \ + — intra-transaction double-spend. result={result:?}" + ); + tracing::info!( + target: "platform_wallet::e2e::cases::sh_033", + "intra-bundle duplicate nullifier correctly rejected by backend" + ); + } + Err(e) => { + // The build rejected the duplicate before it could reach Drive; + // no state write occurs. Acceptable (the dup is stopped early), + // but log it so a reviewer knows the backend arm wasn't exercised. + tracing::info!( + target: "platform_wallet::e2e::cases::sh_033", + error = %e, + "duplicate-nullifier bundle rejected at build time (never reached the backend)" + ); + } + } + + let bank_addr = s + .ctx + .bank() + .primary_receive_address() + .to_bech32m_string(s.ctx.bank().network()); + teardown_sweep_shielded(&s.test_wallet, &handle, &bank_addr).await; + s.teardown().await.expect("teardown"); +} diff --git a/packages/rs-platform-wallet/tests/e2e/cases/sh_034_tampered_binding_signature.rs b/packages/rs-platform-wallet/tests/e2e/cases/sh_034_tampered_binding_signature.rs new file mode 100644 index 00000000000..dbe3b767538 --- /dev/null +++ b/packages/rs-platform-wallet/tests/e2e/cases/sh_034_tampered_binding_signature.rs @@ -0,0 +1,116 @@ +//! SH-034 — ADVERSARIAL: tampered binding signature — backend MUST +//! reject [INJECT]. +//! Spec: `tests/e2e/TEST_SPEC.md` §3 → SH-034. Priority: P1. +//! CRITICAL-if-it-fails (value-balance binding bypass). +//! +//! Attack: capture a VALID Type-17 unshield, flip bytes in +//! `SerializedBundle.binding_signature` (64 bytes), broadcast raw. The +//! binding signature commits to the value balance; a tampered signature +//! must fail Orchard bundle verification at the backend. +//! +//! RED if the backend accepts a tampered binding signature. + +#![cfg(feature = "shielded")] + +use std::time::Duration; + +use crate::framework::prelude::*; +use crate::framework::shielded::{ + adversarial_enabled, bind_shielded, broadcast_raw, capture_unshield_st, + mutate_serialized_bundle, shielded_prover, teardown_sweep_shielded, wait_for_shielded_balance, + BundleField, BundleMutation, +}; +use crate::framework::wait::{ + wait_for_address_balance_chain_confirmed_n, CHAIN_CONFIRMED_CONSECUTIVE_SUCCESSES, +}; + +const FUNDING_CREDITS: u64 = 1_400_000_000; +const SHIELD_AMOUNT: u64 = 200_000_000; +const UNSHIELD_AMOUNT: u64 = 20_000_000; +const STEP_TIMEOUT: Duration = Duration::from_secs(60); + +#[tokio_shared_rt::test(shared, flavor = "multi_thread", worker_threads = 12)] +async fn sh_034_tampered_binding_signature() { + let _ = tracing_subscriber::fmt() + .with_env_filter( + tracing_subscriber::EnvFilter::try_from_default_env() + .unwrap_or_else(|_| "info,platform_wallet=debug".into()), + ) + .with_test_writer() + .try_init(); + + if !adversarial_enabled() { + tracing::info!( + target: "platform_wallet::e2e::cases::sh_034", + "PLATFORM_WALLET_E2E_SHIELDED_ADVERSARIAL unset — abuse case skipped (no-op pass)" + ); + return; + } + + let s = setup().await.expect("e2e setup failed"); + let prover = shielded_prover(); + let handle = bind_shielded(&s.test_wallet, &[0], &s.ctx.workdir) + .await + .expect("bind_shielded"); + + let addr_1 = s + .test_wallet + .next_unused_address() + .await + .expect("derive addr_1"); + s.ctx + .bank() + .fund_address(&addr_1, FUNDING_CREDITS) + .await + .expect("bank.fund_address"); + wait_for_address_balance_chain_confirmed_n( + s.ctx.sdk(), + &addr_1, + FUNDING_CREDITS, + CHAIN_CONFIRMED_CONSECUTIVE_SUCCESSES, + STEP_TIMEOUT, + ) + .await + .expect("addr_1 funding never observed"); + s.test_wallet + .sync_balances() + .await + .expect("pre-shield sync"); + s.test_wallet + .platform_wallet() + .shielded_shield_from_account(0, 0, SHIELD_AMOUNT, s.test_wallet.address_signer(), prover) + .await + .expect("shield_from_account"); + wait_for_shielded_balance(&s.test_wallet, &handle, 0, SHIELD_AMOUNT, STEP_TIMEOUT) + .await + .expect("shielded balance never reached SHIELD_AMOUNT"); + + let dst = s.test_wallet.next_unused_address().await.expect("dst"); + + for mutation in [BundleMutation::FlipByte(0), BundleMutation::Zero] { + let mut st = capture_unshield_st(&s.test_wallet, &handle, 0, &dst, UNSHIELD_AMOUNT) + .await + .expect("capture valid unshield ST"); + mutate_serialized_bundle(&mut st, BundleField::BindingSignature, &mutation) + .expect("tamper binding signature"); + let result = broadcast_raw(s.ctx.sdk(), &st).await; + assert!( + result.is_err(), + "SH-034 FINDING (CRITICAL): backend ACCEPTED a tampered binding signature \ + ({mutation:?}) — value-balance binding bypass. result={result:?}" + ); + tracing::info!( + target: "platform_wallet::e2e::cases::sh_034", + ?mutation, + "tampered binding signature correctly rejected by backend" + ); + } + + let bank_addr = s + .ctx + .bank() + .primary_receive_address() + .to_bech32m_string(s.ctx.bank().network()); + teardown_sweep_shielded(&s.test_wallet, &handle, &bank_addr).await; + s.teardown().await.expect("teardown"); +} diff --git a/packages/rs-platform-wallet/tests/e2e/cases/sh_035_replayed_asset_lock_proof.rs b/packages/rs-platform-wallet/tests/e2e/cases/sh_035_replayed_asset_lock_proof.rs new file mode 100644 index 00000000000..5e1eb51325a --- /dev/null +++ b/packages/rs-platform-wallet/tests/e2e/cases/sh_035_replayed_asset_lock_proof.rs @@ -0,0 +1,115 @@ +//! SH-035 — ADVERSARIAL: replayed Type-18 asset-lock proof — backend +//! MUST reject (single-use) [INJECT]. +//! Spec: `tests/e2e/TEST_SPEC.md` §3 → SH-035. Priority: P1 (Core-L1 +//! gated). CRITICAL-if-it-fails (double-shield from one L1 lock = value +//! forgery). +//! +//! Attack: shield-from-asset-lock (Type 18) with a valid proof, then +//! resubmit the SAME proof in a second Type-18 transition. An asset-lock +//! outpoint is single-use; the second consumption MUST fail. +//! +//! Uses the public `shielded_shield_from_asset_lock` wrapper (Gap-4) + +//! the one-time-key helper (Gap-5). Core-L1 gated — a RED on the +//! proof-build documents the gate, not a defect. + +#![cfg(feature = "shielded")] + +use std::time::Duration; + +use platform_wallet::wallet::shielded::operations::test_utils::derive_asset_lock_private_key; +use platform_wallet::AssetLockFundingType; + +use crate::framework::prelude::*; +use crate::framework::shielded::{adversarial_enabled, bind_shielded, shielded_prover}; +use crate::framework::signer::SeedBackedCoreSigner; + +// 1.2M duffs = 1.2e9 credits — above Drive's 100k-duff asset-lock floor and +// the ~1e9 shielded fee, so the shield (and its REPLAY leg) reach the backend. +// Core funding covers the lock plus its L1 tx fee. +const TEST_WALLET_CORE_FUNDING: u64 = 1_400_000; +const ASSET_LOCK_DUFFS: u64 = 1_200_000; +const SHIELDED_ACCOUNT: u32 = 0; +#[allow(dead_code)] +const STEP_TIMEOUT: Duration = Duration::from_secs(60); + +#[tokio_shared_rt::test(shared, flavor = "multi_thread", worker_threads = 12)] +async fn sh_035_replayed_asset_lock_proof() { + let _ = tracing_subscriber::fmt() + .with_env_filter( + tracing_subscriber::EnvFilter::try_from_default_env() + .unwrap_or_else(|_| "info,platform_wallet=debug".into()), + ) + .with_test_writer() + .try_init(); + + if !adversarial_enabled() { + tracing::info!( + target: "platform_wallet::e2e::cases::sh_035", + "PLATFORM_WALLET_E2E_SHIELDED_ADVERSARIAL unset — abuse case skipped (no-op pass)" + ); + return; + } + + // Core-L1 gate (panics RED if unavailable, documenting the gate). + let s = crate::framework::setup_with_core_funded_test_wallet(TEST_WALLET_CORE_FUNDING) + .await + .expect("setup_with_core_funded_test_wallet (Core-L1 gate)"); + + let network = s.test_wallet.platform_wallet().sdk().network; + let seed_bytes = s.test_wallet.seed_bytes(); + let prover = shielded_prover(); + let _handle = bind_shielded(&s.test_wallet, &[SHIELDED_ACCOUNT], &s.ctx.workdir) + .await + .expect("bind_shielded"); + + let core_signer = SeedBackedCoreSigner::new(seed_bytes, network); + let (proof, path, _outpoint) = s + .test_wallet + .platform_wallet() + .asset_locks() + .create_funded_asset_lock_proof( + ASSET_LOCK_DUFFS, + 0, + AssetLockFundingType::AssetLockShieldedAddressTopUp, + SHIELDED_ACCOUNT, + &core_signer, + ) + .await + .expect("create_funded_asset_lock_proof (Core-L1 seam — RED documents the gate)"); + + let one_time_key = derive_asset_lock_private_key(&seed_bytes, network, &path) + .expect("derive one-time asset-lock private key"); + let credits = dpp::balances::credits::CREDITS_PER_DUFF * ASSET_LOCK_DUFFS; + + // First shield must succeed (consumes the single-use proof). + s.test_wallet + .platform_wallet() + .shielded_shield_from_asset_lock( + SHIELDED_ACCOUNT, + proof.clone(), + &one_time_key, + credits, + prover, + ) + .await + .expect("first shield-from-asset-lock must succeed"); + + // Replay: resubmit the SAME proof. The outpoint is already consumed, + // so the backend MUST reject. + let replay = s + .test_wallet + .platform_wallet() + .shielded_shield_from_asset_lock(SHIELDED_ACCOUNT, proof, &one_time_key, credits, prover) + .await; + assert!( + replay.is_err(), + "SH-035 FINDING (CRITICAL): the SAME asset-lock proof was consumed TWICE — \ + double-shield from one L1 lock = value forgery. result={replay:?}" + ); + tracing::info!( + target: "platform_wallet::e2e::cases::sh_035", + "replayed asset-lock proof correctly rejected (single-use enforced)" + ); + + s.teardown().await.expect("teardown"); +} diff --git a/packages/rs-platform-wallet/tests/e2e/framework/mod.rs b/packages/rs-platform-wallet/tests/e2e/framework/mod.rs index aebc2006d16..783665105ea 100644 --- a/packages/rs-platform-wallet/tests/e2e/framework/mod.rs +++ b/packages/rs-platform-wallet/tests/e2e/framework/mod.rs @@ -77,6 +77,8 @@ pub mod identities; pub mod identity_sync; pub mod registry; pub mod sdk; +#[cfg(feature = "shielded")] +pub mod shielded; pub mod signer; pub mod spv; pub mod tokens; diff --git a/packages/rs-platform-wallet/tests/e2e/framework/shielded.rs b/packages/rs-platform-wallet/tests/e2e/framework/shielded.rs new file mode 100644 index 00000000000..67edd10d9ec --- /dev/null +++ b/packages/rs-platform-wallet/tests/e2e/framework/shielded.rs @@ -0,0 +1,828 @@ +//! Wave H — shielded (Orchard) e2e harness. +//! +//! Spec: `tests/e2e/TEST_SPEC.md` §4 "Wave H — Shielded (Orchard) +//! harness extensions" and §3 "### Shielded (SH)". +//! +//! Everything here is gated behind `#[cfg(feature = "shielded")]`; the +//! SH cases compile only under `--features shielded` (the `e2e` feature +//! pulls `shielded` in). The cost center is the Halo-2 prover — see +//! [`shielded_prover`]. +//! +//! # Per-test isolation model +//! +//! The production `PlatformWalletManager` holds ONE coordinator per +//! network and `configure_shielded` refuses to repoint, so the harness +//! does NOT route through it. Instead [`bind_shielded`] routes every +//! case through ONE process-shared [`NetworkShieldedCoordinator`] over a +//! single persisted SQLite tree (see [`shared_coordinator`]). The +//! commitment tree is chain-wide — identical for every wallet on the +//! network — so sharing it is sharing a cache of public chain data, not +//! wallet state. Per-case isolation is preserved by `SubwalletId = +//! (wallet_id, account_index)` scoping: each case mints a fresh seed, so +//! its notes / spent-marks / watermarks never bleed into another case's. +//! +//! The first case pays one full ~1M-note Orchard scan into the shared +//! tree; [`bind_shielded`] then seeds each freshly-bound account's +//! watermark to the shared `tree_size`, so cases 2..N start their fetch +//! at the tip-aligned chunk and pull only the handful of notes since — +//! turning a per-case full re-scan into a per-case tip-delta scan +//! (~25-30x on the Orchard-scan portion of the suite). +//! +//! [`new_file_backed_coordinator`] still mints a private per-call tree +//! for the two cases that need a controlled, isolated tree (SH-007's +//! bind-ordering hook and SH-013's empty-accounts error path); they do +//! not benefit from the shared tree but keep the rest of the suite's win. +//! +//! # Adversarial injection hooks (SH-020..SH-035 — follow-up wave) +//! +//! The functional cases (SH-001..SH-019) call the guarded +//! `PlatformWallet::shielded_*` methods. The adversarial cases bypass +//! those guards to reach Drive's validation directly; the seams they +//! need ([`build_raw_shielded_transition`], [`broadcast_raw`], +//! [`mutate_serialized_bundle`], [`TamperingProver`], …) live here and +//! are gated behind [`adversarial_enabled`] so a stray malformed +//! broadcast can't pollute a normal functional run. + +#![cfg(feature = "shielded")] + +use std::sync::Arc; +use std::time::Duration; + +use dpp::shielded::builder::OrchardProver; +use grovedb_commitment_tree::ProvingKey; +use platform_wallet::wallet::shielded::{ + CachedOrchardProver, FileBackedShieldedStore, InMemoryShieldedStore, + NetworkShieldedCoordinator, ShieldedStore, SubwalletId, +}; + +use super::wallet_factory::TestWallet; +use super::{FrameworkError, FrameworkResult}; + +/// Env gate for the adversarial / abuse cases (SH-020..SH-035). The +/// hooks below that broadcast malformed transitions are no-ops unless +/// this is set, so the functional tier never accidentally hammers Drive +/// with garbage. Mirrors the `PLATFORM_WALLET_E2E_BANK_CORE_GATE` +/// convention. +pub const ADVERSARIAL_GATE_ENV: &str = "PLATFORM_WALLET_E2E_SHIELDED_ADVERSARIAL"; + +/// Whether the adversarial abuse pass is enabled this run. Accepts the +/// same truthy aliases the rest of the harness uses (`1`/`true`/`yes`/`on`, +/// case-insensitive). +pub fn adversarial_enabled() -> bool { + matches!( + std::env::var(ADVERSARIAL_GATE_ENV) + .unwrap_or_default() + .trim() + .to_ascii_lowercase() + .as_str(), + "1" | "true" | "yes" | "on" + ) +} + +/// Process-wide warmed Orchard prover. +/// +/// [`CachedOrchardProver`] is zero-sized — the expensive Halo-2 +/// [`ProvingKey`] lives in a `OnceLock` inside the prover module, so a +/// single [`CachedOrchardProver::warm_up`] builds it once for the whole +/// process and every SH case borrows `&CachedOrchardProver` cheaply. +/// +/// First call blocks ~30 s building the key; subsequent calls are +/// instant. Returns a `'static` handle so callers can pass +/// `&shielded_prover()` straight to the `shielded_*` methods (the +/// `OrchardProver` impl is on `&CachedOrchardProver`). +pub fn shielded_prover() -> &'static CachedOrchardProver { + static PROVER: CachedOrchardProver = CachedOrchardProver; + PROVER.warm_up(); + &PROVER +} + +/// Handle returned by [`bind_shielded`]: the per-test coordinator plus +/// the bound account list, so the test can drive `sync(true)` and read +/// balances without re-deriving anything. +pub struct ShieldedHandle { + /// Coordinator backing this case. For [`bind_shielded`] this is the + /// process-shared coordinator (one persisted tree across the suite); + /// SH-007 / SH-013 build a private one via + /// [`new_file_backed_coordinator`] and wrap it themselves. + pub coordinator: Arc, + /// ZIP-32 account indices bound on the wallet, ascending. + pub accounts: Vec, +} + +impl ShieldedHandle { + /// Force a sync pass so on-chain notes are scanned into the store. + /// `force=true` bypasses the coordinator's caught-up cooldown. + pub async fn sync(&self) { + let _ = self.coordinator.sync(true).await; + } + + /// This wallet's per-account unspent shielded balances. + pub async fn balances( + &self, + wallet: &TestWallet, + ) -> FrameworkResult> { + wallet + .platform_wallet() + .shielded_balances(&self.coordinator) + .await + .map_err(|e| FrameworkError::Wallet(format!("shielded_balances: {e}"))) + } +} + +/// Bind `accounts` on the wallet's shielded sub-wallet, routed through +/// the process-shared coordinator. +/// +/// All cases (except SH-007 / SH-013, which use +/// [`new_file_backed_coordinator`] for a controlled private tree) share +/// ONE [`NetworkShieldedCoordinator`] over ONE persisted SQLite tree +/// (see [`shared_coordinator`]). The first case pays the full ~1M-note +/// Orchard scan into that tree; this function then seeds each +/// freshly-bound account's watermark to the shared `tree_size`, so +/// cases 2..N start their fetch at the tip-aligned chunk and pull only +/// the notes since (including the one this case is about to shield, +/// which lands at a position `>= tree_size` and so is past the seed). +/// +/// Without the watermark seed a fresh-seed wallet binds at watermark 0, +/// collapsing the sync's `MIN`-watermark fetch start back to position 0 +/// — a shared tree alone would save only the local append, not the +/// dominant network re-fetch. Seeding is what converts the shared tree +/// into a fetch speedup. +/// +/// Per-case isolation holds because notes / spent-marks / watermarks are +/// `SubwalletId`-scoped and each case uses a distinct (fresh-seed) +/// `wallet_id`; `shielded_balances` reads per-subwallet notes, never the +/// shared tree, so a case's pre-sync "balance is 0" assertion stays +/// genuine. +/// +/// Errors: [`FrameworkError::Wallet`] for coordinator, `bind_shielded`, +/// or watermark-seed failures. +pub async fn bind_shielded( + wallet: &TestWallet, + accounts: &[u32], + workdir: &std::path::Path, +) -> FrameworkResult { + let coordinator = shared_coordinator(wallet, workdir).await?; + let seed = wallet.seed_bytes(); + wallet + .platform_wallet() + .bind_shielded(&seed, accounts, &coordinator) + .await + .map_err(|e| FrameworkError::Wallet(format!("bind_shielded: {e}")))?; + + // Seed this wallet's per-account watermark to the shared tree's + // current leaf count so the next sync fetches only the tip delta. + // `bind_shielded` registers the wallet on the coordinator and (for a + // fresh seed) leaves every account at watermark 0; overwrite that + // with `tree_size` under one write guard. A note at exactly + // `tree_size` still passes the sync's strict `position < watermark` + // save gate, so nothing this case owns is skipped. + { + let mut store = coordinator.store().write().await; + let tree_size = store + .tree_size() + .map_err(|e| FrameworkError::Wallet(format!("bind_shielded: tree_size: {e}")))?; + for &account in accounts { + let id = SubwalletId::new(wallet.id(), account); + store + .set_last_synced_note_index(id, tree_size) + .map_err(|e| { + FrameworkError::Wallet(format!("bind_shielded: seed watermark: {e}")) + })?; + } + } + + Ok(ShieldedHandle { + coordinator, + accounts: accounts.to_vec(), + }) +} + +/// The one process-shared coordinator, lazily built on the first +/// [`bind_shielded`]. Module-scoped (not a fn-local static) so +/// [`unregister_shared_coordinator`] can peek it on teardown without +/// re-deriving it from a wallet. +static SHARED_COORDINATOR: tokio::sync::OnceCell> = + tokio::sync::OnceCell::const_new(); + +/// Process-shared coordinator over ONE persisted commitment-tree SQLite +/// file for the whole suite — the speedup's single most important seam. +/// +/// Built lazily on the first [`bind_shielded`] from the shared SDK and a +/// single deterministic path `/shielded/shared_tree_.sqlite`. +/// Every case routes through the SAME `Arc>` +/// the coordinator owns, so there is exactly one SQLite handle (no +/// cross-handle WAL contention) and one persisted tree whose `tree_size` +/// carries the full ~1M-leaf scan forward across cases. +/// +/// Assumes a serial, single-process, single-network sh run +/// (`--test-threads=1`): the `OnceCell` is keyed only on `` (in +/// the db filename) and pins the SDK + workdir of whichever case binds +/// first. A future parallel or multi-network sh run would need to key +/// per-(network, workdir) instead of relying on this one-shot pin. +async fn shared_coordinator( + wallet: &TestWallet, + workdir: &std::path::Path, +) -> FrameworkResult> { + let pw = wallet.platform_wallet(); + let network = pw.sdk().network; + let dir = workdir.join("shielded"); + let sdk = pw.sdk_arc(); + SHARED_COORDINATOR + .get_or_try_init(|| async { + std::fs::create_dir_all(&dir).map_err(|e| { + FrameworkError::Io(format!("create shielded dir {}: {e}", dir.display())) + })?; + let db_path = dir.join(format!("shared_tree_{network}.sqlite")); + let store = FileBackedShieldedStore::open_path(&db_path, 100) + .map_err(|e| FrameworkError::Wallet(format!("open shared shielded store: {e}")))?; + Ok(Arc::new(NetworkShieldedCoordinator::new( + sdk, network, db_path, store, + ))) + }) + .await + .cloned() +} + +/// Unregister `wallet_id` from the process-shared coordinator, bounding +/// its registry as cases complete. No-op when the shared coordinator was +/// never built (e.g. a non-shielded case, or SH-007/SH-013 which use a +/// private tree). Idempotent: a second call (or a wallet that never bound +/// on the shared coordinator) is a clean no-op, so it is safe to call +/// from both [`teardown_sweep_shielded`] and the universal guard teardown. +/// +/// Purges only the wallet's per-subwallet state (notes, spent marks, +/// watermarks); the chain-wide commitment tree is left intact for the +/// next case. +pub async fn unregister_shared_coordinator(wallet_id: [u8; 32]) { + if let Some(coordinator) = SHARED_COORDINATOR.get() { + coordinator.unregister_wallet(wallet_id).await; + } +} + +/// Construct a PRIVATE per-call FileBacked coordinator over a fresh +/// SQLite path WITHOUT binding — the controlled-tree path for the two +/// cases that need an isolated tree: SH-007's bind-ordering hook (the +/// coordinator's tree is advanced via `sync(true)` before the second +/// wallet binds, so it must start empty) and SH-013's empty-accounts +/// error path (which errors before any sync). These two skip the shared +/// tree of [`bind_shielded`]; the rest of the suite keeps the speedup. +pub async fn new_file_backed_coordinator( + wallet: &TestWallet, + workdir: &std::path::Path, +) -> FrameworkResult> { + let dir = workdir.join("shielded"); + std::fs::create_dir_all(&dir) + .map_err(|e| FrameworkError::Io(format!("create shielded dir {}: {e}", dir.display())))?; + let unique = format!( + "{}-{}.sqlite", + hex::encode(&wallet.id()[..6]), + next_db_seq(), + ); + let db_path = dir.join(unique); + let store = FileBackedShieldedStore::open_path(&db_path, 100) + .map_err(|e| FrameworkError::Wallet(format!("open shielded store: {e}")))?; + let pw = wallet.platform_wallet(); + Ok(Arc::new(NetworkShieldedCoordinator::new( + pw.sdk_arc(), + pw.sdk().network, + db_path, + store, + ))) +} + +/// Monotonic per-process counter so each coordinator gets a distinct +/// SQLite file even when two binds in one test share a wallet id prefix. +fn next_db_seq() -> u64 { + use std::sync::atomic::{AtomicU64, Ordering}; + static SEQ: AtomicU64 = AtomicU64::new(0); + SEQ.fetch_add(1, Ordering::Relaxed) +} + +/// In-memory store for SH-005's witness-availability split. The +/// coordinator only accepts a FileBacked store, so the in-memory arm +/// drives the `operations::*` free functions directly with this store. +/// Its `witness()` is a hard `Err` (Found-027), which is exactly what +/// SH-005 pins. +pub fn in_memory_store() -> Arc> { + Arc::new(tokio::sync::RwLock::new(InMemoryShieldedStore::default())) +} + +/// Poll `shielded_balances` after a forced sync until `account`'s +/// balance reaches `expected`, or `timeout` elapses. +/// +/// Drives a `coordinator.sync(true)` each poll (the caught-up cooldown +/// is bypassed by `force=true`), mirroring the +/// [`super::tokens::wait_for_token_balance`] event-driven + +/// chain-confirmed shape. Returns the observed balance on success. +/// +/// Errors: [`FrameworkError::Cleanup`] on timeout (carries account + +/// expected for triage), [`FrameworkError::Wallet`] never — fetch +/// failures are logged and retried. +pub async fn wait_for_shielded_balance( + wallet: &TestWallet, + handle: &ShieldedHandle, + account: u32, + expected: u64, + timeout: Duration, +) -> FrameworkResult { + let deadline = std::time::Instant::now() + timeout; + loop { + handle.sync().await; + match handle.balances(wallet).await { + Ok(balances) => { + let current = balances.get(&account).copied().unwrap_or(0); + if current >= expected { + return Ok(current); + } + tracing::debug!( + target: "platform_wallet::e2e::shielded", + account, + current, + expected, + "shielded balance below target" + ); + } + Err(err) => tracing::debug!( + target: "platform_wallet::e2e::shielded", + account, + error = %err, + "shielded_balances fetch failed; retrying" + ), + } + + if std::time::Instant::now() >= deadline { + return Err(FrameworkError::Cleanup(format!( + "wait_for_shielded_balance timed out after {timeout:?} \ + (account={account} expected={expected})" + ))); + } + tokio::time::sleep(super::wait::DEFAULT_POLL_INTERVAL).await; + } +} + +/// Thin wrapper over `shielded_default_address` returning the raw 43 +/// bytes (SH-003 transfer-recipient plumbing). Errors if `account` +/// isn't bound. +pub async fn shielded_default_address_43( + wallet: &TestWallet, + account: u32, +) -> FrameworkResult<[u8; 43]> { + wallet + .platform_wallet() + .shielded_default_address(account) + .await + .ok_or_else(|| { + FrameworkError::Wallet(format!("shielded account {account} has no default address")) + }) +} + +/// Best-effort teardown sweep: unshield any residual shielded balance on +/// every bound account back to the bank's primary transparent platform +/// address, preventing a bank-fund leak across a long suite. +/// +/// **MUST NOT fail teardown.** Every error is swallowed and logged at +/// `warn` — the RED-by-design cases (SH-005 in-memory arm, any +/// intentionally-broken `witness()` path) WILL fail the sweep, and that +/// failure must never propagate. Mirrors `cancel_pending` and the PA +/// identity-sweep floor (best-effort, below-floor balances left for the +/// next-run orphan sweep). +/// +/// Finally unregisters the wallet from its coordinator so the shared +/// coordinator's registry stays bounded across the suite. This purges +/// only the case's per-subwallet state (notes, spent marks, watermarks); +/// the chain-wide commitment tree is left intact for the next case. +pub async fn teardown_sweep_shielded( + wallet: &TestWallet, + handle: &ShieldedHandle, + bank_addr_bech32m: &str, +) { + let prover = shielded_prover(); + for &account in &handle.accounts { + // Re-scan so the residual is current before we attempt to drain. + handle.sync().await; + let balance = match handle.balances(wallet).await { + Ok(b) => b.get(&account).copied().unwrap_or(0), + Err(err) => { + tracing::warn!( + target: "platform_wallet::e2e::shielded", + account, + error = %err, + "teardown sweep: balance read failed; skipping account" + ); + continue; + } + }; + if balance == 0 { + continue; + } + // The unshield itself pays a shielded fee, so we can't drain the + // full balance — the spend's note-selection folds the fee into + // the requirement. Leave a conservative fee headroom; if it's + // still short the unshield errors and we swallow it. + const FEE_HEADROOM: u64 = 5_000_000; + let sweep_amount = balance.saturating_sub(FEE_HEADROOM); + if sweep_amount == 0 { + continue; + } + match wallet + .platform_wallet() + .shielded_unshield_to( + &handle.coordinator, + account, + bank_addr_bech32m, + sweep_amount, + prover, + ) + .await + { + Ok(()) => tracing::info!( + target: "platform_wallet::e2e::shielded", + account, + sweep_amount, + "teardown sweep: unshielded residual to bank" + ), + Err(err) => tracing::warn!( + target: "platform_wallet::e2e::shielded", + account, + sweep_amount, + error = %err, + "teardown sweep: unshield failed (best-effort, swallowed)" + ), + } + } + + // Bound the shared coordinator's registry: drop this case's + // registration and per-subwallet store state. The chain-wide tree + // (the speedup's carried-forward cache) is left intact. + handle.coordinator.unregister_wallet(wallet.id()).await; +} + +// --------------------------------------------------------------------------- +// Adversarial injection hooks (SH-020..SH-035) +// +// These reach Drive with transitions the guarded `PlatformWallet::shielded_*` +// methods would never assemble: built via the production build/broadcast +// split (`operations::build_*_st`) + the `test-utils` spend-assembly seams, +// then byte-tampered and broadcast directly. All live broadcasts are gated +// behind `adversarial_enabled()`. +// --------------------------------------------------------------------------- + +/// A `SerializedBundle` field selector for [`mutate_serialized_bundle`]. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum BundleField { + /// Halo-2 proof bytes (SH-025). + Proof, + /// 64-byte binding signature (SH-034). + BindingSignature, + /// 32-byte Sinsemilla anchor (SH-026). + Anchor, + /// Net value balance (SH-022 / SH-024). + ValueBalance, +} + +/// How to mutate the selected byte field. +#[derive(Debug, Clone)] +pub enum BundleMutation { + /// Overwrite the whole field with these bytes (length-flexible — + /// truncation / overrun is itself part of the abuse surface). + Overwrite(Vec), + /// Zero every byte of the field. + Zero, + /// XOR-flip the byte at this index. + FlipByte(usize), +} + +/// An `OrchardProver` that emits a structurally-valid-looking but +/// circuit-invalid proof, for the proof-substitution arm of SH-025. +/// +/// The trait is just `proving_key()`, so a tampering prover hands back a +/// real key and the abuse case corrupts the resulting proof bytes +/// post-hoc via [`mutate_serialized_bundle`]. Holding the inner cached +/// prover keeps the key build shared. +pub struct TamperingProver; + +impl OrchardProver for &TamperingProver { + fn proving_key(&self) -> &ProvingKey { + // Borrow the shared, warmed key; the abuse case tampers with the + // emitted proof bytes afterwards rather than corrupting the key. + // The cached prover handle is itself `'static`, so the + // double-reference we hand the inner impl lives long enough. + static PROVER_REF: std::sync::OnceLock<&'static CachedOrchardProver> = + std::sync::OnceLock::new(); + let prover: &'static &'static CachedOrchardProver = PROVER_REF.get_or_init(shielded_prover); + OrchardProver::proving_key(prover) + } +} + +/// Broadcast a built [`StateTransition`] directly, returning the typed +/// backend error so an abuse case can assert the exact rejection variant. +/// Bypasses the guarded `shielded_*` methods. +/// +/// **`Ok(())` means `check_tx` admitted the transition to the mempool — +/// NOT that it committed at consensus.** A transition can pass `check_tx` +/// and still be rejected when the block is processed. To learn the +/// consensus verdict, follow with [`wait_commit_raw`] (SD-002). +/// +/// Gated: refuses unless [`adversarial_enabled`], so a stray malformed +/// broadcast can't pollute a normal functional run. Same broadcast path +/// PA-006 replays through. +pub async fn broadcast_raw( + sdk: &Arc, + state_transition: &dpp::state_transition::StateTransition, +) -> FrameworkResult<()> { + use dash_sdk::platform::transition::broadcast::BroadcastStateTransition; + + if !adversarial_enabled() { + return Err(FrameworkError::Config(format!( + "broadcast_raw refused: set {ADVERSARIAL_GATE_ENV} to run the abuse pass" + ))); + } + state_transition + .broadcast(sdk.as_ref(), None) + .await + .map_err(|e| FrameworkError::Sdk(format!("broadcast_raw: {e}"))) +} + +/// Wait for an already-broadcast [`StateTransition`]'s **consensus** +/// outcome (commit), the verdict [`broadcast_raw`] cannot observe. +/// +/// `Ok(_)` means the transition COMMITTED — Drive processed the block, +/// applied the state change, and returned a verifiable proof. `Err(_)` +/// carries the consensus rejection reason (e.g. nullifier-already-spent), +/// the evidence an adversarial probe must surface rather than swallow. +/// +/// Polls `wait_for_state_transition_result` via the SDK's +/// `wait_for_response` (proof-verified), capped at `timeout`. Use after +/// [`broadcast_raw`] to turn a mempool-admission probe into a +/// consensus-commit probe (SD-002). +/// +/// Gated like [`broadcast_raw`]. +pub async fn wait_commit_raw( + sdk: &Arc, + state_transition: &dpp::state_transition::StateTransition, + timeout: Duration, +) -> FrameworkResult { + use dash_sdk::platform::transition::broadcast::BroadcastStateTransition; + use dash_sdk::platform::transition::put_settings::PutSettings; + use dpp::state_transition::proof_result::StateTransitionProofResult; + + if !adversarial_enabled() { + return Err(FrameworkError::Config(format!( + "wait_commit_raw refused: set {ADVERSARIAL_GATE_ENV} to run the abuse pass" + ))); + } + let settings = PutSettings { + wait_timeout: Some(timeout), + ..Default::default() + }; + state_transition + .wait_for_response::(sdk.as_ref(), Some(settings)) + .await + .map_err(|e| FrameworkError::Sdk(format!("wait_commit_raw: {e}"))) +} + +/// Mutate one `SerializedBundle` field of a built shielded +/// [`StateTransition`] in place, before broadcast (SH-022/024/025/026/034). +/// +/// The shielded transition V0 structs expose `actions` / `value_balance` +/// (or `unshielding_amount`) / `anchor` / `proof` / `binding_signature` +/// as public fields, so the tamper is a direct field write — no byte +/// offsets. The Orchard proof + binding signature are bound to these +/// public inputs, so any mutation yields a transition the BACKEND must +/// reject. Returns an error if `field` doesn't apply to the transition's +/// type (e.g. `ValueBalance` on an unshield, which carries +/// `unshielding_amount` instead — use [`BundleField::ValueBalance`] for +/// both; this maps it onto whichever field the variant has). +pub fn mutate_serialized_bundle( + st: &mut dpp::state_transition::StateTransition, + field: BundleField, + mutation: &BundleMutation, +) -> FrameworkResult<()> { + use dpp::state_transition::StateTransition; + + /// Apply `mutation` to a `Vec` field (proof). + fn mutate_vec(buf: &mut Vec, m: &BundleMutation) { + match m { + BundleMutation::Overwrite(bytes) => *buf = bytes.clone(), + BundleMutation::Zero => buf.iter_mut().for_each(|b| *b = 0), + BundleMutation::FlipByte(i) => { + if let Some(b) = buf.get_mut(*i) { + *b ^= 0xFF; + } + } + } + } + /// Apply `mutation` to a fixed-size byte array field (anchor / sig). + fn mutate_arr(buf: &mut [u8], m: &BundleMutation) { + match m { + BundleMutation::Overwrite(bytes) => { + for (dst, src) in buf.iter_mut().zip(bytes.iter()) { + *dst = *src; + } + } + BundleMutation::Zero => buf.iter_mut().for_each(|b| *b = 0), + BundleMutation::FlipByte(i) => { + if let Some(b) = buf.get_mut(*i) { + *b ^= 0xFF; + } + } + } + } + + macro_rules! tamper_v0 { + ($v0:expr, $has_value_balance:tt) => {{ + match field { + BundleField::Proof => mutate_vec(&mut $v0.proof, mutation), + BundleField::BindingSignature => mutate_arr(&mut $v0.binding_signature, mutation), + BundleField::Anchor => mutate_arr(&mut $v0.anchor, mutation), + BundleField::ValueBalance => tamper_v0!(@value $v0, $has_value_balance), + } + }}; + (@value $v0:expr, value_balance) => {{ + // value_balance is u64; the overwrite's first 8 LE bytes set it. + if let BundleMutation::Overwrite(bytes) = mutation { + let mut le = [0u8; 8]; + for (d, s) in le.iter_mut().zip(bytes.iter()) { + *d = *s; + } + $v0.value_balance = u64::from_le_bytes(le); + } else if matches!(mutation, BundleMutation::Zero) { + $v0.value_balance = 0; + } else { + $v0.value_balance = $v0.value_balance.wrapping_add(1); + } + }}; + (@value $v0:expr, unshielding_amount) => {{ + if let BundleMutation::Overwrite(bytes) = mutation { + let mut le = [0u8; 8]; + for (d, s) in le.iter_mut().zip(bytes.iter()) { + *d = *s; + } + $v0.unshielding_amount = u64::from_le_bytes(le); + } else if matches!(mutation, BundleMutation::Zero) { + $v0.unshielding_amount = 0; + } else { + $v0.unshielding_amount = $v0.unshielding_amount.wrapping_add(1); + } + }}; + } + + use dpp::state_transition::shielded_transfer_transition::ShieldedTransferTransition; + use dpp::state_transition::shielded_withdrawal_transition::ShieldedWithdrawalTransition; + use dpp::state_transition::unshield_transition::UnshieldTransition; + + match st { + StateTransition::Unshield(UnshieldTransition::V0(v0)) => { + tamper_v0!(v0, unshielding_amount) + } + StateTransition::ShieldedTransfer(ShieldedTransferTransition::V0(v0)) => { + tamper_v0!(v0, value_balance) + } + StateTransition::ShieldedWithdrawal(ShieldedWithdrawalTransition::V0(v0)) => { + tamper_v0!(v0, unshielding_amount) + } + other => { + return Err(FrameworkError::Wallet(format!( + "mutate_serialized_bundle: unsupported transition variant for tampering: {:?}", + std::mem::discriminant(other) + ))); + } + } + Ok(()) +} + +/// Build a real, valid Type-17 unshield [`StateTransition`] for `account` +/// against the wallet's synced notes WITHOUT broadcasting it — the shared +/// capture seam for the byte-tamper abuse cases (SH-022/024/025/026/034). +/// +/// Reserves and selects notes via the production reservation path +/// (`test-utils` seam), then calls `operations::build_unshield_st`. The +/// reservation is intentionally NOT released: the abuse case discards the +/// transition after tampering, and the per-test coordinator is torn down, +/// so the in-memory pending mark is irrelevant. +pub async fn capture_unshield_st( + wallet: &TestWallet, + handle: &ShieldedHandle, + account: u32, + to_platform_addr: &dpp::address_funds::PlatformAddress, + amount: u64, +) -> FrameworkResult { + use platform_wallet::wallet::shielded::{operations, OrchardKeySet, SubwalletId}; + + let pw = wallet.platform_wallet(); + let id = SubwalletId::new(pw.wallet_id(), account); + let keyset = OrchardKeySet::from_seed(&wallet.seed_bytes(), pw.sdk().network, account) + .map_err(|e| FrameworkError::Wallet(format!("capture_unshield_st: keyset: {e}")))?; + + let (selected, _total, exact_fee) = operations::test_utils::reserve_unspent_notes_for_test( + &pw.sdk_arc(), + handle.coordinator.store(), + id, + amount, + 1, + ) + .await + .map_err(|e| FrameworkError::Wallet(format!("capture_unshield_st: reserve: {e}")))?; + + operations::build_unshield_st( + &pw.sdk_arc(), + handle.coordinator.store(), + &keyset, + to_platform_addr, + amount, + exact_fee, + &selected, + &shielded_prover(), + ) + .await + .map_err(|e| FrameworkError::Wallet(format!("capture_unshield_st: build: {e}"))) +} + +/// All unspent notes for `account`, so an abuse case can capture a note +/// to build a second (double-spend / replay) or duplicated (intra-bundle) +/// transition against. Reads via the `test-utils` seam. +pub async fn unspent_notes( + wallet: &TestWallet, + handle: &ShieldedHandle, + account: u32, +) -> FrameworkResult> { + use platform_wallet::wallet::shielded::{operations, SubwalletId}; + let pw = wallet.platform_wallet(); + let id = SubwalletId::new(pw.wallet_id(), account); + operations::test_utils::unspent_notes_for_test(handle.coordinator.store(), id) + .await + .map_err(|e| FrameworkError::Wallet(format!("unspent_notes: {e}"))) +} + +/// Build a Type-17 unshield [`StateTransition`] against a CHOSEN note set, +/// SKIPPING the reservation guard — the build-against-note seam for the +/// double-spend (SH-020), replay (SH-021), and intra-bundle-duplicate +/// (SH-033) abuse cases. The caller computes the fee +/// (`compute_minimum_shielded_fee`) since reservation (which derives it) +/// is bypassed. +pub async fn build_unshield_st_against_notes( + wallet: &TestWallet, + handle: &ShieldedHandle, + account: u32, + to_platform_addr: &dpp::address_funds::PlatformAddress, + amount: u64, + exact_fee: u64, + notes: &[platform_wallet::wallet::shielded::ShieldedNote], +) -> FrameworkResult { + use platform_wallet::wallet::shielded::{operations, OrchardKeySet}; + let pw = wallet.platform_wallet(); + let keyset = OrchardKeySet::from_seed(&wallet.seed_bytes(), pw.sdk().network, account) + .map_err(|e| FrameworkError::Wallet(format!("build_unshield_st_against_notes: {e}")))?; + operations::build_unshield_st( + &pw.sdk_arc(), + handle.coordinator.store(), + &keyset, + to_platform_addr, + amount, + exact_fee, + notes, + &shielded_prover(), + ) + .await + .map_err(|e| FrameworkError::Wallet(format!("build_unshield_st_against_notes: {e}"))) +} + +/// Inject a `ShieldedNote` with caller-controlled `note_data` / `cmx` / +/// `nullifier` into a store, for the serde-abuse SH-027. A malformed +/// `note_data` (≠115 bytes) must surface a typed error — never a panic — +/// when the spend path's `deserialize_note` reads it. +/// +/// This seam IS achievable through the public `ShieldedStore` trait +/// (`save_note` + `append_commitment`), so it is wired live. Builds a +/// note that note-selection will pick (`value > 0`, unspent) but whose +/// `note_data` the caller controls. +pub async fn seed_malformed_note( + store: &Arc>, + id: platform_wallet::wallet::shielded::SubwalletId, + value: u64, + note_data: Vec, + cmx: [u8; 32], + nullifier: [u8; 32], +) -> FrameworkResult<()> +where + S: platform_wallet::wallet::shielded::ShieldedStore, +{ + use platform_wallet::wallet::shielded::{ShieldedNote, ShieldedStore}; + let note = ShieldedNote { + position: 0, + cmx, + nullifier, + block_height: 0, + is_spent: false, + value, + note_data, + }; + let mut guard = store.write().await; + guard + .save_note(id, ¬e) + .map_err(|e| FrameworkError::Wallet(format!("seed_malformed_note: save_note: {e}")))?; + guard + .append_commitment(&cmx, true) + .map_err(|e| FrameworkError::Wallet(format!("seed_malformed_note: append: {e}")))?; + Ok(()) +} diff --git a/packages/rs-platform-wallet/tests/e2e/framework/spv.rs b/packages/rs-platform-wallet/tests/e2e/framework/spv.rs index dffcfd8cae3..aba21accc00 100644 --- a/packages/rs-platform-wallet/tests/e2e/framework/spv.rs +++ b/packages/rs-platform-wallet/tests/e2e/framework/spv.rs @@ -456,13 +456,6 @@ fn build_client_config( client_config.devnet = Some(devnet); } - // TODO(porter-live-run): SPV P2P handshake to porter devnet is refused — - // dash-spv (rev cfb01fa) advertises PROTOCOL_VERSION 70237 but porter Dash - // Core 23.1.2 enforces min 70240, dropping us before verack. Genesis - // pre-seed works; this is the remaining blocker. Awaiting an upstream - // rust-dashcore protocol bump (then update the 8 rev lines in /Cargo.toml). - // See PR #3727 Failed Tests ledger. - client_config.validate().map_err(|e| { tracing::error!( target: "platform_wallet::e2e::spv", diff --git a/packages/rs-platform-wallet/tests/e2e/framework/wallet_factory.rs b/packages/rs-platform-wallet/tests/e2e/framework/wallet_factory.rs index 5c9819d6190..deb165b4379 100644 --- a/packages/rs-platform-wallet/tests/e2e/framework/wallet_factory.rs +++ b/packages/rs-platform-wallet/tests/e2e/framework/wallet_factory.rs @@ -832,6 +832,14 @@ impl SetupGuard { self.teardown_called = true; } + // Universal shielded-registry bound: drop this wallet from the + // process-shared coordinator so its SubwalletIds don't linger and + // tax every later case's per-batch trial-decrypt. Idempotent and a + // no-op for non-shielded cases (the shared coordinator was never + // built) and for cases that already swept-and-unregistered. + #[cfg(feature = "shielded")] + super::shielded::unregister_shared_coordinator(self.test_wallet.id()).await; + // Post-sweep Core top-up: the sweep just returned this test's // funds to the bank, so this is the cheapest point to refill // Layer-1 for the next pass. Below-threshold-guarded inside the