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: in-memory index backed by sharded on-disk storage.
18//!
19//! ## On-disk layout
20//!
21//! The pool root contains two subdirectories, `blobs/` and `meta/`, each
22//! sharded into 256 subdirectories named `00`–`ff` after the first byte of the
23//! content hash. An entry with hash `H` is stored as:
24//!
25//! - `blobs/<H[0:2]>/<H>.blob` — raw payload bytes
26//! - `meta/<H[0:2]>/<H>.meta` — SCALE-encoded [`HopEntryMeta`]
27//!
28//! ## Recovery
29//!
30//! On startup the pool scans every `meta/` shard, decodes each `.meta` file,
31//! and rebuilds the in-memory index. `.meta` files that are corrupt, have an
32//! unexpected version, or lack a sibling `.blob` are deleted. Then the
33//! corresponding `blobs/` shard is scanned and any `.blob` without an entry in
34//! the freshly-built index (orphan) is also deleted. Stale `.tmp.*` files left
35//! by a previous crash are removed during both scans.
36
37use crate::{
38	metrics::{removal_reasons, HopMetrics},
39	rate_limit::{RateLimitConfig, RateLimiter},
40	types::{
41		entry_accounted_size, promotion_backoff_blocks, signing_payload, HopBlockNumber,
42		HopEntryMeta, HopError, HopHash, PoolStatus, RecipientVec, SenderId, HOP_ACK_CONTEXT,
43		HOP_CLAIM_CONTEXT, HOP_META_VERSION, MAX_PROMOTION_ATTEMPTS,
44	},
45};
46use codec::{Decode, Encode};
47use parking_lot::{Mutex, RwLock};
48use sp_core::H256;
49use sp_crypto_hashing::blake2_256;
50use sp_runtime::{
51	traits::{IdentifyAccount, Verify},
52	MultiSignature, MultiSigner,
53};
54use std::{
55	collections::{BTreeSet, HashMap, HashSet},
56	fs,
57	path::{Path, PathBuf},
58	process,
59	sync::{
60		atomic::{AtomicU64, Ordering},
61		Arc,
62	},
63	time::{SystemTime, UNIX_EPOCH},
64};
65
66/// Per-process counter that disambiguates concurrent atomic writes targeting
67/// the same final path. Two threads computing the same content hash would
68/// otherwise share a `<path>.tmp` file and stomp each other's bytes.
69static TMP_SEQ: AtomicU64 = AtomicU64::new(0);
70
71const BLOBS_DIR: &str = "blobs";
72const META_DIR: &str = "meta";
73const BLOB_EXT: &str = "blob";
74const META_EXT: &str = "meta";
75/// Number of shards used for both `blobs/` and `meta/` directories (one per
76/// first-byte value of the content hash: `00`–`ff`).
77const SHARD_COUNT: u16 = 256;
78
79/// HOP data pool with disk-backed blob storage and in-memory metadata index.
80pub struct HopDataPool {
81	/// In-memory metadata index (no blobs).
82	index: Mutex<HashMap<HopHash, HopEntryMeta>>,
83	/// Per-user byte usage tracked by sender id.
84	///
85	/// Counters live directly in the map and are charged via `charge_user`
86	/// inside the read guard, so the reclamation pass in `cleanup_expired`
87	/// (which holds `user_usage.write()` together with `index.lock()`) cannot
88	/// interpose between a lookup and its `fetch_add`. Stale entries —
89	/// counter 0 and no live index entry — are reclaimed by the same pass.
90	user_usage: RwLock<HashMap<SenderId, AtomicU64>>,
91	/// Maximum pool size in bytes (counts both data and per-entry metadata overhead).
92	max_size: u64,
93	/// Fixed hard per-user quota in bytes.
94	max_user_size: u64,
95	/// Current pool size in bytes (accounted size — includes metadata overhead).
96	current_size: AtomicU64,
97	/// Data retention period in seconds.
98	retention_secs: u64,
99	/// Root data directory containing blobs/ and meta/ subdirectories.
100	data_dir: PathBuf,
101	/// Per-account submit rate limiter.
102	rate_limiter: Arc<RateLimiter>,
103	/// Prometheus metrics (no-ops without a registry).
104	metrics: HopMetrics,
105}
106
107impl HopDataPool {
108	/// Create a new disk-backed data pool.
109	///
110	/// Creates shard directories under `data_dir` and rebuilds the in-memory index
111	/// from existing `.meta` files on disk (recovery after restart).
112	pub fn new(
113		max_size: u64,
114		max_user_size: u64,
115		retention_secs: u64,
116		data_dir: PathBuf,
117		rate_limit_cfg: RateLimitConfig,
118		metrics: HopMetrics,
119	) -> Result<Self, HopError> {
120		// Create shard directories (256 each for blobs/ and meta/).
121		for i in 0..SHARD_COUNT {
122			let shard = format!("{:02x}", i as u8);
123			fs::create_dir_all(data_dir.join(BLOBS_DIR).join(&shard))?;
124			fs::create_dir_all(data_dir.join(META_DIR).join(&shard))?;
125		}
126
127		let mut index = HashMap::new();
128		let mut user_usage: HashMap<SenderId, AtomicU64> = HashMap::new();
129		let mut current_size = 0u64;
130		// Orphan `.blob`s are not counted: without a `.meta` they were never a
131		// claimable entry.
132		let mut dropped = 0u64;
133
134		// Rebuild index from .meta files and clean orphan .blobs in a single pass.
135		for i in 0..SHARD_COUNT {
136			let shard = format!("{:02x}", i as u8);
137
138			// Scan .meta files → rebuild index (removes corrupt/orphan .meta files).
139			let meta_shard_dir = data_dir.join(META_DIR).join(&shard);
140			if let Ok(entries) = fs::read_dir(&meta_shard_dir) {
141				for entry in entries.flatten() {
142					let path = entry.path();
143					if path.extension().and_then(|e| e.to_str()) != Some(META_EXT) {
144						if path
145							.file_name()
146							.and_then(|n| n.to_str())
147							.map_or(false, |n| n.contains(".tmp."))
148						{
149							let _ = fs::remove_file(&path);
150						}
151						continue;
152					}
153
154					let stem = match path.file_stem().and_then(|s| s.to_str()) {
155						Some(s) => s.to_string(),
156						None => continue,
157					};
158
159					let Some(hash) = parse_hex_hash(&stem) else {
160						tracing::warn!(target: "hop", path = ?path, "Removing .meta with invalid name");
161						let _ = fs::remove_file(&path);
162						dropped += 1;
163						continue;
164					};
165
166					let meta_bytes = match fs::read(&path) {
167						Ok(b) => b,
168						Err(e) => {
169							tracing::warn!(target: "hop", path = ?path, error = %e, "Removing unreadable .meta");
170							let _ = fs::remove_file(&path);
171							dropped += 1;
172							continue;
173						},
174					};
175					let meta = match HopEntryMeta::decode(&mut &meta_bytes[..]) {
176						Ok(m) => m,
177						Err(e) => {
178							tracing::warn!(target: "hop", path = ?path, error = %e, "Removing corrupt .meta");
179							let _ = fs::remove_file(&path);
180							dropped += 1;
181							continue;
182						},
183					};
184					if meta.version != HOP_META_VERSION {
185						tracing::warn!(
186							target: "hop",
187							path = ?path,
188							version = meta.version,
189							expected = HOP_META_VERSION,
190							"Removing .meta with unsupported on-disk version",
191						);
192						let _ = fs::remove_file(&path);
193						let _ = fs::remove_file(Self::entry_path(
194							&data_dir, &hash, BLOBS_DIR, BLOB_EXT,
195						));
196						dropped += 1;
197						continue;
198					}
199
200					let blob_path = Self::entry_path(&data_dir, &hash, BLOBS_DIR, BLOB_EXT);
201					if !blob_path.exists() {
202						tracing::warn!(target: "hop", hash = ?stem, "Removing orphan .meta (no .blob)");
203						let _ = fs::remove_file(&path);
204						dropped += 1;
205						continue;
206					}
207
208					let accounted = entry_accounted_size(meta.size, meta.recipients.len());
209					current_size += accounted;
210					user_usage
211						.entry(meta.sender_id)
212						.or_default()
213						.fetch_add(accounted, Ordering::Relaxed);
214					index.insert(hash, meta);
215				}
216			}
217
218			// Scan .blob files → remove orphans (blobs without corresponding .meta).
219			let blob_shard_dir = data_dir.join(BLOBS_DIR).join(&shard);
220			if let Ok(entries) = fs::read_dir(&blob_shard_dir) {
221				for entry in entries.flatten() {
222					let path = entry.path();
223					if path.extension().and_then(|e| e.to_str()) != Some(BLOB_EXT) {
224						if path
225							.file_name()
226							.and_then(|n| n.to_str())
227							.map_or(false, |n| n.contains(".tmp."))
228						{
229							let _ = fs::remove_file(&path);
230						}
231						continue;
232					}
233					let stem = match path.file_stem().and_then(|s| s.to_str()) {
234						Some(s) => s.to_string(),
235						None => continue,
236					};
237					// Any blob without a corresponding index entry is an orphan.
238					// The meta scan for this shard already populated `index`, so an
239					// in-memory lookup is sufficient and avoids a syscall per blob.
240					// Blobs with unparseable names have no possible index match and
241					// are always removed.
242					let is_orphan = match parse_hex_hash(&stem) {
243						Some(hash) => !index.contains_key(&hash),
244						None => true,
245					};
246					if is_orphan {
247						tracing::warn!(target: "hop", hash = ?stem, "Removing orphan .blob (no .meta)");
248						let _ = fs::remove_file(&path);
249					}
250				}
251			}
252		}
253
254		tracing::info!(
255			target: "hop",
256			entries = index.len(),
257			total_bytes = current_size,
258			dropped,
259			"Recovered HOP pool from disk"
260		);
261
262		metrics.set_pool_status(index.len() as u64, current_size, max_size);
263		metrics.record_removed(removal_reasons::STARTUP_DROPPED, dropped);
264
265		Ok(Self {
266			index: Mutex::new(index),
267			user_usage: RwLock::new(user_usage),
268			max_size,
269			max_user_size,
270			current_size: AtomicU64::new(current_size),
271			retention_secs,
272			data_dir,
273			rate_limiter: Arc::new(RateLimiter::new(rate_limit_cfg)),
274			metrics,
275		})
276	}
277
278	/// Metrics shared by the pool, RPC server, and maintenance task.
279	pub(crate) fn metrics(&self) -> &HopMetrics {
280		&self.metrics
281	}
282
283	/// Snapshot the pool size gauges after a mutation, from inside the index
284	/// critical section with `entries` read from the locked index: publishing
285	/// after the unlock would let an earlier writer overtake a later one and
286	/// leave the gauges stale.
287	fn publish_size_metrics(&self, entries: usize) {
288		self.metrics
289			.set_pool_size(entries as u64, self.current_size.load(Ordering::Relaxed));
290	}
291
292	/// Charge `accounted` bytes against `sender_id`'s per-user quota, creating
293	/// a zero-initialized counter if absent. The read guard held across the
294	/// `fetch_add` excludes the reclamation pass in `cleanup_expired` (which
295	/// takes `user_usage.write()`), so the counter cannot be reclaimed
296	/// between lookup and increment.
297	fn charge_user(&self, sender_id: &SenderId, accounted: u64) -> Result<(), HopError> {
298		// Fast path: sender already in map, a read guard is enough.
299		{
300			let usage = self.user_usage.read();
301			if let Some(counter) = usage.get(sender_id) {
302				return self.try_charge(counter, accounted);
303			}
304		}
305		// Cold path: first insert from this sender — take the write guard.
306		let mut usage = self.user_usage.write();
307		let counter = usage.entry(*sender_id).or_default();
308		self.try_charge(counter, accounted)
309	}
310
311	/// Atomically increment `counter` by `accounted`, rolling back on cap
312	/// overflow. `saturating_add` clamps to `u64::MAX` if concurrent failing
313	/// charges briefly inflate the previous value past the wrap point,
314	/// ensuring overflow always falls into the "exceeds cap" branch.
315	fn try_charge(&self, counter: &AtomicU64, accounted: u64) -> Result<(), HopError> {
316		let previous = counter.fetch_add(accounted, Ordering::Relaxed);
317		if previous.saturating_add(accounted) > self.max_user_size {
318			counter.fetch_sub(accounted, Ordering::Relaxed);
319			return Err(HopError::UserQuotaExceeded { used: previous, limit: self.max_user_size });
320		}
321		Ok(())
322	}
323
324	/// Decrement a user's usage counter. Counters are never removed by this
325	/// path; reclamation happens only in the per-sender pass at the end of
326	/// `cleanup_expired`.
327	fn release_user_quota(&self, sender_id: &SenderId, accounted: u64) {
328		if let Some(counter) = self.user_usage.read().get(sender_id) {
329			saturating_release(counter, accounted);
330		}
331	}
332
333	/// Path to a file within a shard subdirectory rooted at `data_dir`.
334	fn entry_path(data_dir: &Path, hash: &HopHash, subdir: &str, ext: &str) -> PathBuf {
335		let hex = hex::encode(hash);
336		data_dir.join(subdir).join(&hex[..2]).join(format!("{}.{}", hex, ext))
337	}
338
339	/// Path to the blob file for a given hash.
340	fn blob_path(&self, hash: &HopHash) -> PathBuf {
341		Self::entry_path(&self.data_dir, hash, BLOBS_DIR, BLOB_EXT)
342	}
343
344	/// Path to the meta file for a given hash.
345	fn meta_path(&self, hash: &HopHash) -> PathBuf {
346		Self::entry_path(&self.data_dir, hash, META_DIR, META_EXT)
347	}
348
349	/// Atomically write data to a file (write to a unique .tmp path, then rename).
350	///
351	/// The tmp suffix encodes process id + a per-process atomic counter so two
352	/// threads writing the same final path (i.e. same content-addressed hash)
353	/// do not race on a shared tmp file. Removes the tmp file on failure so a
354	/// failed write never leaves an orphan.
355	fn write_atomic(path: &Path, data: &[u8]) -> Result<(), HopError> {
356		let suffix = format!("tmp.{}.{}", process::id(), TMP_SEQ.fetch_add(1, Ordering::Relaxed));
357		let tmp_path = path.with_extension(suffix);
358		if let Err(e) = fs::write(&tmp_path, data) {
359			let _ = fs::remove_file(&tmp_path);
360			return Err(e.into());
361		}
362		if let Err(e) = fs::rename(&tmp_path, path) {
363			let _ = fs::remove_file(&tmp_path);
364			return Err(e.into());
365		}
366		Ok(())
367	}
368
369	/// Insert data into the pool.
370	///
371	/// Returns the hash of the data.
372	pub fn insert(
373		&self,
374		data: Vec<u8>,
375		recipients: RecipientVec,
376		sender_id: SenderId,
377		signer: MultiSigner,
378		signature: MultiSignature,
379		submit_timestamp: u64,
380	) -> Result<HopHash, HopError> {
381		if recipients.is_empty() {
382			return Err(HopError::NoRecipients);
383		}
384		let unique: BTreeSet<&MultiSigner> = recipients.iter().map(|r| &r.signer).collect();
385		if unique.len() != recipients.len() {
386			return Err(HopError::DuplicateRecipient);
387		}
388
389		if data.is_empty() {
390			return Err(HopError::EmptyData);
391		}
392
393		let data_len = data.len() as u64;
394
395		// Total accounted size includes bounded per-recipient metadata overhead so
396		// a submitter cannot inflate memory via large recipient lists while the
397		// capacity counter only tracks `data.len()`. Charge the rate limiter the
398		// same accounted size, otherwise a 1-byte payload with 256 recipients
399		// would cost ~10 KiB of pool capacity while only spending 1 byte of
400		// bandwidth tokens — making the bandwidth dimension non-functional for
401		// fan-out-heavy entries.
402		let accounted = entry_accounted_size(data_len, recipients.len());
403
404		// Rejected requests never reserve capacity — check before any atomic bump.
405		if let Err(retry_after_secs) = self.rate_limiter.check(&sender_id, accounted) {
406			return Err(HopError::RateLimited { retry_after_secs });
407		}
408
409		let previous_size = self.current_size.fetch_add(accounted, Ordering::Relaxed);
410		if previous_size.saturating_add(accounted) > self.max_size {
411			self.current_size.fetch_sub(accounted, Ordering::Relaxed);
412			return Err(HopError::PoolFull(previous_size, self.max_size));
413		}
414
415		if let Err(e) = self.charge_user(&sender_id, accounted) {
416			self.current_size.fetch_sub(accounted, Ordering::Relaxed);
417			return Err(e);
418		}
419
420		let hash = H256(blake2_256(&data));
421
422		// First duplicate check (read lock only).
423		{
424			let index = self.index.lock();
425			if index.contains_key(&hash) {
426				self.release_user_quota(&sender_id, accounted);
427				self.current_size.fetch_sub(accounted, Ordering::Relaxed);
428				return Err(HopError::DuplicateEntry);
429			}
430		}
431
432		// Blob write is outside the lock — content-addressed bytes, racers
433		// produce identical output, rename is atomic.
434		let blob_path = self.blob_path(&hash);
435		if let Err(e) = Self::write_atomic(&blob_path, &data) {
436			self.release_user_quota(&sender_id, accounted);
437			self.current_size.fetch_sub(accounted, Ordering::Relaxed);
438			return Err(e);
439		}
440
441		let expires_at = SystemTime::now()
442			.duration_since(UNIX_EPOCH)
443			.unwrap_or_default()
444			.as_secs()
445			.saturating_add(self.retention_secs);
446		let meta = HopEntryMeta::new(
447			data_len,
448			expires_at,
449			recipients,
450			sender_id,
451			signer,
452			signature,
453			submit_timestamp,
454		);
455		let meta_bytes = meta.encode();
456		let meta_path = self.meta_path(&hash);
457
458		// Meta write goes under the index lock: meta is not content-addressed
459		// (sender_id, signer, signature, recipients, submit_timestamp differ
460		// between submitters), so racing writers would otherwise leave the
461		// loser's bytes on disk, diverging from the winner held in memory.
462		{
463			let mut index = self.index.lock();
464			if index.contains_key(&hash) {
465				tracing::debug!(
466					target: "hop",
467					hash = ?hex::encode(hash),
468					"Duplicate insert race lost; keeping winner's files"
469				);
470				// Drop `index` before `release_user_quota` takes `user_usage.read()`
471				// to keep the outer-to-inner lock order matching `cleanup_expired`.
472				drop(index);
473				self.release_user_quota(&sender_id, accounted);
474				self.current_size.fetch_sub(accounted, Ordering::Relaxed);
475				return Err(HopError::DuplicateEntry);
476			}
477			if let Err(e) = Self::write_atomic(&meta_path, &meta_bytes) {
478				// Index doesn't contain this hash; remove the blob to avoid
479				// leaving an orphan.
480				let _ = fs::remove_file(&blob_path);
481				drop(index);
482				self.release_user_quota(&sender_id, accounted);
483				self.current_size.fetch_sub(accounted, Ordering::Relaxed);
484				return Err(e);
485			}
486			index.insert(hash, meta);
487			self.publish_size_metrics(index.len());
488		}
489		self.metrics.record_inserted_bytes(accounted);
490
491		tracing::info!(
492			target: "hop",
493			hash = ?hex::encode(hash),
494			size = data_len,
495			accounted,
496			expires_at,
497			"Data added to HOP pool"
498		);
499
500		Ok(hash)
501	}
502
503	/// Read a blob from disk and verify its content hash.
504	///
505	/// Content addressing means `blake2_256(data) == *hash` is an invariant
506	/// — corruption (bit rot, partial write, local tampering) violates it.
507	/// On integrity failure the caller-facing result is the same as a missing
508	/// blob and the broken entry is purged so subsequent reads converge.
509	fn read_and_verify_blob(&self, hash: &HopHash) -> Result<Vec<u8>, HopError> {
510		let blob_path = self.blob_path(hash);
511		let data = fs::read(&blob_path).map_err(|e| {
512			if e.kind() == std::io::ErrorKind::NotFound {
513				HopError::NotFound
514			} else {
515				HopError::IoError(e)
516			}
517		})?;
518		if H256(blake2_256(&data)) != *hash {
519			tracing::error!(
520				target: "hop",
521				hash = ?hex::encode(hash),
522				size = data.len(),
523				"Blob integrity check failed; purging entry"
524			);
525			self.purge_corrupt_entry(hash);
526			return Err(HopError::NotFound);
527		}
528		Ok(data)
529	}
530
531	/// Remove a corrupt entry from the index and best-effort delete its files.
532	/// The accounted size is released back to the pool and the user quota.
533	fn purge_corrupt_entry(&self, hash: &HopHash) {
534		let removed = {
535			let mut index = self.index.lock();
536			index.remove(hash).map(|meta| {
537				let accounted = entry_accounted_size(meta.size, meta.recipients.len());
538				self.current_size.fetch_sub(accounted, Ordering::Relaxed);
539				self.publish_size_metrics(index.len());
540				(meta.sender_id, accounted)
541			})
542		};
543		if let Some((sender_id, accounted)) = removed {
544			self.release_user_quota(&sender_id, accounted);
545			self.metrics.record_removed(removal_reasons::CORRUPT, 1);
546		}
547		let _ = fs::remove_file(self.blob_path(hash));
548		let _ = fs::remove_file(self.meta_path(hash));
549	}
550
551	/// Read and verify a blob, returning `None` for missing entries and logging
552	/// any other failure. Shared by [`Self::get`] and [`Self::get_with_auth`].
553	fn read_or_log(&self, hash: &HopHash) -> Option<Vec<u8>> {
554		match self.read_and_verify_blob(hash) {
555			Ok(data) => Some(data),
556			Err(HopError::NotFound) => None,
557			Err(e) => {
558				tracing::error!(
559					target: "hop",
560					hash = ?hex::encode(hash),
561					error = ?e,
562					"Failed to read blob from disk"
563				);
564				None
565			},
566		}
567	}
568
569	/// Get data from the pool by content hash.
570	pub fn get(&self, hash: &HopHash) -> Option<Vec<u8>> {
571		{
572			let index = self.index.lock();
573			if !index.contains_key(hash) {
574				return None;
575			}
576		}
577		self.read_or_log(hash)
578	}
579
580	/// Get data alongside the submitter's `MultiSigner`, `hop_submit` signature,
581	/// and submit timestamp.
582	///
583	/// Used by the promoter so the unsigned promotion extrinsic can carry the
584	/// user's submit-time signature for runtime-side verification.
585	pub fn get_with_auth(
586		&self,
587		hash: &HopHash,
588	) -> Option<(Vec<u8>, MultiSigner, MultiSignature, u64)> {
589		let (signer, signature, submit_timestamp) = {
590			let index = self.index.lock();
591			let meta = index.get(hash)?;
592			(meta.signer.clone(), meta.signature.clone(), meta.submit_timestamp)
593		};
594		let data = self.read_or_log(hash)?;
595		Some((data, signer, signature, submit_timestamp))
596	}
597
598	/// Decode `signature` and return the index of the matching recipient in
599	/// `meta.recipients`. `context` is the operation's domain separator (claim
600	/// / ack). Returning an index keeps a single implementation for both
601	/// shared- and exclusive-borrow callers (`meta.recipients[idx]` works in
602	/// either case).
603	fn find_recipient_idx(
604		meta: &HopEntryMeta,
605		hash: &HopHash,
606		signature: &[u8],
607		context: &[u8],
608	) -> Result<usize, HopError> {
609		let multi_sig =
610			MultiSignature::decode(&mut &signature[..]).map_err(|_| HopError::InvalidSignature)?;
611		let payload = signing_payload(context, hash);
612
613		meta.recipients
614			.iter()
615			.position(|r| multi_sig.verify(&payload[..], &r.signer.clone().into_account()))
616			.ok_or(HopError::NotRecipient)
617	}
618
619	/// Claim data from the pool (read-only). Verifies the signature against recipient
620	/// public keys. Returns the data if the signature matches a recipient.
621	///
622	/// This does NOT mark the recipient as claimed — call `ack` after receiving the data
623	/// to confirm receipt.
624	///
625	/// Returns `AlreadyClaimed` if the recipient has already acked (data may be deleted).
626	pub fn claim(&self, hash: &HopHash, signature: &[u8]) -> Result<Vec<u8>, HopError> {
627		{
628			let index = self.index.lock();
629			let meta = index.get(hash).ok_or(HopError::NotFound)?;
630			// Map NotRecipient → NotFound so callers cannot probe whether a hash
631			// exists by observing different error codes.
632			let idx = Self::find_recipient_idx(meta, hash, signature, HOP_CLAIM_CONTEXT)
633				.map_err(|_| HopError::NotFound)?;
634
635			// If this recipient already acked, the data may be gone.
636			if meta.recipients[idx].claimed {
637				return Err(HopError::AlreadyClaimed);
638			}
639		}
640		// Read blob from disk and verify its content hash. May be gone if
641		// concurrently acked and deleted, in which case we surface NotFound.
642		self.read_and_verify_blob(hash)
643	}
644
645	/// Acknowledge receipt of claimed data. Marks the recipient as claimed and triggers
646	/// cleanup when all recipients have acked.
647	///
648	/// Idempotent: acking a recipient that already acked returns `Ok(())`.
649	pub fn ack(&self, hash: &HopHash, signature: &[u8]) -> Result<(), HopError> {
650		// Phase 1: idempotent fast path under read lock.
651		{
652			let index = self.index.lock();
653			let meta = index.get(hash).ok_or(HopError::NotFound)?;
654			let idx = Self::find_recipient_idx(meta, hash, signature, HOP_ACK_CONTEXT)
655				.map_err(|_| HopError::NotFound)?;
656			if meta.recipients[idx].claimed {
657				return Ok(());
658			}
659		}
660
661		// Phase 2: re-run the lookup against the current meta — the entry could
662		// have been removed and re-submitted with a different recipient list since Phase 1.
663		let mut index = self.index.lock();
664		let meta = index.get_mut(hash).ok_or(HopError::NotFound)?;
665		let idx = Self::find_recipient_idx(meta, hash, signature, HOP_ACK_CONTEXT)
666			.map_err(|_| HopError::NotFound)?;
667
668		if meta.recipients[idx].claimed {
669			return Ok(());
670		}
671
672		meta.recipients[idx].claimed = true;
673
674		// If all recipients have acked, remove the entry entirely.
675		if meta.recipients.iter().all(|r| r.claimed) {
676			let accounted = entry_accounted_size(meta.size, meta.recipients.len());
677			let sender = meta.sender_id;
678			index.remove(hash);
679			self.current_size.fetch_sub(accounted, Ordering::Relaxed);
680			self.publish_size_metrics(index.len());
681			self.release_user_quota(&sender, accounted);
682			drop(index);
683			self.metrics.record_removed(removal_reasons::ACKED, 1);
684
685			// Delete files from disk (best-effort; orphans cleaned on restart).
686			let _ = fs::remove_file(self.blob_path(hash));
687			let _ = fs::remove_file(self.meta_path(hash));
688
689			tracing::info!(
690				target: "hop",
691				hash = ?hex::encode(hash),
692				"All recipients acked, data removed"
693			);
694		} else {
695			let claimed_count = meta.recipients.iter().filter(|r| r.claimed).count();
696			// Persist updated claimed state to disk.
697			let meta_bytes = meta.encode();
698			let meta_path = self.meta_path(hash);
699			if let Err(e) = Self::write_atomic(&meta_path, &meta_bytes) {
700				tracing::error!(target: "hop", hash = ?hex::encode(hash), error = %e, "Failed to persist ack state");
701			}
702			drop(index);
703
704			tracing::debug!(
705				target: "hop",
706				hash = ?hex::encode(hash),
707				claimed = claimed_count,
708				"Recipient acked"
709			);
710		}
711
712		Ok(())
713	}
714
715	/// Check if data exists in the pool.
716	#[cfg(test)]
717	pub fn has(&self, hash: &HopHash) -> bool {
718		let index = self.index.lock();
719		index.contains_key(hash)
720	}
721
722	/// Remove data from the pool.
723	#[cfg(test)]
724	pub fn remove(&self, hash: &HopHash) -> Result<(), HopError> {
725		let meta = {
726			let mut index = self.index.lock();
727			index.remove(hash)
728		};
729
730		if let Some(meta) = meta {
731			let accounted = entry_accounted_size(meta.size, meta.recipients.len());
732			self.current_size.fetch_sub(accounted, Ordering::Relaxed);
733			self.release_user_quota(&meta.sender_id, accounted);
734
735			// Delete files from disk (best-effort).
736			let _ = fs::remove_file(self.blob_path(hash));
737			let _ = fs::remove_file(self.meta_path(hash));
738
739			tracing::debug!(
740				target: "hop",
741				hash = ?hex::encode(hash),
742				"Data removed from pool"
743			);
744
745			Ok(())
746		} else {
747			Err(HopError::NotFound)
748		}
749	}
750
751	/// Get pool status.
752	pub fn status(&self) -> PoolStatus {
753		let index = self.index.lock();
754		PoolStatus {
755			entry_count: index.len(),
756			total_bytes: self.current_size.load(Ordering::Relaxed),
757			max_bytes: self.max_size,
758		}
759	}
760
761	/// Remove expired entries and release their user quotas.
762	/// Returns the total bytes freed.
763	///
764	/// Processes entries in bounded batches to keep the index write lock from
765	/// being held across the full HashMap on huge pools. After all batches the
766	/// per-sender `user_usage` map is GC'd in a single pass.
767	///
768	/// `promotion_buffer_secs` does not affect what is cleaned up. As in
769	/// [`Self::get_promotable`] the caller owns the promotion window; here it
770	/// only scopes the backlog gauge, snapshot from the phase-4 pass.
771	pub fn cleanup_expired(&self, promotion_buffer_secs: u64) -> u64 {
772		const CLEANUP_BATCH_SIZE: usize = 10_000;
773		let mut total_freed: u64 = 0;
774		let now_secs = SystemTime::now().duration_since(UNIX_EPOCH).unwrap_or_default().as_secs();
775
776		loop {
777			// Phase 1: Under index write lock — collect and remove up to one
778			// batch of expired entries. Bounded so the lock hold scales with
779			// batch size, not pool size.
780			let expired: Vec<(HopHash, HopEntryMeta)> = {
781				let mut index = self.index.lock();
782				let expired_keys: Vec<HopHash> = index
783					.iter()
784					.filter(|(_, m)| now_secs >= m.expires_at)
785					.map(|(h, _)| *h)
786					.take(CLEANUP_BATCH_SIZE)
787					.collect();
788
789				expired_keys
790					.into_iter()
791					.filter_map(|hash| index.remove(&hash).map(|meta| (hash, meta)))
792					.collect()
793			};
794
795			if expired.is_empty() {
796				break;
797			}
798
799			// Phase 2: Update counters and batch user-quota release. Entries
800			// expiring unpromoted are the data-loss case; count them separately.
801			let mut freed = 0u64;
802			let mut promoted = 0u64;
803			let mut unpromoted = 0u64;
804			for (_, meta) in &expired {
805				freed =
806					freed.saturating_add(entry_accounted_size(meta.size, meta.recipients.len()));
807				if meta.promoted {
808					promoted = promoted.saturating_add(1);
809				} else {
810					unpromoted = unpromoted.saturating_add(1);
811				}
812			}
813			self.current_size.fetch_sub(freed, Ordering::Relaxed);
814			total_freed = total_freed.saturating_add(freed);
815			self.metrics.record_removed(removal_reasons::EXPIRED_PROMOTED, promoted);
816			self.metrics.record_removed(removal_reasons::EXPIRED_UNPROMOTED, unpromoted);
817
818			{
819				let usage = self.user_usage.read();
820				for (_, meta) in &expired {
821					if let Some(counter) = usage.get(&meta.sender_id) {
822						let accounted = entry_accounted_size(meta.size, meta.recipients.len());
823						saturating_release(counter, accounted);
824					}
825				}
826			}
827
828			// Phase 3: Delete files from disk (best-effort, no locks held).
829			for (hash, _) in &expired {
830				let _ = fs::remove_file(self.blob_path(hash));
831				let _ = fs::remove_file(self.meta_path(hash));
832			}
833		}
834
835		// Phase 4: Reclaim per-sender counters whose owners have no live
836		// entries. Holding `index.lock()` and `user_usage.write()` together
837		// closes the dominant TOCTOU race (concurrent writers cannot create a
838		// new index entry under our held index lock; concurrent
839		// `release_user_quota` only takes `user_usage.read()` which is
840		// excluded). Build a live-sender set in one index pass so retain is
841		// O(senders + entries) instead of O(senders × entries). The same pass
842		// counts the promotion backlog and publishes the size gauges.
843		let backlog = {
844			let index = self.index.lock();
845			let mut usage = self.user_usage.write();
846			let mut live: HashSet<&SenderId> = HashSet::new();
847			let mut backlog = 0u64;
848			// The window test exists only to feed a gauge.
849			let count_backlog = self.metrics.is_enabled();
850			for meta in index.values() {
851				live.insert(&meta.sender_id);
852				if count_backlog && Self::in_promotion_window(meta, now_secs, promotion_buffer_secs)
853				{
854					backlog = backlog.saturating_add(1);
855				}
856			}
857			usage.retain(|sender_id, counter| {
858				counter.load(Ordering::Relaxed) > 0 || live.contains(sender_id)
859			});
860			self.publish_size_metrics(index.len());
861			backlog
862		};
863
864		// Let the rate limiter shed stale per-sender state on the same cadence.
865		self.rate_limiter.evict_stale();
866
867		self.metrics.set_promotion_backlog(backlog);
868
869		total_freed
870	}
871
872	/// Outstanding promotion candidate: unpromoted, near expiry, attempts left.
873	/// Ignores the back-off deadline — a backing-off entry is still outstanding.
874	fn in_promotion_window(meta: &HopEntryMeta, now_secs: u64, buffer_secs: u64) -> bool {
875		!meta.promoted &&
876			now_secs.saturating_add(buffer_secs) >= meta.expires_at &&
877			meta.promotion_attempts < MAX_PROMOTION_ATTEMPTS
878	}
879
880	/// Return hashes of entries within `buffer_secs` of expiry that have not yet been promoted.
881	/// Returns up to `limit` hashes. Use [`Self::get`] to read blob data when needed.
882	/// The maintenance task runs periodically, so remaining entries are picked up next cycle.
883	pub fn get_promotable(
884		&self,
885		current_block: HopBlockNumber,
886		buffer_secs: u64,
887		limit: usize,
888	) -> Vec<HopHash> {
889		let now_secs = SystemTime::now().duration_since(UNIX_EPOCH).unwrap_or_default().as_secs();
890		let index = self.index.lock();
891		// `take(limit)` stays lazy: this holds the index lock, so a full scan
892		// would block inserts and acks. The backlog gauge is snapshot in
893		// `cleanup_expired` instead.
894		index
895			.iter()
896			.filter(|(_, meta)| {
897				Self::in_promotion_window(meta, now_secs, buffer_secs) &&
898					current_block >= meta.next_promotion_attempt_at
899			})
900			.map(|(h, _)| *h)
901			.take(limit)
902			.collect()
903	}
904
905	/// Mark an entry as promoted to permanent on-chain storage.
906	/// Persists the updated metadata to disk.
907	pub fn mark_promoted(&self, hash: &HopHash) {
908		let mut index = self.index.lock();
909		if let Some(meta) = index.get_mut(hash) {
910			// Count the transition, not the call: this setter is idempotent.
911			let newly_promoted = !meta.promoted;
912			meta.promoted = true;
913			let meta_bytes = meta.encode();
914			let meta_path = self.meta_path(hash);
915			drop(index);
916			if newly_promoted {
917				self.metrics.record_promotion_confirmed();
918			}
919
920			if let Err(e) = Self::write_atomic(&meta_path, &meta_bytes) {
921				tracing::error!(
922					target: "hop",
923					hash = ?hex::encode(hash),
924					error = %e,
925					"Failed to persist promoted state"
926				);
927			}
928		}
929	}
930
931	/// Record a promotion attempt: bumps the per-entry attempt counter and
932	/// schedules the next eligible block via exponential back-off. The
933	/// maintenance task will skip the entry until then. Once
934	/// `MAX_PROMOTION_ATTEMPTS` is reached the entry is left to expire.
935	///
936	/// Called on **both** an `Err` from `submit_local` (the tx pool rejected
937	/// us) and an `Ok` followed by a runtime check that the data is not yet
938	/// on-chain (the tx was accepted into the pool but never included). The
939	/// backoff schedule is identical for both cases.
940	pub fn record_promotion_attempt(
941		&self,
942		hash: &HopHash,
943		current_block: HopBlockNumber,
944		check_interval_blocks: u32,
945	) {
946		let mut index = self.index.lock();
947		if let Some(meta) = index.get_mut(hash) {
948			meta.promotion_attempts = meta.promotion_attempts.saturating_add(1);
949			let backoff = promotion_backoff_blocks(meta.promotion_attempts, check_interval_blocks);
950			meta.next_promotion_attempt_at = current_block.saturating_add(backoff);
951			let meta_bytes = meta.encode();
952			let meta_path = self.meta_path(hash);
953			drop(index);
954
955			if let Err(e) = Self::write_atomic(&meta_path, &meta_bytes) {
956				tracing::error!(
957					target: "hop",
958					hash = ?hex::encode(hash),
959					error = %e,
960					"Failed to persist promotion-attempt state"
961				);
962			}
963		}
964	}
965}
966
967/// Decode a 64-char hex stem into a `HopHash`. Returns `None` for any
968/// non-32-byte stem (corrupt name, wrong length, non-hex chars).
969fn parse_hex_hash(stem: &str) -> Option<HopHash> {
970	let bytes = hex::decode(stem).ok()?;
971	let arr: [u8; 32] = bytes.try_into().ok()?;
972	Some(H256(arr))
973}
974
975/// Atomically subtract `accounted` from `counter`, clamped so the counter
976/// cannot underflow. The CAS retry inside `fetch_update` keeps the clamp
977/// value fresh — a plain `counter.fetch_sub(accounted.min(counter.load()), …)`
978/// would race with concurrent releases on the same counter and could wrap
979/// to near `u64::MAX`.
980fn saturating_release(counter: &AtomicU64, accounted: u64) {
981	let _ = counter.fetch_update(Ordering::Relaxed, Ordering::Relaxed, |previous| {
982		Some(previous - accounted.min(previous))
983	});
984}
985
986#[cfg(test)]
987mod tests {
988	use super::*;
989	use crate::types::{Recipient, MAX_RECIPIENTS};
990	use sp_core::{crypto::Pair, ed25519, sr25519};
991	use sp_runtime::MultiSigner;
992	use tempfile::TempDir;
993
994	const SENDER_A: SenderId = [1u8; 32];
995	const SENDER_B: SenderId = [2u8; 32];
996
997	/// Accounted cost of an entry with `data_size` bytes and `num_recipients` recipients.
998	fn acct(data_size: u64, num_recipients: usize) -> u64 {
999		entry_accounted_size(data_size, num_recipients)
1000	}
1001
1002	fn make_pool(max_size: u64, retention_secs: u64) -> (HopDataPool, TempDir) {
1003		let dir = TempDir::new().unwrap();
1004		let pool = HopDataPool::new(
1005			max_size,
1006			max_size,
1007			retention_secs,
1008			dir.path().to_path_buf(),
1009			RateLimitConfig::disabled(),
1010			HopMetrics::disabled(),
1011		)
1012		.unwrap();
1013		(pool, dir)
1014	}
1015
1016	fn make_pool_with_user_cap(
1017		max_size: u64,
1018		max_user_size: u64,
1019		retention_secs: u64,
1020	) -> (HopDataPool, TempDir) {
1021		let dir = TempDir::new().unwrap();
1022		let pool = HopDataPool::new(
1023			max_size,
1024			max_user_size,
1025			retention_secs,
1026			dir.path().to_path_buf(),
1027			RateLimitConfig::disabled(),
1028			HopMetrics::disabled(),
1029		)
1030		.unwrap();
1031		(pool, dir)
1032	}
1033
1034	fn create_test_pool() -> (HopDataPool, TempDir) {
1035		make_pool(1024 * 1024, 100)
1036	}
1037
1038	fn test_recipient() -> (ed25519::Pair, MultiSigner) {
1039		let pair = ed25519::Pair::from_seed(&[1u8; 32]);
1040		let signer = MultiSigner::Ed25519(pair.public());
1041		(pair, signer)
1042	}
1043
1044	/// Deterministic placeholder `(MultiSigner, MultiSignature)` for tests that
1045	/// don't exercise submit-signature semantics. The actual values are never
1046	/// verified by these tests.
1047	fn dummy_auth() -> (MultiSigner, MultiSignature) {
1048		let pair = ed25519::Pair::from_seed(&[7u8; 32]);
1049		let signer = MultiSigner::Ed25519(pair.public());
1050		let sig = MultiSignature::Ed25519(pair.sign(&[]));
1051		(signer, sig)
1052	}
1053
1054	fn sign_ed(pair: &ed25519::Pair, context: &[u8], hash: &HopHash) -> Vec<u8> {
1055		let payload = signing_payload(context, hash);
1056		MultiSignature::Ed25519(pair.sign(&payload)).encode()
1057	}
1058
1059	fn sign_sr(pair: &sr25519::Pair, context: &[u8], hash: &HopHash) -> Vec<u8> {
1060		let payload = signing_payload(context, hash);
1061		MultiSignature::Sr25519(pair.sign(&payload)).encode()
1062	}
1063
1064	fn user_usage(pool: &HopDataPool, sender: &SenderId) -> u64 {
1065		pool.user_usage
1066			.read()
1067			.get(sender)
1068			.map(|c| c.load(Ordering::Relaxed))
1069			.unwrap_or(0)
1070	}
1071
1072	/// Convert a `Vec<MultiSigner>` into a `RecipientVec` (with `claimed=false` for
1073	/// each) for test ergonomics; panics only if a test exceeds `MAX_RECIPIENTS`.
1074	fn bv(v: Vec<MultiSigner>) -> RecipientVec {
1075		let recipients: Vec<Recipient> =
1076			v.into_iter().map(|signer| Recipient { signer, claimed: false }).collect();
1077		RecipientVec::try_from(recipients).expect("test recipient list exceeds MAX_RECIPIENTS")
1078	}
1079
1080	#[test]
1081	fn metrics_track_insert_ack_and_expiry() {
1082		let registry = prometheus_endpoint::Registry::new();
1083		let dir = TempDir::new().unwrap();
1084		let pool = HopDataPool::new(
1085			1024 * 1024,
1086			1024 * 1024,
1087			0, // entries expire immediately
1088			dir.path().to_path_buf(),
1089			RateLimitConfig::disabled(),
1090			HopMetrics::new(Some(&registry)).unwrap(),
1091		)
1092		.unwrap();
1093		let (pair, signer) = test_recipient();
1094
1095		// Insert + full ack removes the entry under `acked`.
1096		let hash = pool
1097			.insert(
1098				vec![1u8; 50],
1099				bv(vec![signer.clone()]),
1100				SENDER_A,
1101				dummy_auth().0,
1102				dummy_auth().1,
1103				0,
1104			)
1105			.unwrap();
1106		assert_eq!(pool.metrics().pool_gauges(), (1, acct(50, 1)));
1107		let ack = sign_ed(&pair, HOP_ACK_CONTEXT, &hash);
1108		pool.ack(&hash, &ack).unwrap();
1109		assert_eq!(pool.metrics().removed_count(removal_reasons::ACKED), 1);
1110		assert_eq!(pool.metrics().pool_gauges(), (0, 0));
1111
1112		// An entry expiring without promotion counts as data loss.
1113		pool.insert(vec![2u8; 30], bv(vec![signer]), SENDER_A, dummy_auth().0, dummy_auth().1, 0)
1114			.unwrap();
1115		pool.cleanup_expired(0);
1116		assert_eq!(pool.metrics().removed_count(removal_reasons::EXPIRED_UNPROMOTED), 1);
1117		assert_eq!(pool.metrics().removed_count(removal_reasons::EXPIRED_PROMOTED), 0);
1118		assert_eq!(pool.metrics().pool_gauges(), (0, 0));
1119	}
1120
1121	/// Pool with registered metrics, so counters can be read back.
1122	fn make_metered_pool(retention_secs: u64) -> (HopDataPool, TempDir) {
1123		let registry = prometheus_endpoint::Registry::new();
1124		let dir = TempDir::new().unwrap();
1125		let pool = HopDataPool::new(
1126			1024 * 1024,
1127			1024 * 1024,
1128			retention_secs,
1129			dir.path().to_path_buf(),
1130			RateLimitConfig::disabled(),
1131			HopMetrics::new(Some(&registry)).unwrap(),
1132		)
1133		.unwrap();
1134		(pool, dir)
1135	}
1136
1137	#[test]
1138	fn backlog_gauge_counts_backing_off_entries() {
1139		let (pool, _dir) = make_metered_pool(/* retention = */ 100);
1140		let (_, signer) = test_recipient();
1141		let buffer = 300_u64;
1142		let hash = pool
1143			.insert(vec![1u8; 10], bv(vec![signer]), SENDER_A, dummy_auth().0, dummy_auth().1, 0)
1144			.unwrap();
1145
1146		// Outside the window with a buffer of 0, inside it with 300s.
1147		pool.cleanup_expired(0);
1148		assert_eq!(pool.metrics().promotion_backlog(), 0);
1149
1150		pool.cleanup_expired(buffer);
1151		assert_eq!(pool.metrics().promotion_backlog(), 1);
1152
1153		// Backing off drops it from `get_promotable` but not from the backlog.
1154		pool.record_promotion_attempt(&hash, 60, /* check_interval_blocks = */ 10);
1155		assert!(pool.get_promotable(60, buffer, 10).is_empty());
1156		pool.cleanup_expired(buffer);
1157		assert_eq!(pool.metrics().promotion_backlog(), 1);
1158
1159		pool.mark_promoted(&hash);
1160		pool.cleanup_expired(buffer);
1161		assert_eq!(pool.metrics().promotion_backlog(), 0);
1162	}
1163
1164	#[test]
1165	fn confirmed_counter_ignores_repeated_mark_promoted() {
1166		let (pool, _dir) = make_metered_pool(100);
1167		let (_, signer) = test_recipient();
1168		let hash = pool
1169			.insert(vec![1u8; 10], bv(vec![signer]), SENDER_A, dummy_auth().0, dummy_auth().1, 0)
1170			.unwrap();
1171
1172		pool.mark_promoted(&hash);
1173		pool.mark_promoted(&hash);
1174		assert_eq!(pool.metrics().promotions_confirmed(), 1, "only the transition counts");
1175	}
1176
1177	#[test]
1178	fn test_insert_and_get() {
1179		let (pool, _dir) = create_test_pool();
1180		let (_, signer) = test_recipient();
1181		let data = vec![1, 2, 3, 4, 5];
1182		let hash = pool
1183			.insert(data.clone(), bv(vec![signer]), SENDER_A, dummy_auth().0, dummy_auth().1, 0)
1184			.unwrap();
1185
1186		let retrieved = pool.get(&hash).unwrap();
1187		assert_eq!(data, retrieved);
1188	}
1189
1190	#[test]
1191	fn test_insert_no_recipients() {
1192		let (pool, _dir) = create_test_pool();
1193		let data = vec![1, 2, 3, 4, 5];
1194		let result = pool.insert(data, bv(vec![]), SENDER_A, dummy_auth().0, dummy_auth().1, 0);
1195		assert!(matches!(result, Err(HopError::NoRecipients)));
1196	}
1197
1198	#[test]
1199	fn test_duplicate_insert() {
1200		let (pool, _dir) = create_test_pool();
1201		let (_, signer) = test_recipient();
1202		let data = vec![1, 2, 3, 4, 5];
1203
1204		pool.insert(
1205			data.clone(),
1206			bv(vec![signer.clone()]),
1207			SENDER_A,
1208			dummy_auth().0,
1209			dummy_auth().1,
1210			0,
1211		)
1212		.unwrap();
1213		let result =
1214			pool.insert(data, bv(vec![signer]), SENDER_A, dummy_auth().0, dummy_auth().1, 0);
1215
1216		assert!(matches!(result, Err(HopError::DuplicateEntry)));
1217	}
1218
1219	#[test]
1220	fn test_too_many_recipients_rejected_at_type_level() {
1221		// Construction of a `RecipientVec` with more than `MAX_RECIPIENTS` entries
1222		// fails at `try_from`; callers (like the RPC) turn that into a
1223		// `TooManyRecipients` error before reaching the pool.
1224		let recipients: Vec<Recipient> = (0..=MAX_RECIPIENTS as u64)
1225			.map(|i| {
1226				let mut seed = [0u8; 32];
1227				seed[..8].copy_from_slice(&i.to_le_bytes());
1228				Recipient {
1229					signer: MultiSigner::Ed25519(ed25519::Pair::from_seed(&seed).public()),
1230					claimed: false,
1231				}
1232			})
1233			.collect();
1234		assert_eq!(recipients.len(), MAX_RECIPIENTS as usize + 1);
1235		assert!(RecipientVec::try_from(recipients).is_err());
1236	}
1237
1238	#[test]
1239	fn test_duplicate_recipient_rejected() {
1240		let (pool, _dir) = create_test_pool();
1241		let (_, signer) = test_recipient();
1242		let result = pool.insert(
1243			vec![1, 2, 3],
1244			bv(vec![signer.clone(), signer]),
1245			SENDER_A,
1246			dummy_auth().0,
1247			dummy_auth().1,
1248			0,
1249		);
1250		assert!(matches!(result, Err(HopError::DuplicateRecipient)));
1251	}
1252
1253	#[test]
1254	fn test_pool_full() {
1255		// Capacity exactly holds one 60-byte entry with one recipient (60 + 40 = 100).
1256		let (pool, _dir) = make_pool(acct(60, 1), 100);
1257		let (_, signer) = test_recipient();
1258
1259		let data1 = vec![0u8; 60];
1260		let data2 = vec![1u8; 50];
1261
1262		pool.insert(data1, bv(vec![signer.clone()]), SENDER_A, dummy_auth().0, dummy_auth().1, 0)
1263			.unwrap();
1264		let result =
1265			pool.insert(data2, bv(vec![signer]), SENDER_A, dummy_auth().0, dummy_auth().1, 0);
1266
1267		assert!(matches!(result, Err(HopError::PoolFull(_, _))));
1268	}
1269
1270	#[test]
1271	fn test_remove() {
1272		let (pool, _dir) = create_test_pool();
1273		let (_, signer) = test_recipient();
1274		let data = vec![1, 2, 3, 4, 5];
1275		let hash = pool
1276			.insert(data, bv(vec![signer]), SENDER_A, dummy_auth().0, dummy_auth().1, 0)
1277			.unwrap();
1278
1279		assert!(pool.has(&hash));
1280		pool.remove(&hash).unwrap();
1281		assert!(!pool.has(&hash));
1282
1283		// Files should be cleaned up.
1284		assert!(!pool.blob_path(&hash).exists());
1285		assert!(!pool.meta_path(&hash).exists());
1286	}
1287
1288	#[test]
1289	fn test_status() {
1290		let (pool, _dir) = create_test_pool();
1291		let (_, signer) = test_recipient();
1292		let data1 = vec![1, 2, 3, 4, 5];
1293		let data2 = vec![6, 7, 8];
1294
1295		pool.insert(
1296			data1.clone(),
1297			bv(vec![signer.clone()]),
1298			SENDER_A,
1299			dummy_auth().0,
1300			dummy_auth().1,
1301			0,
1302		)
1303		.unwrap();
1304		pool.insert(data2.clone(), bv(vec![signer]), SENDER_A, dummy_auth().0, dummy_auth().1, 0)
1305			.unwrap();
1306
1307		let status = pool.status();
1308		assert_eq!(status.entry_count, 2);
1309		assert_eq!(status.total_bytes, acct(data1.len() as u64, 1) + acct(data2.len() as u64, 1));
1310	}
1311
1312	#[test]
1313	fn test_claim_valid_signature() {
1314		let (pool, _dir) = create_test_pool();
1315		let (pair, signer) = test_recipient();
1316		let data = vec![1, 2, 3, 4, 5];
1317		let hash = pool
1318			.insert(data.clone(), bv(vec![signer]), SENDER_A, dummy_auth().0, dummy_auth().1, 0)
1319			.unwrap();
1320
1321		let claim = sign_ed(&pair, HOP_CLAIM_CONTEXT, &hash);
1322		let ack = sign_ed(&pair, HOP_ACK_CONTEXT, &hash);
1323		let result = pool.claim(&hash, &claim).unwrap();
1324		assert_eq!(data, result);
1325
1326		// Entry still exists until ack.
1327		assert!(pool.has(&hash));
1328
1329		pool.ack(&hash, &ack).unwrap();
1330		assert!(!pool.has(&hash));
1331	}
1332
1333	#[test]
1334	fn test_claim_sig_rejected_on_ack() {
1335		// Domain separation: a claim signature cannot be replayed as an ack.
1336		let (pool, _dir) = create_test_pool();
1337		let (pair, signer) = test_recipient();
1338		let hash = pool
1339			.insert(vec![1, 2, 3], bv(vec![signer]), SENDER_A, dummy_auth().0, dummy_auth().1, 0)
1340			.unwrap();
1341
1342		let claim = sign_ed(&pair, HOP_CLAIM_CONTEXT, &hash);
1343		pool.claim(&hash, &claim).unwrap();
1344		assert!(matches!(pool.ack(&hash, &claim), Err(HopError::NotFound)));
1345	}
1346
1347	#[test]
1348	fn test_claim_invalid_signature() {
1349		let (pool, _dir) = create_test_pool();
1350		let (_, signer) = test_recipient();
1351		let data = vec![1, 2, 3, 4, 5];
1352		let hash = pool
1353			.insert(data, bv(vec![signer]), SENDER_A, dummy_auth().0, dummy_auth().1, 0)
1354			.unwrap();
1355
1356		// Use invalid SCALE bytes — cannot decode as MultiSignature
1357		let result = pool.claim(&hash, &[0u8; 3]);
1358		assert!(matches!(result, Err(HopError::NotFound)));
1359	}
1360
1361	#[test]
1362	fn test_claim_wrong_key() {
1363		let (pool, _dir) = create_test_pool();
1364		let (_, signer) = test_recipient();
1365		let hash = pool
1366			.insert(
1367				vec![1, 2, 3, 4, 5],
1368				bv(vec![signer]),
1369				SENDER_A,
1370				dummy_auth().0,
1371				dummy_auth().1,
1372				0,
1373			)
1374			.unwrap();
1375
1376		let wrong_pair = ed25519::Pair::from_seed(&[99u8; 32]);
1377		let wrong_claim = sign_ed(&wrong_pair, HOP_CLAIM_CONTEXT, &hash);
1378		assert!(matches!(pool.claim(&hash, &wrong_claim), Err(HopError::NotFound)));
1379		assert!(pool.has(&hash));
1380	}
1381
1382	#[test]
1383	fn test_claim_multi_recipient() {
1384		let (pool, _dir) = create_test_pool();
1385		let pair1 = ed25519::Pair::from_seed(&[1u8; 32]);
1386		let pair2 = ed25519::Pair::from_seed(&[2u8; 32]);
1387		let signer1 = MultiSigner::Ed25519(pair1.public());
1388		let signer2 = MultiSigner::Ed25519(pair2.public());
1389
1390		let data = vec![1, 2, 3, 4, 5];
1391		let hash = pool
1392			.insert(
1393				data.clone(),
1394				bv(vec![signer1, signer2]),
1395				SENDER_A,
1396				dummy_auth().0,
1397				dummy_auth().1,
1398				0,
1399			)
1400			.unwrap();
1401
1402		let claim1 = sign_ed(&pair1, HOP_CLAIM_CONTEXT, &hash);
1403		let ack1 = sign_ed(&pair1, HOP_ACK_CONTEXT, &hash);
1404		assert_eq!(data, pool.claim(&hash, &claim1).unwrap());
1405		pool.ack(&hash, &ack1).unwrap();
1406		assert!(pool.has(&hash));
1407
1408		let claim2 = sign_ed(&pair2, HOP_CLAIM_CONTEXT, &hash);
1409		let ack2 = sign_ed(&pair2, HOP_ACK_CONTEXT, &hash);
1410		assert_eq!(data, pool.claim(&hash, &claim2).unwrap());
1411		pool.ack(&hash, &ack2).unwrap();
1412		assert!(!pool.has(&hash));
1413		assert_eq!(pool.status().total_bytes, 0);
1414	}
1415
1416	#[test]
1417	fn test_claim_after_ack_returns_already_claimed() {
1418		let (pool, _dir) = create_test_pool();
1419		let (pair, signer) = test_recipient();
1420		let pair2 = ed25519::Pair::from_seed(&[2u8; 32]);
1421		let signer2 = MultiSigner::Ed25519(pair2.public());
1422
1423		let hash = pool
1424			.insert(
1425				vec![1, 2, 3, 4, 5],
1426				bv(vec![signer, signer2]),
1427				SENDER_A,
1428				dummy_auth().0,
1429				dummy_auth().1,
1430				0,
1431			)
1432			.unwrap();
1433
1434		let claim = sign_ed(&pair, HOP_CLAIM_CONTEXT, &hash);
1435		let ack = sign_ed(&pair, HOP_ACK_CONTEXT, &hash);
1436		pool.claim(&hash, &claim).unwrap();
1437		pool.ack(&hash, &ack).unwrap();
1438
1439		// Same recipient claims again — already acked.
1440		assert!(matches!(pool.claim(&hash, &claim), Err(HopError::AlreadyClaimed)));
1441	}
1442
1443	#[test]
1444	fn test_claim_not_found() {
1445		let (pool, _dir) = create_test_pool();
1446		let fake_hash = H256([0u8; 32]);
1447		let result = pool.claim(&fake_hash, &[0u8; 64]);
1448		assert!(matches!(result, Err(HopError::NotFound)));
1449	}
1450
1451	#[test]
1452	fn test_per_user_cap_is_hard_limit() {
1453		// Pool big enough for multiple users; user cap sized to one 60-byte entry (+ metadata).
1454		let (pool, _dir) = make_pool_with_user_cap(10_000, acct(60, 1), 100);
1455		let (_, signer) = test_recipient();
1456
1457		pool.insert(
1458			vec![0u8; 60],
1459			bv(vec![signer.clone()]),
1460			SENDER_A,
1461			dummy_auth().0,
1462			dummy_auth().1,
1463			0,
1464		)
1465		.unwrap();
1466
1467		// User A is at the cap; next insert is rejected regardless of pool headroom.
1468		let result = pool.insert(
1469			vec![1u8; 10],
1470			bv(vec![signer.clone()]),
1471			SENDER_A,
1472			dummy_auth().0,
1473			dummy_auth().1,
1474			0,
1475		);
1476		assert!(matches!(result, Err(HopError::UserQuotaExceeded { .. })));
1477
1478		// User B has their own independent cap.
1479		pool.insert(vec![2u8; 60], bv(vec![signer]), SENDER_B, dummy_auth().0, dummy_auth().1, 0)
1480			.unwrap();
1481	}
1482
1483	#[test]
1484	fn test_quota_released_after_ack() {
1485		let (pool, _dir) = make_pool_with_user_cap(10_000, acct(100, 1), 100);
1486		let (pair, signer) = test_recipient();
1487
1488		let hash = pool
1489			.insert(
1490				vec![0u8; 100],
1491				bv(vec![signer.clone()]),
1492				SENDER_A,
1493				dummy_auth().0,
1494				dummy_auth().1,
1495				0,
1496			)
1497			.unwrap();
1498
1499		// At cap; next insert rejected.
1500		let result = pool.insert(
1501			vec![1u8; 10],
1502			bv(vec![signer.clone()]),
1503			SENDER_A,
1504			dummy_auth().0,
1505			dummy_auth().1,
1506			0,
1507		);
1508		assert!(matches!(result, Err(HopError::UserQuotaExceeded { .. })));
1509
1510		let claim = sign_ed(&pair, HOP_CLAIM_CONTEXT, &hash);
1511		let ack = sign_ed(&pair, HOP_ACK_CONTEXT, &hash);
1512		pool.claim(&hash, &claim).unwrap();
1513		pool.ack(&hash, &ack).unwrap();
1514
1515		// Quota freed — user can insert again.
1516		pool.insert(vec![2u8; 100], bv(vec![signer]), SENDER_A, dummy_auth().0, dummy_auth().1, 0)
1517			.unwrap();
1518	}
1519
1520	#[test]
1521	fn test_cleanup_expired_releases_quota() {
1522		let (pool, _dir) = make_pool(10_000, 0);
1523		let (_, signer) = test_recipient();
1524
1525		pool.insert(vec![0u8; 100], bv(vec![signer]), SENDER_A, dummy_auth().0, dummy_auth().1, 0)
1526			.unwrap();
1527		let charged = acct(100, 1);
1528		assert_eq!(user_usage(&pool, &SENDER_A), charged);
1529
1530		let freed = pool.cleanup_expired(0);
1531		assert_eq!(freed, charged);
1532		assert_eq!(pool.status().total_bytes, 0);
1533		assert_eq!(user_usage(&pool, &SENDER_A), 0);
1534	}
1535
1536	#[test]
1537	fn test_cleanup_expired_honors_wall_clock_retention() {
1538		// Retention is measured in real seconds, not blocks: insert with a 1 s
1539		// retention, sleep past it, and assert cleanup reaps the entry.
1540		let (pool, _dir) = make_pool(10_000, 1);
1541		let (_, signer) = test_recipient();
1542
1543		let hash = pool
1544			.insert(vec![0u8; 100], bv(vec![signer]), SENDER_A, dummy_auth().0, dummy_auth().1, 0)
1545			.unwrap();
1546
1547		// Not yet expired — cleanup must be a no-op.
1548		assert_eq!(
1549			pool.cleanup_expired(0),
1550			0,
1551			"entry should still be live before retention elapses"
1552		);
1553		assert!(pool.has(&hash));
1554
1555		std::thread::sleep(std::time::Duration::from_millis(1_200));
1556
1557		assert!(
1558			pool.cleanup_expired(0) > 0,
1559			"entry should be reaped once wall-clock retention elapses"
1560		);
1561		assert!(!pool.has(&hash));
1562	}
1563
1564	#[test]
1565	fn test_user_counter_preserved_until_cleanup() {
1566		// release_user_quota does not remove the map entry — only cleanup_expired
1567		// reclaims stale per-sender slots. Until then the slot remains at 0 so a
1568		// concurrent insert would not orphan its `Arc`.
1569		let (pool, _dir) = create_test_pool();
1570		let (pair, signer) = test_recipient();
1571
1572		let hash = pool
1573			.insert(vec![0u8; 50], bv(vec![signer]), SENDER_A, dummy_auth().0, dummy_auth().1, 0)
1574			.unwrap();
1575		assert!(pool.user_usage.read().contains_key(&SENDER_A));
1576
1577		let claim = sign_ed(&pair, HOP_CLAIM_CONTEXT, &hash);
1578		let ack = sign_ed(&pair, HOP_ACK_CONTEXT, &hash);
1579		pool.claim(&hash, &claim).unwrap();
1580		pool.ack(&hash, &ack).unwrap();
1581
1582		assert_eq!(user_usage(&pool, &SENDER_A), 0);
1583		assert!(pool.user_usage.read().contains_key(&SENDER_A));
1584	}
1585
1586	#[test]
1587	fn test_cleanup_expired_evicts_idle_user_counters() {
1588		// After cleanup_expired runs and a sender has no live entries with a
1589		// non-zero counter, their map slot must be removed so the map cannot
1590		// grow unbounded across the lifetime of a long-running node.
1591		let (pool, _dir) = make_pool(10_000, 10);
1592		let (pair, signer) = test_recipient();
1593
1594		let hash = pool
1595			.insert(vec![0u8; 50], bv(vec![signer]), SENDER_A, dummy_auth().0, dummy_auth().1, 0)
1596			.unwrap();
1597		let claim = sign_ed(&pair, HOP_CLAIM_CONTEXT, &hash);
1598		let ack = sign_ed(&pair, HOP_ACK_CONTEXT, &hash);
1599		pool.claim(&hash, &claim).unwrap();
1600		pool.ack(&hash, &ack).unwrap();
1601		assert!(pool.user_usage.read().contains_key(&SENDER_A));
1602
1603		pool.cleanup_expired(0);
1604		assert!(!pool.user_usage.read().contains_key(&SENDER_A));
1605	}
1606
1607	#[test]
1608	fn test_cleanup_expired_keeps_active_user_counters() {
1609		// A sender with live (non-expired) entries must keep their counter
1610		// even when the counter dropped to 0 between submissions — otherwise
1611		// concurrent in-flight inserts could orphan their `Arc`.
1612		let (pool, _dir) = make_pool(10_000, 100);
1613		let (_, signer) = test_recipient();
1614
1615		pool.insert(vec![0u8; 50], bv(vec![signer]), SENDER_A, dummy_auth().0, dummy_auth().1, 0)
1616			.unwrap();
1617		// Cleanup at a block where the entry is not yet expired must not
1618		// reclaim the sender's slot — a concurrent insert would otherwise
1619		// orphan its `Arc`.
1620		pool.cleanup_expired(0);
1621		assert!(pool.user_usage.read().contains_key(&SENDER_A));
1622	}
1623
1624	#[test]
1625	fn test_cleanup_expired_processes_more_than_one_batch() {
1626		// Cleanup batch size is 10_000 — feed it 25_000 entries that all expire,
1627		// confirm every entry is removed (proving the loop terminates rather
1628		// than leaving leftovers from the first batch).
1629		const BATCHES: u32 = 2;
1630		const PER_BATCH: u32 = 10_000 + 1; // > one batch each
1631		let total = BATCHES * PER_BATCH;
1632
1633		let dir = TempDir::new().unwrap();
1634		// Pool sized for ~25k tiny entries (4 bytes each + recipient overhead).
1635		let entry_bytes = std::mem::size_of::<u32>() as u64;
1636		let pool = HopDataPool::new(
1637			(acct(entry_bytes, 1) * total as u64) + 1024,
1638			u64::MAX,
1639			0,
1640			dir.path().to_path_buf(),
1641			RateLimitConfig::disabled(),
1642			HopMetrics::disabled(),
1643		)
1644		.unwrap();
1645		let (_, signer) = test_recipient();
1646
1647		for i in 0..total {
1648			let mut sender = SENDER_A;
1649			sender[0] = (i & 0xff) as u8;
1650			sender[1] = ((i >> 8) & 0xff) as u8;
1651			sender[2] = ((i >> 16) & 0xff) as u8;
1652			// Data must be unique per entry — content-addressing means equal
1653			// bytes hash to the same key and the second insert hits
1654			// DuplicateEntry. Embed `i` so each blob is distinct.
1655			let data = i.to_le_bytes().to_vec();
1656			pool.insert(data, bv(vec![signer.clone()]), sender, dummy_auth().0, dummy_auth().1, 0)
1657				.unwrap();
1658		}
1659		assert_eq!(pool.status().entry_count, total as usize);
1660
1661		pool.cleanup_expired(0);
1662		assert_eq!(pool.status().entry_count, 0);
1663		assert_eq!(pool.status().total_bytes, 0);
1664		assert!(pool.user_usage.read().is_empty());
1665	}
1666
1667	#[test]
1668	fn test_restart_recovery() {
1669		let dir = TempDir::new().unwrap();
1670		let (_, signer) = test_recipient();
1671		let expected_accounted = acct(100, 1);
1672
1673		let hash;
1674		{
1675			let pool = HopDataPool::new(
1676				1024 * 1024,
1677				1024 * 1024,
1678				100,
1679				dir.path().to_path_buf(),
1680				RateLimitConfig::disabled(),
1681				HopMetrics::disabled(),
1682			)
1683			.unwrap();
1684			hash = pool
1685				.insert(
1686					vec![42u8; 100],
1687					bv(vec![signer]),
1688					SENDER_A,
1689					dummy_auth().0,
1690					dummy_auth().1,
1691					0,
1692				)
1693				.unwrap();
1694			assert!(pool.has(&hash));
1695			assert_eq!(pool.status().entry_count, 1);
1696			assert_eq!(pool.status().total_bytes, expected_accounted);
1697		}
1698
1699		{
1700			let pool = HopDataPool::new(
1701				1024 * 1024,
1702				1024 * 1024,
1703				100,
1704				dir.path().to_path_buf(),
1705				RateLimitConfig::disabled(),
1706				HopMetrics::disabled(),
1707			)
1708			.unwrap();
1709			assert!(pool.has(&hash));
1710			assert_eq!(pool.status().entry_count, 1);
1711			assert_eq!(pool.status().total_bytes, expected_accounted);
1712
1713			let data = pool.get(&hash).unwrap();
1714			assert_eq!(data, vec![42u8; 100]);
1715			assert_eq!(user_usage(&pool, &SENDER_A), expected_accounted);
1716		}
1717	}
1718
1719	#[test]
1720	fn test_orphan_blob_cleanup() {
1721		let dir = TempDir::new().unwrap();
1722		{
1723			let _pool = HopDataPool::new(
1724				1024 * 1024,
1725				1024 * 1024,
1726				100,
1727				dir.path().to_path_buf(),
1728				RateLimitConfig::disabled(),
1729				HopMetrics::disabled(),
1730			)
1731			.unwrap();
1732		}
1733
1734		let orphan_hash = "aa".to_string() + &"bb".repeat(15);
1735		let blob_path = dir.path().join("blobs").join("aa").join(format!("{}.blob", orphan_hash));
1736		fs::write(&blob_path, b"orphan data").unwrap();
1737		assert!(blob_path.exists());
1738
1739		let _pool = HopDataPool::new(
1740			1024 * 1024,
1741			1024 * 1024,
1742			100,
1743			dir.path().to_path_buf(),
1744			RateLimitConfig::disabled(),
1745			HopMetrics::disabled(),
1746		)
1747		.unwrap();
1748		assert!(!blob_path.exists());
1749	}
1750
1751	#[test]
1752	fn test_corrupt_meta_cleanup() {
1753		let dir = TempDir::new().unwrap();
1754		{
1755			let _pool = HopDataPool::new(
1756				1024 * 1024,
1757				1024 * 1024,
1758				100,
1759				dir.path().to_path_buf(),
1760				RateLimitConfig::disabled(),
1761				HopMetrics::disabled(),
1762			)
1763			.unwrap();
1764		}
1765
1766		let fake_hash = "bb".to_string() + &"cc".repeat(15);
1767		let meta_path = dir.path().join("meta").join("bb").join(format!("{}.meta", fake_hash));
1768		fs::write(&meta_path, b"not valid SCALE data").unwrap();
1769		assert!(meta_path.exists());
1770
1771		let pool = HopDataPool::new(
1772			1024 * 1024,
1773			1024 * 1024,
1774			100,
1775			dir.path().to_path_buf(),
1776			RateLimitConfig::disabled(),
1777			HopMetrics::disabled(),
1778		)
1779		.unwrap();
1780		assert!(!meta_path.exists());
1781		assert_eq!(pool.status().entry_count, 0);
1782	}
1783
1784	#[test]
1785	fn test_claim_sr25519() {
1786		let (pool, _dir) = create_test_pool();
1787		let pair = sr25519::Pair::from_seed(&[3u8; 32]);
1788		let signer = MultiSigner::Sr25519(pair.public());
1789
1790		let data = vec![10, 20, 30];
1791		let hash = pool
1792			.insert(data.clone(), bv(vec![signer]), SENDER_A, dummy_auth().0, dummy_auth().1, 0)
1793			.unwrap();
1794
1795		let claim = sign_sr(&pair, HOP_CLAIM_CONTEXT, &hash);
1796		let ack = sign_sr(&pair, HOP_ACK_CONTEXT, &hash);
1797		assert_eq!(data, pool.claim(&hash, &claim).unwrap());
1798		pool.ack(&hash, &ack).unwrap();
1799		assert!(!pool.has(&hash));
1800	}
1801
1802	#[test]
1803	fn test_claim_mixed_key_types() {
1804		let (pool, _dir) = create_test_pool();
1805		let ed_pair = ed25519::Pair::from_seed(&[4u8; 32]);
1806		let sr_pair = sr25519::Pair::from_seed(&[5u8; 32]);
1807		let ed_signer = MultiSigner::Ed25519(ed_pair.public());
1808		let sr_signer = MultiSigner::Sr25519(sr_pair.public());
1809
1810		let data = vec![42, 43, 44];
1811		let hash = pool
1812			.insert(
1813				data.clone(),
1814				bv(vec![ed_signer, sr_signer]),
1815				SENDER_A,
1816				dummy_auth().0,
1817				dummy_auth().1,
1818				0,
1819			)
1820			.unwrap();
1821
1822		let sr_claim = sign_sr(&sr_pair, HOP_CLAIM_CONTEXT, &hash);
1823		let sr_ack = sign_sr(&sr_pair, HOP_ACK_CONTEXT, &hash);
1824		assert_eq!(data, pool.claim(&hash, &sr_claim).unwrap());
1825		pool.ack(&hash, &sr_ack).unwrap();
1826		assert!(pool.has(&hash));
1827
1828		let ed_claim = sign_ed(&ed_pair, HOP_CLAIM_CONTEXT, &hash);
1829		let ed_ack = sign_ed(&ed_pair, HOP_ACK_CONTEXT, &hash);
1830		assert_eq!(data, pool.claim(&hash, &ed_claim).unwrap());
1831		pool.ack(&hash, &ed_ack).unwrap();
1832		assert!(!pool.has(&hash));
1833	}
1834
1835	#[test]
1836	fn test_claim_is_repeatable() {
1837		let (pool, _dir) = create_test_pool();
1838		let (pair, signer) = test_recipient();
1839		let data = vec![1, 2, 3, 4, 5];
1840		let hash = pool
1841			.insert(data.clone(), bv(vec![signer]), SENDER_A, dummy_auth().0, dummy_auth().1, 0)
1842			.unwrap();
1843
1844		let claim = sign_ed(&pair, HOP_CLAIM_CONTEXT, &hash);
1845		assert_eq!(data, pool.claim(&hash, &claim).unwrap());
1846		assert_eq!(data, pool.claim(&hash, &claim).unwrap());
1847		assert!(pool.has(&hash));
1848	}
1849
1850	#[test]
1851	fn test_ack_idempotent() {
1852		let (pool, _dir) = create_test_pool();
1853		let (pair, signer) = test_recipient();
1854		let pair2 = ed25519::Pair::from_seed(&[2u8; 32]);
1855		let signer2 = MultiSigner::Ed25519(pair2.public());
1856
1857		let hash = pool
1858			.insert(
1859				vec![1, 2, 3, 4, 5],
1860				bv(vec![signer, signer2]),
1861				SENDER_A,
1862				dummy_auth().0,
1863				dummy_auth().1,
1864				0,
1865			)
1866			.unwrap();
1867		let ack = sign_ed(&pair, HOP_ACK_CONTEXT, &hash);
1868
1869		pool.ack(&hash, &ack).unwrap();
1870		pool.ack(&hash, &ack).unwrap();
1871		assert!(pool.has(&hash));
1872	}
1873
1874	#[test]
1875	fn test_multi_recipient_partial_ack() {
1876		let (pool, _dir) = create_test_pool();
1877		let pair1 = ed25519::Pair::from_seed(&[1u8; 32]);
1878		let pair2 = ed25519::Pair::from_seed(&[2u8; 32]);
1879		let signer1 = MultiSigner::Ed25519(pair1.public());
1880		let signer2 = MultiSigner::Ed25519(pair2.public());
1881
1882		let data = vec![1, 2, 3, 4, 5];
1883		let hash = pool
1884			.insert(
1885				data.clone(),
1886				bv(vec![signer1, signer2]),
1887				SENDER_A,
1888				dummy_auth().0,
1889				dummy_auth().1,
1890				0,
1891			)
1892			.unwrap();
1893
1894		let claim1 = sign_ed(&pair1, HOP_CLAIM_CONTEXT, &hash);
1895		let ack1 = sign_ed(&pair1, HOP_ACK_CONTEXT, &hash);
1896		let claim2 = sign_ed(&pair2, HOP_CLAIM_CONTEXT, &hash);
1897		let ack2 = sign_ed(&pair2, HOP_ACK_CONTEXT, &hash);
1898
1899		assert_eq!(data, pool.claim(&hash, &claim1).unwrap());
1900		pool.ack(&hash, &ack1).unwrap();
1901		assert!(pool.has(&hash));
1902
1903		assert_eq!(data, pool.claim(&hash, &claim2).unwrap());
1904		pool.ack(&hash, &ack2).unwrap();
1905		assert!(!pool.has(&hash));
1906		assert_eq!(pool.status().total_bytes, 0);
1907	}
1908
1909	#[test]
1910	fn test_concurrent_inserts_respect_capacity() {
1911		use std::{sync::Barrier, thread};
1912
1913		let (_, signer) = test_recipient();
1914		// Capacity for exactly 4 entries of 50 bytes (accounted = 90 each).
1915		let (pool, _dir) = make_pool(acct(50, 1) * 4, 100);
1916		let pool = Arc::new(pool);
1917		let barrier = Arc::new(Barrier::new(10));
1918
1919		let handles: Vec<_> = (0..10u8)
1920			.map(|i| {
1921				let pool = pool.clone();
1922				let signer = signer.clone();
1923				let barrier = barrier.clone();
1924				thread::spawn(move || {
1925					barrier.wait();
1926					pool.insert(
1927						vec![i; 50],
1928						bv(vec![signer]),
1929						SENDER_A,
1930						dummy_auth().0,
1931						dummy_auth().1,
1932						0,
1933					)
1934				})
1935			})
1936			.collect();
1937
1938		let results: Vec<_> = handles.into_iter().map(|h| h.join().unwrap()).collect();
1939		let successes = results.iter().filter(|r| r.is_ok()).count();
1940
1941		assert!(successes <= 4, "Got {} successes, max should be 4", successes);
1942		assert!(pool.status().total_bytes <= acct(50, 1) * 4);
1943	}
1944
1945	#[test]
1946	fn test_concurrent_inserts_respect_user_quota() {
1947		use std::{sync::Barrier, thread};
1948
1949		let (_, signer) = test_recipient();
1950		// Per-user cap holds 3 entries of 100 bytes. Pool has plenty of room so the
1951		// *user* cap is what actually constrains the test.
1952		let per_entry = acct(100, 1);
1953		let (pool, _dir) = make_pool_with_user_cap(per_entry * 20, per_entry * 3, 100);
1954		let pool = Arc::new(pool);
1955		let barrier = Arc::new(Barrier::new(10));
1956
1957		let handles: Vec<_> = (0..10u8)
1958			.map(|i| {
1959				let pool = pool.clone();
1960				let signer = signer.clone();
1961				let barrier = barrier.clone();
1962				thread::spawn(move || {
1963					barrier.wait();
1964					pool.insert(
1965						vec![i; 100],
1966						bv(vec![signer]),
1967						SENDER_A,
1968						dummy_auth().0,
1969						dummy_auth().1,
1970						0,
1971					)
1972				})
1973			})
1974			.collect();
1975
1976		let results: Vec<_> = handles.into_iter().map(|h| h.join().unwrap()).collect();
1977		let successes = results.iter().filter(|r| r.is_ok()).count();
1978
1979		// Hard per-user cap: at most 3 inserts may succeed regardless of concurrency.
1980		assert!(successes <= 3, "hard per-user cap violated: {} successes", successes);
1981		assert!(user_usage(&pool, &SENDER_A) <= per_entry * 3);
1982	}
1983
1984	#[test]
1985	fn test_concurrent_claim_and_ack() {
1986		use std::{sync::Barrier, thread};
1987
1988		let (pool, _dir) = create_test_pool();
1989		let pool = Arc::new(pool);
1990
1991		let pairs: Vec<_> = (1..=5u8)
1992			.map(|i| {
1993				let pair = ed25519::Pair::from_seed(&[i; 32]);
1994				let signer = MultiSigner::Ed25519(pair.public());
1995				(pair, signer)
1996			})
1997			.collect();
1998
1999		let signers: Vec<_> = pairs.iter().map(|(_, s)| s.clone()).collect();
2000		let data = vec![42u8; 100];
2001		let hash = pool
2002			.insert(data.clone(), bv(signers), SENDER_A, dummy_auth().0, dummy_auth().1, 0)
2003			.unwrap();
2004
2005		let barrier = Arc::new(Barrier::new(5));
2006
2007		let handles: Vec<_> = pairs
2008			.into_iter()
2009			.map(|(pair, _)| {
2010				let pool = pool.clone();
2011				let barrier = barrier.clone();
2012				let data = data.clone();
2013				thread::spawn(move || {
2014					barrier.wait();
2015					let claim = sign_ed(&pair, HOP_CLAIM_CONTEXT, &hash);
2016					let ack = sign_ed(&pair, HOP_ACK_CONTEXT, &hash);
2017
2018					let claimed = pool.claim(&hash, &claim).unwrap();
2019					assert_eq!(data, claimed);
2020					pool.ack(&hash, &ack).unwrap();
2021				})
2022			})
2023			.collect();
2024
2025		for h in handles {
2026			h.join().unwrap();
2027		}
2028
2029		assert!(!pool.has(&hash));
2030		assert_eq!(pool.status().total_bytes, 0);
2031	}
2032
2033	#[test]
2034	fn test_concurrent_duplicate_insert_preserves_files() {
2035		use std::{sync::Barrier, thread};
2036
2037		// Two threads insert identical content concurrently. The race-loser must
2038		// not delete the winner's blob/meta files; the winning hash must remain
2039		// readable via claim().
2040		let (kp, signer) = test_recipient();
2041		let (pool, _dir) = make_pool(1024 * 1024, 100);
2042		let pool = Arc::new(pool);
2043		let data = vec![0xABu8; 4096];
2044		let barrier = Arc::new(Barrier::new(2));
2045
2046		let handles: Vec<_> = (0..2)
2047			.map(|_| {
2048				let pool = pool.clone();
2049				let barrier = barrier.clone();
2050				let signer = signer.clone();
2051				let data = data.clone();
2052				thread::spawn(move || {
2053					barrier.wait();
2054					pool.insert(data, bv(vec![signer]), SENDER_A, dummy_auth().0, dummy_auth().1, 0)
2055				})
2056			})
2057			.collect();
2058		let results: Vec<_> = handles.into_iter().map(|h| h.join().unwrap()).collect();
2059
2060		let oks: Vec<_> = results.iter().filter_map(|r| r.as_ref().ok()).collect();
2061		let dupes = results.iter().filter(|r| matches!(r, Err(HopError::DuplicateEntry))).count();
2062		assert_eq!(oks.len(), 1, "exactly one insert must win the race");
2063		assert_eq!(dupes, 1, "the other must report DuplicateEntry");
2064
2065		let hash = *oks[0];
2066		let sig = sign_ed(&kp, HOP_CLAIM_CONTEXT, &hash);
2067		let claimed = pool.claim(&hash, &sig).expect("claim must succeed");
2068		assert_eq!(claimed, data);
2069	}
2070
2071	#[test]
2072	fn test_concurrent_duplicate_insert_keeps_winner_meta_on_disk() {
2073		use std::{sync::Barrier, thread};
2074
2075		// Same content, different senders. The race-loser's meta must not end
2076		// up on disk; otherwise restart recovery would silently load it as
2077		// canonical for the entry.
2078		let dir = TempDir::new().unwrap();
2079		let pool = Arc::new(
2080			HopDataPool::new(
2081				1024 * 1024,
2082				1024 * 1024,
2083				100,
2084				dir.path().to_path_buf(),
2085				RateLimitConfig::disabled(),
2086				HopMetrics::disabled(),
2087			)
2088			.unwrap(),
2089		);
2090
2091		let signer_a = MultiSigner::Ed25519(ed25519::Pair::from_seed(&[11u8; 32]).public());
2092		let signer_b = MultiSigner::Ed25519(ed25519::Pair::from_seed(&[22u8; 32]).public());
2093		let data = vec![0xCDu8; 4096];
2094
2095		let barrier = Arc::new(Barrier::new(2));
2096		let (p1, d1, b1, s1) = (pool.clone(), data.clone(), barrier.clone(), signer_a.clone());
2097		let h1 = thread::spawn(move || {
2098			b1.wait();
2099			p1.insert(d1, bv(vec![s1]), SENDER_A, dummy_auth().0, dummy_auth().1, 0)
2100		});
2101		let (p2, d2, b2, s2) = (pool.clone(), data.clone(), barrier.clone(), signer_b.clone());
2102		let h2 = thread::spawn(move || {
2103			b2.wait();
2104			p2.insert(d2, bv(vec![s2]), SENDER_B, dummy_auth().0, dummy_auth().1, 0)
2105		});
2106
2107		let r1 = h1.join().unwrap();
2108		let r2 = h2.join().unwrap();
2109
2110		let (winner_hash, winner_sender) = match (&r1, &r2) {
2111			(Ok(h), Err(HopError::DuplicateEntry)) => (*h, SENDER_A),
2112			(Err(HopError::DuplicateEntry), Ok(h)) => (*h, SENDER_B),
2113			other => panic!("expected exactly one winner and one DuplicateEntry, got {other:?}"),
2114		};
2115
2116		// Simulate restart: drop the pool, reopen from the same data dir so
2117		// recovery rebuilds the in-memory index from `.meta` files on disk.
2118		drop(pool);
2119		let pool2 = HopDataPool::new(
2120			1024 * 1024,
2121			1024 * 1024,
2122			100,
2123			dir.path().to_path_buf(),
2124			RateLimitConfig::disabled(),
2125			HopMetrics::disabled(),
2126		)
2127		.unwrap();
2128
2129		let recovered_sender = pool2
2130			.index
2131			.lock()
2132			.get(&winner_hash)
2133			.expect("winner's entry must survive restart")
2134			.sender_id;
2135		assert_eq!(
2136			recovered_sender, winner_sender,
2137			"on-disk meta diverged from the winning insert; loser's meta overwrote the winner's",
2138		);
2139	}
2140
2141	#[test]
2142	fn test_saturating_release_concurrent_no_underflow() {
2143		use std::{sync::Barrier, thread};
2144
2145		// Many threads each release a fixed amount that sums to exactly the
2146		// initial counter. With a non-atomic load-then-clamp-then-fetch_sub,
2147		// stale clamps would let the counter wrap to ~u64::MAX.
2148		// `saturating_release` must keep the result clamped at 0.
2149		const THREADS: u64 = 32;
2150		const RELEASE_PER_THREAD: u64 = 7;
2151		let counter = Arc::new(AtomicU64::new(THREADS * RELEASE_PER_THREAD));
2152		let barrier = Arc::new(Barrier::new(THREADS as usize));
2153
2154		let handles: Vec<_> = (0..THREADS)
2155			.map(|_| {
2156				let counter = counter.clone();
2157				let barrier = barrier.clone();
2158				thread::spawn(move || {
2159					barrier.wait();
2160					saturating_release(&counter, RELEASE_PER_THREAD);
2161				})
2162			})
2163			.collect();
2164		for h in handles {
2165			h.join().unwrap();
2166		}
2167
2168		assert_eq!(counter.load(Ordering::Relaxed), 0, "counter underflowed or did not reach zero");
2169
2170		// Releasing more than the remaining balance must clamp to 0, never wrap.
2171		saturating_release(&counter, u64::MAX);
2172		assert_eq!(counter.load(Ordering::Relaxed), 0);
2173	}
2174
2175	#[test]
2176	fn test_get_promotable_within_buffer() {
2177		// retention=3600s; a freshly-inserted entry is in the promotion window only
2178		// if the buffer is at least as large as the time-to-expiry.
2179		let (pool, _dir) = make_pool(1024 * 1024, 3600);
2180		let (_, signer) = test_recipient();
2181
2182		let hash = pool
2183			.insert(vec![1, 2, 3], bv(vec![signer]), SENDER_A, dummy_auth().0, dummy_auth().1, 0)
2184			.unwrap();
2185
2186		// Small buffer (180s ≪ 3600s retention): not promotable yet.
2187		let promotable = pool.get_promotable(50, 180, usize::MAX);
2188		assert!(promotable.is_empty());
2189
2190		// Large buffer (6000s > 3600s retention): within the window.
2191		let promotable = pool.get_promotable(0, 6000, usize::MAX);
2192		assert_eq!(promotable.len(), 1);
2193		assert_eq!(promotable[0], hash);
2194	}
2195
2196	#[test]
2197	fn test_get_promotable_excludes_promoted() {
2198		let (pool, _dir) = make_pool(1024 * 1024, 100);
2199		let (_, signer) = test_recipient();
2200
2201		let hash = pool
2202			.insert(vec![1, 2, 3], bv(vec![signer]), SENDER_A, dummy_auth().0, dummy_auth().1, 0)
2203			.unwrap();
2204
2205		let promotable = pool.get_promotable(80, 180, usize::MAX);
2206		assert_eq!(promotable.len(), 1);
2207
2208		pool.mark_promoted(&hash);
2209
2210		let promotable = pool.get_promotable(80, 180, usize::MAX);
2211		assert!(promotable.is_empty());
2212	}
2213
2214	#[test]
2215	fn test_mark_promoted_persists_across_restart() {
2216		let dir = TempDir::new().unwrap();
2217		let (_, signer) = test_recipient();
2218
2219		let hash;
2220		{
2221			let pool = HopDataPool::new(
2222				1024 * 1024,
2223				1024 * 1024,
2224				100,
2225				dir.path().to_path_buf(),
2226				RateLimitConfig::disabled(),
2227				HopMetrics::disabled(),
2228			)
2229			.unwrap();
2230			hash = pool
2231				.insert(
2232					vec![42u8; 10],
2233					bv(vec![signer]),
2234					SENDER_A,
2235					dummy_auth().0,
2236					dummy_auth().1,
2237					0,
2238				)
2239				.unwrap();
2240			pool.mark_promoted(&hash);
2241		}
2242
2243		{
2244			let pool = HopDataPool::new(
2245				1024 * 1024,
2246				1024 * 1024,
2247				100,
2248				dir.path().to_path_buf(),
2249				RateLimitConfig::disabled(),
2250				HopMetrics::disabled(),
2251			)
2252			.unwrap();
2253			let promotable = pool.get_promotable(80, 180, usize::MAX);
2254			assert!(promotable.is_empty(), "promoted entry should not be promotable after restart");
2255			assert!(pool.has(&hash), "entry should still exist");
2256		}
2257	}
2258
2259	#[test]
2260	fn test_cleanup_expired_removes_promoted() {
2261		let (pool, _dir) = make_pool(1024 * 1024, 0);
2262		let (_, signer) = test_recipient();
2263
2264		let hash = pool
2265			.insert(vec![1, 2, 3], bv(vec![signer]), SENDER_A, dummy_auth().0, dummy_auth().1, 0)
2266			.unwrap();
2267		pool.mark_promoted(&hash);
2268		assert!(pool.has(&hash));
2269
2270		let freed = pool.cleanup_expired(0);
2271		assert!(freed > 0);
2272		assert!(!pool.has(&hash));
2273	}
2274
2275	#[test]
2276	fn test_rate_limit_rejects_burst_overflow() {
2277		let dir = TempDir::new().unwrap();
2278		// submit_burst=2 so the 3rd request is rate-limited by submit count.
2279		// Bandwidth is sized comfortably above the 3-byte test payloads so the
2280		// rejection comes from the request bucket, not the bandwidth bucket.
2281		let cfg = RateLimitConfig {
2282			enabled: true,
2283			submit_rate_per_min: 60,
2284			submit_burst: 2,
2285			bandwidth_per_min: 1024 * 1024 * 60,
2286			bandwidth_burst: 1024 * 1024,
2287		};
2288		let pool = HopDataPool::new(
2289			1024 * 1024,
2290			1024 * 1024,
2291			100,
2292			dir.path().to_path_buf(),
2293			cfg,
2294			HopMetrics::disabled(),
2295		)
2296		.unwrap();
2297		let (_, signer) = test_recipient();
2298
2299		pool.insert(
2300			vec![1, 2, 3],
2301			bv(vec![signer.clone()]),
2302			SENDER_A,
2303			dummy_auth().0,
2304			dummy_auth().1,
2305			0,
2306		)
2307		.unwrap();
2308		pool.insert(
2309			vec![4, 5, 6],
2310			bv(vec![signer.clone()]),
2311			SENDER_A,
2312			dummy_auth().0,
2313			dummy_auth().1,
2314			0,
2315		)
2316		.unwrap();
2317		assert!(matches!(
2318			pool.insert(
2319				vec![7, 8, 9],
2320				bv(vec![signer]),
2321				SENDER_A,
2322				dummy_auth().0,
2323				dummy_auth().1,
2324				0,
2325			),
2326			Err(HopError::RateLimited { .. })
2327		));
2328	}
2329
2330	#[test]
2331	fn test_meta_version_mismatch_rejected() {
2332		// Persist a HopEntryMeta with version 0 (an older / future schema), then
2333		// boot a fresh pool over the same dir and assert the .meta is wiped and
2334		// not surfaced in the in-memory index.
2335		let dir = TempDir::new().unwrap();
2336		let (_, signer) = test_recipient();
2337		let recipients = bv(vec![signer.clone()]);
2338		let mut meta =
2339			HopEntryMeta::new(100, 0, recipients, SENDER_A, dummy_auth().0, dummy_auth().1, 0);
2340		meta.version = 0;
2341
2342		let fake_hash = "ee".to_string() + &"ff".repeat(15);
2343		let meta_dir = dir.path().join("meta").join("ee");
2344		let blob_dir = dir.path().join("blobs").join("ee");
2345		fs::create_dir_all(&meta_dir).unwrap();
2346		fs::create_dir_all(&blob_dir).unwrap();
2347		let meta_path = meta_dir.join(format!("{}.meta", fake_hash));
2348		let blob_path = blob_dir.join(format!("{}.blob", fake_hash));
2349		fs::write(&meta_path, meta.encode()).unwrap();
2350		fs::write(&blob_path, b"x").unwrap();
2351
2352		let registry = prometheus_endpoint::Registry::new();
2353		let pool = HopDataPool::new(
2354			1024 * 1024,
2355			1024 * 1024,
2356			100,
2357			dir.path().to_path_buf(),
2358			RateLimitConfig::disabled(),
2359			HopMetrics::new(Some(&registry)).unwrap(),
2360		)
2361		.unwrap();
2362		assert!(!meta_path.exists(), "stale-version .meta should be removed");
2363		assert!(!blob_path.exists(), "matching .blob should also be removed");
2364		assert_eq!(pool.status().entry_count, 0);
2365		assert_eq!(pool.metrics().removed_count(removal_reasons::STARTUP_DROPPED), 1);
2366	}
2367
2368	#[test]
2369	fn test_promotion_backoff_skips_until_due_then_gives_up() {
2370		use crate::types::MAX_PROMOTION_ATTEMPTS;
2371
2372		let (pool, _dir) = make_pool(1024 * 1024, /* retention = */ 100);
2373		let (_, signer) = test_recipient();
2374		let hash = pool
2375			.insert(vec![1u8; 100], bv(vec![signer]), SENDER_A, dummy_auth().0, dummy_auth().1, 0)
2376			.unwrap();
2377
2378		// Inside the buffer window (>= retention=100s) so the entry is promotable
2379		// in principle.
2380		let buffer = 300_u64;
2381		let current = 60;
2382		assert_eq!(pool.get_promotable(current, buffer, 10), vec![hash]);
2383
2384		// First failure schedules next attempt at current + 1× check_interval_blocks.
2385		let check_interval_blocks: u32 = 10;
2386		pool.record_promotion_attempt(&hash, current, check_interval_blocks);
2387		assert!(
2388			pool.get_promotable(current, buffer, 10).is_empty(),
2389			"entry should be skipped until back-off elapses"
2390		);
2391		assert_eq!(pool.get_promotable(current + 10, buffer, 10), vec![hash]);
2392
2393		// Burn through the remaining attempts; once at MAX, the entry stays out
2394		// of the promotable set forever (regardless of how far we advance time).
2395		// Schedule after first failure: 1×, 2×, 4×, 8×, 16× check_interval.
2396		let mut now = current + 10;
2397		for next_attempt in 2..=MAX_PROMOTION_ATTEMPTS {
2398			pool.record_promotion_attempt(&hash, now, check_interval_blocks);
2399			let shift = (next_attempt - 1).min(5);
2400			let backoff = check_interval_blocks << shift;
2401			now += backoff;
2402		}
2403		assert!(
2404			pool.get_promotable(now + 10_000, buffer, 10).is_empty(),
2405			"entry should give up after MAX_PROMOTION_ATTEMPTS"
2406		);
2407	}
2408}