diff --git a/core/archipelago/src/api/rpc/lnd.rs b/core/archipelago/src/api/rpc/lnd.rs index 20b6174c..269561e9 100644 --- a/core/archipelago/src/api/rpc/lnd.rs +++ b/core/archipelago/src/api/rpc/lnd.rs @@ -938,6 +938,53 @@ impl RpcHandler { "grpc_port": 10009, })) } + + /// lnd.export-channel-backup — Export all channel static backups (SCB). + /// Returns base64-encoded multi-channel backup that can restore channels on a new node. + pub(super) async fn handle_lnd_export_channel_backup(&self) -> Result { + let macaroon_path = "/var/lib/archipelago/lnd/data/chain/bitcoin/mainnet/admin.macaroon"; + let macaroon_bytes = tokio::fs::read(macaroon_path) + .await + .context("Failed to read LND admin macaroon")?; + let macaroon_hex = hex::encode(&macaroon_bytes); + + let cert_path = "/var/lib/archipelago/lnd/tls.cert"; + let client = reqwest::Client::builder() + .danger_accept_invalid_certs(true) + .timeout(std::time::Duration::from_secs(10)) + .build() + .context("Failed to build HTTP client")?; + + let resp = client + .get("https://127.0.0.1:8080/v1/channels/backup") + .header("Grpc-Metadata-macaroon", &macaroon_hex) + .send() + .await + .context("Failed to reach LND REST API")?; + + if !resp.status().is_success() { + anyhow::bail!("LND returned {}", resp.status()); + } + + let data: serde_json::Value = resp.json().await.context("Invalid JSON from LND")?; + + // Extract the multi_chan_backup bytes + let backup_b64 = data + .get("multi_chan_backup") + .and_then(|m| m.get("multi_chan_backup")) + .and_then(|b| b.as_str()) + .unwrap_or(""); + + Ok(serde_json::json!({ + "backup": backup_b64, + "channel_count": data.get("multi_chan_backup") + .and_then(|m| m.get("chan_points")) + .and_then(|c| c.as_array()) + .map(|a| a.len()) + .unwrap_or(0), + "timestamp": chrono::Utc::now().to_rfc3339(), + })) + } } // Channel types diff --git a/core/archipelago/src/api/rpc/mod.rs b/core/archipelago/src/api/rpc/mod.rs index 78b899b2..850e7235 100644 --- a/core/archipelago/src/api/rpc/mod.rs +++ b/core/archipelago/src/api/rpc/mod.rs @@ -514,6 +514,7 @@ impl RpcHandler { "lnd.create-raw-tx" => self.handle_lnd_create_raw_tx(params).await, "lnd.gettransactions" => self.handle_lnd_gettransactions().await, "lnd.connect-info" => self.handle_lnd_connect_info().await, + "lnd.export-channel-backup" => self.handle_lnd_export_channel_backup().await, // Multi-identity management "identity.list" => self.handle_identity_list(params).await, diff --git a/neode-ui/src/views/Dashboard.vue b/neode-ui/src/views/Dashboard.vue index 43a760eb..cdea2b06 100644 --- a/neode-ui/src/views/Dashboard.vue +++ b/neode-ui/src/views/Dashboard.vue @@ -304,7 +304,9 @@ 'nav-tab-active': item.isCombined ? (item.path === '/dashboard/apps' ? (route.path.includes('/apps') || route.path.includes('/marketplace') || route.path.includes('/discover') || route.path.includes('/app-session')) - : (route.path.includes('/cloud') || route.path.includes('/server'))) + : item.path === '/dashboard/web5' + ? (route.path.includes('/web5') || route.path.includes('/federation') || route.path.includes('/mesh')) + : (route.path.includes('/cloud') || route.path.includes('/server'))) : undefined }" :exact-active-class="item.isCombined ? undefined : 'nav-tab-active'" diff --git a/neode-ui/src/views/Settings.vue b/neode-ui/src/views/Settings.vue index e185f980..170b373c 100644 --- a/neode-ui/src/views/Settings.vue +++ b/neode-ui/src/views/Settings.vue @@ -955,6 +955,26 @@ + +
+

Lightning Channel Backup

+

Export your channel state so you can restore channels on a new node. Does not include on-chain wallet seed.

+
+ +
+
+

{{ channelBackupChannels }} channel{{ channelBackupChannels !== 1 ? 's' : '' }} backed up at {{ channelBackupTime }}

+ + +
+

{{ channelBackupError }}

+
+
@@ -1439,6 +1459,40 @@ interface BackupEntry { } const backupList = ref([]) const loadingBackups = ref(false) + +// Lightning channel backup +const exportingChannelBackup = ref(false) +const channelBackupData = ref('') +const channelBackupChannels = ref(0) +const channelBackupTime = ref('') +const channelBackupError = ref('') +const channelBackupCopied = ref(false) + +async function exportChannelBackup() { + exportingChannelBackup.value = true + channelBackupError.value = '' + try { + const res = await rpcClient.call<{ backup: string; channel_count: number; timestamp: string }>({ + method: 'lnd.export-channel-backup', + timeout: 15000, + }) + channelBackupData.value = res.backup + channelBackupChannels.value = res.channel_count + channelBackupTime.value = new Date(res.timestamp).toLocaleString() + } catch (err: unknown) { + channelBackupError.value = err instanceof Error ? err.message : 'Failed to export' + } finally { + exportingChannelBackup.value = false + } +} + +function copyChannelBackup() { + if (channelBackupData.value) { + navigator.clipboard.writeText(channelBackupData.value).catch(() => {}) + channelBackupCopied.value = true + setTimeout(() => { channelBackupCopied.value = false }, 2000) + } +} const showCreateBackupModal = ref(false) const backupPassphrase = ref('') const backupDescription = ref('')