pub(crate) mod record;
pub(crate) mod state;
use uuid::Uuid;
use self::record::{SendIntentRecord, SendIntentState};
use self::state::{AwaitingConfirmation, Batched, Failed, Pending};
use crate::error::Error;
use crate::storage::{BdkStorage, FailedSendAttemptRecord, FinalizedSendIntentRecord};
use crate::types::{PaymentMetadata, PaymentTier};
#[derive(Debug, Clone)]
pub(crate) struct SendIntent<S> {
pub intent_id: Uuid,
pub quote_id: String,
pub address: String,
pub amount: u64,
pub max_fee_amount: u64,
pub tier: PaymentTier,
pub metadata: PaymentMetadata,
pub created_at: u64,
pub state: S,
}
impl SendIntent<Pending> {
pub async fn new(
storage: &BdkStorage,
quote_id: String,
address: String,
amount: u64,
max_fee_amount: u64,
tier: PaymentTier,
metadata: PaymentMetadata,
) -> Result<Self, Error> {
let intent_id = Uuid::new_v4();
let created_at = crate::util::unix_now();
let record = SendIntentRecord {
intent_id,
quote_id: quote_id.clone(),
address: address.clone(),
amount_sat: amount,
max_fee_amount_sat: max_fee_amount,
tier,
metadata: metadata.clone(),
state: SendIntentState::Pending { created_at },
};
let record = storage.create_or_retry_failed_send_intent(&record).await?;
let created_at = match record.state {
SendIntentState::Pending { created_at } => created_at,
_ => {
return Err(Error::Wallet(
"send intent retry did not return Pending state".to_string(),
));
}
};
Ok(Self {
intent_id: record.intent_id,
quote_id: record.quote_id,
address: record.address,
amount: record.amount_sat,
max_fee_amount: record.max_fee_amount_sat,
tier: record.tier,
metadata: record.metadata,
created_at,
state: Pending,
})
}
pub async fn assign_to_batch(
self,
storage: &BdkStorage,
batch_id: Uuid,
) -> Result<SendIntent<Batched>, Error> {
storage
.update_send_intent(
&self.intent_id,
&SendIntentState::Batched {
batch_id,
created_at: self.created_at,
},
)
.await?;
Ok(SendIntent {
intent_id: self.intent_id,
quote_id: self.quote_id,
address: self.address,
amount: self.amount,
max_fee_amount: self.max_fee_amount,
tier: self.tier,
metadata: self.metadata,
created_at: self.created_at,
state: Batched { batch_id },
})
}
pub async fn fail(
self,
storage: &BdkStorage,
reason: String,
) -> Result<SendIntent<Failed>, Error> {
let failed_at = crate::util::unix_now();
storage
.update_send_intent(
&self.intent_id,
&SendIntentState::Failed {
reason: reason.clone(),
created_at: self.created_at,
failed_at,
},
)
.await?;
storage
.add_failed_send_attempt(&FailedSendAttemptRecord {
attempt_id: Uuid::new_v4(),
intent_id: self.intent_id,
quote_id: self.quote_id.clone(),
reason: reason.clone(),
failed_at,
})
.await?;
Ok(SendIntent {
intent_id: self.intent_id,
quote_id: self.quote_id,
address: self.address,
amount: self.amount,
max_fee_amount: self.max_fee_amount,
tier: self.tier,
metadata: self.metadata,
created_at: self.created_at,
state: Failed,
})
}
}
impl SendIntent<Batched> {
pub async fn mark_broadcast(
self,
storage: &BdkStorage,
txid: String,
outpoint: String,
fee_contribution_sat: u64,
) -> Result<SendIntent<AwaitingConfirmation>, Error> {
storage
.update_send_intent(
&self.intent_id,
&SendIntentState::AwaitingConfirmation {
batch_id: self.state.batch_id,
txid: txid.clone(),
outpoint: outpoint.clone(),
fee_contribution_sat,
created_at: self.created_at,
},
)
.await?;
Ok(SendIntent {
intent_id: self.intent_id,
quote_id: self.quote_id,
address: self.address,
amount: self.amount,
max_fee_amount: self.max_fee_amount,
tier: self.tier,
metadata: self.metadata,
created_at: self.created_at,
state: AwaitingConfirmation {
batch_id: self.state.batch_id,
txid,
outpoint,
fee_contribution_sat,
},
})
}
pub async fn revert_to_pending(
self,
storage: &BdkStorage,
) -> Result<SendIntent<Pending>, Error> {
storage
.update_send_intent(
&self.intent_id,
&SendIntentState::Pending {
created_at: self.created_at,
},
)
.await?;
Ok(SendIntent {
intent_id: self.intent_id,
quote_id: self.quote_id,
address: self.address,
amount: self.amount,
max_fee_amount: self.max_fee_amount,
tier: self.tier,
metadata: self.metadata,
created_at: self.created_at,
state: Pending,
})
}
}
impl SendIntent<AwaitingConfirmation> {
pub async fn finalize(self, storage: &BdkStorage) -> Result<(), Error> {
let total_spent_sat = self.amount + self.state.fee_contribution_sat;
let tombstone = FinalizedSendIntentRecord {
intent_id: self.intent_id,
quote_id: self.quote_id.clone(),
total_spent_sat,
outpoint: self.state.outpoint.clone(),
finalized_at: crate::util::unix_now(),
};
storage
.finalize_send_intent(&self.intent_id, &tombstone)
.await?;
Ok(())
}
}
pub(crate) fn from_record(record: &SendIntentRecord) -> SendIntentAny {
match &record.state {
SendIntentState::Pending { created_at } => SendIntentAny::Pending(SendIntent {
intent_id: record.intent_id,
quote_id: record.quote_id.clone(),
address: record.address.clone(),
amount: record.amount_sat,
max_fee_amount: record.max_fee_amount_sat,
tier: record.tier,
metadata: record.metadata.clone(),
created_at: *created_at,
state: Pending,
}),
SendIntentState::Batched {
batch_id,
created_at,
} => SendIntentAny::Batched(SendIntent {
intent_id: record.intent_id,
quote_id: record.quote_id.clone(),
address: record.address.clone(),
amount: record.amount_sat,
max_fee_amount: record.max_fee_amount_sat,
tier: record.tier,
metadata: record.metadata.clone(),
created_at: *created_at,
state: Batched {
batch_id: *batch_id,
},
}),
SendIntentState::AwaitingConfirmation {
batch_id,
txid,
outpoint,
fee_contribution_sat,
created_at,
} => SendIntentAny::AwaitingConfirmation(SendIntent {
intent_id: record.intent_id,
quote_id: record.quote_id.clone(),
address: record.address.clone(),
amount: record.amount_sat,
max_fee_amount: record.max_fee_amount_sat,
tier: record.tier,
metadata: record.metadata.clone(),
created_at: *created_at,
state: AwaitingConfirmation {
batch_id: *batch_id,
txid: txid.clone(),
outpoint: outpoint.clone(),
fee_contribution_sat: *fee_contribution_sat,
},
}),
SendIntentState::Failed {
reason,
created_at,
failed_at,
} => {
let _ = (reason, created_at, failed_at);
SendIntentAny::Failed
}
}
}
pub(crate) enum SendIntentAny {
Pending(SendIntent<Pending>),
Batched(SendIntent<Batched>),
AwaitingConfirmation(SendIntent<AwaitingConfirmation>),
Failed,
}
#[cfg(test)]
mod tests {
use std::sync::Arc;
use cdk_common::payment::{MakePaymentResponse, PaymentIdentifier};
use cdk_common::{Amount, CurrencyUnit, MeltQuoteState};
use super::*;
use crate::storage::BdkStorage;
async fn test_storage() -> BdkStorage {
let db = cdk_sqlite::mint::memory::empty()
.await
.expect("in-memory db");
BdkStorage::new(Arc::new(db))
}
#[tokio::test]
async fn test_pending_to_batched_to_awaiting() {
let storage = test_storage().await;
let quote_id = "quote123".to_string();
let address = "bcrt1qw508d6qejxtdg4y5r3zarvary0c5xw7kygt080".to_string();
let amount = 10_000;
let max_fee = 500;
let pending = SendIntent::new(
&storage,
quote_id.clone(),
address.clone(),
amount,
max_fee,
PaymentTier::Immediate,
PaymentMetadata::default(),
)
.await
.expect("new");
assert_eq!(pending.amount, amount);
let batch_id = Uuid::new_v4();
let batched = pending
.assign_to_batch(&storage, batch_id)
.await
.expect("assign");
assert_eq!(batched.state.batch_id, batch_id);
let txid = "tx123".to_string();
let outpoint = "tx123:0".to_string();
let fee_contrib = 250;
let awaiting = batched
.mark_broadcast(&storage, txid.clone(), outpoint.clone(), fee_contrib)
.await
.expect("mark_broadcast");
assert_eq!(awaiting.state.txid, txid);
assert_eq!(awaiting.state.outpoint, outpoint);
assert_eq!(awaiting.state.fee_contribution_sat, fee_contrib);
}
#[tokio::test]
async fn test_pending_to_failed() {
let storage = test_storage().await;
let pending = SendIntent::new(
&storage,
"quote-failed".to_string(),
"bcrt1qw508d6qejxtdg4y5r3zarvary0c5xw7kygt080".to_string(),
10_000,
500,
PaymentTier::Immediate,
PaymentMetadata::default(),
)
.await
.expect("new");
let intent_id = pending.intent_id;
let failed = pending
.fail(&storage, "fee too high".to_string())
.await
.expect("fail");
assert_eq!(failed.intent_id, intent_id);
let persisted = storage
.get_send_intent(&intent_id)
.await
.expect("get intent")
.expect("intent should remain as failed terminal record");
assert!(matches!(
persisted.state,
SendIntentState::Failed { ref reason, .. } if reason == "fee too high"
));
let attempts = storage
.get_failed_send_attempts_by_quote_id("quote-failed")
.await
.expect("failed attempts");
assert_eq!(attempts.len(), 1);
assert_eq!(attempts[0].intent_id, intent_id);
assert_eq!(attempts[0].reason, "fee too high");
}
#[tokio::test]
async fn test_failed_intent_can_be_requeued_with_same_quote_id() {
let storage = test_storage().await;
let quote_id = "quote-retry".to_string();
let pending = SendIntent::new(
&storage,
quote_id.clone(),
"bcrt1qw508d6qejxtdg4y5r3zarvary0c5xw7kygt080".to_string(),
10_000,
500,
PaymentTier::Immediate,
PaymentMetadata::default(),
)
.await
.expect("new");
let intent_id = pending.intent_id;
pending
.fail(&storage, "fee too high".to_string())
.await
.expect("fail");
let retried = SendIntent::new(
&storage,
quote_id,
"bcrt1qw508d6qejxtdg4y5r3zarvary0c5xw7kygt080".to_string(),
10_000,
750,
PaymentTier::Immediate,
PaymentMetadata::default(),
)
.await
.expect("retry failed intent");
assert_eq!(retried.intent_id, intent_id);
assert_eq!(retried.max_fee_amount, 750);
let persisted = storage
.get_send_intent(&intent_id)
.await
.expect("get intent")
.expect("intent should remain present");
assert!(matches!(persisted.state, SendIntentState::Pending { .. }));
assert_eq!(persisted.max_fee_amount_sat, 750);
let attempts = storage
.get_failed_send_attempts_by_quote_id("quote-retry")
.await
.expect("failed attempts");
assert_eq!(attempts.len(), 1);
assert_eq!(attempts[0].intent_id, intent_id);
}
#[tokio::test]
async fn test_finalize_send_intent_creates_tombstone_and_preserves_total_spent() {
let storage = test_storage().await;
let pending = SendIntent::new(
&storage,
"quote-finalize".to_string(),
"bcrt1qw508d6qejxtdg4y5r3zarvary0c5xw7kygt080".to_string(),
20_000,
1_000,
PaymentTier::Immediate,
PaymentMetadata::default(),
)
.await
.expect("new");
let batched = pending
.assign_to_batch(&storage, Uuid::new_v4())
.await
.expect("assign");
let awaiting = batched
.mark_broadcast(
&storage,
"txid-finalize".to_string(),
"txid-finalize:1".to_string(),
321,
)
.await
.expect("mark_broadcast");
let intent_id = awaiting.intent_id;
let quote_id = awaiting.quote_id.clone();
let outpoint = awaiting.state.outpoint.clone();
awaiting.finalize(&storage).await.expect("finalize");
let active = storage
.get_send_intent(&intent_id)
.await
.expect("get active");
assert!(
active.is_none(),
"active intent should be deleted after finalization"
);
let tombstone = storage
.get_finalized_intent(&intent_id)
.await
.expect("get tombstone")
.expect("tombstone should exist");
assert_eq!(tombstone.quote_id, quote_id);
assert_eq!(tombstone.outpoint, outpoint);
assert_eq!(tombstone.total_spent_sat, 20_321);
let payment_lookup_id = PaymentIdentifier::CustomId(tombstone.quote_id.clone());
let response = MakePaymentResponse {
payment_lookup_id,
payment_proof: Some(tombstone.outpoint.clone()),
status: MeltQuoteState::Paid,
total_spent: Amount::new(tombstone.total_spent_sat, CurrencyUnit::Sat),
};
assert_eq!(response.status, MeltQuoteState::Paid);
assert_eq!(response.total_spent, Amount::new(20_321, CurrencyUnit::Sat));
}
#[tokio::test]
async fn test_finalized_intent_quote_id_cannot_be_requeued() {
let storage = test_storage().await;
let quote_id = "quote-finalized-no-retry".to_string();
let pending = SendIntent::new(
&storage,
quote_id.clone(),
"bcrt1qw508d6qejxtdg4y5r3zarvary0c5xw7kygt080".to_string(),
20_000,
1_000,
PaymentTier::Immediate,
PaymentMetadata::default(),
)
.await
.expect("new");
let awaiting = pending
.assign_to_batch(&storage, Uuid::new_v4())
.await
.expect("assign")
.mark_broadcast(
&storage,
"txid-finalized-no-retry".to_string(),
"txid-finalized-no-retry:0".to_string(),
250,
)
.await
.expect("mark_broadcast");
awaiting.finalize(&storage).await.expect("finalize");
let result = SendIntent::new(
&storage,
quote_id.clone(),
"bcrt1qw508d6qejxtdg4y5r3zarvary0c5xw7kygt080".to_string(),
20_000,
1_000,
PaymentTier::Immediate,
PaymentMetadata::default(),
)
.await;
assert!(matches!(result, Err(Error::DuplicateQuoteId(id)) if id == quote_id));
}
}