diff --git a/QORTIUM-CHANGELOG.md b/QORTIUM-CHANGELOG.md index ff01c9a73..4e888a382 100644 --- a/QORTIUM-CHANGELOG.md +++ b/QORTIUM-CHANGELOG.md @@ -34,6 +34,10 @@ own chain. ## Change Entries +### 2026-06-02 - Expose preview peer reachability diagnostics + +Added inbound and outbound peer counts plus P2P and QDN reachability flags to the node status response so preview testers can tell the difference between outbound-only connectivity and public reachability. Preview participants now target four outbound chain peers when enough reachable peers are available, and the preview docs explain how firewall or router forwarding affects peer counts. + ### 2026-06-01 - build(deps-dev): bump org.apache.maven.plugins:maven-surefire-plugin from 3.5.5 to 3.5.6 Updated the Maven Surefire Plugin to the current patch release so Qortium's JUnit test runner stays current. This keeps the existing Maven test workflow and skip flags intact while picking up upstream Surefire maintenance fixes and improvements. diff --git a/preview/README.md b/preview/README.md index cdee8318a..fb471f97e 100644 --- a/preview/README.md +++ b/preview/README.md @@ -36,6 +36,12 @@ online accounts before activating so small preview groups can exercise reward distribution sooner. Chain-parameter updates use a 20-block activation delay on preview so governance changes can be tested within the same session. +Participant nodes target four outbound chain peers when enough reachable peers +are known. Nodes that do not forward inbound ports can still sync and publish +outbound, but they may not receive inbound peer connections from other testers. +The `/admin/status` response reports inbound/outbound peer counts and P2P/QDN +reachability flags to make that distinction visible during preview testing. + ## Start A Preview Participant Build Qortium from the repository root: diff --git a/preview/TESTER-GUIDE.md b/preview/TESTER-GUIDE.md index 1d54eec86..84aec96ba 100644 --- a/preview/TESTER-GUIDE.md +++ b/preview/TESTER-GUIDE.md @@ -144,6 +144,10 @@ preview\reset.bat genesis, and settle into the current preview height. - The first useful tests are connecting to the seed nodes, staying synced, sending chat messages, trying QDN features, and reporting issues. +- Preview participant nodes try to keep four outbound chain peers when enough + reachable peers are available. Nodes behind a firewall or router can still use + preview normally, but they may only show outbound peers unless other nodes can + connect back to them. - Preview participant and seed nodes expose limited public read-only API access by default so Qortium Home can discover useful peers, browse QDN resources, and read common chain data. Your local node still keeps transaction building, @@ -156,6 +160,13 @@ and `24894` for QDN/data. If you do not open those ports, your node can still use the preview network normally, but other users may not be able to use it as a public read or QDN peer. +The `/admin/status` response includes directional peer counts and inbound +reachability fields. If `numberOfOutboundConnections` is healthy but +`numberOfInboundConnections` stays at `0`, your node is probably connected +outbound but not publicly reachable for P2P. If `isP2PInboundReachable` or +`isQDNInboundReachable` is `false`, check firewall or router forwarding for the +matching preview port. + The public seed status endpoints are: ```text diff --git a/preview/settings-preview.json b/preview/settings-preview.json index 27ff3c776..82da95109 100644 --- a/preview/settings-preview.json +++ b/preview/settings-preview.json @@ -33,7 +33,7 @@ "dataPath": "data-preview", "blockchainConfig": "previewchain.json", "minBlockchainPeers": 1, - "minOutboundPeers": 2, + "minOutboundPeers": 4, "maxPeers": 32, "maxDataPeers": 32, "maxNetworkThreadPoolSize": 128, diff --git a/src/main/java/org/qortium/api/model/NodeStatus.java b/src/main/java/org/qortium/api/model/NodeStatus.java index 73857a87a..6d2b3327a 100644 --- a/src/main/java/org/qortium/api/model/NodeStatus.java +++ b/src/main/java/org/qortium/api/model/NodeStatus.java @@ -8,6 +8,7 @@ import org.qortium.network.Network; import org.qortium.network.NetworkData; import org.qortium.network.Peer; +import org.qortium.network.PeerList; import org.qortium.settings.Settings; import javax.xml.bind.annotation.XmlAccessType; @@ -54,8 +55,58 @@ public enum SyncPhase { public final int numberOfConnections; + @Schema( + description = "Number of handshaked chain peers that connected inbound to this node." + ) + public final int numberOfInboundConnections; + + @Schema( + description = "Number of handshaked chain peers this node connected to outbound." + ) + public final int numberOfOutboundConnections; + public final int numberOfDataConnections; + @Schema( + description = "Number of handshaked QDN/data peers that connected inbound to this node." + ) + public final int numberOfInboundDataConnections; + + @Schema( + description = "Number of handshaked QDN/data peers this node connected to outbound." + ) + public final int numberOfOutboundDataConnections; + + @Schema( + description = "Whether this node currently appears reachable for inbound chain peer connections." + ) + public final boolean isP2PInboundReachable; + + @Schema( + description = "Whether the chain peer listen socket is bound locally." + ) + public final boolean isP2PListenSocketAvailable; + + @Schema( + description = "Whether the chain peer listen port was mapped through UPnP." + ) + public final boolean isP2PPortMapped; + + @Schema( + description = "Whether this node currently appears reachable for inbound QDN/data peer connections." + ) + public final boolean isQDNInboundReachable; + + @Schema( + description = "Whether the QDN/data listen socket is bound locally." + ) + public final boolean isQDNListenSocketAvailable; + + @Schema( + description = "Whether the QDN/data listen port was mapped through UPnP." + ) + public final boolean isQDNPortMapped; + public final int height; public NodeStatus() { @@ -63,12 +114,31 @@ public NodeStatus() { Synchronizer synchronizer = Synchronizer.getInstance(); Controller controller = Controller.getInstance(); - List handshakedPeers = Network.getInstance().getImmutableHandshakedPeers(); + Network network = Network.getInstance(); + NetworkData networkData = NetworkData.getInstance(); + List handshakedPeers = network.getImmutableHandshakedPeers(); + PeerList handshakedDataPeers = networkData.getImmutableHandshakedPeers(); + PeerConnectionStats chainPeerStats = calculatePeerConnectionStats(handshakedPeers.size(), + countOutboundConnections(handshakedPeers), network.canAcceptInbound(), network.isListenSocketAvailable(), + network.isPortMapped()); + PeerConnectionStats dataPeerStats = calculatePeerConnectionStats(handshakedDataPeers.size(), + countOutboundConnections(handshakedDataPeers), networkData.canAcceptInbound(), + networkData.isListenSocketAvailable(), networkData.isPortMapped()); this.isSynchronizing = synchronizer.isSynchronizing(); - this.numberOfConnections = handshakedPeers.size(); - - this.numberOfDataConnections = NetworkData.getInstance().getImmutableHandshakedPeers().size(); + this.numberOfConnections = chainPeerStats.totalConnections; + this.numberOfInboundConnections = chainPeerStats.inboundConnections; + this.numberOfOutboundConnections = chainPeerStats.outboundConnections; + + this.numberOfDataConnections = dataPeerStats.totalConnections; + this.numberOfInboundDataConnections = dataPeerStats.inboundConnections; + this.numberOfOutboundDataConnections = dataPeerStats.outboundConnections; + this.isP2PInboundReachable = chainPeerStats.inboundReachable; + this.isP2PListenSocketAvailable = chainPeerStats.listenSocketAvailable; + this.isP2PPortMapped = chainPeerStats.portMapped; + this.isQDNInboundReachable = dataPeerStats.inboundReachable; + this.isQDNListenSocketAvailable = dataPeerStats.listenSocketAvailable; + this.isQDNPortMapped = dataPeerStats.portMapped; this.height = controller.getChainHeight(); @@ -125,6 +195,16 @@ public static SyncProgress calculateSyncProgress(int height, Integer activeSyncT return new SyncProgress(syncTargetHeight, syncBlocksRemaining, syncPercent, syncPhase); } + public static PeerConnectionStats calculatePeerConnectionStats(int totalConnections, int outboundConnections, + boolean inboundReachable, boolean listenSocketAvailable, boolean portMapped) { + int boundedTotalConnections = Math.max(0, totalConnections); + int boundedOutboundConnections = Math.max(0, Math.min(outboundConnections, boundedTotalConnections)); + int inboundConnections = boundedTotalConnections - boundedOutboundConnections; + + return new PeerConnectionStats(boundedTotalConnections, inboundConnections, boundedOutboundConnections, + inboundReachable, listenSocketAvailable, portMapped); + } + private static Integer chooseSyncTargetHeight(int height, Integer activeSyncTargetHeight, Integer bestPeerHeight, boolean isUpToDate) { Integer syncTargetHeight = null; @@ -163,6 +243,35 @@ private static Integer getBestPeerHeight(List peers) { return bestPeerHeight; } + private static int countOutboundConnections(Iterable peers) { + int outboundConnections = 0; + + for (Peer peer : peers) + if (peer.isOutbound()) + outboundConnections++; + + return outboundConnections; + } + + public static class PeerConnectionStats { + public final int totalConnections; + public final int inboundConnections; + public final int outboundConnections; + public final boolean inboundReachable; + public final boolean listenSocketAvailable; + public final boolean portMapped; + + private PeerConnectionStats(int totalConnections, int inboundConnections, int outboundConnections, + boolean inboundReachable, boolean listenSocketAvailable, boolean portMapped) { + this.totalConnections = totalConnections; + this.inboundConnections = inboundConnections; + this.outboundConnections = outboundConnections; + this.inboundReachable = inboundReachable; + this.listenSocketAvailable = listenSocketAvailable; + this.portMapped = portMapped; + } + } + public static class SyncProgress { public final Integer syncTargetHeight; public final Integer syncBlocksRemaining; diff --git a/src/main/java/org/qortium/network/Network.java b/src/main/java/org/qortium/network/Network.java index c4a8439e6..2ec6d0dcc 100644 --- a/src/main/java/org/qortium/network/Network.java +++ b/src/main/java/org/qortium/network/Network.java @@ -2470,15 +2470,15 @@ public boolean canAcceptInbound() { return this.inboundReachability.canAcceptInbound(); } - boolean isListenSocketAvailable() { + public boolean isListenSocketAvailable() { return this.inboundReachability.isListenSocketAvailable(); } - boolean isPortMapped() { + public boolean isPortMapped() { return this.inboundReachability.isPortMapped(); } - long getLastInboundHandshakeTimestamp() { + public long getLastInboundHandshakeTimestamp() { return this.inboundReachability.getLastInboundHandshakeTimestamp(); } diff --git a/src/main/java/org/qortium/network/NetworkData.java b/src/main/java/org/qortium/network/NetworkData.java index 8cae61e09..3b8ab3cbf 100644 --- a/src/main/java/org/qortium/network/NetworkData.java +++ b/src/main/java/org/qortium/network/NetworkData.java @@ -2118,15 +2118,15 @@ public boolean canAcceptInbound() { return this.inboundReachability.canAcceptInbound(); } - boolean isListenSocketAvailable() { + public boolean isListenSocketAvailable() { return this.inboundReachability.isListenSocketAvailable(); } - boolean isPortMapped() { + public boolean isPortMapped() { return this.inboundReachability.isPortMapped(); } - long getLastInboundHandshakeTimestamp() { + public long getLastInboundHandshakeTimestamp() { return this.inboundReachability.getLastInboundHandshakeTimestamp(); } diff --git a/src/test/java/org/qortium/test/api/NodeStatusTests.java b/src/test/java/org/qortium/test/api/NodeStatusTests.java index 37037bd61..1592f4506 100644 --- a/src/test/java/org/qortium/test/api/NodeStatusTests.java +++ b/src/test/java/org/qortium/test/api/NodeStatusTests.java @@ -2,11 +2,14 @@ import org.junit.Test; import org.qortium.api.model.NodeStatus; +import org.qortium.api.model.NodeStatus.PeerConnectionStats; import org.qortium.api.model.NodeStatus.SyncPhase; import org.qortium.api.model.NodeStatus.SyncProgress; import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertTrue; public class NodeStatusTests { @@ -80,4 +83,28 @@ public void testSyncedNodeReportsComplete() { assertEquals(SyncPhase.SYNCED, progress.syncPhase); } + @Test + public void testPeerConnectionStatsSplitInboundAndOutbound() { + PeerConnectionStats stats = NodeStatus.calculatePeerConnectionStats(5, 2, true, true, false); + + assertEquals(5, stats.totalConnections); + assertEquals(3, stats.inboundConnections); + assertEquals(2, stats.outboundConnections); + assertTrue(stats.inboundReachable); + assertTrue(stats.listenSocketAvailable); + assertFalse(stats.portMapped); + } + + @Test + public void testPeerConnectionStatsBoundsInvalidCounts() { + PeerConnectionStats stats = NodeStatus.calculatePeerConnectionStats(2, 5, false, true, true); + + assertEquals(2, stats.totalConnections); + assertEquals(0, stats.inboundConnections); + assertEquals(2, stats.outboundConnections); + assertFalse(stats.inboundReachable); + assertTrue(stats.listenSocketAvailable); + assertTrue(stats.portMapped); + } + }