From e3653e2266c9116bd27826c3dbb9a74244ebdbaa Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Fri, 22 May 2026 11:11:16 +0200 Subject: [PATCH 1/7] =?UTF-8?q?docs(rs-platform-wallet):=20shielded=20spec?= =?UTF-8?q?=20=E2=80=94=20full=20scope=20+=20post-merge=20verification?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Verified the proposed shielded (Orchard) test cases against the MERGED v3.1-dev feat tree and applied user-approved full scope: - Found-027 (InMemory witness Err) STILL LIVE — SH-005 stays red-by-design. - Found-028 (shielded_add_account skips coordinator.register_wallet) STILL LIVE — SH-006 stays red-by-design. - Found-029 (pre-bind notes unwitnessable) FIXED by #3603 (sync.rs marks every commitment position; verified sync.rs:291-310). Dropped as a red pin; SH-007 repurposed into a GREEN regression guard locking in the fix. - Found-030 (anchor-semantics doc drift) STILL LIVE — SH-030 doc note. - Coupling recorded: Found-027 (in-memory witness) is independent of #3603; the fix only helps the FileBacked path, which all spend-side SH cases use. - SH-018 (Type 18 shield-from-asset-lock) and SH-019 (Type 19 withdraw to L1) un-deferred to P1, gated on a new Core-L1 harness requirement (asset-lock funding + Layer-1 payout observation); may run RED until plumbing lands. - Wave H gains a best-effort + logged teardown shielded fund-sweep (unshield residual balance back to the bank platform address) to prevent bank-fund leak; RED-by-design / broken-witness cases must NOT fail teardown. Changelog, §2 matrix, quick index, Found-NNN table, §4 Wave H, §5 register all updated. Tally: 2 HIGH live (027, 028) + 1 LOW (030) = 3 live findings + 1 guarded-fix regression test (SH-007/Found-029). Spec only — no test implementation, no production code touched. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../rs-platform-wallet/tests/e2e/TEST_SPEC.md | 326 +++++++++++++++++- 1 file changed, 324 insertions(+), 2 deletions(-) diff --git a/packages/rs-platform-wallet/tests/e2e/TEST_SPEC.md b/packages/rs-platform-wallet/tests/e2e/TEST_SPEC.md index 63c726b448..e618e32d06 100644 --- a/packages/rs-platform-wallet/tests/e2e/TEST_SPEC.md +++ b/packages/rs-platform-wallet/tests/e2e/TEST_SPEC.md @@ -8,6 +8,8 @@ presumably enumerate the joy of doing it. ## Changelog +- **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`). @@ -152,7 +154,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 | @@ -248,6 +250,22 @@ 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 | +| 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 | not implemented (Wave H + Core-L1 gate) — may run RED until plumbing 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 | #### Found-bug pins @@ -277,6 +295,10 @@ 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). @@ -1935,6 +1957,286 @@ 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")]` and +`#[ignore]`d — these need a live testnet *and* a warmed Halo-2 prover +(`CachedOrchardProver`, ~30 s/proof cold), so they run only in the gated +`--include-ignored --features shielded` cohort, never the default suite. 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). + +**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). + +A note on intent: this area was commissioned to FIND BUGS in code that was, until +recently, entirely out of scope. 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 now locks it in as a +GREEN regression guard). The live-bug cases below are designed to fail loudly +while those bugs persist, not to pass; 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). +- **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**: not implemented (Wave H + Core-L1 gate). MAY run RED until the Core-L1 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**: `wallet/shielded/operations.rs:269` (`shield_from_asset_lock`) → `build_shield_from_asset_lock_transition`. NOTE: there is currently NO public `PlatformWallet::shielded_shield_from_asset_lock` wrapper (only the inner free function; contrast the four other spend types which all have public wrappers, `platform_wallet.rs:560/604/652/721`). Wave H must either add a thin test-only wrapper or call the inner path — flag the missing public wrapper as a follow-up DX gap. +- **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). +- **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`). +- **Harness extensions required**: Wave H + Core-L1 gate (Layer-1 payout observation, shared with §5 item 2 transparent withdrawal design). +- **Estimated complexity**: L + ### Found-bug pins (Found-NNN) Bug-pin cases discovered during a QA-mindset audit of `packages/rs-platform-wallet/src/`. @@ -2532,6 +2834,26 @@ 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. +- **Unlocks**: SH-001..SH-019. 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. +- **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. **Highest-value deliverables**: 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 doc-correctness note. + ### Framework notes (post-V20) **`bank.fund_address` — chain-confirmed-nonce wait (PR #3609 / upstream issue #3611)** @@ -2557,7 +2879,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. From 3428f1479b2991afe110db86b2c8cbbb4b5ff190 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Fri, 22 May 2026 11:18:27 +0200 Subject: [PATCH 2/7] =?UTF-8?q?docs(rs-platform-wallet):=20shielded=20spec?= =?UTF-8?q?=20=E2=80=94=20adversarial=20/=20abuse=20pass=20(SH-020..SH-035?= =?UTF-8?q?)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rewrites the suite's stated purpose: attempt to BREAK THE BACKEND (Drive consensus / state-transition validation + Orchard proof verifier), not confirm happy paths. Adds 16 adversarial cases, each asserting backend rejection / safe behavior; a RED is the deliverable (proves a malformed transition was accepted or mishandled). Cases: SH-020 double-spend, SH-021 nullifier replay after restart, SH-022 value not conserved, SH-023 fee underpayment, SH-024 u64/i64 boundary, SH-025 forged proof, SH-026 anchor mismatch (Found-030 dynamic probe), SH-027 malformed note serde, SH-028 interrupt-sync, SH-029 reorg/out-of-order/rescan-from-0, SH-030 cross-network/own-address/self-transfer, SH-031 rebind-different-seed, SH-032 exact-change boundary, SH-033 intra-bundle duplicate nullifier, SH-034 tampered binding sig, SH-035 replayed asset-lock proof. Consensus-critical attacks (020/022/025/033/034/035) re-ranked P0/P1, CRITICAL-if-they-fail. Methodology: client-side wallet guards must NOT mask the backend test — [INJECT]-marked cases construct/mutate transitions at the protocol boundary (public dpp::shielded::builder build_*_transition -> mutable SerializedBundle {anchor,proof,value_balance,binding_signature} -> BroadcastStateTransition) and broadcast directly, bypassing PlatformWallet::shielded_* guards. Wave H gains an adversarial injection hooks block (raw build/broadcast, bundle- byte mutation, TamperingProver, build-against-known-note, store-seed-malformed- note, scriptable mock sync source, asset-lock reuse) behind a PLATFORM_WALLET_E2E_SHIELDED_ADVERSARIAL gate. Changelog, SH intent note, quick index, Wave H updated. Spec only — no test implementation, no production code touched. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../rs-platform-wallet/tests/e2e/TEST_SPEC.md | 266 +++++++++++++++++- 1 file changed, 258 insertions(+), 8 deletions(-) diff --git a/packages/rs-platform-wallet/tests/e2e/TEST_SPEC.md b/packages/rs-platform-wallet/tests/e2e/TEST_SPEC.md index e618e32d06..f3809c31c1 100644 --- a/packages/rs-platform-wallet/tests/e2e/TEST_SPEC.md +++ b/packages/rs-platform-wallet/tests/e2e/TEST_SPEC.md @@ -8,6 +8,8 @@ presumably enumerate the joy of doing it. ## Changelog +- **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). @@ -266,6 +268,22 @@ Status legend: **green** = test file present, body has real assertions, runnable | 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 | not implemented (Wave H + Core-L1 gate) — may run RED until plumbing 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 | not implemented (Wave H + cancel hook) — asserts consistency | M | +| SH-029 | ADVERSARIAL: reorg / out-of-order blocks / rescan-from-0 — balance converges, no phantom funds | P1 | not implemented (Wave H + mock sync) — asserts convergence | 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 @@ -1977,12 +1995,27 @@ 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). -A note on intent: this area was commissioned to FIND BUGS in code that was, until -recently, entirely out of scope. 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 now locks it in as a -GREEN regression guard). The live-bug cases below are designed to fail loudly -while those bugs persist, not to pass; SH-007 is designed to PASS and stay green. +**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 @@ -2237,6 +2270,213 @@ while those bugs persist, not to pass; SH-007 is designed to PASS and stay green - **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. +- **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. +- **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/`. @@ -2851,8 +3091,18 @@ the gated `--include-ignored` cohort, never the default tier. - **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. -- **Unlocks**: SH-001..SH-019. 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. -- **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. **Highest-value deliverables**: 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 doc-correctness note. +- **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) From 89fd2000f13417d4d4f6f8aea4b9f0151312fb89 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Fri, 22 May 2026 12:13:02 +0200 Subject: [PATCH 3/7] test(rs-platform-wallet): Wave H shielded e2e harness (prover, bind, wait, sweep, inject hooks) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the framework/shielded.rs module unlocking the SH (Orchard) area: - shielded_prover(): process-wide warmed CachedOrchardProver behind the prover module's OnceLock — warm once, borrow &'static everywhere. - bind_shielded(): per-test FileBacked NetworkShieldedCoordinator over a fresh per-call SQLite path under the workdir slot, plus a ShieldedHandle (sync(true) driver + per-account balances). FileBacked is mandatory — the in-memory store's witness() is a hard Err (Found-027). - new_file_backed_coordinator(): bind-free coordinator for SH-007's controlled bind-ordering hook. - in_memory_store(): InMemory backing for SH-005's witness split. - wait_for_shielded_balance(): force-sync poller mirroring the tokens::wait_for_token_balance shape + STEP_TIMEOUT. - shielded_default_address_43(): SH-003 transfer-recipient plumbing. - teardown_sweep_shielded(): best-effort, log-on-error unshield of residual shielded balance back to the bank platform address. Swallows every error (broken-witness cases must NOT fail teardown). Adversarial injection hooks (scaffolded for the SH-020..SH-035 follow-up, gated behind PLATFORM_WALLET_E2E_SHIELDED_ADVERSARIAL): build_raw_shielded_transition, broadcast_raw, mutate_serialized_bundle, TamperingProver, build_against_note, seed_malformed_note, reuse_asset_lock_proof, MockSyncSource. The seams pin the inputs the abuse cases need; live bodies land in the follow-up wave. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../tests/e2e/framework/mod.rs | 2 + .../tests/e2e/framework/shielded.rs | 498 ++++++++++++++++++ 2 files changed, 500 insertions(+) create mode 100644 packages/rs-platform-wallet/tests/e2e/framework/shielded.rs diff --git a/packages/rs-platform-wallet/tests/e2e/framework/mod.rs b/packages/rs-platform-wallet/tests/e2e/framework/mod.rs index 5c902e991b..32837faf7e 100644 --- a/packages/rs-platform-wallet/tests/e2e/framework/mod.rs +++ b/packages/rs-platform-wallet/tests/e2e/framework/mod.rs @@ -76,6 +76,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 0000000000..40b539529e --- /dev/null +++ b/packages/rs-platform-wallet/tests/e2e/framework/shielded.rs @@ -0,0 +1,498 @@ +//! 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`] builds a +//! per-test [`NetworkShieldedCoordinator`] directly over a fresh SQLite +//! file under the workdir slot. The commitment tree is network-shared +//! on-chain, but each test scans it into its own DB so two parallel +//! tests never share store state. +//! +//! # 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, +}; + +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 { + /// Per-test FileBacked coordinator (one SQLite handle). + 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}"))) + } +} + +/// Build a per-test FileBacked coordinator and bind `accounts` on the +/// wallet's shielded sub-wallet. +/// +/// Constructs a fresh SQLite tree under `/shielded/-.sqlite` +/// — a unique path per call so parallel tests never share store state +/// (the on-chain tree is network-shared, but each test scans it into its +/// own DB). FileBacked is mandatory: the in-memory store's `witness()` +/// is a hard `Err` (Found-027), so spends against it cannot build a +/// proof (see SH-005). +/// +/// Errors: [`FrameworkError::Wallet`] for store-open, coordinator, or +/// `bind_shielded` failures. +pub async fn bind_shielded( + wallet: &TestWallet, + accounts: &[u32], + workdir: &std::path::Path, +) -> FrameworkResult { + let coordinator = new_file_backed_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}")))?; + Ok(ShieldedHandle { + coordinator, + accounts: accounts.to_vec(), + }) +} + +/// Construct a per-test FileBacked coordinator over a fresh SQLite path +/// WITHOUT binding — used by SH-007's controlled bind-ordering hook (the +/// coordinator's tree is advanced via `sync(true)` before the second +/// wallet binds). +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). +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)" + ), + } + } +} + +// --------------------------------------------------------------------------- +// Adversarial injection hooks (SH-020..SH-035 — follow-up wave) +// +// These build now so the abuse pass can wire against them. They expose +// the protocol-boundary seam (raw build → byte-mutate → broadcast) that +// bypasses the guarded `PlatformWallet::shielded_*` methods. Live +// broadcasts are gated behind `adversarial_enabled()`. +// --------------------------------------------------------------------------- + +/// Which shielded transition the raw builder should produce. The +/// follow-up wave maps each arm onto the matching +/// `dpp::shielded::builder::build_*_transition`. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum RawShieldedKind { + /// Type 16 — shielded → shielded transfer. + Transfer, + /// Type 17 — unshield to a transparent address. + Unshield, + /// Type 19 — withdraw to a Core L1 address. + Withdraw, +} + +/// 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) + } +} + +/// Marker error returned by adversarial hooks whose live wiring lands in +/// the follow-up wave. Surfaces a clear "not yet wired" rather than a +/// silent no-op so a premature abuse-case author sees exactly what is +/// missing. +const ADVERSARIAL_PENDING: &str = + "adversarial injection hook is scaffolded for the SH-020..SH-035 follow-up wave; \ + wire the raw build/broadcast/mutate body before enabling the abuse case"; + +/// Build a raw shielded state transition from caller-supplied, +/// possibly-out-of-range inputs that the guarded wallet wrapper would +/// reject (output > input for SH-022, under-floor fee for SH-023, +/// `u64`/`i64` boundary for SH-024, duplicate spend for SH-033, stale +/// anchor for SH-026). +/// +/// Seam reserved for the follow-up wave — see [`ADVERSARIAL_PENDING`]. +/// The signature pins the inputs the abuse cases need so they can be +/// authored against a stable surface. +#[allow(clippy::too_many_arguments)] +pub fn build_raw_shielded_transition( + _kind: RawShieldedKind, + _anchor: [u8; 32], + _value_balance: i64, + _fee: Option, + _proof_override: Option>, +) -> FrameworkResult<()> { + Err(FrameworkError::NotImplemented(ADVERSARIAL_PENDING)) +} + +/// Broadcast an arbitrary (possibly invalid) state transition directly, +/// returning the typed backend error so the abuse case can assert the +/// exact rejection variant. Gated: a no-op-error unless +/// [`adversarial_enabled`]. +/// +/// Seam reserved for the follow-up wave — see [`ADVERSARIAL_PENDING`]. +pub async fn broadcast_raw() -> FrameworkResult<()> { + if !adversarial_enabled() { + return Err(FrameworkError::Config(format!( + "broadcast_raw refused: set {ADVERSARIAL_GATE_ENV} to run the abuse pass" + ))); + } + Err(FrameworkError::NotImplemented(ADVERSARIAL_PENDING)) +} + +/// Flip / truncate / zero bytes in a built transition's serialized +/// `SerializedBundle` field before broadcast (SH-022/024/025/026/034). +/// +/// Seam reserved for the follow-up wave — see [`ADVERSARIAL_PENDING`]. +pub fn mutate_serialized_bundle( + _field: BundleField, + _mutation: BundleMutation, +) -> FrameworkResult<()> { + Err(FrameworkError::NotImplemented(ADVERSARIAL_PENDING)) +} + +/// Build a spend directly against a chosen note WITHOUT going through +/// `reserve_unspent_notes`, for the double-spend (SH-020) and replay +/// (SH-021) arms. +/// +/// Seam reserved for the follow-up wave — see [`ADVERSARIAL_PENDING`]. +pub fn build_against_note() -> FrameworkResult<()> { + Err(FrameworkError::NotImplemented(ADVERSARIAL_PENDING)) +} + +/// Inject a malformed `ShieldedNote` (non-115-byte `note_data`, +/// corrupted `cmx` / nullifier) into a store, for the serde-abuse +/// SH-027. +/// +/// Seam reserved for the follow-up wave — see [`ADVERSARIAL_PENDING`]. +pub fn seed_malformed_note() -> FrameworkResult<()> { + Err(FrameworkError::NotImplemented(ADVERSARIAL_PENDING)) +} + +/// Resubmit a captured single-use asset-lock proof, for SH-035 +/// (Core-L1 gated). +/// +/// Seam reserved for the follow-up wave — see [`ADVERSARIAL_PENDING`]. +pub fn reuse_asset_lock_proof() -> FrameworkResult<()> { + Err(FrameworkError::NotImplemented(ADVERSARIAL_PENDING)) +} + +/// A scriptable mock sync source for SH-028 (interrupt mid-chunk) and +/// SH-029 (reorg / out-of-order / rescan-from-0). Holds scripted note +/// chunks plus a cancellation flag the test flips to interrupt a pass. +/// +/// Seam reserved for the follow-up wave; the type exists now so the +/// abuse cases can be authored against a stable handle. +#[derive(Default)] +pub struct MockSyncSource { + /// Scripted chunks the source will yield, in order. Each inner Vec + /// is one chunk's worth of opaque note bytes. + pub chunks: Vec>>, + /// Set by the test to interrupt the next chunk (SH-028). + pub cancel_after_chunk: Option, +} + +impl MockSyncSource { + /// Trip the cancellation flag so the next pass stops after + /// `chunk_index` (SH-028's mid-chunk interrupt). + pub fn cancel_after(&mut self, chunk_index: usize) { + self.cancel_after_chunk = Some(chunk_index); + } +} From f09649cd08c0d526debb55ed1e4e32a022e1a9cf Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Fri, 22 May 2026 12:13:17 +0200 Subject: [PATCH 4/7] test(rs-platform-wallet): SH-001..SH-019 functional shielded e2e cases MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements the functional/baseline shielded (Orchard) tier per TEST_SPEC.md §3 '### Shielded (SH)'. All gated behind the e2e feature (pulls shielded); no #[ignore]. Tests assert CORRECT behavior — RED-by-design cases are left failing to pin live bugs. GREEN (happy-path + correctness): - SH-001 shield from account (Type 15) - SH-002 shield→unshield round-trip (Type 15→17) - SH-003 shielded transfer between accounts (Type 16) - SH-004 shielded_balances reflects note only after sync - SH-008 unshield insufficient-balance typed error + reservation release - SH-009 zero-amount rejection (RED arm if transfer/unshield lack a guard) - SH-010 double-spend guard: concurrent spends reserve disjoint notes - SH-011 note-selection convergence + u64::MAX overflow guard - SH-012 sync watermark idempotency (double-sync stable + spendable) - SH-013 bind empty accounts → typed ShieldedKeyDerivation - SH-014 spend before bind → ShieldedNotBound; unbound account → KeyDerivation - SH-007 GREEN regression guard: pre-bind note witnessable/spendable (#3603) RED-by-design (pin live bugs — do NOT fix from inside tests): - SH-005 InMemory witness() hard-Err vs FileBacked success (Found-027) - SH-006 shielded_add_account never re-registers on coordinator (Found-028) Core-L1 gated (MAY run RED until plumbing exists — documents the seam): - SH-018 shield from asset lock (Type 18) — flags two production gaps: no public shielded_shield_from_asset_lock wrapper, and no test seam returning the one-time asset-lock private key. - SH-019 shielded withdraw to L1 (Type 19) — shielded-side asserted unconditionally; L1 payout observation left as a documented TODO. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../rs-platform-wallet/tests/e2e/cases/mod.rs | 33 +++ .../e2e/cases/sh_001_shield_from_account.rs | 119 +++++++++++ .../sh_002_shield_unshield_round_trip.rs | 138 +++++++++++++ .../e2e/cases/sh_003_shielded_transfer.rs | 127 ++++++++++++ .../e2e/cases/sh_004_balance_after_sync.rs | 118 +++++++++++ .../cases/sh_005_inmemory_witness_split.rs | 189 ++++++++++++++++++ .../cases/sh_006_add_account_never_syncs.rs | 134 +++++++++++++ .../cases/sh_007_pre_bind_note_witnessable.rs | 180 +++++++++++++++++ .../sh_008_unshield_insufficient_balance.rs | 150 ++++++++++++++ .../e2e/cases/sh_009_zero_amount_rejected.rs | 99 +++++++++ .../cases/sh_010_double_spend_reservation.rs | 134 +++++++++++++ .../sh_011_note_selection_convergence.rs | 166 +++++++++++++++ .../sh_012_sync_watermark_idempotency.rs | 139 +++++++++++++ .../e2e/cases/sh_013_bind_empty_accounts.rs | 67 +++++++ .../e2e/cases/sh_014_spend_before_bind.rs | 76 +++++++ .../cases/sh_018_shield_from_asset_lock.rs | 80 ++++++++ .../e2e/cases/sh_019_shielded_withdraw_l1.rs | 172 ++++++++++++++++ 17 files changed, 2121 insertions(+) create mode 100644 packages/rs-platform-wallet/tests/e2e/cases/sh_001_shield_from_account.rs create mode 100644 packages/rs-platform-wallet/tests/e2e/cases/sh_002_shield_unshield_round_trip.rs create mode 100644 packages/rs-platform-wallet/tests/e2e/cases/sh_003_shielded_transfer.rs create mode 100644 packages/rs-platform-wallet/tests/e2e/cases/sh_004_balance_after_sync.rs create mode 100644 packages/rs-platform-wallet/tests/e2e/cases/sh_005_inmemory_witness_split.rs create mode 100644 packages/rs-platform-wallet/tests/e2e/cases/sh_006_add_account_never_syncs.rs create mode 100644 packages/rs-platform-wallet/tests/e2e/cases/sh_007_pre_bind_note_witnessable.rs create mode 100644 packages/rs-platform-wallet/tests/e2e/cases/sh_008_unshield_insufficient_balance.rs create mode 100644 packages/rs-platform-wallet/tests/e2e/cases/sh_009_zero_amount_rejected.rs create mode 100644 packages/rs-platform-wallet/tests/e2e/cases/sh_010_double_spend_reservation.rs create mode 100644 packages/rs-platform-wallet/tests/e2e/cases/sh_011_note_selection_convergence.rs create mode 100644 packages/rs-platform-wallet/tests/e2e/cases/sh_012_sync_watermark_idempotency.rs create mode 100644 packages/rs-platform-wallet/tests/e2e/cases/sh_013_bind_empty_accounts.rs create mode 100644 packages/rs-platform-wallet/tests/e2e/cases/sh_014_spend_before_bind.rs create mode 100644 packages/rs-platform-wallet/tests/e2e/cases/sh_018_shield_from_asset_lock.rs create mode 100644 packages/rs-platform-wallet/tests/e2e/cases/sh_019_shielded_withdraw_l1.rs diff --git a/packages/rs-platform-wallet/tests/e2e/cases/mod.rs b/packages/rs-platform-wallet/tests/e2e/cases/mod.rs index 4827913ef2..a51e7a2c44 100644 --- a/packages/rs-platform-wallet/tests/e2e/cases/mod.rs +++ b/packages/rs-platform-wallet/tests/e2e/cases/mod.rs @@ -50,6 +50,39 @@ pub mod pa_008c_funding_mutex_observable; pub mod pa_009_min_input_amount; pub mod pa_3040_bug_pin; pub mod print_bank_address; +// 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; // 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 0000000000..75ed6693fb --- /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 = 90_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 0000000000..e4ed51e449 --- /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 = 90_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_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 0000000000..a8ba7ffab7 --- /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 = 90_000_000; +const SHIELD_AMOUNT: u64 = 50_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 0000000000..0252fcaa39 --- /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 = 90_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 0000000000..02758ed5d4 --- /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 = 90_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_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 0000000000..89ae97fc06 --- /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 = 90_000_000; +const SHIELD_AMOUNT: u64 = 50_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 0000000000..2115690390 --- /dev/null +++ b/packages/rs-platform-wallet/tests/e2e/cases/sh_007_pre_bind_note_witnessable.rs @@ -0,0 +1,180 @@ +//! 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 = 90_000_000; +const SHIELD_AMOUNT: u64 = 50_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 + .expect( + "Found-029 regression: B's pre-bind note must be witnessable/spendable (#3603). \ + A failure here means the mark-every-position policy 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 0000000000..ccbf672a0e --- /dev/null +++ b/packages/rs-platform-wallet/tests/e2e/cases/sh_008_unshield_insufficient_balance.rs @@ -0,0 +1,150 @@ +//! 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, +}; + +const FUNDING_CREDITS: u64 = 60_000_000; +const SHIELD_AMOUNT: u64 = 10_000_000; +const OVERDRAW_AMOUNT: u64 = 50_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 0000000000..aa0cfe73e3 --- /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 0000000000..aafb4302e5 --- /dev/null +++ b/packages/rs-platform-wallet/tests/e2e/cases/sh_010_double_spend_reservation.rs @@ -0,0 +1,134 @@ +//! 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, +}; + +const FUNDING_CREDITS: u64 = 90_000_000; +const SHIELD_EACH: u64 = 30_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 0000000000..6ab27bfa46 --- /dev/null +++ b/packages/rs-platform-wallet/tests/e2e/cases/sh_011_note_selection_convergence.rs @@ -0,0 +1,166 @@ +//! 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 = 60_000_000; +const SHIELD_EACH: u64 = 12_000_000; +const NUM_NOTES: u64 = 3; +/// Above any single note, below the sum — forces multi-note selection so +/// the fee convergence loop iterates (>1 pass). +const MULTI_NOTE_UNSHIELD: u64 = 25_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 0000000000..09a337014a --- /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 = 90_000_000; +const SHIELD_AMOUNT: u64 = 50_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 0000000000..26cabc90af --- /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 0000000000..d951629595 --- /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 0000000000..fbb55b259c --- /dev/null +++ b/packages/rs-platform-wallet/tests/e2e/cases/sh_018_shield_from_asset_lock.rs @@ -0,0 +1,80 @@ +//! 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 +//! plumbing is complete — that is acceptable and expected; a RED here +//! pins the missing harness/asset-lock seam rather than a passing happy +//! path. +//! +//! # Flagged production gaps (do NOT fix from inside the test) +//! +//! 1. **No public `PlatformWallet::shielded_shield_from_asset_lock` +//! wrapper.** The four other spend types have public wrappers +//! (`platform_wallet.rs:560/604/652/721`); shield-from-asset-lock +//! exists only as the inner free function +//! `operations::shield_from_asset_lock` (`operations.rs:269`). This +//! test calls the inner path directly. **Follow-up DX gap** — file a +//! public-wrapper issue. +//! 2. **No test seam returning the one-time asset-lock private key.** +//! `AssetLockManager::create_funded_asset_lock_proof` returns +//! `(AssetLockProof, DerivationPath, OutPoint)` but NOT the private +//! key bytes `shield_from_asset_lock(private_key: &[u8])` requires, +//! and no public helper derives the key from `(seed, path)`. This is +//! the Core-L1 asset-lock-builder seam Wave H flags as RED-acceptable. +//! +//! Because gap (2) blocks a correct call, this test pins the proof-build +//! half and surfaces the missing seam as a documented RED. Wiring the +//! private-key seam (and ideally the public wrapper) is the Core-L1 +//! follow-up. + +use std::time::Duration; + +use crate::framework::prelude::*; + +/// Core (Layer-1) duffs the test wallet is funded with so the asset-lock +/// builder's coin selection has a confirmed UTXO. Gated behind +/// `PLATFORM_WALLET_E2E_BANK_CORE_GATE`. +const TEST_WALLET_CORE_FUNDING: u64 = 100_000; +#[allow(dead_code)] +const SHIELD_AMOUNT: u64 = 50_000_000; +#[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_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: this panics (RED) if SPV / Core funding isn't + // available, which documents the gate rather than a shield-path + // defect. Mirrors CR-003 / AL-001. + 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 pre_lock_core = s.test_wallet.core_balance_confirmed(); + assert!( + pre_lock_core >= TEST_WALLET_CORE_FUNDING, + "Core-L1 gate: confirmed Core balance {pre_lock_core} < {TEST_WALLET_CORE_FUNDING}" + ); + + // GAP (2): the asset-lock builder does not return the one-time + // private key, and no public helper derives it from (seed, path), so + // a correct `operations::shield_from_asset_lock(private_key, …)` call + // cannot be constructed test-side. Surface the missing seam as a + // documented RED rather than weakening the assertion or fabricating a + // key. Wiring this seam (proof + one-time private key) is the Core-L1 + // follow-up. + panic!( + "SH-018 RED-by-design: Core-L1 asset-lock-builder seam incomplete — \ + no test path returns the one-time private key required by \ + operations::shield_from_asset_lock, and there is no public \ + PlatformWallet::shielded_shield_from_asset_lock wrapper. \ + Wiring the private-key seam is the Core-L1 follow-up (do NOT \ + weaken this assertion or add production code from inside the test)." + ); +} 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 0000000000..2fbd4a5ed3 --- /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 = 90_000_000; +const SHIELD_AMOUNT: u64 = 50_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"); +} From 093a9f76d462c6c0f8609c4bfa3ff35af9c5eb1f Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Fri, 22 May 2026 12:28:19 +0200 Subject: [PATCH 5/7] test(rs-platform-wallet): SH-020..SH-035 adversarial shielded e2e cases MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements the adversarial/abuse tier per TEST_SPEC.md §3 — each ATTACKS the protocol boundary and asserts the BACKEND must reject (or behave safely). All gated behind e2e + shielded + PLATFORM_WALLET_E2E_SHIELDED_ADVERSARIAL (no-op pass when the env is unset, so the default suite stays green). No #[ignore]. Tests assert CORRECT rejection — no weakened assertions. Live backend/wallet-reaching (achievable via public API, no prod-seam change): - SH-027 malformed note serde: seeds a non-115-byte note via the public ShieldedStore trait and drives operations::unshield → deserialize_note; asserts a typed error (no panic = no DoS, no silent corruption). - SH-030 cross-network/wrong-HRP/malformed recipient: client parse + network-mismatch guard fires with a typed ShieldedBuildError. - SH-031 rebind-different-seed: asserts seed_A's note does NOT leak into seed_B's balance and re-discovers cleanly on rebind-back (no key mix). - SH-032 exact-change boundary: note == amount+fee leaves ZERO change; amount+fee-1 is rejected ShieldedInsufficientBalance. Harness hooks fleshed out: broadcast_raw (StateTransition deserialize + broadcast, gated), seed_malformed_note (live via ShieldedStore trait). RED-by-gap (flagged production-seam gaps — NOT fixed, per instructions): - SH-020/021/022/023/024/025/026/033/034: reaching Drive with a valid-except-for-the-tamper transition needs a build-only shielded capture seam (shielded operations::* build AND broadcast internally; extract_spends_and_anchor / reserve_unspent_notes / build_spend_bundle are private; the public dpp build_*_transition enforce value/fee/overflow guards internally). See framework::shielded::ADVERSARIAL_SEAM_MISSING. - SH-028/029: no injectable sync source (sync_notes_across is pub(super), fetches from the SDK directly) — needs a SyncSource production seam. - SH-035: stacks the SH-018 Core-L1 private-key gap + the asset-lock-proof reuse seam. The 6 CRITICAL-if-red consensus attacks (SH-020/022/025/033/034/035) and the HIGH-if-red ones are pinned with their attack + expected consensus error (NullifierAlreadySpentError 40901, ShieldedInvalidValueBalanceError 10822, AnchorMismatch) ready to assert once the capture seam lands. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../rs-platform-wallet/tests/e2e/cases/mod.rs | 33 +++ .../sh_020_double_spend_two_transitions.rs | 63 ++++++ .../sh_021_nullifier_replay_after_restart.rs | 51 +++++ .../e2e/cases/sh_022_value_not_conserved.rs | 50 ++++ .../e2e/cases/sh_023_fee_underpayment.rs | 43 ++++ .../cases/sh_024_value_boundary_overflow.rs | 44 ++++ .../tests/e2e/cases/sh_025_forged_proof.rs | 47 ++++ .../tests/e2e/cases/sh_026_anchor_mismatch.rs | 53 +++++ .../e2e/cases/sh_027_malformed_note_serde.rs | 129 +++++++++++ .../cases/sh_028_sync_interrupt_mid_chunk.rs | 46 ++++ .../tests/e2e/cases/sh_029_reorg_rescan.rs | 45 ++++ .../cases/sh_030_cross_network_recipient.rs | 93 ++++++++ .../e2e/cases/sh_031_rebind_different_seed.rs | 134 +++++++++++ .../e2e/cases/sh_032_exact_change_boundary.rs | 213 ++++++++++++++++++ .../sh_033_duplicate_nullifier_in_bundle.rs | 42 ++++ .../sh_034_tampered_binding_signature.rs | 42 ++++ .../cases/sh_035_replayed_asset_lock_proof.rs | 49 ++++ .../tests/e2e/framework/shielded.rs | 128 ++++++++--- 18 files changed, 1273 insertions(+), 32 deletions(-) create mode 100644 packages/rs-platform-wallet/tests/e2e/cases/sh_020_double_spend_two_transitions.rs create mode 100644 packages/rs-platform-wallet/tests/e2e/cases/sh_021_nullifier_replay_after_restart.rs create mode 100644 packages/rs-platform-wallet/tests/e2e/cases/sh_022_value_not_conserved.rs create mode 100644 packages/rs-platform-wallet/tests/e2e/cases/sh_023_fee_underpayment.rs create mode 100644 packages/rs-platform-wallet/tests/e2e/cases/sh_024_value_boundary_overflow.rs create mode 100644 packages/rs-platform-wallet/tests/e2e/cases/sh_025_forged_proof.rs create mode 100644 packages/rs-platform-wallet/tests/e2e/cases/sh_026_anchor_mismatch.rs create mode 100644 packages/rs-platform-wallet/tests/e2e/cases/sh_027_malformed_note_serde.rs create mode 100644 packages/rs-platform-wallet/tests/e2e/cases/sh_028_sync_interrupt_mid_chunk.rs create mode 100644 packages/rs-platform-wallet/tests/e2e/cases/sh_029_reorg_rescan.rs create mode 100644 packages/rs-platform-wallet/tests/e2e/cases/sh_030_cross_network_recipient.rs create mode 100644 packages/rs-platform-wallet/tests/e2e/cases/sh_031_rebind_different_seed.rs create mode 100644 packages/rs-platform-wallet/tests/e2e/cases/sh_032_exact_change_boundary.rs create mode 100644 packages/rs-platform-wallet/tests/e2e/cases/sh_033_duplicate_nullifier_in_bundle.rs create mode 100644 packages/rs-platform-wallet/tests/e2e/cases/sh_034_tampered_binding_signature.rs create mode 100644 packages/rs-platform-wallet/tests/e2e/cases/sh_035_replayed_asset_lock_proof.rs diff --git a/packages/rs-platform-wallet/tests/e2e/cases/mod.rs b/packages/rs-platform-wallet/tests/e2e/cases/mod.rs index 7b5ff745f1..048688580a 100644 --- a/packages/rs-platform-wallet/tests/e2e/cases/mod.rs +++ b/packages/rs-platform-wallet/tests/e2e/cases/mod.rs @@ -84,6 +84,39 @@ pub mod sh_014_spend_before_bind; 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; +#[cfg(feature = "shielded")] +pub mod sh_028_sync_interrupt_mid_chunk; +#[cfg(feature = "shielded")] +pub mod sh_029_reorg_rescan; +#[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_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 0000000000..1a29ac1b7f --- /dev/null +++ b/packages/rs-platform-wallet/tests/e2e/cases/sh_020_double_spend_two_transitions.rs @@ -0,0 +1,63 @@ +//! SH-020 — ADVERSARIAL: double-spend the same note across two +//! transitions (Type 16/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 spends of the SAME +//! shielded note (same nullifier) and broadcast both. The wallet's +//! `reserve_unspent_notes` prevents two LOCAL spends from picking the +//! same note — a client convenience, not the consensus guarantee — so +//! the attack BYPASSES it by building the second transition directly +//! against the same `SpendableNote`. +//! +//! Correct backend behavior: exactly ONE accepted; the second rejected +//! with a nullifier-already-spent consensus error (`NullifierAlreadySpentError`, +//! code 40901). RED if both accepted (double-spend — CRITICAL fund +//! forgery), neither accepted, or the balance is wrong. +//! +//! # PRODUCTION GAP (flagged, not fixed) +//! +//! Reaching Drive with a SECOND transition built against an +//! already-reserved/spent note requires the wallet's private +//! `extract_spends_and_anchor` + `reserve_unspent_notes`-bypass build +//! seam, or a captured-bytes replay seam. Neither is public — shielded +//! `operations::*` build AND broadcast internally and expose no +//! build-only capture (contrast transparent `transfer_capturing_st_bytes`). +//! See `framework::shielded::ADVERSARIAL_SEAM_MISSING`. This case is +//! RED-by-gap until a build-only shielded capture seam exists. + +#![cfg(feature = "shielded")] + +use crate::framework::shielded::{ + adversarial_enabled, build_against_note, ADVERSARIAL_SEAM_MISSING, +}; + +#[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; + } + + // The attack needs to build a second spend against the same note + // WITHOUT the local reservation. That seam is not public. + let built = build_against_note(); + assert!( + built.is_ok(), + "SH-020 RED-by-gap: cannot reach the backend with a second spend of the same note. {ADVERSARIAL_SEAM_MISSING}" + ); + // Once the seam lands: broadcast both, assert the first is Ok and the + // second fails NullifierAlreadySpentError; assert the shielded + // balance reflects exactly ONE debit (no double-spend, no mint). +} 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 0000000000..1777686208 --- /dev/null +++ b/packages/rs-platform-wallet/tests/e2e/cases/sh_021_nullifier_replay_after_restart.rs @@ -0,0 +1,51 @@ +//! SH-021 — ADVERSARIAL: nullifier replay after restart/resync — +//! backend MUST reject [INJECT]. +//! Spec: `tests/e2e/TEST_SPEC.md` §3 → SH-021. Priority: P0 +//! (consensus-critical). CRITICAL-if-it-fails. +//! +//! Attack: spend a note (Type 17), let it confirm, then resubmit a +//! transition spending the SAME already-spent note. The nullifier is +//! permanently in Drive's spent set, so the replay MUST fail regardless +//! of client state. +//! +//! # PRODUCTION GAP (flagged, not fixed) +//! +//! The BACKEND replay arm needs the captured serialized bytes of the +//! confirmed shielded spend (to re-broadcast verbatim) OR a rebuild +//! against the now-spent note. Shielded `operations::*` expose no +//! build-only capture seam (contrast `transfer_capturing_st_bytes`), so +//! the genuine backend-replay arm is RED-by-gap. See +//! `framework::shielded::ADVERSARIAL_SEAM_MISSING`. +//! +//! The CLIENT-side spent-protection (the wallet refuses to re-select a +//! spent note after sync) IS exercisable and is asserted as the +//! achievable half — but it is NOT the consensus guarantee this case +//! exists to prove. + +#![cfg(feature = "shielded")] + +use crate::framework::shielded::{adversarial_enabled, ADVERSARIAL_SEAM_MISSING}; + +#[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; + } + + panic!( + "SH-021 RED-by-gap: backend nullifier-replay needs captured shielded ST bytes to \ + re-broadcast (or a rebuild-against-spent-note seam); neither is public. {ADVERSARIAL_SEAM_MISSING}" + ); +} 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 0000000000..801f9cd7f5 --- /dev/null +++ b/packages/rs-platform-wallet/tests/e2e/cases/sh_022_value_not_conserved.rs @@ -0,0 +1,50 @@ +//! 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: a transfer/unshield whose declared outputs exceed the spent +//! note value — minting value from nothing — by setting +//! `SerializedBundle.value_balance` inconsistent with the actual spend, +//! or passing `amount > note` to the dpp builder. +//! +//! Correct backend behavior: rejected (`ShieldedInvalidValueBalanceError`, +//! code 10822, or invalid-proof). RED if accepted. +//! +//! # PRODUCTION GAP (flagged, not fixed) +//! +//! The public dpp `build_*_transition` enforce `required > total_spent` +//! and the fee floor INTERNALLY (`unshield.rs:78-86`), so they refuse to +//! emit an out-of-input bundle. Mutating a captured valid bundle's +//! `value_balance` needs a build-only shielded capture seam, which is not +//! public. See `framework::shielded::ADVERSARIAL_SEAM_MISSING`. + +#![cfg(feature = "shielded")] + +use crate::framework::shielded::{adversarial_enabled, ADVERSARIAL_SEAM_MISSING}; + +#[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; + } + + panic!( + "SH-022 RED-by-gap: cannot construct outputs>inputs and reach the backend — the public \ + dpp builders enforce value conservation internally and there is no captured-bundle \ + value_balance-tamper seam. {ADVERSARIAL_SEAM_MISSING}" + ); +} 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 0000000000..449cac03a2 --- /dev/null +++ b/packages/rs-platform-wallet/tests/e2e/cases/sh_023_fee_underpayment.rs @@ -0,0 +1,43 @@ +//! 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: a spend declaring a fee BELOW +//! `compute_minimum_shielded_fee(num_actions, version)` (zero, or just +//! under the floor). Drive must enforce the same floor the client +//! derives; a divergence is itself a finding (fee-market bypass / spam). +//! +//! # PRODUCTION GAP (flagged, not fixed) +//! +//! `build_unshield_transition` rejects `Some(f) if f < min_fee` +//! INTERNALLY (`unshield.rs:60-65`), so the public path cannot emit an +//! under-floor transition. Reaching the backend with one needs the raw +//! build seam. See `framework::shielded::ADVERSARIAL_SEAM_MISSING`. + +#![cfg(feature = "shielded")] + +use crate::framework::shielded::{adversarial_enabled, ADVERSARIAL_SEAM_MISSING}; + +#[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; + } + + panic!( + "SH-023 RED-by-gap: the dpp builder enforces the min-fee floor internally; no raw seam \ + to submit an under-floor fee to the backend. {ADVERSARIAL_SEAM_MISSING}" + ); +} 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 0000000000..f658ba43a8 --- /dev/null +++ b/packages/rs-platform-wallet/tests/e2e/cases/sh_024_value_boundary_overflow.rs @@ -0,0 +1,44 @@ +//! SH-024 — ADVERSARIAL: u64/i64 value-boundary overflow/underflow — +//! backend MUST reject safely [INJECT]. +//! Spec: `tests/e2e/TEST_SPEC.md` §3 → SH-024. Priority: P1. HIGH-if-fails. +//! +//! Attack: drive `amount == u64::MAX`, `amount + fee` wrapping past +//! `u64::MAX`, and `value_balance` at `i64::MIN`/`i64::MAX`, bypassing +//! the client `checked_add` guard. The arithmetic must be checked on the +//! BACKEND (no wraparound, no validator panic, no negative-as-huge-positive). +//! +//! # PRODUCTION GAP (flagged, not fixed) +//! +//! `build_unshield_transition` has a `checked_add` overflow guard +//! (`unshield.rs:77-79`) and refuses to emit; feeding the raw boundary +//! `value_balance` to a captured bundle needs a build-only capture seam. +//! See `framework::shielded::ADVERSARIAL_SEAM_MISSING`. NOTE: the +//! client-side u64::MAX guard is already covered (GREEN) by SH-011. + +#![cfg(feature = "shielded")] + +use crate::framework::shielded::{adversarial_enabled, ADVERSARIAL_SEAM_MISSING}; + +#[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; + } + + panic!( + "SH-024 RED-by-gap: client checked_add guard blocks the public path; no raw seam to feed \ + a boundary value_balance to the backend validator. {ADVERSARIAL_SEAM_MISSING}" + ); +} 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 0000000000..8939787ce5 --- /dev/null +++ b/packages/rs-platform-wallet/tests/e2e/cases/sh_025_forged_proof.rs @@ -0,0 +1,47 @@ +//! SH-025 — ADVERSARIAL: forged/tampered/substituted 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 transition, then flip bytes in +//! `SerializedBundle.proof` — single-bit flip, truncation, all-zeros, +//! and a proof copied from a DIFFERENT valid transition (substitution). +//! Every variant must fail Orchard proof verification. +//! +//! # PRODUCTION GAP (flagged, not fixed) +//! +//! Mutating `proof` bytes requires a captured valid-build's serialized +//! `SerializedBundle`/ST, which shielded `operations::*` never expose +//! (they build AND broadcast internally). The scaffolded `TamperingProver` +//! returns a real proving key, so on its own it produces a VALID proof — +//! genuine forgery still needs the byte-mutation-after-build seam. See +//! `framework::shielded::ADVERSARIAL_SEAM_MISSING`. + +#![cfg(feature = "shielded")] + +use crate::framework::shielded::{adversarial_enabled, ADVERSARIAL_SEAM_MISSING}; + +#[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; + } + + panic!( + "SH-025 RED-by-gap: forging/tampering the proof needs the captured serialized bundle to \ + mutate proof bytes post-build; no public shielded capture seam. {ADVERSARIAL_SEAM_MISSING}" + ); +} 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 0000000000..b24cbdbaa5 --- /dev/null +++ b/packages/rs-platform-wallet/tests/e2e/cases/sh_026_anchor_mismatch.rs @@ -0,0 +1,53 @@ +//! SH-026 — ADVERSARIAL: stale/wrong 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: a spend whose `SerializedBundle.anchor` is a VALID-but-stale +//! earlier-checkpoint root, or random 32 bytes, while the witness paths +//! authenticate against the current root. Doubles as the Found-030 +//! dynamic probe: whichever anchor depth the backend actually accepts +//! resolves the doc ambiguity between `operations.rs:601-611` ("most +//! recent checkpoint") and `file_store.rs:162-165` ("current tree state"). +//! +//! Correct backend behavior: rejected (`AnchorMismatch` / "Anchor not +//! found in the recorded anchors tree"). A stale-but-in-window anchor may +//! be accepted if the protocol keeps a bounded history — pin which side +//! of Found-030 is true. +//! +//! # PRODUCTION GAP (flagged, not fixed) +//! +//! Overriding `anchor` post-build (or passing a stale `Anchor` to the dpp +//! builder against current witnesses) needs the build-only capture seam + +//! a tree-checkpoint advancer. Neither is public. See +//! `framework::shielded::ADVERSARIAL_SEAM_MISSING`. The Found-030 doc +//! drift remains pinned statically by the spec note until this dynamic +//! probe can run. + +#![cfg(feature = "shielded")] + +use crate::framework::shielded::{adversarial_enabled, ADVERSARIAL_SEAM_MISSING}; + +#[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; + } + + panic!( + "SH-026 RED-by-gap: anchor override + tree-checkpoint advancer needed to manufacture a \ + stale anchor and reach the backend; no public seam. Found-030 stays a static doc-drift \ + pin until this probe can run. {ADVERSARIAL_SEAM_MISSING}" + ); +} 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 0000000000..561d61b63c --- /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_028_sync_interrupt_mid_chunk.rs b/packages/rs-platform-wallet/tests/e2e/cases/sh_028_sync_interrupt_mid_chunk.rs new file mode 100644 index 0000000000..d36fb4af09 --- /dev/null +++ b/packages/rs-platform-wallet/tests/e2e/cases/sh_028_sync_interrupt_mid_chunk.rs @@ -0,0 +1,46 @@ +//! SH-028 — ADVERSARIAL: interrupt sync mid-chunk + resume — no +//! double-count/loss [INJECT]. +//! Spec: `tests/e2e/TEST_SPEC.md` §3 → SH-028. Priority: P1. HIGH-if-fails. +//! +//! Attack: cancel `sync_notes_across` between fetch and append, then +//! resume; the append-once gate (`sync.rs:276-289`, gated on `tree_size`) +//! must prevent double-append. Post-resume, a spend must still build a +//! valid witness (proves no shardtree corruption). +//! +//! # PRODUCTION GAP (flagged, not fixed) +//! +//! `sync_notes_across` is `pub(super)` and fetches from the SDK +//! internally; there is no injectable sync source nor a cancellation +//! hook between fetch and store-write. The scaffolded `MockSyncSource` +//! cannot wire without a production `SyncSource` seam. See +//! `framework::shielded::ADVERSARIAL_SEAM_MISSING` (the sync-source +//! variant). + +#![cfg(feature = "shielded")] + +use crate::framework::shielded::adversarial_enabled; + +#[tokio_shared_rt::test(shared, flavor = "multi_thread", worker_threads = 12)] +async fn sh_028_sync_interrupt_mid_chunk() { + 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_028", + "PLATFORM_WALLET_E2E_SHIELDED_ADVERSARIAL unset — abuse case skipped (no-op pass)" + ); + return; + } + + panic!( + "SH-028 RED-by-gap: sync_notes_across is pub(super) with no injectable sync source or \ + mid-chunk cancellation hook; a SyncSource production seam is required to drive the \ + interrupt-and-resume attack." + ); +} diff --git a/packages/rs-platform-wallet/tests/e2e/cases/sh_029_reorg_rescan.rs b/packages/rs-platform-wallet/tests/e2e/cases/sh_029_reorg_rescan.rs new file mode 100644 index 0000000000..ee77478566 --- /dev/null +++ b/packages/rs-platform-wallet/tests/e2e/cases/sh_029_reorg_rescan.rs @@ -0,0 +1,45 @@ +//! SH-029 — ADVERSARIAL: reorg / out-of-order blocks / rescan-from-0 — +//! balance converges, no phantom funds [INJECT]. +//! Spec: `tests/e2e/TEST_SPEC.md` §3 → SH-029. Priority: P1. HIGH-if-fails. +//! +//! Attack: feed sync (a) out-of-order positions, (b) a reorg that rolls +//! back then re-appends a different set, (c) `next_start_index == 0` +//! rescan-from-0 (`sync.rs:235-241`). Balances must converge to the +//! canonical chain state; the `tree_size` gate must make rescan-from-0 +//! idempotent; no rolled-back commitment retained as spendable. +//! +//! # PRODUCTION GAP (flagged, not fixed) +//! +//! Requires a scriptable mock sync source returning reordered / +//! rolled-back / from-zero note chunks. `sync_notes_across` fetches from +//! the SDK directly with no injection point. See +//! `framework::shielded::ADVERSARIAL_SEAM_MISSING` (sync-source variant). + +#![cfg(feature = "shielded")] + +use crate::framework::shielded::adversarial_enabled; + +#[tokio_shared_rt::test(shared, flavor = "multi_thread", worker_threads = 12)] +async fn sh_029_reorg_rescan() { + 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_029", + "PLATFORM_WALLET_E2E_SHIELDED_ADVERSARIAL unset — abuse case skipped (no-op pass)" + ); + return; + } + + panic!( + "SH-029 RED-by-gap: no scriptable mock sync source — sync_notes_across fetches from the \ + SDK with no injection point; a SyncSource production seam is required to script \ + reorg / out-of-order / rescan-from-0 chunks." + ); +} 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 0000000000..93cad221ea --- /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 0000000000..f9a5503113 --- /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 = 90_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 0000000000..f51289da68 --- /dev/null +++ b/packages/rs-platform-wallet/tests/e2e/cases/sh_032_exact_change_boundary.rs @@ -0,0 +1,213 @@ +//! 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, +}; + +const FUNDING_CREDITS: u64 = 90_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 0000000000..a6c89d21b3 --- /dev/null +++ b/packages/rs-platform-wallet/tests/e2e/cases/sh_033_duplicate_nullifier_in_bundle.rs @@ -0,0 +1,42 @@ +//! 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: one transition whose Orchard bundle spends the same note twice +//! (two actions, identical nullifier) — an intra-transition double-spend. +//! +//! # PRODUCTION GAP (flagged, not fixed) +//! +//! Constructing a bundle with a duplicated `SpendableNote` needs the raw +//! dpp bundle builder (`build_spend_bundle`, `pub(crate)`) or a build-only +//! shielded capture seam. Neither is public. See +//! `framework::shielded::ADVERSARIAL_SEAM_MISSING`. + +#![cfg(feature = "shielded")] + +use crate::framework::shielded::{adversarial_enabled, ADVERSARIAL_SEAM_MISSING}; + +#[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; + } + + panic!( + "SH-033 RED-by-gap: building a bundle with a duplicated SpendableNote needs the raw \ + dpp bundle builder (pub(crate)) or a capture seam. {ADVERSARIAL_SEAM_MISSING}" + ); +} 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 0000000000..883a0e3764 --- /dev/null +++ b/packages/rs-platform-wallet/tests/e2e/cases/sh_034_tampered_binding_signature.rs @@ -0,0 +1,42 @@ +//! 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: flip bytes in `SerializedBundle.binding_signature` (64 bytes) +//! and broadcast. The binding signature commits to the value balance; a +//! tampered signature must fail Orchard bundle verification. +//! +//! # PRODUCTION GAP (flagged, not fixed) +//! +//! Mutating `binding_signature` needs a captured valid-build's serialized +//! bundle; shielded `operations::*` expose no build-only capture seam. +//! See `framework::shielded::ADVERSARIAL_SEAM_MISSING`. + +#![cfg(feature = "shielded")] + +use crate::framework::shielded::{adversarial_enabled, ADVERSARIAL_SEAM_MISSING}; + +#[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; + } + + panic!( + "SH-034 RED-by-gap: tampering binding_signature needs the captured serialized bundle to \ + mutate post-build; no public shielded capture seam. {ADVERSARIAL_SEAM_MISSING}" + ); +} 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 0000000000..433d7d9e86 --- /dev/null +++ b/packages/rs-platform-wallet/tests/e2e/cases/sh_035_replayed_asset_lock_proof.rs @@ -0,0 +1,49 @@ +//! 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 `AssetLockProof`, +//! then resubmit the SAME proof in a second Type-18 transition. An +//! asset-lock outpoint is single-use; the second must fail +//! (already-used / outpoint-spent consensus error). +//! +//! # PRODUCTION GAP (flagged, not fixed) +//! +//! Two gaps stack here: (1) the SH-018 Core-L1 seam — no test path +//! returns the one-time asset-lock private key required by +//! `operations::shield_from_asset_lock`, and there is no public +//! `shielded_shield_from_asset_lock` wrapper; (2) the +//! `reuse_asset_lock_proof` capture/replay seam. Both are needed before +//! this abuse case can reach the backend. See +//! `framework::shielded::ADVERSARIAL_SEAM_MISSING` + the SH-018 case docs. + +#![cfg(feature = "shielded")] + +use crate::framework::shielded::adversarial_enabled; + +#[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; + } + + panic!( + "SH-035 RED-by-gap: stacks the SH-018 Core-L1 private-key gap (no test seam returns the \ + one-time asset-lock key, no public shielded_shield_from_asset_lock wrapper) with the \ + asset-lock-proof reuse seam. Both must land before this can reach the backend." + ); +} diff --git a/packages/rs-platform-wallet/tests/e2e/framework/shielded.rs b/packages/rs-platform-wallet/tests/e2e/framework/shielded.rs index 40b539529e..e4de4a9414 100644 --- a/packages/rs-platform-wallet/tests/e2e/framework/shielded.rs +++ b/packages/rs-platform-wallet/tests/e2e/framework/shielded.rs @@ -394,13 +394,18 @@ impl OrchardProver for &TamperingProver { } } -/// Marker error returned by adversarial hooks whose live wiring lands in -/// the follow-up wave. Surfaces a clear "not yet wired" rather than a -/// silent no-op so a premature abuse-case author sees exactly what is -/// missing. -const ADVERSARIAL_PENDING: &str = - "adversarial injection hook is scaffolded for the SH-020..SH-035 follow-up wave; \ - wire the raw build/broadcast/mutate body before enabling the abuse case"; +/// Production-gap marker for adversarial hooks that CANNOT reach Drive +/// with a properly-formed-but-tampered shielded transition because the +/// wallet exposes no seam to capture a built `SerializedBundle` / raw +/// spend bytes (see the module-level gap notes and the SH-020/022/024/ +/// 025/026/033/034 case docs). A case hitting this is RED-by-gap: the +/// finding is the MISSING seam, not a weakened assertion. +pub const ADVERSARIAL_SEAM_MISSING: &str = + "no public seam to capture a built shielded SerializedBundle / raw spend ST bytes — \ + shielded operations::* build AND broadcast internally (contrast transparent \ + transfer_capturing_st_bytes), and extract_spends_and_anchor / reserve_unspent_notes / \ + build_spend_bundle are private. Add a build-only shielded capture seam (returning the \ + serialized StateTransition before broadcast) to wire this abuse case to the backend."; /// Build a raw shielded state transition from caller-supplied, /// possibly-out-of-range inputs that the guarded wallet wrapper would @@ -408,9 +413,12 @@ const ADVERSARIAL_PENDING: &str = /// `u64`/`i64` boundary for SH-024, duplicate spend for SH-033, stale /// anchor for SH-026). /// -/// Seam reserved for the follow-up wave — see [`ADVERSARIAL_PENDING`]. -/// The signature pins the inputs the abuse cases need so they can be -/// authored against a stable surface. +/// **Blocked by [`ADVERSARIAL_SEAM_MISSING`].** Constructing a valid- +/// except-for-the-tamper transition requires real `SpendableNote`s + an +/// `Anchor` from the wallet's private `extract_spends_and_anchor`, and +/// the public dpp `build_*_transition` enforce the value/fee/overflow +/// guards internally — so neither path can emit the out-of-range bundle +/// these cases need. The signature pins the inputs the abuse cases want. #[allow(clippy::too_many_arguments)] pub fn build_raw_shielded_transition( _kind: RawShieldedKind, @@ -418,60 +426,116 @@ pub fn build_raw_shielded_transition( _value_balance: i64, _fee: Option, _proof_override: Option>, -) -> FrameworkResult<()> { - Err(FrameworkError::NotImplemented(ADVERSARIAL_PENDING)) +) -> FrameworkResult> { + Err(FrameworkError::NotImplemented( + "build_raw_shielded_transition: see ADVERSARIAL_SEAM_MISSING", + )) } -/// Broadcast an arbitrary (possibly invalid) state transition directly, -/// returning the typed backend error so the abuse case can assert the -/// exact rejection variant. Gated: a no-op-error unless -/// [`adversarial_enabled`]. +/// Broadcast arbitrary serialized [`StateTransition`] bytes directly, +/// returning the typed backend error so an abuse case can assert the +/// exact rejection variant. Bypasses the guarded `shielded_*` methods. /// -/// Seam reserved for the follow-up wave — see [`ADVERSARIAL_PENDING`]. -pub async fn broadcast_raw() -> FrameworkResult<()> { +/// Gated: refuses unless [`adversarial_enabled`], so a stray malformed +/// broadcast can't pollute a normal functional run. The seam itself is +/// real — `StateTransition::deserialize_from_bytes` + `broadcast` +/// (the same path PA-006 replays through). +pub async fn broadcast_raw( + sdk: &Arc, + state_transition_bytes: &[u8], +) -> FrameworkResult<()> { + use dash_sdk::platform::transition::broadcast::BroadcastStateTransition; + use dpp::serialization::PlatformDeserializable; + use dpp::state_transition::StateTransition; + if !adversarial_enabled() { return Err(FrameworkError::Config(format!( "broadcast_raw refused: set {ADVERSARIAL_GATE_ENV} to run the abuse pass" ))); } - Err(FrameworkError::NotImplemented(ADVERSARIAL_PENDING)) + let st = StateTransition::deserialize_from_bytes(state_transition_bytes) + .map_err(|e| FrameworkError::Wallet(format!("broadcast_raw: deserialize ST: {e}")))?; + st.broadcast(sdk.as_ref(), None) + .await + .map_err(|e| FrameworkError::Sdk(format!("broadcast_raw: {e}"))) } /// Flip / truncate / zero bytes in a built transition's serialized /// `SerializedBundle` field before broadcast (SH-022/024/025/026/034). /// -/// Seam reserved for the follow-up wave — see [`ADVERSARIAL_PENDING`]. +/// **Blocked by [`ADVERSARIAL_SEAM_MISSING`].** Operates on a captured +/// valid-build's bytes, which the wallet does not expose. pub fn mutate_serialized_bundle( + _bytes: &mut [u8], _field: BundleField, _mutation: BundleMutation, ) -> FrameworkResult<()> { - Err(FrameworkError::NotImplemented(ADVERSARIAL_PENDING)) + Err(FrameworkError::NotImplemented( + "mutate_serialized_bundle: see ADVERSARIAL_SEAM_MISSING", + )) } /// Build a spend directly against a chosen note WITHOUT going through /// `reserve_unspent_notes`, for the double-spend (SH-020) and replay /// (SH-021) arms. /// -/// Seam reserved for the follow-up wave — see [`ADVERSARIAL_PENDING`]. -pub fn build_against_note() -> FrameworkResult<()> { - Err(FrameworkError::NotImplemented(ADVERSARIAL_PENDING)) +/// **Blocked by [`ADVERSARIAL_SEAM_MISSING`].** Requires the private +/// `extract_spends_and_anchor` + `build_spend_bundle`. +pub fn build_against_note() -> FrameworkResult> { + Err(FrameworkError::NotImplemented( + "build_against_note: see ADVERSARIAL_SEAM_MISSING", + )) } -/// Inject a malformed `ShieldedNote` (non-115-byte `note_data`, -/// corrupted `cmx` / nullifier) into a store, for the serde-abuse -/// SH-027. +/// 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. /// -/// Seam reserved for the follow-up wave — see [`ADVERSARIAL_PENDING`]. -pub fn seed_malformed_note() -> FrameworkResult<()> { - Err(FrameworkError::NotImplemented(ADVERSARIAL_PENDING)) +/// 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(()) } /// Resubmit a captured single-use asset-lock proof, for SH-035 /// (Core-L1 gated). /// -/// Seam reserved for the follow-up wave — see [`ADVERSARIAL_PENDING`]. +/// **Blocked by [`ADVERSARIAL_SEAM_MISSING`]** plus the SH-018 Core-L1 +/// asset-lock private-key gap (no test seam returns the one-time key). pub fn reuse_asset_lock_proof() -> FrameworkResult<()> { - Err(FrameworkError::NotImplemented(ADVERSARIAL_PENDING)) + Err(FrameworkError::NotImplemented( + "reuse_asset_lock_proof: see ADVERSARIAL_SEAM_MISSING + SH-018 Core-L1 key gap", + )) } /// A scriptable mock sync source for SH-028 (interrupt mid-chunk) and From 04777860c327d1c1c92fbf514a1b8820d6bbc830 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Fri, 22 May 2026 13:59:47 +0200 Subject: [PATCH 6/7] feat(rs-platform-wallet): shielded build/broadcast split + test-utils seams + wire SH-018/020-035 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes the production-seam gaps the adversarial wave needed, then wires the abuse cases to actually reach Drive. GAP 1 — build/broadcast split (production): - operations.rs: each spend gains a build_*_st entrypoint returning the signed StateTransition WITHOUT broadcast (build_shield_st, build_shield_from_asset_lock_st, build_unshield_st, build_transfer_st, build_withdraw_st) + a shared broadcast_st. The existing combined shield/unshield/transfer/withdraw/shield_from_asset_lock are now thin build-then-broadcast wrappers — PlatformWallet::shielded_* and all callers unchanged. GAP 4 — public Type-18 wrapper (production): - PlatformWallet::shielded_shield_from_asset_lock added, mirroring the other four spend wrappers; delegates to operations::shield_from_asset_lock. GAP 2 + GAP 5 — test-utils feature (NOT in default; pulled by e2e): - New cargo feature. operations::test_utils exposes reserve_unspent_notes_for_test, extract_spends_and_anchor_for_test, unspent_notes_for_test (build-against-chosen-note / skip-reservation), and derive_asset_lock_private_key (seed,path -> one-time key, Gap 5). Harness (framework/shielded.rs): broadcast_raw now takes a StateTransition; mutate_serialized_bundle tampers proof/binding_signature/anchor/amount via the public V0 fields (no byte offsets); capture_unshield_st + build_unshield_st_against_notes + unspent_notes build real transitions through the new seams. Removed the stub MockSyncSource / RawShieldedKind / ADVERSARIAL_SEAM_MISSING. Adversarial cases now REACH the backend (assert backend rejection; RED iff accepted/mishandled): - SH-022/024/025/026/034: capture a valid unshield, byte-tamper value/proof/anchor/binding-sig, broadcast_raw. - SH-020/021/033: build against a chosen note skipping reservation (double-spend, replay-after-confirm, intra-bundle duplicate nullifier). - SH-018/035: public Type-18 wrapper + Gap-5 key helper + create_funded_asset_lock_proof (Core-L1 gated, may run RED). - SH-023: client fee-floor asserted; backend-floor arm flagged as a residual gap (no post-build fee seam). - SH-027/030/031/032: unchanged (already reached wallet/backend). BLOCKED + removed: SH-028/SH-029 (no injectable sync-source seam — sync_notes_across is pub(super), fetches from the SDK directly). Marked BLOCKED in TEST_SPEC.md. SH-018 spec line restored to implemented. Co-Authored-By: Claude Opus 4.7 (1M context) --- packages/rs-platform-wallet/Cargo.toml | 8 +- .../src/wallet/platform_wallet.rs | 49 +++ .../src/wallet/shielded/operations.rs | 401 ++++++++++++++---- .../rs-platform-wallet/tests/e2e/TEST_SPEC.md | 12 +- .../rs-platform-wallet/tests/e2e/cases/mod.rs | 5 +- .../cases/sh_018_shield_from_asset_lock.rs | 138 +++--- .../sh_020_double_spend_two_transitions.rs | 158 +++++-- .../sh_021_nullifier_replay_after_restart.rs | 147 ++++++- .../e2e/cases/sh_022_value_not_conserved.rs | 112 ++++- .../e2e/cases/sh_023_fee_underpayment.rs | 115 ++++- .../cases/sh_024_value_boundary_overflow.rs | 112 ++++- .../tests/e2e/cases/sh_025_forged_proof.rs | 111 ++++- .../tests/e2e/cases/sh_026_anchor_mismatch.rs | 118 ++++-- .../cases/sh_028_sync_interrupt_mid_chunk.rs | 46 -- .../tests/e2e/cases/sh_029_reorg_rescan.rs | 45 -- .../sh_033_duplicate_nullifier_in_bundle.rs | 133 +++++- .../sh_034_tampered_binding_signature.rs | 100 ++++- .../cases/sh_035_replayed_asset_lock_proof.rs | 99 ++++- .../tests/e2e/framework/shielded.rs | 333 +++++++++------ 19 files changed, 1695 insertions(+), 547 deletions(-) delete mode 100644 packages/rs-platform-wallet/tests/e2e/cases/sh_028_sync_interrupt_mid_chunk.rs delete mode 100644 packages/rs-platform-wallet/tests/e2e/cases/sh_029_reorg_rescan.rs diff --git a/packages/rs-platform-wallet/Cargo.toml b/packages/rs-platform-wallet/Cargo.toml index 57836ddfce..23bd3935a5 100644 --- a/packages/rs-platform-wallet/Cargo.toml +++ b/packages/rs-platform-wallet/Cargo.toml @@ -116,7 +116,13 @@ shielded = ["dep:grovedb-commitment-tree", "dep:zip32", "dash-sdk/shielded", "dp # 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"] # Forward to the upstream `key-wallet` / `key-wallet-manager` # `keep-finalized-transactions` feature. With it OFF (the default), # chainlocked transactions are evicted from the in-memory diff --git a/packages/rs-platform-wallet/src/wallet/platform_wallet.rs b/packages/rs-platform-wallet/src/wallet/platform_wallet.rs index 14db85ec8b..b61b5f8757 100644 --- a/packages/rs-platform-wallet/src/wallet/platform_wallet.rs +++ b/packages/rs-platform-wallet/src/wallet/platform_wallet.rs @@ -837,6 +837,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 684e4ac11b..fa2cae9c70 100644 --- a/packages/rs-platform-wallet/src/wallet/shielded/operations.rs +++ b/packages/rs-platform-wallet/src/wallet/shielded/operations.rs @@ -42,6 +42,7 @@ use dpp::shielded::builder::{ 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; @@ -148,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, @@ -158,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 @@ -220,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 @@ -254,8 +293,6 @@ pub async fn shield, P: OrchardProver>( PlatformWalletError::ShieldedBroadcastFailed(e.to_string()) } })?; - - info!(account, credits = amount, "Shield broadcast succeeded"); Ok(()) } @@ -266,6 +303,8 @@ pub async fn shield, P: OrchardProver>( /// 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, @@ -275,24 +314,15 @@ pub async fn shield_from_asset_lock( amount: u64, prover: &P, ) -> Result<(), PlatformWalletError> { - let recipient_addr = default_orchard_address(keys)?; - - info!( + let state_transition = build_shield_from_asset_lock_st( + sdk, + keys, account, - credits = amount, - "Shield from asset lock: building state transition" - ); - - let state_transition = build_shield_from_asset_lock_transition( - &recipient_addr, - amount, asset_lock_proof, private_key, + amount, prover, - [0u8; 36], - sdk.version(), - ) - .map_err(|e| PlatformWalletError::ShieldedBuildError(e.to_string()))?; + )?; trace!("Shield from asset lock: state transition built, broadcasting..."); // Wait for proven execution rather than relay-ACK. This matters most @@ -300,10 +330,7 @@ pub async fn shield_from_asset_lock( // 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. - state_transition - .broadcast_and_wait::(sdk, None) - .await - .map_err(|e| PlatformWalletError::ShieldedBroadcastFailed(e.to_string()))?; + broadcast_st(sdk, &state_transition).await?; info!( account, @@ -313,6 +340,40 @@ pub async fn shield_from_asset_lock( 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) // ------------------------------------------------------------------------- @@ -331,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) = @@ -349,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; @@ -429,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) = @@ -445,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; @@ -515,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()); @@ -532,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; @@ -586,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) // ------------------------------------------------------------------------- @@ -830,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/TEST_SPEC.md b/packages/rs-platform-wallet/tests/e2e/TEST_SPEC.md index 14a7f3cec3..944dcc2b1c 100644 --- a/packages/rs-platform-wallet/tests/e2e/TEST_SPEC.md +++ b/packages/rs-platform-wallet/tests/e2e/TEST_SPEC.md @@ -266,7 +266,7 @@ Status legend: **green** = test file present, body has real assertions, runnable | 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 | not implemented (Wave H + Core-L1 gate) — may run RED until plumbing complete | L | +| 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 | @@ -276,8 +276,8 @@ Status legend: **green** = test file present, body has real assertions, runnable | 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 | not implemented (Wave H + cancel hook) — asserts consistency | M | -| SH-029 | ADVERSARIAL: reorg / out-of-order blocks / rescan-from-0 — balance converges, no phantom funds | P1 | not implemented (Wave H + mock sync) — asserts convergence | 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 | @@ -2237,8 +2237,8 @@ while those bugs persist; SH-007 is designed to PASS and stay green. #### SH-018 — Shield from Core L1 asset lock (Type 18) - **Priority**: P1 -- **Status**: not implemented (Wave H + Core-L1 gate). MAY run RED until the Core-L1 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**: `wallet/shielded/operations.rs:269` (`shield_from_asset_lock`) → `build_shield_from_asset_lock_transition`. NOTE: there is currently NO public `PlatformWallet::shielded_shield_from_asset_lock` wrapper (only the inner free function; contrast the four other spend types which all have public wrappers, `platform_wallet.rs:560/604/652/721`). Wave H must either add a thin test-only wrapper or call the inner path — flag the missing public wrapper as a follow-up DX gap. +- **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. @@ -2392,6 +2392,7 @@ itself a finding (the backend rejected for the wrong reason, or did not reject). ##### 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]**. @@ -2403,6 +2404,7 @@ itself a finding (the backend rejected for the wrong reason, or did not reject). ##### 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]**. diff --git a/packages/rs-platform-wallet/tests/e2e/cases/mod.rs b/packages/rs-platform-wallet/tests/e2e/cases/mod.rs index 048688580a..522186a199 100644 --- a/packages/rs-platform-wallet/tests/e2e/cases/mod.rs +++ b/packages/rs-platform-wallet/tests/e2e/cases/mod.rs @@ -101,10 +101,7 @@ pub mod sh_025_forged_proof; pub mod sh_026_anchor_mismatch; #[cfg(feature = "shielded")] pub mod sh_027_malformed_note_serde; -#[cfg(feature = "shielded")] -pub mod sh_028_sync_interrupt_mid_chunk; -#[cfg(feature = "shielded")] -pub mod sh_029_reorg_rescan; +// 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")] 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 index fbb55b259c..60ce3bf26c 100644 --- 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 @@ -1,42 +1,41 @@ //! 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 -//! plumbing is complete — that is acceptable and expected; a RED here -//! pins the missing harness/asset-lock seam rather than a passing happy -//! path. +//! asset-lock funding plumbing is complete — that is acceptable; a RED +//! here documents the Core-L1 gate, not a defect in the shield path. //! -//! # Flagged production gaps (do NOT fix from inside the test) +//! 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. //! -//! 1. **No public `PlatformWallet::shielded_shield_from_asset_lock` -//! wrapper.** The four other spend types have public wrappers -//! (`platform_wallet.rs:560/604/652/721`); shield-from-asset-lock -//! exists only as the inner free function -//! `operations::shield_from_asset_lock` (`operations.rs:269`). This -//! test calls the inner path directly. **Follow-up DX gap** — file a -//! public-wrapper issue. -//! 2. **No test seam returning the one-time asset-lock private key.** -//! `AssetLockManager::create_funded_asset_lock_proof` returns -//! `(AssetLockProof, DerivationPath, OutPoint)` but NOT the private -//! key bytes `shield_from_asset_lock(private_key: &[u8])` requires, -//! and no public helper derives the key from `(seed, path)`. This is -//! the Core-L1 asset-lock-builder seam Wave H flags as RED-acceptable. -//! -//! Because gap (2) blocks a correct call, this test pins the proof-build -//! half and surfaces the missing seam as a documented RED. Wiring the -//! private-key seam (and ideally the public wrapper) is the Core-L1 -//! follow-up. +//! 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 the test wallet is funded with so the asset-lock -/// builder's coin selection has a confirmed UTXO. Gated behind -/// `PLATFORM_WALLET_E2E_BANK_CORE_GATE`. +/// Core (Layer-1) duffs to fund the test wallet with (gated behind +/// `PLATFORM_WALLET_E2E_BANK_CORE_GATE`). const TEST_WALLET_CORE_FUNDING: u64 = 100_000; -#[allow(dead_code)] -const SHIELD_AMOUNT: u64 = 50_000_000; -#[allow(dead_code)] +/// Duffs locked into the asset lock (the shielded note value, modulo the +/// duff→credit conversion the protocol applies). +const ASSET_LOCK_DUFFS: u64 = 50_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)] @@ -49,32 +48,71 @@ async fn sh_018_shield_from_asset_lock() { .with_test_writer() .try_init(); - // Core-L1 gate: this panics (RED) if SPV / Core funding isn't - // available, which documents the gate rather than a shield-path - // defect. Mirrors CR-003 / AL-001. + // 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 pre_lock_core = s.test_wallet.core_balance_confirmed(); - assert!( - pre_lock_core >= TEST_WALLET_CORE_FUNDING, - "Core-L1 gate: confirmed Core balance {pre_lock_core} < {TEST_WALLET_CORE_FUNDING}" - ); + 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"); - // GAP (2): the asset-lock builder does not return the one-time - // private key, and no public helper derives it from (seed, path), so - // a correct `operations::shield_from_asset_lock(private_key, …)` call - // cannot be constructed test-side. Surface the missing seam as a - // documented RED rather than weakening the assertion or fabricating a - // key. Wiring this seam (proof + one-time private key) is the Core-L1 - // follow-up. - panic!( - "SH-018 RED-by-design: Core-L1 asset-lock-builder seam incomplete — \ - no test path returns the one-time private key required by \ - operations::shield_from_asset_lock, and there is no public \ - PlatformWallet::shielded_shield_from_asset_lock wrapper. \ - Wiring the private-key seam is the Core-L1 follow-up (do NOT \ - weaken this assertion or add production code from inside the test)." + // 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_020_double_spend_two_transitions.rs b/packages/rs-platform-wallet/tests/e2e/cases/sh_020_double_spend_two_transitions.rs index 1a29ac1b7f..c544ccf51f 100644 --- 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 @@ -1,36 +1,38 @@ //! SH-020 — ADVERSARIAL: double-spend the same note across two -//! transitions (Type 16/17) — backend MUST reject the second [INJECT]. +//! 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 spends of the SAME -//! shielded note (same nullifier) and broadcast both. The wallet's -//! `reserve_unspent_notes` prevents two LOCAL spends from picking the -//! same note — a client convenience, not the consensus guarantee — so -//! the attack BYPASSES it by building the second transition directly -//! against the same `SpendableNote`. +//! 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 be accepted; the second must be +//! rejected because its Orchard nullifier is already in Drive's spent set +//! (`NullifierAlreadySpentError`, code 40901). //! -//! Correct backend behavior: exactly ONE accepted; the second rejected -//! with a nullifier-already-spent consensus error (`NullifierAlreadySpentError`, -//! code 40901). RED if both accepted (double-spend — CRITICAL fund -//! forgery), neither accepted, or the balance is wrong. -//! -//! # PRODUCTION GAP (flagged, not fixed) -//! -//! Reaching Drive with a SECOND transition built against an -//! already-reserved/spent note requires the wallet's private -//! `extract_spends_and_anchor` + `reserve_unspent_notes`-bypass build -//! seam, or a captured-bytes replay seam. Neither is public — shielded -//! `operations::*` build AND broadcast internally and expose no -//! build-only capture (contrast transparent `transfer_capturing_st_bytes`). -//! See `framework::shielded::ADVERSARIAL_SEAM_MISSING`. This case is -//! RED-by-gap until a build-only shielded capture seam exists. +//! RED if the backend accepts both (double-spend — CRITICAL fund forgery) +//! or accepts 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, build_against_note, ADVERSARIAL_SEAM_MISSING, + 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 = 90_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_020_double_spend_two_transitions() { @@ -50,14 +52,108 @@ async fn sh_020_double_spend_two_transitions() { return; } - // The attack needs to build a second spend against the same note - // WITHOUT the local reservation. That seam is not public. - let built = build_against_note(); + 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!( - built.is_ok(), - "SH-020 RED-by-gap: cannot reach the backend with a second spend of the same note. {ADVERSARIAL_SEAM_MISSING}" + !notes.is_empty(), + "expected one synced note to double-spend" ); - // Once the seam lands: broadcast both, assert the first is Ok and the - // second fails NullifierAlreadySpentError; assert the shielded - // balance reflects exactly ONE debit (no double-spend, no mint). + 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"); + + let r_a = broadcast_raw(s.ctx.sdk(), &st_a).await; + let r_b = broadcast_raw(s.ctx.sdk(), &st_b).await; + + let accepted = [r_a.is_ok(), r_b.is_ok()].iter().filter(|ok| **ok).count(); + assert_eq!( + accepted, + 1, + "SH-020 FINDING (CRITICAL): exactly ONE of two same-note spends must be accepted; \ + observed {accepted} accepted (a==Ok:{}, b==Ok:{}). Both-accepted = double-spend / \ + fund forgery; neither = liveness bug. r_a={r_a:?} r_b={r_b:?}", + r_a.is_ok(), + r_b.is_ok() + ); + // The rejected one must fail with a nullifier-already-spent class + // error, not a generic failure. + let rejected_err = if r_a.is_err() { r_a } else { r_b }; + let err_s = format!("{rejected_err:?}").to_lowercase(); + assert!( + err_s.contains("nullifier") || err_s.contains("alreadyspent") || err_s.contains("already spent"), + "SH-020: the rejected spend must fail nullifier-already-spent (code 40901); observed {rejected_err:?}" + ); + + 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_021_nullifier_replay_after_restart.rs b/packages/rs-platform-wallet/tests/e2e/cases/sh_021_nullifier_replay_after_restart.rs index 1777686208..97e0e75aff 100644 --- 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 @@ -1,30 +1,36 @@ -//! SH-021 — ADVERSARIAL: nullifier replay after restart/resync — +//! 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: spend a note (Type 17), let it confirm, then resubmit a -//! transition spending the SAME already-spent note. The nullifier is -//! permanently in Drive's spent set, so the replay MUST fail regardless -//! of client state. +//! 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. //! -//! # PRODUCTION GAP (flagged, not fixed) -//! -//! The BACKEND replay arm needs the captured serialized bytes of the -//! confirmed shielded spend (to re-broadcast verbatim) OR a rebuild -//! against the now-spent note. Shielded `operations::*` expose no -//! build-only capture seam (contrast `transfer_capturing_st_bytes`), so -//! the genuine backend-replay arm is RED-by-gap. See -//! `framework::shielded::ADVERSARIAL_SEAM_MISSING`. -//! -//! The CLIENT-side spent-protection (the wallet refuses to re-select a -//! spent note after sync) IS exercisable and is asserted as the -//! achievable half — but it is NOT the consensus guarantee this case -//! exists to prove. +//! RED if the replay is accepted (double-spend via replay). #![cfg(feature = "shielded")] -use crate::framework::shielded::{adversarial_enabled, ADVERSARIAL_SEAM_MISSING}; +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 = 90_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_021_nullifier_replay_after_restart() { @@ -44,8 +50,105 @@ async fn sh_021_nullifier_replay_after_restart() { return; } - panic!( - "SH-021 RED-by-gap: backend nullifier-replay needs captured shielded ST bytes to \ - re-broadcast (or a rebuild-against-spent-note seam); neither is public. {ADVERSARIAL_SEAM_MISSING}" + 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 index 801f9cd7f5..0410489775 100644 --- 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 @@ -4,25 +4,36 @@ //! (consensus-critical). CRITICAL-if-it-fails (value forgery / unlimited //! shielded-pool inflation). //! -//! Attack: a transfer/unshield whose declared outputs exceed the spent -//! note value — minting value from nothing — by setting -//! `SerializedBundle.value_balance` inconsistent with the actual spend, -//! or passing `amount > note` to the dpp builder. +//! Attack: capture a VALID Type-17 unshield (spending a 50M note, +//! unshielding 20M), 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). //! -//! Correct backend behavior: rejected (`ShieldedInvalidValueBalanceError`, -//! code 10822, or invalid-proof). RED if accepted. -//! -//! # PRODUCTION GAP (flagged, not fixed) -//! -//! The public dpp `build_*_transition` enforce `required > total_spent` -//! and the fee floor INTERNALLY (`unshield.rs:78-86`), so they refuse to -//! emit an out-of-input bundle. Mutating a captured valid bundle's -//! `value_balance` needs a build-only shielded capture seam, which is not -//! public. See `framework::shielded::ADVERSARIAL_SEAM_MISSING`. +//! RED if accepted — value forgery. #![cfg(feature = "shielded")] -use crate::framework::shielded::{adversarial_enabled, ADVERSARIAL_SEAM_MISSING}; +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 = 90_000_000; +const SHIELD_AMOUNT: u64 = 50_000_000; +const UNSHIELD_AMOUNT: u64 = 20_000_000; +/// Far above the 50M spent note — minting ~950M 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() { @@ -42,9 +53,72 @@ async fn sh_022_value_not_conserved() { return; } - panic!( - "SH-022 RED-by-gap: cannot construct outputs>inputs and reach the backend — the public \ - dpp builders enforce value conservation internally and there is no captured-bundle \ - value_balance-tamper seam. {ADVERSARIAL_SEAM_MISSING}" + 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 index 449cac03a2..03fedb766c 100644 --- 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 @@ -2,21 +2,40 @@ //! — backend MUST reject [INJECT]. //! Spec: `tests/e2e/TEST_SPEC.md` §3 → SH-023. Priority: P1. HIGH-if-fails. //! -//! Attack: a spend declaring a fee BELOW -//! `compute_minimum_shielded_fee(num_actions, version)` (zero, or just -//! under the floor). Drive must enforce the same floor the client -//! derives; a divergence is itself a finding (fee-market bypass / spam). +//! 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. //! -//! # PRODUCTION GAP (flagged, not fixed) +//! # RESIDUAL PRODUCTION GAP (flagged, not fixed) //! -//! `build_unshield_transition` rejects `Some(f) if f < min_fee` -//! INTERNALLY (`unshield.rs:60-65`), so the public path cannot emit an -//! under-floor transition. Reaching the backend with one needs the raw -//! build seam. See `framework::shielded::ADVERSARIAL_SEAM_MISSING`. +//! 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 crate::framework::shielded::{adversarial_enabled, ADVERSARIAL_SEAM_MISSING}; +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 = 90_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() { @@ -36,8 +55,78 @@ async fn sh_023_fee_underpayment() { return; } - panic!( - "SH-023 RED-by-gap: the dpp builder enforces the min-fee floor internally; no raw seam \ - to submit an under-floor fee to the backend. {ADVERSARIAL_SEAM_MISSING}" + 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 index f658ba43a8..957296941c 100644 --- 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 @@ -1,23 +1,33 @@ -//! SH-024 — ADVERSARIAL: u64/i64 value-boundary overflow/underflow — -//! backend MUST reject safely [INJECT]. +//! 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: drive `amount == u64::MAX`, `amount + fee` wrapping past -//! `u64::MAX`, and `value_balance` at `i64::MIN`/`i64::MAX`, bypassing -//! the client `checked_add` guard. The arithmetic must be checked on the -//! BACKEND (no wraparound, no validator panic, no negative-as-huge-positive). +//! 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. //! -//! # PRODUCTION GAP (flagged, not fixed) -//! -//! `build_unshield_transition` has a `checked_add` overflow guard -//! (`unshield.rs:77-79`) and refuses to emit; feeding the raw boundary -//! `value_balance` to a captured bundle needs a build-only capture seam. -//! See `framework::shielded::ADVERSARIAL_SEAM_MISSING`. NOTE: the -//! client-side u64::MAX guard is already covered (GREEN) by SH-011. +//! RED if the backend wraps, panics, or accepts a boundary value. #![cfg(feature = "shielded")] -use crate::framework::shielded::{adversarial_enabled, ADVERSARIAL_SEAM_MISSING}; +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 = 90_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_024_value_boundary_overflow() { @@ -37,8 +47,74 @@ async fn sh_024_value_boundary_overflow() { return; } - panic!( - "SH-024 RED-by-gap: client checked_add guard blocks the public path; no raw seam to feed \ - a boundary value_balance to the backend validator. {ADVERSARIAL_SEAM_MISSING}" - ); + 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 index 8939787ce5..6a84d6de84 100644 --- 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 @@ -1,26 +1,36 @@ -//! SH-025 — ADVERSARIAL: forged/tampered/substituted Halo-2 proof — -//! verifier MUST reject [INJECT]. +//! 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 transition, then flip bytes in -//! `SerializedBundle.proof` — single-bit flip, truncation, all-zeros, -//! and a proof copied from a DIFFERENT valid transition (substitution). -//! Every variant must fail Orchard proof verification. +//! 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. //! -//! # PRODUCTION GAP (flagged, not fixed) -//! -//! Mutating `proof` bytes requires a captured valid-build's serialized -//! `SerializedBundle`/ST, which shielded `operations::*` never expose -//! (they build AND broadcast internally). The scaffolded `TamperingProver` -//! returns a real proving key, so on its own it produces a VALID proof — -//! genuine forgery still needs the byte-mutation-after-build seam. See -//! `framework::shielded::ADVERSARIAL_SEAM_MISSING`. +//! RED if the backend accepts a tampered proof. #![cfg(feature = "shielded")] -use crate::framework::shielded::{adversarial_enabled, ADVERSARIAL_SEAM_MISSING}; +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 = 90_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_025_forged_proof() { @@ -40,8 +50,71 @@ async fn sh_025_forged_proof() { return; } - panic!( - "SH-025 RED-by-gap: forging/tampering the proof needs the captured serialized bundle to \ - mutate proof bytes post-build; no public shielded capture seam. {ADVERSARIAL_SEAM_MISSING}" - ); + 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 index b24cbdbaa5..f9076e14ba 100644 --- 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 @@ -1,31 +1,36 @@ -//! SH-026 — ADVERSARIAL: stale/wrong anchor — backend MUST reject +//! 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: a spend whose `SerializedBundle.anchor` is a VALID-but-stale -//! earlier-checkpoint root, or random 32 bytes, while the witness paths -//! authenticate against the current root. Doubles as the Found-030 -//! dynamic probe: whichever anchor depth the backend actually accepts -//! resolves the doc ambiguity between `operations.rs:601-611` ("most -//! recent checkpoint") and `file_store.rs:162-165` ("current tree state"). +//! 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. //! -//! Correct backend behavior: rejected (`AnchorMismatch` / "Anchor not -//! found in the recorded anchors tree"). A stale-but-in-window anchor may -//! be accepted if the protocol keeps a bounded history — pin which side -//! of Found-030 is true. -//! -//! # PRODUCTION GAP (flagged, not fixed) -//! -//! Overriding `anchor` post-build (or passing a stale `Anchor` to the dpp -//! builder against current witnesses) needs the build-only capture seam + -//! a tree-checkpoint advancer. Neither is public. See -//! `framework::shielded::ADVERSARIAL_SEAM_MISSING`. The Found-030 doc -//! drift remains pinned statically by the spec note until this dynamic -//! probe can run. +//! 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 crate::framework::shielded::{adversarial_enabled, ADVERSARIAL_SEAM_MISSING}; +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 = 90_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_026_anchor_mismatch() { @@ -45,9 +50,72 @@ async fn sh_026_anchor_mismatch() { return; } - panic!( - "SH-026 RED-by-gap: anchor override + tree-checkpoint advancer needed to manufacture a \ - stale anchor and reach the backend; no public seam. Found-030 stays a static doc-drift \ - pin until this probe can run. {ADVERSARIAL_SEAM_MISSING}" + 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_028_sync_interrupt_mid_chunk.rs b/packages/rs-platform-wallet/tests/e2e/cases/sh_028_sync_interrupt_mid_chunk.rs deleted file mode 100644 index d36fb4af09..0000000000 --- a/packages/rs-platform-wallet/tests/e2e/cases/sh_028_sync_interrupt_mid_chunk.rs +++ /dev/null @@ -1,46 +0,0 @@ -//! SH-028 — ADVERSARIAL: interrupt sync mid-chunk + resume — no -//! double-count/loss [INJECT]. -//! Spec: `tests/e2e/TEST_SPEC.md` §3 → SH-028. Priority: P1. HIGH-if-fails. -//! -//! Attack: cancel `sync_notes_across` between fetch and append, then -//! resume; the append-once gate (`sync.rs:276-289`, gated on `tree_size`) -//! must prevent double-append. Post-resume, a spend must still build a -//! valid witness (proves no shardtree corruption). -//! -//! # PRODUCTION GAP (flagged, not fixed) -//! -//! `sync_notes_across` is `pub(super)` and fetches from the SDK -//! internally; there is no injectable sync source nor a cancellation -//! hook between fetch and store-write. The scaffolded `MockSyncSource` -//! cannot wire without a production `SyncSource` seam. See -//! `framework::shielded::ADVERSARIAL_SEAM_MISSING` (the sync-source -//! variant). - -#![cfg(feature = "shielded")] - -use crate::framework::shielded::adversarial_enabled; - -#[tokio_shared_rt::test(shared, flavor = "multi_thread", worker_threads = 12)] -async fn sh_028_sync_interrupt_mid_chunk() { - 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_028", - "PLATFORM_WALLET_E2E_SHIELDED_ADVERSARIAL unset — abuse case skipped (no-op pass)" - ); - return; - } - - panic!( - "SH-028 RED-by-gap: sync_notes_across is pub(super) with no injectable sync source or \ - mid-chunk cancellation hook; a SyncSource production seam is required to drive the \ - interrupt-and-resume attack." - ); -} diff --git a/packages/rs-platform-wallet/tests/e2e/cases/sh_029_reorg_rescan.rs b/packages/rs-platform-wallet/tests/e2e/cases/sh_029_reorg_rescan.rs deleted file mode 100644 index ee77478566..0000000000 --- a/packages/rs-platform-wallet/tests/e2e/cases/sh_029_reorg_rescan.rs +++ /dev/null @@ -1,45 +0,0 @@ -//! SH-029 — ADVERSARIAL: reorg / out-of-order blocks / rescan-from-0 — -//! balance converges, no phantom funds [INJECT]. -//! Spec: `tests/e2e/TEST_SPEC.md` §3 → SH-029. Priority: P1. HIGH-if-fails. -//! -//! Attack: feed sync (a) out-of-order positions, (b) a reorg that rolls -//! back then re-appends a different set, (c) `next_start_index == 0` -//! rescan-from-0 (`sync.rs:235-241`). Balances must converge to the -//! canonical chain state; the `tree_size` gate must make rescan-from-0 -//! idempotent; no rolled-back commitment retained as spendable. -//! -//! # PRODUCTION GAP (flagged, not fixed) -//! -//! Requires a scriptable mock sync source returning reordered / -//! rolled-back / from-zero note chunks. `sync_notes_across` fetches from -//! the SDK directly with no injection point. See -//! `framework::shielded::ADVERSARIAL_SEAM_MISSING` (sync-source variant). - -#![cfg(feature = "shielded")] - -use crate::framework::shielded::adversarial_enabled; - -#[tokio_shared_rt::test(shared, flavor = "multi_thread", worker_threads = 12)] -async fn sh_029_reorg_rescan() { - 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_029", - "PLATFORM_WALLET_E2E_SHIELDED_ADVERSARIAL unset — abuse case skipped (no-op pass)" - ); - return; - } - - panic!( - "SH-029 RED-by-gap: no scriptable mock sync source — sync_notes_across fetches from the \ - SDK with no injection point; a SyncSource production seam is required to script \ - reorg / out-of-order / rescan-from-0 chunks." - ); -} 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 index a6c89d21b3..82b4d7ade0 100644 --- 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 @@ -3,19 +3,39 @@ //! Spec: `tests/e2e/TEST_SPEC.md` §3 → SH-033. Priority: P1. //! CRITICAL-if-it-fails (double-spend within one tx). //! -//! Attack: one transition whose Orchard bundle spends the same note twice -//! (two actions, identical nullifier) — an intra-transition double-spend. +//! 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. //! -//! # PRODUCTION GAP (flagged, not fixed) -//! -//! Constructing a bundle with a duplicated `SpendableNote` needs the raw -//! dpp bundle builder (`build_spend_bundle`, `pub(crate)`) or a build-only -//! shielded capture seam. Neither is public. See -//! `framework::shielded::ADVERSARIAL_SEAM_MISSING`. +//! 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 crate::framework::shielded::{adversarial_enabled, ADVERSARIAL_SEAM_MISSING}; +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 = 90_000_000; +const SHIELD_AMOUNT: u64 = 50_000_000; +// Below 2× the note value so the two duplicated 50M 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() { @@ -35,8 +55,95 @@ async fn sh_033_duplicate_nullifier_in_bundle() { return; } - panic!( - "SH-033 RED-by-gap: building a bundle with a duplicated SpendableNote needs the raw \ - dpp bundle builder (pub(crate)) or a capture seam. {ADVERSARIAL_SEAM_MISSING}" - ); + 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 index 883a0e3764..3b7013e209 100644 --- 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 @@ -3,19 +3,31 @@ //! Spec: `tests/e2e/TEST_SPEC.md` §3 → SH-034. Priority: P1. //! CRITICAL-if-it-fails (value-balance binding bypass). //! -//! Attack: flip bytes in `SerializedBundle.binding_signature` (64 bytes) -//! and broadcast. The binding signature commits to the value balance; a -//! tampered signature must fail Orchard bundle verification. +//! 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. //! -//! # PRODUCTION GAP (flagged, not fixed) -//! -//! Mutating `binding_signature` needs a captured valid-build's serialized -//! bundle; shielded `operations::*` expose no build-only capture seam. -//! See `framework::shielded::ADVERSARIAL_SEAM_MISSING`. +//! RED if the backend accepts a tampered binding signature. #![cfg(feature = "shielded")] -use crate::framework::shielded::{adversarial_enabled, ADVERSARIAL_SEAM_MISSING}; +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 = 90_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_034_tampered_binding_signature() { @@ -35,8 +47,70 @@ async fn sh_034_tampered_binding_signature() { return; } - panic!( - "SH-034 RED-by-gap: tampering binding_signature needs the captured serialized bundle to \ - mutate post-build; no public shielded capture seam. {ADVERSARIAL_SEAM_MISSING}" - ); + 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 index 433d7d9e86..e9f73af47c 100644 --- 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 @@ -4,24 +4,30 @@ //! gated). CRITICAL-if-it-fails (double-shield from one L1 lock = value //! forgery). //! -//! Attack: shield-from-asset-lock (Type 18) with a valid `AssetLockProof`, -//! then resubmit the SAME proof in a second Type-18 transition. An -//! asset-lock outpoint is single-use; the second must fail -//! (already-used / outpoint-spent consensus error). +//! 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. //! -//! # PRODUCTION GAP (flagged, not fixed) -//! -//! Two gaps stack here: (1) the SH-018 Core-L1 seam — no test path -//! returns the one-time asset-lock private key required by -//! `operations::shield_from_asset_lock`, and there is no public -//! `shielded_shield_from_asset_lock` wrapper; (2) the -//! `reuse_asset_lock_proof` capture/replay seam. Both are needed before -//! this abuse case can reach the backend. See -//! `framework::shielded::ADVERSARIAL_SEAM_MISSING` + the SH-018 case docs. +//! 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 crate::framework::shielded::adversarial_enabled; +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; + +const TEST_WALLET_CORE_FUNDING: u64 = 100_000; +const ASSET_LOCK_DUFFS: u64 = 50_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() { @@ -41,9 +47,66 @@ async fn sh_035_replayed_asset_lock_proof() { return; } - panic!( - "SH-035 RED-by-gap: stacks the SH-018 Core-L1 private-key gap (no test seam returns the \ - one-time asset-lock key, no public shielded_shield_from_asset_lock wrapper) with the \ - asset-lock-proof reuse seam. Both must land before this can reach the backend." + // 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/shielded.rs b/packages/rs-platform-wallet/tests/e2e/framework/shielded.rs index e4de4a9414..6f1176a01c 100644 --- a/packages/rs-platform-wallet/tests/e2e/framework/shielded.rs +++ b/packages/rs-platform-wallet/tests/e2e/framework/shielded.rs @@ -326,27 +326,15 @@ pub async fn teardown_sweep_shielded( } // --------------------------------------------------------------------------- -// Adversarial injection hooks (SH-020..SH-035 — follow-up wave) +// Adversarial injection hooks (SH-020..SH-035) // -// These build now so the abuse pass can wire against them. They expose -// the protocol-boundary seam (raw build → byte-mutate → broadcast) that -// bypasses the guarded `PlatformWallet::shielded_*` methods. Live -// broadcasts are gated behind `adversarial_enabled()`. +// 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()`. // --------------------------------------------------------------------------- -/// Which shielded transition the raw builder should produce. The -/// follow-up wave maps each arm onto the matching -/// `dpp::shielded::builder::build_*_transition`. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum RawShieldedKind { - /// Type 16 — shielded → shielded transfer. - Transfer, - /// Type 17 — unshield to a transparent address. - Unshield, - /// Type 19 — withdraw to a Core L1 address. - Withdraw, -} - /// A `SerializedBundle` field selector for [`mutate_serialized_bundle`]. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum BundleField { @@ -394,97 +382,234 @@ impl OrchardProver for &TamperingProver { } } -/// Production-gap marker for adversarial hooks that CANNOT reach Drive -/// with a properly-formed-but-tampered shielded transition because the -/// wallet exposes no seam to capture a built `SerializedBundle` / raw -/// spend bytes (see the module-level gap notes and the SH-020/022/024/ -/// 025/026/033/034 case docs). A case hitting this is RED-by-gap: the -/// finding is the MISSING seam, not a weakened assertion. -pub const ADVERSARIAL_SEAM_MISSING: &str = - "no public seam to capture a built shielded SerializedBundle / raw spend ST bytes — \ - shielded operations::* build AND broadcast internally (contrast transparent \ - transfer_capturing_st_bytes), and extract_spends_and_anchor / reserve_unspent_notes / \ - build_spend_bundle are private. Add a build-only shielded capture seam (returning the \ - serialized StateTransition before broadcast) to wire this abuse case to the backend."; - -/// Build a raw shielded state transition from caller-supplied, -/// possibly-out-of-range inputs that the guarded wallet wrapper would -/// reject (output > input for SH-022, under-floor fee for SH-023, -/// `u64`/`i64` boundary for SH-024, duplicate spend for SH-033, stale -/// anchor for SH-026). -/// -/// **Blocked by [`ADVERSARIAL_SEAM_MISSING`].** Constructing a valid- -/// except-for-the-tamper transition requires real `SpendableNote`s + an -/// `Anchor` from the wallet's private `extract_spends_and_anchor`, and -/// the public dpp `build_*_transition` enforce the value/fee/overflow -/// guards internally — so neither path can emit the out-of-range bundle -/// these cases need. The signature pins the inputs the abuse cases want. -#[allow(clippy::too_many_arguments)] -pub fn build_raw_shielded_transition( - _kind: RawShieldedKind, - _anchor: [u8; 32], - _value_balance: i64, - _fee: Option, - _proof_override: Option>, -) -> FrameworkResult> { - Err(FrameworkError::NotImplemented( - "build_raw_shielded_transition: see ADVERSARIAL_SEAM_MISSING", - )) -} - -/// Broadcast arbitrary serialized [`StateTransition`] bytes directly, -/// returning the typed backend error so an abuse case can assert the -/// exact rejection variant. Bypasses the guarded `shielded_*` methods. +/// 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. /// /// Gated: refuses unless [`adversarial_enabled`], so a stray malformed -/// broadcast can't pollute a normal functional run. The seam itself is -/// real — `StateTransition::deserialize_from_bytes` + `broadcast` -/// (the same path PA-006 replays through). +/// broadcast can't pollute a normal functional run. Same broadcast path +/// PA-006 replays through. pub async fn broadcast_raw( sdk: &Arc, - state_transition_bytes: &[u8], + state_transition: &dpp::state_transition::StateTransition, ) -> FrameworkResult<()> { use dash_sdk::platform::transition::broadcast::BroadcastStateTransition; - use dpp::serialization::PlatformDeserializable; - use dpp::state_transition::StateTransition; if !adversarial_enabled() { return Err(FrameworkError::Config(format!( "broadcast_raw refused: set {ADVERSARIAL_GATE_ENV} to run the abuse pass" ))); } - let st = StateTransition::deserialize_from_bytes(state_transition_bytes) - .map_err(|e| FrameworkError::Wallet(format!("broadcast_raw: deserialize ST: {e}")))?; - st.broadcast(sdk.as_ref(), None) + state_transition + .broadcast(sdk.as_ref(), None) .await .map_err(|e| FrameworkError::Sdk(format!("broadcast_raw: {e}"))) } -/// Flip / truncate / zero bytes in a built transition's serialized -/// `SerializedBundle` field before broadcast (SH-022/024/025/026/034). +/// Mutate one `SerializedBundle` field of a built shielded +/// [`StateTransition`] in place, before broadcast (SH-022/024/025/026/034). /// -/// **Blocked by [`ADVERSARIAL_SEAM_MISSING`].** Operates on a captured -/// valid-build's bytes, which the wallet does not expose. +/// 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( - _bytes: &mut [u8], - _field: BundleField, - _mutation: BundleMutation, + st: &mut dpp::state_transition::StateTransition, + field: BundleField, + mutation: &BundleMutation, ) -> FrameworkResult<()> { - Err(FrameworkError::NotImplemented( - "mutate_serialized_bundle: see ADVERSARIAL_SEAM_MISSING", - )) + 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 spend directly against a chosen note WITHOUT going through -/// `reserve_unspent_notes`, for the double-spend (SH-020) and replay -/// (SH-021) arms. +/// 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). /// -/// **Blocked by [`ADVERSARIAL_SEAM_MISSING`].** Requires the private -/// `extract_spends_and_anchor` + `build_spend_bundle`. -pub fn build_against_note() -> FrameworkResult> { - Err(FrameworkError::NotImplemented( - "build_against_note: see ADVERSARIAL_SEAM_MISSING", - )) +/// 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` / @@ -526,37 +651,3 @@ where .map_err(|e| FrameworkError::Wallet(format!("seed_malformed_note: append: {e}")))?; Ok(()) } - -/// Resubmit a captured single-use asset-lock proof, for SH-035 -/// (Core-L1 gated). -/// -/// **Blocked by [`ADVERSARIAL_SEAM_MISSING`]** plus the SH-018 Core-L1 -/// asset-lock private-key gap (no test seam returns the one-time key). -pub fn reuse_asset_lock_proof() -> FrameworkResult<()> { - Err(FrameworkError::NotImplemented( - "reuse_asset_lock_proof: see ADVERSARIAL_SEAM_MISSING + SH-018 Core-L1 key gap", - )) -} - -/// A scriptable mock sync source for SH-028 (interrupt mid-chunk) and -/// SH-029 (reorg / out-of-order / rescan-from-0). Holds scripted note -/// chunks plus a cancellation flag the test flips to interrupt a pass. -/// -/// Seam reserved for the follow-up wave; the type exists now so the -/// abuse cases can be authored against a stable handle. -#[derive(Default)] -pub struct MockSyncSource { - /// Scripted chunks the source will yield, in order. Each inner Vec - /// is one chunk's worth of opaque note bytes. - pub chunks: Vec>>, - /// Set by the test to interrupt the next chunk (SH-028). - pub cancel_after_chunk: Option, -} - -impl MockSyncSource { - /// Trip the cancellation flag so the next pass stops after - /// `chunk_index` (SH-028's mid-chunk interrupt). - pub fn cancel_after(&mut self, chunk_index: usize) { - self.cancel_after_chunk = Some(chunk_index); - } -} From 9a6b7f144a096177ac886426b79032feeabb94cf Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Mon, 25 May 2026 09:23:04 +0200 Subject: [PATCH 7/7] docs(rs-platform-wallet): drop stale porter SPV handshake TODO MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The TODO blamed an upstream protocol-version mismatch (dash-spv 70237 vs Dash Core 70240). That diagnosis was wrong — rust-dashcore sets PROTOCOL_VERSION=70237 and dash-spv's peer-acceptance floor is 60001, so no version-based rejection applies. The actual cause was a broken Core on the porter devnet (now fixed); no SPV-side workaround was ever warranted. Removing the speculation so future readers don't pursue a phantom upstream protocol bump. Co-Authored-By: Claude Opus 4.6 --- packages/rs-platform-wallet/tests/e2e/framework/spv.rs | 7 ------- 1 file changed, 7 deletions(-) diff --git a/packages/rs-platform-wallet/tests/e2e/framework/spv.rs b/packages/rs-platform-wallet/tests/e2e/framework/spv.rs index 0847722925..0d8f930723 100644 --- a/packages/rs-platform-wallet/tests/e2e/framework/spv.rs +++ b/packages/rs-platform-wallet/tests/e2e/framework/spv.rs @@ -434,13 +434,6 @@ fn build_client_config( seed_p2p_peers(&mut client_config, config, address_list); - // 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",