Skip to main content

cdk_bdk/storage/
mod.rs

1//! BDK storage operations using KV store
2
3use std::sync::Arc;
4
5use cdk_common::database::KVStore;
6use serde::de::DeserializeOwned;
7use serde::Serialize;
8
9use crate::error::Error;
10use crate::receive::receive_intent::record::ReceiveIntentRecord;
11use crate::send::batch_transaction::record::SendBatchRecord;
12use crate::send::payment_intent::record::SendIntentRecord;
13
14pub mod receive;
15pub mod send;
16mod types;
17
18pub use types::{FailedSendAttemptRecord, FinalizedReceiveIntentRecord, FinalizedSendIntentRecord};
19
20/// Primary namespace for BDK KV store operations
21pub const BDK_NAMESPACE: &str = "bdk";
22
23/// Secondary namespace for send intents
24pub const SEND_INTENT_NAMESPACE: &str = "send_intent";
25
26/// Secondary namespace for send intent quote id index
27pub const SEND_INTENT_QUOTE_ID_NAMESPACE: &str = "send_intent_quote_id";
28
29/// Secondary namespace for send output quote id index (outpoint -> quote_id)
30pub const SEND_OUTPOINT_QUOTE_ID_NAMESPACE: &str = "send_outpoint_quote_id";
31
32/// Secondary namespace for one-time BDK storage migrations.
33pub const STORAGE_MIGRATION_NAMESPACE: &str = "storage_migration";
34
35/// Marker for the send output quote id index backfill.
36pub const SEND_OUTPOINT_QUOTE_ID_BACKFILL_KEY: &str = "send_outpoint_quote_id_v1";
37
38/// Secondary namespace for send batches
39pub const SEND_BATCH_NAMESPACE: &str = "send_batch";
40
41/// Secondary namespace for failed pre-sign send attempt tombstones.
42pub const FAILED_SEND_ATTEMPT_NAMESPACE: &str = "failed_send_attempt";
43
44/// Secondary namespace for finalized (confirmed) intents.
45/// Stores tombstone records so `check_outgoing_payment` can return
46/// correct `total_spent` after the active intent has been deleted.
47pub const FINALIZED_INTENT_NAMESPACE: &str = "finalized_intent";
48
49/// Secondary namespace for tracked receive address index (address -> quote_id)
50pub const RECEIVE_ADDRESS_QUOTE_ID_NAMESPACE: &str = "receive_address_quote_id";
51
52/// Secondary namespace for receive intents (keyed by intent_id)
53pub const RECEIVE_INTENT_NAMESPACE: &str = "receive_intent";
54
55/// Secondary namespace for receive intent outpoint index (outpoint -> intent_id)
56pub const RECEIVE_INTENT_OUTPOINT_NAMESPACE: &str = "receive_intent_outpoint";
57
58/// Secondary namespace for finalized (confirmed) receive intents.
59/// Stores tombstone records so `check_incoming_payment_status` can
60/// return historical data after the active intent has been deleted.
61pub const FINALIZED_RECEIVE_INTENT_NAMESPACE: &str = "finalized_receive_intent";
62
63/// Secondary namespace for finalized receive intent outpoint index (outpoint -> intent_id)
64pub const FINALIZED_RECEIVE_INTENT_OUTPOINT_NAMESPACE: &str = "finalized_receive_intent_outpoint";
65
66/// Secondary-namespace prefix for the finalized receive-intent quote-id index.
67///
68/// Full namespace: `finalized_receive_intent_by_quote__<quote_id>`, with
69/// one key per finalized intent (`<intent_id>` → empty value). Storing
70/// each intent under its own key lets `finalize_receive_intent` commit a
71/// single idempotent `kv_write` instead of an RMW on a serialized list,
72/// which would otherwise race under Postgres `READ COMMITTED`.
73pub const FINALIZED_RECEIVE_INTENT_BY_QUOTE_NAMESPACE_PREFIX: &str =
74    "finalized_receive_intent_by_quote";
75
76/// Build the per-quote secondary namespace used to index finalized receive intents.
77pub fn finalized_receive_intent_by_quote_namespace(quote_id: &str) -> String {
78    format!("{FINALIZED_RECEIVE_INTENT_BY_QUOTE_NAMESPACE_PREFIX}__{quote_id}")
79}
80
81/// Secondary namespace for finalized send intent quote id index (quote_id -> intent_id)
82pub const FINALIZED_SEND_INTENT_QUOTE_ID_NAMESPACE: &str = "finalized_send_intent_quote_id";
83
84/// Encode an outpoint string for use as a KV store key.
85///
86/// The KV store only allows ASCII letters, numbers, underscore, and
87/// hyphen. Outpoint strings contain `:` (e.g. `txid:vout`), so we
88/// replace it with `-`.
89fn outpoint_to_key(outpoint: &str) -> String {
90    outpoint.replace(':', "-")
91}
92
93pub trait KvRecord: Serialize + DeserializeOwned + Sized {
94    const NAMESPACE: &'static str;
95
96    fn key(&self) -> String;
97}
98
99pub(crate) trait ReplaceState<S>: KvRecord {
100    fn replace_state(&mut self, state: S);
101}
102
103/// BDK KV store operations
104#[derive(Clone)]
105pub struct BdkStorage {
106    pub(crate) kv_store: Arc<dyn KVStore<Err = cdk_common::database::Error> + Send + Sync>,
107}
108
109impl BdkStorage {
110    /// Create a new BdkStorage instance
111    pub fn new(
112        kv_store: Arc<dyn KVStore<Err = cdk_common::database::Error> + Send + Sync>,
113    ) -> Self {
114        Self { kv_store }
115    }
116
117    async fn put_record<T>(&self, record: &T) -> Result<(), Error>
118    where
119        T: KvRecord,
120    {
121        let serialized = serde_json::to_vec(record)?;
122        let mut tx = self
123            .kv_store
124            .begin_transaction()
125            .await
126            .map_err(Error::from)?;
127        tx.kv_write(BDK_NAMESPACE, T::NAMESPACE, &record.key(), &serialized)
128            .await
129            .map_err(Error::from)?;
130        tx.commit().await.map_err(Error::from)?;
131        Ok(())
132    }
133
134    async fn get_record<T>(&self, key: &str) -> Result<Option<T>, Error>
135    where
136        T: KvRecord,
137    {
138        let data = self
139            .kv_store
140            .kv_read(BDK_NAMESPACE, T::NAMESPACE, key)
141            .await
142            .map_err(Error::from)?;
143
144        match data {
145            Some(bytes) => Ok(Some(serde_json::from_slice(&bytes)?)),
146            None => Ok(None),
147        }
148    }
149
150    async fn list_records<T>(&self) -> Result<Vec<T>, Error>
151    where
152        T: KvRecord,
153    {
154        let keys = self
155            .kv_store
156            .kv_list(BDK_NAMESPACE, T::NAMESPACE)
157            .await
158            .map_err(Error::from)?;
159
160        let mut records = Vec::new();
161        for key in keys {
162            if let Some(data) = self
163                .kv_store
164                .kv_read(BDK_NAMESPACE, T::NAMESPACE, &key)
165                .await
166                .map_err(Error::from)?
167            {
168                match serde_json::from_slice::<T>(&data) {
169                    Ok(record) => records.push(record),
170                    Err(e) => {
171                        tracing::warn!("Failed to deserialize {} {}: {}", T::NAMESPACE, key, e);
172                    }
173                }
174            }
175        }
176
177        Ok(records)
178    }
179
180    async fn delete_record<T>(&self, key: &str) -> Result<(), Error>
181    where
182        T: KvRecord,
183    {
184        let mut tx = self
185            .kv_store
186            .begin_transaction()
187            .await
188            .map_err(Error::from)?;
189        tx.kv_remove(BDK_NAMESPACE, T::NAMESPACE, key)
190            .await
191            .map_err(Error::from)?;
192        tx.commit().await.map_err(Error::from)?;
193        Ok(())
194    }
195
196    async fn update_record_state<T, S>(&self, key: &str, new_state: &S) -> Result<(), Error>
197    where
198        T: ReplaceState<S>,
199        S: Clone,
200    {
201        let data = self
202            .kv_store
203            .kv_read(BDK_NAMESPACE, T::NAMESPACE, key)
204            .await
205            .map_err(Error::from)?;
206
207        let Some(bytes) = data else {
208            return Err(Error::Wallet(format!(
209                "Record not found in namespace {} for key {}",
210                T::NAMESPACE,
211                key
212            )));
213        };
214
215        let mut record: T = serde_json::from_slice(&bytes)?;
216        record.replace_state(new_state.clone());
217        self.put_record(&record).await
218    }
219}
220
221impl KvRecord for SendIntentRecord {
222    const NAMESPACE: &'static str = SEND_INTENT_NAMESPACE;
223
224    fn key(&self) -> String {
225        self.intent_id.to_string()
226    }
227}
228
229impl KvRecord for FailedSendAttemptRecord {
230    const NAMESPACE: &'static str = FAILED_SEND_ATTEMPT_NAMESPACE;
231
232    fn key(&self) -> String {
233        self.attempt_id.to_string()
234    }
235}
236
237impl ReplaceState<crate::send::payment_intent::record::SendIntentState> for SendIntentRecord {
238    fn replace_state(&mut self, state: crate::send::payment_intent::record::SendIntentState) {
239        self.state = state;
240    }
241}
242
243impl KvRecord for SendBatchRecord {
244    const NAMESPACE: &'static str = SEND_BATCH_NAMESPACE;
245
246    fn key(&self) -> String {
247        self.batch_id.to_string()
248    }
249}
250
251impl ReplaceState<crate::send::batch_transaction::record::SendBatchState> for SendBatchRecord {
252    fn replace_state(&mut self, state: crate::send::batch_transaction::record::SendBatchState) {
253        self.state = state;
254    }
255}
256
257impl KvRecord for ReceiveIntentRecord {
258    const NAMESPACE: &'static str = RECEIVE_INTENT_NAMESPACE;
259
260    fn key(&self) -> String {
261        self.intent_id.to_string()
262    }
263}
264
265impl KvRecord for FinalizedSendIntentRecord {
266    const NAMESPACE: &'static str = FINALIZED_INTENT_NAMESPACE;
267
268    fn key(&self) -> String {
269        self.intent_id.to_string()
270    }
271}
272
273impl KvRecord for FinalizedReceiveIntentRecord {
274    const NAMESPACE: &'static str = FINALIZED_RECEIVE_INTENT_NAMESPACE;
275
276    fn key(&self) -> String {
277        self.intent_id.to_string()
278    }
279}
280
281#[cfg(test)]
282mod tests {
283    use std::sync::Arc;
284
285    use uuid::Uuid;
286
287    use super::*;
288    use crate::send::batch_transaction::record::{
289        BatchOutputAssignment, SendBatchRecord, SendBatchState,
290    };
291    use crate::send::payment_intent::record::{SendIntentRecord, SendIntentState};
292    use crate::types::{PaymentMetadata, PaymentTier};
293
294    /// Helper: create an in-memory KVStore-backed BdkStorage for tests
295    async fn test_storage() -> BdkStorage {
296        let db = cdk_sqlite::mint::memory::empty()
297            .await
298            .expect("in-memory db");
299        BdkStorage::new(Arc::new(db))
300    }
301
302    /// Helper: build a test SendIntentRecord in Pending state
303    fn make_pending_intent(intent_id: Uuid) -> SendIntentRecord {
304        SendIntentRecord {
305            intent_id,
306            quote_id: "test-quote-1".to_string(),
307            address: "bcrt1qw508d6qejxtdg4y5r3zarvary0c5xw7kygt080".to_string(),
308            amount_sat: 50_000,
309            max_fee_amount_sat: 1_000,
310            tier: PaymentTier::Immediate,
311            metadata: PaymentMetadata::default(),
312            state: SendIntentState::Pending {
313                created_at: 1_700_000_000,
314            },
315        }
316    }
317
318    // ── Serialization round-trip tests ─────────────────────────────
319
320    #[test]
321    fn test_send_intent_record_state_roundtrip() {
322        let batch_id = Uuid::new_v4();
323
324        let states = vec![
325            SendIntentState::Pending {
326                created_at: 1_700_000_000,
327            },
328            SendIntentState::Batched {
329                batch_id,
330                created_at: 1_700_000_000,
331            },
332            SendIntentState::AwaitingConfirmation {
333                batch_id,
334                txid: "abc123def456".to_string(),
335                outpoint: "abc123def456:0".to_string(),
336                fee_contribution_sat: 250,
337                created_at: 1_700_000_000,
338            },
339            SendIntentState::Failed {
340                reason: "pre-sign failure".to_string(),
341                created_at: 1_700_000_000,
342                failed_at: 1_700_000_100,
343            },
344        ];
345
346        for state in states {
347            let json = serde_json::to_string(&state).expect("serialize state");
348            let deserialized: SendIntentState =
349                serde_json::from_str(&json).expect("deserialize state");
350
351            // Re-serialize and compare JSON to verify round-trip
352            let json2 = serde_json::to_string(&deserialized).expect("re-serialize state");
353            assert_eq!(json, json2, "Round-trip failed for state variant");
354        }
355
356        // Also test full SendIntentRecord round-trip
357        let intent = SendIntentRecord {
358            intent_id: Uuid::new_v4(),
359            quote_id: "quote-123".to_string(),
360            address: "bcrt1qw508d6qejxtdg4y5r3zarvary0c5xw7kygt080".to_string(),
361            amount_sat: 100_000,
362            max_fee_amount_sat: 5_000,
363            tier: PaymentTier::Standard,
364            metadata: PaymentMetadata::from_optional_json(Some(r#"{"key": "value"}"#)),
365            state: SendIntentState::Pending {
366                created_at: 1_700_000_000,
367            },
368        };
369        let json = serde_json::to_string(&intent).expect("serialize intent");
370        let deserialized: SendIntentRecord =
371            serde_json::from_str(&json).expect("deserialize intent");
372        assert_eq!(intent.intent_id, deserialized.intent_id);
373        assert_eq!(intent.quote_id, deserialized.quote_id);
374        assert_eq!(intent.address, deserialized.address);
375        assert_eq!(intent.amount_sat, deserialized.amount_sat);
376        assert_eq!(intent.max_fee_amount_sat, deserialized.max_fee_amount_sat);
377    }
378
379    #[test]
380    fn test_send_batch_record_state_roundtrip() {
381        let intent_ids = vec![Uuid::new_v4(), Uuid::new_v4()];
382        let assignments: Vec<BatchOutputAssignment> = intent_ids
383            .iter()
384            .enumerate()
385            .map(|(idx, id)| BatchOutputAssignment {
386                intent_id: *id,
387                vout: idx as u32,
388                fee_contribution_sat: 125,
389            })
390            .collect();
391
392        let states = vec![
393            SendBatchState::Built {
394                psbt_bytes: vec![0x01, 0x02, 0x03, 0x04],
395                intent_ids: intent_ids.clone(),
396            },
397            SendBatchState::Signed {
398                tx_bytes: vec![0x05, 0x06, 0x07, 0x08],
399                assignments: assignments.clone(),
400                fee_sat: 250,
401            },
402            SendBatchState::Broadcast {
403                txid: "deadbeef1234".to_string(),
404                tx_bytes: vec![0x05, 0x06, 0x07, 0x08],
405                assignments: assignments.clone(),
406                fee_sat: 1000,
407            },
408        ];
409
410        for state in states {
411            let json = serde_json::to_string(&state).expect("serialize state");
412            let deserialized: SendBatchState =
413                serde_json::from_str(&json).expect("deserialize state");
414
415            let json2 = serde_json::to_string(&deserialized).expect("re-serialize state");
416            assert_eq!(json, json2, "Round-trip failed for batch state variant");
417        }
418
419        // Also test full SendBatchRecord round-trip
420        let batch = SendBatchRecord {
421            batch_id: Uuid::new_v4(),
422            state: SendBatchState::Broadcast {
423                txid: "abc".to_string(),
424                tx_bytes: vec![1, 2, 3],
425                assignments,
426                fee_sat: 500,
427            },
428        };
429        let json = serde_json::to_string(&batch).expect("serialize batch");
430        let deserialized: SendBatchRecord = serde_json::from_str(&json).expect("deserialize batch");
431        assert_eq!(batch.batch_id, deserialized.batch_id);
432    }
433
434    // ── CRUD tests for send intent storage ────────────────
435
436    #[tokio::test]
437    async fn test_send_intent_crud() {
438        let storage = test_storage().await;
439        let intent_id = Uuid::new_v4();
440        let intent = make_pending_intent(intent_id);
441
442        // Store
443        storage
444            .create_send_intent_if_absent(&intent)
445            .await
446            .expect("store");
447
448        // Get
449        let fetched = storage
450            .get_send_intent(&intent_id)
451            .await
452            .expect("get")
453            .expect("should exist");
454        assert_eq!(fetched.intent_id, intent_id);
455        assert_eq!(fetched.quote_id, "test-quote-1");
456        assert_eq!(fetched.amount_sat, 50_000);
457        assert!(matches!(
458            fetched.state,
459            SendIntentState::Pending {
460                created_at: 1_700_000_000
461            }
462        ));
463
464        // Update state to Batched
465        let batch_id = Uuid::new_v4();
466        storage
467            .update_send_intent(
468                &intent_id,
469                &SendIntentState::Batched {
470                    batch_id,
471                    created_at: 1_700_000_000,
472                },
473            )
474            .await
475            .expect("update");
476
477        let updated = storage
478            .get_send_intent(&intent_id)
479            .await
480            .expect("get")
481            .expect("should exist");
482        match &updated.state {
483            SendIntentState::Batched {
484                batch_id: bid,
485                created_at,
486            } => {
487                assert_eq!(*bid, batch_id);
488                assert_eq!(*created_at, 1_700_000_000);
489            }
490            other => panic!("Expected Batched, got {:?}", other),
491        }
492
493        // get_all_send_intents
494        let all = storage.get_all_send_intents().await.expect("get_all");
495        assert_eq!(all.len(), 1);
496
497        // get_pending_send_intents should now be empty (intent is Batched)
498        let pending = storage
499            .get_pending_send_intents()
500            .await
501            .expect("get_pending");
502        assert!(
503            pending.is_empty(),
504            "Batched intent should not appear in pending"
505        );
506
507        // Revert to Pending and check pending filter
508        storage
509            .update_send_intent(
510                &intent_id,
511                &SendIntentState::Pending {
512                    created_at: 1_700_000_000,
513                },
514            )
515            .await
516            .expect("revert");
517        let pending = storage
518            .get_pending_send_intents()
519            .await
520            .expect("get_pending");
521        assert_eq!(pending.len(), 1);
522
523        // Delete
524        storage
525            .delete_send_intent(&intent_id)
526            .await
527            .expect("delete");
528        let gone = storage.get_send_intent(&intent_id).await.expect("get");
529        assert!(gone.is_none(), "Intent should be deleted");
530
531        // get_all should now be empty
532        let all = storage.get_all_send_intents().await.expect("get_all");
533        assert!(all.is_empty());
534    }
535
536    #[tokio::test]
537    async fn test_create_send_intent_if_absent_rejects_duplicate_quote_id() {
538        let storage = test_storage().await;
539        let first = make_pending_intent(Uuid::new_v4());
540        storage
541            .create_send_intent_if_absent(&first)
542            .await
543            .expect("store first");
544
545        let mut second = make_pending_intent(Uuid::new_v4());
546        second.address = "bcrt1qother".to_string();
547
548        let err = storage
549            .create_send_intent_if_absent(&second)
550            .await
551            .expect_err("duplicate quote id should fail");
552        assert!(matches!(err, Error::DuplicateQuoteId(_)));
553    }
554
555    /// Regression test for un-rolled-back transaction on the active-duplicate
556    /// path. Prior to the fix, `create_send_intent_if_absent` returned
557    /// `DuplicateQuoteId` without rolling back the open transaction, violating
558    /// the `DbTransactionFinalizer` contract. This test hits the duplicate
559    /// branch many times and then performs follow-up storage operations to
560    /// prove the backend isn't starved of connections/locks and subsequent
561    /// writes still succeed.
562    #[tokio::test]
563    async fn test_create_send_intent_if_absent_active_duplicate_rolls_back_tx() {
564        let storage = test_storage().await;
565        let first = make_pending_intent(Uuid::new_v4());
566        storage
567            .create_send_intent_if_absent(&first)
568            .await
569            .expect("store first");
570
571        // Repeatedly trigger the active-duplicate branch. If the transaction
572        // were leaked (never committed or rolled back), a pool-backed KV store
573        // could eventually deadlock or return an error here.
574        for _ in 0..16 {
575            let mut dup = make_pending_intent(Uuid::new_v4());
576            dup.address = "bcrt1qother".to_string();
577            let err = storage
578                .create_send_intent_if_absent(&dup)
579                .await
580                .expect_err("duplicate quote id should fail");
581            assert!(matches!(err, Error::DuplicateQuoteId(_)));
582        }
583
584        // A follow-up write with a fresh quote id must still succeed,
585        // proving the store is not wedged by a leaked transaction.
586        let mut follow_up = make_pending_intent(Uuid::new_v4());
587        follow_up.quote_id = "test-quote-follow-up".to_string();
588        storage
589            .create_send_intent_if_absent(&follow_up)
590            .await
591            .expect("follow-up write must succeed after duplicate rejection");
592
593        // Sanity: the original intent is untouched.
594        let original = storage
595            .get_send_intent(&first.intent_id)
596            .await
597            .expect("get first")
598            .expect("first intent should still exist");
599        assert_eq!(original.intent_id, first.intent_id);
600        assert_eq!(original.quote_id, first.quote_id);
601    }
602
603    /// Regression test for un-rolled-back transaction on the finalized-duplicate
604    /// path. Mirrors the active-duplicate test but exercises the second
605    /// early-return branch (finalized tombstone present).
606    #[tokio::test]
607    async fn test_create_send_intent_if_absent_finalized_duplicate_rolls_back_tx() {
608        let storage = test_storage().await;
609        let intent = make_pending_intent(Uuid::new_v4());
610        let intent_id = intent.intent_id;
611        let quote_id = intent.quote_id.clone();
612        storage
613            .create_send_intent_if_absent(&intent)
614            .await
615            .expect("store intent");
616
617        let tombstone = FinalizedSendIntentRecord {
618            intent_id,
619            quote_id: quote_id.clone(),
620            total_spent_sat: 50_500,
621            outpoint: "txid:0".to_string(),
622            finalized_at: 1_700_000_001,
623        };
624        storage
625            .finalize_send_intent(&intent_id, &tombstone)
626            .await
627            .expect("finalize intent");
628
629        // Repeatedly trigger the finalized-duplicate branch.
630        for _ in 0..16 {
631            let mut dup = make_pending_intent(Uuid::new_v4());
632            dup.quote_id = quote_id.clone();
633            let err = storage
634                .create_send_intent_if_absent(&dup)
635                .await
636                .expect_err("finalized quote id should be rejected");
637            assert!(matches!(err, Error::DuplicateQuoteId(_)));
638        }
639
640        // A follow-up write with a fresh quote id must still succeed.
641        let mut follow_up = make_pending_intent(Uuid::new_v4());
642        follow_up.quote_id = "test-quote-follow-up-finalized".to_string();
643        storage
644            .create_send_intent_if_absent(&follow_up)
645            .await
646            .expect("follow-up write must succeed after duplicate rejection");
647    }
648
649    #[tokio::test]
650    async fn test_finalize_send_intent_removes_quote_id_index() {
651        let storage = test_storage().await;
652        let intent = make_pending_intent(Uuid::new_v4());
653        let intent_id = intent.intent_id;
654        let quote_id = intent.quote_id.clone();
655        storage
656            .create_send_intent_if_absent(&intent)
657            .await
658            .expect("store intent");
659
660        let tombstone = FinalizedSendIntentRecord {
661            intent_id,
662            quote_id: quote_id.clone(),
663            total_spent_sat: 50_500,
664            outpoint: "txid:0".to_string(),
665            finalized_at: 1_700_000_001,
666        };
667        storage
668            .finalize_send_intent(&intent_id, &tombstone)
669            .await
670            .expect("finalize intent");
671
672        assert!(storage
673            .get_send_intent(&intent_id)
674            .await
675            .expect("get intent")
676            .is_none());
677        assert!(storage
678            .get_send_intent_by_quote_id(&quote_id)
679            .await
680            .expect("lookup quote id")
681            .is_none());
682        assert!(storage
683            .get_finalized_intent(&intent_id)
684            .await
685            .expect("get tombstone")
686            .is_some());
687        assert_eq!(
688            storage
689                .get_quote_id_by_send_outpoint("txid:0")
690                .await
691                .expect("lookup finalized output quote"),
692            Some(quote_id.clone())
693        );
694
695        // A new intent for the SAME quote ID should NOT be allowed
696        let mut second = make_pending_intent(Uuid::new_v4());
697        second.quote_id = quote_id.clone();
698        let err = storage
699            .create_send_intent_if_absent(&second)
700            .await
701            .expect_err("should reject already finalized quote id");
702        assert!(matches!(err, Error::DuplicateQuoteId(_)));
703    }
704
705    // ── CRUD tests for send batch storage ─────────────────
706
707    #[tokio::test]
708    async fn test_send_batch_crud() {
709        let storage = test_storage().await;
710        let batch_id = Uuid::new_v4();
711        let intent_ids = vec![Uuid::new_v4(), Uuid::new_v4()];
712
713        let batch = SendBatchRecord {
714            batch_id,
715            state: SendBatchState::Built {
716                psbt_bytes: vec![0xAA, 0xBB],
717                intent_ids: intent_ids.clone(),
718            },
719        };
720
721        // Store
722        storage.store_send_batch(&batch).await.expect("store");
723
724        // Get
725        let fetched = storage
726            .get_send_batch(&batch_id)
727            .await
728            .expect("get")
729            .expect("should exist");
730        assert_eq!(fetched.batch_id, batch_id);
731        match &fetched.state {
732            SendBatchState::Built {
733                psbt_bytes,
734                intent_ids: ids,
735            } => {
736                assert_eq!(psbt_bytes, &vec![0xAA, 0xBB]);
737                assert_eq!(ids, &intent_ids);
738            }
739            other => panic!("Expected Built, got {:?}", other),
740        }
741
742        // Update to Signed
743        let tx_bytes = vec![0xCC, 0xDD, 0xEE];
744        let assignments: Vec<BatchOutputAssignment> = intent_ids
745            .iter()
746            .enumerate()
747            .map(|(idx, intent_id)| BatchOutputAssignment {
748                intent_id: *intent_id,
749                vout: idx as u32,
750                fee_contribution_sat: 125,
751            })
752            .collect();
753        storage
754            .update_send_batch(
755                &batch_id,
756                &SendBatchState::Signed {
757                    tx_bytes: tx_bytes.clone(),
758                    assignments: assignments.clone(),
759                    fee_sat: 250,
760                },
761            )
762            .await
763            .expect("update");
764
765        let updated = storage
766            .get_send_batch(&batch_id)
767            .await
768            .expect("get")
769            .expect("should exist");
770        match &updated.state {
771            SendBatchState::Signed {
772                tx_bytes: tb,
773                assignments: a,
774                fee_sat,
775            } => {
776                assert_eq!(tb, &tx_bytes);
777                assert_eq!(a, &assignments);
778                assert_eq!(*fee_sat, 250);
779            }
780            other => panic!("Expected Signed, got {:?}", other),
781        }
782
783        // Update to Broadcast
784        storage
785            .update_send_batch(
786                &batch_id,
787                &SendBatchState::Broadcast {
788                    txid: "txid123".to_string(),
789                    tx_bytes: tx_bytes.clone(),
790                    assignments: assignments.clone(),
791                    fee_sat: 400,
792                },
793            )
794            .await
795            .expect("update to broadcast");
796
797        let broadcast = storage
798            .get_send_batch(&batch_id)
799            .await
800            .expect("get")
801            .expect("should exist");
802        match &broadcast.state {
803            SendBatchState::Broadcast {
804                txid,
805                tx_bytes: tb,
806                assignments: a,
807                fee_sat,
808            } => {
809                assert_eq!(txid, "txid123");
810                assert_eq!(tb, &tx_bytes);
811                assert_eq!(a, &assignments);
812                assert_eq!(*fee_sat, 400);
813            }
814            other => panic!("Expected Broadcast, got {:?}", other),
815        }
816
817        // get_all
818        let all = storage.get_all_send_batches().await.expect("get_all");
819        assert_eq!(all.len(), 1);
820
821        // Delete
822        storage.delete_send_batch(&batch_id).await.expect("delete");
823        let gone = storage.get_send_batch(&batch_id).await.expect("get");
824        assert!(gone.is_none(), "Batch should be deleted");
825
826        let all = storage.get_all_send_batches().await.expect("get_all");
827        assert!(all.is_empty());
828    }
829
830    // ── Update non-existent records ───────────────────────
831
832    #[tokio::test]
833    async fn test_update_nonexistent_intent_returns_error() {
834        let storage = test_storage().await;
835        let result = storage
836            .update_send_intent(
837                &Uuid::new_v4(),
838                &SendIntentState::Pending {
839                    created_at: 1_700_000_000,
840                },
841            )
842            .await;
843        assert!(result.is_err(), "Updating nonexistent intent should fail");
844    }
845
846    #[tokio::test]
847    async fn test_update_nonexistent_batch_returns_error() {
848        let storage = test_storage().await;
849        let result = storage
850            .update_send_batch(
851                &Uuid::new_v4(),
852                &SendBatchState::Built {
853                    psbt_bytes: vec![],
854                    intent_ids: vec![],
855                },
856            )
857            .await;
858        assert!(result.is_err(), "Updating nonexistent batch should fail");
859    }
860
861    // ── Confirmation storage-level tests ──────────────────
862
863    #[tokio::test]
864    async fn test_awaiting_confirmation_intent_lookup() {
865        let storage = test_storage().await;
866        let batch_id = Uuid::new_v4();
867
868        // Store one Pending and one AwaitingConfirmation intent
869        let pending_id = Uuid::new_v4();
870        let pending = make_pending_intent(pending_id);
871        storage
872            .create_send_intent_if_absent(&pending)
873            .await
874            .expect("store pending");
875
876        let confirming_id = Uuid::new_v4();
877        let confirming = SendIntentRecord {
878            intent_id: confirming_id,
879            quote_id: "quote-confirm".to_string(),
880            address: "bcrt1qw508d6qejxtdg4y5r3zarvary0c5xw7kygt080".to_string(),
881            amount_sat: 75_000,
882            max_fee_amount_sat: 2_000,
883            tier: PaymentTier::Standard,
884            metadata: PaymentMetadata::default(),
885            state: SendIntentState::AwaitingConfirmation {
886                batch_id,
887                txid: "abc123".to_string(),
888                outpoint: "abc123:0".to_string(),
889                fee_contribution_sat: 300,
890                created_at: 1_700_000_000,
891            },
892        };
893        storage
894            .create_send_intent_if_absent(&confirming)
895            .await
896            .expect("store confirming");
897
898        // get_all returns both
899        let all = storage.get_all_send_intents().await.expect("get_all");
900        assert_eq!(all.len(), 2);
901
902        // get_pending only returns the Pending one
903        let pending = storage
904            .get_pending_send_intents()
905            .await
906            .expect("get_pending");
907        assert_eq!(pending.len(), 1);
908        assert_eq!(pending[0].intent_id, pending_id);
909
910        // Can look up AwaitingConfirmation by ID and read its fields
911        let fetched = storage
912            .get_send_intent(&confirming_id)
913            .await
914            .expect("get")
915            .expect("should exist");
916        match &fetched.state {
917            SendIntentState::AwaitingConfirmation {
918                txid,
919                outpoint,
920                fee_contribution_sat,
921                ..
922            } => {
923                assert_eq!(txid, "abc123");
924                assert_eq!(outpoint, "abc123:0");
925                assert_eq!(*fee_contribution_sat, 300);
926            }
927            other => panic!("Expected AwaitingConfirmation, got {:?}", other),
928        }
929        assert_eq!(
930            storage
931                .get_quote_id_by_send_outpoint("abc123:0")
932                .await
933                .expect("lookup output quote"),
934            Some("quote-confirm".to_string())
935        );
936    }
937
938    #[tokio::test]
939    async fn test_send_outpoint_quote_index_tracks_state_changes() {
940        let storage = test_storage().await;
941        let intent_id = Uuid::new_v4();
942        let intent = make_pending_intent(intent_id);
943        storage
944            .create_send_intent_if_absent(&intent)
945            .await
946            .expect("store intent");
947
948        storage
949            .update_send_intent(
950                &intent_id,
951                &SendIntentState::AwaitingConfirmation {
952                    batch_id: Uuid::new_v4(),
953                    txid: "indexed-txid".to_string(),
954                    outpoint: "indexed-txid:2".to_string(),
955                    fee_contribution_sat: 300,
956                    created_at: 1_700_000_000,
957                },
958            )
959            .await
960            .expect("mark intent awaiting confirmation");
961        assert_eq!(
962            storage
963                .get_quote_id_by_send_outpoint("indexed-txid:2")
964                .await
965                .expect("lookup indexed output"),
966            Some(intent.quote_id.clone())
967        );
968
969        storage
970            .update_send_intent(
971                &intent_id,
972                &SendIntentState::Pending {
973                    created_at: 1_700_000_000,
974                },
975            )
976            .await
977            .expect("revert intent to pending");
978        assert_eq!(
979            storage
980                .get_quote_id_by_send_outpoint("indexed-txid:2")
981                .await
982                .expect("lookup removed output index"),
983            None
984        );
985    }
986
987    #[tokio::test]
988    async fn test_backfill_send_outpoint_quote_id_index() {
989        let storage = test_storage().await;
990        let active_intent = SendIntentRecord {
991            intent_id: Uuid::new_v4(),
992            quote_id: "active-legacy-quote".to_string(),
993            address: "bcrt1qw508d6qejxtdg4y5r3zarvary0c5xw7kygt080".to_string(),
994            amount_sat: 75_000,
995            max_fee_amount_sat: 2_000,
996            tier: PaymentTier::Standard,
997            metadata: PaymentMetadata::default(),
998            state: SendIntentState::AwaitingConfirmation {
999                batch_id: Uuid::new_v4(),
1000                txid: "active-legacy-txid".to_string(),
1001                outpoint: "active-legacy-txid:0".to_string(),
1002                fee_contribution_sat: 300,
1003                created_at: 1_700_000_000,
1004            },
1005        };
1006        let finalized_intent = FinalizedSendIntentRecord {
1007            intent_id: Uuid::new_v4(),
1008            quote_id: "finalized-legacy-quote".to_string(),
1009            total_spent_sat: 76_000,
1010            outpoint: "finalized-legacy-txid:1".to_string(),
1011            finalized_at: 1_700_000_001,
1012        };
1013        storage
1014            .put_record(&active_intent)
1015            .await
1016            .expect("store legacy active intent");
1017        storage
1018            .put_record(&finalized_intent)
1019            .await
1020            .expect("store legacy finalized intent");
1021
1022        assert_eq!(
1023            storage
1024                .get_quote_id_by_send_outpoint("active-legacy-txid:0")
1025                .await
1026                .expect("lookup missing active index"),
1027            None
1028        );
1029        storage
1030            .ensure_send_outpoint_quote_id_index()
1031            .await
1032            .expect("backfill output quote index");
1033        assert_eq!(
1034            storage
1035                .get_quote_id_by_send_outpoint("active-legacy-txid:0")
1036                .await
1037                .expect("lookup active index"),
1038            Some(active_intent.quote_id)
1039        );
1040        assert_eq!(
1041            storage
1042                .get_quote_id_by_send_outpoint("finalized-legacy-txid:1")
1043                .await
1044                .expect("lookup finalized index"),
1045            Some(finalized_intent.quote_id)
1046        );
1047    }
1048
1049    #[tokio::test]
1050    async fn test_finalize_confirmed_intent_and_cleanup_batch() {
1051        let storage = test_storage().await;
1052        let batch_id = Uuid::new_v4();
1053        let intent_id_1 = Uuid::new_v4();
1054        let intent_id_2 = Uuid::new_v4();
1055
1056        // Store a Broadcast batch referencing two intents
1057        let batch = SendBatchRecord {
1058            batch_id,
1059            state: SendBatchState::Broadcast {
1060                txid: "tx123".to_string(),
1061                tx_bytes: vec![0x01],
1062                assignments: vec![
1063                    BatchOutputAssignment {
1064                        intent_id: intent_id_1,
1065                        vout: 0,
1066                        fee_contribution_sat: 250,
1067                    },
1068                    BatchOutputAssignment {
1069                        intent_id: intent_id_2,
1070                        vout: 1,
1071                        fee_contribution_sat: 250,
1072                    },
1073                ],
1074                fee_sat: 500,
1075            },
1076        };
1077        storage.store_send_batch(&batch).await.expect("store batch");
1078
1079        // Store both intents as AwaitingConfirmation
1080        for (id, quote) in [(intent_id_1, "q1"), (intent_id_2, "q2")] {
1081            let intent = SendIntentRecord {
1082                intent_id: id,
1083                quote_id: quote.to_string(),
1084                address: "bcrt1qaddr".to_string(),
1085                amount_sat: 10_000,
1086                max_fee_amount_sat: 500,
1087                tier: PaymentTier::Immediate,
1088                metadata: PaymentMetadata::default(),
1089                state: SendIntentState::AwaitingConfirmation {
1090                    batch_id,
1091                    txid: "tx123".to_string(),
1092                    outpoint: format!("tx123:{}", if id == intent_id_1 { 0 } else { 1 }),
1093                    fee_contribution_sat: 250,
1094                    created_at: 1_700_000_000,
1095                },
1096            };
1097            storage
1098                .create_send_intent_if_absent(&intent)
1099                .await
1100                .expect("store intent");
1101        }
1102
1103        // "Finalize" first intent (simulating confirmation handler)
1104        storage
1105            .delete_send_intent(&intent_id_1)
1106            .await
1107            .expect("delete first");
1108
1109        // Batch should still exist -- second intent is still active
1110        let remaining_intents = storage.get_all_send_intents().await.expect("all intents");
1111        assert_eq!(remaining_intents.len(), 1);
1112        assert_eq!(remaining_intents[0].intent_id, intent_id_2);
1113
1114        // "Finalize" second intent
1115        storage
1116            .delete_send_intent(&intent_id_2)
1117            .await
1118            .expect("delete second");
1119
1120        // Now simulate cleanup_completed_batches logic:
1121        // Check if any intents still reference this batch
1122        let all_intents = storage.get_all_send_intents().await.expect("all intents");
1123        let batches = storage.get_all_send_batches().await.expect("all batches");
1124        assert_eq!(batches.len(), 1);
1125
1126        let batch_intent_ids: Vec<Uuid> = match &batches[0].state {
1127            SendBatchState::Broadcast { assignments, .. } => {
1128                assignments.iter().map(|a| a.intent_id).collect()
1129            }
1130            _ => panic!("Expected Broadcast"),
1131        };
1132        let has_remaining = batch_intent_ids
1133            .iter()
1134            .any(|bid| all_intents.iter().any(|i| i.intent_id == *bid));
1135        assert!(!has_remaining, "All intents finalized");
1136
1137        // Clean up batch
1138        storage
1139            .delete_send_batch(&batch_id)
1140            .await
1141            .expect("delete batch");
1142        let batches = storage.get_all_send_batches().await.expect("all batches");
1143        assert!(batches.is_empty());
1144    }
1145
1146    // ── Recovery storage-level tests ─────────────────────
1147
1148    #[tokio::test]
1149    async fn test_pre_broadcast_recovery_reverts_intents() {
1150        let storage = test_storage().await;
1151        let signed_intent_id = Uuid::new_v4();
1152        for state in [
1153            SendBatchState::Built {
1154                psbt_bytes: vec![0x01, 0x02],
1155                intent_ids: vec![Uuid::new_v4(), Uuid::new_v4()],
1156            },
1157            SendBatchState::Signed {
1158                tx_bytes: vec![0xAA, 0xBB],
1159                assignments: vec![BatchOutputAssignment {
1160                    intent_id: signed_intent_id,
1161                    vout: 0,
1162                    fee_contribution_sat: 100,
1163                }],
1164                fee_sat: 100,
1165            },
1166        ] {
1167            let batch_id = Uuid::new_v4();
1168            let intent_ids: Vec<Uuid> = match &state {
1169                SendBatchState::Built { intent_ids, .. } => intent_ids.clone(),
1170                SendBatchState::Signed { assignments, .. } => {
1171                    assignments.iter().map(|a| a.intent_id).collect()
1172                }
1173                SendBatchState::Broadcast { .. } => unreachable!(),
1174            };
1175
1176            let batch = SendBatchRecord { batch_id, state };
1177            storage.store_send_batch(&batch).await.expect("store batch");
1178
1179            for intent_id in &intent_ids {
1180                let intent = SendIntentRecord {
1181                    intent_id: *intent_id,
1182                    quote_id: format!("q-{}", intent_id),
1183                    address: "bcrt1qaddr".to_string(),
1184                    amount_sat: 25_000,
1185                    max_fee_amount_sat: 500,
1186                    tier: PaymentTier::Immediate,
1187                    metadata: PaymentMetadata::default(),
1188                    state: SendIntentState::Batched {
1189                        batch_id,
1190                        created_at: 1_700_000_000,
1191                    },
1192                };
1193                storage
1194                    .create_send_intent_if_absent(&intent)
1195                    .await
1196                    .expect("store intent");
1197            }
1198
1199            for intent_id in &intent_ids {
1200                storage
1201                    .update_send_intent(
1202                        intent_id,
1203                        &SendIntentState::Pending {
1204                            created_at: 1_700_000_000,
1205                        },
1206                    )
1207                    .await
1208                    .expect("revert intent");
1209            }
1210
1211            storage
1212                .delete_send_batch(&batch_id)
1213                .await
1214                .expect("delete batch");
1215
1216            let batches = storage.get_all_send_batches().await.expect("all batches");
1217            assert!(batches.iter().all(|b| b.batch_id != batch_id));
1218
1219            for intent_id in intent_ids {
1220                let intent = storage
1221                    .get_send_intent(&intent_id)
1222                    .await
1223                    .expect("get intent")
1224                    .expect("intent exists");
1225                assert!(matches!(intent.state, SendIntentState::Pending { .. }));
1226                storage
1227                    .delete_send_intent(&intent_id)
1228                    .await
1229                    .expect("cleanup intent");
1230            }
1231        }
1232    }
1233
1234    #[tokio::test]
1235    async fn test_post_broadcast_and_orphaned_recovery_storage_shapes() {
1236        let storage = test_storage().await;
1237        let broadcast_batch_id = Uuid::new_v4();
1238        let broadcast_intent_id = Uuid::new_v4();
1239        let orphan_batch_id = Uuid::new_v4();
1240        let orphan_intent_id = Uuid::new_v4();
1241
1242        let batch = SendBatchRecord {
1243            batch_id: broadcast_batch_id,
1244            state: SendBatchState::Broadcast {
1245                txid: "txid_broadcast".to_string(),
1246                tx_bytes: vec![0x01, 0x02, 0x03],
1247                assignments: vec![BatchOutputAssignment {
1248                    intent_id: broadcast_intent_id,
1249                    vout: 0,
1250                    fee_contribution_sat: 200,
1251                }],
1252                fee_sat: 200,
1253            },
1254        };
1255        storage.store_send_batch(&batch).await.expect("store batch");
1256
1257        let awaiting_intent = SendIntentRecord {
1258            intent_id: broadcast_intent_id,
1259            quote_id: "q-broadcast".to_string(),
1260            address: "bcrt1qaddr".to_string(),
1261            amount_sat: 40_000,
1262            max_fee_amount_sat: 800,
1263            tier: PaymentTier::Economy,
1264            metadata: PaymentMetadata::default(),
1265            state: SendIntentState::AwaitingConfirmation {
1266                batch_id: broadcast_batch_id,
1267                txid: "txid_broadcast".to_string(),
1268                outpoint: "txid_broadcast:0".to_string(),
1269                fee_contribution_sat: 200,
1270                created_at: 1_700_000_000,
1271            },
1272        };
1273        storage
1274            .create_send_intent_if_absent(&awaiting_intent)
1275            .await
1276            .expect("store awaiting intent");
1277
1278        let orphan_intent = SendIntentRecord {
1279            intent_id: orphan_intent_id,
1280            quote_id: "q-orphan".to_string(),
1281            address: "bcrt1qaddr".to_string(),
1282            amount_sat: 20_000,
1283            max_fee_amount_sat: 400,
1284            tier: PaymentTier::Immediate,
1285            metadata: PaymentMetadata::default(),
1286            state: SendIntentState::Batched {
1287                batch_id: orphan_batch_id,
1288                created_at: 1_700_000_000,
1289            },
1290        };
1291        storage
1292            .create_send_intent_if_absent(&orphan_intent)
1293            .await
1294            .expect("store orphan intent");
1295
1296        let batches = storage.get_all_send_batches().await.expect("all batches");
1297        assert_eq!(batches.len(), 1);
1298        assert!(matches!(batches[0].state, SendBatchState::Broadcast { .. }));
1299
1300        let awaiting = storage
1301            .get_send_intent(&broadcast_intent_id)
1302            .await
1303            .expect("get awaiting")
1304            .expect("awaiting exists");
1305        assert!(matches!(
1306            awaiting.state,
1307            SendIntentState::AwaitingConfirmation { .. }
1308        ));
1309
1310        storage
1311            .update_send_intent(
1312                &orphan_intent_id,
1313                &SendIntentState::Pending {
1314                    created_at: 1_700_000_000,
1315                },
1316            )
1317            .await
1318            .expect("revert orphan");
1319
1320        let orphan = storage
1321            .get_send_intent(&orphan_intent_id)
1322            .await
1323            .expect("get orphan")
1324            .expect("orphan exists");
1325        assert!(matches!(orphan.state, SendIntentState::Pending { .. }));
1326    }
1327
1328    #[tokio::test]
1329    async fn test_recovery_shape_batch_can_reference_missing_intent() {
1330        let storage = test_storage().await;
1331        let batch_id = Uuid::new_v4();
1332        let present_intent_id = Uuid::new_v4();
1333        let missing_intent_id = Uuid::new_v4();
1334
1335        let batch = SendBatchRecord {
1336            batch_id,
1337            state: SendBatchState::Built {
1338                psbt_bytes: vec![0x01, 0x02],
1339                intent_ids: vec![present_intent_id, missing_intent_id],
1340            },
1341        };
1342        storage.store_send_batch(&batch).await.expect("store batch");
1343
1344        let intent = SendIntentRecord {
1345            intent_id: present_intent_id,
1346            quote_id: "q-present".to_string(),
1347            address: "bcrt1qaddr".to_string(),
1348            amount_sat: 25_000,
1349            max_fee_amount_sat: 500,
1350            tier: PaymentTier::Immediate,
1351            metadata: PaymentMetadata::default(),
1352            state: SendIntentState::Batched {
1353                batch_id,
1354                created_at: 1_700_000_000,
1355            },
1356        };
1357        storage
1358            .create_send_intent_if_absent(&intent)
1359            .await
1360            .expect("store present intent");
1361
1362        let stored_batch = storage
1363            .get_send_batch(&batch_id)
1364            .await
1365            .expect("get batch")
1366            .expect("batch exists");
1367        match stored_batch.state {
1368            SendBatchState::Built { intent_ids, .. } => {
1369                assert_eq!(intent_ids.len(), 2);
1370                assert!(intent_ids.contains(&missing_intent_id));
1371            }
1372            _ => panic!("expected built batch"),
1373        }
1374    }
1375
1376    #[tokio::test]
1377    async fn test_recovery_shape_intent_can_reference_missing_batch() {
1378        let storage = test_storage().await;
1379        let batch_id = Uuid::new_v4();
1380        let intent_id = Uuid::new_v4();
1381
1382        let intent = SendIntentRecord {
1383            intent_id,
1384            quote_id: "q-missing-batch".to_string(),
1385            address: "bcrt1qaddr".to_string(),
1386            amount_sat: 15_000,
1387            max_fee_amount_sat: 300,
1388            tier: PaymentTier::Immediate,
1389            metadata: PaymentMetadata::default(),
1390            state: SendIntentState::Batched {
1391                batch_id,
1392                created_at: 1_700_000_000,
1393            },
1394        };
1395        storage
1396            .create_send_intent_if_absent(&intent)
1397            .await
1398            .expect("store intent");
1399
1400        let stored = storage
1401            .get_send_intent(&intent_id)
1402            .await
1403            .expect("get intent")
1404            .expect("intent exists");
1405        match stored.state {
1406            SendIntentState::Batched {
1407                batch_id: stored_batch_id,
1408                ..
1409            } => {
1410                assert_eq!(stored_batch_id, batch_id);
1411            }
1412            _ => panic!("expected batched intent"),
1413        }
1414
1415        assert!(
1416            storage
1417                .get_send_batch(&batch_id)
1418                .await
1419                .expect("get batch")
1420                .is_none(),
1421            "batch should be missing for orphan intent scenario"
1422        );
1423    }
1424
1425    #[tokio::test]
1426    async fn test_recovery_shape_batch_and_intent_can_disagree_on_membership() {
1427        let storage = test_storage().await;
1428        let referenced_batch_id = Uuid::new_v4();
1429        let actual_batch_id = Uuid::new_v4();
1430        let intent_id = Uuid::new_v4();
1431
1432        let batch = SendBatchRecord {
1433            batch_id: actual_batch_id,
1434            state: SendBatchState::Broadcast {
1435                txid: "txid_membership".to_string(),
1436                tx_bytes: vec![0x01, 0x02, 0x03],
1437                assignments: Vec::new(),
1438                fee_sat: 200,
1439            },
1440        };
1441        storage.store_send_batch(&batch).await.expect("store batch");
1442
1443        let intent = SendIntentRecord {
1444            intent_id,
1445            quote_id: "q-membership".to_string(),
1446            address: "bcrt1qaddr".to_string(),
1447            amount_sat: 30_000,
1448            max_fee_amount_sat: 700,
1449            tier: PaymentTier::Standard,
1450            metadata: PaymentMetadata::default(),
1451            state: SendIntentState::AwaitingConfirmation {
1452                batch_id: referenced_batch_id,
1453                txid: "txid_membership".to_string(),
1454                outpoint: "txid_membership:0".to_string(),
1455                fee_contribution_sat: 200,
1456                created_at: 1_700_000_000,
1457            },
1458        };
1459        storage
1460            .create_send_intent_if_absent(&intent)
1461            .await
1462            .expect("store intent");
1463
1464        let stored_batch = storage
1465            .get_send_batch(&actual_batch_id)
1466            .await
1467            .expect("get batch")
1468            .expect("batch exists");
1469        match stored_batch.state {
1470            SendBatchState::Broadcast { assignments, .. } => {
1471                assert!(
1472                    assignments.is_empty(),
1473                    "batch intentionally excludes the intent"
1474                );
1475            }
1476            _ => panic!("expected broadcast batch"),
1477        }
1478
1479        let stored_intent = storage
1480            .get_send_intent(&intent_id)
1481            .await
1482            .expect("get intent")
1483            .expect("intent exists");
1484        match stored_intent.state {
1485            SendIntentState::AwaitingConfirmation { batch_id, .. } => {
1486                assert_eq!(batch_id, referenced_batch_id);
1487            }
1488            _ => panic!("expected awaiting confirmation intent"),
1489        }
1490    }
1491
1492    #[tokio::test]
1493    async fn test_recovery_shape_batch_can_have_mixed_intent_states() {
1494        let storage = test_storage().await;
1495        let batch_id = Uuid::new_v4();
1496        let batched_intent_id = Uuid::new_v4();
1497        let awaiting_intent_id = Uuid::new_v4();
1498
1499        let batch = SendBatchRecord {
1500            batch_id,
1501            state: SendBatchState::Broadcast {
1502                txid: "txid_mixed".to_string(),
1503                tx_bytes: vec![0x01, 0x02, 0x03],
1504                assignments: vec![
1505                    BatchOutputAssignment {
1506                        intent_id: batched_intent_id,
1507                        vout: 0,
1508                        fee_contribution_sat: 200,
1509                    },
1510                    BatchOutputAssignment {
1511                        intent_id: awaiting_intent_id,
1512                        vout: 1,
1513                        fee_contribution_sat: 200,
1514                    },
1515                ],
1516                fee_sat: 400,
1517            },
1518        };
1519        storage.store_send_batch(&batch).await.expect("store batch");
1520
1521        for (intent_id, state) in [
1522            (
1523                batched_intent_id,
1524                SendIntentState::Batched {
1525                    batch_id,
1526                    created_at: 1_700_000_000,
1527                },
1528            ),
1529            (
1530                awaiting_intent_id,
1531                SendIntentState::AwaitingConfirmation {
1532                    batch_id,
1533                    txid: "txid_mixed".to_string(),
1534                    outpoint: "txid_mixed:1".to_string(),
1535                    fee_contribution_sat: 200,
1536                    created_at: 1_700_000_000,
1537                },
1538            ),
1539        ] {
1540            let intent = SendIntentRecord {
1541                intent_id,
1542                quote_id: format!("q-{}", intent_id),
1543                address: "bcrt1qaddr".to_string(),
1544                amount_sat: 10_000,
1545                max_fee_amount_sat: 500,
1546                tier: PaymentTier::Immediate,
1547                metadata: PaymentMetadata::default(),
1548                state,
1549            };
1550            storage
1551                .create_send_intent_if_absent(&intent)
1552                .await
1553                .expect("store intent");
1554        }
1555
1556        let intents = storage.get_all_send_intents().await.expect("all intents");
1557        assert_eq!(intents.len(), 2);
1558        assert!(intents
1559            .iter()
1560            .any(|intent| matches!(intent.state, SendIntentState::Batched { .. })));
1561        assert!(intents
1562            .iter()
1563            .any(|intent| matches!(intent.state, SendIntentState::AwaitingConfirmation { .. })));
1564    }
1565
1566    // ── Receive saga: serialization round-trip tests ─────────────────
1567
1568    #[test]
1569    fn test_receive_intent_record_state_roundtrip() {
1570        use crate::receive::receive_intent::record::{ReceiveIntentRecord, ReceiveIntentState};
1571
1572        let state = ReceiveIntentState::Detected {
1573            address: "bcrt1qaddr".to_string(),
1574            txid: "abc123".to_string(),
1575            outpoint: "abc123:0".to_string(),
1576            amount_sat: 50_000,
1577            block_height: 100,
1578            created_at: 1_700_000_000,
1579        };
1580        let json = serde_json::to_string(&state).expect("serialize state");
1581        let deserialized: ReceiveIntentState =
1582            serde_json::from_str(&json).expect("deserialize state");
1583        let json2 = serde_json::to_string(&deserialized).expect("re-serialize");
1584        assert_eq!(json, json2, "Round-trip failed for receive intent state");
1585
1586        // Full intent round-trip
1587        let intent = ReceiveIntentRecord {
1588            intent_id: Uuid::new_v4(),
1589            quote_id: Uuid::new_v4().to_string(),
1590            state,
1591        };
1592        let json = serde_json::to_string(&intent).expect("serialize intent");
1593        let deserialized: ReceiveIntentRecord =
1594            serde_json::from_str(&json).expect("deserialize intent");
1595        assert_eq!(intent.intent_id, deserialized.intent_id);
1596        assert_eq!(intent.quote_id, deserialized.quote_id);
1597    }
1598
1599    #[test]
1600    fn test_finalized_receive_intent_roundtrip() {
1601        let tombstone = FinalizedReceiveIntentRecord {
1602            intent_id: Uuid::new_v4(),
1603            quote_id: Uuid::new_v4().to_string(),
1604            address: "bcrt1qaddr".to_string(),
1605            txid: "abc123".to_string(),
1606            outpoint: "abc123:0".to_string(),
1607            amount_sat: 50_000,
1608            finalized_at: 1_700_000_001,
1609        };
1610        let json = serde_json::to_string(&tombstone).expect("serialize");
1611        let deserialized: FinalizedReceiveIntentRecord =
1612            serde_json::from_str(&json).expect("deserialize");
1613        assert_eq!(tombstone.intent_id, deserialized.intent_id);
1614        assert_eq!(tombstone.quote_id, deserialized.quote_id);
1615        assert_eq!(tombstone.address, deserialized.address);
1616        assert_eq!(tombstone.txid, deserialized.txid);
1617        assert_eq!(tombstone.outpoint, deserialized.outpoint);
1618        assert_eq!(tombstone.amount_sat, deserialized.amount_sat);
1619        assert_eq!(tombstone.finalized_at, deserialized.finalized_at);
1620    }
1621
1622    // ── Receive saga: address index tests ────────────────────────────
1623
1624    #[tokio::test]
1625    async fn test_receive_address_quote_id_index() {
1626        let storage = test_storage().await;
1627
1628        let q1 = Uuid::new_v4().to_string();
1629        let q2 = Uuid::new_v4().to_string();
1630
1631        storage
1632            .track_receive_address("bcrt1qaddr1", &q1)
1633            .await
1634            .expect("track addr1");
1635        storage
1636            .track_receive_address("bcrt1qaddr2", &q2)
1637            .await
1638            .expect("track addr2");
1639
1640        let fetched = storage
1641            .get_quote_id_by_receive_address("bcrt1qaddr1")
1642            .await
1643            .expect("get by address")
1644            .expect("should exist");
1645        assert_eq!(fetched, q1);
1646
1647        let fetched2 = storage
1648            .get_quote_id_by_receive_address("bcrt1qaddr2")
1649            .await
1650            .expect("get by address")
1651            .expect("should exist");
1652        assert_eq!(fetched2, q2);
1653
1654        let missing = storage
1655            .get_quote_id_by_receive_address("unknown")
1656            .await
1657            .expect("get by address");
1658        assert!(missing.is_none());
1659    }
1660
1661    // ── Receive saga: intent CRUD tests ──────────────────────────────
1662
1663    #[tokio::test]
1664    async fn test_receive_intent_crud() {
1665        use crate::receive::receive_intent::record::{ReceiveIntentRecord, ReceiveIntentState};
1666
1667        let storage = test_storage().await;
1668        let intent_id = Uuid::new_v4();
1669        let quote_id = Uuid::new_v4().to_string();
1670        let intent = ReceiveIntentRecord {
1671            intent_id,
1672            quote_id: quote_id.clone(),
1673            state: ReceiveIntentState::Detected {
1674                address: "bcrt1qaddr".to_string(),
1675                txid: "txid_abc".to_string(),
1676                outpoint: "txid_abc:0".to_string(),
1677                amount_sat: 50_000,
1678                block_height: 100,
1679                created_at: 1_700_000_000,
1680            },
1681        };
1682
1683        // Create
1684        let created = storage
1685            .create_receive_intent_if_absent(&intent)
1686            .await
1687            .expect("create");
1688        assert!(created);
1689
1690        // Get
1691        let fetched = storage
1692            .get_receive_intent(&intent_id)
1693            .await
1694            .expect("get")
1695            .expect("should exist");
1696        assert_eq!(fetched.intent_id, intent_id);
1697        assert_eq!(fetched.quote_id, quote_id);
1698
1699        // Get all
1700        let all = storage.get_all_receive_intents().await.expect("get all");
1701        assert_eq!(all.len(), 1);
1702
1703        // Delete
1704        storage
1705            .delete_receive_intent(&intent_id)
1706            .await
1707            .expect("delete");
1708        let gone = storage.get_receive_intent(&intent_id).await.expect("get");
1709        assert!(gone.is_none());
1710    }
1711
1712    #[tokio::test]
1713    async fn test_receive_intent_duplicate_outpoint_rejection() {
1714        use crate::receive::receive_intent::record::{ReceiveIntentRecord, ReceiveIntentState};
1715
1716        let storage = test_storage().await;
1717
1718        let intent1 = ReceiveIntentRecord {
1719            intent_id: Uuid::new_v4(),
1720            quote_id: Uuid::new_v4().to_string(),
1721            state: ReceiveIntentState::Detected {
1722                address: "bcrt1qaddr".to_string(),
1723                txid: "txid_abc".to_string(),
1724                outpoint: "txid_abc:0".to_string(),
1725                amount_sat: 50_000,
1726                block_height: 100,
1727                created_at: 1_700_000_000,
1728            },
1729        };
1730
1731        let intent2 = ReceiveIntentRecord {
1732            intent_id: Uuid::new_v4(),
1733            quote_id: Uuid::new_v4().to_string(),
1734            state: ReceiveIntentState::Detected {
1735                address: "bcrt1qaddr".to_string(),
1736                txid: "txid_abc".to_string(),
1737                outpoint: "txid_abc:0".to_string(), // same outpoint
1738                amount_sat: 50_000,
1739                block_height: 100,
1740                created_at: 1_700_000_001,
1741            },
1742        };
1743
1744        let created1 = storage
1745            .create_receive_intent_if_absent(&intent1)
1746            .await
1747            .expect("create first");
1748        assert!(created1);
1749
1750        let created2 = storage
1751            .create_receive_intent_if_absent(&intent2)
1752            .await
1753            .expect("create second (should not error)");
1754        assert!(!created2, "Duplicate outpoint should be rejected");
1755
1756        // Only one intent should exist
1757        let all = storage.get_all_receive_intents().await.expect("get all");
1758        assert_eq!(all.len(), 1);
1759        assert_eq!(all[0].intent_id, intent1.intent_id);
1760    }
1761
1762    #[tokio::test]
1763    async fn test_finalize_receive_intent_atomicity() {
1764        use crate::receive::receive_intent::record::{ReceiveIntentRecord, ReceiveIntentState};
1765
1766        let storage = test_storage().await;
1767        let intent_id = Uuid::new_v4();
1768        let quote_id = Uuid::new_v4().to_string();
1769
1770        let intent = ReceiveIntentRecord {
1771            intent_id,
1772            quote_id: quote_id.clone(),
1773            state: ReceiveIntentState::Detected {
1774                address: "bcrt1qaddr".to_string(),
1775                txid: "txid_abc".to_string(),
1776                outpoint: "txid_abc:0".to_string(),
1777                amount_sat: 50_000,
1778                block_height: 100,
1779                created_at: 1_700_000_000,
1780            },
1781        };
1782
1783        storage
1784            .create_receive_intent_if_absent(&intent)
1785            .await
1786            .expect("create");
1787
1788        let tombstone = FinalizedReceiveIntentRecord {
1789            intent_id,
1790            quote_id: quote_id.clone(),
1791            address: "bcrt1qaddr".to_string(),
1792            txid: "txid_abc".to_string(),
1793            outpoint: "txid_abc:0".to_string(),
1794            amount_sat: 50_000,
1795            finalized_at: 1_700_000_001,
1796        };
1797
1798        storage
1799            .finalize_receive_intent(&intent_id, &tombstone)
1800            .await
1801            .expect("finalize");
1802
1803        // Active record should be gone
1804        assert!(storage
1805            .get_receive_intent(&intent_id)
1806            .await
1807            .expect("get")
1808            .is_none());
1809
1810        // Tombstone should exist
1811        let fetched_tombstone = storage
1812            .get_finalized_receive_intent(&intent_id)
1813            .await
1814            .expect("get tombstone")
1815            .expect("tombstone should exist");
1816        assert_eq!(fetched_tombstone.intent_id, intent_id);
1817        assert_eq!(fetched_tombstone.amount_sat, 50_000);
1818
1819        // Outpoint should NOT be freed (cannot create a new intent with same outpoint)
1820        let intent2 = ReceiveIntentRecord {
1821            intent_id: Uuid::new_v4(),
1822            quote_id: Uuid::new_v4().to_string(),
1823            state: ReceiveIntentState::Detected {
1824                address: "bcrt1qaddr".to_string(),
1825                txid: "txid_abc".to_string(),
1826                outpoint: "txid_abc:0".to_string(),
1827                amount_sat: 60_000,
1828                block_height: 200,
1829                created_at: 1_700_000_002,
1830            },
1831        };
1832        let created = storage
1833            .create_receive_intent_if_absent(&intent2)
1834            .await
1835            .expect("create after finalization");
1836        assert!(
1837            !created,
1838            "Should NOT be able to create intent after outpoint is finalized"
1839        );
1840
1841        // A DIFFERENT outpoint for the SAME quote ID should be allowed
1842        let intent3 = ReceiveIntentRecord {
1843            intent_id: Uuid::new_v4(),
1844            quote_id: quote_id.clone(),
1845            state: ReceiveIntentState::Detected {
1846                address: "bcrt1qaddr".to_string(),
1847                txid: "txid_abc".to_string(),
1848                outpoint: "txid_abc:1".to_string(), // different vout
1849                amount_sat: 50_000,
1850                block_height: 100,
1851                created_at: 1_700_000_003,
1852            },
1853        };
1854        let created3 = storage
1855            .create_receive_intent_if_absent(&intent3)
1856            .await
1857            .expect("create different outpoint same quote");
1858        assert!(
1859            created3,
1860            "Should be able to create intent for a different outpoint even if same quote ID"
1861        );
1862    }
1863
1864    #[tokio::test]
1865    async fn test_tombstone_query_by_quote_id() {
1866        use crate::receive::receive_intent::record::{ReceiveIntentRecord, ReceiveIntentState};
1867
1868        let storage = test_storage().await;
1869
1870        // Create and finalize two intents for the same quote id
1871        let shared_quote_id = Uuid::new_v4().to_string();
1872        for (i, outpoint) in ["txid_a:0", "txid_b:1"].iter().enumerate() {
1873            let intent_id = Uuid::new_v4();
1874            let intent = ReceiveIntentRecord {
1875                intent_id,
1876                quote_id: shared_quote_id.to_string(),
1877                state: ReceiveIntentState::Detected {
1878                    address: "bcrt1qshared".to_string(),
1879                    txid: format!("txid_{}", i),
1880                    outpoint: outpoint.to_string(),
1881                    amount_sat: 10_000 * (i as u64 + 1),
1882                    block_height: 100 + i as u32,
1883                    created_at: 1_700_000_000 + i as u64,
1884                },
1885            };
1886            storage
1887                .create_receive_intent_if_absent(&intent)
1888                .await
1889                .expect("create");
1890
1891            let tombstone = FinalizedReceiveIntentRecord {
1892                intent_id,
1893                quote_id: shared_quote_id.to_string(),
1894                address: "bcrt1qshared".to_string(),
1895                txid: format!("txid_{}", i),
1896                outpoint: outpoint.to_string(),
1897                amount_sat: 10_000 * (i as u64 + 1),
1898                finalized_at: 1_700_000_010 + i as u64,
1899            };
1900            storage
1901                .finalize_receive_intent(&intent_id, &tombstone)
1902                .await
1903                .expect("finalize");
1904        }
1905
1906        // Also create and finalize one for a different quote id
1907        let other_id = Uuid::new_v4();
1908        let other_quote_id = Uuid::new_v4().to_string();
1909        let other = ReceiveIntentRecord {
1910            intent_id: other_id,
1911            quote_id: other_quote_id.to_string(),
1912            state: ReceiveIntentState::Detected {
1913                address: "bcrt1qother".to_string(),
1914                txid: "txid_c".to_string(),
1915                outpoint: "txid_c:0".to_string(),
1916                amount_sat: 99_000,
1917                block_height: 300,
1918                created_at: 1_700_000_100,
1919            },
1920        };
1921        storage
1922            .create_receive_intent_if_absent(&other)
1923            .await
1924            .expect("create other");
1925        storage
1926            .finalize_receive_intent(
1927                &other_id,
1928                &FinalizedReceiveIntentRecord {
1929                    intent_id: other_id,
1930                    quote_id: other_quote_id.to_string(),
1931                    address: "bcrt1qother".to_string(),
1932                    txid: "txid_c".to_string(),
1933                    outpoint: "txid_c:0".to_string(),
1934                    amount_sat: 99_000,
1935                    finalized_at: 1_700_000_200,
1936                },
1937            )
1938            .await
1939            .expect("finalize other");
1940
1941        // Query by quote id should return only matching ones
1942        let shared = storage
1943            .get_finalized_receive_intents_by_quote_id(&shared_quote_id)
1944            .await
1945            .expect("query shared");
1946        assert_eq!(shared.len(), 2);
1947        assert!(shared.iter().all(|t| t.quote_id == shared_quote_id));
1948
1949        let other_results = storage
1950            .get_finalized_receive_intents_by_quote_id(&other_quote_id)
1951            .await
1952            .expect("query other");
1953        assert_eq!(other_results.len(), 1);
1954        assert_eq!(other_results[0].amount_sat, 99_000);
1955
1956        // Unknown quote id returns empty
1957        let unknown = storage
1958            .get_finalized_receive_intents_by_quote_id("unknown")
1959            .await
1960            .expect("query unknown");
1961        assert!(unknown.is_empty());
1962    }
1963
1964    /// Regression test for the quote-id index write-skew race that affected
1965    /// `finalize_receive_intent` on Postgres `READ COMMITTED`. SQLite
1966    /// serializes writers via `BEGIN IMMEDIATE`, so this test exercises the
1967    /// new code path rather than reproducing the bug; the structural
1968    /// guarantee (one key per intent, no RMW) is what actually fixes
1969    /// Postgres.
1970    #[tokio::test]
1971    async fn test_finalize_receive_intent_concurrent_same_quote_id() {
1972        use crate::receive::receive_intent::record::{ReceiveIntentRecord, ReceiveIntentState};
1973
1974        let storage = test_storage().await;
1975        let shared_quote_id = Uuid::new_v4().to_string();
1976
1977        // Pre-create two active intents under the same quote id.
1978        let mut intent_ids = Vec::new();
1979        for (i, outpoint) in ["txid_a:0", "txid_b:1"].iter().enumerate() {
1980            let intent_id = Uuid::new_v4();
1981            intent_ids.push((intent_id, outpoint.to_string(), i));
1982            let intent = ReceiveIntentRecord {
1983                intent_id,
1984                quote_id: shared_quote_id.to_string(),
1985                state: ReceiveIntentState::Detected {
1986                    address: "bcrt1qshared".to_string(),
1987                    txid: format!("txid_{}", i),
1988                    outpoint: outpoint.to_string(),
1989                    amount_sat: 10_000 * (i as u64 + 1),
1990                    block_height: 100 + i as u32,
1991                    created_at: 1_700_000_000 + i as u64,
1992                },
1993            };
1994            storage
1995                .create_receive_intent_if_absent(&intent)
1996                .await
1997                .expect("create");
1998        }
1999
2000        // Finalize both concurrently.
2001        let storage_a = storage.clone();
2002        let quote_a = shared_quote_id.clone();
2003        let (intent_a, outpoint_a, i_a) = intent_ids[0].clone();
2004        let task_a = tokio::spawn(async move {
2005            let record = FinalizedReceiveIntentRecord {
2006                intent_id: intent_a,
2007                quote_id: quote_a,
2008                address: "bcrt1qshared".to_string(),
2009                txid: format!("txid_{}", i_a),
2010                outpoint: outpoint_a,
2011                amount_sat: 10_000 * (i_a as u64 + 1),
2012                finalized_at: 1_700_000_010 + i_a as u64,
2013            };
2014            storage_a.finalize_receive_intent(&intent_a, &record).await
2015        });
2016
2017        let storage_b = storage.clone();
2018        let quote_b = shared_quote_id.clone();
2019        let (intent_b, outpoint_b, i_b) = intent_ids[1].clone();
2020        let task_b = tokio::spawn(async move {
2021            let record = FinalizedReceiveIntentRecord {
2022                intent_id: intent_b,
2023                quote_id: quote_b,
2024                address: "bcrt1qshared".to_string(),
2025                txid: format!("txid_{}", i_b),
2026                outpoint: outpoint_b,
2027                amount_sat: 10_000 * (i_b as u64 + 1),
2028                finalized_at: 1_700_000_010 + i_b as u64,
2029            };
2030            storage_b.finalize_receive_intent(&intent_b, &record).await
2031        });
2032
2033        task_a.await.expect("join a").expect("finalize a");
2034        task_b.await.expect("join b").expect("finalize b");
2035
2036        // Both intent_ids must be recoverable from the quote-id index.
2037        let results = storage
2038            .get_finalized_receive_intents_by_quote_id(&shared_quote_id)
2039            .await
2040            .expect("query shared");
2041        assert_eq!(
2042            results.len(),
2043            2,
2044            "both concurrently finalized intents must appear in the quote-id index"
2045        );
2046        let returned_ids: std::collections::HashSet<Uuid> =
2047            results.iter().map(|r| r.intent_id).collect();
2048        assert!(returned_ids.contains(&intent_ids[0].0));
2049        assert!(returned_ids.contains(&intent_ids[1].0));
2050    }
2051}