Bladeren bron

feat: add operations table (#1311)

tsk 4 weken geleden
bovenliggende
commit
cea060f0b7
30 gewijzigde bestanden met toevoegingen van 1216 en 325 verwijderingen
  1. 38 0
      crates/cdk-common/src/database/mint/mod.rs
  2. 46 14
      crates/cdk-common/src/database/mint/test/mint.rs
  3. 7 3
      crates/cdk-common/src/database/mint/test/mod.rs
  4. 56 28
      crates/cdk-common/src/database/mint/test/proofs.rs
  5. 126 33
      crates/cdk-common/src/mint.rs
  6. 1 1
      crates/cdk-integration-tests/tests/happy_path_mint_wallet.rs
  7. 4 4
      crates/cdk-integration-tests/tests/test_fees.rs
  8. 18 0
      crates/cdk-sql-common/src/mint/migrations/postgres/20251119000000_add_completed_operations.sql
  9. 2 0
      crates/cdk-sql-common/src/mint/migrations/postgres/20251120000000_add_keyset_fee_collected.sql
  10. 18 0
      crates/cdk-sql-common/src/mint/migrations/sqlite/20251119000000_add_completed_operations.sql
  11. 2 0
      crates/cdk-sql-common/src/mint/migrations/sqlite/20251120000000_add_keyset_fee_collected.sql
  12. 196 3
      crates/cdk-sql-common/src/mint/mod.rs
  13. 12 4
      crates/cdk-sqlite/src/mint/memory.rs
  14. 258 42
      crates/cdk/src/fees.rs
  15. 7 2
      crates/cdk/src/mint/issue/mod.rs
  16. 61 25
      crates/cdk/src/mint/melt/melt_saga/mod.rs
  17. 12 3
      crates/cdk/src/mint/melt/melt_saga/state.rs
  18. 152 72
      crates/cdk/src/mint/melt/melt_saga/tests.rs
  19. 21 10
      crates/cdk/src/mint/melt/mod.rs
  20. 6 3
      crates/cdk/src/mint/mod.rs
  21. 41 12
      crates/cdk/src/mint/swap/swap_saga/mod.rs
  22. 12 3
      crates/cdk/src/mint/swap/swap_saga/state.rs
  23. 18 18
      crates/cdk/src/mint/swap/swap_saga/tests.rs
  24. 6 6
      crates/cdk/src/mint/verification.rs
  25. 3 2
      crates/cdk/src/wallet/melt/melt_bolt11.rs
  26. 7 4
      crates/cdk/src/wallet/mod.rs
  27. 65 22
      crates/cdk/src/wallet/proofs.rs
  28. 3 0
      crates/cdk/src/wallet/receive.rs
  29. 11 8
      crates/cdk/src/wallet/send.rs
  30. 7 3
      crates/cdk/src/wallet/swap.rs

+ 38 - 0
crates/cdk-common/src/database/mint/mod.rs

@@ -403,6 +403,42 @@ pub trait SagaDatabase {
     ) -> Result<Vec<mint::Saga>, Self::Err>;
 }
 
+#[async_trait]
+/// Completed Operations Transaction trait
+pub trait CompletedOperationsTransaction<'a> {
+    /// Completed Operations Database Error
+    type Err: Into<Error> + From<Error>;
+
+    /// Add completed operation
+    async fn add_completed_operation(
+        &mut self,
+        operation: &mint::Operation,
+        fee_by_keyset: &std::collections::HashMap<crate::nuts::Id, crate::Amount>,
+    ) -> Result<(), Self::Err>;
+}
+
+#[async_trait]
+/// Completed Operations Database trait
+pub trait CompletedOperationsDatabase {
+    /// Completed Operations Database Error
+    type Err: Into<Error> + From<Error>;
+
+    /// Get completed operation by operation_id
+    async fn get_completed_operation(
+        &self,
+        operation_id: &uuid::Uuid,
+    ) -> Result<Option<mint::Operation>, Self::Err>;
+
+    /// Get completed operations by operation kind
+    async fn get_completed_operations_by_kind(
+        &self,
+        operation_kind: mint::OperationKind,
+    ) -> Result<Vec<mint::Operation>, Self::Err>;
+
+    /// Get all completed operations
+    async fn get_completed_operations(&self) -> Result<Vec<mint::Operation>, Self::Err>;
+}
+
 /// Key-Value Store Transaction trait
 #[async_trait]
 pub trait KVStoreTransaction<'a, Error>: DbTransactionFinalizer<Err = Error> {
@@ -447,6 +483,7 @@ pub trait Transaction<'a, Error>:
     + ProofsTransaction<'a, Err = Error>
     + KVStoreTransaction<'a, Error>
     + SagaTransaction<'a, Err = Error>
+    + CompletedOperationsTransaction<'a, Err = Error>
 {
 }
 
@@ -492,6 +529,7 @@ pub trait Database<Error>:
     + ProofsDatabase<Err = Error>
     + SignaturesDatabase<Err = Error>
     + SagaDatabase<Err = Error>
