Skip to content

Commit 093e5cc

Browse files
committed
Account for Cashu fees in rebalances
Cashu melts can require proof input and output fees in addition to the quote reserve. Include those fees in trusted wallet estimates and reduce trusted-to-lightning rebalance amounts when needed so payments do not overspend the trusted balance. Update Cashu integration assertions for fee-adjusted rebalance amounts and for on-chain receives that are immediately consumed by splice flows.
1 parent e9ff424 commit 093e5cc

5 files changed

Lines changed: 124 additions & 14 deletions

File tree

graduated-rebalancer/src/lib.rs

Lines changed: 35 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,11 @@ pub trait TrustedWallet: Send + Sync {
7878
&self, method: PaymentMethod, amount: Amount,
7979
) -> Pin<Box<dyn Future<Output = Result<[u8; 32], Self::Error>> + Send + '_>>;
8080

81+
/// Estimate the fee for making a payment using the trusted wallet
82+
fn estimate_fee(
83+
&self, method: PaymentMethod, amount: Amount,
84+
) -> Pin<Box<dyn Future<Output = Result<Amount, Self::Error>> + Send + '_>>;
85+
8186
/// Wait for a payment success notification
8287
fn await_payment_success(
8388
&self, payment_hash: [u8; 32],
@@ -272,10 +277,38 @@ where
272277

273278
/// Perform a rebalance from trusted to lightning wallet
274279
async fn do_trusted_rebalance_locked(&self, params: TriggerParams) {
275-
let transfer_amt = params.amount;
280+
let mut transfer_amt = params.amount;
276281
log_info!(self.logger, "Initiating rebalance");
277282

278-
if let Ok(inv) = self.ln_wallet.get_bolt11_invoice(Some(transfer_amt)).await {
283+
if let Ok(mut inv) = self.ln_wallet.get_bolt11_invoice(Some(transfer_amt)).await {
284+
if let Ok(fee) = self
285+
.trusted
286+
.estimate_fee(PaymentMethod::LightningBolt11(inv.clone()), transfer_amt)
287+
.await
288+
{
289+
if fee >= transfer_amt {
290+
log_error!(
291+
self.logger,
292+
"Rebalance trusted transaction fee {fee:?} exceeds amount {transfer_amt:?}",
293+
);
294+
return;
295+
}
296+
297+
if transfer_amt.saturating_add(fee) > params.amount {
298+
transfer_amt = params.amount.saturating_sub(fee);
299+
match self.ln_wallet.get_bolt11_invoice(Some(transfer_amt)).await {
300+
Ok(reduced_inv) => inv = reduced_inv,
301+
Err(e) => {
302+
log_error!(
303+
self.logger,
304+
"Failed to create fee-adjusted rebalance invoice: {e:?}",
305+
);
306+
return;
307+
},
308+
}
309+
}
310+
}
311+
279312
log_debug!(
280313
self.logger,
281314
"Attempting to pay invoice {inv} to rebalance for {transfer_amt:?}",

orange-sdk/src/trusted_wallet/cashu/mod.rs

Lines changed: 57 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -216,7 +216,10 @@ impl TrustedWalletInterface for Cashu {
216216
})?;
217217

218218
// The fee is in the quote
219-
convert_amount(quote.fee_reserve, &self.unit)
219+
let quote_fee = convert_amount(quote.fee_reserve, &self.unit)?;
220+
let input_fee =
221+
self.estimate_input_fee(quote.amount + quote.fee_reserve).await?;
222+
Ok(quote_fee.saturating_add(input_fee))
220223
},
221224
PaymentMethod::LightningBolt12(offer) => {
222225
let quote = self
@@ -230,7 +233,10 @@ impl TrustedWalletInterface for Cashu {
230233
})?;
231234

232235
// The fee is in the quote
233-
convert_amount(quote.fee_reserve, &self.unit)
236+
let quote_fee = convert_amount(quote.fee_reserve, &self.unit)?;
237+
let input_fee =
238+
self.estimate_input_fee(quote.amount + quote.fee_reserve).await?;
239+
Ok(quote_fee.saturating_add(input_fee))
234240
},
235241
PaymentMethod::OnChain(_) => Err(TrustedError::UnsupportedOperation(
236242
"Cashu mint does not support onchain".to_owned(),
@@ -850,6 +856,55 @@ impl Cashu {
850856
Ok(())
851857
}
852858

859+
async fn estimate_input_fee(&self, input_amount: CdkAmount) -> Result<Amount, TrustedError> {
860+
let proofs = self.cashu_wallet.get_unspent_proofs().await.map_err(|e| {
861+
TrustedError::WalletOperationFailed(format!("Failed to get unspent proofs: {e}"))
862+
})?;
863+
864+
let mut counts_by_keyset = HashMap::new();
865+
for proof in proofs {
866+
*counts_by_keyset.entry(proof.keyset_id).or_insert(0_u64) += 1;
867+
}
868+
869+
let mut fee = Amount::ZERO;
870+
for (keyset_id, proof_count) in counts_by_keyset {
871+
let keyset_fee =
872+
self.cashu_wallet.calculate_fee(proof_count, keyset_id).await.map_err(|e| {
873+
TrustedError::WalletOperationFailed(format!(
874+
"Failed to calculate input fee: {e}"
875+
))
876+
})?;
877+
fee = fee.saturating_add(convert_amount(keyset_fee, &self.unit)?);
878+
}
879+
880+
let active_keyset = self.cashu_wallet.get_active_keyset().await.map_err(|e| {
881+
TrustedError::WalletOperationFailed(format!("Failed to get active keyset: {e}"))
882+
})?;
883+
let fee_and_amounts =
884+
self.cashu_wallet.get_keyset_fees_and_amounts_by_id(active_keyset.id).await.map_err(
885+
|e| {
886+
TrustedError::WalletOperationFailed(format!(
887+
"Failed to get keyset fee amounts: {e}"
888+
))
889+
},
890+
)?;
891+
let output_count = input_amount.split(&fee_and_amounts).map_err(|e| {
892+
TrustedError::WalletOperationFailed(format!(
893+
"Failed to calculate melt output count: {e}"
894+
))
895+
})?;
896+
let output_fee = self
897+
.cashu_wallet
898+
.calculate_fee(output_count.len() as u64, active_keyset.id)
899+
.await
900+
.map_err(|e| {
901+
TrustedError::WalletOperationFailed(format!("Failed to calculate output fee: {e}"))
902+
})?;
903+
fee = fee.saturating_add(convert_amount(output_fee, &self.unit)?);
904+
905+
Ok(fee)
906+
}
907+
853908
pub(crate) async fn await_payment_success(&self) {
854909
let mut flag = self.payment_success_flag.clone();
855910
flag.mark_unchanged();

orange-sdk/src/trusted_wallet/mod.rs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -127,6 +127,12 @@ impl<T: ?Sized + TrustedWalletInterface> graduated_rebalancer::TrustedWallet for
127127
Box::pin(async move { self.0.pay(method, amount).await })
128128
}
129129

130+
fn estimate_fee(
131+
&self, method: PaymentMethod, amount: Amount,
132+
) -> Pin<Box<dyn Future<Output = Result<Amount, Self::Error>> + Send + '_>> {
133+
Box::pin(async move { self.0.estimate_fee(method, amount).await })
134+
}
135+
130136
fn await_payment_success(
131137
&self, payment_hash: [u8; 32],
132138
) -> Pin<Box<dyn Future<Output = Option<ReceivedLightningPayment>> + Send + '_>> {

orange-sdk/tests/integration_tests.rs

Lines changed: 19 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -310,21 +310,24 @@ async fn test_sweep_to_ln() {
310310

311311
let expect_amt = intermediate_amt.saturating_add(recv_amt);
312312

313-
let event = wait_next_event(&wallet).await;
314-
match event {
313+
let received_rebalance_amount = match wait_next_event(&wallet).await {
315314
Event::PaymentReceived { payment_id, amount_msat, lsp_fee_msats, .. } => {
316315
assert!(matches!(payment_id, orange_sdk::PaymentId::SelfCustodial(_)));
317-
assert!(lsp_fee_msats.is_some());
318-
assert_eq!(amount_msat, expect_amt.milli_sats() - lsp_fee_msats.unwrap());
316+
let lsp_fee_msats = lsp_fee_msats.expect("rebalance receive should pay LSP fee");
317+
assert!(
318+
amount_msat + lsp_fee_msats <= expect_amt.milli_sats(),
319+
"rebalance receive should not exceed trusted balance after fees"
320+
);
321+
amount_msat + lsp_fee_msats
319322
},
320323
e => panic!("Expected RebalanceSuccessful event, got {e:?}"),
321-
}
324+
};
322325

323326
let event = wait_next_event(&wallet).await;
324327
match event {
325328
Event::RebalanceSuccessful { amount_msat, fee_msat, .. } => {
326329
assert!(fee_msat > 0);
327-
assert_eq!(amount_msat, expect_amt.milli_sats());
330+
assert_eq!(amount_msat, received_rebalance_amount);
328331
},
329332
e => panic!("Expected RebalanceSuccessful event, got {e:?}"),
330333
}
@@ -786,9 +789,12 @@ async fn test_receive_to_onchain_with_channel() {
786789

787790
// check we received on-chain, should be pending
788791
// wait for payment success
789-
test_utils::wait_for_condition("pending balance to update", || async {
790-
// onchain balance is always listed as pending until we splice it into the channel.
792+
test_utils::wait_for_condition("onchain receive to appear", || async {
791793
wallet.get_balance().await.unwrap().pending_balance == recv_amt
794+
|| wallet.list_transactions().await.unwrap().iter().any(|tx| {
795+
tx.payment_type == PaymentType::IncomingOnChain { txid: Some(sent_txid) }
796+
&& tx.amount == Some(recv_amt)
797+
})
792798
})
793799
.await;
794800

@@ -886,8 +892,12 @@ async fn test_concurrent_splice_in_and_out_preserve_pending_events() {
886892
generate_blocks(&bitcoind, &electrsd, 6).await;
887893
wallet.sync_ln_wallet().unwrap();
888894

889-
test_utils::wait_for_condition("pending balance to update", || async {
895+
test_utils::wait_for_condition("onchain receive to appear", || async {
890896
wallet.get_balance().await.unwrap().pending_balance == recv_amt
897+
|| wallet.list_transactions().await.unwrap().iter().any(|tx| {
898+
tx.payment_type == PaymentType::IncomingOnChain { txid: Some(sent_txid) }
899+
&& tx.amount == Some(recv_amt)
900+
})
891901
})
892902
.await;
893903

orange-sdk/tests/test_utils.rs

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
use bitcoin_payment_instructions::amount::Amount;
44
#[cfg(feature = "_cashu-tests")]
5-
use cdk::mint::{MintBuilder, MintMeltLimits};
5+
use cdk::mint::{MintBuilder, MintMeltLimits, UnitConfig};
66
#[cfg(feature = "_cashu-tests")]
77
use cdk::types::FeeReserve;
88
#[cfg(feature = "_cashu-tests")]
@@ -527,6 +527,12 @@ async fn build_test_nodes() -> TestParams {
527527
let mut mint_seed: [u8; 64] = [0; 64];
528528
rand::thread_rng().fill_bytes(&mut mint_seed);
529529
let mut builder = MintBuilder::new(mem_db.clone());
530+
builder
531+
.configure_unit(
532+
orange_sdk::CurrencyUnit::Sat,
533+
UnitConfig { input_fee_ppk: 1, ..Default::default() },
534+
)
535+
.unwrap();
530536

531537
builder
532538
.add_payment_processor(

0 commit comments

Comments
 (0)