From 326818b40a8b3d523e2b2766b2d409a7c9d4b317 Mon Sep 17 00:00:00 2001 From: Ihor Diachenko Date: Thu, 9 Jul 2026 00:25:16 +0300 Subject: [PATCH 1/8] gl-client: Add low-level splice RPC plumbing --- libs/gl-client-py/glclient/__init__.py | 3 + libs/gl-client/src/signer/model/cln.rs | 89 ++++++++++++++++++++++++++ libs/gl-client/src/signer/model/mod.rs | 4 ++ 3 files changed, 96 insertions(+) diff --git a/libs/gl-client-py/glclient/__init__.py b/libs/gl-client-py/glclient/__init__.py index abc552695..dfd3a402b 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() 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), From 19fcc43b4f2b23c331c6115e42658984386d9176 Mon Sep 17 00:00:00 2001 From: Ihor Diachenko Date: Thu, 9 Jul 2026 19:04:05 +0300 Subject: [PATCH 2/8] gl-client: Add durable splice state primitives --- libs/gl-client/src/persist.rs | 2 + libs/gl-client/src/persist/splice.rs | 940 +++++++++++++++++++++++++++ 2 files changed, 942 insertions(+) create mode 100644 libs/gl-client/src/persist/splice.rs diff --git a/libs/gl-client/src/persist.rs b/libs/gl-client/src/persist.rs index e22ecf66e..bb8a5de87 100644 --- a/libs/gl-client/src/persist.rs +++ b/libs/gl-client/src/persist.rs @@ -1,4 +1,6 @@ mod canonical; +#[allow(dead_code)] +pub(crate) mod splice; 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..5661d786f --- /dev/null +++ b/libs/gl-client/src/persist/splice.rs @@ -0,0 +1,940 @@ +use super::{State, StateEntry}; +use anyhow::{anyhow, bail}; +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(crate) enum SpliceOrigin { + LocalInitiator, + PeerInitiated, + DevSpliceUnresolved, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub(crate) enum SplicePhase { + Negotiating, + CommitmentsSecured, + SignaturesExchanging, + PendingLock, + Locked, + Aborted, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub(crate) 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(crate) enum WalletInputSource { + FundPsbt, + SignPsbt, + Unknown, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub(crate) enum SpliceTerminalReason { + Locked, + Aborted, + ChannelDeleted, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub(crate) struct FundingOutpoint { + pub(crate) txid: String, + pub(crate) vout: u32, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub(crate) struct NormalizedRpcAuth { + pub(crate) schema: String, + pub(crate) schema_version: u16, + pub(crate) uri: String, + pub(crate) request_hash: String, + pub(crate) caller_pubkey_hex: String, + pub(crate) timestamp_ms: u64, +} + +impl NormalizedRpcAuth { + pub(crate) 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(crate) struct PsbtShapeInputV1 { + pub(crate) prev_txid: String, + pub(crate) prev_vout: u32, + pub(crate) sequence: u32, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub(crate) struct PsbtShapeOutputV1 { + pub(crate) value_sat: u64, + pub(crate) script_pubkey_hex: String, + pub(crate) script_pubkey_hash: String, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub(crate) struct PsbtShapeV1 { + pub(crate) schema: String, + pub(crate) version: i32, + pub(crate) locktime: u32, + pub(crate) inputs: Vec, + pub(crate) outputs: Vec, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub(crate) struct OldSpliceState { + pub(crate) funding_outpoint: FundingOutpoint, + pub(crate) channel_value_sat: u64, + pub(crate) local_balance_sat: u64, +} + +#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +pub(crate) struct SpliceAuthState { + pub(crate) splice_init_auth: Option, + pub(crate) latest_splice_update_auth: Option, + pub(crate) splice_signed_auth: Option, +} + +#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +pub(crate) struct FeePolicy { + pub(crate) feerate_per_kw: Option, + pub(crate) force_feerate: Option, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub(crate) struct SpliceIntentState { + pub(crate) authorized_relative_amount_sat: Option, + pub(crate) fee_policy: FeePolicy, +} + +#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +pub(crate) struct SplicePsbtState { + pub(crate) candidate_psbt_shape_hash: Option, + pub(crate) candidate_psbt_shape: Option, + pub(crate) frozen_psbt_shape_hash: Option, +} + +#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +pub(crate) struct SpliceCandidateState { + pub(crate) funding_outpoint: Option, + pub(crate) value_sat: Option, + pub(crate) script_pubkey_hash: Option, + pub(crate) sign_splice_tx_input_index: Option, + pub(crate) remote_funding_key_hex: Option, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) struct CandidateFundingFacts { + pub(crate) funding_outpoint: FundingOutpoint, + pub(crate) value_sat: u64, + pub(crate) script_pubkey_hash: String, + pub(crate) sign_splice_tx_input_index: u32, + pub(crate) 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(crate) struct SpliceDeltaState { + pub(crate) computed: bool, + pub(crate) channel_delta_sat: i64, + pub(crate) wallet_input_delta_sat: i64, + pub(crate) wallet_output_delta_sat: i64, + pub(crate) fee_burden_sat: i64, + pub(crate) no_local_loss: bool, + pub(crate) source: DeltaSource, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub(crate) struct SignerRequestRecord { + pub(crate) request_type: String, + pub(crate) request_hash: String, + pub(crate) phase: SplicePhase, + pub(crate) timestamp_ms: u64, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub(crate) struct SpliceSessionV1 { + pub(crate) schema: String, + pub(crate) schema_version: u16, + pub(crate) origin: SpliceOrigin, + pub(crate) phase: SplicePhase, + pub(crate) node_id_hex: String, + pub(crate) channel_id_hex: String, + pub(crate) node_channel_id_hex: String, + pub(crate) old: OldSpliceState, + pub(crate) auth: SpliceAuthState, + pub(crate) intent: SpliceIntentState, + pub(crate) psbt: SplicePsbtState, + pub(crate) cand: SpliceCandidateState, + pub(crate) delta: SpliceDeltaState, + pub(crate) linked_wallet_psbt_shape_hashes: Vec, + pub(crate) request_history: Vec, + pub(crate) signer_request_history: Vec, + pub(crate) created_at_ms: u64, + pub(crate) updated_at_ms: u64, + pub(crate) terminal_reason: Option, +} + +impl SpliceSessionV1 { + pub(crate) 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(crate) struct SpliceOutpointIndexV1 { + pub(crate) schema: String, + pub(crate) schema_version: u16, + pub(crate) 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(crate) struct WalletInput { + pub(crate) txid: String, + pub(crate) vout: u32, + pub(crate) value_sat: u64, + pub(crate) reserved_to_block: Option, + pub(crate) source: WalletInputSource, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub(crate) struct SpliceWalletPsbtContextV1 { + pub(crate) schema: String, + pub(crate) schema_version: u16, + pub(crate) psbt_shape_hash: String, + pub(crate) latest_psbt_shape: PsbtShapeV1, + pub(crate) fundpsbt_auth: Option, + pub(crate) signpsbt_auth: Option, + pub(crate) signonly: Vec, + pub(crate) wallet_inputs: Vec, + pub(crate) change_outnum: Option, + pub(crate) linked_node_channel_id_hex: Option, + pub(crate) linked_splice_psbt_shape_hash: Option, + pub(crate) created_at_ms: u64, + pub(crate) updated_at_ms: u64, +} + +impl SpliceWalletPsbtContextV1 { + pub(crate) 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, + } + } +} + +impl State { + 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(crate) 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 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(crate) 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(crate) 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(crate) fn create_dev_splice_session( + &mut self, + session: SpliceSessionV1, + ) -> anyhow::Result<()> { + self.create_new_splice_session(session, SpliceOrigin::DevSpliceUnresolved) + } + + pub(crate) 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(crate) 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(crate) 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(crate) 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(crate) 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(crate) 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(crate) fn put_splice_wallet_psbt_context( + &mut self, + context: SpliceWalletPsbtContextV1, + ) -> anyhow::Result<()> { + self.put_splice(&wallet_psbt_key(&context.psbt_shape_hash), &context) + } + + pub(crate) fn get_splice_wallet_psbt_context( + &self, + psbt_shape_hash: &str, + ) -> anyhow::Result> { + self.get_splice(&wallet_psbt_key(psbt_shape_hash)) + } + + pub(crate) 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(crate) 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::pb::SignerStateEntry; + use serde_json::json; + + 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 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 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 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()); + } +} From 5a7d1a0ae2f5b796b0209572887562fd037ef3e0 Mon Sep 17 00:00:00 2001 From: Ihor Diachenko Date: Thu, 16 Jul 2026 02:07:47 +0300 Subject: [PATCH 3/8] gl-client: Capture CLN splice response facts --- Cargo.lock | 1 + libs/gl-client/src/persist.rs | 9 +- libs/gl-client/src/persist/splice.rs | 959 +++++++++++++++++++++++---- libs/gl-plugin/Cargo.toml | 1 + libs/gl-plugin/src/node/wrapper.rs | 305 ++++++++- 5 files changed, 1126 insertions(+), 149 deletions(-) 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-client/src/persist.rs b/libs/gl-client/src/persist.rs index bb8a5de87..3ac6860a8 100644 --- a/libs/gl-client/src/persist.rs +++ b/libs/gl-client/src/persist.rs @@ -1,6 +1,13 @@ mod canonical; #[allow(dead_code)] -pub(crate) mod splice; +mod splice; + +pub use splice::{ + candidate_funding_facts_from_psbt, psbt_shape_from_base64, wallet_inputs_from_psbt, FeePolicy, + FundPsbtResponseFacts, FundingOutpoint, LocalSpliceIntent, NormalizedRpcAuth, OldSpliceState, + SignPsbtResponseFacts, SpliceSignedResponseFacts, SpliceUpdateResponseFacts, + WalletInputReservation, WalletInputSource, +}; 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 index 5661d786f..cab28e49a 100644 --- a/libs/gl-client/src/persist/splice.rs +++ b/libs/gl-client/src/persist/splice.rs @@ -1,5 +1,10 @@ +use super::canonical::canonical_json_bytes; use super::{State, StateEntry}; +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}; @@ -21,7 +26,7 @@ fn wallet_psbt_key(psbt_shape_hash: &str) -> String { #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] -pub(crate) enum SpliceOrigin { +pub enum SpliceOrigin { LocalInitiator, PeerInitiated, DevSpliceUnresolved, @@ -29,7 +34,7 @@ pub(crate) enum SpliceOrigin { #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] -pub(crate) enum SplicePhase { +pub enum SplicePhase { Negotiating, CommitmentsSecured, SignaturesExchanging, @@ -40,7 +45,7 @@ pub(crate) enum SplicePhase { #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] -pub(crate) enum DeltaSource { +pub enum DeltaSource { Vls, Cln, Unresolved, @@ -54,7 +59,7 @@ impl Default for DeltaSource { #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] -pub(crate) enum WalletInputSource { +pub enum WalletInputSource { FundPsbt, SignPsbt, Unknown, @@ -62,30 +67,30 @@ pub(crate) enum WalletInputSource { #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] -pub(crate) enum SpliceTerminalReason { +pub enum SpliceTerminalReason { Locked, Aborted, ChannelDeleted, } #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] -pub(crate) struct FundingOutpoint { - pub(crate) txid: String, - pub(crate) vout: u32, +pub struct FundingOutpoint { + pub txid: String, + pub vout: u32, } #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] -pub(crate) struct NormalizedRpcAuth { - pub(crate) schema: String, - pub(crate) schema_version: u16, - pub(crate) uri: String, - pub(crate) request_hash: String, - pub(crate) caller_pubkey_hex: String, - pub(crate) timestamp_ms: u64, +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(crate) fn new( + pub fn new( uri: String, request_hash: String, caller_pubkey_hex: String, @@ -103,77 +108,91 @@ impl NormalizedRpcAuth { } #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] -pub(crate) struct PsbtShapeInputV1 { - pub(crate) prev_txid: String, - pub(crate) prev_vout: u32, - pub(crate) sequence: u32, +pub struct PsbtShapeInputV1 { + pub prev_txid: String, + pub prev_vout: u32, + pub sequence: u32, } #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] -pub(crate) struct PsbtShapeOutputV1 { - pub(crate) value_sat: u64, - pub(crate) script_pubkey_hex: String, - pub(crate) script_pubkey_hash: String, +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(crate) struct PsbtShapeV1 { - pub(crate) schema: String, - pub(crate) version: i32, - pub(crate) locktime: u32, - pub(crate) inputs: Vec, - pub(crate) outputs: Vec, +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(crate) struct OldSpliceState { - pub(crate) funding_outpoint: FundingOutpoint, - pub(crate) channel_value_sat: u64, - pub(crate) local_balance_sat: u64, +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(crate) struct SpliceAuthState { - pub(crate) splice_init_auth: Option, - pub(crate) latest_splice_update_auth: Option, - pub(crate) splice_signed_auth: Option, +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(crate) struct FeePolicy { - pub(crate) feerate_per_kw: Option, - pub(crate) force_feerate: Option, +pub struct FeePolicy { + pub feerate_per_kw: Option, + pub force_feerate: Option, } #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] -pub(crate) struct SpliceIntentState { - pub(crate) authorized_relative_amount_sat: Option, - pub(crate) fee_policy: FeePolicy, +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(crate) struct SplicePsbtState { - pub(crate) candidate_psbt_shape_hash: Option, - pub(crate) candidate_psbt_shape: Option, - pub(crate) frozen_psbt_shape_hash: Option, +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(crate) struct SpliceCandidateState { - pub(crate) funding_outpoint: Option, - pub(crate) value_sat: Option, - pub(crate) script_pubkey_hash: Option, - pub(crate) sign_splice_tx_input_index: Option, - pub(crate) remote_funding_key_hex: Option, +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(crate) struct CandidateFundingFacts { - pub(crate) funding_outpoint: FundingOutpoint, - pub(crate) value_sat: u64, - pub(crate) script_pubkey_hash: String, - pub(crate) sign_splice_tx_input_index: u32, - pub(crate) remote_funding_key_hex: Option, +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 { @@ -189,49 +208,49 @@ impl From for SpliceCandidateState { } #[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] -pub(crate) struct SpliceDeltaState { - pub(crate) computed: bool, - pub(crate) channel_delta_sat: i64, - pub(crate) wallet_input_delta_sat: i64, - pub(crate) wallet_output_delta_sat: i64, - pub(crate) fee_burden_sat: i64, - pub(crate) no_local_loss: bool, - pub(crate) source: DeltaSource, +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(crate) struct SignerRequestRecord { - pub(crate) request_type: String, - pub(crate) request_hash: String, - pub(crate) phase: SplicePhase, - pub(crate) timestamp_ms: u64, +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(crate) struct SpliceSessionV1 { - pub(crate) schema: String, - pub(crate) schema_version: u16, - pub(crate) origin: SpliceOrigin, - pub(crate) phase: SplicePhase, - pub(crate) node_id_hex: String, - pub(crate) channel_id_hex: String, - pub(crate) node_channel_id_hex: String, - pub(crate) old: OldSpliceState, - pub(crate) auth: SpliceAuthState, - pub(crate) intent: SpliceIntentState, - pub(crate) psbt: SplicePsbtState, - pub(crate) cand: SpliceCandidateState, - pub(crate) delta: SpliceDeltaState, - pub(crate) linked_wallet_psbt_shape_hashes: Vec, - pub(crate) request_history: Vec, - pub(crate) signer_request_history: Vec, - pub(crate) created_at_ms: u64, - pub(crate) updated_at_ms: u64, - pub(crate) terminal_reason: Option, +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(crate) fn new( + pub fn new( origin: SpliceOrigin, node_id_hex: String, channel_id_hex: String, @@ -274,10 +293,10 @@ impl SpliceSessionV1 { } #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] -pub(crate) struct SpliceOutpointIndexV1 { - pub(crate) schema: String, - pub(crate) schema_version: u16, - pub(crate) splice_session_key: String, +pub struct SpliceOutpointIndexV1 { + pub schema: String, + pub schema_version: u16, + pub splice_session_key: String, } impl SpliceOutpointIndexV1 { @@ -291,33 +310,33 @@ impl SpliceOutpointIndexV1 { } #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] -pub(crate) struct WalletInput { - pub(crate) txid: String, - pub(crate) vout: u32, - pub(crate) value_sat: u64, - pub(crate) reserved_to_block: Option, - pub(crate) source: WalletInputSource, +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(crate) struct SpliceWalletPsbtContextV1 { - pub(crate) schema: String, - pub(crate) schema_version: u16, - pub(crate) psbt_shape_hash: String, - pub(crate) latest_psbt_shape: PsbtShapeV1, - pub(crate) fundpsbt_auth: Option, - pub(crate) signpsbt_auth: Option, - pub(crate) signonly: Vec, - pub(crate) wallet_inputs: Vec, - pub(crate) change_outnum: Option, - pub(crate) linked_node_channel_id_hex: Option, - pub(crate) linked_splice_psbt_shape_hash: Option, - pub(crate) created_at_ms: u64, - pub(crate) updated_at_ms: u64, +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(crate) fn new( + pub fn new( psbt_shape_hash: String, latest_psbt_shape: PsbtShapeV1, fundpsbt_auth: Option, @@ -343,6 +362,215 @@ impl SpliceWalletPsbtContextV1 { } } +#[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, +} + +#[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, +} + +fn tx_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())) +} + +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 psbt = Psbt::deserialize(&raw).map_err(|e| anyhow!("failed to parse PSBT: {e}"))?; + let shape = tx_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 psbt = parse_psbt(psbt)?; + let old_input_index = psbt + .unsigned_tx + .input + .iter() + .position(|input| { + input.previous_output.txid.to_string() == old_funding_outpoint.txid + && input.previous_output.vout == old_funding_outpoint.vout + }) + .ok_or_else(|| anyhow!("splice PSBT does not spend old funding outpoint"))?; + let output = psbt + .unsigned_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: 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 { fn get_splice(&self, key: &str) -> anyhow::Result> where @@ -374,7 +602,7 @@ impl State { Ok(()) } - pub(crate) fn get_splice_session( + pub fn get_splice_session( &self, node_channel_id_hex: &str, ) -> anyhow::Result> { @@ -412,10 +640,7 @@ impl State { self.put_splice_session(&session) } - pub(crate) fn create_local_splice_session( - &mut self, - session: SpliceSessionV1, - ) -> anyhow::Result<()> { + 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"); } @@ -425,10 +650,191 @@ impl State { self.create_new_splice_session(session, SpliceOrigin::LocalInitiator) } - pub(crate) fn create_peer_splice_session( + 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; + 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; + 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_splice_update_response( &mut self, - session: SpliceSessionV1, + facts: SpliceUpdateResponseFacts, ) -> anyhow::Result<()> { + 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.put_splice_session(&session) + } + + pub fn record_splice_signed_response( + &mut self, + facts: SpliceSignedResponseFacts, + ) -> anyhow::Result<()> { + 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; + + 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"); } @@ -438,14 +844,11 @@ impl State { self.create_new_splice_session(session, SpliceOrigin::PeerInitiated) } - pub(crate) fn create_dev_splice_session( - &mut self, - session: SpliceSessionV1, - ) -> anyhow::Result<()> { + pub fn create_dev_splice_session(&mut self, session: SpliceSessionV1) -> anyhow::Result<()> { self.create_new_splice_session(session, SpliceOrigin::DevSpliceUnresolved) } - pub(crate) fn update_splice_candidate_shape( + pub fn update_splice_candidate_shape( &mut self, node_channel_id_hex: &str, psbt_shape_hash: String, @@ -470,7 +873,7 @@ impl State { self.put_splice_session(&session) } - pub(crate) fn freeze_splice_candidate( + pub fn freeze_splice_candidate( &mut self, node_channel_id_hex: &str, frozen_psbt_shape_hash: String, @@ -496,7 +899,7 @@ impl State { self.put_splice_outpoint_index(&candidate_outpoint, &index) } - pub(crate) fn mark_splice_signatures_exchanging( + pub fn mark_splice_signatures_exchanging( &mut self, node_channel_id_hex: &str, auth: NormalizedRpcAuth, @@ -521,7 +924,7 @@ impl State { self.put_splice_session(&session) } - pub(crate) fn mark_splice_pending_lock( + pub fn mark_splice_pending_lock( &mut self, node_channel_id_hex: &str, updated_at_ms: u64, @@ -545,7 +948,7 @@ impl State { self.put_splice_session(&session) } - pub(crate) fn put_splice_outpoint_index( + pub fn put_splice_outpoint_index( &mut self, candidate_outpoint: &FundingOutpoint, index: &SpliceOutpointIndexV1, @@ -556,7 +959,7 @@ impl State { ) } - pub(crate) fn get_splice_by_outpoint( + pub fn get_splice_by_outpoint( &self, txid: &str, vout: u32, @@ -569,21 +972,21 @@ impl State { self.get_splice(&index.splice_session_key) } - pub(crate) fn put_splice_wallet_psbt_context( + 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(crate) fn get_splice_wallet_psbt_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(crate) fn link_splice_wallet_psbt( + pub fn link_splice_wallet_psbt( &mut self, psbt_shape_hash: &str, node_channel_id_hex: &str, @@ -623,10 +1026,7 @@ impl State { self.put_splice_session(&session) } - pub(crate) fn tombstone_splice_session( - &mut self, - node_channel_id_hex: &str, - ) -> anyhow::Result<()> { + 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(()); }; @@ -653,8 +1053,15 @@ impl State { #[cfg(test)] mod tests { use super::*; + use crate::bitcoin::absolute::LockTime; + use crate::bitcoin::psbt::Psbt; + use crate::bitcoin::transaction::Version; + use crate::bitcoin::{ + Amount, OutPoint, ScriptBuf, Sequence, Transaction, TxIn, TxOut, Txid, Witness, + }; use crate::pb::SignerStateEntry; use serde_json::json; + use std::str::FromStr; fn auth(uri: &str, request_hash: &str, timestamp_ms: u64) -> NormalizedRpcAuth { NormalizedRpcAuth::new( @@ -690,6 +1097,42 @@ mod tests { } } + 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, @@ -711,6 +1154,236 @@ mod tests { ) } + #[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 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 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 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(); 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/wrapper.rs b/libs/gl-plugin/src/node/wrapper.rs index 6c573daea..b39ea51aa 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, + SignPsbtResponseFacts, 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,7 +435,27 @@ impl Node for WrappedNodeServer { &self, r: Request, ) -> Result, Status> { - self.inner.sign_psbt(r).await + let auth = maybe_normalized_auth("/cln.Node/SignPsbt", &r)?; + let signonly = r.get_ref().signonly.clone(); + let response = self.inner.sign_psbt(r).await?; + if let Some(auth) = auth { + let body = response.get_ref(); + let (psbt_shape_hash, psbt_shape) = + psbt_shape_from_base64(&body.signed_psbt).map_err(internal_status)?; + let timestamp_ms = auth.timestamp_ms; + + self.update_splice_state(|state| { + state.record_signpsbt_response(SignPsbtResponseFacts { + psbt_shape_hash, + psbt_shape, + signpsbt_auth: auth, + signonly, + timestamp_ms, + }) + }) + .await?; + } + Ok(response) } async fn utxo_psbt( @@ -810,21 +874,116 @@ 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 node_channel_id_hex = format!("{node_id_hex}{channel_id_hex}"); + let old = self.old_splice_state(&request_body.channel_id).await?; + + 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 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, + 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, + }) + }) + .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 +1275,143 @@ 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 snapshot = { + let mut state = self.node_server.signer_state.lock().await; + update(&mut state).map_err(internal_status)?; + state.clone() + }; + let store = self.node_server.signer_state_store.lock().await; + store.write(snapshot).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?; + Ok(format!("{node_id_hex}{}", hex::encode(channel_id))) + } + + 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. From 8a285b8b05ede40b03ccd62828834ba44827bee1 Mon Sep 17 00:00:00 2001 From: Ihor Diachenko Date: Thu, 16 Jul 2026 18:33:46 +0300 Subject: [PATCH 4/8] gl-client: Add splice policy dry-run resolver --- libs/gl-client/src/persist.rs | 9 +- libs/gl-client/src/persist/splice.rs | 416 +++++- libs/gl-client/src/signer/mod.rs | 48 +- libs/gl-client/src/signer/resolve.rs | 113 +- libs/gl-client/src/signer/splice_policy.rs | 1341 ++++++++++++++++++++ libs/gl-plugin/src/node/mod.rs | 15 +- libs/gl-plugin/src/node/wrapper.rs | 68 +- 7 files changed, 1967 insertions(+), 43 deletions(-) create mode 100644 libs/gl-client/src/signer/splice_policy.rs diff --git a/libs/gl-client/src/persist.rs b/libs/gl-client/src/persist.rs index 3ac6860a8..6132d5fa2 100644 --- a/libs/gl-client/src/persist.rs +++ b/libs/gl-client/src/persist.rs @@ -5,9 +5,14 @@ mod splice; pub use splice::{ candidate_funding_facts_from_psbt, psbt_shape_from_base64, wallet_inputs_from_psbt, FeePolicy, FundPsbtResponseFacts, FundingOutpoint, LocalSpliceIntent, NormalizedRpcAuth, OldSpliceState, - SignPsbtResponseFacts, SpliceSignedResponseFacts, SpliceUpdateResponseFacts, - WalletInputReservation, WalletInputSource, + 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 index cab28e49a..709c8e355 100644 --- a/libs/gl-client/src/persist/splice.rs +++ b/libs/gl-client/src/persist/splice.rs @@ -1,5 +1,5 @@ use super::canonical::canonical_json_bytes; -use super::{State, StateEntry}; +use super::{State, StateEntry, CHANNEL_PREFIX}; use crate::bitcoin::consensus::encode::deserialize; use crate::bitcoin::psbt::Psbt; use crate::bitcoin::Transaction; @@ -388,6 +388,8 @@ pub struct SignPsbtResponseFacts { pub timestamp_ms: u64, } +pub type SignPsbtIntentFacts = SignPsbtResponseFacts; + #[derive(Clone, Debug, PartialEq, Eq)] pub struct SpliceUpdateResponseFacts { pub node_channel_id_hex: String, @@ -409,7 +411,7 @@ pub struct SpliceSignedResponseFacts { pub timestamp_ms: u64, } -fn tx_shape(tx: &Transaction) -> PsbtShapeV1 { +pub(crate) fn transaction_shape(tx: &Transaction) -> PsbtShapeV1 { PsbtShapeV1 { schema: "PsbtShapeV1".to_string(), version: tx.version.0, @@ -450,7 +452,11 @@ pub fn psbt_shape_from_base64(psbt: &str) -> anyhow::Result<(String, PsbtShapeV1 .decode(psbt) .map_err(|e| anyhow!("failed to decode PSBT base64: {e}"))?; let psbt = Psbt::deserialize(&raw).map_err(|e| anyhow!("failed to parse PSBT: {e}"))?; - let shape = tx_shape(&psbt.unsigned_tx); + psbt_shape_from_psbt(&psbt) +} + +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)) } @@ -572,6 +578,55 @@ pub fn candidate_funding_facts_from_tx( } 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, @@ -613,6 +668,61 @@ impl State { 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, @@ -683,6 +793,12 @@ impl State { 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); } @@ -699,6 +815,12 @@ impl State { ); 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) } @@ -741,10 +863,16 @@ impl State { 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(|| { @@ -785,6 +913,12 @@ impl State { } 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) } @@ -792,6 +926,8 @@ impl State { &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(|| { @@ -822,6 +958,13 @@ impl State { 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(); @@ -1026,6 +1169,54 @@ impl State { 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(()); @@ -1055,11 +1246,18 @@ 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; @@ -1079,6 +1277,38 @@ mod tests { } } + 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(), @@ -1154,6 +1384,54 @@ mod tests { ) } + #[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(); @@ -1193,6 +1471,14 @@ mod tests { 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(); @@ -1322,6 +1608,130 @@ mod tests { 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(); diff --git a/libs/gl-client/src/signer/mod.rs b/libs/gl-client/src/signer/mod.rs index da3fee6e9..3391dda9a 100644 --- a/libs/gl-client/src/signer/mod.rs +++ b/libs/gl-client/src/signer/mod.rs @@ -59,6 +59,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"); @@ -154,6 +155,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), @@ -167,6 +180,15 @@ pub enum Error { ApprovePairingRequestError(String), } +impl Error { + fn is_splice_hard_stop(&self) -> bool { + matches!( + self, + Self::SplicePolicy { .. } | Self::VlsSpliceUnavailable { .. } + ) + } +} + impl Signer { pub fn new(secret: Vec, network: Network, creds: T) -> Result where @@ -752,16 +774,25 @@ 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| { + if let Some(operation) = splice_policy::operation(msg) { + Error::SplicePolicy { + operation: operation.as_str().to_string(), + violation: format!("failed to acquire signer state lock: {e:?}"), + } + } else { + Error::Other(anyhow!("Failed to acquire state lock: {:?}", e)) + } + })?; + Resolver::try_resolve(msg, reqs, &state, &self.id, hsm_context)?; Ok(()) } @@ -779,7 +810,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 +873,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()), @@ -851,8 +881,12 @@ impl Signer { node_id: self.node_id(), }) .await; + // The permissive fallback must not reach VLS handlers without splice proofs. + if e.is_splice_hard_stop() { + return Err(e); + } #[cfg(not(feature = "permissive"))] - return Err(Error::Resolver(req.raw, ctxrequests)); + return Err(e); }; // If present, add the close_to_addr to the allowlist diff --git a/libs/gl-client/src/signer/resolve.rs b/libs/gl-client/src/signer/resolve.rs index 4534c7099..24eb4178a 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,68 @@ 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() { + 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() { + 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..c7e7ceacb --- /dev/null +++ b/libs/gl-client/src/signer/splice_policy.rs @@ -0,0 +1,1341 @@ +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 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::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.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 let Some(violation) = session_violation( + &session, + &[ + SplicePhase::CommitmentsSecured, + SplicePhase::SignaturesExchanging, + ], + ) { + return reject(violation); + } + if context.signpsbt_auth.is_none() { + return reject(SplicePolicyViolation::MissingSignPsbtAuth); + } + 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}, + 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 sign_withdrawal_fixture_for_origin( + origin: SpliceOrigin, + delta_computed: bool, + no_local_loss: 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: true, + 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) + } + + #[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() { + 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 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() { + let (message, pending, state) = + sign_withdrawal_fixture_for_origin(SpliceOrigin::PeerInitiated, false, false); + + 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); + + 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); + + assert_eq!( + classify(&message, &pending, &state, &[], None).unwrap(), + SplicePolicyDecision::Rejected(SplicePolicyViolation::DevSpliceUnresolved) + ); + } + + #[test] + fn matching_local_sign_splice_tx_reaches_vls_boundary() { + 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 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() { + 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() { + 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() { + 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() { + 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/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 b39ea51aa..caafb91ff 100644 --- a/libs/gl-plugin/src/node/wrapper.rs +++ b/libs/gl-plugin/src/node/wrapper.rs @@ -11,7 +11,7 @@ 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, - SignPsbtResponseFacts, SpliceSignedResponseFacts, SpliceUpdateResponseFacts, + SignPsbtIntentFacts, SpliceSignedResponseFacts, SpliceUpdateResponseFacts, WalletInputReservation, WalletInputSource, }; use log::debug; @@ -436,16 +436,15 @@ impl Node for WrappedNodeServer { r: Request, ) -> Result, Status> { let auth = maybe_normalized_auth("/cln.Node/SignPsbt", &r)?; - let signonly = r.get_ref().signonly.clone(); - let response = self.inner.sign_psbt(r).await?; if let Some(auth) = auth { - let body = response.get_ref(); + let body = r.get_ref(); let (psbt_shape_hash, psbt_shape) = - psbt_shape_from_base64(&body.signed_psbt).map_err(internal_status)?; + 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_response(SignPsbtResponseFacts { + state.record_signpsbt_intent(SignPsbtIntentFacts { psbt_shape_hash, psbt_shape, signpsbt_auth: auth, @@ -455,7 +454,7 @@ impl Node for WrappedNodeServer { }) .await?; } - Ok(response) + self.inner.sign_psbt(r).await } async fn utxo_psbt( @@ -878,19 +877,29 @@ impl Node for WrappedNodeServer { 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 node_channel_id_hex = format!("{node_id_hex}{channel_id_hex}"); 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: node_channel_id_hex.clone(), old, splice_init_auth: auth, authorized_relative_amount_sat: request_body.relative_amount, @@ -901,7 +910,16 @@ impl Node for WrappedNodeServer { 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) @@ -1356,13 +1374,10 @@ impl WrappedNodeServer { where F: FnOnce(&mut gl_client::persist::State) -> anyhow::Result<()>, { - let snapshot = { - let mut state = self.node_server.signer_state.lock().await; - update(&mut state).map_err(internal_status)?; - state.clone() - }; + 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(snapshot).await.map_err(internal_status) + store.write(state.clone()).await.map_err(internal_status) } async fn local_node_id_hex(&self) -> Result { @@ -1375,7 +1390,26 @@ impl WrappedNodeServer { async fn node_channel_id_hex(&self, channel_id: &[u8]) -> Result { let node_id_hex = self.local_node_id_hex().await?; - Ok(format!("{node_id_hex}{}", hex::encode(channel_id))) + 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 { From da55526a7fa0f99b92ae77fe91386bc5e9268e6c Mon Sep 17 00:00:00 2001 From: Ihor Diachenko Date: Sun, 19 Jul 2026 02:44:22 +0300 Subject: [PATCH 5/8] gl-client: Enable fail-closed splice regtests --- libs/gl-cli/Cargo.toml | 3 + libs/gl-client-py/Cargo.toml | 1 + libs/gl-client/Cargo.toml | 1 + libs/gl-client/src/persist/splice.rs | 409 ++++++++++++++++++++- libs/gl-client/src/signer/mod.rs | 72 ++-- libs/gl-client/src/signer/resolve.rs | 2 + libs/gl-client/src/signer/splice_policy.rs | 147 +++++++- libs/gl-sdk-cli/Cargo.toml | 3 + libs/gl-sdk-napi/Cargo.toml | 3 + libs/gl-sdk/Cargo.toml | 3 + 10 files changed, 596 insertions(+), 48 deletions(-) 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/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/splice.rs b/libs/gl-client/src/persist/splice.rs index 709c8e355..65cbaf020 100644 --- a/libs/gl-client/src/persist/splice.rs +++ b/libs/gl-client/src/persist/splice.rs @@ -447,12 +447,341 @@ pub fn psbt_shape_hash(shape: &PsbtShapeV1) -> anyhow::Result { 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 psbt = Psbt::deserialize(&raw).map_err(|e| anyhow!("failed to parse PSBT: {e}"))?; - psbt_shape_from_psbt(&psbt) + 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)> { @@ -525,19 +854,17 @@ pub fn candidate_funding_facts_from_psbt( funding_vout: u32, old_funding_outpoint: &FundingOutpoint, ) -> anyhow::Result { - let psbt = parse_psbt(psbt)?; - let old_input_index = psbt - .unsigned_tx - .input + let (_, shape) = psbt_shape_from_base64(psbt)?; + let old_input_index = shape + .inputs .iter() .position(|input| { - input.previous_output.txid.to_string() == old_funding_outpoint.txid - && input.previous_output.vout == old_funding_outpoint.vout + 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 = psbt - .unsigned_tx - .output + let output = shape + .outputs .get(funding_vout as usize) .ok_or_else(|| anyhow!("splice funding output index {} is missing", funding_vout))?; @@ -546,8 +873,8 @@ pub fn candidate_funding_facts_from_psbt( txid: funding_txid.to_string(), vout: funding_vout, }, - value_sat: output.value.to_sat(), - script_pubkey_hash: sha256::digest(output.script_pubkey.as_bytes()), + 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, }) @@ -1564,6 +1891,62 @@ mod tests { 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(); diff --git a/libs/gl-client/src/signer/mod.rs b/libs/gl-client/src/signer/mod.rs index 3391dda9a..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; @@ -70,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 { @@ -180,15 +194,6 @@ pub enum Error { ApprovePairingRequestError(String), } -impl Error { - fn is_splice_hard_stop(&self) -> bool { - matches!( - self, - Self::SplicePolicy { .. } | Self::VlsSpliceUnavailable { .. } - ) - } -} - impl Signer { pub fn new(secret: Vec, network: Network, creds: T) -> Result where @@ -339,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 @@ -782,16 +789,10 @@ impl Signer { reqs ); - let state = self.state.lock().map_err(|e| { - if let Some(operation) = splice_policy::operation(msg) { - Error::SplicePolicy { - operation: operation.as_str().to_string(), - violation: format!("failed to acquire signer state lock: {e:?}"), - } - } else { - Error::Other(anyhow!("Failed to acquire state lock: {:?}", e)) - } - })?; + 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(()) @@ -881,10 +882,6 @@ impl Signer { node_id: self.node_id(), }) .await; - // The permissive fallback must not reach VLS handlers without splice proofs. - if e.is_splice_hard_stop() { - return Err(e); - } #[cfg(not(feature = "permissive"))] return Err(e); }; @@ -1711,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/resolve.rs b/libs/gl-client/src/signer/resolve.rs index 24eb4178a..eb137bf51 100644 --- a/libs/gl-client/src/signer/resolve.rs +++ b/libs/gl-client/src/signer/resolve.rs @@ -182,6 +182,7 @@ mod tests { #[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), @@ -198,6 +199,7 @@ mod tests { #[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), diff --git a/libs/gl-client/src/signer/splice_policy.rs b/libs/gl-client/src/signer/splice_policy.rs index c7e7ceacb..7e51f2956 100644 --- a/libs/gl-client/src/signer/splice_policy.rs +++ b/libs/gl-client/src/signer/splice_policy.rs @@ -278,6 +278,31 @@ fn pending_splice_psbt_matches( }) } +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], @@ -298,6 +323,7 @@ fn classify_sign_splice_tx( if let Some(violation) = session_violation( &session, &[ + SplicePhase::Negotiating, SplicePhase::CommitmentsSecured, SplicePhase::SignaturesExchanging, ], @@ -320,6 +346,12 @@ fn classify_sign_splice_tx( { 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) { @@ -472,6 +504,9 @@ fn classify_sign_withdrawal( 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, &[ @@ -481,9 +516,6 @@ fn classify_sign_withdrawal( ) { return reject(violation); } - if context.signpsbt_auth.is_none() { - return reject(SplicePolicyViolation::MissingSignPsbtAuth); - } 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()) @@ -564,7 +596,7 @@ mod tests { WalletInputSource, }; use crate::signer::model::{ - cln::{SignpsbtRequest, SpliceSignedRequest}, + cln::{SignpsbtRequest, SpliceSignedRequest, SpliceUpdateRequest}, Request, }; use base64::{engine::general_purpose, Engine as _}; @@ -766,10 +798,54 @@ mod tests { }) } + 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(); @@ -881,7 +957,7 @@ mod tests { psbt_shape_hash: shape_hash.clone(), psbt_shape: shape.clone(), splice_update_auth: auth("/cln.Node/SpliceUpdate", "77", 3), - commitments_secured: true, + commitments_secured, signatures_secured: Some(false), timestamp_ms: 3, }) @@ -917,7 +993,7 @@ mod tests { } fn sign_withdrawal_fixture() -> (Message, Vec, State) { - sign_withdrawal_fixture_for_origin(SpliceOrigin::LocalInitiator, false, false) + sign_withdrawal_fixture_for_origin(SpliceOrigin::LocalInitiator, false, false, true) } #[test] @@ -941,6 +1017,7 @@ mod tests { #[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!( @@ -979,6 +1056,27 @@ mod tests { ); } + #[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(); @@ -1021,8 +1119,9 @@ mod tests { #[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); + sign_withdrawal_fixture_for_origin(SpliceOrigin::PeerInitiated, false, false, true); assert_eq!( classify(&message, &pending, &state, &[], None).unwrap(), @@ -1033,7 +1132,7 @@ mod tests { #[test] fn peer_signwithdrawal_with_local_loss_rejects() { let (message, pending, state) = - sign_withdrawal_fixture_for_origin(SpliceOrigin::PeerInitiated, true, false); + sign_withdrawal_fixture_for_origin(SpliceOrigin::PeerInitiated, true, false, true); assert_eq!( classify(&message, &pending, &state, &[], None).unwrap(), @@ -1043,8 +1142,12 @@ mod tests { #[test] fn unresolved_dev_splice_signwithdrawal_rejects() { - let (message, pending, state) = - sign_withdrawal_fixture_for_origin(SpliceOrigin::DevSpliceUnresolved, false, false); + let (message, pending, state) = sign_withdrawal_fixture_for_origin( + SpliceOrigin::DevSpliceUnresolved, + false, + false, + true, + ); assert_eq!( classify(&message, &pending, &state, &[], None).unwrap(), @@ -1054,6 +1157,7 @@ mod tests { #[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!( @@ -1069,6 +1173,25 @@ mod tests { ); } + #[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); @@ -1148,6 +1271,7 @@ mod tests { #[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!( @@ -1182,6 +1306,7 @@ mod tests { #[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); @@ -1240,6 +1365,7 @@ mod tests { #[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 @@ -1271,6 +1397,7 @@ mod tests { #[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 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" From 2b9b71d2032a51bf5a37daeda838f163f676783c Mon Sep 17 00:00:00 2001 From: Ihor Diachenko Date: Sun, 19 Jul 2026 03:43:47 +0300 Subject: [PATCH 6/8] gl-client-py: Adapt decodepay to CLN v26.06 --- libs/gl-client-py/glclient/__init__.py | 15 +++++---------- 1 file changed, 5 insertions(+), 10 deletions(-) diff --git a/libs/gl-client-py/glclient/__init__.py b/libs/gl-client-py/glclient/__init__.py index dfd3a402b..a3b72eefc 100644 --- a/libs/gl-client-py/glclient/__init__.py +++ b/libs/gl-client-py/glclient/__init__.py @@ -282,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" From 4b3fa1b9f5db5a8bbb66a1ec0d1a83fdac5973e0 Mon Sep 17 00:00:00 2001 From: Ihor Diachenko Date: Tue, 11 Aug 2026 13:54:02 +0300 Subject: [PATCH 7/8] gl-client: Simplify splice PSBT handling with psbt-v2 --- Cargo.lock | 21 + libs/gl-client/Cargo.toml | 1 + libs/gl-client/src/lib.rs | 3 +- libs/gl-client/src/persist.rs | 20 +- libs/gl-client/src/persist/splice.rs | 1273 ++++---------------- libs/gl-client/src/psbt.rs | 527 ++++++++ libs/gl-client/src/signer/splice_policy.rs | 265 ++-- libs/gl-plugin/src/node/wrapper.rs | 61 +- 8 files changed, 979 insertions(+), 1192 deletions(-) create mode 100644 libs/gl-client/src/psbt.rs diff --git a/Cargo.lock b/Cargo.lock index 1c1dd6a32..e2bf0bc14 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1553,6 +1553,7 @@ dependencies = [ "pin-project", "prost", "prost-derive", + "psbt-v2", "rand", "rcgen", "reqwest", @@ -2457,6 +2458,16 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" +[[package]] +name = "miniscript" +version = "12.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8343cc1ef1408bd9bdbf69f7aef47017dfab7e6349ec26fddf62e0e9fb5a4cf" +dependencies = [ + "bech32 0.11.1", + "bitcoin 0.32.9", +] + [[package]] name = "miniz_oxide" version = "0.8.9" @@ -3193,6 +3204,16 @@ dependencies = [ "prost", ] +[[package]] +name = "psbt-v2" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e075616cae99e29d70960a6374c2757dff4dd6b646dd0398ce1012666231c7aa" +dependencies = [ + "bitcoin 0.32.9", + "miniscript", +] + [[package]] name = "pyo3" version = "0.18.3" diff --git a/libs/gl-client/Cargo.toml b/libs/gl-client/Cargo.toml index 9eb3990be..ecfd2a876 100644 --- a/libs/gl-client/Cargo.toml +++ b/libs/gl-client/Cargo.toml @@ -36,6 +36,7 @@ picky-asn1-der = "0.4" pin-project = "1.1.5" prost = "0.12" prost-derive = "0.12" +psbt-v2 = { version = "0.3.0", default-features = false, features = ["std"] } # `rustls-tls-webpki-roots` compiles Mozilla's CA bundle into the # binary. Identical TLS behaviour on every platform — Android, iOS, # desktop — with no runtime OS root-store discovery. We previously diff --git a/libs/gl-client/src/lib.rs b/libs/gl-client/src/lib.rs index 80508bed8..e77879f06 100644 --- a/libs/gl-client/src/lib.rs +++ b/libs/gl-client/src/lib.rs @@ -28,8 +28,9 @@ pub mod scheduler; /// move your funds. pub mod signer; -pub mod persist; pub mod metrics; +pub mod persist; +pub mod psbt; pub mod lnurl; diff --git a/libs/gl-client/src/persist.rs b/libs/gl-client/src/persist.rs index 6132d5fa2..f52313c80 100644 --- a/libs/gl-client/src/persist.rs +++ b/libs/gl-client/src/persist.rs @@ -1,18 +1,15 @@ 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, + candidate_funding_facts_from_psbt, parse_base64_psbt, wallet_inputs_from_psbt, + FeePolicy, FundPsbtResponseFacts, FundingOutpoint, LocalSpliceIntent, NormalizedRpcAuth, + OldSpliceState, PsbtCaptureFacts, SignPsbtIntentFacts, SpliceSignedResponseFacts, + SpliceUpdateResponseFacts, WalletInputReservation, }; #[cfg(test)] pub(crate) use splice::{CandidateFundingFacts, WalletInput}; +pub(crate) use splice::{SpliceOrigin, SplicePhase, SpliceSessionV1}; use anyhow::anyhow; use lightning_signer::bitcoin::secp256k1::PublicKey; @@ -1106,10 +1103,13 @@ mod tests { #[test] fn state_entry_canonical_value_bytes_sorts_nested_object_keys() { - let entry = StateEntry::new(0, json!({ + let entry = StateEntry::new( + 0, + json!({ "z": {"b": 1, "a": 2}, "a": [{"d": 4, "c": 3}] - })); + }), + ); let bytes = entry.canonical_value_bytes().unwrap(); diff --git a/libs/gl-client/src/persist/splice.rs b/libs/gl-client/src/persist/splice.rs index 65cbaf020..571f7193c 100644 --- a/libs/gl-client/src/persist/splice.rs +++ b/libs/gl-client/src/persist/splice.rs @@ -1,12 +1,10 @@ -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 crate::bitcoin::{OutPoint, Txid}; +use crate::psbt::ParsedPsbt; use anyhow::{anyhow, bail}; -use base64::{engine::general_purpose, Engine as _}; use serde::de::DeserializeOwned; use serde::{Deserialize, Serialize}; +use std::str::FromStr; const SPLICE_SESSION_PREFIX: &str = "splices"; const SPLICE_OUTPOINT_PREFIX: &str = "splice_outpoints"; @@ -20,8 +18,8 @@ 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}") +fn wallet_psbt_key(psbt_fingerprint: &str) -> String { + format!("{SPLICE_WALLET_PSBT_PREFIX}/{psbt_fingerprint}") } #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] @@ -57,14 +55,6 @@ impl Default for DeltaSource { } } -#[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 { @@ -107,29 +97,6 @@ impl NormalizedRpcAuth { } } -#[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, @@ -165,16 +132,15 @@ pub struct LocalSpliceIntent { 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 initial_psbt_fingerprint: String, + pub initial_psbt_input_outpoints: Vec, pub timestamp_ms: u64, } #[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] pub struct SplicePsbtState { - pub candidate_psbt_shape_hash: Option, - pub candidate_psbt_shape: Option, - pub frozen_psbt_shape_hash: Option, + pub candidate_fingerprint: Option, + pub frozen_fingerprint: Option, } #[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] @@ -227,6 +193,8 @@ pub struct SignerRequestRecord { } #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +// TODO: Reconsider these fields once the VLS splice integration shape is settled. +// Keep Greenlight-owned authorization and intent here, and move protocol state to VLS. pub struct SpliceSessionV1 { pub schema: String, pub schema_version: u16, @@ -241,7 +209,7 @@ pub struct SpliceSessionV1 { pub psbt: SplicePsbtState, pub cand: SpliceCandidateState, pub delta: SpliceDeltaState, - pub linked_wallet_psbt_shape_hashes: Vec, + pub linked_wallet_psbt_fingerprints: Vec, pub request_history: Vec, pub signer_request_history: Vec, pub created_at_ms: u64, @@ -282,7 +250,7 @@ impl SpliceSessionV1 { psbt: SplicePsbtState::default(), cand: SpliceCandidateState::default(), delta: SpliceDeltaState::default(), - linked_wallet_psbt_shape_hashes: Vec::new(), + linked_wallet_psbt_fingerprints: Vec::new(), request_history: splice_init_auth.into_iter().collect(), signer_request_history: Vec::new(), created_at_ms: timestamp_ms, @@ -315,30 +283,25 @@ pub struct WalletInput { pub vout: u32, pub value_sat: u64, pub reserved_to_block: Option, - pub source: WalletInputSource, } #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +// TODO: Reconsider which wallet-PSBT fields must remain durable once splice intent is +// supplied through the VLS approver interface. pub struct SpliceWalletPsbtContextV1 { pub schema: String, pub schema_version: u16, - pub 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, @@ -347,15 +310,11 @@ impl SpliceWalletPsbtContextV1 { 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, } @@ -371,30 +330,25 @@ pub struct WalletInputReservation { #[derive(Clone, Debug, PartialEq, Eq)] pub struct FundPsbtResponseFacts { - pub psbt_shape_hash: String, - pub psbt_shape: PsbtShapeV1, + pub psbt_fingerprint: String, 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 struct SignPsbtIntentFacts { + pub psbt_fingerprint: String, 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 psbt_fingerprint: String, + pub psbt_input_outpoints: Vec, pub splice_update_auth: NormalizedRpcAuth, pub commitments_secured: bool, pub signatures_secured: Option, @@ -404,445 +358,55 @@ pub struct SpliceUpdateResponseFacts { #[derive(Clone, Debug, PartialEq, Eq)] pub struct SpliceSignedResponseFacts { pub node_channel_id_hex: String, - pub psbt_shape_hash: String, - pub psbt_shape: PsbtShapeV1, + pub psbt_fingerprint: String, + pub psbt_input_outpoints: Vec, 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) +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct PsbtCaptureFacts { + pub fingerprint: String, + pub input_outpoints: Vec, } -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() +pub fn parse_base64_psbt(psbt: &str) -> anyhow::Result { + let psbt = ParsedPsbt::from_base64(psbt)?; + Ok(PsbtCaptureFacts { + fingerprint: psbt.fingerprint().to_string(), + input_outpoints: psbt + .input_outpoints() + .into_iter() + .map(|outpoint| FundingOutpoint { + txid: outpoint.txid.to_string(), + vout: outpoint.vout, }) - .collect(); - 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, + .collect(), }) } -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 psbt = ParsedPsbt::from_base64(psbt)?; + let funding_utxos = psbt.funding_utxos()?; + psbt.input_outpoints() + .into_iter() + .zip(funding_utxos) + .map(|(outpoint, funding_utxo)| { + let txid = outpoint.txid.to_string(); + let vout = outpoint.vout; let reservation = reservations .iter() .find(|reservation| reservation.txid == txid && reservation.vout == vout); Ok(WalletInput { txid, vout, - value_sat: psbt_input_value_sat(&psbt, index)?, + value_sat: funding_utxo.value.to_sat(), reserved_to_block: reservation .and_then(|reservation| reservation.reserved_to_block), - source: source.clone(), }) }) .collect() @@ -854,52 +418,24 @@ pub fn candidate_funding_facts_from_psbt( 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, - }) -} + let psbt = ParsedPsbt::from_base64(psbt)?; + let funding_txid = + Txid::from_str(funding_txid).map_err(|e| anyhow!("invalid splice funding txid: {e}"))?; + let old_funding_outpoint = OutPoint::new( + Txid::from_str(&old_funding_outpoint.txid) + .map_err(|e| anyhow!("invalid old funding txid: {e}"))?, + old_funding_outpoint.vout, + ); + let candidate = psbt.candidate_funding(funding_txid, funding_vout, old_funding_outpoint)?; -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, + txid: candidate.outpoint.txid.to_string(), + vout: candidate.outpoint.vout, }, - value_sat: output.value.to_sat(), - script_pubkey_hash: sha256::digest(output.script_pubkey.as_bytes()), - sign_splice_tx_input_index, + value_sat: candidate.txout.value.to_sat(), + script_pubkey_hash: sha256::digest(candidate.txout.script_pubkey.as_bytes()), + sign_splice_tx_input_index: candidate.sign_splice_tx_input_index, remote_funding_key_hex: None, }) } @@ -995,54 +531,46 @@ impl State { self.put_splice(&splice_session_key(&session.node_channel_id_hex), session) } - fn link_splice_shape_context( + fn link_splice_psbt_context( &mut self, session: &mut SpliceSessionV1, - psbt_shape_hash: &str, - psbt_shape: &PsbtShapeV1, + psbt_fingerprint: &str, + psbt_input_outpoints: &[FundingOutpoint], updated_at_ms: u64, ) -> anyhow::Result<()> { - let source_shape_hashes = session.linked_wallet_psbt_shape_hashes.clone(); + let source_fingerprints = session.linked_wallet_psbt_fingerprints.clone(); let mut context = self - .get_splice_wallet_psbt_context(psbt_shape_hash)? + .get_psbt_context(psbt_fingerprint)? .unwrap_or_else(|| { - SpliceWalletPsbtContextV1::new( - psbt_shape_hash.to_string(), - psbt_shape.clone(), - None, - None, - Vec::new(), - updated_at_ms, - ) + SpliceWalletPsbtContextV1::new(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, + "PSBT {} is already linked to splice channel {}", + psbt_fingerprint, 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 + .linked_wallet_psbt_fingerprints .iter() - .any(|hash| hash == psbt_shape_hash) + .any(|fingerprint| fingerprint == psbt_fingerprint) { session - .linked_wallet_psbt_shape_hashes - .push(psbt_shape_hash.to_string()); + .linked_wallet_psbt_fingerprints + .push(psbt_fingerprint.to_string()); } - self.put_splice_wallet_psbt_context(context)?; - for source_shape_hash in source_shape_hashes { + self.put_splice_wallet_psbt_context(psbt_fingerprint, context)?; + for source_fingerprint in source_fingerprints { self.inherit_splice_wallet_context( - &source_shape_hash, - psbt_shape_hash, + &source_fingerprint, + psbt_fingerprint, + psbt_input_outpoints, &session.node_channel_id_hex, updated_at_ms, )?; @@ -1088,13 +616,8 @@ impl State { } 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"), - } + let psbt_fingerprint = intent.initial_psbt_fingerprint.clone(); + let psbt_input_outpoints = intent.initial_psbt_input_outpoints.clone(); if let Some(mut session) = self.get_splice_session(&intent.node_channel_id_hex)? { if session.origin != SpliceOrigin::LocalInitiator { @@ -1116,16 +639,15 @@ impl State { 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.psbt.candidate_fingerprint = Some(psbt_fingerprint.clone()); 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)?; - } + self.link_splice_psbt_context( + &mut session, + &psbt_fingerprint, + &psbt_input_outpoints, + intent.timestamp_ms, + )?; return self.put_splice_session(&session); } @@ -1140,66 +662,44 @@ impl State { 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)?; - } + session.psbt.candidate_fingerprint = Some(psbt_fingerprint.clone()); + self.link_splice_psbt_context( + &mut session, + &psbt_fingerprint, + &psbt_input_outpoints, + intent.timestamp_ms, + )?; self.create_local_splice_session(session) } pub fn record_fundpsbt_response(&mut self, facts: FundPsbtResponseFacts) -> anyhow::Result<()> { - let existing = self.get_splice_wallet_psbt_context(&facts.psbt_shape_hash)?; + let existing = self.get_psbt_context(&facts.psbt_fingerprint)?; 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, - ) + SpliceWalletPsbtContextV1::new(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) + self.put_splice_wallet_psbt_context(&facts.psbt_fingerprint, context) } - pub fn record_signpsbt_response(&mut self, facts: SignPsbtResponseFacts) -> anyhow::Result<()> { - let existing = self.get_splice_wallet_psbt_context(&facts.psbt_shape_hash)?; + pub fn record_signpsbt_intent(&mut self, facts: SignPsbtIntentFacts) -> anyhow::Result<()> { + let existing = self.get_psbt_context(&facts.psbt_fingerprint)?; let mut context = existing.unwrap_or_else(|| { - SpliceWalletPsbtContextV1::new( - facts.psbt_shape_hash.clone(), - facts.psbt_shape.clone(), - None, - None, - Vec::new(), - facts.timestamp_ms, - ) + SpliceWalletPsbtContextV1::new(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) + self.put_splice_wallet_psbt_context(&facts.psbt_fingerprint, context) } 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 psbt_fingerprint = facts.psbt_fingerprint.clone(); + let psbt_input_outpoints = facts.psbt_input_outpoints.clone(); let mut session = self .get_splice_session(&facts.node_channel_id_hex)? .ok_or_else(|| { @@ -1220,9 +720,8 @@ impl State { 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.psbt.frozen_fingerprint = Some(facts.psbt_fingerprint.clone()); + session.psbt.candidate_fingerprint = Some(facts.psbt_fingerprint); session.phase = if facts.signatures_secured == Some(true) { SplicePhase::SignaturesExchanging } else { @@ -1231,19 +730,18 @@ impl State { } else { if session.phase != SplicePhase::Negotiating { bail!( - "candidate PSBT shape can only change while negotiating, current phase {:?}", + "candidate PSBT 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.psbt.candidate_fingerprint = Some(facts.psbt_fingerprint); } session.updated_at_ms = facts.timestamp_ms; - self.link_splice_shape_context( + self.link_splice_psbt_context( &mut session, - &linked_psbt_shape_hash, - &linked_psbt_shape, + &psbt_fingerprint, + &psbt_input_outpoints, facts.timestamp_ms, )?; self.put_splice_session(&session) @@ -1253,8 +751,8 @@ impl State { &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 psbt_fingerprint = facts.psbt_fingerprint.clone(); + let psbt_input_outpoints = facts.psbt_input_outpoints.clone(); let mut session = self .get_splice_session(&facts.node_channel_id_hex)? .ok_or_else(|| { @@ -1277,18 +775,17 @@ impl State { 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.psbt.candidate_fingerprint = Some(facts.psbt_fingerprint.clone()); + if session.psbt.frozen_fingerprint.is_none() { + session.psbt.frozen_fingerprint = Some(facts.psbt_fingerprint); } session.phase = SplicePhase::SignaturesExchanging; session.updated_at_ms = facts.timestamp_ms; - self.link_splice_shape_context( + self.link_splice_psbt_context( &mut session, - &linked_psbt_shape_hash, - &linked_psbt_shape, + &psbt_fingerprint, + &psbt_input_outpoints, facts.timestamp_ms, )?; @@ -1318,11 +815,10 @@ impl State { self.create_new_splice_session(session, SpliceOrigin::DevSpliceUnresolved) } - pub fn update_splice_candidate_shape( + pub fn update_splice_candidate( &mut self, node_channel_id_hex: &str, - psbt_shape_hash: String, - psbt_shape: PsbtShapeV1, + psbt_fingerprint: String, auth: NormalizedRpcAuth, updated_at_ms: u64, ) -> anyhow::Result<()> { @@ -1331,13 +827,12 @@ impl State { .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 {:?}", + "candidate PSBT 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.psbt.candidate_fingerprint = Some(psbt_fingerprint); session.request_history.push(auth); session.updated_at_ms = updated_at_ms; self.put_splice_session(&session) @@ -1346,7 +841,7 @@ impl State { pub fn freeze_splice_candidate( &mut self, node_channel_id_hex: &str, - frozen_psbt_shape_hash: String, + frozen_psbt_fingerprint: String, candidate: CandidateFundingFacts, updated_at_ms: u64, ) -> anyhow::Result<()> { @@ -1362,38 +857,13 @@ impl State { 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.psbt.frozen_fingerprint = Some(frozen_psbt_fingerprint); session.cand = candidate.into(); session.updated_at_ms = updated_at_ms; self.put_splice_session(&session)?; self.put_splice_outpoint_index(&candidate_outpoint, &index) } - pub fn mark_splice_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, @@ -1444,21 +914,22 @@ impl State { pub fn put_splice_wallet_psbt_context( &mut self, + psbt_fingerprint: &str, context: SpliceWalletPsbtContextV1, ) -> anyhow::Result<()> { - self.put_splice(&wallet_psbt_key(&context.psbt_shape_hash), &context) + self.put_splice(&wallet_psbt_key(psbt_fingerprint), &context) } - pub fn get_splice_wallet_psbt_context( + pub fn get_psbt_context( &self, - psbt_shape_hash: &str, + psbt_fingerprint: &str, ) -> anyhow::Result> { - self.get_splice(&wallet_psbt_key(psbt_shape_hash)) + self.get_splice(&wallet_psbt_key(psbt_fingerprint)) } pub fn link_splice_wallet_psbt( &mut self, - psbt_shape_hash: &str, + psbt_fingerprint: &str, node_channel_id_hex: &str, updated_at_ms: u64, ) -> anyhow::Result<()> { @@ -1466,61 +937,61 @@ impl State { .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 { + .get_psbt_context(psbt_fingerprint)? + .ok_or_else(|| anyhow!("missing wallet PSBT context {}", psbt_fingerprint))?; + let fingerprint_matches_splice = session.psbt.candidate_fingerprint.as_deref() + == Some(psbt_fingerprint) + || session.psbt.frozen_fingerprint.as_deref() == Some(psbt_fingerprint); + if !fingerprint_matches_splice { bail!( - "wallet PSBT shape {} does not match splice candidate for channel {}", - psbt_shape_hash, + "wallet PSBT {} does not match splice candidate for channel {}", + psbt_fingerprint, node_channel_id_hex ); } context.linked_node_channel_id_hex = Some(node_channel_id_hex.to_string()); - context.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 + .linked_wallet_psbt_fingerprints .iter() - .any(|hash| hash == psbt_shape_hash) + .any(|fingerprint| fingerprint == psbt_fingerprint) { session - .linked_wallet_psbt_shape_hashes - .push(psbt_shape_hash.to_string()); + .linked_wallet_psbt_fingerprints + .push(psbt_fingerprint.to_string()); } session.updated_at_ms = updated_at_ms; - self.put_splice_wallet_psbt_context(context)?; + self.put_splice_wallet_psbt_context(psbt_fingerprint, context)?; self.put_splice_session(&session) } pub fn inherit_splice_wallet_context( &mut self, - source_psbt_shape_hash: &str, - candidate_psbt_shape_hash: &str, + source_psbt_fingerprint: &str, + candidate_psbt_fingerprint: &str, + candidate_input_outpoints: &[FundingOutpoint], node_channel_id_hex: &str, updated_at_ms: u64, ) -> anyhow::Result<()> { - if source_psbt_shape_hash == candidate_psbt_shape_hash { + if source_psbt_fingerprint == candidate_psbt_fingerprint { return Ok(()); } - let Some(source) = self.get_splice_wallet_psbt_context(source_psbt_shape_hash)? else { + let Some(source) = self.get_psbt_context(source_psbt_fingerprint)? else { return Ok(()); }; let mut candidate = self - .get_splice_wallet_psbt_context(candidate_psbt_shape_hash)? + .get_psbt_context(candidate_psbt_fingerprint)? .ok_or_else(|| { anyhow!( "missing splice candidate PSBT context {}", - candidate_psbt_shape_hash + candidate_psbt_fingerprint ) })?; if candidate.linked_node_channel_id_hex.as_deref() != Some(node_channel_id_hex) { bail!( "splice candidate PSBT context {} is not linked to channel {}", - candidate_psbt_shape_hash, + candidate_psbt_fingerprint, node_channel_id_hex ); } @@ -1529,8 +1000,8 @@ impl State { 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 remains_in_candidate = candidate_input_outpoints.iter().any(|outpoint| { + outpoint.txid == wallet_input.txid && outpoint.vout == wallet_input.vout }); let already_known = candidate .wallet_inputs @@ -1541,7 +1012,7 @@ impl State { } } candidate.updated_at_ms = updated_at_ms; - self.put_splice_wallet_psbt_context(candidate) + self.put_splice_wallet_psbt_context(candidate_psbt_fingerprint, candidate) } pub fn tombstone_splice_session(&mut self, node_channel_id_hex: &str) -> anyhow::Result<()> { @@ -1554,9 +1025,9 @@ impl State { } keys.extend( session - .linked_wallet_psbt_shape_hashes + .linked_wallet_psbt_fingerprints .iter() - .map(|hash| wallet_psbt_key(hash)), + .map(|fingerprint| wallet_psbt_key(fingerprint)), ); keys.sort(); keys.dedup(); @@ -1583,11 +1054,14 @@ mod tests { DelayedPaymentBasepoint, HtlcBasepoint, RevocationBasepoint, }; use crate::pb::SignerStateEntry; + use base64::{engine::general_purpose, Engine as _}; use lightning_signer::channel::{ChannelSetup, CommitmentType}; use lightning_signer::policy::validator::EnforcementState; use serde_json::json; use std::str::FromStr; + use crate::psbt::CLN_PSBT_V2; + fn auth(uri: &str, request_hash: &str, timestamp_ms: u64) -> NormalizedRpcAuth { NormalizedRpcAuth::new( uri.to_string(), @@ -1636,24 +1110,6 @@ mod tests { } } - 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, @@ -1760,8 +1216,9 @@ mod tests { } #[test] - fn record_local_splice_intent_keeps_authority_out_of_psbt_shape() { + fn record_local_splice_intent_persists_auth_and_fingerprint() { let mut state = State::new(); + let fingerprint = "77".repeat(32); state .record_local_splice_intent(LocalSpliceIntent { @@ -1779,8 +1236,8 @@ mod tests { feerate_per_kw: Some(253), force_feerate: Some(false), }, - initial_psbt_shape_hash: Some("shape-init".to_string()), - initial_psbt_shape: Some(shape("04")), + initial_psbt_fingerprint: fingerprint.clone(), + initial_psbt_input_outpoints: vec![outpoint(&"11".repeat(32), 7)], timestamp_ms: 1, }) .unwrap(); @@ -1795,11 +1252,11 @@ mod tests { "/cln.Node/SpliceInit" ); assert_eq!( - session.psbt.candidate_psbt_shape_hash.as_deref(), - Some("shape-init") + session.psbt.candidate_fingerprint.as_deref(), + Some(fingerprint.as_str()) ); let wallet_context = state - .get_splice_wallet_psbt_context("shape-init") + .get_psbt_context(&fingerprint) .unwrap() .expect("splice candidate creates a linked PSBT context"); assert_eq!( @@ -1807,57 +1264,14 @@ mod tests { 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")); + let psbt_value = serde_json::to_value(session.psbt).unwrap(); + assert!(psbt_value.get("splice_init_auth").is_none()); + assert!(psbt_value.get("request_hash").is_none()); + assert!(psbt_value.get("caller_pubkey_hex").is_none()); } #[test] - fn psbt_shape_and_wallet_inputs_are_derived_from_psbt_without_authority() { + fn wallet_inputs_psbt_values_and_reservations() { let prev_txid = "11".repeat(32); let psbt = psbt_fixture( &prev_txid, @@ -1866,12 +1280,6 @@ mod tests { 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 { @@ -1879,101 +1287,64 @@ mod tests { vout: 7, reserved_to_block: Some(42), }], - WalletInputSource::FundPsbt, ) .unwrap(); assert_eq!(wallet_inputs.len(), 1); + assert_eq!(wallet_inputs[0].txid, prev_txid); + assert_eq!(wallet_inputs[0].vout, 7); assert_eq!(wallet_inputs[0].value_sat, 55_000); assert_eq!(wallet_inputs[0].reserved_to_block, Some(42)); - - 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); + fn psbt_v2_and_candidate_facts_use_the_adapter() { + let psbt = parse_base64_psbt(CLN_PSBT_V2).unwrap(); 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" + psbt.input_outpoints, + vec![outpoint( + "c85f81844094f9f0eec1e41f8d63e0a99e9f73dc725d7319871c9c4121d90a0b", + 0, + )] ); let candidate = candidate_funding_facts_from_psbt( - PSBT_V2, + CLN_PSBT_V2, &"99".repeat(32), 0, - &FundingOutpoint { - txid: shape.inputs[0].prev_txid.clone(), - vout: 0, - }, + &psbt.input_outpoints[0], ) .unwrap(); assert_eq!(candidate.value_sat, 800_000_000); assert_eq!(candidate.sign_splice_tx_input_index, 0); assert_eq!( candidate.script_pubkey_hash, - shape.outputs[0].script_pubkey_hash + sha256::digest(hex::decode("0014c430f64c4756da310dbd1a085572ef299926272c").unwrap()) ); } #[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() { + fn records_fundpsbt_response_and_signpsbt_intent_by_fingerprint() { let mut state = State::new(); - let psbt = psbt_fixture( + let serialized_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(); + let psbt = parse_base64_psbt(&serialized_psbt).unwrap(); + let wallet_inputs = wallet_inputs_from_psbt(&serialized_psbt, &[]).unwrap(); state .record_fundpsbt_response(FundPsbtResponseFacts { - psbt_shape_hash: shape_hash.clone(), - psbt_shape: parsed_shape.clone(), + psbt_fingerprint: psbt.fingerprint.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, + .record_signpsbt_intent(SignPsbtIntentFacts { + psbt_fingerprint: psbt.fingerprint.clone(), signpsbt_auth: auth("/cln.Node/SignPsbt", &"cd".repeat(32), 3), signonly: vec![0], timestamp_ms: 3, @@ -1981,13 +1352,12 @@ mod tests { .unwrap(); let context = state - .get_splice_wallet_psbt_context(&shape_hash) + .get_psbt_context(&psbt.fingerprint) .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); } @@ -2007,16 +1377,15 @@ mod tests { 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")), + initial_psbt_fingerprint: "aa".repeat(32), + initial_psbt_input_outpoints: vec![], timestamp_ms: 1, }) .unwrap(); state .record_signpsbt_intent(SignPsbtIntentFacts { - psbt_shape_hash: "shape-a".to_string(), - psbt_shape: shape("06"), + psbt_fingerprint: "aa".repeat(32), signpsbt_auth: auth("/cln.Node/SignPsbt", &"77".repeat(32), 2), signonly: vec![0], timestamp_ms: 2, @@ -2024,7 +1393,7 @@ mod tests { .unwrap(); let context = state - .get_splice_wallet_psbt_context("shape-a") + .get_psbt_context(&"aa".repeat(32)) .unwrap() .unwrap(); assert!(context.signpsbt_auth.is_some()); @@ -2038,26 +1407,20 @@ mod tests { #[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), - }); + let source_fingerprint = "aa".repeat(32); + let candidate_fingerprint = "bb".repeat(32); + let updated_fingerprint = "cc".repeat(32); + let wallet_outpoint = outpoint(&"11".repeat(32), 7); state .record_fundpsbt_response(FundPsbtResponseFacts { - psbt_shape_hash: "source-shape".to_string(), - psbt_shape: source_shape.clone(), + psbt_fingerprint: source_fingerprint.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, + txid: wallet_outpoint.txid.clone(), + vout: wallet_outpoint.vout, value_sat: 25_000, reserved_to_block: Some(100), - source: WalletInputSource::FundPsbt, }], - change_outnum: None, timestamp_ms: 1, }) .unwrap(); @@ -2074,31 +1437,35 @@ mod tests { 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), + initial_psbt_fingerprint: candidate_fingerprint.clone(), + initial_psbt_input_outpoints: vec![wallet_outpoint.clone()], timestamp_ms: 2, }) .unwrap(); state - .inherit_splice_wallet_context("source-shape", "candidate-shape", &"44".repeat(32), 2) + .inherit_splice_wallet_context( + &source_fingerprint, + &candidate_fingerprint, + std::slice::from_ref(&wallet_outpoint), + &"44".repeat(32), + 2, + ) .unwrap(); let candidate = state - .get_splice_wallet_psbt_context("candidate-shape") + .get_psbt_context(&candidate_fingerprint) .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, + psbt_fingerprint: updated_fingerprint.clone(), + psbt_input_outpoints: vec![wallet_outpoint], splice_update_auth: auth("/cln.Node/SpliceUpdate", &"88".repeat(32), 3), commitments_secured: false, signatures_secured: None, @@ -2107,7 +1474,7 @@ mod tests { .unwrap(); let updated = state - .get_splice_wallet_psbt_context("updated-shape") + .get_psbt_context(&updated_fingerprint) .unwrap() .unwrap(); assert!(updated.fundpsbt_auth.is_some()); @@ -2120,19 +1487,19 @@ mod tests { let mut state = State::new(); state.create_local_splice_session(local_session()).unwrap(); let old_txid = "55".repeat(32); - let psbt = psbt_fixture( + let serialized_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(); + let psbt = parse_base64_psbt(&serialized_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(), + psbt_fingerprint: psbt.fingerprint.clone(), + psbt_input_outpoints: psbt.input_outpoints.clone(), splice_update_auth: auth("/cln.Node/SpliceUpdate", &"ef".repeat(32), 2), commitments_secured: true, signatures_secured: Some(false), @@ -2142,13 +1509,13 @@ mod tests { 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()) + session.psbt.frozen_fingerprint.as_deref(), + Some(psbt.fingerprint.as_str()) ); assert!(session.cand.funding_outpoint.is_none()); let candidate = candidate_funding_facts_from_psbt( - &psbt, + &serialized_psbt, &"99".repeat(32), 0, &session.old.funding_outpoint, @@ -2157,8 +1524,8 @@ mod tests { state .record_splice_signed_response(SpliceSignedResponseFacts { node_channel_id_hex: "44".repeat(32), - psbt_shape_hash: shape_hash, - psbt_shape: parsed_shape, + psbt_fingerprint: psbt.fingerprint, + psbt_input_outpoints: psbt.input_outpoints, splice_signed_auth: auth("/cln.Node/SpliceSigned", &"12".repeat(32), 3), candidate: Some(candidate), timestamp_ms: 3, @@ -2178,10 +1545,12 @@ mod tests { } #[test] - fn session_serializes_to_grouped_schema() { - let session = local_session(); + fn session_roundtrips_through_grouped_schema() { + let mut session = local_session(); + session.psbt.candidate_fingerprint = Some("candidate".to_string()); + session.psbt.frozen_fingerprint = Some("frozen".to_string()); - let value = serde_json::to_value(session).unwrap(); + let value = serde_json::to_value(&session).unwrap(); assert_eq!(value["schema"], json!("SpliceSessionV1")); assert!(value.get("old").is_some()); @@ -2190,7 +1559,7 @@ mod tests { 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("linked_wallet_psbt_fingerprints").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()); @@ -2198,180 +1567,52 @@ mod tests { value["auth"]["splice_init_auth"]["uri"], json!("/cln.Node/SpliceInit") ); + assert_eq!( + value["psbt"], + json!({ + "candidate_fingerprint": "candidate", + "frozen_fingerprint": "frozen", + }) + ); assert!(value["auth"]["splice_init_auth"].get("request").is_none()); assert!(value["auth"]["splice_init_auth"].get("payload").is_none()); + assert_eq!( + serde_json::from_value::(value).unwrap(), + session + ); } #[test] - fn local_session_updates_freezes_and_tombstones_related_keys() { + fn outpoint_lookup_survives_restart_and_tombstone_rejects_stale_merge() { let mut state = State::new(); + let fingerprint = "aa".repeat(32); state.create_local_splice_session(local_session()).unwrap(); - state - .update_splice_candidate_shape( + .update_splice_candidate( &"44".repeat(32), - "shape-a".to_string(), - shape("01"), - auth("/cln.Node/SpliceUpdate", &"77".repeat(32), 2), + fingerprint.clone(), + auth("/cln.Node/SpliceUpdate", &"dd".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"), + .put_splice_wallet_psbt_context( + &fingerprint, + SpliceWalletPsbtContextV1::new( 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, - }], + vec![], 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, - ) + .link_splice_wallet_psbt(&fingerprint, &"44".repeat(32), 2) .unwrap(); state .freeze_splice_candidate( &"44".repeat(32), - "shape-a".to_string(), + fingerprint.clone(), CandidateFundingFacts { funding_outpoint: outpoint(&"ee".repeat(32), 2), value_sat: 1_050_000, @@ -2390,6 +1631,10 @@ mod tests { .get_splice_by_outpoint(&"ee".repeat(32), 2) .unwrap() .is_some()); + assert!(restored + .get_psbt_context(&fingerprint) + .unwrap() + .is_some()); state.tombstone_splice_session(&"44".repeat(32)).unwrap(); state.merge(&stale_state).unwrap(); @@ -2402,5 +1647,9 @@ mod tests { .get_splice_by_outpoint(&"ee".repeat(32), 2) .unwrap() .is_none()); + assert!(state + .get_psbt_context(&fingerprint) + .unwrap() + .is_none()); } } diff --git a/libs/gl-client/src/psbt.rs b/libs/gl-client/src/psbt.rs new file mode 100644 index 000000000..4ff74e201 --- /dev/null +++ b/libs/gl-client/src/psbt.rs @@ -0,0 +1,527 @@ +use crate::bitcoin::consensus::encode::VarInt; +use crate::bitcoin::consensus::Decodable; +use crate::bitcoin::{OutPoint, ScriptBuf, Sequence, Transaction, TxIn, TxOut, Txid, Witness}; +use base64::{engine::general_purpose, Engine as _}; +use std::io::Cursor; +use thiserror::Error; + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct CandidateFunding { + pub outpoint: OutPoint, + pub txout: TxOut, + pub sign_splice_tx_input_index: u32, +} + +#[derive(Debug, Error)] +pub enum Error { + #[error("invalid base64 PSBT: {0}")] + InvalidBase64(#[source] base64::DecodeError), + #[error("invalid PSBT (v0: {v0}; v2: {v2})")] + InvalidPsbt { v0: String, v2: String }, + #[error("invalid PSBT framing: {0}")] + InvalidFraming(String), + #[error("PSBT contains trailing data")] + TrailingData, + #[error("PSBT inputs require incompatible lock times")] + IncompatibleLockTime, + #[error("PSBT input {input_index} has no UTXO")] + MissingUtxo { input_index: usize }, + #[error( + "PSBT input {input_index} non-witness UTXO txid mismatch: expected {expected}, got {actual}" + )] + NonWitnessUtxoTxidMismatch { + input_index: usize, + expected: Txid, + actual: Txid, + }, + #[error( + "PSBT input {input_index} references output {vout}, but its non-witness UTXO has {output_count} outputs" + )] + UtxoOutputMissing { + input_index: usize, + vout: u32, + output_count: usize, + }, + #[error("PSBT input {input_index} has inconsistent witness and non-witness UTXOs")] + InconsistentUtxo { input_index: usize }, + #[error("splice PSBT does not spend old funding outpoint {0}")] + FundingOutpointNotSpent(OutPoint), + #[error("splice PSBT spends old funding outpoint {0} more than once")] + FundingOutpointSpentMultipleTimes(OutPoint), + #[error("splice funding output index {vout} is missing")] + FundingOutputMissing { vout: u32 }, + #[error("splice transaction has too many inputs to represent the funding input index")] + FundingInputIndexOverflow, +} + +#[derive(Clone, Debug)] +enum ParsedPsbtInner { + V0(psbt_v2::v0::Psbt), + V2(psbt_v2::v2::Psbt), +} + +#[derive(Clone, Debug)] +pub struct ParsedPsbt { + inner: ParsedPsbtInner, + unsigned_tx: Transaction, +} + +impl ParsedPsbt { + pub fn from_base64(encoded: &str) -> Result { + let bytes = general_purpose::STANDARD + .decode(encoded) + .map_err(Error::InvalidBase64)?; + Self::from_bytes(&bytes) + } + + pub fn from_bytes(bytes: &[u8]) -> Result { + let v0_error = match psbt_v2::v0::Psbt::deserialize(bytes) { + Ok(psbt) => { + ensure_complete_psbt(bytes, psbt.inputs.len(), psbt.outputs.len())?; + let unsigned_tx = psbt.unsigned_tx.clone(); + return Ok(Self { + inner: ParsedPsbtInner::V0(psbt), + unsigned_tx, + }); + } + Err(error) => format!("{error:?}"), + }; + + match psbt_v2::v2::Psbt::deserialize(bytes) { + Ok(psbt) => { + ensure_complete_psbt(bytes, psbt.global.input_count, psbt.global.output_count)?; + let unsigned_tx = v2_unsigned_tx(&psbt)?; + Ok(Self { + inner: ParsedPsbtInner::V2(psbt), + unsigned_tx, + }) + } + Err(error) => Err(Error::InvalidPsbt { + v0: v0_error, + v2: format!("{error:?}"), + }), + } + } + + pub fn unsigned_tx(&self) -> &Transaction { + &self.unsigned_tx + } + + /// Return the version-independent unsigned transaction fingerprint. + pub fn fingerprint(&self) -> Txid { + self.unsigned_tx.compute_txid() + } + + pub fn input_outpoints(&self) -> Vec { + self.unsigned_tx + .input + .iter() + .map(|input| input.previous_output) + .collect() + } + + pub fn funding_utxos(&self) -> Result, Error> { + match &self.inner { + ParsedPsbtInner::V0(psbt) => self + .unsigned_tx + .input + .iter() + .zip(&psbt.inputs) + .enumerate() + .map(|(input_index, (txin, input))| { + validated_funding_utxo( + input_index, + txin.previous_output, + input.witness_utxo.as_ref(), + input.non_witness_utxo.as_ref(), + ) + }) + .collect(), + ParsedPsbtInner::V2(psbt) => psbt + .inputs + .iter() + .enumerate() + .map(|(input_index, input)| { + validated_funding_utxo( + input_index, + OutPoint::new(input.previous_txid, input.spent_output_index), + input.witness_utxo.as_ref(), + input.non_witness_utxo.as_ref(), + ) + }) + .collect(), + } + } + + pub fn candidate_funding( + &self, + funding_txid: Txid, + funding_vout: u32, + old_funding_outpoint: OutPoint, + ) -> Result { + let mut matching_inputs = self + .unsigned_tx + .input + .iter() + .enumerate() + .filter(|(_, input)| input.previous_output == old_funding_outpoint); + let (input_index, _) = matching_inputs + .next() + .ok_or(Error::FundingOutpointNotSpent(old_funding_outpoint))?; + if matching_inputs.next().is_some() { + return Err(Error::FundingOutpointSpentMultipleTimes( + old_funding_outpoint, + )); + } + + let sign_splice_tx_input_index = + u32::try_from(input_index).map_err(|_| Error::FundingInputIndexOverflow)?; + let txout = self + .unsigned_tx + .output + .get(funding_vout as usize) + .cloned() + .ok_or(Error::FundingOutputMissing { vout: funding_vout })?; + + Ok(CandidateFunding { + outpoint: OutPoint::new(funding_txid, funding_vout), + txout, + sign_splice_tx_input_index, + }) + } +} + +fn v2_unsigned_tx(psbt: &psbt_v2::v2::Psbt) -> Result { + let lock_time = psbt + .determine_lock_time() + .map_err(|_| Error::IncompatibleLockTime)?; + let input = psbt + .inputs + .iter() + .map(|input| TxIn { + previous_output: OutPoint::new(input.previous_txid, input.spent_output_index), + script_sig: ScriptBuf::new(), + sequence: input.sequence.unwrap_or(Sequence::MAX), + witness: Witness::new(), + }) + .collect(); + let output = psbt + .outputs + .iter() + .map(|output| TxOut { + value: output.amount, + script_pubkey: output.script_pubkey.clone(), + }) + .collect(); + + Ok(Transaction { + version: psbt.global.tx_version, + lock_time, + input, + output, + }) +} + +fn validated_funding_utxo( + input_index: usize, + previous_output: OutPoint, + witness_utxo: Option<&TxOut>, + non_witness_utxo: Option<&Transaction>, +) -> Result { + let non_witness_output = non_witness_utxo + .map(|transaction| { + let actual = transaction.compute_txid(); + if actual != previous_output.txid { + return Err(Error::NonWitnessUtxoTxidMismatch { + input_index, + expected: previous_output.txid, + actual, + }); + } + transaction + .output + .get(previous_output.vout as usize) + .ok_or(Error::UtxoOutputMissing { + input_index, + vout: previous_output.vout, + output_count: transaction.output.len(), + }) + }) + .transpose()?; + + match (witness_utxo, non_witness_output) { + (Some(witness), Some(non_witness)) if witness != non_witness => { + Err(Error::InconsistentUtxo { input_index }) + } + (Some(witness), _) => Ok(witness.clone()), + (None, Some(non_witness)) => Ok(non_witness.clone()), + (None, None) => Err(Error::MissingUtxo { input_index }), + } +} + +fn ensure_complete_psbt( + bytes: &[u8], + input_count: usize, + output_count: usize, +) -> Result<(), Error> { + if !bytes.starts_with(b"psbt\xff") { + return Err(Error::InvalidFraming("invalid magic bytes".to_string())); + } + + // psbt-v2 0.3.0 accepts trailing bytes. This cursor only validates map + // framing and leaves all PSBT key/value parsing to the crate. + let mut cursor = Cursor::new(bytes); + cursor.set_position(5); + consume_map(&mut cursor)?; + for _ in 0..input_count { + consume_map(&mut cursor)?; + } + for _ in 0..output_count { + consume_map(&mut cursor)?; + } + + if cursor.position() != bytes.len() as u64 { + return Err(Error::TrailingData); + } + Ok(()) +} + +fn consume_map(cursor: &mut Cursor<&[u8]>) -> Result<(), Error> { + loop { + let key_length = read_compact_size(cursor)?; + if key_length == 0 { + return Ok(()); + } + skip_bytes(cursor, key_length)?; + let value_length = read_compact_size(cursor)?; + skip_bytes(cursor, value_length)?; + } +} + +fn read_compact_size(cursor: &mut Cursor<&[u8]>) -> Result { + VarInt::consensus_decode(cursor) + .map(|value| value.0) + .map_err(|error| Error::InvalidFraming(error.to_string())) +} + +fn skip_bytes(cursor: &mut Cursor<&[u8]>, count: u64) -> Result<(), Error> { + let end = cursor + .position() + .checked_add(count) + .ok_or_else(|| Error::InvalidFraming("map length overflow".to_string()))?; + if end > cursor.get_ref().len() as u64 { + return Err(Error::InvalidFraming( + "map entry extends past end of PSBT".to_string(), + )); + } + cursor.set_position(end); + Ok(()) +} + +#[cfg(test)] +pub(crate) const CLN_PSBT_V2: &str = "cHNidP8BAgQCAAAAAQQBAQEFAQIB+wQCAAAAAAEOIAsK2SFBnByHGXNdctxzn56p4GONH+TB7vD5lECEgV/IAQ8EAAAAAAABAwgACK8vAAAAAAEEFgAUxDD2TEdW2jENvRoIVXLvKZkmJywAAQMIi73rCwAAAAABBBYAFE3Rk6yWSlasG54cyoRU/i9HT4UTAA=="; + +#[cfg(test)] +mod tests { + use super::*; + use crate::bitcoin::absolute::LockTime; + use crate::bitcoin::transaction::Version; + use crate::bitcoin::{Amount, ScriptBuf, Sequence, Transaction, TxIn, TxOut, Witness}; + use base64::engine::general_purpose; + use std::str::FromStr; + + fn unsigned_tx(previous_output: OutPoint) -> Transaction { + Transaction { + version: Version::TWO, + lock_time: LockTime::ZERO, + input: vec![TxIn { + previous_output, + script_sig: ScriptBuf::new(), + sequence: Sequence::MAX, + witness: Witness::new(), + }], + output: vec![TxOut { + value: Amount::from_sat(75_000), + script_pubkey: ScriptBuf::new(), + }], + } + } + + fn v0_psbt(previous_output: OutPoint) -> psbt_v2::v0::Psbt { + psbt_v2::v0::Psbt::from_unsigned_tx(unsigned_tx(previous_output)).unwrap() + } + + #[test] + fn parses_cln_v2_with_bip370_sequence_default() { + let psbt = ParsedPsbt::from_base64(CLN_PSBT_V2).unwrap(); + + assert_eq!(psbt.unsigned_tx().input[0].sequence, Sequence::MAX); + assert_eq!(psbt.input_outpoints().len(), 1); + assert_eq!(psbt.fingerprint(), psbt.unsigned_tx().compute_txid()); + } + + #[test] + fn equivalent_v0_and_v2_psbts_have_the_same_fingerprint() { + let v2 = ParsedPsbt::from_base64(CLN_PSBT_V2).unwrap(); + let v0 = psbt_v2::v0::Psbt::from_unsigned_tx(v2.unsigned_tx().clone()).unwrap(); + let v0 = ParsedPsbt::from_bytes(&v0.serialize()).unwrap(); + + assert_eq!(v0.fingerprint(), v2.fingerprint()); + } + + #[test] + fn rejects_trailing_data_for_both_versions() { + let previous_output = OutPoint::new(Txid::from_str(&"11".repeat(32)).unwrap(), 0); + let mut v0 = v0_psbt(previous_output).serialize(); + v0.push(0); + let mut v2 = general_purpose::STANDARD.decode(CLN_PSBT_V2).unwrap(); + v2.push(0); + + assert!(matches!( + ParsedPsbt::from_bytes(&v0), + Err(Error::TrailingData) + )); + assert!(matches!( + ParsedPsbt::from_bytes(&v2), + Err(Error::TrailingData) + )); + } + + #[test] + fn rejects_noncanonical_map_framing() { + let raw = general_purpose::STANDARD.decode(CLN_PSBT_V2).unwrap(); + assert_eq!(raw[5], 1); + let mut non_minimal = Vec::with_capacity(raw.len() + 2); + non_minimal.extend_from_slice(&raw[..5]); + non_minimal.extend_from_slice(&[0xfd, 0x01, 0x00]); + non_minimal.extend_from_slice(&raw[6..]); + + assert!(ParsedPsbt::from_bytes(&non_minimal).is_err()); + } + + #[test] + fn rejects_incompatible_v2_locktimes() { + let raw = general_purpose::STANDARD.decode(CLN_PSBT_V2).unwrap(); + let mut v2 = psbt_v2::v2::Psbt::deserialize(&raw).unwrap(); + v2.inputs[0].min_height = + Some(crate::bitcoin::absolute::Height::from_consensus(1).unwrap()); + let mut time_locked_input = v2.inputs[0].clone(); + time_locked_input.min_height = None; + time_locked_input.min_time = + Some(crate::bitcoin::absolute::Time::from_consensus(500_000_000).unwrap()); + v2.inputs.push(time_locked_input); + v2.global.input_count += 1; + + assert!(matches!( + ParsedPsbt::from_bytes(&v2.serialize()), + Err(Error::IncompatibleLockTime) + )); + } + + #[test] + fn validates_and_returns_funding_utxos() { + let previous_tx = Transaction { + version: Version::TWO, + lock_time: LockTime::ZERO, + input: vec![], + output: vec![TxOut { + value: Amount::from_sat(80_000), + script_pubkey: ScriptBuf::new(), + }], + }; + let previous_output = OutPoint::new(previous_tx.compute_txid(), 0); + let mut v0 = v0_psbt(previous_output); + v0.inputs[0].non_witness_utxo = Some(previous_tx.clone()); + v0.inputs[0].witness_utxo = Some(previous_tx.output[0].clone()); + + let parsed = ParsedPsbt::from_bytes(&v0.serialize()).unwrap(); + + assert_eq!(parsed.funding_utxos().unwrap(), previous_tx.output); + } + + #[test] + fn rejects_missing_funding_utxo() { + let previous_output = OutPoint::new(Txid::from_str(&"11".repeat(32)).unwrap(), 0); + let parsed = ParsedPsbt::from_bytes(&v0_psbt(previous_output).serialize()).unwrap(); + + assert!(matches!( + parsed.funding_utxos(), + Err(Error::MissingUtxo { input_index: 0 }) + )); + } + + #[test] + fn rejects_missing_non_witness_output() { + let previous_tx = Transaction { + version: Version::TWO, + lock_time: LockTime::ZERO, + input: vec![], + output: vec![TxOut { + value: Amount::from_sat(80_000), + script_pubkey: ScriptBuf::new(), + }], + }; + let mut v0 = v0_psbt(OutPoint::new(previous_tx.compute_txid(), 1)); + v0.inputs[0].non_witness_utxo = Some(previous_tx); + let parsed = ParsedPsbt::from_bytes(&v0.serialize()).unwrap(); + + assert!(matches!( + parsed.funding_utxos(), + Err(Error::UtxoOutputMissing { + input_index: 0, + vout: 1, + output_count: 1, + }) + )); + } + + #[test] + fn rejects_mismatched_non_witness_utxo_even_with_witness_utxo() { + let previous_output = OutPoint::new(Txid::from_str(&"11".repeat(32)).unwrap(), 0); + let mut v0 = v0_psbt(previous_output); + let wrong_tx = Transaction { + version: Version::TWO, + lock_time: LockTime::ZERO, + input: vec![], + output: vec![TxOut { + value: Amount::from_sat(80_000), + script_pubkey: ScriptBuf::new(), + }], + }; + v0.inputs[0].non_witness_utxo = Some(wrong_tx.clone()); + v0.inputs[0].witness_utxo = Some(wrong_tx.output[0].clone()); + + let parsed = ParsedPsbt::from_bytes(&v0.serialize()).unwrap(); + + assert!(matches!( + parsed.funding_utxos(), + Err(Error::NonWitnessUtxoTxidMismatch { input_index: 0, .. }) + )); + } + + #[test] + fn rejects_inconsistent_witness_and_non_witness_utxos() { + let previous_tx = Transaction { + version: Version::TWO, + lock_time: LockTime::ZERO, + input: vec![], + output: vec![TxOut { + value: Amount::from_sat(80_000), + script_pubkey: ScriptBuf::new(), + }], + }; + let mut v0 = v0_psbt(OutPoint::new(previous_tx.compute_txid(), 0)); + v0.inputs[0].non_witness_utxo = Some(previous_tx); + v0.inputs[0].witness_utxo = Some(TxOut { + value: Amount::from_sat(79_999), + script_pubkey: ScriptBuf::new(), + }); + + let parsed = ParsedPsbt::from_bytes(&v0.serialize()).unwrap(); + + assert!(matches!( + parsed.funding_utxos(), + Err(Error::InconsistentUtxo { input_index: 0 }) + )); + } +} diff --git a/libs/gl-client/src/signer/splice_policy.rs b/libs/gl-client/src/signer/splice_policy.rs index 7e51f2956..da46b5b17 100644 --- a/libs/gl-client/src/signer/splice_policy.rs +++ b/libs/gl-client/src/signer/splice_policy.rs @@ -1,8 +1,6 @@ use crate::pb::HsmRequestContext; -use crate::persist::{ - psbt_shape_from_base64, psbt_shape_from_psbt, transaction_shape, SpliceOrigin, SplicePhase, - SpliceSessionV1, State, -}; +use crate::persist::{SpliceOrigin, SplicePhase, SpliceSessionV1, State}; +use crate::psbt::ParsedPsbt; use crate::signer::model::Request; use anyhow::{anyhow, bail}; use lightning_signer::bitcoin::secp256k1::PublicKey; @@ -37,7 +35,7 @@ pub(crate) enum SplicePolicyViolation { InvalidPhase, MissingSignPsbtIntent, MissingSignPsbtAuth, - PsbtShapeMismatch, + PsbtFingerprintMismatch, UnknownWalletInput, SignOnlyViolation, OldFundingInputSelected, @@ -253,7 +251,7 @@ fn classify_setup_channel( fn pending_splice_psbt_matches( pending_requests: &[Request], session: &SpliceSessionV1, - expected_shape_hash: &str, + expected_fingerprint: &str, ) -> bool { pending_requests.iter().any(|pending| { let (channel_id, psbt) = match pending { @@ -272,8 +270,8 @@ fn pending_splice_psbt_matches( _ => return false, }; hex::encode(channel_id) == session.channel_id_hex - && psbt_shape_from_base64(psbt) - .map(|(shape_hash, _)| shape_hash == expected_shape_hash) + && ParsedPsbt::from_base64(psbt) + .map(|psbt| psbt.fingerprint().to_string() == expected_fingerprint) .unwrap_or(false) }) } @@ -281,25 +279,25 @@ fn pending_splice_psbt_matches( fn pending_splice_update_psbt_matches( pending_requests: &[Request], session: &SpliceSessionV1, - expected_shape_hash: &str, + expected_fingerprint: &str, ) -> bool { pending_requests.iter().any(|pending| { let Request::SpliceUpdate(request) = pending else { return false; }; let pending_channel_id_hex = hex::encode(&request.channel_id); - let pending_shape_hash = psbt_shape_from_base64(&request.psbt) - .map(|(shape_hash, _)| shape_hash) + let pending_fingerprint = ParsedPsbt::from_base64(&request.psbt) + .map(|psbt| psbt.fingerprint().to_string()) .ok(); log::debug!( - "matching negotiating splice_update: channel_match={}, shape_match={}, pending_shape_hash={:?}, expected_shape_hash={}", + "matching negotiating splice_update: channel_match={}, fingerprint_match={}, pending_fingerprint={:?}, expected_fingerprint={}", pending_channel_id_hex == session.channel_id_hex, - pending_shape_hash.as_deref() == Some(expected_shape_hash), - pending_shape_hash, - expected_shape_hash, + pending_fingerprint.as_deref() == Some(expected_fingerprint), + pending_fingerprint, + expected_fingerprint, ); pending_channel_id_hex == session.channel_id_hex - && pending_shape_hash.as_deref() == Some(expected_shape_hash) + && pending_fingerprint.as_deref() == Some(expected_fingerprint) }) } @@ -332,28 +330,26 @@ fn classify_sign_splice_tx( } let psbt = &request.psbt.0.inner; - let (shape_hash, psbt_shape) = psbt_shape_from_psbt(psbt)?; - if transaction_shape(&request.tx.0) != psbt_shape { + let psbt_fingerprint = psbt.unsigned_tx.compute_txid().to_string(); + if request.tx.0.compute_txid().to_string() != psbt_fingerprint { return reject(SplicePolicyViolation::TxPsbtMismatch); } - let expected_shape_hash = session + let expected_fingerprint = session .psbt - .frozen_psbt_shape_hash + .frozen_fingerprint .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); + .or(session.psbt.candidate_fingerprint.as_deref()); + if expected_fingerprint != Some(psbt_fingerprint.as_str()) { + return reject(SplicePolicyViolation::PsbtFingerprintMismatch); } if session.phase == SplicePhase::Negotiating && session.origin == SpliceOrigin::LocalInitiator - && !pending_splice_update_psbt_matches(pending_requests, &session, &shape_hash) + && !pending_splice_update_psbt_matches(pending_requests, &session, &psbt_fingerprint) { return reject(SplicePolicyViolation::InvalidPhase); } if session.origin == SpliceOrigin::LocalInitiator - && !pending_splice_psbt_matches(pending_requests, &session, &shape_hash) + && !pending_splice_psbt_matches(pending_requests, &session, &psbt_fingerprint) { return reject(SplicePolicyViolation::MissingSpliceIntent); } @@ -494,8 +490,8 @@ fn classify_sign_withdrawal( 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 { + let psbt_fingerprint = psbt.unsigned_tx.compute_txid().to_string(); + let Some(context) = state.get_psbt_context(&psbt_fingerprint)? else { return Ok(SplicePolicyDecision::NotSpliceRelated); }; let Some(node_channel_id_hex) = context.linked_node_channel_id_hex.as_deref() else { @@ -516,32 +512,30 @@ fn classify_sign_withdrawal( ) { 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 + if !session + .linked_wallet_psbt_fingerprints .iter() - .any(|hash| hash == &shape_hash) + .any(|fingerprint| fingerprint == &psbt_fingerprint) { - return reject(SplicePolicyViolation::PsbtShapeMismatch); + return reject(SplicePolicyViolation::PsbtFingerprintMismatch); } - let expected_splice_shape = session + let expected_splice_fingerprint = session .psbt - .frozen_psbt_shape_hash + .frozen_fingerprint .as_deref() - .or(session.psbt.candidate_psbt_shape_hash.as_deref()); - if expected_splice_shape != Some(shape_hash.as_str()) { - return reject(SplicePolicyViolation::PsbtShapeMismatch); + .or(session.psbt.candidate_fingerprint.as_deref()); + if expected_splice_fingerprint != Some(psbt_fingerprint.as_str()) { + return reject(SplicePolicyViolation::PsbtFingerprintMismatch); } let matching_signpsbt = pending_requests.iter().any(|pending| { let Request::SignPsbt(pending) = pending else { return false; }; - psbt_shape_from_base64(&pending.psbt) - .map(|(pending_hash, _)| { - pending_hash == shape_hash && pending.signonly == context.signonly + ParsedPsbt::from_base64(&pending.psbt) + .map(|pending_psbt| { + pending_psbt.fingerprint().to_string() == psbt_fingerprint + && pending.signonly == context.signonly }) .unwrap_or(false) }); @@ -590,10 +584,9 @@ mod tests { 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, + CandidateFundingFacts, FeePolicy, FundPsbtResponseFacts, FundingOutpoint, + LocalSpliceIntent, NormalizedRpcAuth, OldSpliceState, SignPsbtIntentFacts, SpliceOrigin, + SpliceSessionV1, SpliceUpdateResponseFacts, State, WalletInput, }; use crate::signer::model::{ cln::{SignpsbtRequest, SpliceSignedRequest, SpliceUpdateRequest}, @@ -618,6 +611,21 @@ mod tests { ) } + fn input_outpoints(transaction: &Transaction) -> Vec { + transaction + .input + .iter() + .map(|input| FundingOutpoint { + txid: input.previous_output.txid.to_string(), + vout: input.previous_output.vout, + }) + .collect() + } + + fn psbt_fingerprint(psbt: &Psbt) -> String { + psbt.unsigned_tx.compute_txid().to_string() + } + struct SpliceSigningFixture { message: Message, pending: Vec, @@ -677,7 +685,8 @@ mod tests { script_pubkey: ScriptBuf::new(), }); let encoded_psbt = general_purpose::STANDARD.encode(psbt.serialize()); - let (shape_hash, shape) = psbt_shape_from_psbt(&psbt).unwrap(); + let psbt_fingerprint = psbt_fingerprint(&psbt); + let psbt_input_outpoints = input_outpoints(&psbt.unsigned_tx); let old = OldSpliceState { funding_outpoint: FundingOutpoint { txid: old_txid.to_string(), @@ -697,8 +706,8 @@ mod tests { 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()), + initial_psbt_fingerprint: psbt_fingerprint.clone(), + initial_psbt_input_outpoints: psbt_input_outpoints, timestamp_ms: 1, }) .unwrap(), @@ -714,8 +723,7 @@ mod tests { FeePolicy::default(), 1, ); - session.psbt.candidate_psbt_shape_hash = Some(shape_hash.clone()); - session.psbt.candidate_psbt_shape = Some(shape.clone()); + session.psbt.candidate_fingerprint = Some(psbt_fingerprint.clone()); session.delta.computed = delta_computed; session.delta.no_local_loss = no_local_loss; if origin == SpliceOrigin::PeerInitiated { @@ -728,7 +736,7 @@ mod tests { state .freeze_splice_candidate( &node_channel_id_hex, - shape_hash, + psbt_fingerprint, CandidateFundingFacts { funding_outpoint: FundingOutpoint { txid: tx.compute_txid().to_string(), @@ -807,7 +815,9 @@ mod tests { let Request::SpliceSigned(signed) = fixture.pending[0].clone() else { unreachable!(); }; - let (shape_hash, shape) = psbt_shape_from_base64(&signed.psbt).unwrap(); + let psbt = ParsedPsbt::from_base64(&signed.psbt).unwrap(); + let psbt_fingerprint = psbt.fingerprint().to_string(); + let psbt_input_outpoints = input_outpoints(psbt.unsigned_tx()); let mut state = State::new(); state .record_local_splice_intent(LocalSpliceIntent { @@ -818,16 +828,16 @@ mod tests { 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()), + initial_psbt_fingerprint: psbt_fingerprint.clone(), + initial_psbt_input_outpoints: psbt_input_outpoints.clone(), timestamp_ms: 1, }) .unwrap(); state .record_splice_update_response(SpliceUpdateResponseFacts { node_channel_id_hex: session.node_channel_id_hex, - psbt_shape_hash: shape_hash, - psbt_shape: shape, + psbt_fingerprint, + psbt_input_outpoints, splice_update_auth: auth("/cln.Node/SpliceUpdate", "66", 2), commitments_secured: false, signatures_secured: Some(false), @@ -888,7 +898,8 @@ mod tests { script_pubkey: ScriptBuf::new(), }); let encoded_psbt = general_purpose::STANDARD.encode(psbt.serialize()); - let (shape_hash, shape) = psbt_shape_from_psbt(&psbt).unwrap(); + let psbt_fingerprint = psbt_fingerprint(&psbt); + let psbt_input_outpoints = input_outpoints(&psbt.unsigned_tx); let node_channel_id_hex = "44".repeat(74); let old = OldSpliceState { funding_outpoint: FundingOutpoint { @@ -901,17 +912,14 @@ mod tests { let mut state = State::new(); state .record_fundpsbt_response(FundPsbtResponseFacts { - psbt_shape_hash: shape_hash.clone(), - psbt_shape: shape.clone(), + psbt_fingerprint: psbt_fingerprint.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(); @@ -925,8 +933,8 @@ mod tests { 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()), + initial_psbt_fingerprint: psbt_fingerprint.clone(), + initial_psbt_input_outpoints: psbt_input_outpoints.clone(), timestamp_ms: 2, }) .unwrap(), @@ -954,8 +962,8 @@ mod tests { state .record_splice_update_response(SpliceUpdateResponseFacts { node_channel_id_hex, - psbt_shape_hash: shape_hash.clone(), - psbt_shape: shape.clone(), + psbt_fingerprint: psbt_fingerprint.clone(), + psbt_input_outpoints, splice_update_auth: auth("/cln.Node/SpliceUpdate", "77", 3), commitments_secured, signatures_secured: Some(false), @@ -964,8 +972,7 @@ mod tests { .unwrap(); state .record_signpsbt_intent(SignPsbtIntentFacts { - psbt_shape_hash: shape_hash, - psbt_shape: shape, + psbt_fingerprint, signpsbt_auth: auth("/cln.Node/SignPsbt", "88", 4), signonly: vec![1], timestamp_ms: 4, @@ -1026,6 +1033,29 @@ mod tests { ); } + #[test] + fn unlinked_wallet_psbt_remains_non_splice() { + let (message, pending, state) = sign_withdrawal_fixture(); + let Message::SignWithdrawal(request) = &message else { + unreachable!(); + }; + let fingerprint = psbt_fingerprint(&request.psbt.0.psbt.inner); + let mut context = state + .get_psbt_context(&fingerprint) + .unwrap() + .unwrap(); + context.linked_node_channel_id_hex = None; + let mut unrelated_state = State::new(); + unrelated_state + .put_splice_wallet_psbt_context(&fingerprint, context) + .unwrap(); + + assert_eq!( + classify(&message, &pending, &unrelated_state, &[], None).unwrap(), + SplicePolicyDecision::NotSpliceRelated + ); + } + #[test] fn splice_signwithdrawal_without_current_signpsbt_rejects() { let (message, _, state) = sign_withdrawal_fixture(); @@ -1042,34 +1072,15 @@ mod tests { let Message::SignWithdrawal(request) = &message else { unreachable!(); }; - let (shape_hash, _) = psbt_shape_from_psbt(&request.psbt.0.psbt.inner).unwrap(); + let fingerprint = psbt_fingerprint(&request.psbt.0.psbt.inner); let mut context = state - .get_splice_wallet_psbt_context(&shape_hash) + .get_psbt_context(&fingerprint) .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() + state + .put_splice_wallet_psbt_context(&fingerprint, context) .unwrap(); - context.signpsbt_auth = None; - state.put_splice_wallet_psbt_context(context).unwrap(); assert_eq!( classify(&message, &pending, &state, &[], None).unwrap(), @@ -1083,13 +1094,15 @@ mod tests { let Message::SignWithdrawal(request) = &message else { unreachable!(); }; - let (shape_hash, _) = psbt_shape_from_psbt(&request.psbt.0.psbt.inner).unwrap(); + let fingerprint = psbt_fingerprint(&request.psbt.0.psbt.inner); let mut context = state - .get_splice_wallet_psbt_context(&shape_hash) + .get_psbt_context(&fingerprint) .unwrap() .unwrap(); context.signonly = vec![0]; - state.put_splice_wallet_psbt_context(context).unwrap(); + state + .put_splice_wallet_psbt_context(&fingerprint, context) + .unwrap(); let Request::SignPsbt(request) = &mut pending[0] else { unreachable!(); }; @@ -1364,7 +1377,7 @@ mod tests { } #[test] - fn known_splice_check_outpoint_reaches_vls_boundary() { + fn known_splice_outpoint_requests_reach_vls_boundaries() { // TODO: Remove this stub-boundary test once VLS splice support is in place. let mut fixture = splice_signing_fixture(SpliceOrigin::LocalInitiator, false, false); fixture @@ -1377,11 +1390,26 @@ mod tests { .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, - }); - + let funding_txid = Txid::from_str(&outpoint.txid).unwrap(); + let funding_txout = outpoint.vout as u16; + let cases = [ + ( + Message::CheckOutpoint(CheckOutpoint { + funding_txid, + funding_txout, + }), + VlsProof::OutpointBurial, + ), + ( + Message::LockOutpoint(LockOutpoint { + funding_txid, + funding_txout, + }), + VlsProof::OutpointLock, + ), + ]; + + for (message, proof) in cases { assert_eq!( classify( &message, @@ -1391,40 +1419,9 @@ mod tests { Some(&fixture.context), ) .unwrap(), - SplicePolicyDecision::RequiresVlsProof(VlsProof::OutpointBurial) + SplicePolicyDecision::RequiresVlsProof(proof) ); } - - #[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] diff --git a/libs/gl-plugin/src/node/wrapper.rs b/libs/gl-plugin/src/node/wrapper.rs index caafb91ff..a3de65c41 100644 --- a/libs/gl-plugin/src/node/wrapper.rs +++ b/libs/gl-plugin/src/node/wrapper.rs @@ -9,10 +9,10 @@ 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, + candidate_funding_facts_from_psbt, parse_base64_psbt, wallet_inputs_from_psbt, + FeePolicy, FundPsbtResponseFacts, FundingOutpoint, LocalSpliceIntent, NormalizedRpcAuth, + OldSpliceState, SignPsbtIntentFacts, SpliceSignedResponseFacts, SpliceUpdateResponseFacts, + WalletInputReservation, }; use log::debug; use prost::Message; @@ -390,10 +390,8 @@ impl Node for WrappedNodeServer { 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 psbt = parse_base64_psbt(&body.psbt).map_err(internal_status)?; let timestamp_ms = auth.timestamp_ms; - let change_outnum = body.change_outnum; let reservations = body .reservations .iter() @@ -406,16 +404,13 @@ impl Node for WrappedNodeServer { }) .collect::>(); let wallet_inputs = - wallet_inputs_from_psbt(&body.psbt, &reservations, WalletInputSource::FundPsbt) - .map_err(internal_status)?; + wallet_inputs_from_psbt(&body.psbt, &reservations).map_err(internal_status)?; self.update_splice_state(|state| { state.record_fundpsbt_response(FundPsbtResponseFacts { - psbt_shape_hash, - psbt_shape, + psbt_fingerprint: psbt.fingerprint, fundpsbt_auth: auth, wallet_inputs, - change_outnum, timestamp_ms, }) }) @@ -438,15 +433,13 @@ impl Node for WrappedNodeServer { 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 psbt = parse_base64_psbt(&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, + psbt_fingerprint: psbt.fingerprint, signpsbt_auth: auth, signonly, timestamp_ms, @@ -881,19 +874,18 @@ impl Node for WrappedNodeServer { 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 + let source_wallet_psbt_fingerprint = request_body .initialpsbt .as_deref() - .map(psbt_shape_from_base64) + .map(parse_base64_psbt) .transpose() .map_err(internal_status)? - .map(|(hash, _)| hash); + .map(|psbt| psbt.fingerprint); 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 psbt = parse_base64_psbt(&body.psbt).map_err(internal_status)?; + let candidate_psbt_fingerprint = psbt.fingerprint.clone(); let timestamp_ms = auth.timestamp_ms; self.update_splice_state(|state| { state.record_local_splice_intent(LocalSpliceIntent { @@ -907,14 +899,15 @@ impl Node for WrappedNodeServer { 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), + initial_psbt_fingerprint: psbt.fingerprint.clone(), + initial_psbt_input_outpoints: psbt.input_outpoints.clone(), timestamp_ms, })?; - if let Some(source_psbt_shape_hash) = source_wallet_psbt_shape_hash.as_deref() { + if let Some(source_psbt_fingerprint) = source_wallet_psbt_fingerprint.as_deref() { state.inherit_splice_wallet_context( - source_psbt_shape_hash, - &candidate_psbt_shape_hash, + source_psbt_fingerprint, + &candidate_psbt_fingerprint, + &psbt.input_outpoints, &node_channel_id_hex, timestamp_ms, )?; @@ -935,8 +928,7 @@ impl Node for WrappedNodeServer { 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 psbt = parse_base64_psbt(&body.psbt).map_err(internal_status)?; let response_psbt = body.psbt.clone(); let funding_txid = hex::encode(&body.txid); let funding_outnum = body.outnum; @@ -963,8 +955,8 @@ impl Node for WrappedNodeServer { .transpose()?; state.record_splice_signed_response(SpliceSignedResponseFacts { node_channel_id_hex, - psbt_shape_hash, - psbt_shape, + psbt_fingerprint: psbt.fingerprint, + psbt_input_outpoints: psbt.input_outpoints, splice_signed_auth: auth, candidate, timestamp_ms, @@ -984,16 +976,15 @@ impl Node for WrappedNodeServer { 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 psbt = parse_base64_psbt(&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, + psbt_fingerprint: psbt.fingerprint, + psbt_input_outpoints: psbt.input_outpoints, splice_update_auth: auth, commitments_secured, signatures_secured, From 02095aa1cdee7221c061f34bb05716a00ca23c2a Mon Sep 17 00:00:00 2001 From: Ihor Diachenko Date: Wed, 12 Aug 2026 00:32:27 +0300 Subject: [PATCH 8/8] gl-client: Removed splice RPC auth receipts --- Cargo.lock | 1 - libs/gl-client/src/persist.rs | 8 +- libs/gl-client/src/persist/splice.rs | 188 +++------------------ libs/gl-client/src/signer/splice_policy.rs | 51 +----- libs/gl-plugin/Cargo.toml | 1 - libs/gl-plugin/src/node/wrapper.rs | 117 ++++--------- 6 files changed, 65 insertions(+), 301 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index e2bf0bc14..77a4ea3d2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1640,7 +1640,6 @@ dependencies = [ "prost", "serde", "serde_json", - "sha256", "sled", "thiserror 1.0.69", "tokio", diff --git a/libs/gl-client/src/persist.rs b/libs/gl-client/src/persist.rs index f52313c80..1d4510e06 100644 --- a/libs/gl-client/src/persist.rs +++ b/libs/gl-client/src/persist.rs @@ -2,10 +2,10 @@ mod canonical; mod splice; pub use splice::{ - candidate_funding_facts_from_psbt, parse_base64_psbt, wallet_inputs_from_psbt, - FeePolicy, FundPsbtResponseFacts, FundingOutpoint, LocalSpliceIntent, NormalizedRpcAuth, - OldSpliceState, PsbtCaptureFacts, SignPsbtIntentFacts, SpliceSignedResponseFacts, - SpliceUpdateResponseFacts, WalletInputReservation, + candidate_funding_facts_from_psbt, parse_base64_psbt, wallet_inputs_from_psbt, FeePolicy, + FundPsbtResponseFacts, FundingOutpoint, LocalSpliceIntent, OldSpliceState, PsbtCaptureFacts, + SignPsbtIntentFacts, SpliceSignedResponseFacts, SpliceUpdateResponseFacts, + WalletInputReservation, }; #[cfg(test)] pub(crate) use splice::{CandidateFundingFacts, WalletInput}; diff --git a/libs/gl-client/src/persist/splice.rs b/libs/gl-client/src/persist/splice.rs index 571f7193c..e91504950 100644 --- a/libs/gl-client/src/persist/splice.rs +++ b/libs/gl-client/src/persist/splice.rs @@ -69,34 +69,6 @@ pub struct FundingOutpoint { 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 OldSpliceState { pub funding_outpoint: FundingOutpoint, @@ -104,13 +76,6 @@ pub struct OldSpliceState { 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, @@ -129,7 +94,6 @@ pub struct LocalSpliceIntent { 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_fingerprint: String, @@ -194,7 +158,8 @@ pub struct SignerRequestRecord { #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] // TODO: Reconsider these fields once the VLS splice integration shape is settled. -// Keep Greenlight-owned authorization and intent here, and move protocol state to VLS. +// Keep observed Greenlight request facts here, and move authenticated intent and protocol +// state to the signer/VLS integration boundary. pub struct SpliceSessionV1 { pub schema: String, pub schema_version: u16, @@ -204,13 +169,11 @@ pub struct SpliceSessionV1 { 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_fingerprints: Vec, - pub request_history: Vec, pub signer_request_history: Vec, pub created_at_ms: u64, pub updated_at_ms: u64, @@ -224,7 +187,6 @@ impl SpliceSessionV1 { 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, @@ -238,11 +200,6 @@ impl SpliceSessionV1 { 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, @@ -251,7 +208,6 @@ impl SpliceSessionV1 { cand: SpliceCandidateState::default(), delta: SpliceDeltaState::default(), linked_wallet_psbt_fingerprints: 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, @@ -291,8 +247,6 @@ pub struct WalletInput { pub struct SpliceWalletPsbtContextV1 { pub schema: String, pub schema_version: u16, - pub fundpsbt_auth: Option, - pub signpsbt_auth: Option, pub signonly: Vec, pub wallet_inputs: Vec, pub linked_node_channel_id_hex: Option, @@ -301,17 +255,10 @@ pub struct SpliceWalletPsbtContextV1 { } impl SpliceWalletPsbtContextV1 { - pub fn new( - fundpsbt_auth: Option, - signpsbt_auth: Option, - wallet_inputs: Vec, - timestamp_ms: u64, - ) -> Self { + pub fn new(wallet_inputs: Vec, timestamp_ms: u64) -> Self { Self { schema: "SpliceWalletPsbtContextV1".to_string(), schema_version: 1, - fundpsbt_auth, - signpsbt_auth, signonly: Vec::new(), wallet_inputs, linked_node_channel_id_hex: None, @@ -331,7 +278,6 @@ pub struct WalletInputReservation { #[derive(Clone, Debug, PartialEq, Eq)] pub struct FundPsbtResponseFacts { pub psbt_fingerprint: String, - pub fundpsbt_auth: NormalizedRpcAuth, pub wallet_inputs: Vec, pub timestamp_ms: u64, } @@ -339,7 +285,6 @@ pub struct FundPsbtResponseFacts { #[derive(Clone, Debug, PartialEq, Eq)] pub struct SignPsbtIntentFacts { pub psbt_fingerprint: String, - pub signpsbt_auth: NormalizedRpcAuth, pub signonly: Vec, pub timestamp_ms: u64, } @@ -349,7 +294,6 @@ pub struct SpliceUpdateResponseFacts { pub node_channel_id_hex: String, pub psbt_fingerprint: String, pub psbt_input_outpoints: Vec, - pub splice_update_auth: NormalizedRpcAuth, pub commitments_secured: bool, pub signatures_secured: Option, pub timestamp_ms: u64, @@ -360,7 +304,6 @@ pub struct SpliceSignedResponseFacts { pub node_channel_id_hex: String, pub psbt_fingerprint: String, pub psbt_input_outpoints: Vec, - pub splice_signed_auth: NormalizedRpcAuth, pub candidate: Option, pub timestamp_ms: u64, } @@ -541,9 +484,7 @@ impl State { let source_fingerprints = session.linked_wallet_psbt_fingerprints.clone(); let mut context = self .get_psbt_context(psbt_fingerprint)? - .unwrap_or_else(|| { - SpliceWalletPsbtContextV1::new(None, None, Vec::new(), updated_at_ms) - }); + .unwrap_or_else(|| SpliceWalletPsbtContextV1::new(Vec::new(), updated_at_ms)); if let Some(linked_channel) = context.linked_node_channel_id_hex.as_deref() { if linked_channel != session.node_channel_id_hex { bail!( @@ -606,9 +547,6 @@ impl State { } 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"); } @@ -635,12 +573,10 @@ impl State { 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_fingerprint = Some(psbt_fingerprint.clone()); - session.request_history.push(intent.splice_init_auth); session.updated_at_ms = intent.timestamp_ms; self.link_splice_psbt_context( &mut session, @@ -657,7 +593,6 @@ impl State { 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, @@ -674,10 +609,8 @@ impl State { pub fn record_fundpsbt_response(&mut self, facts: FundPsbtResponseFacts) -> anyhow::Result<()> { let existing = self.get_psbt_context(&facts.psbt_fingerprint)?; - let mut context = existing.unwrap_or_else(|| { - SpliceWalletPsbtContextV1::new(None, None, Vec::new(), facts.timestamp_ms) - }); - context.fundpsbt_auth = Some(facts.fundpsbt_auth); + let mut context = existing + .unwrap_or_else(|| SpliceWalletPsbtContextV1::new(Vec::new(), facts.timestamp_ms)); context.wallet_inputs = facts.wallet_inputs; context.updated_at_ms = facts.timestamp_ms; self.put_splice_wallet_psbt_context(&facts.psbt_fingerprint, context) @@ -685,10 +618,8 @@ impl State { pub fn record_signpsbt_intent(&mut self, facts: SignPsbtIntentFacts) -> anyhow::Result<()> { let existing = self.get_psbt_context(&facts.psbt_fingerprint)?; - let mut context = existing.unwrap_or_else(|| { - SpliceWalletPsbtContextV1::new(None, None, Vec::new(), facts.timestamp_ms) - }); - context.signpsbt_auth = Some(facts.signpsbt_auth); + let mut context = existing + .unwrap_or_else(|| SpliceWalletPsbtContextV1::new(Vec::new(), facts.timestamp_ms)); context.signonly = facts.signonly; context.updated_at_ms = facts.timestamp_ms; self.put_splice_wallet_psbt_context(&facts.psbt_fingerprint, context) @@ -716,9 +647,6 @@ impl State { ); } - 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_fingerprint = Some(facts.psbt_fingerprint.clone()); session.psbt.candidate_fingerprint = Some(facts.psbt_fingerprint); @@ -773,8 +701,6 @@ impl State { ); } - session.auth.splice_signed_auth = Some(facts.splice_signed_auth.clone()); - session.request_history.push(facts.splice_signed_auth); session.psbt.candidate_fingerprint = Some(facts.psbt_fingerprint.clone()); if session.psbt.frozen_fingerprint.is_none() { session.psbt.frozen_fingerprint = Some(facts.psbt_fingerprint); @@ -802,9 +728,6 @@ impl State { } 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"); } @@ -819,7 +742,6 @@ impl State { &mut self, node_channel_id_hex: &str, psbt_fingerprint: String, - auth: NormalizedRpcAuth, updated_at_ms: u64, ) -> anyhow::Result<()> { let mut session = self @@ -831,9 +753,7 @@ impl State { session.phase ); } - session.auth.latest_splice_update_auth = Some(auth.clone()); session.psbt.candidate_fingerprint = Some(psbt_fingerprint); - session.request_history.push(auth); session.updated_at_ms = updated_at_ms; self.put_splice_session(&session) } @@ -996,9 +916,6 @@ impl State { ); } - if candidate.fundpsbt_auth.is_none() { - candidate.fundpsbt_auth = source.fundpsbt_auth; - } for wallet_input in source.wallet_inputs { let remains_in_candidate = candidate_input_outpoints.iter().any(|outpoint| { outpoint.txid == wallet_input.txid && outpoint.vout == wallet_input.vout @@ -1062,15 +979,6 @@ mod tests { use crate::psbt::CLN_PSBT_V2; - 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(), @@ -1157,7 +1065,6 @@ mod tests { 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), @@ -1216,7 +1123,7 @@ mod tests { } #[test] - fn record_local_splice_intent_persists_auth_and_fingerprint() { + fn record_local_splice_intent_persists_facts_and_fingerprint() { let mut state = State::new(); let fingerprint = "77".repeat(32); @@ -1230,7 +1137,6 @@ mod tests { 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), @@ -1247,10 +1153,6 @@ mod tests { 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_fingerprint.as_deref(), Some(fingerprint.as_str()) @@ -1264,10 +1166,9 @@ mod tests { Some("4444444444444444444444444444444444444444444444444444444444444444") ); - let psbt_value = serde_json::to_value(session.psbt).unwrap(); - assert!(psbt_value.get("splice_init_auth").is_none()); - assert!(psbt_value.get("request_hash").is_none()); - assert!(psbt_value.get("caller_pubkey_hex").is_none()); + let session_value = serde_json::to_value(session).unwrap(); + assert!(session_value.get("auth").is_none()); + assert!(session_value.get("request_history").is_none()); } #[test] @@ -1337,7 +1238,6 @@ mod tests { state .record_fundpsbt_response(FundPsbtResponseFacts { psbt_fingerprint: psbt.fingerprint.clone(), - fundpsbt_auth: auth("/cln.Node/FundPsbt", &"ab".repeat(32), 2), wallet_inputs, timestamp_ms: 2, }) @@ -1345,20 +1245,17 @@ mod tests { state .record_signpsbt_intent(SignPsbtIntentFacts { psbt_fingerprint: psbt.fingerprint.clone(), - signpsbt_auth: auth("/cln.Node/SignPsbt", &"cd".repeat(32), 3), signonly: vec![0], timestamp_ms: 3, }) .unwrap(); - let context = state - .get_psbt_context(&psbt.fingerprint) - .unwrap() - .unwrap(); - assert!(context.fundpsbt_auth.is_some()); - assert!(context.signpsbt_auth.is_some()); + let context = state.get_psbt_context(&psbt.fingerprint).unwrap().unwrap(); assert_eq!(context.signonly, vec![0]); assert_eq!(context.wallet_inputs[0].value_sat, 25_000); + let value = serde_json::to_value(context).unwrap(); + assert!(value.get("fundpsbt_auth").is_none()); + assert!(value.get("signpsbt_auth").is_none()); } #[test] @@ -1374,7 +1271,6 @@ mod tests { 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_fingerprint: "aa".repeat(32), @@ -1386,17 +1282,12 @@ mod tests { state .record_signpsbt_intent(SignPsbtIntentFacts { psbt_fingerprint: "aa".repeat(32), - signpsbt_auth: auth("/cln.Node/SignPsbt", &"77".repeat(32), 2), signonly: vec![0], timestamp_ms: 2, }) .unwrap(); - let context = state - .get_psbt_context(&"aa".repeat(32)) - .unwrap() - .unwrap(); - assert!(context.signpsbt_auth.is_some()); + let context = state.get_psbt_context(&"aa".repeat(32)).unwrap().unwrap(); assert_eq!(context.signonly, vec![0]); assert_eq!( context.linked_node_channel_id_hex.as_deref(), @@ -1414,7 +1305,6 @@ mod tests { state .record_fundpsbt_response(FundPsbtResponseFacts { psbt_fingerprint: source_fingerprint.clone(), - fundpsbt_auth: auth("/cln.Node/FundPsbt", &"77".repeat(32), 1), wallet_inputs: vec![WalletInput { txid: wallet_outpoint.txid.clone(), vout: wallet_outpoint.vout, @@ -1434,7 +1324,6 @@ mod tests { 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_fingerprint: candidate_fingerprint.clone(), @@ -1457,7 +1346,6 @@ mod tests { .get_psbt_context(&candidate_fingerprint) .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)); @@ -1466,7 +1354,6 @@ mod tests { node_channel_id_hex: "44".repeat(32), psbt_fingerprint: updated_fingerprint.clone(), psbt_input_outpoints: vec![wallet_outpoint], - splice_update_auth: auth("/cln.Node/SpliceUpdate", &"88".repeat(32), 3), commitments_secured: false, signatures_secured: None, timestamp_ms: 3, @@ -1477,7 +1364,6 @@ mod tests { .get_psbt_context(&updated_fingerprint) .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)); } @@ -1500,7 +1386,6 @@ mod tests { node_channel_id_hex: "44".repeat(32), psbt_fingerprint: psbt.fingerprint.clone(), psbt_input_outpoints: psbt.input_outpoints.clone(), - splice_update_auth: auth("/cln.Node/SpliceUpdate", &"ef".repeat(32), 2), commitments_secured: true, signatures_secured: Some(false), timestamp_ms: 2, @@ -1526,7 +1411,6 @@ mod tests { node_channel_id_hex: "44".repeat(32), psbt_fingerprint: psbt.fingerprint, psbt_input_outpoints: psbt.input_outpoints, - splice_signed_auth: auth("/cln.Node/SpliceSigned", &"12".repeat(32), 3), candidate: Some(candidate), timestamp_ms: 3, }) @@ -1545,7 +1429,7 @@ mod tests { } #[test] - fn session_roundtrips_through_grouped_schema() { + fn session_schema_omits_unverifiable_rpc_auth() { let mut session = local_session(); session.psbt.candidate_fingerprint = Some("candidate".to_string()); session.psbt.frozen_fingerprint = Some("frozen".to_string()); @@ -1554,19 +1438,16 @@ mod tests { assert_eq!(value["schema"], json!("SpliceSessionV1")); assert!(value.get("old").is_some()); - assert!(value.get("auth").is_some()); + assert!(value.get("auth").is_none()); assert!(value.get("intent").is_some()); assert!(value.get("psbt").is_some()); assert!(value.get("cand").is_some()); assert!(value.get("delta").is_some()); assert!(value.get("linked_wallet_psbt_fingerprints").is_some()); + assert!(value.get("request_history").is_none()); assert!(value.get("old_funding_outpoint").is_none()); assert!(value.get("splice_init_auth").is_none()); assert!(value.get("candidate_funding_outpoint").is_none()); - assert_eq!( - value["auth"]["splice_init_auth"]["uri"], - json!("/cln.Node/SpliceInit") - ); assert_eq!( value["psbt"], json!({ @@ -1574,8 +1455,6 @@ mod tests { "frozen_fingerprint": "frozen", }) ); - assert!(value["auth"]["splice_init_auth"].get("request").is_none()); - assert!(value["auth"]["splice_init_auth"].get("payload").is_none()); assert_eq!( serde_json::from_value::(value).unwrap(), session @@ -1588,23 +1467,10 @@ mod tests { let fingerprint = "aa".repeat(32); state.create_local_splice_session(local_session()).unwrap(); state - .update_splice_candidate( - &"44".repeat(32), - fingerprint.clone(), - auth("/cln.Node/SpliceUpdate", &"dd".repeat(32), 2), - 2, - ) + .update_splice_candidate(&"44".repeat(32), fingerprint.clone(), 2) .unwrap(); state - .put_splice_wallet_psbt_context( - &fingerprint, - SpliceWalletPsbtContextV1::new( - Some(auth("/cln.Node/FundPsbt", &"aa".repeat(32), 2)), - None, - vec![], - 2, - ), - ) + .put_splice_wallet_psbt_context(&fingerprint, SpliceWalletPsbtContextV1::new(vec![], 2)) .unwrap(); state .link_splice_wallet_psbt(&fingerprint, &"44".repeat(32), 2) @@ -1631,10 +1497,7 @@ mod tests { .get_splice_by_outpoint(&"ee".repeat(32), 2) .unwrap() .is_some()); - assert!(restored - .get_psbt_context(&fingerprint) - .unwrap() - .is_some()); + assert!(restored.get_psbt_context(&fingerprint).unwrap().is_some()); state.tombstone_splice_session(&"44".repeat(32)).unwrap(); state.merge(&stale_state).unwrap(); @@ -1647,9 +1510,6 @@ mod tests { .get_splice_by_outpoint(&"ee".repeat(32), 2) .unwrap() .is_none()); - assert!(state - .get_psbt_context(&fingerprint) - .unwrap() - .is_none()); + assert!(state.get_psbt_context(&fingerprint).unwrap().is_none()); } } diff --git a/libs/gl-client/src/signer/splice_policy.rs b/libs/gl-client/src/signer/splice_policy.rs index da46b5b17..41a1fe403 100644 --- a/libs/gl-client/src/signer/splice_policy.rs +++ b/libs/gl-client/src/signer/splice_policy.rs @@ -34,7 +34,6 @@ pub(crate) enum SplicePolicyViolation { MissingSpliceIntent, InvalidPhase, MissingSignPsbtIntent, - MissingSignPsbtAuth, PsbtFingerprintMismatch, UnknownWalletInput, SignOnlyViolation, @@ -171,8 +170,7 @@ fn session_violation( return Some(SplicePolicyViolation::InvalidPhase); } if session.origin == SpliceOrigin::LocalInitiator - && (session.auth.splice_init_auth.is_none() - || session.intent.authorized_relative_amount_sat.is_none()) + && session.intent.authorized_relative_amount_sat.is_none() { return Some(SplicePolicyViolation::MissingSpliceIntent); } @@ -500,9 +498,6 @@ fn classify_sign_withdrawal( 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, &[ @@ -585,8 +580,8 @@ mod tests { }; use crate::persist::{ CandidateFundingFacts, FeePolicy, FundPsbtResponseFacts, FundingOutpoint, - LocalSpliceIntent, NormalizedRpcAuth, OldSpliceState, SignPsbtIntentFacts, SpliceOrigin, - SpliceSessionV1, SpliceUpdateResponseFacts, State, WalletInput, + LocalSpliceIntent, OldSpliceState, SignPsbtIntentFacts, SpliceOrigin, SpliceSessionV1, + SpliceUpdateResponseFacts, State, WalletInput, }; use crate::signer::model::{ cln::{SignpsbtRequest, SpliceSignedRequest, SpliceUpdateRequest}, @@ -602,15 +597,6 @@ mod tests { 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, - ) - } - fn input_outpoints(transaction: &Transaction) -> Vec { transaction .input @@ -703,7 +689,6 @@ mod tests { 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_fingerprint: psbt_fingerprint.clone(), @@ -719,7 +704,6 @@ mod tests { node_channel_id_hex.clone(), old.clone(), None, - None, FeePolicy::default(), 1, ); @@ -825,7 +809,6 @@ mod tests { 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_fingerprint: psbt_fingerprint.clone(), @@ -838,7 +821,6 @@ mod tests { node_channel_id_hex: session.node_channel_id_hex, psbt_fingerprint, psbt_input_outpoints, - splice_update_auth: auth("/cln.Node/SpliceUpdate", "66", 2), commitments_secured: false, signatures_secured: Some(false), timestamp_ms: 2, @@ -913,7 +895,6 @@ mod tests { state .record_fundpsbt_response(FundPsbtResponseFacts { psbt_fingerprint: psbt_fingerprint.clone(), - fundpsbt_auth: auth("/cln.Node/FundPsbt", "55", 1), wallet_inputs: vec![WalletInput { txid: wallet_txid.to_string(), vout: 1, @@ -930,7 +911,6 @@ mod tests { 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_fingerprint: psbt_fingerprint.clone(), @@ -946,7 +926,6 @@ mod tests { node_channel_id_hex.clone(), old, None, - None, FeePolicy::default(), 2, ); @@ -964,7 +943,6 @@ mod tests { node_channel_id_hex, psbt_fingerprint: psbt_fingerprint.clone(), psbt_input_outpoints, - splice_update_auth: auth("/cln.Node/SpliceUpdate", "77", 3), commitments_secured, signatures_secured: Some(false), timestamp_ms: 3, @@ -973,7 +951,6 @@ mod tests { state .record_signpsbt_intent(SignPsbtIntentFacts { psbt_fingerprint, - signpsbt_auth: auth("/cln.Node/SignPsbt", "88", 4), signonly: vec![1], timestamp_ms: 4, }) @@ -1066,28 +1043,6 @@ mod tests { ); } - #[test] - fn splice_signwithdrawal_with_fundpsbt_only_rejects() { - let (message, pending, mut state) = sign_withdrawal_fixture(); - let Message::SignWithdrawal(request) = &message else { - unreachable!(); - }; - let fingerprint = psbt_fingerprint(&request.psbt.0.psbt.inner); - let mut context = state - .get_psbt_context(&fingerprint) - .unwrap() - .unwrap(); - context.signpsbt_auth = None; - state - .put_splice_wallet_psbt_context(&fingerprint, 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(); diff --git a/libs/gl-plugin/Cargo.toml b/libs/gl-plugin/Cargo.toml index 07ad42916..f1173ddcd 100644 --- a/libs/gl-plugin/Cargo.toml +++ b/libs/gl-plugin/Cargo.toml @@ -34,7 +34,6 @@ 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/wrapper.rs b/libs/gl-plugin/src/node/wrapper.rs index a3de65c41..699158bc7 100644 --- a/libs/gl-plugin/src/node/wrapper.rs +++ b/libs/gl-plugin/src/node/wrapper.rs @@ -1,21 +1,18 @@ use std::collections::HashMap; use std::str::FromStr; +use std::time::{SystemTime, UNIX_EPOCH}; 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, parse_base64_psbt, wallet_inputs_from_psbt, - FeePolicy, FundPsbtResponseFacts, FundingOutpoint, LocalSpliceIntent, NormalizedRpcAuth, - OldSpliceState, SignPsbtIntentFacts, SpliceSignedResponseFacts, SpliceUpdateResponseFacts, - WalletInputReservation, + candidate_funding_facts_from_psbt, parse_base64_psbt, wallet_inputs_from_psbt, FeePolicy, + FundPsbtResponseFacts, FundingOutpoint, LocalSpliceIntent, OldSpliceState, SignPsbtIntentFacts, + SpliceSignedResponseFacts, SpliceUpdateResponseFacts, WalletInputReservation, }; use log::debug; -use prost::Message; use tokio_stream::wrappers::ReceiverStream; use tonic::{Request, Response, Status}; @@ -386,12 +383,11 @@ impl Node for WrappedNodeServer { &self, r: Request, ) -> Result, Status> { - let auth = maybe_normalized_auth("/cln.Node/FundPsbt", &r)?; + let captured_at_ms = rpc_context_timestamp_ms(&r)?; let response = self.inner.fund_psbt(r).await?; - if let Some(auth) = auth { + if let Some(timestamp_ms) = captured_at_ms { let body = response.get_ref(); let psbt = parse_base64_psbt(&body.psbt).map_err(internal_status)?; - let timestamp_ms = auth.timestamp_ms; let reservations = body .reservations .iter() @@ -409,7 +405,6 @@ impl Node for WrappedNodeServer { self.update_splice_state(|state| { state.record_fundpsbt_response(FundPsbtResponseFacts { psbt_fingerprint: psbt.fingerprint, - fundpsbt_auth: auth, wallet_inputs, timestamp_ms, }) @@ -430,17 +425,15 @@ impl Node for WrappedNodeServer { &self, r: Request, ) -> Result, Status> { - let auth = maybe_normalized_auth("/cln.Node/SignPsbt", &r)?; - if let Some(auth) = auth { + let captured_at_ms = rpc_context_timestamp_ms(&r)?; + if let Some(timestamp_ms) = captured_at_ms { let body = r.get_ref(); let psbt = parse_base64_psbt(&body.psbt).map_err(internal_status)?; - let timestamp_ms = auth.timestamp_ms; let signonly = body.signonly.clone(); self.update_splice_state(|state| { state.record_signpsbt_intent(SignPsbtIntentFacts { psbt_fingerprint: psbt.fingerprint, - signpsbt_auth: auth, signonly, timestamp_ms, }) @@ -866,7 +859,7 @@ impl Node for WrappedNodeServer { &self, request: tonic::Request, ) -> Result, tonic::Status> { - let auth = normalized_auth("/cln.Node/SpliceInit", &request)?; + let timestamp_ms = require_rpc_context_timestamp_ms(&request)?; let request_body = request.get_ref().clone(); let node_id_hex = self.local_node_id_hex().await?; let channel_id_hex = hex::encode(&request_body.channel_id); @@ -886,14 +879,12 @@ impl Node for WrappedNodeServer { let body = response.get_ref(); let psbt = parse_base64_psbt(&body.psbt).map_err(internal_status)?; let candidate_psbt_fingerprint = psbt.fingerprint.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, @@ -922,7 +913,7 @@ impl Node for WrappedNodeServer { &self, request: tonic::Request, ) -> Result, tonic::Status> { - let auth = normalized_auth("/cln.Node/SpliceSigned", &request)?; + let timestamp_ms = require_rpc_context_timestamp_ms(&request)?; let request_body = request.get_ref().clone(); let node_channel_id_hex = self.node_channel_id_hex(&request_body.channel_id).await?; @@ -932,7 +923,6 @@ impl Node for WrappedNodeServer { 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| { @@ -957,7 +947,6 @@ impl Node for WrappedNodeServer { node_channel_id_hex, psbt_fingerprint: psbt.fingerprint, psbt_input_outpoints: psbt.input_outpoints, - splice_signed_auth: auth, candidate, timestamp_ms, }) @@ -970,7 +959,7 @@ impl Node for WrappedNodeServer { &self, request: tonic::Request, ) -> Result, tonic::Status> { - let auth = normalized_auth("/cln.Node/SpliceUpdate", &request)?; + let timestamp_ms = require_rpc_context_timestamp_ms(&request)?; let request_body = request.get_ref().clone(); let node_channel_id_hex = self.node_channel_id_hex(&request_body.channel_id).await?; @@ -979,13 +968,11 @@ impl Node for WrappedNodeServer { let psbt = parse_base64_psbt(&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_fingerprint: psbt.fingerprint, psbt_input_outpoints: psbt.input_outpoints, - splice_update_auth: auth, commitments_secured, signatures_secured, timestamp_ms, @@ -1288,70 +1275,34 @@ 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, - )) -} +// Metadata presence controls fact capture only; the signer verifies the live request context. +const RPC_CONTEXT_KEYS: [&str; 4] = ["glauthpubkey", "glauthsig", "glts", "glrune"]; -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 now_ms() -> Result { + let millis = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map_err(internal_status)? + .as_millis(); + u64::try_from(millis).map_err(internal_status) } -fn 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); +fn rpc_context_timestamp_ms(request: &Request) -> Result, Status> { + let present = RPC_CONTEXT_KEYS + .iter() + .filter(|key| request.metadata().contains_key(**key)) + .count(); + match present { + 0 => Ok(None), + 4 => now_ms().map(Some), + _ => Err(Status::unauthenticated( + "incomplete Greenlight RPC context metadata", + )), } +} - normalized_auth(uri, request).map(Some) +fn require_rpc_context_timestamp_ms(request: &Request) -> Result { + rpc_context_timestamp_ms(request)? + .ok_or_else(|| Status::unauthenticated("missing Greenlight RPC context metadata")) } fn amount_sat(amount: Option<&pb::Amount>, field: &'static str) -> Result {