Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -3,6 +3,8 @@
## Compatibility Notes
- 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.
- Users of the VSS storage backend must upgrade their VSS server to at least version
`v0.1.0-alpha.0` before upgrading LDK Node.

# 0.7.0 - Dec. 3, 2025
This seventh minor release introduces numerous new features, bug fixes, and API improvements. In particular, it adds support for channel Splicing, Async Payments, as well as sourcing chain data from a Bitcoin Core REST backend.
Expand Down
2 changes: 1 addition & 1 deletion Cargo.toml
Original file line numberDiff line numberDiff line change
Expand Up@@ -82,7 +82,7 @@ async-trait = { version = "0.1", default-features = false }
tokio-postgres = { version = "0.7", default-features = false, features = ["runtime"], optional = true }
native-tls = { version = "0.2", default-features = false, optional = true }
postgres-native-tls = { version = "0.5", default-features = false, features = ["runtime"], optional = true }
vss-client = { package = "vss-client-ng", version = "0.5" }
vss-client = { package = "vss-client-ng", version = "0.6" }

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Given this version introduced new error types (namely version mismatch, but can't recall if there are others), we'll need to update our retry policy to not retry if we hit a version mismatch, as it's unrecoverable.

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

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

Fixed. Looks like it would have failed before but now has better and more explicit error message

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

No, not what I meant: we need to update

fn retry_policy() -> CustomRetryPolicy{

above to skip_retry_on_error for VSSVersionMismatchError.

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

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

Ah, added

prost = { version = "0.11.6", default-features = false}
#bitcoin-payment-instructions = { version = "0.6" }
bitcoin-payment-instructions = { git = "https://github.com/tnull/bitcoin-payment-instructions", rev = "ff09ce9401afa448549a8f101172700bcd14d7bb" }
Expand Down
246 changes: 192 additions & 54 deletions src/io/vss_store.rs
Original file line numberDiff line numberDiff line change
Expand Up@@ -24,7 +24,7 @@ use bitcoin::Network;
use lightning::impl_writeable_tlv_based_enum;
use lightning::io::{self, Error, ErrorKind};
use lightning::sign::{EntropySource as LdkEntropySource, RandomBytes};
use lightning::util::persist::KVStore;
use lightning::util::persist::{KVStore, PageToken, PaginatedKVStore, PaginatedListResponse};
use lightning::util::ser::{Readable, Writeable};
use prost::Message;
use vss_client::client::VssClient;
Expand DownExpand Up@@ -70,6 +70,8 @@ impl_writeable_tlv_based_enum!(VssSchemaVersion,
(1, V1) => {},
);

const PAGE_SIZE: i32 = 50;

const VSS_HARDENED_CHILD_INDEX: u32 = 877;
const VSS_SIGS_AUTH_HARDENED_CHILD_INDEX: u32 = 139;
const VSS_SCHEMA_VERSION_KEY: &str = "vss_schema_version";
Expand DownExpand Up@@ -293,6 +295,32 @@ impl KVStore for VssStore {
}
}

impl PaginatedKVStore for VssStore {
fn list_paginated(
&self, primary_namespace: &str, secondary_namespace: &str, page_token: Option<PageToken>,
) -> impl Future<Output = Result<PaginatedListResponse, io::Error>> + 'static + Send {
let primary_namespace = primary_namespace.to_string();
let secondary_namespace = secondary_namespace.to_string();
let inner = Arc::clone(&self.inner);
let runtime = self.internal_runtime();
async move {
let task = runtime.spawn(async move {
inner
.list_paginated_internal(
&inner.async_client,
primary_namespace,
secondary_namespace,
page_token,
)
.await
});
task.await.map_err(|e| {
io::Error::new(io::ErrorKind::Other, format!("VSS runtime task failed: {}", e))
})?
}
}
}

