diff --git a/Cargo.lock b/Cargo.lock index 505fee8cf..1c1dd6a32 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1639,6 +1639,7 @@ dependencies = [ "prost", "serde", "serde_json", + "sha256", "sled", "thiserror 1.0.69", "tokio", 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..9eb3990be 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" diff --git a/libs/gl-client/src/persist.rs b/libs/gl-client/src/persist.rs index e22ecf66e..6132d5fa2 100644 --- a/libs/gl-client/src/persist.rs +++ b/libs/gl-client/src/persist.rs @@ -1,4 +1,18 @@ mod canonical; +#[allow(dead_code)] +mod splice; + +pub use splice::{ + candidate_funding_facts_from_psbt, psbt_shape_from_base64, wallet_inputs_from_psbt, FeePolicy, + FundPsbtResponseFacts, FundingOutpoint, LocalSpliceIntent, NormalizedRpcAuth, OldSpliceState, + SignPsbtIntentFacts, SignPsbtResponseFacts, SpliceSignedResponseFacts, + SpliceUpdateResponseFacts, WalletInputReservation, WalletInputSource, +}; +pub(crate) use splice::{ + psbt_shape_from_psbt, transaction_shape, SpliceOrigin, SplicePhase, SpliceSessionV1, +}; +#[cfg(test)] +pub(crate) use splice::{CandidateFundingFacts, WalletInput}; use anyhow::anyhow; use lightning_signer::bitcoin::secp256k1::PublicKey; diff --git a/libs/gl-client/src/persist/splice.rs b/libs/gl-client/src/persist/splice.rs new file mode 100644 index 000000000..65cbaf020 --- /dev/null +++ b/libs/gl-client/src/persist/splice.rs @@ -0,0 +1,2406 @@ +use super::canonical::canonical_json_bytes; +use super::{State, StateEntry, CHANNEL_PREFIX}; +use crate::bitcoin::consensus::encode::deserialize; +use crate::bitcoin::psbt::Psbt; +use crate::bitcoin::Transaction; +use anyhow::{anyhow, bail}; +use base64::{engine::general_purpose, Engine as _}; +use serde::de::DeserializeOwned; +use serde::{Deserialize, Serialize}; + +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_shape_hash: &str) -> String { + format!("{SPLICE_WALLET_PSBT_PREFIX}/{psbt_shape_hash}") +} + +#[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 WalletInputSource { + FundPsbt, + SignPsbt, + Unknown, +} + +#[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 NormalizedRpcAuth { + pub schema: String, + pub schema_version: u16, + pub uri: String, + pub request_hash: String, + pub caller_pubkey_hex: String, + pub timestamp_ms: u64, +} + +impl NormalizedRpcAuth { + pub fn new( + uri: String, + request_hash: String, + caller_pubkey_hex: String, + timestamp_ms: u64, + ) -> Self { + Self { + schema: "NormalizedRpcAuth".to_string(), + schema_version: 1, + uri, + request_hash, + caller_pubkey_hex, + timestamp_ms, + } + } +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct PsbtShapeInputV1 { + pub prev_txid: String, + pub prev_vout: u32, + pub sequence: u32, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct PsbtShapeOutputV1 { + pub value_sat: u64, + pub script_pubkey_hex: String, + pub script_pubkey_hash: String, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct PsbtShapeV1 { + pub schema: String, + pub version: i32, + pub locktime: u32, + pub inputs: Vec, + pub outputs: Vec, +} + +#[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 SpliceAuthState { + pub splice_init_auth: Option, + pub latest_splice_update_auth: Option, + pub splice_signed_auth: Option, +} + +#[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 splice_init_auth: NormalizedRpcAuth, + pub authorized_relative_amount_sat: i64, + pub fee_policy: FeePolicy, + pub initial_psbt_shape_hash: Option, + pub initial_psbt_shape: Option, + pub timestamp_ms: u64, +} + +#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +pub struct SplicePsbtState { + pub candidate_psbt_shape_hash: Option, + pub candidate_psbt_shape: Option, + pub frozen_psbt_shape_hash: 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)] +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 auth: SpliceAuthState, + pub intent: SpliceIntentState, + pub psbt: SplicePsbtState, + pub cand: SpliceCandidateState, + pub delta: SpliceDeltaState, + pub linked_wallet_psbt_shape_hashes: Vec, + pub request_history: 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, + splice_init_auth: Option, + 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, + auth: SpliceAuthState { + splice_init_auth: splice_init_auth.clone(), + latest_splice_update_auth: None, + splice_signed_auth: None, + }, + intent: SpliceIntentState { + authorized_relative_amount_sat, + fee_policy, + }, + psbt: SplicePsbtState::default(), + cand: SpliceCandidateState::default(), + delta: SpliceDeltaState::default(), + linked_wallet_psbt_shape_hashes: Vec::new(), + request_history: splice_init_auth.into_iter().collect(), + 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, + pub source: WalletInputSource, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct SpliceWalletPsbtContextV1 { + pub schema: String, + pub schema_version: u16, + pub psbt_shape_hash: String, + pub latest_psbt_shape: PsbtShapeV1, + pub fundpsbt_auth: Option, + pub signpsbt_auth: Option, + pub signonly: Vec, + pub wallet_inputs: Vec, + pub change_outnum: Option, + pub linked_node_channel_id_hex: Option, + pub linked_splice_psbt_shape_hash: Option, + pub created_at_ms: u64, + pub updated_at_ms: u64, +} + +impl SpliceWalletPsbtContextV1 { + pub fn new( + psbt_shape_hash: String, + latest_psbt_shape: PsbtShapeV1, + fundpsbt_auth: Option, + signpsbt_auth: Option, + wallet_inputs: Vec, + timestamp_ms: u64, + ) -> Self { + Self { + schema: "SpliceWalletPsbtContextV1".to_string(), + schema_version: 1, + psbt_shape_hash, + latest_psbt_shape, + fundpsbt_auth, + signpsbt_auth, + signonly: Vec::new(), + wallet_inputs, + change_outnum: None, + linked_node_channel_id_hex: None, + linked_splice_psbt_shape_hash: 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_shape_hash: String, + pub psbt_shape: PsbtShapeV1, + pub fundpsbt_auth: NormalizedRpcAuth, + pub wallet_inputs: Vec, + pub change_outnum: Option, + pub timestamp_ms: u64, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct SignPsbtResponseFacts { + pub psbt_shape_hash: String, + pub psbt_shape: PsbtShapeV1, + pub signpsbt_auth: NormalizedRpcAuth, + pub signonly: Vec, + pub timestamp_ms: u64, +} + +pub type SignPsbtIntentFacts = SignPsbtResponseFacts; + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct SpliceUpdateResponseFacts { + pub node_channel_id_hex: String, + pub psbt_shape_hash: String, + pub psbt_shape: PsbtShapeV1, + pub splice_update_auth: NormalizedRpcAuth, + 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_shape_hash: String, + pub psbt_shape: PsbtShapeV1, + pub splice_signed_auth: NormalizedRpcAuth, + pub candidate: Option, + pub timestamp_ms: u64, +} + +pub(crate) fn transaction_shape(tx: &Transaction) -> PsbtShapeV1 { + PsbtShapeV1 { + schema: "PsbtShapeV1".to_string(), + version: tx.version.0, + locktime: tx.lock_time.to_consensus_u32(), + inputs: tx + .input + .iter() + .map(|input| PsbtShapeInputV1 { + prev_txid: input.previous_output.txid.to_string(), + prev_vout: input.previous_output.vout, + sequence: input.sequence.to_consensus_u32(), + }) + .collect(), + outputs: tx + .output + .iter() + .map(|output| { + let script_bytes = output.script_pubkey.as_bytes(); + PsbtShapeOutputV1 { + value_sat: output.value.to_sat(), + script_pubkey_hex: hex::encode(script_bytes), + script_pubkey_hash: sha256::digest(script_bytes), + } + }) + .collect(), + } +} + +pub fn psbt_shape_hash(shape: &PsbtShapeV1) -> anyhow::Result { + let value = serde_json::to_value(shape) + .map_err(|e| anyhow!("failed to encode PSBT shape for hashing: {e}"))?; + let bytes = canonical_json_bytes(&value)?; + Ok(sha256::digest(bytes.as_slice())) +} + +#[derive(Clone, Copy)] +struct PsbtMapEntry<'a> { + key_type: u8, + key_data: &'a [u8], + value: &'a [u8], +} + +struct PsbtCursor<'a> { + raw: &'a [u8], + position: usize, +} + +impl<'a> PsbtCursor<'a> { + fn new(raw: &'a [u8]) -> Self { + Self { raw, position: 0 } + } + + fn remaining(&self) -> usize { + self.raw.len() - self.position + } + + fn read_exact(&mut self, length: usize) -> anyhow::Result<&'a [u8]> { + let end = self + .position + .checked_add(length) + .ok_or_else(|| anyhow!("PSBT field length overflow"))?; + if end > self.raw.len() { + bail!("unexpected end of PSBT"); + } + let value = &self.raw[self.position..end]; + self.position = end; + Ok(value) + } + + fn read_compact_size(&mut self) -> anyhow::Result { + let prefix = self.read_exact(1)?[0]; + let (value, minimum) = match prefix { + 0x00..=0xfc => return Ok(prefix as u64), + 0xfd => ( + u16::from_le_bytes(self.read_exact(2)?.try_into().unwrap()) as u64, + 0xfd, + ), + 0xfe => ( + u32::from_le_bytes(self.read_exact(4)?.try_into().unwrap()) as u64, + 0x1_0000, + ), + 0xff => ( + u64::from_le_bytes(self.read_exact(8)?.try_into().unwrap()), + 0x1_0000_0000, + ), + }; + if value < minimum { + bail!("non-canonical CompactSize value in PSBT"); + } + Ok(value) + } + + fn read_map(&mut self, map_name: &str) -> anyhow::Result>> { + let mut entries = Vec::new(); + loop { + let key_length = usize::try_from(self.read_compact_size()?) + .map_err(|_| anyhow!("{map_name} key is too large"))?; + if key_length == 0 { + return Ok(entries); + } + + let key = self.read_exact(key_length)?; + let (&key_type, key_data) = key + .split_first() + .ok_or_else(|| anyhow!("{map_name} contains an empty key"))?; + if entries.iter().any(|entry: &PsbtMapEntry<'_>| { + entry.key_type == key_type && entry.key_data == key_data + }) { + bail!("{map_name} contains a duplicate key"); + } + + let value_length = usize::try_from(self.read_compact_size()?) + .map_err(|_| anyhow!("{map_name} value is too large"))?; + entries.push(PsbtMapEntry { + key_type, + key_data, + value: self.read_exact(value_length)?, + }); + } + } +} + +fn psbt_map_value<'a>( + entries: &[PsbtMapEntry<'a>], + key_type: u8, + field_name: &str, +) -> anyhow::Result> { + let mut value = None; + for entry in entries.iter().filter(|entry| entry.key_type == key_type) { + if !entry.key_data.is_empty() { + bail!("{field_name} must not contain key data"); + } + if value.replace(entry.value).is_some() { + bail!("duplicate {field_name}"); + } + } + Ok(value) +} + +fn required_psbt_map_value<'a>( + entries: &[PsbtMapEntry<'a>], + key_type: u8, + field_name: &str, +) -> anyhow::Result<&'a [u8]> { + psbt_map_value(entries, key_type, field_name)? + .ok_or_else(|| anyhow!("missing required {field_name}")) +} + +fn psbt_u32(value: &[u8], field_name: &str) -> anyhow::Result { + let bytes: [u8; 4] = value + .try_into() + .map_err(|_| anyhow!("{field_name} must be four bytes"))?; + Ok(u32::from_le_bytes(bytes)) +} + +fn psbt_i32(value: &[u8], field_name: &str) -> anyhow::Result { + let bytes: [u8; 4] = value + .try_into() + .map_err(|_| anyhow!("{field_name} must be four bytes"))?; + Ok(i32::from_le_bytes(bytes)) +} + +fn psbt_compact_size(value: &[u8], field_name: &str) -> anyhow::Result { + let mut cursor = PsbtCursor::new(value); + let result = cursor.read_compact_size()?; + if cursor.remaining() != 0 { + bail!("{field_name} contains trailing bytes"); + } + Ok(result) +} + +fn psbt_v2_locktime( + fallback_locktime: u32, + requirements: &[(Option, Option)], +) -> anyhow::Result { + let constrained: Vec<_> = requirements + .iter() + .filter(|(required_time, required_height)| { + required_time.is_some() || required_height.is_some() + }) + .collect(); + if constrained.is_empty() { + return Ok(fallback_locktime); + } + + if constrained + .iter() + .all(|(_, required_height)| required_height.is_some()) + { + return constrained + .iter() + .filter_map(|(_, required_height)| *required_height) + .max() + .ok_or_else(|| anyhow!("PSBTv2 required height locktime is missing")); + } + if constrained + .iter() + .all(|(required_time, _)| required_time.is_some()) + { + return constrained + .iter() + .filter_map(|(required_time, _)| *required_time) + .max() + .ok_or_else(|| anyhow!("PSBTv2 required time locktime is missing")); + } + + bail!("PSBTv2 inputs contain incompatible required locktimes") +} + +fn psbt_v2_shape(raw: &[u8]) -> anyhow::Result { + const PSBT_MAGIC: &[u8] = b"psbt\xff"; + const PSBT_GLOBAL_UNSIGNED_TX: u8 = 0x00; + const PSBT_GLOBAL_TX_VERSION: u8 = 0x02; + const PSBT_GLOBAL_FALLBACK_LOCKTIME: u8 = 0x03; + const PSBT_GLOBAL_INPUT_COUNT: u8 = 0x04; + const PSBT_GLOBAL_OUTPUT_COUNT: u8 = 0x05; + const PSBT_GLOBAL_VERSION: u8 = 0xfb; + const PSBT_IN_PREVIOUS_TXID: u8 = 0x0e; + const PSBT_IN_OUTPUT_INDEX: u8 = 0x0f; + const PSBT_IN_SEQUENCE: u8 = 0x10; + const PSBT_IN_REQUIRED_TIME_LOCKTIME: u8 = 0x11; + const PSBT_IN_REQUIRED_HEIGHT_LOCKTIME: u8 = 0x12; + const PSBT_OUT_AMOUNT: u8 = 0x03; + const PSBT_OUT_SCRIPT: u8 = 0x04; + const LOCKTIME_THRESHOLD: u32 = 500_000_000; + + let mut cursor = PsbtCursor::new(raw); + if cursor.read_exact(PSBT_MAGIC.len())? != PSBT_MAGIC { + bail!("invalid PSBT magic bytes"); + } + + let globals = cursor.read_map("PSBT global map")?; + if psbt_map_value(&globals, PSBT_GLOBAL_UNSIGNED_TX, "PSBT_GLOBAL_UNSIGNED_TX")?.is_some() { + bail!("PSBTv2 must not contain PSBT_GLOBAL_UNSIGNED_TX"); + } + let psbt_version = psbt_u32( + required_psbt_map_value(&globals, PSBT_GLOBAL_VERSION, "PSBT_GLOBAL_VERSION")?, + "PSBT_GLOBAL_VERSION", + )?; + if psbt_version != 2 { + bail!("expected PSBT version 2, got {psbt_version}"); + } + let transaction_version = psbt_i32( + required_psbt_map_value(&globals, PSBT_GLOBAL_TX_VERSION, "PSBT_GLOBAL_TX_VERSION")?, + "PSBT_GLOBAL_TX_VERSION", + )?; + let fallback_locktime = psbt_map_value( + &globals, + PSBT_GLOBAL_FALLBACK_LOCKTIME, + "PSBT_GLOBAL_FALLBACK_LOCKTIME", + )? + .map(|value| psbt_u32(value, "PSBT_GLOBAL_FALLBACK_LOCKTIME")) + .transpose()? + .unwrap_or(0); + let input_count = psbt_compact_size( + required_psbt_map_value(&globals, PSBT_GLOBAL_INPUT_COUNT, "PSBT_GLOBAL_INPUT_COUNT")?, + "PSBT_GLOBAL_INPUT_COUNT", + )?; + let output_count = psbt_compact_size( + required_psbt_map_value( + &globals, + PSBT_GLOBAL_OUTPUT_COUNT, + "PSBT_GLOBAL_OUTPUT_COUNT", + )?, + "PSBT_GLOBAL_OUTPUT_COUNT", + )?; + let map_count = input_count + .checked_add(output_count) + .ok_or_else(|| anyhow!("PSBTv2 input and output count overflow"))?; + if map_count > cursor.remaining() as u64 { + bail!("PSBTv2 input and output counts exceed the remaining data"); + } + let input_count = usize::try_from(input_count) + .map_err(|_| anyhow!("PSBT_GLOBAL_INPUT_COUNT is too large"))?; + let output_count = usize::try_from(output_count) + .map_err(|_| anyhow!("PSBT_GLOBAL_OUTPUT_COUNT is too large"))?; + + let mut inputs = Vec::with_capacity(input_count); + let mut locktime_requirements = Vec::with_capacity(input_count); + for input_index in 0..input_count { + let map_name = format!("PSBT input map {input_index}"); + let input = cursor.read_map(&map_name)?; + let previous_txid = + required_psbt_map_value(&input, PSBT_IN_PREVIOUS_TXID, "PSBT_IN_PREVIOUS_TXID")?; + if previous_txid.len() != 32 { + bail!("PSBT_IN_PREVIOUS_TXID must be 32 bytes"); + } + let previous_vout = psbt_u32( + required_psbt_map_value(&input, PSBT_IN_OUTPUT_INDEX, "PSBT_IN_OUTPUT_INDEX")?, + "PSBT_IN_OUTPUT_INDEX", + )?; + let sequence = psbt_map_value(&input, PSBT_IN_SEQUENCE, "PSBT_IN_SEQUENCE")? + .map(|value| psbt_u32(value, "PSBT_IN_SEQUENCE")) + .transpose()? + .unwrap_or(u32::MAX); + let required_time = psbt_map_value( + &input, + PSBT_IN_REQUIRED_TIME_LOCKTIME, + "PSBT_IN_REQUIRED_TIME_LOCKTIME", + )? + .map(|value| psbt_u32(value, "PSBT_IN_REQUIRED_TIME_LOCKTIME")) + .transpose()?; + if required_time.is_some_and(|locktime| locktime < LOCKTIME_THRESHOLD) { + bail!("PSBT_IN_REQUIRED_TIME_LOCKTIME must be a time-based locktime"); + } + let required_height = psbt_map_value( + &input, + PSBT_IN_REQUIRED_HEIGHT_LOCKTIME, + "PSBT_IN_REQUIRED_HEIGHT_LOCKTIME", + )? + .map(|value| psbt_u32(value, "PSBT_IN_REQUIRED_HEIGHT_LOCKTIME")) + .transpose()?; + if required_height.is_some_and(|locktime| locktime == 0 || locktime >= LOCKTIME_THRESHOLD) { + bail!("PSBT_IN_REQUIRED_HEIGHT_LOCKTIME must be a block height"); + } + + let mut display_txid = previous_txid.to_vec(); + display_txid.reverse(); + inputs.push(PsbtShapeInputV1 { + prev_txid: hex::encode(display_txid), + prev_vout: previous_vout, + sequence, + }); + locktime_requirements.push((required_time, required_height)); + } + + let mut outputs = Vec::with_capacity(output_count); + for output_index in 0..output_count { + let map_name = format!("PSBT output map {output_index}"); + let output = cursor.read_map(&map_name)?; + let amount_bytes: [u8; 8] = + required_psbt_map_value(&output, PSBT_OUT_AMOUNT, "PSBT_OUT_AMOUNT")? + .try_into() + .map_err(|_| anyhow!("PSBT_OUT_AMOUNT must be eight bytes"))?; + let amount = i64::from_le_bytes(amount_bytes); + if amount < 0 { + bail!("PSBT_OUT_AMOUNT must not be negative"); + } + let script = required_psbt_map_value(&output, PSBT_OUT_SCRIPT, "PSBT_OUT_SCRIPT")?; + outputs.push(PsbtShapeOutputV1 { + value_sat: amount as u64, + script_pubkey_hex: hex::encode(script), + script_pubkey_hash: sha256::digest(script), + }); + } + if cursor.remaining() != 0 { + bail!("PSBTv2 contains trailing data"); + } + + Ok(PsbtShapeV1 { + schema: "PsbtShapeV1".to_string(), + version: transaction_version, + locktime: psbt_v2_locktime(fallback_locktime, &locktime_requirements)?, + inputs, + outputs, + }) +} + +pub fn psbt_shape_from_base64(psbt: &str) -> anyhow::Result<(String, PsbtShapeV1)> { + let raw = general_purpose::STANDARD + .decode(psbt) + .map_err(|e| anyhow!("failed to decode PSBT base64: {e}"))?; + let shape = match Psbt::deserialize(&raw) { + Ok(psbt) => transaction_shape(&psbt.unsigned_tx), + Err(v0_error) => psbt_v2_shape(&raw).map_err(|v2_error| { + anyhow!("failed to parse PSBT as v0 ({v0_error}) or v2 ({v2_error})") + })?, + }; + let hash = psbt_shape_hash(&shape)?; + Ok((hash, shape)) +} + +pub(crate) fn psbt_shape_from_psbt(psbt: &Psbt) -> anyhow::Result<(String, PsbtShapeV1)> { + let shape = transaction_shape(&psbt.unsigned_tx); + let hash = psbt_shape_hash(&shape)?; + Ok((hash, shape)) +} + +fn parse_psbt(psbt: &str) -> anyhow::Result { + let raw = general_purpose::STANDARD + .decode(psbt) + .map_err(|e| anyhow!("failed to decode PSBT base64: {e}"))?; + Psbt::deserialize(&raw).map_err(|e| anyhow!("failed to parse PSBT: {e}")) +} + +fn psbt_input_value_sat(psbt: &Psbt, input_index: usize) -> anyhow::Result { + let input = psbt + .inputs + .get(input_index) + .ok_or_else(|| anyhow!("missing PSBT input {}", input_index))?; + if let Some(txout) = &input.witness_utxo { + return Ok(txout.value.to_sat()); + } + + let Some(prev_tx) = &input.non_witness_utxo else { + bail!( + "PSBT input {} has no witness_utxo or non_witness_utxo", + input_index + ); + }; + let vout = psbt.unsigned_tx.input[input_index].previous_output.vout as usize; + prev_tx + .output + .get(vout) + .map(|txout| txout.value.to_sat()) + .ok_or_else(|| anyhow!("PSBT input {} non_witness_utxo missing vout", input_index)) +} + +pub fn wallet_inputs_from_psbt( + psbt: &str, + reservations: &[WalletInputReservation], + source: WalletInputSource, +) -> anyhow::Result> { + let psbt = parse_psbt(psbt)?; + psbt.unsigned_tx + .input + .iter() + .enumerate() + .map(|(index, input)| { + let txid = input.previous_output.txid.to_string(); + let vout = input.previous_output.vout; + let reservation = reservations + .iter() + .find(|reservation| reservation.txid == txid && reservation.vout == vout); + Ok(WalletInput { + txid, + vout, + value_sat: psbt_input_value_sat(&psbt, index)?, + reserved_to_block: reservation + .and_then(|reservation| reservation.reserved_to_block), + source: source.clone(), + }) + }) + .collect() +} + +pub fn candidate_funding_facts_from_psbt( + psbt: &str, + funding_txid: &str, + funding_vout: u32, + old_funding_outpoint: &FundingOutpoint, +) -> anyhow::Result { + let (_, shape) = psbt_shape_from_base64(psbt)?; + let old_input_index = shape + .inputs + .iter() + .position(|input| { + input.prev_txid == old_funding_outpoint.txid + && input.prev_vout == old_funding_outpoint.vout + }) + .ok_or_else(|| anyhow!("splice PSBT does not spend old funding outpoint"))?; + let output = shape + .outputs + .get(funding_vout as usize) + .ok_or_else(|| anyhow!("splice funding output index {} is missing", funding_vout))?; + + Ok(CandidateFundingFacts { + funding_outpoint: FundingOutpoint { + txid: funding_txid.to_string(), + vout: funding_vout, + }, + value_sat: output.value_sat, + script_pubkey_hash: output.script_pubkey_hash.clone(), + sign_splice_tx_input_index: old_input_index as u32, + remote_funding_key_hex: None, + }) +} + +pub fn candidate_funding_facts_from_tx( + tx: &[u8], + funding_txid: &str, + funding_vout: u32, + sign_splice_tx_input_index: u32, +) -> anyhow::Result { + let tx: Transaction = + deserialize(tx).map_err(|e| anyhow!("failed to parse splice transaction: {e}"))?; + let output = tx + .output + .get(funding_vout as usize) + .ok_or_else(|| anyhow!("splice funding output index {} is missing", funding_vout))?; + Ok(CandidateFundingFacts { + funding_outpoint: FundingOutpoint { + txid: funding_txid.to_string(), + vout: funding_vout, + }, + value_sat: output.value.to_sat(), + script_pubkey_hash: sha256::digest(output.script_pubkey.as_bytes()), + 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_shape_context( + &mut self, + session: &mut SpliceSessionV1, + psbt_shape_hash: &str, + psbt_shape: &PsbtShapeV1, + updated_at_ms: u64, + ) -> anyhow::Result<()> { + let source_shape_hashes = session.linked_wallet_psbt_shape_hashes.clone(); + let mut context = self + .get_splice_wallet_psbt_context(psbt_shape_hash)? + .unwrap_or_else(|| { + SpliceWalletPsbtContextV1::new( + psbt_shape_hash.to_string(), + psbt_shape.clone(), + None, + None, + 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 shape {} is already linked to splice channel {}", + psbt_shape_hash, + linked_channel + ); + } + } + + context.latest_psbt_shape = psbt_shape.clone(); + context.linked_node_channel_id_hex = Some(session.node_channel_id_hex.clone()); + context.linked_splice_psbt_shape_hash = Some(psbt_shape_hash.to_string()); + context.updated_at_ms = updated_at_ms; + if !session + .linked_wallet_psbt_shape_hashes + .iter() + .any(|hash| hash == psbt_shape_hash) + { + session + .linked_wallet_psbt_shape_hashes + .push(psbt_shape_hash.to_string()); + } + self.put_splice_wallet_psbt_context(context)?; + for source_shape_hash in source_shape_hashes { + self.inherit_splice_wallet_context( + &source_shape_hash, + psbt_shape_hash, + &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.auth.splice_init_auth.is_none() { + bail!("local splice sessions require splice_init_auth"); + } + 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<()> { + match ( + intent.initial_psbt_shape_hash.as_ref(), + intent.initial_psbt_shape.as_ref(), + ) { + (Some(_), Some(_)) | (None, None) => {} + _ => bail!("initial PSBT shape hash and shape must be recorded together"), + } + + 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.auth.splice_init_auth = Some(intent.splice_init_auth.clone()); + session.intent.authorized_relative_amount_sat = + Some(intent.authorized_relative_amount_sat); + session.intent.fee_policy = intent.fee_policy; + session.psbt.candidate_psbt_shape_hash = intent.initial_psbt_shape_hash; + session.psbt.candidate_psbt_shape = intent.initial_psbt_shape; + session.request_history.push(intent.splice_init_auth); + session.updated_at_ms = intent.timestamp_ms; + if let (Some(hash), Some(shape)) = ( + session.psbt.candidate_psbt_shape_hash.clone(), + session.psbt.candidate_psbt_shape.clone(), + ) { + self.link_splice_shape_context(&mut session, &hash, &shape, 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.splice_init_auth), + Some(intent.authorized_relative_amount_sat), + intent.fee_policy, + intent.timestamp_ms, + ); + session.psbt.candidate_psbt_shape_hash = intent.initial_psbt_shape_hash; + session.psbt.candidate_psbt_shape = intent.initial_psbt_shape; + if let (Some(hash), Some(shape)) = ( + session.psbt.candidate_psbt_shape_hash.clone(), + session.psbt.candidate_psbt_shape.clone(), + ) { + self.link_splice_shape_context(&mut session, &hash, &shape, intent.timestamp_ms)?; + } + self.create_local_splice_session(session) + } + + pub fn record_fundpsbt_response(&mut self, facts: FundPsbtResponseFacts) -> anyhow::Result<()> { + let existing = self.get_splice_wallet_psbt_context(&facts.psbt_shape_hash)?; + let mut context = existing.unwrap_or_else(|| { + SpliceWalletPsbtContextV1::new( + facts.psbt_shape_hash.clone(), + facts.psbt_shape.clone(), + None, + None, + Vec::new(), + facts.timestamp_ms, + ) + }); + context.latest_psbt_shape = facts.psbt_shape; + context.fundpsbt_auth = Some(facts.fundpsbt_auth); + context.wallet_inputs = facts.wallet_inputs; + context.change_outnum = facts.change_outnum; + context.updated_at_ms = facts.timestamp_ms; + self.put_splice_wallet_psbt_context(context) + } + + pub fn record_signpsbt_response(&mut self, facts: SignPsbtResponseFacts) -> anyhow::Result<()> { + let existing = self.get_splice_wallet_psbt_context(&facts.psbt_shape_hash)?; + let mut context = existing.unwrap_or_else(|| { + SpliceWalletPsbtContextV1::new( + facts.psbt_shape_hash.clone(), + facts.psbt_shape.clone(), + None, + None, + Vec::new(), + facts.timestamp_ms, + ) + }); + context.latest_psbt_shape = facts.psbt_shape; + context.signpsbt_auth = Some(facts.signpsbt_auth); + context.signonly = facts.signonly; + context.updated_at_ms = facts.timestamp_ms; + self.put_splice_wallet_psbt_context(context) + } + + pub fn record_signpsbt_intent(&mut self, facts: SignPsbtIntentFacts) -> anyhow::Result<()> { + self.record_signpsbt_response(facts) + } + + pub fn record_splice_update_response( + &mut self, + facts: SpliceUpdateResponseFacts, + ) -> anyhow::Result<()> { + let linked_psbt_shape_hash = facts.psbt_shape_hash.clone(); + let linked_psbt_shape = facts.psbt_shape.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 + ); + } + + session.auth.latest_splice_update_auth = Some(facts.splice_update_auth.clone()); + session.request_history.push(facts.splice_update_auth); + + if facts.commitments_secured { + session.psbt.frozen_psbt_shape_hash = Some(facts.psbt_shape_hash.clone()); + session.psbt.candidate_psbt_shape_hash = Some(facts.psbt_shape_hash); + session.psbt.candidate_psbt_shape = Some(facts.psbt_shape); + session.phase = if facts.signatures_secured == Some(true) { + SplicePhase::SignaturesExchanging + } else { + SplicePhase::CommitmentsSecured + }; + } else { + if session.phase != SplicePhase::Negotiating { + bail!( + "candidate PSBT shape can only change while negotiating, current phase {:?}", + session.phase + ); + } + session.psbt.candidate_psbt_shape_hash = Some(facts.psbt_shape_hash); + session.psbt.candidate_psbt_shape = Some(facts.psbt_shape); + } + + session.updated_at_ms = facts.timestamp_ms; + self.link_splice_shape_context( + &mut session, + &linked_psbt_shape_hash, + &linked_psbt_shape, + facts.timestamp_ms, + )?; + self.put_splice_session(&session) + } + + pub fn record_splice_signed_response( + &mut self, + facts: SpliceSignedResponseFacts, + ) -> anyhow::Result<()> { + let linked_psbt_shape_hash = facts.psbt_shape_hash.clone(); + let linked_psbt_shape = facts.psbt_shape.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.auth.splice_signed_auth = Some(facts.splice_signed_auth.clone()); + session.request_history.push(facts.splice_signed_auth); + session.psbt.candidate_psbt_shape_hash = Some(facts.psbt_shape_hash.clone()); + session.psbt.candidate_psbt_shape = Some(facts.psbt_shape); + if session.psbt.frozen_psbt_shape_hash.is_none() { + session.psbt.frozen_psbt_shape_hash = Some(facts.psbt_shape_hash.clone()); + } + session.phase = SplicePhase::SignaturesExchanging; + session.updated_at_ms = facts.timestamp_ms; + + self.link_splice_shape_context( + &mut session, + &linked_psbt_shape_hash, + &linked_psbt_shape, + 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.auth.splice_init_auth.is_some() { + bail!("peer-initiated splice sessions must not include splice_init_auth"); + } + 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_shape( + &mut self, + node_channel_id_hex: &str, + psbt_shape_hash: String, + psbt_shape: PsbtShapeV1, + auth: NormalizedRpcAuth, + 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 shape can only change while negotiating, current phase {:?}", + session.phase + ); + } + session.auth.latest_splice_update_auth = Some(auth.clone()); + session.psbt.candidate_psbt_shape_hash = Some(psbt_shape_hash); + session.psbt.candidate_psbt_shape = Some(psbt_shape); + session.request_history.push(auth); + 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_shape_hash: 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_psbt_shape_hash = Some(frozen_psbt_shape_hash); + 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_signatures_exchanging( + &mut self, + node_channel_id_hex: &str, + auth: NormalizedRpcAuth, + 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 + ) { + bail!( + "splice_signed auth requires commitments secured, current phase {:?}", + session.phase + ); + } + session.phase = SplicePhase::SignaturesExchanging; + session.auth.splice_signed_auth = Some(auth.clone()); + session.request_history.push(auth); + session.updated_at_ms = updated_at_ms; + self.put_splice_session(&session) + } + + 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, + context: SpliceWalletPsbtContextV1, + ) -> anyhow::Result<()> { + self.put_splice(&wallet_psbt_key(&context.psbt_shape_hash), &context) + } + + pub fn get_splice_wallet_psbt_context( + &self, + psbt_shape_hash: &str, + ) -> anyhow::Result> { + self.get_splice(&wallet_psbt_key(psbt_shape_hash)) + } + + pub fn link_splice_wallet_psbt( + &mut self, + psbt_shape_hash: &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_splice_wallet_psbt_context(psbt_shape_hash)? + .ok_or_else(|| anyhow!("missing wallet PSBT context {}", psbt_shape_hash))?; + let shape_matches_splice = session.psbt.candidate_psbt_shape_hash.as_deref() + == Some(psbt_shape_hash) + || session.psbt.frozen_psbt_shape_hash.as_deref() == Some(psbt_shape_hash); + if !shape_matches_splice { + bail!( + "wallet PSBT shape {} does not match splice candidate for channel {}", + psbt_shape_hash, + node_channel_id_hex + ); + } + + context.linked_node_channel_id_hex = Some(node_channel_id_hex.to_string()); + context.linked_splice_psbt_shape_hash = Some(psbt_shape_hash.to_string()); + context.updated_at_ms = updated_at_ms; + if !session + .linked_wallet_psbt_shape_hashes + .iter() + .any(|hash| hash == psbt_shape_hash) + { + session + .linked_wallet_psbt_shape_hashes + .push(psbt_shape_hash.to_string()); + } + session.updated_at_ms = updated_at_ms; + self.put_splice_wallet_psbt_context(context)?; + self.put_splice_session(&session) + } + + pub fn inherit_splice_wallet_context( + &mut self, + source_psbt_shape_hash: &str, + candidate_psbt_shape_hash: &str, + node_channel_id_hex: &str, + updated_at_ms: u64, + ) -> anyhow::Result<()> { + if source_psbt_shape_hash == candidate_psbt_shape_hash { + return Ok(()); + } + let Some(source) = self.get_splice_wallet_psbt_context(source_psbt_shape_hash)? else { + return Ok(()); + }; + let mut candidate = self + .get_splice_wallet_psbt_context(candidate_psbt_shape_hash)? + .ok_or_else(|| { + anyhow!( + "missing splice candidate PSBT context {}", + candidate_psbt_shape_hash + ) + })?; + 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_shape_hash, + node_channel_id_hex + ); + } + + if candidate.fundpsbt_auth.is_none() { + candidate.fundpsbt_auth = source.fundpsbt_auth; + } + for wallet_input in source.wallet_inputs { + let remains_in_candidate = candidate.latest_psbt_shape.inputs.iter().any(|input| { + input.prev_txid == wallet_input.txid && input.prev_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) + } + + 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_shape_hashes + .iter() + .map(|hash| wallet_psbt_key(hash)), + ); + 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 lightning_signer::channel::{ChannelSetup, CommitmentType}; + use lightning_signer::policy::validator::EnforcementState; + use serde_json::json; + use std::str::FromStr; + + fn auth(uri: &str, request_hash: &str, timestamp_ms: u64) -> NormalizedRpcAuth { + NormalizedRpcAuth::new( + uri.to_string(), + request_hash.to_string(), + "02".repeat(33), + timestamp_ms, + ) + } + + 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 shape(hash_suffix: &str) -> PsbtShapeV1 { + PsbtShapeV1 { + schema: "PsbtShapeV1".to_string(), + version: 2, + locktime: 0, + inputs: vec![PsbtShapeInputV1 { + prev_txid: format!("{}{}", "11".repeat(31), hash_suffix), + prev_vout: 0, + sequence: 0xffff_ffff, + }], + outputs: vec![PsbtShapeOutputV1 { + value_sat: 1000, + script_pubkey_hex: "0014".to_string(), + script_pubkey_hash: "aa".repeat(32), + }], + } + } + + 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(auth("/cln.Node/SpliceInit", &"66".repeat(32), 1)), + 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_keeps_authority_out_of_psbt_shape() { + 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, + }, + splice_init_auth: auth("/cln.Node/SpliceInit", &"66".repeat(32), 1), + authorized_relative_amount_sat: 50_000, + fee_policy: FeePolicy { + feerate_per_kw: Some(253), + force_feerate: Some(false), + }, + initial_psbt_shape_hash: Some("shape-init".to_string()), + initial_psbt_shape: Some(shape("04")), + 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.auth.splice_init_auth.as_ref().unwrap().uri, + "/cln.Node/SpliceInit" + ); + assert_eq!( + session.psbt.candidate_psbt_shape_hash.as_deref(), + Some("shape-init") + ); + let wallet_context = state + .get_splice_wallet_psbt_context("shape-init") + .unwrap() + .expect("splice candidate creates a linked PSBT context"); + assert_eq!( + wallet_context.linked_node_channel_id_hex.as_deref(), + Some("4444444444444444444444444444444444444444444444444444444444444444") + ); + + let psbt_shape_value = + serde_json::to_value(session.psbt.candidate_psbt_shape.as_ref().unwrap()).unwrap(); + assert!(psbt_shape_value + .get("authorized_relative_amount_sat") + .is_none()); + assert!(psbt_shape_value.get("fee_policy").is_none()); + assert!(psbt_shape_value.get("splice_init_auth").is_none()); + assert!(psbt_shape_value.get("request_hash").is_none()); + assert!(psbt_shape_value.get("caller_pubkey_hex").is_none()); + } + + #[test] + fn record_local_splice_intent_rejects_half_recorded_initial_psbt_shape() { + let mut state = State::new(); + let mut 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, + }, + splice_init_auth: auth("/cln.Node/SpliceInit", &"66".repeat(32), 1), + authorized_relative_amount_sat: 50_000, + fee_policy: FeePolicy { + feerate_per_kw: Some(253), + force_feerate: Some(false), + }, + initial_psbt_shape_hash: Some("shape-init".to_string()), + initial_psbt_shape: None, + timestamp_ms: 1, + }; + + let err = state + .record_local_splice_intent(intent.clone()) + .unwrap_err(); + assert!(err + .to_string() + .contains("initial PSBT shape hash and shape must be recorded together")); + + intent.initial_psbt_shape_hash = None; + intent.initial_psbt_shape = Some(shape("05")); + let err = state.record_local_splice_intent(intent).unwrap_err(); + assert!(err + .to_string() + .contains("initial PSBT shape hash and shape must be recorded together")); + } + + #[test] + fn psbt_shape_and_wallet_inputs_are_derived_from_psbt_without_authority() { + let prev_txid = "11".repeat(32); + let psbt = psbt_fixture( + &prev_txid, + 7, + 55_000, + vec![(50_000, "00142222222222222222222222222222222222222222")], + ); + + let (hash, parsed_shape) = psbt_shape_from_base64(&psbt).unwrap(); + assert_eq!(hash, psbt_shape_hash(&parsed_shape).unwrap()); + assert_eq!(parsed_shape.inputs[0].prev_txid, prev_txid); + assert_eq!(parsed_shape.inputs[0].prev_vout, 7); + assert_eq!(parsed_shape.outputs[0].value_sat, 50_000); + + let wallet_inputs = wallet_inputs_from_psbt( + &psbt, + &[WalletInputReservation { + txid: "11".repeat(32), + vout: 7, + reserved_to_block: Some(42), + }], + WalletInputSource::FundPsbt, + ) + .unwrap(); + assert_eq!(wallet_inputs.len(), 1); + assert_eq!(wallet_inputs[0].value_sat, 55_000); + assert_eq!(wallet_inputs[0].reserved_to_block, Some(42)); + + let shape_value = serde_json::to_value(parsed_shape).unwrap(); + assert!(shape_value.get("splice_init_auth").is_none()); + assert!(shape_value.get("signpsbt_auth").is_none()); + } + + #[test] + fn psbt_v2_shape_is_derived_from_required_fields() { + const PSBT_V2: &str = "cHNidP8BAgQCAAAAAQQBAQEFAQIB+wQCAAAAAAEOIAsK2SFBnByHGXNdctxzn56p4GONH+TB7vD5lECEgV/IAQ8EAAAAAAABAwgACK8vAAAAAAEEFgAUxDD2TEdW2jENvRoIVXLvKZkmJywAAQMIi73rCwAAAAABBBYAFE3Rk6yWSlasG54cyoRU/i9HT4UTAA=="; + + let (hash, shape) = psbt_shape_from_base64(PSBT_V2).unwrap(); + + assert_eq!(hash, psbt_shape_hash(&shape).unwrap()); + assert_eq!(shape.schema, "PsbtShapeV1"); + assert_eq!(shape.version, 2); + assert_eq!(shape.locktime, 0); + assert_eq!(shape.inputs.len(), 1); + assert_eq!( + shape.inputs[0].prev_txid, + "c85f81844094f9f0eec1e41f8d63e0a99e9f73dc725d7319871c9c4121d90a0b" + ); + assert_eq!(shape.inputs[0].prev_vout, 0); + assert_eq!(shape.inputs[0].sequence, u32::MAX); + assert_eq!(shape.outputs.len(), 2); + assert_eq!(shape.outputs[0].value_sat, 800_000_000); + assert_eq!(shape.outputs[1].value_sat, 199_998_859); + assert_eq!( + shape.outputs[0].script_pubkey_hex, + "0014c430f64c4756da310dbd1a085572ef299926272c" + ); + assert_eq!( + shape.outputs[1].script_pubkey_hex, + "00144dd193ac964a56ac1b9e1cca8454fe2f474f8513" + ); + + let candidate = candidate_funding_facts_from_psbt( + PSBT_V2, + &"99".repeat(32), + 0, + &FundingOutpoint { + txid: shape.inputs[0].prev_txid.clone(), + vout: 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, + shape.outputs[0].script_pubkey_hash + ); + } + + #[test] + fn psbt_v2_shape_requires_global_input_count() { + const PSBT_V2_WITHOUT_INPUT_COUNT: &str = "cHNidP8BAgQCAAAAAQMEAAAAAAEFAQIB+wQCAAAAAAEAUgIAAAABwaolbiFLlqGCL5PeQr/ztfP/jQUZMG41FddRWl6AWxIAAAAAAP////8BGMaaOwAAAAAWABSwo68UQghBJpPKfRZoUrUtsK7wbgAAAAABAR8Yxpo7AAAAABYAFLCjrxRCCEEmk8p9FmhStS2wrvBuAQ4gCwrZIUGcHIcZc11y3HOfnqngY40f5MHu8PmUQISBX8gBDwQAAAAAARAE/v///wAiAgLWAfhIRqZ1X3dr4A49nej7EKzJNfuDxF+wFi1MrVq3khj2nYc+VAAAgAEAAIAAAACAAAAAACoAAAABAwgACK8vAAAAAAEEFgAUxDD2TEdW2jENvRoIVXLvKZkmJywAIgIC42+/9T3VNAcM+P05ZhRoDzV6m4Xbc0C/HPp0XSrXs0AY9p2HPlQAAIABAACAAAAAgAEAAABkAAAAAQMIi73rCwAAAAABBBYAFE3Rk6yWSlasG54cyoRU/i9HT4UTAA=="; + + let err = psbt_shape_from_base64(PSBT_V2_WITHOUT_INPUT_COUNT).unwrap_err(); + + assert!(err.to_string().contains("PSBT_GLOBAL_INPUT_COUNT")); + } + + #[test] + fn records_fundpsbt_and_signpsbt_response_facts_by_shape_hash() { + let mut state = State::new(); + let psbt = psbt_fixture( + &"11".repeat(32), + 0, + 25_000, + vec![(20_000, "00143333333333333333333333333333333333333333")], + ); + let (shape_hash, parsed_shape) = psbt_shape_from_base64(&psbt).unwrap(); + let wallet_inputs = + wallet_inputs_from_psbt(&psbt, &[], WalletInputSource::FundPsbt).unwrap(); + + state + .record_fundpsbt_response(FundPsbtResponseFacts { + psbt_shape_hash: shape_hash.clone(), + psbt_shape: parsed_shape.clone(), + fundpsbt_auth: auth("/cln.Node/FundPsbt", &"ab".repeat(32), 2), + wallet_inputs, + change_outnum: Some(0), + timestamp_ms: 2, + }) + .unwrap(); + state + .record_signpsbt_response(SignPsbtResponseFacts { + psbt_shape_hash: shape_hash.clone(), + psbt_shape: parsed_shape, + signpsbt_auth: auth("/cln.Node/SignPsbt", &"cd".repeat(32), 3), + signonly: vec![0], + timestamp_ms: 3, + }) + .unwrap(); + + let context = state + .get_splice_wallet_psbt_context(&shape_hash) + .unwrap() + .unwrap(); + assert!(context.fundpsbt_auth.is_some()); + assert!(context.signpsbt_auth.is_some()); + assert_eq!(context.signonly, vec![0]); + assert_eq!(context.change_outnum, Some(0)); + assert_eq!(context.wallet_inputs[0].value_sat, 25_000); + } + + #[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, + }, + splice_init_auth: auth("/cln.Node/SpliceInit", &"66".repeat(32), 1), + authorized_relative_amount_sat: 50_000, + fee_policy: FeePolicy::default(), + initial_psbt_shape_hash: Some("shape-a".to_string()), + initial_psbt_shape: Some(shape("06")), + timestamp_ms: 1, + }) + .unwrap(); + + state + .record_signpsbt_intent(SignPsbtIntentFacts { + psbt_shape_hash: "shape-a".to_string(), + psbt_shape: shape("06"), + signpsbt_auth: auth("/cln.Node/SignPsbt", &"77".repeat(32), 2), + signonly: vec![0], + timestamp_ms: 2, + }) + .unwrap(); + + let context = state + .get_splice_wallet_psbt_context("shape-a") + .unwrap() + .unwrap(); + assert!(context.signpsbt_auth.is_some()); + 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_shape = shape("07"); + let mut candidate_shape = source_shape.clone(); + candidate_shape.outputs.push(PsbtShapeOutputV1 { + value_sat: 50_000, + script_pubkey_hex: "0014".to_string(), + script_pubkey_hash: "bb".repeat(32), + }); + state + .record_fundpsbt_response(FundPsbtResponseFacts { + psbt_shape_hash: "source-shape".to_string(), + psbt_shape: source_shape.clone(), + fundpsbt_auth: auth("/cln.Node/FundPsbt", &"77".repeat(32), 1), + wallet_inputs: vec![WalletInput { + txid: source_shape.inputs[0].prev_txid.clone(), + vout: source_shape.inputs[0].prev_vout, + value_sat: 25_000, + reserved_to_block: Some(100), + source: WalletInputSource::FundPsbt, + }], + change_outnum: None, + 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, + }, + splice_init_auth: auth("/cln.Node/SpliceInit", &"66".repeat(32), 2), + authorized_relative_amount_sat: 50_000, + fee_policy: FeePolicy::default(), + initial_psbt_shape_hash: Some("candidate-shape".to_string()), + initial_psbt_shape: Some(candidate_shape), + timestamp_ms: 2, + }) + .unwrap(); + + state + .inherit_splice_wallet_context("source-shape", "candidate-shape", &"44".repeat(32), 2) + .unwrap(); + + let candidate = state + .get_splice_wallet_psbt_context("candidate-shape") + .unwrap() + .unwrap(); + assert!(candidate.fundpsbt_auth.is_some()); + assert_eq!(candidate.wallet_inputs.len(), 1); + assert_eq!(candidate.wallet_inputs[0].reserved_to_block, Some(100)); + + let mut updated_shape = candidate.latest_psbt_shape.clone(); + updated_shape.outputs[0].value_sat += 1; + state + .record_splice_update_response(SpliceUpdateResponseFacts { + node_channel_id_hex: "44".repeat(32), + psbt_shape_hash: "updated-shape".to_string(), + psbt_shape: updated_shape, + splice_update_auth: auth("/cln.Node/SpliceUpdate", &"88".repeat(32), 3), + commitments_secured: false, + signatures_secured: None, + timestamp_ms: 3, + }) + .unwrap(); + + let updated = state + .get_splice_wallet_psbt_context("updated-shape") + .unwrap() + .unwrap(); + assert!(updated.fundpsbt_auth.is_some()); + 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 psbt = psbt_fixture( + &old_txid, + 0, + 1_000_000, + vec![(1_050_000, "00144444444444444444444444444444444444444444")], + ); + let (shape_hash, parsed_shape) = psbt_shape_from_base64(&psbt).unwrap(); + + state + .record_splice_update_response(SpliceUpdateResponseFacts { + node_channel_id_hex: "44".repeat(32), + psbt_shape_hash: shape_hash.clone(), + psbt_shape: parsed_shape.clone(), + splice_update_auth: auth("/cln.Node/SpliceUpdate", &"ef".repeat(32), 2), + 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_psbt_shape_hash.as_deref(), + Some(shape_hash.as_str()) + ); + assert!(session.cand.funding_outpoint.is_none()); + + let candidate = candidate_funding_facts_from_psbt( + &psbt, + &"99".repeat(32), + 0, + &session.old.funding_outpoint, + ) + .unwrap(); + state + .record_splice_signed_response(SpliceSignedResponseFacts { + node_channel_id_hex: "44".repeat(32), + psbt_shape_hash: shape_hash, + psbt_shape: parsed_shape, + splice_signed_auth: auth("/cln.Node/SpliceSigned", &"12".repeat(32), 3), + 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_serializes_to_grouped_schema() { + let session = local_session(); + + 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_some()); + 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_shape_hashes").is_some()); + 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["auth"]["splice_init_auth"]["uri"], + json!("/cln.Node/SpliceInit") + ); + assert!(value["auth"]["splice_init_auth"].get("request").is_none()); + assert!(value["auth"]["splice_init_auth"].get("payload").is_none()); + } + + #[test] + fn local_session_updates_freezes_and_tombstones_related_keys() { + let mut state = State::new(); + state.create_local_splice_session(local_session()).unwrap(); + + state + .update_splice_candidate_shape( + &"44".repeat(32), + "shape-a".to_string(), + shape("01"), + auth("/cln.Node/SpliceUpdate", &"77".repeat(32), 2), + 2, + ) + .unwrap(); + let candidate = CandidateFundingFacts { + funding_outpoint: outpoint(&"88".repeat(32), 1), + value_sat: 1_050_000, + script_pubkey_hash: "99".repeat(32), + sign_splice_tx_input_index: 0, + remote_funding_key_hex: Some("03".repeat(33)), + }; + state + .freeze_splice_candidate(&"44".repeat(32), "shape-a".to_string(), candidate, 3) + .unwrap(); + + let index_key = splice_outpoint_key(&"88".repeat(32), 1); + let index = state.values.get(&index_key).unwrap(); + assert_eq!( + index.value, + json!({ + "schema": "SpliceOutpointIndexV1", + "schema_version": 1, + "splice_session_key": format!("splices/{}", "44".repeat(32)), + }) + ); + + let session = state.get_splice_session(&"44".repeat(32)).unwrap().unwrap(); + assert_eq!(session.phase, SplicePhase::CommitmentsSecured); + assert_eq!( + session.psbt.frozen_psbt_shape_hash.as_deref(), + Some("shape-a") + ); + + state + .mark_splice_signatures_exchanging( + &"44".repeat(32), + auth("/cln.Node/SpliceSigned", &"aa".repeat(32), 4), + 4, + ) + .unwrap(); + let session = state.get_splice_session(&"44".repeat(32)).unwrap().unwrap(); + assert_eq!(session.phase, SplicePhase::SignaturesExchanging); + + state.mark_splice_pending_lock(&"44".repeat(32), 5).unwrap(); + let session = state.get_splice_session(&"44".repeat(32)).unwrap().unwrap(); + assert_eq!(session.phase, SplicePhase::PendingLock); + + let by_outpoint = state + .get_splice_by_outpoint(&"88".repeat(32), 1) + .unwrap() + .unwrap(); + assert_eq!(by_outpoint.node_channel_id_hex, "44".repeat(32)); + + state.tombstone_splice_session(&"44".repeat(32)).unwrap(); + assert!(state + .get_splice_session(&"44".repeat(32)) + .unwrap() + .is_none()); + assert!(state + .get_splice_by_outpoint(&"88".repeat(32), 1) + .unwrap() + .is_none()); + } + + #[test] + fn peer_and_dev_sessions_store_unresolved_origins_without_local_auth() { + let mut state = State::new(); + let mut peer = local_session(); + peer.origin = SpliceOrigin::PeerInitiated; + peer.auth.splice_init_auth = None; + peer.intent.authorized_relative_amount_sat = None; + peer.delta.computed = false; + peer.delta.no_local_loss = false; + state.create_peer_splice_session(peer.clone()).unwrap(); + + let mut dev = local_session(); + dev.origin = SpliceOrigin::DevSpliceUnresolved; + dev.node_channel_id_hex = "45".repeat(32); + state.create_dev_splice_session(dev.clone()).unwrap(); + + let stored_peer = state.get_splice_session(&"44".repeat(32)).unwrap().unwrap(); + assert_eq!(stored_peer.origin, SpliceOrigin::PeerInitiated); + assert!(stored_peer.auth.splice_init_auth.is_none()); + assert!(stored_peer.intent.authorized_relative_amount_sat.is_none()); + + let stored_dev = state.get_splice_session(&"45".repeat(32)).unwrap().unwrap(); + assert_eq!(stored_dev.origin, SpliceOrigin::DevSpliceUnresolved); + assert_eq!(stored_dev.phase, SplicePhase::Negotiating); + } + + #[test] + fn wallet_context_links_by_shape_and_distinguishes_fundpsbt_from_signpsbt_auth() { + let mut state = State::new(); + let mut session = local_session(); + session.psbt.candidate_psbt_shape_hash = Some("shape-a".to_string()); + state.create_local_splice_session(session).unwrap(); + + let context = SpliceWalletPsbtContextV1::new( + "shape-a".to_string(), + shape("02"), + Some(auth("/cln.Node/FundPsbt", &"aa".repeat(32), 2)), + None, + vec![WalletInput { + txid: "bb".repeat(32), + vout: 0, + value_sat: 25_000, + reserved_to_block: Some(100), + source: WalletInputSource::FundPsbt, + }], + 2, + ); + assert!(context.signpsbt_auth.is_none()); + state.put_splice_wallet_psbt_context(context).unwrap(); + + state + .link_splice_wallet_psbt("shape-a", &"44".repeat(32), 3) + .unwrap(); + + let linked = state + .get_splice_wallet_psbt_context("shape-a") + .unwrap() + .unwrap(); + let expected_channel = "44".repeat(32); + assert_eq!( + linked.linked_node_channel_id_hex.as_deref(), + Some(expected_channel.as_str()) + ); + assert_eq!( + linked.linked_splice_psbt_shape_hash.as_deref(), + Some("shape-a") + ); + + let mut signed = linked; + signed.signpsbt_auth = Some(auth("/cln.Node/SignPsbt", &"cc".repeat(32), 4)); + assert!(signed.signpsbt_auth.is_some()); + + state.tombstone_splice_session(&"44".repeat(32)).unwrap(); + assert!(state + .get_splice_wallet_psbt_context("shape-a") + .unwrap() + .is_none()); + } + + #[test] + fn outpoint_lookup_survives_restart_and_tombstone_rejects_stale_merge() { + let mut state = State::new(); + state.create_local_splice_session(local_session()).unwrap(); + state + .update_splice_candidate_shape( + &"44".repeat(32), + "shape-a".to_string(), + shape("03"), + auth("/cln.Node/SpliceUpdate", &"dd".repeat(32), 2), + 2, + ) + .unwrap(); + state + .freeze_splice_candidate( + &"44".repeat(32), + "shape-a".to_string(), + 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()); + + 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()); + } +} 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..7e51f2956 --- /dev/null +++ b/libs/gl-client/src/signer/splice_policy.rs @@ -0,0 +1,1468 @@ +use crate::pb::HsmRequestContext; +use crate::persist::{ + psbt_shape_from_base64, psbt_shape_from_psbt, transaction_shape, SpliceOrigin, SplicePhase, + SpliceSessionV1, State, +}; +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, + MissingSignPsbtAuth, + PsbtShapeMismatch, + 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.auth.splice_init_auth.is_none() + || 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_shape_hash: &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 + && psbt_shape_from_base64(psbt) + .map(|(shape_hash, _)| shape_hash == expected_shape_hash) + .unwrap_or(false) + }) +} + +fn pending_splice_update_psbt_matches( + pending_requests: &[Request], + session: &SpliceSessionV1, + expected_shape_hash: &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_shape_hash = psbt_shape_from_base64(&request.psbt) + .map(|(shape_hash, _)| shape_hash) + .ok(); + log::debug!( + "matching negotiating splice_update: channel_match={}, shape_match={}, pending_shape_hash={:?}, expected_shape_hash={}", + pending_channel_id_hex == session.channel_id_hex, + pending_shape_hash.as_deref() == Some(expected_shape_hash), + pending_shape_hash, + expected_shape_hash, + ); + pending_channel_id_hex == session.channel_id_hex + && pending_shape_hash.as_deref() == Some(expected_shape_hash) + }) +} + +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 (shape_hash, psbt_shape) = psbt_shape_from_psbt(psbt)?; + if transaction_shape(&request.tx.0) != psbt_shape { + return reject(SplicePolicyViolation::TxPsbtMismatch); + } + let expected_shape_hash = session + .psbt + .frozen_psbt_shape_hash + .as_deref() + .or(session.psbt.candidate_psbt_shape_hash.as_deref()); + if expected_shape_hash != Some(shape_hash.as_str()) + || session.psbt.candidate_psbt_shape.as_ref() != Some(&psbt_shape) + { + return reject(SplicePolicyViolation::PsbtShapeMismatch); + } + if session.phase == SplicePhase::Negotiating + && session.origin == SpliceOrigin::LocalInitiator + && !pending_splice_update_psbt_matches(pending_requests, &session, &shape_hash) + { + return reject(SplicePolicyViolation::InvalidPhase); + } + if session.origin == SpliceOrigin::LocalInitiator + && !pending_splice_psbt_matches(pending_requests, &session, &shape_hash) + { + 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 (shape_hash, shape) = psbt_shape_from_psbt(psbt)?; + let Some(context) = state.get_splice_wallet_psbt_context(&shape_hash)? 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 context.signpsbt_auth.is_none() { + return reject(SplicePolicyViolation::MissingSignPsbtAuth); + } + if let Some(violation) = session_violation( + &session, + &[ + SplicePhase::CommitmentsSecured, + SplicePhase::SignaturesExchanging, + ], + ) { + return reject(violation); + } + if context.psbt_shape_hash != shape_hash + || context.latest_psbt_shape != shape + || context.linked_splice_psbt_shape_hash.as_deref() != Some(shape_hash.as_str()) + || !session + .linked_wallet_psbt_shape_hashes + .iter() + .any(|hash| hash == &shape_hash) + { + return reject(SplicePolicyViolation::PsbtShapeMismatch); + } + let expected_splice_shape = session + .psbt + .frozen_psbt_shape_hash + .as_deref() + .or(session.psbt.candidate_psbt_shape_hash.as_deref()); + if expected_splice_shape != Some(shape_hash.as_str()) { + return reject(SplicePolicyViolation::PsbtShapeMismatch); + } + + let matching_signpsbt = pending_requests.iter().any(|pending| { + let Request::SignPsbt(pending) = pending else { + return false; + }; + psbt_shape_from_base64(&pending.psbt) + .map(|(pending_hash, _)| { + pending_hash == shape_hash && 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::{ + psbt_shape_from_psbt, CandidateFundingFacts, FeePolicy, FundPsbtResponseFacts, + FundingOutpoint, LocalSpliceIntent, NormalizedRpcAuth, OldSpliceState, SignPsbtIntentFacts, + SpliceOrigin, SpliceSessionV1, SpliceUpdateResponseFacts, State, WalletInput, + WalletInputSource, + }; + 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 auth(uri: &str, byte: &str, timestamp_ms: u64) -> NormalizedRpcAuth { + NormalizedRpcAuth::new( + uri.to_string(), + byte.repeat(32), + "02".repeat(33), + timestamp_ms, + ) + } + + 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 (shape_hash, shape) = psbt_shape_from_psbt(&psbt).unwrap(); + 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(), + splice_init_auth: auth("/cln.Node/SpliceInit", "55", 1), + authorized_relative_amount_sat: 20_000, + fee_policy: FeePolicy::default(), + initial_psbt_shape_hash: Some(shape_hash.clone()), + initial_psbt_shape: Some(shape.clone()), + 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, + None, + FeePolicy::default(), + 1, + ); + session.psbt.candidate_psbt_shape_hash = Some(shape_hash.clone()); + session.psbt.candidate_psbt_shape = Some(shape.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, + shape_hash, + 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 (shape_hash, shape) = psbt_shape_from_base64(&signed.psbt).unwrap(); + 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, + splice_init_auth: auth("/cln.Node/SpliceInit", "55", 1), + authorized_relative_amount_sat: 20_000, + fee_policy: FeePolicy::default(), + initial_psbt_shape_hash: Some(shape_hash.clone()), + initial_psbt_shape: Some(shape.clone()), + timestamp_ms: 1, + }) + .unwrap(); + state + .record_splice_update_response(SpliceUpdateResponseFacts { + node_channel_id_hex: session.node_channel_id_hex, + psbt_shape_hash: shape_hash, + psbt_shape: shape, + splice_update_auth: auth("/cln.Node/SpliceUpdate", "66", 2), + 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 (shape_hash, shape) = psbt_shape_from_psbt(&psbt).unwrap(); + 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_shape_hash: shape_hash.clone(), + psbt_shape: shape.clone(), + fundpsbt_auth: auth("/cln.Node/FundPsbt", "55", 1), + wallet_inputs: vec![WalletInput { + txid: wallet_txid.to_string(), + vout: 1, + value_sat: 25_000, + reserved_to_block: Some(100), + source: WalletInputSource::FundPsbt, + }], + change_outnum: None, + 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(), + splice_init_auth: auth("/cln.Node/SpliceInit", "66", 2), + authorized_relative_amount_sat: 20_000, + fee_policy: FeePolicy::default(), + initial_psbt_shape_hash: Some(shape_hash.clone()), + initial_psbt_shape: Some(shape.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, + 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_shape_hash: shape_hash.clone(), + psbt_shape: shape.clone(), + splice_update_auth: auth("/cln.Node/SpliceUpdate", "77", 3), + commitments_secured, + signatures_secured: Some(false), + timestamp_ms: 3, + }) + .unwrap(); + state + .record_signpsbt_intent(SignPsbtIntentFacts { + psbt_shape_hash: shape_hash, + psbt_shape: shape, + signpsbt_auth: auth("/cln.Node/SignPsbt", "88", 4), + 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 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_with_fundpsbt_only_rejects() { + let (message, pending, mut state) = sign_withdrawal_fixture(); + let Message::SignWithdrawal(request) = &message else { + unreachable!(); + }; + let (shape_hash, _) = psbt_shape_from_psbt(&request.psbt.0.psbt.inner).unwrap(); + let mut context = state + .get_splice_wallet_psbt_context(&shape_hash) + .unwrap() + .unwrap(); + context.signpsbt_auth = None; + state.put_splice_wallet_psbt_context(context).unwrap(); + + assert_eq!( + classify(&message, &pending, &state, &[], None).unwrap(), + SplicePolicyDecision::Rejected(SplicePolicyViolation::MissingSignPsbtAuth) + ); + } + + #[test] + fn negotiating_splice_signwithdrawal_with_fundpsbt_only_rejects_missing_auth() { + let (message, pending, mut state) = + sign_withdrawal_fixture_for_origin(SpliceOrigin::LocalInitiator, false, false, false); + let Message::SignWithdrawal(request) = &message else { + unreachable!(); + }; + let (shape_hash, _) = psbt_shape_from_psbt(&request.psbt.0.psbt.inner).unwrap(); + let mut context = state + .get_splice_wallet_psbt_context(&shape_hash) + .unwrap() + .unwrap(); + context.signpsbt_auth = None; + state.put_splice_wallet_psbt_context(context).unwrap(); + + assert_eq!( + classify(&message, &pending, &state, &[], None).unwrap(), + SplicePolicyDecision::Rejected(SplicePolicyViolation::MissingSignPsbtAuth) + ); + } + + #[test] + fn splice_signwithdrawal_outside_signonly_rejects() { + let (message, mut pending, mut state) = sign_withdrawal_fixture(); + let Message::SignWithdrawal(request) = &message else { + unreachable!(); + }; + let (shape_hash, _) = psbt_shape_from_psbt(&request.psbt.0.psbt.inner).unwrap(); + let mut context = state + .get_splice_wallet_psbt_context(&shape_hash) + .unwrap() + .unwrap(); + context.signonly = vec![0]; + state.put_splice_wallet_psbt_context(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_check_outpoint_reaches_vls_boundary() { + // 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 message = Message::CheckOutpoint(CheckOutpoint { + funding_txid: Txid::from_str(&outpoint.txid).unwrap(), + funding_txout: outpoint.vout as u16, + }); + + assert_eq!( + classify( + &message, + &[], + &fixture.state, + &fixture.local_node_id, + Some(&fixture.context), + ) + .unwrap(), + SplicePolicyDecision::RequiresVlsProof(VlsProof::OutpointBurial) + ); + } + + #[test] + fn known_splice_lock_outpoint_reaches_vls_boundary() { + // 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 message = Message::LockOutpoint(LockOutpoint { + funding_txid: Txid::from_str(&outpoint.txid).unwrap(), + funding_txout: outpoint.vout as u16, + }); + + assert_eq!( + classify( + &message, + &[], + &fixture.state, + &fixture.local_node_id, + Some(&fixture.context), + ) + .unwrap(), + SplicePolicyDecision::RequiresVlsProof(VlsProof::OutpointLock) + ); + } + + #[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/Cargo.toml b/libs/gl-plugin/Cargo.toml index f1173ddcd..07ad42916 100644 --- a/libs/gl-plugin/Cargo.toml +++ b/libs/gl-plugin/Cargo.toml @@ -34,6 +34,7 @@ nix = "^0" prost = "0.12" serde = { version = "1.0", features = ["derive"] } serde_json = "1" +sha256 = "1" sled = "0.34" thiserror = "1" tokio = { version = "1", features = ["full"] } 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..caafb91ff 100644 --- a/libs/gl-plugin/src/node/wrapper.rs +++ b/libs/gl-plugin/src/node/wrapper.rs @@ -2,11 +2,20 @@ use std::collections::HashMap; use std::str::FromStr; use anyhow::Error; +use base64::{engine::general_purpose, Engine as _}; +use bytes::Buf; 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, psbt_shape_from_base64, wallet_inputs_from_psbt, FeePolicy, + FundPsbtResponseFacts, FundingOutpoint, LocalSpliceIntent, NormalizedRpcAuth, OldSpliceState, + SignPsbtIntentFacts, SpliceSignedResponseFacts, SpliceUpdateResponseFacts, + WalletInputReservation, WalletInputSource, +}; use log::debug; +use prost::Message; use tokio_stream::wrappers::ReceiverStream; use tonic::{Request, Response, Status}; @@ -377,7 +386,42 @@ impl Node for WrappedNodeServer { &self, r: Request, ) -> Result, Status> { - self.inner.fund_psbt(r).await + let auth = maybe_normalized_auth("/cln.Node/FundPsbt", &r)?; + let response = self.inner.fund_psbt(r).await?; + if let Some(auth) = auth { + let body = response.get_ref(); + let (psbt_shape_hash, psbt_shape) = + psbt_shape_from_base64(&body.psbt).map_err(internal_status)?; + let timestamp_ms = auth.timestamp_ms; + let change_outnum = body.change_outnum; + 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, WalletInputSource::FundPsbt) + .map_err(internal_status)?; + + self.update_splice_state(|state| { + state.record_fundpsbt_response(FundPsbtResponseFacts { + psbt_shape_hash, + psbt_shape, + fundpsbt_auth: auth, + wallet_inputs, + change_outnum, + timestamp_ms, + }) + }) + .await?; + } + Ok(response) } async fn send_psbt( @@ -391,6 +435,25 @@ impl Node for WrappedNodeServer { &self, r: Request, ) -> Result, Status> { + let auth = maybe_normalized_auth("/cln.Node/SignPsbt", &r)?; + if let Some(auth) = auth { + let body = r.get_ref(); + let (psbt_shape_hash, psbt_shape) = + psbt_shape_from_base64(&body.psbt).map_err(internal_status)?; + let timestamp_ms = auth.timestamp_ms; + let signonly = body.signonly.clone(); + + self.update_splice_state(|state| { + state.record_signpsbt_intent(SignPsbtIntentFacts { + psbt_shape_hash, + psbt_shape, + signpsbt_auth: auth, + signonly, + timestamp_ms, + }) + }) + .await?; + } self.inner.sign_psbt(r).await } @@ -810,21 +873,135 @@ impl Node for WrappedNodeServer { &self, request: tonic::Request, ) -> Result, tonic::Status> { - self.inner.splice_init(request).await + let auth = normalized_auth("/cln.Node/SpliceInit", &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_shape_hash = request_body + .initialpsbt + .as_deref() + .map(psbt_shape_from_base64) + .transpose() + .map_err(internal_status)? + .map(|(hash, _)| hash); + + let response = self.inner.splice_init(request).await?; + let body = response.get_ref(); + let (psbt_shape_hash, psbt_shape) = + psbt_shape_from_base64(&body.psbt).map_err(internal_status)?; + let candidate_psbt_shape_hash = psbt_shape_hash.clone(); + let timestamp_ms = auth.timestamp_ms; + 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, + splice_init_auth: auth, + 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_shape_hash: Some(psbt_shape_hash), + initial_psbt_shape: Some(psbt_shape), + timestamp_ms, + })?; + if let Some(source_psbt_shape_hash) = source_wallet_psbt_shape_hash.as_deref() { + state.inherit_splice_wallet_context( + source_psbt_shape_hash, + &candidate_psbt_shape_hash, + &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 auth = normalized_auth("/cln.Node/SpliceSigned", &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_shape_hash, psbt_shape) = + psbt_shape_from_base64(&body.psbt).map_err(internal_status)?; + let response_psbt = body.psbt.clone(); + let funding_txid = hex::encode(&body.txid); + let funding_outnum = body.outnum; + let timestamp_ms = auth.timestamp_ms; + 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_shape_hash, + psbt_shape, + splice_signed_auth: auth, + candidate, + timestamp_ms, + }) + }) + .await?; + Ok(response) } async fn splice_update( &self, request: tonic::Request, ) -> Result, tonic::Status> { - self.inner.splice_update(request).await + let auth = normalized_auth("/cln.Node/SpliceUpdate", &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_shape_hash, psbt_shape) = + psbt_shape_from_base64(&body.psbt).map_err(internal_status)?; + let commitments_secured = body.commitments_secured; + let signatures_secured = body.signatures_secured; + let timestamp_ms = auth.timestamp_ms; + self.update_splice_state(|state| { + state.record_splice_update_response(SpliceUpdateResponseFacts { + node_channel_id_hex, + psbt_shape_hash, + psbt_shape, + splice_update_auth: auth, + commitments_secured, + signatures_secured, + timestamp_ms, + }) + }) + .await?; + Ok(response) } async fn dev_splice( @@ -1116,7 +1293,159 @@ impl Node for WrappedNodeServer { } } +fn internal_status(error: impl std::fmt::Display) -> Status { + Status::internal(error.to_string()) +} + +fn decode_auth_metadata(request: &Request, key: &'static str) -> Result, Status> { + let value = request + .metadata() + .get(key) + .ok_or_else(|| Status::unauthenticated(format!("missing {key} metadata")))?; + general_purpose::STANDARD_NO_PAD + .decode(value.as_bytes()) + .map_err(|e| Status::unauthenticated(format!("invalid {key} metadata: {e}"))) +} + +fn normalized_auth( + uri: &str, + request: &Request, +) -> Result { + let caller_pubkey = decode_auth_metadata(request, "glauthpubkey")?; + let timestamp_bytes = decode_auth_metadata(request, "glts")?; + if timestamp_bytes.len() != 8 { + return Err(Status::unauthenticated(format!( + "invalid glts metadata length {}", + timestamp_bytes.len() + ))); + } + + let mut timestamp_slice = timestamp_bytes.as_slice(); + let timestamp_ms = timestamp_slice.get_u64(); + let mut payload = Vec::new(); + request + .get_ref() + .encode(&mut payload) + .map_err(|e| Status::internal(format!("failed to encode request for auth hash: {e}")))?; + + Ok(normalized_rpc_auth_from_payload( + uri, + &payload, + &caller_pubkey, + timestamp_ms, + )) +} + +fn normalized_rpc_auth_from_payload( + uri: &str, + payload: &[u8], + caller_pubkey: &[u8], + timestamp_ms: u64, +) -> NormalizedRpcAuth { + NormalizedRpcAuth::new( + uri.to_string(), + sha256::digest(payload), + hex::encode(caller_pubkey), + timestamp_ms, + ) +} + +fn maybe_normalized_auth( + uri: &str, + request: &Request, +) -> Result, Status> { + let has_auth_metadata = + request.metadata().contains_key("glauthpubkey") || request.metadata().contains_key("glts"); + if !has_auth_metadata { + return Ok(None); + } + + normalized_auth(uri, request).map(Some) +} + +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"