Skip to content

Commit d5288ba

Browse files
authored
Merge pull request #20 from moneydevkit/austin_mdk-863_vo-max-withrawable
v0 `max_withdrawable` estimator
2 parents 12ae517 + e5c3d66 commit d5288ba

12 files changed

Lines changed: 420 additions & 3 deletions

File tree

src/daemon/api/balance.rs

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
use std::sync::Arc;
22

33
use axum::Json;
4-
use ldk_node::Node;
4+
use mdk::client::MdkClient;
55

66
use crate::daemon::api::error::AppError;
77
use crate::daemon::types::GetBalanceResponse;
@@ -20,16 +20,26 @@ use crate::daemon::types::GetBalanceResponse;
2020
/// can be zero even when the channel has a real outbound balance.
2121
///
2222
/// `onchain_balance_sat` is what the user can actually sweep/send on-chain right now.
23-
pub async fn handle_get_balance(node: Arc<Node>) -> Result<Json<GetBalanceResponse>, AppError> {
23+
///
24+
/// `max_withdrawable_sat` is what `balance_sat` can pay out after subtracting
25+
/// a routing-fee buffer (see [`mdk::max_sendable`]). `None` when no usable
26+
/// LSP channel exists.
27+
pub async fn handle_get_balance(
28+
client: Arc<MdkClient>,
29+
) -> Result<Json<GetBalanceResponse>, AppError> {
30+
let node = client.node();
2431
let balances = node.list_balances();
2532
let lightning_sat: u64 = node
2633
.list_channels()
2734
.iter()
2835
.map(|ch| ch.outbound_capacity_msat / 1000)
2936
.sum();
3037

38+
let max_withdrawable_sat = client.max_sendable().ok().map(|e| e.amount_msat / 1000);
39+
3140
Ok(Json(GetBalanceResponse {
3241
balance_sat: lightning_sat,
3342
onchain_balance_sat: balances.spendable_onchain_balance_sats,
43+
max_withdrawable_sat,
3444
}))
3545
}

src/daemon/api/mod.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -147,7 +147,7 @@ async fn get_info(State(state): State<AppState>) -> Result<Json<GetInfoResponse>
147147
security(("basic_auth" = []))
148148
)]
149149
async fn get_balance(State(state): State<AppState>) -> Result<Json<GetBalanceResponse>, AppError> {
150-
balance::handle_get_balance(state.node).await
150+
balance::handle_get_balance(state.mdk_client).await
151151
}
152152

153153
#[utoipa::path(

src/daemon/config.rs

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ use ldk_node::bitcoin::Network;
88
use ldk_node::lightning::ln::msgs::SocketAddress;
99
use ldk_node::lightning::routing::gossip::NodeAlias;
1010
use log::LevelFilter;
11+
use mdk::max_sendable::MaxSendableConfig;
1112
use mdk::node::ScoringOverrides;
1213
use serde::Deserialize;
1314

@@ -19,6 +20,7 @@ struct TomlConfig {
1920
storage: Option<StorageSection>,
2021
log: Option<LogSection>,
2122
splice: Option<SpliceSection>,
23+
max_sendable: Option<MaxSendableSection>,
2224
}
2325

2426
#[derive(Deserialize)]
@@ -90,6 +92,12 @@ struct SpliceSection {
9092
poll_interval_secs: Option<u64>,
9193
}
9294

