Skip to content

LSPS2: Add TimeProvider trait for expiry checks in no-std and std builds #3746

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

Open
wants to merge 4 commits into
base: main
Choose a base branch
from
Open
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
3 changes: 2 additions & 1 deletion lightning-liquidity/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,9 @@ categories = ["cryptography::cryptocurrencies"]
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html

[features]
default = ["std"]
default = ["std", "time"]
Copy link
Contributor

Choose a reason for hiding this comment

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

Disabling SystemTIme::now without disabling all of std: couldn't this be realized by just passing in another time provider than the std one, without requiring this flag?

std = ["lightning/std"]
time = []
backtrace = ["dep:backtrace"]

[dependencies]
Expand Down
33 changes: 25 additions & 8 deletions lightning-liquidity/src/lsps0/ser.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ use alloc::string::String;

use core::fmt::{self, Display};
use core::str::FromStr;
use core::time::Duration;

use crate::lsps0::msgs::{
LSPS0ListProtocolsRequest, LSPS0Message, LSPS0Request, LSPS0Response,
Expand All @@ -29,8 +30,7 @@ use lightning::util::ser::{LengthLimitedRead, LengthReadable, WithoutLength};

use bitcoin::secp256k1::PublicKey;

#[cfg(feature = "std")]
use std::time::{SystemTime, UNIX_EPOCH};
use crate::sync::Arc;

use serde::de::{self, MapAccess, Visitor};
use serde::ser::SerializeStruct;
Expand Down Expand Up @@ -204,12 +204,8 @@ impl LSPSDateTime {
}

/// Returns if the given time is in the past.
#[cfg(feature = "std")]
pub fn is_past(&self) -> bool {
let now_seconds_since_epoch = SystemTime::now()
.duration_since(UNIX_EPOCH)
.expect("system clock to be ahead of the unix epoch")
.as_secs();
pub fn is_past(&self, time_provider: Arc<dyn TimeProvider>) -> bool {
let now_seconds_since_epoch = time_provider.duration_since_epoch().as_secs();
let datetime_seconds_since_epoch =
self.0.timestamp().try_into().expect("expiration to be ahead of unix epoch");
now_seconds_since_epoch > datetime_seconds_since_epoch
Expand Down Expand Up @@ -784,3 +780,24 @@ pub(crate) mod u32_fee_rate {
Ok(FeeRate::from_sat_per_kwu(fee_rate_sat_kwu as u64))
}
}

/// Trait defining a time provider
///
/// This trait is used to provide the current time service operations and to convert between timestamps and durations.
pub trait TimeProvider {
Copy link
Contributor

Choose a reason for hiding this comment

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

I'm a bit confused why this is re-added here, as we're already adding it (in a different place) in #3662. Maybe it would be good to put this PR in draft until #3662 has been merged, or rebase it on top of #3662, as changes there will need to be consequentially accounted for here?

Copy link
Author

Choose a reason for hiding this comment

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

This PR is extracting some of the code from LSPS5 PR. My idea was to get this PR merged and then rebase LSPS5 on top of it, that way we would lose some commits from the LSPS5 PR and make it a bit smaller (same idea with the solution to this issue #3459 (PR coming soon), where I'm extracting some of the code written in the LSPS5 PR and putting it in a new PR, that way making the LSPS5 PR smaller)

/// Get the current time as a duration since the Unix epoch.
fn duration_since_epoch(&self) -> Duration;
Copy link
Contributor

Choose a reason for hiding this comment

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

I know this name is sort of standard in LDK, but to me something like unix_time would have been better. Duration is already the type, and epoch doesn't specify which epoch.

Copy link
Contributor

Choose a reason for hiding this comment

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

I disagree, exactly because you'd expect unix_time to be a SystemTime or u64, but not a Duration. Plus, homogeneity is an important API feature, so keeping this in-line with the rest of internal APIs is a big plus, IMO.

Copy link
Contributor

Choose a reason for hiding this comment

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

No need to change it now. As you say, keeping it consistent is a good API feature. For me as a developer though, this isn't the most descriptive name.

}

/// Default time provider using the system clock.
#[derive(Clone, Debug)]
#[cfg(feature = "time")]
Copy link
Contributor

Choose a reason for hiding this comment

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

I re-read the PR description, but was still thinking why isn't this just std gated?

Copy link
Author

Choose a reason for hiding this comment

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

Sorry for not mentioning this in the PR description (I’ll update it shortly). This change comes from a discussion in this PR, specifically this comment from tnull:

Actually, now that we went this way, let's not feature gate this behind std, but rather a new time feature. Context is the following: on WASM, you generally have access to std, but not entirely. For example, time-dependent behaviors like SystemTime::now would just panic at runtime. However, when previously feature-gating on std, we good feedback from some WASM users that would like to keep using the non-time std features.
TLDR: would be nice to allow users to disable the SystemTime::now default without disabling the entirety of std. So let's introduce a time feature, as we did in other crates (cf. lightning-transaction-sync).

@tnull tagging Elias for review and input.

Copy link
Contributor

@joostjager joostjager Apr 21, 2025

Choose a reason for hiding this comment

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

But if those users run with std and just don't inject the panic'ing default std time provider and instead supply their own, aren't they good with the time flag?

Copy link
Contributor

Choose a reason for hiding this comment

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

But if those users run with std and just don't inject the panic'ing default std time provider and instead supply their own, aren't they good with the time flag?

Yes, but we still shouldn't expose a 'default' API that would panic if used? That said, given that it requires std, we should probably feature-gate this on all(feature = "std", feature = "time"), or have time enable std.

Copy link
Contributor

Choose a reason for hiding this comment

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

Yes, but we still shouldn't expose a 'default' API that would panic if used?

If some platforms have an incomplete implementation of the std feature, is it the responsibility of this library to protect against hitting the missing part via a feature flag?

I also wouldn't make it a 'default' API, but just require a time provider to be supplied. Overall it seems to me that a time flag may be unnecessary.

pub struct DefaultTimeProvider;

#[cfg(feature = "time")]
impl TimeProvider for DefaultTimeProvider {
fn duration_since_epoch(&self) -> Duration {
use std::time::{SystemTime, UNIX_EPOCH};
SystemTime::now().duration_since(UNIX_EPOCH).expect("system time before Unix epoch")
}
}
36 changes: 29 additions & 7 deletions lightning-liquidity/src/lsps2/msgs.rs
Original file line number Diff line number Diff line change
Expand Up @@ -218,12 +218,32 @@ mod tests {
use super::*;

use crate::alloc::string::ToString;
use crate::lsps0::ser::TimeProvider;
use crate::lsps2::utils::is_valid_opening_fee_params;

use crate::sync::Arc;
use core::cell::RefCell;
use core::str::FromStr;
use core::time::Duration;

struct MockTimeProvider {
current_time: RefCell<Duration>,
}

impl MockTimeProvider {
fn new(seconds_since_epoch: u64) -> Self {
Self { current_time: RefCell::new(Duration::from_secs(seconds_since_epoch)) }
}
}

impl TimeProvider for MockTimeProvider {
fn duration_since_epoch(&self) -> Duration {
*self.current_time.borrow()
}
}

#[test]
fn into_opening_fee_params_produces_valid_promise() {
let time_provider = Arc::new(MockTimeProvider::new(1000));
let min_fee_msat = 100;
let proportional = 21;
let valid_until = LSPSDateTime::from_str("2035-05-20T08:30:45Z").unwrap();
Expand Down Expand Up @@ -254,11 +274,12 @@ mod tests {
assert_eq!(opening_fee_params.min_payment_size_msat, min_payment_size_msat);
assert_eq!(opening_fee_params.max_payment_size_msat, max_payment_size_msat);

assert!(is_valid_opening_fee_params(&opening_fee_params, &promise_secret));
assert!(is_valid_opening_fee_params(&opening_fee_params, &promise_secret, time_provider));
}

#[test]
fn changing_single_field_produced_invalid_params() {
let time_provider = Arc::new(MockTimeProvider::new(1000));
let min_fee_msat = 100;
let proportional = 21;
let valid_until = LSPSDateTime::from_str("2035-05-20T08:30:45Z").unwrap();
Expand All @@ -281,11 +302,12 @@ mod tests {

let mut opening_fee_params = raw.into_opening_fee_params(&promise_secret);
opening_fee_params.min_fee_msat = min_fee_msat + 1;
assert!(!is_valid_opening_fee_params(&opening_fee_params, &promise_secret));
assert!(!is_valid_opening_fee_params(&opening_fee_params, &promise_secret, time_provider));
}

#[test]
fn wrong_secret_produced_invalid_params() {
let time_provider = Arc::new(MockTimeProvider::new(1000));
let min_fee_msat = 100;
let proportional = 21;
let valid_until = LSPSDateTime::from_str("2035-05-20T08:30:45Z").unwrap();
Expand All @@ -308,13 +330,13 @@ mod tests {
let other_secret = [2u8; 32];

let opening_fee_params = raw.into_opening_fee_params(&promise_secret);
assert!(!is_valid_opening_fee_params(&opening_fee_params, &other_secret));
assert!(!is_valid_opening_fee_params(&opening_fee_params, &other_secret, time_provider));
}

#[test]
#[cfg(feature = "std")]
// TODO: We need to find a way to check expiry times in no-std builds.
fn expired_params_produces_invalid_params() {
// 70 years since epoch
let time_provider = Arc::new(MockTimeProvider::new(70 * 365 * 24 * 60 * 60)); // 1970 + 70 years
let min_fee_msat = 100;
let proportional = 21;
let valid_until = LSPSDateTime::from_str("2023-05-20T08:30:45Z").unwrap();
Expand All @@ -336,7 +358,7 @@ mod tests {
let promise_secret = [1u8; 32];

let opening_fee_params = raw.into_opening_fee_params(&promise_secret);
assert!(!is_valid_opening_fee_params(&opening_fee_params, &promise_secret));
assert!(!is_valid_opening_fee_params(&opening_fee_params, &promise_secret, time_provider));
}

#[test]
Expand Down
48 changes: 40 additions & 8 deletions lightning-liquidity/src/lsps2/service.rs
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,12 @@ use core::ops::Deref;
use core::sync::atomic::{AtomicUsize, Ordering};

use crate::events::EventQueue;

#[cfg(feature = "time")]
use crate::lsps0::ser::DefaultTimeProvider;

use crate::lsps0::ser::{
LSPSMessage, LSPSProtocolMessageHandler, LSPSRequestId, LSPSResponseError,
LSPSMessage, LSPSProtocolMessageHandler, LSPSRequestId, LSPSResponseError, TimeProvider,
JSONRPC_INTERNAL_ERROR_ERROR_CODE, JSONRPC_INTERNAL_ERROR_ERROR_MESSAGE,
LSPS0_CLIENT_REJECTED_ERROR_CODE,
};
Expand Down Expand Up @@ -400,18 +404,20 @@ struct OutboundJITChannel {
user_channel_id: u128,
opening_fee_params: LSPS2OpeningFeeParams,
payment_size_msat: Option<u64>,
time_provider: Arc<dyn TimeProvider>,
}

impl OutboundJITChannel {
fn new(
payment_size_msat: Option<u64>, opening_fee_params: LSPS2OpeningFeeParams,
user_channel_id: u128,
user_channel_id: u128, time_provider: Arc<dyn TimeProvider>,
) -> Self {
Self {
user_channel_id,
state: OutboundJITChannelState::new(),
opening_fee_params,
payment_size_msat,
time_provider,
}
}

Expand Down Expand Up @@ -451,7 +457,8 @@ impl OutboundJITChannel {
fn is_prunable(&self) -> bool {
// We deem an OutboundJITChannel prunable if our offer expired and we haven't intercepted
// any HTLCs initiating the flow yet.
let is_expired = is_expired_opening_fee_params(&self.opening_fee_params);
let is_expired =
is_expired_opening_fee_params(&self.opening_fee_params, self.time_provider.clone());
self.is_pending_initial_payment() && is_expired
}
}
Expand Down Expand Up @@ -481,13 +488,16 @@ impl PeerState {
self.outbound_channels_by_intercept_scid.insert(intercept_scid, channel);
}

fn prune_expired_request_state(&mut self) {
fn prune_expired_request_state(&mut self, time_provider: Arc<dyn TimeProvider>) {
self.pending_requests.retain(|_, entry| {
match entry {
LSPS2Request::GetInfo(_) => false,
LSPS2Request::Buy(request) => {
// Prune any expired buy requests.
!is_expired_opening_fee_params(&request.opening_fee_params)
!is_expired_opening_fee_params(
&request.opening_fee_params,
time_provider.clone(),
)
},
}
});
Expand Down Expand Up @@ -566,16 +576,32 @@ where
peer_by_channel_id: RwLock<HashMap<ChannelId, PublicKey>>,
total_pending_requests: AtomicUsize,
config: LSPS2ServiceConfig,
time_provider: Arc<dyn TimeProvider>,
}

impl<CM: Deref> LSPS2ServiceHandler<CM>
where
CM::Target: AChannelManager,
{
#[cfg(feature = "time")]
/// Constructs a `LSPS2ServiceHandler`.
pub(crate) fn new(
pending_messages: Arc<MessageQueue>, pending_events: Arc<EventQueue>, channel_manager: CM,
config: LSPS2ServiceConfig,
) -> Self {
let time_provider = Arc::new(DefaultTimeProvider);
Self::new_with_custom_time_provider(
pending_messages,
pending_events,
channel_manager,
config,
time_provider,
)
}

pub(crate) fn new_with_custom_time_provider(
pending_messages: Arc<MessageQueue>, pending_events: Arc<EventQueue>, channel_manager: CM,
config: LSPS2ServiceConfig, time_provider: Arc<dyn TimeProvider>,
) -> Self {
Self {
pending_messages,
Expand All @@ -586,6 +612,7 @@ where
total_pending_requests: AtomicUsize::new(0),
channel_manager,
config,
time_provider,
}
}

Expand Down Expand Up @@ -737,6 +764,7 @@ where
buy_request.payment_size_msat,
buy_request.opening_fee_params,
user_channel_id,
self.time_provider.clone(),
);

peer_state_lock
Expand Down Expand Up @@ -1192,7 +1220,11 @@ where
}

// TODO: if payment_size_msat is specified, make sure our node has sufficient incoming liquidity from public network to receive it.
if !is_valid_opening_fee_params(&params.opening_fee_params, &self.config.promise_secret) {
if !is_valid_opening_fee_params(
&params.opening_fee_params,
&self.config.promise_secret,
self.time_provider.clone(),
) {
let response = LSPS2Response::BuyError(LSPSResponseError {
code: LSPS2_BUY_REQUEST_INVALID_OPENING_FEE_PARAMS_ERROR_CODE,
message: "valid_until is already past OR the promise did not match the provided parameters".to_string(),
Expand Down Expand Up @@ -1334,7 +1366,7 @@ where
let is_prunable =
if let Some(inner_state_lock) = outer_state_lock.get(&counterparty_node_id) {
let mut peer_state_lock = inner_state_lock.lock().unwrap();
peer_state_lock.prune_expired_request_state();
peer_state_lock.prune_expired_request_state(self.time_provider.clone());
peer_state_lock.is_prunable()
} else {
return;
Expand All @@ -1349,7 +1381,7 @@ where
let mut outer_state_lock = self.per_peer_state.write().unwrap();
outer_state_lock.retain(|_, inner_state_lock| {
let mut peer_state_lock = inner_state_lock.lock().unwrap();
peer_state_lock.prune_expired_request_state();
peer_state_lock.prune_expired_request_state(self.time_provider.clone());
peer_state_lock.is_prunable() == false
});
}
Expand Down
21 changes: 9 additions & 12 deletions lightning-liquidity/src/lsps2/utils.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
//! Utilities for implementing the bLIP-52 / LSPS2 standard.

use crate::sync::Arc;

use crate::lsps0::ser::TimeProvider;
use crate::lsps2::msgs::LSPS2OpeningFeeParams;
use crate::utils;

Expand All @@ -10,8 +13,9 @@ use bitcoin::hashes::{Hash, HashEngine};
/// Determines if the given parameters are valid given the secret used to generate the promise.
pub fn is_valid_opening_fee_params(
fee_params: &LSPS2OpeningFeeParams, promise_secret: &[u8; 32],
time_provider: Arc<dyn TimeProvider>,
) -> bool {
if is_expired_opening_fee_params(fee_params) {
if is_expired_opening_fee_params(fee_params, time_provider) {
return false;
}
let mut hmac = HmacEngine::<Sha256>::new(promise_secret);
Expand All @@ -28,17 +32,10 @@ pub fn is_valid_opening_fee_params(
}

/// Determines if the given parameters are expired, or still valid.
#[cfg_attr(not(feature = "std"), allow(unused_variables))]
pub fn is_expired_opening_fee_params(fee_params: &LSPS2OpeningFeeParams) -> bool {
#[cfg(feature = "std")]
{
fee_params.valid_until.is_past()
}
#[cfg(not(feature = "std"))]
{
// TODO: We need to find a way to check expiry times in no-std builds.
false
}
pub fn is_expired_opening_fee_params(
fee_params: &LSPS2OpeningFeeParams, time_provider: Arc<dyn TimeProvider>,
) -> bool {
fee_params.valid_until.is_past(time_provider)
}

/// Computes the opening fee given a payment size and the fee parameters.
Expand Down
Loading
Loading