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    // The sync's page (engine 0.2.2)
475    // ========================================================================
476
477    /// Apply a GASP page's writes as ONE unit, in order, and answer the
478    /// statements it ran. A backend that can make it atomic should (D1's
479    /// `batch()`); this default applies them one at a time and stops at the
480    /// earliest fault, which is safe because every page write is idempotent and
481    /// the engine keeps the whole page queued until this answers `Ok`. A
482    /// [`crate::page_batch::PageWrite::Backend`] statement belongs to the
483    /// backend that deferred it: a backend that defers none never sees one,
484    /// and this default refuses it.
485    async fn write_batch(
486        &self,
487        writes: &[crate::page_batch::PageWrite],
488    ) -> Result<u64, StorageError> {
489        use crate::page_batch::PageWrite;
490        for write in writes {
491            match write {
492                PageWrite::Output { output, gasp } => {
493                    self.insert_output_with_origin(output, *gasp).await?;
494                }
495                PageWrite::Applied(tx) => self.insert_applied_transaction(tx).await?,
496                PageWrite::Backend(_) => {
497                    return Err(StorageError::Other(
498                        "a backend statement this storage did not defer".into(),
499                    ))
500                }
501            }
502        }
503        Ok(writes.len() as u64)
504    }
505
506    /// Of `outpoints` (`(txid, output_index)`), the outputs held in `topic`,
507    /// without BEEF, spent or not: `find_output(txid, vout, Some(topic),
508    /// None, false)` for a page at once. Default: one read each.
509    async fn find_topic_outputs(
510        &self,
511        topic: &str,
512        outpoints: &[(String, u32)],
513    ) -> Result<Vec<Output>, StorageError> {
514        let mut held = Vec::new();
515        for (txid, vout) in outpoints {
516            if let Some(output) = self
517                .find_output(txid, *vout, Some(topic), None, false)
518                .await?
519            {
520                held.push(output);
521            }
522        }
523        Ok(held)
524    }
525
526    /// Of `txids`, those with an applied row in `topic`. Default: one read
527    /// each.
528    async fn find_applied_transactions(
529        &self,
530        topic: &str,
531        txids: &[String],
532    ) -> Result<Vec<String>, StorageError> {
533        let mut applied = Vec::new();
534        for txid in txids {
535            let tx = AppliedTransaction {
536                txid: txid.clone(),
537                topic: topic.to_string(),
538            };
539            if self.does_applied_transaction_exist(&tx).await? {
540                applied.push(txid.clone());
541            }
542        }
543        Ok(applied)
544    }
545}
546
547/// A (host, topic)'s yieldless streak (bsv-low #555, the delta-2 fold's
548/// D2-M1) — the input to [`crate::gasp::yieldless_sync_failed`].
549#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
550pub struct PeerYieldStreak {
551    /// Consecutive yieldless syncs (0 after a yield).
552    pub yieldless_syncs: u64,
553    /// Seconds since the streak's first yieldless sync. `None` = no streak,
554    /// or a backend with no clock: never past the time bound.
555    pub secs_since_first: Option<u64>,
556}
557
558/// Durable per-(host, topic) GASP sync health (bsv-low#302) — the input to
559/// the pure quarantine rule [`crate::gasp::peer_sync_quarantined`].
560#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
561pub struct PeerSyncHealth {
562    /// Consecutive sync attempts that ALL failed (timeout counts as a
563    /// failure). Reset to 0 by any successful sync.
564    pub consecutive_failures: u64,
565    /// Seconds since the last recorded sync ATTEMPT (success or failure).
566    /// `None` = never attempted / no health tracked — never quarantined.
567    pub secs_since_last_attempt: Option<u64>,
568}
569
570// ============================================================================
571// Rc delegation
572// ============================================================================
573
574/// Delegating impl so an `Rc<T: Storage>` is itself a `Storage` — lets a
575/// caller keep a shared handle to the concrete backend (e.g. a test advancing
576/// [`memory::MemoryStorage::advance_clock`]) while the engine owns a
577/// `Box<dyn Storage>` over the same instance. Pure delegation, no semantics.
578#[async_trait(?Send)]
579impl<T: Storage + ?Sized> Storage for std::rc::Rc<T> {
580    async fn write_batch(
581        &self,
582        writes: &[crate::page_batch::PageWrite],
583    ) -> Result<u64, StorageError> {
584        (**self).write_batch(writes).await
585    }
586    async fn find_topic_outputs(
587        &self,
588        topic: &str,
589        outpoints: &[(String, u32)],
590    ) -> Result<Vec<Output>, StorageError> {
591        (**self).find_topic_outputs(topic, outpoints).await
592    }
593    async fn find_applied_transactions(
594        &self,
595        topic: &str,
596        txids: &[String],
597    ) -> Result<Vec<String>, StorageError> {
598        (**self).find_applied_transactions(topic, txids).await
599    }
600    async fn insert_output_with_origin(
601        &self,
602        output: &Output,
603        gasp: bool,
604    ) -> Result<(), StorageError> {
605        (**self).insert_output_with_origin(output, gasp).await
606    }
607    async fn read_fault_memos(
608        &self,
609        host: &str,
610        topic: &str,
611        outpoints: Option<&[String]>,
612    ) -> Result<crate::fault_memo::FaultMemoBatch, StorageError> {
613        (**self).read_fault_memos(host, topic, outpoints).await
614    }
615
616    async fn get_peer_cadence(
617        &self,
618        host: &str,
619        topic: &str,
620    ) -> Result<crate::sync_load::PeerCadence, StorageError> {
621        (**self).get_peer_cadence(host, topic).await
622    }
623    async fn set_peer_cadence(
624        &self,
625        host: &str,
626        topic: &str,
627        state: &crate::sync_load::PeerCadence,
628    ) -> Result<(), StorageError> {
629        (**self).set_peer_cadence(host, topic, state).await
630    }
631    async fn insert_output(&self, output: &Output) -> Result<(), StorageError> {
632        (**self).insert_output(output).await
633    }
634    async fn delete_output(
635        &self,
636        txid: &str,
637        output_index: u32,
638        topic: &str,
639    ) -> Result<(), StorageError> {
640        (**self).delete_output(txid, output_index, topic).await
641    }
642    async fn mark_utxo_as_spent(
643        &self,
644        txid: &str,
645        output_index: u32,
646        topic: &str,
647    ) -> Result<(), StorageError> {
648        (**self).mark_utxo_as_spent(txid, output_index, topic).await
649    }
650    async fn update_consumed_by(
651        &self,
652        txid: &str,
653        output_index: u32,
654        topic: &str,
655        consumed_by: &[Outpoint],
656    ) -> Result<(), StorageError> {
657        (**self)
658            .update_consumed_by(txid, output_index, topic, consumed_by)
659            .await
660    }
661    async fn update_transaction_beef(&self, txid: &str, beef: &[u8]) -> Result<(), StorageError> {
662        (**self).update_transaction_beef(txid, beef).await
663    }
664    async fn mark_transaction_proven(&self, txid: &str) -> Result<(), StorageError> {
665        (**self).mark_transaction_proven(txid).await
666    }
667    async fn mark_transaction_proven_at(
668        &self,
669        txid: &str,
670        height: Option<u64>,
671    ) -> Result<(), StorageError> {
672        // review LOW-4: forward the anchored latch too — without this the
673        // Rc blanket hits the trait default (drops the height, re-introducing
674        // MED-2) for any caller routing through an `Rc<dyn Storage>`.
675        (**self).mark_transaction_proven_at(txid, height).await
676    }
677    async fn update_output_block_height(
678        &self,
679        txid: &str,
680        output_index: u32,
681        topic: &str,
682        block_height: u32,
683    ) -> Result<(), StorageError> {
684        (**self)
685            .update_output_block_height(txid, output_index, topic, block_height)
686            .await
687    }
688    async fn insert_applied_transaction(
689        &self,
690        tx: &AppliedTransaction,
691    ) -> Result<(), StorageError> {
692        (**self).insert_applied_transaction(tx).await
693    }
694    async fn does_applied_transaction_exist(
695        &self,
696        tx: &AppliedTransaction,
697    ) -> Result<bool, StorageError> {
698        (**self).does_applied_transaction_exist(tx).await
699    }
700    async fn delete_applied_transaction(
701        &self,
702        tx: &AppliedTransaction,
703    ) -> Result<(), StorageError> {
704        (**self).delete_applied_transaction(tx).await
705    }
706    async fn find_output(
707        &self,
708        txid: &str,
709        output_index: u32,
710        topic: Option<&str>,
711        spent: Option<bool>,
712        include_beef: bool,
713    ) -> Result<Option<Output>, StorageError> {
714        (**self)
715            .find_output(txid, output_index, topic, spent, include_beef)
716            .await
717    }
718    async fn find_served_outputs(
719        &self,
720        outpoints: &[Outpoint],
721    ) -> Result<Vec<Output>, StorageError> {
722        (**self).find_served_outputs(outpoints).await
723    }
724
725    async fn find_outputs_for_transaction(
726        &self,
727        txid: &str,
728        include_beef: bool,
729    ) -> Result<Vec<Output>, StorageError> {
730        (**self)
731            .find_outputs_for_transaction(txid, include_beef)
732            .await
733    }
734    async fn find_utxos_for_topic(
735        &self,
736        topic: &str,
737        since: Option<f64>,
738        limit: Option<u64>,
739        include_beef: bool,
740    ) -> Result<Vec<Output>, StorageError> {
741        (**self)
742            .find_utxos_for_topic(topic, since, limit, include_beef)
743            .await
744    }
745    async fn find_transactions_for_proof_check(
746        &self,
747        limit: u64,
748        min_age_secs: u64,
749    ) -> Result<Vec<TransactionBeef>, StorageError> {
750        (**self)
751            .find_transactions_for_proof_check(limit, min_age_secs)
752            .await
753    }
754    async fn update_last_interaction(
755        &self,
756        host: &str,
757        topic: &str,
758        since: u64,
759    ) -> Result<(), StorageError> {
760        (**self).update_last_interaction(host, topic, since).await
761    }
762    async fn get_last_interaction(&self, host: &str, topic: &str) -> Result<u64, StorageError> {
763        (**self).get_last_interaction(host, topic).await
764    }
765    async fn record_peer_sync_outcome(
766        &self,
767        host: &str,
768        topic: &str,
769        success: bool,
770    ) -> Result<(), StorageError> {
771        (**self)
772            .record_peer_sync_outcome(host, topic, success)
773            .await
774    }
775    async fn get_peer_sync_health(
776        &self,
777        host: &str,
778        topic: &str,
779    ) -> Result<PeerSyncHealth, StorageError> {
780        (**self).get_peer_sync_health(host, topic).await
781    }
782    async fn record_peer_sync_yield(
783        &self,
784        host: &str,
785        topic: &str,
786        yielded: bool,
787    ) -> Result<PeerYieldStreak, StorageError> {
788        (**self).record_peer_sync_yield(host, topic, yielded).await
789    }
790    async fn put_deferred_graph(
791        &self,
792        record: &crate::gasp::DeferredGraph,
793    ) -> Result<crate::gasp::DeferredGraphSave, StorageError> {
794        (**self).put_deferred_graph(record).await
795    }
796    async fn reserve_unanchorable_retry(
797        &self,
798        record: &crate::gasp::DeferredGraph,
799    ) -> Result<bool, StorageError> {
800        (**self).reserve_unanchorable_retry(record).await
801    }
802    async fn find_deferred_graphs(
803        &self,
804        host: &str,
805        topic: &str,
806    ) -> Result<Vec<crate::gasp::DeferredGraphKey>, StorageError> {
807        (**self).find_deferred_graphs(host, topic).await
808    }
809    async fn get_deferred_graph(
810        &self,
811        host: &str,
812        topic: &str,
813        outpoint: &str,
814    ) -> Result<Option<crate::gasp::DeferredGraph>, StorageError> {
815        (**self).get_deferred_graph(host, topic, outpoint).await
816    }
817    async fn delete_deferred_graph(
818        &self,
819        host: &str,
820        topic: &str,
821        outpoint: &str,
822    ) -> Result<(), StorageError> {
823        (**self).delete_deferred_graph(host, topic, outpoint).await
824    }
825    async fn remember_expired_outpoint(
826        &self,
827        topic: &str,
828        outpoint: &str,
829        expiry: u64,
830    ) -> Result<(), StorageError> {
831        (**self)
832            .remember_expired_outpoint(topic, outpoint, expiry)
833            .await
834    }
835    async fn find_expired_outpoints(
836        &self,
837        topic: &str,
838        outpoints: &[String],
839    ) -> Result<Vec<String>, StorageError> {
840        (**self).find_expired_outpoints(topic, outpoints).await
841    }
842}
843
844// ============================================================================
845// Error type
846// ============================================================================
847
848/// Storage operation errors.
849#[derive(Debug, thiserror::Error)]
850pub enum StorageError {
851    #[error("not found: {0}")]
852    NotFound(String),
853
854    #[error("duplicate: {0}")]
855    Duplicate(String),
856
857    #[error("database error: {0}")]
858    Database(String),
859
860    #[error("serialization error: {0}")]
861    Serialization(String),
862
863    #[error("{0}")]
864    Other(String),
865}
866
867// ============================================================================
868// In-memory storage (for tests and local dev)
869// ============================================================================
870
871#[cfg(any(test, feature = "memory-storage"))]
872pub mod memory {
873    use super::*;
874    use std::collections::HashMap;
875    use std::sync::Mutex;
876
877    /// In-memory Storage implementation for testing and local development.
878    ///
879    /// NOT suitable for production — no persistence, no concurrency beyond Mutex.
880    #[derive(Debug, Default)]
881    pub struct MemoryStorage {
882        fault_memos: Mutex<HashMap<(String, String, String), crate::fault_memo::FaultMemo>>,
883        fault_memo_ceiling: Mutex<Option<usize>>,
884        gasp_transactions: Mutex<std::collections::HashSet<String>>,
885        cadence: Mutex<HashMap<(String, String), (crate::sync_load::PeerCadence, u64)>>,
886        /// outputs keyed by (txid, outputIndex, topic)
887        outputs: Mutex<HashMap<(String, u32, String), Output>>,
888        /// BEEF data keyed by txid
889        transactions: Mutex<HashMap<String, Vec<u8>>>,
890        /// txids whose `has_proof` flag is set. Models the real D1
891        /// `transactions.has_proof` column (overlay migration 0010), which is
892        /// the source of truth for `find_transactions_for_proof_check` — NOT a
893        /// live re-parse of the BEEF. A txid is absent here (flag = 0) until
894        /// something explicitly proves it, so a BEEF that already carries a
895        /// proof but is not in this set is still returned as a candidate — the
896        /// exact window-clog the cron must clear via `mark_transaction_proven`.
897        proven: Mutex<std::collections::HashSet<String>>,
898        /// applied transactions keyed by (txid, topic)
899        applied: Mutex<HashMap<(String, String), bool>>,
900        /// GASP sync state keyed by (host, topic)
901        sync_state: Mutex<HashMap<(String, String), u64>>,
902        /// Deterministic logical clock (seconds) for the push-primary backstop
903        /// age gate — models the D1 backend's `unixepoch()`. Tests advance it
904        /// with [`MemoryStorage::advance_clock`]; no wall clock is ever read
905        /// (wasm-safe, deterministic).
906        clock_secs: Mutex<u64>,
907        /// First-store stamp (clock seconds) per txid — models the D1
908        /// `transactions.created_at` column. A txid ABSENT here has UNKNOWN
909        /// age and is treated as OLD (eligible) — the fail-safe direction.
910        created_at: Mutex<HashMap<String, u64>>,
911        /// GASP peer health keyed by (host, topic) —
912        /// `(consecutive_failures, last_attempt_at_clock_secs)`. Models the
913        /// D1 `gasp_peer_health` table (bsv-low#302); the logical clock
914        /// above stands in for `unixepoch()`.
915        peer_health: Mutex<HashMap<(String, String), (u64, u64)>>,
916        /// review LOW-4: records the height an `_at` latch carried, so a pin
917        /// can prove the `Rc` blanket forwards `mark_transaction_proven_at`
918        /// (the trait default would drop the height).
919        proven_at: Mutex<HashMap<String, Option<u64>>>,
920        /// Deferred GASP graphs (bsv-low #555) keyed by (host, topic,
921        /// outpoint): models the D1 `gasp_deferred_graphs` table.
922        deferred_graphs: Mutex<HashMap<(String, String, String), crate::gasp::DeferredGraph>>,
923        /// How many times a deferred graph was WRITTEN (a pin counts the
924        /// storage writes of a deferral).
925        deferred_graph_writes: Mutex<u64>,
926        /// How many single records were READ (bsv-low #555, the lens
927        /// fold's L3).
928        deferred_graph_reads: Mutex<u64>,
929        /// Test knob: the key read of the deferred graphs faults.
930        deferred_graph_keys_fault: Mutex<bool>,
931        /// Test knob: a NEW record past this many held is refused
932        /// `AtCeiling` (the worker's global ceiling).
933        deferred_graph_ceiling: Mutex<Option<usize>>,
934        /// Test knob: a save that would take the bytes of every record held
935        /// past this is refused `AtCeiling`, a held key's too (the worker's
936        /// byte bounds, bsv-low #585).
937        deferred_graph_byte_ceiling: Mutex<Option<usize>>,
938        /// Test knob: every save of a deferred graph faults.
939        deferred_graph_put_fault: Mutex<bool>,
940        /// Expired GASP roots (engine 0.1.8) keyed by (topic, outpoint):
941        /// `(keep_until, written_at)` on the logical clock. Models the D1
942        /// `gasp_expired_outpoints` table.
943        expired_outpoints: Mutex<HashMap<(String, String), (u64, u64)>>,
944        /// How many saves of a deferred graph were ASKED (written, refused
945        /// or faulted).
946        deferred_graph_put_attempts: Mutex<u64>,
947        /// The yieldless streak keyed by (host, topic): `(count,
948        /// first_at, last_at)` on the logical clock. Models the D1
949        /// `gasp_peer_health` columns `yieldless_syncs`, `first_yieldless_at`
950        /// and `last_yieldless_at` (bsv-low #555, the delta fold's D-M2 and
951        /// the delta-2 fold's D2-M1).
952        peer_yieldless: Mutex<HashMap<(String, String), YieldStreakRow>>,
953    }
954
955    /// `(count, first_at, last_at)` of a yieldless streak, on the logical
956    /// clock.
957    type YieldStreakRow = (u64, u64, u64);
958
959    impl MemoryStorage {
960        pub fn fault_memos(&self) -> Vec<crate::fault_memo::FaultMemo> {
961            self.fault_memos.lock().unwrap().values().cloned().collect()
962        }
963
964        pub fn set_fault_memo_ceiling(&self, ceiling: Option<usize>) {
965            *self.fault_memo_ceiling.lock().unwrap() = ceiling;
966        }
967
968        pub fn seed_legacy_fault(&self, record: crate::gasp::DeferredGraph) {
969            self.deferred_graphs.lock().unwrap().insert(
970                (
971                    record.peer.clone(),
972                    record.topic.clone(),
973                    record.outpoint.clone(),
974                ),
975                record,
976            );
977        }
978
979        fn cap_fault_memos(
980            held: &mut HashMap<(String, String, String), crate::fault_memo::FaultMemo>,
981            topic: &str,
982        ) {
983            if held.len() <= crate::fault_memo::FAULT_MEMO_MAX_ROWS {
984                return;
985            }
986            let mut keys: Vec<_> = held
987                .iter()
988                .filter(|(_, r)| r.topic == topic)
989                .map(|(k, r)| (r.first_at, k.clone()))
990                .collect();
991            let excess = keys
992                .len()
993                .saturating_sub(crate::fault_memo::FAULT_MEMO_MAX_ROWS);
994            if excess > 0 {
995                keys.sort();
996                for (_, key) in keys.into_iter().take(excess) {
997                    held.remove(&key);
998                }
999            }
1000        }
1001
1002        pub fn new() -> Self {
1003            Self::default()
1004        }
1005
1006        /// The anchor height a `mark_transaction_proven_at` recorded for
1007        /// `txid` (review LOW-4): `None` = never called `_at`; `Some(h)` =
1008        /// called with height `h`.
1009        pub fn proven_at_height(&self, txid: &str) -> Option<Option<u64>> {
1010            self.proven_at.lock().unwrap().get(txid).copied()
1011        }
1012
1013        /// Advance the deterministic logical clock by `secs` (test hook for
1014        /// the push-primary backstop age gate — see
1015        /// [`Storage::find_transactions_for_proof_check`]).
1016        /// The expired roots remembered for `topic` and not yet swept.
1017        pub fn expired_outpoints(&self, topic: &str) -> Vec<String> {
1018            let now = *self.clock_secs.lock().unwrap();
1019            let mut held: Vec<String> = self
1020                .expired_outpoints
1021                .lock()
1022                .unwrap()
1023                .iter()
1024                .filter(|((t, _), (keep, _))| t == topic && *keep > now)
1025                .map(|((_, o), _)| o.clone())
1026                .collect();
1027            held.sort();
1028            held
1029        }
1030
1031        pub fn advance_clock(&self, secs: u64) {
1032            *self.clock_secs.lock().unwrap() += secs;
1033        }
1034
1035        /// Stamp `txid`'s first-store time at the current clock if absent
1036        /// (models `COALESCE(existing, unixepoch())` in the D1 backend).
1037        fn stamp_created(&self, txid: &str) {
1038            let now = *self.clock_secs.lock().unwrap();
1039            self.created_at
1040                .lock()
1041                .unwrap()
1042                .entry(txid.to_string())
1043                .or_insert(now);
1044        }
1045
1046        /// Whether `txid` clears the backstop age gate: unknown age is OLD
1047        /// (eligible, fail-safe); otherwise `clock - created >= min_age_secs`.
1048        fn age_gate_open(&self, txid: &str, min_age_secs: u64) -> bool {
1049            if min_age_secs == 0 {
1050                return true;
1051            }
1052            let now = *self.clock_secs.lock().unwrap();
1053            match self.created_at.lock().unwrap().get(txid) {
1054                None => true, // unknown age → treated old → eligible
1055                Some(created) => now.saturating_sub(*created) >= min_age_secs,
1056            }
1057        }
1058
1059        /// Every deferred GASP graph held, any peer or topic (bsv-low #555).
1060        pub fn deferred_graphs(&self) -> Vec<crate::gasp::DeferredGraph> {
1061            let mut all: Vec<_> = self
1062                .deferred_graphs
1063                .lock()
1064                .unwrap()
1065                .values()
1066                .cloned()
1067                .collect();
1068            all.sort_by(|a, b| (a.score, &a.outpoint).cmp(&(b.score, &b.outpoint)));
1069            all
1070        }
1071
1072        /// How many deferred-graph writes were made (bsv-low #555).
1073        pub fn deferred_graph_writes(&self) -> u64 {
1074            *self.deferred_graph_writes.lock().unwrap()
1075        }
1076
1077        /// How many single records were read (bsv-low #555).
1078        pub fn deferred_graph_reads(&self) -> u64 {
1079            *self.deferred_graph_reads.lock().unwrap()
1080        }
1081
1082        /// Make the key read of the deferred graphs fault, or not (a test
1083        /// knob, bsv-low #555).
1084        pub fn set_deferred_graph_keys_fault(&self, fault: bool) {
1085            *self.deferred_graph_keys_fault.lock().unwrap() = fault;
1086        }
1087
1088        /// Refuse a NEW record `AtCeiling` once this many are held, as the
1089        /// worker's global ceiling does (a test knob, bsv-low #555).
1090        pub fn set_deferred_graph_ceiling(&self, ceiling: Option<usize>) {
1091            *self.deferred_graph_ceiling.lock().unwrap() = ceiling;
1092        }
1093
1094        /// Refuse `AtCeiling` a save, new or a replacement, that would take
1095        /// the bytes of every record held past this, as the worker's byte
1096        /// bounds do (a test knob, bsv-low #585). The held record stays.
1097        pub fn set_deferred_graph_byte_ceiling(&self, ceiling: Option<usize>) {
1098            *self.deferred_graph_byte_ceiling.lock().unwrap() = ceiling;
1099        }
1100
1101        /// Make every save of a deferred graph fault, or not (a test knob,
1102        /// bsv-low #555's delta fold, D-L2).
1103        pub fn set_deferred_graph_put_fault(&self, fault: bool) {
1104            *self.deferred_graph_put_fault.lock().unwrap() = fault;
1105        }
1106
1107        /// How many saves of a deferred graph were asked, whatever their
1108        /// answer (bsv-low #555's delta fold, D-L1).
1109        pub fn deferred_graph_put_attempts(&self) -> u64 {
1110            *self.deferred_graph_put_attempts.lock().unwrap()
1111        }
1112
1113        /// The consecutive yieldless syncs held for (host, topic)
1114        /// (bsv-low #555's delta fold, D-M2). `host` may be a peer URL: it
1115        /// is read under its [`crate::gasp::peer_origin`], the key the
1116        /// engine writes (the delta-2 fold's D2-M2).
1117        pub fn peer_yieldless_syncs(&self, host: &str, topic: &str) -> u64 {
1118            self.peer_yieldless
1119                .lock()
1120                .unwrap()
1121                .get(&(crate::gasp::peer_origin(host), topic.to_string()))
1122                .map_or(0, |(count, _, _)| *count)
1123        }
1124
1125        /// Count total outputs (for testing assertions).
1126        pub fn output_count(&self) -> usize {
1127            self.outputs.lock().unwrap().len()
1128        }
1129
1130        /// Count total transactions (for testing assertions).
1131        pub fn transaction_count(&self) -> usize {
1132            self.transactions.lock().unwrap().len()
1133        }
1134    }
1135
1136    #[async_trait(?Send)]
1137    impl Storage for MemoryStorage {
1138        async fn insert_output_with_origin(
1139            &self,
1140            output: &Output,
1141            gasp: bool,
1142        ) -> Result<(), StorageError> {
1143            self.insert_output(output).await?;
1144            if gasp && output.beef.is_some() {
1145                self.gasp_transactions
1146                    .lock()
1147                    .unwrap()
1148                    .insert(output.txid.clone());
1149            }
1150            Ok(())
1151        }
1152
1153        async fn read_fault_memos(
1154            &self,
1155            host: &str,
1156            topic: &str,
1157            outpoints: Option<&[String]>,
1158        ) -> Result<crate::fault_memo::FaultMemoBatch, StorageError> {
1159            use crate::fault_memo::{FaultMemo, FaultMemoBatch, FAULT_MEMO_DUE_LIMIT};
1160            let now = (crate::engine::current_timestamp_ms() / 1000.0) as u64;
1161            let mut batch = FaultMemoBatch::default();
1162            let mut held = self.fault_memos.lock().unwrap();
1163            if outpoints.is_none() {
1164                let mut legacy = self.deferred_graphs.lock().unwrap();
1165                legacy.retain(|key, record| {
1166                    if record.peer != host || record.topic != topic {
1167                        return true;
1168                    }
1169                    let Some(memo) = FaultMemo::from_record(record, now) else {
1170                        return true;
1171                    };
1172                    held.entry(key.clone()).or_insert(memo);
1173                    batch.folded += 1;
1174                    false
1175                });
1176                Self::cap_fault_memos(&mut held, topic);
1177                let mut expired = self.expired_outpoints.lock().unwrap();
1178                let logical_now = *self.clock_secs.lock().unwrap();
1179                for r in held.values().filter(|r| {
1180                    r.peer == host
1181                        && r.topic == topic
1182                        && (r.dead || r.unanchorable)
1183                        && r.expiry.is_some_and(|t| t <= now)
1184                }) {
1185                    expired.insert(
1186                        (topic.into(), r.outpoint.clone()),
1187                        (
1188                            logical_now + crate::gasp::EXPIRED_OUTPOINT_MEMO_SECS,
1189                            logical_now,
1190                        ),
1191                    );
1192                }
1193                held.retain(|_, r| {
1194                    let remove = r.peer == host
1195                        && r.topic == topic
1196                        && (r.dead || r.unanchorable)
1197                        && (r.expiry.is_some_and(|t| t <= now)
1198                            || expired
1199                                .get(&(topic.into(), r.outpoint.clone()))
1200                                .is_some_and(|(until, _)| *until > logical_now));
1201                    if remove {
1202                        batch.swept += 1;
1203                    }
1204                    !remove
1205                });
1206            }
1207            batch.records = held
1208                .values()
1209                .filter(|r| r.peer == host && r.topic == topic)
1210                .filter(|r| {
1211                    outpoints.map_or(!r.dead && r.next_at <= now, |keys| {
1212                        keys.contains(&r.outpoint)
1213                    })
1214                })
1215                .cloned()
1216                .collect();
1217            batch.records.sort_by(|a, b| {
1218                (a.next_at, a.score, &a.outpoint).cmp(&(b.next_at, b.score, &b.outpoint))
1219            });
1220            if outpoints.is_none() {
1221                batch.records.truncate(FAULT_MEMO_DUE_LIMIT);
1222            }
1223            Ok(batch)
1224        }
1225
1226        async fn insert_output(&self, output: &Output) -> Result<(), StorageError> {
1227            let key = (
1228                output.txid.clone(),
1229                output.output_index,
1230                output.topic.clone(),
1231            );
1232            let mut outputs = self.outputs.lock().unwrap();
1233
1234            // No-op if already exists (match KnexStorage behavior)
1235            if outputs.contains_key(&key) {
1236                // Still upsert BEEF if provided
1237                if let Some(ref beef) = output.beef {
1238                    self.transactions
1239                        .lock()
1240                        .unwrap()
1241                        .entry(output.txid.clone())
1242                        .or_insert_with(|| beef.clone());
1243                }
1244                return Ok(());
1245            }
1246
1247            // Store output without BEEF (BEEF goes in transactions table)
1248            let mut stored = output.clone();
1249            stored.beef = None;
1250            outputs.insert(key, stored);
1251
1252            // Upsert BEEF into transactions table
1253            if let Some(ref beef) = output.beef {
1254                self.transactions
1255                    .lock()
1256                    .unwrap()
1257                    .entry(output.txid.clone())
1258                    .or_insert_with(|| beef.clone());
1259                self.stamp_created(&output.txid);
1260            }
1261
1262            Ok(())
1263        }
1264
1265        async fn delete_output(
1266            &self,
1267            txid: &str,
1268            output_index: u32,
1269            topic: &str,
1270        ) -> Result<(), StorageError> {
1271            let key = (txid.to_string(), output_index, topic.to_string());
1272            let mut outputs = self.outputs.lock().unwrap();
1273            outputs.remove(&key);
1274
1275            // If no more outputs reference this txid, remove the BEEF
1276            let has_remaining = outputs.keys().any(|(t, _, _)| t == txid);
1277            if !has_remaining {
1278                self.transactions.lock().unwrap().remove(txid);
1279                self.proven.lock().unwrap().remove(txid);
1280                self.gasp_transactions.lock().unwrap().remove(txid);
1281            }
1282
1283            Ok(())
1284        }
1285
1286        async fn mark_utxo_as_spent(
1287            &self,
1288            txid: &str,
1289            output_index: u32,
1290            topic: &str,
1291        ) -> Result<(), StorageError> {
1292            let key = (txid.to_string(), output_index, topic.to_string());
1293            if let Some(output) = self.outputs.lock().unwrap().get_mut(&key) {
1294                output.spent = true;
1295            }
1296            Ok(())
1297        }
1298
1299        async fn update_consumed_by(
1300            &self,
1301            txid: &str,
1302            output_index: u32,
1303            topic: &str,
1304            consumed_by: &[Outpoint],
1305        ) -> Result<(), StorageError> {
1306            let key = (txid.to_string(), output_index, topic.to_string());
1307            if let Some(output) = self.outputs.lock().unwrap().get_mut(&key) {
1308                output.consumed_by = consumed_by.to_vec();
1309            }
1310            Ok(())
1311        }
1312
1313        async fn update_transaction_beef(
1314            &self,
1315            txid: &str,
1316            beef: &[u8],
1317        ) -> Result<(), StorageError> {
1318            // Model the D1 backend: keep the proof flag accurate on every BEEF
1319            // write by inspecting whether the new BEEF proves the target tx.
1320            // (The proof-completion stitch calls back here with a proven BEEF,
1321            // which is how a row legitimately flips proofless → proven.)
1322            // A streaming fold: one element in hand, no `Beef` built.
1323            let has_proof = beef_limits::has_proof(beef, txid);
1324            if has_proof {
1325                self.proven.lock().unwrap().insert(txid.to_string());
1326            }
1327            self.transactions
1328                .lock()
1329                .unwrap()
1330                .insert(txid.to_string(), beef.to_vec());
1331            // Preserve-or-stamp: a rewrite keeps the ORIGINAL first-store
1332            // time (models the D1 COALESCE), so the backstop age stays real.
1333            self.stamp_created(txid);
1334            Ok(())
1335        }
1336
1337        async fn mark_transaction_proven(&self, txid: &str) -> Result<(), StorageError> {
1338            // Lightweight flag-flip (no BEEF rewrite), idempotent. Mirrors the
1339            // D1 `UPDATE transactions SET has_proof = 1 WHERE txid = ?`.
1340            self.proven.lock().unwrap().insert(txid.to_string());
1341            Ok(())
1342        }
1343
1344        async fn mark_transaction_proven_at(
1345            &self,
1346            txid: &str,
1347            height: Option<u64>,
1348        ) -> Result<(), StorageError> {
1349            // Models the D1 `MARK_TX_PROVEN_AT_SQL`: flip has_proof AND record
1350            // the anchor (review MED-2/LOW-4). Recorded so the Rc-forward pin
1351            // can observe the height reaching the inner store.
1352            self.proven.lock().unwrap().insert(txid.to_string());
1353            self.proven_at
1354                .lock()
1355                .unwrap()
1356                .insert(txid.to_string(), height);
1357            Ok(())
1358        }
1359
1360        async fn update_output_block_height(
1361            &self,
1362            txid: &str,
1363            output_index: u32,
1364            topic: &str,
1365            block_height: u32,
1366        ) -> Result<(), StorageError> {
1367            let key = (txid.to_string(), output_index, topic.to_string());
1368            if let Some(output) = self.outputs.lock().unwrap().get_mut(&key) {
1369                output.block_height = Some(block_height);
1370            }
1371            Ok(())
1372        }
1373
1374        async fn insert_applied_transaction(
1375            &self,
1376            tx: &AppliedTransaction,
1377        ) -> Result<(), StorageError> {
1378            self.applied
1379                .lock()
1380                .unwrap()
1381                .insert((tx.txid.clone(), tx.topic.clone()), true);
1382            Ok(())
1383        }
1384
1385        async fn does_applied_transaction_exist(
1386            &self,
1387            tx: &AppliedTransaction,
1388        ) -> Result<bool, StorageError> {
1389            Ok(self
1390                .applied
1391                .lock()
1392                .unwrap()
1393                .contains_key(&(tx.txid.clone(), tx.topic.clone())))
1394        }
1395
1396        async fn delete_applied_transaction(
1397            &self,
1398            tx: &AppliedTransaction,
1399        ) -> Result<(), StorageError> {
1400            self.applied
1401                .lock()
1402                .unwrap()
1403                .remove(&(tx.txid.clone(), tx.topic.clone()));
1404            Ok(())
1405        }
1406
1407        async fn find_output(
1408            &self,
1409            txid: &str,
1410            output_index: u32,
1411            topic: Option<&str>,
1412            spent: Option<bool>,
1413            include_beef: bool,
1414        ) -> Result<Option<Output>, StorageError> {
1415            let outputs = self.outputs.lock().unwrap();
1416
1417            // If topic is specified, do a direct lookup
1418            if let Some(topic) = topic {
1419                let key = (txid.to_string(), output_index, topic.to_string());
1420                if let Some(output) = outputs.get(&key) {
1421                    if let Some(s) = spent {
1422                        if output.spent != s {
1423                            return Ok(None);
1424                        }
1425                    }
1426                    let mut result = output.clone();
1427                    if include_beef {
1428                        result.beef = self.transactions.lock().unwrap().get(txid).cloned();
1429                    }
1430                    return Ok(Some(result));
1431                }
1432                return Ok(None);
1433            }
1434
1435            // No topic — find first matching (txid, outputIndex)
1436            for ((t, oi, _), output) in outputs.iter() {
1437                if t == txid && *oi == output_index {
1438                    if let Some(s) = spent {
1439                        if output.spent != s {
1440                            continue;
1441                        }
1442                    }
1443                    let mut result = output.clone();
1444                    if include_beef {
1445                        result.beef = self.transactions.lock().unwrap().get(txid).cloned();
1446                    }
1447                    return Ok(Some(result));
1448                }
1449            }
1450            Ok(None)
1451        }
1452
1453        async fn find_served_outputs(
1454            &self,
1455            outpoints: &[Outpoint],
1456        ) -> Result<Vec<Output>, StorageError> {
1457            let outputs = self.find_outputs_by_outpoints(outpoints, true).await?;
1458            let now = i64::try_from(*self.clock_secs.lock().unwrap())
1459                .unwrap_or(i64::MAX)
1460                .saturating_mul(1000);
1461            let proven = self.proven.lock().unwrap();
1462            let stamps = self.created_at.lock().unwrap();
1463            Ok(outputs
1464                .into_iter()
1465                .filter(|output| {
1466                    let seen = stamps
1467                        .get(&output.txid)
1468                        .and_then(|s| i64::try_from(*s).ok())
1469                        .map(|s| s.saturating_mul(1000));
1470                    crate::proof_window::verdict(proven.contains(&output.txid), seen, now)
1471                        == crate::proof_window::Verdict::Served
1472                })
1473                .collect())
1474        }
1475
1476        async fn find_outputs_for_transaction(
1477            &self,
1478            txid: &str,
1479            include_beef: bool,
1480        ) -> Result<Vec<Output>, StorageError> {
1481            let outputs = self.outputs.lock().unwrap();
1482            let beef = if include_beef {
1483                self.transactions.lock().unwrap().get(txid).cloned()
1484            } else {
1485                None
1486            };
1487
1488            let results: Vec<Output> = outputs
1489                .iter()
1490                .filter(|((t, _, _), _)| t == txid)
1491                .map(|(_, output)| {
1492                    let mut o = output.clone();
1493                    if include_beef {
1494                        o.beef.clone_from(&beef);
1495                    }
1496                    o
1497                })
1498                .collect();
1499
1500            Ok(results)
1501        }
1502
1503        async fn find_utxos_for_topic(
1504            &self,
1505            topic: &str,
1506            since: Option<f64>,
1507            limit: Option<u64>,
1508            include_beef: bool,
1509        ) -> Result<Vec<Output>, StorageError> {
1510            let outputs = self.outputs.lock().unwrap();
1511            let transactions = self.transactions.lock().unwrap();
1512
1513            let mut results: Vec<Output> = outputs
1514                .iter()
1515                .filter(|((_, _, t), o)| {
1516                    t == topic && !o.spent && since.is_none_or(|s| o.score.unwrap_or(0.0) >= s)
1517                })
1518                .map(|(_, output)| {
1519                    let mut o = output.clone();
1520                    if include_beef {
1521                        o.beef = transactions.get(&o.txid).cloned();
1522                    }
1523                    o
1524                })
1525                .collect();
1526
1527            // Sort by score ascending (matching KnexStorage behavior)
1528            results.sort_by(|a, b| {
1529                a.score
1530                    .unwrap_or(0.0)
1531                    .partial_cmp(&b.score.unwrap_or(0.0))
1532                    .unwrap_or(std::cmp::Ordering::Equal)
1533            });
1534
1535            if let Some(limit) = limit {
1536                results.truncate(limit as usize);
1537            }
1538
1539            Ok(results)
1540        }
1541
1542        async fn find_transactions_for_proof_check(
1543            &self,
1544            limit: u64,
1545            min_age_secs: u64,
1546        ) -> Result<Vec<TransactionBeef>, StorageError> {
1547            // Models the real D1 `WHERE has_proof = 0 LIMIT {limit}` query
1548            // (overlay migration 0010): the candidate set is driven by the
1549            // `has_proof` FLAG, not a live re-parse of the BEEF. A row whose
1550            // BEEF already carries a proof but whose flag is still 0 (e.g. a
1551            // GASP-synced proof written before the migration, which defaulted
1552            // every existing row to 0) is therefore STILL returned here — it
1553            // only drops out once `mark_transaction_proven` flips the flag.
1554            // This reproduces the window-clog the engine must clear.
1555            //
1556            // The push-primary backstop age gate (#228) excludes rows younger
1557            // than `min_age_secs` (their proof is expected via /arc-ingest);
1558            // unknown-age rows stay eligible (fail-safe — trait doc).
1559            let candidates: Vec<TransactionBeef> = {
1560                let transactions = self.transactions.lock().unwrap();
1561                let proven = self.proven.lock().unwrap();
1562                transactions
1563                    .iter()
1564                    .filter(|(txid, _)| !proven.contains(*txid))
1565                    .map(|(txid, beef)| TransactionBeef {
1566                        txid: txid.clone(),
1567                        beef: beef.clone(),
1568                        early_gasp: self.gasp_transactions.lock().unwrap().contains(txid)
1569                            && !self.age_gate_open(txid, min_age_secs),
1570                    })
1571                    .collect()
1572            };
1573            Ok(candidates
1574                .into_iter()
1575                .filter(|c| c.early_gasp || self.age_gate_open(&c.txid, min_age_secs))
1576                .take(limit as usize)
1577                .collect())
1578        }
1579
1580        async fn update_last_interaction(
1581            &self,
1582            host: &str,
1583            topic: &str,
1584            since: u64,
1585        ) -> Result<(), StorageError> {
1586            self.sync_state
1587                .lock()
1588                .unwrap()
1589                .insert((host.to_string(), topic.to_string()), since);
1590            Ok(())
1591        }
1592
1593        async fn get_last_interaction(&self, host: &str, topic: &str) -> Result<u64, StorageError> {
1594            Ok(*self
1595                .sync_state
1596                .lock()
1597                .unwrap()
1598                .get(&(host.to_string(), topic.to_string()))
1599                .unwrap_or(&0))
1600        }
1601
1602        async fn get_peer_cadence(
1603            &self,
1604            host: &str,
1605            topic: &str,
1606        ) -> Result<crate::sync_load::PeerCadence, StorageError> {
1607            let now = *self.clock_secs.lock().unwrap();
1608            Ok(self
1609                .cadence
1610                .lock()
1611                .unwrap()
1612                .get(&(host.into(), topic.into()))
1613                .map_or_else(Default::default, |(s, at)| {
1614                    let mut state = s.clone();
1615                    state.retry_after_secs = at.saturating_sub(now);
1616                    state
1617                }))
1618        }
1619        async fn set_peer_cadence(
1620            &self,
1621            host: &str,
1622            topic: &str,
1623            state: &crate::sync_load::PeerCadence,
1624        ) -> Result<(), StorageError> {
1625            let now = *self.clock_secs.lock().unwrap();
1626            self.cadence.lock().unwrap().insert(
1627                (host.into(), topic.into()),
1628                (state.clone(), now.saturating_add(state.retry_after_secs)),
1629            );
1630            Ok(())
1631        }
1632        async fn record_peer_sync_outcome(
1633            &self,
1634            host: &str,
1635            topic: &str,
1636            success: bool,
1637        ) -> Result<(), StorageError> {
1638            let now = *self.clock_secs.lock().unwrap();
1639            let mut health = self.peer_health.lock().unwrap();
1640            let entry = health
1641                .entry((host.to_string(), topic.to_string()))
1642                .or_insert((0, now));
1643            entry.0 = if success { 0 } else { entry.0 + 1 };
1644            entry.1 = now;
1645            Ok(())
1646        }
1647
1648        async fn get_peer_sync_health(
1649            &self,
1650            host: &str,
1651            topic: &str,
1652        ) -> Result<PeerSyncHealth, StorageError> {
1653            let now = *self.clock_secs.lock().unwrap();
1654            Ok(self
1655                .peer_health
1656                .lock()
1657                .unwrap()
1658                .get(&(host.to_string(), topic.to_string()))
1659                .map(|(fails, attempt_at)| PeerSyncHealth {
1660                    consecutive_failures: *fails,
1661                    secs_since_last_attempt: Some(now.saturating_sub(*attempt_at)),
1662                })
1663                .unwrap_or_default())
1664        }
1665
1666        async fn record_peer_sync_yield(
1667            &self,
1668            host: &str,
1669            topic: &str,
1670            yielded: bool,
1671        ) -> Result<PeerYieldStreak, StorageError> {
1672            let now = *self.clock_secs.lock().unwrap();
1673            let key = (host.to_string(), topic.to_string());
1674            // The delta-3 fold's D3-M1: the decay only for a peer that is
1675            // not failed (the worker's statement reads the same row).
1676            let not_failed = self
1677                .peer_health
1678                .lock()
1679                .unwrap()
1680                .get(&key)
1681                .is_none_or(|(fails, _)| *fails == 0);
1682            let mut held = self.peer_yieldless.lock().unwrap();
1683            if yielded {
1684                held.remove(&key);
1685                return Ok(PeerYieldStreak::default());
1686            }
1687            let streak = held.entry(key).or_insert((0, now, now));
1688            if streak.0 == 0
1689                || (not_failed
1690                    && now.saturating_sub(streak.2) > crate::gasp::PEER_YIELDLESS_DECAY_SECS)
1691            {
1692                *streak = (0, now, now);
1693            }
1694            streak.0 += 1;
1695            streak.2 = now;
1696            Ok(PeerYieldStreak {
1697                yieldless_syncs: streak.0,
1698                secs_since_first: Some(now.saturating_sub(streak.1)),
1699            })
1700        }
1701
1702        async fn put_deferred_graph(
1703            &self,
1704            record: &crate::gasp::DeferredGraph,
1705        ) -> Result<crate::gasp::DeferredGraphSave, StorageError> {
1706            let key = (
1707                record.peer.clone(),
1708                record.topic.clone(),
1709                record.outpoint.clone(),
1710            );
1711            *self.deferred_graph_put_attempts.lock().unwrap() += 1;
1712            if *self.deferred_graph_put_fault.lock().unwrap() {
1713                return Err(StorageError::Database("deferred graph save faulted".into()));
1714            }
1715            if let Some(mut memo) = crate::fault_memo::FaultMemo::from_record(
1716                record,
1717                (crate::engine::current_timestamp_ms() / 1000.0) as u64,
1718            ) {
1719                let mut held = self.fault_memos.lock().unwrap();
1720                if self
1721                    .fault_memo_ceiling
1722                    .lock()
1723                    .unwrap()
1724                    .is_some_and(|n| !held.contains_key(&key) && held.len() >= n)
1725                {
1726                    return Ok(crate::gasp::DeferredGraphSave::AtCeiling);
1727                }
1728                if let Some(prior) = held.get(&key).filter(|r| r.score == memo.score) {
1729                    memo.first_at = prior.first_at;
1730                }
1731                held.insert(key.clone(), memo);
1732                Self::cap_fault_memos(&mut held, &record.topic);
1733                self.deferred_graphs.lock().unwrap().remove(&key);
1734                *self.deferred_graph_writes.lock().unwrap() += 1;
1735                return Ok(crate::gasp::DeferredGraphSave::Saved);
1736            }
1737            if let Some(ceiling) = *self.deferred_graph_ceiling.lock().unwrap() {
1738                let held = self.deferred_graphs.lock().unwrap();
1739                if !held.contains_key(&key) && held.len() >= ceiling {
1740                    return Ok(crate::gasp::DeferredGraphSave::AtCeiling);
1741                }
1742            }
1743            if let Some(ceiling) = *self.deferred_graph_byte_ceiling.lock().unwrap() {
1744                let held = self.deferred_graphs.lock().unwrap();
1745                let others: usize = held
1746                    .iter()
1747                    .filter(|(k, _)| **k != key)
1748                    .map(|(_, r)| r.byte_size())
1749                    .sum();
1750                if others + record.byte_size() > ceiling {
1751                    return Ok(crate::gasp::DeferredGraphSave::AtCeiling);
1752                }
1753            }
1754            self.fault_memos.lock().unwrap().remove(&key);
1755            *self.deferred_graph_writes.lock().unwrap() += 1;
1756            self.deferred_graphs.lock().unwrap().insert(
1757                (
1758                    record.peer.clone(),
1759                    record.topic.clone(),
1760                    record.outpoint.clone(),
1761                ),
1762                record.clone(),
1763            );
1764            Ok(crate::gasp::DeferredGraphSave::Saved)
1765        }
1766
1767        async fn reserve_unanchorable_retry(
1768            &self,
1769            record: &crate::gasp::DeferredGraph,
1770        ) -> Result<bool, StorageError> {
1771            *self.deferred_graph_put_attempts.lock().unwrap() += 1;
1772            if *self.deferred_graph_put_fault.lock().unwrap() {
1773                return Err(StorageError::Database("retry reservation faulted".into()));
1774            }
1775            let key = (
1776                record.peer.clone(),
1777                record.topic.clone(),
1778                record.outpoint.clone(),
1779            );
1780            let mut held = self.fault_memos.lock().unwrap();
1781            let Some(prior) = held.get_mut(&key) else {
1782                return Ok(false);
1783            };
1784            let now = (crate::engine::current_timestamp_ms() / 1000.0) as u64;
1785            if prior.dead
1786                || !prior.unanchorable
1787                || record.fault_class() != Some(prior.class.as_str())
1788                || prior.score != record.score
1789                || prior.next_at > now
1790                || prior.retries.checked_add(1) != Some(record.unanchorable_retries)
1791                || record.unanchorable_retries > crate::gasp::UNANCHORABLE_RETRIES
1792            {
1793                return Ok(false);
1794            }
1795            prior.retries = record.unanchorable_retries;
1796            prior.next_at = record.retry_at;
1797            *self.deferred_graph_writes.lock().unwrap() += 1;
1798            Ok(true)
1799        }
1800
1801        async fn find_deferred_graphs(
1802            &self,
1803            host: &str,
1804            topic: &str,
1805        ) -> Result<Vec<crate::gasp::DeferredGraphKey>, StorageError> {
1806            if *self.deferred_graph_keys_fault.lock().unwrap() {
1807                return Err(StorageError::Database("deferred graph keys: fault".into()));
1808            }
1809            Ok(self
1810                .deferred_graphs()
1811                .into_iter()
1812                .filter(|r| r.peer == host && r.topic == topic)
1813                .map(|r| crate::gasp::DeferredGraphKey {
1814                    outpoint: r.outpoint,
1815                    score: r.score,
1816                })
1817                .collect())
1818        }
1819
1820        async fn get_deferred_graph(
1821            &self,
1822            host: &str,
1823            topic: &str,
1824            outpoint: &str,
1825        ) -> Result<Option<crate::gasp::DeferredGraph>, StorageError> {
1826            *self.deferred_graph_reads.lock().unwrap() += 1;
1827            if let Some(memo) = self
1828                .fault_memos
1829                .lock()
1830                .unwrap()
1831                .get(&(host.into(), topic.into(), outpoint.into()))
1832                .cloned()
1833            {
1834                return Ok(Some(memo.into_record()));
1835            }
1836            Ok(self
1837                .deferred_graphs
1838                .lock()
1839                .unwrap()
1840                .get(&(host.to_string(), topic.to_string(), outpoint.to_string()))
1841                .cloned())
1842        }
1843
1844        async fn delete_deferred_graph(
1845            &self,
1846            host: &str,
1847            topic: &str,
1848            outpoint: &str,
1849        ) -> Result<(), StorageError> {
1850            self.fault_memos
1851                .lock()
1852                .unwrap()
1853                .remove(&(host.into(), topic.into(), outpoint.into()));
1854            self.deferred_graphs.lock().unwrap().remove(&(
1855                host.to_string(),
1856                topic.to_string(),
1857                outpoint.to_string(),
1858            ));
1859            Ok(())
1860        }
1861
1862        async fn remember_expired_outpoint(
1863            &self,
1864            topic: &str,
1865            outpoint: &str,
1866            expiry: u64,
1867        ) -> Result<(), StorageError> {
1868            let now = *self.clock_secs.lock().unwrap();
1869            let mut held = self.expired_outpoints.lock().unwrap();
1870            held.retain(|_, (keep, _)| *keep > now);
1871            held.insert(
1872                (topic.to_string(), outpoint.to_string()),
1873                (
1874                    expiry.max(now) + crate::gasp::EXPIRED_OUTPOINT_MEMO_SECS,
1875                    now,
1876                ),
1877            );
1878            // The bound: the most recently written rows of the topic stay.
1879            let mut of_topic: Vec<(u64, String)> = held
1880                .iter()
1881                .filter(|((t, _), _)| t == topic)
1882                .map(|((_, o), (_, at))| (*at, o.clone()))
1883                .collect();
1884            let cap = crate::gasp::EXPIRED_OUTPOINT_MEMO_MAX_ROWS;
1885            if of_topic.len() > cap {
1886                of_topic.sort();
1887                for (_, o) in &of_topic[..of_topic.len() - cap] {
1888                    held.remove(&(topic.to_string(), o.clone()));
1889                }
1890            }
1891            Ok(())
1892        }
1893
1894        async fn find_expired_outpoints(
1895            &self,
1896            topic: &str,
1897            outpoints: &[String],
1898        ) -> Result<Vec<String>, StorageError> {
1899            let now = *self.clock_secs.lock().unwrap();
1900            let held = self.expired_outpoints.lock().unwrap();
1901            Ok(outpoints
1902                .iter()
1903                .filter(|o| {
1904                    held.get(&(topic.to_string(), (*o).clone()))
1905                        .is_some_and(|(keep, _)| *keep > now)
1906                })
1907                .cloned()
1908                .collect())
1909        }
1910    }
1911}
1912
1913// ============================================================================
1914// Tests
1915// ============================================================================
1916
1917#[cfg(test)]
1918mod tests {
1919    use super::memory::MemoryStorage;
1920    use super::*;
1921    use crate::types::AppliedTransaction;
1922
1923    /// review LOW-4: the `Rc<T: Storage>` blanket must FORWARD
1924    /// `mark_transaction_proven_at`, not fall through to the trait default
1925    /// (which drops the height, re-introducing MED-2). Call it through an
1926    /// `Rc<dyn Storage>` and assert the height reached the inner store.
1927    #[tokio::test]
1928    async fn the_rc_blanket_forwards_the_anchored_proven_latch() {
1929        let store = std::rc::Rc::new(MemoryStorage::new());
1930        let via_dyn: std::rc::Rc<dyn Storage> = store.clone();
1931        via_dyn
1932            .mark_transaction_proven_at("tx", Some(965_772))
1933            .await
1934            .unwrap();
1935        assert_eq!(
1936            store.proven_at_height("tx"),
1937            Some(Some(965_772)),
1938            "the Rc blanket forwarded the height to the inner store"
1939        );
1940        // a height-less call still forwards (records None, not "never called")
1941        via_dyn
1942            .mark_transaction_proven_at("tx2", None)
1943            .await
1944            .unwrap();
1945        assert_eq!(store.proven_at_height("tx2"), Some(None));
1946        assert_eq!(
1947            store.proven_at_height("never"),
1948            None,
1949            "a txid never latched is absent"
1950        );
1951    }
1952
1953    fn make_output(txid: &str, index: u32, topic: &str, score: f64) -> Output {
1954        Output {
1955            txid: txid.to_string(),
1956            output_index: index,
1957            output_script: vec![0x76, 0xa9],
1958            satoshis: 1000,
1959            topic: topic.to_string(),
1960            spent: false,
1961            outputs_consumed: vec![],
1962            consumed_by: vec![],
1963            beef: Some(vec![0xBE, 0xEF]),
1964            block_height: None,
1965            score: Some(score),
1966        }
1967    }
1968
1969    #[tokio::test]
1970    async fn test_insert_and_find_output() {
1971        let store = MemoryStorage::new();
1972        let output = make_output("abc", 0, "tm_test", 1.0);
1973        store.insert_output(&output).await.unwrap();
1974
1975        // Find without BEEF
1976        let found = store
1977            .find_output("abc", 0, Some("tm_test"), None, false)
1978            .await
1979            .unwrap()
1980            .unwrap();
1981        assert_eq!(found.txid, "abc");
1982        assert_eq!(found.output_index, 0);
1983        assert!(found.beef.is_none());
1984
1985        // Find with BEEF
1986        let found = store
1987            .find_output("abc", 0, Some("tm_test"), None, true)
1988            .await
1989            .unwrap()
1990            .unwrap();
1991        assert_eq!(found.beef.unwrap(), vec![0xBE, 0xEF]);
1992    }
1993
1994    #[tokio::test]
1995    async fn test_insert_duplicate_is_noop() {
1996        let store = MemoryStorage::new();
1997        let output = make_output("abc", 0, "tm_test", 1.0);
1998        store.insert_output(&output).await.unwrap();
1999        store.insert_output(&output).await.unwrap();
2000        assert_eq!(store.output_count(), 1);
2001    }
2002
2003    #[tokio::test]
2004    async fn test_delete_output_cleans_up_beef() {
2005        let store = MemoryStorage::new();
2006        let output = make_output("abc", 0, "tm_test", 1.0);
2007        store.insert_output(&output).await.unwrap();
2008        assert_eq!(store.transaction_count(), 1);
2009
2010        store.delete_output("abc", 0, "tm_test").await.unwrap();
2011        assert_eq!(store.output_count(), 0);
2012        assert_eq!(store.transaction_count(), 0); // BEEF cleaned up
2013    }
2014
2015    #[tokio::test]
2016    async fn test_delete_output_keeps_beef_if_other_outputs_exist() {
2017        let store = MemoryStorage::new();
2018        store
2019            .insert_output(&make_output("abc", 0, "tm_test", 1.0))
2020            .await
2021            .unwrap();
2022        store
2023            .insert_output(&make_output("abc", 1, "tm_test", 2.0))
2024            .await
2025            .unwrap();
2026        assert_eq!(store.output_count(), 2);
2027        assert_eq!(store.transaction_count(), 1);
2028
2029        store.delete_output("abc", 0, "tm_test").await.unwrap();
2030        assert_eq!(store.output_count(), 1);
2031        assert_eq!(store.transaction_count(), 1); // BEEF kept for output 1
2032    }
2033
2034    #[tokio::test]
2035    async fn test_mark_utxo_as_spent() {
2036        let store = MemoryStorage::new();
2037        store
2038            .insert_output(&make_output("abc", 0, "tm_test", 1.0))
2039            .await
2040            .unwrap();
2041
2042        store.mark_utxo_as_spent("abc", 0, "tm_test").await.unwrap();
2043
2044        let found = store
2045            .find_output("abc", 0, Some("tm_test"), None, false)
2046            .await
2047            .unwrap()
2048            .unwrap();
2049        assert!(found.spent);
2050    }
2051
2052    #[tokio::test]
2053    async fn test_find_output_with_spent_filter() {
2054        let store = MemoryStorage::new();
2055        store
2056            .insert_output(&make_output("abc", 0, "tm_test", 1.0))
2057            .await
2058            .unwrap();
2059
2060        // Not spent — should find with spent=false, not with spent=true
2061        assert!(store
2062            .find_output("abc", 0, Some("tm_test"), Some(false), false)
2063            .await
2064            .unwrap()
2065            .is_some());
2066        assert!(store
2067            .find_output("abc", 0, Some("tm_test"), Some(true), false)
2068            .await
2069            .unwrap()
2070            .is_none());
2071
2072        store.mark_utxo_as_spent("abc", 0, "tm_test").await.unwrap();
2073
2074        // Now spent — reversed
2075        assert!(store
2076            .find_output("abc", 0, Some("tm_test"), Some(true), false)
2077            .await
2078            .unwrap()
2079            .is_some());
2080        assert!(store
2081            .find_output("abc", 0, Some("tm_test"), Some(false), false)
2082            .await
2083            .unwrap()
2084            .is_none());
2085    }
2086
2087    #[tokio::test]
2088    async fn test_update_consumed_by() {
2089        let store = MemoryStorage::new();
2090        store
2091            .insert_output(&make_output("abc", 0, "tm_test", 1.0))
2092            .await
2093            .unwrap();
2094
2095        let consumed = vec![Outpoint::new("def", 0), Outpoint::new("ghi", 1)];
2096        store
2097            .update_consumed_by("abc", 0, "tm_test", &consumed)
2098            .await
2099            .unwrap();
2100
2101        let found = store
2102            .find_output("abc", 0, Some("tm_test"), None, false)
2103            .await
2104            .unwrap()
2105            .unwrap();
2106        assert_eq!(found.consumed_by.len(), 2);
2107        assert_eq!(found.consumed_by[0].txid, "def");
2108        assert_eq!(found.consumed_by[1].output_index, 1);
2109    }
2110
2111    #[tokio::test]
2112    async fn test_update_transaction_beef() {
2113        let store = MemoryStorage::new();
2114        store
2115            .insert_output(&make_output("abc", 0, "tm_test", 1.0))
2116            .await
2117            .unwrap();
2118
2119        store
2120            .update_transaction_beef("abc", &[0xDE, 0xAD])
2121            .await
2122            .unwrap();
2123
2124        let found = store
2125            .find_output("abc", 0, Some("tm_test"), None, true)
2126            .await
2127            .unwrap()
2128            .unwrap();
2129        assert_eq!(found.beef.unwrap(), vec![0xDE, 0xAD]);
2130    }
2131
2132    #[tokio::test]
2133    async fn test_update_output_block_height() {
2134        let store = MemoryStorage::new();
2135        store
2136            .insert_output(&make_output("abc", 0, "tm_test", 1.0))
2137            .await
2138            .unwrap();
2139
2140        store
2141            .update_output_block_height("abc", 0, "tm_test", 850_000)
2142            .await
2143            .unwrap();
2144
2145        let found = store
2146            .find_output("abc", 0, Some("tm_test"), None, false)
2147            .await
2148            .unwrap()
2149            .unwrap();
2150        assert_eq!(found.block_height, Some(850_000));
2151    }
2152
2153    #[tokio::test]
2154    async fn test_find_outputs_for_transaction() {
2155        let store = MemoryStorage::new();
2156        store
2157            .insert_output(&make_output("abc", 0, "tm_test", 1.0))
2158            .await
2159            .unwrap();
2160        store
2161            .insert_output(&make_output("abc", 1, "tm_test", 2.0))
2162            .await
2163            .unwrap();
2164        store
2165            .insert_output(&make_output("other", 0, "tm_test", 3.0))
2166            .await
2167            .unwrap();
2168
2169        let results = store
2170            .find_outputs_for_transaction("abc", false)
2171            .await
2172            .unwrap();
2173        assert_eq!(results.len(), 2);
2174    }
2175
2176    #[tokio::test]
2177    async fn test_find_utxos_for_topic() {
2178        let store = MemoryStorage::new();
2179        store
2180            .insert_output(&make_output("a", 0, "tm_test", 1.0))
2181            .await
2182            .unwrap();
2183        store
2184            .insert_output(&make_output("b", 0, "tm_test", 2.0))
2185            .await
2186            .unwrap();
2187        store
2188            .insert_output(&make_output("c", 0, "tm_test", 3.0))
2189            .await
2190            .unwrap();
2191        store
2192            .insert_output(&make_output("d", 0, "tm_other", 4.0))
2193            .await
2194            .unwrap();
2195
2196        // Mark one as spent
2197        store.mark_utxo_as_spent("b", 0, "tm_test").await.unwrap();
2198
2199        // All unspent for tm_test
2200        let results = store
2201            .find_utxos_for_topic("tm_test", None, None, false)
2202            .await
2203            .unwrap();
2204        assert_eq!(results.len(), 2); // a and c (b is spent)
2205
2206        // With since filter
2207        let results = store
2208            .find_utxos_for_topic("tm_test", Some(2.0), None, false)
2209            .await
2210            .unwrap();
2211        assert_eq!(results.len(), 1); // only c (score 3.0 >= 2.0, a is 1.0)
2212
2213        // With limit
2214        let results = store
2215            .find_utxos_for_topic("tm_test", None, Some(1), false)
2216            .await
2217            .unwrap();
2218        assert_eq!(results.len(), 1);
2219        assert_eq!(results[0].txid, "a"); // lowest score first
2220    }
2221
2222    #[tokio::test]
2223    async fn test_find_utxos_for_topic_sorted_by_score() {
2224        let store = MemoryStorage::new();
2225        store
2226            .insert_output(&make_output("c", 0, "tm_test", 30.0))
2227            .await
2228            .unwrap();
2229        store
2230            .insert_output(&make_output("a", 0, "tm_test", 10.0))
2231            .await
2232            .unwrap();
2233        store
2234            .insert_output(&make_output("b", 0, "tm_test", 20.0))
2235            .await
2236            .unwrap();
2237
2238        let results = store
2239            .find_utxos_for_topic("tm_test", None, None, false)
2240            .await
2241            .unwrap();
2242        assert_eq!(results[0].txid, "a");
2243        assert_eq!(results[1].txid, "b");
2244        assert_eq!(results[2].txid, "c");
2245    }
2246
2247    #[tokio::test]
2248    async fn test_applied_transaction() {
2249        let store = MemoryStorage::new();
2250        let tx = AppliedTransaction {
2251            txid: "abc".to_string(),
2252            topic: "tm_test".to_string(),
2253        };
2254
2255        assert!(!store.does_applied_transaction_exist(&tx).await.unwrap());
2256        store.insert_applied_transaction(&tx).await.unwrap();
2257        assert!(store.does_applied_transaction_exist(&tx).await.unwrap());
2258
2259        // Different topic — should not exist
2260        let tx2 = AppliedTransaction {
2261            txid: "abc".to_string(),
2262            topic: "tm_other".to_string(),
2263        };
2264        assert!(!store.does_applied_transaction_exist(&tx2).await.unwrap());
2265    }
2266
2267    #[tokio::test]
2268    async fn test_sync_state() {
2269        let store = MemoryStorage::new();
2270
2271        assert_eq!(
2272            store
2273                .get_last_interaction("host1", "tm_test")
2274                .await
2275                .unwrap(),
2276            0
2277        );
2278
2279        store
2280            .update_last_interaction("host1", "tm_test", 100)
2281            .await
2282            .unwrap();
2283        assert_eq!(
2284            store
2285                .get_last_interaction("host1", "tm_test")
2286                .await
2287                .unwrap(),
2288            100
2289        );
2290
2291        // Update (upsert)
2292        store
2293            .update_last_interaction("host1", "tm_test", 200)
2294            .await
2295            .unwrap();
2296        assert_eq!(
2297            store
2298                .get_last_interaction("host1", "tm_test")
2299                .await
2300                .unwrap(),
2301            200
2302        );
2303
2304        // Different host — independent
2305        assert_eq!(
2306            store
2307                .get_last_interaction("host2", "tm_test")
2308                .await
2309                .unwrap(),
2310            0
2311        );
2312    }
2313
2314    #[tokio::test]
2315    async fn test_find_outputs_by_outpoints_batch() {
2316        let store = MemoryStorage::new();
2317        store
2318            .insert_output(&make_output("a", 0, "tm_test", 1.0))
2319            .await
2320            .unwrap();
2321        store
2322            .insert_output(&make_output("b", 0, "tm_test", 2.0))
2323            .await
2324            .unwrap();
2325        store
2326            .insert_output(&make_output("c", 0, "tm_test", 3.0))
2327            .await
2328            .unwrap();
2329
2330        let outpoints = vec![
2331            Outpoint::new("a", 0),
2332            Outpoint::new("c", 0),
2333            Outpoint::new("missing", 0),
2334        ];
2335        let results = store
2336            .find_outputs_by_outpoints(&outpoints, false)
2337            .await
2338            .unwrap();
2339        assert_eq!(results.len(), 2); // a and c found, missing skipped
2340    }
2341
2342    #[tokio::test]
2343    async fn test_find_output_without_topic() {
2344        let store = MemoryStorage::new();
2345        store
2346            .insert_output(&make_output("abc", 0, "tm_test", 1.0))
2347            .await
2348            .unwrap();
2349
2350        // Find without specifying topic
2351        let found = store
2352            .find_output("abc", 0, None, None, false)
2353            .await
2354            .unwrap();
2355        assert!(found.is_some());
2356        assert_eq!(found.unwrap().topic, "tm_test");
2357    }
2358}