From c6f11f8ddb10e35e5b6bf601a75dc1197f741931 Mon Sep 17 00:00:00 2001 From: archipelago Date: Sun, 26 Jul 2026 07:05:10 -0400 Subject: [PATCH] feat(wallet): on-chain send fee control + BTC/sats amount entry - 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 --- core/archipelago/src/api/rpc/dispatcher.rs | 1 + core/archipelago/src/api/rpc/lnd/wallet.rs | 110 ++++++++++- docs/api-reference.md | 3 +- neode-ui/mock-backend.js | 7 + neode-ui/src/components/SendBitcoinModal.vue | 195 ++++++++++++++++++- 5 files changed, 307 insertions(+), 9 deletions(-) diff --git a/core/archipelago/src/api/rpc/dispatcher.rs b/core/archipelago/src/api/rpc/dispatcher.rs index 7a244db3..bd673ec6 100644 --- a/core/archipelago/src/api/rpc/dispatcher.rs +++ b/core/archipelago/src/api/rpc/dispatcher.rs @@ -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, diff --git a/core/archipelago/src/api/rpc/lnd/wallet.rs b/core/archipelago/src/api/rpc/lnd/wallet.rs index 286f84b5..df0bab3b 100644 --- a/core/archipelago/src/api/rpc/lnd/wallet.rs +++ b/core/archipelago/src/api/rpc/lnd/wallet.rs @@ -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, + ) -> Result { + 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::().ok()) + .unwrap_or(0); + let sat_per_vbyte = body + .get("sat_per_vbyte") + .and_then(|v| v.as_str()) + .and_then(|s| s.parse::().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)`. /// diff --git a/docs/api-reference.md b/docs/api-reference.md index 50bc437f..785ec331 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -162,7 +162,8 @@ All endpoints use JSON-RPC over HTTP POST to `/rpc/v1`. | `lnd.openchannel` | `{ pubkey: string, amount: number }` | `{ funding_txid: string }` | Yes | | `lnd.closechannel` | `{ channel_point: string }` | `{ closing_txid: string }` | Yes | | `lnd.newaddress` | — | `{ address: string }` | Yes | -| `lnd.sendcoins` | `{ addr: string, amount: number }` | `{ txid: string }` | Yes | +| `lnd.sendcoins` | `{ addr: string, amount?: number, send_all?: bool, target_conf?: number, sat_per_vbyte?: number }` | `{ txid: string }` | Yes | +| `lnd.estimatefee` | `{ addr: string, amount: number, target_conf?: number }` | `{ fee_sat: number, sat_per_vbyte: number }` | Yes | | `lnd.createinvoice` | `{ amount: number, memo?: string }` | `{ payment_request: string }` | Yes | | `lnd.payinvoice` | `{ payment_request: string }` | `{ preimage: string }` | Yes | | `lnd.create-psbt` | `{ outputs: object, ... }` | `{ psbt: string }` | Yes | diff --git a/neode-ui/mock-backend.js b/neode-ui/mock-backend.js index 39360015..adda7e00 100755 --- a/neode-ui/mock-backend.js +++ b/neode-ui/mock-backend.js @@ -3532,6 +3532,13 @@ app.post('/rpc/v1', (req, res) => { }) } + case 'lnd.estimatefee': { + // ~141 vB P2WPKH spend at a rate that scales with urgency + const target = params?.target_conf || 6 + const rate = target <= 1 ? 22 : target <= 6 ? 8 : 2 + return res.json({ result: { fee_sat: rate * 141, sat_per_vbyte: rate } }) + } + case 'lnd.decodepayreq': { return res.json({ result: { diff --git a/neode-ui/src/components/SendBitcoinModal.vue b/neode-ui/src/components/SendBitcoinModal.vue index c1474628..ca025fd1 100644 --- a/neode-ui/src/components/SendBitcoinModal.vue +++ b/neode-ui/src/components/SendBitcoinModal.vue @@ -78,6 +78,10 @@ −{{ confirmAmount.toLocaleString() }} sats +
+ Network fee + {{ feeEstimateLabel }} +
Balance after @@ -117,7 +121,19 @@
- +
+ + +
+ +
+
set by invoice
@@ -147,6 +164,7 @@

Zero-amount invoice — enter how many sats to pay.

+

{{ unitConversionHint }}

@@ -165,6 +183,48 @@
+ +
+ +
+ +
+

+ {{ onchainFeePresets.find(p => p.key === feePreset)?.hint }} +

+
+
+ + +
+
+ + +
+

Set one — sats per vByte takes precedence when both are set

+
+
+

{{ t('sendBitcoin.tokenShareLabel') }}