From 8731433b680701dc2fbefe54e0f90aaec52f44c6 Mon Sep 17 00:00:00 2001 From: Leo Nash Date: Mon, 25 May 2026 16:49:30 +0000 Subject: [PATCH 1/3] Add `vss_client_v050_compatibility` test We recently added the requirement that VSS servers return key-version lists in creation order. We also added a version header to all VSS responses such that clients requiring creation-order lists can ensure the VSS server they talk to provides this guarantee. These VSS server changes remain fully backwards compatible with previously-released VSS clients. Here we add a test that ensures that this is true against the last VSS client release made prior to these changes. This new test also makes sure that any future changes to VSS server remain backwards compatible with VSS client v0.5.0. AI wrote the test. --- ...mpl_tests.yml => implementation-tests.yml} | 3 +- ...ion.yml => ldk-node-integration-tests.yml} | 2 +- ...ild-and-deploy-rust.yml => ping-tests.yml} | 5 +- .github/workflows/server-tests.yml | 46 +++ rust/Cargo.lock | 182 +++++++++- rust/Cargo.toml | 2 +- rust/server/Cargo.toml | 3 + .../tests/vss_client_v050_compatibility.rs | 328 ++++++++++++++++++ 8 files changed, 562 insertions(+), 9 deletions(-) rename .github/workflows/{impl_tests.yml => implementation-tests.yml} (94%) rename .github/workflows/{ldk-node-integration.yml => ldk-node-integration-tests.yml} (98%) rename .github/workflows/{build-and-deploy-rust.yml => ping-tests.yml} (96%) create mode 100644 .github/workflows/server-tests.yml create mode 100644 rust/server/tests/vss_client_v050_compatibility.rs diff --git a/.github/workflows/impl_tests.yml b/.github/workflows/implementation-tests.yml similarity index 94% rename from .github/workflows/impl_tests.yml rename to .github/workflows/implementation-tests.yml index d740a2f6..20a116eb 100644 --- a/.github/workflows/impl_tests.yml +++ b/.github/workflows/implementation-tests.yml @@ -7,13 +7,14 @@ concurrency: cancel-in-progress: true jobs: - test-postgres-backend: + postgres-backend-tests: strategy: fail-fast: false matrix: platform: [ ubuntu-latest ] toolchain: [ stable, 1.85.0 ] # 1.85.0 is the MSRV runs-on: ${{ matrix.platform }} + timeout-minutes: 15 services: postgres: diff --git a/.github/workflows/ldk-node-integration.yml b/.github/workflows/ldk-node-integration-tests.yml similarity index 98% rename from .github/workflows/ldk-node-integration.yml rename to .github/workflows/ldk-node-integration-tests.yml index 73ce8202..6dcec98b 100644 --- a/.github/workflows/ldk-node-integration.yml +++ b/.github/workflows/ldk-node-integration-tests.yml @@ -7,7 +7,7 @@ concurrency: cancel-in-progress: true jobs: - build-and-test: + ldk-node-integration-tests: strategy: fail-fast: false matrix: diff --git a/.github/workflows/build-and-deploy-rust.yml b/.github/workflows/ping-tests.yml similarity index 96% rename from .github/workflows/build-and-deploy-rust.yml rename to .github/workflows/ping-tests.yml index 1cc7aefa..07c2d545 100644 --- a/.github/workflows/build-and-deploy-rust.yml +++ b/.github/workflows/ping-tests.yml @@ -1,4 +1,4 @@ -name: Ping Check +name: Ping Tests on: [push, pull_request] @@ -7,13 +7,14 @@ concurrency: cancel-in-progress: true jobs: - ping-check: + ping-tests: strategy: fail-fast: false matrix: platform: [ ubuntu-latest ] toolchain: [ stable, 1.85.0 ] # 1.85.0 is the MSRV runs-on: ${{ matrix.platform }} + timeout-minutes: 15 services: postgres: diff --git a/.github/workflows/server-tests.yml b/.github/workflows/server-tests.yml new file mode 100644 index 00000000..acdc83b4 --- /dev/null +++ b/.github/workflows/server-tests.yml @@ -0,0 +1,46 @@ +name: Server Tests + +on: [push, pull_request] + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + server-tests: + strategy: + fail-fast: false + matrix: + platform: [ ubuntu-latest ] + toolchain: [ stable, 1.85.0 ] # 1.85.0 is the MSRV + runs-on: ${{ matrix.platform }} + timeout-minutes: 15 + + services: + postgres: + image: postgres:latest + ports: + - 5432:5432 + env: + POSTGRES_DB: postgres + POSTGRES_USER: postgres + POSTGRES_PASSWORD: postgres + options: >- + --health-cmd pg_isready + --health-interval 10s + --health-timeout 5s + --health-retries 5 + + steps: + - name: Checkout code + uses: actions/checkout@v3 + - name: Build and Deploy VSS Server + run: | + cd rust + RUSTFLAGS="--cfg noop_authorizer" cargo build --no-default-features + ./target/debug/vss-server server/vss-server-config.toml& + - name: Run the server tests + run: | + sleep 5 + cd rust/server + RUSTFLAGS="--cfg vss_client_v050_compatibility" cargo test diff --git a/rust/Cargo.lock b/rust/Cargo.lock index 9c67c152..0aab3dfa 100644 --- a/rust/Cargo.lock +++ b/rust/Cargo.lock @@ -71,7 +71,7 @@ dependencies = [ "hex-conservative 1.0.1", "jsonwebtoken", "openssl", - "secp256k1", + "secp256k1 0.31.1", "serde", "serde_json", "tokio", @@ -83,21 +83,60 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" +[[package]] +name = "base58ck" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c8d66485a3a2ea485c1913c4572ce0256067a5377ac8c75c4960e1cda98605f" +dependencies = [ + "bitcoin-internals 0.3.0", + "bitcoin_hashes 0.14.1", +] + [[package]] name = "base64" version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" +[[package]] +name = "bech32" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32637268377fc7b10a8c6d51de3e7fba1ce5dd371a96e342b34e6078db558e7f" + +[[package]] +name = "bitcoin" +version = "0.32.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9cf93e61f2dbc3e3c41234ca26a65e2c0b0975c52e0f069ab9893ebbede584d3" +dependencies = [ + "base58ck", + "bech32", + "bitcoin-internals 0.3.0", + "bitcoin-io", + "bitcoin-units", + "bitcoin_hashes 0.14.1", + "hex-conservative 0.2.2", + "hex_lit", + "secp256k1 0.29.1", +] + [[package]] name = "bitcoin-consensus-encoding" version = "1.0.0-rc.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9cd69023e5db2f3f7241672de6be29408373ba0ff407e7fda71d70d728bec05a" dependencies = [ - "bitcoin-internals", + "bitcoin-internals 0.5.0", ] +[[package]] +name = "bitcoin-internals" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30bdbe14aa07b06e6cfeffc529a1f099e5fbe249524f8125358604df99a4bed2" + [[package]] name = "bitcoin-internals" version = "0.5.0" @@ -110,6 +149,15 @@ version = "0.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2dee39a0ee5b4095224a0cfc6bf4cc1baf0f9624b96b367e53b66d974e51d953" +[[package]] +name = "bitcoin-units" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "346568ebaab2918487cea76dd55dae13c27bb618cdb737c952e69eb2017c4118" +dependencies = [ + "bitcoin-internals 0.3.0", +] + [[package]] name = "bitcoin_hashes" version = "0.14.1" @@ -127,7 +175,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9aaf7add9aa250546d4d7a0ad0755a25327f5205dc2d7eba6b6ec08cd864c79e" dependencies = [ "bitcoin-consensus-encoding", - "bitcoin-internals", + "bitcoin-internals 0.5.0", ] [[package]] @@ -136,6 +184,19 @@ version = "2.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "812e12b5285cc515a9c72a5c1d3b6d46a19dac5acfef5265968c166106e31dd3" +[[package]] +name = "bitreq" +version = "0.3.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6df90cd78f0510165fd370574676aeb57dbec0ee3bfff68645bb7b0e9a65dbd5" +dependencies = [ + "rustls", + "rustls-webpki", + "tokio", + "tokio-rustls", + "webpki-roots", +] + [[package]] name = "block-buffer" version = "0.10.4" @@ -179,6 +240,12 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" +[[package]] +name = "chacha20-poly1305" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b4b0fc281743d80256607bd65e8beedc42cb0787ea119c85b81b4c0eab85e5f" + [[package]] name = "chrono" version = "0.4.43" @@ -411,6 +478,12 @@ version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "366fa3443ac84474447710ec17bb00b05dfbd096137817981e86f992f21a2793" +[[package]] +name = "hex_lit" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3011d1213f159867b13cfd6ac92d2cd5f1345762c63be3554e84092d85a50bbd" + [[package]] name = "hmac" version = "0.12.1" @@ -1149,6 +1222,40 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "rustls" +version = "0.23.40" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef86cd5876211988985292b91c96a8f2d298df24e75989a43a3c73f2d4d8168b" +dependencies = [ + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-pki-types" +version = "1.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30a7197ae7eb376e574fe940d068c30fe0462554a3ddbe4eca7838e049c937a9" +dependencies = [ + "zeroize", +] + +[[package]] +name = "rustls-webpki" +version = "0.103.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + [[package]] name = "rustversion" version = "1.0.22" @@ -1170,6 +1277,17 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" +[[package]] +name = "secp256k1" +version = "0.29.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9465315bc9d4566e1724f0fffcbcc446268cb522e60f9a27bcded6b19c108113" +dependencies = [ + "bitcoin_hashes 0.14.1", + "rand 0.8.5", + "secp256k1-sys 0.10.1", +] + [[package]] name = "secp256k1" version = "0.31.1" @@ -1178,7 +1296,16 @@ checksum = "2c3c81b43dc2d8877c216a3fccf76677ee1ebccd429566d3e67447290d0c42b2" dependencies = [ "bitcoin_hashes 0.14.1", "rand 0.9.2", - "secp256k1-sys", + "secp256k1-sys 0.11.0", +] + +[[package]] +name = "secp256k1-sys" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4387882333d3aa8cb20530a17c69a3752e97837832f34f6dccc760e715001d9" +dependencies = [ + "cc", ] [[package]] @@ -1507,6 +1634,16 @@ dependencies = [ "whoami", ] +[[package]] +name = "tokio-rustls" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" +dependencies = [ + "rustls", + "tokio", +] + [[package]] name = "tokio-util" version = "0.7.18" @@ -1605,6 +1742,27 @@ version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" +[[package]] +name = "vss-client-ng" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6334cb4940aba86a2e2aa9dde7c722a2510f55815422088a2a2ac24f46579e6a" +dependencies = [ + "async-trait", + "base64", + "bitcoin", + "bitcoin_hashes 0.14.1", + "bitreq", + "chacha20-poly1305", + "log", + "prost", + "prost-build", + "rand 0.8.5", + "serde", + "serde_json", + "tokio", +] + [[package]] name = "vss-server" version = "0.1.0" @@ -1623,6 +1781,7 @@ dependencies = [ "serde", "tokio", "toml", + "vss-client-ng", ] [[package]] @@ -1713,6 +1872,15 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "webpki-roots" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52f5ee44c96cf55f1b349600768e3ece3a8f26010c05265ab73f945bb1a2eb9d" +dependencies = [ + "rustls-pki-types", +] + [[package]] name = "which" version = "4.4.2" @@ -1995,6 +2163,12 @@ dependencies = [ "syn 2.0.114", ] +[[package]] +name = "zeroize" +version = "1.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" + [[package]] name = "zmij" version = "1.0.19" diff --git a/rust/Cargo.toml b/rust/Cargo.toml index 5a4fbf6c..e5de3502 100644 --- a/rust/Cargo.toml +++ b/rust/Cargo.toml @@ -16,4 +16,4 @@ panic = "abort" [workspace.lints.rust.unexpected_cfgs] level = "forbid" -check-cfg = ['cfg(noop_authorizer)', 'cfg(genproto)'] +check-cfg = ['cfg(noop_authorizer)', 'cfg(genproto)', 'cfg(vss_client_v050_compatibility)'] diff --git a/rust/server/Cargo.toml b/rust/server/Cargo.toml index c3a44698..084a2000 100644 --- a/rust/server/Cargo.toml +++ b/rust/server/Cargo.toml @@ -29,5 +29,8 @@ rand = { version = "0.9.2", default-features = false } [target.'cfg(noop_authorizer)'.dependencies] api = { path = "../api", features = ["_test_utils"] } +[target.'cfg(vss_client_v050_compatibility)'.dev-dependencies] +vss-client-v050 = { package = "vss-client-ng", version = "=0.5.0" } + [lints] workspace = true diff --git a/rust/server/tests/vss_client_v050_compatibility.rs b/rust/server/tests/vss_client_v050_compatibility.rs new file mode 100644 index 00000000..cc2a7a69 --- /dev/null +++ b/rust/server/tests/vss_client_v050_compatibility.rs @@ -0,0 +1,328 @@ +//! Compatibility shakedown for the pinned vss-client-ng v0.5.0 dependency against current +//! vss-server master. This test assumes a no-auth VSS server is already running at +//! `localhost:8080` and exercises a full client lifecycle through the public v0.5.0 client API: +//! empty listing, missing-key reads, conditional and non-conditional writes, gets, conflict +//! handling, transactional put/delete, direct deletes, paginated listing, and cleanup. + +#![cfg(vss_client_v050_compatibility)] + +use std::collections::{BTreeMap, BTreeSet}; +use std::time::{Duration, SystemTime, UNIX_EPOCH}; + +use vss_client_v050::client::VssClient; +use vss_client_v050::error::VssError; +use vss_client_v050::types::{ + DeleteObjectRequest, GetObjectRequest, GetObjectResponse, KeyValue, ListKeyVersionsRequest, + PutObjectRequest, +}; +use vss_client_v050::util::retry::{ExponentialBackoffRetryPolicy, RetryPolicy}; + +const VSS_SERVER_BASE_URL: &str = "http://localhost:8080/vss"; +const KEY_ALPHA: &str = "compat/alpha"; +const KEY_BETA: &str = "compat/beta"; +const KEY_DELTA: &str = "compat/delta"; +const KEY_EPSILON: &str = "compat/epsilon"; +const KEY_GAMMA: &str = "compat/gamma"; +const KEY_OUTSIDE_PREFIX: &str = "outside-prefix"; +const KEY_STALE_GLOBAL: &str = "compat/stale-global"; +const KEY_THETA: &str = "compat/theta"; +const KEY_PREFIX: &str = "compat/"; +const GLOBAL_VERSION_KEY: &str = "global_version"; +const LIST_PAGE_SIZE: i32 = 2; + +#[tokio::test] +async fn test_vss_client_v050_compatibility() -> Result<(), VssError> { + let client = VssClient::new(VSS_SERVER_BASE_URL.to_string(), retry_policy()); + let store_id = unique_store_id(); + let mut global_version = 0; + + let empty_list = + client.list_key_versions(&list_request(&store_id, None, Some(10), None)).await?; + // A new store should report the initial global version. + assert_eq!(empty_list.global_version, Some(global_version)); + // A new store should not contain any key-version entries. + assert!(empty_list.key_versions.is_empty()); + // An empty result set should also be the final page. + assert_eq!(empty_list.next_page_token.as_deref(), Some("")); + + // Reading a key that has never been written should surface the protocol's missing-key error. + assert_no_such_key(client.get_object(&get_request(&store_id, "missing")).await, "missing"); + + client + .put_object(&put_request( + &store_id, + Some(global_version), + vec![kv(KEY_ALPHA, 0, b"alpha-v1"), kv(KEY_BETA, 0, b"beta-v1")], + vec![], + )) + .await?; + global_version += 1; + + // The first conditional write should make alpha readable at server-side version 1. + assert_key_value(&client, &store_id, KEY_ALPHA, 1, b"alpha-v1").await?; + // The first conditional write should make beta readable at server-side version 1. + assert_key_value(&client, &store_id, KEY_BETA, 1, b"beta-v1").await?; + + client + .put_object(&put_request( + &store_id, + Some(global_version), + vec![ + kv(KEY_ALPHA, 1, b"alpha-v2"), + kv(KEY_GAMMA, 0, b"gamma-v1"), + kv(KEY_OUTSIDE_PREFIX, 0, b"outside-prefix-v1"), + ], + vec![], + )) + .await?; + global_version += 1; + + // Updating alpha with the matching key version should advance alpha to version 2. + assert_key_value(&client, &store_id, KEY_ALPHA, 2, b"alpha-v2").await?; + // Creating gamma in the same request should make it readable at version 1. + assert_key_value(&client, &store_id, KEY_GAMMA, 1, b"gamma-v1").await?; + + let stale_put = client + .put_object(&put_request( + &store_id, + Some(global_version), + vec![kv(KEY_ALPHA, 1, b"stale-alpha")], + vec![], + )) + .await; + // Reusing alpha's old key version should be rejected as a conflict. + assert_conflict(stale_put); + // The rejected stale write must not change alpha's committed value. + assert_key_value(&client, &store_id, KEY_ALPHA, 2, b"alpha-v2").await?; + + let stale_global_version_put = client + .put_object(&put_request( + &store_id, + Some(global_version - 1), + vec![kv(KEY_STALE_GLOBAL, 0, b"stale-global-version")], + vec![], + )) + .await; + // Reusing an old global version should be rejected independently of key-level versions. + assert_conflict(stale_global_version_put); + // A failed global-version write must not create the requested key. + assert_no_such_key( + client.get_object(&get_request(&store_id, KEY_STALE_GLOBAL)).await, + KEY_STALE_GLOBAL, + ); + + client + .put_object(&put_request( + &store_id, + Some(global_version), + vec![kv(KEY_DELTA, -1, b"delta-v1")], + vec![], + )) + .await?; + global_version += 1; + + client + .put_object(&put_request( + &store_id, + Some(global_version), + vec![kv(KEY_DELTA, -1, b"delta-v2")], + vec![], + )) + .await?; + global_version += 1; + + // Non-conditional writes should reset the server-side key version to 1 and keep the last value. + assert_key_value(&client, &store_id, KEY_DELTA, 1, b"delta-v2").await?; + + client + .put_object(&put_request( + &store_id, + Some(global_version), + vec![kv(KEY_THETA, 0, b"theta-v1")], + vec![kv(KEY_BETA, 1, b"")], + )) + .await?; + global_version += 1; + + // A transaction mixing a put and delete should commit the put side. + assert_key_value(&client, &store_id, KEY_THETA, 1, b"theta-v1").await?; + // The same transaction should remove beta atomically. + assert_no_such_key(client.get_object(&get_request(&store_id, KEY_BETA)).await, KEY_BETA); + + client.delete_object(&delete_request(&store_id, KEY_GAMMA, 1)).await?; + client.delete_object(&delete_request(&store_id, KEY_GAMMA, 1)).await?; + // Repeating a direct delete should leave gamma deleted and exercise delete idempotency. + assert_no_such_key(client.get_object(&get_request(&store_id, KEY_GAMMA)).await, KEY_GAMMA); + + client + .put_object(&put_request(&store_id, None, vec![kv(KEY_EPSILON, 0, b"epsilon-v1")], vec![])) + .await?; + // A write without global-version checking should still create the key at version 1. + assert_key_value(&client, &store_id, KEY_EPSILON, 1, b"epsilon-v1").await?; + + let listed_versions = + list_all_key_versions(&client, &store_id, Some(KEY_PREFIX), global_version).await?; + let listed_keys: BTreeSet<&str> = listed_versions.keys().map(String::as_str).collect(); + // Prefix listing should include only the live keys under compat/ after deletes and conflicts. + assert_eq!(listed_keys, BTreeSet::from([KEY_ALPHA, KEY_DELTA, KEY_EPSILON, KEY_THETA])); + // Listing should report alpha's latest key version. + assert_eq!(listed_versions[KEY_ALPHA], 2); + // Listing should report delta's non-conditional write version. + assert_eq!(listed_versions[KEY_DELTA], 1); + // Listing should report epsilon's no-global-version write version. + assert_eq!(listed_versions[KEY_EPSILON], 1); + // Listing should report theta's transactional write version. + assert_eq!(listed_versions[KEY_THETA], 1); + + let cleanup_keys = + [KEY_ALPHA, KEY_DELTA, KEY_EPSILON, KEY_THETA, KEY_OUTSIDE_PREFIX, GLOBAL_VERSION_KEY]; + for key in cleanup_keys { + client.delete_object(&delete_request(&store_id, key, -1)).await?; + } + + let final_list = + client.list_key_versions(&list_request(&store_id, None, Some(10), None)).await?; + // Deleting the protocol global-version key should make the store report the default version. + assert_eq!(final_list.global_version, Some(0)); + // Cleanup should leave no key-version entries behind for this store. + assert!(final_list.key_versions.is_empty()); + // Cleanup should leave the final list response on its last page. + assert_eq!(final_list.next_page_token.as_deref(), Some("")); + + Ok(()) +} + +fn retry_policy() -> impl RetryPolicy { + ExponentialBackoffRetryPolicy::new(Duration::from_millis(10)).with_max_attempts(1) +} + +fn unique_store_id() -> String { + let nanos = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("system clock must be after UNIX epoch") + .as_nanos(); + format!("v050-compat-{nanos}") +} + +fn get_request(store_id: &str, key: &str) -> GetObjectRequest { + GetObjectRequest { store_id: store_id.to_string(), key: key.to_string() } +} + +fn put_request( + store_id: &str, global_version: Option, transaction_items: Vec, + delete_items: Vec, +) -> PutObjectRequest { + PutObjectRequest { + store_id: store_id.to_string(), + global_version, + transaction_items, + delete_items, + } +} + +fn delete_request(store_id: &str, key: &str, version: i64) -> DeleteObjectRequest { + DeleteObjectRequest { store_id: store_id.to_string(), key_value: Some(kv(key, version, b"")) } +} + +fn list_request( + store_id: &str, page_token: Option, page_size: Option, key_prefix: Option<&str>, +) -> ListKeyVersionsRequest { + ListKeyVersionsRequest { + store_id: store_id.to_string(), + key_prefix: key_prefix.map(str::to_string), + page_size, + page_token, + } +} + +fn kv(key: &str, version: i64, value: &[u8]) -> KeyValue { + KeyValue { key: key.to_string(), version, value: value.to_vec() } +} + +async fn assert_key_value( + client: &VssClient>, store_id: &str, key: &str, + expected_version: i64, expected_value: &[u8], +) -> Result<(), VssError> { + let response = client.get_object(&get_request(store_id, key)).await?; + let value = response_value(response, key); + // The server must echo the requested key in a successful get response. + assert_eq!(value.key, key); + // The key-level version must match the lifecycle step's expected version. + assert_eq!(value.version, expected_version); + // The stored bytes must round-trip unchanged through the v0.5.0 client. + assert_eq!(value.value, expected_value); + Ok(()) +} + +fn response_value(response: GetObjectResponse, key: &str) -> KeyValue { + // A successful get response must include a KeyValue payload. + response.value.unwrap_or_else(|| panic!("expected GetObjectResponse to include {key}")) +} + +fn assert_no_such_key(result: Result, key: &str) { + match result { + // The expected protocol error is the only accepted missing-key outcome. + Err(VssError::NoSuchKeyError(_)) => {}, + // Any other error would indicate the request failed for the wrong reason. + Err(e) => panic!("expected {key} to be missing, got {e}"), + // A successful get would mean the key unexpectedly exists. + Ok(_) => panic!("expected {key} to be missing"), + } +} + +fn assert_conflict(result: Result) { + match result { + // The expected protocol error is the only accepted conflict outcome. + Err(VssError::ConflictError(_)) => {}, + // Any other error would indicate the rejected write failed for the wrong reason. + Err(e) => panic!("expected conflict error, got {e}"), + // A successful write would mean conflict detection is not working. + Ok(_) => panic!("expected conflict error"), + } +} + +async fn list_all_key_versions( + client: &VssClient>, store_id: &str, key_prefix: Option<&str>, + expected_global_version: i64, +) -> Result, VssError> { + let mut page_token = None; + let mut key_versions = BTreeMap::new(); + let mut page_count = 0; + + loop { + let page = client + .list_key_versions(&list_request( + store_id, + page_token.take(), + Some(LIST_PAGE_SIZE), + key_prefix, + )) + .await?; + // Each paginated response must honor the requested maximum page size. + assert!(page.key_versions.len() <= LIST_PAGE_SIZE as usize); + + if page_count == 0 { + // Only the first page should include the store's global version. + assert_eq!(page.global_version, Some(expected_global_version)); + } else { + // Follow-up pages should omit the global version per the VSS protocol. + assert!(page.global_version.is_none()); + } + page_count += 1; + + for key_value in page.key_versions { + // List responses should include only key/version metadata, not stored values. + assert!(key_value.value.is_empty()); + key_versions.insert(key_value.key, key_value.version); + } + + match page.next_page_token { + Some(token) if !token.is_empty() => page_token = Some(token), + _ => break, + } + } + + // With four matching keys and a page size of two, this path must exercise pagination. + assert!(page_count > 1); + Ok(key_versions) +} From 8592436e814a27212a602f6296f8859e4bc77e6e Mon Sep 17 00:00:00 2001 From: Leo Nash Date: Mon, 25 May 2026 19:47:00 +0000 Subject: [PATCH 2/3] Fix CI to actually run rust stable and MSRV --- .github/workflows/implementation-tests.yml | 4 ++++ .github/workflows/ldk-node-integration-tests.yml | 9 +++++++++ .github/workflows/ping-tests.yml | 6 +++++- .github/workflows/server-tests.yml | 4 ++++ 4 files changed, 22 insertions(+), 1 deletion(-) diff --git a/.github/workflows/implementation-tests.yml b/.github/workflows/implementation-tests.yml index 20a116eb..4aa629c7 100644 --- a/.github/workflows/implementation-tests.yml +++ b/.github/workflows/implementation-tests.yml @@ -36,6 +36,10 @@ jobs: uses: actions/checkout@v3 with: path: vss-server + - name: Install Rust toolchain + run: | + rustup toolchain install ${{ matrix.toolchain }} --profile minimal + rustup default ${{ matrix.toolchain }} - name: Run postgres backend test suite run: | diff --git a/.github/workflows/ldk-node-integration-tests.yml b/.github/workflows/ldk-node-integration-tests.yml index 6dcec98b..707905c5 100644 --- a/.github/workflows/ldk-node-integration-tests.yml +++ b/.github/workflows/ldk-node-integration-tests.yml @@ -41,12 +41,21 @@ jobs: with: repository: lightningdevkit/ldk-node path: ldk-node + - name: Install Rust toolchain + run: | + rustup toolchain install ${{ matrix.toolchain }} --profile minimal + rustup default ${{ matrix.toolchain }} - name: Build and Deploy VSS Server run: | cd vss-server/rust RUSTFLAGS="--cfg noop_authorizer" cargo build --no-default-features ./target/debug/vss-server server/vss-server-config.toml& + - name: Pin packages to allow for MSRV + if: "matrix.toolchain == '1.85.0'" + run: | + cd ldk-node + cargo update -p idna_adapter --precise "1.2.0" --verbose # idna_adapter 1.2.1 uses ICU4X 2.2.0, requiring 1.86 and newer - name: Run LDK Node Integration tests run: | cd ldk-node diff --git a/.github/workflows/ping-tests.yml b/.github/workflows/ping-tests.yml index 07c2d545..10b7a22f 100644 --- a/.github/workflows/ping-tests.yml +++ b/.github/workflows/ping-tests.yml @@ -34,8 +34,12 @@ jobs: steps: - name: Checkout code uses: actions/checkout@v3 + - name: Install Rust toolchain + run: | + rustup toolchain install ${{ matrix.toolchain }} --profile minimal --component rustfmt + rustup default ${{ matrix.toolchain }} - name: Check formatting - run: rustup component add rustfmt && cd rust && cargo fmt --all -- --check + run: cd rust && cargo fmt --all -- --check - name: Build and Deploy VSS Server run: | cd rust diff --git a/.github/workflows/server-tests.yml b/.github/workflows/server-tests.yml index acdc83b4..e46b123e 100644 --- a/.github/workflows/server-tests.yml +++ b/.github/workflows/server-tests.yml @@ -34,6 +34,10 @@ jobs: steps: - name: Checkout code uses: actions/checkout@v3 + - name: Install Rust toolchain + run: | + rustup toolchain install ${{ matrix.toolchain }} --profile minimal + rustup default ${{ matrix.toolchain }} - name: Build and Deploy VSS Server run: | cd rust From 3a1d16f106ca6c619486cdb318cc61e0af6f6742 Mon Sep 17 00:00:00 2001 From: Leo Nash Date: Mon, 1 Jun 2026 17:07:17 +0000 Subject: [PATCH 3/3] Run the compat test against vss-client main branch In a previous commit, we added a compatibility test against vss-client v0.5.0. We now also run the same test against the current vss-client main branch. --- .github/workflows/server-tests.yml | 2 + rust/Cargo.lock | 23 ++++++- rust/Cargo.toml | 7 +- rust/server/Cargo.toml | 3 + ...ibility.rs => vss_client_compatibility.rs} | 67 ++++++++++++------- 5 files changed, 76 insertions(+), 26 deletions(-) rename rust/server/tests/{vss_client_v050_compatibility.rs => vss_client_compatibility.rs} (84%) diff --git a/.github/workflows/server-tests.yml b/.github/workflows/server-tests.yml index e46b123e..27b1830a 100644 --- a/.github/workflows/server-tests.yml +++ b/.github/workflows/server-tests.yml @@ -48,3 +48,5 @@ jobs: sleep 5 cd rust/server RUSTFLAGS="--cfg vss_client_v050_compatibility" cargo test + cargo update -p vss-client-ng@0.6.0 --verbose + RUSTFLAGS="--cfg vss_client_main_compatibility" cargo test diff --git a/rust/Cargo.lock b/rust/Cargo.lock index 0aab3dfa..8e411188 100644 --- a/rust/Cargo.lock +++ b/rust/Cargo.lock @@ -1763,6 +1763,26 @@ dependencies = [ "tokio", ] +[[package]] +name = "vss-client-ng" +version = "0.6.0" +source = "git+https://github.com/lightningdevkit/vss-client.git?branch=main#88cfcc11d5a460d7bae57b56ae9211d33979533f" +dependencies = [ + "async-trait", + "base64", + "bitcoin", + "bitcoin_hashes 0.14.1", + "bitreq", + "chacha20-poly1305", + "log", + "prost", + "prost-build", + "rand 0.8.5", + "serde", + "serde_json", + "tokio", +] + [[package]] name = "vss-server" version = "0.1.0" @@ -1781,7 +1801,8 @@ dependencies = [ "serde", "tokio", "toml", - "vss-client-ng", + "vss-client-ng 0.5.0", + "vss-client-ng 0.6.0", ] [[package]] diff --git a/rust/Cargo.toml b/rust/Cargo.toml index e5de3502..899bd267 100644 --- a/rust/Cargo.toml +++ b/rust/Cargo.toml @@ -16,4 +16,9 @@ panic = "abort" [workspace.lints.rust.unexpected_cfgs] level = "forbid" -check-cfg = ['cfg(noop_authorizer)', 'cfg(genproto)', 'cfg(vss_client_v050_compatibility)'] +check-cfg = [ + 'cfg(noop_authorizer)', + 'cfg(genproto)', + 'cfg(vss_client_v050_compatibility)', + 'cfg(vss_client_main_compatibility)', +] diff --git a/rust/server/Cargo.toml b/rust/server/Cargo.toml index 084a2000..57739c04 100644 --- a/rust/server/Cargo.toml +++ b/rust/server/Cargo.toml @@ -32,5 +32,8 @@ api = { path = "../api", features = ["_test_utils"] } [target.'cfg(vss_client_v050_compatibility)'.dev-dependencies] vss-client-v050 = { package = "vss-client-ng", version = "=0.5.0" } +[target.'cfg(vss_client_main_compatibility)'.dev-dependencies] +vss-client-main = { package = "vss-client-ng", git = "https://github.com/lightningdevkit/vss-client.git", branch = "main" } + [lints] workspace = true diff --git a/rust/server/tests/vss_client_v050_compatibility.rs b/rust/server/tests/vss_client_compatibility.rs similarity index 84% rename from rust/server/tests/vss_client_v050_compatibility.rs rename to rust/server/tests/vss_client_compatibility.rs index cc2a7a69..78739932 100644 --- a/rust/server/tests/vss_client_v050_compatibility.rs +++ b/rust/server/tests/vss_client_compatibility.rs @@ -1,21 +1,25 @@ -//! Compatibility shakedown for the pinned vss-client-ng v0.5.0 dependency against current -//! vss-server master. This test assumes a no-auth VSS server is already running at -//! `localhost:8080` and exercises a full client lifecycle through the public v0.5.0 client API: -//! empty listing, missing-key reads, conditional and non-conditional writes, gets, conflict -//! handling, transactional put/delete, direct deletes, paginated listing, and cleanup. +//! Compatibility shakedown for vss-client-ng dependencies against current vss-server master. This +//! test assumes a no-auth VSS server is already running at `localhost:8080` and exercises a full +//! client lifecycle through the public client API: empty listing, missing-key reads, conditional +//! and non-conditional writes, gets, conflict handling, transactional put/delete, direct deletes, +//! paginated listing, and cleanup. -#![cfg(vss_client_v050_compatibility)] +#![cfg(any(vss_client_v050_compatibility, vss_client_main_compatibility))] -use std::collections::{BTreeMap, BTreeSet}; use std::time::{Duration, SystemTime, UNIX_EPOCH}; -use vss_client_v050::client::VssClient; -use vss_client_v050::error::VssError; -use vss_client_v050::types::{ +#[cfg(vss_client_main_compatibility)] +use vss_client_main as vss_client; +#[cfg(vss_client_v050_compatibility)] +use vss_client_v050 as vss_client; + +use vss_client::client::VssClient; +use vss_client::error::VssError; +use vss_client::types::{ DeleteObjectRequest, GetObjectRequest, GetObjectResponse, KeyValue, ListKeyVersionsRequest, PutObjectRequest, }; -use vss_client_v050::util::retry::{ExponentialBackoffRetryPolicy, RetryPolicy}; +use vss_client::util::retry::{ExponentialBackoffRetryPolicy, RetryPolicy}; const VSS_SERVER_BASE_URL: &str = "http://localhost:8080/vss"; const KEY_ALPHA: &str = "compat/alpha"; @@ -31,7 +35,7 @@ const GLOBAL_VERSION_KEY: &str = "global_version"; const LIST_PAGE_SIZE: i32 = 2; #[tokio::test] -async fn test_vss_client_v050_compatibility() -> Result<(), VssError> { +async fn test_vss_client_compatibility() -> Result<(), VssError> { let client = VssClient::new(VSS_SERVER_BASE_URL.to_string(), retry_policy()); let store_id = unique_store_id(); let mut global_version = 0; @@ -162,17 +166,32 @@ async fn test_vss_client_v050_compatibility() -> Result<(), VssError> { let listed_versions = list_all_key_versions(&client, &store_id, Some(KEY_PREFIX), global_version).await?; - let listed_keys: BTreeSet<&str> = listed_versions.keys().map(String::as_str).collect(); - // Prefix listing should include only the live keys under compat/ after deletes and conflicts. - assert_eq!(listed_keys, BTreeSet::from([KEY_ALPHA, KEY_DELTA, KEY_EPSILON, KEY_THETA])); + let listed_keys: Vec<&str> = + listed_versions.iter().map(|(k, _v)| k).map(String::as_str).collect(); + // Prefix listing should include only the live keys under compat/ after deletes and conflicts, and + // in creation order. vss-client-v050 does not require creation-time ordering, but it is within + // its API contract. vss-client-v060 and onwards require creation-time ordering. + assert_eq!(listed_keys, [KEY_EPSILON, KEY_THETA, KEY_DELTA, KEY_ALPHA]); // Listing should report alpha's latest key version. - assert_eq!(listed_versions[KEY_ALPHA], 2); + assert_eq!( + listed_versions.iter().find_map(|(k, v)| (k == KEY_ALPHA).then_some(*v)).unwrap(), + 2 + ); // Listing should report delta's non-conditional write version. - assert_eq!(listed_versions[KEY_DELTA], 1); + assert_eq!( + listed_versions.iter().find_map(|(k, v)| (k == KEY_DELTA).then_some(*v)).unwrap(), + 1 + ); // Listing should report epsilon's no-global-version write version. - assert_eq!(listed_versions[KEY_EPSILON], 1); + assert_eq!( + listed_versions.iter().find_map(|(k, v)| (k == KEY_EPSILON).then_some(*v)).unwrap(), + 1 + ); // Listing should report theta's transactional write version. - assert_eq!(listed_versions[KEY_THETA], 1); + assert_eq!( + listed_versions.iter().find_map(|(k, v)| (k == KEY_THETA).then_some(*v)).unwrap(), + 1 + ); let cleanup_keys = [KEY_ALPHA, KEY_DELTA, KEY_EPSILON, KEY_THETA, KEY_OUTSIDE_PREFIX, GLOBAL_VERSION_KEY]; @@ -201,7 +220,7 @@ fn unique_store_id() -> String { .duration_since(UNIX_EPOCH) .expect("system clock must be after UNIX epoch") .as_nanos(); - format!("v050-compat-{nanos}") + format!("vss-client-compat-{nanos}") } fn get_request(store_id: &str, key: &str) -> GetObjectRequest { @@ -249,7 +268,7 @@ async fn assert_key_value( assert_eq!(value.key, key); // The key-level version must match the lifecycle step's expected version. assert_eq!(value.version, expected_version); - // The stored bytes must round-trip unchanged through the v0.5.0 client. + // The stored bytes must round-trip unchanged through the client. assert_eq!(value.value, expected_value); Ok(()) } @@ -284,9 +303,9 @@ fn assert_conflict(result: Result) { async fn list_all_key_versions( client: &VssClient>, store_id: &str, key_prefix: Option<&str>, expected_global_version: i64, -) -> Result, VssError> { +) -> Result, VssError> { let mut page_token = None; - let mut key_versions = BTreeMap::new(); + let mut key_versions = Vec::new(); let mut page_count = 0; loop { @@ -313,7 +332,7 @@ async fn list_all_key_versions( for key_value in page.key_versions { // List responses should include only key/version metadata, not stored values. assert!(key_value.value.is_empty()); - key_versions.insert(key_value.key, key_value.version); + key_versions.push((key_value.key, key_value.version)); } match page.next_page_token {