1use 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
20pub const BDK_NAMESPACE: &str = "bdk";
22
23pub const SEND_INTENT_NAMESPACE: &str = "send_intent";
25
26pub const SEND_INTENT_QUOTE_ID_NAMESPACE: &str = "send_intent_quote_id";
28
29pub const SEND_OUTPOINT_QUOTE_ID_NAMESPACE: &str = "send_outpoint_quote_id";
31
32pub const STORAGE_MIGRATION_NAMESPACE: &str = "storage_migration";
34
35pub const SEND_OUTPOINT_QUOTE_ID_BACKFILL_KEY: &str = "send_outpoint_quote_id_v1";
37
38pub const SEND_BATCH_NAMESPACE: &str = "send_batch";
40
41pub const FAILED_SEND_ATTEMPT_NAMESPACE: &str = "failed_send_attempt";
43
44pub const FINALIZED_INTENT_NAMESPACE: &str = "finalized_intent";
48
49pub const RECEIVE_ADDRESS_QUOTE_ID_NAMESPACE: &str = "receive_address_quote_id";
51
52pub const RECEIVE_INTENT_NAMESPACE: &str = "receive_intent";
54
55pub const RECEIVE_INTENT_OUTPOINT_NAMESPACE: &str = "receive_intent_outpoint";
57
58pub const FINALIZED_RECEIVE_INTENT_NAMESPACE: &str = "finalized_receive_intent";
62
63pub const FINALIZED_RECEIVE_INTENT_OUTPOINT_NAMESPACE: &str = "finalized_receive_intent_outpoint";
65
66pub const FINALIZED_RECEIVE_INTENT_BY_QUOTE_NAMESPACE_PREFIX: &str =
74 "finalized_receive_intent_by_quote";
75
76pub fn finalized_receive_intent_by_quote_namespace(quote_id: &str) -> String {
78 format!("{FINALIZED_RECEIVE_INTENT_BY_QUOTE_NAMESPACE_PREFIX}__{quote_id}")
79}
80
81pub const FINALIZED_SEND_INTENT_QUOTE_ID_NAMESPACE: &str = "finalized_send_intent_quote_id";
83
84fn 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#[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 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 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 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 #[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 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 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 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 #[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 storage
444 .create_send_intent_if_absent(&intent)
445 .await
446 .expect("store");
447
448 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 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 let all = storage.get_all_send_intents().await.expect("get_all");
495 assert_eq!(all.len(), 1);
496
497 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 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 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 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 #[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 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 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 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 #[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 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 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("e_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 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 #[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 storage.store_send_batch(&batch).await.expect("store");
723
724 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 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 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 let all = storage.get_all_send_batches().await.expect("get_all");
819 assert_eq!(all.len(), 1);
820
821 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 #[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 #[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 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 let all = storage.get_all_send_intents().await.expect("get_all");
900 assert_eq!(all.len(), 2);
901
902 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 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 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 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 storage
1105 .delete_send_intent(&intent_id_1)
1106 .await
1107 .expect("delete first");
1108
1109 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 storage
1116 .delete_send_intent(&intent_id_2)
1117 .await
1118 .expect("delete second");
1119
1120 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 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 #[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 #[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 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 #[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 #[tokio::test]
1662 async fn test_track_receive_address_never_remaps_to_another_quote() {
1663 let storage = test_storage().await;
1664
1665 let q1 = Uuid::new_v4().to_string();
1666 let q2 = Uuid::new_v4().to_string();
1667
1668 let reserved = storage
1669 .track_receive_address("bcrt1qaddr1", &q1)
1670 .await
1671 .expect("track addr1");
1672 assert!(reserved);
1673
1674 let reserved = storage
1676 .track_receive_address("bcrt1qaddr1", &q1)
1677 .await
1678 .expect("re-track addr1");
1679 assert!(reserved);
1680
1681 let reserved = storage
1683 .track_receive_address("bcrt1qaddr1", &q2)
1684 .await
1685 .expect("track addr1 for q2");
1686 assert!(!reserved);
1687
1688 let fetched = storage
1689 .get_quote_id_by_receive_address("bcrt1qaddr1")
1690 .await
1691 .expect("get by address")
1692 .expect("should exist");
1693 assert_eq!(fetched, q1);
1694 }
1695
1696 #[tokio::test]
1699 async fn test_receive_intent_crud() {
1700 use crate::receive::receive_intent::record::{ReceiveIntentRecord, ReceiveIntentState};
1701
1702 let storage = test_storage().await;
1703 let intent_id = Uuid::new_v4();
1704 let quote_id = Uuid::new_v4().to_string();
1705 let intent = ReceiveIntentRecord {
1706 intent_id,
1707 quote_id: quote_id.clone(),
1708 state: ReceiveIntentState::Detected {
1709 address: "bcrt1qaddr".to_string(),
1710 txid: "txid_abc".to_string(),
1711 outpoint: "txid_abc:0".to_string(),
1712 amount_sat: 50_000,
1713 block_height: 100,
1714 created_at: 1_700_000_000,
1715 },
1716 };
1717
1718 let created = storage
1720 .create_receive_intent_if_absent(&intent)
1721 .await
1722 .expect("create");
1723 assert!(created);
1724
1725 let fetched = storage
1727 .get_receive_intent(&intent_id)
1728 .await
1729 .expect("get")
1730 .expect("should exist");
1731 assert_eq!(fetched.intent_id, intent_id);
1732 assert_eq!(fetched.quote_id, quote_id);
1733
1734 let all = storage.get_all_receive_intents().await.expect("get all");
1736 assert_eq!(all.len(), 1);
1737
1738 storage
1740 .delete_receive_intent(&intent_id)
1741 .await
1742 .expect("delete");
1743 let gone = storage.get_receive_intent(&intent_id).await.expect("get");
1744 assert!(gone.is_none());
1745 }
1746
1747 #[tokio::test]
1748 async fn test_receive_intent_duplicate_outpoint_rejection() {
1749 use crate::receive::receive_intent::record::{ReceiveIntentRecord, ReceiveIntentState};
1750
1751 let storage = test_storage().await;
1752
1753 let intent1 = ReceiveIntentRecord {
1754 intent_id: Uuid::new_v4(),
1755 quote_id: Uuid::new_v4().to_string(),
1756 state: ReceiveIntentState::Detected {
1757 address: "bcrt1qaddr".to_string(),
1758 txid: "txid_abc".to_string(),
1759 outpoint: "txid_abc:0".to_string(),
1760 amount_sat: 50_000,
1761 block_height: 100,
1762 created_at: 1_700_000_000,
1763 },
1764 };
1765
1766 let intent2 = ReceiveIntentRecord {
1767 intent_id: Uuid::new_v4(),
1768 quote_id: Uuid::new_v4().to_string(),
1769 state: ReceiveIntentState::Detected {
1770 address: "bcrt1qaddr".to_string(),
1771 txid: "txid_abc".to_string(),
1772 outpoint: "txid_abc:0".to_string(), amount_sat: 50_000,
1774 block_height: 100,
1775 created_at: 1_700_000_001,
1776 },
1777 };
1778
1779 let created1 = storage
1780 .create_receive_intent_if_absent(&intent1)
1781 .await
1782 .expect("create first");
1783 assert!(created1);
1784
1785 let created2 = storage
1786 .create_receive_intent_if_absent(&intent2)
1787 .await
1788 .expect("create second (should not error)");
1789 assert!(!created2, "Duplicate outpoint should be rejected");
1790
1791 let all = storage.get_all_receive_intents().await.expect("get all");
1793 assert_eq!(all.len(), 1);
1794 assert_eq!(all[0].intent_id, intent1.intent_id);
1795 }
1796
1797 #[tokio::test]
1798 async fn test_finalize_receive_intent_atomicity() {
1799 use crate::receive::receive_intent::record::{ReceiveIntentRecord, ReceiveIntentState};
1800
1801 let storage = test_storage().await;
1802 let intent_id = Uuid::new_v4();
1803 let quote_id = Uuid::new_v4().to_string();
1804
1805 let intent = ReceiveIntentRecord {
1806 intent_id,
1807 quote_id: quote_id.clone(),
1808 state: ReceiveIntentState::Detected {
1809 address: "bcrt1qaddr".to_string(),
1810 txid: "txid_abc".to_string(),
1811 outpoint: "txid_abc:0".to_string(),
1812 amount_sat: 50_000,
1813 block_height: 100,
1814 created_at: 1_700_000_000,
1815 },
1816 };
1817
1818 storage
1819 .create_receive_intent_if_absent(&intent)
1820 .await
1821 .expect("create");
1822
1823 let tombstone = FinalizedReceiveIntentRecord {
1824 intent_id,
1825 quote_id: quote_id.clone(),
1826 address: "bcrt1qaddr".to_string(),
1827 txid: "txid_abc".to_string(),
1828 outpoint: "txid_abc:0".to_string(),
1829 amount_sat: 50_000,
1830 finalized_at: 1_700_000_001,
1831 };
1832
1833 storage
1834 .finalize_receive_intent(&intent_id, &tombstone)
1835 .await
1836 .expect("finalize");
1837
1838 assert!(storage
1840 .get_receive_intent(&intent_id)
1841 .await
1842 .expect("get")
1843 .is_none());
1844
1845 let fetched_tombstone = storage
1847 .get_finalized_receive_intent(&intent_id)
1848 .await
1849 .expect("get tombstone")
1850 .expect("tombstone should exist");
1851 assert_eq!(fetched_tombstone.intent_id, intent_id);
1852 assert_eq!(fetched_tombstone.amount_sat, 50_000);
1853
1854 let intent2 = ReceiveIntentRecord {
1856 intent_id: Uuid::new_v4(),
1857 quote_id: Uuid::new_v4().to_string(),
1858 state: ReceiveIntentState::Detected {
1859 address: "bcrt1qaddr".to_string(),
1860 txid: "txid_abc".to_string(),
1861 outpoint: "txid_abc:0".to_string(),
1862 amount_sat: 60_000,
1863 block_height: 200,
1864 created_at: 1_700_000_002,
1865 },
1866 };
1867 let created = storage
1868 .create_receive_intent_if_absent(&intent2)
1869 .await
1870 .expect("create after finalization");
1871 assert!(
1872 !created,
1873 "Should NOT be able to create intent after outpoint is finalized"
1874 );
1875
1876 let intent3 = ReceiveIntentRecord {
1878 intent_id: Uuid::new_v4(),
1879 quote_id: quote_id.clone(),
1880 state: ReceiveIntentState::Detected {
1881 address: "bcrt1qaddr".to_string(),
1882 txid: "txid_abc".to_string(),
1883 outpoint: "txid_abc:1".to_string(), amount_sat: 50_000,
1885 block_height: 100,
1886 created_at: 1_700_000_003,
1887 },
1888 };
1889 let created3 = storage
1890 .create_receive_intent_if_absent(&intent3)
1891 .await
1892 .expect("create different outpoint same quote");
1893 assert!(
1894 created3,
1895 "Should be able to create intent for a different outpoint even if same quote ID"
1896 );
1897 }
1898
1899 #[tokio::test]
1900 async fn test_tombstone_query_by_quote_id() {
1901 use crate::receive::receive_intent::record::{ReceiveIntentRecord, ReceiveIntentState};
1902
1903 let storage = test_storage().await;
1904
1905 let shared_quote_id = Uuid::new_v4().to_string();
1907 for (i, outpoint) in ["txid_a:0", "txid_b:1"].iter().enumerate() {
1908 let intent_id = Uuid::new_v4();
1909 let intent = ReceiveIntentRecord {
1910 intent_id,
1911 quote_id: shared_quote_id.to_string(),
1912 state: ReceiveIntentState::Detected {
1913 address: "bcrt1qshared".to_string(),
1914 txid: format!("txid_{}", i),
1915 outpoint: outpoint.to_string(),
1916 amount_sat: 10_000 * (i as u64 + 1),
1917 block_height: 100 + i as u32,
1918 created_at: 1_700_000_000 + i as u64,
1919 },
1920 };
1921 storage
1922 .create_receive_intent_if_absent(&intent)
1923 .await
1924 .expect("create");
1925
1926 let tombstone = FinalizedReceiveIntentRecord {
1927 intent_id,
1928 quote_id: shared_quote_id.to_string(),
1929 address: "bcrt1qshared".to_string(),
1930 txid: format!("txid_{}", i),
1931 outpoint: outpoint.to_string(),
1932 amount_sat: 10_000 * (i as u64 + 1),
1933 finalized_at: 1_700_000_010 + i as u64,
1934 };
1935 storage
1936 .finalize_receive_intent(&intent_id, &tombstone)
1937 .await
1938 .expect("finalize");
1939 }
1940
1941 let other_id = Uuid::new_v4();
1943 let other_quote_id = Uuid::new_v4().to_string();
1944 let other = ReceiveIntentRecord {
1945 intent_id: other_id,
1946 quote_id: other_quote_id.to_string(),
1947 state: ReceiveIntentState::Detected {
1948 address: "bcrt1qother".to_string(),
1949 txid: "txid_c".to_string(),
1950 outpoint: "txid_c:0".to_string(),
1951 amount_sat: 99_000,
1952 block_height: 300,
1953 created_at: 1_700_000_100,
1954 },
1955 };
1956 storage
1957 .create_receive_intent_if_absent(&other)
1958 .await
1959 .expect("create other");
1960 storage
1961 .finalize_receive_intent(
1962 &other_id,
1963 &FinalizedReceiveIntentRecord {
1964 intent_id: other_id,
1965 quote_id: other_quote_id.to_string(),
1966 address: "bcrt1qother".to_string(),
1967 txid: "txid_c".to_string(),
1968 outpoint: "txid_c:0".to_string(),
1969 amount_sat: 99_000,
1970 finalized_at: 1_700_000_200,
1971 },
1972 )
1973 .await
1974 .expect("finalize other");
1975
1976 let shared = storage
1978 .get_finalized_receive_intents_by_quote_id(&shared_quote_id)
1979 .await
1980 .expect("query shared");
1981 assert_eq!(shared.len(), 2);
1982 assert!(shared.iter().all(|t| t.quote_id == shared_quote_id));
1983
1984 let other_results = storage
1985 .get_finalized_receive_intents_by_quote_id(&other_quote_id)
1986 .await
1987 .expect("query other");
1988 assert_eq!(other_results.len(), 1);
1989 assert_eq!(other_results[0].amount_sat, 99_000);
1990
1991 let unknown = storage
1993 .get_finalized_receive_intents_by_quote_id("unknown")
1994 .await
1995 .expect("query unknown");
1996 assert!(unknown.is_empty());
1997 }
1998
1999 #[tokio::test]
2006 async fn test_finalize_receive_intent_concurrent_same_quote_id() {
2007 use crate::receive::receive_intent::record::{ReceiveIntentRecord, ReceiveIntentState};
2008
2009 let storage = test_storage().await;
2010 let shared_quote_id = Uuid::new_v4().to_string();
2011
2012 let mut intent_ids = Vec::new();
2014 for (i, outpoint) in ["txid_a:0", "txid_b:1"].iter().enumerate() {
2015 let intent_id = Uuid::new_v4();
2016 intent_ids.push((intent_id, outpoint.to_string(), i));
2017 let intent = ReceiveIntentRecord {
2018 intent_id,
2019 quote_id: shared_quote_id.to_string(),
2020 state: ReceiveIntentState::Detected {
2021 address: "bcrt1qshared".to_string(),
2022 txid: format!("txid_{}", i),
2023 outpoint: outpoint.to_string(),
2024 amount_sat: 10_000 * (i as u64 + 1),
2025 block_height: 100 + i as u32,
2026 created_at: 1_700_000_000 + i as u64,
2027 },
2028 };
2029 storage
2030 .create_receive_intent_if_absent(&intent)
2031 .await
2032 .expect("create");
2033 }
2034
2035 let storage_a = storage.clone();
2037 let quote_a = shared_quote_id.clone();
2038 let (intent_a, outpoint_a, i_a) = intent_ids[0].clone();
2039 let task_a = tokio::spawn(async move {
2040 let record = FinalizedReceiveIntentRecord {
2041 intent_id: intent_a,
2042 quote_id: quote_a,
2043 address: "bcrt1qshared".to_string(),
2044 txid: format!("txid_{}", i_a),
2045 outpoint: outpoint_a,
2046 amount_sat: 10_000 * (i_a as u64 + 1),
2047 finalized_at: 1_700_000_010 + i_a as u64,
2048 };
2049 storage_a.finalize_receive_intent(&intent_a, &record).await
2050 });
2051
2052 let storage_b = storage.clone();
2053 let quote_b = shared_quote_id.clone();
2054 let (intent_b, outpoint_b, i_b) = intent_ids[1].clone();
2055 let task_b = tokio::spawn(async move {
2056 let record = FinalizedReceiveIntentRecord {
2057 intent_id: intent_b,
2058 quote_id: quote_b,
2059 address: "bcrt1qshared".to_string(),
2060 txid: format!("txid_{}", i_b),
2061 outpoint: outpoint_b,
2062 amount_sat: 10_000 * (i_b as u64 + 1),
2063 finalized_at: 1_700_000_010 + i_b as u64,
2064 };
2065 storage_b.finalize_receive_intent(&intent_b, &record).await
2066 });
2067
2068 task_a.await.expect("join a").expect("finalize a");
2069 task_b.await.expect("join b").expect("finalize b");
2070
2071 let results = storage
2073 .get_finalized_receive_intents_by_quote_id(&shared_quote_id)
2074 .await
2075 .expect("query shared");
2076 assert_eq!(
2077 results.len(),
2078 2,
2079 "both concurrently finalized intents must appear in the quote-id index"
2080 );
2081 let returned_ids: std::collections::HashSet<Uuid> =
2082 results.iter().map(|r| r.intent_id).collect();
2083 assert!(returned_ids.contains(&intent_ids[0].0));
2084 assert!(returned_ids.contains(&intent_ids[1].0));
2085 }
2086}