Explain unaffordable fee bump quotes

This commit is contained in:
archipelago
2026-10-07 08:42:08 -04:00
parent beb0dbc1f4
commit c57119e9a7
2 changed files with 99 additions and 1 deletions
+87 -1
View File
@@ -120,6 +120,63 @@ fn fee_budget(
Ok(budget)
}
/// Find the highest rate that fits the already selected wallet output. This is
/// only a quote-time calculation: it never asks LND to reserve or spend the
/// output. Keeping it here lets the caller give an actionable answer when the
/// requested target is too expensive.
fn highest_affordable_rate(
requested: u64,
parent_size: u64,
parent_fee: u64,
size: u64,
old_fee: u64,
relay: u64,
input: u64,
) -> Option<u64> {
if requested <= 1 {
return None;
}
let mut low = 1;
let mut high = requested.saturating_sub(1);
let mut best = None;
while low <= high {
let mid = low + (high - low) / 2;
if fee_budget(mid, parent_size, parent_fee, size, old_fee, relay, input).is_ok() {
best = Some(mid);
low = mid.saturating_add(1);
} else {
high = mid.saturating_sub(1);
}
}
best
}
fn quote_budget_error(
requested: u64,
parent_size: u64,
parent_fee: u64,
size: u64,
old_fee: u64,
relay: u64,
input: u64,
) -> anyhow::Error {
let available = input.saturating_sub(1000);
let suggestion = highest_affordable_rate(
requested,
parent_size,
parent_fee,
size,
old_fee,
relay,
input,
)
.map(|rate| format!(" Try {rate} sat/vB or lower."))
.unwrap_or_else(|| " No fee rate can currently fit this output.".into());
anyhow::anyhow!(
"Not enough wallet change for {requested} sat/vB: at most {available} sats is spendable for this bump.{suggestion}"
)
}
async fn lnd(
client: &reqwest::Client,
macaroon: &str,
@@ -501,7 +558,22 @@ impl RpcHandler {
old_fee,
relay.max(floor),
input_sats,
)?;
)
.map_err(|error| {
if error.to_string().contains("Not enough wallet change") {
quote_budget_error(
rate,
parent_size,
parent_fee,
size,
old_fee,
relay.max(floor),
input_sats,
)
} else {
error
}
})?;
let tip: String = self
.bitcoin_rpc_call(&client, "getbestblockhash", &[])
.await?;
@@ -797,6 +869,20 @@ mod tests {
assert!(fee_budget(0, 142, 144, 112, 0, 1, 21126).is_err());
}
#[test]
fn unaffordable_quote_explains_spendable_change_and_viable_rate() {
let suggested = highest_affordable_rate(5000, 142, 144, 112, 0, 1, 21126);
assert_eq!(suggested, Some(79));
let error = quote_budget_error(5000, 142, 144, 112, 0, 1, 21126).to_string();
assert!(error.contains("at most 20126 sats is spendable"));
assert!(error.contains("Try 79 sat/vB or lower"));
}
#[test]
fn no_affordable_rate_is_reported_without_mutating_the_output() {
let error = quote_budget_error(10, 142, 144, 112, 9_000, 1, 10_000).to_string();
assert!(error.contains("at most 9000 sats is spendable"));
assert!(error.contains("No fee rate can currently fit this output"));
}
#[test]
fn rbf_pays_incremental_relay_cost_and_counts_only_extra_cost() {
let fee = fee_budget(3, 142, 144, 112, 650, 1, 21126).unwrap();
assert_eq!(fee, 763);
@@ -47,6 +47,18 @@ describe('Bump review', () => {
expect(rpcClient.call).toHaveBeenLastCalledWith(expect.objectContaining({ method: 'lnd.bump-quote', params: { txid: quote.txid, sat_per_vbyte: 10 } }))
wrapper.unmount()
})
it('shows an actionable wallet-change shortfall without offering submit', async () => {
vi.mocked(rpcClient.call).mockImplementation(async ({ method }) => {
if (method === 'lnd.bump-status') return { status: 'none' } as never
throw new Error('Not enough wallet change for 5000 sat/vB: at most 20126 sats is spendable for this bump. Try 79 sat/vB or lower.')
})
const { wrapper } = open(); await flushPromises()
expect(wrapper.text()).toContain('at most 20126 sats is spendable')
expect(wrapper.text()).toContain('Try 79 sat/vB or lower')
expect(wrapper.text()).not.toContain('Confirm bump')
expect(vi.mocked(rpcClient.call).mock.calls.some(([request]) => request.method === 'lnd.bump-submit')).toBe(false)
wrapper.unmount()
})
it('blocks expired quotes and duplicate clicks; never retries a mutation', async () => {
const { wrapper, vm } = open(); await flushPromises()
vm.quote.expires_at = 1; await vm.submit()