Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 42 additions & 0 deletions index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ export interface MdkNodeOptions {
lspAddress: string
scoringParamOverrides?: ScoringParamOverrides
splice?: SpliceConfig
maxSendable?: MaxSendableConfig
}
/**
* Configuration for the auto-splice manager. The manager wakes up every
Expand All @@ -53,6 +54,17 @@ export interface SpliceConfig {
/** Poll interval in seconds. Default: 30. */
pollIntervalSecs?: number
}
/**
* Configuration for the max-sendable estimator. Subtracts a routing-fee
* buffer from the raw outbound liquidity so consumers don't try to spend
* `getBalance()` worth and watch it fail to route.
*/
export interface MaxSendableConfig {
/** Percentage buffer in basis points (1 bps = 0.01 %). Default: 100 (1 %). */
feeBufferBps?: number
/** Absolute lower bound on the buffer, in sats. Default: 10. */
feeBufferFloorSats?: number
}
export interface PaymentMetadata {
bolt11: string
paymentHash: string
Expand Down Expand Up @@ -102,6 +114,22 @@ export interface NodeChannel {
isUsable: boolean
isPublic: boolean
}
/**
* Best-effort estimate of the largest amount that can flow out over
* Lightning right now, with routing-fee headroom subtracted.
*/
export interface MaxSendableEstimate {
/**
* Amount to surface to the payer as "max sendable", in msat. Zero
* when the balance is fully consumed by the buffer (dust).
*/
amountMsat: number
/**
* The buffer subtracted from the raw outbound liquidity to reach
* `amount_msat`. Doubles as a hint for `max_total_routing_fee_msat`.
*/
feeBudgetMsat: number
}
export declare class MdkNode {
constructor(options: MdkNodeOptions)
/**
Expand Down Expand Up @@ -152,6 +180,20 @@ export declare class MdkNode {
*/
getBalanceWhileRunning(): number
listChannels(): Array<NodeChannel>
/**
* Best-effort estimate of the largest amount that can flow out over
* Lightning right now, with routing-fee headroom subtracted.
*
* Returns `null` when no usable LSP channel exists. `Some(amountMsat: 0)`
* is distinct from `null` — it means a channel exists but the balance
* is fully consumed by the fee buffer (dust). Consumers should never
* see a positive `getBalance()` paired with a `null` here: both
* project from the same `list_channels()` snapshot inside a single
* call.
*
* Read-only; safe to call whether or not the node has been started.
*/
getMaxSendable(): MaxSendableEstimate | null
/**
* Manually sync the RGS snapshot.
*
Expand Down
90 changes: 90 additions & 0 deletions src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ use tokio_util::sync::CancellationToken;
#[macro_use]
extern crate napi_derive;

mod max_sendable;
mod splice_manager;

/// Polling interval for event loops and state checks.
Expand Down Expand Up @@ -301,6 +302,7 @@ pub struct MdkNodeOptions {
pub lsp_address: String,
pub scoring_param_overrides: Option<ScoringParamOverrides>,
pub splice: Option<SpliceConfig>,
pub max_sendable: Option<MaxSendableConfig>,
}

/// Configuration for the auto-splice manager. The manager wakes up every
Expand Down Expand Up @@ -340,6 +342,51 @@ impl ResolvedSpliceConfig {
}
}

/// Default percentage buffer (in basis points) applied when the caller
/// leaves `fee_buffer_bps` unset on [`MaxSendableConfig`]. 100 bps = 1 %.
const DEFAULT_FEE_BUFFER_BPS: u16 = 100;
/// Default floor (in sats) on the buffer when the caller leaves
/// `fee_buffer_floor_sats` unset on [`MaxSendableConfig`].
const DEFAULT_FEE_BUFFER_FLOOR_SATS: u64 = 10;

/// Configuration for the max-sendable estimator. Subtracts a routing-fee
/// buffer from the raw outbound liquidity so consumers don't try to spend
/// `getBalance()` worth and watch it fail to route.
#[napi(object)]
pub struct MaxSendableConfig {
/// Percentage buffer in basis points (1 bps = 0.01 %). Default: 100 (1 %).
pub fee_buffer_bps: Option<u32>,
/// Absolute lower bound on the buffer, in sats. Default: 10.
pub fee_buffer_floor_sats: Option<i64>,
}

impl MaxSendableConfig {
/// Resolve the napi-shaped optional/wider-typed config into the
/// `(bps, floor_sats)` pair that `max_sendable::compute_estimate`
/// consumes. Bad input from JS (negative floor, bps > `u16::MAX`)
/// gets clamped silently
fn resolve(&self) -> (u16, u64) {
let bps = self
.fee_buffer_bps
.map(|v| v.min(u16::MAX as u32) as u16)
.unwrap_or(DEFAULT_FEE_BUFFER_BPS);
let floor_sats = self
.fee_buffer_floor_sats
.map(|v| v.max(0) as u64)
.unwrap_or(DEFAULT_FEE_BUFFER_FLOOR_SATS);
(bps, floor_sats)
}
}

impl Default for MaxSendableConfig {
fn default() -> Self {
Self {
fee_buffer_bps: None,
fee_buffer_floor_sats: None,
}
}
}

#[napi(object)]
pub struct PaymentMetadata {
pub bolt11: String,
Expand Down Expand Up @@ -405,6 +452,18 @@ pub struct NodeChannel {
pub is_public: bool,
}

/// Best-effort estimate of the largest amount that can flow out over
/// Lightning right now, with routing-fee headroom subtracted.
#[napi(object)]
pub struct MaxSendableEstimate {
/// Amount to surface to the payer as "max sendable", in msat. Zero
/// when the balance is fully consumed by the buffer (dust).
pub amount_msat: i64,
/// The buffer subtracted from the raw outbound liquidity to reach
/// `amount_msat`. Doubles as a hint for `max_total_routing_fee_msat`.
pub fee_budget_msat: i64,
}

#[napi]
pub struct MdkNode {
node: Option<Arc<Node>>,
Expand All @@ -413,6 +472,7 @@ pub struct MdkNode {
/// channels by counterparty.
lsp_pubkey: PublicKey,
splice_cfg: ResolvedSpliceConfig,
max_sendable_cfg: MaxSendableConfig,
/// One-worker tokio runtime dedicated to the splice manager.
splice_runtime: Runtime,
/// `Some` while a splice manager is running, `None` otherwise.
Expand Down Expand Up @@ -526,6 +586,7 @@ impl MdkNode {
.map_err(|err| napi::Error::from_reason(err.to_string()))?;

let splice_cfg = ResolvedSpliceConfig::from_options(options.splice);
let max_sendable_cfg = options.max_sendable.unwrap_or_default();

// One self-driving worker is enough; the manager sleeps between ticks.
let splice_runtime = tokio::runtime::Builder::new_multi_thread()
Expand All @@ -540,6 +601,7 @@ impl MdkNode {
network,
lsp_pubkey: lsp_node_id,
splice_cfg,
max_sendable_cfg,
splice_runtime,
splice_task: Mutex::new(None),
})
Expand Down Expand Up @@ -883,6 +945,34 @@ impl MdkNode {
.collect()
}

/// Best-effort estimate of the largest amount that can flow out over
/// Lightning right now, with routing-fee headroom subtracted.
///
/// Returns `null` when no usable LSP channel exists. `Some(amountMsat: 0)`
/// is distinct from `null` — it means a channel exists but the balance
/// is fully consumed by the fee buffer (dust). Consumers should never
/// see a positive `getBalance()` paired with a `null` here: both
/// project from the same `list_channels()` snapshot inside a single
/// call.
///
/// Read-only; safe to call whether or not the node has been started.
#[napi]
pub fn get_max_sendable(&self) -> Option<MaxSendableEstimate> {
let snaps: Vec<max_sendable::ChannelSnapshot> = self
.node()
.list_channels()
.iter()
.map(max_sendable::ChannelSnapshot::from)
.collect();
let (bps, floor_sats) = self.max_sendable_cfg.resolve();
max_sendable::compute_estimate(&snaps, &self.lsp_pubkey, bps, floor_sats)
.ok()
.map(|e| MaxSendableEstimate {
amount_msat: u64_to_i64(e.amount_msat),
fee_budget_msat: u64_to_i64(e.fee_budget_msat),
})
}

/// Manually sync the RGS snapshot.
///
/// If `do_full_sync` is true, the RGS snapshot will be updated from scratch. Otherwise, the
Expand Down
Loading
Loading