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