From 6be7a6c189785c6705d59cba7dcf6e72b2609c6c Mon Sep 17 00:00:00 2001 From: Adam Tucker Date: Thu, 8 Oct 2026 14:27:42 -0600 Subject: [PATCH 01/40] Add the receiver PIR restore client zakura-pir-receiver runs restore sweeps of dynamic keys. It accepts a receiver directory publication only at a block of the wallet's own chain, tests the wallet's receivers against the publication's labeled filter sets, which every wallet downloads alike, and looks up only those in the paid set over PIR, fetching their note data through Enhance PIR. Results go through the sweep steps of the backend's dynamic IVK traits, so the client works with any store that implements them; SQLite is only a test dependency. A provider's recent set keeps a receiver it was given lately scanning, and its seen set marks a receiver it ever had so issuance skips it. The wallet trusts a recent set only when it is current, its feed having read the provider within the last fifteen minutes, and its declared window covers the wallet's own 24-hour restore watch with the feed running throughout; otherwise every restored key keeps scanning for the watch. A wallet with nothing paid makes no PIR query. When the directory cannot be accepted, every due key backs off as if its own lookup had failed. The receiver crates come from wallet-pir's open receiver stack, pinned at 502d930e, until they land on its main. The crate has its own CI lane and stays out of default; the graph check builds it without the wallet feature. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/verify.yml | 2 +- Cargo.lock | 109 +++- Cargo.toml | 1 + README.md | 4 +- manifests/sources.toml | 2 +- scripts/dev.py | 3 +- scripts/verify-zakura-graph.sh | 7 +- zakura/pir-receiver/CHANGELOG.md | 15 + zakura/pir-receiver/Cargo.toml | 42 ++ zakura/pir-receiver/README.md | 40 ++ zakura/pir-receiver/src/lib.rs | 35 ++ zakura/pir-receiver/src/seen.rs | 87 ++++ zakura/pir-receiver/src/sweep.rs | 650 +++++++++++++++++++++++ zakura/pir-receiver/tests/sweep.rs | 793 +++++++++++++++++++++++++++++ 14 files changed, 1780 insertions(+), 10 deletions(-) create mode 100644 zakura/pir-receiver/CHANGELOG.md create mode 100644 zakura/pir-receiver/Cargo.toml create mode 100644 zakura/pir-receiver/README.md create mode 100644 zakura/pir-receiver/src/lib.rs create mode 100644 zakura/pir-receiver/src/seen.rs create mode 100644 zakura/pir-receiver/src/sweep.rs create mode 100644 zakura/pir-receiver/tests/sweep.rs diff --git a/.github/workflows/verify.yml b/.github/workflows/verify.yml index 4888c663..11eab12b 100644 --- a/.github/workflows/verify.yml +++ b/.github/workflows/verify.yml @@ -79,7 +79,7 @@ jobs: strategy: fail-fast: false matrix: - config: [default, orchard, transparent, transparent-import, sqlite, enhance-wallet, enhance, transparent-pir, transparent-pir-sqlite, transparent-pir-testing] + config: [default, orchard, transparent, transparent-import, sqlite, enhance-wallet, enhance, transparent-pir, transparent-pir-sqlite, transparent-pir-testing, pir-receiver] env: WALLET_LIB_BUILD_ROOT: ${{ github.workspace }}/target/dev-lanes steps: diff --git a/Cargo.lock b/Cargo.lock index 878c08a5..2c9cb727 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3723,6 +3723,19 @@ dependencies = [ "sha2 0.10.9", ] +[[package]] +name = "pir-control" +version = "0.1.0" +source = "git+https://github.com/valargroup/wallet-pir?rev=502d930eaa8acdd3ed43c9ed73b9310168de893c#502d930eaa8acdd3ed43c9ed73b9310168de893c" +dependencies = [ + "axum", + "hex", + "rand 0.8.8", + "serde", + "sha2 0.10.9", + "tokio", +] + [[package]] name = "pir-native" version = "0.1.0" @@ -3735,6 +3748,18 @@ dependencies = [ "reinspiring 0.1.2 (git+https://github.com/valargroup/ipir-sp.git?tag=v0.1.0-rc.6)", ] +[[package]] +name = "pir-native" +version = "0.1.0" +source = "git+https://github.com/valargroup/wallet-pir?rev=502d930eaa8acdd3ed43c9ed73b9310168de893c#502d930eaa8acdd3ed43c9ed73b9310168de893c" +dependencies = [ + "ipir-sp 0.1.0-rc.6 (git+https://github.com/valargroup/ipir-sp.git?tag=v0.1.0-rc.6)", + "rand 0.8.8", + "rand_chacha 0.3.1", + "rayon", + "reinspiring 0.1.2 (git+https://github.com/valargroup/ipir-sp.git?tag=v0.1.0-rc.6)", +] + [[package]] name = "pir-observability" version = "0.1.0" @@ -3745,6 +3770,16 @@ dependencies = [ "serde", ] +[[package]] +name = "pir-observability" +version = "0.1.0" +source = "git+https://github.com/valargroup/wallet-pir?rev=502d930eaa8acdd3ed43c9ed73b9310168de893c#502d930eaa8acdd3ed43c9ed73b9310168de893c" +dependencies = [ + "axum", + "http-body", + "serde", +] + [[package]] name = "pkcs1" version = "0.7.5" @@ -4262,6 +4297,52 @@ dependencies = [ "rand_core 0.6.4", ] +[[package]] +name = "receiver-directory" +version = "0.0.1" +source = "git+https://github.com/valargroup/wallet-pir?rev=502d930eaa8acdd3ed43c9ed73b9310168de893c#502d930eaa8acdd3ed43c9ed73b9310168de893c" +dependencies = [ + "bitcoin", + "incrementalmerkletree", + "serde", + "serde_json", + "sha2 0.10.9", + "thiserror 2.0.21", + "zakura-orchard", + "zcash_note_encryption", +] + +[[package]] +name = "receiver-pir" +version = "0.0.1" +source = "git+https://github.com/valargroup/wallet-pir?rev=502d930eaa8acdd3ed43c9ed73b9310168de893c#502d930eaa8acdd3ed43c9ed73b9310168de893c" +dependencies = [ + "hex", + "ipir-sp 0.1.0-rc.6 (git+https://github.com/valargroup/ipir-sp.git?tag=v0.1.0-rc.6)", + "pir-native 0.1.0 (git+https://github.com/valargroup/wallet-pir?rev=502d930eaa8acdd3ed43c9ed73b9310168de893c)", + "rand 0.8.8", + "receiver-directory", + "serde", + "serde_json", + "sha2 0.10.9", + "thiserror 2.0.21", +] + +[[package]] +name = "receiver-pir-server" +version = "0.0.1" +source = "git+https://github.com/valargroup/wallet-pir?rev=502d930eaa8acdd3ed43c9ed73b9310168de893c#502d930eaa8acdd3ed43c9ed73b9310168de893c" +dependencies = [ + "axum", + "hex", + "pir-control 0.1.0 (git+https://github.com/valargroup/wallet-pir?rev=502d930eaa8acdd3ed43c9ed73b9310168de893c)", + "pir-observability 0.1.0 (git+https://github.com/valargroup/wallet-pir?rev=502d930eaa8acdd3ed43c9ed73b9310168de893c)", + "receiver-directory", + "receiver-pir", + "serde_json", + "tokio", +] + [[package]] name = "reddsa" version = "0.5.2" @@ -6839,7 +6920,7 @@ version = "0.1.0" source = "git+https://github.com/valargroup/wallet-pir?rev=06a972db469f302d8c561416d4674e937fdde0cb#06a972db469f302d8c561416d4674e937fdde0cb" dependencies = [ "hex", - "pir-native", + "pir-native 0.1.0 (git+https://github.com/valargroup/wallet-pir?rev=06a972db469f302d8c561416d4674e937fdde0cb)", "rayon", "serde", "serde_json", @@ -6873,8 +6954,8 @@ dependencies = [ "http-body", "ipir-sp 0.1.0-rc.6 (git+https://github.com/valargroup/ipir-sp.git?tag=v0.1.0-rc.6)", "memmap2", - "pir-control", - "pir-observability", + "pir-control 0.1.0 (git+https://github.com/valargroup/wallet-pir?rev=06a972db469f302d8c561416d4674e937fdde0cb)", + "pir-observability 0.1.0 (git+https://github.com/valargroup/wallet-pir?rev=06a972db469f302d8c561416d4674e937fdde0cb)", "rayon", "reinspiring 0.1.2 (git+https://github.com/valargroup/ipir-sp.git?tag=v0.1.0-rc.6)", "rustix", @@ -8176,6 +8257,28 @@ dependencies = [ "reinspiring 0.1.2 (registry+https://github.com/rust-lang/crates.io-index)", ] +[[package]] +name = "zakura-pir-receiver" +version = "0.0.1-rc0" +dependencies = [ + "axum", + "futures-util", + "receiver-directory", + "receiver-pir", + "receiver-pir-server", + "thiserror 2.0.21", + "tokio", + "tower", + "zakura-client-backend", + "zakura-client-sqlite", + "zakura-dynamic-ivk", + "zakura-orchard", + "zakura-pir-enhance", + "zakura-primitives", + "zakura-protocol", + "zcash_note_encryption", +] + [[package]] name = "zakura-pir-status" version = "0.0.1-rc0" diff --git a/Cargo.toml b/Cargo.toml index 4cc1b9c0..05b7bf9a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -21,6 +21,7 @@ members = [ "zakura/transaction-status", "zakura/pir-transparent", "zakura/dynamic-ivk", + "zakura/pir-receiver", ] resolver = "2" diff --git a/README.md b/README.md index 811468cc..d8971661 100644 --- a/README.md +++ b/README.md @@ -57,7 +57,9 @@ regeneration command, separate from ordinary builds. The unpublished [dynamic IVK](zakura/dynamic-ivk/README.md) crate provides shared derivation of receiving keys that keep an account's spending authority with their own incoming viewing keys, and refund memo helpers, with an Ironwood proof test for -mixed ordinary, refund, and incoming inputs. +mixed ordinary, refund, and incoming inputs. The unpublished +[receiver PIR](zakura/pir-receiver/README.md) crate runs the restore sweeps that +find payments to those keys through a receiver directory. ## How the rewiring works diff --git a/manifests/sources.toml b/manifests/sources.toml index 80e1bbd7..f593a498 100644 --- a/manifests/sources.toml +++ b/manifests/sources.toml @@ -27,7 +27,7 @@ vendored_directory = "librustzcash" vendor_branch = "vendor/librustzcash" upstream_manifest = "librustzcash/upstream-workspace.toml" # `zakura/` members are added here as they are written. -extra_members = ["wallet-lib", "zakura/pir-enhance", "zakura/pir-enhance-types", "zakura/pir-native", "zakura/pir-status", "zakura/transaction-status", "zakura/pir-transparent", "zakura/dynamic-ivk"] +extra_members = ["wallet-lib", "zakura/pir-enhance", "zakura/pir-enhance-types", "zakura/pir-native", "zakura/pir-status", "zakura/transaction-status", "zakura/pir-transparent", "zakura/dynamic-ivk", "zakura/pir-receiver"] # The backend selector. Named separately because the verification scripts have # to build it one backend at a time, unlike every other member. facade = "wallet-lib" diff --git a/scripts/dev.py b/scripts/dev.py index 60ac4a9b..31850348 100644 --- a/scripts/dev.py +++ b/scripts/dev.py @@ -15,7 +15,7 @@ ROOT = Path(__file__).resolve().parents[1] CONFIGS = { - "default": (["--workspace", "--exclude", "zakura-wallet-lib", "--exclude", "zakura-pir-enhance", "--exclude", "zakura-pir-transparent"], []), + "default": (["--workspace", "--exclude", "zakura-wallet-lib", "--exclude", "zakura-pir-enhance", "--exclude", "zakura-pir-transparent", "--exclude", "zakura-pir-receiver"], []), "orchard": (["-p", "zakura-client-backend", "-p", "zakura-client-sqlite"], ["orchard", "test-dependencies"]), "transparent": (["-p", "zakura-client-backend", "-p", "zakura-client-sqlite"], ["orchard", "transparent-inputs", "test-dependencies", "unstable"]), "transparent-import": (["-p", "zakura-client-backend", "-p", "zakura-client-sqlite"], ["orchard", "transparent-inputs", "transparent-key-import", "test-dependencies", "unstable"]), @@ -25,6 +25,7 @@ "transparent-pir": (["-p", "zakura-pir-transparent"], ["wallet"]), "transparent-pir-sqlite": (["-p", "zakura-pir-transparent"], ["sqlite"]), "transparent-pir-testing": (["-p", "zakura-pir-transparent"], ["sqlite", "testing"]), + "pir-receiver": (["-p", "zakura-pir-receiver"], ["wallet"]), } VERIFY = ("zakura-graph", "wallet-lib-modes", "vendor-ancestry") diff --git a/scripts/verify-zakura-graph.sh b/scripts/verify-zakura-graph.sh index c6a1c21d..68e08266 100755 --- a/scripts/verify-zakura-graph.sh +++ b/scripts/verify-zakura-graph.sh @@ -36,10 +36,11 @@ PY cargo check --manifest-path "$repo_root/Cargo.toml" \ --workspace --exclude "$facade" --all-targets --all-features --locked -# No development lane builds the transparent PIR adapter without `wallet`: its -# tests serve shards in process, so the `default` lane leaves it out. +# No development lane builds the transparent PIR adapter or the receiver PIR +# client without `wallet`: their tests serve shards or directories in process, +# so the `default` lane leaves them out. cargo check --manifest-path "$repo_root/Cargo.toml" \ - -p zakura-pir-transparent --lib --locked + -p zakura-pir-transparent -p zakura-pir-receiver --lib --locked cargo metadata --locked --manifest-path "$repo_root/Cargo.toml" \ --format-version 1 --all-features \ diff --git a/zakura/pir-receiver/CHANGELOG.md b/zakura/pir-receiver/CHANGELOG.md new file mode 100644 index 00000000..00bdca18 --- /dev/null +++ b/zakura/pir-receiver/CHANGELOG.md @@ -0,0 +1,15 @@ +# Changelog + +## [Unreleased] + +### Added + +- `sweep`, behind the `wallet` feature, which runs a restored wallet's + receiver-directory sweeps of its dynamic keys for any store implementing the + backend's `DynamicIvkWrite`, with `Transport`, `NoteSource`, `EnhanceNotes`, `WriteLock`, + `Swept`, `Error` and `MAINNET_GENESIS`. It reads a publication's labeled filter + sets: `paid`, and each provider's `recent` and `seen`, trusting a recent set only + while its feed is current. The receiver PIR client moved + here from Vizor. +- `fetch_seen` and `Seen`: a publication's swap provider seen sets, which a wallet + checks before issuing a swap address. They need no `wallet` feature. diff --git a/zakura/pir-receiver/Cargo.toml b/zakura/pir-receiver/Cargo.toml new file mode 100644 index 00000000..a929cc44 --- /dev/null +++ b/zakura/pir-receiver/Cargo.toml @@ -0,0 +1,42 @@ +[package] +name = "zakura-pir-receiver" +version = "0.0.1-rc0" +publish = false +description = "Restore sweeps of dynamic IVKs through a receiver directory over PIR" +edition.workspace = true +rust-version.workspace = true +license.workspace = true +repository.workspace = true +readme = "README.md" + +[features] +default = [] +## The restore sweep, over a wallet store's dynamic IVK traits. +wallet = ["dep:futures-util", "dep:thiserror", "dep:zakura-pir-enhance", "dep:zakura-dynamic-ivk", "dep:zcash_client_backend", "dep:zcash_protocol"] + +[dependencies] +futures-util = { version = "0.3", optional = true } +thiserror = { version = "2", optional = true } +zakura-pir-enhance = { version = "=0.0.1-rc1", path = "../pir-enhance", features = ["wallet"], optional = true } +zakura-dynamic-ivk = { version = "0.0.0", path = "../dynamic-ivk", optional = true } +zcash_client_backend = { workspace = true, features = ["orchard"], optional = true } +zcash_protocol = { workspace = true, optional = true } +# The receiver crates come from wallet-pir's open receiver stack until they land on its `main`. +receiver-directory = { git = "https://github.com/valargroup/wallet-pir", rev = "502d930eaa8acdd3ed43c9ed73b9310168de893c" } +receiver-pir = { git = "https://github.com/valargroup/wallet-pir", rev = "502d930eaa8acdd3ed43c9ed73b9310168de893c" } + +[dev-dependencies] +axum = "0.7" +orchard.workspace = true +receiver-pir = { git = "https://github.com/valargroup/wallet-pir", rev = "502d930eaa8acdd3ed43c9ed73b9310168de893c", features = ["server"] } +receiver-pir-server = { git = "https://github.com/valargroup/wallet-pir", rev = "502d930eaa8acdd3ed43c9ed73b9310168de893c" } +tokio = { workspace = true, features = ["macros", "rt"] } +tower = { workspace = true, features = ["util"] } +zcash_client_backend = { workspace = true, features = ["orchard", "test-dependencies"] } +zcash_client_sqlite = { path = "../../librustzcash/zcash_client_sqlite", package = "zakura-client-sqlite", features = ["orchard", "test-dependencies"] } +zcash_note_encryption.workspace = true +zcash_primitives.workspace = true + +[[test]] +name = "sweep" +required-features = ["wallet"] diff --git a/zakura/pir-receiver/README.md b/zakura/pir-receiver/README.md new file mode 100644 index 00000000..735dc51a --- /dev/null +++ b/zakura/pir-receiver/README.md @@ -0,0 +1,40 @@ +# Receiver directory restore sweeps + +With the `wallet` feature, `sweep` runs a restored wallet's sweeps of its dynamic keys +for any wallet store that implements the backend's dynamic IVK traits +(`zcash_client_backend::data_api::dynamic_ivk`), such as `zakura-client-sqlite`. +Each dynamic key recovered from the seed, a refund key from its funding memo or an +incoming lookahead key, is first tested against the publication's filters, which +every wallet downloads alike. Only a receiver in the paid set is looked up over PIR, +and the payments found are imported only after the wallet checks them against its own +chain. A swap provider's sets decide what happens to the key afterwards: one the +provider was given within the wallet's restore watch keeps scanning after its sweep, +and one it ever had is marked seen, so issuance does not hand it out again. A +provider's recent set is trusted only if its feed read the provider within the last +fifteen minutes and its window and feed history cover that watch; otherwise every key +keeps scanning. A run that finds nothing to look up opens +no PIR session and fetches no witnesses. + +Applications supply: + +- the directory's HTTPS origin and a `Transport` for it, and a `NoteSource` for the + note data of found payments, usually `EnhanceNotes` over Enhance PIR. Both carry + the application's route policy, timeouts and cancellation; this crate has no HTTP + client. +- a `WriteLock` that serializes the sweep's wallet writes with the application's + other writers. +- the wallet's consensus parameters, the network's genesis hash (`MAINNET_GENESIS` on + mainnet) and the clock. + +See the `sweep` rustdoc for when a run sends requests, which publications it +accepts, and how failures are retried. + +The receiver crates are pinned to a commit of wallet-pir's open receiver stack +until they land on its `main`. + +`tests/sweep.rs` serves a directory in process through the receiver service's own +routes and checks that a restored wallet finds and imports a payout, extends its +lookahead past it, repeats no finished sweep, imports every payment of a key paid +twice, makes no lookup when nothing was paid, keeps scanning only recently quoted +addresses, keeps every address scanning when the recent set cannot vouch for the +whole watch, and refuses a publication that is not on its chain before any lookup. diff --git a/zakura/pir-receiver/src/lib.rs b/zakura/pir-receiver/src/lib.rs new file mode 100644 index 00000000..6b9ff8f7 --- /dev/null +++ b/zakura/pir-receiver/src/lib.rs @@ -0,0 +1,35 @@ +//! Restore sweeps of dynamic keys through a receiver directory over PIR. +//! +//! A restored wallet cannot rescan public history for every key its seed may have +//! used, so each dynamic key recovered from the seed is looked up once in a receiver +//! directory: a public index of Ironwood payments sent with the zero outgoing viewing +//! key, keyed by receiver. Lookups use PIR, so the directory does not learn which +//! receivers the wallet holds. A publication is used only at a block the wallet +//! scanned, and the wallet authenticates every payment with its derived key and its +//! own chain before crediting it. +//! +//! `fetch_seen` downloads the swap provider seen sets that a wallet checks before +//! issuing a swap address. The `wallet` feature adds `sweep`, which runs these lookups +//! for any wallet store that implements the backend's `DynamicIvkWrite`. The caller +//! supplies transports, which carry its route policy, cancellation and timeouts, and a +//! `WriteLock` that serializes wallet writes with its other writers. Dropping a `sweep` +//! future at any await is safe: each wallet step commits on its own, and every begun +//! attempt is already backed off. + +mod seen; +#[cfg(feature = "wallet")] +mod sweep; +pub use seen::{Seen, fetch_seen}; +#[cfg(feature = "wallet")] +pub use sweep::{EnhanceNotes, Error, NoteSource, Swept, WriteLock, sweep}; + +pub use receiver_pir::{Error as DirectoryError, transport::Transport}; +/// The directory format and client underneath, for callers that need them directly. +pub use {receiver_directory, receiver_pir}; + +/// Zcash mainnet's genesis block hash, in internal byte order, which its directory +/// publications commit to. +pub const MAINNET_GENESIS: [u8; 32] = [ + 0x08, 0xce, 0x3d, 0x97, 0x31, 0xb0, 0x00, 0xc0, 0x83, 0x38, 0x45, 0x5c, 0x8a, 0x4a, 0x6b, 0xd0, + 0x5d, 0xa1, 0x6e, 0x26, 0xb1, 0x1d, 0xaa, 0x1b, 0x91, 0x71, 0x84, 0xec, 0xe8, 0x0f, 0x04, 0x00, +]; diff --git a/zakura/pir-receiver/src/seen.rs b/zakura/pir-receiver/src/seen.rs new file mode 100644 index 00000000..0fe4d8b3 --- /dev/null +++ b/zakura/pir-receiver/src/seen.rs @@ -0,0 +1,87 @@ +//! Swap provider seen sets, which wallets read before issuing a swap address. +use receiver_directory::{ + Receiver, + filter::{Filter, SEEN}, +}; +use receiver_pir::transport::DirectoryClient; + +use crate::{DirectoryError, Transport}; + +/// The swap provider seen sets of one receiver directory publication: every receiver +/// each provider was given while its feed ran. Every wallet downloads them alike. +pub struct Seen { + salt: receiver_directory::Hash, + sets: Vec, + since: i64, + until: i64, +} + +impl Seen { + /// When the latest-starting provider feed started, in Unix seconds. Receivers given + /// earlier may be missing. + pub fn since(&self) -> i64 { + self.since + } + + /// When the earliest of the feeds' last complete reads began, in Unix seconds. + /// Receivers given later may be missing. + pub fn until(&self) -> i64 { + self.until + } + + /// Whether any provider's set holds each of `receivers`, in order. A set may hold a + /// receiver it was never given, at its filter's false positive rate. A receiver + /// that is not a valid Orchard-format receiver is reported as held. + pub fn contains(&self, receivers: &[[u8; 43]]) -> Vec { + let parsed: Vec<_> = receivers + .iter() + .map(|r| Receiver::from_bytes(*r).ok()) + .collect(); + let valid: Vec<_> = parsed.iter().flatten().copied().collect(); + let mut held = vec![false; valid.len()]; + for set in &self.sets { + for (held, hit) in held.iter_mut().zip(set.matches(&self.salt, &valid)) { + *held |= hit; + } + } + let mut held = held.into_iter(); + parsed + .iter() + .map(|r| r.is_none() || held.next().unwrap_or(true)) + .collect() + } +} + +/// Downloads the seen sets of the publication `origin` serves. Fails if it declares +/// none, or a seen set without its feed's read times. +pub async fn fetch_seen(origin: &str, transport: &T) -> Result { + let manifest = DirectoryClient::fetch_manifest(origin, transport).await?; + let filters = DirectoryClient::fetch_filters(origin, transport, &manifest).await?; + let mut seen = Seen { + salt: manifest.directory.salt, + sets: Vec::new(), + since: i64::MIN, + until: i64::MAX, + }; + for set in &manifest.directory.filters { + if set.label.split_once('/').map(|(_, kind)| kind) != Some(SEEN) { + continue; + } + let (Some(since), Some(until)) = (set.since_unix, set.until_unix) else { + return Err(DirectoryError::Malformed); + }; + seen.since = seen.since.max(since); + seen.until = seen.until.min(until); + // Fetching the filters checked them against the manifest's sets. + seen.sets.push( + filters + .get(&set.label) + .ok_or(DirectoryError::Malformed)? + .clone(), + ); + } + if seen.sets.is_empty() { + return Err(DirectoryError::Malformed); + } + Ok(seen) +} diff --git a/zakura/pir-receiver/src/sweep.rs b/zakura/pir-receiver/src/sweep.rs new file mode 100644 index 00000000..b750c29b --- /dev/null +++ b/zakura/pir-receiver/src/sweep.rs @@ -0,0 +1,650 @@ +//! Restore sweeps over a wallet's dynamic IVK traits. + +use std::{collections::BTreeMap, num::NonZeroU32}; + +use futures_util::StreamExt; +use receiver_directory::{ + Receiver, + filter::{Filters, PAID, RECENT, SEEN}, + snapshot::FilterSet, + witness::WitnessSnapshot, +}; +use receiver_pir::{AcceptedCoverage, transport::DirectoryClient}; +use zakura_dynamic_ivk::{KeyId, lifecycle::RESTORE_WATCH_SECS}; +use zakura_pir_enhance::{ + ClientError, ClientResourceLimits, + transport::{Client, PendingClient, Transport as EnhanceTransport}, + wallet::{Acceptance, acceptance}, +}; +use zcash_client_backend::data_api::{ + WalletRead, + dynamic_ivk::{ + DirectoryPayment, DiscoveryWork, DynamicIvkRead, DynamicIvkWrite, PaymentApplication, + ProviderView, SweepDeferral, + }, + enhance_pir::EnhancePirRead, + transparent_ledger::ChainPoint, +}; +use zcash_protocol::consensus::{BlockHeight, NetworkUpgrade, Parameters}; + +use crate::{DirectoryError, Transport}; + +/// A wallet's account identifier. +type AccountId = ::AccountId; +/// A wallet's error type. +type AccountError = ::Error; +/// Due sweeps, each with its account. +type Work = Vec<(AccountId, DiscoveryWork)>; + +/// Due sweeps prepared per account at a time. +const BATCH: NonZeroU32 = NonZeroU32::new(64).unwrap(); +/// Positions whose note data one request asks for. Each batch is queued as it arrives, +/// so a long history keeps its progress when a run stops partway. +const NOTE_BATCH: usize = 64; +/// How long before `now` a provider's recent set may have last read its feed and still +/// rule a receiver out. A quote made after that read is missing from the set. +const RECENT_SET_MAX_AGE_SECS: i64 = 15 * 60; + +/// Why a sweep run, or one key's sweep, stopped. `E` is the wallet's error type. +#[derive(Debug, thiserror::Error)] +pub enum Error { + /// The receiver directory refused, failed or answered inconsistently. + #[error("receiver directory: {0}")] + Directory(#[from] DirectoryError), + /// Enhance PIR could not supply note data. + #[error("note data: {0}")] + NoteData(#[from] ClientError), + /// A wallet step refused the work, or the wallet's storage failed. + #[error(transparent)] + Wallet(E), + /// A wallet step must wait for more scanning or a newer publication. + #[error("deferred: {0:?}")] + Deferred(SweepDeferral), + /// A service's publication cannot be used against the wallet's chain yet. + #[error("{0}")] + Unavailable(&'static str), + /// A queued payment is waiting, for example for its witness. + #[error("payment remains queued: {0:?}")] + Queued(PaymentApplication), +} + +/// Serializes a sweep's wallet writes with the caller's other writers. +pub trait WriteLock { + /// Runs `write` while holding the caller's wallet write lock. `label` names the + /// step. + fn write(&self, label: &'static str, write: impl FnOnce() -> T) -> T; +} + +/// Supplies the note data that payments a lookup found need. +#[allow(async_fn_in_trait)] +pub trait NoteSource { + /// The 528 ciphertext bytes after the directory's prefix for each of `positions`, + /// keyed by commitment tree position. `wallet` is the wallet being swept. + async fn note_data( + &mut self, + wallet: &W, + positions: Vec, + ) -> Result, Error>>; +} + +/// Note data over Enhance PIR. Its session opens on first use, accepted against the +/// wallet's scanned chain, so a run whose lookups find nothing makes no Enhance +/// request. +pub struct EnhanceNotes<'a, T, P> { + origin: &'a str, + transport: &'a T, + params: &'a P, + session: Option, +} + +impl<'a, T: EnhanceTransport, P: Parameters> EnhanceNotes<'a, T, P> { + /// Note data from the Enhance PIR service at `origin`, reached through `transport`, + /// for a wallet on `params`' network. + pub fn new(origin: &'a str, transport: &'a T, params: &'a P) -> Self { + Self { + origin, + transport, + params, + session: None, + } + } +} + +impl NoteSource for EnhanceNotes<'_, T, P> +where + T: EnhanceTransport, + P: Parameters, + W: DynamicIvkRead + EnhancePirRead, +{ + async fn note_data( + &mut self, + wallet: &W, + positions: Vec, + ) -> Result, Error>> { + let mut note_data = BTreeMap::new(); + if positions.is_empty() { + return Ok(note_data); + } + if self.session.is_none() { + let pending = PendingClient::fetch(self.transport, self.origin).await?; + let limits = ClientResourceLimits::with_cache(32768, 2); + let accepted = acceptance(wallet, pending.manifest(), self.params, limits) + .map_err(Error::Wallet)??; + let accepted = match accepted { + Acceptance::Accepted(accepted) => accepted, + Acceptance::WaitingForScanning => { + return Err(Error::Unavailable( + "the Enhance PIR anchor has not been scanned", + )); + } + Acceptance::Mismatch => { + return Err(Error::Unavailable( + "the Enhance PIR anchor differs from the wallet's chain", + )); + } + }; + self.session = Some(pending.accept(&accepted)?); + } + let session = self.session.as_mut().expect("opened above"); + // Enhance groups these positions into shared row requests itself. + let stream = session.query_batch(self.transport, positions)?; + futures_util::pin_mut!(stream); + while let Some(result) = stream.next().await { + note_data.insert(result.position, *result.record?.enc_ciphertext_suffix()); + } + Ok(note_data) + } +} + +/// What one [`sweep`] run did. `E` is the wallet's error type. +#[derive(Debug)] +pub struct Swept { + /// Keys whose sweep finished. + pub finished: usize, + /// Keys left for a later run, each already backed off, with why. + pub deferred: Vec<(KeyId, Error)>, + /// Whether any of the accounts still has unfinished sweeps or queued payments. + pub pending: bool, +} + +/// Runs `accounts`' due restore sweeps against the receiver directory at `origin`, +/// then releases Ironwood spend evidence that no sweep needs any longer (see +/// [`DynamicIvkWrite::finish_dynamic_nullifier_recovery`]). +/// +/// `through` is the wallet's fully scanned tip, and sweeps run only while it is also +/// the stored chain tip. When none is due, the run makes no request. Otherwise the +/// publication must commit to `genesis` ([`MAINNET_GENESIS`](crate::MAINNET_GENESIS) +/// on mainnet), cover history from Ironwood activation on `params`' network, and end at +/// a block the wallet accepts (see [`DynamicIvkRead::directory_publication_anchor`]). +/// +/// Each key's receiver is first tested against the publication's filters, which every +/// wallet downloads alike: only a receiver in the paid set is looked up over PIR. A +/// swap provider's sets then say whether a swap may still pay the receiver, which keeps +/// its key scanning after the sweep, and whether the provider ever had it (see +/// [`ProviderView`]). A provider's recent set can rule a receiver out only if it is +/// current and complete: its feed's last read began at most fifteen minutes before +/// `now`, and its window, with the feed running throughout, covers the wallet's own +/// restore watch, [`RESTORE_WATCH_SECS`]. If any recent set falls short, or the +/// publication has none, every key keeps scanning. +/// +/// A run that looks nothing up opens no PIR session and fetches no witnesses. New +/// payments a lookup finds take their note data from `notes`. After each batch the +/// incoming lookahead moves past paid indices and its new keys are swept too. A key +/// that fails is backed off and listed in [`Swept::deferred`], and the others go on, +/// unless the directory's session or connection failed: then the run stops before +/// leasing more keys. `now` is the caller's clock in seconds, for retry backoff and the +/// recent sets. +#[allow(clippy::too_many_arguments)] +pub async fn sweep( + wallet: &mut W, + params: &P, + accounts: &[AccountId], + through: ChainPoint, + genesis: [u8; 32], + origin: &str, + transport: T, + notes: &mut N, + lock: &L, + now: i64, +) -> Result>, Error>> +where + W: DynamicIvkWrite, + P: Parameters, + T: Transport, + N: NoteSource, + L: WriteLock, +{ + let mut swept = Swept { + finished: 0, + deferred: Vec::new(), + pending: false, + }; + let tip = wallet.chain_height().map_err(Error::Wallet)?; + if tip == Some(through.height) { + let mut work = due(wallet, accounts, through, lock, now)?; + if !work.is_empty() { + let accepted = + Directory::accept(wallet, params, through, genesis, origin, transport).await; + let mut directory = match accepted { + Ok(directory) => directory, + Err(e) => { + // No key can be swept without the directory, so each backs off as + // if its own lookup had failed, and the next sync does not wait on + // the same directory again. + for (account, item) in &work { + lock.write("dynamic_sweep.attempt", || { + wallet.begin_dynamic_sweep_attempt(*account, item.key, now) + }) + .map_err(Error::Wallet)?; + } + return Err(e); + } + }; + 'run: loop { + let checks = directory.check(&work, now)?; + let lookups = work + .iter() + .zip(&checks) + .filter(|((_, item), check)| item.lookup.is_none() && check.paid) + .count(); + if let Some(session) = &mut directory.session { + session.client.use_file_for_work(lookups).await?; + } + for ((account, item), check) in work.into_iter().zip(checks) { + let swept_key = sweep_key( + wallet, + &mut directory, + account, + &item, + check, + lookups, + through, + notes, + lock, + now, + ); + match swept_key.await { + Ok(()) => swept.finished += 1, + Err(e) => { + // Every later key would fail the same way and back off. + let unavailable = matches!( + e, + Error::Directory( + DirectoryError::Revision | DirectoryError::Transport(_) + ) + ); + swept.deferred.push((item.key, e)); + if unavailable { + break 'run; + } + } + } + } + lock.write("dynamic_sweep.lookahead", || { + accounts + .iter() + .try_for_each(|account| wallet.maintain_dynamic_ivks(*account)) + }) + .map_err(Error::Wallet)?; + work = due(wallet, accounts, through, lock, now)?; + if work.is_empty() { + break; + } + } + } + } + for account in accounts { + lock.write("dynamic_sweep.release", || { + wallet.finish_dynamic_nullifier_recovery(*account, through) + }) + .map_err(Error::Wallet)?; + swept.pending |= wallet + .dynamic_history_pending(*account, through.height) + .map_err(Error::Wallet)?; + } + Ok(swept) +} + +/// What a publication's filters say about one key's receiver. +#[derive(Clone, Copy)] +struct Check { + /// The paid set holds it, so a lookup may find payments. + paid: bool, + /// What the swap provider sets say. + provider: ProviderView, +} + +/// A receiver-directory publication accepted at a block the wallet scanned, with its +/// filters. Its PIR session and common witnesses are fetched only once a key needs a +/// lookup or has payments to apply. +struct Directory<'a, T> { + origin: &'a str, + /// Moves into the session when one opens. + transport: Option, + manifest: receiver_pir::Manifest, + accepted: AcceptedCoverage, + anchor: ChainPoint, + filters: Filters, + session: Option>, +} + +/// An open PIR session and the publication's common witnesses, which every wallet +/// fetches identically. +struct Session { + client: DirectoryClient, + witnesses: WitnessSnapshot, +} + +impl<'a, T: Transport> Directory<'a, T> { + /// Accepts the publication `origin` serves, at the block of the wallet's chain it + /// ends at, and downloads its filters. + async fn accept( + wallet: &W, + params: &P, + through: ChainPoint, + genesis: [u8; 32], + origin: &'a str, + transport: T, + ) -> Result>> { + let manifest = DirectoryClient::fetch_manifest(origin, &transport).await?; + let end = BlockHeight::from(manifest.directory.end_height); + let anchor = wallet + .directory_publication_anchor(end, through) + .map_err(Error::Wallet)? + .map_err(Error::Deferred)?; + let activation = params + .activation_height(NetworkUpgrade::Nu6_3) + .ok_or(Error::Unavailable("Ironwood is not active on this network"))?; + let accepted = AcceptedCoverage { + genesis, + required_start: activation.into(), + height: anchor.height.into(), + hash: anchor.hash.0, + }; + accepted.check(&manifest.directory)?; + let filters = DirectoryClient::fetch_filters(origin, &transport, &manifest).await?; + Ok(Self { + origin, + transport: Some(transport), + manifest, + accepted, + anchor, + filters, + session: None, + }) + } + + /// What the filters say about each item's receiver at `now`; see [`sweep`] for how + /// the provider sets are read. + fn check( + &self, + work: &[(A, DiscoveryWork)], + now: i64, + ) -> Result, DirectoryError> { + let receivers = work + .iter() + .map(|(_, item)| Receiver::from_bytes(item.receiver)) + .collect::, _>>()?; + let key = &self.manifest.directory.salt; + // Fetching the filters checked them against the manifest's sets. + let matches = |label: &str| { + self.filters + .get(label) + .expect("the manifest declares every set") + .matches(key, &receivers) + }; + let mut checks: Vec<_> = matches(PAID) + .into_iter() + .map(|paid| Check { + paid, + provider: ProviderView::default(), + }) + .collect(); + let mut recent_complete = false; + let mut recent_incomplete = false; + for set in &self.manifest.directory.filters { + let kind = set.label.split_once('/').map(|(_, kind)| kind); + if kind == Some(RECENT) { + if covers_restore_watch(set, now) { + recent_complete = true; + for (check, hit) in checks.iter_mut().zip(matches(&set.label)) { + check.provider.recent |= hit; + } + } else { + recent_incomplete = true; + } + } else if kind == Some(SEEN) { + for (check, hit) in checks.iter_mut().zip(matches(&set.label)) { + check.provider.seen |= hit; + } + } + } + if recent_incomplete || !recent_complete { + for check in &mut checks { + check.provider.recent = true; + } + } + Ok(checks) + } + + /// The PIR session, opened for `lookups` lookups if none is open yet. + async fn session(&mut self, lookups: usize) -> Result<&mut Session, Error> { + if self.session.is_none() { + let transport = self + .transport + .take() + .ok_or(Error::Unavailable("the receiver directory session failed"))?; + let client = DirectoryClient::connect_manifest( + self.origin, + transport, + self.accepted, + self.manifest.clone(), + lookups, + ) + .await?; + let witnesses = client.witnesses().await?; + self.session = Some(Session { client, witnesses }); + } + Ok(self.session.as_mut().expect("opened above")) + } +} + +/// Whether a provider's recent set can rule a receiver out at `now`: its feed's last +/// read began at most [`RECENT_SET_MAX_AGE_SECS`] before `now`, and its window, which +/// reaches back from that read through the time the feed was running, covers +/// [`RESTORE_WATCH_SECS`]. +fn covers_restore_watch(set: &FilterSet, now: i64) -> bool { + let window = set.window_secs.and_then(|w| i64::try_from(w).ok()); + match (window, set.since_unix, set.until_unix) { + (Some(window), Some(since), Some(until)) => { + window >= RESTORE_WATCH_SECS + && since <= until.saturating_sub(window) + && until >= now.saturating_sub(RECENT_SET_MAX_AGE_SECS) + } + _ => false, + } +} + +/// `accounts`' due sweeps at `through`, a bounded batch per account. +fn due( + wallet: &mut W, + accounts: &[AccountId], + through: ChainPoint, + lock: &L, + now: i64, +) -> Result, Error>> { + let mut work = Vec::new(); + for &account in accounts { + let batch = lock + .write("dynamic_sweep.due", || { + wallet.prepare_dynamic_sweeps(account, through, now, BATCH) + }) + .map_err(Error::Wallet)? + .map_err(Error::Deferred)?; + work.extend(batch.into_iter().map(|item| (account, item))); + } + Ok(work) +} + +/// Sweeps one key: leases an attempt, looks up its receiver if the paid set holds it +/// and no earlier lookup is queued, queues the new payments with their note data in +/// batches, and applies them with the publication's witnesses. `lookups` is the +/// batch's lookup count, which sizes a session that this key opens. +#[allow(clippy::too_many_arguments)] +async fn sweep_key( + wallet: &mut W, + directory: &mut Directory<'_, T>, + account: AccountId, + item: &DiscoveryWork, + check: Check, + lookups: usize, + through: ChainPoint, + notes: &mut N, + lock: &L, + now: i64, +) -> Result<(), Error>> +where + W: DynamicIvkWrite, + T: Transport, + N: NoteSource, + L: WriteLock, +{ + let key = item.key; + let anchor = directory.anchor; + lock.write("dynamic_sweep.attempt", || { + wallet.begin_dynamic_sweep_attempt(account, key, now) + }) + .map_err(Error::Wallet)?; + if item.lookup.is_none() { + let payments: Vec<_> = if check.paid { + let receiver = Receiver::from_bytes(item.receiver).map_err(DirectoryError::from)?; + let accepted = directory.accepted; + let session = directory.session(lookups).await?; + session + .client + .lookup(receiver, accepted) + .await? + .into_iter() + .map(directory_payment) + .collect() + } else { + Vec::new() + }; + loop { + let batch: Vec<_> = wallet + .directory_note_data_needed(account, key, &payments) + .map_err(Error::Wallet)? + .into_iter() + .take(NOTE_BATCH) + .collect(); + let requested = batch.len(); + let note_data = notes.note_data(wallet, batch).await?; + let done = lock + .write("dynamic_sweep.queue", || { + wallet.queue_directory_lookup(account, key, anchor, &payments, ¬e_data) + }) + .map_err(Error::Wallet)? + .map_err(Error::Deferred)?; + if done { + break; + } + if note_data.len() < requested { + return Err(Error::Unavailable( + "note data is missing for a directory payment", + )); + } + } + } + let witnesses = if check.paid || item.lookup.is_some() { + Some(&directory.session(lookups).await?.witnesses) + } else { + None + }; + let applied = lock + .write("dynamic_sweep.apply", || { + wallet.apply_dynamic_sweep( + account, + key, + through, + anchor, + check.provider, + |position, cmx| witnesses.and_then(|w| w.path(position, cmx).ok()), + ) + }) + .map_err(Error::Wallet)? + .map_err(Error::Deferred)?; + match applied { + PaymentApplication::Applied => Ok(()), + waiting => Err(Error::Queued(waiting)), + } +} + +/// A directory payment as the wallet takes it. +fn directory_payment(payment: receiver_directory::Payment) -> DirectoryPayment { + DirectoryPayment { + height: payment.height, + block_hash: payment.block_hash, + txid: payment.txid, + tx_index: payment.tx_index, + action_index: payment.action_index, + position: payment.position, + action_nullifier: payment.action_nullifier, + cmx: payment.cmx, + ephemeral_key: payment.ephemeral_key, + ciphertext_prefix: payment.ciphertext_prefix, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// A recent set of `window_secs` from a feed that started at `since_unix` and last + /// read the provider at `until_unix`. + fn recent(window_secs: Option, since_unix: Option, until_unix: i64) -> FilterSet { + FilterSet { + label: "near-intents/recent".into(), + count: 0, + window_secs, + since_unix, + until_unix: Some(until_unix), + } + } + + #[test] + fn a_recent_set_rules_receivers_out_only_when_current_across_the_whole_watch() { + let now = 10 * RESTORE_WATCH_SECS; + let watch = RESTORE_WATCH_SECS as u64; + let read = now - 60; + let started = Some(read - RESTORE_WATCH_SECS); + assert!(covers_restore_watch( + &recent(Some(watch), started, read), + now + )); + assert!(covers_restore_watch( + &recent(Some(2 * watch), Some(0), read), + now + )); + // A shorter window, or a feed younger than the window, can miss a quote. + assert!(!covers_restore_watch( + &recent(Some(watch - 1), started, read), + now + )); + assert!(!covers_restore_watch( + &recent(Some(watch), Some(read - 60), read), + now + )); + assert!(!covers_restore_watch(&recent(None, started, read), now)); + assert!(!covers_restore_watch(&recent(Some(watch), None, read), now)); + // So can a set whose feed stalled: quotes after its last read are missing. + let stalled = now - RECENT_SET_MAX_AGE_SECS - 1; + let started = Some(stalled - RESTORE_WATCH_SECS); + assert!(!covers_restore_watch( + &recent(Some(watch), started, stalled), + now + )); + let mut undated = recent(Some(watch), started, read); + undated.until_unix = None; + assert!(!covers_restore_watch(&undated, now)); + } +} diff --git a/zakura/pir-receiver/tests/sweep.rs b/zakura/pir-receiver/tests/sweep.rs new file mode 100644 index 00000000..b1c7316e --- /dev/null +++ b/zakura/pir-receiver/tests/sweep.rs @@ -0,0 +1,793 @@ +//! Restore sweeps end to end: a restored wallet looks up its dynamic keys in a receiver +//! directory served in process, over the service's own routes and real PIR, and +//! imports what it finds only after checking it against its own chain. + +use std::{collections::BTreeMap, sync::Mutex}; + +use axum::{Router, body::Body, http::Request}; +use orchard::{ + keys::{FullViewingKey, Scope}, + note_encryption::{CompactAction, IronwoodDomain, IronwoodNoteEncryption}, +}; +use receiver_directory::{ + Payment, Receiver, Record, + snapshot::{Manifest, PROFILE, ProviderSet, Snapshot}, + witness::WitnessSnapshot, +}; +use receiver_pir::{MIN_ROWS, server::Server}; +use receiver_pir_server::{Publication, Publications, router_with_publications}; +use tower::ServiceExt as _; +use zakura_dynamic_ivk::lifecycle::RESTORE_WATCH_SECS; +use zakura_pir_enhance::{ + ClientError, + transport::{Request as EnhanceRequest, ResponseBody, Transport as EnhanceTransport}, +}; +use zakura_pir_receiver::{ + DirectoryError, EnhanceNotes, Error, NoteSource, Transport, WriteLock, fetch_seen, sweep, +}; +use zcash_client_backend::data_api::{ + Account as _, WalletRead as _, + chain::BlockSource as _, + dynamic_ivk::{DynamicIvkRead, DynamicIvkWrite as _}, + testing::{AddressType, IronwoodFvk, TestBuilder, TestState}, + transparent_ledger::ChainPoint, +}; +use zcash_client_sqlite::{ + AccountUuid, + testing::{ + BlockCache, + db::{TestDb, TestDbFactory}, + }, + wallet::dynamic_ivk::{KeyId, ProviderSeen, Purpose, RECEIVE_GAP_LIMIT}, +}; +use zcash_note_encryption::{Domain as _, try_compact_note_decryption}; +use zcash_primitives::block::BlockHash; +use zcash_protocol::{consensus::BlockHeight, local_consensus::LocalNetwork, value::Zatoshis}; + +type State = TestState; + +/// The genesis hash the fixture's publications commit to. +const GENESIS: [u8; 32] = [9; 32]; +/// The origin requests name. The transport serves them in process. +const ORIGIN: &str = "https://receiver.test"; +/// Ironwood activation, the history every publication must cover. +const ACTIVATION: u32 = 100_000; +/// The caller's clock. +const NOW: i64 = 1_700_000_000; +/// The payout's value. +const VALUE: u64 = 100_000; + +/// A wallet with Ironwood active from its first block. +fn ironwood_wallet() -> State { + let activation = BlockHeight::from_u32(ACTIVATION); + TestBuilder::new() + .with_network(LocalNetwork { + nu6: Some(activation), + nu6_1: Some(activation), + nu6_2: Some(activation), + nu6_3: Some(activation), + ..TestBuilder::<(), ()>::DEFAULT_NETWORK + }) + .with_data_store_factory(TestDbFactory::default()) + .with_block_cache(BlockCache::new()) + .with_account_from_sapling_activation(BlockHash([0; 32])) + .build() +} + +/// The wallet's fully scanned tip. +fn tip(st: &State) -> ChainPoint { + let block = st.wallet().block_fully_scanned().unwrap().unwrap(); + ChainPoint { + height: block.block_height(), + hash: block.block_hash(), + } +} + +/// Pays incoming dynamic key `index` in a new scanned block, before any restore has +/// registered the key, so scanning cannot find it. Returns the payment as the +/// directory lists it and its note data. The output is the chain's Ironwood leaf at +/// `position`, which is the number of earlier Ironwood outputs. +fn pay_restored_key(st: &mut State, index: u64, position: u64) -> (Record, [u8; 528]) { + let parent = FullViewingKey::from(st.test_account().unwrap().usk().orchard()); + let fvk = KeyId::new(Purpose::Receive, index).derive(&parent).unwrap(); + let (height, _, _) = st.generate_next_block( + &IronwoodFvk(fvk.clone()), + AddressType::DefaultExternal, + Zatoshis::const_from_u64(VALUE), + ); + st.scan_cached_blocks_with_dynamic_ivks(height, 1); + let mut block = None; + st.cache() + .with_blocks::<_, ()>(Some(height), Some(1), |cb| { + block = Some(cb); + Ok(()) + }) + .unwrap(); + let block = block.unwrap(); + let tx = &block.vtx[0]; + let compact = &tx.ironwood_actions[0]; + let action = CompactAction::try_from(compact).unwrap(); + let (note, _) = try_compact_note_decryption( + &IronwoodDomain::for_compact_action(&action), + &fvk.to_ivk(Scope::External).prepare(), + &action, + ) + .unwrap(); + // Encrypting the note again reproduces the output, with a known memo. + let encryption = IronwoodNoteEncryption::new(None, note, [4; 512]); + let ciphertext = encryption.encrypt_note_plaintext(); + assert_eq!(&ciphertext[..52], compact.ciphertext.as_slice()); + let receiver = fvk.address_at(0u32, Scope::External).to_raw_address_bytes(); + let record = Record { + receiver: Receiver::from_bytes(receiver).unwrap(), + page: 0, + total: 1, + payment: Payment { + height: u32::from(height), + block_hash: block.hash().0, + txid: *tx.txid().as_ref(), + tx_index: tx.index.try_into().unwrap(), + action_index: 0, + position, + action_nullifier: compact.nullifier.as_slice().try_into().unwrap(), + cmx: compact.cmx.as_slice().try_into().unwrap(), + ephemeral_key: IronwoodDomain::epk_bytes(encryption.epk()).0, + ciphertext_prefix: ciphertext[..52].try_into().unwrap(), + }, + }; + (record, ciphertext[52..].try_into().unwrap()) +} + +/// A swap provider's sets: the receivers it was given within the wallet's restore watch, +/// and every receiver it ever had, from a feed that started long before [`NOW`] and last +/// read the provider a minute before it. +fn provider(recent: &[Receiver], seen: &[Receiver]) -> Vec { + let since_unix = NOW - 2 * RESTORE_WATCH_SECS; + let until_unix = NOW - 60; + vec![ + ProviderSet { + label: "near-intents/recent".into(), + window_secs: Some(RESTORE_WATCH_SECS.try_into().unwrap()), + since_unix, + until_unix, + receivers: recent.to_vec(), + }, + ProviderSet { + label: "near-intents/seen".into(), + window_secs: None, + since_unix, + until_unix, + receivers: seen.to_vec(), + }, + ] +} + +/// A publication of `records` ending at `end`, with the common witnesses over the +/// chain's Ironwood `commitments`, if any, and the swap `provider` sets in its filters, +/// served by the receiver service's routes. +fn publish( + records: &[Record], + commitments: &[[u8; 32]], + end: ChainPoint, + provider: &[ProviderSet], +) -> Router { + let manifest = Manifest { + profile: PROFILE.into(), + genesis: GENESIS, + start_height: ACTIVATION, + start_parent: [0; 32], + start_position: 0, + end_height: end.height.into(), + end_hash: end.hash.0, + end_position: commitments.len() as u64, + rows: MIN_ROWS, + salt: [5; 32], + records: 0, + data_sha256: [0; 32], + filters: Vec::new(), + filters_sha256: [0; 32], + }; + let snapshot = Snapshot::build(manifest, records, provider).unwrap(); + let positions = records + .iter() + .map(|record| u32::try_from(record.payment.position).unwrap()) + .collect(); + // A chain without Ironwood outputs has no witnesses to serve. + let witnesses = (!commitments.is_empty()).then(|| { + WitnessSnapshot::build(&snapshot.manifest, commitments, &positions) + .unwrap() + .encode() + }); + let publication = Publication::new(Server::new(snapshot).unwrap(), witnesses).unwrap(); + let publications = Publications::default(); + assert!(publications.publish(publication, publications.epoch())); + router_with_publications(publications) +} + +/// Serves a directory's routes in process and records every request. +struct Directory { + router: Router, + requests: Mutex>, +} + +impl Directory { + /// Serves `router` at [`ORIGIN`]. + fn new(router: Router) -> Self { + Self { + router, + requests: Mutex::new(Vec::new()), + } + } + + /// Every request so far, as method and path. + fn requests(&self) -> Vec { + self.requests.lock().unwrap().clone() + } + + /// Sends one request to the routes, failing over `limit` bytes as a transport must. + async fn send( + &self, + method: &str, + url: &str, + body: Vec, + limit: usize, + ) -> Result, DirectoryError> { + let path = url + .strip_prefix(ORIGIN) + .expect("requests stay on the origin"); + self.requests + .lock() + .unwrap() + .push(format!("{method} {path}")); + let request = Request::builder() + .method(method) + .uri(path) + .body(Body::from(body)) + .unwrap(); + let response = self.router.clone().oneshot(request).await.unwrap(); + let status = response.status().as_u16(); + let body = axum::body::to_bytes(response.into_body(), limit) + .await + .map_err(|_| DirectoryError::Transport("response over its limit".into()))?; + match status { + 200 => Ok(body.to_vec()), + 409 | 410 => Err(DirectoryError::Revision), + status => Err(DirectoryError::Transport(format!("HTTP {status}"))), + } + } +} + +impl Transport for Directory { + async fn get(&self, url: &str, limit: usize) -> Result, DirectoryError> { + self.send("GET", url, Vec::new(), limit).await + } + + async fn post( + &self, + url: &str, + body: Vec, + limit: usize, + ) -> Result, DirectoryError> { + self.send("POST", url, body, limit).await + } +} + +/// Note data from a fixed map, recording each request that needs some. +struct Notes { + data: BTreeMap, + requested: Vec>, +} + +impl NoteSource for Notes { + async fn note_data( + &mut self, + _: &W, + positions: Vec, + ) -> Result, Error> { + if !positions.is_empty() { + self.requested.push(positions.clone()); + } + Ok(positions + .into_iter() + .filter_map(|position| Some((position, *self.data.get(&position)?))) + .collect()) + } +} + +/// A test needs no lock: nothing else writes to its wallet. +struct NoLock; + +impl WriteLock for NoLock { + fn write(&self, _: &'static str, write: impl FnOnce() -> T) -> T { + write() + } +} + +/// Registers the restored incoming lookahead, as a restore does at the tip. +fn restore(st: &mut State, account: AccountUuid) { + st.wallet_mut() + .db_mut() + .maintain_dynamic_ivks(account) + .unwrap(); +} + +#[tokio::test] +async fn a_restore_sweep_finds_a_payout_and_moves_the_lookahead_past_it() { + let mut st = ironwood_wallet(); + let network = *st.network(); + let account = st.test_account().unwrap().id(); + let paid = 3; + let (record, note_data) = pay_restored_key(&mut st, paid, 0); + st.generate_and_scan_empty_blocks_with_dynamic_ivks(1); + restore(&mut st, account); + let through = tip(&st); + assert_eq!(st.get_total_balance(account), Zatoshis::ZERO); + let directory = Directory::new(publish( + std::slice::from_ref(&record), + &[record.payment.cmx], + through, + &provider(&[], &[]), + )); + let mut notes = Notes { + data: BTreeMap::from([(record.payment.position, note_data)]), + requested: Vec::new(), + }; + let swept = sweep( + st.wallet_mut().db_mut(), + &network, + &[account], + through, + GENESIS, + ORIGIN, + &directory, + &mut notes, + &NoLock, + NOW, + ) + .await + .unwrap(); + assert!(swept.deferred.is_empty(), "{:?}", swept.deferred); + assert!(!swept.pending); + // The first window, then the keys above it that the paid index brings in. + assert_eq!(swept.finished as u64, RECEIVE_GAP_LIMIT + paid + 1); + assert_eq!(notes.requested, [vec![record.payment.position]]); + assert_eq!( + st.get_total_balance(account), + Zatoshis::const_from_u64(VALUE) + ); + // Lookups and the common witnesses go over the service's routes only. + assert!( + directory + .requests() + .iter() + .all(|r| r.starts_with("GET /v1/receiver/") || r == "POST /v1/receiver/query"), + "{:?}", + directory.requests() + ); + + // A later run finds every sweep finished and asks the directory nothing. + let before = directory.requests(); + let swept = sweep( + st.wallet_mut().db_mut(), + &network, + &[account], + through, + GENESIS, + ORIGIN, + &directory, + &mut notes, + &NoLock, + NOW, + ) + .await + .unwrap(); + assert_eq!(swept.finished, 0); + assert!(!swept.pending); + assert_eq!(directory.requests(), before); +} + +#[tokio::test] +async fn a_key_paid_twice_is_imported_in_full() { + let mut st = ironwood_wallet(); + let network = *st.network(); + let account = st.test_account().unwrap().id(); + let paid = 2; + let (mut first, first_data) = pay_restored_key(&mut st, paid, 0); + let (mut second, second_data) = pay_restored_key(&mut st, paid, 1); + first.total = 2; + second.page = 1; + second.total = 2; + st.generate_and_scan_empty_blocks_with_dynamic_ivks(1); + restore(&mut st, account); + let through = tip(&st); + let directory = Directory::new(publish( + &[first.clone(), second.clone()], + &[first.payment.cmx, second.payment.cmx], + through, + &provider(&[], &[]), + )); + let mut notes = Notes { + data: BTreeMap::from([(0, first_data), (1, second_data)]), + requested: Vec::new(), + }; + let swept = sweep( + st.wallet_mut().db_mut(), + &network, + &[account], + through, + GENESIS, + ORIGIN, + &directory, + &mut notes, + &NoLock, + NOW, + ) + .await + .unwrap(); + assert!(swept.deferred.is_empty(), "{:?}", swept.deferred); + assert!(!swept.pending); + assert_eq!(notes.requested, [vec![0, 1]]); + assert_eq!( + st.get_total_balance(account), + Zatoshis::const_from_u64(2 * VALUE) + ); + // Two pages cost less over PIR than the row file. + let requests = directory.requests(); + assert!( + !requests + .iter() + .any(|r| r.starts_with("GET /v1/receiver/rows/")), + "{requests:?}" + ); +} + +#[tokio::test] +async fn a_long_history_takes_its_note_data_in_batches() { + let mut st = ironwood_wallet(); + let network = *st.network(); + let account = st.test_account().unwrap().id(); + let paid = 1; + // One more payment than a batch of note data holds. + let count = 65; + let mut records = Vec::new(); + let mut data = BTreeMap::new(); + for position in 0..count { + let (mut record, note_data) = pay_restored_key(&mut st, paid, position); + record.page = position.try_into().unwrap(); + record.total = count.try_into().unwrap(); + records.push(record); + data.insert(position, note_data); + } + st.generate_and_scan_empty_blocks_with_dynamic_ivks(1); + restore(&mut st, account); + let through = tip(&st); + let commitments: Vec<_> = records.iter().map(|record| record.payment.cmx).collect(); + let directory = Directory::new(publish( + &records, + &commitments, + through, + &provider(&[], &[]), + )); + let mut notes = Notes { + data, + requested: Vec::new(), + }; + let swept = sweep( + st.wallet_mut().db_mut(), + &network, + &[account], + through, + GENESIS, + ORIGIN, + &directory, + &mut notes, + &NoLock, + NOW, + ) + .await + .unwrap(); + assert!(swept.deferred.is_empty(), "{:?}", swept.deferred); + assert!(!swept.pending); + let batches: Vec<_> = notes.requested.iter().map(Vec::len).collect(); + assert_eq!(batches, [64, 1]); + assert_eq!( + st.get_total_balance(account), + Zatoshis::const_from_u64(count * VALUE) + ); +} + +#[tokio::test] +async fn a_wallet_without_payments_downloads_only_the_filters() { + let mut st = ironwood_wallet(); + let network = *st.network(); + let account = st.test_account().unwrap().id(); + st.generate_and_scan_empty_blocks_with_dynamic_ivks(1); + restore(&mut st, account); + let through = tip(&st); + let directory = Directory::new(publish(&[], &[], through, &provider(&[], &[]))); + let mut notes = Notes { + data: BTreeMap::new(), + requested: Vec::new(), + }; + let swept = sweep( + st.wallet_mut().db_mut(), + &network, + &[account], + through, + GENESIS, + ORIGIN, + &directory, + &mut notes, + &NoLock, + NOW, + ) + .await + .unwrap(); + assert!(swept.deferred.is_empty(), "{:?}", swept.deferred); + assert_eq!(swept.finished as u64, RECEIVE_GAP_LIMIT); + assert!(!swept.pending); + // No PIR session, witnesses or note data: one manifest and one filter file. + let requests = directory.requests(); + assert_eq!(requests.len(), 2, "{requests:?}"); + assert!(requests[1].starts_with("GET /v1/receiver/filters/")); + assert!(notes.requested.is_empty()); + // No swap reached these addresses recently, so none keeps scanning. + assert!(st.wallet().get_dynamic_scanning_keys().unwrap().is_empty()); +} + +#[tokio::test] +async fn only_recently_quoted_addresses_keep_scanning_after_their_sweep() { + let mut st = ironwood_wallet(); + let network = *st.network(); + let account = st.test_account().unwrap().id(); + st.generate_and_scan_empty_blocks_with_dynamic_ivks(1); + restore(&mut st, account); + let through = tip(&st); + let parent = FullViewingKey::from(st.test_account().unwrap().usk().orchard()); + let receiver = |key: KeyId| { + let address = key + .derive(&parent) + .unwrap() + .address_at(0u32, Scope::External); + Receiver::from_bytes(address.to_raw_address_bytes()).unwrap() + }; + // The old device quoted addresses 0 and 2, and 2 only today. + let (old, quoted) = ( + KeyId::new(Purpose::Receive, 0), + KeyId::new(Purpose::Receive, 2), + ); + let directory = Directory::new(publish( + &[], + &[], + through, + &provider(&[receiver(quoted)], &[receiver(old), receiver(quoted)]), + )); + let mut notes = Notes { + data: BTreeMap::new(), + requested: Vec::new(), + }; + let swept = sweep( + st.wallet_mut().db_mut(), + &network, + &[account], + through, + GENESIS, + ORIGIN, + &directory, + &mut notes, + &NoLock, + NOW, + ) + .await + .unwrap(); + assert!(swept.deferred.is_empty(), "{:?}", swept.deferred); + let scanning: Vec<_> = st + .wallet() + .get_dynamic_scanning_keys() + .unwrap() + .iter() + .map(|k| k.key_id()) + .collect(); + assert_eq!(scanning, [quoted]); + // Issuance reads the same seen set, skips both, and takes the lowest address the + // provider never had. + let seen = fetch_seen(ORIGIN, &directory).await.unwrap(); + assert_eq!( + (seen.since(), seen.until()), + (NOW - 2 * RESTORE_WATCH_SECS, NOW - 60) + ); + let bytes = |key: KeyId| *receiver(key).as_bytes(); + assert_eq!( + seen.contains(&[bytes(old), bytes(KeyId::new(Purpose::Receive, 1))]), + [true, false] + ); + let contains = |receivers: &[[u8; 43]]| seen.contains(receivers); + let view = ProviderSeen { + since: seen.since(), + until: seen.until(), + contains: &contains, + }; + let issued = st + .wallet_mut() + .db_mut() + .prepare_receive_reservation(account, NOW, through.height, Some(&view)) + .unwrap() + .unwrap(); + assert_eq!(issued.key_id(), KeyId::new(Purpose::Receive, 1)); +} + +#[tokio::test] +async fn every_address_keeps_scanning_when_the_recent_set_falls_short_of_the_watch() { + let mut st = ironwood_wallet(); + let network = *st.network(); + let account = st.test_account().unwrap().id(); + st.generate_and_scan_empty_blocks_with_dynamic_ivks(1); + restore(&mut st, account); + let through = tip(&st); + // A swap quoted two hours ago is outside a one-hour recent set. + let mut sets = provider(&[], &[]); + sets[0].window_secs = Some(3600); + let directory = Directory::new(publish(&[], &[], through, &sets)); + let mut notes = Notes { + data: BTreeMap::new(), + requested: Vec::new(), + }; + let swept = sweep( + st.wallet_mut().db_mut(), + &network, + &[account], + through, + GENESIS, + ORIGIN, + &directory, + &mut notes, + &NoLock, + NOW, + ) + .await + .unwrap(); + assert!(swept.deferred.is_empty(), "{:?}", swept.deferred); + let scanning = st.wallet().get_dynamic_scanning_keys().unwrap().len(); + assert_eq!(scanning as u64, RECEIVE_GAP_LIMIT); +} + +/// A directory whose queries fail after it accepted the session. +struct FailingQueries(Directory); + +impl Transport for FailingQueries { + async fn get(&self, url: &str, limit: usize) -> Result, DirectoryError> { + self.0.get(url, limit).await + } + + async fn post(&self, url: &str, _: Vec, _: usize) -> Result, DirectoryError> { + self.0.requests.lock().unwrap().push(format!("POST {url}")); + Err(DirectoryError::Revision) + } +} + +#[tokio::test] +async fn a_revoked_session_stops_the_run_before_leasing_more_keys() { + let mut st = ironwood_wallet(); + let network = *st.network(); + let account = st.test_account().unwrap().id(); + // The first key in the batch is paid, so it needs the failing lookup. + let (record, _) = pay_restored_key(&mut st, 0, 0); + st.generate_and_scan_empty_blocks_with_dynamic_ivks(1); + restore(&mut st, account); + let through = tip(&st); + let directory = FailingQueries(Directory::new(publish( + std::slice::from_ref(&record), + &[record.payment.cmx], + through, + &provider(&[], &[]), + ))); + let mut notes = Notes { + data: BTreeMap::new(), + requested: Vec::new(), + }; + let swept = sweep( + st.wallet_mut().db_mut(), + &network, + &[account], + through, + GENESIS, + ORIGIN, + &directory, + &mut notes, + &NoLock, + NOW, + ) + .await + .unwrap(); + assert_eq!(swept.finished, 0); + assert_eq!(swept.deferred.len(), 1); + assert!(swept.pending); + let posts = directory + .0 + .requests() + .iter() + .filter(|r| r.starts_with("POST")) + .count(); + assert_eq!(posts, 1); +} + +#[tokio::test] +async fn a_publication_off_the_wallets_chain_is_refused_before_any_lookup() { + let mut st = ironwood_wallet(); + let network = *st.network(); + let account = st.test_account().unwrap().id(); + let (record, note_data) = pay_restored_key(&mut st, 0, 0); + st.generate_and_scan_empty_blocks_with_dynamic_ivks(1); + restore(&mut st, account); + let through = tip(&st); + let other = ChainPoint { + hash: BlockHash([1; 32]), + ..through + }; + let directory = Directory::new(publish( + std::slice::from_ref(&record), + &[record.payment.cmx], + other, + &provider(&[], &[]), + )); + let mut notes = Notes { + data: BTreeMap::from([(record.payment.position, note_data)]), + requested: Vec::new(), + }; + let result = sweep( + st.wallet_mut().db_mut(), + &network, + &[account], + through, + GENESIS, + ORIGIN, + &directory, + &mut notes, + &NoLock, + NOW, + ) + .await; + assert!(matches!(result, Err(Error::Directory(_))), "{result:?}"); + assert_eq!(directory.requests(), ["GET /v1/receiver/init"]); + assert!(notes.requested.is_empty()); + assert!( + st.wallet() + .db() + .dynamic_history_pending(account, through.height) + .unwrap() + ); + // Every due key backed off, so the directory is not asked again until a minute on. + for (now, requests) in [(NOW, 1), (NOW + 60, 2)] { + let _ = sweep( + st.wallet_mut().db_mut(), + &network, + &[account], + through, + GENESIS, + ORIGIN, + &directory, + &mut notes, + &NoLock, + now, + ) + .await; + assert_eq!(directory.requests().len(), requests); + } +} + +/// An Enhance transport that must not be used. +struct NoEnhance; + +impl EnhanceTransport for NoEnhance { + async fn execute(&self, _: EnhanceRequest) -> Result { + panic!("no Enhance request was needed"); + } +} + +#[tokio::test] +async fn enhance_notes_open_no_session_until_a_payment_needs_note_data() { + let st = ironwood_wallet(); + let mut notes = EnhanceNotes::new("https://enhance.test", &NoEnhance, st.network()); + let data = notes.note_data(st.wallet().db(), Vec::new()).await.unwrap(); + assert!(data.is_empty()); +} From 06685a8428614c7d7a1df051e6ab2afb8314e161 Mon Sep 17 00:00:00 2001 From: Adam Tucker Date: Fri, 9 Oct 2026 18:03:32 -0600 Subject: [PATCH 02/40] Pin the receiver crates to wallet-pir's main The receiver stack has merged into wallet-pir's main at 0d6ad4aa, so pin receiver-directory, receiver-pir and receiver-pir-server there instead of the open stack's 502d930e. The lock moves only the wallet-pir packages those crates use. The merged directory refuses a payment at transaction index 0 as a coinbase, which it excludes, so the sweep fixture's mock block, which has no coinbase, now places its payment after an implicit one. Co-Authored-By: Claude Opus 5.5 --- Cargo.lock | 18 +++++++++--------- zakura/pir-receiver/Cargo.toml | 10 +++++----- zakura/pir-receiver/tests/sweep.rs | 3 ++- 3 files changed, 16 insertions(+), 15 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 2c9cb727..56a04b10 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3726,7 +3726,7 @@ dependencies = [ [[package]] name = "pir-control" version = "0.1.0" -source = "git+https://github.com/valargroup/wallet-pir?rev=502d930eaa8acdd3ed43c9ed73b9310168de893c#502d930eaa8acdd3ed43c9ed73b9310168de893c" +source = "git+https://github.com/valargroup/wallet-pir?rev=0d6ad4aa94abbb2838d4182c869f06e1881e6c35#0d6ad4aa94abbb2838d4182c869f06e1881e6c35" dependencies = [ "axum", "hex", @@ -3751,7 +3751,7 @@ dependencies = [ [[package]] name = "pir-native" version = "0.1.0" -source = "git+https://github.com/valargroup/wallet-pir?rev=502d930eaa8acdd3ed43c9ed73b9310168de893c#502d930eaa8acdd3ed43c9ed73b9310168de893c" +source = "git+https://github.com/valargroup/wallet-pir?rev=0d6ad4aa94abbb2838d4182c869f06e1881e6c35#0d6ad4aa94abbb2838d4182c869f06e1881e6c35" dependencies = [ "ipir-sp 0.1.0-rc.6 (git+https://github.com/valargroup/ipir-sp.git?tag=v0.1.0-rc.6)", "rand 0.8.8", @@ -3773,7 +3773,7 @@ dependencies = [ [[package]] name = "pir-observability" version = "0.1.0" -source = "git+https://github.com/valargroup/wallet-pir?rev=502d930eaa8acdd3ed43c9ed73b9310168de893c#502d930eaa8acdd3ed43c9ed73b9310168de893c" +source = "git+https://github.com/valargroup/wallet-pir?rev=0d6ad4aa94abbb2838d4182c869f06e1881e6c35#0d6ad4aa94abbb2838d4182c869f06e1881e6c35" dependencies = [ "axum", "http-body", @@ -4300,7 +4300,7 @@ dependencies = [ [[package]] name = "receiver-directory" version = "0.0.1" -source = "git+https://github.com/valargroup/wallet-pir?rev=502d930eaa8acdd3ed43c9ed73b9310168de893c#502d930eaa8acdd3ed43c9ed73b9310168de893c" +source = "git+https://github.com/valargroup/wallet-pir?rev=0d6ad4aa94abbb2838d4182c869f06e1881e6c35#0d6ad4aa94abbb2838d4182c869f06e1881e6c35" dependencies = [ "bitcoin", "incrementalmerkletree", @@ -4315,11 +4315,11 @@ dependencies = [ [[package]] name = "receiver-pir" version = "0.0.1" -source = "git+https://github.com/valargroup/wallet-pir?rev=502d930eaa8acdd3ed43c9ed73b9310168de893c#502d930eaa8acdd3ed43c9ed73b9310168de893c" +source = "git+https://github.com/valargroup/wallet-pir?rev=0d6ad4aa94abbb2838d4182c869f06e1881e6c35#0d6ad4aa94abbb2838d4182c869f06e1881e6c35" dependencies = [ "hex", "ipir-sp 0.1.0-rc.6 (git+https://github.com/valargroup/ipir-sp.git?tag=v0.1.0-rc.6)", - "pir-native 0.1.0 (git+https://github.com/valargroup/wallet-pir?rev=502d930eaa8acdd3ed43c9ed73b9310168de893c)", + "pir-native 0.1.0 (git+https://github.com/valargroup/wallet-pir?rev=0d6ad4aa94abbb2838d4182c869f06e1881e6c35)", "rand 0.8.8", "receiver-directory", "serde", @@ -4331,12 +4331,12 @@ dependencies = [ [[package]] name = "receiver-pir-server" version = "0.0.1" -source = "git+https://github.com/valargroup/wallet-pir?rev=502d930eaa8acdd3ed43c9ed73b9310168de893c#502d930eaa8acdd3ed43c9ed73b9310168de893c" +source = "git+https://github.com/valargroup/wallet-pir?rev=0d6ad4aa94abbb2838d4182c869f06e1881e6c35#0d6ad4aa94abbb2838d4182c869f06e1881e6c35" dependencies = [ "axum", "hex", - "pir-control 0.1.0 (git+https://github.com/valargroup/wallet-pir?rev=502d930eaa8acdd3ed43c9ed73b9310168de893c)", - "pir-observability 0.1.0 (git+https://github.com/valargroup/wallet-pir?rev=502d930eaa8acdd3ed43c9ed73b9310168de893c)", + "pir-control 0.1.0 (git+https://github.com/valargroup/wallet-pir?rev=0d6ad4aa94abbb2838d4182c869f06e1881e6c35)", + "pir-observability 0.1.0 (git+https://github.com/valargroup/wallet-pir?rev=0d6ad4aa94abbb2838d4182c869f06e1881e6c35)", "receiver-directory", "receiver-pir", "serde_json", diff --git a/zakura/pir-receiver/Cargo.toml b/zakura/pir-receiver/Cargo.toml index a929cc44..68c256e2 100644 --- a/zakura/pir-receiver/Cargo.toml +++ b/zakura/pir-receiver/Cargo.toml @@ -21,15 +21,15 @@ zakura-pir-enhance = { version = "=0.0.1-rc1", path = "../pir-enhance", features zakura-dynamic-ivk = { version = "0.0.0", path = "../dynamic-ivk", optional = true } zcash_client_backend = { workspace = true, features = ["orchard"], optional = true } zcash_protocol = { workspace = true, optional = true } -# The receiver crates come from wallet-pir's open receiver stack until they land on its `main`. -receiver-directory = { git = "https://github.com/valargroup/wallet-pir", rev = "502d930eaa8acdd3ed43c9ed73b9310168de893c" } -receiver-pir = { git = "https://github.com/valargroup/wallet-pir", rev = "502d930eaa8acdd3ed43c9ed73b9310168de893c" } +# The receiver crates come from wallet-pir's `main`. +receiver-directory = { git = "https://github.com/valargroup/wallet-pir", rev = "0d6ad4aa94abbb2838d4182c869f06e1881e6c35" } +receiver-pir = { git = "https://github.com/valargroup/wallet-pir", rev = "0d6ad4aa94abbb2838d4182c869f06e1881e6c35" } [dev-dependencies] axum = "0.7" orchard.workspace = true -receiver-pir = { git = "https://github.com/valargroup/wallet-pir", rev = "502d930eaa8acdd3ed43c9ed73b9310168de893c", features = ["server"] } -receiver-pir-server = { git = "https://github.com/valargroup/wallet-pir", rev = "502d930eaa8acdd3ed43c9ed73b9310168de893c" } +receiver-pir = { git = "https://github.com/valargroup/wallet-pir", rev = "0d6ad4aa94abbb2838d4182c869f06e1881e6c35", features = ["server"] } +receiver-pir-server = { git = "https://github.com/valargroup/wallet-pir", rev = "0d6ad4aa94abbb2838d4182c869f06e1881e6c35" } tokio = { workspace = true, features = ["macros", "rt"] } tower = { workspace = true, features = ["util"] } zcash_client_backend = { workspace = true, features = ["orchard", "test-dependencies"] } diff --git a/zakura/pir-receiver/tests/sweep.rs b/zakura/pir-receiver/tests/sweep.rs index b1c7316e..da1f74f6 100644 --- a/zakura/pir-receiver/tests/sweep.rs +++ b/zakura/pir-receiver/tests/sweep.rs @@ -126,7 +126,8 @@ fn pay_restored_key(st: &mut State, index: u64, position: u64) -> (Record, [u8; height: u32::from(height), block_hash: block.hash().0, txid: *tx.txid().as_ref(), - tx_index: tx.index.try_into().unwrap(), + // The mock block has no coinbase; the directory refuses index 0 as one. + tx_index: u32::try_from(tx.index).unwrap() + 1, action_index: 0, position, action_nullifier: compact.nullifier.as_slice().try_into().unwrap(), From 768944928a718ca383ad2e336ee8a03865bdb0b8 Mon Sep 17 00:00:00 2001 From: Adam Tucker Date: Sat, 10 Oct 2026 00:53:08 -0600 Subject: [PATCH 03/40] Require a current recent set from every provider A sweep let receivers stop scanning once any provider's recent set was current and complete. A publication with a complete `a/recent` but only `b/seen` therefore ruled out receivers that provider `b` may have quoted recently, and a payment to one after the publication anchor would go unscanned. Track completeness per provider prefix: every provider with a `recent` or `seen` set needs a current, complete recent set, otherwise every key keeps scanning. Hit matching still combines the sets. Co-Authored-By: Claude Opus 5.5 --- zakura/pir-receiver/README.md | 8 ++--- zakura/pir-receiver/src/sweep.rs | 38 +++++++++++--------- zakura/pir-receiver/tests/sweep.rs | 58 ++++++++++++++++++++++++++++-- 3 files changed, 82 insertions(+), 22 deletions(-) diff --git a/zakura/pir-receiver/README.md b/zakura/pir-receiver/README.md index 735dc51a..9ac26e75 100644 --- a/zakura/pir-receiver/README.md +++ b/zakura/pir-receiver/README.md @@ -11,8 +11,8 @@ chain. A swap provider's sets decide what happens to the key afterwards: one the provider was given within the wallet's restore watch keeps scanning after its sweep, and one it ever had is marked seen, so issuance does not hand it out again. A provider's recent set is trusted only if its feed read the provider within the last -fifteen minutes and its window and feed history cover that watch; otherwise every key -keeps scanning. A run that finds nothing to look up opens +fifteen minutes and its window and feed history cover that watch. Unless every +provider with sets in the publication has such a recent set, every key keeps scanning. A run that finds nothing to look up opens no PIR session and fetches no witnesses. Applications supply: @@ -36,5 +36,5 @@ until they land on its `main`. routes and checks that a restored wallet finds and imports a payout, extends its lookahead past it, repeats no finished sweep, imports every payment of a key paid twice, makes no lookup when nothing was paid, keeps scanning only recently quoted -addresses, keeps every address scanning when the recent set cannot vouch for the -whole watch, and refuses a publication that is not on its chain before any lookup. +addresses, keeps every address scanning when a recent set cannot vouch for the +whole watch or a provider has none, and refuses a publication that is not on its chain before any lookup. diff --git a/zakura/pir-receiver/src/sweep.rs b/zakura/pir-receiver/src/sweep.rs index b750c29b..0343f05e 100644 --- a/zakura/pir-receiver/src/sweep.rs +++ b/zakura/pir-receiver/src/sweep.rs @@ -184,8 +184,9 @@ pub struct Swept { /// [`ProviderView`]). A provider's recent set can rule a receiver out only if it is /// current and complete: its feed's last read began at most fifteen minutes before /// `now`, and its window, with the feed running throughout, covers the wallet's own -/// restore watch, [`RESTORE_WATCH_SECS`]. If any recent set falls short, or the -/// publication has none, every key keeps scanning. +/// restore watch, [`RESTORE_WATCH_SECS`]. If any recent set falls short, a provider +/// with sets has no recent set, or the publication has no provider sets, every key +/// keeps scanning. /// /// A run that looks nothing up opens no PIR session and fetches no witnesses. New /// payments a lookup finds take their note data from `notes`. After each batch the @@ -400,26 +401,31 @@ impl<'a, T: Transport> Directory<'a, T> { provider: ProviderView::default(), }) .collect(); - let mut recent_complete = false; - let mut recent_incomplete = false; + // Each provider with a set, and whether its recent sets, if any, are all current + // and complete. + let mut providers = BTreeMap::<&str, Option>::new(); for set in &self.manifest.directory.filters { - let kind = set.label.split_once('/').map(|(_, kind)| kind); - if kind == Some(RECENT) { - if covers_restore_watch(set, now) { - recent_complete = true; - for (check, hit) in checks.iter_mut().zip(matches(&set.label)) { - check.provider.recent |= hit; + match set.label.split_once('/') { + Some((provider, RECENT)) => { + let covers = covers_restore_watch(set, now); + let complete = providers.entry(provider).or_default(); + *complete = Some(complete.unwrap_or(true) && covers); + if covers { + for (check, hit) in checks.iter_mut().zip(matches(&set.label)) { + check.provider.recent |= hit; + } } - } else { - recent_incomplete = true; } - } else if kind == Some(SEEN) { - for (check, hit) in checks.iter_mut().zip(matches(&set.label)) { - check.provider.seen |= hit; + Some((provider, SEEN)) => { + providers.entry(provider).or_default(); + for (check, hit) in checks.iter_mut().zip(matches(&set.label)) { + check.provider.seen |= hit; + } } + _ => {} } } - if recent_incomplete || !recent_complete { + if providers.is_empty() || providers.values().any(|&complete| complete != Some(true)) { for check in &mut checks { check.provider.recent = true; } diff --git a/zakura/pir-receiver/tests/sweep.rs b/zakura/pir-receiver/tests/sweep.rs index da1f74f6..089bd584 100644 --- a/zakura/pir-receiver/tests/sweep.rs +++ b/zakura/pir-receiver/tests/sweep.rs @@ -143,18 +143,23 @@ fn pay_restored_key(st: &mut State, index: u64, position: u64) -> (Record, [u8; /// and every receiver it ever had, from a feed that started long before [`NOW`] and last /// read the provider a minute before it. fn provider(recent: &[Receiver], seen: &[Receiver]) -> Vec { + provider_named("near-intents", recent, seen) +} + +/// [`provider`]'s sets for the provider `name`. +fn provider_named(name: &str, recent: &[Receiver], seen: &[Receiver]) -> Vec { let since_unix = NOW - 2 * RESTORE_WATCH_SECS; let until_unix = NOW - 60; vec![ ProviderSet { - label: "near-intents/recent".into(), + label: format!("{name}/recent"), window_secs: Some(RESTORE_WATCH_SECS.try_into().unwrap()), since_unix, until_unix, receivers: recent.to_vec(), }, ProviderSet { - label: "near-intents/seen".into(), + label: format!("{name}/seen"), window_secs: None, since_unix, until_unix, @@ -652,6 +657,55 @@ async fn every_address_keeps_scanning_when_the_recent_set_falls_short_of_the_wat assert_eq!(scanning as u64, RECEIVE_GAP_LIMIT); } +#[tokio::test] +async fn every_address_keeps_scanning_unless_each_provider_has_a_recent_set() { + for both_recent in [true, false] { + let mut st = ironwood_wallet(); + let network = *st.network(); + let account = st.test_account().unwrap().id(); + st.generate_and_scan_empty_blocks_with_dynamic_ivks(1); + restore(&mut st, account); + let through = tip(&st); + // Provider `b` publishes only its seen set when `both_recent` is false. + let mut sets = provider_named("a", &[], &[]); + let b = provider_named("b", &[], &[]); + sets.extend(b.into_iter().skip(usize::from(!both_recent))); + let directory = Directory::new(publish(&[], &[], through, &sets)); + let mut notes = Notes { + data: BTreeMap::new(), + requested: Vec::new(), + }; + let swept = sweep( + st.wallet_mut().db_mut(), + &network, + &[account], + through, + GENESIS, + ORIGIN, + &directory, + &mut notes, + &NoLock, + NOW, + ) + .await + .unwrap(); + assert!(swept.deferred.is_empty(), "{:?}", swept.deferred); + let scanning = st.wallet().get_dynamic_scanning_keys().unwrap().len() as u64; + if both_recent { + assert_eq!(scanning, 0); + } else { + assert_eq!(scanning, RECEIVE_GAP_LIMIT); + // `b` may have quoted any address, so a payment after the publication is + // still found by scanning. + pay_restored_key(&mut st, 0, 0); + assert_eq!( + st.get_total_balance(account), + Zatoshis::const_from_u64(VALUE) + ); + } + } +} + /// A directory whose queries fail after it accepted the session. struct FailingQueries(Directory); From 7b786b99c0f09cf9106ceac73a94a23c4af1e72a Mon Sep 17 00:00:00 2001 From: Adam Tucker Date: Sat, 10 Oct 2026 00:55:54 -0600 Subject: [PATCH 04/40] Refresh the Enhance routing before each note-data batch `EnhanceNotes` accepted an Enhance manifest once and reused the client. Enhance rejects new work once `refresh_due()`, 30 seconds after acceptance or for good after a 409 or 410, so a long restore deferred every later payment and reusing the same `EnhanceNotes` never recovered. Before each nonempty batch, when no client exists or a refresh is due, fetch the pending manifest and wallet-accept it: the first time through `PendingClient::accept`, then through `Client::accept_routing`, which clears expiry, rejects a routing rollback and keeps compatible sessions. A failed batch is not retried; the next call refreshes. The regression test serves an all-zero Enhance database whose first query answers 409 or 410, and checks that the next call refetches, re-accepts and succeeds. It reuses pir-enhance's synthetic manifest, which needs `sha2`, `hex`, `serde_json` and `base64` as dev-deps. Co-Authored-By: Claude Opus 5.5 --- Cargo.lock | 4 + zakura/pir-receiver/Cargo.toml | 4 + zakura/pir-receiver/README.md | 2 + zakura/pir-receiver/src/sweep.rs | 11 ++- zakura/pir-receiver/tests/sweep.rs | 125 ++++++++++++++++++++++++++++- 5 files changed, 140 insertions(+), 6 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 56a04b10..e4f33d02 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -8262,10 +8262,14 @@ name = "zakura-pir-receiver" version = "0.0.1-rc0" dependencies = [ "axum", + "base64 0.22.1", "futures-util", + "hex", "receiver-directory", "receiver-pir", "receiver-pir-server", + "serde_json", + "sha2 0.10.9", "thiserror 2.0.21", "tokio", "tower", diff --git a/zakura/pir-receiver/Cargo.toml b/zakura/pir-receiver/Cargo.toml index 68c256e2..68658620 100644 --- a/zakura/pir-receiver/Cargo.toml +++ b/zakura/pir-receiver/Cargo.toml @@ -27,9 +27,13 @@ receiver-pir = { git = "https://github.com/valargroup/wallet-pir", rev = "0d6ad4 [dev-dependencies] axum = "0.7" +base64.workspace = true +hex.workspace = true orchard.workspace = true receiver-pir = { git = "https://github.com/valargroup/wallet-pir", rev = "0d6ad4aa94abbb2838d4182c869f06e1881e6c35", features = ["server"] } receiver-pir-server = { git = "https://github.com/valargroup/wallet-pir", rev = "0d6ad4aa94abbb2838d4182c869f06e1881e6c35" } +serde_json.workspace = true +sha2.workspace = true tokio = { workspace = true, features = ["macros", "rt"] } tower = { workspace = true, features = ["util"] } zcash_client_backend = { workspace = true, features = ["orchard", "test-dependencies"] } diff --git a/zakura/pir-receiver/README.md b/zakura/pir-receiver/README.md index 9ac26e75..48cf54f7 100644 --- a/zakura/pir-receiver/README.md +++ b/zakura/pir-receiver/README.md @@ -38,3 +38,5 @@ lookahead past it, repeats no finished sweep, imports every payment of a key pai twice, makes no lookup when nothing was paid, keeps scanning only recently quoted addresses, keeps every address scanning when a recent set cannot vouch for the whole watch or a provider has none, and refuses a publication that is not on its chain before any lookup. +Against a fake Enhance service, `EnhanceNotes` accepts fresh routing after a refused +query. diff --git a/zakura/pir-receiver/src/sweep.rs b/zakura/pir-receiver/src/sweep.rs index 0343f05e..bbe0bd4d 100644 --- a/zakura/pir-receiver/src/sweep.rs +++ b/zakura/pir-receiver/src/sweep.rs @@ -89,7 +89,9 @@ pub trait NoteSource { /// Note data over Enhance PIR. Its session opens on first use, accepted against the /// wallet's scanned chain, so a run whose lookups find nothing makes no Enhance -/// request. +/// request. A later batch first accepts fresh routing if Enhance says a refresh is +/// due: thirty seconds on, or after a 409 or 410. A failed batch is not retried; the +/// next call refreshes. pub struct EnhanceNotes<'a, T, P> { origin: &'a str, transport: &'a T, @@ -125,7 +127,7 @@ where if positions.is_empty() { return Ok(note_data); } - if self.session.is_none() { + if self.session.as_ref().is_none_or(Client::refresh_due) { let pending = PendingClient::fetch(self.transport, self.origin).await?; let limits = ClientResourceLimits::with_cache(32768, 2); let accepted = acceptance(wallet, pending.manifest(), self.params, limits) @@ -143,7 +145,10 @@ where )); } }; - self.session = Some(pending.accept(&accepted)?); + match &mut self.session { + Some(session) => session.accept_routing(pending, &accepted)?, + None => self.session = Some(pending.accept(&accepted)?), + } } let session = self.session.as_mut().expect("opened above"); // Enhance groups these positions into shared row requests itself. diff --git a/zakura/pir-receiver/tests/sweep.rs b/zakura/pir-receiver/tests/sweep.rs index 089bd584..62d1ae49 100644 --- a/zakura/pir-receiver/tests/sweep.rs +++ b/zakura/pir-receiver/tests/sweep.rs @@ -2,9 +2,10 @@ //! directory served in process, over the service's own routes and real PIR, and //! imports what it finds only after checking it against its own chain. -use std::{collections::BTreeMap, sync::Mutex}; +use std::{cell::Cell, collections::BTreeMap, sync::Mutex}; use axum::{Router, body::Body, http::Request}; +use base64::Engine as _; use orchard::{ keys::{FullViewingKey, Scope}, note_encryption::{CompactAction, IronwoodDomain, IronwoodNoteEncryption}, @@ -16,11 +17,13 @@ use receiver_directory::{ }; use receiver_pir::{MIN_ROWS, server::Server}; use receiver_pir_server::{Publication, Publications, router_with_publications}; +use sha2::{Digest as _, Sha256}; use tower::ServiceExt as _; use zakura_dynamic_ivk::lifecycle::RESTORE_WATCH_SECS; use zakura_pir_enhance::{ - ClientError, + ClientError, QueryBinding, ShardSession, transport::{Request as EnhanceRequest, ResponseBody, Transport as EnhanceTransport}, + types, }; use zakura_pir_receiver::{ DirectoryError, EnhanceNotes, Error, NoteSource, Transport, WriteLock, fetch_seen, sweep, @@ -42,7 +45,14 @@ use zcash_client_sqlite::{ }; use zcash_note_encryption::{Domain as _, try_compact_note_decryption}; use zcash_primitives::block::BlockHash; -use zcash_protocol::{consensus::BlockHeight, local_consensus::LocalNetwork, value::Zatoshis}; +use zcash_protocol::{ + consensus::{BlockHeight, NetworkType, NetworkUpgrade, Parameters}, + local_consensus::LocalNetwork, + value::Zatoshis, +}; + +#[path = "../../pir-enhance/src/test_support.rs"] +mod test_support; type State = TestState; @@ -846,3 +856,112 @@ async fn enhance_notes_open_no_session_until_a_payment_needs_note_data() { let data = notes.note_data(st.wallet().db(), Vec::new()).await.unwrap(); assert!(data.is_empty()); } + +/// The test network's upgrades under mainnet's network type, the only one Enhance PIR +/// accepts. +#[derive(Clone)] +struct EnhanceNetwork(LocalNetwork); + +impl Parameters for EnhanceNetwork { + fn network_type(&self) -> NetworkType { + NetworkType::Main + } + + fn activation_height(&self, nu: NetworkUpgrade) -> Option { + self.0.activation_height(nu) + } +} + +/// An Enhance service over an all-zero database whose first query is refused with +/// `status`. It counts manifest fetches and queries. +struct RefusingEnhance { + manifest: Vec, + session: Vec, + rows: u64, + status: Cell>, + inits: Cell, + queries: Cell, +} + +impl RefusingEnhance { + /// A service whose manifest ends at `anchor`, covering `records` Ironwood outputs. + fn new(anchor: ChainPoint, records: u64, status: u16) -> Self { + let public = |rows| vec![0; types::session_public_len(rows).unwrap()]; + let mut manifest = test_support::synthetic_manifest( + records, + |shard| hex::encode(Sha256::digest(public(shard.logical_rows))), + &"00".repeat(32), + ); + let rows = manifest.coverage.shards[0].logical_rows; + manifest.anchor_height = u32::from(anchor.height).into(); + manifest.anchor_block_hash = anchor.hash.to_string(); + let session = ShardSession { + session_id: hex::encode(manifest.session_id(0).unwrap()), + generation: manifest.generation, + shard_id: 0, + params: types::parameters(rows).unwrap(), + public_params_base64: base64::engine::general_purpose::STANDARD.encode(public(rows)), + }; + Self { + manifest: serde_json::to_vec(&manifest).unwrap(), + session: serde_json::to_vec(&session).unwrap(), + rows, + status: Cell::new(Some(status)), + inits: Cell::new(0), + queries: Cell::new(0), + } + } +} + +impl EnhanceTransport for RefusingEnhance { + async fn execute(&self, request: EnhanceRequest) -> Result { + let bytes = if request.url.ends_with("/v1/enhance/init") { + self.inits.set(self.inits.get() + 1); + self.manifest.clone() + } else if request.url.contains("/v1/enhance/session/") { + self.session.clone() + } else if request.url.ends_with("/v1/enhance/query") { + self.queries.set(self.queries.get() + 1); + if let Some(status) = self.status.take() { + return Err(ClientError::HttpStatus(status)); + } + // Zero public material and a zero response decode to an all-zero row. + let mut response = QueryBinding::decode(&request.body).unwrap().encode(); + response.resize(types::response_len(self.rows).unwrap(), 0); + response + } else { + panic!("unexpected endpoint: {}", request.url); + }; + let mut body = request.response_body(); + body.extend(&bytes)?; + Ok(body.finish()) + } +} + +#[tokio::test] +async fn enhance_notes_accept_fresh_routing_after_a_refused_query() { + for status in [409, 410] { + let mut st = ironwood_wallet(); + pay_restored_key(&mut st, 0, 0); + let anchor = tip(&st); + let records = st + .wallet() + .block_metadata(anchor.height) + .unwrap() + .unwrap() + .ironwood_tree_size() + .unwrap(); + let enhance = RefusingEnhance::new(anchor, records.into(), status); + let network = EnhanceNetwork(*st.network()); + let mut notes = EnhanceNotes::new("https://enhance.test", &enhance, &network); + let refused = notes.note_data(st.wallet().db(), vec![0]).await; + assert!( + matches!(refused, Err(Error::NoteData(ClientError::HttpStatus(s))) if s == status), + "{refused:?}" + ); + // The same notes fetch and accept the routing again before the next batch. + let data = notes.note_data(st.wallet().db(), vec![0]).await.unwrap(); + assert_eq!(data, BTreeMap::from([(0, [0; 528])])); + assert_eq!((enhance.inits.get(), enhance.queries.get()), (2, 2)); + } +} From aa479cb946c94eb7b54607e281b0e4712ed1fc8d Mon Sep 17 00:00:00 2001 From: Adam Tucker Date: Sat, 10 Oct 2026 01:53:24 -0600 Subject: [PATCH 05/40] Restore the receiver-client references with the client The lower PRs dropped mentions of zakura-pir-receiver because it did not exist there. Point the dynamic-ivk README and the SQLite module doc back at its sweep now that this PR adds it. Co-Authored-By: Claude Opus 5.5 --- librustzcash/zcash_client_sqlite/src/wallet/dynamic_ivk.rs | 2 +- zakura/dynamic-ivk/README.md | 5 +++-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/librustzcash/zcash_client_sqlite/src/wallet/dynamic_ivk.rs b/librustzcash/zcash_client_sqlite/src/wallet/dynamic_ivk.rs index bb2d6909..e6526dde 100644 --- a/librustzcash/zcash_client_sqlite/src/wallet/dynamic_ivk.rs +++ b/librustzcash/zcash_client_sqlite/src/wallet/dynamic_ivk.rs @@ -22,7 +22,7 @@ //! [`WalletDb::begin_receive_operation`], [`WalletDb::finish_receive_operation`] and //! [`WalletDb::start_receive_operation`]. //! - [`WalletDb::record_operation_status`] for each provider status of either. -//! - The [`DynamicIvkWrite`] sweep steps for restore sweeps, and +//! - `zakura_pir_receiver::sweep` for restore sweeps, and //! [`WalletDb::recheck_dynamic_key_history`] when the user asks to recheck swaps. //! //! [`DynamicIvkRead`]: zcash_client_backend::data_api::dynamic_ivk::DynamicIvkRead diff --git a/zakura/dynamic-ivk/README.md b/zakura/dynamic-ivk/README.md index 3b5ba110..bf05f182 100644 --- a/zakura/dynamic-ivk/README.md +++ b/zakura/dynamic-ivk/README.md @@ -26,8 +26,9 @@ owns reservation, persistence, coverage, lifecycle, and note selection. There is no alternate balance store in this crate. The SQLite backend implements this contract with its `orchard` feature: durable -key registration, scanning, completion, and restore sweeps. Its rustdoc, starting -at `zcash_client_sqlite::wallet::dynamic_ivk`, documents the calls a wallet makes. +key registration, scanning, completion, and the restore sweeps that +`zakura-pir-receiver` runs. Its rustdoc, starting at +`zcash_client_sqlite::wallet::dynamic_ivk`, documents the calls a wallet makes. Software PCZT signing of dynamic-key notes works through the same builder. Hardware signers need firmware qualification before dynamic keys are enabled for From dfa2c5548c2baabf5130e2a60309e2580891570c Mon Sep 17 00:00:00 2001 From: Adam Tucker Date: Sat, 10 Oct 2026 02:52:52 -0600 Subject: [PATCH 06/40] Stop reading recent sets in restore sweeps The store now watches every swept key from the block after its lookup, so a recent set no longer decides anything: drop the recent-coverage decision, its fifteen-minute freshness constant and helper, and the per-provider recent completeness check, and pass apply_dynamic_sweep only whether a seen set holds the receiver. Paid lookups and seen handling are unchanged. Replace the tests of recent-set coverage with checks that every swept key keeps scanning, so a payment after the publication is found, and that seen addresses are still never issued. Co-Authored-By: Claude Opus 5.5 --- zakura/pir-receiver/CHANGELOG.md | 5 +- zakura/pir-receiver/README.md | 19 ++-- zakura/pir-receiver/src/sweep.rs | 146 ++++------------------------- zakura/pir-receiver/tests/sweep.rs | 142 ++++++---------------------- 4 files changed, 56 insertions(+), 256 deletions(-) diff --git a/zakura/pir-receiver/CHANGELOG.md b/zakura/pir-receiver/CHANGELOG.md index 00bdca18..9de4572b 100644 --- a/zakura/pir-receiver/CHANGELOG.md +++ b/zakura/pir-receiver/CHANGELOG.md @@ -8,8 +8,7 @@ receiver-directory sweeps of its dynamic keys for any store implementing the backend's `DynamicIvkWrite`, with `Transport`, `NoteSource`, `EnhanceNotes`, `WriteLock`, `Swept`, `Error` and `MAINNET_GENESIS`. It reads a publication's labeled filter - sets: `paid`, and each provider's `recent` and `seen`, trusting a recent set only - while its feed is current. The receiver PIR client moved - here from Vizor. + sets `paid` and each provider's `seen`. The receiver PIR client moved here from + Vizor. - `fetch_seen` and `Seen`: a publication's swap provider seen sets, which a wallet checks before issuing a swap address. They need no `wallet` feature. diff --git a/zakura/pir-receiver/README.md b/zakura/pir-receiver/README.md index 48cf54f7..8e684dd9 100644 --- a/zakura/pir-receiver/README.md +++ b/zakura/pir-receiver/README.md @@ -7,13 +7,12 @@ Each dynamic key recovered from the seed, a refund key from its funding memo or incoming lookahead key, is first tested against the publication's filters, which every wallet downloads alike. Only a receiver in the paid set is looked up over PIR, and the payments found are imported only after the wallet checks them against its own -chain. A swap provider's sets decide what happens to the key afterwards: one the -provider was given within the wallet's restore watch keeps scanning after its sweep, -and one it ever had is marked seen, so issuance does not hand it out again. A -provider's recent set is trusted only if its feed read the provider within the last -fifteen minutes and its window and feed history cover that watch. Unless every -provider with sets in the publication has such a recent set, every key keeps scanning. A run that finds nothing to look up opens -no PIR session and fetches no witnesses. +chain. A receiver in a swap provider's seen set is marked seen, so issuance does not +hand it out again. Every swept key then keeps scanning for the store's restore watch, +from the block after the publication's end, so a payment the publication missed is +found by scanning; the store documents how long the watch lasts (for +`zakura-client-sqlite`, `close_finished_dynamic_keys`). A run that finds nothing to +look up opens no PIR session and fetches no witnesses. Applications supply: @@ -35,8 +34,8 @@ until they land on its `main`. `tests/sweep.rs` serves a directory in process through the receiver service's own routes and checks that a restored wallet finds and imports a payout, extends its lookahead past it, repeats no finished sweep, imports every payment of a key paid -twice, makes no lookup when nothing was paid, keeps scanning only recently quoted -addresses, keeps every address scanning when a recent set cannot vouch for the -whole watch or a provider has none, and refuses a publication that is not on its chain before any lookup. +twice, makes no lookup when nothing was paid, keeps every swept address scanning and +issues no seen one, and refuses a publication that is not on its chain before any +lookup. Against a fake Enhance service, `EnhanceNotes` accepts fresh routing after a refused query. diff --git a/zakura/pir-receiver/src/sweep.rs b/zakura/pir-receiver/src/sweep.rs index bbe0bd4d..6b46397d 100644 --- a/zakura/pir-receiver/src/sweep.rs +++ b/zakura/pir-receiver/src/sweep.rs @@ -5,12 +5,11 @@ use std::{collections::BTreeMap, num::NonZeroU32}; use futures_util::StreamExt; use receiver_directory::{ Receiver, - filter::{Filters, PAID, RECENT, SEEN}, - snapshot::FilterSet, + filter::{Filters, PAID, SEEN}, witness::WitnessSnapshot, }; use receiver_pir::{AcceptedCoverage, transport::DirectoryClient}; -use zakura_dynamic_ivk::{KeyId, lifecycle::RESTORE_WATCH_SECS}; +use zakura_dynamic_ivk::KeyId; use zakura_pir_enhance::{ ClientError, ClientResourceLimits, transport::{Client, PendingClient, Transport as EnhanceTransport}, @@ -20,7 +19,7 @@ use zcash_client_backend::data_api::{ WalletRead, dynamic_ivk::{ DirectoryPayment, DiscoveryWork, DynamicIvkRead, DynamicIvkWrite, PaymentApplication, - ProviderView, SweepDeferral, + SweepDeferral, }, enhance_pir::EnhancePirRead, transparent_ledger::ChainPoint, @@ -41,9 +40,6 @@ const BATCH: NonZeroU32 = NonZeroU32::new(64).unwrap(); /// Positions whose note data one request asks for. Each batch is queued as it arrives, /// so a long history keeps its progress when a run stops partway. const NOTE_BATCH: usize = 64; -/// How long before `now` a provider's recent set may have last read its feed and still -/// rule a receiver out. A quote made after that read is missing from the set. -const RECENT_SET_MAX_AGE_SECS: i64 = 15 * 60; /// Why a sweep run, or one key's sweep, stopped. `E` is the wallet's error type. #[derive(Debug, thiserror::Error)] @@ -183,23 +179,17 @@ pub struct Swept { /// a block the wallet accepts (see [`DynamicIvkRead::directory_publication_anchor`]). /// /// Each key's receiver is first tested against the publication's filters, which every -/// wallet downloads alike: only a receiver in the paid set is looked up over PIR. A -/// swap provider's sets then say whether a swap may still pay the receiver, which keeps -/// its key scanning after the sweep, and whether the provider ever had it (see -/// [`ProviderView`]). A provider's recent set can rule a receiver out only if it is -/// current and complete: its feed's last read began at most fifteen minutes before -/// `now`, and its window, with the feed running throughout, covers the wallet's own -/// restore watch, [`RESTORE_WATCH_SECS`]. If any recent set falls short, a provider -/// with sets has no recent set, or the publication has no provider sets, every key -/// keeps scanning. +/// wallet downloads alike: only a receiver in the paid set is looked up over PIR, and +/// one in a swap provider's seen set is never issued again. Whatever the provider sets +/// say, every swept key then keeps scanning for the wallet's restore watch (see +/// [`DynamicIvkWrite::apply_dynamic_sweep`]). /// /// A run that looks nothing up opens no PIR session and fetches no witnesses. New /// payments a lookup finds take their note data from `notes`. After each batch the /// incoming lookahead moves past paid indices and its new keys are swept too. A key /// that fails is backed off and listed in [`Swept::deferred`], and the others go on, /// unless the directory's session or connection failed: then the run stops before -/// leasing more keys. `now` is the caller's clock in seconds, for retry backoff and the -/// recent sets. +/// leasing more keys. `now` is the caller's clock in seconds, for retry backoff. #[allow(clippy::too_many_arguments)] pub async fn sweep( wallet: &mut W, @@ -247,7 +237,7 @@ where } }; 'run: loop { - let checks = directory.check(&work, now)?; + let checks = directory.check(&work)?; let lookups = work .iter() .zip(&checks) @@ -316,8 +306,8 @@ where struct Check { /// The paid set holds it, so a lookup may find payments. paid: bool, - /// What the swap provider sets say. - provider: ProviderView, + /// A swap provider's seen set holds it, so it is never issued again. + seen: bool, } /// A receiver-directory publication accepted at a block the wallet scanned, with its @@ -380,13 +370,8 @@ impl<'a, T: Transport> Directory<'a, T> { }) } - /// What the filters say about each item's receiver at `now`; see [`sweep`] for how - /// the provider sets are read. - fn check( - &self, - work: &[(A, DiscoveryWork)], - now: i64, - ) -> Result, DirectoryError> { + /// What the filters say about each item's receiver. + fn check(&self, work: &[(A, DiscoveryWork)]) -> Result, DirectoryError> { let receivers = work .iter() .map(|(_, item)| Receiver::from_bytes(item.receiver)) @@ -401,38 +386,13 @@ impl<'a, T: Transport> Directory<'a, T> { }; let mut checks: Vec<_> = matches(PAID) .into_iter() - .map(|paid| Check { - paid, - provider: ProviderView::default(), - }) + .map(|paid| Check { paid, seen: false }) .collect(); - // Each provider with a set, and whether its recent sets, if any, are all current - // and complete. - let mut providers = BTreeMap::<&str, Option>::new(); for set in &self.manifest.directory.filters { - match set.label.split_once('/') { - Some((provider, RECENT)) => { - let covers = covers_restore_watch(set, now); - let complete = providers.entry(provider).or_default(); - *complete = Some(complete.unwrap_or(true) && covers); - if covers { - for (check, hit) in checks.iter_mut().zip(matches(&set.label)) { - check.provider.recent |= hit; - } - } - } - Some((provider, SEEN)) => { - providers.entry(provider).or_default(); - for (check, hit) in checks.iter_mut().zip(matches(&set.label)) { - check.provider.seen |= hit; - } + if set.label.split_once('/').map(|(_, kind)| kind) == Some(SEEN) { + for (check, hit) in checks.iter_mut().zip(matches(&set.label)) { + check.seen |= hit; } - _ => {} - } - } - if providers.is_empty() || providers.values().any(|&complete| complete != Some(true)) { - for check in &mut checks { - check.provider.recent = true; } } Ok(checks) @@ -460,22 +420,6 @@ impl<'a, T: Transport> Directory<'a, T> { } } -/// Whether a provider's recent set can rule a receiver out at `now`: its feed's last -/// read began at most [`RECENT_SET_MAX_AGE_SECS`] before `now`, and its window, which -/// reaches back from that read through the time the feed was running, covers -/// [`RESTORE_WATCH_SECS`]. -fn covers_restore_watch(set: &FilterSet, now: i64) -> bool { - let window = set.window_secs.and_then(|w| i64::try_from(w).ok()); - match (window, set.since_unix, set.until_unix) { - (Some(window), Some(since), Some(until)) => { - window >= RESTORE_WATCH_SECS - && since <= until.saturating_sub(window) - && until >= now.saturating_sub(RECENT_SET_MAX_AGE_SECS) - } - _ => false, - } -} - /// `accounts`' due sweeps at `through`, a bounded batch per account. fn due( wallet: &mut W, @@ -578,7 +522,7 @@ where key, through, anchor, - check.provider, + check.seen, |position, cmx| witnesses.and_then(|w| w.path(position, cmx).ok()), ) }) @@ -605,57 +549,3 @@ fn directory_payment(payment: receiver_directory::Payment) -> DirectoryPayment { ciphertext_prefix: payment.ciphertext_prefix, } } - -#[cfg(test)] -mod tests { - use super::*; - - /// A recent set of `window_secs` from a feed that started at `since_unix` and last - /// read the provider at `until_unix`. - fn recent(window_secs: Option, since_unix: Option, until_unix: i64) -> FilterSet { - FilterSet { - label: "near-intents/recent".into(), - count: 0, - window_secs, - since_unix, - until_unix: Some(until_unix), - } - } - - #[test] - fn a_recent_set_rules_receivers_out_only_when_current_across_the_whole_watch() { - let now = 10 * RESTORE_WATCH_SECS; - let watch = RESTORE_WATCH_SECS as u64; - let read = now - 60; - let started = Some(read - RESTORE_WATCH_SECS); - assert!(covers_restore_watch( - &recent(Some(watch), started, read), - now - )); - assert!(covers_restore_watch( - &recent(Some(2 * watch), Some(0), read), - now - )); - // A shorter window, or a feed younger than the window, can miss a quote. - assert!(!covers_restore_watch( - &recent(Some(watch - 1), started, read), - now - )); - assert!(!covers_restore_watch( - &recent(Some(watch), Some(read - 60), read), - now - )); - assert!(!covers_restore_watch(&recent(None, started, read), now)); - assert!(!covers_restore_watch(&recent(Some(watch), None, read), now)); - // So can a set whose feed stalled: quotes after its last read are missing. - let stalled = now - RECENT_SET_MAX_AGE_SECS - 1; - let started = Some(stalled - RESTORE_WATCH_SECS); - assert!(!covers_restore_watch( - &recent(Some(watch), started, stalled), - now - )); - let mut undated = recent(Some(watch), started, read); - undated.until_unix = None; - assert!(!covers_restore_watch(&undated, now)); - } -} diff --git a/zakura/pir-receiver/tests/sweep.rs b/zakura/pir-receiver/tests/sweep.rs index 62d1ae49..f205ca99 100644 --- a/zakura/pir-receiver/tests/sweep.rs +++ b/zakura/pir-receiver/tests/sweep.rs @@ -149,15 +149,15 @@ fn pay_restored_key(st: &mut State, index: u64, position: u64) -> (Record, [u8; (record, ciphertext[52..].try_into().unwrap()) } -/// A swap provider's sets: the receivers it was given within the wallet's restore watch, -/// and every receiver it ever had, from a feed that started long before [`NOW`] and last -/// read the provider a minute before it. -fn provider(recent: &[Receiver], seen: &[Receiver]) -> Vec { - provider_named("near-intents", recent, seen) +/// A swap provider's sets: an empty recent set, which wallets do not read, and every +/// receiver it ever had, from a feed that started long before [`NOW`] and last read the +/// provider a minute before it. +fn provider(seen: &[Receiver]) -> Vec { + provider_named("near-intents", seen) } /// [`provider`]'s sets for the provider `name`. -fn provider_named(name: &str, recent: &[Receiver], seen: &[Receiver]) -> Vec { +fn provider_named(name: &str, seen: &[Receiver]) -> Vec { let since_unix = NOW - 2 * RESTORE_WATCH_SECS; let until_unix = NOW - 60; vec![ @@ -166,7 +166,7 @@ fn provider_named(name: &str, recent: &[Receiver], seen: &[Receiver]) -> Vec = records.iter().map(|record| record.payment.cmx).collect(); - let directory = Directory::new(publish( - &records, - &commitments, - through, - &provider(&[], &[]), - )); + let directory = Directory::new(publish(&records, &commitments, through, &provider(&[]))); let mut notes = Notes { data, requested: Vec::new(), @@ -520,7 +515,7 @@ async fn a_wallet_without_payments_downloads_only_the_filters() { st.generate_and_scan_empty_blocks_with_dynamic_ivks(1); restore(&mut st, account); let through = tip(&st); - let directory = Directory::new(publish(&[], &[], through, &provider(&[], &[]))); + let directory = Directory::new(publish(&[], &[], through, &provider(&[]))); let mut notes = Notes { data: BTreeMap::new(), requested: Vec::new(), @@ -547,12 +542,18 @@ async fn a_wallet_without_payments_downloads_only_the_filters() { assert_eq!(requests.len(), 2, "{requests:?}"); assert!(requests[1].starts_with("GET /v1/receiver/filters/")); assert!(notes.requested.is_empty()); - // No swap reached these addresses recently, so none keeps scanning. - assert!(st.wallet().get_dynamic_scanning_keys().unwrap().is_empty()); + // Every swept key keeps scanning, so a payment after the publication is found. + let scanning = st.wallet().get_dynamic_scanning_keys().unwrap().len(); + assert_eq!(scanning as u64, RECEIVE_GAP_LIMIT); + pay_restored_key(&mut st, 0, 0); + assert_eq!( + st.get_total_balance(account), + Zatoshis::const_from_u64(VALUE) + ); } #[tokio::test] -async fn only_recently_quoted_addresses_keep_scanning_after_their_sweep() { +async fn seen_addresses_keep_scanning_and_are_never_issued_again() { let mut st = ironwood_wallet(); let network = *st.network(); let account = st.test_account().unwrap().id(); @@ -567,7 +568,7 @@ async fn only_recently_quoted_addresses_keep_scanning_after_their_sweep() { .address_at(0u32, Scope::External); Receiver::from_bytes(address.to_raw_address_bytes()).unwrap() }; - // The old device quoted addresses 0 and 2, and 2 only today. + // The old device quoted addresses 0 and 2. let (old, quoted) = ( KeyId::new(Purpose::Receive, 0), KeyId::new(Purpose::Receive, 2), @@ -576,7 +577,7 @@ async fn only_recently_quoted_addresses_keep_scanning_after_their_sweep() { &[], &[], through, - &provider(&[receiver(quoted)], &[receiver(old), receiver(quoted)]), + &provider(&[receiver(old), receiver(quoted)]), )); let mut notes = Notes { data: BTreeMap::new(), @@ -597,14 +598,9 @@ async fn only_recently_quoted_addresses_keep_scanning_after_their_sweep() { .await .unwrap(); assert!(swept.deferred.is_empty(), "{:?}", swept.deferred); - let scanning: Vec<_> = st - .wallet() - .get_dynamic_scanning_keys() - .unwrap() - .iter() - .map(|k| k.key_id()) - .collect(); - assert_eq!(scanning, [quoted]); + // The seen address 2 moves the lookahead, and every swept key keeps scanning. + let scanning = st.wallet().get_dynamic_scanning_keys().unwrap().len(); + assert_eq!(scanning as u64, RECEIVE_GAP_LIMIT + 3); // Issuance reads the same seen set, skips both, and takes the lowest address the // provider never had. let seen = fetch_seen(ORIGIN, &directory).await.unwrap(); @@ -632,90 +628,6 @@ async fn only_recently_quoted_addresses_keep_scanning_after_their_sweep() { assert_eq!(issued.key_id(), KeyId::new(Purpose::Receive, 1)); } -#[tokio::test] -async fn every_address_keeps_scanning_when_the_recent_set_falls_short_of_the_watch() { - let mut st = ironwood_wallet(); - let network = *st.network(); - let account = st.test_account().unwrap().id(); - st.generate_and_scan_empty_blocks_with_dynamic_ivks(1); - restore(&mut st, account); - let through = tip(&st); - // A swap quoted two hours ago is outside a one-hour recent set. - let mut sets = provider(&[], &[]); - sets[0].window_secs = Some(3600); - let directory = Directory::new(publish(&[], &[], through, &sets)); - let mut notes = Notes { - data: BTreeMap::new(), - requested: Vec::new(), - }; - let swept = sweep( - st.wallet_mut().db_mut(), - &network, - &[account], - through, - GENESIS, - ORIGIN, - &directory, - &mut notes, - &NoLock, - NOW, - ) - .await - .unwrap(); - assert!(swept.deferred.is_empty(), "{:?}", swept.deferred); - let scanning = st.wallet().get_dynamic_scanning_keys().unwrap().len(); - assert_eq!(scanning as u64, RECEIVE_GAP_LIMIT); -} - -#[tokio::test] -async fn every_address_keeps_scanning_unless_each_provider_has_a_recent_set() { - for both_recent in [true, false] { - let mut st = ironwood_wallet(); - let network = *st.network(); - let account = st.test_account().unwrap().id(); - st.generate_and_scan_empty_blocks_with_dynamic_ivks(1); - restore(&mut st, account); - let through = tip(&st); - // Provider `b` publishes only its seen set when `both_recent` is false. - let mut sets = provider_named("a", &[], &[]); - let b = provider_named("b", &[], &[]); - sets.extend(b.into_iter().skip(usize::from(!both_recent))); - let directory = Directory::new(publish(&[], &[], through, &sets)); - let mut notes = Notes { - data: BTreeMap::new(), - requested: Vec::new(), - }; - let swept = sweep( - st.wallet_mut().db_mut(), - &network, - &[account], - through, - GENESIS, - ORIGIN, - &directory, - &mut notes, - &NoLock, - NOW, - ) - .await - .unwrap(); - assert!(swept.deferred.is_empty(), "{:?}", swept.deferred); - let scanning = st.wallet().get_dynamic_scanning_keys().unwrap().len() as u64; - if both_recent { - assert_eq!(scanning, 0); - } else { - assert_eq!(scanning, RECEIVE_GAP_LIMIT); - // `b` may have quoted any address, so a payment after the publication is - // still found by scanning. - pay_restored_key(&mut st, 0, 0); - assert_eq!( - st.get_total_balance(account), - Zatoshis::const_from_u64(VALUE) - ); - } - } -} - /// A directory whose queries fail after it accepted the session. struct FailingQueries(Directory); @@ -744,7 +656,7 @@ async fn a_revoked_session_stops_the_run_before_leasing_more_keys() { std::slice::from_ref(&record), &[record.payment.cmx], through, - &provider(&[], &[]), + &provider(&[]), ))); let mut notes = Notes { data: BTreeMap::new(), @@ -793,7 +705,7 @@ async fn a_publication_off_the_wallets_chain_is_refused_before_any_lookup() { std::slice::from_ref(&record), &[record.payment.cmx], other, - &provider(&[], &[]), + &provider(&[]), )); let mut notes = Notes { data: BTreeMap::from([(record.payment.position, note_data)]), From 5ae2a9244d616ea1b56a8a509c7147dcb813176d Mon Sep 17 00:00:00 2001 From: Adam Tucker Date: Sat, 10 Oct 2026 02:56:35 -0600 Subject: [PATCH 07/40] Require a seen set for every provider with sets A publication with a/recent but no a/seen, plus a dated b/seen, passed fetch_seen: provider a was ignored, so its receivers looked unseen inside b's coverage and issuance could reuse an address a already had. Sweeps had the same gap when marking restored keys seen. Track every provider represented by a recent or seen set and treat the publication as malformed unless each has a dated seen set, for fetch_seen and for sweep's directory acceptance alike. Coverage stays the intersection of the feeds' read times and membership the union of the sets. Without fetched evidence, the SQLite store's seen: None fallback still counts every locally recorded quote as seen. Co-Authored-By: Claude Opus 5.5 --- zakura/pir-receiver/CHANGELOG.md | 4 +- zakura/pir-receiver/README.md | 4 +- zakura/pir-receiver/src/seen.rs | 49 +++++++++++++++------- zakura/pir-receiver/src/sweep.rs | 23 +++++++---- zakura/pir-receiver/tests/sweep.rs | 66 ++++++++++++++++++++++++++++++ 5 files changed, 120 insertions(+), 26 deletions(-) diff --git a/zakura/pir-receiver/CHANGELOG.md b/zakura/pir-receiver/CHANGELOG.md index 9de4572b..93115060 100644 --- a/zakura/pir-receiver/CHANGELOG.md +++ b/zakura/pir-receiver/CHANGELOG.md @@ -11,4 +11,6 @@ sets `paid` and each provider's `seen`. The receiver PIR client moved here from Vizor. - `fetch_seen` and `Seen`: a publication's swap provider seen sets, which a wallet - checks before issuing a swap address. They need no `wallet` feature. + checks before issuing a swap address. A publication in which a provider with sets + lacks a dated seen set is malformed, for `fetch_seen` and `sweep` alike. They need + no `wallet` feature. diff --git a/zakura/pir-receiver/README.md b/zakura/pir-receiver/README.md index 8e684dd9..72d5b591 100644 --- a/zakura/pir-receiver/README.md +++ b/zakura/pir-receiver/README.md @@ -35,7 +35,7 @@ until they land on its `main`. routes and checks that a restored wallet finds and imports a payout, extends its lookahead past it, repeats no finished sweep, imports every payment of a key paid twice, makes no lookup when nothing was paid, keeps every swept address scanning and -issues no seen one, and refuses a publication that is not on its chain before any -lookup. +issues no seen one, refuses a publication that is not on its chain before any lookup, +and refuses one in which a provider lacks a seen set. Against a fake Enhance service, `EnhanceNotes` accepts fresh routing after a refused query. diff --git a/zakura/pir-receiver/src/seen.rs b/zakura/pir-receiver/src/seen.rs index 0fe4d8b3..b5fb678f 100644 --- a/zakura/pir-receiver/src/seen.rs +++ b/zakura/pir-receiver/src/seen.rs @@ -1,7 +1,10 @@ //! Swap provider seen sets, which wallets read before issuing a swap address. +use std::collections::BTreeMap; + use receiver_directory::{ Receiver, - filter::{Filter, SEEN}, + filter::{Filter, RECENT, SEEN}, + snapshot::FilterSet, }; use receiver_pir::transport::DirectoryClient; @@ -53,7 +56,8 @@ impl Seen { } /// Downloads the seen sets of the publication `origin` serves. Fails if it declares -/// none, or a seen set without its feed's read times. +/// none, or if any provider with a set lacks a seen set with its feed's read times (see +/// [`seen_sets`]). pub async fn fetch_seen(origin: &str, transport: &T) -> Result { let manifest = DirectoryClient::fetch_manifest(origin, transport).await?; let filters = DirectoryClient::fetch_filters(origin, transport, &manifest).await?; @@ -63,25 +67,40 @@ pub async fn fetch_seen(origin: &str, transport: &T) -> Result Result, DirectoryError> { + let mut providers = BTreeMap::<&str, Option<(&str, i64, i64)>>::new(); + for set in sets { + match set.label.split_once('/') { + Some((provider, RECENT)) => { + providers.entry(provider).or_default(); + } + Some((provider, SEEN)) => { + let (Some(since), Some(until)) = (set.since_unix, set.until_unix) else { + return Err(DirectoryError::Malformed); + }; + providers.insert(provider, Some((&set.label, since, until))); + } + _ => {} + } + } + providers + .into_values() + .map(|seen| seen.ok_or(DirectoryError::Malformed)) + .collect() +} diff --git a/zakura/pir-receiver/src/sweep.rs b/zakura/pir-receiver/src/sweep.rs index 6b46397d..2788ebb5 100644 --- a/zakura/pir-receiver/src/sweep.rs +++ b/zakura/pir-receiver/src/sweep.rs @@ -5,7 +5,7 @@ use std::{collections::BTreeMap, num::NonZeroU32}; use futures_util::StreamExt; use receiver_directory::{ Receiver, - filter::{Filters, PAID, SEEN}, + filter::{Filters, PAID}, witness::WitnessSnapshot, }; use receiver_pir::{AcceptedCoverage, transport::DirectoryClient}; @@ -26,7 +26,7 @@ use zcash_client_backend::data_api::{ }; use zcash_protocol::consensus::{BlockHeight, NetworkUpgrade, Parameters}; -use crate::{DirectoryError, Transport}; +use crate::{DirectoryError, Transport, seen::seen_sets}; /// A wallet's account identifier. type AccountId = ::AccountId; @@ -176,7 +176,9 @@ pub struct Swept { /// the stored chain tip. When none is due, the run makes no request. Otherwise the /// publication must commit to `genesis` ([`MAINNET_GENESIS`](crate::MAINNET_GENESIS) /// on mainnet), cover history from Ironwood activation on `params`' network, and end at -/// a block the wallet accepts (see [`DynamicIvkRead::directory_publication_anchor`]). +/// a block the wallet accepts (see [`DynamicIvkRead::directory_publication_anchor`]), +/// and every swap provider with a set must have a dated seen set, as for +/// [`fetch_seen`](crate::fetch_seen). /// /// Each key's receiver is first tested against the publication's filters, which every /// wallet downloads alike: only a receiver in the paid set is looked up over PIR, and @@ -321,6 +323,8 @@ struct Directory<'a, T> { accepted: AcceptedCoverage, anchor: ChainPoint, filters: Filters, + /// The labels of the provider seen sets. + seen: Vec, session: Option>, } @@ -358,6 +362,10 @@ impl<'a, T: Transport> Directory<'a, T> { hash: anchor.hash.0, }; accepted.check(&manifest.directory)?; + let seen = seen_sets(&manifest.directory.filters)? + .into_iter() + .map(|(label, _, _)| label.to_owned()) + .collect(); let filters = DirectoryClient::fetch_filters(origin, &transport, &manifest).await?; Ok(Self { origin, @@ -366,6 +374,7 @@ impl<'a, T: Transport> Directory<'a, T> { accepted, anchor, filters, + seen, session: None, }) } @@ -388,11 +397,9 @@ impl<'a, T: Transport> Directory<'a, T> { .into_iter() .map(|paid| Check { paid, seen: false }) .collect(); - for set in &self.manifest.directory.filters { - if set.label.split_once('/').map(|(_, kind)| kind) == Some(SEEN) { - for (check, hit) in checks.iter_mut().zip(matches(&set.label)) { - check.seen |= hit; - } + for label in &self.seen { + for (check, hit) in checks.iter_mut().zip(matches(label)) { + check.seen |= hit; } } Ok(checks) diff --git a/zakura/pir-receiver/tests/sweep.rs b/zakura/pir-receiver/tests/sweep.rs index f205ca99..30c5fcd4 100644 --- a/zakura/pir-receiver/tests/sweep.rs +++ b/zakura/pir-receiver/tests/sweep.rs @@ -628,6 +628,72 @@ async fn seen_addresses_keep_scanning_and_are_never_issued_again() { assert_eq!(issued.key_id(), KeyId::new(Purpose::Receive, 1)); } +/// A provider with a recent set but no seen set may have had any receiver, so issuance +/// and sweeps both refuse the publication until every provider has a dated seen set. +/// The sets then cover the quote times they all cover, and hold what any of them holds. +#[tokio::test] +async fn every_provider_with_sets_needs_a_seen_set() { + let mut st = ironwood_wallet(); + let network = *st.network(); + let account = st.test_account().unwrap().id(); + st.generate_and_scan_empty_blocks_with_dynamic_ivks(1); + restore(&mut st, account); + let through = tip(&st); + let parent = FullViewingKey::from(st.test_account().unwrap().usk().orchard()); + let receiver = |index| { + let fvk = KeyId::new(Purpose::Receive, index).derive(&parent).unwrap(); + Receiver::from_bytes(fvk.address_at(0u32, Scope::External).to_raw_address_bytes()).unwrap() + }; + // Provider `a` has its seen set only if `a_seen`. `b` has only a seen set, from a + // later feed start and a later read. + let sets = |a_seen: bool| { + let mut sets = provider_named("a", &[receiver(0)]); + sets.truncate(1 + usize::from(a_seen)); + let mut b = provider_named("b", &[receiver(1)]).pop().unwrap(); + b.since_unix = NOW - RESTORE_WATCH_SECS; + b.until_unix = NOW - 30; + sets.push(b); + sets + }; + let directory = Directory::new(publish(&[], &[], through, &sets(false))); + let refused = fetch_seen(ORIGIN, &directory).await; + assert!(matches!(refused, Err(DirectoryError::Malformed))); + let mut notes = Notes { + data: BTreeMap::new(), + requested: Vec::new(), + }; + let result = sweep( + st.wallet_mut().db_mut(), + &network, + &[account], + through, + GENESIS, + ORIGIN, + &directory, + &mut notes, + &NoLock, + NOW, + ) + .await; + assert!( + matches!(result, Err(Error::Directory(DirectoryError::Malformed))), + "{result:?}" + ); + // A publication without provider sets is refused too. + let directory = Directory::new(publish(&[], &[], through, &[])); + let refused = fetch_seen(ORIGIN, &directory).await; + assert!(matches!(refused, Err(DirectoryError::Malformed))); + + let directory = Directory::new(publish(&[], &[], through, &sets(true))); + let seen = fetch_seen(ORIGIN, &directory).await.unwrap(); + assert_eq!( + (seen.since(), seen.until()), + (NOW - RESTORE_WATCH_SECS, NOW - 60) + ); + let held = [0, 1, 2].map(|index| *receiver(index).as_bytes()); + assert_eq!(seen.contains(&held), [true, true, false]); +} + /// A directory whose queries fail after it accepted the session. struct FailingQueries(Directory); From 627c0f5b873cc0595eba8326096209172f92e8ca Mon Sep 17 00:00:00 2001 From: Adam Tucker Date: Sat, 10 Oct 2026 04:06:30 -0600 Subject: [PATCH 08/40] Cover a seen hit across a rejected payment and a later miss A receiver that publication A's seen set holds, but whose listed payment A's witnesses reject, must stay seen when publication B's seen set misses it and its lookup finds nothing. The hit is now stored with the attempt lease, before any lookup; say so in the README. Co-Authored-By: Claude Opus 5.5 --- zakura/pir-receiver/README.md | 4 +- zakura/pir-receiver/tests/sweep.rs | 96 +++++++++++++++++++++++++++++- 2 files changed, 97 insertions(+), 3 deletions(-) diff --git a/zakura/pir-receiver/README.md b/zakura/pir-receiver/README.md index 72d5b591..036509e5 100644 --- a/zakura/pir-receiver/README.md +++ b/zakura/pir-receiver/README.md @@ -7,8 +7,8 @@ Each dynamic key recovered from the seed, a refund key from its funding memo or incoming lookahead key, is first tested against the publication's filters, which every wallet downloads alike. Only a receiver in the paid set is looked up over PIR, and the payments found are imported only after the wallet checks them against its own -chain. A receiver in a swap provider's seen set is marked seen, so issuance does not -hand it out again. Every swept key then keeps scanning for the store's restore watch, +chain. A receiver in a swap provider's seen set is marked seen as its sweep attempt +starts, before any lookup, so issuance does not hand it out again. Every swept key then keeps scanning for the store's restore watch, from the block after the publication's end, so a payment the publication missed is found by scanning; the store documents how long the watch lasts (for `zakura-client-sqlite`, `close_finished_dynamic_keys`). A run that finds nothing to diff --git a/zakura/pir-receiver/tests/sweep.rs b/zakura/pir-receiver/tests/sweep.rs index 30c5fcd4..ee884c23 100644 --- a/zakura/pir-receiver/tests/sweep.rs +++ b/zakura/pir-receiver/tests/sweep.rs @@ -31,7 +31,7 @@ use zakura_pir_receiver::{ use zcash_client_backend::data_api::{ Account as _, WalletRead as _, chain::BlockSource as _, - dynamic_ivk::{DynamicIvkRead, DynamicIvkWrite as _}, + dynamic_ivk::{DynamicIvkRead, DynamicIvkWrite as _, PaymentApplication}, testing::{AddressType, IronwoodFvk, TestBuilder, TestState}, transparent_ledger::ChainPoint, }; @@ -628,6 +628,100 @@ async fn seen_addresses_keep_scanning_and_are_never_issued_again() { assert_eq!(issued.key_id(), KeyId::new(Purpose::Receive, 1)); } +/// A seen hit counts from the attempt that observes it. When publication A's seen set +/// holds a receiver whose payment it then misstates, a later publication whose seen set +/// misses the receiver and whose lookup finds nothing does not make it issuable. +#[tokio::test] +async fn a_seen_hit_outlives_a_rejected_payment_and_a_later_miss() { + let mut st = ironwood_wallet(); + let network = *st.network(); + let account = st.test_account().unwrap().id(); + let key = KeyId::new(Purpose::Receive, 0); + let (record, note_data) = pay_restored_key(&mut st, key.index(), 0); + st.generate_and_scan_empty_blocks_with_dynamic_ivks(1); + restore(&mut st, account); + let through = tip(&st); + let seen = |st: &State| -> bool { + st.wallet() + .conn() + .query_row( + "SELECT provider_seen FROM ironwood_receiving_keys + WHERE purpose = 1 AND key_index = ?1", + [key.index().to_be_bytes()], + |r| r.get(0), + ) + .unwrap() + }; + let mut notes = Notes { + data: BTreeMap::from([(record.payment.position, note_data)]), + requested: Vec::new(), + }; + // Publication A's witnesses are over another tree, so its payment is rejected. + let a = Directory::new(publish( + std::slice::from_ref(&record), + &[record.payment.cmx, [1; 32]], + through, + &provider(&[record.receiver]), + )); + let swept = sweep( + st.wallet_mut().db_mut(), + &network, + &[account], + through, + GENESIS, + ORIGIN, + &a, + &mut notes, + &NoLock, + NOW, + ) + .await + .unwrap(); + assert!( + matches!( + &swept.deferred[..], + [(deferred, Error::Queued(PaymentApplication::Rejected))] if *deferred == key + ), + "{:?}", + swept.deferred + ); + assert!(seen(&st)); + + let b = Directory::new(publish(&[], &[], through, &provider(&[]))); + let later = NOW + 3_600; + let swept = sweep( + st.wallet_mut().db_mut(), + &network, + &[account], + through, + GENESIS, + ORIGIN, + &b, + &mut notes, + &NoLock, + later, + ) + .await + .unwrap(); + assert!(swept.deferred.is_empty(), "{:?}", swept.deferred); + assert!(!swept.pending); + assert!(seen(&st)); + let fetched = fetch_seen(ORIGIN, &b).await.unwrap(); + let contains = |receivers: &[[u8; 43]]| fetched.contains(receivers); + let view = ProviderSeen { + since: fetched.since(), + until: fetched.until(), + contains: &contains, + }; + let issued = st + .wallet_mut() + .db_mut() + .prepare_receive_reservation(account, later, through.height, Some(&view)) + .unwrap() + .unwrap(); + assert_eq!(issued.key_id(), KeyId::new(Purpose::Receive, 1)); +} + /// A provider with a recent set but no seen set may have had any receiver, so issuance /// and sweeps both refuse the publication until every provider has a dated seen set. /// The sets then cover the quote times they all cover, and hold what any of them holds. From 335237c689b1ea1756d357976d617bbd5c8d3e29 Mon Sep 17 00:00:00 2001 From: Adam Tucker Date: Sat, 10 Oct 2026 04:07:23 -0600 Subject: [PATCH 09/40] Stop the sweep run when leasing an attempt fails A failed begin_dynamic_sweep_attempt was handled like a key's recoverable failure, but its retry time never advanced, so the next batch selected the key again and a persistent failure could loop forever. Lease each attempt in the run loop, before the key's recoverable work, and return the wallet's error. Cover it with a commit hook that fails only the first lease. Co-Authored-By: Claude Opus 5.5 --- zakura/pir-receiver/src/sweep.rs | 30 +++++------ zakura/pir-receiver/tests/sweep.rs | 84 +++++++++++++++++++++++++++++- 2 files changed, 98 insertions(+), 16 deletions(-) diff --git a/zakura/pir-receiver/src/sweep.rs b/zakura/pir-receiver/src/sweep.rs index 6aaa0549..e4afcf14 100644 --- a/zakura/pir-receiver/src/sweep.rs +++ b/zakura/pir-receiver/src/sweep.rs @@ -192,7 +192,8 @@ pub struct Swept { /// incoming lookahead moves past paid indices and its new keys are swept too. A key /// that fails is backed off and listed in [`Swept::deferred`], and the others go on, /// unless the directory's session or connection failed: then the run stops before -/// leasing more keys. `now` is the caller's clock in seconds, for retry backoff. +/// leasing more keys. A lease the wallet fails to store stops the run with its error. +/// `now` is the caller's clock in seconds, for retry backoff. #[allow(clippy::too_many_arguments)] pub async fn sweep( wallet: &mut W, @@ -250,17 +251,21 @@ where session.client.use_file_for_work(lookups).await?; } for ((account, item), check) in work.into_iter().zip(checks) { + // A key whose lease is not stored would be due again at once. + lock.write("dynamic_sweep.attempt", || { + wallet.begin_dynamic_sweep_attempt(account, item.key, now, check.seen) + }) + .map_err(Error::Wallet)?; let swept_key = sweep_key( wallet, &mut directory, account, &item, - check, + check.paid, lookups, through, notes, lock, - now, ); match swept_key.await { Ok(()) => swept.finished += 1, @@ -449,22 +454,21 @@ fn due( Ok(work) } -/// Sweeps one key: leases an attempt, looks up its receiver if the paid set holds it -/// and no earlier lookup is queued, queues the new payments with their note data in -/// batches, and applies them with the publication's witnesses. `lookups` is the -/// batch's lookup count, which sizes a session that this key opens. +/// Sweeps one key whose attempt is leased: looks up its receiver if `paid`, the paid +/// set holds it, and no earlier lookup is queued, queues the new payments with their +/// note data in batches, and applies them with the publication's witnesses. `lookups` +/// is the batch's lookup count, which sizes a session that this key opens. #[allow(clippy::too_many_arguments)] async fn sweep_key( wallet: &mut W, directory: &mut Directory<'_, T>, account: AccountId, item: &DiscoveryWork, - check: Check, + paid: bool, lookups: usize, through: ChainPoint, notes: &mut N, lock: &L, - now: i64, ) -> Result<(), Error>> where W: DynamicIvkWrite, @@ -474,12 +478,8 @@ where { let key = item.key; let anchor = directory.anchor; - lock.write("dynamic_sweep.attempt", || { - wallet.begin_dynamic_sweep_attempt(account, key, now, check.seen) - }) - .map_err(Error::Wallet)?; if item.lookup.is_none() { - let payments: Vec<_> = if check.paid { + let payments: Vec<_> = if paid { let receiver = Receiver::from_bytes(item.receiver).map_err(DirectoryError::from)?; let accepted = directory.accepted; let session = directory.session(lookups).await?; @@ -518,7 +518,7 @@ where } } } - let witnesses = if check.paid || item.lookup.is_some() { + let witnesses = if paid || item.lookup.is_some() { Some(&directory.session(lookups).await?.witnesses) } else { None diff --git a/zakura/pir-receiver/tests/sweep.rs b/zakura/pir-receiver/tests/sweep.rs index ee884c23..e2b35391 100644 --- a/zakura/pir-receiver/tests/sweep.rs +++ b/zakura/pir-receiver/tests/sweep.rs @@ -2,7 +2,14 @@ //! directory served in process, over the service's own routes and real PIR, and //! imports what it finds only after checking it against its own chain. -use std::{cell::Cell, collections::BTreeMap, sync::Mutex}; +use std::{ + cell::{Cell, RefCell}, + collections::BTreeMap, + sync::{ + Arc, Mutex, + atomic::{AtomicBool, Ordering}, + }, +}; use axum::{Router, body::Body, http::Request}; use base64::Engine as _; @@ -722,6 +729,81 @@ async fn a_seen_hit_outlives_a_rejected_payment_and_a_later_miss() { assert_eq!(issued.key_id(), KeyId::new(Purpose::Receive, 1)); } +/// Records each write's label, and arms `fail` for the first attempt's write, which +/// the wallet's commit hook then fails. +struct FailFirstAttempt { + fail: Arc, + labels: RefCell>, +} + +impl WriteLock for FailFirstAttempt { + fn write(&self, label: &'static str, write: impl FnOnce() -> T) -> T { + let mut labels = self.labels.borrow_mut(); + if label == "dynamic_sweep.attempt" && !labels.contains(&label) { + self.fail.store(true, Ordering::SeqCst); + } + labels.push(label); + drop(labels); + write() + } +} + +/// A failure to lease an attempt stops the run with the wallet's error, before that +/// key's lookup and without retrying it. +#[tokio::test] +async fn a_failed_attempt_write_stops_the_run() { + let mut st = ironwood_wallet(); + let network = *st.network(); + let account = st.test_account().unwrap().id(); + let (record, note_data) = pay_restored_key(&mut st, 0, 0); + st.generate_and_scan_empty_blocks_with_dynamic_ivks(1); + restore(&mut st, account); + let through = tip(&st); + let directory = Directory::new(publish( + std::slice::from_ref(&record), + &[record.payment.cmx], + through, + &provider(&[]), + )); + let mut notes = Notes { + data: BTreeMap::from([(record.payment.position, note_data)]), + requested: Vec::new(), + }; + let lock = FailFirstAttempt { + fail: Arc::new(AtomicBool::new(false)), + labels: RefCell::new(Vec::new()), + }; + let fail = lock.fail.clone(); + // Returning true turns the commit into a rollback, which fails the write. + st.wallet() + .conn() + .commit_hook(Some(move || fail.swap(false, Ordering::SeqCst))); + let result = sweep( + st.wallet_mut().db_mut(), + &network, + &[account], + through, + GENESIS, + ORIGIN, + &directory, + &mut notes, + &lock, + NOW, + ) + .await; + assert!(matches!(result, Err(Error::Wallet(_))), "{result:?}"); + assert_eq!( + *lock.labels.borrow(), + ["dynamic_sweep.due", "dynamic_sweep.attempt"] + ); + assert!( + directory.requests().iter().all(|r| !r.starts_with("POST")), + "{:?}", + directory.requests() + ); + assert!(notes.requested.is_empty()); +} + /// A provider with a recent set but no seen set may have had any receiver, so issuance /// and sweeps both refuse the publication until every provider has a dated seen set. /// The sets then cover the quote times they all cover, and hold what any of them holds. From 422938c298ba7195797abc37d23fc29a5bfe4c0f Mon Sep 17 00:00:00 2001 From: Adam Tucker Date: Sat, 10 Oct 2026 12:05:59 -0600 Subject: [PATCH 10/40] Cover a deferred payment replaced by a later publication A candidate whose application waits for scanning, a witness checkpoint or spend history takes the next attempt's answer: a corrected position is recovered and a paid-filter miss drops it. An unchanged answer is looked up again but reuses the queued note data. Co-Authored-By: Claude Opus 5.5 --- zakura/pir-receiver/README.md | 3 +- zakura/pir-receiver/tests/sweep.rs | 193 +++++++++++++++++++++++++++++ 2 files changed, 195 insertions(+), 1 deletion(-) diff --git a/zakura/pir-receiver/README.md b/zakura/pir-receiver/README.md index 036509e5..fe2a219f 100644 --- a/zakura/pir-receiver/README.md +++ b/zakura/pir-receiver/README.md @@ -36,6 +36,7 @@ routes and checks that a restored wallet finds and imports a payout, extends its lookahead past it, repeats no finished sweep, imports every payment of a key paid twice, makes no lookup when nothing was paid, keeps every swept address scanning and issues no seen one, refuses a publication that is not on its chain before any lookup, -and refuses one in which a provider lacks a seen set. +refuses one in which a provider lacks a seen set, and replaces a deferred payment with +a later publication's answer. Against a fake Enhance service, `EnhanceNotes` accepts fresh routing after a refused query. diff --git a/zakura/pir-receiver/tests/sweep.rs b/zakura/pir-receiver/tests/sweep.rs index e2b35391..33699fdb 100644 --- a/zakura/pir-receiver/tests/sweep.rs +++ b/zakura/pir-receiver/tests/sweep.rs @@ -1119,3 +1119,196 @@ async fn enhance_notes_accept_fresh_routing_after_a_refused_query() { assert_eq!((enhance.inits.get(), enhance.queries.get()), (2, 2)); } } + +/// Runs one sweep of `accounts` through `through` at `now`. +async fn run( + st: &mut State, + accounts: &[AccountUuid], + through: ChainPoint, + directory: impl Transport, + notes: &mut Notes, + now: i64, +) -> Result< + zakura_pir_receiver::Swept, + Error, +> { + let network = *st.network(); + sweep( + st.wallet_mut().db_mut(), + &network, + accounts, + through, + GENESIS, + ORIGIN, + directory, + notes, + &NoLock, + now, + ) + .await +} + +/// A queued candidate that defers is replaced by each later attempt's answer from the +/// publication it uses: a corrected payment is recovered, and a paid-filter miss drops +/// the candidate, whatever made it wait. +#[tokio::test] +async fn a_deferred_candidate_takes_each_publications_current_answer() { + use PaymentApplication::{AwaitingScan, AwaitingSpendHistory, AwaitingWitness}; + for (deferral, corrected) in [ + (AwaitingScan, true), + (AwaitingScan, false), + (AwaitingWitness, false), + (AwaitingSpendHistory, false), + ] { + let mut st = ironwood_wallet(); + let account = st.test_account().unwrap().id(); + let key = KeyId::new(Purpose::Receive, 0); + let (record, note_data) = pay_restored_key(&mut st, key.index(), 0); + st.generate_and_scan_empty_blocks_with_dynamic_ivks(1); + restore(&mut st, account); + let mut through = tip(&st); + let mut first = record.clone(); + let mut commitments = vec![record.payment.cmx]; + match deferral { + // A block below the tip is not scanned yet. + AwaitingScan => { + st.generate_empty_block(); + let (top, _) = st.generate_empty_block(); + st.scan_cached_blocks_with_dynamic_ivks(top, 1); + let block = st.wallet().block_metadata(top).unwrap().unwrap(); + through = ChainPoint { + height: top, + hash: block.block_hash(), + }; + if corrected { + // Publication A misplaces the payment in another tree. + first.payment.position = 1; + commitments.insert(0, [1; 32]); + } + } + AwaitingWitness => { + st.wallet() + .conn() + .execute("DELETE FROM ironwood_tree_checkpoints", []) + .unwrap(); + } + _ => { + st.wallet() + .conn() + .execute_batch( + "DELETE FROM nullifier_map; DELETE FROM ironwood_nullifier_scan_blocks;", + ) + .unwrap(); + } + } + let mut notes = Notes { + data: BTreeMap::from([(0, note_data), (1, note_data)]), + requested: Vec::new(), + }; + let a = Directory::new(publish(&[first], &commitments, through, &provider(&[]))); + let swept = run(&mut st, &[account], through, &a, &mut notes, NOW) + .await + .unwrap(); + assert!( + matches!( + &swept.deferred[..], + [(k, Error::Queued(d))] if *k == key && *d == deferral + ), + "{deferral:?}: {:?}", + swept.deferred + ); + assert!(swept.pending); + if deferral == AwaitingScan { + st.scan_cached_blocks_with_dynamic_ivks(through.height - 1, 1); + } + + // Publication B, after the attempt's backoff. + let b = if corrected { + publish( + std::slice::from_ref(&record), + &[record.payment.cmx], + through, + &provider(&[]), + ) + } else { + publish(&[], &[], through, &provider(&[])) + }; + let swept = run( + &mut st, + &[account], + through, + &Directory::new(b), + &mut notes, + NOW + 60, + ) + .await + .unwrap(); + assert!( + swept.deferred.is_empty(), + "{deferral:?}: {:?}", + swept.deferred + ); + assert!(!swept.pending, "{deferral:?}"); + let value = if corrected { VALUE } else { 0 }; + assert_eq!( + st.get_total_balance(account), + Zatoshis::const_from_u64(value), + "{deferral:?}" + ); + } +} + +/// An unchanged answer reuses the queued candidate's note data. +#[tokio::test] +async fn an_unchanged_answer_reuses_queued_note_data() { + let mut st = ironwood_wallet(); + let account = st.test_account().unwrap().id(); + let (record, note_data) = pay_restored_key(&mut st, 0, 0); + st.generate_and_scan_empty_blocks_with_dynamic_ivks(1); + restore(&mut st, account); + let through = tip(&st); + st.wallet() + .conn() + .execute_batch("DELETE FROM nullifier_map; DELETE FROM ironwood_nullifier_scan_blocks;") + .unwrap(); + let mut notes = Notes { + data: BTreeMap::from([(record.payment.position, note_data)]), + requested: Vec::new(), + }; + let directory = Directory::new(publish( + std::slice::from_ref(&record), + &[record.payment.cmx], + through, + &provider(&[]), + )); + let swept = run(&mut st, &[account], through, &directory, &mut notes, NOW) + .await + .unwrap(); + assert!( + matches!( + &swept.deferred[..], + [(_, Error::Queued(PaymentApplication::AwaitingSpendHistory))] + ), + "{:?}", + swept.deferred + ); + // The replay the deferral queued is still unscanned. + let swept = run(&mut st, &[account], through, &directory, &mut notes, NOW + 60) + .await + .unwrap(); + assert!( + matches!( + &swept.deferred[..], + [(_, Error::Queued(PaymentApplication::AwaitingScan))] + ), + "{:?}", + swept.deferred + ); + let posts = directory + .requests() + .iter() + .filter(|r| *r == "POST /v1/receiver/query") + .count(); + assert!(posts >= 2, "{:?}", directory.requests()); + assert_eq!(notes.requested, [vec![record.payment.position]]); +} From b994e58306e6d6d6fbcd33e44a531bf72464928c Mon Sep 17 00:00:00 2001 From: Adam Tucker Date: Sat, 10 Oct 2026 12:08:26 -0600 Subject: [PATCH 11/40] Sweep one batch per account in each run A seen set that held every tested receiver moved the lookahead after each batch, and the run selected the new keys again, so one publication could keep a run deriving and writing keys until index exhaustion. Drop the refill loop: a run prepares at most one batch per account, maintains the lookahead once and leaves the new keys pending for a later run. Tests that expected one run to finish everything now run until nothing is pending. Co-Authored-By: Claude Opus 5.5 --- zakura/pir-receiver/src/sweep.rs | 100 ++++++++-------- zakura/pir-receiver/tests/sweep.rs | 184 ++++++++++++++++------------- 2 files changed, 150 insertions(+), 134 deletions(-) diff --git a/zakura/pir-receiver/src/sweep.rs b/zakura/pir-receiver/src/sweep.rs index 299729ba..b55f9249 100644 --- a/zakura/pir-receiver/src/sweep.rs +++ b/zakura/pir-receiver/src/sweep.rs @@ -188,10 +188,11 @@ pub struct Swept { /// [`DynamicIvkWrite::apply_dynamic_sweep`]). /// /// A run that looks nothing up opens no PIR session and fetches no witnesses. New -/// payments a lookup finds take their note data from `notes`. After each batch the -/// incoming lookahead moves past paid indices and its new keys are swept too. A key -/// that fails is backed off and listed in [`Swept::deferred`], and the others go on, -/// unless the directory's session or connection failed: then the run stops before +/// payments a lookup finds take their note data from `notes`. A run sweeps at most one +/// batch of due keys per account, then moves the incoming lookahead past paid and seen +/// indices; its new keys are left for a later run, and [`Swept::pending`] reports them. +/// A key that fails is backed off and listed in [`Swept::deferred`], and the others go +/// on, unless the directory's session or connection failed: then the run stops before /// leasing more keys. A lease the wallet fails to store stops the run with its error. /// `now` is the caller's clock in seconds, for retry backoff. #[allow(clippy::too_many_arguments)] @@ -221,7 +222,7 @@ where }; let tip = wallet.chain_height().map_err(Error::Wallet)?; if tip == Some(through.height) { - let mut work = due(wallet, accounts, through, lock, now)?; + let work = due(wallet, accounts, through, lock, now)?; if !work.is_empty() { let accepted = Directory::accept(wallet, params, through, genesis, origin, transport).await; @@ -240,57 +241,52 @@ where return Err(e); } }; - 'run: loop { - let checks = directory.check(&work)?; - let lookups = checks.iter().filter(|check| check.paid).count(); - if let Some(session) = &mut directory.session { - session.client.use_file_for_work(lookups).await?; - } - for ((account, item), check) in work.into_iter().zip(checks) { - // A key whose lease is not stored would be due again at once. - lock.write("dynamic_sweep.attempt", || { - wallet.begin_dynamic_sweep_attempt(account, item.key, now, check.seen) - }) - .map_err(Error::Wallet)?; - let swept_key = sweep_key( - wallet, - &mut directory, - account, - &item, - check.paid, - lookups, - through, - notes, - lock, - ); - match swept_key.await { - Ok(()) => swept.finished += 1, - Err(e) => { - // Every later key would fail the same way and back off. - let unavailable = matches!( - e, - Error::Directory( - DirectoryError::Revision | DirectoryError::Transport(_) - ) - ); - swept.deferred.push((item.key, e)); - if unavailable { - break 'run; - } - } - } - } - lock.write("dynamic_sweep.lookahead", || { - accounts - .iter() - .try_for_each(|account| wallet.maintain_dynamic_ivks(*account)) + let checks = directory.check(&work)?; + let lookups = checks.iter().filter(|check| check.paid).count(); + if let Some(session) = &mut directory.session { + session.client.use_file_for_work(lookups).await?; + } + for ((account, item), check) in work.into_iter().zip(checks) { + // A key whose lease is not stored would be due again at once. + lock.write("dynamic_sweep.attempt", || { + wallet.begin_dynamic_sweep_attempt(account, item.key, now, check.seen) }) .map_err(Error::Wallet)?; - work = due(wallet, accounts, through, lock, now)?; - if work.is_empty() { - break; + let swept_key = sweep_key( + wallet, + &mut directory, + account, + &item, + check.paid, + lookups, + through, + notes, + lock, + ); + match swept_key.await { + Ok(()) => swept.finished += 1, + Err(e) => { + // Every later key would fail the same way and back off. + let unavailable = matches!( + e, + Error::Directory( + DirectoryError::Revision | DirectoryError::Transport(_) + ) + ); + swept.deferred.push((item.key, e)); + if unavailable { + break; + } + } } } + // New lookahead keys wait for a later run, which bounds this one. + lock.write("dynamic_sweep.lookahead", || { + accounts + .iter() + .try_for_each(|account| wallet.maintain_dynamic_ivks(*account)) + }) + .map_err(Error::Wallet)?; } } for account in accounts { diff --git a/zakura/pir-receiver/tests/sweep.rs b/zakura/pir-receiver/tests/sweep.rs index 33699fdb..01fb78ff 100644 --- a/zakura/pir-receiver/tests/sweep.rs +++ b/zakura/pir-receiver/tests/sweep.rs @@ -355,24 +355,10 @@ async fn a_restore_sweep_finds_a_payout_and_moves_the_lookahead_past_it() { data: BTreeMap::from([(record.payment.position, note_data)]), requested: Vec::new(), }; - let swept = sweep( - st.wallet_mut().db_mut(), - &network, - &[account], - through, - GENESIS, - ORIGIN, - &directory, - &mut notes, - &NoLock, - NOW, - ) - .await - .unwrap(); - assert!(swept.deferred.is_empty(), "{:?}", swept.deferred); - assert!(!swept.pending); - // The first window, then the keys above it that the paid index brings in. - assert_eq!(swept.finished as u64, RECEIVE_GAP_LIMIT + paid + 1); + let finished = settle(&mut st, &[account], through, &directory, &mut notes).await; + // The first window, then, in a later run, the keys above it that the paid index + // brings in. + assert_eq!(finished as u64, RECEIVE_GAP_LIMIT + paid + 1); assert_eq!(notes.requested, [vec![record.payment.position]]); assert_eq!( st.get_total_balance(account), @@ -412,7 +398,6 @@ async fn a_restore_sweep_finds_a_payout_and_moves_the_lookahead_past_it() { #[tokio::test] async fn a_key_paid_twice_is_imported_in_full() { let mut st = ironwood_wallet(); - let network = *st.network(); let account = st.test_account().unwrap().id(); let paid = 2; let (mut first, first_data) = pay_restored_key(&mut st, paid, 0); @@ -433,22 +418,7 @@ async fn a_key_paid_twice_is_imported_in_full() { data: BTreeMap::from([(0, first_data), (1, second_data)]), requested: Vec::new(), }; - let swept = sweep( - st.wallet_mut().db_mut(), - &network, - &[account], - through, - GENESIS, - ORIGIN, - &directory, - &mut notes, - &NoLock, - NOW, - ) - .await - .unwrap(); - assert!(swept.deferred.is_empty(), "{:?}", swept.deferred); - assert!(!swept.pending); + settle(&mut st, &[account], through, &directory, &mut notes).await; assert_eq!(notes.requested, [vec![0, 1]]); assert_eq!( st.get_total_balance(account), @@ -467,7 +437,6 @@ async fn a_key_paid_twice_is_imported_in_full() { #[tokio::test] async fn a_long_history_takes_its_note_data_in_batches() { let mut st = ironwood_wallet(); - let network = *st.network(); let account = st.test_account().unwrap().id(); let paid = 1; // One more payment than a batch of note data holds. @@ -490,22 +459,7 @@ async fn a_long_history_takes_its_note_data_in_batches() { data, requested: Vec::new(), }; - let swept = sweep( - st.wallet_mut().db_mut(), - &network, - &[account], - through, - GENESIS, - ORIGIN, - &directory, - &mut notes, - &NoLock, - NOW, - ) - .await - .unwrap(); - assert!(swept.deferred.is_empty(), "{:?}", swept.deferred); - assert!(!swept.pending); + settle(&mut st, &[account], through, &directory, &mut notes).await; let batches: Vec<_> = notes.requested.iter().map(Vec::len).collect(); assert_eq!(batches, [64, 1]); assert_eq!( @@ -562,7 +516,6 @@ async fn a_wallet_without_payments_downloads_only_the_filters() { #[tokio::test] async fn seen_addresses_keep_scanning_and_are_never_issued_again() { let mut st = ironwood_wallet(); - let network = *st.network(); let account = st.test_account().unwrap().id(); st.generate_and_scan_empty_blocks_with_dynamic_ivks(1); restore(&mut st, account); @@ -590,21 +543,7 @@ async fn seen_addresses_keep_scanning_and_are_never_issued_again() { data: BTreeMap::new(), requested: Vec::new(), }; - let swept = sweep( - st.wallet_mut().db_mut(), - &network, - &[account], - through, - GENESIS, - ORIGIN, - &directory, - &mut notes, - &NoLock, - NOW, - ) - .await - .unwrap(); - assert!(swept.deferred.is_empty(), "{:?}", swept.deferred); + settle(&mut st, &[account], through, &directory, &mut notes).await; // The seen address 2 moves the lookahead, and every swept key keeps scanning. let scanning = st.wallet().get_dynamic_scanning_keys().unwrap().len(); assert_eq!(scanning as u64, RECEIVE_GAP_LIMIT + 3); @@ -1148,6 +1087,29 @@ async fn run( .await } +/// Runs sweeps at [`NOW`] until none is pending, expecting no deferral, and returns how +/// many keys they finished. +async fn settle( + st: &mut State, + accounts: &[AccountUuid], + through: ChainPoint, + directory: &Directory, + notes: &mut Notes, +) -> usize { + let mut finished = 0; + for _ in 0..20 { + let swept = run(st, accounts, through, directory, notes, NOW) + .await + .unwrap(); + assert!(swept.deferred.is_empty(), "{:?}", swept.deferred); + finished += swept.finished; + if !swept.pending { + return finished; + } + } + panic!("sweeps are still pending after 20 runs"); +} + /// A queued candidate that defers is replaced by each later attempt's answer from the /// publication it uses: a corrected payment is recovered, and a paid-filter miss drops /// the candidate, whatever made it wait. @@ -1233,22 +1195,16 @@ async fn a_deferred_candidate_takes_each_publications_current_answer() { } else { publish(&[], &[], through, &provider(&[])) }; - let swept = run( - &mut st, - &[account], - through, - &Directory::new(b), - &mut notes, - NOW + 60, - ) - .await - .unwrap(); + let b = Directory::new(b); + let swept = run(&mut st, &[account], through, &b, &mut notes, NOW + 60) + .await + .unwrap(); assert!( swept.deferred.is_empty(), "{deferral:?}: {:?}", swept.deferred ); - assert!(!swept.pending, "{deferral:?}"); + settle(&mut st, &[account], through, &b, &mut notes).await; let value = if corrected { VALUE } else { 0 }; assert_eq!( st.get_total_balance(account), @@ -1293,9 +1249,16 @@ async fn an_unchanged_answer_reuses_queued_note_data() { swept.deferred ); // The replay the deferral queued is still unscanned. - let swept = run(&mut st, &[account], through, &directory, &mut notes, NOW + 60) - .await - .unwrap(); + let swept = run( + &mut st, + &[account], + through, + &directory, + &mut notes, + NOW + 60, + ) + .await + .unwrap(); assert!( matches!( &swept.deferred[..], @@ -1312,3 +1275,60 @@ async fn an_unchanged_answer_reuses_queued_note_data() { assert!(posts >= 2, "{:?}", directory.requests()); assert_eq!(notes.requested, [vec![record.payment.position]]); } + +/// The receiver of `account_fvk`'s incoming dynamic key `index`. +fn incoming_receiver(account_fvk: &FullViewingKey, index: u64) -> Receiver { + let fvk = KeyId::new(Purpose::Receive, index) + .derive(account_fvk) + .unwrap(); + Receiver::from_bytes(fvk.address_at(0u32, Scope::External).to_raw_address_bytes()).unwrap() +} + +/// A seen set that holds every restored receiver moves the lookahead after each batch, +/// so a run sweeps at most one batch per account and leaves the new keys pending. Later +/// runs progress and find a payment far past the first batch. +#[tokio::test] +async fn each_run_sweeps_one_batch_and_later_runs_progress() { + let mut st = ironwood_wallet(); + let account = st.test_account().unwrap().id(); + let paid = 100; + let (record, note_data) = pay_restored_key(&mut st, paid, 0); + st.generate_and_scan_empty_blocks_with_dynamic_ivks(1); + restore(&mut st, account); + let through = tip(&st); + let parent = FullViewingKey::from(st.test_account().unwrap().usk().orchard()); + // The set is finite, so an unbounded run stops past it instead of hanging. + let seen: Vec<_> = (0..200).map(|i| incoming_receiver(&parent, i)).collect(); + let directory = Directory::new(publish( + std::slice::from_ref(&record), + &[record.payment.cmx], + through, + &provider(&seen), + )); + let mut notes = Notes { + data: BTreeMap::from([(record.payment.position, note_data)]), + requested: Vec::new(), + }; + let mut runs = 0; + loop { + let swept = run(&mut st, &[account], through, &directory, &mut notes, NOW) + .await + .unwrap(); + runs += 1; + assert!(swept.deferred.is_empty(), "{:?}", swept.deferred); + assert!( + (1..=64).contains(&swept.finished), + "run {runs} finished {}", + swept.finished + ); + if !swept.pending { + break; + } + assert!(runs < 20); + } + assert!(runs > 1); + assert_eq!( + st.get_total_balance(account), + Zatoshis::const_from_u64(VALUE) + ); +} From 693ef90a350a1fdfdbc446b1939ce00d2fc84e1d Mon Sep 17 00:00:00 2001 From: Adam Tucker Date: Sat, 10 Oct 2026 12:10:21 -0600 Subject: [PATCH 12/40] Name the account of each deferred sweep key `KeyId` holds only a purpose and index, so with several accounts a deferred `Receive/0` could belong to any of them. `Swept` now takes the account type and lists each deferred key as `(account, key, error)`. Co-Authored-By: Claude Opus 5.5 --- zakura/pir-receiver/src/sweep.rs | 13 ++--- zakura/pir-receiver/tests/sweep.rs | 83 +++++++++++++++++++++++++++--- 2 files changed, 84 insertions(+), 12 deletions(-) diff --git a/zakura/pir-receiver/src/sweep.rs b/zakura/pir-receiver/src/sweep.rs index b55f9249..9efdf9a6 100644 --- a/zakura/pir-receiver/src/sweep.rs +++ b/zakura/pir-receiver/src/sweep.rs @@ -157,13 +157,14 @@ where } } -/// What one [`sweep`] run did. `E` is the wallet's error type. +/// What one [`sweep`] run did. `A` is the wallet's account identifier and `E` its error +/// type. #[derive(Debug)] -pub struct Swept { +pub struct Swept { /// Keys whose sweep finished. pub finished: usize, - /// Keys left for a later run, each already backed off, with why. - pub deferred: Vec<(KeyId, Error)>, + /// Keys left for a later run, each with its account, already backed off, with why. + pub deferred: Vec<(A, KeyId, Error)>, /// Whether any of the accounts still has unfinished sweeps or queued payments. pub pending: bool, } @@ -207,7 +208,7 @@ pub async fn sweep( notes: &mut N, lock: &L, now: i64, -) -> Result>, Error>> +) -> Result, AccountError>, Error>> where W: DynamicIvkWrite, P: Parameters, @@ -273,7 +274,7 @@ where DirectoryError::Revision | DirectoryError::Transport(_) ) ); - swept.deferred.push((item.key, e)); + swept.deferred.push((account, item.key, e)); if unavailable { break; } diff --git a/zakura/pir-receiver/tests/sweep.rs b/zakura/pir-receiver/tests/sweep.rs index 01fb78ff..a5b621d5 100644 --- a/zakura/pir-receiver/tests/sweep.rs +++ b/zakura/pir-receiver/tests/sweep.rs @@ -106,7 +106,17 @@ fn tip(st: &State) -> ChainPoint { /// `position`, which is the number of earlier Ironwood outputs. fn pay_restored_key(st: &mut State, index: u64, position: u64) -> (Record, [u8; 528]) { let parent = FullViewingKey::from(st.test_account().unwrap().usk().orchard()); - let fvk = KeyId::new(Purpose::Receive, index).derive(&parent).unwrap(); + pay_restored_key_of(st, &parent, index, position) +} + +/// [`pay_restored_key`] for the account whose Orchard viewing key is `parent`. +fn pay_restored_key_of( + st: &mut State, + parent: &FullViewingKey, + index: u64, + position: u64, +) -> (Record, [u8; 528]) { + let fvk = KeyId::new(Purpose::Receive, index).derive(parent).unwrap(); let (height, _, _) = st.generate_next_block( &IronwoodFvk(fvk.clone()), AddressType::DefaultExternal, @@ -626,7 +636,7 @@ async fn a_seen_hit_outlives_a_rejected_payment_and_a_later_miss() { assert!( matches!( &swept.deferred[..], - [(deferred, Error::Queued(PaymentApplication::Rejected))] if *deferred == key + [(_, deferred, Error::Queued(PaymentApplication::Rejected))] if *deferred == key ), "{:?}", swept.deferred @@ -1068,7 +1078,7 @@ async fn run( notes: &mut Notes, now: i64, ) -> Result< - zakura_pir_receiver::Swept, + zakura_pir_receiver::Swept, Error, > { let network = *st.network(); @@ -1174,7 +1184,7 @@ async fn a_deferred_candidate_takes_each_publications_current_answer() { assert!( matches!( &swept.deferred[..], - [(k, Error::Queued(d))] if *k == key && *d == deferral + [(_, k, Error::Queued(d))] if *k == key && *d == deferral ), "{deferral:?}: {:?}", swept.deferred @@ -1243,7 +1253,11 @@ async fn an_unchanged_answer_reuses_queued_note_data() { assert!( matches!( &swept.deferred[..], - [(_, Error::Queued(PaymentApplication::AwaitingSpendHistory))] + [( + _, + _, + Error::Queued(PaymentApplication::AwaitingSpendHistory) + )] ), "{:?}", swept.deferred @@ -1262,7 +1276,7 @@ async fn an_unchanged_answer_reuses_queued_note_data() { assert!( matches!( &swept.deferred[..], - [(_, Error::Queued(PaymentApplication::AwaitingScan))] + [(_, _, Error::Queued(PaymentApplication::AwaitingScan))] ), "{:?}", swept.deferred @@ -1332,3 +1346,60 @@ async fn each_run_sweeps_one_batch_and_later_runs_progress() { Zatoshis::const_from_u64(VALUE) ); } + +/// Accounts share key identities, so each deferred key names its account. +#[tokio::test] +async fn each_deferred_key_names_its_account() { + let mut st = ironwood_wallet(); + let first = st.test_account().unwrap().id(); + let (second, usk) = st.create_account_from_test_seed("second"); + let key = KeyId::new(Purpose::Receive, 0); + let (paid_first, first_data) = pay_restored_key(&mut st, key.index(), 0); + let second_fvk = FullViewingKey::from(usk.orchard()); + let (paid_second, second_data) = pay_restored_key_of(&mut st, &second_fvk, 0, 1); + st.generate_and_scan_empty_blocks_with_dynamic_ivks(1); + restore(&mut st, first); + restore(&mut st, second); + let through = tip(&st); + // The first payment waits for spend history, and the replay that queues makes the + // second wait for scanning. + st.wallet() + .conn() + .execute_batch("DELETE FROM nullifier_map; DELETE FROM ironwood_nullifier_scan_blocks;") + .unwrap(); + let directory = Directory::new(publish( + &[paid_first.clone(), paid_second.clone()], + &[paid_first.payment.cmx, paid_second.payment.cmx], + through, + &provider(&[]), + )); + let mut notes = Notes { + data: BTreeMap::from([(0, first_data), (1, second_data)]), + requested: Vec::new(), + }; + let swept = run( + &mut st, + &[first, second], + through, + &directory, + &mut notes, + NOW, + ) + .await + .unwrap(); + let deferred: Vec<_> = swept + .deferred + .iter() + .map(|(account, key, e)| match e { + Error::Queued(application) => (*account, *key, *application), + other => panic!("{other:?}"), + }) + .collect(); + assert_eq!( + deferred, + [ + (first, key, PaymentApplication::AwaitingSpendHistory), + (second, key, PaymentApplication::AwaitingScan), + ] + ); +} From a1e359c9c01be8f61cac677ce05ebf7ab208233e Mon Sep 17 00:00:00 2001 From: Adam Tucker Date: Sat, 10 Oct 2026 12:58:17 -0600 Subject: [PATCH 13/40] Stop the sweep run when the PIR session fails to open Opening the session moves the transport out of the directory, so when that fails with a non-transport error such as a malformed witness snapshot, every later paid key was still leased, failed with `Error::Unavailable` and backed off. The run now stops after the first failure whenever the directory is left with neither a session nor a transport, as it already did for revisions and transport errors. Co-Authored-By: Claude Opus 5.5 --- zakura/pir-receiver/src/sweep.rs | 17 ++++--- zakura/pir-receiver/tests/sweep.rs | 73 ++++++++++++++++++++++++++++++ 2 files changed, 83 insertions(+), 7 deletions(-) diff --git a/zakura/pir-receiver/src/sweep.rs b/zakura/pir-receiver/src/sweep.rs index 9efdf9a6..8840e300 100644 --- a/zakura/pir-receiver/src/sweep.rs +++ b/zakura/pir-receiver/src/sweep.rs @@ -267,13 +267,16 @@ where match swept_key.await { Ok(()) => swept.finished += 1, Err(e) => { - // Every later key would fail the same way and back off. - let unavailable = matches!( - e, - Error::Directory( - DirectoryError::Revision | DirectoryError::Transport(_) - ) - ); + // Every later key would fail the same way and back off. A session + // that failed to open took the transport without replacing it. + let unavailable = directory.transport.is_none() + && directory.session.is_none() + || matches!( + e, + Error::Directory( + DirectoryError::Revision | DirectoryError::Transport(_) + ) + ); swept.deferred.push((account, item.key, e)); if unavailable { break; diff --git a/zakura/pir-receiver/tests/sweep.rs b/zakura/pir-receiver/tests/sweep.rs index a5b621d5..c9b3ab1b 100644 --- a/zakura/pir-receiver/tests/sweep.rs +++ b/zakura/pir-receiver/tests/sweep.rs @@ -5,6 +5,7 @@ use std::{ cell::{Cell, RefCell}, collections::BTreeMap, + num::NonZeroU32, sync::{ Arc, Mutex, atomic::{AtomicBool, Ordering}, @@ -879,6 +880,78 @@ async fn a_revoked_session_stops_the_run_before_leasing_more_keys() { assert_eq!(posts, 1); } +/// A directory whose witness snapshot is malformed, so no session opens. +struct MalformedWitnesses(Directory); + +impl Transport for MalformedWitnesses { + async fn get(&self, url: &str, limit: usize) -> Result, DirectoryError> { + if url.contains("/v1/receiver/witness/") { + self.0.requests.lock().unwrap().push(format!("GET {url}")); + return Ok(vec![0]); + } + self.0.get(url, limit).await + } + + async fn post( + &self, + url: &str, + body: Vec, + limit: usize, + ) -> Result, DirectoryError> { + self.0.post(url, body, limit).await + } +} + +/// A session that fails to open stops the run, so later paid keys are neither leased +/// nor backed off. +#[tokio::test] +async fn a_failed_session_open_stops_the_run_before_leasing_more_keys() { + let mut st = ironwood_wallet(); + let account = st.test_account().unwrap().id(); + // The batch's first two keys are paid, so each needs the session. + let records: Vec<_> = (0..2) + .map(|index| pay_restored_key(&mut st, index, index).0) + .collect(); + st.generate_and_scan_empty_blocks_with_dynamic_ivks(1); + restore(&mut st, account); + let through = tip(&st); + let cmxs: Vec<_> = records.iter().map(|record| record.payment.cmx).collect(); + let directory = MalformedWitnesses(Directory::new(publish( + &records, + &cmxs, + through, + &provider(&[]), + ))); + let mut notes = Notes { + data: BTreeMap::new(), + requested: Vec::new(), + }; + let swept = run(&mut st, &[account], through, &directory, &mut notes, NOW) + .await + .unwrap(); + assert!( + matches!( + &swept.deferred[..], + [(_, key, Error::Directory(_))] if *key == KeyId::new(Purpose::Receive, 0) + ), + "{:?}", + swept.deferred + ); + assert_eq!(swept.finished, 0); + let due = st + .wallet_mut() + .db_mut() + .prepare_dynamic_sweeps(account, through, NOW, NonZeroU32::new(64).unwrap()) + .unwrap() + .unwrap(); + let due = |index| { + due.iter() + .any(|item| item.key == KeyId::new(Purpose::Receive, index)) + }; + assert!(!due(0)); + assert!(due(1)); +} + #[tokio::test] async fn a_publication_off_the_wallets_chain_is_refused_before_any_lookup() { let mut st = ironwood_wallet(); From bb62c90f63c05d288ec148987a34e6211d452bd0 Mon Sep 17 00:00:00 2001 From: Adam Tucker Date: Sat, 10 Oct 2026 14:06:27 -0600 Subject: [PATCH 14/40] Check the network of fetched seen sets `fetch_seen` trusted any well-formed publication at `origin`, without checking `manifest.directory.genesis`. With `origin` pointed at another network, a miss in that network's seen sets could pass to `ProviderSeen` as evidence that an address was never quoted, and a restored wallet could reissue an address it had already used. It now takes the network's genesis, as `sweep` does, and refuses a publication committing to another one with the error `sweep`'s coverage check gives, `receiver_directory::Error::Coverage`. Co-Authored-By: Claude Opus 5.5 --- zakura/pir-receiver/src/seen.rs | 18 +++++++++++++---- zakura/pir-receiver/tests/sweep.rs | 31 +++++++++++++++++++++++++----- 2 files changed, 40 insertions(+), 9 deletions(-) diff --git a/zakura/pir-receiver/src/seen.rs b/zakura/pir-receiver/src/seen.rs index b5fb678f..2391d886 100644 --- a/zakura/pir-receiver/src/seen.rs +++ b/zakura/pir-receiver/src/seen.rs @@ -55,11 +55,21 @@ impl Seen { } } -/// Downloads the seen sets of the publication `origin` serves. Fails if it declares -/// none, or if any provider with a set lacks a seen set with its feed's read times (see -/// [`seen_sets`]). -pub async fn fetch_seen(origin: &str, transport: &T) -> Result { +/// Downloads the seen sets of the publication `origin` serves, which must commit to +/// the network's `genesis` ([`MAINNET_GENESIS`](crate::MAINNET_GENESIS) on mainnet), as +/// for `sweep`, since another network's sets say nothing of this one's quotes. Fails if +/// it declares none, or if any provider with a set lacks a seen set with its feed's read +/// times (see [`seen_sets`]). +pub async fn fetch_seen( + origin: &str, + transport: &T, + genesis: [u8; 32], +) -> Result { let manifest = DirectoryClient::fetch_manifest(origin, transport).await?; + // The error `sweep`'s coverage check gives for another genesis. + if manifest.directory.genesis != genesis { + return Err(receiver_directory::Error::Coverage.into()); + } let filters = DirectoryClient::fetch_filters(origin, transport, &manifest).await?; let mut seen = Seen { salt: manifest.directory.salt, diff --git a/zakura/pir-receiver/tests/sweep.rs b/zakura/pir-receiver/tests/sweep.rs index c9b3ab1b..627fe94d 100644 --- a/zakura/pir-receiver/tests/sweep.rs +++ b/zakura/pir-receiver/tests/sweep.rs @@ -560,7 +560,7 @@ async fn seen_addresses_keep_scanning_and_are_never_issued_again() { assert_eq!(scanning as u64, RECEIVE_GAP_LIMIT + 3); // Issuance reads the same seen set, skips both, and takes the lowest address the // provider never had. - let seen = fetch_seen(ORIGIN, &directory).await.unwrap(); + let seen = fetch_seen(ORIGIN, &directory, GENESIS).await.unwrap(); assert_eq!( (seen.since(), seen.until()), (NOW - 2 * RESTORE_WATCH_SECS, NOW - 60) @@ -663,7 +663,7 @@ async fn a_seen_hit_outlives_a_rejected_payment_and_a_later_miss() { assert!(swept.deferred.is_empty(), "{:?}", swept.deferred); assert!(!swept.pending); assert!(seen(&st)); - let fetched = fetch_seen(ORIGIN, &b).await.unwrap(); + let fetched = fetch_seen(ORIGIN, &b, GENESIS).await.unwrap(); let contains = |receivers: &[[u8; 43]]| fetched.contains(receivers); let view = ProviderSeen { since: fetched.since(), @@ -754,6 +754,27 @@ async fn a_failed_attempt_write_stops_the_run() { assert!(notes.requested.is_empty()); } +/// Another network's seen sets say nothing about this network's quotes, so a +/// publication committing to another genesis is refused as a sweep refuses it. +#[tokio::test] +async fn seen_sets_of_another_network_are_refused() { + let mut st = ironwood_wallet(); + st.generate_and_scan_empty_blocks_with_dynamic_ivks(1); + let directory = Directory::new(publish(&[], &[], tip(&st), &provider(&[]))); + let refused = fetch_seen(ORIGIN, &directory, [8; 32]).await; + assert!( + matches!( + refused, + Err(DirectoryError::Directory( + receiver_directory::Error::Coverage + )) + ), + "{:?}", + refused.err() + ); + assert!(fetch_seen(ORIGIN, &directory, GENESIS).await.is_ok()); +} + /// A provider with a recent set but no seen set may have had any receiver, so issuance /// and sweeps both refuse the publication until every provider has a dated seen set. /// The sets then cover the quote times they all cover, and hold what any of them holds. @@ -782,7 +803,7 @@ async fn every_provider_with_sets_needs_a_seen_set() { sets }; let directory = Directory::new(publish(&[], &[], through, &sets(false))); - let refused = fetch_seen(ORIGIN, &directory).await; + let refused = fetch_seen(ORIGIN, &directory, GENESIS).await; assert!(matches!(refused, Err(DirectoryError::Malformed))); let mut notes = Notes { data: BTreeMap::new(), @@ -807,11 +828,11 @@ async fn every_provider_with_sets_needs_a_seen_set() { ); // A publication without provider sets is refused too. let directory = Directory::new(publish(&[], &[], through, &[])); - let refused = fetch_seen(ORIGIN, &directory).await; + let refused = fetch_seen(ORIGIN, &directory, GENESIS).await; assert!(matches!(refused, Err(DirectoryError::Malformed))); let directory = Directory::new(publish(&[], &[], through, &sets(true))); - let seen = fetch_seen(ORIGIN, &directory).await.unwrap(); + let seen = fetch_seen(ORIGIN, &directory, GENESIS).await.unwrap(); assert_eq!( (seen.since(), seen.until()), (NOW - RESTORE_WATCH_SECS, NOW - 60) From fa8bd177929404afe5b325e5912f1982b46c6452 Mon Sep 17 00:00:00 2001 From: Adam Tucker Date: Sat, 10 Oct 2026 14:54:38 -0600 Subject: [PATCH 15/40] Pin transparent PIR to the receiver's wallet-pir revision zakura-pir-transparent pinned wallet-pir at 06a972db while zakura-pir-receiver pins 0d6ad4aa, so a build with both carried two copies of pir-native, pir-control and pir-observability. Move every wallet-pir dependency of pir-transparent, normal and dev, to 0d6ad4aa, a descendant of 06a972db whose transparent crates are unchanged; the lockfile drops the second copies and changes nothing else. Co-Authored-By: Claude Opus 5.5 --- Cargo.lock | 61 ++++++-------------------- zakura/pir-transparent/CHANGELOG.md | 3 ++ zakura/pir-transparent/Cargo.toml | 20 ++++----- zakura/pir-transparent/src/recovery.rs | 2 +- 4 files changed, 28 insertions(+), 58 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 774c4e80..50eda04b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3712,17 +3712,6 @@ version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" -[[package]] -name = "pir-control" -version = "0.1.0" -source = "git+https://github.com/valargroup/wallet-pir?rev=06a972db469f302d8c561416d4674e937fdde0cb#06a972db469f302d8c561416d4674e937fdde0cb" -dependencies = [ - "hex", - "rand 0.8.8", - "serde", - "sha2 0.10.9", -] - [[package]] name = "pir-control" version = "0.1.0" @@ -3736,18 +3725,6 @@ dependencies = [ "tokio", ] -[[package]] -name = "pir-native" -version = "0.1.0" -source = "git+https://github.com/valargroup/wallet-pir?rev=06a972db469f302d8c561416d4674e937fdde0cb#06a972db469f302d8c561416d4674e937fdde0cb" -dependencies = [ - "ipir-sp 0.1.0-rc.6 (git+https://github.com/valargroup/ipir-sp.git?tag=v0.1.0-rc.6)", - "rand 0.8.8", - "rand_chacha 0.3.1", - "rayon", - "reinspiring 0.1.2 (git+https://github.com/valargroup/ipir-sp.git?tag=v0.1.0-rc.6)", -] - [[package]] name = "pir-native" version = "0.1.0" @@ -3760,16 +3737,6 @@ dependencies = [ "reinspiring 0.1.2 (git+https://github.com/valargroup/ipir-sp.git?tag=v0.1.0-rc.6)", ] -[[package]] -name = "pir-observability" -version = "0.1.0" -source = "git+https://github.com/valargroup/wallet-pir?rev=06a972db469f302d8c561416d4674e937fdde0cb#06a972db469f302d8c561416d4674e937fdde0cb" -dependencies = [ - "axum", - "http-body", - "serde", -] - [[package]] name = "pir-observability" version = "0.1.0" @@ -4319,7 +4286,7 @@ source = "git+https://github.com/valargroup/wallet-pir?rev=0d6ad4aa94abbb2838d41 dependencies = [ "hex", "ipir-sp 0.1.0-rc.6 (git+https://github.com/valargroup/ipir-sp.git?tag=v0.1.0-rc.6)", - "pir-native 0.1.0 (git+https://github.com/valargroup/wallet-pir?rev=0d6ad4aa94abbb2838d4182c869f06e1881e6c35)", + "pir-native", "rand 0.8.8", "receiver-directory", "serde", @@ -4335,8 +4302,8 @@ source = "git+https://github.com/valargroup/wallet-pir?rev=0d6ad4aa94abbb2838d41 dependencies = [ "axum", "hex", - "pir-control 0.1.0 (git+https://github.com/valargroup/wallet-pir?rev=0d6ad4aa94abbb2838d4182c869f06e1881e6c35)", - "pir-observability 0.1.0 (git+https://github.com/valargroup/wallet-pir?rev=0d6ad4aa94abbb2838d4182c869f06e1881e6c35)", + "pir-control", + "pir-observability", "receiver-directory", "receiver-pir", "serde_json", @@ -6896,7 +6863,7 @@ dependencies = [ [[package]] name = "transparent-events" version = "0.1.0" -source = "git+https://github.com/valargroup/wallet-pir?rev=06a972db469f302d8c561416d4674e937fdde0cb#06a972db469f302d8c561416d4674e937fdde0cb" +source = "git+https://github.com/valargroup/wallet-pir?rev=0d6ad4aa94abbb2838d4182c869f06e1881e6c35#0d6ad4aa94abbb2838d4182c869f06e1881e6c35" dependencies = [ "hex", "thiserror 2.0.21", @@ -6905,7 +6872,7 @@ dependencies = [ [[package]] name = "transparent-filter" version = "0.1.0" -source = "git+https://github.com/valargroup/wallet-pir?rev=06a972db469f302d8c561416d4674e937fdde0cb#06a972db469f302d8c561416d4674e937fdde0cb" +source = "git+https://github.com/valargroup/wallet-pir?rev=0d6ad4aa94abbb2838d4182c869f06e1881e6c35#0d6ad4aa94abbb2838d4182c869f06e1881e6c35" dependencies = [ "bitcoin", "hex", @@ -6917,10 +6884,10 @@ dependencies = [ [[package]] name = "transparent-native" version = "0.1.0" -source = "git+https://github.com/valargroup/wallet-pir?rev=06a972db469f302d8c561416d4674e937fdde0cb#06a972db469f302d8c561416d4674e937fdde0cb" +source = "git+https://github.com/valargroup/wallet-pir?rev=0d6ad4aa94abbb2838d4182c869f06e1881e6c35#0d6ad4aa94abbb2838d4182c869f06e1881e6c35" dependencies = [ "hex", - "pir-native 0.1.0 (git+https://github.com/valargroup/wallet-pir?rev=06a972db469f302d8c561416d4674e937fdde0cb)", + "pir-native", "rayon", "serde", "serde_json", @@ -6930,7 +6897,7 @@ dependencies = [ [[package]] name = "transparent-shard" version = "0.1.0" -source = "git+https://github.com/valargroup/wallet-pir?rev=06a972db469f302d8c561416d4674e937fdde0cb#06a972db469f302d8c561416d4674e937fdde0cb" +source = "git+https://github.com/valargroup/wallet-pir?rev=0d6ad4aa94abbb2838d4182c869f06e1881e6c35#0d6ad4aa94abbb2838d4182c869f06e1881e6c35" dependencies = [ "base64 0.22.1", "hex", @@ -6945,7 +6912,7 @@ dependencies = [ [[package]] name = "transparent-shard-server" version = "0.1.0" -source = "git+https://github.com/valargroup/wallet-pir?rev=06a972db469f302d8c561416d4674e937fdde0cb#06a972db469f302d8c561416d4674e937fdde0cb" +source = "git+https://github.com/valargroup/wallet-pir?rev=0d6ad4aa94abbb2838d4182c869f06e1881e6c35#0d6ad4aa94abbb2838d4182c869f06e1881e6c35" dependencies = [ "axum", "base64 0.22.1", @@ -6954,8 +6921,8 @@ dependencies = [ "http-body", "ipir-sp 0.1.0-rc.6 (git+https://github.com/valargroup/ipir-sp.git?tag=v0.1.0-rc.6)", "memmap2", - "pir-control 0.1.0 (git+https://github.com/valargroup/wallet-pir?rev=06a972db469f302d8c561416d4674e937fdde0cb)", - "pir-observability 0.1.0 (git+https://github.com/valargroup/wallet-pir?rev=06a972db469f302d8c561416d4674e937fdde0cb)", + "pir-control", + "pir-observability", "rayon", "reinspiring 0.1.2 (git+https://github.com/valargroup/ipir-sp.git?tag=v0.1.0-rc.6)", "rustix", @@ -6978,7 +6945,7 @@ dependencies = [ [[package]] name = "transparent-txid-client" version = "0.1.0" -source = "git+https://github.com/valargroup/wallet-pir?rev=06a972db469f302d8c561416d4674e937fdde0cb#06a972db469f302d8c561416d4674e937fdde0cb" +source = "git+https://github.com/valargroup/wallet-pir?rev=0d6ad4aa94abbb2838d4182c869f06e1881e6c35#0d6ad4aa94abbb2838d4182c869f06e1881e6c35" dependencies = [ "base64 0.22.1", "hex", @@ -6993,7 +6960,7 @@ dependencies = [ [[package]] name = "transparent-wallet" version = "0.1.0" -source = "git+https://github.com/valargroup/wallet-pir?rev=06a972db469f302d8c561416d4674e937fdde0cb#06a972db469f302d8c561416d4674e937fdde0cb" +source = "git+https://github.com/valargroup/wallet-pir?rev=0d6ad4aa94abbb2838d4182c869f06e1881e6c35#0d6ad4aa94abbb2838d4182c869f06e1881e6c35" dependencies = [ "base64 0.22.1", "hex", @@ -7011,7 +6978,7 @@ dependencies = [ [[package]] name = "transparent-wallet-store" version = "0.1.0" -source = "git+https://github.com/valargroup/wallet-pir?rev=06a972db469f302d8c561416d4674e937fdde0cb#06a972db469f302d8c561416d4674e937fdde0cb" +source = "git+https://github.com/valargroup/wallet-pir?rev=0d6ad4aa94abbb2838d4182c869f06e1881e6c35#0d6ad4aa94abbb2838d4182c869f06e1881e6c35" dependencies = [ "hex", "rusqlite", diff --git a/zakura/pir-transparent/CHANGELOG.md b/zakura/pir-transparent/CHANGELOG.md index 31bddacd..0fb8942b 100644 --- a/zakura/pir-transparent/CHANGELOG.md +++ b/zakura/pir-transparent/CHANGELOG.md @@ -146,6 +146,9 @@ this one uses are the dithered query (wallet-pir's `pir-native` and the transparent client and server); the `BelowFloor` call sites were re-checked there. +- wallet-pir then moves to `0d6ad4aa94abbb2838d4182c869f06e1881e6c35`, the + revision `zakura-pir-receiver` pins, so the two share one copy of each + wallet-pir crate. Its transparent crates are unchanged from `06a972db`. - Sources are `transparent-reference-source-v3`: the shard id is no longer bound, only the geometry, its seal parameters and the start height, so a renumbered shard or tail keeps its source. diff --git a/zakura/pir-transparent/Cargo.toml b/zakura/pir-transparent/Cargo.toml index 16fc5c8a..c7a636c9 100644 --- a/zakura/pir-transparent/Cargo.toml +++ b/zakura/pir-transparent/Cargo.toml @@ -33,13 +33,13 @@ same-file = { version = "1.0.6", optional = true } base64 = { workspace = true, optional = true } thiserror = "2" url = "2" -transparent-wallet = { git = "https://github.com/valargroup/wallet-pir", rev = "06a972db469f302d8c561416d4674e937fdde0cb", optional = true } -transparent-wallet-store = { git = "https://github.com/valargroup/wallet-pir", rev = "06a972db469f302d8c561416d4674e937fdde0cb", optional = true } -transparent-events = { git = "https://github.com/valargroup/wallet-pir", rev = "06a972db469f302d8c561416d4674e937fdde0cb", optional = true } -transparent-filter = { git = "https://github.com/valargroup/wallet-pir", rev = "06a972db469f302d8c561416d4674e937fdde0cb", optional = true } -transparent-txid-client = { git = "https://github.com/valargroup/wallet-pir", rev = "06a972db469f302d8c561416d4674e937fdde0cb", optional = true } -transparent-shard = { git = "https://github.com/valargroup/wallet-pir", rev = "06a972db469f302d8c561416d4674e937fdde0cb", optional = true } -transparent-native = { git = "https://github.com/valargroup/wallet-pir", rev = "06a972db469f302d8c561416d4674e937fdde0cb", optional = true } +transparent-wallet = { git = "https://github.com/valargroup/wallet-pir", rev = "0d6ad4aa94abbb2838d4182c869f06e1881e6c35", optional = true } +transparent-wallet-store = { git = "https://github.com/valargroup/wallet-pir", rev = "0d6ad4aa94abbb2838d4182c869f06e1881e6c35", optional = true } +transparent-events = { git = "https://github.com/valargroup/wallet-pir", rev = "0d6ad4aa94abbb2838d4182c869f06e1881e6c35", optional = true } +transparent-filter = { git = "https://github.com/valargroup/wallet-pir", rev = "0d6ad4aa94abbb2838d4182c869f06e1881e6c35", optional = true } +transparent-txid-client = { git = "https://github.com/valargroup/wallet-pir", rev = "0d6ad4aa94abbb2838d4182c869f06e1881e6c35", optional = true } +transparent-shard = { git = "https://github.com/valargroup/wallet-pir", rev = "0d6ad4aa94abbb2838d4182c869f06e1881e6c35", optional = true } +transparent-native = { git = "https://github.com/valargroup/wallet-pir", rev = "0d6ad4aa94abbb2838d4182c869f06e1881e6c35", optional = true } # Companion locks: `flock` directly, which `std` supports on Android only from Rust 1.98. [target.'cfg(unix)'.dependencies] @@ -53,11 +53,11 @@ zcash_client_backend = { workspace = true, features = ["orchard", "test-dependen zcash_keys = { workspace = true, features = ["transparent-inputs"] } # The example and the end-to-end test build the reference HTTP adapters; the # library takes caller transports. -transparent-wallet = { git = "https://github.com/valargroup/wallet-pir", rev = "06a972db469f302d8c561416d4674e937fdde0cb", features = ["reqwest"] } +transparent-wallet = { git = "https://github.com/valargroup/wallet-pir", rev = "0d6ad4aa94abbb2838d4182c869f06e1881e6c35", features = ["reqwest"] } # The end-to-end test publishes shards and serves them in process; it requires # `wallet`, which brings the event and filter crates. -transparent-shard = { git = "https://github.com/valargroup/wallet-pir", rev = "06a972db469f302d8c561416d4674e937fdde0cb" } -transparent-shard-server = { git = "https://github.com/valargroup/wallet-pir", rev = "06a972db469f302d8c561416d4674e937fdde0cb" } +transparent-shard = { git = "https://github.com/valargroup/wallet-pir", rev = "0d6ad4aa94abbb2838d4182c869f06e1881e6c35" } +transparent-shard-server = { git = "https://github.com/valargroup/wallet-pir", rev = "0d6ad4aa94abbb2838d4182c869f06e1881e6c35" } axum = "0.7" tokio = { workspace = true, features = ["rt-multi-thread", "net"] } regex = { workspace = true, features = ["std"] } diff --git a/zakura/pir-transparent/src/recovery.rs b/zakura/pir-transparent/src/recovery.rs index 595dece7..5e4920ee 100644 --- a/zakura/pir-transparent/src/recovery.rs +++ b/zakura/pir-transparent/src/recovery.rs @@ -805,7 +805,7 @@ fn declarations(map: &ShardMap) -> usize { /// /// A wallet holds no blocks below its birthday, while a publication may start far /// below it (the live map starts at genesis). Leniency there is sound because, at -/// wallet-pir 06a972db, `sync_into` asks below the floor only for rollback +/// wallet-pir 0d6ad4aa, `sync_into` asks below the floor only for rollback /// anchors and, in one case below, the end of a replacement shard: /// /// - It plans only shards meeting `[required_from, target]` (`sync.rs:949-972`), From be6f0b55bccbfa66801ac021cf0d3042380de7d5 Mon Sep 17 00:00:00 2001 From: Adam Tucker Date: Sat, 10 Oct 2026 14:54:45 -0600 Subject: [PATCH 16/40] Flag a package resolved from two git sources The graph check groups packages by name and version, so two git revisions of one crate at the same version passed, as wallet-pir's pir-native did while the PIR clients pinned different revisions. Fail when any package name in the workspace graph resolves to more than one git source. Co-Authored-By: Claude Opus 5.5 --- scripts/verify-zakura-graph.sh | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/scripts/verify-zakura-graph.sh b/scripts/verify-zakura-graph.sh index a6dec9f2..1b589d6b 100755 --- a/scripts/verify-zakura-graph.sh +++ b/scripts/verify-zakura-graph.sh @@ -94,6 +94,16 @@ for name, found in sorted(versions.items()): if len(found) > 1 and (name.startswith("zakura-") or name in expected_packages): problems.append(f"{name}: {len(found)} versions in the graph: {sorted(found)}") +# Two git sources of one name are separate crates even at one version. Checked +# over the whole workspace graph, where the PIR clients share wallet-pir crates. +git_sources = defaultdict(set) +for package in packages.values(): + if (package["source"] or "").startswith("git+"): + git_sources[package["name"]].add(package["source"]) +for name, found in sorted(git_sources.items()): + if len(found) > 1: + problems.append(f"{name}: {len(found)} git sources in the graph: {sorted(found)}") + missing = expected_packages - versions.keys() for name in sorted(missing): problems.append(f"{name}: vendored crate is not in the resolved graph") From b1c193901c33a3552bc16f6032314135dba0034c Mon Sep 17 00:00:00 2001 From: Adam Tucker Date: Sat, 10 Oct 2026 14:54:45 -0600 Subject: [PATCH 17/40] Check every requested position in returned note data The sweep compared the size of the returned note data with the number of positions it asked for, so a batch keyed by other positions passed that check while the lookup stayed incomplete, and the loop asked for the same positions forever. Require an entry for each requested position, so such a batch returns the missing-data error. Co-Authored-By: Claude Opus 5.5 --- zakura/pir-receiver/src/sweep.rs | 6 +-- zakura/pir-receiver/tests/sweep.rs | 65 ++++++++++++++++++++++++++++++ 2 files changed, 68 insertions(+), 3 deletions(-) diff --git a/zakura/pir-receiver/src/sweep.rs b/zakura/pir-receiver/src/sweep.rs index 8840e300..7e5697de 100644 --- a/zakura/pir-receiver/src/sweep.rs +++ b/zakura/pir-receiver/src/sweep.rs @@ -495,8 +495,7 @@ where .into_iter() .take(NOTE_BATCH) .collect(); - let requested = batch.len(); - let note_data = notes.note_data(wallet, batch).await?; + let note_data = notes.note_data(wallet, batch.clone()).await?; let done = lock .write("dynamic_sweep.queue", || { wallet.queue_directory_lookup(account, key, anchor, &payments, ¬e_data) @@ -506,7 +505,8 @@ where if done { break; } - if note_data.len() < requested { + // A requested position left without data would be asked for again forever. + if batch.iter().any(|p| !note_data.contains_key(p)) { return Err(Error::Unavailable( "note data is missing for a directory payment", )); diff --git a/zakura/pir-receiver/tests/sweep.rs b/zakura/pir-receiver/tests/sweep.rs index 627fe94d..93705d92 100644 --- a/zakura/pir-receiver/tests/sweep.rs +++ b/zakura/pir-receiver/tests/sweep.rs @@ -479,6 +479,71 @@ async fn a_long_history_takes_its_note_data_in_batches() { ); } +/// Note data keyed by the position after each requested one. +struct Mislabeled { + data: [u8; 528], + calls: usize, +} + +impl NoteSource for Mislabeled { + async fn note_data( + &mut self, + _: &W, + positions: Vec, + ) -> Result, Error> { + self.calls += 1; + assert!(self.calls <= 3, "the sweep keeps asking for {positions:?}"); + Ok(positions.into_iter().map(|p| (p + 1, self.data)).collect()) + } +} + +#[tokio::test] +async fn note_data_for_other_positions_is_missing_data() { + let mut st = ironwood_wallet(); + let network = *st.network(); + let account = st.test_account().unwrap().id(); + let paid = 1; + let (record, note_data) = pay_restored_key(&mut st, paid, 0); + st.generate_and_scan_empty_blocks_with_dynamic_ivks(1); + restore(&mut st, account); + let through = tip(&st); + let directory = Directory::new(publish( + std::slice::from_ref(&record), + &[record.payment.cmx], + through, + &provider(&[]), + )); + let mut notes = Mislabeled { + data: note_data, + calls: 0, + }; + let swept = sweep( + st.wallet_mut().db_mut(), + &network, + &[account], + through, + GENESIS, + ORIGIN, + &directory, + &mut notes, + &NoLock, + NOW, + ) + .await + .unwrap(); + assert_eq!(notes.calls, 1); + assert!( + matches!( + swept.deferred.as_slice(), + [(_, key, Error::Unavailable("note data is missing for a directory payment"))] + if key.index() == paid + ), + "{:?}", + swept.deferred + ); + assert_eq!(st.get_total_balance(account), Zatoshis::ZERO); +} + #[tokio::test] async fn a_wallet_without_payments_downloads_only_the_filters() { let mut st = ironwood_wallet(); From 1dbea92743cae0bd61e7cc4db9ac552a3c9e1f6b Mon Sep 17 00:00:00 2001 From: Adam Tucker Date: Sat, 10 Oct 2026 14:56:04 -0600 Subject: [PATCH 18/40] Count only note data requests that name positions The sweep asks a note source for every key it sweeps, with no positions for a key without payments, so the mis-keyed regression's bound on repeated requests tripped on the unpaid keys of the window. Answer those without counting them. Co-Authored-By: Claude Opus 5.5 --- zakura/pir-receiver/tests/sweep.rs | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/zakura/pir-receiver/tests/sweep.rs b/zakura/pir-receiver/tests/sweep.rs index 93705d92..7426417a 100644 --- a/zakura/pir-receiver/tests/sweep.rs +++ b/zakura/pir-receiver/tests/sweep.rs @@ -479,7 +479,8 @@ async fn a_long_history_takes_its_note_data_in_batches() { ); } -/// Note data keyed by the position after each requested one. +/// Note data keyed by the position after each requested one, counting the requests +/// that need some. struct Mislabeled { data: [u8; 528], calls: usize, @@ -491,6 +492,9 @@ impl NoteSource for Mislabeled { _: &W, positions: Vec, ) -> Result, Error> { + if positions.is_empty() { + return Ok(BTreeMap::new()); + } self.calls += 1; assert!(self.calls <= 3, "the sweep keeps asking for {positions:?}"); Ok(positions.into_iter().map(|p| (p + 1, self.data)).collect()) From 7423ae8c24b475a74c1e3ac715de419a2f807726 Mon Sep 17 00:00:00 2001 From: Adam Tucker Date: Sat, 10 Oct 2026 17:00:40 -0600 Subject: [PATCH 19/40] Skip the note source when a sweep needs no positions A paid-filter miss, an empty lookup, or payments whose note data was already queued left the sweep with no positions to fetch, but it still called the caller's `NoteSource`. Only `EnhanceNotes` returned early on an empty request; another source could do needless I/O or reject it, deferring an otherwise complete unpaid sweep on every run. The sweep now queues the lookup with no note data without calling the source, and `NoteSource` documents that positions are never empty. `EnhanceNotes` keeps its early return, since it is public and may be called directly. The test sources drop their now-unused empty cases, and the paymentless sweep test uses a source that fails every call. Co-Authored-By: Claude Opus 5.5 --- zakura/pir-receiver/src/sweep.rs | 7 ++++++- zakura/pir-receiver/tests/sweep.rs | 32 ++++++++++++++++-------------- 2 files changed, 23 insertions(+), 16 deletions(-) diff --git a/zakura/pir-receiver/src/sweep.rs b/zakura/pir-receiver/src/sweep.rs index 7e5697de..0d0cc53b 100644 --- a/zakura/pir-receiver/src/sweep.rs +++ b/zakura/pir-receiver/src/sweep.rs @@ -76,6 +76,7 @@ pub trait WriteLock { pub trait NoteSource { /// The 528 ciphertext bytes after the directory's prefix for each of `positions`, /// keyed by commitment tree position. `wallet` is the wallet being swept. + /// `positions` is never empty when a sweep calls this. async fn note_data( &mut self, wallet: &W, @@ -495,7 +496,11 @@ where .into_iter() .take(NOTE_BATCH) .collect(); - let note_data = notes.note_data(wallet, batch.clone()).await?; + let note_data = if batch.is_empty() { + BTreeMap::new() + } else { + notes.note_data(wallet, batch.clone()).await? + }; let done = lock .write("dynamic_sweep.queue", || { wallet.queue_directory_lookup(account, key, anchor, &payments, ¬e_data) diff --git a/zakura/pir-receiver/tests/sweep.rs b/zakura/pir-receiver/tests/sweep.rs index 7426417a..f6366d88 100644 --- a/zakura/pir-receiver/tests/sweep.rs +++ b/zakura/pir-receiver/tests/sweep.rs @@ -306,7 +306,7 @@ impl Transport for Directory { } } -/// Note data from a fixed map, recording each request that needs some. +/// Note data from a fixed map, recording each request. struct Notes { data: BTreeMap, requested: Vec>, @@ -318,9 +318,7 @@ impl NoteSource for Notes { _: &W, positions: Vec, ) -> Result, Error> { - if !positions.is_empty() { - self.requested.push(positions.clone()); - } + self.requested.push(positions.clone()); Ok(positions .into_iter() .filter_map(|position| Some((position, *self.data.get(&position)?))) @@ -479,8 +477,7 @@ async fn a_long_history_takes_its_note_data_in_batches() { ); } -/// Note data keyed by the position after each requested one, counting the requests -/// that need some. +/// Note data keyed by the position after each requested one, counting the requests. struct Mislabeled { data: [u8; 528], calls: usize, @@ -492,9 +489,6 @@ impl NoteSource for Mislabeled { _: &W, positions: Vec, ) -> Result, Error> { - if positions.is_empty() { - return Ok(BTreeMap::new()); - } self.calls += 1; assert!(self.calls <= 3, "the sweep keeps asking for {positions:?}"); Ok(positions.into_iter().map(|p| (p + 1, self.data)).collect()) @@ -548,6 +542,19 @@ async fn note_data_for_other_positions_is_missing_data() { assert_eq!(st.get_total_balance(account), Zatoshis::ZERO); } +/// A note source that fails every call. +struct NoNotes; + +impl NoteSource for NoNotes { + async fn note_data( + &mut self, + _: &W, + _: Vec, + ) -> Result, Error> { + Err(Error::Unavailable("no note data")) + } +} + #[tokio::test] async fn a_wallet_without_payments_downloads_only_the_filters() { let mut st = ironwood_wallet(); @@ -557,10 +564,6 @@ async fn a_wallet_without_payments_downloads_only_the_filters() { restore(&mut st, account); let through = tip(&st); let directory = Directory::new(publish(&[], &[], through, &provider(&[]))); - let mut notes = Notes { - data: BTreeMap::new(), - requested: Vec::new(), - }; let swept = sweep( st.wallet_mut().db_mut(), &network, @@ -569,7 +572,7 @@ async fn a_wallet_without_payments_downloads_only_the_filters() { GENESIS, ORIGIN, &directory, - &mut notes, + &mut NoNotes, &NoLock, NOW, ) @@ -582,7 +585,6 @@ async fn a_wallet_without_payments_downloads_only_the_filters() { let requests = directory.requests(); assert_eq!(requests.len(), 2, "{requests:?}"); assert!(requests[1].starts_with("GET /v1/receiver/filters/")); - assert!(notes.requested.is_empty()); // Every swept key keeps scanning, so a payment after the publication is found. let scanning = st.wallet().get_dynamic_scanning_keys().unwrap().len(); assert_eq!(scanning as u64, RECEIVE_GAP_LIMIT); From 4f224157556ece3138395af6bc7acf9057d521fd Mon Sep 17 00:00:00 2001 From: Adam Tucker Date: Sat, 10 Oct 2026 21:35:34 -0600 Subject: [PATCH 20/40] Stop the sweep when the note source cannot serve the chain When EnhanceNotes could not accept its manifest because the anchor was unscanned or on another chain, it returned Error::Unavailable, which a sweep cannot tell apart from one key's missing note data. The run then leased every remaining paid key only to repeat the same failure and back the whole batch off. Report that case as a new Error::NotesUnavailable, which the sweep treats like a failed directory session: the run stops before leasing more keys. A failure-injection test pays two keys and checks that the second is neither leased nor backed off. Co-Authored-By: Claude Opus 5.5 --- zakura/pir-receiver/src/sweep.rs | 17 +++++---- zakura/pir-receiver/tests/sweep.rs | 58 ++++++++++++++++++++++++++++++ 2 files changed, 69 insertions(+), 6 deletions(-) diff --git a/zakura/pir-receiver/src/sweep.rs b/zakura/pir-receiver/src/sweep.rs index 0d0cc53b..44e6eea0 100644 --- a/zakura/pir-receiver/src/sweep.rs +++ b/zakura/pir-receiver/src/sweep.rs @@ -59,6 +59,10 @@ pub enum Error { /// A service's publication cannot be used against the wallet's chain yet. #[error("{0}")] Unavailable(&'static str), + /// The note source cannot serve the wallet's chain, so no key can get note data + /// this run. + #[error("note source: {0}")] + NotesUnavailable(&'static str), /// A queued payment is waiting, for example for its witness. #[error("payment remains queued: {0:?}")] Queued(PaymentApplication), @@ -76,7 +80,8 @@ pub trait WriteLock { pub trait NoteSource { /// The 528 ciphertext bytes after the directory's prefix for each of `positions`, /// keyed by commitment tree position. `wallet` is the wallet being swept. - /// `positions` is never empty when a sweep calls this. + /// `positions` is never empty when a sweep calls this. [`Error::NotesUnavailable`] + /// stops the run before it leases more keys. async fn note_data( &mut self, wallet: &W, @@ -132,12 +137,12 @@ where let accepted = match accepted { Acceptance::Accepted(accepted) => accepted, Acceptance::WaitingForScanning => { - return Err(Error::Unavailable( + return Err(Error::NotesUnavailable( "the Enhance PIR anchor has not been scanned", )); } Acceptance::Mismatch => { - return Err(Error::Unavailable( + return Err(Error::NotesUnavailable( "the Enhance PIR anchor differs from the wallet's chain", )); } @@ -194,8 +199,8 @@ pub struct Swept { /// batch of due keys per account, then moves the incoming lookahead past paid and seen /// indices; its new keys are left for a later run, and [`Swept::pending`] reports them. /// A key that fails is backed off and listed in [`Swept::deferred`], and the others go -/// on, unless the directory's session or connection failed: then the run stops before -/// leasing more keys. A lease the wallet fails to store stops the run with its error. +/// on, unless the directory's session or connection failed or `notes` cannot serve the +/// wallet's chain: then the run stops before leasing more keys. A lease the wallet fails to store stops the run with its error. /// `now` is the caller's clock in seconds, for retry backoff. #[allow(clippy::too_many_arguments)] pub async fn sweep( @@ -276,7 +281,7 @@ where e, Error::Directory( DirectoryError::Revision | DirectoryError::Transport(_) - ) + ) | Error::NotesUnavailable(_) ); swept.deferred.push((account, item.key, e)); if unavailable { diff --git a/zakura/pir-receiver/tests/sweep.rs b/zakura/pir-receiver/tests/sweep.rs index f6366d88..04b0a5fc 100644 --- a/zakura/pir-receiver/tests/sweep.rs +++ b/zakura/pir-receiver/tests/sweep.rs @@ -1234,6 +1234,64 @@ async fn enhance_notes_accept_fresh_routing_after_a_refused_query() { } } +/// Enhance notes that cannot accept their manifest stop the run at the first paid key +/// that needs note data, so later paid keys are neither leased nor backed off. +#[tokio::test] +async fn an_unusable_note_source_stops_the_run_before_leasing_more_keys() { + let mut st = ironwood_wallet(); + let network = *st.network(); + let account = st.test_account().unwrap().id(); + let records: Vec<_> = (0..2) + .map(|index| pay_restored_key(&mut st, index, index).0) + .collect(); + st.generate_and_scan_empty_blocks_with_dynamic_ivks(1); + restore(&mut st, account); + let through = tip(&st); + let cmxs: Vec<_> = records.iter().map(|record| record.payment.cmx).collect(); + let directory = Directory::new(publish(&records, &cmxs, through, &provider(&[]))); + // Enhance's anchor is at the wallet's tip height but on another chain. + let other = ChainPoint { + height: through.height, + hash: BlockHash([7; 32]), + }; + let enhance = RefusingEnhance::new(other, 2, 409); + let enhance_network = EnhanceNetwork(network); + let mut notes = EnhanceNotes::new("https://enhance.test", &enhance, &enhance_network); + let swept = sweep( + st.wallet_mut().db_mut(), + &network, + &[account], + through, + GENESIS, + ORIGIN, + &directory, + &mut notes, + &NoLock, + NOW, + ) + .await + .unwrap(); + assert!( + matches!( + &swept.deferred[..], + [(_, key, Error::NotesUnavailable(_))] if *key == KeyId::new(Purpose::Receive, 0) + ), + "{:?}", + swept.deferred + ); + assert_eq!((swept.finished, enhance.inits.get()), (0, 1)); + let due = st + .wallet_mut() + .db_mut() + .prepare_dynamic_sweeps(account, through, NOW, NonZeroU32::new(64).unwrap()) + .unwrap() + .unwrap(); + assert!( + due.iter() + .any(|item| item.key == KeyId::new(Purpose::Receive, 1)) + ); +} + /// Runs one sweep of `accounts` through `through` at `now`. async fn run( st: &mut State, From bf7d27ab2f0345aa14fa63b5cceb088b732cb231 Mon Sep 17 00:00:00 2001 From: Adam Tucker Date: Sat, 10 Oct 2026 21:37:02 -0600 Subject: [PATCH 21/40] Release spend evidence only at the current tip The sweep skipped its work when the wallet had scanned past `through`, but still called finish_dynamic_nullifier_recovery with that stale point. The trait requires `through` to be a canonical scanned tip, so a backend without SQLite's defensive tip check could release evidence a later sweep still needs. Put the release under the same tip guard as the sweep work. Co-Authored-By: Claude Opus 5.5 --- zakura/pir-receiver/src/sweep.rs | 18 ++++++------ zakura/pir-receiver/tests/sweep.rs | 46 ++++++++++++++++++++++++++++++ 2 files changed, 56 insertions(+), 8 deletions(-) diff --git a/zakura/pir-receiver/src/sweep.rs b/zakura/pir-receiver/src/sweep.rs index 44e6eea0..3401e205 100644 --- a/zakura/pir-receiver/src/sweep.rs +++ b/zakura/pir-receiver/src/sweep.rs @@ -179,8 +179,8 @@ pub struct Swept { /// then releases Ironwood spend evidence that no sweep needs any longer (see /// [`DynamicIvkWrite::finish_dynamic_nullifier_recovery`]). /// -/// `through` is the wallet's fully scanned tip, and sweeps run only while it is also -/// the stored chain tip. When none is due, the run makes no request. Otherwise the +/// `through` is the wallet's fully scanned tip, and sweeps and release run only while +/// it is also the stored chain tip. When none is due, the run makes no request. Otherwise the /// publication must commit to `genesis` ([`MAINNET_GENESIS`](crate::MAINNET_GENESIS) /// on mainnet), cover history from Ironwood activation on `params`' network, and end at /// a block the wallet accepts (see [`DynamicIvkRead::directory_publication_anchor`]), @@ -227,8 +227,8 @@ where deferred: Vec::new(), pending: false, }; - let tip = wallet.chain_height().map_err(Error::Wallet)?; - if tip == Some(through.height) { + let current = wallet.chain_height().map_err(Error::Wallet)? == Some(through.height); + if current { let work = due(wallet, accounts, through, lock, now)?; if !work.is_empty() { let accepted = @@ -300,10 +300,12 @@ where } } for account in accounts { - lock.write("dynamic_sweep.release", || { - wallet.finish_dynamic_nullifier_recovery(*account, through) - }) - .map_err(Error::Wallet)?; + if current { + lock.write("dynamic_sweep.release", || { + wallet.finish_dynamic_nullifier_recovery(*account, through) + }) + .map_err(Error::Wallet)?; + } swept.pending |= wallet .dynamic_history_pending(*account, through.height) .map_err(Error::Wallet)?; diff --git a/zakura/pir-receiver/tests/sweep.rs b/zakura/pir-receiver/tests/sweep.rs index 04b0a5fc..9a3846fa 100644 --- a/zakura/pir-receiver/tests/sweep.rs +++ b/zakura/pir-receiver/tests/sweep.rs @@ -825,6 +825,52 @@ async fn a_failed_attempt_write_stops_the_run() { assert!(notes.requested.is_empty()); } +/// Records each write's label. +#[derive(Default)] +struct Labels(RefCell>); + +impl WriteLock for Labels { + fn write(&self, label: &'static str, write: impl FnOnce() -> T) -> T { + self.0.borrow_mut().push(label); + write() + } +} + +/// A `through` the wallet has scanned past is not a tip to release evidence at, so a +/// run with it writes nothing. +#[tokio::test] +async fn a_stale_tip_releases_nothing() { + let mut st = ironwood_wallet(); + let network = *st.network(); + let account = st.test_account().unwrap().id(); + st.generate_and_scan_empty_blocks_with_dynamic_ivks(1); + restore(&mut st, account); + let through = tip(&st); + st.generate_and_scan_empty_blocks_with_dynamic_ivks(1); + let directory = Directory::new(publish(&[], &[], through, &provider(&[]))); + let mut notes = Notes { + data: BTreeMap::new(), + requested: Vec::new(), + }; + let lock = Labels::default(); + sweep( + st.wallet_mut().db_mut(), + &network, + &[account], + through, + GENESIS, + ORIGIN, + &directory, + &mut notes, + &lock, + NOW, + ) + .await + .unwrap(); + assert!(lock.0.borrow().is_empty(), "{:?}", lock.0.borrow()); + assert!(directory.requests().is_empty()); +} + /// Another network's seen sets say nothing about this network's quotes, so a /// publication committing to another genesis is refused as a sweep refuses it. #[tokio::test] From b65a4b8f5997ec81654d64a742afe3c11580576c Mon Sep 17 00:00:00 2001 From: Adam Tucker Date: Sat, 10 Oct 2026 21:38:09 -0600 Subject: [PATCH 22/40] Sweep a repeated account only once Each account's due batch is prepared before any key is leased, so an account listed twice got the same batch twice: every key was leased and looked up twice, and a deferred key was backed off twice and listed twice. Deduplicate the accounts, keeping first-seen order, before preparing work. Co-Authored-By: Claude Opus 5.5 --- zakura/pir-receiver/src/sweep.rs | 8 ++++ zakura/pir-receiver/tests/sweep.rs | 61 ++++++++++++++++++++++++++++++ 2 files changed, 69 insertions(+) diff --git a/zakura/pir-receiver/src/sweep.rs b/zakura/pir-receiver/src/sweep.rs index 3401e205..a3bf9f88 100644 --- a/zakura/pir-receiver/src/sweep.rs +++ b/zakura/pir-receiver/src/sweep.rs @@ -222,6 +222,14 @@ where N: NoteSource, L: WriteLock, { + // A repeated account would lease and back off each of its keys twice. + let mut unique = Vec::with_capacity(accounts.len()); + for account in accounts { + if !unique.contains(account) { + unique.push(*account); + } + } + let accounts = &unique[..]; let mut swept = Swept { finished: 0, deferred: Vec::new(), diff --git a/zakura/pir-receiver/tests/sweep.rs b/zakura/pir-receiver/tests/sweep.rs index 9a3846fa..6773ba2a 100644 --- a/zakura/pir-receiver/tests/sweep.rs +++ b/zakura/pir-receiver/tests/sweep.rs @@ -871,6 +871,67 @@ async fn a_stale_tip_releases_nothing() { assert!(directory.requests().is_empty()); } +/// A repeated account sweeps its keys once: each is leased once, and a deferred key is +/// listed once. +#[tokio::test] +async fn a_repeated_account_sweeps_each_key_once() { + let mut st = ironwood_wallet(); + let network = *st.network(); + let account = st.test_account().unwrap().id(); + let (record, _) = pay_restored_key(&mut st, 0, 0); + st.generate_and_scan_empty_blocks_with_dynamic_ivks(1); + restore(&mut st, account); + let through = tip(&st); + let due = st + .wallet_mut() + .db_mut() + .prepare_dynamic_sweeps(account, through, NOW, NonZeroU32::new(64).unwrap()) + .unwrap() + .unwrap() + .len(); + let directory = Directory::new(publish( + std::slice::from_ref(&record), + &[record.payment.cmx], + through, + &provider(&[]), + )); + // Without note data the paid key defers. + let mut notes = Notes { + data: BTreeMap::new(), + requested: Vec::new(), + }; + let lock = Labels::default(); + let swept = sweep( + st.wallet_mut().db_mut(), + &network, + &[account, account], + through, + GENESIS, + ORIGIN, + &directory, + &mut notes, + &lock, + NOW, + ) + .await + .unwrap(); + let attempts = lock + .0 + .borrow() + .iter() + .filter(|label| **label == "dynamic_sweep.attempt") + .count(); + assert_eq!(attempts, due); + assert!( + matches!( + &swept.deferred[..], + [(_, key, Error::Unavailable(_))] if *key == KeyId::new(Purpose::Receive, 0) + ), + "{:?}", + swept.deferred + ); +} + /// Another network's seen sets say nothing about this network's quotes, so a /// publication committing to another genesis is refused as a sweep refuses it. #[tokio::test] From 6de9eb905a5b57c37090f910b235a8be13ea3357 Mon Sep 17 00:00:00 2001 From: Adam Tucker Date: Sat, 10 Oct 2026 22:26:17 -0600 Subject: [PATCH 23/40] Revalidate the chain point before releasing spend evidence The sweep's write lock is released across the directory and Enhance awaits, so another writer can advance or reorg the tip before release. finish_dynamic_nullifier_recovery may rely on through being the canonical scanned tip, so check the stored tip height and the scanned block hash at through inside the release lock, and skip release when either moved. The entry guard now checks the same full chain point. The regression moves the tip through a second wallet handle just before release takes the lock (advance, rewind, and rewind then re-announce the same height), and detects a release call through an SQLite authorizer. rusqlite becomes a test-only dependency for that hook. Co-Authored-By: Claude Opus 5.5 --- Cargo.lock | 1 + zakura/pir-receiver/Cargo.toml | 1 + zakura/pir-receiver/src/sweep.rs | 19 ++++- zakura/pir-receiver/tests/sweep.rs | 120 ++++++++++++++++++++++++++++- 4 files changed, 134 insertions(+), 7 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index d4af6d05..4ea9b66a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -8235,6 +8235,7 @@ dependencies = [ "receiver-directory", "receiver-pir", "receiver-pir-server", + "rusqlite", "serde_json", "sha2 0.10.9", "thiserror 2.0.21", diff --git a/zakura/pir-receiver/Cargo.toml b/zakura/pir-receiver/Cargo.toml index 80945099..5856ea74 100644 --- a/zakura/pir-receiver/Cargo.toml +++ b/zakura/pir-receiver/Cargo.toml @@ -32,6 +32,7 @@ hex.workspace = true orchard.workspace = true receiver-pir = { git = "https://github.com/valargroup/wallet-pir", rev = "9d2cbda0b4336258cfce76ca8ee77b095daa7fd7", features = ["server"] } receiver-pir-server = { git = "https://github.com/valargroup/wallet-pir", rev = "9d2cbda0b4336258cfce76ca8ee77b095daa7fd7" } +rusqlite = { workspace = true, features = ["hooks"] } serde_json.workspace = true sha2.workspace = true tokio = { workspace = true, features = ["macros", "rt"] } diff --git a/zakura/pir-receiver/src/sweep.rs b/zakura/pir-receiver/src/sweep.rs index a3bf9f88..a77c6cad 100644 --- a/zakura/pir-receiver/src/sweep.rs +++ b/zakura/pir-receiver/src/sweep.rs @@ -180,7 +180,9 @@ pub struct Swept { /// [`DynamicIvkWrite::finish_dynamic_nullifier_recovery`]). /// /// `through` is the wallet's fully scanned tip, and sweeps and release run only while -/// it is also the stored chain tip. When none is due, the run makes no request. Otherwise the +/// it is also the stored chain tip and the block the wallet scanned there, which +/// release checks again under its write lock. When none is due, the run makes no +/// request. Otherwise the /// publication must commit to `genesis` ([`MAINNET_GENESIS`](crate::MAINNET_GENESIS) /// on mainnet), cover history from Ironwood activation on `params`' network, and end at /// a block the wallet accepts (see [`DynamicIvkRead::directory_publication_anchor`]), @@ -235,7 +237,7 @@ where deferred: Vec::new(), pending: false, }; - let current = wallet.chain_height().map_err(Error::Wallet)? == Some(through.height); + let current = at_tip(wallet, through).map_err(Error::Wallet)?; if current { let work = due(wallet, accounts, through, lock, now)?; if !work.is_empty() { @@ -309,8 +311,12 @@ where } for account in accounts { if current { + // Another writer may have moved the tip while the sweep held no lock. lock.write("dynamic_sweep.release", || { - wallet.finish_dynamic_nullifier_recovery(*account, through) + if at_tip(wallet, through)? { + wallet.finish_dynamic_nullifier_recovery(*account, through)?; + } + Ok(()) }) .map_err(Error::Wallet)?; } @@ -321,6 +327,13 @@ where Ok(swept) } +/// Whether `through` is still the wallet's stored chain tip and the block it scanned +/// there. +fn at_tip(wallet: &W, through: ChainPoint) -> Result { + Ok(wallet.chain_height()? == Some(through.height) + && wallet.get_block_hash(through.height)? == Some(through.hash)) +} + /// What a publication's filters say about one key's receiver. #[derive(Clone, Copy)] struct Check { diff --git a/zakura/pir-receiver/tests/sweep.rs b/zakura/pir-receiver/tests/sweep.rs index 6773ba2a..ec14e0e0 100644 --- a/zakura/pir-receiver/tests/sweep.rs +++ b/zakura/pir-receiver/tests/sweep.rs @@ -25,6 +25,7 @@ use receiver_directory::{ }; use receiver_pir::{MIN_ROWS, server::Server}; use receiver_pir_server::{Publication, Publications, router_with_publications}; +use rusqlite::hooks::{AuthAction, AuthContext, Authorization}; use sha2::{Digest as _, Sha256}; use tower::ServiceExt as _; use zakura_dynamic_ivk::lifecycle::RESTORE_WATCH_SECS; @@ -37,18 +38,19 @@ use zakura_pir_receiver::{ DirectoryError, EnhanceNotes, Error, NoteSource, Transport, WriteLock, fetch_seen, sweep, }; use zcash_client_backend::data_api::{ - Account as _, WalletRead as _, + Account as _, WalletRead as _, WalletWrite as _, chain::BlockSource as _, dynamic_ivk::{DynamicIvkRead, DynamicIvkWrite as _, PaymentApplication}, - testing::{AddressType, IronwoodFvk, TestBuilder, TestState}, + testing::{AddressType, IronwoodFvk, TestBuilder, TestRng, TestState}, transparent_ledger::ChainPoint, }; use zcash_client_sqlite::{ - AccountUuid, + AccountUuid, WalletDb, testing::{ BlockCache, db::{TestDb, TestDbFactory}, }, + util::SystemClock, wallet::dynamic_ivk::{KeyId, ProviderSeen, Purpose, RECEIVE_GAP_LIMIT}, }; use zcash_note_encryption::{Domain as _, try_compact_note_decryption}; @@ -77,6 +79,11 @@ const VALUE: u64 = 100_000; /// A wallet with Ironwood active from its first block. fn ironwood_wallet() -> State { + ironwood_wallet_in(TestDbFactory::default()) +} + +/// [`ironwood_wallet`] stored as `factory` makes it. +fn ironwood_wallet_in(factory: TestDbFactory) -> State { let activation = BlockHeight::from_u32(ACTIVATION); TestBuilder::new() .with_network(LocalNetwork { @@ -86,7 +93,7 @@ fn ironwood_wallet() -> State { nu6_3: Some(activation), ..TestBuilder::<(), ()>::DEFAULT_NETWORK }) - .with_data_store_factory(TestDbFactory::default()) + .with_data_store_factory(factory) .with_block_cache(BlockCache::new()) .with_account_from_sapling_activation(BlockHash([0; 32])) .build() @@ -871,6 +878,111 @@ async fn a_stale_tip_releases_nothing() { assert!(directory.requests().is_empty()); } +/// Another handle on the wallet's file, as another writer holds. +type OtherWriter = WalletDb; + +/// Runs `other` just before the first release, as another writer may while the sweep +/// holds no lock, then sets `releasing` while release holds the lock. +struct WriterBeforeRelease { + other: RefCell>, + releasing: Arc, +} + +impl WriteLock for WriterBeforeRelease { + fn write(&self, label: &'static str, write: impl FnOnce() -> T) -> T { + if label != "dynamic_sweep.release" { + return write(); + } + if let Some(other) = self.other.borrow_mut().take() { + other(); + } + self.releasing.store(true, Ordering::SeqCst); + let result = write(); + self.releasing.store(false, Ordering::SeqCst); + result + } +} + +/// Sweeps a restored wallet whose release follows `other`'s write through a second +/// handle, and returns whether release ran, judged by its read of the retention table. +async fn release_after(other: impl FnOnce(&mut OtherWriter, ChainPoint)) -> bool { + let mut st = ironwood_wallet_in(TestDbFactory::file_backed()); + let network = *st.network(); + let account = st.test_account().unwrap().id(); + st.generate_and_scan_empty_blocks_with_dynamic_ivks(2); + restore(&mut st, account); + let through = tip(&st); + let path = st.wallet().conn().path().unwrap().to_owned(); + let mut handle = + OtherWriter::for_path(path, network, SystemClock, TestRng::seed_from_u64(0)).unwrap(); + let lock = WriterBeforeRelease { + other: RefCell::new(Some(|| other(&mut handle, through))), + releasing: Arc::new(AtomicBool::new(false)), + }; + let released = Arc::new(AtomicBool::new(false)); + let (releasing, read) = (lock.releasing.clone(), released.clone()); + st.wallet() + .conn() + .authorizer(Some(move |ctx: AuthContext<'_>| { + if releasing.load(Ordering::SeqCst) + && matches!( + ctx.action, + AuthAction::Read { + table_name: "ironwood_dynamic_spend_retention", + .. + } + ) + { + read.store(true, Ordering::SeqCst); + } + Authorization::Allow + })); + let directory = Directory::new(publish(&[], &[], through, &provider(&[]))); + let swept = sweep( + st.wallet_mut().db_mut(), + &network, + &[account], + through, + GENESIS, + ORIGIN, + &directory, + &mut NoNotes, + &lock, + NOW, + ) + .await + .unwrap(); + assert_eq!(swept.finished as u64, RECEIVE_GAP_LIMIT); + st.wallet() + .conn() + .authorizer(None::) -> Authorization>); + released.load(Ordering::SeqCst) +} + +/// Release checks the chain point again under its lock, since another writer may move +/// the tip while the sweep's lookups run without it. +#[tokio::test] +async fn a_tip_moved_before_release_releases_nothing() { + assert!( + release_after(|_, _| ()).await, + "release runs at an unmoved tip" + ); + let advanced = release_after(|other, through| { + other.update_chain_tip(through.height + 1).unwrap(); + }); + assert!(!advanced.await, "released after the tip advanced"); + let rewound = release_after(|other, through| { + other.truncate_to_height(through.height - 1).unwrap(); + }); + assert!(!rewound.await, "released after a reorg rewound the tip"); + // The tip is back at `through`'s height, but its block is not scanned yet. + let replaced = release_after(|other, through| { + other.truncate_to_height(through.height - 1).unwrap(); + other.update_chain_tip(through.height).unwrap(); + }); + assert!(!replaced.await, "released at a replaced block"); +} + /// A repeated account sweeps its keys once: each is leased once, and a deferred key is /// listed once. #[tokio::test] From 691e9d9dbbff53688e0be39de6043bcbca36b262 Mon Sep 17 00:00:00 2001 From: Adam Tucker Date: Sun, 11 Oct 2026 01:01:10 -0600 Subject: [PATCH 24/40] Match a publication's end position to the wallet's anchor tree A publication accepted by its end height and hash alone could stop partway through that block's Ironwood actions. A paid-filter miss then finished the key and scanning resumed at `anchor + 1`, so a payment in the omitted tail was neither looked up nor rescanned. Compare the manifest's `end_position` with the wallet's Ironwood tree size at the anchor, from `block_metadata`, and refuse a mismatch before any lookup, so every due key backs off as for an anchor mismatch. The fixtures that published fewer commitments than the chain holds now publish the chain's tree, and the misplaced-payment cases keep their rejection or correction against a tree of the wallet's size. Co-Authored-By: Claude Opus 5.5 --- zakura/pir-receiver/src/sweep.rs | 23 +++++++++---- zakura/pir-receiver/tests/sweep.rs | 52 ++++++++++++++++++++++++++---- 2 files changed, 62 insertions(+), 13 deletions(-) diff --git a/zakura/pir-receiver/src/sweep.rs b/zakura/pir-receiver/src/sweep.rs index a77c6cad..6706c21f 100644 --- a/zakura/pir-receiver/src/sweep.rs +++ b/zakura/pir-receiver/src/sweep.rs @@ -182,12 +182,12 @@ pub struct Swept { /// `through` is the wallet's fully scanned tip, and sweeps and release run only while /// it is also the stored chain tip and the block the wallet scanned there, which /// release checks again under its write lock. When none is due, the run makes no -/// request. Otherwise the -/// publication must commit to `genesis` ([`MAINNET_GENESIS`](crate::MAINNET_GENESIS) -/// on mainnet), cover history from Ironwood activation on `params`' network, and end at -/// a block the wallet accepts (see [`DynamicIvkRead::directory_publication_anchor`]), -/// and every swap provider with a set must have a dated seen set, as for -/// [`fetch_seen`](crate::fetch_seen). +/// request. Otherwise the publication must commit to `genesis` +/// ([`MAINNET_GENESIS`](crate::MAINNET_GENESIS) on mainnet), cover history from +/// Ironwood activation on `params`' network, and end at a block the wallet accepts +/// (see [`DynamicIvkRead::directory_publication_anchor`]) with the wallet's Ironwood +/// tree size there, and every swap provider with a set must have a dated seen set, as +/// for [`fetch_seen`](crate::fetch_seen). /// /// Each key's receiver is first tested against the publication's filters, which every /// wallet downloads alike: only a receiver in the paid set is looked up over PIR, and @@ -393,6 +393,17 @@ impl<'a, T: Transport> Directory<'a, T> { hash: anchor.hash.0, }; accepted.check(&manifest.directory)?; + // Height and hash alone accept a publication that stops partway through the + // anchor block, whose omitted payments no lookup or scan from `anchor + 1` finds. + let tree_size = wallet + .block_metadata(anchor.height) + .map_err(Error::Wallet)? + .and_then(|block| block.ironwood_tree_size()); + if tree_size.map(u64::from) != Some(manifest.directory.end_position) { + return Err(Error::Unavailable( + "the receiver directory's tree size differs from the wallet's at its anchor", + )); + } let seen = seen_sets(&manifest.directory.filters)? .into_iter() .map(|(label, _, _)| label.to_owned()) diff --git a/zakura/pir-receiver/tests/sweep.rs b/zakura/pir-receiver/tests/sweep.rs index ec14e0e0..f26e2ddb 100644 --- a/zakura/pir-receiver/tests/sweep.rs +++ b/zakura/pir-receiver/tests/sweep.rs @@ -673,6 +673,9 @@ async fn a_seen_hit_outlives_a_rejected_payment_and_a_later_miss() { let account = st.test_account().unwrap().id(); let key = KeyId::new(Purpose::Receive, 0); let (record, note_data) = pay_restored_key(&mut st, key.index(), 0); + // A later output, which publication A's tree replaces. + let (later_output, _) = pay_restored_key(&mut st, 1000, 1); + let commitments = [record.payment.cmx, later_output.payment.cmx]; st.generate_and_scan_empty_blocks_with_dynamic_ivks(1); restore(&mut st, account); let through = tip(&st); @@ -722,7 +725,7 @@ async fn a_seen_hit_outlives_a_rejected_payment_and_a_later_miss() { ); assert!(seen(&st)); - let b = Directory::new(publish(&[], &[], through, &provider(&[]))); + let b = Directory::new(publish(&[], &commitments, through, &provider(&[]))); let later = NOW + 3_600; let swept = sweep( st.wallet_mut().db_mut(), @@ -1327,6 +1330,42 @@ async fn a_publication_off_the_wallets_chain_is_refused_before_any_lookup() { } } +/// A publication ending at the wallet's block but short of that block's last Ironwood +/// output is refused before any lookup, as one off the chain is: a payment in the +/// omitted tail would be neither looked up nor rescanned. +#[tokio::test] +async fn a_publication_short_of_its_anchor_blocks_outputs_is_refused() { + let mut st = ironwood_wallet(); + let network = *st.network(); + let account = st.test_account().unwrap().id(); + pay_restored_key(&mut st, 0, 0); + restore(&mut st, account); + let through = tip(&st); + // Ends at the paid block, without the chain's one Ironwood output, which it holds. + let directory = Directory::new(publish(&[], &[], through, &provider(&[]))); + let result = sweep( + st.wallet_mut().db_mut(), + &network, + &[account], + through, + GENESIS, + ORIGIN, + &directory, + &mut NoNotes, + &NoLock, + NOW, + ) + .await; + assert!(matches!(result, Err(Error::Unavailable(_))), "{result:?}"); + assert_eq!(directory.requests(), ["GET /v1/receiver/init"]); + assert!( + st.wallet() + .db() + .dynamic_history_pending(account, through.height) + .unwrap() + ); +} + /// An Enhance transport that must not be used. struct NoEnhance; @@ -1582,7 +1621,7 @@ async fn a_deferred_candidate_takes_each_publications_current_answer() { restore(&mut st, account); let mut through = tip(&st); let mut first = record.clone(); - let mut commitments = vec![record.payment.cmx]; + let commitments = [record.payment.cmx]; match deferral { // A block below the tip is not scanned yet. AwaitingScan => { @@ -1595,9 +1634,8 @@ async fn a_deferred_candidate_takes_each_publications_current_answer() { hash: block.block_hash(), }; if corrected { - // Publication A misplaces the payment in another tree. - first.payment.position = 1; - commitments.insert(0, [1; 32]); + // Publication A misstates the payment's transaction. + first.payment.tx_index += 1; } } AwaitingWitness => { @@ -1616,7 +1654,7 @@ async fn a_deferred_candidate_takes_each_publications_current_answer() { } } let mut notes = Notes { - data: BTreeMap::from([(0, note_data), (1, note_data)]), + data: BTreeMap::from([(0, note_data)]), requested: Vec::new(), }; let a = Directory::new(publish(&[first], &commitments, through, &provider(&[]))); @@ -1645,7 +1683,7 @@ async fn a_deferred_candidate_takes_each_publications_current_answer() { &provider(&[]), ) } else { - publish(&[], &[], through, &provider(&[])) + publish(&[], &commitments, through, &provider(&[])) }; let b = Directory::new(b); let swept = run(&mut st, &[account], through, &b, &mut notes, NOW + 60) From e2f7f31fc32e9bbc7c52c1e0f4d7625417e5d05d Mon Sep 17 00:00:00 2001 From: Adam Tucker Date: Sun, 11 Oct 2026 01:01:18 -0600 Subject: [PATCH 25/40] Recheck the fully scanned height before releasing spend evidence Finishing keys against a publication behind `through` activates them from `anchor + 1` and can queue a rescan, lowering the wallet's fully scanned height while its chain height and tip hash stay unchanged. The release guard checked only those two, so a generic backend relying on the canonical-scanned-tip precondition could discard spend evidence before that rescan found its notes. Also require the fully scanned height to equal `through` under the release lock. Sweeping itself still runs at a lowered height, where queued candidates defer until the rescan completes. Co-Authored-By: Claude Opus 5.5 --- zakura/pir-receiver/src/sweep.rs | 20 ++++++++++--------- zakura/pir-receiver/tests/sweep.rs | 32 ++++++++++++++++++++++-------- 2 files changed, 35 insertions(+), 17 deletions(-) diff --git a/zakura/pir-receiver/src/sweep.rs b/zakura/pir-receiver/src/sweep.rs index 6706c21f..471c66c5 100644 --- a/zakura/pir-receiver/src/sweep.rs +++ b/zakura/pir-receiver/src/sweep.rs @@ -181,13 +181,13 @@ pub struct Swept { /// /// `through` is the wallet's fully scanned tip, and sweeps and release run only while /// it is also the stored chain tip and the block the wallet scanned there, which -/// release checks again under its write lock. When none is due, the run makes no -/// request. Otherwise the publication must commit to `genesis` -/// ([`MAINNET_GENESIS`](crate::MAINNET_GENESIS) on mainnet), cover history from -/// Ironwood activation on `params`' network, and end at a block the wallet accepts -/// (see [`DynamicIvkRead::directory_publication_anchor`]) with the wallet's Ironwood -/// tree size there, and every swap provider with a set must have a dated seen set, as -/// for [`fetch_seen`](crate::fetch_seen). +/// release checks again under its write lock along with the fully scanned height. +/// When none is due, the run makes no request. Otherwise the publication must commit +/// to `genesis` ([`MAINNET_GENESIS`](crate::MAINNET_GENESIS) on mainnet), cover +/// history from Ironwood activation on `params`' network, and end at a block the +/// wallet accepts (see [`DynamicIvkRead::directory_publication_anchor`]) with the +/// wallet's Ironwood tree size there, and every swap provider with a set must have a +/// dated seen set, as for [`fetch_seen`](crate::fetch_seen). /// /// Each key's receiver is first tested against the publication's filters, which every /// wallet downloads alike: only a receiver in the paid set is looked up over PIR, and @@ -311,9 +311,11 @@ where } for account in accounts { if current { - // Another writer may have moved the tip while the sweep held no lock. + // Another writer may have moved the tip while the sweep held no lock, and a + // finished key may have queued a rescan below it. lock.write("dynamic_sweep.release", || { - if at_tip(wallet, through)? { + let scanned = wallet.block_fully_scanned()?.map(|b| b.block_height()); + if at_tip(wallet, through)? && scanned == Some(through.height) { wallet.finish_dynamic_nullifier_recovery(*account, through)?; } Ok(()) diff --git a/zakura/pir-receiver/tests/sweep.rs b/zakura/pir-receiver/tests/sweep.rs index f26e2ddb..1d7107b5 100644 --- a/zakura/pir-receiver/tests/sweep.rs +++ b/zakura/pir-receiver/tests/sweep.rs @@ -906,9 +906,10 @@ impl WriteLock for WriterBeforeRelease { } } -/// Sweeps a restored wallet whose release follows `other`'s write through a second -/// handle, and returns whether release ran, judged by its read of the retention table. -async fn release_after(other: impl FnOnce(&mut OtherWriter, ChainPoint)) -> bool { +/// Sweeps a restored wallet against a publication ending `behind` blocks below its tip, +/// with release following `other`'s write through a second handle, and returns whether +/// release ran, judged by its read of the retention table. +async fn release_after(behind: u32, other: impl FnOnce(&mut OtherWriter, ChainPoint)) -> bool { let mut st = ironwood_wallet_in(TestDbFactory::file_backed()); let network = *st.network(); let account = st.test_account().unwrap().id(); @@ -940,7 +941,12 @@ async fn release_after(other: impl FnOnce(&mut OtherWriter, ChainPoint)) -> bool } Authorization::Allow })); - let directory = Directory::new(publish(&[], &[], through, &provider(&[]))); + let height = through.height - behind; + let end = ChainPoint { + height, + hash: st.wallet().get_block_hash(height).unwrap().unwrap(), + }; + let directory = Directory::new(publish(&[], &[], end, &provider(&[]))); let swept = sweep( st.wallet_mut().db_mut(), &network, @@ -967,25 +973,35 @@ async fn release_after(other: impl FnOnce(&mut OtherWriter, ChainPoint)) -> bool #[tokio::test] async fn a_tip_moved_before_release_releases_nothing() { assert!( - release_after(|_, _| ()).await, + release_after(0, |_, _| ()).await, "release runs at an unmoved tip" ); - let advanced = release_after(|other, through| { + let advanced = release_after(0, |other, through| { other.update_chain_tip(through.height + 1).unwrap(); }); assert!(!advanced.await, "released after the tip advanced"); - let rewound = release_after(|other, through| { + let rewound = release_after(0, |other, through| { other.truncate_to_height(through.height - 1).unwrap(); }); assert!(!rewound.await, "released after a reorg rewound the tip"); // The tip is back at `through`'s height, but its block is not scanned yet. - let replaced = release_after(|other, through| { + let replaced = release_after(0, |other, through| { other.truncate_to_height(through.height - 1).unwrap(); other.update_chain_tip(through.height).unwrap(); }); assert!(!replaced.await, "released at a replaced block"); } +/// Finishing keys against a publication behind `through` rescans the blocks after it, +/// lowering the fully scanned height without moving the tip, so release waits for it. +#[tokio::test] +async fn a_rescan_the_sweep_queued_releases_nothing() { + assert!( + !release_after(1, |_, _| ()).await, + "released below the fully scanned tip" + ); +} + /// A repeated account sweeps its keys once: each is leased once, and a deferred key is /// listed once. #[tokio::test] From dc037b25fcb27008c69ce50b6fc20527c07e1ccf Mon Sep 17 00:00:00 2001 From: Adam Tucker Date: Sun, 11 Oct 2026 02:56:31 -0600 Subject: [PATCH 26/40] Reject sweeps of a publication without provider seen sets fetch_seen refused a publication that declares no provider seen sets, but a sweep accepted it with no seen labels. Its keys could then finish without any seen hit and release retained spend evidence, so a later seen set revealing a quoted address at the lookahead edge left the new keys' directory payments awaiting spend history. seen_sets now fails for a publication with no provider sets, so the sweep refuses it as fetch_seen does: every due key backs off before any finishes, and nothing is released. Co-Authored-By: Claude Opus 5.5 --- zakura/pir-receiver/CHANGELOG.md | 6 ++-- zakura/pir-receiver/README.md | 4 +-- zakura/pir-receiver/src/seen.rs | 10 +++---- zakura/pir-receiver/src/sweep.rs | 4 +-- zakura/pir-receiver/tests/sweep.rs | 45 ++++++++++++++++++++++++++++++ 5 files changed, 57 insertions(+), 12 deletions(-) diff --git a/zakura/pir-receiver/CHANGELOG.md b/zakura/pir-receiver/CHANGELOG.md index 93115060..9205a7e0 100644 --- a/zakura/pir-receiver/CHANGELOG.md +++ b/zakura/pir-receiver/CHANGELOG.md @@ -11,6 +11,6 @@ sets `paid` and each provider's `seen`. The receiver PIR client moved here from Vizor. - `fetch_seen` and `Seen`: a publication's swap provider seen sets, which a wallet - checks before issuing a swap address. A publication in which a provider with sets - lacks a dated seen set is malformed, for `fetch_seen` and `sweep` alike. They need - no `wallet` feature. + checks before issuing a swap address. A publication without seen sets, or in which + a provider with sets lacks a dated seen set, is malformed, for `fetch_seen` and + `sweep` alike. They need no `wallet` feature. diff --git a/zakura/pir-receiver/README.md b/zakura/pir-receiver/README.md index fe2a219f..6fab3c0c 100644 --- a/zakura/pir-receiver/README.md +++ b/zakura/pir-receiver/README.md @@ -36,7 +36,7 @@ routes and checks that a restored wallet finds and imports a payout, extends its lookahead past it, repeats no finished sweep, imports every payment of a key paid twice, makes no lookup when nothing was paid, keeps every swept address scanning and issues no seen one, refuses a publication that is not on its chain before any lookup, -refuses one in which a provider lacks a seen set, and replaces a deferred payment with -a later publication's answer. +refuses one without seen sets or in which a provider lacks one, and replaces a +deferred payment with a later publication's answer. Against a fake Enhance service, `EnhanceNotes` accepts fresh routing after a refused query. diff --git a/zakura/pir-receiver/src/seen.rs b/zakura/pir-receiver/src/seen.rs index 2391d886..a9b2276e 100644 --- a/zakura/pir-receiver/src/seen.rs +++ b/zakura/pir-receiver/src/seen.rs @@ -84,15 +84,12 @@ pub async fn fetch_seen( seen.sets .push(filters.get(label).ok_or(DirectoryError::Malformed)?.clone()); } - if seen.sets.is_empty() { - return Err(DirectoryError::Malformed); - } Ok(seen) } /// The labels and feed read times of the seen sets among a publication's filter `sets`. -/// Fails unless every provider with a recent or seen set has a dated seen set, since -/// receivers a provider without one had would look unseen. +/// Fails if there are none, or unless every provider with a recent or seen set has a +/// dated seen set, since receivers a provider without one had would look unseen. pub(crate) fn seen_sets(sets: &[FilterSet]) -> Result, DirectoryError> { let mut providers = BTreeMap::<&str, Option<(&str, i64, i64)>>::new(); for set in sets { @@ -109,6 +106,9 @@ pub(crate) fn seen_sets(sets: &[FilterSet]) -> Result, Dir _ => {} } } + if providers.is_empty() { + return Err(DirectoryError::Malformed); + } providers .into_values() .map(|seen| seen.ok_or(DirectoryError::Malformed)) diff --git a/zakura/pir-receiver/src/sweep.rs b/zakura/pir-receiver/src/sweep.rs index 471c66c5..1a01e40b 100644 --- a/zakura/pir-receiver/src/sweep.rs +++ b/zakura/pir-receiver/src/sweep.rs @@ -186,8 +186,8 @@ pub struct Swept { /// to `genesis` ([`MAINNET_GENESIS`](crate::MAINNET_GENESIS) on mainnet), cover /// history from Ironwood activation on `params`' network, and end at a block the /// wallet accepts (see [`DynamicIvkRead::directory_publication_anchor`]) with the -/// wallet's Ironwood tree size there, and every swap provider with a set must have a -/// dated seen set, as for [`fetch_seen`](crate::fetch_seen). +/// wallet's Ironwood tree size there, and have a dated seen set for at least one swap +/// provider and for every provider with a set, as for [`fetch_seen`](crate::fetch_seen). /// /// Each key's receiver is first tested against the publication's filters, which every /// wallet downloads alike: only a receiver in the paid set is looked up over PIR, and diff --git a/zakura/pir-receiver/tests/sweep.rs b/zakura/pir-receiver/tests/sweep.rs index 1d7107b5..d5d23387 100644 --- a/zakura/pir-receiver/tests/sweep.rs +++ b/zakura/pir-receiver/tests/sweep.rs @@ -1150,6 +1150,51 @@ async fn every_provider_with_sets_needs_a_seen_set() { assert_eq!(seen.contains(&held), [true, true, false]); } +/// A publication without provider seen sets says nothing of which addresses were +/// quoted, so a sweep refuses it as `fetch_seen` does, before any key finishes or +/// spend evidence is released. +#[tokio::test] +async fn a_publication_without_seen_sets_finishes_no_sweep() { + let mut st = ironwood_wallet(); + let network = *st.network(); + let account = st.test_account().unwrap().id(); + st.generate_and_scan_empty_blocks_with_dynamic_ivks(1); + restore(&mut st, account); + let through = tip(&st); + let directory = Directory::new(publish(&[], &[], through, &[])); + let lock = Labels::default(); + let result = sweep( + st.wallet_mut().db_mut(), + &network, + &[account], + through, + GENESIS, + ORIGIN, + &directory, + &mut NoNotes, + &lock, + NOW, + ) + .await; + assert!( + matches!(result, Err(Error::Directory(DirectoryError::Malformed))), + "{result:?}" + ); + let labels = lock.0.borrow(); + assert!( + labels + .iter() + .all(|l| ["dynamic_sweep.due", "dynamic_sweep.attempt"].contains(l)), + "{labels:?}" + ); + assert!( + st.wallet() + .db() + .dynamic_history_pending(account, through.height) + .unwrap() + ); +} + /// A directory whose queries fail after it accepted the session. struct FailingQueries(Directory); From c37511ca3cc4de74d08d3dfa026d656e7e183e69 Mon Sep 17 00:00:00 2001 From: Adam Tucker Date: Sun, 11 Oct 2026 02:57:58 -0600 Subject: [PATCH 27/40] Continue a sweep's application after a before-birthday payment apply_dynamic_sweep may stop at PaymentApplication::BeforeBirthday, a payment it already authenticated and processed. The sweep treated that as queued work and backed the key off, so a backend that returns it needed one delayed run per pre-birthday payment, up to the twelve-hour backoff, before the sweep could finish. The sweep now calls apply_dynamic_sweep again after BeforeBirthday, at most once per payment of the lookup, so a backend that keeps returning it still ends as queued rather than looping. Co-Authored-By: Claude Opus 5.5 --- zakura/pir-receiver/src/sweep.rs | 56 ++++++++++++++++++++++++++++++-- 1 file changed, 53 insertions(+), 3 deletions(-) diff --git a/zakura/pir-receiver/src/sweep.rs b/zakura/pir-receiver/src/sweep.rs index 1a01e40b..858820b6 100644 --- a/zakura/pir-receiver/src/sweep.rs +++ b/zakura/pir-receiver/src/sweep.rs @@ -564,20 +564,38 @@ where } else { None }; - let applied = lock - .write("dynamic_sweep.apply", || { + let applied = apply_queued(payments.len(), || { + lock.write("dynamic_sweep.apply", || { wallet.apply_dynamic_sweep(account, key, through, anchor, |position, cmx| { witnesses.and_then(|w| w.path(position, cmx).ok()) }) }) .map_err(Error::Wallet)? - .map_err(Error::Deferred)?; + .map_err(Error::Deferred) + })?; match applied { PaymentApplication::Applied => Ok(()), waiting => Err(Error::Queued(waiting)), } } +/// Runs `apply`, one `apply_dynamic_sweep` call, again after each +/// [`PaymentApplication::BeforeBirthday`], which reports a payment processed rather than +/// waiting. Each of the at most `queued` payments can report it once. +fn apply_queued( + queued: usize, + mut apply: impl FnMut() -> Result, +) -> Result { + let mut applied = apply()?; + for _ in 0..queued { + if applied != PaymentApplication::BeforeBirthday { + break; + } + applied = apply()?; + } + Ok(applied) +} + /// A directory payment as the wallet takes it. fn directory_payment(payment: receiver_directory::Payment) -> DirectoryPayment { DirectoryPayment { @@ -593,3 +611,35 @@ fn directory_payment(payment: receiver_directory::Payment) -> DirectoryPayment { ciphertext_prefix: payment.ciphertext_prefix, } } + +#[cfg(test)] +mod tests { + use super::*; + + /// Runs [`apply_queued`] for `queued` payments over `results`, returning what it + /// returned and how many results it took. + fn run(queued: usize, results: &[PaymentApplication]) -> (PaymentApplication, usize) { + let mut results = results.iter().copied(); + let mut calls = 0; + let applied = apply_queued::<()>(queued, || { + calls += 1; + Ok(results.next().expect("called too often")) + }); + (applied.unwrap(), calls) + } + + /// A before-birthday payment was processed, so applying goes on to finish the + /// sweep, but a backend reporting it more often than it has payments is waiting. + #[test] + fn before_birthday_payments_continue_applying() { + use PaymentApplication::*; + let finished = [BeforeBirthday, BeforeBirthday, Applied]; + assert_eq!(run(2, &finished), (Applied, 3)); + assert_eq!( + run(2, &[BeforeBirthday, AwaitingWitness]), + (AwaitingWitness, 2) + ); + assert_eq!(run(1, &[BeforeBirthday; 3]), (BeforeBirthday, 2)); + assert_eq!(run(0, &[Applied]), (Applied, 1)); + } +} From 7f0fa011d7ae5c254128d7f181980da830f587bc Mon Sep 17 00:00:00 2001 From: Adam Tucker Date: Sun, 11 Oct 2026 04:17:57 -0600 Subject: [PATCH 28/40] Note why a sweep ignores seen-feed dates The seen feeds start before any dynamic address existed, so a sweep uses their membership without their dates; issuance is where coverage matters and enforces it. Co-Authored-By: Claude Opus 5.5 --- zakura/pir-receiver/src/sweep.rs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/zakura/pir-receiver/src/sweep.rs b/zakura/pir-receiver/src/sweep.rs index 858820b6..115acc41 100644 --- a/zakura/pir-receiver/src/sweep.rs +++ b/zakura/pir-receiver/src/sweep.rs @@ -406,6 +406,8 @@ impl<'a, T: Transport> Directory<'a, T> { "the receiver directory's tree size differs from the wallet's at its anchor", )); } + // Seen feeds start at `EARLIEST_RECEIVE_QUOTE`, before any dynamic address existed, so a + // sweep ignores their dates; issuance enforces coverage where reuse is at stake. let seen = seen_sets(&manifest.directory.filters)? .into_iter() .map(|(label, _, _)| label.to_owned()) From bed573b578250e4b08716821d7fe0c9ed6038f6d Mon Sep 17 00:00:00 2001 From: Adam Tucker Date: Sun, 11 Oct 2026 10:54:37 -0600 Subject: [PATCH 29/40] Watch only recent restored keys and recheck closed ones by paid filter Read each provider's recent set again: a sweep passes whether a current, complete recent set holds the key's receiver, so only such a key keeps scanning for the restore watch. A set counts only if its feed read the provider within fifteen minutes and its window and feed history cover the watch, and unless every provider has one, every key keeps scanning. A closed key with no known payment is the store's backstop: after release, a run checks those whose sweep finished below the current publication against its paid filter and records the result, which queues a sweep for each hit. Filters are now fetched only when a key needs them, so a run over an already checked publication fetches only its manifest. A backstop failure is returned after release, so a failing directory cannot hold back spend evidence release. Restore the recent-set rule and provider completeness tests, close non-recent keys in the existing ones, and add a regression in which a payment to a closed key after the publication anchor is found only once a newer publication shows it, while a paid key is not swept again. Co-Authored-By: Claude Opus 5.5 --- zakura/pir-receiver/CHANGELOG.md | 5 +- zakura/pir-receiver/README.md | 18 ++- zakura/pir-receiver/src/sweep.rs | 240 ++++++++++++++++++++++++----- zakura/pir-receiver/tests/sweep.rs | 124 ++++++++++++--- 4 files changed, 321 insertions(+), 66 deletions(-) diff --git a/zakura/pir-receiver/CHANGELOG.md b/zakura/pir-receiver/CHANGELOG.md index 9205a7e0..bf6a42de 100644 --- a/zakura/pir-receiver/CHANGELOG.md +++ b/zakura/pir-receiver/CHANGELOG.md @@ -8,8 +8,9 @@ receiver-directory sweeps of its dynamic keys for any store implementing the backend's `DynamicIvkWrite`, with `Transport`, `NoteSource`, `EnhanceNotes`, `WriteLock`, `Swept`, `Error` and `MAINNET_GENESIS`. It reads a publication's labeled filter - sets `paid` and each provider's `seen`. The receiver PIR client moved here from - Vizor. + sets `paid` and each provider's `recent` and `seen`, trusting a recent set only while + its feed is current, and rechecks closed keys against each newer paid filter. The + receiver PIR client moved here from Vizor. - `fetch_seen` and `Seen`: a publication's swap provider seen sets, which a wallet checks before issuing a swap address. A publication without seen sets, or in which a provider with sets lacks a dated seen set, is malformed, for `fetch_seen` and diff --git a/zakura/pir-receiver/README.md b/zakura/pir-receiver/README.md index 6fab3c0c..16b7f748 100644 --- a/zakura/pir-receiver/README.md +++ b/zakura/pir-receiver/README.md @@ -8,11 +8,14 @@ incoming lookahead key, is first tested against the publication's filters, which every wallet downloads alike. Only a receiver in the paid set is looked up over PIR, and the payments found are imported only after the wallet checks them against its own chain. A receiver in a swap provider's seen set is marked seen as its sweep attempt -starts, before any lookup, so issuance does not hand it out again. Every swept key then keeps scanning for the store's restore watch, -from the block after the publication's end, so a payment the publication missed is -found by scanning; the store documents how long the watch lasts (for -`zakura-client-sqlite`, `close_finished_dynamic_keys`). A run that finds nothing to -look up opens no PIR session and fetches no witnesses. +starts, before any lookup, so issuance does not hand it out again. A key whose receiver +a provider was given within the wallet's restore watch keeps scanning after its sweep, +and any other closes. A provider's recent set is trusted only if its feed read the +provider within the last fifteen minutes and its window and feed history cover that +watch; unless every provider has such a set, every key keeps scanning. A closed key +with no known payment is checked against each newer publication's paid filter, and +swept again on a hit. A run that finds nothing to look up opens no PIR session and +fetches no witnesses. Applications supply: @@ -34,8 +37,9 @@ until they land on its `main`. `tests/sweep.rs` serves a directory in process through the receiver service's own routes and checks that a restored wallet finds and imports a payout, extends its lookahead past it, repeats no finished sweep, imports every payment of a key paid -twice, makes no lookup when nothing was paid, keeps every swept address scanning and -issues no seen one, refuses a publication that is not on its chain before any lookup, +twice, makes no lookup when nothing was paid, keeps scanning only recently quoted +addresses, and every address unless each provider has a recent set, issues no seen +one, finds a payment to a closed address through a newer paid filter, refuses a publication that is not on its chain before any lookup, refuses one without seen sets or in which a provider lacks one, and replaces a deferred payment with a later publication's answer. Against a fake Enhance service, `EnhanceNotes` accepts fresh routing after a refused diff --git a/zakura/pir-receiver/src/sweep.rs b/zakura/pir-receiver/src/sweep.rs index 115acc41..de7849d6 100644 --- a/zakura/pir-receiver/src/sweep.rs +++ b/zakura/pir-receiver/src/sweep.rs @@ -5,11 +5,12 @@ use std::{collections::BTreeMap, num::NonZeroU32}; use futures_util::StreamExt; use receiver_directory::{ Receiver, - filter::{Filters, PAID}, + filter::{Filters, PAID, RECENT}, + snapshot::FilterSet, witness::WitnessSnapshot, }; use receiver_pir::{AcceptedCoverage, transport::DirectoryClient}; -use zakura_dynamic_ivk::KeyId; +use zakura_dynamic_ivk::{KeyId, lifecycle::RESTORE_WATCH_SECS}; use zakura_pir_enhance::{ ClientError, ClientResourceLimits, transport::{Client, PendingClient, Transport as EnhanceTransport}, @@ -40,6 +41,9 @@ const BATCH: NonZeroU32 = NonZeroU32::new(64).unwrap(); /// Positions whose note data one request asks for. Each batch is queued as it arrives, /// so a long history keeps its progress when a run stops partway. const NOTE_BATCH: usize = 64; +/// How long before `now` a provider's recent set may have last read its feed and still +/// rule a receiver out. A quote made after that read is missing from the set. +const RECENT_SET_MAX_AGE_SECS: i64 = 15 * 60; /// Why a sweep run, or one key's sweep, stopped. `E` is the wallet's error type. #[derive(Debug, thiserror::Error)] @@ -192,9 +196,19 @@ pub struct Swept { /// Each key's receiver is first tested against the publication's filters, which every /// wallet downloads alike: only a receiver in the paid set is looked up over PIR, and /// one in a swap provider's seen set is never issued again (see -/// [`DynamicIvkWrite::begin_dynamic_sweep_attempt`]). Whatever the provider sets say, -/// every swept key then keeps scanning for the wallet's restore watch (see -/// [`DynamicIvkWrite::apply_dynamic_sweep`]). +/// [`DynamicIvkWrite::begin_dynamic_sweep_attempt`]). A key whose receiver a provider's +/// recent set holds keeps scanning for the wallet's restore watch, and any other closes +/// (see [`DynamicIvkWrite::apply_dynamic_sweep`]). A recent set can rule a receiver out +/// only if it is current and complete: its feed's last read began at most fifteen +/// minutes before `now`, and its window, with the feed running throughout, covers +/// [`RESTORE_WATCH_SECS`]. Unless every provider has such a set, every key keeps +/// scanning. +/// +/// After releasing, the run checks closed keys with no known payment against the +/// publication's paid filter once per publication (see +/// [`DynamicIvkWrite::record_paid_filter_check`]), so a payment the publication shows +/// after such a key closed is swept on a later run. While any such key exists, every +/// run fetches the manifest; it fetches the filters only for a newer publication. /// /// A run that looks nothing up opens no PIR session and fetches no witnesses. New /// payments a lookup finds take their note data from `notes`. A run sweeps at most one @@ -203,7 +217,7 @@ pub struct Swept { /// A key that fails is backed off and listed in [`Swept::deferred`], and the others go /// on, unless the directory's session or connection failed or `notes` cannot serve the /// wallet's chain: then the run stops before leasing more keys. A lease the wallet fails to store stops the run with its error. -/// `now` is the caller's clock in seconds, for retry backoff. +/// `now` is the caller's clock in seconds, for retry backoff and the recent sets. #[allow(clippy::too_many_arguments)] pub async fn sweep( wallet: &mut W, @@ -238,12 +252,15 @@ where pending: false, }; let current = at_tip(wallet, through).map_err(Error::Wallet)?; + let mut transport = Some(transport); + let mut directory = None; if current { let work = due(wallet, accounts, through, lock, now)?; if !work.is_empty() { + let transport = transport.take().expect("not yet used"); let accepted = Directory::accept(wallet, params, through, genesis, origin, transport).await; - let mut directory = match accepted { + let directory = directory.insert(match accepted { Ok(directory) => directory, Err(e) => { // No key can be swept without the directory, so each backs off as @@ -257,8 +274,8 @@ where } return Err(e); } - }; - let checks = directory.check(&work)?; + }); + let checks = directory.check(&work, now).await?; let lookups = checks.iter().filter(|check| check.paid).count(); if let Some(session) = &mut directory.session { session.client.use_file_for_work(lookups).await?; @@ -270,15 +287,7 @@ where }) .map_err(Error::Wallet)?; let swept_key = sweep_key( - wallet, - &mut directory, - account, - &item, - check.paid, - lookups, - through, - notes, - lock, + wallet, directory, account, &item, check, lookups, through, notes, lock, ); match swept_key.await { Ok(()) => swept.finished += 1, @@ -326,9 +335,62 @@ where .dynamic_history_pending(*account, through.height) .map_err(Error::Wallet)?; } + if let (true, Some(transport)) = (current, transport) { + let mut closed = false; + for account in accounts { + let keys = wallet.dynamic_keys_to_recheck(*account, through.height + 1); + closed |= !keys.map_err(Error::Wallet)?.is_empty(); + } + if closed { + let accepted = Directory::accept(wallet, params, through, genesis, origin, transport); + directory = Some(accepted.await?); + } + } + if let Some(directory) = &mut directory { + swept.pending |= recheck(wallet, directory, accounts, lock, now).await?; + } Ok(swept) } +/// Records the publication's paid filter for `accounts`' keys whose sweep finished below +/// its anchor (see [`DynamicIvkWrite::record_paid_filter_check`]), fetching the filters +/// only if a key needs them. Returns whether any sweep was queued again. +async fn recheck( + wallet: &mut W, + directory: &mut Directory<'_, T>, + accounts: &[AccountId], + lock: &L, + now: i64, +) -> Result>> +where + W: DynamicIvkWrite, + T: Transport, + L: WriteLock, +{ + let mut queued = false; + for &account in accounts { + let keys = wallet + .dynamic_keys_to_recheck(account, directory.anchor.height) + .map_err(Error::Wallet)?; + if keys.is_empty() { + continue; + } + let work: Vec<_> = keys.into_iter().map(|item| ((), item)).collect(); + let checks = directory.check(&work, now).await?; + let checked: Vec<_> = work + .iter() + .zip(checks) + .map(|((_, item), check)| (item.key, check.paid)) + .collect(); + queued |= checked.iter().any(|&(_, paid)| paid); + lock.write("dynamic_sweep.recheck", || { + wallet.record_paid_filter_check(account, directory.anchor, &checked) + }) + .map_err(Error::Wallet)?; + } + Ok(queued) +} + /// Whether `through` is still the wallet's stored chain tip and the block it scanned /// there. fn at_tip(wallet: &W, through: ChainPoint) -> Result { @@ -341,13 +403,15 @@ fn at_tip(wallet: &W, through: ChainPoint) -> Result { origin: &'a str, /// Moves into the session when one opens. @@ -355,7 +419,7 @@ struct Directory<'a, T> { manifest: receiver_pir::Manifest, accepted: AcceptedCoverage, anchor: ChainPoint, - filters: Filters, + filters: Option, /// The labels of the provider seen sets. seen: Vec, session: Option>, @@ -370,7 +434,7 @@ struct Session { impl<'a, T: Transport> Directory<'a, T> { /// Accepts the publication `origin` serves, at the block of the wallet's chain it - /// ends at, and downloads its filters. + /// ends at. async fn accept( wallet: &W, params: &P, @@ -412,41 +476,79 @@ impl<'a, T: Transport> Directory<'a, T> { .into_iter() .map(|(label, _, _)| label.to_owned()) .collect(); - let filters = DirectoryClient::fetch_filters(origin, &transport, &manifest).await?; Ok(Self { origin, transport: Some(transport), manifest, accepted, anchor, - filters, + filters: None, seen, session: None, }) } - /// What the filters say about each item's receiver. - fn check(&self, work: &[(A, DiscoveryWork)]) -> Result, DirectoryError> { + /// What the filters say about each item's receiver at `now`, downloading them first + /// if needed; see [`sweep`] for how the recent sets are read. + async fn check( + &mut self, + work: &[(A, DiscoveryWork)], + now: i64, + ) -> Result, Error> { let receivers = work .iter() .map(|(_, item)| Receiver::from_bytes(item.receiver)) - .collect::, _>>()?; + .collect::, _>>() + .map_err(DirectoryError::from)?; + if self.filters.is_none() { + let transport = self + .transport + .as_ref() + .ok_or(Error::Unavailable("the receiver directory session failed"))?; + let filters = + DirectoryClient::fetch_filters(self.origin, transport, &self.manifest).await?; + self.filters = Some(filters); + } let key = &self.manifest.directory.salt; + let filters = self.filters.as_ref().expect("fetched above"); // Fetching the filters checked them against the manifest's sets. let matches = |label: &str| { - self.filters + filters .get(label) .expect("the manifest declares every set") .matches(key, &receivers) }; let mut checks: Vec<_> = matches(PAID) .into_iter() - .map(|paid| Check { paid, seen: false }) + .map(|paid| Check { + paid, + recent: false, + seen: false, + }) .collect(); + // Every provider has a seen set (see `seen_sets`), and needs a current, complete + // recent set too, or any receiver may still be paid. + let mut complete = true; for label in &self.seen { for (check, hit) in checks.iter_mut().zip(matches(label)) { check.seen |= hit; } + let provider = label.split_once('/').map_or(label.as_str(), |(p, _)| p); + let recent = format!("{provider}/{RECENT}"); + let sets = &self.manifest.directory.filters; + match sets.iter().find(|set| set.label == recent) { + Some(set) if covers_restore_watch(set, now) => { + for (check, hit) in checks.iter_mut().zip(matches(&recent)) { + check.recent |= hit; + } + } + _ => complete = false, + } + } + if !complete { + for check in &mut checks { + check.recent = true; + } } Ok(checks) } @@ -473,6 +575,22 @@ impl<'a, T: Transport> Directory<'a, T> { } } +/// Whether a provider's recent set can rule a receiver out at `now`: its feed's last +/// read began at most [`RECENT_SET_MAX_AGE_SECS`] before `now`, and its window, which +/// reaches back from that read through the time the feed was running, covers +/// [`RESTORE_WATCH_SECS`]. +fn covers_restore_watch(set: &FilterSet, now: i64) -> bool { + let window = set.window_secs.and_then(|w| i64::try_from(w).ok()); + match (window, set.since_unix, set.until_unix) { + (Some(window), Some(since), Some(until)) => { + window >= RESTORE_WATCH_SECS + && since <= until.saturating_sub(window) + && until >= now.saturating_sub(RECENT_SET_MAX_AGE_SECS) + } + _ => false, + } +} + /// `accounts`' due sweeps at `through`, a bounded batch per account. fn due( wallet: &mut W, @@ -494,9 +612,9 @@ fn due( Ok(work) } -/// Sweeps one key whose attempt is leased: looks up its receiver if `paid`, the paid -/// set holds it, queues the answer, empty on a miss, with new payments' note data in -/// batches, and applies it with the publication's witnesses. `lookups` is the batch's +/// Sweeps one key whose attempt is leased: looks up its receiver if `check` shows the +/// paid set holds it, queues the answer, empty on a miss, with new payments' note data +/// in batches, and applies it with the publication's witnesses. `lookups` is the batch's /// lookup count, which sizes a session that this key opens. #[allow(clippy::too_many_arguments)] async fn sweep_key( @@ -504,7 +622,7 @@ async fn sweep_key( directory: &mut Directory<'_, T>, account: AccountId, item: &DiscoveryWork, - paid: bool, + check: Check, lookups: usize, through: ChainPoint, notes: &mut N, @@ -518,6 +636,7 @@ where { let key = item.key; let anchor = directory.anchor; + let paid = check.paid; let payments: Vec<_> = if paid { let receiver = Receiver::from_bytes(item.receiver).map_err(DirectoryError::from)?; let accepted = directory.accepted; @@ -568,8 +687,8 @@ where }; let applied = apply_queued(payments.len(), || { lock.write("dynamic_sweep.apply", || { - wallet.apply_dynamic_sweep(account, key, through, anchor, |position, cmx| { - witnesses.and_then(|w| w.path(position, cmx).ok()) + wallet.apply_dynamic_sweep(account, key, through, anchor, check.recent, |p, cmx| { + witnesses.and_then(|w| w.path(p, cmx).ok()) }) }) .map_err(Error::Wallet)? @@ -618,6 +737,55 @@ fn directory_payment(payment: receiver_directory::Payment) -> DirectoryPayment { mod tests { use super::*; + /// A recent set of `window_secs` from a feed that started at `since_unix` and last + /// read the provider at `until_unix`. + fn recent(window_secs: Option, since_unix: Option, until_unix: i64) -> FilterSet { + FilterSet { + label: "near-intents/recent".into(), + count: 0, + window_secs, + since_unix, + until_unix: Some(until_unix), + } + } + + #[test] + fn a_recent_set_rules_receivers_out_only_when_current_across_the_whole_watch() { + let now = 10 * RESTORE_WATCH_SECS; + let watch = RESTORE_WATCH_SECS as u64; + let read = now - 60; + let started = Some(read - RESTORE_WATCH_SECS); + assert!(covers_restore_watch( + &recent(Some(watch), started, read), + now + )); + assert!(covers_restore_watch( + &recent(Some(2 * watch), Some(0), read), + now + )); + // A shorter window, or a feed younger than the window, can miss a quote. + assert!(!covers_restore_watch( + &recent(Some(watch - 1), started, read), + now + )); + assert!(!covers_restore_watch( + &recent(Some(watch), Some(read - 60), read), + now + )); + assert!(!covers_restore_watch(&recent(None, started, read), now)); + assert!(!covers_restore_watch(&recent(Some(watch), None, read), now)); + // So can a set whose feed stalled: quotes after its last read are missing. + let stalled = now - RECENT_SET_MAX_AGE_SECS - 1; + let started = Some(stalled - RESTORE_WATCH_SECS); + assert!(!covers_restore_watch( + &recent(Some(watch), started, stalled), + now + )); + let mut undated = recent(Some(watch), started, read); + undated.until_unix = None; + assert!(!covers_restore_watch(&undated, now)); + } + /// Runs [`apply_queued`] for `queued` payments over `results`, returning what it /// returned and how many results it took. fn run(queued: usize, results: &[PaymentApplication]) -> (PaymentApplication, usize) { diff --git a/zakura/pir-receiver/tests/sweep.rs b/zakura/pir-receiver/tests/sweep.rs index d5d23387..d71c066f 100644 --- a/zakura/pir-receiver/tests/sweep.rs +++ b/zakura/pir-receiver/tests/sweep.rs @@ -174,9 +174,9 @@ fn pay_restored_key_of( (record, ciphertext[52..].try_into().unwrap()) } -/// A swap provider's sets: an empty recent set, which wallets do not read, and every -/// receiver it ever had, from a feed that started long before [`NOW`] and last read the -/// provider a minute before it. +/// A swap provider's sets: an empty recent set covering the wallet's restore watch, and +/// every receiver it ever had, from a feed that started long before [`NOW`] and last +/// read the provider a minute before it. fn provider(seen: &[Receiver]) -> Vec { provider_named("near-intents", seen) } @@ -390,8 +390,10 @@ async fn a_restore_sweep_finds_a_payout_and_moves_the_lookahead_past_it() { directory.requests() ); - // A later run finds every sweep finished and asks the directory nothing. - let before = directory.requests(); + // A later run finds every sweep finished and the publication already checked, so + // it fetches only the manifest. + let mut before = directory.requests(); + before.push("GET /v1/receiver/init".into()); let swept = sweep( st.wallet_mut().db_mut(), &network, @@ -592,18 +594,72 @@ async fn a_wallet_without_payments_downloads_only_the_filters() { let requests = directory.requests(); assert_eq!(requests.len(), 2, "{requests:?}"); assert!(requests[1].starts_with("GET /v1/receiver/filters/")); - // Every swept key keeps scanning, so a payment after the publication is found. - let scanning = st.wallet().get_dynamic_scanning_keys().unwrap().len(); - assert_eq!(scanning as u64, RECEIVE_GAP_LIMIT); - pay_restored_key(&mut st, 0, 0); + // No swap reached these addresses recently, so none keeps scanning. + assert!(st.wallet().get_dynamic_scanning_keys().unwrap().is_empty()); +} + +/// A key closed after its sweep is checked against each newer publication's paid filter, +/// so a payment after the first publication's anchor is found once a publication shows +/// it, and not before. A publication already checked costs only its manifest, and a key +/// with a known payment is not swept again. +#[tokio::test] +async fn a_newer_paid_filter_finds_a_payment_to_a_closed_key() { + let mut st = ironwood_wallet(); + let account = st.test_account().unwrap().id(); + st.generate_and_scan_empty_blocks_with_dynamic_ivks(1); + restore(&mut st, account); + let first = tip(&st); + let directory = Directory::new(publish(&[], &[], first, &provider(&[]))); + let mut notes = Notes { + data: BTreeMap::new(), + requested: Vec::new(), + }; + settle(&mut st, &[account], first, &directory, &mut notes).await; + assert!(st.wallet().get_dynamic_scanning_keys().unwrap().is_empty()); + + // The closed key misses a payment, which the same publication cannot show. + let (record, note_data) = pay_restored_key(&mut st, 0, 0); + let through = tip(&st); + let before = directory.requests().len(); + let swept = run(&mut st, &[account], through, &directory, &mut notes, NOW).await; + assert!(!swept.unwrap().pending); + assert_eq!(directory.requests()[before..], ["GET /v1/receiver/init"]); + assert_eq!(st.get_total_balance(account), Zatoshis::ZERO); + + let directory = Directory::new(publish( + std::slice::from_ref(&record), + &[record.payment.cmx], + through, + &provider(&[]), + )); + notes.data.insert(record.payment.position, note_data); + // The paid key, then the lookahead key its index brings in. + assert_eq!( + settle(&mut st, &[account], through, &directory, &mut notes).await, + 2 + ); assert_eq!( st.get_total_balance(account), Zatoshis::const_from_u64(VALUE) ); + + // A later publication still holds the paid key, but it is not looked up again. + st.generate_and_scan_empty_blocks_with_dynamic_ivks(1); + let later = tip(&st); + let directory = Directory::new(publish( + std::slice::from_ref(&record), + &[record.payment.cmx], + later, + &provider(&[]), + )); + let swept = run(&mut st, &[account], later, &directory, &mut notes, NOW).await; + let swept = swept.unwrap(); + assert_eq!((swept.finished, swept.pending), (0, false)); + assert!(directory.requests().iter().all(|r| !r.starts_with("POST"))); } #[tokio::test] -async fn seen_addresses_keep_scanning_and_are_never_issued_again() { +async fn only_recently_quoted_addresses_keep_scanning_after_their_sweep() { let mut st = ironwood_wallet(); let account = st.test_account().unwrap().id(); st.generate_and_scan_empty_blocks_with_dynamic_ivks(1); @@ -617,25 +673,27 @@ async fn seen_addresses_keep_scanning_and_are_never_issued_again() { .address_at(0u32, Scope::External); Receiver::from_bytes(address.to_raw_address_bytes()).unwrap() }; - // The old device quoted addresses 0 and 2. + // The old device quoted addresses 0 and 2, and 2 only today. let (old, quoted) = ( KeyId::new(Purpose::Receive, 0), KeyId::new(Purpose::Receive, 2), ); - let directory = Directory::new(publish( - &[], - &[], - through, - &provider(&[receiver(old), receiver(quoted)]), - )); + let mut sets = provider(&[receiver(old), receiver(quoted)]); + sets[0].receivers = vec![receiver(quoted)]; + let directory = Directory::new(publish(&[], &[], through, &sets)); let mut notes = Notes { data: BTreeMap::new(), requested: Vec::new(), }; settle(&mut st, &[account], through, &directory, &mut notes).await; - // The seen address 2 moves the lookahead, and every swept key keeps scanning. - let scanning = st.wallet().get_dynamic_scanning_keys().unwrap().len(); - assert_eq!(scanning as u64, RECEIVE_GAP_LIMIT + 3); + let scanning: Vec<_> = st + .wallet() + .get_dynamic_scanning_keys() + .unwrap() + .iter() + .map(|k| k.key_id()) + .collect(); + assert_eq!(scanning, [quoted]); // Issuance reads the same seen set, skips both, and takes the lowest address the // provider never had. let seen = fetch_seen(ORIGIN, &directory, GENESIS).await.unwrap(); @@ -946,7 +1004,8 @@ async fn release_after(behind: u32, other: impl FnOnce(&mut OtherWriter, ChainPo height, hash: st.wallet().get_block_hash(height).unwrap().unwrap(), }; - let directory = Directory::new(publish(&[], &[], end, &provider(&[]))); + // Without a recent set, every key keeps scanning from the block after `end`. + let directory = Directory::new(publish(&[], &[], end, &provider(&[])[1..])); let swept = sweep( st.wallet_mut().db_mut(), &network, @@ -1195,6 +1254,29 @@ async fn a_publication_without_seen_sets_finishes_no_sweep() { ); } +#[tokio::test] +async fn every_address_keeps_scanning_unless_each_provider_has_a_recent_set() { + for both_recent in [true, false] { + let mut st = ironwood_wallet(); + let account = st.test_account().unwrap().id(); + st.generate_and_scan_empty_blocks_with_dynamic_ivks(1); + restore(&mut st, account); + let through = tip(&st); + // Provider `b` publishes only its seen set when `both_recent` is false. + let mut sets = provider_named("a", &[]); + let b = provider_named("b", &[]); + sets.extend(b.into_iter().skip(usize::from(!both_recent))); + let directory = Directory::new(publish(&[], &[], through, &sets)); + let mut notes = Notes { + data: BTreeMap::new(), + requested: Vec::new(), + }; + settle(&mut st, &[account], through, &directory, &mut notes).await; + let scanning = st.wallet().get_dynamic_scanning_keys().unwrap().len() as u64; + assert_eq!(scanning, if both_recent { 0 } else { RECEIVE_GAP_LIMIT }); + } +} + /// A directory whose queries fail after it accepted the session. struct FailingQueries(Directory); From 404c34e3052be524372c07dbfe5f31782e292c5e Mon Sep 17 00:00:00 2001 From: Adam Tucker Date: Sun, 11 Oct 2026 12:04:04 -0600 Subject: [PATCH 30/40] Remove the before-birthday continuation from sweeps The backend no longer reports a pre-birthday directory payment as `PaymentApplication::BeforeBirthday`: it counts as applied, like any other untracked pre-birthday note. One `apply_dynamic_sweep` call therefore finishes a key or says why it waits, so the loop that applied again after each before-birthday report, and its unit test, go. Co-Authored-By: Claude Opus 5.5 --- zakura/pir-receiver/src/sweep.rs | 51 ++------------------------------ 1 file changed, 3 insertions(+), 48 deletions(-) diff --git a/zakura/pir-receiver/src/sweep.rs b/zakura/pir-receiver/src/sweep.rs index de7849d6..67f64b0d 100644 --- a/zakura/pir-receiver/src/sweep.rs +++ b/zakura/pir-receiver/src/sweep.rs @@ -685,38 +685,20 @@ where } else { None }; - let applied = apply_queued(payments.len(), || { - lock.write("dynamic_sweep.apply", || { + let applied = lock + .write("dynamic_sweep.apply", || { wallet.apply_dynamic_sweep(account, key, through, anchor, check.recent, |p, cmx| { witnesses.and_then(|w| w.path(p, cmx).ok()) }) }) .map_err(Error::Wallet)? - .map_err(Error::Deferred) - })?; + .map_err(Error::Deferred)?; match applied { PaymentApplication::Applied => Ok(()), waiting => Err(Error::Queued(waiting)), } } -/// Runs `apply`, one `apply_dynamic_sweep` call, again after each -/// [`PaymentApplication::BeforeBirthday`], which reports a payment processed rather than -/// waiting. Each of the at most `queued` payments can report it once. -fn apply_queued( - queued: usize, - mut apply: impl FnMut() -> Result, -) -> Result { - let mut applied = apply()?; - for _ in 0..queued { - if applied != PaymentApplication::BeforeBirthday { - break; - } - applied = apply()?; - } - Ok(applied) -} - /// A directory payment as the wallet takes it. fn directory_payment(payment: receiver_directory::Payment) -> DirectoryPayment { DirectoryPayment { @@ -785,31 +767,4 @@ mod tests { undated.until_unix = None; assert!(!covers_restore_watch(&undated, now)); } - - /// Runs [`apply_queued`] for `queued` payments over `results`, returning what it - /// returned and how many results it took. - fn run(queued: usize, results: &[PaymentApplication]) -> (PaymentApplication, usize) { - let mut results = results.iter().copied(); - let mut calls = 0; - let applied = apply_queued::<()>(queued, || { - calls += 1; - Ok(results.next().expect("called too often")) - }); - (applied.unwrap(), calls) - } - - /// A before-birthday payment was processed, so applying goes on to finish the - /// sweep, but a backend reporting it more often than it has payments is waiting. - #[test] - fn before_birthday_payments_continue_applying() { - use PaymentApplication::*; - let finished = [BeforeBirthday, BeforeBirthday, Applied]; - assert_eq!(run(2, &finished), (Applied, 3)); - assert_eq!( - run(2, &[BeforeBirthday, AwaitingWitness]), - (AwaitingWitness, 2) - ); - assert_eq!(run(1, &[BeforeBirthday; 3]), (BeforeBirthday, 2)); - assert_eq!(run(0, &[Applied]), (Applied, 1)); - } } From bf902cfd3494b8cb7dbe6ecc953c8115584e377b Mon Sep 17 00:00:00 2001 From: Adam Tucker Date: Sun, 11 Oct 2026 12:05:41 -0600 Subject: [PATCH 31/40] Sweep one account per call `sweep` took a list of accounts, which needed deduplication so a repeated account did not lease and back off its keys twice, and named each deferred key's account because accounts share key identities. It now takes one account, so neither is needed: `Swept` loses its account parameter and lists deferred keys as `(KeyId, Error)`, and the dedupe, its test and the per-account deferral test go. A wallet with several accounts calls `sweep` once for each. Each run still prepares at most one batch of due keys, still stops before leasing more keys when the directory session or the note source fails, and still runs release and the paid-filter recheck for the account it swept. Co-Authored-By: Claude Opus 5.5 --- zakura/pir-receiver/src/sweep.rs | 172 ++++++++------------- zakura/pir-receiver/tests/sweep.rs | 233 ++++++----------------------- 2 files changed, 111 insertions(+), 294 deletions(-) diff --git a/zakura/pir-receiver/src/sweep.rs b/zakura/pir-receiver/src/sweep.rs index 67f64b0d..66035f55 100644 --- a/zakura/pir-receiver/src/sweep.rs +++ b/zakura/pir-receiver/src/sweep.rs @@ -33,10 +33,8 @@ use crate::{DirectoryError, Transport, seen::seen_sets}; type AccountId = ::AccountId; /// A wallet's error type. type AccountError = ::Error; -/// Due sweeps, each with its account. -type Work = Vec<(AccountId, DiscoveryWork)>; -/// Due sweeps prepared per account at a time. +/// Due sweeps prepared per run. const BATCH: NonZeroU32 = NonZeroU32::new(64).unwrap(); /// Positions whose note data one request asks for. Each batch is queued as it arrives, /// so a long history keeps its progress when a run stops partway. @@ -167,21 +165,21 @@ where } } -/// What one [`sweep`] run did. `A` is the wallet's account identifier and `E` its error -/// type. +/// What one [`sweep`] run did. `E` is the wallet's error type. #[derive(Debug)] -pub struct Swept { +pub struct Swept { /// Keys whose sweep finished. pub finished: usize, - /// Keys left for a later run, each with its account, already backed off, with why. - pub deferred: Vec<(A, KeyId, Error)>, - /// Whether any of the accounts still has unfinished sweeps or queued payments. + /// Keys left for a later run, already backed off, with why. + pub deferred: Vec<(KeyId, Error)>, + /// Whether the account still has unfinished sweeps or queued payments. pub pending: bool, } -/// Runs `accounts`' due restore sweeps against the receiver directory at `origin`, +/// Runs `account`'s due restore sweeps against the receiver directory at `origin`, /// then releases Ironwood spend evidence that no sweep needs any longer (see -/// [`DynamicIvkWrite::finish_dynamic_nullifier_recovery`]). +/// [`DynamicIvkWrite::finish_dynamic_nullifier_recovery`]). A wallet with several +/// accounts calls it once for each. /// /// `through` is the wallet's fully scanned tip, and sweeps and release run only while /// it is also the stored chain tip and the block the wallet scanned there, which @@ -212,17 +210,18 @@ pub struct Swept { /// /// A run that looks nothing up opens no PIR session and fetches no witnesses. New /// payments a lookup finds take their note data from `notes`. A run sweeps at most one -/// batch of due keys per account, then moves the incoming lookahead past paid and seen -/// indices; its new keys are left for a later run, and [`Swept::pending`] reports them. -/// A key that fails is backed off and listed in [`Swept::deferred`], and the others go -/// on, unless the directory's session or connection failed or `notes` cannot serve the -/// wallet's chain: then the run stops before leasing more keys. A lease the wallet fails to store stops the run with its error. -/// `now` is the caller's clock in seconds, for retry backoff and the recent sets. +/// batch of due keys, then moves the incoming lookahead past paid and seen indices; its +/// new keys are left for a later run, and [`Swept::pending`] reports them. A key that +/// fails is backed off and listed in [`Swept::deferred`], and the others go on, unless +/// the directory's session or connection failed or `notes` cannot serve the wallet's +/// chain: then the run stops before leasing more keys. A lease the wallet fails to +/// store stops the run with its error. `now` is the caller's clock in seconds, for +/// retry backoff and the recent sets. #[allow(clippy::too_many_arguments)] pub async fn sweep( wallet: &mut W, params: &P, - accounts: &[AccountId], + account: AccountId, through: ChainPoint, genesis: [u8; 32], origin: &str, @@ -230,7 +229,7 @@ pub async fn sweep( notes: &mut N, lock: &L, now: i64, -) -> Result, AccountError>, Error>> +) -> Result>, Error>> where W: DynamicIvkWrite, P: Parameters, @@ -238,14 +237,6 @@ where N: NoteSource, L: WriteLock, { - // A repeated account would lease and back off each of its keys twice. - let mut unique = Vec::with_capacity(accounts.len()); - for account in accounts { - if !unique.contains(account) { - unique.push(*account); - } - } - let accounts = &unique[..]; let mut swept = Swept { finished: 0, deferred: Vec::new(), @@ -255,7 +246,12 @@ where let mut transport = Some(transport); let mut directory = None; if current { - let work = due(wallet, accounts, through, lock, now)?; + let work = lock + .write("dynamic_sweep.due", || { + wallet.prepare_dynamic_sweeps(account, through, now, BATCH) + }) + .map_err(Error::Wallet)? + .map_err(Error::Deferred)?; if !work.is_empty() { let transport = transport.take().expect("not yet used"); let accepted = @@ -266,9 +262,9 @@ where // No key can be swept without the directory, so each backs off as // if its own lookup had failed, and the next sync does not wait on // the same directory again. - for (account, item) in &work { + for item in &work { lock.write("dynamic_sweep.attempt", || { - wallet.begin_dynamic_sweep_attempt(*account, item.key, now, false) + wallet.begin_dynamic_sweep_attempt(account, item.key, now, false) }) .map_err(Error::Wallet)?; } @@ -280,7 +276,7 @@ where if let Some(session) = &mut directory.session { session.client.use_file_for_work(lookups).await?; } - for ((account, item), check) in work.into_iter().zip(checks) { + for (item, check) in work.into_iter().zip(checks) { // A key whose lease is not stored would be due again at once. lock.write("dynamic_sweep.attempt", || { wallet.begin_dynamic_sweep_attempt(account, item.key, now, check.seen) @@ -302,7 +298,7 @@ where DirectoryError::Revision | DirectoryError::Transport(_) ) | Error::NotesUnavailable(_) ); - swept.deferred.push((account, item.key, e)); + swept.deferred.push((item.key, e)); if unavailable { break; } @@ -311,54 +307,44 @@ where } // New lookahead keys wait for a later run, which bounds this one. lock.write("dynamic_sweep.lookahead", || { - accounts - .iter() - .try_for_each(|account| wallet.maintain_dynamic_ivks(*account)) + wallet.maintain_dynamic_ivks(account) }) .map_err(Error::Wallet)?; } + // Another writer may have moved the tip while the sweep held no lock, and a + // finished key may have queued a rescan below it. + lock.write("dynamic_sweep.release", || { + let scanned = wallet.block_fully_scanned()?.map(|b| b.block_height()); + if at_tip(wallet, through)? && scanned == Some(through.height) { + wallet.finish_dynamic_nullifier_recovery(account, through)?; + } + Ok(()) + }) + .map_err(Error::Wallet)?; } - for account in accounts { - if current { - // Another writer may have moved the tip while the sweep held no lock, and a - // finished key may have queued a rescan below it. - lock.write("dynamic_sweep.release", || { - let scanned = wallet.block_fully_scanned()?.map(|b| b.block_height()); - if at_tip(wallet, through)? && scanned == Some(through.height) { - wallet.finish_dynamic_nullifier_recovery(*account, through)?; - } - Ok(()) - }) - .map_err(Error::Wallet)?; - } - swept.pending |= wallet - .dynamic_history_pending(*account, through.height) - .map_err(Error::Wallet)?; - } + swept.pending = wallet + .dynamic_history_pending(account, through.height) + .map_err(Error::Wallet)?; if let (true, Some(transport)) = (current, transport) { - let mut closed = false; - for account in accounts { - let keys = wallet.dynamic_keys_to_recheck(*account, through.height + 1); - closed |= !keys.map_err(Error::Wallet)?.is_empty(); - } - if closed { + let keys = wallet.dynamic_keys_to_recheck(account, through.height + 1); + if !keys.map_err(Error::Wallet)?.is_empty() { let accepted = Directory::accept(wallet, params, through, genesis, origin, transport); directory = Some(accepted.await?); } } if let Some(directory) = &mut directory { - swept.pending |= recheck(wallet, directory, accounts, lock, now).await?; + swept.pending |= recheck(wallet, directory, account, lock, now).await?; } Ok(swept) } -/// Records the publication's paid filter for `accounts`' keys whose sweep finished below +/// Records the publication's paid filter for `account`'s keys whose sweep finished below /// its anchor (see [`DynamicIvkWrite::record_paid_filter_check`]), fetching the filters /// only if a key needs them. Returns whether any sweep was queued again. async fn recheck( wallet: &mut W, directory: &mut Directory<'_, T>, - accounts: &[AccountId], + account: AccountId, lock: &L, now: i64, ) -> Result>> @@ -367,28 +353,23 @@ where T: Transport, L: WriteLock, { - let mut queued = false; - for &account in accounts { - let keys = wallet - .dynamic_keys_to_recheck(account, directory.anchor.height) - .map_err(Error::Wallet)?; - if keys.is_empty() { - continue; - } - let work: Vec<_> = keys.into_iter().map(|item| ((), item)).collect(); - let checks = directory.check(&work, now).await?; - let checked: Vec<_> = work - .iter() - .zip(checks) - .map(|((_, item), check)| (item.key, check.paid)) - .collect(); - queued |= checked.iter().any(|&(_, paid)| paid); - lock.write("dynamic_sweep.recheck", || { - wallet.record_paid_filter_check(account, directory.anchor, &checked) - }) + let keys = wallet + .dynamic_keys_to_recheck(account, directory.anchor.height) .map_err(Error::Wallet)?; + if keys.is_empty() { + return Ok(false); } - Ok(queued) + let checks = directory.check(&keys, now).await?; + let checked: Vec<_> = keys + .iter() + .zip(checks) + .map(|(item, check)| (item.key, check.paid)) + .collect(); + lock.write("dynamic_sweep.recheck", || { + wallet.record_paid_filter_check(account, directory.anchor, &checked) + }) + .map_err(Error::Wallet)?; + Ok(checked.iter().any(|&(_, paid)| paid)) } /// Whether `through` is still the wallet's stored chain tip and the block it scanned @@ -490,14 +471,10 @@ impl<'a, T: Transport> Directory<'a, T> { /// What the filters say about each item's receiver at `now`, downloading them first /// if needed; see [`sweep`] for how the recent sets are read. - async fn check( - &mut self, - work: &[(A, DiscoveryWork)], - now: i64, - ) -> Result, Error> { + async fn check(&mut self, work: &[DiscoveryWork], now: i64) -> Result, Error> { let receivers = work .iter() - .map(|(_, item)| Receiver::from_bytes(item.receiver)) + .map(|item| Receiver::from_bytes(item.receiver)) .collect::, _>>() .map_err(DirectoryError::from)?; if self.filters.is_none() { @@ -591,27 +568,6 @@ fn covers_restore_watch(set: &FilterSet, now: i64) -> bool { } } -/// `accounts`' due sweeps at `through`, a bounded batch per account. -fn due( - wallet: &mut W, - accounts: &[AccountId], - through: ChainPoint, - lock: &L, - now: i64, -) -> Result, Error>> { - let mut work = Vec::new(); - for &account in accounts { - let batch = lock - .write("dynamic_sweep.due", || { - wallet.prepare_dynamic_sweeps(account, through, now, BATCH) - }) - .map_err(Error::Wallet)? - .map_err(Error::Deferred)?; - work.extend(batch.into_iter().map(|item| (account, item))); - } - Ok(work) -} - /// Sweeps one key whose attempt is leased: looks up its receiver if `check` shows the /// paid set holds it, queues the answer, empty on a miss, with new payments' note data /// in batches, and applies it with the publication's witnesses. `lookups` is the batch's diff --git a/zakura/pir-receiver/tests/sweep.rs b/zakura/pir-receiver/tests/sweep.rs index d71c066f..2eae1327 100644 --- a/zakura/pir-receiver/tests/sweep.rs +++ b/zakura/pir-receiver/tests/sweep.rs @@ -114,17 +114,7 @@ fn tip(st: &State) -> ChainPoint { /// `position`, which is the number of earlier Ironwood outputs. fn pay_restored_key(st: &mut State, index: u64, position: u64) -> (Record, [u8; 528]) { let parent = FullViewingKey::from(st.test_account().unwrap().usk().orchard()); - pay_restored_key_of(st, &parent, index, position) -} - -/// [`pay_restored_key`] for the account whose Orchard viewing key is `parent`. -fn pay_restored_key_of( - st: &mut State, - parent: &FullViewingKey, - index: u64, - position: u64, -) -> (Record, [u8; 528]) { - let fvk = KeyId::new(Purpose::Receive, index).derive(parent).unwrap(); + let fvk = KeyId::new(Purpose::Receive, index).derive(&parent).unwrap(); let (height, _, _) = st.generate_next_block( &IronwoodFvk(fvk.clone()), AddressType::DefaultExternal, @@ -371,7 +361,7 @@ async fn a_restore_sweep_finds_a_payout_and_moves_the_lookahead_past_it() { data: BTreeMap::from([(record.payment.position, note_data)]), requested: Vec::new(), }; - let finished = settle(&mut st, &[account], through, &directory, &mut notes).await; + let finished = settle(&mut st, account, through, &directory, &mut notes).await; // The first window, then, in a later run, the keys above it that the paid index // brings in. assert_eq!(finished as u64, RECEIVE_GAP_LIMIT + paid + 1); @@ -397,7 +387,7 @@ async fn a_restore_sweep_finds_a_payout_and_moves_the_lookahead_past_it() { let swept = sweep( st.wallet_mut().db_mut(), &network, - &[account], + account, through, GENESIS, ORIGIN, @@ -436,7 +426,7 @@ async fn a_key_paid_twice_is_imported_in_full() { data: BTreeMap::from([(0, first_data), (1, second_data)]), requested: Vec::new(), }; - settle(&mut st, &[account], through, &directory, &mut notes).await; + settle(&mut st, account, through, &directory, &mut notes).await; assert_eq!(notes.requested, [vec![0, 1]]); assert_eq!( st.get_total_balance(account), @@ -477,7 +467,7 @@ async fn a_long_history_takes_its_note_data_in_batches() { data, requested: Vec::new(), }; - settle(&mut st, &[account], through, &directory, &mut notes).await; + settle(&mut st, account, through, &directory, &mut notes).await; let batches: Vec<_> = notes.requested.iter().map(Vec::len).collect(); assert_eq!(batches, [64, 1]); assert_eq!( @@ -527,7 +517,7 @@ async fn note_data_for_other_positions_is_missing_data() { let swept = sweep( st.wallet_mut().db_mut(), &network, - &[account], + account, through, GENESIS, ORIGIN, @@ -542,7 +532,7 @@ async fn note_data_for_other_positions_is_missing_data() { assert!( matches!( swept.deferred.as_slice(), - [(_, key, Error::Unavailable("note data is missing for a directory payment"))] + [(key, Error::Unavailable("note data is missing for a directory payment"))] if key.index() == paid ), "{:?}", @@ -576,7 +566,7 @@ async fn a_wallet_without_payments_downloads_only_the_filters() { let swept = sweep( st.wallet_mut().db_mut(), &network, - &[account], + account, through, GENESIS, ORIGIN, @@ -614,14 +604,14 @@ async fn a_newer_paid_filter_finds_a_payment_to_a_closed_key() { data: BTreeMap::new(), requested: Vec::new(), }; - settle(&mut st, &[account], first, &directory, &mut notes).await; + settle(&mut st, account, first, &directory, &mut notes).await; assert!(st.wallet().get_dynamic_scanning_keys().unwrap().is_empty()); // The closed key misses a payment, which the same publication cannot show. let (record, note_data) = pay_restored_key(&mut st, 0, 0); let through = tip(&st); let before = directory.requests().len(); - let swept = run(&mut st, &[account], through, &directory, &mut notes, NOW).await; + let swept = run(&mut st, account, through, &directory, &mut notes, NOW).await; assert!(!swept.unwrap().pending); assert_eq!(directory.requests()[before..], ["GET /v1/receiver/init"]); assert_eq!(st.get_total_balance(account), Zatoshis::ZERO); @@ -635,7 +625,7 @@ async fn a_newer_paid_filter_finds_a_payment_to_a_closed_key() { notes.data.insert(record.payment.position, note_data); // The paid key, then the lookahead key its index brings in. assert_eq!( - settle(&mut st, &[account], through, &directory, &mut notes).await, + settle(&mut st, account, through, &directory, &mut notes).await, 2 ); assert_eq!( @@ -652,7 +642,7 @@ async fn a_newer_paid_filter_finds_a_payment_to_a_closed_key() { later, &provider(&[]), )); - let swept = run(&mut st, &[account], later, &directory, &mut notes, NOW).await; + let swept = run(&mut st, account, later, &directory, &mut notes, NOW).await; let swept = swept.unwrap(); assert_eq!((swept.finished, swept.pending), (0, false)); assert!(directory.requests().iter().all(|r| !r.starts_with("POST"))); @@ -685,7 +675,7 @@ async fn only_recently_quoted_addresses_keep_scanning_after_their_sweep() { data: BTreeMap::new(), requested: Vec::new(), }; - settle(&mut st, &[account], through, &directory, &mut notes).await; + settle(&mut st, account, through, &directory, &mut notes).await; let scanning: Vec<_> = st .wallet() .get_dynamic_scanning_keys() @@ -762,7 +752,7 @@ async fn a_seen_hit_outlives_a_rejected_payment_and_a_later_miss() { let swept = sweep( st.wallet_mut().db_mut(), &network, - &[account], + account, through, GENESIS, ORIGIN, @@ -776,7 +766,7 @@ async fn a_seen_hit_outlives_a_rejected_payment_and_a_later_miss() { assert!( matches!( &swept.deferred[..], - [(_, deferred, Error::Queued(PaymentApplication::Rejected))] if *deferred == key + [(deferred, Error::Queued(PaymentApplication::Rejected))] if *deferred == key ), "{:?}", swept.deferred @@ -788,7 +778,7 @@ async fn a_seen_hit_outlives_a_rejected_payment_and_a_later_miss() { let swept = sweep( st.wallet_mut().db_mut(), &network, - &[account], + account, through, GENESIS, ORIGIN, @@ -870,7 +860,7 @@ async fn a_failed_attempt_write_stops_the_run() { let result = sweep( st.wallet_mut().db_mut(), &network, - &[account], + account, through, GENESIS, ORIGIN, @@ -924,7 +914,7 @@ async fn a_stale_tip_releases_nothing() { sweep( st.wallet_mut().db_mut(), &network, - &[account], + account, through, GENESIS, ORIGIN, @@ -1009,7 +999,7 @@ async fn release_after(behind: u32, other: impl FnOnce(&mut OtherWriter, ChainPo let swept = sweep( st.wallet_mut().db_mut(), &network, - &[account], + account, through, GENESIS, ORIGIN, @@ -1061,67 +1051,6 @@ async fn a_rescan_the_sweep_queued_releases_nothing() { ); } -/// A repeated account sweeps its keys once: each is leased once, and a deferred key is -/// listed once. -#[tokio::test] -async fn a_repeated_account_sweeps_each_key_once() { - let mut st = ironwood_wallet(); - let network = *st.network(); - let account = st.test_account().unwrap().id(); - let (record, _) = pay_restored_key(&mut st, 0, 0); - st.generate_and_scan_empty_blocks_with_dynamic_ivks(1); - restore(&mut st, account); - let through = tip(&st); - let due = st - .wallet_mut() - .db_mut() - .prepare_dynamic_sweeps(account, through, NOW, NonZeroU32::new(64).unwrap()) - .unwrap() - .unwrap() - .len(); - let directory = Directory::new(publish( - std::slice::from_ref(&record), - &[record.payment.cmx], - through, - &provider(&[]), - )); - // Without note data the paid key defers. - let mut notes = Notes { - data: BTreeMap::new(), - requested: Vec::new(), - }; - let lock = Labels::default(); - let swept = sweep( - st.wallet_mut().db_mut(), - &network, - &[account, account], - through, - GENESIS, - ORIGIN, - &directory, - &mut notes, - &lock, - NOW, - ) - .await - .unwrap(); - let attempts = lock - .0 - .borrow() - .iter() - .filter(|label| **label == "dynamic_sweep.attempt") - .count(); - assert_eq!(attempts, due); - assert!( - matches!( - &swept.deferred[..], - [(_, key, Error::Unavailable(_))] if *key == KeyId::new(Purpose::Receive, 0) - ), - "{:?}", - swept.deferred - ); -} - /// Another network's seen sets say nothing about this network's quotes, so a /// publication committing to another genesis is refused as a sweep refuses it. #[tokio::test] @@ -1180,7 +1109,7 @@ async fn every_provider_with_sets_needs_a_seen_set() { let result = sweep( st.wallet_mut().db_mut(), &network, - &[account], + account, through, GENESIS, ORIGIN, @@ -1225,7 +1154,7 @@ async fn a_publication_without_seen_sets_finishes_no_sweep() { let result = sweep( st.wallet_mut().db_mut(), &network, - &[account], + account, through, GENESIS, ORIGIN, @@ -1271,7 +1200,7 @@ async fn every_address_keeps_scanning_unless_each_provider_has_a_recent_set() { data: BTreeMap::new(), requested: Vec::new(), }; - settle(&mut st, &[account], through, &directory, &mut notes).await; + settle(&mut st, account, through, &directory, &mut notes).await; let scanning = st.wallet().get_dynamic_scanning_keys().unwrap().len() as u64; assert_eq!(scanning, if both_recent { 0 } else { RECEIVE_GAP_LIMIT }); } @@ -1314,7 +1243,7 @@ async fn a_revoked_session_stops_the_run_before_leasing_more_keys() { let swept = sweep( st.wallet_mut().db_mut(), &network, - &[account], + account, through, GENESIS, ORIGIN, @@ -1383,13 +1312,13 @@ async fn a_failed_session_open_stops_the_run_before_leasing_more_keys() { data: BTreeMap::new(), requested: Vec::new(), }; - let swept = run(&mut st, &[account], through, &directory, &mut notes, NOW) + let swept = run(&mut st, account, through, &directory, &mut notes, NOW) .await .unwrap(); assert!( matches!( &swept.deferred[..], - [(_, key, Error::Directory(_))] if *key == KeyId::new(Purpose::Receive, 0) + [(key, Error::Directory(_))] if *key == KeyId::new(Purpose::Receive, 0) ), "{:?}", swept.deferred @@ -1435,7 +1364,7 @@ async fn a_publication_off_the_wallets_chain_is_refused_before_any_lookup() { let result = sweep( st.wallet_mut().db_mut(), &network, - &[account], + account, through, GENESIS, ORIGIN, @@ -1459,7 +1388,7 @@ async fn a_publication_off_the_wallets_chain_is_refused_before_any_lookup() { let _ = sweep( st.wallet_mut().db_mut(), &network, - &[account], + account, through, GENESIS, ORIGIN, @@ -1489,7 +1418,7 @@ async fn a_publication_short_of_its_anchor_blocks_outputs_is_refused() { let result = sweep( st.wallet_mut().db_mut(), &network, - &[account], + account, through, GENESIS, ORIGIN, @@ -1661,7 +1590,7 @@ async fn an_unusable_note_source_stops_the_run_before_leasing_more_keys() { let swept = sweep( st.wallet_mut().db_mut(), &network, - &[account], + account, through, GENESIS, ORIGIN, @@ -1675,7 +1604,7 @@ async fn an_unusable_note_source_stops_the_run_before_leasing_more_keys() { assert!( matches!( &swept.deferred[..], - [(_, key, Error::NotesUnavailable(_))] if *key == KeyId::new(Purpose::Receive, 0) + [(key, Error::NotesUnavailable(_))] if *key == KeyId::new(Purpose::Receive, 0) ), "{:?}", swept.deferred @@ -1693,23 +1622,23 @@ async fn an_unusable_note_source_stops_the_run_before_leasing_more_keys() { ); } -/// Runs one sweep of `accounts` through `through` at `now`. +/// Runs one sweep of `account` through `through` at `now`. async fn run( st: &mut State, - accounts: &[AccountUuid], + account: AccountUuid, through: ChainPoint, directory: impl Transport, notes: &mut Notes, now: i64, ) -> Result< - zakura_pir_receiver::Swept, + zakura_pir_receiver::Swept, Error, > { let network = *st.network(); sweep( st.wallet_mut().db_mut(), &network, - accounts, + account, through, GENESIS, ORIGIN, @@ -1725,14 +1654,14 @@ async fn run( /// many keys they finished. async fn settle( st: &mut State, - accounts: &[AccountUuid], + account: AccountUuid, through: ChainPoint, directory: &Directory, notes: &mut Notes, ) -> usize { let mut finished = 0; for _ in 0..20 { - let swept = run(st, accounts, through, directory, notes, NOW) + let swept = run(st, account, through, directory, notes, NOW) .await .unwrap(); assert!(swept.deferred.is_empty(), "{:?}", swept.deferred); @@ -1801,13 +1730,13 @@ async fn a_deferred_candidate_takes_each_publications_current_answer() { requested: Vec::new(), }; let a = Directory::new(publish(&[first], &commitments, through, &provider(&[]))); - let swept = run(&mut st, &[account], through, &a, &mut notes, NOW) + let swept = run(&mut st, account, through, &a, &mut notes, NOW) .await .unwrap(); assert!( matches!( &swept.deferred[..], - [(_, k, Error::Queued(d))] if *k == key && *d == deferral + [(k, Error::Queued(d))] if *k == key && *d == deferral ), "{deferral:?}: {:?}", swept.deferred @@ -1829,7 +1758,7 @@ async fn a_deferred_candidate_takes_each_publications_current_answer() { publish(&[], &commitments, through, &provider(&[])) }; let b = Directory::new(b); - let swept = run(&mut st, &[account], through, &b, &mut notes, NOW + 60) + let swept = run(&mut st, account, through, &b, &mut notes, NOW + 60) .await .unwrap(); assert!( @@ -1837,7 +1766,7 @@ async fn a_deferred_candidate_takes_each_publications_current_answer() { "{deferral:?}: {:?}", swept.deferred ); - settle(&mut st, &[account], through, &b, &mut notes).await; + settle(&mut st, account, through, &b, &mut notes).await; let value = if corrected { VALUE } else { 0 }; assert_eq!( st.get_total_balance(account), @@ -1870,36 +1799,25 @@ async fn an_unchanged_answer_reuses_queued_note_data() { through, &provider(&[]), )); - let swept = run(&mut st, &[account], through, &directory, &mut notes, NOW) + let swept = run(&mut st, account, through, &directory, &mut notes, NOW) .await .unwrap(); assert!( matches!( &swept.deferred[..], - [( - _, - _, - Error::Queued(PaymentApplication::AwaitingSpendHistory) - )] + [(_, Error::Queued(PaymentApplication::AwaitingSpendHistory))] ), "{:?}", swept.deferred ); // The replay the deferral queued is still unscanned. - let swept = run( - &mut st, - &[account], - through, - &directory, - &mut notes, - NOW + 60, - ) - .await - .unwrap(); + let swept = run(&mut st, account, through, &directory, &mut notes, NOW + 60) + .await + .unwrap(); assert!( matches!( &swept.deferred[..], - [(_, _, Error::Queued(PaymentApplication::AwaitingScan))] + [(_, Error::Queued(PaymentApplication::AwaitingScan))] ), "{:?}", swept.deferred @@ -1948,7 +1866,7 @@ async fn each_run_sweeps_one_batch_and_later_runs_progress() { }; let mut runs = 0; loop { - let swept = run(&mut st, &[account], through, &directory, &mut notes, NOW) + let swept = run(&mut st, account, through, &directory, &mut notes, NOW) .await .unwrap(); runs += 1; @@ -1969,60 +1887,3 @@ async fn each_run_sweeps_one_batch_and_later_runs_progress() { Zatoshis::const_from_u64(VALUE) ); } - -/// Accounts share key identities, so each deferred key names its account. -#[tokio::test] -async fn each_deferred_key_names_its_account() { - let mut st = ironwood_wallet(); - let first = st.test_account().unwrap().id(); - let (second, usk) = st.create_account_from_test_seed("second"); - let key = KeyId::new(Purpose::Receive, 0); - let (paid_first, first_data) = pay_restored_key(&mut st, key.index(), 0); - let second_fvk = FullViewingKey::from(usk.orchard()); - let (paid_second, second_data) = pay_restored_key_of(&mut st, &second_fvk, 0, 1); - st.generate_and_scan_empty_blocks_with_dynamic_ivks(1); - restore(&mut st, first); - restore(&mut st, second); - let through = tip(&st); - // The first payment waits for spend history, and the replay that queues makes the - // second wait for scanning. - st.wallet() - .conn() - .execute_batch("DELETE FROM nullifier_map; DELETE FROM ironwood_nullifier_scan_blocks;") - .unwrap(); - let directory = Directory::new(publish( - &[paid_first.clone(), paid_second.clone()], - &[paid_first.payment.cmx, paid_second.payment.cmx], - through, - &provider(&[]), - )); - let mut notes = Notes { - data: BTreeMap::from([(0, first_data), (1, second_data)]), - requested: Vec::new(), - }; - let swept = run( - &mut st, - &[first, second], - through, - &directory, - &mut notes, - NOW, - ) - .await - .unwrap(); - let deferred: Vec<_> = swept - .deferred - .iter() - .map(|(account, key, e)| match e { - Error::Queued(application) => (*account, *key, *application), - other => panic!("{other:?}"), - }) - .collect(); - assert_eq!( - deferred, - [ - (first, key, PaymentApplication::AwaitingSpendHistory), - (second, key, PaymentApplication::AwaitingScan), - ] - ); -} From 3ee74204b949d23f0cdf1322c494ab80742d3425 Mon Sep 17 00:00:00 2001 From: Adam Tucker Date: Sun, 11 Oct 2026 12:07:07 -0600 Subject: [PATCH 32/40] Inline the paid-filter recheck With one account per run the recheck was a single-use helper that returned whether a hit queued a sweep, so the run could fold it into `pending`. Inline it and read `dynamic_history_pending` after the recheck instead: a hit requeues the key's sweep, which that read already reports. The run still makes no request unless the store lists a closed key with no known payment, fetches only the manifest when the store says this publication already checked every such key, and fetches the filters only for keys it has not checked. SQLite now lists closed keys that were never swept too, which this code needs no change for. Co-Authored-By: Claude Opus 5.5 --- zakura/pir-receiver/src/sweep.rs | 77 +++++++++++++------------------- 1 file changed, 31 insertions(+), 46 deletions(-) diff --git a/zakura/pir-receiver/src/sweep.rs b/zakura/pir-receiver/src/sweep.rs index 66035f55..511a58f6 100644 --- a/zakura/pir-receiver/src/sweep.rs +++ b/zakura/pir-receiver/src/sweep.rs @@ -237,11 +237,8 @@ where N: NoteSource, L: WriteLock, { - let mut swept = Swept { - finished: 0, - deferred: Vec::new(), - pending: false, - }; + let mut finished = 0; + let mut deferred = Vec::new(); let current = at_tip(wallet, through).map_err(Error::Wallet)?; let mut transport = Some(transport); let mut directory = None; @@ -286,7 +283,7 @@ where wallet, directory, account, &item, check, lookups, through, notes, lock, ); match swept_key.await { - Ok(()) => swept.finished += 1, + Ok(()) => finished += 1, Err(e) => { // Every later key would fail the same way and back off. A session // that failed to open took the transport without replacing it. @@ -298,7 +295,7 @@ where DirectoryError::Revision | DirectoryError::Transport(_) ) | Error::NotesUnavailable(_) ); - swept.deferred.push((item.key, e)); + deferred.push((item.key, e)); if unavailable { break; } @@ -322,54 +319,42 @@ where }) .map_err(Error::Wallet)?; } - swept.pending = wallet - .dynamic_history_pending(account, through.height) - .map_err(Error::Wallet)?; if let (true, Some(transport)) = (current, transport) { - let keys = wallet.dynamic_keys_to_recheck(account, through.height + 1); - if !keys.map_err(Error::Wallet)?.is_empty() { + // Closed keys with no known payment need each newer publication's paid filter. + let closed = wallet.dynamic_keys_to_recheck(account, through.height + 1); + if !closed.map_err(Error::Wallet)?.is_empty() { let accepted = Directory::accept(wallet, params, through, genesis, origin, transport); directory = Some(accepted.await?); } } if let Some(directory) = &mut directory { - swept.pending |= recheck(wallet, directory, account, lock, now).await?; + // Only keys this publication has not checked, so the filters are fetched only for + // a newer one. + let keys = wallet + .dynamic_keys_to_recheck(account, directory.anchor.height) + .map_err(Error::Wallet)?; + if !keys.is_empty() { + let checks = directory.check(&keys, now).await?; + let checked: Vec<_> = keys + .iter() + .zip(checks) + .map(|(item, check)| (item.key, check.paid)) + .collect(); + lock.write("dynamic_sweep.recheck", || { + wallet.record_paid_filter_check(account, directory.anchor, &checked) + }) + .map_err(Error::Wallet)?; + } } - Ok(swept) -} - -/// Records the publication's paid filter for `account`'s keys whose sweep finished below -/// its anchor (see [`DynamicIvkWrite::record_paid_filter_check`]), fetching the filters -/// only if a key needs them. Returns whether any sweep was queued again. -async fn recheck( - wallet: &mut W, - directory: &mut Directory<'_, T>, - account: AccountId, - lock: &L, - now: i64, -) -> Result>> -where - W: DynamicIvkWrite, - T: Transport, - L: WriteLock, -{ - let keys = wallet - .dynamic_keys_to_recheck(account, directory.anchor.height) + // Read last, since a paid filter hit queues its key's sweep again. + let pending = wallet + .dynamic_history_pending(account, through.height) .map_err(Error::Wallet)?; - if keys.is_empty() { - return Ok(false); - } - let checks = directory.check(&keys, now).await?; - let checked: Vec<_> = keys - .iter() - .zip(checks) - .map(|(item, check)| (item.key, check.paid)) - .collect(); - lock.write("dynamic_sweep.recheck", || { - wallet.record_paid_filter_check(account, directory.anchor, &checked) + Ok(Swept { + finished, + deferred, + pending, }) - .map_err(Error::Wallet)?; - Ok(checked.iter().any(|&(_, paid)| paid)) } /// Whether `through` is still the wallet's stored chain tip and the block it scanned From af80885e7d9c2bb7da6025bca0c79199d2c487a4 Mon Sep 17 00:00:00 2001 From: Adam Tucker Date: Sun, 11 Oct 2026 12:16:30 -0600 Subject: [PATCH 33/40] Consolidate the receiver sweep tests into table tests Several single-scenario tests checked one rule each way. Fold them into table tests: imports with note-data batching (and whether the row file is fetched), which keys keep scanning, release at the fully scanned tip, the seen-set requirement for sweeps and `fetch_seen`, the run-fatal failures, a publication off the wallet's chain or short of its anchor block, and the Enhance note source's session, routing and chain checks. A shared `run` helper replaces the ten-argument `sweep` calls, and the transports and note sources only the folded tests used go. Every rule keeps a case. The first test's rerun that fetched only the manifest goes, since the paid-filter test covers it, as does the issuance check in the scanning test, which the seen-hit test covers. Co-Authored-By: Claude Opus 5.5 --- zakura/pir-receiver/tests/sweep.rs | 1325 ++++++++++------------------ 1 file changed, 459 insertions(+), 866 deletions(-) diff --git a/zakura/pir-receiver/tests/sweep.rs b/zakura/pir-receiver/tests/sweep.rs index 2eae1327..f8369f4f 100644 --- a/zakura/pir-receiver/tests/sweep.rs +++ b/zakura/pir-receiver/tests/sweep.rs @@ -35,7 +35,7 @@ use zakura_pir_enhance::{ types, }; use zakura_pir_receiver::{ - DirectoryError, EnhanceNotes, Error, NoteSource, Transport, WriteLock, fetch_seen, sweep, + DirectoryError, EnhanceNotes, Error, NoteSource, Swept, Transport, WriteLock, fetch_seen, sweep, }; use zcash_client_backend::data_api::{ Account as _, WalletRead as _, WalletWrite as _, @@ -46,11 +46,12 @@ use zcash_client_backend::data_api::{ }; use zcash_client_sqlite::{ AccountUuid, WalletDb, + error::SqliteClientError, testing::{ BlockCache, db::{TestDb, TestDbFactory}, }, - util::SystemClock, + util::{SystemClock, testing::FixedClock}, wallet::dynamic_ivk::{KeyId, ProviderSeen, Purpose, RECEIVE_GAP_LIMIT}, }; use zcash_note_encryption::{Domain as _, try_compact_note_decryption}; @@ -65,6 +66,8 @@ use zcash_protocol::{ mod test_support; type State = TestState; +/// The wallet a test sweeps. +type Wallet = WalletDb; /// The genesis hash the fixture's publications commit to. const GENESIS: [u8; 32] = [9; 32]; @@ -108,6 +111,13 @@ fn tip(st: &State) -> ChainPoint { } } +/// The receiver of the test account's incoming dynamic key `index`. +fn receiver(st: &State, index: u64) -> Receiver { + let parent = FullViewingKey::from(st.test_account().unwrap().usk().orchard()); + let fvk = KeyId::new(Purpose::Receive, index).derive(&parent).unwrap(); + Receiver::from_bytes(fvk.address_at(0u32, Scope::External).to_raw_address_bytes()).unwrap() +} + /// Pays incoming dynamic key `index` in a new scanned block, before any restore has /// registered the key, so scanning cannot find it. Returns the payment as the /// directory lists it and its note data. The output is the chain's Ironwood leaf at @@ -142,9 +152,8 @@ fn pay_restored_key(st: &mut State, index: u64, position: u64) -> (Record, [u8; let encryption = IronwoodNoteEncryption::new(None, note, [4; 512]); let ciphertext = encryption.encrypt_note_plaintext(); assert_eq!(&ciphertext[..52], compact.ciphertext.as_slice()); - let receiver = fvk.address_at(0u32, Scope::External).to_raw_address_bytes(); let record = Record { - receiver: Receiver::from_bytes(receiver).unwrap(), + receiver: receiver(st, index), page: 0, total: 1, payment: Payment { @@ -309,6 +318,16 @@ struct Notes { requested: Vec>, } +impl Notes { + /// Serves `data`, keyed by position. + fn new(data: BTreeMap) -> Self { + Self { + data, + requested: Vec::new(), + } + } +} + impl NoteSource for Notes { async fn note_data( &mut self, @@ -323,6 +342,19 @@ impl NoteSource for Notes { } } +/// A note source that cannot serve the wallet's chain. +struct NoNotes; + +impl NoteSource for NoNotes { + async fn note_data( + &mut self, + _: &W, + _: Vec, + ) -> Result, Error> { + Err(Error::NotesUnavailable("no note data")) + } +} + /// A test needs no lock: nothing else writes to its wallet. struct NoLock; @@ -332,6 +364,17 @@ impl WriteLock for NoLock { } } +/// Records each write's label. +#[derive(Default)] +struct Labels(RefCell>); + +impl WriteLock for Labels { + fn write(&self, label: &'static str, write: impl FnOnce() -> T) -> T { + self.0.borrow_mut().push(label); + write() + } +} + /// Registers the restored incoming lookahead, as a restore does at the tip. fn restore(st: &mut State, account: AccountUuid) { st.wallet_mut() @@ -340,10 +383,57 @@ fn restore(st: &mut State, account: AccountUuid) { .unwrap(); } +/// Runs one sweep of the test account through `through` at `now`. +async fn run>( + st: &mut State, + through: ChainPoint, + directory: impl Transport, + notes: &mut N, + lock: &impl WriteLock, + now: i64, +) -> Result, Error> { + let network = *st.network(); + let account = st.test_account().unwrap().id(); + sweep( + st.wallet_mut().db_mut(), + &network, + account, + through, + GENESIS, + ORIGIN, + directory, + notes, + lock, + now, + ) + .await +} + +/// Runs sweeps at [`NOW`] until none is pending, expecting no deferral, and returns how +/// many keys they finished. +async fn settle( + st: &mut State, + through: ChainPoint, + directory: &Directory, + notes: &mut Notes, +) -> usize { + let mut finished = 0; + for _ in 0..20 { + let swept = run(st, through, directory, notes, &NoLock, NOW) + .await + .unwrap(); + assert!(swept.deferred.is_empty(), "{:?}", swept.deferred); + finished += swept.finished; + if !swept.pending { + return finished; + } + } + panic!("sweeps are still pending after 20 runs"); +} + #[tokio::test] async fn a_restore_sweep_finds_a_payout_and_moves_the_lookahead_past_it() { let mut st = ironwood_wallet(); - let network = *st.network(); let account = st.test_account().unwrap().id(); let paid = 3; let (record, note_data) = pay_restored_key(&mut st, paid, 0); @@ -357,11 +447,8 @@ async fn a_restore_sweep_finds_a_payout_and_moves_the_lookahead_past_it() { through, &provider(&[]), )); - let mut notes = Notes { - data: BTreeMap::from([(record.payment.position, note_data)]), - requested: Vec::new(), - }; - let finished = settle(&mut st, account, through, &directory, &mut notes).await; + let mut notes = Notes::new(BTreeMap::from([(record.payment.position, note_data)])); + let finished = settle(&mut st, through, &directory, &mut notes).await; // The first window, then, in a later run, the keys above it that the paid index // brings in. assert_eq!(finished as u64, RECEIVE_GAP_LIMIT + paid + 1); @@ -379,101 +466,41 @@ async fn a_restore_sweep_finds_a_payout_and_moves_the_lookahead_past_it() { "{:?}", directory.requests() ); - - // A later run finds every sweep finished and the publication already checked, so - // it fetches only the manifest. - let mut before = directory.requests(); - before.push("GET /v1/receiver/init".into()); - let swept = sweep( - st.wallet_mut().db_mut(), - &network, - account, - through, - GENESIS, - ORIGIN, - &directory, - &mut notes, - &NoLock, - NOW, - ) - .await - .unwrap(); - assert_eq!(swept.finished, 0); - assert!(!swept.pending); - assert_eq!(directory.requests(), before); -} - -#[tokio::test] -async fn a_key_paid_twice_is_imported_in_full() { - let mut st = ironwood_wallet(); - let account = st.test_account().unwrap().id(); - let paid = 2; - let (mut first, first_data) = pay_restored_key(&mut st, paid, 0); - let (mut second, second_data) = pay_restored_key(&mut st, paid, 1); - first.total = 2; - second.page = 1; - second.total = 2; - st.generate_and_scan_empty_blocks_with_dynamic_ivks(1); - restore(&mut st, account); - let through = tip(&st); - let directory = Directory::new(publish( - &[first.clone(), second.clone()], - &[first.payment.cmx, second.payment.cmx], - through, - &provider(&[]), - )); - let mut notes = Notes { - data: BTreeMap::from([(0, first_data), (1, second_data)]), - requested: Vec::new(), - }; - settle(&mut st, account, through, &directory, &mut notes).await; - assert_eq!(notes.requested, [vec![0, 1]]); - assert_eq!( - st.get_total_balance(account), - Zatoshis::const_from_u64(2 * VALUE) - ); - // Two pages cost less over PIR than the row file. - let requests = directory.requests(); - assert!( - !requests - .iter() - .any(|r| r.starts_with("GET /v1/receiver/rows/")), - "{requests:?}" - ); } +/// Every page of a key's lookup is imported, with note data in batches of 64. A few +/// pages cost less over PIR than the row file, and many more. #[tokio::test] -async fn a_long_history_takes_its_note_data_in_batches() { - let mut st = ironwood_wallet(); - let account = st.test_account().unwrap().id(); - let paid = 1; - // One more payment than a batch of note data holds. - let count = 65; - let mut records = Vec::new(); - let mut data = BTreeMap::new(); - for position in 0..count { - let (mut record, note_data) = pay_restored_key(&mut st, paid, position); - record.page = position.try_into().unwrap(); - record.total = count.try_into().unwrap(); - records.push(record); - data.insert(position, note_data); +async fn every_payment_to_a_key_is_imported_in_note_data_batches() { + for (count, batches, row_file) in [(2, vec![2], false), (65, vec![64, 1], true)] { + let mut st = ironwood_wallet(); + let account = st.test_account().unwrap().id(); + let mut records = Vec::new(); + let mut data = BTreeMap::new(); + for position in 0..count { + let (mut record, note_data) = pay_restored_key(&mut st, 1, position); + record.page = position.try_into().unwrap(); + record.total = count.try_into().unwrap(); + records.push(record); + data.insert(position, note_data); + } + st.generate_and_scan_empty_blocks_with_dynamic_ivks(1); + restore(&mut st, account); + let through = tip(&st); + let commitments: Vec<_> = records.iter().map(|record| record.payment.cmx).collect(); + let directory = Directory::new(publish(&records, &commitments, through, &provider(&[]))); + let mut notes = Notes::new(data); + settle(&mut st, through, &directory, &mut notes).await; + let sizes: Vec<_> = notes.requested.iter().map(Vec::len).collect(); + assert_eq!(sizes, batches); + assert_eq!( + st.get_total_balance(account), + Zatoshis::const_from_u64(count * VALUE) + ); + let requests = directory.requests(); + let rows = |r: &String| r.starts_with("GET /v1/receiver/rows/"); + assert_eq!(requests.iter().any(rows), row_file, "{requests:?}"); } - st.generate_and_scan_empty_blocks_with_dynamic_ivks(1); - restore(&mut st, account); - let through = tip(&st); - let commitments: Vec<_> = records.iter().map(|record| record.payment.cmx).collect(); - let directory = Directory::new(publish(&records, &commitments, through, &provider(&[]))); - let mut notes = Notes { - data, - requested: Vec::new(), - }; - settle(&mut st, account, through, &directory, &mut notes).await; - let batches: Vec<_> = notes.requested.iter().map(Vec::len).collect(); - assert_eq!(batches, [64, 1]); - assert_eq!( - st.get_total_balance(account), - Zatoshis::const_from_u64(count * VALUE) - ); } /// Note data keyed by the position after each requested one, counting the requests. @@ -497,7 +524,6 @@ impl NoteSource for Mislabeled { #[tokio::test] async fn note_data_for_other_positions_is_missing_data() { let mut st = ironwood_wallet(); - let network = *st.network(); let account = st.test_account().unwrap().id(); let paid = 1; let (record, note_data) = pay_restored_key(&mut st, paid, 0); @@ -514,20 +540,9 @@ async fn note_data_for_other_positions_is_missing_data() { data: note_data, calls: 0, }; - let swept = sweep( - st.wallet_mut().db_mut(), - &network, - account, - through, - GENESIS, - ORIGIN, - &directory, - &mut notes, - &NoLock, - NOW, - ) - .await - .unwrap(); + let swept = run(&mut st, through, &directory, &mut notes, &NoLock, NOW) + .await + .unwrap(); assert_eq!(notes.calls, 1); assert!( matches!( @@ -541,42 +556,17 @@ async fn note_data_for_other_positions_is_missing_data() { assert_eq!(st.get_total_balance(account), Zatoshis::ZERO); } -/// A note source that fails every call. -struct NoNotes; - -impl NoteSource for NoNotes { - async fn note_data( - &mut self, - _: &W, - _: Vec, - ) -> Result, Error> { - Err(Error::Unavailable("no note data")) - } -} - #[tokio::test] async fn a_wallet_without_payments_downloads_only_the_filters() { let mut st = ironwood_wallet(); - let network = *st.network(); let account = st.test_account().unwrap().id(); st.generate_and_scan_empty_blocks_with_dynamic_ivks(1); restore(&mut st, account); let through = tip(&st); let directory = Directory::new(publish(&[], &[], through, &provider(&[]))); - let swept = sweep( - st.wallet_mut().db_mut(), - &network, - account, - through, - GENESIS, - ORIGIN, - &directory, - &mut NoNotes, - &NoLock, - NOW, - ) - .await - .unwrap(); + let swept = run(&mut st, through, &directory, &mut NoNotes, &NoLock, NOW) + .await + .unwrap(); assert!(swept.deferred.is_empty(), "{:?}", swept.deferred); assert_eq!(swept.finished as u64, RECEIVE_GAP_LIMIT); assert!(!swept.pending); @@ -584,8 +574,6 @@ async fn a_wallet_without_payments_downloads_only_the_filters() { let requests = directory.requests(); assert_eq!(requests.len(), 2, "{requests:?}"); assert!(requests[1].starts_with("GET /v1/receiver/filters/")); - // No swap reached these addresses recently, so none keeps scanning. - assert!(st.wallet().get_dynamic_scanning_keys().unwrap().is_empty()); } /// A key closed after its sweep is checked against each newer publication's paid filter, @@ -600,18 +588,15 @@ async fn a_newer_paid_filter_finds_a_payment_to_a_closed_key() { restore(&mut st, account); let first = tip(&st); let directory = Directory::new(publish(&[], &[], first, &provider(&[]))); - let mut notes = Notes { - data: BTreeMap::new(), - requested: Vec::new(), - }; - settle(&mut st, account, first, &directory, &mut notes).await; + let mut notes = Notes::new(BTreeMap::new()); + settle(&mut st, first, &directory, &mut notes).await; assert!(st.wallet().get_dynamic_scanning_keys().unwrap().is_empty()); // The closed key misses a payment, which the same publication cannot show. let (record, note_data) = pay_restored_key(&mut st, 0, 0); let through = tip(&st); let before = directory.requests().len(); - let swept = run(&mut st, account, through, &directory, &mut notes, NOW).await; + let swept = run(&mut st, through, &directory, &mut notes, &NoLock, NOW).await; assert!(!swept.unwrap().pending); assert_eq!(directory.requests()[before..], ["GET /v1/receiver/init"]); assert_eq!(st.get_total_balance(account), Zatoshis::ZERO); @@ -624,10 +609,7 @@ async fn a_newer_paid_filter_finds_a_payment_to_a_closed_key() { )); notes.data.insert(record.payment.position, note_data); // The paid key, then the lookahead key its index brings in. - assert_eq!( - settle(&mut st, account, through, &directory, &mut notes).await, - 2 - ); + assert_eq!(settle(&mut st, through, &directory, &mut notes).await, 2); assert_eq!( st.get_total_balance(account), Zatoshis::const_from_u64(VALUE) @@ -642,73 +624,51 @@ async fn a_newer_paid_filter_finds_a_payment_to_a_closed_key() { later, &provider(&[]), )); - let swept = run(&mut st, account, later, &directory, &mut notes, NOW).await; + let swept = run(&mut st, later, &directory, &mut notes, &NoLock, NOW).await; let swept = swept.unwrap(); assert_eq!((swept.finished, swept.pending), (0, false)); assert!(directory.requests().iter().all(|r| !r.starts_with("POST"))); } +/// After its sweep, a key keeps scanning only if a provider's recent set holds its +/// receiver, and every key does unless each provider has a recent set. #[tokio::test] async fn only_recently_quoted_addresses_keep_scanning_after_their_sweep() { - let mut st = ironwood_wallet(); - let account = st.test_account().unwrap().id(); - st.generate_and_scan_empty_blocks_with_dynamic_ivks(1); - restore(&mut st, account); - let through = tip(&st); - let parent = FullViewingKey::from(st.test_account().unwrap().usk().orchard()); - let receiver = |key: KeyId| { - let address = key - .derive(&parent) + for (case, expected) in [ + ("quoted", vec![2]), + ("both recent", vec![]), + ("one recent", (0..RECEIVE_GAP_LIMIT).collect()), + ] { + let mut st = ironwood_wallet(); + let account = st.test_account().unwrap().id(); + st.generate_and_scan_empty_blocks_with_dynamic_ivks(1); + restore(&mut st, account); + let through = tip(&st); + let sets = if case == "quoted" { + // The old device quoted addresses 0 and 2, and 2 only today. + let mut sets = provider(&[receiver(&st, 0), receiver(&st, 2)]); + sets[0].receivers = vec![receiver(&st, 2)]; + sets + } else { + // Provider `b` publishes only its seen set in the last case. + let mut sets = provider_named("a", &[]); + let b = provider_named("b", &[]).into_iter(); + sets.extend(b.skip(usize::from(case == "one recent"))); + sets + }; + let directory = Directory::new(publish(&[], &[], through, &sets)); + let mut notes = Notes::new(BTreeMap::new()); + settle(&mut st, through, &directory, &mut notes).await; + let mut scanning: Vec<_> = st + .wallet() + .get_dynamic_scanning_keys() .unwrap() - .address_at(0u32, Scope::External); - Receiver::from_bytes(address.to_raw_address_bytes()).unwrap() - }; - // The old device quoted addresses 0 and 2, and 2 only today. - let (old, quoted) = ( - KeyId::new(Purpose::Receive, 0), - KeyId::new(Purpose::Receive, 2), - ); - let mut sets = provider(&[receiver(old), receiver(quoted)]); - sets[0].receivers = vec![receiver(quoted)]; - let directory = Directory::new(publish(&[], &[], through, &sets)); - let mut notes = Notes { - data: BTreeMap::new(), - requested: Vec::new(), - }; - settle(&mut st, account, through, &directory, &mut notes).await; - let scanning: Vec<_> = st - .wallet() - .get_dynamic_scanning_keys() - .unwrap() - .iter() - .map(|k| k.key_id()) - .collect(); - assert_eq!(scanning, [quoted]); - // Issuance reads the same seen set, skips both, and takes the lowest address the - // provider never had. - let seen = fetch_seen(ORIGIN, &directory, GENESIS).await.unwrap(); - assert_eq!( - (seen.since(), seen.until()), - (NOW - 2 * RESTORE_WATCH_SECS, NOW - 60) - ); - let bytes = |key: KeyId| *receiver(key).as_bytes(); - assert_eq!( - seen.contains(&[bytes(old), bytes(KeyId::new(Purpose::Receive, 1))]), - [true, false] - ); - let contains = |receivers: &[[u8; 43]]| seen.contains(receivers); - let view = ProviderSeen { - since: seen.since(), - until: seen.until(), - contains: &contains, - }; - let issued = st - .wallet_mut() - .db_mut() - .prepare_receive_reservation(account, NOW, through.height, Some(&view)) - .unwrap() - .unwrap(); - assert_eq!(issued.key_id(), KeyId::new(Purpose::Receive, 1)); + .iter() + .map(|k| k.key_id().index()) + .collect(); + scanning.sort(); + assert_eq!(scanning, expected, "{case}"); + } } /// A seen hit counts from the attempt that observes it. When publication A's seen set @@ -717,7 +677,6 @@ async fn only_recently_quoted_addresses_keep_scanning_after_their_sweep() { #[tokio::test] async fn a_seen_hit_outlives_a_rejected_payment_and_a_later_miss() { let mut st = ironwood_wallet(); - let network = *st.network(); let account = st.test_account().unwrap().id(); let key = KeyId::new(Purpose::Receive, 0); let (record, note_data) = pay_restored_key(&mut st, key.index(), 0); @@ -738,10 +697,7 @@ async fn a_seen_hit_outlives_a_rejected_payment_and_a_later_miss() { ) .unwrap() }; - let mut notes = Notes { - data: BTreeMap::from([(record.payment.position, note_data)]), - requested: Vec::new(), - }; + let mut notes = Notes::new(BTreeMap::from([(record.payment.position, note_data)])); // Publication A's witnesses are over another tree, so its payment is rejected. let a = Directory::new(publish( std::slice::from_ref(&record), @@ -749,20 +705,9 @@ async fn a_seen_hit_outlives_a_rejected_payment_and_a_later_miss() { through, &provider(&[record.receiver]), )); - let swept = sweep( - st.wallet_mut().db_mut(), - &network, - account, - through, - GENESIS, - ORIGIN, - &a, - &mut notes, - &NoLock, - NOW, - ) - .await - .unwrap(); + let swept = run(&mut st, through, &a, &mut notes, &NoLock, NOW) + .await + .unwrap(); assert!( matches!( &swept.deferred[..], @@ -775,20 +720,9 @@ async fn a_seen_hit_outlives_a_rejected_payment_and_a_later_miss() { let b = Directory::new(publish(&[], &commitments, through, &provider(&[]))); let later = NOW + 3_600; - let swept = sweep( - st.wallet_mut().db_mut(), - &network, - account, - through, - GENESIS, - ORIGIN, - &b, - &mut notes, - &NoLock, - later, - ) - .await - .unwrap(); + let swept = run(&mut st, through, &b, &mut notes, &NoLock, later) + .await + .unwrap(); assert!(swept.deferred.is_empty(), "{:?}", swept.deferred); assert!(!swept.pending); assert!(seen(&st)); @@ -832,7 +766,6 @@ impl WriteLock for FailFirstAttempt { #[tokio::test] async fn a_failed_attempt_write_stops_the_run() { let mut st = ironwood_wallet(); - let network = *st.network(); let account = st.test_account().unwrap().id(); let (record, note_data) = pay_restored_key(&mut st, 0, 0); st.generate_and_scan_empty_blocks_with_dynamic_ivks(1); @@ -844,10 +777,7 @@ async fn a_failed_attempt_write_stops_the_run() { through, &provider(&[]), )); - let mut notes = Notes { - data: BTreeMap::from([(record.payment.position, note_data)]), - requested: Vec::new(), - }; + let mut notes = Notes::new(BTreeMap::from([(record.payment.position, note_data)])); let lock = FailFirstAttempt { fail: Arc::new(AtomicBool::new(false)), labels: RefCell::new(Vec::new()), @@ -857,19 +787,7 @@ async fn a_failed_attempt_write_stops_the_run() { st.wallet() .conn() .commit_hook(Some(move || fail.swap(false, Ordering::SeqCst))); - let result = sweep( - st.wallet_mut().db_mut(), - &network, - account, - through, - GENESIS, - ORIGIN, - &directory, - &mut notes, - &lock, - NOW, - ) - .await; + let result = run(&mut st, through, &directory, &mut notes, &lock, NOW).await; assert!(matches!(result, Err(Error::Wallet(_))), "{result:?}"); assert_eq!( *lock.labels.borrow(), @@ -883,48 +801,21 @@ async fn a_failed_attempt_write_stops_the_run() { assert!(notes.requested.is_empty()); } -/// Records each write's label. -#[derive(Default)] -struct Labels(RefCell>); - -impl WriteLock for Labels { - fn write(&self, label: &'static str, write: impl FnOnce() -> T) -> T { - self.0.borrow_mut().push(label); - write() - } -} - /// A `through` the wallet has scanned past is not a tip to release evidence at, so a /// run with it writes nothing. #[tokio::test] async fn a_stale_tip_releases_nothing() { let mut st = ironwood_wallet(); - let network = *st.network(); let account = st.test_account().unwrap().id(); st.generate_and_scan_empty_blocks_with_dynamic_ivks(1); restore(&mut st, account); let through = tip(&st); st.generate_and_scan_empty_blocks_with_dynamic_ivks(1); let directory = Directory::new(publish(&[], &[], through, &provider(&[]))); - let mut notes = Notes { - data: BTreeMap::new(), - requested: Vec::new(), - }; let lock = Labels::default(); - sweep( - st.wallet_mut().db_mut(), - &network, - account, - through, - GENESIS, - ORIGIN, - &directory, - &mut notes, - &lock, - NOW, - ) - .await - .unwrap(); + run(&mut st, through, &directory, &mut NoNotes, &lock, NOW) + .await + .unwrap(); assert!(lock.0.borrow().is_empty(), "{:?}", lock.0.borrow()); assert!(directory.requests().is_empty()); } @@ -932,6 +823,9 @@ async fn a_stale_tip_releases_nothing() { /// Another handle on the wallet's file, as another writer holds. type OtherWriter = WalletDb; +/// A write through another handle on the wallet's file at `through`. +type OtherWrite = fn(&mut OtherWriter, ChainPoint); + /// Runs `other` just before the first release, as another writer may while the sweep /// holds no lock, then sets `releasing` while release holds the lock. struct WriterBeforeRelease { @@ -954,101 +848,93 @@ impl WriteLock for WriterBeforeRelease { } } -/// Sweeps a restored wallet against a publication ending `behind` blocks below its tip, -/// with release following `other`'s write through a second handle, and returns whether -/// release ran, judged by its read of the retention table. -async fn release_after(behind: u32, other: impl FnOnce(&mut OtherWriter, ChainPoint)) -> bool { - let mut st = ironwood_wallet_in(TestDbFactory::file_backed()); - let network = *st.network(); - let account = st.test_account().unwrap().id(); - st.generate_and_scan_empty_blocks_with_dynamic_ivks(2); - restore(&mut st, account); - let through = tip(&st); - let path = st.wallet().conn().path().unwrap().to_owned(); - let mut handle = - OtherWriter::for_path(path, network, SystemClock, TestRng::seed_from_u64(0)).unwrap(); - let lock = WriterBeforeRelease { - other: RefCell::new(Some(|| other(&mut handle, through))), - releasing: Arc::new(AtomicBool::new(false)), - }; - let released = Arc::new(AtomicBool::new(false)); - let (releasing, read) = (lock.releasing.clone(), released.clone()); - st.wallet() - .conn() - .authorizer(Some(move |ctx: AuthContext<'_>| { - if releasing.load(Ordering::SeqCst) - && matches!( - ctx.action, - AuthAction::Read { - table_name: "ironwood_dynamic_spend_retention", - .. - } - ) - { - read.store(true, Ordering::SeqCst); - } - Authorization::Allow - })); - let height = through.height - behind; - let end = ChainPoint { - height, - hash: st.wallet().get_block_hash(height).unwrap().unwrap(), - }; - // Without a recent set, every key keeps scanning from the block after `end`. - let directory = Directory::new(publish(&[], &[], end, &provider(&[])[1..])); - let swept = sweep( - st.wallet_mut().db_mut(), - &network, - account, - through, - GENESIS, - ORIGIN, - &directory, - &mut NoNotes, - &lock, - NOW, - ) - .await - .unwrap(); - assert_eq!(swept.finished as u64, RECEIVE_GAP_LIMIT); - st.wallet() - .conn() - .authorizer(None::) -> Authorization>); - released.load(Ordering::SeqCst) -} - /// Release checks the chain point again under its lock, since another writer may move -/// the tip while the sweep's lookups run without it. -#[tokio::test] -async fn a_tip_moved_before_release_releases_nothing() { - assert!( - release_after(0, |_, _| ()).await, - "release runs at an unmoved tip" - ); - let advanced = release_after(0, |other, through| { - other.update_chain_tip(through.height + 1).unwrap(); - }); - assert!(!advanced.await, "released after the tip advanced"); - let rewound = release_after(0, |other, through| { - other.truncate_to_height(through.height - 1).unwrap(); - }); - assert!(!rewound.await, "released after a reorg rewound the tip"); - // The tip is back at `through`'s height, but its block is not scanned yet. - let replaced = release_after(0, |other, through| { - other.truncate_to_height(through.height - 1).unwrap(); - other.update_chain_tip(through.height).unwrap(); - }); - assert!(!replaced.await, "released at a replaced block"); -} - -/// Finishing keys against a publication behind `through` rescans the blocks after it, -/// lowering the fully scanned height without moving the tip, so release waits for it. +/// the tip while the sweep's lookups run without it, and waits for the fully scanned +/// height to reach it. Each case sweeps against a publication `behind` blocks below the +/// tip, with `other` writing through a second handle just before release, and judges +/// whether release ran by its read of the retention table. #[tokio::test] -async fn a_rescan_the_sweep_queued_releases_nothing() { - assert!( - !release_after(1, |_, _| ()).await, - "released below the fully scanned tip" - ); +async fn release_runs_only_at_the_fully_scanned_tip() { + let cases: [(&str, u32, OtherWrite, bool); 5] = [ + ("an unmoved tip", 0, |_, _| (), true), + ( + "an advanced tip", + 0, + |other, through| { + other.update_chain_tip(through.height + 1).unwrap(); + }, + false, + ), + ( + "a reorg that rewound the tip", + 0, + |other, through| { + other.truncate_to_height(through.height - 1).unwrap(); + }, + false, + ), + // The tip is back at `through`'s height, but its block is not scanned yet. + ( + "a replaced block", + 0, + |other, through| { + other.truncate_to_height(through.height - 1).unwrap(); + other.update_chain_tip(through.height).unwrap(); + }, + false, + ), + // Finishing keys against a publication behind `through` rescans the blocks + // after it, lowering the fully scanned height without moving the tip. + ("a rescan the sweep queued", 1, |_, _| (), false), + ]; + for (case, behind, other, expected) in cases { + let mut st = ironwood_wallet_in(TestDbFactory::file_backed()); + let network = *st.network(); + let account = st.test_account().unwrap().id(); + st.generate_and_scan_empty_blocks_with_dynamic_ivks(2); + restore(&mut st, account); + let through = tip(&st); + let path = st.wallet().conn().path().unwrap().to_owned(); + let mut handle = + OtherWriter::for_path(path, network, SystemClock, TestRng::seed_from_u64(0)).unwrap(); + let lock = WriterBeforeRelease { + other: RefCell::new(Some(|| other(&mut handle, through))), + releasing: Arc::new(AtomicBool::new(false)), + }; + let released = Arc::new(AtomicBool::new(false)); + let (releasing, read) = (lock.releasing.clone(), released.clone()); + st.wallet() + .conn() + .authorizer(Some(move |ctx: AuthContext<'_>| { + if releasing.load(Ordering::SeqCst) + && matches!( + ctx.action, + AuthAction::Read { + table_name: "ironwood_dynamic_spend_retention", + .. + } + ) + { + read.store(true, Ordering::SeqCst); + } + Authorization::Allow + })); + let height = through.height - behind; + let end = ChainPoint { + height, + hash: st.wallet().get_block_hash(height).unwrap().unwrap(), + }; + // Without a recent set, every key keeps scanning from the block after `end`. + let directory = Directory::new(publish(&[], &[], end, &provider(&[])[1..])); + let swept = run(&mut st, through, &directory, &mut NoNotes, &lock, NOW) + .await + .unwrap(); + assert_eq!(swept.finished as u64, RECEIVE_GAP_LIMIT); + st.wallet() + .conn() + .authorizer(None::) -> Authorization>); + assert_eq!(released.load(Ordering::SeqCst), expected, "{case}"); + } } /// Another network's seen sets say nothing about this network's quotes, so a @@ -1072,210 +958,87 @@ async fn seen_sets_of_another_network_are_refused() { assert!(fetch_seen(ORIGIN, &directory, GENESIS).await.is_ok()); } -/// A provider with a recent set but no seen set may have had any receiver, so issuance -/// and sweeps both refuse the publication until every provider has a dated seen set. -/// The sets then cover the quote times they all cover, and hold what any of them holds. +/// A publication without seen sets, or with a provider that has a recent set but no +/// seen set, may omit any receiver, so issuance and sweeps both refuse it, before any +/// key finishes or spend evidence is released. Once every provider has a dated seen +/// set, the sets cover the quote times they all cover, and hold what any of them holds. #[tokio::test] async fn every_provider_with_sets_needs_a_seen_set() { let mut st = ironwood_wallet(); - let network = *st.network(); let account = st.test_account().unwrap().id(); st.generate_and_scan_empty_blocks_with_dynamic_ivks(1); restore(&mut st, account); let through = tip(&st); - let parent = FullViewingKey::from(st.test_account().unwrap().usk().orchard()); - let receiver = |index| { - let fvk = KeyId::new(Purpose::Receive, index).derive(&parent).unwrap(); - Receiver::from_bytes(fvk.address_at(0u32, Scope::External).to_raw_address_bytes()).unwrap() - }; // Provider `a` has its seen set only if `a_seen`. `b` has only a seen set, from a // later feed start and a later read. let sets = |a_seen: bool| { - let mut sets = provider_named("a", &[receiver(0)]); + let mut sets = provider_named("a", &[receiver(&st, 0)]); sets.truncate(1 + usize::from(a_seen)); - let mut b = provider_named("b", &[receiver(1)]).pop().unwrap(); + let mut b = provider_named("b", &[receiver(&st, 1)]).pop().unwrap(); b.since_unix = NOW - RESTORE_WATCH_SECS; b.until_unix = NOW - 30; sets.push(b); sets }; - let directory = Directory::new(publish(&[], &[], through, &sets(false))); - let refused = fetch_seen(ORIGIN, &directory, GENESIS).await; - assert!(matches!(refused, Err(DirectoryError::Malformed))); - let mut notes = Notes { - data: BTreeMap::new(), - requested: Vec::new(), - }; - let result = sweep( - st.wallet_mut().db_mut(), - &network, - account, - through, - GENESIS, - ORIGIN, - &directory, - &mut notes, - &NoLock, - NOW, - ) - .await; - assert!( - matches!(result, Err(Error::Directory(DirectoryError::Malformed))), - "{result:?}" - ); - // A publication without provider sets is refused too. - let directory = Directory::new(publish(&[], &[], through, &[])); - let refused = fetch_seen(ORIGIN, &directory, GENESIS).await; - assert!(matches!(refused, Err(DirectoryError::Malformed))); + let (missing, complete) = (sets(false), sets(true)); + // The first refusal backs every due key off for a minute. + for (sets, now) in [(missing, NOW), (Vec::new(), NOW + 60)] { + let directory = Directory::new(publish(&[], &[], through, &sets)); + let refused = fetch_seen(ORIGIN, &directory, GENESIS).await; + assert!(matches!(refused, Err(DirectoryError::Malformed))); + let lock = Labels::default(); + let result = run(&mut st, through, &directory, &mut NoNotes, &lock, now).await; + assert!( + matches!(result, Err(Error::Directory(DirectoryError::Malformed))), + "{result:?}" + ); + let labels = lock.0.borrow(); + assert!( + labels + .iter() + .all(|l| ["dynamic_sweep.due", "dynamic_sweep.attempt"].contains(l)), + "{labels:?}" + ); + assert!( + st.wallet() + .db() + .dynamic_history_pending(account, through.height) + .unwrap() + ); + } - let directory = Directory::new(publish(&[], &[], through, &sets(true))); + let directory = Directory::new(publish(&[], &[], through, &complete)); let seen = fetch_seen(ORIGIN, &directory, GENESIS).await.unwrap(); assert_eq!( (seen.since(), seen.until()), (NOW - RESTORE_WATCH_SECS, NOW - 60) ); - let held = [0, 1, 2].map(|index| *receiver(index).as_bytes()); + let held = [0, 1, 2].map(|index| *receiver(&st, index).as_bytes()); assert_eq!(seen.contains(&held), [true, true, false]); } -/// A publication without provider seen sets says nothing of which addresses were -/// quoted, so a sweep refuses it as `fetch_seen` does, before any key finishes or -/// spend evidence is released. -#[tokio::test] -async fn a_publication_without_seen_sets_finishes_no_sweep() { - let mut st = ironwood_wallet(); - let network = *st.network(); - let account = st.test_account().unwrap().id(); - st.generate_and_scan_empty_blocks_with_dynamic_ivks(1); - restore(&mut st, account); - let through = tip(&st); - let directory = Directory::new(publish(&[], &[], through, &[])); - let lock = Labels::default(); - let result = sweep( - st.wallet_mut().db_mut(), - &network, - account, - through, - GENESIS, - ORIGIN, - &directory, - &mut NoNotes, - &lock, - NOW, - ) - .await; - assert!( - matches!(result, Err(Error::Directory(DirectoryError::Malformed))), - "{result:?}" - ); - let labels = lock.0.borrow(); - assert!( - labels - .iter() - .all(|l| ["dynamic_sweep.due", "dynamic_sweep.attempt"].contains(l)), - "{labels:?}" - ); - assert!( - st.wallet() - .db() - .dynamic_history_pending(account, through.height) - .unwrap() - ); +/// Which part of a directory fails. +#[derive(Clone, Copy, Debug)] +enum Fault { + None, + /// Queries fail after the session was accepted. + Queries, + /// The witness snapshot is malformed, so no session opens. + Witnesses, } -#[tokio::test] -async fn every_address_keeps_scanning_unless_each_provider_has_a_recent_set() { - for both_recent in [true, false] { - let mut st = ironwood_wallet(); - let account = st.test_account().unwrap().id(); - st.generate_and_scan_empty_blocks_with_dynamic_ivks(1); - restore(&mut st, account); - let through = tip(&st); - // Provider `b` publishes only its seen set when `both_recent` is false. - let mut sets = provider_named("a", &[]); - let b = provider_named("b", &[]); - sets.extend(b.into_iter().skip(usize::from(!both_recent))); - let directory = Directory::new(publish(&[], &[], through, &sets)); - let mut notes = Notes { - data: BTreeMap::new(), - requested: Vec::new(), - }; - settle(&mut st, account, through, &directory, &mut notes).await; - let scanning = st.wallet().get_dynamic_scanning_keys().unwrap().len() as u64; - assert_eq!(scanning, if both_recent { 0 } else { RECEIVE_GAP_LIMIT }); - } +/// A directory with `fault`. +struct Faulty { + directory: Directory, + fault: Fault, } -/// A directory whose queries fail after it accepted the session. -struct FailingQueries(Directory); - -impl Transport for FailingQueries { +impl Transport for Faulty { async fn get(&self, url: &str, limit: usize) -> Result, DirectoryError> { - self.0.get(url, limit).await - } - - async fn post(&self, url: &str, _: Vec, _: usize) -> Result, DirectoryError> { - self.0.requests.lock().unwrap().push(format!("POST {url}")); - Err(DirectoryError::Revision) - } -} - -#[tokio::test] -async fn a_revoked_session_stops_the_run_before_leasing_more_keys() { - let mut st = ironwood_wallet(); - let network = *st.network(); - let account = st.test_account().unwrap().id(); - // The first key in the batch is paid, so it needs the failing lookup. - let (record, _) = pay_restored_key(&mut st, 0, 0); - st.generate_and_scan_empty_blocks_with_dynamic_ivks(1); - restore(&mut st, account); - let through = tip(&st); - let directory = FailingQueries(Directory::new(publish( - std::slice::from_ref(&record), - &[record.payment.cmx], - through, - &provider(&[]), - ))); - let mut notes = Notes { - data: BTreeMap::new(), - requested: Vec::new(), - }; - let swept = sweep( - st.wallet_mut().db_mut(), - &network, - account, - through, - GENESIS, - ORIGIN, - &directory, - &mut notes, - &NoLock, - NOW, - ) - .await - .unwrap(); - assert_eq!(swept.finished, 0); - assert_eq!(swept.deferred.len(), 1); - assert!(swept.pending); - let posts = directory - .0 - .requests() - .iter() - .filter(|r| r.starts_with("POST")) - .count(); - assert_eq!(posts, 1); -} - -/// A directory whose witness snapshot is malformed, so no session opens. -struct MalformedWitnesses(Directory); - -impl Transport for MalformedWitnesses { - async fn get(&self, url: &str, limit: usize) -> Result, DirectoryError> { - if url.contains("/v1/receiver/witness/") { - self.0.requests.lock().unwrap().push(format!("GET {url}")); + if matches!(self.fault, Fault::Witnesses) && url.contains("/v1/receiver/witness/") { return Ok(vec![0]); } - self.0.get(url, limit).await + self.directory.get(url, limit).await } async fn post( @@ -1284,175 +1047,113 @@ impl Transport for MalformedWitnesses { body: Vec, limit: usize, ) -> Result, DirectoryError> { - self.0.post(url, body, limit).await + if matches!(self.fault, Fault::Queries) { + return Err(DirectoryError::Revision); + } + self.directory.post(url, body, limit).await } } -/// A session that fails to open stops the run, so later paid keys are neither leased -/// nor backed off. -#[tokio::test] -async fn a_failed_session_open_stops_the_run_before_leasing_more_keys() { - let mut st = ironwood_wallet(); - let account = st.test_account().unwrap().id(); - // The batch's first two keys are paid, so each needs the session. - let records: Vec<_> = (0..2) - .map(|index| pay_restored_key(&mut st, index, index).0) - .collect(); - st.generate_and_scan_empty_blocks_with_dynamic_ivks(1); - restore(&mut st, account); - let through = tip(&st); - let cmxs: Vec<_> = records.iter().map(|record| record.payment.cmx).collect(); - let directory = MalformedWitnesses(Directory::new(publish( - &records, - &cmxs, - through, - &provider(&[]), - ))); - let mut notes = Notes { - data: BTreeMap::new(), - requested: Vec::new(), - }; - let swept = run(&mut st, account, through, &directory, &mut notes, NOW) - .await - .unwrap(); - assert!( - matches!( - &swept.deferred[..], - [(key, Error::Directory(_))] if *key == KeyId::new(Purpose::Receive, 0) - ), - "{:?}", - swept.deferred - ); - assert_eq!(swept.finished, 0); - let due = st - .wallet_mut() - .db_mut() - .prepare_dynamic_sweeps(account, through, NOW, NonZeroU32::new(64).unwrap()) - .unwrap() - .unwrap(); - let due = |index| { - due.iter() - .any(|item| item.key == KeyId::new(Purpose::Receive, index)) - }; - assert!(!due(0)); - assert!(due(1)); -} - +/// A revoked session, a session that fails to open, or a note source that cannot serve +/// the wallet's chain would fail every later key the same way, so the run stops at the +/// first paid key, and later paid keys are neither leased nor backed off. #[tokio::test] -async fn a_publication_off_the_wallets_chain_is_refused_before_any_lookup() { - let mut st = ironwood_wallet(); - let network = *st.network(); - let account = st.test_account().unwrap().id(); - let (record, note_data) = pay_restored_key(&mut st, 0, 0); - st.generate_and_scan_empty_blocks_with_dynamic_ivks(1); - restore(&mut st, account); - let through = tip(&st); - let other = ChainPoint { - hash: BlockHash([1; 32]), - ..through - }; - let directory = Directory::new(publish( - std::slice::from_ref(&record), - &[record.payment.cmx], - other, - &provider(&[]), - )); - let mut notes = Notes { - data: BTreeMap::from([(record.payment.position, note_data)]), - requested: Vec::new(), - }; - let result = sweep( - st.wallet_mut().db_mut(), - &network, - account, - through, - GENESIS, - ORIGIN, - &directory, - &mut notes, - &NoLock, - NOW, - ) - .await; - assert!(matches!(result, Err(Error::Directory(_))), "{result:?}"); - assert_eq!(directory.requests(), ["GET /v1/receiver/init"]); - assert!(notes.requested.is_empty()); - assert!( - st.wallet() - .db() - .dynamic_history_pending(account, through.height) +async fn a_run_fatal_failure_stops_the_run_before_leasing_more_keys() { + for fault in [Fault::Queries, Fault::Witnesses, Fault::None] { + let mut st = ironwood_wallet(); + let account = st.test_account().unwrap().id(); + // The batch's first two keys are paid, so each needs a lookup. + let records: Vec<_> = (0..2) + .map(|index| pay_restored_key(&mut st, index, index).0) + .collect(); + st.generate_and_scan_empty_blocks_with_dynamic_ivks(1); + restore(&mut st, account); + let through = tip(&st); + let cmxs: Vec<_> = records.iter().map(|record| record.payment.cmx).collect(); + let directory = Faulty { + directory: Directory::new(publish(&records, &cmxs, through, &provider(&[]))), + fault, + }; + let swept = run(&mut st, through, &directory, &mut NoNotes, &NoLock, NOW) + .await + .unwrap(); + let [(key, e)] = &swept.deferred[..] else { + panic!("{fault:?}: {:?}", swept.deferred); + }; + let failed = match fault { + Fault::Queries => matches!(e, Error::Directory(DirectoryError::Revision)), + Fault::Witnesses => matches!(e, Error::Directory(_)), + Fault::None => matches!(e, Error::NotesUnavailable(_)), + }; + assert!( + *key == KeyId::new(Purpose::Receive, 0) && failed, + "{fault:?}: {e:?}" + ); + assert_eq!(swept.finished, 0); + assert!(swept.pending); + let due: Vec<_> = st + .wallet_mut() + .db_mut() + .prepare_dynamic_sweeps(account, through, NOW, NonZeroU32::new(64).unwrap()) .unwrap() - ); - // Every due key backed off, so the directory is not asked again until a minute on. - for (now, requests) in [(NOW, 1), (NOW + 60, 2)] { - let _ = sweep( - st.wallet_mut().db_mut(), - &network, - account, - through, - GENESIS, - ORIGIN, - &directory, - &mut notes, - &NoLock, - now, - ) - .await; - assert_eq!(directory.requests().len(), requests); - } -} - -/// A publication ending at the wallet's block but short of that block's last Ironwood -/// output is refused before any lookup, as one off the chain is: a payment in the -/// omitted tail would be neither looked up nor rescanned. -#[tokio::test] -async fn a_publication_short_of_its_anchor_blocks_outputs_is_refused() { - let mut st = ironwood_wallet(); - let network = *st.network(); - let account = st.test_account().unwrap().id(); - pay_restored_key(&mut st, 0, 0); - restore(&mut st, account); - let through = tip(&st); - // Ends at the paid block, without the chain's one Ironwood output, which it holds. - let directory = Directory::new(publish(&[], &[], through, &provider(&[]))); - let result = sweep( - st.wallet_mut().db_mut(), - &network, - account, - through, - GENESIS, - ORIGIN, - &directory, - &mut NoNotes, - &NoLock, - NOW, - ) - .await; - assert!(matches!(result, Err(Error::Unavailable(_))), "{result:?}"); - assert_eq!(directory.requests(), ["GET /v1/receiver/init"]); - assert!( - st.wallet() - .db() - .dynamic_history_pending(account, through.height) .unwrap() - ); -} - -/// An Enhance transport that must not be used. -struct NoEnhance; - -impl EnhanceTransport for NoEnhance { - async fn execute(&self, _: EnhanceRequest) -> Result { - panic!("no Enhance request was needed"); + .iter() + .map(|item| item.key.index()) + .collect(); + assert!(!due.contains(&0) && due.contains(&1), "{fault:?}: {due:?}"); } } +/// A publication off the wallet's chain, or ending at the wallet's block but short of +/// that block's last Ironwood output, is refused before any lookup: a payment in the +/// omitted tail would be neither looked up nor rescanned. Every due key backs off, so +/// the directory is not asked again until a minute on. #[tokio::test] -async fn enhance_notes_open_no_session_until_a_payment_needs_note_data() { - let st = ironwood_wallet(); - let mut notes = EnhanceNotes::new("https://enhance.test", &NoEnhance, st.network()); - let data = notes.note_data(st.wallet().db(), Vec::new()).await.unwrap(); - assert!(data.is_empty()); +async fn a_publication_off_the_wallets_chain_is_refused_before_any_lookup() { + for off_chain in [true, false] { + let mut st = ironwood_wallet(); + let account = st.test_account().unwrap().id(); + let (record, note_data) = pay_restored_key(&mut st, 0, 0); + st.generate_and_scan_empty_blocks_with_dynamic_ivks(1); + restore(&mut st, account); + let through = tip(&st); + let publication = if off_chain { + let other = ChainPoint { + hash: BlockHash([1; 32]), + ..through + }; + publish( + std::slice::from_ref(&record), + &[record.payment.cmx], + other, + &provider(&[]), + ) + } else { + // Without the chain's one Ironwood output. + publish(&[], &[], through, &provider(&[])) + }; + let directory = Directory::new(publication); + let mut notes = Notes::new(BTreeMap::from([(record.payment.position, note_data)])); + let result = run(&mut st, through, &directory, &mut notes, &NoLock, NOW).await; + let refused = match result { + Err(Error::Directory(_)) => off_chain, + Err(Error::Unavailable(_)) => !off_chain, + _ => false, + }; + assert!(refused, "{result:?}"); + assert_eq!(directory.requests(), ["GET /v1/receiver/init"]); + assert!(notes.requested.is_empty()); + assert!( + st.wallet() + .db() + .dynamic_history_pending(account, through.height) + .unwrap() + ); + for (now, requests) in [(NOW, 1), (NOW + 60, 2)] { + let _ = run(&mut st, through, &directory, &mut notes, &NoLock, now).await; + assert_eq!(directory.requests().len(), requests); + } + } } /// The test network's upgrades under mainnet's network type, the only one Enhance PIR @@ -1536,22 +1237,28 @@ impl EnhanceTransport for RefusingEnhance { } } +/// Enhance notes open no session until a position needs note data, accept fresh routing +/// after a refused query, and cannot serve a wallet whose chain lacks their anchor. #[tokio::test] -async fn enhance_notes_accept_fresh_routing_after_a_refused_query() { +async fn enhance_notes_follow_the_wallets_chain_and_fresh_routing() { + let mut st = ironwood_wallet(); + pay_restored_key(&mut st, 0, 0); + let anchor = tip(&st); + let records = st + .wallet() + .block_metadata(anchor.height) + .unwrap() + .unwrap() + .ironwood_tree_size() + .unwrap() + .into(); + let network = EnhanceNetwork(*st.network()); for status in [409, 410] { - let mut st = ironwood_wallet(); - pay_restored_key(&mut st, 0, 0); - let anchor = tip(&st); - let records = st - .wallet() - .block_metadata(anchor.height) - .unwrap() - .unwrap() - .ironwood_tree_size() - .unwrap(); - let enhance = RefusingEnhance::new(anchor, records.into(), status); - let network = EnhanceNetwork(*st.network()); + let enhance = RefusingEnhance::new(anchor, records, status); let mut notes = EnhanceNotes::new("https://enhance.test", &enhance, &network); + let data = notes.note_data(st.wallet().db(), Vec::new()).await.unwrap(); + assert!(data.is_empty()); + assert_eq!(enhance.inits.get(), 0); let refused = notes.note_data(st.wallet().db(), vec![0]).await; assert!( matches!(refused, Err(Error::NoteData(ClientError::HttpStatus(s))) if s == status), @@ -1562,115 +1269,19 @@ async fn enhance_notes_accept_fresh_routing_after_a_refused_query() { assert_eq!(data, BTreeMap::from([(0, [0; 528])])); assert_eq!((enhance.inits.get(), enhance.queries.get()), (2, 2)); } -} - -/// Enhance notes that cannot accept their manifest stop the run at the first paid key -/// that needs note data, so later paid keys are neither leased nor backed off. -#[tokio::test] -async fn an_unusable_note_source_stops_the_run_before_leasing_more_keys() { - let mut st = ironwood_wallet(); - let network = *st.network(); - let account = st.test_account().unwrap().id(); - let records: Vec<_> = (0..2) - .map(|index| pay_restored_key(&mut st, index, index).0) - .collect(); - st.generate_and_scan_empty_blocks_with_dynamic_ivks(1); - restore(&mut st, account); - let through = tip(&st); - let cmxs: Vec<_> = records.iter().map(|record| record.payment.cmx).collect(); - let directory = Directory::new(publish(&records, &cmxs, through, &provider(&[]))); // Enhance's anchor is at the wallet's tip height but on another chain. let other = ChainPoint { - height: through.height, hash: BlockHash([7; 32]), + ..anchor }; - let enhance = RefusingEnhance::new(other, 2, 409); - let enhance_network = EnhanceNetwork(network); - let mut notes = EnhanceNotes::new("https://enhance.test", &enhance, &enhance_network); - let swept = sweep( - st.wallet_mut().db_mut(), - &network, - account, - through, - GENESIS, - ORIGIN, - &directory, - &mut notes, - &NoLock, - NOW, - ) - .await - .unwrap(); - assert!( - matches!( - &swept.deferred[..], - [(key, Error::NotesUnavailable(_))] if *key == KeyId::new(Purpose::Receive, 0) - ), - "{:?}", - swept.deferred - ); - assert_eq!((swept.finished, enhance.inits.get()), (0, 1)); - let due = st - .wallet_mut() - .db_mut() - .prepare_dynamic_sweeps(account, through, NOW, NonZeroU32::new(64).unwrap()) - .unwrap() - .unwrap(); + let enhance = RefusingEnhance::new(other, records, 409); + let mut notes = EnhanceNotes::new("https://enhance.test", &enhance, &network); + let refused = notes.note_data(st.wallet().db(), vec![0]).await; assert!( - due.iter() - .any(|item| item.key == KeyId::new(Purpose::Receive, 1)) + matches!(refused, Err(Error::NotesUnavailable(_))), + "{refused:?}" ); -} - -/// Runs one sweep of `account` through `through` at `now`. -async fn run( - st: &mut State, - account: AccountUuid, - through: ChainPoint, - directory: impl Transport, - notes: &mut Notes, - now: i64, -) -> Result< - zakura_pir_receiver::Swept, - Error, -> { - let network = *st.network(); - sweep( - st.wallet_mut().db_mut(), - &network, - account, - through, - GENESIS, - ORIGIN, - directory, - notes, - &NoLock, - now, - ) - .await -} - -/// Runs sweeps at [`NOW`] until none is pending, expecting no deferral, and returns how -/// many keys they finished. -async fn settle( - st: &mut State, - account: AccountUuid, - through: ChainPoint, - directory: &Directory, - notes: &mut Notes, -) -> usize { - let mut finished = 0; - for _ in 0..20 { - let swept = run(st, account, through, directory, notes, NOW) - .await - .unwrap(); - assert!(swept.deferred.is_empty(), "{:?}", swept.deferred); - finished += swept.finished; - if !swept.pending { - return finished; - } - } - panic!("sweeps are still pending after 20 runs"); + assert_eq!(enhance.queries.get(), 0); } /// A queued candidate that defers is replaced by each later attempt's answer from the @@ -1725,12 +1336,9 @@ async fn a_deferred_candidate_takes_each_publications_current_answer() { .unwrap(); } } - let mut notes = Notes { - data: BTreeMap::from([(0, note_data)]), - requested: Vec::new(), - }; + let mut notes = Notes::new(BTreeMap::from([(0, note_data)])); let a = Directory::new(publish(&[first], &commitments, through, &provider(&[]))); - let swept = run(&mut st, account, through, &a, &mut notes, NOW) + let swept = run(&mut st, through, &a, &mut notes, &NoLock, NOW) .await .unwrap(); assert!( @@ -1758,7 +1366,7 @@ async fn a_deferred_candidate_takes_each_publications_current_answer() { publish(&[], &commitments, through, &provider(&[])) }; let b = Directory::new(b); - let swept = run(&mut st, account, through, &b, &mut notes, NOW + 60) + let swept = run(&mut st, through, &b, &mut notes, &NoLock, NOW + 60) .await .unwrap(); assert!( @@ -1766,7 +1374,7 @@ async fn a_deferred_candidate_takes_each_publications_current_answer() { "{deferral:?}: {:?}", swept.deferred ); - settle(&mut st, account, through, &b, &mut notes).await; + settle(&mut st, through, &b, &mut notes).await; let value = if corrected { VALUE } else { 0 }; assert_eq!( st.get_total_balance(account), @@ -1789,17 +1397,14 @@ async fn an_unchanged_answer_reuses_queued_note_data() { .conn() .execute_batch("DELETE FROM nullifier_map; DELETE FROM ironwood_nullifier_scan_blocks;") .unwrap(); - let mut notes = Notes { - data: BTreeMap::from([(record.payment.position, note_data)]), - requested: Vec::new(), - }; + let mut notes = Notes::new(BTreeMap::from([(record.payment.position, note_data)])); let directory = Directory::new(publish( std::slice::from_ref(&record), &[record.payment.cmx], through, &provider(&[]), )); - let swept = run(&mut st, account, through, &directory, &mut notes, NOW) + let swept = run(&mut st, through, &directory, &mut notes, &NoLock, NOW) .await .unwrap(); assert!( @@ -1811,7 +1416,7 @@ async fn an_unchanged_answer_reuses_queued_note_data() { swept.deferred ); // The replay the deferral queued is still unscanned. - let swept = run(&mut st, account, through, &directory, &mut notes, NOW + 60) + let swept = run(&mut st, through, &directory, &mut notes, &NoLock, NOW + 60) .await .unwrap(); assert!( @@ -1831,17 +1436,9 @@ async fn an_unchanged_answer_reuses_queued_note_data() { assert_eq!(notes.requested, [vec![record.payment.position]]); } -/// The receiver of `account_fvk`'s incoming dynamic key `index`. -fn incoming_receiver(account_fvk: &FullViewingKey, index: u64) -> Receiver { - let fvk = KeyId::new(Purpose::Receive, index) - .derive(account_fvk) - .unwrap(); - Receiver::from_bytes(fvk.address_at(0u32, Scope::External).to_raw_address_bytes()).unwrap() -} - /// A seen set that holds every restored receiver moves the lookahead after each batch, -/// so a run sweeps at most one batch per account and leaves the new keys pending. Later -/// runs progress and find a payment far past the first batch. +/// so a run sweeps at most one batch and leaves the new keys pending. Later runs +/// progress and find a payment far past the first batch. #[tokio::test] async fn each_run_sweeps_one_batch_and_later_runs_progress() { let mut st = ironwood_wallet(); @@ -1851,22 +1448,18 @@ async fn each_run_sweeps_one_batch_and_later_runs_progress() { st.generate_and_scan_empty_blocks_with_dynamic_ivks(1); restore(&mut st, account); let through = tip(&st); - let parent = FullViewingKey::from(st.test_account().unwrap().usk().orchard()); // The set is finite, so an unbounded run stops past it instead of hanging. - let seen: Vec<_> = (0..200).map(|i| incoming_receiver(&parent, i)).collect(); + let seen: Vec<_> = (0..200).map(|i| receiver(&st, i)).collect(); let directory = Directory::new(publish( std::slice::from_ref(&record), &[record.payment.cmx], through, &provider(&seen), )); - let mut notes = Notes { - data: BTreeMap::from([(record.payment.position, note_data)]), - requested: Vec::new(), - }; + let mut notes = Notes::new(BTreeMap::from([(record.payment.position, note_data)])); let mut runs = 0; loop { - let swept = run(&mut st, account, through, &directory, &mut notes, NOW) + let swept = run(&mut st, through, &directory, &mut notes, &NoLock, NOW) .await .unwrap(); runs += 1; From 4e34b8aa741a64e21dffdba982d19b7bf64df969 Mon Sep 17 00:00:00 2001 From: Adam Tucker Date: Sun, 11 Oct 2026 12:17:46 -0600 Subject: [PATCH 34/40] State each receiver rule once in its docs The README and CHANGELOG restated the sweep rules that the `sweep` rustdoc already gives, and the README still said the receiver crates were pinned to wallet-pir's open receiver stack, though the pin is on its `main`. Both now describe the crate and point at the rustdoc. The seen-set comment cited the removed `EARLIEST_RECEIVE_QUOTE`; it now says only that sweeps use membership and issuance checks dates. The recent-set and seen-set requirements are stated once, in the `sweep` and `fetch_seen` rustdoc, which also stops `fetch_seen` linking to a private item. Co-Authored-By: Claude Opus 5.5 --- zakura/pir-receiver/CHANGELOG.md | 16 +++++-------- zakura/pir-receiver/README.md | 40 +++++++------------------------- zakura/pir-receiver/src/seen.rs | 10 ++++---- zakura/pir-receiver/src/sweep.rs | 8 ++----- 4 files changed, 22 insertions(+), 52 deletions(-) diff --git a/zakura/pir-receiver/CHANGELOG.md b/zakura/pir-receiver/CHANGELOG.md index bf6a42de..aff38f50 100644 --- a/zakura/pir-receiver/CHANGELOG.md +++ b/zakura/pir-receiver/CHANGELOG.md @@ -4,14 +4,10 @@ ### Added -- `sweep`, behind the `wallet` feature, which runs a restored wallet's - receiver-directory sweeps of its dynamic keys for any store implementing the - backend's `DynamicIvkWrite`, with `Transport`, `NoteSource`, `EnhanceNotes`, `WriteLock`, - `Swept`, `Error` and `MAINNET_GENESIS`. It reads a publication's labeled filter - sets `paid` and each provider's `recent` and `seen`, trusting a recent set only while - its feed is current, and rechecks closed keys against each newer paid filter. The - receiver PIR client moved here from Vizor. +- `sweep`, behind the `wallet` feature, which runs one account's receiver-directory + restore sweeps of its dynamic keys for any store implementing the backend's + `DynamicIvkWrite`, with `Transport`, `NoteSource`, `EnhanceNotes`, `WriteLock`, + `Swept`, `Error` and `MAINNET_GENESIS`. The receiver PIR client moved here from + Vizor. - `fetch_seen` and `Seen`: a publication's swap provider seen sets, which a wallet - checks before issuing a swap address. A publication without seen sets, or in which - a provider with sets lacks a dated seen set, is malformed, for `fetch_seen` and - `sweep` alike. They need no `wallet` feature. + checks before issuing a swap address. They need no `wallet` feature. diff --git a/zakura/pir-receiver/README.md b/zakura/pir-receiver/README.md index 16b7f748..3610bcdf 100644 --- a/zakura/pir-receiver/README.md +++ b/zakura/pir-receiver/README.md @@ -1,21 +1,13 @@ # Receiver directory restore sweeps -With the `wallet` feature, `sweep` runs a restored wallet's sweeps of its dynamic keys -for any wallet store that implements the backend's dynamic IVK traits -(`zcash_client_backend::data_api::dynamic_ivk`), such as `zakura-client-sqlite`. -Each dynamic key recovered from the seed, a refund key from its funding memo or an -incoming lookahead key, is first tested against the publication's filters, which -every wallet downloads alike. Only a receiver in the paid set is looked up over PIR, -and the payments found are imported only after the wallet checks them against its own -chain. A receiver in a swap provider's seen set is marked seen as its sweep attempt -starts, before any lookup, so issuance does not hand it out again. A key whose receiver -a provider was given within the wallet's restore watch keeps scanning after its sweep, -and any other closes. A provider's recent set is trusted only if its feed read the -provider within the last fifteen minutes and its window and feed history cover that -watch; unless every provider has such a set, every key keeps scanning. A closed key -with no known payment is checked against each newer publication's paid filter, and -swept again on a hit. A run that finds nothing to look up opens no PIR session and -fetches no witnesses. +A restored wallet finds payments to its dynamic keys by looking each one up in a +receiver directory over PIR. `fetch_seen` downloads the swap provider seen sets that +a wallet checks before issuing a swap address. With the `wallet` feature, `sweep` +runs one account's restore sweeps for any wallet store that implements the backend's +dynamic IVK traits (`zcash_client_backend::data_api::dynamic_ivk`), such as +`zakura-client-sqlite`. Its rustdoc states which publications a run accepts, when it +sends requests, which keys keep scanning or are checked again, and how failures are +retried. Applications supply: @@ -28,19 +20,5 @@ Applications supply: - the wallet's consensus parameters, the network's genesis hash (`MAINNET_GENESIS` on mainnet) and the clock. -See the `sweep` rustdoc for when a run sends requests, which publications it -accepts, and how failures are retried. - -The receiver crates are pinned to a commit of wallet-pir's open receiver stack -until they land on its `main`. - `tests/sweep.rs` serves a directory in process through the receiver service's own -routes and checks that a restored wallet finds and imports a payout, extends its -lookahead past it, repeats no finished sweep, imports every payment of a key paid -twice, makes no lookup when nothing was paid, keeps scanning only recently quoted -addresses, and every address unless each provider has a recent set, issues no seen -one, finds a payment to a closed address through a newer paid filter, refuses a publication that is not on its chain before any lookup, -refuses one without seen sets or in which a provider lacks one, and replaces a -deferred payment with a later publication's answer. -Against a fake Enhance service, `EnhanceNotes` accepts fresh routing after a refused -query. +routes and real PIR, and checks each of those rules against restored SQLite wallets. diff --git a/zakura/pir-receiver/src/seen.rs b/zakura/pir-receiver/src/seen.rs index a9b2276e..890f766f 100644 --- a/zakura/pir-receiver/src/seen.rs +++ b/zakura/pir-receiver/src/seen.rs @@ -58,8 +58,9 @@ impl Seen { /// Downloads the seen sets of the publication `origin` serves, which must commit to /// the network's `genesis` ([`MAINNET_GENESIS`](crate::MAINNET_GENESIS) on mainnet), as /// for `sweep`, since another network's sets say nothing of this one's quotes. Fails if -/// it declares none, or if any provider with a set lacks a seen set with its feed's read -/// times (see [`seen_sets`]). +/// it declares no seen sets, or unless every provider with a recent or seen set has a +/// seen set dated with its feed's read times, since receivers a provider without one had +/// would look unseen. pub async fn fetch_seen( origin: &str, transport: &T, @@ -87,9 +88,8 @@ pub async fn fetch_seen( Ok(seen) } -/// The labels and feed read times of the seen sets among a publication's filter `sets`. -/// Fails if there are none, or unless every provider with a recent or seen set has a -/// dated seen set, since receivers a provider without one had would look unseen. +/// The labels and feed read times of the seen sets among a publication's filter `sets`, +/// failing as [`fetch_seen`] does. pub(crate) fn seen_sets(sets: &[FilterSet]) -> Result, DirectoryError> { let mut providers = BTreeMap::<&str, Option<(&str, i64, i64)>>::new(); for set in sets { diff --git a/zakura/pir-receiver/src/sweep.rs b/zakura/pir-receiver/src/sweep.rs index 511a58f6..bd6738e9 100644 --- a/zakura/pir-receiver/src/sweep.rs +++ b/zakura/pir-receiver/src/sweep.rs @@ -436,8 +436,7 @@ impl<'a, T: Transport> Directory<'a, T> { "the receiver directory's tree size differs from the wallet's at its anchor", )); } - // Seen feeds start at `EARLIEST_RECEIVE_QUOTE`, before any dynamic address existed, so a - // sweep ignores their dates; issuance enforces coverage where reuse is at stake. + // Sweeps use only seen-set membership; issuance checks the sets' dates as it issues. let seen = seen_sets(&manifest.directory.filters)? .into_iter() .map(|(label, _, _)| label.to_owned()) @@ -537,10 +536,7 @@ impl<'a, T: Transport> Directory<'a, T> { } } -/// Whether a provider's recent set can rule a receiver out at `now`: its feed's last -/// read began at most [`RECENT_SET_MAX_AGE_SECS`] before `now`, and its window, which -/// reaches back from that read through the time the feed was running, covers -/// [`RESTORE_WATCH_SECS`]. +/// Whether a provider's recent set can rule a receiver out at `now` (see [`sweep`]). fn covers_restore_watch(set: &FilterSet, now: i64) -> bool { let window = set.window_secs.and_then(|w| i64::try_from(w).ok()); match (window, set.since_unix, set.until_unix) { From 60d1208fd35b6d5d6aa7578505c8e03b158b4c10 Mon Sep 17 00:00:00 2001 From: Adam Tucker Date: Sun, 11 Oct 2026 18:27:32 -0600 Subject: [PATCH 35/40] Rename dynamic IVKs to one-time IVKs in the PIR receiver Follow the rename through `zakura-pir-receiver`: its dependency on `zakura-one-time-ivk`, the `OneTimeIvkRead`/`OneTimeIvkWrite` bounds, the `*_one_time_*` calls, the `one_time_sweep.*` write-lock labels, and the docs, README, CHANGELOG and tests that name them. Co-Authored-By: Claude Opus 5.5 --- Cargo.lock | 2 +- zakura/pir-receiver/CHANGELOG.md | 4 +- zakura/pir-receiver/Cargo.toml | 8 +-- zakura/pir-receiver/README.md | 4 +- zakura/pir-receiver/src/lib.rs | 6 +-- zakura/pir-receiver/src/sweep.rs | 64 +++++++++++------------ zakura/pir-receiver/tests/sweep.rs | 84 +++++++++++++++--------------- 7 files changed, 86 insertions(+), 86 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 53775e32..3b8ad066 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -8243,7 +8243,7 @@ dependencies = [ "tower", "zakura-client-backend", "zakura-client-sqlite", - "zakura-dynamic-ivk", + "zakura-one-time-ivk", "zakura-orchard", "zakura-pir-enhance", "zakura-primitives", diff --git a/zakura/pir-receiver/CHANGELOG.md b/zakura/pir-receiver/CHANGELOG.md index aff38f50..9a2a801d 100644 --- a/zakura/pir-receiver/CHANGELOG.md +++ b/zakura/pir-receiver/CHANGELOG.md @@ -5,8 +5,8 @@ ### Added - `sweep`, behind the `wallet` feature, which runs one account's receiver-directory - restore sweeps of its dynamic keys for any store implementing the backend's - `DynamicIvkWrite`, with `Transport`, `NoteSource`, `EnhanceNotes`, `WriteLock`, + restore sweeps of its one-time keys for any store implementing the backend's + `OneTimeIvkWrite`, with `Transport`, `NoteSource`, `EnhanceNotes`, `WriteLock`, `Swept`, `Error` and `MAINNET_GENESIS`. The receiver PIR client moved here from Vizor. - `fetch_seen` and `Seen`: a publication's swap provider seen sets, which a wallet diff --git a/zakura/pir-receiver/Cargo.toml b/zakura/pir-receiver/Cargo.toml index 5856ea74..e48d9aea 100644 --- a/zakura/pir-receiver/Cargo.toml +++ b/zakura/pir-receiver/Cargo.toml @@ -2,7 +2,7 @@ name = "zakura-pir-receiver" version = "0.0.1-rc0" publish = false -description = "Restore sweeps of dynamic IVKs through a receiver directory over PIR" +description = "Restore sweeps of one-time IVKs through a receiver directory over PIR" edition.workspace = true rust-version.workspace = true license.workspace = true @@ -11,14 +11,14 @@ readme = "README.md" [features] default = [] -## The restore sweep, over a wallet store's dynamic IVK traits. -wallet = ["dep:futures-util", "dep:thiserror", "dep:zakura-pir-enhance", "dep:zakura-dynamic-ivk", "dep:zcash_client_backend", "dep:zcash_protocol"] +## The restore sweep, over a wallet store's one-time IVK traits. +wallet = ["dep:futures-util", "dep:thiserror", "dep:zakura-pir-enhance", "dep:zakura-one-time-ivk", "dep:zcash_client_backend", "dep:zcash_protocol"] [dependencies] futures-util = { version = "0.3", optional = true } thiserror = { version = "2", optional = true } zakura-pir-enhance = { version = "=0.0.1-rc1", path = "../pir-enhance", features = ["wallet"], optional = true } -zakura-dynamic-ivk = { version = "=0.0.1-rc0", path = "../dynamic-ivk", optional = true } +zakura-one-time-ivk = { version = "=0.0.1-rc0", path = "../one-time-ivk", optional = true } zcash_client_backend = { workspace = true, features = ["orchard"], optional = true } zcash_protocol = { workspace = true, optional = true } # The receiver crates come from wallet-pir's `main`. diff --git a/zakura/pir-receiver/README.md b/zakura/pir-receiver/README.md index 3610bcdf..a70b49f4 100644 --- a/zakura/pir-receiver/README.md +++ b/zakura/pir-receiver/README.md @@ -1,10 +1,10 @@ # Receiver directory restore sweeps -A restored wallet finds payments to its dynamic keys by looking each one up in a +A restored wallet finds payments to its one-time keys by looking each one up in a receiver directory over PIR. `fetch_seen` downloads the swap provider seen sets that a wallet checks before issuing a swap address. With the `wallet` feature, `sweep` runs one account's restore sweeps for any wallet store that implements the backend's -dynamic IVK traits (`zcash_client_backend::data_api::dynamic_ivk`), such as +one-time IVK traits (`zcash_client_backend::data_api::one_time_ivk`), such as `zakura-client-sqlite`. Its rustdoc states which publications a run accepts, when it sends requests, which keys keep scanning or are checked again, and how failures are retried. diff --git a/zakura/pir-receiver/src/lib.rs b/zakura/pir-receiver/src/lib.rs index 6b9ff8f7..fc295593 100644 --- a/zakura/pir-receiver/src/lib.rs +++ b/zakura/pir-receiver/src/lib.rs @@ -1,7 +1,7 @@ -//! Restore sweeps of dynamic keys through a receiver directory over PIR. +//! Restore sweeps of one-time keys through a receiver directory over PIR. //! //! A restored wallet cannot rescan public history for every key its seed may have -//! used, so each dynamic key recovered from the seed is looked up once in a receiver +//! used, so each one-time key recovered from the seed is looked up once in a receiver //! directory: a public index of Ironwood payments sent with the zero outgoing viewing //! key, keyed by receiver. Lookups use PIR, so the directory does not learn which //! receivers the wallet holds. A publication is used only at a block the wallet @@ -10,7 +10,7 @@ //! //! `fetch_seen` downloads the swap provider seen sets that a wallet checks before //! issuing a swap address. The `wallet` feature adds `sweep`, which runs these lookups -//! for any wallet store that implements the backend's `DynamicIvkWrite`. The caller +//! for any wallet store that implements the backend's `OneTimeIvkWrite`. The caller //! supplies transports, which carry its route policy, cancellation and timeouts, and a //! `WriteLock` that serializes wallet writes with its other writers. Dropping a `sweep` //! future at any await is safe: each wallet step commits on its own, and every begun diff --git a/zakura/pir-receiver/src/sweep.rs b/zakura/pir-receiver/src/sweep.rs index bd6738e9..dd38ad59 100644 --- a/zakura/pir-receiver/src/sweep.rs +++ b/zakura/pir-receiver/src/sweep.rs @@ -1,4 +1,4 @@ -//! Restore sweeps over a wallet's dynamic IVK traits. +//! Restore sweeps over a wallet's one-time IVK traits. use std::{collections::BTreeMap, num::NonZeroU32}; @@ -10,7 +10,7 @@ use receiver_directory::{ witness::WitnessSnapshot, }; use receiver_pir::{AcceptedCoverage, transport::DirectoryClient}; -use zakura_dynamic_ivk::{KeyId, lifecycle::RESTORE_WATCH_SECS}; +use zakura_one_time_ivk::{KeyId, lifecycle::RESTORE_WATCH_SECS}; use zakura_pir_enhance::{ ClientError, ClientResourceLimits, transport::{Client, PendingClient, Transport as EnhanceTransport}, @@ -18,11 +18,11 @@ use zakura_pir_enhance::{ }; use zcash_client_backend::data_api::{ WalletRead, - dynamic_ivk::{ - DirectoryPayment, DiscoveryWork, DynamicIvkRead, DynamicIvkWrite, PaymentApplication, + enhance_pir::EnhancePirRead, + one_time_ivk::{ + DirectoryPayment, DiscoveryWork, OneTimeIvkRead, OneTimeIvkWrite, PaymentApplication, SweepDeferral, }, - enhance_pir::EnhancePirRead, transparent_ledger::ChainPoint, }; use zcash_protocol::consensus::{BlockHeight, NetworkUpgrade, Parameters}; @@ -79,7 +79,7 @@ pub trait WriteLock { /// Supplies the note data that payments a lookup found need. #[allow(async_fn_in_trait)] -pub trait NoteSource { +pub trait NoteSource { /// The 528 ciphertext bytes after the directory's prefix for each of `positions`, /// keyed by commitment tree position. `wallet` is the wallet being swept. /// `positions` is never empty when a sweep calls this. [`Error::NotesUnavailable`] @@ -120,7 +120,7 @@ impl NoteSource for EnhanceNotes<'_, T, P> where T: EnhanceTransport, P: Parameters, - W: DynamicIvkRead + EnhancePirRead, + W: OneTimeIvkRead + EnhancePirRead, { async fn note_data( &mut self, @@ -178,7 +178,7 @@ pub struct Swept { /// Runs `account`'s due restore sweeps against the receiver directory at `origin`, /// then releases Ironwood spend evidence that no sweep needs any longer (see -/// [`DynamicIvkWrite::finish_dynamic_nullifier_recovery`]). A wallet with several +/// [`OneTimeIvkWrite::finish_one_time_nullifier_recovery`]). A wallet with several /// accounts calls it once for each. /// /// `through` is the wallet's fully scanned tip, and sweeps and release run only while @@ -187,16 +187,16 @@ pub struct Swept { /// When none is due, the run makes no request. Otherwise the publication must commit /// to `genesis` ([`MAINNET_GENESIS`](crate::MAINNET_GENESIS) on mainnet), cover /// history from Ironwood activation on `params`' network, and end at a block the -/// wallet accepts (see [`DynamicIvkRead::directory_publication_anchor`]) with the +/// wallet accepts (see [`OneTimeIvkRead::directory_publication_anchor`]) with the /// wallet's Ironwood tree size there, and have a dated seen set for at least one swap /// provider and for every provider with a set, as for [`fetch_seen`](crate::fetch_seen). /// /// Each key's receiver is first tested against the publication's filters, which every /// wallet downloads alike: only a receiver in the paid set is looked up over PIR, and /// one in a swap provider's seen set is never issued again (see -/// [`DynamicIvkWrite::begin_dynamic_sweep_attempt`]). A key whose receiver a provider's +/// [`OneTimeIvkWrite::begin_one_time_sweep_attempt`]). A key whose receiver a provider's /// recent set holds keeps scanning for the wallet's restore watch, and any other closes -/// (see [`DynamicIvkWrite::apply_dynamic_sweep`]). A recent set can rule a receiver out +/// (see [`OneTimeIvkWrite::apply_one_time_sweep`]). A recent set can rule a receiver out /// only if it is current and complete: its feed's last read began at most fifteen /// minutes before `now`, and its window, with the feed running throughout, covers /// [`RESTORE_WATCH_SECS`]. Unless every provider has such a set, every key keeps @@ -204,7 +204,7 @@ pub struct Swept { /// /// After releasing, the run checks closed keys with no known payment against the /// publication's paid filter once per publication (see -/// [`DynamicIvkWrite::record_paid_filter_check`]), so a payment the publication shows +/// [`OneTimeIvkWrite::record_paid_filter_check`]), so a payment the publication shows /// after such a key closed is swept on a later run. While any such key exists, every /// run fetches the manifest; it fetches the filters only for a newer publication. /// @@ -231,7 +231,7 @@ pub async fn sweep( now: i64, ) -> Result>, Error>> where - W: DynamicIvkWrite, + W: OneTimeIvkWrite, P: Parameters, T: Transport, N: NoteSource, @@ -244,8 +244,8 @@ where let mut directory = None; if current { let work = lock - .write("dynamic_sweep.due", || { - wallet.prepare_dynamic_sweeps(account, through, now, BATCH) + .write("one_time_sweep.due", || { + wallet.prepare_one_time_sweeps(account, through, now, BATCH) }) .map_err(Error::Wallet)? .map_err(Error::Deferred)?; @@ -260,8 +260,8 @@ where // if its own lookup had failed, and the next sync does not wait on // the same directory again. for item in &work { - lock.write("dynamic_sweep.attempt", || { - wallet.begin_dynamic_sweep_attempt(account, item.key, now, false) + lock.write("one_time_sweep.attempt", || { + wallet.begin_one_time_sweep_attempt(account, item.key, now, false) }) .map_err(Error::Wallet)?; } @@ -275,8 +275,8 @@ where } for (item, check) in work.into_iter().zip(checks) { // A key whose lease is not stored would be due again at once. - lock.write("dynamic_sweep.attempt", || { - wallet.begin_dynamic_sweep_attempt(account, item.key, now, check.seen) + lock.write("one_time_sweep.attempt", || { + wallet.begin_one_time_sweep_attempt(account, item.key, now, check.seen) }) .map_err(Error::Wallet)?; let swept_key = sweep_key( @@ -303,17 +303,17 @@ where } } // New lookahead keys wait for a later run, which bounds this one. - lock.write("dynamic_sweep.lookahead", || { - wallet.maintain_dynamic_ivks(account) + lock.write("one_time_sweep.lookahead", || { + wallet.maintain_one_time_ivks(account) }) .map_err(Error::Wallet)?; } // Another writer may have moved the tip while the sweep held no lock, and a // finished key may have queued a rescan below it. - lock.write("dynamic_sweep.release", || { + lock.write("one_time_sweep.release", || { let scanned = wallet.block_fully_scanned()?.map(|b| b.block_height()); if at_tip(wallet, through)? && scanned == Some(through.height) { - wallet.finish_dynamic_nullifier_recovery(account, through)?; + wallet.finish_one_time_nullifier_recovery(account, through)?; } Ok(()) }) @@ -321,7 +321,7 @@ where } if let (true, Some(transport)) = (current, transport) { // Closed keys with no known payment need each newer publication's paid filter. - let closed = wallet.dynamic_keys_to_recheck(account, through.height + 1); + let closed = wallet.one_time_keys_to_recheck(account, through.height + 1); if !closed.map_err(Error::Wallet)?.is_empty() { let accepted = Directory::accept(wallet, params, through, genesis, origin, transport); directory = Some(accepted.await?); @@ -331,7 +331,7 @@ where // Only keys this publication has not checked, so the filters are fetched only for // a newer one. let keys = wallet - .dynamic_keys_to_recheck(account, directory.anchor.height) + .one_time_keys_to_recheck(account, directory.anchor.height) .map_err(Error::Wallet)?; if !keys.is_empty() { let checks = directory.check(&keys, now).await?; @@ -340,7 +340,7 @@ where .zip(checks) .map(|(item, check)| (item.key, check.paid)) .collect(); - lock.write("dynamic_sweep.recheck", || { + lock.write("one_time_sweep.recheck", || { wallet.record_paid_filter_check(account, directory.anchor, &checked) }) .map_err(Error::Wallet)?; @@ -348,7 +348,7 @@ where } // Read last, since a paid filter hit queues its key's sweep again. let pending = wallet - .dynamic_history_pending(account, through.height) + .one_time_history_pending(account, through.height) .map_err(Error::Wallet)?; Ok(Swept { finished, @@ -401,7 +401,7 @@ struct Session { impl<'a, T: Transport> Directory<'a, T> { /// Accepts the publication `origin` serves, at the block of the wallet's chain it /// ends at. - async fn accept( + async fn accept( wallet: &W, params: &P, through: ChainPoint, @@ -566,7 +566,7 @@ async fn sweep_key( lock: &L, ) -> Result<(), Error>> where - W: DynamicIvkWrite, + W: OneTimeIvkWrite, T: Transport, N: NoteSource, L: WriteLock, @@ -601,7 +601,7 @@ where notes.note_data(wallet, batch.clone()).await? }; let done = lock - .write("dynamic_sweep.queue", || { + .write("one_time_sweep.queue", || { wallet.queue_directory_lookup(account, key, anchor, &payments, ¬e_data) }) .map_err(Error::Wallet)? @@ -623,8 +623,8 @@ where None }; let applied = lock - .write("dynamic_sweep.apply", || { - wallet.apply_dynamic_sweep(account, key, through, anchor, check.recent, |p, cmx| { + .write("one_time_sweep.apply", || { + wallet.apply_one_time_sweep(account, key, through, anchor, check.recent, |p, cmx| { witnesses.and_then(|w| w.path(p, cmx).ok()) }) }) diff --git a/zakura/pir-receiver/tests/sweep.rs b/zakura/pir-receiver/tests/sweep.rs index f8369f4f..c4b36c8b 100644 --- a/zakura/pir-receiver/tests/sweep.rs +++ b/zakura/pir-receiver/tests/sweep.rs @@ -1,4 +1,4 @@ -//! Restore sweeps end to end: a restored wallet looks up its dynamic keys in a receiver +//! Restore sweeps end to end: a restored wallet looks up its one-time keys in a receiver //! directory served in process, over the service's own routes and real PIR, and //! imports what it finds only after checking it against its own chain. @@ -28,7 +28,7 @@ use receiver_pir_server::{Publication, Publications, router_with_publications}; use rusqlite::hooks::{AuthAction, AuthContext, Authorization}; use sha2::{Digest as _, Sha256}; use tower::ServiceExt as _; -use zakura_dynamic_ivk::lifecycle::RESTORE_WATCH_SECS; +use zakura_one_time_ivk::lifecycle::RESTORE_WATCH_SECS; use zakura_pir_enhance::{ ClientError, QueryBinding, ShardSession, transport::{Request as EnhanceRequest, ResponseBody, Transport as EnhanceTransport}, @@ -40,7 +40,7 @@ use zakura_pir_receiver::{ use zcash_client_backend::data_api::{ Account as _, WalletRead as _, WalletWrite as _, chain::BlockSource as _, - dynamic_ivk::{DynamicIvkRead, DynamicIvkWrite as _, PaymentApplication}, + one_time_ivk::{OneTimeIvkRead, OneTimeIvkWrite as _, PaymentApplication}, testing::{AddressType, IronwoodFvk, TestBuilder, TestRng, TestState}, transparent_ledger::ChainPoint, }; @@ -52,7 +52,7 @@ use zcash_client_sqlite::{ db::{TestDb, TestDbFactory}, }, util::{SystemClock, testing::FixedClock}, - wallet::dynamic_ivk::{KeyId, ProviderSeen, Purpose, RECEIVE_GAP_LIMIT}, + wallet::one_time_ivk::{KeyId, ProviderSeen, Purpose, RECEIVE_GAP_LIMIT}, }; use zcash_note_encryption::{Domain as _, try_compact_note_decryption}; use zcash_primitives::block::BlockHash; @@ -111,14 +111,14 @@ fn tip(st: &State) -> ChainPoint { } } -/// The receiver of the test account's incoming dynamic key `index`. +/// The receiver of the test account's incoming one-time key `index`. fn receiver(st: &State, index: u64) -> Receiver { let parent = FullViewingKey::from(st.test_account().unwrap().usk().orchard()); let fvk = KeyId::new(Purpose::Receive, index).derive(&parent).unwrap(); Receiver::from_bytes(fvk.address_at(0u32, Scope::External).to_raw_address_bytes()).unwrap() } -/// Pays incoming dynamic key `index` in a new scanned block, before any restore has +/// Pays incoming one-time key `index` in a new scanned block, before any restore has /// registered the key, so scanning cannot find it. Returns the payment as the /// directory lists it and its note data. The output is the chain's Ironwood leaf at /// `position`, which is the number of earlier Ironwood outputs. @@ -130,7 +130,7 @@ fn pay_restored_key(st: &mut State, index: u64, position: u64) -> (Record, [u8; AddressType::DefaultExternal, Zatoshis::const_from_u64(VALUE), ); - st.scan_cached_blocks_with_dynamic_ivks(height, 1); + st.scan_cached_blocks_with_one_time_ivks(height, 1); let mut block = None; st.cache() .with_blocks::<_, ()>(Some(height), Some(1), |cb| { @@ -328,7 +328,7 @@ impl Notes { } } -impl NoteSource for Notes { +impl NoteSource for Notes { async fn note_data( &mut self, _: &W, @@ -345,7 +345,7 @@ impl NoteSource for Notes { /// A note source that cannot serve the wallet's chain. struct NoNotes; -impl NoteSource for NoNotes { +impl NoteSource for NoNotes { async fn note_data( &mut self, _: &W, @@ -379,7 +379,7 @@ impl WriteLock for Labels { fn restore(st: &mut State, account: AccountUuid) { st.wallet_mut() .db_mut() - .maintain_dynamic_ivks(account) + .maintain_one_time_ivks(account) .unwrap(); } @@ -437,7 +437,7 @@ async fn a_restore_sweep_finds_a_payout_and_moves_the_lookahead_past_it() { let account = st.test_account().unwrap().id(); let paid = 3; let (record, note_data) = pay_restored_key(&mut st, paid, 0); - st.generate_and_scan_empty_blocks_with_dynamic_ivks(1); + st.generate_and_scan_empty_blocks_with_one_time_ivks(1); restore(&mut st, account); let through = tip(&st); assert_eq!(st.get_total_balance(account), Zatoshis::ZERO); @@ -484,7 +484,7 @@ async fn every_payment_to_a_key_is_imported_in_note_data_batches() { records.push(record); data.insert(position, note_data); } - st.generate_and_scan_empty_blocks_with_dynamic_ivks(1); + st.generate_and_scan_empty_blocks_with_one_time_ivks(1); restore(&mut st, account); let through = tip(&st); let commitments: Vec<_> = records.iter().map(|record| record.payment.cmx).collect(); @@ -509,7 +509,7 @@ struct Mislabeled { calls: usize, } -impl NoteSource for Mislabeled { +impl NoteSource for Mislabeled { async fn note_data( &mut self, _: &W, @@ -527,7 +527,7 @@ async fn note_data_for_other_positions_is_missing_data() { let account = st.test_account().unwrap().id(); let paid = 1; let (record, note_data) = pay_restored_key(&mut st, paid, 0); - st.generate_and_scan_empty_blocks_with_dynamic_ivks(1); + st.generate_and_scan_empty_blocks_with_one_time_ivks(1); restore(&mut st, account); let through = tip(&st); let directory = Directory::new(publish( @@ -560,7 +560,7 @@ async fn note_data_for_other_positions_is_missing_data() { async fn a_wallet_without_payments_downloads_only_the_filters() { let mut st = ironwood_wallet(); let account = st.test_account().unwrap().id(); - st.generate_and_scan_empty_blocks_with_dynamic_ivks(1); + st.generate_and_scan_empty_blocks_with_one_time_ivks(1); restore(&mut st, account); let through = tip(&st); let directory = Directory::new(publish(&[], &[], through, &provider(&[]))); @@ -584,13 +584,13 @@ async fn a_wallet_without_payments_downloads_only_the_filters() { async fn a_newer_paid_filter_finds_a_payment_to_a_closed_key() { let mut st = ironwood_wallet(); let account = st.test_account().unwrap().id(); - st.generate_and_scan_empty_blocks_with_dynamic_ivks(1); + st.generate_and_scan_empty_blocks_with_one_time_ivks(1); restore(&mut st, account); let first = tip(&st); let directory = Directory::new(publish(&[], &[], first, &provider(&[]))); let mut notes = Notes::new(BTreeMap::new()); settle(&mut st, first, &directory, &mut notes).await; - assert!(st.wallet().get_dynamic_scanning_keys().unwrap().is_empty()); + assert!(st.wallet().get_one_time_scanning_keys().unwrap().is_empty()); // The closed key misses a payment, which the same publication cannot show. let (record, note_data) = pay_restored_key(&mut st, 0, 0); @@ -616,7 +616,7 @@ async fn a_newer_paid_filter_finds_a_payment_to_a_closed_key() { ); // A later publication still holds the paid key, but it is not looked up again. - st.generate_and_scan_empty_blocks_with_dynamic_ivks(1); + st.generate_and_scan_empty_blocks_with_one_time_ivks(1); let later = tip(&st); let directory = Directory::new(publish( std::slice::from_ref(&record), @@ -641,7 +641,7 @@ async fn only_recently_quoted_addresses_keep_scanning_after_their_sweep() { ] { let mut st = ironwood_wallet(); let account = st.test_account().unwrap().id(); - st.generate_and_scan_empty_blocks_with_dynamic_ivks(1); + st.generate_and_scan_empty_blocks_with_one_time_ivks(1); restore(&mut st, account); let through = tip(&st); let sets = if case == "quoted" { @@ -661,7 +661,7 @@ async fn only_recently_quoted_addresses_keep_scanning_after_their_sweep() { settle(&mut st, through, &directory, &mut notes).await; let mut scanning: Vec<_> = st .wallet() - .get_dynamic_scanning_keys() + .get_one_time_scanning_keys() .unwrap() .iter() .map(|k| k.key_id().index()) @@ -683,7 +683,7 @@ async fn a_seen_hit_outlives_a_rejected_payment_and_a_later_miss() { // A later output, which publication A's tree replaces. let (later_output, _) = pay_restored_key(&mut st, 1000, 1); let commitments = [record.payment.cmx, later_output.payment.cmx]; - st.generate_and_scan_empty_blocks_with_dynamic_ivks(1); + st.generate_and_scan_empty_blocks_with_one_time_ivks(1); restore(&mut st, account); let through = tip(&st); let seen = |st: &State| -> bool { @@ -752,7 +752,7 @@ struct FailFirstAttempt { impl WriteLock for FailFirstAttempt { fn write(&self, label: &'static str, write: impl FnOnce() -> T) -> T { let mut labels = self.labels.borrow_mut(); - if label == "dynamic_sweep.attempt" && !labels.contains(&label) { + if label == "one_time_sweep.attempt" && !labels.contains(&label) { self.fail.store(true, Ordering::SeqCst); } labels.push(label); @@ -768,7 +768,7 @@ async fn a_failed_attempt_write_stops_the_run() { let mut st = ironwood_wallet(); let account = st.test_account().unwrap().id(); let (record, note_data) = pay_restored_key(&mut st, 0, 0); - st.generate_and_scan_empty_blocks_with_dynamic_ivks(1); + st.generate_and_scan_empty_blocks_with_one_time_ivks(1); restore(&mut st, account); let through = tip(&st); let directory = Directory::new(publish( @@ -791,7 +791,7 @@ async fn a_failed_attempt_write_stops_the_run() { assert!(matches!(result, Err(Error::Wallet(_))), "{result:?}"); assert_eq!( *lock.labels.borrow(), - ["dynamic_sweep.due", "dynamic_sweep.attempt"] + ["one_time_sweep.due", "one_time_sweep.attempt"] ); assert!( directory.requests().iter().all(|r| !r.starts_with("POST")), @@ -807,10 +807,10 @@ async fn a_failed_attempt_write_stops_the_run() { async fn a_stale_tip_releases_nothing() { let mut st = ironwood_wallet(); let account = st.test_account().unwrap().id(); - st.generate_and_scan_empty_blocks_with_dynamic_ivks(1); + st.generate_and_scan_empty_blocks_with_one_time_ivks(1); restore(&mut st, account); let through = tip(&st); - st.generate_and_scan_empty_blocks_with_dynamic_ivks(1); + st.generate_and_scan_empty_blocks_with_one_time_ivks(1); let directory = Directory::new(publish(&[], &[], through, &provider(&[]))); let lock = Labels::default(); run(&mut st, through, &directory, &mut NoNotes, &lock, NOW) @@ -835,7 +835,7 @@ struct WriterBeforeRelease { impl WriteLock for WriterBeforeRelease { fn write(&self, label: &'static str, write: impl FnOnce() -> T) -> T { - if label != "dynamic_sweep.release" { + if label != "one_time_sweep.release" { return write(); } if let Some(other) = self.other.borrow_mut().take() { @@ -891,7 +891,7 @@ async fn release_runs_only_at_the_fully_scanned_tip() { let mut st = ironwood_wallet_in(TestDbFactory::file_backed()); let network = *st.network(); let account = st.test_account().unwrap().id(); - st.generate_and_scan_empty_blocks_with_dynamic_ivks(2); + st.generate_and_scan_empty_blocks_with_one_time_ivks(2); restore(&mut st, account); let through = tip(&st); let path = st.wallet().conn().path().unwrap().to_owned(); @@ -910,7 +910,7 @@ async fn release_runs_only_at_the_fully_scanned_tip() { && matches!( ctx.action, AuthAction::Read { - table_name: "ironwood_dynamic_spend_retention", + table_name: "ironwood_one_time_spend_retention", .. } ) @@ -942,7 +942,7 @@ async fn release_runs_only_at_the_fully_scanned_tip() { #[tokio::test] async fn seen_sets_of_another_network_are_refused() { let mut st = ironwood_wallet(); - st.generate_and_scan_empty_blocks_with_dynamic_ivks(1); + st.generate_and_scan_empty_blocks_with_one_time_ivks(1); let directory = Directory::new(publish(&[], &[], tip(&st), &provider(&[]))); let refused = fetch_seen(ORIGIN, &directory, [8; 32]).await; assert!( @@ -966,7 +966,7 @@ async fn seen_sets_of_another_network_are_refused() { async fn every_provider_with_sets_needs_a_seen_set() { let mut st = ironwood_wallet(); let account = st.test_account().unwrap().id(); - st.generate_and_scan_empty_blocks_with_dynamic_ivks(1); + st.generate_and_scan_empty_blocks_with_one_time_ivks(1); restore(&mut st, account); let through = tip(&st); // Provider `a` has its seen set only if `a_seen`. `b` has only a seen set, from a @@ -996,13 +996,13 @@ async fn every_provider_with_sets_needs_a_seen_set() { assert!( labels .iter() - .all(|l| ["dynamic_sweep.due", "dynamic_sweep.attempt"].contains(l)), + .all(|l| ["one_time_sweep.due", "one_time_sweep.attempt"].contains(l)), "{labels:?}" ); assert!( st.wallet() .db() - .dynamic_history_pending(account, through.height) + .one_time_history_pending(account, through.height) .unwrap() ); } @@ -1066,7 +1066,7 @@ async fn a_run_fatal_failure_stops_the_run_before_leasing_more_keys() { let records: Vec<_> = (0..2) .map(|index| pay_restored_key(&mut st, index, index).0) .collect(); - st.generate_and_scan_empty_blocks_with_dynamic_ivks(1); + st.generate_and_scan_empty_blocks_with_one_time_ivks(1); restore(&mut st, account); let through = tip(&st); let cmxs: Vec<_> = records.iter().map(|record| record.payment.cmx).collect(); @@ -1094,7 +1094,7 @@ async fn a_run_fatal_failure_stops_the_run_before_leasing_more_keys() { let due: Vec<_> = st .wallet_mut() .db_mut() - .prepare_dynamic_sweeps(account, through, NOW, NonZeroU32::new(64).unwrap()) + .prepare_one_time_sweeps(account, through, NOW, NonZeroU32::new(64).unwrap()) .unwrap() .unwrap() .iter() @@ -1114,7 +1114,7 @@ async fn a_publication_off_the_wallets_chain_is_refused_before_any_lookup() { let mut st = ironwood_wallet(); let account = st.test_account().unwrap().id(); let (record, note_data) = pay_restored_key(&mut st, 0, 0); - st.generate_and_scan_empty_blocks_with_dynamic_ivks(1); + st.generate_and_scan_empty_blocks_with_one_time_ivks(1); restore(&mut st, account); let through = tip(&st); let publication = if off_chain { @@ -1146,7 +1146,7 @@ async fn a_publication_off_the_wallets_chain_is_refused_before_any_lookup() { assert!( st.wallet() .db() - .dynamic_history_pending(account, through.height) + .one_time_history_pending(account, through.height) .unwrap() ); for (now, requests) in [(NOW, 1), (NOW + 60, 2)] { @@ -1300,7 +1300,7 @@ async fn a_deferred_candidate_takes_each_publications_current_answer() { let account = st.test_account().unwrap().id(); let key = KeyId::new(Purpose::Receive, 0); let (record, note_data) = pay_restored_key(&mut st, key.index(), 0); - st.generate_and_scan_empty_blocks_with_dynamic_ivks(1); + st.generate_and_scan_empty_blocks_with_one_time_ivks(1); restore(&mut st, account); let mut through = tip(&st); let mut first = record.clone(); @@ -1310,7 +1310,7 @@ async fn a_deferred_candidate_takes_each_publications_current_answer() { AwaitingScan => { st.generate_empty_block(); let (top, _) = st.generate_empty_block(); - st.scan_cached_blocks_with_dynamic_ivks(top, 1); + st.scan_cached_blocks_with_one_time_ivks(top, 1); let block = st.wallet().block_metadata(top).unwrap().unwrap(); through = ChainPoint { height: top, @@ -1351,7 +1351,7 @@ async fn a_deferred_candidate_takes_each_publications_current_answer() { ); assert!(swept.pending); if deferral == AwaitingScan { - st.scan_cached_blocks_with_dynamic_ivks(through.height - 1, 1); + st.scan_cached_blocks_with_one_time_ivks(through.height - 1, 1); } // Publication B, after the attempt's backoff. @@ -1390,7 +1390,7 @@ async fn an_unchanged_answer_reuses_queued_note_data() { let mut st = ironwood_wallet(); let account = st.test_account().unwrap().id(); let (record, note_data) = pay_restored_key(&mut st, 0, 0); - st.generate_and_scan_empty_blocks_with_dynamic_ivks(1); + st.generate_and_scan_empty_blocks_with_one_time_ivks(1); restore(&mut st, account); let through = tip(&st); st.wallet() @@ -1445,7 +1445,7 @@ async fn each_run_sweeps_one_batch_and_later_runs_progress() { let account = st.test_account().unwrap().id(); let paid = 100; let (record, note_data) = pay_restored_key(&mut st, paid, 0); - st.generate_and_scan_empty_blocks_with_dynamic_ivks(1); + st.generate_and_scan_empty_blocks_with_one_time_ivks(1); restore(&mut st, account); let through = tip(&st); // The set is finite, so an unbounded run stops past it instead of hanging. From e3a8f7b6c089bef56067ef29d8d5bf2e48b33da4 Mon Sep 17 00:00:00 2001 From: Adam Tucker Date: Sun, 11 Oct 2026 21:09:18 -0600 Subject: [PATCH 36/40] Run receiver sweeps over the one-time IVK restore traits `sweep` and `NoteSource` now bound on the backend's `one_time_ivk::restore` traits, which hold the directory restore protocol, and `RESTORE_WATCH_SECS` comes from the one-time IVK crate's `swap` module. The `wallet` feature enables the backend's `one-time-ivk` feature instead of `orchard`, and the tests use the SQLite store's `one-time-ivk` feature and its `swap` module. The test wallets register their incoming lookahead before they scan, as a restore does, since ordinary scanning now keeps Ironwood spend evidence only for a wallet holding one-time IVK state. The READMEs, CHANGELOG and rustdoc name the restore traits and the feature. Co-Authored-By: Claude Opus 5.5 --- Cargo.lock | 2 +- zakura/one-time-ivk/README.md | 4 +-- zakura/pir-receiver/CHANGELOG.md | 6 ++--- zakura/pir-receiver/Cargo.toml | 6 ++--- zakura/pir-receiver/README.md | 8 +++--- zakura/pir-receiver/src/lib.rs | 10 ++++---- zakura/pir-receiver/src/sweep.rs | 40 +++++++++++++++--------------- zakura/pir-receiver/tests/sweep.rs | 32 ++++++++++++++++-------- 8 files changed, 59 insertions(+), 49 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 0cfb7571..44a4b424 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -7950,9 +7950,9 @@ dependencies = [ "zakura-address", "zakura-bls12-381", "zakura-client-backend", - "zakura-one-time-ivk", "zakura-jubjub", "zakura-keys", + "zakura-one-time-ivk", "zakura-orchard", "zakura-pasta-curves", "zakura-pczt", diff --git a/zakura/one-time-ivk/README.md b/zakura/one-time-ivk/README.md index 5bbad6b4..3989640f 100644 --- a/zakura/one-time-ivk/README.md +++ b/zakura/one-time-ivk/README.md @@ -25,8 +25,8 @@ replace recipient, nullifier, signature, value, or change validation. The wallet owns reservation, persistence, coverage, lifecycle, and note selection. There is no alternate balance store in this crate. -The SQLite backend implements this contract with its `orchard` feature: durable -key registration, scanning, completion, and the restore sweeps that +The SQLite backend implements this contract with its `one-time-ivk` feature: +durable key registration, scanning, completion, and the restore sweeps that `zakura-pir-receiver` runs. Its rustdoc, starting at `zcash_client_sqlite::wallet::one_time_ivk`, documents the calls a wallet makes. diff --git a/zakura/pir-receiver/CHANGELOG.md b/zakura/pir-receiver/CHANGELOG.md index 9a2a801d..33ab571a 100644 --- a/zakura/pir-receiver/CHANGELOG.md +++ b/zakura/pir-receiver/CHANGELOG.md @@ -6,8 +6,8 @@ - `sweep`, behind the `wallet` feature, which runs one account's receiver-directory restore sweeps of its one-time keys for any store implementing the backend's - `OneTimeIvkWrite`, with `Transport`, `NoteSource`, `EnhanceNotes`, `WriteLock`, - `Swept`, `Error` and `MAINNET_GENESIS`. The receiver PIR client moved here from - Vizor. + `one_time_ivk::restore::OneTimeIvkRestoreWrite`, with `Transport`, `NoteSource`, + `EnhanceNotes`, `WriteLock`, `Swept`, `Error` and `MAINNET_GENESIS`. The receiver PIR + client moved here from Vizor. `wallet` enables the backend's `one-time-ivk` feature. - `fetch_seen` and `Seen`: a publication's swap provider seen sets, which a wallet checks before issuing a swap address. They need no `wallet` feature. diff --git a/zakura/pir-receiver/Cargo.toml b/zakura/pir-receiver/Cargo.toml index e48d9aea..ab17dbe6 100644 --- a/zakura/pir-receiver/Cargo.toml +++ b/zakura/pir-receiver/Cargo.toml @@ -19,7 +19,7 @@ futures-util = { version = "0.3", optional = true } thiserror = { version = "2", optional = true } zakura-pir-enhance = { version = "=0.0.1-rc1", path = "../pir-enhance", features = ["wallet"], optional = true } zakura-one-time-ivk = { version = "=0.0.1-rc0", path = "../one-time-ivk", optional = true } -zcash_client_backend = { workspace = true, features = ["orchard"], optional = true } +zcash_client_backend = { workspace = true, features = ["one-time-ivk"], optional = true } zcash_protocol = { workspace = true, optional = true } # The receiver crates come from wallet-pir's `main`. receiver-directory = { git = "https://github.com/valargroup/wallet-pir", rev = "9d2cbda0b4336258cfce76ca8ee77b095daa7fd7" } @@ -37,8 +37,8 @@ serde_json.workspace = true sha2.workspace = true tokio = { workspace = true, features = ["macros", "rt"] } tower = { workspace = true, features = ["util"] } -zcash_client_backend = { workspace = true, features = ["orchard", "test-dependencies"] } -zcash_client_sqlite = { path = "../../librustzcash/zcash_client_sqlite", package = "zakura-client-sqlite", features = ["orchard", "test-dependencies"] } +zcash_client_backend = { workspace = true, features = ["one-time-ivk", "test-dependencies"] } +zcash_client_sqlite = { path = "../../librustzcash/zcash_client_sqlite", package = "zakura-client-sqlite", features = ["one-time-ivk", "test-dependencies"] } zcash_note_encryption.workspace = true zcash_primitives.workspace = true diff --git a/zakura/pir-receiver/README.md b/zakura/pir-receiver/README.md index a70b49f4..4b379007 100644 --- a/zakura/pir-receiver/README.md +++ b/zakura/pir-receiver/README.md @@ -4,10 +4,10 @@ A restored wallet finds payments to its one-time keys by looking each one up in receiver directory over PIR. `fetch_seen` downloads the swap provider seen sets that a wallet checks before issuing a swap address. With the `wallet` feature, `sweep` runs one account's restore sweeps for any wallet store that implements the backend's -one-time IVK traits (`zcash_client_backend::data_api::one_time_ivk`), such as -`zakura-client-sqlite`. Its rustdoc states which publications a run accepts, when it -sends requests, which keys keep scanning or are checked again, and how failures are -retried. +restore traits (`zcash_client_backend::data_api::one_time_ivk::restore`, from its +`one-time-ivk` feature), such as `zakura-client-sqlite`. Its rustdoc states which +publications a run accepts, when it sends requests, which keys keep scanning or are +checked again, and how failures are retried. Applications supply: diff --git a/zakura/pir-receiver/src/lib.rs b/zakura/pir-receiver/src/lib.rs index fc295593..a51ab013 100644 --- a/zakura/pir-receiver/src/lib.rs +++ b/zakura/pir-receiver/src/lib.rs @@ -10,11 +10,11 @@ //! //! `fetch_seen` downloads the swap provider seen sets that a wallet checks before //! issuing a swap address. The `wallet` feature adds `sweep`, which runs these lookups -//! for any wallet store that implements the backend's `OneTimeIvkWrite`. The caller -//! supplies transports, which carry its route policy, cancellation and timeouts, and a -//! `WriteLock` that serializes wallet writes with its other writers. Dropping a `sweep` -//! future at any await is safe: each wallet step commits on its own, and every begun -//! attempt is already backed off. +//! for any wallet store that implements the backend's `OneTimeIvkRestoreWrite`. The +//! caller supplies transports, which carry its route policy, cancellation and timeouts, +//! and a `WriteLock` that serializes wallet writes with its other writers. Dropping a +//! `sweep` future at any await is safe: each wallet step commits on its own, and every +//! begun attempt is already backed off. mod seen; #[cfg(feature = "wallet")] diff --git a/zakura/pir-receiver/src/sweep.rs b/zakura/pir-receiver/src/sweep.rs index dd38ad59..314d6727 100644 --- a/zakura/pir-receiver/src/sweep.rs +++ b/zakura/pir-receiver/src/sweep.rs @@ -10,7 +10,7 @@ use receiver_directory::{ witness::WitnessSnapshot, }; use receiver_pir::{AcceptedCoverage, transport::DirectoryClient}; -use zakura_one_time_ivk::{KeyId, lifecycle::RESTORE_WATCH_SECS}; +use zakura_one_time_ivk::{KeyId, swap::RESTORE_WATCH_SECS}; use zakura_pir_enhance::{ ClientError, ClientResourceLimits, transport::{Client, PendingClient, Transport as EnhanceTransport}, @@ -19,9 +19,9 @@ use zakura_pir_enhance::{ use zcash_client_backend::data_api::{ WalletRead, enhance_pir::EnhancePirRead, - one_time_ivk::{ - DirectoryPayment, DiscoveryWork, OneTimeIvkRead, OneTimeIvkWrite, PaymentApplication, - SweepDeferral, + one_time_ivk::restore::{ + DirectoryPayment, DiscoveryWork, OneTimeIvkRestoreRead, OneTimeIvkRestoreWrite, + PaymentApplication, SweepDeferral, }, transparent_ledger::ChainPoint, }; @@ -79,7 +79,7 @@ pub trait WriteLock { /// Supplies the note data that payments a lookup found need. #[allow(async_fn_in_trait)] -pub trait NoteSource { +pub trait NoteSource { /// The 528 ciphertext bytes after the directory's prefix for each of `positions`, /// keyed by commitment tree position. `wallet` is the wallet being swept. /// `positions` is never empty when a sweep calls this. [`Error::NotesUnavailable`] @@ -120,7 +120,7 @@ impl NoteSource for EnhanceNotes<'_, T, P> where T: EnhanceTransport, P: Parameters, - W: OneTimeIvkRead + EnhancePirRead, + W: OneTimeIvkRestoreRead + EnhancePirRead, { async fn note_data( &mut self, @@ -178,8 +178,8 @@ pub struct Swept { /// Runs `account`'s due restore sweeps against the receiver directory at `origin`, /// then releases Ironwood spend evidence that no sweep needs any longer (see -/// [`OneTimeIvkWrite::finish_one_time_nullifier_recovery`]). A wallet with several -/// accounts calls it once for each. +/// [`OneTimeIvkRestoreWrite::finish_one_time_nullifier_recovery`]). A wallet with +/// several accounts calls it once for each. /// /// `through` is the wallet's fully scanned tip, and sweeps and release run only while /// it is also the stored chain tip and the block the wallet scanned there, which @@ -187,26 +187,26 @@ pub struct Swept { /// When none is due, the run makes no request. Otherwise the publication must commit /// to `genesis` ([`MAINNET_GENESIS`](crate::MAINNET_GENESIS) on mainnet), cover /// history from Ironwood activation on `params`' network, and end at a block the -/// wallet accepts (see [`OneTimeIvkRead::directory_publication_anchor`]) with the -/// wallet's Ironwood tree size there, and have a dated seen set for at least one swap +/// wallet accepts (see [`OneTimeIvkRestoreRead::directory_publication_anchor`]) with +/// the wallet's Ironwood tree size there, and have a dated seen set for at least one swap /// provider and for every provider with a set, as for [`fetch_seen`](crate::fetch_seen). /// /// Each key's receiver is first tested against the publication's filters, which every /// wallet downloads alike: only a receiver in the paid set is looked up over PIR, and /// one in a swap provider's seen set is never issued again (see -/// [`OneTimeIvkWrite::begin_one_time_sweep_attempt`]). A key whose receiver a provider's -/// recent set holds keeps scanning for the wallet's restore watch, and any other closes -/// (see [`OneTimeIvkWrite::apply_one_time_sweep`]). A recent set can rule a receiver out -/// only if it is current and complete: its feed's last read began at most fifteen +/// [`OneTimeIvkRestoreWrite::begin_one_time_sweep_attempt`]). A key whose receiver a +/// provider's recent set holds keeps scanning for the wallet's restore watch, and any +/// other closes (see [`OneTimeIvkRestoreWrite::apply_one_time_sweep`]). A recent set can +/// rule a receiver out only if it is current and complete: its feed's last read began at most fifteen /// minutes before `now`, and its window, with the feed running throughout, covers /// [`RESTORE_WATCH_SECS`]. Unless every provider has such a set, every key keeps /// scanning. /// /// After releasing, the run checks closed keys with no known payment against the /// publication's paid filter once per publication (see -/// [`OneTimeIvkWrite::record_paid_filter_check`]), so a payment the publication shows -/// after such a key closed is swept on a later run. While any such key exists, every -/// run fetches the manifest; it fetches the filters only for a newer publication. +/// [`OneTimeIvkRestoreWrite::record_paid_filter_check`]), so a payment the publication +/// shows after such a key closed is swept on a later run. While any such key exists, +/// every run fetches the manifest; it fetches the filters only for a newer publication. /// /// A run that looks nothing up opens no PIR session and fetches no witnesses. New /// payments a lookup finds take their note data from `notes`. A run sweeps at most one @@ -231,7 +231,7 @@ pub async fn sweep( now: i64, ) -> Result>, Error>> where - W: OneTimeIvkWrite, + W: OneTimeIvkRestoreWrite, P: Parameters, T: Transport, N: NoteSource, @@ -401,7 +401,7 @@ struct Session { impl<'a, T: Transport> Directory<'a, T> { /// Accepts the publication `origin` serves, at the block of the wallet's chain it /// ends at. - async fn accept( + async fn accept( wallet: &W, params: &P, through: ChainPoint, @@ -566,7 +566,7 @@ async fn sweep_key( lock: &L, ) -> Result<(), Error>> where - W: OneTimeIvkWrite, + W: OneTimeIvkRestoreWrite, T: Transport, N: NoteSource, L: WriteLock, diff --git a/zakura/pir-receiver/tests/sweep.rs b/zakura/pir-receiver/tests/sweep.rs index c4b36c8b..98ec9ecc 100644 --- a/zakura/pir-receiver/tests/sweep.rs +++ b/zakura/pir-receiver/tests/sweep.rs @@ -28,7 +28,7 @@ use receiver_pir_server::{Publication, Publications, router_with_publications}; use rusqlite::hooks::{AuthAction, AuthContext, Authorization}; use sha2::{Digest as _, Sha256}; use tower::ServiceExt as _; -use zakura_one_time_ivk::lifecycle::RESTORE_WATCH_SECS; +use zakura_one_time_ivk::swap::RESTORE_WATCH_SECS; use zakura_pir_enhance::{ ClientError, QueryBinding, ShardSession, transport::{Request as EnhanceRequest, ResponseBody, Transport as EnhanceTransport}, @@ -40,7 +40,10 @@ use zakura_pir_receiver::{ use zcash_client_backend::data_api::{ Account as _, WalletRead as _, WalletWrite as _, chain::BlockSource as _, - one_time_ivk::{OneTimeIvkRead, OneTimeIvkWrite as _, PaymentApplication}, + one_time_ivk::{ + OneTimeIvkRead as _, + restore::{OneTimeIvkRestoreRead, OneTimeIvkRestoreWrite as _, PaymentApplication}, + }, testing::{AddressType, IronwoodFvk, TestBuilder, TestRng, TestState}, transparent_ledger::ChainPoint, }; @@ -52,7 +55,10 @@ use zcash_client_sqlite::{ db::{TestDb, TestDbFactory}, }, util::{SystemClock, testing::FixedClock}, - wallet::one_time_ivk::{KeyId, ProviderSeen, Purpose, RECEIVE_GAP_LIMIT}, + wallet::one_time_ivk::{ + KeyId, Purpose, + swap::{ProviderSeen, RECEIVE_GAP_LIMIT}, + }, }; use zcash_note_encryption::{Domain as _, try_compact_note_decryption}; use zcash_primitives::block::BlockHash; @@ -85,10 +91,11 @@ fn ironwood_wallet() -> State { ironwood_wallet_in(TestDbFactory::default()) } -/// [`ironwood_wallet`] stored as `factory` makes it. +/// [`ironwood_wallet`] stored as `factory` makes it. Its incoming lookahead is registered +/// before it scans, as a restore does, so scanning keeps the spend evidence sweeps need. fn ironwood_wallet_in(factory: TestDbFactory) -> State { let activation = BlockHeight::from_u32(ACTIVATION); - TestBuilder::new() + let mut st = TestBuilder::new() .with_network(LocalNetwork { nu6: Some(activation), nu6_1: Some(activation), @@ -99,7 +106,10 @@ fn ironwood_wallet_in(factory: TestDbFactory) -> State { .with_data_store_factory(factory) .with_block_cache(BlockCache::new()) .with_account_from_sapling_activation(BlockHash([0; 32])) - .build() + .build(); + let account = st.test_account().unwrap().id(); + restore(&mut st, account); + st } /// The wallet's fully scanned tip. @@ -118,8 +128,8 @@ fn receiver(st: &State, index: u64) -> Receiver { Receiver::from_bytes(fvk.address_at(0u32, Scope::External).to_raw_address_bytes()).unwrap() } -/// Pays incoming one-time key `index` in a new scanned block, before any restore has -/// registered the key, so scanning cannot find it. Returns the payment as the +/// Pays incoming one-time key `index` in a new scanned block. Scanning cannot find it: a +/// restored key is swept before it is ever scanned. Returns the payment as the /// directory lists it and its note data. The output is the chain's Ironwood leaf at /// `position`, which is the number of earlier Ironwood outputs. fn pay_restored_key(st: &mut State, index: u64, position: u64) -> (Record, [u8; 528]) { @@ -328,7 +338,7 @@ impl Notes { } } -impl NoteSource for Notes { +impl NoteSource for Notes { async fn note_data( &mut self, _: &W, @@ -345,7 +355,7 @@ impl NoteSource for Notes { /// A note source that cannot serve the wallet's chain. struct NoNotes; -impl NoteSource for NoNotes { +impl NoteSource for NoNotes { async fn note_data( &mut self, _: &W, @@ -509,7 +519,7 @@ struct Mislabeled { calls: usize, } -impl NoteSource for Mislabeled { +impl NoteSource for Mislabeled { async fn note_data( &mut self, _: &W, From 8673c1a80fc7b26d830033b237f332ad8c57accf Mon Sep 17 00:00:00 2001 From: Adam Tucker Date: Sun, 11 Oct 2026 21:17:31 -0600 Subject: [PATCH 37/40] List the receiver PIR lane in the development guide The `pir-receiver` lane and its exclusion from `default` were missing from the configuration table. Co-Authored-By: Claude Opus 5.5 --- docs/development.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/docs/development.md b/docs/development.md index de5b8866..60f00e29 100644 --- a/docs/development.md +++ b/docs/development.md @@ -18,7 +18,7 @@ python3 scripts/dev.py verify | Configuration | Packages and features | | --- | --- | -| `default` | Workspace except the facade, Enhance PIR and the transparent PIR adapter; default features | +| `default` | Workspace except the facade, Enhance PIR, the transparent PIR adapter and receiver PIR; default features | | `orchard` | Backend + SQLite; `orchard,test-dependencies` | | `transparent` | Backend + SQLite; `orchard,transparent-inputs,test-dependencies,unstable` | | `transparent-import` | Backend + SQLite; transparent features plus `transparent-key-import` | @@ -29,6 +29,7 @@ python3 scripts/dev.py verify | `transparent-pir` | Transparent PIR adapter; `wallet` | | `transparent-pir-sqlite` | Transparent PIR adapter; `sqlite` (`wallet` plus the SQLite apply-and-acknowledge integration and the in-process end-to-end tests) | | `transparent-pir-testing` | Transparent PIR adapter; `sqlite` and `testing` (the test support applications build their recovery and txid display tests on) | +| `pir-receiver` | Receiver PIR; `wallet` (which enables the backend's `one-time-ivk`; its tests use SQLite with `one-time-ivk`) | `default` builds the backend and SQLite crates without Orchard or transparent inputs, which `orchard` and `transparent` test, so items used only under those From 0d24519895c5de0375652834dd7410bfcafe736d Mon Sep 17 00:00:00 2001 From: Adam Tucker Date: Mon, 12 Oct 2026 11:16:26 -0600 Subject: [PATCH 38/40] Expect no sweep work while the wallet is behind its scanned tip The store now prepares no restore sweep until the wallet has scanned every block up to its chain tip, so a run behind it can no longer defer a key with AwaitingScan. A new test checks that such a run makes no request and leases nothing, and that the sweep runs at once, without backoff, after scanning catches up. The deferral test now covers a corrected answer after an AwaitingSpendHistory deferral, and checks that the replay it queued holds the next attempt without a lookup; the note-data reuse test scans that replay before its second attempt. Co-Authored-By: Claude Opus 5.5 --- zakura/pir-receiver/tests/sweep.rs | 151 ++++++++++++++++++----------- 1 file changed, 97 insertions(+), 54 deletions(-) diff --git a/zakura/pir-receiver/tests/sweep.rs b/zakura/pir-receiver/tests/sweep.rs index 98ec9ecc..ccc84a9f 100644 --- a/zakura/pir-receiver/tests/sweep.rs +++ b/zakura/pir-receiver/tests/sweep.rs @@ -1294,15 +1294,82 @@ async fn enhance_notes_follow_the_wallets_chain_and_fresh_routing() { assert_eq!(enhance.queries.get(), 0); } +/// The receiver lookups `directory` has served. +fn lookups(directory: &Directory) -> usize { + let requests = directory.requests(); + requests + .iter() + .filter(|r| *r == "POST /v1/receiver/query") + .count() +} + +/// Scans every range the wallet has queued, such as a spend-history replay. +fn scan_queued(st: &mut State) { + for range in st.wallet().suggest_scan_ranges().unwrap() { + let blocks = range.block_range(); + let count = u32::from(blocks.end) - u32::from(blocks.start); + st.scan_cached_blocks_with_one_time_ivks(blocks.start, count as usize); + } +} + +/// While a block below the wallet's chain tip is unscanned, a run prepares no sweep: it +/// makes no request and leases no attempt, since any payment it found could only wait +/// for scanning. Once scanning catches up, the sweep runs at once, without backoff. +#[tokio::test] +async fn a_run_behind_the_fully_scanned_tip_leases_and_requests_nothing() { + let mut st = ironwood_wallet(); + let account = st.test_account().unwrap().id(); + let (record, note_data) = pay_restored_key(&mut st, 0, 0); + st.generate_and_scan_empty_blocks_with_one_time_ivks(1); + restore(&mut st, account); + // A block below the tip is not scanned yet. + st.generate_empty_block(); + let (top, _) = st.generate_empty_block(); + st.scan_cached_blocks_with_one_time_ivks(top, 1); + let block = st.wallet().block_metadata(top).unwrap().unwrap(); + let through = ChainPoint { + height: top, + hash: block.block_hash(), + }; + let mut notes = Notes::new(BTreeMap::from([(0, note_data)])); + let records = std::slice::from_ref(&record); + let directory = Directory::new(publish( + records, + &[record.payment.cmx], + through, + &provider(&[]), + )); + let swept = run(&mut st, through, &directory, &mut notes, &NoLock, NOW) + .await + .unwrap(); + assert!(swept.deferred.is_empty(), "{:?}", swept.deferred); + assert!(swept.pending); + assert!( + directory.requests().is_empty(), + "{:?}", + directory.requests() + ); + st.scan_cached_blocks_with_one_time_ivks(top - 1, 1); + let swept = run(&mut st, through, &directory, &mut notes, &NoLock, NOW) + .await + .unwrap(); + assert!(swept.deferred.is_empty(), "{:?}", swept.deferred); + settle(&mut st, through, &directory, &mut notes).await; + assert_eq!( + st.get_total_balance(account), + Zatoshis::const_from_u64(VALUE) + ); +} + /// A queued candidate that defers is replaced by each later attempt's answer from the /// publication it uses: a corrected payment is recovered, and a paid-filter miss drops -/// the candidate, whatever made it wait. +/// the candidate, whatever made it wait. A deferral that queued a replay holds the next +/// attempt, without a request, until the replay is scanned. #[tokio::test] async fn a_deferred_candidate_takes_each_publications_current_answer() { - use PaymentApplication::{AwaitingScan, AwaitingSpendHistory, AwaitingWitness}; + use PaymentApplication::{AwaitingSpendHistory, AwaitingWitness}; for (deferral, corrected) in [ - (AwaitingScan, true), - (AwaitingScan, false), + (AwaitingSpendHistory, true), (AwaitingWitness, false), (AwaitingSpendHistory, false), ] { @@ -1312,40 +1379,18 @@ async fn a_deferred_candidate_takes_each_publications_current_answer() { let (record, note_data) = pay_restored_key(&mut st, key.index(), 0); st.generate_and_scan_empty_blocks_with_one_time_ivks(1); restore(&mut st, account); - let mut through = tip(&st); + let through = tip(&st); let mut first = record.clone(); - let commitments = [record.payment.cmx]; - match deferral { - // A block below the tip is not scanned yet. - AwaitingScan => { - st.generate_empty_block(); - let (top, _) = st.generate_empty_block(); - st.scan_cached_blocks_with_one_time_ivks(top, 1); - let block = st.wallet().block_metadata(top).unwrap().unwrap(); - through = ChainPoint { - height: top, - hash: block.block_hash(), - }; - if corrected { - // Publication A misstates the payment's transaction. - first.payment.tx_index += 1; - } - } - AwaitingWitness => { - st.wallet() - .conn() - .execute("DELETE FROM ironwood_tree_checkpoints", []) - .unwrap(); - } - _ => { - st.wallet() - .conn() - .execute_batch( - "DELETE FROM nullifier_map; DELETE FROM ironwood_nullifier_scan_blocks;", - ) - .unwrap(); - } + if corrected { + // Publication A misstates the payment's transaction. + first.payment.tx_index += 1; } + let commitments = [record.payment.cmx]; + let wipe = match deferral { + AwaitingWitness => "DELETE FROM ironwood_tree_checkpoints", + _ => "DELETE FROM nullifier_map; DELETE FROM ironwood_nullifier_scan_blocks;", + }; + st.wallet().conn().execute_batch(wipe).unwrap(); let mut notes = Notes::new(BTreeMap::from([(0, note_data)])); let a = Directory::new(publish(&[first], &commitments, through, &provider(&[]))); let swept = run(&mut st, through, &a, &mut notes, &NoLock, NOW) @@ -1360,15 +1405,12 @@ async fn a_deferred_candidate_takes_each_publications_current_answer() { swept.deferred ); assert!(swept.pending); - if deferral == AwaitingScan { - st.scan_cached_blocks_with_one_time_ivks(through.height - 1, 1); - } // Publication B, after the attempt's backoff. let b = if corrected { publish( std::slice::from_ref(&record), - &[record.payment.cmx], + &commitments, through, &provider(&[]), ) @@ -1376,6 +1418,14 @@ async fn a_deferred_candidate_takes_each_publications_current_answer() { publish(&[], &commitments, through, &provider(&[])) }; let b = Directory::new(b); + if deferral == AwaitingSpendHistory { + let swept = run(&mut st, through, &b, &mut notes, &NoLock, NOW + 60) + .await + .unwrap(); + assert!(swept.deferred.is_empty(), "{:?}", swept.deferred); + assert_eq!(lookups(&b), 0, "{:?}", b.requests()); + scan_queued(&mut st); + } let swept = run(&mut st, through, &b, &mut notes, &NoLock, NOW + 60) .await .unwrap(); @@ -1425,24 +1475,17 @@ async fn an_unchanged_answer_reuses_queued_note_data() { "{:?}", swept.deferred ); - // The replay the deferral queued is still unscanned. + // Once the replay the deferral queued is scanned, the next attempt applies it. + scan_queued(&mut st); let swept = run(&mut st, through, &directory, &mut notes, &NoLock, NOW + 60) .await .unwrap(); - assert!( - matches!( - &swept.deferred[..], - [(_, Error::Queued(PaymentApplication::AwaitingScan))] - ), - "{:?}", - swept.deferred + assert!(swept.deferred.is_empty(), "{:?}", swept.deferred); + assert_eq!( + st.get_total_balance(account), + Zatoshis::const_from_u64(VALUE) ); - let posts = directory - .requests() - .iter() - .filter(|r| *r == "POST /v1/receiver/query") - .count(); - assert!(posts >= 2, "{:?}", directory.requests()); + assert!(lookups(&directory) >= 2, "{:?}", directory.requests()); assert_eq!(notes.requested, [vec![record.payment.position]]); } From bb884dbd601dc542eb439c3c6a252132b9b55895 Mon Sep 17 00:00:00 2001 From: Adam Tucker Date: Mon, 12 Oct 2026 11:56:05 -0600 Subject: [PATCH 39/40] Point sweep's doc at the trait methods that own each contract The doc restated what begin_one_time_sweep_attempt, apply_one_time_sweep, record_paid_filter_check and finish_one_time_nullifier_recovery already document. It now keeps what only sweep does (run gating, the publication it accepts, how it reads the recent sets, batching and what stops a run) and links to the rest. NoteSource::note_data points at sweep instead of restating when a run stops. Co-Authored-By: Claude Opus 5.5 --- zakura/pir-receiver/src/sweep.rs | 67 +++++++++++++------------------- 1 file changed, 28 insertions(+), 39 deletions(-) diff --git a/zakura/pir-receiver/src/sweep.rs b/zakura/pir-receiver/src/sweep.rs index 314d6727..b7f5525f 100644 --- a/zakura/pir-receiver/src/sweep.rs +++ b/zakura/pir-receiver/src/sweep.rs @@ -83,7 +83,7 @@ pub trait NoteSource { /// The 528 ciphertext bytes after the directory's prefix for each of `positions`, /// keyed by commitment tree position. `wallet` is the wallet being swept. /// `positions` is never empty when a sweep calls this. [`Error::NotesUnavailable`] - /// stops the run before it leases more keys. + /// stops the run (see [`sweep`]). async fn note_data( &mut self, wallet: &W, @@ -176,47 +176,36 @@ pub struct Swept { pub pending: bool, } -/// Runs `account`'s due restore sweeps against the receiver directory at `origin`, -/// then releases Ironwood spend evidence that no sweep needs any longer (see -/// [`OneTimeIvkRestoreWrite::finish_one_time_nullifier_recovery`]). A wallet with -/// several accounts calls it once for each. +/// Runs `account`'s due restore sweeps (see [`OneTimeIvkRestoreWrite`]) against the +/// receiver directory at `origin`, releases the spend evidence they no longer need (see +/// [`OneTimeIvkRestoreWrite::finish_one_time_nullifier_recovery`]), and checks closed keys +/// against each newer publication's paid filter (see +/// [`OneTimeIvkRestoreWrite::record_paid_filter_check`]). A wallet with several accounts +/// calls it once for each. /// -/// `through` is the wallet's fully scanned tip, and sweeps and release run only while -/// it is also the stored chain tip and the block the wallet scanned there, which -/// release checks again under its write lock along with the fully scanned height. -/// When none is due, the run makes no request. Otherwise the publication must commit -/// to `genesis` ([`MAINNET_GENESIS`](crate::MAINNET_GENESIS) on mainnet), cover -/// history from Ironwood activation on `params`' network, and end at a block the -/// wallet accepts (see [`OneTimeIvkRestoreRead::directory_publication_anchor`]) with -/// the wallet's Ironwood tree size there, and have a dated seen set for at least one swap -/// provider and for every provider with a set, as for [`fetch_seen`](crate::fetch_seen). +/// Nothing runs unless `through`, the wallet's fully scanned tip, is also its stored chain +/// tip and the block it scanned there, which release checks again under its write lock +/// with the fully scanned height. A run with nothing due or to check makes no request. +/// Otherwise the publication must commit to `genesis` +/// ([`MAINNET_GENESIS`](crate::MAINNET_GENESIS) on mainnet), cover history from Ironwood +/// activation on `params`' network, end at an anchor the wallet accepts (see +/// [`OneTimeIvkRestoreRead::directory_publication_anchor`]) with the wallet's Ironwood +/// tree size there, and have the seen sets [`fetch_seen`](crate::fetch_seen) requires. /// -/// Each key's receiver is first tested against the publication's filters, which every -/// wallet downloads alike: only a receiver in the paid set is looked up over PIR, and -/// one in a swap provider's seen set is never issued again (see -/// [`OneTimeIvkRestoreWrite::begin_one_time_sweep_attempt`]). A key whose receiver a -/// provider's recent set holds keeps scanning for the wallet's restore watch, and any -/// other closes (see [`OneTimeIvkRestoreWrite::apply_one_time_sweep`]). A recent set can -/// rule a receiver out only if it is current and complete: its feed's last read began at most fifteen -/// minutes before `now`, and its window, with the feed running throughout, covers -/// [`RESTORE_WATCH_SECS`]. Unless every provider has such a set, every key keeps -/// scanning. +/// Only receivers in the paid filter are looked up over PIR, with note data from `notes`. +/// The seen and recent sets give each key's `seen` and `recent` (see +/// [`OneTimeIvkRestoreWrite::begin_one_time_sweep_attempt`] and +/// [`OneTimeIvkRestoreWrite::apply_one_time_sweep`]). A provider's recent set counts only +/// if its feed's last read began at most fifteen minutes before `now` and its window, with +/// the feed running throughout, covers [`RESTORE_WATCH_SECS`]; unless every provider has +/// one, every key is recent. /// -/// After releasing, the run checks closed keys with no known payment against the -/// publication's paid filter once per publication (see -/// [`OneTimeIvkRestoreWrite::record_paid_filter_check`]), so a payment the publication -/// shows after such a key closed is swept on a later run. While any such key exists, -/// every run fetches the manifest; it fetches the filters only for a newer publication. -/// -/// A run that looks nothing up opens no PIR session and fetches no witnesses. New -/// payments a lookup finds take their note data from `notes`. A run sweeps at most one -/// batch of due keys, then moves the incoming lookahead past paid and seen indices; its -/// new keys are left for a later run, and [`Swept::pending`] reports them. A key that -/// fails is backed off and listed in [`Swept::deferred`], and the others go on, unless -/// the directory's session or connection failed or `notes` cannot serve the wallet's -/// chain: then the run stops before leasing more keys. A lease the wallet fails to -/// store stops the run with its error. `now` is the caller's clock in seconds, for -/// retry backoff and the recent sets. +/// A run sweeps at most one batch of due keys, then moves the incoming lookahead, leaving +/// its new keys to a later run ([`Swept::pending`]). A key that fails is backed off and +/// listed in [`Swept::deferred`] while the others go on, unless the directory's session or +/// connection failed or `notes` cannot serve the wallet's chain: then the run stops leasing +/// keys. A failed wallet write stops the run with its error. `now` is the caller's clock in +/// seconds. #[allow(clippy::too_many_arguments)] pub async fn sweep( wallet: &mut W, From 09d7884d1eeccd6f95ad31f2a136fafb5d3d13e3 Mon Sep 17 00:00:00 2001 From: Adam Tucker Date: Mon, 12 Oct 2026 11:56:06 -0600 Subject: [PATCH 40/40] Consolidate the restore sweep tests Nineteen tests become twelve, sharing a restored-wallet fixture, a default directory, one configurable note source and an assert_matches helper: - the payout, note data batch, empty wallet and one-batch-per-run tests are rows of one import table, which checks that every run finishes between one key and one batch; - the stale-tip and behind-the-tip runs share one test; - the off-chain, short and seen-set refusals are rows of one refusal table, and the genesis check joins the other fetch_seen checks; - mislabeled note data is a non-fatal row of the run-fatal table; - the unchanged-answer case is a row of the deferred-candidate table. Every earlier assertion is kept, and some rows assert more: the positions note data was requested for, the keys each run finishes, the write labels of skipped runs, and how many note data requests and lookups each answer costs. Co-Authored-By: Claude Opus 5.5 --- zakura/pir-receiver/tests/sweep.rs | 1218 +++++++++++----------------- 1 file changed, 494 insertions(+), 724 deletions(-) diff --git a/zakura/pir-receiver/tests/sweep.rs b/zakura/pir-receiver/tests/sweep.rs index ccc84a9f..9295c7aa 100644 --- a/zakura/pir-receiver/tests/sweep.rs +++ b/zakura/pir-receiver/tests/sweep.rs @@ -71,6 +71,15 @@ use zcash_protocol::{ #[path = "../../pir-enhance/src/test_support.rs"] mod test_support; +/// Asserts that a value matches a pattern, showing the value otherwise. +macro_rules! assert_matches { + ($value:expr, $($pattern:tt)+) => { + match &$value { + value => assert!(matches!(value, $($pattern)+), "{value:?}"), + } + }; +} + type State = TestState; /// The wallet a test sweeps. type Wallet = WalletDb; @@ -86,13 +95,9 @@ const NOW: i64 = 1_700_000_000; /// The payout's value. const VALUE: u64 = 100_000; -/// A wallet with Ironwood active from its first block. -fn ironwood_wallet() -> State { - ironwood_wallet_in(TestDbFactory::default()) -} - -/// [`ironwood_wallet`] stored as `factory` makes it. Its incoming lookahead is registered -/// before it scans, as a restore does, so scanning keeps the spend evidence sweeps need. +/// A wallet with Ironwood active from its first block, stored as `factory` makes it. Its +/// incoming lookahead is registered before it scans, as a restore does, so scanning keeps +/// the spend evidence sweeps need. fn ironwood_wallet_in(factory: TestDbFactory) -> State { let activation = BlockHeight::from_u32(ACTIVATION); let mut st = TestBuilder::new() @@ -183,6 +188,76 @@ fn pay_restored_key(st: &mut State, index: u64, position: u64) -> (Record, [u8; (record, ciphertext[52..].try_into().unwrap()) } +/// A restored wallet at its fully scanned tip, after payments to its incoming keys. +struct Restored { + st: State, + account: AccountUuid, + through: ChainPoint, + /// The payments as the directory lists them, in chain order. + records: Vec, + /// Their note data, by position. + data: BTreeMap, +} + +/// Pays incoming key `paid[i]` as the chain's Ironwood output `i`, each payment a page of +/// its key's lookup, then scans one more block and registers the restored lookahead. +fn restored(paid: &[u64]) -> Restored { + let mut st = ironwood_wallet_in(TestDbFactory::default()); + let account = st.test_account().unwrap().id(); + let (mut records, mut data) = (Vec::new(), BTreeMap::new()); + for (position, index) in (0..).zip(paid) { + let (mut record, note_data) = pay_restored_key(&mut st, *index, position); + let pages = |paid: &[u64]| paid.iter().filter(|i| *i == index).count(); + record.page = pages(&paid[..records.len()]).try_into().unwrap(); + record.total = pages(paid).try_into().unwrap(); + records.push(record); + data.insert(position, note_data); + } + st.generate_and_scan_empty_blocks_with_one_time_ivks(1); + restore(&mut st, account); + let through = tip(&st); + Restored { + st, + account, + through, + records, + data, + } +} + +impl Restored { + /// A note source serving every payment's note data. + fn notes(&self) -> Notes { + let data = self.data.clone(); + Notes { + data, + ..Notes::default() + } + } +} + +/// The test account's total balance in zatoshis. +fn balance(st: &State) -> u64 { + st.get_total_balance(st.test_account().unwrap().id()) + .into_u64() +} + +/// Whether the test account still has sweeps or queued payments through `through`. +fn pending(st: &State, through: ChainPoint) -> bool { + let account = st.test_account().unwrap().id(); + let db = st.wallet().db(); + db.one_time_history_pending(account, through.height) + .unwrap() +} + +/// The indices of the keys the wallet scans with, in order. +fn scanning(st: &State) -> Vec { + let keys = st.wallet().get_one_time_scanning_keys().unwrap(); + let mut indices: Vec<_> = keys.iter().map(|k| k.key_id().index()).collect(); + indices.sort(); + indices +} + /// A swap provider's sets: an empty recent set covering the wallet's restore watch, and /// every receiver it ever had, from a feed that started long before [`NOW`] and last /// read the provider a minute before it. @@ -254,6 +329,13 @@ fn publish( router_with_publications(publications) } +/// Serves a [`publish`]ed publication of `records`, the chain's only Ironwood outputs, +/// ending at `end`, with a provider that has seen nothing. +fn serve(records: &[Record], end: ChainPoint) -> Directory { + let commitments: Vec<_> = records.iter().map(|record| record.payment.cmx).collect(); + Directory::new(publish(records, &commitments, end, &provider(&[]))) +} + /// Serves a directory's routes in process and records every request. struct Directory { router: Router, @@ -322,18 +404,33 @@ impl Transport for Directory { } } -/// Note data from a fixed map, recording each request. +/// The receiver lookups `directory` has served. +fn lookups(directory: &Directory) -> usize { + let requests = directory.requests(); + requests + .iter() + .filter(|r| *r == "POST /v1/receiver/query") + .count() +} + +/// Note data from a fixed map, recording each request. Each answer is keyed `shift` +/// positions after its request, and an `unavailable` source cannot serve the wallet's +/// chain. +#[derive(Default)] struct Notes { data: BTreeMap, requested: Vec>, + shift: u64, + unavailable: bool, } impl Notes { - /// Serves `data`, keyed by position. - fn new(data: BTreeMap) -> Self { + /// A source that cannot serve the wallet's chain. + fn unavailable() -> Self { + let unavailable = true; Self { - data, - requested: Vec::new(), + unavailable, + ..Self::default() } } } @@ -344,24 +441,16 @@ impl NoteSource for Notes { _: &W, positions: Vec, ) -> Result, Error> { + if self.unavailable { + return Err(Error::NotesUnavailable("no note data")); + } self.requested.push(positions.clone()); - Ok(positions - .into_iter() - .filter_map(|position| Some((position, *self.data.get(&position)?))) - .collect()) - } -} - -/// A note source that cannot serve the wallet's chain. -struct NoNotes; - -impl NoteSource for NoNotes { - async fn note_data( - &mut self, - _: &W, - _: Vec, - ) -> Result, Error> { - Err(Error::NotesUnavailable("no note data")) + assert!( + self.requested.len() <= 3, + "the sweep keeps asking for {positions:?}" + ); + let answer = |p| Some((p + self.shift, *self.data.get(&p)?)); + Ok(positions.into_iter().filter_map(answer).collect()) } } @@ -393,6 +482,15 @@ fn restore(st: &mut State, account: AccountUuid) { .unwrap(); } +/// Scans every range the wallet has queued, such as a spend-history replay. +fn scan_queued(st: &mut State) { + for range in st.wallet().suggest_scan_ranges().unwrap() { + let blocks = range.block_range(); + let count = u32::from(blocks.end) - u32::from(blocks.start); + st.scan_cached_blocks_with_one_time_ivks(blocks.start, count as usize); + } +} + /// Runs one sweep of the test account through `through` at `now`. async fn run>( st: &mut State, @@ -419,21 +517,33 @@ async fn run>( .await } +/// [`run`]s without a lock, expecting no key deferred. +async fn run_clean>( + st: &mut State, + through: ChainPoint, + directory: impl Transport, + notes: &mut N, + now: i64, +) -> Swept { + let swept = run(st, through, directory, notes, &NoLock, now) + .await + .unwrap(); + assert!(swept.deferred.is_empty(), "{:?}", swept.deferred); + swept +} + /// Runs sweeps at [`NOW`] until none is pending, expecting no deferral, and returns how -/// many keys they finished. +/// many keys each run finished. async fn settle( st: &mut State, through: ChainPoint, directory: &Directory, notes: &mut Notes, -) -> usize { - let mut finished = 0; +) -> Vec { + let mut finished = Vec::new(); for _ in 0..20 { - let swept = run(st, through, directory, notes, &NoLock, NOW) - .await - .unwrap(); - assert!(swept.deferred.is_empty(), "{:?}", swept.deferred); - finished += swept.finished; + let swept = run_clean(st, through, directory, notes, NOW).await; + finished.push(swept.finished); if !swept.pending { return finished; } @@ -441,200 +551,91 @@ async fn settle( panic!("sweeps are still pending after 20 runs"); } +/// A restore sweep imports every payment to a key, each a page of its lookup, with note +/// data in batches of 64, then moves the lookahead past the paid and `seen` indices, one +/// batch per run, so later runs find a payment far past the first batch. Every request +/// stays on the service's routes: a wallet without payments downloads only a manifest and +/// the filters, and a few pages cost less over PIR than the row file, unlike many more. #[tokio::test] -async fn a_restore_sweep_finds_a_payout_and_moves_the_lookahead_past_it() { - let mut st = ironwood_wallet(); - let account = st.test_account().unwrap().id(); - let paid = 3; - let (record, note_data) = pay_restored_key(&mut st, paid, 0); - st.generate_and_scan_empty_blocks_with_one_time_ivks(1); - restore(&mut st, account); - let through = tip(&st); - assert_eq!(st.get_total_balance(account), Zatoshis::ZERO); - let directory = Directory::new(publish( - std::slice::from_ref(&record), - &[record.payment.cmx], - through, - &provider(&[]), - )); - let mut notes = Notes::new(BTreeMap::from([(record.payment.position, note_data)])); - let finished = settle(&mut st, through, &directory, &mut notes).await; - // The first window, then, in a later run, the keys above it that the paid index - // brings in. - assert_eq!(finished as u64, RECEIVE_GAP_LIMIT + paid + 1); - assert_eq!(notes.requested, [vec![record.payment.position]]); - assert_eq!( - st.get_total_balance(account), - Zatoshis::const_from_u64(VALUE) - ); - // Lookups and the common witnesses go over the service's routes only. - assert!( - directory - .requests() - .iter() - .all(|r| r.starts_with("GET /v1/receiver/") || r == "POST /v1/receiver/query"), - "{:?}", - directory.requests() - ); -} - -/// Every page of a key's lookup is imported, with note data in batches of 64. A few -/// pages cost less over PIR than the row file, and many more. -#[tokio::test] -async fn every_payment_to_a_key_is_imported_in_note_data_batches() { - for (count, batches, row_file) in [(2, vec![2], false), (65, vec![64, 1], true)] { - let mut st = ironwood_wallet(); - let account = st.test_account().unwrap().id(); - let mut records = Vec::new(); - let mut data = BTreeMap::new(); - for position in 0..count { - let (mut record, note_data) = pay_restored_key(&mut st, 1, position); - record.page = position.try_into().unwrap(); - record.total = count.try_into().unwrap(); - records.push(record); - data.insert(position, note_data); - } - st.generate_and_scan_empty_blocks_with_one_time_ivks(1); - restore(&mut st, account); - let through = tip(&st); - let commitments: Vec<_> = records.iter().map(|record| record.payment.cmx).collect(); - let directory = Directory::new(publish(&records, &commitments, through, &provider(&[]))); - let mut notes = Notes::new(data); - settle(&mut st, through, &directory, &mut notes).await; +async fn a_restore_sweep_imports_every_payment_and_moves_the_lookahead_past_it() { + for (paid, seen, batches, row_file) in [ + (vec![], 0, vec![], false), + (vec![3], 0, vec![1], false), + (vec![1; 2], 0, vec![2], false), + (vec![1; 65], 0, vec![64, 1], true), + // The set is finite, so an unbounded run would stop past it instead of hanging. + (vec![100], 200, vec![1], false), + ] { + let mut r = restored(&paid); + assert_eq!(balance(&r.st), 0); + let seen: Vec<_> = (0..seen).map(|i| receiver(&r.st, i)).collect(); + let commitments: Vec<_> = r.records.iter().map(|r| r.payment.cmx).collect(); + let sets = provider(&seen); + let directory = Directory::new(publish(&r.records, &commitments, r.through, &sets)); + let mut notes = r.notes(); + let runs = settle(&mut r.st, r.through, &directory, &mut notes).await; + // Each run sweeps at most one batch, and later runs progress. + assert!(runs.iter().all(|f| (1..=64).contains(f)), "{runs:?}"); + let finished: usize = runs.iter().sum(); + // The first window, then, in later runs, the keys above it that the paid and seen + // indices bring in. + let past = paid + .last() + .map_or(0, |index| index + 1) + .max(seen.len() as u64); + assert_eq!(finished as u64, RECEIVE_GAP_LIMIT + past, "{paid:?}"); let sizes: Vec<_> = notes.requested.iter().map(Vec::len).collect(); assert_eq!(sizes, batches); - assert_eq!( - st.get_total_balance(account), - Zatoshis::const_from_u64(count * VALUE) - ); + let positions: Vec<_> = (0..paid.len() as u64).collect(); + assert_eq!(notes.requested.concat(), positions); + assert_eq!(balance(&r.st), paid.len() as u64 * VALUE); let requests = directory.requests(); + let routes = + |r: &String| r.starts_with("GET /v1/receiver/") || r == "POST /v1/receiver/query"; + assert!(requests.iter().all(routes), "{requests:?}"); let rows = |r: &String| r.starts_with("GET /v1/receiver/rows/"); assert_eq!(requests.iter().any(rows), row_file, "{requests:?}"); + if paid.is_empty() { + // No PIR session, witnesses or note data, and no second run. + assert_eq!(requests.len(), 2, "{requests:?}"); + assert!(requests[1].starts_with("GET /v1/receiver/filters/")); + } } } -/// Note data keyed by the position after each requested one, counting the requests. -struct Mislabeled { - data: [u8; 528], - calls: usize, -} - -impl NoteSource for Mislabeled { - async fn note_data( - &mut self, - _: &W, - positions: Vec, - ) -> Result, Error> { - self.calls += 1; - assert!(self.calls <= 3, "the sweep keeps asking for {positions:?}"); - Ok(positions.into_iter().map(|p| (p + 1, self.data)).collect()) - } -} - -#[tokio::test] -async fn note_data_for_other_positions_is_missing_data() { - let mut st = ironwood_wallet(); - let account = st.test_account().unwrap().id(); - let paid = 1; - let (record, note_data) = pay_restored_key(&mut st, paid, 0); - st.generate_and_scan_empty_blocks_with_one_time_ivks(1); - restore(&mut st, account); - let through = tip(&st); - let directory = Directory::new(publish( - std::slice::from_ref(&record), - &[record.payment.cmx], - through, - &provider(&[]), - )); - let mut notes = Mislabeled { - data: note_data, - calls: 0, - }; - let swept = run(&mut st, through, &directory, &mut notes, &NoLock, NOW) - .await - .unwrap(); - assert_eq!(notes.calls, 1); - assert!( - matches!( - swept.deferred.as_slice(), - [(key, Error::Unavailable("note data is missing for a directory payment"))] - if key.index() == paid - ), - "{:?}", - swept.deferred - ); - assert_eq!(st.get_total_balance(account), Zatoshis::ZERO); -} - -#[tokio::test] -async fn a_wallet_without_payments_downloads_only_the_filters() { - let mut st = ironwood_wallet(); - let account = st.test_account().unwrap().id(); - st.generate_and_scan_empty_blocks_with_one_time_ivks(1); - restore(&mut st, account); - let through = tip(&st); - let directory = Directory::new(publish(&[], &[], through, &provider(&[]))); - let swept = run(&mut st, through, &directory, &mut NoNotes, &NoLock, NOW) - .await - .unwrap(); - assert!(swept.deferred.is_empty(), "{:?}", swept.deferred); - assert_eq!(swept.finished as u64, RECEIVE_GAP_LIMIT); - assert!(!swept.pending); - // No PIR session, witnesses or note data: one manifest and one filter file. - let requests = directory.requests(); - assert_eq!(requests.len(), 2, "{requests:?}"); - assert!(requests[1].starts_with("GET /v1/receiver/filters/")); -} - /// A key closed after its sweep is checked against each newer publication's paid filter, /// so a payment after the first publication's anchor is found once a publication shows /// it, and not before. A publication already checked costs only its manifest, and a key /// with a known payment is not swept again. #[tokio::test] async fn a_newer_paid_filter_finds_a_payment_to_a_closed_key() { - let mut st = ironwood_wallet(); - let account = st.test_account().unwrap().id(); - st.generate_and_scan_empty_blocks_with_one_time_ivks(1); - restore(&mut st, account); - let first = tip(&st); - let directory = Directory::new(publish(&[], &[], first, &provider(&[]))); - let mut notes = Notes::new(BTreeMap::new()); - settle(&mut st, first, &directory, &mut notes).await; - assert!(st.wallet().get_one_time_scanning_keys().unwrap().is_empty()); + let mut r = restored(&[]); + let directory = serve(&[], r.through); + let mut notes = r.notes(); + settle(&mut r.st, r.through, &directory, &mut notes).await; + assert!(scanning(&r.st).is_empty()); // The closed key misses a payment, which the same publication cannot show. - let (record, note_data) = pay_restored_key(&mut st, 0, 0); - let through = tip(&st); + let (record, note_data) = pay_restored_key(&mut r.st, 0, 0); + let through = tip(&r.st); let before = directory.requests().len(); - let swept = run(&mut st, through, &directory, &mut notes, &NoLock, NOW).await; + let swept = run(&mut r.st, through, &directory, &mut notes, &NoLock, NOW).await; assert!(!swept.unwrap().pending); assert_eq!(directory.requests()[before..], ["GET /v1/receiver/init"]); - assert_eq!(st.get_total_balance(account), Zatoshis::ZERO); + assert_eq!(balance(&r.st), 0); - let directory = Directory::new(publish( - std::slice::from_ref(&record), - &[record.payment.cmx], - through, - &provider(&[]), - )); + let directory = serve(std::slice::from_ref(&record), through); notes.data.insert(record.payment.position, note_data); // The paid key, then the lookahead key its index brings in. - assert_eq!(settle(&mut st, through, &directory, &mut notes).await, 2); - assert_eq!( - st.get_total_balance(account), - Zatoshis::const_from_u64(VALUE) - ); + let runs = settle(&mut r.st, through, &directory, &mut notes).await; + assert_eq!(runs.iter().sum::(), 2); + assert_eq!(balance(&r.st), VALUE); // A later publication still holds the paid key, but it is not looked up again. - st.generate_and_scan_empty_blocks_with_one_time_ivks(1); - let later = tip(&st); - let directory = Directory::new(publish( - std::slice::from_ref(&record), - &[record.payment.cmx], - later, - &provider(&[]), - )); - let swept = run(&mut st, later, &directory, &mut notes, &NoLock, NOW).await; + r.st.generate_and_scan_empty_blocks_with_one_time_ivks(1); + let later = tip(&r.st); + let directory = serve(std::slice::from_ref(&record), later); + let swept = run(&mut r.st, later, &directory, &mut notes, &NoLock, NOW).await; let swept = swept.unwrap(); assert_eq!((swept.finished, swept.pending), (0, false)); assert!(directory.requests().iter().all(|r| !r.starts_with("POST"))); @@ -649,15 +650,11 @@ async fn only_recently_quoted_addresses_keep_scanning_after_their_sweep() { ("both recent", vec![]), ("one recent", (0..RECEIVE_GAP_LIMIT).collect()), ] { - let mut st = ironwood_wallet(); - let account = st.test_account().unwrap().id(); - st.generate_and_scan_empty_blocks_with_one_time_ivks(1); - restore(&mut st, account); - let through = tip(&st); + let mut r = restored(&[]); let sets = if case == "quoted" { // The old device quoted addresses 0 and 2, and 2 only today. - let mut sets = provider(&[receiver(&st, 0), receiver(&st, 2)]); - sets[0].receivers = vec![receiver(&st, 2)]; + let mut sets = provider(&[receiver(&r.st, 0), receiver(&r.st, 2)]); + sets[0].receivers = vec![receiver(&r.st, 2)]; sets } else { // Provider `b` publishes only its seen set in the last case. @@ -666,18 +663,10 @@ async fn only_recently_quoted_addresses_keep_scanning_after_their_sweep() { sets.extend(b.skip(usize::from(case == "one recent"))); sets }; - let directory = Directory::new(publish(&[], &[], through, &sets)); - let mut notes = Notes::new(BTreeMap::new()); - settle(&mut st, through, &directory, &mut notes).await; - let mut scanning: Vec<_> = st - .wallet() - .get_one_time_scanning_keys() - .unwrap() - .iter() - .map(|k| k.key_id().index()) - .collect(); - scanning.sort(); - assert_eq!(scanning, expected, "{case}"); + let directory = Directory::new(publish(&[], &[], r.through, &sets)); + let mut notes = r.notes(); + settle(&mut r.st, r.through, &directory, &mut notes).await; + assert_eq!(scanning(&r.st), expected, "{case}"); } } @@ -686,16 +675,11 @@ async fn only_recently_quoted_addresses_keep_scanning_after_their_sweep() { /// misses the receiver and whose lookup finds nothing does not make it issuable. #[tokio::test] async fn a_seen_hit_outlives_a_rejected_payment_and_a_later_miss() { - let mut st = ironwood_wallet(); - let account = st.test_account().unwrap().id(); + // Key 1000's later output is one that publication A's tree replaces. + let mut r = restored(&[0, 1000]); let key = KeyId::new(Purpose::Receive, 0); - let (record, note_data) = pay_restored_key(&mut st, key.index(), 0); - // A later output, which publication A's tree replaces. - let (later_output, _) = pay_restored_key(&mut st, 1000, 1); - let commitments = [record.payment.cmx, later_output.payment.cmx]; - st.generate_and_scan_empty_blocks_with_one_time_ivks(1); - restore(&mut st, account); - let through = tip(&st); + let record = &r.records[0]; + let commitments = [record.payment.cmx, r.records[1].payment.cmx]; let seen = |st: &State| -> bool { st.wallet() .conn() @@ -707,35 +691,26 @@ async fn a_seen_hit_outlives_a_rejected_payment_and_a_later_miss() { ) .unwrap() }; - let mut notes = Notes::new(BTreeMap::from([(record.payment.position, note_data)])); + let mut notes = r.notes(); // Publication A's witnesses are over another tree, so its payment is rejected. let a = Directory::new(publish( - std::slice::from_ref(&record), + std::slice::from_ref(record), &[record.payment.cmx, [1; 32]], - through, + r.through, &provider(&[record.receiver]), )); - let swept = run(&mut st, through, &a, &mut notes, &NoLock, NOW) + let swept = run(&mut r.st, r.through, &a, &mut notes, &NoLock, NOW) .await .unwrap(); - assert!( - matches!( - &swept.deferred[..], - [(deferred, Error::Queued(PaymentApplication::Rejected))] if *deferred == key - ), - "{:?}", - swept.deferred - ); - assert!(seen(&st)); + let rejected = PaymentApplication::Rejected; + assert_matches!(swept.deferred[..], [(k, Error::Queued(a))] if *k == key && *a == rejected); + assert!(seen(&r.st)); - let b = Directory::new(publish(&[], &commitments, through, &provider(&[]))); + let b = Directory::new(publish(&[], &commitments, r.through, &provider(&[]))); let later = NOW + 3_600; - let swept = run(&mut st, through, &b, &mut notes, &NoLock, later) - .await - .unwrap(); - assert!(swept.deferred.is_empty(), "{:?}", swept.deferred); + let swept = run_clean(&mut r.st, r.through, &b, &mut notes, later).await; assert!(!swept.pending); - assert!(seen(&st)); + assert!(seen(&r.st)); let fetched = fetch_seen(ORIGIN, &b, GENESIS).await.unwrap(); let contains = |receivers: &[[u8; 43]]| fetched.contains(receivers); let view = ProviderSeen { @@ -743,12 +718,12 @@ async fn a_seen_hit_outlives_a_rejected_payment_and_a_later_miss() { until: fetched.until(), contains: &contains, }; - let issued = st - .wallet_mut() - .db_mut() - .prepare_receive_reservation(account, later, through.height, Some(&view)) - .unwrap() - .unwrap(); + let issued = + r.st.wallet_mut() + .db_mut() + .prepare_receive_reservation(r.account, later, r.through.height, Some(&view)) + .unwrap() + .unwrap(); assert_eq!(issued.key_id(), KeyId::new(Purpose::Receive, 1)); } @@ -775,67 +750,69 @@ impl WriteLock for FailFirstAttempt { /// key's lookup and without retrying it. #[tokio::test] async fn a_failed_attempt_write_stops_the_run() { - let mut st = ironwood_wallet(); - let account = st.test_account().unwrap().id(); - let (record, note_data) = pay_restored_key(&mut st, 0, 0); - st.generate_and_scan_empty_blocks_with_one_time_ivks(1); - restore(&mut st, account); - let through = tip(&st); - let directory = Directory::new(publish( - std::slice::from_ref(&record), - &[record.payment.cmx], - through, - &provider(&[]), - )); - let mut notes = Notes::new(BTreeMap::from([(record.payment.position, note_data)])); + let mut r = restored(&[0]); + let directory = serve(&r.records, r.through); + let mut notes = r.notes(); let lock = FailFirstAttempt { fail: Arc::new(AtomicBool::new(false)), labels: RefCell::new(Vec::new()), }; let fail = lock.fail.clone(); // Returning true turns the commit into a rollback, which fails the write. - st.wallet() + r.st.wallet() .conn() .commit_hook(Some(move || fail.swap(false, Ordering::SeqCst))); - let result = run(&mut st, through, &directory, &mut notes, &lock, NOW).await; - assert!(matches!(result, Err(Error::Wallet(_))), "{result:?}"); + let result = run(&mut r.st, r.through, &directory, &mut notes, &lock, NOW).await; + assert_matches!(result, Err(Error::Wallet(_))); assert_eq!( *lock.labels.borrow(), ["one_time_sweep.due", "one_time_sweep.attempt"] ); - assert!( - directory.requests().iter().all(|r| !r.starts_with("POST")), - "{:?}", - directory.requests() - ); + assert!(directory.requests().iter().all(|r| !r.starts_with("POST"))); assert!(notes.requested.is_empty()); } -/// A `through` the wallet has scanned past is not a tip to release evidence at, so a -/// run with it writes nothing. +/// A `through` the wallet's chain has moved past is not a tip to sweep or release +/// evidence at, so a run with it writes and requests nothing. While a block below the +/// tip is unscanned, a run prepares no sweep: it makes no request and leases no attempt, +/// since any payment it found could only wait for scanning. Once scanning catches up, +/// the sweep runs at once, without backoff. #[tokio::test] -async fn a_stale_tip_releases_nothing() { - let mut st = ironwood_wallet(); - let account = st.test_account().unwrap().id(); - st.generate_and_scan_empty_blocks_with_one_time_ivks(1); - restore(&mut st, account); - let through = tip(&st); - st.generate_and_scan_empty_blocks_with_one_time_ivks(1); - let directory = Directory::new(publish(&[], &[], through, &provider(&[]))); - let lock = Labels::default(); - run(&mut st, through, &directory, &mut NoNotes, &lock, NOW) - .await - .unwrap(); - assert!(lock.0.borrow().is_empty(), "{:?}", lock.0.borrow()); - assert!(directory.requests().is_empty()); +async fn a_run_off_the_scanned_tip_leases_and_requests_nothing() { + let mut r = restored(&[0]); + let stale = r.through; + // A block below the tip is not scanned yet. + r.st.generate_empty_block(); + let (top, _) = r.st.generate_empty_block(); + r.st.scan_cached_blocks_with_one_time_ivks(top, 1); + let block = r.st.wallet().block_metadata(top).unwrap().unwrap(); + let through = ChainPoint { + height: top, + hash: block.block_hash(), + }; + let directory = serve(&r.records, through); + let mut notes = r.notes(); + let behind = ["one_time_sweep.due", "one_time_sweep.release"]; + for (through, labels) in [(stale, &[][..]), (through, &behind[..])] { + let lock = Labels::default(); + let swept = run(&mut r.st, through, &directory, &mut notes, &lock, NOW) + .await + .unwrap(); + assert!(swept.deferred.is_empty(), "{:?}", swept.deferred); + assert!(swept.pending); + assert_eq!(*lock.0.borrow(), labels); + let requests = directory.requests(); + assert!(requests.is_empty(), "{requests:?}"); + } + r.st.scan_cached_blocks_with_one_time_ivks(top - 1, 1); + run_clean(&mut r.st, through, &directory, &mut notes, NOW).await; + settle(&mut r.st, through, &directory, &mut notes).await; + assert_eq!(balance(&r.st), VALUE); } /// Another handle on the wallet's file, as another writer holds. type OtherWriter = WalletDb; -/// A write through another handle on the wallet's file at `through`. -type OtherWrite = fn(&mut OtherWriter, ChainPoint); - /// Runs `other` just before the first release, as another writer may while the sweep /// holds no lock, then sets `releasing` while release holds the lock. struct WriterBeforeRelease { @@ -861,54 +838,43 @@ impl WriteLock for WriterBeforeRelease { /// Release checks the chain point again under its lock, since another writer may move /// the tip while the sweep's lookups run without it, and waits for the fully scanned /// height to reach it. Each case sweeps against a publication `behind` blocks below the -/// tip, with `other` writing through a second handle just before release, and judges -/// whether release ran by its read of the retention table. +/// tip, with another writer moving the chain through a second handle just before +/// release, and judges whether release ran by its read of the retention table. #[tokio::test] async fn release_runs_only_at_the_fully_scanned_tip() { - let cases: [(&str, u32, OtherWrite, bool); 5] = [ - ("an unmoved tip", 0, |_, _| (), true), - ( - "an advanced tip", - 0, - |other, through| { - other.update_chain_tip(through.height + 1).unwrap(); - }, - false, - ), - ( - "a reorg that rewound the tip", - 0, - |other, through| { - other.truncate_to_height(through.height - 1).unwrap(); - }, - false, - ), + for (case, behind, expected) in [ + ("an unmoved tip", 0, true), + ("an advanced tip", 0, false), + ("a reorg that rewound the tip", 0, false), // The tip is back at `through`'s height, but its block is not scanned yet. - ( - "a replaced block", - 0, - |other, through| { - other.truncate_to_height(through.height - 1).unwrap(); - other.update_chain_tip(through.height).unwrap(); - }, - false, - ), + ("a replaced block", 0, false), // Finishing keys against a publication behind `through` rescans the blocks // after it, lowering the fully scanned height without moving the tip. - ("a rescan the sweep queued", 1, |_, _| (), false), - ]; - for (case, behind, other, expected) in cases { + ("a rescan the sweep queued", 1, false), + ] { let mut st = ironwood_wallet_in(TestDbFactory::file_backed()); let network = *st.network(); - let account = st.test_account().unwrap().id(); st.generate_and_scan_empty_blocks_with_one_time_ivks(2); + let account = st.test_account().unwrap().id(); restore(&mut st, account); let through = tip(&st); let path = st.wallet().conn().path().unwrap().to_owned(); let mut handle = OtherWriter::for_path(path, network, SystemClock, TestRng::seed_from_u64(0)).unwrap(); + let h = through.height; + let other = || match case { + "an advanced tip" => handle.update_chain_tip(h + 1).unwrap(), + "a reorg that rewound the tip" => { + handle.truncate_to_height(h - 1).unwrap(); + } + "a replaced block" => { + handle.truncate_to_height(h - 1).unwrap(); + handle.update_chain_tip(h).unwrap(); + } + _ => {} + }; let lock = WriterBeforeRelease { - other: RefCell::new(Some(|| other(&mut handle, through))), + other: RefCell::new(Some(other)), releasing: Arc::new(AtomicBool::new(false)), }; let released = Arc::new(AtomicBool::new(false)); @@ -916,29 +882,33 @@ async fn release_runs_only_at_the_fully_scanned_tip() { st.wallet() .conn() .authorizer(Some(move |ctx: AuthContext<'_>| { - if releasing.load(Ordering::SeqCst) - && matches!( - ctx.action, - AuthAction::Read { - table_name: "ironwood_one_time_spend_retention", - .. - } - ) - { + let retention = matches!( + ctx.action, + AuthAction::Read { + table_name: "ironwood_one_time_spend_retention", + .. + } + ); + if releasing.load(Ordering::SeqCst) && retention { read.store(true, Ordering::SeqCst); } Authorization::Allow })); let height = through.height - behind; - let end = ChainPoint { - height, - hash: st.wallet().get_block_hash(height).unwrap().unwrap(), - }; - // Without a recent set, every key keeps scanning from the block after `end`. + let hash = st.wallet().get_block_hash(height).unwrap().unwrap(); + // Without a recent set, every key keeps scanning from the block after the end. + let end = ChainPoint { height, hash }; let directory = Directory::new(publish(&[], &[], end, &provider(&[])[1..])); - let swept = run(&mut st, through, &directory, &mut NoNotes, &lock, NOW) - .await - .unwrap(); + let swept = run( + &mut st, + through, + &directory, + &mut Notes::unavailable(), + &lock, + NOW, + ) + .await + .unwrap(); assert_eq!(swept.finished as u64, RECEIVE_GAP_LIMIT); st.wallet() .conn() @@ -947,94 +917,55 @@ async fn release_runs_only_at_the_fully_scanned_tip() { } } -/// Another network's seen sets say nothing about this network's quotes, so a -/// publication committing to another genesis is refused as a sweep refuses it. -#[tokio::test] -async fn seen_sets_of_another_network_are_refused() { - let mut st = ironwood_wallet(); - st.generate_and_scan_empty_blocks_with_one_time_ivks(1); - let directory = Directory::new(publish(&[], &[], tip(&st), &provider(&[]))); - let refused = fetch_seen(ORIGIN, &directory, [8; 32]).await; - assert!( - matches!( - refused, - Err(DirectoryError::Directory( - receiver_directory::Error::Coverage - )) - ), - "{:?}", - refused.err() - ); - assert!(fetch_seen(ORIGIN, &directory, GENESIS).await.is_ok()); -} - -/// A publication without seen sets, or with a provider that has a recent set but no -/// seen set, may omit any receiver, so issuance and sweeps both refuse it, before any -/// key finishes or spend evidence is released. Once every provider has a dated seen -/// set, the sets cover the quote times they all cover, and hold what any of them holds. +/// Seen sets must commit to the wallet's genesis, as a sweep's publication must: another +/// network's sets say nothing about this network's quotes. A publication without seen +/// sets, or with a provider that has a recent set but no seen set, may omit any receiver, +/// so issuance refuses it as sweeps do. Once every provider has a dated seen set, the +/// sets cover the quote times they all cover, and hold what any of them holds. #[tokio::test] -async fn every_provider_with_sets_needs_a_seen_set() { - let mut st = ironwood_wallet(); - let account = st.test_account().unwrap().id(); - st.generate_and_scan_empty_blocks_with_one_time_ivks(1); - restore(&mut st, account); - let through = tip(&st); +async fn seen_sets_need_this_network_and_every_provider() { + let r = restored(&[]); // Provider `a` has its seen set only if `a_seen`. `b` has only a seen set, from a // later feed start and a later read. let sets = |a_seen: bool| { - let mut sets = provider_named("a", &[receiver(&st, 0)]); + let mut sets = provider_named("a", &[receiver(&r.st, 0)]); sets.truncate(1 + usize::from(a_seen)); - let mut b = provider_named("b", &[receiver(&st, 1)]).pop().unwrap(); + let mut b = provider_named("b", &[receiver(&r.st, 1)]).pop().unwrap(); b.since_unix = NOW - RESTORE_WATCH_SECS; b.until_unix = NOW - 30; sets.push(b); sets }; - let (missing, complete) = (sets(false), sets(true)); - // The first refusal backs every due key off for a minute. - for (sets, now) in [(missing, NOW), (Vec::new(), NOW + 60)] { - let directory = Directory::new(publish(&[], &[], through, &sets)); + for sets in [sets(false), Vec::new()] { + let directory = Directory::new(publish(&[], &[], r.through, &sets)); let refused = fetch_seen(ORIGIN, &directory, GENESIS).await; assert!(matches!(refused, Err(DirectoryError::Malformed))); - let lock = Labels::default(); - let result = run(&mut st, through, &directory, &mut NoNotes, &lock, now).await; - assert!( - matches!(result, Err(Error::Directory(DirectoryError::Malformed))), - "{result:?}" - ); - let labels = lock.0.borrow(); - assert!( - labels - .iter() - .all(|l| ["one_time_sweep.due", "one_time_sweep.attempt"].contains(l)), - "{labels:?}" - ); - assert!( - st.wallet() - .db() - .one_time_history_pending(account, through.height) - .unwrap() - ); } - let directory = Directory::new(publish(&[], &[], through, &complete)); + let directory = Directory::new(publish(&[], &[], r.through, &sets(true))); + let refused = fetch_seen(ORIGIN, &directory, [8; 32]).await; + let coverage = |e: &_| matches!(e, receiver_directory::Error::Coverage); + assert_matches!(refused.err(), Some(DirectoryError::Directory(e)) if coverage(e)); let seen = fetch_seen(ORIGIN, &directory, GENESIS).await.unwrap(); assert_eq!( (seen.since(), seen.until()), (NOW - RESTORE_WATCH_SECS, NOW - 60) ); - let held = [0, 1, 2].map(|index| *receiver(&st, index).as_bytes()); + let held = [0, 1, 2].map(|index| *receiver(&r.st, index).as_bytes()); assert_eq!(seen.contains(&held), [true, true, false]); } -/// Which part of a directory fails. -#[derive(Clone, Copy, Debug)] +/// Which part of a sweep fails. +#[derive(Clone, Copy, Debug, PartialEq)] enum Fault { - None, /// Queries fail after the session was accepted. Queries, /// The witness snapshot is malformed, so no session opens. Witnesses, + /// The note source cannot serve the wallet's chain. + Notes, + /// Note data comes back for other positions. + OtherPositions, } /// A directory with `fault`. @@ -1066,102 +997,126 @@ impl Transport for Faulty { /// A revoked session, a session that fails to open, or a note source that cannot serve /// the wallet's chain would fail every later key the same way, so the run stops at the -/// first paid key, and later paid keys are neither leased nor backed off. +/// first paid key, and later paid keys are neither leased nor backed off. Note data for +/// other positions fails only that key, without asking for it again. #[tokio::test] async fn a_run_fatal_failure_stops_the_run_before_leasing_more_keys() { - for fault in [Fault::Queries, Fault::Witnesses, Fault::None] { - let mut st = ironwood_wallet(); - let account = st.test_account().unwrap().id(); + for fault in [ + Fault::Queries, + Fault::Witnesses, + Fault::Notes, + Fault::OtherPositions, + ] { // The batch's first two keys are paid, so each needs a lookup. - let records: Vec<_> = (0..2) - .map(|index| pay_restored_key(&mut st, index, index).0) - .collect(); - st.generate_and_scan_empty_blocks_with_one_time_ivks(1); - restore(&mut st, account); - let through = tip(&st); - let cmxs: Vec<_> = records.iter().map(|record| record.payment.cmx).collect(); + let mut r = restored(&[0, 1]); let directory = Faulty { - directory: Directory::new(publish(&records, &cmxs, through, &provider(&[]))), + directory: serve(&r.records, r.through), fault, }; - let swept = run(&mut st, through, &directory, &mut NoNotes, &NoLock, NOW) - .await - .unwrap(); - let [(key, e)] = &swept.deferred[..] else { - panic!("{fault:?}: {:?}", swept.deferred); + let mut notes = match fault { + Fault::Notes => Notes::unavailable(), + Fault::OtherPositions => Notes { + shift: 1, + ..r.notes() + }, + _ => r.notes(), }; - let failed = match fault { - Fault::Queries => matches!(e, Error::Directory(DirectoryError::Revision)), - Fault::Witnesses => matches!(e, Error::Directory(_)), - Fault::None => matches!(e, Error::NotesUnavailable(_)), + let swept = run(&mut r.st, r.through, &directory, &mut notes, &NoLock, NOW).await; + let swept = swept.unwrap(); + let fatal = fault != Fault::OtherPositions; + let keys: Vec<_> = swept.deferred.iter().map(|(key, _)| key.index()).collect(); + assert_eq!(keys, if fatal { &[0][..] } else { &[0, 1] }, "{fault:?}"); + for (_, e) in &swept.deferred { + let failed = match fault { + Fault::Queries => matches!(e, Error::Directory(DirectoryError::Revision)), + Fault::Witnesses => matches!(e, Error::Directory(_)), + Fault::Notes => matches!(e, Error::NotesUnavailable(_)), + Fault::OtherPositions => matches!( + e, + Error::Unavailable("note data is missing for a directory payment") + ), + }; + assert!(failed, "{fault:?}: {e:?}"); + } + // Unless the run stopped, every unpaid key in the batch finishes. + let unpaid = if fatal { + 0 + } else { + RECEIVE_GAP_LIMIT as usize - 2 }; + assert_eq!((swept.finished, swept.pending), (unpaid, true)); + assert_eq!(notes.requested.len(), keys.len() * usize::from(!fatal)); + assert_eq!(balance(&r.st), 0); + let due: Vec<_> = + r.st.wallet_mut() + .db_mut() + .prepare_one_time_sweeps(r.account, r.through, NOW, NonZeroU32::new(64).unwrap()) + .unwrap() + .unwrap() + .iter() + .map(|item| item.key.index()) + .collect(); assert!( - *key == KeyId::new(Purpose::Receive, 0) && failed, - "{fault:?}: {e:?}" + !due.contains(&0) && due.contains(&1) == fatal, + "{fault:?}: {due:?}" ); - assert_eq!(swept.finished, 0); - assert!(swept.pending); - let due: Vec<_> = st - .wallet_mut() - .db_mut() - .prepare_one_time_sweeps(account, through, NOW, NonZeroU32::new(64).unwrap()) - .unwrap() - .unwrap() - .iter() - .map(|item| item.key.index()) - .collect(); - assert!(!due.contains(&0) && due.contains(&1), "{fault:?}: {due:?}"); } } +/// How a sweep refuses an unusable publication. +type Refusal = fn(&Error) -> bool; + /// A publication off the wallet's chain, or ending at the wallet's block but short of -/// that block's last Ironwood output, is refused before any lookup: a payment in the -/// omitted tail would be neither looked up nor rescanned. Every due key backs off, so -/// the directory is not asked again until a minute on. +/// its last Ironwood output, whose omitted payments neither a lookup nor a rescan would +/// find, or one that may omit any seen receiver, is refused before any lookup, key finish +/// or release. Every due key backs off, so the directory is not asked again until a +/// minute on. #[tokio::test] -async fn a_publication_off_the_wallets_chain_is_refused_before_any_lookup() { - for off_chain in [true, false] { - let mut st = ironwood_wallet(); - let account = st.test_account().unwrap().id(); - let (record, note_data) = pay_restored_key(&mut st, 0, 0); - st.generate_and_scan_empty_blocks_with_one_time_ivks(1); - restore(&mut st, account); - let through = tip(&st); - let publication = if off_chain { - let other = ChainPoint { - hash: BlockHash([1; 32]), - ..through - }; - publish( - std::slice::from_ref(&record), - &[record.payment.cmx], - other, - &provider(&[]), - ) - } else { +async fn an_unusable_publication_is_refused_before_any_lookup() { + let malformed: Refusal = |e| matches!(e, Error::Directory(DirectoryError::Malformed)); + let rows: [(&str, Refusal); 4] = [ + ("off chain", |e| matches!(e, Error::Directory(_))), + ("short", |e| matches!(e, Error::Unavailable(_))), + ("unseen provider", malformed), + ("no seen sets", malformed), + ]; + for (case, refused) in rows { + let mut r = restored(&[0]); + let (records, through) = (&r.records[..], r.through); + let directory = match case { + "off chain" => serve( + records, + ChainPoint { + hash: BlockHash([1; 32]), + ..through + }, + ), // Without the chain's one Ironwood output. - publish(&[], &[], through, &provider(&[])) - }; - let directory = Directory::new(publication); - let mut notes = Notes::new(BTreeMap::from([(record.payment.position, note_data)])); - let result = run(&mut st, through, &directory, &mut notes, &NoLock, NOW).await; - let refused = match result { - Err(Error::Directory(_)) => off_chain, - Err(Error::Unavailable(_)) => !off_chain, - _ => false, + "short" => serve(&[], through), + // A provider with a recent set but no seen set, or no provider at all. + _ => { + let mut sets = provider(&[]); + sets.truncate(usize::from(case == "unseen provider")); + let commitments = [records[0].payment.cmx]; + Directory::new(publish(records, &commitments, through, &sets)) + } }; - assert!(refused, "{result:?}"); - assert_eq!(directory.requests(), ["GET /v1/receiver/init"]); - assert!(notes.requested.is_empty()); + let mut notes = r.notes(); + let lock = Labels::default(); + let result = run(&mut r.st, through, &directory, &mut notes, &lock, NOW).await; + assert!(result.as_ref().is_err_and(refused), "{case}: {result:?}"); + let attempts = ["one_time_sweep.due", "one_time_sweep.attempt"]; + let labels = lock.0.take(); assert!( - st.wallet() - .db() - .one_time_history_pending(account, through.height) - .unwrap() + labels.iter().all(|l| attempts.contains(l)), + "{case}: {labels:?}" ); + assert_eq!(directory.requests(), ["GET /v1/receiver/init"], "{case}"); + assert!(notes.requested.is_empty()); + assert!(pending(&r.st, through)); for (now, requests) in [(NOW, 1), (NOW + 60, 2)] { - let _ = run(&mut st, through, &directory, &mut notes, &NoLock, now).await; - assert_eq!(directory.requests().len(), requests); + let _ = run(&mut r.st, through, &directory, &mut notes, &NoLock, now).await; + assert_eq!(directory.requests().len(), requests, "{case}"); } } } @@ -1251,31 +1206,21 @@ impl EnhanceTransport for RefusingEnhance { /// after a refused query, and cannot serve a wallet whose chain lacks their anchor. #[tokio::test] async fn enhance_notes_follow_the_wallets_chain_and_fresh_routing() { - let mut st = ironwood_wallet(); - pay_restored_key(&mut st, 0, 0); - let anchor = tip(&st); - let records = st - .wallet() - .block_metadata(anchor.height) - .unwrap() - .unwrap() - .ironwood_tree_size() - .unwrap() - .into(); - let network = EnhanceNetwork(*st.network()); + let r = restored(&[0]); + let (anchor, db) = (r.through, r.st.wallet().db()); + let block = db.block_metadata(anchor.height).unwrap().unwrap(); + let records = block.ironwood_tree_size().unwrap().into(); + let network = EnhanceNetwork(*r.st.network()); for status in [409, 410] { let enhance = RefusingEnhance::new(anchor, records, status); let mut notes = EnhanceNotes::new("https://enhance.test", &enhance, &network); - let data = notes.note_data(st.wallet().db(), Vec::new()).await.unwrap(); + let data = notes.note_data(db, Vec::new()).await.unwrap(); assert!(data.is_empty()); assert_eq!(enhance.inits.get(), 0); - let refused = notes.note_data(st.wallet().db(), vec![0]).await; - assert!( - matches!(refused, Err(Error::NoteData(ClientError::HttpStatus(s))) if s == status), - "{refused:?}" - ); + let refused = notes.note_data(db, vec![0]).await; + assert_matches!(refused, Err(Error::NoteData(ClientError::HttpStatus(s))) if *s == status); // The same notes fetch and accept the routing again before the next batch. - let data = notes.note_data(st.wallet().db(), vec![0]).await.unwrap(); + let data = notes.note_data(db, vec![0]).await.unwrap(); assert_eq!(data, BTreeMap::from([(0, [0; 528])])); assert_eq!((enhance.inits.get(), enhance.queries.get()), (2, 2)); } @@ -1286,250 +1231,75 @@ async fn enhance_notes_follow_the_wallets_chain_and_fresh_routing() { }; let enhance = RefusingEnhance::new(other, records, 409); let mut notes = EnhanceNotes::new("https://enhance.test", &enhance, &network); - let refused = notes.note_data(st.wallet().db(), vec![0]).await; - assert!( - matches!(refused, Err(Error::NotesUnavailable(_))), - "{refused:?}" - ); + let refused = notes.note_data(db, vec![0]).await; + assert_matches!(refused, Err(Error::NotesUnavailable(_))); assert_eq!(enhance.queries.get(), 0); } -/// The receiver lookups `directory` has served. -fn lookups(directory: &Directory) -> usize { - let requests = directory.requests(); - requests - .iter() - .filter(|r| *r == "POST /v1/receiver/query") - .count() -} - -/// Scans every range the wallet has queued, such as a spend-history replay. -fn scan_queued(st: &mut State) { - for range in st.wallet().suggest_scan_ranges().unwrap() { - let blocks = range.block_range(); - let count = u32::from(blocks.end) - u32::from(blocks.start); - st.scan_cached_blocks_with_one_time_ivks(blocks.start, count as usize); - } -} - -/// While a block below the wallet's chain tip is unscanned, a run prepares no sweep: it -/// makes no request and leases no attempt, since any payment it found could only wait -/// for scanning. Once scanning catches up, the sweep runs at once, without backoff. -#[tokio::test] -async fn a_run_behind_the_fully_scanned_tip_leases_and_requests_nothing() { - let mut st = ironwood_wallet(); - let account = st.test_account().unwrap().id(); - let (record, note_data) = pay_restored_key(&mut st, 0, 0); - st.generate_and_scan_empty_blocks_with_one_time_ivks(1); - restore(&mut st, account); - // A block below the tip is not scanned yet. - st.generate_empty_block(); - let (top, _) = st.generate_empty_block(); - st.scan_cached_blocks_with_one_time_ivks(top, 1); - let block = st.wallet().block_metadata(top).unwrap().unwrap(); - let through = ChainPoint { - height: top, - hash: block.block_hash(), - }; - let mut notes = Notes::new(BTreeMap::from([(0, note_data)])); - let records = std::slice::from_ref(&record); - let directory = Directory::new(publish( - records, - &[record.payment.cmx], - through, - &provider(&[]), - )); - let swept = run(&mut st, through, &directory, &mut notes, &NoLock, NOW) - .await - .unwrap(); - assert!(swept.deferred.is_empty(), "{:?}", swept.deferred); - assert!(swept.pending); - assert!( - directory.requests().is_empty(), - "{:?}", - directory.requests() - ); - st.scan_cached_blocks_with_one_time_ivks(top - 1, 1); - let swept = run(&mut st, through, &directory, &mut notes, &NoLock, NOW) - .await - .unwrap(); - assert!(swept.deferred.is_empty(), "{:?}", swept.deferred); - settle(&mut st, through, &directory, &mut notes).await; - assert_eq!( - st.get_total_balance(account), - Zatoshis::const_from_u64(VALUE) - ); +/// What a later publication answers for a deferred candidate. +#[derive(Clone, Copy, Debug, PartialEq)] +enum Answer { + /// The payment the first publication misstated. + Corrected, + /// The same payment. + Unchanged, + /// Nothing: its paid filter misses the receiver. + Dropped, } /// A queued candidate that defers is replaced by each later attempt's answer from the -/// publication it uses: a corrected payment is recovered, and a paid-filter miss drops -/// the candidate, whatever made it wait. A deferral that queued a replay holds the next -/// attempt, without a request, until the replay is scanned. +/// publication it uses, whatever made it wait: a corrected payment is recovered with +/// fresh note data, an unchanged one reuses the queued note data, and a paid-filter miss +/// drops the candidate. A deferral that queued a replay holds the next attempt, without +/// a request, until the replay is scanned. #[tokio::test] async fn a_deferred_candidate_takes_each_publications_current_answer() { use PaymentApplication::{AwaitingSpendHistory, AwaitingWitness}; - for (deferral, corrected) in [ - (AwaitingSpendHistory, true), - (AwaitingWitness, false), - (AwaitingSpendHistory, false), + for (deferral, answer) in [ + (AwaitingSpendHistory, Answer::Corrected), + (AwaitingSpendHistory, Answer::Unchanged), + (AwaitingSpendHistory, Answer::Dropped), + (AwaitingWitness, Answer::Dropped), ] { - let mut st = ironwood_wallet(); - let account = st.test_account().unwrap().id(); - let key = KeyId::new(Purpose::Receive, 0); - let (record, note_data) = pay_restored_key(&mut st, key.index(), 0); - st.generate_and_scan_empty_blocks_with_one_time_ivks(1); - restore(&mut st, account); - let through = tip(&st); + let mut r = restored(&[0]); + let (record, through) = (r.records[0].clone(), r.through); let mut first = record.clone(); - if corrected { + if answer == Answer::Corrected { // Publication A misstates the payment's transaction. first.payment.tx_index += 1; } - let commitments = [record.payment.cmx]; let wipe = match deferral { AwaitingWitness => "DELETE FROM ironwood_tree_checkpoints", _ => "DELETE FROM nullifier_map; DELETE FROM ironwood_nullifier_scan_blocks;", }; - st.wallet().conn().execute_batch(wipe).unwrap(); - let mut notes = Notes::new(BTreeMap::from([(0, note_data)])); - let a = Directory::new(publish(&[first], &commitments, through, &provider(&[]))); - let swept = run(&mut st, through, &a, &mut notes, &NoLock, NOW) + r.st.wallet().conn().execute_batch(wipe).unwrap(); + let mut notes = r.notes(); + let a = serve(&[first], through); + let swept = run(&mut r.st, through, &a, &mut notes, &NoLock, NOW) .await .unwrap(); - assert!( - matches!( - &swept.deferred[..], - [(k, Error::Queued(d))] if *k == key && *d == deferral - ), - "{deferral:?}: {:?}", - swept.deferred - ); + assert_matches!(swept.deferred[..], [(k, Error::Queued(d))] if k.index() == 0 && *d == deferral); assert!(swept.pending); // Publication B, after the attempt's backoff. - let b = if corrected { - publish( - std::slice::from_ref(&record), - &commitments, - through, - &provider(&[]), - ) - } else { - publish(&[], &commitments, through, &provider(&[])) + let b = match answer { + Answer::Dropped => { + Directory::new(publish(&[], &[record.payment.cmx], through, &provider(&[]))) + } + _ => serve(&[record], through), }; - let b = Directory::new(b); if deferral == AwaitingSpendHistory { - let swept = run(&mut st, through, &b, &mut notes, &NoLock, NOW + 60) - .await - .unwrap(); - assert!(swept.deferred.is_empty(), "{:?}", swept.deferred); + run_clean(&mut r.st, through, &b, &mut notes, NOW + 60).await; assert_eq!(lookups(&b), 0, "{:?}", b.requests()); - scan_queued(&mut st); - } - let swept = run(&mut st, through, &b, &mut notes, &NoLock, NOW + 60) - .await - .unwrap(); - assert!( - swept.deferred.is_empty(), - "{deferral:?}: {:?}", - swept.deferred - ); - settle(&mut st, through, &b, &mut notes).await; - let value = if corrected { VALUE } else { 0 }; - assert_eq!( - st.get_total_balance(account), - Zatoshis::const_from_u64(value), - "{deferral:?}" - ); - } -} - -/// An unchanged answer reuses the queued candidate's note data. -#[tokio::test] -async fn an_unchanged_answer_reuses_queued_note_data() { - let mut st = ironwood_wallet(); - let account = st.test_account().unwrap().id(); - let (record, note_data) = pay_restored_key(&mut st, 0, 0); - st.generate_and_scan_empty_blocks_with_one_time_ivks(1); - restore(&mut st, account); - let through = tip(&st); - st.wallet() - .conn() - .execute_batch("DELETE FROM nullifier_map; DELETE FROM ironwood_nullifier_scan_blocks;") - .unwrap(); - let mut notes = Notes::new(BTreeMap::from([(record.payment.position, note_data)])); - let directory = Directory::new(publish( - std::slice::from_ref(&record), - &[record.payment.cmx], - through, - &provider(&[]), - )); - let swept = run(&mut st, through, &directory, &mut notes, &NoLock, NOW) - .await - .unwrap(); - assert!( - matches!( - &swept.deferred[..], - [(_, Error::Queued(PaymentApplication::AwaitingSpendHistory))] - ), - "{:?}", - swept.deferred - ); - // Once the replay the deferral queued is scanned, the next attempt applies it. - scan_queued(&mut st); - let swept = run(&mut st, through, &directory, &mut notes, &NoLock, NOW + 60) - .await - .unwrap(); - assert!(swept.deferred.is_empty(), "{:?}", swept.deferred); - assert_eq!( - st.get_total_balance(account), - Zatoshis::const_from_u64(VALUE) - ); - assert!(lookups(&directory) >= 2, "{:?}", directory.requests()); - assert_eq!(notes.requested, [vec![record.payment.position]]); -} - -/// A seen set that holds every restored receiver moves the lookahead after each batch, -/// so a run sweeps at most one batch and leaves the new keys pending. Later runs -/// progress and find a payment far past the first batch. -#[tokio::test] -async fn each_run_sweeps_one_batch_and_later_runs_progress() { - let mut st = ironwood_wallet(); - let account = st.test_account().unwrap().id(); - let paid = 100; - let (record, note_data) = pay_restored_key(&mut st, paid, 0); - st.generate_and_scan_empty_blocks_with_one_time_ivks(1); - restore(&mut st, account); - let through = tip(&st); - // The set is finite, so an unbounded run stops past it instead of hanging. - let seen: Vec<_> = (0..200).map(|i| receiver(&st, i)).collect(); - let directory = Directory::new(publish( - std::slice::from_ref(&record), - &[record.payment.cmx], - through, - &provider(&seen), - )); - let mut notes = Notes::new(BTreeMap::from([(record.payment.position, note_data)])); - let mut runs = 0; - loop { - let swept = run(&mut st, through, &directory, &mut notes, &NoLock, NOW) - .await - .unwrap(); - runs += 1; - assert!(swept.deferred.is_empty(), "{:?}", swept.deferred); - assert!( - (1..=64).contains(&swept.finished), - "run {runs} finished {}", - swept.finished - ); - if !swept.pending { - break; + scan_queued(&mut r.st); } - assert!(runs < 20); + run_clean(&mut r.st, through, &b, &mut notes, NOW + 60).await; + settle(&mut r.st, through, &b, &mut notes).await; + let case = format!("{deferral:?} {answer:?}"); + let value = if answer == Answer::Dropped { 0 } else { VALUE }; + assert_eq!(balance(&r.st), value, "{case}"); + assert_eq!(lookups(&b) > 0, answer != Answer::Dropped, "{case}"); + let requested = if answer == Answer::Corrected { 2 } else { 1 }; + assert_eq!(notes.requested.len(), requested, "{case}"); } - assert!(runs > 1); - assert_eq!( - st.get_total_balance(account), - Zatoshis::const_from_u64(VALUE) - ); }