diff --git a/core/archipelago/src/api/rpc/dispatcher.rs b/core/archipelago/src/api/rpc/dispatcher.rs index f68fc812..3e44de6c 100644 --- a/core/archipelago/src/api/rpc/dispatcher.rs +++ b/core/archipelago/src/api/rpc/dispatcher.rs @@ -133,6 +133,7 @@ impl RpcHandler { "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.invoicestatus" => self.handle_lnd_invoicestatus(params).await, "lnd.payinvoice" => self.handle_lnd_payinvoice(params).await, "lnd.paymentstatus" => self.handle_lnd_paymentstatus(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 52193e4a..147a7978 100644 --- a/core/archipelago/src/api/rpc/lnd/wallet.rs +++ b/core/archipelago/src/api/rpc/lnd/wallet.rs @@ -607,9 +607,77 @@ impl RpcHandler { .unwrap_or("") .to_string(); + // LND returns r_hash base64-encoded; the lookup endpoint the Receive + // flow polls (`lnd.invoicestatus`) wants it hex — hand the UI the + // ready-to-use form. + let r_hash_hex = { + use base64::Engine as _; + body.get("r_hash") + .and_then(|v| v.as_str()) + .and_then(|b64| base64::engine::general_purpose::STANDARD.decode(b64).ok()) + .map(hex::encode) + .unwrap_or_default() + }; + Ok(serde_json::json!({ "payment_request": payment_request, "amount_sats": amount_sats, + "r_hash_hex": r_hash_hex, + })) + } + + /// lnd.invoicestatus — is this invoice settled yet? Polled by the wallet's + /// Receive flow so a Lightning payment gets the same "money has arrived" + /// success screen as on-chain (minus the broadcast step: settlement is + /// final). Params: `{ "r_hash_hex": string }`. + pub(in crate::api::rpc) async fn handle_lnd_invoicestatus( + &self, + params: Option, + ) -> Result { + let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?; + let r_hash_hex = params + .get("r_hash_hex") + .and_then(|v| v.as_str()) + .ok_or_else(|| anyhow::anyhow!("Missing 'r_hash_hex' parameter"))?; + if r_hash_hex.len() != 64 || !r_hash_hex.chars().all(|c| c.is_ascii_hexdigit()) { + return Err(anyhow::anyhow!("r_hash_hex must be 64 hex characters")); + } + + let (client, macaroon_hex) = self.lnd_client().await?; + let resp = client + .get(format!("{LND_REST_BASE_URL}/v1/invoice/{r_hash_hex}")) + .header("Grpc-Metadata-macaroon", &macaroon_hex) + .send() + .await + .context("Failed to query invoice")?; + let status = resp.status(); + let body: serde_json::Value = resp + .json() + .await + .context("Failed to parse invoice lookup response")?; + if !status.is_success() { + let msg = body + .get("message") + .and_then(|v| v.as_str()) + .unwrap_or("Unknown error"); + return Err(anyhow::anyhow!("Invoice lookup failed: {}", msg)); + } + + let settled = body + .get("state") + .and_then(|v| v.as_str()) + .map(|s| s == "SETTLED") + .unwrap_or_else(|| body.get("settled").and_then(|v| v.as_bool()).unwrap_or(false)); + let amt_paid_sat = body + .get("amt_paid_sat") + .and_then(|v| v.as_str()) + .and_then(|s| s.parse::().ok()) + .or_else(|| body.get("amt_paid_sat").and_then(|v| v.as_i64())) + .unwrap_or(0); + + Ok(serde_json::json!({ + "settled": settled, + "amt_paid_sat": amt_paid_sat, })) } diff --git a/neode-ui/src/components/ReceiveBitcoinModal.vue b/neode-ui/src/components/ReceiveBitcoinModal.vue index 88294352..12728321 100644 --- a/neode-ui/src/components/ReceiveBitcoinModal.vue +++ b/neode-ui/src/components/ReceiveBitcoinModal.vue @@ -21,11 +21,25 @@ -
+ +
+
+
+ + + +
+
+

{{ t('receiveBitcoin.paymentConfirmed') }}

+

+ {{ invoicePaid.amountSats.toLocaleString() }} sats +

+
+
-

{{ t('receiveBitcoin.invoiceShareLabel') }}

-

{{ invoiceResult }}

- +

{{ t('receiveBitcoin.invoiceShareLabel') }}

+
@@ -147,6 +161,9 @@ watch(() => props.show, (open) => { return } paymentSeen.value = null + invoicePaid.value = null + invoiceRHash.value = '' + stopWatchingInvoice() // Blank slate on every open: a leftover amount/memo/token or a previous // invoice quietly carrying into a new receive flow is exactly the stale- // state class the operator flagged on the send modal (2026-08-05). @@ -238,6 +255,46 @@ async function checkForPayment() { onUnmounted(stopWatchingPayment) +// Lightning settlement watch — the on-chain pattern minus the broadcast +// step: an invoice is either open or SETTLED (final), so the success view +// goes straight to the green check. +const invoicePaid = ref(null) +const invoiceRHash = ref('') +let invoiceTimer: ReturnType | null = null + +function stopWatchingInvoice() { + if (invoiceTimer) { + clearInterval(invoiceTimer) + invoiceTimer = null + } +} + +function startWatchingInvoice() { + stopWatchingInvoice() + invoiceTimer = setInterval(() => void checkInvoice(), 3000) +} + +async function checkInvoice() { + if (!props.show || !invoiceRHash.value) { + stopWatchingInvoice() + return + } + try { + const res = await rpcClient.call<{ settled: boolean; amt_paid_sat: number }>({ + method: 'lnd.invoicestatus', + params: { r_hash_hex: invoiceRHash.value }, + }) + if (!res.settled) return + invoicePaid.value = { amountSats: res.amt_paid_sat || invoiceAmount.value } + stopWatchingInvoice() + emit('received') + } catch { + // Transient poll failure — keep watching. + } +} + +onUnmounted(stopWatchingInvoice) + async function renderQr(data: string, canvas: HTMLCanvasElement | null, prefix = '') { if (!canvas || !data) return try { @@ -272,11 +329,14 @@ async function receive() { // lnd.createinvoice fail with connection-refused (FED-08 follow-up). if (!(await lightning.requireLightningReady('receive'))) return if (!invoiceAmount.value) { error.value = t('receiveBitcoin.enterAnAmount'); return } - const res = await rpcClient.call<{ payment_request: string }>({ + const res = await rpcClient.call<{ payment_request: string; r_hash_hex?: string }>({ method: 'lnd.createinvoice', params: { amount_sats: invoiceAmount.value, memo: invoiceMemo.value || undefined }, }) invoiceResult.value = res.payment_request + invoiceRHash.value = res.r_hash_hex || '' + invoicePaid.value = null + if (invoiceRHash.value) startWatchingInvoice() nextTick(() => renderQr(res.payment_request, lightningQrCanvas.value, 'lightning:')) } else if (receiveMethod.value === 'onchain') { const res = await rpcClient.call<{ address: string }>({ method: 'lnd.newaddress' }) diff --git a/neode-ui/src/components/SendBitcoinModal.vue b/neode-ui/src/components/SendBitcoinModal.vue index e5fb240e..bd796a00 100644 --- a/neode-ui/src/components/SendBitcoinModal.vue +++ b/neode-ui/src/components/SendBitcoinModal.vue @@ -227,7 +227,7 @@

{{ ecashToken }}

- +
{{ error }}
@@ -604,10 +604,6 @@ function sendAnother() { error.value = '' } -function copyText(text: string) { - navigator.clipboard.writeText(text).catch(() => {}) -} - const tokenQrCanvas = ref(null) watch(ecashToken, async (token) => { if (!token) return diff --git a/neode-ui/src/components/WalletSettingsModal.vue b/neode-ui/src/components/WalletSettingsModal.vue index a9519d75..b7024ea7 100644 --- a/neode-ui/src/components/WalletSettingsModal.vue +++ b/neode-ui/src/components/WalletSettingsModal.vue @@ -202,11 +202,7 @@
{{ arkAddress }} - +
@@ -277,6 +273,7 @@