diff --git a/Cargo.lock b/Cargo.lock index 2cd2b65..b40cc67 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -549,8 +549,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" dependencies = [ "iana-time-zone", + "js-sys", "num-traits", "serde", + "wasm-bindgen", "windows-link", ] @@ -583,6 +585,24 @@ dependencies = [ "tracing-subscriber", ] +[[package]] +name = "cln-rpc" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8c63eb175d58f0212e35e427237cf3e4f856f47ec7b8ad95110484d292fc819" +dependencies = [ + "anyhow", + "bitcoin", + "bytes", + "futures-util", + "hex", + "log", + "serde", + "serde_json", + "tokio", + "tokio-util", +] + [[package]] name = "cln-rpc" version = "0.7.0" @@ -2188,7 +2208,7 @@ dependencies = [ "anyhow", "axum", "cln-plugin", - "cln-rpc", + "cln-rpc 0.7.0", "prometheus", "serde", "serde_json", @@ -2967,6 +2987,20 @@ dependencies = [ "web-sys", ] +[[package]] +name = "reserves-attestor-plugin" +version = "0.1.0" +dependencies = [ + "anyhow", + "chrono", + "cln-plugin", + "cln-rpc 0.6.0", + "serde", + "serde_json", + "tokio", + "tracing", +] + [[package]] name = "resolv-conf" version = "0.7.6" @@ -2994,7 +3028,7 @@ dependencies = [ "anyhow", "base64 0.23.1", "cln-plugin", - "cln-rpc", + "cln-rpc 0.7.0", "google-cloud-auth", "google-cloud-storage", "serde", diff --git a/Cargo.toml b/Cargo.toml index 425d0f1..ca6e8fd 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -3,7 +3,8 @@ resolver = "3" members = [ "rpc-log-plugin", "event-plugin", - "metrics-plugin" + "metrics-plugin", + "reserves-attestor-plugin" ] [workspace.package] diff --git a/README.md b/README.md index 3f0b7aa..5befe87 100644 --- a/README.md +++ b/README.md @@ -17,16 +17,17 @@ A growing collection of plugins for [Core Lightning](https://github.com/Elements Running a Core Lightning node often means connecting it to the rest of your infrastructure: monitoring, event pipelines, dashboards, alerts, and more. This repository keeps those integrations small, composable, and open source. -Each plugin lives in its own workspace crate and can be built, configured, and run independently. There are three plugins +Each plugin lives in its own workspace crate and can be built, configured, and run independently. There are four plugins today—and the collection is designed to grow. ## Plugins -| Plugin | What it does | Integrates with | -|--------------------------------------|--------------------------------------------------------------------|----------------------| -| [`event-plugin`](./event-plugin) | Publishes CLN events and hook data to a message broker | RabbitMQ | -| [`metrics-plugin`](./metrics-plugin) | Exposes node, funds, liquidity, channel, peer, and event metrics | Prometheus | -| [`rpc-log-plugin`](./rpc-log-plugin) | Stores selected, sanitized RPC requests as individual JSON objects | Google Cloud Storage | +| Plugin | What it does | Integrates with | +|----------------------------------------------------------|--------------------------------------------------------------------|----------------------| +| [`event-plugin`](./event-plugin) | Publishes CLN events and hook data to a message broker | RabbitMQ | +| [`metrics-plugin`](./metrics-plugin) | Exposes node, funds, liquidity, channel, peer, and event metrics | Prometheus | +| [`reserves-attestor-plugin`](./reserves-attestor-plugin) | Produces verifiable, node-signed balance statements | BLIP-50 signatures | +| [`rpc-log-plugin`](./rpc-log-plugin) | Stores selected, sanitized RPC requests as individual JSON objects | Google Cloud Storage | ### Event plugin @@ -46,6 +47,18 @@ Use it as the foundation for dashboards, alerts, and day-to-day node monitoring. [Configuration and metric reference →](./metrics-plugin/README.md) +### Reserves attestor plugin + +Produce a signed, point-in-time statement of the funds controlled by your node. The plugin reports confirmed spendable +on-chain funds and the node's side of open Lightning channels, while listing unconfirmed, reserved, immature, and pending +amounts separately so they cannot be mistaken for the available balance. + +Each statement is signed with the node's key through CLN's `signmessage` RPC and can be independently verified with +`checkmessage`. Its heading defaults to `Core Lightning node balance statement` and can be customized with the +`reserves-attestor-service-name` option. + +[Configuration, statement format, and verification →](./reserves-attestor-plugin/README.md) + ### RPC log plugin Keep a durable audit trail of requests made to your node. The plugin observes selected calls through CLN's @@ -82,6 +95,7 @@ The executables are created in `target/release/`: ```text target/release/event-plugin target/release/metrics-plugin +target/release/reserves-attestor-plugin target/release/rpc-log-plugin ``` @@ -105,6 +119,7 @@ For example: blockstream/cln-plugins-rpc-log-plugin:v1.2.3 blockstream/cln-plugins-event-plugin:v1.2.3 blockstream/cln-plugins-metrics-plugin:v1.2.3 +blockstream/cln-plugins-reserves-attestor-plugin:v1.2.3 ``` ### Install a plugin @@ -198,4 +213,3 @@ discussed early. Lightning plugins run alongside your node and may receive sensitive operational data. Review code and configuration before deployment, bind network endpoints conservatively, protect broker credentials, and test changes outside production first. - diff --git a/reserves-attestor-plugin/Cargo.toml b/reserves-attestor-plugin/Cargo.toml new file mode 100644 index 0000000..3a1214e --- /dev/null +++ b/reserves-attestor-plugin/Cargo.toml @@ -0,0 +1,20 @@ +[package] +name = "reserves-attestor-plugin" +version = "0.1.0" +edition = "2024" +description = "CLN plugin that produces a signed on-chain + Lightning balance statement for a single node" +license.workspace = true + +[[bin]] +name = "reserves-attestor-plugin" +path = "src/main.rs" + +[dependencies] +cln-plugin = { version = "0.7" } +cln-rpc = { version = "0.6" } +tokio = { version = "1", features = ["full"] } +chrono = { version = "0.4" } +serde = { version = "1", features = ["derive"] } +serde_json = { version = "1" } +anyhow = { version = "1" } +tracing = { version = "0.1" } \ No newline at end of file diff --git a/reserves-attestor-plugin/README.md b/reserves-attestor-plugin/README.md new file mode 100644 index 0000000..375ad5b --- /dev/null +++ b/reserves-attestor-plugin/README.md @@ -0,0 +1,179 @@ +# Reserves attestor - CLN Plugin + +Core Lightning plugin that produces a signed statement of how +much Bitcoin a node controls - on-chain and in channels - at a point in time. + +--- + +## Why + +A verifier needs an artifact that: + +- states a node's balance at a point in time, +- was produced by the node itself +- signed with the node's own key, so it can be verified against the + node's public Node ID without trusting whoever forwarded the report. + +`lightning-cli signmessage`/`checkmessage` already give a node a way to sign +and verify arbitrary text with its own key ([BLIP-50](https://github.com/lightning/blips/blob/master/blip-0050.md)). +This plugin builds the statement text and signs it, so nobody has to hand-craft +the message or shell out to `signmessage` by hand. + +--- + +## Configuration + +| Option | Required | Description | +|---|---|---| +| `reserves-attestor-service-name` | no | Service name printed in the signed statement heading. Defaults to `Core Lightning`. | + +``` +plugin=/path/to/reserves-attestor-plugin +reserves-attestor-service-name=Example Service +``` + +If the option is omitted, the heading is `Core Lightning node balance +statement`. + +--- + +## RPC + +### `get-signed-balance` + +Produces and signs a statement for this node, as of now. + +``` +lightning-cli get-signed-balance +``` + +The heading of the statement comes from the `reserves-attestor-service-name` +option (see Configuration) and is part of the signed text. + +```json +{ + "statement_version": 1, + "node_id": "02aabb...", + "network": "bitcoin", + "statement_time": "2026-09-17T17:42:00Z", + "block_height": 912345, + "onchain_balance_msat": 1000000000, + "lightning_balance_msat": 250000000, + "total_msat": 1250000000, + "onchain_unconfirmed_msat": 0, + "onchain_reserved_msat": 0, + "onchain_immature_msat": 0, + "lightning_pending_msat": 0, + "statement": "-----BEGIN CLN SIGNED MESSAGE-----\nCore Lightning node balance statement\n\nStatement version: 1\nNode ID: 02aabb...\nNetwork: bitcoin\nStatement time: 2026-09-17T17:42:00Z\nBlock height: 912345\n\nOn-chain balance: 1000000000 msats (confirmed, spendable by this node)\nLightning balance: 250000000 msats (this node's side of open channels)\nTotal under control: 1250000000 msats\n\nAccounting data: none\n\nExcluded from totals:\n - Unconfirmed on-chain: 0 msats\n - Reserved on-chain UTXOs: 0 msats\n - Immature on-chain, e.g. coinbase: 0 msats\n - Pending channels, opening/closing: 0 msats\n-----BEGIN CLN SIGNATURE-----\n\n-----END CLN SIGNATURE-----\n", + "signature": "" +} +``` + +`statement` is the plain-text artifact to hand to the verifier - save it as-is +(e.g. `lightning-cli get-signed-balance | jq -r .statement > statement.txt`). +The other fields are the same numbers, structured, for anything that wants to +consume them programmatically (a dashboard) without +re-parsing the text block. + +`signature` is the zbase32 signature from `signmessage` over the message block +(the text inside the `statement` armor, byte-for-byte). The plugin runs its +own `checkmessage` round trip against that message/signature pair before the +RPC ever returns, and the call fails outright (instead of returning a +statement) if that check doesn't come back verified against the node's own +`node_id` - so any `statement`/`signature` pair you get back was already +independently re-checked by the node, not just asserted. + +--- + +## Data sources + +| Field | Source | Definition | +|---|---|---| +| On-chain balance | `listfunds` | Sum (in msat) of wallet outputs with `status = confirmed` and `reserved = false`. | +| Lightning balance | `listpeerchannels` | Sum of `to_us_msat` for channels with `state = CHANNELD_NORMAL`. | +| Total under control | - | On-chain balance + Lightning balance. Nothing else is ever added in. | + +All amounts are msat - Lightning's native unit - so nothing has to be rounded +to fit; on-chain sat amounts are simply reported as `sat_amount * 1000`. + +Caveated amounts are never added into any of the totals above; they are +reported as separate `Excluded from totals` lines (in the text) / their own +top-level `*_msat` fields (in the JSON). These fields are always present, with +zeros when there's nothing to caveat, so "missing" and "zero" are never +ambiguous and a parser +always sees the same shape: + +| Note | Source | +|---|---| +| Unconfirmed on-chain | `listfunds` outputs with `status = unconfirmed` | +| Reserved on-chain UTXOs | `listfunds` outputs with `status = confirmed`, `reserved = true` (e.g. locked by an in-flight PSBT) | +| Immature on-chain | `listfunds` outputs with `status = immature` (e.g. unmatured coinbase) | +| Pending channels | `listpeerchannels` channels not in `CHANNELD_NORMAL` (opening or closing) | + +--- + +## Signing and verification + +The signed portion is exactly the text between the armor lines - the armor +itself is never part of what gets signed: + +``` +-----BEGIN CLN SIGNED MESSAGE----- +Core Lightning node balance statement + +Statement version: 1 +Node ID: 02aabb... +Network: bitcoin +Statement time: 2026-09-17T17:42:00Z +Block height: 912345 + +On-chain balance: 1000000000 msats (confirmed, spendable by this node) +Lightning balance: 250000000 msats (this node's side of open channels) +Total under control: 1250000000 msats + +Accounting data: none + +Excluded from totals: + - Unconfirmed on-chain: 15000000 msats + - Reserved on-chain UTXOs: 5000000 msats + - Immature on-chain, e.g. coinbase: 2000000 msats + - Pending channels, opening/closing: 8000000 msats +-----BEGIN CLN SIGNATURE----- + +-----END CLN SIGNATURE----- +``` + +- `Statement version` is bumped whenever the layout changes, so a parser + knows which rules apply instead of guessing from shape. +- `Statement time` is RFC 3339 (`%Y-%m-%dT%H:%M:%SZ`), always UTC. +- All amounts are msat, Lightning's native unit, so nothing is ever rounded + to fit. +- `Excluded from totals` is always present, one line per field, with zeros + when there's nothing to caveat - "missing" and "zero" are never ambiguous, + and the section header already says these are excluded, so the per-line + `(not counted)` suffix is gone. +- `Accounting data` is `none` for now; the line reserves a slot for a future + CSV hash so adding one is a value change on that line, not a format change. + +The plugin signs via `signmessage` (BLIP-50: +`zbase32(SigRec(SHA256(SHA256("Lightning Signed Message:" + msg))))`). + +To verify, run `checkmessage` with the message and the zbase32 signature: + +- `$SIGNATURE` is the `signature` field from the `get-signed-balance` + response, used as-is. +- `$MESSAGE` is everything between (and not including) the `BEGIN`/`END` + armor lines inside `statement`. + +``` +lightning-cli checkmessage "$MESSAGE" "$SIGNATURE" [node_id] +``` + +`checkmessage` returns `verified: true` when the signature matches, and +recovers the pubkey when `node_id` is omitted - useful to double check it +matches the `Node ID` printed in the statement itself. Passing `node_id` also +checks the recovered key matches it directly. The plugin already runs this +exact check once (server-side) before returning the statement - the RPC call +fails outright if it doesn't come back verified - so a mismatch on user's +side means the statement was altered in transit, not that the node's +signature was ever bad. diff --git a/reserves-attestor-plugin/src/lib.rs b/reserves-attestor-plugin/src/lib.rs new file mode 100644 index 0000000..b38e491 --- /dev/null +++ b/reserves-attestor-plugin/src/lib.rs @@ -0,0 +1,23 @@ +//! Wire-format types for `get-signed-balance`, shared between the plugin +//! binary and clients that call it over CLN's JSON-RPC and need to deserialize +//! the exact response shape. +use serde::{Deserialize, Serialize}; + +/// `get-signed-balance`'s JSON response. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct RpcGetSignedBalanceResponse { + pub statement_version: u32, + pub node_id: String, + pub network: String, + pub statement_time: String, + pub block_height: u32, + pub onchain_balance_msat: u64, + pub lightning_balance_msat: u64, + pub total_msat: u64, + pub onchain_unconfirmed_msat: u64, + pub onchain_reserved_msat: u64, + pub onchain_immature_msat: u64, + pub lightning_pending_msat: u64, + pub statement: String, + pub signature: String, +} diff --git a/reserves-attestor-plugin/src/main.rs b/reserves-attestor-plugin/src/main.rs new file mode 100644 index 0000000..0c588af --- /dev/null +++ b/reserves-attestor-plugin/src/main.rs @@ -0,0 +1,112 @@ +mod rpc; +mod statement; + +use anyhow::{Context, Result}; +use cln_plugin::options::DefaultStringConfigOption; +use cln_plugin::{Builder, Plugin}; +use cln_rpc::ClnRpc; +use reserves_attestor_plugin::RpcGetSignedBalanceResponse; +use serde_json::Value; +use std::path::PathBuf; +use tracing::info; + +#[derive(Clone)] +pub struct PluginState { + pub socket_path: PathBuf, + pub service_name: String, +} + +const SERVICE_NAME_OPTION: DefaultStringConfigOption = + DefaultStringConfigOption::new_str_with_default( + "reserves-attestor-service-name", + "Core Lightning", + "Service name printed in the signed statement heading", + ); + +#[tokio::main] +async fn main() -> Result<()> { + let configured = Builder::new(tokio::io::stdin(), tokio::io::stdout()) + .option(SERVICE_NAME_OPTION) + .rpcmethod( + "get-signed-balance", + "Produce a signed on-chain + Lightning balance statement for this node", + get_signed_balance, + ) + .configure() + .await?; + + let Some(configured) = configured else { + return Ok(()); + }; + + let socket_path = PathBuf::from(configured.configuration().lightning_dir) + .join(configured.configuration().rpc_file.clone()); + + let service_name = configured.option(&SERVICE_NAME_OPTION)?; + + let state = PluginState { + socket_path, + service_name, + }; + + info!("reserves-attestor-plugin configured"); + + let plugin = configured.start(state).await?; + plugin.join().await?; + Ok(()) +} + +async fn get_signed_balance( + plugin: Plugin, + _args: Value, +) -> Result { + let state = plugin.state(); + let mut rpc = ClnRpc::new(&state.socket_path) + .await + .context("connecting to CLN RPC")?; + + let inputs = rpc::fetch_inputs(&mut rpc).await?; + let message = statement::build_message_block(&state.service_name, &inputs); + let signature = rpc::sign_message(&mut rpc, &message).await?; + + // Self-check: run the same checkmessage round trip the verifier will do before + // ever returning the statement, so a signing bug is caught here instead + // of by whoever tries to verify it later. + let (verified, recovered_pubkey) = rpc::check_message( + &mut rpc, + &message, + &signature, + Some(inputs.node_id.parse().context("failed to parse node id")?), + ) + .await?; + + if !verified { + anyhow::bail!( + "signmessage/checkmessage self-check failed: verified={verified}, recovered pubkey={recovered_pubkey}", + ); + } + + let signed = statement::build_signed_statement(&message, &signature); + let total_msat = inputs.total_msat() as u64; + + let response = RpcGetSignedBalanceResponse { + statement_version: statement::STATEMENT_VERSION, + node_id: inputs.node_id, + network: inputs.network, + statement_time: inputs + .statement_time + .to_rfc3339_opts(chrono::SecondsFormat::Secs, true), + block_height: inputs.block_height, + onchain_balance_msat: inputs.onchain_confirmed_msat as u64, + lightning_balance_msat: inputs.lightning_open_msat as u64, + total_msat, + onchain_unconfirmed_msat: inputs.onchain_unconfirmed_msat as u64, + onchain_reserved_msat: inputs.onchain_reserved_msat as u64, + onchain_immature_msat: inputs.onchain_immature_msat as u64, + lightning_pending_msat: inputs.lightning_pending_msat as u64, + statement: signed, + signature, + }; + + Ok(serde_json::to_value(response)?) +} diff --git a/reserves-attestor-plugin/src/rpc.rs b/reserves-attestor-plugin/src/rpc.rs new file mode 100644 index 0000000..67307e6 --- /dev/null +++ b/reserves-attestor-plugin/src/rpc.rs @@ -0,0 +1,117 @@ +use crate::statement::BalanceStatementInputs; +use anyhow::{Context, Result}; +use chrono::Utc; +use cln_rpc::ClnRpc; +use cln_rpc::model::requests::{ + CheckmessageRequest, GetinfoRequest, ListfundsRequest, ListpeerchannelsRequest, + SignmessageRequest, +}; +use cln_rpc::model::responses::ListfundsOutputsStatus; +use cln_rpc::primitives::{ChannelState, PublicKey}; + +/// Signs `message` (the plain message block, with no armor) via `signmessage` +/// and returns the zbase32-encoded signature. +pub async fn sign_message(rpc: &mut ClnRpc, message: &str) -> Result { + let resp = rpc + .call_typed(&SignmessageRequest { + message: message.to_string(), + }) + .await + .context("signmessage")?; + + Ok(resp.zbase) +} + +/// Verifies a message/signature pair against `pubkey` via `checkmessage` and +/// returns whether it verified plus the pubkey CLN recovered from the +/// signature. Run right after signing so a broken signature is caught before +/// the statement ever leaves the node, instead of being discovered by users +/// later. +pub async fn check_message( + rpc: &mut ClnRpc, + message: &str, + zbase: &str, + pubkey: Option, +) -> Result<(bool, String)> { + let resp = rpc + .call_typed(&CheckmessageRequest { + pubkey, + message: message.to_string(), + zbase: zbase.to_string(), + }) + .await + .context("checkmessage")?; + + Ok((resp.verified, resp.pubkey.to_string())) +} + +/// Pulls `getinfo` / `listfunds` / `listpeerchannels` and reduces them to the +/// msat-denominated inputs the statement needs. On-chain funds are split by +/// `listfunds` status/reserved flag; Lightning funds are split by whether the +/// channel is `CHANNELD_NORMAL` (open) or anything else (pending open/close) - +/// only the confirmed-unreserved and open buckets ever reach a total. +pub async fn fetch_inputs(rpc: &mut ClnRpc) -> Result { + let info = rpc + .call_typed(&GetinfoRequest {}) + .await + .context("getinfo")?; + + let funds = rpc + .call_typed(&ListfundsRequest { spent: None }) + .await + .context("listfunds")?; + + let peerchannels = rpc + .call_typed(&ListpeerchannelsRequest { + id: None, + channel_id: None, + short_channel_id: None, + }) + .await + .context("listpeerchannels")?; + + let mut onchain_confirmed_msat = 0i64; + let mut onchain_unconfirmed_msat = 0i64; + let mut onchain_reserved_msat = 0i64; + let mut onchain_immature_msat = 0i64; + + for output in &funds.outputs { + let msat = output.amount_msat.msat() as i64; + + match output.status { + ListfundsOutputsStatus::CONFIRMED if !output.reserved => { + onchain_confirmed_msat += msat; + } + ListfundsOutputsStatus::CONFIRMED => onchain_reserved_msat += msat, + ListfundsOutputsStatus::UNCONFIRMED => onchain_unconfirmed_msat += msat, + ListfundsOutputsStatus::IMMATURE => onchain_immature_msat += msat, + ListfundsOutputsStatus::SPENT => {} + } + } + + let mut lightning_open_msat = 0i64; + let mut lightning_pending_msat = 0i64; + + for channel in &peerchannels.channels { + let msat = channel.to_us_msat.map(|a| a.msat() as i64).unwrap_or(0); + + if channel.state == ChannelState::CHANNELD_NORMAL { + lightning_open_msat += msat; + } else { + lightning_pending_msat += msat; + } + } + + Ok(BalanceStatementInputs { + node_id: info.id.to_string(), + network: info.network, + statement_time: Utc::now(), + block_height: info.blockheight, + onchain_confirmed_msat, + onchain_unconfirmed_msat, + onchain_reserved_msat, + onchain_immature_msat, + lightning_open_msat, + lightning_pending_msat, + }) +} diff --git a/reserves-attestor-plugin/src/statement.rs b/reserves-attestor-plugin/src/statement.rs new file mode 100644 index 0000000..5251950 --- /dev/null +++ b/reserves-attestor-plugin/src/statement.rs @@ -0,0 +1,212 @@ +use chrono::{DateTime, Utc}; + +pub const BEGIN_MESSAGE: &str = "-----BEGIN CLN SIGNED MESSAGE-----"; +pub const BEGIN_SIGNATURE: &str = "-----BEGIN CLN SIGNATURE-----"; +pub const END_SIGNATURE: &str = "-----END CLN SIGNATURE-----"; + +/// Bumped whenever the statement layout changes. Lets a parser pick the +/// right rules for the statement it's looking at instead of guessing from +/// shape. +pub const STATEMENT_VERSION: u32 = 1; + +/// Everything needed to render a balance statement, already reduced to plain +/// msat amounts - Lightning's native unit, so nothing has to be rounded to +/// fit. Caveated amounts (unconfirmed, reserved, immature, pending channel) +/// are tracked separately from the totalled amounts so they can never +/// accidentally leak into `total_msat`. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct BalanceStatementInputs { + pub node_id: String, + pub network: String, + /// RFC 3339, e.g. `2026-09-22T10:00:00Z` datetime. + pub statement_time: DateTime, + pub block_height: u32, + + /// Confirmed, unreserved wallet UTXOs - the only on-chain funds counted. + pub onchain_confirmed_msat: i64, + pub onchain_unconfirmed_msat: i64, + pub onchain_reserved_msat: i64, + pub onchain_immature_msat: i64, + + /// Our side of channels in CHANNELD_NORMAL - the only Lightning funds counted. + pub lightning_open_msat: i64, + pub lightning_pending_msat: i64, +} + +impl BalanceStatementInputs { + pub fn total_msat(&self) -> i64 { + self.onchain_confirmed_msat + self.lightning_open_msat + } +} + +/// Builds the exact block of text that gets signed - the content between the +/// BEGIN/END CLN SIGNED MESSAGE armor, not including the armor itself. +pub fn build_message_block(service_name: &str, inputs: &BalanceStatementInputs) -> String { + let lines = vec![ + format!("{service_name} node balance statement"), + String::new(), + format!("Statement version: {STATEMENT_VERSION}"), + format!("Node ID: {}", inputs.node_id), + format!("Network: {}", inputs.network), + format!( + "Statement time: {}", + inputs.statement_time.format("%Y-%m-%dT%H:%M:%SZ") + ), + format!("Block height: {}", inputs.block_height), + String::new(), + format!( + "On-chain balance: {} msats (confirmed, spendable by this node)", + inputs.onchain_confirmed_msat + ), + format!( + "Lightning balance: {} msats (this node's side of open channels)", + inputs.lightning_open_msat + ), + format!("Total under control: {} msats", inputs.total_msat()), + String::new(), + // Reserves the slot for a CSV hash later, so adding one is a value + // change on this line, not a format change. + "Accounting data: none".to_string(), + String::new(), + "Excluded from totals:".to_string(), + format!( + " - {:<36}{} msats", + "Unconfirmed on-chain:", inputs.onchain_unconfirmed_msat + ), + format!( + " - {:<36}{} msats", + "Reserved on-chain UTXOs:", inputs.onchain_reserved_msat + ), + format!( + " - {:<36}{} msats", + "Immature on-chain, e.g. coinbase:", inputs.onchain_immature_msat + ), + format!( + " - {:<36}{} msats", + "Pending channels, opening/closing:", inputs.lightning_pending_msat + ), + ]; + + lines.join("\n") +} + +/// Wraps a signed message block with the armor the verifier is told to expect. +/// Only `message_block` was passed to `signmessage` - the armor is added +/// after the fact and is never itself part of the signed content. +pub fn build_signed_statement(message_block: &str, zbase_signature: &str) -> String { + format!( + "{BEGIN_MESSAGE}\n{message_block}\n{BEGIN_SIGNATURE}\n{zbase_signature}\n{END_SIGNATURE}\n" + ) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn base_inputs() -> BalanceStatementInputs { + BalanceStatementInputs { + node_id: "02aabb".to_string(), + network: "bitcoin".to_string(), + statement_time: DateTime::parse_from_rfc3339("2026-09-17T12:00:00Z") + .unwrap() + .with_timezone(&Utc), + block_height: 900_000, + onchain_confirmed_msat: 100_000_000, + onchain_unconfirmed_msat: 0, + onchain_reserved_msat: 0, + onchain_immature_msat: 0, + lightning_open_msat: 50_000_000, + lightning_pending_msat: 0, + } + } + + #[test] + fn service_name_selects_heading() { + let inputs = base_inputs(); + assert!( + build_message_block("Example Service", &inputs) + .starts_with("Example Service node balance statement") + ); + } + + #[test] + fn total_sums_only_confirmed_and_open() { + let mut inputs = base_inputs(); + inputs.onchain_unconfirmed_msat = 1_000; + inputs.onchain_reserved_msat = 2_000; + inputs.onchain_immature_msat = 3_000; + inputs.lightning_pending_msat = 4_000; + + assert_eq!(inputs.total_msat(), 150_000_000); + } + + #[test] + fn message_block_contains_required_fields_and_zeroed_exclusions_when_clean() { + let inputs = base_inputs(); + let msg = build_message_block("Core Lightning", &inputs); + + assert!(msg.starts_with("Core Lightning node balance statement")); + assert!(msg.contains("Statement version: 1")); + assert!(msg.contains("Node ID: 02aabb")); + assert!(msg.contains("Network: bitcoin")); + assert!(msg.contains("Statement time: 2026-09-17T12:00:00Z")); + assert!(msg.contains("Block height: 900000")); + assert!(msg.contains("On-chain balance: 100000000 msats")); + assert!(msg.contains("Lightning balance: 50000000 msats")); + assert!(msg.contains("Total under control: 150000000 msats")); + assert!(msg.contains("Accounting data: none")); + // The exclusions section is always present, with zeros - "missing" + // and "zero" must never be confused. + assert!(msg.contains("Excluded from totals:")); + assert!(msg.contains("Unconfirmed on-chain:")); + assert!(msg.contains("Reserved on-chain UTXOs:")); + assert!(msg.contains("Immature on-chain, e.g. coinbase:")); + assert!(msg.contains("Pending channels, opening/closing:")); + assert!(!msg.contains(BEGIN_MESSAGE)); + } + + #[test] + fn message_block_reports_caveats_on_separate_lines_excluded_from_total() { + let mut inputs = base_inputs(); + inputs.onchain_unconfirmed_msat = 1_000; + inputs.onchain_reserved_msat = 2_000; + inputs.onchain_immature_msat = 3_000; + inputs.lightning_pending_msat = 4_000; + + let msg = build_message_block("Core Lightning", &inputs); + + assert!(msg.contains("Excluded from totals:")); + assert!(msg.contains("Unconfirmed on-chain:")); + assert!(msg.contains("1000 msats")); + assert!(msg.contains("Reserved on-chain UTXOs:")); + assert!(msg.contains("2000 msats")); + assert!(msg.contains("Immature on-chain, e.g. coinbase:")); + assert!(msg.contains("3000 msats")); + assert!(msg.contains("Pending channels, opening/closing:")); + assert!(msg.contains("4000 msats")); + // Caveated amounts must never appear in the totalled lines. + assert!(msg.contains("On-chain balance: 100000000 msats")); + assert!(msg.contains("Lightning balance: 50000000 msats")); + assert!(msg.contains("Total under control: 150000000 msats")); + } + + #[test] + fn signed_statement_wraps_exact_message_with_armor() { + let inputs = base_inputs(); + let msg = build_message_block("Core Lightning", &inputs); + let signed = build_signed_statement(&msg, "ZBASE32SIGNATURE"); + + let expected = format!( + "{BEGIN_MESSAGE}\n{msg}\n{BEGIN_SIGNATURE}\nZBASE32SIGNATURE\n{END_SIGNATURE}\n" + ); + assert_eq!(signed, expected); + + // The signature block must wrap the message verbatim - the verifier strips + // only the outer armor before calling checkmessage. + let inner = signed + .strip_prefix(BEGIN_MESSAGE) + .unwrap() + .trim_start_matches('\n'); + assert!(inner.starts_with(&msg)); + } +} diff --git a/reserves-attestor-plugin/tests/test_plugin.py b/reserves-attestor-plugin/tests/test_plugin.py new file mode 100644 index 0000000..72949e3 --- /dev/null +++ b/reserves-attestor-plugin/tests/test_plugin.py @@ -0,0 +1,76 @@ +from pathlib import Path + +from pyln.testing.fixtures import * # noqa: F403,F401 + + +PLUGIN_PATH = ( + Path(__file__).parents[2] / "target" / "release" / "reserves-attestor-plugin" +) +BEGIN_MESSAGE = "-----BEGIN CLN SIGNED MESSAGE-----\n" +BEGIN_SIGNATURE = "\n-----BEGIN CLN SIGNATURE-----\n" +END_SIGNATURE = "\n-----END CLN SIGNATURE-----\n" + + +def start_node(node_factory, **options): + assert PLUGIN_PATH.exists(), f"Build the plugin first: {PLUGIN_PATH}" + return node_factory.get_node( + options={"plugin": str(PLUGIN_PATH), **options}, + start=True, + ) + + +def verify_statement(node, response): + statement = response["statement"] + assert statement.startswith(BEGIN_MESSAGE) + + message, signature_block = statement.removeprefix(BEGIN_MESSAGE).split( + BEGIN_SIGNATURE, 1 + ) + signature, trailing = signature_block.split(END_SIGNATURE, 1) + + assert not trailing + assert signature == response["signature"] + + result = node.rpc.call( + method="checkmessage", + payload={ + "message": message, + "zbase": signature, + "pubkey": response["node_id"], + }, + ) + assert result["verified"] + return message + + +def test_get_signed_balance(node_factory): # noqa: F811 + node = start_node(node_factory) + response = node.rpc.call("get-signed-balance") + + print(response["statement"]) + + assert response["statement_version"] == 1 + assert response["node_id"] == node.info["id"] + assert response["network"] == node.info["network"] + assert response["block_height"] == node.info["blockheight"] + assert response["total_msat"] == ( + response["onchain_balance_msat"] + response["lightning_balance_msat"] + ) + assert "block_hash" not in response + + message = verify_statement(node, response) + assert message.startswith("Core Lightning node balance statement\n") + assert f"Node ID: {node.info['id']}" in message + assert f"Network: {node.info['network']}" in message + assert f"Block height: {node.info['blockheight']}" in message + + +def test_custom_service_name(node_factory): # noqa: F811 + node = start_node( + node_factory, + **{"reserves-attestor-service-name": "Example Service"}, + ) + response = node.rpc.call("get-signed-balance") + + message = verify_statement(node, response) + assert message.startswith("Example Service node balance statement\n")