referrerpolicy=no-referrer-when-downgrade

sc_hop/
pool.rs

1// Copyright (C) Parity Technologies (UK) Ltd.
2// SPDX-License-Identifier: GPL-3.0-or-later WITH Classpath-exception-2.0
3
4// This program is free software: you can redistribute it and/or modify
5// it under the terms of the GNU General Public License as published by
6// the Free Software Foundation, either version 3 of the License, or
7// (at your option) any later version.
8
9// This program is distributed in the hope that it will be useful,
10// but WITHOUT ANY WARRANTY; without even the implied warranty of
11// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12// GNU General Public License for more details.
13
14// You should have received a copy of the GNU General Public License
15// along with this program. If not, see <https://www.gnu.org/licenses/>.
16
17//! HOP data pool: parity-db metadata at `<data_dir>/meta-db/` plus
18//! content-addressed blob files at `<data_dir>/blobs/{shard}/{hash}.blob`.
19//! In-memory counters are caches rebuilt at startup. RMW ops serialise on
20//! `rmw_lock` since parity-db has no CAS.
21//!
22//! The database carries a schema version row in `COL_DB_META`, checked on
23//! every open so a database written by a newer binary is rejected rather than
24//! misread. Startup also imports any leftover `<data_dir>/meta/` metadata files
25//! from the pre-KV-store layout and removes that tree; see
26//! `HopDataPool::import_legacy_meta_files`.
27
28use crate::{
29	metrics::{removal_reasons, HopMetrics},
30	rate_limit::{RateLimitConfig, RateLimiter},
31	types::{
32		entry_accounted_size, promotion_backoff_blocks, signing_payload, HopBlockNumber,
33		HopEntryMeta, HopError, HopHash, PoolStatus, RecipientVec, SenderId, HOP_ACK_CONTEXT,
34		HOP_CLAIM_CONTEXT, HOP_META_VERSION, MAX_PROMOTION_ATTEMPTS,
35	},
36};
37use codec::{Decode, Encode};
38use parking_lot::{Mutex, RwLock};
39use sp_core::H256;
40use sp_crypto_hashing::blake2_256;
41use sp_runtime::{
42	traits::{IdentifyAccount, Verify},
43	MultiSignature, MultiSigner,
44};
45use std::{
46	collections::{BTreeSet, HashMap},
47	fs,
48	ops::Bound,
49	path::{Path, PathBuf},
50	process,
51	sync::{
52		atomic::{AtomicU64, Ordering},
53		Arc,
54	},
55	time::{SystemTime, UNIX_EPOCH},
56};
57
58/// Disambiguates concurrent atomic writes to the same final blob path so two
59/// threads with the same content hash don't share a `<path>.tmp` file.
60static TMP_SEQ: AtomicU64 = AtomicU64::new(0);
61
62const BLOBS_DIR: &str = "blobs";
63const BLOB_EXT: &str = "blob";
64/// Subdirectory of `data_dir` housing the parity-db instance.
65const META_DB_DIR: &str = "meta-db";
66/// Number of shards used for the `blobs/` directory (one per first-byte value
67/// of the content hash: `00`–`ff`). Metadata is no longer sharded since it
68/// lives in parity-db.
69const SHARD_COUNT: u16 = 256;
70
71/// Pre-KV-store metadata layout: `<data_dir>/meta/{shard}/{hash}.meta`, one
72/// SCALE-encoded `HopEntryMeta` per file. Imported into [`COL_META`] and
73/// removed on the first startup after the upgrade.
74const LEGACY_META_DIR: &str = "meta";
75const LEGACY_META_EXT: &str = "meta";
76
77/// parity-db column holding `HopHash` → SCALE-encoded `HopEntryMeta`.
78const COL_META: u8 = 0;
79/// parity-db column holding pool-wide metadata. Kept separate from
80/// [`COL_META`] so the startup scan can iterate entry rows without having to
81/// recognise and skip non-entry keys.
82const COL_DB_META: u8 = 1;
83/// Total number of parity-db columns this pool uses.
84const COL_COUNT: u8 = 2;
85
86/// Key of the schema version row in [`COL_DB_META`].
87const KEY_DB_VERSION: &[u8] = b"version";
88/// On-disk schema version this binary writes and understands.
89const CURRENT_DB_VERSION: u32 = 1;
90/// Upper bound on rows buffered per `commit` while importing legacy metadata,
91/// so a large pool doesn't build one unbounded transaction.
92const MIGRATION_CHUNK: usize = 10_000;
93
94/// HOP data pool: parity-db metadata + content-addressed blob files.
95pub struct HopDataPool {
96	/// Metadata KV store; source of truth for entry existence and state.
97	db: Arc<parity_db::Db>,
98	/// Serialises get-then-conditional-write pairs (parity-db has no CAS).
99	rmw_lock: Mutex<()>,
100	/// Per-user byte usage cache, rebuilt at startup.
101	user_usage: RwLock<HashMap<SenderId, AtomicU64>>,
102	/// Expiry-ordered index of live entries, rebuilt at startup. Lets
103	/// `cleanup_expired` and `get_promotable` run as bounded range scans
104	/// instead of iterating the entire meta column each tick. Stale or
105	/// missing entries are tolerated: maintenance re-reads each candidate
106	/// under `rmw_lock` before acting on it.
107	expiry_index: RwLock<BTreeSet<(u64, HopHash)>>,
108	/// Maximum pool size in bytes (data + per-entry metadata overhead).
109	max_size: u64,
110	/// Fixed hard per-user quota in bytes.
111	max_user_size: u64,
112	/// Current accounted pool size in bytes.
113	current_size: AtomicU64,
114	/// Cached entry count for `status()`.
115	entry_count: AtomicU64,
116	/// Data retention period in seconds.
117	retention_secs: u64,
118	/// Root data directory.
119	data_dir: PathBuf,
120	/// Per-account submit rate limiter.
121	rate_limiter: Arc<RateLimiter>,
122	/// Prometheus metrics (no-ops without a registry).
123	metrics: HopMetrics,
124}
125
126impl HopDataPool {
127	/// Open or create the metadata DB, rebuild counter caches by iterating it,
128	/// and remove orphan `.blob` files in the same pass.
129	pub fn new(
130		max_size: u64,
131		max_user_size: u64,
132		retention_secs: u64,
133		data_dir: PathBuf,
134		rate_limit_cfg: RateLimitConfig,
135		metrics: HopMetrics,
136	) -> Result<Self, HopError> {
137		// Blob shard directories (256 of them, named 00..ff).
138		for i in 0..SHARD_COUNT {
139			let shard = format!("{:02x}", i as u8);
140			fs::create_dir_all(data_dir.join(BLOBS_DIR).join(&shard))?;
141		}
142
143		let db_path = data_dir.join(META_DB_DIR);
144		fs::create_dir_all(&db_path)?;
145		// Column layout upgrades happen while the DB is closed, since parity-db
146		// refuses to open a path whose stored column config differs from ours.
147		Self::migrate_columns(&db_path)?;
148		let db = parity_db::Db::open_or_create(&Self::db_options(&db_path))
149			.map_err(|e| HopError::Db(e.to_string()))?;
150		Self::check_db_version(&db)?;
151
152		// Import pre-KV-store metadata before the scan below, so the imported
153		// rows feed the counter caches and their blobs count as live.
154		Self::import_legacy_meta_files(&db, &data_dir)?;
155
156		// Rebuild counters + live-hash set, dropping any unsupported-version rows.
157		let mut user_usage: HashMap<SenderId, AtomicU64> = HashMap::new();
158		let mut current_size: u64 = 0;
159		let mut entry_count: u64 = 0;
160		let mut live_hashes: std::collections::HashSet<HopHash> = std::collections::HashSet::new();
161		let mut expiry_index: BTreeSet<(u64, HopHash)> = BTreeSet::new();
162		let mut stale_keys: Vec<Vec<u8>> = Vec::new();
163
164		{
165			let mut iter = db.iter(COL_META).map_err(|e| HopError::Db(e.to_string()))?;
166			while let Some((key, value)) = iter.next().map_err(|e| HopError::Db(e.to_string()))? {
167				let hash = match <[u8; 32]>::try_from(key.as_slice()) {
168					Ok(arr) => H256(arr),
169					Err(_) => {
170						tracing::warn!(target: "hop", key_len = key.len(), "Dropping meta row with non-32-byte key");
171						stale_keys.push(key);
172						continue;
173					},
174				};
175				match HopEntryMeta::decode(&mut value.as_slice()) {
176					Ok(meta) if meta.version == HOP_META_VERSION => {
177						// `insert` writes the blob before committing the meta row, so a
178						// committed row must have a sibling blob. A missing blob means a
179						// crash persisted the blob unlink but lost the async meta-delete;
180						// drop the row here rather than let it hold pool + user quota
181						// until expiry.
182						if !Self::entry_path(&data_dir, &hash, BLOBS_DIR, BLOB_EXT).exists() {
183							tracing::warn!(target: "hop", hash = ?hex::encode(hash), "Dropping meta row with missing blob");
184							stale_keys.push(key);
185							continue;
186						}
187						let accounted = entry_accounted_size(meta.size, meta.recipients.len());
188						current_size = current_size.saturating_add(accounted);
189						entry_count = entry_count.saturating_add(1);
190						user_usage
191							.entry(meta.sender_id)
192							.or_default()
193							.fetch_add(accounted, Ordering::Relaxed);
194						live_hashes.insert(hash);
195						expiry_index.insert((meta.expires_at, hash));
196					},
197					Ok(meta) => {
198						tracing::warn!(
199							target: "hop",
200							version = meta.version,
201							expected = HOP_META_VERSION,
202							hash = ?hex::encode(hash),
203							"Dropping meta row with unsupported version",
204						);
205						stale_keys.push(key);
206					},
207					Err(e) => {
208						tracing::warn!(target: "hop", hash = ?hex::encode(hash), error = %e, "Dropping undecodable meta row");
209						stale_keys.push(key);
210					},
211				}
212			}
213		}
214
215		// Delete stale keys and the corresponding blobs in one batch.
216		if !stale_keys.is_empty() {
217			let ops: Vec<_> =
218				stale_keys.iter().cloned().map(|k| (COL_META, k, None::<Vec<u8>>)).collect();
219			db.commit(ops).map_err(|e| HopError::Db(e.to_string()))?;
220			for k in &stale_keys {
221				if let Ok(arr) = <[u8; 32]>::try_from(k.as_slice()) {
222					let _ = fs::remove_file(Self::entry_path(
223						&data_dir,
224						&H256(arr),
225						BLOBS_DIR,
226						BLOB_EXT,
227					));
228				}
229			}
230		}
231
232		// Reap orphan `.blob` and leftover `.tmp.*` files. Deliberately
233		// unconditional: an empty meta column is a legitimate state for a pool
234		// that has drained, so refusing to reap when it is empty would leak
235		// genuine orphans forever. The case where the column is empty only
236		// because metadata hasn't been read yet is prevented upstream, by
237		// `import_legacy_meta_files` running before this scan.
238		for i in 0..SHARD_COUNT {
239			let shard = format!("{:02x}", i as u8);
240			let blob_shard_dir = data_dir.join(BLOBS_DIR).join(&shard);
241			let Ok(entries) = fs::read_dir(&blob_shard_dir) else { continue };
242			for entry in entries.flatten() {
243				let path = entry.path();
244				if path.extension().and_then(|e| e.to_str()) != Some(BLOB_EXT) {
245					if path
246						.file_name()
247						.and_then(|n| n.to_str())
248						.map_or(false, |n| n.contains(".tmp."))
249					{
250						let _ = fs::remove_file(&path);
251					}
252					continue;
253				}
254				let Some(stem) = path.file_stem().and_then(|s| s.to_str()) else { continue };
255				let is_orphan = match parse_hex_hash(stem) {
256					Some(hash) => !live_hashes.contains(&hash),
257					None => true,
258				};
259				if is_orphan {
260					tracing::warn!(target: "hop", hash = ?stem, "Removing orphan .blob (no meta row)");
261					let _ = fs::remove_file(&path);
262				}
263			}
264		}
265
266		tracing::info!(
267			target: "hop",
268			entries = entry_count,
269			total_bytes = current_size,
270			"Recovered HOP pool from disk"
271		);
272
273		// `stale_keys` are the meta rows dropped during recovery (bad key,
274		// unsupported version, undecodable, or missing blob). Orphan `.blob`s
275		// are not counted: without a meta row they were never a claimable entry.
276		metrics.set_pool_status(entry_count, current_size, max_size);
277		metrics.record_removed(removal_reasons::STARTUP_DROPPED, stale_keys.len() as u64);
278
279		Ok(Self {
280			db: Arc::new(db),
281			rmw_lock: Mutex::new(()),
282			user_usage: RwLock::new(user_usage),
283			expiry_index: RwLock::new(expiry_index),
284			max_size,
285			max_user_size,
286			current_size: AtomicU64::new(current_size),
287			entry_count: AtomicU64::new(entry_count),
288			retention_secs,
289			data_dir,
290			rate_limiter: Arc::new(RateLimiter::new(rate_limit_cfg)),
291			metrics,
292		})
293	}
294
295	/// Metrics shared by the pool, RPC server, and maintenance task.
296	pub(crate) fn metrics(&self) -> &HopMetrics {
297		&self.metrics
298	}
299
300	/// Snapshot the pool size gauges after a mutation. Reads the accounted-size
301	/// and entry-count atomics, which are updated under `rmw_lock` at every
302	/// mutation site, so a gauge publish issued from inside that critical
303	/// section reflects the write that just landed.
304	fn publish_size_metrics(&self) {
305		self.metrics.set_pool_size(
306			self.entry_count.load(Ordering::Relaxed),
307			self.current_size.load(Ordering::Relaxed),
308		);
309	}
310
311	/// Column layout this pool expects of its parity-db instance.
312	///
313	/// `btree_index` on [`COL_META`] is what lets startup recovery iterate
314	/// `(key, value)` pairs; [`COL_DB_META`] stays at the default hash index
315	/// since it is only ever point-queried.
316	fn db_options(db_path: &Path) -> parity_db::Options {
317		let mut options = parity_db::Options::with_columns(db_path, COL_COUNT);
318		options.columns[COL_META as usize].btree_index = true;
319		options
320	}
321
322	/// Append any columns an older on-disk layout is missing.
323	///
324	/// Must run before the DB is opened. No-op for a path with no database yet
325	/// (`open_or_create` will lay down every column) and for one already at
326	/// [`COL_COUNT`], so this is safe to call on every startup. Appended
327	/// columns take their options from [`Self::db_options`], which keeps the
328	/// migration and the open path from ever disagreeing.
329	fn migrate_columns(db_path: &Path) -> Result<(), HopError> {
330		let Some(metadata) =
331			parity_db::Options::load_metadata(db_path).map_err(|e| HopError::Db(e.to_string()))?
332		else {
333			return Ok(());
334		};
335		if metadata.columns.len() >= COL_COUNT as usize {
336			return Ok(());
337		}
338		let desired = Self::db_options(db_path).columns;
339		let mut migrate_options = parity_db::Options::with_columns(db_path, 0);
340		migrate_options.columns = metadata.columns;
341		tracing::info!(
342			target: "hop",
343			from = migrate_options.columns.len(),
344			to = COL_COUNT,
345			"Extending HOP database column layout",
346		);
347		// `add_column` takes options by value and pushes onto
348		// `migrate_options.columns`, so `skip` is evaluated once against the
349		// pre-migration length.
350		for column in desired.into_iter().skip(migrate_options.columns.len()) {
351			parity_db::Db::add_column(&mut migrate_options, column)
352				.map_err(|e| HopError::Db(e.to_string()))?;
353		}
354		Ok(())
355	}
356
357	/// Reconcile the on-disk schema version with [`CURRENT_DB_VERSION`].
358	///
359	/// A database with no version row is stamped with the current version; one
360	/// written by a newer binary is rejected rather than silently misread.
361	fn check_db_version(db: &parity_db::Db) -> Result<(), HopError> {
362		match db.get(COL_DB_META, KEY_DB_VERSION).map_err(|e| HopError::Db(e.to_string()))? {
363			Some(bytes) => {
364				let version = u32::from_le_bytes(
365					bytes
366						.try_into()
367						.map_err(|_| HopError::Db("Malformed HOP database version".into()))?,
368				);
369				if version > CURRENT_DB_VERSION {
370					return Err(HopError::Db(format!(
371						"Unsupported HOP database version {version}; this binary supports up to \
372						 {CURRENT_DB_VERSION}"
373					)));
374				}
375				// Only version 1 exists so far. Future forward migrations
376				// dispatch here on `version < CURRENT_DB_VERSION` and stamp the
377				// new version last, so a crash mid-migration re-runs it.
378				Ok(())
379			},
380			None => db
381				.commit([(
382					COL_DB_META,
383					KEY_DB_VERSION.to_vec(),
384					Some(CURRENT_DB_VERSION.to_le_bytes().to_vec()),
385				)])
386				.map_err(|e| HopError::Db(e.to_string())),
387		}
388	}
389
390	/// Import pre-KV-store `<data_dir>/meta/{shard}/{hash}.meta` files
391	/// into [`COL_META`], then remove the tree.
392	///
393	/// Runs before startup recovery iterates the column, so imported rows are
394	/// picked up by the normal scan: counters, per-user usage and the expiry
395	/// index all come for free, and the orphan pass sees the imported hashes as
396	/// live instead of unlinking every blob in the pool.
397	///
398	/// Gated on the directory existing rather than on the schema version, so an
399	/// import interrupted by a crash is retried on the next boot. Idempotent: a
400	/// row already in the column always wins over the legacy file. Per-file
401	/// problems are logged and skipped, leaving the blob to the orphan pass —
402	/// the same outcome the pre-migration recovery scan produced.
403	fn import_legacy_meta_files(db: &parity_db::Db, data_dir: &Path) -> Result<(), HopError> {
404		let legacy_dir = data_dir.join(LEGACY_META_DIR);
405		if !legacy_dir.exists() {
406			return Ok(());
407		}
408
409		let mut ops: Vec<(u8, Vec<u8>, Option<Vec<u8>>)> = Vec::new();
410		let mut imported: u64 = 0;
411		let mut skipped: u64 = 0;
412
413		for i in 0..SHARD_COUNT {
414			let shard = format!("{:02x}", i as u8);
415			let Ok(entries) = fs::read_dir(legacy_dir.join(&shard)) else { continue };
416			for entry in entries.flatten() {
417				let path = entry.path();
418				// Leftover `.tmp.*` files need no special handling: the whole
419				// tree is removed once the import completes.
420				if path.extension().and_then(|e| e.to_str()) != Some(LEGACY_META_EXT) {
421					continue;
422				}
423				let Some(stem) = path.file_stem().and_then(|s| s.to_str()) else { continue };
424				let Some(hash) = parse_hex_hash(stem) else {
425					tracing::warn!(target: "hop", path = ?path, "Skipping legacy .meta with invalid name");
426					skipped += 1;
427					continue;
428				};
429				let bytes = match fs::read(&path) {
430					Ok(b) => b,
431					Err(e) => {
432						tracing::warn!(target: "hop", path = ?path, error = %e, "Skipping unreadable legacy .meta");
433						skipped += 1;
434						continue;
435					},
436				};
437				let meta = match HopEntryMeta::decode(&mut bytes.as_slice()) {
438					Ok(meta) => meta,
439					Err(e) => {
440						tracing::warn!(target: "hop", path = ?path, error = %e, "Skipping corrupt legacy .meta");
441						skipped += 1;
442						continue;
443					},
444				};
445				if meta.version != HOP_META_VERSION {
446					tracing::warn!(
447						target: "hop",
448						path = ?path,
449						version = meta.version,
450						expected = HOP_META_VERSION,
451						"Skipping legacy .meta with unsupported version",
452					);
453					skipped += 1;
454					continue;
455				}
456				if !Self::entry_path(data_dir, &hash, BLOBS_DIR, BLOB_EXT).exists() {
457					tracing::warn!(target: "hop", hash = ?stem, "Skipping legacy .meta with no .blob");
458					skipped += 1;
459					continue;
460				}
461				// A row already in the column is newer than the legacy file.
462				if db
463					.get(COL_META, hash.as_bytes())
464					.map_err(|e| HopError::Db(e.to_string()))?
465					.is_some()
466				{
467					skipped += 1;
468					continue;
469				}
470
471				ops.push((COL_META, hash.as_bytes().to_vec(), Some(bytes)));
472				imported += 1;
473				if ops.len() >= MIGRATION_CHUNK {
474					db.commit(ops.drain(..)).map_err(|e| HopError::Db(e.to_string()))?;
475				}
476			}
477		}
478
479		if !ops.is_empty() {
480			db.commit(ops).map_err(|e| HopError::Db(e.to_string()))?;
481		}
482
483		// Rows are committed at this point, so a failure here is not fatal: the
484		// next boot rescans, skips every file as already-present, and retries.
485		if let Err(e) = fs::remove_dir_all(&legacy_dir) {
486			tracing::warn!(
487				target: "hop",
488				path = ?legacy_dir,
489				error = %e,
490				"Failed to remove legacy meta directory after import",
491			);
492		}
493
494		// The pre-KV-store build created all 256 shard directories on every
495		// boot, so an empty tree is the normal case for an upgraded idle node
496		// and must not look like an event.
497		if imported > 0 || skipped > 0 {
498			tracing::info!(
499				target: "hop",
500				imported,
501				skipped,
502				"Imported legacy HOP metadata into the KV store",
503			);
504		}
505		Ok(())
506	}
507
508	/// Get + decode a meta row. Decode failures surface as `Db(...)` since the
509	/// startup scan should have already dropped corrupt rows.
510	fn fetch_meta(&self, hash: &HopHash) -> Result<Option<HopEntryMeta>, HopError> {
511		match self
512			.db
513			.get(COL_META, hash.as_bytes())
514			.map_err(|e| HopError::Db(e.to_string()))?
515		{
516			Some(bytes) => HopEntryMeta::decode(&mut bytes.as_slice())
517				.map(Some)
518				.map_err(|e| HopError::Db(format!("decoding meta for {}: {e}", hex::encode(hash)))),
519			None => Ok(None),
520		}
521	}
522
523	/// Commit a single (key, optional value) op to the meta column.
524	fn commit_meta(&self, hash: &HopHash, value: Option<Vec<u8>>) -> Result<(), HopError> {
525		self.db
526			.commit([(COL_META, hash.as_bytes().to_vec(), value)])
527			.map_err(|e| HopError::Db(e.to_string()))
528	}
529
530	/// Charge `accounted` bytes against `sender_id`'s per-user quota, creating
531	/// a zero-initialized counter if absent. The read guard held across the
532	/// `fetch_add` excludes the reclamation pass in `cleanup_expired` (which
533	/// takes `user_usage.write()`), so the counter cannot be reclaimed
534	/// between lookup and increment.
535	fn charge_user(&self, sender_id: &SenderId, accounted: u64) -> Result<(), HopError> {
536		// Fast path: sender already in map, a read guard is enough.
537		{
538			let usage = self.user_usage.read();
539			if let Some(counter) = usage.get(sender_id) {
540				return self.try_charge(counter, accounted);
541			}
542		}
543		// Cold path: first insert from this sender — take the write guard.
544		let mut usage = self.user_usage.write();
545		let counter = usage.entry(*sender_id).or_default();
546		self.try_charge(counter, accounted)
547	}
548
549	/// Atomically increment `counter` by `accounted`, rolling back on cap
550	/// overflow. `saturating_add` clamps to `u64::MAX` if concurrent failing
551	/// charges briefly inflate the previous value past the wrap point,
552	/// ensuring overflow always falls into the "exceeds cap" branch.
553	fn try_charge(&self, counter: &AtomicU64, accounted: u64) -> Result<(), HopError> {
554		let previous = counter.fetch_add(accounted, Ordering::Relaxed);
555		if previous.saturating_add(accounted) > self.max_user_size {
556			counter.fetch_sub(accounted, Ordering::Relaxed);
557			return Err(HopError::UserQuotaExceeded { used: previous, limit: self.max_user_size });
558		}
559		Ok(())
560	}
561
562	/// Decrement a user's usage counter. Counters are never removed by this
563	/// path; reclamation happens only in the per-sender pass at the end of
564	/// `cleanup_expired`.
565	fn release_user_quota(&self, sender_id: &SenderId, accounted: u64) {
566		if let Some(counter) = self.user_usage.read().get(sender_id) {
567			saturating_release(counter, accounted);
568		}
569	}
570
571	/// Path to a file within a shard subdirectory rooted at `data_dir`.
572	fn entry_path(data_dir: &Path, hash: &HopHash, subdir: &str, ext: &str) -> PathBuf {
573		let hex = hex::encode(hash);
574		data_dir.join(subdir).join(&hex[..2]).join(format!("{}.{}", hex, ext))
575	}
576
577	/// Path to the blob file for a given hash.
578	fn blob_path(&self, hash: &HopHash) -> PathBuf {
579		Self::entry_path(&self.data_dir, hash, BLOBS_DIR, BLOB_EXT)
580	}
581
582	/// Atomically write data to a file (write to a unique .tmp path, then rename).
583	///
584	/// The tmp suffix encodes process id + a per-process atomic counter so two
585	/// threads writing the same final path (i.e. same content-addressed hash)
586	/// do not race on a shared tmp file. Removes the tmp file on failure so a
587	/// failed write never leaves an orphan.
588	fn write_atomic(path: &Path, data: &[u8]) -> Result<(), HopError> {
589		let suffix = format!("tmp.{}.{}", process::id(), TMP_SEQ.fetch_add(1, Ordering::Relaxed));
590		let tmp_path = path.with_extension(suffix);
591		if let Err(e) = fs::write(&tmp_path, data) {
592			let _ = fs::remove_file(&tmp_path);
593			return Err(e.into());
594		}
595		if let Err(e) = fs::rename(&tmp_path, path) {
596			let _ = fs::remove_file(&tmp_path);
597			return Err(e.into());
598		}
599		Ok(())
600	}
601
602	/// Insert data into the pool.
603	///
604	/// Returns the hash of the data.
605	pub fn insert(
606		&self,
607		data: Vec<u8>,
608		recipients: RecipientVec,
609		sender_id: SenderId,
610		signer: MultiSigner,
611		signature: MultiSignature,
612		submit_timestamp: u64,
613	) -> Result<HopHash, HopError> {
614		if recipients.is_empty() {
615			return Err(HopError::NoRecipients);
616		}
617		let unique: BTreeSet<&MultiSigner> = recipients.iter().map(|r| &r.signer).collect();
618		if unique.len() != recipients.len() {
619			return Err(HopError::DuplicateRecipient);
620		}
621
622		if data.is_empty() {
623			return Err(HopError::EmptyData);
624		}
625
626		let data_len = data.len() as u64;
627
628		// Total accounted size includes bounded per-recipient metadata overhead so
629		// a submitter cannot inflate memory via large recipient lists while the
630		// capacity counter only tracks `data.len()`. Charge the rate limiter the
631		// same accounted size, otherwise a 1-byte payload with 256 recipients
632		// would cost ~10 KiB of pool capacity while only spending 1 byte of
633		// bandwidth tokens — making the bandwidth dimension non-functional for
634		// fan-out-heavy entries.
635		let accounted = entry_accounted_size(data_len, recipients.len());
636
637		// Rejected requests never reserve capacity — check before any atomic bump.
638		if let Err(retry_after_secs) = self.rate_limiter.check(&sender_id, accounted) {
639			return Err(HopError::RateLimited { retry_after_secs });
640		}
641
642		let previous_size = self.current_size.fetch_add(accounted, Ordering::Relaxed);
643		if previous_size.saturating_add(accounted) > self.max_size {
644			self.current_size.fetch_sub(accounted, Ordering::Relaxed);
645			return Err(HopError::PoolFull(previous_size, self.max_size));
646		}
647
648		if let Err(e) = self.charge_user(&sender_id, accounted) {
649			self.current_size.fetch_sub(accounted, Ordering::Relaxed);
650			return Err(e);
651		}
652
653		let hash = H256(blake2_256(&data));
654
655		// Best-effort duplicate check; authoritative check happens under rmw_lock.
656		match self.fetch_meta(&hash) {
657			Ok(Some(_)) => {
658				self.release_user_quota(&sender_id, accounted);
659				self.current_size.fetch_sub(accounted, Ordering::Relaxed);
660				return Err(HopError::DuplicateEntry);
661			},
662			Ok(None) => (),
663			Err(e) => {
664				self.release_user_quota(&sender_id, accounted);
665				self.current_size.fetch_sub(accounted, Ordering::Relaxed);
666				return Err(e);
667			},
668		}
669
670		// Blob first; an orphan from a crash before commit is reaped on next startup.
671		let blob_path = self.blob_path(&hash);
672		if let Err(e) = Self::write_atomic(&blob_path, &data) {
673			self.release_user_quota(&sender_id, accounted);
674			self.current_size.fetch_sub(accounted, Ordering::Relaxed);
675			return Err(e);
676		}
677
678		let expires_at = SystemTime::now()
679			.duration_since(UNIX_EPOCH)
680			.unwrap_or_default()
681			.as_secs()
682			.saturating_add(self.retention_secs);
683		let meta = HopEntryMeta::new(
684			data_len,
685			expires_at,
686			recipients,
687			sender_id,
688			signer,
689			signature,
690			submit_timestamp,
691		);
692		let meta_bytes = meta.encode();
693
694		// Authoritative dup-check + commit (CAS substitute).
695		{
696			let _guard = self.rmw_lock.lock();
697			match self.fetch_meta(&hash) {
698				Ok(Some(_)) => {
699					drop(_guard);
700					// Winner's blob is byte-identical (content addressing); leave it.
701					tracing::debug!(
702						target: "hop",
703						hash = ?hex::encode(hash),
704						"Duplicate insert race lost; keeping winner's blob"
705					);
706					self.release_user_quota(&sender_id, accounted);
707					self.current_size.fetch_sub(accounted, Ordering::Relaxed);
708					return Err(HopError::DuplicateEntry);
709				},
710				Ok(None) => (),
711				Err(e) => {
712					drop(_guard);
713					let _ = fs::remove_file(&blob_path);
714					self.release_user_quota(&sender_id, accounted);
715					self.current_size.fetch_sub(accounted, Ordering::Relaxed);
716					return Err(e);
717				},
718			}
719			if let Err(e) = self.commit_meta(&hash, Some(meta_bytes)) {
720				drop(_guard);
721				let _ = fs::remove_file(&blob_path);
722				self.release_user_quota(&sender_id, accounted);
723				self.current_size.fetch_sub(accounted, Ordering::Relaxed);
724				return Err(e);
725			}
726		}
727
728		self.expiry_index.write().insert((expires_at, hash));
729		self.entry_count.fetch_add(1, Ordering::Relaxed);
730		self.metrics.record_inserted_bytes(accounted);
731		self.publish_size_metrics();
732
733		tracing::info!(
734			target: "hop",
735			hash = ?hex::encode(hash),
736			size = data_len,
737			accounted,
738			expires_at,
739			"Data added to HOP pool"
740		);
741
742		Ok(hash)
743	}
744
745	/// Read a blob from disk and verify its content hash.
746	///
747	/// Content addressing means `blake2_256(data) == *hash` is an invariant
748	/// — corruption (bit rot, partial write, local tampering) violates it.
749	/// On integrity failure the caller-facing result is the same as a missing
750	/// blob and the broken entry is purged so subsequent reads converge.
751	fn read_and_verify_blob(&self, hash: &HopHash) -> Result<Vec<u8>, HopError> {
752		let blob_path = self.blob_path(hash);
753		let data = fs::read(&blob_path).map_err(|e| {
754			if e.kind() == std::io::ErrorKind::NotFound {
755				HopError::NotFound
756			} else {
757				HopError::IoError(e)
758			}
759		})?;
760		if H256(blake2_256(&data)) != *hash {
761			tracing::error!(
762				target: "hop",
763				hash = ?hex::encode(hash),
764				size = data.len(),
765				"Blob integrity check failed; purging entry"
766			);
767			self.purge_corrupt_entry(hash);
768			return Err(HopError::NotFound);
769		}
770		Ok(data)
771	}
772
773	/// Remove a corrupt entry's meta row and best-effort delete its blob.
774	/// The accounted size is released back to the pool and the user quota.
775	fn purge_corrupt_entry(&self, hash: &HopHash) {
776		let removed = {
777			let _guard = self.rmw_lock.lock();
778			match self.fetch_meta(hash) {
779				Ok(Some(meta)) => {
780					if let Err(e) = self.commit_meta(hash, None) {
781						tracing::error!(
782							target: "hop",
783							hash = ?hex::encode(hash),
784							error = %e,
785							"Failed to delete corrupt meta row",
786						);
787						None
788					} else {
789						Some(meta)
790					}
791				},
792				Ok(None) => None,
793				Err(e) => {
794					tracing::error!(target: "hop", hash = ?hex::encode(hash), error = %e, "Failed to read meta during corrupt-entry purge");
795					None
796				},
797			}
798		};
799		if let Some(meta) = removed {
800			let accounted = entry_accounted_size(meta.size, meta.recipients.len());
801			self.expiry_index.write().remove(&(meta.expires_at, *hash));
802			self.current_size.fetch_sub(accounted, Ordering::Relaxed);
803			self.entry_count.fetch_sub(1, Ordering::Relaxed);
804			self.release_user_quota(&meta.sender_id, accounted);
805			self.metrics.record_removed(removal_reasons::CORRUPT, 1);
806			self.publish_size_metrics();
807		}
808		let _ = fs::remove_file(self.blob_path(hash));
809	}
810
811	/// Read and verify a blob, returning `None` for missing entries and logging
812	/// any other failure. Shared by [`Self::get`] and [`Self::get_with_auth`].
813	fn read_or_log(&self, hash: &HopHash) -> Option<Vec<u8>> {
814		match self.read_and_verify_blob(hash) {
815			Ok(data) => Some(data),
816			Err(HopError::NotFound) => None,
817			Err(e) => {
818				tracing::error!(
819					target: "hop",
820					hash = ?hex::encode(hash),
821					error = ?e,
822					"Failed to read blob from disk"
823				);
824				None
825			},
826		}
827	}
828
829	/// Get data from the pool by content hash.
830	pub fn get(&self, hash: &HopHash) -> Option<Vec<u8>> {
831		match self.fetch_meta(hash) {
832			Ok(Some(_)) => self.read_or_log(hash),
833			Ok(None) => None,
834			Err(e) => {
835				tracing::error!(target: "hop", hash = ?hex::encode(hash), error = %e, "Failed to read meta for get");
836				None
837			},
838		}
839	}
840
841	/// Get data alongside the submitter's `MultiSigner`, `hop_submit` signature,
842	/// and submit timestamp.
843	///
844	/// Used by the promoter so the unsigned promotion extrinsic can carry the
845	/// user's submit-time signature for runtime-side verification.
846	pub fn get_with_auth(
847		&self,
848		hash: &HopHash,
849	) -> Option<(Vec<u8>, MultiSigner, MultiSignature, u64)> {
850		let meta = match self.fetch_meta(hash) {
851			Ok(Some(m)) => m,
852			Ok(None) => return None,
853			Err(e) => {
854				tracing::error!(target: "hop", hash = ?hex::encode(hash), error = %e, "Failed to read meta for get_with_auth");
855				return None;
856			},
857		};
858		let data = self.read_or_log(hash)?;
859		Some((data, meta.signer, meta.signature, meta.submit_timestamp))
860	}
861
862	/// Decode `signature` and return the index of the matching recipient in
863	/// `meta.recipients`. `context` is the operation's domain separator (claim
864	/// / ack). Returning an index keeps a single implementation for both
865	/// shared- and exclusive-borrow callers (`meta.recipients[idx]` works in
866	/// either case).
867	fn find_recipient_idx(
868		meta: &HopEntryMeta,
869		hash: &HopHash,
870		signature: &[u8],
871		context: &[u8],
872	) -> Result<usize, HopError> {
873		let multi_sig =
874			MultiSignature::decode(&mut &signature[..]).map_err(|_| HopError::InvalidSignature)?;
875		let payload = signing_payload(context, hash);
876
877		meta.recipients
878			.iter()
879			.position(|r| multi_sig.verify(&payload[..], &r.signer.clone().into_account()))
880			.ok_or(HopError::NotRecipient)
881	}
882
883	/// Claim data from the pool (read-only). Verifies the signature against recipient
884	/// public keys. Returns the data if the signature matches a recipient.
885	///
886	/// This does NOT mark the recipient as claimed — call `ack` after receiving the data
887	/// to confirm receipt.
888	///
889	/// Returns `AlreadyClaimed` if the recipient has already acked (data may be deleted).
890	pub fn claim(&self, hash: &HopHash, signature: &[u8]) -> Result<Vec<u8>, HopError> {
891		let meta = self.fetch_meta(hash)?.ok_or(HopError::NotFound)?;
892		// Map NotRecipient → NotFound so callers cannot probe whether a hash
893		// exists by observing different error codes.
894		let idx = Self::find_recipient_idx(&meta, hash, signature, HOP_CLAIM_CONTEXT)
895			.map_err(|_| HopError::NotFound)?;
896
897		// If this recipient already acked, the data may be gone.
898		if meta.recipients[idx].claimed {
899			return Err(HopError::AlreadyClaimed);
900		}
901		// Read blob from disk and verify its content hash. May be gone if
902		// concurrently acked and deleted, in which case we surface NotFound.
903		self.read_and_verify_blob(hash)
904	}
905
906	/// Acknowledge receipt of claimed data. Marks the recipient as claimed and triggers
907	/// cleanup when all recipients have acked.
908	///
909	/// Idempotent: acking a recipient that already acked returns `Ok(())`.
910	pub fn ack(&self, hash: &HopHash, signature: &[u8]) -> Result<(), HopError> {
911		// Phase 1: idempotent fast-path read; no lock acquired.
912		{
913			let meta = self.fetch_meta(hash)?.ok_or(HopError::NotFound)?;
914			let idx = Self::find_recipient_idx(&meta, hash, signature, HOP_ACK_CONTEXT)
915				.map_err(|_| HopError::NotFound)?;
916			if meta.recipients[idx].claimed {
917				return Ok(());
918			}
919		}
920
921		// Phase 2: RMW under rmw_lock; re-run the lookup as the meta may have changed.
922		let _guard = self.rmw_lock.lock();
923		let mut meta = self.fetch_meta(hash)?.ok_or(HopError::NotFound)?;
924		let idx = Self::find_recipient_idx(&meta, hash, signature, HOP_ACK_CONTEXT)
925			.map_err(|_| HopError::NotFound)?;
926
927		if meta.recipients[idx].claimed {
928			return Ok(());
929		}
930
931		meta.recipients[idx].claimed = true;
932
933		if meta.recipients.iter().all(|r| r.claimed) {
934			let accounted = entry_accounted_size(meta.size, meta.recipients.len());
935			let sender = meta.sender_id;
936			let expires_at = meta.expires_at;
937			self.commit_meta(hash, None)?;
938			drop(_guard);
939
940			self.expiry_index.write().remove(&(expires_at, *hash));
941			self.current_size.fetch_sub(accounted, Ordering::Relaxed);
942			self.entry_count.fetch_sub(1, Ordering::Relaxed);
943			self.release_user_quota(&sender, accounted);
944			self.metrics.record_removed(removal_reasons::ACKED, 1);
945			self.publish_size_metrics();
946
947			// Blob delete is best-effort; orphans get reaped on restart.
948			let _ = fs::remove_file(self.blob_path(hash));
949
950			tracing::info!(
951				target: "hop",
952				hash = ?hex::encode(hash),
953				"All recipients acked, data removed"
954			);
955		} else {
956			let claimed_count = meta.recipients.iter().filter(|r| r.claimed).count();
957			self.commit_meta(hash, Some(meta.encode()))?;
958			drop(_guard);
959
960			tracing::debug!(
961				target: "hop",
962				hash = ?hex::encode(hash),
963				claimed = claimed_count,
964				"Recipient acked"
965			);
966		}
967
968		Ok(())
969	}
970
971	/// Check if data exists in the pool.
972	#[cfg(test)]
973	pub fn has(&self, hash: &HopHash) -> bool {
974		matches!(self.fetch_meta(hash), Ok(Some(_)))
975	}
976
977	/// Remove data from the pool.
978	#[cfg(test)]
979	pub fn remove(&self, hash: &HopHash) -> Result<(), HopError> {
980		let meta = {
981			let _guard = self.rmw_lock.lock();
982			let Some(meta) = self.fetch_meta(hash)? else {
983				return Err(HopError::NotFound);
984			};
985			self.commit_meta(hash, None)?;
986			meta
987		};
988
989		let accounted = entry_accounted_size(meta.size, meta.recipients.len());
990		self.expiry_index.write().remove(&(meta.expires_at, *hash));
991		self.current_size.fetch_sub(accounted, Ordering::Relaxed);
992		self.entry_count.fetch_sub(1, Ordering::Relaxed);
993		self.release_user_quota(&meta.sender_id, accounted);
994
995		let _ = fs::remove_file(self.blob_path(hash));
996
997		tracing::debug!(
998			target: "hop",
999			hash = ?hex::encode(hash),
1000			"Data removed from pool"
1001		);
1002
1003		Ok(())
1004	}
1005
1006	/// Get pool status.
1007	pub fn status(&self) -> PoolStatus {
1008		PoolStatus {
1009			entry_count: self.entry_count.load(Ordering::Relaxed) as usize,
1010			total_bytes: self.current_size.load(Ordering::Relaxed),
1011			max_bytes: self.max_size,
1012		}
1013	}
1014
1015	/// Remove expired entries, release their quotas, return total bytes freed.
1016	///
1017	/// Uses `expiry_index` to enumerate expired hashes in O(K), not O(N) over
1018	/// the meta column. Entries are processed in batches so a long outage
1019	/// can't buffer the whole expired set in RAM before any progress lands.
1020	///
1021	/// `promotion_buffer_secs` does not affect what is cleaned up; as in
1022	/// [`Self::get_promotable`] the caller owns the promotion window. Here it
1023	/// only scopes the promotion-backlog gauge, snapshot after the sweep.
1024	pub fn cleanup_expired(&self, promotion_buffer_secs: u64) -> u64 {
1025		const CLEANUP_BATCH_SIZE: usize = 10_000;
1026		let now_secs = SystemTime::now().duration_since(UNIX_EPOCH).unwrap_or_default().as_secs();
1027
1028		let mut total_freed: u64 = 0;
1029
1030		loop {
1031			// Snapshot one batch of expired index entries. The range is
1032			// inclusive of `(now, max-hash)` so an entry expiring exactly at
1033			// `now` is reaped this tick.
1034			let batch: Vec<(u64, HopHash)> = {
1035				let guard = self.expiry_index.read();
1036				guard
1037					.range((Bound::Unbounded, Bound::Included(&(now_secs, H256([0xff; 32])))))
1038					.take(CLEANUP_BATCH_SIZE)
1039					.copied()
1040					.collect()
1041			};
1042
1043			if batch.is_empty() {
1044				break;
1045			}
1046
1047			// Phase 2: re-read under rmw_lock (entries may have changed since
1048			// the snapshot), commit deletions, collect metas for accounting.
1049			let mut processed: Vec<(HopHash, HopEntryMeta)> = Vec::with_capacity(batch.len());
1050			{
1051				let _guard = self.rmw_lock.lock();
1052				let mut ops: Vec<(u8, Vec<u8>, Option<Vec<u8>>)> = Vec::with_capacity(batch.len());
1053				for (_, hash) in &batch {
1054					match self.fetch_meta(hash) {
1055						Ok(Some(meta)) if now_secs >= meta.expires_at => {
1056							ops.push((COL_META, hash.as_bytes().to_vec(), None));
1057							processed.push((*hash, meta));
1058						},
1059						Ok(_) => (), // gone or refreshed by a concurrent op
1060						Err(e) => {
1061							tracing::error!(target: "hop", hash = ?hex::encode(hash), error = %e, "cleanup_expired: failed to re-read meta");
1062						},
1063					}
1064				}
1065				if !ops.is_empty() {
1066					if let Err(e) = self.db.commit(ops) {
1067						tracing::error!(target: "hop", error = %e, "cleanup_expired: batch commit failed");
1068						break;
1069					}
1070				}
1071			}
1072
1073			// Phase 3: drop every batch entry from the index — both processed
1074			// ones and any stale snapshots from a racing op — to guarantee
1075			// forward progress. Releasing the read guard before taking write
1076			// avoids a deadlock since the snapshot already finished above.
1077			{
1078				let mut index = self.expiry_index.write();
1079				for entry in &batch {
1080					index.remove(entry);
1081				}
1082			}
1083
1084			if processed.is_empty() {
1085				continue;
1086			}
1087
1088			// Entries expiring unpromoted are the data-loss case; count them
1089			// separately from ones that made it on-chain before expiry.
1090			let mut freed = 0u64;
1091			let mut promoted = 0u64;
1092			let mut unpromoted = 0u64;
1093			for (_, meta) in &processed {
1094				freed =
1095					freed.saturating_add(entry_accounted_size(meta.size, meta.recipients.len()));
1096				if meta.promoted {
1097					promoted = promoted.saturating_add(1);
1098				} else {
1099					unpromoted = unpromoted.saturating_add(1);
1100				}
1101			}
1102			self.current_size.fetch_sub(freed, Ordering::Relaxed);
1103			self.entry_count.fetch_sub(processed.len() as u64, Ordering::Relaxed);
1104			total_freed = total_freed.saturating_add(freed);
1105			self.metrics.record_removed(removal_reasons::EXPIRED_PROMOTED, promoted);
1106			self.metrics.record_removed(removal_reasons::EXPIRED_UNPROMOTED, unpromoted);
1107
1108			{
1109				let usage = self.user_usage.read();
1110				for (_, meta) in &processed {
1111					if let Some(counter) = usage.get(&meta.sender_id) {
1112						let accounted = entry_accounted_size(meta.size, meta.recipients.len());
1113						saturating_release(counter, accounted);
1114					}
1115				}
1116			}
1117
1118			for (hash, _) in &processed {
1119				let _ = fs::remove_file(self.blob_path(hash));
1120			}
1121		}
1122
1123		// Phase 4: drop per-sender counters that have settled to 0. A live
1124		// sender's counter is kept above 0 by `charge_user`'s read guard,
1125		// which excludes this write guard, so usage=0 means no live entries.
1126		{
1127			let mut usage = self.user_usage.write();
1128			usage.retain(|_, counter| counter.load(Ordering::Relaxed) > 0);
1129		}
1130
1131		// Let the rate limiter shed stale per-sender state on the same cadence.
1132		self.rate_limiter.evict_stale();
1133
1134		// Snapshot the size gauges and the promotion backlog. The backlog walk
1135		// re-reads the meta column for every entry in the promotion window, so
1136		// it is gated on metrics being enabled; `promotion_buffer_secs` only
1137		// scopes this gauge and does not change what was cleaned up above.
1138		self.publish_size_metrics();
1139		let backlog = if self.metrics.is_enabled() {
1140			let frontier = now_secs.saturating_add(promotion_buffer_secs);
1141			let candidates: Vec<HopHash> = {
1142				let guard = self.expiry_index.read();
1143				guard
1144					.range((Bound::Unbounded, Bound::Included(&(frontier, H256([0xff; 32])))))
1145					.map(|(_, hash)| *hash)
1146					.collect()
1147			};
1148			candidates
1149				.into_iter()
1150				.filter(|hash| {
1151					matches!(
1152						self.fetch_meta(hash),
1153						Ok(Some(meta))
1154							if Self::in_promotion_window(&meta, now_secs, promotion_buffer_secs)
1155					)
1156				})
1157				.count() as u64
1158		} else {
1159			0
1160		};
1161		self.metrics.set_promotion_backlog(backlog);
1162
1163		total_freed
1164	}
1165
1166	/// Outstanding promotion candidate: unpromoted, near expiry, attempts left.
1167	/// Ignores the back-off deadline — a backing-off entry is still outstanding.
1168	fn in_promotion_window(meta: &HopEntryMeta, now_secs: u64, buffer_secs: u64) -> bool {
1169		!meta.promoted &&
1170			now_secs.saturating_add(buffer_secs) >= meta.expires_at &&
1171			meta.promotion_attempts < MAX_PROMOTION_ATTEMPTS
1172	}
1173
1174	/// Return hashes of entries within `buffer_secs` of expiry that have not yet been promoted.
1175	/// Returns up to `limit` hashes. Use [`Self::get`] to read blob data when needed.
1176	/// The maintenance task runs periodically, so remaining entries are picked up next cycle.
1177	///
1178	/// Uses `expiry_index` to walk only entries inside the promotion window;
1179	/// the meta column is touched only for candidates that pass the window
1180	/// filter, so cost scales with the window size, not the pool.
1181	pub fn get_promotable(
1182		&self,
1183		current_block: HopBlockNumber,
1184		buffer_secs: u64,
1185		limit: usize,
1186	) -> Vec<HopHash> {
1187		let now_secs = SystemTime::now().duration_since(UNIX_EPOCH).unwrap_or_default().as_secs();
1188		let frontier = now_secs.saturating_add(buffer_secs);
1189
1190		// Snapshot candidate hashes inside the window so the read guard isn't
1191		// held across `fetch_meta` calls.
1192		let candidates: Vec<HopHash> = {
1193			let guard = self.expiry_index.read();
1194			guard
1195				.range((Bound::Unbounded, Bound::Included(&(frontier, H256([0xff; 32])))))
1196				.map(|(_, hash)| *hash)
1197				.collect()
1198		};
1199
1200		let mut out: Vec<HopHash> = Vec::new();
1201		for hash in candidates {
1202			if out.len() >= limit {
1203				break;
1204			}
1205			match self.fetch_meta(&hash) {
1206				Ok(Some(meta))
1207					if Self::in_promotion_window(&meta, now_secs, buffer_secs) &&
1208						current_block >= meta.next_promotion_attempt_at =>
1209				{
1210					out.push(hash);
1211				},
1212				_ => (),
1213			}
1214		}
1215		out
1216	}
1217
1218	/// Mark an entry as promoted to permanent on-chain storage.
1219	pub fn mark_promoted(&self, hash: &HopHash) {
1220		let _guard = self.rmw_lock.lock();
1221		let Ok(Some(mut meta)) = self.fetch_meta(hash) else { return };
1222		// Count the transition, not the call: this setter is idempotent.
1223		let newly_promoted = !meta.promoted;
1224		meta.promoted = true;
1225		if let Err(e) = self.commit_meta(hash, Some(meta.encode())) {
1226			tracing::error!(
1227				target: "hop",
1228				hash = ?hex::encode(hash),
1229				error = %e,
1230				"Failed to persist promoted state"
1231			);
1232			return;
1233		}
1234		if newly_promoted {
1235			self.metrics.record_promotion_confirmed();
1236		}
1237	}
1238
1239	/// Record a promotion attempt: bumps the per-entry attempt counter and
1240	/// schedules the next eligible block via exponential back-off. The
1241	/// maintenance task will skip the entry until then. Once
1242	/// `MAX_PROMOTION_ATTEMPTS` is reached the entry is left to expire.
1243	///
1244	/// Called on **both** an `Err` from `submit_local` (the tx pool rejected
1245	/// us) and an `Ok` followed by a runtime check that the data is not yet
1246	/// on-chain (the tx was accepted into the pool but never included). The
1247	/// backoff schedule is identical for both cases.
1248	pub fn record_promotion_attempt(
1249		&self,
1250		hash: &HopHash,
1251		current_block: HopBlockNumber,
1252		check_interval_blocks: u32,
1253	) {
1254		let _guard = self.rmw_lock.lock();
1255		let Ok(Some(mut meta)) = self.fetch_meta(hash) else { return };
1256		meta.promotion_attempts = meta.promotion_attempts.saturating_add(1);
1257		let backoff = promotion_backoff_blocks(meta.promotion_attempts, check_interval_blocks);
1258		meta.next_promotion_attempt_at = current_block.saturating_add(backoff);
1259		if let Err(e) = self.commit_meta(hash, Some(meta.encode())) {
1260			tracing::error!(
1261				target: "hop",
1262				hash = ?hex::encode(hash),
1263				error = %e,
1264				"Failed to persist promotion-attempt state"
1265			);
1266		}
1267	}
1268}
1269
1270/// Decode a 64-char hex stem into a `HopHash`. Returns `None` for any
1271/// non-32-byte stem (corrupt name, wrong length, non-hex chars).
1272fn parse_hex_hash(stem: &str) -> Option<HopHash> {
1273	let bytes = hex::decode(stem).ok()?;
1274	let arr: [u8; 32] = bytes.try_into().ok()?;
1275	Some(H256(arr))
1276}
1277
1278/// Atomically subtract `accounted` from `counter`, clamped so the counter
1279/// cannot underflow. The CAS retry inside `fetch_update` keeps the clamp
1280/// value fresh — a plain `counter.fetch_sub(accounted.min(counter.load()), …)`
1281/// would race with concurrent releases on the same counter and could wrap
1282/// to near `u64::MAX`.
1283fn saturating_release(counter: &AtomicU64, accounted: u64) {
1284	let _ = counter.fetch_update(Ordering::Relaxed, Ordering::Relaxed, |previous| {
1285		Some(previous - accounted.min(previous))
1286	});
1287}
1288
1289#[cfg(test)]
1290mod tests {
1291	use super::*;
1292	use crate::types::{Recipient, MAX_RECIPIENTS};
1293	use sp_core::{crypto::Pair, ed25519, sr25519};
1294	use sp_runtime::MultiSigner;
1295	use tempfile::TempDir;
1296
1297	const SENDER_A: SenderId = [1u8; 32];
1298	const SENDER_B: SenderId = [2u8; 32];
1299
1300	/// Accounted cost of an entry with `data_size` bytes and `num_recipients` recipients.
1301	fn acct(data_size: u64, num_recipients: usize) -> u64 {
1302		entry_accounted_size(data_size, num_recipients)
1303	}
1304
1305	fn make_pool(max_size: u64, retention_secs: u64) -> (HopDataPool, TempDir) {
1306		let dir = TempDir::new().unwrap();
1307		let pool = HopDataPool::new(
1308			max_size,
1309			max_size,
1310			retention_secs,
1311			dir.path().to_path_buf(),
1312			RateLimitConfig::disabled(),
1313			HopMetrics::disabled(),
1314		)
1315		.unwrap();
1316		(pool, dir)
1317	}
1318
1319	fn make_pool_with_user_cap(
1320		max_size: u64,
1321		max_user_size: u64,
1322		retention_secs: u64,
1323	) -> (HopDataPool, TempDir) {
1324		let dir = TempDir::new().unwrap();
1325		let pool = HopDataPool::new(
1326			max_size,
1327			max_user_size,
1328			retention_secs,
1329			dir.path().to_path_buf(),
1330			RateLimitConfig::disabled(),
1331			HopMetrics::disabled(),
1332		)
1333		.unwrap();
1334		(pool, dir)
1335	}
1336
1337	fn create_test_pool() -> (HopDataPool, TempDir) {
1338		make_pool(1024 * 1024, 100)
1339	}
1340
1341	fn test_recipient() -> (ed25519::Pair, MultiSigner) {
1342		let pair = ed25519::Pair::from_seed(&[1u8; 32]);
1343		let signer = MultiSigner::Ed25519(pair.public());
1344		(pair, signer)
1345	}
1346
1347	/// Deterministic placeholder `(MultiSigner, MultiSignature)` for tests that
1348	/// don't exercise submit-signature semantics. The actual values are never
1349	/// verified by these tests.
1350	fn dummy_auth() -> (MultiSigner, MultiSignature) {
1351		let pair = ed25519::Pair::from_seed(&[7u8; 32]);
1352		let signer = MultiSigner::Ed25519(pair.public());
1353		let sig = MultiSignature::Ed25519(pair.sign(&[]));
1354		(signer, sig)
1355	}
1356
1357	fn sign_ed(pair: &ed25519::Pair, context: &[u8], hash: &HopHash) -> Vec<u8> {
1358		let payload = signing_payload(context, hash);
1359		MultiSignature::Ed25519(pair.sign(&payload)).encode()
1360	}
1361
1362	fn sign_sr(pair: &sr25519::Pair, context: &[u8], hash: &HopHash) -> Vec<u8> {
1363		let payload = signing_payload(context, hash);
1364		MultiSignature::Sr25519(pair.sign(&payload)).encode()
1365	}
1366
1367	fn user_usage(pool: &HopDataPool, sender: &SenderId) -> u64 {
1368		pool.user_usage
1369			.read()
1370			.get(sender)
1371			.map(|c| c.load(Ordering::Relaxed))
1372			.unwrap_or(0)
1373	}
1374
1375	/// Convert a `Vec<MultiSigner>` into a `RecipientVec` (with `claimed=false` for
1376	/// each) for test ergonomics; panics only if a test exceeds `MAX_RECIPIENTS`.
1377	fn bv(v: Vec<MultiSigner>) -> RecipientVec {
1378		let recipients: Vec<Recipient> =
1379			v.into_iter().map(|signer| Recipient { signer, claimed: false }).collect();
1380		RecipientVec::try_from(recipients).expect("test recipient list exceeds MAX_RECIPIENTS")
1381	}
1382
1383	/// A `HopEntryMeta` matching `data` for `sender`, expiring far in the future.
1384	fn legacy_meta(data: &[u8], sender: SenderId) -> HopEntryMeta {
1385		let (_, signer) = test_recipient();
1386		HopEntryMeta::new(
1387			data.len() as u64,
1388			u64::MAX,
1389			bv(vec![signer]),
1390			sender,
1391			dummy_auth().0,
1392			dummy_auth().1,
1393			0,
1394		)
1395	}
1396
1397	/// Lay down a pre-KV-store entry by hand: `blobs/{shard}/{hash}.blob` plus a
1398	/// SCALE-encoded `meta/{shard}/{hash}.meta` companion file, with no `meta-db/`
1399	/// involved. Returns the content hash.
1400	fn write_legacy_entry(dir: &Path, data: &[u8], meta_bytes: &[u8]) -> HopHash {
1401		let hash = H256(blake2_256(data));
1402		let blob_path = HopDataPool::entry_path(dir, &hash, BLOBS_DIR, BLOB_EXT);
1403		fs::create_dir_all(blob_path.parent().unwrap()).unwrap();
1404		fs::write(&blob_path, data).unwrap();
1405		write_legacy_meta_only(dir, &hash, meta_bytes);
1406		hash
1407	}
1408
1409	/// Write only the legacy `.meta` file for `hash`, with no blob.
1410	fn write_legacy_meta_only(dir: &Path, hash: &HopHash, meta_bytes: &[u8]) {
1411		let meta_path = HopDataPool::entry_path(dir, hash, LEGACY_META_DIR, LEGACY_META_EXT);
1412		fs::create_dir_all(meta_path.parent().unwrap()).unwrap();
1413		fs::write(&meta_path, meta_bytes).unwrap();
1414	}
1415
1416	/// Read the raw schema version row, or `None` if it is absent.
1417	fn read_db_version(dir: &Path) -> Option<u32> {
1418		let db = parity_db::Db::open_or_create(&HopDataPool::db_options(&dir.join(META_DB_DIR)))
1419			.unwrap();
1420		db.get(COL_DB_META, KEY_DB_VERSION)
1421			.unwrap()
1422			.map(|bytes| u32::from_le_bytes(bytes.try_into().unwrap()))
1423	}
1424
1425	#[test]
1426	fn test_insert_and_get() {
1427		let (pool, _dir) = create_test_pool();
1428		let (_, signer) = test_recipient();
1429		let data = vec![1, 2, 3, 4, 5];
1430		let hash = pool
1431			.insert(data.clone(), bv(vec![signer]), SENDER_A, dummy_auth().0, dummy_auth().1, 0)
1432			.unwrap();
1433
1434		let retrieved = pool.get(&hash).unwrap();
1435		assert_eq!(data, retrieved);
1436	}
1437
1438	#[test]
1439	fn test_insert_no_recipients() {
1440		let (pool, _dir) = create_test_pool();
1441		let data = vec![1, 2, 3, 4, 5];
1442		let result = pool.insert(data, bv(vec![]), SENDER_A, dummy_auth().0, dummy_auth().1, 0);
1443		assert!(matches!(result, Err(HopError::NoRecipients)));
1444	}
1445
1446	#[test]
1447	fn test_duplicate_insert() {
1448		let (pool, _dir) = create_test_pool();
1449		let (_, signer) = test_recipient();
1450		let data = vec![1, 2, 3, 4, 5];
1451
1452		pool.insert(
1453			data.clone(),
1454			bv(vec![signer.clone()]),
1455			SENDER_A,
1456			dummy_auth().0,
1457			dummy_auth().1,
1458			0,
1459		)
1460		.unwrap();
1461		let result =
1462			pool.insert(data, bv(vec![signer]), SENDER_A, dummy_auth().0, dummy_auth().1, 0);
1463
1464		assert!(matches!(result, Err(HopError::DuplicateEntry)));
1465	}
1466
1467	#[test]
1468	fn test_too_many_recipients_rejected_at_type_level() {
1469		// Construction of a `RecipientVec` with more than `MAX_RECIPIENTS` entries
1470		// fails at `try_from`; callers (like the RPC) turn that into a
1471		// `TooManyRecipients` error before reaching the pool.
1472		let recipients: Vec<Recipient> = (0..=MAX_RECIPIENTS as u64)
1473			.map(|i| {
1474				let mut seed = [0u8; 32];
1475				seed[..8].copy_from_slice(&i.to_le_bytes());
1476				Recipient {
1477					signer: MultiSigner::Ed25519(ed25519::Pair::from_seed(&seed).public()),
1478					claimed: false,
1479				}
1480			})
1481			.collect();
1482		assert_eq!(recipients.len(), MAX_RECIPIENTS as usize + 1);
1483		assert!(RecipientVec::try_from(recipients).is_err());
1484	}
1485
1486	#[test]
1487	fn test_duplicate_recipient_rejected() {
1488		let (pool, _dir) = create_test_pool();
1489		let (_, signer) = test_recipient();
1490		let result = pool.insert(
1491			vec![1, 2, 3],
1492			bv(vec![signer.clone(), signer]),
1493			SENDER_A,
1494			dummy_auth().0,
1495			dummy_auth().1,
1496			0,
1497		);
1498		assert!(matches!(result, Err(HopError::DuplicateRecipient)));
1499	}
1500
1501	#[test]
1502	fn test_pool_full() {
1503		// Capacity exactly holds one 60-byte entry with one recipient (60 + 40 = 100).
1504		let (pool, _dir) = make_pool(acct(60, 1), 100);
1505		let (_, signer) = test_recipient();
1506
1507		let data1 = vec![0u8; 60];
1508		let data2 = vec![1u8; 50];
1509
1510		pool.insert(data1, bv(vec![signer.clone()]), SENDER_A, dummy_auth().0, dummy_auth().1, 0)
1511			.unwrap();
1512		let result =
1513			pool.insert(data2, bv(vec![signer]), SENDER_A, dummy_auth().0, dummy_auth().1, 0);
1514
1515		assert!(matches!(result, Err(HopError::PoolFull(_, _))));
1516	}
1517
1518	#[test]
1519	fn test_remove() {
1520		let (pool, _dir) = create_test_pool();
1521		let (_, signer) = test_recipient();
1522		let data = vec![1, 2, 3, 4, 5];
1523		let hash = pool
1524			.insert(data, bv(vec![signer]), SENDER_A, dummy_auth().0, dummy_auth().1, 0)
1525			.unwrap();
1526
1527		assert!(pool.has(&hash));
1528		pool.remove(&hash).unwrap();
1529		assert!(!pool.has(&hash));
1530
1531		// Blob file should be cleaned up; meta lives in parity-db (not a file).
1532		assert!(!pool.blob_path(&hash).exists());
1533	}
1534
1535	#[test]
1536	fn test_status() {
1537		let (pool, _dir) = create_test_pool();
1538		let (_, signer) = test_recipient();
1539		let data1 = vec![1, 2, 3, 4, 5];
1540		let data2 = vec![6, 7, 8];
1541
1542		pool.insert(
1543			data1.clone(),
1544			bv(vec![signer.clone()]),
1545			SENDER_A,
1546			dummy_auth().0,
1547			dummy_auth().1,
1548			0,
1549		)
1550		.unwrap();
1551		pool.insert(data2.clone(), bv(vec![signer]), SENDER_A, dummy_auth().0, dummy_auth().1, 0)
1552			.unwrap();
1553
1554		let status = pool.status();
1555		assert_eq!(status.entry_count, 2);
1556		assert_eq!(status.total_bytes, acct(data1.len() as u64, 1) + acct(data2.len() as u64, 1));
1557	}
1558
1559	#[test]
1560	fn test_claim_valid_signature() {
1561		let (pool, _dir) = create_test_pool();
1562		let (pair, signer) = test_recipient();
1563		let data = vec![1, 2, 3, 4, 5];
1564		let hash = pool
1565			.insert(data.clone(), bv(vec![signer]), SENDER_A, dummy_auth().0, dummy_auth().1, 0)
1566			.unwrap();
1567
1568		let claim = sign_ed(&pair, HOP_CLAIM_CONTEXT, &hash);
1569		let ack = sign_ed(&pair, HOP_ACK_CONTEXT, &hash);
1570		let result = pool.claim(&hash, &claim).unwrap();
1571		assert_eq!(data, result);
1572
1573		// Entry still exists until ack.
1574		assert!(pool.has(&hash));
1575
1576		pool.ack(&hash, &ack).unwrap();
1577		assert!(!pool.has(&hash));
1578	}
1579
1580	#[test]
1581	fn test_claim_sig_rejected_on_ack() {
1582		// Domain separation: a claim signature cannot be replayed as an ack.
1583		let (pool, _dir) = create_test_pool();
1584		let (pair, signer) = test_recipient();
1585		let hash = pool
1586			.insert(vec![1, 2, 3], bv(vec![signer]), SENDER_A, dummy_auth().0, dummy_auth().1, 0)
1587			.unwrap();
1588
1589		let claim = sign_ed(&pair, HOP_CLAIM_CONTEXT, &hash);
1590		pool.claim(&hash, &claim).unwrap();
1591		assert!(matches!(pool.ack(&hash, &claim), Err(HopError::NotFound)));
1592	}
1593
1594	#[test]
1595	fn test_claim_invalid_signature() {
1596		let (pool, _dir) = create_test_pool();
1597		let (_, signer) = test_recipient();
1598		let data = vec![1, 2, 3, 4, 5];
1599		let hash = pool
1600			.insert(data, bv(vec![signer]), SENDER_A, dummy_auth().0, dummy_auth().1, 0)
1601			.unwrap();
1602
1603		// Use invalid SCALE bytes — cannot decode as MultiSignature
1604		let result = pool.claim(&hash, &[0u8; 3]);
1605		assert!(matches!(result, Err(HopError::NotFound)));
1606	}
1607
1608	#[test]
1609	fn test_claim_wrong_key() {
1610		let (pool, _dir) = create_test_pool();
1611		let (_, signer) = test_recipient();
1612		let hash = pool
1613			.insert(
1614				vec![1, 2, 3, 4, 5],
1615				bv(vec![signer]),
1616				SENDER_A,
1617				dummy_auth().0,
1618				dummy_auth().1,
1619				0,
1620			)
1621			.unwrap();
1622
1623		let wrong_pair = ed25519::Pair::from_seed(&[99u8; 32]);
1624		let wrong_claim = sign_ed(&wrong_pair, HOP_CLAIM_CONTEXT, &hash);
1625		assert!(matches!(pool.claim(&hash, &wrong_claim), Err(HopError::NotFound)));
1626		assert!(pool.has(&hash));
1627	}
1628
1629	#[test]
1630	fn test_claim_multi_recipient() {
1631		let (pool, _dir) = create_test_pool();
1632		let pair1 = ed25519::Pair::from_seed(&[1u8; 32]);
1633		let pair2 = ed25519::Pair::from_seed(&[2u8; 32]);
1634		let signer1 = MultiSigner::Ed25519(pair1.public());
1635		let signer2 = MultiSigner::Ed25519(pair2.public());
1636
1637		let data = vec![1, 2, 3, 4, 5];
1638		let hash = pool
1639			.insert(
1640				data.clone(),
1641				bv(vec![signer1, signer2]),
1642				SENDER_A,
1643				dummy_auth().0,
1644				dummy_auth().1,
1645				0,
1646			)
1647			.unwrap();
1648
1649		let claim1 = sign_ed(&pair1, HOP_CLAIM_CONTEXT, &hash);
1650		let ack1 = sign_ed(&pair1, HOP_ACK_CONTEXT, &hash);
1651		assert_eq!(data, pool.claim(&hash, &claim1).unwrap());
1652		pool.ack(&hash, &ack1).unwrap();
1653		assert!(pool.has(&hash));
1654
1655		let claim2 = sign_ed(&pair2, HOP_CLAIM_CONTEXT, &hash);
1656		let ack2 = sign_ed(&pair2, HOP_ACK_CONTEXT, &hash);
1657		assert_eq!(data, pool.claim(&hash, &claim2).unwrap());
1658		pool.ack(&hash, &ack2).unwrap();
1659		assert!(!pool.has(&hash));
1660		assert_eq!(pool.status().total_bytes, 0);
1661	}
1662
1663	#[test]
1664	fn test_claim_after_ack_returns_already_claimed() {
1665		let (pool, _dir) = create_test_pool();
1666		let (pair, signer) = test_recipient();
1667		let pair2 = ed25519::Pair::from_seed(&[2u8; 32]);
1668		let signer2 = MultiSigner::Ed25519(pair2.public());
1669
1670		let hash = pool
1671			.insert(
1672				vec![1, 2, 3, 4, 5],
1673				bv(vec![signer, signer2]),
1674				SENDER_A,
1675				dummy_auth().0,
1676				dummy_auth().1,
1677				0,
1678			)
1679			.unwrap();
1680
1681		let claim = sign_ed(&pair, HOP_CLAIM_CONTEXT, &hash);
1682		let ack = sign_ed(&pair, HOP_ACK_CONTEXT, &hash);
1683		pool.claim(&hash, &claim).unwrap();
1684		pool.ack(&hash, &ack).unwrap();
1685
1686		// Same recipient claims again — already acked.
1687		assert!(matches!(pool.claim(&hash, &claim), Err(HopError::AlreadyClaimed)));
1688	}
1689
1690	#[test]
1691	fn test_claim_not_found() {
1692		let (pool, _dir) = create_test_pool();
1693		let fake_hash = H256([0u8; 32]);
1694		let result = pool.claim(&fake_hash, &[0u8; 64]);
1695		assert!(matches!(result, Err(HopError::NotFound)));
1696	}
1697
1698	#[test]
1699	fn test_per_user_cap_is_hard_limit() {
1700		// Pool big enough for multiple users; user cap sized to one 60-byte entry (+ metadata).
1701		let (pool, _dir) = make_pool_with_user_cap(10_000, acct(60, 1), 100);
1702		let (_, signer) = test_recipient();
1703
1704		pool.insert(
1705			vec![0u8; 60],
1706			bv(vec![signer.clone()]),
1707			SENDER_A,
1708			dummy_auth().0,
1709			dummy_auth().1,
1710			0,
1711		)
1712		.unwrap();
1713
1714		// User A is at the cap; next insert is rejected regardless of pool headroom.
1715		let result = pool.insert(
1716			vec![1u8; 10],
1717			bv(vec![signer.clone()]),
1718			SENDER_A,
1719			dummy_auth().0,
1720			dummy_auth().1,
1721			0,
1722		);
1723		assert!(matches!(result, Err(HopError::UserQuotaExceeded { .. })));
1724
1725		// User B has their own independent cap.
1726		pool.insert(vec![2u8; 60], bv(vec![signer]), SENDER_B, dummy_auth().0, dummy_auth().1, 0)
1727			.unwrap();
1728	}
1729
1730	#[test]
1731	fn test_quota_released_after_ack() {
1732		let (pool, _dir) = make_pool_with_user_cap(10_000, acct(100, 1), 100);
1733		let (pair, signer) = test_recipient();
1734
1735		let hash = pool
1736			.insert(
1737				vec![0u8; 100],
1738				bv(vec![signer.clone()]),
1739				SENDER_A,
1740				dummy_auth().0,
1741				dummy_auth().1,
1742				0,
1743			)
1744			.unwrap();
1745
1746		// At cap; next insert rejected.
1747		let result = pool.insert(
1748			vec![1u8; 10],
1749			bv(vec![signer.clone()]),
1750			SENDER_A,
1751			dummy_auth().0,
1752			dummy_auth().1,
1753			0,
1754		);
1755		assert!(matches!(result, Err(HopError::UserQuotaExceeded { .. })));
1756
1757		let claim = sign_ed(&pair, HOP_CLAIM_CONTEXT, &hash);
1758		let ack = sign_ed(&pair, HOP_ACK_CONTEXT, &hash);
1759		pool.claim(&hash, &claim).unwrap();
1760		pool.ack(&hash, &ack).unwrap();
1761
1762		// Quota freed — user can insert again.
1763		pool.insert(vec![2u8; 100], bv(vec![signer]), SENDER_A, dummy_auth().0, dummy_auth().1, 0)
1764			.unwrap();
1765	}
1766
1767	#[test]
1768	fn test_cleanup_expired_releases_quota() {
1769		let (pool, _dir) = make_pool(10_000, 0);
1770		let (_, signer) = test_recipient();
1771
1772		pool.insert(vec![0u8; 100], bv(vec![signer]), SENDER_A, dummy_auth().0, dummy_auth().1, 0)
1773			.unwrap();
1774		let charged = acct(100, 1);
1775		assert_eq!(user_usage(&pool, &SENDER_A), charged);
1776
1777		let freed = pool.cleanup_expired(0);
1778		assert_eq!(freed, charged);
1779		assert_eq!(pool.status().total_bytes, 0);
1780		assert_eq!(user_usage(&pool, &SENDER_A), 0);
1781	}
1782
1783	#[test]
1784	fn test_cleanup_expired_honors_wall_clock_retention() {
1785		// Retention is measured in real seconds, not blocks: insert with a 1 s
1786		// retention, sleep past it, and assert cleanup reaps the entry.
1787		let (pool, _dir) = make_pool(10_000, 1);
1788		let (_, signer) = test_recipient();
1789
1790		let hash = pool
1791			.insert(vec![0u8; 100], bv(vec![signer]), SENDER_A, dummy_auth().0, dummy_auth().1, 0)
1792			.unwrap();
1793
1794		// Not yet expired — cleanup must be a no-op.
1795		assert_eq!(
1796			pool.cleanup_expired(0),
1797			0,
1798			"entry should still be live before retention elapses"
1799		);
1800		assert!(pool.has(&hash));
1801
1802		std::thread::sleep(std::time::Duration::from_millis(1_200));
1803
1804		assert!(
1805			pool.cleanup_expired(0) > 0,
1806			"entry should be reaped once wall-clock retention elapses"
1807		);
1808		assert!(!pool.has(&hash));
1809	}
1810
1811	#[test]
1812	fn test_user_counter_preserved_until_cleanup() {
1813		// release_user_quota does not remove the map entry — only cleanup_expired
1814		// reclaims stale per-sender slots. Until then the slot remains at 0 so a
1815		// concurrent insert would not orphan its `Arc`.
1816		let (pool, _dir) = create_test_pool();
1817		let (pair, signer) = test_recipient();
1818
1819		let hash = pool
1820			.insert(vec![0u8; 50], bv(vec![signer]), SENDER_A, dummy_auth().0, dummy_auth().1, 0)
1821			.unwrap();
1822		assert!(pool.user_usage.read().contains_key(&SENDER_A));
1823
1824		let claim = sign_ed(&pair, HOP_CLAIM_CONTEXT, &hash);
1825		let ack = sign_ed(&pair, HOP_ACK_CONTEXT, &hash);
1826		pool.claim(&hash, &claim).unwrap();
1827		pool.ack(&hash, &ack).unwrap();
1828
1829		assert_eq!(user_usage(&pool, &SENDER_A), 0);
1830		assert!(pool.user_usage.read().contains_key(&SENDER_A));
1831	}
1832
1833	#[test]
1834	fn test_cleanup_expired_evicts_idle_user_counters() {
1835		// After cleanup_expired runs and a sender has no live entries with a
1836		// non-zero counter, their map slot must be removed so the map cannot
1837		// grow unbounded across the lifetime of a long-running node.
1838		let (pool, _dir) = make_pool(10_000, 10);
1839		let (pair, signer) = test_recipient();
1840
1841		let hash = pool
1842			.insert(vec![0u8; 50], bv(vec![signer]), SENDER_A, dummy_auth().0, dummy_auth().1, 0)
1843			.unwrap();
1844		let claim = sign_ed(&pair, HOP_CLAIM_CONTEXT, &hash);
1845		let ack = sign_ed(&pair, HOP_ACK_CONTEXT, &hash);
1846		pool.claim(&hash, &claim).unwrap();
1847		pool.ack(&hash, &ack).unwrap();
1848		assert!(pool.user_usage.read().contains_key(&SENDER_A));
1849
1850		pool.cleanup_expired(0);
1851		assert!(!pool.user_usage.read().contains_key(&SENDER_A));
1852	}
1853
1854	#[test]
1855	fn test_cleanup_expired_keeps_active_user_counters() {
1856		// A sender with live (non-expired) entries must keep their counter
1857		// even when the counter dropped to 0 between submissions — otherwise
1858		// concurrent in-flight inserts could orphan their `Arc`.
1859		let (pool, _dir) = make_pool(10_000, 100);
1860		let (_, signer) = test_recipient();
1861
1862		pool.insert(vec![0u8; 50], bv(vec![signer]), SENDER_A, dummy_auth().0, dummy_auth().1, 0)
1863			.unwrap();
1864		// Cleanup at a block where the entry is not yet expired must not
1865		// reclaim the sender's slot — a concurrent insert would otherwise
1866		// orphan its `Arc`.
1867		pool.cleanup_expired(0);
1868		assert!(pool.user_usage.read().contains_key(&SENDER_A));
1869	}
1870
1871	#[test]
1872	fn test_cleanup_expired_processes_more_than_one_batch() {
1873		// Cleanup batch size is 10_000 — feed it 25_000 entries that all expire,
1874		// confirm every entry is removed (proving the loop terminates rather
1875		// than leaving leftovers from the first batch).
1876		const BATCHES: u32 = 2;
1877		const PER_BATCH: u32 = 10_000 + 1; // > one batch each
1878		let total = BATCHES * PER_BATCH;
1879
1880		let dir = TempDir::new().unwrap();
1881		// Pool sized for ~25k tiny entries (4 bytes each + recipient overhead).
1882		let entry_bytes = std::mem::size_of::<u32>() as u64;
1883		let pool = HopDataPool::new(
1884			(acct(entry_bytes, 1) * total as u64) + 1024,
1885			u64::MAX,
1886			0,
1887			dir.path().to_path_buf(),
1888			RateLimitConfig::disabled(),
1889			HopMetrics::disabled(),
1890		)
1891		.unwrap();
1892		let (_, signer) = test_recipient();
1893
1894		for i in 0..total {
1895			let mut sender = SENDER_A;
1896			sender[0] = (i & 0xff) as u8;
1897			sender[1] = ((i >> 8) & 0xff) as u8;
1898			sender[2] = ((i >> 16) & 0xff) as u8;
1899			// Data must be unique per entry — content-addressing means equal
1900			// bytes hash to the same key and the second insert hits
1901			// DuplicateEntry. Embed `i` so each blob is distinct.
1902			let data = i.to_le_bytes().to_vec();
1903			pool.insert(data, bv(vec![signer.clone()]), sender, dummy_auth().0, dummy_auth().1, 0)
1904				.unwrap();
1905		}
1906		assert_eq!(pool.status().entry_count, total as usize);
1907
1908		pool.cleanup_expired(0);
1909		assert_eq!(pool.status().entry_count, 0);
1910		assert_eq!(pool.status().total_bytes, 0);
1911		assert!(pool.user_usage.read().is_empty());
1912	}
1913
1914	#[test]
1915	fn test_restart_recovery() {
1916		let dir = TempDir::new().unwrap();
1917		let (_, signer) = test_recipient();
1918		let expected_accounted = acct(100, 1);
1919
1920		let hash;
1921		{
1922			let pool = HopDataPool::new(
1923				1024 * 1024,
1924				1024 * 1024,
1925				100,
1926				dir.path().to_path_buf(),
1927				RateLimitConfig::disabled(),
1928				HopMetrics::disabled(),
1929			)
1930			.unwrap();
1931			hash = pool
1932				.insert(
1933					vec![42u8; 100],
1934					bv(vec![signer]),
1935					SENDER_A,
1936					dummy_auth().0,
1937					dummy_auth().1,
1938					0,
1939				)
1940				.unwrap();
1941			assert!(pool.has(&hash));
1942			assert_eq!(pool.status().entry_count, 1);
1943			assert_eq!(pool.status().total_bytes, expected_accounted);
1944		}
1945
1946		{
1947			let pool = HopDataPool::new(
1948				1024 * 1024,
1949				1024 * 1024,
1950				100,
1951				dir.path().to_path_buf(),
1952				RateLimitConfig::disabled(),
1953				HopMetrics::disabled(),
1954			)
1955			.unwrap();
1956			assert!(pool.has(&hash));
1957			assert_eq!(pool.status().entry_count, 1);
1958			assert_eq!(pool.status().total_bytes, expected_accounted);
1959
1960			let data = pool.get(&hash).unwrap();
1961			assert_eq!(data, vec![42u8; 100]);
1962			assert_eq!(user_usage(&pool, &SENDER_A), expected_accounted);
1963		}
1964	}
1965
1966	#[test]
1967	fn test_orphan_blob_cleanup() {
1968		let dir = TempDir::new().unwrap();
1969		{
1970			let _pool = HopDataPool::new(
1971				1024 * 1024,
1972				1024 * 1024,
1973				100,
1974				dir.path().to_path_buf(),
1975				RateLimitConfig::disabled(),
1976				HopMetrics::disabled(),
1977			)
1978			.unwrap();
1979		}
1980
1981		let orphan_hash = "aa".to_string() + &"bb".repeat(15);
1982		let blob_path = dir.path().join("blobs").join("aa").join(format!("{}.blob", orphan_hash));
1983		fs::write(&blob_path, b"orphan data").unwrap();
1984		assert!(blob_path.exists());
1985
1986		let _pool = HopDataPool::new(
1987			1024 * 1024,
1988			1024 * 1024,
1989			100,
1990			dir.path().to_path_buf(),
1991			RateLimitConfig::disabled(),
1992			HopMetrics::disabled(),
1993		)
1994		.unwrap();
1995		assert!(!blob_path.exists());
1996	}
1997
1998	#[test]
1999	fn test_corrupt_meta_cleanup() {
2000		let dir = TempDir::new().unwrap();
2001		// Boot once to create the parity-db on-disk layout.
2002		{
2003			let _pool = HopDataPool::new(
2004				1024 * 1024,
2005				1024 * 1024,
2006				100,
2007				dir.path().to_path_buf(),
2008				RateLimitConfig::disabled(),
2009				HopMetrics::disabled(),
2010			)
2011			.unwrap();
2012		}
2013
2014		// Inject an undecodable meta row directly into the column.
2015		let fake_hash = H256([0xbbu8; 32]);
2016		{
2017			let db_path = dir.path().join(META_DB_DIR);
2018			let db = parity_db::Db::open_or_create(&HopDataPool::db_options(&db_path)).unwrap();
2019			db.commit([(
2020				COL_META,
2021				fake_hash.as_bytes().to_vec(),
2022				Some(b"not valid SCALE data".to_vec()),
2023			)])
2024			.unwrap();
2025		}
2026
2027		let pool = HopDataPool::new(
2028			1024 * 1024,
2029			1024 * 1024,
2030			100,
2031			dir.path().to_path_buf(),
2032			RateLimitConfig::disabled(),
2033			HopMetrics::disabled(),
2034		)
2035		.unwrap();
2036		// The corrupt row should have been dropped on startup.
2037		assert!(!pool.has(&fake_hash));
2038		assert_eq!(pool.status().entry_count, 0);
2039	}
2040
2041	#[test]
2042	fn test_claim_sr25519() {
2043		let (pool, _dir) = create_test_pool();
2044		let pair = sr25519::Pair::from_seed(&[3u8; 32]);
2045		let signer = MultiSigner::Sr25519(pair.public());
2046
2047		let data = vec![10, 20, 30];
2048		let hash = pool
2049			.insert(data.clone(), bv(vec![signer]), SENDER_A, dummy_auth().0, dummy_auth().1, 0)
2050			.unwrap();
2051
2052		let claim = sign_sr(&pair, HOP_CLAIM_CONTEXT, &hash);
2053		let ack = sign_sr(&pair, HOP_ACK_CONTEXT, &hash);
2054		assert_eq!(data, pool.claim(&hash, &claim).unwrap());
2055		pool.ack(&hash, &ack).unwrap();
2056		assert!(!pool.has(&hash));
2057	}
2058
2059	#[test]
2060	fn test_claim_mixed_key_types() {
2061		let (pool, _dir) = create_test_pool();
2062		let ed_pair = ed25519::Pair::from_seed(&[4u8; 32]);
2063		let sr_pair = sr25519::Pair::from_seed(&[5u8; 32]);
2064		let ed_signer = MultiSigner::Ed25519(ed_pair.public());
2065		let sr_signer = MultiSigner::Sr25519(sr_pair.public());
2066
2067		let data = vec![42, 43, 44];
2068		let hash = pool
2069			.insert(
2070				data.clone(),
2071				bv(vec![ed_signer, sr_signer]),
2072				SENDER_A,
2073				dummy_auth().0,
2074				dummy_auth().1,
2075				0,
2076			)
2077			.unwrap();
2078
2079		let sr_claim = sign_sr(&sr_pair, HOP_CLAIM_CONTEXT, &hash);
2080		let sr_ack = sign_sr(&sr_pair, HOP_ACK_CONTEXT, &hash);
2081		assert_eq!(data, pool.claim(&hash, &sr_claim).unwrap());
2082		pool.ack(&hash, &sr_ack).unwrap();
2083		assert!(pool.has(&hash));
2084
2085		let ed_claim = sign_ed(&ed_pair, HOP_CLAIM_CONTEXT, &hash);
2086		let ed_ack = sign_ed(&ed_pair, HOP_ACK_CONTEXT, &hash);
2087		assert_eq!(data, pool.claim(&hash, &ed_claim).unwrap());
2088		pool.ack(&hash, &ed_ack).unwrap();
2089		assert!(!pool.has(&hash));
2090	}
2091
2092	#[test]
2093	fn test_claim_is_repeatable() {
2094		let (pool, _dir) = create_test_pool();
2095		let (pair, signer) = test_recipient();
2096		let data = vec![1, 2, 3, 4, 5];
2097		let hash = pool
2098			.insert(data.clone(), bv(vec![signer]), SENDER_A, dummy_auth().0, dummy_auth().1, 0)
2099			.unwrap();
2100
2101		let claim = sign_ed(&pair, HOP_CLAIM_CONTEXT, &hash);
2102		assert_eq!(data, pool.claim(&hash, &claim).unwrap());
2103		assert_eq!(data, pool.claim(&hash, &claim).unwrap());
2104		assert!(pool.has(&hash));
2105	}
2106
2107	#[test]
2108	fn test_ack_idempotent() {
2109		let (pool, _dir) = create_test_pool();
2110		let (pair, signer) = test_recipient();
2111		let pair2 = ed25519::Pair::from_seed(&[2u8; 32]);
2112		let signer2 = MultiSigner::Ed25519(pair2.public());
2113
2114		let hash = pool
2115			.insert(
2116				vec![1, 2, 3, 4, 5],
2117				bv(vec![signer, signer2]),
2118				SENDER_A,
2119				dummy_auth().0,
2120				dummy_auth().1,
2121				0,
2122			)
2123			.unwrap();
2124		let ack = sign_ed(&pair, HOP_ACK_CONTEXT, &hash);
2125
2126		pool.ack(&hash, &ack).unwrap();
2127		pool.ack(&hash, &ack).unwrap();
2128		assert!(pool.has(&hash));
2129	}
2130
2131	#[test]
2132	fn test_multi_recipient_partial_ack() {
2133		let (pool, _dir) = create_test_pool();
2134		let pair1 = ed25519::Pair::from_seed(&[1u8; 32]);
2135		let pair2 = ed25519::Pair::from_seed(&[2u8; 32]);
2136		let signer1 = MultiSigner::Ed25519(pair1.public());
2137		let signer2 = MultiSigner::Ed25519(pair2.public());
2138
2139		let data = vec![1, 2, 3, 4, 5];
2140		let hash = pool
2141			.insert(
2142				data.clone(),
2143				bv(vec![signer1, signer2]),
2144				SENDER_A,
2145				dummy_auth().0,
2146				dummy_auth().1,
2147				0,
2148			)
2149			.unwrap();
2150
2151		let claim1 = sign_ed(&pair1, HOP_CLAIM_CONTEXT, &hash);
2152		let ack1 = sign_ed(&pair1, HOP_ACK_CONTEXT, &hash);
2153		let claim2 = sign_ed(&pair2, HOP_CLAIM_CONTEXT, &hash);
2154		let ack2 = sign_ed(&pair2, HOP_ACK_CONTEXT, &hash);
2155
2156		assert_eq!(data, pool.claim(&hash, &claim1).unwrap());
2157		pool.ack(&hash, &ack1).unwrap();
2158		assert!(pool.has(&hash));
2159
2160		assert_eq!(data, pool.claim(&hash, &claim2).unwrap());
2161		pool.ack(&hash, &ack2).unwrap();
2162		assert!(!pool.has(&hash));
2163		assert_eq!(pool.status().total_bytes, 0);
2164	}
2165
2166	#[test]
2167	fn test_concurrent_inserts_respect_capacity() {
2168		use std::{sync::Barrier, thread};
2169
2170		let (_, signer) = test_recipient();
2171		// Capacity for exactly 4 entries of 50 bytes (accounted = 90 each).
2172		let (pool, _dir) = make_pool(acct(50, 1) * 4, 100);
2173		let pool = Arc::new(pool);
2174		let barrier = Arc::new(Barrier::new(10));
2175
2176		let handles: Vec<_> = (0..10u8)
2177			.map(|i| {
2178				let pool = pool.clone();
2179				let signer = signer.clone();
2180				let barrier = barrier.clone();
2181				thread::spawn(move || {
2182					barrier.wait();
2183					pool.insert(
2184						vec![i; 50],
2185						bv(vec![signer]),
2186						SENDER_A,
2187						dummy_auth().0,
2188						dummy_auth().1,
2189						0,
2190					)
2191				})
2192			})
2193			.collect();
2194
2195		let results: Vec<_> = handles.into_iter().map(|h| h.join().unwrap()).collect();
2196		let successes = results.iter().filter(|r| r.is_ok()).count();
2197
2198		assert!(successes <= 4, "Got {} successes, max should be 4", successes);
2199		assert!(pool.status().total_bytes <= acct(50, 1) * 4);
2200	}
2201
2202	#[test]
2203	fn test_concurrent_inserts_respect_user_quota() {
2204		use std::{sync::Barrier, thread};
2205
2206		let (_, signer) = test_recipient();
2207		// Per-user cap holds 3 entries of 100 bytes. Pool has plenty of room so the
2208		// *user* cap is what actually constrains the test.
2209		let per_entry = acct(100, 1);
2210		let (pool, _dir) = make_pool_with_user_cap(per_entry * 20, per_entry * 3, 100);
2211		let pool = Arc::new(pool);
2212		let barrier = Arc::new(Barrier::new(10));
2213
2214		let handles: Vec<_> = (0..10u8)
2215			.map(|i| {
2216				let pool = pool.clone();
2217				let signer = signer.clone();
2218				let barrier = barrier.clone();
2219				thread::spawn(move || {
2220					barrier.wait();
2221					pool.insert(
2222						vec![i; 100],
2223						bv(vec![signer]),
2224						SENDER_A,
2225						dummy_auth().0,
2226						dummy_auth().1,
2227						0,
2228					)
2229				})
2230			})
2231			.collect();
2232
2233		let results: Vec<_> = handles.into_iter().map(|h| h.join().unwrap()).collect();
2234		let successes = results.iter().filter(|r| r.is_ok()).count();
2235
2236		// Hard per-user cap: at most 3 inserts may succeed regardless of concurrency.
2237		assert!(successes <= 3, "hard per-user cap violated: {} successes", successes);
2238		assert!(user_usage(&pool, &SENDER_A) <= per_entry * 3);
2239	}
2240
2241	#[test]
2242	fn test_concurrent_claim_and_ack() {
2243		use std::{sync::Barrier, thread};
2244
2245		let (pool, _dir) = create_test_pool();
2246		let pool = Arc::new(pool);
2247
2248		let pairs: Vec<_> = (1..=5u8)
2249			.map(|i| {
2250				let pair = ed25519::Pair::from_seed(&[i; 32]);
2251				let signer = MultiSigner::Ed25519(pair.public());
2252				(pair, signer)
2253			})
2254			.collect();
2255
2256		let signers: Vec<_> = pairs.iter().map(|(_, s)| s.clone()).collect();
2257		let data = vec![42u8; 100];
2258		let hash = pool
2259			.insert(data.clone(), bv(signers), SENDER_A, dummy_auth().0, dummy_auth().1, 0)
2260			.unwrap();
2261
2262		let barrier = Arc::new(Barrier::new(5));
2263
2264		let handles: Vec<_> = pairs
2265			.into_iter()
2266			.map(|(pair, _)| {
2267				let pool = pool.clone();
2268				let barrier = barrier.clone();
2269				let data = data.clone();
2270				thread::spawn(move || {
2271					barrier.wait();
2272					let claim = sign_ed(&pair, HOP_CLAIM_CONTEXT, &hash);
2273					let ack = sign_ed(&pair, HOP_ACK_CONTEXT, &hash);
2274
2275					let claimed = pool.claim(&hash, &claim).unwrap();
2276					assert_eq!(data, claimed);
2277					pool.ack(&hash, &ack).unwrap();
2278				})
2279			})
2280			.collect();
2281
2282		for h in handles {
2283			h.join().unwrap();
2284		}
2285
2286		assert!(!pool.has(&hash));
2287		assert_eq!(pool.status().total_bytes, 0);
2288	}
2289
2290	#[test]
2291	fn test_concurrent_duplicate_insert_preserves_files() {
2292		use std::{sync::Barrier, thread};
2293
2294		// Two threads insert identical content concurrently. The race-loser must
2295		// not delete the winner's blob or evict the winner's meta row; the
2296		// winning hash must remain readable via claim().
2297		let (kp, signer) = test_recipient();
2298		let (pool, _dir) = make_pool(1024 * 1024, 100);
2299		let pool = Arc::new(pool);
2300		let data = vec![0xABu8; 4096];
2301		let barrier = Arc::new(Barrier::new(2));
2302
2303		let handles: Vec<_> = (0..2)
2304			.map(|_| {
2305				let pool = pool.clone();
2306				let barrier = barrier.clone();
2307				let signer = signer.clone();
2308				let data = data.clone();
2309				thread::spawn(move || {
2310					barrier.wait();
2311					pool.insert(data, bv(vec![signer]), SENDER_A, dummy_auth().0, dummy_auth().1, 0)
2312				})
2313			})
2314			.collect();
2315		let results: Vec<_> = handles.into_iter().map(|h| h.join().unwrap()).collect();
2316
2317		let oks: Vec<_> = results.iter().filter_map(|r| r.as_ref().ok()).collect();
2318		let dupes = results.iter().filter(|r| matches!(r, Err(HopError::DuplicateEntry))).count();
2319		assert_eq!(oks.len(), 1, "exactly one insert must win the race");
2320		assert_eq!(dupes, 1, "the other must report DuplicateEntry");
2321
2322		let hash = *oks[0];
2323		let sig = sign_ed(&kp, HOP_CLAIM_CONTEXT, &hash);
2324		let claimed = pool.claim(&hash, &sig).expect("claim must succeed");
2325		assert_eq!(claimed, data);
2326	}
2327
2328	#[test]
2329	fn test_concurrent_duplicate_insert_keeps_winner_meta_on_disk() {
2330		use std::{sync::Barrier, thread};
2331
2332		// Same content, different senders. The race-loser's meta must not end
2333		// up in the parity-db column; otherwise restart recovery would silently
2334		// load it as canonical for the entry.
2335		let dir = TempDir::new().unwrap();
2336		let pool = Arc::new(
2337			HopDataPool::new(
2338				1024 * 1024,
2339				1024 * 1024,
2340				100,
2341				dir.path().to_path_buf(),
2342				RateLimitConfig::disabled(),
2343				HopMetrics::disabled(),
2344			)
2345			.unwrap(),
2346		);
2347
2348		let signer_a = MultiSigner::Ed25519(ed25519::Pair::from_seed(&[11u8; 32]).public());
2349		let signer_b = MultiSigner::Ed25519(ed25519::Pair::from_seed(&[22u8; 32]).public());
2350		let auth_a = dummy_auth();
2351		let auth_b = {
2352			let pair = ed25519::Pair::from_seed(&[33u8; 32]);
2353			(MultiSigner::Ed25519(pair.public()), MultiSignature::Ed25519(pair.sign(b"x")))
2354		};
2355		let data = vec![0xCDu8; 4096];
2356
2357		let barrier = Arc::new(Barrier::new(2));
2358		let (p1, d1, b1, s1, a1) =
2359			(pool.clone(), data.clone(), barrier.clone(), signer_a.clone(), auth_a.clone());
2360		let h1 = thread::spawn(move || {
2361			b1.wait();
2362			p1.insert(d1, bv(vec![s1]), SENDER_A, a1.0, a1.1, 0)
2363		});
2364		let (p2, d2, b2, s2, a2) =
2365			(pool.clone(), data.clone(), barrier.clone(), signer_b.clone(), auth_b.clone());
2366		let h2 = thread::spawn(move || {
2367			b2.wait();
2368			p2.insert(d2, bv(vec![s2]), SENDER_B, a2.0, a2.1, 0)
2369		});
2370
2371		let r1 = h1.join().unwrap();
2372		let r2 = h2.join().unwrap();
2373
2374		let (winner_hash, winner_sender_auth) = match (&r1, &r2) {
2375			(Ok(h), Err(HopError::DuplicateEntry)) => (*h, auth_a.0.clone()),
2376			(Err(HopError::DuplicateEntry), Ok(h)) => (*h, auth_b.0.clone()),
2377			other => panic!("expected exactly one winner and one DuplicateEntry, got {other:?}"),
2378		};
2379
2380		// Simulate restart: drop the pool, reopen the same data dir so the new
2381		// pool reconstructs its caches from the parity-db meta column.
2382		drop(pool);
2383		let pool2 = HopDataPool::new(
2384			1024 * 1024,
2385			1024 * 1024,
2386			100,
2387			dir.path().to_path_buf(),
2388			RateLimitConfig::disabled(),
2389			HopMetrics::disabled(),
2390		)
2391		.unwrap();
2392
2393		let (_data, recovered_auth_signer, _sig, _ts) =
2394			pool2.get_with_auth(&winner_hash).expect("winner's entry must survive restart");
2395		assert_eq!(
2396			recovered_auth_signer, winner_sender_auth,
2397			"meta in parity-db diverged from the winning insert; loser's meta overwrote the winner's",
2398		);
2399	}
2400
2401	#[test]
2402	fn test_saturating_release_concurrent_no_underflow() {
2403		use std::{sync::Barrier, thread};
2404
2405		// Many threads each release a fixed amount that sums to exactly the
2406		// initial counter. With a non-atomic load-then-clamp-then-fetch_sub,
2407		// stale clamps would let the counter wrap to ~u64::MAX.
2408		// `saturating_release` must keep the result clamped at 0.
2409		const THREADS: u64 = 32;
2410		const RELEASE_PER_THREAD: u64 = 7;
2411		let counter = Arc::new(AtomicU64::new(THREADS * RELEASE_PER_THREAD));
2412		let barrier = Arc::new(Barrier::new(THREADS as usize));
2413
2414		let handles: Vec<_> = (0..THREADS)
2415			.map(|_| {
2416				let counter = counter.clone();
2417				let barrier = barrier.clone();
2418				thread::spawn(move || {
2419					barrier.wait();
2420					saturating_release(&counter, RELEASE_PER_THREAD);
2421				})
2422			})
2423			.collect();
2424		for h in handles {
2425			h.join().unwrap();
2426		}
2427
2428		assert_eq!(counter.load(Ordering::Relaxed), 0, "counter underflowed or did not reach zero");
2429
2430		// Releasing more than the remaining balance must clamp to 0, never wrap.
2431		saturating_release(&counter, u64::MAX);
2432		assert_eq!(counter.load(Ordering::Relaxed), 0);
2433	}
2434
2435	#[test]
2436	fn test_get_promotable_within_buffer() {
2437		// retention=3600s; a freshly-inserted entry is in the promotion window only
2438		// if the buffer is at least as large as the time-to-expiry.
2439		let (pool, _dir) = make_pool(1024 * 1024, 3600);
2440		let (_, signer) = test_recipient();
2441
2442		let hash = pool
2443			.insert(vec![1, 2, 3], bv(vec![signer]), SENDER_A, dummy_auth().0, dummy_auth().1, 0)
2444			.unwrap();
2445
2446		// Small buffer (180s ≪ 3600s retention): not promotable yet.
2447		let promotable = pool.get_promotable(50, 180, usize::MAX);
2448		assert!(promotable.is_empty());
2449
2450		// Large buffer (6000s > 3600s retention): within the window.
2451		let promotable = pool.get_promotable(0, 6000, usize::MAX);
2452		assert_eq!(promotable.len(), 1);
2453		assert_eq!(promotable[0], hash);
2454	}
2455
2456	#[test]
2457	fn test_get_promotable_excludes_promoted() {
2458		let (pool, _dir) = make_pool(1024 * 1024, 100);
2459		let (_, signer) = test_recipient();
2460
2461		let hash = pool
2462			.insert(vec![1, 2, 3], bv(vec![signer]), SENDER_A, dummy_auth().0, dummy_auth().1, 0)
2463			.unwrap();
2464
2465		let promotable = pool.get_promotable(80, 180, usize::MAX);
2466		assert_eq!(promotable.len(), 1);
2467
2468		pool.mark_promoted(&hash);
2469
2470		let promotable = pool.get_promotable(80, 180, usize::MAX);
2471		assert!(promotable.is_empty());
2472	}
2473
2474	#[test]
2475	fn test_mark_promoted_persists_across_restart() {
2476		let dir = TempDir::new().unwrap();
2477		let (_, signer) = test_recipient();
2478
2479		let hash;
2480		{
2481			let pool = HopDataPool::new(
2482				1024 * 1024,
2483				1024 * 1024,
2484				100,
2485				dir.path().to_path_buf(),
2486				RateLimitConfig::disabled(),
2487				HopMetrics::disabled(),
2488			)
2489			.unwrap();
2490			hash = pool
2491				.insert(
2492					vec![42u8; 10],
2493					bv(vec![signer]),
2494					SENDER_A,
2495					dummy_auth().0,
2496					dummy_auth().1,
2497					0,
2498				)
2499				.unwrap();
2500			pool.mark_promoted(&hash);
2501		}
2502
2503		{
2504			let pool = HopDataPool::new(
2505				1024 * 1024,
2506				1024 * 1024,
2507				100,
2508				dir.path().to_path_buf(),
2509				RateLimitConfig::disabled(),
2510				HopMetrics::disabled(),
2511			)
2512			.unwrap();
2513			let promotable = pool.get_promotable(80, 180, usize::MAX);
2514			assert!(promotable.is_empty(), "promoted entry should not be promotable after restart");
2515			assert!(pool.has(&hash), "entry should still exist");
2516		}
2517	}
2518
2519	#[test]
2520	fn test_cleanup_expired_removes_promoted() {
2521		let (pool, _dir) = make_pool(1024 * 1024, 0);
2522		let (_, signer) = test_recipient();
2523
2524		let hash = pool
2525			.insert(vec![1, 2, 3], bv(vec![signer]), SENDER_A, dummy_auth().0, dummy_auth().1, 0)
2526			.unwrap();
2527		pool.mark_promoted(&hash);
2528		assert!(pool.has(&hash));
2529
2530		let freed = pool.cleanup_expired(0);
2531		assert!(freed > 0);
2532		assert!(!pool.has(&hash));
2533	}
2534
2535	#[test]
2536	fn test_rate_limit_rejects_burst_overflow() {
2537		let dir = TempDir::new().unwrap();
2538		// submit_burst=2 so the 3rd request is rate-limited by submit count.
2539		// Bandwidth is sized comfortably above the 3-byte test payloads so the
2540		// rejection comes from the request bucket, not the bandwidth bucket.
2541		let cfg = RateLimitConfig {
2542			enabled: true,
2543			submit_rate_per_min: 60,
2544			submit_burst: 2,
2545			bandwidth_per_min: 1024 * 1024 * 60,
2546			bandwidth_burst: 1024 * 1024,
2547		};
2548		let pool = HopDataPool::new(
2549			1024 * 1024,
2550			1024 * 1024,
2551			100,
2552			dir.path().to_path_buf(),
2553			cfg,
2554			HopMetrics::disabled(),
2555		)
2556		.unwrap();
2557		let (_, signer) = test_recipient();
2558
2559		pool.insert(
2560			vec![1, 2, 3],
2561			bv(vec![signer.clone()]),
2562			SENDER_A,
2563			dummy_auth().0,
2564			dummy_auth().1,
2565			0,
2566		)
2567		.unwrap();
2568		pool.insert(
2569			vec![4, 5, 6],
2570			bv(vec![signer.clone()]),
2571			SENDER_A,
2572			dummy_auth().0,
2573			dummy_auth().1,
2574			0,
2575		)
2576		.unwrap();
2577		assert!(matches!(
2578			pool.insert(
2579				vec![7, 8, 9],
2580				bv(vec![signer]),
2581				SENDER_A,
2582				dummy_auth().0,
2583				dummy_auth().1,
2584				0,
2585			),
2586			Err(HopError::RateLimited { .. })
2587		));
2588	}
2589
2590	#[test]
2591	fn test_meta_version_mismatch_rejected() {
2592		// Persist a HopEntryMeta with version 0 (an older / future schema) into
2593		// the meta column, write its matching blob, then boot a fresh pool and
2594		// assert the row is wiped, the blob is reaped, and the entry never
2595		// surfaces.
2596		let dir = TempDir::new().unwrap();
2597
2598		// Boot once to create the parity-db layout.
2599		{
2600			let _pool = HopDataPool::new(
2601				1024 * 1024,
2602				1024 * 1024,
2603				100,
2604				dir.path().to_path_buf(),
2605				RateLimitConfig::disabled(),
2606				HopMetrics::disabled(),
2607			)
2608			.unwrap();
2609		}
2610
2611		let (_, signer) = test_recipient();
2612		let recipients = bv(vec![signer.clone()]);
2613		let mut meta =
2614			HopEntryMeta::new(100, 0, recipients, SENDER_A, dummy_auth().0, dummy_auth().1, 0);
2615		meta.version = 0;
2616
2617		let fake_hash = H256([0xeeu8; 32]);
2618		let blob_shard = dir.path().join(BLOBS_DIR).join("ee");
2619		fs::create_dir_all(&blob_shard).unwrap();
2620		let blob_path = blob_shard.join(format!("{}.blob", hex::encode(fake_hash)));
2621		fs::write(&blob_path, b"x").unwrap();
2622
2623		{
2624			let db_path = dir.path().join(META_DB_DIR);
2625			let db = parity_db::Db::open_or_create(&HopDataPool::db_options(&db_path)).unwrap();
2626			db.commit([(COL_META, fake_hash.as_bytes().to_vec(), Some(meta.encode()))])
2627				.unwrap();
2628		}
2629
2630		let pool = HopDataPool::new(
2631			1024 * 1024,
2632			1024 * 1024,
2633			100,
2634			dir.path().to_path_buf(),
2635			RateLimitConfig::disabled(),
2636			HopMetrics::disabled(),
2637		)
2638		.unwrap();
2639		assert!(!pool.has(&fake_hash), "stale-version row should be dropped");
2640		assert!(!blob_path.exists(), "matching .blob should also be removed");
2641		assert_eq!(pool.status().entry_count, 0);
2642	}
2643
2644	#[test]
2645	fn test_meta_row_without_blob_dropped_on_recovery() {
2646		// A crash can persist a blob unlink but lose the async parity-db
2647		// meta-delete, leaving a valid current-version meta row whose blob is
2648		// gone. Boot a pool with exactly such a row and assert it is dropped and
2649		// its pool + user quota is not accounted, rather than leaking until
2650		// expiry.
2651		let dir = TempDir::new().unwrap();
2652
2653		// Boot once to create the parity-db layout.
2654		{
2655			let _pool = HopDataPool::new(
2656				1024 * 1024,
2657				1024 * 1024,
2658				100,
2659				dir.path().to_path_buf(),
2660				RateLimitConfig::disabled(),
2661				HopMetrics::disabled(),
2662			)
2663			.unwrap();
2664		}
2665
2666		let (_, signer) = test_recipient();
2667		let recipients = bv(vec![signer]);
2668		// Current-version meta (as `HopEntryMeta::new` produces), so only the
2669		// missing-blob check can reject it.
2670		let meta =
2671			HopEntryMeta::new(100, 0, recipients, SENDER_A, dummy_auth().0, dummy_auth().1, 0);
2672		assert_eq!(meta.version, HOP_META_VERSION);
2673
2674		// Commit the meta row but deliberately write no blob.
2675		let fake_hash = H256([0xabu8; 32]);
2676		{
2677			let db_path = dir.path().join(META_DB_DIR);
2678			let mut opts = parity_db::Options::with_columns(&db_path, COL_COUNT);
2679			opts.columns[COL_META as usize].btree_index = true;
2680			let db = parity_db::Db::open_or_create(&opts).unwrap();
2681			db.commit([(COL_META, fake_hash.as_bytes().to_vec(), Some(meta.encode()))])
2682				.unwrap();
2683		}
2684
2685		let pool = HopDataPool::new(
2686			1024 * 1024,
2687			1024 * 1024,
2688			100,
2689			dir.path().to_path_buf(),
2690			RateLimitConfig::disabled(),
2691			HopMetrics::disabled(),
2692		)
2693		.unwrap();
2694
2695		assert!(!pool.has(&fake_hash), "meta row with no blob should be dropped");
2696		assert_eq!(pool.status().entry_count, 0, "dropped row must not count toward entries");
2697		assert_eq!(pool.status().total_bytes, 0, "dropped row must not hold pool quota");
2698		// The meta row is actually gone from the DB, not just uncounted.
2699		assert!(matches!(pool.fetch_meta(&fake_hash), Ok(None)));
2700	}
2701
2702	#[test]
2703	fn test_metrics_track_insert_promotion_and_expiry() {
2704		// Registered metrics so counters read back (disabled() metrics are no-ops).
2705		let dir = TempDir::new().unwrap();
2706		let registry = prometheus_endpoint::Registry::new();
2707		let pool = HopDataPool::new(
2708			1024 * 1024,
2709			1024 * 1024,
2710			// retention =
2711			0, // entries expire immediately
2712			dir.path().to_path_buf(),
2713			RateLimitConfig::disabled(),
2714			HopMetrics::new(Some(&registry)).unwrap(),
2715		)
2716		.unwrap();
2717		let (_, signer) = test_recipient();
2718
2719		// Insert publishes the size gauges.
2720		let hash = pool
2721			.insert(
2722				vec![1u8; 50],
2723				bv(vec![signer.clone()]),
2724				SENDER_A,
2725				dummy_auth().0,
2726				dummy_auth().1,
2727				0,
2728			)
2729			.unwrap();
2730		assert_eq!(pool.metrics().pool_gauges(), (1, acct(50, 1)));
2731
2732		// Promotion is counted once, on the false->true transition.
2733		pool.mark_promoted(&hash);
2734		pool.mark_promoted(&hash);
2735		assert_eq!(pool.metrics().promotions_confirmed(), 1);
2736
2737		// Expiring a promoted entry counts as EXPIRED_PROMOTED and clears gauges.
2738		pool.cleanup_expired(0);
2739		assert_eq!(pool.metrics().removed_count(removal_reasons::EXPIRED_PROMOTED), 1);
2740		assert_eq!(pool.metrics().pool_gauges(), (0, 0));
2741
2742		// An unpromoted entry expiring is the data-loss case: EXPIRED_UNPROMOTED.
2743		pool.insert(vec![2u8; 30], bv(vec![signer]), SENDER_A, dummy_auth().0, dummy_auth().1, 0)
2744			.unwrap();
2745		pool.cleanup_expired(0);
2746		assert_eq!(pool.metrics().removed_count(removal_reasons::EXPIRED_UNPROMOTED), 1);
2747		assert_eq!(pool.metrics().pool_gauges(), (0, 0));
2748	}
2749
2750	#[test]
2751	fn test_metrics_record_startup_dropped() {
2752		// Boot once to lay down the DB, commit a meta row with no sibling blob,
2753		// then reopen with registered metrics and assert the dropped row is
2754		// counted under STARTUP_DROPPED.
2755		let dir = TempDir::new().unwrap();
2756		{
2757			let _pool = HopDataPool::new(
2758				1024 * 1024,
2759				1024 * 1024,
2760				100,
2761				dir.path().to_path_buf(),
2762				RateLimitConfig::disabled(),
2763				HopMetrics::disabled(),
2764			)
2765			.unwrap();
2766		}
2767
2768		let (_, signer) = test_recipient();
2769		let meta = HopEntryMeta::new(
2770			100,
2771			0,
2772			bv(vec![signer]),
2773			SENDER_A,
2774			dummy_auth().0,
2775			dummy_auth().1,
2776			0,
2777		);
2778		let fake_hash = H256([0xcdu8; 32]);
2779		{
2780			let db_path = dir.path().join(META_DB_DIR);
2781			let mut opts = parity_db::Options::with_columns(&db_path, COL_COUNT);
2782			opts.columns[COL_META as usize].btree_index = true;
2783			let db = parity_db::Db::open_or_create(&opts).unwrap();
2784			db.commit([(COL_META, fake_hash.as_bytes().to_vec(), Some(meta.encode()))])
2785				.unwrap();
2786		}
2787
2788		let registry = prometheus_endpoint::Registry::new();
2789		let pool = HopDataPool::new(
2790			1024 * 1024,
2791			1024 * 1024,
2792			100,
2793			dir.path().to_path_buf(),
2794			RateLimitConfig::disabled(),
2795			HopMetrics::new(Some(&registry)).unwrap(),
2796		)
2797		.unwrap();
2798
2799		assert_eq!(pool.metrics().removed_count(removal_reasons::STARTUP_DROPPED), 1);
2800		assert_eq!(pool.metrics().pool_gauges(), (0, 0));
2801	}
2802
2803	#[test]
2804	fn test_promotion_backoff_skips_until_due_then_gives_up() {
2805		use crate::types::MAX_PROMOTION_ATTEMPTS;
2806
2807		let (pool, _dir) = make_pool(1024 * 1024, /* retention = */ 100);
2808		let (_, signer) = test_recipient();
2809		let hash = pool
2810			.insert(vec![1u8; 100], bv(vec![signer]), SENDER_A, dummy_auth().0, dummy_auth().1, 0)
2811			.unwrap();
2812
2813		// Inside the buffer window (>= retention=100s) so the entry is promotable
2814		// in principle.
2815		let buffer = 300_u64;
2816		let current = 60;
2817		assert_eq!(pool.get_promotable(current, buffer, 10), vec![hash]);
2818
2819		// First failure schedules next attempt at current + 1× check_interval_blocks.
2820		let check_interval_blocks: u32 = 10;
2821		pool.record_promotion_attempt(&hash, current, check_interval_blocks);
2822		assert!(
2823			pool.get_promotable(current, buffer, 10).is_empty(),
2824			"entry should be skipped until back-off elapses"
2825		);
2826		assert_eq!(pool.get_promotable(current + 10, buffer, 10), vec![hash]);
2827
2828		// Burn through the remaining attempts; once at MAX, the entry stays out
2829		// of the promotable set forever (regardless of how far we advance time).
2830		// Schedule after first failure: 1×, 2×, 4×, 8×, 16× check_interval.
2831		let mut now = current + 10;
2832		for next_attempt in 2..=MAX_PROMOTION_ATTEMPTS {
2833			pool.record_promotion_attempt(&hash, now, check_interval_blocks);
2834			let shift = (next_attempt - 1).min(5);
2835			let backoff = check_interval_blocks << shift;
2836			now += backoff;
2837		}
2838		assert!(
2839			pool.get_promotable(now + 10_000, buffer, 10).is_empty(),
2840			"entry should give up after MAX_PROMOTION_ATTEMPTS"
2841		);
2842	}
2843
2844	#[test]
2845	fn test_legacy_meta_files_migrated() {
2846		// The upgrade path: a data dir holding only the pre-KV-store layout, no
2847		// `meta-db/` at all. Both entries must survive the first boot rather
2848		// than being reaped as orphans.
2849		let dir = TempDir::new().unwrap();
2850		let data_a = vec![1u8; 100];
2851		let data_b = vec![2u8; 250];
2852		let hash_a =
2853			write_legacy_entry(dir.path(), &data_a, &legacy_meta(&data_a, SENDER_A).encode());
2854		let hash_b =
2855			write_legacy_entry(dir.path(), &data_b, &legacy_meta(&data_b, SENDER_B).encode());
2856
2857		let pool = HopDataPool::new(
2858			1024 * 1024,
2859			1024 * 1024,
2860			100,
2861			dir.path().to_path_buf(),
2862			RateLimitConfig::disabled(),
2863			HopMetrics::disabled(),
2864		)
2865		.unwrap();
2866
2867		assert!(pool.has(&hash_a));
2868		assert!(pool.has(&hash_b));
2869		assert_eq!(pool.get(&hash_a).unwrap(), data_a);
2870		assert_eq!(pool.get(&hash_b).unwrap(), data_b);
2871		assert_eq!(pool.status().entry_count, 2);
2872		assert_eq!(pool.status().total_bytes, acct(100, 1) + acct(250, 1));
2873		assert_eq!(user_usage(&pool, &SENDER_A), acct(100, 1));
2874		assert_eq!(user_usage(&pool, &SENDER_B), acct(250, 1));
2875		// The legacy tree is reclaimed, not left to leak on disk.
2876		assert!(!dir.path().join(LEGACY_META_DIR).exists());
2877	}
2878
2879	#[test]
2880	fn test_legacy_meta_survives_second_restart() {
2881		// Restart durability must hold across the upgrade boundary, not just for
2882		// the boot that performed the import.
2883		let dir = TempDir::new().unwrap();
2884		let data = vec![7u8; 64];
2885		let hash = write_legacy_entry(dir.path(), &data, &legacy_meta(&data, SENDER_A).encode());
2886
2887		for _ in 0..2 {
2888			let pool = HopDataPool::new(
2889				1024 * 1024,
2890				1024 * 1024,
2891				100,
2892				dir.path().to_path_buf(),
2893				RateLimitConfig::disabled(),
2894				HopMetrics::disabled(),
2895			)
2896			.unwrap();
2897			assert!(pool.has(&hash));
2898			assert_eq!(pool.get(&hash).unwrap(), data);
2899			assert_eq!(pool.status().entry_count, 1);
2900			assert_eq!(pool.status().total_bytes, acct(64, 1));
2901		}
2902	}
2903
2904	#[test]
2905	fn test_legacy_meta_without_blob_skipped() {
2906		let dir = TempDir::new().unwrap();
2907		let orphan_hash = H256([0x5au8; 32]);
2908		write_legacy_meta_only(dir.path(), &orphan_hash, &legacy_meta(&[], SENDER_A).encode());
2909
2910		let pool = HopDataPool::new(
2911			1024 * 1024,
2912			1024 * 1024,
2913			100,
2914			dir.path().to_path_buf(),
2915			RateLimitConfig::disabled(),
2916			HopMetrics::disabled(),
2917		)
2918		.unwrap();
2919
2920		assert!(!pool.has(&orphan_hash));
2921		assert_eq!(pool.status().entry_count, 0);
2922		assert!(!dir.path().join(LEGACY_META_DIR).exists());
2923	}
2924
2925	#[test]
2926	fn test_legacy_meta_version_mismatch_dropped() {
2927		// An unsupported record version is not imported, and the blob it points
2928		// at is then reaped by the normal orphan pass.
2929		let dir = TempDir::new().unwrap();
2930		let data = vec![3u8; 32];
2931		let mut meta = legacy_meta(&data, SENDER_A);
2932		meta.version = 0;
2933		let hash = write_legacy_entry(dir.path(), &data, &meta.encode());
2934		let blob_path = HopDataPool::entry_path(dir.path(), &hash, BLOBS_DIR, BLOB_EXT);
2935		assert!(blob_path.exists());
2936
2937		let pool = HopDataPool::new(
2938			1024 * 1024,
2939			1024 * 1024,
2940			100,
2941			dir.path().to_path_buf(),
2942			RateLimitConfig::disabled(),
2943			HopMetrics::disabled(),
2944		)
2945		.unwrap();
2946
2947		assert!(!pool.has(&hash));
2948		assert_eq!(pool.status().entry_count, 0);
2949		assert!(!blob_path.exists());
2950		assert!(!dir.path().join(LEGACY_META_DIR).exists());
2951	}
2952
2953	#[test]
2954	fn test_legacy_meta_corrupt_file_skipped() {
2955		// An undecodable `.meta` file must not abort startup.
2956		let dir = TempDir::new().unwrap();
2957		let data = vec![9u8; 16];
2958		let hash = write_legacy_entry(dir.path(), &data, b"not valid SCALE data");
2959
2960		let pool = HopDataPool::new(
2961			1024 * 1024,
2962			1024 * 1024,
2963			100,
2964			dir.path().to_path_buf(),
2965			RateLimitConfig::disabled(),
2966			HopMetrics::disabled(),
2967		)
2968		.unwrap();
2969
2970		assert!(!pool.has(&hash));
2971		assert_eq!(pool.status().entry_count, 0);
2972		assert!(!dir.path().join(LEGACY_META_DIR).exists());
2973	}
2974
2975	#[test]
2976	fn test_legacy_meta_does_not_clobber_existing_row() {
2977		// A row already in the KV store is newer than any leftover `.meta` file, so
2978		// the import must not overwrite it.
2979		let dir = TempDir::new().unwrap();
2980		let data = vec![4u8; 48];
2981		let (_, signer) = test_recipient();
2982
2983		let hash;
2984		let live_expires_at;
2985		{
2986			let pool = HopDataPool::new(
2987				1024 * 1024,
2988				1024 * 1024,
2989				100,
2990				dir.path().to_path_buf(),
2991				RateLimitConfig::disabled(),
2992				HopMetrics::disabled(),
2993			)
2994			.unwrap();
2995			hash = pool
2996				.insert(data.clone(), bv(vec![signer]), SENDER_A, dummy_auth().0, dummy_auth().1, 0)
2997				.unwrap();
2998			live_expires_at = pool.fetch_meta(&hash).unwrap().unwrap().expires_at;
2999		}
3000
3001		// Same hash, deliberately different expiry, written as a legacy `.meta` file.
3002		let mut stale = legacy_meta(&data, SENDER_A);
3003		stale.expires_at = live_expires_at.wrapping_add(999_999);
3004		write_legacy_meta_only(dir.path(), &hash, &stale.encode());
3005
3006		let pool = HopDataPool::new(
3007			1024 * 1024,
3008			1024 * 1024,
3009			100,
3010			dir.path().to_path_buf(),
3011			RateLimitConfig::disabled(),
3012			HopMetrics::disabled(),
3013		)
3014		.unwrap();
3015
3016		assert!(pool.has(&hash));
3017		assert_eq!(pool.status().entry_count, 1);
3018		assert_eq!(pool.fetch_meta(&hash).unwrap().unwrap().expires_at, live_expires_at);
3019		assert!(!dir.path().join(LEGACY_META_DIR).exists());
3020	}
3021
3022	#[test]
3023	fn test_legacy_empty_meta_dir_removed() {
3024		// What an upgraded but idle node has on disk: 256 empty shard dirs. The
3025		// boot must be uneventful and the tree reclaimed anyway.
3026		let dir = TempDir::new().unwrap();
3027		for i in 0..SHARD_COUNT {
3028			fs::create_dir_all(dir.path().join(LEGACY_META_DIR).join(format!("{:02x}", i as u8)))
3029				.unwrap();
3030		}
3031
3032		let pool = HopDataPool::new(
3033			1024 * 1024,
3034			1024 * 1024,
3035			100,
3036			dir.path().to_path_buf(),
3037			RateLimitConfig::disabled(),
3038			HopMetrics::disabled(),
3039		)
3040		.unwrap();
3041
3042		assert_eq!(pool.status().entry_count, 0);
3043		assert!(!dir.path().join(LEGACY_META_DIR).exists());
3044	}
3045
3046	#[test]
3047	fn test_db_version_stamped_on_fresh_pool() {
3048		let dir = TempDir::new().unwrap();
3049		{
3050			let _pool = HopDataPool::new(
3051				1024 * 1024,
3052				1024 * 1024,
3053				100,
3054				dir.path().to_path_buf(),
3055				RateLimitConfig::disabled(),
3056				HopMetrics::disabled(),
3057			)
3058			.unwrap();
3059		}
3060		assert_eq!(read_db_version(dir.path()), Some(CURRENT_DB_VERSION));
3061	}
3062
3063	#[test]
3064	fn test_future_db_version_rejected() {
3065		// A database written by a newer binary must be refused, not misread.
3066		let dir = TempDir::new().unwrap();
3067		{
3068			let _pool = HopDataPool::new(
3069				1024 * 1024,
3070				1024 * 1024,
3071				100,
3072				dir.path().to_path_buf(),
3073				RateLimitConfig::disabled(),
3074				HopMetrics::disabled(),
3075			)
3076			.unwrap();
3077		}
3078		{
3079			let db = parity_db::Db::open_or_create(&HopDataPool::db_options(
3080				&dir.path().join(META_DB_DIR),
3081			))
3082			.unwrap();
3083			db.commit([(
3084				COL_DB_META,
3085				KEY_DB_VERSION.to_vec(),
3086				Some((CURRENT_DB_VERSION + 1).to_le_bytes().to_vec()),
3087			)])
3088			.unwrap();
3089		}
3090
3091		let result = HopDataPool::new(
3092			1024 * 1024,
3093			1024 * 1024,
3094			100,
3095			dir.path().to_path_buf(),
3096			RateLimitConfig::disabled(),
3097			HopMetrics::disabled(),
3098		);
3099		assert!(matches!(result, Err(HopError::Db(_))), "future db version must be rejected");
3100	}
3101
3102	#[test]
3103	fn test_column_layout_migration() {
3104		// A database laid down with the previous single-column layout must be
3105		// extended in place, with its rows intact.
3106		let dir = TempDir::new().unwrap();
3107		let data = vec![8u8; 80];
3108		let hash = H256(blake2_256(&data));
3109		let blob_path = HopDataPool::entry_path(dir.path(), &hash, BLOBS_DIR, BLOB_EXT);
3110		fs::create_dir_all(blob_path.parent().unwrap()).unwrap();
3111		fs::write(&blob_path, &data).unwrap();
3112
3113		let db_path = dir.path().join(META_DB_DIR);
3114		fs::create_dir_all(&db_path).unwrap();
3115		{
3116			let mut options = parity_db::Options::with_columns(&db_path, 1);
3117			options.columns[COL_META as usize].btree_index = true;
3118			let db = parity_db::Db::open_or_create(&options).unwrap();
3119			db.commit([(
3120				COL_META,
3121				hash.as_bytes().to_vec(),
3122				Some(legacy_meta(&data, SENDER_A).encode()),
3123			)])
3124			.unwrap();
3125		}
3126
3127		let pool = HopDataPool::new(
3128			1024 * 1024,
3129			1024 * 1024,
3130			100,
3131			dir.path().to_path_buf(),
3132			RateLimitConfig::disabled(),
3133			HopMetrics::disabled(),
3134		)
3135		.unwrap();
3136
3137		assert!(pool.has(&hash));
3138		assert_eq!(pool.get(&hash).unwrap(), data);
3139		assert_eq!(pool.status().entry_count, 1);
3140		drop(pool);
3141		assert_eq!(read_db_version(dir.path()), Some(CURRENT_DB_VERSION));
3142	}
3143}