Skip to main content

bsv_overlay_engine/
storage.rs

1//! Storage trait for the Overlay Services Engine.
2//!
3//! Defines the async storage interface that backends (D1, SQLite, in-memory) must implement.
4//! The Engine never talks to a database directly — everything goes through this trait.
5//!
6//! Ported from `~/bsv/overlay-services/src/storage/Storage.ts`.
7//! Reference implementation: `~/bsv/overlay-services/src/storage/knex/KnexStorage.ts`.
8
9#[cfg(feature = "memory-storage")]
10use crate::beef_limits;
11
12use async_trait::async_trait;
13
14use crate::types::{AppliedTransaction, Outpoint, Output};
15
16/// A stored transaction's txid plus its serialized BEEF bytes.
17///
18/// Returned by [`Storage::find_transactions_for_proof_check`] so the engine can
19/// parse the BEEF and decide whether the target tx still needs a merkle proof.
20#[derive(Debug, Clone)]
21pub struct TransactionBeef {
22    /// The transaction id (hex, lowercase).
23    pub txid: String,
24    /// The serialized BEEF bytes for this transaction.
25    pub beef: Vec<u8>,
26}
27
28/// Overlay Services storage backend.
29///
30/// All methods are async. Uses `?Send` futures for wasm32 compatibility
31/// (Cloudflare Workers D1).
32#[async_trait(?Send)]
33pub trait Storage {
34    // ========================================================================
35    // Write operations
36    // ========================================================================
37
38    /// Insert a new output into storage.
39    ///
40    /// If an output with the same (txid, outputIndex, topic) already exists, this is a no-op.
41    /// If `output.beef` is Some, also upsert the BEEF into the transactions table
42    /// (deduplicated by txid).
43    async fn insert_output(&self, output: &Output) -> Result<(), StorageError>;
44
45    /// Delete an output from storage.
46    ///
47    /// After deletion, if no other outputs reference the same txid, also delete
48    /// the transaction's BEEF from the transactions table.
49    async fn delete_output(
50        &self,
51        txid: &str,
52        output_index: u32,
53        topic: &str,
54    ) -> Result<(), StorageError>;
55
56    /// Mark an output as spent.
57    async fn mark_utxo_as_spent(
58        &self,
59        txid: &str,
60        output_index: u32,
61        topic: &str,
62    ) -> Result<(), StorageError>;
63
64    /// Update the `consumed_by` list on an existing output.
65    async fn update_consumed_by(
66        &self,
67        txid: &str,
68        output_index: u32,
69        topic: &str,
70        consumed_by: &[Outpoint],
71    ) -> Result<(), StorageError>;
72
73    /// Update the BEEF data for a transaction (used when merkle proofs arrive).
74    async fn update_transaction_beef(&self, txid: &str, beef: &[u8]) -> Result<(), StorageError>;
75
76    /// Mark a transaction as already carrying its own merkle proof, WITHOUT
77    /// rewriting its BEEF.
78    ///
79    /// This is the lightweight flag-flip used by
80    /// [`Engine::complete_missing_proofs`](crate::Engine::complete_missing_proofs)
81    /// when a scanned candidate turns out to ALREADY be proven (its stored BEEF
82    /// proves the target tx) yet its backend `has_proof` flag is still `0` —
83    /// e.g. a GASP-synced tx that arrived with a proof, written before overlay
84    /// migration 0010 added the flag (the migration defaulted every existing
85    /// row to `0`). Such rows are returned by
86    /// [`Storage::find_transactions_for_proof_check`] every tick and skipped by
87    /// the engine, clogging the candidate window and starving genuinely
88    /// proofless rows behind them. Flipping the flag here drops them out.
89    ///
90    /// The reference D1 backend runs
91    /// `UPDATE transactions SET has_proof = 1 WHERE txid = ?`. Backends that do
92    /// not track a proof flag may use this no-op default. Idempotent.
93    async fn mark_transaction_proven(&self, txid: &str) -> Result<(), StorageError> {
94        let _ = txid;
95        Ok(())
96    }
97
98    /// [`Self::mark_transaction_proven`] that ALSO records the block height
99    /// the just-verified bump anchors the tx to (`proofHeight`, bsv-low M19
100    /// R2 round 3 review MED-2), so the revalidation sweep's transactions
101    /// leg can window the row. `None` records no anchor. Default: the plain
102    /// latch (a backend without the column loses only the windowing, never
103    /// correctness).
104    async fn mark_transaction_proven_at(
105        &self,
106        txid: &str,
107        height: Option<u64>,
108    ) -> Result<(), StorageError> {
109        let _ = height;
110        self.mark_transaction_proven(txid).await
111    }
112
113    /// Update the block height on an output (when it gets mined).
114    ///
115    /// Optional — backends that don't track block height can provide a no-op default.
116    async fn update_output_block_height(
117        &self,
118        txid: &str,
119        output_index: u32,
120        topic: &str,
121        block_height: u32,
122    ) -> Result<(), StorageError> {
123        let _ = (txid, output_index, topic, block_height);
124        Ok(())
125    }
126
127    /// Record that a transaction has been applied to a topic (deduplication).
128    async fn insert_applied_transaction(&self, tx: &AppliedTransaction)
129        -> Result<(), StorageError>;
130
131    /// Check if a transaction has already been applied to a topic.
132    async fn does_applied_transaction_exist(
133        &self,
134        tx: &AppliedTransaction,
135    ) -> Result<bool, StorageError>;
136
137    /// bsv-low PLAN-PRE-LOOP4 §H4 (2026-09-06): forget a (txid, topic) applied
138    /// record so a re-submit of those bytes is re-validated instead of
139    /// deduplicated away. The caller proves the row is PHANTOM (no stored
140    /// output of that txid on that topic) before asking — see
141    /// `Engine::forget_phantom_applied`.
142    async fn delete_applied_transaction(&self, tx: &AppliedTransaction)
143        -> Result<(), StorageError>;
144
145    // ========================================================================
146    // Read operations
147    // ========================================================================
148
149    /// Find a single output by txid + outputIndex, with optional topic and spent filters.
150    ///
151    /// If `include_beef` is true, load the BEEF from the transactions table and
152    /// attach it to the returned Output.
153    async fn find_output(
154        &self,
155        txid: &str,
156        output_index: u32,
157        topic: Option<&str>,
158        spent: Option<bool>,
159        include_beef: bool,
160    ) -> Result<Option<Output>, StorageError>;
161
162    /// Batch-find outputs by outpoints. More efficient than individual find_output calls.
163    ///
164    /// Default implementation falls back to individual lookups.
165    async fn find_outputs_by_outpoints(
166        &self,
167        outpoints: &[Outpoint],
168        include_beef: bool,
169    ) -> Result<Vec<Output>, StorageError> {
170        let mut results = Vec::with_capacity(outpoints.len());
171        for op in outpoints {
172            if let Some(output) = self
173                .find_output(&op.txid, op.output_index, None, None, include_beef)
174                .await?
175            {
176                results.push(output);
177            }
178        }
179        Ok(results)
180    }
181
182    /// Lookup-only read. Backends with observation stamps apply the shared
183    /// proof window here, before returning BEEF. Internal graph reads remain available.
184    async fn find_served_outputs(
185        &self,
186        outpoints: &[Outpoint],
187    ) -> Result<Vec<Output>, StorageError> {
188        self.find_outputs_by_outpoints(outpoints, true).await
189    }
190
191    /// Find all outputs for a given transaction.
192    async fn find_outputs_for_transaction(
193        &self,
194        txid: &str,
195        include_beef: bool,
196    ) -> Result<Vec<Output>, StorageError>;
197
198    /// Find unspent outputs for a topic, ordered by score ascending.
199    ///
200    /// - `since`: minimum score threshold (exclusive of scores below this)
201    /// - `limit`: maximum number of results
202    async fn find_utxos_for_topic(
203        &self,
204        topic: &str,
205        since: Option<f64>,
206        limit: Option<u64>,
207        include_beef: bool,
208    ) -> Result<Vec<Output>, StorageError>;
209
210    /// Return a bounded page of *proofless* stored transactions (txid + BEEF
211    /// bytes) for proof-completion scanning.
212    ///
213    /// Backends MUST return only transactions whose own merkle proof is still
214    /// missing — i.e. the historical/recent backlog the
215    /// [`Engine::complete_missing_proofs`](crate::Engine::complete_missing_proofs)
216    /// cron is meant to clear. The reference D1 backend keeps a `has_proof`
217    /// flag (overlay migration 0010), set on every BEEF write, and answers this
218    /// with `WHERE has_proof = 0 LIMIT {limit}` — so every proofless tx is
219    /// eventually reached (no "newest N rows only" starvation) and proven rows
220    /// are never re-fetched. The engine still defensively re-parses each
221    /// returned BEEF and skips any that turn out already-proven, so an
222    /// over-inclusive backend is merely less efficient, not incorrect.
223    ///
224    /// Backends that cannot enumerate transactions (or have nothing to
225    /// complete) may return an empty `Vec` via this default, in which case
226    /// proof completion is a no-op.
227    ///
228    /// `limit` bounds the returned page (and therefore the per-tick WoC fetch /
229    /// CPU budget).
230    ///
231    /// `min_age_secs` is the PUSH-PRIMARY BACKSTOP gate (bsv-low #228 /
232    /// arcade#259): rows stored less than `min_age_secs` ago are EXCLUDED —
233    /// their proof is expected to arrive via the Arcade MINED webhook
234    /// (`/arc-ingest`) at push speed, so polling them is wasted budget. Rules:
235    /// - `0` disables the gate (poll everything — today's behaviour).
236    /// - a row whose age is UNKNOWN (pre-migration `NULL` timestamp) MUST be
237    ///   treated as OLD, i.e. eligible — the fail-safe direction is to poll
238    ///   MORE, never to starve a row of its backstop.
239    /// The reference D1 backend answers with
240    /// `AND (created_at IS NULL OR created_at <= unixepoch() - min_age_secs)`.
241    async fn find_transactions_for_proof_check(
242        &self,
243        limit: u64,
244        min_age_secs: u64,
245    ) -> Result<Vec<TransactionBeef>, StorageError> {
246        let _ = (limit, min_age_secs);
247        Ok(Vec::new())
248    }
249
250    // ========================================================================
251    // GASP sync state
252    // ========================================================================
253
254    /// Update the last interaction score for a host+topic pair (upsert).
255    async fn update_last_interaction(
256        &self,
257        host: &str,
258        topic: &str,
259        since: u64,
260    ) -> Result<(), StorageError>;
261
262    /// Get the last interaction score for a host+topic pair. Returns 0 if not found.
263    async fn get_last_interaction(&self, host: &str, topic: &str) -> Result<u64, StorageError>;
264
265    /// Optional load control. An older backend stays eligible on every run.
266    async fn get_peer_cadence(
267        &self,
268        _host: &str,
269        _topic: &str,
270    ) -> Result<crate::sync_load::PeerCadence, StorageError> {
271        Ok(crate::sync_load::PeerCadence::default())
272    }
273
274    async fn set_peer_cadence(
275        &self,
276        _host: &str,
277        _topic: &str,
278        _state: &crate::sync_load::PeerCadence,
279    ) -> Result<(), StorageError> {
280        Ok(())
281    }
282
283    // ========================================================================
284    // GASP peer health (bsv-low#302 — dead-peer quarantine)
285    // ========================================================================
286
287    // The three peer-health methods are REQUIRED (bsv-low #555, the
288    // delta-2 fold's D2-L2): with defaults a wrapper that did not forward
289    // one compiled and silently turned the quarantine or the yieldless bound
290    // off. A backend that keeps no peer health answers as the old defaults
291    // did (`Ok(())`, `PeerSyncHealth::default()`, `PeerYieldStreak::default()`):
292    // fail-safe, every peer keeps being attempted. `host` is the peer's
293    // NORMALIZED ORIGIN (`crate::gasp::peer_origin`), not its URL: the engine
294    // passes it (the delta-2 fold's D2-M2).
295
296    /// Record the outcome of ONE GASP sync attempt with `host` for `topic`
297    /// (bsv-low#302). `success = true` resets the consecutive-failure count
298    /// to 0 (full re-admission); `false` increments it. The backend stamps
299    /// its own clock for the attempt time — the engine never supplies wall
300    /// time. Quarantine-SKIPPED peers are NOT recorded (a skip is not an
301    /// attempt; the last-attempt age must keep growing so the re-probe
302    /// window opens). Nor is, with a per-graph budget, a sync of a peer at
303    /// the quarantine threshold that did not fail and yielded nothing (bsv-low
304    /// #555, the delta-4 fold's D4-L2): only a yield re-admits that peer.
305    async fn record_peer_sync_outcome(
306        &self,
307        host: &str,
308        topic: &str,
309        success: bool,
310    ) -> Result<(), StorageError>;
311
312    /// Current sync health of the (host, topic) pairing (bsv-low#302). The
313    /// backend answers with its consecutive-failure count and the AGE of
314    /// the last attempt (relative seconds, so the engine needs no clock of
315    /// its own). Pristine: never attempted, never quarantined.
316    async fn get_peer_sync_health(
317        &self,
318        host: &str,
319        topic: &str,
320    ) -> Result<PeerSyncHealth, StorageError>;
321
322    /// Record whether ONE GASP sync with `host` for `topic` YIELDED (it
323    /// finalized a graph or moved the cursor) or not, and answer the
324    /// yieldless STREAK it now holds (bsv-low #555, the delta fold's D-M2 and
325    /// the delta-2 fold's D2-M1; `crate::gasp::yieldless_sync_failed`):
326    /// - `true` ends the streak: count 0, no start; answers that.
327    /// - `false` adds one to the streak and answers its count and the
328    ///   seconds since its FIRST yieldless sync, on the backend's own clock
329    ///   (0 on the sync that starts it). A streak whose LAST yieldless sync
330    ///   is more than `crate::gasp::PEER_YIELDLESS_DECAY_SECS` old starts
331    ///   again (count 1, age 0): the decay. NOT for a peer whose
332    ///   [`Self::record_peer_sync_outcome`] count is above 0 (the delta-3
333    ///   fold's D3-M1): the decay equals the reprobe window, so a quarantined
334    ///   peer's probe would start a fresh streak, succeed and lift the
335    ///   quarantine; its probe continues the streak instead and fails.
336    ///
337    /// The engine calls it only with a per-graph budget, only for a sync
338    /// the peer served work, and before [`Self::record_peer_sync_outcome`].
339    async fn record_peer_sync_yield(
340        &self,
341        host: &str,
342        topic: &str,
343        yielded: bool,
344    ) -> Result<PeerYieldStreak, StorageError>;
345
346    // ========================================================================
347    // Deferred GASP graphs (bsv-low #555)
348    // ========================================================================
349
350    /// Save (REPLACE) the record of one deferred GASP graph, keyed by
351    /// (`record.peer`, `record.topic`, `record.outpoint`). One row per graph:
352    /// a later deferral of the same graph overwrites it, never appends.
353    /// `AtCeiling`: refused at a bound of the storage's own (the worker's
354    /// global ceiling), counted `too_many`; the walk then goes on under the
355    /// per-peer budget alone, as before #555.
356    ///
357    /// The deferred-graph methods are REQUIRED (the lens fold's M2,
358    /// the house style of `TopicManager::identify_admissible_outputs`): with
359    /// a default, a storage or WRAPPER that did not forward them compiled,
360    /// and `Engine::set_graph_budget` over it saved nothing, walked every
361    /// graph past the call budget from its root on every tick and never
362    /// admitted it (`store_fault`), a hard cap on graph size worse than no
363    /// budget. A wrapper forwards these methods to the storage it wraps:
364    ///
365    /// ```ignore
366    /// async fn put_deferred_graph(&self, record: &DeferredGraph)
367    ///     -> Result<DeferredGraphSave, StorageError> {
368    ///     self.inner.put_deferred_graph(record).await
369    /// }
370    /// // and the same for find_deferred_graphs, get_deferred_graph and
371    /// // delete_deferred_graph and reserve_unanchorable_retry.
372    /// ```
373    ///
374    /// A backend that keeps no records answers none from
375    /// `find_deferred_graphs` and `get_deferred_graph` and an `Err` from
376    /// this one: every graph past the budget then fails its UTXO, so it
377    /// must not be given a per-graph budget.
378    async fn put_deferred_graph(
379        &self,
380        record: &crate::gasp::DeferredGraph,
381    ) -> Result<crate::gasp::DeferredGraphSave, StorageError>;
382
383    /// Atomically replace an unanchorable record only when its score and class
384    /// match, its backoff elapsed, and `record` reserves exactly the next retry.
385    /// Return false on a stale claim or exhausted room. Wrappers must forward it.
386    async fn reserve_unanchorable_retry(
387        &self,
388        record: &crate::gasp::DeferredGraph,
389    ) -> Result<bool, StorageError>;
390
391    /// The KEYS of the records of (`host`, `topic`), lowest score first. A
392    /// sync reads these up front and each record only when its UTXO is
393    /// served (the lens fold's L3).
394    async fn find_deferred_graphs(
395        &self,
396        host: &str,
397        topic: &str,
398    ) -> Result<Vec<crate::gasp::DeferredGraphKey>, StorageError>;
399
400    /// The record of the graph of (`host`, `topic`) rooted at `outpoint`.
401    async fn get_deferred_graph(
402        &self,
403        host: &str,
404        topic: &str,
405        outpoint: &str,
406    ) -> Result<Option<crate::gasp::DeferredGraph>, StorageError>;
407
408    /// Delete the record of the graph rooted at `outpoint`.
409    async fn delete_deferred_graph(
410        &self,
411        host: &str,
412        topic: &str,
413        outpoint: &str,
414    ) -> Result<(), StorageError>;
415
416    // ========================================================================
417    // Expired GASP roots (engine 0.1.8)
418    // ========================================================================
419
420    /// Remember that the GASP walk refused `outpoint` of `topic` because its
421    /// own advert had expired at `expiry` (unix seconds, read from the
422    /// output's bytes bound to its txid, never a peer's word). The backend
423    /// keeps the row until `max(expiry, now) +`
424    /// [`crate::gasp::EXPIRED_OUTPOINT_MEMO_SECS`] on its own clock and at
425    /// most [`crate::gasp::EXPIRED_OUTPOINT_MEMO_MAX_ROWS`] rows a topic, so
426    /// a later sync skips it with no node fetch.
427    ///
428    /// Defaulted, unlike the deferred-graph methods: a backend or wrapper
429    /// that does not forward these is fail-safe, the walk fetches and
430    /// refuses the dead again each run as it did before 0.1.8. A wrapper
431    /// forwards both all the same.
432    async fn remember_expired_outpoint(
433        &self,
434        _topic: &str,
435        _outpoint: &str,
436        _expiry: u64,
437    ) -> Result<(), StorageError> {
438        Ok(())
439    }
440
441    /// Of `outpoints`, those of `topic` remembered as expired and not yet
442    /// swept (see [`Self::remember_expired_outpoint`]).
443    async fn find_expired_outpoints(
444        &self,
445        _topic: &str,
446        _outpoints: &[String],
447    ) -> Result<Vec<String>, StorageError> {
448        Ok(Vec::new())
449    }
450}
451
452/// A (host, topic)'s yieldless streak (bsv-low #555, the delta-2 fold's
453/// D2-M1) — the input to [`crate::gasp::yieldless_sync_failed`].
454#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
455pub struct PeerYieldStreak {
456    /// Consecutive yieldless syncs (0 after a yield).
457    pub yieldless_syncs: u64,
458    /// Seconds since the streak's first yieldless sync. `None` = no streak,
459    /// or a backend with no clock: never past the time bound.
460    pub secs_since_first: Option<u64>,
461}
462
463/// Durable per-(host, topic) GASP sync health (bsv-low#302) — the input to
464/// the pure quarantine rule [`crate::gasp::peer_sync_quarantined`].
465#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
466pub struct PeerSyncHealth {
467    /// Consecutive sync attempts that ALL failed (timeout counts as a
468    /// failure). Reset to 0 by any successful sync.
469    pub consecutive_failures: u64,
470    /// Seconds since the last recorded sync ATTEMPT (success or failure).
471    /// `None` = never attempted / no health tracked — never quarantined.
472    pub secs_since_last_attempt: Option<u64>,
473}
474
475// ============================================================================
476// Rc delegation
477// ============================================================================
478
479/// Delegating impl so an `Rc<T: Storage>` is itself a `Storage` — lets a
480/// caller keep a shared handle to the concrete backend (e.g. a test advancing
481/// [`memory::MemoryStorage::advance_clock`]) while the engine owns a
482/// `Box<dyn Storage>` over the same instance. Pure delegation, no semantics.
483#[async_trait(?Send)]
484impl<T: Storage + ?Sized> Storage for std::rc::Rc<T> {
485    async fn get_peer_cadence(
486        &self,
487        host: &str,
488        topic: &str,
489    ) -> Result<crate::sync_load::PeerCadence, StorageError> {
490        (**self).get_peer_cadence(host, topic).await
491    }
492    async fn set_peer_cadence(
493        &self,
494        host: &str,
495        topic: &str,
496        state: &crate::sync_load::PeerCadence,
497    ) -> Result<(), StorageError> {
498        (**self).set_peer_cadence(host, topic, state).await
499    }
500    async fn insert_output(&self, output: &Output) -> Result<(), StorageError> {
501        (**self).insert_output(output).await
502    }
503    async fn delete_output(
504        &self,
505        txid: &str,
506        output_index: u32,
507        topic: &str,
508    ) -> Result<(), StorageError> {
509        (**self).delete_output(txid, output_index, topic).await
510    }
511    async fn mark_utxo_as_spent(
512        &self,
513        txid: &str,
514        output_index: u32,
515        topic: &str,
516    ) -> Result<(), StorageError> {
517        (**self).mark_utxo_as_spent(txid, output_index, topic).await
518    }
519    async fn update_consumed_by(
520        &self,
521        txid: &str,
522        output_index: u32,
523        topic: &str,
524        consumed_by: &[Outpoint],
525    ) -> Result<(), StorageError> {
526        (**self)
527            .update_consumed_by(txid, output_index, topic, consumed_by)
528            .await
529    }
530    async fn update_transaction_beef(&self, txid: &str, beef: &[u8]) -> Result<(), StorageError> {
531        (**self).update_transaction_beef(txid, beef).await
532    }
533    async fn mark_transaction_proven(&self, txid: &str) -> Result<(), StorageError> {
534        (**self).mark_transaction_proven(txid).await
535    }
536    async fn mark_transaction_proven_at(
537        &self,
538        txid: &str,
539        height: Option<u64>,
540    ) -> Result<(), StorageError> {
541        // review LOW-4: forward the anchored latch too — without this the
542        // Rc blanket hits the trait default (drops the height, re-introducing
543        // MED-2) for any caller routing through an `Rc<dyn Storage>`.
544        (**self).mark_transaction_proven_at(txid, height).await
545    }
546    async fn update_output_block_height(
547        &self,
548        txid: &str,
549        output_index: u32,
550        topic: &str,
551        block_height: u32,
552    ) -> Result<(), StorageError> {
553        (**self)
554            .update_output_block_height(txid, output_index, topic, block_height)
555            .await
556    }
557    async fn insert_applied_transaction(
558        &self,
559        tx: &AppliedTransaction,
560    ) -> Result<(), StorageError> {
561        (**self).insert_applied_transaction(tx).await
562    }
563    async fn does_applied_transaction_exist(
564        &self,
565        tx: &AppliedTransaction,
566    ) -> Result<bool, StorageError> {
567        (**self).does_applied_transaction_exist(tx).await
568    }
569    async fn delete_applied_transaction(
570        &self,
571        tx: &AppliedTransaction,
572    ) -> Result<(), StorageError> {
573        (**self).delete_applied_transaction(tx).await
574    }
575    async fn find_output(
576        &self,
577        txid: &str,
578        output_index: u32,
579        topic: Option<&str>,
580        spent: Option<bool>,
581        include_beef: bool,
582    ) -> Result<Option<Output>, StorageError> {
583        (**self)
584            .find_output(txid, output_index, topic, spent, include_beef)
585            .await
586    }
587    async fn find_served_outputs(
588        &self,
589        outpoints: &[Outpoint],
590    ) -> Result<Vec<Output>, StorageError> {
591        (**self).find_served_outputs(outpoints).await
592    }
593
594    async fn find_outputs_for_transaction(
595        &self,
596        txid: &str,
597        include_beef: bool,
598    ) -> Result<Vec<Output>, StorageError> {
599        (**self)
600            .find_outputs_for_transaction(txid, include_beef)
601            .await
602    }
603    async fn find_utxos_for_topic(
604        &self,
605        topic: &str,
606        since: Option<f64>,
607        limit: Option<u64>,
608        include_beef: bool,
609    ) -> Result<Vec<Output>, StorageError> {
610        (**self)
611            .find_utxos_for_topic(topic, since, limit, include_beef)
612            .await
613    }
614    async fn find_transactions_for_proof_check(
615        &self,
616        limit: u64,
617        min_age_secs: u64,
618    ) -> Result<Vec<TransactionBeef>, StorageError> {
619        (**self)
620            .find_transactions_for_proof_check(limit, min_age_secs)
621            .await
622    }
623    async fn update_last_interaction(
624        &self,
625        host: &str,
626        topic: &str,
627        since: u64,
628    ) -> Result<(), StorageError> {
629        (**self).update_last_interaction(host, topic, since).await
630    }
631    async fn get_last_interaction(&self, host: &str, topic: &str) -> Result<u64, StorageError> {
632        (**self).get_last_interaction(host, topic).await
633    }
634    async fn record_peer_sync_outcome(
635        &self,
636        host: &str,
637        topic: &str,
638        success: bool,
639    ) -> Result<(), StorageError> {
640        (**self)
641            .record_peer_sync_outcome(host, topic, success)
642            .await
643    }
644    async fn get_peer_sync_health(
645        &self,
646        host: &str,
647        topic: &str,
648    ) -> Result<PeerSyncHealth, StorageError> {
649        (**self).get_peer_sync_health(host, topic).await
650    }
651    async fn record_peer_sync_yield(
652        &self,
653        host: &str,
654        topic: &str,
655        yielded: bool,
656    ) -> Result<PeerYieldStreak, StorageError> {
657        (**self).record_peer_sync_yield(host, topic, yielded).await
658    }
659    async fn put_deferred_graph(
660        &self,
661        record: &crate::gasp::DeferredGraph,
662    ) -> Result<crate::gasp::DeferredGraphSave, StorageError> {
663        (**self).put_deferred_graph(record).await
664    }
665    async fn reserve_unanchorable_retry(
666        &self,
667        record: &crate::gasp::DeferredGraph,
668    ) -> Result<bool, StorageError> {
669        (**self).reserve_unanchorable_retry(record).await
670    }
671    async fn find_deferred_graphs(
672        &self,
673        host: &str,
674        topic: &str,
675    ) -> Result<Vec<crate::gasp::DeferredGraphKey>, StorageError> {
676        (**self).find_deferred_graphs(host, topic).await
677    }
678    async fn get_deferred_graph(
679        &self,
680        host: &str,
681        topic: &str,
682        outpoint: &str,
683    ) -> Result<Option<crate::gasp::DeferredGraph>, StorageError> {
684        (**self).get_deferred_graph(host, topic, outpoint).await
685    }
686    async fn delete_deferred_graph(
687        &self,
688        host: &str,
689        topic: &str,
690        outpoint: &str,
691    ) -> Result<(), StorageError> {
692        (**self).delete_deferred_graph(host, topic, outpoint).await
693    }
694    async fn remember_expired_outpoint(
695        &self,
696        topic: &str,
697        outpoint: &str,
698        expiry: u64,
699    ) -> Result<(), StorageError> {
700        (**self)
701            .remember_expired_outpoint(topic, outpoint, expiry)
702            .await
703    }
704    async fn find_expired_outpoints(
705        &self,
706        topic: &str,
707        outpoints: &[String],
708    ) -> Result<Vec<String>, StorageError> {
709        (**self).find_expired_outpoints(topic, outpoints).await
710    }
711}
712
713// ============================================================================
714// Error type
715// ============================================================================
716
717/// Storage operation errors.
718#[derive(Debug, thiserror::Error)]
719pub enum StorageError {
720    #[error("not found: {0}")]
721    NotFound(String),
722
723    #[error("duplicate: {0}")]
724    Duplicate(String),
725
726    #[error("database error: {0}")]
727    Database(String),
728
729    #[error("serialization error: {0}")]
730    Serialization(String),
731
732    #[error("{0}")]
733    Other(String),
734}
735
736// ============================================================================
737// In-memory storage (for tests and local dev)
738// ============================================================================
739
740#[cfg(any(test, feature = "memory-storage"))]
741pub mod memory {
742    use super::*;
743    use std::collections::HashMap;
744    use std::sync::Mutex;
745
746    /// In-memory Storage implementation for testing and local development.
747    ///
748    /// NOT suitable for production — no persistence, no concurrency beyond Mutex.
749    #[derive(Debug, Default)]
750    pub struct MemoryStorage {
751        cadence: Mutex<HashMap<(String, String), (crate::sync_load::PeerCadence, u64)>>,
752        /// outputs keyed by (txid, outputIndex, topic)
753        outputs: Mutex<HashMap<(String, u32, String), Output>>,
754        /// BEEF data keyed by txid
755        transactions: Mutex<HashMap<String, Vec<u8>>>,
756        /// txids whose `has_proof` flag is set. Models the real D1
757        /// `transactions.has_proof` column (overlay migration 0010), which is
758        /// the source of truth for `find_transactions_for_proof_check` — NOT a
759        /// live re-parse of the BEEF. A txid is absent here (flag = 0) until
760        /// something explicitly proves it, so a BEEF that already carries a
761        /// proof but is not in this set is still returned as a candidate — the
762        /// exact window-clog the cron must clear via `mark_transaction_proven`.
763        proven: Mutex<std::collections::HashSet<String>>,
764        /// applied transactions keyed by (txid, topic)
765        applied: Mutex<HashMap<(String, String), bool>>,
766        /// GASP sync state keyed by (host, topic)
767        sync_state: Mutex<HashMap<(String, String), u64>>,
768        /// Deterministic logical clock (seconds) for the push-primary backstop
769        /// age gate — models the D1 backend's `unixepoch()`. Tests advance it
770        /// with [`MemoryStorage::advance_clock`]; no wall clock is ever read
771        /// (wasm-safe, deterministic).
772        clock_secs: Mutex<u64>,
773        /// First-store stamp (clock seconds) per txid — models the D1
774        /// `transactions.created_at` column. A txid ABSENT here has UNKNOWN
775        /// age and is treated as OLD (eligible) — the fail-safe direction.
776        created_at: Mutex<HashMap<String, u64>>,
777        /// GASP peer health keyed by (host, topic) —
778        /// `(consecutive_failures, last_attempt_at_clock_secs)`. Models the
779        /// D1 `gasp_peer_health` table (bsv-low#302); the logical clock
780        /// above stands in for `unixepoch()`.
781        peer_health: Mutex<HashMap<(String, String), (u64, u64)>>,
782        /// review LOW-4: records the height an `_at` latch carried, so a pin
783        /// can prove the `Rc` blanket forwards `mark_transaction_proven_at`
784        /// (the trait default would drop the height).
785        proven_at: Mutex<HashMap<String, Option<u64>>>,
786        /// Deferred GASP graphs (bsv-low #555) keyed by (host, topic,
787        /// outpoint): models the D1 `gasp_deferred_graphs` table.
788        deferred_graphs: Mutex<HashMap<(String, String, String), crate::gasp::DeferredGraph>>,
789        /// How many times a deferred graph was WRITTEN (a pin counts the
790        /// storage writes of a deferral).
791        deferred_graph_writes: Mutex<u64>,
792        /// How many single records were READ (bsv-low #555, the lens
793        /// fold's L3).
794        deferred_graph_reads: Mutex<u64>,
795        /// Test knob: the key read of the deferred graphs faults.
796        deferred_graph_keys_fault: Mutex<bool>,
797        /// Test knob: a NEW record past this many held is refused
798        /// `AtCeiling` (the worker's global ceiling).
799        deferred_graph_ceiling: Mutex<Option<usize>>,
800        /// Test knob: a save that would take the bytes of every record held
801        /// past this is refused `AtCeiling`, a held key's too (the worker's
802        /// byte bounds, bsv-low #585).
803        deferred_graph_byte_ceiling: Mutex<Option<usize>>,
804        /// Test knob: every save of a deferred graph faults.
805        deferred_graph_put_fault: Mutex<bool>,
806        /// Expired GASP roots (engine 0.1.8) keyed by (topic, outpoint):
807        /// `(keep_until, written_at)` on the logical clock. Models the D1
808        /// `gasp_expired_outpoints` table.
809        expired_outpoints: Mutex<HashMap<(String, String), (u64, u64)>>,
810        /// How many saves of a deferred graph were ASKED (written, refused
811        /// or faulted).
812        deferred_graph_put_attempts: Mutex<u64>,
813        /// The yieldless streak keyed by (host, topic): `(count,
814        /// first_at, last_at)` on the logical clock. Models the D1
815        /// `gasp_peer_health` columns `yieldless_syncs`, `first_yieldless_at`
816        /// and `last_yieldless_at` (bsv-low #555, the delta fold's D-M2 and
817        /// the delta-2 fold's D2-M1).
818        peer_yieldless: Mutex<HashMap<(String, String), YieldStreakRow>>,
819    }
820
821    /// `(count, first_at, last_at)` of a yieldless streak, on the logical
822    /// clock.
823    type YieldStreakRow = (u64, u64, u64);
824
825    impl MemoryStorage {
826        pub fn new() -> Self {
827            Self::default()
828        }
829
830        /// The anchor height a `mark_transaction_proven_at` recorded for
831        /// `txid` (review LOW-4): `None` = never called `_at`; `Some(h)` =
832        /// called with height `h`.
833        pub fn proven_at_height(&self, txid: &str) -> Option<Option<u64>> {
834            self.proven_at.lock().unwrap().get(txid).copied()
835        }
836
837        /// Advance the deterministic logical clock by `secs` (test hook for
838        /// the push-primary backstop age gate — see
839        /// [`Storage::find_transactions_for_proof_check`]).
840        /// The expired roots remembered for `topic` and not yet swept.
841        pub fn expired_outpoints(&self, topic: &str) -> Vec<String> {
842            let now = *self.clock_secs.lock().unwrap();
843            let mut held: Vec<String> = self
844                .expired_outpoints
845                .lock()
846                .unwrap()
847                .iter()
848                .filter(|((t, _), (keep, _))| t == topic && *keep > now)
849                .map(|((_, o), _)| o.clone())
850                .collect();
851            held.sort();
852            held
853        }
854
855        pub fn advance_clock(&self, secs: u64) {
856            *self.clock_secs.lock().unwrap() += secs;
857        }
858
859        /// Stamp `txid`'s first-store time at the current clock if absent
860        /// (models `COALESCE(existing, unixepoch())` in the D1 backend).
861        fn stamp_created(&self, txid: &str) {
862            let now = *self.clock_secs.lock().unwrap();
863            self.created_at
864                .lock()
865                .unwrap()
866                .entry(txid.to_string())
867                .or_insert(now);
868        }
869
870        /// Whether `txid` clears the backstop age gate: unknown age is OLD
871        /// (eligible, fail-safe); otherwise `clock - created >= min_age_secs`.
872        fn age_gate_open(&self, txid: &str, min_age_secs: u64) -> bool {
873            if min_age_secs == 0 {
874                return true;
875            }
876            let now = *self.clock_secs.lock().unwrap();
877            match self.created_at.lock().unwrap().get(txid) {
878                None => true, // unknown age → treated old → eligible
879                Some(created) => now.saturating_sub(*created) >= min_age_secs,
880            }
881        }
882
883        /// Every deferred GASP graph held, any peer or topic (bsv-low #555).
884        pub fn deferred_graphs(&self) -> Vec<crate::gasp::DeferredGraph> {
885            let mut all: Vec<_> = self
886                .deferred_graphs
887                .lock()
888                .unwrap()
889                .values()
890                .cloned()
891                .collect();
892            all.sort_by(|a, b| (a.score, &a.outpoint).cmp(&(b.score, &b.outpoint)));
893            all
894        }
895
896        /// How many deferred-graph writes were made (bsv-low #555).
897        pub fn deferred_graph_writes(&self) -> u64 {
898            *self.deferred_graph_writes.lock().unwrap()
899        }
900
901        /// How many single records were read (bsv-low #555).
902        pub fn deferred_graph_reads(&self) -> u64 {
903            *self.deferred_graph_reads.lock().unwrap()
904        }
905
906        /// Make the key read of the deferred graphs fault, or not (a test
907        /// knob, bsv-low #555).
908        pub fn set_deferred_graph_keys_fault(&self, fault: bool) {
909            *self.deferred_graph_keys_fault.lock().unwrap() = fault;
910        }
911
912        /// Refuse a NEW record `AtCeiling` once this many are held, as the
913        /// worker's global ceiling does (a test knob, bsv-low #555).
914        pub fn set_deferred_graph_ceiling(&self, ceiling: Option<usize>) {
915            *self.deferred_graph_ceiling.lock().unwrap() = ceiling;
916        }
917
918        /// Refuse `AtCeiling` a save, new or a replacement, that would take
919        /// the bytes of every record held past this, as the worker's byte
920        /// bounds do (a test knob, bsv-low #585). The held record stays.
921        pub fn set_deferred_graph_byte_ceiling(&self, ceiling: Option<usize>) {
922            *self.deferred_graph_byte_ceiling.lock().unwrap() = ceiling;
923        }
924
925        /// Make every save of a deferred graph fault, or not (a test knob,
926        /// bsv-low #555's delta fold, D-L2).
927        pub fn set_deferred_graph_put_fault(&self, fault: bool) {
928            *self.deferred_graph_put_fault.lock().unwrap() = fault;
929        }
930
931        /// How many saves of a deferred graph were asked, whatever their
932        /// answer (bsv-low #555's delta fold, D-L1).
933        pub fn deferred_graph_put_attempts(&self) -> u64 {
934            *self.deferred_graph_put_attempts.lock().unwrap()
935        }
936
937        /// The consecutive yieldless syncs held for (host, topic)
938        /// (bsv-low #555's delta fold, D-M2). `host` may be a peer URL: it
939        /// is read under its [`crate::gasp::peer_origin`], the key the
940        /// engine writes (the delta-2 fold's D2-M2).
941        pub fn peer_yieldless_syncs(&self, host: &str, topic: &str) -> u64 {
942            self.peer_yieldless
943                .lock()
944                .unwrap()
945                .get(&(crate::gasp::peer_origin(host), topic.to_string()))
946                .map_or(0, |(count, _, _)| *count)
947        }
948
949        /// Count total outputs (for testing assertions).
950        pub fn output_count(&self) -> usize {
951            self.outputs.lock().unwrap().len()
952        }
953
954        /// Count total transactions (for testing assertions).
955        pub fn transaction_count(&self) -> usize {
956            self.transactions.lock().unwrap().len()
957        }
958    }
959
960    #[async_trait(?Send)]
961    impl Storage for MemoryStorage {
962        async fn insert_output(&self, output: &Output) -> Result<(), StorageError> {
963            let key = (
964                output.txid.clone(),
965                output.output_index,
966                output.topic.clone(),
967            );
968            let mut outputs = self.outputs.lock().unwrap();
969
970            // No-op if already exists (match KnexStorage behavior)
971            if outputs.contains_key(&key) {
972                // Still upsert BEEF if provided
973                if let Some(ref beef) = output.beef {
974                    self.transactions
975                        .lock()
976                        .unwrap()
977                        .entry(output.txid.clone())
978                        .or_insert_with(|| beef.clone());
979                }
980                return Ok(());
981            }
982
983            // Store output without BEEF (BEEF goes in transactions table)
984            let mut stored = output.clone();
985            stored.beef = None;
986            outputs.insert(key, stored);
987
988            // Upsert BEEF into transactions table
989            if let Some(ref beef) = output.beef {
990                self.transactions
991                    .lock()
992                    .unwrap()
993                    .entry(output.txid.clone())
994                    .or_insert_with(|| beef.clone());
995                self.stamp_created(&output.txid);
996            }
997
998            Ok(())
999        }
1000
1001        async fn delete_output(
1002            &self,
1003            txid: &str,
1004            output_index: u32,
1005            topic: &str,
1006        ) -> Result<(), StorageError> {
1007            let key = (txid.to_string(), output_index, topic.to_string());
1008            let mut outputs = self.outputs.lock().unwrap();
1009            outputs.remove(&key);
1010
1011            // If no more outputs reference this txid, remove the BEEF
1012            let has_remaining = outputs.keys().any(|(t, _, _)| t == txid);
1013            if !has_remaining {
1014                self.transactions.lock().unwrap().remove(txid);
1015                self.proven.lock().unwrap().remove(txid);
1016            }
1017
1018            Ok(())
1019        }
1020
1021        async fn mark_utxo_as_spent(
1022            &self,
1023            txid: &str,
1024            output_index: u32,
1025            topic: &str,
1026        ) -> Result<(), StorageError> {
1027            let key = (txid.to_string(), output_index, topic.to_string());
1028            if let Some(output) = self.outputs.lock().unwrap().get_mut(&key) {
1029                output.spent = true;
1030            }
1031            Ok(())
1032        }
1033
1034        async fn update_consumed_by(
1035            &self,
1036            txid: &str,
1037            output_index: u32,
1038            topic: &str,
1039            consumed_by: &[Outpoint],
1040        ) -> Result<(), StorageError> {
1041            let key = (txid.to_string(), output_index, topic.to_string());
1042            if let Some(output) = self.outputs.lock().unwrap().get_mut(&key) {
1043                output.consumed_by = consumed_by.to_vec();
1044            }
1045            Ok(())
1046        }
1047
1048        async fn update_transaction_beef(
1049            &self,
1050            txid: &str,
1051            beef: &[u8],
1052        ) -> Result<(), StorageError> {
1053            // Model the D1 backend: keep the proof flag accurate on every BEEF
1054            // write by inspecting whether the new BEEF proves the target tx.
1055            // (The proof-completion stitch calls back here with a proven BEEF,
1056            // which is how a row legitimately flips proofless → proven.)
1057            // A streaming fold: one element in hand, no `Beef` built.
1058            let has_proof = beef_limits::has_proof(beef, txid);
1059            if has_proof {
1060                self.proven.lock().unwrap().insert(txid.to_string());
1061            }
1062            self.transactions
1063                .lock()
1064                .unwrap()
1065                .insert(txid.to_string(), beef.to_vec());
1066            // Preserve-or-stamp: a rewrite keeps the ORIGINAL first-store
1067            // time (models the D1 COALESCE), so the backstop age stays real.
1068            self.stamp_created(txid);
1069            Ok(())
1070        }
1071
1072        async fn mark_transaction_proven(&self, txid: &str) -> Result<(), StorageError> {
1073            // Lightweight flag-flip (no BEEF rewrite), idempotent. Mirrors the
1074            // D1 `UPDATE transactions SET has_proof = 1 WHERE txid = ?`.
1075            self.proven.lock().unwrap().insert(txid.to_string());
1076            Ok(())
1077        }
1078
1079        async fn mark_transaction_proven_at(
1080            &self,
1081            txid: &str,
1082            height: Option<u64>,
1083        ) -> Result<(), StorageError> {
1084            // Models the D1 `MARK_TX_PROVEN_AT_SQL`: flip has_proof AND record
1085            // the anchor (review MED-2/LOW-4). Recorded so the Rc-forward pin
1086            // can observe the height reaching the inner store.
1087            self.proven.lock().unwrap().insert(txid.to_string());
1088            self.proven_at
1089                .lock()
1090                .unwrap()
1091                .insert(txid.to_string(), height);
1092            Ok(())
1093        }
1094
1095        async fn update_output_block_height(
1096            &self,
1097            txid: &str,
1098            output_index: u32,
1099            topic: &str,
1100            block_height: u32,
1101        ) -> Result<(), StorageError> {
1102            let key = (txid.to_string(), output_index, topic.to_string());
1103            if let Some(output) = self.outputs.lock().unwrap().get_mut(&key) {
1104                output.block_height = Some(block_height);
1105            }
1106            Ok(())
1107        }
1108
1109        async fn insert_applied_transaction(
1110            &self,
1111            tx: &AppliedTransaction,
1112        ) -> Result<(), StorageError> {
1113            self.applied
1114                .lock()
1115                .unwrap()
1116                .insert((tx.txid.clone(), tx.topic.clone()), true);
1117            Ok(())
1118        }
1119
1120        async fn does_applied_transaction_exist(
1121            &self,
1122            tx: &AppliedTransaction,
1123        ) -> Result<bool, StorageError> {
1124            Ok(self
1125                .applied
1126                .lock()
1127                .unwrap()
1128                .contains_key(&(tx.txid.clone(), tx.topic.clone())))
1129        }
1130
1131        async fn delete_applied_transaction(
1132            &self,
1133            tx: &AppliedTransaction,
1134        ) -> Result<(), StorageError> {
1135            self.applied
1136                .lock()
1137                .unwrap()
1138                .remove(&(tx.txid.clone(), tx.topic.clone()));
1139            Ok(())
1140        }
1141
1142        async fn find_output(
1143            &self,
1144            txid: &str,
1145            output_index: u32,
1146            topic: Option<&str>,
1147            spent: Option<bool>,
1148            include_beef: bool,
1149        ) -> Result<Option<Output>, StorageError> {
1150            let outputs = self.outputs.lock().unwrap();
1151
1152            // If topic is specified, do a direct lookup
1153            if let Some(topic) = topic {
1154                let key = (txid.to_string(), output_index, topic.to_string());
1155                if let Some(output) = outputs.get(&key) {
1156                    if let Some(s) = spent {
1157                        if output.spent != s {
1158                            return Ok(None);
1159                        }
1160                    }
1161                    let mut result = output.clone();
1162                    if include_beef {
1163                        result.beef = self.transactions.lock().unwrap().get(txid).cloned();
1164                    }
1165                    return Ok(Some(result));
1166                }
1167                return Ok(None);
1168            }
1169
1170            // No topic — find first matching (txid, outputIndex)
1171            for ((t, oi, _), output) in outputs.iter() {
1172                if t == txid && *oi == output_index {
1173                    if let Some(s) = spent {
1174                        if output.spent != s {
1175                            continue;
1176                        }
1177                    }
1178                    let mut result = output.clone();
1179                    if include_beef {
1180                        result.beef = self.transactions.lock().unwrap().get(txid).cloned();
1181                    }
1182                    return Ok(Some(result));
1183                }
1184            }
1185            Ok(None)
1186        }
1187
1188        async fn find_served_outputs(
1189            &self,
1190            outpoints: &[Outpoint],
1191        ) -> Result<Vec<Output>, StorageError> {
1192            let outputs = self.find_outputs_by_outpoints(outpoints, true).await?;
1193            let now = i64::try_from(*self.clock_secs.lock().unwrap())
1194                .unwrap_or(i64::MAX)
1195                .saturating_mul(1000);
1196            let proven = self.proven.lock().unwrap();
1197            let stamps = self.created_at.lock().unwrap();
1198            Ok(outputs
1199                .into_iter()
1200                .filter(|output| {
1201                    let seen = stamps
1202                        .get(&output.txid)
1203                        .and_then(|s| i64::try_from(*s).ok())
1204                        .map(|s| s.saturating_mul(1000));
1205                    crate::proof_window::verdict(proven.contains(&output.txid), seen, now)
1206                        == crate::proof_window::Verdict::Served
1207                })
1208                .collect())
1209        }
1210
1211        async fn find_outputs_for_transaction(
1212            &self,
1213            txid: &str,
1214            include_beef: bool,
1215        ) -> Result<Vec<Output>, StorageError> {
1216            let outputs = self.outputs.lock().unwrap();
1217            let beef = if include_beef {
1218                self.transactions.lock().unwrap().get(txid).cloned()
1219            } else {
1220                None
1221            };
1222
1223            let results: Vec<Output> = outputs
1224                .iter()
1225                .filter(|((t, _, _), _)| t == txid)
1226                .map(|(_, output)| {
1227                    let mut o = output.clone();
1228                    if include_beef {
1229                        o.beef.clone_from(&beef);
1230                    }
1231                    o
1232                })
1233                .collect();
1234
1235            Ok(results)
1236        }
1237
1238        async fn find_utxos_for_topic(
1239            &self,
1240            topic: &str,
1241            since: Option<f64>,
1242            limit: Option<u64>,
1243            include_beef: bool,
1244        ) -> Result<Vec<Output>, StorageError> {
1245            let outputs = self.outputs.lock().unwrap();
1246            let transactions = self.transactions.lock().unwrap();
1247
1248            let mut results: Vec<Output> = outputs
1249                .iter()
1250                .filter(|((_, _, t), o)| {
1251                    t == topic && !o.spent && since.is_none_or(|s| o.score.unwrap_or(0.0) >= s)
1252                })
1253                .map(|(_, output)| {
1254                    let mut o = output.clone();
1255                    if include_beef {
1256                        o.beef = transactions.get(&o.txid).cloned();
1257                    }
1258                    o
1259                })
1260                .collect();
1261
1262            // Sort by score ascending (matching KnexStorage behavior)
1263            results.sort_by(|a, b| {
1264                a.score
1265                    .unwrap_or(0.0)
1266                    .partial_cmp(&b.score.unwrap_or(0.0))
1267                    .unwrap_or(std::cmp::Ordering::Equal)
1268            });
1269
1270            if let Some(limit) = limit {
1271                results.truncate(limit as usize);
1272            }
1273
1274            Ok(results)
1275        }
1276
1277        async fn find_transactions_for_proof_check(
1278            &self,
1279            limit: u64,
1280            min_age_secs: u64,
1281        ) -> Result<Vec<TransactionBeef>, StorageError> {
1282            // Models the real D1 `WHERE has_proof = 0 LIMIT {limit}` query
1283            // (overlay migration 0010): the candidate set is driven by the
1284            // `has_proof` FLAG, not a live re-parse of the BEEF. A row whose
1285            // BEEF already carries a proof but whose flag is still 0 (e.g. a
1286            // GASP-synced proof written before the migration, which defaulted
1287            // every existing row to 0) is therefore STILL returned here — it
1288            // only drops out once `mark_transaction_proven` flips the flag.
1289            // This reproduces the window-clog the engine must clear.
1290            //
1291            // The push-primary backstop age gate (#228) excludes rows younger
1292            // than `min_age_secs` (their proof is expected via /arc-ingest);
1293            // unknown-age rows stay eligible (fail-safe — trait doc).
1294            let candidates: Vec<TransactionBeef> = {
1295                let transactions = self.transactions.lock().unwrap();
1296                let proven = self.proven.lock().unwrap();
1297                transactions
1298                    .iter()
1299                    .filter(|(txid, _)| !proven.contains(*txid))
1300                    .map(|(txid, beef)| TransactionBeef {
1301                        txid: txid.clone(),
1302                        beef: beef.clone(),
1303                    })
1304                    .collect()
1305            };
1306            Ok(candidates
1307                .into_iter()
1308                .filter(|c| self.age_gate_open(&c.txid, min_age_secs))
1309                .take(limit as usize)
1310                .collect())
1311        }
1312
1313        async fn update_last_interaction(
1314            &self,
1315            host: &str,
1316            topic: &str,
1317            since: u64,
1318        ) -> Result<(), StorageError> {
1319            self.sync_state
1320                .lock()
1321                .unwrap()
1322                .insert((host.to_string(), topic.to_string()), since);
1323            Ok(())
1324        }
1325
1326        async fn get_last_interaction(&self, host: &str, topic: &str) -> Result<u64, StorageError> {
1327            Ok(*self
1328                .sync_state
1329                .lock()
1330                .unwrap()
1331                .get(&(host.to_string(), topic.to_string()))
1332                .unwrap_or(&0))
1333        }
1334
1335        async fn get_peer_cadence(
1336            &self,
1337            host: &str,
1338            topic: &str,
1339        ) -> Result<crate::sync_load::PeerCadence, StorageError> {
1340            let now = *self.clock_secs.lock().unwrap();
1341            Ok(self
1342                .cadence
1343                .lock()
1344                .unwrap()
1345                .get(&(host.into(), topic.into()))
1346                .map_or_else(Default::default, |(s, at)| {
1347                    let mut state = s.clone();
1348                    state.retry_after_secs = at.saturating_sub(now);
1349                    state
1350                }))
1351        }
1352        async fn set_peer_cadence(
1353            &self,
1354            host: &str,
1355            topic: &str,
1356            state: &crate::sync_load::PeerCadence,
1357        ) -> Result<(), StorageError> {
1358            let now = *self.clock_secs.lock().unwrap();
1359            self.cadence.lock().unwrap().insert(
1360                (host.into(), topic.into()),
1361                (state.clone(), now.saturating_add(state.retry_after_secs)),
1362            );
1363            Ok(())
1364        }
1365        async fn record_peer_sync_outcome(
1366            &self,
1367            host: &str,
1368            topic: &str,
1369            success: bool,
1370        ) -> Result<(), StorageError> {
1371            let now = *self.clock_secs.lock().unwrap();
1372            let mut health = self.peer_health.lock().unwrap();
1373            let entry = health
1374                .entry((host.to_string(), topic.to_string()))
1375                .or_insert((0, now));
1376            entry.0 = if success { 0 } else { entry.0 + 1 };
1377            entry.1 = now;
1378            Ok(())
1379        }
1380
1381        async fn get_peer_sync_health(
1382            &self,
1383            host: &str,
1384            topic: &str,
1385        ) -> Result<PeerSyncHealth, StorageError> {
1386            let now = *self.clock_secs.lock().unwrap();
1387            Ok(self
1388                .peer_health
1389                .lock()
1390                .unwrap()
1391                .get(&(host.to_string(), topic.to_string()))
1392                .map(|(fails, attempt_at)| PeerSyncHealth {
1393                    consecutive_failures: *fails,
1394                    secs_since_last_attempt: Some(now.saturating_sub(*attempt_at)),
1395                })
1396                .unwrap_or_default())
1397        }
1398
1399        async fn record_peer_sync_yield(
1400            &self,
1401            host: &str,
1402            topic: &str,
1403            yielded: bool,
1404        ) -> Result<PeerYieldStreak, StorageError> {
1405            let now = *self.clock_secs.lock().unwrap();
1406            let key = (host.to_string(), topic.to_string());
1407            // The delta-3 fold's D3-M1: the decay only for a peer that is
1408            // not failed (the worker's statement reads the same row).
1409            let not_failed = self
1410                .peer_health
1411                .lock()
1412                .unwrap()
1413                .get(&key)
1414                .is_none_or(|(fails, _)| *fails == 0);
1415            let mut held = self.peer_yieldless.lock().unwrap();
1416            if yielded {
1417                held.remove(&key);
1418                return Ok(PeerYieldStreak::default());
1419            }
1420            let streak = held.entry(key).or_insert((0, now, now));
1421            if streak.0 == 0
1422                || (not_failed
1423                    && now.saturating_sub(streak.2) > crate::gasp::PEER_YIELDLESS_DECAY_SECS)
1424            {
1425                *streak = (0, now, now);
1426            }
1427            streak.0 += 1;
1428            streak.2 = now;
1429            Ok(PeerYieldStreak {
1430                yieldless_syncs: streak.0,
1431                secs_since_first: Some(now.saturating_sub(streak.1)),
1432            })
1433        }
1434
1435        async fn put_deferred_graph(
1436            &self,
1437            record: &crate::gasp::DeferredGraph,
1438        ) -> Result<crate::gasp::DeferredGraphSave, StorageError> {
1439            let key = (
1440                record.peer.clone(),
1441                record.topic.clone(),
1442                record.outpoint.clone(),
1443            );
1444            *self.deferred_graph_put_attempts.lock().unwrap() += 1;
1445            if *self.deferred_graph_put_fault.lock().unwrap() {
1446                return Err(StorageError::Database("deferred graph save faulted".into()));
1447            }
1448            if let Some(ceiling) = *self.deferred_graph_ceiling.lock().unwrap() {
1449                let held = self.deferred_graphs.lock().unwrap();
1450                if !held.contains_key(&key) && held.len() >= ceiling {
1451                    return Ok(crate::gasp::DeferredGraphSave::AtCeiling);
1452                }
1453            }
1454            if let Some(ceiling) = *self.deferred_graph_byte_ceiling.lock().unwrap() {
1455                let held = self.deferred_graphs.lock().unwrap();
1456                let others: usize = held
1457                    .iter()
1458                    .filter(|(k, _)| **k != key)
1459                    .map(|(_, r)| r.byte_size())
1460                    .sum();
1461                if others + record.byte_size() > ceiling {
1462                    return Ok(crate::gasp::DeferredGraphSave::AtCeiling);
1463                }
1464            }
1465            *self.deferred_graph_writes.lock().unwrap() += 1;
1466            self.deferred_graphs.lock().unwrap().insert(
1467                (
1468                    record.peer.clone(),
1469                    record.topic.clone(),
1470                    record.outpoint.clone(),
1471                ),
1472                record.clone(),
1473            );
1474            Ok(crate::gasp::DeferredGraphSave::Saved)
1475        }
1476
1477        async fn reserve_unanchorable_retry(
1478            &self,
1479            record: &crate::gasp::DeferredGraph,
1480        ) -> Result<bool, StorageError> {
1481            *self.deferred_graph_put_attempts.lock().unwrap() += 1;
1482            if *self.deferred_graph_put_fault.lock().unwrap() {
1483                return Err(StorageError::Database("retry reservation faulted".into()));
1484            }
1485            let ceiling = *self.deferred_graph_byte_ceiling.lock().unwrap();
1486            let key = (
1487                record.peer.clone(),
1488                record.topic.clone(),
1489                record.outpoint.clone(),
1490            );
1491            let mut held = self.deferred_graphs.lock().unwrap();
1492            let Some(prior) = held.get(&key) else {
1493                return Ok(false);
1494            };
1495            let now = (crate::engine::current_timestamp_ms() / 1000.0) as u64;
1496            if !prior.is_unanchorable()
1497                || prior.reason != record.reason
1498                || prior.score != record.score
1499                || prior.retry_at > now
1500                || prior.unanchorable_retries.checked_add(1) != Some(record.unanchorable_retries)
1501                || record.unanchorable_retries > crate::gasp::UNANCHORABLE_RETRIES
1502            {
1503                return Ok(false);
1504            }
1505            if ceiling.is_some_and(|limit| {
1506                held.iter()
1507                    .filter(|(k, _)| **k != key)
1508                    .map(|(_, r)| r.byte_size())
1509                    .sum::<usize>()
1510                    + record.byte_size()
1511                    > limit
1512            }) {
1513                return Ok(false);
1514            }
1515            held.insert(key, record.clone());
1516            *self.deferred_graph_writes.lock().unwrap() += 1;
1517            Ok(true)
1518        }
1519
1520        async fn find_deferred_graphs(
1521            &self,
1522            host: &str,
1523            topic: &str,
1524        ) -> Result<Vec<crate::gasp::DeferredGraphKey>, StorageError> {
1525            if *self.deferred_graph_keys_fault.lock().unwrap() {
1526                return Err(StorageError::Database("deferred graph keys: fault".into()));
1527            }
1528            Ok(self
1529                .deferred_graphs()
1530                .into_iter()
1531                .filter(|r| r.peer == host && r.topic == topic)
1532                .map(|r| crate::gasp::DeferredGraphKey {
1533                    outpoint: r.outpoint,
1534                    score: r.score,
1535                })
1536                .collect())
1537        }
1538
1539        async fn get_deferred_graph(
1540            &self,
1541            host: &str,
1542            topic: &str,
1543            outpoint: &str,
1544        ) -> Result<Option<crate::gasp::DeferredGraph>, StorageError> {
1545            *self.deferred_graph_reads.lock().unwrap() += 1;
1546            Ok(self
1547                .deferred_graphs
1548                .lock()
1549                .unwrap()
1550                .get(&(host.to_string(), topic.to_string(), outpoint.to_string()))
1551                .cloned())
1552        }
1553
1554        async fn delete_deferred_graph(
1555            &self,
1556            host: &str,
1557            topic: &str,
1558            outpoint: &str,
1559        ) -> Result<(), StorageError> {
1560            self.deferred_graphs.lock().unwrap().remove(&(
1561                host.to_string(),
1562                topic.to_string(),
1563                outpoint.to_string(),
1564            ));
1565            Ok(())
1566        }
1567
1568        async fn remember_expired_outpoint(
1569            &self,
1570            topic: &str,
1571            outpoint: &str,
1572            expiry: u64,
1573        ) -> Result<(), StorageError> {
1574            let now = *self.clock_secs.lock().unwrap();
1575            let mut held = self.expired_outpoints.lock().unwrap();
1576            held.retain(|_, (keep, _)| *keep > now);
1577            held.insert(
1578                (topic.to_string(), outpoint.to_string()),
1579                (
1580                    expiry.max(now) + crate::gasp::EXPIRED_OUTPOINT_MEMO_SECS,
1581                    now,
1582                ),
1583            );
1584            // The bound: the most recently written rows of the topic stay.
1585            let mut of_topic: Vec<(u64, String)> = held
1586                .iter()
1587                .filter(|((t, _), _)| t == topic)
1588                .map(|((_, o), (_, at))| (*at, o.clone()))
1589                .collect();
1590            let cap = crate::gasp::EXPIRED_OUTPOINT_MEMO_MAX_ROWS;
1591            if of_topic.len() > cap {
1592                of_topic.sort();
1593                for (_, o) in &of_topic[..of_topic.len() - cap] {
1594                    held.remove(&(topic.to_string(), o.clone()));
1595                }
1596            }
1597            Ok(())
1598        }
1599
1600        async fn find_expired_outpoints(
1601            &self,
1602            topic: &str,
1603            outpoints: &[String],
1604        ) -> Result<Vec<String>, StorageError> {
1605            let now = *self.clock_secs.lock().unwrap();
1606            let held = self.expired_outpoints.lock().unwrap();
1607            Ok(outpoints
1608                .iter()
1609                .filter(|o| {
1610                    held.get(&(topic.to_string(), (*o).clone()))
1611                        .is_some_and(|(keep, _)| *keep > now)
1612                })
1613                .cloned()
1614                .collect())
1615        }
1616    }
1617}
1618
1619// ============================================================================
1620// Tests
1621// ============================================================================
1622
1623#[cfg(test)]
1624mod tests {
1625    use super::memory::MemoryStorage;
1626    use super::*;
1627    use crate::types::AppliedTransaction;
1628
1629    /// review LOW-4: the `Rc<T: Storage>` blanket must FORWARD
1630    /// `mark_transaction_proven_at`, not fall through to the trait default
1631    /// (which drops the height, re-introducing MED-2). Call it through an
1632    /// `Rc<dyn Storage>` and assert the height reached the inner store.
1633    #[tokio::test]
1634    async fn the_rc_blanket_forwards_the_anchored_proven_latch() {
1635        let store = std::rc::Rc::new(MemoryStorage::new());
1636        let via_dyn: std::rc::Rc<dyn Storage> = store.clone();
1637        via_dyn
1638            .mark_transaction_proven_at("tx", Some(965_772))
1639            .await
1640            .unwrap();
1641        assert_eq!(
1642            store.proven_at_height("tx"),
1643            Some(Some(965_772)),
1644            "the Rc blanket forwarded the height to the inner store"
1645        );
1646        // a height-less call still forwards (records None, not "never called")
1647        via_dyn
1648            .mark_transaction_proven_at("tx2", None)
1649            .await
1650            .unwrap();
1651        assert_eq!(store.proven_at_height("tx2"), Some(None));
1652        assert_eq!(
1653            store.proven_at_height("never"),
1654            None,
1655            "a txid never latched is absent"
1656        );
1657    }
1658
1659    fn make_output(txid: &str, index: u32, topic: &str, score: f64) -> Output {
1660        Output {
1661            txid: txid.to_string(),
1662            output_index: index,
1663            output_script: vec![0x76, 0xa9],
1664            satoshis: 1000,
1665            topic: topic.to_string(),
1666            spent: false,
1667            outputs_consumed: vec![],
1668            consumed_by: vec![],
1669            beef: Some(vec![0xBE, 0xEF]),
1670            block_height: None,
1671            score: Some(score),
1672        }
1673    }
1674
1675    #[tokio::test]
1676    async fn test_insert_and_find_output() {
1677        let store = MemoryStorage::new();
1678        let output = make_output("abc", 0, "tm_test", 1.0);
1679        store.insert_output(&output).await.unwrap();
1680
1681        // Find without BEEF
1682        let found = store
1683            .find_output("abc", 0, Some("tm_test"), None, false)
1684            .await
1685            .unwrap()
1686            .unwrap();
1687        assert_eq!(found.txid, "abc");
1688        assert_eq!(found.output_index, 0);
1689        assert!(found.beef.is_none());
1690
1691        // Find with BEEF
1692        let found = store
1693            .find_output("abc", 0, Some("tm_test"), None, true)
1694            .await
1695            .unwrap()
1696            .unwrap();
1697        assert_eq!(found.beef.unwrap(), vec![0xBE, 0xEF]);
1698    }
1699
1700    #[tokio::test]
1701    async fn test_insert_duplicate_is_noop() {
1702        let store = MemoryStorage::new();
1703        let output = make_output("abc", 0, "tm_test", 1.0);
1704        store.insert_output(&output).await.unwrap();
1705        store.insert_output(&output).await.unwrap();
1706        assert_eq!(store.output_count(), 1);
1707    }
1708
1709    #[tokio::test]
1710    async fn test_delete_output_cleans_up_beef() {
1711        let store = MemoryStorage::new();
1712        let output = make_output("abc", 0, "tm_test", 1.0);
1713        store.insert_output(&output).await.unwrap();
1714        assert_eq!(store.transaction_count(), 1);
1715
1716        store.delete_output("abc", 0, "tm_test").await.unwrap();
1717        assert_eq!(store.output_count(), 0);
1718        assert_eq!(store.transaction_count(), 0); // BEEF cleaned up
1719    }
1720
1721    #[tokio::test]
1722    async fn test_delete_output_keeps_beef_if_other_outputs_exist() {
1723        let store = MemoryStorage::new();
1724        store
1725            .insert_output(&make_output("abc", 0, "tm_test", 1.0))
1726            .await
1727            .unwrap();
1728        store
1729            .insert_output(&make_output("abc", 1, "tm_test", 2.0))
1730            .await
1731            .unwrap();
1732        assert_eq!(store.output_count(), 2);
1733        assert_eq!(store.transaction_count(), 1);
1734
1735        store.delete_output("abc", 0, "tm_test").await.unwrap();
1736        assert_eq!(store.output_count(), 1);
1737        assert_eq!(store.transaction_count(), 1); // BEEF kept for output 1
1738    }
1739
1740    #[tokio::test]
1741    async fn test_mark_utxo_as_spent() {
1742        let store = MemoryStorage::new();
1743        store
1744            .insert_output(&make_output("abc", 0, "tm_test", 1.0))
1745            .await
1746            .unwrap();
1747
1748        store.mark_utxo_as_spent("abc", 0, "tm_test").await.unwrap();
1749
1750        let found = store
1751            .find_output("abc", 0, Some("tm_test"), None, false)
1752            .await
1753            .unwrap()
1754            .unwrap();
1755        assert!(found.spent);
1756    }
1757
1758    #[tokio::test]
1759    async fn test_find_output_with_spent_filter() {
1760        let store = MemoryStorage::new();
1761        store
1762            .insert_output(&make_output("abc", 0, "tm_test", 1.0))
1763            .await
1764            .unwrap();
1765
1766        // Not spent — should find with spent=false, not with spent=true
1767        assert!(store
1768            .find_output("abc", 0, Some("tm_test"), Some(false), false)
1769            .await
1770            .unwrap()
1771            .is_some());
1772        assert!(store
1773            .find_output("abc", 0, Some("tm_test"), Some(true), false)
1774            .await
1775            .unwrap()
1776            .is_none());
1777
1778        store.mark_utxo_as_spent("abc", 0, "tm_test").await.unwrap();
1779
1780        // Now spent — reversed
1781        assert!(store
1782            .find_output("abc", 0, Some("tm_test"), Some(true), false)
1783            .await
1784            .unwrap()
1785            .is_some());
1786        assert!(store
1787            .find_output("abc", 0, Some("tm_test"), Some(false), false)
1788            .await
1789            .unwrap()
1790            .is_none());
1791    }
1792
1793    #[tokio::test]
1794    async fn test_update_consumed_by() {
1795        let store = MemoryStorage::new();
1796        store
1797            .insert_output(&make_output("abc", 0, "tm_test", 1.0))
1798            .await
1799            .unwrap();
1800
1801        let consumed = vec![Outpoint::new("def", 0), Outpoint::new("ghi", 1)];
1802        store
1803            .update_consumed_by("abc", 0, "tm_test", &consumed)
1804            .await
1805            .unwrap();
1806
1807        let found = store
1808            .find_output("abc", 0, Some("tm_test"), None, false)
1809            .await
1810            .unwrap()
1811            .unwrap();
1812        assert_eq!(found.consumed_by.len(), 2);
1813        assert_eq!(found.consumed_by[0].txid, "def");
1814        assert_eq!(found.consumed_by[1].output_index, 1);
1815    }
1816
1817    #[tokio::test]
1818    async fn test_update_transaction_beef() {
1819        let store = MemoryStorage::new();
1820        store
1821            .insert_output(&make_output("abc", 0, "tm_test", 1.0))
1822            .await
1823            .unwrap();
1824
1825        store
1826            .update_transaction_beef("abc", &[0xDE, 0xAD])
1827            .await
1828            .unwrap();
1829
1830        let found = store
1831            .find_output("abc", 0, Some("tm_test"), None, true)
1832            .await
1833            .unwrap()
1834            .unwrap();
1835        assert_eq!(found.beef.unwrap(), vec![0xDE, 0xAD]);
1836    }
1837
1838    #[tokio::test]
1839    async fn test_update_output_block_height() {
1840        let store = MemoryStorage::new();
1841        store
1842            .insert_output(&make_output("abc", 0, "tm_test", 1.0))
1843            .await
1844            .unwrap();
1845
1846        store
1847            .update_output_block_height("abc", 0, "tm_test", 850_000)
1848            .await
1849            .unwrap();
1850
1851        let found = store
1852            .find_output("abc", 0, Some("tm_test"), None, false)
1853            .await
1854            .unwrap()
1855            .unwrap();
1856        assert_eq!(found.block_height, Some(850_000));
1857    }
1858
1859    #[tokio::test]
1860    async fn test_find_outputs_for_transaction() {
1861        let store = MemoryStorage::new();
1862        store
1863            .insert_output(&make_output("abc", 0, "tm_test", 1.0))
1864            .await
1865            .unwrap();
1866        store
1867            .insert_output(&make_output("abc", 1, "tm_test", 2.0))
1868            .await
1869            .unwrap();
1870        store
1871            .insert_output(&make_output("other", 0, "tm_test", 3.0))
1872            .await
1873            .unwrap();
1874
1875        let results = store
1876            .find_outputs_for_transaction("abc", false)
1877            .await
1878            .unwrap();
1879        assert_eq!(results.len(), 2);
1880    }
1881
1882    #[tokio::test]
1883    async fn test_find_utxos_for_topic() {
1884        let store = MemoryStorage::new();
1885        store
1886            .insert_output(&make_output("a", 0, "tm_test", 1.0))
1887            .await
1888            .unwrap();
1889        store
1890            .insert_output(&make_output("b", 0, "tm_test", 2.0))
1891            .await
1892            .unwrap();
1893        store
1894            .insert_output(&make_output("c", 0, "tm_test", 3.0))
1895            .await
1896            .unwrap();
1897        store
1898            .insert_output(&make_output("d", 0, "tm_other", 4.0))
1899            .await
1900            .unwrap();
1901
1902        // Mark one as spent
1903        store.mark_utxo_as_spent("b", 0, "tm_test").await.unwrap();
1904
1905        // All unspent for tm_test
1906        let results = store
1907            .find_utxos_for_topic("tm_test", None, None, false)
1908            .await
1909            .unwrap();
1910        assert_eq!(results.len(), 2); // a and c (b is spent)
1911
1912        // With since filter
1913        let results = store
1914            .find_utxos_for_topic("tm_test", Some(2.0), None, false)
1915            .await
1916            .unwrap();
1917        assert_eq!(results.len(), 1); // only c (score 3.0 >= 2.0, a is 1.0)
1918
1919        // With limit
1920        let results = store
1921            .find_utxos_for_topic("tm_test", None, Some(1), false)
1922            .await
1923            .unwrap();
1924        assert_eq!(results.len(), 1);
1925        assert_eq!(results[0].txid, "a"); // lowest score first
1926    }
1927
1928    #[tokio::test]
1929    async fn test_find_utxos_for_topic_sorted_by_score() {
1930        let store = MemoryStorage::new();
1931        store
1932            .insert_output(&make_output("c", 0, "tm_test", 30.0))
1933            .await
1934            .unwrap();
1935        store
1936            .insert_output(&make_output("a", 0, "tm_test", 10.0))
1937            .await
1938            .unwrap();
1939        store
1940            .insert_output(&make_output("b", 0, "tm_test", 20.0))
1941            .await
1942            .unwrap();
1943
1944        let results = store
1945            .find_utxos_for_topic("tm_test", None, None, false)
1946            .await
1947            .unwrap();
1948        assert_eq!(results[0].txid, "a");
1949        assert_eq!(results[1].txid, "b");
1950        assert_eq!(results[2].txid, "c");
1951    }
1952
1953    #[tokio::test]
1954    async fn test_applied_transaction() {
1955        let store = MemoryStorage::new();
1956        let tx = AppliedTransaction {
1957            txid: "abc".to_string(),
1958            topic: "tm_test".to_string(),
1959        };
1960
1961        assert!(!store.does_applied_transaction_exist(&tx).await.unwrap());
1962        store.insert_applied_transaction(&tx).await.unwrap();
1963        assert!(store.does_applied_transaction_exist(&tx).await.unwrap());
1964
1965        // Different topic — should not exist
1966        let tx2 = AppliedTransaction {
1967            txid: "abc".to_string(),
1968            topic: "tm_other".to_string(),
1969        };
1970        assert!(!store.does_applied_transaction_exist(&tx2).await.unwrap());
1971    }
1972
1973    #[tokio::test]
1974    async fn test_sync_state() {
1975        let store = MemoryStorage::new();
1976
1977        assert_eq!(
1978            store
1979                .get_last_interaction("host1", "tm_test")
1980                .await
1981                .unwrap(),
1982            0
1983        );
1984
1985        store
1986            .update_last_interaction("host1", "tm_test", 100)
1987            .await
1988            .unwrap();
1989        assert_eq!(
1990            store
1991                .get_last_interaction("host1", "tm_test")
1992                .await
1993                .unwrap(),
1994            100
1995        );
1996
1997        // Update (upsert)
1998        store
1999            .update_last_interaction("host1", "tm_test", 200)
2000            .await
2001            .unwrap();
2002        assert_eq!(
2003            store
2004                .get_last_interaction("host1", "tm_test")
2005                .await
2006                .unwrap(),
2007            200
2008        );
2009
2010        // Different host — independent
2011        assert_eq!(
2012            store
2013                .get_last_interaction("host2", "tm_test")
2014                .await
2015                .unwrap(),
2016            0
2017        );
2018    }
2019
2020    #[tokio::test]
2021    async fn test_find_outputs_by_outpoints_batch() {
2022        let store = MemoryStorage::new();
2023        store
2024            .insert_output(&make_output("a", 0, "tm_test", 1.0))
2025            .await
2026            .unwrap();
2027        store
2028            .insert_output(&make_output("b", 0, "tm_test", 2.0))
2029            .await
2030            .unwrap();
2031        store
2032            .insert_output(&make_output("c", 0, "tm_test", 3.0))
2033            .await
2034            .unwrap();
2035
2036        let outpoints = vec![
2037            Outpoint::new("a", 0),
2038            Outpoint::new("c", 0),
2039            Outpoint::new("missing", 0),
2040        ];
2041        let results = store
2042            .find_outputs_by_outpoints(&outpoints, false)
2043            .await
2044            .unwrap();
2045        assert_eq!(results.len(), 2); // a and c found, missing skipped
2046    }
2047
2048    #[tokio::test]
2049    async fn test_find_output_without_topic() {
2050        let store = MemoryStorage::new();
2051        store
2052            .insert_output(&make_output("abc", 0, "tm_test", 1.0))
2053            .await
2054            .unwrap();
2055
2056        // Find without specifying topic
2057        let found = store
2058            .find_output("abc", 0, None, None, false)
2059            .await
2060            .unwrap();
2061        assert!(found.is_some());
2062        assert_eq!(found.unwrap().topic, "tm_test");
2063    }
2064}