diff options
| author | bitflash-coin <bookofshamers@outlook.com> | 2026-08-04 06:34:32 -0700 |
|---|---|---|
| committer | bitflash-coin <bookofshamers@outlook.com> | 2026-08-04 06:34:32 -0700 |
| commit | 5ce650382352c1503f40abf08129ada69402af55 (patch) | |
| tree | 6a18198901f1e2c4933f1ac53c9365eec512bf63 /src/nostr.cpp | |
| parent | f34113291da7e5e3dc7d2468d4f9c212c05c584b (diff) | |
public commit
Diffstat (limited to 'src/nostr.cpp')
| -rw-r--r-- | src/nostr.cpp | 1775 |
1 files changed, 1775 insertions, 0 deletions
diff --git a/src/nostr.cpp b/src/nostr.cpp new file mode 100644 index 0000000..1fd60ad --- /dev/null +++ b/src/nostr.cpp | |||
| @@ -0,0 +1,1775 @@ | |||
| 1 | // Copyright (c) 2026 Bitflash developers | ||
| 2 | // Distributed under the MIT/X11 software license. | ||
| 3 | // | ||
| 4 | // Peer discovery over Nostr relays. See nostr.h. | ||
| 5 | |||
| 6 | #include "headers.h" | ||
| 7 | #include <openssl/ssl.h> | ||
| 8 | #include <openssl/err.h> | ||
| 9 | // We link libsecp256k1 statically (without this the header assumes DLL import). | ||
| 10 | // Both makefiles also pass -DSECP256K1_STATIC; the guard keeps that from | ||
| 11 | // warning while leaving this file correct if built without the flag. | ||
| 12 | #ifndef SECP256K1_STATIC | ||
| 13 | #define SECP256K1_STATIC | ||
| 14 | #endif | ||
| 15 | #include <secp256k1.h> | ||
| 16 | #include <secp256k1_schnorrsig.h> | ||
| 17 | #include <secp256k1_extrakeys.h> | ||
| 18 | |||
| 19 | // util.h redefines snprintf as a macro (2009 MSVC compat); this breaks | ||
| 20 | // nlohmann/json, which calls std::snprintf. Undo the macro just here. | ||
| 21 | #ifdef snprintf | ||
| 22 | #undef snprintf | ||
| 23 | #endif | ||
| 24 | #include <nlohmann/json.hpp> | ||
| 25 | |||
| 26 | #include "btfaddr.h" | ||
| 27 | #include "btfchan.h" | ||
| 28 | |||
| 29 | using json = nlohmann::json; | ||
| 30 | |||
| 31 | // Bitflash rendezvous descriptor: parameterized-replaceable Nostr event so each | ||
| 32 | // node keeps exactly one current descriptor (keyed by author pubkey). | ||
| 33 | static const int BTF_DESC_KIND = 38501; | ||
| 34 | // "-2" scopes the 2026 stable relaunch: new nodes discover only each other, and | ||
| 35 | // the abandoned test .btf descriptors lingering on the relays become invisible. | ||
| 36 | static const char* BTF_DESC_DTAG = "btf-descriptor-2"; | ||
| 37 | |||
| 38 | // Relay auto-discovery: a relay operator announces their relay as an addressable | ||
| 39 | // event (kind 38502) so every node learns it automatically -- no manual | ||
| 40 | // seed-list edit, no maintainer approval. Volunteer relays just work. | ||
| 41 | static const int BTF_RELAY_KIND = 38502; | ||
| 42 | static const char* BTF_RELAY_DTAG = "btf-relay-2"; | ||
| 43 | static const size_t BTF_MAX_DISCOVERED_RELAYS = 50; // anti-spam cap | ||
| 44 | |||
| 45 | // Pool announcements carry the operator's .btf address, fee, and live stats. | ||
| 46 | static const int BTF_POOL_KIND = 38503; | ||
| 47 | static const char* BTF_POOL_DTAG = "btf-pool-2"; | ||
| 48 | static const int64 BTF_POOL_MAX_AGE = 3 * 60; // seconds | ||
| 49 | |||
| 50 | // Anonymous auto-discovery: how many peers to keep connected via .btf rendezvous | ||
| 51 | // before we stop dialing more, and how many new dials to attempt per cycle. | ||
| 52 | // Target outbound peer count. With ~100 known .btf peers on the network, | ||
| 53 | // an Erdos-Renyi random graph stays connected with overwhelming probability | ||
| 54 | // once average degree exceeds ln(N) (~4.6 here); 24 is ~5x past that | ||
| 55 | // threshold, so this is plenty *provided* selection isn't fully correlated | ||
| 56 | // across nodes -- see the diversity slice in ConnectDiscoveredBtfPeers. | ||
| 57 | static const unsigned int BTF_TARGET_CONN = 24; | ||
| 58 | static const int BTF_DIALS_PER_PASS = 12; // try more peers per Nostr cycle | ||
| 59 | |||
| 60 | // Nostr descriptor freshness cutoff: skip peers whose descriptor hasn't been | ||
| 61 | // refreshed in this many seconds. Stale descriptors mean the node isn't running. | ||
| 62 | static const int64 BTF_PEER_MAX_AGE = 3 * 60 * 60; // 3 hours | ||
| 63 | |||
| 64 | // Other nodes' .btf addresses learned from their descriptors on the relays. | ||
| 65 | // Maps address -> descriptor created_at timestamp (Unix seconds). | ||
| 66 | static map<string, int64> g_btfPeers; | ||
| 67 | static CCriticalSection cs_btfPeers; | ||
| 68 | |||
| 69 | // Public discovery relays. Tunable. | ||
| 70 | // | ||
| 71 | // Picked for: free/public (no NIP-42 auth or payment wall), track record of | ||
| 72 | // high uptime, and *infrastructure* diversity, not just different hostnames. | ||
| 73 | // relay.damus.io, relay.snort.social and relay.primal.net are all fronted by | ||
| 74 | // Cloudflare -- different operators, same upstream, so a Cloudflare-side | ||
| 75 | // incident (or a Cloudflare IP range getting rate-limited/blocked on some | ||
| 76 | // network) can take out several of them at once even though they look like | ||
| 77 | // independent relays. Kept only one Cloudflare-fronted relay (damus.io, the | ||
| 78 | // most established) and filled the rest with relays on different hosts | ||
| 79 | // (Hetzner, various independents) so no single upstream provider is a | ||
| 80 | // single point of failure for peer discovery. 7 relays lands inside the | ||
| 81 | // commonly recommended 5-10 range for redundancy without keeping an | ||
| 82 | // excessive number of persistent sockets open per node. | ||
| 83 | const char* pszNostrRelays[] = { | ||
| 84 | "wss://relay.damus.io", | ||
| 85 | "wss://nos.lol", | ||
| 86 | // Was relay.nostr.band, which went down and stayed down (2026-07-31: | ||
| 87 | // resolves, refuses TCP on 443, from several networks). Replaced with a | ||
| 88 | // relay on a different upstream again -- 206.245.132.32, neither | ||
| 89 | // Cloudflare nor Hetzner, which the rest of this list already leans on. | ||
| 90 | "wss://nostr.bitcoiner.social", | ||
| 91 | "wss://nostr21.com", | ||
| 92 | "wss://offchain.pub", | ||
| 93 | "wss://nostr.mom", | ||
| 94 | "wss://relay.primal.net", | ||
| 95 | }; | ||
| 96 | const int nNostrRelays = ARRAYLEN(pszNostrRelays); | ||
| 97 | |||
| 98 | // Rendezvous meeting relay this node registers at (ThreadBtfAccept in net.cpp) | ||
| 99 | // and advertises in its .btf descriptor. Seed default; /rvrelay overrides. | ||
| 100 | // Seed rendezvous relays. More entries = more resilience: the node fails over | ||
| 101 | // between them (see ThreadBtfAccept), so DDoSing one relay IP can't take the | ||
| 102 | // network down. /rvrelay overrides this list with a single entry. | ||
| 103 | vector<string> vBtfMeetingRelays = { | ||
| 104 | "92.246.128.180:8434", // Sao Paulo, BR | ||
| 105 | "31.44.4.249:8434", // New Jersey, US | ||
| 106 | "90.156.222.107:8434", // Almaty, KZ | ||
| 107 | }; | ||
| 108 | string strBtfActiveRelay; | ||
| 109 | static CCriticalSection cs_activeRelay; | ||
| 110 | |||
| 111 | // Only the rendezvous we actually registered at (BtfSetActiveRelay, called from | ||
| 112 | // ThreadBtfAccept once registration succeeds). Empty until then, on purpose: it | ||
| 113 | // used to fall back to vBtfMeetingRelays[0], and since ThreadNostrSeed and | ||
| 114 | // ThreadBtfAccept start in parallel (net.cpp), that fallback is what got | ||
| 115 | // published at boot -- a descriptor telling peers to dial a relay we were not | ||
| 116 | // registered at. When that first seed was down, every peer that read the | ||
| 117 | // descriptor burned a dial on a dead rendezvous. | ||
| 118 | string BtfActiveRelay() | ||
| 119 | { | ||
| 120 | CRITICAL_BLOCK(cs_activeRelay) | ||
| 121 | return strBtfActiveRelay; | ||
| 122 | return ""; | ||
| 123 | } | ||
| 124 | |||
| 125 | void BtfSetActiveRelay(const string& relay) | ||
| 126 | { | ||
| 127 | CRITICAL_BLOCK(cs_activeRelay) | ||
| 128 | strBtfActiveRelay = relay; | ||
| 129 | } | ||
| 130 | |||
| 131 | // A relay this node advertises on Nostr for others to discover (/announcerelay). | ||
| 132 | string strBtfAnnounceRelay; | ||
| 133 | // Relays learned from other operators' Nostr announcements. | ||
| 134 | static set<string> g_discoveredRelays; | ||
| 135 | static CCriticalSection cs_discoveredRelays; | ||
| 136 | |||
| 137 | // Live pool announcements discovered from the relays. | ||
| 138 | static map<string, BtfPoolAnnouncement> g_discoveredPools; | ||
| 139 | static CCriticalSection cs_discoveredPools; | ||
| 140 | |||
| 141 | // Basic sanity: "host:port" with a numeric 1..65535 port and a plausible host. | ||
| 142 | static bool LooksLikeRelay(const string& r) | ||
| 143 | { | ||
| 144 | size_t colon = r.rfind(':'); | ||
| 145 | if (colon == string::npos || colon == 0 || colon + 1 >= r.size()) | ||
| 146 | return false; | ||
| 147 | if (r.size() > 100) | ||
| 148 | return false; | ||
| 149 | for (size_t i = colon + 1; i < r.size(); i++) | ||
| 150 | if (!isdigit((unsigned char)r[i])) | ||
| 151 | return false; | ||
| 152 | int port = atoi(r.c_str() + colon + 1); | ||
| 153 | if (port <= 0 || port > 65535) | ||
| 154 | return false; | ||
| 155 | // host: allow letters, digits, dot, dash, colon (IPv6 not supported yet) | ||
| 156 | for (size_t i = 0; i < colon; i++) | ||
| 157 | { | ||
| 158 | char c = r[i]; | ||
| 159 | if (!(isalnum((unsigned char)c) || c == '.' || c == '-')) | ||
| 160 | return false; | ||
| 161 | } | ||
| 162 | return true; | ||
| 163 | } | ||
| 164 | |||
| 165 | // Every relay to try: curated seeds first (reliable bootstrap), then the ones | ||
| 166 | // discovered from Nostr announcements (volunteer relays). | ||
| 167 | vector<string> BtfAllRelays() | ||
| 168 | { | ||
| 169 | vector<string> all = vBtfMeetingRelays; | ||
| 170 | CRITICAL_BLOCK(cs_discoveredRelays) | ||
| 171 | foreach(const string& r, g_discoveredRelays) | ||
| 172 | if (find(all.begin(), all.end(), r) == all.end()) | ||
| 173 | all.push_back(r); | ||
| 174 | return all; | ||
| 175 | } | ||
| 176 | |||
| 177 | |||
| 178 | // | ||
| 179 | // Utilities | ||
| 180 | // | ||
| 181 | static string HexEncode(const unsigned char* p, size_t n) | ||
| 182 | { | ||
| 183 | static const char* h = "0123456789abcdef"; | ||
| 184 | string s; | ||
| 185 | s.reserve(n * 2); | ||
| 186 | for (size_t i = 0; i < n; i++) | ||
| 187 | { | ||
| 188 | s += h[p[i] >> 4]; | ||
| 189 | s += h[p[i] & 0xf]; | ||
| 190 | } | ||
| 191 | return s; | ||
| 192 | } | ||
| 193 | |||
| 194 | static bool HexDecode(const string& s, unsigned char* out, size_t n) | ||
| 195 | { | ||
| 196 | if (s.size() != n * 2) | ||
| 197 | return false; | ||
| 198 | for (size_t i = 0; i < n; i++) | ||
| 199 | { | ||
| 200 | unsigned int b; | ||
| 201 | if (sscanf(s.c_str() + i * 2, "%2x", &b) != 1) | ||
| 202 | return false; | ||
| 203 | out[i] = (unsigned char)b; | ||
| 204 | } | ||
| 205 | return true; | ||
| 206 | } | ||
| 207 | |||
| 208 | static string Base64Encode(const unsigned char* p, size_t n) | ||
| 209 | { | ||
| 210 | static const char* tbl = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; | ||
| 211 | string s; | ||
| 212 | for (size_t i = 0; i < n; i += 3) | ||
| 213 | { | ||
| 214 | unsigned int b = p[i] << 16; | ||
| 215 | if (i + 1 < n) b |= p[i + 1] << 8; | ||
| 216 | if (i + 2 < n) b |= p[i + 2]; | ||
| 217 | s += tbl[(b >> 18) & 0x3f]; | ||
| 218 | s += tbl[(b >> 12) & 0x3f]; | ||
| 219 | s += (i + 1 < n) ? tbl[(b >> 6) & 0x3f] : '='; | ||
| 220 | s += (i + 2 < n) ? tbl[b & 0x3f] : '='; | ||
| 221 | } | ||
| 222 | return s; | ||
| 223 | } | ||
| 224 | |||
| 225 | |||
| 226 | // | ||
| 227 | // Node Nostr key -- secp256k1 x-only (BIP340), persisted in <appdir>/nostr.key | ||
| 228 | // | ||
| 229 | class CNostrKey | ||
| 230 | { | ||
| 231 | public: | ||
| 232 | secp256k1_context* ctx; | ||
| 233 | secp256k1_keypair keypair; | ||
| 234 | unsigned char seckey[32]; // node's secp256k1 secret (for descriptor signing) | ||
| 235 | unsigned char xonly[32]; // node's x-only pubkey (also its .btf identity) | ||
| 236 | unsigned char enc_pk[32]; // node's x25519 public key (echan `enc` field) | ||
| 237 | unsigned char enc_sk[32]; // node's x25519 secret key | ||
| 238 | bool fValid; | ||
| 239 | |||
| 240 | CNostrKey() : ctx(NULL), fValid(false) {} | ||
| 241 | |||
| 242 | ~CNostrKey() | ||
| 243 | { | ||
| 244 | if (ctx) secp256k1_context_destroy(ctx); | ||
| 245 | } | ||
| 246 | |||
| 247 | // Load or generate a 32-byte key persisted as hex in <appdir>/<name>. | ||
| 248 | static void LoadOrGenSecp(secp256k1_context* ctx, const string& file, unsigned char sk[32]) | ||
| 249 | { | ||
| 250 | FILE* f = fopen(file.c_str(), "rb"); | ||
| 251 | string strHex; | ||
| 252 | if (f) { char buf[65] = {0}; fread(buf, 1, 64, f); fclose(f); strHex = buf; } | ||
| 253 | if (!HexDecode(strHex, sk, 32) || !secp256k1_ec_seckey_verify(ctx, sk)) | ||
| 254 | { | ||
| 255 | do { RAND_bytes(sk, sizeof(unsigned char) * 32); } while (!secp256k1_ec_seckey_verify(ctx, sk)); | ||
| 256 | FILE* fw = fopen(file.c_str(), "wb"); | ||
| 257 | if (fw) { string h = HexEncode(sk, 32); fwrite(h.c_str(), 1, h.size(), fw); fclose(fw); } | ||
| 258 | } | ||
| 259 | } | ||
| 260 | |||
| 261 | bool Init() | ||
| 262 | { | ||
| 263 | ctx = secp256k1_context_create(SECP256K1_CONTEXT_SIGN | SECP256K1_CONTEXT_VERIFY); | ||
| 264 | if (!ctx) | ||
| 265 | return false; | ||
| 266 | unsigned char seed[32]; | ||
| 267 | RAND_bytes(seed, sizeof(seed)); | ||
| 268 | secp256k1_context_randomize(ctx, seed); | ||
| 269 | |||
| 270 | LoadOrGenSecp(ctx, GetAppDir() + "/nostr.key", seckey); | ||
| 271 | |||
| 272 | if (!secp256k1_keypair_create(ctx, &keypair, seckey)) | ||
| 273 | return false; | ||
| 274 | secp256k1_xonly_pubkey xpub; | ||
| 275 | if (!secp256k1_keypair_xonly_pub(ctx, &xpub, NULL, &keypair)) | ||
| 276 | return false; | ||
| 277 | secp256k1_xonly_pubkey_serialize(ctx, xonly, &xpub); | ||
| 278 | |||
| 279 | // x25519 encryption keypair for the end-to-end channel, persisted separately. | ||
| 280 | btf::ChanInit(); | ||
| 281 | string strEncFile = GetAppDir() + "/btf_enc.key"; | ||
| 282 | FILE* fe = fopen(strEncFile.c_str(), "rb"); | ||
| 283 | bool fLoaded = false; | ||
| 284 | if (fe) | ||
| 285 | { | ||
| 286 | char buf[65] = {0}; | ||
| 287 | if (fread(buf, 1, 64, fe) == 64 && HexDecode(string(buf), enc_sk, 32)) | ||
| 288 | fLoaded = true; | ||
| 289 | fclose(fe); | ||
| 290 | } | ||
| 291 | if (fLoaded) | ||
| 292 | { | ||
| 293 | // Derive the public key from the stored secret (stable identity). | ||
| 294 | btf::ChanPublicFromSecret(enc_pk, enc_sk); | ||
| 295 | } | ||
| 296 | else | ||
| 297 | { | ||
| 298 | btf::ChanKeypair(enc_pk, enc_sk); | ||
| 299 | FILE* few = fopen(strEncFile.c_str(), "wb"); | ||
| 300 | if (few) { string h = HexEncode(enc_sk, 32); fwrite(h.c_str(), 1, h.size(), few); fclose(few); } | ||
| 301 | } | ||
| 302 | |||
| 303 | fValid = true; | ||
| 304 | return true; | ||
| 305 | } | ||
| 306 | |||
| 307 | string PubKeyHex() const { return HexEncode(xonly, 32); } | ||
| 308 | string EncPubHex() const { return HexEncode(enc_pk, 32); } | ||
| 309 | string BtfAddress() const { return btf::Address(xonly); } | ||
| 310 | |||
| 311 | bool SignId(const unsigned char id[32], string& sigHexOut) | ||
| 312 | { | ||
| 313 | unsigned char sig[64]; | ||
| 314 | unsigned char aux[32]; | ||
| 315 | RAND_bytes(aux, sizeof(aux)); | ||
| 316 | if (!secp256k1_schnorrsig_sign32(ctx, sig, id, &keypair, aux)) | ||
| 317 | return false; | ||
| 318 | sigHexOut = HexEncode(sig, 64); | ||
| 319 | return true; | ||
| 320 | } | ||
| 321 | }; | ||
| 322 | |||
| 323 | // The node's single Nostr/.btf identity, shared by the seeding thread here and | ||
| 324 | // the rendezvous transport in net.cpp. Lazily initialized under a lock; the | ||
| 325 | // secp256k1 context is immutable after Init, so concurrent use is safe. | ||
| 326 | static CNostrKey g_nostrKey; | ||
| 327 | static CCriticalSection cs_nostrKey; | ||
| 328 | |||
| 329 | static bool EnsureNostrKey() | ||
| 330 | { | ||
| 331 | CRITICAL_BLOCK(cs_nostrKey) | ||
| 332 | if (!g_nostrKey.fValid) | ||
| 333 | g_nostrKey.Init(); | ||
| 334 | return g_nostrKey.fValid; | ||
| 335 | } | ||
| 336 | |||
| 337 | bool BtfGetIdentity(unsigned char pubkey[32], unsigned char enc_sk[32]) | ||
| 338 | { | ||
| 339 | if (!EnsureNostrKey()) | ||
| 340 | return false; | ||
| 341 | memcpy(pubkey, g_nostrKey.xonly, 32); | ||
| 342 | memcpy(enc_sk, g_nostrKey.enc_sk, 32); | ||
| 343 | return true; | ||
| 344 | } | ||
| 345 | |||
| 346 | std::string BtfLocalAddress() | ||
| 347 | { | ||
| 348 | if (!EnsureNostrKey()) | ||
| 349 | return ""; | ||
| 350 | return g_nostrKey.BtfAddress(); | ||
| 351 | } | ||
| 352 | |||
| 353 | // This node's own descriptor, signed and self-certifying -- the same blob | ||
| 354 | // PublishDescriptor puts on Nostr, but handed straight to a peer instead. | ||
| 355 | // | ||
| 356 | // Reads strBtfActiveRelay directly rather than going through BtfActiveRelay(): | ||
| 357 | // only a rendezvous we actually registered at is worth announcing. Handing out | ||
| 358 | // a meeting node we are not reachable at would make every peer that receives | ||
| 359 | // it burn a dial, and over peer exchange that mistake travels further than it | ||
| 360 | // does over Nostr. | ||
| 361 | std::string BtfLocalDescriptor() | ||
| 362 | { | ||
| 363 | if (!EnsureNostrKey()) | ||
| 364 | return ""; | ||
| 365 | string meeting; | ||
| 366 | CRITICAL_BLOCK(cs_activeRelay) | ||
| 367 | meeting = strBtfActiveRelay; | ||
| 368 | if (meeting.empty()) | ||
| 369 | return ""; | ||
| 370 | return btf::SignDescriptor(g_nostrKey.ctx, g_nostrKey.seckey, | ||
| 371 | g_nostrKey.EncPubHex(), meeting, (uint64_t)GetTime()); | ||
| 372 | } | ||
| 373 | |||
| 374 | // The shared secp256k1 context, so net.cpp can verify descriptors that arrive | ||
| 375 | // from peers. NULL if the identity could not be loaded. | ||
| 376 | void* BtfSecpContext() | ||
| 377 | { | ||
| 378 | if (!EnsureNostrKey()) | ||
| 379 | return NULL; | ||
| 380 | return (void*)g_nostrKey.ctx; | ||
| 381 | } | ||
| 382 | |||
| 383 | int GetDiscoveredPeerCount() | ||
| 384 | { | ||
| 385 | int n = 0; | ||
| 386 | CRITICAL_BLOCK(cs_btfPeers) { n = (int)g_btfPeers.size(); } | ||
| 387 | return n; | ||
| 388 | } | ||
| 389 | |||
| 390 | void BtfGetPoolAnnouncements(std::vector<BtfPoolAnnouncement>& out) | ||
| 391 | { | ||
| 392 | out.clear(); | ||
| 393 | int64 now = GetTime(); | ||
| 394 | CRITICAL_BLOCK(cs_discoveredPools) | ||
| 395 | { | ||
| 396 | for (map<string, BtfPoolAnnouncement>::iterator it = g_discoveredPools.begin(); it != g_discoveredPools.end(); ) | ||
| 397 | { | ||
| 398 | if (it->second.createdAt > 0 && now - it->second.createdAt > BTF_POOL_MAX_AGE) | ||
| 399 | { | ||
| 400 | it = g_discoveredPools.erase(it); | ||
| 401 | continue; | ||
| 402 | } | ||
| 403 | out.push_back(it->second); | ||
| 404 | ++it; | ||
| 405 | } | ||
| 406 | } | ||
| 407 | sort(out.begin(), out.end(), [](const BtfPoolAnnouncement& a, const BtfPoolAnnouncement& b) { | ||
| 408 | return a.createdAt > b.createdAt; | ||
| 409 | }); | ||
| 410 | } | ||
| 411 | |||
| 412 | // Build and sign a Nostr event (NIP-01). Returns the event JSON. | ||
| 413 | static bool BuildSignedEvent(CNostrKey& key, int kind, const json& tags, | ||
| 414 | const string& content, json& eventOut) | ||
| 415 | { | ||
| 416 | int64 created = GetTime(); | ||
| 417 | // Canonical serialization for the id: [0,pubkey,created_at,kind,tags,content] | ||
| 418 | json ser = json::array({ 0, key.PubKeyHex(), created, kind, tags, content }); | ||
| 419 | string s = ser.dump(); | ||
| 420 | |||
| 421 | unsigned char id[32]; | ||
| 422 | SHA256((const unsigned char*)s.data(), s.size(), id); | ||
| 423 | |||
| 424 | string sigHex; | ||
| 425 | if (!key.SignId(id, sigHex)) | ||
| 426 | return false; | ||
| 427 | |||
| 428 | eventOut = json::object(); | ||
| 429 | eventOut["id"] = HexEncode(id, 32); | ||
| 430 | eventOut["pubkey"] = key.PubKeyHex(); | ||
| 431 | eventOut["created_at"] = created; | ||
| 432 | eventOut["kind"] = kind; | ||
| 433 | eventOut["tags"] = tags; | ||
| 434 | eventOut["content"] = content; | ||
| 435 | eventOut["sig"] = sigHex; | ||
| 436 | return true; | ||
| 437 | } | ||
| 438 | |||
| 439 | |||
| 440 | // | ||
| 441 | // Minimal WebSocket client over TLS (wss) or TCP (ws), blocking with timeout. | ||
| 442 | // | ||
| 443 | // Ceilings on what a relay may make this node allocate. Nostr events are | ||
| 444 | // small -- relays themselves usually refuse anything over a few hundred KB -- | ||
| 445 | // so these are generous, and they bound an otherwise attacker-chosen size. | ||
| 446 | static const uint64 MAX_WS_FRAME = 1 << 20; // 1 MiB per frame | ||
| 447 | static const uint64 MAX_WS_MESSAGE = 4 << 20; // 4 MiB reassembled | ||
| 448 | |||
| 449 | class CWebSocket | ||
| 450 | { | ||
| 451 | public: | ||
| 452 | SOCKET hSocket; | ||
| 453 | SSL_CTX* sslctx; | ||
| 454 | SSL* ssl; | ||
| 455 | bool fTls; | ||
| 456 | string recvbuf; | ||
| 457 | |||
| 458 | CWebSocket() : hSocket(INVALID_SOCKET), sslctx(NULL), ssl(NULL), fTls(false) {} | ||
| 459 | ~CWebSocket() { Close(); } | ||
| 460 | |||
| 461 | void Close() | ||
| 462 | { | ||
| 463 | if (ssl) { SSL_shutdown(ssl); SSL_free(ssl); ssl = NULL; } | ||
| 464 | if (sslctx) { SSL_CTX_free(sslctx); sslctx = NULL; } | ||
| 465 | if (hSocket != INVALID_SOCKET) { BtfCloseSocket(hSocket); hSocket = INVALID_SOCKET; } | ||
| 466 | } | ||
| 467 | |||
| 468 | int RawRead(char* buf, int len) | ||
| 469 | { | ||
| 470 | if (fTls) return SSL_read(ssl, buf, len); | ||
| 471 | return recv(hSocket, buf, len, 0); | ||
| 472 | } | ||
| 473 | |||
| 474 | bool RawWrite(const char* buf, int len) | ||
| 475 | { | ||
| 476 | int off = 0; | ||
| 477 | while (off < len) | ||
| 478 | { | ||
| 479 | int n = fTls ? SSL_write(ssl, buf + off, len - off) | ||
| 480 | : send(hSocket, buf + off, len - off, 0); | ||
| 481 | if (n <= 0) return false; | ||
| 482 | off += n; | ||
| 483 | } | ||
| 484 | return true; | ||
| 485 | } | ||
| 486 | |||
| 487 | // Connect and perform the WebSocket handshake. url = wss://host[:port]/path | ||
| 488 | bool Connect(const string& url, int timeoutSec) | ||
| 489 | { | ||
| 490 | string u = url; | ||
| 491 | int port = 443; | ||
| 492 | fTls = true; | ||
| 493 | if (u.compare(0, 6, "wss://") == 0) { u = u.substr(6); fTls = true; port = 443; } | ||
| 494 | else if (u.compare(0, 5, "ws://") == 0) { u = u.substr(5); fTls = false; port = 80; } | ||
| 495 | else return false; | ||
| 496 | |||
| 497 | string host, path = "/"; | ||
| 498 | size_t slash = u.find('/'); | ||
| 499 | if (slash != string::npos) { host = u.substr(0, slash); path = u.substr(slash); } | ||
| 500 | else host = u; | ||
| 501 | size_t colon = host.find(':'); | ||
| 502 | if (colon != string::npos) { port = atoi(host.substr(colon + 1).c_str()); host = host.substr(0, colon); } | ||
| 503 | |||
| 504 | // Resolve and connect (TCP). AF_UNSPEC so we get both A and AAAA | ||
| 505 | // records back and try them in the order the resolver prefers, | ||
| 506 | // rather than hard-failing whenever the IPv4 path to a relay is | ||
| 507 | // filtered/rate-limited (increasingly common for CDN-fronted | ||
| 508 | // relays) while a working IPv6 route exists right there in the | ||
| 509 | // same getaddrinfo() result. | ||
| 510 | struct addrinfo hints, *res = NULL, *rp = NULL; | ||
| 511 | memset(&hints, 0, sizeof(hints)); | ||
| 512 | hints.ai_family = AF_UNSPEC; | ||
| 513 | hints.ai_socktype = SOCK_STREAM; | ||
| 514 | char portstr[16]; sprintf(portstr, "%d", port); | ||
| 515 | if (getaddrinfo(host.c_str(), portstr, &hints, &res) != 0 || !res) | ||
| 516 | return error("Nostr: getaddrinfo %s failed", host.c_str()); | ||
| 517 | |||
| 518 | bool fConnected = false; | ||
| 519 | for (rp = res; rp != NULL; rp = rp->ai_next) | ||
| 520 | { | ||
| 521 | hSocket = BtfSocketTag(socket(rp->ai_family, rp->ai_socktype, rp->ai_protocol), SOCK_NOSTR); | ||
| 522 | if (hSocket == INVALID_SOCKET) | ||
| 523 | continue; | ||
| 524 | |||
| 525 | // Socket receive/send timeout: Windows takes a DWORD of | ||
| 526 | // milliseconds; POSIX takes a struct timeval. | ||
| 527 | #ifdef _WIN32 | ||
| 528 | DWORD tv = timeoutSec * 1000; | ||
| 529 | setsockopt(hSocket, SOL_SOCKET, SO_RCVTIMEO, (char*)&tv, sizeof(tv)); | ||
| 530 | setsockopt(hSocket, SOL_SOCKET, SO_SNDTIMEO, (char*)&tv, sizeof(tv)); | ||
| 531 | #else | ||
| 532 | struct timeval tv; | ||
| 533 | tv.tv_sec = timeoutSec; | ||
| 534 | tv.tv_usec = 0; | ||
| 535 | setsockopt(hSocket, SOL_SOCKET, SO_RCVTIMEO, (char*)&tv, sizeof(tv)); | ||
| 536 | setsockopt(hSocket, SOL_SOCKET, SO_SNDTIMEO, (char*)&tv, sizeof(tv)); | ||
| 537 | #endif | ||
| 538 | |||
| 539 | if (connect(hSocket, rp->ai_addr, (int)rp->ai_addrlen) == 0) | ||
| 540 | { | ||
| 541 | fConnected = true; | ||
| 542 | break; | ||
| 543 | } | ||
| 544 | |||
| 545 | BtfCloseSocket(hSocket); | ||
| 546 | hSocket = INVALID_SOCKET; | ||
| 547 | } | ||
| 548 | freeaddrinfo(res); | ||
| 549 | |||
| 550 | if (!fConnected) | ||
| 551 | return error("Nostr: connect %s:%d failed", host.c_str(), port); | ||
| 552 | |||
| 553 | // TLS | ||
| 554 | if (fTls) | ||
| 555 | { | ||
| 556 | sslctx = SSL_CTX_new(TLS_client_method()); | ||
| 557 | if (!sslctx) return false; | ||
| 558 | ssl = SSL_new(sslctx); | ||
| 559 | SSL_set_fd(ssl, (int)hSocket); | ||
| 560 | SSL_set_tlsext_host_name(ssl, host.c_str()); // SNI | ||
| 561 | if (SSL_connect(ssl) != 1) | ||
| 562 | return error("Nostr: TLS handshake with %s failed", host.c_str()); | ||
| 563 | } | ||
| 564 | |||
| 565 | // WebSocket handshake (HTTP Upgrade) | ||
| 566 | unsigned char keybytes[16]; | ||
| 567 | RAND_bytes(keybytes, sizeof(keybytes)); | ||
| 568 | string wskey = Base64Encode(keybytes, sizeof(keybytes)); | ||
| 569 | string req = | ||
| 570 | "GET " + path + " HTTP/1.1\r\n" | ||
| 571 | "Host: " + host + "\r\n" | ||
| 572 | "Upgrade: websocket\r\n" | ||
| 573 | "Connection: Upgrade\r\n" | ||
| 574 | "Sec-WebSocket-Key: " + wskey + "\r\n" | ||
| 575 | "Sec-WebSocket-Version: 13\r\n\r\n"; | ||
| 576 | if (!RawWrite(req.data(), (int)req.size())) | ||
| 577 | return false; | ||
| 578 | |||
| 579 | // Read the handshake response until \r\n\r\n | ||
| 580 | string resp; | ||
| 581 | char c; | ||
| 582 | while (resp.find("\r\n\r\n") == string::npos) | ||
| 583 | { | ||
| 584 | int n = RawRead(&c, 1); | ||
| 585 | if (n <= 0) return error("Nostr: no handshake response from %s", host.c_str()); | ||
| 586 | resp += c; | ||
| 587 | // Bail out instead of breaking: breaking left the socket parked in | ||
| 588 | // the middle of the header block, and since the status line is read | ||
| 589 | // first the " 101 " check below still passed -- so the caller got a | ||
| 590 | // "connected" socket whose next bytes were leftover HTTP, which the | ||
| 591 | // frame parser then read as garbage frames. | ||
| 592 | if (resp.size() > 8192) | ||
| 593 | return error("Nostr: handshake headers too large from %s", host.c_str()); | ||
| 594 | } | ||
| 595 | if (resp.find(" 101 ") == string::npos) | ||
| 596 | return error("Nostr: handshake refused by %s", host.c_str()); | ||
| 597 | return true; | ||
| 598 | } | ||
| 599 | |||
| 600 | // Send a text message (masked frame, as required by the protocol) | ||
| 601 | bool SendText(const string& msg) | ||
| 602 | { | ||
| 603 | string frame; | ||
| 604 | frame += (char)0x81; // FIN + opcode texto | ||
| 605 | size_t len = msg.size(); | ||
| 606 | if (len < 126) | ||
| 607 | frame += (char)(0x80 | len); | ||
| 608 | else if (len <= 0xffff) | ||
| 609 | { | ||
| 610 | frame += (char)(0x80 | 126); | ||
| 611 | frame += (char)((len >> 8) & 0xff); | ||
| 612 | frame += (char)(len & 0xff); | ||
| 613 | } | ||
| 614 | else | ||
| 615 | { | ||
| 616 | frame += (char)(0x80 | 127); | ||
| 617 | for (int i = 7; i >= 0; i--) frame += (char)((len >> (8 * i)) & 0xff); | ||
| 618 | } | ||
| 619 | unsigned char mask[4]; | ||
| 620 | RAND_bytes(mask, 4); | ||
| 621 | frame.append((char*)mask, 4); | ||
| 622 | string masked = msg; | ||
| 623 | for (size_t i = 0; i < masked.size(); i++) | ||
| 624 | masked[i] ^= mask[i & 3]; | ||
| 625 | frame += masked; | ||
| 626 | return RawWrite(frame.data(), (int)frame.size()); | ||
| 627 | } | ||
| 628 | |||
| 629 | // Receive the next complete text message. Returns false on error/timeout. | ||
| 630 | bool RecvText(string& out) | ||
| 631 | { | ||
| 632 | out.clear(); | ||
| 633 | for (;;) | ||
| 634 | { | ||
| 635 | unsigned char hdr[2]; | ||
| 636 | if (!ReadN((char*)hdr, 2)) return false; | ||
| 637 | bool fin = hdr[0] & 0x80; | ||
| 638 | int opcode = hdr[0] & 0x0f; | ||
| 639 | bool masked = hdr[1] & 0x80; | ||
| 640 | uint64 len = hdr[1] & 0x7f; | ||
| 641 | if (len == 126) | ||
| 642 | { | ||
| 643 | unsigned char e[2]; | ||
| 644 | if (!ReadN((char*)e, 2)) return false; | ||
| 645 | len = (e[0] << 8) | e[1]; | ||
| 646 | } | ||
| 647 | else if (len == 127) | ||
| 648 | { | ||
| 649 | unsigned char e[8]; | ||
| 650 | if (!ReadN((char*)e, 8)) return false; | ||
| 651 | len = 0; | ||
| 652 | for (int i = 0; i < 8; i++) len = (len << 8) | e[i]; | ||
| 653 | } | ||
| 654 | // The length is whatever the relay says it is, and these are public | ||
| 655 | // relays the node does not control. Unbounded, a single frame header | ||
| 656 | // claiming 2^40 bytes makes the node allocate until it dies -- and | ||
| 657 | // reassembly across continuation frames grows without limit even | ||
| 658 | // when each frame is small. Cap both, and refuse anything that will | ||
| 659 | // not survive the int narrowing ReadN takes. | ||
| 660 | if (len > MAX_WS_FRAME) return false; | ||
| 661 | |||
| 662 | unsigned char mask[4] = {0,0,0,0}; | ||
| 663 | if (masked && !ReadN((char*)mask, 4)) return false; | ||
| 664 | |||
| 665 | string payload; | ||
| 666 | payload.resize((size_t)len); | ||
| 667 | if (len && !ReadN(&payload[0], (int)len)) return false; | ||
| 668 | if (masked) | ||
| 669 | for (size_t i = 0; i < payload.size(); i++) | ||
| 670 | payload[i] ^= mask[i & 3]; | ||
| 671 | |||
| 672 | if (opcode == 0x8) return false; // close | ||
| 673 | if (opcode == 0x9) { SendPong(payload); continue; } // ping | ||
| 674 | if (opcode == 0xA) continue; // pong | ||
| 675 | if (out.size() + payload.size() > MAX_WS_MESSAGE) return false; | ||
| 676 | out += payload; // text/continuation | ||
| 677 | if (fin) return true; | ||
| 678 | } | ||
| 679 | } | ||
| 680 | |||
| 681 | private: | ||
| 682 | bool ReadN(char* buf, int n) | ||
| 683 | { | ||
| 684 | int off = 0; | ||
| 685 | while (off < n) | ||
| 686 | { | ||
| 687 | int r = RawRead(buf + off, n - off); | ||
| 688 | if (r <= 0) return false; | ||
| 689 | off += r; | ||
| 690 | } | ||
| 691 | return true; | ||
| 692 | } | ||
| 693 | |||
| 694 | void SendPong(const string& payload) | ||
| 695 | { | ||
| 696 | string frame; | ||
| 697 | frame += (char)0x8A; | ||
| 698 | frame += (char)(0x80 | (payload.size() & 0x7f)); | ||
| 699 | unsigned char mask[4]; RAND_bytes(mask, 4); | ||
| 700 | frame.append((char*)mask, 4); | ||
| 701 | string m = payload; | ||
| 702 | for (size_t i = 0; i < m.size(); i++) m[i] ^= mask[i & 3]; | ||
| 703 | frame += m; | ||
| 704 | RawWrite(frame.data(), (int)frame.size()); | ||
| 705 | } | ||
| 706 | }; | ||
| 707 | |||
| 708 | |||
| 709 | // Persistent relay connections. Public relays (relay.damus.io in particular) | ||
| 710 | // rate-limit new connections per source IP fairly aggressively. Before this, | ||
| 711 | // every single operation -- self-test, per-pass seeding, batch descriptor | ||
| 712 | // resolve, pool announce, pool query -- opened its own fresh CWebSocket, | ||
| 713 | // meaning a single node could fire a dozen-plus brand-new TCP+TLS+WS | ||
| 714 | // handshakes at the same 4 relays within any given minute (ThreadNostrSeed's | ||
| 715 | // own loop plus the separate ThreadBtfPoolAnnouncer thread, each reconnecting | ||
| 716 | // from scratch every cycle). That's exactly what was showing up as constant | ||
| 717 | // "handshake refused by relay.damus.io" and "connect ... failed" -- not | ||
| 718 | // necessarily dead relays, just this node getting rate-limited by its own | ||
| 719 | // reconnect churn. | ||
| 720 | // | ||
| 721 | // Now every relay-touching call reuses one long-lived socket per relay via | ||
| 722 | // this pool, reconnecting only when a socket actually drops. That cuts fresh | ||
| 723 | // connections from several per minute down to essentially one per relay for | ||
| 724 | // the life of the process. | ||
| 725 | struct RelayConn | ||
| 726 | { | ||
| 727 | CWebSocket ws; | ||
| 728 | CCriticalSection cs; // serializes use of this relay's socket | ||
| 729 | bool fConnected; | ||
| 730 | RelayConn() : fConnected(false) {} | ||
| 731 | }; | ||
| 732 | static map<string, RelayConn*> g_relayPool; | ||
| 733 | static CCriticalSection cs_relayPool; | ||
| 734 | |||
| 735 | static RelayConn* GetRelayConn(const string& relay) | ||
| 736 | { | ||
| 737 | CRITICAL_BLOCK(cs_relayPool) | ||
| 738 | { | ||
| 739 | map<string, RelayConn*>::iterator it = g_relayPool.find(relay); | ||
| 740 | if (it != g_relayPool.end()) | ||
| 741 | return it->second; | ||
| 742 | RelayConn* rc = new RelayConn(); | ||
| 743 | g_relayPool[relay] = rc; | ||
| 744 | return rc; | ||
| 745 | } | ||
| 746 | return NULL; // unreachable | ||
| 747 | } | ||
| 748 | |||
| 749 | // RAII lease on a relay's pooled connection: connects (if not already | ||
| 750 | // connected) on construction, and on destruction either keeps the socket | ||
| 751 | // alive for the next caller (if the exchange succeeded) or closes it so the | ||
| 752 | // next caller reconnects instead of reusing a socket that may be wedged. | ||
| 753 | // Caller must set fOk = true once its exchange with the relay completed | ||
| 754 | // cleanly. | ||
| 755 | class CRelayLease | ||
| 756 | { | ||
| 757 | public: | ||
| 758 | RelayConn* rc; | ||
| 759 | bool fOk; | ||
| 760 | bool fJustConnected; // true if this lease had to (re)connect, false if reusing a live socket | ||
| 761 | |||
| 762 | CRelayLease(const string& relay, int timeoutSec) : rc(NULL), fOk(false), fJustConnected(false) | ||
| 763 | { | ||
| 764 | rc = GetRelayConn(relay); | ||
| 765 | rc->cs.Enter(); | ||
| 766 | if (!rc->fConnected) | ||
| 767 | { | ||
| 768 | rc->ws.Close(); | ||
| 769 | rc->fConnected = rc->ws.Connect(relay, timeoutSec); | ||
| 770 | fJustConnected = rc->fConnected; | ||
| 771 | } | ||
| 772 | } | ||
| 773 | ~CRelayLease() | ||
| 774 | { | ||
| 775 | if (!fOk) | ||
| 776 | { | ||
| 777 | rc->ws.Close(); | ||
| 778 | rc->fConnected = false; | ||
| 779 | } | ||
| 780 | rc->cs.Leave(); | ||
| 781 | } | ||
| 782 | bool Connected() const { return rc->fConnected; } | ||
| 783 | CWebSocket& Ws() { return rc->ws; } | ||
| 784 | |||
| 785 | private: | ||
| 786 | CRelayLease(const CRelayLease&); | ||
| 787 | CRelayLease& operator=(const CRelayLease&); | ||
| 788 | }; | ||
| 789 | |||
| 790 | |||
| 791 | // Publish this node's self-certifying `.btf` descriptor: "reach me (this key) | ||
| 792 | // via <meeting_node>, encrypting to my x25519 key <enc>". Signed by the node | ||
| 793 | // key so only the address's owner can publish it (no hijacking). | ||
| 794 | static void PublishDescriptor(CWebSocket& ws, CNostrKey& key) | ||
| 795 | { | ||
| 796 | // meeting_node is the rendezvous relay this node's hidden service | ||
| 797 | // (ThreadBtfAccept in net.cpp) is registered at -- where clients dial us. | ||
| 798 | string meeting_node = BtfActiveRelay(); | ||
| 799 | if (meeting_node.empty()) | ||
| 800 | { | ||
| 801 | // Not registered anywhere yet. Publishing now would advertise a meeting | ||
| 802 | // node we can't be reached at, and peers would waste dials on it. The | ||
| 803 | // ThreadNostrSeed loop republishes on every pass, so skipping costs | ||
| 804 | // nothing but the delay until ThreadBtfAccept registers. | ||
| 805 | LogPrint("nostr", "Nostr: skipping descriptor publish, no rendezvous registered yet\n"); | ||
| 806 | return; | ||
| 807 | } | ||
| 808 | string desc = btf::SignDescriptor(key.ctx, key.seckey, key.EncPubHex(), | ||
| 809 | meeting_node, (uint64_t)GetTime()); | ||
| 810 | if (desc.empty()) | ||
| 811 | return; | ||
| 812 | json tags = json::array({ json::array({ "d", BTF_DESC_DTAG }) }); | ||
| 813 | json ev; | ||
| 814 | if (BuildSignedEvent(key, BTF_DESC_KIND, tags, desc, ev)) | ||
| 815 | { | ||
| 816 | json pub = json::array({ "EVENT", ev }); | ||
| 817 | ws.SendText(pub.dump()); | ||
| 818 | } | ||
| 819 | } | ||
| 820 | |||
| 821 | // Announce our relay (from /announcerelay=host:port) so every node discovers it | ||
| 822 | // automatically -- volunteer relays join the network with no maintainer step. | ||
| 823 | static void PublishRelayAnnouncement(CWebSocket& ws, CNostrKey& key) | ||
| 824 | { | ||
| 825 | if (strBtfAnnounceRelay.empty()) | ||
| 826 | return; | ||
| 827 | json tags = json::array({ json::array({ "d", BTF_RELAY_DTAG }) }); | ||
| 828 | json ev; | ||
| 829 | if (BuildSignedEvent(key, BTF_RELAY_KIND, tags, strBtfAnnounceRelay, ev)) | ||
| 830 | { | ||
| 831 | json pub = json::array({ "EVENT", ev }); | ||
| 832 | ws.SendText(pub.dump()); | ||
| 833 | } | ||
| 834 | } | ||
| 835 | |||
| 836 | static bool PublishPoolAnnouncementOnRelay(CWebSocket& ws, CNostrKey& key, | ||
| 837 | const BtfPoolAnnouncement& ann) | ||
| 838 | { | ||
| 839 | if (ann.btfAddress.empty() || ann.poolName.empty()) | ||
| 840 | return false; | ||
| 841 | |||
| 842 | json content = json::object(); | ||
| 843 | content["btf"] = ann.btfAddress; | ||
| 844 | content["name"] = ann.poolName; | ||
| 845 | if (!ann.dashboardUrl.empty()) | ||
| 846 | content["dashboard_url"] = ann.dashboardUrl; | ||
| 847 | content["fee_pct"] = ann.feePercent; | ||
| 848 | content["miners"] = ann.connectedMiners; | ||
| 849 | content["blocks"] = ann.blocksFound; | ||
| 850 | content["hashrate"] = ann.hashRate; | ||
| 851 | |||
| 852 | json tags = json::array({ | ||
| 853 | json::array({ "d", BTF_POOL_DTAG }), | ||
| 854 | json::array({ "t", BTF_POOL_DTAG }) | ||
| 855 | }); | ||
| 856 | json ev; | ||
| 857 | if (!BuildSignedEvent(key, BTF_POOL_KIND, tags, content.dump(), ev)) | ||
| 858 | return false; | ||
| 859 | json pub = json::array({ "EVENT", ev }); | ||
| 860 | return ws.SendText(pub.dump()); | ||
| 861 | } | ||
| 862 | |||
| 863 | bool BtfPublishPoolAnnouncement(const BtfPoolAnnouncement& ann) | ||
| 864 | { | ||
| 865 | if (!EnsureNostrKey()) | ||
| 866 | return false; | ||
| 867 | |||
| 868 | bool fPublished = false; | ||
| 869 | for (int i = 0; i < nNostrRelays && !fShutdown; i++) | ||
| 870 | { | ||
| 871 | try | ||
| 872 | { | ||
| 873 | CRelayLease lease(pszNostrRelays[i], 10); | ||
| 874 | if (!lease.Connected()) | ||
| 875 | continue; | ||
| 876 | if (PublishPoolAnnouncementOnRelay(lease.Ws(), g_nostrKey, ann)) | ||
| 877 | fPublished = lease.fOk = true; | ||
| 878 | } | ||
| 879 | CATCH_PRINT_EXCEPTION("BtfPublishPoolAnnouncement") | ||
| 880 | } | ||
| 881 | return fPublished; | ||
| 882 | } | ||
| 883 | |||
| 884 | bool BtfQueryPoolAnnouncement(const std::string& poolBtfAddr, BtfPoolAnnouncement& out) | ||
| 885 | { | ||
| 886 | unsigned char pk[32]; | ||
| 887 | if (!btf::ParseAddress(poolBtfAddr, pk)) | ||
| 888 | return false; | ||
| 889 | |||
| 890 | if (!EnsureNostrKey()) | ||
| 891 | return false; | ||
| 892 | |||
| 893 | int64 bestCreatedAt = 0; | ||
| 894 | bool fFound = false; | ||
| 895 | for (int i = 0; i < nNostrRelays && !fShutdown; i++) | ||
| 896 | { | ||
| 897 | try | ||
| 898 | { | ||
| 899 | CRelayLease lease(pszNostrRelays[i], 10); | ||
| 900 | if (!lease.Connected()) | ||
| 901 | continue; | ||
| 902 | CWebSocket& ws = lease.Ws(); | ||
| 903 | |||
| 904 | json filter = json::object(); | ||
| 905 | filter["authors"] = json::array({ HexEncode(pk, 32) }); | ||
| 906 | filter["kinds"] = json::array({ BTF_POOL_KIND }); | ||
| 907 | filter["#d"] = json::array({ BTF_POOL_DTAG }); | ||
| 908 | filter["limit"] = 1; | ||
| 909 | json req = json::array({ "REQ", "btf-pool-query", filter }); | ||
| 910 | if (!ws.SendText(req.dump())) | ||
| 911 | continue; | ||
| 912 | |||
| 913 | for (;;) | ||
| 914 | { | ||
| 915 | string msg; | ||
| 916 | if (!ws.RecvText(msg)) | ||
| 917 | break; | ||
| 918 | json j; | ||
| 919 | try { j = json::parse(msg); } catch (...) { continue; } | ||
| 920 | if (!j.is_array() || j.empty() || !j[0].is_string()) continue; | ||
| 921 | string type = j[0].get<string>(); | ||
| 922 | if (type == "EVENT" && j.size() >= 3) | ||
| 923 | { | ||
| 924 | const json& ev = j[2]; | ||
| 925 | if (!ev.contains("content") || !ev["content"].is_string()) | ||
| 926 | continue; | ||
| 927 | BtfPoolAnnouncement ann; | ||
| 928 | try | ||
| 929 | { | ||
| 930 | json content = json::parse(ev["content"].get<string>()); | ||
| 931 | if (content.contains("btf") && content["btf"].is_string()) | ||
| 932 | ann.btfAddress = content["btf"].get<string>(); | ||
| 933 | if (content.contains("name") && content["name"].is_string()) | ||
| 934 | ann.poolName = content["name"].get<string>(); | ||
| 935 | if (content.contains("dashboard_url") && content["dashboard_url"].is_string()) | ||
| 936 | ann.dashboardUrl = content["dashboard_url"].get<string>(); | ||
| 937 | ann.feePercent = content.value("fee_pct", content.value("fee", 0.0)); | ||
| 938 | ann.connectedMiners = content.value("miners", 0); | ||
| 939 | ann.blocksFound = content.value("blocks", 0); | ||
| 940 | ann.hashRate = content.value("hashrate", 0.0); | ||
| 941 | ann.createdAt = ev.value("created_at", (int64)0); | ||
| 942 | } | ||
| 943 | catch (...) | ||
| 944 | { | ||
| 945 | continue; | ||
| 946 | } | ||
| 947 | if (ann.btfAddress == poolBtfAddr && ann.createdAt >= bestCreatedAt) | ||
| 948 | { | ||
| 949 | bestCreatedAt = ann.createdAt; | ||
| 950 | out = ann; | ||
| 951 | fFound = true; | ||
| 952 | } | ||
| 953 | } | ||
| 954 | else if (type == "EOSE") | ||
| 955 | break; | ||
| 956 | } | ||
| 957 | lease.fOk = true; | ||
| 958 | } | ||
| 959 | CATCH_PRINT_EXCEPTION("BtfQueryPoolAnnouncement") | ||
| 960 | } | ||
| 961 | |||
| 962 | return fFound; | ||
| 963 | } | ||
| 964 | |||
| 965 | static void HandlePoolAnnouncement(const json& ev) | ||
| 966 | { | ||
| 967 | if (!ev.contains("content") || !ev["content"].is_string()) | ||
| 968 | return; | ||
| 969 | |||
| 970 | BtfPoolAnnouncement ann; | ||
| 971 | try | ||
| 972 | { | ||
| 973 | json content = json::parse(ev["content"].get<string>()); | ||
| 974 | if (content.contains("btf") && content["btf"].is_string()) | ||
| 975 | ann.btfAddress = content["btf"].get<string>(); | ||
| 976 | else if (content.contains("address") && content["address"].is_string()) | ||
| 977 | ann.btfAddress = content["address"].get<string>(); | ||
| 978 | if (content.contains("name") && content["name"].is_string()) | ||
| 979 | ann.poolName = content["name"].get<string>(); | ||
| 980 | else if (content.contains("pool_name") && content["pool_name"].is_string()) | ||
| 981 | ann.poolName = content["pool_name"].get<string>(); | ||
| 982 | if (content.contains("dashboard_url") && content["dashboard_url"].is_string()) | ||
| 983 | ann.dashboardUrl = content["dashboard_url"].get<string>(); | ||
| 984 | else if (content.contains("dashboard") && content["dashboard"].is_string()) | ||
| 985 | ann.dashboardUrl = content["dashboard"].get<string>(); | ||
| 986 | ann.feePercent = content.value("fee_pct", content.value("fee", 0.0)); | ||
| 987 | ann.connectedMiners = content.value("miners", 0); | ||
| 988 | ann.blocksFound = content.value("blocks", 0); | ||
| 989 | ann.hashRate = content.value("hashrate", 0.0); | ||
| 990 | ann.createdAt = ev.value("created_at", (int64)0); | ||
| 991 | } | ||
| 992 | catch (...) | ||
| 993 | { | ||
| 994 | return; | ||
| 995 | } | ||
| 996 | |||
| 997 | if (ann.btfAddress.empty() || ann.poolName.empty()) | ||
| 998 | return; | ||
| 999 | |||
| 1000 | CRITICAL_BLOCK(cs_discoveredPools) | ||
| 1001 | { | ||
| 1002 | map<string, BtfPoolAnnouncement>::iterator it = g_discoveredPools.find(ann.btfAddress); | ||
| 1003 | if (it == g_discoveredPools.end() || ann.createdAt >= it->second.createdAt) | ||
| 1004 | g_discoveredPools[ann.btfAddress] = ann; | ||
| 1005 | } | ||
| 1006 | } | ||
| 1007 | |||
| 1008 | // Resolve a `.btf` address via a relay: fetch the descriptor published by that | ||
| 1009 | // address's key and verify it self-certifies under the decoded pubkey. | ||
| 1010 | static bool ResolveDescriptor(CWebSocket& ws, void* ctx, const string& btfAddr, | ||
| 1011 | btf::Descriptor& out) | ||
| 1012 | { | ||
| 1013 | unsigned char pk[32]; | ||
| 1014 | if (!btf::ParseAddress(btfAddr, pk)) | ||
| 1015 | return false; | ||
| 1016 | json filter = json::object(); | ||
| 1017 | filter["authors"] = json::array({ HexEncode(pk, 32) }); | ||
| 1018 | filter["kinds"] = json::array({ BTF_DESC_KIND }); | ||
| 1019 | filter["limit"] = 1; | ||
| 1020 | json req = json::array({ "REQ", "btf-resolve", filter }); | ||
| 1021 | if (!ws.SendText(req.dump())) | ||
| 1022 | return false; | ||
| 1023 | for (;;) | ||
| 1024 | { | ||
| 1025 | string msg; | ||
| 1026 | if (!ws.RecvText(msg)) | ||
| 1027 | break; | ||
| 1028 | json j; | ||
| 1029 | try { j = json::parse(msg); } catch (...) { continue; } | ||
| 1030 | if (!j.is_array() || j.empty() || !j[0].is_string()) continue; | ||
| 1031 | string t = j[0].get<string>(); | ||
| 1032 | if (t == "EVENT" && j.size() >= 3) | ||
| 1033 | { | ||
| 1034 | const json& ev = j[2]; | ||
| 1035 | if (ev.contains("content") && | ||
| 1036 | btf::VerifyDescriptor(ctx, ev["content"].get<string>(), pk, out)) | ||
| 1037 | return true; | ||
| 1038 | } | ||
| 1039 | else if (t == "EOSE") | ||
| 1040 | break; | ||
| 1041 | } | ||
| 1042 | return false; | ||
| 1043 | } | ||
| 1044 | |||
| 1045 | // Resolve a `.btf` address into its rendezvous coordinates by querying the | ||
| 1046 | // public discovery relays for the owner's self-certified descriptor. Used by | ||
| 1047 | // ConnectNodeBtf in net.cpp. | ||
| 1048 | bool BtfResolve(const std::string& btfAddr, std::string& meetingHostPort, | ||
| 1049 | unsigned char enc_pub[32]) | ||
| 1050 | { | ||
| 1051 | if (!EnsureNostrKey()) | ||
| 1052 | return false; | ||
| 1053 | for (int i = 0; i < nNostrRelays && !fShutdown; i++) | ||
| 1054 | { | ||
| 1055 | try | ||
| 1056 | { | ||
| 1057 | CRelayLease lease(pszNostrRelays[i], 10); | ||
| 1058 | if (!lease.Connected()) | ||
| 1059 | continue; | ||
| 1060 | btf::Descriptor d; | ||
| 1061 | if (!ResolveDescriptor(lease.Ws(), g_nostrKey.ctx, btfAddr, d)) | ||
| 1062 | { | ||
| 1063 | lease.fOk = true; // exchange completed, just no match | ||
| 1064 | continue; | ||
| 1065 | } | ||
| 1066 | lease.fOk = true; | ||
| 1067 | if (d.meeting_node.empty() || d.meeting_node == "rendezvous-pending") | ||
| 1068 | continue; // descriptor predates the owner's relay config | ||
| 1069 | if (!HexDecode(d.enc, enc_pub, 32)) | ||
| 1070 | continue; | ||
| 1071 | meetingHostPort = d.meeting_node; | ||
| 1072 | return true; | ||
| 1073 | } | ||
| 1074 | CATCH_PRINT_EXCEPTION("BtfResolve") | ||
| 1075 | } | ||
| 1076 | return false; | ||
| 1077 | } | ||
| 1078 | |||
| 1079 | // Resolve MANY .btf addresses in one pass. A single relay connection carries | ||
| 1080 | // a REQ with every still-unresolved address's pubkey in "authors", instead of | ||
| 1081 | // opening one relay connection per address. This matters because callers like | ||
| 1082 | // ConnectDiscoveredBtfPeers dial many candidates in parallel; without | ||
| 1083 | // batching, each of those dial threads independently walks the same handful | ||
| 1084 | // of public relays on its own, so a pass of N candidates can fire up to | ||
| 1085 | // N * nNostrRelays near-simultaneous connections at the same few relays -- | ||
| 1086 | // enough to get rate-limited or have handshakes refused, which then looks | ||
| 1087 | // like "dead peers" even though the descriptors are perfectly resolvable. | ||
| 1088 | bool BtfResolveMany(const std::vector<std::string>& btfAddrs, | ||
| 1089 | std::map<std::string, BtfResolvedPeer>& out) | ||
| 1090 | { | ||
| 1091 | if (!EnsureNostrKey() || btfAddrs.empty()) | ||
| 1092 | return false; | ||
| 1093 | |||
| 1094 | map<string, string> hexToAddr; // pubkey hex -> .btf addr, for matching events back | ||
| 1095 | set<string> pending; | ||
| 1096 | for (const string& a : btfAddrs) | ||
| 1097 | { | ||
| 1098 | unsigned char pk[32]; | ||
| 1099 | if (!btf::ParseAddress(a, pk)) | ||
| 1100 | continue; | ||
| 1101 | hexToAddr[HexEncode(pk, 32)] = a; | ||
| 1102 | pending.insert(a); | ||
| 1103 | } | ||
| 1104 | if (pending.empty()) | ||
| 1105 | return false; | ||
| 1106 | |||
| 1107 | for (int i = 0; i < nNostrRelays && !fShutdown && !pending.empty(); i++) | ||
| 1108 | { | ||
| 1109 | try | ||
| 1110 | { | ||
| 1111 | CRelayLease lease(pszNostrRelays[i], 10); | ||
| 1112 | if (!lease.Connected()) | ||
| 1113 | continue; | ||
| 1114 | CWebSocket& ws = lease.Ws(); | ||
| 1115 | |||
| 1116 | json authors = json::array(); | ||
| 1117 | for (auto& kv : hexToAddr) | ||
| 1118 | if (pending.count(kv.second)) | ||
| 1119 | authors.push_back(kv.first); | ||
| 1120 | if (authors.empty()) | ||
| 1121 | { | ||
| 1122 | lease.fOk = true; | ||
| 1123 | break; | ||
| 1124 | } | ||
| 1125 | |||
| 1126 | json filter = json::object(); | ||
| 1127 | filter["authors"] = authors; | ||
| 1128 | filter["kinds"] = json::array({ BTF_DESC_KIND }); | ||
| 1129 | filter["limit"] = (int)authors.size(); | ||
| 1130 | json req = json::array({ "REQ", "btf-resolve-many", filter }); | ||
| 1131 | if (!ws.SendText(req.dump())) | ||
| 1132 | continue; | ||
| 1133 | |||
| 1134 | for (;;) | ||
| 1135 | { | ||
| 1136 | string msg; | ||
| 1137 | if (!ws.RecvText(msg)) | ||
| 1138 | break; | ||
| 1139 | json j; | ||
| 1140 | try { j = json::parse(msg); } catch (...) { continue; } | ||
| 1141 | if (!j.is_array() || j.empty() || !j[0].is_string()) continue; | ||
| 1142 | string t = j[0].get<string>(); | ||
| 1143 | if (t == "EVENT" && j.size() >= 3) | ||
| 1144 | { | ||
| 1145 | const json& ev = j[2]; | ||
| 1146 | if (!ev.contains("pubkey") || !ev.contains("content")) | ||
| 1147 | continue; | ||
| 1148 | string hexpk = ev["pubkey"].get<string>(); | ||
| 1149 | auto ai = hexToAddr.find(hexpk); | ||
| 1150 | if (ai == hexToAddr.end() || !pending.count(ai->second)) | ||
| 1151 | continue; // not one we're waiting on (or already resolved) | ||
| 1152 | |||
| 1153 | unsigned char pk[32]; | ||
| 1154 | if (!HexDecode(hexpk, pk, 32)) | ||
| 1155 | continue; | ||
| 1156 | btf::Descriptor d; | ||
| 1157 | if (!btf::VerifyDescriptor(g_nostrKey.ctx, ev["content"].get<string>(), pk, d)) | ||
| 1158 | continue; | ||
| 1159 | if (d.meeting_node.empty() || d.meeting_node == "rendezvous-pending") | ||
| 1160 | continue; | ||
| 1161 | BtfResolvedPeer rp; | ||
| 1162 | if (!HexDecode(d.enc, rp.enc_pub, 32)) | ||
| 1163 | continue; | ||
| 1164 | rp.meetingHostPort = d.meeting_node; | ||
| 1165 | out[ai->second] = rp; | ||
| 1166 | pending.erase(ai->second); | ||
| 1167 | } | ||
| 1168 | else if (t == "EOSE") | ||
| 1169 | break; | ||
| 1170 | } | ||
| 1171 | lease.fOk = true; | ||
| 1172 | } | ||
| 1173 | CATCH_PRINT_EXCEPTION("BtfResolveMany") | ||
| 1174 | } | ||
| 1175 | return !out.empty(); | ||
| 1176 | } | ||
| 1177 | |||
| 1178 | |||
| 1179 | // ---- per-relay backoff ----------------------------------------------------- | ||
| 1180 | // | ||
| 1181 | // A relay that is down costs the whole discovery loop the full connect timeout, | ||
| 1182 | // every round, forever. relay.nostr.band went down and produced 1092 identical | ||
| 1183 | // error lines on the seed while adding ten seconds to every pass -- and the | ||
| 1184 | // loop is what discovers peers, so a dead relay was slowing down the live ones. | ||
| 1185 | // | ||
| 1186 | // Back off per relay instead: 30s after the first failure, doubling to a | ||
| 1187 | // fifteen-minute ceiling, cleared the moment it answers again. This changes | ||
| 1188 | // only when an attempt happens, never whether discovery is correct. | ||
| 1189 | struct CRelayBackoff | ||
| 1190 | { | ||
| 1191 | int64 nNextTry; | ||
| 1192 | int nFails; | ||
| 1193 | CRelayBackoff() : nNextTry(0), nFails(0) {} | ||
| 1194 | }; | ||
| 1195 | static map<string, CRelayBackoff> mapRelayBackoff; | ||
| 1196 | static CCriticalSection cs_relayBackoff; | ||
| 1197 | |||
| 1198 | static const int64 RELAY_BACKOFF_BASE_SECS = 30; | ||
| 1199 | static const int64 RELAY_BACKOFF_MAX_SECS = 15 * 60; | ||
| 1200 | |||
| 1201 | static bool RelayDueForRetry(const string& relay) | ||
| 1202 | { | ||
| 1203 | CRITICAL_BLOCK(cs_relayBackoff) | ||
| 1204 | { | ||
| 1205 | map<string, CRelayBackoff>::iterator it = mapRelayBackoff.find(relay); | ||
| 1206 | if (it == mapRelayBackoff.end()) | ||
| 1207 | return true; | ||
| 1208 | return GetTime() >= it->second.nNextTry; | ||
| 1209 | } | ||
| 1210 | return true; | ||
| 1211 | } | ||
| 1212 | |||
| 1213 | static void RelayNoteFailure(const string& relay) | ||
| 1214 | { | ||
| 1215 | CRITICAL_BLOCK(cs_relayBackoff) | ||
| 1216 | { | ||
| 1217 | CRelayBackoff& b = mapRelayBackoff[relay]; | ||
| 1218 | b.nFails++; | ||
| 1219 | int64 nWait = RELAY_BACKOFF_BASE_SECS; | ||
| 1220 | for (int i = 1; i < b.nFails && nWait < RELAY_BACKOFF_MAX_SECS; i++) | ||
| 1221 | nWait *= 2; | ||
| 1222 | if (nWait > RELAY_BACKOFF_MAX_SECS) | ||
| 1223 | nWait = RELAY_BACKOFF_MAX_SECS; | ||
| 1224 | b.nNextTry = GetTime() + nWait; | ||
| 1225 | // Only on the way in, so a relay that stays down says this once per | ||
| 1226 | // backoff step rather than once per pass. | ||
| 1227 | LogPrint("nostr", "Nostr: %s unreachable (%d in a row), not retrying for %ds\n", | ||
| 1228 | relay.c_str(), b.nFails, (int)nWait); | ||
| 1229 | } | ||
| 1230 | } | ||
| 1231 | |||
| 1232 | static void RelayNoteSuccess(const string& relay) | ||
| 1233 | { | ||
| 1234 | CRITICAL_BLOCK(cs_relayBackoff) | ||
| 1235 | { | ||
| 1236 | map<string, CRelayBackoff>::iterator it = mapRelayBackoff.find(relay); | ||
| 1237 | if (it != mapRelayBackoff.end()) | ||
| 1238 | { | ||
| 1239 | if (it->second.nFails > 0) | ||
| 1240 | LogPrint("nostr", "Nostr: %s answering again after %d failure(s)\n", | ||
| 1241 | relay.c_str(), it->second.nFails); | ||
| 1242 | mapRelayBackoff.erase(it); | ||
| 1243 | } | ||
| 1244 | } | ||
| 1245 | } | ||
| 1246 | |||
| 1247 | // Connect to a relay: publish our .btf descriptor and collect relay data. | ||
| 1248 | static bool SeedFromRelay(CNostrKey& key, const string& relay) | ||
| 1249 | { | ||
| 1250 | CRelayLease lease(relay, 10); | ||
| 1251 | if (!lease.Connected()) | ||
| 1252 | return false; | ||
| 1253 | CWebSocket& ws = lease.Ws(); | ||
| 1254 | if (lease.fJustConnected) | ||
| 1255 | LogPrint("nostr", "Nostr: connected to %s\n", relay.c_str()); | ||
| 1256 | |||
| 1257 | // Publish our self-certifying .btf descriptor (rendezvous discovery) | ||
| 1258 | PublishDescriptor(ws, key); | ||
| 1259 | |||
| 1260 | // If we run a relay, announce it so others discover it automatically | ||
| 1261 | PublishRelayAnnouncement(ws, key); | ||
| 1262 | |||
| 1263 | // Discover other nodes' .btf descriptors so we can auto-connect anonymously | ||
| 1264 | // (no manual /connectbtf). Each node publishes one addressable descriptor | ||
| 1265 | // (kind 38501, d-tag btf-descriptor); we learn every peer's .btf address by | ||
| 1266 | // deriving it from the event author. Reachability is verified later, when | ||
| 1267 | // the rendezvous tunnel boxes to that key's static x25519 key. | ||
| 1268 | ws.SendText(json::array({ "CLOSE", "bf-sub" }).dump()); | ||
| 1269 | { | ||
| 1270 | json dfilter = json::object(); | ||
| 1271 | dfilter["kinds"] = json::array({ BTF_DESC_KIND }); | ||
| 1272 | dfilter["#d"] = json::array({ BTF_DESC_DTAG }); | ||
| 1273 | dfilter["limit"] = 200; | ||
| 1274 | json dreq = json::array({ "REQ", "btf-disc", dfilter }); | ||
| 1275 | int nPeers = 0; | ||
| 1276 | if (ws.SendText(dreq.dump())) | ||
| 1277 | { | ||
| 1278 | for (;;) | ||
| 1279 | { | ||
| 1280 | string msg; | ||
| 1281 | if (!ws.RecvText(msg)) | ||
| 1282 | break; | ||
| 1283 | json j; | ||
| 1284 | try { j = json::parse(msg); } catch (...) { continue; } | ||
| 1285 | if (!j.is_array() || j.empty() || !j[0].is_string()) continue; | ||
| 1286 | string type = j[0].get<string>(); | ||
| 1287 | if (type == "EVENT" && j.size() >= 3) | ||
| 1288 | { | ||
| 1289 | const json& ev = j[2]; | ||
| 1290 | if (!ev.contains("pubkey") || !ev.contains("kind")) continue; | ||
| 1291 | if (ev["kind"].get<int>() != BTF_DESC_KIND) continue; | ||
| 1292 | unsigned char pkb[32]; | ||
| 1293 | if (!HexDecode(ev["pubkey"].get<string>(), pkb, 32)) continue; | ||
| 1294 | if (memcmp(pkb, key.xonly, 32) == 0) continue; // not ourselves | ||
| 1295 | string peerAddr = btf::Address(pkb); | ||
| 1296 | int64 createdAt = ev.value("created_at", (int64)0); | ||
| 1297 | bool fNew = false; | ||
| 1298 | CRITICAL_BLOCK(cs_btfPeers) | ||
| 1299 | { | ||
| 1300 | auto it = g_btfPeers.find(peerAddr); | ||
| 1301 | if (it == g_btfPeers.end()) { g_btfPeers[peerAddr] = createdAt; fNew = true; } | ||
| 1302 | else if (createdAt > it->second) it->second = createdAt; // refresh | ||
| 1303 | } | ||
| 1304 | if (fNew && fDebug) | ||
| 1305 | LogPrint("nostr", "Nostr: discovered .btf peer %s\n", peerAddr.c_str()); | ||
| 1306 | if (++nPeers > 500) break; | ||
| 1307 | } | ||
| 1308 | else if (type == "EOSE") | ||
| 1309 | break; | ||
| 1310 | } | ||
| 1311 | } | ||
| 1312 | } | ||
| 1313 | |||
| 1314 | // Discover volunteer relays announced by other operators (kind 38502). Nodes | ||
| 1315 | // add these to their failover list automatically, so anyone can strengthen | ||
| 1316 | // the network without a maintainer editing the seed list. Bad/dead relays | ||
| 1317 | // just fail to pair and get skipped -- and a relay is trustless anyway (it | ||
| 1318 | // only forwards echan ciphertext and can't read or MITM the traffic). | ||
| 1319 | ws.SendText(json::array({ "CLOSE", "btf-disc" }).dump()); | ||
| 1320 | { | ||
| 1321 | json rfilter = json::object(); | ||
| 1322 | rfilter["kinds"] = json::array({ BTF_RELAY_KIND }); | ||
| 1323 | rfilter["#d"] = json::array({ BTF_RELAY_DTAG }); | ||
| 1324 | rfilter["limit"] = 200; | ||
| 1325 | json rreq = json::array({ "REQ", "btf-relays", rfilter }); | ||
| 1326 | if (ws.SendText(rreq.dump())) | ||
| 1327 | { | ||
| 1328 | for (;;) | ||
| 1329 | { | ||
| 1330 | string msg; | ||
| 1331 | if (!ws.RecvText(msg)) | ||
| 1332 | break; | ||
| 1333 | json j; | ||
| 1334 | try { j = json::parse(msg); } catch (...) { continue; } | ||
| 1335 | if (!j.is_array() || j.empty() || !j[0].is_string()) continue; | ||
| 1336 | string type = j[0].get<string>(); | ||
| 1337 | if (type == "EVENT" && j.size() >= 3) | ||
| 1338 | { | ||
| 1339 | const json& ev = j[2]; | ||
| 1340 | if (!ev.contains("content") || !ev.contains("kind")) continue; | ||
| 1341 | if (ev["kind"].get<int>() != BTF_RELAY_KIND) continue; | ||
| 1342 | string r = ev["content"].get<string>(); | ||
| 1343 | if (!LooksLikeRelay(r)) continue; | ||
| 1344 | bool fNew = false; | ||
| 1345 | CRITICAL_BLOCK(cs_discoveredRelays) | ||
| 1346 | if (g_discoveredRelays.size() < BTF_MAX_DISCOVERED_RELAYS) | ||
| 1347 | fNew = g_discoveredRelays.insert(r).second; | ||
| 1348 | if (fNew && fDebug) | ||
| 1349 | LogPrint("nostr", "Nostr: discovered relay %s\n", r.c_str()); | ||
| 1350 | } | ||
| 1351 | else if (type == "EOSE") | ||
| 1352 | break; | ||
| 1353 | } | ||
| 1354 | } | ||
| 1355 | } | ||
| 1356 | |||
| 1357 | // Discover live pool announcements. These are expiring status beacons, not | ||
| 1358 | // permanent directory entries, so the GUI can show only fresh pools. | ||
| 1359 | ws.SendText(json::array({ "CLOSE", "btf-pools" }).dump()); | ||
| 1360 | { | ||
| 1361 | json pfilter = json::object(); | ||
| 1362 | pfilter["kinds"] = json::array({ BTF_POOL_KIND }); | ||
| 1363 | pfilter["#d"] = json::array({ BTF_POOL_DTAG }); | ||
| 1364 | pfilter["limit"] = 200; | ||
| 1365 | json preq = json::array({ "REQ", "btf-pools", pfilter }); | ||
| 1366 | if (ws.SendText(preq.dump())) | ||
| 1367 | { | ||
| 1368 | for (;;) | ||
| 1369 | { | ||
| 1370 | string msg; | ||
| 1371 | if (!ws.RecvText(msg)) | ||
| 1372 | break; | ||
| 1373 | json j; | ||
| 1374 | try { j = json::parse(msg); } catch (...) { continue; } | ||
| 1375 | if (!j.is_array() || j.empty() || !j[0].is_string()) continue; | ||
| 1376 | string type = j[0].get<string>(); | ||
| 1377 | if (type == "EVENT" && j.size() >= 3) | ||
| 1378 | HandlePoolAnnouncement(j[2]); | ||
| 1379 | else if (type == "EOSE") | ||
| 1380 | break; | ||
| 1381 | } | ||
| 1382 | } | ||
| 1383 | } | ||
| 1384 | lease.fOk = true; | ||
| 1385 | return true; | ||
| 1386 | } | ||
| 1387 | |||
| 1388 | volatile bool gAnnounceNow = false; | ||
| 1389 | |||
| 1390 | void ThreadBtfPoolAnnouncer(void*) | ||
| 1391 | { | ||
| 1392 | LogPrint("pool", "ThreadBtfPoolAnnouncer started\n"); | ||
| 1393 | while (!fShutdown) | ||
| 1394 | { | ||
| 1395 | if (nMineMode == MINE_OPERATOR && gPoolRunning) | ||
| 1396 | { | ||
| 1397 | BtfPoolAnnouncement ann; | ||
| 1398 | ann.btfAddress = BtfLocalAddress(); | ||
| 1399 | ann.poolName = strPoolName; | ||
| 1400 | ann.dashboardUrl = strPoolDashboardUrl; | ||
| 1401 | ann.feePercent = dPoolFeePercent; | ||
| 1402 | uint64 roundShares = 0; | ||
| 1403 | double totalHashRate = 0.0; | ||
| 1404 | GetPoolOperatorStats(ann.connectedMiners, ann.blocksFound, roundShares, totalHashRate); | ||
| 1405 | ann.hashRate = totalHashRate; | ||
| 1406 | ann.createdAt = GetTime(); | ||
| 1407 | if (!ann.btfAddress.empty() && !ann.poolName.empty()) | ||
| 1408 | BtfPublishPoolAnnouncement(ann); | ||
| 1409 | } | ||
| 1410 | |||
| 1411 | for (int i = 0; i < 60 && !fShutdown && !gAnnounceNow; i++) | ||
| 1412 | Sleep(1000); | ||
| 1413 | gAnnounceNow = false; | ||
| 1414 | } | ||
| 1415 | } | ||
| 1416 | |||
| 1417 | |||
| 1418 | // Per-peer backoff: don't redial a .btf that just failed until this time. Keeps | ||
| 1419 | // dead/stale descriptors (e.g. abandoned test identities that linger on the | ||
| 1420 | // relays) from consuming the whole per-pass dial budget and starving the live | ||
| 1421 | // peers. Backoff grows on repeated failures, capped. | ||
| 1422 | // | ||
| 1423 | // g_btfPeerFails also doubles as the priority signal for dial ordering: a peer | ||
| 1424 | // we've connected to before (present in g_btfPeerEverConnected) is known-good | ||
| 1425 | // and goes first; a peer we've never tried goes next; a peer with a growing | ||
| 1426 | // fail count is known-bad and goes last, worst offenders furthest back. This | ||
| 1427 | // replaces the old behavior where the whole candidate list was shuffled right | ||
| 1428 | // after being sorted by freshness, which made the freshness sort a no-op and | ||
| 1429 | // gave dead peers equal footing with everyone else. | ||
| 1430 | static map<string, int64> g_btfPeerBackoff; // addr -> next-try time | ||
| 1431 | static map<string, int> g_btfPeerFails; // addr -> consecutive failures | ||
| 1432 | static set<string> g_btfPeerEverConnected; // addr -> has succeeded at least once | ||
| 1433 | static CCriticalSection cs_btfPeerState; | ||
| 1434 | |||
| 1435 | // Backoff cap for repeatedly-dead peers. The old 15-minute cap meant a | ||
| 1436 | // descriptor that has failed many times in a row cycles back into the dial | ||
| 1437 | // pool just as often as a peer we've never tried, wasting dial budget on | ||
| 1438 | // known-dead addresses every pass. Once a peer has failed enough times to | ||
| 1439 | // be "chronic", stretch the cap much further so it consumes budget far less | ||
| 1440 | // often while still getting retried eventually in case it comes back online. | ||
| 1441 | static const int64 BTF_BACKOFF_CAP_SECS = 15 * 60; // 15 min: initial ramp cap | ||
| 1442 | static const int64 BTF_BACKOFF_CAP_SECS_DEAD = 6 * 60 * 60; // 6 hr: cap for chronic failures | ||
| 1443 | static const int BTF_CHRONIC_FAIL_THRESHOLD = 6; // fails before "chronic" cap kicks in | ||
| 1444 | |||
| 1445 | // Dial the .btf peers discovered on the relays, up to a target connection count. | ||
| 1446 | // Connections are attempted in parallel so a single slow/dead relay doesn't | ||
| 1447 | // stall the entire pass. Each candidate gets its own thread; all threads start | ||
| 1448 | // at once and we wait for them to finish before returning. | ||
| 1449 | static void ConnectDiscoveredBtfPeers() | ||
| 1450 | { | ||
| 1451 | vector<pair<int64,string>> peersWithAge; | ||
| 1452 | int nTotal = 0; | ||
| 1453 | CRITICAL_BLOCK(cs_btfPeers) | ||
| 1454 | { | ||
| 1455 | nTotal = g_btfPeers.size(); | ||
| 1456 | for (auto& kv : g_btfPeers) | ||
| 1457 | peersWithAge.push_back({kv.second, kv.first}); | ||
| 1458 | } | ||
| 1459 | int64 cutoff = GetTime() - BTF_PEER_MAX_AGE; | ||
| 1460 | int nFresh = 0; | ||
| 1461 | vector<pair<int64,string>> fresh; // (createdAt, addr), newest first | ||
| 1462 | for (auto& p : peersWithAge) | ||
| 1463 | { | ||
| 1464 | if (p.first < cutoff) | ||
| 1465 | continue; | ||
| 1466 | fresh.push_back(p); | ||
| 1467 | nFresh++; | ||
| 1468 | } | ||
| 1469 | if (!fresh.empty()) | ||
| 1470 | LogPrint("nostr", "Nostr: %d fresh peers, %d stale (of %d total)\n", nFresh, nTotal-nFresh, nTotal); | ||
| 1471 | |||
| 1472 | // Drop stale peers so we stop retrying dead descriptors until they refresh. | ||
| 1473 | { | ||
| 1474 | CRITICAL_BLOCK(cs_btfPeers) | ||
| 1475 | { | ||
| 1476 | for (map<string, int64>::iterator it = g_btfPeers.begin(); it != g_btfPeers.end(); ) | ||
| 1477 | { | ||
| 1478 | if (it->second < cutoff) | ||
| 1479 | it = g_btfPeers.erase(it); | ||
| 1480 | else | ||
| 1481 | ++it; | ||
| 1482 | } | ||
| 1483 | } | ||
| 1484 | } | ||
| 1485 | |||
| 1486 | // Sort newest descriptor first -- recently-active nodes are much more | ||
| 1487 | // likely to be online right now. | ||
| 1488 | sort(fresh.begin(), fresh.end(), | ||
| 1489 | [](const pair<int64,string>& a, const pair<int64,string>& b){ return a.first > b.first; }); | ||
| 1490 | |||
| 1491 | // Reorder into tiers: known-good (succeeded before) first, untried next, | ||
| 1492 | // known-bad (has a fail count) last -- worst offenders furthest back. | ||
| 1493 | // Freshness order is preserved *within* each tier, so recency still | ||
| 1494 | // matters, but a peer we know is dead no longer gets equal footing with | ||
| 1495 | // a peer we've never tried or one that's worked before. | ||
| 1496 | vector<string> tierGood, tierUntried, tierBad; | ||
| 1497 | CRITICAL_BLOCK(cs_btfPeerState) | ||
| 1498 | { | ||
| 1499 | for (auto& p : fresh) | ||
| 1500 | { | ||
| 1501 | const string& addr = p.second; | ||
| 1502 | if (g_btfPeerEverConnected.count(addr)) | ||
| 1503 | tierGood.push_back(addr); | ||
| 1504 | else if (g_btfPeerFails.find(addr) == g_btfPeerFails.end()) | ||
| 1505 | tierUntried.push_back(addr); | ||
| 1506 | else | ||
| 1507 | tierBad.push_back(addr); | ||
| 1508 | } | ||
| 1509 | // Within known-bad, put fewer-fails peers ahead of chronic offenders. | ||
| 1510 | sort(tierBad.begin(), tierBad.end(), | ||
| 1511 | [](const string& a, const string& b){ return g_btfPeerFails[a] < g_btfPeerFails[b]; }); | ||
| 1512 | } | ||
| 1513 | |||
| 1514 | vector<string> peers; | ||
| 1515 | peers.reserve(fresh.size()); | ||
| 1516 | peers.insert(peers.end(), tierGood.begin(), tierGood.end()); | ||
| 1517 | peers.insert(peers.end(), tierUntried.begin(), tierUntried.end()); | ||
| 1518 | peers.insert(peers.end(), tierBad.begin(), tierBad.end()); | ||
| 1519 | |||
| 1520 | // Pick candidates for this pass (skip those in backoff, cap at budget). | ||
| 1521 | // | ||
| 1522 | // The tiered order above is deliberately biased toward peers we know | ||
| 1523 | // are good -- that's the right default, since dialing untested/bad | ||
| 1524 | // peers wastes attempts. But if every node on the network applies the | ||
| 1525 | // same bias, everyone converges on the same small set of "known good" | ||
| 1526 | // peers as neighbors: that correlates peer sets across the network, | ||
| 1527 | // which is exactly what standard random-graph connectivity results | ||
| 1528 | // assume you avoid. If that shared hub set has a bad day (e.g. one | ||
| 1529 | // relay outage), many nodes lose the same edges at once instead of | ||
| 1530 | // losing independent, uncorrelated edges. | ||
| 1531 | // | ||
| 1532 | // So: fill most of the pass from the reliable/tiered order, but reserve | ||
| 1533 | // a slice for peers picked at random from the *entire* fresh pool | ||
| 1534 | // (not just the front of the sorted list). Same pattern Bitcoin's own | ||
| 1535 | // peer manager and Kademlia-style DHTs use -- sticky reliable peers | ||
| 1536 | // plus a random sample, not one or the other. | ||
| 1537 | int64 now = GetTime(); | ||
| 1538 | int nBudget = (int)BTF_DIALS_PER_PASS; | ||
| 1539 | { | ||
| 1540 | int nRoom = (int)BTF_TARGET_CONN - (int)vNodes.size(); | ||
| 1541 | if (nRoom < nBudget) nBudget = nRoom; | ||
| 1542 | } | ||
| 1543 | if (nBudget < 0) nBudget = 0; | ||
| 1544 | int nRandomSlots = (nBudget > 0) ? max(1, nBudget * 3 / 10) : 0; | ||
| 1545 | int nSortedSlots = nBudget - nRandomSlots; | ||
| 1546 | |||
| 1547 | vector<string> candidates; | ||
| 1548 | set<string> setChosen; | ||
| 1549 | CRITICAL_BLOCK(cs_btfPeerState) | ||
| 1550 | { | ||
| 1551 | // Reliability slice: walk the tiered (good -> untried -> bad) order. | ||
| 1552 | for (const string& addr : peers) | ||
| 1553 | { | ||
| 1554 | if ((int)vNodes.size() >= (int)BTF_TARGET_CONN) break; | ||
| 1555 | if ((int)candidates.size() >= nSortedSlots) break; | ||
| 1556 | map<string, int64>::iterator bi = g_btfPeerBackoff.find(addr); | ||
| 1557 | if (bi != g_btfPeerBackoff.end() && now < bi->second) | ||
| 1558 | continue; | ||
| 1559 | candidates.push_back(addr); | ||
| 1560 | setChosen.insert(addr); | ||
| 1561 | } | ||
| 1562 | |||
| 1563 | // Diversity slice: random sample from the full fresh pool, so the | ||
| 1564 | // dial isn't limited to whatever the tiered sort put up front. | ||
| 1565 | vector<string> pool; | ||
| 1566 | for (auto& p : fresh) | ||
| 1567 | { | ||
| 1568 | const string& addr = p.second; | ||
| 1569 | if (setChosen.count(addr)) | ||
| 1570 | continue; | ||
| 1571 | map<string, int64>::iterator bi = g_btfPeerBackoff.find(addr); | ||
| 1572 | if (bi != g_btfPeerBackoff.end() && now < bi->second) | ||
| 1573 | continue; | ||
| 1574 | pool.push_back(addr); | ||
| 1575 | } | ||
| 1576 | int nWant = min(nRandomSlots, (int)pool.size()); | ||
| 1577 | for (int i = 0; i < nWant; i++) | ||
| 1578 | { | ||
| 1579 | if ((int)vNodes.size() + (int)candidates.size() >= (int)BTF_TARGET_CONN) break; | ||
| 1580 | if ((int)candidates.size() >= nBudget) break; | ||
| 1581 | size_t j = i + (size_t)GetRand((uint64)(pool.size() - i)); | ||
| 1582 | std::swap(pool[i], pool[j]); | ||
| 1583 | candidates.push_back(pool[i]); | ||
| 1584 | setChosen.insert(pool[i]); | ||
| 1585 | } | ||
| 1586 | } | ||
| 1587 | |||
| 1588 | if (candidates.empty()) return; | ||
| 1589 | |||
| 1590 | // Resolve every candidate's descriptor in one batched pass (a handful of | ||
| 1591 | // relay round-trips total, one REQ per relay covering all candidates) | ||
| 1592 | // instead of each dial thread independently walking the relay list on | ||
| 1593 | // its own. That per-thread approach meant a 12-candidate pass could fire | ||
| 1594 | // up to 12 * nNostrRelays near-simultaneous connections at the same | ||
| 1595 | // handful of public relays -- enough to trip rate limits or get | ||
| 1596 | // handshakes refused, which then looks exactly like "dead peers" even | ||
| 1597 | // though the descriptors were perfectly resolvable. | ||
| 1598 | map<string, BtfResolvedPeer> resolved; | ||
| 1599 | for (const string& addr : candidates) | ||
| 1600 | BtfChurnNoteResolveAttempt(); | ||
| 1601 | BtfResolveMany(candidates, resolved); | ||
| 1602 | |||
| 1603 | // Candidates whose descriptor didn't resolve this pass are recorded as a | ||
| 1604 | // failure right away (no point spawning a tunnel thread with nothing to | ||
| 1605 | // dial), same bookkeeping as a failed connect attempt. | ||
| 1606 | vector<string> toDial; | ||
| 1607 | CRITICAL_BLOCK(cs_btfPeerState) | ||
| 1608 | { | ||
| 1609 | for (const string& addr : candidates) | ||
| 1610 | { | ||
| 1611 | if (resolved.count(addr)) | ||
| 1612 | { | ||
| 1613 | BtfChurnNoteResolveResult(true); | ||
| 1614 | toDial.push_back(addr); | ||
| 1615 | continue; | ||
| 1616 | } | ||
| 1617 | BtfChurnNoteResolveResult(false); | ||
| 1618 | int n = ++g_btfPeerFails[addr]; | ||
| 1619 | int64 cap = (n >= BTF_CHRONIC_FAIL_THRESHOLD) ? BTF_BACKOFF_CAP_SECS_DEAD | ||
| 1620 | : BTF_BACKOFF_CAP_SECS; | ||
| 1621 | int64 secs = 30; | ||
| 1622 | for (int k = 1; k < n && secs < cap; k++) secs *= 2; | ||
| 1623 | if (secs > cap) secs = cap; | ||
| 1624 | g_btfPeerBackoff[addr] = GetTime() + secs; | ||
| 1625 | } | ||
| 1626 | } | ||
| 1627 | if (toDial.empty()) return; | ||
| 1628 | |||
| 1629 | // Launch one thread per resolved candidate -- all connect attempts run in | ||
| 1630 | // parallel, but purely against each peer's own rendezvous meeting node, | ||
| 1631 | // not the shared Nostr relays, so there's no thundering-herd effect here. | ||
| 1632 | // Each thread writes its result back via shared state guarded by cs_btfPeerState. | ||
| 1633 | struct DialCtx { string addr; BtfResolvedPeer rp; }; | ||
| 1634 | vector<DialCtx*> ctxs; | ||
| 1635 | for (const string& addr : toDial) | ||
| 1636 | { | ||
| 1637 | DialCtx* ctx = new DialCtx{addr, resolved[addr]}; | ||
| 1638 | ctxs.push_back(ctx); | ||
| 1639 | _beginthread([](void* arg) { | ||
| 1640 | DialCtx* ctx = (DialCtx*)arg; | ||
| 1641 | string addr = ctx->addr; | ||
| 1642 | string meeting = ctx->rp.meetingHostPort; | ||
| 1643 | unsigned char enc_pub[32]; | ||
| 1644 | memcpy(enc_pub, ctx->rp.enc_pub, 32); | ||
| 1645 | delete ctx; | ||
| 1646 | |||
| 1647 | CNode* pnode = ConnectNodeBtfResolved(addr, meeting, enc_pub); | ||
| 1648 | CRITICAL_BLOCK(cs_btfPeerState) | ||
| 1649 | { | ||
| 1650 | if (pnode) | ||
| 1651 | { | ||
| 1652 | if (!pnode->fNetworkNode) | ||
| 1653 | pnode->fNetworkNode = true; | ||
| 1654 | else | ||
| 1655 | pnode->Release(); | ||
| 1656 | g_btfPeerBackoff.erase(addr); | ||
| 1657 | g_btfPeerFails.erase(addr); | ||
| 1658 | g_btfPeerEverConnected.insert(addr); | ||
| 1659 | } | ||
| 1660 | else | ||
| 1661 | { | ||
| 1662 | int n = ++g_btfPeerFails[addr]; | ||
| 1663 | int64 cap = (n >= BTF_CHRONIC_FAIL_THRESHOLD) ? BTF_BACKOFF_CAP_SECS_DEAD | ||
| 1664 | : BTF_BACKOFF_CAP_SECS; | ||
| 1665 | int64 secs = 30; | ||
| 1666 | for (int k = 1; k < n && secs < cap; k++) secs *= 2; | ||
| 1667 | if (secs > cap) secs = cap; | ||
| 1668 | g_btfPeerBackoff[addr] = GetTime() + secs; | ||
| 1669 | } | ||
| 1670 | } | ||
| 1671 | }, 0, ctx); | ||
| 1672 | } | ||
| 1673 | |||
| 1674 | // Wait long enough for all parallel dials to finish (each has a 10s socket | ||
| 1675 | // timeout, so 12s gives them all time to resolve). | ||
| 1676 | for (int i = 0; i < 12 && !fShutdown; i++) | ||
| 1677 | Sleep(1000); | ||
| 1678 | } | ||
| 1679 | |||
| 1680 | |||
| 1681 | void ThreadNostrSeed(void* parg) | ||
| 1682 | { | ||
| 1683 | LogPrint("nostr", "ThreadNostrSeed started\n"); | ||
| 1684 | |||
| 1685 | if (!EnsureNostrKey()) | ||
| 1686 | { | ||
| 1687 | LogPrint("nostr", "Nostr: failed to initialize secp256k1 key\n"); | ||
| 1688 | return; | ||
| 1689 | } | ||
| 1690 | CNostrKey& key = g_nostrKey; | ||
| 1691 | LogPrint("nostr", "Nostr: node pubkey = %s\n", key.PubKeyHex().c_str()); | ||
| 1692 | LogPrint("nostr", "Nostr: node .btf address = %s\n", key.BtfAddress().c_str()); | ||
| 1693 | |||
| 1694 | // The self-test below publishes a real descriptor, and PublishDescriptor now | ||
| 1695 | // refuses to publish before ThreadBtfAccept has registered a rendezvous. Give | ||
| 1696 | // registration a bounded head start so the test measures the publish/resolve | ||
| 1697 | // chain instead of losing the race against it. | ||
| 1698 | for (int i = 0; i < 60 && !fShutdown && BtfActiveRelay().empty(); i++) | ||
| 1699 | Sleep(1000); | ||
| 1700 | |||
| 1701 | // One-time self-test of the rendezvous: publish our descriptor to a relay, | ||
| 1702 | // then resolve our OWN .btf address back and verify it self-certifies. Proves | ||
| 1703 | // the .btf publish/resolve/verify chain works against real public relays. | ||
| 1704 | for (int i = 0; i < nNostrRelays && !fShutdown; i++) | ||
| 1705 | { | ||
| 1706 | try | ||
| 1707 | { | ||
| 1708 | CRelayLease lease(pszNostrRelays[i], 10); | ||
| 1709 | if (!lease.Connected()) continue; | ||
| 1710 | CWebSocket& ws = lease.Ws(); | ||
| 1711 | PublishDescriptor(ws, key); | ||
| 1712 | Sleep(700); // let the relay store the replaceable event | ||
| 1713 | btf::Descriptor d; | ||
| 1714 | if (ResolveDescriptor(ws, key.ctx, key.BtfAddress(), d)) | ||
| 1715 | { | ||
| 1716 | LogPrint("nostr", "Nostr: .btf self-resolve OK via %s (meeting_node=%s, enc=%s...)\n", | ||
| 1717 | pszNostrRelays[i], d.meeting_node.c_str(), d.enc.substr(0, 16).c_str()); | ||
| 1718 | lease.fOk = true; | ||
| 1719 | break; | ||
| 1720 | } | ||
| 1721 | lease.fOk = true; | ||
| 1722 | } | ||
| 1723 | CATCH_PRINT_EXCEPTION("btf self-test") | ||
| 1724 | } | ||
| 1725 | |||
| 1726 | // OpenSSL is already initialized by the rest of the app; TLS_client_method is enough. | ||
| 1727 | loop | ||
| 1728 | { | ||
| 1729 | for (int i = 0; i < nNostrRelays; i++) | ||
| 1730 | { | ||
| 1731 | if (fShutdown) return; | ||
| 1732 | if (!RelayDueForRetry(pszNostrRelays[i])) | ||
| 1733 | continue; | ||
| 1734 | try | ||
| 1735 | { | ||
| 1736 | if (SeedFromRelay(key, pszNostrRelays[i])) | ||
| 1737 | RelayNoteSuccess(pszNostrRelays[i]); | ||
| 1738 | else | ||
| 1739 | RelayNoteFailure(pszNostrRelays[i]); | ||
| 1740 | } | ||
| 1741 | CATCH_PRINT_EXCEPTION("SeedFromRelay") | ||
| 1742 | } | ||
| 1743 | |||
| 1744 | // Auto-connect to discovered .btf peers over the rendezvous relay -- the | ||
| 1745 | // anonymous, CGNAT-friendly replacement for the old IP-based connect. No | ||
| 1746 | // manual /connectbtf needed: nodes find each other purely via Nostr. | ||
| 1747 | try | ||
| 1748 | { | ||
| 1749 | ConnectDiscoveredBtfPeers(); | ||
| 1750 | } | ||
| 1751 | CATCH_PRINT_EXCEPTION("ConnectDiscoveredBtfPeers") | ||
| 1752 | |||
| 1753 | if (nMineMode == MINE_OPERATOR && gPoolRunning) | ||
| 1754 | { | ||
| 1755 | BtfPoolAnnouncement ann; | ||
| 1756 | ann.btfAddress = BtfLocalAddress(); | ||
| 1757 | ann.poolName = strPoolName; | ||
| 1758 | ann.dashboardUrl = strPoolDashboardUrl; | ||
| 1759 | ann.feePercent = dPoolFeePercent; | ||
| 1760 | uint64 roundShares = 0; | ||
| 1761 | double totalHashRate = 0.0; | ||
| 1762 | GetPoolOperatorStats(ann.connectedMiners, ann.blocksFound, roundShares, totalHashRate); | ||
| 1763 | ann.hashRate = totalHashRate; | ||
| 1764 | ann.createdAt = GetTime(); | ||
| 1765 | if (!ann.btfAddress.empty() && !ann.poolName.empty()) | ||
| 1766 | BtfPublishPoolAnnouncement(ann); | ||
| 1767 | } | ||
| 1768 | |||
| 1769 | // Re-discover periodically. Sleep less aggressively when | ||
| 1770 | // we have no connections yet so we find peers faster on startup. | ||
| 1771 | int nSleepSecs = vNodes.empty() ? 20 : 60; | ||
| 1772 | for (int i = 0; i < nSleepSecs && !fShutdown; i++) | ||
| 1773 | Sleep(1000); | ||
| 1774 | } | ||
| 1775 | } | ||