shared.rs 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534
  1. //! Shared logic for melt operations across saga and startup check.
  2. //!
  3. //! This module contains common functions used by both:
  4. //! - `melt_saga`: Normal melt operation flow
  5. //! - `start_up_check`: Recovery of interrupted melts during startup
  6. //!
  7. //! The functions here ensure consistency between these two code paths.
  8. use cdk_common::database::{self, Acquired, DynMintDatabase};
  9. use cdk_common::nuts::{BlindSignature, BlindedMessage, MeltQuoteState, State};
  10. use cdk_common::{Amount, Error, PublicKey, QuoteId};
  11. use cdk_signatory::signatory::SignatoryKeySet;
  12. use crate::mint::subscription::PubSubManager;
  13. use crate::mint::MeltQuote;
  14. /// Retrieves fee and amount configuration for the keyset matching the change outputs.
  15. ///
  16. /// Searches active keysets for one matching the first output's keyset_id.
  17. /// Used during change calculation for melts.
  18. ///
  19. /// # Arguments
  20. ///
  21. /// * `keysets` - Arc reference to the loaded keysets
  22. /// * `outputs` - Change output blinded messages
  23. ///
  24. /// # Returns
  25. ///
  26. /// Fee per thousand and allowed amounts for the keyset, or default if not found
  27. pub fn get_keyset_fee_and_amounts(
  28. keysets: &arc_swap::ArcSwap<Vec<SignatoryKeySet>>,
  29. outputs: &[BlindedMessage],
  30. ) -> cdk_common::amount::FeeAndAmounts {
  31. keysets
  32. .load()
  33. .iter()
  34. .filter_map(|keyset| {
  35. if keyset.active && Some(keyset.id) == outputs.first().map(|x| x.keyset_id) {
  36. Some((keyset.input_fee_ppk, keyset.amounts.clone()).into())
  37. } else {
  38. None
  39. }
  40. })
  41. .next()
  42. .unwrap_or_else(|| (0, (0..32).map(|x| 2u64.pow(x)).collect::<Vec<_>>()).into())
  43. }
  44. /// Rolls back a melt quote by removing all setup artifacts and resetting state.
  45. ///
  46. /// This function is used by both:
  47. /// - `melt_saga::compensation::RemoveMeltSetup` when saga fails
  48. /// - `start_up_check::rollback_failed_melt_quote` when recovering failed payments
  49. ///
  50. /// # What This Does
  51. ///
  52. /// Within a single database transaction:
  53. /// 1. Removes input proofs from database
  54. /// 2. Removes change output blinded messages
  55. /// 3. Resets quote state from Pending to Unpaid
  56. /// 4. Deletes melt request tracking record
  57. ///
  58. /// This restores the database to its pre-melt state, allowing retry.
  59. ///
  60. /// # Arguments
  61. ///
  62. /// * `db` - Database connection
  63. /// * `quote_id` - ID of the quote to rollback
  64. /// * `input_ys` - Y values (public keys) from input proofs
  65. /// * `blinded_secrets` - Blinded secrets from change outputs
  66. ///
  67. /// # Errors
  68. ///
  69. /// Returns database errors if transaction fails
  70. pub async fn rollback_melt_quote(
  71. db: &DynMintDatabase,
  72. quote_id: &QuoteId,
  73. input_ys: &[PublicKey],
  74. blinded_secrets: &[PublicKey],
  75. operation_id: &uuid::Uuid,
  76. ) -> Result<(), Error> {
  77. if input_ys.is_empty() && blinded_secrets.is_empty() {
  78. return Ok(());
  79. }
  80. tracing::info!(
  81. "Rolling back melt quote {} ({} proofs, {} blinded messages, saga {})",
  82. quote_id,
  83. input_ys.len(),
  84. blinded_secrets.len(),
  85. operation_id
  86. );
  87. let mut tx = db.begin_transaction().await?;
  88. // Remove input proofs
  89. if !input_ys.is_empty() {
  90. tx.remove_proofs(input_ys, Some(quote_id.clone())).await?;
  91. }
  92. // Remove blinded messages (change outputs)
  93. if !blinded_secrets.is_empty() {
  94. tx.delete_blinded_messages(blinded_secrets).await?;
  95. }
  96. // Get and lock the quote, then reset state from Pending to Unpaid
  97. if let Some(mut quote) = tx.get_melt_quote(quote_id).await? {
  98. let previous_state = tx
  99. .update_melt_quote_state(&mut quote, MeltQuoteState::Unpaid, None)
  100. .await?;
  101. if previous_state != MeltQuoteState::Pending {
  102. tracing::warn!(
  103. "Unexpected quote state during rollback: expected Pending, got {}",
  104. previous_state
  105. );
  106. }
  107. }
  108. // Delete melt request tracking record
  109. tx.delete_melt_request(quote_id).await?;
  110. // Delete saga state record
  111. if let Err(e) = tx.delete_saga(operation_id).await {
  112. tracing::warn!(
  113. "Failed to delete saga {} during rollback: {}",
  114. operation_id,
  115. e
  116. );
  117. // Continue anyway - saga cleanup is best-effort
  118. }
  119. tx.commit().await?;
  120. tracing::info!(
  121. "Successfully rolled back melt quote {} and deleted saga {}",
  122. quote_id,
  123. operation_id
  124. );
  125. Ok(())
  126. }
  127. /// Processes change for a melt operation.
  128. ///
  129. /// This function handles the complete change workflow:
  130. /// 1. Calculate change target amount
  131. /// 2. Split into denominations based on keyset configuration
  132. /// 3. Sign change outputs (external call to blind_sign)
  133. /// 4. Store signatures in database (new transaction)
  134. ///
  135. /// # Transaction Management
  136. ///
  137. /// This function expects that the caller has already committed or will rollback
  138. /// their current transaction before calling. It will:
  139. /// - Call blind_sign (external, no DB lock held)
  140. /// - Open a new transaction to store signatures
  141. /// - Return the new transaction for the caller to commit
  142. ///
  143. /// # Arguments
  144. ///
  145. /// * `mint` - Mint instance (for keysets and blind_sign)
  146. /// * `db` - Database connection
  147. /// * `quote_id` - Quote ID for associating signatures
  148. /// * `inputs_amount` - Total amount from input proofs
  149. /// * `total_spent` - Amount spent on payment
  150. /// * `inputs_fee` - Fee paid for inputs
  151. /// * `change_outputs` - Blinded messages for change
  152. ///
  153. /// # Returns
  154. ///
  155. /// Tuple of:
  156. /// - `Option<Vec<BlindSignature>>` - Signed change outputs (if any)
  157. /// - `Box<dyn MintTransaction>` - New transaction with signatures stored
  158. ///
  159. /// # Errors
  160. ///
  161. /// Returns error if:
  162. /// - Change calculation fails
  163. /// - Blind signing fails
  164. /// - Database operations fail
  165. pub async fn process_melt_change(
  166. mint: &super::super::Mint,
  167. db: &DynMintDatabase,
  168. quote_id: &QuoteId,
  169. inputs_amount: Amount,
  170. total_spent: Amount,
  171. inputs_fee: Amount,
  172. change_outputs: Vec<BlindedMessage>,
  173. ) -> Result<
  174. (
  175. Option<Vec<BlindSignature>>,
  176. Box<dyn database::MintTransaction<database::Error> + Send + Sync>,
  177. ),
  178. Error,
  179. > {
  180. // Check if change is needed
  181. let needs_change = inputs_amount > total_spent;
  182. if !needs_change || change_outputs.is_empty() {
  183. // No change needed - open transaction and return empty result
  184. let tx = db.begin_transaction().await?;
  185. return Ok((None, tx));
  186. }
  187. let change_target = inputs_amount - total_spent - inputs_fee;
  188. // Get keyset configuration
  189. let fee_and_amounts = get_keyset_fee_and_amounts(&mint.keysets, &change_outputs);
  190. // Split change into denominations
  191. let mut amounts = change_target.split(&fee_and_amounts);
  192. if change_outputs.len() < amounts.len() {
  193. tracing::debug!(
  194. "Providing change requires {} blinded messages, but only {} provided",
  195. amounts.len(),
  196. change_outputs.len()
  197. );
  198. amounts.sort_by(|a, b| b.cmp(a));
  199. }
  200. // Prepare blinded messages with amounts
  201. let mut blinded_messages_to_sign = vec![];
  202. for (amount, mut blinded_message) in amounts.iter().zip(change_outputs.iter().cloned()) {
  203. blinded_message.amount = *amount;
  204. blinded_messages_to_sign.push(blinded_message);
  205. }
  206. // External call: sign change outputs (no DB transaction held)
  207. let change_sigs = mint.blind_sign(blinded_messages_to_sign.clone()).await?;
  208. // Open new transaction to store signatures
  209. let mut tx = db.begin_transaction().await?;
  210. let blinded_secrets: Vec<_> = blinded_messages_to_sign
  211. .iter()
  212. .map(|bm| bm.blinded_secret)
  213. .collect();
  214. tx.add_blind_signatures(&blinded_secrets, &change_sigs, Some(quote_id.clone()))
  215. .await?;
  216. Ok((Some(change_sigs), tx))
  217. }
  218. /// Loads a melt quote and acquires exclusive locks on all related quotes.
  219. ///
  220. /// This function combines quote loading with defensive locking to prevent race conditions in BOLT12
  221. /// scenarios where multiple melt quotes can share the same `request_lookup_id`. It performs the
  222. /// following operations atomically in a single query:
  223. ///
  224. /// 1. Acquires row-level locks on ALL quotes sharing the same lookup identifier (including target)
  225. /// 2. Returns the target quote and validates no sibling is already `Pending` or `Paid`
  226. ///
  227. /// # Deadlock Prevention
  228. ///
  229. /// This function uses a single atomic query to lock all related quotes at once, ordered by ID.
  230. /// This prevents deadlocks that would occur if we locked the target quote first, then tried to
  231. /// lock related quotes separately - concurrent transactions would each hold one lock and wait
  232. /// for the other, creating a circular wait condition.
  233. ///
  234. /// # Arguments
  235. ///
  236. /// * `tx` - The active database transaction used to load and acquire locks.
  237. /// * `quote_id` - The ID of the melt quote to load and process.
  238. ///
  239. /// # Returns
  240. ///
  241. /// The loaded and locked melt quote, ready for state transitions.
  242. ///
  243. /// # Errors
  244. ///
  245. /// * [`Error::UnknownQuote`] if no quote exists with the given ID.
  246. /// * [`Error::Database(Duplicate)`] if another quote with the same lookup ID is already pending
  247. /// or paid, indicating a conflicting concurrent melt operation.
  248. pub async fn load_melt_quotes_exclusively(
  249. tx: &mut Box<dyn database::MintTransaction<database::Error> + Send + Sync>,
  250. quote_id: &QuoteId,
  251. ) -> Result<Acquired<MeltQuote>, Error> {
  252. // Lock ALL related quotes in a single atomic query to prevent deadlocks.
  253. // The query locks quotes ordered by ID, ensuring consistent lock acquisition order
  254. // across concurrent transactions.
  255. let locked = tx
  256. .lock_melt_quote_and_related(quote_id)
  257. .await
  258. .map_err(|e| match e {
  259. database::Error::Locked => {
  260. tracing::warn!("Quote {quote_id} or related quotes are locked by another process");
  261. database::Error::Duplicate
  262. }
  263. e => e,
  264. })?;
  265. let quote = locked.target.ok_or(Error::UnknownQuote)?;
  266. if locked.all_related.iter().any(|locked_quote| {
  267. locked_quote.id != quote.id
  268. && (locked_quote.state == MeltQuoteState::Pending
  269. || locked_quote.state == MeltQuoteState::Paid)
  270. }) {
  271. tracing::warn!(
  272. "Cannot transition quote {} to Pending: another quote with lookup_id {:?} is already pending or paid",
  273. quote.id,
  274. quote.request_lookup_id,
  275. );
  276. return Err(Error::Database(crate::cdk_database::Error::Duplicate));
  277. }
  278. Ok(quote)
  279. }
  280. /// Finalizes a melt quote by updating proofs, quote state, and publishing changes.
  281. ///
  282. /// This function performs the core finalization operations that are common to both
  283. /// the saga finalize step and startup check recovery:
  284. /// 1. Validates amounts (total_spent vs quote amount, inputs vs total_spent)
  285. /// 2. Marks input proofs as SPENT
  286. /// 3. Publishes proof state changes
  287. /// 4. Updates quote state to PAID
  288. /// 5. Updates payment lookup ID if changed
  289. /// 6. Deletes melt request tracking
  290. ///
  291. /// # Transaction Management
  292. ///
  293. /// This function expects an open transaction and will NOT commit it.
  294. /// The caller is responsible for committing the transaction.
  295. ///
  296. /// # Arguments
  297. ///
  298. /// * `tx` - Open database transaction
  299. /// * `pubsub` - Pubsub manager for state notifications
  300. /// * `quote` - Melt quote being finalized
  301. /// * `input_ys` - Y values of input proofs
  302. /// * `inputs_amount` - Total amount from inputs
  303. /// * `inputs_fee` - Fee for inputs
  304. /// * `total_spent` - Amount spent on payment
  305. /// * `payment_preimage` - Payment preimage (if any)
  306. /// * `payment_lookup_id` - Payment lookup identifier
  307. ///
  308. /// # Returns
  309. ///
  310. /// `Ok(())` if finalization succeeds
  311. ///
  312. /// # Errors
  313. ///
  314. /// Returns error if:
  315. /// - Amount validation fails
  316. /// - Proofs are already spent
  317. /// - Database operations fail
  318. #[allow(clippy::too_many_arguments)]
  319. pub async fn finalize_melt_core(
  320. tx: &mut Box<dyn database::MintTransaction<database::Error> + Send + Sync>,
  321. pubsub: &PubSubManager,
  322. quote: &mut Acquired<MeltQuote>,
  323. input_ys: &[PublicKey],
  324. inputs_amount: Amount,
  325. inputs_fee: Amount,
  326. total_spent: Amount,
  327. payment_preimage: Option<String>,
  328. payment_lookup_id: &cdk_common::payment::PaymentIdentifier,
  329. ) -> Result<(), Error> {
  330. // Validate quote amount vs payment amount
  331. if quote.amount > total_spent {
  332. tracing::error!(
  333. "Payment amount {} is less than quote amount {} for quote {}",
  334. total_spent,
  335. quote.amount,
  336. quote.id
  337. );
  338. return Err(Error::IncorrectQuoteAmount);
  339. }
  340. // Validate inputs amount
  341. if inputs_amount - inputs_fee < total_spent {
  342. tracing::error!("Over paid melt quote {}", quote.id);
  343. return Err(Error::IncorrectQuoteAmount);
  344. }
  345. // Update quote state to Paid
  346. tx.update_melt_quote_state(quote, MeltQuoteState::Paid, payment_preimage.clone())
  347. .await?;
  348. // Update payment lookup ID if changed
  349. if quote.request_lookup_id.as_ref() != Some(payment_lookup_id) {
  350. tracing::info!(
  351. "Payment lookup id changed post payment from {:?} to {}",
  352. &quote.request_lookup_id,
  353. payment_lookup_id
  354. );
  355. tx.update_melt_quote_request_lookup_id(quote, payment_lookup_id)
  356. .await?;
  357. }
  358. let mut proofs = tx.get_proofs(input_ys).await?;
  359. proofs.set_new_state(State::Spent)?;
  360. // Mark input proofs as spent
  361. match tx.update_proofs(&mut proofs).await {
  362. Ok(_) => {}
  363. Err(database::Error::AttemptUpdateSpentProof) => {
  364. tracing::info!("Proofs for quote {} already marked as spent", quote.id);
  365. return Ok(());
  366. }
  367. Err(err) => {
  368. return Err(err.into());
  369. }
  370. }
  371. // Publish proof state changes
  372. for pk in input_ys.iter() {
  373. pubsub.proof_state((*pk, State::Spent));
  374. }
  375. Ok(())
  376. }
  377. /// High-level melt finalization that handles the complete workflow.
  378. ///
  379. /// This function orchestrates:
  380. /// 1. Getting melt request info
  381. /// 2. Getting input proof Y values
  382. /// 3. Processing change (if needed)
  383. /// 4. Core finalization operations
  384. /// 5. Transaction commit
  385. /// 6. Pubsub notification
  386. ///
  387. /// # Arguments
  388. ///
  389. /// * `mint` - Mint instance
  390. /// * `db` - Database connection
  391. /// * `pubsub` - Pubsub manager
  392. /// * `quote` - Melt quote to finalize
  393. /// * `total_spent` - Amount spent on payment
  394. /// * `payment_preimage` - Payment preimage (if any)
  395. /// * `payment_lookup_id` - Payment lookup identifier
  396. ///
  397. /// # Returns
  398. ///
  399. /// `Option<Vec<BlindSignature>>` - Change signatures (if any)
  400. pub async fn finalize_melt_quote(
  401. mint: &super::super::Mint,
  402. db: &DynMintDatabase,
  403. pubsub: &PubSubManager,
  404. quote: &MeltQuote,
  405. total_spent: Amount,
  406. payment_preimage: Option<String>,
  407. payment_lookup_id: &cdk_common::payment::PaymentIdentifier,
  408. ) -> Result<Option<Vec<BlindSignature>>, Error> {
  409. use cdk_common::amount::to_unit;
  410. tracing::info!("Finalizing melt quote {}", quote.id);
  411. // Convert total_spent to quote unit
  412. let total_spent = to_unit(total_spent, &quote.unit, &quote.unit).unwrap_or(total_spent);
  413. let mut tx = db.begin_transaction().await?;
  414. // Acquire lock on the quote for safe state update
  415. let mut locked_quote = load_melt_quotes_exclusively(&mut tx, &quote.id).await?;
  416. // Get melt request info
  417. let melt_request_info = match tx.get_melt_request_and_blinded_messages(&quote.id).await? {
  418. Some(info) => info,
  419. None => {
  420. tracing::warn!(
  421. "No melt request found for quote {} - may have been completed already",
  422. quote.id
  423. );
  424. tx.rollback().await?;
  425. return Ok(None);
  426. }
  427. };
  428. // Get input proof Y values
  429. let input_ys = tx.get_proof_ys_by_quote_id(&quote.id).await?;
  430. if input_ys.is_empty() {
  431. tracing::warn!(
  432. "No input proofs found for quote {} - may have been completed already",
  433. quote.id
  434. );
  435. tx.rollback().await?;
  436. return Ok(None);
  437. }
  438. // Core finalization (marks proofs spent, updates quote)
  439. finalize_melt_core(
  440. &mut tx,
  441. pubsub,
  442. &mut locked_quote,
  443. &input_ys,
  444. melt_request_info.inputs_amount,
  445. melt_request_info.inputs_fee,
  446. total_spent,
  447. payment_preimage.clone(),
  448. payment_lookup_id,
  449. )
  450. .await?;
  451. // Close transaction before external call
  452. tx.commit().await?;
  453. // Process change (if needed) - opens new transaction
  454. let (change_sigs, mut tx) = process_melt_change(
  455. mint,
  456. db,
  457. &quote.id,
  458. melt_request_info.inputs_amount,
  459. total_spent,
  460. melt_request_info.inputs_fee,
  461. melt_request_info.change_outputs.clone(),
  462. )
  463. .await?;
  464. // Delete melt request tracking
  465. tx.delete_melt_request(&quote.id).await?;
  466. // Commit transaction
  467. tx.commit().await?;
  468. // Publish quote status change
  469. pubsub.melt_quote_status(
  470. quote,
  471. payment_preimage,
  472. change_sigs.clone(),
  473. MeltQuoteState::Paid,
  474. );
  475. tracing::info!("Successfully finalized melt quote {}", quote.id);
  476. Ok(change_sigs)
  477. }