From e34174ec0a1b06ab22d6c923e60380389fd20956 Mon Sep 17 00:00:00 2001 From: Josh Date: Mon, 19 Oct 2026 19:25:23 +0200 Subject: [PATCH 1/7] fix(module): gate WASM CLI-spec registration on unix; fix storage test types ModuleManager::ipc_server only exists on unix, but the WASM load path read it ungated, so blvm-node failed to compile for x86_64-pc-windows-gnu with wasm-modules (E0609/E0282; blvm run 39185005040, Windows blvm-sdk experimental). Gate the registration like the other ipc_server uses. tests/storage_tests.rs: the witness-compression test passed a witness stack one level too shallow for store_witness(&[Vec]), and the utxo-compression test used a u32 value and an unwrapped UTXO. Build the correct shapes (one tx -> one input -> one stack; i64 value; Arc); no API change. --- src/module/manager.rs | 1 + tests/storage_tests.rs | 7 ++++--- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/src/module/manager.rs b/src/module/manager.rs index 3ab97c35..6423324d 100644 --- a/src/module/manager.rs +++ b/src/module/manager.rs @@ -721,6 +721,7 @@ impl ModuleManager { }; modules.insert(module_name.to_string(), managed); drop(modules); + #[cfg(unix)] if let Some(ref ipc) = self.ipc_server { ipc.lock() .await diff --git a/tests/storage_tests.rs b/tests/storage_tests.rs index c4f19463..0426a473 100644 --- a/tests/storage_tests.rs +++ b/tests/storage_tests.rs @@ -1215,7 +1215,8 @@ fn test_witness_compression_roundtrip() { .build(); let block_hash = blockstore.get_block_hash(&block); - let witnesses = vec![vec![vec![0x01, 0x02, 0x03, 0x04, 0x05]]]; // Single witness stack + // One transaction -> one input -> witness stack with a single item. + let witnesses: Vec> = vec![vec![vec![vec![0x01, 0x02, 0x03, 0x04, 0x05]]]]; // Store witness blockstore.store_witness(&block_hash, &witnesses).unwrap(); @@ -1302,12 +1303,12 @@ fn test_utxo_compression_auto_detection() { index: i, }; let utxo = UTXO { - value: 1000000 * (i + 1), + value: 1000000 * (i64::from(i) + 1), script_pubkey: p2pkh_script(random_hash20()).into(), height: 0, is_coinbase: false, }; - utxo_set.insert(outpoint, utxo); + utxo_set.insert(outpoint, Arc::new(utxo)); } utxostore.store_utxo_set(&utxo_set).unwrap(); From 12c326440b4def5261b1e93f671ce100604b3fe5 Mon Sep 17 00:00:00 2001 From: Josh Date: Mon, 19 Oct 2026 19:34:08 +0200 Subject: [PATCH 2/7] test(payment): use utils::new_request_id instead of the removed uuid crate c161636 dropped uuid from the manifest in favour of utils::new_request_id, but payment_rest_integration_tests still imported uuid::Uuid, so the test failed to compile with ctv+bip70-http+rest-api (any --all-features --tests build). It was hidden behind the storage_tests failure, which stops cargo first. --- tests/payment_rest_integration_tests.rs | 22 +++++++++++----------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/tests/payment_rest_integration_tests.rs b/tests/payment_rest_integration_tests.rs index 0c9fd288..b992d1bf 100644 --- a/tests/payment_rest_integration_tests.rs +++ b/tests/payment_rest_integration_tests.rs @@ -24,7 +24,7 @@ use hyper::{Method, Response, StatusCode}; use serde_json::json; use std::sync::Arc; use tempfile::TempDir; -use uuid::Uuid; +use blvm_node::utils::new_request_id; /// Helper to create test payment state machine fn create_test_state_machine() -> Arc { @@ -73,7 +73,7 @@ async fn extract_response_body( #[tokio::test] async fn test_rest_create_vault() { let (state_machine, _temp_dir) = create_test_state_machine_with_storage(); - let request_id = Uuid::new_v4().to_string(); + let request_id = new_request_id(); let body = Some(json!({ "vault_id": "test_vault_rest_1", "deposit_amount": 100000, @@ -98,7 +98,7 @@ async fn test_rest_create_vault() { #[tokio::test] async fn test_rest_create_vault_missing_fields() { let (state_machine, _temp_dir) = create_test_state_machine_with_storage(); - let request_id = Uuid::new_v4().to_string(); + let request_id = new_request_id(); let body = Some(json!({ "vault_id": "test_vault_rest_2" // Missing deposit_amount and withdrawal_script @@ -120,7 +120,7 @@ async fn test_rest_create_vault_missing_fields() { #[tokio::test] async fn test_rest_unvault_vault() { let (state_machine, _temp_dir) = create_test_state_machine_with_storage(); - let request_id = Uuid::new_v4().to_string(); + let request_id = new_request_id(); // First create vault let create_body = Some(json!({ @@ -163,7 +163,7 @@ async fn test_rest_unvault_vault() { #[tokio::test] async fn test_rest_get_vault_state() { let (state_machine, _temp_dir) = create_test_state_machine_with_storage(); - let request_id = Uuid::new_v4().to_string(); + let request_id = new_request_id(); // Create vault let create_body = Some(json!({ @@ -203,7 +203,7 @@ async fn test_rest_get_vault_state() { #[tokio::test] async fn test_rest_create_pool() { let (state_machine, _temp_dir) = create_test_state_machine_with_storage(); - let request_id = Uuid::new_v4().to_string(); + let request_id = new_request_id(); let body = Some(json!({ "pool_id": "test_pool_rest_1", "initial_participants": [ @@ -238,7 +238,7 @@ async fn test_rest_create_pool() { #[tokio::test] async fn test_rest_join_pool() { let (state_machine, _temp_dir) = create_test_state_machine_with_storage(); - let request_id = Uuid::new_v4().to_string(); + let request_id = new_request_id(); // Create pool let create_body = Some(json!({ @@ -285,7 +285,7 @@ async fn test_rest_join_pool() { #[tokio::test] async fn test_rest_get_pool_state() { let (state_machine, _temp_dir) = create_test_state_machine_with_storage(); - let request_id = Uuid::new_v4().to_string(); + let request_id = new_request_id(); // Create pool let create_body = Some(json!({ @@ -330,7 +330,7 @@ async fn test_rest_get_pool_state() { #[tokio::test] async fn test_rest_create_batch() { let (state_machine, _temp_dir) = create_test_state_machine_with_storage(); - let request_id = Uuid::new_v4().to_string(); + let request_id = new_request_id(); let body = Some(json!({ "batch_id": "test_batch_rest_1", "target_fee_rate": 10 @@ -354,7 +354,7 @@ async fn test_rest_create_batch() { #[tokio::test] async fn test_rest_get_batch_state() { let (state_machine, _temp_dir) = create_test_state_machine_with_storage(); - let request_id = Uuid::new_v4().to_string(); + let request_id = new_request_id(); // Create batch let create_body = Some(json!({ @@ -388,7 +388,7 @@ async fn test_rest_get_batch_state() { #[tokio::test] async fn test_rest_get_congestion_metrics() { let (state_machine, _temp_dir) = create_test_state_machine_with_storage(); - let request_id = Uuid::new_v4().to_string(); + let request_id = new_request_id(); // Note: get_congestion_metrics requires mempool manager // Without mempool, it will return an error From 62daa4aa8594a5a37dd259ec51ae5b252e7c1d65 Mon Sep 17 00:00:00 2001 From: Josh Date: Mon, 19 Oct 2026 19:54:29 +0200 Subject: [PATCH 3/7] style: rustfmt import order in payment_rest_integration_tests --- tests/payment_rest_integration_tests.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/payment_rest_integration_tests.rs b/tests/payment_rest_integration_tests.rs index b992d1bf..4ad952fa 100644 --- a/tests/payment_rest_integration_tests.rs +++ b/tests/payment_rest_integration_tests.rs @@ -17,6 +17,7 @@ use blvm_node::rpc::rest::pool::handle_pool_request; #[cfg(feature = "rest-api")] use blvm_node::rpc::rest::vault::handle_vault_request; use blvm_node::storage::Storage; +use blvm_node::utils::new_request_id; use bytes::Bytes; use http_body_util::BodyExt; use http_body_util::Full; @@ -24,7 +25,6 @@ use hyper::{Method, Response, StatusCode}; use serde_json::json; use std::sync::Arc; use tempfile::TempDir; -use blvm_node::utils::new_request_id; /// Helper to create test payment state machine fn create_test_state_machine() -> Arc { From d5079c8d1ba5ea8b310908ce5e485d5771ce7fd6 Mon Sep 17 00:00:00 2001 From: Josh Date: Tue, 20 Oct 2026 15:50:58 +0200 Subject: [PATCH 4/7] fix(rpc): refill the RPC rate limiter from a monotonic clock RpcRateLimiter refilled from current_timestamp(), whole wall-clock Unix seconds. Any wait that straddled a second boundary was credited a full second of tokens, so 2s could count as 3s and a request right after the burst could be refilled after a few microseconds. That is the flake in rpc::rest::tests::security_tests::test_rate_limiting_time_window (run 39405496449). Wall-clock steps (NTP, manual) also moved the bucket. The limiter now uses tokio::time::Instant and credits elapsed nanoseconds exactly: elapsed_ns * rate plus a carried sub-token remainder (units of 1e-9 token, u128 math), whole tokens into the bucket, remainder carried. Tokens stay clamped to burst_limit; a full bucket or rate 0 banks nothing. Burst, rate and batch (n > burst) semantics are unchanged, and every caller still builds the limiter with the same (burst, rate) as before. last_refill() now returns the monotonic Instant of the last check, and cleanup_stale_limiters() compares against Instant::now() with the same one-hour threshold. Tests: limiter tests that slept now run on a paused tokio clock with advance() (tokio test-util as a dev-dependency). New unit tests cover a real-clock second-boundary reproduction, wall-clock independence, exact carry over 1000 x 1ms and with interleaved consumption, the burst cap, huge elapsed/rate without overflow, and monotonic stale eviction. The ignored QUIC 429 smoke test uses rate 0 so it no longer depends on timing. --- Cargo.toml | 2 + src/rpc/auth_impl.rs | 228 +++++++++++++++++++++++--- src/rpc/rest/tests/security_tests.rs | 16 +- tests/quic_rpc_smoke_tests.rs | 2 +- tests/rpc_auth_comprehensive_tests.rs | 42 +++-- tests/rpc_auth_security_tests.rs | 4 +- tests/rpc_rate_limiting_tests.rs | 131 +++++++-------- 7 files changed, 307 insertions(+), 118 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index e2c414c8..8cd74140 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -330,6 +330,8 @@ criterion = "=0.5.1" proptest = "=1.5.0" dhat = "=0.3" # Heap profiling serial_test = "=3.0" # Sequential test execution for database isolation +# Paused/advanced tokio clock for deterministic rate-limiter tests (same version as the main dep) +tokio = { version = "=1.48.0", features = ["test-util"] } # Release profile optimizations (mirrors blvm-consensus) [profile.release] diff --git a/src/rpc/auth_impl.rs b/src/rpc/auth_impl.rs index 9872346c..13f79260 100644 --- a/src/rpc/auth_impl.rs +++ b/src/rpc/auth_impl.rs @@ -12,7 +12,9 @@ use hyper::HeaderMap; use std::collections::{HashMap, HashSet}; use std::net::SocketAddr; use std::sync::Arc; +use std::time::Duration; use tokio::sync::Mutex; +use tokio::time::Instant; use tracing::{debug, warn}; /// Opaque bearer token registered server-side (not a self-describing JWT/HMAC credential). @@ -52,6 +54,13 @@ pub struct AuthResult { } /// Token bucket rate limiter for RPC requests +/// +/// Refill is driven by the monotonic [`tokio::time::Instant`] clock, so wall-clock +/// adjustments (NTP steps, manual clock changes) cannot add or withhold tokens. +/// Elapsed time is credited exactly in integer arithmetic: each refill adds +/// `elapsed_ns * rate` to a sub-token remainder (in units of 1e-9 token), whole +/// tokens are moved into the bucket and the rest is carried, so tokens accrue at +/// exactly `rate` per second however calls are spaced. pub struct RpcRateLimiter { /// Current number of tokens available tokens: u32, @@ -59,19 +68,55 @@ pub struct RpcRateLimiter { burst_limit: u32, /// Tokens per second refill rate rate: u32, - /// Last refill timestamp (Unix seconds) - last_refill: u64, + /// Monotonic time of the last refill + last_refill: Instant, + /// Fractional token carried between refills, in units of 1e-9 token (< 1e9) + carry: u64, } +const NANOS_PER_SEC: u128 = 1_000_000_000; + impl RpcRateLimiter { /// Create a new rate limiter pub fn new(burst_limit: u32, rate: u32) -> Self { - let now = current_timestamp(); Self { tokens: burst_limit, burst_limit, rate, - last_refill: now, + last_refill: Instant::now(), + carry: 0, + } + } + + /// Credit tokens for the time elapsed since the last refill, carrying any fractional + /// remainder forward. Never exceeds `burst_limit`. + fn refill(&mut self) { + let now = Instant::now(); + let elapsed_ns = now.saturating_duration_since(self.last_refill).as_nanos(); + self.last_refill = now; + + if self.rate == 0 || self.tokens >= self.burst_limit { + // Nothing can be added (no refill, or bucket already full); time spent + // here is not banked toward future tokens. + self.tokens = self.tokens.min(self.burst_limit); + self.carry = 0; + return; + } + + // u128 cannot overflow: elapsed_ns < 2^94 (Duration max), rate < 2^32, carry < 2^30. + let credit = elapsed_ns + .saturating_mul(u128::from(self.rate)) + .saturating_add(u128::from(self.carry)); + let earned = credit / NANOS_PER_SEC; + let room = u128::from(self.burst_limit - self.tokens); + if earned >= room { + // Bucket fills: excess tokens and the remainder are dropped. + self.tokens = self.burst_limit; + self.carry = 0; + } else { + // earned < room <= u32::MAX and the remainder is < 1e9: casts are lossless. + self.tokens += earned as u32; + self.carry = (credit % NANOS_PER_SEC) as u64; } } @@ -89,18 +134,7 @@ impl RpcRateLimiter { return false; } - let now = current_timestamp(); - - // Refill tokens based on elapsed time - let elapsed = now.saturating_sub(self.last_refill); - if elapsed > 0 { - let tokens_to_add = (elapsed as u32).saturating_mul(self.rate); - self.tokens = self - .tokens - .saturating_add(tokens_to_add) - .min(self.burst_limit); - self.last_refill = now; - } + self.refill(); if self.tokens >= n { self.tokens -= n; @@ -115,8 +149,9 @@ impl RpcRateLimiter { self.tokens } - /// Last refill timestamp (for stale limiter eviction) - pub fn last_refill(&self) -> u64 { + /// Monotonic time of the last refill, i.e. the last rate-limit check (for stale + /// limiter eviction) + pub fn last_refill(&self) -> Instant { self.last_refill } } @@ -618,11 +653,12 @@ impl RpcAuthManager { /// Clean up rate limiters for users inactive beyond threshold (default: 1 hour) pub async fn cleanup_stale_limiters(&self) { - const STALE_THRESHOLD_SECS: u64 = 3600; // 1 hour - let now = current_timestamp(); + const STALE_THRESHOLD: Duration = Duration::from_secs(3600); // 1 hour + let now = Instant::now(); let mut limiters = self.rate_limiters.lock().await; - limiters - .retain(|_, limiter| now.saturating_sub(limiter.last_refill()) < STALE_THRESHOLD_SECS); + limiters.retain(|_, limiter| { + now.saturating_duration_since(limiter.last_refill()) < STALE_THRESHOLD + }); } } @@ -832,3 +868,151 @@ impl AuthFailureTracker { exceeded } } + +#[cfg(test)] +mod rate_limiter_tests { + use super::*; + use std::time::{SystemTime, UNIX_EPOCH}; + use tokio::time::advance; + + fn exhaust(limiter: &mut RpcRateLimiter) { + while limiter.check_and_consume() {} + assert_eq!(limiter.tokens_remaining(), 0); + } + + /// Regression for the CI flake in `test_rate_limiting_time_window`: refill used whole + /// wall-clock Unix seconds, so a short wait that straddled a second boundary was + /// credited as a full second. Runs on the real clock on purpose. + #[tokio::test] + async fn short_wait_across_wall_clock_second_boundary_earns_nothing() { + // Line up just before a wall-clock second boundary. + loop { + let sub_ms = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .subsec_millis(); + if (900..950).contains(&sub_ms) { + break; + } + std::thread::sleep(Duration::from_millis(5)); + } + let mut limiter = RpcRateLimiter::new(5, 1); + exhaust(&mut limiter); + // ~200ms of real time crosses the boundary but is only 0.2 tokens at 1/sec. + std::thread::sleep(Duration::from_millis(200)); + assert!( + !limiter.check_and_consume(), + "200ms at 1 token/sec must not refill a token" + ); + } + + /// The limiter follows the monotonic tokio clock, not the wall clock: real (wall) + /// time passing while the monotonic clock stands still must not add tokens. + #[tokio::test(start_paused = true)] + async fn wall_clock_progress_does_not_refill() { + let mut limiter = RpcRateLimiter::new(3, 1); + exhaust(&mut limiter); + std::thread::sleep(Duration::from_millis(1100)); + assert!(!limiter.check_and_consume()); + advance(Duration::from_secs(1)).await; + assert!(limiter.check_and_consume()); + assert!(!limiter.check_and_consume()); + } + + /// Many small advances credit exactly `rate * elapsed` whole tokens, never more. + #[tokio::test(start_paused = true)] + async fn fractional_carry_never_overcredits() { + for rate in [1u32, 3, 7, 10, 999, 1000, 1001] { + let mut limiter = RpcRateLimiter::new(u32::MAX, rate); + limiter.tokens = 0; + for step in 1..=1000u64 { + advance(Duration::from_millis(1)).await; + limiter.refill(); + let exact = u64::from(rate) * step / 1000; // floor(rate * step ms) + assert_eq!( + u64::from(limiter.tokens_remaining()), + exact, + "rate {rate}/s after {step}ms" + ); + } + assert_eq!(limiter.tokens_remaining(), rate, "rate {rate}/s after 1s"); + } + } + + /// Consuming between small advances does not leak or gain tokens either. + #[tokio::test(start_paused = true)] + async fn carry_with_interleaved_consumption_is_exact() { + let mut limiter = RpcRateLimiter::new(1000, 7); + exhaust(&mut limiter); + let mut granted = 0u32; + for _ in 0..10_000 { + advance(Duration::from_millis(1)).await; + if limiter.check_and_consume() { + granted += 1; + } + } + // 10s at 7/sec + assert_eq!(granted, 70); + assert_eq!(limiter.tokens_remaining(), 0); + } + + /// Tokens are clamped to the burst limit no matter how long the limiter idles. + #[tokio::test(start_paused = true)] + async fn burst_cap_never_exceeded() { + for (burst, rate) in [(1u32, 1u32), (5, 100), (100, 10), (10, u32::MAX)] { + let mut limiter = RpcRateLimiter::new(burst, rate); + exhaust(&mut limiter); + for idle in [100u64, 3600, 86_400 * 365] { + advance(Duration::from_secs(idle)).await; + limiter.refill(); + assert_eq!(limiter.tokens_remaining(), burst); + exhaust(&mut limiter); + } + // Idle while full: time spent full is not banked beyond the cap. + advance(Duration::from_secs(10)).await; + limiter.refill(); + assert_eq!(limiter.tokens_remaining(), burst); + assert!(!limiter.check_and_consume_n(burst + 1)); + } + } + + /// Extreme elapsed time and rates neither overflow nor panic. + #[tokio::test(start_paused = true)] + async fn huge_elapsed_and_rate_do_not_overflow() { + let mut limiter = RpcRateLimiter::new(u32::MAX, u32::MAX); + assert!(limiter.check_and_consume_n(u32::MAX)); + assert_eq!(limiter.tokens_remaining(), 0); + // ~100 years + advance(Duration::from_secs(100 * 365 * 86_400)).await; + assert!(limiter.check_and_consume_n(u32::MAX)); + // Partial refill at maximum rate: 1ns earns floor(u32::MAX / 1e9) = 4 tokens. + advance(Duration::from_nanos(1)).await; + limiter.refill(); + assert_eq!(limiter.tokens_remaining(), 4); + + // A limiter last refilled long ago clamps cleanly at maximum rate. + let mut old = RpcRateLimiter::new(3, u32::MAX); + exhaust(&mut old); + advance(Duration::from_secs(10 * 365 * 86_400)).await; + assert!(old.check_and_consume()); + assert_eq!(old.tokens_remaining(), 2); + } + + /// Stale-limiter eviction uses the monotonic clock: idle limiters go after an hour, + /// active ones stay. + #[tokio::test(start_paused = true)] + async fn stale_limiters_evicted_on_monotonic_clock() { + let auth = RpcAuthManager::with_rate_limits(false, 5, 1); + let idle = UserId::Ip("127.0.0.1:1".parse().unwrap()); + let active = UserId::Ip("127.0.0.1:2".parse().unwrap()); + assert!(auth.check_rate_limit(&idle).await); + assert!(auth.check_rate_limit(&active).await); + advance(Duration::from_secs(3599)).await; + assert!(auth.check_rate_limit(&active).await); + advance(Duration::from_secs(2)).await; + auth.cleanup_stale_limiters().await; + let limiters = auth.rate_limiters.lock().await; + assert!(!limiters.contains_key(&idle)); + assert!(limiters.contains_key(&active)); + } +} diff --git a/src/rpc/rest/tests/security_tests.rs b/src/rpc/rest/tests/security_tests.rs index 88c49586..c4820fc2 100644 --- a/src/rpc/rest/tests/security_tests.rs +++ b/src/rpc/rest/tests/security_tests.rs @@ -8,7 +8,7 @@ use crate::rpc::auth::{RpcAuthManager, UserId}; use std::net::SocketAddr; -use tokio::time::{Duration, sleep}; +use tokio::time::{Duration, advance}; // Test strategy: we test security logic (auth, rate limit, validation) without starting the server. @@ -54,7 +54,7 @@ async fn test_rest_api_auth_optional() { } /// Test rate limiting with endpoint information -#[tokio::test] +#[tokio::test(start_paused = true)] async fn test_rest_api_rate_limiting_with_endpoint() { let auth = RpcAuthManager::with_rate_limits(false, 5, 1); // 5 burst, 1/sec let user_id = UserId::Ip("127.0.0.1:8080".parse().unwrap()); @@ -80,7 +80,7 @@ async fn test_rest_api_rate_limiting_with_endpoint() { } /// Test IP-based rate limiting for unauthenticated requests -#[tokio::test] +#[tokio::test(start_paused = true)] async fn test_rest_api_ip_rate_limiting() { let auth = RpcAuthManager::with_rate_limits(false, 5, 1); // 5 burst, 1/sec let addr: SocketAddr = "127.0.0.1:8080".parse().unwrap(); @@ -244,7 +244,7 @@ async fn test_rest_oversized_body_returns_error() { } /// Test that rate limiting respects time windows -#[tokio::test] +#[tokio::test(start_paused = true)] async fn test_rate_limiting_time_window() { let auth = RpcAuthManager::with_rate_limits(false, 5, 1); // 5 burst, 1/sec let user_id = UserId::Ip("127.0.0.1:8080".parse().unwrap()); @@ -257,10 +257,12 @@ async fn test_rate_limiting_time_window() { // Should be rate limited immediately assert!(!auth.check_rate_limit(&user_id).await); - // Wait 2 seconds (should refill 2 tokens) - sleep(Duration::from_secs(2)).await; + // Just under one second: no whole token earned yet + advance(Duration::from_millis(999)).await; + assert!(!auth.check_rate_limit(&user_id).await); - // Should now allow 2 more requests + // 2 seconds in total refill exactly 2 tokens + advance(Duration::from_millis(1001)).await; assert!(auth.check_rate_limit(&user_id).await); assert!(auth.check_rate_limit(&user_id).await); assert!(!auth.check_rate_limit(&user_id).await); diff --git a/tests/quic_rpc_smoke_tests.rs b/tests/quic_rpc_smoke_tests.rs index 74155bc3..5b13901f 100644 --- a/tests/quic_rpc_smoke_tests.rs +++ b/tests/quic_rpc_smoke_tests.rs @@ -112,7 +112,7 @@ async fn h3_json_rpc( async fn quic_rpc_rate_limit_rejection() { let quinn_addr: SocketAddr = "127.0.0.1:18333".parse().unwrap(); let placeholder: SocketAddr = "127.0.0.1:0".parse().unwrap(); - let auth_manager = Arc::new(RpcAuthManager::with_rate_limits(false, 2, 1)); + let auth_manager = Arc::new(RpcAuthManager::with_rate_limits(false, 2, 0)); // no refill: 429 is timing-independent let rpc = Arc::new(RpcServer::with_auth(placeholder, auth_manager)); let server = QuinnRpcServer::new(quinn_addr, rpc); diff --git a/tests/rpc_auth_comprehensive_tests.rs b/tests/rpc_auth_comprehensive_tests.rs index b133b82c..bd4b4730 100644 --- a/tests/rpc_auth_comprehensive_tests.rs +++ b/tests/rpc_auth_comprehensive_tests.rs @@ -59,8 +59,8 @@ fn test_rate_limiter_consume_token() { assert_eq!(limiter.tokens_remaining(), 9); } -#[test] -fn test_rate_limiter_exhaust_tokens() { +#[tokio::test(start_paused = true)] +async fn test_rate_limiter_exhaust_tokens() { let mut limiter = RpcRateLimiter::new(3, 1); // Consume all tokens @@ -73,33 +73,39 @@ fn test_rate_limiter_exhaust_tokens() { assert!(!limiter.check_and_consume()); } -#[test] -fn test_rate_limiter_refill() { +#[tokio::test(start_paused = true)] +async fn test_rate_limiter_refill() { let mut limiter = RpcRateLimiter::new(10, 2); // Exhaust tokens for _ in 0..10 { - limiter.check_and_consume(); + assert!(limiter.check_and_consume()); } assert_eq!(limiter.tokens_remaining(), 0); - // Wait for refill (simulate by manipulating time) - // Note: In real tests, we'd use a time mock, but for now we test the logic - // The refill happens based on elapsed time, so we can't easily test without mocks - // This test verifies the basic structure works + // 2/sec: one token every 500ms + tokio::time::advance(std::time::Duration::from_millis(499)).await; + assert!(!limiter.check_and_consume()); + tokio::time::advance(std::time::Duration::from_millis(1)).await; + assert!(limiter.check_and_consume()); + assert!(!limiter.check_and_consume()); } -#[test] -fn test_rate_limiter_burst_limit() { +#[tokio::test(start_paused = true)] +async fn test_rate_limiter_burst_limit() { let mut limiter = RpcRateLimiter::new(5, 10); - // Should not exceed burst limit even with high refill rate - for _ in 0..10 { - limiter.check_and_consume(); + for _ in 0..5 { + assert!(limiter.check_and_consume()); } + assert!(!limiter.check_and_consume()); - // After exhausting, refill should cap at burst_limit - // This is tested implicitly - the limiter should never exceed burst_limit + // A long idle period refills to the burst limit and no further + tokio::time::advance(std::time::Duration::from_secs(86_400)).await; + for _ in 0..5 { + assert!(limiter.check_and_consume()); + } + assert!(!limiter.check_and_consume()); } #[tokio::test] @@ -201,7 +207,7 @@ async fn test_auth_manager_add_certificate() { assert!(result.user_id.is_some()); } -#[tokio::test] +#[tokio::test(start_paused = true)] async fn test_auth_manager_set_user_rate_limit() { let manager = RpcAuthManager::new(false); let token_str = "token".to_string(); @@ -228,7 +234,7 @@ async fn test_auth_manager_set_user_rate_limit() { assert!(!manager.check_rate_limit(&user_id).await); } -#[tokio::test] +#[tokio::test(start_paused = true)] async fn test_auth_manager_set_method_rate_limit() { let manager = RpcAuthManager::new(false); diff --git a/tests/rpc_auth_security_tests.rs b/tests/rpc_auth_security_tests.rs index 0e45cc6f..1a53b5ca 100644 --- a/tests/rpc_auth_security_tests.rs +++ b/tests/rpc_auth_security_tests.rs @@ -262,7 +262,7 @@ async fn test_auth_brute_force_protection() { // This tests that brute force attempts don't break the system } -#[tokio::test] +#[tokio::test(start_paused = true)] async fn test_rpc_rate_limiting_without_auth() { // Rate-limit-only mode: auth disabled, IP rate limiting applies let manager = RpcAuthManager::with_rate_limits(false, 5, 1); @@ -287,7 +287,7 @@ async fn test_rpc_rate_limiting_without_auth() { ); } -#[tokio::test] +#[tokio::test(start_paused = true)] async fn test_batch_rate_limiting() { // Batch consumes min(batch_len, 10) tokens let manager = RpcAuthManager::with_rate_limits(false, 5, 1); diff --git a/tests/rpc_rate_limiting_tests.rs b/tests/rpc_rate_limiting_tests.rs index bd70140e..71949ff9 100644 --- a/tests/rpc_rate_limiting_tests.rs +++ b/tests/rpc_rate_limiting_tests.rs @@ -5,9 +5,9 @@ use blvm_node::rpc::auth::{RpcAuthManager, RpcRateLimiter, UserId}; use std::net::SocketAddr; use std::time::Duration; -use tokio::time::sleep; +use tokio::time::advance; -#[tokio::test] +#[tokio::test(start_paused = true)] async fn test_rate_limiter_basic_functionality() { let mut limiter = RpcRateLimiter::new(10, 5); @@ -22,50 +22,51 @@ async fn test_rate_limiter_basic_functionality() { assert_eq!(limiter.tokens_remaining(), 0); } -#[tokio::test] +#[tokio::test(start_paused = true)] async fn test_rate_limiter_refill_over_time() { let mut limiter = RpcRateLimiter::new(10, 10); // 10 tokens/sec // Exhaust tokens for _ in 0..10 { - limiter.check_and_consume(); + assert!(limiter.check_and_consume()); } - assert_eq!(limiter.tokens_remaining(), 0); - - // Wait 1 second - should refill - sleep(Duration::from_millis(1100)).await; + assert!(!limiter.check_and_consume()); - // Should have refilled (at least some tokens) - // Note: Timing-dependent test, may be flaky - let tokens_before = limiter.tokens_remaining(); - if tokens_before > 0 { + // 999ms at 10/sec earns 9.99 tokens: 9 whole tokens, 99ms carried + advance(Duration::from_millis(999)).await; + for _ in 0..9 { assert!(limiter.check_and_consume()); - } else { - // If no tokens yet, wait a bit more - sleep(Duration::from_millis(200)).await; - assert!(limiter.tokens_remaining() > 0 || limiter.check_and_consume()); } + assert!(!limiter.check_and_consume()); + + // 101ms more plus the 99ms carry is 200ms: exactly 2 tokens + advance(Duration::from_millis(101)).await; + assert!(limiter.check_and_consume()); + assert!(limiter.check_and_consume()); + assert!(!limiter.check_and_consume()); } -#[tokio::test] +#[tokio::test(start_paused = true)] async fn test_rate_limiter_burst_limit_cap() { let mut limiter = RpcRateLimiter::new(5, 100); // High refill rate, low burst // Exhaust tokens for _ in 0..5 { - limiter.check_and_consume(); + assert!(limiter.check_and_consume()); } - // Wait and check - should never exceed burst limit - sleep(Duration::from_millis(100)).await; - let tokens = limiter.tokens_remaining(); + // An hour at 100/sec would earn 360000 tokens; the bucket holds 5 + advance(Duration::from_secs(3600)).await; + for _ in 0..5 { + assert!(limiter.check_and_consume()); + } assert!( - tokens <= 5, - "Tokens should not exceed burst limit of 5, got {tokens}" + !limiter.check_and_consume(), + "Tokens must not exceed burst limit of 5" ); } -#[tokio::test] +#[tokio::test(start_paused = true)] async fn test_auth_manager_rate_limit_per_user() { let manager = RpcAuthManager::new(false); @@ -104,7 +105,7 @@ async fn test_auth_manager_rate_limit_per_user() { assert!(allowed, "User2 should still have tokens"); } -#[tokio::test] +#[tokio::test(start_paused = true)] async fn test_auth_manager_rate_limit_per_method() { let manager = RpcAuthManager::new(false); @@ -123,7 +124,7 @@ async fn test_auth_manager_rate_limit_per_method() { assert!(manager.check_method_rate_limit("cheap_method").await); } -#[tokio::test] +#[tokio::test(start_paused = true)] async fn test_auth_manager_ip_rate_limiting() { let manager = RpcAuthManager::new(false); let addr1: SocketAddr = "127.0.0.1:8080".parse().unwrap(); @@ -148,39 +149,33 @@ async fn test_auth_manager_ip_rate_limiting() { } } -#[tokio::test] +#[tokio::test(start_paused = true)] async fn test_auth_manager_rate_limit_recovery() { let manager = RpcAuthManager::new(false); let user = UserId::Ip("127.0.0.1:8080".parse().unwrap()); - // Exhaust rate limit (IP limit is 50 burst, 5/sec) - let mut exhausted = false; - for i in 0..60 { - if !manager.check_rate_limit(&user).await { - exhausted = true; - break; - } - if i >= 49 { - // Should be exhausted by now - exhausted = true; - } + // Default limit is 100 burst, 10/sec + let mut allowed = 0; + while manager.check_rate_limit(&user).await { + allowed += 1; + assert!(allowed <= 100, "burst exceeded"); } + assert_eq!(allowed, 100); - // Should be rate limited - assert!(!manager.check_rate_limit(&user).await || exhausted); - - // Wait for refill - sleep(Duration::from_millis(1100)).await; + // 99ms at 10/sec is 0.99 tokens: still limited + advance(Duration::from_millis(99)).await; + assert!(!manager.check_rate_limit(&user).await); - // Should recover and allow requests again - // Wait a bit more to ensure refill - sleep(Duration::from_millis(200)).await; - let allowed = manager.check_rate_limit(&user).await; - // Should have at least some tokens after refill - assert!(allowed, "Rate limit should recover after refill period"); + // 1ms more completes one token + advance(Duration::from_millis(1)).await; + assert!( + manager.check_rate_limit(&user).await, + "Rate limit should recover after refill period" + ); + assert!(!manager.check_rate_limit(&user).await); } -#[tokio::test] +#[tokio::test(start_paused = true)] async fn test_auth_manager_concurrent_rate_limiting() { use std::sync::Arc; let manager = Arc::new(RpcAuthManager::new(false)); @@ -212,7 +207,7 @@ async fn test_auth_manager_concurrent_rate_limiting() { ); } -#[tokio::test] +#[tokio::test(start_paused = true)] async fn test_rate_limiter_zero_rate() { let mut limiter = RpcRateLimiter::new(10, 0); // No refill @@ -222,32 +217,32 @@ async fn test_rate_limiter_zero_rate() { } // Should never refill - sleep(Duration::from_millis(1100)).await; - assert_eq!(limiter.tokens_remaining(), 0); + advance(Duration::from_secs(3600)).await; assert!(!limiter.check_and_consume()); + assert_eq!(limiter.tokens_remaining(), 0); } -#[tokio::test] -#[ignore] // Timing-dependent test, may be flaky +#[tokio::test(start_paused = true)] async fn test_rate_limiter_high_rate() { let mut limiter = RpcRateLimiter::new(10, 1000); // Very high refill rate // Exhaust tokens for _ in 0..10 { - limiter.check_and_consume(); + assert!(limiter.check_and_consume()); } + assert!(!limiter.check_and_consume()); - // Should refill quickly (but capped at burst limit) - // With 1000 tokens/sec, 100ms should give ~100 tokens, but capped at 10 - sleep(Duration::from_millis(100)).await; - // Refill happens on check_and_consume, so check it - let had_tokens = limiter.check_and_consume(); - // Should have tokens, but not exceed burst limit - let tokens = limiter.tokens_remaining(); - assert!(tokens <= 10, "Should not exceed burst limit of 10"); - // With such a high rate, should have refilled - assert!( - had_tokens || tokens >= 8, - "Should have refilled (had_tokens: {had_tokens}, remaining: {tokens})" + // 1ms at 1000/sec is exactly one token + advance(Duration::from_millis(1)).await; + assert!(limiter.check_and_consume()); + assert!(!limiter.check_and_consume()); + + // 100ms would earn 100 tokens, capped at the burst of 10 + advance(Duration::from_millis(100)).await; + assert!(limiter.check_and_consume()); + assert_eq!( + limiter.tokens_remaining(), + 9, + "Should not exceed burst limit of 10" ); } From 7ade033ea801e3419ab1d24248fc37401e05a967 Mon Sep 17 00:00:00 2001 From: Josh Date: Tue, 20 Oct 2026 16:21:00 +0200 Subject: [PATCH 5/7] docs: stop calling the consensus mining functions formally verified. blvm-consensus docs/VERIFICATION.md (ed2fac0) counts 3 of 182 anchors as mutation-validated; create_new_block has no #[spec_locked]. Say the mining functions are tested, call spec-locked network code annotated and Z3-checked, and say the SECURITY.md item is property tests. Comments and docs only. --- SECURITY.md | 2 +- docs/NETWORK_VERIFICATION.md | 8 ++++---- src/module/traits.rs | 2 +- src/rpc/mining.rs | 3 ++- 4 files changed, 8 insertions(+), 7 deletions(-) diff --git a/SECURITY.md b/SECURITY.md index 77cf3188..7d6133ff 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -172,7 +172,7 @@ This document defines the security boundaries, threat model, and limitations of #### Phase 3: Mainnet Hardening - [ ] Professional security audit (external, requires security firm) -- [x] Formal verification of critical paths (property tests for node invariants added) +- [x] Property tests for node invariants on critical paths (testing, not formal verification) - [x] Advanced peer management (connection quality tracking, peer selection, reliability scoring) - [x] Performance optimization (performance profiler infrastructure added) - [x] Monitoring and alerting (metrics collection, health checks, RPC endpoints) diff --git a/docs/NETWORK_VERIFICATION.md b/docs/NETWORK_VERIFICATION.md index 20eb3004..a9776f30 100644 --- a/docs/NETWORK_VERIFICATION.md +++ b/docs/NETWORK_VERIFICATION.md @@ -1,14 +1,14 @@ -# Network Protocol Formal Verification +# Network Protocol Spec-Lock Checks ## Overview -This document describes formal verification of Bitcoin P2P protocol message parsing, serialization, and processing. Verification uses **blvm-spec-lock** (Z3-based) for consensus code and Dandelion (Section 10.6). +This document describes the spec-lock checks on Bitcoin P2P protocol message parsing, serialization, and processing. Functions are annotated with `#[spec_locked]` and Z3-checked by **blvm-spec-lock** (consensus code and Dandelion, Section 10.6). An annotated, Z3-checked function is not formally verified: in blvm-consensus only 3 of 182 anchors are mutation-validated (see [`docs/VERIFICATION.md`](https://github.com/BTCDecoded/blvm-consensus/blob/main/docs/VERIFICATION.md)). ## Verification Status ### blvm-spec-lock (Orange Paper) -- **Dandelion (10.6)** – ✅ Verified in `src/network/dandelion.rs` via `#[spec_locked("10.6")]` +- **Dandelion (10.6)** – annotated and Z3-checked in `src/network/dandelion.rs` via `#[spec_locked("10.6")]` - **Protocol parsing (10.1)** – ✅ Covered: parse_message, calculate_checksum (protocol-verification feature, enabled by default) ### Planned spec-lock coverage @@ -35,7 +35,7 @@ cargo spec-lock verify --crate-path . \ ### CI -Formal verification runs via **blvm-spec-lock** in **`.github/workflows/ci.yml`** (**verify** job): installs **`cargo-spec-lock`** from crates.io, runs **`verify`** on the published **blvm-consensus** sources and again on **blvm-node** (merged **`F_*`** gate + Rust rows; **`formula_registry`** in **`spec_lock_*_verify.json`**). Report shape and **`jq`**: **`blvm-spec-lock`** **`docs/VERIFY_JSON.md`**. +Spec-lock checks run via **blvm-spec-lock** in **`.github/workflows/ci.yml`** (**verify** job): installs **`cargo-spec-lock`** from crates.io, runs **`verify`** on the published **blvm-consensus** sources and again on **blvm-node** (merged **`F_*`** gate + Rust rows; **`formula_registry`** in **`spec_lock_*_verify.json`**). Report shape and **`jq`**: **`blvm-spec-lock`** **`docs/VERIFY_JSON.md`**. ## References diff --git a/src/module/traits.rs b/src/module/traits.rs index 9ff9e530..0d372ae8 100644 --- a/src/module/traits.rs +++ b/src/module/traits.rs @@ -492,7 +492,7 @@ pub trait NodeAPI: Send + Sync { /// Get block template (GBT equivalent) /// /// Returns a block template suitable for mining. - /// Uses the same formally verified consensus function as RPC getblocktemplate. + /// Uses the same tested blvm-consensus mining function as RPC getblocktemplate. /// /// # Arguments /// * `rules` - Consensus rules to apply (e.g., ["segwit"]) diff --git a/src/rpc/mining.rs b/src/rpc/mining.rs index f23d20cc..34db5dae 100644 --- a/src/rpc/mining.rs +++ b/src/rpc/mining.rs @@ -1,7 +1,8 @@ //! Mining RPC methods //! //! Implements mining-related JSON-RPC methods for block template generation and mining. -//! Uses formally verified blvm-consensus mining functions. +//! Uses the blvm-consensus mining functions, which are covered by tests. They are not +//! formally verified: see [`docs/VERIFICATION.md`](https://github.com/BTCDecoded/blvm-consensus/blob/main/docs/VERIFICATION.md) in blvm-consensus. use crate::network::NetworkManager; use crate::node::event_publisher::EventPublisher; From da8ab260afde1a720b8386834f65b4ebd2d055af Mon Sep 17 00:00:00 2001 From: Josh Date: Tue, 20 Oct 2026 17:27:16 +0200 Subject: [PATCH 6/7] ci: guard Build job against low disk before restoring the target cache. --- .github/workflows/ci.yml | 35 +++++++++++++++++++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 259144b1..6774770f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1073,11 +1073,46 @@ jobs: mkdir -p "${RUNNER_TEMP}/cargo" echo "CARGO_HOME=${RUNNER_TEMP}/cargo" >> "$GITHUB_ENV" + # Build runs last on a host that test/clippy/docs/fuzz may already have filled. + # Free space before restoring the target cache, and skip that restore when + # space is still short (run 39430830510 hit ENOSPC during restore + unpack). + - name: Low-disk guard before cache restore + id: disk + run: | + df -h / "${{ github.workspace }}" /mnt/data 2>/dev/null || true + CARGO_TEMP="${RUNNER_TEMP}/cargo" + if [ -d "$CARGO_TEMP" ]; then + rm -rf "$CARGO_TEMP/registry/src" "$CARGO_TEMP/git/checkouts" 2>/dev/null || true + fi + BUILD_ROOT="${BLVM_RUNNER_DATA_ROOT}" + RUN_ID="${{ github.run_id }}" + if [ -d "$BUILD_ROOT" ]; then + find "$BUILD_ROOT" -mindepth 1 -maxdepth 1 -type d \ + -not -name "$RUN_ID" \ + -exec rm -rf {} + 2>/dev/null || true + fi + rm -rf fuzz/target 2>/dev/null || true + AVAIL=$(df -B1 "${{ github.workspace }}" | tail -1 | awk '{print $4}') + MIN_FREE=$((20 * 1024 * 1024 * 1024)) + if [ -n "$AVAIL" ] && [ "$AVAIL" -lt "$MIN_FREE" ]; then + echo "::warning::Only $AVAIL bytes free; skipping target cache restore (cold build)" + echo "restore-target=false" >> "$GITHUB_OUTPUT" + else + echo "restore-target=true" >> "$GITHUB_OUTPUT" + fi + df -h "${{ github.workspace }}" || true + + - name: Prune runner caches + uses: BTCDecoded/rust-ci/runner-cargo-cache@main + with: + operation: prune + - name: Cache uses: BTCDecoded/rust-ci/runner-cargo-cache@main with: operation: bind-env cache-key: ${{ needs.setup.outputs.cache-key }} + include-target: ${{ steps.disk.outputs.restore-target }} - name: Restore Cargo cache uses: BTCDecoded/rust-ci/runner-cargo-cache@main From b86f56aac1e9822c27293be1d72970965e1e2563 Mon Sep 17 00:00:00 2001 From: Josh Date: Tue, 20 Oct 2026 18:08:13 +0200 Subject: [PATCH 7/7] test(rpc): pin limiter no-banking while full and remainder drop on fill. --- src/rpc/auth_impl.rs | 35 +++++++++++++++++++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/src/rpc/auth_impl.rs b/src/rpc/auth_impl.rs index 13f79260..84bff1e8 100644 --- a/src/rpc/auth_impl.rs +++ b/src/rpc/auth_impl.rs @@ -1015,4 +1015,39 @@ mod rate_limiter_tests { assert!(!limiters.contains_key(&idle)); assert!(limiters.contains_key(&active)); } + + /// Idle time while full is not banked: right after a long idle period, exhausting the + /// bucket leaves nothing (otherwise a client gets 2x burst back-to-back). + #[tokio::test(start_paused = true)] + async fn idle_while_full_is_not_banked() { + let mut l = RpcRateLimiter::new(3, 1); + advance(Duration::from_secs(100)).await; + for _ in 0..3 { + assert!(l.check_and_consume()); + } + assert!(!l.check_and_consume()); + } + + /// A refill that overfills the bucket drops the sub-token remainder. + #[tokio::test(start_paused = true)] + async fn overfill_drops_remainder() { + let mut l = RpcRateLimiter::new(1, 1); + assert!(l.check_and_consume()); + advance(Duration::from_millis(1500)).await; + assert!(l.check_and_consume()); + advance(Duration::from_millis(600)).await; + assert!(!l.check_and_consume()); + } + + /// A refill that exactly fills the bucket also drops the remainder. + #[tokio::test(start_paused = true)] + async fn exact_fill_drops_remainder() { + let mut l = RpcRateLimiter::new(2, 1); + assert!(l.check_and_consume_n(2)); + advance(Duration::from_millis(1500)).await; + advance(Duration::from_millis(700)).await; + assert!(l.check_and_consume_n(2)); + advance(Duration::from_millis(900)).await; + assert!(!l.check_and_consume()); + } }