Skip to content

Support client_trusts_lsp on LSPS2 #3838

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Draft
wants to merge 7 commits into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions lightning-liquidity/src/lsps2/event.rs
Original file line number Diff line number Diff line change
Expand Up @@ -160,4 +160,16 @@ pub enum LSPS2ServiceEvent {
/// The intercept short channel id to use in the route hint.
intercept_scid: u64,
},
/// You should broadcast the funding transaction for the channel you opened.
///
/// On a client_trusts_lsp context, the client has claimed the payment, so now
/// you must broadcast the funding transaction.
BroadcastFundingTransaction {
/// The node id of the counterparty.
counterparty_node_id: PublicKey,
/// The user channel id that was used to open the channel.
user_channel_id: u128,
/// The funding transaction to broadcast.
funding_tx: bitcoin::Transaction,
},
}
240 changes: 239 additions & 1 deletion lightning-liquidity/src/lsps2/service.rs
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ use lightning::util::logger::Level;
use lightning_types::payment::PaymentHash;

use bitcoin::secp256k1::PublicKey;
use bitcoin::Transaction;

use crate::lsps2::msgs::{
LSPS2BuyRequest, LSPS2BuyResponse, LSPS2GetInfoRequest, LSPS2GetInfoResponse, LSPS2Message,
Expand Down Expand Up @@ -107,6 +108,89 @@ struct ForwardPaymentAction(ChannelId, FeePayment);
#[derive(Debug, PartialEq)]
struct ForwardHTLCsAction(ChannelId, Vec<InterceptedHTLC>);

#[derive(Debug, Clone)]
enum TrustModel {
ClientTrustsLsp {
funding_tx_broadcast_safe: bool,
payment_claimed: bool,
funding_tx: Option<Transaction>,
},
LspTrustsClient,
}

impl TrustModel {
fn should_manually_broadcast(&self) -> bool {
match self {
TrustModel::ClientTrustsLsp {
funding_tx_broadcast_safe,
payment_claimed,
funding_tx,
} => *funding_tx_broadcast_safe && *payment_claimed && funding_tx.is_some(),
// in lsp-trusts-client, the broadcast is automatic, so we never need to manually broadcast.
TrustModel::LspTrustsClient => false,
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Shouldn't this always be true?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

actually the method name is confusing. this should be false because in lsp-trusts-client, the broadcast is automatic, so we should return false to avoid doing a manual broadcast

Copy link
Contributor Author

@martinsaposnic martinsaposnic Jul 23, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

fixup commit changes this but will revert in a future commit, that will also include an e2e test

}
}

fn new(client_trusts_lsp: bool) -> Self {
if client_trusts_lsp {
TrustModel::ClientTrustsLsp {
funding_tx_broadcast_safe: false,
payment_claimed: false,
funding_tx: None,
}
} else {
TrustModel::LspTrustsClient
}
}

fn set_funding_tx(&mut self, funding_tx: Transaction) {
match self {
TrustModel::ClientTrustsLsp { funding_tx: tx, .. } => {
*tx = Some(funding_tx);
},
TrustModel::LspTrustsClient => {
// No-op
},
}
}

fn set_funding_tx_broadcast_safe(&mut self, funding_tx_broadcast_safe: bool) {
match self {
TrustModel::ClientTrustsLsp { funding_tx_broadcast_safe: safe, .. } => {
*safe = funding_tx_broadcast_safe;
},
TrustModel::LspTrustsClient => {
// No-op
},
}
}

fn set_payment_claimed(&mut self, payment_claimed: bool) {
match self {
TrustModel::ClientTrustsLsp { payment_claimed: claimed, .. } => {
*claimed = payment_claimed;
},
TrustModel::LspTrustsClient => {
// No-op
},
}
}

fn get_funding_tx(&self) -> Option<Transaction> {
match self {
TrustModel::ClientTrustsLsp { funding_tx, .. } => funding_tx.clone(),
TrustModel::LspTrustsClient => None,
}
}

fn is_client_trusts_lsp(&self) -> bool {
match self {
TrustModel::ClientTrustsLsp { .. } => true,
TrustModel::LspTrustsClient => false,
}
}
}

/// The different states a requested JIT channel can be in.
#[derive(Debug)]
enum OutboundJITChannelState {
Expand Down Expand Up @@ -383,18 +467,20 @@ struct OutboundJITChannel {
user_channel_id: u128,
opening_fee_params: LSPS2OpeningFeeParams,
payment_size_msat: Option<u64>,
trust_model: TrustModel,
}

impl OutboundJITChannel {
fn new(
payment_size_msat: Option<u64>, opening_fee_params: LSPS2OpeningFeeParams,
user_channel_id: u128,
user_channel_id: u128, client_trusts_lsp: bool,
) -> Self {
Self {
user_channel_id,
state: OutboundJITChannelState::new(),
opening_fee_params,
payment_size_msat,
trust_model: TrustModel::new(client_trusts_lsp),
}
}

Expand All @@ -420,6 +506,9 @@ impl OutboundJITChannel {

fn payment_forwarded(&mut self) -> Result<Option<ForwardHTLCsAction>, LightningError> {
let action = self.state.payment_forwarded()?;
if action.is_some() {
self.trust_model.set_payment_claimed(true);
}
Ok(action)
}

Expand All @@ -433,6 +522,26 @@ impl OutboundJITChannel {
let is_expired = is_expired_opening_fee_params(&self.opening_fee_params);
self.is_pending_initial_payment() && is_expired
}

fn set_funding_tx(&mut self, funding_tx: Transaction) {
self.trust_model.set_funding_tx(funding_tx);
}

fn set_funding_tx_broadcast_safe(&mut self, funding_tx_broadcast_safe: bool) {
self.trust_model.set_funding_tx_broadcast_safe(funding_tx_broadcast_safe);
}

fn should_broadcast_funding_transaction(&self) -> bool {
self.trust_model.should_manually_broadcast()
}

fn get_funding_tx(&self) -> Option<Transaction> {
self.trust_model.get_funding_tx()
}

fn client_trusts_lsp(&self) -> bool {
self.trust_model.is_client_trusts_lsp()
}
}

struct PeerState {
Expand Down Expand Up @@ -698,6 +807,7 @@ where
buy_request.payment_size_msat,
buy_request.opening_fee_params,
user_channel_id,
client_trusts_lsp,
);

peer_state_lock
Expand Down Expand Up @@ -932,6 +1042,11 @@ where
})
},
}

