Merged
11 changes: 11 additions & 0 deletions CHANGELOG.md
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,10 @@
# Pending

## Compatibility Notes
- Migrating between storage backends does not preserve the relative creation order of
pre-existing payments, as the generic KV store migration copies entries in an unspecified
order. Expect the order in which `Node::list_payments` returns pre-existing payments to
change once after such a migration. Payment contents and completeness are unaffected.
- Pending JIT-channel payments created before upgrading may fail after upgrade because the
prior LSPS2 fee-limit state stored in `PaymentKind::Bolt11Jit` is not migrated.
- Upgrading from LDK Node v0.1 is no longer supported if the event queue still contains
Expand All@@ -19,6 +23,13 @@
`Event::PaymentClaimable`.

## Feature and API updates
- `Node::list_payments` is now paginated: it takes an optional `PageToken` and returns a
`PaymentDetailsPage` holding one page of payments, ordered from most recently created to
least recently created, plus the token for the next page. Ordering and page tokens come
from the configured storage backend, and token lifetime follows that backend's guarantees.
This replaces the previous unpaginated `Node::list_payments`, and
`Node::list_payments_with_filter` has been removed; filter the returned pages instead.
- `Node::payment` now returns a `Result`, as retrieving a payment may fail.
Comment thread
benthecarman marked this conversation as resolved.
- The Bitcoin Core RPC and REST chain-source builder methods now accept an optional
`wallet_rescan_from_height` argument. Passing a height lets fresh wallets rescan from a known
birthday block instead of checkpointing at the current tip, which is useful when restoring a
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -301,8 +301,12 @@ class LibraryTest {
assert(paymentReceivedEvent is Event.PaymentReceived)
node2.eventHandled()

assert(node1.listPayments().size == 3)
assert(node2.listPayments().size == 2)
assert(node1.listPayments(null).payments.size == 3)
assert(node2.listPayments(null).payments.size == 2)

// A page token has to survive a round trip through a string, so that an app can persist
// one and resume paginating after a restart.
assert(PageToken("some-page-token").toString() == "some-page-token")

node2.closeChannel(userChannelId, nodeId1)

Expand Down
9 changes: 8 additions & 1 deletion bindings/ldk_node.udl
Original file line numberDiff line numberDiff line change
Expand Up@@ -147,11 +147,13 @@ interface Node {
void update_channel_config([ByRef]UserChannelId user_channel_id, PublicKey counterparty_node_id, ChannelConfig channel_config);
[Throws=NodeError]
void sync_wallets();
[Throws=NodeError]
PaymentDetails? payment([ByRef]PaymentId payment_id);
[Throws=NodeError]
void remove_payment([ByRef]PaymentId payment_id);
BalanceDetails list_balances();
sequence<PaymentDetails> list_payments();
[Throws=NodeError]
PaymentDetailsPage list_payments(PageToken? page_token);
sequence<PeerDetails> list_peers();
sequence<ChannelDetails> list_channels();
NetworkGraph network_graph();
Expand DownExpand Up@@ -236,6 +238,7 @@ enum NodeError {
"InvalidDateTime",
"InvalidFeeRate",
"InvalidScriptPubKey",
"InvalidPageToken",
"DuplicatePayment",
"UnsupportedCurrency",
"InsufficientFunds",
Expand DownExpand Up@@ -279,6 +282,10 @@ enum PaymentFailureReason {

typedef dictionary PaymentDetails;

typedef dictionary PaymentDetailsPage;

typedef interface PageToken;

[Remote]
dictionary RouteParametersConfig {
u64? max_total_routing_fee_msat;
Expand Down
21 changes: 16 additions & 5 deletions src/builder.rs
Original file line numberDiff line numberDiff line change
Expand Up@@ -50,18 +50,20 @@ use crate::config::{
default_user_config, may_announce_channel, AnnounceError, AsyncPaymentsRole,
BitcoindRestClientConfig, Config, ElectrumSyncConfig, EsploraSyncConfig, HRNResolverConfig,
TorConfig, DEFAULT_ESPLORA_SERVER_URL, DEFAULT_LOG_FILENAME, DEFAULT_LOG_LEVEL,
DEFAULT_MAX_PROBE_AMOUNT_MSAT, DEFAULT_MIN_PROBE_AMOUNT_MSAT,
DEFAULT_MAX_PROBE_AMOUNT_MSAT, DEFAULT_MIN_PROBE_AMOUNT_MSAT, PAYMENT_CACHE_CAPACITY,
PAYMENT_CACHE_WARMUP_COUNT,
};
use crate::connection::ConnectionManager;
use crate::data_store::{KeepAllEntries, KeepLeastRecentlyUsed};
use crate::entropy::NodeEntropy;
use crate::event::EventQueue;
use crate::fee_estimator::OnchainFeeEstimator;
use crate::gossip::GossipSource;
use crate::io::sqlite_store::SqliteStore;
use crate::io::utils::{
open_or_migrate_fs_store, read_all_objects, read_event_queue,
read_external_pathfinding_scores_from_cache, read_network_graph, read_node_metrics,
read_output_sweeper, read_peer_info, read_scorer,
read_external_pathfinding_scores_from_cache, read_n_objects, read_network_graph,
read_node_metrics, read_output_sweeper, read_peer_info, read_scorer,
};
use crate::io::vss_store::VssStoreBuilder;
use crate::io::{
Expand DownExpand Up@@ -1458,10 +1460,11 @@ fn build_with_store_internal(
let (payment_store_res, node_metris_res, pending_payment_store_res, address_pool_res) = runtime
.block_on(async move {
tokio::join!(
read_all_objects(
read_n_objects(
&*kv_store_ref,
PAYMENT_INFO_PERSISTENCE_PRIMARY_NAMESPACE,
PAYMENT_INFO_PERSISTENCE_SECONDARY_NAMESPACE,
PAYMENT_CACHE_WARMUP_COUNT,
Arc::clone(&logger_ref),
),
read_node_metrics(&*kv_store_ref, Arc::clone(&logger_ref)),
Expand DownExpand Up@@ -1490,7 +1493,11 @@ fn build_with_store_internal(

let payment_store = match payment_store_res {
Ok(payments) => Arc::new(PaymentStore::new(
payments,
// The read hands us the newest payments first, while the cache treats the objects it
// is seeded with as increasingly recently used. Reverse them, so that the newest
// payment is the last one to be evicted rather than the first.
payments.into_iter().rev().collect(),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

LRU seeding order is guarded only by a commentsrc/builder.rs:1495read_n_objects returns newest-first, but DataStore::new treats the last seed element as most-recently-used, so the builder must .rev(). Dropping that call would silently evict the newest payments first with no test to catch it.

KeepLeastRecentlyUsed::new(PAYMENT_CACHE_CAPACITY),
PAYMENT_INFO_PERSISTENCE_PRIMARY_NAMESPACE.to_string(),
PAYMENT_INFO_PERSISTENCE_SECONDARY_NAMESPACE.to_string(),
Arc::clone(&kv_store),
Expand DownExpand Up@@ -1745,8 +1752,12 @@ fn build_with_store_internal(
};

let pending_payment_store = match pending_payment_store_res {
// NOTE: This store must keep all its entries in memory: the wallet scans it in full on
// every chain tip change and to resolve replaced transactions. It stays bounded anyway,
// as entries are removed once a payment is no longer pending.
Ok(pending_payments) => Arc::new(PendingPaymentStore::new(
pending_payments,
KeepAllEntries,
PENDING_PAYMENT_INFO_PERSISTENCE_PRIMARY_NAMESPACE.to_string(),
PENDING_PAYMENT_INFO_PERSISTENCE_SECONDARY_NAMESPACE.to_string(),
Arc::clone(&kv_store),
Expand Down
17 changes: 17 additions & 0 deletions src/config.rs
Original file line numberDiff line numberDiff line change
Expand Up@@ -8,6 +8,7 @@
//! Objects for configuring the node.

use std::fmt;
use std::num::NonZeroUsize;
use std::str::FromStr;
use std::time::Duration;

Expand DownExpand Up@@ -48,6 +49,22 @@ pub(crate) const DEFAULT_FEE_RATE_CACHE_UPDATE_TIMEOUT_SECS: u64 = 10;
// The default timeout after which we abort a transaction broadcast operation.
pub(crate) const DEFAULT_TX_BROADCAST_TIMEOUT_SECS: u64 = 10;

// The number of payments we keep in memory.
//
// The payment history grows for the lifetime of a node, so we cache only the most recently used
// payments and read the rest back from the store as they are needed. At roughly 400 to 500 bytes
// per cached payment, this bounds the payment store's share of memory at well under a megabyte,
// while still covering the recent payments a node actually works with.
pub(crate) const PAYMENT_CACHE_CAPACITY: NonZeroUsize = NonZeroUsize::new(1000).unwrap();

// The number of payments we read into the cache when starting up.
//
// This matches the built-in storage backends' page size, so warming the cache costs a single page
// listing and one batch of reads. Immediately after startup, a first-page `Node::list_payments`
// call reads only its keys from storage; the payment bodies come from the cache. Later activity
// may displace those entries.
pub(crate) const PAYMENT_CACHE_WARMUP_COUNT: NonZeroUsize = NonZeroUsize::new(50).unwrap();

@benthecarmanbenthecarmanAug 13, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

we have this 50 as a const somewhere, can we use that?

Copy link
Copy Markdown
CollaboratorAuthor

Choose a reason for hiding this comment

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

Hmm, we have some consts that happen to be 50, but they are somewhat orthogonal? Which one do you have in mind exactly?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

well the comment highlights that its equal ot the page size. We have separate consts for posgres and sqlite page size. Should unify those and use that const here

@tnulltnullAug 17, 2026

Copy link
Copy Markdown
CollaboratorAuthor

Choose a reason for hiding this comment

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

Hmm, not sure? It's basically a coincidence that it's a page size, though likely a multiple of page size makes sense? Not sure if we want to couple the concepts strongly here, would also be a layer violation somewhat, and one additional thing we'd need to disentangle when upstreaming VSS/Postgres stores?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

I mostly say this because the comment says

This matches the built-in storage backends' page size, so warming the cache costs a single page listing and one batch of reads

So it implies that they are meant to be the same. If its a coincidence / orthogonal i guess that's fine and yeah when we eventually upstream we would have to change anyways.


// The default {Esplora,Electrum} client timeout we're using.
const DEFAULT_PER_REQUEST_TIMEOUT_SECS: u8 = 10;

Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
Skip to content
Merged
11 changes: 11 additions & 0 deletions CHANGELOG.md
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,10 @@
# Pending

## Compatibility Notes
- Migrating between storage backends does not preserve the relative creation order of
pre-existing payments, as the generic KV store migration copies entries in an unspecified
order. Expect the order in which `Node::list_payments` returns pre-existing payments to
change once after such a migration. Payment contents and completeness are unaffected.
- Pending JIT-channel payments created before upgrading may fail after upgrade because the
prior LSPS2 fee-limit state stored in `PaymentKind::Bolt11Jit` is not migrated.
- Upgrading from LDK Node v0.1 is no longer supported if the event queue still contains
Expand All@@ -19,6 +23,13 @@
`Event::PaymentClaimable`.

## Feature and API updates
- `Node::list_payments` is now paginated: it takes an optional `PageToken` and returns a
`PaymentDetailsPage` holding one page of payments, ordered from most recently created to
least recently created, plus the token for the next page. Ordering and page tokens come
from the configured storage backend, and token lifetime follows that backend's guarantees.
This replaces the previous unpaginated `Node::list_payments`, and
`Node::list_payments_with_filter` has been removed; filter the returned pages instead.
- `Node::payment` now returns a `Result`, as retrieving a payment may fail.
Comment thread
benthecarman marked this conversation as resolved.
- The Bitcoin Core RPC and REST chain-source builder methods now accept an optional
`wallet_rescan_from_height` argument. Passing a height lets fresh wallets rescan from a known
birthday block instead of checkpointing at the current tip, which is useful when restoring a
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -301,8 +301,12 @@ class LibraryTest {
assert(paymentReceivedEvent is Event.PaymentReceived)
node2.eventHandled()

assert(node1.listPayments().size == 3)
assert(node2.listPayments().size == 2)
assert(node1.listPayments(null).payments.size == 3)
assert(node2.listPayments(null).payments.size == 2)

// A page token has to survive a round trip through a string, so that an app can persist
// one and resume paginating after a restart.
assert(PageToken("some-page-token").toString() == "some-page-token")

node2.closeChannel(userChannelId, nodeId1)

Expand Down
9 changes: 8 additions & 1 deletion bindings/ldk_node.udl
Original file line numberDiff line numberDiff line change
Expand Up@@ -147,11 +147,13 @@ interface Node {
void update_channel_config([ByRef]UserChannelId user_channel_id, PublicKey counterparty_node_id, ChannelConfig channel_config);
[Throws=NodeError]
void sync_wallets();
[Throws=NodeError]
PaymentDetails? payment([ByRef]PaymentId payment_id);
[Throws=NodeError]
void remove_payment([ByRef]PaymentId payment_id);
BalanceDetails list_balances();
sequence<PaymentDetails> list_payments();
[Throws=NodeError]
PaymentDetailsPage list_payments(PageToken? page_token);
sequence<PeerDetails> list_peers();
sequence<ChannelDetails> list_channels();
NetworkGraph network_graph();
Expand DownExpand Up@@ -236,6 +238,7 @@ enum NodeError {
"InvalidDateTime",
"InvalidFeeRate",
"InvalidScriptPubKey",
"InvalidPageToken",
"DuplicatePayment",
"UnsupportedCurrency",
"InsufficientFunds",
Expand DownExpand Up@@ -279,6 +282,10 @@ enum PaymentFailureReason {

typedef dictionary PaymentDetails;

typedef dictionary PaymentDetailsPage;

typedef interface PageToken;

[Remote]
dictionary RouteParametersConfig {
u64? max_total_routing_fee_msat;
Expand Down
21 changes: 16 additions & 5 deletions src/builder.rs
Original file line numberDiff line numberDiff line change
Expand Up@@ -50,18 +50,20 @@ use crate::config::{
default_user_config, may_announce_channel, AnnounceError, AsyncPaymentsRole,
BitcoindRestClientConfig, Config, ElectrumSyncConfig, EsploraSyncConfig, HRNResolverConfig,
TorConfig, DEFAULT_ESPLORA_SERVER_URL, DEFAULT_LOG_FILENAME, DEFAULT_LOG_LEVEL,
DEFAULT_MAX_PROBE_AMOUNT_MSAT, DEFAULT_MIN_PROBE_AMOUNT_MSAT,
DEFAULT_MAX_PROBE_AMOUNT_MSAT, DEFAULT_MIN_PROBE_AMOUNT_MSAT, PAYMENT_CACHE_CAPACITY,
PAYMENT_CACHE_WARMUP_COUNT,
};
use crate::connection::ConnectionManager;
use crate::data_store::{KeepAllEntries, KeepLeastRecentlyUsed};
use crate::entropy::NodeEntropy;
use crate::event::EventQueue;
use crate::fee_estimator::OnchainFeeEstimator;
use crate::gossip::GossipSource;
use crate::io::sqlite_store::SqliteStore;
use crate::io::utils::{
open_or_migrate_fs_store, read_all_objects, read_event_queue,
read_external_pathfinding_scores_from_cache, read_network_graph, read_node_metrics,
read_output_sweeper, read_peer_info, read_scorer,
read_external_pathfinding_scores_from_cache, read_n_objects, read_network_graph,
read_node_metrics, read_output_sweeper, read_peer_info, read_scorer,
};
use crate::io::vss_store::VssStoreBuilder;
use crate::io::{
Expand DownExpand Up@@ -1458,10 +1460,11 @@ fn build_with_store_internal(
let (payment_store_res, node_metris_res, pending_payment_store_res, address_pool_res) = runtime
.block_on(async move {
tokio::join!(
read_all_objects(
read_n_objects(
&*kv_store_ref,
PAYMENT_INFO_PERSISTENCE_PRIMARY_NAMESPACE,
PAYMENT_INFO_PERSISTENCE_SECONDARY_NAMESPACE,
PAYMENT_CACHE_WARMUP_COUNT,
Arc::clone(&logger_ref),
),
read_node_metrics(&*kv_store_ref, Arc::clone(&logger_ref)),
Expand DownExpand Up@@ -1490,7 +1493,11 @@ fn build_with_store_internal(

let payment_store = match payment_store_res {
Ok(payments) => Arc::new(PaymentStore::new(
payments,
// The read hands us the newest payments first, while the cache treats the objects it
// is seeded with as increasingly recently used. Reverse them, so that the newest
// payment is the last one to be evicted rather than the first.
payments.into_iter().rev().collect(),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

LRU seeding order is guarded only by a commentsrc/builder.rs:1495read_n_objects returns newest-first, but DataStore::new treats the last seed element as most-recently-used, so the builder must .rev(). Dropping that call would silently evict the newest payments first with no test to catch it.

KeepLeastRecentlyUsed::new(PAYMENT_CACHE_CAPACITY),
PAYMENT_INFO_PERSISTENCE_PRIMARY_NAMESPACE.to_string(),
PAYMENT_INFO_PERSISTENCE_SECONDARY_NAMESPACE.to_string(),
Arc::clone(&kv_store),
Expand DownExpand Up@@ -1745,8 +1752,12 @@ fn build_with_store_internal(
};

let pending_payment_store = match pending_payment_store_res {
// NOTE: This store must keep all its entries in memory: the wallet scans it in full on
// every chain tip change and to resolve replaced transactions. It stays bounded anyway,
// as entries are removed once a payment is no longer pending.
Ok(pending_payments) => Arc::new(PendingPaymentStore::new(
pending_payments,
KeepAllEntries,
PENDING_PAYMENT_INFO_PERSISTENCE_PRIMARY_NAMESPACE.to_string(),
PENDING_PAYMENT_INFO_PERSISTENCE_SECONDARY_NAMESPACE.to_string(),
Arc::clone(&kv_store),
Expand Down
17 changes: 17 additions & 0 deletions src/config.rs
Original file line numberDiff line numberDiff line change
Expand Up@@ -8,6 +8,7 @@
//! Objects for configuring the node.

use std::fmt;
use std::num::NonZeroUsize;
use std::str::FromStr;
use std::time::Duration;

Expand DownExpand Up@@ -48,6 +49,22 @@ pub(crate) const DEFAULT_FEE_RATE_CACHE_UPDATE_TIMEOUT_SECS: u64 = 10;
// The default timeout after which we abort a transaction broadcast operation.
pub(crate) const DEFAULT_TX_BROADCAST_TIMEOUT_SECS: u64 = 10;

// The number of payments we keep in memory.
//
// The payment history grows for the lifetime of a node, so we cache only the most recently used
// payments and read the rest back from the store as they are needed. At roughly 400 to 500 bytes
// per cached payment, this bounds the payment store's share of memory at well under a megabyte,
// while still covering the recent payments a node actually works with.
pub(crate) const PAYMENT_CACHE_CAPACITY: NonZeroUsize = NonZeroUsize::new(1000).unwrap();

// The number of payments we read into the cache when starting up.
//
// This matches the built-in storage backends' page size, so warming the cache costs a single page
// listing and one batch of reads. Immediately after startup, a first-page `Node::list_payments`
// call reads only its keys from storage; the payment bodies come from the cache. Later activity
// may displace those entries.
pub(crate) const PAYMENT_CACHE_WARMUP_COUNT: NonZeroUsize = NonZeroUsize::new(50).unwrap();

@benthecarmanbenthecarmanAug 13, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

we have this 50 as a const somewhere, can we use that?

Copy link
Copy Markdown
CollaboratorAuthor

Choose a reason for hiding this comment

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

Hmm, we have some consts that happen to be 50, but they are somewhat orthogonal? Which one do you have in mind exactly?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

well the comment highlights that its equal ot the page size. We have separate consts for posgres and sqlite page size. Should unify those and use that const here

@tnulltnullAug 17, 2026

Copy link
Copy Markdown
CollaboratorAuthor

Choose a reason for hiding this comment

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

Hmm, not sure? It's basically a coincidence that it's a page size, though likely a multiple of page size makes sense? Not sure if we want to couple the concepts strongly here, would also be a layer violation somewhat, and one additional thing we'd need to disentangle when upstreaming VSS/Postgres stores?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

I mostly say this because the comment says

This matches the built-in storage backends' page size, so warming the cache costs a single page listing and one batch of reads

So it implies that they are meant to be the same. If its a coincidence / orthogonal i guess that's fine and yeah when we eventually upstream we would have to change anyways.


// The default {Esplora,Electrum} client timeout we're using.
const DEFAULT_PER_REQUEST_TIMEOUT_SECS: u8 = 10;

Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
11 changes: 11 additions & 0 deletions CHANGELOG.md
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,10 @@
# Pending

## Compatibility Notes
- Migrating between storage backends does not preserve the relative creation order of
pre-existing payments, as the generic KV store migration copies entries in an unspecified
order. Expect the order in which `Node::list_payments` returns pre-existing payments to
change once after such a migration. Payment contents and completeness are unaffected.
- Pending JIT-channel payments created before upgrading may fail after upgrade because the
prior LSPS2 fee-limit state stored in `PaymentKind::Bolt11Jit` is not migrated.
- Upgrading from LDK Node v0.1 is no longer supported if the event queue still contains
Expand All@@ -19,6 +23,13 @@
`Event::PaymentClaimable`.

## Feature and API updates
- `Node::list_payments` is now paginated: it takes an optional `PageToken` and returns a
`PaymentDetailsPage` holding one page of payments, ordered from most recently created to
least recently created, plus the token for the next page. Ordering and page tokens come
from the configured storage backend, and token lifetime follows that backend's guarantees.
This replaces the previous unpaginated `Node::list_payments`, and
`Node::list_payments_with_filter` has been removed; filter the returned pages instead.
- `Node::payment` now returns a `Result`, as retrieving a payment may fail.
Comment thread
benthecarman marked this conversation as resolved.
- The Bitcoin Core RPC and REST chain-source builder methods now accept an optional
`wallet_rescan_from_height` argument. Passing a height lets fresh wallets rescan from a known
birthday block instead of checkpointing at the current tip, which is useful when restoring a
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -301,8 +301,12 @@ class LibraryTest {
assert(paymentReceivedEvent is Event.PaymentReceived)
node2.eventHandled()

assert(node1.listPayments().size == 3)
assert(node2.listPayments().size == 2)
assert(node1.listPayments(null).payments.size == 3)
assert(node2.listPayments(null).payments.size == 2)

// A page token has to survive a round trip through a string, so that an app can persist
// one and resume paginating after a restart.
assert(PageToken("some-page-token").toString() == "some-page-token")

node2.closeChannel(userChannelId, nodeId1)

Expand Down
9 changes: 8 additions & 1 deletion bindings/ldk_node.udl
Original file line numberDiff line numberDiff line change
Expand Up@@ -147,11 +147,13 @@ interface Node {
void update_channel_config([ByRef]UserChannelId user_channel_id, PublicKey counterparty_node_id, ChannelConfig channel_config);
[Throws=NodeError]
void sync_wallets();
[Throws=NodeError]
PaymentDetails? payment([ByRef]PaymentId payment_id);
[Throws=NodeError]
void remove_payment([ByRef]PaymentId payment_id);
BalanceDetails list_balances();
sequence<PaymentDetails> list_payments();
[Throws=NodeError]
PaymentDetailsPage list_payments(PageToken? page_token);
sequence<PeerDetails> list_peers();
sequence<ChannelDetails> list_channels();
NetworkGraph network_graph();
Expand DownExpand Up@@ -236,6 +238,7 @@ enum NodeError {
"InvalidDateTime",
"InvalidFeeRate",
"InvalidScriptPubKey",
"InvalidPageToken",
"DuplicatePayment",
"UnsupportedCurrency",
"InsufficientFunds",
Expand DownExpand Up@@ -279,6 +282,10 @@ enum PaymentFailureReason {

typedef dictionary PaymentDetails;

typedef dictionary PaymentDetailsPage;

typedef interface PageToken;

[Remote]
dictionary RouteParametersConfig {
u64? max_total_routing_fee_msat;
Expand Down
21 changes: 16 additions & 5 deletions src/builder.rs
Original file line numberDiff line numberDiff line change
Expand Up@@ -50,18 +50,20 @@ use crate::config::{
default_user_config, may_announce_channel, AnnounceError, AsyncPaymentsRole,
BitcoindRestClientConfig, Config, ElectrumSyncConfig, EsploraSyncConfig, HRNResolverConfig,
TorConfig, DEFAULT_ESPLORA_SERVER_URL, DEFAULT_LOG_FILENAME, DEFAULT_LOG_LEVEL,
DEFAULT_MAX_PROBE_AMOUNT_MSAT, DEFAULT_MIN_PROBE_AMOUNT_MSAT,
DEFAULT_MAX_PROBE_AMOUNT_MSAT, DEFAULT_MIN_PROBE_AMOUNT_MSAT, PAYMENT_CACHE_CAPACITY,
PAYMENT_CACHE_WARMUP_COUNT,
};
use crate::connection::ConnectionManager;
use crate::data_store::{KeepAllEntries, KeepLeastRecentlyUsed};
use crate::entropy::NodeEntropy;
use crate::event::EventQueue;
use crate::fee_estimator::OnchainFeeEstimator;
use crate::gossip::GossipSource;
use crate::io::sqlite_store::SqliteStore;
use crate::io::utils::{
open_or_migrate_fs_store, read_all_objects, read_event_queue,
read_external_pathfinding_scores_from_cache, read_network_graph, read_node_metrics,
read_output_sweeper, read_peer_info, read_scorer,
read_external_pathfinding_scores_from_cache, read_n_objects, read_network_graph,
read_node_metrics, read_output_sweeper, read_peer_info, read_scorer,
};
use crate::io::vss_store::VssStoreBuilder;
use crate::io::{
Expand DownExpand Up@@ -1458,10 +1460,11 @@ fn build_with_store_internal(
let (payment_store_res, node_metris_res, pending_payment_store_res, address_pool_res) = runtime
.block_on(async move {
tokio::join!(
read_all_objects(
read_n_objects(
&*kv_store_ref,
PAYMENT_INFO_PERSISTENCE_PRIMARY_NAMESPACE,
PAYMENT_INFO_PERSISTENCE_SECONDARY_NAMESPACE,
PAYMENT_CACHE_WARMUP_COUNT,
Arc::clone(&logger_ref),
),
read_node_metrics(&*kv_store_ref, Arc::clone(&logger_ref)),
Expand DownExpand Up@@ -1490,7 +1493,11 @@ fn build_with_store_internal(

let payment_store = match payment_store_res {
Ok(payments) => Arc::new(PaymentStore::new(
payments,
// The read hands us the newest payments first, while the cache treats the objects it
// is seeded with as increasingly recently used. Reverse them, so that the newest
// payment is the last one to be evicted rather than the first.
payments.into_iter().rev().collect(),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

LRU seeding order is guarded only by a commentsrc/builder.rs:1495read_n_objects returns newest-first, but DataStore::new treats the last seed element as most-recently-used, so the builder must .rev(). Dropping that call would silently evict the newest payments first with no test to catch it.

KeepLeastRecentlyUsed::new(PAYMENT_CACHE_CAPACITY),
PAYMENT_INFO_PERSISTENCE_PRIMARY_NAMESPACE.to_string(),
PAYMENT_INFO_PERSISTENCE_SECONDARY_NAMESPACE.to_string(),
Arc::clone(&kv_store),
Expand DownExpand Up@@ -1745,8 +1752,12 @@ fn build_with_store_internal(
};

let pending_payment_store = match pending_payment_store_res {
// NOTE: This store must keep all its entries in memory: the wallet scans it in full on
// every chain tip change and to resolve replaced transactions. It stays bounded anyway,
// as entries are removed once a payment is no longer pending.
Ok(pending_payments) => Arc::new(PendingPaymentStore::new(
pending_payments,
KeepAllEntries,
PENDING_PAYMENT_INFO_PERSISTENCE_PRIMARY_NAMESPACE.to_string(),
PENDING_PAYMENT_INFO_PERSISTENCE_SECONDARY_NAMESPACE.to_string(),
Arc::clone(&kv_store),
Expand Down
17 changes: 17 additions & 0 deletions src/config.rs
Original file line numberDiff line numberDiff line change
Expand Up@@ -8,6 +8,7 @@
//! Objects for configuring the node.

use std::fmt;
use std::num::NonZeroUsize;
use std::str::FromStr;
use std::time::Duration;

Expand DownExpand Up@@ -48,6 +49,22 @@ pub(crate) const DEFAULT_FEE_RATE_CACHE_UPDATE_TIMEOUT_SECS: u64 = 10;
// The default timeout after which we abort a transaction broadcast operation.
pub(crate) const DEFAULT_TX_BROADCAST_TIMEOUT_SECS: u64 = 10;

// The number of payments we keep in memory.
//
// The payment history grows for the lifetime of a node, so we cache only the most recently used
// payments and read the rest back from the store as they are needed. At roughly 400 to 500 bytes
// per cached payment, this bounds the payment store's share of memory at well under a megabyte,
// while still covering the recent payments a node actually works with.
pub(crate) const PAYMENT_CACHE_CAPACITY: NonZeroUsize = NonZeroUsize::new(1000).unwrap();

// The number of payments we read into the cache when starting up.
//
// This matches the built-in storage backends' page size, so warming the cache costs a single page
// listing and one batch of reads. Immediately after startup, a first-page `Node::list_payments`
// call reads only its keys from storage; the payment bodies come from the cache. Later activity
// may displace those entries.
pub(crate) const PAYMENT_CACHE_WARMUP_COUNT: NonZeroUsize = NonZeroUsize::new(50).unwrap();

@benthecarmanbenthecarmanAug 13, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

we have this 50 as a const somewhere, can we use that?

Copy link
Copy Markdown
CollaboratorAuthor

Choose a reason for hiding this comment

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

Hmm, we have some consts that happen to be 50, but they are somewhat orthogonal? Which one do you have in mind exactly?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

well the comment highlights that its equal ot the page size. We have separate consts for posgres and sqlite page size. Should unify those and use that const here

@tnulltnullAug 17, 2026

Copy link
Copy Markdown
CollaboratorAuthor

Choose a reason for hiding this comment

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

Hmm, not sure? It's basically a coincidence that it's a page size, though likely a multiple of page size makes sense? Not sure if we want to couple the concepts strongly here, would also be a layer violation somewhat, and one additional thing we'd need to disentangle when upstreaming VSS/Postgres stores?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

I mostly say this because the comment says

This matches the built-in storage backends' page size, so warming the cache costs a single page listing and one batch of reads

So it implies that they are meant to be the same. If its a coincidence / orthogonal i guess that's fine and yeah when we eventually upstream we would have to change anyways.


// The default {Esplora,Electrum} client timeout we're using.
const DEFAULT_PER_REQUEST_TIMEOUT_SECS: u8 = 10;

Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
11 changes: 11 additions & 0 deletions CHANGELOG.md
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,10 @@
# Pending

## Compatibility Notes
- Migrating between storage backends does not preserve the relative creation order of
pre-existing payments, as the generic KV store migration copies entries in an unspecified
order. Expect the order in which `Node::list_payments` returns pre-existing payments to
change once after such a migration. Payment contents and completeness are unaffected.
- Pending JIT-channel payments created before upgrading may fail after upgrade because the
prior LSPS2 fee-limit state stored in `PaymentKind::Bolt11Jit` is not migrated.
- Upgrading from LDK Node v0.1 is no longer supported if the event queue still contains
Expand All@@ -19,6 +23,13 @@
`Event::PaymentClaimable`.

## Feature and API updates
- `Node::list_payments` is now paginated: it takes an optional `PageToken` and returns a
`PaymentDetailsPage` holding one page of payments, ordered from most recently created to
least recently created, plus the token for the next page. Ordering and page tokens come
from the configured storage backend, and token lifetime follows that backend's guarantees.
This replaces the previous unpaginated `Node::list_payments`, and
`Node::list_payments_with_filter` has been removed; filter the returned pages instead.
- `Node::payment` now returns a `Result`, as retrieving a payment may fail.
Comment thread
benthecarman marked this conversation as resolved.
- The Bitcoin Core RPC and REST chain-source builder methods now accept an optional
`wallet_rescan_from_height` argument. Passing a height lets fresh wallets rescan from a known
birthday block instead of checkpointing at the current tip, which is useful when restoring a
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -301,8 +301,12 @@ class LibraryTest {
assert(paymentReceivedEvent is Event.PaymentReceived)
node2.eventHandled()

assert(node1.listPayments().size == 3)
assert(node2.listPayments().size == 2)
assert(node1.listPayments(null).payments.size == 3)
assert(node2.listPayments(null).payments.size == 2)

// A page token has to survive a round trip through a string, so that an app can persist
// one and resume paginating after a restart.
assert(PageToken("some-page-token").toString() == "some-page-token")

node2.closeChannel(userChannelId, nodeId1)

Expand Down
9 changes: 8 additions & 1 deletion bindings/ldk_node.udl
Original file line numberDiff line numberDiff line change
Expand Up@@ -147,11 +147,13 @@ interface Node {
void update_channel_config([ByRef]UserChannelId user_channel_id, PublicKey counterparty_node_id, ChannelConfig channel_config);
[Throws=NodeError]
void sync_wallets();
[Throws=NodeError]
PaymentDetails? payment([ByRef]PaymentId payment_id);
[Throws=NodeError]
void remove_payment([ByRef]PaymentId payment_id);
BalanceDetails list_balances();
sequence<PaymentDetails> list_payments();
[Throws=NodeError]
PaymentDetailsPage list_payments(PageToken? page_token);
sequence<PeerDetails> list_peers();
sequence<ChannelDetails> list_channels();
NetworkGraph network_graph();
Expand DownExpand Up@@ -236,6 +238,7 @@ enum NodeError {
"InvalidDateTime",
"InvalidFeeRate",
"InvalidScriptPubKey",
"InvalidPageToken",
"DuplicatePayment",
"UnsupportedCurrency",
"InsufficientFunds",
Expand DownExpand Up@@ -279,6 +282,10 @@ enum PaymentFailureReason {

typedef dictionary PaymentDetails;

typedef dictionary PaymentDetailsPage;

typedef interface PageToken;

[Remote]
dictionary RouteParametersConfig {
u64? max_total_routing_fee_msat;
Expand Down
21 changes: 16 additions & 5 deletions src/builder.rs
Original file line numberDiff line numberDiff line change
Expand Up@@ -50,18 +50,20 @@ use crate::config::{
default_user_config, may_announce_channel, AnnounceError, AsyncPaymentsRole,
BitcoindRestClientConfig, Config, ElectrumSyncConfig, EsploraSyncConfig, HRNResolverConfig,
TorConfig, DEFAULT_ESPLORA_SERVER_URL, DEFAULT_LOG_FILENAME, DEFAULT_LOG_LEVEL,
DEFAULT_MAX_PROBE_AMOUNT_MSAT, DEFAULT_MIN_PROBE_AMOUNT_MSAT,
DEFAULT_MAX_PROBE_AMOUNT_MSAT, DEFAULT_MIN_PROBE_AMOUNT_MSAT, PAYMENT_CACHE_CAPACITY,
PAYMENT_CACHE_WARMUP_COUNT,
};
use crate::connection::ConnectionManager;
use crate::data_store::{KeepAllEntries, KeepLeastRecentlyUsed};
use crate::entropy::NodeEntropy;
use crate::event::EventQueue;
use crate::fee_estimator::OnchainFeeEstimator;
use crate::gossip::GossipSource;
use crate::io::sqlite_store::SqliteStore;
use crate::io::utils::{
open_or_migrate_fs_store, read_all_objects, read_event_queue,
read_external_pathfinding_scores_from_cache, read_network_graph, read_node_metrics,
read_output_sweeper, read_peer_info, read_scorer,
read_external_pathfinding_scores_from_cache, read_n_objects, read_network_graph,
read_node_metrics, read_output_sweeper, read_peer_info, read_scorer,
};
use crate::io::vss_store::VssStoreBuilder;
use crate::io::{
Expand DownExpand Up@@ -1458,10 +1460,11 @@ fn build_with_store_internal(
let (payment_store_res, node_metris_res, pending_payment_store_res, address_pool_res) = runtime
.block_on(async move {
tokio::join!(
read_all_objects(
read_n_objects(
&*kv_store_ref,
PAYMENT_INFO_PERSISTENCE_PRIMARY_NAMESPACE,
PAYMENT_INFO_PERSISTENCE_SECONDARY_NAMESPACE,
PAYMENT_CACHE_WARMUP_COUNT,
Arc::clone(&logger_ref),
),
read_node_metrics(&*kv_store_ref, Arc::clone(&logger_ref)),
Expand DownExpand Up@@ -1490,7 +1493,11 @@ fn build_with_store_internal(

let payment_store = match payment_store_res {
Ok(payments) => Arc::new(PaymentStore::new(
payments,
// The read hands us the newest payments first, while the cache treats the objects it
// is seeded with as increasingly recently used. Reverse them, so that the newest
// payment is the last one to be evicted rather than the first.
payments.into_iter().rev().collect(),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

LRU seeding order is guarded only by a commentsrc/builder.rs:1495read_n_objects returns newest-first, but DataStore::new treats the last seed element as most-recently-used, so the builder must .rev(). Dropping that call would silently evict the newest payments first with no test to catch it.

KeepLeastRecentlyUsed::new(PAYMENT_CACHE_CAPACITY),
PAYMENT_INFO_PERSISTENCE_PRIMARY_NAMESPACE.to_string(),
PAYMENT_INFO_PERSISTENCE_SECONDARY_NAMESPACE.to_string(),
Arc::clone(&kv_store),
Expand DownExpand Up@@ -1745,8 +1752,12 @@ fn build_with_store_internal(
};

let pending_payment_store = match pending_payment_store_res {
// NOTE: This store must keep all its entries in memory: the wallet scans it in full on
// every chain tip change and to resolve replaced transactions. It stays bounded anyway,
// as entries are removed once a payment is no longer pending.
Ok(pending_payments) => Arc::new(PendingPaymentStore::new(
pending_payments,
KeepAllEntries,
PENDING_PAYMENT_INFO_PERSISTENCE_PRIMARY_NAMESPACE.to_string(),
PENDING_PAYMENT_INFO_PERSISTENCE_SECONDARY_NAMESPACE.to_string(),
Arc::clone(&kv_store),
Expand Down
17 changes: 17 additions & 0 deletions src/config.rs
Original file line numberDiff line numberDiff line change
Expand Up@@ -8,6 +8,7 @@
//! Objects for configuring the node.

use std::fmt;
use std::num::NonZeroUsize;
use std::str::FromStr;
use std::time::Duration;

Expand DownExpand Up@@ -48,6 +49,22 @@ pub(crate) const DEFAULT_FEE_RATE_CACHE_UPDATE_TIMEOUT_SECS: u64 = 10;
// The default timeout after which we abort a transaction broadcast operation.
pub(crate) const DEFAULT_TX_BROADCAST_TIMEOUT_SECS: u64 = 10;

// The number of payments we keep in memory.
//
// The payment history grows for the lifetime of a node, so we cache only the most recently used
// payments and read the rest back from the store as they are needed. At roughly 400 to 500 bytes
// per cached payment, this bounds the payment store's share of memory at well under a megabyte,
// while still covering the recent payments a node actually works with.
pub(crate) const PAYMENT_CACHE_CAPACITY: NonZeroUsize = NonZeroUsize::new(1000).unwrap();

// The number of payments we read into the cache when starting up.
//
// This matches the built-in storage backends' page size, so warming the cache costs a single page
// listing and one batch of reads. Immediately after startup, a first-page `Node::list_payments`
// call reads only its keys from storage; the payment bodies come from the cache. Later activity
// may displace those entries.
pub(crate) const PAYMENT_CACHE_WARMUP_COUNT: NonZeroUsize = NonZeroUsize::new(50).unwrap();

@benthecarmanbenthecarmanAug 13, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

we have this 50 as a const somewhere, can we use that?

Copy link
Copy Markdown
CollaboratorAuthor

Choose a reason for hiding this comment

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

Hmm, we have some consts that happen to be 50, but they are somewhat orthogonal? Which one do you have in mind exactly?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

well the comment highlights that its equal ot the page size. We have separate consts for posgres and sqlite page size. Should unify those and use that const here

@tnulltnullAug 17, 2026

Copy link
Copy Markdown
CollaboratorAuthor

Choose a reason for hiding this comment

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

Hmm, not sure? It's basically a coincidence that it's a page size, though likely a multiple of page size makes sense? Not sure if we want to couple the concepts strongly here, would also be a layer violation somewhat, and one additional thing we'd need to disentangle when upstreaming VSS/Postgres stores?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

I mostly say this because the comment says

This matches the built-in storage backends' page size, so warming the cache costs a single page listing and one batch of reads

So it implies that they are meant to be the same. If its a coincidence / orthogonal i guess that's fine and yeah when we eventually upstream we would have to change anyways.


// The default {Esplora,Electrum} client timeout we're using.
const DEFAULT_PER_REQUEST_TIMEOUT_SECS: u8 = 10;

Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
Skip to content
Merged
11 changes: 11 additions & 0 deletions CHANGELOG.md
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,10 @@
# Pending

## Compatibility Notes
- Migrating between storage backends does not preserve the relative creation order of
pre-existing payments, as the generic KV store migration copies entries in an unspecified
order. Expect the order in which `Node::list_payments` returns pre-existing payments to
change once after such a migration. Payment contents and completeness are unaffected.
- Pending JIT-channel payments created before upgrading may fail after upgrade because the
prior LSPS2 fee-limit state stored in `PaymentKind::Bolt11Jit` is not migrated.
- Upgrading from LDK Node v0.1 is no longer supported if the event queue still contains
Expand All@@ -19,6 +23,13 @@
`Event::PaymentClaimable`.

## Feature and API updates
- `Node::list_payments` is now paginated: it takes an optional `PageToken` and returns a
`PaymentDetailsPage` holding one page of payments, ordered from most recently created to
least recently created, plus the token for the next page. Ordering and page tokens come
from the configured storage backend, and token lifetime follows that backend's guarantees.
This replaces the previous unpaginated `Node::list_payments`, and
`Node::list_payments_with_filter` has been removed; filter the returned pages instead.
- `Node::payment` now returns a `Result`, as retrieving a payment may fail.
Comment thread
benthecarman marked this conversation as resolved.
- The Bitcoin Core RPC and REST chain-source builder methods now accept an optional
`wallet_rescan_from_height` argument. Passing a height lets fresh wallets rescan from a known
birthday block instead of checkpointing at the current tip, which is useful when restoring a
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -301,8 +301,12 @@ class LibraryTest {
assert(paymentReceivedEvent is Event.PaymentReceived)
node2.eventHandled()

assert(node1.listPayments().size == 3)
assert(node2.listPayments().size == 2)
assert(node1.listPayments(null).payments.size == 3)
assert(node2.listPayments(null).payments.size == 2)

// A page token has to survive a round trip through a string, so that an app can persist
// one and resume paginating after a restart.
assert(PageToken("some-page-token").toString() == "some-page-token")

node2.closeChannel(userChannelId, nodeId1)

Expand Down
9 changes: 8 additions & 1 deletion bindings/ldk_node.udl
Original file line numberDiff line numberDiff line change
Expand Up@@ -147,11 +147,13 @@ interface Node {
void update_channel_config([ByRef]UserChannelId user_channel_id, PublicKey counterparty_node_id, ChannelConfig channel_config);
[Throws=NodeError]
void sync_wallets();
[Throws=NodeError]
PaymentDetails? payment([ByRef]PaymentId payment_id);
[Throws=NodeError]
void remove_payment([ByRef]PaymentId payment_id);
BalanceDetails list_balances();
sequence<PaymentDetails> list_payments();
[Throws=NodeError]
PaymentDetailsPage list_payments(PageToken? page_token);
sequence<PeerDetails> list_peers();
sequence<ChannelDetails> list_channels();
NetworkGraph network_graph();
Expand DownExpand Up@@ -236,6 +238,7 @@ enum NodeError {
"InvalidDateTime",
"InvalidFeeRate",
"InvalidScriptPubKey",
"InvalidPageToken",
"DuplicatePayment",
"UnsupportedCurrency",
"InsufficientFunds",
Expand DownExpand Up@@ -279,6 +282,10 @@ enum PaymentFailureReason {

typedef dictionary PaymentDetails;

typedef dictionary PaymentDetailsPage;

typedef interface PageToken;

[Remote]
dictionary RouteParametersConfig {
u64? max_total_routing_fee_msat;
Expand Down
21 changes: 16 additions & 5 deletions src/builder.rs
Original file line numberDiff line numberDiff line change
Expand Up@@ -50,18 +50,20 @@ use crate::config::{
default_user_config, may_announce_channel, AnnounceError, AsyncPaymentsRole,
BitcoindRestClientConfig, Config, ElectrumSyncConfig, EsploraSyncConfig, HRNResolverConfig,
TorConfig, DEFAULT_ESPLORA_SERVER_URL, DEFAULT_LOG_FILENAME, DEFAULT_LOG_LEVEL,
DEFAULT_MAX_PROBE_AMOUNT_MSAT, DEFAULT_MIN_PROBE_AMOUNT_MSAT,
DEFAULT_MAX_PROBE_AMOUNT_MSAT, DEFAULT_MIN_PROBE_AMOUNT_MSAT, PAYMENT_CACHE_CAPACITY,
PAYMENT_CACHE_WARMUP_COUNT,
};
use crate::connection::ConnectionManager;
use crate::data_store::{KeepAllEntries, KeepLeastRecentlyUsed};
use crate::entropy::NodeEntropy;
use crate::event::EventQueue;
use crate::fee_estimator::OnchainFeeEstimator;
use crate::gossip::GossipSource;
use crate::io::sqlite_store::SqliteStore;
use crate::io::utils::{
open_or_migrate_fs_store, read_all_objects, read_event_queue,
read_external_pathfinding_scores_from_cache, read_network_graph, read_node_metrics,
read_output_sweeper, read_peer_info, read_scorer,
read_external_pathfinding_scores_from_cache, read_n_objects, read_network_graph,
read_node_metrics, read_output_sweeper, read_peer_info, read_scorer,
};
use crate::io::vss_store::VssStoreBuilder;
use crate::io::{
Expand DownExpand Up@@ -1458,10 +1460,11 @@ fn build_with_store_internal(
let (payment_store_res, node_metris_res, pending_payment_store_res, address_pool_res) = runtime
.block_on(async move {
tokio::join!(
read_all_objects(
read_n_objects(
&*kv_store_ref,
PAYMENT_INFO_PERSISTENCE_PRIMARY_NAMESPACE,
PAYMENT_INFO_PERSISTENCE_SECONDARY_NAMESPACE,
PAYMENT_CACHE_WARMUP_COUNT,
Arc::clone(&logger_ref),
),
read_node_metrics(&*kv_store_ref, Arc::clone(&logger_ref)),
Expand DownExpand Up@@ -1490,7 +1493,11 @@ fn build_with_store_internal(

let payment_store = match payment_store_res {
Ok(payments) => Arc::new(PaymentStore::new(
payments,
// The read hands us the newest payments first, while the cache treats the objects it
// is seeded with as increasingly recently used. Reverse them, so that the newest
// payment is the last one to be evicted rather than the first.
payments.into_iter().rev().collect(),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

LRU seeding order is guarded only by a commentsrc/builder.rs:1495read_n_objects returns newest-first, but DataStore::new treats the last seed element as most-recently-used, so the builder must .rev(). Dropping that call would silently evict the newest payments first with no test to catch it.

KeepLeastRecentlyUsed::new(PAYMENT_CACHE_CAPACITY),
PAYMENT_INFO_PERSISTENCE_PRIMARY_NAMESPACE.to_string(),
PAYMENT_INFO_PERSISTENCE_SECONDARY_NAMESPACE.to_string(),
Arc::clone(&kv_store),
Expand DownExpand Up@@ -1745,8 +1752,12 @@ fn build_with_store_internal(
};

let pending_payment_store = match pending_payment_store_res {
// NOTE: This store must keep all its entries in memory: the wallet scans it in full on
// every chain tip change and to resolve replaced transactions. It stays bounded anyway,
// as entries are removed once a payment is no longer pending.
Ok(pending_payments) => Arc::new(PendingPaymentStore::new(
pending_payments,
KeepAllEntries,
PENDING_PAYMENT_INFO_PERSISTENCE_PRIMARY_NAMESPACE.to_string(),
PENDING_PAYMENT_INFO_PERSISTENCE_SECONDARY_NAMESPACE.to_string(),
Arc::clone(&kv_store),
Expand Down
17 changes: 17 additions & 0 deletions src/config.rs
Original file line numberDiff line numberDiff line change
Expand Up@@ -8,6 +8,7 @@
//! Objects for configuring the node.

use std::fmt;
use std::num::NonZeroUsize;
use std::str::FromStr;
use std::time::Duration;

Expand DownExpand Up@@ -48,6 +49,22 @@ pub(crate) const DEFAULT_FEE_RATE_CACHE_UPDATE_TIMEOUT_SECS: u64 = 10;
// The default timeout after which we abort a transaction broadcast operation.
pub(crate) const DEFAULT_TX_BROADCAST_TIMEOUT_SECS: u64 = 10;

// The number of payments we keep in memory.
//
// The payment history grows for the lifetime of a node, so we cache only the most recently used
// payments and read the rest back from the store as they are needed. At roughly 400 to 500 bytes
// per cached payment, this bounds the payment store's share of memory at well under a megabyte,
// while still covering the recent payments a node actually works with.
pub(crate) const PAYMENT_CACHE_CAPACITY: NonZeroUsize = NonZeroUsize::new(1000).unwrap();

// The number of payments we read into the cache when starting up.
//
// This matches the built-in storage backends' page size, so warming the cache costs a single page
// listing and one batch of reads. Immediately after startup, a first-page `Node::list_payments`
// call reads only its keys from storage; the payment bodies come from the cache. Later activity
// may displace those entries.
pub(crate) const PAYMENT_CACHE_WARMUP_COUNT: NonZeroUsize = NonZeroUsize::new(50).unwrap();

@benthecarmanbenthecarmanAug 13, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

we have this 50 as a const somewhere, can we use that?

Copy link
Copy Markdown
CollaboratorAuthor

Choose a reason for hiding this comment

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

Hmm, we have some consts that happen to be 50, but they are somewhat orthogonal? Which one do you have in mind exactly?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

well the comment highlights that its equal ot the page size. We have separate consts for posgres and sqlite page size. Should unify those and use that const here

@tnulltnullAug 17, 2026

Copy link
Copy Markdown
CollaboratorAuthor

Choose a reason for hiding this comment

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

Hmm, not sure? It's basically a coincidence that it's a page size, though likely a multiple of page size makes sense? Not sure if we want to couple the concepts strongly here, would also be a layer violation somewhat, and one additional thing we'd need to disentangle when upstreaming VSS/Postgres stores?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

I mostly say this because the comment says

This matches the built-in storage backends' page size, so warming the cache costs a single page listing and one batch of reads

So it implies that they are meant to be the same. If its a coincidence / orthogonal i guess that's fine and yeah when we eventually upstream we would have to change anyways.


// The default {Esplora,Electrum} client timeout we're using.
const DEFAULT_PER_REQUEST_TIMEOUT_SECS: u8 = 10;

Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
11 changes: 11 additions & 0 deletions CHANGELOG.md
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,10 @@
# Pending

## Compatibility Notes
- Migrating between storage backends does not preserve the relative creation order of
pre-existing payments, as the generic KV store migration copies entries in an unspecified
order. Expect the order in which `Node::list_payments` returns pre-existing payments to
change once after such a migration. Payment contents and completeness are unaffected.
- Pending JIT-channel payments created before upgrading may fail after upgrade because the
prior LSPS2 fee-limit state stored in `PaymentKind::Bolt11Jit` is not migrated.
- Upgrading from LDK Node v0.1 is no longer supported if the event queue still contains
Expand All@@ -19,6 +23,13 @@
`Event::PaymentClaimable`.

## Feature and API updates
- `Node::list_payments` is now paginated: it takes an optional `PageToken` and returns a
`PaymentDetailsPage` holding one page of payments, ordered from most recently created to
least recently created, plus the token for the next page. Ordering and page tokens come
from the configured storage backend, and token lifetime follows that backend's guarantees.
This replaces the previous unpaginated `Node::list_payments`, and
`Node::list_payments_with_filter` has been removed; filter the returned pages instead.
- `Node::payment` now returns a `Result`, as retrieving a payment may fail.
Comment thread
benthecarman marked this conversation as resolved.
- The Bitcoin Core RPC and REST chain-source builder methods now accept an optional
`wallet_rescan_from_height` argument. Passing a height lets fresh wallets rescan from a known
birthday block instead of checkpointing at the current tip, which is useful when restoring a
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -301,8 +301,12 @@ class LibraryTest {
assert(paymentReceivedEvent is Event.PaymentReceived)
node2.eventHandled()

assert(node1.listPayments().size == 3)
assert(node2.listPayments().size == 2)
assert(node1.listPayments(null).payments.size == 3)
assert(node2.listPayments(null).payments.size == 2)

// A page token has to survive a round trip through a string, so that an app can persist
// one and resume paginating after a restart.
assert(PageToken("some-page-token").toString() == "some-page-token")

node2.closeChannel(userChannelId, nodeId1)

Expand Down
9 changes: 8 additions & 1 deletion bindings/ldk_node.udl
Original file line numberDiff line numberDiff line change
Expand Up@@ -147,11 +147,13 @@ interface Node {
void update_channel_config([ByRef]UserChannelId user_channel_id, PublicKey counterparty_node_id, ChannelConfig channel_config);
[Throws=NodeError]
void sync_wallets();
[Throws=NodeError]
PaymentDetails? payment([ByRef]PaymentId payment_id);
[Throws=NodeError]
void remove_payment([ByRef]PaymentId payment_id);
BalanceDetails list_balances();
sequence<PaymentDetails> list_payments();
[Throws=NodeError]
PaymentDetailsPage list_payments(PageToken? page_token);
sequence<PeerDetails> list_peers();
sequence<ChannelDetails> list_channels();
NetworkGraph network_graph();
Expand DownExpand Up@@ -236,6 +238,7 @@ enum NodeError {
"InvalidDateTime",
"InvalidFeeRate",
"InvalidScriptPubKey",
"InvalidPageToken",
"DuplicatePayment",
"UnsupportedCurrency",
"InsufficientFunds",
Expand DownExpand Up@@ -279,6 +282,10 @@ enum PaymentFailureReason {

typedef dictionary PaymentDetails;

typedef dictionary PaymentDetailsPage;

typedef interface PageToken;

[Remote]
dictionary RouteParametersConfig {
u64? max_total_routing_fee_msat;
Expand Down
21 changes: 16 additions & 5 deletions src/builder.rs
Original file line numberDiff line numberDiff line change
Expand Up@@ -50,18 +50,20 @@ use crate::config::{
default_user_config, may_announce_channel, AnnounceError, AsyncPaymentsRole,
BitcoindRestClientConfig, Config, ElectrumSyncConfig, EsploraSyncConfig, HRNResolverConfig,
TorConfig, DEFAULT_ESPLORA_SERVER_URL, DEFAULT_LOG_FILENAME, DEFAULT_LOG_LEVEL,
DEFAULT_MAX_PROBE_AMOUNT_MSAT, DEFAULT_MIN_PROBE_AMOUNT_MSAT,
DEFAULT_MAX_PROBE_AMOUNT_MSAT, DEFAULT_MIN_PROBE_AMOUNT_MSAT, PAYMENT_CACHE_CAPACITY,
PAYMENT_CACHE_WARMUP_COUNT,
};
use crate::connection::ConnectionManager;
use crate::data_store::{KeepAllEntries, KeepLeastRecentlyUsed};
use crate::entropy::NodeEntropy;
use crate::event::EventQueue;
use crate::fee_estimator::OnchainFeeEstimator;
use crate::gossip::GossipSource;
use crate::io::sqlite_store::SqliteStore;
use crate::io::utils::{
open_or_migrate_fs_store, read_all_objects, read_event_queue,
read_external_pathfinding_scores_from_cache, read_network_graph, read_node_metrics,
read_output_sweeper, read_peer_info, read_scorer,
read_external_pathfinding_scores_from_cache, read_n_objects, read_network_graph,
read_node_metrics, read_output_sweeper, read_peer_info, read_scorer,
};
use crate::io::vss_store::VssStoreBuilder;
use crate::io::{
Expand DownExpand Up@@ -1458,10 +1460,11 @@ fn build_with_store_internal(
let (payment_store_res, node_metris_res, pending_payment_store_res, address_pool_res) = runtime
.block_on(async move {
tokio::join!(
read_all_objects(
read_n_objects(
&*kv_store_ref,
PAYMENT_INFO_PERSISTENCE_PRIMARY_NAMESPACE,
PAYMENT_INFO_PERSISTENCE_SECONDARY_NAMESPACE,
PAYMENT_CACHE_WARMUP_COUNT,
Arc::clone(&logger_ref),
),
read_node_metrics(&*kv_store_ref, Arc::clone(&logger_ref)),
Expand DownExpand Up@@ -1490,7 +1493,11 @@ fn build_with_store_internal(

let payment_store = match payment_store_res {
Ok(payments) => Arc::new(PaymentStore::new(
payments,
// The read hands us the newest payments first, while the cache treats the objects it
// is seeded with as increasingly recently used. Reverse them, so that the newest
// payment is the last one to be evicted rather than the first.
payments.into_iter().rev().collect(),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

LRU seeding order is guarded only by a commentsrc/builder.rs:1495read_n_objects returns newest-first, but DataStore::new treats the last seed element as most-recently-used, so the builder must .rev(). Dropping that call would silently evict the newest payments first with no test to catch it.

KeepLeastRecentlyUsed::new(PAYMENT_CACHE_CAPACITY),
PAYMENT_INFO_PERSISTENCE_PRIMARY_NAMESPACE.to_string(),
PAYMENT_INFO_PERSISTENCE_SECONDARY_NAMESPACE.to_string(),
Arc::clone(&kv_store),
Expand DownExpand Up@@ -1745,8 +1752,12 @@ fn build_with_store_internal(
};

let pending_payment_store = match pending_payment_store_res {
// NOTE: This store must keep all its entries in memory: the wallet scans it in full on
// every chain tip change and to resolve replaced transactions. It stays bounded anyway,
// as entries are removed once a payment is no longer pending.
Ok(pending_payments) => Arc::new(PendingPaymentStore::new(
pending_payments,
KeepAllEntries,
PENDING_PAYMENT_INFO_PERSISTENCE_PRIMARY_NAMESPACE.to_string(),
PENDING_PAYMENT_INFO_PERSISTENCE_SECONDARY_NAMESPACE.to_string(),
Arc::clone(&kv_store),
Expand Down
17 changes: 17 additions & 0 deletions src/config.rs
Original file line numberDiff line numberDiff line change
Expand Up@@ -8,6 +8,7 @@
//! Objects for configuring the node.

use std::fmt;
use std::num::NonZeroUsize;
use std::str::FromStr;
use std::time::Duration;

Expand DownExpand Up@@ -48,6 +49,22 @@ pub(crate) const DEFAULT_FEE_RATE_CACHE_UPDATE_TIMEOUT_SECS: u64 = 10;
// The default timeout after which we abort a transaction broadcast operation.
pub(crate) const DEFAULT_TX_BROADCAST_TIMEOUT_SECS: u64 = 10;

// The number of payments we keep in memory.
//
// The payment history grows for the lifetime of a node, so we cache only the most recently used
// payments and read the rest back from the store as they are needed. At roughly 400 to 500 bytes
// per cached payment, this bounds the payment store's share of memory at well under a megabyte,
// while still covering the recent payments a node actually works with.
pub(crate) const PAYMENT_CACHE_CAPACITY: NonZeroUsize = NonZeroUsize::new(1000).unwrap();

// The number of payments we read into the cache when starting up.
//
// This matches the built-in storage backends' page size, so warming the cache costs a single page
// listing and one batch of reads. Immediately after startup, a first-page `Node::list_payments`
// call reads only its keys from storage; the payment bodies come from the cache. Later activity
// may displace those entries.
pub(crate) const PAYMENT_CACHE_WARMUP_COUNT: NonZeroUsize = NonZeroUsize::new(50).unwrap();

@benthecarmanbenthecarmanAug 13, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

we have this 50 as a const somewhere, can we use that?

Copy link
Copy Markdown
CollaboratorAuthor

Choose a reason for hiding this comment

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

Hmm, we have some consts that happen to be 50, but they are somewhat orthogonal? Which one do you have in mind exactly?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

well the comment highlights that its equal ot the page size. We have separate consts for posgres and sqlite page size. Should unify those and use that const here

@tnulltnullAug 17, 2026

Copy link
Copy Markdown
CollaboratorAuthor

Choose a reason for hiding this comment

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

Hmm, not sure? It's basically a coincidence that it's a page size, though likely a multiple of page size makes sense? Not sure if we want to couple the concepts strongly here, would also be a layer violation somewhat, and one additional thing we'd need to disentangle when upstreaming VSS/Postgres stores?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

I mostly say this because the comment says

This matches the built-in storage backends' page size, so warming the cache costs a single page listing and one batch of reads

So it implies that they are meant to be the same. If its a coincidence / orthogonal i guess that's fine and yeah when we eventually upstream we would have to change anyways.


// The default {Esplora,Electrum} client timeout we're using.
const DEFAULT_PER_REQUEST_TIMEOUT_SECS: u8 = 10;

Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
11 changes: 11 additions & 0 deletions CHANGELOG.md
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,10 @@
# Pending

## Compatibility Notes
- Migrating between storage backends does not preserve the relative creation order of
pre-existing payments, as the generic KV store migration copies entries in an unspecified
order. Expect the order in which `Node::list_payments` returns pre-existing payments to
change once after such a migration. Payment contents and completeness are unaffected.
- Pending JIT-channel payments created before upgrading may fail after upgrade because the
prior LSPS2 fee-limit state stored in `PaymentKind::Bolt11Jit` is not migrated.
- Upgrading from LDK Node v0.1 is no longer supported if the event queue still contains
Expand All@@ -19,6 +23,13 @@
`Event::PaymentClaimable`.

## Feature and API updates
- `Node::list_payments` is now paginated: it takes an optional `PageToken` and returns a
`PaymentDetailsPage` holding one page of payments, ordered from most recently created to
least recently created, plus the token for the next page. Ordering and page tokens come
from the configured storage backend, and token lifetime follows that backend's guarantees.
This replaces the previous unpaginated `Node::list_payments`, and
`Node::list_payments_with_filter` has been removed; filter the returned pages instead.
- `Node::payment` now returns a `Result`, as retrieving a payment may fail.
Comment thread
benthecarman marked this conversation as resolved.
- The Bitcoin Core RPC and REST chain-source builder methods now accept an optional
`wallet_rescan_from_height` argument. Passing a height lets fresh wallets rescan from a known
birthday block instead of checkpointing at the current tip, which is useful when restoring a
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -301,8 +301,12 @@ class LibraryTest {
assert(paymentReceivedEvent is Event.PaymentReceived)
node2.eventHandled()

assert(node1.listPayments().size == 3)
assert(node2.listPayments().size == 2)
assert(node1.listPayments(null).payments.size == 3)
assert(node2.listPayments(null).payments.size == 2)

// A page token has to survive a round trip through a string, so that an app can persist
// one and resume paginating after a restart.
assert(PageToken("some-page-token").toString() == "some-page-token")

node2.closeChannel(userChannelId, nodeId1)

Expand Down
9 changes: 8 additions & 1 deletion bindings/ldk_node.udl
Original file line numberDiff line numberDiff line change
Expand Up@@ -147,11 +147,13 @@ interface Node {
void update_channel_config([ByRef]UserChannelId user_channel_id, PublicKey counterparty_node_id, ChannelConfig channel_config);
[Throws=NodeError]
void sync_wallets();
[Throws=NodeError]
PaymentDetails? payment([ByRef]PaymentId payment_id);
[Throws=NodeError]
void remove_payment([ByRef]PaymentId payment_id);
BalanceDetails list_balances();
sequence<PaymentDetails> list_payments();
[Throws=NodeError]
PaymentDetailsPage list_payments(PageToken? page_token);
sequence<PeerDetails> list_peers();
sequence<ChannelDetails> list_channels();
NetworkGraph network_graph();
Expand DownExpand Up@@ -236,6 +238,7 @@ enum NodeError {
"InvalidDateTime",
"InvalidFeeRate",
"InvalidScriptPubKey",
"InvalidPageToken",
"DuplicatePayment",
"UnsupportedCurrency",
"InsufficientFunds",
Expand DownExpand Up@@ -279,6 +282,10 @@ enum PaymentFailureReason {

typedef dictionary PaymentDetails;

typedef dictionary PaymentDetailsPage;

typedef interface PageToken;

[Remote]
dictionary RouteParametersConfig {
u64? max_total_routing_fee_msat;
Expand Down
21 changes: 16 additions & 5 deletions src/builder.rs
Original file line numberDiff line numberDiff line change
Expand Up@@ -50,18 +50,20 @@ use crate::config::{
default_user_config, may_announce_channel, AnnounceError, AsyncPaymentsRole,
BitcoindRestClientConfig, Config, ElectrumSyncConfig, EsploraSyncConfig, HRNResolverConfig,
TorConfig, DEFAULT_ESPLORA_SERVER_URL, DEFAULT_LOG_FILENAME, DEFAULT_LOG_LEVEL,
DEFAULT_MAX_PROBE_AMOUNT_MSAT, DEFAULT_MIN_PROBE_AMOUNT_MSAT,
DEFAULT_MAX_PROBE_AMOUNT_MSAT, DEFAULT_MIN_PROBE_AMOUNT_MSAT, PAYMENT_CACHE_CAPACITY,
PAYMENT_CACHE_WARMUP_COUNT,
};
use crate::connection::ConnectionManager;
use crate::data_store::{KeepAllEntries, KeepLeastRecentlyUsed};
use crate::entropy::NodeEntropy;
use crate::event::EventQueue;
use crate::fee_estimator::OnchainFeeEstimator;
use crate::gossip::GossipSource;
use crate::io::sqlite_store::SqliteStore;
use crate::io::utils::{
open_or_migrate_fs_store, read_all_objects, read_event_queue,
read_external_pathfinding_scores_from_cache, read_network_graph, read_node_metrics,
read_output_sweeper, read_peer_info, read_scorer,
read_external_pathfinding_scores_from_cache, read_n_objects, read_network_graph,
read_node_metrics, read_output_sweeper, read_peer_info, read_scorer,
};
use crate::io::vss_store::VssStoreBuilder;
use crate::io::{
Expand DownExpand Up@@ -1458,10 +1460,11 @@ fn build_with_store_internal(
let (payment_store_res, node_metris_res, pending_payment_store_res, address_pool_res) = runtime
.block_on(async move {
tokio::join!(
read_all_objects(
read_n_objects(
&*kv_store_ref,
PAYMENT_INFO_PERSISTENCE_PRIMARY_NAMESPACE,
PAYMENT_INFO_PERSISTENCE_SECONDARY_NAMESPACE,
PAYMENT_CACHE_WARMUP_COUNT,
Arc::clone(&logger_ref),
),
read_node_metrics(&*kv_store_ref, Arc::clone(&logger_ref)),
Expand DownExpand Up@@ -1490,7 +1493,11 @@ fn build_with_store_internal(

let payment_store = match payment_store_res {
Ok(payments) => Arc::new(PaymentStore::new(
payments,
// The read hands us the newest payments first, while the cache treats the objects it
// is seeded with as increasingly recently used. Reverse them, so that the newest
// payment is the last one to be evicted rather than the first.
payments.into_iter().rev().collect(),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

LRU seeding order is guarded only by a commentsrc/builder.rs:1495read_n_objects returns newest-first, but DataStore::new treats the last seed element as most-recently-used, so the builder must .rev(). Dropping that call would silently evict the newest payments first with no test to catch it.

KeepLeastRecentlyUsed::new(PAYMENT_CACHE_CAPACITY),
PAYMENT_INFO_PERSISTENCE_PRIMARY_NAMESPACE.to_string(),
PAYMENT_INFO_PERSISTENCE_SECONDARY_NAMESPACE.to_string(),
Arc::clone(&kv_store),
Expand DownExpand Up@@ -1745,8 +1752,12 @@ fn build_with_store_internal(
};

let pending_payment_store = match pending_payment_store_res {
// NOTE: This store must keep all its entries in memory: the wallet scans it in full on
// every chain tip change and to resolve replaced transactions. It stays bounded anyway,
// as entries are removed once a payment is no longer pending.
Ok(pending_payments) => Arc::new(PendingPaymentStore::new(
pending_payments,
KeepAllEntries,
PENDING_PAYMENT_INFO_PERSISTENCE_PRIMARY_NAMESPACE.to_string(),
PENDING_PAYMENT_INFO_PERSISTENCE_SECONDARY_NAMESPACE.to_string(),
Arc::clone(&kv_store),
Expand Down
17 changes: 17 additions & 0 deletions src/config.rs
Original file line numberDiff line numberDiff line change
Expand Up@@ -8,6 +8,7 @@
//! Objects for configuring the node.

use std::fmt;
use std::num::NonZeroUsize;
use std::str::FromStr;
use std::time::Duration;

Expand DownExpand Up@@ -48,6 +49,22 @@ pub(crate) const DEFAULT_FEE_RATE_CACHE_UPDATE_TIMEOUT_SECS: u64 = 10;
// The default timeout after which we abort a transaction broadcast operation.
pub(crate) const DEFAULT_TX_BROADCAST_TIMEOUT_SECS: u64 = 10;

// The number of payments we keep in memory.
//
// The payment history grows for the lifetime of a node, so we cache only the most recently used
// payments and read the rest back from the store as they are needed. At roughly 400 to 500 bytes
// per cached payment, this bounds the payment store's share of memory at well under a megabyte,
// while still covering the recent payments a node actually works with.
pub(crate) const PAYMENT_CACHE_CAPACITY: NonZeroUsize = NonZeroUsize::new(1000).unwrap();

// The number of payments we read into the cache when starting up.
//
// This matches the built-in storage backends' page size, so warming the cache costs a single page
// listing and one batch of reads. Immediately after startup, a first-page `Node::list_payments`
// call reads only its keys from storage; the payment bodies come from the cache. Later activity
// may displace those entries.
pub(crate) const PAYMENT_CACHE_WARMUP_COUNT: NonZeroUsize = NonZeroUsize::new(50).unwrap();

@benthecarmanbenthecarmanAug 13, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

we have this 50 as a const somewhere, can we use that?

Copy link
Copy Markdown
CollaboratorAuthor

Choose a reason for hiding this comment

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

Hmm, we have some consts that happen to be 50, but they are somewhat orthogonal? Which one do you have in mind exactly?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

well the comment highlights that its equal ot the page size. We have separate consts for posgres and sqlite page size. Should unify those and use that const here

@tnulltnullAug 17, 2026

Copy link
Copy Markdown
CollaboratorAuthor

Choose a reason for hiding this comment

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

Hmm, not sure? It's basically a coincidence that it's a page size, though likely a multiple of page size makes sense? Not sure if we want to couple the concepts strongly here, would also be a layer violation somewhat, and one additional thing we'd need to disentangle when upstreaming VSS/Postgres stores?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

I mostly say this because the comment says

This matches the built-in storage backends' page size, so warming the cache costs a single page listing and one batch of reads

So it implies that they are meant to be the same. If its a coincidence / orthogonal i guess that's fine and yeah when we eventually upstream we would have to change anyways.


// The default {Esplora,Electrum} client timeout we're using.
const DEFAULT_PER_REQUEST_TIMEOUT_SECS: u8 = 10;

Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
Skip to content
Merged
11 changes: 11 additions & 0 deletions CHANGELOG.md
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,10 @@
# Pending

## Compatibility Notes
- Migrating between storage backends does not preserve the relative creation order of
pre-existing payments, as the generic KV store migration copies entries in an unspecified
order. Expect the order in which `Node::list_payments` returns pre-existing payments to
change once after such a migration. Payment contents and completeness are unaffected.
- Pending JIT-channel payments created before upgrading may fail after upgrade because the
prior LSPS2 fee-limit state stored in `PaymentKind::Bolt11Jit` is not migrated.
- Upgrading from LDK Node v0.1 is no longer supported if the event queue still contains
Expand All@@ -19,6 +23,13 @@
`Event::PaymentClaimable`.

## Feature and API updates
- `Node::list_payments` is now paginated: it takes an optional `PageToken` and returns a
`PaymentDetailsPage` holding one page of payments, ordered from most recently created to
least recently created, plus the token for the next page. Ordering and page tokens come
from the configured storage backend, and token lifetime follows that backend's guarantees.
This replaces the previous unpaginated `Node::list_payments`, and
`Node::list_payments_with_filter` has been removed; filter the returned pages instead.
- `Node::payment` now returns a `Result`, as retrieving a payment may fail.
Comment thread
benthecarman marked this conversation as resolved.
- The Bitcoin Core RPC and REST chain-source builder methods now accept an optional
`wallet_rescan_from_height` argument. Passing a height lets fresh wallets rescan from a known
birthday block instead of checkpointing at the current tip, which is useful when restoring a
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -301,8 +301,12 @@ class LibraryTest {
assert(paymentReceivedEvent is Event.PaymentReceived)
node2.eventHandled()

assert(node1.listPayments().size == 3)
assert(node2.listPayments().size == 2)
assert(node1.listPayments(null).payments.size == 3)
assert(node2.listPayments(null).payments.size == 2)

// A page token has to survive a round trip through a string, so that an app can persist
// one and resume paginating after a restart.
assert(PageToken("some-page-token").toString() == "some-page-token")

node2.closeChannel(userChannelId, nodeId1)

Expand Down
9 changes: 8 additions & 1 deletion bindings/ldk_node.udl
Original file line numberDiff line numberDiff line change
Expand Up@@ -147,11 +147,13 @@ interface Node {
void update_channel_config([ByRef]UserChannelId user_channel_id, PublicKey counterparty_node_id, ChannelConfig channel_config);
[Throws=NodeError]
void sync_wallets();
[Throws=NodeError]
PaymentDetails? payment([ByRef]PaymentId payment_id);
[Throws=NodeError]
void remove_payment([ByRef]PaymentId payment_id);
BalanceDetails list_balances();
sequence<PaymentDetails> list_payments();
[Throws=NodeError]
PaymentDetailsPage list_payments(PageToken? page_token);
sequence<PeerDetails> list_peers();
sequence<ChannelDetails> list_channels();
NetworkGraph network_graph();
Expand DownExpand Up@@ -236,6 +238,7 @@ enum NodeError {
"InvalidDateTime",
"InvalidFeeRate",
"InvalidScriptPubKey",
"InvalidPageToken",
"DuplicatePayment",
"UnsupportedCurrency",
"InsufficientFunds",
Expand DownExpand Up@@ -279,6 +282,10 @@ enum PaymentFailureReason {

typedef dictionary PaymentDetails;

typedef dictionary PaymentDetailsPage;

typedef interface PageToken;

[Remote]
dictionary RouteParametersConfig {
u64? max_total_routing_fee_msat;
Expand Down
21 changes: 16 additions & 5 deletions src/builder.rs
Original file line numberDiff line numberDiff line change
Expand Up@@ -50,18 +50,20 @@ use crate::config::{
default_user_config, may_announce_channel, AnnounceError, AsyncPaymentsRole,
BitcoindRestClientConfig, Config, ElectrumSyncConfig, EsploraSyncConfig, HRNResolverConfig,
TorConfig, DEFAULT_ESPLORA_SERVER_URL, DEFAULT_LOG_FILENAME, DEFAULT_LOG_LEVEL,
DEFAULT_MAX_PROBE_AMOUNT_MSAT, DEFAULT_MIN_PROBE_AMOUNT_MSAT,
DEFAULT_MAX_PROBE_AMOUNT_MSAT, DEFAULT_MIN_PROBE_AMOUNT_MSAT, PAYMENT_CACHE_CAPACITY,
PAYMENT_CACHE_WARMUP_COUNT,
};
use crate::connection::ConnectionManager;
use crate::data_store::{KeepAllEntries, KeepLeastRecentlyUsed};
use crate::entropy::NodeEntropy;
use crate::event::EventQueue;
use crate::fee_estimator::OnchainFeeEstimator;
use crate::gossip::GossipSource;
use crate::io::sqlite_store::SqliteStore;
use crate::io::utils::{
open_or_migrate_fs_store, read_all_objects, read_event_queue,
read_external_pathfinding_scores_from_cache, read_network_graph, read_node_metrics,
read_output_sweeper, read_peer_info, read_scorer,
read_external_pathfinding_scores_from_cache, read_n_objects, read_network_graph,
read_node_metrics, read_output_sweeper, read_peer_info, read_scorer,
};
use crate::io::vss_store::VssStoreBuilder;
use crate::io::{
Expand DownExpand Up@@ -1458,10 +1460,11 @@ fn build_with_store_internal(
let (payment_store_res, node_metris_res, pending_payment_store_res, address_pool_res) = runtime
.block_on(async move {
tokio::join!(
read_all_objects(
read_n_objects(
&*kv_store_ref,
PAYMENT_INFO_PERSISTENCE_PRIMARY_NAMESPACE,
PAYMENT_INFO_PERSISTENCE_SECONDARY_NAMESPACE,
PAYMENT_CACHE_WARMUP_COUNT,
Arc::clone(&logger_ref),
),
read_node_metrics(&*kv_store_ref, Arc::clone(&logger_ref)),
Expand DownExpand Up@@ -1490,7 +1493,11 @@ fn build_with_store_internal(

let payment_store = match payment_store_res {
Ok(payments) => Arc::new(PaymentStore::new(
payments,
// The read hands us the newest payments first, while the cache treats the objects it
// is seeded with as increasingly recently used. Reverse them, so that the newest
// payment is the last one to be evicted rather than the first.
payments.into_iter().rev().collect(),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

LRU seeding order is guarded only by a commentsrc/builder.rs:1495read_n_objects returns newest-first, but DataStore::new treats the last seed element as most-recently-used, so the builder must .rev(). Dropping that call would silently evict the newest payments first with no test to catch it.

KeepLeastRecentlyUsed::new(PAYMENT_CACHE_CAPACITY),
PAYMENT_INFO_PERSISTENCE_PRIMARY_NAMESPACE.to_string(),
PAYMENT_INFO_PERSISTENCE_SECONDARY_NAMESPACE.to_string(),
Arc::clone(&kv_store),
Expand DownExpand Up@@ -1745,8 +1752,12 @@ fn build_with_store_internal(
};

let pending_payment_store = match pending_payment_store_res {
// NOTE: This store must keep all its entries in memory: the wallet scans it in full on
// every chain tip change and to resolve replaced transactions. It stays bounded anyway,
// as entries are removed once a payment is no longer pending.
Ok(pending_payments) => Arc::new(PendingPaymentStore::new(
pending_payments,
KeepAllEntries,
PENDING_PAYMENT_INFO_PERSISTENCE_PRIMARY_NAMESPACE.to_string(),
PENDING_PAYMENT_INFO_PERSISTENCE_SECONDARY_NAMESPACE.to_string(),
Arc::clone(&kv_store),
Expand Down
17 changes: 17 additions & 0 deletions src/config.rs
Original file line numberDiff line numberDiff line change
Expand Up@@ -8,6 +8,7 @@
//! Objects for configuring the node.

use std::fmt;
use std::num::NonZeroUsize;
use std::str::FromStr;
use std::time::Duration;

Expand DownExpand Up@@ -48,6 +49,22 @@ pub(crate) const DEFAULT_FEE_RATE_CACHE_UPDATE_TIMEOUT_SECS: u64 = 10;
// The default timeout after which we abort a transaction broadcast operation.
pub(crate) const DEFAULT_TX_BROADCAST_TIMEOUT_SECS: u64 = 10;

// The number of payments we keep in memory.
//
// The payment history grows for the lifetime of a node, so we cache only the most recently used
// payments and read the rest back from the store as they are needed. At roughly 400 to 500 bytes
// per cached payment, this bounds the payment store's share of memory at well under a megabyte,
// while still covering the recent payments a node actually works with.
pub(crate) const PAYMENT_CACHE_CAPACITY: NonZeroUsize = NonZeroUsize::new(1000).unwrap();

// The number of payments we read into the cache when starting up.
//
// This matches the built-in storage backends' page size, so warming the cache costs a single page
// listing and one batch of reads. Immediately after startup, a first-page `Node::list_payments`
// call reads only its keys from storage; the payment bodies come from the cache. Later activity
// may displace those entries.
pub(crate) const PAYMENT_CACHE_WARMUP_COUNT: NonZeroUsize = NonZeroUsize::new(50).unwrap();

@benthecarmanbenthecarmanAug 13, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

we have this 50 as a const somewhere, can we use that?

Copy link
Copy Markdown
CollaboratorAuthor

Choose a reason for hiding this comment

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

Hmm, we have some consts that happen to be 50, but they are somewhat orthogonal? Which one do you have in mind exactly?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

well the comment highlights that its equal ot the page size. We have separate consts for posgres and sqlite page size. Should unify those and use that const here

@tnulltnullAug 17, 2026

Copy link
Copy Markdown
CollaboratorAuthor

Choose a reason for hiding this comment

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

Hmm, not sure? It's basically a coincidence that it's a page size, though likely a multiple of page size makes sense? Not sure if we want to couple the concepts strongly here, would also be a layer violation somewhat, and one additional thing we'd need to disentangle when upstreaming VSS/Postgres stores?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

I mostly say this because the comment says

This matches the built-in storage backends' page size, so warming the cache costs a single page listing and one batch of reads

So it implies that they are meant to be the same. If its a coincidence / orthogonal i guess that's fine and yeah when we eventually upstream we would have to change anyways.


// The default {Esplora,Electrum} client timeout we're using.
const DEFAULT_PER_REQUEST_TIMEOUT_SECS: u8 = 10;

Expand Down
Loading
Loading