feat(wallet): on-chain send fee control + BTC/sats amount entry
Demo images / Build & push demo images (push) Successful in 2m55s

- sats/BTC unit toggle on the on-chain amount field with live conversion
  hint; canonical value stays sats end-to-end
- Fast / Standard / Slow fee presets (1 / 6 / 144 block targets) plus a
  custom pane taking target blocks or an explicit sat/vB rate
- confirm pane shows LND's fee estimate for the chosen speed via the new
  lnd.estimatefee RPC (GET /v1/transactions/fee)
- lnd.sendcoins now accepts target_conf / sat_per_vbyte with the same
  mutual-exclusion + range validation as channel opens

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
archipelago
2026-07-26 07:05:10 -04:00
co-authored by Claude Fable 5
parent a26090e561
commit c6f11f8ddb
5 changed files with 307 additions and 9 deletions
@@ -129,6 +129,7 @@ impl RpcHandler {
"lnd.closechannel" => self.handle_lnd_closechannel(params).await,
"lnd.newaddress" => self.handle_lnd_newaddress().await,
"lnd.sendcoins" => self.handle_lnd_sendcoins(params).await,
"lnd.estimatefee" => self.handle_lnd_estimatefee(params).await,
"lnd.createinvoice" => self.handle_lnd_createinvoice(params).await,
"lnd.payinvoice" => self.handle_lnd_payinvoice(params).await,
"lnd.create-psbt" => self.handle_lnd_create_psbt(params).await,
+109 -1
View File
@@ -124,16 +124,41 @@ impl RpcHandler {
return Err(anyhow::anyhow!("Invalid Bitcoin address format"));
}
// Fee control: either a confirmation target or an explicit fee rate
let target_conf = params.get("target_conf").and_then(|v| v.as_i64());
let sat_per_vbyte = params.get("sat_per_vbyte").and_then(|v| v.as_i64());
if target_conf.is_some() && sat_per_vbyte.is_some() {
return Err(anyhow::anyhow!(
"Invalid fee parameters: specify either target_conf or sat_per_vbyte, not both"
));
}
if let Some(tc) = target_conf {
if !(1..=1008).contains(&tc) {
return Err(anyhow::anyhow!(
"Invalid target_conf: must be between 1 and 1008 blocks"
));
}
}
if let Some(rate) = sat_per_vbyte {
if !(1..=5000).contains(&rate) {
return Err(anyhow::anyhow!(
"Invalid sat_per_vbyte: must be between 1 and 5000"
));
}
}
info!(
addr = addr,
amount = amount,
send_all = send_all,
target_conf = target_conf,
sat_per_vbyte = sat_per_vbyte,
"Sending on-chain Bitcoin"
);
let (client, macaroon_hex) = self.lnd_client().await?;
let send_body = match amount {
let mut send_body = match amount {
Some(amount) => serde_json::json!({
"addr": addr,
"amount": amount.to_string(),
@@ -143,6 +168,13 @@ impl RpcHandler {
"send_all": true,
}),
};
if let Some(tc) = target_conf {
send_body["target_conf"] = serde_json::json!(tc);
}
if let Some(rate) = sat_per_vbyte {
// LND REST encodes uint64 as a JSON string
send_body["sat_per_vbyte"] = serde_json::json!(rate.to_string());
}
let resp = client
.post(format!("{LND_REST_BASE_URL}/v1/transactions"))
@@ -171,6 +203,82 @@ impl RpcHandler {
Ok(serde_json::json!({ "txid": txid }))
}
/// Estimate the on-chain fee for sending `amount` sats to `addr` at a
/// confirmation target. Returns `{ fee_sat, sat_per_vbyte }` so the send
/// UI can show the cost of each preset before the user confirms.
pub(in crate::api::rpc) async fn handle_lnd_estimatefee(
&self,
params: Option<serde_json::Value>,
) -> Result<serde_json::Value> {
let params = params.unwrap_or_default();
let addr = params
.get("addr")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow::anyhow!("Missing 'addr' parameter"))?;
if addr.len() < 14 || addr.len() > 90 || !addr.chars().all(|c| c.is_ascii_alphanumeric()) {
return Err(anyhow::anyhow!("Invalid Bitcoin address format"));
}
let amount = params
.get("amount")
.and_then(|v| v.as_i64())
.ok_or_else(|| anyhow::anyhow!("Missing 'amount' parameter (sats)"))?;
if !(546..=21_000_000 * 100_000_000).contains(&amount) {
return Err(anyhow::anyhow!("Invalid amount"));
}
let target_conf = params
.get("target_conf")
.and_then(|v| v.as_i64())
.unwrap_or(6);
if !(1..=1008).contains(&target_conf) {
return Err(anyhow::anyhow!(
"Invalid target_conf: must be between 1 and 1008 blocks"
));
}
let (client, macaroon_hex) = self.lnd_client().await?;
let resp = client
.get(format!("{LND_REST_BASE_URL}/v1/transactions/fee"))
.query(&[
(format!("AddrToAmount[{addr}]"), amount.to_string()),
("target_conf".to_string(), target_conf.to_string()),
])
.header("Grpc-Metadata-macaroon", &macaroon_hex)
.send()
.await
.context("Failed to estimate fee")?;
let status = resp.status();
let body: serde_json::Value = resp
.json()
.await
.context("Failed to parse fee estimate response")?;
if !status.is_success() {
let msg = body
.get("message")
.and_then(|v| v.as_str())
.unwrap_or("Unknown error");
return Err(anyhow::anyhow!("Failed to estimate fee: {}", msg));
}
// LND REST encodes int64 as JSON strings
let fee_sat = body
.get("fee_sat")
.and_then(|v| v.as_str())
.and_then(|s| s.parse::<i64>().ok())
.unwrap_or(0);
let sat_per_vbyte = body
.get("sat_per_vbyte")
.and_then(|v| v.as_str())
.and_then(|s| s.parse::<i64>().ok())
.unwrap_or(0);
Ok(serde_json::json!({
"fee_sat": fee_sat,
"sat_per_vbyte": sat_per_vbyte,
}))
}
/// Create a Lightning invoice.
/// Create a Lightning invoice and return `(bolt11, payment_hash_hex)`.
///