From 8e1e84ebfb3b7dd9775f70751a43dbb28a1dc625 Mon Sep 17 00:00:00 2001 From: Ihor Diachenko Date: Tue, 28 Apr 2026 19:13:37 +0300 Subject: [PATCH 01/12] gl-client: initial backup implementation --- libs/gl-client/src/persist.rs | 18 +- libs/gl-client/src/signer/backup.rs | 306 ++++++++++++++++++++++++++++ libs/gl-client/src/signer/mod.rs | 138 ++++++++++--- 3 files changed, 438 insertions(+), 24 deletions(-) create mode 100644 libs/gl-client/src/signer/backup.rs diff --git a/libs/gl-client/src/persist.rs b/libs/gl-client/src/persist.rs index dbbc37ed0..080c38a86 100644 --- a/libs/gl-client/src/persist.rs +++ b/libs/gl-client/src/persist.rs @@ -15,7 +15,7 @@ use log::{trace, warn}; use serde::de::{self, SeqAccess, Visitor}; use serde::ser::SerializeSeq; use serde::{Deserialize, Deserializer, Serialize, Serializer}; -use std::collections::BTreeMap; +use std::collections::{BTreeMap, BTreeSet}; use std::fmt; use std::str::FromStr; use std::sync::Arc; @@ -530,6 +530,22 @@ impl State { .collect(); State { values } } + + pub(crate) fn recoverable_channel_keys(&self) -> BTreeSet { + self.values + .iter() + .filter(|(_, value)| value.version != TOMBSTONE_VERSION) + .filter(|(key, _)| key.starts_with(&format!("{CHANNEL_PREFIX}/"))) + .filter(|(_, value)| { + value + .value + .get("channel_setup") + .map(|setup| !setup.is_null()) + .unwrap_or(false) + }) + .map(|(key, _)| key.clone()) + .collect() + } } #[derive(Clone, Serialize, Deserialize, Debug, Default)] diff --git a/libs/gl-client/src/signer/backup.rs b/libs/gl-client/src/signer/backup.rs new file mode 100644 index 000000000..8811e647a --- /dev/null +++ b/libs/gl-client/src/signer/backup.rs @@ -0,0 +1,306 @@ +use super::{SignerBackupConfig, SignerBackupStrategy}; +use crate::persist::State; +use anyhow::{anyhow, Context, Result}; +use serde::{Deserialize, Serialize}; +use std::io::Write; +use std::path::Path; + +const BACKUP_VERSION: u32 = 1; +const PEERLIST_PREFIX: [&str; 2] = ["greenlight", "peerlist"]; + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub(crate) struct PeerlistEntry { + pub peer_id: String, + pub addr: String, + pub direction: String, + pub features: String, + pub generation: Option, + pub raw_datastore_string: String, +} + +#[derive(Deserialize)] +struct PeerRecord { + id: String, + direction: String, + addr: String, + features: String, +} + +#[derive(Serialize)] +struct BackupSnapshot { + version: u32, + created_at: String, + node_id: String, + strategy: SignerBackupStrategy, + state: State, + peerlist: Vec, +} + +pub(crate) fn should_snapshot_new_channels(before: &State, after: &State) -> bool { + let before_channels = before.recoverable_channel_keys(); + after + .recoverable_channel_keys() + .iter() + .any(|key| !before_channels.contains(key)) +} + +pub(crate) fn should_snapshot( + strategy: SignerBackupStrategy, + before: &State, + after: &State, +) -> bool { + match strategy { + SignerBackupStrategy::NewChannelsOnly => should_snapshot_new_channels(before, after), + } +} + +pub(crate) fn parse_peerlist( + entries: &[crate::pb::cln::ListdatastoreDatastore], +) -> Result> { + let mut peers = entries + .iter() + .map(parse_peerlist_entry) + .collect::>>()?; + peers.sort_by(|a, b| a.peer_id.cmp(&b.peer_id)); + Ok(peers) +} + +pub(crate) fn write_snapshot( + config: &SignerBackupConfig, + node_id: &[u8], + state: State, + peerlist: Vec, +) -> Result<()> { + let snapshot = BackupSnapshot { + version: BACKUP_VERSION, + created_at: chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Secs, true), + node_id: hex::encode(node_id), + strategy: config.strategy, + state, + peerlist, + }; + + let dir = backup_dir(&config.path); + let mut tmp = tempfile::NamedTempFile::new_in(dir) + .with_context(|| format!("creating temporary backup file in {}", dir.display()))?; + + serde_json::to_writer_pretty(&mut tmp, &snapshot) + .with_context(|| format!("serializing backup snapshot for {}", config.path.display()))?; + tmp.write_all(b"\n") + .with_context(|| format!("finalizing backup snapshot for {}", config.path.display()))?; + tmp.as_file_mut() + .sync_all() + .with_context(|| format!("syncing backup snapshot for {}", config.path.display()))?; + tmp.persist(&config.path).map_err(|e| { + anyhow!( + "persisting backup snapshot to {}: {}", + config.path.display(), + e + ) + })?; + + Ok(()) +} + +fn backup_dir(path: &Path) -> &Path { + path.parent() + .filter(|parent| !parent.as_os_str().is_empty()) + .unwrap_or_else(|| Path::new(".")) +} + +fn parse_peerlist_entry(entry: &crate::pb::cln::ListdatastoreDatastore) -> Result { + if entry.key.len() != 3 + || entry.key[0] != PEERLIST_PREFIX[0] + || entry.key[1] != PEERLIST_PREFIX[1] + { + return Err(anyhow!("invalid peerlist datastore key: {:?}", entry.key)); + } + + let key_peer_id = &entry.key[2]; + let raw = entry + .string + .as_ref() + .ok_or_else(|| anyhow!("peerlist entry {} is missing string payload", key_peer_id))?; + let peer = parse_peer_record(raw) + .with_context(|| format!("parsing peerlist entry {}", key_peer_id))?; + + if peer.id != *key_peer_id { + return Err(anyhow!( + "peerlist key {} does not match payload id {}", + key_peer_id, + peer.id + )); + } + + Ok(PeerlistEntry { + peer_id: peer.id, + addr: peer.addr, + direction: peer.direction, + features: peer.features, + generation: entry.generation, + raw_datastore_string: raw.clone(), + }) +} + +fn parse_peer_record(raw: &str) -> Result { + serde_json::from_str(raw).or_else(|first_error| { + let cleaned = raw.replace('\\', ""); + serde_json::from_str(&cleaned) + .with_context(|| format!("invalid peer JSON; original parse error: {}", first_error)) + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + fn state(entries: serde_json::Value) -> State { + serde_json::from_value(json!({ "values": entries })).unwrap() + } + + fn channel(setup: serde_json::Value) -> serde_json::Value { + json!({ + "channel_setup": setup, + "channel_value_satoshis": 1000, + "id": null, + "enforcement_state": {}, + "blockheight": null + }) + } + + fn peer_entry( + key: Vec<&str>, + generation: Option, + string: Option<&str>, + ) -> crate::pb::cln::ListdatastoreDatastore { + crate::pb::cln::ListdatastoreDatastore { + key: key.into_iter().map(str::to_owned).collect(), + generation, + hex: None, + string: string.map(str::to_owned), + } + } + + #[test] + fn new_channel_trigger_requires_channel_setup() { + let empty = state(json!({})); + let stub = state(json!({ + "channels/a": [0, channel(serde_json::Value::Null)] + })); + let ready = state(json!({ + "channels/a": [1, channel(json!({ "funding_outpoint": { "txid": "00", "vout": 0 } }))] + })); + let ready_updated = state(json!({ + "channels/a": [2, channel(json!({ "funding_outpoint": { "txid": "00", "vout": 0 } }))] + })); + let second_ready = state(json!({ + "channels/a": [2, channel(json!({ "funding_outpoint": { "txid": "00", "vout": 0 } }))], + "channels/b": [0, channel(json!({ "funding_outpoint": { "txid": "11", "vout": 1 } }))] + })); + + assert!(!should_snapshot_new_channels(&empty, &empty)); + assert!(!should_snapshot_new_channels(&empty, &stub)); + assert!(should_snapshot_new_channels(&stub, &ready)); + assert!(!should_snapshot_new_channels(&ready, &ready_updated)); + assert!(should_snapshot_new_channels(&ready_updated, &second_ready)); + } + + #[test] + fn parse_peerlist_normalizes_valid_entries() { + let raw = r#"{"id":"02aa","direction":"out","addr":"127.0.0.1:9735","features":"abcd"}"#; + let peers = parse_peerlist(&[peer_entry( + vec!["greenlight", "peerlist", "02aa"], + Some(7), + Some(raw), + )]) + .unwrap(); + + assert_eq!( + peers, + vec![PeerlistEntry { + peer_id: "02aa".to_string(), + addr: "127.0.0.1:9735".to_string(), + direction: "out".to_string(), + features: "abcd".to_string(), + generation: Some(7), + raw_datastore_string: raw.to_string(), + }] + ); + } + + #[test] + fn parse_peerlist_rejects_malformed_entries() { + assert!(parse_peerlist(&[peer_entry( + vec!["greenlight", "peerlist", "02aa"], + None, + Some("not-json"), + )]) + .is_err()); + + assert!(parse_peerlist(&[peer_entry( + vec!["greenlight", "peerlist", "02aa"], + None, + Some(r#"{"id":"02aa","direction":"out","features":""}"#), + )]) + .is_err()); + + assert!(parse_peerlist(&[peer_entry( + vec!["greenlight", "wrong", "02aa"], + None, + Some(r#"{"id":"02aa","direction":"out","addr":"127.0.0.1:9735","features":""}"#), + )]) + .is_err()); + } + + #[test] + fn write_snapshot_includes_state_peerlist_and_omits_tombstones() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("backup.json"); + let config = SignerBackupConfig::new(path.clone()); + let state = state(json!({ + "channels/a": [0, channel(json!({ "funding_outpoint": { "txid": "00", "vout": 0 } }))], + "channels/deleted": [u64::MAX, null] + })) + .omit_tombstones(); + let peerlist = vec![PeerlistEntry { + peer_id: "02aa".to_string(), + addr: "127.0.0.1:9735".to_string(), + direction: "out".to_string(), + features: "".to_string(), + generation: Some(3), + raw_datastore_string: + r#"{"id":"02aa","direction":"out","addr":"127.0.0.1:9735","features":""}"# + .to_string(), + }]; + + write_snapshot(&config, &[2u8; 33], state, peerlist).unwrap(); + + let written: serde_json::Value = + serde_json::from_slice(&std::fs::read(path).unwrap()).unwrap(); + assert_eq!(written["version"], 1); + assert_eq!(written["node_id"], hex::encode([2u8; 33])); + assert_eq!(written["strategy"], "new_channels_only"); + assert!(written["state"]["values"]["channels/a"].is_array()); + assert!(written["state"]["values"] + .as_object() + .unwrap() + .get("channels/deleted") + .is_none()); + assert_eq!(written["peerlist"][0]["peer_id"], "02aa"); + assert_eq!(written["peerlist"][0]["generation"], 3); + assert!(written["peerlist"][0]["raw_datastore_string"] + .as_str() + .unwrap() + .contains("\"addr\"")); + } + + #[test] + fn write_snapshot_fails_when_parent_is_missing() { + let dir = tempfile::tempdir().unwrap(); + let config = SignerBackupConfig::new(dir.path().join("missing").join("backup.json")); + let state = state(json!({})); + + assert!(write_snapshot(&config, &[2u8; 33], state, vec![]).is_err()); + } +} diff --git a/libs/gl-client/src/signer/mod.rs b/libs/gl-client/src/signer/mod.rs index 4289fe569..c16beb496 100644 --- a/libs/gl-client/src/signer/mod.rs +++ b/libs/gl-client/src/signer/mod.rs @@ -31,13 +31,14 @@ use ring::digest::{digest, SHA256}; use ring::signature::{UnparsedPublicKey, ECDSA_P256_SHA256_FIXED}; use runeauth::{Condition, Restriction, Rune, RuneError}; use std::convert::{TryFrom, TryInto}; +use std::path::PathBuf; use std::sync::Arc; use std::sync::Mutex; use std::time::SystemTime; use tokio::sync::mpsc; use tokio::time::{sleep, Duration}; use tokio_stream::wrappers::ReceiverStream; -use tonic::transport::{Endpoint, Uri}; +use tonic::transport::{Channel, Endpoint, Uri}; use tonic::{Code, Request}; use vls_protocol::msgs::{DeBolt, HsmdInitReplyV4}; use vls_protocol::serde_bolt::Octets; @@ -47,6 +48,7 @@ use vls_protocol_signer::handler::Handler; mod approver; mod auth; +mod backup; pub mod model; mod report; mod resolve; @@ -84,6 +86,28 @@ pub struct StateSignatureOverrideConfig { pub struct SignerConfig { pub state_signature_mode: StateSignatureMode, pub state_signature_override: Option, + pub backup: Option, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct SignerBackupConfig { + pub path: PathBuf, + pub strategy: SignerBackupStrategy, +} + +impl SignerBackupConfig { + pub fn new(path: impl Into) -> Self { + Self { + path: path.into(), + strategy: SignerBackupStrategy::NewChannelsOnly, + } + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize)] +#[serde(rename_all = "snake_case")] +pub enum SignerBackupStrategy { + NewChannelsOnly, } #[derive(Debug, Default)] @@ -116,6 +140,7 @@ pub struct Signer { network: Network, state: Arc>, + backup: Option, } #[derive(thiserror::Error, Debug)] @@ -179,6 +204,7 @@ impl Signer { info!("Initializing signer for {VERSION} ({GITHASH}) (VLS)"); let state_signature_mode = config.state_signature_mode; + let backup = config.backup.clone(); let (state_signature_override_enabled, state_signature_override_note) = match config.state_signature_override { Some(override_config) => { @@ -323,6 +349,7 @@ impl Signer { init, network, state: persister.state(), + backup, }) } @@ -658,7 +685,8 @@ impl Signer { .keep_alive_while_idle(true) .connect_lazy(); - let mut client = NodeClient::new(c); + let mut client = NodeClient::new(c.clone()); + let mut peerlist_client = self.backup_peerlist_client(c)?; let mut stream = client .stream_hsm_requests(Request::new(Empty::default())) @@ -683,7 +711,10 @@ impl Signer { let signer_state = req.signer_state.clone(); trace!("Received request {}", hex_req); - match self.process_request(req.clone()).await { + match self + .process_request(req.clone(), peerlist_client.as_mut()) + .await + { Ok(response) => { trace!("Sending response {}", hex::encode(&response.raw)); client @@ -722,6 +753,23 @@ impl Signer { } } + fn backup_peerlist_client(&self, channel: Channel) -> Result, Error> { + if self.backup.is_none() { + return Ok(None); + } + + let private_key = self + .tls + .private_key + .clone() + .ok_or_else(|| Error::Other(anyhow!("missing TLS private key for CLN auth")))?; + let auth = node::service::AuthLayer::new(private_key, self.master_rune.to_base64()) + .map_err(Error::Other)?; + let service = tower::ServiceBuilder::new().layer(auth).service(channel); + + Ok(Some(node::ClnClient::new(service))) + } + fn authenticate_request( &self, msg: &vls_protocol::msgs::Message, @@ -739,7 +787,11 @@ impl Signer { Ok(()) } - async fn process_request(&self, req: HsmRequest) -> Result { + async fn process_request( + &self, + req: HsmRequest, + mut node_client: Option<&mut crate::node::ClnClient>, + ) -> Result { debug!("Processing request {:?}", req); let req = req; @@ -758,7 +810,7 @@ impl Signer { // including the initial VLS state created during Signer::new(). let prestate_sketch = incoming_state.sketch(); - let prestate_log = { + let (prestate_log, pre_backup_state) = { debug!("Updating local signer state with state from node"); let mut state = self.state.lock().map_err(|e| { Error::Other(anyhow!("Failed to acquire state lock: {:?}", e)) @@ -773,9 +825,11 @@ impl Signer { ); } trace!("Processing request {}", hex::encode(&req.raw)); - serde_json::to_string(&*state).map_err(|e| { + let pre_backup_state = state.clone(); + let prestate_log = serde_json::to_string(&*state).map_err(|e| { Error::Other(anyhow!("Failed to serialize signer state for logging: {:?}", e)) - })? + })?; + (prestate_log, pre_backup_state) }; // The first two bytes represent the message type. Check that @@ -905,7 +959,10 @@ impl Signer { } }; - let signer_state: Vec = { + let (signer_state, pending_backup): ( + Vec, + Option<(SignerBackupConfig, crate::persist::State)>, + ) = { debug!("Serializing state changes to report to node"); let mut state = self.state.lock().map_err(|e| { Error::Other(anyhow!( @@ -918,6 +975,11 @@ impl Signer { self.sign_state_payload(key, version, value) }) .map_err(|e| Error::Other(anyhow!("Failed to sign signer state entries: {e}")))?; + let final_state = state.clone(); + let pending_backup = self.backup.as_ref().and_then(|config| { + backup::should_snapshot(config.strategy, &pre_backup_state, &final_state) + .then(|| (config.clone(), final_state.omit_tombstones())) + }); let full_wire_bytes = { let full_entries: Vec = state.clone().into(); signer_state_response_wire_bytes(&full_entries) @@ -933,8 +995,20 @@ impl Signer { full_wire_bytes, saved_percent ); - diff_entries + (diff_entries, pending_backup) }; + if let Some((backup_config, backup_state)) = pending_backup { + let peerlist = match node_client.as_mut() { + Some(client) => Self::fetch_backup_peerlist(*client).await?, + None => { + return Err(Error::Other(anyhow!( + "backup snapshot is due but no node client is available to refresh peerlist" + ))) + } + }; + backup::write_snapshot(&backup_config, &self.id, backup_state, peerlist) + .map_err(|e| Error::Other(anyhow!("failed to write signer backup: {e}")))?; + } Ok(HsmResponse { raw: response.as_vec(), request_id: req.request_id, @@ -943,6 +1017,20 @@ impl Signer { }) } + async fn fetch_backup_peerlist( + client: &mut crate::node::ClnClient, + ) -> Result, Error> { + let response = client + .list_datastore(Request::new(crate::pb::cln::ListdatastoreRequest { + key: vec!["greenlight".to_string(), "peerlist".to_string()], + })) + .await + .map_err(|e| Error::Other(anyhow!("failed to refresh backup peerlist: {e}")))?; + + backup::parse_peerlist(&response.into_inner().datastore) + .map_err(|e| Error::Other(anyhow!("failed to parse backup peerlist: {e}"))) + } + pub fn node_id(&self) -> Vec { self.id.clone() } @@ -1602,6 +1690,7 @@ mod tests { SignerConfig { state_signature_mode: mode, state_signature_override: None, + backup: None, }, ) .unwrap() @@ -1615,6 +1704,7 @@ mod tests { SignerConfig { state_signature_mode: mode, state_signature_override: Some(test_override_config(note)), + backup: None, }, ) .unwrap() @@ -1655,7 +1745,7 @@ mod tests { raw: msg, signer_state: vec![], requests: Vec::new(), - },) + }, None) .await .is_err()); } @@ -1678,7 +1768,7 @@ mod tests { raw: vec![], signer_state: vec![], requests: Vec::new(), - },) + }, None) .await .unwrap_err() .to_string(), @@ -1707,7 +1797,7 @@ mod tests { signer_state: vec![mk_state_entry(&key, 1, json!({"v": 1}))], requests: vec![], }; - let response = signer.process_request(req).await.unwrap(); + let response = signer.process_request(req, None).await.unwrap(); let repaired = response .signer_state .iter() @@ -1728,7 +1818,7 @@ mod tests { raw: heartbeat_raw(), signer_state: vec![entry], requests: vec![], - }) + }, None) .await .unwrap_err() .to_string(); @@ -1745,7 +1835,7 @@ mod tests { raw: heartbeat_raw(), signer_state: vec![mk_state_entry("state/test", 1, json!({"v": 1}))], requests: vec![], - }) + }, None) .await .unwrap_err() .to_string(); @@ -1764,7 +1854,7 @@ mod tests { raw: heartbeat_raw(), signer_state: vec![entry], requests: vec![], - }) + }, None) .await .unwrap_err() .to_string(); @@ -1786,7 +1876,7 @@ mod tests { raw: heartbeat_raw(), signer_state: vec![entry], requests: vec![], - }) + }, None) .await; assert!(res.is_ok()); } @@ -1804,7 +1894,7 @@ mod tests { raw: heartbeat_raw(), signer_state: vec![invalid, missing], requests: vec![], - }) + }, None) .await; assert!(res.is_ok()); } @@ -1822,7 +1912,7 @@ mod tests { raw: heartbeat_raw(), signer_state: vec![invalid1], requests: vec![], - }) + }, None) .await .unwrap(); let snapshot1: Vec = { @@ -1841,7 +1931,7 @@ mod tests { raw: heartbeat_raw(), signer_state: vec![invalid2], requests: vec![], - }) + }, None) .await; assert!(res2.is_ok()); @@ -1865,7 +1955,7 @@ mod tests { raw: heartbeat_raw(), signer_state: vec![mk_state_entry(key, 1, json!({"v": request_id}))], requests: vec![], - }) + }, None) .await .unwrap(); let snapshot: Vec = { @@ -1890,7 +1980,7 @@ mod tests { raw: heartbeat_raw(), signer_state: vec![invalid], requests: vec![], - }) + }, None) .await .unwrap(); @@ -1923,7 +2013,7 @@ mod tests { raw: heartbeat_raw(), signer_state: vec![valid.clone(), invalid], requests: vec![], - }) + }, None) .await .unwrap(); @@ -1951,6 +2041,7 @@ mod tests { SignerConfig { state_signature_mode: StateSignatureMode::Off, state_signature_override: Some(test_override_config(Some("test"))), + backup: None, }, ); let err = signer.err().unwrap().to_string(); @@ -1969,6 +2060,7 @@ mod tests { ack: "WRONG".to_string(), note: None, }), + backup: None, }, ); let err = signer.err().unwrap().to_string(); @@ -1991,7 +2083,7 @@ mod tests { raw: heartbeat_raw(), signer_state: vec![entry], requests: vec![], - }) + }, None) .await .unwrap_err() .to_string(); From 9f11d61df0df519fe7737f0660f124fc78f356d2 Mon Sep 17 00:00:00 2001 From: Ihor Diachenko Date: Wed, 29 Apr 2026 18:32:56 +0300 Subject: [PATCH 02/12] gl-client: add periodic signer backups --- libs/gl-client/src/signer/backup.rs | 128 ++++++++++++++++++++++++++-- libs/gl-client/src/signer/mod.rs | 127 ++++++++++++++++++++++----- 2 files changed, 226 insertions(+), 29 deletions(-) diff --git a/libs/gl-client/src/signer/backup.rs b/libs/gl-client/src/signer/backup.rs index 8811e647a..15cece2f4 100644 --- a/libs/gl-client/src/signer/backup.rs +++ b/libs/gl-client/src/signer/backup.rs @@ -36,6 +36,50 @@ struct BackupSnapshot { peerlist: Vec, } +#[derive(Default)] +pub(crate) struct BackupRuntime { + updates_since_backup: u32, + snapshot_pending: bool, + last_backed_state: Option, +} + +impl BackupRuntime { + pub(crate) fn observe( + &mut self, + strategy: SignerBackupStrategy, + before: &State, + after: &State, + ) -> bool { + if should_snapshot_new_channels(before, after) { + self.snapshot_pending = true; + } + + if let SignerBackupStrategy::Periodic { updates } = strategy { + if has_recoverable_state_update(before, after) { + self.updates_since_backup = self.updates_since_backup.saturating_add(1); + if updates > 0 && self.updates_since_backup >= updates { + self.snapshot_pending = true; + } + } + } + + self.snapshot_pending && self.has_unbacked_recoverable_state(after) + } + + pub(crate) fn snapshot_succeeded(&mut self, state: &State) { + self.updates_since_backup = 0; + self.snapshot_pending = false; + self.last_backed_state = Some(state.clone()); + } + + fn has_unbacked_recoverable_state(&self, state: &State) -> bool { + self.last_backed_state + .as_ref() + .map(|last| has_recoverable_state_update(last, state)) + .unwrap_or(true) + } +} + pub(crate) fn should_snapshot_new_channels(before: &State, after: &State) -> bool { let before_channels = before.recoverable_channel_keys(); after @@ -44,14 +88,8 @@ pub(crate) fn should_snapshot_new_channels(before: &State, after: &State) -> boo .any(|key| !before_channels.contains(key)) } -pub(crate) fn should_snapshot( - strategy: SignerBackupStrategy, - before: &State, - after: &State, -) -> bool { - match strategy { - SignerBackupStrategy::NewChannelsOnly => should_snapshot_new_channels(before, after), - } +fn has_recoverable_state_update(before: &State, after: &State) -> bool { + !before.diff_state(after).recoverable_channel_keys().is_empty() } pub(crate) fn parse_peerlist( @@ -206,6 +244,67 @@ mod tests { assert!(should_snapshot_new_channels(&ready_updated, &second_ready)); } + #[test] + fn runtime_retries_new_channel_snapshot_until_success() { + let stub = state(json!({ + "channels/a": [0, channel(serde_json::Value::Null)] + })); + let ready = state(json!({ + "channels/a": [1, channel(json!({ "funding_outpoint": { "txid": "00", "vout": 0 } }))] + })); + let mut runtime = BackupRuntime::default(); + + assert!(runtime.observe(SignerBackupStrategy::NewChannelsOnly, &stub, &ready)); + assert!(runtime.observe(SignerBackupStrategy::NewChannelsOnly, &ready, &ready)); + + runtime.snapshot_succeeded(&ready); + + assert!(!runtime.observe(SignerBackupStrategy::NewChannelsOnly, &ready, &ready)); + } + + #[test] + fn periodic_trigger_counts_recoverable_channel_updates() { + let ready = state(json!({ + "channels/a": [1, channel(json!({ "funding_outpoint": { "txid": "00", "vout": 0 } }))] + })); + let updated_once = state(json!({ + "channels/a": [2, channel(json!({ "funding_outpoint": { "txid": "00", "vout": 0 } }))] + })); + let updated_twice = state(json!({ + "channels/a": [3, channel(json!({ "funding_outpoint": { "txid": "00", "vout": 0 } }))] + })); + let mut runtime = BackupRuntime::default(); + let strategy = SignerBackupStrategy::Periodic { updates: 2 }; + runtime.snapshot_succeeded(&ready); + + assert!(!runtime.observe(strategy, &ready, &updated_once)); + assert!(runtime.observe(strategy, &updated_once, &updated_twice)); + assert!(runtime.observe(strategy, &updated_twice, &updated_twice)); + + runtime.snapshot_succeeded(&updated_twice); + + assert!(!runtime.observe(strategy, &updated_twice, &updated_twice)); + } + + #[test] + fn periodic_trigger_writes_new_channels_immediately() { + let ready = state(json!({ + "channels/a": [1, channel(json!({ "funding_outpoint": { "txid": "00", "vout": 0 } }))] + })); + let second_ready = state(json!({ + "channels/a": [1, channel(json!({ "funding_outpoint": { "txid": "00", "vout": 0 } }))], + "channels/b": [1, channel(json!({ "funding_outpoint": { "txid": "11", "vout": 1 } }))] + })); + let mut runtime = BackupRuntime::default(); + runtime.snapshot_succeeded(&ready); + + assert!(runtime.observe( + SignerBackupStrategy::Periodic { updates: 100 }, + &ready, + &second_ready + )); + } + #[test] fn parse_peerlist_normalizes_valid_entries() { let raw = r#"{"id":"02aa","direction":"out","addr":"127.0.0.1:9735","features":"abcd"}"#; @@ -303,4 +402,17 @@ mod tests { assert!(write_snapshot(&config, &[2u8; 33], state, vec![]).is_err()); } + + #[test] + fn write_snapshot_serializes_periodic_strategy() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("backup.json"); + let config = SignerBackupConfig::periodic(path.clone(), 5).unwrap(); + + write_snapshot(&config, &[2u8; 33], state(json!({})), vec![]).unwrap(); + + let written: serde_json::Value = + serde_json::from_slice(&std::fs::read(path).unwrap()).unwrap(); + assert_eq!(written["strategy"]["periodic"]["updates"], 5); + } } diff --git a/libs/gl-client/src/signer/mod.rs b/libs/gl-client/src/signer/mod.rs index c16beb496..f971357bc 100644 --- a/libs/gl-client/src/signer/mod.rs +++ b/libs/gl-client/src/signer/mod.rs @@ -102,12 +102,38 @@ impl SignerBackupConfig { strategy: SignerBackupStrategy::NewChannelsOnly, } } + + pub fn periodic(path: impl Into, updates: u32) -> Result { + let config = Self { + path: path.into(), + strategy: SignerBackupStrategy::Periodic { updates }, + }; + config.validate()?; + Ok(config) + } + + fn validate(&self) -> Result<()> { + self.strategy.validate() + } } #[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize)] #[serde(rename_all = "snake_case")] pub enum SignerBackupStrategy { NewChannelsOnly, + Periodic { updates: u32 }, +} + +impl SignerBackupStrategy { + fn validate(&self) -> Result<()> { + match self { + SignerBackupStrategy::NewChannelsOnly => Ok(()), + SignerBackupStrategy::Periodic { updates: 0 } => { + Err(anyhow!("periodic signer backup updates must be greater than zero")) + } + SignerBackupStrategy::Periodic { .. } => Ok(()), + } + } } #[derive(Debug, Default)] @@ -141,6 +167,7 @@ pub struct Signer { network: Network, state: Arc>, backup: Option, + backup_runtime: Arc>, } #[derive(thiserror::Error, Debug)] @@ -204,6 +231,9 @@ impl Signer { info!("Initializing signer for {VERSION} ({GITHASH}) (VLS)"); let state_signature_mode = config.state_signature_mode; + if let Some(backup) = &config.backup { + backup.validate()?; + } let backup = config.backup.clone(); let (state_signature_override_enabled, state_signature_override_note) = match config.state_signature_override { @@ -350,6 +380,7 @@ impl Signer { network, state: persister.state(), backup, + backup_runtime: Arc::new(Mutex::new(backup::BackupRuntime::default())), }) } @@ -686,7 +717,7 @@ impl Signer { .connect_lazy(); let mut client = NodeClient::new(c.clone()); - let mut peerlist_client = self.backup_peerlist_client(c)?; + let mut peerlist_client = self.backup_peerlist_client(c); let mut stream = client .stream_hsm_requests(Request::new(Empty::default())) @@ -753,21 +784,25 @@ impl Signer { } } - fn backup_peerlist_client(&self, channel: Channel) -> Result, Error> { + fn backup_peerlist_client(&self, channel: Channel) -> Option { if self.backup.is_none() { - return Ok(None); + return None; } - let private_key = self - .tls - .private_key - .clone() - .ok_or_else(|| Error::Other(anyhow!("missing TLS private key for CLN auth")))?; - let auth = node::service::AuthLayer::new(private_key, self.master_rune.to_base64()) - .map_err(Error::Other)?; + let Some(private_key) = self.tls.private_key.clone() else { + warn!("Signer backup enabled but missing TLS private key for CLN auth"); + return None; + }; + let auth = match node::service::AuthLayer::new(private_key, self.master_rune.to_base64()) { + Ok(auth) => auth, + Err(e) => { + warn!("Signer backup peerlist client setup failed: {e}"); + return None; + } + }; let service = tower::ServiceBuilder::new().layer(auth).service(channel); - Ok(Some(node::ClnClient::new(service))) + Some(node::ClnClient::new(service)) } fn authenticate_request( @@ -977,7 +1012,15 @@ impl Signer { .map_err(|e| Error::Other(anyhow!("Failed to sign signer state entries: {e}")))?; let final_state = state.clone(); let pending_backup = self.backup.as_ref().and_then(|config| { - backup::should_snapshot(config.strategy, &pre_backup_state, &final_state) + let mut runtime = match self.backup_runtime.lock() { + Ok(runtime) => runtime, + Err(e) => { + error!("Signer backup runtime lock failed; skipping backup snapshot: {e}"); + return None; + } + }; + runtime + .observe(config.strategy, &pre_backup_state, &final_state) .then(|| (config.clone(), final_state.omit_tombstones())) }); let full_wire_bytes = { @@ -998,16 +1041,33 @@ impl Signer { (diff_entries, pending_backup) }; if let Some((backup_config, backup_state)) = pending_backup { - let peerlist = match node_client.as_mut() { - Some(client) => Self::fetch_backup_peerlist(*client).await?, - None => { - return Err(Error::Other(anyhow!( - "backup snapshot is due but no node client is available to refresh peerlist" - ))) - } + let backup_result = match node_client.as_mut() { + Some(client) => match Self::fetch_backup_peerlist(*client).await { + Ok(peerlist) => backup::write_snapshot( + &backup_config, + &self.id, + backup_state.clone(), + peerlist, + ) + .map_err(|e| Error::Other(anyhow!("failed to write signer backup: {e}"))), + Err(e) => Err(e), + }, + None => Err(Error::Other(anyhow!( + "backup snapshot is due but no node client is available to refresh peerlist" + ))), }; - backup::write_snapshot(&backup_config, &self.id, backup_state, peerlist) - .map_err(|e| Error::Other(anyhow!("failed to write signer backup: {e}")))?; + + match backup_result { + Ok(()) => match self.backup_runtime.lock() { + Ok(mut runtime) => runtime.snapshot_succeeded(&backup_state), + Err(e) => { + error!("Signer backup runtime lock failed after successful snapshot: {e}") + } + }, + Err(e) => { + error!("Signer backup failed; continuing without backup snapshot: {e}"); + } + } } Ok(HsmResponse { raw: response.as_vec(), @@ -1723,6 +1783,31 @@ mod tests { } } + #[test] + fn periodic_backup_rejects_zero_updates() { + assert!(SignerBackupConfig::periodic("backup.json", 0).is_err()); + + let signer = Signer::new_with_config( + vec![0u8; 32], + Network::Bitcoin, + credentials::Nobody::default(), + SignerConfig { + state_signature_mode: StateSignatureMode::Soft, + state_signature_override: None, + backup: Some(SignerBackupConfig { + path: PathBuf::from("backup.json"), + strategy: SignerBackupStrategy::Periodic { updates: 0 }, + }), + }, + ); + + assert!(signer + .err() + .unwrap() + .to_string() + .contains("periodic signer backup updates must be greater than zero")); + } + /// We should not sign messages that we get from the node, since /// we're using the sign_message RPC message to create TLS /// certificate attestations. We can remove this limitation once From e231f25f8d6fa65ce31e36fc977e37c6d534a4a0 Mon Sep 17 00:00:00 2001 From: Ihor Diachenko Date: Wed, 29 Apr 2026 18:59:25 +0300 Subject: [PATCH 03/12] gl-client: parse backup data --- libs/gl-client/src/persist.rs | 16 ++ libs/gl-client/src/signer/backup.rs | 365 +++++++++++++++++++++++++++- libs/gl-client/src/signer/mod.rs | 6 +- 3 files changed, 376 insertions(+), 11 deletions(-) diff --git a/libs/gl-client/src/persist.rs b/libs/gl-client/src/persist.rs index 080c38a86..42b6e2020 100644 --- a/libs/gl-client/src/persist.rs +++ b/libs/gl-client/src/persist.rs @@ -546,6 +546,22 @@ impl State { .map(|(key, _)| key.clone()) .collect() } + + pub(crate) fn recoverable_channel_values(&self) -> Vec<(String, serde_json::Value)> { + self.values + .iter() + .filter(|(_, value)| value.version != TOMBSTONE_VERSION) + .filter(|(key, _)| key.starts_with(&format!("{CHANNEL_PREFIX}/"))) + .filter(|(_, value)| { + value + .value + .get("channel_setup") + .map(|setup| !setup.is_null()) + .unwrap_or(false) + }) + .map(|(key, value)| (key.clone(), value.value.clone())) + .collect() + } } #[derive(Clone, Serialize, Deserialize, Debug, Default)] diff --git a/libs/gl-client/src/signer/backup.rs b/libs/gl-client/src/signer/backup.rs index 15cece2f4..5a3b17b67 100644 --- a/libs/gl-client/src/signer/backup.rs +++ b/libs/gl-client/src/signer/backup.rs @@ -2,6 +2,8 @@ use super::{SignerBackupConfig, SignerBackupStrategy}; use crate::persist::State; use anyhow::{anyhow, Context, Result}; use serde::{Deserialize, Serialize}; +use std::collections::BTreeMap; +use std::fs; use std::io::Write; use std::path::Path; @@ -9,7 +11,7 @@ const BACKUP_VERSION: u32 = 1; const PEERLIST_PREFIX: [&str; 2] = ["greenlight", "peerlist"]; #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] -pub(crate) struct PeerlistEntry { +pub struct PeerlistEntry { pub peer_id: String, pub addr: String, pub direction: String, @@ -26,14 +28,139 @@ struct PeerRecord { features: String, } -#[derive(Serialize)] -struct BackupSnapshot { - version: u32, - created_at: String, - node_id: String, - strategy: SignerBackupStrategy, - state: State, - peerlist: Vec, +#[derive(Clone, Serialize, Deserialize)] +pub struct SignerBackupSnapshot { + pub version: u32, + pub created_at: String, + pub node_id: String, + pub strategy: SignerBackupStrategy, + pub state: State, + pub peerlist: Vec, +} + +impl SignerBackupSnapshot { + pub fn read(path: impl AsRef) -> Result { + let path = path.as_ref(); + let bytes = fs::read(path) + .with_context(|| format!("reading signer backup {}", path.display()))?; + let snapshot: Self = serde_json::from_slice(&bytes) + .with_context(|| format!("parsing signer backup {}", path.display()))?; + snapshot.validate()?; + Ok(snapshot) + } + + pub fn recovery_data(&self) -> Result> { + self.validate()?; + + let peers: BTreeMap<&str, &PeerlistEntry> = self + .peerlist + .iter() + .map(|peer| (peer.peer_id.as_str(), peer)) + .collect(); + + self.state + .omit_tombstones() + .recoverable_channel_values() + .into_iter() + .map(|(channel_key, value)| { + let peer_id = peer_id_from_channel_key(&channel_key)?; + let entry: ChannelEntry = serde_json::from_value(value) + .with_context(|| format!("parsing recoverable channel {}", channel_key))?; + let setup = entry.channel_setup.ok_or_else(|| { + anyhow!("recoverable channel {} is missing channel_setup", channel_key) + })?; + let peer_addr = peers + .get(peer_id.as_str()) + .and_then(|peer| (!peer.addr.is_empty()).then(|| peer.addr.clone())); + let mut warnings = Vec::new(); + if peer_addr.is_none() { + warnings.push("missing_peer_addr".to_string()); + } + + Ok(RecoverableChannel { + channel_key, + peer_id, + peer_addr, + complete: warnings.is_empty(), + warnings, + funding_outpoint: setup.funding_outpoint, + funding_sats: setup.channel_value_sat, + remote_basepoints: setup.counterparty_points, + opener: if setup.is_outbound { + RecoverableChannelOpener::Local + } else { + RecoverableChannelOpener::Remote + }, + remote_to_self_delay: setup.counterparty_selected_contest_delay, + commitment_type: setup.commitment_type, + }) + }) + .collect() + } + + fn validate(&self) -> Result<()> { + if self.version != BACKUP_VERSION { + return Err(anyhow!( + "unsupported signer backup version {}; expected {}", + self.version, + BACKUP_VERSION + )); + } + + self.strategy.validate() + } +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct RecoverableChannel { + pub channel_key: String, + pub peer_id: String, + pub peer_addr: Option, + pub complete: bool, + pub warnings: Vec, + pub funding_outpoint: RecoverableFundingOutpoint, + pub funding_sats: u64, + pub remote_basepoints: RecoverableBasepoints, + pub opener: RecoverableChannelOpener, + pub remote_to_self_delay: u64, + pub commitment_type: String, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct RecoverableFundingOutpoint { + pub txid: String, + pub vout: u32, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct RecoverableBasepoints { + pub delayed_payment_basepoint: String, + pub funding_pubkey: String, + pub htlc_basepoint: String, + pub payment_point: String, + pub revocation_basepoint: String, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum RecoverableChannelOpener { + Local, + Remote, +} + +#[derive(Deserialize)] +struct ChannelEntry { + channel_setup: Option, +} + +#[derive(Deserialize)] +struct ChannelSetup { + channel_value_sat: u64, + commitment_type: String, + counterparty_points: RecoverableBasepoints, + counterparty_selected_contest_delay: u64, + funding_outpoint: RecoverableFundingOutpoint, + is_outbound: bool, } #[derive(Default)] @@ -109,7 +236,7 @@ pub(crate) fn write_snapshot( state: State, peerlist: Vec, ) -> Result<()> { - let snapshot = BackupSnapshot { + let snapshot = SignerBackupSnapshot { version: BACKUP_VERSION, created_at: chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Secs, true), node_id: hex::encode(node_id), @@ -146,6 +273,26 @@ fn backup_dir(path: &Path) -> &Path { .unwrap_or_else(|| Path::new(".")) } +fn peer_id_from_channel_key(channel_key: &str) -> Result { + let encoded = channel_key + .strip_prefix("channels/") + .ok_or_else(|| anyhow!("invalid channel key prefix: {}", channel_key))?; + let raw = hex::decode(encoded) + .with_context(|| format!("decoding channel key {}", channel_key))?; + let channel_id = raw + .get(33..) + .ok_or_else(|| anyhow!("channel key {} is missing node id prefix", channel_key))?; + + if channel_id.len() < 41 { + return Err(anyhow!( + "channel key {} does not contain a CLN-style peer id", + channel_key + )); + } + + Ok(hex::encode(&channel_id[..33])) +} + fn parse_peerlist_entry(entry: &crate::pb::cln::ListdatastoreDatastore) -> Result { if entry.key.len() != 3 || entry.key[0] != PEERLIST_PREFIX[0] @@ -220,6 +367,51 @@ mod tests { } } + fn peer_id(byte: u8) -> String { + let mut bytes = vec![byte; 33]; + bytes[0] = 2; + hex::encode(bytes) + } + + fn channel_key(peer_id: &str, oid: u64) -> String { + let mut raw = vec![3u8; 33]; + raw.extend(hex::decode(peer_id).unwrap()); + raw.extend(oid.to_le_bytes()); + format!("channels/{}", hex::encode(raw)) + } + + fn recovery_setup(txid: &str, vout: u32, sats: u64, is_outbound: bool) -> serde_json::Value { + json!({ + "channel_value_sat": sats, + "commitment_type": "AnchorsZeroFeeHtlc", + "counterparty_points": { + "delayed_payment_basepoint": "02aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "funding_pubkey": "02bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "htlc_basepoint": "02cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", + "payment_point": "02dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd", + "revocation_basepoint": "02eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee" + }, + "counterparty_selected_contest_delay": 144, + "counterparty_shutdown_script": null, + "funding_outpoint": { + "txid": txid, + "vout": vout + }, + "holder_selected_contest_delay": 144, + "holder_shutdown_script": null, + "is_outbound": is_outbound, + "push_value_msat": 0 + }) + } + + fn write_json(path: &Path, value: serde_json::Value) { + std::fs::write(path, serde_json::to_vec_pretty(&value).unwrap()).unwrap(); + } + + fn read_backup_err(path: &Path) -> String { + SignerBackupSnapshot::read(path).err().unwrap().to_string() + } + #[test] fn new_channel_trigger_requires_channel_setup() { let empty = state(json!({})); @@ -394,6 +586,153 @@ mod tests { .contains("\"addr\"")); } + #[test] + fn read_snapshot_accepts_v1_new_channels_backup() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("backup.json"); + let config = SignerBackupConfig::new(path.clone()); + + write_snapshot(&config, &[2u8; 33], state(json!({})), vec![]).unwrap(); + + let snapshot = SignerBackupSnapshot::read(&path).unwrap(); + assert_eq!(snapshot.version, 1); + assert_eq!(snapshot.strategy, SignerBackupStrategy::NewChannelsOnly); + assert_eq!(snapshot.node_id, hex::encode([2u8; 33])); + } + + #[test] + fn read_snapshot_rejects_unsupported_version() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("backup.json"); + write_json( + &path, + json!({ + "version": 2, + "created_at": "2026-04-29T00:00:00Z", + "node_id": hex::encode([2u8; 33]), + "strategy": "new_channels_only", + "state": { "values": {} }, + "peerlist": [] + }), + ); + + let err = read_backup_err(&path); + assert!(err.contains("unsupported signer backup version 2")); + } + + #[test] + fn read_snapshot_rejects_malformed_json_and_state() { + let dir = tempfile::tempdir().unwrap(); + let malformed_json = dir.path().join("malformed.json"); + let malformed_state = dir.path().join("malformed-state.json"); + std::fs::write(&malformed_json, b"not-json").unwrap(); + write_json( + &malformed_state, + json!({ + "version": 1, + "created_at": "2026-04-29T00:00:00Z", + "node_id": hex::encode([2u8; 33]), + "strategy": "new_channels_only", + "state": { "values": { "channels/a": "not-a-state-entry" } }, + "peerlist": [] + }), + ); + + assert!(read_backup_err(&malformed_json).contains("parsing signer backup")); + assert!(read_backup_err(&malformed_state).contains("parsing signer backup")); + } + + #[test] + fn recovery_data_extracts_channels_and_joins_peer_addresses() { + let peer_a = peer_id(0xaa); + let peer_b = peer_id(0xbb); + let channel_a = channel_key(&peer_a, 1); + let channel_b = channel_key(&peer_a, 2); + let channel_missing_addr = channel_key(&peer_b, 3); + let stub = channel_key(&peer_a, 4); + let tombstone = channel_key(&peer_a, 5); + let state = state(json!({ + channel_a.clone(): [1, channel(recovery_setup("00", 0, 1000, true))], + channel_b.clone(): [1, channel(recovery_setup("11", 1, 2000, false))], + channel_missing_addr.clone(): [1, channel(recovery_setup("22", 2, 3000, false))], + stub: [1, channel(serde_json::Value::Null)], + tombstone: [u64::MAX, null] + })); + let snapshot = SignerBackupSnapshot { + version: BACKUP_VERSION, + created_at: "2026-04-29T00:00:00Z".to_string(), + node_id: hex::encode([2u8; 33]), + strategy: SignerBackupStrategy::NewChannelsOnly, + state, + peerlist: vec![PeerlistEntry { + peer_id: peer_a.clone(), + addr: "127.0.0.1:9735".to_string(), + direction: "out".to_string(), + features: "".to_string(), + generation: Some(1), + raw_datastore_string: "{}".to_string(), + }], + }; + + let inventory = snapshot.recovery_data().unwrap(); + + assert_eq!(inventory.len(), 3); + let first = inventory + .iter() + .find(|channel| channel.channel_key == channel_a) + .unwrap(); + assert!(first.complete); + assert_eq!(first.peer_id, peer_a); + assert_eq!(first.peer_addr.as_deref(), Some("127.0.0.1:9735")); + assert_eq!(first.funding_outpoint.txid, "00"); + assert_eq!(first.funding_outpoint.vout, 0); + assert_eq!(first.funding_sats, 1000); + assert_eq!(first.opener, RecoverableChannelOpener::Local); + assert_eq!(first.remote_to_self_delay, 144); + assert_eq!(first.commitment_type, "AnchorsZeroFeeHtlc"); + assert_eq!( + first.remote_basepoints.funding_pubkey, + "02bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + ); + + let second = inventory + .iter() + .find(|channel| channel.channel_key == channel_b) + .unwrap(); + assert_eq!(second.peer_addr.as_deref(), Some("127.0.0.1:9735")); + assert_eq!(second.opener, RecoverableChannelOpener::Remote); + + let missing_addr = inventory + .iter() + .find(|channel| channel.channel_key == channel_missing_addr) + .unwrap(); + assert!(!missing_addr.complete); + assert_eq!(missing_addr.peer_addr, None); + assert_eq!(missing_addr.warnings, vec!["missing_peer_addr".to_string()]); + } + + #[test] + fn recovery_data_rejects_malformed_channel_json() { + let peer = peer_id(0xaa); + let channel_key = channel_key(&peer, 1); + let snapshot = SignerBackupSnapshot { + version: BACKUP_VERSION, + created_at: "2026-04-29T00:00:00Z".to_string(), + node_id: hex::encode([2u8; 33]), + strategy: SignerBackupStrategy::NewChannelsOnly, + state: state(json!({ + channel_key: [1, channel(json!({ "channel_value_sat": 1000 }))] + })), + peerlist: vec![], + }; + + assert!(snapshot + .recovery_data() + .unwrap_err() + .to_string() + .contains("parsing recoverable channel")); + } + #[test] fn write_snapshot_fails_when_parent_is_missing() { let dir = tempfile::tempdir().unwrap(); @@ -414,5 +753,11 @@ mod tests { let written: serde_json::Value = serde_json::from_slice(&std::fs::read(path).unwrap()).unwrap(); assert_eq!(written["strategy"]["periodic"]["updates"], 5); + + let snapshot = SignerBackupSnapshot::read(dir.path().join("backup.json")).unwrap(); + assert_eq!( + snapshot.strategy, + SignerBackupStrategy::Periodic { updates: 5 } + ); } } diff --git a/libs/gl-client/src/signer/mod.rs b/libs/gl-client/src/signer/mod.rs index f971357bc..0510d0759 100644 --- a/libs/gl-client/src/signer/mod.rs +++ b/libs/gl-client/src/signer/mod.rs @@ -49,6 +49,10 @@ use vls_protocol_signer::handler::Handler; mod approver; mod auth; mod backup; +pub use backup::{ + PeerlistEntry, RecoverableBasepoints, RecoverableChannel, RecoverableChannelOpener, + RecoverableFundingOutpoint, SignerBackupSnapshot, +}; pub mod model; mod report; mod resolve; @@ -117,7 +121,7 @@ impl SignerBackupConfig { } } -#[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize)] +#[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)] #[serde(rename_all = "snake_case")] pub enum SignerBackupStrategy { NewChannelsOnly, From 31c9eb31057aaeab8de3e71a86477f9a86cff627 Mon Sep 17 00:00:00 2001 From: Ihor Diachenko Date: Wed, 29 Apr 2026 19:20:08 +0300 Subject: [PATCH 04/12] gl-client: added inspect backup command --- Cargo.lock | 3 + libs/gl-cli/Cargo.toml | 5 + libs/gl-cli/src/signer.rs | 414 +++++++++++++++++++++++++++++++++++++- 3 files changed, 419 insertions(+), 3 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 9f4d377b6..214424c74 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1522,6 +1522,9 @@ dependencies = [ "futures", "gl-client", "hex", + "serde", + "serde_json", + "tempfile", "thiserror 2.0.18", "tokio", "vls-core", diff --git a/libs/gl-cli/Cargo.toml b/libs/gl-cli/Cargo.toml index b0bd4d40f..7b3ab2de6 100644 --- a/libs/gl-cli/Cargo.toml +++ b/libs/gl-cli/Cargo.toml @@ -25,9 +25,14 @@ env_logger = "0.11" futures = "0.3" gl-client = { version = "0.4", path = "../gl-client" } hex = "0.4" +serde = { version = "1", features = ["derive"] } +serde_json = "1" thiserror = "2.0.11" tokio = "1.43.0" vls-core.workspace = true +[dev-dependencies] +tempfile = "3.10.1" + [badges] maintenance = { status = "actively-developed" } diff --git a/libs/gl-cli/src/signer.rs b/libs/gl-cli/src/signer.rs index 767d30556..dd40e80cf 100644 --- a/libs/gl-cli/src/signer.rs +++ b/libs/gl-cli/src/signer.rs @@ -3,10 +3,12 @@ use crate::util; use clap::{Subcommand, ValueEnum}; use core::fmt::Debug; use gl_client::signer::{ - Signer, SignerConfig, StateSignatureMode, StateSignatureOverrideConfig, + RecoverableChannel, Signer, SignerBackupSnapshot, SignerBackupStrategy, SignerConfig, + StateSignatureMode, StateSignatureOverrideConfig, }; use lightning_signer::bitcoin::Network; -use std::path::Path; +use serde::Serialize; +use std::path::{Path, PathBuf}; use tokio::{join, signal}; use util::{CREDENTIALS_FILE_NAME, SEED_FILE_NAME}; @@ -38,6 +40,18 @@ impl From for StateSignatureMode { } } +#[derive(Copy, Clone, Debug, Eq, PartialEq, ValueEnum)] +pub enum BackupInspectFormat { + Json, + Text, +} + +impl Default for BackupInspectFormat { + fn default() -> Self { + Self::Json + } +} + #[derive(Subcommand, Debug)] pub enum Command { /// Starts a signer that connects to greenlight @@ -49,6 +63,13 @@ pub enum Command { #[arg(long = "state-override-note")] state_override_note: Option, }, + /// Inspects a local signer backup file + InspectBackup { + #[arg(long)] + path: PathBuf, + #[arg(long, value_enum, default_value_t = BackupInspectFormat::Json)] + format: BackupInspectFormat, + }, /// Prints the version of the signer used Version, } @@ -68,10 +89,127 @@ pub async fn command_handler>(cmd: Command, config: Config

) -> ) .await } + Command::InspectBackup { path, format } => inspect_backup(&path, format), Command::Version => version(config).await, } } +#[derive(Clone, Debug, PartialEq, Eq, Serialize)] +pub struct BackupInspectionReport { + pub version: u32, + pub created_at: String, + pub node_id: String, + pub strategy: SignerBackupStrategy, + pub total_channels: usize, + pub complete_channels: usize, + pub incomplete_channels: usize, + pub channels: Vec, +} + +fn inspect_backup(path: &Path, format: BackupInspectFormat) -> Result<()> { + let report = inspect_backup_report(path)?; + + match format { + BackupInspectFormat::Json => { + let output = serde_json::to_string_pretty(&report).map_err(Error::custom)?; + println!("{output}"); + } + BackupInspectFormat::Text => print!("{}", format_backup_report_text(&report)), + } + + Ok(()) +} + +fn inspect_backup_report(path: &Path) -> Result { + let snapshot = SignerBackupSnapshot::read(path).map_err(|e| { + Error::custom(format!( + "failed to read signer backup {}: {}", + path.display(), + e + )) + })?; + let channels = snapshot.recovery_data().map_err(|e| { + Error::custom(format!( + "failed to inspect signer backup {}: {}", + path.display(), + e + )) + })?; + + Ok(backup_inspection_report(snapshot, channels)) +} + +fn backup_inspection_report( + snapshot: SignerBackupSnapshot, + channels: Vec, +) -> BackupInspectionReport { + let complete_channels = channels.iter().filter(|channel| channel.complete).count(); + let total_channels = channels.len(); + + BackupInspectionReport { + version: snapshot.version, + created_at: snapshot.created_at, + node_id: snapshot.node_id, + strategy: snapshot.strategy, + total_channels, + complete_channels, + incomplete_channels: total_channels - complete_channels, + channels, + } +} + +fn format_backup_report_text(report: &BackupInspectionReport) -> String { + let mut output = String::new(); + output.push_str(&format!("Signer backup {}\n", report.node_id)); + output.push_str(&format!("version: {}\n", report.version)); + output.push_str(&format!("created_at: {}\n", report.created_at)); + output.push_str(&format!("strategy: {}\n", format_strategy(report.strategy))); + output.push_str(&format!( + "channels: total={} complete={} incomplete={}\n", + report.total_channels, report.complete_channels, report.incomplete_channels + )); + + for channel in &report.channels { + let status = if channel.complete { + "complete" + } else { + "incomplete" + }; + let peer_addr = channel.peer_addr.as_deref().unwrap_or("missing"); + let warnings = if channel.warnings.is_empty() { + "none".to_string() + } else { + channel.warnings.join(",") + }; + + output.push_str(&format!("\nchannel: {}\n", channel.channel_key)); + output.push_str(&format!(" status: {status}\n")); + output.push_str(&format!(" peer_id: {}\n", channel.peer_id)); + output.push_str(&format!(" peer_addr: {peer_addr}\n")); + output.push_str(&format!( + " funding: {}:{}\n", + channel.funding_outpoint.txid, channel.funding_outpoint.vout + )); + output.push_str(&format!(" funding_sats: {}\n", channel.funding_sats)); + output.push_str(&format!(" opener: {:?}\n", channel.opener)); + output.push_str(&format!( + " remote_to_self_delay: {}\n", + channel.remote_to_self_delay + )); + output.push_str(&format!(" commitment_type: {}\n", channel.commitment_type)); + output.push_str(&format!(" warnings: {warnings}\n")); + } + + output +} + +fn format_strategy(strategy: SignerBackupStrategy) -> String { + match strategy { + SignerBackupStrategy::NewChannelsOnly => "new_channels_only".to_string(), + SignerBackupStrategy::Periodic { updates } => format!("periodic(updates={updates})"), + } +} + async fn run_handler>( config: Config

