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 4 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,
},
}
264 changes: 251 additions & 13 deletions lightning-liquidity/src/lsps2/service.rs
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,14 @@ use crate::lsps0::ser::{
LSPS0_CLIENT_REJECTED_ERROR_CODE,
};
use crate::lsps2::event::LSPS2ServiceEvent;
use crate::lsps2::msgs::{
LSPS2BuyRequest, LSPS2BuyResponse, LSPS2GetInfoRequest, LSPS2GetInfoResponse, LSPS2Message,
LSPS2OpeningFeeParams, LSPS2RawOpeningFeeParams, LSPS2Request, LSPS2Response,
LSPS2_BUY_REQUEST_INVALID_OPENING_FEE_PARAMS_ERROR_CODE,
LSPS2_BUY_REQUEST_PAYMENT_SIZE_TOO_LARGE_ERROR_CODE,
LSPS2_BUY_REQUEST_PAYMENT_SIZE_TOO_SMALL_ERROR_CODE,
LSPS2_GET_INFO_REQUEST_UNRECOGNIZED_OR_STALE_TOKEN_ERROR_CODE,
};
use crate::lsps2::payment_queue::{InterceptedHTLC, PaymentQueue};
use crate::lsps2::utils::{
compute_opening_fee, is_expired_opening_fee_params, is_valid_opening_fee_params,
Expand All @@ -42,15 +50,7 @@ use lightning::util::logger::Level;
use lightning_types::payment::PaymentHash;

use bitcoin::secp256k1::PublicKey;

use crate::lsps2::msgs::{
LSPS2BuyRequest, LSPS2BuyResponse, LSPS2GetInfoRequest, LSPS2GetInfoResponse, LSPS2Message,
LSPS2OpeningFeeParams, LSPS2RawOpeningFeeParams, LSPS2Request, LSPS2Response,
LSPS2_BUY_REQUEST_INVALID_OPENING_FEE_PARAMS_ERROR_CODE,
LSPS2_BUY_REQUEST_PAYMENT_SIZE_TOO_LARGE_ERROR_CODE,
LSPS2_BUY_REQUEST_PAYMENT_SIZE_TOO_SMALL_ERROR_CODE,
LSPS2_GET_INFO_REQUEST_UNRECOGNIZED_OR_STALE_TOKEN_ERROR_CODE,
};
use bitcoin::Transaction;

const MAX_PENDING_REQUESTS_PER_PEER: usize = 10;
const MAX_TOTAL_PENDING_REQUESTS: usize = 1000;
Expand Down Expand Up @@ -107,6 +107,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 @@ -284,6 +367,7 @@ impl OutboundJITChannelState {
channel_id,
FeePayment { opening_fee_msat: *opening_fee_msat, htlcs },
);

*self = OutboundJITChannelState::PendingPaymentForward {
payment_queue: core::mem::take(payment_queue),
opening_fee_msat: *opening_fee_msat,
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 @@ -727,6 +836,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 @@ -973,12 +1083,17 @@ where
Err(e) => {
return Err(APIError::APIMisuseError {
err: format!(
"Forwarded payment was not applicable for JIT channel: {}",
e.err
),
"Forwarded payment was not applicable for JIT channel: {}",
e.err
),
})
},
}

self.emit_broadcast_funding_transaction_event_if_applies(
jit_channel,
counterparty_node_id,
);
}
} else {
return Err(APIError::APIMisuseError {
Expand Down Expand Up @@ -1467,6 +1582,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
3 changes: 2 additions & 1 deletion lightning-liquidity/tests/common/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ use std::sync::Arc;
pub(crate) struct LSPSNodes<'a, 'b, 'c> {
pub service_node: LiquidityNode<'a, 'b, 'c>,
pub client_node: LiquidityNode<'a, 'b, 'c>,
pub payer_node_optional: Option<Node<'a, 'b, 'c>>,
}

pub(crate) fn create_service_and_client_nodes<'a, 'b, 'c>(
Expand Down Expand Up @@ -47,7 +48,7 @@ pub(crate) fn create_service_and_client_nodes<'a, 'b, 'c>(
let service_node = LiquidityNode::new(iter.next().unwrap(), service_lm);
let client_node = LiquidityNode::new(iter.next().unwrap(), client_lm);

LSPSNodes { service_node, client_node }
LSPSNodes { service_node, client_node, payer_node_optional: iter.next() }
}

pub(crate) struct LiquidityNode<'a, 'b, 'c> {
Expand Down
2 changes: 1 addition & 1 deletion lightning-liquidity/tests/lsps0_integration_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ fn list_protocols_integration_test() {
let service_node_id = nodes[0].node.get_our_node_id();
let client_node_id = nodes[1].node.get_our_node_id();

let LSPSNodes { service_node, client_node } =
let LSPSNodes { service_node, client_node, .. } =
create_service_and_client_nodes(nodes, service_config, client_config);

let client_handler = client_node.liquidity_manager.lsps0_client_handler();
Expand Down
Loading
Loading