self.emit_broadcast_funding_transaction_event_if_applies(
jit_channel,
counterparty_node_id,
);
}
} else {
return Err(APIError::APIMisuseError {
Expand Down Expand Up @@ -1418,6 +1533,129 @@ where
peer_state_lock.is_prunable() == false
});
}

/// Checks if the JIT channel with the given `user_channel_id` needs manual broadcast.
/// Will be true if client_trusts_lsp is set to true
pub fn channel_needs_manual_broadcast(
&self, user_channel_id: u128, counterparty_node_id: &PublicKey,
) -> Result<bool, APIError> {
let outer_state_lock = self.per_peer_state.read().unwrap();
let inner_state_lock =
outer_state_lock.get(counterparty_node_id).ok_or_else(|| APIError::APIMisuseError {
err: format!("No counterparty state for: {}", counterparty_node_id),
})?;
let peer_state = inner_state_lock.lock().unwrap();

let intercept_scid = peer_state
.intercept_scid_by_user_channel_id
.get(&user_channel_id)
.copied()
.ok_or_else(|| APIError::APIMisuseError {
err: format!("Could not find a channel with user_channel_id {}", user_channel_id),
})?;

let jit_channel = peer_state
.outbound_channels_by_intercept_scid
.get(&intercept_scid)
.ok_or_else(|| APIError::APIMisuseError {
err: format!(
"Failed to map intercept_scid {} for user_channel_id {} to a channel.",
intercept_scid, user_channel_id,
),
})?;

Ok(jit_channel.client_trusts_lsp())
}

/// Called to store the funding transaction for a JIT channel.
/// This should be called when the funding transaction is created but before it's broadcast.
pub fn store_funding_transaction(
&self, user_channel_id: u128, counterparty_node_id: &PublicKey, funding_tx: Transaction,
) -> Result<(), APIError> {
let outer_state_lock = self.per_peer_state.read().unwrap();
let inner_state_lock =
outer_state_lock.get(counterparty_node_id).ok_or_else(|| APIError::APIMisuseError {
err: format!("No counterparty state for: {}", counterparty_node_id),
})?;
let mut peer_state = inner_state_lock.lock().unwrap();

let intercept_scid = peer_state
.intercept_scid_by_user_channel_id
.get(&user_channel_id)
.copied()
.ok_or_else(|| APIError::APIMisuseError {
err: format!("Could not find a channel with user_channel_id {}", user_channel_id),
})?;

let jit_channel = peer_state
.outbound_channels_by_intercept_scid
.get_mut(&intercept_scid)
.ok_or_else(|| APIError::APIMisuseError {
err: format!(
"Failed to map intercept_scid {} for user_channel_id {} to a channel.",
intercept_scid, user_channel_id,
),
})?;

jit_channel.set_funding_tx(funding_tx);

self.emit_broadcast_funding_transaction_event_if_applies(jit_channel, counterparty_node_id);
Ok(())
}

/// Called when the funding transaction is safe to broadcast.
/// This marks the funding_tx_broadcast_safe flag as true for the given user_channel_id.
pub fn funding_tx_broadcast_safe(
&self, user_channel_id: u128, counterparty_node_id: &PublicKey,
) -> Result<(), APIError> {
let outer_state_lock = self.per_peer_state.read().unwrap();
let inner_state_lock =
outer_state_lock.get(counterparty_node_id).ok_or_else(|| APIError::APIMisuseError {
err: format!("No counterparty state for: {}", counterparty_node_id),
})?;
let mut peer_state = inner_state_lock.lock().unwrap();

let intercept_scid = peer_state
.intercept_scid_by_user_channel_id
.get(&user_channel_id)
.copied()
.ok_or_else(|| APIError::APIMisuseError {
err: format!("Could not find a channel with user_channel_id {}", user_channel_id),
})?;

let jit_channel = peer_state
.outbound_channels_by_intercept_scid
.get_mut(&intercept_scid)
.ok_or_else(|| APIError::APIMisuseError {
err: format!(
"Failed to map intercept_scid {} for user_channel_id {} to a channel.",
intercept_scid, user_channel_id,
),
})?;

jit_channel.set_funding_tx_broadcast_safe(true);

self.emit_broadcast_funding_transaction_event_if_applies(jit_channel, counterparty_node_id);
Ok(())
}

