1use crate::{pool::HopDataPool, runtime_api};
30use sp_api::{ApiExt, CallApiAt, ProvideRuntimeApi};
31use sp_blockchain::HeaderBackend;
32use sp_runtime::{
33 traits::Block as BlockT, AccountId32, MultiSignature, MultiSigner, SaturatedConversion,
34};
35use std::{marker::PhantomData, sync::Arc, time::Duration};
36
37pub trait HopPromoter: Send + Sync + 'static {
43 fn promote(
50 &self,
51 data: Vec<u8>,
52 signer: MultiSigner,
53 signature: MultiSignature,
54 submit_timestamp: u64,
55 ) -> Result<(), Box<dyn std::error::Error + Send + Sync>>;
56
57 fn is_promoted_on_chain(
62 &self,
63 hash: &[u8; 32],
64 ) -> Result<bool, Box<dyn std::error::Error + Send + Sync>>;
65}
66
67pub struct RuntimeApiPromoter<Block: BlockT, C, P> {
71 client: Arc<C>,
72 tx_pool: Arc<P>,
73 _phantom: PhantomData<Block>,
74}
75
76impl<Block, C, P> RuntimeApiPromoter<Block, C, P>
77where
78 Block: BlockT,
79 C: HeaderBackend<Block> + CallApiAt<Block> + Send + Sync + 'static,
80 P: sc_transaction_pool_api::LocalTransactionPool<Block = Block> + 'static,
81{
82 pub fn new(client: Arc<C>, tx_pool: Arc<P>) -> Self {
84 Self { client, tx_pool, _phantom: PhantomData }
85 }
86}
87
88impl<Block, C, P> HopPromoter for RuntimeApiPromoter<Block, C, P>
89where
90 Block: BlockT,
91 C: HeaderBackend<Block> + CallApiAt<Block> + Send + Sync + 'static,
92 P: sc_transaction_pool_api::LocalTransactionPool<Block = Block> + 'static,
93{
94 fn promote(
95 &self,
96 data: Vec<u8>,
97 signer: MultiSigner,
98 signature: MultiSignature,
99 submit_timestamp: u64,
100 ) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
101 let best_hash = self.client.info().best_hash;
102 let ext = runtime_api::create_promotion_extrinsic::<Block, _>(
103 &*self.client,
104 best_hash,
105 data,
106 signer,
107 signature,
108 submit_timestamp,
109 )?;
110 self.tx_pool
111 .submit_local(best_hash, ext)
112 .map_err(|e| format!("submit_local failed: {:?}", e))?;
113 Ok(())
114 }
115
116 fn is_promoted_on_chain(
117 &self,
118 hash: &[u8; 32],
119 ) -> Result<bool, Box<dyn std::error::Error + Send + Sync>> {
120 let best_hash = self.client.info().best_hash;
121 Ok(runtime_api::is_promoted_on_chain::<Block, _>(&*self.client, best_hash, *hash)?)
122 }
123}
124
125pub fn try_build_promoter<Block, C, P>(
131 client: &Arc<C>,
132 tx_pool: &Arc<P>,
133) -> Option<Arc<dyn HopPromoter>>
134where
135 Block: BlockT,
136 C: HeaderBackend<Block> + ProvideRuntimeApi<Block> + CallApiAt<Block> + Send + Sync + 'static,
137 P: sc_transaction_pool_api::LocalTransactionPool<Block = Block> + 'static,
138{
139 let best_hash = client.info().best_hash;
140 match client
141 .runtime_api()
142 .has_api_with::<dyn sp_hop::HopRuntimeApi<Block, AccountId32>, _>(best_hash, |v| v >= 1)
143 {
144 Ok(true) => {
145 tracing::info!(target: "hop", "HopRuntimeApi detected — promotion enabled");
146 Some(Arc::new(RuntimeApiPromoter::new(client.clone(), tx_pool.clone())))
147 },
148 Ok(false) => {
149 tracing::warn!(
150 target: "hop",
151 "HOP enabled but runtime does not support HopRuntimeApi — running cleanup only"
152 );
153 None
154 },
155 Err(e) => {
156 tracing::warn!(
157 target: "hop",
158 error = %e,
159 "Failed to check HopRuntimeApi support — running cleanup only"
160 );
161 None
162 },
163 }
164}
165
166pub fn build_maintenance_task<Block, C, P>(
172 client: &Arc<C>,
173 tx_pool: &Arc<P>,
174 pool: Arc<HopDataPool>,
175 buffer_secs: u64,
176 check_interval_secs: u64,
177) -> HopMaintenanceTask
178where
179 Block: BlockT,
180 C: HeaderBackend<Block> + ProvideRuntimeApi<Block> + CallApiAt<Block> + Send + Sync + 'static,
181 P: sc_transaction_pool_api::LocalTransactionPool<Block = Block> + 'static,
182{
183 let promoter = try_build_promoter::<Block, _, _>(client, tx_pool);
184 let best_block_client = client.clone();
185 let best_block: Arc<dyn Fn() -> u32 + Send + Sync> =
186 Arc::new(move || best_block_client.info().best_number.saturated_into::<u32>());
187 HopMaintenanceTask::new(pool, promoter, best_block, buffer_secs, check_interval_secs)
188}
189
190pub struct HopMaintenanceTask {
193 hop_pool: Arc<HopDataPool>,
194 promoter: Option<Arc<dyn HopPromoter>>,
195 buffer_secs: u64,
196 check_interval_secs: u64,
197 check_interval_blocks: u32,
198 best_block: Arc<dyn Fn() -> u32 + Send + Sync>,
199}
200
201impl HopMaintenanceTask {
202 pub fn new(
209 hop_pool: Arc<HopDataPool>,
210 promoter: Option<Arc<dyn HopPromoter>>,
211 best_block: Arc<dyn Fn() -> u32 + Send + Sync>,
212 buffer_secs: u64,
213 check_interval_secs: u64,
214 ) -> Self {
215 let check_interval_blocks =
216 (check_interval_secs.max(1) / crate::types::HOP_BLOCK_TIME_SECS.max(1)).max(1) as u32;
217 Self {
218 hop_pool,
219 promoter,
220 buffer_secs,
221 check_interval_secs,
222 check_interval_blocks,
223 best_block,
224 }
225 }
226
227 pub async fn run(self) {
229 loop {
230 futures_timer::Delay::new(Duration::from_secs(self.check_interval_secs)).await;
231 self.tick();
232 }
233 }
234
235 pub fn tick(&self) {
237 let current_block = (self.best_block)();
238
239 if let Some(ref promoter) = self.promoter {
241 const PROMOTION_BATCH_SIZE: usize = 10;
242 let hashes =
243 self.hop_pool
244 .get_promotable(current_block, self.buffer_secs, PROMOTION_BATCH_SIZE);
245 for hash in hashes {
246 match promoter.is_promoted_on_chain(hash.as_fixed_bytes()) {
250 Ok(true) => {
251 self.hop_pool.mark_promoted(&hash);
252 tracing::info!(
253 target: "hop",
254 hash = ?hex::encode(hash),
255 "HOP entry already on-chain — flagged locally"
256 );
257 continue;
258 },
259 Ok(false) => {},
260 Err(e) => {
261 tracing::warn!(
265 target: "hop",
266 hash = ?hex::encode(hash),
267 error = %e,
268 "is_promoted_on_chain failed; assuming not on-chain"
269 );
270 },
271 }
272
273 let (data, signer, signature, submit_timestamp) =
274 match self.hop_pool.get_with_auth(&hash) {
275 Some(t) => t,
276 None => continue,
277 };
278 let size = data.len();
279 let result = promoter.promote(data, signer, signature, submit_timestamp);
280 self.hop_pool.record_promotion_attempt(
286 &hash,
287 current_block,
288 self.check_interval_blocks,
289 );
290 match result {
291 Ok(()) => tracing::info!(
292 target: "hop",
293 hash = ?hex::encode(hash),
294 size,
295 "Submitted HOP promotion extrinsic; awaiting on-chain confirmation"
296 ),
297 Err(e) => tracing::warn!(
298 target: "hop",
299 hash = ?hex::encode(hash),
300 error = %e,
301 "Failed to submit HOP promotion extrinsic; will back off"
302 ),
303 }
304 }
305 }
306
307 let freed = self.hop_pool.cleanup_expired(self.buffer_secs);
308 if freed > 0 {
309 tracing::info!(
310 target: "hop",
311 freed_bytes = freed,
312 "Cleaned up expired HOP entries"
313 );
314 }
315
316 self.hop_pool.metrics().record_maintenance_tick();
317 }
318}
319
320#[cfg(test)]
321mod tests {
322 use super::*;
323 use crate::{
324 pool::HopDataPool,
325 rate_limit::RateLimitConfig,
326 types::{Recipient, RecipientVec, SenderId},
327 };
328 use sp_core::{crypto::Pair, ed25519};
329 use sp_runtime::{MultiSignature, MultiSigner};
330 use std::sync::Mutex;
331 use tempfile::TempDir;
332
333 const SENDER_A: SenderId = [1u8; 32];
334
335 fn test_recipient() -> (ed25519::Pair, MultiSigner) {
336 let pair = ed25519::Pair::from_seed(&[1u8; 32]);
337 let signer = MultiSigner::Ed25519(pair.public());
338 (pair, signer)
339 }
340
341 fn dummy_auth() -> (MultiSigner, MultiSignature) {
342 let pair = ed25519::Pair::from_seed(&[7u8; 32]);
343 let signer = MultiSigner::Ed25519(pair.public());
344 let sig = MultiSignature::Ed25519(pair.sign(&[]));
345 (signer, sig)
346 }
347
348 fn bv(v: Vec<MultiSigner>) -> RecipientVec {
349 let recipients: Vec<Recipient> =
350 v.into_iter().map(|signer| Recipient { signer, claimed: false }).collect();
351 RecipientVec::try_from(recipients).expect("test recipient list exceeds MAX_RECIPIENTS")
352 }
353
354 fn test_pool(max_size: u64, retention_secs: u64, dir: &TempDir) -> Arc<HopDataPool> {
355 Arc::new(
356 HopDataPool::new(
357 max_size,
358 max_size,
359 retention_secs,
360 dir.path().to_path_buf(),
361 RateLimitConfig::disabled(),
362 crate::metrics::HopMetrics::disabled(),
363 )
364 .unwrap(),
365 )
366 }
367
368 struct MockPromoter {
369 calls: Mutex<Vec<Vec<u8>>>,
370 should_fail: bool,
371 on_chain: Mutex<std::collections::HashSet<[u8; 32]>>,
373 }
374
375 impl MockPromoter {
376 fn new(should_fail: bool) -> Self {
377 Self {
378 calls: Mutex::new(Vec::new()),
379 should_fail,
380 on_chain: Mutex::new(std::collections::HashSet::new()),
381 }
382 }
383
384 fn call_count(&self) -> usize {
385 self.calls.lock().unwrap().len()
386 }
387
388 fn calls(&self) -> Vec<Vec<u8>> {
389 self.calls.lock().unwrap().clone()
390 }
391
392 fn set_on_chain(&self, hash: [u8; 32]) {
394 self.on_chain.lock().unwrap().insert(hash);
395 }
396 }
397
398 impl HopPromoter for MockPromoter {
399 fn promote(
400 &self,
401 data: Vec<u8>,
402 _signer: MultiSigner,
403 _signature: MultiSignature,
404 _submit_timestamp: u64,
405 ) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
406 self.calls.lock().unwrap().push(data);
407 if self.should_fail {
408 Err("mock failure".into())
409 } else {
410 Ok(())
411 }
412 }
413
414 fn is_promoted_on_chain(
415 &self,
416 hash: &[u8; 32],
417 ) -> Result<bool, Box<dyn std::error::Error + Send + Sync>> {
418 Ok(self.on_chain.lock().unwrap().contains(hash))
419 }
420 }
421
422 #[test]
423 fn tick_promotes_near_expiry_entries() {
424 let dir = TempDir::new().unwrap();
425 let pool = test_pool(1024 * 1024, 100, &dir);
426 let (_, signer) = test_recipient();
427
428 let hash = pool
429 .insert(vec![42u8; 10], bv(vec![signer]), SENDER_A, dummy_auth().0, dummy_auth().1, 0)
430 .unwrap();
431
432 let promoter = Arc::new(MockPromoter::new(false));
433 let task = HopMaintenanceTask::new(
434 pool.clone(),
435 Some(promoter.clone()),
436 Arc::new(|| 80), 180, 60,
439 );
440
441 task.tick();
442
443 assert_eq!(promoter.call_count(), 1);
444 assert_eq!(promoter.calls()[0], vec![42u8; 10]);
445
446 assert!(pool.has(&hash));
449 let promotable = pool.get_promotable(80, 180, usize::MAX);
450 assert!(promotable.is_empty(), "back-off should suppress immediate re-promotion");
451 }
452
453 #[test]
454 fn tick_skips_promotion_when_no_promoter() {
455 let dir = TempDir::new().unwrap();
456 let pool = test_pool(1024 * 1024, 100, &dir);
457 let (_, signer) = test_recipient();
458
459 pool.insert(vec![42u8; 10], bv(vec![signer]), SENDER_A, dummy_auth().0, dummy_auth().1, 0)
460 .unwrap();
461
462 let task = HopMaintenanceTask::new(
463 pool.clone(),
464 None, Arc::new(|| 80),
466 180,
467 60,
468 );
469
470 task.tick();
471
472 let promotable = pool.get_promotable(80, 180, usize::MAX);
474 assert_eq!(promotable.len(), 1);
475 }
476
477 #[test]
478 fn tick_does_not_mark_promoted_on_failure() {
479 let dir = TempDir::new().unwrap();
480 let pool = test_pool(1024 * 1024, 100, &dir);
481 let (_, signer) = test_recipient();
482
483 pool.insert(vec![42u8; 10], bv(vec![signer]), SENDER_A, dummy_auth().0, dummy_auth().1, 0)
484 .unwrap();
485
486 let promoter = Arc::new(MockPromoter::new(true)); let task =
488 HopMaintenanceTask::new(pool.clone(), Some(promoter.clone()), Arc::new(|| 80), 180, 60);
489
490 task.tick();
491
492 assert_eq!(promoter.call_count(), 1);
494
495 assert!(pool.get_promotable(80, 180, usize::MAX).is_empty());
500 assert_eq!(pool.get_promotable(95, 180, usize::MAX).len(), 1);
501 }
502
503 #[test]
504 fn tick_cleans_up_expired_entries() {
505 let dir = TempDir::new().unwrap();
506 let pool = test_pool(1024 * 1024, 0, &dir);
508 let (_, signer) = test_recipient();
509
510 pool.insert(vec![42u8; 50], bv(vec![signer]), SENDER_A, dummy_auth().0, dummy_auth().1, 0)
511 .unwrap();
512 assert_eq!(pool.status().entry_count, 1);
513
514 let task = HopMaintenanceTask::new(pool.clone(), None, Arc::new(|| 0), 5, 60);
515
516 task.tick();
517
518 assert_eq!(pool.status().entry_count, 0);
519 assert_eq!(pool.status().total_bytes, 0);
520 }
521
522 #[test]
523 fn tick_promotes_then_cleans_up_independently() {
524 let dir = TempDir::new().unwrap();
525 let pool = test_pool(1024 * 1024, 100, &dir);
526 let (_, signer) = test_recipient();
527
528 let hash = pool
529 .insert(
530 vec![1u8; 10],
531 bv(vec![signer.clone()]),
532 SENDER_A,
533 dummy_auth().0,
534 dummy_auth().1,
535 0,
536 )
537 .unwrap();
538
539 let promoter = Arc::new(MockPromoter::new(false));
540
541 let block = Arc::new(Mutex::new(80u32));
544 let block_clone = block.clone();
545 let task = HopMaintenanceTask::new(
546 pool.clone(),
547 Some(promoter.clone()),
548 Arc::new(move || *block_clone.lock().unwrap()),
549 180,
550 60,
551 );
552
553 task.tick();
554 assert_eq!(promoter.call_count(), 1);
555 assert_eq!(pool.status().entry_count, 1);
556
557 promoter.set_on_chain(*hash.as_fixed_bytes());
560
561 *block.lock().unwrap() = 100;
564 task.tick();
565 assert_eq!(promoter.call_count(), 1, "promoter must not be called again once on-chain");
566 }
567
568 #[test]
569 fn tick_skips_promotion_when_already_on_chain() {
570 let dir = TempDir::new().unwrap();
571 let pool = test_pool(1024 * 1024, 100, &dir);
572 let (_, signer) = test_recipient();
573
574 let hash = pool
575 .insert(vec![42u8; 10], bv(vec![signer]), SENDER_A, dummy_auth().0, dummy_auth().1, 0)
576 .unwrap();
577
578 let promoter = Arc::new(MockPromoter::new(false));
579 promoter.set_on_chain(*hash.as_fixed_bytes());
581
582 let task =
583 HopMaintenanceTask::new(pool.clone(), Some(promoter.clone()), Arc::new(|| 80), 180, 60);
584
585 task.tick();
586
587 assert_eq!(promoter.call_count(), 0, "promote must not be called when already on-chain");
588 assert!(pool.get_promotable(80, 180, usize::MAX).is_empty());
589 }
590
591 #[test]
592 fn tick_retries_unconfirmed_with_backoff() {
593 let dir = TempDir::new().unwrap();
594 let pool = test_pool(1024 * 1024, 100, &dir);
595 let (_, signer) = test_recipient();
596
597 pool.insert(vec![42u8; 10], bv(vec![signer]), SENDER_A, dummy_auth().0, dummy_auth().1, 0)
598 .unwrap();
599
600 let promoter = Arc::new(MockPromoter::new(false));
601 let block = Arc::new(Mutex::new(80u32));
602 let block_clone = block.clone();
603 let task = HopMaintenanceTask::new(
606 pool.clone(),
607 Some(promoter.clone()),
608 Arc::new(move || *block_clone.lock().unwrap()),
609 180,
610 60,
611 );
612
613 task.tick();
615 assert_eq!(promoter.call_count(), 1);
616
617 *block.lock().unwrap() = 85;
619 task.tick();
620 assert_eq!(promoter.call_count(), 1);
621
622 *block.lock().unwrap() = 90;
625 task.tick();
626 assert_eq!(promoter.call_count(), 2);
627
628 *block.lock().unwrap() = 109;
630 task.tick();
631 assert_eq!(promoter.call_count(), 2);
632 }
633}