+    + CompletedOperationsDatabase<Err = Error>
 {
     /// Begins a transaction
     async fn begin_transaction<'a>(

+ 46 - 14
crates/cdk-common/src/database/mint/test/mint.rs

@@ -435,9 +435,13 @@ where
     tx.add_melt_request(&quote.id, inputs_amount, inputs_fee)
         .await
         .unwrap();
-    tx.add_blinded_messages(Some(&quote.id), &blinded_messages, &Operation::new_melt())
-        .await
-        .unwrap();
+    tx.add_blinded_messages(
+        Some(&quote.id),
+        &blinded_messages,
+        &Operation::new_melt(Amount::ZERO, Amount::ZERO, cashu::PaymentMethod::Bolt11),
+    )
+    .await
+    .unwrap();
     tx.commit().await.unwrap();
 
     // Verify retrieval
@@ -497,7 +501,11 @@ where
         .await
         .unwrap();
     let result = tx
-        .add_blinded_messages(Some(&quote2.id), &blinded_messages, &Operation::new_melt())
+        .add_blinded_messages(
+            Some(&quote2.id),
+            &blinded_messages,
+            &Operation::new_melt(Amount::ZERO, Amount::ZERO, cashu::PaymentMethod::Bolt11),
+        )
         .await;
     assert!(result.is_err() && matches!(result.unwrap_err(), Error::Duplicate));
     tx.rollback().await.unwrap(); // Rollback to avoid partial state
@@ -530,7 +538,11 @@ where
         .await
         .unwrap();
     assert!(tx
-        .add_blinded_messages(Some(&quote.id), &blinded_messages, &Operation::new_melt())
+        .add_blinded_messages(
+            Some(&quote.id),
+            &blinded_messages,
+            &Operation::new_melt(Amount::ZERO, Amount::ZERO, cashu::PaymentMethod::Bolt11)
+        )
         .await
         .is_ok());
     tx.commit().await.unwrap();
@@ -543,7 +555,11 @@ where
         .await
         .unwrap();
     let result = tx
-        .add_blinded_messages(Some(&quote.id), &blinded_messages, &Operation::new_melt())
+        .add_blinded_messages(
+            Some(&quote.id),
+            &blinded_messages,
+            &Operation::new_melt(Amount::ZERO, Amount::ZERO, cashu::PaymentMethod::Bolt11),
+        )
         .await;
     // Expect a database error due to unique violation
     assert!(result.is_err()); // Specific error might be DB-specific, e.g., SqliteError or PostgresError
@@ -576,9 +592,13 @@ where
     tx1.add_melt_request(&quote.id, inputs_amount, inputs_fee)
         .await
         .unwrap();
-    tx1.add_blinded_messages(Some(&quote.id), &blinded_messages, &Operation::new_melt())
-        .await
-        .unwrap();
+    tx1.add_blinded_messages(
+        Some(&quote.id),
+        &blinded_messages,
+        &Operation::new_melt(Amount::ZERO, Amount::ZERO, cashu::PaymentMethod::Bolt11),
+    )
+    .await
+    .unwrap();
     tx1.commit().await.unwrap();
 
     // Simulate processing: get and delete
@@ -952,9 +972,13 @@ where
 
     // Add blinded messages
     let mut tx = Database::begin_transaction(&db).await.unwrap();
-    tx.add_blinded_messages(None, &blinded_messages, &Operation::new_mint())
-        .await
-        .unwrap();
+    tx.add_blinded_messages(
+        None,
+        &blinded_messages,
+        &Operation::new_mint(Amount::ZERO, cashu::PaymentMethod::Bolt11),
+    )
+    .await
+    .unwrap();
     tx.commit().await.unwrap();
 
     // Delete one blinded message
@@ -967,11 +991,19 @@ where
     // Try to add same blinded messages again - first should succeed, second should fail
     let mut tx = Database::begin_transaction(&db).await.unwrap();
     assert!(tx
-        .add_blinded_messages(None, &[blinded_message1], &Operation::new_mint())
+        .add_blinded_messages(
+            None,
+            &[blinded_message1],
+            &Operation::new_mint(Amount::ZERO, cashu::PaymentMethod::Bolt11)
+        )
         .await
         .is_ok());
     assert!(tx
-        .add_blinded_messages(None, &[blinded_message2], &Operation::new_mint())
+        .add_blinded_messages(
+            None,
+            &[blinded_message2],
+            &Operation::new_mint(Amount::ZERO, cashu::PaymentMethod::Bolt11)
+        )
         .await
         .is_err());
     tx.rollback().await.unwrap();

+ 7 - 3
crates/cdk-common/src/database/mint/test/mod.rs

@@ -86,9 +86,13 @@ where
 
     // Add proofs to database
     let mut tx = Database::begin_transaction(&db).await.unwrap();
-    tx.add_proofs(proofs.clone(), None, &Operation::new_swap())
-        .await
-        .unwrap();
+    tx.add_proofs(
+        proofs.clone(),
+        None,
+        &Operation::new_swap(Amount::ZERO, Amount::ZERO, Amount::ZERO),
+    )
+    .await
+    .unwrap();
 
     // Mark one proof as `pending`
     assert!(tx

+ 56 - 28
crates/cdk-common/src/database/mint/test/proofs.rs

@@ -37,9 +37,13 @@ where
 
     // Add proofs to database
     let mut tx = Database::begin_transaction(&db).await.unwrap();
-    tx.add_proofs(proofs, Some(quote_id), &Operation::new_swap())
-        .await
-        .unwrap();
+    tx.add_proofs(
+        proofs,
+        Some(quote_id),
+        &Operation::new_swap(Amount::ZERO, Amount::ZERO, Amount::ZERO),
+    )
+    .await
+    .unwrap();
     assert!(tx.commit().await.is_ok());
 
     let (proofs, states) = db.get_proofs_by_keyset_id(&keyset_id).await.unwrap();
@@ -94,7 +98,7 @@ where
     tx.add_proofs(
         proofs.clone(),
         Some(quote_id.clone()),
-        &Operation::new_swap(),
+        &Operation::new_swap(Amount::ZERO, Amount::ZERO, Amount::ZERO),
     )
     .await
     .unwrap();
@@ -142,7 +146,7 @@ where
     tx.add_proofs(
         proofs.clone(),
         Some(quote_id.clone()),
-        &Operation::new_swap(),
+        &Operation::new_swap(Amount::ZERO, Amount::ZERO, Amount::ZERO),
     )
     .await
     .unwrap();
@@ -153,7 +157,7 @@ where
         .add_proofs(
             proofs.clone(),
             Some(quote_id.clone()),
-            &Operation::new_swap(),
+            &Operation::new_swap(Amount::ZERO, Amount::ZERO, Amount::ZERO),
         )
         .await;
 
@@ -196,9 +200,13 @@ where
 
     // Add proofs
     let mut tx = Database::begin_transaction(&db).await.unwrap();
-    tx.add_proofs(proofs.clone(), Some(quote_id), &Operation::new_swap())
-        .await
-        .unwrap();
+    tx.add_proofs(
+        proofs.clone(),
+        Some(quote_id),
+        &Operation::new_swap(Amount::ZERO, Amount::ZERO, Amount::ZERO),
+    )
+    .await
+    .unwrap();
     tx.commit().await.unwrap();
 
     // Check initial state - states may vary by implementation
@@ -263,7 +271,7 @@ where
     tx.add_proofs(
         proofs.clone(),
         Some(quote_id.clone()),
-        &Operation::new_swap(),
+        &Operation::new_swap(Amount::ZERO, Amount::ZERO, Amount::ZERO),
     )
     .await
     .unwrap();
@@ -327,9 +335,13 @@ where
 
     // Add proofs
     let mut tx = Database::begin_transaction(&db).await.unwrap();
-    tx.add_proofs(proofs.clone(), Some(quote_id), &Operation::new_swap())
-        .await
-        .unwrap();
+    tx.add_proofs(
+        proofs.clone(),
+        Some(quote_id),
+        &Operation::new_swap(Amount::ZERO, Amount::ZERO, Amount::ZERO),
+    )
+    .await
+    .unwrap();
     tx.commit().await.unwrap();
 
     // First update to Pending (valid state transition)
@@ -399,14 +411,14 @@ where
     tx.add_proofs(
         proofs1.clone(),
         Some(quote_id1.clone()),
-        &Operation::new_swap(),
+        &Operation::new_swap(Amount::ZERO, Amount::ZERO, Amount::ZERO),
     )
     .await
     .unwrap();
     tx.add_proofs(
         proofs2.clone(),
         Some(quote_id2.clone()),
-        &Operation::new_swap(),
+        &Operation::new_swap(Amount::ZERO, Amount::ZERO, Amount::ZERO),
     )
     .await
     .unwrap();
@@ -457,9 +469,13 @@ where
 
     // Add proofs
     let mut tx = Database::begin_transaction(&db).await.unwrap();
-    tx.add_proofs(proofs.clone(), Some(quote_id), &Operation::new_swap())
-        .await
-        .unwrap();
+    tx.add_proofs(
+        proofs.clone(),
+        Some(quote_id),
+        &Operation::new_swap(Amount::ZERO, Amount::ZERO, Amount::ZERO),
+    )
+    .await
+    .unwrap();
     tx.commit().await.unwrap();
 
     // Get states - behavior may vary by implementation
@@ -537,9 +553,13 @@ where
 
     // Start a transaction and add proof but don't commit
     let mut tx = Database::begin_transaction(&db).await.unwrap();
-    tx.add_proofs(vec![proof.clone()], Some(quote_id), &Operation::new_swap())
-        .await
-        .unwrap();
+    tx.add_proofs(
+        vec![proof.clone()],
+        Some(quote_id),
+        &Operation::new_swap(Amount::ZERO, Amount::ZERO, Amount::ZERO),
+    )
+    .await
+    .unwrap();
 
     // Commit the transaction
     tx.commit().await.unwrap();
@@ -571,9 +591,13 @@ where
 
     // Start a transaction, add proof, then rollback
     let mut tx = Database::begin_transaction(&db).await.unwrap();
-    tx.add_proofs(vec![proof.clone()], Some(quote_id), &Operation::new_swap())
-        .await
-        .unwrap();
+    tx.add_proofs(
+        vec![proof.clone()],
+        Some(quote_id),
+        &Operation::new_swap(Amount::ZERO, Amount::ZERO, Amount::ZERO),
+    )
+    .await
+    .unwrap();
     tx.rollback().await.unwrap();
 
     // Proof should not exist after rollback
@@ -602,9 +626,13 @@ where
 
     // Add all proofs
     let mut tx = Database::begin_transaction(&db).await.unwrap();
-    tx.add_proofs(proofs.clone(), None, &Operation::new_swap())
-        .await
-        .unwrap();
+    tx.add_proofs(
+        proofs.clone(),
+        None,
+        &Operation::new_swap(Amount::ZERO, Amount::ZERO, Amount::ZERO),
+    )
+    .await
+    .unwrap();
     tx.commit().await.unwrap();
 
     // Get proofs by keyset
@@ -657,7 +685,7 @@ where
     tx.add_proofs(
         proofs.clone(),
         Some(quote_id.clone()),
-        &Operation::new_swap(),
+        &Operation::new_swap(Amount::ZERO, Amount::ZERO, Amount::ZERO),
     )
     .await
     .unwrap();

+ 126 - 33
crates/cdk-common/src/mint.rs

@@ -231,56 +231,149 @@ impl Saga {
 }
 
 /// Operation
-pub enum Operation {
-    /// Mint
-    Mint(Uuid),
-    /// Melt
-    Melt(Uuid),
-    /// Swap
-    Swap(Uuid),
+pub struct Operation {
+    id: Uuid,
+    kind: OperationKind,
+    total_issued: Amount,
+    total_redeemed: Amount,
+    fee_collected: Amount,
+    complete_at: Option<u64>,
+    /// Payment amount (only for melt operations)
+    payment_amount: Option<Amount>,
+    /// Payment fee (only for melt operations)
+    payment_fee: Option<Amount>,
+    /// Payment method (only for mint/melt operations)
+    payment_method: Option<PaymentMethod>,
 }
 
 impl Operation {
+    /// New
+    pub fn new(
+        id: Uuid,
+        kind: OperationKind,
+        total_issued: Amount,
+        total_redeemed: Amount,
+        fee_collected: Amount,
+        complete_at: Option<u64>,
+        payment_method: Option<PaymentMethod>,
+    ) -> Self {
+        Self {
+            id,
+            kind,
+            total_issued,
+            total_redeemed,
+            fee_collected,
+            complete_at,
+            payment_amount: None,
+            payment_fee: None,
+            payment_method,
+        }
+    }
+
     /// Mint
-    pub fn new_mint() -> Self {
-        Self::Mint(Uuid::new_v4())
+    pub fn new_mint(total_issued: Amount, payment_method: PaymentMethod) -> Self {
+        Self {
+            id: Uuid::new_v4(),
+            kind: OperationKind::Mint,
+            total_issued,
+            total_redeemed: Amount::ZERO,
+            fee_collected: Amount::ZERO,
+            complete_at: None,
+            payment_amount: None,
+            payment_fee: None,
+            payment_method: Some(payment_method),
+        }
     }
     /// Melt
-    pub fn new_melt() -> Self {
-        Self::Melt(Uuid::new_v4())
+    ///
+    /// In the context of a melt total_issued refrests to the change
+    pub fn new_melt(
+        total_redeemed: Amount,
+        fee_collected: Amount,
+        payment_method: PaymentMethod,
+    ) -> Self {
+        Self {
+            id: Uuid::new_v4(),
+            kind: OperationKind::Melt,
+            total_issued: Amount::ZERO,
+            total_redeemed,
+            fee_collected,
+            complete_at: None,
+            payment_amount: None,
+            payment_fee: None,
+            payment_method: Some(payment_method),
+        }
     }
+
     /// Swap
-    pub fn new_swap() -> Self {
-        Self::Swap(Uuid::new_v4())
+    pub fn new_swap(total_issued: Amount, total_redeemed: Amount, fee_collected: Amount) -> Self {
+        Self {
+            id: Uuid::new_v4(),
+            kind: OperationKind::Swap,
+            total_issued,
+            total_redeemed,
+            fee_collected,
+            complete_at: None,
+            payment_amount: None,
+            payment_fee: None,
+            payment_method: None,
+        }
     }
 
     /// Operation id
     pub fn id(&self) -> &Uuid {
-        match self {
-            Operation::Mint(id) => id,
-            Operation::Melt(id) => id,
-            Operation::Swap(id) => id,
-        }
+        &self.id
     }
 
     /// Operation kind
-    pub fn kind(&self) -> &str {
-        match self {
-            Operation::Mint(_) => "mint",
-            Operation::Melt(_) => "melt",
-            Operation::Swap(_) => "swap",
-        }
+    pub fn kind(&self) -> OperationKind {
+        self.kind
     }
 
-    /// From kind and i
-    pub fn from_kind_and_id(kind: &str, id: &str) -> Result<Self, Error> {
-        let uuid = Uuid::parse_str(id)?;
-        match kind {
-            "mint" => Ok(Self::Mint(uuid)),
-            "melt" => Ok(Self::Melt(uuid)),
-            "swap" => Ok(Self::Swap(uuid)),
-            _ => Err(Error::Custom(format!("Invalid operation kind: {kind}"))),
-        }
+    /// Total issued
+    pub fn total_issued(&self) -> Amount {
+        self.total_issued
+    }
+
+    /// Total redeemed
+    pub fn total_redeemed(&self) -> Amount {
+        self.total_redeemed
+    }
+
+    /// Fee collected
+    pub fn fee_collected(&self) -> Amount {
+        self.fee_collected
+    }
+
+    /// Completed time
+    pub fn completed_at(&self) -> &Option<u64> {
+        &self.complete_at
+    }
+
+    /// Add change
+    pub fn add_change(&mut self, change: Amount) {
+        self.total_issued = change;
+    }
+
+    /// Payment amount (only for melt operations)
+    pub fn payment_amount(&self) -> Option<Amount> {
+        self.payment_amount
+    }
+
+    /// Payment fee (only for melt operations)
+    pub fn payment_fee(&self) -> Option<Amount> {
+        self.payment_fee
+    }
+
+    /// Set payment details for melt operations
+    pub fn set_payment_details(&mut self, payment_amount: Amount, payment_fee: Amount) {
+        self.payment_amount = Some(payment_amount);
+        self.payment_fee = Some(payment_fee);
+    }
+
+    /// Payment method (only for mint/melt operations)
+    pub fn payment_method(&self) -> Option<PaymentMethod> {
+        self.payment_method.clone()
     }
 }
 

+ 1 - 1
crates/cdk-integration-tests/tests/happy_path_mint_wallet.rs

@@ -332,7 +332,7 @@ async fn test_restore() {
 
     assert!(!proofs.is_empty());
 
-    let expected_fee = wallet.get_proofs_fee(&proofs).await.unwrap();
+    let expected_fee = wallet.get_proofs_fee(&proofs).await.unwrap().total;
     wallet_2
         .swap(None, SplitTarget::default(), proofs, None, false)
         .await

+ 4 - 4
crates/cdk-integration-tests/tests/test_fees.rs

@@ -60,7 +60,7 @@ async fn test_swap() {
 
     let proofs = send.proofs();
 
-    let fee = wallet.get_proofs_fee(&proofs).await.unwrap();
+    let fee = wallet.get_proofs_fee(&proofs).await.unwrap().total;
 
     assert_eq!(fee, 1.into());
 
@@ -115,7 +115,7 @@ async fn test_fake_melt_change_in_quote() {
 
     let proofs_total = proofs.total_amount().unwrap();
 
-    let fee = wallet.get_proofs_fee(&proofs).await.unwrap();
+    let fee_breakdown = wallet.get_proofs_fee(&proofs).await.unwrap();
     let melt = wallet
         .melt_proofs(&melt_quote.id, proofs.clone())
         .await
@@ -124,7 +124,7 @@ async fn test_fake_melt_change_in_quote() {
     let idk = proofs.total_amount().unwrap() - Amount::from(invoice_amount) - change;
 
     println!("{}", idk);
-    println!("{}", fee);
+    println!("{}", fee_breakdown.total);
     println!("{}", proofs_total);
     println!("{}", change);
 
@@ -132,6 +132,6 @@ async fn test_fake_melt_change_in_quote() {
 
     assert_eq!(
         wallet.total_balance().await.unwrap(),
-        Amount::from(100 - invoice_amount - u64::from(fee) - ln_fee)
+        Amount::from(100 - invoice_amount - u64::from(fee_breakdown.total) - ln_fee)
     );
 }

+ 18 - 0
crates/cdk-sql-common/src/mint/migrations/postgres/20251119000000_add_completed_operations.sql

@@ -0,0 +1,18 @@
+-- Create completed_operations table to track finished operations
+CREATE TABLE IF NOT EXISTS completed_operations (
+    operation_id TEXT PRIMARY KEY NOT NULL,
+    operation_kind TEXT NOT NULL,
+    completed_at BIGINT NOT NULL,
+    total_issued BIGINT NOT NULL,
+    total_redeemed BIGINT NOT NULL,
+    fee_collected BIGINT NOT NULL,
+    payment_amount BIGINT,
+    payment_fee BIGINT,
+    payment_method TEXT
+);
+
+-- Create index for efficient querying by operation kind and time
+CREATE INDEX IF NOT EXISTS idx_completed_operations_kind_time ON completed_operations(operation_kind, completed_at);
+
+-- Create index for time-based queries
+CREATE INDEX IF NOT EXISTS idx_completed_operations_time ON completed_operations(completed_at);

+ 2 - 0
crates/cdk-sql-common/src/mint/migrations/postgres/20251120000000_add_keyset_fee_collected.sql

@@ -0,0 +1,2 @@
+-- Add fee_collected column to keyset_amounts table
+ALTER TABLE keyset_amounts ADD COLUMN fee_collected BIGINT NOT NULL DEFAULT 0;

+ 18 - 0
crates/cdk-sql-common/src/mint/migrations/sqlite/20251119000000_add_completed_operations.sql

@@ -0,0 +1,18 @@
+-- Create completed_operations table to track finished operations
+CREATE TABLE IF NOT EXISTS completed_operations (
+    operation_id TEXT PRIMARY KEY NOT NULL,
+    operation_kind TEXT NOT NULL,
+    completed_at INTEGER NOT NULL,
+    total_issued INTEGER NOT NULL,
+    total_redeemed INTEGER NOT NULL,
+    fee_collected INTEGER NOT NULL,
+    payment_amount INTEGER,
+    payment_fee INTEGER,
+    payment_method TEXT
+);
+
+-- Create index for efficient querying by operation kind and time
+CREATE INDEX IF NOT EXISTS idx_completed_operations_kind_time ON completed_operations(operation_kind, completed_at);
+
+-- Create index for time-based queries
+CREATE INDEX IF NOT EXISTS idx_completed_operations_time ON completed_operations(completed_at);

+ 2 - 0
crates/cdk-sql-common/src/mint/migrations/sqlite/20251120000000_add_keyset_fee_collected.sql

@@ -0,0 +1,2 @@
+-- Add fee_collected column to keyset_amounts table
+ALTER TABLE keyset_amounts ADD COLUMN fee_collected INTEGER NOT NULL DEFAULT 0;

+ 196 - 3
crates/cdk-sql-common/src/mint/mod.rs

@@ -15,7 +15,10 @@ use std::sync::Arc;
 
 use async_trait::async_trait;
 use bitcoin::bip32::DerivationPath;
-use cdk_common::database::mint::{validate_kvstore_params, SagaDatabase, SagaTransaction};
+use cdk_common::database::mint::{
+    validate_kvstore_params, CompletedOperationsDatabase, CompletedOperationsTransaction,
+    SagaDatabase, SagaTransaction,
+};
 use cdk_common::database::{
     self, ConversionError, DbTransactionFinalizer, Error, MintDatabase, MintKeyDatabaseTransaction,
     MintKeysDatabase, MintProofsDatabase, MintQuotesDatabase, MintQuotesTransaction,
@@ -184,7 +187,7 @@ where
             .bind("state", "UNSPENT".to_string())
             .bind("quote_id", quote_id.clone().map(|q| q.to_string()))
             .bind("created_time", current_time as i64)
-            .bind("operation_kind", operation.kind())
+            .bind("operation_kind", operation.kind().to_string())
             .bind("operation_id", operation.id().to_string())
             .execute(&self.inner)
             .await?;
@@ -834,7 +837,7 @@ where
             .bind("keyset_id", message.keyset_id.to_string())
             .bind("quote_id", quote_id.map(|q| q.to_string()))
             .bind("created_time", current_time as i64)
-            .bind("operation_kind", operation.kind())
+            .bind("operation_kind", operation.kind().to_string())
             .bind("operation_id", operation.id().to_string())
             .execute(&self.inner)
             .await
@@ -2356,6 +2359,150 @@ where
 }
 
 #[async_trait]
+impl<RM> CompletedOperationsTransaction<'_> for SQLTransaction<RM>
+where
+    RM: DatabasePool + 'static,
+{
+    type Err = Error;
+
+    async fn add_completed_operation(
+        &mut self,
+        operation: &mint::Operation,
+        fee_by_keyset: &std::collections::HashMap<cdk_common::nuts::Id, cdk_common::Amount>,
+    ) -> Result<(), Self::Err> {
+        query(
+            r#"
+            INSERT INTO completed_operations
+            (operation_id, operation_kind, completed_at, total_issued, total_redeemed, fee_collected, payment_amount, payment_fee, payment_method)
+            VALUES
+            (:operation_id, :operation_kind, :completed_at, :total_issued, :total_redeemed, :fee_collected, :payment_amount, :payment_fee, :payment_method)
+            "#,
+        )?
+        .bind("operation_id", operation.id().to_string())
+        .bind("operation_kind", operation.kind().to_string())
+        .bind("completed_at", operation.completed_at().unwrap_or(unix_time()) as i64)
+        .bind("total_issued", operation.total_issued().to_u64() as i64)
+        .bind("total_redeemed", operation.total_redeemed().to_u64() as i64)
+        .bind("fee_collected", operation.fee_collected().to_u64() as i64)
+        .bind("payment_amount", operation.payment_amount().map(|a| a.to_u64() as i64))
+        .bind("payment_fee", operation.payment_fee().map(|a| a.to_u64() as i64))
+        .bind("payment_method", operation.payment_method().map(|m| m.to_string()))
+        .execute(&self.inner)
+        .await?;
+
+        // Update keyset_amounts with fee_collected from the breakdown
+        for (keyset_id, fee) in fee_by_keyset {
+            if fee.to_u64() > 0 {
+                query(
+                    r#"
+                    INSERT INTO keyset_amounts (keyset_id, total_issued, total_redeemed, fee_collected)
+                    VALUES (:keyset_id, 0, 0, :fee)
+                    ON CONFLICT (keyset_id)
+                    DO UPDATE SET fee_collected = keyset_amounts.fee_collected + EXCLUDED.fee_collected
+                    "#,
+                )?
+                .bind("keyset_id", keyset_id.to_string())
+                .bind("fee", fee.to_u64() as i64)
+                .execute(&self.inner)
+                .await?;
+            }
+        }
+
+        Ok(())
+    }
+}
+
+#[async_trait]
+impl<RM> CompletedOperationsDatabase for SQLMintDatabase<RM>
+where
+    RM: DatabasePool + 'static,
+{
+    type Err = Error;
+
+    async fn get_completed_operation(
+        &self,
+        operation_id: &uuid::Uuid,
+    ) -> Result<Option<mint::Operation>, Self::Err> {
+        let conn = self.pool.get().map_err(|e| Error::Database(Box::new(e)))?;
+        Ok(query(
+            r#"
+            SELECT
+                operation_id,
+                operation_kind,
+                completed_at,
+                total_issued,
+                total_redeemed,
+                fee_collected,
+                payment_method
+            FROM
+                completed_operations
+            WHERE
+                operation_id = :operation_id
+            "#,
+        )?
+        .bind("operation_id", operation_id.to_string())
+        .fetch_one(&*conn)
+        .await?
+        .map(sql_row_to_completed_operation)
+        .transpose()?)
+    }
+
+    async fn get_completed_operations_by_kind(
+        &self,
+        operation_kind: mint::OperationKind,
+    ) -> Result<Vec<mint::Operation>, Self::Err> {
+        let conn = self.pool.get().map_err(|e| Error::Database(Box::new(e)))?;
+        Ok(query(
+            r#"
+            SELECT
+                operation_id,
+                operation_kind,
+                completed_at,
+                total_issued,
+                total_redeemed,
+                fee_collected,
+                payment_method
+            FROM
+                completed_operations
+            WHERE
+                operation_kind = :operation_kind
+            ORDER BY completed_at DESC
+            "#,
+        )?
+        .bind("operation_kind", operation_kind.to_string())
+        .fetch_all(&*conn)
+        .await?
+        .into_iter()
+        .map(sql_row_to_completed_operation)
+        .collect::<Result<Vec<_>, _>>()?)
+    }
+
+    async fn get_completed_operations(&self) -> Result<Vec<mint::Operation>, Self::Err> {
+        let conn = self.pool.get().map_err(|e| Error::Database(Box::new(e)))?;
+        Ok(query(
+            r#"
+            SELECT
+                operation_id,
+                operation_kind,
+                completed_at,
+                total_issued,
+                total_redeemed,
+                fee_collected,
+                payment_method
+            FROM
+                completed_operations
+            ORDER BY completed_at DESC
+            "#,
+        )?
+        .fetch_all(&*conn)
+        .await?
+        .into_iter()
+        .map(sql_row_to_completed_operation)
+        .collect::<Result<Vec<_>, _>>()?)
+    }
+}
+
+#[async_trait]
 impl<RM> MintDatabase<Error> for SQLMintDatabase<RM>
 where
     RM: DatabasePool + 'static,
@@ -2692,6 +2839,52 @@ fn sql_row_to_saga(row: Vec<Column>) -> Result<mint::Saga, Error> {
     })
 }
 
+fn sql_row_to_completed_operation(row: Vec<Column>) -> Result<mint::Operation, Error> {
+    unpack_into!(
+        let (
+            operation_id,
+            operation_kind,
+            completed_at,
+            total_issued,
+            total_redeemed,
+            fee_collected,
+            payment_method
+        ) = row
+    );
+
+    let operation_id_str = column_as_string!(&operation_id);
+    let operation_id = uuid::Uuid::parse_str(&operation_id_str)
+        .map_err(|e| Error::Internal(format!("Invalid operation_id UUID: {e}")))?;
+
+    let operation_kind_str = column_as_string!(&operation_kind);
+    let operation_kind = mint::OperationKind::from_str(&operation_kind_str)
+        .map_err(|e| Error::Internal(format!("Invalid operation kind: {e}")))?;
+
+    let completed_at: u64 = column_as_number!(completed_at);
+    let total_issued_u64: u64 = column_as_number!(total_issued);
+    let total_redeemed_u64: u64 = column_as_number!(total_redeemed);
+    let fee_collected_u64: u64 = column_as_number!(fee_collected);
+
+    let total_issued = Amount::from(total_issued_u64);
+    let total_redeemed = Amount::from(total_redeemed_u64);
+    let fee_collected = Amount::from(fee_collected_u64);
+
+    let payment_method = column_as_nullable_string!(payment_method)
+        .map(|s| PaymentMethod::from_str(&s))
+        .transpose()
+        .map_err(|e| Error::Internal(format!("Invalid payment method: {e}")))?;
+
+    Ok(mint::Operation::new(
+        operation_id,
+        operation_kind,
+        total_issued,
+        total_redeemed,
+        fee_collected,
+        Some(completed_at),
+        payment_method,
+    ))
+}
+
 #[cfg(test)]
 mod test {
     use super::*;

+ 12 - 4
crates/cdk-sqlite/src/mint/memory.rs

@@ -56,10 +56,18 @@ pub async fn new_with_state(
         tx.add_melt_quote(quote).await?;
     }
 
-    tx.add_proofs(pending_proofs, None, &Operation::new_swap())
-        .await?;
-    tx.add_proofs(spent_proofs, None, &Operation::new_swap())
-        .await?;
+    tx.add_proofs(
+        pending_proofs,
+        None,
+        &Operation::new_swap(Default::default(), Default::default(), Default::default()),
+    )
+    .await?;
+    tx.add_proofs(
+        spent_proofs,
+        None,
+        &Operation::new_swap(Default::default(), Default::default(), Default::default()),
+    )
+    .await?;
     let mint_info_bytes = serde_json::to_vec(&mint_info)?;
     tx.kv_write(
         CDK_MINT_PRIMARY_NAMESPACE,

+ 258 - 42
crates/cdk/src/fees.rs

@@ -2,20 +2,30 @@
 //!
 //! <https://github.com/cashubtc/nuts/blob/main/02.md>
 
-use std::collections::HashMap;
+use std::collections::{BTreeMap, HashMap};
 
 use tracing::instrument;
 
 use crate::nuts::Id;
 use crate::{Amount, Error};
 
+/// Fee breakdown containing total fee and fee per keyset
+#[derive(Debug, Clone, PartialEq)]
+pub struct ProofsFeeBreakdown {
+    /// Total fee across all keysets
+    pub total: Amount,
+    /// Fee collected per keyset
+    pub per_keyset: HashMap<Id, Amount>,
+}
+
 /// Fee required for proof set
 #[instrument(skip_all)]
 pub fn calculate_fee(
     proofs_count: &HashMap<Id, u64>,
     keyset_fee: &HashMap<Id, u64>,
-) -> Result<Amount, Error> {
+) -> Result<ProofsFeeBreakdown, Error> {
     let mut sum_fee: u64 = 0;
+    let mut fee_per_keyset_raw: BTreeMap<Id, u64> = BTreeMap::new();
 
     for (keyset_id, proof_count) in proofs_count {
         let keyset_fee_ppk = keyset_fee
@@ -27,11 +37,39 @@ pub fn calculate_fee(
         sum_fee = sum_fee
             .checked_add(proofs_fee)
             .ok_or(Error::AmountOverflow)?;
+
+        fee_per_keyset_raw.insert(*keyset_id, proofs_fee);
     }
 
-    let fee = (sum_fee.checked_add(999).ok_or(Error::AmountOverflow)?) / 1000;
+    let total_fee = (sum_fee.checked_add(999).ok_or(Error::AmountOverflow)?) / 1000;
+
+    // Calculate fee per keyset proportionally based on the total
+    // BTreeMap ensures deterministic iteration order (sorted by keyset ID)
+    let mut per_keyset = HashMap::new();
+    let mut distributed_fee: u64 = 0;
+    let keyset_count = fee_per_keyset_raw.len();
+
+    for (i, (keyset_id, raw_fee)) in fee_per_keyset_raw.iter().enumerate() {
+        if sum_fee == 0 {
+            continue;
+        }
+
+        // Calculate proportional fee, rounding down
+        let keyset_fee = if i == keyset_count - 1 {
+            // Last keyset gets the remainder to ensure total matches
+            total_fee.saturating_sub(distributed_fee)
+        } else {
+            (raw_fee * total_fee) / sum_fee
+        };
+
+        distributed_fee = distributed_fee.saturating_add(keyset_fee);
+        per_keyset.insert(*keyset_id, keyset_fee.into());
+    }
 
-    Ok(fee.into())
+    Ok(ProofsFeeBreakdown {
+        total: total_fee.into(),
+        per_keyset,
+    })
 }
 
 #[cfg(test)]
@@ -54,33 +92,39 @@ mod tests {
 
         proofs_count.insert(keyset_id, 1);
 
-        let sum_fee = calculate_fee(&proofs_count, &keyset_fees).unwrap();
+        let breakdown = calculate_fee(&proofs_count, &keyset_fees).unwrap();
 
-        assert_eq!(sum_fee, 1.into());
+        assert_eq!(breakdown.total, 1.into());
+        assert_eq!(breakdown.per_keyset[&keyset_id], 1.into());
 
         proofs_count.insert(keyset_id, 500);
 
-        let sum_fee = calculate_fee(&proofs_count, &keyset_fees).unwrap();
+        let breakdown = calculate_fee(&proofs_count, &keyset_fees).unwrap();
 
-        assert_eq!(sum_fee, 1.into());
+        assert_eq!(breakdown.total, 1.into());
+        assert_eq!(breakdown.per_keyset[&keyset_id], 1.into());
 
         proofs_count.insert(keyset_id, 1000);
 
-        let sum_fee = calculate_fee(&proofs_count, &keyset_fees).unwrap();
+        let breakdown = calculate_fee(&proofs_count, &keyset_fees).unwrap();
 
-        assert_eq!(sum_fee, 2.into());
+        assert_eq!(breakdown.total, 2.into());
+        assert_eq!(breakdown.per_keyset[&keyset_id], 2.into());
 
         proofs_count.insert(keyset_id, 2000);
-        let sum_fee = calculate_fee(&proofs_count, &keyset_fees).unwrap();
-        assert_eq!(sum_fee, 4.into());
+        let breakdown = calculate_fee(&proofs_count, &keyset_fees).unwrap();
+        assert_eq!(breakdown.total, 4.into());
+        assert_eq!(breakdown.per_keyset[&keyset_id], 4.into());
 
         proofs_count.insert(keyset_id, 3500);
-        let sum_fee = calculate_fee(&proofs_count, &keyset_fees).unwrap();
-        assert_eq!(sum_fee, 7.into());
+        let breakdown = calculate_fee(&proofs_count, &keyset_fees).unwrap();
+        assert_eq!(breakdown.total, 7.into());
+        assert_eq!(breakdown.per_keyset[&keyset_id], 7.into());
 
         proofs_count.insert(keyset_id, 3501);
-        let sum_fee = calculate_fee(&proofs_count, &keyset_fees).unwrap();
-        assert_eq!(sum_fee, 8.into());
+        let breakdown = calculate_fee(&proofs_count, &keyset_fees).unwrap();
+        assert_eq!(breakdown.total, 8.into());
+        assert_eq!(breakdown.per_keyset[&keyset_id], 8.into());
     }
 
     #[test]
@@ -95,20 +139,32 @@ mod tests {
         let mut proofs_count = HashMap::new();
 
         proofs_count.insert(keyset_id, 1);
-        let sum_fee = calculate_fee(&proofs_count, &keyset_fees).unwrap();
-        assert_eq!(sum_fee, 1.into(), "1 proof: ceil(200/1000) = 1 sat");
+        let breakdown = calculate_fee(&proofs_count, &keyset_fees).unwrap();
+        assert_eq!(breakdown.total, 1.into(), "1 proof: ceil(200/1000) = 1 sat");
 
         proofs_count.insert(keyset_id, 3);
-        let sum_fee = calculate_fee(&proofs_count, &keyset_fees).unwrap();
-        assert_eq!(sum_fee, 1.into(), "3 proofs: ceil(600/1000) = 1 sat");
+        let breakdown = calculate_fee(&proofs_count, &keyset_fees).unwrap();
+        assert_eq!(
+            breakdown.total,
+            1.into(),
+            "3 proofs: ceil(600/1000) = 1 sat"
+        );
 
         proofs_count.insert(keyset_id, 5);
-        let sum_fee = calculate_fee(&proofs_count, &keyset_fees).unwrap();
-        assert_eq!(sum_fee, 1.into(), "5 proofs: ceil(1000/1000) = 1 sat");
+        let breakdown = calculate_fee(&proofs_count, &keyset_fees).unwrap();
+        assert_eq!(
+            breakdown.total,
+            1.into(),
+            "5 proofs: ceil(1000/1000) = 1 sat"
+        );
 
         proofs_count.insert(keyset_id, 6);
-        let sum_fee = calculate_fee(&proofs_count, &keyset_fees).unwrap();
-        assert_eq!(sum_fee, 2.into(), "6 proofs: ceil(1200/1000) = 2 sats");
+        let breakdown = calculate_fee(&proofs_count, &keyset_fees).unwrap();
+        assert_eq!(
+            breakdown.total,
+            2.into(),
+            "6 proofs: ceil(1200/1000) = 2 sats"
+        );
     }
 
     #[test]
@@ -123,16 +179,20 @@ mod tests {
         let mut proofs_count = HashMap::new();
 
         proofs_count.insert(keyset_id, 1);
-        let sum_fee = calculate_fee(&proofs_count, &keyset_fees).unwrap();
-        assert_eq!(sum_fee, 1.into(), "1 proof at 1000 ppk = 1 sat");
+        let breakdown = calculate_fee(&proofs_count, &keyset_fees).unwrap();
+        assert_eq!(breakdown.total, 1.into(), "1 proof at 1000 ppk = 1 sat");
 
         proofs_count.insert(keyset_id, 2);
-        let sum_fee = calculate_fee(&proofs_count, &keyset_fees).unwrap();
-        assert_eq!(sum_fee, 2.into(), "2 proofs at 1000 ppk = 2 sats");
+        let breakdown = calculate_fee(&proofs_count, &keyset_fees).unwrap();
+        assert_eq!(breakdown.total, 2.into(), "2 proofs at 1000 ppk = 2 sats");
 
         proofs_count.insert(keyset_id, 10);
-        let sum_fee = calculate_fee(&proofs_count, &keyset_fees).unwrap();
-        assert_eq!(sum_fee, 10.into(), "10 proofs at 1000 ppk = 10 sats");
+        let breakdown = calculate_fee(&proofs_count, &keyset_fees).unwrap();
+        assert_eq!(
+            breakdown.total,
+            10.into(),
+            "10 proofs at 1000 ppk = 10 sats"
+        );
     }
 
     #[test]
@@ -147,8 +207,12 @@ mod tests {
         let mut proofs_count = HashMap::new();
 
         proofs_count.insert(keyset_id, 100);
-        let sum_fee = calculate_fee(&proofs_count, &keyset_fees).unwrap();
-        assert_eq!(sum_fee, 0.into(), "0 ppk means no fee: ceil(0/1000) = 0");
+        let breakdown = calculate_fee(&proofs_count, &keyset_fees).unwrap();
+        assert_eq!(
+            breakdown.total,
+            0.into(),
+            "0 ppk means no fee: ceil(0/1000) = 0"
+        );
     }
 
     #[test]
@@ -163,20 +227,32 @@ mod tests {
         let mut proofs_count = HashMap::new();
 
         proofs_count.insert(keyset_id, 1);
-        let sum_fee = calculate_fee(&proofs_count, &keyset_fees).unwrap();
-        assert_eq!(sum_fee, 1.into(), "1 proof: ceil(100/1000) = 1 sat");
+        let breakdown = calculate_fee(&proofs_count, &keyset_fees).unwrap();
+        assert_eq!(breakdown.total, 1.into(), "1 proof: ceil(100/1000) = 1 sat");
 
         proofs_count.insert(keyset_id, 10);
-        let sum_fee = calculate_fee(&proofs_count, &keyset_fees).unwrap();
-        assert_eq!(sum_fee, 1.into(), "10 proofs: ceil(1000/1000) = 1 sat");
+        let breakdown = calculate_fee(&proofs_count, &keyset_fees).unwrap();
+        assert_eq!(
+            breakdown.total,
+            1.into(),
+            "10 proofs: ceil(1000/1000) = 1 sat"
+        );
 
         proofs_count.insert(keyset_id, 11);
-        let sum_fee = calculate_fee(&proofs_count, &keyset_fees).unwrap();
-        assert_eq!(sum_fee, 2.into(), "11 proofs: ceil(1100/1000) = 2 sats");
+        let breakdown = calculate_fee(&proofs_count, &keyset_fees).unwrap();
+        assert_eq!(
+            breakdown.total,
+            2.into(),
+            "11 proofs: ceil(1100/1000) = 2 sats"
+        );
 
         proofs_count.insert(keyset_id, 91);
-        let sum_fee = calculate_fee(&proofs_count, &keyset_fees).unwrap();
-        assert_eq!(sum_fee, 10.into(), "91 proofs: ceil(9100/1000) = 10 sats");
+        let breakdown = calculate_fee(&proofs_count, &keyset_fees).unwrap();
+        assert_eq!(
+            breakdown.total,
+            10.into(),
+            "91 proofs: ceil(9100/1000) = 10 sats"
+        );
     }
 
     #[test]
@@ -207,11 +283,151 @@ mod tests {
         proofs_count.insert(keyset_id_1, 3);
         proofs_count.insert(keyset_id_2, 2);
 
-        let sum_fee = calculate_fee(&proofs_count, &keyset_fees).unwrap();
+        let breakdown = calculate_fee(&proofs_count, &keyset_fees).unwrap();
         assert_eq!(
-            sum_fee,
+            breakdown.total,
             2.into(),
             "3*200 + 2*500 = 1600, ceil(1600/1000) = 2"
         );
     }
+
+    #[test]
+    fn test_per_keyset_fee_sums_to_total() {
+        let keyset_id_1 = Id::from_str("001711afb1de20cb").unwrap();
+        let keyset_id_2 = Id::from_str("001711afb1de20cc").unwrap();
+        let keyset_id_3 = Id::from_str("001711afb1de20cd").unwrap();
+
+        let mut keyset_fees = HashMap::new();
+        keyset_fees.insert(keyset_id_1, 100);
+        keyset_fees.insert(keyset_id_2, 100);
+        keyset_fees.insert(keyset_id_3, 100);
+
+        let mut proofs_count = HashMap::new();
+        proofs_count.insert(keyset_id_1, 1);
+        proofs_count.insert(keyset_id_2, 1);
+        proofs_count.insert(keyset_id_3, 1);
+
+        // 3 proofs * 100 ppk = 300 ppk, ceil(300/1000) = 1 sat total
+        let breakdown = calculate_fee(&proofs_count, &keyset_fees).unwrap();
+
+        assert_eq!(breakdown.total, 1.into());
+
+        // Sum of per_keyset fees must equal total
+        let per_keyset_sum: u64 = breakdown.per_keyset.values().map(|a| u64::from(*a)).sum();
+        assert_eq!(per_keyset_sum, u64::from(breakdown.total));
+    }
+
+    #[test]
+    fn test_per_keyset_fee_remainder_goes_to_last_sorted_keyset() {
+        // Use keyset IDs where sorting order is predictable
+        let keyset_id_1 = Id::from_str("00aaaaaaaaaaaaa1").unwrap();
+        let keyset_id_2 = Id::from_str("00aaaaaaaaaaaaa2").unwrap();
+        let keyset_id_3 = Id::from_str("00aaaaaaaaaaaaa3").unwrap();
+
+        let mut keyset_fees = HashMap::new();
+        keyset_fees.insert(keyset_id_1, 100);
+        keyset_fees.insert(keyset_id_2, 100);
+        keyset_fees.insert(keyset_id_3, 100);
+
+        let mut proofs_count = HashMap::new();
+        proofs_count.insert(keyset_id_1, 1);
+        proofs_count.insert(keyset_id_2, 1);
+        proofs_count.insert(keyset_id_3, 1);
+
+        // 3 * 100 = 300 ppk, ceil(300/1000) = 1 sat total
+        // Each keyset contributed 100/300 = 1/3 of raw fee
+        // Proportional: (100 * 1) / 300 = 0 for first two (integer division)
+        // Last keyset (keyset_id_3) gets remainder: 1 - 0 - 0 = 1
+        let breakdown = calculate_fee(&proofs_count, &keyset_fees).unwrap();
+
+        assert_eq!(breakdown.total, 1.into());
+        assert_eq!(breakdown.per_keyset[&keyset_id_1], 0.into());
+        assert_eq!(breakdown.per_keyset[&keyset_id_2], 0.into());
+        assert_eq!(breakdown.per_keyset[&keyset_id_3], 1.into());
+    }
+
+    #[test]
+    fn test_per_keyset_fee_distribution_is_deterministic() {
+        let keyset_id_1 = Id::from_str("001711afb1de20cb").unwrap();
+        let keyset_id_2 = Id::from_str("001711afb1de20cc").unwrap();
+
+        let mut keyset_fees = HashMap::new();
+        keyset_fees.insert(keyset_id_1, 333);
+        keyset_fees.insert(keyset_id_2, 333);
+
+        let mut proofs_count = HashMap::new();
+        proofs_count.insert(keyset_id_1, 1);
+        proofs_count.insert(keyset_id_2, 1);
+
+        // Run multiple times to verify determinism
+        let breakdown1 = calculate_fee(&proofs_count, &keyset_fees).unwrap();
+        let breakdown2 = calculate_fee(&proofs_count, &keyset_fees).unwrap();
+        let breakdown3 = calculate_fee(&proofs_count, &keyset_fees).unwrap();
+
+        // All runs should produce identical per-keyset results
+        assert_eq!(
+            breakdown1.per_keyset[&keyset_id_1],
+            breakdown2.per_keyset[&keyset_id_1]
+        );
+        assert_eq!(
+            breakdown1.per_keyset[&keyset_id_2],
+            breakdown2.per_keyset[&keyset_id_2]
+        );
+        assert_eq!(
+            breakdown2.per_keyset[&keyset_id_1],
+            breakdown3.per_keyset[&keyset_id_1]
+        );
+        assert_eq!(
+            breakdown2.per_keyset[&keyset_id_2],
+            breakdown3.per_keyset[&keyset_id_2]
+        );
+    }
+
+    #[test]
+    fn test_per_keyset_fee_proportional_distribution() {
+        let keyset_id_1 = Id::from_str("001711afb1de20cb").unwrap();
+        let keyset_id_2 = Id::from_str("001711afb1de20cc").unwrap();
+
+        let mut keyset_fees = HashMap::new();
+        keyset_fees.insert(keyset_id_1, 1000); // 1 sat per proof
+        keyset_fees.insert(keyset_id_2, 1000);
+
+        let mut proofs_count = HashMap::new();
+        proofs_count.insert(keyset_id_1, 3); // 3000 ppk = 3 sat raw
+        proofs_count.insert(keyset_id_2, 7); // 7000 ppk = 7 sat raw
+
+        // Total: 10000 ppk = 10 sat
+        let breakdown = calculate_fee(&proofs_count, &keyset_fees).unwrap();
+
+        assert_eq!(breakdown.total, 10.into());
+        // keyset_id_1: (3000 * 10) / 10000 = 3
+        // keyset_id_2: 10 - 3 = 7 (gets remainder, but happens to be exact)
+        assert_eq!(breakdown.per_keyset[&keyset_id_1], 3.into());
+        assert_eq!(breakdown.per_keyset[&keyset_id_2], 7.into());
+    }
+
+    #[test]
+    fn test_per_keyset_fee_with_uneven_distribution() {
+        let keyset_id_1 = Id::from_str("00aaaaaaaaaaaaa1").unwrap();
+        let keyset_id_2 = Id::from_str("00aaaaaaaaaaaaa2").unwrap();
+
+        let mut keyset_fees = HashMap::new();
+        keyset_fees.insert(keyset_id_1, 100);
+        keyset_fees.insert(keyset_id_2, 100);
+
+        let mut proofs_count = HashMap::new();
+        proofs_count.insert(keyset_id_1, 5); // 500 ppk
+        proofs_count.insert(keyset_id_2, 6); // 600 ppk
+
+        // Total: 1100 ppk, ceil(1100/1000) = 2 sat
+        // keyset_id_1: (500 * 2) / 1100 = 0 (integer division)
+        // keyset_id_2: 2 - 0 = 2 (gets remainder)
+        let breakdown = calculate_fee(&proofs_count, &keyset_fees).unwrap();
+
+        assert_eq!(breakdown.total, 2.into());
+
+        // Verify sum equals total
+        let per_keyset_sum: u64 = breakdown.per_keyset.values().map(|a| u64::from(*a)).sum();
+        assert_eq!(per_keyset_sum, 2);
+    }
 }

+ 7 - 2
crates/cdk/src/mint/issue/mod.rs

@@ -657,7 +657,8 @@ impl Mint {
         let unit = unit.ok_or(Error::UnsupportedUnit)?;
         ensure_cdk!(unit == mint_quote.unit, Error::UnsupportedUnit);
 
-        let operation = Operation::new_mint();
+        let amount_issued = mint_request.total_amount()?;
+        let operation = Operation::new_mint(amount_issued, mint_quote.payment_method.clone());
 
         tx.add_blinded_messages(Some(&mint_request.quote), &mint_request.outputs, &operation).await?;
 
@@ -672,12 +673,16 @@ impl Mint {
         )
             .await?;
 
-        let amount_issued = mint_request.total_amount()?;
 
         let total_issued = tx
             .increment_mint_quote_amount_issued(&mint_request.quote, amount_issued)
             .await?;
 
+
+        // Mint operations have no input fees (no proofs being spent)
+        let fee_by_keyset = std::collections::HashMap::new();
+        tx.add_completed_operation(&operation, &fee_by_keyset).await?;
+
         tx.commit().await?;
 
         self.pubsub_manager

+ 61 - 25
crates/cdk/src/mint/melt/melt_saga/mod.rs

@@ -127,8 +127,8 @@ pub struct MeltSaga<S> {
     pubsub: Arc<PubSubManager>,
     /// Compensating actions in LIFO order (most recent first)
     compensations: Arc<Mutex<VecDeque<Box<dyn CompensatingAction>>>>,
-    /// Operation for tracking
-    operation: Operation,
+    /// Operation ID (used for saga tracking, generated upfront)
+    operation_id: uuid::Uuid,
     /// Tracks if metrics were incremented (for cleanup)
     #[cfg(feature = "prometheus")]
     metrics_incremented: bool,
@@ -141,15 +141,17 @@ impl MeltSaga<Initial> {
         #[cfg(feature = "prometheus")]
         METRICS.inc_in_flight_requests("melt_bolt11");
 
+        let operation_id = uuid::Uuid::new_v4();
+
         Self {
             mint,
             db,
             pubsub,
             compensations: Arc::new(Mutex::new(VecDeque::new())),
-            operation: Operation::new_melt(),
+            operation_id,
             #[cfg(feature = "prometheus")]
             metrics_incremented: true,
-            state_data: Initial,
+            state_data: Initial { operation_id },
         }
     }
 
@@ -188,6 +190,7 @@ impl MeltSaga<Initial> {
         self,
         melt_request: &MeltRequest<QuoteId>,
         input_verification: Verification,
+        payment_method: cdk_common::PaymentMethod,
     ) -> Result<MeltSaga<SetupComplete>, Error> {
         tracing::info!("TX1: Setting up melt (verify + inputs + outputs)");
 
@@ -198,12 +201,28 @@ impl MeltSaga<Initial> {
 
         let mut tx = self.db.begin_transaction().await?;
 
+        // Calculate fee to create Operation with actual amounts
+        let fee_breakdown = self.mint.get_proofs_fee(melt_request.inputs()).await?;
+
+        // Create Operation with actual amounts now that we know them
+        // total_redeemed = input_amount (proofs being burnt)
+        // fee_collected = fee
+        let operation = Operation::new(
+            self.state_data.operation_id,
+            cdk_common::mint::OperationKind::Melt,
+            Amount::ZERO,         // total_issued (change will be calculated later)
+            input_amount,         // total_redeemed
+            fee_breakdown.total,  // fee_collected
+            None,                 // complete_at
+            Some(payment_method), // payment_method
+        );
+
         // Add proofs to the database
         if let Err(err) = tx
             .add_proofs(
                 melt_request.inputs().clone(),
                 Some(melt_request.quote_id().to_owned()),
-                &self.operation,
+                &operation,
             )
             .await
         {
@@ -291,9 +310,9 @@ impl MeltSaga<Initial> {
         self.pubsub
             .melt_quote_status(&quote, None, None, MeltQuoteState::Pending);
 
-        let fee = self.mint.get_proofs_fee(melt_request.inputs()).await?;
+        let inputs_fee_breakdown = self.mint.get_proofs_fee(melt_request.inputs()).await?;
 
-        let required_total = quote.amount + quote.fee_reserve + fee;
+        let required_total = quote.amount + quote.fee_reserve + inputs_fee_breakdown.total;
 
         if input_amount < required_total {
             tracing::info!(
@@ -301,14 +320,14 @@ impl MeltSaga<Initial> {
                 input_amount,
                 quote.amount,
                 quote.fee_reserve,
-                fee,
+                inputs_fee_breakdown.total,
                 required_total
             );
             tx.rollback().await?;
             return Err(Error::TransactionUnbalanced(
                 input_amount.into(),
                 quote.amount.into(),
-                (fee + quote.fee_reserve).into(),
+                (inputs_fee_breakdown.total + quote.fee_reserve).into(),
             ));
         }
 
@@ -330,13 +349,11 @@ impl MeltSaga<Initial> {
             }
         }
 
-        let inputs_fee = self.mint.get_proofs_fee(melt_request.inputs()).await?;
-
         // Add melt request tracking record
         tx.add_melt_request(
             melt_request.quote_id(),
             melt_request.inputs_amount()?,
-            inputs_fee,
+            inputs_fee_breakdown.total,
         )
         .await?;
 
@@ -344,7 +361,7 @@ impl MeltSaga<Initial> {
         tx.add_blinded_messages(
             Some(melt_request.quote_id()),
             melt_request.outputs().as_ref().unwrap_or(&Vec::new()),
-            &self.operation,
+            &operation,
         )
         .await?;
 
@@ -359,7 +376,7 @@ impl MeltSaga<Initial> {
 
         // Persist saga state for crash recovery (atomic with TX1)
         let saga = Saga::new_melt(
-            *self.operation.id(),
+            self.operation_id,
             MeltSagaState::SetupComplete,
             input_ys.clone(),
             blinded_secrets.clone(),
@@ -385,7 +402,7 @@ impl MeltSaga<Initial> {
                 input_ys: input_ys.clone(),
                 blinded_secrets,
                 quote_id: quote.id.clone(),
-                operation_id: *self.operation.id(),
+                operation_id: self.operation_id,
             }));
 
         // Transition to SetupComplete state
@@ -394,13 +411,15 @@ impl MeltSaga<Initial> {
             db: self.db,
             pubsub: self.pubsub,
             compensations: self.compensations,
-            operation: self.operation,
+            operation_id: self.operation_id,
             #[cfg(feature = "prometheus")]
             metrics_incremented: self.metrics_incremented,
             state_data: SetupComplete {
                 quote,
                 input_ys,
                 blinded_messages: blinded_messages_vec,
+                operation,
+                fee_breakdown,
             },
         })
     }
@@ -505,7 +524,7 @@ impl MeltSaga<SetupComplete> {
         // Update saga state to PaymentAttempted BEFORE internal settlement commits
         // This ensures crash recovery knows payment may have occurred
         tx.update_saga(
-            self.operation.id(),
+            &self.operation_id,
             SagaStateEnum::Melt(MeltSagaState::PaymentAttempted),
         )
         .await?;
@@ -630,7 +649,7 @@ impl MeltSaga<SetupComplete> {
                 {
                     let mut tx = self.db.begin_transaction().await?;
                     tx.update_saga(
-                        self.operation.id(),
+                        &self.operation_id,
                         SagaStateEnum::Melt(MeltSagaState::PaymentAttempted),
                     )
                     .await?;
@@ -747,7 +766,7 @@ impl MeltSaga<SetupComplete> {
             db: self.db,
             pubsub: self.pubsub,
             compensations: self.compensations,
-            operation: self.operation,
+            operation_id: self.operation_id,
             #[cfg(feature = "prometheus")]
             metrics_incremented: self.metrics_incremented,
             state_data: PaymentConfirmed {
@@ -755,6 +774,8 @@ impl MeltSaga<SetupComplete> {
                 input_ys: self.state_data.input_ys,
                 blinded_messages: self.state_data.blinded_messages,
                 payment_result,
+                operation: self.state_data.operation,
+                fee_breakdown: self.state_data.fee_breakdown,
             },
         })
     }
@@ -905,11 +926,30 @@ impl MeltSaga<PaymentConfirmed> {
         tx.delete_melt_request(&self.state_data.quote.id).await?;
 
         // Delete saga - melt completed successfully (best-effort)
-        if let Err(e) = tx.delete_saga(self.operation.id()).await {
+        if let Err(e) = tx.delete_saga(&self.operation_id).await {
             tracing::warn!("Failed to delete saga in finalize: {}", e);
             // Don't rollback - melt succeeded
         }
 
+        let mut operation = self.state_data.operation;
+        let change_amount = change
+            .as_ref()
+            .map(|c| Amount::try_sum(c.iter().map(|a| a.amount)).expect("Change cannot overflow"))
+            .unwrap_or_default();
+
+        operation.add_change(change_amount);
+
+        // Set payment details for melt operation
+        // payment_amount = the Lightning invoice amount
+        // payment_fee = actual fee paid (total_spent - invoice_amount)
+        let payment_fee = total_spent
+            .checked_sub(self.state_data.quote.amount)
+            .unwrap_or(Amount::ZERO);
+        operation.set_payment_details(self.state_data.quote.amount, payment_fee);
+
+        tx.add_completed_operation(&operation, &self.state_data.fee_breakdown.per_keyset)
+            .await?;
+
         tx.commit().await?;
 
         self.pubsub.melt_quote_status(
@@ -924,11 +964,7 @@ impl MeltSaga<PaymentConfirmed> {
             self.state_data.quote.id,
             total_spent,
             inputs_amount,
-            change
-                .as_ref()
-                .map(|c| Amount::try_sum(c.iter().map(|a| a.amount))
-                    .expect("Change cannot overflow"))
-                .unwrap_or_default()
+            change_amount
         );
 
         self.compensations.lock().await.clear();

+ 12 - 3
crates/cdk/src/mint/melt/melt_saga/state.rs

@@ -1,15 +1,20 @@
+use cdk_common::mint::Operation;
 use cdk_common::nuts::BlindedMessage;
 use cdk_common::{Amount, PublicKey};
+use uuid::Uuid;
 
 use crate::cdk_payment::MakePaymentResponse;
 use crate::mint::MeltQuote;
 
-/// Initial state - no data yet.
+/// Initial state - only has operation ID.
 ///
 /// The melt saga starts in this state. Only the `setup_melt` method is available.
-pub struct Initial;
+/// The operation ID is generated upfront but the full Operation (with amounts) is created during setup.
+pub struct Initial {
+    pub operation_id: Uuid,
+}
 
-/// Setup complete - has quote, input Ys, and blinded messages.
+/// Setup complete - has quote, input Ys, blinded messages, and the Operation with actual amounts.
 ///
 /// After successful setup, the saga transitions to this state.
 /// The `attempt_internal_settlement` and `make_payment` methods are available.
@@ -17,6 +22,8 @@ pub struct SetupComplete {
     pub quote: MeltQuote,
     pub input_ys: Vec<PublicKey>,
     pub blinded_messages: Vec<BlindedMessage>,
+    pub operation: Operation,
+    pub fee_breakdown: crate::fees::ProofsFeeBreakdown,
 }
 
 /// Payment confirmed - has everything including payment result.
@@ -29,6 +36,8 @@ pub struct PaymentConfirmed {
     #[allow(dead_code)] // Stored for completeness, accessed from DB in finalize
     pub blinded_messages: Vec<BlindedMessage>,
     pub payment_result: MakePaymentResponse,
+    pub operation: Operation,
+    pub fee_breakdown: crate::fees::ProofsFeeBreakdown,
 }
 
 /// Result of attempting internal settlement for a melt operation.

+ 152 - 72
crates/cdk/src/mint/melt/melt_saga/tests.rs

@@ -12,7 +12,7 @@ use std::str::FromStr;
 
 use cdk_common::mint::{MeltSagaState, OperationKind, Saga};
 use cdk_common::nuts::MeltQuoteState;
-use cdk_common::{Amount, ProofsMethods, State};
+use cdk_common::{Amount, PaymentMethod, ProofsMethods, State};
 
 use crate::mint::melt::melt_saga::MeltSaga;
 use crate::test_helpers::mint::{create_test_mint, mint_test_proofs};
@@ -52,9 +52,12 @@ async fn test_saga_state_persistence_after_setup() {
         mint.localstore(),
         mint.pubsub_manager(),
     );
-    let setup_saga = saga.setup_melt(&melt_request, verification).await.unwrap();
+    let setup_saga = saga
+        .setup_melt(&melt_request, verification, PaymentMethod::Bolt11)
+        .await
+        .unwrap();
 
-    let operation_id = *setup_saga.operation.id();
+    let operation_id = *setup_saga.state_data.operation.id();
 
     // STEP 3: Query database for saga
     let sagas = mint
@@ -150,8 +153,11 @@ async fn test_saga_deletion_on_success() {
     );
 
     // Setup
-    let setup_saga = saga.setup_melt(&melt_request, verification).await.unwrap();
-    let operation_id = *setup_saga.operation.id();
+    let setup_saga = saga
+        .setup_melt(&melt_request, verification, PaymentMethod::Bolt11)
+        .await
+        .unwrap();
+    let operation_id = *setup_saga.state_data.operation.id();
 
     // Verify saga exists
     assert_saga_exists(&mint, &operation_id).await;
@@ -224,7 +230,7 @@ async fn test_crash_recovery_setup_complete() {
         mint.pubsub_manager(),
     );
     let setup_saga = saga
-        .setup_melt(&melt_request, verification)
+        .setup_melt(&melt_request, verification, PaymentMethod::Bolt11)
         .await
         .expect("Setup should succeed");
 
@@ -232,7 +238,7 @@ async fn test_crash_recovery_setup_complete() {
     assert_proofs_state(&mint, &input_ys, Some(State::Pending)).await;
 
     // STEP 7: Verify saga was persisted
-    let operation_id = *setup_saga.operation.id();
+    let operation_id = *setup_saga.state_data.operation.id();
     assert_saga_exists(&mint, &operation_id).await;
 
     // STEP 8: Simulate crash - drop saga without finalizing
@@ -294,9 +300,12 @@ async fn test_crash_recovery_multiple_sagas() {
             mint.localstore(),
             mint.pubsub_manager(),
         );
-        let setup_saga = saga.setup_melt(&melt_request, verification).await.unwrap();
+        let setup_saga = saga
+            .setup_melt(&melt_request, verification, PaymentMethod::Bolt11)
+            .await
+            .unwrap();
 
-        operation_ids.push(*setup_saga.operation.id());
+        operation_ids.push(*setup_saga.state_data.operation.id());
         proof_ys_list.push(input_ys);
         quote_ids.push(quote.id.clone());
 
@@ -397,9 +406,12 @@ async fn test_crash_recovery_orphaned_saga() {
         mint.localstore(),
         mint.pubsub_manager(),
     );
-    let setup_saga = saga.setup_melt(&melt_request, verification).await.unwrap();
+    let setup_saga = saga
+        .setup_melt(&melt_request, verification, PaymentMethod::Bolt11)
+        .await
+        .unwrap();
 
-    let operation_id = *setup_saga.operation.id();
+    let operation_id = *setup_saga.state_data.operation.id();
     let input_ys = proofs.ys().unwrap();
 
     // Drop saga (simulate crash)
@@ -518,8 +530,11 @@ async fn test_crash_recovery_internal_settlement() {
         mint.pubsub_manager(),
     );
 
-    let setup_saga = saga.setup_melt(&melt_request, verification).await.unwrap();
-    let operation_id = *setup_saga.operation.id();
+    let setup_saga = saga
+        .setup_melt(&melt_request, verification, PaymentMethod::Bolt11)
+        .await
+        .unwrap();
+    let operation_id = *setup_saga.state_data.operation.id();
 
     // STEP 5: Attempt internal settlement - this will commit and update saga state
     let (payment_saga, decision) = setup_saga
@@ -636,9 +651,12 @@ async fn test_startup_recovery_integration() {
         mint.localstore(),
         mint.pubsub_manager(),
     );
-    let setup_saga = saga.setup_melt(&melt_request, verification).await.unwrap();
+    let setup_saga = saga
+        .setup_melt(&melt_request, verification, PaymentMethod::Bolt11)
+        .await
+        .unwrap();
 
-    let operation_id = *setup_saga.operation.id();
+    let operation_id = *setup_saga.state_data.operation.id();
     let input_ys = proofs.ys().unwrap();
 
     // Drop saga (simulate crash)
@@ -673,7 +691,7 @@ async fn test_startup_recovery_integration() {
         mint.pubsub_manager(),
     );
     let _new_setup = new_saga
-        .setup_melt(&new_request, new_verification)
+        .setup_melt(&new_request, new_verification, PaymentMethod::Bolt11)
         .await
         .unwrap();
 
@@ -714,9 +732,12 @@ async fn test_compensation_removes_proofs() {
         mint.localstore(),
         mint.pubsub_manager(),
     );
-    let setup_saga = saga.setup_melt(&melt_request, verification).await.unwrap();
+    let setup_saga = saga
+        .setup_melt(&melt_request, verification, PaymentMethod::Bolt11)
+        .await
+        .unwrap();
 
-    let operation_id = *setup_saga.operation.id();
+    let operation_id = *setup_saga.state_data.operation.id();
 
     // Verify proofs are PENDING
     assert_proofs_state(&mint, &input_ys, Some(State::Pending)).await;
@@ -746,12 +767,12 @@ async fn test_compensation_removes_proofs() {
         mint.pubsub_manager(),
     );
     let new_setup = new_saga
-        .setup_melt(&new_request, new_verification)
+        .setup_melt(&new_request, new_verification, PaymentMethod::Bolt11)
         .await
         .expect("Should be able to reuse proofs after compensation");
 
     // Verify new saga was created successfully
-    assert_saga_exists(&mint, new_setup.operation.id()).await;
+    assert_saga_exists(&mint, new_setup.state_data.operation.id()).await;
 
     // SUCCESS: Compensation properly removed proofs and they can be reused!
 }
@@ -794,9 +815,12 @@ async fn test_compensation_removes_change_outputs() {
         mint.localstore(),
         mint.pubsub_manager(),
     );
-    let setup_saga = saga.setup_melt(&melt_request, verification).await.unwrap();
+    let setup_saga = saga
+        .setup_melt(&melt_request, verification, PaymentMethod::Bolt11)
+        .await
+        .unwrap();
 
-    let operation_id = *setup_saga.operation.id();
+    let operation_id = *setup_saga.state_data.operation.id();
 
     // STEP 5: Verify blinded messages are stored in database
     let stored_info = {
@@ -872,9 +896,12 @@ async fn test_compensation_resets_quote_state() {
         mint.localstore(),
         mint.pubsub_manager(),
     );
-    let setup_saga = saga.setup_melt(&melt_request, verification).await.unwrap();
+    let setup_saga = saga
+        .setup_melt(&melt_request, verification, PaymentMethod::Bolt11)
+        .await
+        .unwrap();
 
-    let operation_id = *setup_saga.operation.id();
+    let operation_id = *setup_saga.state_data.operation.id();
 
     // STEP 3: Verify quote state became PENDING
     let pending_quote = mint
@@ -925,7 +952,7 @@ async fn test_compensation_resets_quote_state() {
         mint.pubsub_manager(),
     );
     let _new_setup = new_saga
-        .setup_melt(&new_request, new_verification)
+        .setup_melt(&new_request, new_verification, PaymentMethod::Bolt11)
         .await
         .expect("Should be able to reuse quote after compensation");
 
@@ -953,9 +980,12 @@ async fn test_compensation_idempotent() {
         mint.localstore(),
         mint.pubsub_manager(),
     );
-    let setup_saga = saga.setup_melt(&melt_request, verification).await.unwrap();
+    let setup_saga = saga
+        .setup_melt(&melt_request, verification, PaymentMethod::Bolt11)
+        .await
+        .unwrap();
 
-    let operation_id = *setup_saga.operation.id();
+    let operation_id = *setup_saga.state_data.operation.id();
 
     // Verify initial state
     assert_proofs_state(&mint, &input_ys, Some(State::Pending)).await;
@@ -1068,8 +1098,11 @@ async fn test_saga_deleted_after_payment_failure() {
         mint.localstore(),
         mint.pubsub_manager(),
     );
-    let setup_saga = saga.setup_melt(&melt_request, verification).await.unwrap();
-    let operation_id = *setup_saga.operation.id();
+    let setup_saga = saga
+        .setup_melt(&melt_request, verification, PaymentMethod::Bolt11)
+        .await
+        .unwrap();
+    let operation_id = setup_saga.operation_id;
 
     // Verify saga exists after setup
     assert_saga_exists(&mint, &operation_id).await;
@@ -1145,9 +1178,12 @@ async fn test_saga_content_validation() {
         mint.localstore(),
         mint.pubsub_manager(),
     );
-    let setup_saga = saga.setup_melt(&melt_request, verification).await.unwrap();
+    let setup_saga = saga
+        .setup_melt(&melt_request, verification, PaymentMethod::Bolt11)
+        .await
+        .unwrap();
 
-    let operation_id = *setup_saga.operation.id();
+    let operation_id = *setup_saga.state_data.operation.id();
 
     // STEP 3: Retrieve saga from database
     let persisted_saga = assert_saga_exists(&mint, &operation_id).await;
@@ -1255,9 +1291,12 @@ async fn test_saga_state_updates_timestamp() {
         mint.localstore(),
         mint.pubsub_manager(),
     );
-    let setup_saga = saga.setup_melt(&melt_request, verification).await.unwrap();
+    let setup_saga = saga
+        .setup_melt(&melt_request, verification, PaymentMethod::Bolt11)
+        .await
+        .unwrap();
 
-    let operation_id = *setup_saga.operation.id();
+    let operation_id = *setup_saga.state_data.operation.id();
 
     // STEP 3: Retrieve saga and note timestamps
     let saga1 = assert_saga_exists(&mint, &operation_id).await;
@@ -1319,11 +1358,11 @@ async fn test_get_incomplete_sagas_filters_by_kind() {
         mint.pubsub_manager(),
     );
     let melt_setup = melt_saga
-        .setup_melt(&melt_request, melt_verification)
+        .setup_melt(&melt_request, melt_verification, PaymentMethod::Bolt11)
         .await
         .unwrap();
 
-    let melt_operation_id = *melt_setup.operation.id();
+    let melt_operation_id = *melt_setup.state_data.operation.id();
 
     // STEP 3: Create a swap saga
     let swap_proofs = mint_test_proofs(&mint, Amount::from(5_000)).await.unwrap();
@@ -1444,8 +1483,11 @@ async fn test_concurrent_melt_operations() {
                 mint_clone.localstore(),
                 mint_clone.pubsub_manager(),
             );
-            let setup_saga = saga.setup_melt(&melt_request, verification).await.unwrap();
-            let operation_id = *setup_saga.operation.id();
+            let setup_saga = saga
+                .setup_melt(&melt_request, verification, PaymentMethod::Bolt11)
+                .await
+                .unwrap();
+            let operation_id = *setup_saga.state_data.operation.id();
             // Drop setup_saga before returning to avoid lifetime issues
             drop(setup_saga);
             operation_id
@@ -1504,10 +1546,10 @@ async fn test_concurrent_recovery_and_operations() {
         mint.pubsub_manager(),
     );
     let setup_saga1 = saga1
-        .setup_melt(&melt_request1, verification1)
+        .setup_melt(&melt_request1, verification1, PaymentMethod::Bolt11)
         .await
         .unwrap();
-    let incomplete_operation_id = *setup_saga1.operation.id();
+    let incomplete_operation_id = *setup_saga1.state_data.operation.id();
 
     // Drop saga to simulate crash
     drop(setup_saga1);
@@ -1542,10 +1584,10 @@ async fn test_concurrent_recovery_and_operations() {
             mint_for_new_op.pubsub_manager(),
         );
         let setup_saga2 = saga2
-            .setup_melt(&melt_request2, verification2)
+            .setup_melt(&melt_request2, verification2, PaymentMethod::Bolt11)
             .await
             .unwrap();
-        *setup_saga2.operation.id()
+        *setup_saga2.state_data.operation.id()
     });
 
     // STEP 4: Wait for both tasks to complete
@@ -1585,7 +1627,7 @@ async fn test_double_spend_detection() {
         mint.pubsub_manager(),
     );
     let _setup_saga1 = saga1
-        .setup_melt(&melt_request1, verification1)
+        .setup_melt(&melt_request1, verification1, PaymentMethod::Bolt11)
         .await
         .unwrap();
 
@@ -1605,7 +1647,9 @@ async fn test_double_spend_detection() {
         mint.localstore(),
         mint.pubsub_manager(),
     );
-    let setup_result2 = saga2.setup_melt(&melt_request2, verification2).await;
+    let setup_result2 = saga2
+        .setup_melt(&melt_request2, verification2, PaymentMethod::Bolt11)
+        .await;
 
     // STEP 5: Verify second setup fails with appropriate error
     assert!(
@@ -1655,7 +1699,9 @@ async fn test_insufficient_funds() {
         mint.localstore(),
         mint.pubsub_manager(),
     );
-    let setup_result = saga.setup_melt(&melt_request, verification).await;
+    let setup_result = saga
+        .setup_melt(&melt_request, verification, PaymentMethod::Bolt11)
+        .await;
 
     // With 10000 msats input and 9000 msats quote, this should succeed
     assert!(
@@ -1698,7 +1744,9 @@ async fn test_invalid_quote_id() {
             mint.localstore(),
             mint.pubsub_manager(),
         );
-        let setup_result = saga.setup_melt(&melt_request, verification).await;
+        let setup_result = saga
+            .setup_melt(&melt_request, verification, PaymentMethod::Bolt11)
+            .await;
 
         // STEP 4: Verify setup fails with unknown quote error
         assert!(
@@ -1747,7 +1795,7 @@ async fn test_quote_already_paid() {
         mint.pubsub_manager(),
     );
     let setup_saga1 = saga1
-        .setup_melt(&melt_request1, verification1)
+        .setup_melt(&melt_request1, verification1, PaymentMethod::Bolt11)
         .await
         .unwrap();
 
@@ -1782,7 +1830,9 @@ async fn test_quote_already_paid() {
         mint.localstore(),
         mint.pubsub_manager(),
     );
-    let setup_result2 = saga2.setup_melt(&melt_request2, verification2).await;
+    let setup_result2 = saga2
+        .setup_melt(&melt_request2, verification2, PaymentMethod::Bolt11)
+        .await;
 
     // STEP 4: Verify setup fails
     assert!(
@@ -1822,7 +1872,7 @@ async fn test_quote_already_pending() {
         mint.pubsub_manager(),
     );
     let _setup_saga1 = saga1
-        .setup_melt(&melt_request1, verification1)
+        .setup_melt(&melt_request1, verification1, PaymentMethod::Bolt11)
         .await
         .unwrap();
 
@@ -1849,7 +1899,9 @@ async fn test_quote_already_pending() {
         mint.localstore(),
         mint.pubsub_manager(),
     );
-    let setup_result2 = saga2.setup_melt(&melt_request2, verification2).await;
+    let setup_result2 = saga2
+        .setup_melt(&melt_request2, verification2, PaymentMethod::Bolt11)
+        .await;
 
     // STEP 4: Verify second setup fails
     assert!(
@@ -1955,9 +2007,12 @@ async fn test_recovery_no_melt_request() {
         mint.localstore(),
         mint.pubsub_manager(),
     );
-    let setup_saga = saga.setup_melt(&melt_request, verification).await.unwrap();
+    let setup_saga = saga
+        .setup_melt(&melt_request, verification, PaymentMethod::Bolt11)
+        .await
+        .unwrap();
 
-    let operation_id = *setup_saga.operation.id();
+    let operation_id = *setup_saga.state_data.operation.id();
     let input_ys = proofs.ys().unwrap();
 
     // Drop saga (simulate crash)
@@ -2004,9 +2059,12 @@ async fn test_recovery_order_on_startup() {
         mint.localstore(),
         mint.pubsub_manager(),
     );
-    let setup_saga = saga.setup_melt(&melt_request, verification).await.unwrap();
+    let setup_saga = saga
+        .setup_melt(&melt_request, verification, PaymentMethod::Bolt11)
+        .await
+        .unwrap();
 
-    let operation_id = *setup_saga.operation.id();
+    let operation_id = *setup_saga.state_data.operation.id();
     let input_ys = proofs.ys().unwrap();
 
     // Drop saga (simulate crash) - this leaves quote in PENDING state
@@ -2065,7 +2123,7 @@ async fn test_recovery_order_on_startup() {
         mint.pubsub_manager(),
     );
     let _new_setup = new_saga
-        .setup_melt(&new_request, new_verification)
+        .setup_melt(&new_request, new_verification, PaymentMethod::Bolt11)
         .await
         .unwrap();
 
@@ -2092,9 +2150,12 @@ async fn test_no_duplicate_recovery() {
         mint.localstore(),
         mint.pubsub_manager(),
     );
-    let setup_saga = saga.setup_melt(&melt_request, verification).await.unwrap();
+    let setup_saga = saga
+        .setup_melt(&melt_request, verification, PaymentMethod::Bolt11)
+        .await
+        .unwrap();
 
-    let operation_id = *setup_saga.operation.id();
+    let operation_id = *setup_saga.state_data.operation.id();
     let input_ys = proofs.ys().unwrap();
 
     // Drop saga (simulate crash)
@@ -2167,9 +2228,12 @@ async fn test_operation_id_uniqueness_and_tracking() {
             mint.localstore(),
             mint.pubsub_manager(),
         );
-        let setup_saga = saga.setup_melt(&melt_request, verification).await.unwrap();
+        let setup_saga = saga
+            .setup_melt(&melt_request, verification, PaymentMethod::Bolt11)
+            .await
+            .unwrap();
 
-        let operation_id = *setup_saga.operation.id();
+        let operation_id = *setup_saga.state_data.operation.id();
         operation_ids.push(operation_id);
 
         // Keep saga alive
@@ -2219,8 +2283,11 @@ async fn test_saga_drop_without_finalize() {
         mint.localstore(),
         mint.pubsub_manager(),
     );
-    let setup_saga = saga.setup_melt(&melt_request, verification).await.unwrap();
-    let operation_id = *setup_saga.operation.id();
+    let setup_saga = saga
+        .setup_melt(&melt_request, verification, PaymentMethod::Bolt11)
+        .await
+        .unwrap();
+    let operation_id = *setup_saga.state_data.operation.id();
 
     // STEP 3: Drop saga without finalizing (simulates crash)
     drop(setup_saga);
@@ -2254,8 +2321,11 @@ async fn test_saga_drop_after_payment() {
         mint.localstore(),
         mint.pubsub_manager(),
     );
-    let setup_saga = saga.setup_melt(&melt_request, verification).await.unwrap();
-    let operation_id = *setup_saga.operation.id();
+    let setup_saga = saga
+        .setup_melt(&melt_request, verification, PaymentMethod::Bolt11)
+        .await
+        .unwrap();
+    let operation_id = *setup_saga.state_data.operation.id();
 
     // Verify proofs are PENDING after setup
     assert_proofs_state(&mint, &input_ys, Some(State::Pending)).await;
@@ -2335,8 +2405,11 @@ async fn test_payment_attempted_state_triggers_ln_check() {
         mint.localstore(),
         mint.pubsub_manager(),
     );
-    let setup_saga = saga.setup_melt(&melt_request, verification).await.unwrap();
-    let operation_id = *setup_saga.operation.id();
+    let setup_saga = saga
+        .setup_melt(&melt_request, verification, PaymentMethod::Bolt11)
+        .await
+        .unwrap();
+    let operation_id = *setup_saga.state_data.operation.id();
 
     // Check initial state is SetupComplete
     let saga_before_payment = assert_saga_exists(&mint, &operation_id).await;
@@ -2422,8 +2495,11 @@ async fn test_setup_complete_state_compensates() {
         mint.localstore(),
         mint.pubsub_manager(),
     );
-    let setup_saga = saga.setup_melt(&melt_request, verification).await.unwrap();
-    let operation_id = *setup_saga.operation.id();
+    let setup_saga = saga
+        .setup_melt(&melt_request, verification, PaymentMethod::Bolt11)
+        .await
+        .unwrap();
+    let operation_id = *setup_saga.state_data.operation.id();
 
     // Verify state is SetupComplete
     let saga_in_db = assert_saga_exists(&mint, &operation_id).await;
@@ -2683,7 +2759,7 @@ async fn test_duplicate_lookup_id_prevents_second_pending() {
         mint.pubsub_manager(),
     );
     let setup_saga1 = saga1
-        .setup_melt(&melt_request1, verification1)
+        .setup_melt(&melt_request1, verification1, PaymentMethod::Bolt11)
         .await
         .unwrap();
 
@@ -2724,7 +2800,9 @@ async fn test_duplicate_lookup_id_prevents_second_pending() {
         mint.localstore(),
         mint.pubsub_manager(),
     );
-    let setup_result2 = saga2.setup_melt(&melt_request2, verification2).await;
+    let setup_result2 = saga2
+        .setup_melt(&melt_request2, verification2, PaymentMethod::Bolt11)
+        .await;
 
     // STEP 5: Verify second setup fails due to duplicate pending lookup_id
     assert!(
@@ -2832,7 +2910,7 @@ async fn test_paid_lookup_id_prevents_pending() {
         mint.pubsub_manager(),
     );
     let setup_saga1 = saga1
-        .setup_melt(&melt_request1, verification1)
+        .setup_melt(&melt_request1, verification1, PaymentMethod::Bolt11)
         .await
         .unwrap();
 
@@ -2867,7 +2945,9 @@ async fn test_paid_lookup_id_prevents_pending() {
         mint.localstore(),
         mint.pubsub_manager(),
     );
-    let setup_result2 = saga2.setup_melt(&melt_request2, verification2).await;
+    let setup_result2 = saga2
+        .setup_melt(&melt_request2, verification2, PaymentMethod::Bolt11)
+        .await;
 
     // STEP 5: Verify second setup fails due to already paid lookup_id
     assert!(
@@ -2919,7 +2999,7 @@ async fn test_different_lookup_ids_allow_concurrent_pending() {
         mint.pubsub_manager(),
     );
     let _setup_saga1 = saga1
-        .setup_melt(&melt_request1, verification1)
+        .setup_melt(&melt_request1, verification1, PaymentMethod::Bolt11)
         .await
         .unwrap();
 
@@ -2934,7 +3014,7 @@ async fn test_different_lookup_ids_allow_concurrent_pending() {
         mint.pubsub_manager(),
     );
     let _setup_saga2 = saga2
-        .setup_melt(&melt_request2, verification2)
+        .setup_melt(&melt_request2, verification2, PaymentMethod::Bolt11)
         .await
         .unwrap();
 

+ 21 - 10
crates/cdk/src/mint/melt/mod.rs

@@ -433,6 +433,13 @@ impl Mint {
 
         let verification = self.verify_inputs(melt_request.inputs()).await?;
 
+        // Fetch the quote to get payment_method for operation tracking
+        let quote = self
+            .localstore
+            .get_melt_quote(melt_request.quote())
+            .await?
+            .ok_or(Error::UnknownQuote)?;
+
         let init_saga = MeltSaga::new(
             std::sync::Arc::new(self.clone()),
             self.localstore.clone(),
@@ -440,7 +447,9 @@ impl Mint {
         );
 
         // Step 1: Setup (TX1 - reserves inputs and outputs)
-        let setup_saga = init_saga.setup_melt(melt_request, verification).await?;
+        let setup_saga = init_saga
+            .setup_melt(melt_request, verification, quote.payment_method)
+            .await?;
 
         // Step 2: Attempt internal settlement (returns saga + SettlementDecision)
         // Note: Compensation is handled internally if this fails
@@ -464,15 +473,7 @@ impl Mint {
     ) -> Result<MeltQuoteBolt11Response<QuoteId>, Error> {
         let verification = self.verify_inputs(melt_request.inputs()).await?;
 
-        let init_saga = MeltSaga::new(
-            std::sync::Arc::new(self.clone()),
-            self.localstore.clone(),
-            std::sync::Arc::clone(&self.pubsub_manager),
-        );
-
-        let setup_saga = init_saga.setup_melt(melt_request, verification).await?;
-
-        // Get the quote to return with PENDING state
+        // Get the quote first for payment_method and to return with PENDING state
         let quote_id = melt_request.quote().clone();
         let quote = self
             .localstore
@@ -480,6 +481,16 @@ impl Mint {
             .await?
             .ok_or(Error::UnknownQuote)?;
 
+        let init_saga = MeltSaga::new(
+            std::sync::Arc::new(self.clone()),
+            self.localstore.clone(),
+            std::sync::Arc::clone(&self.pubsub_manager),
+        );
+
+        let setup_saga = init_saga
+            .setup_melt(melt_request, verification, quote.payment_method.clone())
+            .await?;
+
         // Spawn background task to complete the melt operation
         let melt_request_clone = melt_request.clone();
         let quote_id_clone = quote_id.clone();

+ 6 - 3
crates/cdk/src/mint/mod.rs

@@ -779,7 +779,10 @@ impl Mint {
 
     /// Fee required for proof set
     #[instrument(skip_all)]
-    pub async fn get_proofs_fee(&self, proofs: &Proofs) -> Result<Amount, Error> {
+    pub async fn get_proofs_fee(
+        &self,
+        proofs: &Proofs,
+    ) -> Result<crate::fees::ProofsFeeBreakdown, Error> {
         let mut proofs_per_keyset = HashMap::new();
         let mut fee_per_keyset = HashMap::new();
 
@@ -799,9 +802,9 @@ impl Mint {
                 .or_insert(1);
         }
 
-        let fee = calculate_fee(&proofs_per_keyset, &fee_per_keyset)?;
+        let fee_breakdown = calculate_fee(&proofs_per_keyset, &fee_per_keyset)?;
 
-        Ok(fee)
+        Ok(fee_breakdown)
     }
 
     /// Get active keysets

+ 41 - 12
crates/cdk/src/mint/swap/swap_saga/mod.rs

@@ -4,7 +4,7 @@ use std::sync::Arc;
 use cdk_common::database::DynMintDatabase;
 use cdk_common::mint::{Operation, Saga, SwapSagaState};
 use cdk_common::nuts::BlindedMessage;
-use cdk_common::{database, Error, Proofs, ProofsMethods, PublicKey, QuoteId, State};
+use cdk_common::{database, Amount, Error, Proofs, ProofsMethods, PublicKey, QuoteId, State};
 use tokio::sync::Mutex;
 use tracing::instrument;
 
@@ -96,19 +96,22 @@ pub struct SwapSaga<'a, S> {
     pubsub: Arc<PubSubManager>,
     /// Compensating actions in LIFO order (most recent first)
     compensations: Arc<Mutex<VecDeque<Box<dyn CompensatingAction>>>>,
-    operation: Operation,
+    /// Operation ID (used for saga tracking, generated upfront)
+    operation_id: uuid::Uuid,
     state_data: S,
 }
 
 impl<'a> SwapSaga<'a, Initial> {
     pub fn new(mint: &'a super::Mint, db: DynMintDatabase, pubsub: Arc<PubSubManager>) -> Self {
+        let operation_id = uuid::Uuid::new_v4();
+
         Self {
             mint,
             db,
             pubsub,
             compensations: Arc::new(Mutex::new(VecDeque::new())),
-            operation: Operation::new_swap(),
-            state_data: Initial,
+            operation_id,
+            state_data: Initial { operation_id },
         }
     }
 
@@ -152,15 +155,31 @@ impl<'a> SwapSaga<'a, Initial> {
         self.mint
             .verify_transaction_balanced(
                 &mut tx,
-                input_verification,
+                input_verification.clone(),
                 input_proofs,
                 blinded_messages,
             )
             .await?;
 
+        // Calculate amounts to create Operation
+        let total_redeemed = input_verification.amount;
+        let total_issued = Amount::try_sum(blinded_messages.iter().map(|bm| bm.amount))?;
+        let fee_breakdown = self.mint.get_proofs_fee(input_proofs).await?;
+
+        // Create Operation with actual amounts now that we know them
+        let operation = Operation::new(
+            self.state_data.operation_id,
+            cdk_common::mint::OperationKind::Swap,
+            total_issued,
+            total_redeemed,
+            fee_breakdown.total,
+            None, // complete_at
+            None, // payment_method (not applicable for swap)
+        );
+
         // Add input proofs to DB
         if let Err(err) = tx
-            .add_proofs(input_proofs.clone(), quote_id.clone(), &self.operation)
+            .add_proofs(input_proofs.clone(), quote_id.clone(), &operation)
             .await
         {
             tx.rollback().await?;
@@ -211,7 +230,7 @@ impl<'a> SwapSaga<'a, Initial> {
 
         // Add output blinded messages
         if let Err(err) = tx
-            .add_blinded_messages(quote_id.as_ref(), blinded_messages, &self.operation)
+            .add_blinded_messages(quote_id.as_ref(), blinded_messages, &operation)
             .await
         {
             tx.rollback().await?;
@@ -235,7 +254,7 @@ impl<'a> SwapSaga<'a, Initial> {
 
         // Persist saga state for crash recovery (atomic with TX1)
         let saga = Saga::new_swap(
-            *self.operation.id(),
+            self.operation_id,
             SwapSagaState::SetupComplete,
             blinded_secrets.clone(),
             ys.clone(),
@@ -256,7 +275,7 @@ impl<'a> SwapSaga<'a, Initial> {
             .push_front(Box::new(RemoveSwapSetup {
                 blinded_secrets: blinded_secrets.clone(),
                 input_ys: ys.clone(),
-                operation_id: *self.operation.id(),
+                operation_id: self.operation_id,
             }));
 
         // Transition to SetupComplete state
@@ -265,10 +284,12 @@ impl<'a> SwapSaga<'a, Initial> {
             db: self.db,
             pubsub: self.pubsub,
             compensations: self.compensations,
-            operation: self.operation,
+            operation_id: self.operation_id,
             state_data: SetupComplete {
                 blinded_messages: blinded_messages_vec,
                 ys,
+                operation,
+                fee_breakdown,
             },
         })
     }
@@ -313,11 +334,13 @@ impl<'a> SwapSaga<'a, SetupComplete> {
                     db: self.db,
                     pubsub: self.pubsub,
                     compensations: self.compensations,
-                    operation: self.operation,
+                    operation_id: self.operation_id,
                     state_data: Signed {
                         blinded_messages: self.state_data.blinded_messages,
                         ys: self.state_data.ys,
                         signatures,
+                        operation: self.state_data.operation,
+                        fee_breakdown: self.state_data.fee_breakdown,
                     },
                 })
             }
@@ -433,10 +456,16 @@ impl SwapSaga<'_, Signed> {
             self.pubsub.proof_state((*pk, State::Spent));
         }
 
+        tx.add_completed_operation(
+            &self.state_data.operation,
+            &self.state_data.fee_breakdown.per_keyset,
+        )
+        .await?;
+
         // Delete saga - swap completed successfully (best-effort, atomic with TX2)
         // Don't fail the swap if saga deletion fails - orphaned saga will be
         // cleaned up on next recovery
-        if let Err(e) = tx.delete_saga(self.operation.id()).await {
+        if let Err(e) = tx.delete_saga(&self.operation_id).await {
             tracing::warn!(
                 "Failed to delete saga in finalize (will be cleaned up on recovery): {}",
                 e

+ 12 - 3
crates/cdk/src/mint/swap/swap_saga/state.rs

@@ -1,18 +1,25 @@
+use cdk_common::mint::Operation;
 use cdk_common::nuts::{BlindSignature, BlindedMessage};
 use cdk_common::PublicKey;
+use uuid::Uuid;
 
-/// Initial state - no data yet.
+/// Initial state - only has operation ID.
 ///
 /// The swap saga starts in this state. Only the `setup_swap` method is available.
-pub struct Initial;
+/// The operation ID is generated upfront but the full Operation (with amounts) is created during setup.
+pub struct Initial {
+    pub operation_id: Uuid,
+}
 
-/// Setup complete - has blinded messages and input Y values.
+/// Setup complete - has blinded messages, input Y values, and the Operation with actual amounts.
 ///
 /// After successful setup, the saga transitions to this state.
 /// Only the `sign_outputs` method is available.
 pub struct SetupComplete {
     pub blinded_messages: Vec<BlindedMessage>,
     pub ys: Vec<PublicKey>,
+    pub operation: Operation,
+    pub fee_breakdown: crate::fees::ProofsFeeBreakdown,
 }
 
 /// Signed state - has everything including signatures.
@@ -23,4 +30,6 @@ pub struct Signed {
     pub blinded_messages: Vec<BlindedMessage>,
     pub ys: Vec<PublicKey>,
     pub signatures: Vec<BlindSignature>,
+    pub operation: Operation,
+    pub fee_breakdown: crate::fees::ProofsFeeBreakdown,
 }

+ 18 - 18
crates/cdk/src/mint/swap/swap_saga/tests.rs

@@ -1185,7 +1185,7 @@ async fn test_saga_state_persistence_after_setup() {
         .await
         .expect("Setup should succeed");
 
-    let operation_id = saga.operation.id();
+    let operation_id = saga.state_data.operation.id();
 
     // Verify saga exists in database
     let saga = {
@@ -1255,7 +1255,7 @@ async fn test_saga_deletion_on_success() {
         .await
         .expect("Setup should succeed");
 
-    let operation_id = *saga.operation.id();
+    let operation_id = *saga.state_data.operation.id();
 
     // Verify saga exists after setup
     let saga_after_setup = {
@@ -1357,7 +1357,7 @@ async fn test_get_incomplete_sagas_basic() {
         )
         .await
         .expect("Setup should succeed");
-    let op_id_1 = *saga_1.operation.id();
+    let op_id_1 = *saga_1.state_data.operation.id();
 
     // Should have 1 incomplete saga
     let incomplete_after_1 = db
@@ -1378,7 +1378,7 @@ async fn test_get_incomplete_sagas_basic() {
         )
         .await
         .expect("Setup should succeed");
-    let op_id_2 = *saga_2.operation.id();
+    let op_id_2 = *saga_2.state_data.operation.id();
 
     // Should have 2 incomplete sagas
     let incomplete_after_2 = db
@@ -1447,7 +1447,7 @@ async fn test_saga_content_validation() {
         .await
         .expect("Setup should succeed");
 
-    let operation_id = *saga.operation.id();
+    let operation_id = *saga.state_data.operation.id();
 
     // Query saga
     let saga = {
@@ -1530,7 +1530,7 @@ async fn test_saga_state_updates_persisted() {
         .await
         .expect("Setup should succeed");
 
-    let operation_id = *saga.operation.id();
+    let operation_id = *saga.state_data.operation.id();
 
     // Query saga
     let state_after_setup = {
@@ -2093,7 +2093,7 @@ async fn test_crash_recovery_without_compensation() {
             .await
             .expect("Setup should succeed");
 
-        operation_id = *saga.operation.id();
+        operation_id = *saga.state_data.operation.id();
 
         // CRITICAL: Drop saga WITHOUT calling compensate_all()
         // This simulates a crash where in-memory compensations are lost
@@ -2186,7 +2186,7 @@ async fn test_crash_recovery_after_setup_only() {
             .await
             .expect("Setup should succeed");
 
-        operation_id = *saga.operation.id();
+        operation_id = *saga.state_data.operation.id();
 
         // Verify saga was persisted
         let saga = {
@@ -2273,7 +2273,7 @@ async fn test_crash_recovery_after_signing() {
             .await
             .expect("Setup should succeed");
 
-        operation_id = *saga.operation.id();
+        operation_id = *saga.state_data.operation.id();
 
         let saga = saga.sign_outputs().await.expect("Signing should succeed");
 
@@ -2373,7 +2373,7 @@ async fn test_recovery_multiple_incomplete_sagas() {
             .setup_swap(&proofs_a, &outputs_a, None, verification_a)
             .await
             .expect("Setup A should succeed");
-        op_id_a = *saga.operation.id();
+        op_id_a = *saga.state_data.operation.id();
         drop(saga);
     }
 
@@ -2385,7 +2385,7 @@ async fn test_recovery_multiple_incomplete_sagas() {
             .setup_swap(&proofs_b, &outputs_b, None, verification_b)
             .await
             .expect("Setup B should succeed");
-        op_id_b = *saga.operation.id();
+        op_id_b = *saga.state_data.operation.id();
         let saga = saga.sign_outputs().await.expect("Sign B should succeed");
         drop(saga);
     }
@@ -2398,7 +2398,7 @@ async fn test_recovery_multiple_incomplete_sagas() {
             .setup_swap(&proofs_c, &outputs_c, None, verification_c)
             .await
             .expect("Setup C should succeed");
-        op_id_c = *saga.operation.id();
+        op_id_c = *saga.state_data.operation.id();
         let saga = saga.sign_outputs().await.expect("Sign C should succeed");
         let _response = saga.finalize().await.expect("Finalize C should succeed");
     }
@@ -2500,7 +2500,7 @@ async fn test_recovery_idempotence() {
             .setup_swap(&input_proofs, &output_blinded_messages, None, verification)
             .await
             .expect("Setup should succeed");
-        operation_id = *saga.operation.id();
+        operation_id = *saga.state_data.operation.id();
         drop(saga);
     }
 
@@ -2596,7 +2596,7 @@ async fn test_orphaned_saga_cleanup() {
         .await
         .expect("Setup should succeed");
 
-    let operation_id = *saga.operation.id();
+    let operation_id = *saga.state_data.operation.id();
     let ys = input_proofs.ys().unwrap();
 
     let saga = saga.sign_outputs().await.expect("Signing should succeed");
@@ -2675,7 +2675,7 @@ async fn test_recovery_with_orphaned_proofs() {
             .await
             .expect("Setup should succeed");
 
-        let op_id = *saga.operation.id();
+        let op_id = *saga.state_data.operation.id();
 
         // Drop saga (crash simulation)
         drop(saga);
@@ -2783,7 +2783,7 @@ async fn test_recovery_with_partial_state() {
             .await
             .expect("Setup should succeed");
 
-        let op_id = *saga.operation.id();
+        let op_id = *saga.state_data.operation.id();
 
         // Drop saga (crash simulation)
         drop(saga);
@@ -2875,7 +2875,7 @@ async fn test_recovery_with_missing_blinded_messages() {
             .await
             .expect("Setup should succeed");
 
-        let op_id = *saga.operation.id();
+        let op_id = *saga.state_data.operation.id();
         drop(saga); // Crash
 
         op_id
@@ -2952,7 +2952,7 @@ async fn test_saga_deletion_failure_handling() {
         .await
         .expect("Setup should succeed");
 
-    let operation_id = *saga.operation.id();
+    let operation_id = *saga.state_data.operation.id();
     let ys = input_proofs.ys().unwrap();
 
     let saga = saga.sign_outputs().await.expect("Signing should succeed");

+ 6 - 6
crates/cdk/src/mint/verification.rs

@@ -227,15 +227,15 @@ impl Mint {
             err
         })?;
 
-        let fees = self.get_proofs_fee(inputs).await?;
+        let fee_breakdown = self.get_proofs_fee(inputs).await?;
 
         if output_verification
             .unit
             .as_ref()
             .ok_or(Error::TransactionUnbalanced(
-                input_verification.amount.to_u64(),
-                output_verification.amount.to_u64(),
-                fees.into(),
+                input_verification.amount.into(),
+                output_verification.amount.into(),
+                fee_breakdown.total.into(),
             ))?
             != input_verification
                 .unit
@@ -257,13 +257,13 @@ impl Mint {
         if output_verification.amount
             != input_verification
                 .amount
-                .checked_sub(fees)
+                .checked_sub(fee_breakdown.total)
                 .ok_or(Error::AmountOverflow)?
         {
             return Err(Error::TransactionUnbalanced(
                 input_verification.amount.into(),
                 output_verification.amount.into(),
-                fees.into(),
+                fee_breakdown.total.into(),
             ));
         }
 

+ 3 - 2
crates/cdk/src/wallet/melt/melt_bolt11.rs

@@ -175,7 +175,7 @@ impl Wallet {
 
         // Calculate change accounting for input fees
         // The mint deducts input fees from available funds before calculating change
-        let input_fee = self.get_proofs_fee(&proofs).await?;
+        let input_fee = self.get_proofs_fee(&proofs).await?.total;
         let change_amount = proofs_total - quote_info.amount - input_fee;
 
         let premint_secrets = if change_amount <= Amount::ZERO {
@@ -448,7 +448,8 @@ impl Wallet {
                     .into_iter()
                     .collect(),
             )
-            .await?;
+            .await?
+            .total;
 
         // Since we could not select the correct inputs amount needed for melting,
         // we select again this time including the amount we will now have to pay as a fee for the swap.

+ 7 - 4
crates/cdk/src/wallet/mod.rs

@@ -214,7 +214,10 @@ impl Wallet {
 
     /// Fee required for proof set
     #[instrument(skip_all)]
-    pub async fn get_proofs_fee(&self, proofs: &Proofs) -> Result<Amount, Error> {
+    pub async fn get_proofs_fee(
+        &self,
+        proofs: &Proofs,
+    ) -> Result<crate::fees::ProofsFeeBreakdown, Error> {
         let proofs_per_keyset = proofs.count_by_keyset();
         self.get_proofs_fee_by_count(proofs_per_keyset).await
     }
@@ -223,7 +226,7 @@ impl Wallet {
     pub async fn get_proofs_fee_by_count(
         &self,
         proofs_per_keyset: HashMap<Id, u64>,
-    ) -> Result<Amount, Error> {
+    ) -> Result<crate::fees::ProofsFeeBreakdown, Error> {
         let mut fee_per_keyset = HashMap::new();
         let metadata = self
             .metadata_cache
@@ -241,9 +244,9 @@ impl Wallet {
             fee_per_keyset.insert(*keyset_id, mint_keyset_info.input_fee_ppk);
         }
 
-        let fee = calculate_fee(&proofs_per_keyset, &fee_per_keyset)?;
+        let fee_breakdown = calculate_fee(&proofs_per_keyset, &fee_per_keyset)?;
 
-        Ok(fee)
+        Ok(fee_breakdown)
     }
 
     /// Get fee for count of proofs in a keyset

+ 65 - 22
crates/cdk/src/wallet/proofs.rs

@@ -469,6 +469,30 @@ impl Wallet {
         fees_and_keyset_amounts: &KeysetFeeAndAmounts,
     ) -> Result<Proofs, Error> {
         tracing::debug!("Including fees");
+        let fee_breakdown = calculate_fee(
+            &selected_proofs.count_by_keyset(),
+            &fees_and_keyset_amounts
+                .iter()
+                .map(|(key, values)| (*key, values.fee()))
+                .collect(),
+        )?;
+        let net_amount = selected_proofs.total_amount()? - fee_breakdown.total;
+        tracing::debug!(
+            "Net amount={}, fee={}, total amount={}",
+            net_amount,
+            fee_breakdown.total,
+            selected_proofs.total_amount()?
+        );
+        if net_amount >= amount {
+            tracing::debug!(
+                "Selected proofs: {:?}",
+                selected_proofs
+                    .iter()
+                    .map(|p| p.amount.into())
+                    .collect::<Vec<u64>>(),
+            );
+            return Ok(selected_proofs);
+        }
 
         let keyset_fees: HashMap<Id, u64> = fees_and_keyset_amounts
             .iter()
@@ -481,8 +505,7 @@ impl Wallet {
             .collect();
 
         loop {
-            let fee =
-                calculate_fee(&selected_proofs.count_by_keyset(), &keyset_fees).unwrap_or_default();
+            let fee = calculate_fee(&selected_proofs.count_by_keyset(), &keyset_fees)?.total;
             let total = selected_proofs.total_amount()?;
             let net_amount = total - fee;
 
@@ -809,7 +832,8 @@ mod tests {
                 .map(|(k, v)| (*k, v.fee()))
                 .collect(),
         )
-        .unwrap();
+        .unwrap()
+        .total;
         let net = total - fee;
 
         assert!(
@@ -860,7 +884,8 @@ mod tests {
                 .map(|(k, v)| (*k, v.fee()))
                 .collect(),
         )
-        .unwrap();
+        .unwrap()
+        .total;
         let net = total - fee;
 
         assert!(
@@ -1005,7 +1030,8 @@ mod tests {
                 .map(|(k, v)| (*k, v.fee()))
                 .collect(),
         )
-        .unwrap();
+        .unwrap()
+        .total;
         let net = total - fee;
 
         assert_eq!(selected_proofs.len(), 1);
@@ -1061,7 +1087,8 @@ mod tests {
                 .map(|(k, v)| (*k, v.fee()))
                 .collect(),
         )
-        .unwrap();
+        .unwrap()
+        .total;
         let net = total - fee;
 
         assert!(net >= amount, "5120 - 1 = 5119 >= 5000");
@@ -1103,7 +1130,8 @@ mod tests {
                 .map(|(k, v)| (*k, v.fee()))
                 .collect(),
         )
-        .unwrap();
+        .unwrap()
+        .total;
         let net = total - fee;
 
         assert!(
@@ -1151,7 +1179,8 @@ mod tests {
                 .map(|(k, v)| (*k, v.fee()))
                 .collect(),
         )
-        .unwrap();
+        .unwrap()
+        .total;
         let net = total - fee;
 
         assert!(
@@ -1191,7 +1220,8 @@ mod tests {
                 .map(|(k, v)| (*k, v.fee()))
                 .collect(),
         )
-        .unwrap();
+        .unwrap()
+        .total;
         let net = total - fee;
 
         assert!(
@@ -1234,7 +1264,8 @@ mod tests {
                 .map(|(k, v)| (*k, v.fee()))
                 .collect(),
         )
-        .unwrap();
+        .unwrap()
+        .total;
         let net = total - fee;
 
         assert!(
@@ -1347,7 +1378,8 @@ mod tests {
                 .map(|(k, v)| (*k, v.fee()))
                 .collect(),
         )
-        .unwrap();
+        .unwrap()
+        .total;
 
         let net = total - fee;
 
@@ -1394,7 +1426,8 @@ mod tests {
                 .map(|(k, v)| (*k, v.fee()))
                 .collect(),
         )
-        .unwrap();
+        .unwrap()
+        .total;
         let net = total - fee;
 
         assert!(net >= amount, "Net amount {} should be >= {}", net, amount);
@@ -1429,7 +1462,8 @@ mod tests {
                 .map(|(k, v)| (*k, v.fee()))
                 .collect(),
         )
-        .unwrap();
+        .unwrap()
+        .total;
         let net = total - fee;
 
         assert_eq!(selected_proofs.len(), 2);
@@ -1465,7 +1499,8 @@ mod tests {
                 .map(|(k, v)| (*k, v.fee()))
                 .collect(),
         )
-        .unwrap();
+        .unwrap()
+        .total;
         let net = total - fee;
 
         assert!(
@@ -1565,7 +1600,8 @@ mod tests {
                 .map(|(k, v)| (*k, v.fee()))
                 .collect(),
         )
-        .unwrap();
+        .unwrap()
+        .total;
         let net = total - fee;
 
         assert!(net >= amount, "Net {} should be >= {}", net, amount);
@@ -1630,7 +1666,8 @@ mod tests {
                 .map(|(k, v)| (*k, v.fee()))
                 .collect(),
         )
-        .unwrap();
+        .unwrap()
+        .total;
         let net = total - fee;
 
         assert!(
@@ -1679,7 +1716,8 @@ mod tests {
                 .map(|(k, v)| (*k, v.fee()))
                 .collect(),
         )
-        .unwrap();
+        .unwrap()
+        .total;
         let net = total - fee;
 
         assert!(
@@ -1722,7 +1760,8 @@ mod tests {
                 .map(|(k, v)| (*k, v.fee()))
                 .collect(),
         )
-        .unwrap();
+        .unwrap()
+        .total;
         let net = total - fee;
 
         assert!(
@@ -1765,7 +1804,8 @@ mod tests {
                 .map(|(k, v)| (*k, v.fee()))
                 .collect(),
         )
-        .unwrap();
+        .unwrap()
+        .total;
         let net = total - fee;
 
         assert!(
@@ -1819,7 +1859,8 @@ mod tests {
                 .map(|(k, v)| (*k, v.fee()))
                 .collect(),
         )
-        .unwrap();
+        .unwrap()
+        .total;
         let net = total - fee;
 
         assert!(
@@ -1896,7 +1937,8 @@ mod tests {
                 .map(|(k, v)| (*k, v.fee()))
                 .collect(),
         )
-        .unwrap();
+        .unwrap()
+        .total;
         let net = total - fee;
 
         assert!(
@@ -1946,7 +1988,8 @@ mod tests {
                 .map(|(k, v)| (*k, v.fee()))
                 .collect(),
         )
-        .unwrap();
+        .unwrap()
+        .total;
         let net = total - fee;
 
         assert!(

+ 3 - 0
crates/cdk/src/wallet/receive.rs

@@ -111,6 +111,8 @@ impl Wallet {
             }
         }
 
+        let fee_breakdown = self.get_proofs_fee(&proofs).await?;
+
         // Since the proofs are unknown they need to be added to the database
         let proofs_info = proofs
             .clone()
@@ -131,6 +133,7 @@ impl Wallet {
                 proofs,
                 None,
                 false,
+                &fee_breakdown,
             )
             .await?;
 

+ 11 - 8
crates/cdk/src/wallet/send.rs

@@ -106,7 +106,7 @@ impl Wallet {
                         .collect(),
                 )
                 .await?;
-            amount + send_fee
+            amount + send_fee.total
         } else {
             amount
         };
@@ -122,7 +122,7 @@ impl Wallet {
 
         // Check if selected proofs are exact
         let send_fee = if opts.include_fee {
-            self.get_proofs_fee(&selected_proofs).await?
+            self.get_proofs_fee(&selected_proofs).await?.total
         } else {
             Amount::ZERO
         };
@@ -175,7 +175,10 @@ impl Wallet {
             (send_split, send_fee)
         } else {
             let send_split = amount.split(&fee_and_amounts);
-            let send_fee = Amount::ZERO;
+            let send_fee = crate::fees::ProofsFeeBreakdown {
+                total: Amount::ZERO,
+                per_keyset: std::collections::HashMap::new(),
+            };
             (send_split, send_fee)
         };
         tracing::debug!("Send amounts: {:?}", send_amounts);
@@ -188,7 +191,7 @@ impl Wallet {
             .await?;
 
         // Check if proofs are exact send amount (and does not exceed max_proofs)
-        let mut exact_proofs = proofs.total_amount()? == amount + send_fee;
+        let mut exact_proofs = proofs.total_amount()? == amount + send_fee.total;
         if let Some(max_proofs) = opts.max_proofs {
             exact_proofs &= proofs.len() <= max_proofs;
         }
@@ -209,7 +212,7 @@ impl Wallet {
             proofs,
             &send_amounts,
             amount,
-            send_fee,
+            send_fee.total,
             &keyset_fees,
             force_swap,
             is_exact_or_offline,
@@ -225,7 +228,7 @@ impl Wallet {
             proofs_to_swap: split_result.proofs_to_swap,
             swap_fee: split_result.swap_fee,
             proofs_to_send: split_result.proofs_to_send,
-            send_fee,
+            send_fee: send_fee.total,
         })
     }
 }
@@ -570,7 +573,7 @@ pub fn split_proofs_for_send(
                 // Ensure proofs_to_swap can cover the swap's input fee plus the needed output
                 loop {
                     let swap_input_fee =
-                        calculate_fee(&proofs_to_swap.count_by_keyset(), keyset_fees)?;
+                        calculate_fee(&proofs_to_swap.count_by_keyset(), keyset_fees)?.total;
                     let swap_total = proofs_to_swap.total_amount()?;
 
                     let swap_can_produce = swap_total.checked_sub(swap_input_fee);
@@ -595,7 +598,7 @@ pub fn split_proofs_for_send(
         }
     }
 
-    let swap_fee = calculate_fee(&proofs_to_swap.count_by_keyset(), keyset_fees)?;
+    let swap_fee = calculate_fee(&proofs_to_swap.count_by_keyset(), keyset_fees)?.total;
 
     Ok(ProofSplitResult {
         proofs_to_send,

+ 7 - 3
crates/cdk/src/wallet/swap.rs

@@ -6,6 +6,7 @@ use tracing::instrument;
 
 use crate::amount::SplitTarget;
 use crate::dhke::construct_proofs;
+use crate::fees::ProofsFeeBreakdown;
 use crate::nuts::nut00::ProofsMethods;
 use crate::nuts::{
     nut10, PreMintSecrets, PreSwap, Proofs, PublicKey, SpendingConditions, State, SwapRequest,
@@ -32,6 +33,8 @@ impl Wallet {
             .get_keyset_fees_and_amounts_by_id(active_keyset_id)
             .await?;
 
+        let fee_breakdown = self.get_proofs_fee(&input_proofs).await?;
+
         let pre_swap = self
             .create_swap(
                 self.localstore.begin_db_transaction().await?,
@@ -42,6 +45,7 @@ impl Wallet {
                 input_proofs.clone(),
                 spending_conditions.clone(),
                 include_fees,
+                &fee_breakdown,
             )
             .await?;
 
@@ -207,19 +211,19 @@ impl Wallet {
         proofs: Proofs,
         spending_conditions: Option<SpendingConditions>,
         include_fees: bool,
+        proofs_fee_breakdown: &ProofsFeeBreakdown,
     ) -> Result<PreSwap, Error> {
         tracing::info!("Creating swap");
 
         // Desired amount is either amount passed or value of all proof
         let proofs_total = proofs.total_amount()?;
-        let fee = self.get_proofs_fee(&proofs).await?;
 
         let ys: Vec<PublicKey> = proofs.ys()?;
         tx.update_proofs_state(ys, State::Reserved).await?;
 
         let total_to_subtract = amount
             .unwrap_or(Amount::ZERO)
-            .checked_add(fee)
+            .checked_add(proofs_fee_breakdown.total)
             .ok_or(Error::AmountOverflow)?;
 
         let change_amount: Amount = proofs_total
@@ -365,7 +369,7 @@ impl Wallet {
             pre_mint_secrets: desired_messages,
             swap_request,
             derived_secret_count: derived_secret_count as u32,
-            fee,
+            fee: proofs_fee_breakdown.total,
         })
     }
 }