fn emit_broadcast_funding_transaction_event_if_applies(
&self, jit_channel: &OutboundJITChannel, counterparty_node_id: &PublicKey,
) {
if jit_channel.should_broadcast_funding_transaction() {
let funding_tx = jit_channel.get_funding_tx();

if let Some(funding_tx) = funding_tx {
let event_queue_notifier = self.pending_events.notifier();
let event = LSPS2ServiceEvent::BroadcastFundingTransaction {
counterparty_node_id: *counterparty_node_id,
user_channel_id: jit_channel.user_channel_id,
funding_tx,
};
event_queue_notifier.enqueue(event);
}
}
}
}

impl<CM: Deref> LSPSProtocolMessageHandler for LSPS2ServiceHandler<CM>
Expand Down
40 changes: 36 additions & 4 deletions lightning-liquidity/tests/common/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -19,10 +19,20 @@ pub(crate) struct LSPSNodes<'a, 'b, 'c> {
pub client_node: LiquidityNode<'a, 'b, 'c>,
}

pub(crate) fn create_service_and_client_nodes<'a, 'b, 'c>(
nodes: Vec<Node<'a, 'b, 'c>>, service_config: LiquidityServiceConfig,
// this is ONLY used on LSPS2 so it says it's not used but it is
#[allow(dead_code)]
pub(crate) struct LSPSNodesWithPayer<'a, 'b, 'c> {
pub service_node: LiquidityNode<'a, 'b, 'c>,
pub client_node: LiquidityNode<'a, 'b, 'c>,
pub payer_node: Node<'a, 'b, 'c>,
}

// Reusable helper: consumes a Vec<Node>, builds service + client LiquidityNodes, returns optional leftover node.
fn build_service_and_client<'a, 'b, 'c>(
mut nodes: Vec<Node<'a, 'b, 'c>>, service_config: LiquidityServiceConfig,
client_config: LiquidityClientConfig, time_provider: Arc<dyn TimeProvider + Send + Sync>,
) -> LSPSNodes<'a, 'b, 'c> {
) -> (LiquidityNode<'a, 'b, 'c>, LiquidityNode<'a, 'b, 'c>, Option<Node<'a, 'b, 'c>>) {
assert!(nodes.len() >= 2, "Need at least two nodes (service, client)");
let chain_params = ChainParameters {
network: Network::Testnet,
best_block: BestBlock::from_network(Network::Testnet),
Expand All @@ -49,13 +59,35 @@ pub(crate) fn create_service_and_client_nodes<'a, 'b, 'c>(
time_provider,
);

let mut iter = nodes.into_iter();
let mut iter = nodes.drain(..);
let service_node = LiquidityNode::new(iter.next().unwrap(), service_lm);
let client_node = LiquidityNode::new(iter.next().unwrap(), client_lm);
let leftover = iter.next(); // payer if present
(service_node, client_node, leftover)
}

pub(crate) fn create_service_and_client_nodes<'a, 'b, 'c>(
nodes: Vec<Node<'a, 'b, 'c>>, service_config: LiquidityServiceConfig,
client_config: LiquidityClientConfig, time_provider: Arc<dyn TimeProvider + Send + Sync>,
) -> LSPSNodes<'a, 'b, 'c> {
let (service_node, client_node, _extra) =
build_service_and_client(nodes, service_config, client_config, time_provider);
LSPSNodes { service_node, client_node }
}

// this is ONLY used on LSPS2 so it says it's not used but it is
#[allow(dead_code)]
pub(crate) fn create_service_client_and_payer_nodes<'a, 'b, 'c>(
nodes: Vec<Node<'a, 'b, 'c>>, service_config: LiquidityServiceConfig,
client_config: LiquidityClientConfig, time_provider: Arc<dyn TimeProvider + Send + Sync>,
) -> LSPSNodesWithPayer<'a, 'b, 'c> {
assert!(nodes.len() >= 3, "Need three nodes (service, client, payer)");
let (service_node, client_node, payer_opt) =
build_service_and_client(nodes, service_config, client_config, time_provider);
let payer_node = payer_opt.expect("payer node missing");
LSPSNodesWithPayer { service_node, client_node, payer_node }
}

pub(crate) struct LiquidityNode<'a, 'b, 'c> {
pub inner: Node<'a, 'b, 'c>,
pub liquidity_manager: LiquidityManager<
Expand Down
Loading
Loading