diff --git a/Cargo.lock b/Cargo.lock index 505fee8cf..77a4ea3d2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1553,6 +1553,7 @@ dependencies = [ "pin-project", "prost", "prost-derive", + "psbt-v2", "rand", "rcgen", "reqwest", @@ -2456,6 +2457,16 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" +[[package]] +name = "miniscript" +version = "12.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8343cc1ef1408bd9bdbf69f7aef47017dfab7e6349ec26fddf62e0e9fb5a4cf" +dependencies = [ + "bech32 0.11.1", + "bitcoin 0.32.9", +] + [[package]] name = "miniz_oxide" version = "0.8.9" @@ -3192,6 +3203,16 @@ dependencies = [ "prost", ] +[[package]] +name = "psbt-v2" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e075616cae99e29d70960a6374c2757dff4dd6b646dd0398ce1012666231c7aa" +dependencies = [ + "bitcoin 0.32.9", + "miniscript", +] + [[package]] name = "pyo3" version = "0.18.3" diff --git a/libs/gl-cli/Cargo.toml b/libs/gl-cli/Cargo.toml index 6c2e85bd5..df9c2ff9f 100644 --- a/libs/gl-cli/Cargo.toml +++ b/libs/gl-cli/Cargo.toml @@ -12,6 +12,9 @@ categories = ["command-line-utilities", "cryptography::cryptocurrencies"] license = "MIT" readme = "README.md" +[features] +experimental-splicing = ["gl-client/experimental-splicing"] + [[bin]] name = "glcli" test = true diff --git a/libs/gl-client-py/Cargo.toml b/libs/gl-client-py/Cargo.toml index 38d94e1ba..11776bb44 100644 --- a/libs/gl-client-py/Cargo.toml +++ b/libs/gl-client-py/Cargo.toml @@ -36,3 +36,4 @@ thiserror = "1" [features] default = ["permissive"] permissive = ["gl-client/permissive"] +experimental-splicing = ["gl-client/experimental-splicing"] diff --git a/libs/gl-client-py/glclient/__init__.py b/libs/gl-client-py/glclient/__init__.py index abc552695..a3b72eefc 100644 --- a/libs/gl-client-py/glclient/__init__.py +++ b/libs/gl-client-py/glclient/__init__.py @@ -141,6 +141,9 @@ def __init__(self, node_id: bytes, grpc_uri: str, creds: Credentials) -> None: self.inner = native.Node(node_id=node_id, grpc_uri=grpc_uri, creds=creds) self.logger = logging.getLogger("glclient.Node") + def call(self, path: str, request: bytes) -> bytes: + return bytes(self.inner.call(path, bytes(request))) + def get_info(self) -> clnpb.GetinfoResponse: uri = "/cln.Node/Getinfo" req = clnpb.GetinfoRequest().SerializeToString() @@ -279,16 +282,11 @@ def decode(self, string: str) -> clnpb.DecodeResponse: return res.FromString(bytes(self.inner.call(uri, bytes(req)))) def decodepay( - self, bolt11: str, description: Optional[str] - ) -> clnpb.DecodepayResponse: - uri = "/cln.Node/DecodePay" - res = clnpb.DecodepayResponse - req = clnpb.DecodepayRequest( - bolt11=bolt11, - description=description, - ).SerializeToString() - - return res.FromString(bytes(self.inner.call(uri, bytes(req)))) + self, bolt11: str, description: Optional[str] = None + ) -> clnpb.DecodeResponse: + if description is not None: + raise ValueError("CLN's Decode RPC does not accept a description") + return self.decode(bolt11) def disconnect_peer(self, peer_id: str, force=False) -> clnpb.DisconnectResponse: uri = "/cln.Node/Disconnect" diff --git a/libs/gl-client/Cargo.toml b/libs/gl-client/Cargo.toml index b8eb0bf80..ecfd2a876 100644 --- a/libs/gl-client/Cargo.toml +++ b/libs/gl-client/Cargo.toml @@ -15,6 +15,7 @@ default = ["permissive", "export"] permissive = [] export = ["chacha20poly1305", "secp256k1"] backup = [] +experimental-splicing = [] [dependencies] aes = "0.8" @@ -35,6 +36,7 @@ picky-asn1-der = "0.4" pin-project = "1.1.5" prost = "0.12" prost-derive = "0.12" +psbt-v2 = { version = "0.3.0", default-features = false, features = ["std"] } # `rustls-tls-webpki-roots` compiles Mozilla's CA bundle into the # binary. Identical TLS behaviour on every platform — Android, iOS, # desktop — with no runtime OS root-store discovery. We previously diff --git a/libs/gl-client/src/lib.rs b/libs/gl-client/src/lib.rs index 80508bed8..e77879f06 100644 --- a/libs/gl-client/src/lib.rs +++ b/libs/gl-client/src/lib.rs @@ -28,8 +28,9 @@ pub mod scheduler; /// move your funds. pub mod signer; -pub mod persist; pub mod metrics; +pub mod persist; +pub mod psbt; pub mod lnurl; diff --git a/libs/gl-client/src/persist.rs b/libs/gl-client/src/persist.rs index e22ecf66e..1d4510e06 100644 --- a/libs/gl-client/src/persist.rs +++ b/libs/gl-client/src/persist.rs @@ -1,4 +1,15 @@ mod canonical; +mod splice; + +pub use splice::{ + candidate_funding_facts_from_psbt, parse_base64_psbt, wallet_inputs_from_psbt, FeePolicy, + FundPsbtResponseFacts, FundingOutpoint, LocalSpliceIntent, OldSpliceState, PsbtCaptureFacts, + SignPsbtIntentFacts, SpliceSignedResponseFacts, SpliceUpdateResponseFacts, + WalletInputReservation, +}; +#[cfg(test)] +pub(crate) use splice::{CandidateFundingFacts, WalletInput}; +pub(crate) use splice::{SpliceOrigin, SplicePhase, SpliceSessionV1}; use anyhow::anyhow; use lightning_signer::bitcoin::secp256k1::PublicKey; @@ -1092,10 +1103,13 @@ mod tests { #[test] fn state_entry_canonical_value_bytes_sorts_nested_object_keys() { - let entry = StateEntry::new(0, json!({ + let entry = StateEntry::new( + 0, + json!({ "z": {"b": 1, "a": 2}, "a": [{"d": 4, "c": 3}] - })); + }), + ); let bytes = entry.canonical_value_bytes().unwrap(); diff --git a/libs/gl-client/src/persist/splice.rs b/libs/gl-client/src/persist/splice.rs new file mode 100644 index 000000000..e91504950 --- /dev/null +++ b/libs/gl-client/src/persist/splice.rs @@ -0,0 +1,1515 @@ +use super::{State, StateEntry, CHANNEL_PREFIX}; +use crate::bitcoin::{OutPoint, Txid}; +use crate::psbt::ParsedPsbt; +use anyhow::{anyhow, bail}; +use serde::de::DeserializeOwned; +use serde::{Deserialize, Serialize}; +use std::str::FromStr; + +const SPLICE_SESSION_PREFIX: &str = "splices"; +const SPLICE_OUTPOINT_PREFIX: &str = "splice_outpoints"; +const SPLICE_WALLET_PSBT_PREFIX: &str = "splice_wallet_psbts"; + +fn splice_session_key(node_channel_id_hex: &str) -> String { + format!("{SPLICE_SESSION_PREFIX}/{node_channel_id_hex}") +} + +fn splice_outpoint_key(txid: &str, vout: u32) -> String { + format!("{SPLICE_OUTPOINT_PREFIX}/{txid}:{vout}") +} + +fn wallet_psbt_key(psbt_fingerprint: &str) -> String { + format!("{SPLICE_WALLET_PSBT_PREFIX}/{psbt_fingerprint}") +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum SpliceOrigin { + LocalInitiator, + PeerInitiated, + DevSpliceUnresolved, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum SplicePhase { + Negotiating, + CommitmentsSecured, + SignaturesExchanging, + PendingLock, + Locked, + Aborted, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum DeltaSource { + Vls, + Cln, + Unresolved, +} + +impl Default for DeltaSource { + fn default() -> Self { + Self::Unresolved + } +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum SpliceTerminalReason { + Locked, + Aborted, + ChannelDeleted, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct FundingOutpoint { + pub txid: String, + pub vout: u32, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct OldSpliceState { + pub funding_outpoint: FundingOutpoint, + pub channel_value_sat: u64, + pub local_balance_sat: u64, +} + +#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +pub struct FeePolicy { + pub feerate_per_kw: Option, + pub force_feerate: Option, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct SpliceIntentState { + pub authorized_relative_amount_sat: Option, + pub fee_policy: FeePolicy, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct LocalSpliceIntent { + pub node_id_hex: String, + pub channel_id_hex: String, + pub node_channel_id_hex: String, + pub old: OldSpliceState, + pub authorized_relative_amount_sat: i64, + pub fee_policy: FeePolicy, + pub initial_psbt_fingerprint: String, + pub initial_psbt_input_outpoints: Vec, + pub timestamp_ms: u64, +} + +#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +pub struct SplicePsbtState { + pub candidate_fingerprint: Option, + pub frozen_fingerprint: Option, +} + +#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +pub struct SpliceCandidateState { + pub funding_outpoint: Option, + pub value_sat: Option, + pub script_pubkey_hash: Option, + pub sign_splice_tx_input_index: Option, + pub remote_funding_key_hex: Option, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct CandidateFundingFacts { + pub funding_outpoint: FundingOutpoint, + pub value_sat: u64, + pub script_pubkey_hash: String, + pub sign_splice_tx_input_index: u32, + pub remote_funding_key_hex: Option, +} + +impl From for SpliceCandidateState { + fn from(value: CandidateFundingFacts) -> Self { + Self { + funding_outpoint: Some(value.funding_outpoint), + value_sat: Some(value.value_sat), + script_pubkey_hash: Some(value.script_pubkey_hash), + sign_splice_tx_input_index: Some(value.sign_splice_tx_input_index), + remote_funding_key_hex: value.remote_funding_key_hex, + } + } +} + +#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +pub struct SpliceDeltaState { + pub computed: bool, + pub channel_delta_sat: i64, + pub wallet_input_delta_sat: i64, + pub wallet_output_delta_sat: i64, + pub fee_burden_sat: i64, + pub no_local_loss: bool, + pub source: DeltaSource, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct SignerRequestRecord { + pub request_type: String, + pub request_hash: String, + pub phase: SplicePhase, + pub timestamp_ms: u64, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +// TODO: Reconsider these fields once the VLS splice integration shape is settled. +// Keep observed Greenlight request facts here, and move authenticated intent and protocol +// state to the signer/VLS integration boundary. +pub struct SpliceSessionV1 { + pub schema: String, + pub schema_version: u16, + pub origin: SpliceOrigin, + pub phase: SplicePhase, + pub node_id_hex: String, + pub channel_id_hex: String, + pub node_channel_id_hex: String, + pub old: OldSpliceState, + pub intent: SpliceIntentState, + pub psbt: SplicePsbtState, + pub cand: SpliceCandidateState, + pub delta: SpliceDeltaState, + pub linked_wallet_psbt_fingerprints: Vec, + pub signer_request_history: Vec, + pub created_at_ms: u64, + pub updated_at_ms: u64, + pub terminal_reason: Option, +} + +impl SpliceSessionV1 { + pub fn new( + origin: SpliceOrigin, + node_id_hex: String, + channel_id_hex: String, + node_channel_id_hex: String, + old: OldSpliceState, + authorized_relative_amount_sat: Option, + fee_policy: FeePolicy, + timestamp_ms: u64, + ) -> Self { + Self { + schema: "SpliceSessionV1".to_string(), + schema_version: 1, + origin, + phase: SplicePhase::Negotiating, + node_id_hex, + channel_id_hex, + node_channel_id_hex, + old, + intent: SpliceIntentState { + authorized_relative_amount_sat, + fee_policy, + }, + psbt: SplicePsbtState::default(), + cand: SpliceCandidateState::default(), + delta: SpliceDeltaState::default(), + linked_wallet_psbt_fingerprints: Vec::new(), + signer_request_history: Vec::new(), + created_at_ms: timestamp_ms, + updated_at_ms: timestamp_ms, + terminal_reason: None, + } + } +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct SpliceOutpointIndexV1 { + pub schema: String, + pub schema_version: u16, + pub splice_session_key: String, +} + +impl SpliceOutpointIndexV1 { + fn for_session(node_channel_id_hex: &str) -> Self { + Self { + schema: "SpliceOutpointIndexV1".to_string(), + schema_version: 1, + splice_session_key: splice_session_key(node_channel_id_hex), + } + } +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct WalletInput { + pub txid: String, + pub vout: u32, + pub value_sat: u64, + pub reserved_to_block: Option, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +// TODO: Reconsider which wallet-PSBT fields must remain durable once splice intent is +// supplied through the VLS approver interface. +pub struct SpliceWalletPsbtContextV1 { + pub schema: String, + pub schema_version: u16, + pub signonly: Vec, + pub wallet_inputs: Vec, + pub linked_node_channel_id_hex: Option, + pub created_at_ms: u64, + pub updated_at_ms: u64, +} + +impl SpliceWalletPsbtContextV1 { + pub fn new(wallet_inputs: Vec, timestamp_ms: u64) -> Self { + Self { + schema: "SpliceWalletPsbtContextV1".to_string(), + schema_version: 1, + signonly: Vec::new(), + wallet_inputs, + linked_node_channel_id_hex: None, + created_at_ms: timestamp_ms, + updated_at_ms: timestamp_ms, + } + } +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct WalletInputReservation { + pub txid: String, + pub vout: u32, + pub reserved_to_block: Option, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct FundPsbtResponseFacts { + pub psbt_fingerprint: String, + pub wallet_inputs: Vec, + pub timestamp_ms: u64, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct SignPsbtIntentFacts { + pub psbt_fingerprint: String, + pub signonly: Vec, + pub timestamp_ms: u64, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct SpliceUpdateResponseFacts { + pub node_channel_id_hex: String, + pub psbt_fingerprint: String, + pub psbt_input_outpoints: Vec, + pub commitments_secured: bool, + pub signatures_secured: Option, + pub timestamp_ms: u64, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct SpliceSignedResponseFacts { + pub node_channel_id_hex: String, + pub psbt_fingerprint: String, + pub psbt_input_outpoints: Vec, + pub candidate: Option, + pub timestamp_ms: u64, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct PsbtCaptureFacts { + pub fingerprint: String, + pub input_outpoints: Vec, +} + +pub fn parse_base64_psbt(psbt: &str) -> anyhow::Result { + let psbt = ParsedPsbt::from_base64(psbt)?; + Ok(PsbtCaptureFacts { + fingerprint: psbt.fingerprint().to_string(), + input_outpoints: psbt + .input_outpoints() + .into_iter() + .map(|outpoint| FundingOutpoint { + txid: outpoint.txid.to_string(), + vout: outpoint.vout, + }) + .collect(), + }) +} + +pub fn wallet_inputs_from_psbt( + psbt: &str, + reservations: &[WalletInputReservation], +) -> anyhow::Result> { + let psbt = ParsedPsbt::from_base64(psbt)?; + let funding_utxos = psbt.funding_utxos()?; + psbt.input_outpoints() + .into_iter() + .zip(funding_utxos) + .map(|(outpoint, funding_utxo)| { + let txid = outpoint.txid.to_string(); + let vout = outpoint.vout; + let reservation = reservations + .iter() + .find(|reservation| reservation.txid == txid && reservation.vout == vout); + Ok(WalletInput { + txid, + vout, + value_sat: funding_utxo.value.to_sat(), + reserved_to_block: reservation + .and_then(|reservation| reservation.reserved_to_block), + }) + }) + .collect() +} + +pub fn candidate_funding_facts_from_psbt( + psbt: &str, + funding_txid: &str, + funding_vout: u32, + old_funding_outpoint: &FundingOutpoint, +) -> anyhow::Result { + let psbt = ParsedPsbt::from_base64(psbt)?; + let funding_txid = + Txid::from_str(funding_txid).map_err(|e| anyhow!("invalid splice funding txid: {e}"))?; + let old_funding_outpoint = OutPoint::new( + Txid::from_str(&old_funding_outpoint.txid) + .map_err(|e| anyhow!("invalid old funding txid: {e}"))?, + old_funding_outpoint.vout, + ); + let candidate = psbt.candidate_funding(funding_txid, funding_vout, old_funding_outpoint)?; + + Ok(CandidateFundingFacts { + funding_outpoint: FundingOutpoint { + txid: candidate.outpoint.txid.to_string(), + vout: candidate.outpoint.vout, + }, + value_sat: candidate.txout.value.to_sat(), + script_pubkey_hash: sha256::digest(candidate.txout.script_pubkey.as_bytes()), + sign_splice_tx_input_index: candidate.sign_splice_tx_input_index, + remote_funding_key_hex: None, + }) +} + +impl State { + pub fn node_channel_id_for_funding_outpoint( + &self, + node_id_hex: &str, + funding_outpoint: &FundingOutpoint, + ) -> anyhow::Result> { + let node_id = hex::decode(node_id_hex) + .map_err(|e| anyhow!("invalid node id hex for channel lookup: {e}"))?; + if node_id.len() != 33 { + bail!( + "invalid node id length for channel lookup: expected 33 bytes, got {}", + node_id.len() + ); + } + + let key_prefix = format!("{CHANNEL_PREFIX}/{node_id_hex}"); + let mut matches = Vec::new(); + for (key, entry) in self.values.iter() { + if !key.starts_with(&key_prefix) || self.is_tombstone(key) { + continue; + } + let channel: vls_persist::model::ChannelEntry = + serde_json::from_value(entry.value.clone()).map_err(|e| { + anyhow!("failed to decode channel state value for key {key}: {e}") + })?; + let Some(setup) = channel.channel_setup else { + continue; + }; + if setup.funding_outpoint.txid.to_string() == funding_outpoint.txid + && setup.funding_outpoint.vout == funding_outpoint.vout + { + matches.push( + key.strip_prefix(&format!("{CHANNEL_PREFIX}/")) + .expect("channel key prefix checked") + .to_string(), + ); + } + } + + match matches.as_slice() { + [] => Ok(None), + [node_channel_id_hex] => Ok(Some(node_channel_id_hex.clone())), + _ => bail!( + "multiple channels match funding outpoint {}:{}", + funding_outpoint.txid, + funding_outpoint.vout + ), + } + } + + fn get_splice(&self, key: &str) -> anyhow::Result> + where + T: DeserializeOwned, + { + if self.is_tombstone(key) { + return Ok(None); + } + let Some(entry) = self.values.get(key) else { + return Ok(None); + }; + serde_json::from_value(entry.value.clone()) + .map(Some) + .map_err(|e| anyhow!("failed to decode splice state value for key {}: {}", key, e)) + } + + fn put_splice(&mut self, key: &str, value: &T) -> anyhow::Result<()> + where + T: Serialize, + { + if self.is_tombstone(key) { + anyhow::bail!("key {} has been deleted", key); + } + let value = serde_json::to_value(value) + .map_err(|e| anyhow!("failed to encode splice state value for key {}: {}", key, e))?; + let version = self.next_version(key); + self.values + .insert(key.to_owned(), StateEntry::new(version, value)); + Ok(()) + } + + pub fn get_splice_session( + &self, + node_channel_id_hex: &str, + ) -> anyhow::Result> { + self.get_splice(&splice_session_key(node_channel_id_hex)) + } + + fn put_splice_session(&mut self, session: &SpliceSessionV1) -> anyhow::Result<()> { + self.put_splice(&splice_session_key(&session.node_channel_id_hex), session) + } + + fn link_splice_psbt_context( + &mut self, + session: &mut SpliceSessionV1, + psbt_fingerprint: &str, + psbt_input_outpoints: &[FundingOutpoint], + updated_at_ms: u64, + ) -> anyhow::Result<()> { + let source_fingerprints = session.linked_wallet_psbt_fingerprints.clone(); + let mut context = self + .get_psbt_context(psbt_fingerprint)? + .unwrap_or_else(|| SpliceWalletPsbtContextV1::new(Vec::new(), updated_at_ms)); + if let Some(linked_channel) = context.linked_node_channel_id_hex.as_deref() { + if linked_channel != session.node_channel_id_hex { + bail!( + "PSBT {} is already linked to splice channel {}", + psbt_fingerprint, + linked_channel + ); + } + } + + context.linked_node_channel_id_hex = Some(session.node_channel_id_hex.clone()); + context.updated_at_ms = updated_at_ms; + if !session + .linked_wallet_psbt_fingerprints + .iter() + .any(|fingerprint| fingerprint == psbt_fingerprint) + { + session + .linked_wallet_psbt_fingerprints + .push(psbt_fingerprint.to_string()); + } + self.put_splice_wallet_psbt_context(psbt_fingerprint, context)?; + for source_fingerprint in source_fingerprints { + self.inherit_splice_wallet_context( + &source_fingerprint, + psbt_fingerprint, + psbt_input_outpoints, + &session.node_channel_id_hex, + updated_at_ms, + )?; + } + Ok(()) + } + + fn create_new_splice_session( + &mut self, + session: SpliceSessionV1, + expected_origin: SpliceOrigin, + ) -> anyhow::Result<()> { + if session.origin != expected_origin { + bail!( + "splice session origin {:?} does not match expected {:?}", + session.origin, + expected_origin + ); + } + if session.phase != SplicePhase::Negotiating { + bail!("new splice sessions must start in negotiating phase"); + } + if self + .get_splice_session(&session.node_channel_id_hex)? + .is_some() + { + bail!( + "live splice session already exists for channel {}", + session.node_channel_id_hex + ); + } + self.put_splice_session(&session) + } + + pub fn create_local_splice_session(&mut self, session: SpliceSessionV1) -> anyhow::Result<()> { + if session.intent.authorized_relative_amount_sat.is_none() { + bail!("local splice sessions require authorized relative amount"); + } + self.create_new_splice_session(session, SpliceOrigin::LocalInitiator) + } + + pub fn record_local_splice_intent(&mut self, intent: LocalSpliceIntent) -> anyhow::Result<()> { + let psbt_fingerprint = intent.initial_psbt_fingerprint.clone(); + let psbt_input_outpoints = intent.initial_psbt_input_outpoints.clone(); + + if let Some(mut session) = self.get_splice_session(&intent.node_channel_id_hex)? { + if session.origin != SpliceOrigin::LocalInitiator { + bail!( + "cannot replace {:?} splice session with local intent", + session.origin + ); + } + if session.phase != SplicePhase::Negotiating { + bail!( + "splice_init intent can only update negotiating session, current phase {:?}", + session.phase + ); + } + session.node_id_hex = intent.node_id_hex; + session.channel_id_hex = intent.channel_id_hex; + session.old = intent.old; + session.intent.authorized_relative_amount_sat = + Some(intent.authorized_relative_amount_sat); + session.intent.fee_policy = intent.fee_policy; + session.psbt.candidate_fingerprint = Some(psbt_fingerprint.clone()); + session.updated_at_ms = intent.timestamp_ms; + self.link_splice_psbt_context( + &mut session, + &psbt_fingerprint, + &psbt_input_outpoints, + intent.timestamp_ms, + )?; + return self.put_splice_session(&session); + } + + let mut session = SpliceSessionV1::new( + SpliceOrigin::LocalInitiator, + intent.node_id_hex, + intent.channel_id_hex, + intent.node_channel_id_hex, + intent.old, + Some(intent.authorized_relative_amount_sat), + intent.fee_policy, + intent.timestamp_ms, + ); + session.psbt.candidate_fingerprint = Some(psbt_fingerprint.clone()); + self.link_splice_psbt_context( + &mut session, + &psbt_fingerprint, + &psbt_input_outpoints, + intent.timestamp_ms, + )?; + self.create_local_splice_session(session) + } + + pub fn record_fundpsbt_response(&mut self, facts: FundPsbtResponseFacts) -> anyhow::Result<()> { + let existing = self.get_psbt_context(&facts.psbt_fingerprint)?; + let mut context = existing + .unwrap_or_else(|| SpliceWalletPsbtContextV1::new(Vec::new(), facts.timestamp_ms)); + context.wallet_inputs = facts.wallet_inputs; + context.updated_at_ms = facts.timestamp_ms; + self.put_splice_wallet_psbt_context(&facts.psbt_fingerprint, context) + } + + pub fn record_signpsbt_intent(&mut self, facts: SignPsbtIntentFacts) -> anyhow::Result<()> { + let existing = self.get_psbt_context(&facts.psbt_fingerprint)?; + let mut context = existing + .unwrap_or_else(|| SpliceWalletPsbtContextV1::new(Vec::new(), facts.timestamp_ms)); + context.signonly = facts.signonly; + context.updated_at_ms = facts.timestamp_ms; + self.put_splice_wallet_psbt_context(&facts.psbt_fingerprint, context) + } + + pub fn record_splice_update_response( + &mut self, + facts: SpliceUpdateResponseFacts, + ) -> anyhow::Result<()> { + let psbt_fingerprint = facts.psbt_fingerprint.clone(); + let psbt_input_outpoints = facts.psbt_input_outpoints.clone(); + let mut session = self + .get_splice_session(&facts.node_channel_id_hex)? + .ok_or_else(|| { + anyhow!( + "missing splice session for channel {}", + facts.node_channel_id_hex + ) + })?; + + if matches!(session.phase, SplicePhase::Locked | SplicePhase::Aborted) { + bail!( + "splice_update cannot update terminal splice phase {:?}", + session.phase + ); + } + + if facts.commitments_secured { + session.psbt.frozen_fingerprint = Some(facts.psbt_fingerprint.clone()); + session.psbt.candidate_fingerprint = Some(facts.psbt_fingerprint); + session.phase = if facts.signatures_secured == Some(true) { + SplicePhase::SignaturesExchanging + } else { + SplicePhase::CommitmentsSecured + }; + } else { + if session.phase != SplicePhase::Negotiating { + bail!( + "candidate PSBT can only change while negotiating, current phase {:?}", + session.phase + ); + } + session.psbt.candidate_fingerprint = Some(facts.psbt_fingerprint); + } + + session.updated_at_ms = facts.timestamp_ms; + self.link_splice_psbt_context( + &mut session, + &psbt_fingerprint, + &psbt_input_outpoints, + facts.timestamp_ms, + )?; + self.put_splice_session(&session) + } + + pub fn record_splice_signed_response( + &mut self, + facts: SpliceSignedResponseFacts, + ) -> anyhow::Result<()> { + let psbt_fingerprint = facts.psbt_fingerprint.clone(); + let psbt_input_outpoints = facts.psbt_input_outpoints.clone(); + let mut session = self + .get_splice_session(&facts.node_channel_id_hex)? + .ok_or_else(|| { + anyhow!( + "missing splice session for channel {}", + facts.node_channel_id_hex + ) + })?; + if !matches!( + session.phase, + SplicePhase::CommitmentsSecured + | SplicePhase::SignaturesExchanging + | SplicePhase::PendingLock + ) { + bail!( + "splice_signed response requires commitments secured, current phase {:?}", + session.phase + ); + } + + session.psbt.candidate_fingerprint = Some(facts.psbt_fingerprint.clone()); + if session.psbt.frozen_fingerprint.is_none() { + session.psbt.frozen_fingerprint = Some(facts.psbt_fingerprint); + } + session.phase = SplicePhase::SignaturesExchanging; + session.updated_at_ms = facts.timestamp_ms; + + self.link_splice_psbt_context( + &mut session, + &psbt_fingerprint, + &psbt_input_outpoints, + facts.timestamp_ms, + )?; + + if let Some(candidate) = facts.candidate { + let index = SpliceOutpointIndexV1::for_session(&facts.node_channel_id_hex); + let candidate_outpoint = candidate.funding_outpoint.clone(); + session.phase = SplicePhase::PendingLock; + session.cand = candidate.into(); + self.put_splice_session(&session)?; + return self.put_splice_outpoint_index(&candidate_outpoint, &index); + } + + self.put_splice_session(&session) + } + + pub fn create_peer_splice_session(&mut self, session: SpliceSessionV1) -> anyhow::Result<()> { + if session.intent.authorized_relative_amount_sat.is_some() { + bail!("peer-initiated splice sessions must not include local relative amount intent"); + } + self.create_new_splice_session(session, SpliceOrigin::PeerInitiated) + } + + pub fn create_dev_splice_session(&mut self, session: SpliceSessionV1) -> anyhow::Result<()> { + self.create_new_splice_session(session, SpliceOrigin::DevSpliceUnresolved) + } + + pub fn update_splice_candidate( + &mut self, + node_channel_id_hex: &str, + psbt_fingerprint: String, + updated_at_ms: u64, + ) -> anyhow::Result<()> { + let mut session = self + .get_splice_session(node_channel_id_hex)? + .ok_or_else(|| anyhow!("missing splice session for channel {}", node_channel_id_hex))?; + if session.phase != SplicePhase::Negotiating { + bail!( + "candidate PSBT can only change while negotiating, current phase {:?}", + session.phase + ); + } + session.psbt.candidate_fingerprint = Some(psbt_fingerprint); + session.updated_at_ms = updated_at_ms; + self.put_splice_session(&session) + } + + pub fn freeze_splice_candidate( + &mut self, + node_channel_id_hex: &str, + frozen_psbt_fingerprint: String, + candidate: CandidateFundingFacts, + updated_at_ms: u64, + ) -> anyhow::Result<()> { + let mut session = self + .get_splice_session(node_channel_id_hex)? + .ok_or_else(|| anyhow!("missing splice session for channel {}", node_channel_id_hex))?; + if session.phase != SplicePhase::Negotiating { + bail!( + "candidate can only be frozen from negotiating phase, current phase {:?}", + session.phase + ); + } + let index = SpliceOutpointIndexV1::for_session(node_channel_id_hex); + let candidate_outpoint = candidate.funding_outpoint.clone(); + session.phase = SplicePhase::CommitmentsSecured; + session.psbt.frozen_fingerprint = Some(frozen_psbt_fingerprint); + session.cand = candidate.into(); + session.updated_at_ms = updated_at_ms; + self.put_splice_session(&session)?; + self.put_splice_outpoint_index(&candidate_outpoint, &index) + } + + pub fn mark_splice_pending_lock( + &mut self, + node_channel_id_hex: &str, + updated_at_ms: u64, + ) -> anyhow::Result<()> { + let mut session = self + .get_splice_session(node_channel_id_hex)? + .ok_or_else(|| anyhow!("missing splice session for channel {}", node_channel_id_hex))?; + if !matches!( + session.phase, + SplicePhase::CommitmentsSecured + | SplicePhase::SignaturesExchanging + | SplicePhase::PendingLock + ) { + bail!( + "pending lock requires secured commitments or signatures, current phase {:?}", + session.phase + ); + } + session.phase = SplicePhase::PendingLock; + session.updated_at_ms = updated_at_ms; + self.put_splice_session(&session) + } + + pub fn put_splice_outpoint_index( + &mut self, + candidate_outpoint: &FundingOutpoint, + index: &SpliceOutpointIndexV1, + ) -> anyhow::Result<()> { + self.put_splice( + &splice_outpoint_key(&candidate_outpoint.txid, candidate_outpoint.vout), + index, + ) + } + + pub fn get_splice_by_outpoint( + &self, + txid: &str, + vout: u32, + ) -> anyhow::Result> { + let Some(index): Option = + self.get_splice(&splice_outpoint_key(txid, vout))? + else { + return Ok(None); + }; + self.get_splice(&index.splice_session_key) + } + + pub fn put_splice_wallet_psbt_context( + &mut self, + psbt_fingerprint: &str, + context: SpliceWalletPsbtContextV1, + ) -> anyhow::Result<()> { + self.put_splice(&wallet_psbt_key(psbt_fingerprint), &context) + } + + pub fn get_psbt_context( + &self, + psbt_fingerprint: &str, + ) -> anyhow::Result> { + self.get_splice(&wallet_psbt_key(psbt_fingerprint)) + } + + pub fn link_splice_wallet_psbt( + &mut self, + psbt_fingerprint: &str, + node_channel_id_hex: &str, + updated_at_ms: u64, + ) -> anyhow::Result<()> { + let mut session = self + .get_splice_session(node_channel_id_hex)? + .ok_or_else(|| anyhow!("missing splice session for channel {}", node_channel_id_hex))?; + let mut context = self + .get_psbt_context(psbt_fingerprint)? + .ok_or_else(|| anyhow!("missing wallet PSBT context {}", psbt_fingerprint))?; + let fingerprint_matches_splice = session.psbt.candidate_fingerprint.as_deref() + == Some(psbt_fingerprint) + || session.psbt.frozen_fingerprint.as_deref() == Some(psbt_fingerprint); + if !fingerprint_matches_splice { + bail!( + "wallet PSBT {} does not match splice candidate for channel {}", + psbt_fingerprint, + node_channel_id_hex + ); + } + + context.linked_node_channel_id_hex = Some(node_channel_id_hex.to_string()); + context.updated_at_ms = updated_at_ms; + if !session + .linked_wallet_psbt_fingerprints + .iter() + .any(|fingerprint| fingerprint == psbt_fingerprint) + { + session + .linked_wallet_psbt_fingerprints + .push(psbt_fingerprint.to_string()); + } + session.updated_at_ms = updated_at_ms; + self.put_splice_wallet_psbt_context(psbt_fingerprint, context)?; + self.put_splice_session(&session) + } + + pub fn inherit_splice_wallet_context( + &mut self, + source_psbt_fingerprint: &str, + candidate_psbt_fingerprint: &str, + candidate_input_outpoints: &[FundingOutpoint], + node_channel_id_hex: &str, + updated_at_ms: u64, + ) -> anyhow::Result<()> { + if source_psbt_fingerprint == candidate_psbt_fingerprint { + return Ok(()); + } + let Some(source) = self.get_psbt_context(source_psbt_fingerprint)? else { + return Ok(()); + }; + let mut candidate = self + .get_psbt_context(candidate_psbt_fingerprint)? + .ok_or_else(|| { + anyhow!( + "missing splice candidate PSBT context {}", + candidate_psbt_fingerprint + ) + })?; + if candidate.linked_node_channel_id_hex.as_deref() != Some(node_channel_id_hex) { + bail!( + "splice candidate PSBT context {} is not linked to channel {}", + candidate_psbt_fingerprint, + node_channel_id_hex + ); + } + + for wallet_input in source.wallet_inputs { + let remains_in_candidate = candidate_input_outpoints.iter().any(|outpoint| { + outpoint.txid == wallet_input.txid && outpoint.vout == wallet_input.vout + }); + let already_known = candidate + .wallet_inputs + .iter() + .any(|input| input.txid == wallet_input.txid && input.vout == wallet_input.vout); + if remains_in_candidate && !already_known { + candidate.wallet_inputs.push(wallet_input); + } + } + candidate.updated_at_ms = updated_at_ms; + self.put_splice_wallet_psbt_context(candidate_psbt_fingerprint, candidate) + } + + pub fn tombstone_splice_session(&mut self, node_channel_id_hex: &str) -> anyhow::Result<()> { + let Some(session) = self.get_splice_session(node_channel_id_hex)? else { + return Ok(()); + }; + let mut keys = vec![splice_session_key(node_channel_id_hex)]; + if let Some(outpoint) = &session.cand.funding_outpoint { + keys.push(splice_outpoint_key(&outpoint.txid, outpoint.vout)); + } + keys.extend( + session + .linked_wallet_psbt_fingerprints + .iter() + .map(|fingerprint| wallet_psbt_key(fingerprint)), + ); + keys.sort(); + keys.dedup(); + + for key in keys { + self.put_tombstone(&key); + } + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::bitcoin::absolute::LockTime; + use crate::bitcoin::psbt::Psbt; + use crate::bitcoin::secp256k1::{PublicKey, Secp256k1, SecretKey}; + use crate::bitcoin::transaction::Version; + use crate::bitcoin::{ + Amount, OutPoint, ScriptBuf, Sequence, Transaction, TxIn, TxOut, Txid, Witness, + }; + use crate::lightning::ln::chan_utils::ChannelPublicKeys; + use crate::lightning::ln::channel_keys::{ + DelayedPaymentBasepoint, HtlcBasepoint, RevocationBasepoint, + }; + use crate::pb::SignerStateEntry; + use base64::{engine::general_purpose, Engine as _}; + use lightning_signer::channel::{ChannelSetup, CommitmentType}; + use lightning_signer::policy::validator::EnforcementState; + use serde_json::json; + use std::str::FromStr; + + use crate::psbt::CLN_PSBT_V2; + + fn outpoint(txid: &str, vout: u32) -> FundingOutpoint { + FundingOutpoint { + txid: txid.to_string(), + vout, + } + } + + fn channel_entry(txid: &str, vout: u32) -> vls_persist::model::ChannelEntry { + let secret = SecretKey::from_slice(&[1; 32]).unwrap(); + let pubkey = PublicKey::from_secret_key(&Secp256k1::signing_only(), &secret); + vls_persist::model::ChannelEntry { + channel_value_satoshis: 1_000_000, + channel_setup: Some(ChannelSetup { + is_outbound: true, + channel_value_sat: 1_000_000, + push_value_msat: 0, + funding_outpoint: OutPoint { + txid: Txid::from_str(txid).unwrap(), + vout, + }, + holder_selected_contest_delay: 6, + holder_shutdown_script: None, + counterparty_points: ChannelPublicKeys { + funding_pubkey: pubkey, + revocation_basepoint: RevocationBasepoint(pubkey), + payment_point: pubkey, + delayed_payment_basepoint: DelayedPaymentBasepoint(pubkey), + htlc_basepoint: HtlcBasepoint(pubkey), + }, + counterparty_selected_contest_delay: 6, + counterparty_shutdown_script: None, + commitment_type: CommitmentType::StaticRemoteKey, + }), + id: None, + enforcement_state: EnforcementState::new(600_000), + blockheight: None, + } + } + + fn psbt_fixture( + prev_txid: &str, + prev_vout: u32, + input_value_sat: u64, + outputs: Vec<(u64, &str)>, + ) -> String { + let input_script = + ScriptBuf::from_hex("00140000000000000000000000000000000000000000").unwrap(); + let tx = Transaction { + version: Version::TWO, + lock_time: LockTime::ZERO, + input: vec![TxIn { + previous_output: OutPoint { + txid: Txid::from_str(prev_txid).unwrap(), + vout: prev_vout, + }, + script_sig: ScriptBuf::new(), + sequence: Sequence::MAX, + witness: Witness::new(), + }], + output: outputs + .into_iter() + .map(|(value_sat, script_hex)| TxOut { + value: Amount::from_sat(value_sat), + script_pubkey: ScriptBuf::from_hex(script_hex).unwrap(), + }) + .collect(), + }; + let mut psbt = Psbt::from_unsigned_tx(tx).unwrap(); + psbt.inputs[0].witness_utxo = Some(TxOut { + value: Amount::from_sat(input_value_sat), + script_pubkey: input_script, + }); + general_purpose::STANDARD.encode(psbt.serialize()) + } + + fn local_session() -> SpliceSessionV1 { + SpliceSessionV1::new( + SpliceOrigin::LocalInitiator, + "02".repeat(33), + "33".repeat(32), + "44".repeat(32), + OldSpliceState { + funding_outpoint: outpoint(&"55".repeat(32), 0), + channel_value_sat: 1_000_000, + local_balance_sat: 600_000, + }, + Some(50_000), + FeePolicy { + feerate_per_kw: Some(253), + force_feerate: Some(false), + }, + 1, + ) + } + + #[test] + fn resolves_canonical_node_channel_id_from_old_funding_outpoint() { + let mut state = State::new(); + let node_id_hex = "02".repeat(33); + let channel_id = format!("{}{}", node_id_hex, "03".repeat(41)); + let funding_txid = "11".repeat(32); + state + .insert_channel(&channel_id, channel_entry(&funding_txid, 7)) + .unwrap(); + + let resolved = state + .node_channel_id_for_funding_outpoint( + &node_id_hex, + &FundingOutpoint { + txid: funding_txid, + vout: 7, + }, + ) + .unwrap(); + + assert_eq!(resolved.as_deref(), Some(channel_id.as_str())); + } + + #[test] + fn rejects_ambiguous_node_channel_id_for_funding_outpoint() { + let mut state = State::new(); + let node_id_hex = "02".repeat(33); + let funding_txid = "11".repeat(32); + for suffix in ["03", "04"] { + let channel_id = format!("{}{}", node_id_hex, suffix.repeat(41)); + state + .insert_channel(&channel_id, channel_entry(&funding_txid, 7)) + .unwrap(); + } + + let error = state + .node_channel_id_for_funding_outpoint( + &node_id_hex, + &FundingOutpoint { + txid: funding_txid, + vout: 7, + }, + ) + .unwrap_err(); + + assert!(error.to_string().contains("multiple channels")); + } + + #[test] + fn record_local_splice_intent_persists_facts_and_fingerprint() { + let mut state = State::new(); + let fingerprint = "77".repeat(32); + + state + .record_local_splice_intent(LocalSpliceIntent { + node_id_hex: "02".repeat(33), + channel_id_hex: "33".repeat(32), + node_channel_id_hex: "44".repeat(32), + old: OldSpliceState { + funding_outpoint: outpoint(&"55".repeat(32), 0), + channel_value_sat: 1_000_000, + local_balance_sat: 600_000, + }, + authorized_relative_amount_sat: 50_000, + fee_policy: FeePolicy { + feerate_per_kw: Some(253), + force_feerate: Some(false), + }, + initial_psbt_fingerprint: fingerprint.clone(), + initial_psbt_input_outpoints: vec![outpoint(&"11".repeat(32), 7)], + timestamp_ms: 1, + }) + .unwrap(); + + let session = state.get_splice_session(&"44".repeat(32)).unwrap().unwrap(); + assert_eq!(session.origin, SpliceOrigin::LocalInitiator); + assert_eq!(session.phase, SplicePhase::Negotiating); + assert_eq!(session.intent.authorized_relative_amount_sat, Some(50_000)); + assert_eq!(session.intent.fee_policy.feerate_per_kw, Some(253)); + assert_eq!( + session.psbt.candidate_fingerprint.as_deref(), + Some(fingerprint.as_str()) + ); + let wallet_context = state + .get_psbt_context(&fingerprint) + .unwrap() + .expect("splice candidate creates a linked PSBT context"); + assert_eq!( + wallet_context.linked_node_channel_id_hex.as_deref(), + Some("4444444444444444444444444444444444444444444444444444444444444444") + ); + + let session_value = serde_json::to_value(session).unwrap(); + assert!(session_value.get("auth").is_none()); + assert!(session_value.get("request_history").is_none()); + } + + #[test] + fn wallet_inputs_psbt_values_and_reservations() { + let prev_txid = "11".repeat(32); + let psbt = psbt_fixture( + &prev_txid, + 7, + 55_000, + vec![(50_000, "00142222222222222222222222222222222222222222")], + ); + + let wallet_inputs = wallet_inputs_from_psbt( + &psbt, + &[WalletInputReservation { + txid: "11".repeat(32), + vout: 7, + reserved_to_block: Some(42), + }], + ) + .unwrap(); + assert_eq!(wallet_inputs.len(), 1); + assert_eq!(wallet_inputs[0].txid, prev_txid); + assert_eq!(wallet_inputs[0].vout, 7); + assert_eq!(wallet_inputs[0].value_sat, 55_000); + assert_eq!(wallet_inputs[0].reserved_to_block, Some(42)); + } + + #[test] + fn psbt_v2_and_candidate_facts_use_the_adapter() { + let psbt = parse_base64_psbt(CLN_PSBT_V2).unwrap(); + assert_eq!( + psbt.input_outpoints, + vec![outpoint( + "c85f81844094f9f0eec1e41f8d63e0a99e9f73dc725d7319871c9c4121d90a0b", + 0, + )] + ); + + let candidate = candidate_funding_facts_from_psbt( + CLN_PSBT_V2, + &"99".repeat(32), + 0, + &psbt.input_outpoints[0], + ) + .unwrap(); + assert_eq!(candidate.value_sat, 800_000_000); + assert_eq!(candidate.sign_splice_tx_input_index, 0); + assert_eq!( + candidate.script_pubkey_hash, + sha256::digest(hex::decode("0014c430f64c4756da310dbd1a085572ef299926272c").unwrap()) + ); + } + + #[test] + fn records_fundpsbt_response_and_signpsbt_intent_by_fingerprint() { + let mut state = State::new(); + let serialized_psbt = psbt_fixture( + &"11".repeat(32), + 0, + 25_000, + vec![(20_000, "00143333333333333333333333333333333333333333")], + ); + let psbt = parse_base64_psbt(&serialized_psbt).unwrap(); + let wallet_inputs = wallet_inputs_from_psbt(&serialized_psbt, &[]).unwrap(); + + state + .record_fundpsbt_response(FundPsbtResponseFacts { + psbt_fingerprint: psbt.fingerprint.clone(), + wallet_inputs, + timestamp_ms: 2, + }) + .unwrap(); + state + .record_signpsbt_intent(SignPsbtIntentFacts { + psbt_fingerprint: psbt.fingerprint.clone(), + signonly: vec![0], + timestamp_ms: 3, + }) + .unwrap(); + + let context = state.get_psbt_context(&psbt.fingerprint).unwrap().unwrap(); + assert_eq!(context.signonly, vec![0]); + assert_eq!(context.wallet_inputs[0].value_sat, 25_000); + let value = serde_json::to_value(context).unwrap(); + assert!(value.get("fundpsbt_auth").is_none()); + assert!(value.get("signpsbt_auth").is_none()); + } + + #[test] + fn signpsbt_intent_preserves_existing_splice_link() { + let mut state = State::new(); + state + .record_local_splice_intent(LocalSpliceIntent { + node_id_hex: "02".repeat(33), + channel_id_hex: "33".repeat(32), + node_channel_id_hex: "44".repeat(32), + old: OldSpliceState { + funding_outpoint: outpoint(&"55".repeat(32), 0), + channel_value_sat: 1_000_000, + local_balance_sat: 600_000, + }, + authorized_relative_amount_sat: 50_000, + fee_policy: FeePolicy::default(), + initial_psbt_fingerprint: "aa".repeat(32), + initial_psbt_input_outpoints: vec![], + timestamp_ms: 1, + }) + .unwrap(); + + state + .record_signpsbt_intent(SignPsbtIntentFacts { + psbt_fingerprint: "aa".repeat(32), + signonly: vec![0], + timestamp_ms: 2, + }) + .unwrap(); + + let context = state.get_psbt_context(&"aa".repeat(32)).unwrap().unwrap(); + assert_eq!(context.signonly, vec![0]); + assert_eq!( + context.linked_node_channel_id_hex.as_deref(), + Some("4444444444444444444444444444444444444444444444444444444444444444") + ); + } + + #[test] + fn splice_candidate_inherits_wallet_inputs_from_initial_psbt() { + let mut state = State::new(); + let source_fingerprint = "aa".repeat(32); + let candidate_fingerprint = "bb".repeat(32); + let updated_fingerprint = "cc".repeat(32); + let wallet_outpoint = outpoint(&"11".repeat(32), 7); + state + .record_fundpsbt_response(FundPsbtResponseFacts { + psbt_fingerprint: source_fingerprint.clone(), + wallet_inputs: vec![WalletInput { + txid: wallet_outpoint.txid.clone(), + vout: wallet_outpoint.vout, + value_sat: 25_000, + reserved_to_block: Some(100), + }], + timestamp_ms: 1, + }) + .unwrap(); + state + .record_local_splice_intent(LocalSpliceIntent { + node_id_hex: "02".repeat(33), + channel_id_hex: "33".repeat(32), + node_channel_id_hex: "44".repeat(32), + old: OldSpliceState { + funding_outpoint: outpoint(&"55".repeat(32), 0), + channel_value_sat: 1_000_000, + local_balance_sat: 600_000, + }, + authorized_relative_amount_sat: 50_000, + fee_policy: FeePolicy::default(), + initial_psbt_fingerprint: candidate_fingerprint.clone(), + initial_psbt_input_outpoints: vec![wallet_outpoint.clone()], + timestamp_ms: 2, + }) + .unwrap(); + + state + .inherit_splice_wallet_context( + &source_fingerprint, + &candidate_fingerprint, + std::slice::from_ref(&wallet_outpoint), + &"44".repeat(32), + 2, + ) + .unwrap(); + + let candidate = state + .get_psbt_context(&candidate_fingerprint) + .unwrap() + .unwrap(); + assert_eq!(candidate.wallet_inputs.len(), 1); + assert_eq!(candidate.wallet_inputs[0].reserved_to_block, Some(100)); + + state + .record_splice_update_response(SpliceUpdateResponseFacts { + node_channel_id_hex: "44".repeat(32), + psbt_fingerprint: updated_fingerprint.clone(), + psbt_input_outpoints: vec![wallet_outpoint], + commitments_secured: false, + signatures_secured: None, + timestamp_ms: 3, + }) + .unwrap(); + + let updated = state + .get_psbt_context(&updated_fingerprint) + .unwrap() + .unwrap(); + assert_eq!(updated.wallet_inputs.len(), 1); + assert_eq!(updated.wallet_inputs[0].reserved_to_block, Some(100)); + } + + #[test] + fn records_splice_update_and_signed_response_phase_facts() { + let mut state = State::new(); + state.create_local_splice_session(local_session()).unwrap(); + let old_txid = "55".repeat(32); + let serialized_psbt = psbt_fixture( + &old_txid, + 0, + 1_000_000, + vec![(1_050_000, "00144444444444444444444444444444444444444444")], + ); + let psbt = parse_base64_psbt(&serialized_psbt).unwrap(); + + state + .record_splice_update_response(SpliceUpdateResponseFacts { + node_channel_id_hex: "44".repeat(32), + psbt_fingerprint: psbt.fingerprint.clone(), + psbt_input_outpoints: psbt.input_outpoints.clone(), + commitments_secured: true, + signatures_secured: Some(false), + timestamp_ms: 2, + }) + .unwrap(); + let session = state.get_splice_session(&"44".repeat(32)).unwrap().unwrap(); + assert_eq!(session.phase, SplicePhase::CommitmentsSecured); + assert_eq!( + session.psbt.frozen_fingerprint.as_deref(), + Some(psbt.fingerprint.as_str()) + ); + assert!(session.cand.funding_outpoint.is_none()); + + let candidate = candidate_funding_facts_from_psbt( + &serialized_psbt, + &"99".repeat(32), + 0, + &session.old.funding_outpoint, + ) + .unwrap(); + state + .record_splice_signed_response(SpliceSignedResponseFacts { + node_channel_id_hex: "44".repeat(32), + psbt_fingerprint: psbt.fingerprint, + psbt_input_outpoints: psbt.input_outpoints, + candidate: Some(candidate), + timestamp_ms: 3, + }) + .unwrap(); + + let session = state.get_splice_session(&"44".repeat(32)).unwrap().unwrap(); + assert_eq!(session.phase, SplicePhase::PendingLock); + assert_eq!( + session.cand.funding_outpoint.as_ref().unwrap().txid, + "99".repeat(32) + ); + assert!(state + .get_splice_by_outpoint(&"99".repeat(32), 0) + .unwrap() + .is_some()); + } + + #[test] + fn session_schema_omits_unverifiable_rpc_auth() { + let mut session = local_session(); + session.psbt.candidate_fingerprint = Some("candidate".to_string()); + session.psbt.frozen_fingerprint = Some("frozen".to_string()); + + let value = serde_json::to_value(&session).unwrap(); + + assert_eq!(value["schema"], json!("SpliceSessionV1")); + assert!(value.get("old").is_some()); + assert!(value.get("auth").is_none()); + assert!(value.get("intent").is_some()); + assert!(value.get("psbt").is_some()); + assert!(value.get("cand").is_some()); + assert!(value.get("delta").is_some()); + assert!(value.get("linked_wallet_psbt_fingerprints").is_some()); + assert!(value.get("request_history").is_none()); + assert!(value.get("old_funding_outpoint").is_none()); + assert!(value.get("splice_init_auth").is_none()); + assert!(value.get("candidate_funding_outpoint").is_none()); + assert_eq!( + value["psbt"], + json!({ + "candidate_fingerprint": "candidate", + "frozen_fingerprint": "frozen", + }) + ); + assert_eq!( + serde_json::from_value::(value).unwrap(), + session + ); + } + + #[test] + fn outpoint_lookup_survives_restart_and_tombstone_rejects_stale_merge() { + let mut state = State::new(); + let fingerprint = "aa".repeat(32); + state.create_local_splice_session(local_session()).unwrap(); + state + .update_splice_candidate(&"44".repeat(32), fingerprint.clone(), 2) + .unwrap(); + state + .put_splice_wallet_psbt_context(&fingerprint, SpliceWalletPsbtContextV1::new(vec![], 2)) + .unwrap(); + state + .link_splice_wallet_psbt(&fingerprint, &"44".repeat(32), 2) + .unwrap(); + state + .freeze_splice_candidate( + &"44".repeat(32), + fingerprint.clone(), + CandidateFundingFacts { + funding_outpoint: outpoint(&"ee".repeat(32), 2), + value_sat: 1_050_000, + script_pubkey_hash: "ff".repeat(32), + sign_splice_tx_input_index: 0, + remote_funding_key_hex: None, + }, + 3, + ) + .unwrap(); + + let stale_state = state.clone(); + let entries: Vec = state.clone().into(); + let restored = State::try_from(entries.as_slice()).unwrap(); + assert!(restored + .get_splice_by_outpoint(&"ee".repeat(32), 2) + .unwrap() + .is_some()); + assert!(restored.get_psbt_context(&fingerprint).unwrap().is_some()); + + state.tombstone_splice_session(&"44".repeat(32)).unwrap(); + state.merge(&stale_state).unwrap(); + + assert!(state + .get_splice_session(&"44".repeat(32)) + .unwrap() + .is_none()); + assert!(state + .get_splice_by_outpoint(&"ee".repeat(32), 2) + .unwrap() + .is_none()); + assert!(state.get_psbt_context(&fingerprint).unwrap().is_none()); + } +} diff --git a/libs/gl-client/src/psbt.rs b/libs/gl-client/src/psbt.rs new file mode 100644 index 000000000..4ff74e201 --- /dev/null +++ b/libs/gl-client/src/psbt.rs @@ -0,0 +1,527 @@ +use crate::bitcoin::consensus::encode::VarInt; +use crate::bitcoin::consensus::Decodable; +use crate::bitcoin::{OutPoint, ScriptBuf, Sequence, Transaction, TxIn, TxOut, Txid, Witness}; +use base64::{engine::general_purpose, Engine as _}; +use std::io::Cursor; +use thiserror::Error; + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct CandidateFunding { + pub outpoint: OutPoint, + pub txout: TxOut, + pub sign_splice_tx_input_index: u32, +} + +#[derive(Debug, Error)] +pub enum Error { + #[error("invalid base64 PSBT: {0}")] + InvalidBase64(#[source] base64::DecodeError), + #[error("invalid PSBT (v0: {v0}; v2: {v2})")] + InvalidPsbt { v0: String, v2: String }, + #[error("invalid PSBT framing: {0}")] + InvalidFraming(String), + #[error("PSBT contains trailing data")] + TrailingData, + #[error("PSBT inputs require incompatible lock times")] + IncompatibleLockTime, + #[error("PSBT input {input_index} has no UTXO")] + MissingUtxo { input_index: usize }, + #[error( + "PSBT input {input_index} non-witness UTXO txid mismatch: expected {expected}, got {actual}" + )] + NonWitnessUtxoTxidMismatch { + input_index: usize, + expected: Txid, + actual: Txid, + }, + #[error( + "PSBT input {input_index} references output {vout}, but its non-witness UTXO has {output_count} outputs" + )] + UtxoOutputMissing { + input_index: usize, + vout: u32, + output_count: usize, + }, + #[error("PSBT input {input_index} has inconsistent witness and non-witness UTXOs")] + InconsistentUtxo { input_index: usize }, + #[error("splice PSBT does not spend old funding outpoint {0}")] + FundingOutpointNotSpent(OutPoint), + #[error("splice PSBT spends old funding outpoint {0} more than once")] + FundingOutpointSpentMultipleTimes(OutPoint), + #[error("splice funding output index {vout} is missing")] + FundingOutputMissing { vout: u32 }, + #[error("splice transaction has too many inputs to represent the funding input index")] + FundingInputIndexOverflow, +} + +#[derive(Clone, Debug)] +enum ParsedPsbtInner { + V0(psbt_v2::v0::Psbt), + V2(psbt_v2::v2::Psbt), +} + +#[derive(Clone, Debug)] +pub struct ParsedPsbt { + inner: ParsedPsbtInner, + unsigned_tx: Transaction, +} + +impl ParsedPsbt { + pub fn from_base64(encoded: &str) -> Result { + let bytes = general_purpose::STANDARD + .decode(encoded) + .map_err(Error::InvalidBase64)?; + Self::from_bytes(&bytes) + } + + pub fn from_bytes(bytes: &[u8]) -> Result { + let v0_error = match psbt_v2::v0::Psbt::deserialize(bytes) { + Ok(psbt) => { + ensure_complete_psbt(bytes, psbt.inputs.len(), psbt.outputs.len())?; + let unsigned_tx = psbt.unsigned_tx.clone(); + return Ok(Self { + inner: ParsedPsbtInner::V0(psbt), + unsigned_tx, + }); + } + Err(error) => format!("{error:?}"), + }; + + match psbt_v2::v2::Psbt::deserialize(bytes) { + Ok(psbt) => { + ensure_complete_psbt(bytes, psbt.global.input_count, psbt.global.output_count)?; + let unsigned_tx = v2_unsigned_tx(&psbt)?; + Ok(Self { + inner: ParsedPsbtInner::V2(psbt), + unsigned_tx, + }) + } + Err(error) => Err(Error::InvalidPsbt { + v0: v0_error, + v2: format!("{error:?}"), + }), + } + } + + pub fn unsigned_tx(&self) -> &Transaction { + &self.unsigned_tx + } + + /// Return the version-independent unsigned transaction fingerprint. + pub fn fingerprint(&self) -> Txid { + self.unsigned_tx.compute_txid() + } + + pub fn input_outpoints(&self) -> Vec { + self.unsigned_tx + .input + .iter() + .map(|input| input.previous_output) + .collect() + } + + pub fn funding_utxos(&self) -> Result, Error> { + match &self.inner { + ParsedPsbtInner::V0(psbt) => self + .unsigned_tx + .input + .iter() + .zip(&psbt.inputs) + .enumerate() + .map(|(input_index, (txin, input))| { + validated_funding_utxo( + input_index, + txin.previous_output, + input.witness_utxo.as_ref(), + input.non_witness_utxo.as_ref(), + ) + }) + .collect(), + ParsedPsbtInner::V2(psbt) => psbt + .inputs + .iter() + .enumerate() + .map(|(input_index, input)| { + validated_funding_utxo( + input_index, + OutPoint::new(input.previous_txid, input.spent_output_index), + input.witness_utxo.as_ref(), + input.non_witness_utxo.as_ref(), + ) + }) + .collect(), + } + } + + pub fn candidate_funding( + &self, + funding_txid: Txid, + funding_vout: u32, + old_funding_outpoint: OutPoint, + ) -> Result { + let mut matching_inputs = self + .unsigned_tx + .input + .iter() + .enumerate() + .filter(|(_, input)| input.previous_output == old_funding_outpoint); + let (input_index, _) = matching_inputs + .next() + .ok_or(Error::FundingOutpointNotSpent(old_funding_outpoint))?; + if matching_inputs.next().is_some() { + return Err(Error::FundingOutpointSpentMultipleTimes( + old_funding_outpoint, + )); + } + + let sign_splice_tx_input_index = + u32::try_from(input_index).map_err(|_| Error::FundingInputIndexOverflow)?; + let txout = self + .unsigned_tx + .output + .get(funding_vout as usize) + .cloned() + .ok_or(Error::FundingOutputMissing { vout: funding_vout })?; + + Ok(CandidateFunding { + outpoint: OutPoint::new(funding_txid, funding_vout), + txout, + sign_splice_tx_input_index, + }) + } +} + +fn v2_unsigned_tx(psbt: &psbt_v2::v2::Psbt) -> Result { + let lock_time = psbt + .determine_lock_time() + .map_err(|_| Error::IncompatibleLockTime)?; + let input = psbt + .inputs + .iter() + .map(|input| TxIn { + previous_output: OutPoint::new(input.previous_txid, input.spent_output_index), + script_sig: ScriptBuf::new(), + sequence: input.sequence.unwrap_or(Sequence::MAX), + witness: Witness::new(), + }) + .collect(); + let output = psbt + .outputs + .iter() + .map(|output| TxOut { + value: output.amount, + script_pubkey: output.script_pubkey.clone(), + }) + .collect(); + + Ok(Transaction { + version: psbt.global.tx_version, + lock_time, + input, + output, + }) +} + +fn validated_funding_utxo( + input_index: usize, + previous_output: OutPoint, + witness_utxo: Option<&TxOut>, + non_witness_utxo: Option<&Transaction>, +) -> Result { + let non_witness_output = non_witness_utxo + .map(|transaction| { + let actual = transaction.compute_txid(); + if actual != previous_output.txid { + return Err(Error::NonWitnessUtxoTxidMismatch { + input_index, + expected: previous_output.txid, + actual, + }); + } + transaction + .output + .get(previous_output.vout as usize) + .ok_or(Error::UtxoOutputMissing { + input_index, + vout: previous_output.vout, + output_count: transaction.output.len(), + }) + }) + .transpose()?; + + match (witness_utxo, non_witness_output) { + (Some(witness), Some(non_witness)) if witness != non_witness => { + Err(Error::InconsistentUtxo { input_index }) + } + (Some(witness), _) => Ok(witness.clone()), + (None, Some(non_witness)) => Ok(non_witness.clone()), + (None, None) => Err(Error::MissingUtxo { input_index }), + } +} + +fn ensure_complete_psbt( + bytes: &[u8], + input_count: usize, + output_count: usize, +) -> Result<(), Error> { + if !bytes.starts_with(b"psbt\xff") { + return Err(Error::InvalidFraming("invalid magic bytes".to_string())); + } + + // psbt-v2 0.3.0 accepts trailing bytes. This cursor only validates map + // framing and leaves all PSBT key/value parsing to the crate. + let mut cursor = Cursor::new(bytes); + cursor.set_position(5); + consume_map(&mut cursor)?; + for _ in 0..input_count { + consume_map(&mut cursor)?; + } + for _ in 0..output_count { + consume_map(&mut cursor)?; + } + + if cursor.position() != bytes.len() as u64 { + return Err(Error::TrailingData); + } + Ok(()) +} + +fn consume_map(cursor: &mut Cursor<&[u8]>) -> Result<(), Error> { + loop { + let key_length = read_compact_size(cursor)?; + if key_length == 0 { + return Ok(()); + } + skip_bytes(cursor, key_length)?; + let value_length = read_compact_size(cursor)?; + skip_bytes(cursor, value_length)?; + } +} + +fn read_compact_size(cursor: &mut Cursor<&[u8]>) -> Result { + VarInt::consensus_decode(cursor) + .map(|value| value.0) + .map_err(|error| Error::InvalidFraming(error.to_string())) +} + +fn skip_bytes(cursor: &mut Cursor<&[u8]>, count: u64) -> Result<(), Error> { + let end = cursor + .position() + .checked_add(count) + .ok_or_else(|| Error::InvalidFraming("map length overflow".to_string()))?; + if end > cursor.get_ref().len() as u64 { + return Err(Error::InvalidFraming( + "map entry extends past end of PSBT".to_string(), + )); + } + cursor.set_position(end); + Ok(()) +} + +#[cfg(test)] +pub(crate) const CLN_PSBT_V2: &str = "cHNidP8BAgQCAAAAAQQBAQEFAQIB+wQCAAAAAAEOIAsK2SFBnByHGXNdctxzn56p4GONH+TB7vD5lECEgV/IAQ8EAAAAAAABAwgACK8vAAAAAAEEFgAUxDD2TEdW2jENvRoIVXLvKZkmJywAAQMIi73rCwAAAAABBBYAFE3Rk6yWSlasG54cyoRU/i9HT4UTAA=="; + +#[cfg(test)] +mod tests { + use super::*; + use crate::bitcoin::absolute::LockTime; + use crate::bitcoin::transaction::Version; + use crate::bitcoin::{Amount, ScriptBuf, Sequence, Transaction, TxIn, TxOut, Witness}; + use base64::engine::general_purpose; + use std::str::FromStr; + + fn unsigned_tx(previous_output: OutPoint) -> Transaction { + Transaction { + version: Version::TWO, + lock_time: LockTime::ZERO, + input: vec![TxIn { + previous_output, + script_sig: ScriptBuf::new(), + sequence: Sequence::MAX, + witness: Witness::new(), + }], + output: vec![TxOut { + value: Amount::from_sat(75_000), + script_pubkey: ScriptBuf::new(), + }], + } + } + + fn v0_psbt(previous_output: OutPoint) -> psbt_v2::v0::Psbt { + psbt_v2::v0::Psbt::from_unsigned_tx(unsigned_tx(previous_output)).unwrap() + } + + #[test] + fn parses_cln_v2_with_bip370_sequence_default() { + let psbt = ParsedPsbt::from_base64(CLN_PSBT_V2).unwrap(); + + assert_eq!(psbt.unsigned_tx().input[0].sequence, Sequence::MAX); + assert_eq!(psbt.input_outpoints().len(), 1); + assert_eq!(psbt.fingerprint(), psbt.unsigned_tx().compute_txid()); + } + + #[test] + fn equivalent_v0_and_v2_psbts_have_the_same_fingerprint() { + let v2 = ParsedPsbt::from_base64(CLN_PSBT_V2).unwrap(); + let v0 = psbt_v2::v0::Psbt::from_unsigned_tx(v2.unsigned_tx().clone()).unwrap(); + let v0 = ParsedPsbt::from_bytes(&v0.serialize()).unwrap(); + + assert_eq!(v0.fingerprint(), v2.fingerprint()); + } + + #[test] + fn rejects_trailing_data_for_both_versions() { + let previous_output = OutPoint::new(Txid::from_str(&"11".repeat(32)).unwrap(), 0); + let mut v0 = v0_psbt(previous_output).serialize(); + v0.push(0); + let mut v2 = general_purpose::STANDARD.decode(CLN_PSBT_V2).unwrap(); + v2.push(0); + + assert!(matches!( + ParsedPsbt::from_bytes(&v0), + Err(Error::TrailingData) + )); + assert!(matches!( + ParsedPsbt::from_bytes(&v2), + Err(Error::TrailingData) + )); + } + + #[test] + fn rejects_noncanonical_map_framing() { + let raw = general_purpose::STANDARD.decode(CLN_PSBT_V2).unwrap(); + assert_eq!(raw[5], 1); + let mut non_minimal = Vec::with_capacity(raw.len() + 2); + non_minimal.extend_from_slice(&raw[..5]); + non_minimal.extend_from_slice(&[0xfd, 0x01, 0x00]); + non_minimal.extend_from_slice(&raw[6..]); + + assert!(ParsedPsbt::from_bytes(&non_minimal).is_err()); + } + + #[test] + fn rejects_incompatible_v2_locktimes() { + let raw = general_purpose::STANDARD.decode(CLN_PSBT_V2).unwrap(); + let mut v2 = psbt_v2::v2::Psbt::deserialize(&raw).unwrap(); + v2.inputs[0].min_height = + Some(crate::bitcoin::absolute::Height::from_consensus(1).unwrap()); + let mut time_locked_input = v2.inputs[0].clone(); + time_locked_input.min_height = None; + time_locked_input.min_time = + Some(crate::bitcoin::absolute::Time::from_consensus(500_000_000).unwrap()); + v2.inputs.push(time_locked_input); + v2.global.input_count += 1; + + assert!(matches!( + ParsedPsbt::from_bytes(&v2.serialize()), + Err(Error::IncompatibleLockTime) + )); + } + + #[test] + fn validates_and_returns_funding_utxos() { + let previous_tx = Transaction { + version: Version::TWO, + lock_time: LockTime::ZERO, + input: vec![], + output: vec![TxOut { + value: Amount::from_sat(80_000), + script_pubkey: ScriptBuf::new(), + }], + }; + let previous_output = OutPoint::new(previous_tx.compute_txid(), 0); + let mut v0 = v0_psbt(previous_output); + v0.inputs[0].non_witness_utxo = Some(previous_tx.clone()); + v0.inputs[0].witness_utxo = Some(previous_tx.output[0].clone()); + + let parsed = ParsedPsbt::from_bytes(&v0.serialize()).unwrap(); + + assert_eq!(parsed.funding_utxos().unwrap(), previous_tx.output); + } + + #[test] + fn rejects_missing_funding_utxo() { + let previous_output = OutPoint::new(Txid::from_str(&"11".repeat(32)).unwrap(), 0); + let parsed = ParsedPsbt::from_bytes(&v0_psbt(previous_output).serialize()).unwrap(); + + assert!(matches!( + parsed.funding_utxos(), + Err(Error::MissingUtxo { input_index: 0 }) + )); + } + + #[test] + fn rejects_missing_non_witness_output() { + let previous_tx = Transaction { + version: Version::TWO, + lock_time: LockTime::ZERO, + input: vec![], + output: vec![TxOut { + value: Amount::from_sat(80_000), + script_pubkey: ScriptBuf::new(), + }], + }; + let mut v0 = v0_psbt(OutPoint::new(previous_tx.compute_txid(), 1)); + v0.inputs[0].non_witness_utxo = Some(previous_tx); + let parsed = ParsedPsbt::from_bytes(&v0.serialize()).unwrap(); + + assert!(matches!( + parsed.funding_utxos(), + Err(Error::UtxoOutputMissing { + input_index: 0, + vout: 1, + output_count: 1, + }) + )); + } + + #[test] + fn rejects_mismatched_non_witness_utxo_even_with_witness_utxo() { + let previous_output = OutPoint::new(Txid::from_str(&"11".repeat(32)).unwrap(), 0); + let mut v0 = v0_psbt(previous_output); + let wrong_tx = Transaction { + version: Version::TWO, + lock_time: LockTime::ZERO, + input: vec![], + output: vec![TxOut { + value: Amount::from_sat(80_000), + script_pubkey: ScriptBuf::new(), + }], + }; + v0.inputs[0].non_witness_utxo = Some(wrong_tx.clone()); + v0.inputs[0].witness_utxo = Some(wrong_tx.output[0].clone()); + + let parsed = ParsedPsbt::from_bytes(&v0.serialize()).unwrap(); + + assert!(matches!( + parsed.funding_utxos(), + Err(Error::NonWitnessUtxoTxidMismatch { input_index: 0, .. }) + )); + } + + #[test] + fn rejects_inconsistent_witness_and_non_witness_utxos() { + let previous_tx = Transaction { + version: Version::TWO, + lock_time: LockTime::ZERO, + input: vec![], + output: vec![TxOut { + value: Amount::from_sat(80_000), + script_pubkey: ScriptBuf::new(), + }], + }; + let mut v0 = v0_psbt(OutPoint::new(previous_tx.compute_txid(), 0)); + v0.inputs[0].non_witness_utxo = Some(previous_tx); + v0.inputs[0].witness_utxo = Some(TxOut { + value: Amount::from_sat(79_999), + script_pubkey: ScriptBuf::new(), + }); + + let parsed = ParsedPsbt::from_bytes(&v0.serialize()).unwrap(); + + assert!(matches!( + parsed.funding_utxos(), + Err(Error::InconsistentUtxo { input_index: 0 }) + )); + } +} diff --git a/libs/gl-client/src/signer/mod.rs b/libs/gl-client/src/signer/mod.rs index da3fee6e9..0e7415bec 100644 --- a/libs/gl-client/src/signer/mod.rs +++ b/libs/gl-client/src/signer/mod.rs @@ -39,7 +39,9 @@ use tokio::time::{sleep, Duration}; use tokio_stream::wrappers::ReceiverStream; use tonic::transport::{Endpoint, Uri}; use tonic::{Code, Request}; -use vls_protocol::msgs::{DeBolt, HsmdInitReplyV4}; +#[cfg(any(feature = "experimental-splicing", test))] +use vls_protocol::msgs::SignSpliceTx; +use vls_protocol::msgs::{DeBolt, HsmdInitReplyV4, SerBolt}; use vls_protocol::serde_bolt::Octets; use vls_protocol_signer::approver::{Approve, MemoApprover}; use vls_protocol_signer::handler; @@ -59,6 +61,7 @@ pub use backup::{ pub mod model; mod report; mod resolve; +mod splice_policy; const VERSION: &str = "v26.06"; const GITHASH: &str = env!("GIT_HASH"); @@ -69,6 +72,18 @@ const STATE_DERIVATION_SECRET: &str = "greenlight/state-signing/v1"; const STATE_SIGNING_DOMAIN: &[u8] = b"greenlight/state-signing/v1\0"; const STATE_SIGNATURE_OVERRIDE_ACK: &str = "I_ACCEPT_OPERATOR_ASSISTED_STATE_OVERRIDE"; const COMPACT_SIGNATURE_LEN: usize = 64; +#[cfg(any(feature = "experimental-splicing", test))] +const SIGN_SPLICE_TX_CAPABILITY: u32 = SignSpliceTx::TYPE as u32; + +#[cfg(feature = "experimental-splicing")] +fn advertise_experimental_splicing(mut init: HsmdInitReplyV4) -> HsmdInitReplyV4 { + // TODO: Remove this shim once VLS advertises SignSpliceTx itself. + if !init.hsm_capabilities.0.contains(&SIGN_SPLICE_TX_CAPABILITY) { + warn!("Advertising SignSpliceTx while VLS splice support remains fail-closed"); + init.hsm_capabilities.0.push(SIGN_SPLICE_TX_CAPABILITY); + } + init +} #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum StateSignatureMode { @@ -154,6 +169,18 @@ pub enum Error { #[error("resolver error: request {0:?}, context: {1:?}")] Resolver(Vec, Vec), + #[error("splice policy rejected {operation}: {violation}")] + SplicePolicy { + operation: String, + violation: String, + }, + + #[error("splice request {operation} requires unavailable VLS proof: {required_proof}")] + VlsSpliceUnavailable { + operation: String, + required_proof: String, + }, + #[error("error asking node to be upgraded: {0}")] Upgrade(tonic::Status), @@ -317,8 +344,10 @@ impl Signer { let init = HsmdInitReplyV4::from_vec(init) .map_err(|e| anyhow!("Failed to parse init message as HsmdInitReplyV4: {:?}", e))?; + #[cfg(feature = "experimental-splicing")] + let init = advertise_experimental_splicing(init); + let id = init.node_id.0.to_vec(); - use vls_protocol::msgs::SerBolt; let init = init.as_vec(); // Init master rune. We create the rune seed from the nodes @@ -752,16 +781,19 @@ impl Signer { fn authenticate_request( &self, msg: &vls_protocol::msgs::Message, - reqs: &Vec, + reqs: &[model::Request], + hsm_context: Option<&HsmRequestContext>, ) -> Result<(), Error> { log::trace!( "Resolving signature request against pending grpc commands: {:?}", reqs ); - // Quick path out of here: we can't find a resolution for a - // request, then abort! - Resolver::try_resolve(msg, &reqs)?; + let state = self + .state + .lock() + .map_err(|e| Error::Other(anyhow!("Failed to acquire state lock: {:?}", e)))?; + Resolver::try_resolve(msg, reqs, &state, &self.id, hsm_context)?; Ok(()) } @@ -779,7 +811,6 @@ impl Signer { let incoming_state = crate::persist::State::try_from(req.signer_state.as_slice()) .map_err(|e| Error::Other(anyhow!("Failed to decode signer state: {e}")))?; - // Create sketch from incoming state (nodelet's view) so we can // send back any entries the nodelet doesn't know about yet, // including the initial VLS state created during Signer::new(). @@ -843,7 +874,7 @@ impl Signer { log::debug!("Handling message {:?}", msg); log::trace!("Signer state {}", prestate_log); - if let Err(e) = self.authenticate_request(&msg, &ctxrequests) { + if let Err(e) = self.authenticate_request(&msg, &ctxrequests, req.context.as_ref()) { report::Reporter::report(crate::pb::scheduler::SignerRejection { msg: e.to_string(), request: Some(req.clone()), @@ -852,7 +883,7 @@ impl Signer { }) .await; #[cfg(not(feature = "permissive"))] - return Err(Error::Resolver(req.raw, ctxrequests)); + return Err(e); }; // If present, add the close_to_addr to the allowlist @@ -1677,6 +1708,31 @@ mod tests { .unwrap() } + #[cfg(not(feature = "experimental-splicing"))] + #[test] + fn splice_signing_capability_is_not_advertised_by_default() { + let signer = mk_signer(StateSignatureMode::Soft); + let init = HsmdInitReplyV4::from_vec(signer.get_init()).unwrap(); + + assert!(!init.hsm_capabilities.0.contains(&SIGN_SPLICE_TX_CAPABILITY)); + } + + #[cfg(feature = "experimental-splicing")] + #[test] + fn splice_signing_capability_is_advertised_once() { + let signer = mk_signer(StateSignatureMode::Soft); + let init = HsmdInitReplyV4::from_vec(signer.get_init()).unwrap(); + + assert_eq!( + init.hsm_capabilities + .0 + .iter() + .filter(|capability| **capability == SIGN_SPLICE_TX_CAPABILITY) + .count(), + 1 + ); + } + fn heartbeat_raw() -> Vec { vls_protocol::msgs::GetHeartbeat {}.as_vec() } diff --git a/libs/gl-client/src/signer/model/cln.rs b/libs/gl-client/src/signer/model/cln.rs index b88d19712..d34abb756 100644 --- a/libs/gl-client/src/signer/model/cln.rs +++ b/libs/gl-client/src/signer/model/cln.rs @@ -45,6 +45,10 @@ pub fn decode_request(uri: &str, p: &[u8]) -> anyhow::Result { "/cln.Node/FundPsbt" => Request::FundPsbt(FundpsbtRequest::decode(p)?), "/cln.Node/SendPsbt" => Request::SendPsbt(SendpsbtRequest::decode(p)?), "/cln.Node/SignPsbt" => Request::SignPsbt(SignpsbtRequest::decode(p)?), + "/cln.Node/SpliceInit" => Request::SpliceInit(SpliceInitRequest::decode(p)?), + "/cln.Node/SpliceUpdate" => Request::SpliceUpdate(SpliceUpdateRequest::decode(p)?), + "/cln.Node/SpliceSigned" => Request::SpliceSigned(SpliceSignedRequest::decode(p)?), + "/cln.Node/DevSplice" => Request::DevSplice(DevspliceRequest::decode(p)?), "/cln.Node/UtxoPsbt" => Request::UtxoPsbt(UtxopsbtRequest::decode(p)?), "/cln.Node/TxDiscard" => Request::TxDiscard(TxdiscardRequest::decode(p)?), "/cln.Node/TxPrepare" => Request::TxPrepare(TxprepareRequest::decode(p)?), @@ -70,3 +74,88 @@ pub fn decode_request(uri: &str, p: &[u8]) -> anyhow::Result { uri => return Err(anyhow!("Unknown URI {}, can't decode payload", uri)), }) } + +#[cfg(test)] +mod tests { + use super::*; + use prost::Message; + + #[test] + fn decodes_splice_rpc_context_requests() { + let mut payload = Vec::new(); + SpliceInitRequest { + channel_id: vec![1; 32], + relative_amount: 50_000, + initialpsbt: Some("init-psbt".to_string()), + feerate_per_kw: Some(253), + force_feerate: Some(false), + } + .encode(&mut payload) + .unwrap(); + match decode_request("/cln.Node/SpliceInit", &payload).unwrap() { + Request::SpliceInit(req) => { + assert_eq!(req.channel_id, vec![1; 32]); + assert_eq!(req.relative_amount, 50_000); + assert_eq!(req.initialpsbt.as_deref(), Some("init-psbt")); + } + other => panic!("unexpected request: {:?}", other), + } + + payload.clear(); + SpliceUpdateRequest { + channel_id: vec![2; 32], + psbt: "update-psbt".to_string(), + } + .encode(&mut payload) + .unwrap(); + match decode_request("/cln.Node/SpliceUpdate", &payload).unwrap() { + Request::SpliceUpdate(req) => { + assert_eq!(req.channel_id, vec![2; 32]); + assert_eq!(req.psbt, "update-psbt"); + } + other => panic!("unexpected request: {:?}", other), + } + + payload.clear(); + SpliceSignedRequest { + channel_id: vec![3; 32], + psbt: "signed-psbt".to_string(), + sign_first: Some(true), + } + .encode(&mut payload) + .unwrap(); + match decode_request("/cln.Node/SpliceSigned", &payload).unwrap() { + Request::SpliceSigned(req) => { + assert_eq!(req.channel_id, vec![3; 32]); + assert_eq!(req.psbt, "signed-psbt"); + assert_eq!(req.sign_first, Some(true)); + } + other => panic!("unexpected request: {:?}", other), + } + + payload.clear(); + DevspliceRequest { + script_or_json: "script".to_string(), + dryrun: Some(true), + force_feerate: Some(false), + debug_log: Some(true), + dev_wetrun: Some(false), + } + .encode(&mut payload) + .unwrap(); + match decode_request("/cln.Node/DevSplice", &payload).unwrap() { + Request::DevSplice(req) => { + assert_eq!(req.script_or_json, "script"); + assert_eq!(req.dryrun, Some(true)); + } + other => panic!("unexpected request: {:?}", other), + } + } + + #[test] + fn unknown_splice_rpc_context_request_fails_closed() { + let err = decode_request("/cln.Node/SpliceUnknown", &[]).unwrap_err(); + assert!(err.to_string().contains("Unknown URI")); + assert!(err.to_string().contains("/cln.Node/SpliceUnknown")); + } +} diff --git a/libs/gl-client/src/signer/model/mod.rs b/libs/gl-client/src/signer/model/mod.rs index 8a8b47b41..e5ad0eb48 100644 --- a/libs/gl-client/src/signer/model/mod.rs +++ b/libs/gl-client/src/signer/model/mod.rs @@ -44,6 +44,10 @@ pub enum Request { FundPsbt(cln::FundpsbtRequest), SendPsbt(cln::SendpsbtRequest), SignPsbt(cln::SignpsbtRequest), + SpliceInit(cln::SpliceInitRequest), + SpliceUpdate(cln::SpliceUpdateRequest), + SpliceSigned(cln::SpliceSignedRequest), + DevSplice(cln::DevspliceRequest), UtxoPsbt(cln::UtxopsbtRequest), TxDiscard(cln::TxdiscardRequest), TxPrepare(cln::TxprepareRequest), diff --git a/libs/gl-client/src/signer/resolve.rs b/libs/gl-client/src/signer/resolve.rs index 4534c7099..eb137bf51 100644 --- a/libs/gl-client/src/signer/resolve.rs +++ b/libs/gl-client/src/signer/resolve.rs @@ -1,19 +1,57 @@ //! Resolver utilities to match incoming requests against the request //! context and find a justifications. +use crate::pb::HsmRequestContext; +use crate::persist::State; +use crate::signer::splice_policy::{self, SpliceOperation, SplicePolicyDecision}; use crate::signer::{model::Request, Error}; use vls_protocol::msgs::Message; pub struct Resolver {} impl Resolver { + #[allow(clippy::result_large_err)] + fn enforce_splice_decision( + operation: SpliceOperation, + decision: SplicePolicyDecision, + ) -> Result<(), Error> { + match decision { + SplicePolicyDecision::NotSpliceRelated => Ok(()), + SplicePolicyDecision::Rejected(violation) => Err(Error::SplicePolicy { + operation: operation.as_str().to_string(), + violation: format!("{violation:?}"), + }), + SplicePolicyDecision::RequiresVlsProof(proof) => Err(Error::VlsSpliceUnavailable { + operation: operation.as_str().to_string(), + required_proof: proof.as_str().to_string(), + }), + } + } + /// Attempt to find a resolution for a given request. We default /// to failing, and allowlist individual matches between pending /// context requests and the signer request being resolved. Where /// possible we also verify the contents of the request against /// the contents of the context request. TODOs in here may /// indicate ways to strengthen the verification. - pub fn try_resolve(req: &Message, reqctx: &Vec) -> Result<(), Error> { + pub fn try_resolve( + req: &Message, + reqctx: &[Request], + state: &State, + local_node_id: &[u8], + hsm_context: Option<&HsmRequestContext>, + ) -> Result<(), Error> { log::trace!("Resolving {:?}", req); + if let Some(operation) = splice_policy::operation(req) { + let splice_decision = + splice_policy::classify(req, reqctx, state, local_node_id, hsm_context).map_err( + |e| Error::SplicePolicy { + operation: operation.as_str().to_string(), + violation: format!("classification failed: {e}"), + }, + )?; + Self::enforce_splice_decision(operation, splice_decision)?; + } + // Some requests do not need a justification. For example we // reconnect automatically, so there may not even be a context // request pending which would skip the entire stack below, so @@ -82,10 +120,10 @@ impl Resolver { // later on } (Message::SignInvoice(_l), Request::LspInvoice(_r)) => { - // TODO: This could also need some - // strengthening. See below. - true - } + // TODO: This could also need some + // strengthening. See below. + true + } (Message::SignInvoice(_l), Request::Invoice(_r)) => { // TODO: This could be strengthened by parsing the // invoice from `l.u5bytes` and verify the @@ -118,3 +156,70 @@ impl Resolver { Err(Error::Resolver(ser, reqctx.to_vec())) } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::signer::splice_policy::{ + SpliceOperation, SplicePolicyDecision, SplicePolicyViolation, VlsProof, + }; + + #[test] + fn rejected_splice_decision_is_a_policy_error() { + let result = Resolver::enforce_splice_decision( + SpliceOperation::SignSpliceTx, + SplicePolicyDecision::Rejected(SplicePolicyViolation::CandidateMismatch), + ); + + assert!(matches!( + result, + Err(Error::SplicePolicy { + operation, + violation, + }) if operation == "sign_splice_tx" && violation == "CandidateMismatch" + )); + } + + #[test] + fn required_vls_proof_stops_at_vls_boundary() { + // TODO: Remove this stub-boundary test once VLS splice support is in place. + let result = Resolver::enforce_splice_decision( + SpliceOperation::SignSpliceTx, + SplicePolicyDecision::RequiresVlsProof(VlsProof::SpliceSigning), + ); + + assert!(matches!( + result, + Err(Error::VlsSpliceUnavailable { + operation, + required_proof, + }) if operation == "sign_splice_tx" && required_proof == "splice_signing" + )); + } + + #[test] + fn unresolved_peer_proof_stops_at_vls_boundary() { + // TODO: Remove this stub-boundary test once VLS splice support is in place. + let result = Resolver::enforce_splice_decision( + SpliceOperation::SignSpliceTx, + SplicePolicyDecision::RequiresVlsProof(VlsProof::NoLocalLoss), + ); + + assert!(matches!( + result, + Err(Error::VlsSpliceUnavailable { + operation, + required_proof, + }) if operation == "sign_splice_tx" && required_proof == "no_local_loss" + )); + } + + #[test] + fn unrelated_decision_continues_through_legacy_resolver() { + Resolver::enforce_splice_decision( + SpliceOperation::SignSpliceTx, + SplicePolicyDecision::NotSpliceRelated, + ) + .unwrap(); + } +} diff --git a/libs/gl-client/src/signer/splice_policy.rs b/libs/gl-client/src/signer/splice_policy.rs new file mode 100644 index 000000000..41a1fe403 --- /dev/null +++ b/libs/gl-client/src/signer/splice_policy.rs @@ -0,0 +1,1420 @@ +use crate::pb::HsmRequestContext; +use crate::persist::{SpliceOrigin, SplicePhase, SpliceSessionV1, State}; +use crate::psbt::ParsedPsbt; +use crate::signer::model::Request; +use anyhow::{anyhow, bail}; +use lightning_signer::bitcoin::secp256k1::PublicKey; +use lightning_signer::channel::ChannelId; +use vls_protocol::msgs::{ + CheckOutpoint, LockOutpoint, Message, SetupChannel, SignSpliceTx, SignWithdrawal, +}; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum SpliceOperation { + SignWithdrawal, + SetupChannel, + SignSpliceTx, + CheckOutpoint, + LockOutpoint, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum VlsProof { + SpliceSigning, + CandidateFunding, + NoLocalLoss, + OutpointBurial, + OutpointLock, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) enum SplicePolicyViolation { + InvalidHsmContext, + MissingSpliceSession, + MissingSpliceIntent, + InvalidPhase, + MissingSignPsbtIntent, + PsbtFingerprintMismatch, + UnknownWalletInput, + SignOnlyViolation, + OldFundingInputSelected, + TxPsbtMismatch, + OldFundingInputMismatch, + CandidateMismatch, + PeerLocalLoss, + DevSpliceUnresolved, + UnknownOutpoint, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) enum SplicePolicyDecision { + NotSpliceRelated, + RequiresVlsProof(VlsProof), + Rejected(SplicePolicyViolation), +} + +impl SpliceOperation { + pub(crate) fn as_str(self) -> &'static str { + match self { + Self::SignWithdrawal => "sign_withdrawal", + Self::SetupChannel => "setup_channel", + Self::SignSpliceTx => "sign_splice_tx", + Self::CheckOutpoint => "check_outpoint", + Self::LockOutpoint => "lock_outpoint", + } + } +} + +impl VlsProof { + pub(crate) fn as_str(self) -> &'static str { + match self { + Self::SpliceSigning => "splice_signing", + Self::CandidateFunding => "candidate_funding", + Self::NoLocalLoss => "no_local_loss", + Self::OutpointBurial => "outpoint_burial", + Self::OutpointLock => "outpoint_lock", + } + } +} + +pub(crate) fn operation(message: &Message) -> Option { + match message { + Message::SignWithdrawal(_) => Some(SpliceOperation::SignWithdrawal), + Message::SetupChannel(_) => Some(SpliceOperation::SetupChannel), + Message::SignSpliceTx(_) => Some(SpliceOperation::SignSpliceTx), + Message::CheckOutpoint(_) => Some(SpliceOperation::CheckOutpoint), + Message::LockOutpoint(_) => Some(SpliceOperation::LockOutpoint), + _ => None, + } +} + +pub(crate) fn node_channel_id_hex( + local_node_id: &[u8], + context: &HsmRequestContext, +) -> anyhow::Result { + let local_node_id = PublicKey::from_slice(local_node_id) + .map_err(|e| anyhow!("invalid local node id in signer context: {e}"))?; + let peer_id: [u8; 33] = context.node_id.as_slice().try_into().map_err(|_| { + anyhow!( + "invalid peer id length in HSM context: expected 33 bytes, got {}", + context.node_id.len() + ) + })?; + if context.dbid == 0 { + bail!("channel-scoped HSM context has zero dbid"); + } + let channel_id = ChannelId::new_from_peer_id_and_oid(&peer_id, context.dbid); + Ok(hex::encode( + vls_persist::model::NodeChannelId::new(&local_node_id, &channel_id).0, + )) +} + +pub(crate) fn classify( + message: &Message, + pending_requests: &[Request], + state: &State, + local_node_id: &[u8], + hsm_context: Option<&HsmRequestContext>, +) -> anyhow::Result { + match message { + Message::SignWithdrawal(request) => { + classify_sign_withdrawal(request, pending_requests, state) + } + Message::SetupChannel(request) => { + classify_setup_channel(request, state, local_node_id, hsm_context) + } + Message::SignSpliceTx(request) => { + classify_sign_splice_tx(request, pending_requests, state, local_node_id, hsm_context) + } + Message::CheckOutpoint(request) => { + classify_check_outpoint(request, state, local_node_id, hsm_context) + } + Message::LockOutpoint(request) => { + classify_lock_outpoint(request, state, local_node_id, hsm_context) + } + _ => Ok(SplicePolicyDecision::NotSpliceRelated), + } +} + +fn reject(violation: SplicePolicyViolation) -> anyhow::Result { + Ok(SplicePolicyDecision::Rejected(violation)) +} + +fn requires_vls_proof(proof: VlsProof) -> anyhow::Result { + Ok(SplicePolicyDecision::RequiresVlsProof(proof)) +} + +fn channel_session( + state: &State, + local_node_id: &[u8], + hsm_context: Option<&HsmRequestContext>, +) -> anyhow::Result> { + let Some(context) = hsm_context else { + return Ok(None); + }; + let Ok(node_channel_id_hex) = node_channel_id_hex(local_node_id, context) else { + return Ok(None); + }; + let session = state.get_splice_session(&node_channel_id_hex)?; + Ok(session.map(|session| (node_channel_id_hex, session))) +} + +fn session_violation( + session: &SpliceSessionV1, + allowed_phases: &[SplicePhase], +) -> Option { + if session.origin == SpliceOrigin::DevSpliceUnresolved { + return Some(SplicePolicyViolation::DevSpliceUnresolved); + } + if !allowed_phases.contains(&session.phase) { + return Some(SplicePolicyViolation::InvalidPhase); + } + if session.origin == SpliceOrigin::LocalInitiator + && session.intent.authorized_relative_amount_sat.is_none() + { + return Some(SplicePolicyViolation::MissingSpliceIntent); + } + None +} + +fn peer_policy_decision(session: &SpliceSessionV1) -> Option { + if session.origin != SpliceOrigin::PeerInitiated { + return None; + } + if !session.delta.computed { + return Some(SplicePolicyDecision::RequiresVlsProof( + VlsProof::NoLocalLoss, + )); + } + if !session.delta.no_local_loss { + return Some(SplicePolicyDecision::Rejected( + SplicePolicyViolation::PeerLocalLoss, + )); + } + None +} + +fn classify_setup_channel( + request: &SetupChannel, + state: &State, + local_node_id: &[u8], + hsm_context: Option<&HsmRequestContext>, +) -> anyhow::Result { + let txid = request.funding_txid.to_string(); + let by_outpoint = state.get_splice_by_outpoint(&txid, request.funding_txout as u32)?; + let by_context = channel_session(state, local_node_id, hsm_context)?; + let session = match (by_outpoint, by_context) { + (None, None) => return Ok(SplicePolicyDecision::NotSpliceRelated), + (Some(session), Some((context_id, _))) if context_id == session.node_channel_id_hex => { + session + } + (Some(_), _) => return reject(SplicePolicyViolation::InvalidHsmContext), + (None, Some((_, session))) => session, + }; + + if let Some(violation) = session_violation( + &session, + &[ + SplicePhase::Negotiating, + SplicePhase::CommitmentsSecured, + SplicePhase::SignaturesExchanging, + ], + ) { + return reject(violation); + } + let Some(candidate_outpoint) = session.cand.funding_outpoint.as_ref() else { + return requires_vls_proof(VlsProof::CandidateFunding); + }; + let Some(candidate_value_sat) = session.cand.value_sat else { + return requires_vls_proof(VlsProof::CandidateFunding); + }; + if candidate_outpoint.txid != txid + || candidate_outpoint.vout != request.funding_txout as u32 + || candidate_value_sat != request.channel_value + { + return reject(SplicePolicyViolation::CandidateMismatch); + } + if let Some(remote_funding_key_hex) = session.cand.remote_funding_key_hex.as_deref() { + if remote_funding_key_hex != hex::encode(request.remote_funding_pubkey.0) { + return reject(SplicePolicyViolation::CandidateMismatch); + } + } + if let Some(decision) = peer_policy_decision(&session) { + return Ok(decision); + } + + requires_vls_proof(VlsProof::CandidateFunding) +} + +fn pending_splice_psbt_matches( + pending_requests: &[Request], + session: &SpliceSessionV1, + expected_fingerprint: &str, +) -> bool { + pending_requests.iter().any(|pending| { + let (channel_id, psbt) = match pending { + Request::SpliceInit(request) => { + let Some(psbt) = request.initialpsbt.as_deref() else { + return false; + }; + (request.channel_id.as_slice(), psbt) + } + Request::SpliceUpdate(request) => { + (request.channel_id.as_slice(), request.psbt.as_str()) + } + Request::SpliceSigned(request) => { + (request.channel_id.as_slice(), request.psbt.as_str()) + } + _ => return false, + }; + hex::encode(channel_id) == session.channel_id_hex + && ParsedPsbt::from_base64(psbt) + .map(|psbt| psbt.fingerprint().to_string() == expected_fingerprint) + .unwrap_or(false) + }) +} + +fn pending_splice_update_psbt_matches( + pending_requests: &[Request], + session: &SpliceSessionV1, + expected_fingerprint: &str, +) -> bool { + pending_requests.iter().any(|pending| { + let Request::SpliceUpdate(request) = pending else { + return false; + }; + let pending_channel_id_hex = hex::encode(&request.channel_id); + let pending_fingerprint = ParsedPsbt::from_base64(&request.psbt) + .map(|psbt| psbt.fingerprint().to_string()) + .ok(); + log::debug!( + "matching negotiating splice_update: channel_match={}, fingerprint_match={}, pending_fingerprint={:?}, expected_fingerprint={}", + pending_channel_id_hex == session.channel_id_hex, + pending_fingerprint.as_deref() == Some(expected_fingerprint), + pending_fingerprint, + expected_fingerprint, + ); + pending_channel_id_hex == session.channel_id_hex + && pending_fingerprint.as_deref() == Some(expected_fingerprint) + }) +} + +fn classify_sign_splice_tx( + request: &SignSpliceTx, + pending_requests: &[Request], + state: &State, + local_node_id: &[u8], + hsm_context: Option<&HsmRequestContext>, +) -> anyhow::Result { + let Some(context) = hsm_context else { + return reject(SplicePolicyViolation::InvalidHsmContext); + }; + let node_channel_id_hex = match node_channel_id_hex(local_node_id, context) { + Ok(node_channel_id_hex) => node_channel_id_hex, + Err(_) => return reject(SplicePolicyViolation::InvalidHsmContext), + }; + let Some(session) = state.get_splice_session(&node_channel_id_hex)? else { + return reject(SplicePolicyViolation::MissingSpliceSession); + }; + if let Some(violation) = session_violation( + &session, + &[ + SplicePhase::Negotiating, + SplicePhase::CommitmentsSecured, + SplicePhase::SignaturesExchanging, + ], + ) { + return reject(violation); + } + + let psbt = &request.psbt.0.inner; + let psbt_fingerprint = psbt.unsigned_tx.compute_txid().to_string(); + if request.tx.0.compute_txid().to_string() != psbt_fingerprint { + return reject(SplicePolicyViolation::TxPsbtMismatch); + } + let expected_fingerprint = session + .psbt + .frozen_fingerprint + .as_deref() + .or(session.psbt.candidate_fingerprint.as_deref()); + if expected_fingerprint != Some(psbt_fingerprint.as_str()) { + return reject(SplicePolicyViolation::PsbtFingerprintMismatch); + } + if session.phase == SplicePhase::Negotiating + && session.origin == SpliceOrigin::LocalInitiator + && !pending_splice_update_psbt_matches(pending_requests, &session, &psbt_fingerprint) + { + return reject(SplicePolicyViolation::InvalidPhase); + } + if session.origin == SpliceOrigin::LocalInitiator + && !pending_splice_psbt_matches(pending_requests, &session, &psbt_fingerprint) + { + return reject(SplicePolicyViolation::MissingSpliceIntent); + } + + let old_input_indices: Vec = request + .tx + .0 + .input + .iter() + .enumerate() + .filter_map(|(index, input)| { + let old = &session.old.funding_outpoint; + (input.previous_output.txid.to_string() == old.txid + && input.previous_output.vout == old.vout) + .then_some(index) + }) + .collect(); + if old_input_indices.as_slice() != [request.input_index as usize] { + return reject(SplicePolicyViolation::OldFundingInputMismatch); + } + match session.cand.sign_splice_tx_input_index { + Some(input_index) if input_index != request.input_index => { + return reject(SplicePolicyViolation::OldFundingInputMismatch) + } + None => return requires_vls_proof(VlsProof::CandidateFunding), + Some(_) => {} + } + + let Some(candidate_outpoint) = session.cand.funding_outpoint.as_ref() else { + return requires_vls_proof(VlsProof::CandidateFunding); + }; + let Some(candidate_value_sat) = session.cand.value_sat else { + return requires_vls_proof(VlsProof::CandidateFunding); + }; + let Some(candidate_script_hash) = session.cand.script_pubkey_hash.as_deref() else { + return requires_vls_proof(VlsProof::CandidateFunding); + }; + let Some(candidate_output) = request.tx.0.output.get(candidate_outpoint.vout as usize) else { + return reject(SplicePolicyViolation::CandidateMismatch); + }; + if candidate_outpoint.txid != request.tx.0.compute_txid().to_string() + || candidate_output.value.to_sat() != candidate_value_sat + || sha256::digest(candidate_output.script_pubkey.as_bytes()) != candidate_script_hash + { + return reject(SplicePolicyViolation::CandidateMismatch); + } + if let Some(remote_funding_key_hex) = session.cand.remote_funding_key_hex.as_deref() { + if remote_funding_key_hex != hex::encode(request.remote_funding_key.0) { + return reject(SplicePolicyViolation::CandidateMismatch); + } + } + if let Some(decision) = peer_policy_decision(&session) { + return Ok(decision); + } + + requires_vls_proof(VlsProof::SpliceSigning) +} + +fn classify_check_outpoint( + request: &CheckOutpoint, + state: &State, + local_node_id: &[u8], + hsm_context: Option<&HsmRequestContext>, +) -> anyhow::Result { + classify_outpoint( + request.funding_txid.to_string(), + request.funding_txout as u32, + state, + local_node_id, + hsm_context, + VlsProof::OutpointBurial, + ) +} + +fn classify_lock_outpoint( + request: &LockOutpoint, + state: &State, + local_node_id: &[u8], + hsm_context: Option<&HsmRequestContext>, +) -> anyhow::Result { + classify_outpoint( + request.funding_txid.to_string(), + request.funding_txout as u32, + state, + local_node_id, + hsm_context, + VlsProof::OutpointLock, + ) +} + +fn classify_outpoint( + txid: String, + vout: u32, + state: &State, + local_node_id: &[u8], + hsm_context: Option<&HsmRequestContext>, + proof: VlsProof, +) -> anyhow::Result { + let Some(session) = state.get_splice_by_outpoint(&txid, vout)? else { + if channel_session(state, local_node_id, hsm_context)?.is_some() { + return reject(SplicePolicyViolation::UnknownOutpoint); + } + return Ok(SplicePolicyDecision::NotSpliceRelated); + }; + if let Some(context) = hsm_context { + if context.dbid != 0 { + let context_id = match node_channel_id_hex(local_node_id, context) { + Ok(context_id) => context_id, + Err(_) => return reject(SplicePolicyViolation::InvalidHsmContext), + }; + if context_id != session.node_channel_id_hex { + return reject(SplicePolicyViolation::InvalidHsmContext); + } + } + } + if let Some(violation) = session_violation(&session, &[SplicePhase::PendingLock]) { + return reject(violation); + } + if session + .cand + .funding_outpoint + .as_ref() + .map(|outpoint| (&outpoint.txid, outpoint.vout)) + != Some((&txid, vout)) + { + return reject(SplicePolicyViolation::UnknownOutpoint); + } + if let Some(decision) = peer_policy_decision(&session) { + return Ok(decision); + } + + requires_vls_proof(proof) +} + +fn classify_sign_withdrawal( + request: &SignWithdrawal, + pending_requests: &[Request], + state: &State, +) -> anyhow::Result { + let psbt = &request.psbt.0.psbt.inner; + let psbt_fingerprint = psbt.unsigned_tx.compute_txid().to_string(); + let Some(context) = state.get_psbt_context(&psbt_fingerprint)? else { + return Ok(SplicePolicyDecision::NotSpliceRelated); + }; + let Some(node_channel_id_hex) = context.linked_node_channel_id_hex.as_deref() else { + return Ok(SplicePolicyDecision::NotSpliceRelated); + }; + let Some(session) = state.get_splice_session(node_channel_id_hex)? else { + return reject(SplicePolicyViolation::MissingSpliceSession); + }; + if let Some(violation) = session_violation( + &session, + &[ + SplicePhase::CommitmentsSecured, + SplicePhase::SignaturesExchanging, + ], + ) { + return reject(violation); + } + if !session + .linked_wallet_psbt_fingerprints + .iter() + .any(|fingerprint| fingerprint == &psbt_fingerprint) + { + return reject(SplicePolicyViolation::PsbtFingerprintMismatch); + } + let expected_splice_fingerprint = session + .psbt + .frozen_fingerprint + .as_deref() + .or(session.psbt.candidate_fingerprint.as_deref()); + if expected_splice_fingerprint != Some(psbt_fingerprint.as_str()) { + return reject(SplicePolicyViolation::PsbtFingerprintMismatch); + } + + let matching_signpsbt = pending_requests.iter().any(|pending| { + let Request::SignPsbt(pending) = pending else { + return false; + }; + ParsedPsbt::from_base64(&pending.psbt) + .map(|pending_psbt| { + pending_psbt.fingerprint().to_string() == psbt_fingerprint + && pending.signonly == context.signonly + }) + .unwrap_or(false) + }); + if !matching_signpsbt { + return reject(SplicePolicyViolation::MissingSignPsbtIntent); + } + + for utxo in request.utxos.iter() { + let txid = utxo.txid.to_string(); + if session.old.funding_outpoint.txid == txid + && session.old.funding_outpoint.vout == utxo.outnum + { + return reject(SplicePolicyViolation::OldFundingInputSelected); + } + let Some(input_index) = psbt.unsigned_tx.input.iter().position(|input| { + input.previous_output.txid == utxo.txid && input.previous_output.vout == utxo.outnum + }) else { + return reject(SplicePolicyViolation::UnknownWalletInput); + }; + let known_wallet_input = context.wallet_inputs.iter().any(|input| { + input.txid == txid && input.vout == utxo.outnum && input.value_sat == utxo.amount + }); + if !known_wallet_input { + return reject(SplicePolicyViolation::UnknownWalletInput); + } + if !context.signonly.is_empty() && !context.signonly.contains(&(input_index as u32)) { + return reject(SplicePolicyViolation::SignOnlyViolation); + } + } + + if let Some(decision) = peer_policy_decision(&session) { + return Ok(decision); + } + + requires_vls_proof(VlsProof::SpliceSigning) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::bitcoin::absolute::LockTime; + use crate::bitcoin::psbt::Psbt; + use crate::bitcoin::secp256k1::{PublicKey, Secp256k1, SecretKey}; + use crate::bitcoin::transaction::Version; + use crate::bitcoin::{ + Amount, OutPoint, ScriptBuf, Sequence, Transaction, TxIn, TxOut, Txid, Witness, + }; + use crate::persist::{ + CandidateFundingFacts, FeePolicy, FundPsbtResponseFacts, FundingOutpoint, + LocalSpliceIntent, OldSpliceState, SignPsbtIntentFacts, SpliceOrigin, SpliceSessionV1, + SpliceUpdateResponseFacts, State, WalletInput, + }; + use crate::signer::model::{ + cln::{SignpsbtRequest, SpliceSignedRequest, SpliceUpdateRequest}, + Request, + }; + use base64::{engine::general_purpose, Engine as _}; + use lightning_signer::channel::ChannelId; + use std::str::FromStr; + use vls_protocol::model::{Basepoints, PubKey, Utxo}; + use vls_protocol::msgs::{ + CheckOutpoint, LockOutpoint, Message, SetupChannel, SignSpliceTx, SignWithdrawal, + }; + use vls_protocol::psbt::{PsbtWrapper, StreamedPSBT}; + use vls_protocol::serde_bolt::{Array, Octets, WithSize}; + + fn input_outpoints(transaction: &Transaction) -> Vec { + transaction + .input + .iter() + .map(|input| FundingOutpoint { + txid: input.previous_output.txid.to_string(), + vout: input.previous_output.vout, + }) + .collect() + } + + fn psbt_fingerprint(psbt: &Psbt) -> String { + psbt.unsigned_tx.compute_txid().to_string() + } + + struct SpliceSigningFixture { + message: Message, + pending: Vec, + state: State, + local_node_id: Vec, + context: HsmRequestContext, + node_channel_id_hex: String, + } + + fn test_public_key(byte: u8) -> PublicKey { + PublicKey::from_secret_key( + &Secp256k1::signing_only(), + &SecretKey::from_slice(&[byte; 32]).unwrap(), + ) + } + + fn splice_signing_fixture( + origin: SpliceOrigin, + delta_computed: bool, + no_local_loss: bool, + ) -> SpliceSigningFixture { + let local_node_id = test_public_key(1).serialize().to_vec(); + let peer_id = test_public_key(2); + let remote_funding_key = test_public_key(3); + let context = HsmRequestContext { + node_id: peer_id.serialize().to_vec(), + dbid: 42, + capabilities: 0, + }; + let node_channel_id_hex = node_channel_id_hex(&local_node_id, &context).unwrap(); + let old_txid = Txid::from_str(&"11".repeat(32)).unwrap(); + let channel_id = vec![4; 32]; + let funding_script = ScriptBuf::from_hex( + "00203333333333333333333333333333333333333333333333333333333333333333", + ) + .unwrap(); + let tx = Transaction { + version: Version::TWO, + lock_time: LockTime::ZERO, + input: vec![TxIn { + previous_output: OutPoint { + txid: old_txid, + vout: 0, + }, + script_sig: ScriptBuf::new(), + sequence: Sequence::MAX, + witness: Witness::new(), + }], + output: vec![TxOut { + value: Amount::from_sat(1_020_000), + script_pubkey: funding_script, + }], + }; + let mut psbt = Psbt::from_unsigned_tx(tx.clone()).unwrap(); + psbt.inputs[0].witness_utxo = Some(TxOut { + value: Amount::from_sat(1_000_000), + script_pubkey: ScriptBuf::new(), + }); + let encoded_psbt = general_purpose::STANDARD.encode(psbt.serialize()); + let psbt_fingerprint = psbt_fingerprint(&psbt); + let psbt_input_outpoints = input_outpoints(&psbt.unsigned_tx); + let old = OldSpliceState { + funding_outpoint: FundingOutpoint { + txid: old_txid.to_string(), + vout: 0, + }, + channel_value_sat: 1_000_000, + local_balance_sat: 600_000, + }; + let mut state = State::new(); + match origin { + SpliceOrigin::LocalInitiator => state + .record_local_splice_intent(LocalSpliceIntent { + node_id_hex: hex::encode(&local_node_id), + channel_id_hex: hex::encode(&channel_id), + node_channel_id_hex: node_channel_id_hex.clone(), + old: old.clone(), + authorized_relative_amount_sat: 20_000, + fee_policy: FeePolicy::default(), + initial_psbt_fingerprint: psbt_fingerprint.clone(), + initial_psbt_input_outpoints: psbt_input_outpoints, + timestamp_ms: 1, + }) + .unwrap(), + SpliceOrigin::PeerInitiated | SpliceOrigin::DevSpliceUnresolved => { + let mut session = SpliceSessionV1::new( + origin.clone(), + hex::encode(&local_node_id), + hex::encode(&channel_id), + node_channel_id_hex.clone(), + old.clone(), + None, + FeePolicy::default(), + 1, + ); + session.psbt.candidate_fingerprint = Some(psbt_fingerprint.clone()); + session.delta.computed = delta_computed; + session.delta.no_local_loss = no_local_loss; + if origin == SpliceOrigin::PeerInitiated { + state.create_peer_splice_session(session).unwrap(); + } else { + state.create_dev_splice_session(session).unwrap(); + } + } + } + state + .freeze_splice_candidate( + &node_channel_id_hex, + psbt_fingerprint, + CandidateFundingFacts { + funding_outpoint: FundingOutpoint { + txid: tx.compute_txid().to_string(), + vout: 0, + }, + value_sat: tx.output[0].value.to_sat(), + script_pubkey_hash: sha256::digest(tx.output[0].script_pubkey.as_bytes()), + sign_splice_tx_input_index: 0, + remote_funding_key_hex: Some(hex::encode(remote_funding_key.serialize())), + }, + 2, + ) + .unwrap(); + + let pending = if origin == SpliceOrigin::LocalInitiator { + vec![Request::SpliceSigned(SpliceSignedRequest { + channel_id, + psbt: encoded_psbt, + sign_first: Some(true), + })] + } else { + Vec::new() + }; + let message = Message::SignSpliceTx(SignSpliceTx { + tx: WithSize(tx), + psbt: WithSize(PsbtWrapper::from(psbt)), + remote_funding_key: PubKey(remote_funding_key.serialize()), + input_index: 0, + }); + SpliceSigningFixture { + message, + pending, + state, + local_node_id, + context, + node_channel_id_hex, + } + } + + fn setup_channel_message(fixture: &SpliceSigningFixture) -> Message { + let session = fixture + .state + .get_splice_session(&fixture.node_channel_id_hex) + .unwrap() + .unwrap(); + let outpoint = session.cand.funding_outpoint.unwrap(); + let remote_key = PubKey(test_public_key(3).serialize()); + Message::SetupChannel(SetupChannel { + is_outbound: true, + channel_value: session.cand.value_sat.unwrap(), + push_value: 0, + funding_txid: Txid::from_str(&outpoint.txid).unwrap(), + funding_txout: outpoint.vout as u16, + to_self_delay: 6, + local_shutdown_script: Octets(Vec::new()), + local_shutdown_wallet_index: None, + remote_basepoints: Basepoints { + revocation: PubKey(test_public_key(5).serialize()), + payment: PubKey(test_public_key(6).serialize()), + htlc: PubKey(test_public_key(7).serialize()), + delayed_payment: PubKey(test_public_key(8).serialize()), + }, + remote_funding_pubkey: remote_key, + remote_to_self_delay: 6, + remote_shutdown_script: Octets(Vec::new()), + channel_type: Octets(Vec::new()), + }) + } + + fn set_negotiating_local_update(fixture: &mut SpliceSigningFixture) { + let session = fixture + .state + .get_splice_session(&fixture.node_channel_id_hex) + .unwrap() + .unwrap(); + let Request::SpliceSigned(signed) = fixture.pending[0].clone() else { + unreachable!(); + }; + let psbt = ParsedPsbt::from_base64(&signed.psbt).unwrap(); + let psbt_fingerprint = psbt.fingerprint().to_string(); + let psbt_input_outpoints = input_outpoints(psbt.unsigned_tx()); + let mut state = State::new(); + state + .record_local_splice_intent(LocalSpliceIntent { + node_id_hex: session.node_id_hex, + channel_id_hex: session.channel_id_hex, + node_channel_id_hex: session.node_channel_id_hex.clone(), + old: session.old, + authorized_relative_amount_sat: 20_000, + fee_policy: FeePolicy::default(), + initial_psbt_fingerprint: psbt_fingerprint.clone(), + initial_psbt_input_outpoints: psbt_input_outpoints.clone(), + timestamp_ms: 1, + }) + .unwrap(); + state + .record_splice_update_response(SpliceUpdateResponseFacts { + node_channel_id_hex: session.node_channel_id_hex, + psbt_fingerprint, + psbt_input_outpoints, + commitments_secured: false, + signatures_secured: Some(false), + timestamp_ms: 2, + }) + .unwrap(); + fixture.state = state; + fixture.pending = vec![Request::SpliceUpdate(SpliceUpdateRequest { + channel_id: signed.channel_id, + psbt: signed.psbt, + })]; + } + + fn sign_withdrawal_fixture_for_origin( + origin: SpliceOrigin, + delta_computed: bool, + no_local_loss: bool, + commitments_secured: bool, + ) -> (Message, Vec, State) { + let old_txid = Txid::from_str(&"11".repeat(32)).unwrap(); + let wallet_txid = Txid::from_str(&"22".repeat(32)).unwrap(); + let tx = Transaction { + version: Version::TWO, + lock_time: LockTime::ZERO, + input: vec![ + TxIn { + previous_output: OutPoint { + txid: old_txid, + vout: 0, + }, + script_sig: ScriptBuf::new(), + sequence: Sequence::MAX, + witness: Witness::new(), + }, + TxIn { + previous_output: OutPoint { + txid: wallet_txid, + vout: 1, + }, + script_sig: ScriptBuf::new(), + sequence: Sequence::MAX, + witness: Witness::new(), + }, + ], + output: vec![TxOut { + value: Amount::from_sat(1_020_000), + script_pubkey: ScriptBuf::from_hex("00143333333333333333333333333333333333333333") + .unwrap(), + }], + }; + let mut psbt = Psbt::from_unsigned_tx(tx).unwrap(); + psbt.inputs[0].witness_utxo = Some(TxOut { + value: Amount::from_sat(1_000_000), + script_pubkey: ScriptBuf::new(), + }); + psbt.inputs[1].witness_utxo = Some(TxOut { + value: Amount::from_sat(25_000), + script_pubkey: ScriptBuf::new(), + }); + let encoded_psbt = general_purpose::STANDARD.encode(psbt.serialize()); + let psbt_fingerprint = psbt_fingerprint(&psbt); + let psbt_input_outpoints = input_outpoints(&psbt.unsigned_tx); + let node_channel_id_hex = "44".repeat(74); + let old = OldSpliceState { + funding_outpoint: FundingOutpoint { + txid: old_txid.to_string(), + vout: 0, + }, + channel_value_sat: 1_000_000, + local_balance_sat: 600_000, + }; + let mut state = State::new(); + state + .record_fundpsbt_response(FundPsbtResponseFacts { + psbt_fingerprint: psbt_fingerprint.clone(), + wallet_inputs: vec![WalletInput { + txid: wallet_txid.to_string(), + vout: 1, + value_sat: 25_000, + reserved_to_block: Some(100), + }], + timestamp_ms: 1, + }) + .unwrap(); + match origin { + SpliceOrigin::LocalInitiator => state + .record_local_splice_intent(LocalSpliceIntent { + node_id_hex: "02".repeat(33), + channel_id_hex: "33".repeat(32), + node_channel_id_hex: node_channel_id_hex.clone(), + old: old.clone(), + authorized_relative_amount_sat: 20_000, + fee_policy: FeePolicy::default(), + initial_psbt_fingerprint: psbt_fingerprint.clone(), + initial_psbt_input_outpoints: psbt_input_outpoints.clone(), + timestamp_ms: 2, + }) + .unwrap(), + SpliceOrigin::PeerInitiated | SpliceOrigin::DevSpliceUnresolved => { + let mut session = SpliceSessionV1::new( + origin.clone(), + "02".repeat(33), + "33".repeat(32), + node_channel_id_hex.clone(), + old, + None, + FeePolicy::default(), + 2, + ); + session.delta.computed = delta_computed; + session.delta.no_local_loss = no_local_loss; + if origin == SpliceOrigin::PeerInitiated { + state.create_peer_splice_session(session).unwrap(); + } else { + state.create_dev_splice_session(session).unwrap(); + } + } + } + state + .record_splice_update_response(SpliceUpdateResponseFacts { + node_channel_id_hex, + psbt_fingerprint: psbt_fingerprint.clone(), + psbt_input_outpoints, + commitments_secured, + signatures_secured: Some(false), + timestamp_ms: 3, + }) + .unwrap(); + state + .record_signpsbt_intent(SignPsbtIntentFacts { + psbt_fingerprint, + signonly: vec![1], + timestamp_ms: 4, + }) + .unwrap(); + + let message = Message::SignWithdrawal(SignWithdrawal { + utxos: Array(vec![Utxo { + txid: wallet_txid, + outnum: 1, + amount: 25_000, + keyindex: 0, + is_p2sh: false, + script: Octets(vec![]), + close_info: None, + is_in_coinbase: false, + }]), + psbt: WithSize(StreamedPSBT::new(psbt)), + }); + let pending = vec![Request::SignPsbt(SignpsbtRequest { + psbt: encoded_psbt, + signonly: vec![1], + })]; + (message, pending, state) + } + + fn sign_withdrawal_fixture() -> (Message, Vec, State) { + sign_withdrawal_fixture_for_origin(SpliceOrigin::LocalInitiator, false, false, true) + } + + #[test] + fn hsm_context_resolves_canonical_node_channel_id() { + let secp = Secp256k1::signing_only(); + let local = PublicKey::from_secret_key(&secp, &SecretKey::from_slice(&[1; 32]).unwrap()); + let peer = PublicKey::from_secret_key(&secp, &SecretKey::from_slice(&[2; 32]).unwrap()); + let context = HsmRequestContext { + node_id: peer.serialize().to_vec(), + dbid: 42, + capabilities: 0, + }; + let channel_id = ChannelId::new_from_peer_id_and_oid(&peer.serialize(), 42); + let expected = hex::encode(vls_persist::model::NodeChannelId::new(&local, &channel_id).0); + + assert_eq!( + node_channel_id_hex(&local.serialize(), &context).unwrap(), + expected + ); + } + + #[test] + fn matching_splice_signwithdrawal_reaches_vls_boundary() { + // TODO: Remove this stub-boundary test once VLS splice support is in place. + let (message, pending, state) = sign_withdrawal_fixture(); + + assert_eq!( + classify(&message, &pending, &state, &[], None).unwrap(), + SplicePolicyDecision::RequiresVlsProof(VlsProof::SpliceSigning) + ); + } + + #[test] + fn unlinked_wallet_psbt_remains_non_splice() { + let (message, pending, state) = sign_withdrawal_fixture(); + let Message::SignWithdrawal(request) = &message else { + unreachable!(); + }; + let fingerprint = psbt_fingerprint(&request.psbt.0.psbt.inner); + let mut context = state + .get_psbt_context(&fingerprint) + .unwrap() + .unwrap(); + context.linked_node_channel_id_hex = None; + let mut unrelated_state = State::new(); + unrelated_state + .put_splice_wallet_psbt_context(&fingerprint, context) + .unwrap(); + + assert_eq!( + classify(&message, &pending, &unrelated_state, &[], None).unwrap(), + SplicePolicyDecision::NotSpliceRelated + ); + } + + #[test] + fn splice_signwithdrawal_without_current_signpsbt_rejects() { + let (message, _, state) = sign_withdrawal_fixture(); + + assert_eq!( + classify(&message, &[], &state, &[], None).unwrap(), + SplicePolicyDecision::Rejected(SplicePolicyViolation::MissingSignPsbtIntent) + ); + } + + #[test] + fn splice_signwithdrawal_outside_signonly_rejects() { + let (message, mut pending, mut state) = sign_withdrawal_fixture(); + let Message::SignWithdrawal(request) = &message else { + unreachable!(); + }; + let fingerprint = psbt_fingerprint(&request.psbt.0.psbt.inner); + let mut context = state + .get_psbt_context(&fingerprint) + .unwrap() + .unwrap(); + context.signonly = vec![0]; + state + .put_splice_wallet_psbt_context(&fingerprint, context) + .unwrap(); + let Request::SignPsbt(request) = &mut pending[0] else { + unreachable!(); + }; + request.signonly = vec![0]; + + assert_eq!( + classify(&message, &pending, &state, &[], None).unwrap(), + SplicePolicyDecision::Rejected(SplicePolicyViolation::SignOnlyViolation) + ); + } + + #[test] + fn splice_signwithdrawal_selecting_old_funding_input_rejects() { + let (mut message, pending, state) = sign_withdrawal_fixture(); + let Message::SignWithdrawal(request) = &mut message else { + unreachable!(); + }; + request.utxos.0[0].txid = Txid::from_str(&"11".repeat(32)).unwrap(); + request.utxos.0[0].outnum = 0; + request.utxos.0[0].amount = 1_000_000; + + assert_eq!( + classify(&message, &pending, &state, &[], None).unwrap(), + SplicePolicyDecision::Rejected(SplicePolicyViolation::OldFundingInputSelected) + ); + } + + #[test] + fn unresolved_peer_signwithdrawal_requires_no_local_loss_proof() { + // TODO: Remove this stub-boundary test once VLS splice support is in place. + let (message, pending, state) = + sign_withdrawal_fixture_for_origin(SpliceOrigin::PeerInitiated, false, false, true); + + assert_eq!( + classify(&message, &pending, &state, &[], None).unwrap(), + SplicePolicyDecision::RequiresVlsProof(VlsProof::NoLocalLoss) + ); + } + + #[test] + fn peer_signwithdrawal_with_local_loss_rejects() { + let (message, pending, state) = + sign_withdrawal_fixture_for_origin(SpliceOrigin::PeerInitiated, true, false, true); + + assert_eq!( + classify(&message, &pending, &state, &[], None).unwrap(), + SplicePolicyDecision::Rejected(SplicePolicyViolation::PeerLocalLoss) + ); + } + + #[test] + fn unresolved_dev_splice_signwithdrawal_rejects() { + let (message, pending, state) = sign_withdrawal_fixture_for_origin( + SpliceOrigin::DevSpliceUnresolved, + false, + false, + true, + ); + + assert_eq!( + classify(&message, &pending, &state, &[], None).unwrap(), + SplicePolicyDecision::Rejected(SplicePolicyViolation::DevSpliceUnresolved) + ); + } + + #[test] + fn matching_local_sign_splice_tx_reaches_vls_boundary() { + // TODO: Remove this stub-boundary test once VLS splice support is in place. + let fixture = splice_signing_fixture(SpliceOrigin::LocalInitiator, false, false); + + assert_eq!( + classify( + &fixture.message, + &fixture.pending, + &fixture.state, + &fixture.local_node_id, + Some(&fixture.context), + ) + .unwrap(), + SplicePolicyDecision::RequiresVlsProof(VlsProof::SpliceSigning) + ); + } + + #[test] + fn final_splice_update_signing_reaches_candidate_funding_boundary() { + // TODO: Remove this stub-boundary test once VLS splice support is in place. + let mut fixture = splice_signing_fixture(SpliceOrigin::LocalInitiator, false, false); + set_negotiating_local_update(&mut fixture); + + assert_eq!( + classify( + &fixture.message, + &fixture.pending, + &fixture.state, + &fixture.local_node_id, + Some(&fixture.context), + ) + .unwrap(), + SplicePolicyDecision::RequiresVlsProof(VlsProof::CandidateFunding) + ); + } + + #[test] + fn local_sign_splice_tx_without_current_splice_rpc_rejects() { + let mut fixture = splice_signing_fixture(SpliceOrigin::LocalInitiator, false, false); + fixture.pending.clear(); + + assert_eq!( + classify( + &fixture.message, + &fixture.pending, + &fixture.state, + &fixture.local_node_id, + Some(&fixture.context), + ) + .unwrap(), + SplicePolicyDecision::Rejected(SplicePolicyViolation::MissingSpliceIntent) + ); + } + + #[test] + fn unresolved_dev_splice_transaction_signing_rejects() { + let fixture = splice_signing_fixture(SpliceOrigin::DevSpliceUnresolved, false, false); + + assert_eq!( + classify( + &fixture.message, + &fixture.pending, + &fixture.state, + &fixture.local_node_id, + Some(&fixture.context), + ) + .unwrap(), + SplicePolicyDecision::Rejected(SplicePolicyViolation::DevSpliceUnresolved) + ); + } + + #[test] + fn sign_splice_tx_with_wrong_old_funding_input_index_rejects() { + let mut fixture = splice_signing_fixture(SpliceOrigin::LocalInitiator, false, false); + let Message::SignSpliceTx(request) = &mut fixture.message else { + unreachable!(); + }; + request.input_index = 1; + + assert_eq!( + classify( + &fixture.message, + &fixture.pending, + &fixture.state, + &fixture.local_node_id, + Some(&fixture.context), + ) + .unwrap(), + SplicePolicyDecision::Rejected(SplicePolicyViolation::OldFundingInputMismatch) + ); + } + + #[test] + fn sign_splice_tx_with_different_transaction_and_psbt_rejects() { + let mut fixture = splice_signing_fixture(SpliceOrigin::LocalInitiator, false, false); + let Message::SignSpliceTx(request) = &mut fixture.message else { + unreachable!(); + }; + request.tx.0.output[0].value = Amount::from_sat(1_019_999); + + assert_eq!( + classify( + &fixture.message, + &fixture.pending, + &fixture.state, + &fixture.local_node_id, + Some(&fixture.context), + ) + .unwrap(), + SplicePolicyDecision::Rejected(SplicePolicyViolation::TxPsbtMismatch) + ); + } + + #[test] + fn unresolved_peer_sign_splice_tx_requires_no_local_loss_proof() { + // TODO: Remove this stub-boundary test once VLS splice support is in place. + let fixture = splice_signing_fixture(SpliceOrigin::PeerInitiated, false, false); + + assert_eq!( + classify( + &fixture.message, + &fixture.pending, + &fixture.state, + &fixture.local_node_id, + Some(&fixture.context), + ) + .unwrap(), + SplicePolicyDecision::RequiresVlsProof(VlsProof::NoLocalLoss) + ); + } + + #[test] + fn peer_sign_splice_tx_with_local_loss_rejects() { + let fixture = splice_signing_fixture(SpliceOrigin::PeerInitiated, true, false); + + assert_eq!( + classify( + &fixture.message, + &fixture.pending, + &fixture.state, + &fixture.local_node_id, + Some(&fixture.context), + ) + .unwrap(), + SplicePolicyDecision::Rejected(SplicePolicyViolation::PeerLocalLoss) + ); + } + + #[test] + fn matching_splice_setup_channel_reaches_vls_boundary() { + // TODO: Remove this stub-boundary test once VLS splice support is in place. + let fixture = splice_signing_fixture(SpliceOrigin::LocalInitiator, false, false); + let message = setup_channel_message(&fixture); + + assert_eq!( + classify( + &message, + &fixture.pending, + &fixture.state, + &fixture.local_node_id, + Some(&fixture.context), + ) + .unwrap(), + SplicePolicyDecision::RequiresVlsProof(VlsProof::CandidateFunding) + ); + } + + #[test] + fn splice_setup_channel_with_candidate_value_mismatch_rejects() { + let fixture = splice_signing_fixture(SpliceOrigin::LocalInitiator, false, false); + let mut message = setup_channel_message(&fixture); + let Message::SetupChannel(request) = &mut message else { + unreachable!(); + }; + request.channel_value += 1; + + assert_eq!( + classify( + &message, + &fixture.pending, + &fixture.state, + &fixture.local_node_id, + Some(&fixture.context), + ) + .unwrap(), + SplicePolicyDecision::Rejected(SplicePolicyViolation::CandidateMismatch) + ); + } + + #[test] + fn unrelated_setup_channel_remains_non_splice() { + let fixture = splice_signing_fixture(SpliceOrigin::LocalInitiator, false, false); + let message = setup_channel_message(&fixture); + + assert_eq!( + classify( + &message, + &[], + &State::new(), + &fixture.local_node_id, + Some(&fixture.context), + ) + .unwrap(), + SplicePolicyDecision::NotSpliceRelated + ); + } + + #[test] + fn known_splice_outpoint_requests_reach_vls_boundaries() { + // TODO: Remove this stub-boundary test once VLS splice support is in place. + let mut fixture = splice_signing_fixture(SpliceOrigin::LocalInitiator, false, false); + fixture + .state + .mark_splice_pending_lock(&fixture.node_channel_id_hex, 3) + .unwrap(); + let session = fixture + .state + .get_splice_session(&fixture.node_channel_id_hex) + .unwrap() + .unwrap(); + let outpoint = session.cand.funding_outpoint.unwrap(); + let funding_txid = Txid::from_str(&outpoint.txid).unwrap(); + let funding_txout = outpoint.vout as u16; + let cases = [ + ( + Message::CheckOutpoint(CheckOutpoint { + funding_txid, + funding_txout, + }), + VlsProof::OutpointBurial, + ), + ( + Message::LockOutpoint(LockOutpoint { + funding_txid, + funding_txout, + }), + VlsProof::OutpointLock, + ), + ]; + + for (message, proof) in cases { + assert_eq!( + classify( + &message, + &[], + &fixture.state, + &fixture.local_node_id, + Some(&fixture.context), + ) + .unwrap(), + SplicePolicyDecision::RequiresVlsProof(proof) + ); + } + } + + #[test] + fn active_splice_with_unknown_outpoint_rejects() { + let mut fixture = splice_signing_fixture(SpliceOrigin::LocalInitiator, false, false); + fixture + .state + .mark_splice_pending_lock(&fixture.node_channel_id_hex, 3) + .unwrap(); + let message = Message::CheckOutpoint(CheckOutpoint { + funding_txid: Txid::from_str(&"99".repeat(32)).unwrap(), + funding_txout: 0, + }); + + assert_eq!( + classify( + &message, + &[], + &fixture.state, + &fixture.local_node_id, + Some(&fixture.context), + ) + .unwrap(), + SplicePolicyDecision::Rejected(SplicePolicyViolation::UnknownOutpoint) + ); + } + + #[test] + fn unrelated_check_outpoint_remains_non_splice() { + let fixture = splice_signing_fixture(SpliceOrigin::LocalInitiator, false, false); + let message = Message::CheckOutpoint(CheckOutpoint { + funding_txid: Txid::from_str(&"99".repeat(32)).unwrap(), + funding_txout: 0, + }); + + assert_eq!( + classify(&message, &[], &State::new(), &fixture.local_node_id, None,).unwrap(), + SplicePolicyDecision::NotSpliceRelated + ); + } +} diff --git a/libs/gl-plugin/src/node/mod.rs b/libs/gl-plugin/src/node/mod.rs index f3e2e209f..b9aa7c191 100644 --- a/libs/gl-plugin/src/node/mod.rs +++ b/libs/gl-plugin/src/node/mod.rs @@ -142,16 +142,13 @@ impl PluginNodeServer { 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 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; + } let store = signer_state_store.lock().await; - if let Err(e) = store.write(snapshot).await { + if let Err(e) = store.write(state.clone()).await { warn!("Failed to persist signer peer state: {e}"); } } diff --git a/libs/gl-plugin/src/node/wrapper.rs b/libs/gl-plugin/src/node/wrapper.rs index 6c573daea..699158bc7 100644 --- a/libs/gl-plugin/src/node/wrapper.rs +++ b/libs/gl-plugin/src/node/wrapper.rs @@ -1,11 +1,17 @@ use std::collections::HashMap; use std::str::FromStr; +use std::time::{SystemTime, UNIX_EPOCH}; use anyhow::Error; use cln_grpc; use cln_grpc::pb::{self, node_server::Node}; use cln_rpc::primitives::ChannelState; use cln_rpc::{self}; +use gl_client::persist::{ + candidate_funding_facts_from_psbt, parse_base64_psbt, wallet_inputs_from_psbt, FeePolicy, + FundPsbtResponseFacts, FundingOutpoint, LocalSpliceIntent, OldSpliceState, SignPsbtIntentFacts, + SpliceSignedResponseFacts, SpliceUpdateResponseFacts, WalletInputReservation, +}; use log::debug; use tokio_stream::wrappers::ReceiverStream; use tonic::{Request, Response, Status}; @@ -377,7 +383,35 @@ impl Node for WrappedNodeServer { &self, r: Request, ) -> Result, Status> { - self.inner.fund_psbt(r).await + let captured_at_ms = rpc_context_timestamp_ms(&r)?; + let response = self.inner.fund_psbt(r).await?; + if let Some(timestamp_ms) = captured_at_ms { + let body = response.get_ref(); + let psbt = parse_base64_psbt(&body.psbt).map_err(internal_status)?; + let reservations = body + .reservations + .iter() + .map(|reservation| WalletInputReservation { + txid: hex::encode(&reservation.txid), + vout: reservation.vout, + reserved_to_block: reservation + .reserved + .then_some(reservation.reserved_to_block), + }) + .collect::>(); + let wallet_inputs = + wallet_inputs_from_psbt(&body.psbt, &reservations).map_err(internal_status)?; + + self.update_splice_state(|state| { + state.record_fundpsbt_response(FundPsbtResponseFacts { + psbt_fingerprint: psbt.fingerprint, + wallet_inputs, + timestamp_ms, + }) + }) + .await?; + } + Ok(response) } async fn send_psbt( @@ -391,6 +425,21 @@ impl Node for WrappedNodeServer { &self, r: Request, ) -> Result, Status> { + let captured_at_ms = rpc_context_timestamp_ms(&r)?; + if let Some(timestamp_ms) = captured_at_ms { + let body = r.get_ref(); + let psbt = parse_base64_psbt(&body.psbt).map_err(internal_status)?; + let signonly = body.signonly.clone(); + + self.update_splice_state(|state| { + state.record_signpsbt_intent(SignPsbtIntentFacts { + psbt_fingerprint: psbt.fingerprint, + signonly, + timestamp_ms, + }) + }) + .await?; + } self.inner.sign_psbt(r).await } @@ -810,21 +859,127 @@ impl Node for WrappedNodeServer { &self, request: tonic::Request, ) -> Result, tonic::Status> { - self.inner.splice_init(request).await + let timestamp_ms = require_rpc_context_timestamp_ms(&request)?; + let request_body = request.get_ref().clone(); + let node_id_hex = self.local_node_id_hex().await?; + let channel_id_hex = hex::encode(&request_body.channel_id); + let old = self.old_splice_state(&request_body.channel_id).await?; + let node_channel_id_hex = self + .node_channel_id_hex_for_outpoint(&node_id_hex, &old.funding_outpoint) + .await?; + let source_wallet_psbt_fingerprint = request_body + .initialpsbt + .as_deref() + .map(parse_base64_psbt) + .transpose() + .map_err(internal_status)? + .map(|psbt| psbt.fingerprint); + + let response = self.inner.splice_init(request).await?; + let body = response.get_ref(); + let psbt = parse_base64_psbt(&body.psbt).map_err(internal_status)?; + let candidate_psbt_fingerprint = psbt.fingerprint.clone(); + self.update_splice_state(|state| { + state.record_local_splice_intent(LocalSpliceIntent { + node_id_hex, + channel_id_hex, + node_channel_id_hex: node_channel_id_hex.clone(), + old, + authorized_relative_amount_sat: request_body.relative_amount, + fee_policy: FeePolicy { + feerate_per_kw: request_body.feerate_per_kw, + force_feerate: request_body.force_feerate, + }, + initial_psbt_fingerprint: psbt.fingerprint.clone(), + initial_psbt_input_outpoints: psbt.input_outpoints.clone(), + timestamp_ms, + })?; + if let Some(source_psbt_fingerprint) = source_wallet_psbt_fingerprint.as_deref() { + state.inherit_splice_wallet_context( + source_psbt_fingerprint, + &candidate_psbt_fingerprint, + &psbt.input_outpoints, + &node_channel_id_hex, + timestamp_ms, + )?; + } + Ok(()) + }) + .await?; + Ok(response) } async fn splice_signed( &self, request: tonic::Request, ) -> Result, tonic::Status> { - self.inner.splice_signed(request).await + let timestamp_ms = require_rpc_context_timestamp_ms(&request)?; + let request_body = request.get_ref().clone(); + let node_channel_id_hex = self.node_channel_id_hex(&request_body.channel_id).await?; + + let response = self.inner.splice_signed(request).await?; + let body = response.get_ref(); + let psbt = parse_base64_psbt(&body.psbt).map_err(internal_status)?; + let response_psbt = body.psbt.clone(); + let funding_txid = hex::encode(&body.txid); + let funding_outnum = body.outnum; + self.update_splice_state(|state| { + let candidate = funding_outnum + .map(|outnum| { + let session = + state + .get_splice_session(&node_channel_id_hex)? + .ok_or_else(|| { + anyhow::anyhow!( + "missing splice session for channel {}", + node_channel_id_hex + ) + })?; + candidate_funding_facts_from_psbt( + &response_psbt, + &funding_txid, + outnum, + &session.old.funding_outpoint, + ) + }) + .transpose()?; + state.record_splice_signed_response(SpliceSignedResponseFacts { + node_channel_id_hex, + psbt_fingerprint: psbt.fingerprint, + psbt_input_outpoints: psbt.input_outpoints, + candidate, + timestamp_ms, + }) + }) + .await?; + Ok(response) } async fn splice_update( &self, request: tonic::Request, ) -> Result, tonic::Status> { - self.inner.splice_update(request).await + let timestamp_ms = require_rpc_context_timestamp_ms(&request)?; + let request_body = request.get_ref().clone(); + let node_channel_id_hex = self.node_channel_id_hex(&request_body.channel_id).await?; + + let response = self.inner.splice_update(request).await?; + let body = response.get_ref(); + let psbt = parse_base64_psbt(&body.psbt).map_err(internal_status)?; + let commitments_secured = body.commitments_secured; + let signatures_secured = body.signatures_secured; + self.update_splice_state(|state| { + state.record_splice_update_response(SpliceUpdateResponseFacts { + node_channel_id_hex, + psbt_fingerprint: psbt.fingerprint, + psbt_input_outpoints: psbt.input_outpoints, + commitments_secured, + signatures_secured, + timestamp_ms, + }) + }) + .await?; + Ok(response) } async fn dev_splice( @@ -1116,7 +1271,123 @@ impl Node for WrappedNodeServer { } } +fn internal_status(error: impl std::fmt::Display) -> Status { + Status::internal(error.to_string()) +} + +// Metadata presence controls fact capture only; the signer verifies the live request context. +const RPC_CONTEXT_KEYS: [&str; 4] = ["glauthpubkey", "glauthsig", "glts", "glrune"]; + +fn now_ms() -> Result { + let millis = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map_err(internal_status)? + .as_millis(); + u64::try_from(millis).map_err(internal_status) +} + +fn rpc_context_timestamp_ms(request: &Request) -> Result, Status> { + let present = RPC_CONTEXT_KEYS + .iter() + .filter(|key| request.metadata().contains_key(**key)) + .count(); + match present { + 0 => Ok(None), + 4 => now_ms().map(Some), + _ => Err(Status::unauthenticated( + "incomplete Greenlight RPC context metadata", + )), + } +} + +fn require_rpc_context_timestamp_ms(request: &Request) -> Result { + rpc_context_timestamp_ms(request)? + .ok_or_else(|| Status::unauthenticated("missing Greenlight RPC context metadata")) +} + +fn amount_sat(amount: Option<&pb::Amount>, field: &'static str) -> Result { + amount + .map(|amount| amount.msat / 1000) + .ok_or_else(|| Status::failed_precondition(format!("channel is missing {field}"))) +} + impl WrappedNodeServer { + async fn update_splice_state(&self, update: F) -> Result<(), Status> + where + F: FnOnce(&mut gl_client::persist::State) -> anyhow::Result<()>, + { + let mut state = self.node_server.signer_state.lock().await; + update(&mut state).map_err(internal_status)?; + let store = self.node_server.signer_state_store.lock().await; + store.write(state.clone()).await.map_err(internal_status) + } + + async fn local_node_id_hex(&self) -> Result { + let response = self + .inner + .getinfo(Request::new(pb::GetinfoRequest {})) + .await?; + Ok(hex::encode(response.into_inner().id)) + } + + async fn node_channel_id_hex(&self, channel_id: &[u8]) -> Result { + let node_id_hex = self.local_node_id_hex().await?; + let old = self.old_splice_state(channel_id).await?; + self.node_channel_id_hex_for_outpoint(&node_id_hex, &old.funding_outpoint) + .await + } + + async fn node_channel_id_hex_for_outpoint( + &self, + node_id_hex: &str, + funding_outpoint: &FundingOutpoint, + ) -> Result { + let state = self.node_server.signer_state.lock().await; + state + .node_channel_id_for_funding_outpoint(node_id_hex, funding_outpoint) + .map_err(internal_status)? + .ok_or_else(|| { + Status::failed_precondition(format!( + "missing signer channel for funding outpoint {}:{}", + funding_outpoint.txid, funding_outpoint.vout + )) + }) + } + + async fn old_splice_state(&self, channel_id: &[u8]) -> Result { + let response = self + .inner + .list_peer_channels(Request::new(pb::ListpeerchannelsRequest { id: None })) + .await?; + let channel = response + .into_inner() + .channels + .into_iter() + .find(|channel| channel.channel_id.as_deref() == Some(channel_id)) + .ok_or_else(|| { + Status::failed_precondition(format!( + "missing channel {} for splice", + hex::encode(channel_id) + )) + })?; + + let funding_txid = channel + .funding_txid + .ok_or_else(|| Status::failed_precondition("channel is missing funding_txid"))?; + let funding_outnum = channel + .funding_outnum + .ok_or_else(|| Status::failed_precondition("channel is missing funding_outnum"))?; + + Ok(OldSpliceState { + funding_outpoint: FundingOutpoint { + txid: hex::encode(funding_txid), + vout: funding_outnum, + }, + channel_value_sat: amount_sat(channel.total_msat.as_ref(), "total_msat")?, + local_balance_sat: amount_sat(channel.to_us_msat.as_ref(), "to_us_msat")?, + }) + } + async fn get_routehints(&self, rpc: &mut cln_rpc::ClnRpc) -> Result, Error> { // Get a map of active channels to peers with a status of // "CHANNELD_NORMAL", and it aliases. diff --git a/libs/gl-sdk-cli/Cargo.toml b/libs/gl-sdk-cli/Cargo.toml index aec4cd090..e37a7efdf 100644 --- a/libs/gl-sdk-cli/Cargo.toml +++ b/libs/gl-sdk-cli/Cargo.toml @@ -4,6 +4,9 @@ version = "0.3.0" edition = "2021" description = "CLI wrapper for gl-sdk" +[features] +experimental-splicing = ["glsdk/experimental-splicing"] + [[bin]] name = "glsdk" path = "src/bin/glsdk.rs" diff --git a/libs/gl-sdk-napi/Cargo.toml b/libs/gl-sdk-napi/Cargo.toml index e04c84f1a..0b90ac42c 100644 --- a/libs/gl-sdk-napi/Cargo.toml +++ b/libs/gl-sdk-napi/Cargo.toml @@ -4,6 +4,9 @@ version = "0.2.0" edition = "2021" license = "MIT" +[features] +experimental-splicing = ["glsdk/experimental-splicing"] + [lib] crate-type = ["cdylib"] diff --git a/libs/gl-sdk/Cargo.toml b/libs/gl-sdk/Cargo.toml index afd0d6382..3051dff5f 100644 --- a/libs/gl-sdk/Cargo.toml +++ b/libs/gl-sdk/Cargo.toml @@ -6,6 +6,9 @@ description = "High-level SDK for Greenlight with UniFFI language bindings" license = "MIT" repository = "https://github.com/Blockstream/greenlight" +[features] +experimental-splicing = ["gl-client/experimental-splicing"] + [lib] crate-type = ["cdylib", "staticlib", "rlib"] name = "glsdk"