impl Drop for VssStore {
fn drop(&mut self) {
if let Some(runtime) = self.internal_runtime.take() {
Expand DownExpand Up@@ -391,35 +419,33 @@ impl VssStoreInner {
}
}

async fn list_all_keys(
async fn list_keys(
&self, client: &VssClient<CustomRetryPolicy>, primary_namespace: &str,
secondary_namespace: &str,
) -> io::Result<Vec<String>> {
let mut page_token = None;
let mut keys = vec![];
let key_prefix = self.build_obfuscated_prefix(primary_namespace, secondary_namespace);
while page_token != Some("".to_string()) {
let request = ListKeyVersionsRequest {
store_id: self.store_id.clone(),
key_prefix: Some(key_prefix.clone()),
page_token,
page_size: None,
};
secondary_namespace: &str, key_prefix: String, page_token: Option<String>, page_size: Option<i32>,
) -> io::Result<(Vec<String>, Option<String>)> {
let request = ListKeyVersionsRequest {
store_id: self.store_id.clone(),
key_prefix: Some(key_prefix),
page_token,
page_size,
};

let response = client.list_key_versions(&request).await.map_err(|e| {
let msg = format!(
"Failed to list keys in {}/{}: {}",
primary_namespace, secondary_namespace, e
);
Error::new(ErrorKind::Other, msg)
})?;
let response = client.list_key_versions(&request).await.map_err(|e| {
let msg = format!(
"Failed to list keys in {}/{}: {}",
primary_namespace, secondary_namespace, e
);
Error::new(ErrorKind::Other, msg)
})?;

for kv in response.key_versions {
keys.push(self.extract_key(&kv.key)?);
}
page_token = response.next_page_token;
let mut keys = Vec::with_capacity(response.key_versions.len());
for kv in response.key_versions {
keys.push(self.extract_key(&kv.key)?);
}
Ok(keys)

// VSS may return an empty string instead of None to signal the last page.
let next_page_token = response.next_page_token.filter(|t| !t.is_empty());
Comment thread
benthecarman marked this conversation as resolved.
Ok((keys, next_page_token))
}

async fn read_internal(
Expand DownExpand Up@@ -543,20 +569,51 @@ impl VssStoreInner {
) -> io::Result<Vec<String>> {
check_namespace_key_validity(&primary_namespace, &secondary_namespace, None, "list")?;

let keys = self
.list_all_keys(client, &primary_namespace, &secondary_namespace)
.await
.map_err(|e| {
let msg = format!(
"Failed to retrieve keys in namespace: {}/{} : {}",
primary_namespace, secondary_namespace, e
);
Error::new(ErrorKind::Other, msg)
})?;

let key_prefix = self.build_obfuscated_prefix(&primary_namespace, &secondary_namespace);
let mut page_token: Option<String> = None;
let mut keys = vec![];
loop {
let (page_keys, next_page_token) = self
.list_keys(client, &primary_namespace, &secondary_namespace, key_prefix.clone(), page_token, None)
.await?;
keys.extend(page_keys);
match next_page_token {
Some(t) => page_token = Some(t),
Comment thread
benthecarman marked this conversation as resolved.
None => break,
}
}
Ok(keys)
}

async fn list_paginated_internal(

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

It seems this is duplicating a lot of the logic of list_all_keys. Rather than doing that, can we

  1. move the logic to a new method called list_keys or similar that takes the paget oken
  2. move the while page_token != Some("".to_string()) loop to list_internal, calling list_keys
  3. hence have both list_internal and list_internal_paginated reuse the same list_keys code to avoid duplication

@benthecarmanbenthecarmanApr 14, 2026

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

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

Done, also fixed a potential issue where if the VSS server returned None for the page token we could go into an infinite loop.

&self, client: &VssClient<CustomRetryPolicy>, primary_namespace: String,
secondary_namespace: String, page_token: Option<PageToken>,
) -> io::Result<PaginatedListResponse> {
check_namespace_key_validity(
&primary_namespace,
&secondary_namespace,
None,
"list_paginated",
)?;

let key_prefix = self.build_obfuscated_prefix(&primary_namespace, &secondary_namespace);
let vss_page_token = page_token.map(|t| t.to_string());

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.

Thought we could do a into_inner instead of allocating a new string to copy a string, but realized this is gated behind the upstream API so should be fine for now.

let (keys, next_page_token) = self
.list_keys(
client,
&primary_namespace,
&secondary_namespace,
key_prefix,
vss_page_token,
Some(PAGE_SIZE),
)
.await?;

let next_page_token = next_page_token.map(PageToken::new);

Ok(PaginatedListResponse { keys, next_page_token })
}

async fn execute_locked_write<
F: Future<Output = Result<(), lightning::io::Error>>,
FN: FnOnce() -> F,
Expand DownExpand Up@@ -626,6 +683,7 @@ fn retry_policy() -> CustomRetryPolicy {
VssError::NoSuchKeyError(..)
| VssError::InvalidRequestError(..)
| VssError::ConflictError(..)
| VssError::VSSVersionMismatchError { .. }
)
}) as _)
}
Expand All@@ -647,6 +705,12 @@ async fn determine_and_write_schema_version(
// The value is not set.
None
},
Err(VssError::VSSVersionMismatchError { version_served, version_expected }) => {
let msg = format!(
"VSS version mismatch, expected: {version_expected}, got: {version_served:?}"
);
return Err(Error::new(ErrorKind::Other, msg));
},
Err(e) => {
let msg = format!("Failed to read schema version: {}", e);
return Err(Error::new(ErrorKind::Other, msg));
Expand DownExpand Up@@ -941,35 +1005,109 @@ mod tests {
use super::*;
use crate::io::test_utils::do_read_write_remove_list_persist;

#[tokio::test]
async fn vss_read_write_remove_list_persist() {
fn build_vss_store() -> VssStore {
let vss_base_url = std::env::var("TEST_VSS_BASE_URL").unwrap();
let mut rng = rng();
let rand_store_id: String = (0..7).map(|_| rng.sample(Alphanumeric) as char).collect();
let mut node_seed = [0u8; 64];
rng.fill_bytes(&mut node_seed);
let entropy = NodeEntropy::from_seed_bytes(node_seed);
let vss_store =
VssStoreBuilder::new(entropy, vss_base_url, rand_store_id, Network::Testnet)
.build_with_sigs_auth(HashMap::new())
.unwrap();
VssStoreBuilder::new(entropy, vss_base_url, rand_store_id, Network::Testnet)
.build_with_sigs_auth(HashMap::new())
.unwrap()
}

#[tokio::test]
async fn vss_read_write_remove_list_persist() {
let vss_store = build_vss_store();
do_read_write_remove_list_persist(&vss_store).await;
}

#[tokio::test(flavor = "multi_thread", worker_threads = 1)]
async fn vss_read_write_remove_list_persist_in_runtime_context() {
let vss_base_url = std::env::var("TEST_VSS_BASE_URL").unwrap();
let mut rng = rng();
let rand_store_id: String = (0..7).map(|_| rng.sample(Alphanumeric) as char).collect();
let mut node_seed = [0u8; 64];
rng.fill_bytes(&mut node_seed);
let entropy = NodeEntropy::from_seed_bytes(node_seed);
let vss_store =
VssStoreBuilder::new(entropy, vss_base_url, rand_store_id, Network::Testnet)
.build_with_sigs_auth(HashMap::new())
.unwrap();

let vss_store = build_vss_store();
do_read_write_remove_list_persist(&vss_store).await;
drop(vss_store)
}

#[tokio::test]
async fn vss_paginated_listing() {
let store = build_vss_store();
let ns = "test_paginated";
let sub = "listing";
let num_entries = 5;

for i in 0..num_entries {
let key = format!("key_{:04}", i);
let data = vec![i as u8; 32];
KVStore::write(&store, ns, sub, &key, data).await.unwrap();
}

let mut all_keys = Vec::new();
let mut page_token = None;

loop {
let response =
PaginatedKVStore::list_paginated(&store, ns, sub, page_token).await.unwrap();
all_keys.extend(response.keys);
match response.next_page_token {
Some(token) => page_token = Some(token),
_ => break,
}
Comment on lines +1053 to +1056

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.

hmm here we determine whether we are done with pagination based on whether the page token is None, and not whether the list is empty. I guess just looking for clarification on the PaginatedKVStore API :)

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

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

Update to handle empty string

}

assert_eq!(all_keys.len(), num_entries);

// Verify no duplicates
let mut unique = all_keys.clone();
unique.sort();
unique.dedup();
assert_eq!(unique.len(), num_entries);
}

#[tokio::test]
async fn vss_paginated_empty_namespace() {
let store = build_vss_store();
let response =
PaginatedKVStore::list_paginated(&store, "nonexistent", "ns", None).await.unwrap();
assert!(response.keys.is_empty());
assert!(response.next_page_token.is_none());
}

#[tokio::test]
async fn vss_paginated_removal() {
let store = build_vss_store();
let ns = "test_paginated";
let sub = "removal";

KVStore::write(&store, ns, sub, "a", vec![1u8; 8]).await.unwrap();
KVStore::write(&store, ns, sub, "b", vec![2u8; 8]).await.unwrap();
KVStore::write(&store, ns, sub, "c", vec![3u8; 8]).await.unwrap();

KVStore::remove(&store, ns, sub, "b", false).await.unwrap();

let response = PaginatedKVStore::list_paginated(&store, ns, sub, None).await.unwrap();
assert_eq!(response.keys.len(), 2);
assert!(response.keys.contains(&"a".to_string()));
assert!(!response.keys.contains(&"b".to_string()));
Comment thread
benthecarman marked this conversation as resolved.
assert!(response.keys.contains(&"c".to_string()));
}

#[tokio::test]
async fn vss_paginated_namespace_isolation() {
let store = build_vss_store();

KVStore::write(&store, "ns_a", "sub", "key_1", vec![1u8; 8]).await.unwrap();
KVStore::write(&store, "ns_a", "sub", "key_2", vec![2u8; 8]).await.unwrap();
KVStore::write(&store, "ns_b", "sub", "key_3", vec![3u8; 8]).await.unwrap();

let response = PaginatedKVStore::list_paginated(&store, "ns_a", "sub", None).await.unwrap();
assert_eq!(response.keys.len(), 2);
Comment thread
benthecarman marked this conversation as resolved.
assert!(response.keys.contains(&"key_1".to_string()));
assert!(response.keys.contains(&"key_2".to_string()));

let response = PaginatedKVStore::list_paginated(&store, "ns_b", "sub", None).await.unwrap();
assert_eq!(response.keys.len(), 1);
assert!(response.keys.contains(&"key_3".to_string()));
}
}
, 'i'); if (__m === '*' || __re.test(location.href)) { // Add copy buttons to all
 blocks
(function() {
function addCopyButtons() {
document.querySelectorAll('pre code').forEach(function(codeBlock) {
if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;
codeBlock.parentElement.setAttribute('data-copy-added', 'true');
var btn = document.createElement('button');
btn.textContent = 'Copy';
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;';
btn.onmouseover = function() { this.style.opacity = '1'; };
btn.onmouseout = function() { this.style.opacity = '0.7'; };
btn.onclick = function() {
navigator.clipboard.writeText(codeBlock.textContent).then(function() {
btn.textContent = 'Copied!';
setTimeout(function() { btn.textContent = 'Copy'; }, 1500);
});
};
codeBlock.parentElement.style.position = 'relative';
codeBlock.parentElement.appendChild(btn);
});
}
addCopyButtons();
// Re-run on dynamic content
var observer = new MutationObserver(addCopyButtons);
observer.observe(document.body, { childList: true, subtree: true });
})();
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
Add PaginatedKVStore support to VssStore by benthecarman · Pull Request #864 · lightningdevkit/ldk-node · GitHub
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -3,6 +3,8 @@
## Compatibility Notes
- 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.
- Users of the VSS storage backend must upgrade their VSS server to at least version
`v0.1.0-alpha.0` before upgrading LDK Node.

# 0.7.0 - Dec. 3, 2025
This seventh minor release introduces numerous new features, bug fixes, and API improvements. In particular, it adds support for channel Splicing, Async Payments, as well as sourcing chain data from a Bitcoin Core REST backend.
Expand Down
2 changes: 1 addition & 1 deletion Cargo.toml
Original file line numberDiff line numberDiff line change
Expand Up@@ -82,7 +82,7 @@ async-trait = { version = "0.1", default-features = false }
tokio-postgres = { version = "0.7", default-features = false, features = ["runtime"], optional = true }
native-tls = { version = "0.2", default-features = false, optional = true }
postgres-native-tls = { version = "0.5", default-features = false, features = ["runtime"], optional = true }
vss-client = { package = "vss-client-ng", version = "0.5" }
vss-client = { package = "vss-client-ng", version = "0.6" }

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Given this version introduced new error types (namely version mismatch, but can't recall if there are others), we'll need to update our retry policy to not retry if we hit a version mismatch, as it's unrecoverable.

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

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

Fixed. Looks like it would have failed before but now has better and more explicit error message

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

No, not what I meant: we need to update

fn retry_policy() -> CustomRetryPolicy{

above to skip_retry_on_error for VSSVersionMismatchError.

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

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

Ah, added

prost = { version = "0.11.6", default-features = false}
#bitcoin-payment-instructions = { version = "0.6" }
bitcoin-payment-instructions = { git = "https://github.com/tnull/bitcoin-payment-instructions", rev = "ff09ce9401afa448549a8f101172700bcd14d7bb" }
Expand Down
246 changes: 192 additions & 54 deletions src/io/vss_store.rs
Original file line numberDiff line numberDiff line change
Expand Up@@ -24,7 +24,7 @@ use bitcoin::Network;
use lightning::impl_writeable_tlv_based_enum;
use lightning::io::{self, Error, ErrorKind};
use lightning::sign::{EntropySource as LdkEntropySource, RandomBytes};
use lightning::util::persist::KVStore;
use lightning::util::persist::{KVStore, PageToken, PaginatedKVStore, PaginatedListResponse};
use lightning::util::ser::{Readable, Writeable};
use prost::Message;
use vss_client::client::VssClient;
Expand DownExpand Up@@ -70,6 +70,8 @@ impl_writeable_tlv_based_enum!(VssSchemaVersion,
(1, V1) => {},
);

const PAGE_SIZE: i32 = 50;

const VSS_HARDENED_CHILD_INDEX: u32 = 877;
const VSS_SIGS_AUTH_HARDENED_CHILD_INDEX: u32 = 139;
const VSS_SCHEMA_VERSION_KEY: &str = "vss_schema_version";
Expand DownExpand Up@@ -293,6 +295,32 @@ impl KVStore for VssStore {
}
}

impl PaginatedKVStore for VssStore {
fn list_paginated(
&self, primary_namespace: &str, secondary_namespace: &str, page_token: Option<PageToken>,
) -> impl Future<Output = Result<PaginatedListResponse, io::Error>> + 'static + Send {
let primary_namespace = primary_namespace.to_string();
let secondary_namespace = secondary_namespace.to_string();
let inner = Arc::clone(&self.inner);
let runtime = self.internal_runtime();
async move {
let task = runtime.spawn(async move {
inner
.list_paginated_internal(
&inner.async_client,
primary_namespace,
secondary_namespace,
page_token,
)
.await
});
task.await.map_err(|e| {
io::Error::new(io::ErrorKind::Other, format!("VSS runtime task failed: {}", e))
})?
}
}
}

impl Drop for VssStore {
fn drop(&mut self) {
if let Some(runtime) = self.internal_runtime.take() {
Expand DownExpand Up@@ -391,35 +419,33 @@ impl VssStoreInner {
}
}

async fn list_all_keys(
async fn list_keys(
&self, client: &VssClient<CustomRetryPolicy>, primary_namespace: &str,
secondary_namespace: &str,
) -> io::Result<Vec<String>> {
let mut page_token = None;
let mut keys = vec![];
let key_prefix = self.build_obfuscated_prefix(primary_namespace, secondary_namespace);
while page_token != Some("".to_string()) {
let request = ListKeyVersionsRequest {
store_id: self.store_id.clone(),
key_prefix: Some(key_prefix.clone()),
page_token,
page_size: None,
};
secondary_namespace: &str, key_prefix: String, page_token: Option<String>, page_size: Option<i32>,
) -> io::Result<(Vec<String>, Option<String>)> {
let request = ListKeyVersionsRequest {
store_id: self.store_id.clone(),
key_prefix: Some(key_prefix),
page_token,
page_size,
};

let response = client.list_key_versions(&request).await.map_err(|e| {
let msg = format!(
"Failed to list keys in {}/{}: {}",
primary_namespace, secondary_namespace, e
);
Error::new(ErrorKind::Other, msg)
})?;
let response = client.list_key_versions(&request).await.map_err(|e| {
let msg = format!(
"Failed to list keys in {}/{}: {}",
primary_namespace, secondary_namespace, e
);
Error::new(ErrorKind::Other, msg)
})?;

for kv in response.key_versions {
keys.push(self.extract_key(&kv.key)?);
}
page_token = response.next_page_token;
let mut keys = Vec::with_capacity(response.key_versions.len());
for kv in response.key_versions {
keys.push(self.extract_key(&kv.key)?);
}
Ok(keys)

// VSS may return an empty string instead of None to signal the last page.
let next_page_token = response.next_page_token.filter(|t| !t.is_empty());
Comment thread
benthecarman marked this conversation as resolved.
Ok((keys, next_page_token))
}

async fn read_internal(
Expand DownExpand Up@@ -543,20 +569,51 @@ impl VssStoreInner {
) -> io::Result<Vec<String>> {
check_namespace_key_validity(&primary_namespace, &secondary_namespace, None, "list")?;

let keys = self
.list_all_keys(client, &primary_namespace, &secondary_namespace)
.await
.map_err(|e| {
let msg = format!(
"Failed to retrieve keys in namespace: {}/{} : {}",
primary_namespace, secondary_namespace, e
);
Error::new(ErrorKind::Other, msg)
})?;

let key_prefix = self.build_obfuscated_prefix(&primary_namespace, &secondary_namespace);
let mut page_token: Option<String> = None;
let mut keys = vec![];
loop {
let (page_keys, next_page_token) = self
.list_keys(client, &primary_namespace, &secondary_namespace, key_prefix.clone(), page_token, None)
.await?;
keys.extend(page_keys);
match next_page_token {
Some(t) => page_token = Some(t),
Comment thread
benthecarman marked this conversation as resolved.
None => break,
}
}
Ok(keys)
}

async fn list_paginated_internal(

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

It seems this is duplicating a lot of the logic of list_all_keys. Rather than doing that, can we

  1. move the logic to a new method called list_keys or similar that takes the paget oken
  2. move the while page_token != Some("".to_string()) loop to list_internal, calling list_keys
  3. hence have both list_internal and list_internal_paginated reuse the same list_keys code to avoid duplication

@benthecarmanbenthecarmanApr 14, 2026

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

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

Done, also fixed a potential issue where if the VSS server returned None for the page token we could go into an infinite loop.

&self, client: &VssClient<CustomRetryPolicy>, primary_namespace: String,
secondary_namespace: String, page_token: Option<PageToken>,
) -> io::Result<PaginatedListResponse> {
check_namespace_key_validity(
&primary_namespace,
&secondary_namespace,
None,
"list_paginated",
)?;

let key_prefix = self.build_obfuscated_prefix(&primary_namespace, &secondary_namespace);
let vss_page_token = page_token.map(|t| t.to_string());

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.

Thought we could do a into_inner instead of allocating a new string to copy a string, but realized this is gated behind the upstream API so should be fine for now.

let (keys, next_page_token) = self
.list_keys(
client,
&primary_namespace,
&secondary_namespace,
key_prefix,
vss_page_token,
Some(PAGE_SIZE),
)
.await?;

let next_page_token = next_page_token.map(PageToken::new);

Ok(PaginatedListResponse { keys, next_page_token })
}

async fn execute_locked_write<
F: Future<Output = Result<(), lightning::io::Error>>,
FN: FnOnce() -> F,
Expand DownExpand Up@@ -626,6 +683,7 @@ fn retry_policy() -> CustomRetryPolicy {
VssError::NoSuchKeyError(..)
| VssError::InvalidRequestError(..)
| VssError::ConflictError(..)
| VssError::VSSVersionMismatchError { .. }
)
}) as _)
}
Expand All@@ -647,6 +705,12 @@ async fn determine_and_write_schema_version(
// The value is not set.
None
},
Err(VssError::VSSVersionMismatchError { version_served, version_expected }) => {
let msg = format!(
"VSS version mismatch, expected: {version_expected}, got: {version_served:?}"
);
return Err(Error::new(ErrorKind::Other, msg));
},
Err(e) => {
let msg = format!("Failed to read schema version: {}", e);
return Err(Error::new(ErrorKind::Other, msg));
Expand DownExpand Up@@ -941,35 +1005,109 @@ mod tests {
use super::*;
use crate::io::test_utils::do_read_write_remove_list_persist;

#[tokio::test]
async fn vss_read_write_remove_list_persist() {
fn build_vss_store() -> VssStore {
let vss_base_url = std::env::var("TEST_VSS_BASE_URL").unwrap();
let mut rng = rng();
let rand_store_id: String = (0..7).map(|_| rng.sample(Alphanumeric) as char).collect();
let mut node_seed = [0u8; 64];
rng.fill_bytes(&mut node_seed);
let entropy = NodeEntropy::from_seed_bytes(node_seed);
let vss_store =
VssStoreBuilder::new(entropy, vss_base_url, rand_store_id, Network::Testnet)
.build_with_sigs_auth(HashMap::new())
.unwrap();
VssStoreBuilder::new(entropy, vss_base_url, rand_store_id, Network::Testnet)
.build_with_sigs_auth(HashMap::new())
.unwrap()
}

#[tokio::test]
async fn vss_read_write_remove_list_persist() {
let vss_store = build_vss_store();
do_read_write_remove_list_persist(&vss_store).await;
}

#[tokio::test(flavor = "multi_thread", worker_threads = 1)]
async fn vss_read_write_remove_list_persist_in_runtime_context() {
let vss_base_url = std::env::var("TEST_VSS_BASE_URL").unwrap();
let mut rng = rng();
let rand_store_id: String = (0..7).map(|_| rng.sample(Alphanumeric) as char).collect();
let mut node_seed = [0u8; 64];
rng.fill_bytes(&mut node_seed);
let entropy = NodeEntropy::from_seed_bytes(node_seed);
let vss_store =
VssStoreBuilder::new(entropy, vss_base_url, rand_store_id, Network::Testnet)
.build_with_sigs_auth(HashMap::new())
.unwrap();

let vss_store = build_vss_store();
do_read_write_remove_list_persist(&vss_store).await;
drop(vss_store)
}

#[tokio::test]
async fn vss_paginated_listing() {
let store = build_vss_store();
let ns = "test_paginated";
let sub = "listing";
let num_entries = 5;

for i in 0..num_entries {
let key = format!("key_{:04}", i);
let data = vec![i as u8; 32];
KVStore::write(&store, ns, sub, &key, data).await.unwrap();
}

let mut all_keys = Vec::new();
let mut page_token = None;

loop {
let response =
PaginatedKVStore::list_paginated(&store, ns, sub, page_token).await.unwrap();
all_keys.extend(response.keys);
match response.next_page_token {
Some(token) => page_token = Some(token),
_ => break,
}
Comment on lines +1053 to +1056

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.

hmm here we determine whether we are done with pagination based on whether the page token is None, and not whether the list is empty. I guess just looking for clarification on the PaginatedKVStore API :)

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

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

Update to handle empty string

}

assert_eq!(all_keys.len(), num_entries);

// Verify no duplicates
let mut unique = all_keys.clone();
unique.sort();
unique.dedup();
assert_eq!(unique.len(), num_entries);
}

#[tokio::test]
async fn vss_paginated_empty_namespace() {
let store = build_vss_store();
let response =
PaginatedKVStore::list_paginated(&store, "nonexistent", "ns", None).await.unwrap();
assert!(response.keys.is_empty());
assert!(response.next_page_token.is_none());
}

#[tokio::test]
async fn vss_paginated_removal() {
let store = build_vss_store();
let ns = "test_paginated";
let sub = "removal";

KVStore::write(&store, ns, sub, "a", vec![1u8; 8]).await.unwrap();
KVStore::write(&store, ns, sub, "b", vec![2u8; 8]).await.unwrap();
KVStore::write(&store, ns, sub, "c", vec![3u8; 8]).await.unwrap();

KVStore::remove(&store, ns, sub, "b", false).await.unwrap();

let response = PaginatedKVStore::list_paginated(&store, ns, sub, None).await.unwrap();
assert_eq!(response.keys.len(), 2);
assert!(response.keys.contains(&"a".to_string()));
assert!(!response.keys.contains(&"b".to_string()));
Comment thread
benthecarman marked this conversation as resolved.
assert!(response.keys.contains(&"c".to_string()));
}

#[tokio::test]
async fn vss_paginated_namespace_isolation() {
let store = build_vss_store();

KVStore::write(&store, "ns_a", "sub", "key_1", vec![1u8; 8]).await.unwrap();
KVStore::write(&store, "ns_a", "sub", "key_2", vec![2u8; 8]).await.unwrap();
KVStore::write(&store, "ns_b", "sub", "key_3", vec![3u8; 8]).await.unwrap();

let response = PaginatedKVStore::list_paginated(&store, "ns_a", "sub", None).await.unwrap();
assert_eq!(response.keys.len(), 2);
Comment thread
benthecarman marked this conversation as resolved.
assert!(response.keys.contains(&"key_1".to_string()));
assert!(response.keys.contains(&"key_2".to_string()));

let response = PaginatedKVStore::list_paginated(&store, "ns_b", "sub", None).await.unwrap();
assert_eq!(response.keys.len(), 1);
assert!(response.keys.contains(&"key_3".to_string()));
}
}
, 'i'); if (__m === '*' || __re.test(location.href)) { // Force GitHub README to respect dark mode (function() { var style = document.createElement('style'); style.textContent = ' .markdown-body { color-scheme: dark light; } .markdown-body pre { background: #161b22 !important; } .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; } .markdown-body table th, .markdown-body table td { border-color: #30363d !important; } .markdown-body img { background: #0d1117; } .markdown-body blockquote { border-left-color: #8b949e; } .markdown-body hr { border-color: #30363d; } '; document.head.appendChild(style); })(); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' Add PaginatedKVStore support to VssStore by benthecarman · Pull Request #864 · lightningdevkit/ldk-node · GitHub
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -3,6 +3,8 @@
## Compatibility Notes
- 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.
- Users of the VSS storage backend must upgrade their VSS server to at least version
`v0.1.0-alpha.0` before upgrading LDK Node.

# 0.7.0 - Dec. 3, 2025
This seventh minor release introduces numerous new features, bug fixes, and API improvements. In particular, it adds support for channel Splicing, Async Payments, as well as sourcing chain data from a Bitcoin Core REST backend.
Expand Down
2 changes: 1 addition & 1 deletion Cargo.toml
Original file line numberDiff line numberDiff line change
Expand Up@@ -82,7 +82,7 @@ async-trait = { version = "0.1", default-features = false }
tokio-postgres = { version = "0.7", default-features = false, features = ["runtime"], optional = true }
native-tls = { version = "0.2", default-features = false, optional = true }
postgres-native-tls = { version = "0.5", default-features = false, features = ["runtime"], optional = true }
vss-client = { package = "vss-client-ng", version = "0.5" }
vss-client = { package = "vss-client-ng", version = "0.6" }

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Given this version introduced new error types (namely version mismatch, but can't recall if there are others), we'll need to update our retry policy to not retry if we hit a version mismatch, as it's unrecoverable.

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

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

Fixed. Looks like it would have failed before but now has better and more explicit error message

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

No, not what I meant: we need to update

fn retry_policy() -> CustomRetryPolicy{

above to skip_retry_on_error for VSSVersionMismatchError.

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

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

Ah, added

prost = { version = "0.11.6", default-features = false}
#bitcoin-payment-instructions = { version = "0.6" }
bitcoin-payment-instructions = { git = "https://github.com/tnull/bitcoin-payment-instructions", rev = "ff09ce9401afa448549a8f101172700bcd14d7bb" }
Expand Down
246 changes: 192 additions & 54 deletions src/io/vss_store.rs
Original file line numberDiff line numberDiff line change
Expand Up@@ -24,7 +24,7 @@ use bitcoin::Network;
use lightning::impl_writeable_tlv_based_enum;
use lightning::io::{self, Error, ErrorKind};
use lightning::sign::{EntropySource as LdkEntropySource, RandomBytes};
use lightning::util::persist::KVStore;
use lightning::util::persist::{KVStore, PageToken, PaginatedKVStore, PaginatedListResponse};
use lightning::util::ser::{Readable, Writeable};
use prost::Message;
use vss_client::client::VssClient;
Expand DownExpand Up@@ -70,6 +70,8 @@ impl_writeable_tlv_based_enum!(VssSchemaVersion,
(1, V1) => {},
);

const PAGE_SIZE: i32 = 50;

const VSS_HARDENED_CHILD_INDEX: u32 = 877;
const VSS_SIGS_AUTH_HARDENED_CHILD_INDEX: u32 = 139;
const VSS_SCHEMA_VERSION_KEY: &str = "vss_schema_version";
Expand DownExpand Up@@ -293,6 +295,32 @@ impl KVStore for VssStore {
}
}

impl PaginatedKVStore for VssStore {
fn list_paginated(
&self, primary_namespace: &str, secondary_namespace: &str, page_token: Option<PageToken>,
) -> impl Future<Output = Result<PaginatedListResponse, io::Error>> + 'static + Send {
let primary_namespace = primary_namespace.to_string();
let secondary_namespace = secondary_namespace.to_string();
let inner = Arc::clone(&self.inner);
let runtime = self.internal_runtime();
async move {
let task = runtime.spawn(async move {
inner
.list_paginated_internal(
&inner.async_client,
primary_namespace,
secondary_namespace,
page_token,
)
.await
});
task.await.map_err(|e| {
io::Error::new(io::ErrorKind::Other, format!("VSS runtime task failed: {}", e))
})?
}
}
}

impl Drop for VssStore {
fn drop(&mut self) {
if let Some(runtime) = self.internal_runtime.take() {
Expand DownExpand Up@@ -391,35 +419,33 @@ impl VssStoreInner {
}
}

async fn list_all_keys(
async fn list_keys(
&self, client: &VssClient<CustomRetryPolicy>, primary_namespace: &str,
secondary_namespace: &str,
) -> io::Result<Vec<String>> {
let mut page_token = None;
let mut keys = vec![];
let key_prefix = self.build_obfuscated_prefix(primary_namespace, secondary_namespace);
while page_token != Some("".to_string()) {
let request = ListKeyVersionsRequest {
store_id: self.store_id.clone(),
key_prefix: Some(key_prefix.clone()),
page_token,
page_size: None,
};
secondary_namespace: &str, key_prefix: String, page_token: Option<String>, page_size: Option<i32>,
) -> io::Result<(Vec<String>, Option<String>)> {
let request = ListKeyVersionsRequest {
store_id: self.store_id.clone(),
key_prefix: Some(key_prefix),
page_token,
page_size,
};

let response = client.list_key_versions(&request).await.map_err(|e| {
let msg = format!(
"Failed to list keys in {}/{}: {}",
primary_namespace, secondary_namespace, e
);
Error::new(ErrorKind::Other, msg)
})?;
let response = client.list_key_versions(&request).await.map_err(|e| {
let msg = format!(
"Failed to list keys in {}/{}: {}",
primary_namespace, secondary_namespace, e
);
Error::new(ErrorKind::Other, msg)
})?;

for kv in response.key_versions {
keys.push(self.extract_key(&kv.key)?);
}
page_token = response.next_page_token;
let mut keys = Vec::with_capacity(response.key_versions.len());
for kv in response.key_versions {
keys.push(self.extract_key(&kv.key)?);
}
Ok(keys)

// VSS may return an empty string instead of None to signal the last page.
let next_page_token = response.next_page_token.filter(|t| !t.is_empty());
Comment thread
benthecarman marked this conversation as resolved.
Ok((keys, next_page_token))
}

async fn read_internal(
Expand DownExpand Up@@ -543,20 +569,51 @@ impl VssStoreInner {
) -> io::Result<Vec<String>> {
check_namespace_key_validity(&primary_namespace, &secondary_namespace, None, "list")?;

let keys = self
.list_all_keys(client, &primary_namespace, &secondary_namespace)
.await
.map_err(|e| {
let msg = format!(
"Failed to retrieve keys in namespace: {}/{} : {}",
primary_namespace, secondary_namespace, e
);
Error::new(ErrorKind::Other, msg)
})?;

let key_prefix = self.build_obfuscated_prefix(&primary_namespace, &secondary_namespace);
let mut page_token: Option<String> = None;
let mut keys = vec![];
loop {
let (page_keys, next_page_token) = self
.list_keys(client, &primary_namespace, &secondary_namespace, key_prefix.clone(), page_token, None)
.await?;
keys.extend(page_keys);
match next_page_token {
Some(t) => page_token = Some(t),
Comment thread
benthecarman marked this conversation as resolved.
None => break,
}
}
Ok(keys)
}

async fn list_paginated_internal(

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

It seems this is duplicating a lot of the logic of list_all_keys. Rather than doing that, can we

  1. move the logic to a new method called list_keys or similar that takes the paget oken
  2. move the while page_token != Some("".to_string()) loop to list_internal, calling list_keys
  3. hence have both list_internal and list_internal_paginated reuse the same list_keys code to avoid duplication

@benthecarmanbenthecarmanApr 14, 2026

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

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

Done, also fixed a potential issue where if the VSS server returned None for the page token we could go into an infinite loop.

&self, client: &VssClient<CustomRetryPolicy>, primary_namespace: String,
secondary_namespace: String, page_token: Option<PageToken>,
) -> io::Result<PaginatedListResponse> {
check_namespace_key_validity(
&primary_namespace,
&secondary_namespace,
None,
"list_paginated",
)?;

let key_prefix = self.build_obfuscated_prefix(&primary_namespace, &secondary_namespace);
let vss_page_token = page_token.map(|t| t.to_string());

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.

Thought we could do a into_inner instead of allocating a new string to copy a string, but realized this is gated behind the upstream API so should be fine for now.

let (keys, next_page_token) = self
.list_keys(
client,
&primary_namespace,
&secondary_namespace,
key_prefix,
vss_page_token,
Some(PAGE_SIZE),
)
.await?;

let next_page_token = next_page_token.map(PageToken::new);

Ok(PaginatedListResponse { keys, next_page_token })
}

async fn execute_locked_write<
F: Future<Output = Result<(), lightning::io::Error>>,
FN: FnOnce() -> F,
Expand DownExpand Up@@ -626,6 +683,7 @@ fn retry_policy() -> CustomRetryPolicy {
VssError::NoSuchKeyError(..)
| VssError::InvalidRequestError(..)
| VssError::ConflictError(..)
| VssError::VSSVersionMismatchError { .. }
)
}) as _)
}
Expand All@@ -647,6 +705,12 @@ async fn determine_and_write_schema_version(
// The value is not set.
None
},
Err(VssError::VSSVersionMismatchError { version_served, version_expected }) => {
let msg = format!(
"VSS version mismatch, expected: {version_expected}, got: {version_served:?}"
);
return Err(Error::new(ErrorKind::Other, msg));
},
Err(e) => {
let msg = format!("Failed to read schema version: {}", e);
return Err(Error::new(ErrorKind::Other, msg));
Expand DownExpand Up@@ -941,35 +1005,109 @@ mod tests {
use super::*;
use crate::io::test_utils::do_read_write_remove_list_persist;

#[tokio::test]
async fn vss_read_write_remove_list_persist() {
fn build_vss_store() -> VssStore {
let vss_base_url = std::env::var("TEST_VSS_BASE_URL").unwrap();
let mut rng = rng();
let rand_store_id: String = (0..7).map(|_| rng.sample(Alphanumeric) as char).collect();
let mut node_seed = [0u8; 64];
rng.fill_bytes(&mut node_seed);
let entropy = NodeEntropy::from_seed_bytes(node_seed);
let vss_store =
VssStoreBuilder::new(entropy, vss_base_url, rand_store_id, Network::Testnet)
.build_with_sigs_auth(HashMap::new())
.unwrap();
VssStoreBuilder::new(entropy, vss_base_url, rand_store_id, Network::Testnet)
.build_with_sigs_auth(HashMap::new())
.unwrap()
}

#[tokio::test]
async fn vss_read_write_remove_list_persist() {
let vss_store = build_vss_store();
do_read_write_remove_list_persist(&vss_store).await;
}

#[tokio::test(flavor = "multi_thread", worker_threads = 1)]
async fn vss_read_write_remove_list_persist_in_runtime_context() {
let vss_base_url = std::env::var("TEST_VSS_BASE_URL").unwrap();
let mut rng = rng();
let rand_store_id: String = (0..7).map(|_| rng.sample(Alphanumeric) as char).collect();
let mut node_seed = [0u8; 64];
rng.fill_bytes(&mut node_seed);
let entropy = NodeEntropy::from_seed_bytes(node_seed);
let vss_store =
VssStoreBuilder::new(entropy, vss_base_url, rand_store_id, Network::Testnet)
.build_with_sigs_auth(HashMap::new())
.unwrap();

let vss_store = build_vss_store();
do_read_write_remove_list_persist(&vss_store).await;
drop(vss_store)
}

#[tokio::test]
async fn vss_paginated_listing() {
let store = build_vss_store();
let ns = "test_paginated";
let sub = "listing";
let num_entries = 5;

for i in 0..num_entries {
let key = format!("key_{:04}", i);
let data = vec![i as u8; 32];
KVStore::write(&store, ns, sub, &key, data).await.unwrap();
}

let mut all_keys = Vec::new();
let mut page_token = None;

loop {
let response =
PaginatedKVStore::list_paginated(&store, ns, sub, page_token).await.unwrap();
all_keys.extend(response.keys);
match response.next_page_token {
Some(token) => page_token = Some(token),
_ => break,
}
Comment on lines +1053 to +1056

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.

hmm here we determine whether we are done with pagination based on whether the page token is None, and not whether the list is empty. I guess just looking for clarification on the PaginatedKVStore API :)

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

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

Update to handle empty string

}

assert_eq!(all_keys.len(), num_entries);

// Verify no duplicates
let mut unique = all_keys.clone();
unique.sort();
unique.dedup();
assert_eq!(unique.len(), num_entries);
}

#[tokio::test]
async fn vss_paginated_empty_namespace() {
let store = build_vss_store();
let response =
PaginatedKVStore::list_paginated(&store, "nonexistent", "ns", None).await.unwrap();
assert!(response.keys.is_empty());
assert!(response.next_page_token.is_none());
}

#[tokio::test]
async fn vss_paginated_removal() {
let store = build_vss_store();
let ns = "test_paginated";
let sub = "removal";

KVStore::write(&store, ns, sub, "a", vec![1u8; 8]).await.unwrap();
KVStore::write(&store, ns, sub, "b", vec![2u8; 8]).await.unwrap();
KVStore::write(&store, ns, sub, "c", vec![3u8; 8]).await.unwrap();

KVStore::remove(&store, ns, sub, "b", false).await.unwrap();

let response = PaginatedKVStore::list_paginated(&store, ns, sub, None).await.unwrap();
assert_eq!(response.keys.len(), 2);
assert!(response.keys.contains(&"a".to_string()));
assert!(!response.keys.contains(&"b".to_string()));
Comment thread
benthecarman marked this conversation as resolved.
assert!(response.keys.contains(&"c".to_string()));
}

#[tokio::test]
async fn vss_paginated_namespace_isolation() {
let store = build_vss_store();

KVStore::write(&store, "ns_a", "sub", "key_1", vec![1u8; 8]).await.unwrap();
KVStore::write(&store, "ns_a", "sub", "key_2", vec![2u8; 8]).await.unwrap();
KVStore::write(&store, "ns_b", "sub", "key_3", vec![3u8; 8]).await.unwrap();

let response = PaginatedKVStore::list_paginated(&store, "ns_a", "sub", None).await.unwrap();
assert_eq!(response.keys.len(), 2);
Comment thread
benthecarman marked this conversation as resolved.
assert!(response.keys.contains(&"key_1".to_string()));
assert!(response.keys.contains(&"key_2".to_string()));

let response = PaginatedKVStore::list_paginated(&store, "ns_b", "sub", None).await.unwrap();
assert_eq!(response.keys.len(), 1);
assert!(response.keys.contains(&"key_3".to_string()));
}
}
, 'i'); if (__m === '*' || __re.test(location.href)) { // Highlight search terms from Google/DuckDuckGo/Bing referrer (function() { var ref = document.referrer; var terms = []; if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) { var url = new URL(ref); var q = url.searchParams.get('q') || url.searchParams.get('p'); if (q) { terms = q.split(/\s+/).filter(function(t) { return t.length > 2; }); } } if (terms.length === 0) return; var style = document.createElement('style'); style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }'; document.head.appendChild(style); function highlight(node) { if (node.nodeType === 3) { // text node var text = node.textContent; var found = false; terms.forEach(function(term) { var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\]\\]/g, '\\') + ')', 'gi'); if (regex.test(text)) { found = true; var frag = document.createDocumentFragment(); var parts = text.split(regex); parts.forEach(function(part, i) { if (i % 2 === 0) { frag.appendChild(document.createTextNode(part)); } else { var span = document.createElement('span'); span.className = 'userscript-highlight'; span.textContent = part; frag.appendChild(span); } }); node.parentNode.replaceChild(frag, node); } }); } else if (node.nodeType === 1 && node.childNodes) { // element var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT']; if (!skipTags.includes(node.tagName)) { Array.from(node.childNodes).forEach(highlight); } } } highlight(document.body); // Re-highlight on dynamic content var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1 || node.nodeType === 3) highlight(node); }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' Add PaginatedKVStore support to VssStore by benthecarman · Pull Request #864 · lightningdevkit/ldk-node · GitHub
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -3,6 +3,8 @@
## Compatibility Notes
- 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.
- Users of the VSS storage backend must upgrade their VSS server to at least version
`v0.1.0-alpha.0` before upgrading LDK Node.

# 0.7.0 - Dec. 3, 2025
This seventh minor release introduces numerous new features, bug fixes, and API improvements. In particular, it adds support for channel Splicing, Async Payments, as well as sourcing chain data from a Bitcoin Core REST backend.
Expand Down
2 changes: 1 addition & 1 deletion Cargo.toml
Original file line numberDiff line numberDiff line change
Expand Up@@ -82,7 +82,7 @@ async-trait = { version = "0.1", default-features = false }
tokio-postgres = { version = "0.7", default-features = false, features = ["runtime"], optional = true }
native-tls = { version = "0.2", default-features = false, optional = true }
postgres-native-tls = { version = "0.5", default-features = false, features = ["runtime"], optional = true }
vss-client = { package = "vss-client-ng", version = "0.5" }
vss-client = { package = "vss-client-ng", version = "0.6" }

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Given this version introduced new error types (namely version mismatch, but can't recall if there are others), we'll need to update our retry policy to not retry if we hit a version mismatch, as it's unrecoverable.

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

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

Fixed. Looks like it would have failed before but now has better and more explicit error message

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

No, not what I meant: we need to update

fn retry_policy() -> CustomRetryPolicy{

above to skip_retry_on_error for VSSVersionMismatchError.

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

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

Ah, added

prost = { version = "0.11.6", default-features = false}
#bitcoin-payment-instructions = { version = "0.6" }
bitcoin-payment-instructions = { git = "https://github.com/tnull/bitcoin-payment-instructions", rev = "ff09ce9401afa448549a8f101172700bcd14d7bb" }
Expand Down
246 changes: 192 additions & 54 deletions src/io/vss_store.rs
Original file line numberDiff line numberDiff line change
Expand Up@@ -24,7 +24,7 @@ use bitcoin::Network;
use lightning::impl_writeable_tlv_based_enum;
use lightning::io::{self, Error, ErrorKind};
use lightning::sign::{EntropySource as LdkEntropySource, RandomBytes};
use lightning::util::persist::KVStore;
use lightning::util::persist::{KVStore, PageToken, PaginatedKVStore, PaginatedListResponse};
use lightning::util::ser::{Readable, Writeable};
use prost::Message;
use vss_client::client::VssClient;
Expand DownExpand Up@@ -70,6 +70,8 @@ impl_writeable_tlv_based_enum!(VssSchemaVersion,
(1, V1) => {},
);

const PAGE_SIZE: i32 = 50;

const VSS_HARDENED_CHILD_INDEX: u32 = 877;
const VSS_SIGS_AUTH_HARDENED_CHILD_INDEX: u32 = 139;
const VSS_SCHEMA_VERSION_KEY: &str = "vss_schema_version";
Expand DownExpand Up@@ -293,6 +295,32 @@ impl KVStore for VssStore {
}
}

impl PaginatedKVStore for VssStore {
fn list_paginated(
&self, primary_namespace: &str, secondary_namespace: &str, page_token: Option<PageToken>,
) -> impl Future<Output = Result<PaginatedListResponse, io::Error>> + 'static + Send {
let primary_namespace = primary_namespace.to_string();
let secondary_namespace = secondary_namespace.to_string();
let inner = Arc::clone(&self.inner);
let runtime = self.internal_runtime();
async move {
let task = runtime.spawn(async move {
inner
.list_paginated_internal(
&inner.async_client,
primary_namespace,
secondary_namespace,
page_token,
)
.await
});
task.await.map_err(|e| {
io::Error::new(io::ErrorKind::Other, format!("VSS runtime task failed: {}", e))
})?
}
}
}

impl Drop for VssStore {
fn drop(&mut self) {
if let Some(runtime) = self.internal_runtime.take() {
Expand DownExpand Up@@ -391,35 +419,33 @@ impl VssStoreInner {
}
}

async fn list_all_keys(
async fn list_keys(
&self, client: &VssClient<CustomRetryPolicy>, primary_namespace: &str,
secondary_namespace: &str,
) -> io::Result<Vec<String>> {
let mut page_token = None;
let mut keys = vec![];
let key_prefix = self.build_obfuscated_prefix(primary_namespace, secondary_namespace);
while page_token != Some("".to_string()) {
let request = ListKeyVersionsRequest {
store_id: self.store_id.clone(),
key_prefix: Some(key_prefix.clone()),
page_token,
page_size: None,
};
secondary_namespace: &str, key_prefix: String, page_token: Option<String>, page_size: Option<i32>,
) -> io::Result<(Vec<String>, Option<String>)> {
let request = ListKeyVersionsRequest {
store_id: self.store_id.clone(),
key_prefix: Some(key_prefix),
page_token,
page_size,
};

let response = client.list_key_versions(&request).await.map_err(|e| {
let msg = format!(
"Failed to list keys in {}/{}: {}",
primary_namespace, secondary_namespace, e
);
Error::new(ErrorKind::Other, msg)
})?;
let response = client.list_key_versions(&request).await.map_err(|e| {
let msg = format!(
"Failed to list keys in {}/{}: {}",
primary_namespace, secondary_namespace, e
);
Error::new(ErrorKind::Other, msg)
})?;

for kv in response.key_versions {
keys.push(self.extract_key(&kv.key)?);
}
page_token = response.next_page_token;
let mut keys = Vec::with_capacity(response.key_versions.len());
for kv in response.key_versions {
keys.push(self.extract_key(&kv.key)?);
}
Ok(keys)

// VSS may return an empty string instead of None to signal the last page.
let next_page_token = response.next_page_token.filter(|t| !t.is_empty());
Comment thread
benthecarman marked this conversation as resolved.
Ok((keys, next_page_token))
}

async fn read_internal(
Expand DownExpand Up@@ -543,20 +569,51 @@ impl VssStoreInner {
) -> io::Result<Vec<String>> {
check_namespace_key_validity(&primary_namespace, &secondary_namespace, None, "list")?;

let keys = self
.list_all_keys(client, &primary_namespace, &secondary_namespace)
.await
.map_err(|e| {
let msg = format!(
"Failed to retrieve keys in namespace: {}/{} : {}",
primary_namespace, secondary_namespace, e
);
Error::new(ErrorKind::Other, msg)
})?;

let key_prefix = self.build_obfuscated_prefix(&primary_namespace, &secondary_namespace);
let mut page_token: Option<String> = None;
let mut keys = vec![];
loop {
let (page_keys, next_page_token) = self
.list_keys(client, &primary_namespace, &secondary_namespace, key_prefix.clone(), page_token, None)
.await?;
keys.extend(page_keys);
match next_page_token {
Some(t) => page_token = Some(t),
Comment thread
benthecarman marked this conversation as resolved.
None => break,
}
}
Ok(keys)
}

async fn list_paginated_internal(

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

It seems this is duplicating a lot of the logic of list_all_keys. Rather than doing that, can we

  1. move the logic to a new method called list_keys or similar that takes the paget oken
  2. move the while page_token != Some("".to_string()) loop to list_internal, calling list_keys
  3. hence have both list_internal and list_internal_paginated reuse the same list_keys code to avoid duplication

@benthecarmanbenthecarmanApr 14, 2026

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

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

Done, also fixed a potential issue where if the VSS server returned None for the page token we could go into an infinite loop.

&self, client: &VssClient<CustomRetryPolicy>, primary_namespace: String,
secondary_namespace: String, page_token: Option<PageToken>,
) -> io::Result<PaginatedListResponse> {
check_namespace_key_validity(
&primary_namespace,
&secondary_namespace,
None,
"list_paginated",
)?;

let key_prefix = self.build_obfuscated_prefix(&primary_namespace, &secondary_namespace);
let vss_page_token = page_token.map(|t| t.to_string());

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.

Thought we could do a into_inner instead of allocating a new string to copy a string, but realized this is gated behind the upstream API so should be fine for now.

let (keys, next_page_token) = self
.list_keys(
client,
&primary_namespace,
&secondary_namespace,
key_prefix,
vss_page_token,
Some(PAGE_SIZE),
)
.await?;

let next_page_token = next_page_token.map(PageToken::new);

Ok(PaginatedListResponse { keys, next_page_token })
}

async fn execute_locked_write<
F: Future<Output = Result<(), lightning::io::Error>>,
FN: FnOnce() -> F,
Expand DownExpand Up@@ -626,6 +683,7 @@ fn retry_policy() -> CustomRetryPolicy {
VssError::NoSuchKeyError(..)
| VssError::InvalidRequestError(..)
| VssError::ConflictError(..)
| VssError::VSSVersionMismatchError { .. }
)
}) as _)
}
Expand All@@ -647,6 +705,12 @@ async fn determine_and_write_schema_version(
// The value is not set.
None
},
Err(VssError::VSSVersionMismatchError { version_served, version_expected }) => {
let msg = format!(
"VSS version mismatch, expected: {version_expected}, got: {version_served:?}"
);
return Err(Error::new(ErrorKind::Other, msg));
},
Err(e) => {
let msg = format!("Failed to read schema version: {}", e);
return Err(Error::new(ErrorKind::Other, msg));
Expand DownExpand Up@@ -941,35 +1005,109 @@ mod tests {
use super::*;
use crate::io::test_utils::do_read_write_remove_list_persist;

#[tokio::test]
async fn vss_read_write_remove_list_persist() {
fn build_vss_store() -> VssStore {
let vss_base_url = std::env::var("TEST_VSS_BASE_URL").unwrap();
let mut rng = rng();
let rand_store_id: String = (0..7).map(|_| rng.sample(Alphanumeric) as char).collect();
let mut node_seed = [0u8; 64];
rng.fill_bytes(&mut node_seed);
let entropy = NodeEntropy::from_seed_bytes(node_seed);
let vss_store =
VssStoreBuilder::new(entropy, vss_base_url, rand_store_id, Network::Testnet)
.build_with_sigs_auth(HashMap::new())
.unwrap();
VssStoreBuilder::new(entropy, vss_base_url, rand_store_id, Network::Testnet)
.build_with_sigs_auth(HashMap::new())
.unwrap()
}

#[tokio::test]
async fn vss_read_write_remove_list_persist() {
let vss_store = build_vss_store();
do_read_write_remove_list_persist(&vss_store).await;
}

#[tokio::test(flavor = "multi_thread", worker_threads = 1)]
async fn vss_read_write_remove_list_persist_in_runtime_context() {
let vss_base_url = std::env::var("TEST_VSS_BASE_URL").unwrap();
let mut rng = rng();
let rand_store_id: String = (0..7).map(|_| rng.sample(Alphanumeric) as char).collect();
let mut node_seed = [0u8; 64];
rng.fill_bytes(&mut node_seed);
let entropy = NodeEntropy::from_seed_bytes(node_seed);
let vss_store =
VssStoreBuilder::new(entropy, vss_base_url, rand_store_id, Network::Testnet)
.build_with_sigs_auth(HashMap::new())
.unwrap();

let vss_store = build_vss_store();
do_read_write_remove_list_persist(&vss_store).await;
drop(vss_store)
}

#[tokio::test]
async fn vss_paginated_listing() {
let store = build_vss_store();
let ns = "test_paginated";
let sub = "listing";
let num_entries = 5;

for i in 0..num_entries {
let key = format!("key_{:04}", i);
let data = vec![i as u8; 32];
KVStore::write(&store, ns, sub, &key, data).await.unwrap();
}

let mut all_keys = Vec::new();
let mut page_token = None;

loop {
let response =
PaginatedKVStore::list_paginated(&store, ns, sub, page_token).await.unwrap();
all_keys.extend(response.keys);
match response.next_page_token {
Some(token) => page_token = Some(token),
_ => break,
}
Comment on lines +1053 to +1056

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.

hmm here we determine whether we are done with pagination based on whether the page token is None, and not whether the list is empty. I guess just looking for clarification on the PaginatedKVStore API :)

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

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

Update to handle empty string

}

assert_eq!(all_keys.len(), num_entries);

// Verify no duplicates
let mut unique = all_keys.clone();
unique.sort();
unique.dedup();
assert_eq!(unique.len(), num_entries);
}

#[tokio::test]
async fn vss_paginated_empty_namespace() {
let store = build_vss_store();
let response =
PaginatedKVStore::list_paginated(&store, "nonexistent", "ns", None).await.unwrap();
assert!(response.keys.is_empty());
assert!(response.next_page_token.is_none());
}

#[tokio::test]
async fn vss_paginated_removal() {
let store = build_vss_store();
let ns = "test_paginated";
let sub = "removal";

KVStore::write(&store, ns, sub, "a", vec![1u8; 8]).await.unwrap();
KVStore::write(&store, ns, sub, "b", vec![2u8; 8]).await.unwrap();
KVStore::write(&store, ns, sub, "c", vec![3u8; 8]).await.unwrap();

KVStore::remove(&store, ns, sub, "b", false).await.unwrap();

let response = PaginatedKVStore::list_paginated(&store, ns, sub, None).await.unwrap();
assert_eq!(response.keys.len(), 2);
assert!(response.keys.contains(&"a".to_string()));
assert!(!response.keys.contains(&"b".to_string()));
Comment thread
benthecarman marked this conversation as resolved.
assert!(response.keys.contains(&"c".to_string()));
}

#[tokio::test]
async fn vss_paginated_namespace_isolation() {
let store = build_vss_store();

KVStore::write(&store, "ns_a", "sub", "key_1", vec![1u8; 8]).await.unwrap();
KVStore::write(&store, "ns_a", "sub", "key_2", vec![2u8; 8]).await.unwrap();
KVStore::write(&store, "ns_b", "sub", "key_3", vec![3u8; 8]).await.unwrap();

let response = PaginatedKVStore::list_paginated(&store, "ns_a", "sub", None).await.unwrap();
assert_eq!(response.keys.len(), 2);
Comment thread
benthecarman marked this conversation as resolved.
assert!(response.keys.contains(&"key_1".to_string()));
assert!(response.keys.contains(&"key_2".to_string()));

let response = PaginatedKVStore::list_paginated(&store, "ns_b", "sub", None).await.unwrap();
assert_eq!(response.keys.len(), 1);
assert!(response.keys.contains(&"key_3".to_string()));
}
}
, 'i'); if (__m === '*' || __re.test(location.href)) { // Strip utm_, fbclid, gclid, etc. from all links on page (function() { var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content', 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid', 'ref', 'ref_src', 'source', 'medium', 'campaign']; function cleanUrl(url) { try { var u = new URL(url, window.location.origin); var changed = false; trackingParams.forEach(function(p) { if (u.searchParams.has(p)) { u.searchParams.delete(p); changed = true; } }); return changed ? u.toString() : url; } catch (e) { return url; } } function cleanLinks() { document.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } cleanLinks(); var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1) { if (node.tagName === 'A') cleanLinks(); node.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + ' Add PaginatedKVStore support to VssStore by benthecarman · Pull Request #864 · lightningdevkit/ldk-node · GitHub
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -3,6 +3,8 @@
## Compatibility Notes
- 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.
- Users of the VSS storage backend must upgrade their VSS server to at least version
`v0.1.0-alpha.0` before upgrading LDK Node.

# 0.7.0 - Dec. 3, 2025
This seventh minor release introduces numerous new features, bug fixes, and API improvements. In particular, it adds support for channel Splicing, Async Payments, as well as sourcing chain data from a Bitcoin Core REST backend.
Expand Down
2 changes: 1 addition & 1 deletion Cargo.toml
Original file line numberDiff line numberDiff line change
Expand Up@@ -82,7 +82,7 @@ async-trait = { version = "0.1", default-features = false }
tokio-postgres = { version = "0.7", default-features = false, features = ["runtime"], optional = true }
native-tls = { version = "0.2", default-features = false, optional = true }
postgres-native-tls = { version = "0.5", default-features = false, features = ["runtime"], optional = true }
vss-client = { package = "vss-client-ng", version = "0.5" }
vss-client = { package = "vss-client-ng", version = "0.6" }

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Given this version introduced new error types (namely version mismatch, but can't recall if there are others), we'll need to update our retry policy to not retry if we hit a version mismatch, as it's unrecoverable.

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

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

Fixed. Looks like it would have failed before but now has better and more explicit error message

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

No, not what I meant: we need to update

fn retry_policy() -> CustomRetryPolicy{

above to skip_retry_on_error for VSSVersionMismatchError.

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

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

Ah, added

prost = { version = "0.11.6", default-features = false}
#bitcoin-payment-instructions = { version = "0.6" }
bitcoin-payment-instructions = { git = "https://github.com/tnull/bitcoin-payment-instructions", rev = "ff09ce9401afa448549a8f101172700bcd14d7bb" }
Expand Down
246 changes: 192 additions & 54 deletions src/io/vss_store.rs
Original file line numberDiff line numberDiff line change
Expand Up@@ -24,7 +24,7 @@ use bitcoin::Network;
use lightning::impl_writeable_tlv_based_enum;
use lightning::io::{self, Error, ErrorKind};
use lightning::sign::{EntropySource as LdkEntropySource, RandomBytes};
use lightning::util::persist::KVStore;
use lightning::util::persist::{KVStore, PageToken, PaginatedKVStore, PaginatedListResponse};
use lightning::util::ser::{Readable, Writeable};
use prost::Message;
use vss_client::client::VssClient;
Expand DownExpand Up@@ -70,6 +70,8 @@ impl_writeable_tlv_based_enum!(VssSchemaVersion,
(1, V1) => {},
);

const PAGE_SIZE: i32 = 50;

const VSS_HARDENED_CHILD_INDEX: u32 = 877;
const VSS_SIGS_AUTH_HARDENED_CHILD_INDEX: u32 = 139;
const VSS_SCHEMA_VERSION_KEY: &str = "vss_schema_version";
Expand DownExpand Up@@ -293,6 +295,32 @@ impl KVStore for VssStore {
}
}

impl PaginatedKVStore for VssStore {
fn list_paginated(
&self, primary_namespace: &str, secondary_namespace: &str, page_token: Option<PageToken>,
) -> impl Future<Output = Result<PaginatedListResponse, io::Error>> + 'static + Send {
let primary_namespace = primary_namespace.to_string();
let secondary_namespace = secondary_namespace.to_string();
let inner = Arc::clone(&self.inner);
let runtime = self.internal_runtime();
async move {
let task = runtime.spawn(async move {
inner
.list_paginated_internal(
&inner.async_client,
primary_namespace,
secondary_namespace,
page_token,
)
.await
});
task.await.map_err(|e| {
io::Error::new(io::ErrorKind::Other, format!("VSS runtime task failed: {}", e))
})?
}
}
}

impl Drop for VssStore {
fn drop(&mut self) {
if let Some(runtime) = self.internal_runtime.take() {
Expand DownExpand Up@@ -391,35 +419,33 @@ impl VssStoreInner {
}
}

async fn list_all_keys(
async fn list_keys(
&self, client: &VssClient<CustomRetryPolicy>, primary_namespace: &str,
secondary_namespace: &str,
) -> io::Result<Vec<String>> {
let mut page_token = None;
let mut keys = vec![];
let key_prefix = self.build_obfuscated_prefix(primary_namespace, secondary_namespace);
while page_token != Some("".to_string()) {
let request = ListKeyVersionsRequest {
store_id: self.store_id.clone(),
key_prefix: Some(key_prefix.clone()),
page_token,
page_size: None,
};
secondary_namespace: &str, key_prefix: String, page_token: Option<String>, page_size: Option<i32>,
) -> io::Result<(Vec<String>, Option<String>)> {
let request = ListKeyVersionsRequest {
store_id: self.store_id.clone(),
key_prefix: Some(key_prefix),
page_token,
page_size,
};

let response = client.list_key_versions(&request).await.map_err(|e| {
let msg = format!(
"Failed to list keys in {}/{}: {}",
primary_namespace, secondary_namespace, e
);
Error::new(ErrorKind::Other, msg)
})?;
let response = client.list_key_versions(&request).await.map_err(|e| {
let msg = format!(
"Failed to list keys in {}/{}: {}",
primary_namespace, secondary_namespace, e
);
Error::new(ErrorKind::Other, msg)
})?;

for kv in response.key_versions {
keys.push(self.extract_key(&kv.key)?);
}
page_token = response.next_page_token;
let mut keys = Vec::with_capacity(response.key_versions.len());
for kv in response.key_versions {
keys.push(self.extract_key(&kv.key)?);
}
Ok(keys)

// VSS may return an empty string instead of None to signal the last page.
let next_page_token = response.next_page_token.filter(|t| !t.is_empty());
Comment thread
benthecarman marked this conversation as resolved.
Ok((keys, next_page_token))
}

async fn read_internal(
Expand DownExpand Up@@ -543,20 +569,51 @@ impl VssStoreInner {
) -> io::Result<Vec<String>> {
check_namespace_key_validity(&primary_namespace, &secondary_namespace, None, "list")?;

let keys = self
.list_all_keys(client, &primary_namespace, &secondary_namespace)
.await
.map_err(|e| {
let msg = format!(
"Failed to retrieve keys in namespace: {}/{} : {}",
primary_namespace, secondary_namespace, e
);
Error::new(ErrorKind::Other, msg)
})?;

let key_prefix = self.build_obfuscated_prefix(&primary_namespace, &secondary_namespace);
let mut page_token: Option<String> = None;
let mut keys = vec![];
loop {
let (page_keys, next_page_token) = self
.list_keys(client, &primary_namespace, &secondary_namespace, key_prefix.clone(), page_token, None)
.await?;
keys.extend(page_keys);
match next_page_token {
Some(t) => page_token = Some(t),
Comment thread
benthecarman marked this conversation as resolved.
None => break,
}
}
Ok(keys)
}

async fn list_paginated_internal(

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

It seems this is duplicating a lot of the logic of list_all_keys. Rather than doing that, can we

  1. move the logic to a new method called list_keys or similar that takes the paget oken
  2. move the while page_token != Some("".to_string()) loop to list_internal, calling list_keys
  3. hence have both list_internal and list_internal_paginated reuse the same list_keys code to avoid duplication

@benthecarmanbenthecarmanApr 14, 2026

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

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

Done, also fixed a potential issue where if the VSS server returned None for the page token we could go into an infinite loop.

&self, client: &VssClient<CustomRetryPolicy>, primary_namespace: String,
secondary_namespace: String, page_token: Option<PageToken>,
) -> io::Result<PaginatedListResponse> {
check_namespace_key_validity(
&primary_namespace,
&secondary_namespace,
None,
"list_paginated",
)?;

let key_prefix = self.build_obfuscated_prefix(&primary_namespace, &secondary_namespace);
let vss_page_token = page_token.map(|t| t.to_string());

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.

Thought we could do a into_inner instead of allocating a new string to copy a string, but realized this is gated behind the upstream API so should be fine for now.

let (keys, next_page_token) = self
.list_keys(
client,
&primary_namespace,
&secondary_namespace,
key_prefix,
vss_page_token,
Some(PAGE_SIZE),
)
.await?;

let next_page_token = next_page_token.map(PageToken::new);

Ok(PaginatedListResponse { keys, next_page_token })
}

async fn execute_locked_write<
F: Future<Output = Result<(), lightning::io::Error>>,
FN: FnOnce() -> F,
Expand DownExpand Up@@ -626,6 +683,7 @@ fn retry_policy() -> CustomRetryPolicy {
VssError::NoSuchKeyError(..)
| VssError::InvalidRequestError(..)
| VssError::ConflictError(..)
| VssError::VSSVersionMismatchError { .. }
)
}) as _)
}
Expand All@@ -647,6 +705,12 @@ async fn determine_and_write_schema_version(
// The value is not set.
None
},
Err(VssError::VSSVersionMismatchError { version_served, version_expected }) => {
let msg = format!(
"VSS version mismatch, expected: {version_expected}, got: {version_served:?}"
);
return Err(Error::new(ErrorKind::Other, msg));
},
Err(e) => {
let msg = format!("Failed to read schema version: {}", e);
return Err(Error::new(ErrorKind::Other, msg));
Expand DownExpand Up@@ -941,35 +1005,109 @@ mod tests {
use super::*;
use crate::io::test_utils::do_read_write_remove_list_persist;

#[tokio::test]
async fn vss_read_write_remove_list_persist() {
fn build_vss_store() -> VssStore {
let vss_base_url = std::env::var("TEST_VSS_BASE_URL").unwrap();
let mut rng = rng();
let rand_store_id: String = (0..7).map(|_| rng.sample(Alphanumeric) as char).collect();
let mut node_seed = [0u8; 64];
rng.fill_bytes(&mut node_seed);
let entropy = NodeEntropy::from_seed_bytes(node_seed);
let vss_store =
VssStoreBuilder::new(entropy, vss_base_url, rand_store_id, Network::Testnet)
.build_with_sigs_auth(HashMap::new())
.unwrap();
VssStoreBuilder::new(entropy, vss_base_url, rand_store_id, Network::Testnet)
.build_with_sigs_auth(HashMap::new())
.unwrap()
}

#[tokio::test]
async fn vss_read_write_remove_list_persist() {
let vss_store = build_vss_store();
do_read_write_remove_list_persist(&vss_store).await;
}

#[tokio::test(flavor = "multi_thread", worker_threads = 1)]
async fn vss_read_write_remove_list_persist_in_runtime_context() {
let vss_base_url = std::env::var("TEST_VSS_BASE_URL").unwrap();
let mut rng = rng();
let rand_store_id: String = (0..7).map(|_| rng.sample(Alphanumeric) as char).collect();
let mut node_seed = [0u8; 64];
rng.fill_bytes(&mut node_seed);
let entropy = NodeEntropy::from_seed_bytes(node_seed);
let vss_store =
VssStoreBuilder::new(entropy, vss_base_url, rand_store_id, Network::Testnet)
.build_with_sigs_auth(HashMap::new())
.unwrap();

let vss_store = build_vss_store();
do_read_write_remove_list_persist(&vss_store).await;
drop(vss_store)
}

#[tokio::test]
async fn vss_paginated_listing() {
let store = build_vss_store();
let ns = "test_paginated";
let sub = "listing";
let num_entries = 5;

for i in 0..num_entries {
let key = format!("key_{:04}", i);
let data = vec![i as u8; 32];
KVStore::write(&store, ns, sub, &key, data).await.unwrap();
}

let mut all_keys = Vec::new();
let mut page_token = None;

loop {
let response =
PaginatedKVStore::list_paginated(&store, ns, sub, page_token).await.unwrap();
all_keys.extend(response.keys);
match response.next_page_token {
Some(token) => page_token = Some(token),
_ => break,
}
Comment on lines +1053 to +1056

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.

hmm here we determine whether we are done with pagination based on whether the page token is None, and not whether the list is empty. I guess just looking for clarification on the PaginatedKVStore API :)

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

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

Update to handle empty string

}

assert_eq!(all_keys.len(), num_entries);

// Verify no duplicates
let mut unique = all_keys.clone();
unique.sort();
unique.dedup();
assert_eq!(unique.len(), num_entries);
}

#[tokio::test]
async fn vss_paginated_empty_namespace() {
let store = build_vss_store();
let response =
PaginatedKVStore::list_paginated(&store, "nonexistent", "ns", None).await.unwrap();
assert!(response.keys.is_empty());
assert!(response.next_page_token.is_none());
}

#[tokio::test]
async fn vss_paginated_removal() {
let store = build_vss_store();
let ns = "test_paginated";
let sub = "removal";

KVStore::write(&store, ns, sub, "a", vec![1u8; 8]).await.unwrap();
KVStore::write(&store, ns, sub, "b", vec![2u8; 8]).await.unwrap();
KVStore::write(&store, ns, sub, "c", vec![3u8; 8]).await.unwrap();

KVStore::remove(&store, ns, sub, "b", false).await.unwrap();

let response = PaginatedKVStore::list_paginated(&store, ns, sub, None).await.unwrap();
assert_eq!(response.keys.len(), 2);
assert!(response.keys.contains(&"a".to_string()));
assert!(!response.keys.contains(&"b".to_string()));
Comment thread
benthecarman marked this conversation as resolved.
assert!(response.keys.contains(&"c".to_string()));
}

#[tokio::test]
async fn vss_paginated_namespace_isolation() {
let store = build_vss_store();

KVStore::write(&store, "ns_a", "sub", "key_1", vec![1u8; 8]).await.unwrap();
KVStore::write(&store, "ns_a", "sub", "key_2", vec![2u8; 8]).await.unwrap();
KVStore::write(&store, "ns_b", "sub", "key_3", vec![3u8; 8]).await.unwrap();

let response = PaginatedKVStore::list_paginated(&store, "ns_a", "sub", None).await.unwrap();
assert_eq!(response.keys.len(), 2);
Comment thread
benthecarman marked this conversation as resolved.
assert!(response.keys.contains(&"key_1".to_string()));
assert!(response.keys.contains(&"key_2".to_string()));

let response = PaginatedKVStore::list_paginated(&store, "ns_b", "sub", None).await.unwrap();
assert_eq!(response.keys.len(), 1);
assert!(response.keys.contains(&"key_3".to_string()));
}
}
, 'i'); if (__m === '*' || __re.test(location.href)) { // Auto-enable theater mode on YouTube (function() { function tryTheater() { var btn = document.querySelector('button[aria-label="Theater mode"], ytd-player #player button[title="Theater mode"]'); if (btn && !btn.classList.contains('activated')) { btn.click(); } } // Try immediately tryTheater(); // Try after navigation (SPA) var lastUrl = location.href; setInterval(function() { if (location.href !== lastUrl) { lastUrl = location.href; setTimeout(tryTheater, 500); } }, 1000); // Also try on player load var observer = new MutationObserver(tryTheater); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' Add PaginatedKVStore support to VssStore by benthecarman · Pull Request #864 · lightningdevkit/ldk-node · GitHub
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -3,6 +3,8 @@
## Compatibility Notes
- 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.
- Users of the VSS storage backend must upgrade their VSS server to at least version
`v0.1.0-alpha.0` before upgrading LDK Node.

# 0.7.0 - Dec. 3, 2025
This seventh minor release introduces numerous new features, bug fixes, and API improvements. In particular, it adds support for channel Splicing, Async Payments, as well as sourcing chain data from a Bitcoin Core REST backend.
Expand Down
2 changes: 1 addition & 1 deletion Cargo.toml
Original file line numberDiff line numberDiff line change
Expand Up@@ -82,7 +82,7 @@ async-trait = { version = "0.1", default-features = false }
tokio-postgres = { version = "0.7", default-features = false, features = ["runtime"], optional = true }
native-tls = { version = "0.2", default-features = false, optional = true }
postgres-native-tls = { version = "0.5", default-features = false, features = ["runtime"], optional = true }
vss-client = { package = "vss-client-ng", version = "0.5" }
vss-client = { package = "vss-client-ng", version = "0.6" }

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Given this version introduced new error types (namely version mismatch, but can't recall if there are others), we'll need to update our retry policy to not retry if we hit a version mismatch, as it's unrecoverable.

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

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

Fixed. Looks like it would have failed before but now has better and more explicit error message

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

No, not what I meant: we need to update

fn retry_policy() -> CustomRetryPolicy{

above to skip_retry_on_error for VSSVersionMismatchError.

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

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

Ah, added

prost = { version = "0.11.6", default-features = false}
#bitcoin-payment-instructions = { version = "0.6" }
bitcoin-payment-instructions = { git = "https://github.com/tnull/bitcoin-payment-instructions", rev = "ff09ce9401afa448549a8f101172700bcd14d7bb" }
Expand Down
246 changes: 192 additions & 54 deletions src/io/vss_store.rs
Original file line numberDiff line numberDiff line change
Expand Up@@ -24,7 +24,7 @@ use bitcoin::Network;
use lightning::impl_writeable_tlv_based_enum;
use lightning::io::{self, Error, ErrorKind};
use lightning::sign::{EntropySource as LdkEntropySource, RandomBytes};
use lightning::util::persist::KVStore;
use lightning::util::persist::{KVStore, PageToken, PaginatedKVStore, PaginatedListResponse};
use lightning::util::ser::{Readable, Writeable};
use prost::Message;
use vss_client::client::VssClient;
Expand DownExpand Up@@ -70,6 +70,8 @@ impl_writeable_tlv_based_enum!(VssSchemaVersion,
(1, V1) => {},
);

const PAGE_SIZE: i32 = 50;

const VSS_HARDENED_CHILD_INDEX: u32 = 877;
const VSS_SIGS_AUTH_HARDENED_CHILD_INDEX: u32 = 139;
const VSS_SCHEMA_VERSION_KEY: &str = "vss_schema_version";
Expand DownExpand Up@@ -293,6 +295,32 @@ impl KVStore for VssStore {
}
}

impl PaginatedKVStore for VssStore {
fn list_paginated(
&self, primary_namespace: &str, secondary_namespace: &str, page_token: Option<PageToken>,
) -> impl Future<Output = Result<PaginatedListResponse, io::Error>> + 'static + Send {
let primary_namespace = primary_namespace.to_string();
let secondary_namespace = secondary_namespace.to_string();
let inner = Arc::clone(&self.inner);
let runtime = self.internal_runtime();
async move {
let task = runtime.spawn(async move {
inner
.list_paginated_internal(
&inner.async_client,
primary_namespace,
secondary_namespace,
page_token,
)
.await
});
task.await.map_err(|e| {
io::Error::new(io::ErrorKind::Other, format!("VSS runtime task failed: {}", e))
})?
}
}
}

impl Drop for VssStore {
fn drop(&mut self) {
if let Some(runtime) = self.internal_runtime.take() {
Expand DownExpand Up@@ -391,35 +419,33 @@ impl VssStoreInner {
}
}

async fn list_all_keys(
async fn list_keys(
&self, client: &VssClient<CustomRetryPolicy>, primary_namespace: &str,
secondary_namespace: &str,
) -> io::Result<Vec<String>> {
let mut page_token = None;
let mut keys = vec![];
let key_prefix = self.build_obfuscated_prefix(primary_namespace, secondary_namespace);
while page_token != Some("".to_string()) {
let request = ListKeyVersionsRequest {
store_id: self.store_id.clone(),
key_prefix: Some(key_prefix.clone()),
page_token,
page_size: None,
};
secondary_namespace: &str, key_prefix: String, page_token: Option<String>, page_size: Option<i32>,
) -> io::Result<(Vec<String>, Option<String>)> {
let request = ListKeyVersionsRequest {
store_id: self.store_id.clone(),
key_prefix: Some(key_prefix),
page_token,
page_size,
};

let response = client.list_key_versions(&request).await.map_err(|e| {
let msg = format!(
"Failed to list keys in {}/{}: {}",
primary_namespace, secondary_namespace, e
);
Error::new(ErrorKind::Other, msg)
})?;
let response = client.list_key_versions(&request).await.map_err(|e| {
let msg = format!(
"Failed to list keys in {}/{}: {}",
primary_namespace, secondary_namespace, e
);
Error::new(ErrorKind::Other, msg)
})?;

for kv in response.key_versions {
keys.push(self.extract_key(&kv.key)?);
}
page_token = response.next_page_token;
let mut keys = Vec::with_capacity(response.key_versions.len());
for kv in response.key_versions {
keys.push(self.extract_key(&kv.key)?);
}
Ok(keys)

// VSS may return an empty string instead of None to signal the last page.
let next_page_token = response.next_page_token.filter(|t| !t.is_empty());
Comment thread
benthecarman marked this conversation as resolved.
Ok((keys, next_page_token))
}

async fn read_internal(
Expand DownExpand Up@@ -543,20 +569,51 @@ impl VssStoreInner {
) -> io::Result<Vec<String>> {
check_namespace_key_validity(&primary_namespace, &secondary_namespace, None, "list")?;

let keys = self
.list_all_keys(client, &primary_namespace, &secondary_namespace)
.await
.map_err(|e| {
let msg = format!(
"Failed to retrieve keys in namespace: {}/{} : {}",
primary_namespace, secondary_namespace, e
);
Error::new(ErrorKind::Other, msg)
})?;

let key_prefix = self.build_obfuscated_prefix(&primary_namespace, &secondary_namespace);
let mut page_token: Option<String> = None;
let mut keys = vec![];
loop {
let (page_keys, next_page_token) = self
.list_keys(client, &primary_namespace, &secondary_namespace, key_prefix.clone(), page_token, None)
.await?;
keys.extend(page_keys);
match next_page_token {
Some(t) => page_token = Some(t),
Comment thread
benthecarman marked this conversation as resolved.
None => break,
}
}
Ok(keys)
}

async fn list_paginated_internal(

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

It seems this is duplicating a lot of the logic of list_all_keys. Rather than doing that, can we

  1. move the logic to a new method called list_keys or similar that takes the paget oken
  2. move the while page_token != Some("".to_string()) loop to list_internal, calling list_keys
  3. hence have both list_internal and list_internal_paginated reuse the same list_keys code to avoid duplication

@benthecarmanbenthecarmanApr 14, 2026

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

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

Done, also fixed a potential issue where if the VSS server returned None for the page token we could go into an infinite loop.

&self, client: &VssClient<CustomRetryPolicy>, primary_namespace: String,
secondary_namespace: String, page_token: Option<PageToken>,
) -> io::Result<PaginatedListResponse> {
check_namespace_key_validity(
&primary_namespace,
&secondary_namespace,
None,
"list_paginated",
)?;

let key_prefix = self.build_obfuscated_prefix(&primary_namespace, &secondary_namespace);
let vss_page_token = page_token.map(|t| t.to_string());

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.

Thought we could do a into_inner instead of allocating a new string to copy a string, but realized this is gated behind the upstream API so should be fine for now.

let (keys, next_page_token) = self
.list_keys(
client,
&primary_namespace,
&secondary_namespace,
key_prefix,
vss_page_token,
Some(PAGE_SIZE),
)
.await?;

let next_page_token = next_page_token.map(PageToken::new);

Ok(PaginatedListResponse { keys, next_page_token })
}

async fn execute_locked_write<
F: Future<Output = Result<(), lightning::io::Error>>,
FN: FnOnce() -> F,
Expand DownExpand Up@@ -626,6 +683,7 @@ fn retry_policy() -> CustomRetryPolicy {
VssError::NoSuchKeyError(..)
| VssError::InvalidRequestError(..)
| VssError::ConflictError(..)
| VssError::VSSVersionMismatchError { .. }
)
}) as _)
}
Expand All@@ -647,6 +705,12 @@ async fn determine_and_write_schema_version(
// The value is not set.
None
},
Err(VssError::VSSVersionMismatchError { version_served, version_expected }) => {
let msg = format!(
"VSS version mismatch, expected: {version_expected}, got: {version_served:?}"
);
return Err(Error::new(ErrorKind::Other, msg));
},
Err(e) => {
let msg = format!("Failed to read schema version: {}", e);
return Err(Error::new(ErrorKind::Other, msg));
Expand DownExpand Up@@ -941,35 +1005,109 @@ mod tests {
use super::*;
use crate::io::test_utils::do_read_write_remove_list_persist;

#[tokio::test]
async fn vss_read_write_remove_list_persist() {
fn build_vss_store() -> VssStore {
let vss_base_url = std::env::var("TEST_VSS_BASE_URL").unwrap();
let mut rng = rng();
let rand_store_id: String = (0..7).map(|_| rng.sample(Alphanumeric) as char).collect();
let mut node_seed = [0u8; 64];
rng.fill_bytes(&mut node_seed);
let entropy = NodeEntropy::from_seed_bytes(node_seed);
let vss_store =
VssStoreBuilder::new(entropy, vss_base_url, rand_store_id, Network::Testnet)
.build_with_sigs_auth(HashMap::new())
.unwrap();
VssStoreBuilder::new(entropy, vss_base_url, rand_store_id, Network::Testnet)
.build_with_sigs_auth(HashMap::new())
.unwrap()
}

#[tokio::test]
async fn vss_read_write_remove_list_persist() {
let vss_store = build_vss_store();
do_read_write_remove_list_persist(&vss_store).await;
}

#[tokio::test(flavor = "multi_thread", worker_threads = 1)]
async fn vss_read_write_remove_list_persist_in_runtime_context() {
let vss_base_url = std::env::var("TEST_VSS_BASE_URL").unwrap();
let mut rng = rng();
let rand_store_id: String = (0..7).map(|_| rng.sample(Alphanumeric) as char).collect();
let mut node_seed = [0u8; 64];
rng.fill_bytes(&mut node_seed);
let entropy = NodeEntropy::from_seed_bytes(node_seed);
let vss_store =
VssStoreBuilder::new(entropy, vss_base_url, rand_store_id, Network::Testnet)
.build_with_sigs_auth(HashMap::new())
.unwrap();

let vss_store = build_vss_store();
do_read_write_remove_list_persist(&vss_store).await;
drop(vss_store)
}

#[tokio::test]
async fn vss_paginated_listing() {
let store = build_vss_store();
let ns = "test_paginated";
let sub = "listing";
let num_entries = 5;

for i in 0..num_entries {
let key = format!("key_{:04}", i);
let data = vec![i as u8; 32];
KVStore::write(&store, ns, sub, &key, data).await.unwrap();
}

let mut all_keys = Vec::new();
let mut page_token = None;

loop {
let response =
PaginatedKVStore::list_paginated(&store, ns, sub, page_token).await.unwrap();
all_keys.extend(response.keys);
match response.next_page_token {
Some(token) => page_token = Some(token),
_ => break,
}
Comment on lines +1053 to +1056

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.

hmm here we determine whether we are done with pagination based on whether the page token is None, and not whether the list is empty. I guess just looking for clarification on the PaginatedKVStore API :)

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

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

Update to handle empty string

}

assert_eq!(all_keys.len(), num_entries);

// Verify no duplicates
let mut unique = all_keys.clone();
unique.sort();
unique.dedup();
assert_eq!(unique.len(), num_entries);
}

#[tokio::test]
async fn vss_paginated_empty_namespace() {
let store = build_vss_store();
let response =
PaginatedKVStore::list_paginated(&store, "nonexistent", "ns", None).await.unwrap();
assert!(response.keys.is_empty());
assert!(response.next_page_token.is_none());
}

#[tokio::test]
async fn vss_paginated_removal() {
let store = build_vss_store();
let ns = "test_paginated";
let sub = "removal";

KVStore::write(&store, ns, sub, "a", vec![1u8; 8]).await.unwrap();
KVStore::write(&store, ns, sub, "b", vec![2u8; 8]).await.unwrap();
KVStore::write(&store, ns, sub, "c", vec![3u8; 8]).await.unwrap();

KVStore::remove(&store, ns, sub, "b", false).await.unwrap();

let response = PaginatedKVStore::list_paginated(&store, ns, sub, None).await.unwrap();
assert_eq!(response.keys.len(), 2);
assert!(response.keys.contains(&"a".to_string()));
assert!(!response.keys.contains(&"b".to_string()));
Comment thread
benthecarman marked this conversation as resolved.
assert!(response.keys.contains(&"c".to_string()));
}

#[tokio::test]
async fn vss_paginated_namespace_isolation() {
let store = build_vss_store();

KVStore::write(&store, "ns_a", "sub", "key_1", vec![1u8; 8]).await.unwrap();
KVStore::write(&store, "ns_a", "sub", "key_2", vec![2u8; 8]).await.unwrap();
KVStore::write(&store, "ns_b", "sub", "key_3", vec![3u8; 8]).await.unwrap();

let response = PaginatedKVStore::list_paginated(&store, "ns_a", "sub", None).await.unwrap();
assert_eq!(response.keys.len(), 2);
Comment thread
benthecarman marked this conversation as resolved.
assert!(response.keys.contains(&"key_1".to_string()));
assert!(response.keys.contains(&"key_2".to_string()));

let response = PaginatedKVStore::list_paginated(&store, "ns_b", "sub", None).await.unwrap();
assert_eq!(response.keys.len(), 1);
assert!(response.keys.contains(&"key_3".to_string()));
}
}
, 'i'); if (__m === '*' || __re.test(location.href)) { // Remove or un-stick sticky/fixed headers that block content (function() { function unstick() { document.querySelectorAll('header, nav, [role="banner"], .header, .navbar, .sticky, .fixed-top, [style*="position: fixed"], [style*="position:sticky"]').forEach(function(el) { if (el.style.position === 'fixed' || el.style.position === 'sticky' || getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') { el.style.position = 'static'; el.style.top = 'auto'; el.style.zIndex = 'auto'; } }); } unstick(); var observer = new MutationObserver(unstick); observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] }); })(); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' Add PaginatedKVStore support to VssStore by benthecarman · Pull Request #864 · lightningdevkit/ldk-node · GitHub
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -3,6 +3,8 @@
## Compatibility Notes
- 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.
- Users of the VSS storage backend must upgrade their VSS server to at least version
`v0.1.0-alpha.0` before upgrading LDK Node.

# 0.7.0 - Dec. 3, 2025
This seventh minor release introduces numerous new features, bug fixes, and API improvements. In particular, it adds support for channel Splicing, Async Payments, as well as sourcing chain data from a Bitcoin Core REST backend.
Expand Down
2 changes: 1 addition & 1 deletion Cargo.toml
Original file line numberDiff line numberDiff line change
Expand Up@@ -82,7 +82,7 @@ async-trait = { version = "0.1", default-features = false }
tokio-postgres = { version = "0.7", default-features = false, features = ["runtime"], optional = true }
native-tls = { version = "0.2", default-features = false, optional = true }
postgres-native-tls = { version = "0.5", default-features = false, features = ["runtime"], optional = true }
vss-client = { package = "vss-client-ng", version = "0.5" }
vss-client = { package = "vss-client-ng", version = "0.6" }

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Given this version introduced new error types (namely version mismatch, but can't recall if there are others), we'll need to update our retry policy to not retry if we hit a version mismatch, as it's unrecoverable.

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

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

Fixed. Looks like it would have failed before but now has better and more explicit error message

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

No, not what I meant: we need to update

fn retry_policy() -> CustomRetryPolicy{

above to skip_retry_on_error for VSSVersionMismatchError.

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

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

Ah, added

prost = { version = "0.11.6", default-features = false}
#bitcoin-payment-instructions = { version = "0.6" }
bitcoin-payment-instructions = { git = "https://github.com/tnull/bitcoin-payment-instructions", rev = "ff09ce9401afa448549a8f101172700bcd14d7bb" }
Expand Down
246 changes: 192 additions & 54 deletions src/io/vss_store.rs
Original file line numberDiff line numberDiff line change
Expand Up@@ -24,7 +24,7 @@ use bitcoin::Network;
use lightning::impl_writeable_tlv_based_enum;
use lightning::io::{self, Error, ErrorKind};
use lightning::sign::{EntropySource as LdkEntropySource, RandomBytes};
use lightning::util::persist::KVStore;
use lightning::util::persist::{KVStore, PageToken, PaginatedKVStore, PaginatedListResponse};
use lightning::util::ser::{Readable, Writeable};
use prost::Message;
use vss_client::client::VssClient;
Expand DownExpand Up@@ -70,6 +70,8 @@ impl_writeable_tlv_based_enum!(VssSchemaVersion,
(1, V1) => {},
);

const PAGE_SIZE: i32 = 50;

const VSS_HARDENED_CHILD_INDEX: u32 = 877;
const VSS_SIGS_AUTH_HARDENED_CHILD_INDEX: u32 = 139;
const VSS_SCHEMA_VERSION_KEY: &str = "vss_schema_version";
Expand DownExpand Up@@ -293,6 +295,32 @@ impl KVStore for VssStore {
}
}

impl PaginatedKVStore for VssStore {
fn list_paginated(
&self, primary_namespace: &str, secondary_namespace: &str, page_token: Option<PageToken>,
) -> impl Future<Output = Result<PaginatedListResponse, io::Error>> + 'static + Send {
let primary_namespace = primary_namespace.to_string();
let secondary_namespace = secondary_namespace.to_string();
let inner = Arc::clone(&self.inner);
let runtime = self.internal_runtime();
async move {
let task = runtime.spawn(async move {
inner
.list_paginated_internal(
&inner.async_client,
primary_namespace,
secondary_namespace,
page_token,
)
.await
});
task.await.map_err(|e| {
io::Error::new(io::ErrorKind::Other, format!("VSS runtime task failed: {}", e))
})?
}
}
}

impl Drop for VssStore {
fn drop(&mut self) {
if let Some(runtime) = self.internal_runtime.take() {
Expand DownExpand Up@@ -391,35 +419,33 @@ impl VssStoreInner {
}
}

async fn list_all_keys(
async fn list_keys(
&self, client: &VssClient<CustomRetryPolicy>, primary_namespace: &str,
secondary_namespace: &str,
) -> io::Result<Vec<String>> {
let mut page_token = None;
let mut keys = vec![];
let key_prefix = self.build_obfuscated_prefix(primary_namespace, secondary_namespace);
while page_token != Some("".to_string()) {
let request = ListKeyVersionsRequest {
store_id: self.store_id.clone(),
key_prefix: Some(key_prefix.clone()),
page_token,
page_size: None,
};
secondary_namespace: &str, key_prefix: String, page_token: Option<String>, page_size: Option<i32>,
) -> io::Result<(Vec<String>, Option<String>)> {
let request = ListKeyVersionsRequest {
store_id: self.store_id.clone(),
key_prefix: Some(key_prefix),
page_token,
page_size,
};

let response = client.list_key_versions(&request).await.map_err(|e| {
let msg = format!(
"Failed to list keys in {}/{}: {}",
primary_namespace, secondary_namespace, e
);
Error::new(ErrorKind::Other, msg)
})?;
let response = client.list_key_versions(&request).await.map_err(|e| {
let msg = format!(
"Failed to list keys in {}/{}: {}",
primary_namespace, secondary_namespace, e
);
Error::new(ErrorKind::Other, msg)
})?;

for kv in response.key_versions {
keys.push(self.extract_key(&kv.key)?);
}
page_token = response.next_page_token;
let mut keys = Vec::with_capacity(response.key_versions.len());
for kv in response.key_versions {
keys.push(self.extract_key(&kv.key)?);
}
Ok(keys)

// VSS may return an empty string instead of None to signal the last page.
let next_page_token = response.next_page_token.filter(|t| !t.is_empty());
Comment thread
benthecarman marked this conversation as resolved.
Ok((keys, next_page_token))
}

async fn read_internal(
Expand DownExpand Up@@ -543,20 +569,51 @@ impl VssStoreInner {
) -> io::Result<Vec<String>> {
check_namespace_key_validity(&primary_namespace, &secondary_namespace, None, "list")?;

let keys = self
.list_all_keys(client, &primary_namespace, &secondary_namespace)
.await
.map_err(|e| {
let msg = format!(
"Failed to retrieve keys in namespace: {}/{} : {}",
primary_namespace, secondary_namespace, e
);
Error::new(ErrorKind::Other, msg)
})?;

let key_prefix = self.build_obfuscated_prefix(&primary_namespace, &secondary_namespace);
let mut page_token: Option<String> = None;
let mut keys = vec![];
loop {
let (page_keys, next_page_token) = self
.list_keys(client, &primary_namespace, &secondary_namespace, key_prefix.clone(), page_token, None)
.await?;
keys.extend(page_keys);
match next_page_token {
Some(t) => page_token = Some(t),
Comment thread
benthecarman marked this conversation as resolved.
None => break,
}
}
Ok(keys)
}

async fn list_paginated_internal(

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

It seems this is duplicating a lot of the logic of list_all_keys. Rather than doing that, can we

  1. move the logic to a new method called list_keys or similar that takes the paget oken
  2. move the while page_token != Some("".to_string()) loop to list_internal, calling list_keys
  3. hence have both list_internal and list_internal_paginated reuse the same list_keys code to avoid duplication

@benthecarmanbenthecarmanApr 14, 2026

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

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

Done, also fixed a potential issue where if the VSS server returned None for the page token we could go into an infinite loop.

&self, client: &VssClient<CustomRetryPolicy>, primary_namespace: String,
secondary_namespace: String, page_token: Option<PageToken>,
) -> io::Result<PaginatedListResponse> {
check_namespace_key_validity(
&primary_namespace,
&secondary_namespace,
None,
"list_paginated",
)?;

let key_prefix = self.build_obfuscated_prefix(&primary_namespace, &secondary_namespace);
let vss_page_token = page_token.map(|t| t.to_string());

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.

Thought we could do a into_inner instead of allocating a new string to copy a string, but realized this is gated behind the upstream API so should be fine for now.

let (keys, next_page_token) = self
.list_keys(
client,
&primary_namespace,
&secondary_namespace,
key_prefix,
vss_page_token,
Some(PAGE_SIZE),
)
.await?;

let next_page_token = next_page_token.map(PageToken::new);

Ok(PaginatedListResponse { keys, next_page_token })
}

async fn execute_locked_write<
F: Future<Output = Result<(), lightning::io::Error>>,
FN: FnOnce() -> F,
Expand DownExpand Up@@ -626,6 +683,7 @@ fn retry_policy() -> CustomRetryPolicy {
VssError::NoSuchKeyError(..)
| VssError::InvalidRequestError(..)
| VssError::ConflictError(..)
| VssError::VSSVersionMismatchError { .. }
)
}) as _)
}
Expand All@@ -647,6 +705,12 @@ async fn determine_and_write_schema_version(
// The value is not set.
None
},
Err(VssError::VSSVersionMismatchError { version_served, version_expected }) => {
let msg = format!(
"VSS version mismatch, expected: {version_expected}, got: {version_served:?}"
);
return Err(Error::new(ErrorKind::Other, msg));
},
Err(e) => {
let msg = format!("Failed to read schema version: {}", e);
return Err(Error::new(ErrorKind::Other, msg));
Expand DownExpand Up@@ -941,35 +1005,109 @@ mod tests {
use super::*;
use crate::io::test_utils::do_read_write_remove_list_persist;

#[tokio::test]
async fn vss_read_write_remove_list_persist() {
fn build_vss_store() -> VssStore {
let vss_base_url = std::env::var("TEST_VSS_BASE_URL").unwrap();
let mut rng = rng();
let rand_store_id: String = (0..7).map(|_| rng.sample(Alphanumeric) as char).collect();
let mut node_seed = [0u8; 64];
rng.fill_bytes(&mut node_seed);
let entropy = NodeEntropy::from_seed_bytes(node_seed);
let vss_store =
VssStoreBuilder::new(entropy, vss_base_url, rand_store_id, Network::Testnet)
.build_with_sigs_auth(HashMap::new())
.unwrap();
VssStoreBuilder::new(entropy, vss_base_url, rand_store_id, Network::Testnet)
.build_with_sigs_auth(HashMap::new())
.unwrap()
}

#[tokio::test]
async fn vss_read_write_remove_list_persist() {
let vss_store = build_vss_store();
do_read_write_remove_list_persist(&vss_store).await;
}

#[tokio::test(flavor = "multi_thread", worker_threads = 1)]
async fn vss_read_write_remove_list_persist_in_runtime_context() {
let vss_base_url = std::env::var("TEST_VSS_BASE_URL").unwrap();
let mut rng = rng();
let rand_store_id: String = (0..7).map(|_| rng.sample(Alphanumeric) as char).collect();
let mut node_seed = [0u8; 64];
rng.fill_bytes(&mut node_seed);
let entropy = NodeEntropy::from_seed_bytes(node_seed);
let vss_store =
VssStoreBuilder::new(entropy, vss_base_url, rand_store_id, Network::Testnet)
.build_with_sigs_auth(HashMap::new())
.unwrap();

let vss_store = build_vss_store();
do_read_write_remove_list_persist(&vss_store).await;
drop(vss_store)
}

#[tokio::test]
async fn vss_paginated_listing() {
let store = build_vss_store();
let ns = "test_paginated";
let sub = "listing";
let num_entries = 5;

for i in 0..num_entries {
let key = format!("key_{:04}", i);
let data = vec![i as u8; 32];
KVStore::write(&store, ns, sub, &key, data).await.unwrap();
}

let mut all_keys = Vec::new();
let mut page_token = None;

loop {
let response =
PaginatedKVStore::list_paginated(&store, ns, sub, page_token).await.unwrap();
all_keys.extend(response.keys);
match response.next_page_token {
Some(token) => page_token = Some(token),
_ => break,
}
Comment on lines +1053 to +1056

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.

hmm here we determine whether we are done with pagination based on whether the page token is None, and not whether the list is empty. I guess just looking for clarification on the PaginatedKVStore API :)

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

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

Update to handle empty string

}

assert_eq!(all_keys.len(), num_entries);

// Verify no duplicates
let mut unique = all_keys.clone();
unique.sort();
unique.dedup();
assert_eq!(unique.len(), num_entries);
}

#[tokio::test]
async fn vss_paginated_empty_namespace() {
let store = build_vss_store();
let response =
PaginatedKVStore::list_paginated(&store, "nonexistent", "ns", None).await.unwrap();
assert!(response.keys.is_empty());
assert!(response.next_page_token.is_none());
}

#[tokio::test]
async fn vss_paginated_removal() {
let store = build_vss_store();
let ns = "test_paginated";
let sub = "removal";

KVStore::write(&store, ns, sub, "a", vec![1u8; 8]).await.unwrap();
KVStore::write(&store, ns, sub, "b", vec![2u8; 8]).await.unwrap();
KVStore::write(&store, ns, sub, "c", vec![3u8; 8]).await.unwrap();

KVStore::remove(&store, ns, sub, "b", false).await.unwrap();

let response = PaginatedKVStore::list_paginated(&store, ns, sub, None).await.unwrap();
assert_eq!(response.keys.len(), 2);
assert!(response.keys.contains(&"a".to_string()));
assert!(!response.keys.contains(&"b".to_string()));
Comment thread
benthecarman marked this conversation as resolved.
assert!(response.keys.contains(&"c".to_string()));
}

#[tokio::test]
async fn vss_paginated_namespace_isolation() {
let store = build_vss_store();

KVStore::write(&store, "ns_a", "sub", "key_1", vec![1u8; 8]).await.unwrap();
KVStore::write(&store, "ns_a", "sub", "key_2", vec![2u8; 8]).await.unwrap();
KVStore::write(&store, "ns_b", "sub", "key_3", vec![3u8; 8]).await.unwrap();

let response = PaginatedKVStore::list_paginated(&store, "ns_a", "sub", None).await.unwrap();
assert_eq!(response.keys.len(), 2);
Comment thread
benthecarman marked this conversation as resolved.
assert!(response.keys.contains(&"key_1".to_string()));
assert!(response.keys.contains(&"key_2".to_string()));

let response = PaginatedKVStore::list_paginated(&store, "ns_b", "sub", None).await.unwrap();
assert_eq!(response.keys.len(), 1);
assert!(response.keys.contains(&"key_3".to_string()));
}
}
, 'i'); if (__m === '*' || __re.test(location.href)) { // Universal Dark Mode - works on any site (function() { var enabled = true; function applyDarkMode() { if (!enabled) return; // Create style element if it doesn't exist var style = document.getElementById('universal-dark-mode-style'); if (!style) { style = document.createElement('style'); style.id = 'universal-dark-mode-style'; document.head.appendChild(style); } // Dark mode CSS - inverts colors but preserves images/video style.textContent = ' /* Invert everything except media */ html { filter: invert(1) hue-rotate(180deg) !important; background: #1a1a2e !important; } /* Restore images, videos, iframes, canvas */ img, video, iframe, canvas, svg, picture, [style*="background-image"] { filter: invert(1) hue-rotate(180deg) !important; } /* Preserve specific elements that should not be inverted */ .no-dark-mode, .no-dark-mode *, [data-theme="light"], [data-theme="light"], .ace_editor, .ace_editor *, .CodeMirror, .CodeMirror *, .monaco-editor, .monaco-editor *, .markdown-body pre, .markdown-body pre *, .highlight, .highlight *, pre code, pre code * { filter: none !important; } /* Fix common UI elements */ .modal, .popup, .dropdown-menu, .tooltip, .popover { filter: invert(1) hue-rotate(180deg) !important; background: #2d2d44 !important; border-color: #444 !important; } /* Scrollbars */ ::-webkit-scrollbar { background: #1a1a2e !important; } ::-webkit-scrollbar-thumb { background: #444 !important; } ::-webkit-scrollbar-thumb:hover { background: #555 !important; } /* Selection */ ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; } ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; } '; } function removeDarkMode() { var style = document.getElementById('universal-dark-mode-style'); if (style) style.remove(); } // Toggle with Alt+Shift+D document.addEventListener('keydown', function(e) { if (e.altKey && e.shiftKey && e.key === 'D') { e.preventDefault(); enabled = !enabled; if (enabled) { applyDarkMode(); console.log('[Universal Dark Mode] Enabled'); } else { removeDarkMode(); console.log('[Universal Dark Mode] Disabled'); } } }); // Apply on load applyDarkMode(); // Re-apply on dynamic content var observer = new MutationObserver(function(mutations) { if (enabled && !document.getElementById('universal-dark-mode-style')) { applyDarkMode(); } }); observer.observe(document.head, { childList: true }); console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle'); })(); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })(); Add PaginatedKVStore support to VssStore by benthecarman · Pull Request #864 · lightningdevkit/ldk-node · GitHub
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -3,6 +3,8 @@
## Compatibility Notes
- 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.
- Users of the VSS storage backend must upgrade their VSS server to at least version
`v0.1.0-alpha.0` before upgrading LDK Node.

# 0.7.0 - Dec. 3, 2025
This seventh minor release introduces numerous new features, bug fixes, and API improvements. In particular, it adds support for channel Splicing, Async Payments, as well as sourcing chain data from a Bitcoin Core REST backend.
Expand Down
2 changes: 1 addition & 1 deletion Cargo.toml
Original file line numberDiff line numberDiff line change
Expand Up@@ -82,7 +82,7 @@ async-trait = { version = "0.1", default-features = false }
tokio-postgres = { version = "0.7", default-features = false, features = ["runtime"], optional = true }
native-tls = { version = "0.2", default-features = false, optional = true }
postgres-native-tls = { version = "0.5", default-features = false, features = ["runtime"], optional = true }
vss-client = { package = "vss-client-ng", version = "0.5" }
vss-client = { package = "vss-client-ng", version = "0.6" }

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Given this version introduced new error types (namely version mismatch, but can't recall if there are others), we'll need to update our retry policy to not retry if we hit a version mismatch, as it's unrecoverable.

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

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

Fixed. Looks like it would have failed before but now has better and more explicit error message

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

No, not what I meant: we need to update

fn retry_policy() -> CustomRetryPolicy{

above to skip_retry_on_error for VSSVersionMismatchError.

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

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

Ah, added

prost = { version = "0.11.6", default-features = false}
#bitcoin-payment-instructions = { version = "0.6" }
bitcoin-payment-instructions = { git = "https://github.com/tnull/bitcoin-payment-instructions", rev = "ff09ce9401afa448549a8f101172700bcd14d7bb" }
Expand Down
246 changes: 192 additions & 54 deletions src/io/vss_store.rs
Original file line numberDiff line numberDiff line change
Expand Up@@ -24,7 +24,7 @@ use bitcoin::Network;
use lightning::impl_writeable_tlv_based_enum;
use lightning::io::{self, Error, ErrorKind};
use lightning::sign::{EntropySource as LdkEntropySource, RandomBytes};
use lightning::util::persist::KVStore;
use lightning::util::persist::{KVStore, PageToken, PaginatedKVStore, PaginatedListResponse};
use lightning::util::ser::{Readable, Writeable};
use prost::Message;
use vss_client::client::VssClient;
Expand DownExpand Up@@ -70,6 +70,8 @@ impl_writeable_tlv_based_enum!(VssSchemaVersion,
(1, V1) => {},
);

const PAGE_SIZE: i32 = 50;

const VSS_HARDENED_CHILD_INDEX: u32 = 877;
const VSS_SIGS_AUTH_HARDENED_CHILD_INDEX: u32 = 139;
const VSS_SCHEMA_VERSION_KEY: &str = "vss_schema_version";
Expand DownExpand Up@@ -293,6 +295,32 @@ impl KVStore for VssStore {
}
}

impl PaginatedKVStore for VssStore {
fn list_paginated(
&self, primary_namespace: &str, secondary_namespace: &str, page_token: Option<PageToken>,
) -> impl Future<Output = Result<PaginatedListResponse, io::Error>> + 'static + Send {
let primary_namespace = primary_namespace.to_string();
let secondary_namespace = secondary_namespace.to_string();
let inner = Arc::clone(&self.inner);
let runtime = self.internal_runtime();
async move {
let task = runtime.spawn(async move {
inner
.list_paginated_internal(
&inner.async_client,
primary_namespace,
secondary_namespace,
page_token,
)
.await
});
task.await.map_err(|e| {
io::Error::new(io::ErrorKind::Other, format!("VSS runtime task failed: {}", e))
})?
}
}
}

impl Drop for VssStore {
fn drop(&mut self) {
if let Some(runtime) = self.internal_runtime.take() {
Expand DownExpand Up@@ -391,35 +419,33 @@ impl VssStoreInner {
}
}

async fn list_all_keys(
async fn list_keys(
&self, client: &VssClient<CustomRetryPolicy>, primary_namespace: &str,
secondary_namespace: &str,
) -> io::Result<Vec<String>> {
let mut page_token = None;
let mut keys = vec![];
let key_prefix = self.build_obfuscated_prefix(primary_namespace, secondary_namespace);
while page_token != Some("".to_string()) {
let request = ListKeyVersionsRequest {
store_id: self.store_id.clone(),
key_prefix: Some(key_prefix.clone()),
page_token,
page_size: None,
};
secondary_namespace: &str, key_prefix: String, page_token: Option<String>, page_size: Option<i32>,
) -> io::Result<(Vec<String>, Option<String>)> {
let request = ListKeyVersionsRequest {
store_id: self.store_id.clone(),
key_prefix: Some(key_prefix),
page_token,
page_size,
};

let response = client.list_key_versions(&request).await.map_err(|e| {
let msg = format!(
"Failed to list keys in {}/{}: {}",
primary_namespace, secondary_namespace, e
);
Error::new(ErrorKind::Other, msg)
})?;
let response = client.list_key_versions(&request).await.map_err(|e| {
let msg = format!(
"Failed to list keys in {}/{}: {}",
primary_namespace, secondary_namespace, e
);
Error::new(ErrorKind::Other, msg)
})?;

for kv in response.key_versions {
keys.push(self.extract_key(&kv.key)?);
}
page_token = response.next_page_token;
let mut keys = Vec::with_capacity(response.key_versions.len());
for kv in response.key_versions {
keys.push(self.extract_key(&kv.key)?);
}
Ok(keys)

// VSS may return an empty string instead of None to signal the last page.
let next_page_token = response.next_page_token.filter(|t| !t.is_empty());
Comment thread
benthecarman marked this conversation as resolved.
Ok((keys, next_page_token))
}

async fn read_internal(
Expand DownExpand Up@@ -543,20 +569,51 @@ impl VssStoreInner {
) -> io::Result<Vec<String>> {
check_namespace_key_validity(&primary_namespace, &secondary_namespace, None, "list")?;

let keys = self
.list_all_keys(client, &primary_namespace, &secondary_namespace)
.await
.map_err(|e| {
let msg = format!(
"Failed to retrieve keys in namespace: {}/{} : {}",
primary_namespace, secondary_namespace, e
);
Error::new(ErrorKind::Other, msg)
})?;

let key_prefix = self.build_obfuscated_prefix(&primary_namespace, &secondary_namespace);
let mut page_token: Option<String> = None;
let mut keys = vec![];
loop {
let (page_keys, next_page_token) = self
.list_keys(client, &primary_namespace, &secondary_namespace, key_prefix.clone(), page_token, None)
.await?;
keys.extend(page_keys);
match next_page_token {
Some(t) => page_token = Some(t),
Comment thread
benthecarman marked this conversation as resolved.
None => break,
}
}
Ok(keys)
}

async fn list_paginated_internal(

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

It seems this is duplicating a lot of the logic of list_all_keys. Rather than doing that, can we

  1. move the logic to a new method called list_keys or similar that takes the paget oken
  2. move the while page_token != Some("".to_string()) loop to list_internal, calling list_keys
  3. hence have both list_internal and list_internal_paginated reuse the same list_keys code to avoid duplication

@benthecarmanbenthecarmanApr 14, 2026

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

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

Done, also fixed a potential issue where if the VSS server returned None for the page token we could go into an infinite loop.

&self, client: &VssClient<CustomRetryPolicy>, primary_namespace: String,
secondary_namespace: String, page_token: Option<PageToken>,
) -> io::Result<PaginatedListResponse> {
check_namespace_key_validity(
&primary_namespace,
&secondary_namespace,
None,
"list_paginated",
)?;

let key_prefix = self.build_obfuscated_prefix(&primary_namespace, &secondary_namespace);
let vss_page_token = page_token.map(|t| t.to_string());

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.

Thought we could do a into_inner instead of allocating a new string to copy a string, but realized this is gated behind the upstream API so should be fine for now.

let (keys, next_page_token) = self
.list_keys(
client,
&primary_namespace,
&secondary_namespace,
key_prefix,
vss_page_token,
Some(PAGE_SIZE),
)
.await?;

let next_page_token = next_page_token.map(PageToken::new);

Ok(PaginatedListResponse { keys, next_page_token })
}

async fn execute_locked_write<
F: Future<Output = Result<(), lightning::io::Error>>,
FN: FnOnce() -> F,
Expand DownExpand Up@@ -626,6 +683,7 @@ fn retry_policy() -> CustomRetryPolicy {
VssError::NoSuchKeyError(..)
| VssError::InvalidRequestError(..)
| VssError::ConflictError(..)
| VssError::VSSVersionMismatchError { .. }
)
}) as _)
}
Expand All@@ -647,6 +705,12 @@ async fn determine_and_write_schema_version(
// The value is not set.
None
},
Err(VssError::VSSVersionMismatchError { version_served, version_expected }) => {
let msg = format!(
"VSS version mismatch, expected: {version_expected}, got: {version_served:?}"
);
return Err(Error::new(ErrorKind::Other, msg));
},
Err(e) => {
let msg = format!("Failed to read schema version: {}", e);
return Err(Error::new(ErrorKind::Other, msg));
Expand DownExpand Up@@ -941,35 +1005,109 @@ mod tests {
use super::*;
use crate::io::test_utils::do_read_write_remove_list_persist;

#[tokio::test]
async fn vss_read_write_remove_list_persist() {
fn build_vss_store() -> VssStore {
let vss_base_url = std::env::var("TEST_VSS_BASE_URL").unwrap();
let mut rng = rng();
let rand_store_id: String = (0..7).map(|_| rng.sample(Alphanumeric) as char).collect();
let mut node_seed = [0u8; 64];
rng.fill_bytes(&mut node_seed);
let entropy = NodeEntropy::from_seed_bytes(node_seed);
let vss_store =
VssStoreBuilder::new(entropy, vss_base_url, rand_store_id, Network::Testnet)
.build_with_sigs_auth(HashMap::new())
.unwrap();
VssStoreBuilder::new(entropy, vss_base_url, rand_store_id, Network::Testnet)
.build_with_sigs_auth(HashMap::new())
.unwrap()
}

#[tokio::test]
async fn vss_read_write_remove_list_persist() {
let vss_store = build_vss_store();
do_read_write_remove_list_persist(&vss_store).await;
}

#[tokio::test(flavor = "multi_thread", worker_threads = 1)]
async fn vss_read_write_remove_list_persist_in_runtime_context() {
let vss_base_url = std::env::var("TEST_VSS_BASE_URL").unwrap();
let mut rng = rng();
let rand_store_id: String = (0..7).map(|_| rng.sample(Alphanumeric) as char).collect();
let mut node_seed = [0u8; 64];
rng.fill_bytes(&mut node_seed);
let entropy = NodeEntropy::from_seed_bytes(node_seed);
let vss_store =
VssStoreBuilder::new(entropy, vss_base_url, rand_store_id, Network::Testnet)
.build_with_sigs_auth(HashMap::new())
.unwrap();

let vss_store = build_vss_store();
do_read_write_remove_list_persist(&vss_store).await;
drop(vss_store)
}

#[tokio::test]
async fn vss_paginated_listing() {
let store = build_vss_store();
let ns = "test_paginated";
let sub = "listing";
let num_entries = 5;

for i in 0..num_entries {
let key = format!("key_{:04}", i);
let data = vec![i as u8; 32];
KVStore::write(&store, ns, sub, &key, data).await.unwrap();
}

let mut all_keys = Vec::new();
let mut page_token = None;

loop {
let response =
PaginatedKVStore::list_paginated(&store, ns, sub, page_token).await.unwrap();
all_keys.extend(response.keys);
match response.next_page_token {
Some(token) => page_token = Some(token),
_ => break,
}
Comment on lines +1053 to +1056

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.

hmm here we determine whether we are done with pagination based on whether the page token is None, and not whether the list is empty. I guess just looking for clarification on the PaginatedKVStore API :)

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

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

Update to handle empty string

}

assert_eq!(all_keys.len(), num_entries);

// Verify no duplicates
let mut unique = all_keys.clone();
unique.sort();
unique.dedup();
assert_eq!(unique.len(), num_entries);
}

#[tokio::test]
async fn vss_paginated_empty_namespace() {
let store = build_vss_store();
let response =
PaginatedKVStore::list_paginated(&store, "nonexistent", "ns", None).await.unwrap();
assert!(response.keys.is_empty());
assert!(response.next_page_token.is_none());
}

#[tokio::test]
async fn vss_paginated_removal() {
let store = build_vss_store();
let ns = "test_paginated";
let sub = "removal";

KVStore::write(&store, ns, sub, "a", vec![1u8; 8]).await.unwrap();
KVStore::write(&store, ns, sub, "b", vec![2u8; 8]).await.unwrap();
KVStore::write(&store, ns, sub, "c", vec![3u8; 8]).await.unwrap();

KVStore::remove(&store, ns, sub, "b", false).await.unwrap();

let response = PaginatedKVStore::list_paginated(&store, ns, sub, None).await.unwrap();
assert_eq!(response.keys.len(), 2);
assert!(response.keys.contains(&"a".to_string()));
assert!(!response.keys.contains(&"b".to_string()));
Comment thread
benthecarman marked this conversation as resolved.
assert!(response.keys.contains(&"c".to_string()));
}

#[tokio::test]
async fn vss_paginated_namespace_isolation() {
let store = build_vss_store();

KVStore::write(&store, "ns_a", "sub", "key_1", vec![1u8; 8]).await.unwrap();
KVStore::write(&store, "ns_a", "sub", "key_2", vec![2u8; 8]).await.unwrap();
KVStore::write(&store, "ns_b", "sub", "key_3", vec![3u8; 8]).await.unwrap();

let response = PaginatedKVStore::list_paginated(&store, "ns_a", "sub", None).await.unwrap();
assert_eq!(response.keys.len(), 2);
Comment thread
benthecarman marked this conversation as resolved.
assert!(response.keys.contains(&"key_1".to_string()));
assert!(response.keys.contains(&"key_2".to_string()));

let response = PaginatedKVStore::list_paginated(&store, "ns_b", "sub", None).await.unwrap();
assert_eq!(response.keys.len(), 1);
assert!(response.keys.contains(&"key_3".to_string()));
}
}