Ver código fonte

Fix exact proofs (#1407)

* feat: add tests for select exact proofs

* fix: fee_ppk unit mismatch in select_exact_proofs

fee_ppk was being used as sats but it's actually ppk (parts per thousand).
With fee_ppk=100, the code treated it as 100 sats instead of 1 sat.

select_exact_proofs is unused - consider removing it.
tsk 1 mês atrás
pai
commit
16b27d5f69
1 arquivos alterados com 244 adições e 3 exclusões
  1. 244 3
      crates/cdk/src/wallet/proofs.rs

+ 244 - 3
crates/cdk/src/wallet/proofs.rs

@@ -239,12 +239,14 @@ impl Wallet {
                 let fee_ppk = fees_and_keyset_amounts
                     .get(&proof_to_exchange.keyset_id)
                     .map(|fee_and_amounts| fee_and_amounts.fee())
-                    .unwrap_or_default()
-                    .into();
+                    .unwrap_or_default();
+                // Convert fee from ppk (parts per thousand) to sats per NUT-02 spec:
+                // fee = ceil(fee_ppk / 1000) for 1 proof
+                let fee: Amount = fee_ppk.div_ceil(1000).into();
 
                 if let Some(exact_amount_to_melt) = total_for_proofs
                     .checked_sub(proof_to_exchange.amount)
-                    .and_then(|a| a.checked_add(fee_ppk))
+                    .and_then(|a| a.checked_add(fee))
                     .and_then(|b| amount.checked_sub(b))
                 {
                     exchange = Some((proof_to_exchange, exact_amount_to_melt));
@@ -2167,4 +2169,243 @@ mod tests {
         );
         assert_eq!(selected_proofs.len(), 1, "Should select only 1 proof");
     }
+
+    // ========================================================================
+    // select_exact_proofs Fee Tests (NUT-02 compliance)
+    // ========================================================================
+
+    /// Test select_exact_proofs with fee_ppk=100 (0.1 sat per proof)
+    ///
+    /// Per NUT-02 spec: fee = ceil(sum(input_fee_ppk) / 1000)
+    /// For 1 proof with fee_ppk=100: fee = ceil(100/1000) = 1 sat
+    #[test]
+    fn test_select_exact_proofs_with_fee_ppk_100() {
+        let active_id = id();
+        let mut keyset_fee_and_amounts = HashMap::new();
+        keyset_fee_and_amounts.insert(
+            active_id,
+            (100, (0..32).map(|x| 2u64.pow(x)).collect::<Vec<_>>()).into(),
+        );
+        // Proofs: 64 + 32 + 4 = 100 sats total
+        let proofs = vec![proof(64), proof(4), proof(32)];
+
+        // Request 97 sats
+        let (selected_proofs, exchange) = Wallet::select_exact_proofs(
+            97.into(),
+            proofs,
+            &vec![active_id],
+            &keyset_fee_and_amounts,
+            false,
+        )
+        .unwrap();
+
+        assert!(exchange.is_some(), "Should have a proof to exchange");
+        let (proof_to_exchange, exact_amount_to_melt) = exchange.unwrap();
+
+        // selected_proofs should be [32, 4] = 36 sats
+        assert_eq!(selected_proofs.len(), 2);
+        let selected_total: u64 = selected_proofs.iter().map(|p| u64::from(p.amount)).sum();
+        assert_eq!(selected_total, 36, "Selected proofs should total 36 sats");
+
+        // proof_to_exchange should be the 64 sat proof
+        assert_eq!(proof_to_exchange.amount, 64.into());
+
+        // Per NUT-02: fee for 1 proof with fee_ppk=100 is ceil(100/1000) = 1 sat
+        // exact_amount_to_melt = amount - (selected_total + fee)
+        //                      = 97 - (36 + 1) = 60 sats
+        assert_eq!(
+            exact_amount_to_melt,
+            60.into(),
+            "exact_amount_to_melt should be 60 (97 - 36 - 1 fee)"
+        );
+    }
+
+    /// Test select_exact_proofs with fee_ppk=1000 (1 sat per proof)
+    ///
+    /// Per NUT-02 spec: fee = ceil(sum(input_fee_ppk) / 1000)
+    /// For 1 proof with fee_ppk=1000: fee = ceil(1000/1000) = 1 sat
+    #[test]
+    fn test_select_exact_proofs_with_fee_ppk_1000() {
+        let active_id = id();
+        let mut keyset_fee_and_amounts = HashMap::new();
+        keyset_fee_and_amounts.insert(
+            active_id,
+            (1000, (0..32).map(|x| 2u64.pow(x)).collect::<Vec<_>>()).into(),
+        );
+        let proofs = vec![proof(64), proof(4), proof(32)];
+
+        let (selected_proofs, exchange) = Wallet::select_exact_proofs(
+            97.into(),
+            proofs,
+            &vec![active_id],
+            &keyset_fee_and_amounts,
+            false,
+        )
+        .unwrap();
+
+        assert!(exchange.is_some(), "Should have a proof to exchange");
+        let (proof_to_exchange, exact_amount_to_melt) = exchange.unwrap();
+
+        assert_eq!(proof_to_exchange.amount, 64.into());
+
+        let selected_total: u64 = selected_proofs.iter().map(|p| u64::from(p.amount)).sum();
+        assert_eq!(selected_total, 36);
+
+        // Per NUT-02: fee for 1 proof with fee_ppk=1000 is ceil(1000/1000) = 1 sat
+        // exact_amount_to_melt = 97 - (36 + 1) = 60 sats
+        assert_eq!(
+            exact_amount_to_melt,
+            60.into(),
+            "exact_amount_to_melt should be 60 (97 - 36 - 1 fee)"
+        );
+    }
+
+    /// Test select_exact_proofs with fee_ppk=200 (0.2 sat per proof)
+    ///
+    /// Per NUT-02 spec: fee = ceil(sum(input_fee_ppk) / 1000)
+    /// For 1 proof with fee_ppk=200: fee = ceil(200/1000) = 1 sat
+    #[test]
+    fn test_select_exact_proofs_with_fee_ppk_200() {
+        let active_id = id();
+        let mut keyset_fee_and_amounts = HashMap::new();
+        keyset_fee_and_amounts.insert(
+            active_id,
+            (200, (0..32).map(|x| 2u64.pow(x)).collect::<Vec<_>>()).into(),
+        );
+        let proofs = vec![proof(64), proof(4), proof(32)];
+
+        let (selected_proofs, exchange) = Wallet::select_exact_proofs(
+            97.into(),
+            proofs,
+            &vec![active_id],
+            &keyset_fee_and_amounts,
+            false,
+        )
+        .unwrap();
+
+        assert!(exchange.is_some());
+        let (proof_to_exchange, exact_amount_to_melt) = exchange.unwrap();
+
+        assert_eq!(proof_to_exchange.amount, 64.into());
+
+        let selected_total: u64 = selected_proofs.iter().map(|p| u64::from(p.amount)).sum();
+        assert_eq!(selected_total, 36);
+
+        // fee = ceil(200/1000) = 1 sat
+        // exact_amount_to_melt = 97 - 36 - 1 = 60
+        assert_eq!(
+            exact_amount_to_melt,
+            60.into(),
+            "exact_amount_to_melt should be 60 (97 - 36 - 1 fee)"
+        );
+    }
+
+    /// Test select_exact_proofs with fee_ppk=2000 (2 sats per proof)
+    ///
+    /// Per NUT-02 spec: fee = ceil(sum(input_fee_ppk) / 1000)
+    /// For 1 proof with fee_ppk=2000: fee = ceil(2000/1000) = 2 sats
+    #[test]
+    fn test_select_exact_proofs_with_fee_ppk_2000() {
+        let active_id = id();
+        let mut keyset_fee_and_amounts = HashMap::new();
+        keyset_fee_and_amounts.insert(
+            active_id,
+            (2000, (0..32).map(|x| 2u64.pow(x)).collect::<Vec<_>>()).into(),
+        );
+        let proofs = vec![proof(64), proof(4), proof(32)];
+
+        let (selected_proofs, exchange) = Wallet::select_exact_proofs(
+            97.into(),
+            proofs,
+            &vec![active_id],
+            &keyset_fee_and_amounts,
+            false,
+        )
+        .unwrap();
+
+        assert!(exchange.is_some());
+        let (proof_to_exchange, exact_amount_to_melt) = exchange.unwrap();
+
+        assert_eq!(proof_to_exchange.amount, 64.into());
+
+        let selected_total: u64 = selected_proofs.iter().map(|p| u64::from(p.amount)).sum();
+        assert_eq!(selected_total, 36);
+
+        // fee = ceil(2000/1000) = 2 sats
+        // exact_amount_to_melt = 97 - 36 - 2 = 59
+        assert_eq!(
+            exact_amount_to_melt,
+            59.into(),
+            "exact_amount_to_melt should be 59 (97 - 36 - 2 fee)"
+        );
+    }
+
+    /// Test that select_exact_proofs correctly handles the fee calculation
+    /// by verifying the math: exact_amount = amount - selected_total - fee
+    #[test]
+    fn test_select_exact_proofs_fee_calculation_math() {
+        let active_id = id();
+
+        // Test multiple fee_ppk values
+        let test_cases: Vec<(u64, u64)> = vec![
+            (100, 1),    // ceil(100/1000) = 1
+            (500, 1),    // ceil(500/1000) = 1
+            (1000, 1),   // ceil(1000/1000) = 1
+            (1001, 2),   // ceil(1001/1000) = 2
+            (1500, 2),   // ceil(1500/1000) = 2
+            (2000, 2),   // ceil(2000/1000) = 2
+            (5000, 5),   // ceil(5000/1000) = 5
+            (10000, 10), // ceil(10000/1000) = 10
+        ];
+
+        for (fee_ppk, expected_fee) in test_cases {
+            let mut keyset_fee_and_amounts = HashMap::new();
+            keyset_fee_and_amounts.insert(
+                active_id,
+                (fee_ppk, (0..32).map(|x| 2u64.pow(x)).collect::<Vec<_>>()).into(),
+            );
+
+            // Use proofs that will definitely trigger the exchange path
+            // 128 + 64 + 32 = 224, request 200
+            let proofs = vec![proof(128), proof(64), proof(32)];
+            let amount: Amount = 200.into();
+
+            let (selected_proofs, exchange) = Wallet::select_exact_proofs(
+                amount,
+                proofs,
+                &vec![active_id],
+                &keyset_fee_and_amounts,
+                false,
+            )
+            .unwrap();
+
+            if let Some((proof_to_exchange, exact_amount_to_melt)) = exchange {
+                let selected_total: u64 = selected_proofs.iter().map(|p| u64::from(p.amount)).sum();
+
+                // Per NUT-02 spec:
+                // exact_amount_to_melt = amount - selected_total - fee
+                let expected_exact = u64::from(amount) - selected_total - expected_fee;
+
+                assert_eq!(
+                    u64::from(exact_amount_to_melt),
+                    expected_exact,
+                    "fee_ppk={}: exact_amount_to_melt should be {} (amount {} - selected {} - fee {}), got {}",
+                    fee_ppk,
+                    expected_exact,
+                    u64::from(amount),
+                    selected_total,
+                    expected_fee,
+                    u64::from(exact_amount_to_melt)
+                );
+
+                // Also verify the proof to exchange has enough to cover exact_amount_to_melt
+                assert!(
+                    proof_to_exchange.amount >= exact_amount_to_melt,
+                    "Proof to exchange ({}) must be >= exact_amount_to_melt ({})",
+                    proof_to_exchange.amount,
+                    exact_amount_to_melt
+                );
+            }
+        }
+    }
 }