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/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..2c104f79c --- /dev/null +++ b/docs/src/reference/signer-backups.md @@ -0,0 +1,111 @@ +# 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 signer state entries for recoverable channels and known peers. + +## 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. + +## 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 + +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 `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 + +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": [""] +} +``` + +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. 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: + +```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 included only when VLS counterparty revocation + secrets are present in the backup. +- 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/Cargo.toml b/libs/gl-cli/Cargo.toml index b0bd4d40f..22de1c3bb 100644 --- a/libs/gl-cli/Cargo.toml +++ b/libs/gl-cli/Cargo.toml @@ -23,11 +23,16 @@ 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" 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..acb65ead8 100644 --- a/libs/gl-cli/src/signer.rs +++ b/libs/gl-cli/src/signer.rs @@ -3,10 +3,13 @@ use crate::util; use clap::{Subcommand, ValueEnum}; use core::fmt::Debug; use gl_client::signer::{ - Signer, SignerConfig, StateSignatureMode, StateSignatureOverrideConfig, + RecoverableChannel, CLNBackup, CLNBackupOptions, Signer, + SignerBackupConfig, 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 +41,35 @@ 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, + Text, +} + +impl Default for BackupInspectFormat { + fn default() -> Self { + Self::Json + } +} + +#[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 @@ -48,6 +80,30 @@ 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 { + #[arg(long)] + path: PathBuf, + #[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, @@ -59,24 +115,212 @@ 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 } + 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, } } +#[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 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, +) -> 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, 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); @@ -115,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, @@ -123,6 +368,7 @@ async fn run_handler>( SignerConfig { state_signature_mode: state_signature_mode.into(), state_signature_override, + backup, }, ) .map_err(|e| Error::custom(format!("Failed to create signer: {}", e)))?; @@ -140,10 +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::{Command, StateSignatureModeArg}; + use super::{ + 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, PathBuf}; #[derive(Parser, Debug)] struct TestCli { @@ -165,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"), } @@ -182,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"), } @@ -206,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"), } @@ -232,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!( @@ -239,10 +549,605 @@ 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"]); + 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 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(); + 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!([peer_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()); + assert!(serialized.get("peers").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_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!([peer_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(); + 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")); + } + + #[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!([peer_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()); + assert!(value.get("peers").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!([peer_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, + 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": values + } + }) + } + + fn peer_entry(peer_id: &str, addr: &str) -> serde_json::Value { + json!({ + "peer_id": peer_id, + "addr": addr, + "direction": "out", + "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 { + 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 + }) + } + + 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 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(); + } } async fn version>(config: Config

) -> Result<()> { 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 dbbc37ed0..e22ecf66e 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, @@ -530,6 +556,14 @@ impl State { .collect(); State { values } } + + #[cfg(feature = "backup")] + pub(crate) fn live_values(&self) -> impl Iterator + '_ { + self.values + .iter() + .filter(|(_, value)| value.version != TOMBSTONE_VERSION) + .map(|(key, value)| (key.as_str(), &value.value)) + } } #[derive(Clone, Serialize, Deserialize, Debug, Default)] @@ -962,8 +996,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; @@ -1475,6 +1509,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 new file mode 100644 index 000000000..92c8aa303 --- /dev/null +++ b/libs/gl-client/src/signer/backup.rs @@ -0,0 +1,1741 @@ +pub use crate::persist::PeerEntry; +use crate::persist::State; +use anyhow::{anyhow, Context, Result}; +use serde::{Deserialize, Serialize}; +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, PathBuf}; + +const BACKUP_VERSION: u32 = 1; +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_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)] +pub struct SignerBackupSnapshot { + pub version: u32, + pub created_at: String, + pub node_id: String, + pub strategy: SignerBackupStrategy, + pub state: State, +} + +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 = peers_from_state(&self.state)? + .into_iter() + .map(|peer| (peer.peer_id.clone(), peer)) + .collect::>(); + + recoverable_channel_values(&self.state) + .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) + .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() + } + + /// 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 shachains = self.recoverable_channel_shachains()?; + 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 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, + 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: encoded.warnings, + }); + } + + 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!( + "unsupported signer backup version {}; expected {}", + self.version, + BACKUP_VERSION + )); + } + + self.strategy.validate() + } + + fn recoverable_channel_shachains( + &self, + ) -> Result>>> { + recoverable_channel_values(&self.state) + .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)] +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(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, +} + +#[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, + commitment_type: String, + counterparty_points: RecoverableBasepoints, + counterparty_selected_contest_delay: u64, + funding_outpoint: RecoverableFundingOutpoint, + is_outbound: bool, +} + +#[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 = 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 { + !recoverable_channel_keys(&before.diff_state(after)).is_empty() +} + +pub(crate) fn write_snapshot( + config: &SignerBackupConfig, + node_id: &[u8], + state: State, +) -> Result<()> { + let snapshot = SignerBackupSnapshot { + 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, + }; + + 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 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 = peer_values(state) + .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/") + .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])) +} + +struct EncodedRecoverchannelScb { + cln_dbid: u64, + channel_id: String, + scb: String, + warnings: Vec, +} + +struct ClnChannelKey { + peer_id: [u8; PEER_ID_LEN], + dbid: u64, +} + +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 { + 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, warnings) = encode_scb_tlvs(channel, old_secrets)?; + + 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), + warnings, + }) +} + +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]> { + decode_hex_array::("funding txid", txid) +} + +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, + 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::( + "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, 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> { + 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()); +} + +#[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 { + 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, + "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(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, + } + } + + 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 full_txid() -> &'static str { + "000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f" + } + + fn expected_cln_txid(txid: &str) -> [u8; 32] { + hex::decode(txid).unwrap().try_into().unwrap() + } + + 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(); + } + + 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!({})); + 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 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 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_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(); + + write_snapshot(&config, &[2u8; 33], state).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["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] + 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!({}))).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": {} } + }), + ); + + 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" } } + }), + ); + + assert!(read_backup_err(&malformed_json).contains("parsing signer backup")); + 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); + 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_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(); + + 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_marks_missing_peer_state_incomplete() { + let peer = peer_id(0xaa); + 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!({ + key: [1, channel(recovery_setup("00", 0, 1000, true))], + format!("peers/{peer}"): [0, json!({ "peer_id": peer })] + })), + }; + + 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() + .to_string() + .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 = backup_snapshot(state(json!({ + channel_key: [1, channel(recovery_setup(full_txid(), 0, 1000, true))] + }))); + + 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 = 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 { + 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_MISSING_WARNING.to_string()] + ); + 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 = backup_snapshot(state(json!({ + channel_key(&peer, 1): [1, channel(recovery_setup(full_txid(), 0, 1000, true))] + }))); + + 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 = 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 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 tlvs = scb_tlvs(&scb); + 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_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 = 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(); + 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 = 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(); + 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 = 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(); + + 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 = 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(); + 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 = 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()) + .unwrap_err() + .to_string(); + assert!(err.contains(expected), "{err}"); + } + } + + #[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 = 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()) + .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(); + let config = SignerBackupConfig::new(dir.path().join("missing").join("backup.json")); + let state = state(json!({})); + + assert!(write_snapshot(&config, &[2u8; 33], state).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!({}))).unwrap(); + + 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/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 4289fe569..9d6875680 100644 --- a/libs/gl-client/src/signer/mod.rs +++ b/libs/gl-client/src/signer/mod.rs @@ -47,6 +47,16 @@ 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, SignerBackupConfig, SignerBackupSnapshot, + SignerBackupStrategy, +}; pub mod model; mod report; mod resolve; @@ -84,6 +94,8 @@ pub struct StateSignatureOverrideConfig { pub struct SignerConfig { pub state_signature_mode: StateSignatureMode, pub state_signature_override: Option, + #[cfg(feature = "backup")] + pub backup: Option, } #[derive(Debug, Default)] @@ -116,6 +128,7 @@ pub struct Signer { network: Network, state: Arc>, + backup: backup_runtime::Runtime, } #[derive(thiserror::Error, Debug)] @@ -179,6 +192,8 @@ impl Signer { info!("Initializing signer for {VERSION} ({GITHASH}) (VLS)"); let state_signature_mode = config.state_signature_mode; + let backup = backup_runtime::Runtime::new(&config)?; + let (state_signature_override_enabled, state_signature_override_note) = match config.state_signature_override { Some(override_config) => { @@ -323,6 +338,7 @@ impl Signer { init, network, state: persister.state(), + backup, }) } @@ -758,7 +774,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 +789,11 @@ impl Signer { ); } trace!("Processing request {}", hex::encode(&req.raw)); - serde_json::to_string(&*state).map_err(|e| { + 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)) - })? + })?; + (prestate_log, pre_backup_state) }; // The first two bytes represent the message type. Check that @@ -905,7 +923,7 @@ impl Signer { } }; - let signer_state: Vec = { + 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!( @@ -918,6 +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 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) @@ -933,8 +952,9 @@ impl Signer { full_wire_bytes, saved_percent ); - diff_entries + (diff_entries, pending_backup) }; + self.backup.write_pending(&self.id, pending_backup); Ok(HsmResponse { raw: response.as_vec(), request_id: req.request_id, @@ -1602,6 +1622,7 @@ mod tests { SignerConfig { state_signature_mode: mode, state_signature_override: None, + ..SignerConfig::default() }, ) .unwrap() @@ -1615,6 +1636,7 @@ mod tests { SignerConfig { state_signature_mode: mode, state_signature_override: Some(test_override_config(note)), + ..SignerConfig::default() }, ) .unwrap() @@ -1633,6 +1655,32 @@ mod tests { } } + #[cfg(feature = "backup")] + #[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: "backup.json".into(), + 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 @@ -1655,7 +1703,7 @@ mod tests { raw: msg, signer_state: vec![], requests: Vec::new(), - },) + }) .await .is_err()); } @@ -1678,7 +1726,7 @@ mod tests { raw: vec![], signer_state: vec![], requests: Vec::new(), - },) + }) .await .unwrap_err() .to_string(), @@ -1951,6 +1999,7 @@ mod tests { SignerConfig { state_signature_mode: StateSignatureMode::Off, state_signature_override: Some(test_override_config(Some("test"))), + ..SignerConfig::default() }, ); let err = signer.err().unwrap().to_string(); @@ -1969,6 +2018,7 @@ mod tests { ack: "WRONG".to_string(), note: None, }), + ..SignerConfig::default() }, ); let err = signer.err().unwrap().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;