95+
#[derive(Deserialize)]
96+
struct MaxSendableSection {
97+
fee_buffer_bps: Option<u16>,
98+
fee_buffer_floor_sats: Option<u64>,
99+
}
100+
93101
pub struct MdkConfig {
94102
pub network: Network,
95103
pub listening_addrs: Option<Vec<SocketAddress>>,
@@ -101,6 +109,7 @@ pub struct MdkConfig {
101109
pub pathfinding_scores_source_url: Option<String>,
102110
pub scoring_overrides: ScoringOverrides,
103111
pub splice: SpliceConfig,
112+
pub max_sendable: MaxSendableConfig,
104113
}
105114

106115
pub fn load_config(path: &str) -> io::Result<MdkConfig> {
@@ -173,6 +182,19 @@ pub fn load_config(path: &str) -> io::Result<MdkConfig> {
173182
None => SpliceConfig::default(),
174183
};
175184

185+
let max_sendable = match toml.max_sendable {
186+
Some(s) => {
187+
let defaults = MaxSendableConfig::default();
188+
MaxSendableConfig {
189+
fee_buffer_bps: s.fee_buffer_bps.unwrap_or(defaults.fee_buffer_bps),
190+
fee_buffer_floor_sats: s
191+
.fee_buffer_floor_sats
192+
.unwrap_or(defaults.fee_buffer_floor_sats),
193+
}
194+
}
195+
None => MaxSendableConfig::default(),
196+
};
197+
176198
Ok(MdkConfig {
177199
network,
178200
listening_addrs,
@@ -184,6 +206,7 @@ pub fn load_config(path: &str) -> io::Result<MdkConfig> {
184206
pathfinding_scores_source_url: node.pathfinding_scores_source_url,
185207
scoring_overrides,
186208
splice,
209+
max_sendable,
187210
})
188211
}
189212

src/daemon/types.rs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -152,6 +152,12 @@ pub struct GetBalanceResponse {
152152
pub balance_sat: u64,
153153
/// Spendable on-chain balance in sats.
154154
pub onchain_balance_sat: u64,
155+
/// Best-effort max sendable over Lightning right now (sats), with
156+
/// routing-fee headroom subtracted. `null` when no usable LSP
157+
/// channel exists. `Some(0)` is distinct from `null`: it means a
158+
/// channel exists but the balance is fully consumed by the fee
159+
/// buffer (dust).
160+
pub max_withdrawable_sat: Option<u64>,
155161
}
156162

157163
#[derive(Serialize, ToSchema)]

src/lib.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ mod mdk;
33
pub use mdk::client;
44
pub use mdk::config;
55
pub use mdk::error;
6+
pub use mdk::max_sendable;
67
pub use mdk::mdk_api;
78
pub use mdk::node;
89
pub use mdk::types;

src/main.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -133,6 +133,7 @@ fn main() {
133133
infra,
134134
scoring_overrides: config_file.scoring_overrides,
135135
splice: config_file.splice,
136+
max_sendable: config_file.max_sendable,
136137
};
137138

138139
// Separate HTTP client for daemon concerns (webhooks, expiry monitor).

src/mdk/client.rs

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,9 @@ use tokio::sync::broadcast;
1515
use tokio_util::sync::CancellationToken;
1616

1717
use crate::mdk::error::{MdkError, SpliceError};
18+
use crate::mdk::max_sendable::{
19+
compute_estimate, ChannelSnapshot, MaxSendableConfig, MaxSendableError, MaxSendableEstimate,
20+
};
1821
use crate::mdk::mdk_api::client::MdkApiClient;
1922
use crate::mdk::mdk_api::types::{
2023
CheckoutCustomer, CreateCheckoutRequest, PaymentEntry, PaymentReceivedRequest,
@@ -37,6 +40,7 @@ pub struct MdkClient {
3740
api: Arc<MdkApiClient>,
3841
lsp_pubkey: PublicKey,
3942
splice_cfg: SpliceConfig,
43+
max_sendable_cfg: MaxSendableConfig,
4044
event_tx: broadcast::Sender<MdkEvent>,
4145
event_handler: Option<EventHandler>,
4246
shutdown: CancellationToken,
@@ -77,6 +81,7 @@ impl MdkClient {
7781
let lsp_pubkey = PublicKey::from_str(&config.infra.lsp_node_id)
7882
.map_err(|e| MdkError::InvalidInput(format!("bad lsp_node_id: {e}")))?;
7983
let splice_cfg = config.splice.clone();
84+
let max_sendable_cfg = config.max_sendable.clone();
8085

8186
let node = build_node(config, handle.clone())?;
8287
let http_client = build_http_client(socks_proxy.as_deref())?;
@@ -92,6 +97,7 @@ impl MdkClient {
9297
api,
9398
lsp_pubkey,
9499
splice_cfg,
100+
max_sendable_cfg,
95101
event_tx,
96102
event_handler,
97103
shutdown: CancellationToken::new(),
@@ -137,6 +143,20 @@ impl MdkClient {
137143
self.lsp_pubkey
138144
}
139145

146+
/// Best-effort estimate of the largest amount that can flow out
147+
/// over Lightning right now, with routing-fee headroom subtracted.
148+
/// Computed inline from `node.list_channels()` on every call so
149+
/// the result reflects in-flight HTLCs and reserve as of *now*.
150+
pub fn max_sendable(&self) -> Result<MaxSendableEstimate, MaxSendableError> {
151+
let snaps: Vec<ChannelSnapshot> = self
152+
.node
153+
.list_channels()
154+
.iter()
155+
.map(ChannelSnapshot::from)
156+
.collect();
157+
compute_estimate(&snaps, &self.lsp_pubkey, &self.max_sendable_cfg)
158+
}
159+
140160
/// Splice `amount_sats` of confirmed on-chain funds into the
141161
/// existing channel identified by `user_channel_id`, with the
142162
/// LSP as counterparty.

0 commit comments

Comments
 (0)