diff --git a/README.md b/README.md
index 9ef09b5..222e2b9 100644
--- a/README.md
+++ b/README.md
@@ -79,6 +79,97 @@ The binary is at `target/release/wolfe`.
./target/release/wolfe --config my-config.toml start
```
+### Stop
+
+The node shuts down gracefully via any of these methods:
+
+```bash
+# From the terminal that started it
+Ctrl+C
+
+# Via JSON-RPC
+curl -s http://127.0.0.1:8332/ \
+ -H "Content-Type: application/json" \
+ -d '{"jsonrpc":"2.0","id":1,"method":"stop"}'
+
+# Via signal (SIGINT or SIGTERM)
+kill -INT $(pgrep -f "wolfe start") # or: kill $(pgrep -f "wolfe start")
+```
+
+You can also stop from the **Settings** page in the web dashboard.
+
+All methods trigger the same graceful shutdown: Lightning channel state is persisted, the consensus engine is interrupted cleanly, and peer connections are closed. Both `SIGINT` (Ctrl+C) and `SIGTERM` (default for `kill`, `systemctl stop`, and `launchctl unload`) are handled the same way.
+
+### Auto-Start on macOS (launchd)
+
+To have BitcoinWolfe start automatically on login and restart after crashes:
+
+```bash
+# 1. Copy the template plist
+cp config/com.bitcoinwolfe.node.plist ~/Library/LaunchAgents/
+
+# 2. Edit it — replace the placeholder paths with your actual paths:
+# __WOLFE_BINARY__ → full path to the wolfe binary (e.g. /Users/you/BitcoinWolfe/target/release/wolfe)
+# __WOLFE_DIR__ → full path to the project directory (e.g. /Users/you/BitcoinWolfe)
+nano ~/Library/LaunchAgents/com.bitcoinwolfe.node.plist
+
+# 3. Load the service
+launchctl load ~/Library/LaunchAgents/com.bitcoinwolfe.node.plist
+
+# 4. Verify it's running
+launchctl list | grep bitcoinwolfe
+curl -s http://127.0.0.1:8332/api/peers | head -c 100
+```
+
+To manage the service:
+
+```bash
+# Stop the service (and prevent auto-restart)
+launchctl unload ~/Library/LaunchAgents/com.bitcoinwolfe.node.plist
+
+# Reload after config changes
+launchctl unload ~/Library/LaunchAgents/com.bitcoinwolfe.node.plist
+launchctl load ~/Library/LaunchAgents/com.bitcoinwolfe.node.plist
+```
+
+> **Note:** The plist uses `KeepAlive` with `SuccessfulExit = false`, so launchd will restart the node if it crashes but not if you stop it intentionally via RPC `stop`, `Ctrl+C`, or the dashboard.
+>
+> `~/Library/LaunchAgents/` runs at user **login**, not at boot. For boot-time startup install the plist into `/Library/LaunchDaemons/` (requires `sudo`) and add a `UserName` key — note that `LaunchDaemons` run as root unless `UserName` is set.
+
+### Auto-Start on Linux (systemd)
+
+```bash
+# Create a service file
+sudo tee /etc/systemd/system/bitcoinwolfe.service << 'EOF'
+[Unit]
+Description=BitcoinWolfe Node
+After=network-online.target
+Wants=network-online.target
+
+[Service]
+Type=simple
+User=YOUR_USER
+WorkingDirectory=/path/to/BitcoinWolfe
+ExecStart=/path/to/BitcoinWolfe/target/release/wolfe start
+Restart=on-failure
+RestartSec=10
+# systemctl stop sends SIGTERM, which the node handles gracefully.
+# Give Lightning state and consensus enough time to persist before SIGKILL.
+TimeoutStopSec=30
+
+[Install]
+WantedBy=multi-user.target
+EOF
+
+# Enable and start
+sudo systemctl daemon-reload
+sudo systemctl enable bitcoinwolfe
+sudo systemctl start bitcoinwolfe
+
+# Check status
+sudo systemctl status bitcoinwolfe
+```
+
---
## CLI Usage
@@ -170,6 +261,11 @@ accept_inbound_channels = true
min_channel_size_sat = 20000
max_channel_size_sat = 16777215
# rapid_gossip_sync_url = "https://rapidsync.lightningdevkit.org/snapshot"
+
+# Lightning peers that the node should keep connected. The reconnector
+# checks every 60 seconds and dials any listed peer that's currently
+# offline. Hostnames are re-resolved on each tick.
+# persistent_peers = ["02abc...@host.example.com:9735"]
```
### Nostr
diff --git a/config/com.bitcoinwolfe.node.plist b/config/com.bitcoinwolfe.node.plist
new file mode 100644
index 0000000..83c69d5
--- /dev/null
+++ b/config/com.bitcoinwolfe.node.plist
@@ -0,0 +1,44 @@
+
+
+
+
+
+ Label
+ com.bitcoinwolfe.node
+
+
+ ProgramArguments
+
+ __WOLFE_BINARY__
+ start
+
+
+
+ WorkingDirectory
+ __WOLFE_DIR__
+
+
+ RunAtLoad
+
+
+
+ KeepAlive
+
+ SuccessfulExit
+
+
+
+
+ ThrottleInterval
+ 10
+
+
+ StandardOutPath
+ __WOLFE_DIR__/wolfe-launchd.log
+ StandardErrorPath
+ __WOLFE_DIR__/wolfe-launchd.log
+
+
diff --git a/crates/wolfe-node/src/main.rs b/crates/wolfe-node/src/main.rs
index 48cfed8..699ec32 100644
--- a/crates/wolfe-node/src/main.rs
+++ b/crates/wolfe-node/src/main.rs
@@ -425,6 +425,79 @@ async fn main() -> Result<()> {
}
});
info!("Lightning background processor started");
+
+ // Periodically (re)connect to persistent peers from config.
+ // Pubkeys are validated once here, then any persistent peer not
+ // currently connected is dialed every 60 seconds. Hostnames are
+ // re-resolved on each tick so DHCP/DNS changes on the LAN don't
+ // permanently break reconnection.
+ if !config.lightning.persistent_peers.is_empty() {
+ let mut valid_peers: Vec<(bitcoin::secp256k1::PublicKey, String, String)> = Vec::new();
+ for peer_str in &config.lightning.persistent_peers {
+ match split_ln_peer(peer_str) {
+ Ok((pubkey, addr_str)) => {
+ valid_peers.push((pubkey, addr_str, peer_str.clone()));
+ }
+ Err(e) => {
+ warn!(%peer_str, %e, "invalid persistent_peers entry, skipping");
+ }
+ }
+ }
+
+ if !valid_peers.is_empty() {
+ let valid_count = valid_peers.len();
+ let ln_reconnect = ln.clone();
+ let ln_reconnect_shutdown = shutdown.clone();
+ tokio::spawn(async move {
+ // Brief delay to let the listener bind first
+ tokio::time::sleep(std::time::Duration::from_secs(2)).await;
+ let mut interval = tokio::time::interval(std::time::Duration::from_secs(60));
+ loop {
+ interval.tick().await;
+ if ln_reconnect_shutdown.load(Ordering::Relaxed) {
+ break;
+ }
+
+ let connected: std::collections::HashSet =
+ ln_reconnect
+ .peer_manager()
+ .list_peers()
+ .into_iter()
+ .map(|p| p.counterparty_node_id)
+ .collect();
+
+ for (pubkey, addr_str, peer_str) in &valid_peers {
+ if connected.contains(pubkey) {
+ continue;
+ }
+ let addr = match resolve_addr(addr_str).await {
+ Ok(a) => a,
+ Err(e) => {
+ debug!(%peer_str, ?e, "persistent peer DNS resolve failed, will retry");
+ continue;
+ }
+ };
+ match ln_reconnect.connect_peer(*pubkey, addr).await {
+ Ok(()) => info!(
+ %pubkey,
+ %addr,
+ "persistent Lightning peer connected"
+ ),
+ Err(e) => debug!(
+ %peer_str,
+ ?e,
+ "persistent peer not reachable, will retry"
+ ),
+ }
+ }
+ }
+ });
+ info!(
+ valid = valid_count,
+ "persistent Lightning peer reconnector started"
+ );
+ }
+ }
}
// ── Initialize RPC server ───────────────────────────────────────────
@@ -484,6 +557,7 @@ async fn main() -> Result<()> {
config.nostr.fee_oracle_interval_secs,
config.nostr.name.clone(),
config.nostr.about.clone(),
+ config.nostr.picture.clone(),
)
.await
{
@@ -899,9 +973,8 @@ async fn main() -> Result<()> {
}
}
- // Shutdown on Ctrl+C
- _ = tokio::signal::ctrl_c() => {
- info!("shutdown signal received");
+ // Shutdown on SIGINT (Ctrl+C) or SIGTERM (kill, systemctl stop, launchctl unload)
+ _ = shutdown_signal() => {
shutdown.store(true, Ordering::Relaxed);
break;
}
@@ -1027,3 +1100,64 @@ fn prune_block_files(blocks_dir: &std::path::Path, target_bytes: u64) -> Result<
Ok(())
}
+
+/// Parse a "pubkey@host:port" string. Validates the pubkey synchronously and
+/// returns the address portion as a string for later async resolution. Splitting
+/// pubkey parsing from DNS lookup lets us validate the static parts once at
+/// startup while still re-resolving hostnames each reconnect cycle.
+fn split_ln_peer(s: &str) -> Result<(bitcoin::secp256k1::PublicKey, String)> {
+ let (pk_str, addr_str) = s
+ .split_once('@')
+ .ok_or_else(|| anyhow::anyhow!("expected pubkey@host:port"))?;
+ let pubkey: bitcoin::secp256k1::PublicKey = pk_str.parse()?;
+ Ok((pubkey, addr_str.to_string()))
+}
+
+/// Resolve a "host:port" string to a SocketAddr without blocking the runtime.
+async fn resolve_addr(addr_str: &str) -> Result {
+ tokio::net::lookup_host(addr_str)
+ .await?
+ .next()
+ .ok_or_else(|| anyhow::anyhow!("could not resolve {}", addr_str))
+}
+
+/// Wait for any termination signal. On Unix this is SIGINT or SIGTERM, so
+/// `Ctrl+C`, `kill`, `systemctl stop`, and `launchctl unload` all trigger the
+/// same graceful shutdown path.
+async fn shutdown_signal() {
+ #[cfg(unix)]
+ {
+ use tokio::signal::unix::{signal, SignalKind};
+ let mut sigterm = match signal(SignalKind::terminate()) {
+ Ok(s) => s,
+ Err(e) => {
+ error!(
+ ?e,
+ "failed to install SIGTERM handler, falling back to Ctrl+C only"
+ );
+ let _ = tokio::signal::ctrl_c().await;
+ return;
+ }
+ };
+ let mut sigint = match signal(SignalKind::interrupt()) {
+ Ok(s) => s,
+ Err(e) => {
+ error!(
+ ?e,
+ "failed to install SIGINT handler, falling back to Ctrl+C only"
+ );
+ let _ = tokio::signal::ctrl_c().await;
+ return;
+ }
+ };
+ tokio::select! {
+ _ = sigterm.recv() => info!("SIGTERM received"),
+ _ = sigint.recv() => info!("SIGINT received"),
+ }
+ }
+ #[cfg(not(unix))]
+ {
+ let _ = tokio::signal::ctrl_c().await;
+ info!("Ctrl+C received");
+ }
+}
diff --git a/crates/wolfe-nostr/src/lib.rs b/crates/wolfe-nostr/src/lib.rs
index 138a716..d56265c 100644
--- a/crates/wolfe-nostr/src/lib.rs
+++ b/crates/wolfe-nostr/src/lib.rs
@@ -47,6 +47,7 @@ pub struct NostrBridge {
keys: Keys,
profile_name: Option,
profile_about: Option,
+ profile_picture: Option,
}
/// Handle for sending events to the Nostr bridge from the main loop.
@@ -72,6 +73,7 @@ impl NostrBridge {
///
/// Returns `(bridge, sender, client)` where `client` is a shared handle
/// that can be used by RPC handlers to publish events or query relays.
+ #[allow(clippy::too_many_arguments)]
pub async fn new(
secret_key: Option<&str>,
relays: &[String],
@@ -80,6 +82,7 @@ impl NostrBridge {
fee_oracle_interval_secs: u64,
profile_name: Option,
profile_about: Option,
+ profile_picture: Option,
) -> Result<(Self, NostrSender, Arc), NostrError> {
let keys = match secret_key {
Some(sk) => Keys::parse(sk).map_err(|e| NostrError::InvalidKey(e.to_string()))?,
@@ -115,6 +118,7 @@ impl NostrBridge {
keys,
profile_name,
profile_about,
+ profile_picture,
},
NostrSender { tx },
shared_client,
@@ -141,7 +145,10 @@ impl NostrBridge {
);
// Publish profile metadata (NIP-01 kind 0) if configured
- if self.profile_name.is_some() || self.profile_about.is_some() {
+ if self.profile_name.is_some()
+ || self.profile_about.is_some()
+ || self.profile_picture.is_some()
+ {
let mut metadata = Metadata::new();
if let Some(ref name) = self.profile_name {
metadata = metadata.name(name);
@@ -149,6 +156,12 @@ impl NostrBridge {
if let Some(ref about) = self.profile_about {
metadata = metadata.about(about);
}
+ if let Some(ref picture) = self.profile_picture {
+ match Url::parse(picture) {
+ Ok(url) => metadata = metadata.picture(url),
+ Err(e) => warn!(picture, ?e, "invalid nostr.picture URL, skipping"),
+ }
+ }
match self.client.set_metadata(&metadata).await {
Ok(output) => {
info!(
diff --git a/crates/wolfe-types/src/config.rs b/crates/wolfe-types/src/config.rs
index 8d26302..798e2e2 100644
--- a/crates/wolfe-types/src/config.rs
+++ b/crates/wolfe-types/src/config.rs
@@ -286,6 +286,8 @@ pub struct NostrConfig {
pub name: Option,
/// Profile about/bio text.
pub about: Option,
+ /// Profile picture URL (NIP-01 kind 0 metadata).
+ pub picture: Option,
/// Relay URLs to publish events to.
pub relays: Vec,
/// Publish new block announcements to relays.
@@ -322,6 +324,8 @@ pub struct LightningConfig {
pub max_channel_size_sat: u64,
/// URL for Rapid Gossip Sync server (optional, speeds up initial gossip).
pub rapid_gossip_sync_url: Option,
+ /// Peers to keep connected to, periodically reconnecting as needed (pubkey@host:port).
+ pub persistent_peers: Vec,
}
impl Default for LightningConfig {
@@ -336,6 +340,7 @@ impl Default for LightningConfig {
min_channel_size_sat: 20_000,
max_channel_size_sat: 16_777_215,
rapid_gossip_sync_url: None,
+ persistent_peers: vec![],
}
}
}
@@ -374,6 +379,7 @@ impl Default for NostrConfig {
secret_key: None,
name: None,
about: None,
+ picture: None,
relays: vec![
"wss://relay.damus.io".to_string(),
"wss://nos.lol".to_string(),