, state_signature_mode: StateSignatureModeArg, @@ -123,6 +261,7 @@ async fn run_handler>( SignerConfig { state_signature_mode: state_signature_mode.into(), state_signature_override, + backup: None, }, ) .map_err(|e| Error::custom(format!("Failed to create signer: {}", e)))?; @@ -142,8 +281,13 @@ async fn run_handler>( #[cfg(test)] mod tests { - use super::{Command, StateSignatureModeArg}; + use super::{ + format_backup_report_text, inspect_backup_report, BackupInspectFormat, Command, + StateSignatureModeArg, + }; use clap::{Parser, Subcommand}; + use serde_json::json; + use std::path::Path; #[derive(Parser, Debug)] struct TestCli { @@ -243,6 +387,270 @@ mod tests { _ => panic!("expected run command"), } } + + #[test] + fn parse_inspect_backup_defaults_to_json() { + let cli = TestCli::parse_from(["test", "inspect-backup", "--path", "backup.json"]); + match cli.cmd { + Command::InspectBackup { path, format } => { + assert_eq!(path, Path::new("backup.json")); + assert_eq!(format, BackupInspectFormat::Json); + } + _ => panic!("expected inspect-backup command"), + } + } + + #[test] + fn parse_inspect_backup_text_format() { + let cli = TestCli::parse_from([ + "test", + "inspect-backup", + "--path", + "backup.json", + "--format", + "text", + ]); + match cli.cmd { + Command::InspectBackup { path, format } => { + assert_eq!(path, Path::new("backup.json")); + assert_eq!(format, BackupInspectFormat::Text); + } + _ => panic!("expected inspect-backup command"), + } + } + + #[test] + fn parse_inspect_backup_rejects_invalid_format() { + assert!(TestCli::try_parse_from([ + "test", + "inspect-backup", + "--path", + "backup.json", + "--format", + "yaml", + ]) + .is_err()); + } + + #[test] + fn inspect_backup_report_counts_channels_and_warnings() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("backup.json"); + let peer_a = peer_id(0xaa); + let peer_b = peer_id(0xbb); + write_json( + &path, + backup_json( + json!({ + channel_key(&peer_a, 1): [1, channel(recovery_setup("00", 0, 1000, true))], + channel_key(&peer_a, 2): [1, channel(recovery_setup("11", 1, 2000, false))], + channel_key(&peer_b, 3): [1, channel(recovery_setup("22", 2, 3000, false))] + }), + json!([peerlist_entry(&peer_a, "127.0.0.1:9735")]), + json!("new_channels_only"), + ), + ); + + let report = inspect_backup_report(&path).unwrap(); + + assert_eq!(report.version, 1); + assert_eq!(report.node_id, hex::encode([2u8; 33])); + assert_eq!(report.total_channels, 3); + assert_eq!(report.complete_channels, 2); + assert_eq!(report.incomplete_channels, 1); + assert_eq!(report.channels[0].funding_outpoint.txid, "00"); + assert_eq!( + report.channels[0].peer_addr.as_deref(), + Some("127.0.0.1:9735") + ); + let serialized = serde_json::to_value(&report).unwrap(); + assert_eq!(serialized["total_channels"], 3); + assert!(serialized["channels"][0]["remote_basepoints"].is_object()); + assert!(serialized.get("state").is_none()); + assert!(serialized.get("peerlist").is_none()); + let incomplete = report + .channels + .iter() + .find(|channel| channel.peer_id == peer_b) + .unwrap(); + assert!(!incomplete.complete); + assert_eq!(incomplete.peer_addr, None); + assert_eq!(incomplete.warnings, vec!["missing_peer_addr".to_string()]); + } + + #[test] + fn inspect_backup_report_accepts_periodic_strategy() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("backup.json"); + write_json( + &path, + backup_json( + json!({}), + json!([]), + json!({ "periodic": { "updates": 5 } }), + ), + ); + + let report = inspect_backup_report(&path).unwrap(); + + assert_eq!(report.total_channels, 0); + assert_eq!( + serde_json::to_value(report.strategy).unwrap()["periodic"]["updates"], + 5 + ); + } + + #[test] + fn inspect_backup_report_rejects_unsupported_version() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("backup.json"); + let mut backup = backup_json(json!({}), json!([]), json!("new_channels_only")); + backup["version"] = json!(2); + write_json(&path, backup); + + let err = inspect_backup_report(&path).unwrap_err().to_string(); + + assert!(err.contains("unsupported signer backup version 2")); + } + + #[test] + fn inspect_backup_report_rejects_malformed_json() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("backup.json"); + std::fs::write(&path, "not-json").unwrap(); + + let err = inspect_backup_report(&path).unwrap_err().to_string(); + + assert!(err.contains("parsing signer backup")); + } + + #[test] + fn inspect_backup_report_rejects_malformed_state() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("backup.json"); + write_json( + &path, + backup_json( + json!({ + "channels/not-state-entry": "not-a-state-entry" + }), + json!([]), + json!("new_channels_only"), + ), + ); + + let err = inspect_backup_report(&path).unwrap_err().to_string(); + + assert!(err.contains("parsing signer backup")); + } + + #[test] + fn backup_report_text_includes_recovery_fields_and_warnings() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("backup.json"); + let peer = peer_id(0xaa); + write_json( + &path, + backup_json( + json!({ + channel_key(&peer, 1): [1, channel(recovery_setup("00", 0, 1000, true))] + }), + json!([]), + json!("new_channels_only"), + ), + ); + let report = inspect_backup_report(&path).unwrap(); + + let text = format_backup_report_text(&report); + + assert!(text.contains("version: 1")); + assert!(text.contains("channels: total=1 complete=0 incomplete=1")); + assert!(text.contains(&format!("peer_id: {peer}"))); + assert!(text.contains("peer_addr: missing")); + assert!(text.contains("funding: 00:0")); + assert!(text.contains("funding_sats: 1000")); + assert!(text.contains("warnings: missing_peer_addr")); + } + + fn backup_json( + channels: serde_json::Value, + peerlist: serde_json::Value, + strategy: serde_json::Value, + ) -> serde_json::Value { + json!({ + "version": 1, + "created_at": "2026-04-29T00:00:00Z", + "node_id": hex::encode([2u8; 33]), + "strategy": strategy, + "state": { + "values": channels + }, + "peerlist": peerlist + }) + } + + fn peerlist_entry(peer_id: &str, addr: &str) -> serde_json::Value { + json!({ + "peer_id": peer_id, + "addr": addr, + "direction": "out", + "features": "", + "generation": 7, + "raw_datastore_string": format!( + r#"{{"id":"{peer_id}","direction":"out","addr":"{addr}","features":""}}"# + ) + }) + } + + fn peer_id(byte: u8) -> String { + let mut bytes = vec![byte; 33]; + bytes[0] = 2; + hex::encode(bytes) + } + + fn channel_key(peer_id: &str, oid: u64) -> String { + let mut raw = vec![3u8; 33]; + raw.extend(hex::decode(peer_id).unwrap()); + raw.extend(oid.to_le_bytes()); + format!("channels/{}", hex::encode(raw)) + } + + fn channel(channel_setup: serde_json::Value) -> serde_json::Value { + json!({ + "channel_setup": channel_setup, + "id": { + "id": "00" + } + }) + } + + fn recovery_setup(txid: &str, vout: u32, sats: u64, is_outbound: bool) -> serde_json::Value { + json!({ + "channel_value_sat": sats, + "commitment_type": "AnchorsZeroFeeHtlc", + "counterparty_points": { + "delayed_payment_basepoint": "02aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "funding_pubkey": "02bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "htlc_basepoint": "02cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", + "payment_point": "02dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd", + "revocation_basepoint": "02eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee" + }, + "counterparty_selected_contest_delay": 144, + "counterparty_shutdown_script": null, + "funding_outpoint": { + "txid": txid, + "vout": vout + }, + "holder_selected_contest_delay": 144, + "holder_shutdown_script": null, + "is_outbound": is_outbound, + "push_value_msat": 0 + }) + } + + fn write_json(path: &Path, value: serde_json::Value) { + std::fs::write(path, serde_json::to_vec_pretty(&value).unwrap()).unwrap(); + } } async fn version>(config: Config

) -> Result<()> { From 13aec33c6bcba51ce1918a4ad9e465a6c627f623 Mon Sep 17 00:00:00 2001 From: Ihor Diachenko Date: Thu, 30 Apr 2026 18:07:42 +0300 Subject: [PATCH 05/12] signer: add CLN backup conversion --- libs/gl-cli/src/signer.rs | 230 +++++++++- libs/gl-client/src/signer/backup.rs | 626 ++++++++++++++++++++++++++++ libs/gl-client/src/signer/mod.rs | 4 +- 3 files changed, 855 insertions(+), 5 deletions(-) diff --git a/libs/gl-cli/src/signer.rs b/libs/gl-cli/src/signer.rs index dd40e80cf..4585ebbf6 100644 --- a/libs/gl-cli/src/signer.rs +++ b/libs/gl-cli/src/signer.rs @@ -3,8 +3,9 @@ use crate::util; use clap::{Subcommand, ValueEnum}; use core::fmt::Debug; use gl_client::signer::{ - RecoverableChannel, Signer, SignerBackupSnapshot, SignerBackupStrategy, SignerConfig, - StateSignatureMode, StateSignatureOverrideConfig, + RecoverableChannel, CLNBackup, CLNBackupOptions, Signer, + SignerBackupSnapshot, SignerBackupStrategy, SignerConfig, StateSignatureMode, + StateSignatureOverrideConfig, }; use lightning_signer::bitcoin::Network; use serde::Serialize; @@ -52,6 +53,17 @@ impl Default for BackupInspectFormat { } } +#[derive(Copy, Clone, Debug, Eq, PartialEq, ValueEnum)] +pub enum BackupConvertFormat { + Cln, +} + +impl Default for BackupConvertFormat { + fn default() -> Self { + Self::Cln + } +} + #[derive(Subcommand, Debug)] pub enum Command { /// Starts a signer that connects to greenlight @@ -70,6 +82,17 @@ pub enum Command { #[arg(long, value_enum, default_value_t = BackupInspectFormat::Json)] format: BackupInspectFormat, }, + /// Converts a signer backup to another channel recovery format + ConvertBackup { + #[arg(long)] + path: PathBuf, + #[arg(long)] + output: Option, + #[arg(long, value_enum, default_value_t = BackupConvertFormat::Cln)] + format: BackupConvertFormat, + #[arg(long)] + skip_incomplete: bool, + }, /// Prints the version of the signer used Version, } @@ -90,6 +113,12 @@ pub async fn command_handler>(cmd: Command, config: Config

) -> .await } Command::InspectBackup { path, format } => inspect_backup(&path, format), + Command::ConvertBackup { + path, + output, + format, + skip_incomplete, + } => convert_backup(&path, output.as_deref(), format, skip_incomplete), Command::Version => version(config).await, } } @@ -139,6 +168,62 @@ fn inspect_backup_report(path: &Path) -> Result { Ok(backup_inspection_report(snapshot, channels)) } +fn convert_backup( + path: &Path, + output: Option<&Path>, + format: BackupConvertFormat, + skip_incomplete: bool, +) -> Result<()> { + let rendered = convert_backup_output(path, format, skip_incomplete)?; + + if let Some(output) = output { + std::fs::write(output, format!("{rendered}\n")).map_err(|e| { + Error::custom(format!( + "failed to write converted backup {}: {}", + output.display(), + e + )) + })?; + } else { + println!("{rendered}"); + } + + Ok(()) +} + +fn convert_backup_output( + path: &Path, + format: BackupConvertFormat, + skip_incomplete: bool, +) -> Result { + match format { + BackupConvertFormat::Cln => { + let export = cln_to_cln_backup(path, skip_incomplete)?; + serde_json::to_string_pretty(&export.request).map_err(Error::custom) + } + } +} + +fn cln_to_cln_backup(path: &Path, skip_incomplete: bool) -> Result { + let snapshot = SignerBackupSnapshot::read(path).map_err(|e| { + Error::custom(format!( + "failed to read signer backup {}: {}", + path.display(), + e + )) + })?; + + snapshot + .to_cln_backup(CLNBackupOptions { skip_incomplete }) + .map_err(|e| { + Error::custom(format!( + "failed to convert signer backup {} to CLN recovery data: {}", + path.display(), + e + )) + }) +} + fn backup_inspection_report( snapshot: SignerBackupSnapshot, channels: Vec, @@ -282,8 +367,8 @@ async fn run_handler>( #[cfg(test)] mod tests { use super::{ - format_backup_report_text, inspect_backup_report, BackupInspectFormat, Command, - StateSignatureModeArg, + convert_backup_output, format_backup_report_text, inspect_backup_report, + BackupConvertFormat, BackupInspectFormat, Command, StateSignatureModeArg, }; use clap::{Parser, Subcommand}; use serde_json::json; @@ -432,6 +517,67 @@ mod tests { .is_err()); } + #[test] + fn parse_convert_backup_defaults_to_cln() { + let cli = TestCli::parse_from(["test", "convert-backup", "--path", "backup.json"]); + match cli.cmd { + Command::ConvertBackup { + path, + output, + format, + skip_incomplete, + } => { + assert_eq!(path, Path::new("backup.json")); + assert!(output.is_none()); + assert_eq!(format, BackupConvertFormat::Cln); + assert!(!skip_incomplete); + } + _ => panic!("expected convert-backup command"), + } + } + + #[test] + fn parse_convert_backup_cln_output_and_skip() { + let cli = TestCli::parse_from([ + "test", + "convert-backup", + "--path", + "backup.json", + "--output", + "recoverchannel.json", + "--format", + "cln", + "--skip-incomplete", + ]); + match cli.cmd { + Command::ConvertBackup { + path, + output, + format, + skip_incomplete, + } => { + assert_eq!(path, Path::new("backup.json")); + assert_eq!(output.as_deref(), Some(Path::new("recoverchannel.json"))); + assert_eq!(format, BackupConvertFormat::Cln); + assert!(skip_incomplete); + } + _ => panic!("expected convert-backup command"), + } + } + + #[test] + fn parse_convert_backup_rejects_invalid_format() { + assert!(TestCli::try_parse_from([ + "test", + "convert-backup", + "--path", + "backup.json", + "--format", + "yaml", + ]) + .is_err()); + } + #[test] fn inspect_backup_report_counts_channels_and_warnings() { let dir = tempfile::tempdir().unwrap(); @@ -572,6 +718,78 @@ mod tests { assert!(text.contains("warnings: missing_peer_addr")); } + #[test] + fn convert_backup_cln_output_contains_only_scb_request() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("backup.json"); + let peer = peer_id(0xaa); + write_json( + &path, + backup_json( + json!({ + channel_key(&peer, 7): [1, channel(recovery_setup(full_txid(), 0, 1000, true))] + }), + json!([peerlist_entry(&peer, "127.0.0.1:9735")]), + json!("new_channels_only"), + ), + ); + + let output = convert_backup_output(&path, BackupConvertFormat::Cln, false).unwrap(); + let value: serde_json::Value = serde_json::from_str(&output).unwrap(); + + assert!(value["scb"][0].as_str().unwrap().len() > 100); + assert!(value.get("channels").is_none()); + assert!(value.get("state").is_none()); + assert!(value.get("peerlist").is_none()); + } + + #[test] + fn convert_backup_cln_skips_incomplete_channels_when_requested() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("backup.json"); + let peer_a = peer_id(0xaa); + let peer_b = peer_id(0xbb); + write_json( + &path, + backup_json( + json!({ + channel_key(&peer_a, 7): [1, channel(recovery_setup(full_txid(), 0, 1000, true))], + channel_key(&peer_b, 8): [1, channel(recovery_setup(full_txid(), 1, 2000, false))] + }), + json!([peerlist_entry(&peer_a, "127.0.0.1:9735")]), + json!("new_channels_only"), + ), + ); + + let output = convert_backup_output(&path, BackupConvertFormat::Cln, true).unwrap(); + let value: serde_json::Value = serde_json::from_str(&output).unwrap(); + + assert_eq!(value["scb"].as_array().unwrap().len(), 1); + } + + #[test] + fn convert_backup_cln_fails_for_incomplete_channel_without_skip() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("backup.json"); + let peer = peer_id(0xaa); + write_json( + &path, + backup_json( + json!({ + channel_key(&peer, 7): [1, channel(recovery_setup(full_txid(), 0, 1000, true))] + }), + json!([]), + json!("new_channels_only"), + ), + ); + + let err = convert_backup_output(&path, BackupConvertFormat::Cln, false) + .unwrap_err() + .to_string(); + + assert!(err.contains("missing_peer_addr")); + } + fn backup_json( channels: serde_json::Value, peerlist: serde_json::Value, @@ -648,6 +866,10 @@ mod tests { }) } + fn full_txid() -> &'static str { + "000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f" + } + fn write_json(path: &Path, value: serde_json::Value) { std::fs::write(path, serde_json::to_vec_pretty(&value).unwrap()).unwrap(); } diff --git a/libs/gl-client/src/signer/backup.rs b/libs/gl-client/src/signer/backup.rs index 5a3b17b67..ec8f8f892 100644 --- a/libs/gl-client/src/signer/backup.rs +++ b/libs/gl-client/src/signer/backup.rs @@ -3,12 +3,22 @@ use crate::persist::State; use anyhow::{anyhow, Context, Result}; use serde::{Deserialize, Serialize}; use std::collections::BTreeMap; +use std::convert::TryInto; use std::fs; use std::io::Write; +use std::net::{IpAddr, SocketAddr}; use std::path::Path; const BACKUP_VERSION: u32 = 1; const PEERLIST_PREFIX: [&str; 2] = ["greenlight", "peerlist"]; +const NODE_ID_LEN: usize = 33; +const PEER_ID_LEN: usize = 33; +const CLN_DBID_LEN: usize = 8; +const CLN_CHANNEL_KEY_LEN: usize = PEER_ID_LEN + CLN_DBID_LEN; +const VLS_CHANNEL_KEY_LEN: usize = NODE_ID_LEN + CLN_CHANNEL_KEY_LEN; +const TXID_LEN: usize = 32; +const PUBKEY_LEN: usize = 33; +const SHACHAIN_OMITTED_WARNING: &str = "shachain_tlv_omitted"; #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] pub struct PeerlistEntry { @@ -98,6 +108,73 @@ impl SignerBackupSnapshot { .collect() } + /// Converts the snapshot's recoverable channels into a CLN-compatible backup format suitable for `recoverchannel` import. + /// If `options.skip_incomplete` is true, channels with missing peer addresses will be skipped and + /// included in the `skipped` list with warnings instead of causing an error. + pub fn to_cln_backup( + &self, + options: CLNBackupOptions, + ) -> Result { + let recovery_data = self.recovery_data()?; + let total_channels = recovery_data.len(); + let mut request = RecoverchannelRequest { scb: vec![] }; + let mut channels = Vec::new(); + let mut skipped = Vec::new(); + + for channel in recovery_data { + if !channel.complete { + if options.skip_incomplete { + skipped.push(RecoverchannelSkippedChannel { + channel_key: channel.channel_key, + peer_id: channel.peer_id, + warnings: channel.warnings, + }); + continue; + } + + return Err(anyhow!( + "channel {} is incomplete: {}", + channel.channel_key, + channel.warnings.join(",") + )); + } + + let encoded = encode_recoverchannel_scb(&channel).map_err(|e| { + anyhow!( + "encoding recoverchannel SCB for {}: {}", + channel.channel_key, + e + ) + })?; + request.scb.push(encoded.scb.clone()); + channels.push(CLNBackupChannel { + channel_key: channel.channel_key, + peer_id: channel.peer_id, + peer_addr: channel.peer_addr.expect("complete channel has peer_addr"), + funding_outpoint: channel.funding_outpoint, + cln_dbid: encoded.cln_dbid, + channel_id: encoded.channel_id, + scb: encoded.scb, + warnings: vec![SHACHAIN_OMITTED_WARNING.to_string()], + }); + } + + if request.scb.is_empty() { + return Err(anyhow!( + "no complete recoverable channels available for recoverchannel export" + )); + } + + Ok(CLNBackup { + request, + total_channels, + exported_channels: channels.len(), + skipped_channels: skipped.len(), + channels, + skipped, + }) + } + fn validate(&self) -> Result<()> { if self.version != BACKUP_VERSION { return Err(anyhow!( @@ -148,6 +225,45 @@ pub enum RecoverableChannelOpener { Remote, } +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub struct CLNBackupOptions { + pub skip_incomplete: bool, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct RecoverchannelRequest { + pub scb: Vec, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct CLNBackup { + pub request: RecoverchannelRequest, + pub total_channels: usize, + pub exported_channels: usize, + pub skipped_channels: usize, + pub channels: Vec, + pub skipped: Vec, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct CLNBackupChannel { + pub channel_key: String, + pub peer_id: String, + pub peer_addr: String, + pub funding_outpoint: RecoverableFundingOutpoint, + pub cln_dbid: u64, + pub channel_id: String, + pub scb: String, + pub warnings: Vec, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct RecoverchannelSkippedChannel { + pub channel_key: String, + pub peer_id: String, + pub warnings: Vec, +} + #[derive(Deserialize)] struct ChannelEntry { channel_setup: Option, @@ -293,6 +409,297 @@ fn peer_id_from_channel_key(channel_key: &str) -> Result { Ok(hex::encode(&channel_id[..33])) } +struct EncodedRecoverchannelScb { + cln_dbid: u64, + channel_id: String, + scb: String, +} + +struct ClnChannelKey { + peer_id: [u8; PEER_ID_LEN], + dbid: u64, +} + +fn encode_recoverchannel_scb(channel: &RecoverableChannel) -> Result { + let channel_key = decode_cln_channel_key(&channel.channel_key)?; + let expected_peer_id = decode_hex_array::("peer_id", &channel.peer_id)?; + if channel_key.peer_id != expected_peer_id { + return Err(anyhow!( + "channel key peer id {} does not match channel peer id {}", + hex::encode(channel_key.peer_id), + channel.peer_id + )); + } + + let txid = decode_txid_for_cln_wire(&channel.funding_outpoint.txid)?; + let channel_id = derive_v1_channel_id(txid, channel.funding_outpoint.vout); + let wireaddr = encode_wireaddr( + channel + .peer_addr + .as_deref() + .ok_or_else(|| anyhow!("missing peer address"))?, + )?; + let channel_type = encode_channel_type(&channel.commitment_type)?; + let tlvs = encode_scb_tlvs(channel)?; + + let mut scb = Vec::new(); + put_u64(&mut scb, channel_key.dbid); + scb.extend(channel_id); + scb.extend(channel_key.peer_id); + scb.extend(wireaddr); + scb.extend(txid); + put_u32(&mut scb, channel.funding_outpoint.vout); + put_u64(&mut scb, channel.funding_sats); + put_u16(&mut scb, channel_type.len().try_into()?); + scb.extend(channel_type); + put_u32(&mut scb, tlvs.len().try_into()?); + scb.extend(tlvs); + + Ok(EncodedRecoverchannelScb { + cln_dbid: channel_key.dbid, + channel_id: hex::encode(channel_id), + scb: hex::encode(scb), + }) +} + +fn decode_cln_channel_key(channel_key: &str) -> Result { + let encoded = channel_key + .strip_prefix("channels/") + .ok_or_else(|| anyhow!("invalid channel key prefix: {}", channel_key))?; + let raw = hex::decode(encoded) + .with_context(|| format!("decoding channel key {}", channel_key))?; + + if raw.len() != VLS_CHANNEL_KEY_LEN { + return Err(anyhow!( + "channel key {} is not a CLN-style VLS channel key: expected {} bytes, got {}", + channel_key, + VLS_CHANNEL_KEY_LEN, + raw.len() + )); + } + + let peer_id = raw[NODE_ID_LEN..NODE_ID_LEN + PEER_ID_LEN] + .try_into() + .expect("slice length checked"); + let dbid = u64::from_le_bytes( + raw[NODE_ID_LEN + PEER_ID_LEN..] + .try_into() + .expect("slice length checked"), + ); + + Ok(ClnChannelKey { peer_id, dbid }) +} + +fn decode_txid_for_cln_wire(txid: &str) -> Result<[u8; TXID_LEN]> { + let mut bytes = decode_hex_array::("funding txid", txid)?; + bytes.reverse(); + Ok(bytes) +} + +fn derive_v1_channel_id(txid: [u8; TXID_LEN], vout: u32) -> [u8; TXID_LEN] { + let mut channel_id = txid; + channel_id[TXID_LEN - 2] ^= (vout >> 8) as u8; + channel_id[TXID_LEN - 1] ^= vout as u8; + channel_id +} + +fn encode_channel_type(commitment_type: &str) -> Result> { + let commitment_type = match commitment_type { + "Legacy" => lightning_signer::channel::CommitmentType::Legacy, + "StaticRemoteKey" => lightning_signer::channel::CommitmentType::StaticRemoteKey, + "Anchors" => lightning_signer::channel::CommitmentType::Anchors, + "AnchorsZeroFeeHtlc" => lightning_signer::channel::CommitmentType::AnchorsZeroFeeHtlc, + unknown => return Err(anyhow!("unsupported commitment type {}", unknown)), + }; + if commitment_type == lightning_signer::channel::CommitmentType::Legacy { + return Ok(Vec::new()); + } + + Ok(vls_protocol_signer::util::commitment_type_to_channel_type( + commitment_type, + )) +} + +fn encode_scb_tlvs(channel: &RecoverableChannel) -> Result> { + let mut tlvs = Vec::new(); + + let mut basepoints = Vec::new(); + basepoints.extend(decode_hex_array::( + "revocation basepoint", + &channel.remote_basepoints.revocation_basepoint, + )?); + basepoints.extend(decode_hex_array::( + "payment basepoint", + &channel.remote_basepoints.payment_point, + )?); + basepoints.extend(decode_hex_array::( + "htlc basepoint", + &channel.remote_basepoints.htlc_basepoint, + )?); + basepoints.extend(decode_hex_array::( + "delayed payment basepoint", + &channel.remote_basepoints.delayed_payment_basepoint, + )?); + put_tlv(&mut tlvs, 3, &basepoints); + + let opener = match channel.opener { + RecoverableChannelOpener::Local => 0, + RecoverableChannelOpener::Remote => 1, + }; + put_tlv(&mut tlvs, 5, &[opener]); + + let remote_to_self_delay: u16 = channel.remote_to_self_delay.try_into().with_context(|| { + format!( + "remote_to_self_delay {} does not fit in CLN SCB u16", + channel.remote_to_self_delay + ) + })?; + let mut delay = Vec::new(); + put_u16(&mut delay, remote_to_self_delay); + put_tlv(&mut tlvs, 7, &delay); + + Ok(tlvs) +} + +fn encode_wireaddr(addr: &str) -> Result> { + if let Ok(socket_addr) = addr.parse::() { + return Ok(encode_socket_addr(socket_addr)); + } + + let (host, port) = addr + .rsplit_once(':') + .ok_or_else(|| anyhow!("peer address {} is missing port", addr))?; + let port = port + .parse::() + .with_context(|| format!("invalid peer address port in {}", addr))?; + if host.contains(':') { + return Err(anyhow!( + "IPv6 peer address {} must use [addr]:port form", + addr + )); + } + + if let Some(onion) = host.strip_suffix(".onion") { + let onion = decode_tor_v3_onion(onion) + .with_context(|| format!("invalid Tor v3 peer address {}", addr))?; + let mut wire = vec![4]; + wire.extend(onion); + put_u16(&mut wire, port); + return Ok(wire); + } + + let host = host.as_bytes(); + if host.is_empty() || host.len() > u8::MAX as usize { + return Err(anyhow!( + "DNS peer address host length is invalid in {}", + addr + )); + } + + let mut wire = vec![5, host.len() as u8]; + wire.extend(host); + put_u16(&mut wire, port); + Ok(wire) +} + +fn encode_socket_addr(addr: SocketAddr) -> Vec { + let mut wire = Vec::new(); + match addr.ip() { + IpAddr::V4(ip) => { + wire.push(1); + wire.extend(ip.octets()); + } + IpAddr::V6(ip) => { + wire.push(2); + wire.extend(ip.octets()); + } + } + put_u16(&mut wire, addr.port()); + wire +} + +fn decode_tor_v3_onion(host: &str) -> Result<[u8; 35]> { + if host.len() != 56 { + return Err(anyhow!( + "Tor v3 onion host must be 56 base32 characters, got {}", + host.len() + )); + } + + let mut bits: u16 = 0; + let mut bit_count: u8 = 0; + let mut out = Vec::with_capacity(35); + + for byte in host.bytes() { + let value = match byte { + b'a'..=b'z' => byte - b'a', + b'A'..=b'Z' => byte - b'A', + b'2'..=b'7' => byte - b'2' + 26, + _ => return Err(anyhow!("invalid base32 character {}", byte as char)), + }; + bits = (bits << 5) | u16::from(value); + bit_count += 5; + while bit_count >= 8 { + bit_count -= 8; + out.push((bits >> bit_count) as u8); + bits &= (1u16 << bit_count) - 1; + } + } + + if bit_count != 0 || out.len() != 35 { + return Err(anyhow!("invalid Tor v3 onion base32 length")); + } + + Ok(out.try_into().expect("length checked")) +} + +fn decode_hex_array(label: &str, value: &str) -> Result<[u8; N]> { + let bytes = hex::decode(value).with_context(|| format!("decoding {}", label))?; + if bytes.len() != N { + return Err(anyhow!( + "{} must be {} bytes, got {}", + label, + N, + bytes.len() + )); + } + + Ok(bytes.try_into().expect("length checked")) +} + +fn put_tlv(out: &mut Vec, typ: u64, value: &[u8]) { + put_bigsize(out, typ); + put_bigsize(out, value.len() as u64); + out.extend(value); +} + +fn put_bigsize(out: &mut Vec, value: u64) { + if value < 0xfd { + out.push(value as u8); + } else if value <= 0xffff { + out.push(0xfd); + put_u16(out, value as u16); + } else if value <= 0xffff_ffff { + out.push(0xfe); + put_u32(out, value as u32); + } else { + out.push(0xff); + put_u64(out, value); + } +} + +fn put_u16(out: &mut Vec, value: u16) { + out.extend(value.to_be_bytes()); +} + +fn put_u32(out: &mut Vec, value: u32) { + out.extend(value.to_be_bytes()); +} + +fn put_u64(out: &mut Vec, value: u64) { + out.extend(value.to_be_bytes()); +} + fn parse_peerlist_entry(entry: &crate::pb::cln::ListdatastoreDatastore) -> Result { if entry.key.len() != 3 || entry.key[0] != PEERLIST_PREFIX[0] @@ -404,6 +811,16 @@ mod tests { }) } + fn full_txid() -> &'static str { + "000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f" + } + + fn expected_cln_txid(txid: &str) -> [u8; 32] { + let mut bytes: [u8; 32] = hex::decode(txid).unwrap().try_into().unwrap(); + bytes.reverse(); + bytes + } + fn write_json(path: &Path, value: serde_json::Value) { std::fs::write(path, serde_json::to_vec_pretty(&value).unwrap()).unwrap(); } @@ -733,6 +1150,215 @@ mod tests { .contains("parsing recoverable channel")); } + #[test] + fn to_cln_backup_rejects_incomplete_channels_by_default() { + let peer = peer_id(0xaa); + let channel_key = channel_key(&peer, 1); + let snapshot = SignerBackupSnapshot { + version: BACKUP_VERSION, + created_at: "2026-04-29T00:00:00Z".to_string(), + node_id: hex::encode([2u8; 33]), + strategy: SignerBackupStrategy::NewChannelsOnly, + state: state(json!({ + channel_key: [1, channel(recovery_setup(full_txid(), 0, 1000, true))] + })), + peerlist: vec![], + }; + + let err = snapshot + .to_cln_backup(CLNBackupOptions::default()) + .unwrap_err() + .to_string(); + + assert!(err.contains("missing_peer_addr")); + } + + #[test] + fn to_cln_backup_skips_incomplete_channels_when_requested() { + let peer_a = peer_id(0xaa); + let peer_b = peer_id(0xbb); + let channel_a = channel_key(&peer_a, 1); + let channel_b = channel_key(&peer_b, 2); + let snapshot = SignerBackupSnapshot { + version: BACKUP_VERSION, + created_at: "2026-04-29T00:00:00Z".to_string(), + node_id: hex::encode([2u8; 33]), + strategy: SignerBackupStrategy::NewChannelsOnly, + state: state(json!({ + channel_a.clone(): [1, channel(recovery_setup(full_txid(), 0, 1000, true))], + channel_b.clone(): [1, channel(recovery_setup(full_txid(), 1, 2000, false))] + })), + peerlist: vec![PeerlistEntry { + peer_id: peer_a.clone(), + addr: "127.0.0.1:9735".to_string(), + direction: "out".to_string(), + features: "".to_string(), + generation: Some(1), + raw_datastore_string: "{}".to_string(), + }], + }; + + let export = snapshot + .to_cln_backup(CLNBackupOptions { + skip_incomplete: true, + }) + .unwrap(); + + assert_eq!(export.request.scb.len(), 1); + assert_eq!(export.total_channels, 2); + assert_eq!(export.exported_channels, 1); + assert_eq!(export.skipped_channels, 1); + assert_eq!(export.channels[0].channel_key, channel_a); + assert_eq!(export.channels[0].cln_dbid, 1); + assert_eq!(export.channels[0].warnings, vec![SHACHAIN_OMITTED_WARNING]); + assert_eq!(export.skipped[0].channel_key, channel_b); + assert_eq!(export.skipped[0].warnings, vec!["missing_peer_addr"]); + } + + #[test] + fn to_cln_backup_fails_when_every_channel_is_skipped() { + let peer = peer_id(0xaa); + let snapshot = SignerBackupSnapshot { + version: BACKUP_VERSION, + created_at: "2026-04-29T00:00:00Z".to_string(), + node_id: hex::encode([2u8; 33]), + strategy: SignerBackupStrategy::NewChannelsOnly, + state: state(json!({ + channel_key(&peer, 1): [1, channel(recovery_setup(full_txid(), 0, 1000, true))] + })), + peerlist: vec![], + }; + + let err = snapshot + .to_cln_backup(CLNBackupOptions { + skip_incomplete: true, + }) + .unwrap_err() + .to_string(); + + assert!(err.contains("no complete recoverable channels")); + } + + #[test] + fn to_cln_backup_encodes_modern_scb_for_ipv4_peer() { + let peer = peer_id(0xaa); + let channel_key = channel_key(&peer, 42); + let snapshot = SignerBackupSnapshot { + version: BACKUP_VERSION, + created_at: "2026-04-29T00:00:00Z".to_string(), + node_id: hex::encode([2u8; 33]), + strategy: SignerBackupStrategy::NewChannelsOnly, + state: state(json!({ + channel_key.clone(): [1, channel(recovery_setup(full_txid(), 0x0102, 1_000_000, true))] + })), + peerlist: vec![PeerlistEntry { + peer_id: peer.clone(), + addr: "127.0.0.1:9735".to_string(), + direction: "out".to_string(), + features: "".to_string(), + generation: Some(1), + raw_datastore_string: "{}".to_string(), + }], + }; + + let export = snapshot + .to_cln_backup(CLNBackupOptions::default()) + .unwrap(); + let scb = hex::decode(&export.request.scb[0]).unwrap(); + let txid = expected_cln_txid(full_txid()); + let mut channel_id = txid; + channel_id[30] ^= 0x01; + channel_id[31] ^= 0x02; + + assert_eq!(&scb[0..8], &42u64.to_be_bytes()); + assert_eq!(&scb[8..40], &channel_id); + assert_eq!(export.channels[0].channel_id, hex::encode(channel_id)); + assert_eq!(&scb[40..73], hex::decode(&peer).unwrap()); + assert_eq!(&scb[73..80], hex::decode("017f0000012607").unwrap()); + assert_eq!(&scb[80..112], &txid); + assert_eq!(&scb[112..116], &0x0102u32.to_be_bytes()); + assert_eq!(&scb[116..124], &1_000_000u64.to_be_bytes()); + + let channel_type_len = u16::from_be_bytes(scb[124..126].try_into().unwrap()) as usize; + assert!(channel_type_len > 0); + let tlv_len_offset = 126 + channel_type_len; + let tlv_len = + u32::from_be_bytes(scb[tlv_len_offset..tlv_len_offset + 4].try_into().unwrap()) + as usize; + let tlvs = &scb[tlv_len_offset + 4..]; + assert_eq!(tlvs.len(), tlv_len); + assert_eq!(tlvs[0], 3); + assert_eq!(tlvs[1], 132); + assert_eq!( + &tlvs[2..35], + hex::decode("02eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee") + .unwrap() + ); + assert!(tlvs.windows(3).any(|window| window == [5, 1, 0])); + assert!(tlvs.windows(4).any(|window| window == [7, 2, 0, 144])); + } + + #[test] + fn to_cln_backup_rejects_malformed_export_fields() { + let peer = peer_id(0xaa); + let mut setup = recovery_setup(full_txid(), 0, 1000, true); + setup["commitment_type"] = json!("Unknown"); + let snapshot = SignerBackupSnapshot { + version: BACKUP_VERSION, + created_at: "2026-04-29T00:00:00Z".to_string(), + node_id: hex::encode([2u8; 33]), + strategy: SignerBackupStrategy::NewChannelsOnly, + state: state(json!({ + channel_key(&peer, 1): [1, channel(setup)] + })), + peerlist: vec![PeerlistEntry { + peer_id: peer, + addr: "127.0.0.1:9735".to_string(), + direction: "out".to_string(), + features: "".to_string(), + generation: Some(1), + raw_datastore_string: "{}".to_string(), + }], + }; + + let err = snapshot + .to_cln_backup(CLNBackupOptions::default()) + .unwrap_err() + .to_string(); + + assert!(err.contains("unsupported commitment type")); + } + + #[test] + fn encode_wireaddr_supports_ipv4_ipv6_dns_and_tor_v3() { + let tor = format!("{}.onion:9735", "a".repeat(56)); + + assert_eq!( + encode_wireaddr("127.0.0.1:9735").unwrap(), + hex::decode("017f0000012607").unwrap() + ); + assert_eq!( + encode_wireaddr("[2001:db8::1]:9735").unwrap(), + hex::decode("0220010db80000000000000000000000012607").unwrap() + ); + assert_eq!( + encode_wireaddr("example.com:9735").unwrap(), + hex::decode("050b6578616d706c652e636f6d2607").unwrap() + ); + + let encoded_tor = encode_wireaddr(&tor).unwrap(); + assert_eq!(encoded_tor.len(), 38); + assert_eq!(encoded_tor[0], 4); + assert_eq!(&encoded_tor[1..36], &[0u8; 35]); + assert_eq!(&encoded_tor[36..38], &9735u16.to_be_bytes()); + } + + #[test] + fn encode_channel_type_preserves_legacy_as_empty_features() { + assert_eq!(encode_channel_type("Legacy").unwrap(), Vec::::new()); + assert!(!encode_channel_type("StaticRemoteKey").unwrap().is_empty()); + } + #[test] fn write_snapshot_fails_when_parent_is_missing() { let dir = tempfile::tempdir().unwrap(); diff --git a/libs/gl-client/src/signer/mod.rs b/libs/gl-client/src/signer/mod.rs index 0510d0759..9421d8e5e 100644 --- a/libs/gl-client/src/signer/mod.rs +++ b/libs/gl-client/src/signer/mod.rs @@ -51,7 +51,9 @@ mod auth; mod backup; pub use backup::{ PeerlistEntry, RecoverableBasepoints, RecoverableChannel, RecoverableChannelOpener, - RecoverableFundingOutpoint, SignerBackupSnapshot, + RecoverableFundingOutpoint, CLNBackup, CLNBackupChannel, + CLNBackupOptions, RecoverchannelRequest, RecoverchannelSkippedChannel, + SignerBackupSnapshot, }; pub mod model; mod report; From 6eea3a8d449666e52e66bf75db4677527891099a Mon Sep 17 00:00:00 2001 From: Ihor Diachenko Date: Thu, 30 Apr 2026 18:34:14 +0300 Subject: [PATCH 06/12] gl-cli: add signer backup run flags --- libs/gl-cli/src/signer.rs | 240 +++++++++++++++++++++++++++++++++++++- 1 file changed, 234 insertions(+), 6 deletions(-) diff --git a/libs/gl-cli/src/signer.rs b/libs/gl-cli/src/signer.rs index 4585ebbf6..88581d9b1 100644 --- a/libs/gl-cli/src/signer.rs +++ b/libs/gl-cli/src/signer.rs @@ -4,8 +4,8 @@ use clap::{Subcommand, ValueEnum}; use core::fmt::Debug; use gl_client::signer::{ RecoverableChannel, CLNBackup, CLNBackupOptions, Signer, - SignerBackupSnapshot, SignerBackupStrategy, SignerConfig, StateSignatureMode, - StateSignatureOverrideConfig, + SignerBackupConfig, SignerBackupSnapshot, SignerBackupStrategy, SignerConfig, + StateSignatureMode, StateSignatureOverrideConfig, }; use lightning_signer::bitcoin::Network; use serde::Serialize; @@ -41,6 +41,12 @@ impl From for StateSignatureMode { } } +#[derive(Copy, Clone, Debug, Eq, PartialEq, ValueEnum)] +pub enum BackupStrategyArg { + NewChannelsOnly, + Periodic, +} + #[derive(Copy, Clone, Debug, Eq, PartialEq, ValueEnum)] pub enum BackupInspectFormat { Json, @@ -74,6 +80,12 @@ pub enum Command { state_override: Option, #[arg(long = "state-override-note")] state_override_note: Option, + #[arg(long = "backup-path")] + backup_path: Option, + #[arg(long = "backup-strategy", value_enum)] + backup_strategy: Option, + #[arg(long = "backup-periodic-updates")] + backup_periodic_updates: Option, }, /// Inspects a local signer backup file InspectBackup { @@ -103,12 +115,18 @@ pub async fn command_handler>(cmd: Command, config: Config

) -> state_signature_mode, state_override, state_override_note, + backup_path, + backup_strategy, + backup_periodic_updates, } => { run_handler( config, state_signature_mode, state_override, state_override_note, + backup_path, + backup_strategy, + backup_periodic_updates, ) .await } @@ -300,6 +318,9 @@ async fn run_handler>( state_signature_mode: StateSignatureModeArg, state_override: Option, state_override_note: Option, + backup_path: Option, + backup_strategy: Option, + backup_periodic_updates: Option, ) -> Result<()> { // Check if we can find a seed file, if we can not find one, we need to register first. let seed_path = config.data_dir.as_ref().join(SEED_FILE_NAME); @@ -338,6 +359,7 @@ async fn run_handler>( note: state_override_note, } }); + let backup = backup_config_from_args(backup_path, backup_strategy, backup_periodic_updates)?; let signer = Signer::new_with_config( seed, @@ -346,7 +368,7 @@ async fn run_handler>( SignerConfig { state_signature_mode: state_signature_mode.into(), state_signature_override, - backup: None, + backup, }, ) .map_err(|e| Error::custom(format!("Failed to create signer: {}", e)))?; @@ -364,15 +386,53 @@ async fn run_handler>( Ok(()) } +fn backup_config_from_args( + backup_path: Option, + backup_strategy: Option, + backup_periodic_updates: Option, +) -> Result> { + let Some(path) = backup_path else { + if backup_strategy.is_some() { + return Err(Error::custom("--backup-strategy requires --backup-path")); + } + if backup_periodic_updates.is_some() { + return Err(Error::custom( + "--backup-periodic-updates requires --backup-path", + )); + } + return Ok(None); + }; + + match backup_strategy.unwrap_or(BackupStrategyArg::NewChannelsOnly) { + BackupStrategyArg::NewChannelsOnly => { + if backup_periodic_updates.is_some() { + return Err(Error::custom( + "--backup-periodic-updates requires --backup-strategy periodic", + )); + } + Ok(Some(SignerBackupConfig::new(path))) + } + BackupStrategyArg::Periodic => { + let updates = backup_periodic_updates.ok_or_else(|| { + Error::custom("--backup-periodic-updates is required for periodic backup strategy") + })?; + SignerBackupConfig::periodic(path, updates) + .map(Some) + .map_err(Error::custom) + } + } +} + #[cfg(test)] mod tests { use super::{ - convert_backup_output, format_backup_report_text, inspect_backup_report, - BackupConvertFormat, BackupInspectFormat, Command, StateSignatureModeArg, + backup_config_from_args, convert_backup_output, format_backup_report_text, + inspect_backup_report, BackupConvertFormat, BackupInspectFormat, BackupStrategyArg, + Command, SignerBackupStrategy, StateSignatureModeArg, }; use clap::{Parser, Subcommand}; use serde_json::json; - use std::path::Path; + use std::path::{Path, PathBuf}; #[derive(Parser, Debug)] struct TestCli { @@ -394,10 +454,16 @@ mod tests { state_signature_mode, state_override, state_override_note, + backup_path, + backup_strategy, + backup_periodic_updates, } => { assert_eq!(state_signature_mode, StateSignatureModeArg::Hard); assert!(state_override.is_none()); assert!(state_override_note.is_none()); + assert!(backup_path.is_none()); + assert!(backup_strategy.is_none()); + assert!(backup_periodic_updates.is_none()); } _ => panic!("expected run command"), } @@ -411,10 +477,16 @@ mod tests { state_signature_mode, state_override, state_override_note, + backup_path, + backup_strategy, + backup_periodic_updates, } => { assert_eq!(state_signature_mode, StateSignatureModeArg::Soft); assert!(state_override.is_none()); assert!(state_override_note.is_none()); + assert!(backup_path.is_none()); + assert!(backup_strategy.is_none()); + assert!(backup_periodic_updates.is_none()); } _ => panic!("expected run command"), } @@ -435,10 +507,16 @@ mod tests { state_signature_mode, state_override, state_override_note, + backup_path, + backup_strategy, + backup_periodic_updates, }) => { assert_eq!(state_signature_mode, StateSignatureModeArg::Off); assert!(state_override.is_none()); assert!(state_override_note.is_none()); + assert!(backup_path.is_none()); + assert!(backup_strategy.is_none()); + assert!(backup_periodic_updates.is_none()); } _ => panic!("expected signer run"), } @@ -461,6 +539,9 @@ mod tests { state_signature_mode, state_override, state_override_note, + backup_path, + backup_strategy, + backup_periodic_updates, } => { assert_eq!(state_signature_mode, StateSignatureModeArg::Hard); assert_eq!( @@ -468,11 +549,158 @@ mod tests { Some("I_ACCEPT_OPERATOR_ASSISTED_STATE_OVERRIDE") ); assert_eq!(state_override_note.as_deref(), Some("debug session")); + assert!(backup_path.is_none()); + assert!(backup_strategy.is_none()); + assert!(backup_periodic_updates.is_none()); + } + _ => panic!("expected run command"), + } + } + + #[test] + fn parse_run_backup_path_defaults_to_new_channels_only() { + let cli = TestCli::parse_from(["test", "run", "--backup-path", "backup.json"]); + match cli.cmd { + Command::Run { + backup_path, + backup_strategy, + backup_periodic_updates, + .. + } => { + assert_eq!(backup_path.as_deref(), Some(Path::new("backup.json"))); + assert!(backup_strategy.is_none()); + assert!(backup_periodic_updates.is_none()); } _ => panic!("expected run command"), } } + #[test] + fn parse_run_backup_new_channels_strategy() { + let cli = TestCli::parse_from([ + "test", + "run", + "--backup-path", + "backup.json", + "--backup-strategy", + "new-channels-only", + ]); + match cli.cmd { + Command::Run { + backup_path, + backup_strategy, + backup_periodic_updates, + .. + } => { + assert_eq!(backup_path.as_deref(), Some(Path::new("backup.json"))); + assert_eq!(backup_strategy, Some(BackupStrategyArg::NewChannelsOnly)); + assert!(backup_periodic_updates.is_none()); + } + _ => panic!("expected run command"), + } + } + + #[test] + fn parse_run_backup_periodic_strategy() { + let cli = TestCli::parse_from([ + "test", + "run", + "--backup-path", + "backup.json", + "--backup-strategy", + "periodic", + "--backup-periodic-updates", + "10", + ]); + match cli.cmd { + Command::Run { + backup_path, + backup_strategy, + backup_periodic_updates, + .. + } => { + assert_eq!(backup_path.as_deref(), Some(Path::new("backup.json"))); + assert_eq!(backup_strategy, Some(BackupStrategyArg::Periodic)); + assert_eq!(backup_periodic_updates, Some(10)); + } + _ => panic!("expected run command"), + } + } + + #[test] + fn parse_run_backup_rejects_invalid_strategy() { + assert!(TestCli::try_parse_from([ + "test", + "run", + "--backup-path", + "backup.json", + "--backup-strategy", + "always", + ]) + .is_err()); + } + + #[test] + fn backup_config_from_args_validates_backup_flags() { + assert!(backup_config_from_args(None, None, None).unwrap().is_none()); + + let config = + backup_config_from_args(Some(PathBuf::from("backup.json")), None, None).unwrap(); + let config = config.unwrap(); + assert_eq!(config.path, PathBuf::from("backup.json")); + assert_eq!(config.strategy, SignerBackupStrategy::NewChannelsOnly); + + let config = backup_config_from_args( + Some(PathBuf::from("backup.json")), + Some(BackupStrategyArg::Periodic), + Some(10), + ) + .unwrap() + .unwrap(); + assert_eq!(config.strategy, SignerBackupStrategy::Periodic { updates: 10 }); + } + + #[test] + fn backup_config_from_args_rejects_invalid_backup_flags() { + let strategy_without_path = + backup_config_from_args(None, Some(BackupStrategyArg::Periodic), None) + .unwrap_err() + .to_string(); + assert!(strategy_without_path.contains("--backup-strategy requires --backup-path")); + + let updates_without_path = backup_config_from_args(None, None, Some(10)) + .unwrap_err() + .to_string(); + assert!(updates_without_path.contains("--backup-periodic-updates requires --backup-path")); + + let periodic_without_updates = backup_config_from_args( + Some(PathBuf::from("backup.json")), + Some(BackupStrategyArg::Periodic), + None, + ) + .unwrap_err() + .to_string(); + assert!(periodic_without_updates.contains("--backup-periodic-updates is required")); + + let updates_with_new_channels = backup_config_from_args( + Some(PathBuf::from("backup.json")), + Some(BackupStrategyArg::NewChannelsOnly), + Some(10), + ) + .unwrap_err() + .to_string(); + assert!(updates_with_new_channels.contains("--backup-strategy periodic")); + + let zero_updates = backup_config_from_args( + Some(PathBuf::from("backup.json")), + Some(BackupStrategyArg::Periodic), + Some(0), + ) + .unwrap_err() + .to_string(); + assert!(zero_updates.contains("periodic signer backup updates must be greater than zero")); + } + #[test] fn parse_inspect_backup_defaults_to_json() { let cli = TestCli::parse_from(["test", "inspect-backup", "--path", "backup.json"]); From 16abbeb558799e7059655d42a534d007d943081e Mon Sep 17 00:00:00 2001 From: Ihor Diachenko Date: Thu, 30 Apr 2026 19:25:06 +0300 Subject: [PATCH 07/12] docs: add signer backup reference --- docs/mkdocs.yml | 1 + docs/src/reference/signer-backups.md | 93 ++++++++++++++++++++++++++++ 2 files changed, 94 insertions(+) create mode 100644 docs/src/reference/signer-backups.md diff --git a/docs/mkdocs.yml b/docs/mkdocs.yml index c4e1fe1d8..74ef4d02d 100644 --- a/docs/mkdocs.yml +++ b/docs/mkdocs.yml @@ -83,6 +83,7 @@ nav: - Testserver: reference/testserver.md - Certificates: reference/certs.md - Security: reference/security.md + - Signer Backups: reference/signer-backups.md - LSP Integration: reference/lsp.md - Webhooks: reference/webhooks.md - Pairing: reference/pairing.md diff --git a/docs/src/reference/signer-backups.md b/docs/src/reference/signer-backups.md new file mode 100644 index 000000000..1c481bf01 --- /dev/null +++ b/docs/src/reference/signer-backups.md @@ -0,0 +1,93 @@ +# Signer Backups + +Greenlight signers can keep a local copy of the VLS signer state to enable migration to a self-hosted node. This backup is opt-in and disabled by default. When enabled, the backup file contains the signer state plus the peer address list needed to +build channel recovery data. + +## Enable backups + +Start the signer with a backup path: + +```bash +glcli signer run --backup-path backup.json +``` + +By default, the signer writes a snapshot when a new recoverable channel appears. +For periodic snapshots, use: + +```bash +glcli signer run \ + --backup-path backup.json \ + --backup-strategy periodic \ + --backup-periodic-updates 10 +``` + +The backup file is not created immediately at process startup. It is created +after a snapshot trigger, such as a new recoverable channel or the configured +periodic update threshold. + +If a backup write or peer-list refresh fails, the signer logs the error and +continues. Check signer logs when relying on local backups. + +## Inspect a backup + +Use `inspect-backup` to verify that the file can be read and to list the +recoverable channel inventory: + +```bash +glcli signer inspect-backup --path backup.json +``` + +For human-readable output: + +```bash +glcli signer inspect-backup --path backup.json --format text +``` + +Channels with missing peer addresses are marked incomplete. Incomplete channels +remain visible, but they cannot be converted into complete CLN +recovery entries until an address is available. + +## Convert for Core Lightning + +Convert the signer backup to Core Lightning recovery input: + +```bash +glcli signer convert-backup --path backup.json --format cln +``` + +To write the converted recovery request to a file: + +```bash +glcli signer convert-backup \ + --path backup.json \ + --format cln \ + --output cln-recoverchannel.json +``` + +The output is a CLN `recoverchannel` request body containing `scb` entries: + +```json +{ + "scb": [""] +} +``` + +Pass the generated `scb` array to CLN's `recoverchannel` RPC. The exact command +depends on the CLN RPC client you are using. + +If the backup contains incomplete channels and you still want to export the +complete ones, use: + +```bash +glcli signer convert-backup \ + --path backup.json \ + --format cln \ + --skip-incomplete +``` + +## Current limitations + +- CLN conversion assumes current v1 channels where the channel id is derived + from the funding outpoint. +- The CLN shachain TLV is currently omitted. +- Missing peer addresses make affected channels incomplete. From 2b0d046a2b5234b8c8d76e9b5f4f18e600825f57 Mon Sep 17 00:00:00 2001 From: Ihor Diachenko Date: Thu, 30 Apr 2026 21:22:02 +0300 Subject: [PATCH 08/12] signer: encode CLN shachain in backup conversion --- docs/src/reference/signer-backups.md | 10 +- libs/gl-cli/src/signer.rs | 43 ++- libs/gl-client/src/signer/backup.rs | 453 ++++++++++++++++++++++++++- 3 files changed, 488 insertions(+), 18 deletions(-) diff --git a/docs/src/reference/signer-backups.md b/docs/src/reference/signer-backups.md index 1c481bf01..e183508d0 100644 --- a/docs/src/reference/signer-backups.md +++ b/docs/src/reference/signer-backups.md @@ -72,8 +72,13 @@ The output is a CLN `recoverchannel` request body containing `scb` entries: } ``` +When VLS counterparty revocation secrets are present in the backup, the +converted CLN SCB entries include the shachain TLV. If that signer state is +absent, conversion still emits CLN recovery input without the shachain TLV. + Pass the generated `scb` array to CLN's `recoverchannel` RPC. The exact command -depends on the CLN RPC client you are using. +depends on the CLN RPC client you are using. Greenlight does not execute +recovery; `convert-backup` only prepares CLN recovery input. If the backup contains incomplete channels and you still want to export the complete ones, use: @@ -89,5 +94,6 @@ glcli signer convert-backup \ - CLN conversion assumes current v1 channels where the channel id is derived from the funding outpoint. -- The CLN shachain TLV is currently omitted. +- The CLN shachain TLV is included only when VLS counterparty revocation + secrets are present in the backup. - Missing peer addresses make affected channels incomplete. diff --git a/libs/gl-cli/src/signer.rs b/libs/gl-cli/src/signer.rs index 88581d9b1..6f7ff9b0a 100644 --- a/libs/gl-cli/src/signer.rs +++ b/libs/gl-cli/src/signer.rs @@ -852,6 +852,39 @@ mod tests { assert_eq!(incomplete.warnings, vec!["missing_peer_addr".to_string()]); } + #[test] + fn inspect_backup_report_does_not_expose_counterparty_secrets() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("backup.json"); + let peer = peer_id(0xaa); + write_json( + &path, + backup_json( + json!({ + channel_key(&peer, 1): [1, channel_with_enforcement( + recovery_setup("00", 0, 1000, true), + json!({ + "counterparty_secrets": { + "old_secrets": [ + [vec![0x11; 32], (1u64 << 48) - 1] + ] + } + }) + )] + }), + json!([peerlist_entry(&peer, "127.0.0.1:9735")]), + json!("new_channels_only"), + ), + ); + + let report = inspect_backup_report(&path).unwrap(); + let serialized = serde_json::to_string(&report).unwrap(); + + assert_eq!(report.total_channels, 1); + assert!(!serialized.contains("counterparty_secrets")); + assert!(!serialized.contains("old_secrets")); + } + #[test] fn inspect_backup_report_accepts_periodic_strategy() { let dir = tempfile::tempdir().unwrap(); @@ -1062,11 +1095,19 @@ mod tests { } fn channel(channel_setup: serde_json::Value) -> serde_json::Value { + channel_with_enforcement(channel_setup, json!({})) + } + + fn channel_with_enforcement( + channel_setup: serde_json::Value, + enforcement_state: serde_json::Value, + ) -> serde_json::Value { json!({ "channel_setup": channel_setup, "id": { "id": "00" - } + }, + "enforcement_state": enforcement_state }) } diff --git a/libs/gl-client/src/signer/backup.rs b/libs/gl-client/src/signer/backup.rs index ec8f8f892..e666bdbde 100644 --- a/libs/gl-client/src/signer/backup.rs +++ b/libs/gl-client/src/signer/backup.rs @@ -18,7 +18,10 @@ const CLN_CHANNEL_KEY_LEN: usize = PEER_ID_LEN + CLN_DBID_LEN; const VLS_CHANNEL_KEY_LEN: usize = NODE_ID_LEN + CLN_CHANNEL_KEY_LEN; const TXID_LEN: usize = 32; const PUBKEY_LEN: usize = 33; -const SHACHAIN_OMITTED_WARNING: &str = "shachain_tlv_omitted"; +const SHACHAIN_SECRET_LEN: usize = 32; +const SHACHAIN_EMPTY_INDEX: u64 = 1 << 48; +const SHACHAIN_MAX_ENTRIES: usize = 49; +const SHACHAIN_MISSING_WARNING: &str = "shachain_tlv_missing"; #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] pub struct PeerlistEntry { @@ -116,6 +119,7 @@ impl SignerBackupSnapshot { options: CLNBackupOptions, ) -> Result { let recovery_data = self.recovery_data()?; + let shachains = self.recoverable_channel_shachains()?; let total_channels = recovery_data.len(); let mut request = RecoverchannelRequest { scb: vec![] }; let mut channels = Vec::new(); @@ -139,7 +143,13 @@ impl SignerBackupSnapshot { )); } - let encoded = encode_recoverchannel_scb(&channel).map_err(|e| { + let old_secrets = shachains + .get(&channel.channel_key) + .ok_or_else(|| { + anyhow!("missing shachain state for channel {}", channel.channel_key) + })? + .as_deref(); + let encoded = encode_recoverchannel_scb(&channel, old_secrets).map_err(|e| { anyhow!( "encoding recoverchannel SCB for {}: {}", channel.channel_key, @@ -155,7 +165,7 @@ impl SignerBackupSnapshot { cln_dbid: encoded.cln_dbid, channel_id: encoded.channel_id, scb: encoded.scb, - warnings: vec![SHACHAIN_OMITTED_WARNING.to_string()], + warnings: encoded.warnings, }); } @@ -186,6 +196,31 @@ impl SignerBackupSnapshot { self.strategy.validate() } + + fn recoverable_channel_shachains( + &self, + ) -> Result>>> { + self.state + .omit_tombstones() + .recoverable_channel_values() + .into_iter() + .map(|(channel_key, value)| { + let entry: ClnChannelEntry = serde_json::from_value(value).with_context(|| { + format!( + "parsing CLN shachain state for recoverable channel {}", + channel_key + ) + })?; + Ok(( + channel_key, + entry + .enforcement_state + .and_then(|state| state.counterparty_secrets) + .map(|secrets| secrets.old_secrets), + )) + }) + .collect() + } } #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] @@ -269,6 +304,24 @@ struct ChannelEntry { channel_setup: Option, } +#[derive(Deserialize)] +struct ClnChannelEntry { + enforcement_state: Option, +} + +#[derive(Deserialize)] +struct ChannelEnforcementState { + counterparty_secrets: Option, +} + +#[derive(Deserialize)] +struct CounterpartySecrets { + old_secrets: Vec, +} + +#[derive(Clone, Debug, PartialEq, Eq, Deserialize)] +struct CounterpartySecret(Vec, u64); + #[derive(Deserialize)] struct ChannelSetup { channel_value_sat: u64, @@ -413,6 +466,7 @@ struct EncodedRecoverchannelScb { cln_dbid: u64, channel_id: String, scb: String, + warnings: Vec, } struct ClnChannelKey { @@ -420,7 +474,10 @@ struct ClnChannelKey { dbid: u64, } -fn encode_recoverchannel_scb(channel: &RecoverableChannel) -> Result { +fn encode_recoverchannel_scb( + channel: &RecoverableChannel, + old_secrets: Option<&[CounterpartySecret]>, +) -> Result { let channel_key = decode_cln_channel_key(&channel.channel_key)?; let expected_peer_id = decode_hex_array::("peer_id", &channel.peer_id)?; if channel_key.peer_id != expected_peer_id { @@ -440,7 +497,7 @@ fn encode_recoverchannel_scb(channel: &RecoverableChannel) -> Result Result Result> { )) } -fn encode_scb_tlvs(channel: &RecoverableChannel) -> Result> { +fn encode_scb_tlvs( + channel: &RecoverableChannel, + old_secrets: Option<&[CounterpartySecret]>, +) -> Result<(Vec, Vec)> { let mut tlvs = Vec::new(); + let mut warnings = Vec::new(); + + match old_secrets { + Some(old_secrets) => { + if let Some(shachain) = encode_shachain(old_secrets)? { + put_tlv(&mut tlvs, 1, &shachain); + } + } + None => warnings.push(SHACHAIN_MISSING_WARNING.to_string()), + } let mut basepoints = Vec::new(); basepoints.extend(decode_hex_array::( @@ -558,7 +629,97 @@ fn encode_scb_tlvs(channel: &RecoverableChannel) -> Result> { put_u16(&mut delay, remote_to_self_delay); put_tlv(&mut tlvs, 7, &delay); - Ok(tlvs) + Ok((tlvs, warnings)) +} + +fn encode_shachain(old_secrets: &[CounterpartySecret]) -> Result>> { + if old_secrets.len() > SHACHAIN_MAX_ENTRIES { + return Err(anyhow!( + "shachain has {} entries, maximum is {}", + old_secrets.len(), + SHACHAIN_MAX_ENTRIES + )); + } + + let mut known = Vec::new(); + let mut trailing_dummy_start = None; + + for (position, secret) in old_secrets.iter().enumerate() { + if is_dummy_shachain_secret(secret) { + trailing_dummy_start = Some(position); + break; + } + + if secret.0.len() != SHACHAIN_SECRET_LEN { + return Err(anyhow!( + "shachain secret at position {} must be {} bytes, got {}", + position, + SHACHAIN_SECRET_LEN, + secret.0.len() + )); + } + + let expected_position = shachain_position(secret.1)?; + if expected_position != position { + return Err(anyhow!( + "shachain secret index {} belongs at position {}, found at position {}", + secret.1, + expected_position, + position + )); + } + + known.push(secret); + } + + if let Some(start) = trailing_dummy_start { + for (position, secret) in old_secrets.iter().enumerate().skip(start) { + if !is_dummy_shachain_secret(secret) { + return Err(anyhow!( + "missing shachain position {} before real secret at position {}", + start, + position + )); + } + } + } + + if known.is_empty() { + return Ok(None); + } + + let min_index = known + .iter() + .map(|secret| secret.1) + .min() + .expect("known is not empty"); + let mut shachain = Vec::new(); + put_u64(&mut shachain, min_index); + put_u32(&mut shachain, known.len().try_into()?); + for secret in known { + put_u64(&mut shachain, secret.1); + shachain.extend(&secret.0); + } + + Ok(Some(shachain)) +} + +fn is_dummy_shachain_secret(secret: &CounterpartySecret) -> bool { + secret.1 == SHACHAIN_EMPTY_INDEX && secret.0.iter().all(|byte| *byte == 0) +} + +fn shachain_position(index: u64) -> Result { + if index >= SHACHAIN_EMPTY_INDEX { + return Err(anyhow!("invalid shachain index {}", index)); + } + + for position in 0..48 { + if index & (1u64 << position) == (1u64 << position) { + return Ok(position); + } + } + + Ok(48) } fn encode_wireaddr(addr: &str) -> Result> { @@ -752,15 +913,42 @@ mod tests { } fn channel(setup: serde_json::Value) -> serde_json::Value { + channel_with_enforcement(setup, json!({})) + } + + fn channel_with_enforcement( + setup: serde_json::Value, + enforcement_state: serde_json::Value, + ) -> serde_json::Value { json!({ "channel_setup": setup, "channel_value_satoshis": 1000, "id": null, - "enforcement_state": {}, + "enforcement_state": enforcement_state, "blockheight": null }) } + fn enforcement_with_old_secrets(old_secrets: serde_json::Value) -> serde_json::Value { + json!({ + "counterparty_secrets": { + "old_secrets": old_secrets + } + }) + } + + fn old_secret(byte: u8, index: u64) -> serde_json::Value { + json!([vec![byte; SHACHAIN_SECRET_LEN], index]) + } + + fn malformed_old_secret(secret: Vec, index: u64) -> serde_json::Value { + json!([secret, index]) + } + + fn dummy_old_secret() -> serde_json::Value { + json!([vec![0u8; SHACHAIN_SECRET_LEN], SHACHAIN_EMPTY_INDEX]) + } + fn peer_entry( key: Vec<&str>, generation: Option, @@ -821,6 +1009,17 @@ mod tests { bytes } + fn scb_tlvs(scb: &[u8]) -> &[u8] { + let channel_type_len = u16::from_be_bytes(scb[124..126].try_into().unwrap()) as usize; + let tlv_len_offset = 126 + channel_type_len; + let tlv_len = + u32::from_be_bytes(scb[tlv_len_offset..tlv_len_offset + 4].try_into().unwrap()) + as usize; + let tlvs = &scb[tlv_len_offset + 4..]; + assert_eq!(tlvs.len(), tlv_len); + tlvs + } + fn write_json(path: &Path, value: serde_json::Value) { std::fs::write(path, serde_json::to_vec_pretty(&value).unwrap()).unwrap(); } @@ -1210,7 +1409,10 @@ mod tests { assert_eq!(export.skipped_channels, 1); assert_eq!(export.channels[0].channel_key, channel_a); assert_eq!(export.channels[0].cln_dbid, 1); - assert_eq!(export.channels[0].warnings, vec![SHACHAIN_OMITTED_WARNING]); + assert_eq!( + export.channels[0].warnings, + vec![SHACHAIN_MISSING_WARNING.to_string()] + ); assert_eq!(export.skipped[0].channel_key, channel_b); assert_eq!(export.skipped[0].warnings, vec!["missing_peer_addr"]); } @@ -1281,12 +1483,7 @@ mod tests { let channel_type_len = u16::from_be_bytes(scb[124..126].try_into().unwrap()) as usize; assert!(channel_type_len > 0); - let tlv_len_offset = 126 + channel_type_len; - let tlv_len = - u32::from_be_bytes(scb[tlv_len_offset..tlv_len_offset + 4].try_into().unwrap()) - as usize; - let tlvs = &scb[tlv_len_offset + 4..]; - assert_eq!(tlvs.len(), tlv_len); + let tlvs = scb_tlvs(&scb); assert_eq!(tlvs[0], 3); assert_eq!(tlvs[1], 132); assert_eq!( @@ -1298,6 +1495,232 @@ mod tests { assert!(tlvs.windows(4).any(|window| window == [7, 2, 0, 144])); } + #[test] + fn to_cln_backup_encodes_shachain_tlv_when_secrets_are_present() { + let peer = peer_id(0xaa); + let channel_key = channel_key(&peer, 42); + let first_index = SHACHAIN_EMPTY_INDEX - 1; + let second_index = SHACHAIN_EMPTY_INDEX - 2; + let snapshot = SignerBackupSnapshot { + version: BACKUP_VERSION, + created_at: "2026-04-29T00:00:00Z".to_string(), + node_id: hex::encode([2u8; 33]), + strategy: SignerBackupStrategy::NewChannelsOnly, + state: state(json!({ + channel_key.clone(): [1, channel_with_enforcement( + recovery_setup(full_txid(), 0, 1_000_000, true), + enforcement_with_old_secrets(json!([ + old_secret(0x11, first_index), + old_secret(0x22, second_index) + ])) + )] + })), + peerlist: vec![PeerlistEntry { + peer_id: peer, + addr: "127.0.0.1:9735".to_string(), + direction: "out".to_string(), + features: "".to_string(), + generation: Some(1), + raw_datastore_string: "{}".to_string(), + }], + }; + + let export = snapshot.to_cln_backup(CLNBackupOptions::default()).unwrap(); + let scb = hex::decode(&export.request.scb[0]).unwrap(); + let tlvs = scb_tlvs(&scb); + + assert!(export.channels[0].warnings.is_empty()); + assert_eq!(tlvs[0], 1); + assert_eq!(tlvs[1], 92); + let shachain = &tlvs[2..94]; + assert_eq!( + u64::from_be_bytes(shachain[0..8].try_into().unwrap()), + second_index + ); + assert_eq!(u32::from_be_bytes(shachain[8..12].try_into().unwrap()), 2); + assert_eq!( + u64::from_be_bytes(shachain[12..20].try_into().unwrap()), + first_index + ); + assert_eq!(&shachain[20..52], &[0x11; SHACHAIN_SECRET_LEN]); + assert_eq!( + u64::from_be_bytes(shachain[52..60].try_into().unwrap()), + second_index + ); + assert_eq!(&shachain[60..92], &[0x22; SHACHAIN_SECRET_LEN]); + assert_eq!(tlvs[94], 3); + } + + #[test] + fn to_cln_backup_omits_empty_shachain_without_warning() { + let peer = peer_id(0xaa); + let channel_key = channel_key(&peer, 42); + let snapshot = SignerBackupSnapshot { + version: BACKUP_VERSION, + created_at: "2026-04-29T00:00:00Z".to_string(), + node_id: hex::encode([2u8; 33]), + strategy: SignerBackupStrategy::NewChannelsOnly, + state: state(json!({ + channel_key: [1, channel_with_enforcement( + recovery_setup(full_txid(), 0, 1_000_000, true), + enforcement_with_old_secrets(json!([])) + )] + })), + peerlist: vec![PeerlistEntry { + peer_id: peer, + addr: "127.0.0.1:9735".to_string(), + direction: "out".to_string(), + features: "".to_string(), + generation: Some(1), + raw_datastore_string: "{}".to_string(), + }], + }; + + let export = snapshot.to_cln_backup(CLNBackupOptions::default()).unwrap(); + let scb = hex::decode(&export.request.scb[0]).unwrap(); + let tlvs = scb_tlvs(&scb); + + assert!(export.channels[0].warnings.is_empty()); + assert_eq!(tlvs[0], 3); + } + + #[test] + fn to_cln_backup_warns_when_counterparty_secrets_are_missing() { + let peer = peer_id(0xaa); + let channel_key = channel_key(&peer, 42); + let snapshot = SignerBackupSnapshot { + version: BACKUP_VERSION, + created_at: "2026-04-29T00:00:00Z".to_string(), + node_id: hex::encode([2u8; 33]), + strategy: SignerBackupStrategy::NewChannelsOnly, + state: state(json!({ + channel_key: [1, channel_with_enforcement( + recovery_setup(full_txid(), 0, 1_000_000, true), + json!({ "counterparty_secrets": null }) + )] + })), + peerlist: vec![PeerlistEntry { + peer_id: peer, + addr: "127.0.0.1:9735".to_string(), + direction: "out".to_string(), + features: "".to_string(), + generation: Some(1), + raw_datastore_string: "{}".to_string(), + }], + }; + + let export = snapshot.to_cln_backup(CLNBackupOptions::default()).unwrap(); + + assert_eq!( + export.channels[0].warnings, + vec![SHACHAIN_MISSING_WARNING.to_string()] + ); + } + + #[test] + fn to_cln_backup_ignores_trailing_dummy_shachain_entries() { + let peer = peer_id(0xaa); + let channel_key = channel_key(&peer, 42); + let first_index = SHACHAIN_EMPTY_INDEX - 1; + let snapshot = SignerBackupSnapshot { + version: BACKUP_VERSION, + created_at: "2026-04-29T00:00:00Z".to_string(), + node_id: hex::encode([2u8; 33]), + strategy: SignerBackupStrategy::NewChannelsOnly, + state: state(json!({ + channel_key: [1, channel_with_enforcement( + recovery_setup(full_txid(), 0, 1_000_000, true), + enforcement_with_old_secrets(json!([ + old_secret(0x11, first_index), + dummy_old_secret(), + dummy_old_secret() + ])) + )] + })), + peerlist: vec![PeerlistEntry { + peer_id: peer, + addr: "127.0.0.1:9735".to_string(), + direction: "out".to_string(), + features: "".to_string(), + generation: Some(1), + raw_datastore_string: "{}".to_string(), + }], + }; + + let export = snapshot.to_cln_backup(CLNBackupOptions::default()).unwrap(); + let scb = hex::decode(&export.request.scb[0]).unwrap(); + let tlvs = scb_tlvs(&scb); + + assert!(export.channels[0].warnings.is_empty()); + assert_eq!(tlvs[0], 1); + assert_eq!(tlvs[1], 52); + let shachain = &tlvs[2..54]; + assert_eq!(u32::from_be_bytes(shachain[8..12].try_into().unwrap()), 1); + assert_eq!(tlvs[54], 3); + } + + #[test] + fn to_cln_backup_rejects_malformed_shachain_entries() { + let peer = peer_id(0xaa); + let channel_key = channel_key(&peer, 42); + + for (old_secrets, expected) in [ + ( + json!([malformed_old_secret( + vec![0x11; SHACHAIN_SECRET_LEN - 1], + SHACHAIN_EMPTY_INDEX - 1 + )]), + "must be 32 bytes", + ), + ( + json!([old_secret(0x22, SHACHAIN_EMPTY_INDEX - 2)]), + "belongs at position", + ), + ( + json!([ + old_secret(0x11, SHACHAIN_EMPTY_INDEX - 1), + dummy_old_secret(), + old_secret(0x22, SHACHAIN_EMPTY_INDEX - 2) + ]), + "missing shachain position", + ), + ( + json!([malformed_old_secret( + vec![0x11; SHACHAIN_SECRET_LEN], + SHACHAIN_EMPTY_INDEX + )]), + "invalid shachain index", + ), + ] { + let snapshot = SignerBackupSnapshot { + version: BACKUP_VERSION, + created_at: "2026-04-29T00:00:00Z".to_string(), + node_id: hex::encode([2u8; 33]), + strategy: SignerBackupStrategy::NewChannelsOnly, + state: state(json!({ + channel_key.clone(): [1, channel_with_enforcement( + recovery_setup(full_txid(), 0, 1_000_000, true), + enforcement_with_old_secrets(old_secrets) + )] + })), + peerlist: vec![PeerlistEntry { + peer_id: peer.clone(), + addr: "127.0.0.1:9735".to_string(), + direction: "out".to_string(), + features: "".to_string(), + generation: Some(1), + raw_datastore_string: "{}".to_string(), + }], + }; + + let err = snapshot + .to_cln_backup(CLNBackupOptions::default()) + .unwrap_err() + .to_string(); + assert!(err.contains(expected), "{err}"); + } + } + #[test] fn to_cln_backup_rejects_malformed_export_fields() { let peer = peer_id(0xaa); From 89d22e6fec0dd3e269327ec67d7588c54a5cccbd Mon Sep 17 00:00:00 2001 From: Ihor Diachenko Date: Fri, 1 May 2026 14:37:58 +0300 Subject: [PATCH 09/12] backups: fixed txid byte order --- libs/gl-client/src/signer/backup.rs | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/libs/gl-client/src/signer/backup.rs b/libs/gl-client/src/signer/backup.rs index e666bdbde..3af26db86 100644 --- a/libs/gl-client/src/signer/backup.rs +++ b/libs/gl-client/src/signer/backup.rs @@ -549,9 +549,7 @@ fn decode_cln_channel_key(channel_key: &str) -> Result { } fn decode_txid_for_cln_wire(txid: &str) -> Result<[u8; TXID_LEN]> { - let mut bytes = decode_hex_array::("funding txid", txid)?; - bytes.reverse(); - Ok(bytes) + decode_hex_array::("funding txid", txid) } fn derive_v1_channel_id(txid: [u8; TXID_LEN], vout: u32) -> [u8; TXID_LEN] { @@ -1004,9 +1002,7 @@ mod tests { } fn expected_cln_txid(txid: &str) -> [u8; 32] { - let mut bytes: [u8; 32] = hex::decode(txid).unwrap().try_into().unwrap(); - bytes.reverse(); - bytes + hex::decode(txid).unwrap().try_into().unwrap() } fn scb_tlvs(scb: &[u8]) -> &[u8] { From fa6acb2730a50d31abb9940f76302ab7d29f56c6 Mon Sep 17 00:00:00 2001 From: Ihor Diachenko Date: Fri, 1 May 2026 14:59:57 +0300 Subject: [PATCH 10/12] docs: update signer backups description --- docs/src/reference/signer-backups.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/src/reference/signer-backups.md b/docs/src/reference/signer-backups.md index e183508d0..5be682089 100644 --- a/docs/src/reference/signer-backups.md +++ b/docs/src/reference/signer-backups.md @@ -1,6 +1,6 @@ # Signer Backups -Greenlight signers can keep a local copy of the VLS signer state to enable migration to a self-hosted node. This backup is opt-in and disabled by default. When enabled, the backup file contains the signer state plus the peer address list needed to +Greenlight signers can keep a local copy of the VLS signer state to enable disaster recovery or migration to a self-hosted node. This backup is opt-in and disabled by default. When enabled, the backup file contains the signer state plus the peer address list needed to build channel recovery data. ## Enable backups From 3adc48f26ed4e96c5664455d02f5f2a9bbebba36 Mon Sep 17 00:00:00 2001 From: Ihor Diachenko Date: Tue, 12 May 2026 22:55:11 +0300 Subject: [PATCH 11/12] signer: store backup peers in signer state --- docs/src/reference/signer-backups.md | 28 +- libs/gl-cli/src/signer.rs | 34 +- libs/gl-client/src/persist.rs | 85 +++- libs/gl-client/src/signer/backup.rs | 611 +++++++++++---------------- libs/gl-client/src/signer/mod.rs | 104 ++--- libs/gl-plugin/src/events.rs | 7 + libs/gl-plugin/src/lib.rs | 23 +- libs/gl-plugin/src/node/mod.rs | 29 ++ 8 files changed, 439 insertions(+), 482 deletions(-) diff --git a/docs/src/reference/signer-backups.md b/docs/src/reference/signer-backups.md index 5be682089..2c104f79c 100644 --- a/docs/src/reference/signer-backups.md +++ b/docs/src/reference/signer-backups.md @@ -1,7 +1,6 @@ # Signer Backups -Greenlight signers can keep a local copy of the VLS signer state to enable disaster recovery or migration to a self-hosted node. This backup is opt-in and disabled by default. When enabled, the backup file contains the signer state plus the peer address list needed to -build channel recovery data. +Greenlight signers can keep a local copy of the VLS signer state to enable disaster recovery or migration to a self-hosted node. This backup is opt-in and disabled by default. When enabled, the backup file contains signer state entries for recoverable channels and known peers. ## Enable backups @@ -25,8 +24,19 @@ The backup file is not created immediately at process startup. It is created after a snapshot trigger, such as a new recoverable channel or the configured periodic update threshold. -If a backup write or peer-list refresh fails, the signer logs the error and -continues. Check signer logs when relying on local backups. +## Backup strategies + +`new-channels-only` is the default strategy. It writes a snapshot when a channel +first becomes recoverable, which keeps disk writes low while still capturing the +data needed to recover that channel later. + +`periodic` writes the initial snapshot for new recoverable channels and then +writes again after the configured number of recoverable channel updates. Use it +when you want the local file to track ongoing signer-state changes more closely, +at the cost of more frequent disk writes. + +If a backup write fails, the signer logs the error and continues. Check signer +logs when relying on local backups. ## Inspect a backup @@ -43,9 +53,9 @@ For human-readable output: glcli signer inspect-backup --path backup.json --format text ``` -Channels with missing peer addresses are marked incomplete. Incomplete channels -remain visible, but they cannot be converted into complete CLN -recovery entries until an address is available. +Channels with missing `peers/{peer_id}` signer-state entries are marked +incomplete. Incomplete channels remain visible, but they cannot be converted +into complete CLN recovery entries until an address is available. ## Convert for Core Lightning @@ -96,4 +106,6 @@ glcli signer convert-backup \ from the funding outpoint. - The CLN shachain TLV is included only when VLS counterparty revocation secrets are present in the backup. -- Missing peer addresses make affected channels incomplete. +- Missing `peers/{peer_id}` signer-state entries make affected channels + incomplete. This issue will gradually resolve as the signer receives peer + addresses during normal operation. diff --git a/libs/gl-cli/src/signer.rs b/libs/gl-cli/src/signer.rs index 6f7ff9b0a..acb65ead8 100644 --- a/libs/gl-cli/src/signer.rs +++ b/libs/gl-cli/src/signer.rs @@ -820,7 +820,7 @@ mod tests { channel_key(&peer_a, 2): [1, channel(recovery_setup("11", 1, 2000, false))], channel_key(&peer_b, 3): [1, channel(recovery_setup("22", 2, 3000, false))] }), - json!([peerlist_entry(&peer_a, "127.0.0.1:9735")]), + json!([peer_entry(&peer_a, "127.0.0.1:9735")]), json!("new_channels_only"), ), ); @@ -842,6 +842,7 @@ mod tests { assert!(serialized["channels"][0]["remote_basepoints"].is_object()); assert!(serialized.get("state").is_none()); assert!(serialized.get("peerlist").is_none()); + assert!(serialized.get("peers").is_none()); let incomplete = report .channels .iter() @@ -872,7 +873,7 @@ mod tests { }) )] }), - json!([peerlist_entry(&peer, "127.0.0.1:9735")]), + json!([peer_entry(&peer, "127.0.0.1:9735")]), json!("new_channels_only"), ), ); @@ -990,7 +991,7 @@ mod tests { json!({ channel_key(&peer, 7): [1, channel(recovery_setup(full_txid(), 0, 1000, true))] }), - json!([peerlist_entry(&peer, "127.0.0.1:9735")]), + json!([peer_entry(&peer, "127.0.0.1:9735")]), json!("new_channels_only"), ), ); @@ -1002,6 +1003,7 @@ mod tests { assert!(value.get("channels").is_none()); assert!(value.get("state").is_none()); assert!(value.get("peerlist").is_none()); + assert!(value.get("peers").is_none()); } #[test] @@ -1017,7 +1019,7 @@ mod tests { channel_key(&peer_a, 7): [1, channel(recovery_setup(full_txid(), 0, 1000, true))], channel_key(&peer_b, 8): [1, channel(recovery_setup(full_txid(), 1, 2000, false))] }), - json!([peerlist_entry(&peer_a, "127.0.0.1:9735")]), + json!([peer_entry(&peer_a, "127.0.0.1:9735")]), json!("new_channels_only"), ), ); @@ -1053,31 +1055,35 @@ mod tests { fn backup_json( channels: serde_json::Value, - peerlist: serde_json::Value, + peers: serde_json::Value, strategy: serde_json::Value, ) -> serde_json::Value { + let mut values = channels; + { + let values = values.as_object_mut().expect("backup state values object"); + for peer in peers.as_array().expect("backup peers array") { + let peer_id = peer["peer_id"].as_str().expect("peer_id"); + values.insert(format!("peers/{peer_id}"), json!([0, peer])); + } + } + json!({ "version": 1, "created_at": "2026-04-29T00:00:00Z", "node_id": hex::encode([2u8; 33]), "strategy": strategy, "state": { - "values": channels - }, - "peerlist": peerlist + "values": values + } }) } - fn peerlist_entry(peer_id: &str, addr: &str) -> serde_json::Value { + fn peer_entry(peer_id: &str, addr: &str) -> serde_json::Value { json!({ "peer_id": peer_id, "addr": addr, "direction": "out", - "features": "", - "generation": 7, - "raw_datastore_string": format!( - r#"{{"id":"{peer_id}","direction":"out","addr":"{addr}","features":""}}"# - ) + "features": "" }) } diff --git a/libs/gl-client/src/persist.rs b/libs/gl-client/src/persist.rs index 42b6e2020..f6f1eb079 100644 --- a/libs/gl-client/src/persist.rs +++ b/libs/gl-client/src/persist.rs @@ -28,8 +28,18 @@ const NODE_STATE_PREFIX: &str = "nodestates"; const CHANNEL_PREFIX: &str = "channels"; const ALLOWLIST_PREFIX: &str = "allowlists"; const TRACKER_PREFIX: &str = "trackers"; +const PEER_PREFIX: &str = "peers"; const TOMBSTONE_VERSION: u64 = u64::MAX; +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct PeerEntry { + pub peer_id: String, + pub addr: String, + pub direction: String, + #[serde(default)] + pub features: String, +} + #[derive(Clone, Debug, PartialEq)] struct StateEntry { version: u64, @@ -224,6 +234,22 @@ impl State { Ok(()) } + pub fn insert_or_update_peer(&mut self, peer: PeerEntry) -> Result<(), Error> { + let key = format!("{PEER_PREFIX}/{}", peer.peer_id); + self.ensure_not_tombstone(&key)?; + let value = serde_json::to_value(peer).unwrap(); + match self.values.get_mut(&key) { + Some(entry) => { + *entry = StateEntry::new(entry.version.saturating_add(1), value); + } + None => { + let version = self.next_version(&key); + self.values.insert(key, StateEntry::new(version, value)); + } + } + Ok(()) + } + fn get_channel( &self, key: &str, @@ -562,6 +588,15 @@ impl State { .map(|(key, value)| (key.clone(), value.value.clone())) .collect() } + + pub(crate) fn peer_values(&self) -> Vec<(String, serde_json::Value)> { + self.values + .iter() + .filter(|(_, value)| value.version != TOMBSTONE_VERSION) + .filter(|(key, _)| key.starts_with(&format!("{PEER_PREFIX}/"))) + .map(|(key, value)| (key.clone(), value.value.clone())) + .collect() + } } #[derive(Clone, Serialize, Deserialize, Debug, Default)] @@ -994,8 +1029,8 @@ mod tests { use crate::persist::TOMBSTONE_VERSION; use super::{ - State, StateEntry, StateSketch, ALLOWLIST_PREFIX, CHANNEL_PREFIX, NODE_PREFIX, - NODE_STATE_PREFIX, TRACKER_PREFIX, + PeerEntry, State, StateEntry, StateSketch, ALLOWLIST_PREFIX, CHANNEL_PREFIX, NODE_PREFIX, + NODE_STATE_PREFIX, PEER_PREFIX, TRACKER_PREFIX, }; use crate::pb::SignerStateEntry; use serde_json::json; @@ -1507,6 +1542,52 @@ mod tests { assert_tombstone(&state, &live_key); } + #[test] + fn insert_or_update_peer_keeps_last_known_address() { + let mut state = State::new(); + let key = format!("{PEER_PREFIX}/02aa"); + + state + .insert_or_update_peer(PeerEntry { + peer_id: "02aa".to_string(), + addr: "127.0.0.1:9735".to_string(), + direction: "out".to_string(), + features: "".to_string(), + }) + .unwrap(); + assert_entry( + &state, + &key, + 0, + json!({ + "peer_id": "02aa", + "addr": "127.0.0.1:9735", + "direction": "out", + "features": "" + }), + ); + + state + .insert_or_update_peer(PeerEntry { + peer_id: "02aa".to_string(), + addr: "127.0.0.2:9735".to_string(), + direction: "out".to_string(), + features: "abcd".to_string(), + }) + .unwrap(); + assert_entry( + &state, + &key, + 1, + json!({ + "peer_id": "02aa", + "addr": "127.0.0.2:9735", + "direction": "out", + "features": "abcd" + }), + ); + } + #[test] fn delete_node_creates_tombstones_for_node_related_keys() { let node_id = "deadbeef"; diff --git a/libs/gl-client/src/signer/backup.rs b/libs/gl-client/src/signer/backup.rs index 3af26db86..74142dbc1 100644 --- a/libs/gl-client/src/signer/backup.rs +++ b/libs/gl-client/src/signer/backup.rs @@ -1,4 +1,5 @@ use super::{SignerBackupConfig, SignerBackupStrategy}; +pub use crate::persist::PeerEntry; use crate::persist::State; use anyhow::{anyhow, Context, Result}; use serde::{Deserialize, Serialize}; @@ -10,7 +11,6 @@ use std::net::{IpAddr, SocketAddr}; use std::path::Path; const BACKUP_VERSION: u32 = 1; -const PEERLIST_PREFIX: [&str; 2] = ["greenlight", "peerlist"]; const NODE_ID_LEN: usize = 33; const PEER_ID_LEN: usize = 33; const CLN_DBID_LEN: usize = 8; @@ -23,32 +23,14 @@ const SHACHAIN_EMPTY_INDEX: u64 = 1 << 48; const SHACHAIN_MAX_ENTRIES: usize = 49; const SHACHAIN_MISSING_WARNING: &str = "shachain_tlv_missing"; -#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] -pub struct PeerlistEntry { - pub peer_id: String, - pub addr: String, - pub direction: String, - pub features: String, - pub generation: Option, - pub raw_datastore_string: String, -} - -#[derive(Deserialize)] -struct PeerRecord { - id: String, - direction: String, - addr: String, - features: String, -} - #[derive(Clone, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] pub struct SignerBackupSnapshot { pub version: u32, pub created_at: String, pub node_id: String, pub strategy: SignerBackupStrategy, pub state: State, - pub peerlist: Vec, } impl SignerBackupSnapshot { @@ -65,14 +47,13 @@ impl SignerBackupSnapshot { pub fn recovery_data(&self) -> Result> { self.validate()?; - let peers: BTreeMap<&str, &PeerlistEntry> = self - .peerlist - .iter() - .map(|peer| (peer.peer_id.as_str(), peer)) - .collect(); + let state = self.state.omit_tombstones(); + let peers = peers_from_state(&state)? + .into_iter() + .map(|peer| (peer.peer_id.clone(), peer)) + .collect::>(); - self.state - .omit_tombstones() + state .recoverable_channel_values() .into_iter() .map(|(channel_key, value)| { @@ -83,7 +64,7 @@ impl SignerBackupSnapshot { anyhow!("recoverable channel {} is missing channel_setup", channel_key) })?; let peer_addr = peers - .get(peer_id.as_str()) + .get(&peer_id) .and_then(|peer| (!peer.addr.is_empty()).then(|| peer.addr.clone())); let mut warnings = Vec::new(); if peer_addr.is_none() { @@ -388,22 +369,10 @@ fn has_recoverable_state_update(before: &State, after: &State) -> bool { !before.diff_state(after).recoverable_channel_keys().is_empty() } -pub(crate) fn parse_peerlist( - entries: &[crate::pb::cln::ListdatastoreDatastore], -) -> Result> { - let mut peers = entries - .iter() - .map(parse_peerlist_entry) - .collect::>>()?; - peers.sort_by(|a, b| a.peer_id.cmp(&b.peer_id)); - Ok(peers) -} - pub(crate) fn write_snapshot( config: &SignerBackupConfig, node_id: &[u8], state: State, - peerlist: Vec, ) -> Result<()> { let snapshot = SignerBackupSnapshot { version: BACKUP_VERSION, @@ -411,7 +380,6 @@ pub(crate) fn write_snapshot( node_id: hex::encode(node_id), strategy: config.strategy, state, - peerlist, }; let dir = backup_dir(&config.path); @@ -442,6 +410,32 @@ fn backup_dir(path: &Path) -> &Path { .unwrap_or_else(|| Path::new(".")) } +fn peers_from_state(state: &State) -> Result> { + let mut peers = state + .peer_values() + .into_iter() + .map(|(key, value)| peer_from_state_value(&key, value)) + .collect::>>()?; + peers.sort_by(|a, b| a.peer_id.cmp(&b.peer_id)); + Ok(peers) +} + +fn peer_from_state_value(key: &str, value: serde_json::Value) -> Result { + let key_peer_id = key + .strip_prefix("peers/") + .ok_or_else(|| anyhow!("invalid peer state key: {}", key))?; + let peer: PeerEntry = + serde_json::from_value(value).with_context(|| format!("parsing peer state {}", key))?; + if peer.peer_id != key_peer_id { + return Err(anyhow!( + "peer state key {} does not match payload peer_id {}", + key_peer_id, + peer.peer_id + )); + } + Ok(peer) +} + fn peer_id_from_channel_key(channel_key: &str) -> Result { let encoded = channel_key .strip_prefix("channels/") @@ -859,48 +853,6 @@ fn put_u64(out: &mut Vec, value: u64) { out.extend(value.to_be_bytes()); } -fn parse_peerlist_entry(entry: &crate::pb::cln::ListdatastoreDatastore) -> Result { - if entry.key.len() != 3 - || entry.key[0] != PEERLIST_PREFIX[0] - || entry.key[1] != PEERLIST_PREFIX[1] - { - return Err(anyhow!("invalid peerlist datastore key: {:?}", entry.key)); - } - - let key_peer_id = &entry.key[2]; - let raw = entry - .string - .as_ref() - .ok_or_else(|| anyhow!("peerlist entry {} is missing string payload", key_peer_id))?; - let peer = parse_peer_record(raw) - .with_context(|| format!("parsing peerlist entry {}", key_peer_id))?; - - if peer.id != *key_peer_id { - return Err(anyhow!( - "peerlist key {} does not match payload id {}", - key_peer_id, - peer.id - )); - } - - Ok(PeerlistEntry { - peer_id: peer.id, - addr: peer.addr, - direction: peer.direction, - features: peer.features, - generation: entry.generation, - raw_datastore_string: raw.clone(), - }) -} - -fn parse_peer_record(raw: &str) -> Result { - serde_json::from_str(raw).or_else(|first_error| { - let cleaned = raw.replace('\\', ""); - serde_json::from_str(&cleaned) - .with_context(|| format!("invalid peer JSON; original parse error: {}", first_error)) - }) -} - #[cfg(test)] mod tests { use super::*; @@ -947,16 +899,30 @@ mod tests { json!([vec![0u8; SHACHAIN_SECRET_LEN], SHACHAIN_EMPTY_INDEX]) } - fn peer_entry( - key: Vec<&str>, - generation: Option, - string: Option<&str>, - ) -> crate::pb::cln::ListdatastoreDatastore { - crate::pb::cln::ListdatastoreDatastore { - key: key.into_iter().map(str::to_owned).collect(), - generation, - hex: None, - string: string.map(str::to_owned), + fn peer_entry(peer_id: &str, addr: &str) -> serde_json::Value { + json!({ + "peer_id": peer_id, + "addr": addr, + "direction": "out", + "features": "" + }) + } + + fn state_with_peers(mut entries: serde_json::Value, peers: &[(&str, &str)]) -> State { + let entries = entries.as_object_mut().expect("state entries object"); + for (peer_id, addr) in peers { + entries.insert(format!("peers/{peer_id}"), json!([0, peer_entry(peer_id, addr)])); + } + state(serde_json::Value::Object(entries.clone())) + } + + fn backup_snapshot(state: State) -> SignerBackupSnapshot { + SignerBackupSnapshot { + version: BACKUP_VERSION, + created_at: "2026-04-29T00:00:00Z".to_string(), + node_id: hex::encode([2u8; 33]), + strategy: SignerBackupStrategy::NewChannelsOnly, + state, } } @@ -1110,74 +1076,17 @@ mod tests { } #[test] - fn parse_peerlist_normalizes_valid_entries() { - let raw = r#"{"id":"02aa","direction":"out","addr":"127.0.0.1:9735","features":"abcd"}"#; - let peers = parse_peerlist(&[peer_entry( - vec!["greenlight", "peerlist", "02aa"], - Some(7), - Some(raw), - )]) - .unwrap(); - - assert_eq!( - peers, - vec![PeerlistEntry { - peer_id: "02aa".to_string(), - addr: "127.0.0.1:9735".to_string(), - direction: "out".to_string(), - features: "abcd".to_string(), - generation: Some(7), - raw_datastore_string: raw.to_string(), - }] - ); - } - - #[test] - fn parse_peerlist_rejects_malformed_entries() { - assert!(parse_peerlist(&[peer_entry( - vec!["greenlight", "peerlist", "02aa"], - None, - Some("not-json"), - )]) - .is_err()); - - assert!(parse_peerlist(&[peer_entry( - vec!["greenlight", "peerlist", "02aa"], - None, - Some(r#"{"id":"02aa","direction":"out","features":""}"#), - )]) - .is_err()); - - assert!(parse_peerlist(&[peer_entry( - vec!["greenlight", "wrong", "02aa"], - None, - Some(r#"{"id":"02aa","direction":"out","addr":"127.0.0.1:9735","features":""}"#), - )]) - .is_err()); - } - - #[test] - fn write_snapshot_includes_state_peerlist_and_omits_tombstones() { + fn write_snapshot_includes_state_peers_and_omits_tombstones() { let dir = tempfile::tempdir().unwrap(); let path = dir.path().join("backup.json"); let config = SignerBackupConfig::new(path.clone()); - let state = state(json!({ + let state = state_with_peers(json!({ "channels/a": [0, channel(json!({ "funding_outpoint": { "txid": "00", "vout": 0 } }))], "channels/deleted": [u64::MAX, null] - })) + }), &[("02aa", "127.0.0.1:9735")]) .omit_tombstones(); - let peerlist = vec![PeerlistEntry { - peer_id: "02aa".to_string(), - addr: "127.0.0.1:9735".to_string(), - direction: "out".to_string(), - features: "".to_string(), - generation: Some(3), - raw_datastore_string: - r#"{"id":"02aa","direction":"out","addr":"127.0.0.1:9735","features":""}"# - .to_string(), - }]; - - write_snapshot(&config, &[2u8; 33], state, peerlist).unwrap(); + + write_snapshot(&config, &[2u8; 33], state).unwrap(); let written: serde_json::Value = serde_json::from_slice(&std::fs::read(path).unwrap()).unwrap(); @@ -1190,12 +1099,13 @@ mod tests { .unwrap() .get("channels/deleted") .is_none()); - assert_eq!(written["peerlist"][0]["peer_id"], "02aa"); - assert_eq!(written["peerlist"][0]["generation"], 3); - assert!(written["peerlist"][0]["raw_datastore_string"] - .as_str() - .unwrap() - .contains("\"addr\"")); + assert_eq!(written["state"]["values"]["peers/02aa"][1]["peer_id"], "02aa"); + assert_eq!( + written["state"]["values"]["peers/02aa"][1]["addr"], + "127.0.0.1:9735" + ); + assert!(written.get("peers").is_none()); + assert!(written.get("peerlist").is_none()); } #[test] @@ -1204,7 +1114,7 @@ mod tests { let path = dir.path().join("backup.json"); let config = SignerBackupConfig::new(path.clone()); - write_snapshot(&config, &[2u8; 33], state(json!({})), vec![]).unwrap(); + write_snapshot(&config, &[2u8; 33], state(json!({}))).unwrap(); let snapshot = SignerBackupSnapshot::read(&path).unwrap(); assert_eq!(snapshot.version, 1); @@ -1223,8 +1133,7 @@ mod tests { "created_at": "2026-04-29T00:00:00Z", "node_id": hex::encode([2u8; 33]), "strategy": "new_channels_only", - "state": { "values": {} }, - "peerlist": [] + "state": { "values": {} } }), ); @@ -1245,8 +1154,7 @@ mod tests { "created_at": "2026-04-29T00:00:00Z", "node_id": hex::encode([2u8; 33]), "strategy": "new_channels_only", - "state": { "values": { "channels/a": "not-a-state-entry" } }, - "peerlist": [] + "state": { "values": { "channels/a": "not-a-state-entry" } } }), ); @@ -1254,6 +1162,25 @@ mod tests { assert!(read_backup_err(&malformed_state).contains("parsing signer backup")); } + #[test] + fn read_snapshot_rejects_top_level_peers_field() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("backup.json"); + write_json( + &path, + json!({ + "version": 1, + "created_at": "2026-04-29T00:00:00Z", + "node_id": hex::encode([2u8; 33]), + "strategy": "new_channels_only", + "state": { "values": {} }, + "peers": [] + }), + ); + + assert!(read_backup_err(&path).contains("parsing signer backup")); + } + #[test] fn recovery_data_extracts_channels_and_joins_peer_addresses() { let peer_a = peer_id(0xaa); @@ -1263,28 +1190,17 @@ mod tests { let channel_missing_addr = channel_key(&peer_b, 3); let stub = channel_key(&peer_a, 4); let tombstone = channel_key(&peer_a, 5); - let state = state(json!({ - channel_a.clone(): [1, channel(recovery_setup("00", 0, 1000, true))], - channel_b.clone(): [1, channel(recovery_setup("11", 1, 2000, false))], - channel_missing_addr.clone(): [1, channel(recovery_setup("22", 2, 3000, false))], - stub: [1, channel(serde_json::Value::Null)], - tombstone: [u64::MAX, null] - })); - let snapshot = SignerBackupSnapshot { - version: BACKUP_VERSION, - created_at: "2026-04-29T00:00:00Z".to_string(), - node_id: hex::encode([2u8; 33]), - strategy: SignerBackupStrategy::NewChannelsOnly, - state, - peerlist: vec![PeerlistEntry { - peer_id: peer_a.clone(), - addr: "127.0.0.1:9735".to_string(), - direction: "out".to_string(), - features: "".to_string(), - generation: Some(1), - raw_datastore_string: "{}".to_string(), - }], - }; + let state = state_with_peers( + json!({ + channel_a.clone(): [1, channel(recovery_setup("00", 0, 1000, true))], + channel_b.clone(): [1, channel(recovery_setup("11", 1, 2000, false))], + channel_missing_addr.clone(): [1, channel(recovery_setup("22", 2, 3000, false))], + stub: [1, channel(serde_json::Value::Null)], + tombstone: [u64::MAX, null] + }), + &[(&peer_a, "127.0.0.1:9735")], + ); + let snapshot = backup_snapshot(state); let inventory = snapshot.recovery_data().unwrap(); @@ -1324,20 +1240,51 @@ mod tests { } #[test] - fn recovery_data_rejects_malformed_channel_json() { + fn recovery_data_marks_missing_peer_state_incomplete() { let peer = peer_id(0xaa); - let channel_key = channel_key(&peer, 1); + let key = channel_key(&peer, 1); + let snapshot = backup_snapshot(state(json!({ + key: [1, channel(recovery_setup("00", 0, 1000, true))] + }))); + + let inventory = snapshot.recovery_data().unwrap(); + + assert_eq!(inventory.len(), 1); + assert!(!inventory[0].complete); + assert_eq!(inventory[0].peer_addr, None); + assert_eq!(inventory[0].warnings, vec!["missing_peer_addr"]); + } + + #[test] + fn recovery_data_rejects_malformed_peer_state() { + let peer = peer_id(0xaa); + let key = channel_key(&peer, 1); let snapshot = SignerBackupSnapshot { version: BACKUP_VERSION, created_at: "2026-04-29T00:00:00Z".to_string(), node_id: hex::encode([2u8; 33]), strategy: SignerBackupStrategy::NewChannelsOnly, state: state(json!({ - channel_key: [1, channel(json!({ "channel_value_sat": 1000 }))] + key: [1, channel(recovery_setup("00", 0, 1000, true))], + format!("peers/{peer}"): [0, json!({ "peer_id": peer })] })), - peerlist: vec![], }; + assert!(snapshot + .recovery_data() + .unwrap_err() + .to_string() + .contains("parsing peer state")); + } + + #[test] + fn recovery_data_rejects_malformed_channel_json() { + let peer = peer_id(0xaa); + let channel_key = channel_key(&peer, 1); + let snapshot = backup_snapshot(state(json!({ + channel_key: [1, channel(json!({ "channel_value_sat": 1000 }))] + }))); + assert!(snapshot .recovery_data() .unwrap_err() @@ -1349,16 +1296,9 @@ mod tests { fn to_cln_backup_rejects_incomplete_channels_by_default() { let peer = peer_id(0xaa); let channel_key = channel_key(&peer, 1); - let snapshot = SignerBackupSnapshot { - version: BACKUP_VERSION, - created_at: "2026-04-29T00:00:00Z".to_string(), - node_id: hex::encode([2u8; 33]), - strategy: SignerBackupStrategy::NewChannelsOnly, - state: state(json!({ - channel_key: [1, channel(recovery_setup(full_txid(), 0, 1000, true))] - })), - peerlist: vec![], - }; + let snapshot = backup_snapshot(state(json!({ + channel_key: [1, channel(recovery_setup(full_txid(), 0, 1000, true))] + }))); let err = snapshot .to_cln_backup(CLNBackupOptions::default()) @@ -1374,24 +1314,15 @@ mod tests { let peer_b = peer_id(0xbb); let channel_a = channel_key(&peer_a, 1); let channel_b = channel_key(&peer_b, 2); - let snapshot = SignerBackupSnapshot { - version: BACKUP_VERSION, - created_at: "2026-04-29T00:00:00Z".to_string(), - node_id: hex::encode([2u8; 33]), - strategy: SignerBackupStrategy::NewChannelsOnly, - state: state(json!({ - channel_a.clone(): [1, channel(recovery_setup(full_txid(), 0, 1000, true))], - channel_b.clone(): [1, channel(recovery_setup(full_txid(), 1, 2000, false))] - })), - peerlist: vec![PeerlistEntry { - peer_id: peer_a.clone(), - addr: "127.0.0.1:9735".to_string(), - direction: "out".to_string(), - features: "".to_string(), - generation: Some(1), - raw_datastore_string: "{}".to_string(), - }], - }; + let snapshot = backup_snapshot( + state_with_peers( + json!({ + channel_a.clone(): [1, channel(recovery_setup(full_txid(), 0, 1000, true))], + channel_b.clone(): [1, channel(recovery_setup(full_txid(), 1, 2000, false))] + }), + &[(&peer_a, "127.0.0.1:9735")], + ), + ); let export = snapshot .to_cln_backup(CLNBackupOptions { @@ -1416,16 +1347,9 @@ mod tests { #[test] fn to_cln_backup_fails_when_every_channel_is_skipped() { let peer = peer_id(0xaa); - let snapshot = SignerBackupSnapshot { - version: BACKUP_VERSION, - created_at: "2026-04-29T00:00:00Z".to_string(), - node_id: hex::encode([2u8; 33]), - strategy: SignerBackupStrategy::NewChannelsOnly, - state: state(json!({ - channel_key(&peer, 1): [1, channel(recovery_setup(full_txid(), 0, 1000, true))] - })), - peerlist: vec![], - }; + let snapshot = backup_snapshot(state(json!({ + channel_key(&peer, 1): [1, channel(recovery_setup(full_txid(), 0, 1000, true))] + }))); let err = snapshot .to_cln_backup(CLNBackupOptions { @@ -1441,27 +1365,16 @@ mod tests { fn to_cln_backup_encodes_modern_scb_for_ipv4_peer() { let peer = peer_id(0xaa); let channel_key = channel_key(&peer, 42); - let snapshot = SignerBackupSnapshot { - version: BACKUP_VERSION, - created_at: "2026-04-29T00:00:00Z".to_string(), - node_id: hex::encode([2u8; 33]), - strategy: SignerBackupStrategy::NewChannelsOnly, - state: state(json!({ - channel_key.clone(): [1, channel(recovery_setup(full_txid(), 0x0102, 1_000_000, true))] - })), - peerlist: vec![PeerlistEntry { - peer_id: peer.clone(), - addr: "127.0.0.1:9735".to_string(), - direction: "out".to_string(), - features: "".to_string(), - generation: Some(1), - raw_datastore_string: "{}".to_string(), - }], - }; + let snapshot = backup_snapshot( + state_with_peers( + json!({ + channel_key.clone(): [1, channel(recovery_setup(full_txid(), 0x0102, 1_000_000, true))] + }), + &[(&peer, "127.0.0.1:9735")], + ), + ); - let export = snapshot - .to_cln_backup(CLNBackupOptions::default()) - .unwrap(); + let export = snapshot.to_cln_backup(CLNBackupOptions::default()).unwrap(); let scb = hex::decode(&export.request.scb[0]).unwrap(); let txid = expected_cln_txid(full_txid()); let mut channel_id = txid; @@ -1497,29 +1410,20 @@ mod tests { let channel_key = channel_key(&peer, 42); let first_index = SHACHAIN_EMPTY_INDEX - 1; let second_index = SHACHAIN_EMPTY_INDEX - 2; - let snapshot = SignerBackupSnapshot { - version: BACKUP_VERSION, - created_at: "2026-04-29T00:00:00Z".to_string(), - node_id: hex::encode([2u8; 33]), - strategy: SignerBackupStrategy::NewChannelsOnly, - state: state(json!({ - channel_key.clone(): [1, channel_with_enforcement( - recovery_setup(full_txid(), 0, 1_000_000, true), - enforcement_with_old_secrets(json!([ - old_secret(0x11, first_index), - old_secret(0x22, second_index) - ])) - )] - })), - peerlist: vec![PeerlistEntry { - peer_id: peer, - addr: "127.0.0.1:9735".to_string(), - direction: "out".to_string(), - features: "".to_string(), - generation: Some(1), - raw_datastore_string: "{}".to_string(), - }], - }; + let snapshot = backup_snapshot( + state_with_peers( + json!({ + channel_key.clone(): [1, channel_with_enforcement( + recovery_setup(full_txid(), 0, 1_000_000, true), + enforcement_with_old_secrets(json!([ + old_secret(0x11, first_index), + old_secret(0x22, second_index) + ])) + )] + }), + &[(&peer, "127.0.0.1:9735")], + ), + ); let export = snapshot.to_cln_backup(CLNBackupOptions::default()).unwrap(); let scb = hex::decode(&export.request.scb[0]).unwrap(); @@ -1551,26 +1455,17 @@ mod tests { fn to_cln_backup_omits_empty_shachain_without_warning() { let peer = peer_id(0xaa); let channel_key = channel_key(&peer, 42); - let snapshot = SignerBackupSnapshot { - version: BACKUP_VERSION, - created_at: "2026-04-29T00:00:00Z".to_string(), - node_id: hex::encode([2u8; 33]), - strategy: SignerBackupStrategy::NewChannelsOnly, - state: state(json!({ - channel_key: [1, channel_with_enforcement( - recovery_setup(full_txid(), 0, 1_000_000, true), - enforcement_with_old_secrets(json!([])) - )] - })), - peerlist: vec![PeerlistEntry { - peer_id: peer, - addr: "127.0.0.1:9735".to_string(), - direction: "out".to_string(), - features: "".to_string(), - generation: Some(1), - raw_datastore_string: "{}".to_string(), - }], - }; + let snapshot = backup_snapshot( + state_with_peers( + json!({ + channel_key: [1, channel_with_enforcement( + recovery_setup(full_txid(), 0, 1_000_000, true), + enforcement_with_old_secrets(json!([])) + )] + }), + &[(&peer, "127.0.0.1:9735")], + ), + ); let export = snapshot.to_cln_backup(CLNBackupOptions::default()).unwrap(); let scb = hex::decode(&export.request.scb[0]).unwrap(); @@ -1584,26 +1479,17 @@ mod tests { fn to_cln_backup_warns_when_counterparty_secrets_are_missing() { let peer = peer_id(0xaa); let channel_key = channel_key(&peer, 42); - let snapshot = SignerBackupSnapshot { - version: BACKUP_VERSION, - created_at: "2026-04-29T00:00:00Z".to_string(), - node_id: hex::encode([2u8; 33]), - strategy: SignerBackupStrategy::NewChannelsOnly, - state: state(json!({ - channel_key: [1, channel_with_enforcement( - recovery_setup(full_txid(), 0, 1_000_000, true), - json!({ "counterparty_secrets": null }) - )] - })), - peerlist: vec![PeerlistEntry { - peer_id: peer, - addr: "127.0.0.1:9735".to_string(), - direction: "out".to_string(), - features: "".to_string(), - generation: Some(1), - raw_datastore_string: "{}".to_string(), - }], - }; + let snapshot = backup_snapshot( + state_with_peers( + json!({ + channel_key: [1, channel_with_enforcement( + recovery_setup(full_txid(), 0, 1_000_000, true), + json!({ "counterparty_secrets": null }) + )] + }), + &[(&peer, "127.0.0.1:9735")], + ), + ); let export = snapshot.to_cln_backup(CLNBackupOptions::default()).unwrap(); @@ -1618,30 +1504,21 @@ mod tests { let peer = peer_id(0xaa); let channel_key = channel_key(&peer, 42); let first_index = SHACHAIN_EMPTY_INDEX - 1; - let snapshot = SignerBackupSnapshot { - version: BACKUP_VERSION, - created_at: "2026-04-29T00:00:00Z".to_string(), - node_id: hex::encode([2u8; 33]), - strategy: SignerBackupStrategy::NewChannelsOnly, - state: state(json!({ - channel_key: [1, channel_with_enforcement( - recovery_setup(full_txid(), 0, 1_000_000, true), - enforcement_with_old_secrets(json!([ - old_secret(0x11, first_index), - dummy_old_secret(), - dummy_old_secret() - ])) - )] - })), - peerlist: vec![PeerlistEntry { - peer_id: peer, - addr: "127.0.0.1:9735".to_string(), - direction: "out".to_string(), - features: "".to_string(), - generation: Some(1), - raw_datastore_string: "{}".to_string(), - }], - }; + let snapshot = backup_snapshot( + state_with_peers( + json!({ + channel_key: [1, channel_with_enforcement( + recovery_setup(full_txid(), 0, 1_000_000, true), + enforcement_with_old_secrets(json!([ + old_secret(0x11, first_index), + dummy_old_secret(), + dummy_old_secret() + ])) + )] + }), + &[(&peer, "127.0.0.1:9735")], + ), + ); let export = snapshot.to_cln_backup(CLNBackupOptions::default()).unwrap(); let scb = hex::decode(&export.request.scb[0]).unwrap(); @@ -1688,26 +1565,17 @@ mod tests { "invalid shachain index", ), ] { - let snapshot = SignerBackupSnapshot { - version: BACKUP_VERSION, - created_at: "2026-04-29T00:00:00Z".to_string(), - node_id: hex::encode([2u8; 33]), - strategy: SignerBackupStrategy::NewChannelsOnly, - state: state(json!({ - channel_key.clone(): [1, channel_with_enforcement( - recovery_setup(full_txid(), 0, 1_000_000, true), - enforcement_with_old_secrets(old_secrets) - )] - })), - peerlist: vec![PeerlistEntry { - peer_id: peer.clone(), - addr: "127.0.0.1:9735".to_string(), - direction: "out".to_string(), - features: "".to_string(), - generation: Some(1), - raw_datastore_string: "{}".to_string(), - }], - }; + let snapshot = backup_snapshot( + state_with_peers( + json!({ + channel_key.clone(): [1, channel_with_enforcement( + recovery_setup(full_txid(), 0, 1_000_000, true), + enforcement_with_old_secrets(old_secrets) + )] + }), + &[(&peer, "127.0.0.1:9735")], + ), + ); let err = snapshot .to_cln_backup(CLNBackupOptions::default()) @@ -1722,23 +1590,14 @@ mod tests { let peer = peer_id(0xaa); let mut setup = recovery_setup(full_txid(), 0, 1000, true); setup["commitment_type"] = json!("Unknown"); - let snapshot = SignerBackupSnapshot { - version: BACKUP_VERSION, - created_at: "2026-04-29T00:00:00Z".to_string(), - node_id: hex::encode([2u8; 33]), - strategy: SignerBackupStrategy::NewChannelsOnly, - state: state(json!({ - channel_key(&peer, 1): [1, channel(setup)] - })), - peerlist: vec![PeerlistEntry { - peer_id: peer, - addr: "127.0.0.1:9735".to_string(), - direction: "out".to_string(), - features: "".to_string(), - generation: Some(1), - raw_datastore_string: "{}".to_string(), - }], - }; + let snapshot = backup_snapshot( + state_with_peers( + json!({ + channel_key(&peer, 1): [1, channel(setup)] + }), + &[(&peer, "127.0.0.1:9735")], + ), + ); let err = snapshot .to_cln_backup(CLNBackupOptions::default()) @@ -1784,7 +1643,7 @@ mod tests { let config = SignerBackupConfig::new(dir.path().join("missing").join("backup.json")); let state = state(json!({})); - assert!(write_snapshot(&config, &[2u8; 33], state, vec![]).is_err()); + assert!(write_snapshot(&config, &[2u8; 33], state).is_err()); } #[test] @@ -1793,7 +1652,7 @@ mod tests { let path = dir.path().join("backup.json"); let config = SignerBackupConfig::periodic(path.clone(), 5).unwrap(); - write_snapshot(&config, &[2u8; 33], state(json!({})), vec![]).unwrap(); + write_snapshot(&config, &[2u8; 33], state(json!({}))).unwrap(); let written: serde_json::Value = serde_json::from_slice(&std::fs::read(path).unwrap()).unwrap(); diff --git a/libs/gl-client/src/signer/mod.rs b/libs/gl-client/src/signer/mod.rs index 9421d8e5e..86fe7ed2e 100644 --- a/libs/gl-client/src/signer/mod.rs +++ b/libs/gl-client/src/signer/mod.rs @@ -38,7 +38,7 @@ use std::time::SystemTime; use tokio::sync::mpsc; use tokio::time::{sleep, Duration}; use tokio_stream::wrappers::ReceiverStream; -use tonic::transport::{Channel, Endpoint, Uri}; +use tonic::transport::{Endpoint, Uri}; use tonic::{Code, Request}; use vls_protocol::msgs::{DeBolt, HsmdInitReplyV4}; use vls_protocol::serde_bolt::Octets; @@ -50,10 +50,9 @@ mod approver; mod auth; mod backup; pub use backup::{ - PeerlistEntry, RecoverableBasepoints, RecoverableChannel, RecoverableChannelOpener, - RecoverableFundingOutpoint, CLNBackup, CLNBackupChannel, - CLNBackupOptions, RecoverchannelRequest, RecoverchannelSkippedChannel, - SignerBackupSnapshot, + CLNBackup, CLNBackupChannel, CLNBackupOptions, PeerEntry, RecoverableBasepoints, + RecoverableChannel, RecoverableChannelOpener, RecoverableFundingOutpoint, + RecoverchannelRequest, RecoverchannelSkippedChannel, SignerBackupSnapshot, }; pub mod model; mod report; @@ -722,8 +721,7 @@ impl Signer { .keep_alive_while_idle(true) .connect_lazy(); - let mut client = NodeClient::new(c.clone()); - let mut peerlist_client = self.backup_peerlist_client(c); + let mut client = NodeClient::new(c); let mut stream = client .stream_hsm_requests(Request::new(Empty::default())) @@ -748,10 +746,7 @@ impl Signer { let signer_state = req.signer_state.clone(); trace!("Received request {}", hex_req); - match self - .process_request(req.clone(), peerlist_client.as_mut()) - .await - { + match self.process_request(req.clone()).await { Ok(response) => { trace!("Sending response {}", hex::encode(&response.raw)); client @@ -790,27 +785,6 @@ impl Signer { } } - fn backup_peerlist_client(&self, channel: Channel) -> Option { - if self.backup.is_none() { - return None; - } - - let Some(private_key) = self.tls.private_key.clone() else { - warn!("Signer backup enabled but missing TLS private key for CLN auth"); - return None; - }; - let auth = match node::service::AuthLayer::new(private_key, self.master_rune.to_base64()) { - Ok(auth) => auth, - Err(e) => { - warn!("Signer backup peerlist client setup failed: {e}"); - return None; - } - }; - let service = tower::ServiceBuilder::new().layer(auth).service(channel); - - Some(node::ClnClient::new(service)) - } - fn authenticate_request( &self, msg: &vls_protocol::msgs::Message, @@ -828,11 +802,7 @@ impl Signer { Ok(()) } - async fn process_request( - &self, - req: HsmRequest, - mut node_client: Option<&mut crate::node::ClnClient>, - ) -> Result { + async fn process_request(&self, req: HsmRequest) -> Result { debug!("Processing request {:?}", req); let req = req; @@ -1047,21 +1017,9 @@ impl Signer { (diff_entries, pending_backup) }; if let Some((backup_config, backup_state)) = pending_backup { - let backup_result = match node_client.as_mut() { - Some(client) => match Self::fetch_backup_peerlist(*client).await { - Ok(peerlist) => backup::write_snapshot( - &backup_config, - &self.id, - backup_state.clone(), - peerlist, - ) - .map_err(|e| Error::Other(anyhow!("failed to write signer backup: {e}"))), - Err(e) => Err(e), - }, - None => Err(Error::Other(anyhow!( - "backup snapshot is due but no node client is available to refresh peerlist" - ))), - }; + let backup_result = + backup::write_snapshot(&backup_config, &self.id, backup_state.clone()) + .map_err(|e| Error::Other(anyhow!("failed to write signer backup: {e}"))); match backup_result { Ok(()) => match self.backup_runtime.lock() { @@ -1083,20 +1041,6 @@ impl Signer { }) } - async fn fetch_backup_peerlist( - client: &mut crate::node::ClnClient, - ) -> Result, Error> { - let response = client - .list_datastore(Request::new(crate::pb::cln::ListdatastoreRequest { - key: vec!["greenlight".to_string(), "peerlist".to_string()], - })) - .await - .map_err(|e| Error::Other(anyhow!("failed to refresh backup peerlist: {e}")))?; - - backup::parse_peerlist(&response.into_inner().datastore) - .map_err(|e| Error::Other(anyhow!("failed to parse backup peerlist: {e}"))) - } - pub fn node_id(&self) -> Vec { self.id.clone() } @@ -1836,7 +1780,7 @@ mod tests { raw: msg, signer_state: vec![], requests: Vec::new(), - }, None) + }) .await .is_err()); } @@ -1859,7 +1803,7 @@ mod tests { raw: vec![], signer_state: vec![], requests: Vec::new(), - }, None) + }) .await .unwrap_err() .to_string(), @@ -1888,7 +1832,7 @@ mod tests { signer_state: vec![mk_state_entry(&key, 1, json!({"v": 1}))], requests: vec![], }; - let response = signer.process_request(req, None).await.unwrap(); + let response = signer.process_request(req).await.unwrap(); let repaired = response .signer_state .iter() @@ -1909,7 +1853,7 @@ mod tests { raw: heartbeat_raw(), signer_state: vec![entry], requests: vec![], - }, None) + }) .await .unwrap_err() .to_string(); @@ -1926,7 +1870,7 @@ mod tests { raw: heartbeat_raw(), signer_state: vec![mk_state_entry("state/test", 1, json!({"v": 1}))], requests: vec![], - }, None) + }) .await .unwrap_err() .to_string(); @@ -1945,7 +1889,7 @@ mod tests { raw: heartbeat_raw(), signer_state: vec![entry], requests: vec![], - }, None) + }) .await .unwrap_err() .to_string(); @@ -1967,7 +1911,7 @@ mod tests { raw: heartbeat_raw(), signer_state: vec![entry], requests: vec![], - }, None) + }) .await; assert!(res.is_ok()); } @@ -1985,7 +1929,7 @@ mod tests { raw: heartbeat_raw(), signer_state: vec![invalid, missing], requests: vec![], - }, None) + }) .await; assert!(res.is_ok()); } @@ -2003,7 +1947,7 @@ mod tests { raw: heartbeat_raw(), signer_state: vec![invalid1], requests: vec![], - }, None) + }) .await .unwrap(); let snapshot1: Vec = { @@ -2022,7 +1966,7 @@ mod tests { raw: heartbeat_raw(), signer_state: vec![invalid2], requests: vec![], - }, None) + }) .await; assert!(res2.is_ok()); @@ -2046,7 +1990,7 @@ mod tests { raw: heartbeat_raw(), signer_state: vec![mk_state_entry(key, 1, json!({"v": request_id}))], requests: vec![], - }, None) + }) .await .unwrap(); let snapshot: Vec = { @@ -2071,7 +2015,7 @@ mod tests { raw: heartbeat_raw(), signer_state: vec![invalid], requests: vec![], - }, None) + }) .await .unwrap(); @@ -2104,7 +2048,7 @@ mod tests { raw: heartbeat_raw(), signer_state: vec![valid.clone(), invalid], requests: vec![], - }, None) + }) .await .unwrap(); @@ -2174,7 +2118,7 @@ mod tests { raw: heartbeat_raw(), signer_state: vec![entry], requests: vec![], - }, None) + }) .await .unwrap_err() .to_string(); diff --git a/libs/gl-plugin/src/events.rs b/libs/gl-plugin/src/events.rs index cd0dbeae9..088217a75 100644 --- a/libs/gl-plugin/src/events.rs +++ b/libs/gl-plugin/src/events.rs @@ -194,6 +194,7 @@ use crate::pb; use crate::stager; +use gl_client::persist::PeerEntry; use std::any::Any; use std::fmt::Debug; use std::sync::Arc; @@ -220,6 +221,9 @@ pub enum Event { /// A custom message was received from a peer. CustomMsg(pb::Custommsg), + /// A peer address was learned from CLN's `peer_connected` hook. + PeerConnected(PeerEntry), + /// Internal events from gl-plugin-internal or other extensions. /// This variant is not used when `I = ()`. Internal(I), @@ -236,6 +240,7 @@ impl Event { Event::RpcCall(r) => Event::RpcCall(r), Event::IncomingPayment(p) => Event::IncomingPayment(p), Event::CustomMsg(m) => Event::CustomMsg(m), + Event::PeerConnected(p) => Event::PeerConnected(p), Event::Internal(i) => Event::Internal(f(i)), } } @@ -251,6 +256,7 @@ impl Event { Event::RpcCall(r) => Some(Event::RpcCall(r)), Event::IncomingPayment(p) => Some(Event::IncomingPayment(p)), Event::CustomMsg(m) => Some(Event::CustomMsg(m)), + Event::PeerConnected(p) => Some(Event::PeerConnected(p)), Event::Internal(i) => f(i).map(Event::Internal), } } @@ -339,6 +345,7 @@ impl ErasedEventExt for ErasedEvent { Event::RpcCall(r) => Some(Event::RpcCall(r.clone())), Event::IncomingPayment(p) => Some(Event::IncomingPayment(p.clone())), Event::CustomMsg(m) => Some(Event::CustomMsg(m.clone())), + Event::PeerConnected(p) => Some(Event::PeerConnected(p.clone())), Event::Internal(any) => any.downcast_ref::().cloned().map(Event::Internal), } } diff --git a/libs/gl-plugin/src/lib.rs b/libs/gl-plugin/src/lib.rs index b940f71c4..584c05d5a 100644 --- a/libs/gl-plugin/src/lib.rs +++ b/libs/gl-plugin/src/lib.rs @@ -135,8 +135,7 @@ async fn on_custommsg(plugin: Plugin, v: serde_json::Value) -> Result Result { debug!("Got a successful peer connection: {:?}", v); let call = serde_json::from_value::(v.clone()).unwrap(); @@ -156,6 +155,26 @@ async fn on_peer_connected(plugin: Plugin, v: serde_json::Value) -> Result "in", + messages::Direction::Out => "out", + }; + let peer = gl_client::persist::PeerEntry { + peer_id: call.peer.id, + addr: call.peer.addr, + direction: direction.to_string(), + features: call.peer.features, + }; + if let Err(e) = plugin + .state() + .events + .clone() + .send(Event::PeerConnected(peer)) + { + log::debug!("Error sending peer connection to listeners: {}", e); + } + Ok(json!({"result": "continue"})) } diff --git a/libs/gl-plugin/src/node/mod.rs b/libs/gl-plugin/src/node/mod.rs index 2ed8890ad..b6e68c465 100644 --- a/libs/gl-plugin/src/node/mod.rs +++ b/libs/gl-plugin/src/node/mod.rs @@ -137,6 +137,35 @@ impl PluginNodeServer { notifications, }; + let signer_state = s.signer_state.clone(); + let signer_state_store = s.signer_state_store.clone(); + let mut peer_events = s.events.subscribe(); + tokio::spawn(async move { + loop { + match peer_events.recv().await { + Ok(super::Event::PeerConnected(peer)) => { + let snapshot = { + let mut state = signer_state.lock().await; + if let Err(e) = state.insert_or_update_peer(peer) { + warn!("Failed to update signer peer state: {e}"); + continue; + } + state.clone() + }; + let store = signer_state_store.lock().await; + if let Err(e) = store.write(snapshot).await { + warn!("Failed to persist signer peer state: {e}"); + } + } + Ok(_) => {} + Err(tokio::sync::broadcast::error::RecvError::Lagged(skipped)) => { + warn!("Signer peer state listener skipped {skipped} events"); + } + Err(tokio::sync::broadcast::error::RecvError::Closed) => break, + } + } + }); + tokio::spawn(async move { let rpc_arc = get_rpc(&rpc_path).await.clone(); let mut rpc = rpc_arc.lock().await; From 2237e894cb027afa070432d14f08caf3f1f9bb60 Mon Sep 17 00:00:00 2001 From: Ihor Diachenko Date: Wed, 13 May 2026 01:39:46 +0300 Subject: [PATCH 12/12] signer: gate backup support behind feature --- libs/gl-cli/Cargo.toml | 2 +- libs/gl-client/Cargo.toml | 1 + libs/gl-client/src/persist.rs | 41 +------ libs/gl-client/src/signer/backup.rs | 106 +++++++++++++++--- libs/gl-client/src/signer/backup_runtime.rs | 96 ++++++++++++++++ libs/gl-client/src/signer/mod.rs | 115 ++++---------------- 6 files changed, 211 insertions(+), 150 deletions(-) create mode 100644 libs/gl-client/src/signer/backup_runtime.rs diff --git a/libs/gl-cli/Cargo.toml b/libs/gl-cli/Cargo.toml index 7b3ab2de6..22de1c3bb 100644 --- a/libs/gl-cli/Cargo.toml +++ b/libs/gl-cli/Cargo.toml @@ -23,7 +23,7 @@ clap = { version = "4.5", features = ["derive"] } dirs = "6.0" env_logger = "0.11" futures = "0.3" -gl-client = { version = "0.4", path = "../gl-client" } +gl-client = { version = "0.4", path = "../gl-client", features = ["backup"] } hex = "0.4" serde = { version = "1", features = ["derive"] } serde_json = "1" diff --git a/libs/gl-client/Cargo.toml b/libs/gl-client/Cargo.toml index 245e7496c..5c588d255 100644 --- a/libs/gl-client/Cargo.toml +++ b/libs/gl-client/Cargo.toml @@ -14,6 +14,7 @@ license = "MIT" default = ["permissive", "export"] permissive = [] export = ["chacha20poly1305", "secp256k1"] +backup = [] [dependencies] aes = "0.8" diff --git a/libs/gl-client/src/persist.rs b/libs/gl-client/src/persist.rs index f6f1eb079..e22ecf66e 100644 --- a/libs/gl-client/src/persist.rs +++ b/libs/gl-client/src/persist.rs @@ -15,7 +15,7 @@ use log::{trace, warn}; use serde::de::{self, SeqAccess, Visitor}; use serde::ser::SerializeSeq; use serde::{Deserialize, Deserializer, Serialize, Serializer}; -use std::collections::{BTreeMap, BTreeSet}; +use std::collections::BTreeMap; use std::fmt; use std::str::FromStr; use std::sync::Arc; @@ -557,45 +557,12 @@ impl State { State { values } } - pub(crate) fn recoverable_channel_keys(&self) -> BTreeSet { + #[cfg(feature = "backup")] + pub(crate) fn live_values(&self) -> impl Iterator + '_ { self.values .iter() .filter(|(_, value)| value.version != TOMBSTONE_VERSION) - .filter(|(key, _)| key.starts_with(&format!("{CHANNEL_PREFIX}/"))) - .filter(|(_, value)| { - value - .value - .get("channel_setup") - .map(|setup| !setup.is_null()) - .unwrap_or(false) - }) - .map(|(key, _)| key.clone()) - .collect() - } - - pub(crate) fn recoverable_channel_values(&self) -> Vec<(String, serde_json::Value)> { - self.values - .iter() - .filter(|(_, value)| value.version != TOMBSTONE_VERSION) - .filter(|(key, _)| key.starts_with(&format!("{CHANNEL_PREFIX}/"))) - .filter(|(_, value)| { - value - .value - .get("channel_setup") - .map(|setup| !setup.is_null()) - .unwrap_or(false) - }) - .map(|(key, value)| (key.clone(), value.value.clone())) - .collect() - } - - pub(crate) fn peer_values(&self) -> Vec<(String, serde_json::Value)> { - self.values - .iter() - .filter(|(_, value)| value.version != TOMBSTONE_VERSION) - .filter(|(key, _)| key.starts_with(&format!("{PEER_PREFIX}/"))) - .map(|(key, value)| (key.clone(), value.value.clone())) - .collect() + .map(|(key, value)| (key.as_str(), &value.value)) } } diff --git a/libs/gl-client/src/signer/backup.rs b/libs/gl-client/src/signer/backup.rs index 74142dbc1..92c8aa303 100644 --- a/libs/gl-client/src/signer/backup.rs +++ b/libs/gl-client/src/signer/backup.rs @@ -1,14 +1,13 @@ -use super::{SignerBackupConfig, SignerBackupStrategy}; pub use crate::persist::PeerEntry; use crate::persist::State; use anyhow::{anyhow, Context, Result}; use serde::{Deserialize, Serialize}; -use std::collections::BTreeMap; +use std::collections::{BTreeMap, BTreeSet}; use std::convert::TryInto; use std::fs; use std::io::Write; use std::net::{IpAddr, SocketAddr}; -use std::path::Path; +use std::path::{Path, PathBuf}; const BACKUP_VERSION: u32 = 1; const NODE_ID_LEN: usize = 33; @@ -22,6 +21,55 @@ const SHACHAIN_SECRET_LEN: usize = 32; const SHACHAIN_EMPTY_INDEX: u64 = 1 << 48; const SHACHAIN_MAX_ENTRIES: usize = 49; const SHACHAIN_MISSING_WARNING: &str = "shachain_tlv_missing"; +const CHANNEL_PREFIX: &str = "channels/"; +const PEER_PREFIX: &str = "peers/"; + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct SignerBackupConfig { + pub path: PathBuf, + pub strategy: SignerBackupStrategy, +} + +impl SignerBackupConfig { + pub fn new(path: impl Into) -> Self { + Self { + path: path.into(), + strategy: SignerBackupStrategy::NewChannelsOnly, + } + } + + pub fn periodic(path: impl Into, updates: u32) -> Result { + let config = Self { + path: path.into(), + strategy: SignerBackupStrategy::Periodic { updates }, + }; + config.validate()?; + Ok(config) + } + + pub(crate) fn validate(&self) -> Result<()> { + self.strategy.validate() + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum SignerBackupStrategy { + NewChannelsOnly, + Periodic { updates: u32 }, +} + +impl SignerBackupStrategy { + pub(crate) fn validate(&self) -> Result<()> { + match self { + SignerBackupStrategy::NewChannelsOnly => Ok(()), + SignerBackupStrategy::Periodic { updates: 0 } => { + Err(anyhow!("periodic signer backup updates must be greater than zero")) + } + SignerBackupStrategy::Periodic { .. } => Ok(()), + } + } +} #[derive(Clone, Serialize, Deserialize)] #[serde(deny_unknown_fields)] @@ -47,14 +95,12 @@ impl SignerBackupSnapshot { pub fn recovery_data(&self) -> Result> { self.validate()?; - let state = self.state.omit_tombstones(); - let peers = peers_from_state(&state)? + let peers = peers_from_state(&self.state)? .into_iter() .map(|peer| (peer.peer_id.clone(), peer)) .collect::>(); - state - .recoverable_channel_values() + recoverable_channel_values(&self.state) .into_iter() .map(|(channel_key, value)| { let peer_id = peer_id_from_channel_key(&channel_key)?; @@ -181,9 +227,7 @@ impl SignerBackupSnapshot { fn recoverable_channel_shachains( &self, ) -> Result>>> { - self.state - .omit_tombstones() - .recoverable_channel_values() + recoverable_channel_values(&self.state) .into_iter() .map(|(channel_key, value)| { let entry: ClnChannelEntry = serde_json::from_value(value).with_context(|| { @@ -358,15 +402,14 @@ impl BackupRuntime { } pub(crate) fn should_snapshot_new_channels(before: &State, after: &State) -> bool { - let before_channels = before.recoverable_channel_keys(); - after - .recoverable_channel_keys() + let before_channels = recoverable_channel_keys(before); + recoverable_channel_keys(after) .iter() .any(|key| !before_channels.contains(key)) } fn has_recoverable_state_update(before: &State, after: &State) -> bool { - !before.diff_state(after).recoverable_channel_keys().is_empty() + !recoverable_channel_keys(&before.diff_state(after)).is_empty() } pub(crate) fn write_snapshot( @@ -410,9 +453,40 @@ fn backup_dir(path: &Path) -> &Path { .unwrap_or_else(|| Path::new(".")) } +fn recoverable_channel_entries( + state: &State, +) -> impl Iterator + '_ { + state.live_values().filter(|(key, value)| { + key.starts_with(CHANNEL_PREFIX) + && value + .get("channel_setup") + .map(|setup| !setup.is_null()) + .unwrap_or(false) + }) +} + +fn recoverable_channel_keys(state: &State) -> BTreeSet { + recoverable_channel_entries(state) + .map(|(key, _)| key.to_string()) + .collect() +} + +fn recoverable_channel_values(state: &State) -> Vec<(String, serde_json::Value)> { + recoverable_channel_entries(state) + .map(|(key, value)| (key.to_string(), value.clone())) + .collect() +} + +fn peer_values(state: &State) -> Vec<(String, serde_json::Value)> { + state + .live_values() + .filter(|(key, _)| key.starts_with(PEER_PREFIX)) + .map(|(key, value)| (key.to_string(), value.clone())) + .collect() +} + fn peers_from_state(state: &State) -> Result> { - let mut peers = state - .peer_values() + let mut peers = peer_values(state) .into_iter() .map(|(key, value)| peer_from_state_value(&key, value)) .collect::>>()?; diff --git a/libs/gl-client/src/signer/backup_runtime.rs b/libs/gl-client/src/signer/backup_runtime.rs new file mode 100644 index 000000000..3e7d0753d --- /dev/null +++ b/libs/gl-client/src/signer/backup_runtime.rs @@ -0,0 +1,96 @@ +#[cfg(feature = "backup")] +mod enabled { + use super::super::backup::{self, SignerBackupConfig}; + use super::super::SignerConfig; + use crate::persist::State; + use anyhow::Result; + use log::error; + use std::sync::{Arc, Mutex}; + + pub(crate) type Pending = Option<(SignerBackupConfig, State)>; + + #[derive(Clone)] + pub(crate) struct Runtime { + config: Option, + runtime: Arc>, + } + + impl Runtime { + pub(crate) fn new(config: &SignerConfig) -> Result { + if let Some(backup) = &config.backup { + backup.validate()?; + } + + Ok(Self { + config: config.backup.clone(), + runtime: Arc::new(Mutex::new(backup::BackupRuntime::default())), + }) + } + + pub(crate) fn before_request(&self, state: &State) -> State { + state.clone() + } + + pub(crate) fn after_request(&self, before: &State, final_state: &State) -> Pending { + self.config.as_ref().and_then(|config| { + let mut runtime = match self.runtime.lock() { + Ok(runtime) => runtime, + Err(e) => { + error!("Signer backup runtime lock failed; skipping backup snapshot: {e}"); + return None; + } + }; + runtime + .observe(config.strategy, before, final_state) + .then(|| (config.clone(), final_state.omit_tombstones())) + }) + } + + pub(crate) fn write_pending(&self, node_id: &[u8], pending: Pending) { + if let Some((config, state)) = pending { + match backup::write_snapshot(&config, node_id, state.clone()) { + Ok(()) => match self.runtime.lock() { + Ok(mut runtime) => runtime.snapshot_succeeded(&state), + Err(e) => { + error!( + "Signer backup runtime lock failed after successful snapshot: {e}" + ) + } + }, + Err(e) => { + error!("Signer backup failed; continuing without backup snapshot: {e}"); + } + } + } + } + } +} + +#[cfg(not(feature = "backup"))] +mod disabled { + use super::super::SignerConfig; + use crate::persist::State; + use anyhow::Result; + + pub(crate) type Pending = (); + + #[derive(Clone)] + pub(crate) struct Runtime; + + impl Runtime { + pub(crate) fn new(_config: &SignerConfig) -> Result { + Ok(Self) + } + + pub(crate) fn before_request(&self, _state: &State) {} + + pub(crate) fn after_request(&self, _before: &(), _final_state: &State) -> Pending {} + + pub(crate) fn write_pending(&self, _node_id: &[u8], _pending: Pending) {} + } +} + +#[cfg(not(feature = "backup"))] +pub(crate) use disabled::Runtime; +#[cfg(feature = "backup")] +pub(crate) use enabled::Runtime; diff --git a/libs/gl-client/src/signer/mod.rs b/libs/gl-client/src/signer/mod.rs index 86fe7ed2e..9d6875680 100644 --- a/libs/gl-client/src/signer/mod.rs +++ b/libs/gl-client/src/signer/mod.rs @@ -31,7 +31,6 @@ use ring::digest::{digest, SHA256}; use ring::signature::{UnparsedPublicKey, ECDSA_P256_SHA256_FIXED}; use runeauth::{Condition, Restriction, Rune, RuneError}; use std::convert::{TryFrom, TryInto}; -use std::path::PathBuf; use std::sync::Arc; use std::sync::Mutex; use std::time::SystemTime; @@ -48,11 +47,15 @@ use vls_protocol_signer::handler::Handler; mod approver; mod auth; +#[cfg(feature = "backup")] mod backup; +mod backup_runtime; +#[cfg(feature = "backup")] pub use backup::{ CLNBackup, CLNBackupChannel, CLNBackupOptions, PeerEntry, RecoverableBasepoints, RecoverableChannel, RecoverableChannelOpener, RecoverableFundingOutpoint, - RecoverchannelRequest, RecoverchannelSkippedChannel, SignerBackupSnapshot, + RecoverchannelRequest, RecoverchannelSkippedChannel, SignerBackupConfig, SignerBackupSnapshot, + SignerBackupStrategy, }; pub mod model; mod report; @@ -91,56 +94,10 @@ pub struct StateSignatureOverrideConfig { pub struct SignerConfig { pub state_signature_mode: StateSignatureMode, pub state_signature_override: Option, + #[cfg(feature = "backup")] pub backup: Option, } -#[derive(Clone, Debug, PartialEq, Eq)] -pub struct SignerBackupConfig { - pub path: PathBuf, - pub strategy: SignerBackupStrategy, -} - -impl SignerBackupConfig { - pub fn new(path: impl Into) -> Self { - Self { - path: path.into(), - strategy: SignerBackupStrategy::NewChannelsOnly, - } - } - - pub fn periodic(path: impl Into, updates: u32) -> Result { - let config = Self { - path: path.into(), - strategy: SignerBackupStrategy::Periodic { updates }, - }; - config.validate()?; - Ok(config) - } - - fn validate(&self) -> Result<()> { - self.strategy.validate() - } -} - -#[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)] -#[serde(rename_all = "snake_case")] -pub enum SignerBackupStrategy { - NewChannelsOnly, - Periodic { updates: u32 }, -} - -impl SignerBackupStrategy { - fn validate(&self) -> Result<()> { - match self { - SignerBackupStrategy::NewChannelsOnly => Ok(()), - SignerBackupStrategy::Periodic { updates: 0 } => { - Err(anyhow!("periodic signer backup updates must be greater than zero")) - } - SignerBackupStrategy::Periodic { .. } => Ok(()), - } - } -} - #[derive(Debug, Default)] struct OverrideSignatureUsage { missing_keys: Vec, @@ -171,8 +128,7 @@ pub struct Signer { network: Network, state: Arc>, - backup: Option, - backup_runtime: Arc>, + backup: backup_runtime::Runtime, } #[derive(thiserror::Error, Debug)] @@ -236,10 +192,8 @@ impl Signer { info!("Initializing signer for {VERSION} ({GITHASH}) (VLS)"); let state_signature_mode = config.state_signature_mode; - if let Some(backup) = &config.backup { - backup.validate()?; - } - let backup = config.backup.clone(); + let backup = backup_runtime::Runtime::new(&config)?; + let (state_signature_override_enabled, state_signature_override_note) = match config.state_signature_override { Some(override_config) => { @@ -385,7 +339,6 @@ impl Signer { network, state: persister.state(), backup, - backup_runtime: Arc::new(Mutex::new(backup::BackupRuntime::default())), }) } @@ -836,7 +789,7 @@ impl Signer { ); } trace!("Processing request {}", hex::encode(&req.raw)); - let pre_backup_state = state.clone(); + let pre_backup_state = self.backup.before_request(&state); let prestate_log = serde_json::to_string(&*state).map_err(|e| { Error::Other(anyhow!("Failed to serialize signer state for logging: {:?}", e)) })?; @@ -970,10 +923,7 @@ impl Signer { } }; - let (signer_state, pending_backup): ( - Vec, - Option<(SignerBackupConfig, crate::persist::State)>, - ) = { + let (signer_state, pending_backup) = { debug!("Serializing state changes to report to node"); let mut state = self.state.lock().map_err(|e| { Error::Other(anyhow!( @@ -986,19 +936,7 @@ impl Signer { self.sign_state_payload(key, version, value) }) .map_err(|e| Error::Other(anyhow!("Failed to sign signer state entries: {e}")))?; - let final_state = state.clone(); - let pending_backup = self.backup.as_ref().and_then(|config| { - let mut runtime = match self.backup_runtime.lock() { - Ok(runtime) => runtime, - Err(e) => { - error!("Signer backup runtime lock failed; skipping backup snapshot: {e}"); - return None; - } - }; - runtime - .observe(config.strategy, &pre_backup_state, &final_state) - .then(|| (config.clone(), final_state.omit_tombstones())) - }); + let pending_backup = self.backup.after_request(&pre_backup_state, &state); let full_wire_bytes = { let full_entries: Vec = state.clone().into(); signer_state_response_wire_bytes(&full_entries) @@ -1016,23 +954,7 @@ impl Signer { ); (diff_entries, pending_backup) }; - if let Some((backup_config, backup_state)) = pending_backup { - let backup_result = - backup::write_snapshot(&backup_config, &self.id, backup_state.clone()) - .map_err(|e| Error::Other(anyhow!("failed to write signer backup: {e}"))); - - match backup_result { - Ok(()) => match self.backup_runtime.lock() { - Ok(mut runtime) => runtime.snapshot_succeeded(&backup_state), - Err(e) => { - error!("Signer backup runtime lock failed after successful snapshot: {e}") - } - }, - Err(e) => { - error!("Signer backup failed; continuing without backup snapshot: {e}"); - } - } - } + self.backup.write_pending(&self.id, pending_backup); Ok(HsmResponse { raw: response.as_vec(), request_id: req.request_id, @@ -1700,7 +1622,7 @@ mod tests { SignerConfig { state_signature_mode: mode, state_signature_override: None, - backup: None, + ..SignerConfig::default() }, ) .unwrap() @@ -1714,7 +1636,7 @@ mod tests { SignerConfig { state_signature_mode: mode, state_signature_override: Some(test_override_config(note)), - backup: None, + ..SignerConfig::default() }, ) .unwrap() @@ -1733,6 +1655,7 @@ mod tests { } } + #[cfg(feature = "backup")] #[test] fn periodic_backup_rejects_zero_updates() { assert!(SignerBackupConfig::periodic("backup.json", 0).is_err()); @@ -1745,7 +1668,7 @@ mod tests { state_signature_mode: StateSignatureMode::Soft, state_signature_override: None, backup: Some(SignerBackupConfig { - path: PathBuf::from("backup.json"), + path: "backup.json".into(), strategy: SignerBackupStrategy::Periodic { updates: 0 }, }), }, @@ -2076,7 +1999,7 @@ mod tests { SignerConfig { state_signature_mode: StateSignatureMode::Off, state_signature_override: Some(test_override_config(Some("test"))), - backup: None, + ..SignerConfig::default() }, ); let err = signer.err().unwrap().to_string(); @@ -2095,7 +2018,7 @@ mod tests { ack: "WRONG".to_string(), note: None, }), - backup: None, + ..SignerConfig::default() }, ); let err = signer.err().unwrap().to_string();