diff --git a/.github/workflows/0fc-integration.yml b/.github/workflows/0fc-integration.yml index 1eab8b3ae1..bc0c5099d2 100644 --- a/.github/workflows/0fc-integration.yml +++ b/.github/workflows/0fc-integration.yml @@ -13,6 +13,10 @@ jobs: steps: - name: Checkout source code uses: actions/checkout@v4 + + - name: Pin proptest for compatibility + run: cargo update -p proptest --precise "1.11.0" --verbose + - name: Install Rust stable toolchain run: | curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --profile=minimal --default-toolchain stable diff --git a/.github/workflows/benchmarks.yml b/.github/workflows/benchmarks.yml index afdf32e779..5e25282a9a 100644 --- a/.github/workflows/benchmarks.yml +++ b/.github/workflows/benchmarks.yml @@ -15,6 +15,10 @@ jobs: steps: - name: Checkout source code uses: actions/checkout@v4 + + - name: Pin proptest for compatibility + run: cargo update -p proptest --precise "1.11.0" --verbose + - name: Install Rust toolchain run: | curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --profile=minimal --default-toolchain stable diff --git a/.github/workflows/cln-integration.yml b/.github/workflows/cln-integration.yml index daf8355bdd..dfca73abc7 100644 --- a/.github/workflows/cln-integration.yml +++ b/.github/workflows/cln-integration.yml @@ -14,6 +14,9 @@ jobs: - name: Checkout repository uses: actions/checkout@v6 + - name: Pin proptest for compatibility + run: cargo update -p proptest --precise "1.11.0" --verbose + - name: Create temporary directory for CLN data run: echo "CLN_DATA_DIR=$(mktemp -d)" >> $GITHUB_ENV diff --git a/.github/workflows/eclair-integration.yml b/.github/workflows/eclair-integration.yml index 68acc8b13e..5b1531a9a2 100644 --- a/.github/workflows/eclair-integration.yml +++ b/.github/workflows/eclair-integration.yml @@ -24,6 +24,9 @@ jobs: - name: Checkout repository uses: actions/checkout@v4 + - name: Pin proptest for compatibility + run: cargo update -p proptest --precise "1.11.0" --verbose + - name: Start bitcoind and electrs run: docker compose -p ldk-node -f tests/docker/docker-compose-eclair.yml up -d bitcoin electrs diff --git a/.github/workflows/hrn-integration.yml b/.github/workflows/hrn-integration.yml index 732aad029f..ecc559a139 100644 --- a/.github/workflows/hrn-integration.yml +++ b/.github/workflows/hrn-integration.yml @@ -14,6 +14,10 @@ jobs: steps: - name: Checkout source code uses: actions/checkout@v3 + + - name: Pin proptest for compatibility + run: cargo update -p proptest --precise "1.11.0" --verbose + - name: Install Rust stable toolchain run: | curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --profile=minimal --default-toolchain stable diff --git a/.github/workflows/kotlin.yml b/.github/workflows/kotlin.yml index 84429df339..9fcb32ada7 100644 --- a/.github/workflows/kotlin.yml +++ b/.github/workflows/kotlin.yml @@ -20,6 +20,9 @@ jobs: - name: Checkout repository uses: actions/checkout@v6 + - name: Pin proptest for compatibility + run: cargo update -p proptest --precise "1.11.0" --verbose + - name: Set up JDK uses: actions/setup-java@v5 with: diff --git a/.github/workflows/lnd-integration.yml b/.github/workflows/lnd-integration.yml index 6c755e2a21..fb8e767618 100644 --- a/.github/workflows/lnd-integration.yml +++ b/.github/workflows/lnd-integration.yml @@ -14,6 +14,9 @@ jobs: - name: Checkout repository uses: actions/checkout@v6 + - name: Pin proptest for compatibility + run: cargo update -p proptest --precise "1.11.0" --verbose + - name: Check and install CMake if needed # lnd_grpc_rust (via prost-build v0.10.4) requires CMake >= 3.5 but is incompatible with CMake >= 4.0. # This step checks if CMake is missing, below 3.5, or 4.0 or higher, and installs CMake 3.31.6 if needed, diff --git a/.github/workflows/postgres-integration.yml b/.github/workflows/postgres-integration.yml index d27e3123ea..419add418f 100644 --- a/.github/workflows/postgres-integration.yml +++ b/.github/workflows/postgres-integration.yml @@ -29,6 +29,10 @@ jobs: steps: - name: Checkout code uses: actions/checkout@v6 + + - name: Pin proptest for compatibility + run: cargo update -p proptest --precise "1.11.0" --verbose + - name: Install Rust stable toolchain run: | curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --profile=minimal --default-toolchain stable diff --git a/.github/workflows/python.yml b/.github/workflows/python.yml index 3639a1a1c5..c756201feb 100644 --- a/.github/workflows/python.yml +++ b/.github/workflows/python.yml @@ -20,6 +20,9 @@ jobs: - name: Checkout repository uses: actions/checkout@v6 + - name: Pin proptest for compatibility + run: cargo update -p proptest --precise "1.11.0" --verbose + - name: Install uv uses: astral-sh/setup-uv@v7 with: diff --git a/.github/workflows/swift.yml b/.github/workflows/swift.yml index af3928c4a8..1afdd5d668 100644 --- a/.github/workflows/swift.yml +++ b/.github/workflows/swift.yml @@ -18,6 +18,9 @@ jobs: - name: Checkout repository uses: actions/checkout@v6 + - name: Pin proptest for compatibility + run: cargo update -p proptest --precise "1.11.0" --verbose + - name: Set default Rust version to stable run: rustup default stable diff --git a/.github/workflows/vss-integration.yml b/.github/workflows/vss-integration.yml index 6b66eacd48..9620f04c01 100644 --- a/.github/workflows/vss-integration.yml +++ b/.github/workflows/vss-integration.yml @@ -31,6 +31,9 @@ jobs: uses: actions/checkout@v6 with: path: ldk-node + - name: Pin proptest for compatibility + run: cd ldk-node && cargo update -p proptest --precise "1.11.0" --verbose + - name: Checkout VSS uses: actions/checkout@v6 with: diff --git a/.github/workflows/vss-no-auth-integration.yml b/.github/workflows/vss-no-auth-integration.yml index 9e7fb6c51b..dd1b723217 100644 --- a/.github/workflows/vss-no-auth-integration.yml +++ b/.github/workflows/vss-no-auth-integration.yml @@ -31,6 +31,9 @@ jobs: uses: actions/checkout@v6 with: path: ldk-node + - name: Pin proptest for compatibility + run: cd ldk-node && cargo update -p proptest --precise "1.11.0" --verbose + - name: Checkout VSS uses: actions/checkout@v6 with: diff --git a/src/builder.rs b/src/builder.rs index ff7626ae26..324163d978 100644 --- a/src/builder.rs +++ b/src/builder.rs @@ -515,17 +515,8 @@ impl NodeBuilder { self } - /// Configures the [`Node`] instance to source inbound liquidity from the given LSP. - /// - /// The node will discover the LSP's supported protocols (LSPS1/LSPS2) on startup via [bLIP-50 / LSPS0] - /// and select the appropriate protocol per request automatically. - /// - /// The given `token` will be used by the LSP to authenticate the user. - /// `trust_peer_0conf` controls whether the node will additionally accept - /// 0-confirmation channels opened by this LSP. If `false`, 0-confirmation - /// acceptance for this peer falls back to [`Config::trusted_peers_0conf`]. - /// - /// May be called multiple times to register several LSPs. Duplicate `node_id`s are ignored. + /// May be called multiple times to register several LSPs. Subsequent calls with + /// the same `node_id` will upsert the configuration (address, token, trust_peer_0conf). /// /// [bLIP-50 / LSPS0]: https://github.com/lightning/blips/blob/master/blip-0050.md pub fn add_liquidity_source( @@ -535,7 +526,10 @@ impl NodeBuilder { let liquidity_source_config = self.liquidity_source_config.get_or_insert(LiquiditySourceConfig::default()); - if liquidity_source_config.lsp_nodes.iter().any(|n| n.node_id == node_id) { + if let Some(existing) = liquidity_source_config.lsp_nodes.iter_mut().find(|n| n.node_id == node_id) { + existing.address = address; + existing.token = token; + existing.trust_peer_0conf = trust_peer_0conf; return self; } @@ -548,6 +542,11 @@ impl NodeBuilder { self } + /// Returns the currently configured LSP nodes (for testing/inspection). + pub(crate) fn lsp_nodes(&self) -> &[LspConfig] { + &self.liquidity_source_config.as_ref().map(|c| &c.lsp_nodes).unwrap_or(&[]) + } + /// Configures the [`Node`] instance to provide an [LSPS2] service, issuing just-in-time /// channels to clients. /// @@ -2805,4 +2804,42 @@ mod tests { builder.set_runtime(multi_thread_runtime.handle().clone()).unwrap(); assert!(builder.setup_runtime(&logger).is_ok(), "a multi-threaded runtime should be used"); } + + #[test] + fn add_liquidity_source_upserts_on_duplicate_node_id() { + use bitcoin::secp256k1::PublicKey; + use lightning::ln::msgs::SocketAddress; + use std::net::{IpAddr, Ipv4Addr, SocketAddr as StdSocketAddr}; + + let mut builder = NodeBuilder::new(); + let logger = Arc::new(Logger::new_log_facade()); + builder.set_log_facade_logger(); + + // Dummy node_id (32-byte public key) and two addresses + let node_id_bytes = [1u8; 33]; //compressed pubkey format + let node_id = PublicKey::from_slice(&node_id_bytes).unwrap(); + + let addr1 = SocketAddress::new( + node_id, + StdSocketAddr::new(IpAddr::V4(Ipv4Addr::new(127, 0, 0, 1)), 9735).into(), + ); + let addr2 = SocketAddress::new( + node_id, + StdSocketAddr::new(IpAddr::V4(Ipv4Addr::new(127, 0, 0, 2)), 9736).into(), + ); + + // Add first LSP + builder.add_liquidity_source(node_id, addr1.clone(), None, false); + let nodes = builder.lsp_nodes(); + assert_eq!(nodes.len(), 1, "should have one LSP node after first add"); + assert_eq!(nodes[0].address, addr1, "address should be addr1 after first add"); + + // Upsert with same node_id but different address + builder.add_liquidity_source(node_id, addr2.clone(), Some("token".to_string()), true); + let nodes = builder.lsp_nodes(); + assert_eq!(nodes.len(), 1, "should still have one LSP node after upsert (not duplicated)"); + assert_eq!(nodes[0].address, addr2, "address should be upserted to addr2"); + assert_eq!(nodes[0].token, Some("token".to_string()), "token should be upserted"); + assert!(nodes[0].trust_peer_0conf, "trust_peer_0conf should be upserted to true"); + } } diff --git a/src/peer_store.rs b/src/peer_store.rs index 8345bf7111..7147253eec 100644 --- a/src/peer_store.rs +++ b/src/peer_store.rs @@ -42,17 +42,44 @@ where Self { peers, mutation_lock, kv_store, logger } } + /// Inserts or updates a peer entry. + /// + /// If the peer is already known with the same address, this is a no-op. If the peer is new or + /// the stored address changed (e.g. an LSP migrated hosts), the entry is updated and persisted. + /// In-memory state is only mutated after a successful store write, matching [`Self::remove_peer`]. pub(crate) async fn add_peer(&self, peer_info: PeerInfo) -> Result<(), Error> { let _guard = self.mutation_lock.lock().await; let data = { let mut locked_peers = self.peers.write().expect("lock"); - if locked_peers.contains_key(&peer_info.node_id) { - return Ok(()); + if let Some(existing) = locked_peers.get(&peer_info.node_id) { + if existing.address == peer_info.address { + return Ok(()); + } + } + + // Temporarily apply the update so it is included in the + // serialized representation. + let previous = locked_peers.insert(peer_info.node_id, peer_info.clone()); + let data = PeerStoreSerWrapper(&locked_peers).encode(); + + // Restore the original in-memory state before persistence. + match previous { + Some(previous) => { + locked_peers.insert(peer_info.node_id, previous); + }, + None => { + locked_peers.remove(&peer_info.node_id); + }, } - locked_peers.insert(peer_info.node_id, peer_info); - PeerStoreSerWrapper(&locked_peers).encode() + + data }; - self.persist_peers(data).await + + self.persist_peers(data).await?; + + // Persistence succeeded, so apply the update permanently. + self.peers.write().expect("lock").insert(peer_info.node_id, peer_info); + Ok(()) } pub(crate) async fn remove_peer(&self, node_id: &PublicKey) -> Result<(), Error> { @@ -277,4 +304,93 @@ mod tests { assert_eq!(Err(Error::PersistenceFailed), peer_store.remove_peer(&node_id).await); assert_eq!(Some(peer_info), peer_store.get_peer(&node_id)); } + + #[tokio::test] + async fn peer_address_updated_on_readd() { + let store: Arc = Arc::new(DynStoreWrapper(InMemoryStore::new())); + let logger = Arc::new(TestLogger::new()); + let peer_store = PeerStore::new(Arc::clone(&store), Arc::clone(&logger)); + + let node_id = PublicKey::from_str( + "0276607124ebe6a6c9338517b6f485825b27c2dcc0b9fc2aa6a4c0df91194e5993", + ) + .unwrap(); + let old_address = SocketAddress::from_str("127.0.0.1:9738").unwrap(); + let new_address = SocketAddress::from_str("127.0.0.1:9739").unwrap(); + + peer_store.add_peer(PeerInfo { node_id, address: old_address.clone() }).await.unwrap(); + assert_eq!(peer_store.get_peer(&node_id), Some(PeerInfo { node_id, address: old_address })); + + // Re-adding the same peer with a new socket address must refresh the stored entry + // (regression for https://github.com/lightningdevkit/ldk-node/issues/700). + let updated = PeerInfo { node_id, address: new_address.clone() }; + peer_store.add_peer(updated.clone()).await.unwrap(); + assert_eq!(peer_store.get_peer(&node_id), Some(updated.clone())); + + let persisted_bytes = KVStore::read( + &*store, + PEER_INFO_PERSISTENCE_PRIMARY_NAMESPACE, + PEER_INFO_PERSISTENCE_SECONDARY_NAMESPACE, + PEER_INFO_PERSISTENCE_KEY, + ) + .await + .unwrap(); + let deser_peer_store = + PeerStore::read(&mut &persisted_bytes[..], (Arc::clone(&store), logger)).unwrap(); + assert_eq!(deser_peer_store.get_peer(&node_id), Some(updated)); + } + + #[tokio::test] + async fn peer_same_address_skips_persist() { + let store: Arc = Arc::new(DynStoreWrapper(InMemoryStore::new())); + let logger = Arc::new(TestLogger::new()); + let peer_store = PeerStore::new(Arc::clone(&store), Arc::clone(&logger)); + + let node_id = PublicKey::from_str( + "0276607124ebe6a6c9338517b6f485825b27c2dcc0b9fc2aa6a4c0df91194e5993", + ) + .unwrap(); + let address = SocketAddress::from_str("127.0.0.1:9738").unwrap(); + let peer_info = PeerInfo { node_id, address }; + + peer_store.add_peer(peer_info.clone()).await.unwrap(); + let first_bytes = KVStore::read( + &*store, + PEER_INFO_PERSISTENCE_PRIMARY_NAMESPACE, + PEER_INFO_PERSISTENCE_SECONDARY_NAMESPACE, + PEER_INFO_PERSISTENCE_KEY, + ) + .await + .unwrap(); + + // Identical re-add is a no-op for the store payload. + peer_store.add_peer(peer_info.clone()).await.unwrap(); + let second_bytes = KVStore::read( + &*store, + PEER_INFO_PERSISTENCE_PRIMARY_NAMESPACE, + PEER_INFO_PERSISTENCE_SECONDARY_NAMESPACE, + PEER_INFO_PERSISTENCE_KEY, + ) + .await + .unwrap(); + assert_eq!(first_bytes, second_bytes); + assert_eq!(peer_store.get_peer(&node_id), Some(peer_info)); + } + + #[tokio::test] + async fn add_peer_does_not_mutate_memory_if_persist_fails() { + let store: Arc = Arc::new(DynStoreWrapper(FailingStore)); + let logger = Arc::new(TestLogger::new()); + let peer_store = PeerStore::new(store, logger); + + let node_id = PublicKey::from_str( + "0276607124ebe6a6c9338517b6f485825b27c2dcc0b9fc2aa6a4c0df91194e5993", + ) + .unwrap(); + let peer_info = + PeerInfo { node_id, address: SocketAddress::from_str("127.0.0.1:9738").unwrap() }; + + assert_eq!(Err(Error::PersistenceFailed), peer_store.add_peer(peer_info.clone()).await); + assert_eq!(None, peer_store.get_peer(&node_id)); + } }