ZEUS-3972: fix: Neutrino: invalid peer displays as good

This commit is contained in:
Evan Kaloudis
2026-04-15 02:38:47 -04:00
parent ffd97d1b5e
commit 7a802fc40a
5 changed files with 110 additions and 31 deletions
+1
View File
@@ -1370,6 +1370,7 @@
"views.Settings.EmbeddedNode.NeutrinoPeers.allowingOtherPeers": "Allowing connections to other peers.",
"views.Settings.EmbeddedNode.NeutrinoPeers.notAllowingOtherPeers": "Not allowing connections to other peers.",
"views.Settings.EmbeddedNode.NeutrinoPeers.timedOut": "Ping timed out",
"views.Settings.EmbeddedNode.NeutrinoPeers.unreachable": "Host could not be reached",
"views.Settings.EmbeddedNode.NeutrinoPeers.invalidHost": "Please enter a hostname or IP address without http:// or https://",
"views.Settings.EmbeddedNode.NeutrinoPeers.optimize": "Optimize peers selection",
"views.Settings.EmbeddedNode.ZeroConfPeers.title": "Zero conf Peers",
+7 -3
View File
@@ -84,11 +84,15 @@ export default class AlertStore {
const peer = peers[i];
await new Promise(async (resolve) => {
try {
const ms = await pingPeer(peer);
console.log(`# ${peer} - ${ms}`);
const result = await pingPeer(peer);
console.log(`# ${peer} - ${result.ms}`);
results.push({
peer,
ms
ms: result.reachable
? result.ms
: localeString(
'views.Settings.EmbeddedNode.NeutrinoPeers.unreachable'
)
});
resolve(true);
} catch (e) {
+43 -12
View File
@@ -84,10 +84,15 @@ export const NEUTRINO_PING_THRESHOLD_MS = 1000;
// Fetch-based latency check that runs entirely on the JS thread,
// avoiding the native thread race condition in react-native-ping.
export interface PingResult {
ms: number;
reachable: boolean;
}
export async function pingPeer(
host: string,
timeout: number = NEUTRINO_PING_TIMEOUT_MS
): Promise<number> {
): Promise<PingResult> {
if (host.includes('://')) {
throw new Error(
localeString(
@@ -98,15 +103,15 @@ export async function pingPeer(
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), timeout);
const start = global.performance.now();
let reachable = false;
try {
await fetch(`http://${host}:8333`, {
method: 'HEAD',
signal: controller.signal
});
} catch {
// We only care about the round-trip time. A connection refused,
// reset, or other network error still proves the host is reachable.
// Only an abort (timeout) should be treated as unreachable.
// Any HTTP response (even non-200) means the host is reachable.
reachable = true;
} catch (e: any) {
if (controller.signal.aborted) {
throw new Error(
localeString(
@@ -114,10 +119,36 @@ export async function pingPeer(
)
);
}
// Bitcoin peers don't speak HTTP so fetch always throws — even
// for reachable peers (TCP connects, then the non-HTTP response
// causes a parse error). We try to distinguish real peers from
// nonexistent hosts via two independent signals:
//
// 1. Platform-specific DNS error messages.
const msg = (e?.message || '').toLowerCase();
const hasDnsHint =
msg.includes('unable to resolve host') || // Android (OkHttp)
msg.includes('no address associated') || // Android variant
msg.includes('could not find the server') || // iOS
msg.includes('cannot find host') || // iOS variant
msg.includes('nodename nor servname') || // iOS/macOS getaddrinfo
msg.includes('hostname could not be found') || // Windows
msg.includes('not known'); // Linux getaddrinfo
if (hasDnsHint) {
reachable = false;
} else {
// 2. Timing heuristic for platforms where the error message
// is generic (e.g. Android "Network request failed").
// A real host needs at least one network round-trip for
// DNS + TCP before the error, while a DNS NXDOMAIN from
// the local resolver returns almost instantly.
const elapsed = global.performance.now() - start;
reachable = elapsed >= 100;
}
} finally {
clearTimeout(timer);
}
return Math.round(global.performance.now() - start);
return { ms: Math.round(global.performance.now() - start), reachable };
}
// ~4GB
@@ -911,11 +942,11 @@ export async function optimizeNeutrinoPeers(
const peer = peers[i];
await new Promise(async (resolve) => {
try {
const ms = await pingPeer(peer);
console.log(`# ${peer} - ${ms}`);
const result = await pingPeer(peer);
console.log(`# ${peer} - ${result.ms}`);
results.push({
peer,
ms
ms: result.reachable ? result.ms : 'Unreachable'
});
resolve(true);
} catch (e) {
@@ -1014,11 +1045,11 @@ export async function optimizeNeutrinoPeers(
const peer = peers[i];
await new Promise(async (resolve) => {
try {
const ms = await pingPeer(peer);
console.log(`# ${peer} - ${ms}`);
const result = await pingPeer(peer);
console.log(`# ${peer} - ${result.ms}`);
results.push({
peer,
ms
ms: result.reachable ? result.ms : 'Unreachable'
});
resolve(true);
} catch (e) {
+5 -2
View File
@@ -84,8 +84,11 @@ export default class NodeChoice extends React.Component<
let reachableCount = 0;
for (const peer of DEFAULT_NEUTRINO_PEERS_MAINNET) {
try {
const ms = await pingPeer(peer);
if (ms < NEUTRINO_PING_THRESHOLD_MS) {
const result = await pingPeer(peer);
if (
result.reachable &&
result.ms < NEUTRINO_PING_THRESHOLD_MS
) {
reachableCount++;
}
} catch {
@@ -45,6 +45,7 @@ interface NeutrinoPeersState {
addPeer: string;
pingTime: number;
pingTimeout: boolean;
pingUnreachable: boolean;
pingHost: string;
loading: boolean;
}
@@ -70,6 +71,7 @@ export default class NeutrinoPeers extends React.Component<
addPeer: '',
pingTime: 0,
pingTimeout: false,
pingUnreachable: false,
pingHost: '',
loading: false
};
@@ -89,6 +91,7 @@ export default class NeutrinoPeers extends React.Component<
addPeer,
pingTime,
pingTimeout,
pingUnreachable,
pingHost,
loading
} = this.state;
@@ -125,6 +128,7 @@ export default class NeutrinoPeers extends React.Component<
<View style={{ flex: 1 }}>
{loading && <LoadingIndicator />}
{!pingTimeout &&
!pingUnreachable &&
!loading &&
pingHost &&
pingTime <= 200 && (
@@ -134,6 +138,7 @@ export default class NeutrinoPeers extends React.Component<
/>
)}
{!pingTimeout &&
!pingUnreachable &&
!loading &&
pingHost &&
pingTime < NEUTRINO_PING_THRESHOLD_MS &&
@@ -144,6 +149,7 @@ export default class NeutrinoPeers extends React.Component<
/>
)}
{!pingTimeout &&
!pingUnreachable &&
!loading &&
pingHost &&
pingTime >= NEUTRINO_PING_THRESHOLD_MS && (
@@ -160,6 +166,14 @@ export default class NeutrinoPeers extends React.Component<
dismissable
/>
)}
{!loading && pingHost && !!pingUnreachable && (
<ErrorMessage
message={`${pingHost}: ${localeString(
'views.Settings.EmbeddedNode.NeutrinoPeers.unreachable'
)}`}
dismissable
/>
)}
<ScrollView style={{ margin: 5 }}>
<>
<View
@@ -266,18 +280,29 @@ export default class NeutrinoPeers extends React.Component<
this.setState({
pingTime: 0,
pingTimeout: false,
pingUnreachable:
false,
pingHost: addPeer,
loading: true
});
const ms =
const result =
await pingPeer(
addPeer
);
if (result.reachable) {
this.setState({
pingTime: ms,
pingTime:
result.ms,
loading: false
});
} else {
this.setState({
pingUnreachable:
true,
loading: false
});
}
} catch (e) {
this.setState({
pingTimeout: true,
@@ -375,6 +400,8 @@ export default class NeutrinoPeers extends React.Component<
pingTime: 0,
pingTimeout:
false,
pingUnreachable:
false,
pingHost:
item,
loading:
@@ -382,18 +409,31 @@ export default class NeutrinoPeers extends React.Component<
}
);
const ms =
const result =
await pingPeer(
item
);
if (
result.reachable
) {
this.setState(
{
pingTime:
ms,
result.ms,
loading:
false
}
);
} else {
this.setState(
{
pingUnreachable:
true,
loading:
false
}
);
}
} catch (e) {
this.setState(
{