From 6ca1a1d6799474f8ca977b93ab630af7ace879c2 Mon Sep 17 00:00:00 2001 From: Victorio Nascimento <155357371+Vic-Nas@users.noreply.github.com> Date: Mon, 27 Jul 2026 16:45:28 -0400 Subject: Add Linux GUI installer via Wine (#3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Add Linux GUI installer via Wine * Fix launcher creation and add BTF_RELAY env var support * Pool server, Linux GUI, unified binary, README update * Root Makefile: make linux / make windows, cleaner README * Remove headless.cpp * Fix XPM color count mismatch causing toolbar image errors on Linux * XPM icons: same code on Windows and Linux, no ifdefs around image loading * Fix GTK icon crash and Linux printf format * btfrv: 10s socket timeout on relay connects * Faster peer discovery: more dials per pass, shorter backoff and sleep * Parallel btf peer connections * Fix GetMyExternalIP - was disabled, now uses modern IP echo services * Fix tunnel disconnect on idle and restore external IP detection * Filter stale Nostr descriptors * Sort peers by freshness, fresh first * Pool: add .btf Stratum listener — miners connect via rendezvous, no port forwarding needed * btf pool: self-replacing listeners, one per relay, no queuing between miners * Mining modes: solo/operator/participant — pool server on-demand, participant Stratum client * Status bar: show external IP and .btf address * README: clean rewrite, concept-first, minimal * make linux produces AppImage * make linux produces AppImage * Pool dashboard: connect instructions, tabs, share bars * Perf fixes: GetMyExternalIP threaded, btf listener counted * Perf fixes: GetMyExternalIP threaded, btf listener counted * Fix pool payouts: defer until coinbase maturity * Pool: clean mode switching, IP warning * Pool: clean mode switching * Fix gPoolRunning: remove static * AppImage: use real icon from bitcoin.ico * Fix exit crash; Mining menu * Fix exit crash; Mining menu * Fix exit crash; Mining menu * Fix exit crash; Mining menu * Fix exit crash; Mining menu * Windows build: use mingw32-make * Windows build: simpler make invocation * Add deps-windows target * Windows build fixes * Windows build fixes * Windows makefile: fix obj dir * Windows: output to repo root * Windows: bundle DLLs into zip * Replace wxWidgets with ImGui+GLFW: same build on Linux and Windows * Fix %I64d that slipped back through rebase (main.h, net.h) * MinGW: add _mkdir shim for non-MSVC Windows; fix %I64d * Fix gPoolRunning extern outside ifndef _WIN32 * Remove -lboost_system: boost is header-only in this codebase * Status bar: copy buttons for IP and .btf address * Windows: add app icon via windres, strip wx stuff from rc file * Clean GUI: single Options menu item, bigger font, better spacing, cleaner options dialog; update README * GUI: embed Roboto font at 16px for clear readability on all platforms * Windows: windres depends on obj dir target * Fix balance hidden off-screen: put on own line below address bar * Windows: inline mkdir in every rule, remove dir target that confused make * Windows: move all target to top so it's the default rule * Fix pool server not starting: always start when operator selected, re-sync dialog state on open * Write debug.log to data directory (AppData/Roaming/Bitflash on Windows) * Participant miner: use wallet address as username so payouts go to wallet * Add .gitattributes to lock LF line endings, prevent CRLF drift * Refactor pool mining to btf-only transport * Persist pool settings and startup wiring * Prune stale peers and expose pool queries * Fix build regressions and add CLI mode/arg parity * Fix rpc miner stats declaration order * Centralize pool runtime extern declarations * Separate operator/participant dashboards and restore peer stats * Add live telemetry strip and clearer per-mode runtime metrics * Reduce mining dashboard clutter and remove manual pool entry * Count only authorized pool miners * Seed initial pool job on startup * Use a real share target for pool work * Switch pool accounting to round-based shares * Fix nostr pool announcer stats call sites * Fix pool stats definition order * Improve pool worker status display * Add mutex include for participant status * Replace ImClamp in dashboard layout * Clarify participant work status * Log participant status transitions * Reduce peer discovery log noise * Add participant status badge * Track participant auth acknowledgements * Log and validate pool job delivery --------- Co-authored-by: Bitflash Pool Dev --- src/main_gui.cpp | 169 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 169 insertions(+) create mode 100644 src/main_gui.cpp (limited to 'src/main_gui.cpp') diff --git a/src/main_gui.cpp b/src/main_gui.cpp new file mode 100644 index 0000000..b7377da --- /dev/null +++ b/src/main_gui.cpp @@ -0,0 +1,169 @@ +// Bitflash entry point — starts node threads then runs GUI (or headless). + +#include "headers_core.h" +#ifndef _WIN32 +#include +#endif + +int RunGUI(int argc, char* argv[]); + +// Global definitions (were in ui.cpp, now here) +map mapAddressBook; +bool gPoolServerRunning = false; + +static bool arg(int argc, char* argv[], const char* key) +{ + for (int i=1;i Date: Tue, 28 Jul 2026 12:55:04 -0300 Subject: Merge PR #6 (large refactor), preserving main's fixes (#9) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Add Linux GUI installer via Wine * Fix launcher creation and add BTF_RELAY env var support * Pool server, Linux GUI, unified binary, README update * Root Makefile: make linux / make windows, cleaner README * Remove headless.cpp * Fix XPM color count mismatch causing toolbar image errors on Linux * XPM icons: same code on Windows and Linux, no ifdefs around image loading * Fix GTK icon crash and Linux printf format * btfrv: 10s socket timeout on relay connects * Faster peer discovery: more dials per pass, shorter backoff and sleep * Parallel btf peer connections * Fix GetMyExternalIP - was disabled, now uses modern IP echo services * Fix tunnel disconnect on idle and restore external IP detection * Filter stale Nostr descriptors * Sort peers by freshness, fresh first * Pool: add .btf Stratum listener — miners connect via rendezvous, no port forwarding needed * btf pool: self-replacing listeners, one per relay, no queuing between miners * Mining modes: solo/operator/participant — pool server on-demand, participant Stratum client * Status bar: show external IP and .btf address * README: clean rewrite, concept-first, minimal * make linux produces AppImage * make linux produces AppImage * Pool dashboard: connect instructions, tabs, share bars * Perf fixes: GetMyExternalIP threaded, btf listener counted * Perf fixes: GetMyExternalIP threaded, btf listener counted * Fix pool payouts: defer until coinbase maturity * Pool: clean mode switching, IP warning * Pool: clean mode switching * Fix gPoolRunning: remove static * AppImage: use real icon from bitcoin.ico * Fix exit crash; Mining menu * Fix exit crash; Mining menu * Fix exit crash; Mining menu * Fix exit crash; Mining menu * Fix exit crash; Mining menu * Windows build: use mingw32-make * Windows build: simpler make invocation * Add deps-windows target * Windows build fixes * Windows build fixes * Windows makefile: fix obj dir * Windows: output to repo root * Windows: bundle DLLs into zip * Replace wxWidgets with ImGui+GLFW: same build on Linux and Windows * Fix %I64d that slipped back through rebase (main.h, net.h) * MinGW: add _mkdir shim for non-MSVC Windows; fix %I64d * Fix gPoolRunning extern outside ifndef _WIN32 * Remove -lboost_system: boost is header-only in this codebase * Status bar: copy buttons for IP and .btf address * Windows: add app icon via windres, strip wx stuff from rc file * Clean GUI: single Options menu item, bigger font, better spacing, cleaner options dialog; update README * GUI: embed Roboto font at 16px for clear readability on all platforms * Windows: windres depends on obj dir target * Fix balance hidden off-screen: put on own line below address bar * Windows: inline mkdir in every rule, remove dir target that confused make * Windows: move all target to top so it's the default rule * Fix pool server not starting: always start when operator selected, re-sync dialog state on open * Write debug.log to data directory (AppData/Roaming/Bitflash on Windows) * Participant miner: use wallet address as username so payouts go to wallet * Add .gitattributes to lock LF line endings, prevent CRLF drift * Refactor pool mining to btf-only transport * Persist pool settings and startup wiring * Prune stale peers and expose pool queries * Fix build regressions and add CLI mode/arg parity * Fix rpc miner stats declaration order * Centralize pool runtime extern declarations * Separate operator/participant dashboards and restore peer stats * Add live telemetry strip and clearer per-mode runtime metrics * Reduce mining dashboard clutter and remove manual pool entry * Count only authorized pool miners * Seed initial pool job on startup * Use a real share target for pool work * Switch pool accounting to round-based shares * Fix nostr pool announcer stats call sites * Fix pool stats definition order * Improve pool worker status display * Add mutex include for participant status * Replace ImClamp in dashboard layout * Clarify participant work status * Log participant status transitions * Reduce peer discovery log noise * Add participant status badge * Track participant auth acknowledgements * Log and validate pool job delivery * Stop persisting mining mode/pool settings across restarts LoadWallet() runs after CLI flags are parsed in main_gui.cpp, and was restoring nMineMode/strParticipantPool/strPoolName/strPoolDashboardUrl/ dPoolFeePercent/fGenerateBitcoins from wallet.dat every time, silently overwriting whatever was just set on the command line or in the current Options session -- no error, no log line. This is why pool name/address could show correctly in the UI but stay stale in the logs (UI held the in-memory value just set; logs eventually reflected whatever LoadWallet clobbered it back to), and why mode changed behavior across restarts. Mode/pool config is now decided fresh every launch: CLI flags if given, otherwise the compiled-in default. Nothing is written back to wallet.dat for these settings anymore, so there's nothing left to go stale or fight over load order. * Add explicit Relay mode as the new startup default 4th mining mode: node runs/syncs, never mines, no pool server -- an explicit option instead of an implicit side-effect of 'Solo + never click Start Mining'. Now the compiled-in default (was Solo). - main.h: MINE_RELAY = 0, renumbered other modes (nothing compares nMineMode against raw integers elsewhere, verified). - main.cpp: default nMineMode = MINE_RELAY; BitcoinMiner() early-returns for Relay, mirroring the existing Participant-mode guard. - gui.cpp: added Relay radio button, status label, and telemetry-strip text. Start/Stop Mining button is hidden for Relay (meaningless -- it never mines by definition); OK forces fGenerateBitcoins off if you switch into Relay while previously mining. * Announce pool changes immediately instead of waiting up to 60s ThreadBtfPoolAnnouncer re-read strPoolName/dPoolFeePercent/etc live, but only once per iteration of its 60s sleep loop, so a pool name/fee change in Options could take up to a minute to reach other nodes. - nostr.h/.cpp: add gAnnounceNow flag; the announcer's sleep loop now wakes early and publishes immediately when it's set, then clears it. - gui.cpp: Options OK handler sets gAnnounceNow when operator pool settings actually changed (name/dashboard/fee/mode), so a saved change is announced right away instead of on the next tick. * Give each pool miner a distinct extranonce2 so jobs no longer overlap mining.notify previously broadcast one identical 80-byte header (baked from a single server-side extraNonce) to every connected miner, so any two miners on the same job searched the exact same nNonce space instead of disjoint ranges. Each Miner connection is now assigned a unique extranonce2 on connect. BuildMinerBlock() takes the shared job template and appends that miner's extranonce2 to the coinbase scriptSig, recomputing the merkle root, so every miner gets its own header variant of the same job. mining.submit validates and, on a full block, assembles the block using that same miner-specific variant (via m->extranonce2), not the shared template. No client-side protocol change needed: main.cpp's participant miner already just takes whatever header bytes mining.notify hands it and submits (jobId, nonce) back, so it works unmodified against per-miner headers. Still simplified vs standard Stratum (no coinb1/coinb2/merkle-branch wire split, no mining.extranonce.subscribe reply payload) since this protocol was already a custom simplification pre-existing this fix. Syntax-checked with g++ -fsyntax-only (full link needs secp256k1/ RandomX/GLFW not present in this sandbox). * Fix debug.log never being written on the real Windows build printf-to-debug.log routing was gated on: #if defined(_WINDOWS) || !defined(_WIN32) _WINDOWS is only ever defined by the legacy, unused MSVC makefile.vc. The actual Windows build (makefile.mingw) never defines it, and _WIN32 IS defined there (mingw builtin), so on real Windows this condition was always false || false = false. printf() fell through to plain libc printf (stdout only), so debug.log was never created, and output was only visible if the process happened to be run attached to a console. OutputDebugStringF() was already platform-safe internally (always writes debug.log; only echoes to console when not on Windows), so the outer condition was pure dead weight. Made the routing unconditional. * Reduce base UI font size one more step (16px -> 14px) * Remove Start/Stop Mining button; mining state follows selected mode The button let a user configure a pool then click a separate toggle, creating states where the button's label/action didn't match what the selected mode actually needed. OK now starts/stops the miner thread itself based on nMineMode: Relay clears fGenerateBitcoins, the other three modes set it and spawn ThreadBitcoinMiner if it isn't already running (guarded by vfThreadRunning[3]). * Fix participant miner stuck waiting for notify after buffered multi-line recv Root cause of the operator/participant pairing successfully but no work ever flowing: PoolParticipantMiner()'s receive loop only pulled one line out of the socket per select() wakeup. The server sends the authorize reply, mining.set_difficulty, and mining.notify back-to-back on the same connection, and these often land in a single TCP segment — one recv() inside StratumRecvLine() buffers all three lines into 'buf', but only the first was extracted and processed. The remaining lines (frequently mining.notify itself) sat in 'buf' indefinitely, because select() only reports readability of the OS socket, not whether our own buffer still holds unprocessed complete lines — and the server had nothing further to send once the initial burst was over, so select() never fired again. Fix: drain any complete line already sitting in 'buf' first; only fall back to select()+recv() when 'buf' has no complete line left. * Fix mining.submit rejecting every share due to wrong param-count check Server required params.size() >= 4 for mining.submit, but the client only ever sends 3: [username, jobId, nonceHex] (indices 0-2, matching what the handler actually reads: p[1]=jobId, p[2]=nonceHex). Every submission was bounced with 'bad params' before its nonce was ever checked against the job, producing an endless share-sent/share-rejected loop with no other visible cause. Likely a leftover from the standard 5-field Stratum mining.submit shape, which this simplified 3-field protocol never matched. Threshold corrected to size() >= 3. * Fix mining.submit rejecting legitimate shares that weren't also winning blocks Pool share difficulty is intentionally ~256x easier than full network difficulty (MakeShareTarget() shifts the block target left 8 bits), so miners are expected to submit many shares per actual block found. But the handler replied to the miner based on ProcessBlock()/CheckBlock() success — validation against the much harder network target — rather than on whether the share met pool difficulty. Every legitimate share that wasn't also a winning block (the vast majority, by design) failed CheckBlock()'s nBits check and got reported to the miner as rejected. Now: reply(true) as soon as a submission clears pool-share difficulty. Only if it ALSO clears full network difficulty (job.target, not job.shareTarget) does it get submitted as a block — with no second reply sent, since the share was already acknowledged. * Fix dead-peer dial priority: freshness sort was undone by full shuffle ConnectDiscoveredBtfPeers sorted candidates newest-first, then immediately shuffled the whole list, making the freshness sort a no-op — every pass was fully random. Meanwhile g_btfPeerFails was tracked but never used to prioritize, so a peer that had failed 20 times in a row got equal footing with an untried or previously-successful peer; the only effect was backoff, capped at 15 minutes, so dead descriptors cycled back into the dial pool constantly. - Track successful connections (g_btfPeerEverConnected) and use it, plus fail history, to order candidates in tiers: known-good, then untried, then known-bad (worst offenders last). Freshness order is preserved within each tier instead of being discarded. - Raise the backoff cap for chronic failures (6+ in a row) from 15 minutes to 6 hours, so descriptors that are clearly dead stop consuming dial budget every pass while still getting retried eventually. * Batch-resolve .btf descriptors to stop hammering public relays during dial passes Root cause of 'stuck at 1 peer' despite good peer discovery: each parallel dial thread in ConnectDiscoveredBtfPeers called ConnectNodeBtf, which itself walked all configured relays (relay.damus.io, nos.lol, relay.nostr.band, relay.snort.social) to resolve that one peer's descriptor. With up to BTF_DIALS_PER_PASS (12) threads dialing at once, a single pass could fire up to 12 * nNostrRelays near-simultaneous fresh connections at the same handful of public relays -- easily enough to trip rate limits or get handshakes refused (exactly what the logs showed: 'handshake refused by relay.damus.io', repeated connect failures to relay.nostr.band / relay.snort.social). Peers that were genuinely reachable looked dead simply because their descriptor couldn't be fetched under that load, and then got penalized as known-bad by the dial-priority tiering from the previous fix. - Add BtfResolveMany: resolves every still-unresolved candidate's descriptor in a single Nostr REQ per relay (multiple authors in one filter), instead of one relay connection per candidate. A 12-candidate pass now costs at most a few relay round-trips total instead of dozens. - Add ConnectNodeBtfResolved / ConnectNodeBtfTail (net.cpp): given an already-resolved descriptor, dial straight to the peer's rendezvous node with zero further relay contact. ConnectDiscoveredBtfPeers now batch- resolves all candidates up front, then fans out tunnel-only connect threads in parallel -- no thundering herd against the relays. - Candidates that fail to resolve are recorded as a failure immediately (same backoff/fail-count bookkeeping as before) instead of spawning a thread with nothing to dial. * Stop reconnecting to relays constantly: pool one persistent connection per relay Real root cause of the ongoing 'handshake refused' / 'connect failed' spam and peer count stuck at 0-1: every relay-touching operation (startup self- test, per-pass seeding, batch descriptor resolve, pool announce, pool query) opened its own brand-new CWebSocket every single time it ran. Two independent threads (ThreadNostrSeed and ThreadBtfPoolAnnouncer) each reconnect from scratch on their own ~60s cycle, so a node could fire well over a dozen fresh TCP+TLS+WS handshakes at the same 4 public relays inside any given minute. Public relays -- relay.damus.io especially -- rate-limit new connections per source IP fairly aggressively, so this node was effectively DoS-ing itself: peers were genuinely reachable, but their descriptors couldn't be fetched because the relay had already started refusing this node's own repeated reconnects. - Add a small per-relay connection pool (g_relayPool) and an RAII CRelayLease that reuses a relay's live socket across calls instead of reconnecting every time, only reconnecting when a socket actually drops or was never established. - Route every relay call site through it: the startup self-test, the self-test loop, SeedFromRelay, BtfResolve, BtfResolveMany, BtfPublishPoolAnnouncement, BtfQueryPoolAnnouncement. - Only log 'connected to ' on an actual fresh (re)connect, not on every pass that's just reusing an already-live socket, so the log reflects what's really happening instead of implying constant churn. This cuts fresh relay connections from several per minute down to essentially one per relay for the life of the process (until a relay actually drops the connection), which should make the rate-limit-driven refusals go away and let descriptor resolution -- and therefore dialing -- actually succeed at the rate the peer list implies it should. * Fix wallet crash on rejected send; remove legacy IP-based peer discovery - SendMoney() no longer throws when a committed tx is rejected by AcceptTransaction() (e.g. raced input already spent). It now unwinds the wallet state via RollbackTransactionSpent() and returns false, which the GUI already handles as 'Transaction failed.' - Removed the dead IP-based peer discovery/connect path now that discovery is entirely Nostr/.btf: CAddrDB/addr.dat, mapAddresses, AddAddress, ConnectNode(CAddress), FindNode(CAddress), ConnectSocket (SOCKS4 proxy helper), addrProxy, ThreadOpenConnections/2 class-C dialer and its thread spawn, the addr/getaddr P2P handlers, and the dead vAddrToSend/setAddrKnown fields on CNode. - Deleted irc.cpp/irc.h (already-disabled legacy seed code) and scrubbed references from headers_core.h and all four makefiles. * Raise peer connection target and add dial diversity; fix IPv4-only relay connect - BTF_TARGET_CONN: 8 -> 24. With ~100 known .btf peers, average degree needs only to exceed ln(N)~=4.6 for the network to stay connected with overwhelming probability (Erdos-Renyi); 24 is a healthy margin. - ConnectDiscoveredBtfPeers now splits each pass's dial budget ~70/30 between the existing reliability-tiered order and a random sample from the full fresh peer pool. Pure reliability bias means every node converges on the same 'known good' hub set, which correlates peer sets across the network -- a relay outage then drops the same edges everywhere at once instead of independent ones. The random slice keeps a floor of uncorrelated edges. - CWebSocket::Connect (relay websocket) now resolves with AF_UNSPEC and tries every returned address (IPv6 or IPv4) in turn instead of hard-failing on AF_INET-only resolution, which was one plausible cause of the relay.nostr.band:443 connect failures (CDN-fronted relays increasingly rate-limit/filter the v4 path). * Remove accidentally committed build binary; ignore src/bitflash * Fix pool participants getting stuck after the first job JobWatcherFn only rebuilt/broadcast a new Stratum job when a new block arrived on this node or no job existed yet. Once a job was built after startup, gHaveJob stayed true forever unless *this* node found the block, so with no new block (slow interval, or this node just not seeing one) every connected miner sat hashing the exact same job -- stale timestamp, stale mempool snapshot -- indefinitely. From the miner's side that's indistinguishable from being stuck: it worked at connect (gets the one-time job sent on authorize) and then nothing ever arrives again. Added a 60s periodic refresh so the job (and therefore mining.notify) gets rebroadcast on a timer too, same as a normal stratum pool, not only on new-block/no-job events. * Diversify Nostr discovery relay list Went from 4 relays to 7, and reduced Cloudflare concentration: previously 2 of 4 (damus.io, snort.social) sat behind the same CDN, so a Cloudflare- side issue could take out half the list even though they're nominally independent relays. Kept damus.io (most established) as the one CF-fronted entry and filled the rest with different hosts (nos.lol, nostr.band on Hetzner, nostr21.com, offchain.pub, nostr.mom, primal.net) so peer discovery doesn't have a single shared upstream as a point of failure. 7 relays is within the commonly recommended 5-10 range for redundancy. * Fix data race in RebuildJob: chain-state reads weren't holding cs_main pindexBest, nBestHeight, GetNextWorkRequired() and GetMedianTimePast() were all read without cs_main. A block being accepted concurrently by the P2P thread could interleave with this: pindexPrev captured from the old best block, but nBestHeight already bumped to the new one by the time job.height is computed, or pindexBest reassigned mid-read. That mismatch would make QueuePayouts() schedule a payout for the wrong maturity height for whatever block gets found against that job. Wrapped the whole chain-state snapshot in one CRITICAL_BLOCK(cs_main), consistent with how the rest of the codebase touches this state. * add operator fee * Add categorized, burst-collapsing logging for pool/worker/payout flow debug.log was growing unboundedly because every printf() call wrote unconditionally, so loops that log once per item (discovered peers, connect attempts) produced a near-identical line per address with no cap. Add LogPrint(category, ...): a drop-in printf replacement that tags each call with a subsystem (pool/worker/payout/net/nostr) and collapses repeated calls from the same call site into one line per 3s window plus a '(xN similar in 3s)' summary, so log growth is bounded by elapsed time instead of event count. Wire it through the whole pool -> work -> payout pipeline (rpc.cpp operator side, main.cpp participant/worker side, net.cpp/nostr.cpp peer & relay discovery), and along the way fix several previously *silent* failure points that produced exactly the reported symptom (worker stuck asking for work, nothing logged): - Relay listener registration failing: no log at all, so a pool with every relay down looked identical to a healthy idle pool. - No relays configured: silent infinite 5s retry loop. - Initial job seed failing at pool startup: silent. - JobWatcher: added a stuck-job watchdog that, after 15s with no job available, logs a specific diagnosis (no chain tip yet vs. RebuildJob() failing) instead of retrying forever in silence. - QueuePayouts(): a block found with zero recorded shares queued no payout and logged nothing. - FlushMaturePayouts(): an unparseable payout address was skipped with no log. - Participant miner: the 'waiting for pool notify' stall was only ever shown as a transient GUI status string, never written to debug.log. Now logs on a 15s cadence while stalled, and after 120s proactively drops the tunnel so the existing reconnect-in-10s loop gets a fresh attempt instead of hanging indefinitely. - Participant miner: share rejections were only a transient GUI status string; now logged with the pool's rejection reason. All edited files verified with g++ -fsyntax-only (gnu++14). * Fix two bugs causing worker stuck + log bloat 1. Worker stuck asking for work (main.cpp): The participant miner checked msg["result"].is_boolean() to detect the mining.subscribe acknowledgement (id==1), but the pool sends an array ([[["mining.notify",sid],sid,4]]) as per Stratum spec, not a boolean. is_boolean() always returned false, so gotSubscribeAck was never set true. This didn't directly block authorize (id==2 path is independent), but it did mean the status never advanced through "subscribed", leaving the progress logging misleading. More importantly, the same wrong assumption meant any pool that also gated job dispatch on a prior subscribe confirmation would leave the worker stuck forever with no diagnosis. Fix: accept any non-null result for id==1 (the array is the session/extranonce info; its contents don't matter for our use case). 2. Log bloat from ProcessMessages (main.cpp): ProcessMessages() used a bare printf() which fired unconditionally for every P2P message received -- the bulk of debug.log was this one line repeating thousands of times. Convert to LogPrint("net", ...) so it is gated behind fDebug (off by default) and burst-collapsed by the dedup introduced in the previous commit. * Fix log bloat, em-dashes, dead code, status bar, Connected/Detected peers Log bloat: - Gate all per-message/per-block/per-loop printf in main.cpp, net.h, net.cpp behind LogAcceptsCategory("net") -- only visible with -debug. This includes: received:/sending: hex dumps, got inventory, askfor, getblocks, getdata, received block, AddToWallet, RelayWalletTransactions, ProcessMessage FAILED, mining loop N-transactions line, retarget block, socket recv/send errors. AddToBlockIndex new best and ProcessBlock ACCEPTED intentionally left always-on (one line per block). - PoolParticipantMiner: status -> now LogPrint(worker) not printf. Em-dashes: - Replace all UTF-8 em-dashes (0xE2 0x80 0x94) with ASCII '--' in every source file (rpc.cpp, gui.cpp, main.cpp, main_gui.cpp, net.cpp, nostr.cpp, util.cpp, btfrv.cpp, db.cpp, db.h, main.h, headers.h, headers_core.h, util.h). 40 occurrences across 14 files. Dead code: - Remove unused gJobSeq atomic from rpc.cpp (written, never read). - Fix minerReward: was computed but payout loop used blockReward directly (operator fee was advertised but never deducted). Now payout correctly uses minerReward = blockReward * (1 - feeFrac). Status bar / peers: - Add GetDiscoveredPeerCount() to nostr.cpp/nostr.h (reads g_btfPeers size under cs_btfPeers). - Replace scrollable inline status text with DrawStatusBar(): a separate ImGui overlay window pinned to bottom of screen (NoScrollbar, NoBringToDisplayFront). Labels changed from 'Peers N' to 'Connected peers: N Detected peers: N'. * Rewrite gui.cpp: proper fixed status bar, scrollable content child Layout architecture: - DrawMainWindow() covers the full display width, height minus STATUS_BAR_H (22px). NoScrollbar + NoScrollWithMouse on the outer window so the chrome (menu, address/balance strip, telemetry strip, separators) never scrolls. - A BeginChild("##scroll") fills the remaining height after the fixed chrome, sized to GetContentRegionAvail().y -- which is already the space between the cursor and the bottom of the outer window. All mode-specific content (transaction table, participant pool list, operator workers/payouts) lives inside this child. Content that overflows gets a scrollbar; nothing bleeds past the outer window edge. - DrawStatusBar() is a separate overlay window pinned to the bottom 22px, drawn last so it is always on top. NoBringToDisplayFront is NOT used here -- drawing last is sufficient and avoids interaction issues with dialogs. - Status bar background is fully opaque (not alpha-blended) so it never shows content behind it. Labels and content: - 'Peers N' replaced with 'Connected peers: N Detected peers: N' using GetDiscoveredPeerCount(). - Mining indicator: '* Mode' instead of the 'filled circle' Unicode glyph that renders as boxes on some Linux font configs. - FmtAge used consistently; 'blocks deep' shortened to 'N deep' in tx list. - Options dialog: em-dashes removed from RadioButton labels (ASCII '--'). - Removed usedY dead variable (GetCursorPosY() result was unused). * Fix blocking bugs, dead code, pool/participant flow correctness Bugs fixed: StratumRecvLine blocking (main.cpp): The participant miner's recv loop called recv() with no timeout after select() said the socket was readable. If the server sent a partial line (split across two TCP segments), the second recv() would block indefinitely waiting for the rest -- the classic 'stuck asking for work' hang even though the pool was alive and had sent data. Fix: single non-blocking recv() pass (MSG_DONTWAIT on Linux, FIONREAD+recv on Windows). If no complete line is in the buffer yet, return false; the outer select() loop retries when more data arrives. BroadcastJob holding mutex during send() (rpc.cpp): BroadcastJob() held gMinersMutex across all SendLine() calls. send() can block if a slow miner's TCP receive buffer is full. With the mutex held, MinerThreadFn could not acquire it to remove a disconnecting miner, causing a deadlock-adjacent stall: broadcast blocked on slow miner, disconnect blocked on broadcast, new jobs not delivered. Fix: snapshot {fd, authorised, address, extranonce2} under the mutex, release it, then send to each fd outside the lock. Dead code removed (rpc.cpp): - gRpcUser, gRpcPassword: defined, never read or set by anything. - dHashesPerSec, nHPSTimerStart: leftover Bitcoin 0.1 stubs, unused. - IsInitialBlockDownload(): stub that returned pindexBest==NULL, never called anywhere in this codebase. - struct Share: declared with three fields, never instantiated. - Duplicate comment block before payout section. Other cleanup (main.cpp): - STUCK_NO_JOB_TIMEOUT_SECS moved from inside the loop body to file scope (was 'static const' inside a loop -- correct but confusing). - pblock->print() in solo miner gated behind LogAcceptsCategory("net") -- was unconditionally dumping a full block to stdout on every solve. - ERROR in BitcoinMiner printf converted to LogPrint("net"). * Pool/participant: fix recv blocking, BroadcastJob mutex, listener spawn, escaped em-dash main.cpp -- StratumRecvLine rewrite: The previous version called recv() in a while loop -- if select() said the socket was readable but only a partial line had arrived, the second recv() call would block indefinitely waiting for the rest of the line. This was the root cause of the recurring stuck-worker bug. Rewrite: single non-blocking recv (MSG_DONTWAIT on Linux, FIONREAD+recv on Windows). If no complete newline is in the buffer yet, return false immediately; the outer 50ms select() loop retries when more data arrives. This means the hash loop always stays responsive regardless of network fragmentation. rpc.cpp -- BroadcastJob mutex held across send(): BroadcastJob held gMinersMutex for the entire duration of sending jobs to all connected miners. send() can block if a slow miner's TCP buffer is full. With the mutex held, MinerThreadFn could not acquire it to clean up a disconnecting miner, stalling the whole broadcast. Fix: snapshot {fd, authorised, address, extranonce2} under the mutex, release it, then send to each fd without holding any lock. rpc.cpp -- LaunchThread failure not handled in BtfStratumAcceptOneFn: The replacement listener thread was launched via LaunchThread() which wraps _beginthread() but discarded the return value. If _beginthread fails (thread limit, OOM), the listener count stayed at 1 with no thread behind it -- that relay would get no new listener until the 30s outer loop in BtfStratumAcceptFn fired. Fix: call _beginthread() directly, check the return value, decrement and log on failure. rpc.cpp -- escaped em-dash in log string: BtfStratumAcceptFn had a literal \xe2\x80\x94 in a LogPrint format string that survived the em-dash sweep (it was source-encoded, not a UTF-8 byte in the file). Replaced with '--'. * Rewrite pool server: single event loop, no per-miner threads Replace the old architecture (one thread per connected miner + separate JobWatcher thread + BtfStratumAcceptFn thread with listener count tracking) with a clean two-layer design: Accept layer (one thread per relay, AcceptOneFn): Each relay gets exactly one persistent thread. It loops: calls blocking RvServiceRegister (waits for a miner to knock), then BtfServiceWrap (echan handshake), then pushes the finished plain socket to a small lock-protected queue, then loops immediately to accept the next miner. No listener count tracking. No replacement thread spawning. No races around spawn failure. The relay always has exactly one pending accept. Event loop (PoolEventLoop, runs on the ThreadRPCServer thread): Single thread owns all connected miner sockets. Each tick: 1. Drain the accept queue -- pick up any newly-connected miners. 2. Check for a new block -- rebuild job and broadcast if so. 3. No-job watchdog -- log and retry if job is missing too long. 4. Periodic job refresh -- restamp nTime every 60s. 5. select() over all miner fds with 50ms timeout. 6. For each readable fd: non-blocking RecvLine, drain all complete lines, dispatch each to HandleLine, collect dead fds. 7. Refresh GUI stats snapshot every 2s. Because the event loop is the only writer to gMiners, no mutex is needed on the miner list. The only mutexes remaining are: - gStatsMutex: guards the GUI-visible snapshot (StatsSnapshot, gWorkerStats, gPayoutViews) written by the event loop and read by GUI threads via GetPoolOperatorStats/GetPoolWorkerStats/GetPendingPayouts. - gReadyMutex: guards the accept queue between AcceptOneFn threads and the event loop. Other improvements in this rewrite: - gPendingPayouts is event-loop-only (no mutex needed). - Round share tracking lives in the event loop (roundShareCount map + roundShareTotal counter) -- zeroed on block found, no per-miner stat mutex needed mid-round. - QueuePayouts takes the share snapshot as a parameter instead of reaching into gMinerStats under a separate mutex. - BroadcastJob no longer snapshots miners to avoid a mutex -- it runs directly in the event loop with exclusive access to gMiners. - AcceptOneFn backs off 30s on relay registration failure instead of returning -- one relay going down doesn't kill the accept thread. - No goto. No per-thread dynamic allocation for listener contexts on each accepted connection. * Thorough logging and correctness fixes across the full pool/participant flow Every failure point in the operator->miner->payout chain now logs exactly what broke, with enough context to diagnose without a debugger. Operator side (rpc.cpp): RebuildJob: logs 'no chain tip (pindexBest is null)' on failure instead of silently returning false. HandleLine: - JSON parse errors logged with the raw line (first 80 chars) - subscribe: logs fd and session id - authorize: logs address, worker, and whether the address is valid - submit: logs stale-job case with submitted vs current job id; logs high-hash with actual hash and target values; logs valid shares with nonce and running session count; logs BLOCK CANDIDATE separately from BLOCK ACCEPTED/REJECTED so a ProcessBlock failure is unambiguous - unknown method logged (reveals miner software quirks) - reply() logs send failures with method name QueuePayouts: logs total payout amount, miner count, fee applied, and how many miners were below dust threshold. FlushMaturePayouts: logs 'insufficient funds or wallet locked?' on SendMoney failure instead of just 'FAILED'. BroadcastJob: logs job id and miner count on every broadcast. Event loop: logs when each new miner socket enters the loop; logs disconnect with session and round share counts. No-job watchdog: RebuildJob retried every 5s (not every 50ms tick), eliminating cs_main hammering when there is no chain tip. AcceptLoopFn: re-polls BtfAllRelays every 60s so relays discovered after startup are picked up; logs each new relay thread spawned. AcceptOneFn: logs relay name on start/stop; logs BtfServiceWrap (echan handshake) failure separately from RvServiceRegister failure. RecvLine: disconnected out-param distinguishes 'socket closed' from 'no complete line yet', preventing incorrect disconnection on partial recv. Participant side (main.cpp): StratumRecvLine: same disconnected out-param fix as RecvLine in rpc.cpp. Previously a false return could mean either 'disconnect' or 'no data yet', causing spurious reconnects when a partial line arrived. PoolParticipantMiner rewritten with step-by-step logging: Step 1 resolve: logs specific failure (no descriptor vs bad format) Step 2 tunnel: logs relay and failure reason Step 3 address: warns explicitly if payout address cannot be determined Step 4 subscribe/authorize: logs each send and each response Step 5 RandomX: logs VM mode Step 6 main loop: - malformed mining.notify (wrong param count or short header/target) logged with sizes instead of silently ignored - authorize rejected: logs reason, does not disconnect (some pools reject bad addresses but still send work) - share accepted/rejected: logs share id and rejection reason - unknown server method logged - pool disconnect logged with handshake state at that moment (gotSubscribeAck, gotAuthorizeAck, haveJob) so you know exactly how far the connection got - nonce space exhausted logged (was silent before) - on exit: logs total hashes, shares sent, shares accepted * Fix build error: ImGuiWindowFlags_NoBringToDisplayFront -> NoBringToFrontOnFocus NoBringToDisplayFront does not exist in this version of ImGui (v1.89.x). The correct flag is ImGuiWindowFlags_NoBringToFrontOnFocus (bit 13). Used in two places: ##statusbar and ##main windows. Also remove dead variable usedY (GetCursorPosY() result assigned but never read) and replace the confusing 'ImGuiWindowFlags_NoScrollbar * 0' expression (evaluates to 0 but looks like a mistake) with a plain 0 in the BeginChild call. * Remove duplicate status logging from SetParticipantMiningStatus Every transition in PoolParticipantMiner already has a direct LogPrint with full context (addresses, job ids, nonces, error reasons). The LogPrint inside SetParticipantMiningStatus was firing on top of those, producing redundant 'PoolParticipantMiner: status -> ...' lines after every meaningful log entry. SetParticipantMiningStatus now just updates the GUI string silently. * Implement vardiff: stable share rate instead of fixed difficulty Previously the pool set shareTarget = blockTarget >> 8 for every miner forever. This meant at low network difficulty a fast miner found shares every few nonces (dozens per second), while at high difficulty a slow miner might go hours without one -- both are bad for fair payout accounting and pool stability. Pool (rpc.cpp): Per-miner difficulty that adjusts to target one share every 15 seconds. After every 5 shares, check actual seconds/share vs target. If the ratio is off by more than 50% (VARDIFF_RETARGET_RATIO), recompute: new_difficulty = current_difficulty / (actual_secs_per_share / target) i.e. shares coming too fast -> difficulty goes up; too slow -> down. Only apply if the change exceeds 10% to avoid jitter. Difficulty is bounded to [0.001, 1e12]. Each miner gets their own shareTarget derived from their difficulty via ShareTargetFromDifficulty(), which maps Stratum difficulty to a uint256 target using the Bitcoin difficulty-1 base (0x1d00ffff compact). New difficulty is sent to the miner via mining.set_difficulty. Participant (main.cpp): Apply the received difficulty value from mining.set_difficulty. Previously the handler was a no-op ('acknowledge only'); now it parses the difficulty double and logs the change. The share target used for comparison comes from the pool's mining.notify p[3] field, which the pool updates after each vardiff adjustment -- this is the standard approach and matches how cpuminer, xmrig etc. handle it. With vardiff, share rate is roughly constant regardless of miner speed or network difficulty. Payouts are proportional to shares submitted, so each miner's payout accurately reflects their CPU contribution. * Fix share target mismatch: pool rejected shares it should have accepted Root cause: the pool put job.shareTarget (from blockTarget<<8, a fixed per-job value) into mining.notify p[3], but checked m->shareTarget (from ShareTargetFromDifficulty, the vardiff value) on submit. With difficulty=1.0, ShareTargetFromDifficulty gives a much HARDER target than blockTarget<<8, so shares that the participant thought were valid (passed the easier notify target) were rejected by the pool (failed the harder difficulty-1 target). Fix: MakeNotifyParams now takes the per-miner shareTarget as an explicit parameter. SendJob passes m->shareTarget. Both sides -- the participant reading p[3] and HandleLine checking m->shareTarget on submit -- now use exactly the same value. Single source of truth. Cleanup: - Remove shareTarget field from StratumJob (was only set, never read after the MakeNotifyParams call was fixed). - Remove MakeShareTarget() function (was only used to populate that now-dead field). - Participant set_difficulty log: 'initial' vs 'updated' instead of 'unchanged' (first connect sends 1.0 which matches the default 1.0, so it was misleadingly logged as 'unchanged'). * Fix double broadcast, correct block reward calculation, improve payout log Double broadcast (event loop vs HandleLine): When a pool miner finds a block, HandleLine calls RebuildJob() + BroadcastJob(true) immediately. The event loop then detects nBestHeight changed (step 2) and was calling RebuildJob() + BroadcastJob(true) again -- miners received duplicate job broadcasts. Fix: step 2 now checks gHaveJob before rebuilding. If HandleLine already built and broadcast a job for this height (gHaveJob=true), step 2 just logs 'job already broadcast by submit handler' and skips. If the block came from an external peer (not our miner), gHaveJob is false and step 2 handles it as before. Block reward calculation in QueuePayouts: Was calling CBlock().GetBlockValue(0) on an empty block object. GetBlockValue uses nBestHeight globally, which by the time QueuePayouts runs has already been incremented to the height AFTER the found block. Fix: compute subsidy directly as '50 COIN >> (blockHeight / 210000)' using the actual found height. At current scale this makes no difference (same subsidy for thousands of blocks) but is now correct by construction. Payout log now shows: - Full block subsidy - Miner share after fee - Operator fee amount in BTF (not just percentage) - Number of recipients, maturity height, dust-filtered count Example: '[payout] block 2096: subsidy 50.00 BTF, miners get 49.00 (fee 2.00% = 1.00 to operator), 3 recipients, matures height 2196' * Fix 6 bugs found in full flow re-read Bug 1 -- Double broadcast (re-applied fix that was lost): When HandleLine accepted a block it called RebuildJob()+BroadcastJob(true). The event loop then detected nBestHeight changed and did it again. Miners received two back-to-back mining.notify clean=true, wasting their work. Fix: step 2 of event loop checks gHaveJob before rebuilding. If HandleLine already built a fresh job (gHaveJob=true), step 2 just logs and skips. Bug 3 -- Disconnect log showed global totals, not session totals: gParticipantSharesSent/Accepted accumulate since node start, not since the last connect. Added sessionSharesSent/sessionSharesAccepted local counters that reset each time PoolParticipantMiner() is called. Bug 4 -- Dead function ParticipantShareTargetFromDiff (main.cpp): Defined but never called anywhere. Removed. Bug 5 -- Invalid-address miner shares silently burned: If a miner authorized with an invalid address, their share of the block reward was neither paid out nor logged -- it just vanished. QueuePayouts now validates addresses up front, logs the forfeited amount explicitly ('X BTF forfeited (bad address)'), and excludes those entries so the payout log is accurate. Bug 6 -- SendMoney called from event loop (stalls miner I/O): FlushMaturePayouts called SendMoney which takes cs_main. If the P2P thread held cs_main (processing a block), the event loop would stall for seconds, causing all connected miners to time out waiting for responses. Fix: FlushMaturePayouts now only moves matured entries to gPayoutExecQueue (fast, no locks except a brief mutex). A new PayoutThreadFn thread drains the queue and calls SendMoney independently, so the event loop is never blocked by wallet I/O. Bug 7 -- Dead goto + dead HandleLine return-false path: HandleLine always returns true; the goto next_miner path was never reached. Removed goto and simplified the select drain loop. * Fix share-target scale mismatch and dead pool hashrate reporting - ShareTargetFromDifficulty() based Stratum difficulty 1 on Bitcoin's SHA256 diff-1 constant (0x1d00ffff, ~2^32 hashes/share) instead of this chain's actual PoW floor (bnProofOfWorkLimit = ~uint256(0)>>12, ~2^12 hashes/share). That's a ~2^20 (~1,048,576x) mismatch: at the supposedly-easy difficulty of 1.0, workers were being asked for shares roughly a million times harder than the chain's own easiest target. Vardiff never gets a chance to correct this because it only retargets after real shares come in, and none were coming in. Fixed by basing the diff-1 reference on bnProofOfWorkLimit instead. - Pool hashrate was never computed anywhere despite the data (per-miner difficulty + share timing) being available: ThreadBtfPoolAnnouncer in nostr.cpp hardcoded ann.hashRate = 0.0, PoolWorkerStatView had no hashrate field, and the GUI workers table had no column for it. Added an EWMA hashrate estimate per miner (updated on each accepted share from expected-hashes-at-difficulty / time-since-last-share), threaded it through GetPoolOperatorStats/GetPoolWorkerStats, the pool announcement payload, and the GUI (new Hashrate column + totals in the telemetry line). Verified: full clean build via src/Makefile (secp256k1 + RandomX deps built from source, g++ 13, all four touched files compile and link). * Remove stale wxWidgets-era build files and orphaned assets These predate the ImGui/GLFW migration (83af065) and are dead weight: - src/makefile.linux, build-linux.sh: superseded by src/Makefile + root Makefile (make linux). The old ones reference obj/uibase.o and obj/ui.o, which don't exist -- they never worked post-migration. - src/makefile.vc: MSVC build for the old wx UI (obj\ui.obj, obj\uibase.obj, obj\irc.obj) -- irc.cpp doesn't even exist anymore, and nothing invokes this makefile. Windows builds go through makefile.mingw via the root Makefile's target. - src/ui.rc, src/uiproject.fbp: wxFormBuilder project/resource files for the old wx UI. The current Windows resource file is bitflash.rc. - src/rc/{addressbook,send}*.bmp, check.ico, bitcoin_bc_original.ico: icons used only by the old wx UI. bitcoin.ico (used by bitflash.rc) is kept. Verified nothing includes uibase.h/ui.h/irc.h anywhere, and did a full clean rebuild afterward (secp256k1 + RandomX from source, src/Makefile) to confirm removal doesn't break the real build. * Mine with RandomX fast (2GB dataset) mode instead of light mode forever RandomXInitDataset() was fully implemented and ready to switch mining into fast mode, but nothing ever called it -- not in PoolParticipantMiner() (pool worker), not in the solo BitcoinMiner() loop. RandomXCreateMinerVM() falls back to light mode whenever the dataset isn't built, so every miner, participant or solo, has always been hashing in light/cache-only mode. Light mode exists for cheap single-hash verification (which is why the pool's own share verification correctly stays on it) -- it recomputes dataset items on the fly for every hash, which is dramatically slower than fast mode's direct dataset lookups. Combined with the share-target scale bug fixed in e87a905, this compounds into exactly the symptom reported: a participant sitting there hashing for many minutes per share instead of the vardiff-targeted ~15s, even after the target fix landed. Fix: build the ~2GB dataset once (multi-threaded, sized to hardware_concurrency) before mining starts, in both the participant and solo paths, so RandomXCreateMinerVM() actually gets to use fast mode. Verified: full clean rebuild via src/Makefile. * Add vardiff stall watchdog: force difficulty down on zero shares, not just share count ShareTargetFromDifficulty already fixed the diff-1 scale mismatch (chain floor vs Bitcoin's SHA256 diff-1), so difficulty 1 correctly means ~4096 expected hashes on this chain, not ~2^32. That part was already correct. But the normal vardiff retarget in HandleLine only runs after VARDIFF_SHARES_PER_CHECK (5) shares arrive. A miner that never finds even one share -- e.g. stuck in RandomX light/cache mode instead of the fast 2GB dataset, or just slower hardware than the target assumes -- never reaches that check, so it can sit at its initial/current difficulty indefinitely with vardiff having no samples to act on. Add a time-based fallback in PoolEventLoop: if a miner's vardiff window has been open for VARDIFF_STALL_SECS (90s, 6x the 15s target) with zero shares, force difficulty down by VARDIFF_STALL_CUT (4x) regardless of share count, and re-notify via mining.set_difficulty. Once a miner is already at VARDIFF_MIN, log it plainly as a hashrate/connectivity issue instead of a difficulty issue, so it's not confused with the same-looking but different problem this was actually fixed for before. * Fix PrintException rethrow killing the whole node on any transient exception The uploaded participant log shows the real bug behind the mining stalls: the node process itself was dying and restarting from scratch every minute or two ('UNKNOWN EXCEPTION ... in ThreadMessageHandler()' / 'ThreadSocketHandler()'), wiping the RandomX dataset/cache and losing all progress toward a share -- not a difficulty problem. Every background thread (ThreadMessageHandler, ThreadSocketHandler, ThreadBtfConnect, the Nostr helpers) wraps its real work in a 'try { Foo2() } catch(...) { log, sleep 5s, loop again }' self-healing retry pattern specifically so one bad exception doesn't take the whole node down. But PrintException(), called from inside every one of those catch blocks, ended with 'if (!fShutdown) throw;' -- re-throwing the just- caught exception during ordinary operation. Nothing above the retry loop catches it, so it escapes the thread and std::terminate()s the entire process on the very first transient error (a flaky relay connect, a malformed message, whatever). The comment's intent (suppress noise while already shutting down) was inverted from what the surrounding retry loops actually need. Removed the rethrow. Threads now log the exception and let their own retry loop handle it, as designed. * Push a fresh job immediately after any difficulty change, not just on next broadcast mining.set_difficulty alone doesn't change what the worker actually hashes against -- the participant only reads its share target from mining.notify's params[3] (see main.cpp's mining.notify handler); the set_difficulty message just updates a locally-logged currentDifficulty value. So both the normal vardiff retarget (in HandleLine, on 5 shares) and the new stall watchdog were updating m->shareTarget and telling the worker about the new difficulty, but the worker kept hashing against its old target until the next natural job broadcast -- a new block, or the 60s trickle refresh. That could delay any difficulty correction by up to a minute for no reason. Call SendJob(m, false) right after each set_difficulty send so the updated shareTarget reaches the worker in the very next tick instead of waiting on the next unrelated broadcast. * Build RandomX dataset before stratum handshake, not after Worker was sending mining.subscribe/authorize, then blocking for the duration of the synchronous ~2GB RandomX dataset build, and only then reading the socket. The pool's replies (subscribe ack, authorize ack, set_difficulty, first job) sat unread in the OS receive buffer for that entire window -- nothing was lost, but with the build taking anywhere from seconds to a couple minutes depending on core count, the worker looked and logged as permanently stuck on 'waiting for pool response' when it just wasn't listening yet. Move the dataset build ahead of the subscribe/authorize sends so the socket is actively being read the moment the pool replies. Also add missing RandomXDestroyMinerVM cleanup on the two early-return paths that now happen after VM creation. * Reset round shares on externally-found blocks, not just our own Round shares were only cleared in HandleLine() after our own miner's block got accepted. When the chain height advanced because someone else found the block, the main loop rebuilt the job but left roundShareCount/roundShareTotal/Miner::roundShares untouched, so they kept accumulating across rounds instead of resetting. * Show pool hashrate in participant view; show immature coinbase value with maturity countdown * Persist pending payouts to disk -- survive operator restarts * Fix log escapes, stall timeout 90->45s, save-before-exec ordering, Immature tx status * Sort transactions by nTimeReceived not chain median time -- new txs always on top * Sort txs by chain depth not nTimeReceived -- survives resync, unconfirmed always first * Fix orphan block false-positive payout; meaningful tx status instead of raw depth * Show Orphaned status in red for orphaned coinbases; distinguish from genuinely unconfirmed --------- Co-authored-by: Vic-Nas Co-authored-by: Bitflash Pool Dev Co-authored-by: Victorio Nascimento <155357371+Vic-Nas@users.noreply.github.com> Co-authored-by: Claude --- .gitignore | 1 + Bitflash-1.1.0-windows/glfw3.dll | Bin 0 -> 263756 bytes Bitflash-1.1.0-windows/libcrypto-3-x64.dll | Bin 0 -> 5495100 bytes Bitflash-1.1.0-windows/libdb_cxx-6.2.dll | Bin 0 -> 1666981 bytes Bitflash-1.1.0-windows/libgcc_s_seh-1.dll | Bin 0 -> 153412 bytes Bitflash-1.1.0-windows/libsodium-26.dll | Bin 0 -> 451415 bytes Bitflash-1.1.0-windows/libssl-3-x64.dll | Bin 0 -> 996164 bytes Bitflash-1.1.0-windows/libstdc++-6.dll | Bin 0 -> 2667044 bytes Bitflash-1.1.0-windows/libwinpthread-1.dll | Bin 0 -> 64703 bytes build-linux.sh | 72 - install-bitflash-wine.sh | 211 - src/Makefile | 4 +- src/btfrv.cpp | 4 +- src/db.cpp | 97 +- src/db.h | 15 +- src/gui.cpp | 701 +- src/headers.h | 2 +- src/headers_core.h | 5 +- src/irc.cpp | 271 - src/irc.h | 7 - src/main.cpp | 631 +- src/main.h | 23 +- src/main_gui.cpp | 4 +- src/makefile.linux | 55 - src/makefile.mingw | 4 +- src/makefile.vc | 77 - src/net.cpp | 391 +- src/net.h | 31 +- src/nostr.cpp | 518 +- src/nostr.h | 26 + src/rc/addressbook16.bmp | Bin 1334 -> 0 bytes src/rc/addressbook16mask.bmp | Bin 126 -> 0 bytes src/rc/addressbook20.bmp | Bin 1478 -> 0 bytes src/rc/addressbook20mask.bmp | Bin 142 -> 0 bytes src/rc/bitcoin_bc_original.ico | Bin 22486 -> 0 bytes src/rc/check.ico | Bin 766 -> 0 bytes src/rc/send16.bmp | Bin 1334 -> 0 bytes src/rc/send16mask.bmp | Bin 126 -> 0 bytes src/rc/send16masknoshadow.bmp | Bin 126 -> 0 bytes src/rc/send20.bmp | Bin 1478 -> 0 bytes src/rc/send20mask.bmp | Bin 142 -> 0 bytes src/rpc.cpp | 1544 ++-- src/ui.rc | 14 - src/uiproject.fbp | 11825 --------------------------- src/util.cpp | 108 +- src/util.h | 42 +- 46 files changed, 2648 insertions(+), 14035 deletions(-) create mode 100644 Bitflash-1.1.0-windows/glfw3.dll create mode 100644 Bitflash-1.1.0-windows/libcrypto-3-x64.dll create mode 100644 Bitflash-1.1.0-windows/libdb_cxx-6.2.dll create mode 100644 Bitflash-1.1.0-windows/libgcc_s_seh-1.dll create mode 100644 Bitflash-1.1.0-windows/libsodium-26.dll create mode 100644 Bitflash-1.1.0-windows/libssl-3-x64.dll create mode 100644 Bitflash-1.1.0-windows/libstdc++-6.dll create mode 100644 Bitflash-1.1.0-windows/libwinpthread-1.dll delete mode 100644 build-linux.sh delete mode 100644 install-bitflash-wine.sh delete mode 100644 src/irc.cpp delete mode 100644 src/irc.h delete mode 100644 src/makefile.linux delete mode 100644 src/makefile.vc delete mode 100644 src/rc/addressbook16.bmp delete mode 100644 src/rc/addressbook16mask.bmp delete mode 100644 src/rc/addressbook20.bmp delete mode 100644 src/rc/addressbook20mask.bmp delete mode 100644 src/rc/bitcoin_bc_original.ico delete mode 100644 src/rc/check.ico delete mode 100644 src/rc/send16.bmp delete mode 100644 src/rc/send16mask.bmp delete mode 100644 src/rc/send16masknoshadow.bmp delete mode 100644 src/rc/send20.bmp delete mode 100644 src/rc/send20mask.bmp delete mode 100644 src/ui.rc delete mode 100644 src/uiproject.fbp (limited to 'src/main_gui.cpp') diff --git a/.gitignore b/.gitignore index 29ebcff..a734faf 100644 --- a/.gitignore +++ b/.gitignore @@ -28,3 +28,4 @@ dist/ # Editor/OS cruft .DS_Store Thumbs.db +src/bitflash diff --git a/Bitflash-1.1.0-windows/glfw3.dll b/Bitflash-1.1.0-windows/glfw3.dll new file mode 100644 index 0000000..f988a2e Binary files /dev/null and b/Bitflash-1.1.0-windows/glfw3.dll differ diff --git a/Bitflash-1.1.0-windows/libcrypto-3-x64.dll b/Bitflash-1.1.0-windows/libcrypto-3-x64.dll new file mode 100644 index 0000000..76250f0 Binary files /dev/null and b/Bitflash-1.1.0-windows/libcrypto-3-x64.dll differ diff --git a/Bitflash-1.1.0-windows/libdb_cxx-6.2.dll b/Bitflash-1.1.0-windows/libdb_cxx-6.2.dll new file mode 100644 index 0000000..c4279f6 Binary files /dev/null and b/Bitflash-1.1.0-windows/libdb_cxx-6.2.dll differ diff --git a/Bitflash-1.1.0-windows/libgcc_s_seh-1.dll b/Bitflash-1.1.0-windows/libgcc_s_seh-1.dll new file mode 100644 index 0000000..55f4ad1 Binary files /dev/null and b/Bitflash-1.1.0-windows/libgcc_s_seh-1.dll differ diff --git a/Bitflash-1.1.0-windows/libsodium-26.dll b/Bitflash-1.1.0-windows/libsodium-26.dll new file mode 100644 index 0000000..add05ab Binary files /dev/null and b/Bitflash-1.1.0-windows/libsodium-26.dll differ diff --git a/Bitflash-1.1.0-windows/libssl-3-x64.dll b/Bitflash-1.1.0-windows/libssl-3-x64.dll new file mode 100644 index 0000000..cbabbbc Binary files /dev/null and b/Bitflash-1.1.0-windows/libssl-3-x64.dll differ diff --git a/Bitflash-1.1.0-windows/libstdc++-6.dll b/Bitflash-1.1.0-windows/libstdc++-6.dll new file mode 100644 index 0000000..bd3fc0f Binary files /dev/null and b/Bitflash-1.1.0-windows/libstdc++-6.dll differ diff --git a/Bitflash-1.1.0-windows/libwinpthread-1.dll b/Bitflash-1.1.0-windows/libwinpthread-1.dll new file mode 100644 index 0000000..32d8a01 Binary files /dev/null and b/Bitflash-1.1.0-windows/libwinpthread-1.dll differ diff --git a/build-linux.sh b/build-linux.sh deleted file mode 100644 index 4c3844d..0000000 --- a/build-linux.sh +++ /dev/null @@ -1,72 +0,0 @@ -#!/usr/bin/env bash -# Bitflash headless node/miner -- one-shot Linux build (Debian/Ubuntu). -# Builds the dependencies (libsecp256k1 with schnorrsig, RandomX) and then the -# node itself. Run from the project root (the directory that contains src/): -# -# bash build-linux.sh -# -# Result: src/bitflash-node . Run it with: ./src/bitflash-node -gen - -set -e -ROOT="$(cd "$(dirname "$0")" && pwd)" -cd "$ROOT" - -# Use sudo only when not already root (a root login often has no sudo installed). -SUDO="" -if [ "$(id -u)" -ne 0 ]; then SUDO="sudo"; fi - -echo "==> installing system packages" -if command -v apt-get >/dev/null 2>&1; then - $SUDO apt-get update - $SUDO apt-get install -y build-essential cmake git pkg-config autoconf libtool \ - libssl-dev libdb5.3++-dev libsodium-dev nlohmann-json3-dev libboost-dev \ - libboost-system-dev libwxgtk3.2-dev -else - echo "!! non-apt system: install manually -> g++ cmake git autoconf libtool" - echo " libssl-dev libdb++-dev libsodium-dev nlohmann-json (headers)" -fi - -# ---- libsecp256k1 (Schnorr/BIP340 + extrakeys), static, installed to /usr/local -if [ ! -f /usr/local/lib/libsecp256k1.a ]; then - echo "==> building libsecp256k1" - rm -rf "$ROOT/secp256k1-build" - git clone --depth 1 https://github.com/bitcoin-core/secp256k1 "$ROOT/secp256k1-build" - cd "$ROOT/secp256k1-build" - ./autogen.sh - ./configure --enable-module-schnorrsig --enable-module-extrakeys \ - --disable-shared --with-pic --disable-benchmark --disable-tests - make -j"$(nproc)" - $SUDO make install - $SUDO ldconfig - cd "$ROOT" -else - echo "==> libsecp256k1 already installed, skipping" -fi - -# ---- RandomX (memory-hard CPU PoW), static lib -if [ ! -f "$HOME/RandomX/build/librandomx.a" ]; then - echo "==> building RandomX" - rm -rf "$HOME/RandomX" - git clone --depth 1 https://github.com/tevador/RandomX "$HOME/RandomX" - mkdir -p "$HOME/RandomX/build" - cd "$HOME/RandomX/build" - cmake .. -DCMAKE_BUILD_TYPE=Release - make -j"$(nproc)" randomx - cd "$ROOT" -else - echo "==> RandomX already built, skipping" -fi - -# ---- build -echo "==> building bitflash" -cd "$ROOT/src" -make -f makefile.linux RANDOMX_DIR="$HOME/RandomX" -j"$(nproc)" - -echo -echo "===================================================================" -echo " DONE. $ROOT/src/bitflash" -echo -echo " GUI: ./src/bitflash" -echo " Daemon mode: ./src/bitflash /nogui /gen" -echo " Pool server starts automatically on Stratum :3333, stats :19012" -echo "===================================================================" diff --git a/install-bitflash-wine.sh b/install-bitflash-wine.sh deleted file mode 100644 index 91fef59..0000000 --- a/install-bitflash-wine.sh +++ /dev/null @@ -1,211 +0,0 @@ -#!/usr/bin/env bash -# Bitflash GUI installer for Linux (via Wine) — hardened -set -euo pipefail - -INSTALL_DIR="$HOME/.btf-wine" -WINE_DATA="$HOME/.wine/drive_c/users/$USER/AppData/Roaming/Bitflash" -LINUX_DATA="$HOME/.bitflash" -BIN_DIR="$HOME/.local/bin" -REPO="Bitflash-sh/bitflash" - -echo "==> Bitflash GUI installer" -echo - -# ---- Safety: don't run as root (breaks Wine + pollutes /root) -if [ "$(id -u)" -eq 0 ]; then - echo "ERROR: do not run this as root. Run as your normal user (it will call sudo only when needed)." >&2 - exit 1 -fi - -need() { command -v "$1" >/dev/null 2>&1; } - -# ---- Wine -echo "==> Checking Wine..." -if ! need wine; then - echo " Installing Wine..." - if ! need apt-get; then - echo "ERROR: apt-get not found. Install 'wine' manually, then re-run." >&2 - exit 1 - fi - sudo apt-get update -qq - sudo apt-get install -y wine -fi -echo " Wine: $(wine --version)" - -# ---- db-util (db_dump / db_load for wallet conversion) -echo "==> Checking db-util..." -if ! need db_dump; then - echo " Installing db-util..." - sudo apt-get install -y db-util -fi -echo " db_dump: $(command -v db_dump)" - -# ---- unzip + sha256sum must exist -for tool in unzip sha256sum curl; do - if ! need "$tool"; then - echo " Installing $tool..." - sudo apt-get install -y "$tool" - fi -done - -# ---- Resolve latest release (single API call) -echo "==> Fetching latest release metadata from GitHub..." -API_JSON=$(curl -fsSL "https://api.github.com/repos/$REPO/releases/latest") - -RELEASE_URL=$(printf '%s\n' "$API_JSON" | grep '"browser_download_url"' \ - | grep -iE 'win|windows|gui' | head -1 | cut -d '"' -f 4 || true) -[ -z "$RELEASE_URL" ] && RELEASE_URL=$(printf '%s\n' "$API_JSON" | grep '"browser_download_url"' \ - | grep -iE '\.zip"?$' | head -1 | cut -d '"' -f 4 || true) - -if [ -z "$RELEASE_URL" ]; then - echo "ERROR: no downloadable release asset found at https://github.com/$REPO/releases" >&2 - exit 1 -fi -ASSET_NAME=$(basename "$RELEASE_URL") -echo " Asset: $ASSET_NAME" - -# checksum manifest published alongside the release (SHA256SUMS) -SUMS_URL=$(printf '%s\n' "$API_JSON" | grep '"browser_download_url"' \ - | grep -iE 'sha256sums|checksums|\.sha256"?$' | head -1 | cut -d '"' -f 4 || true) - -# ---- Download the release -TMPZIP=$(mktemp /tmp/bitflash-XXXXXX.zip) -cleanup() { rm -f "$TMPZIP" "${TMPSUMS:-}"; } -trap cleanup EXIT -echo "==> Downloading release..." -curl -fL --retry 3 --retry-delay 2 -o "$TMPZIP" "$RELEASE_URL" -echo " Downloaded: $(du -h "$TMPZIP" | cut -f1)" - -# ---- MANDATORY integrity check -echo "==> Verifying integrity..." -GOT=$(sha256sum "$TMPZIP" | awk '{print $1}') -if [ -n "${EXPECTED_SHA256:-}" ]; then - # Strongest: user pinned a known-good hash out-of-band. - if [ "$GOT" != "$EXPECTED_SHA256" ]; then - echo "ERROR: checksum mismatch. expected $EXPECTED_SHA256, got $GOT" >&2 - exit 1 - fi - echo " OK (matches pinned EXPECTED_SHA256)" -elif [ -n "$SUMS_URL" ]; then - TMPSUMS=$(mktemp /tmp/bitflash-sums-XXXXXX) - curl -fsSL --retry 3 -o "$TMPSUMS" "$SUMS_URL" - WANT=$(grep -iF "$ASSET_NAME" "$TMPSUMS" | awk '{print $1}' | head -1 || true) - if [ -z "$WANT" ]; then - echo "ERROR: no checksum for $ASSET_NAME in published SHA256SUMS." >&2 - exit 1 - fi - if [ "$GOT" != "$WANT" ]; then - echo "ERROR: checksum mismatch. published $WANT, got $GOT" >&2 - exit 1 - fi - echo " OK (matches published SHA256SUMS)" -else - echo "ERROR: no checksum available for this release." >&2 - echo " Publish a SHA256SUMS asset with the release, or re-run with" >&2 - echo " EXPECTED_SHA256= to verify against a known-good value." >&2 - echo " (To bypass at your own risk: ALLOW_UNVERIFIED=1)" >&2 - if [ "${ALLOW_UNVERIFIED:-0}" != "1" ]; then - exit 1 - fi - echo " WARNING: proceeding UNVERIFIED (ALLOW_UNVERIFIED=1). sha256=$GOT" -fi - -# ---- Extract -echo "==> Extracting to $INSTALL_DIR..." -rm -rf "$INSTALL_DIR" -mkdir -p "$INSTALL_DIR" -unzip -j -o "$TMPZIP" -d "$INSTALL_DIR" >/dev/null - -# ---- Rename backslash-mangled entries (Windows zip paths) -count=0 -for f in "$INSTALL_DIR"/*; do - [ -f "$f" ] || continue - base=$(basename "$f") - newname=$(printf '%s' "$base" | sed 's/.*\\//') - if [ "$newname" != "$base" ]; then - mv "$f" "$INSTALL_DIR/$newname" - count=$((count+1)) - fi -done -echo " Renamed $count files; total: $(ls "$INSTALL_DIR" | wc -l)" - -# Locate the Bitflash exe case-insensitively (zip may ship Bitflash.exe or bitflash.exe) -EXE=$(find "$INSTALL_DIR" -maxdepth 1 -iname 'bitflash.exe' | head -1) -if [ -z "$EXE" ]; then - echo "ERROR: bitflash exe not found after extract. Contents:" >&2 - ls "$INSTALL_DIR" >&2 - exit 1 -fi -echo " Found $(basename "$EXE")" - -# ---- Init Wine data dir -echo "==> Initializing Wine data directory..." -WINEDLLOVERRIDES="libdb_cxx-6.2=n" wine "$EXE" & -WINE_PID=$! -created=0 -for i in $(seq 1 30); do - sleep 1 - if [ -f "$WINE_DATA/wallet.dat" ]; then - echo " wallet.dat created after ${i}s" - created=1 - break - fi -done -kill "$WINE_PID" 2>/dev/null || true -wait "$WINE_PID" 2>/dev/null || true -sleep 1 -mkdir -p "$WINE_DATA" -[ "$created" -eq 1 ] || echo " NOTE: Wine did not create wallet.dat in 30s; continuing." - -# ---- Optional wallet import (with backup) -echo -if [ -f "$LINUX_DATA/wallet.dat" ]; then - echo "==> Found existing Linux wallet at $LINUX_DATA/wallet.dat" - read -r -p " Import it into the GUI wallet? [Y/n] " ans - ans=${ans:-Y} - if [[ "$ans" =~ ^[Yy] ]]; then - if [ -f "$WINE_DATA/wallet.dat" ]; then - BK="$WINE_DATA/wallet.dat.bak.$(date +%Y%m%d%H%M%S)" - cp "$WINE_DATA/wallet.dat" "$BK" - echo " Backed up existing GUI wallet -> $BK" - fi - DUMP=$(mktemp); CONVERTED=$(mktemp); rm -f "$CONVERTED" - echo " Converting Linux wallet format..." - db_dump -p "$LINUX_DATA/wallet.dat" > "$DUMP" - db_load -f "$DUMP" "$CONVERTED" - cp "$CONVERTED" "$WINE_DATA/wallet.dat" - rm -f "$DUMP" "$CONVERTED" - echo " Wallet imported." - else - echo " Skipped. Re-run to import later." - fi -else - echo "==> No Linux wallet found, starting fresh." -fi - -# ---- Launcher -echo "==> Creating 'bitflash' command..." -mkdir -p "$BIN_DIR" -LAUNCHER_FILE="$BIN_DIR/bitflash" -cat > "$LAUNCHER_FILE" </dev/null; then - echo 'export PATH="$HOME/.local/bin:$PATH"' >> "$HOME/.bashrc" - echo " Added ~/.local/bin to PATH (restart shell or: source ~/.bashrc)" -fi - -echo -echo "==================================================================" -echo " DONE." -echo " Launch: bitflash" -echo " Mine: bitflash -gen" -echo " Relay: bitflash -announcerelay=YOUR_HOST:8434" -echo " Data: $WINE_DATA" -echo "==================================================================" diff --git a/src/Makefile b/src/Makefile index 3280fd3..c0b6e1d 100644 --- a/src/Makefile +++ b/src/Makefile @@ -21,14 +21,14 @@ LIBS = -L$(SECP_LIB) -lsecp256k1 \ -lglfw -lGL HEADERS = headers_core.h headers.h util.h main.h serialize.h uint256.h \ - key.h bignum.h script.h db.h base58.h net.h irc.h nostr.h \ + key.h bignum.h script.h db.h base58.h net.h nostr.h \ randomx_pow.h btfaddr.h btfchan.h btfrv.h btftunnel.h market.h compat.h IMGUI_OBJS = obj/imgui.o obj/imgui_draw.o obj/imgui_tables.o \ obj/imgui_widgets.o obj/imgui_impl_glfw.o obj/imgui_impl_opengl3.o NODE_OBJS = obj/util.o obj/script.o obj/db.o obj/net.o obj/main.o \ - obj/market.o obj/sha.o obj/irc.o obj/nostr.o obj/randomx_pow.o \ + obj/market.o obj/sha.o obj/nostr.o obj/randomx_pow.o \ obj/btfaddr.o obj/btfchan.o obj/btfrv.o obj/btftunnel.o obj/rpc.o GUI_OBJS = obj/gui.o obj/main_gui.o diff --git a/src/btfrv.cpp b/src/btfrv.cpp index 812d077..0d02ca8 100644 --- a/src/btfrv.cpp +++ b/src/btfrv.cpp @@ -101,7 +101,7 @@ static SOCKET ConnectTo(const char* host, unsigned short port) // Apply a short timeout only for the connect() and initial handshake so an // unresponsive relay doesn't block the thread. Once the tunnel is live the - // timeout is cleared — a forwarding pipe must block indefinitely on recv(). + // timeout is cleared -- a forwarding pipe must block indefinitely on recv(). #ifdef _WIN32 DWORD tvShort = 10000; setsockopt(s, SOL_SOCKET, SO_RCVTIMEO, (const char*)&tvShort, sizeof(tvShort)); @@ -118,7 +118,7 @@ static SOCKET ConnectTo(const char* host, unsigned short port) } freeaddrinfo(res); - // Clear the timeout — the socket is now a live tunnel and must not + // Clear the timeout -- the socket is now a live tunnel and must not // time out when idle. recv() will block until data arrives or peer closes. #ifdef _WIN32 DWORD tvOff = 0; diff --git a/src/db.cpp b/src/db.cpp index de71a57..199fa44 100644 --- a/src/db.cpp +++ b/src/db.cpp @@ -392,77 +392,8 @@ bool CTxDB::LoadBlockIndex() -// -// CAddrDB -// - -bool CAddrDB::WriteAddress(const CAddress& addr) -{ - return Write(make_pair(string("addr"), addr.GetKey()), addr); -} - -bool CAddrDB::LoadAddresses() -{ - CRITICAL_BLOCK(cs_mapAddresses) - { - // Load user provided addresses - CAutoFile filein = fopen("addr.txt", "rt"); - if (filein) - { - try - { - char psz[1000]; - while (fgets(psz, sizeof(psz), filein)) - { - CAddress addr(psz, NODE_NETWORK); - if (addr.ip != 0) - AddAddress(*this, addr); - } - } - catch (...) { } - } - - // Get cursor - Dbc* pcursor = GetCursor(); - if (!pcursor) - return false; - - loop - { - // Read next record - CDataStream ssKey; - CDataStream ssValue; - int ret = ReadAtCursor(pcursor, ssKey, ssValue); - if (ret == DB_NOTFOUND) - break; - else if (ret != 0) - return false; - - // Unserialize - string strType; - ssKey >> strType; - if (strType == "addr") - { - CAddress addr; - ssValue >> addr; - mapAddresses.insert(make_pair(addr.GetKey(), addr)); - } - } - - //// debug print - printf("mapAddresses:\n"); - foreach(const PAIRTYPE(vector, CAddress)& item, mapAddresses) - item.second.print(); - printf("-----\n"); - } - - return true; -} - -bool LoadAddresses() -{ - return CAddrDB("cr+").LoadAddresses(); -} +// Legacy IP-based peer address database (CAddrDB / addr.dat) removed -- +// peer discovery is entirely Nostr/.btf-based now. @@ -563,24 +494,28 @@ bool CWalletDB::LoadWallet(vector& vchDefaultKeyRet) { string strKey; ssKey >> strKey; - if (strKey == "fGenerateBitcoins") ssValue >> fGenerateBitcoins; if (strKey == "nTransactionFee") ssValue >> nTransactionFee; if (strKey == "addrIncoming") ssValue >> addrIncoming; - if (strKey == "nMineMode") ssValue >> nMineMode; - if (strKey == "strParticipantPool") ssValue >> strParticipantPool; - if (strKey == "strPoolName") ssValue >> strPoolName; - if (strKey == "strPoolDashboardUrl") ssValue >> strPoolDashboardUrl; - if (strKey == "dPoolFeePercent") ssValue >> dPoolFeePercent; + // Mining-mode/pool settings (nMineMode, strParticipantPool, strPoolName, + // strPoolDashboardUrl, dPoolFeePercent, fGenerateBitcoins) are intentionally + // NOT restored here. They used to be, and LoadWallet() runs after CLI flags + // are parsed in main_gui.cpp, so a saved value would silently overwrite + // whatever was just requested on the command line or in a previous Options + // session -- no error, no log line. That's what caused pool name/address to + // show correctly in the UI but stay stale in the logs, and mode to change + // behavior across restarts. Mode/pool config is decided fresh every launch + // instead: CLI flags if given, otherwise the compiled-in default. Nothing to + // go stale, nothing to fight over load order. } } } - printf("fGenerateBitcoins = %d\n", fGenerateBitcoins); printf("nTransactionFee = %lld\n", nTransactionFee); printf("addrIncoming = %s\n", addrIncoming.ToString().c_str()); - printf("nMineMode = %d, strParticipantPool = %s\n", nMineMode, strParticipantPool.c_str()); - printf("strPoolName = %s, dPoolFeePercent = %.2f\n", strPoolName.c_str(), dPoolFeePercent); - printf("strPoolDashboardUrl = %s\n", strPoolDashboardUrl.c_str()); + printf("nMineMode = %d, strParticipantPool = %s, fGenerateBitcoins = %d (from CLI/default, not wallet.dat)\n", + nMineMode, strParticipantPool.c_str(), fGenerateBitcoins); + printf("strPoolName = %s, dPoolFeePercent = %.2f, strPoolDashboardUrl = %s (from CLI/default, not wallet.dat)\n", + strPoolName.c_str(), dPoolFeePercent, strPoolDashboardUrl.c_str()); return true; } diff --git a/src/db.h b/src/db.h index feaa45e..6dc3a4f 100644 --- a/src/db.h +++ b/src/db.h @@ -316,19 +316,8 @@ private: -class CAddrDB : public CDB -{ -public: - CAddrDB(const char* pszMode="r+", bool fTxn=false) : CDB("addr.dat", pszMode, fTxn) { } -private: - CAddrDB(const CAddrDB&); - void operator=(const CAddrDB&); -public: - bool WriteAddress(const CAddress& addr); - bool LoadAddresses(); -}; - -bool LoadAddresses(); +// Legacy IP-based peer address database (CAddrDB / addr.dat) removed -- +// peer discovery is entirely Nostr/.btf-based now. diff --git a/src/gui.cpp b/src/gui.cpp index 2210296..a1a0698 100644 --- a/src/gui.cpp +++ b/src/gui.cpp @@ -1,4 +1,4 @@ -// Bitflash GUI — ImGui + GLFW + OpenGL3 +// Bitflash GUI -- ImGui + GLFW + OpenGL3 // Same source on Linux and Windows. No platform ifdefs. #include "imgui/imgui.h" @@ -25,6 +25,7 @@ extern std::string strParticipantPool; extern CAddress addrLocalHost; void MainFrameRepaint(); +int GetDiscoveredPeerCount(); string DateTimeStr(int64 nTime); bool SendMoney(CScript scriptPubKey, int64 nValue, CWalletTx& wtxNew); int64 GetBalance(); @@ -34,15 +35,20 @@ void ThreadBitcoinMiner(void*); void ThreadRPCServer(void*); // --------------------------------------------------------------------------- -// State +// Layout constants +// --------------------------------------------------------------------------- +static const float STATUS_BAR_H = 22.0f; + +// --------------------------------------------------------------------------- +// GUI state // --------------------------------------------------------------------------- static std::string g_myAddress; -static int64 g_balance = 0; +static int64 g_balance = 0; static int64 g_lastWalletRefresh = 0; -static bool g_showSend = false; -static bool g_showOptions = false; -static bool g_showAbout = false; -static bool g_needRefresh = true; +static bool g_showSend = false; +static bool g_showOptions = false; +static bool g_showAbout = false; +static bool g_needRefresh = true; static char g_sendAddr[128] = {}; static char g_sendAmount[32] = {}; @@ -73,36 +79,34 @@ static std::string FmtMoney(int64 n) static std::string FmtAge(int64 createdAt) { int64 age = GetTime() - createdAt; - if (age < 0) - age = 0; - if (age < 60) - return strprintf("%llds", (long long)age); - if (age < 3600) - return strprintf("%lldm", (long long)(age / 60)); - return strprintf("%lldh", (long long)(age / 3600)); + if (age < 0) age = 0; + if (age < 60) return strprintf("%llds", (long long)age); + if (age < 3600) return strprintf("%lldm", (long long)(age / 60)); + return strprintf("%lldh", (long long)(age / 3600)); } -static ImVec4 ParticipantStatusColor(const std::string& status) +static ImVec4 StatusColor(const std::string& s) { - if (status.find("failed") != std::string::npos || - status.find("rejected") != std::string::npos || - status.find("stopped") != std::string::npos) - return ImVec4(1.0f, 0.38f, 0.38f, 1.0f); - if (status.find("hashing") != std::string::npos || - status.find("accepted") != std::string::npos || - status.find("connected") != std::string::npos) - return ImVec4(0.35f, 1.0f, 0.45f, 1.0f); - if (status.find("asking") != std::string::npos || - status.find("authoriz") != std::string::npos || - status.find("subscrib") != std::string::npos) - return ImVec4(1.0f, 0.75f, 0.25f, 1.0f); - return ImVec4(0.55f, 0.80f, 1.0f, 1.0f); + if (s.find("failed") != std::string::npos || + s.find("rejected") != std::string::npos || + s.find("stopped") != std::string::npos) + return ImVec4(1.0f, 0.38f, 0.38f, 1.0f); // red + if (s.find("hashing") != std::string::npos || + s.find("accepted") != std::string::npos || + s.find("connected")!= std::string::npos) + return ImVec4(0.35f, 1.0f, 0.45f, 1.0f); // green + if (s.find("asking") != std::string::npos || + s.find("authoriz") != std::string::npos || + s.find("subscrib") != std::string::npos) + return ImVec4(1.0f, 0.75f, 0.25f, 1.0f); // amber + return ImVec4(0.55f, 0.80f, 1.0f, 1.0f); // blue-grey } static std::string PoolLabel(const BtfPoolAnnouncement& ann) { std::ostringstream o; - o << ann.poolName << " - " << std::fixed << std::setprecision(2) << ann.feePercent << "%"; + o << ann.poolName << " - " + << std::fixed << std::setprecision(2) << ann.feePercent << "%"; if (ann.connectedMiners > 0) o << " - " << ann.connectedMiners << " miners"; return o.str(); @@ -118,19 +122,47 @@ static void RefreshWallet() g_txRows.clear(); TRY_CRITICAL_BLOCK(cs_mapWallet) { - std::vector> vs; - for (auto& kv : mapWallet) vs.push_back({kv.second.GetTxTime(), kv.first}); - std::sort(vs.rbegin(), vs.rend()); + // Sort: unconfirmed first (depth 0), then by depth ascending (least + // deep = most recent). We can't use nTimeReceived because a node + // resync re-stamps all txs with the same time. Depth is always correct. + std::vector> vs; + for (auto& kv : mapWallet) + vs.push_back({kv.second.GetDepthInMainChain(), kv.second.GetHash()}); + std::sort(vs.begin(), vs.end(), + [](const std::pair& a, const std::pair& b) { + return a.first < b.first; // 0 (unconfirmed) first, then shallowest + }); for (auto& sv : vs) { auto mi = mapWallet.find(sv.second); if (mi == mapWallet.end()) continue; CWalletTx& wtx = mi->second; - int64 net = wtx.GetCredit() - wtx.GetDebit(); - int depth = wtx.GetDepthInMainChain(); + int depth = wtx.GetDepthInMainChain(); + int toMature = wtx.IsCoinBase() ? wtx.GetBlocksToMaturity() : 0; + // An orphaned coinbase has hashBlock set but depth=0 (block not in main chain). + // A genuinely unconfirmed tx has hashBlock==0. + bool isOrphaned = wtx.IsCoinBase() && wtx.hashBlock != 0 && depth == 0; + int64 net; std::string desc; - if (wtx.IsCoinBase()) desc = depth<1 ? "Generated (unconfirmed)" : "Generated"; - else if (wtx.GetDebit() > 0) desc = "Sent"; - else desc = "Received"; + if (wtx.IsCoinBase()) { + if (isOrphaned) { + net = 0; + desc = "Generated (orphaned -- block not in main chain)"; + } else if (toMature > 0) { + net = wtx.GetValueOut(); + char buf[64]; + snprintf(buf, sizeof(buf), "Generated (matures in %d blocks)", toMature); + desc = buf; + } else { + net = wtx.GetCredit() - wtx.GetDebit(); + desc = depth < 1 ? "Generated (unconfirmed)" : "Generated"; + } + } else if (wtx.GetDebit() > 0) { + net = wtx.GetCredit() - wtx.GetDebit(); + desc = "Sent"; + } else { + net = wtx.GetCredit() - wtx.GetDebit(); + desc = "Received"; + } g_txRows.push_back({DateTimeStr(wtx.GetTxTime()), desc, net, depth}); } } @@ -148,19 +180,104 @@ string DateTimeStr(int64 nTime) return buf; } +// --------------------------------------------------------------------------- +// Status bar -- separate fullscreen-width overlay pinned to bottom edge. +// Drawn last so it always sits on top of everything else. +// --------------------------------------------------------------------------- +static void DrawStatusBar() +{ + ImGuiIO& io = ImGui::GetIO(); + ImGui::SetNextWindowPos(ImVec2(0.0f, io.DisplaySize.y - STATUS_BAR_H)); + ImGui::SetNextWindowSize(ImVec2(io.DisplaySize.x, STATUS_BAR_H)); + ImGui::SetNextWindowBgAlpha(1.0f); + ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding, ImVec2(10.0f, 3.0f)); + ImGui::PushStyleVar(ImGuiStyleVar_WindowRounding, 0.0f); + ImGui::PushStyleVar(ImGuiStyleVar_WindowBorderSize, 0.0f); + ImGui::PushStyleColor(ImGuiCol_WindowBg, ImVec4(0.13f, 0.13f, 0.13f, 1.0f)); + ImGui::Begin("##statusbar", nullptr, + ImGuiWindowFlags_NoTitleBar | + ImGuiWindowFlags_NoResize | + ImGuiWindowFlags_NoMove | + ImGuiWindowFlags_NoScrollbar | + ImGuiWindowFlags_NoScrollWithMouse| + ImGuiWindowFlags_NoSavedSettings | + ImGuiWindowFlags_NoBringToFrontOnFocus); + ImGui::PopStyleVar(3); + ImGui::PopStyleColor(); + + int connected = (int)vNodes.size(); + int discovered = GetDiscoveredPeerCount(); + + if (nMineMode == MINE_PARTICIPANT) { + uint64 sent = 0, accepted = 0; + double hashRate = 0.0; + std::string st = GetParticipantMiningStatus(); + GetParticipantMiningStats(sent, accepted, hashRate); + if (sent > 0 || accepted > 0 || hashRate > 0.0) + ImGui::TextDisabled( + "Connected peers: %d Detected peers: %d Height: %d" + " Shares: %llu sent / %llu accepted %.2f H/s", + connected, discovered, nBestHeight, + (unsigned long long)sent, (unsigned long long)accepted, hashRate); + else if (!st.empty()) + ImGui::TextDisabled( + "Connected peers: %d Detected peers: %d Height: %d %s", + connected, discovered, nBestHeight, st.c_str()); + else + ImGui::TextDisabled( + "Connected peers: %d Detected peers: %d Height: %d", + connected, discovered, nBestHeight); + } else if (nMineMode == MINE_OPERATOR) { + int miners = 0, blocksFound = 0; + uint64 roundShares = 0; + double totalHashRate = 0.0; + GetPoolOperatorStats(miners, blocksFound, roundShares, totalHashRate); + ImGui::TextDisabled( + "Connected peers: %d Detected peers: %d Height: %d" + " Miners: %d Round shares: %llu Blocks found: %d %.2f H/s", + connected, discovered, nBestHeight, + miners, (unsigned long long)roundShares, blocksFound, totalHashRate); + } else { + ImGui::TextDisabled( + "Connected peers: %d Detected peers: %d Height: %d", + connected, discovered, nBestHeight); + } + + ImGui::End(); +} + // --------------------------------------------------------------------------- // Main window +// +// Layout: +// [menu bar] -- ImGui built-in menu +// [header strip] -- address, balance, send button, mine status +// [separator] +// [telemetry strip] -- mode-specific live stats (never scrolls) +// [separator] +// [scrollable content child] -- transactions + mode panels +// +// The scrollable child ends at the top of the status bar so content never +// hides behind it. The child is explicitly sized to fill the remaining space. // --------------------------------------------------------------------------- static void DrawMainWindow() { ImGuiIO& io = ImGui::GetIO(); - ImGui::SetNextWindowPos(ImVec2(0,0)); - ImGui::SetNextWindowSize(io.DisplaySize); - ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding, ImVec2(12,12)); + + // Full display height minus the status bar at the bottom. + const float winH = io.DisplaySize.y - STATUS_BAR_H; + + ImGui::SetNextWindowPos(ImVec2(0.0f, 0.0f)); + ImGui::SetNextWindowSize(ImVec2(io.DisplaySize.x, winH)); + ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding, ImVec2(12.0f, 12.0f)); ImGui::Begin("##main", nullptr, - ImGuiWindowFlags_NoTitleBar | ImGuiWindowFlags_NoResize | - ImGuiWindowFlags_NoMove | ImGuiWindowFlags_MenuBar | - ImGuiWindowFlags_NoFocusOnAppearing); + ImGuiWindowFlags_NoTitleBar | + ImGuiWindowFlags_NoResize | + ImGuiWindowFlags_NoMove | + ImGuiWindowFlags_NoScrollbar | + ImGuiWindowFlags_NoScrollWithMouse | + ImGuiWindowFlags_MenuBar | + ImGuiWindowFlags_NoBringToFrontOnFocus); ImGui::SetWindowFontScale(0.92f); ImGui::PopStyleVar(); @@ -170,179 +287,209 @@ static void DrawMainWindow() if (ImGui::MenuItem("Exit")) glfwSetWindowShouldClose(glfwGetCurrentContext(), true); ImGui::EndMenu(); } - if (ImGui::MenuItem("Options")) g_showOptions = true; - if (ImGui::MenuItem("About")) g_showAbout = true; + if (ImGui::MenuItem("Options")) g_showOptions = true; + if (ImGui::MenuItem("About")) g_showAbout = true; ImGui::EndMenuBar(); } - // ---- Address bar ---- + // ---- Header strip (never scrolls) ---- ImGui::Spacing(); ImGui::TextDisabled("Address:"); ImGui::SameLine(); - ImGui::SetNextItemWidth(-80); - ImGui::PushStyleColor(ImGuiCol_FrameBg, ImVec4(0.14f,0.14f,0.14f,1)); + ImGui::SetNextItemWidth(-80.0f); + ImGui::PushStyleColor(ImGuiCol_FrameBg, ImVec4(0.14f, 0.14f, 0.14f, 1.0f)); ImGui::InputText("##addr", (char*)g_myAddress.c_str(), g_myAddress.size()+1, ImGuiInputTextFlags_ReadOnly); ImGui::PopStyleColor(); ImGui::SameLine(); if (ImGui::Button("Copy##a")) ImGui::SetClipboardText(g_myAddress.c_str()); - // ---- Balance on its own line ---- ImGui::TextDisabled("Balance:"); ImGui::SameLine(); - ImGui::PushStyleColor(ImGuiCol_Text, ImVec4(0.35f,1.0f,0.35f,1.0f)); + ImGui::PushStyleColor(ImGuiCol_Text, ImVec4(0.35f, 1.0f, 0.35f, 1.0f)); ImGui::Text("%s BTF", FmtMoney(g_balance).c_str()); ImGui::PopStyleColor(); - // ---- Toolbar ---- ImGui::Spacing(); - ImGui::PushStyleColor(ImGuiCol_Button, ImVec4(0.15f,0.40f,0.15f,1)); - ImGui::PushStyleColor(ImGuiCol_ButtonHovered, ImVec4(0.20f,0.55f,0.20f,1)); + ImGui::PushStyleColor(ImGuiCol_Button, ImVec4(0.15f, 0.40f, 0.15f, 1.0f)); + ImGui::PushStyleColor(ImGuiCol_ButtonHovered, ImVec4(0.20f, 0.55f, 0.20f, 1.0f)); if (ImGui::Button(" Send Coins ")) g_showSend = true; ImGui::PopStyleColor(2); - // Mining status indicator - ImGui::SameLine(0, 20); + ImGui::SameLine(0.0f, 20.0f); if (fGenerateBitcoins) { - ImGui::PushStyleColor(ImGuiCol_Text, ImVec4(1.0f,0.6f,0.0f,1.0f)); - const char* modeLabel = nMineMode==MINE_OPERATOR ? "Mining (operator)" : - nMineMode==MINE_PARTICIPANT ? "Mining (pool)" : "Mining"; - ImGui::Text("⬤ %s", modeLabel); + ImGui::PushStyleColor(ImGuiCol_Text, ImVec4(1.0f, 0.6f, 0.0f, 1.0f)); + const char* modeLabel = + nMineMode == MINE_OPERATOR ? "Mining (operator)" : + nMineMode == MINE_PARTICIPANT ? "Mining (pool)" : + nMineMode == MINE_RELAY ? "Relay" : "Mining"; + ImGui::Text("* %s", modeLabel); ImGui::PopStyleColor(); if (nMineMode == MINE_PARTICIPANT) { - std::string mineStatus = GetParticipantMiningStatus(); - if (!mineStatus.empty()) { - ImGui::SameLine(0, 12); - ImGui::PushStyleColor(ImGuiCol_Text, ParticipantStatusColor(mineStatus)); - ImGui::Text("[%s]", mineStatus.c_str()); + std::string st = GetParticipantMiningStatus(); + if (!st.empty()) { + ImGui::SameLine(0.0f, 12.0f); + ImGui::PushStyleColor(ImGuiCol_Text, StatusColor(st)); + ImGui::Text("[%s]", st.c_str()); ImGui::PopStyleColor(); } } } else { - ImGui::TextDisabled("○ Not mining"); + ImGui::TextDisabled("Not mining"); } ImGui::Spacing(); ImGui::Separator(); - ImGui::Spacing(); - // ---- Live telemetry strip ---- + // ---- Telemetry strip (never scrolls) ---- { - const char* modeName = nMineMode==MINE_OPERATOR ? "Operator" : - nMineMode==MINE_PARTICIPANT ? "Participant" : "Solo"; - int64 age = g_lastWalletRefresh > 0 ? (GetTime() - g_lastWalletRefresh) : -1; - if (age < 0) age = 0; + ImGui::Spacing(); + const char* modeName = + nMineMode == MINE_OPERATOR ? "Operator" : + nMineMode == MINE_PARTICIPANT ? "Participant" : + nMineMode == MINE_RELAY ? "Relay" : "Solo"; + int64 age = g_lastWalletRefresh > 0 ? GetTime() - g_lastWalletRefresh : 0; ImGui::Text("Mode: %s", modeName); - ImGui::SameLine(0, 16); - ImGui::Text("Wallet refresh: %llds ago", (long long)age); + ImGui::SameLine(0.0f, 16.0f); + ImGui::TextDisabled("Wallet refreshed %llds ago", (long long)age); if (nMineMode == MINE_PARTICIPANT) { uint64 sent = 0, accepted = 0; double hashRate = 0.0; - std::string mineStatus = GetParticipantMiningStatus(); + std::string st = GetParticipantMiningStatus(); GetParticipantMiningStats(sent, accepted, hashRate); if (sent > 0 || accepted > 0 || hashRate > 0.0) { - double acceptPct = sent ? (100.0 * (double)accepted / (double)sent) : 0.0; - ImGui::TextDisabled("Your mining: sent %llu, accepted %llu (%.1f%%), %.2f H/s", - (unsigned long long)sent, - (unsigned long long)accepted, - acceptPct, - hashRate); - } else if (!mineStatus.empty()) { - ImGui::TextDisabled("Your mining: %s", mineStatus.c_str()); + double pct = sent ? (100.0 * (double)accepted / (double)sent) : 0.0; + ImGui::TextDisabled( + "Sent %llu shares, accepted %llu (%.1f%%), %.2f H/s", + (unsigned long long)sent, (unsigned long long)accepted, pct, hashRate); + } else if (!st.empty()) { + ImGui::TextDisabled("Status: %s", st.c_str()); } else { - ImGui::TextDisabled("Your mining: asking pool for work"); + ImGui::TextDisabled("Waiting for pool work..."); } } else if (nMineMode == MINE_OPERATOR) { int miners = 0, blocksFound = 0; uint64 roundShares = 0; - GetPoolOperatorStats(miners, blocksFound, roundShares); + double totalHashRate = 0.0; + GetPoolOperatorStats(miners, blocksFound, roundShares, totalHashRate); if (miners > 0 || blocksFound > 0 || roundShares > 0) - ImGui::TextDisabled("Pool runtime: %d authorized miners, %llu round shares, %d blocks found", miners, (unsigned long long)roundShares, blocksFound); + ImGui::TextDisabled( + "Authorized miners: %d Round shares: %llu Blocks found: %d %.2f H/s", + miners, (unsigned long long)roundShares, blocksFound, totalHashRate); else - ImGui::TextDisabled("Pool runtime: waiting for miners"); + ImGui::TextDisabled("Pool server running, waiting for miners"); + } else if (nMineMode == MINE_RELAY) { + ImGui::TextDisabled("Relay mode: syncing, not mining"); } else { - ImGui::TextDisabled("Solo runtime: local wallet mining telemetry"); + ImGui::TextDisabled("Solo mining"); } ImGui::Spacing(); ImGui::Separator(); ImGui::Spacing(); } + // ---- Scrollable content child ---- + // Fills all remaining height in the window (cursor Y to bottom of window). + // Content that overflows scrolls; nothing leaks past the window edge. + // Size the scrollable child to fill exactly the remaining window height. + // GetContentRegionAvail() already accounts for padding/borders. + const float availH = ImGui::GetContentRegionAvail().y; + ImGui::BeginChild("##scroll", ImVec2(0.0f, availH), false, 0); + // ---- Transaction list ---- - float listHeight = io.DisplaySize.y * 0.26f; - if (listHeight < 190.0f) listHeight = 190.0f; - if (listHeight > 260.0f) listHeight = 260.0f; + float txH = io.DisplaySize.y * 0.26f; + if (txH < 180.0f) txH = 180.0f; + if (txH > 260.0f) txH = 260.0f; + if (ImGui::BeginTable("txlist", 4, - ImGuiTableFlags_Borders | ImGuiTableFlags_RowBg | - ImGuiTableFlags_ScrollY | ImGuiTableFlags_SizingStretchProp, - ImVec2(0, listHeight))) + ImGuiTableFlags_Borders | + ImGuiTableFlags_RowBg | + ImGuiTableFlags_ScrollY | + ImGuiTableFlags_SizingStretchProp, + ImVec2(0.0f, txH))) { ImGui::TableSetupScrollFreeze(0, 1); - ImGui::TableSetupColumn("Status", ImGuiTableColumnFlags_WidthFixed, 110); - ImGui::TableSetupColumn("Date", ImGuiTableColumnFlags_WidthFixed, 130); + ImGui::TableSetupColumn("Status", ImGuiTableColumnFlags_WidthFixed, 110.0f); + ImGui::TableSetupColumn("Date", ImGuiTableColumnFlags_WidthFixed, 130.0f); ImGui::TableSetupColumn("Description", ImGuiTableColumnFlags_WidthStretch); - ImGui::TableSetupColumn("Amount", ImGuiTableColumnFlags_WidthFixed, 140); + ImGui::TableSetupColumn("Amount", ImGuiTableColumnFlags_WidthFixed, 140.0f); ImGui::TableHeadersRow(); for (auto& row : g_txRows) { ImGui::TableNextRow(); ImGui::TableSetColumnIndex(0); - if (row.depth < 1) - ImGui::TextColored(ImVec4(1,0.6f,0,1), "Unconfirmed"); + bool isImmatureCoinbase = (row.desc.find("matures in") != std::string::npos); + bool isOrphaned = (row.desc.find("orphaned") != std::string::npos); + if (isOrphaned) + ImGui::TextColored(ImVec4(1.0f, 0.3f, 0.3f, 1.0f), "Orphaned"); + else if (row.depth < 1) + ImGui::TextColored(ImVec4(1.0f, 0.6f, 0.0f, 1.0f), "Unconfirmed"); + else if (isImmatureCoinbase) + ImGui::TextColored(ImVec4(1.0f, 0.75f, 0.25f, 1.0f), "Immature"); + else if (row.depth < 6) + ImGui::TextColored(ImVec4(1.0f, 0.85f, 0.3f, 1.0f), "%d confirm%s", + row.depth, row.depth == 1 ? "" : "s"); else - ImGui::Text("%d blocks deep", row.depth); + ImGui::TextColored(ImVec4(0.5f, 0.9f, 0.5f, 1.0f), "Confirmed"); ImGui::TableSetColumnIndex(1); ImGui::TextDisabled("%s", row.date.c_str()); ImGui::TableSetColumnIndex(2); ImGui::TextUnformatted(row.desc.c_str()); ImGui::TableSetColumnIndex(3); if (row.amount >= 0) - ImGui::TextColored(ImVec4(0.35f,1,0.35f,1), "+%s BTF", FmtMoney(row.amount).c_str()); + ImGui::TextColored(ImVec4(0.35f, 1.0f, 0.35f, 1.0f), + "+%s BTF", FmtMoney(row.amount).c_str()); else - ImGui::TextColored(ImVec4(1,0.4f,0.4f,1), "%s BTF", FmtMoney(row.amount).c_str()); + ImGui::TextColored(ImVec4(1.0f, 0.4f, 0.4f, 1.0f), + "%s BTF", FmtMoney(row.amount).c_str()); } ImGui::EndTable(); } + // ---- Mode panels ---- std::vector pools; BtfGetPoolAnnouncements(pools); if (nMineMode == MINE_PARTICIPANT) { ImGui::Spacing(); ImGui::SeparatorText("Participant Mining"); - ImGui::Text("Selected pool:"); + + ImGui::TextDisabled("Selected pool:"); + ImGui::SameLine(); if (strParticipantPool.empty()) - ImGui::TextDisabled("No pool selected yet"); + ImGui::TextDisabled("none"); else ImGui::TextWrapped("%s", strParticipantPool.c_str()); - const BtfPoolAnnouncement* selected = NULL; + const BtfPoolAnnouncement* sel = nullptr; for (size_t i = 0; i < pools.size(); i++) - if (pools[i].btfAddress == strParticipantPool) { selected = &pools[i]; break; } - - if (selected) { - ImGui::Text("Pool name: %s", selected->poolName.c_str()); - ImGui::Text("Fee: %.2f%%", selected->feePercent); - ImGui::Text("Live stats: %d miners, %d blocks", selected->connectedMiners, selected->blocksFound); - if (!selected->dashboardUrl.empty()) - ImGui::TextWrapped("Dashboard: %s", selected->dashboardUrl.c_str()); + if (pools[i].btfAddress == strParticipantPool) { sel = &pools[i]; break; } + + if (sel) { + ImGui::Text("Pool: %s Fee: %.2f%% Miners: %d Blocks: %d %.2f H/s", + sel->poolName.c_str(), sel->feePercent, + sel->connectedMiners, sel->blocksFound, sel->hashRate); + if (!sel->dashboardUrl.empty()) + ImGui::TextDisabled("Dashboard: %s", sel->dashboardUrl.c_str()); } else if (!strParticipantPool.empty()) { - ImGui::TextDisabled("No fresh announcement found for this pool yet"); + ImGui::TextDisabled("No live announcement for this pool yet."); } if (!pools.empty()) { ImGui::Spacing(); ImGui::SeparatorText("Live Pools"); if (ImGui::BeginTable("livepools", 5, - ImGuiTableFlags_Borders | ImGuiTableFlags_RowBg | ImGuiTableFlags_SizingStretchProp)) + ImGuiTableFlags_Borders | + ImGuiTableFlags_RowBg | + ImGuiTableFlags_SizingStretchProp)) { - ImGui::TableSetupColumn("Pool", ImGuiTableColumnFlags_WidthStretch); - ImGui::TableSetupColumn("Fee", ImGuiTableColumnFlags_WidthFixed, 70); - ImGui::TableSetupColumn("Stats", ImGuiTableColumnFlags_WidthFixed, 160); + ImGui::TableSetupColumn("Pool", ImGuiTableColumnFlags_WidthStretch); + ImGui::TableSetupColumn("Fee", ImGuiTableColumnFlags_WidthFixed, 60.0f); + ImGui::TableSetupColumn("Stats", ImGuiTableColumnFlags_WidthFixed, 150.0f); ImGui::TableSetupColumn("Dashboard", ImGuiTableColumnFlags_WidthStretch); - ImGui::TableSetupColumn("Use", ImGuiTableColumnFlags_WidthFixed, 60); + ImGui::TableSetupColumn("Use", ImGuiTableColumnFlags_WidthFixed, 50.0f); ImGui::TableHeadersRow(); for (size_t i = 0; i < pools.size(); i++) { const BtfPoolAnnouncement& ann = pools[i]; @@ -353,15 +500,19 @@ static void DrawMainWindow() ImGui::TableSetColumnIndex(1); ImGui::Text("%.2f%%", ann.feePercent); ImGui::TableSetColumnIndex(2); - ImGui::Text("%d miners, %d blocks, %s old", ann.connectedMiners, ann.blocksFound, FmtAge(ann.createdAt).c_str()); + ImGui::Text("%d miners %d blocks %.2f H/s %s", + ann.connectedMiners, ann.blocksFound, ann.hashRate, + FmtAge(ann.createdAt).c_str()); ImGui::TableSetColumnIndex(3); if (!ann.dashboardUrl.empty()) ImGui::TextWrapped("%s", ann.dashboardUrl.c_str()); else ImGui::TextDisabled("-"); ImGui::TableSetColumnIndex(4); - if (ImGui::SmallButton((std::string("Use##") + ann.btfAddress).c_str())) { - strncpy(g_participantPool, ann.btfAddress.c_str(), sizeof(g_participantPool)-1); + std::string btnId = "Use##" + ann.btfAddress; + if (ImGui::SmallButton(btnId.c_str())) { + strncpy(g_participantPool, ann.btfAddress.c_str(), + sizeof(g_participantPool)-1); strParticipantPool = ann.btfAddress; CWalletDB().WriteSetting("strParticipantPool", strParticipantPool); g_needRefresh = true; @@ -375,55 +526,60 @@ static void DrawMainWindow() if (nMineMode == MINE_OPERATOR) { ImGui::Spacing(); ImGui::SeparatorText("Operator Overview"); - ImGui::Text("Pool .btf address:"); + std::string opAddr = BtfLocalAddress(); + ImGui::TextDisabled("Pool .btf address:"); + ImGui::SameLine(); if (opAddr.empty()) - ImGui::TextDisabled("Identity not ready yet"); + ImGui::TextDisabled("identity not ready"); else ImGui::TextWrapped("%s", opAddr.c_str()); + // Pending payouts std::vector owed; GetPendingPayouts(owed); ImGui::Spacing(); - ImGui::SeparatorText("Pending Pool Payouts (you owe miners)"); + ImGui::SeparatorText("Pending Payouts (owed to miners)"); if (owed.empty()) { - ImGui::TextDisabled("No pending pool payouts"); + ImGui::TextDisabled("No pending payouts"); } else if (ImGui::BeginTable("owedpayouts", 4, - ImGuiTableFlags_Borders | ImGuiTableFlags_RowBg | ImGuiTableFlags_SizingStretchProp)) + ImGuiTableFlags_Borders | + ImGuiTableFlags_RowBg | + ImGuiTableFlags_SizingStretchProp)) { - ImGui::TableSetupColumn("Matures At", ImGuiTableColumnFlags_WidthFixed, 100); - ImGui::TableSetupColumn("Blocks Left", ImGuiTableColumnFlags_WidthFixed, 90); - ImGui::TableSetupColumn("Recipients", ImGuiTableColumnFlags_WidthFixed, 90); - ImGui::TableSetupColumn("Total", ImGuiTableColumnFlags_WidthStretch); + ImGui::TableSetupColumn("Matures At", ImGuiTableColumnFlags_WidthFixed, 90.0f); + ImGui::TableSetupColumn("Blocks left", ImGuiTableColumnFlags_WidthFixed, 80.0f); + ImGui::TableSetupColumn("Recipients", ImGuiTableColumnFlags_WidthFixed, 80.0f); + ImGui::TableSetupColumn("Total", ImGuiTableColumnFlags_WidthStretch); ImGui::TableHeadersRow(); for (const PendingPayoutView& row : owed) { - int remaining = std::max(0, row.matureAtHeight - nBestHeight); + int left = std::max(0, row.matureAtHeight - nBestHeight); ImGui::TableNextRow(); - ImGui::TableSetColumnIndex(0); - ImGui::Text("%d", row.matureAtHeight); - ImGui::TableSetColumnIndex(1); - ImGui::Text("%d", remaining); - ImGui::TableSetColumnIndex(2); - ImGui::Text("%d", row.recipients); - ImGui::TableSetColumnIndex(3); - ImGui::Text("%s BTF", FmtMoney(row.totalAmount).c_str()); + ImGui::TableSetColumnIndex(0); ImGui::Text("%d", row.matureAtHeight); + ImGui::TableSetColumnIndex(1); ImGui::Text("%d", left); + ImGui::TableSetColumnIndex(2); ImGui::Text("%d", row.recipients); + ImGui::TableSetColumnIndex(3); ImGui::Text("%s BTF", FmtMoney(row.totalAmount).c_str()); } ImGui::EndTable(); } + // Workers std::vector workers; GetPoolWorkerStats(workers); ImGui::Spacing(); ImGui::SeparatorText("Current Round Workers"); if (workers.empty()) { - ImGui::TextDisabled("No active workers in this round"); - } else if (ImGui::BeginTable("workers", 4, - ImGuiTableFlags_Borders | ImGuiTableFlags_RowBg | ImGuiTableFlags_SizingStretchProp)) + ImGui::TextDisabled("No active workers this round"); + } else if (ImGui::BeginTable("workers", 5, + ImGuiTableFlags_Borders | + ImGuiTableFlags_RowBg | + ImGuiTableFlags_SizingStretchProp)) { - ImGui::TableSetupColumn("Payout Addr", ImGuiTableColumnFlags_WidthStretch); - ImGui::TableSetupColumn("Worker", ImGuiTableColumnFlags_WidthStretch); - ImGui::TableSetupColumn("Round Shares", ImGuiTableColumnFlags_WidthFixed, 90); - ImGui::TableSetupColumn("Last Seen", ImGuiTableColumnFlags_WidthFixed, 100); + ImGui::TableSetupColumn("Payout Address", ImGuiTableColumnFlags_WidthStretch); + ImGui::TableSetupColumn("Worker", ImGuiTableColumnFlags_WidthStretch); + ImGui::TableSetupColumn("Hashrate", ImGuiTableColumnFlags_WidthFixed, 90.0f); + ImGui::TableSetupColumn("Round shares", ImGuiTableColumnFlags_WidthFixed, 90.0f); + ImGui::TableSetupColumn("Last seen", ImGuiTableColumnFlags_WidthFixed, 90.0f); ImGui::TableHeadersRow(); for (const PoolWorkerStatView& row : workers) { ImGui::TableNextRow(); @@ -432,53 +588,19 @@ static void DrawMainWindow() ImGui::TableSetColumnIndex(1); ImGui::TextDisabled("%s", row.worker.empty() ? "worker" : row.worker.c_str()); ImGui::TableSetColumnIndex(2); - ImGui::Text("%llu", (unsigned long long)row.roundShares); + ImGui::Text("%.2f H/s", row.hashRate); ImGui::TableSetColumnIndex(3); - ImGui::TextDisabled("%s", FmtAge(row.lastSeen).c_str()); + ImGui::Text("%llu", (unsigned long long)row.roundShares); + ImGui::TableSetColumnIndex(4); + ImGui::TextDisabled("%s ago", FmtAge(row.lastSeen).c_str()); } ImGui::EndTable(); } } - // ---- Status bar ---- - { - ImGui::Spacing(); - ImGui::PushStyleVar(ImGuiStyleVar_ItemSpacing, ImVec2(4,2)); - - int peers = (int)vNodes.size(); - if (nMineMode == MINE_PARTICIPANT) { - uint64 sent = 0, accepted = 0; - double hashRate = 0.0; - std::string mineStatus = GetParticipantMiningStatus(); - GetParticipantMiningStats(sent, accepted, hashRate); - if (sent > 0 || accepted > 0 || hashRate > 0.0) - ImGui::TextDisabled("Peers %d height %d shares sent %llu accepted %llu hash rate %.2f H/s", - peers, - nBestHeight, - (unsigned long long)sent, - (unsigned long long)accepted, - hashRate); - else if (!mineStatus.empty()) - ImGui::TextDisabled("Peers %d height %d %s", peers, nBestHeight, mineStatus.c_str()); - else - ImGui::TextDisabled("Peers %d height %d asking pool for work", peers, nBestHeight); - } else if (nMineMode == MINE_OPERATOR) { - int miners = 0, blocksFound = 0; - uint64 roundShares = 0; - GetPoolOperatorStats(miners, blocksFound, roundShares); - if (miners > 0 || blocksFound > 0 || roundShares > 0) - ImGui::TextDisabled("Peers %d height %d authorized miners %d round shares %llu blocks found %d", - peers, nBestHeight, miners, (unsigned long long)roundShares, blocksFound); - else - ImGui::TextDisabled("Peers %d height %d waiting for miners", peers, nBestHeight); - } else { - ImGui::TextDisabled("Peers %d height %d", peers, nBestHeight); - } - ImGui::PopStyleVar(); - } - + ImGui::EndChild(); // ##scroll ImGui::SetWindowFontScale(1.0f); - ImGui::End(); + ImGui::End(); // ##main } // --------------------------------------------------------------------------- @@ -487,42 +609,53 @@ static void DrawMainWindow() static void DrawSendDialog() { if (!g_showSend) return; - ImGui::SetNextWindowSize(ImVec2(500, 180), ImGuiCond_Always); - ImGui::SetNextWindowPos(ImGui::GetMainViewport()->GetCenter(), ImGuiCond_Always, ImVec2(.5f,.5f)); + ImGui::SetNextWindowSize(ImVec2(500.0f, 180.0f), ImGuiCond_Always); + ImGui::SetNextWindowPos(ImGui::GetMainViewport()->GetCenter(), + ImGuiCond_Always, ImVec2(0.5f, 0.5f)); if (ImGui::Begin("Send Coins", &g_showSend, ImGuiWindowFlags_NoResize | ImGuiWindowFlags_NoCollapse)) { ImGui::Text("Recipient address:"); - ImGui::SetNextItemWidth(-1); + ImGui::SetNextItemWidth(-1.0f); ImGui::InputText("##sa", g_sendAddr, sizeof(g_sendAddr)); ImGui::Text("Amount (BTF):"); - ImGui::SetNextItemWidth(180); + ImGui::SetNextItemWidth(180.0f); ImGui::InputText("##sm", g_sendAmount, sizeof(g_sendAmount)); if (!g_sendStatus.empty()) { ImGui::Spacing(); - ImGui::TextColored(ImVec4(1,.4f,.4f,1), "%s", g_sendStatus.c_str()); + ImGui::TextColored(ImVec4(1.0f, 0.4f, 0.4f, 1.0f), + "%s", g_sendStatus.c_str()); } ImGui::Spacing(); ImGui::Separator(); ImGui::Spacing(); - if (ImGui::Button("Send", ImVec2(90,0))) { + if (ImGui::Button("Send", ImVec2(90.0f, 0.0f))) { uint160 h; int64 nv = 0; - if (!AddressToHash160(g_sendAddr, h)) g_sendStatus = "Invalid address."; - else if (!ParseMoney(g_sendAmount,nv)||nv<=0) g_sendStatus = "Invalid amount."; + if (!AddressToHash160(g_sendAddr, h)) + g_sendStatus = "Invalid address."; + else if (!ParseMoney(g_sendAmount, nv) || nv <= 0) + g_sendStatus = "Invalid amount."; else { - CScript s; s << OP_DUP << OP_HASH160 << h << OP_EQUALVERIFY << OP_CHECKSIG; + CScript sc; + sc << OP_DUP << OP_HASH160 << h << OP_EQUALVERIFY << OP_CHECKSIG; CWalletTx wtx; - if (SendMoney(s, nv, wtx)) { - g_showSend=false; g_sendStatus=""; - memset(g_sendAddr,0,sizeof(g_sendAddr)); - memset(g_sendAmount,0,sizeof(g_sendAmount)); + if (SendMoney(sc, nv, wtx)) { + g_showSend = false; + g_sendStatus = ""; + memset(g_sendAddr, 0, sizeof(g_sendAddr)); + memset(g_sendAmount, 0, sizeof(g_sendAmount)); g_needRefresh = true; - } else g_sendStatus = "Transaction failed."; + } else { + g_sendStatus = "Transaction failed."; + } } } ImGui::SameLine(); - if (ImGui::Button("Cancel", ImVec2(90,0))) { g_showSend=false; g_sendStatus=""; } + if (ImGui::Button("Cancel", ImVec2(90.0f, 0.0f))) { + g_showSend = false; + g_sendStatus = ""; + } } ImGui::End(); } @@ -534,7 +667,6 @@ static void DrawOptionsDialog() { if (!g_showOptions) return; - // Always sync from current globals when dialog opens static bool wasOpen = false; if (!wasOpen) { g_mineRadio = nMineMode; @@ -544,42 +676,46 @@ static void DrawOptionsDialog() strncpy(g_poolDash, strPoolDashboardUrl.c_str(), sizeof(g_poolDash)-1); } wasOpen = true; - ImGui::SetNextWindowSize(ImVec2(500, 310), ImGuiCond_Always); - ImGui::SetNextWindowPos(ImGui::GetMainViewport()->GetCenter(), ImGuiCond_Always, ImVec2(.5f,.5f)); + + ImGui::SetNextWindowSize(ImVec2(500.0f, 335.0f), ImGuiCond_Always); + ImGui::SetNextWindowPos(ImGui::GetMainViewport()->GetCenter(), + ImGuiCond_Always, ImVec2(0.5f, 0.5f)); if (ImGui::Begin("Options", &g_showOptions, ImGuiWindowFlags_NoResize | ImGuiWindowFlags_NoCollapse)) { // Transaction fee ImGui::SeparatorText("Transaction Fee"); static char feeStr[32] = {}; - if (feeStr[0]=='\0') snprintf(feeStr,sizeof(feeStr),"%s",FmtMoney(nTransactionFee).c_str()); - ImGui::SetNextItemWidth(180); + if (feeStr[0] == '\0') + snprintf(feeStr, sizeof(feeStr), "%s", FmtMoney(nTransactionFee).c_str()); + ImGui::SetNextItemWidth(180.0f); ImGui::InputText("BTF per transaction##fee", feeStr, sizeof(feeStr)); - // Mining + // Mining mode ImGui::SeparatorText("Mining Mode"); - ImGui::RadioButton("Solo — rewards go to your wallet", &g_mineRadio, MINE_SOLO); - ImGui::RadioButton("Operator — run a pool for other miners", &g_mineRadio, MINE_OPERATOR); - ImGui::RadioButton("Participant — mine to someone's pool", &g_mineRadio, MINE_PARTICIPANT); + ImGui::RadioButton("Relay -- just run the node, don't mine", &g_mineRadio, MINE_RELAY); + ImGui::RadioButton("Solo -- rewards go to your wallet", &g_mineRadio, MINE_SOLO); + ImGui::RadioButton("Operator -- run a pool for other miners", &g_mineRadio, MINE_OPERATOR); + ImGui::RadioButton("Participant -- mine to someone's pool", &g_mineRadio, MINE_PARTICIPANT); if (g_mineRadio == MINE_OPERATOR) { ImGui::Spacing(); ImGui::SeparatorText("Pool Announcement"); ImGui::Text("Pool name:"); - ImGui::SetNextItemWidth(-1); + ImGui::SetNextItemWidth(-1.0f); ImGui::InputText("##poolname", g_poolName, sizeof(g_poolName)); ImGui::Text("Fee %%:"); - ImGui::SetNextItemWidth(120); + ImGui::SetNextItemWidth(120.0f); ImGui::InputText("##poolfee", g_poolFee, sizeof(g_poolFee)); ImGui::Text("Dashboard URL:"); - ImGui::SetNextItemWidth(-1); + ImGui::SetNextItemWidth(-1.0f); ImGui::InputText("##pooldash", g_poolDash, sizeof(g_poolDash)); } if (g_mineRadio == MINE_PARTICIPANT) { ImGui::Spacing(); ImGui::SeparatorText("Pool Selection"); - ImGui::TextDisabled("Pick from live discovered pools. Manual entry is disabled here."); + ImGui::TextDisabled("Pick from live discovered pools."); std::vector pools; BtfGetPoolAnnouncements(pools); @@ -591,46 +727,49 @@ static void DrawOptionsDialog() break; } } - if (ImGui::BeginCombo("Discovered pools", current.c_str())) { + if (ImGui::BeginCombo("Discovered pools##combo", current.c_str())) { for (size_t i = 0; i < pools.size(); i++) { const BtfPoolAnnouncement& ann = pools[i]; - std::string label = PoolLabel(ann); - bool selected = ann.btfAddress == g_participantPool; - if (ImGui::Selectable(label.c_str(), selected)) { - strncpy(g_participantPool, ann.btfAddress.c_str(), sizeof(g_participantPool)-1); - } + bool selected = (ann.btfAddress == g_participantPool); + if (ImGui::Selectable(PoolLabel(ann).c_str(), selected)) + strncpy(g_participantPool, ann.btfAddress.c_str(), + sizeof(g_participantPool)-1); } ImGui::EndCombo(); } } else { - ImGui::TextDisabled("No live pools discovered yet"); + ImGui::TextDisabled("No live pools discovered yet."); } } - ImGui::Spacing(); - const char* genLabel = fGenerateBitcoins ? "Stop Mining" : "Start Mining"; - if (ImGui::Button(genLabel, ImVec2(130,0))) { - fGenerateBitcoins = fGenerateBitcoins ? 0 : 1; - if (fGenerateBitcoins) _beginthread(ThreadBitcoinMiner, 0, NULL); - CWalletDB().WriteSetting("fGenerateBitcoins", fGenerateBitcoins); - } - ImGui::Spacing(); ImGui::Separator(); ImGui::Spacing(); - if (ImGui::Button("OK", ImVec2(90,0))) { - int64 fee=0; - if (ParseMoney(feeStr,fee)) { nTransactionFee=fee; CWalletDB().WriteSetting("nTransactionFee",nTransactionFee); } - - bool changed = (g_mineRadio != nMineMode); - nMineMode = g_mineRadio; - strParticipantPool = g_participantPool; - strPoolName = g_poolName[0] ? g_poolName : "Bitflash Pool"; - strPoolDashboardUrl = g_poolDash; - dPoolFeePercent = atof(g_poolFee); - CWalletDB().WriteSetting("nMineMode", nMineMode); - CWalletDB().WriteSetting("strParticipantPool", strParticipantPool); - CWalletDB().WriteSetting("strPoolName", strPoolName); - CWalletDB().WriteSetting("strPoolDashboardUrl", strPoolDashboardUrl); - CWalletDB().WriteSetting("dPoolFeePercent", dPoolFeePercent); + if (ImGui::Button("OK", ImVec2(90.0f, 0.0f))) { + int64 fee = 0; + if (ParseMoney(feeStr, fee)) { + nTransactionFee = fee; + CWalletDB().WriteSetting("nTransactionFee", nTransactionFee); + } + + bool poolChanged = + nMineMode != g_mineRadio || + strPoolName != (g_poolName[0] ? g_poolName : "Bitflash Pool") || + strPoolDashboardUrl != g_poolDash || + dPoolFeePercent != atof(g_poolFee); + + nMineMode = g_mineRadio; + strParticipantPool = g_participantPool; + strPoolName = g_poolName[0] ? g_poolName : "Bitflash Pool"; + strPoolDashboardUrl = g_poolDash; + dPoolFeePercent = atof(g_poolFee); + + if (nMineMode == MINE_OPERATOR && poolChanged) gAnnounceNow = true; + + if (nMineMode == MINE_RELAY) { + fGenerateBitcoins = 0; + } else { + fGenerateBitcoins = 1; + if (!vfThreadRunning[3]) _beginthread(ThreadBitcoinMiner, 0, NULL); + } if (nMineMode == MINE_OPERATOR) { if (!gPoolServerRunning) { @@ -645,7 +784,7 @@ static void DrawOptionsDialog() g_showOptions = false; } ImGui::SameLine(); - if (ImGui::Button("Cancel", ImVec2(90,0))) g_showOptions = false; + if (ImGui::Button("Cancel", ImVec2(90.0f, 0.0f))) g_showOptions = false; } if (!g_showOptions) wasOpen = false; ImGui::End(); @@ -657,18 +796,21 @@ static void DrawOptionsDialog() static void DrawAboutDialog() { if (!g_showAbout) return; - ImGui::SetNextWindowSize(ImVec2(380, 170), ImGuiCond_Always); - ImGui::SetNextWindowPos(ImGui::GetMainViewport()->GetCenter(), ImGuiCond_Always, ImVec2(.5f,.5f)); + ImGui::SetNextWindowSize(ImVec2(380.0f, 170.0f), ImGuiCond_Always); + ImGui::SetNextWindowPos(ImGui::GetMainViewport()->GetCenter(), + ImGuiCond_Always, ImVec2(0.5f, 0.5f)); if (ImGui::Begin("About Bitflash", &g_showAbout, ImGuiWindowFlags_NoResize | ImGuiWindowFlags_NoCollapse)) { - ImGui::TextColored(ImVec4(1,.6f,0,1), "Bitflash BTF v1.1.0"); + ImGui::TextColored(ImVec4(1.0f, 0.6f, 0.0f, 1.0f), "Bitflash BTF v1.1.0"); ImGui::Spacing(); - ImGui::TextWrapped("CPU-only cryptocurrency. RandomX proof of work. Anonymous .btf addressing over Nostr. No premine."); + ImGui::TextWrapped( + "CPU-only cryptocurrency. RandomX proof of work. " + "Anonymous .btf addressing over Nostr. No premine."); ImGui::Spacing(); ImGui::TextDisabled("Based on Bitcoin 0.1.0 (Satoshi Nakamoto, 2009)"); ImGui::Spacing(); - if (ImGui::Button("OK", ImVec2(90,0))) g_showAbout = false; + if (ImGui::Button("OK", ImVec2(90.0f, 0.0f))) g_showAbout = false; } ImGui::End(); } @@ -705,34 +847,33 @@ int RunGUI(int argc, char* argv[]) ImGuiIO& io = ImGui::GetIO(); io.IniFilename = nullptr; - // Load Roboto at 16px — clean, readable on all platforms io.Fonts->AddFontFromMemoryCompressedTTF( - RobotoMedium_compressed_data, RobotoMedium_compressed_size, 16.0f); + RobotoMedium_compressed_data, RobotoMedium_compressed_size, 14.0f); ImGui::StyleColorsDark(); - ImGuiStyle& s = ImGui::GetStyle(); - s.WindowRounding = s.FrameRounding = s.PopupRounding = 4.0f; - s.ItemSpacing = ImVec2(10, 7); - s.FramePadding = ImVec2(8, 5); - s.WindowPadding = ImVec2(12, 12); + ImGuiStyle& st = ImGui::GetStyle(); + st.WindowRounding = st.FrameRounding = st.PopupRounding = 4.0f; + st.ItemSpacing = ImVec2(10.0f, 7.0f); + st.FramePadding = ImVec2(8.0f, 5.0f); + st.WindowPadding = ImVec2(12.0f, 12.0f); // Orange accent - s.Colors[ImGuiCol_TitleBgActive] = ImVec4(.80f,.45f,.00f,1); - s.Colors[ImGuiCol_CheckMark] = ImVec4(1.0f,.60f,.00f,1); - s.Colors[ImGuiCol_SliderGrab] = ImVec4(.80f,.45f,.00f,1); - s.Colors[ImGuiCol_Button] = ImVec4(.25f,.25f,.25f,1); - s.Colors[ImGuiCol_ButtonHovered] = ImVec4(.80f,.45f,.00f,1); - s.Colors[ImGuiCol_ButtonActive] = ImVec4(.60f,.30f,.00f,1); - s.Colors[ImGuiCol_Header] = ImVec4(.80f,.45f,.00f,.40f); - s.Colors[ImGuiCol_HeaderHovered] = ImVec4(.80f,.45f,.20f,.80f); - s.Colors[ImGuiCol_Tab] = ImVec4(.20f,.20f,.20f,1); - s.Colors[ImGuiCol_TabHovered] = ImVec4(.80f,.45f,.00f,1); - s.Colors[ImGuiCol_TabActive] = ImVec4(.80f,.45f,.00f,1); - s.Colors[ImGuiCol_SeparatorHovered]=ImVec4(.80f,.45f,.00f,1); - s.Colors[ImGuiCol_FrameBg] = ImVec4(.14f,.14f,.14f,1); - s.Colors[ImGuiCol_FrameBgHovered] = ImVec4(.20f,.20f,.20f,1); - s.Colors[ImGuiCol_TableHeaderBg] = ImVec4(.18f,.18f,.18f,1); - s.Colors[ImGuiCol_TableRowBg] = ImVec4(.11f,.11f,.11f,1); - s.Colors[ImGuiCol_TableRowBgAlt] = ImVec4(.14f,.14f,.14f,1); + st.Colors[ImGuiCol_TitleBgActive] = ImVec4(.80f, .45f, .00f, 1.0f); + st.Colors[ImGuiCol_CheckMark] = ImVec4(1.0f, .60f, .00f, 1.0f); + st.Colors[ImGuiCol_SliderGrab] = ImVec4(.80f, .45f, .00f, 1.0f); + st.Colors[ImGuiCol_Button] = ImVec4(.25f, .25f, .25f, 1.0f); + st.Colors[ImGuiCol_ButtonHovered] = ImVec4(.80f, .45f, .00f, 1.0f); + st.Colors[ImGuiCol_ButtonActive] = ImVec4(.60f, .30f, .00f, 1.0f); + st.Colors[ImGuiCol_Header] = ImVec4(.80f, .45f, .00f, .40f); + st.Colors[ImGuiCol_HeaderHovered] = ImVec4(.80f, .45f, .20f, .80f); + st.Colors[ImGuiCol_Tab] = ImVec4(.20f, .20f, .20f, 1.0f); + st.Colors[ImGuiCol_TabHovered] = ImVec4(.80f, .45f, .00f, 1.0f); + st.Colors[ImGuiCol_TabActive] = ImVec4(.80f, .45f, .00f, 1.0f); + st.Colors[ImGuiCol_SeparatorHovered]= ImVec4(.80f, .45f, .00f, 1.0f); + st.Colors[ImGuiCol_FrameBg] = ImVec4(.14f, .14f, .14f, 1.0f); + st.Colors[ImGuiCol_FrameBgHovered] = ImVec4(.20f, .20f, .20f, 1.0f); + st.Colors[ImGuiCol_TableHeaderBg] = ImVec4(.18f, .18f, .18f, 1.0f); + st.Colors[ImGuiCol_TableRowBg] = ImVec4(.11f, .11f, .11f, 1.0f); + st.Colors[ImGuiCol_TableRowBgAlt] = ImVec4(.14f, .14f, .14f, 1.0f); ImGui_ImplGlfw_InitForOpenGL(window, true); ImGui_ImplOpenGL3_Init("#version 330"); @@ -744,22 +885,28 @@ int RunGUI(int argc, char* argv[]) while (!glfwWindowShouldClose(window) && !fShutdown) { glfwPollEvents(); - if (g_needRefresh || (frame % 120 == 0)) { RefreshWallet(); g_needRefresh = false; } + if (g_needRefresh || (frame % 120 == 0)) { + RefreshWallet(); + g_needRefresh = false; + } frame++; ImGui_ImplOpenGL3_NewFrame(); ImGui_ImplGlfw_NewFrame(); ImGui::NewFrame(); + // Draw order matters: status bar last so it is always on top. DrawMainWindow(); DrawSendDialog(); DrawOptionsDialog(); DrawAboutDialog(); + DrawStatusBar(); ImGui::Render(); - int w, h; glfwGetFramebufferSize(window, &w, &h); + int w, h; + glfwGetFramebufferSize(window, &w, &h); glViewport(0, 0, w, h); - glClearColor(.08f,.08f,.08f,1); + glClearColor(.08f, .08f, .08f, 1.0f); glClear(GL_COLOR_BUFFER_BIT); ImGui_ImplOpenGL3_RenderDrawData(ImGui::GetDrawData()); glfwSwapBuffers(window); diff --git a/src/headers.h b/src/headers.h index bb252c7..367e9b0 100644 --- a/src/headers.h +++ b/src/headers.h @@ -1,4 +1,4 @@ -// Bitflash — main include file. +// Bitflash -- main include file. // Includes core node headers. GUI is handled separately in gui.cpp. #pragma once #include "headers_core.h" diff --git a/src/headers_core.h b/src/headers_core.h index 089b67f..6c0c602 100644 --- a/src/headers_core.h +++ b/src/headers_core.h @@ -1,4 +1,4 @@ -// Core node headers — no GUI framework dependency. +// Core node headers -- no GUI framework dependency. // Included by gui.cpp and all non-GUI source files. #ifdef _MSC_VER @@ -63,12 +63,11 @@ inline typename std::common_type::type max(const T1& a, const T2& b) #include "script.h" #include "db.h" #include "net.h" -#include "irc.h" #include "nostr.h" #include "randomx_pow.h" #include "main.h" #include "market.h" -// GUI callback — implemented in gui.cpp (or as no-op in headless builds) +// GUI callback -- implemented in gui.cpp (or as no-op in headless builds) void MainFrameRepaint(); string DateTimeStr(int64 nTime); diff --git a/src/irc.cpp b/src/irc.cpp deleted file mode 100644 index ff82c66..0000000 --- a/src/irc.cpp +++ /dev/null @@ -1,271 +0,0 @@ -// Copyright (c) 2009 Satoshi Nakamoto -// Distributed under the MIT/X11 software license, see the accompanying -// file license.txt or http://www.opensource.org/licenses/mit-license.php. - -#include "headers.h" - - - - -#pragma pack(1) -struct ircaddr -{ - int ip; - short port; -}; - -string EncodeAddress(const CAddress& addr) -{ - struct ircaddr tmp; - tmp.ip = addr.ip; - tmp.port = addr.port; - - vector vch(UBEGIN(tmp), UEND(tmp)); - return string("u") + EncodeBase58Check(vch); -} - -bool DecodeAddress(string str, CAddress& addr) -{ - vector vch; - if (!DecodeBase58Check(str.substr(1), vch)) - return false; - - struct ircaddr tmp; - if (vch.size() != sizeof(tmp)) - return false; - memcpy(&tmp, &vch[0], sizeof(tmp)); - - addr = CAddress(tmp.ip, tmp.port); - return true; -} - - - - - - -static bool Send(SOCKET hSocket, const char* pszSend) -{ - if (strstr(pszSend, "PONG") != pszSend) - printf("SENDING: %s\n", pszSend); - const char* psz = pszSend; - const char* pszEnd = psz + strlen(psz); - while (psz < pszEnd) - { - int ret = send(hSocket, psz, pszEnd - psz, 0); - if (ret < 0) - return false; - psz += ret; - } - return true; -} - -bool RecvLine(SOCKET hSocket, string& strLine) -{ - strLine = ""; - loop - { - char c; - int nBytes = recv(hSocket, &c, 1, 0); - if (nBytes > 0) - { - if (c == '\n') - continue; - if (c == '\r') - return true; - strLine += c; - } - else if (nBytes <= 0) - { - if (!strLine.empty()) - return true; - // socket closed - printf("IRC socket closed\n"); - return false; - } - else - { - // socket error - int nErr = WSAGetLastError(); - if (nErr != WSAEMSGSIZE && nErr != WSAEINTR && nErr != WSAEINPROGRESS) - { - printf("IRC recv failed: %d\n", nErr); - return false; - } - } - } -} - -bool RecvLineIRC(SOCKET hSocket, string& strLine) -{ - loop - { - bool fRet = RecvLine(hSocket, strLine); - if (fRet) - { - if (fShutdown) - return false; - vector vWords; - ParseString(strLine, ' ', vWords); - if (vWords[0] == "PING") - { - strLine[1] = 'O'; - strLine += '\r'; - Send(hSocket, strLine.c_str()); - continue; - } - } - return fRet; - } -} - -bool RecvUntil(SOCKET hSocket, const char* psz1, const char* psz2=NULL, const char* psz3=NULL) -{ - loop - { - string strLine; - if (!RecvLineIRC(hSocket, strLine)) - return false; - printf("IRC %s\n", strLine.c_str()); - if (psz1 && strLine.find(psz1) != -1) - return true; - if (psz2 && strLine.find(psz2) != -1) - return true; - if (psz3 && strLine.find(psz3) != -1) - return true; - } -} - - - - -bool fRestartIRCSeed = false; - -void ThreadIRCSeed(void* parg) -{ - // IRC peer discovery disabled: the 2009 IRC network no longer exists (and - // what answers today crashes the parser). Replaced by Nostr relay - // discovery. - printf("ThreadIRCSeed: IRC peer discovery disabled (using Nostr)\n"); - return; - - loop - { - struct hostent* phostent = gethostbyname("chat.freenode.net"); - CAddress addrConnect(*(u_long*)phostent->h_addr_list[0], htons(6667)); - - SOCKET hSocket; - if (!ConnectSocket(addrConnect, hSocket)) - { - printf("IRC connect failed\n"); - return; - } - - if (!RecvUntil(hSocket, "Found your hostname", "using your IP address instead", "Couldn't look up your hostname")) - { - closesocket(hSocket); - return; - } - - string strMyName = EncodeAddress(addrLocalHost); - - if (!addrLocalHost.IsRoutable()) - strMyName = strprintf("x%u", GetRand(1000000000)); - - Send(hSocket, strprintf("NICK %s\r", strMyName.c_str()).c_str()); - Send(hSocket, strprintf("USER %s 8 * : %s\r", strMyName.c_str(), strMyName.c_str()).c_str()); - - if (!RecvUntil(hSocket, " 004 ")) - { - closesocket(hSocket); - return; - } - Sleep(500); - - Send(hSocket, "JOIN #bitcoin\r"); - Send(hSocket, "WHO #bitcoin\r"); - - while (!fRestartIRCSeed) - { - string strLine; - if (fShutdown || !RecvLineIRC(hSocket, strLine)) - { - closesocket(hSocket); - return; - } - if (strLine.empty() || strLine[0] != ':') - continue; - printf("IRC %s\n", strLine.c_str()); - - vector vWords; - ParseString(strLine, ' ', vWords); - if (vWords.size() < 2) - continue; - - char pszName[10000]; - pszName[0] = '\0'; - - if (vWords[1] == "352" && vWords.size() >= 8) - { - // index 7 is limited to 16 characters - // could get full length name at index 10, but would be different from join messages - strcpy(pszName, vWords[7].c_str()); - printf("GOT WHO: [%s] ", pszName); - } - - if (vWords[1] == "JOIN") - { - // :username!username@50000007.F000000B.90000002.IP JOIN :#channelname - strcpy(pszName, vWords[0].c_str() + 1); - if (strchr(pszName, '!')) - *strchr(pszName, '!') = '\0'; - printf("GOT JOIN: [%s] ", pszName); - } - - if (pszName[0] == 'u') - { - CAddress addr; - if (DecodeAddress(pszName, addr)) - { - CAddrDB addrdb; - if (AddAddress(addrdb, addr)) - printf("new "); - addr.print(); - } - else - { - printf("decode failed\n"); - } - } - } - - fRestartIRCSeed = false; - closesocket(hSocket); - } -} - - - - - - - - - - -#ifdef TEST -int main(int argc, char *argv[]) -{ - WSADATA wsadata; - if (WSAStartup(MAKEWORD(2,2), &wsadata) != NO_ERROR) - { - printf("Error at WSAStartup()\n"); - return false; - } - - ThreadIRCSeed(NULL); - - WSACleanup(); - return 0; -} -#endif diff --git a/src/irc.h b/src/irc.h deleted file mode 100644 index 1ae4ffc..0000000 --- a/src/irc.h +++ /dev/null @@ -1,7 +0,0 @@ -// Copyright (c) 2009 Satoshi Nakamoto -// Distributed under the MIT/X11 software license, see the accompanying -// file license.txt or http://www.opensource.org/licenses/mit-license.php. - -extern bool RecvLine(SOCKET hSocket, string& strLine); -extern void ThreadIRCSeed(void* parg); -extern bool fRestartIRCSeed; diff --git a/src/main.cpp b/src/main.cpp index f3de4d3..aff7bab 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -13,6 +13,7 @@ #include "sha.h" #include #include +#include #include "btfaddr.h" #include "btftunnel.h" #ifndef _WIN32 @@ -73,7 +74,7 @@ bool fSoloMineTest = false; // /solomine: mine without requiring a peer (local t int fGenerateBitcoins; int64 nTransactionFee = 0; CAddress addrIncoming; -int nMineMode = MINE_SOLO; +int nMineMode = MINE_RELAY; string strParticipantPool; // participant mode: pool .btf address string strPoolName = "Bitflash Pool"; string strPoolDashboardUrl; @@ -88,11 +89,10 @@ static std::string gParticipantStatus; void SetParticipantMiningStatus(const std::string& status) { + // Updates the GUI status string only. Logging is done at each call site + // with full context; duplicating it here just adds noise to the log. std::lock_guard lk(gParticipantStatusMutex); - if (gParticipantStatus == status) - return; gParticipantStatus = status; - printf("PoolParticipantMiner: status -> %s\n", status.c_str()); } std::string GetParticipantMiningStatus() @@ -159,7 +159,7 @@ bool AddToWallet(const CWalletTx& wtxIn) wtx.nTimeReceived = GetAdjustedTime(); //// debug print - printf("AddToWallet %s %s\n", wtxIn.GetHash().ToString().substr(0,6).c_str(), fInsertedNew ? "new" : "update"); + if (LogAcceptsCategory("net")) printf("AddToWallet %s %s\n", wtxIn.GetHash().ToString().substr(0,6).c_str(), fInsertedNew ? "new" : "update"); if (!fInsertedNew) { @@ -372,7 +372,7 @@ int CMerkleTx::SetMerkleBranch(const CBlock* pblock) { vMerkleBranch.clear(); nIndex = -1; - printf("ERROR: SetMerkleBranch() : couldn't find tx in block\n"); + if (LogAcceptsCategory("net")) printf("ERROR: SetMerkleBranch() : couldn't find tx in block\n"); return 0; } @@ -433,7 +433,7 @@ void CWalletTx::AddSupportingTransactions(CTxDB& txdb) } else { - printf("ERROR: AddSupportingTransactions() : unsupported transaction\n"); + if (LogAcceptsCategory("net")) printf("ERROR: AddSupportingTransactions() : unsupported transaction\n"); continue; } @@ -519,7 +519,7 @@ bool CTransaction::AcceptTransaction(CTxDB& txdb, bool fCheckInputs, bool* pfMis { if (ptxOld) { - printf("mapTransaction.erase(%s) replacing with new version\n", ptxOld->GetHash().ToString().c_str()); + if (LogAcceptsCategory("net")) printf("mapTransaction.erase(%s) replacing with new version\n", ptxOld->GetHash().ToString().c_str()); mapTransactions.erase(ptxOld->GetHash()); } AddToMemoryPool(); @@ -530,7 +530,7 @@ bool CTransaction::AcceptTransaction(CTxDB& txdb, bool fCheckInputs, bool* pfMis if (ptxOld) EraseFromWallet(ptxOld->GetHash()); - printf("AcceptTransaction(): accepted %s\n", hash.ToString().substr(0,6).c_str()); + if (LogAcceptsCategory("net")) printf("AcceptTransaction(): accepted %s\n", hash.ToString().substr(0,6).c_str()); return true; } @@ -669,7 +669,7 @@ void CWalletTx::RelayWalletTransaction(CTxDB& txdb) uint256 hash = GetHash(); if (!txdb.ContainsTx(hash)) { - printf("Relaying wtx %s\n", hash.ToString().substr(0,6).c_str()); + if (LogAcceptsCategory("net")) printf("Relaying wtx %s\n", hash.ToString().substr(0,6).c_str()); RelayMessage(CInv(MSG_TX, hash), (CTransaction)*this); } } @@ -683,7 +683,7 @@ void RelayWalletTransactions() nLastTime = GetTime(); // Rebroadcast any of our txes that aren't in a block yet - printf("RelayWalletTransactions()\n"); + if (LogAcceptsCategory("net")) printf("RelayWalletTransactions()\n"); CTxDB txdb("r"); CRITICAL_BLOCK(cs_mapWallet) { @@ -764,7 +764,7 @@ unsigned int GetNextWorkRequired(const CBlockIndex* pindexLast) // Limit adjustment step unsigned int nActualTimespan = pindexLast->nTime - pindexFirst->nTime; - printf(" nActualTimespan = %d before bounds\n", nActualTimespan); + if (LogAcceptsCategory("net")) printf(" nActualTimespan = %d before bounds\n", nActualTimespan); if (nActualTimespan < nTargetTimespan/4) nActualTimespan = nTargetTimespan/4; if (nActualTimespan > nTargetTimespan*4) @@ -780,10 +780,7 @@ unsigned int GetNextWorkRequired(const CBlockIndex* pindexLast) bnNew = bnProofOfWorkLimit; /// debug print - printf("\n\n\nGetNextWorkRequired RETARGET *****\n"); - printf("nTargetTimespan = %d nActualTimespan = %d\n", nTargetTimespan, nActualTimespan); - printf("Before: %08x %s\n", pindexLast->nBits, CBigNum().SetCompact(pindexLast->nBits).getuint256().ToString().c_str()); - printf("After: %08x %s\n", bnNew.GetCompact(), bnNew.getuint256().ToString().c_str()); + if (LogAcceptsCategory("net")) { printf("\n\n\nGetNextWorkRequired RETARGET *****\n"); printf("nTargetTimespan = %d nActualTimespan = %d\n", nTargetTimespan, nActualTimespan); printf("Before: %08x %s\n", pindexLast->nBits, CBigNum().SetCompact(pindexLast->nBits).getuint256().ToString().c_str()); printf("After: %08x %s\n", bnNew.GetCompact(), bnNew.getuint256().ToString().c_str()); } return bnNew.GetCompact(); } @@ -1037,7 +1034,7 @@ bool CBlock::ConnectBlock(CTxDB& txdb, CBlockIndex* pindex) bool Reorganize(CTxDB& txdb, CBlockIndex* pindexNew) { - printf("*** REORGANIZE ***\n"); + LogPrint("net", "*** REORGANIZE ***\n"); // Find the fork CBlockIndex* pfork = pindexBest; @@ -1712,7 +1709,7 @@ bool ProcessMessages(CNode* pfrom) CDataStream& vRecv = pfrom->vRecv; if (vRecv.empty()) return true; - printf("ProcessMessages(%d bytes)\n", vRecv.size()); + LogPrint("net", "ProcessMessages(%d bytes)\n", vRecv.size()); // // Message format @@ -1730,13 +1727,13 @@ bool ProcessMessages(CNode* pfrom) { if (vRecv.size() > sizeof(CMessageHeader)) { - printf("\n\nPROCESSMESSAGE MESSAGESTART NOT FOUND\n\n"); + if (LogAcceptsCategory("net")) printf("\n\nPROCESSMESSAGE MESSAGESTART NOT FOUND\n\n"); vRecv.erase(vRecv.begin(), vRecv.end() - sizeof(CMessageHeader)); } break; } if (pstart - vRecv.begin() > 0) - printf("\n\nPROCESSMESSAGE SKIPPED %d BYTES\n\n", pstart - vRecv.begin()); + if (LogAcceptsCategory("net")) printf("\n\nPROCESSMESSAGE SKIPPED %d BYTES\n\n", pstart - vRecv.begin()); vRecv.erase(vRecv.begin(), pstart); // Read header @@ -1744,7 +1741,7 @@ bool ProcessMessages(CNode* pfrom) vRecv >> hdr; if (!hdr.IsValid()) { - printf("\n\nPROCESSMESSAGE: ERRORS IN HEADER %s\n\n\n", hdr.GetCommand().c_str()); + if (LogAcceptsCategory("net")) printf("\n\nPROCESSMESSAGE: ERRORS IN HEADER %s\n\n\n", hdr.GetCommand().c_str()); continue; } string strCommand = hdr.GetCommand(); @@ -1755,7 +1752,7 @@ bool ProcessMessages(CNode* pfrom) { // Rewind and wait for rest of message ///// need a mechanism to give up waiting for overlong message size error - printf("MESSAGE-BREAK 2\n"); + if (LogAcceptsCategory("net")) printf("MESSAGE-BREAK 2\n"); vRecv.insert(vRecv.begin(), BEGIN(hdr), END(hdr)); Sleep(100); break; @@ -1776,7 +1773,7 @@ bool ProcessMessages(CNode* pfrom) } CATCH_PRINT_EXCEPTION("ProcessMessage()") if (!fRet) - printf("ProcessMessage(%s, %d bytes) from %s to %s FAILED\n", strCommand.c_str(), nMessageSize, pfrom->addr.ToString().c_str(), addrLocalHost.ToString().c_str()); + if (LogAcceptsCategory("net")) printf("ProcessMessage(%s, %d bytes) from %s to %s FAILED\n", strCommand.c_str(), nMessageSize, pfrom->addr.ToString().c_str(), addrLocalHost.ToString().c_str()); } vRecv.Compact(); @@ -1789,13 +1786,10 @@ bool ProcessMessages(CNode* pfrom) bool ProcessMessage(CNode* pfrom, string strCommand, CDataStream& vRecv) { static map > mapReuseKey; - printf("received: %-12s (%d bytes) ", strCommand.c_str(), vRecv.size()); - for (int i = 0; i < min(vRecv.size(), (unsigned int)25); i++) - printf("%02x ", vRecv[i] & 0xff); - printf("\n"); + if (LogAcceptsCategory("net")) { printf("received: %-12s (%d bytes) ", strCommand.c_str(), vRecv.size()); for (int i = 0; i < min(vRecv.size(), (unsigned int)25); i++) printf("%02x ", vRecv[i] & 0xff); printf("\n"); } if (nDropMessagesTest > 0 && GetRand(nDropMessagesTest) == 0) { - printf("dropmessages DROPPING RECV MESSAGE\n"); + if (LogAcceptsCategory("net")) printf("dropmessages DROPPING RECV MESSAGE\n"); return true; } @@ -1833,7 +1827,7 @@ bool ProcessMessage(CNode* pfrom, string strCommand, CDataStream& vRecv) pfrom->PushMessage("getblocks", CBlockLocator(pindexBest), uint256(0)); } - printf("version addrMe = %s\n", addrMe.ToString().c_str()); + if (LogAcceptsCategory("net")) printf("version addrMe = %s\n", addrMe.ToString().c_str()); } @@ -1844,30 +1838,6 @@ bool ProcessMessage(CNode* pfrom, string strCommand, CDataStream& vRecv) } - else if (strCommand == "addr") - { - vector vAddr; - vRecv >> vAddr; - - // Store the new addresses - CAddrDB addrdb; - foreach(const CAddress& addr, vAddr) - { - if (fShutdown) - return true; - if (AddAddress(addrdb, addr)) - { - // Put on lists to send to other nodes - pfrom->setAddrKnown.insert(addr); - CRITICAL_BLOCK(cs_vNodes) - foreach(CNode* pnode, vNodes) - if (!pnode->setAddrKnown.count(addr)) - pnode->vAddrToSend.push_back(addr); - } - } - } - - else if (strCommand == "inv") { vector vInv; @@ -1881,7 +1851,7 @@ bool ProcessMessage(CNode* pfrom, string strCommand, CDataStream& vRecv) pfrom->AddInventoryKnown(inv); bool fAlreadyHave = AlreadyHave(txdb, inv); - printf(" got inventory: %s %s\n", inv.ToString().c_str(), fAlreadyHave ? "have" : "new"); + if (LogAcceptsCategory("net")) printf(" got inventory: %s %s\n", inv.ToString().c_str(), fAlreadyHave ? "have" : "new"); if (!fAlreadyHave) pfrom->AskFor(inv); @@ -1900,7 +1870,7 @@ bool ProcessMessage(CNode* pfrom, string strCommand, CDataStream& vRecv) { if (fShutdown) return true; - printf("received getdata for: %s\n", inv.ToString().c_str()); + if (LogAcceptsCategory("net")) printf("received getdata for: %s\n", inv.ToString().c_str()); if (inv.type == MSG_BLOCK) { @@ -1940,12 +1910,12 @@ bool ProcessMessage(CNode* pfrom, string strCommand, CDataStream& vRecv) // Send the rest of the chain if (pindex) pindex = pindex->pnext; - printf("getblocks %d to %s\n", (pindex ? pindex->nHeight : -1), hashStop.ToString().substr(0,14).c_str()); + if (LogAcceptsCategory("net")) printf("getblocks %d to %s\n", (pindex ? pindex->nHeight : -1), hashStop.ToString().substr(0,14).c_str()); for (; pindex; pindex = pindex->pnext) { if (pindex->GetBlockHash() == hashStop) { - printf(" getblocks stopping at %d %s\n", pindex->nHeight, pindex->GetBlockHash().ToString().substr(0,14).c_str()); + if (LogAcceptsCategory("net")) printf(" getblocks stopping at %d %s\n", pindex->nHeight, pindex->GetBlockHash().ToString().substr(0,14).c_str()); break; } @@ -1997,7 +1967,7 @@ bool ProcessMessage(CNode* pfrom, string strCommand, CDataStream& vRecv) if (tx.AcceptTransaction(true)) { - printf(" accepted orphan tx %s\n", inv.hash.ToString().substr(0,6).c_str()); + if (LogAcceptsCategory("net")) printf(" accepted orphan tx %s\n", inv.hash.ToString().substr(0,6).c_str()); AddToWalletIfMine(tx, NULL); RelayMessage(inv, vMsg); mapAlreadyAskedFor.erase(inv); @@ -2011,7 +1981,7 @@ bool ProcessMessage(CNode* pfrom, string strCommand, CDataStream& vRecv) } else if (fMissingInputs) { - printf("storing orphan tx %s\n", inv.hash.ToString().substr(0,6).c_str()); + if (LogAcceptsCategory("net")) printf("storing orphan tx %s\n", inv.hash.ToString().substr(0,6).c_str()); AddOrphanTx(vMsg); } } @@ -2041,7 +2011,7 @@ bool ProcessMessage(CNode* pfrom, string strCommand, CDataStream& vRecv) vRecv >> *pblock; //// debug print - printf("received block:\n"); pblock->print(); + if (LogAcceptsCategory("net")) { printf("received block:\n"); pblock->print(); } CInv inv(MSG_BLOCK, pblock->GetHash()); pfrom->AddInventoryKnown(inv); @@ -2051,25 +2021,6 @@ bool ProcessMessage(CNode* pfrom, string strCommand, CDataStream& vRecv) } - else if (strCommand == "getaddr") - { - pfrom->vAddrToSend.clear(); - //// need to expand the time range if not enough found - int64 nSince = GetAdjustedTime() - 60 * 60; // in the last hour - CRITICAL_BLOCK(cs_mapAddresses) - { - foreach(const PAIRTYPE(vector, CAddress)& item, mapAddresses) - { - if (fShutdown) - return true; - const CAddress& addr = item.second; - if (addr.nTime > nSince) - pfrom->vAddrToSend.push_back(addr); - } - } - } - - else if (strCommand == "checkorder") { uint256 hashReply; @@ -2134,12 +2085,12 @@ bool ProcessMessage(CNode* pfrom, string strCommand, CDataStream& vRecv) else { // Ignore unknown commands for extensibility - printf("ProcessMessage(%s) : Ignored unknown message\n", strCommand.c_str()); + if (LogAcceptsCategory("net")) printf("ProcessMessage(%s) : Ignored unknown message\n", strCommand.c_str()); } if (!vRecv.empty()) - printf("ProcessMessage(%s) : %d extra bytes\n", strCommand.c_str(), vRecv.size()); + if (LogAcceptsCategory("net")) printf("ProcessMessage(%s) : %d extra bytes\n", strCommand.c_str(), vRecv.size()); return true; } @@ -2162,19 +2113,6 @@ bool SendMessages(CNode* pto) return true; - // - // Message: addr - // - vector vAddrToSend; - vAddrToSend.reserve(pto->vAddrToSend.size()); - foreach(const CAddress& addr, pto->vAddrToSend) - if (!pto->setAddrKnown.count(addr)) - vAddrToSend.push_back(addr); - pto->vAddrToSend.clear(); - if (!vAddrToSend.empty()) - pto->PushMessage("addr", vAddrToSend); - - // // Message: inventory // @@ -2204,7 +2142,7 @@ bool SendMessages(CNode* pto) while (!pto->mapAskFor.empty() && (*pto->mapAskFor.begin()).first <= nNow) { const CInv& inv = (*pto->mapAskFor.begin()).second; - printf("sending getdata: %s\n", inv.ToString().c_str()); + if (LogAcceptsCategory("net")) printf("sending getdata: %s\n", inv.ToString().c_str()); if (!AlreadyHave(txdb, inv)) vAskFor.push_back(inv); pto->mapAskFor.erase(pto->mapAskFor.begin()); @@ -2280,13 +2218,21 @@ void BlockSHA256(const void* pin, unsigned int nBlocks, void* pout) // --------------------------------------------------------------------------- -// Participant pool miner — connects to an external Stratum server, receives +// Participant pool miner -- connects to an external Stratum server, receives // RandomX jobs, hashes with the local CPU, and submits shares. // Called from BitcoinMiner() when nMineMode == MINE_PARTICIPANT. // --------------------------------------------------------------------------- using json = nlohmann::json; + +// --------------------------------------------------------------------------- +// Participant miner -- connects to a pool over a .btf tunnel, runs Stratum, +// hashes with RandomX, submits shares. Called from BitcoinMiner(). +// --------------------------------------------------------------------------- + +using json = nlohmann::json; + static std::string ToHexStr(const void* p, size_t n) { const unsigned char* b = (const unsigned char*)p; @@ -2300,10 +2246,10 @@ static std::vector FromHexStr(const std::string& s) { std::vector v; for (size_t i = 0; i+1 < s.size(); i += 2) { - auto h=[](char c)->int{ - if(c>='0'&&c<='9')return c-'0'; - if(c>='a'&&c<='f')return c-'a'+10; - if(c>='A'&&c<='F')return c-'A'+10; + auto h = [](char c)->int{ + if(c>='0'&&c<='9') return c-'0'; + if(c>='a'&&c<='f') return c-'a'+10; + if(c>='A'&&c<='F') return c-'A'+10; return 0; }; v.push_back((unsigned char)((h(s[i])<<4)|h(s[i+1]))); @@ -2313,14 +2259,13 @@ static std::vector FromHexStr(const std::string& s) static void UpdateParticipantHashRate(uint64 hashesSinceSample, int64 sampleStart) { - int64 now = GetTime(); - int64 elapsed = now - sampleStart; - if (elapsed <= 0) - elapsed = 1; - double rate = (double)hashesSinceSample / (double)elapsed; - gParticipantHashRateX1000.store((uint64)(rate * 1000.0)); + int64 elapsed = GetTime() - sampleStart; + if (elapsed <= 0) elapsed = 1; + gParticipantHashRateX1000.store( + (uint64)((double)hashesSinceSample / (double)elapsed * 1000.0)); } +// Blocking send of a JSON line to the pool. static bool StratumSendLine(SOCKET s, const json& j) { std::string line = j.dump() + "\n"; @@ -2333,38 +2278,59 @@ static bool StratumSendLine(SOCKET s, const json& j) return true; } -static bool StratumRecvLine(SOCKET s, std::string& buf, std::string& line) +// Non-blocking recv into carry buffer. Returns true + fills line when a +// complete newline-terminated message is ready. Returns false in two cases: +// disconnected=true -- socket closed by peer; caller must stop +// disconnected=false -- no complete line yet; caller should select() and retry +static bool StratumRecvLine(SOCKET s, std::string& buf, + std::string& line, bool& disconnected) { - while (true) { - size_t pos = buf.find('\n'); - if (pos != std::string::npos) { - line = buf.substr(0, pos); - if (!line.empty() && line.back()=='\r') line.pop_back(); - buf = buf.substr(pos+1); - return true; - } - char tmp[4096]; - int r = recv(s, tmp, sizeof(tmp)-1, 0); - if (r <= 0) return false; - tmp[r] = 0; - buf += tmp; + disconnected = false; + char tmp[4096]; +#ifdef _WIN32 + u_long avail = 0; + ioctlsocket(s, FIONREAD, &avail); + if (avail > 0) { + int r = recv(s, tmp, (int)std::min((u_long)(sizeof(tmp)-1), avail), 0); + if (r > 0) { tmp[r] = 0; buf += tmp; } + else if (r == 0) { disconnected = true; return false; } + } +#else + { + int r = recv(s, tmp, sizeof(tmp)-1, MSG_DONTWAIT); + if (r > 0) { tmp[r] = 0; buf += tmp; } + else if (r == 0) { disconnected = true; return false; } + // r < 0: EAGAIN -- no data right now } +#endif + size_t pos = buf.find('\n'); + if (pos == std::string::npos) return false; + line = buf.substr(0, pos); + if (!line.empty() && line.back() == '\r') line.pop_back(); + buf = buf.substr(pos + 1); + return true; } +// How long to wait for a mining.notify after authorize before giving up +static const int64 STUCK_NO_JOB_TIMEOUT_SECS = 120; + static bool PoolParticipantMiner() { - printf("PoolParticipantMiner: connecting to %s\n", strParticipantPool.c_str()); - SetParticipantMiningStatus("resolving pool address"); - + // ------------------------------------------------------------------ + // Step 1: resolve .btf address -> meeting node + // ------------------------------------------------------------------ if (strParticipantPool.empty()) { - printf("PoolParticipantMiner: no pool selected\n"); + LogPrint("worker", "[worker] no pool selected\n"); SetParticipantMiningStatus("no pool selected"); return false; } + LogPrint("worker", "[worker] connecting to pool %s\n", strParticipantPool.c_str()); + SetParticipantMiningStatus("resolving pool address"); + unsigned char target_pubkey[32]; if (!btf::ParseAddress(strParticipantPool, target_pubkey)) { - printf("PoolParticipantMiner: invalid .btf address %s\n", strParticipantPool.c_str()); + LogPrint("worker", "[worker] invalid .btf address: %s\n", strParticipantPool.c_str()); SetParticipantMiningStatus("invalid pool address"); return false; } @@ -2372,35 +2338,48 @@ static bool PoolParticipantMiner() std::string meetingHostPort; unsigned char service_enc_pub[32]; if (!BtfResolve(strParticipantPool, meetingHostPort, service_enc_pub)) { - printf("PoolParticipantMiner: failed to resolve %s\n", strParticipantPool.c_str()); + LogPrint("worker", "[worker] failed to resolve pool %s" + " (no Nostr descriptor found -- pool may be offline or relays down)\n", + strParticipantPool.c_str()); SetParticipantMiningStatus("failed to resolve pool"); return false; } + LogPrint("worker", "[worker] pool resolved to meeting node %s\n", meetingHostPort.c_str()); size_t colon = meetingHostPort.rfind(':'); if (colon == std::string::npos) { - printf("PoolParticipantMiner: bad meeting node %s\n", meetingHostPort.c_str()); + LogPrint("worker", "[worker] bad meeting node format '%s'\n", meetingHostPort.c_str()); SetParticipantMiningStatus("bad pool endpoint"); return false; } std::string host = meetingHostPort.substr(0, colon); int port = atoi(meetingHostPort.substr(colon + 1).c_str()); if (port <= 0 || port > 65535) { - printf("PoolParticipantMiner: bad meeting port %d\n", port); + LogPrint("worker", "[worker] bad meeting port %d in '%s'\n", + port, meetingHostPort.c_str()); SetParticipantMiningStatus("bad pool port"); return false; } - btf_socket_t s = btf::BtfClientTunnel(host.c_str(), (unsigned short)port, target_pubkey, service_enc_pub); + // ------------------------------------------------------------------ + // Step 2: open encrypted tunnel to pool + // ------------------------------------------------------------------ + SetParticipantMiningStatus("opening tunnel"); + btf_socket_t s = btf::BtfClientTunnel(host.c_str(), (unsigned short)port, + target_pubkey, service_enc_pub); if (s == INVALID_SOCKET) { - printf("PoolParticipantMiner: tunnel connect failed via %s\n", meetingHostPort.c_str()); + LogPrint("worker", "[worker] tunnel connect FAILED via %s" + " (relay unreachable or pool not listening)\n", + meetingHostPort.c_str()); SetParticipantMiningStatus("tunnel connect failed"); return false; } - printf("PoolParticipantMiner: connected via %s\n", meetingHostPort.c_str()); - SetParticipantMiningStatus("tunnel connected"); + LogPrint("worker", "[worker] tunnel open via %s\n", meetingHostPort.c_str()); + SetParticipantMiningStatus("tunnel open"); - // Use our wallet address as mining username so payouts go to our wallet + // ------------------------------------------------------------------ + // Step 3: determine our payout address (mining username) + // ------------------------------------------------------------------ std::string username; { std::vector vchPubKey; @@ -2408,102 +2387,260 @@ static bool PoolParticipantMiner() username = PubKeyToAddress(vchPubKey); } if (username.empty()) username = BtfLocalAddress(); - if (username.empty()) username = "unknown"; - + if (username.empty()) { + LogPrint("worker", "[worker] WARNING: could not determine wallet address" + " -- shares will not pay out\n"); + username = "unknown"; + } + LogPrint("worker", "[worker] payout address: %s\n", username.c_str()); + + // ------------------------------------------------------------------ + // Step 4: build the fast (~2 GB dataset) RandomX mode if not already + // built, then start our VM. Without this, RandomXCreateMinerVM() falls + // back to "light" mode (cache only) forever, which is dramatically + // slower per hash than fast mode -- it recomputes dataset items on the + // fly for every single hash instead of a direct memory lookup. That + // alone can turn a "few seconds per share" pool into "many minutes per + // share" regardless of how easy the share target is. + // + // This MUST happen before we send mining.subscribe/authorize, not + // after: it's a synchronous, single-threaded-per-call build that can + // take anywhere from several seconds to a couple of minutes depending + // on core count. If it runs after the handshake messages are sent, + // the pool's replies (subscribe ack, authorize ack, set_difficulty, + // and the first job) all land in the OS socket buffer while we're + // not reading -- nothing is lost, but we sit there looking (and + // logging) like we're "stuck waiting for pool response" for the + // entire build duration, when really we're just not listening yet. + // Building the dataset first means that by the time we say + // "subscribing", we're genuinely ready to read the reply the instant + // it arrives. + // ------------------------------------------------------------------ + if (!RandomXFastReady()) { + SetParticipantMiningStatus("building RandomX dataset (~2GB, one-time)"); + unsigned int nThreads = std::thread::hardware_concurrency(); + RandomXInitDataset(nThreads > 0 ? (int)nThreads : 1); + } + void* rxvm = RandomXCreateMinerVM(); + if (!rxvm) { + LogPrint("worker", "[worker] failed to create RandomX VM\n"); + SetParticipantMiningStatus("RandomX init failed"); + closesocket(s); return false; + } + LogPrint("worker", "[worker] RandomX VM ready (%s)\n", + RandomXFastReady() ? "fast 2GB" : "light 256MB"); + + // ------------------------------------------------------------------ + // Step 5: Stratum subscribe + authorize. The socket is read from + // immediately after this, in the main loop below, so nothing sits + // unread behind a blocking dataset build anymore. + // ------------------------------------------------------------------ std::string buf; int msgId = 1; std::set pendingSubmitIds; - // Subscribe SetParticipantMiningStatus("subscribing"); - if (!StratumSendLine(s, json{{"id",msgId++},{"method","mining.subscribe"},{"params",json::array()}})) - { SetParticipantMiningStatus("subscribe failed"); closesocket(s); return false; } + LogPrint("worker", "[worker->pool] sending mining.subscribe\n"); + if (!StratumSendLine(s, json{{"id",msgId++},{"method","mining.subscribe"}, + {"params",json::array()}})) { + LogPrint("worker", "[worker] subscribe send FAILED -- pool dropped connection\n"); + SetParticipantMiningStatus("subscribe send failed"); + RandomXDestroyMinerVM(rxvm); + closesocket(s); return false; + } - // Authorize SetParticipantMiningStatus("authorizing"); + LogPrint("worker", "[worker->pool] sending mining.authorize as %s\n", username.c_str()); if (!StratumSendLine(s, json{{"id",msgId++},{"method","mining.authorize"}, - {"params",json::array({username,"x"})}})) - { SetParticipantMiningStatus("authorize failed"); closesocket(s); return false; } + {"params",json::array({username,"x"})}})) { + LogPrint("worker", "[worker] authorize send FAILED -- pool dropped connection\n"); + SetParticipantMiningStatus("authorize send failed"); + RandomXDestroyMinerVM(rxvm); + closesocket(s); return false; + } - // Job state + // ------------------------------------------------------------------ + // Step 6: main loop -- receive messages, hash, submit shares + // ------------------------------------------------------------------ std::string jobId; - unsigned char header[80]; + unsigned char header[80] = {}; uint256 target; - bool haveJob = false; + double currentDifficulty = 1.0; // updated by mining.set_difficulty from pool + bool haveJob = false; bool gotSubscribeAck = false; bool gotAuthorizeAck = false; - int64 authWaitStart = 0; - unsigned int nNonce = 1; - uint64 hashesSinceSample = 0; - int64 sampleStart = GetTime(); + int64 authWaitStart = 0; + int64 lastStuckLog = 0; + unsigned int nNonce = 1; + uint64 hashesSinceSample = 0; + uint64 sessionSharesSent = 0; // this connection only + uint64 sessionSharesAccepted = 0; + int64 sampleStart = GetTime(); - void* rxvm = RandomXCreateMinerVM(); - if (!rxvm) { SetParticipantMiningStatus("failed to start RandomX miner"); closesocket(s); return false; } - printf("PoolParticipantMiner: RandomX VM ready (%s)\n", - RandomXFastReady() ? "fast 2GB" : "light 256MB"); - SetParticipantMiningStatus("authorized; asking pool for work"); + SetParticipantMiningStatus("waiting for pool response"); while (fGenerateBitcoins && nMineMode == MINE_PARTICIPANT && !fShutdown) { - // Non-blocking receive check + // -- Receive: check buffer first, then select -- { - fd_set fds; FD_ZERO(&fds); FD_SET(s, &fds); - struct timeval tv={0,50000}; // 50ms - if (select((int)s+1, &fds, NULL, NULL, &tv) > 0) { + bool haveBuffered = buf.find('\n') != std::string::npos; + bool readable = false; + if (!haveBuffered) { + fd_set fds; FD_ZERO(&fds); FD_SET(s, &fds); + struct timeval tv = {0, 50000}; // 50ms + readable = select((int)s+1, &fds, NULL, NULL, &tv) > 0; + } + + if (haveBuffered || readable) { std::string line; - if (!StratumRecvLine(s, buf, line)) break; - try { - json msg = json::parse(line); - if (msg.contains("id") && msg["id"].is_number_integer() && msg.contains("result")) { - int id = msg["id"].get(); - if (id == 1 && msg["result"].is_boolean() && msg["result"].get()) { - gotSubscribeAck = true; - SetParticipantMiningStatus("subscribed; authorizing"); - } else if (id == 2 && msg["result"].is_boolean()) { - gotAuthorizeAck = msg["result"].get(); - authWaitStart = GetTime(); - SetParticipantMiningStatus(gotAuthorizeAck ? "authorized; asking pool for work" : "authorization rejected"); + bool disc = false; + while (StratumRecvLine(s, buf, line, disc)) { + json msg; + try { msg = json::parse(line); } catch (...) { + LogPrint("worker", "[pool->worker] JSON parse error: %.80s\n", + line.c_str()); + continue; + } + + // -- Response to our subscribe (id=1) -- + if (msg.contains("id") && msg["id"].is_number_integer() + && msg["id"].get() == 1 + && msg.contains("result") && !msg["result"].is_null()) { + gotSubscribeAck = true; + LogPrint("worker", "[pool->worker] subscribe ack received\n"); + SetParticipantMiningStatus("subscribed; waiting for authorize ack"); + } + + // -- Response to our authorize (id=2) -- + else if (msg.contains("id") && msg["id"].is_number_integer() + && msg["id"].get() == 2 + && msg.contains("result")) { + bool ok = msg["result"].is_boolean() && msg["result"].get(); + authWaitStart = GetTime(); + if (ok) { + gotAuthorizeAck = true; + LogPrint("worker", "[pool->worker] authorize OK -- waiting for job\n"); + SetParticipantMiningStatus("authorized; waiting for job"); + } else { + std::string reason = msg.value("error", json("unspecified")).is_string() + ? msg["error"].get() : "unspecified"; + LogPrint("worker", "[pool->worker] authorize REJECTED: %s\n", + reason.c_str()); + SetParticipantMiningStatus("authorization rejected"); + // Don't break -- pool might still send a job (some pools + // reject bad addresses but still send work) } - if (pendingSubmitIds.erase(id) > 0) { - if (msg["result"].is_boolean() && msg["result"].get()) { + } + + // -- Share submission response -- + else if (msg.contains("id") && msg["id"].is_number_integer()) { + int rid = msg["id"].get(); + if (pendingSubmitIds.erase(rid) > 0) { + bool accepted = msg.contains("result") + && msg["result"].is_boolean() + && msg["result"].get(); + if (accepted) { gParticipantSharesAccepted.fetch_add(1); + sessionSharesAccepted++; + LogPrint("worker", "[pool->worker] share id=%d ACCEPTED\n", rid); SetParticipantMiningStatus("share accepted"); } else { - SetParticipantMiningStatus("share rejected"); + std::string reason = (msg.contains("error") + && msg["error"].is_string()) + ? msg["error"].get() : "unspecified"; + LogPrint("worker", "[pool->worker] share id=%d REJECTED: %s\n", + rid, reason.c_str()); + SetParticipantMiningStatus("share rejected: " + reason); } } } - std::string method = msg.value("method",""); - if (method == "mining.notify") { - auto& p = msg["params"]; - jobId = p[0].get(); - // p[1] = full 80-byte header hex - auto hdrBytes = FromHexStr(p[1].get()); - if (hdrBytes.size() >= 80) - memcpy(header, hdrBytes.data(), 80); - // p[3] = share target hex (little-endian uint256) - auto tgtBytes = FromHexStr(p[3].get()); - if (tgtBytes.size() >= 32) - memcpy(&target, tgtBytes.data(), 32); - haveJob = true; - nNonce = 1; - SetParticipantMiningStatus("job received; hashing"); - printf("PoolParticipantMiner: new job %s\n", jobId.c_str()); - } else if (method == "mining.set_difficulty") { - // acknowledged, we use the target from notify + + // -- Server notification -- + else { + std::string method = msg.value("method", ""); + + if (method == "mining.notify") { + auto& p = msg["params"]; + if (!p.is_array() || p.size() < 4) { + LogPrint("worker", "[pool->worker] malformed mining.notify" + " (params size=%zu)\n", p.is_array() ? p.size() : 0); + } else { + std::string newJobId = p[0].get(); + auto hdrBytes = FromHexStr(p[1].get()); + auto tgtBytes = FromHexStr(p[3].get()); + + if (hdrBytes.size() < 80) { + LogPrint("worker", "[pool->worker] mining.notify:" + " header too short (%zu bytes)\n", + hdrBytes.size()); + } else if (tgtBytes.size() < 32) { + LogPrint("worker", "[pool->worker] mining.notify:" + " target too short (%zu bytes)\n", + tgtBytes.size()); + } else { + jobId = newJobId; + memcpy(header, hdrBytes.data(), 80); + memcpy(&target, tgtBytes.data(), 32); + haveJob = true; + nNonce = 1; + LogPrint("worker", "[pool->worker] new job %s received\n", + jobId.c_str()); + SetParticipantMiningStatus("hashing job " + jobId); + } + } + } else if (method == "mining.set_difficulty") { + // Pool adjusts our share difficulty via vardiff. + // Apply it immediately so we start hashing at the new target. + if (msg.contains("params") && msg["params"].is_array() + && msg["params"].size() > 0 + && msg["params"][0].is_number()) { + double newDiff = msg["params"][0].get(); + if (newDiff > 0.0) { + bool changed = (newDiff != currentDifficulty); + currentDifficulty = newDiff; + LogPrint("worker", "[pool->worker] set_difficulty %.6f%s\n", + newDiff, changed ? " (updated)" : " (initial)"); + } + } else { + LogPrint("worker", "[pool->worker] set_difficulty" + " received (no valid params)\n"); + } + } else if (!method.empty()) { + LogPrint("worker", "[pool->worker] unknown server method: %s\n", + method.c_str()); + } } - } catch(...) {} + } + + if (disc) { + LogPrint("worker", "[worker] pool closed the connection" + " (gotSubscribeAck=%d gotAuthorizeAck=%d haveJob=%d)\n", + gotSubscribeAck, gotAuthorizeAck, haveJob); + break; + } } } - if (gotAuthorizeAck && !haveJob && authWaitStart > 0 && GetTime() - authWaitStart > 10) { - SetParticipantMiningStatus("authorized; waiting for pool notify"); - authWaitStart = 0; + // -- Stuck watchdog: authorized but no job arriving -- + if (gotAuthorizeAck && !haveJob && authWaitStart > 0) { + int64 waited = GetTime() - authWaitStart; + if (waited > 10 && GetTime() - lastStuckLog >= 15) { + LogPrint("worker", "[worker] authorized but no job after %llds" + " -- pool tunnel is live, waiting on mining.notify\n", + (long long)waited); + lastStuckLog = GetTime(); + } + if (waited > STUCK_NO_JOB_TIMEOUT_SECS) { + LogPrint("worker", "[worker] no job received after %llds -- disconnecting" + " and reconnecting\n", (long long)STUCK_NO_JOB_TIMEOUT_SECS); + SetParticipantMiningStatus("timeout waiting for job; reconnecting"); + break; + } } if (!haveJob) { Sleep(100); continue; } - // Hash one nonce + // -- Hash one nonce -- memcpy(header+76, &nNonce, 4); uint256 hash = RandomXHashWithVM(rxvm, header, 80); hashesSinceSample++; @@ -2512,38 +2649,59 @@ static bool PoolParticipantMiner() UpdateParticipantHashRate(hashesSinceSample, sampleStart); if (hash <= target) { - printf("PoolParticipantMiner: share found! nonce=%u\n", nNonce); + LogPrint("worker", "[worker] share found! nonce=%u job=%s difficulty=%.4f\n", + nNonce, jobId.c_str(), currentDifficulty); SetParticipantMiningStatus("share found; submitting"); int submitId = msgId++; pendingSubmitIds.insert(submitId); gParticipantSharesSent.fetch_add(1); + sessionSharesSent++; json submit = {{"id",submitId},{"method","mining.submit"}, - {"params",json::array({username, jobId, - ToHexStr(&nNonce,4)})}}; - if (!StratumSendLine(s, submit)) break; + {"params",json::array({username, jobId, ToHexStr(&nNonce,4)})}}; + if (!StratumSendLine(s, submit)) { + LogPrint("worker", "[worker] share submit send FAILED" + " -- pool connection lost\n"); + break; + } } nNonce++; - if (nNonce == 0) { haveJob = false; } // exhausted, wait for new job + if (nNonce == 0) { + LogPrint("worker", "[worker] nonce space exhausted for job %s" + " -- waiting for new job\n", jobId.c_str()); + haveJob = false; + } } + // ------------------------------------------------------------------ + // Cleanup + // ------------------------------------------------------------------ RandomXDestroyMinerVM(rxvm); closesocket(s); UpdateParticipantHashRate(hashesSinceSample, sampleStart); - SetParticipantMiningStatus("participant miner stopped"); - printf("PoolParticipantMiner: disconnected\n"); + SetParticipantMiningStatus("disconnected"); + LogPrint("worker", "[worker] disconnected from pool" + " (hashes this session: %llu, shares sent: %llu, accepted: %llu)\n", + (unsigned long long)hashesSinceSample, + (unsigned long long)sessionSharesSent, + (unsigned long long)sessionSharesAccepted); return true; } bool BitcoinMiner() { + // Relay mode: never mines, just relays/syncs. (Shouldn't normally get here + // since the GUI hides Start Mining for Relay, but guard it regardless.) + if (nMineMode == MINE_RELAY) + return true; + // Participant mode: connect to external pool instead of mining solo/operator if (nMineMode == MINE_PARTICIPANT) { while (fGenerateBitcoins && nMineMode == MINE_PARTICIPANT && !fShutdown) { PoolParticipantMiner(); if (fGenerateBitcoins && nMineMode == MINE_PARTICIPANT && !fShutdown) { - printf("PoolParticipantMiner: reconnecting in 10s\n"); + LogPrint("worker", "PoolParticipantMiner: reconnecting in 10s\n"); for (int i = 0; i < 10 && !fShutdown; i++) Sleep(1000); } } @@ -2554,9 +2712,16 @@ bool BitcoinMiner() if (nMineMode == MINE_OPERATOR) return true; - printf("BitcoinMiner started\n"); + LogPrint("net", "BitcoinMiner started\n"); SetThreadPriority(GetCurrentThread(), THREAD_PRIORITY_LOWEST); + // Build the fast (~2 GB dataset) RandomX mode once, up front, so we're + // not stuck mining in the much slower light/cache-only mode forever. + if (!RandomXFastReady()) { + unsigned int nThreads = std::thread::hardware_concurrency(); + RandomXInitDataset(nThreads > 0 ? (int)nThreads : 1); + } + CKey key; key.MakeNewKey(); CBigNum bnExtraNonce = 0; @@ -2639,7 +2804,7 @@ bool BitcoinMiner() } pblock->nBits = nBits; pblock->vtx[0].vout[0].nValue = pblock->GetBlockValue(nFees); - printf("\n\nRunning BitcoinMiner with %d transactions in block\n", pblock->vtx.size()); + if (LogAcceptsCategory("net")) printf("\n\nRunning BitcoinMiner with %d transactions in block\n", pblock->vtx.size()); // @@ -2658,11 +2823,11 @@ bool BitcoinMiner() void* rxvm = RandomXCreateMinerVM(); if (!rxvm) { - printf("BitcoinMiner: failed to create RandomX VM\n"); + LogPrint("net", "BitcoinMiner: failed to create RandomX VM\n"); Sleep(1000); continue; } - printf("BitcoinMiner: mining with RandomX (%s)\n", + LogPrint("net", "BitcoinMiner: mining with RandomX (%s)\n", RandomXFastReady() ? "fast 2GB" : "light 256MB"); unsigned int nStart = GetTime(); @@ -2675,9 +2840,9 @@ bool BitcoinMiner() if (hash <= hashTarget) { //// debug print - printf("BitcoinMiner:\n"); - printf("RandomX proof-of-work found \n powhash: %s \ntarget: %s\n", hash.GetHex().c_str(), hashTarget.GetHex().c_str()); - pblock->print(); + if (LogAcceptsCategory("net")) printf("BitcoinMiner:\n"); + if (LogAcceptsCategory("net")) printf("RandomX proof-of-work found \n powhash: %s \ntarget: %s\n", hash.GetHex().c_str(), hashTarget.GetHex().c_str()); + if (LogAcceptsCategory("net")) pblock->print(); SetThreadPriority(GetCurrentThread(), THREAD_PRIORITY_NORMAL); CRITICAL_BLOCK(cs_main) @@ -2692,7 +2857,7 @@ bool BitcoinMiner() // Process this block the same as if we had received it from another node if (!ProcessBlock(NULL, pblock.release())) - printf("ERROR in BitcoinMiner, ProcessBlock, block not accepted\n"); + LogPrint("net", "ERROR in BitcoinMiner, ProcessBlock, block not accepted\n"); } SetThreadPriority(GetCurrentThread(), THREAD_PRIORITY_LOWEST); @@ -2860,11 +3025,11 @@ bool SelectCoins(int64 nTargetValue, set& setCoinsRet) setCoinsRet.insert(vValue[i].second); //// debug print - printf("SelectCoins() best subset: "); + if (LogAcceptsCategory("net")) printf("SelectCoins() best subset: "); for (int i = 0; i < vValue.size(); i++) if (vfBest[i]) - printf("%s ", FormatMoney(vValue[i].first).c_str()); - printf("total %s\n", FormatMoney(nBest).c_str()); + if (LogAcceptsCategory("net")) printf("%s ", FormatMoney(vValue[i].first).c_str()); + if (LogAcceptsCategory("net")) printf("total %s\n", FormatMoney(nBest).c_str()); } return true; @@ -2953,6 +3118,34 @@ bool CreateTransaction(CScript scriptPubKey, int64 nValue, CWalletTx& wtxNew, in return true; } +// Undo what CommitTransactionSpent did: unmark the inputs it marked spent +// and erase the half-committed wallet entry. Used when a transaction is +// accepted into the wallet but then rejected by the mempool/chain, so we +// don't end up with a wallet that thinks money is spent that never left. +void RollbackTransactionSpent(const CWalletTx& wtxNew) +{ + CRITICAL_BLOCK(cs_main) + CRITICAL_BLOCK(cs_mapWallet) + { + set setCoins; + foreach(const CTxIn& txin, wtxNew.vin) + { + map::iterator mi = mapWallet.find(txin.prevout.hash); + if (mi != mapWallet.end()) + setCoins.insert(&mi->second); + } + foreach(CWalletTx* pcoin, setCoins) + { + pcoin->fSpent = false; + pcoin->WriteToDisk(); + vWalletUpdated.push_back(make_pair(pcoin->GetHash(), false)); + } + + EraseFromWallet(wtxNew.GetHash()); + } + MainFrameRepaint(); +} + // Call after CreateTransaction unless you want to abort bool CommitTransactionSpent(const CWalletTx& wtxNew) { @@ -3003,13 +3196,17 @@ bool SendMoney(CScript scriptPubKey, int64 nValue, CWalletTx& wtxNew) return error("SendMoney() : Error finalizing transaction"); } - printf("SendMoney: %s\n", wtxNew.GetHash().ToString().substr(0,6).c_str()); + if (LogAcceptsCategory("net")) printf("SendMoney: %s\n", wtxNew.GetHash().ToString().substr(0,6).c_str()); // Broadcast if (!wtxNew.AcceptTransaction()) { - // This must not fail. The transaction has already been signed and recorded. - throw runtime_error("SendMoney() : wtxNew.AcceptTransaction() failed\n"); + // The transaction was already signed and committed to the wallet + // above, but the chain/mempool just rejected it (e.g. a raced + // input already spent by another transaction). Unwind the + // wallet state we just wrote instead of crashing the app. + RollbackTransactionSpent(wtxNew); + return error("SendMoney() : wtxNew.AcceptTransaction() failed"); } wtxNew.RelayWalletTransaction(); } diff --git a/src/main.h b/src/main.h index 3d7d6dd..8bb8609 100644 --- a/src/main.h +++ b/src/main.h @@ -48,13 +48,17 @@ extern CBlockIndex* pindexBest; extern unsigned int nTransactionsUpdated; extern string strSetDataDir; extern int nDropMessagesTest; -// Mining mode — persisted in wallet.dat -// MINE_SOLO (0): mine to own wallet, no pool server -// MINE_OPERATOR (1): run pool server, mine to own wallet, distribute to miners -// MINE_PARTICIPANT (2): connect to external pool, submit shares, receive payouts -#define MINE_SOLO 0 -#define MINE_OPERATOR 1 -#define MINE_PARTICIPANT 2 +// Mining mode -- persisted in wallet.dat +// Mining mode -- decided fresh every launch (CLI flags, else default). Never +// restored from wallet.dat; see LoadWallet() in db.cpp. +// MINE_RELAY (0): default. Node runs/syncs, never mines, no pool server. +// MINE_SOLO (1): mine to own wallet, no pool server +// MINE_OPERATOR (2): run pool server, mine to own wallet, distribute to miners +// MINE_PARTICIPANT (3): connect to external pool, submit shares, receive payouts +#define MINE_RELAY 0 +#define MINE_SOLO 1 +#define MINE_OPERATOR 2 +#define MINE_PARTICIPANT 3 extern int nMineMode; extern string strParticipantPool; // participant: pool .btf address extern string strPoolName; // operator: announced pool name @@ -78,6 +82,7 @@ struct PoolWorkerStatView uint64 totalShares; uint64 roundShares; int64 lastSeen; + double hashRate; // estimated H/s, derived from share difficulty + submit rate }; // Settings @@ -102,11 +107,11 @@ void RelayWalletTransactions(); bool LoadBlockIndex(bool fAllowNew=true); void PrintBlockTree(); bool BitcoinMiner(); -void ThreadRPCServer(void* parg); // rpc.cpp — .btf pool server +void ThreadRPCServer(void* parg); // rpc.cpp -- .btf pool server void GetParticipantMiningStats(uint64& sharesSent, uint64& sharesAccepted, double& hashRate); void SetParticipantMiningStatus(const std::string& status); std::string GetParticipantMiningStatus(); -void GetPoolOperatorStats(int& authorizedMiners, int& blocksFound, uint64& roundShares); +void GetPoolOperatorStats(int& authorizedMiners, int& blocksFound, uint64& roundShares, double& totalHashRate); void GetPoolWorkerStats(std::vector& out); void GetPendingPayouts(std::vector& out); bool ProcessMessages(CNode* pfrom); diff --git a/src/main_gui.cpp b/src/main_gui.cpp index b7377da..842b0d5 100644 --- a/src/main_gui.cpp +++ b/src/main_gui.cpp @@ -1,4 +1,4 @@ -// Bitflash entry point — starts node threads then runs GUI (or headless). +// Bitflash entry point -- starts node threads then runs GUI (or headless). #include "headers_core.h" #ifndef _WIN32 @@ -126,8 +126,6 @@ int main(int argc, char* argv[]) ParseStartupArguments(argc, argv); - printf("Loading addresses...\n"); - if (!LoadAddresses()) printf("Warning: error loading addr.dat\n"); printf("Loading block index...\n"); string strErrors; if (!LoadBlockIndex()) { fprintf(stderr,"LoadBlockIndex failed\n"); return 1; } diff --git a/src/makefile.linux b/src/makefile.linux deleted file mode 100644 index 9e63f7d..0000000 --- a/src/makefile.linux +++ /dev/null @@ -1,55 +0,0 @@ -# Bitflash Linux build — GUI wallet + pool server + daemon mode. -# Requires: bash build-linux.sh (installs deps, builds secp256k1 + RandomX) -# Result: src/bitflash -# -# ./bitflash — GUI wallet -# ./bitflash /nogui /gen — headless daemon (same binary, no window) - -CXX ?= g++ - -SECP_INC ?= /usr/local/include -SECP_LIB ?= /usr/local/lib -RANDOMX_DIR ?= $(HOME)/RandomX - -WX_CFLAGS := $(shell wx-config --cxxflags) -WX_LIBS := $(shell wx-config --libs) - -CXXFLAGS = -std=gnu++14 -O2 -w -fpermissive -pthread \ - -DSECP256K1_STATIC \ - -I$(SECP_INC) -I$(RANDOMX_DIR)/src \ - -I/usr/include/nlohmann \ - $(WX_CFLAGS) - -LIBS = -L$(SECP_LIB) -lsecp256k1 \ - -L$(RANDOMX_DIR)/build -lrandomx \ - -lsodium -lssl -lcrypto -ldb_cxx -lpthread \ - -lboost_system \ - $(WX_LIBS) - -HEADERS = headers.h util.h main.h serialize.h uint256.h key.h bignum.h script.h \ - db.h base58.h net.h irc.h nostr.h randomx_pow.h btfaddr.h btfchan.h \ - btfrv.h btftunnel.h market.h compat.h uibase.h ui.h - -OBJS = obj/util.o obj/script.o obj/db.o obj/net.o obj/main.o obj/market.o \ - obj/sha.o obj/irc.o obj/nostr.o obj/randomx_pow.o \ - obj/btfaddr.o obj/btfchan.o obj/btfrv.o obj/btftunnel.o \ - obj/rpc.o obj/uibase.o obj/ui.o - -all: bitflash - -obj/%.o: %.cpp $(HEADERS) - @mkdir -p obj - $(CXX) -c $(CXXFLAGS) -o $@ $< - -obj/sha.o: sha.cpp sha.h - @mkdir -p obj - $(CXX) -c $(CXXFLAGS) -O3 -o $@ $< - -bitflash: $(OBJS) - $(CXX) $(CXXFLAGS) -o $@ $(OBJS) $(LIBS) - @echo "Built: bitflash" - @echo " GUI: ./bitflash" - @echo " Daemon: ./bitflash /nogui /gen" - -clean: - rm -rf obj bitflash diff --git a/src/makefile.mingw b/src/makefile.mingw index d381653..7d35d92 100644 --- a/src/makefile.mingw +++ b/src/makefile.mingw @@ -28,13 +28,13 @@ IMGUI_OBJS = obj/imgui.o obj/imgui_draw.o obj/imgui_tables.o \ obj/imgui_widgets.o obj/imgui_impl_glfw.o obj/imgui_impl_opengl3.o NODE_OBJS = obj/util.o obj/script.o obj/db.o obj/net.o obj/main.o \ - obj/market.o obj/sha.o obj/irc.o obj/nostr.o obj/randomx_pow.o \ + obj/market.o obj/sha.o obj/nostr.o obj/randomx_pow.o \ obj/btfaddr.o obj/btfchan.o obj/btfrv.o obj/btftunnel.o obj/rpc.o GUI_OBJS = obj/gui.o obj/main_gui.o obj/bitflash_res.o HEADERS = headers_core.h headers.h util.h main.h serialize.h uint256.h \ - key.h bignum.h script.h db.h base58.h net.h irc.h nostr.h \ + key.h bignum.h script.h db.h base58.h net.h nostr.h \ randomx_pow.h btfaddr.h btfchan.h btfrv.h btftunnel.h market.h all: bitflash.exe diff --git a/src/makefile.vc b/src/makefile.vc deleted file mode 100644 index 60dca78..0000000 --- a/src/makefile.vc +++ /dev/null @@ -1,77 +0,0 @@ -# Copyright (c) 2009 Satoshi Nakamoto -# Distributed under the MIT/X11 software license, see the accompanying -# file license.txt or http://www.opensource.org/licenses/mit-license.php. - - -!IF "$(BUILD)" != "debug" && "$(BUILD)" != "release" -BUILD=debug -!ENDIF -!IF "$(BUILD)" == "debug" -D=d -DEBUGFLAGS=/Zi /Od /D__WXDEBUG__ -!ENDIF - - - -INCLUDEPATHS=/I"/boost" /I"/DB/build_windows" /I"/OpenSSL/include" /I"/wxWidgets/lib/vc_lib/mswd" /I"/wxWidgets/include" -LIBPATHS=/LIBPATH:"/DB/build_windows/$(BUILD)" /LIBPATH:"/OpenSSL/out" /LIBPATH:"/wxWidgets/lib/vc_lib" -LIBS= \ - libdb47s$(D).lib \ - libeay32.lib \ - wxmsw28$(D)_richtext.lib wxmsw28$(D)_html.lib wxmsw28$(D)_core.lib wxbase28$(D).lib wxtiff$(D).lib wxjpeg$(D).lib wxpng$(D).lib wxzlib$(D).lib wxregex$(D).lib wxexpat$(D).lib \ - kernel32.lib user32.lib gdi32.lib comdlg32.lib winspool.lib winmm.lib shell32.lib comctl32.lib ole32.lib oleaut32.lib uuid.lib rpcrt4.lib advapi32.lib ws2_32.lib -WXDEFS=/DWIN32 /D__WXMSW__ /D_WINDOWS /DNOPCH -CFLAGS=/c /nologo /Ob0 /MD$(D) /EHsc /GR /Zm300 /YX /Fpobj/headers.pch $(DEBUGFLAGS) $(WXDEFS) $(INCLUDEPATHS) -HEADERS=headers.h util.h main.h serialize.h uint256.h key.h bignum.h script.h db.h base58.h - - - -all: bitcoin.exe - - -obj\util.obj: util.cpp $(HEADERS) - cl $(CFLAGS) /Fo$@ %s - -obj\script.obj: script.cpp $(HEADERS) - cl $(CFLAGS) /Fo$@ %s - -obj\db.obj: db.cpp $(HEADERS) market.h - cl $(CFLAGS) /Fo$@ %s - -obj\net.obj: net.cpp $(HEADERS) net.h - cl $(CFLAGS) /Fo$@ %s - -obj\main.obj: main.cpp $(HEADERS) net.h market.h - cl $(CFLAGS) /Fo$@ %s - -obj\market.obj: market.cpp $(HEADERS) market.h - cl $(CFLAGS) /Fo$@ %s - -obj\ui.obj: ui.cpp $(HEADERS) net.h uibase.h ui.h market.h - cl $(CFLAGS) /Fo$@ %s - -obj\uibase.obj: uibase.cpp uibase.h - cl $(CFLAGS) /Fo$@ %s - -obj\sha.obj: sha.cpp sha.h - cl $(CFLAGS) /O2 /Fo$@ %s - -obj\irc.obj: irc.cpp $(HEADERS) - cl $(CFLAGS) /Fo$@ %s - -obj\ui.res: ui.rc rc/bitcoin.ico rc/check.ico rc/send16.bmp rc/send16mask.bmp rc/send16masknoshadow.bmp rc/send20.bmp rc/send20mask.bmp rc/addressbook16.bmp rc/addressbook16mask.bmp rc/addressbook20.bmp rc/addressbook20mask.bmp - rc $(INCLUDEPATHS) $(WXDEFS) /Fo$@ %s - - - -OBJS=obj\util.obj obj\script.obj obj\db.obj obj\net.obj obj\main.obj obj\market.obj \ - obj\ui.obj obj\uibase.obj obj\sha.obj obj\irc.obj obj\ui.res - -bitcoin.exe: $(OBJS) - -kill /f bitcoin.exe & sleep 1 - link /nologo /DEBUG /SUBSYSTEM:WINDOWS /OUT:$@ $(LIBPATHS) $** $(LIBS) - -clean: - -del /Q obj\* - -del *.ilk - -del *.pdb diff --git a/src/net.cpp b/src/net.cpp index bdfb9c3..ab07f94 100644 --- a/src/net.cpp +++ b/src/net.cpp @@ -12,7 +12,6 @@ void ThreadMessageHandler2(void* parg); void ThreadSocketHandler2(void* parg); -void ThreadOpenConnections2(void* parg); @@ -32,8 +31,6 @@ bool fShutdown = false; array vfThreadRunning; vector vNodes; CCriticalSection cs_vNodes; -map, CAddress> mapAddresses; -CCriticalSection cs_mapAddresses; map mapRelay; deque > vRelayExpiration; CCriticalSection cs_mapRelay; @@ -41,61 +38,6 @@ map mapAlreadyAskedFor; string strBtfConnect; // .btf peer to keep connected to (from /connectbtf) - -CAddress addrProxy; - -bool ConnectSocket(const CAddress& addrConnect, SOCKET& hSocketRet) -{ - hSocketRet = INVALID_SOCKET; - - SOCKET hSocket = socket(AF_INET, SOCK_STREAM, 0); - if (hSocket == INVALID_SOCKET) - return false; - - bool fRoutable = !(addrConnect.GetByte(3) == 10 || (addrConnect.GetByte(3) == 192 && addrConnect.GetByte(2) == 168)); - bool fProxy = (addrProxy.ip && fRoutable); - struct sockaddr_in sockaddr = (fProxy ? addrProxy.GetSockAddr() : addrConnect.GetSockAddr()); - - if (connect(hSocket, (struct sockaddr*)&sockaddr, sizeof(sockaddr)) == SOCKET_ERROR) - { - closesocket(hSocket); - return false; - } - - if (fProxy) - { - printf("Proxy connecting to %s\n", addrConnect.ToString().c_str()); - char pszSocks4IP[] = "\4\1\0\0\0\0\0\0user"; - memcpy(pszSocks4IP + 2, &addrConnect.port, 2); - memcpy(pszSocks4IP + 4, &addrConnect.ip, 4); - char* pszSocks4 = pszSocks4IP; - int nSize = sizeof(pszSocks4IP); - - int ret = send(hSocket, pszSocks4, nSize, 0); - if (ret != nSize) - { - closesocket(hSocket); - return error("Error sending to proxy\n"); - } - char pchRet[8]; - if (recv(hSocket, pchRet, 8, 0) != 8) - { - closesocket(hSocket); - return error("Error reading proxy response\n"); - } - if (pchRet[1] != 0x5a) - { - closesocket(hSocket); - return error("Proxy returned error %d\n", pchRet[1]); - } - printf("Proxy connection established %s\n", addrConnect.ToString().c_str()); - } - - hSocketRet = hSocket; - return true; -} - - bool GetMyExternalIP(unsigned int& ipRet) { // Try several plain-text IP echo services in order. @@ -178,38 +120,6 @@ bool GetMyExternalIP(unsigned int& ipRet) -bool AddAddress(CAddrDB& addrdb, const CAddress& addr) -{ - if (!addr.IsRoutable()) - return false; - if (addr.ip == addrLocalHost.ip) - return false; - CRITICAL_BLOCK(cs_mapAddresses) - { - map, CAddress>::iterator it = mapAddresses.find(addr.GetKey()); - if (it == mapAddresses.end()) - { - // New address - mapAddresses.insert(make_pair(addr.GetKey(), addr)); - addrdb.WriteAddress(addr); - return true; - } - else - { - CAddress& addrFound = (*it).second; - if ((addrFound.nServices | addr.nServices) != addrFound.nServices) - { - // Services have been added - addrFound.nServices |= addr.nServices; - addrdb.WriteAddress(addrFound); - return true; - } - } - } - return false; -} - - @@ -330,64 +240,6 @@ CNode* FindNode(unsigned int ip) return NULL; } -CNode* FindNode(CAddress addr) -{ - CRITICAL_BLOCK(cs_vNodes) - { - foreach(CNode* pnode, vNodes) - if (pnode->addr == addr) - return (pnode); - } - return NULL; -} - -CNode* ConnectNode(CAddress addrConnect, int64 nTimeout) -{ - if (addrConnect.ip == addrLocalHost.ip) - return NULL; - - // Look for an existing connection - CNode* pnode = FindNode(addrConnect.ip); - if (pnode) - { - if (nTimeout != 0) - pnode->AddRef(nTimeout); - else - pnode->AddRef(); - return pnode; - } - - if (fDebug) - printf("trying %s\n", addrConnect.ToString().c_str()); - - // Connect - SOCKET hSocket; - if (ConnectSocket(addrConnect, hSocket)) - { - if (fDebug) - printf("connected %s\n", addrConnect.ToString().c_str()); - - // Add node - CNode* pnode = new CNode(hSocket, addrConnect, false); - if (nTimeout != 0) - pnode->AddRef(nTimeout); - else - pnode->AddRef(); - CRITICAL_BLOCK(cs_vNodes) - vNodes.push_back(pnode); - - CRITICAL_BLOCK(cs_mapAddresses) - mapAddresses[addrConnect.GetKey()].nLastFailed = 0; - return pnode; - } - else - { - CRITICAL_BLOCK(cs_mapAddresses) - mapAddresses[addrConnect.GetKey()].nLastFailed = GetTime(); - return NULL; - } -} - // Synthetic marker address for a tunneled `.btf` peer. The real endpoint is // unknown by design (the tunnel hides it), so tag the CNode with a non-routable // 10.x.x.x address (fails IsRoutable, so it is never gossiped as a real peer) @@ -401,20 +253,15 @@ static CAddress BtfMarkerAddr(const unsigned char b5[5]) return CAddress(ip, port, nLocalServices); } -// Connect to a peer by its `.btf` address: resolve the self-certified -// descriptor over Nostr, then tunnel through its meeting node with the -// end-to-end channel. Neither side ever learns the other's IP. -CNode* ConnectNodeBtf(const string& strBtfAddr) +// Shared tail: given a peer's pubkey and already-resolved rendezvous +// coordinates, open the tunnel and register the CNode. Used by both +// ConnectNodeBtf (which resolves the descriptor itself) and +// ConnectNodeBtfResolved (which takes an already-resolved descriptor, so it +// makes no Nostr relay calls at all -- see nostr.cpp's BtfResolveMany for why +// that matters when dialing many candidates in parallel). +static CNode* ConnectNodeBtfTail(const string& strBtfAddr, const unsigned char pk[32], + const string& strMeeting, const unsigned char enc_pub[32]) { - unsigned char pk[32]; - if (!btf::ParseAddress(strBtfAddr, pk)) - { - printf("ConnectNodeBtf: invalid address %s\n", strBtfAddr.c_str()); - return NULL; - } - if (BtfLocalAddress() == strBtfAddr) - return NULL; // ourselves - CAddress addr = BtfMarkerAddr(pk); CNode* pnode = FindNode(addr.ip); if (pnode) @@ -423,17 +270,6 @@ CNode* ConnectNodeBtf(const string& strBtfAddr) return pnode; } - if (fDebug) - printf("trying %s\n", strBtfAddr.c_str()); - - string strMeeting; - unsigned char enc_pub[32]; - if (!BtfResolve(strBtfAddr, strMeeting, enc_pub)) - { - if (fDebug) - printf("ConnectNodeBtf: could not resolve a descriptor for %s\n", strBtfAddr.c_str()); - return NULL; - } size_t colon = strMeeting.rfind(':'); if (colon == string::npos) return NULL; @@ -446,12 +282,12 @@ CNode* ConnectNodeBtf(const string& strBtfAddr) if (hSocket == INVALID_SOCKET) { if (fDebug) - printf("ConnectNodeBtf: tunnel to %s via %s failed\n", strBtfAddr.c_str(), strMeeting.c_str()); + LogPrint("net", "ConnectNodeBtf: tunnel to %s via %s failed\n", strBtfAddr.c_str(), strMeeting.c_str()); return NULL; } if (fDebug) - printf("connected %s via rendezvous %s\n", strBtfAddr.c_str(), strMeeting.c_str()); + LogPrint("net", "connected %s via rendezvous %s\n", strBtfAddr.c_str(), strMeeting.c_str()); // Add node pnode = new CNode(hSocket, addr, false); @@ -461,6 +297,53 @@ CNode* ConnectNodeBtf(const string& strBtfAddr) return pnode; } +// Connect to a peer by its `.btf` address: resolve the self-certified +// descriptor over Nostr, then tunnel through its meeting node with the +// end-to-end channel. Neither side ever learns the other's IP. +CNode* ConnectNodeBtf(const string& strBtfAddr) +{ + unsigned char pk[32]; + if (!btf::ParseAddress(strBtfAddr, pk)) + { + LogPrint("net", "ConnectNodeBtf: invalid address %s\n", strBtfAddr.c_str()); + return NULL; + } + if (BtfLocalAddress() == strBtfAddr) + return NULL; // ourselves + + if (fDebug) + LogPrint("net", "trying %s\n", strBtfAddr.c_str()); + + string strMeeting; + unsigned char enc_pub[32]; + if (!BtfResolve(strBtfAddr, strMeeting, enc_pub)) + { + if (fDebug) + LogPrint("net", "ConnectNodeBtf: could not resolve a descriptor for %s\n", strBtfAddr.c_str()); + return NULL; + } + return ConnectNodeBtfTail(strBtfAddr, pk, strMeeting, enc_pub); +} + +// Same as ConnectNodeBtf, but skips the Nostr resolve step because the caller +// already resolved the descriptor (e.g. via BtfResolveMany, batching many +// addresses into one relay round-trip instead of one relay round-trip per +// address). Makes zero relay connections -- only talks to the peer's +// rendezvous meeting node. +CNode* ConnectNodeBtfResolved(const string& strBtfAddr, const string& strMeeting, + const unsigned char enc_pub[32]) +{ + unsigned char pk[32]; + if (!btf::ParseAddress(strBtfAddr, pk)) + { + LogPrint("net", "ConnectNodeBtf: invalid address %s\n", strBtfAddr.c_str()); + return NULL; + } + if (BtfLocalAddress() == strBtfAddr) + return NULL; // ourselves + return ConnectNodeBtfTail(strBtfAddr, pk, strMeeting, enc_pub); +} + // Anonymous inbound listener (this node's `.btf` hidden service). Registers at // the meeting relay and blocks until a client dials our address; each pairing // becomes a normal inbound CNode riding the end-to-end channel, then we @@ -535,7 +418,7 @@ void ThreadBtfAccept(void* parg) RAND_bytes(rnd, sizeof(rnd)); CAddress addr = BtfMarkerAddr(rnd); - printf("accepted .btf connection via rendezvous %s\n", strMeeting.c_str()); + LogPrint("net", "accepted .btf connection via rendezvous %s\n", strMeeting.c_str()); CNode* pnode = new CNode(hSocket, addr, true); pnode->AddRef(); CRITICAL_BLOCK(cs_vNodes) @@ -574,7 +457,7 @@ void ThreadBtfConnect(void* parg) void CNode::Disconnect() { - printf("disconnecting node %s\n", addr.ToString().c_str()); + LogPrint("net", "disconnecting node %s\n", addr.ToString().c_str()); closesocket(hSocket); @@ -653,7 +536,7 @@ void ThreadSocketHandler2(void* parg) if (pnodeExtra->GetRefCount() <= (pnodeExtra->fNetworkNode ? 1 : 0)) { - printf("(%d nodes) disconnecting duplicate: %s\n", vNodes.size(), pnodeExtra->addr.ToString().c_str()); + LogPrint("net", "(%d nodes) disconnecting duplicate: %s\n", vNodes.size(), pnodeExtra->addr.ToString().c_str()); if (pnodeExtra->fNetworkNode && !pnode->fNetworkNode) { pnode->AddRef(); @@ -745,7 +628,7 @@ void ThreadSocketHandler2(void* parg) if (nSelect == SOCKET_ERROR) { int nErr = WSAGetLastError(); - printf("select failed: %d\n", nErr); + LogPrint("net", "select failed: %d\n", nErr); for (int i = 0; i <= hSocketMax; i++) { FD_SET(i, &fdsetRecv); @@ -784,7 +667,7 @@ void ThreadSocketHandler2(void* parg) } else { - printf("accepted connection from %s\n", addr.ToString().c_str()); + LogPrint("net", "accepted connection from %s\n", addr.ToString().c_str()); CNode* pnode = new CNode(hSocket, addr, true); pnode->AddRef(); CRITICAL_BLOCK(cs_vNodes) @@ -823,7 +706,7 @@ void ThreadSocketHandler2(void* parg) { // socket closed gracefully if (!pnode->fDisconnect) - printf("recv: socket closed\n"); + LogPrint("net", "recv: socket closed\n"); pnode->fDisconnect = true; } else if (nBytes < 0) @@ -833,7 +716,7 @@ void ThreadSocketHandler2(void* parg) if (nErr != WSAEWOULDBLOCK && nErr != WSAEMSGSIZE && nErr != WSAEINTR && nErr != WSAEINPROGRESS) { if (!pnode->fDisconnect) - printf("recv failed: %d\n", nErr); + LogPrint("net", "recv failed: %d\n", nErr); pnode->fDisconnect = true; } } @@ -862,7 +745,7 @@ void ThreadSocketHandler2(void* parg) } else { - printf("send error %d\n", nBytes); + LogPrint("net", "send error %d\n", nBytes); if (pnode->ReadyToDisconnect()) pnode->vSend.clear(); } @@ -885,141 +768,6 @@ void ThreadSocketHandler2(void* parg) -void ThreadOpenConnections(void* parg) -{ - IMPLEMENT_RANDOMIZE_STACK(ThreadOpenConnections(parg)); - - loop - { - vfThreadRunning[1] = true; - CheckForShutdown(1); - try - { - ThreadOpenConnections2(parg); - } - CATCH_PRINT_EXCEPTION("ThreadOpenConnections()") - vfThreadRunning[1] = false; - Sleep(5000); - } -} - -void ThreadOpenConnections2(void* parg) -{ - printf("ThreadOpenConnections started\n"); - - // Initiate network connections - const int nMaxConnections = 15; - loop - { - // Wait - vfThreadRunning[1] = false; - Sleep(500); - while (vNodes.size() >= nMaxConnections || vNodes.size() >= mapAddresses.size()) - { - CheckForShutdown(1); - Sleep(2000); - } - vfThreadRunning[1] = true; - CheckForShutdown(1); - - - // Make a list of unique class C's - unsigned char pchIPCMask[4] = { 0xff, 0xff, 0xff, 0x00 }; - unsigned int nIPCMask = *(unsigned int*)pchIPCMask; - vector vIPC; - CRITICAL_BLOCK(cs_mapAddresses) - { - vIPC.reserve(mapAddresses.size()); - unsigned int nPrev = 0; - foreach(const PAIRTYPE(vector, CAddress)& item, mapAddresses) - { - const CAddress& addr = item.second; - if (!addr.IsIPv4()) - continue; - - // Taking advantage of mapAddresses being in sorted order, - // with IPs of the same class C grouped together. - unsigned int ipC = addr.ip & nIPCMask; - if (ipC != nPrev) - vIPC.push_back(nPrev = ipC); - } - } - - // - // The IP selection process is designed to limit vulnerability to address flooding. - // Any class C (a.b.c.?) has an equal chance of being chosen, then an IP is - // chosen within the class C. An attacker may be able to allocate many IPs, but - // they would normally be concentrated in blocks of class C's. They can hog the - // attention within their class C, but not the whole IP address space overall. - // A lone node in a class C will get as much attention as someone holding all 255 - // IPs in another class C. - // - bool fSuccess = false; - int nLimit = vIPC.size(); - while (!fSuccess && nLimit-- > 0) - { - // Choose a random class C - unsigned int ipC = vIPC[GetRand(vIPC.size())]; - - // Organize all addresses in the class C by IP - map > mapIP; - CRITICAL_BLOCK(cs_mapAddresses) - { - unsigned int nDelay = ((30 * 60) << vNodes.size()); - if (nDelay > 8 * 60 * 60) - nDelay = 8 * 60 * 60; - for (map, CAddress>::iterator mi = mapAddresses.lower_bound(CAddress(ipC, 0).GetKey()); - mi != mapAddresses.upper_bound(CAddress(ipC | ~nIPCMask, 0xffff).GetKey()); - ++mi) - { - const CAddress& addr = (*mi).second; - unsigned int nRandomizer = (addr.nLastFailed * addr.ip * 7777U) % 20000; - if (GetTime() - addr.nLastFailed > nDelay * nRandomizer / 10000) - mapIP[addr.ip].push_back(addr); - } - } - if (mapIP.empty()) - break; - - // Choose a random IP in the class C - map >::iterator mi = mapIP.begin(); - advance(mi, GetRand(mapIP.size())); - - // Once we've chosen an IP, we'll try every given port before moving on - foreach(const CAddress& addrConnect, (*mi).second) - { - if (addrConnect.ip == addrLocalHost.ip || !addrConnect.IsIPv4() || FindNode(addrConnect.ip)) - continue; - - CNode* pnode = ConnectNode(addrConnect); - if (!pnode) - continue; - pnode->fNetworkNode = true; - - if (addrLocalHost.IsRoutable()) - { - // Advertise our address - vector vAddrToSend; - vAddrToSend.push_back(addrLocalHost); - pnode->PushMessage("addr", vAddrToSend); - } - - // Get as many addresses as we can - pnode->PushMessage("getaddr"); - - ////// should the one on the receiving end do this too? - // Subscribe our local subscription list - const unsigned int nHops = 0; - for (unsigned int nChannel = 0; nChannel < pnodeLocalHost->vfSubscribe.size(); nChannel++) - if (pnodeLocalHost->vfSubscribe[nChannel]) - pnode->PushMessage("subscribe", nChannel, nHops); - - fSuccess = true; - break; - } - } - } -} @@ -1187,7 +935,7 @@ bool StartNode(string& strError) return false; } - // Get our external IP in a background thread — up to 4 HTTP requests with + // Get our external IP in a background thread -- up to 4 HTTP requests with // 5s timeouts each could otherwise delay startup by 20s on first run. // addrIncoming from a previous session is used immediately as a fallback. if (addrIncoming.ip) @@ -1228,13 +976,6 @@ bool StartNode(string& strError) return false; } - if (_beginthread(ThreadOpenConnections, 0, NULL) == -1) - { - strError = "Error: _beginthread(ThreadOpenConnections) failed"; - printf("%s\n", strError.c_str()); - return false; - } - if (_beginthread(ThreadMessageHandler, 0, NULL) == -1) { strError = "Error: _beginthread(ThreadMessageHandler) failed"; diff --git a/src/net.h b/src/net.h index e732eb3..f028c3b 100644 --- a/src/net.h +++ b/src/net.h @@ -22,12 +22,10 @@ enum -bool ConnectSocket(const CAddress& addrConnect, SOCKET& hSocketRet); bool GetMyExternalIP(unsigned int& ipRet); -bool AddAddress(CAddrDB& addrdb, const CAddress& addr); -CNode* FindNode(unsigned int ip); -CNode* ConnectNode(CAddress addrConnect, int64 nTimeout=0); CNode* ConnectNodeBtf(const string& strBtfAddr); +CNode* ConnectNodeBtfResolved(const string& strBtfAddr, const string& strMeeting, + const unsigned char enc_pub[32]); void ThreadBtfAccept(void* parg); void ThreadBtfConnect(void* parg); extern string strBtfConnect; @@ -120,7 +118,7 @@ public: // Message size if (nMessageSize > 0x10000000) { - printf("CMessageHeader::IsValid() : nMessageSize too large %u\n", nMessageSize); + if (LogAcceptsCategory("net")) printf("CMessageHeader::IsValid() : nMessageSize too large %u\n", nMessageSize); return false; } @@ -294,7 +292,7 @@ public: void print() const { - printf("CAddress(%s)\n", ToString().c_str()); + if (LogAcceptsCategory("net")) printf("CAddress(%s)\n", ToString().c_str()); } }; @@ -387,7 +385,7 @@ public: void print() const { - printf("CInv(%s)\n", ToString().c_str()); + if (LogAcceptsCategory("net")) printf("CInv(%s)\n", ToString().c_str()); } }; @@ -426,13 +424,10 @@ extern bool fShutdown; extern array vfThreadRunning; extern vector vNodes; extern CCriticalSection cs_vNodes; -extern map, CAddress> mapAddresses; -extern CCriticalSection cs_mapAddresses; extern map mapRelay; extern deque > vRelayExpiration; extern CCriticalSection cs_mapRelay; extern map mapAlreadyAskedFor; -extern CAddress addrProxy; @@ -462,10 +457,6 @@ public: map mapRequests; CCriticalSection cs_mapRequests; - // flood - vector vAddrToSend; - set setAddrKnown; - // inventory based relay set setInventoryKnown; set setInventoryKnown2; @@ -555,7 +546,7 @@ public: // We're using mapAskFor as a priority queue, // the key is the earliest time the request can be sent int64& nRequestTime = mapAlreadyAskedFor[inv]; - printf("askfor %s %lld\n", inv.ToString().c_str(), nRequestTime); + if (LogAcceptsCategory("net")) printf("askfor %s %lld\n", inv.ToString().c_str(), nRequestTime); // Make sure not to reuse time indexes to keep things in the same order int64 nNow = (GetTime() - 1) * 1000000; @@ -576,7 +567,7 @@ public: AbortMessage(); nPushPos = vSend.size(); vSend << CMessageHeader(pszCommand, 0); - printf("sending: %-12s ", pszCommand); + if (LogAcceptsCategory("net")) printf("sending: %-12s ", pszCommand); } void AbortMessage() @@ -586,7 +577,7 @@ public: vSend.resize(nPushPos); nPushPos = -1; LeaveCriticalSection(&cs_vSend); - printf("(aborted)\n"); + if (LogAcceptsCategory("net")) printf("(aborted)\n"); } void EndMessage() @@ -594,7 +585,7 @@ public: extern int nDropMessagesTest; if (nDropMessagesTest > 0 && GetRand(nDropMessagesTest) == 0) { - printf("dropmessages DROPPING SEND MESSAGE\n"); + if (LogAcceptsCategory("net")) printf("dropmessages DROPPING SEND MESSAGE\n"); AbortMessage(); return; } @@ -606,10 +597,10 @@ public: unsigned int nSize = vSend.size() - nPushPos - sizeof(CMessageHeader); memcpy((char*)&vSend[nPushPos] + offsetof(CMessageHeader, nMessageSize), &nSize, sizeof(nSize)); - printf("(%d bytes) ", nSize); + if (LogAcceptsCategory("net")) printf("(%d bytes) ", nSize); //for (int i = nPushPos+sizeof(CMessageHeader); i < min(vSend.size(), nPushPos+sizeof(CMessageHeader)+20U); i++) // printf("%02x ", vSend[i] & 0xff); - printf("\n"); + if (LogAcceptsCategory("net")) printf("\n"); nPushPos = -1; LeaveCriticalSection(&cs_vSend); diff --git a/src/nostr.cpp b/src/nostr.cpp index c2ccc4c..009edd1 100644 --- a/src/nostr.cpp +++ b/src/nostr.cpp @@ -45,7 +45,12 @@ static const int64 BTF_POOL_MAX_AGE = 3 * 60; // seconds // Anonymous auto-discovery: how many peers to keep connected via .btf rendezvous // before we stop dialing more, and how many new dials to attempt per cycle. -static const unsigned int BTF_TARGET_CONN = 8; +// Target outbound peer count. With ~100 known .btf peers on the network, +// an Erdos-Renyi random graph stays connected with overwhelming probability +// once average degree exceeds ln(N) (~4.6 here); 24 is ~5x past that +// threshold, so this is plenty *provided* selection isn't fully correlated +// across nodes -- see the diversity slice in ConnectDiscoveredBtfPeers. +static const unsigned int BTF_TARGET_CONN = 24; static const int BTF_DIALS_PER_PASS = 12; // try more peers per Nostr cycle // Nostr descriptor freshness cutoff: skip peers whose descriptor hasn't been @@ -58,11 +63,27 @@ static map g_btfPeers; static CCriticalSection cs_btfPeers; // Public discovery relays. Tunable. +// +// Picked for: free/public (no NIP-42 auth or payment wall), track record of +// high uptime, and *infrastructure* diversity, not just different hostnames. +// relay.damus.io, relay.snort.social and relay.primal.net are all fronted by +// Cloudflare -- different operators, same upstream, so a Cloudflare-side +// incident (or a Cloudflare IP range getting rate-limited/blocked on some +// network) can take out several of them at once even though they look like +// independent relays. Kept only one Cloudflare-fronted relay (damus.io, the +// most established) and filled the rest with relays on different hosts +// (Hetzner, various independents) so no single upstream provider is a +// single point of failure for peer discovery. 7 relays lands inside the +// commonly recommended 5-10 range for redundancy without keeping an +// excessive number of persistent sockets open per node. const char* pszNostrRelays[] = { "wss://relay.damus.io", "wss://nos.lol", "wss://relay.nostr.band", - "wss://relay.snort.social", + "wss://nostr21.com", + "wss://offchain.pub", + "wss://nostr.mom", + "wss://relay.primal.net", }; const int nNostrRelays = ARRAYLEN(pszNostrRelays); @@ -317,6 +338,13 @@ std::string BtfLocalAddress() return g_nostrKey.BtfAddress(); } +int GetDiscoveredPeerCount() +{ + int n = 0; + CRITICAL_BLOCK(cs_btfPeers) { n = (int)g_btfPeers.size(); } + return n; +} + void BtfGetPoolAnnouncements(std::vector& out) { out.clear(); @@ -425,38 +453,55 @@ public: size_t colon = host.find(':'); if (colon != string::npos) { port = atoi(host.substr(colon + 1).c_str()); host = host.substr(0, colon); } - // Resolve and connect (TCP) - struct addrinfo hints, *res = NULL; + // Resolve and connect (TCP). AF_UNSPEC so we get both A and AAAA + // records back and try them in the order the resolver prefers, + // rather than hard-failing whenever the IPv4 path to a relay is + // filtered/rate-limited (increasingly common for CDN-fronted + // relays) while a working IPv6 route exists right there in the + // same getaddrinfo() result. + struct addrinfo hints, *res = NULL, *rp = NULL; memset(&hints, 0, sizeof(hints)); - hints.ai_family = AF_INET; + hints.ai_family = AF_UNSPEC; hints.ai_socktype = SOCK_STREAM; char portstr[16]; sprintf(portstr, "%d", port); if (getaddrinfo(host.c_str(), portstr, &hints, &res) != 0 || !res) return error("Nostr: getaddrinfo %s failed", host.c_str()); - hSocket = socket(res->ai_family, res->ai_socktype, res->ai_protocol); - if (hSocket == INVALID_SOCKET) { freeaddrinfo(res); return false; } - // Socket receive/send timeout: Windows takes a DWORD of milliseconds; - // POSIX takes a struct timeval. + bool fConnected = false; + for (rp = res; rp != NULL; rp = rp->ai_next) + { + hSocket = socket(rp->ai_family, rp->ai_socktype, rp->ai_protocol); + if (hSocket == INVALID_SOCKET) + continue; + + // Socket receive/send timeout: Windows takes a DWORD of + // milliseconds; POSIX takes a struct timeval. #ifdef _WIN32 - DWORD tv = timeoutSec * 1000; - setsockopt(hSocket, SOL_SOCKET, SO_RCVTIMEO, (char*)&tv, sizeof(tv)); - setsockopt(hSocket, SOL_SOCKET, SO_SNDTIMEO, (char*)&tv, sizeof(tv)); + DWORD tv = timeoutSec * 1000; + setsockopt(hSocket, SOL_SOCKET, SO_RCVTIMEO, (char*)&tv, sizeof(tv)); + setsockopt(hSocket, SOL_SOCKET, SO_SNDTIMEO, (char*)&tv, sizeof(tv)); #else - struct timeval tv; - tv.tv_sec = timeoutSec; - tv.tv_usec = 0; - setsockopt(hSocket, SOL_SOCKET, SO_RCVTIMEO, (char*)&tv, sizeof(tv)); - setsockopt(hSocket, SOL_SOCKET, SO_SNDTIMEO, (char*)&tv, sizeof(tv)); + struct timeval tv; + tv.tv_sec = timeoutSec; + tv.tv_usec = 0; + setsockopt(hSocket, SOL_SOCKET, SO_RCVTIMEO, (char*)&tv, sizeof(tv)); + setsockopt(hSocket, SOL_SOCKET, SO_SNDTIMEO, (char*)&tv, sizeof(tv)); #endif - if (connect(hSocket, res->ai_addr, (int)res->ai_addrlen) != 0) - { - freeaddrinfo(res); - return error("Nostr: connect %s:%d failed", host.c_str(), port); + if (connect(hSocket, rp->ai_addr, (int)rp->ai_addrlen) == 0) + { + fConnected = true; + break; + } + + closesocket(hSocket); + hSocket = INVALID_SOCKET; } freeaddrinfo(res); + if (!fConnected) + return error("Nostr: connect %s:%d failed", host.c_str(), port); + // TLS if (fTls) { @@ -598,6 +643,88 @@ private: }; +// Persistent relay connections. Public relays (relay.damus.io in particular) +// rate-limit new connections per source IP fairly aggressively. Before this, +// every single operation -- self-test, per-pass seeding, batch descriptor +// resolve, pool announce, pool query -- opened its own fresh CWebSocket, +// meaning a single node could fire a dozen-plus brand-new TCP+TLS+WS +// handshakes at the same 4 relays within any given minute (ThreadNostrSeed's +// own loop plus the separate ThreadBtfPoolAnnouncer thread, each reconnecting +// from scratch every cycle). That's exactly what was showing up as constant +// "handshake refused by relay.damus.io" and "connect ... failed" -- not +// necessarily dead relays, just this node getting rate-limited by its own +// reconnect churn. +// +// Now every relay-touching call reuses one long-lived socket per relay via +// this pool, reconnecting only when a socket actually drops. That cuts fresh +// connections from several per minute down to essentially one per relay for +// the life of the process. +struct RelayConn +{ + CWebSocket ws; + CCriticalSection cs; // serializes use of this relay's socket + bool fConnected; + RelayConn() : fConnected(false) {} +}; +static map g_relayPool; +static CCriticalSection cs_relayPool; + +static RelayConn* GetRelayConn(const string& relay) +{ + CRITICAL_BLOCK(cs_relayPool) + { + map::iterator it = g_relayPool.find(relay); + if (it != g_relayPool.end()) + return it->second; + RelayConn* rc = new RelayConn(); + g_relayPool[relay] = rc; + return rc; + } + return NULL; // unreachable +} + +// RAII lease on a relay's pooled connection: connects (if not already +// connected) on construction, and on destruction either keeps the socket +// alive for the next caller (if the exchange succeeded) or closes it so the +// next caller reconnects instead of reusing a socket that may be wedged. +// Caller must set fOk = true once its exchange with the relay completed +// cleanly. +class CRelayLease +{ +public: + RelayConn* rc; + bool fOk; + bool fJustConnected; // true if this lease had to (re)connect, false if reusing a live socket + + CRelayLease(const string& relay, int timeoutSec) : rc(NULL), fOk(false), fJustConnected(false) + { + rc = GetRelayConn(relay); + rc->cs.Enter(); + if (!rc->fConnected) + { + rc->ws.Close(); + rc->fConnected = rc->ws.Connect(relay, timeoutSec); + fJustConnected = rc->fConnected; + } + } + ~CRelayLease() + { + if (!fOk) + { + rc->ws.Close(); + rc->fConnected = false; + } + rc->cs.Leave(); + } + bool Connected() const { return rc->fConnected; } + CWebSocket& Ws() { return rc->ws; } + +private: + CRelayLease(const CRelayLease&); + CRelayLease& operator=(const CRelayLease&); +}; + + // Publish this node's self-certifying `.btf` descriptor: "reach me (this key) // via , encrypting to my x25519 key ". Signed by the node // key so only the address's owner can publish it (no hijacking). @@ -673,11 +800,11 @@ bool BtfPublishPoolAnnouncement(const BtfPoolAnnouncement& ann) { try { - CWebSocket ws; - if (!ws.Connect(pszNostrRelays[i], 10)) + CRelayLease lease(pszNostrRelays[i], 10); + if (!lease.Connected()) continue; - if (PublishPoolAnnouncementOnRelay(ws, g_nostrKey, ann)) - fPublished = true; + if (PublishPoolAnnouncementOnRelay(lease.Ws(), g_nostrKey, ann)) + fPublished = lease.fOk = true; } CATCH_PRINT_EXCEPTION("BtfPublishPoolAnnouncement") } @@ -699,9 +826,10 @@ bool BtfQueryPoolAnnouncement(const std::string& poolBtfAddr, BtfPoolAnnouncemen { try { - CWebSocket ws; - if (!ws.Connect(pszNostrRelays[i], 10)) + CRelayLease lease(pszNostrRelays[i], 10); + if (!lease.Connected()) continue; + CWebSocket& ws = lease.Ws(); json filter = json::object(); filter["authors"] = json::array({ HexEncode(pk, 32) }); @@ -756,6 +884,7 @@ bool BtfQueryPoolAnnouncement(const std::string& poolBtfAddr, BtfPoolAnnouncemen else if (type == "EOSE") break; } + lease.fOk = true; } CATCH_PRINT_EXCEPTION("BtfQueryPoolAnnouncement") } @@ -855,12 +984,16 @@ bool BtfResolve(const std::string& btfAddr, std::string& meetingHostPort, { try { - CWebSocket ws; - if (!ws.Connect(pszNostrRelays[i], 10)) + CRelayLease lease(pszNostrRelays[i], 10); + if (!lease.Connected()) continue; btf::Descriptor d; - if (!ResolveDescriptor(ws, g_nostrKey.ctx, btfAddr, d)) + if (!ResolveDescriptor(lease.Ws(), g_nostrKey.ctx, btfAddr, d)) + { + lease.fOk = true; // exchange completed, just no match continue; + } + lease.fOk = true; if (d.meeting_node.empty() || d.meeting_node == "rendezvous-pending") continue; // descriptor predates the owner's relay config if (!HexDecode(d.enc, enc_pub, 32)) @@ -873,14 +1006,115 @@ bool BtfResolve(const std::string& btfAddr, std::string& meetingHostPort, return false; } +// Resolve MANY .btf addresses in one pass. A single relay connection carries +// a REQ with every still-unresolved address's pubkey in "authors", instead of +// opening one relay connection per address. This matters because callers like +// ConnectDiscoveredBtfPeers dial many candidates in parallel; without +// batching, each of those dial threads independently walks the same handful +// of public relays on its own, so a pass of N candidates can fire up to +// N * nNostrRelays near-simultaneous connections at the same few relays -- +// enough to get rate-limited or have handshakes refused, which then looks +// like "dead peers" even though the descriptors are perfectly resolvable. +bool BtfResolveMany(const std::vector& btfAddrs, + std::map& out) +{ + if (!EnsureNostrKey() || btfAddrs.empty()) + return false; + + map hexToAddr; // pubkey hex -> .btf addr, for matching events back + set pending; + for (const string& a : btfAddrs) + { + unsigned char pk[32]; + if (!btf::ParseAddress(a, pk)) + continue; + hexToAddr[HexEncode(pk, 32)] = a; + pending.insert(a); + } + if (pending.empty()) + return false; + + for (int i = 0; i < nNostrRelays && !fShutdown && !pending.empty(); i++) + { + try + { + CRelayLease lease(pszNostrRelays[i], 10); + if (!lease.Connected()) + continue; + CWebSocket& ws = lease.Ws(); + + json authors = json::array(); + for (auto& kv : hexToAddr) + if (pending.count(kv.second)) + authors.push_back(kv.first); + if (authors.empty()) + { + lease.fOk = true; + break; + } + + json filter = json::object(); + filter["authors"] = authors; + filter["kinds"] = json::array({ BTF_DESC_KIND }); + filter["limit"] = (int)authors.size(); + json req = json::array({ "REQ", "btf-resolve-many", filter }); + if (!ws.SendText(req.dump())) + continue; + + for (;;) + { + string msg; + if (!ws.RecvText(msg)) + break; + json j; + try { j = json::parse(msg); } catch (...) { continue; } + if (!j.is_array() || j.empty()) continue; + string t = j[0].get(); + if (t == "EVENT" && j.size() >= 3) + { + const json& ev = j[2]; + if (!ev.contains("pubkey") || !ev.contains("content")) + continue; + string hexpk = ev["pubkey"].get(); + auto ai = hexToAddr.find(hexpk); + if (ai == hexToAddr.end() || !pending.count(ai->second)) + continue; // not one we're waiting on (or already resolved) + + unsigned char pk[32]; + if (!HexDecode(hexpk, pk, 32)) + continue; + btf::Descriptor d; + if (!btf::VerifyDescriptor(g_nostrKey.ctx, ev["content"].get(), pk, d)) + continue; + if (d.meeting_node.empty() || d.meeting_node == "rendezvous-pending") + continue; + BtfResolvedPeer rp; + if (!HexDecode(d.enc, rp.enc_pub, 32)) + continue; + rp.meetingHostPort = d.meeting_node; + out[ai->second] = rp; + pending.erase(ai->second); + } + else if (t == "EOSE") + break; + } + lease.fOk = true; + } + CATCH_PRINT_EXCEPTION("BtfResolveMany") + } + return !out.empty(); +} + // Connect to a relay: publish our .btf descriptor and collect relay data. static bool SeedFromRelay(CNostrKey& key, const string& relay) { - CWebSocket ws; - if (!ws.Connect(relay, 10)) + CRelayLease lease(relay, 10); + if (!lease.Connected()) return false; - printf("Nostr: connected to %s\n", relay.c_str()); + CWebSocket& ws = lease.Ws(); + if (lease.fJustConnected) + LogPrint("nostr", "Nostr: connected to %s\n", relay.c_str()); // Publish our self-certifying .btf descriptor (rendezvous discovery) PublishDescriptor(ws, key); @@ -930,7 +1164,7 @@ static bool SeedFromRelay(CNostrKey& key, const string& relay) else if (createdAt > it->second) it->second = createdAt; // refresh } if (fNew && fDebug) - printf("Nostr: discovered .btf peer %s\n", peerAddr.c_str()); + LogPrint("nostr", "Nostr: discovered .btf peer %s\n", peerAddr.c_str()); if (++nPeers > 500) break; } else if (type == "EOSE") @@ -974,7 +1208,7 @@ static bool SeedFromRelay(CNostrKey& key, const string& relay) if (g_discoveredRelays.size() < BTF_MAX_DISCOVERED_RELAYS) fNew = g_discoveredRelays.insert(r).second; if (fNew && fDebug) - printf("Nostr: discovered relay %s\n", r.c_str()); + LogPrint("nostr", "Nostr: discovered relay %s\n", r.c_str()); } else if (type == "EOSE") break; @@ -1009,12 +1243,15 @@ static bool SeedFromRelay(CNostrKey& key, const string& relay) } } } + lease.fOk = true; return true; } +volatile bool gAnnounceNow = false; + void ThreadBtfPoolAnnouncer(void*) { - printf("ThreadBtfPoolAnnouncer started\n"); + LogPrint("pool", "ThreadBtfPoolAnnouncer started\n"); while (!fShutdown) { if (nMineMode == MINE_OPERATOR && gPoolRunning) @@ -1025,15 +1262,17 @@ void ThreadBtfPoolAnnouncer(void*) ann.dashboardUrl = strPoolDashboardUrl; ann.feePercent = dPoolFeePercent; uint64 roundShares = 0; - GetPoolOperatorStats(ann.connectedMiners, ann.blocksFound, roundShares); - ann.hashRate = 0.0; + double totalHashRate = 0.0; + GetPoolOperatorStats(ann.connectedMiners, ann.blocksFound, roundShares, totalHashRate); + ann.hashRate = totalHashRate; ann.createdAt = GetTime(); if (!ann.btfAddress.empty() && !ann.poolName.empty()) BtfPublishPoolAnnouncement(ann); } - for (int i = 0; i < 60 && !fShutdown; i++) + for (int i = 0; i < 60 && !fShutdown && !gAnnounceNow; i++) Sleep(1000); + gAnnounceNow = false; } } @@ -1042,10 +1281,29 @@ void ThreadBtfPoolAnnouncer(void*) // dead/stale descriptors (e.g. abandoned test identities that linger on the // relays) from consuming the whole per-pass dial budget and starving the live // peers. Backoff grows on repeated failures, capped. +// +// g_btfPeerFails also doubles as the priority signal for dial ordering: a peer +// we've connected to before (present in g_btfPeerEverConnected) is known-good +// and goes first; a peer we've never tried goes next; a peer with a growing +// fail count is known-bad and goes last, worst offenders furthest back. This +// replaces the old behavior where the whole candidate list was shuffled right +// after being sorted by freshness, which made the freshness sort a no-op and +// gave dead peers equal footing with everyone else. static map g_btfPeerBackoff; // addr -> next-try time static map g_btfPeerFails; // addr -> consecutive failures +static set g_btfPeerEverConnected; // addr -> has succeeded at least once static CCriticalSection cs_btfPeerState; +// Backoff cap for repeatedly-dead peers. The old 15-minute cap meant a +// descriptor that has failed many times in a row cycles back into the dial +// pool just as often as a peer we've never tried, wasting dial budget on +// known-dead addresses every pass. Once a peer has failed enough times to +// be "chronic", stretch the cap much further so it consumes budget far less +// often while still getting retried eventually in case it comes back online. +static const int64 BTF_BACKOFF_CAP_SECS = 15 * 60; // 15 min: initial ramp cap +static const int64 BTF_BACKOFF_CAP_SECS_DEAD = 6 * 60 * 60; // 6 hr: cap for chronic failures +static const int BTF_CHRONIC_FAIL_THRESHOLD = 6; // fails before "chronic" cap kicks in + // Dial the .btf peers discovered on the relays, up to a target connection count. // Connections are attempted in parallel so a single slow/dead relay doesn't // stall the entire pass. Each candidate gets its own thread; all threads start @@ -1060,23 +1318,18 @@ static void ConnectDiscoveredBtfPeers() for (auto& kv : g_btfPeers) peersWithAge.push_back({kv.second, kv.first}); } - // Sort newest descriptor first — recently-active nodes are much more - // likely to be online right now. Stale ones stay in the list as fallback. - sort(peersWithAge.begin(), peersWithAge.end(), - [](const pair& a, const pair& b){ return a.first > b.first; }); - int64 cutoff = GetTime() - BTF_PEER_MAX_AGE; int nFresh = 0; - vector peers; + vector> fresh; // (createdAt, addr), newest first for (auto& p : peersWithAge) { if (p.first < cutoff) continue; - peers.push_back(p.second); + fresh.push_back(p); nFresh++; } - if (!peers.empty()) - printf("Nostr: %d fresh peers, %d stale (of %d total)\n", nFresh, nTotal-nFresh, nTotal); + if (!fresh.empty()) + LogPrint("nostr", "Nostr: %d fresh peers, %d stale (of %d total)\n", nFresh, nTotal-nFresh, nTotal); // Drop stale peers so we stop retrying dead descriptors until they refresh. { @@ -1092,42 +1345,164 @@ static void ConnectDiscoveredBtfPeers() } } - // Shuffle so we don't always retry the same dead peers at the front. - for (size_t i = peers.size(); i > 1; i--) - swap(peers[i - 1], peers[(size_t)GetRand(i)]); + // Sort newest descriptor first -- recently-active nodes are much more + // likely to be online right now. + sort(fresh.begin(), fresh.end(), + [](const pair& a, const pair& b){ return a.first > b.first; }); + + // Reorder into tiers: known-good (succeeded before) first, untried next, + // known-bad (has a fail count) last -- worst offenders furthest back. + // Freshness order is preserved *within* each tier, so recency still + // matters, but a peer we know is dead no longer gets equal footing with + // a peer we've never tried or one that's worked before. + vector tierGood, tierUntried, tierBad; + CRITICAL_BLOCK(cs_btfPeerState) + { + for (auto& p : fresh) + { + const string& addr = p.second; + if (g_btfPeerEverConnected.count(addr)) + tierGood.push_back(addr); + else if (g_btfPeerFails.find(addr) == g_btfPeerFails.end()) + tierUntried.push_back(addr); + else + tierBad.push_back(addr); + } + // Within known-bad, put fewer-fails peers ahead of chronic offenders. + sort(tierBad.begin(), tierBad.end(), + [](const string& a, const string& b){ return g_btfPeerFails[a] < g_btfPeerFails[b]; }); + } + + vector peers; + peers.reserve(fresh.size()); + peers.insert(peers.end(), tierGood.begin(), tierGood.end()); + peers.insert(peers.end(), tierUntried.begin(), tierUntried.end()); + peers.insert(peers.end(), tierBad.begin(), tierBad.end()); // Pick candidates for this pass (skip those in backoff, cap at budget). + // + // The tiered order above is deliberately biased toward peers we know + // are good -- that's the right default, since dialing untested/bad + // peers wastes attempts. But if every node on the network applies the + // same bias, everyone converges on the same small set of "known good" + // peers as neighbors: that correlates peer sets across the network, + // which is exactly what standard random-graph connectivity results + // assume you avoid. If that shared hub set has a bad day (e.g. one + // relay outage), many nodes lose the same edges at once instead of + // losing independent, uncorrelated edges. + // + // So: fill most of the pass from the reliable/tiered order, but reserve + // a slice for peers picked at random from the *entire* fresh pool + // (not just the front of the sorted list). Same pattern Bitcoin's own + // peer manager and Kademlia-style DHTs use -- sticky reliable peers + // plus a random sample, not one or the other. int64 now = GetTime(); + int nBudget = (int)BTF_DIALS_PER_PASS; + { + int nRoom = (int)BTF_TARGET_CONN - (int)vNodes.size(); + if (nRoom < nBudget) nBudget = nRoom; + } + if (nBudget < 0) nBudget = 0; + int nRandomSlots = (nBudget > 0) ? max(1, nBudget * 3 / 10) : 0; + int nSortedSlots = nBudget - nRandomSlots; + vector candidates; + set setChosen; CRITICAL_BLOCK(cs_btfPeerState) { + // Reliability slice: walk the tiered (good -> untried -> bad) order. for (const string& addr : peers) { if ((int)vNodes.size() >= (int)BTF_TARGET_CONN) break; - if ((int)candidates.size() >= BTF_DIALS_PER_PASS) break; + if ((int)candidates.size() >= nSortedSlots) break; map::iterator bi = g_btfPeerBackoff.find(addr); if (bi != g_btfPeerBackoff.end() && now < bi->second) continue; candidates.push_back(addr); + setChosen.insert(addr); + } + + // Diversity slice: random sample from the full fresh pool, so the + // dial isn't limited to whatever the tiered sort put up front. + vector pool; + for (auto& p : fresh) + { + const string& addr = p.second; + if (setChosen.count(addr)) + continue; + map::iterator bi = g_btfPeerBackoff.find(addr); + if (bi != g_btfPeerBackoff.end() && now < bi->second) + continue; + pool.push_back(addr); + } + int nWant = min(nRandomSlots, (int)pool.size()); + for (int i = 0; i < nWant; i++) + { + if ((int)vNodes.size() + (int)candidates.size() >= (int)BTF_TARGET_CONN) break; + if ((int)candidates.size() >= nBudget) break; + size_t j = i + (size_t)GetRand((uint64)(pool.size() - i)); + std::swap(pool[i], pool[j]); + candidates.push_back(pool[i]); + setChosen.insert(pool[i]); } } if (candidates.empty()) return; - // Launch one thread per candidate — all connect attempts run in parallel. + // Resolve every candidate's descriptor in one batched pass (a handful of + // relay round-trips total, one REQ per relay covering all candidates) + // instead of each dial thread independently walking the relay list on + // its own. That per-thread approach meant a 12-candidate pass could fire + // up to 12 * nNostrRelays near-simultaneous connections at the same + // handful of public relays -- enough to trip rate limits or get + // handshakes refused, which then looks exactly like "dead peers" even + // though the descriptors were perfectly resolvable. + map resolved; + BtfResolveMany(candidates, resolved); + + // Candidates whose descriptor didn't resolve this pass are recorded as a + // failure right away (no point spawning a tunnel thread with nothing to + // dial), same bookkeeping as a failed connect attempt. + vector toDial; + CRITICAL_BLOCK(cs_btfPeerState) + { + for (const string& addr : candidates) + { + if (resolved.count(addr)) + { + toDial.push_back(addr); + continue; + } + int n = ++g_btfPeerFails[addr]; + int64 cap = (n >= BTF_CHRONIC_FAIL_THRESHOLD) ? BTF_BACKOFF_CAP_SECS_DEAD + : BTF_BACKOFF_CAP_SECS; + int64 secs = 30; + for (int k = 1; k < n && secs < cap; k++) secs *= 2; + if (secs > cap) secs = cap; + g_btfPeerBackoff[addr] = GetTime() + secs; + } + } + if (toDial.empty()) return; + + // Launch one thread per resolved candidate -- all connect attempts run in + // parallel, but purely against each peer's own rendezvous meeting node, + // not the shared Nostr relays, so there's no thundering-herd effect here. // Each thread writes its result back via shared state guarded by cs_btfPeerState. - struct DialCtx { string addr; }; + struct DialCtx { string addr; BtfResolvedPeer rp; }; vector ctxs; - for (const string& addr : candidates) + for (const string& addr : toDial) { - DialCtx* ctx = new DialCtx{addr}; + DialCtx* ctx = new DialCtx{addr, resolved[addr]}; ctxs.push_back(ctx); _beginthread([](void* arg) { DialCtx* ctx = (DialCtx*)arg; string addr = ctx->addr; + string meeting = ctx->rp.meetingHostPort; + unsigned char enc_pub[32]; + memcpy(enc_pub, ctx->rp.enc_pub, 32); delete ctx; - CNode* pnode = ConnectNodeBtf(addr); + CNode* pnode = ConnectNodeBtfResolved(addr, meeting, enc_pub); CRITICAL_BLOCK(cs_btfPeerState) { if (pnode) @@ -1138,13 +1513,16 @@ static void ConnectDiscoveredBtfPeers() pnode->Release(); g_btfPeerBackoff.erase(addr); g_btfPeerFails.erase(addr); + g_btfPeerEverConnected.insert(addr); } else { int n = ++g_btfPeerFails[addr]; + int64 cap = (n >= BTF_CHRONIC_FAIL_THRESHOLD) ? BTF_BACKOFF_CAP_SECS_DEAD + : BTF_BACKOFF_CAP_SECS; int64 secs = 30; - for (int k = 1; k < n && secs < 15*60; k++) secs *= 2; - if (secs > 15*60) secs = 15*60; + for (int k = 1; k < n && secs < cap; k++) secs *= 2; + if (secs > cap) secs = cap; g_btfPeerBackoff[addr] = GetTime() + secs; } } @@ -1160,16 +1538,16 @@ static void ConnectDiscoveredBtfPeers() void ThreadNostrSeed(void* parg) { - printf("ThreadNostrSeed started\n"); + LogPrint("nostr", "ThreadNostrSeed started\n"); if (!EnsureNostrKey()) { - printf("Nostr: failed to initialize secp256k1 key\n"); + LogPrint("nostr", "Nostr: failed to initialize secp256k1 key\n"); return; } CNostrKey& key = g_nostrKey; - printf("Nostr: node pubkey = %s\n", key.PubKeyHex().c_str()); - printf("Nostr: node .btf address = %s\n", key.BtfAddress().c_str()); + LogPrint("nostr", "Nostr: node pubkey = %s\n", key.PubKeyHex().c_str()); + LogPrint("nostr", "Nostr: node .btf address = %s\n", key.BtfAddress().c_str()); // One-time self-test of the rendezvous: publish our descriptor to a relay, // then resolve our OWN .btf address back and verify it self-certifies. Proves @@ -1178,17 +1556,20 @@ void ThreadNostrSeed(void* parg) { try { - CWebSocket ws; - if (!ws.Connect(pszNostrRelays[i], 10)) continue; + CRelayLease lease(pszNostrRelays[i], 10); + if (!lease.Connected()) continue; + CWebSocket& ws = lease.Ws(); PublishDescriptor(ws, key); Sleep(700); // let the relay store the replaceable event btf::Descriptor d; if (ResolveDescriptor(ws, key.ctx, key.BtfAddress(), d)) { - printf("Nostr: .btf self-resolve OK via %s (meeting_node=%s, enc=%s...)\n", + LogPrint("nostr", "Nostr: .btf self-resolve OK via %s (meeting_node=%s, enc=%s...)\n", pszNostrRelays[i], d.meeting_node.c_str(), d.enc.substr(0, 16).c_str()); + lease.fOk = true; break; } + lease.fOk = true; } CATCH_PRINT_EXCEPTION("btf self-test") } @@ -1223,8 +1604,9 @@ void ThreadNostrSeed(void* parg) ann.dashboardUrl = strPoolDashboardUrl; ann.feePercent = dPoolFeePercent; uint64 roundShares = 0; - GetPoolOperatorStats(ann.connectedMiners, ann.blocksFound, roundShares); - ann.hashRate = 0.0; + double totalHashRate = 0.0; + GetPoolOperatorStats(ann.connectedMiners, ann.blocksFound, roundShares, totalHashRate); + ann.hashRate = totalHashRate; ann.createdAt = GetTime(); if (!ann.btfAddress.empty() && !ann.poolName.empty()) BtfPublishPoolAnnouncement(ann); diff --git a/src/nostr.h b/src/nostr.h index 63fc095..37b3b05 100644 --- a/src/nostr.h +++ b/src/nostr.h @@ -12,11 +12,17 @@ #include #include +#include // Thread entry point (signature compatible with _beginthread) void ThreadNostrSeed(void* parg); void ThreadBtfPoolAnnouncer(void* parg); +// Set to true to make ThreadBtfPoolAnnouncer publish immediately instead of +// waiting for its ~60s sleep loop (e.g. right after the operator changes pool +// name/fee/dashboard URL in Options). The announcer clears it after publishing. +extern volatile bool gAnnounceNow; + // Public relays used for discovery. Tunable. extern const char* pszNostrRelays[]; extern const int nNostrRelays; @@ -56,6 +62,9 @@ struct BtfPoolAnnouncement } }; +// Number of .btf peers discovered from Nostr relays (includes not-yet-connected). +int GetDiscoveredPeerCount(); + // Current live pool announcements discovered from Nostr relays. void BtfGetPoolAnnouncements(std::vector& out); @@ -78,4 +87,21 @@ std::string BtfLocalAddress(); bool BtfResolve(const std::string& btfAddr, std::string& meetingHostPort, unsigned char enc_pub[32]); +// Result of resolving one .btf address via BtfResolveMany. +struct BtfResolvedPeer +{ + std::string meetingHostPort; + unsigned char enc_pub[32]; +}; + +// Resolve many .btf addresses at once. Batches all still-unresolved addresses +// into a single Nostr REQ per relay (multiple "authors"), instead of opening +// one relay connection per address like BtfResolve does. Use this whenever +// resolving more than a handful of addresses in the same pass -- e.g. before +// dialing several discovered peers in parallel -- to avoid hammering the +// same few public relays with a burst of simultaneous connections. Returns +// true if at least one address resolved; check `out` for which ones. +bool BtfResolveMany(const std::vector& btfAddrs, + std::map& out); + #endif diff --git a/src/rc/addressbook16.bmp b/src/rc/addressbook16.bmp deleted file mode 100644 index c557691..0000000 Binary files a/src/rc/addressbook16.bmp and /dev/null differ diff --git a/src/rc/addressbook16mask.bmp b/src/rc/addressbook16mask.bmp deleted file mode 100644 index d3a478d..0000000 Binary files a/src/rc/addressbook16mask.bmp and /dev/null differ diff --git a/src/rc/addressbook20.bmp b/src/rc/addressbook20.bmp deleted file mode 100644 index 2b33b22..0000000 Binary files a/src/rc/addressbook20.bmp and /dev/null differ diff --git a/src/rc/addressbook20mask.bmp b/src/rc/addressbook20mask.bmp deleted file mode 100644 index 56ce612..0000000 Binary files a/src/rc/addressbook20mask.bmp and /dev/null differ diff --git a/src/rc/bitcoin_bc_original.ico b/src/rc/bitcoin_bc_original.ico deleted file mode 100644 index 88cc240..0000000 Binary files a/src/rc/bitcoin_bc_original.ico and /dev/null differ diff --git a/src/rc/check.ico b/src/rc/check.ico deleted file mode 100644 index 0c4e6e8..0000000 Binary files a/src/rc/check.ico and /dev/null differ diff --git a/src/rc/send16.bmp b/src/rc/send16.bmp deleted file mode 100644 index 676b5c4..0000000 Binary files a/src/rc/send16.bmp and /dev/null differ diff --git a/src/rc/send16mask.bmp b/src/rc/send16mask.bmp deleted file mode 100644 index 06c747f..0000000 Binary files a/src/rc/send16mask.bmp and /dev/null differ diff --git a/src/rc/send16masknoshadow.bmp b/src/rc/send16masknoshadow.bmp deleted file mode 100644 index faf24e0..0000000 Binary files a/src/rc/send16masknoshadow.bmp and /dev/null differ diff --git a/src/rc/send20.bmp b/src/rc/send20.bmp deleted file mode 100644 index 2b90422..0000000 Binary files a/src/rc/send20.bmp and /dev/null differ diff --git a/src/rc/send20mask.bmp b/src/rc/send20mask.bmp deleted file mode 100644 index f124d0d..0000000 Binary files a/src/rc/send20mask.bmp and /dev/null differ diff --git a/src/rpc.cpp b/src/rpc.cpp index 99d182e..1b529e8 100644 --- a/src/rpc.cpp +++ b/src/rpc.cpp @@ -1,14 +1,19 @@ // Copyright (c) 2009 Satoshi Nakamoto / Copyright (c) 2026 Bitflash developers // Distributed under the MIT/X11 software license. // -// Bitflash pool server — .btf-only pool listener. -// Miners connect through rendezvous tunnels; no raw IP:port endpoints remain. -// Pending payouts are held until maturity and shown in the GUI. +// Bitflash pool server -- .btf Stratum pool, operator side. // -// Compiles on both Windows (mingw) and Linux unchanged. -// Launched from ui.cpp (GUI) and headless.cpp (node) via ThreadRPCServer(). +// Architecture: +// PoolEventLoop -- single thread, owns all miner fds, all job/share/payout +// state. No locks needed on any of that data. +// AcceptOneFn -- one thread per relay. Blocks on RvServiceRegister + +// BtfServiceWrap, pushes finished socket to gReadyQueue. +// AcceptLoopFn -- spawns AcceptOneFn threads, re-polls relay list every +// 60s so dynamically discovered relays get picked up. +// +// GUI-visible state is snapshotted under gStatsMutex every 2s. +// Accept queue handoff uses gReadyMutex (held for microseconds). -// nlohmann must be included before headers.h because util.h redefines snprintf #pragma push_macro("snprintf") #undef snprintf #include @@ -20,8 +25,6 @@ #endif #include "btftunnel.h" -// POSIX socket headers — already provided by compat.h on Windows, -// and by the OS directly on Linux. #ifndef _WIN32 #include #include @@ -33,14 +36,15 @@ #include #include #include +#include #include +#include +#include #include #include -#include using json = nlohmann::json; -// Cross-platform socket close / send flags #ifdef _WIN32 #define sock_close(s) closesocket(s) #define SEND_FLAGS 0 @@ -49,660 +53,1260 @@ using json = nlohmann::json; #define SEND_FLAGS MSG_NOSIGNAL #endif -// Cross-platform thread launch: wraps _beginthread (compat.h maps it to -// pthread_create+detach on Linux, native _beginthread on Windows). -// fn must be void(*)(void*). -static void LaunchThread(void (*fn)(void*), void* arg) -{ - _beginthread(fn, 0, arg); -} - -// --------------------------------------------------------------------------- -// Config — set by headless.cpp / ui.cpp before threads launch -// --------------------------------------------------------------------------- -std::string gRpcUser; -std::string gRpcPassword; - -// Pool server running flag — set false to stop server cleanly (all loops check it) volatile bool gPoolRunning = false; -// --------------------------------------------------------------------------- -// Stubs for symbols absent from Bitflash 0.1.0 -// --------------------------------------------------------------------------- -double dHashesPerSec = 0.0; -int64 nHPSTimerStart = 0; - -static bool IsInitialBlockDownload() -{ - return (pindexBest == NULL || nBestHeight < 1); -} - // --------------------------------------------------------------------------- // Hex helpers // --------------------------------------------------------------------------- static std::string ToHex(const void* p, size_t n) { const unsigned char* b = (const unsigned char*)p; - std::string s; s.reserve(n*2); + std::string s; s.reserve(n * 2); static const char* H = "0123456789abcdef"; - for (size_t i = 0; i < n; i++) { s += H[b[i]>>4]; s += H[b[i]&0xf]; } + for (size_t i = 0; i < n; i++) { s += H[b[i] >> 4]; s += H[b[i] & 0xf]; } return s; } + static std::vector FromHex(const std::string& s) { std::vector v; - for (size_t i = 0; i+1 < s.size(); i += 2) { - auto h=[](char c)->int{ - if(c>='0'&&c<='9') return c-'0'; - if(c>='a'&&c<='f') return c-'a'+10; - if(c>='A'&&c<='F') return c-'A'+10; + for (size_t i = 0; i + 1 < s.size(); i += 2) { + auto h = [](char c) -> int { + if (c >= '0' && c <= '9') return c - '0'; + if (c >= 'a' && c <= 'f') return c - 'a' + 10; + if (c >= 'A' && c <= 'F') return c - 'A' + 10; return 0; }; - v.push_back((unsigned char)((h(s[i])<<4)|h(s[i+1]))); + v.push_back((unsigned char)((h(s[i]) << 4) | h(s[i + 1]))); } return v; } // --------------------------------------------------------------------------- -// PPLNS share tracking -// --------------------------------------------------------------------------- -struct Share { - std::string minerAddress; - int64 timestamp; - uint64 difficulty; -}; - -struct MinerStats { - std::string address; - std::string worker; - uint64 totalShares = 0; - uint64 roundShares = 0; - int64 lastSeen = 0; -}; -static std::mutex gMinerStatsMutex; -static std::map gMinerStats; - -struct FoundBlock { - int height; - int64 timestamp; - std::string minerAddress; - uint256 hash; -}; -static std::mutex gBlocksMutex; -static std::deque gFoundBlocks; - -struct Miner; -static std::mutex gMinersMutex; -static std::vector gMiners; - -static std::string MinerKey(const std::string& address, const std::string& worker) -{ - return address + "\n" + worker; -} - -void GetPoolWorkerStats(std::vector& out) -{ - out.clear(); - int64 now = GetTime(); - std::lock_guard lk(gMinerStatsMutex); - for (std::map::const_iterator it = gMinerStats.begin(); it != gMinerStats.end(); ++it) - { - const MinerStats& ms = it->second; - if (ms.lastSeen <= 0) - continue; - if (now - ms.lastSeen > 15 * 60) - continue; - PoolWorkerStatView view; - view.address = ms.address; - view.worker = ms.worker; - view.totalShares = ms.totalShares; - view.roundShares = ms.roundShares; - view.lastSeen = ms.lastSeen; - out.push_back(view); - } - std::sort(out.begin(), out.end(), [](const PoolWorkerStatView& a, const PoolWorkerStatView& b) { - if (a.roundShares != b.roundShares) return a.roundShares > b.roundShares; - if (a.totalShares != b.totalShares) return a.totalShares > b.totalShares; - return a.lastSeen > b.lastSeen; - }); -} - -// --------------------------------------------------------------------------- -// Current mining job +// Job -- written and read exclusively by the event loop thread // --------------------------------------------------------------------------- struct StratumJob { std::string jobId; CBlock block; - uint256 target; - uint256 shareTarget; + uint256 target; // full block target; share targets are per-miner via vardiff int height; }; +static StratumJob gCurrentJob; +static bool gHaveJob = false; + +// Compute a share target from a Stratum difficulty value. +// Stratum difficulty 1 must correspond to THIS chain's easiest allowed target +// (bnProofOfWorkLimit = ~uint256(0) >> 12, compact 0x1f0fffff), not Bitcoin's +// SHA256 diff-1 target (0x1d00ffff). Those differ by about 2^20 (~1e6x): +// Bitcoin's diff-1 needs ~2^32 hashes/share on average, while this chain's PoW +// floor needs only ~2^12. Using the Bitcoin constant made every miner's +// "difficulty 1" about a million times harder than the chain's own floor, so a +// worker could hash for minutes without ever finding a share -- and vardiff +// never gets the samples it needs to correct course, because no share ever +// comes in for it to measure. Base off the actual chain limit instead so +// difficulty 1 is genuinely easy and vardiff has real data to retarget from. +// Higher difficulty = smaller (harder) target. +static uint256 ShareTargetFromDifficulty(double difficulty) +{ + if (difficulty <= 0.0) difficulty = 1.0; + static const uint256 diff1 = CBigNum(bnProofOfWorkLimit).getuint256(); + // shareTarget = diff1 / difficulty + CBigNum bn; bn.setuint256(diff1); + // Multiply by 2^16 to keep precision, divide, shift back + bn <<= 16; + bn /= (uint64)(difficulty * 65536.0); + uint256 t = bn.getuint256(); + // Cap at all-ones (maximum, easiest possible) + if (t < diff1 && difficulty < 1.0) t = ~uint256(0); + return t; +} -static std::mutex gJobMutex; -static StratumJob gCurrentJob; -static bool gHaveJob = false; -static std::atomic gJobSeq{0}; - -static uint256 MakeShareTarget(const uint256& blockTarget) +// Expected number of hashes to find one share at a given Stratum difficulty, +// on this chain's scale (difficulty 1 == bnProofOfWorkLimit, which needs on +// average 2^12 = 4096 hashes). Used to turn "shares per second" into H/s. +static const double SHARE_HASHES_AT_DIFF_1 = 4096.0; +static double ExpectedHashesForDifficulty(double difficulty) { - uint256 shareTarget = blockTarget; - shareTarget <<= 8; - if (shareTarget < blockTarget) - shareTarget = ~uint256(0); - return shareTarget; + if (difficulty <= 0.0) difficulty = 1.0; + return difficulty * SHARE_HASHES_AT_DIFF_1; } +// Rebuild the current job from the best chain tip. +// Must be called from the event loop thread (or startup, before the loop). +// Returns true on success. Logs the specific failure reason on false. static bool RebuildJob() { - if (!pindexBest) return false; + CRITICAL_BLOCK(cs_main) + { + if (!pindexBest) { + LogPrint("pool", "RebuildJob: no chain tip (pindexBest is null)\n"); + return false; + } - CBlockIndex* pindexPrev = pindexBest; - unsigned int nBits = GetNextWorkRequired(pindexPrev); + CBlockIndex* pindexPrev = pindexBest; + unsigned int nBits = GetNextWorkRequired(pindexPrev); - CKey key; - key.MakeNewKey(); - static std::atomic sExtra{0}; - uint32_t extraNonce = ++sExtra; + CKey key; key.MakeNewKey(); + static std::atomic sExtra{0}; + uint32_t extraNonce = ++sExtra; - CTransaction txNew; - txNew.vin.resize(1); - txNew.vin[0].prevout.SetNull(); - txNew.vin[0].scriptSig << nBits << (CBigNum)extraNonce; - txNew.vout.resize(1); - // Always pay coinbase to a fresh wallet key. DoPayouts() distributes - // shares to miners via SendMoney() after the block is accepted. - txNew.vout[0].scriptPubKey << key.GetPubKey() << OP_CHECKSIG; + CTransaction txNew; + txNew.vin.resize(1); + txNew.vin[0].prevout.SetNull(); + txNew.vin[0].scriptSig << nBits << (CBigNum)extraNonce; + txNew.vout.resize(1); + txNew.vout[0].scriptPubKey << key.GetPubKey() << OP_CHECKSIG; - CBlock block; - block.vtx.push_back(txNew); + CBlock block; + block.vtx.push_back(txNew); - { - CTxDB txdb("r"); - CRITICAL_BLOCK(cs_mapTransactions) { - map pool; - unsigned int sz = 0; - for (auto& kv : mapTransactions) { - CTransaction& tx = kv.second; - if (tx.IsCoinBase() || !tx.IsFinal()) continue; - int64 nFees = 0; - int64 nMinFee = tx.GetMinFee(block.vtx.size() < 100); - map tmp(pool); - if (!tx.ConnectInputs(txdb, tmp, CDiskTxPos(1,1,1), 0, nFees, false, true, nMinFee)) continue; - pool = tmp; - block.vtx.push_back(tx); - sz += ::GetSerializeSize(tx, SER_NETWORK); - if (sz > MAX_SIZE/2) break; + CTxDB txdb("r"); + CRITICAL_BLOCK(cs_mapTransactions) + { + map pool; + unsigned int sz = 0; + for (auto& kv : mapTransactions) { + CTransaction& tx = kv.second; + if (tx.IsCoinBase() || !tx.IsFinal()) continue; + int64 nFees = 0; + map tmp(pool); + if (!tx.ConnectInputs(txdb, tmp, CDiskTxPos(1,1,1), 0, nFees, + false, true, tx.GetMinFee(block.vtx.size() < 100))) + continue; + pool = tmp; + block.vtx.push_back(tx); + sz += ::GetSerializeSize(tx, SER_NETWORK); + if (sz > MAX_SIZE / 2) break; + } } } + + block.vtx[0].vout[0].nValue = block.GetBlockValue(0); + block.hashPrevBlock = pindexPrev->GetBlockHash(); + block.hashMerkleRoot = block.BuildMerkleTree(); + block.nTime = max((unsigned int)(pindexPrev->GetMedianTimePast() + 1), + (unsigned int)GetAdjustedTime()); + block.nBits = nBits; + block.nNonce = 1; + AddKey(key); + + StratumJob job; + job.jobId = ToHex(&extraNonce, 4); + job.block = block; + job.target = CBigNum().SetCompact(nBits).getuint256(); + job.height = nBestHeight + 1; + + gCurrentJob = job; + gHaveJob = true; + return true; } - block.vtx[0].vout[0].nValue = block.GetBlockValue(0); - block.hashPrevBlock = pindexPrev->GetBlockHash(); - block.hashMerkleRoot = block.BuildMerkleTree(); - block.nTime = max((unsigned int)(pindexPrev->GetMedianTimePast()+1), - (unsigned int)GetAdjustedTime()); - block.nBits = nBits; - block.nNonce = 1; - - AddKey(key); - - StratumJob job; - job.jobId = ToHex(&extraNonce, 4); - job.block = block; - job.target = CBigNum().SetCompact(nBits).getuint256(); - job.shareTarget = MakeShareTarget(job.target); - job.height = nBestHeight + 1; - - std::lock_guard lk(gJobMutex); - gCurrentJob = job; - gHaveJob = true; - ++gJobSeq; - return true; + return false; } // --------------------------------------------------------------------------- -// PPLNS payout -// --------------------------------------------------------------------------- -// --------------------------------------------------------------------------- -// Deferred payouts — coinbase outputs require COINBASE_MATURITY (100) blocks -// before they can be spent. We store the share snapshot when a block is found -// and execute the actual SendMoney() calls once the coinbase matures. +// Payouts -- event loop thread only // --------------------------------------------------------------------------- struct PendingPayout { - int matureAtHeight; // pay out when nBestHeight >= this - std::map amounts; // addr -> BTF amount + int matureAtHeight; + std::map amounts; // address -> satoshis }; +static std::vector gPendingPayouts; -static std::mutex gPayoutMutex; -static std::vector gPendingPayouts; +static std::string PayoutFilePath() +{ + return GetAppDir() + "/pending_payouts.json"; +} -void GetPendingPayouts(std::vector& out) +static void SavePendingPayouts() { - out.clear(); - std::lock_guard lk(gPayoutMutex); - for (const PendingPayout& p : gPendingPayouts) - { - PendingPayoutView view; - view.matureAtHeight = p.matureAtHeight; - view.recipients = (int)p.amounts.size(); - view.totalAmount = 0; - for (const std::map::value_type& kv : p.amounts) - view.totalAmount += kv.second; - out.push_back(view); + nlohmann::json arr = nlohmann::json::array(); + for (const auto& pp : gPendingPayouts) { + nlohmann::json obj; + obj["matureAtHeight"] = pp.matureAtHeight; + nlohmann::json amounts = nlohmann::json::object(); + for (const auto& kv : pp.amounts) + amounts[kv.first] = kv.second; + obj["amounts"] = amounts; + arr.push_back(obj); + } + std::string path = PayoutFilePath(); + FILE* f = fopen(path.c_str(), "w"); + if (f) { + std::string s = arr.dump(2); + fwrite(s.c_str(), 1, s.size(), f); + fclose(f); + } else { + LogPrint("payout", "[payout] WARNING: could not write %s\n", path.c_str()); } } -// Called when a block is found: snapshot shares, schedule payout for maturity. -static void QueuePayouts(int blockHeight) +static void LoadPendingPayouts() { - if (nMineMode != MINE_OPERATOR) return; - - std::map shareCount; - uint64 total = 0; - { - std::lock_guard lk(gMinerStatsMutex); - for (std::map::iterator it = gMinerStats.begin(); it != gMinerStats.end(); ++it) - { - MinerStats& ms = it->second; - if (ms.roundShares == 0) - continue; - if (ms.address.empty()) - continue; - shareCount[ms.address] += ms.roundShares; - total += ms.roundShares; - ms.roundShares = 0; + std::string path = PayoutFilePath(); + FILE* f = fopen(path.c_str(), "r"); + if (!f) return; // no file yet -- first run, nothing to load + fseek(f, 0, SEEK_END); + long sz = ftell(f); + fseek(f, 0, SEEK_SET); + std::string s(sz, '\0'); + fread(&s[0], 1, sz, f); + fclose(f); + try { + nlohmann::json arr = nlohmann::json::parse(s); + for (const auto& obj : arr) { + PendingPayout pp; + pp.matureAtHeight = obj["matureAtHeight"].get(); + for (auto it = obj["amounts"].begin(); it != obj["amounts"].end(); ++it) + pp.amounts[it.key()] = it.value().get(); + gPendingPayouts.push_back(pp); } + LogPrint("payout", "[payout] loaded %zu pending payout(s) from disk\n", + gPendingPayouts.size()); + } catch (const std::exception& e) { + LogPrint("payout", "[payout] WARNING: failed to parse %s: %s\n", + path.c_str(), e.what()); + } +} + +// Payouts ready to execute are moved here under gPayoutExecMutex. +// A dedicated thread drains this list so SendMoney (which takes cs_main) +// never runs on the event loop thread and can't stall miner I/O. +static std::vector gPayoutExecQueue; +static std::mutex gPayoutExecMutex; +static std::atomic gPayoutThreadRunning{false}; + +static void QueuePayouts(int blockHeight, + const std::map& shareCount, + uint64 total) +{ + if (total == 0) { + LogPrint("payout", "[payout] block %d: zero shares recorded -- nothing to pay out\n", + blockHeight); + return; } - if (total == 0) return; - int64 blockReward = CBlock().GetBlockValue(0); + double feeFrac = dPoolFeePercent / 100.0; + if (feeFrac < 0.0) feeFrac = 0.0; + if (feeFrac > 1.0) feeFrac = 1.0; + + // Compute the block subsidy at the height that was just found. + // We do not include tx fees in miner payout -- those stay in the operator + // wallet as part of the coinbase, which is the operator's cut on top of feeFrac. + int64 nSubsidy = 50 * COIN; + nSubsidy >>= (blockHeight / 210000); // halving schedule + int64 minerReward = (int64)((double)nSubsidy * (1.0 - feeFrac)); + PendingPayout pp; pp.matureAtHeight = blockHeight + COINBASE_MATURITY; + + int skipped = 0; + int64 burned = 0; // amount owed to miners with bad/empty addresses for (auto& kv : shareCount) { - int64 amount = (int64)(((double)kv.second / (double)total) * (double)blockReward); - if (amount >= CENT) - pp.amounts[kv.first] = amount; + int64 amount = (int64)(((double)kv.second / (double)total) * (double)minerReward); + if (amount < CENT) { + skipped++; + continue; + } + uint160 addrCheck; + if (kv.first.empty() || !AddressToHash160(kv.first, addrCheck)) { + // Address was invalid at authorize time (warned then). Their share + // of the reward cannot be paid -- log the amount so it's visible. + burned += amount; + LogPrint("payout", "[payout] WARNING: %s owed to miner with invalid" + " address '%s' -- cannot pay out, amount forfeited\n", + FormatMoney(amount).c_str(), kv.first.c_str()); + continue; + } + pp.amounts[kv.first] = amount; } - { std::lock_guard lk(gPayoutMutex); gPendingPayouts.push_back(pp); } - - printf("Pool: block %d payout queued for height %d (%zu miners)\n", - blockHeight, pp.matureAtHeight, pp.amounts.size()); + gPendingPayouts.push_back(pp); + SavePendingPayouts(); + + int64 operatorCut = nSubsidy - minerReward; + LogPrint("payout", + "[payout] block %d: subsidy %s BTF, miners get %s" + " (fee %.2f%% = %s to operator)," + " %zu recipients, matures height %d," + " %d below dust%s\n", + blockHeight, + FormatMoney(nSubsidy).c_str(), + FormatMoney(minerReward).c_str(), + dPoolFeePercent, + FormatMoney(operatorCut).c_str(), + pp.amounts.size(), pp.matureAtHeight, skipped, + burned > 0 ? (", " + FormatMoney(burned) + " forfeited (bad address)").c_str() + : ""); } -// Called from JobWatcher every new block: execute any matured payouts. +// Called from event loop: move matured entries to the exec queue (fast). +// Save BEFORE moving to exec queue: if we crash between the save and SendMoney, +// LoadPendingPayouts re-queues them from the exec-queue section of the file. static void FlushMaturePayouts() { - if (nMineMode != MINE_OPERATOR) return; - - std::lock_guard lk(gPayoutMutex); + std::vector ready; for (auto it = gPendingPayouts.begin(); it != gPendingPayouts.end(); ) { if (nBestHeight < it->matureAtHeight) { ++it; continue; } - - printf("Pool: executing payout for block matured at height %d\n", it->matureAtHeight); - for (auto& kv : it->amounts) { - uint160 hash160; - if (!AddressToHash160(kv.first, hash160)) continue; - CScript scriptPubKey; - scriptPubKey << OP_DUP << OP_HASH160 << hash160 << OP_EQUALVERIFY << OP_CHECKSIG; - CWalletTx wtx; - wtx.mapValue["comment"] = strprintf("Pool payout height %d", it->matureAtHeight); - if (SendMoney(scriptPubKey, kv.second, wtx)) - printf("Pool: paid %s to %s\n", FormatMoney(kv.second).c_str(), kv.first.c_str()); + LogPrint("payout", "[payout] block height %d matured -- queuing %zu payments\n", + it->matureAtHeight, it->amounts.size()); + ready.push_back(*it); // copy first; erase after save + ++it; + } + if (!ready.empty()) { + // Erase from pending then save before handing off to exec thread, + // so the file never contains an entry that is already being sent. + for (auto it = gPendingPayouts.begin(); it != gPendingPayouts.end(); ) { + if (nBestHeight >= it->matureAtHeight) + it = gPendingPayouts.erase(it); else - printf("Pool: payout FAILED for %s (will not retry)\n", kv.first.c_str()); + ++it; + } + SavePendingPayouts(); + std::lock_guard lk(gPayoutExecMutex); + for (auto& pp : ready) + gPayoutExecQueue.push_back(std::move(pp)); + } +} + +// Payout execution thread: drains gPayoutExecQueue, calls SendMoney. +// Runs independently of the event loop so slow wallet operations +// (SendMoney takes cs_main) never stall miner I/O. +static void PayoutThreadFn(void*) +{ + gPayoutThreadRunning = true; + LogPrint("payout", "[payout] payout thread started\n"); + + while (!fShutdown && gPoolRunning) { + std::vector batch; + { + std::lock_guard lk(gPayoutExecMutex); + batch.swap(gPayoutExecQueue); + } + + for (auto& pp : batch) { + LogPrint("payout", "[payout] executing payments for block matured at %d" + " (%zu miners)\n", pp.matureAtHeight, pp.amounts.size()); + for (auto& kv : pp.amounts) { + uint160 h160; + if (!AddressToHash160(kv.first, h160)) { + // Should not happen -- bad addresses are filtered in QueuePayouts + LogPrint("payout", "[payout] SKIPPED bad address '%s'\n", + kv.first.c_str()); + continue; + } + CScript sc; + sc << OP_DUP << OP_HASH160 << h160 << OP_EQUALVERIFY << OP_CHECKSIG; + CWalletTx wtx; + wtx.mapValue["comment"] = strprintf("Pool payout height %d", + pp.matureAtHeight); + if (SendMoney(sc, kv.second, wtx)) + LogPrint("payout", "[payout] paid %s to %s\n", + FormatMoney(kv.second).c_str(), kv.first.c_str()); + else + LogPrint("payout", "[payout] FAILED to pay %s to %s" + " (insufficient funds or wallet locked?)\n", + FormatMoney(kv.second).c_str(), kv.first.c_str()); + } } - it = gPendingPayouts.erase(it); + + Sleep(500); // check for new work every 500ms } + + gPayoutThreadRunning = false; + LogPrint("payout", "[payout] payout thread stopped\n"); } // --------------------------------------------------------------------------- -// Stratum protocol +// GUI stats snapshot -- event loop writes, GUI threads read // --------------------------------------------------------------------------- -static json MakeNotifyParams(const StratumJob& job, bool clean) +struct StatsSnapshot { + int authorizedMiners = 0; + int blocksFound = 0; + uint64 roundShares = 0; + double totalHashRate = 0.0; +}; +static StatsSnapshot gStats; +static std::vector gWorkerStats; +static std::vector gPayoutViews; +static std::mutex gStatsMutex; + +void GetPoolOperatorStats(int& authorizedMiners, int& blocksFound, uint64& roundShares, double& totalHashRate) { - CBlock& b = const_cast(job.block); - unsigned char hdr[80]; - memcpy(hdr, &b.nVersion, 4); - memcpy(hdr+4, &b.hashPrevBlock, 32); - memcpy(hdr+36, &b.hashMerkleRoot, 32); - memcpy(hdr+68, &b.nTime, 4); - memcpy(hdr+72, &b.nBits, 4); - memcpy(hdr+76, &b.nNonce, 4); - unsigned char shareTgt[32]; memcpy(shareTgt, &job.shareTarget, 32); - return json::array({job.jobId, ToHex(hdr,80), b.hashPrevBlock.GetHex(), ToHex(shareTgt,32), clean}); + std::lock_guard lk(gStatsMutex); + authorizedMiners = gStats.authorizedMiners; + blocksFound = gStats.blocksFound; + roundShares = gStats.roundShares; + totalHashRate = gStats.totalHashRate; +} + +void GetPoolWorkerStats(std::vector& out) +{ + std::lock_guard lk(gStatsMutex); + out = gWorkerStats; +} + +void GetPendingPayouts(std::vector& out) +{ + std::lock_guard lk(gStatsMutex); + out = gPayoutViews; } +// --------------------------------------------------------------------------- +// Miner state -- event loop thread only +// --------------------------------------------------------------------------- +static std::atomic gNextExtranonce2{1}; + +// Vardiff target: aim for one share every VARDIFF_TARGET_SECS seconds per miner. +// After VARDIFF_SHARES_PER_CHECK shares, recalculate and adjust if rate is off +// by more than 50%. Shares are the stable unit; difficulty floats to match. +static const int64 VARDIFF_TARGET_SECS = 15; // one share every 15s +static const uint64 VARDIFF_SHARES_PER_CHECK = 5; // re-evaluate every 5 shares +static const double VARDIFF_RETARGET_RATIO = 1.5; // adjust if >50% off target +static const double VARDIFF_MIN = 0.001; +static const double VARDIFF_MAX = 1e12; +// If a miner has gone this long since its vardiff window opened with ZERO +// shares, the normal vardiff retarget (which only fires after +// VARDIFF_SHARES_PER_CHECK shares) can never trigger -- it has no samples to +// act on. This is the residual gap the ShareTargetFromDifficulty fix above +// doesn't cover: even with a correctly-easy diff-1 target, a miner stuck in +// RandomX light mode (or otherwise much slower than expected) can go +// indefinitely without a single share, and difficulty would never drop to +// meet it. Force a difficulty cut on a timeout instead of waiting for shares +// that will never come. +static const int64 VARDIFF_STALL_SECS = 45; // 3x share target -- reacts faster without false-triggering on normal variance +static const double VARDIFF_STALL_CUT = 4.0; // divide difficulty by this much + struct Miner { - SOCKET fd; - bool authorised; - std::string address; - std::string worker; - std::string readBuf; - int64 connectTime; - uint64 sessionShares; - Miner(SOCKET f) : fd(f), authorised(false), connectTime(GetTime()), sessionShares(0) {} + btf_socket_t fd; + bool authorised = false; + std::string address; + std::string worker; + std::string readBuf; + int64 connectTime; + uint64 sessionShares = 0; + uint64 roundShares = 0; + uint64 totalShares = 0; + int64 lastSeen = 0; + uint32_t extranonce2; + + // Vardiff state + double difficulty = 1.0; // current share difficulty sent to this miner + uint256 shareTarget; // derived from difficulty; what we check against + int64 vardiffWindowStart = 0; // when the current vardiff window opened + uint64 vardiffShares = 0; // shares found in current window + + // Hashrate estimate -- EWMA of (expected hashes per share at current + // difficulty) / (seconds since the previous share). We had all the data + // to compute this (difficulty + share timing) but never actually did, + // so pool/GUI hashrate always showed as zero/missing. + int64 lastShareTime = 0; // GetTime() of the previous accepted share + double hashRate = 0.0; // smoothed H/s estimate + + explicit Miner(btf_socket_t f) + : fd(f), connectTime(GetTime()), + extranonce2(gNextExtranonce2.fetch_add(1)) {} }; -void GetPoolOperatorStats(int& authorizedMiners, int& blocksFound, uint64& roundShares) +static std::vector gMiners; + +struct FoundBlock { int height; int64 ts; std::string addr; }; +static std::deque gFoundBlocks; + +// --------------------------------------------------------------------------- +// Accept queue -- accept threads push; event loop pops at top of each tick +// --------------------------------------------------------------------------- +static std::vector gReadyQueue; +static std::mutex gReadyMutex; + +static void PushReady(btf_socket_t s) { - authorizedMiners = 0; - blocksFound = 0; - roundShares = 0; - { - std::lock_guard lk(gMinersMutex); - for (Miner* m : gMiners) - if (m->authorised) - authorizedMiners++; + std::lock_guard lk(gReadyMutex); + gReadyQueue.push_back(s); +} + +// --------------------------------------------------------------------------- +// I/O helpers -- event loop thread only +// --------------------------------------------------------------------------- + +// Blocking send of a complete JSON line. Called only from the event loop so +// no other thread is writing to the same fd concurrently. +static bool SendLine(btf_socket_t fd, const json& j) +{ + std::string s = j.dump() + "\n"; + int off = 0, total = (int)s.size(); + while (off < total) { + int r = send(fd, s.c_str() + off, total - off, SEND_FLAGS); + if (r <= 0) return false; + off += r; } - { - std::lock_guard lk(gBlocksMutex); - blocksFound = (int)gFoundBlocks.size(); + return true; +} + +// Non-blocking recv: pull whatever bytes are available now into buf, then +// return one complete line if one is ready. Three outcomes: +// true -- line is filled; buf may still contain more lines +// false, errno EAGAIN/EWOULDBLOCK (or FIONREAD==0) -- no data right now +// false, r==0 -- clean disconnect; caller must treat fd as dead +// Caller distinguishes disconnect from "not yet" by checking whether +// line.empty() after a false return (line is "" on both paths, so we +// use a separate out-param for the disconnect signal). +static bool RecvLine(btf_socket_t fd, std::string& buf, + std::string& line, bool& disconnected) +{ + disconnected = false; + char tmp[4096]; + +#ifdef _WIN32 + u_long avail = 0; + ioctlsocket(fd, FIONREAD, &avail); + if (avail > 0) { + int r = recv(fd, tmp, (int)std::min((u_long)(sizeof(tmp)-1), avail), 0); + if (r > 0) { tmp[r] = 0; buf += tmp; } + else if (r == 0){ disconnected = true; return false; } + // r < 0: WSAEWOULDBLOCK -- fall through with what we have } +#else { - std::lock_guard lk(gMinerStatsMutex); - for (std::map::const_iterator it = gMinerStats.begin(); it != gMinerStats.end(); ++it) - roundShares += it->second.roundShares; + int r = recv(fd, tmp, sizeof(tmp) - 1, MSG_DONTWAIT); + if (r > 0) { tmp[r] = 0; buf += tmp; } + else if (r == 0) { disconnected = true; return false; } + // r < 0: EAGAIN/EWOULDBLOCK -- fall through with what we have } +#endif + + size_t pos = buf.find('\n'); + if (pos == std::string::npos) return false; // no complete line yet + + line = buf.substr(0, pos); + if (!line.empty() && line.back() == '\r') line.pop_back(); + buf = buf.substr(pos + 1); + return true; } -static bool SendLine(Miner* m, const json& j) +// --------------------------------------------------------------------------- +// Job helpers +// --------------------------------------------------------------------------- +static CBlock BuildMinerBlock(const StratumJob& job, uint32_t extranonce2) { - std::string s = j.dump() + "\n"; - int off = 0; - while (off < (int)s.size()) { - int n = send(m->fd, s.c_str() + off, (int)s.size() - off, SEND_FLAGS); - if (n <= 0) - return false; - off += n; - } - return true; + CBlock blk = job.block; + blk.vtx[0].vin[0].scriptSig << (CBigNum)extranonce2; + blk.hashMerkleRoot = blk.BuildMerkleTree(); + return blk; +} + +// shareTarget is per-miner (set by vardiff) and passed explicitly so the +// value in p[3] of mining.notify always matches what HandleLine checks on submit. +// This is the single source of truth for share difficulty. +static json MakeNotifyParams(const StratumJob& job, const CBlock& b, + const uint256& shareTarget, bool clean) +{ + unsigned char hdr[80]; + memcpy(hdr, &b.nVersion, 4); + memcpy(hdr+4, &b.hashPrevBlock, 32); + memcpy(hdr+36, &b.hashMerkleRoot, 32); + memcpy(hdr+68, &b.nTime, 4); + memcpy(hdr+72, &b.nBits, 4); + memcpy(hdr+76, &b.nNonce, 4); + unsigned char tgt[32]; memcpy(tgt, &shareTarget, 32); + return json::array({job.jobId, ToHex(hdr, 80), + b.hashPrevBlock.GetHex(), ToHex(tgt, 32), clean}); +} + +static void SendJob(Miner* m, bool clean) +{ + if (!gHaveJob) return; + CBlock mb = BuildMinerBlock(gCurrentJob, m->extranonce2); + // Use this miner's own shareTarget (set by vardiff) in the notify params. + // The participant miner reads p[3] as its share target, and HandleLine + // checks m->shareTarget on submit -- both sides now use the same value. + json n = {{"id",nullptr},{"method","mining.notify"}, + {"params", MakeNotifyParams(gCurrentJob, mb, m->shareTarget, clean)}}; + if (!SendLine(m->fd, n)) + LogPrint("worker", "[pool->worker] job send FAILED for %s -- miner will be dropped\n", + m->address.empty() ? "(not yet authorized)" : m->address.c_str()); } static void BroadcastJob(bool clean) { - StratumJob job; - { std::lock_guard lk(gJobMutex); if(!gHaveJob) return; job=gCurrentJob; } - json n = {{"id",nullptr},{"method","mining.notify"},{"params",MakeNotifyParams(job,clean)}}; - std::lock_guard lk(gMinersMutex); - for (Miner* m : gMiners) - if (m->authorised && !SendLine(m, n)) - printf("Stratum: failed to broadcast job to %s\n", m->address.c_str()); + if (!gHaveJob) return; + int sent = 0; + for (Miner* m : gMiners) { + if (!m->authorised) continue; + SendJob(m, clean); + sent++; + } + LogPrint("pool", "[pool] job %s broadcast to %d miner(s)\n", + gCurrentJob.jobId.c_str(), sent); } -static void HandleLine(Miner* m, const std::string& line) +// --------------------------------------------------------------------------- +// Stratum message handler -- called for each complete line from a miner +// Returns false if the miner should be disconnected immediately. +// --------------------------------------------------------------------------- +static bool HandleLine(Miner* m, const std::string& rawLine, + std::map& roundShareCount, + uint64& roundShareTotal, + int& blocksFoundThisSession) { json req; - try { req = json::parse(line); } catch(...){ return; } + try { req = json::parse(rawLine); } + catch (...) { + LogPrint("worker", "[worker] JSON parse error from %s: %.80s\n", + m->address.empty() ? "(unauth)" : m->address.c_str(), + rawLine.c_str()); + return true; // don't disconnect on bad JSON, just ignore the line + } - json id = req.value("id", json(nullptr)); - std::string method = req.value("method",""); + json id = req.value("id", json(nullptr)); + std::string method = req.value("method", ""); - auto reply = [&](json result, json error=nullptr){ - if (!SendLine(m, json{{"id",id},{"result",result},{"error",error}})) - printf("Stratum: failed to reply to miner %s\n", m->address.c_str()); + // Reply helper: always sends result+error pair as Stratum requires + auto reply = [&](json result, std::string error = "") { + json err = error.empty() ? json(nullptr) : json(error); + if (!SendLine(m->fd, json{{"id",id},{"result",result},{"error",err}})) + LogPrint("worker", "[pool->worker] reply send FAILED for %s (method=%s)\n", + m->address.empty() ? "(unauth)" : m->address.c_str(), + method.c_str()); }; + // ------------------------------------------------------------------ if (method == "mining.subscribe") { std::string sid = ToHex(&m->fd, 4); - printf("Stratum: miner subscribed %s\n", sid.c_str()); + LogPrint("worker", "[worker->pool] subscribe from fd=%d sid=%s\n", + (int)m->fd, sid.c_str()); reply(json::array({json::array({json::array({"mining.notify",sid})}),sid,4})); - return; + return true; } + // ------------------------------------------------------------------ if (method == "mining.authorize") { auto& p = req["params"]; - m->address = (p.size()>0 && p[0].is_string()) ? p[0].get() : ""; - m->worker = (p.size()>1 && p[1].is_string()) ? p[1].get() : "worker"; - m->authorised = true; - printf("Stratum: miner authorized address=%s worker=%s\n", m->address.c_str(), m->worker.c_str()); - reply(true); + m->address = (p.size() > 0 && p[0].is_string()) ? p[0].get() : ""; + m->worker = (p.size() > 1 && p[1].is_string()) ? p[1].get() : "worker"; + m->lastSeen = GetTime(); - uint160 h; bool valid = AddressToHash160(m->address, h); - if (!valid) printf("Stratum: miner %s invalid address '%s' — shares won't pay out\n", - m->worker.c_str(), m->address.c_str()); + // Validate address before authorising + uint160 h160; bool validAddr = AddressToHash160(m->address, h160); - { std::lock_guard lk(gMinerStatsMutex); - auto& ms = gMinerStats[m->address]; - ms.lastSeen = GetTime(); ms.worker = m->worker; } + m->authorised = true; // authorise regardless -- shares just won't pay out + LogPrint("worker", "[worker->pool] authorize address=%s worker=%s addr_valid=%s\n", + m->address.c_str(), m->worker.c_str(), validAddr ? "yes" : "NO"); - if (!SendLine(m, json{{"id",nullptr},{"method","mining.set_difficulty"},{"params",json::array({1})}})) - printf("Stratum: failed to send difficulty to %s\n", m->address.c_str()); + if (!validAddr) + LogPrint("worker", "[pool] WARNING: address '%s' is invalid -- " + "miner will hash but shares cannot be paid out\n", + m->address.c_str()); - StratumJob job; bool have; - { std::lock_guard lk(gJobMutex); have=gHaveJob; if(have) job=gCurrentJob; } - if (have) { - printf("Stratum: sending job %s to %s\n", job.jobId.c_str(), m->address.c_str()); - if (!SendLine(m, json{{"id",nullptr},{"method","mining.notify"},{"params",MakeNotifyParams(job,true)}})) - printf("Stratum: failed to send job %s to %s\n", job.jobId.c_str(), m->address.c_str()); + reply(true); + + // Initialize vardiff state and send initial difficulty + m->difficulty = 1.0; + m->shareTarget = ShareTargetFromDifficulty(m->difficulty); + m->vardiffWindowStart = GetTime(); + m->vardiffShares = 0; + LogPrint("worker", "[pool->worker] initial difficulty=%.4f for %s\n", + m->difficulty, m->address.c_str()); + if (!SendLine(m->fd, json{{"id",nullptr},{"method","mining.set_difficulty"}, + {"params",json::array({m->difficulty})}})) + LogPrint("worker", "[pool->worker] set_difficulty send FAILED for %s\n", + m->address.c_str()); + + if (gHaveJob) { + LogPrint("worker", "[pool->worker] sending job %s to %s\n", + gCurrentJob.jobId.c_str(), m->address.c_str()); + SendJob(m, true); } else { - printf("Stratum: no job available for %s\n", m->address.c_str()); + LogPrint("worker", "[pool->worker] no job available yet for %s" + " -- will be sent one as soon as the pool builds it\n", + m->address.c_str()); } - return; + return true; } - if (method == "mining.extranonce.subscribe") { reply(true); return; } + // ------------------------------------------------------------------ + if (method == "mining.extranonce.subscribe") { reply(true); return true; } + if (method == "mining.get_transactions") { reply(json::array()); return true; } + // ------------------------------------------------------------------ if (method == "mining.submit") { - if (!m->authorised) { reply(false,"not authorised"); return; } + if (!m->authorised) { + LogPrint("worker", "[worker->pool] submit from unauthorized miner (fd=%d)\n", + (int)m->fd); + reply(false, "not authorised"); + return true; + } auto& p = req["params"]; - if (p.size() < 4) { reply(false,"bad params"); return; } + if (p.size() < 3) { + LogPrint("worker", "[worker->pool] submit bad params from %s (size=%zu)\n", + m->address.c_str(), p.size()); + reply(false, "bad params"); + return true; + } - std::string jobId = p[1].get(); - std::string nonceHex = p[2].get(); + std::string submitJobId = p[1].get(); + std::string nonceHex = p[2].get(); - StratumJob job; - { std::lock_guard lk(gJobMutex); - if (!gHaveJob || gCurrentJob.jobId != jobId) { reply(false,"stale job"); return; } - job = gCurrentJob; } + if (!gHaveJob) { + LogPrint("worker", "[worker->pool] submit from %s but pool has no current job\n", + m->address.c_str()); + reply(false, "stale job"); + return true; + } + if (gCurrentJob.jobId != submitJobId) { + LogPrint("worker", "[worker->pool] submit stale job from %s" + " (submitted=%s current=%s)\n", + m->address.c_str(), submitJobId.c_str(), gCurrentJob.jobId.c_str()); + reply(false, "stale job"); + return true; + } auto nb = FromHex(nonceHex); - if (nb.size()<4) { reply(false,"bad nonce"); return; } + if (nb.size() < 4) { + LogPrint("worker", "[worker->pool] submit bad nonce from %s: '%s'\n", + m->address.c_str(), nonceHex.c_str()); + reply(false, "bad nonce"); + return true; + } unsigned int nNonce; memcpy(&nNonce, nb.data(), 4); - CBlock& b = job.block; + // Rebuild this miner's exact block (unique merkle via extranonce2) + CBlock b = BuildMinerBlock(gCurrentJob, m->extranonce2); b.nNonce = nNonce; unsigned char hdr[80]; - memcpy(hdr, &b.nVersion, 4); - memcpy(hdr+4, &b.hashPrevBlock, 32); - memcpy(hdr+36, &b.hashMerkleRoot,32); - memcpy(hdr+68, &b.nTime, 4); - memcpy(hdr+72, &b.nBits, 4); - memcpy(hdr+76, &b.nNonce, 4); + memcpy(hdr, &b.nVersion, 4); + memcpy(hdr+4, &b.hashPrevBlock, 32); + memcpy(hdr+36, &b.hashMerkleRoot, 32); + memcpy(hdr+68, &b.nTime, 4); + memcpy(hdr+72, &b.nBits, 4); + memcpy(hdr+76, &b.nNonce, 4); uint256 powHash = RandomXPoWHash(hdr, 80); - // Record share in the current round. - std::string key = MinerKey(m->address, m->worker); - { std::lock_guard lk(gMinerStatsMutex); - MinerStats& ms = gMinerStats[key]; - ms.address = m->address; - ms.worker = m->worker; - ms.roundShares++; - ms.totalShares++; - ms.lastSeen = GetTime(); } + // Check against this miner's personal share target (set by vardiff) + if (powHash > m->shareTarget) { + LogPrint("worker", "[worker->pool] high-hash from %s" + " nonce=%u (difficulty=%.4f)\n", + m->address.c_str(), nNonce, m->difficulty); + reply(false, "high-hash"); + return true; + } + + // Valid share -- record it m->sessionShares++; + m->roundShares++; + m->totalShares++; + m->vardiffShares++; + int64 shareTime = GetTime(); + m->lastSeen = shareTime; + roundShareCount[m->address]++; + roundShareTotal++; + + // Update hashrate estimate: expected hashes for this share divided by + // the time since the last one, smoothed with an EWMA so a single fast + // or slow share doesn't make the number jump around. + if (m->lastShareTime > 0) { + int64 dt = shareTime - m->lastShareTime; + if (dt <= 0) dt = 1; + double inst = ExpectedHashesForDifficulty(m->difficulty) / (double)dt; + const double alpha = 0.25; + m->hashRate = (m->hashRate <= 0.0) ? inst + : alpha * inst + (1.0 - alpha) * m->hashRate; + } + m->lastShareTime = shareTime; - if (powHash > job.shareTarget) { reply(true); return; } // valid share, not a full block + LogPrint("worker", "[worker->pool] valid share from %s" + " nonce=%u job=%s difficulty=%.4f (session: %llu)\n", + m->address.c_str(), nNonce, + gCurrentJob.jobId.c_str(), m->difficulty, + (unsigned long long)m->sessionShares); + reply(true); + + // Vardiff: after every VARDIFF_SHARES_PER_CHECK shares, check rate + if (m->vardiffShares >= VARDIFF_SHARES_PER_CHECK) { + int64 elapsed = GetTime() - m->vardiffWindowStart; + if (elapsed <= 0) elapsed = 1; + double actualSecsPerShare = (double)elapsed / (double)m->vardiffShares; + double ratio = actualSecsPerShare / (double)VARDIFF_TARGET_SECS; + // ratio < 1 means shares arriving faster than target (too easy) + // ratio > 1 means shares arriving slower than target (too hard) + if (ratio < (1.0 / VARDIFF_RETARGET_RATIO) || + ratio > VARDIFF_RETARGET_RATIO) { + // New difficulty scales inversely with actual share rate + double newDiff = m->difficulty / ratio; + if (newDiff < VARDIFF_MIN) newDiff = VARDIFF_MIN; + if (newDiff > VARDIFF_MAX) newDiff = VARDIFF_MAX; + // Only apply if the change is meaningful (>10%) + if (newDiff / m->difficulty > 1.1 || m->difficulty / newDiff > 1.1) { + m->difficulty = newDiff; + m->shareTarget = ShareTargetFromDifficulty(m->difficulty); + LogPrint("worker", "[pool] vardiff: %s new difficulty=%.4f" + " (%.1fs/share actual vs %ds target)\n", + m->address.c_str(), m->difficulty, + actualSecsPerShare, (int)VARDIFF_TARGET_SECS); + SendLine(m->fd, json{{"id",nullptr}, + {"method","mining.set_difficulty"}, + {"params",json::array({m->difficulty})}}); + // set_difficulty alone doesn't change what the worker + // actually hashes against -- it only reads its share + // target from mining.notify params[3]. Without this, the + // new difficulty wouldn't take effect until the next + // natural job broadcast (new block, or the 60s trickle + // refresh), so push a fresh notify for this miner now. + SendJob(m, false); + } + } + // Reset window + m->vardiffWindowStart = GetTime(); + m->vardiffShares = 0; + } + + if (powHash > gCurrentJob.target) return true; // valid share, not a block + + // Full difficulty met -- submit block to the chain + LogPrint("pool", "[pool] BLOCK CANDIDATE from %s height=%d nNonce=%u -- submitting\n", + m->address.c_str(), gCurrentJob.height, nNonce); - // Full block CBlock* pblock = new CBlock(b); bool accepted = false; CRITICAL_BLOCK(cs_main) { + int heightBefore = nBestHeight; if (ProcessBlock(NULL, pblock)) { - accepted = true; - printf("Pool: BLOCK FOUND by %s height=%d nNonce=%u\n", - m->address.c_str(), job.height, nNonce); - FoundBlock fb{job.height, GetTime(), m->address, pblock->GetHash()}; - { std::lock_guard lk(gBlocksMutex); - gFoundBlocks.push_front(fb); - if (gFoundBlocks.size()>50) gFoundBlocks.pop_back(); } - { std::lock_guard lk(gJobMutex); gHaveJob=false; } + // ProcessBlock returns true for orphans too -- verify the chain + // actually advanced. If nBestHeight didn't increase, the block + // was shelved as an orphan (pool was behind the chain tip) and + // no coinbase was added to the wallet. Do not pay out. + if (nBestHeight > heightBefore) { + accepted = true; + LogPrint("pool", "[pool] BLOCK ACCEPTED height=%d by %s\n", + gCurrentJob.height, m->address.c_str()); + gFoundBlocks.push_front({gCurrentJob.height, GetTime(), m->address}); + if (gFoundBlocks.size() > 50) gFoundBlocks.pop_back(); + blocksFoundThisSession++; + gHaveJob = false; // invalidate job immediately + } else { + LogPrint("pool", "[pool] BLOCK ORPHANED height=%d by %s" + " -- pool was behind chain tip, no payout\n", + gCurrentJob.height, m->address.c_str()); + gHaveJob = false; // job is stale regardless + } } else { + LogPrint("pool", "[pool] BLOCK REJECTED height=%d (ProcessBlock returned false)\n", + gCurrentJob.height); delete pblock; } } - reply(accepted); - if (accepted) { QueuePayouts(job.height); RebuildJob(); BroadcastJob(true); } - return; + + if (accepted) { + // Snapshot height before RebuildJob() (which increments it) + int foundHeight = gCurrentJob.height; + QueuePayouts(foundHeight, roundShareCount, roundShareTotal); + // Reset round shares + roundShareCount.clear(); + roundShareTotal = 0; + for (Miner* mi : gMiners) mi->roundShares = 0; + // Build and broadcast new job immediately + if (RebuildJob()) { + LogPrint("pool", "[pool] new job %s built after block %d\n", + gCurrentJob.jobId.c_str(), foundHeight); + BroadcastJob(true); + } else { + LogPrint("pool", "[pool] WARNING: RebuildJob() failed after block %d" + " -- miners will wait\n", foundHeight); + } + } + return true; } - if (method == "mining.get_transactions") { reply(json::array()); return; } + // Unknown method -- log it (can reveal miner software quirks) + LogPrint("worker", "[worker->pool] unknown method '%s' from %s\n", + method.c_str(), + m->address.empty() ? "(unauth)" : m->address.c_str()); reply(nullptr, "unknown method"); + return true; } -static void MinerThreadFn(void* arg) +// --------------------------------------------------------------------------- +// Stats refresh -- called every 2s from event loop +// --------------------------------------------------------------------------- +static void RefreshStats(int blocksFoundThisSession, uint64 roundShareTotal) { - Miner* m = (Miner*)arg; - while (!fShutdown && gPoolRunning) { - fd_set fds; FD_ZERO(&fds); FD_SET(m->fd, &fds); - struct timeval tv={1,0}; - if (select((int)m->fd+1, &fds, NULL, NULL, &tv) <= 0) continue; - char buf[4096]; - int n = recv(m->fd, buf, sizeof(buf)-1, 0); - if (n <= 0) break; - buf[n]=0; m->readBuf += buf; - size_t pos; - while ((pos = m->readBuf.find('\n')) != std::string::npos) { - std::string line = m->readBuf.substr(0, pos); - m->readBuf = m->readBuf.substr(pos+1); - if (!line.empty() && line.back()=='\r') line.pop_back(); - if (!line.empty()) HandleLine(m, line); - } + std::lock_guard lk(gStatsMutex); + + gStats.authorizedMiners = 0; + gStats.blocksFound = blocksFoundThisSession; + gStats.roundShares = roundShareTotal; + gStats.totalHashRate = 0.0; + for (Miner* m : gMiners) + if (m->authorised) gStats.authorizedMiners++; + + gWorkerStats.clear(); + for (Miner* m : gMiners) { + if (!m->authorised || m->lastSeen <= 0) continue; + PoolWorkerStatView v; + v.address = m->address; + v.worker = m->worker; + v.roundShares = m->roundShares; + v.totalShares = m->totalShares; + v.lastSeen = m->lastSeen; + v.hashRate = m->hashRate; + gWorkerStats.push_back(v); + gStats.totalHashRate += m->hashRate; + } + std::sort(gWorkerStats.begin(), gWorkerStats.end(), + [](const PoolWorkerStatView& a, const PoolWorkerStatView& b) { + return a.roundShares != b.roundShares ? a.roundShares > b.roundShares + : a.lastSeen > b.lastSeen; + }); + + // gPendingPayouts is event-loop-only data (no lock needed here since + // RefreshStats is always called from the event loop thread). + gPayoutViews.clear(); + for (const PendingPayout& pp : gPendingPayouts) { + PendingPayoutView pv; + pv.matureAtHeight = pp.matureAtHeight; + pv.recipients = (int)pp.amounts.size(); + pv.totalAmount = 0; + for (auto& kv : pp.amounts) pv.totalAmount += kv.second; + gPayoutViews.push_back(pv); } - sock_close(m->fd); - printf("Stratum: miner %s disconnected (%llu shares)\n", - m->worker.empty()?"unknown":m->worker.c_str(), - (unsigned long long)m->sessionShares); - { std::lock_guard lk(gMinersMutex); - auto it = std::find(gMiners.begin(), gMiners.end(), m); - if (it != gMiners.end()) gMiners.erase(it); } - delete m; } -static void JobWatcherFn(void*) +// --------------------------------------------------------------------------- +// Event loop +// --------------------------------------------------------------------------- +static void PoolEventLoop() { - int lastHeight = -1; + int lastHeight = -1; + int64 lastRefresh = GetTime(); + int64 lastStatsRefresh = 0; + int blocksFoundThisSession = 0; + int64 noJobSince = 0; // 0 = have a job + bool warnedNoJob = false; + int64 lastNoJobRetry = 0; // throttle RebuildJob calls when no tip + + std::map roundShareCount; + uint64 roundShareTotal = 0; + + static const int64 JOB_REFRESH_SECS = 60; // re-stamp nTime periodically + static const int64 NO_JOB_WARN_SECS = 15; // warn after this long with no job + static const int64 NO_JOB_RETRY_SECS = 5; // how often to retry RebuildJob + while (!fShutdown && gPoolRunning) { - Sleep(500); - bool newBlock = false; - CRITICAL_BLOCK(cs_main) - { if (nBestHeight != lastHeight && pindexBest) { lastHeight = nBestHeight; newBlock = true; } } - if (newBlock) { - FlushMaturePayouts(); - if (RebuildJob()) { printf("Stratum: new job height=%d\n", lastHeight+1); BroadcastJob(true); } - } else if (!gHaveJob) { + + // 1. Drain accept queue + { + std::lock_guard lk(gReadyMutex); + for (btf_socket_t fd : gReadyQueue) { + gMiners.push_back(new Miner(fd)); + LogPrint("worker", "[pool] miner fd=%d entered event loop" + " -- awaiting subscribe/authorize\n", (int)fd); + } + gReadyQueue.clear(); + } + + // 2. Detect new block. + // When our own miner found the block, HandleLine already called + // RebuildJob() + BroadcastJob(true) and set gHaveJob=true. + // We still flush mature payouts and update bookkeeping, but skip + // rebuilding if a fresh job already exists for this height. + { + bool newBlock = false; + CRITICAL_BLOCK(cs_main) + { + if (pindexBest && nBestHeight != lastHeight) { + lastHeight = nBestHeight; + newBlock = true; + } + } + if (newBlock) { + FlushMaturePayouts(); + if (!gHaveJob) { + // External block (not found by our miners), or HandleLine's + // RebuildJob failed. Either way this round is over with no + // payout from us, so clear round shares before starting the + // next one -- otherwise they'd keep accumulating across + // rounds that our own miners didn't win. + roundShareCount.clear(); + roundShareTotal = 0; + for (Miner* mi : gMiners) mi->roundShares = 0; + + // Build and broadcast now. + if (RebuildJob()) { + LogPrint("pool", "[pool] new block height=%d -- job %s ready," + " %zu miner(s) connected\n", + lastHeight, gCurrentJob.jobId.c_str(), gMiners.size()); + BroadcastJob(true); + } else { + LogPrint("pool", "[pool] new block height=%d but RebuildJob() failed" + " -- miners will wait\n", lastHeight); + } + } else { + // HandleLine already built and broadcast for this height. + LogPrint("pool", "[pool] new block height=%d -- job already" + " broadcast by submit handler\n", lastHeight); + } + lastRefresh = GetTime(); + noJobSince = 0; + warnedNoJob = false; + } + } + + // 3. No-job watchdog (throttled: only retry every NO_JOB_RETRY_SECS) + if (!gHaveJob) { + int64 now = GetTime(); + if (noJobSince == 0) noJobSince = now; + + if (now - lastNoJobRetry >= NO_JOB_RETRY_SECS) { + lastNoJobRetry = now; + if (RebuildJob()) { + LogPrint("pool", "[pool] job built after %llds without one\n", + (long long)(now - noJobSince)); + BroadcastJob(false); + noJobSince = 0; + warnedNoJob = false; + lastRefresh = now; + } else if (!warnedNoJob && now - noJobSince >= NO_JOB_WARN_SECS) { + LogPrint("pool", "[pool] STUCK: no job for %llds -- %s." + " Miners are waiting. Will keep retrying every %llds.\n", + (long long)(now - noJobSince), + pindexBest ? "RebuildJob() keeps failing (check above for why)" + : "no chain tip yet (node still syncing)", + (long long)NO_JOB_RETRY_SECS); + warnedNoJob = true; + } + } + } else if (GetTime() - lastRefresh >= JOB_REFRESH_SECS) { + // Periodic nTime refresh -- stale timestamps cause valid hashes to be rejected if (RebuildJob()) BroadcastJob(false); + lastRefresh = GetTime(); + } + + // 3.5. Vardiff stall watchdog -- runs independently of share count. + // The normal vardiff check in HandleLine only fires once + // VARDIFF_SHARES_PER_CHECK shares have arrived; a miner that never + // finds a single share never reaches that check, so it can be stuck + // at its initial (or last) difficulty forever. Catch that here. + for (Miner* m : gMiners) { + if (!m->authorised || m->vardiffShares != 0 || m->vardiffWindowStart <= 0) + continue; + int64 stalled = GetTime() - m->vardiffWindowStart; + if (stalled < VARDIFF_STALL_SECS) continue; + + double newDiff = m->difficulty / VARDIFF_STALL_CUT; + if (newDiff < VARDIFF_MIN) newDiff = VARDIFF_MIN; + + if (newDiff < m->difficulty) { + m->difficulty = newDiff; + m->shareTarget = ShareTargetFromDifficulty(m->difficulty); + m->vardiffWindowStart = GetTime(); + LogPrint("worker", "[pool] vardiff STALL: %s sent zero shares in" + " %llds -- forcing difficulty down to %.6f (no share data" + " to retarget from normally)\n", + m->address.c_str(), (long long)stalled, m->difficulty); + SendLine(m->fd, json{{"id",nullptr}, + {"method","mining.set_difficulty"}, + {"params",json::array({m->difficulty})}}); + // Same reasoning as the normal vardiff retarget above: the + // worker only reads its share target from mining.notify, so + // push a fresh job now instead of waiting on the next + // natural broadcast. + SendJob(m, false); + } else { + // Already at the difficulty floor -- this miner's problem isn't + // difficulty, it's something else (slow hashing, bad connection, + // stuck RandomX light mode, etc). Log once per stall window so + // it's visible without spamming. + LogPrint("worker", "[pool] vardiff STALL: %s still at zero shares" + " after %llds at floor difficulty %.6f -- likely a" + " hashrate/connectivity problem, not a difficulty problem\n", + m->address.c_str(), (long long)stalled, m->difficulty); + m->vardiffWindowStart = GetTime(); // avoid re-logging every tick + } + } + + // 4. Select over miner sockets + if (!gMiners.empty()) { + fd_set fds; FD_ZERO(&fds); + int maxfd = 0; + for (Miner* m : gMiners) { + FD_SET(m->fd, &fds); + if ((int)m->fd > maxfd) maxfd = (int)m->fd; + } + struct timeval tv = {0, 50000}; // 50ms + if (select(maxfd + 1, &fds, NULL, NULL, &tv) > 0) { + std::vector dead; + for (Miner* m : gMiners) { + if (!FD_ISSET(m->fd, &fds)) continue; + std::string line; + bool disc = false; + // Drain all complete lines buffered for this miner. + // HandleLine always returns true; disc signals clean disconnect. + while (RecvLine(m->fd, m->readBuf, line, disc)) + HandleLine(m, line, roundShareCount, + roundShareTotal, blocksFoundThisSession); + if (disc) dead.push_back(m); + } + for (Miner* m : dead) { + LogPrint("worker", "[pool] miner %s disconnected" + " (session shares: %llu, round shares: %llu)\n", + m->worker.empty() ? "(never authorized)" : m->worker.c_str(), + (unsigned long long)m->sessionShares, + (unsigned long long)m->roundShares); + sock_close(m->fd); + gMiners.erase(std::find(gMiners.begin(), gMiners.end(), m)); + delete m; + } + } + } else { + Sleep(50); + } + + // 5. Refresh GUI stats every 2s + if (GetTime() - lastStatsRefresh >= 2) { + RefreshStats(blocksFoundThisSession, roundShareTotal); + lastStatsRefresh = GetTime(); } } + + for (Miner* m : gMiners) { sock_close(m->fd); delete m; } + gMiners.clear(); } // --------------------------------------------------------------------------- -// Main Stratum accept loop — entry point called from ui.cpp and headless.cpp -// --------------------------------------------------------------------------- -// .btf Stratum listener — miners connect via rendezvous relay using the -// node's .btf address instead of a raw IP:port. Same Stratum protocol, -// same MinerThreadFn handler, works through CGNAT with no port forwarding. -// -// Keeps one listener registered per relay at all times. When a miner -// connects, a replacement listener is spawned immediately so the next -// miner doesn't have to wait. +// Accept threads // --------------------------------------------------------------------------- +struct AcceptCtx { unsigned char pk[32]; unsigned char sk[32]; std::string relay; }; -struct BtfAcceptCtx { unsigned char pk[32]; unsigned char sk[32]; string relay; }; - -// Track active listener count per relay to avoid pile-up when relay is slow -static std::map gBtfListenerCount; -static std::mutex gBtfListenerMutex; - -static void BtfStratumAcceptOneFn(void* arg) +// One persistent thread per relay. Loops: register -> wrap -> push -> repeat. +static void AcceptOneFn(void* arg) { - BtfAcceptCtx* ctx = (BtfAcceptCtx*)arg; + AcceptCtx* ctx = (AcceptCtx*)arg; unsigned char pk[32], sk[32]; memcpy(pk, ctx->pk, 32); memcpy(sk, ctx->sk, 32); - string relay = ctx->relay; + std::string relay = ctx->relay; delete ctx; - auto decrement = [&]() { - std::lock_guard lk(gBtfListenerMutex); - gBtfListenerCount[relay]--; - }; - size_t colon = relay.rfind(':'); - if (colon == string::npos) { decrement(); return; } - string strHost = relay.substr(0, colon); - int nPort = atoi(relay.substr(colon+1).c_str()); - - btf::RvSocket rv = btf::RvServiceRegister(strHost.c_str(), (unsigned short)nPort, pk); - if (fShutdown || !gPoolRunning) { if (rv != btf::RV_INVALID) btf::RvClose(rv); decrement(); return; } - if (rv == btf::RV_INVALID) { decrement(); return; } - - // Paired: spawn replacement (count transfers to new thread) - if (!fShutdown && gPoolRunning) { - BtfAcceptCtx* next = new BtfAcceptCtx(); - memcpy(next->pk, pk, 32); memcpy(next->sk, sk, 32); - next->relay = relay; - LaunchThread(BtfStratumAcceptOneFn, next); - } else { - decrement(); + if (colon == std::string::npos) { + LogPrint("pool", "[pool] AcceptOneFn: bad relay address '%s'\n", relay.c_str()); + return; } + std::string host = relay.substr(0, colon); + int port = atoi(relay.substr(colon + 1).c_str()); + + LogPrint("pool", "[pool] accept thread started for relay %s\n", relay.c_str()); + + while (!fShutdown && gPoolRunning) { + // Blocks until a miner connects to us at this relay + btf::RvSocket rv = btf::RvServiceRegister(host.c_str(), (unsigned short)port, pk); - btf_socket_t hSocket = btf::BtfServiceWrap(rv, sk); - if (hSocket == INVALID_SOCKET) return; + if (fShutdown || !gPoolRunning) { + if (rv != btf::RV_INVALID) btf::RvClose(rv); + break; + } + if (rv == btf::RV_INVALID) { + LogPrint("pool", "[pool] relay %s: RvServiceRegister failed" + " -- backing off 30s before retry\n", relay.c_str()); + for (int i = 0; i < 30 && !fShutdown && gPoolRunning; i++) Sleep(1000); + continue; + } - printf("Stratum/.btf: miner connected via %s\n", relay.c_str()); - Miner* m = new Miner(hSocket); - { std::lock_guard lk(gMinersMutex); gMiners.push_back(m); } - LaunchThread(MinerThreadFn, m); + // Echan handshake -- blocking but fast (local + relay round-trip) + btf_socket_t fd = btf::BtfServiceWrap(rv, sk); + if (fd == INVALID_SOCKET) { + LogPrint("pool", "[pool] relay %s: BtfServiceWrap failed (echan handshake)" + " -- miner dropped, will accept next\n", relay.c_str()); + continue; + } + + LogPrint("worker", "[pool] miner arrived via relay %s -- pushing to event loop\n", + relay.c_str()); + PushReady(fd); + // Loop immediately: register again so the next miner can connect + } + + LogPrint("pool", "[pool] accept thread stopped for relay %s\n", relay.c_str()); } -static void BtfStratumAcceptFn(void*) +// Spawns one AcceptOneFn per relay. Re-polls relay list every 60s to pick up +// newly discovered relays. Uses a set to avoid spawning duplicate threads. +static void AcceptLoopFn(void*) { - printf("Stratum/.btf: starting \xe2\x80\x94 miners can connect via %s\n", - BtfLocalAddress().c_str()); + LogPrint("pool", "[pool] AcceptLoopFn started -- miners can connect via %s\n", + BtfLocalAddress().c_str()); + // Wait for our identity to be ready unsigned char pk[32], sk[32]; + int waitSecs = 0; while (!BtfGetIdentity(pk, sk)) { if (fShutdown || !gPoolRunning) return; + if (waitSecs == 0) + LogPrint("pool", "[pool] waiting for .btf identity...\n"); + waitSecs++; Sleep(2000); } + LogPrint("pool", "[pool] .btf identity ready after %ds\n", waitSecs * 2); + + std::set spawned; // relays we've already started a thread for while (!fShutdown && gPoolRunning) { vector relays = BtfAllRelays(); - if (relays.empty()) { Sleep(5000); continue; } - - // Spawn one listener per relay, only if none already active - for (const string& relay : relays) { - std::lock_guard lk(gBtfListenerMutex); - if (gBtfListenerCount[relay] == 0) { - gBtfListenerCount[relay]++; - BtfAcceptCtx* ctx = new BtfAcceptCtx(); + if (relays.empty()) { + LogPrint("pool", "[pool] no relays available -- miners cannot connect yet\n"); + } else { + for (const string& relay : relays) { + if (spawned.count(relay)) continue; // already running + AcceptCtx* ctx = new AcceptCtx(); memcpy(ctx->pk, pk, 32); memcpy(ctx->sk, sk, 32); ctx->relay = relay; - LaunchThread(BtfStratumAcceptOneFn, ctx); + uintptr_t tid = _beginthread(AcceptOneFn, 0, ctx); + if (tid == (uintptr_t)-1) { + delete ctx; + LogPrint("pool", "[pool] failed to spawn accept thread for %s\n", + relay.c_str()); + } else { + spawned.insert(relay); + LogPrint("pool", "[pool] accept thread spawned for relay %s" + " (%zu total)\n", relay.c_str(), spawned.size()); + } } } - for (int i = 0; i < 30 && !fShutdown && gPoolRunning; i++) Sleep(1000); + // Re-poll every 60s -- newly discovered relays get picked up + for (int i = 0; i < 60 && !fShutdown && gPoolRunning; i++) Sleep(1000); } } + +// --------------------------------------------------------------------------- +// Entry point +// --------------------------------------------------------------------------- void ThreadRPCServer(void*) { gPoolRunning = true; - printf("Bitflash pool server starting\n"); - printf(" Stratum .btf: %s (no port forwarding needed)\n", - BtfLocalAddress().c_str()); - printf(" Mode: operator\n"); - if (RebuildJob()) - printf("Stratum: initial job seeded height=%d\n", nBestHeight + 1); + LogPrint("pool", "[pool] ============================================\n"); + LogPrint("pool", "[pool] Pool server starting\n"); + LogPrint("pool", "[pool] .btf address: %s\n", BtfLocalAddress().c_str()); + LogPrint("pool", "[pool] fee: %.2f%%\n", dPoolFeePercent); + + if (RebuildJob()) { + LogPrint("pool", "[pool] initial job %s seeded at height %d\n", + gCurrentJob.jobId.c_str(), nBestHeight + 1); + } else { + LogPrint("pool", "[pool] no chain tip yet -- job will build on first block\n"); + } + + LoadPendingPayouts(); + + _beginthread(AcceptLoopFn, 0, NULL); + _beginthread(ThreadBtfPoolAnnouncer, 0, NULL); + _beginthread(PayoutThreadFn, 0, NULL); - LaunchThread(JobWatcherFn, NULL); - LaunchThread(BtfStratumAcceptFn, NULL); - LaunchThread(ThreadBtfPoolAnnouncer, NULL); + PoolEventLoop(); // blocks until gPoolRunning = false or fShutdown - while (!fShutdown && gPoolRunning) - Sleep(1000); - printf("Pool server stopped\n"); + LogPrint("pool", "[pool] pool server stopped\n"); + LogPrint("pool", "[pool] ============================================\n"); + gPoolRunning = false; } diff --git a/src/ui.rc b/src/ui.rc deleted file mode 100644 index ec8b142..0000000 --- a/src/ui.rc +++ /dev/null @@ -1,14 +0,0 @@ -bitcoin ICON "rc/bitcoin.ico" - -#include "wx/msw/wx.rc" - -check ICON "rc/check.ico" -send16 BITMAP "rc/send16.bmp" -send16mask BITMAP "rc/send16mask.bmp" -send16masknoshadow BITMAP "rc/send16masknoshadow.bmp" -send20 BITMAP "rc/send20.bmp" -send20mask BITMAP "rc/send20mask.bmp" -addressbook16 BITMAP "rc/addressbook16.bmp" -addressbook16mask BITMAP "rc/addressbook16mask.bmp" -addressbook20 BITMAP "rc/addressbook20.bmp" -addressbook20mask BITMAP "rc/addressbook20mask.bmp" diff --git a/src/uiproject.fbp b/src/uiproject.fbp deleted file mode 100644 index 57a2950..0000000 --- a/src/uiproject.fbp +++ /dev/null @@ -1,11825 +0,0 @@ - - - - - - C++ - 1 - UTF-8 - connect - uibase - 1000 - none - 0 - - - . - - 1 - 0 - 0 - - wxSYS_COLOUR_BTNFACE - - - 1 - - - - 0 - wxID_MAINFRAME - - - CMainFrameBase - - 705,484 - wxDEFAULT_FRAME_STYLE|wxRESIZE_BORDER - - Bitcoin - - - - wxTAB_TRAVERSAL - 1 - - - - OnClose - - - - - OnIdle - - - - - - - - - - - - OnMouseEvents - - OnPaint - - - - - - - - 240,240,240 - - 1 - - - 0 - wxID_ANY - MyMenuBar - - - m_menubar - protected - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - &File - m_menuFile - protected - - - 0 - 1 - - wxID_ANY - wxITEM_NORMAL - E&xit - m_menuFileExit - none - - - OnMenuFileExit - - - - - &Options - m_menuOptions - public - - - 0 - 1 - - wxID_OPTIONSGENERATEBITCOINS - wxITEM_CHECK - &Generate Coins - m_menuOptionsGenerateBitcoins - none - - - OnMenuOptionsGenerate - - - - - 0 - 1 - - wxID_ANY - wxITEM_NORMAL - &Options... - m_menuOptionsOptions - none - - - OnMenuOptionsOptions - - - - - &Help - m_menuHelp - protected - - - 0 - 1 - - wxID_ANY - wxITEM_NORMAL - &About... - m_menuHelpAbout - none - - - OnMenuHelpAbout - - - - - - - 20,20 - - 1 - - ,90,90,-1,70,0 - 0 - wxID_ANY - - - - m_toolBar - 1 - protected - - 1 - -1,-1 - wxTB_FLAT|wxTB_HORZ_TEXT - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - send20; Load From Resource - wxID_BUTTONSEND - wxITEM_NORMAL - &Send Coins - m_tool1 - - - - OnButtonSend - - - - - - addressbook20; Load From Resource - wxID_BUTTONRECEIVE - wxITEM_NORMAL - &Address Book - m_tool2 - - - - OnButtonAddressBook - - - - - - - 240,240,240 - - 1 - - 1 - - 0 - wxID_ANY - - - m_statusBar - protected - - - wxST_SIZEGRIP - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - bSizer2 - wxVERTICAL - none - - 5 - wxEXPAND - 0 - - 2 - protected - 0 - - - - 5 - wxEXPAND|wxRIGHT|wxLEFT - 0 - - - bSizer85 - wxHORIZONTAL - none - - 5 - wxALIGN_CENTER_VERTICAL|wxLEFT - 0 - - - - 1 - - - 0 - wxID_ANY - Your Bitcoin Address: - - - m_staticText32 - protected - - - - - - - - - -1 - - - - - - - - - - - - - - - - - - - - - - - - - - - 5 - wxALIGN_CENTER_VERTICAL|wxRIGHT|wxLEFT - 0 - - wxSYS_COLOUR_MENU - - 1 - - - 0 - wxID_TEXTCTRLADDRESS - -1,-1 - 0 - -1,-1 - m_textCtrlAddress - protected - - 250,-1 - wxTE_READONLY - - - - - - - - - - OnKeyDown - - - - - - - - - - - OnMouseEventsAddress - - - - - - OnSetFocusAddress - - - - - - - - - - 5 - wxALIGN_CENTER_VERTICAL|wxRIGHT - 0 - - - - 0 - 1 - - - 0 - wxID_BUTTONCOPY - &Copy to Clipboard - - - m_buttonCopy - protected - - - wxBU_EXACTFIT - - - - - - OnButtonCopy - - - - - - - - - - - - - - - - - - - - - - - - - - - 5 - wxRIGHT - 0 - - - - 0 - 1 - - - 0 - wxID_BUTTONCHANGE - C&hange... - - - m_button91 - protected - - - - - - - - - OnButtonChange - - - - - - - - - - - - - - - - - - - - - - - - - - - 5 - wxEXPAND - 0 - - 0 - protected - 0 - - - - - - 5 - wxEXPAND - 0 - - - bSizer3 - wxHORIZONTAL - none - - 5 - wxEXPAND|wxALIGN_BOTTOM|wxALL - 1 - - - - 1 - - - 0 - wxID_ANY - - - m_panel14 - protected - - - - - - - wxTAB_TRAVERSAL - - - - - - - - - - - - - - - - - - - - - - - - - - bSizer66 - wxHORIZONTAL - none - - 5 - wxALIGN_CENTER_VERTICAL|wxTOP|wxBOTTOM|wxLEFT - 0 - - - - 1 - - - 0 - wxID_ANY - Balance: - - - m_staticText41 - protected - - -1,15 - - - - - - - -1 - - - - - - - - - - - - - - - - - - - - - - - - - - - 5 - wxALIGN_CENTER_VERTICAL|wxALL - 0 - - 240,240,240 - - 1 - - ,90,90,8,70,0 - 0 - wxID_ANY - - - - m_staticTextBalance - protected - - 120,15 - wxALIGN_RIGHT|wxST_NO_AUTORESIZE - - - - - - -1 - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - 5 - wxEXPAND - 0 - - 0 - protected - 0 - - - - 5 - wxALIGN_BOTTOM|wxTOP|wxRIGHT|wxLEFT - 0 - - - " All" " Sent" " Received" " In Progress" - - 1 - - - 1 - wxID_ANY - - - m_choiceFilter - protected - - 0 - 110,-1 - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - 5 - wxEXPAND - 1 - - - - - 1 - - - 0 - wxID_ANY - - - m_notebook - protected - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - All Transactions - 0 - - - - 1 - - - 0 - wxID_ANY - - - m_panel7 - protected - - - - - - - wxTAB_TRAVERSAL - - - - - - - - - - - - - - - - - - - - - - - - - - bSizer157 - wxVERTICAL - none - - 5 - wxEXPAND|wxALL - 1 - - - - 1 - - - 0 - wxID_ANY - - - m_listCtrl - public - - - wxLC_NO_SORT_HEADER|wxLC_REPORT|wxLC_SORT_DESCENDING - - - - - wxALWAYS_SHOW_SB - - - - - - - - - - - - - - - OnListColBeginDrag - - - - - - - - - OnListItemActivatedAllTransactions - - - - - - - - - - - - - OnPaintListCtrl - - - - - - - - - - - - - - - 5 - wxEXPAND - 1 - - - bSizer_TabsForFutureUse - wxVERTICAL - none - - 5 - wxEXPAND | wxALL - 1 - - - - 1 - - - 1 - wxID_ANY - - - m_panel9 - protected - - - - - - - wxTAB_TRAVERSAL - - - - - - - - - - - - - - - - - - - - - - - - - - bSizer159 - wxVERTICAL - none - - 5 - wxALL|wxEXPAND - 1 - - - - 1 - - - 0 - wxID_ANY - - - m_listCtrlEscrows - public - - - wxLC_NO_SORT_HEADER|wxLC_REPORT - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - 5 - wxEXPAND | wxALL - 1 - - - - 1 - - - 1 - wxID_ANY - - - m_panel8 - protected - - - - - - - wxTAB_TRAVERSAL - - - - - - - - - - - - - - - - - - - - - - - - - - bSizer158 - wxVERTICAL - none - - 5 - wxALL|wxEXPAND - 1 - - - - 1 - - - 0 - wxID_ANY - - - m_listCtrlOrdersSent - public - - - wxLC_NO_SORT_HEADER|wxLC_REPORT - - - - - - - - - - - - - - - - - - - - - - - - - - - - - OnListItemActivatedOrdersSent - - - - - - - - - - - - - - - - - - - - - - - - - - 5 - wxEXPAND | wxALL - 1 - - - - 1 - - - 1 - wxID_ANY - - - m_panel10 - protected - - - - - - - wxTAB_TRAVERSAL - - - - - - - - - - - - - - - - - - - - - - - - - - bSizer160 - wxVERTICAL - none - - 5 - wxALL|wxEXPAND - 1 - - - - 1 - - - 0 - wxID_ANY - - - m_listCtrlProductsSent - public - - - wxLC_NO_SORT_HEADER|wxLC_REPORT - - - - - - - - - - - - - - - - - - - - - - - - - - - - - OnListItemActivatedProductsSent - - - - - - - - - - - - - - - - - - - - - - - - - - 5 - wxEXPAND | wxALL - 1 - - - - 1 - - - 1 - wxID_ANY - - - m_panel11 - protected - - - - - - - wxTAB_TRAVERSAL - - - - - - - - - - - - - - - - - - - - - - - - - - bSizer161 - wxVERTICAL - none - - 5 - wxALL|wxEXPAND - 1 - - - - 1 - - - 0 - wxID_ANY - - - m_listCtrlOrdersReceived - public - - - wxLC_NO_SORT_HEADER|wxLC_REPORT - - - - - - - - - - - - - - - - - - - - - - - - - - - - - OnListItemActivatedOrdersReceived - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - 1 - - - - 0 - wxID_ANY - - - CTxDetailsDialogBase - - 620,450 - wxDEFAULT_DIALOG_STYLE|wxRESIZE_BORDER - - Transaction Details - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - bSizer64 - wxVERTICAL - none - - 5 - wxEXPAND - 1 - - - bSizer66 - wxVERTICAL - none - - 5 - wxALL|wxEXPAND - 1 - - - - 1 - - - 0 - wxID_ANY - - - m_htmlWin - protected - - - wxHW_SCROLLBAR_AUTO - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - 5 - wxALIGN_RIGHT - 0 - - - bSizer65 - wxVERTICAL - none - - 5 - wxALL - 0 - - - - 0 - 1 - - - 0 - wxID_OK - OK - - - m_buttonOK - protected - - 85,25 - - - - - - - OnButtonOK - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - 1 - - - - 0 - wxID_ANY - - - COptionsDialogBase - - 500,261 - wxDEFAULT_DIALOG_STYLE - - Options - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - bSizer55 - wxVERTICAL - none - - 5 - wxEXPAND|wxLEFT - 1 - - - bSizer57 - wxVERTICAL - none - - 5 - wxEXPAND - 0 - - 20 - protected - 0 - - - - 5 - wxALIGN_CENTER_VERTICAL|wxALL - 0 - - - - 1 - - - 0 - wxID_ANY - Optional transaction fee you give to the nodes that process your transactions. - - - m_staticText32 - protected - - - - - - - - - -1 - - - - - - - - - - - - - - - - - - - - - - - - - - - 5 - wxEXPAND - 0 - - - bSizer56 - wxHORIZONTAL - none - - 5 - wxALIGN_CENTER_VERTICAL|wxTOP|wxBOTTOM|wxLEFT - 0 - - - - 1 - - - 0 - wxID_ANY - Transaction fee: - - - m_staticText31 - protected - - - - - - - - - -1 - - - - - - - - - - - - - - - - - - - - - - - - - - - 5 - wxALL|wxALIGN_CENTER_VERTICAL - 0 - - - - 1 - - - 0 - wxID_TRANSACTIONFEE - - 0 - - m_textCtrlTransactionFee - protected - - 70,-1 - - - - - - - - - - - - - OnKillFocusTransactionFee - - - - - - - - - - - - - - - - - - - - - - - - - - - - - 5 - wxALIGN_RIGHT - 0 - - - bSizer58 - wxHORIZONTAL - none - - 5 - wxALL - 0 - - - - 0 - 1 - - - 0 - wxID_OK - OK - - - m_buttonOK - protected - - 85,25 - - - - - - - OnButtonOK - - - - - - - - - - - - - - - - - - - - - - - - - - - 5 - wxALL - 0 - - - - 0 - 1 - - - 0 - wxID_CANCEL - Cancel - - 85,25 - m_buttonCancel - protected - - -1,-1 - - - - - - - OnButtonCancel - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - 1 - - - - 0 - wxID_ANY - - - CAboutDialogBase - - 507,298 - wxDEFAULT_DIALOG_STYLE - - About Bitcoin - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - bSizer60 - wxVERTICAL - none - - 5 - wxEXPAND - 1 - - - bSizer62 - wxHORIZONTAL - none - - 5 - wxEXPAND - 0 - - 0 - protected - 60 - - - - 5 - wxEXPAND - 1 - - - bSizer63 - wxVERTICAL - none - - 5 - wxEXPAND - 0 - - 50 - protected - 0 - - - - 5 - wxEXPAND - 0 - - - bSizer64 - wxHORIZONTAL - none - - 5 - wxALIGN_BOTTOM|wxTOP|wxBOTTOM|wxLEFT - 0 - - - - 1 - - Tahoma,90,92,10,74,0 - 0 - wxID_ANY - Bitcoin - - - m_staticText40 - protected - - - - - - - - - -1 - - - - - - - - - - - - - - - - - - - - - - - - - - - 5 - wxALIGN_BOTTOM|wxTOP|wxBOTTOM|wxRIGHT - 0 - - - - 1 - - Tahoma,90,90,10,74,0 - 0 - wxID_ANY - version - - - m_staticTextVersion - public - - - - - - - - - -1 - - - - - - - - - - - - - - - - - - - - - - - - - - - - - 5 - wxEXPAND - 0 - - 4 - protected - 0 - - - - 5 - wxALL - 0 - - - - 1 - - - 0 - wxID_ANY - Copyright © 2009 Satoshi Nakamoto. This is experimental software. Do not rely on it for actual financial transactions. Distributed under the MIT/X11 software license, see the accompanying file license.txt or http://www.opensource.org/licenses/mit-license.php. This product includes software developed by the OpenSSL Project for use in the OpenSSL Toolkit (http://www.openssl.org/) and cryptographic software written by Eric Young (eay@cryptsoft.com). - - - m_staticTextMain - protected - - - - - - - - - 400 - - - - - - - - - - - - - - - - - - - - - - - - - - - 5 - wxEXPAND - 1 - - 0 - protected - 0 - - - - - - - - 5 - wxALIGN_RIGHT|wxEXPAND - 0 - - - bSizer61 - wxHORIZONTAL - none - - 5 - wxEXPAND - 1 - - 0 - protected - 0 - - - - 5 - wxALL - 0 - - - - 0 - 1 - - - 0 - wxID_OK - OK - - - m_buttonOK - protected - - 85,25 - - - - - - - OnButtonOK - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - 1 - - - - 0 - wxID_ANY - - - CSendDialogBase - - 675,338 - wxDEFAULT_DIALOG_STYLE|wxRESIZE_BORDER - - Send Coins - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - bSizer21 - wxVERTICAL - none - - 5 - wxEXPAND - 0 - - 5 - protected - 0 - - - - 5 - wxEXPAND|wxLEFT - 0 - - 2 - wxBOTH - 1 - - 0 - - fgSizer1 - wxFLEX_GROWMODE_SPECIFIED - none - 3 - 0 - - 5 - wxEXPAND - 0 - - 0 - protected - 0 - - - - 5 - wxTOP|wxRIGHT|wxLEFT - 0 - - - - 1 - - - 0 - wxID_ANY - Enter the recipient's IP address (e.g. 123.45.6.7) for online transfer with comments and confirmation, or bitcoin address (e.g. 1NS17iag9jJgTHD1VXjvLCEnZuQ3rJED9L) if recipient is not online. - - - m_staticText14 - protected - - - - - - - - - -1 - - - - - - - - - - - - - - - - - - - - - - - - - - - 5 - wxEXPAND|wxLEFT - 1 - - 70,-1 - bSizer47 - wxHORIZONTAL - none - - 5 - wxEXPAND - 1 - - 0 - protected - 0 - - - - 5 - wxALL|wxALIGN_CENTER_VERTICAL - 0 - - - check; Load From Icon Resource [-1; -1] - - 1 - - - 0 - wxID_ANY - - - m_bitmapCheckMark - protected - - 16,16 - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - 5 - wxALIGN_CENTER_VERTICAL|wxTOP|wxBOTTOM|wxLEFT - 0 - - - - 1 - - - 0 - wxID_ANY - Pay &To: - - - m_staticText36 - protected - - -1,-1 - wxALIGN_RIGHT - - - - - - -1 - - - - - - - - - - - - - - - - - - - - - - - - - - - - - 5 - wxEXPAND|wxRIGHT - 1 - - - bSizer19 - wxHORIZONTAL - none - - 5 - wxALIGN_CENTER_VERTICAL|wxALL - 1 - - - - 1 - - - 0 - wxID_TEXTCTRLPAYTO - - 0 - - m_textCtrlAddress - protected - - - - - - - - - - - - - OnKeyDown - - - - - - - - - - - - - - - - - - - OnTextAddress - - - - - - - - 5 - wxALIGN_CENTER_VERTICAL|wxRIGHT - 0 - - - - 0 - 1 - - - 0 - wxID_BUTTONPASTE - &Paste - - - m_buttonPaste - protected - - -1,-1 - wxBU_EXACTFIT - - - - - - OnButtonPaste - - - - - - - - - - - - - - - - - - - - - - - - - - - 5 - wxALIGN_CENTER_VERTICAL|wxRIGHT - 0 - - - - 0 - 1 - - - 0 - wxID_BUTTONADDRESSBOOK - Address &Book... - - - m_buttonAddress - protected - - - - - - - - - OnButtonAddressBook - - - - - - - - - - - - - - - - - - - - - - - - - - - - - 5 - wxALIGN_CENTER_VERTICAL|wxTOP|wxBOTTOM|wxLEFT|wxALIGN_RIGHT - 0 - - - - 1 - - - 0 - wxID_ANY - &Amount: - - - m_staticText19 - protected - - -1,-1 - wxALIGN_RIGHT - - - - - - -1 - - - - - - - - - - - - - - - - - - - - - - - - - - - 5 - wxALIGN_CENTER_VERTICAL|wxALL - 0 - - - - 1 - - ,90,90,-1,70,0 - 0 - wxID_TEXTCTRLAMOUNT - - 20 - - m_textCtrlAmount - protected - - 145,-1 - - - - - - - - - - - OnKeyDown - - OnKillFocusAmount - - - - - - - - - - - - - - - - - - - - - - - - - 5 - wxALIGN_CENTER_VERTICAL|wxALIGN_RIGHT|wxTOP|wxBOTTOM|wxLEFT - 0 - - - - 1 - - - 0 - wxID_ANY - T&ransfer: - - - m_staticText20 - protected - - -1,-1 - wxALIGN_RIGHT - - - - - - -1 - - - - - - - - - - - - - - - - - - - - - - - - - - - 5 - wxALIGN_CENTER_VERTICAL|wxALL - 0 - - - " Standard" - - 1 - - - 0 - wxID_CHOICETRANSFERTYPE - - - m_choiceTransferType - protected - - 0 - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - 5 - wxEXPAND - 0 - - - bSizer672 - wxHORIZONTAL - none - - 5 - wxEXPAND|wxBOTTOM|wxRIGHT|wxLEFT - 1 - - - bSizer681 - wxVERTICAL - none - - 5 - wxBOTTOM|wxLEFT - 0 - - - - 1 - - - 0 - wxID_ANY - &From: - - - m_staticTextFrom - protected - - - - - - - - - -1 - - - - - - - - - - - - - - - - - - - - - - - - - - - 5 - wxLEFT|wxEXPAND - 0 - - - - 1 - - - 0 - wxID_ANY - - 0 - - m_textCtrlFrom - protected - - -1,-1 - - - - - - - - - - - OnKeyDown - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - 5 - wxEXPAND - 1 - - - bSizer67 - wxHORIZONTAL - none - - 5 - wxEXPAND|wxBOTTOM|wxRIGHT|wxLEFT - 1 - - - bSizer68 - wxVERTICAL - none - - 5 - wxTOP|wxBOTTOM|wxLEFT - 0 - - - - 1 - - - 0 - wxID_ANY - &Message: - - - m_staticTextMessage - protected - - - - - - - - - -1 - - - - - - - - - - - - - - - - - - - - - - - - - - - 5 - wxEXPAND|wxLEFT - 1 - - - - 1 - - - 0 - wxID_ANY - - 0 - - m_textCtrlMessage - protected - - - wxTE_MULTILINE - - - - - - - - - - OnKeyDown - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - 5 - wxEXPAND - 0 - - - bSizer23 - wxHORIZONTAL - none - - 5 - wxEXPAND - 1 - - 0 - protected - 0 - - - - 5 - wxALL - 0 - - - - 0 - 1 - - ,90,90,-1,70,0 - 0 - wxID_BUTTONSEND - &Send - - 85,25 - m_buttonSend - protected - - -1,-1 - - - - - - - OnButtonSend - - - - - - - - - - - - - - - - - - - - - - - - - - - 5 - wxALL - 0 - - - - 0 - 1 - - - 0 - wxID_CANCEL - Cancel - - 85,25 - m_buttonCancel - protected - - -1,-1 - - - - - - - OnButtonCancel - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - 1 - - - - 0 - wxID_ANY - - - CSendingDialogBase - - 442,151 - wxDEFAULT_DIALOG_STYLE - - Sending... - - - - - - - - OnClose - - - - - - - - - - - - - - - - - - - - OnPaint - - - - - - - - - bSizer68 - wxVERTICAL - none - - 8 - wxALIGN_CENTER_VERTICAL|wxEXPAND|wxTOP|wxRIGHT|wxLEFT - 0 - - - - 1 - - - 0 - wxID_ANY - - - - m_staticTextSending - protected - - -1,14 - - - - - - - -1 - - - - - - - - - - - - - - - - - - - - - - - - - - - 10 - wxEXPAND|wxRIGHT|wxLEFT - 1 - - wxSYS_COLOUR_BTNFACE - - 1 - - - 0 - wxID_ANY - - 0 - - m_textCtrlStatus - protected - - - wxTE_CENTRE|wxTE_MULTILINE|wxTE_NO_VSCROLL|wxTE_READONLY - - - Connecting... - - - wxNO_BORDER - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - 5 - wxEXPAND - 0 - - - bSizer69 - wxHORIZONTAL - none - - 5 - wxEXPAND - 1 - - 0 - protected - 0 - - - - 5 - wxALL - 0 - - - - 0 - 0 - - - 0 - wxID_ANY - OK - - 85,25 - m_buttonOK - protected - - - - - - - - - OnButtonOK - - - - - - - - - - - - - - - - - - - - - - - - - - - 5 - wxALL - 0 - - - - 0 - 1 - - - 0 - wxID_CANCEL - Cancel - - 85,25 - m_buttonCancel - protected - - -1,-1 - - - - - - - OnButtonCancel - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - 1 - - - - 0 - wxID_ANY - - - CYourAddressDialogBase - - 610,390 - wxDEFAULT_DIALOG_STYLE|wxRESIZE_BORDER - - Your Bitcoin Address - - - - - - - - OnClose - - - - - - - - - - - - - - - - - - - - - - - - - - - - - bSizer68 - wxVERTICAL - none - - 5 - wxEXPAND - 0 - - 5 - protected - 0 - - - - 5 - wxALL - 0 - - - - 1 - - - 0 - wxID_ANY - These are your Bitcoin addresses for receiving payments. You may want to give a different one to each sender so you can keep track of who is paying you. - - - m_staticText45 - protected - - - - - - - - - 600 - - - - - - - - - - - - - - - - - - - - - - - - - - - 5 - wxALL|wxEXPAND - 1 - - - - 1 - - - 0 - wxID_LISTCTRL - - - m_listCtrl - protected - - - wxLC_NO_SORT_HEADER|wxLC_REPORT|wxLC_SORT_ASCENDING - - - - - - - - - - - - - - - - - - - - - - - - - - - OnListEndLabelEdit - - OnListItemActivated - - - - - OnListItemSelected - - - - - - - - - - - - - - - - - - 5 - wxEXPAND - 0 - - - bSizer69 - wxHORIZONTAL - none - - 5 - wxEXPAND - 1 - - 0 - protected - 0 - - - - 5 - wxALL - 0 - - - - 0 - 1 - - - 0 - wxID_BUTTONRENAME - &Rename... - - 85,25 - m_buttonRename - protected - - - - - - - - - OnButtonRename - - - - - - - - - - - - - - - - - - - - - - - - - - - 5 - wxALL - 0 - - - - 0 - 1 - - - 0 - wxID_BUTTONNEW - &New Address... - - 110,25 - m_buttonNew - protected - - -1,-1 - - - - - - - OnButtonNew - - - - - - - - - - - - - - - - - - - - - - - - - - - 5 - wxALL - 0 - - - - 0 - 1 - - - 0 - wxID_BUTTONCOPY - &Copy to Clipboard - - 120,25 - m_buttonCopy - protected - - -1,-1 - - - - - - - OnButtonCopy - - - - - - - - - - - - - - - - - - - - - - - - - - - 5 - wxALL - 0 - - - - 0 - 1 - - - 0 - wxID_OK - OK - - 85,25 - m_buttonOK - protected - - - - - - - - - OnButtonOK - - - - - - - - - - - - - - - - - - - - - - - - - - - 5 - wxALL - 0 - - - - 0 - 1 - - - 0 - wxID_CANCEL - Cancel - - 85,25 - m_buttonCancel - protected - - -1,-1 - - - - - - - OnButtonCancel - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - 1 - - - - 0 - wxID_ANY - - - CAddressBookDialogBase - - 610,390 - wxDEFAULT_DIALOG_STYLE|wxRESIZE_BORDER - - Address Book - - - - - - - - OnClose - - - - - - - - - - - - - - - - - - - - - - - - - - - - - bSizer68 - wxVERTICAL - none - - 5 - wxEXPAND - 0 - - 5 - protected - 0 - - - - 5 - wxTOP|wxRIGHT|wxLEFT - 0 - - - - 1 - - - 1 - wxID_ANY - Bitcoin Address - - - m_staticText55 - protected - - - - - - - - - -1 - - - - - - - - - - - - - - - - - - - - - - - - - - - 5 - wxALL|wxEXPAND - 1 - - - - 1 - - - 0 - wxID_LISTCTRL - - - m_listCtrl - protected - - - wxLC_NO_SORT_HEADER|wxLC_REPORT|wxLC_SORT_ASCENDING - - - - - - - - - - - - - - - - - - - - - - - - - - - OnListEndLabelEdit - - OnListItemActivated - - - - - OnListItemSelected - - - - - - - - - - - - - - - - - - 5 - wxEXPAND - 0 - - - bSizer69 - wxHORIZONTAL - none - - 5 - wxEXPAND - 1 - - 0 - protected - 0 - - - - 5 - wxALL - 0 - - - - 0 - 1 - - - 0 - wxID_BUTTONEDIT - &Edit... - - 85,25 - m_buttonEdit - protected - - - - - - - - - OnButtonEdit - - - - - - - - - - - - - - - - - - - - - - - - - - - 5 - wxALL - 0 - - - - 0 - 1 - - - 0 - wxID_BUTTONNEW - &New Address... - - 110,25 - m_buttonNew - protected - - - - - - - - - OnButtonNew - - - - - - - - - - - - - - - - - - - - - - - - - - - 5 - wxALL - 0 - - - - 0 - 1 - - - 0 - wxID_BUTTONDELETE - &Delete - - 85,25 - m_buttonDelete - protected - - - - - - - - - OnButtonDelete - - - - - - - - - - - - - - - - - - - - - - - - - - - 5 - wxALL - 0 - - - - 0 - 1 - - - 0 - wxID_OK - OK - - 85,25 - m_buttonOK - protected - - -1,-1 - - - - - - - OnButtonOK - - - - - - - - - - - - - - - - - - - - - - - - - - - 5 - wxALL - 0 - - - - 0 - 1 - - - 0 - wxID_CANCEL - Cancel - - 85,25 - m_buttonCancel - public - - -1,-1 - - - - - - - OnButtonCancel - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - 1 - - - - 0 - wxID_ANY - - - CProductsDialogBase - - 708,535 - wxDEFAULT_DIALOG_STYLE|wxRESIZE_BORDER - - Marketplace - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - bSizer22 - wxVERTICAL - none - - 5 - wxEXPAND|wxTOP|wxBOTTOM|wxRIGHT - 0 - - - bSizer23 - wxHORIZONTAL - none - - 5 - wxALL|wxALIGN_CENTER_VERTICAL - 0 - - - "(Any Category)" - - 1 - - - 0 - wxID_ANY - - - m_comboBoxCategory - protected - - 150,-1 - - - - (Any Category) - - - - - OnCombobox - - - - - - - - - - - - - - - - - - - - - - - - - - - - 5 - wxALL|wxALIGN_CENTER_VERTICAL - 1 - - - - 1 - - - 0 - wxID_ANY - - 0 - - m_textCtrlSearch - protected - - - - - - - - - - - - - OnKeyDown - - - - - - - - - - - - - - - - - - - - - - - - - - - 5 - wxALL|wxALIGN_CENTER_VERTICAL - 0 - - - - 0 - 1 - - - 0 - wxID_ANY - &Search - - - m_buttonSearch - protected - - - - - - - - - OnButtonSearch - - - - - - - - - - - - - - - - - - - - - - - - - - - - - 5 - wxALL|wxEXPAND - 1 - - - - 1 - - - 0 - wxID_ANY - - - m_listCtrl - protected - - - wxLC_NO_SORT_HEADER|wxLC_REPORT - - - - - - - - - - - - - - - - - - - - - - - - - - - - - OnListItemActivated - - - - - - - - - - - - - - - - - - - - - - - - - wxSYS_COLOUR_MENU - - - 1 - - - - 0 - wxID_ANY - - - CEditProductDialogBase - - 660,640 - wxDEFAULT_FRAME_STYLE|wxRESIZE_BORDER - - Edit Product - - - - wxTAB_TRAVERSAL - 1 - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - bSizer20 - wxVERTICAL - none - - 5 - wxEXPAND|wxALL - 1 - - wxSYS_COLOUR_WINDOW - - 1 - - - 0 - wxID_ANY - - - m_scrolledWindow - protected - - 5 - 5 - - - - - - wxHSCROLL|wxTAB_TRAVERSAL|wxVSCROLL - - - - - - - - - - - - - - - - - - - - - - - - - - bSizer21 - wxVERTICAL - none - - 5 - wxEXPAND|wxTOP|wxRIGHT|wxLEFT - 0 - - 2 - wxBOTH - 1 - - 0 - - fgSizer8 - wxFLEX_GROWMODE_SPECIFIED - none - 0 - 0 - - 5 - wxALIGN_CENTER_VERTICAL|wxALIGN_RIGHT|wxTOP|wxBOTTOM|wxLEFT - 0 - - - - 1 - - - 0 - wxID_ANY - Category - - - m_staticText106 - protected - - - wxALIGN_RIGHT - - - - - - -1 - - - - - - - - - - - - - - - - - - - - - - - - - - - 5 - wxALL|wxALIGN_CENTER_VERTICAL - 0 - - - - - 1 - - - 0 - wxID_ANY - - 180,-1 - m_comboBoxCategory - protected - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - 5 - wxALIGN_CENTER_VERTICAL|wxALIGN_RIGHT|wxTOP|wxBOTTOM|wxLEFT - 0 - - - - 1 - - - 0 - wxID_ANY - Title - - - m_staticText108 - protected - - - wxALIGN_RIGHT - - - - - - -1 - - - - - - - - - - - - - - - - - - - - - - - - - - - 5 - wxALL|wxALIGN_CENTER_VERTICAL|wxEXPAND - 1 - - - - 1 - - - 0 - wxID_ANY - - 0 - - m_textCtrlTitle - protected - - - - - - - - - - - - - OnKeyDown - - - - - - - - - - - - - - - - - - - - - - - - - - - 5 - wxALIGN_CENTER_VERTICAL|wxALIGN_RIGHT|wxTOP|wxBOTTOM|wxLEFT - 0 - - - - 1 - - - 0 - wxID_ANY - Price - - - m_staticText107 - protected - - - wxALIGN_RIGHT - - - - - - -1 - - - - - - - - - - - - - - - - - - - - - - - - - - - 5 - wxALL|wxALIGN_CENTER_VERTICAL - 0 - - - - 1 - - - 0 - wxID_ANY - - 0 - 105,-1 - m_textCtrlPrice - protected - - - - - - - - - - - - - OnKeyDown - - - - - - - - - - - - - - - - - - - - - - - - - - - - - 5 - wxTOP|wxRIGHT|wxLEFT - 0 - - - - 1 - - - 0 - wxID_ANY - Page 1: Description - - - m_staticText22 - protected - - - - - - - - - -1 - - - - - - - - - - - - - - - - - - - - - - - - - - - 5 - wxALL|wxEXPAND - 0 - - - - 1 - - - 0 - wxID_ANY - - 0 - -1,170 - m_textCtrlDescription - protected - - - wxTE_MULTILINE - - - - - - - - - - OnKeyDown - - - - - - - - - - - - - - - - - - - - - - - - - - - 5 - wxTOP|wxRIGHT|wxLEFT - 0 - - - - 1 - - - 0 - wxID_ANY - Page 2: Order Form - - - m_staticText23 - protected - - - - - - - - - -1 - - - - - - - - - - - - - - - - - - - - - - - - - - - 5 - wxEXPAND|wxALL - 0 - - - - 1 - - - 0 - wxID_ANY - - 0 - -1,120 - m_textCtrlInstructions - protected - - - wxTE_MULTILINE - - - - - - - - - - OnKeyDown - - - - - - - - - - - - - - - - - - - - - - - - - - - 5 - wxEXPAND - 0 - - 3 - wxBOTH - 1 - - 0 - - fgSizer5 - wxFLEX_GROWMODE_SPECIFIED - public - 0 - 0 - - 5 - wxALIGN_CENTER_VERTICAL|wxTOP|wxRIGHT|wxLEFT - 0 - - - - 1 - - - 0 - wxID_ANY - Label - - - m_staticText24 - protected - - - - - - - - - -1 - - - - - - - - - - - - - - - - - - - - - - - - - - - 5 - wxALIGN_CENTER_VERTICAL|wxTOP|wxRIGHT|wxLEFT - 0 - - - - 1 - - - 0 - wxID_ANY - Comma separated list of choices, or leave blank for text field - - - m_staticText25 - protected - - - - - - - - - -1 - - - - - - - - - - - - - - - - - - - - - - - - - - - 5 - wxEXPAND - 1 - - 0 - protected - 0 - - - - 5 - wxALL|wxALIGN_CENTER_VERTICAL - 0 - - - - 1 - - - 0 - wxID_ANY - - 0 - 150,-1 - m_textCtrlLabel0 - protected - - - - - - - - - - - - - OnKeyDown - - - - - - - - - - - - - - - - - - - - - - - - - - - 5 - wxALL|wxEXPAND|wxALIGN_CENTER_VERTICAL - 0 - - - - 1 - - - 0 - wxID_ANY - -1,-1 - 0 - -1,-1 - m_textCtrlField0 - protected - - -1,-1 - - - - - - - - - - - OnKeyDown - - - - - - - - - - - - - - - - - - - - - - - - - - - 5 - wxRIGHT|wxLEFT|wxALIGN_CENTER_VERTICAL - 0 - - - - 0 - 1 - - - 0 - wxID_DEL0 - Delete - - -1,-1 - m_buttonDel0 - protected - - 60,20 - - - - - - - OnButtonDel0 - - - - - - - - - - - - - - - - - - - - - - - - - - - 5 - wxALL|wxALIGN_CENTER_VERTICAL - 0 - - - - 1 - - - 0 - wxID_ANY - - 0 - 150,-1 - m_textCtrlLabel1 - protected - - - - - - - - - - - - - OnKeyDown - - - - - - - - - - - - - - - - - - - - - - - - - - - 5 - wxALL|wxEXPAND|wxALIGN_CENTER_VERTICAL - 0 - - - - 1 - - - 0 - wxID_ANY - -1,-1 - 0 - -1,-1 - m_textCtrlField1 - protected - - -1,-1 - - - - - - - - - - - OnKeyDown - - - - - - - - - - - - - - - - - - - - - - - - - - - 5 - wxALIGN_CENTER_VERTICAL|wxRIGHT|wxLEFT - 0 - - - - 0 - 1 - - - 0 - wxID_DEL1 - Delete - - -1,-1 - m_buttonDel1 - protected - - 60,20 - - - - - - - OnButtonDel1 - - - - - - - - - - - - - - - - - - - - - - - - - - - 5 - wxALL|wxALIGN_CENTER_VERTICAL - 0 - - - - 1 - - - 0 - wxID_ANY - - 0 - 150,-1 - m_textCtrlLabel2 - protected - - - - - - - - - - - - - OnKeyDown - - - - - - - - - - - - - - - - - - - - - - - - - - - 5 - wxALL|wxEXPAND|wxALIGN_CENTER_VERTICAL - 0 - - - - 1 - - - 0 - wxID_ANY - -1,-1 - 0 - -1,-1 - m_textCtrlField2 - protected - - -1,-1 - - - - - - - - - - - OnKeyDown - - - - - - - - - - - - - - - - - - - - - - - - - - - 5 - wxALIGN_CENTER_VERTICAL|wxRIGHT|wxLEFT - 0 - - - - 0 - 1 - - - 0 - wxID_DEL2 - Delete - - -1,-1 - m_buttonDel2 - protected - - 60,20 - - - - - - - OnButtonDel2 - - - - - - - - - - - - - - - - - - - - - - - - - - - 5 - wxALL|wxALIGN_CENTER_VERTICAL - 0 - - - - 1 - - - 0 - wxID_ANY - - 0 - 150,-1 - m_textCtrlLabel3 - protected - - - - - - - - - - - - - OnKeyDown - - - - - - - - - - - - - - - - - - - - - - - - - - - 5 - wxALL|wxEXPAND|wxALIGN_CENTER_VERTICAL - 0 - - - - 1 - - - 0 - wxID_ANY - -1,-1 - 0 - -1,-1 - m_textCtrlField3 - protected - - -1,-1 - - - - - - - - - - - OnKeyDown - - - - - - - - - - - - - - - - - - - - - - - - - - - 5 - wxALIGN_CENTER_VERTICAL|wxRIGHT|wxLEFT - 0 - - - - 0 - 1 - - - 0 - wxID_DEL3 - Delete - - -1,-1 - m_buttonDel3 - protected - - 60,20 - - - - - - - OnButtonDel3 - - - - - - - - - - - - - - - - - - - - - - - - - - - 5 - wxALL|wxALIGN_CENTER_VERTICAL - 0 - - - - 1 - - - 0 - wxID_ANY - - 0 - 150,-1 - m_textCtrlLabel4 - protected - - - - - - - - - - - - - OnKeyDown - - - - - - - - - - - - - - - - - - - - - - - - - - - 5 - wxALL|wxEXPAND|wxALIGN_CENTER_VERTICAL - 0 - - - - 1 - - - 0 - wxID_ANY - -1,-1 - 0 - -1,-1 - m_textCtrlField4 - protected - - -1,-1 - - - - - - - - - - - OnKeyDown - - - - - - - - - - - - - - - - - - - - - - - - - - - 5 - wxALIGN_CENTER_VERTICAL|wxRIGHT|wxLEFT - 0 - - - - 0 - 1 - - - 0 - wxID_DEL4 - Delete - - -1,-1 - m_buttonDel4 - protected - - 60,20 - - - - - - - OnButtonDel4 - - - - - - - - - - - - - - - - - - - - - - - - - - - 5 - wxALL|wxALIGN_CENTER_VERTICAL - 0 - - - - 1 - - - 0 - wxID_ANY - - 0 - 150,-1 - m_textCtrlLabel5 - protected - - - - - - - - - - - - - OnKeyDown - - - - - - - - - - - - - - - - - - - - - - - - - - - 5 - wxALL|wxEXPAND|wxALIGN_CENTER_VERTICAL - 0 - - - - 1 - - - 0 - wxID_ANY - -1,-1 - 0 - -1,-1 - m_textCtrlField5 - protected - - -1,-1 - - - - - - - - - - - OnKeyDown - - - - - - - - - - - - - - - - - - - - - - - - - - - 5 - wxALIGN_CENTER_VERTICAL|wxRIGHT|wxLEFT - 0 - - - - 0 - 1 - - - 0 - wxID_DEL5 - Delete - - -1,-1 - m_buttonDel5 - protected - - 60,20 - - - - - - - OnButtonDel5 - - - - - - - - - - - - - - - - - - - - - - - - - - - 5 - wxALL|wxALIGN_CENTER_VERTICAL - 0 - - - - 1 - - - 0 - wxID_ANY - - 0 - 150,-1 - m_textCtrlLabel6 - protected - - - - - - - - - - - - - OnKeyDown - - - - - - - - - - - - - - - - - - - - - - - - - - - 5 - wxALL|wxEXPAND|wxALIGN_CENTER_VERTICAL - 0 - - - - 1 - - - 0 - wxID_ANY - -1,-1 - 0 - -1,-1 - m_textCtrlField6 - protected - - -1,-1 - - - - - - - - - - - OnKeyDown - - - - - - - - - - - - - - - - - - - - - - - - - - - 5 - wxALIGN_CENTER_VERTICAL|wxRIGHT|wxLEFT - 0 - - - - 0 - 1 - - - 0 - wxID_DEL6 - Delete - - -1,-1 - m_buttonDel6 - protected - - 60,20 - - - - - - - OnButtonDel6 - - - - - - - - - - - - - - - - - - - - - - - - - - - 5 - wxALL|wxALIGN_CENTER_VERTICAL - 0 - - - - 1 - - - 0 - wxID_ANY - - 0 - 150,-1 - m_textCtrlLabel7 - protected - - - - - - - - - - - - - OnKeyDown - - - - - - - - - - - - - - - - - - - - - - - - - - - 5 - wxALL|wxEXPAND|wxALIGN_CENTER_VERTICAL - 0 - - - - 1 - - - 0 - wxID_ANY - -1,-1 - 0 - -1,-1 - m_textCtrlField7 - protected - - -1,-1 - - - - - - - - - - - OnKeyDown - - - - - - - - - - - - - - - - - - - - - - - - - - - 5 - wxALIGN_CENTER_VERTICAL|wxRIGHT|wxLEFT - 0 - - - - 0 - 1 - - - 0 - wxID_DEL7 - Delete - - -1,-1 - m_buttonDel7 - protected - - 60,20 - - - - - - - OnButtonDel7 - - - - - - - - - - - - - - - - - - - - - - - - - - - 5 - wxALL|wxALIGN_CENTER_VERTICAL - 0 - - - - 1 - - - 0 - wxID_ANY - - 0 - 150,-1 - m_textCtrlLabel8 - protected - - - - - - - - - - - - - OnKeyDown - - - - - - - - - - - - - - - - - - - - - - - - - - - 5 - wxALL|wxEXPAND|wxALIGN_CENTER_VERTICAL - 0 - - - - 1 - - - 0 - wxID_ANY - -1,-1 - 0 - -1,-1 - m_textCtrlField8 - protected - - -1,-1 - - - - - - - - - - - OnKeyDown - - - - - - - - - - - - - - - - - - - - - - - - - - - 5 - wxALIGN_CENTER_VERTICAL|wxRIGHT|wxLEFT - 0 - - - - 0 - 1 - - - 0 - wxID_DEL8 - Delete - - -1,-1 - m_buttonDel8 - protected - - 60,20 - - - - - - - OnButtonDel8 - - - - - - - - - - - - - - - - - - - - - - - - - - - 5 - wxALL|wxALIGN_CENTER_VERTICAL - 0 - - - - 1 - - - 0 - wxID_ANY - - 0 - 150,-1 - m_textCtrlLabel9 - protected - - - - - - - - - - - - - OnKeyDown - - - - - - - - - - - - - - - - - - - - - - - - - - - 5 - wxALL|wxEXPAND|wxALIGN_CENTER_VERTICAL - 0 - - - - 1 - - - 0 - wxID_ANY - -1,-1 - 0 - -1,-1 - m_textCtrlField9 - protected - - -1,-1 - - - - - - - - - - - OnKeyDown - - - - - - - - - - - - - - - - - - - - - - - - - - - 5 - wxALIGN_CENTER_VERTICAL|wxRIGHT|wxLEFT - 0 - - - - 0 - 1 - - - 0 - wxID_DEL9 - Delete - - -1,-1 - m_buttonDel9 - protected - - 60,20 - - - - - - - OnButtonDel9 - - - - - - - - - - - - - - - - - - - - - - - - - - - 5 - wxALL|wxALIGN_CENTER_VERTICAL - 0 - - - - 1 - - - 0 - wxID_ANY - - 0 - 150,-1 - m_textCtrlLabel10 - protected - - - - - - - - - - - - - OnKeyDown - - - - - - - - - - - - - - - - - - - - - - - - - - - 5 - wxALL|wxEXPAND|wxALIGN_CENTER_VERTICAL - 0 - - - - 1 - - - 0 - wxID_ANY - -1,-1 - 0 - -1,-1 - m_textCtrlField10 - protected - - -1,-1 - - - - - - - - - - - OnKeyDown - - - - - - - - - - - - - - - - - - - - - - - - - - - 5 - wxALIGN_CENTER_VERTICAL|wxRIGHT|wxLEFT - 0 - - - - 0 - 1 - - - 0 - wxID_DEL10 - Delete - - -1,-1 - m_buttonDel10 - protected - - 60,20 - - - - - - - OnButtonDel10 - - - - - - - - - - - - - - - - - - - - - - - - - - - 5 - wxALL|wxALIGN_CENTER_VERTICAL - 0 - - - - 1 - - - 0 - wxID_ANY - - 0 - 150,-1 - m_textCtrlLabel11 - protected - - - - - - - - - - - - - OnKeyDown - - - - - - - - - - - - - - - - - - - - - - - - - - - 5 - wxALL|wxEXPAND|wxALIGN_CENTER_VERTICAL - 0 - - - - 1 - - - 0 - wxID_ANY - -1,-1 - 0 - -1,-1 - m_textCtrlField11 - protected - - -1,-1 - - - - - - - - - - - OnKeyDown - - - - - - - - - - - - - - - - - - - - - - - - - - - 5 - wxALIGN_CENTER_VERTICAL|wxRIGHT|wxLEFT - 0 - - - - 0 - 1 - - - 0 - wxID_DEL11 - Delete - - -1,-1 - m_buttonDel11 - protected - - 60,20 - - - - - - - OnButtonDel11 - - - - - - - - - - - - - - - - - - - - - - - - - - - 5 - wxALL|wxALIGN_CENTER_VERTICAL - 0 - - - - 1 - - - 0 - wxID_ANY - - 0 - 150,-1 - m_textCtrlLabel12 - protected - - - - - - - - - - - - - OnKeyDown - - - - - - - - - - - - - - - - - - - - - - - - - - - 5 - wxALL|wxEXPAND|wxALIGN_CENTER_VERTICAL - 0 - - - - 1 - - - 0 - wxID_ANY - -1,-1 - 0 - -1,-1 - m_textCtrlField12 - protected - - -1,-1 - - - - - - - - - - - OnKeyDown - - - - - - - - - - - - - - - - - - - - - - - - - - - 5 - wxALIGN_CENTER_VERTICAL|wxRIGHT|wxLEFT - 0 - - - - 0 - 1 - - - 0 - wxID_DEL12 - Delete - - -1,-1 - m_buttonDel12 - protected - - 60,20 - - - - - - - OnButtonDel12 - - - - - - - - - - - - - - - - - - - - - - - - - - - 5 - wxALL|wxALIGN_CENTER_VERTICAL - 0 - - - - 1 - - - 0 - wxID_ANY - - 0 - 150,-1 - m_textCtrlLabel13 - protected - - - - - - - - - - - - - OnKeyDown - - - - - - - - - - - - - - - - - - - - - - - - - - - 5 - wxALL|wxEXPAND|wxALIGN_CENTER_VERTICAL - 0 - - - - 1 - - - 0 - wxID_ANY - -1,-1 - 0 - -1,-1 - m_textCtrlField13 - protected - - -1,-1 - - - - - - - - - - - OnKeyDown - - - - - - - - - - - - - - - - - - - - - - - - - - - 5 - wxALIGN_CENTER_VERTICAL|wxRIGHT|wxLEFT - 0 - - - - 0 - 1 - - - 0 - wxID_DEL13 - Delete - - -1,-1 - m_buttonDel13 - protected - - 60,20 - - - - - - - OnButtonDel13 - - - - - - - - - - - - - - - - - - - - - - - - - - - 5 - wxALL|wxALIGN_CENTER_VERTICAL - 0 - - - - 1 - - - 0 - wxID_ANY - - 0 - 150,-1 - m_textCtrlLabel14 - protected - - - - - - - - - - - - - OnKeyDown - - - - - - - - - - - - - - - - - - - - - - - - - - - 5 - wxALL|wxEXPAND|wxALIGN_CENTER_VERTICAL - 0 - - - - 1 - - - 0 - wxID_ANY - -1,-1 - 0 - -1,-1 - m_textCtrlField14 - protected - - -1,-1 - - - - - - - - - - - OnKeyDown - - - - - - - - - - - - - - - - - - - - - - - - - - - 5 - wxALIGN_CENTER_VERTICAL|wxRIGHT|wxLEFT - 0 - - - - 0 - 1 - - - 0 - wxID_DEL14 - Delete - - -1,-1 - m_buttonDel14 - protected - - 60,20 - - - - - - - OnButtonDel14 - - - - - - - - - - - - - - - - - - - - - - - - - - - 5 - wxALL|wxALIGN_CENTER_VERTICAL - 0 - - - - 1 - - - 0 - wxID_ANY - - 0 - 150,-1 - m_textCtrlLabel15 - protected - - - - - - - - - - - - - OnKeyDown - - - - - - - - - - - - - - - - - - - - - - - - - - - 5 - wxALL|wxEXPAND|wxALIGN_CENTER_VERTICAL - 0 - - - - 1 - - - 0 - wxID_ANY - -1,-1 - 0 - -1,-1 - m_textCtrlField15 - protected - - -1,-1 - - - - - - - - - - - OnKeyDown - - - - - - - - - - - - - - - - - - - - - - - - - - - 5 - wxALIGN_CENTER_VERTICAL|wxRIGHT|wxLEFT - 0 - - - - 0 - 1 - - - 0 - wxID_DEL15 - Delete - - -1,-1 - m_buttonDel15 - protected - - 60,20 - - - - - - - OnButtonDel15 - - - - - - - - - - - - - - - - - - - - - - - - - - - 5 - wxALL|wxALIGN_CENTER_VERTICAL - 0 - - - - 1 - - - 0 - wxID_ANY - - 0 - 150,-1 - m_textCtrlLabel16 - protected - - - - - - - - - - - - - OnKeyDown - - - - - - - - - - - - - - - - - - - - - - - - - - - 5 - wxALL|wxEXPAND|wxALIGN_CENTER_VERTICAL - 0 - - - - 1 - - - 0 - wxID_ANY - -1,-1 - 0 - -1,-1 - m_textCtrlField16 - protected - - -1,-1 - - - - - - - - - - - OnKeyDown - - - - - - - - - - - - - - - - - - - - - - - - - - - 5 - wxALIGN_CENTER_VERTICAL|wxRIGHT|wxLEFT - 0 - - - - 0 - 1 - - - 0 - wxID_DEL16 - Delete - - -1,-1 - m_buttonDel16 - protected - - 60,20 - - - - - - - OnButtonDel16 - - - - - - - - - - - - - - - - - - - - - - - - - - - 5 - wxALL|wxALIGN_CENTER_VERTICAL - 0 - - - - 1 - - - 0 - wxID_ANY - - 0 - 150,-1 - m_textCtrlLabel17 - protected - - - - - - - - - - - - - OnKeyDown - - - - - - - - - - - - - - - - - - - - - - - - - - - 5 - wxALL|wxEXPAND|wxALIGN_CENTER_VERTICAL - 0 - - - - 1 - - - 0 - wxID_ANY - -1,-1 - 0 - -1,-1 - m_textCtrlField17 - protected - - -1,-1 - - - - - - - - - - - OnKeyDown - - - - - - - - - - - - - - - - - - - - - - - - - - - 5 - wxALIGN_CENTER_VERTICAL|wxRIGHT|wxLEFT - 0 - - - - 0 - 1 - - - 0 - wxID_DEL17 - Delete - - -1,-1 - m_buttonDel17 - protected - - 60,20 - - - - - - - OnButtonDel17 - - - - - - - - - - - - - - - - - - - - - - - - - - - 5 - wxALL|wxALIGN_CENTER_VERTICAL - 0 - - - - 1 - - - 0 - wxID_ANY - - 0 - 150,-1 - m_textCtrlLabel18 - protected - - - - - - - - - - - - - OnKeyDown - - - - - - - - - - - - - - - - - - - - - - - - - - - 5 - wxALL|wxEXPAND|wxALIGN_CENTER_VERTICAL - 0 - - - - 1 - - - 0 - wxID_ANY - -1,-1 - 0 - -1,-1 - m_textCtrlField18 - protected - - -1,-1 - - - - - - - - - - - OnKeyDown - - - - - - - - - - - - - - - - - - - - - - - - - - - 5 - wxALIGN_CENTER_VERTICAL|wxRIGHT|wxLEFT - 0 - - - - 0 - 1 - - - 0 - wxID_DEL18 - Delete - - -1,-1 - m_buttonDel18 - protected - - 60,20 - - - - - - - OnButtonDel18 - - - - - - - - - - - - - - - - - - - - - - - - - - - 5 - wxALL|wxALIGN_CENTER_VERTICAL - 0 - - - - 1 - - - 0 - wxID_ANY - - 0 - 150,-1 - m_textCtrlLabel19 - protected - - - - - - - - - - - - - OnKeyDown - - - - - - - - - - - - - - - - - - - - - - - - - - - 5 - wxALL|wxEXPAND|wxALIGN_CENTER_VERTICAL - 0 - - - - 1 - - - 0 - wxID_ANY - -1,-1 - 0 - -1,-1 - m_textCtrlField19 - protected - - -1,-1 - - - - - - - - - - - OnKeyDown - - - - - - - - - - - - - - - - - - - - - - - - - - - 5 - wxALIGN_CENTER_VERTICAL|wxRIGHT|wxLEFT - 0 - - - - 0 - 1 - - - 0 - wxID_DEL19 - Delete - - -1,-1 - m_buttonDel19 - protected - - 60,20 - - - - - - - OnButtonDel19 - - - - - - - - - - - - - - - - - - - - - - - - - - - - - 5 - wxALIGN_CENTER_HORIZONTAL - 0 - - - bSizer25 - wxHORIZONTAL - none - - 5 - wxALL - 0 - - - - 0 - 1 - - - 0 - wxID_ANY - &Add Field - - - m_buttonAddField - protected - - - - - - - - - OnButtonAddField - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - 5 - wxALIGN_RIGHT - 0 - - - bSizer26 - wxHORIZONTAL - none - - 5 - wxALL - 0 - - - - 0 - 1 - - - 0 - wxID_BUTTONSEND - &Send - - 85,25 - m_buttonOK - protected - - - - - - - - - OnButtonSend - - - - - - - - - - - - - - - - - - - - - - - - - - - 5 - wxALL - 0 - - - - 0 - 1 - - - 0 - wxID_BUTTONPREVIEW - &Preview - - 85,25 - m_buttonPreview - protected - - - - - - - - - OnButtonPreview - - - - - - - - - - - - - - - - - - - - - - - - - - - 5 - wxALL - 0 - - - - 0 - 1 - - - 0 - wxID_CANCEL - Cancel - - 85,25 - m_buttonCancel - protected - - - - - - - - - OnButtonCancel - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - wxSYS_COLOUR_MENU - - - 1 - - - - 0 - wxID_ANY - - - CViewProductDialogBase - - 630,520 - wxDEFAULT_FRAME_STYLE|wxRESIZE_BORDER - - Order Form - - - - wxTAB_TRAVERSAL - 1 - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - bSizer20 - wxVERTICAL - none - - 5 - wxEXPAND - 1 - - - bSizer116 - wxHORIZONTAL - none - - 5 - wxALL|wxEXPAND - 1 - - - - 1 - - - 1 - wxID_ANY - - - m_htmlWinReviews - protected - - - wxHW_SCROLLBAR_AUTO - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - 5 - wxEXPAND|wxALL - 1 - - wxSYS_COLOUR_WINDOW - - 1 - - - 0 - wxID_ANY - - - m_scrolledWindow - protected - - 5 - 5 - - - - - - wxHSCROLL|wxTAB_TRAVERSAL|wxVSCROLL - - - - - - - - - - - - - - - - - - - - - - - - - - bSizer21 - wxVERTICAL - none - - 5 - wxEXPAND - 0 - - - - 1 - - - 0 - wxID_ANY - - -1,-1 - m_richTextHeading - protected - - -1,50 - wxTE_READONLY - - - - - wxNO_BORDER - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - 5 - wxALL|wxEXPAND - 0 - - - - 1 - - - 0 - wxID_ANY - Order Form instructions here multiple lines 1 2 3 4 5 6 - - - m_staticTextInstructions - protected - - - - - - - - - -1 - - - - - - - - - - - - - - - - - - - - - - - - - - - 5 - wxALIGN_CENTER_HORIZONTAL - 0 - - - bSizer25 - wxHORIZONTAL - none - - 5 - wxALL - 0 - - - - 0 - 1 - - - 0 - wxID_BUTTONSAMPLE - &Submit - - -1,-1 - m_buttonSubmitForm - protected - - - - - - - - - OnButtonSubmitForm - - - - - - - - - - - - - - - - - - - - - - - - - - - 5 - wxALL - 0 - - - - 0 - 1 - - - 0 - wxID_CANCEL2 - Cancel - - - m_buttonCancelForm - protected - - - - - - - - - OnButtonCancelForm - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - 5 - wxALIGN_RIGHT - 0 - - - bSizer26 - wxHORIZONTAL - none - - 5 - wxALL - 0 - - - - 0 - 0 - - - 0 - wxID_BUTTONBACK - < &Back - - 85,25 - m_buttonBack - protected - - - - - - - - - OnButtonBack - - - - - - - - - - - - - - - - - - - - - - - - - - - 5 - wxALL - 0 - - - - 0 - 1 - - - 0 - wxID_BUTTONNEXT - &Next > - - 85,25 - m_buttonNext - protected - - - - - - - - - OnButtonNext - - - - - - - - - - - - - - - - - - - - - - - - - - - 5 - wxALL - 0 - - - - 0 - 1 - - - 0 - wxID_CANCEL - Cancel - - 85,25 - m_buttonCancel - protected - - - - - - - - - OnButtonCancel - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - wxSYS_COLOUR_MENU - - - 1 - - - - 0 - wxID_ANY - - - CViewOrderDialogBase - - 630,520 - wxDEFAULT_FRAME_STYLE|wxRESIZE_BORDER - - View Order - - - - wxTAB_TRAVERSAL - 1 - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - bSizer20 - wxVERTICAL - none - - 5 - wxEXPAND - 1 - - - bSizer116 - wxHORIZONTAL - none - - 5 - wxALL|wxEXPAND - 1 - - - - 1 - - - 0 - wxID_ANY - - - m_htmlWin - protected - - - wxHW_SCROLLBAR_AUTO - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - 5 - wxALIGN_RIGHT - 0 - - - bSizer26 - wxHORIZONTAL - none - - 5 - wxALL - 0 - - - - 0 - 1 - - - 0 - wxID_OK - OK - - 85,25 - m_buttonOK - protected - - - - - - - - - OnButtonOK - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - wxSYS_COLOUR_MENU - - - 1 - - - - 0 - wxID_ANY - - - CEditReviewDialogBase - - 630,440 - wxDEFAULT_FRAME_STYLE|wxRESIZE_BORDER - - Enter Review - - - - wxTAB_TRAVERSAL - 1 - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - bSizer112 - wxVERTICAL - none - - 5 - - 0 - - 3 - protected - 0 - - - - 5 - wxALL|wxEXPAND - 0 - - - - 1 - - - 0 - wxID_ANY - - - - m_staticTextSeller - protected - - - - - - - - - -1 - - - - - - - - - - - - - - - - - - - - - - - - - - - 5 - - 0 - - 3 - protected - 0 - - - - 5 - wxTOP|wxRIGHT|wxLEFT - 0 - - - - 1 - - - 0 - wxID_ANY - Rating - - - m_staticText110 - protected - - - - - - - - - -1 - - - - - - - - - - - - - - - - - - - - - - - - - - - 5 - wxALL - 0 - - - " 1 star" " 2 stars" " 3 stars" " 4 stars" " 5 stars" - - 1 - - - 0 - wxID_ANY - - - m_choiceStars - protected - - 0 - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - 5 - wxTOP|wxRIGHT|wxLEFT - 0 - - - - 1 - - - 0 - wxID_ANY - Review - - - m_staticText43 - protected - - - - - - - - - -1 - - - - - - - - - - - - - - - - - - - - - - - - - - - 5 - wxALL|wxEXPAND - 1 - - - - 1 - - - 0 - wxID_ANY - - 0 - - m_textCtrlReview - protected - - - wxTE_MULTILINE - - - - - - - - - - OnKeyDown - - - - - - - - - - - - - - - - - - - - - - - - - - - 5 - wxALIGN_RIGHT - 0 - - - bSizer113 - wxHORIZONTAL - none - - 5 - wxALL - 0 - - - - 0 - 1 - - - 0 - wxID_SUBMIT - &Submit - - 85,25 - m_buttonSubmit - protected - - - - - - - - - OnButtonSubmit - - - - - - - - - - - - - - - - - - - - - - - - - - - 5 - wxALL - 0 - - - - 0 - 1 - - - 0 - wxID_CANCEL - Cancel - - 85,25 - m_buttonCancel - protected - - - - - - - - - OnButtonCancel - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - wxSYS_COLOUR_BTNFACE - - - 1 - - - - 0 - wxID_ANY - - - CPokerLobbyDialogBase - - 586,457 - wxDEFAULT_FRAME_STYLE - - Poker Lobby - - - - wxTAB_TRAVERSAL - 1 - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - bSizer156 - wxHORIZONTAL - none - - 5 - wxEXPAND|wxTOP|wxBOTTOM|wxLEFT - 0 - - - - 1 - - - 0 - wxID_ANY - - 130,-1 - m_treeCtrl - protected - - - wxTR_HAS_BUTTONS|wxTR_HIDE_ROOT|wxTR_LINES_AT_ROOT - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - OnTreeSelChanged - - - - - - - - 5 - wxEXPAND - 1 - - - bSizer172 - wxVERTICAL - none - - 5 - wxEXPAND|wxALL - 1 - - - - 1 - - - 0 - wxID_ANY - - - m_listCtrl - protected - - - wxLC_NO_SORT_HEADER|wxLC_REPORT - - - - - - - - - - - - - - - - - - - - - - - - - - - - - OnListItemActivated - - - - - OnListItemSelected - - - - - - - - - - - - - - - - - - 5 - wxALL - 0 - - - - 0 - 1 - - - 0 - wxID_OPENNEWTABLE - &Open New Table - - - m_buttonNewTable - protected - - - - - - - - - OnButtonNewTable - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - 1 - - - - 0 - wxID_ANY - - - CPokerDialogBase - - 806,550 - wxDEFAULT_FRAME_STYLE|wxFRAME_NO_TASKBAR - - Poker - - - - wxFULL_REPAINT_ON_RESIZE|wxTAB_TRAVERSAL - 1 - - - - OnClose - - - - - - - - - - - - - - - - - OnMouseEvents - - OnPaint - - - - - OnSize - - - - bSizer174 - wxVERTICAL - none - - 5 - wxALL - 0 - - - 0 - - 1 - - - 0 - wxID_ANY - Deal Me Out - - - m_checkSitOut - public - - - - - - - - - - OnCheckSitOut - - - - - - - - - - - - - - - - - - - - - - - - - - 5 - wxALL - 0 - - - - 0 - 1 - - - 0 - wxID_DEALHAND - &Deal Hand - - - m_buttonDealHand - protected - - 150,25 - - - - - - - OnButtonDealHand - - - - - - - - - - - - - - - - - - - - - - - - - - - 5 - wxALL - 0 - - - - 0 - 1 - - - 0 - wxID_FOLD - &Fold - - - m_buttonFold - protected - - 80,25 - - - - - - - OnButtonFold - - - - - - - - - - - - - - - - - - - - - - - - - - - 5 - wxALL - 0 - - - - 0 - 1 - - - 0 - wxID_CALL - &Call - - - m_buttonCall - protected - - 80,25 - - - - - - - OnButtonCall - - - - - - - - - - - - - - - - - - - - - - - - - - - 5 - wxALL - 0 - - - - 0 - 1 - - - 0 - wxID_RAISE - &Raise - - - m_buttonRaise - protected - - 80,25 - - - - - - - OnButtonRaise - - - - - - - - - - - - - - - - - - - - - - - - - - - 5 - wxALL - 0 - - - - 0 - 1 - - - 0 - wxID_LEAVETABLE - &Leave Table - - - m_buttonLeaveTable - protected - - 90,25 - - - - - - - OnButtonLeaveTable - - - - - - - - - - - - - - - - - - - - - - - - - - - 5 - wxALL - 0 - - - - 1 - - - 0 - wxID_DITCHPLAYER - - 0 - - m_textDitchPlayer - protected - - 45,-1 - wxTE_PROCESS_ENTER - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - OnDitchPlayer - - - - - - - 5 - wxALL - 0 - - - 0 - - 1 - - - 0 - wxID_ANY - FOLD - - - m_checkPreFold - public - - 100,-1 - - - - - - - - OnCheckPreFold - - - - - - - - - - - - - - - - - - - - - - - - - - 5 - wxALL - 0 - - - 0 - - 1 - - - 0 - wxID_ANY - CALL - - - m_checkPreCall - public - - 100,-1 - - - - - - - - OnCheckPreCall - - - - - - - - - - - - - - - - - - - - - - - - - - 5 - wxALL - 0 - - - 0 - - 1 - - - 0 - wxID_ANY - CALL ANY - - - m_checkPreCallAny - public - - 100,-1 - - - - - - - - OnCheckPreCallAny - - - - - - - - - - - - - - - - - - - - - - - - - - 5 - wxALL - 0 - - - 0 - - 1 - - - 0 - wxID_ANY - RAISE - - - m_checkPreRaise - public - - 100,-1 - - - - - - - - OnCheckPreRaise - - - - - - - - - - - - - - - - - - - - - - - - - - 5 - wxALL - 0 - - - 0 - - 1 - - - 0 - wxID_ANY - RAISE ANY - - - m_checkPreRaiseAny - public - - 100,-1 - - - - - - - - OnCheckPreRaiseAny - - - - - - - - - - - - - - - - - - - - - - - - - - - - - 1 - - 1 - - 0 - wxID_ANY - - - m_statusBar - public - - - wxST_SIZEGRIP - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - 1 - - - - 0 - wxID_ANY - - - CGetTextFromUserDialogBase - - 403,138 - wxDEFAULT_DIALOG_STYLE - - - - - - - - - - OnClose - - - - - - - - - - - - - - - - - - - - - - - - - - - - - bSizer79 - wxVERTICAL - none - - 10 - wxEXPAND|wxALL - 1 - - - bSizer81 - wxVERTICAL - none - - 5 - wxEXPAND - 1 - - 0 - protected - 0 - - - - 5 - wxTOP|wxRIGHT|wxLEFT - 0 - - - - 1 - - - 0 - wxID_ANY - - - - m_staticTextMessage1 - protected - - - - - - - - - -1 - - - - - - - - - - - - - - - - - - - - - - - - - - - 5 - wxALL|wxEXPAND|wxALIGN_CENTER_HORIZONTAL - 0 - - - - 1 - - - 0 - wxID_TEXTCTRL - - 0 - - m_textCtrl1 - protected - - - wxTE_PROCESS_ENTER - - - - - - - - - - OnKeyDown - - - - - - - - - - - - - - - - - - - - - - - - - - - 5 - wxTOP|wxRIGHT|wxLEFT - 0 - - - - 1 - - - 1 - wxID_ANY - - - - m_staticTextMessage2 - protected - - - - - - - - - -1 - - - - - - - - - - - - - - - - - - - - - - - - - - - 5 - wxALL|wxEXPAND|wxALIGN_CENTER_HORIZONTAL - 0 - - - - 1 - - - 1 - wxID_TEXTCTRL - - 0 - - m_textCtrl2 - protected - - - wxTE_PROCESS_ENTER - - - - - - - - - - OnKeyDown - - - - - - - - - - - - - - - - - - - - - - - - - - - 5 - wxEXPAND - 1 - - 0 - protected - 0 - - - - - - 5 - wxEXPAND - 0 - - - bSizer80 - wxHORIZONTAL - none - - 5 - wxEXPAND - 1 - - 0 - protected - 0 - - - - 5 - wxALL - 0 - - - - 0 - 1 - - - 0 - wxID_OK - OK - - 85,25 - m_buttonOK - protected - - -1,-1 - - - - - - - OnButtonOK - - - - - - - - - - - - - - - - - - - - - - - - - - - 5 - wxALL - 0 - - - - 0 - 1 - - - 0 - wxID_CANCEL - Cancel - - 85,25 - m_buttonCancel - protected - - - - - - - - - OnButtonCancel - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/src/util.cpp b/src/util.cpp index 7f56530..68bd18e 100644 --- a/src/util.cpp +++ b/src/util.cpp @@ -8,6 +8,99 @@ bool fDebug = false; +// --------------------------------------------------------------------------- +// Categorized, burst-collapsing logging -- see the LogPrint doc comment in +// util.h for the problem this solves. +// --------------------------------------------------------------------------- + +// Categories that explain the pool/worker/payout pipeline are on unconditionally: +// silence there is exactly the "worker stuck, nothing then happens" complaint +// this exists to fix, so it can't be opted out of by forgetting -debug. +// "net" and "nostr" cover the per-address connection/discovery chatter that +// used to dominate debug.log; they're gated behind fDebug like the printf() +// calls they replace, so default output stays quiet unless requested. +static bool LogCategoryDefaultOn(const char* category) +{ + return strcmp(category, "pool") == 0 || + strcmp(category, "worker") == 0 || + strcmp(category, "payout") == 0; +} + +bool LogAcceptsCategory(const char* category) +{ + if (LogCategoryDefaultOn(category)) + return true; + return fDebug; +} + +// Only collapse bursts from the exact same call site within this window; +// a different LogPrint() call (different file/line) is never merged with +// this one, so unrelated messages never get mixed into one summary. +static const int64 DEDUP_WINDOW_SECS = 3; + +struct LogDedupState { + int64 windowStart; // GetTime() when the current window opened + int suppressed; // calls swallowed so far this window + std::string lastMsg; // most recent message text, for the summary line +}; + +static std::map g_logDedup; +static CCriticalSection cs_logDedup; + +void LogPrintDedup(const char* category, const char* file, int line, const std::string& msgIn) +{ + // Call sites follow the existing printf() convention of ending their + // format string with \n; strip it here so the single \n this function + // adds below doesn't turn into a blank line. + std::string msg = msgIn; + while (!msg.empty() && (msg.back() == '\n' || msg.back() == '\r')) + msg.pop_back(); + + char key[512]; + my_snprintf(key, sizeof(key), "%s|%s:%d", category, file, line); + + int64 now = GetTime(); + bool fEmitNow = false; + int suppressedToFlush = 0; + std::string flushedMsg; + + CRITICAL_BLOCK(cs_logDedup) + { + std::map::iterator it = g_logDedup.find(key); + if (it == g_logDedup.end()) + { + // First time this call site has fired (or its last window is long + // gone) -- print it directly and open a fresh window. + g_logDedup[key] = LogDedupState{now, 0, msg}; + fEmitNow = true; + } + else if (now - it->second.windowStart >= DEDUP_WINDOW_SECS) + { + // Window rolled over: flush what was suppressed during it (if + // anything), then start a new window with this call printed. + suppressedToFlush = it->second.suppressed; + flushedMsg = it->second.lastMsg; + it->second.windowStart = now; + it->second.suppressed = 0; + it->second.lastMsg = msg; + fEmitNow = true; + } + else + { + // Still inside the window and already printed one line for it: + // count this occurrence instead of writing another near-duplicate. + it->second.suppressed++; + it->second.lastMsg = msg; + } + } + + if (suppressedToFlush > 0) + OutputDebugStringF("[%s] (%d similar in %llds, last: %s)\n", + category, suppressedToFlush, (long long)DEDUP_WINDOW_SECS, flushedMsg.c_str()); + if (fEmitNow) + OutputDebugStringF("[%s] %s\n", category, msg.c_str()); +} + @@ -200,11 +293,16 @@ void PrintException(std::exception* pex, const char* pszThread) snprintf(pszMessage, sizeof(pszMessage), "UNKNOWN EXCEPTION \n%s in %s \n", pszModule, pszThread); printf("\n\n************************\n%s", pszMessage); - // Suppress the dialog and rethrow during shutdown — threads unwinding on - // exit cause spurious "UNKNOWN EXCEPTION" popups that aren't real errors. - // Show error in GUI via repaint signal — GUI polls g_errorMessage - if (!fShutdown) - throw; + // Do NOT rethrow here. Every caller of PrintException (via + // CATCH_PRINT_EXCEPTION) is a thread wrapped in its own + // "try { ThreadFoo2() } catch(...) { log, sleep, loop again }" self-healing + // retry loop -- that's the whole point of the wrapper. Rethrowing from + // inside that catch block escapes the retry loop entirely (nothing above + // it in the thread catches it) and takes down the whole process with + // std::terminate() on the very first transient error -- a flaky relay + // connection, a malformed message, anything -- instead of letting the + // thread log it, sleep 5s, and try again like it's designed to. + // Show error in GUI via repaint signal -- GUI polls g_errorMessage } diff --git a/src/util.h b/src/util.h index c53293a..641b838 100644 --- a/src/util.h +++ b/src/util.h @@ -26,11 +26,14 @@ typedef unsigned long long uint64; #define UEND(a) ((unsigned char*)&((&(a))[1])) #define ARRAYLEN(array) (sizeof(array)/sizeof((array)[0])) -// Route printf to debug.log (and the console) on the GUI Windows build and on -// the headless Linux build alike. -#if defined(_WINDOWS) || !defined(_WIN32) +// Route printf to debug.log (and the console on non-Windows) everywhere. +// (Previously gated on `defined(_WINDOWS) || !defined(_WIN32)` -- but +// _WINDOWS is only ever defined by the legacy, unused makefile.vc build; +// the real Windows build (makefile.mingw) never defines it, so on actual +// Windows this condition was always false and debug.log was never written. +// OutputDebugStringF() is already platform-safe internally: it always +// writes debug.log, and only echoes to the console when not on Windows.) #define printf OutputDebugStringF -#endif #ifdef snprintf #undef snprintf @@ -70,6 +73,35 @@ inline T& REF(const T& val) extern bool fDebug; +// --------------------------------------------------------------------------- +// Categorized, burst-collapsing logging +// +// Plain printf() (routed to OutputDebugStringF below) writes every call +// unfiltered to debug.log. That's fine for one-off events, but a loop that +// logs once per item -- once per discovered peer, once per connect attempt, +// once per share -- turns debug.log into a wall of near-identical lines that +// differ only by an address, while the line that actually explains a stuck +// flow ("Stratum: no job available for ...") scrolls off screen under the +// noise. LogPrint() is a drop-in replacement for printf() that fixes both +// problems without touching call sites' formatting: +// +// 1. Category tag: every call names the subsystem it belongs to ("pool", +// "worker", "payout", "net", "nostr", ...). LogAcceptsCategory() +// controls what's written (see util.cpp); the pool/worker/payout flow +// the person actually needs visibility into is on by default. +// 2. Burst collapse: repeated LogPrint() calls from the *same source line* +// only emit one line per DEDUP_WINDOW_SECS; if more calls land inside +// that window they're counted instead of reprinted, and the count is +// flushed as a single "(xN similar in Ws)" line the moment the window +// rolls over. A loop discovering 500 peers by address now produces a +// small, bounded number of lines instead of 500 -- log growth is capped +// by elapsed time, not by how many similar events happen to fire. +// --------------------------------------------------------------------------- +bool LogAcceptsCategory(const char* category); +void LogPrintDedup(const char* category, const char* file, int line, const std::string& msg); +#define LogPrint(category, ...) \ + do { if (LogAcceptsCategory(category)) LogPrintDedup((category), __FILE__, __LINE__, strprintf(__VA_ARGS__)); } while (0) + void RandAddSeed(bool fPerfmon=false); int my_snprintf(char* buffer, size_t limit, const char* format, ...); string strprintf(const char* format, ...); @@ -231,7 +263,7 @@ void PrintHex(const T pbegin, const T pend, const char* pszFormat="%s", bool fSp -// Forward declaration — defined in main.cpp +// Forward declaration -- defined in main.cpp string GetAppDir(); inline int OutputDebugStringF(const char* pszFormat, ...) -- cgit v1.2.3 From 96a8e42bbc313f96d54377ae5886da84298e61a3 Mon Sep 17 00:00:00 2001 From: Bitflash Date: Wed, 29 Jul 2026 11:52:12 -0300 Subject: Remember .btf peers that answered, and dial them first on start (#25) * net: remember .btf peers that answered, and dial them first on start Discovery depends entirely on the Nostr relays. A node that has been up for hours knows exactly which peers respond, and throws all of it away on exit; the next start reaches for the relays again and walks the same descriptor list, of which roughly 96% is dead. That is what makes a restart take minutes before the first tunnel opens -- measured today at about seven. Every peer that completes a tunnel is now written to btfpeers.json with its meeting node and encryption key, which is everything needed to redial it. On start a thread tries those before the relays have answered anything, stopping once eight are connected. Entries older than a week are dropped, and the file holds at most fifty. Cache only: nothing here changes how a first-ever run bootstraps, and a missing or corrupt file just falls through to relay discovery. * net: exchange signed .btf descriptors between peers Discovery has depended entirely on the Nostr relays. Two connected nodes never told each other who else existed, so a relay outage left a running network unable to grow, and a node that found one stale peer had no way to learn there was anything better. The old `addr` gossip went out with the IRC seed. Peers now trade signed descriptors over a new "btfpeers" message: our own first, then peers that actually answered us. The receiver checks each Schnorr signature against the key the `.btf` address decodes to, so the sender is trusted for nothing -- forging an entry needs somebody else's secret key. That leaves flooding with self-generated keys, which the per-message caps and the "peers that answered" ordering are there to blunt. Unknown commands are ignored (main.cpp), so nodes without this just drop the message; nothing here is a protocol break. The cache from the peer-cache change now keeps each peer's descriptor verbatim alongside the parsed fields, because that signature is what makes a peer relayable. Entries without one -- resolved through Nostr, or written before this -- stay dialable, just not passed on. A dial never clears a descriptor already on file. * main_gui: implement /port, which net.cpp already documented net.cpp has said nListenPort is "tunable via /port" since it was written, but nothing ever parsed the option. Two nodes could not share a machine, which is exactly what testing peer exchange needs. Sets addrLocalHost.port too, so we do not advertise a port we never bound. --- src/main.cpp | 31 ++++++ src/main_gui.cpp | 18 ++++ src/net.cpp | 280 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/net.h | 19 ++++ src/nostr.cpp | 30 ++++++ src/nostr.h | 8 ++ 6 files changed, 386 insertions(+) (limited to 'src/main_gui.cpp') diff --git a/src/main.cpp b/src/main.cpp index a75bf54..bb317d2 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -1877,6 +1877,14 @@ bool ProcessMessage(CNode* pfrom, string strCommand, CDataStream& vRecv) pfrom->PushMessage("getblocks", CBlockLocator(pindexBest), uint256(0)); } + // Introduce ourselves and hand over the .btf peers that answered us. + // Everything in here is self-certifying, so this is safe to send to a + // node we know nothing about, and safe for it to act on. + vector vPexOut; + BtfPexCollect(vPexOut); + if (!vPexOut.empty()) + pfrom->PushMessage("btfpeers", vPexOut); + if (LogAcceptsCategory("net")) printf("version addrMe = %s\n", addrMe.ToString().c_str()); } @@ -1888,6 +1896,29 @@ bool ProcessMessage(CNode* pfrom, string strCommand, CDataStream& vRecv) } + else if (strCommand == "btfpeers") + { + // Peer exchange. Nothing here is taken on trust: BtfPexAccept checks + // every descriptor's signature against the key its .btf address + // decodes to, and drops the rest. + int64 nNow = GetTime(); + if (nNow - pfrom->nLastPexRecv < PEX_MIN_INTERVAL) + { + if (LogAcceptsCategory("net")) + printf("btfpeers: ignoring, %s is sending them too fast\n", + pfrom->addr.ToString().c_str()); + return true; + } + pfrom->nLastPexRecv = nNow; + + vector vDesc; + vRecv >> vDesc; + int nKept = BtfPexAccept(vDesc); + LogPrint("net", "btfpeers: kept %d of %zu descriptor(s) from %s\n", + nKept, vDesc.size(), pfrom->addr.ToString().c_str()); + } + + else if (strCommand == "inv") { vector vInv; diff --git a/src/main_gui.cpp b/src/main_gui.cpp index 842b0d5..edc772d 100644 --- a/src/main_gui.cpp +++ b/src/main_gui.cpp @@ -58,6 +58,7 @@ static void PrintUsage() printf(" /connectbtf=PEER_BTF_ADDRESS\n"); printf(" /rvrelay=HOST:PORT\n"); printf(" /announcerelay=HOST:PORT\n"); + printf(" /port=N (P2P listen port, default 8433)\n"); printf("\n"); printf("Each option also accepts '-' instead of '/'.\n"); } @@ -113,6 +114,23 @@ static void ParseStartupArguments(int argc, char* argv[]) string announceRelay = argval2(argc, argv, "/announcerelay", "-announcerelay"); if (!announceRelay.empty()) strBtfAnnounceRelay = announceRelay; + + // net.cpp has claimed nListenPort was "tunable via /port" since it was + // written, but nothing ever read the option. Without it a second node + // cannot start on a machine that already runs one -- which is exactly what + // testing peer exchange needs. + string strPort = argval2(argc, argv, "/port", "-port"); + if (!strPort.empty()) + { + int nPort = atoi(strPort.c_str()); + if (nPort <= 0 || nPort > 65535) + fprintf(stderr, "Ignoring /port=%s: not a port number\n", strPort.c_str()); + else + { + nListenPort = htons((unsigned short)nPort); + addrLocalHost.port = nListenPort; // or we advertise a port we never bound + } + } } int main(int argc, char* argv[]) diff --git a/src/net.cpp b/src/net.cpp index ab07f94..dfeb4a2 100644 --- a/src/net.cpp +++ b/src/net.cpp @@ -2,6 +2,13 @@ // Distributed under the MIT/X11 software license, see the accompanying // file license.txt or http://www.opensource.org/licenses/mit-license.php. +#pragma push_macro("snprintf") +#undef snprintf +#include +#pragma pop_macro("snprintf") +#ifdef snprintf +#undef snprintf +#endif #include "headers.h" #ifdef _WIN32 #include @@ -10,6 +17,7 @@ #include "btfaddr.h" #include "btftunnel.h" +void ThreadReconnectCachedBtfPeers(void* parg); void ThreadMessageHandler2(void* parg); void ThreadSocketHandler2(void* parg); @@ -259,6 +267,269 @@ static CAddress BtfMarkerAddr(const unsigned char b5[5]) // ConnectNodeBtfResolved (which takes an already-resolved descriptor, so it // makes no Nostr relay calls at all -- see nostr.cpp's BtfResolveMany for why // that matters when dialing many candidates in parallel). +// +// Peers we have actually reached, remembered across restarts. +// +// Discovery is otherwise entirely dependent on the Nostr relays: a node that +// has been running for hours and knows exactly which peers answer throws all +// of it away on exit, and the next start walks the same ~96%-dead descriptor +// list again. Reconnecting from this file skips the relays completely -- +// address, meeting node and encryption key are all we need. +// +static const size_t MAX_CACHED_BTF_PEERS = 50; +static const int64 CACHED_BTF_PEER_TTL = 7 * 24 * 60 * 60; // a week + +struct CachedBtfPeer +{ + string btfAddr; + string meeting; + string encHex; + int64 lastSeen; + // The peer's own signed descriptor, as it announced itself. Kept verbatim + // because that signature is what lets us hand this peer on to somebody + // else: the receiver checks it against the key the address decodes to and + // never has to take our word for anything. Empty for entries learned + // before peer exchange existed, or resolved through Nostr -- those are + // still dialable, just not relayable. + string desc; +}; + +static string BtfPeerCachePath() { return GetAppDir() + "/btfpeers.json"; } + +static string BytesToHex(const unsigned char* p, size_t n) +{ + static const char* h = "0123456789abcdef"; + string s; + s.reserve(n * 2); + for (size_t i = 0; i < n; i++) { s += h[p[i] >> 4]; s += h[p[i] & 0xf]; } + return s; +} + +static bool HexToBytes(const string& s, unsigned char* out, size_t n) +{ + if (s.size() != n * 2) return false; + for (size_t i = 0; i < n; i++) + { + unsigned int b; + if (sscanf(s.c_str() + i * 2, "%2x", &b) != 1) return false; + out[i] = (unsigned char)b; + } + return true; +} + +void LoadCachedBtfPeers(vector& out) +{ + out.clear(); + FILE* f = fopen(BtfPeerCachePath().c_str(), "r"); + if (!f) return; + string body; + char buf[4096]; + size_t r; + while ((r = fread(buf, 1, sizeof(buf), f)) > 0) body.append(buf, r); + fclose(f); + try + { + nlohmann::json arr = nlohmann::json::parse(body); + if (!arr.is_array()) return; + int64 nNow = GetTime(); + for (const auto& o : arr) + { + if (!o.is_object()) continue; + if (!o.contains("btf") || !o["btf"].is_string()) continue; + if (!o.contains("meeting") || !o["meeting"].is_string()) continue; + if (!o.contains("enc") || !o["enc"].is_string()) continue; + CachedBtfPeer p; + p.btfAddr = o["btf"].get(); + p.meeting = o["meeting"].get(); + p.encHex = o["enc"].get(); + p.lastSeen = o.value("seen", (int64)0); + if (o.contains("desc") && o["desc"].is_string()) + p.desc = o["desc"].get(); + if (nNow - p.lastSeen > CACHED_BTF_PEER_TTL) continue; // long gone + out.push_back(p); + } + } + catch (...) + { + LogPrint("net", "btfpeers: cache unreadable, starting empty\n"); + out.clear(); + } +} + +static CCriticalSection cs_btfPeerCache; + +// strDesc is the peer's own signed descriptor when we have it (it announced +// itself over peer exchange), "" when we only resolved it through Nostr. An +// empty one never clears a descriptor already on file: dialing a peer we first +// learned about by exchange must not cost us the ability to pass it on. +static void RememberBtfPeer(const string& strBtfAddr, const string& strMeeting, + const unsigned char enc_pub[32], + const string& strDesc = string()) +{ + CRITICAL_BLOCK(cs_btfPeerCache) + { + vector peers; + LoadCachedBtfPeers(peers); + + string strKeepDesc = strDesc; + for (size_t i = 0; i < peers.size(); i++) + if (peers[i].btfAddr == strBtfAddr) + { + if (strKeepDesc.empty()) + strKeepDesc = peers[i].desc; + peers.erase(peers.begin() + i); + break; + } + + CachedBtfPeer p; + p.btfAddr = strBtfAddr; + p.meeting = strMeeting; + p.encHex = BytesToHex(enc_pub, 32); + p.lastSeen = GetTime(); + p.desc = strKeepDesc; + peers.insert(peers.begin(), p); // most recent first + + if (peers.size() > MAX_CACHED_BTF_PEERS) + peers.resize(MAX_CACHED_BTF_PEERS); + + nlohmann::json arr = nlohmann::json::array(); + foreach(const CachedBtfPeer& q, peers) + { + nlohmann::json o; + o["btf"] = q.btfAddr; + o["meeting"] = q.meeting; + o["enc"] = q.encHex; + o["seen"] = q.lastSeen; + if (!q.desc.empty()) + o["desc"] = q.desc; + arr.push_back(o); + } + FILE* f = fopen(BtfPeerCachePath().c_str(), "w"); + if (f) + { + string s = arr.dump(2); + fwrite(s.c_str(), 1, s.size(), f); + fclose(f); + } + } +} + +// Dial everything we reached last time, before the relays have said anything. +void ThreadReconnectCachedBtfPeers(void* parg) +{ + vector peers; + LoadCachedBtfPeers(peers); + if (peers.empty()) + { + LogPrint("net", "btfpeers: no cache yet, waiting on relay discovery\n"); + return; + } + LogPrint("net", "btfpeers: trying %zu remembered peer(s) before discovery\n", + peers.size()); + + int nConnected = 0; + foreach(const CachedBtfPeer& p, peers) + { + if (fShutdown) return; + unsigned char enc[32]; + if (!HexToBytes(p.encHex, enc, 32)) continue; + if (ConnectNodeBtfResolved(p.btfAddr, p.meeting, enc)) + { + nConnected++; + if (nConnected >= 8) break; // enough to bootstrap; the rest can wait + } + } + LogPrint("net", "btfpeers: %d of %zu remembered peer(s) answered\n", + nConnected, peers.size()); +} + +// +// .btf peer exchange +// +// Until now discovery depended entirely on the Nostr relays: two connected +// nodes never told each other who else existed, so a relay outage left a +// running network unable to grow, and a node that found one stale peer had no +// way to learn there was anything better. +// +// What travels here is each peer's own signed descriptor. The receiver checks +// the Schnorr signature against the key the `.btf` address decodes to, so the +// sender is trusted for nothing -- forging an entry would need somebody else's +// secret key. That leaves flooding: valid descriptors for keys the sender +// generated itself. The caps below bound one message, and the cache prefers +// peers that actually answered, so a flood costs a Sybil more than it costs us. +// + +void BtfPexCollect(vector& vDescOut) +{ + vDescOut.clear(); + + // Ours first -- the node on the other end may know nobody but us. + string strMine = BtfLocalDescriptor(); + if (!strMine.empty()) + vDescOut.push_back(strMine); + + vector peers; + CRITICAL_BLOCK(cs_btfPeerCache) + LoadCachedBtfPeers(peers); + + // Most recently seen first: these answered us, they are not names copied + // off a relay listing. + foreach(const CachedBtfPeer& p, peers) + { + if (vDescOut.size() >= MAX_PEX_DESCRIPTORS) break; + if (p.desc.empty()) continue; // nothing provable to pass on + if (p.desc.size() > MAX_PEX_DESCRIPTOR_BYTES) continue; + vDescOut.push_back(p.desc); + } +} + +int BtfPexAccept(const vector& vDesc) +{ + void* ctx = BtfSecpContext(); + if (!ctx) + return 0; + + string strSelf = BtfLocalAddress(); + int nKept = 0; + unsigned int nSeen = 0; + + foreach(const string& strDesc, vDesc) + { + if (++nSeen > MAX_PEX_DESCRIPTORS) break; + if (strDesc.empty() || strDesc.size() > MAX_PEX_DESCRIPTOR_BYTES) continue; + + // The descriptor names the key it belongs to, and taking that key from + // the blob is safe precisely because the signature must verify under + // it: nobody can produce a valid descriptor for a key they don't hold. + unsigned char pubkey[32]; + try + { + nlohmann::json v = nlohmann::json::parse(strDesc); + if (!v.is_object() || !v.contains("pubkey") || !v["pubkey"].is_string()) + continue; + if (!HexToBytes(v["pubkey"].get(), pubkey, 32)) + continue; + } + catch (...) { continue; } // malformed JSON from an untrusted peer + + btf::Descriptor d; + if (!btf::VerifyDescriptor(ctx, strDesc, pubkey, d)) + continue; + + string strAddr = btf::Address(pubkey); + if (strAddr.empty() || strAddr == strSelf) + continue; // ourselves, nothing to learn + + unsigned char enc[32]; + if (!HexToBytes(d.enc, enc, 32)) + continue; + + RememberBtfPeer(strAddr, d.meeting_node, enc, strDesc); + nKept++; + } + return nKept; +} + static CNode* ConnectNodeBtfTail(const string& strBtfAddr, const unsigned char pk[32], const string& strMeeting, const unsigned char enc_pub[32]) { @@ -289,6 +560,9 @@ static CNode* ConnectNodeBtfTail(const string& strBtfAddr, const unsigned char p if (fDebug) LogPrint("net", "connected %s via rendezvous %s\n", strBtfAddr.c_str(), strMeeting.c_str()); + // This one answered -- worth trying first next time we start. + RememberBtfPeer(strBtfAddr, strMeeting, enc_pub); + // Add node pnode = new CNode(hSocket, addr, false); pnode->AddRef(); @@ -951,6 +1225,12 @@ bool StartNode(string& strError) } }, 0, NULL); + // Peers that answered last time, dialled straight away. The relays are the + // only way to find anyone otherwise, and reaching them plus walking their + // descriptor list is what makes a restart take minutes. + if (_beginthread(ThreadReconnectCachedBtfPeers, 0, NULL) == -1) + printf("Error: _beginthread(ThreadReconnectCachedBtfPeers) failed\n"); + // Peer discovery over Nostr relays (replaces the old IRC seed) if (_beginthread(ThreadNostrSeed, 0, NULL) == -1) printf("Error: _beginthread(ThreadNostrSeed) failed\n"); diff --git a/src/net.h b/src/net.h index f028c3b..7d70721 100644 --- a/src/net.h +++ b/src/net.h @@ -29,6 +29,21 @@ CNode* ConnectNodeBtfResolved(const string& strBtfAddr, const string& strMeeting void ThreadBtfAccept(void* parg); void ThreadBtfConnect(void* parg); extern string strBtfConnect; + +// --- .btf peer exchange --------------------------------------------------- +// Peers gossip signed descriptors so discovery survives a relay outage. Both +// caps apply to a single "btfpeers" message; the generic header limit is 256 MB, +// which is no protection at all for something a stranger can send unprompted. +static const unsigned int MAX_PEX_DESCRIPTORS = 20; +static const unsigned int MAX_PEX_DESCRIPTOR_BYTES = 1024; +// Ignore a peer's exchange more often than this. One announcement per side per +// connection is the intended traffic; anything faster is someone else's idea. +static const int64 PEX_MIN_INTERVAL = 60; + +// Descriptors to hand a peer: ours first, then peers that answered us. +void BtfPexCollect(std::vector& vDescOut); +// Verify descriptors a peer sent and remember the good ones. Returns how many. +int BtfPexAccept(const std::vector& vDesc); void AbandonRequests(void (*fn)(void*, CDataStream&), void* param1); bool AnySubscribed(unsigned int nChannel); void ThreadBitcoinMiner(void* parg); @@ -467,6 +482,9 @@ public: // publish and subscription vector vfSubscribe; + // Last "btfpeers" we accepted from this node, to rate-limit the exchange. + int64 nLastPexRecv; + CNode(SOCKET hSocketIn, CAddress addrIn, bool fInboundIn=false) { @@ -483,6 +501,7 @@ public: fDisconnect = false; nRefCount = 0; nReleaseTime = 0; + nLastPexRecv = 0; vfSubscribe.assign(256, false); // Push a version message diff --git a/src/nostr.cpp b/src/nostr.cpp index ec02c2e..482a835 100644 --- a/src/nostr.cpp +++ b/src/nostr.cpp @@ -342,6 +342,36 @@ std::string BtfLocalAddress() return g_nostrKey.BtfAddress(); } +// This node's own descriptor, signed and self-certifying -- the same blob +// PublishDescriptor puts on Nostr, but handed straight to a peer instead. +// +// Reads strBtfActiveRelay directly rather than going through BtfActiveRelay(): +// only a rendezvous we actually registered at is worth announcing. Handing out +// a meeting node we are not reachable at would make every peer that receives +// it burn a dial, and over peer exchange that mistake travels further than it +// does over Nostr. +std::string BtfLocalDescriptor() +{ + if (!EnsureNostrKey()) + return ""; + string meeting; + CRITICAL_BLOCK(cs_activeRelay) + meeting = strBtfActiveRelay; + if (meeting.empty()) + return ""; + return btf::SignDescriptor(g_nostrKey.ctx, g_nostrKey.seckey, + g_nostrKey.EncPubHex(), meeting, (uint64_t)GetTime()); +} + +// The shared secp256k1 context, so net.cpp can verify descriptors that arrive +// from peers. NULL if the identity could not be loaded. +void* BtfSecpContext() +{ + if (!EnsureNostrKey()) + return NULL; + return (void*)g_nostrKey.ctx; +} + int GetDiscoveredPeerCount() { int n = 0; diff --git a/src/nostr.h b/src/nostr.h index 04149e3..b5eb5e3 100644 --- a/src/nostr.h +++ b/src/nostr.h @@ -82,6 +82,14 @@ bool BtfGetIdentity(unsigned char pubkey[32], unsigned char enc_sk[32]); // This node's printable .btf address, or "" if the identity failed to load. std::string BtfLocalAddress(); +// This node's own signed descriptor, ready to hand to a peer over the wire. +// "" until ThreadBtfAccept has registered at a rendezvous. +std::string BtfLocalDescriptor(); + +// The shared secp256k1 context, for btf::VerifyDescriptor (which takes it as +// void* to keep headers free of secp). NULL if the identity failed to load. +void* BtfSecpContext(); + // Resolve a .btf address via the public discovery relays: fetch the owner's // self-certified descriptor and return its meeting node ("host:port") and // x25519 public key for the end-to-end channel. -- cgit v1.2.3 From 137a166fe0b95ccdef444041e907358bd26bc226 Mon Sep 17 00:00:00 2001 From: Bitflash Date: Wed, 29 Jul 2026 12:05:12 -0300 Subject: Remove peer exchange from main; it was merged unreviewed (#27) PR #25 was opened, reviewed and approved as a single-file change to net.cpp: the .btf peer cache, +169/-0. I then pushed peer exchange and a /port option onto that same branch while the PR was open, which silently widened it to six files. The merge carried all of it in. Nothing about the peer exchange had been validated. Its two-node test never completed -- node B could not dial node A at all -- so what landed on main is unproven network protocol code that no one agreed to take. This restores main to what #25 actually described. The peer cache stays, with its runtime numbers (36.3s to 2.1s to first peer). #26 stays. What comes out: - the "btfpeers" message, both directions - BtfPexCollect / BtfPexAccept and the per-message caps - CNode::nLastPexRecv - the `desc` field on the cached peer record - BtfLocalDescriptor / BtfSecpContext - /port Peer exchange returns as its own pull request once its test passes. The /port option is worth having on its own -- two nodes cannot share a machine without it, which is what blocked the test -- and should go up separately rather than ride along again. Release v1.2.2 is unaffected: it was built from ba2de82, before either merge. --- src/main.cpp | 31 --------------- src/main_gui.cpp | 18 --------- src/net.cpp | 115 +------------------------------------------------------ src/net.h | 19 --------- src/nostr.cpp | 30 --------------- src/nostr.h | 8 ---- 6 files changed, 2 insertions(+), 219 deletions(-) (limited to 'src/main_gui.cpp') diff --git a/src/main.cpp b/src/main.cpp index bb317d2..a75bf54 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -1877,14 +1877,6 @@ bool ProcessMessage(CNode* pfrom, string strCommand, CDataStream& vRecv) pfrom->PushMessage("getblocks", CBlockLocator(pindexBest), uint256(0)); } - // Introduce ourselves and hand over the .btf peers that answered us. - // Everything in here is self-certifying, so this is safe to send to a - // node we know nothing about, and safe for it to act on. - vector vPexOut; - BtfPexCollect(vPexOut); - if (!vPexOut.empty()) - pfrom->PushMessage("btfpeers", vPexOut); - if (LogAcceptsCategory("net")) printf("version addrMe = %s\n", addrMe.ToString().c_str()); } @@ -1896,29 +1888,6 @@ bool ProcessMessage(CNode* pfrom, string strCommand, CDataStream& vRecv) } - else if (strCommand == "btfpeers") - { - // Peer exchange. Nothing here is taken on trust: BtfPexAccept checks - // every descriptor's signature against the key its .btf address - // decodes to, and drops the rest. - int64 nNow = GetTime(); - if (nNow - pfrom->nLastPexRecv < PEX_MIN_INTERVAL) - { - if (LogAcceptsCategory("net")) - printf("btfpeers: ignoring, %s is sending them too fast\n", - pfrom->addr.ToString().c_str()); - return true; - } - pfrom->nLastPexRecv = nNow; - - vector vDesc; - vRecv >> vDesc; - int nKept = BtfPexAccept(vDesc); - LogPrint("net", "btfpeers: kept %d of %zu descriptor(s) from %s\n", - nKept, vDesc.size(), pfrom->addr.ToString().c_str()); - } - - else if (strCommand == "inv") { vector vInv; diff --git a/src/main_gui.cpp b/src/main_gui.cpp index edc772d..842b0d5 100644 --- a/src/main_gui.cpp +++ b/src/main_gui.cpp @@ -58,7 +58,6 @@ static void PrintUsage() printf(" /connectbtf=PEER_BTF_ADDRESS\n"); printf(" /rvrelay=HOST:PORT\n"); printf(" /announcerelay=HOST:PORT\n"); - printf(" /port=N (P2P listen port, default 8433)\n"); printf("\n"); printf("Each option also accepts '-' instead of '/'.\n"); } @@ -114,23 +113,6 @@ static void ParseStartupArguments(int argc, char* argv[]) string announceRelay = argval2(argc, argv, "/announcerelay", "-announcerelay"); if (!announceRelay.empty()) strBtfAnnounceRelay = announceRelay; - - // net.cpp has claimed nListenPort was "tunable via /port" since it was - // written, but nothing ever read the option. Without it a second node - // cannot start on a machine that already runs one -- which is exactly what - // testing peer exchange needs. - string strPort = argval2(argc, argv, "/port", "-port"); - if (!strPort.empty()) - { - int nPort = atoi(strPort.c_str()); - if (nPort <= 0 || nPort > 65535) - fprintf(stderr, "Ignoring /port=%s: not a port number\n", strPort.c_str()); - else - { - nListenPort = htons((unsigned short)nPort); - addrLocalHost.port = nListenPort; // or we advertise a port we never bound - } - } } int main(int argc, char* argv[]) diff --git a/src/net.cpp b/src/net.cpp index dfeb4a2..7b2ef83 100644 --- a/src/net.cpp +++ b/src/net.cpp @@ -285,13 +285,6 @@ struct CachedBtfPeer string meeting; string encHex; int64 lastSeen; - // The peer's own signed descriptor, as it announced itself. Kept verbatim - // because that signature is what lets us hand this peer on to somebody - // else: the receiver checks it against the key the address decodes to and - // never has to take our word for anything. Empty for entries learned - // before peer exchange existed, or resolved through Nostr -- those are - // still dialable, just not relayable. - string desc; }; static string BtfPeerCachePath() { return GetAppDir() + "/btfpeers.json"; } @@ -343,8 +336,6 @@ void LoadCachedBtfPeers(vector& out) p.meeting = o["meeting"].get(); p.encHex = o["enc"].get(); p.lastSeen = o.value("seen", (int64)0); - if (o.contains("desc") && o["desc"].is_string()) - p.desc = o["desc"].get(); if (nNow - p.lastSeen > CACHED_BTF_PEER_TTL) continue; // long gone out.push_back(p); } @@ -358,35 +349,22 @@ void LoadCachedBtfPeers(vector& out) static CCriticalSection cs_btfPeerCache; -// strDesc is the peer's own signed descriptor when we have it (it announced -// itself over peer exchange), "" when we only resolved it through Nostr. An -// empty one never clears a descriptor already on file: dialing a peer we first -// learned about by exchange must not cost us the ability to pass it on. static void RememberBtfPeer(const string& strBtfAddr, const string& strMeeting, - const unsigned char enc_pub[32], - const string& strDesc = string()) + const unsigned char enc_pub[32]) { CRITICAL_BLOCK(cs_btfPeerCache) { vector peers; LoadCachedBtfPeers(peers); - string strKeepDesc = strDesc; for (size_t i = 0; i < peers.size(); i++) - if (peers[i].btfAddr == strBtfAddr) - { - if (strKeepDesc.empty()) - strKeepDesc = peers[i].desc; - peers.erase(peers.begin() + i); - break; - } + if (peers[i].btfAddr == strBtfAddr) { peers.erase(peers.begin() + i); break; } CachedBtfPeer p; p.btfAddr = strBtfAddr; p.meeting = strMeeting; p.encHex = BytesToHex(enc_pub, 32); p.lastSeen = GetTime(); - p.desc = strKeepDesc; peers.insert(peers.begin(), p); // most recent first if (peers.size() > MAX_CACHED_BTF_PEERS) @@ -400,8 +378,6 @@ static void RememberBtfPeer(const string& strBtfAddr, const string& strMeeting, o["meeting"] = q.meeting; o["enc"] = q.encHex; o["seen"] = q.lastSeen; - if (!q.desc.empty()) - o["desc"] = q.desc; arr.push_back(o); } FILE* f = fopen(BtfPeerCachePath().c_str(), "w"); @@ -443,93 +419,6 @@ void ThreadReconnectCachedBtfPeers(void* parg) nConnected, peers.size()); } -// -// .btf peer exchange -// -// Until now discovery depended entirely on the Nostr relays: two connected -// nodes never told each other who else existed, so a relay outage left a -// running network unable to grow, and a node that found one stale peer had no -// way to learn there was anything better. -// -// What travels here is each peer's own signed descriptor. The receiver checks -// the Schnorr signature against the key the `.btf` address decodes to, so the -// sender is trusted for nothing -- forging an entry would need somebody else's -// secret key. That leaves flooding: valid descriptors for keys the sender -// generated itself. The caps below bound one message, and the cache prefers -// peers that actually answered, so a flood costs a Sybil more than it costs us. -// - -void BtfPexCollect(vector& vDescOut) -{ - vDescOut.clear(); - - // Ours first -- the node on the other end may know nobody but us. - string strMine = BtfLocalDescriptor(); - if (!strMine.empty()) - vDescOut.push_back(strMine); - - vector peers; - CRITICAL_BLOCK(cs_btfPeerCache) - LoadCachedBtfPeers(peers); - - // Most recently seen first: these answered us, they are not names copied - // off a relay listing. - foreach(const CachedBtfPeer& p, peers) - { - if (vDescOut.size() >= MAX_PEX_DESCRIPTORS) break; - if (p.desc.empty()) continue; // nothing provable to pass on - if (p.desc.size() > MAX_PEX_DESCRIPTOR_BYTES) continue; - vDescOut.push_back(p.desc); - } -} - -int BtfPexAccept(const vector& vDesc) -{ - void* ctx = BtfSecpContext(); - if (!ctx) - return 0; - - string strSelf = BtfLocalAddress(); - int nKept = 0; - unsigned int nSeen = 0; - - foreach(const string& strDesc, vDesc) - { - if (++nSeen > MAX_PEX_DESCRIPTORS) break; - if (strDesc.empty() || strDesc.size() > MAX_PEX_DESCRIPTOR_BYTES) continue; - - // The descriptor names the key it belongs to, and taking that key from - // the blob is safe precisely because the signature must verify under - // it: nobody can produce a valid descriptor for a key they don't hold. - unsigned char pubkey[32]; - try - { - nlohmann::json v = nlohmann::json::parse(strDesc); - if (!v.is_object() || !v.contains("pubkey") || !v["pubkey"].is_string()) - continue; - if (!HexToBytes(v["pubkey"].get(), pubkey, 32)) - continue; - } - catch (...) { continue; } // malformed JSON from an untrusted peer - - btf::Descriptor d; - if (!btf::VerifyDescriptor(ctx, strDesc, pubkey, d)) - continue; - - string strAddr = btf::Address(pubkey); - if (strAddr.empty() || strAddr == strSelf) - continue; // ourselves, nothing to learn - - unsigned char enc[32]; - if (!HexToBytes(d.enc, enc, 32)) - continue; - - RememberBtfPeer(strAddr, d.meeting_node, enc, strDesc); - nKept++; - } - return nKept; -} - static CNode* ConnectNodeBtfTail(const string& strBtfAddr, const unsigned char pk[32], const string& strMeeting, const unsigned char enc_pub[32]) { diff --git a/src/net.h b/src/net.h index 7d70721..f028c3b 100644 --- a/src/net.h +++ b/src/net.h @@ -29,21 +29,6 @@ CNode* ConnectNodeBtfResolved(const string& strBtfAddr, const string& strMeeting void ThreadBtfAccept(void* parg); void ThreadBtfConnect(void* parg); extern string strBtfConnect; - -// --- .btf peer exchange --------------------------------------------------- -// Peers gossip signed descriptors so discovery survives a relay outage. Both -// caps apply to a single "btfpeers" message; the generic header limit is 256 MB, -// which is no protection at all for something a stranger can send unprompted. -static const unsigned int MAX_PEX_DESCRIPTORS = 20; -static const unsigned int MAX_PEX_DESCRIPTOR_BYTES = 1024; -// Ignore a peer's exchange more often than this. One announcement per side per -// connection is the intended traffic; anything faster is someone else's idea. -static const int64 PEX_MIN_INTERVAL = 60; - -// Descriptors to hand a peer: ours first, then peers that answered us. -void BtfPexCollect(std::vector& vDescOut); -// Verify descriptors a peer sent and remember the good ones. Returns how many. -int BtfPexAccept(const std::vector& vDesc); void AbandonRequests(void (*fn)(void*, CDataStream&), void* param1); bool AnySubscribed(unsigned int nChannel); void ThreadBitcoinMiner(void* parg); @@ -482,9 +467,6 @@ public: // publish and subscription vector vfSubscribe; - // Last "btfpeers" we accepted from this node, to rate-limit the exchange. - int64 nLastPexRecv; - CNode(SOCKET hSocketIn, CAddress addrIn, bool fInboundIn=false) { @@ -501,7 +483,6 @@ public: fDisconnect = false; nRefCount = 0; nReleaseTime = 0; - nLastPexRecv = 0; vfSubscribe.assign(256, false); // Push a version message diff --git a/src/nostr.cpp b/src/nostr.cpp index 482a835..ec02c2e 100644 --- a/src/nostr.cpp +++ b/src/nostr.cpp @@ -342,36 +342,6 @@ std::string BtfLocalAddress() return g_nostrKey.BtfAddress(); } -// This node's own descriptor, signed and self-certifying -- the same blob -// PublishDescriptor puts on Nostr, but handed straight to a peer instead. -// -// Reads strBtfActiveRelay directly rather than going through BtfActiveRelay(): -// only a rendezvous we actually registered at is worth announcing. Handing out -// a meeting node we are not reachable at would make every peer that receives -// it burn a dial, and over peer exchange that mistake travels further than it -// does over Nostr. -std::string BtfLocalDescriptor() -{ - if (!EnsureNostrKey()) - return ""; - string meeting; - CRITICAL_BLOCK(cs_activeRelay) - meeting = strBtfActiveRelay; - if (meeting.empty()) - return ""; - return btf::SignDescriptor(g_nostrKey.ctx, g_nostrKey.seckey, - g_nostrKey.EncPubHex(), meeting, (uint64_t)GetTime()); -} - -// The shared secp256k1 context, so net.cpp can verify descriptors that arrive -// from peers. NULL if the identity could not be loaded. -void* BtfSecpContext() -{ - if (!EnsureNostrKey()) - return NULL; - return (void*)g_nostrKey.ctx; -} - int GetDiscoveredPeerCount() { int n = 0; diff --git a/src/nostr.h b/src/nostr.h index b5eb5e3..04149e3 100644 --- a/src/nostr.h +++ b/src/nostr.h @@ -82,14 +82,6 @@ bool BtfGetIdentity(unsigned char pubkey[32], unsigned char enc_sk[32]); // This node's printable .btf address, or "" if the identity failed to load. std::string BtfLocalAddress(); -// This node's own signed descriptor, ready to hand to a peer over the wire. -// "" until ThreadBtfAccept has registered at a rendezvous. -std::string BtfLocalDescriptor(); - -// The shared secp256k1 context, for btf::VerifyDescriptor (which takes it as -// void* to keep headers free of secp). NULL if the identity failed to load. -void* BtfSecpContext(); - // Resolve a .btf address via the public discovery relays: fetch the owner's // self-certified descriptor and return its meeting node ("host:port") and // x25519 public key for the end-to-end channel. -- cgit v1.2.3 From 1227068a504418521f3238acf9c4dca297141d27 Mon Sep 17 00:00:00 2001 From: Bitflash Date: Wed, 29 Jul 2026 12:45:48 -0300 Subject: main_gui: implement /port, which net.cpp already documented (#28) net.cpp has described nListenPort as "tunable via /port" since it was written: unsigned short nListenPort = DEFAULT_PORT; // local P2P port (tunable via /port) Nothing ever parsed the option. The port was fixed at 8433, so a second node could not start on a machine already running one -- StartNode fails the bind and the node comes up with no networking at all. That makes local multi-node testing impossible, which is how this was found: a two-node test could not be run. Sets addrLocalHost.port alongside nListenPort, or the node announces a port it never bound. An out-of-range value is rejected with a message and the default kept, rather than silently wrapping through the unsigned short cast. --- src/main_gui.cpp | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) (limited to 'src/main_gui.cpp') diff --git a/src/main_gui.cpp b/src/main_gui.cpp index 842b0d5..63bdbb7 100644 --- a/src/main_gui.cpp +++ b/src/main_gui.cpp @@ -59,6 +59,9 @@ static void PrintUsage() printf(" /rvrelay=HOST:PORT\n"); printf(" /announcerelay=HOST:PORT\n"); printf("\n"); + printf("Network:\n"); + printf(" /port=N (P2P listen port, default 8433)\n"); + printf("\n"); printf("Each option also accepts '-' instead of '/'.\n"); } @@ -113,6 +116,23 @@ static void ParseStartupArguments(int argc, char* argv[]) string announceRelay = argval2(argc, argv, "/announcerelay", "-announcerelay"); if (!announceRelay.empty()) strBtfAnnounceRelay = announceRelay; + + // net.cpp has described nListenPort as "tunable via /port" since it was + // written, but nothing ever read the option, so the port was fixed at 8433 + // and a second node could not start on a machine already running one. + string strPort = argval2(argc, argv, "/port", "-port"); + if (!strPort.empty()) + { + int nPort = atoi(strPort.c_str()); + if (nPort <= 0 || nPort > 65535) + fprintf(stderr, "Ignoring /port=%s: not a port number\n", strPort.c_str()); + else + { + nListenPort = htons((unsigned short)nPort); + // Or we would announce a port we never bound. + addrLocalHost.port = nListenPort; + } + } } int main(int argc, char* argv[]) -- cgit v1.2.3 From ab24958f1470cb62a4637126345fdf14c2ec0bd4 Mon Sep 17 00:00:00 2001 From: Bitflash Date: Wed, 29 Jul 2026 16:01:43 -0300 Subject: net: compiled-in bootstrap seeds, so a first run does not need a relay (#36) * net: compiled-in bootstrap seeds, so a first run does not need a relay A node with no cache has only the Nostr relays. If they are down, blocked or slow, a first run has no way into the network at all -- and that is the one moment a user has no patience for. Seeds are the floor under that. A seed entry is an address and an encryption key. Deliberately no meeting node: the rendezvous relay pairs on the service's public key, and a `.btf` address is that key, so a client can find a seed by trying the known relays in turn. Recording which relay a seed was on would rot the moment it failed over, which is exactly the failure this project spent weeks chasing -- advertising a rendezvous somebody is not registered at. Seeds carry no authority. They serve the same signed, self-certifying descriptors as any peer, so a hostile seed can stall a bootstrap but cannot forge a peer or feed a false chain. The compiled list is empty. Which nodes become permanent public bootstrap targets is a decision for whoever runs the network, not a default, and it is close to irreversible once binaries are distributed. Empty is valid: discovery falls back to Nostr exactly as before. /btfseed=ADDRESS:ENCHEX (repeatable) appends at runtime, for testing and for private networks. Tested against the live network, clean datadir, no cache, three seeds carrying no meeting node: btfpeers: no cache yet, falling back to seeds btfseed: trying 3 seed(s) across 3 relay(s) btfseed: reached q5jxyzn...btf via 90.156.222.107:8434 btfseed: 3 seed(s) answered * net: add the Almaty bootstrap node to the seed list Dedicated node, no wallet balance, no mining, running beside the rendezvous relay on the same host. It exists only to answer a first dial. Fresh datadir, no flags, compiled list only: btfpeers: no cache yet, falling back to seeds btfseed: trying 1 seed(s) across 3 relay(s) btfseed: reached ygffz37...btf via 90.156.222.107:8434 btfseed: 1 seed(s) answered btfpeers: kept 2 of 2 descriptor(s) 5.2 seconds from nothing to a peer, against 36s for a cold start that had to go through the relays. The seed then handed over two more peers by exchange, which the cache keeps for next time. --- src/main_gui.cpp | 20 ++++++++++++ src/net.cpp | 93 +++++++++++++++++++++++++++++++++++++++++++++++++++++++- src/net.h | 3 ++ 3 files changed, 115 insertions(+), 1 deletion(-) (limited to 'src/main_gui.cpp') diff --git a/src/main_gui.cpp b/src/main_gui.cpp index 63bdbb7..9f7e34e 100644 --- a/src/main_gui.cpp +++ b/src/main_gui.cpp @@ -61,6 +61,7 @@ static void PrintUsage() printf("\n"); printf("Network:\n"); printf(" /port=N (P2P listen port, default 8433)\n"); + printf(" /btfseed=ADDRESS:ENCHEX (extra bootstrap peer, repeatable)\n"); printf("\n"); printf("Each option also accepts '-' instead of '/'.\n"); } @@ -117,6 +118,25 @@ static void ParseStartupArguments(int argc, char* argv[]) if (!announceRelay.empty()) strBtfAnnounceRelay = announceRelay; + // /btfseed=ADDRESS:ENCHEX -- extra bootstrap peers, repeatable. Useful for + // testing the seed path and for private networks that ship no compiled list. + for (int i = 1; i < argc; i++) + { + string s = argv[i]; + size_t eq = s.find('='); + if (eq == string::npos) continue; + string key = s.substr(0, eq); + if (key != "/btfseed" && key != "-btfseed") continue; + string val = s.substr(eq + 1); + size_t colon = val.rfind(':'); + if (colon == string::npos || colon + 1 >= val.size()) + { + fprintf(stderr, "Ignoring %s: expected ADDRESS:ENCHEX\n", s.c_str()); + continue; + } + vBtfExtraSeeds.push_back(make_pair(val.substr(0, colon), val.substr(colon + 1))); + } + // net.cpp has described nListenPort as "tunable via /port" since it was // written, but nothing ever read the option, so the port was fixed at 8433 // and a second node could not start on a machine already running one. diff --git a/src/net.cpp b/src/net.cpp index 1d552cf..08a3348 100644 --- a/src/net.cpp +++ b/src/net.cpp @@ -414,6 +414,93 @@ static void RememberBtfPeer(const string& strBtfAddr, const string& strMeeting, } } +// +// Compiled-in bootstrap seeds. +// +// A node with no cache has nothing but the Nostr relays, and if those are down, +// blocked or simply slow, a first run has no way into the network at all. Seeds +// are the floor under that: a handful of long-lived peers baked into the binary. +// +// A seed entry is deliberately just an address and an encryption key -- no +// meeting node. The rendezvous relay pairs on the service's public key, and a +// `.btf` address *is* that key, so a client can find a seed by trying the known +// relays in turn. Recording which relay a seed was on would rot the moment it +// failed over, which is the exact failure this project spent a long time +// chasing: advertising a rendezvous somebody is not registered at. +// +// Seeds are ordinary nodes with no special authority. They hand out signed, +// self-certifying descriptors like any peer, so a hostile seed can stall a +// bootstrap but cannot forge a peer or feed a false chain. +// +struct BtfSeed +{ + const char* btfAddr; + const char* encHex; // 64 hex chars, the peer's x25519 public key +}; + +static const BtfSeed pszBtfSeeds[] = +{ + // Dedicated bootstrap node, Almaty. Runs beside a rendezvous relay on the + // same host, holds no wallet balance and does not mine -- it exists only to + // answer a first dial. It is trusted for nothing: it serves the same signed + // descriptors any peer does. + { "ygffz37jczlmrkzicxok6chobauyratdexc7hfgwzjdabzvb2nkngqy.btf", + "de8284b9d4effa2132e7981b566c1a297c39c1857d9c128e8ae093ef511d3200" }, +}; +static const size_t nBtfSeeds = ARRAYLEN(pszBtfSeeds); + +// Extra seeds from the command line (/btfseed=ADDRESS:ENCHEX, repeatable). +vector > vBtfExtraSeeds; + +static int TryBtfSeeds() +{ + vector > seeds; + for (size_t i = 0; i < nBtfSeeds; i++) + seeds.push_back(make_pair(string(pszBtfSeeds[i].btfAddr), + string(pszBtfSeeds[i].encHex))); + foreach(const PAIRTYPE(string, string)& s, vBtfExtraSeeds) + seeds.push_back(s); + + if (seeds.empty()) + return 0; + + vector relays = BtfAllRelays(); + if (relays.empty()) + return 0; + + LogPrint("net", "btfseed: trying %zu seed(s) across %zu relay(s)\n", + seeds.size(), relays.size()); + + int nConnected = 0; + foreach(const PAIRTYPE(string, string)& s, seeds) + { + if (fShutdown) return nConnected; + unsigned char enc[32]; + if (!HexToBytes(s.second, enc, 32)) + { + LogPrint("net", "btfseed: %s has a malformed encryption key, skipped\n", + s.first.c_str()); + continue; + } + // We do not know which relay this seed is registered at, so walk them. + foreach(const string& strRelay, relays) + { + if (fShutdown) return nConnected; + if (ConnectNodeBtfResolved(s.first, strRelay, enc)) + { + LogPrint("net", "btfseed: reached %s via %s\n", + s.first.c_str(), strRelay.c_str()); + nConnected++; + break; // found it; no need to try this seed's other relays + } + } + if (nConnected >= 4) + break; // enough of a foothold; the rest comes from discovery + } + LogPrint("net", "btfseed: %d seed(s) answered\n", nConnected); + return nConnected; +} + // Dial everything we reached last time, before the relays have said anything. void ThreadReconnectCachedBtfPeers(void* parg) { @@ -421,7 +508,11 @@ void ThreadReconnectCachedBtfPeers(void* parg) LoadCachedBtfPeers(peers); if (peers.empty()) { - LogPrint("net", "btfpeers: no cache yet, waiting on relay discovery\n"); + // First run, or the cache aged out. Seeds are the only way in that does + // not depend on a relay answering. + LogPrint("net", "btfpeers: no cache yet, falling back to seeds\n"); + if (TryBtfSeeds() == 0) + LogPrint("net", "btfpeers: no seed answered, waiting on relay discovery\n"); return; } LogPrint("net", "btfpeers: trying %zu remembered peer(s) before discovery\n", diff --git a/src/net.h b/src/net.h index 7d70721..d13de61 100644 --- a/src/net.h +++ b/src/net.h @@ -29,6 +29,9 @@ CNode* ConnectNodeBtfResolved(const string& strBtfAddr, const string& strMeeting void ThreadBtfAccept(void* parg); void ThreadBtfConnect(void* parg); extern string strBtfConnect; +// Bootstrap seeds added at runtime with /btfseed=ADDRESS:ENCHEX (repeatable). +// The compiled-in list lives in net.cpp; these are appended to it. +extern std::vector > vBtfExtraSeeds; // --- .btf peer exchange --------------------------------------------------- // Peers gossip signed descriptors so discovery survives a relay outage. Both -- cgit v1.2.3 From c404e1c7f5d3f04d07e7e5b35a7a8ffcc998d855 Mon Sep 17 00:00:00 2001 From: Bitflash Date: Wed, 29 Jul 2026 16:33:02 -0300 Subject: build: headless Linux node that does not need libGL (#37) The GUI build requires libGL at load time even when started with -nogui. The dynamic loader resolves every dependency before main() runs, so the flag arrives far too late -- on a clean server the binary dies with error while loading shared libraries: libGL.so.1: cannot open shared object file and nothing suggests it would otherwise have worked. A server is where a node most naturally lives, so this is the deployment that mattered most and the one that failed. The AppImage additionally needs FUSE, which minimal servers lack. Bundling libGL into the AppImage would be the wrong fix: GL has to come from the host's driver stack, and shipping our own breaks machines that have a real GPU. So: a second binary, built from the same node objects without ImGui, GLFW or OpenGL. `make linux-node` produces bitflash-node--x86_64, 2.5 MB stripped, with no graphics or FUSE dependency at all. `make linux` now builds it alongside the AppImage. Anyone wanting a window keeps using the AppImage. Two small moves were needed. MainFrameRepaint is called from main.cpp and net.cpp but defined in gui.cpp, so headless.cpp provides an empty one rather than putting ifdefs at six call sites. DateTimeStr also lived in gui.cpp while main.cpp logs block times with it -- nothing about formatting a date is GUI work, so it moved to util.cpp and both builds share one definition. Verified on Debian 13: ldd bitflash-node | grep -iE 'libGL|glfw|X11' -> nothing ldd bitflash | grep -iE 'libGL|glfw' -> libglfw.so.3, libGL.so.1, libGLdispatch.so.0 and at runtime, from an empty datadir: btfseed: reached ygffz37...btf via 90.156.222.107:8434 rendezvous: registered at 90.156.222.107:8434, waiting for a dial self-resolve OK (meeting_node=90.156.222.107:8434) btfpeers: kept 15 of 15 descriptor(s) first block at ~30s, 3182 blocks accepted, synced to the tip The GUI builds on Linux and Windows were rebuilt and are unaffected. --- Makefile | 12 ++++++++++++ src/Makefile | 32 +++++++++++++++++++++++++++++++- src/gui.cpp | 10 ++-------- src/headless.cpp | 20 ++++++++++++++++++++ src/main_gui.cpp | 13 +++++++++++++ src/util.cpp | 12 ++++++++++++ 6 files changed, 90 insertions(+), 9 deletions(-) create mode 100644 src/headless.cpp (limited to 'src/main_gui.cpp') diff --git a/Makefile b/Makefile index 9cfef2f..b5bfa9a 100644 --- a/Makefile +++ b/Makefile @@ -15,6 +15,18 @@ linux: deps-linux $(MAKE) -C src -f Makefile -j$(NPROC) strip src/bitflash $(MAKE) -f Makefile appimage + $(MAKE) -f Makefile linux-node + +# Headless node, no ImGui/GLFW/OpenGL. The GUI build needs libGL present at load +# time even when started with -nogui -- the dynamic loader resolves everything +# before main() runs, so the flag comes far too late. On a clean server, which is +# where a node most naturally lives, that is a hard failure. This binary has no +# such dependency and needs no FUSE either, so it drops onto a VPS and runs. +linux-node: deps-linux + $(MAKE) -C src -f Makefile bitflash-node -j$(NPROC) + strip src/bitflash-node + cp src/bitflash-node bitflash-node-$(VERSION)-x86_64 + @echo "Built: bitflash-node-$(VERSION)-x86_64" deps-linux: deps-apt deps-secp256k1 deps-randomx diff --git a/src/Makefile b/src/Makefile index c0b6e1d..9846d8e 100644 --- a/src/Makefile +++ b/src/Makefile @@ -71,5 +71,35 @@ bitflash: $(IMGUI_OBJS) $(NODE_OBJS) $(GUI_OBJS) $(CXX) $(CXXFLAGS) -o $@ $^ $(LIBS) @echo "Built: bitflash" +# ---- headless node --------------------------------------------------------- +# +# Same node, built without ImGui, GLFW or OpenGL. Linking the GUI made the +# binary require libGL at load time even when started with -nogui, because the +# dynamic loader resolves everything before main() runs -- so on a clean server, +# which is where a node most naturally lives, it failed to start with an error +# about shared libraries and no hint that it would otherwise work. +# +# Objects go to obj-node/ because main_gui.cpp is compiled twice, with and +# without -DBITFLASH_NO_GUI, and the two must not overwrite each other. + +NODE_LIBS = -L$(SECP_LIB) -lsecp256k1 \ + -L$(RANDOMX_DIR)/build -lrandomx \ + -lsodium -lssl -lcrypto -ldb_cxx -lpthread + +NODE_ONLY_OBJS = $(patsubst obj/%,obj-node/%,$(NODE_OBJS)) \ + obj-node/main_gui.o obj-node/headless.o + +obj-node/%.o: %.cpp $(HEADERS) + @mkdir -p obj-node + $(CXX) -c $(CXXFLAGS) -DBITFLASH_NO_GUI -o $@ $< + +obj-node/sha.o: sha.cpp sha.h + @mkdir -p obj-node + $(CXX) -c $(CXXFLAGS) -DBITFLASH_NO_GUI -O3 -o $@ $< + +bitflash-node: $(NODE_ONLY_OBJS) + $(CXX) $(CXXFLAGS) -o $@ $^ $(NODE_LIBS) + @echo "Built: bitflash-node (headless, no libGL needed)" + clean: - rm -rf obj bitflash + rm -rf obj obj-node bitflash bitflash-node diff --git a/src/gui.cpp b/src/gui.cpp index a1a0698..fc6f1e1 100644 --- a/src/gui.cpp +++ b/src/gui.cpp @@ -171,14 +171,8 @@ static void RefreshWallet() void MainFrameRepaint() { g_needRefresh = true; } -string DateTimeStr(int64 nTime) -{ - time_t t = (time_t)nTime; - struct tm* p = localtime(&t); - if (!p) return ""; - char buf[32]; strftime(buf, sizeof(buf), "%m/%d/%y %H:%M", p); - return buf; -} +// DateTimeStr moved to util.cpp: main.cpp logs block times with it, so it is +// needed by builds that have no GUI at all. // --------------------------------------------------------------------------- // Status bar -- separate fullscreen-width overlay pinned to bottom edge. diff --git a/src/headless.cpp b/src/headless.cpp new file mode 100644 index 0000000..3372936 --- /dev/null +++ b/src/headless.cpp @@ -0,0 +1,20 @@ +// Copyright (c) 2026 Bitflash developers +// Distributed under the MIT/X11 software license. +// +// Stand-ins for the handful of symbols the node core expects the GUI to +// provide, so a headless binary can be linked without ImGui, GLFW or OpenGL. +// +// This exists because linking the GUI made the node need libGL at load time +// even when started with -nogui: the dynamic loader resolves everything before +// main() runs, so the flag came far too late. On a clean server -- which is +// where a node most naturally lives -- that is a hard failure with an error +// message about shared libraries and no hint that the program would otherwise +// have worked fine. + +#include "headers_core.h" + +// The GUI uses this to mark itself dirty. With no window to repaint there is +// nothing to do, and the call sites in main.cpp and net.cpp stay unchanged. +void MainFrameRepaint() +{ +} diff --git a/src/main_gui.cpp b/src/main_gui.cpp index 9f7e34e..99e63d4 100644 --- a/src/main_gui.cpp +++ b/src/main_gui.cpp @@ -5,7 +5,11 @@ #include #endif +// BITFLASH_NO_GUI builds the same entry point without ImGui, GLFW or OpenGL, +// so the binary does not need libGL present just to start. See headless.cpp. +#ifndef BITFLASH_NO_GUI int RunGUI(int argc, char* argv[]); +#endif // Global definitions (were in ui.cpp, now here) map mapAddressBook; @@ -186,8 +190,13 @@ int main(int argc, char* argv[]) if (_beginthread(ThreadBitcoinMiner, 0, NULL) == (uintptr_t)-1) printf("Error: _beginthread(ThreadBitcoinMiner) failed\n"); +#ifdef BITFLASH_NO_GUI + // Nothing else this binary can do; /nogui is accepted and redundant. + bool fHeadless = true; +#else bool fHeadless = arg(argc,argv,"/nogui") || arg(argc,argv,"-nogui") || arg(argc,argv,"/daemon") || arg(argc,argv,"-daemon"); +#endif if (fHeadless) { #ifndef _WIN32 @@ -200,8 +209,12 @@ int main(int argc, char* argv[]) return 0; } +#ifdef BITFLASH_NO_GUI + return 0; // unreachable: fHeadless is always true in this build +#else int ret = RunGUI(argc, argv); fShutdown = true; StopNode(); return ret; +#endif } diff --git a/src/util.cpp b/src/util.cpp index 68bd18e..e4e314f 100644 --- a/src/util.cpp +++ b/src/util.cpp @@ -490,3 +490,15 @@ void AddTimeData(unsigned int ip, int64 nTime) printf("| nTimeOffset = %+lld (%+lld minutes)\n", nTimeOffset, nTimeOffset/60); } } + +// Formats a block or transaction timestamp for logs and for the wallet view. +// It lived in gui.cpp, but main.cpp logs block times with it, so a headless +// build needed it too -- and nothing about formatting a date is GUI work. +string DateTimeStr(int64 nTime) +{ + time_t t = (time_t)nTime; + struct tm* p = localtime(&t); + if (!p) return ""; + char buf[32]; strftime(buf, sizeof(buf), "%m/%d/%y %H:%M", p); + return buf; +} -- cgit v1.2.3 From 72683da2b01286030fd61228168712c84aea79d4 Mon Sep 17 00:00:00 2001 From: Bitflash Date: Wed, 29 Jul 2026 17:41:10 -0300 Subject: db: catch BDB exceptions so unreadable files fail cleanly (#34) (#38) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Berkeley DB throws when it cannot open a file written by another platform's build (e.g. BDB 5.3 wallet read by 6.2). Nothing on the call path caught these, so the exception unwound into std::terminate -- surfacing on Windows as STATUS_STACK_BUFFER_OVERRUN (0xC0000409) with no useful message. Three layers of defense: 1. main_gui.cpp: try/catch around LoadBlockIndex() and LoadWallet(), printing the reason and exiting cleanly. 2. db.cpp LoadWallet: try/catch around the record-reading loop (Satoshi's 2009 TODO), tracking the last record type so the error message says what it choked on. 3. db.cpp cleanup paths: every dbenv.close(), pdb->close(), txn_checkpoint() and log_archive() wrapped in try/catch so the environment teardown after a failed open does not throw a second exception into std::terminate. Tested: cross-platform wallet (Linux BDB 5.3 → Windows 6.2) now prints a clear message and exits with code 1, wallet left untouched. Closes #34 Co-authored-by: Claude Opus 4.6 --- src/db.cpp | 47 +++++++++++++++++++++++++++++++++++++++++------ src/main_gui.cpp | 41 ++++++++++++++++++++++++++++++++++++++--- 2 files changed, 79 insertions(+), 9 deletions(-) (limited to 'src/main_gui.cpp') diff --git a/src/db.cpp b/src/db.cpp index 199fa44..d43e4ef 100644 --- a/src/db.cpp +++ b/src/db.cpp @@ -28,7 +28,8 @@ public: { if (fDbEnvInit) { - dbenv.close(0); + try { dbenv.close(0); } + catch (...) { } fDbEnvInit = false; } } @@ -118,10 +119,10 @@ void CDB::Close() if (!vTxn.empty()) vTxn.front()->abort(); vTxn.clear(); - pdb->close(0); + try { pdb->close(0); } catch (...) { } delete pdb; pdb = NULL; - dbenv.txn_checkpoint(0, 0, 0); + try { dbenv.txn_checkpoint(0, 0, 0); } catch (...) { } CRITICAL_BLOCK(cs_db) --mapFileUseCount[strFile]; @@ -154,8 +155,8 @@ void DBFlush(bool fShutdown) { char** listp; if (mapFileUseCount.empty()) - dbenv.log_archive(&listp, DB_ARCH_REMOVE); - dbenv.close(0); + try { dbenv.log_archive(&listp, DB_ARCH_REMOVE); } catch (...) { } + try { dbenv.close(0); } catch (...) { } fDbEnvInit = false; } } @@ -427,7 +428,25 @@ bool CWalletDB::LoadWallet(vector& vchDefaultKeyRet) { vchDefaultKeyRet.clear(); - //// todo: shouldn't we catch exceptions and try to recover and continue? + // Satoshi's "todo: shouldn't we catch exceptions" sat here since 2009, and + // it was not a nicety. Nothing on this path caught anything, so a record + // this build could not read -- a wallet from another platform's Berkeley + // DB, a truncated file, a field it does not understand -- threw out of + // LoadWallet, out of main(), into std::terminate and abort(). On Windows + // that surfaces as STATUS_STACK_BUFFER_OVERRUN (0xC0000409) in + // ucrtbase.dll: no message, no log line past "Loading wallet...", and a + // faulting module that has nothing to do with the actual problem. + // + // The program knew exactly what had gone wrong and threw the reason away. + // Now it says so and stops cleanly, which is the difference between "your + // wallet is unreadable and here is why" and a crash nobody can act on. + // + // Deliberately a hard failure rather than skipping the bad record: a + // partially loaded wallet is worse than one that refuses to open, because + // the balance looks plausible while keys or transactions are missing. + string strLastType; + try + { CRITICAL_BLOCK(cs_mapKeys) CRITICAL_BLOCK(cs_mapWallet) { @@ -452,6 +471,7 @@ bool CWalletDB::LoadWallet(vector& vchDefaultKeyRet) // is just the two items serialized one after the other string strType; ssKey >> strType; + strLastType = strType; if (strType == "name") { string strAddress; @@ -509,6 +529,21 @@ bool CWalletDB::LoadWallet(vector& vchDefaultKeyRet) } } } + } + catch (const std::exception& e) + { + printf("LoadWallet: wallet.dat could not be read. Failed while handling " + "a '%s' record: %s\n", strLastType.c_str(), e.what()); + printf("LoadWallet: the file was left untouched. A wallet.dat written by " + "a different platform's Berkeley DB is the usual cause.\n"); + return false; + } + catch (...) + { + printf("LoadWallet: wallet.dat could not be read. Unknown failure while " + "handling a '%s' record.\n", strLastType.c_str()); + return false; + } printf("nTransactionFee = %lld\n", nTransactionFee); printf("addrIncoming = %s\n", addrIncoming.ToString().c_str()); diff --git a/src/main_gui.cpp b/src/main_gui.cpp index 99e63d4..a94d01d 100644 --- a/src/main_gui.cpp +++ b/src/main_gui.cpp @@ -170,11 +170,46 @@ int main(int argc, char* argv[]) ParseStartupArguments(argc, argv); - printf("Loading block index...\n"); + // Berkeley DB reports failure by throwing, and CDB's constructor lets it + // through. Nothing on this path caught anything, so an unreadable + // blkindex.dat or wallet.dat -- one written by another platform's Berkeley + // DB, a truncated file, a version mismatch -- unwound out of main() into + // std::terminate and abort(). On Windows that surfaces as + // STATUS_STACK_BUFFER_OVERRUN (0xC0000409) inside ucrtbase.dll: no message, + // no log line past "Loading wallet...", and a faulting module with nothing + // to do with the real problem. Confirmed by stack trace: + // + // libdb_cxx-6.2.dll -> CDB::CDB(...) -> LoadBlockIndex(...) -> main() + // + // The program knew what had gone wrong and threw the reason away. string strErrors; - if (!LoadBlockIndex()) { fprintf(stderr,"LoadBlockIndex failed\n"); return 1; } + printf("Loading block index...\n"); + try + { + if (!LoadBlockIndex()) { fprintf(stderr, "LoadBlockIndex failed\n"); return 1; } + } + catch (const std::exception& e) + { + fprintf(stderr, "Cannot read the block index: %s\n", e.what()); + fprintf(stderr, "blkindex.dat and blk0001.dat may be from another machine or " + "incomplete. Deleting both is safe -- they are re-downloaded -- " + "but never delete wallet.dat, which holds your keys.\n"); + return 1; + } + printf("Loading wallet...\n"); - if (!LoadWallet()) { fprintf(stderr,"LoadWallet failed\n"); return 1; } + try + { + if (!LoadWallet()) { fprintf(stderr, "LoadWallet failed\n"); return 1; } + } + catch (const std::exception& e) + { + fprintf(stderr, "Cannot read wallet.dat: %s\n", e.what()); + fprintf(stderr, "The file was left untouched. A wallet.dat written by a " + "different platform's Berkeley DB is the usual cause; back it " + "up before trying anything else.\n"); + return 1; + } printf("Height=%d\n", nBestHeight); ReacceptWalletTransactions(); -- cgit v1.2.3 From f30f45849ecd45be6a073b6b1d308e1c12b8b52b Mon Sep 17 00:00:00 2001 From: Eric Date: Thu, 30 Jul 2026 07:37:43 -0600 Subject: main: make /gen actually mine (#42) nMineMode defaults to MINE_RELAY (0) and ParseStartupArguments() never changed it, so a node started with /gen would spawn ThreadBitcoinMiner, enter BitcoinMiner(), hit if (nMineMode == MINE_RELAY) return true; and return before doing any work. The node then ran indefinitely looking completely healthy -- it synced, it held peers, it reported no errors -- while earning nothing. Nothing in the log said otherwise; the only hint was `nMineMode = 0` in a startup line that reads like a status echo. This affects every headless miner, because the GUI is the only other way to select a mining mode. /gen now implies MINE_SOLO unless /operator or /participant asked for something else. Verify with `nMineMode = 1` at startup. Co-authored-by: Claude Opus 5 --- src/main_gui.cpp | 8 ++++++++ 1 file changed, 8 insertions(+) (limited to 'src/main_gui.cpp') diff --git a/src/main_gui.cpp b/src/main_gui.cpp index a94d01d..c64dca9 100644 --- a/src/main_gui.cpp +++ b/src/main_gui.cpp @@ -79,7 +79,15 @@ static void ParseStartupArguments(int argc, char* argv[]) fDebug = true; if (arg(argc,argv,"/gen") || arg(argc,argv,"-gen")) + { fGenerateBitcoins = 1; + // Mining mode defaults to MINE_RELAY, and nothing in this parser ever + // changed it, so BitcoinMiner() returned immediately at its relay guard + // and /gen mined nothing at all. /operator and /participant still set + // their own mode below, so only the unqualified case is affected. + if (nMineMode == MINE_RELAY) + nMineMode = MINE_SOLO; + } if (arg(argc,argv,"/solomine") || arg(argc,argv,"-solomine")) fSoloMineTest = true; -- cgit v1.2.3 From 99f7c1dfebb1a15dc6f77a338a8b0aaa3962564b Mon Sep 17 00:00:00 2001 From: Bitflash Date: Thu, 30 Jul 2026 11:27:49 -0300 Subject: wallet: make wallet.dat a file you can actually back up (#46) * wallet: make wallet.dat a file you can actually back up Issue #40: a wallet.dat copied out on its own will not open anywhere else. The file is intact -- same bytes, same keys -- and the node refuses it with "Db::put: Invalid argument", thrown from a path that aborted the process. The reporter believes they lost funds to this, having saved the one file anybody would think to save. The chain, from the bottom: _endthread() is pthread_exit() on POSIX, and glibc implements that by throwing abi::__forced_unwind through the stack. That is not a real exception; catching it without rethrowing is a fatal error by contract. CATCH_PRINT_EXCEPTION ended in a bare catch(...), so every Linux shutdown ended: UNKNOWN EXCEPTION bitflash-node in ThreadSocketHandler() FATAL: exception not rethrown An abort, mid-shutdown, so nothing after StopNode() ran. Which mattered because Berkeley DB stamps every page with a log sequence number belonging to the environment that wrote it. Carried elsewhere, those numbers point at logs that do not exist and BDB refuses to write. DBFlush(true) calls lsn_reset, which is precisely the cure -- and DBFlush has been declared since 2009 and called from nowhere. Even had it been called, the abort stood in front of it. So the wallet was never released from its directory, and the one file users copy was the one file that could not travel. Three fixes, each covering a different case: - Rethrow abi::__forced_unwind, and call DBFlush(true) on both shutdown paths. Shutdown completes, and wallet.dat is left portable by construction. - Adopt orphaned files on open: a freshly created database environment beside pre-existing .dat files means they came from elsewhere, so reset their sequence numbers. This is what recovers wallets already stranded by older builds -- including, if they still hold the file, the reporter's. - BackupWallet(), reachable as /backupwallet=FILE, which checkpoints, copies, and clears the copy's sequence numbers. Bitcoin 0.1.0 had a backup command; this tree lost it in the wxWidgets removal and has shipped with no way to take a backup since. DBFlush's own txn_checkpoint and lsn_reset are wrapped too -- they throw, and throwing on the way out is how this started. Verified on Linux and Windows: shutdown exits 0 rather than 134, DBFlush runs, and a lone wallet.dat opens in an empty directory with no exceptions and byte-identical key records (defaultkey, key, name, setting, version) before and after. Not addressed here, and the larger half of #40: keys are generated as they are needed rather than derived from a seed, so any backup is a snapshot and coins paid to addresses created after it are not in it. /backupwallet says so on every run. The real answer is deterministic derivation, which is a wallet format change and its own piece of work. * README: document what a wallet backup actually requires The PR description claimed this section existed and it did not. Covers the two things that cost money and are not obvious: a lone wallet.dat is refused elsewhere because Berkeley DB ties it to the database/ directory beside it, and every backup is a snapshot because keys are made on demand rather than derived from a seed. --- README.md | 31 +++++++++++++ src/db.cpp | 134 +++++++++++++++++++++++++++++++++++++++++++++++++++++-- src/db.h | 6 +++ src/main_gui.cpp | 32 +++++++++++++ src/util.h | 32 +++++++++++++ 5 files changed, 232 insertions(+), 3 deletions(-) (limited to 'src/main_gui.cpp') diff --git a/README.md b/README.md index 1720c46..da8183a 100644 --- a/README.md +++ b/README.md @@ -69,6 +69,37 @@ backup — it holds your keys. --- +## Backing up your wallet + +Two things about this wallet will cost you money if you do not know them. Both +are consequences of the 0.1.0 wallet format, and neither is obvious. + +**Copying `wallet.dat` on its own is not a backup.** Berkeley DB ties the file +to the environment in the `database/` subdirectory beside it, so a lone +`wallet.dat` will not open elsewhere — the keys are all still in there, and the +file is refused anyway. Use the built-in command, which writes a copy that +stands on its own: + +``` +bitflash -backupwallet=/path/to/wallet-backup.dat +``` + +It loads the wallet, writes the copy, and exits without starting the node. If +you would rather copy by hand, shut the node down first and take the **whole** +data directory, not just `wallet.dat`. + +**Every backup is a snapshot, so repeat it.** Keys are generated when they are +needed rather than derived from a seed, so a backup contains only the keys that +existed the moment it was taken. Mine a block or receive to a new address +afterwards, and those coins sit on-chain at an address whose private key is in +no backup you hold. Back up again whenever you use a new address. + +The second point is the pre-BIP32 problem that deterministic wallets were +invented to solve; see [#47](https://github.com/Bitflash-sh/bitflash/issues/47). +Until that lands, repetition is the only defence. + +--- + ## Mining Open **Options** from the menu bar. Under Mining Mode: diff --git a/src/db.cpp b/src/db.cpp index d43e4ef..fc5f180 100644 --- a/src/db.cpp +++ b/src/db.cpp @@ -59,7 +59,12 @@ CDB::CDB(const char* pszFile, const char* pszMode, bool fTxn) : pdb(NULL) { string strAppDir = GetAppDir(); string strLogDir = strAppDir + "/database"; - _mkdir(strLogDir.c_str()); + + // _mkdir returns 0 only when it actually created the directory, so + // this is also the test for "this environment did not exist until + // now". See the recovery block after dbenv.open() for why we care. + bool fFreshEnv = (_mkdir(strLogDir.c_str()) == 0); + printf("dbenv.open strAppDir=%s\n", strAppDir.c_str()); dbenv.set_lg_dir(strLogDir.c_str()); @@ -81,6 +86,41 @@ CDB::CDB(const char* pszFile, const char* pszMode, bool fTxn) : pdb(NULL) if (ret > 0) throw runtime_error(strprintf("CDB() : error %d opening database environment\n", ret)); fDbEnvInit = true; + + // A Berkeley DB file records, in every page header, a log sequence + // number belonging to the environment that wrote it. Carried into a + // different environment those numbers refer to logs that do not + // exist, and BDB refuses to write: "Db::put: Invalid argument", + // thrown from a path that used to abort the process. + // + // That is what happens when someone backs up wallet.dat on its own. + // It is the obviously important file, it is the only one people + // copy, and the environment it is welded to lives in a database/ + // subdirectory nobody has reason to suspect. The file is intact -- + // same bytes, same keys -- and the wallet still will not open. + // Issue #40 reports exactly this, after the fact. + // + // lsn_reset() exists for precisely this migration, so do it for + // them. A fresh environment beside pre-existing .dat files means + // those files came from somewhere else; in the normal case the + // environment is already there and none of this runs. + if (fFreshEnv) + { + static const char* pszBdbFiles[] = { "wallet.dat", "blkindex.dat" }; + for (int i = 0; i < (int)(sizeof(pszBdbFiles)/sizeof(pszBdbFiles[0])); i++) + { + const char* pszName = pszBdbFiles[i]; + if (!FileExists((strAppDir + "/" + pszName).c_str())) + continue; + try + { + if (dbenv.lsn_reset(pszName, 0) == 0) + printf("CDB() : adopted %s from another environment " + "(log sequence numbers reset)\n", pszName); + } + catch (...) { } // best effort; the open below reports real trouble + } + } } strFile = pszFile; @@ -137,7 +177,14 @@ void DBFlush(bool fShutdown) printf("DBFlush(%s)\n", fShutdown ? "true" : "false"); CRITICAL_BLOCK(cs_db) { - dbenv.txn_checkpoint(0, 0, 0); + // This runs on the way out. Anything that throws here and is not caught + // takes the process down before the wallet has been put down cleanly, + // which is the difference between a portable wallet.dat and one welded + // to this directory -- the lsn_reset just below is what frees it. + try { dbenv.txn_checkpoint(0, 0, 0); } + catch (const std::exception& e) + { printf("DBFlush() : checkpoint failed: %s\n", e.what()); } + map::iterator mi = mapFileUseCount.begin(); while (mi != mapFileUseCount.end()) { @@ -145,7 +192,9 @@ void DBFlush(bool fShutdown) int nRefCount = (*mi).second; if (nRefCount == 0) { - dbenv.lsn_reset(strFile.c_str(), 0); + try { dbenv.lsn_reset(strFile.c_str(), 0); } + catch (const std::exception& e) + { printf("DBFlush() : lsn_reset(%s) failed: %s\n", strFile.c_str(), e.what()); } mapFileUseCount.erase(mi++); } else @@ -580,3 +629,82 @@ bool LoadWallet() return true; } + + +// Write a copy of wallet.dat that can be opened anywhere. +// +// A plain file copy is not a backup here. Berkeley DB stamps every page with a +// log sequence number tied to the environment that wrote it, so the copy only +// works beside the database/ directory it grew up with -- which is why backing +// up "just wallet.dat", the one file anybody would think to save, produces +// something that will not open. See the recovery block in CDB::CDB and #40. +// +// So: flush outstanding writes, copy, then clear the copy's log sequence +// numbers. What lands on disk is a wallet.dat that stands on its own. +// +// This is still a point-in-time snapshot. Keys are generated as they are +// needed, not derived from a seed, so coins paid to an address created after +// this file was written are not spendable from it. Backing up once is not +// enough, and that is a property of the wallet format rather than of this +// function. +bool BackupWallet(const string& strDest) +{ + if (strDest.empty()) + return error("BackupWallet() : no destination given\n"); + + string strSrc = GetAppDir() + "/wallet.dat"; + if (!FileExists(strSrc.c_str())) + return error("BackupWallet() : %s does not exist\n", strSrc.c_str()); + + CRITICAL_BLOCK(cs_db) + { + // Committing first, so the copy is not missing the newest records. + // Both calls can throw, and a failed backup must not take the node + // down with it. + try { dbenv.txn_checkpoint(0, 0, 0); } + catch (const std::exception& e) + { return error("BackupWallet() : checkpoint failed: %s\n", e.what()); } + + try + { + FILE* pfIn = fopen(strSrc.c_str(), "rb"); + if (!pfIn) + return error("BackupWallet() : cannot read %s\n", strSrc.c_str()); + FILE* pfOut = fopen(strDest.c_str(), "wb"); + if (!pfOut) + { + fclose(pfIn); + return error("BackupWallet() : cannot write %s\n", strDest.c_str()); + } + + char buf[65536]; + size_t n; + bool fOk = true; + while ((n = fread(buf, 1, sizeof(buf), pfIn)) > 0) + if (fwrite(buf, 1, n, pfOut) != n) { fOk = false; break; } + if (ferror(pfIn)) + fOk = false; + fclose(pfIn); + // Flushed and closed before lsn_reset touches it. + if (fclose(pfOut) != 0) + fOk = false; + + if (!fOk) + { + remove(strDest.c_str()); // half a wallet is worse than none + return error("BackupWallet() : copy to %s failed\n", strDest.c_str()); + } + + // Without this the copy is welded to this node's database/ dir. + int ret = dbenv.lsn_reset(strDest.c_str(), 0); + if (ret != 0) + printf("BackupWallet() : warning -- lsn_reset returned %d; the copy " + "may only open beside this node's database/ directory\n", ret); + } + catch (const std::exception& e) + { return error("BackupWallet() : %s\n", e.what()); } + } + + printf("BackupWallet() : wrote %s\n", strDest.c_str()); + return true; +} diff --git a/src/db.h b/src/db.h index 6dc3a4f..987786c 100644 --- a/src/db.h +++ b/src/db.h @@ -403,6 +403,12 @@ public: bool LoadWallet(); +// Writes a wallet.dat that opens on its own, anywhere -- see the definition in +// db.cpp for why a plain file copy does not. Still a point-in-time snapshot: +// keys are made as needed rather than derived from a seed, so coins paid to an +// address created after this call are not spendable from the file it writes. +bool BackupWallet(const string& strDest); + inline bool SetAddressBookName(const string& strAddress, const string& strName) { return CWalletDB().WriteName(strAddress, strName); diff --git a/src/main_gui.cpp b/src/main_gui.cpp index c64dca9..976cd42 100644 --- a/src/main_gui.cpp +++ b/src/main_gui.cpp @@ -67,6 +67,10 @@ static void PrintUsage() printf(" /port=N (P2P listen port, default 8433)\n"); printf(" /btfseed=ADDRESS:ENCHEX (extra bootstrap peer, repeatable)\n"); printf("\n"); + printf("Wallet:\n"); + printf(" /backupwallet=FILE (write a wallet.dat that opens on its own,\n"); + printf(" then exit; run it again after new addresses)\n"); + printf("\n"); printf("Each option also accepts '-' instead of '/'.\n"); } @@ -219,6 +223,28 @@ int main(int argc, char* argv[]) return 1; } printf("Height=%d\n", nBestHeight); + + // Before the node opens sockets or touches anything: the wallet is loaded, + // which is all a backup needs, and finishing here means the copy is taken + // from a quiet directory rather than from under a running node. + string strBackup = argval2(argc, argv, "/backupwallet", "-backupwallet"); + if (!strBackup.empty()) + { + bool fOk = BackupWallet(strBackup); + if (fOk) + { + printf("Backed up to %s\n", strBackup.c_str()); + printf("This file opens on its own -- it does not need the database/ " + "directory beside it.\n"); + printf("It is a snapshot: coins paid to addresses created after now are " + "not in it, so back up again whenever you receive to a new address.\n"); + } + else + fprintf(stderr, "Backup failed -- nothing was written.\n"); + DBFlush(true); + return fOk ? 0 : 1; + } + ReacceptWalletTransactions(); if (!StartNode(strErrors)) { fprintf(stderr,"StartNode: %s\n",strErrors.c_str()); return 1; } @@ -249,6 +275,11 @@ int main(int argc, char* argv[]) printf("Running headless. Ctrl-C to stop.\n"); while (!fShutdown) Sleep(500); StopNode(); + // Checkpoints, releases wallet.dat from this directory's Berkeley DB + // environment, and closes it. Declared since 2009 and never once + // called here, which is why a wallet.dat copied elsewhere would not + // open -- issue #40. Not optional. + DBFlush(true); return 0; } @@ -258,6 +289,7 @@ int main(int argc, char* argv[]) int ret = RunGUI(argc, argv); fShutdown = true; StopNode(); + DBFlush(true); return ret; #endif } diff --git a/src/util.h b/src/util.h index 797ed27..25ef118 100644 --- a/src/util.h +++ b/src/util.h @@ -3,6 +3,11 @@ // file license.txt or http://www.opensource.org/licenses/mit-license.php. +// For abi::__forced_unwind, which CATCH_PRINT_EXCEPTION below must let past. +#ifndef _WIN32 +#include +#endif + #if defined(_MSC_VER) || defined(__BORLANDC__) typedef __int64 int64; typedef unsigned __int64 uint64; @@ -419,7 +424,34 @@ inline void heapchk() } \ } +// The forced-unwind arm has to come first, and it has to rethrow. +// +// On POSIX, _endthread() is pthread_exit(), and glibc implements that by +// throwing abi::__forced_unwind through the stack. It is not a real exception +// and catching it without rethrowing is a fatal error by contract: glibc +// notices and calls abort(). +// +// The bare catch(...) below did exactly that. Every shutdown on Linux died with +// +// UNKNOWN EXCEPTION bitflash-node in ThreadSocketHandler() +// FATAL: exception not rethrown +// +// which looked like nothing worse than an ugly exit, and was not: the process +// aborted partway through shutting down, so nothing after StopNode() ran. See +// main_gui.cpp, where DBFlush(true) is now called -- it could never have +// worked while this abort stood in front of it. Windows is unaffected; there +// _endthread() is the CRT's and unwinds nothing. +#ifdef _WIN32 +#define CATCH_FORCED_UNWIND +#else +#define CATCH_FORCED_UNWIND \ + catch (abi::__forced_unwind&) { \ + throw; \ + } +#endif + #define CATCH_PRINT_EXCEPTION(pszFn) \ + CATCH_FORCED_UNWIND \ catch (std::exception& e) { \ PrintException(&e, (pszFn)); \ } catch (...) { \ -- cgit v1.2.3 From b539d776bd86df9224d97048f26f33d2750d1c92 Mon Sep 17 00:00:00 2001 From: Bitflash Date: Thu, 30 Jul 2026 12:06:08 -0300 Subject: wallet: export and import keys as text (#49) The last door out. Everything else assumes Berkeley DB will cooperate. When it will not -- a build that cannot read the file, a truncation, an environment past recovering -- the keys are usually still perfectly intact and were unreachable anyway. #40 is one person's account of exactly that, and #46 only rescues the cases where BDB still opens the file at all. bitflash -dumpwallet=keys.txt bitflash -importwallet=keys.txt One key per line with its address and label. No database, no environment, no version coupling: anything that can read a line can recover from it. The format is the DER private key in hex, which round-trips exactly through SetPrivKey. Not interchangeable with other wallets -- worth having eventually, but a WIF encoder I got subtly wrong would be worse than no encoder at all, and this file exists for the case where nothing else works. Care taken, because the failure modes here cost money: - Refuses to overwrite an existing file. A mistyped path must not eat the only copy of something irreplaceable. - Written owner-only on POSIX, before any key reaches it. Windows inherits the folder's ACL; the README says so rather than pretending otherwise. - Newlines in labels are flattened, so a label cannot forge an extra entry on read-back. - Import skips keys already present, so running it twice does nothing, and one unreadable line is reported and stepped over instead of costing the reader every other key in the file. Verified on Linux and Windows: a key exported from one wallet imports into a different one and its address then appears there; re-importing adds nothing; a corrupted line is skipped while the rest still load; the dump is 0600 and refuses to overwrite. This does not solve #47. Keys still are not derived from a seed, so a dump is as much a snapshot as any other backup. What it does is make the keys reachable no matter what state the database is in. --- README.md | 24 +++++++ src/db.cpp | 191 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/db.h | 7 ++ src/main_gui.cpp | 33 ++++++++++ 4 files changed, 255 insertions(+) (limited to 'src/main_gui.cpp') diff --git a/README.md b/README.md index da8183a..16e979f 100644 --- a/README.md +++ b/README.md @@ -98,6 +98,30 @@ The second point is the pre-BIP32 problem that deterministic wallets were invented to solve; see [#47](https://github.com/Bitflash-sh/bitflash/issues/47). Until that lands, repetition is the only defence. +### When wallet.dat itself will not open + +Everything above still runs through Berkeley DB. If the database is the thing +that is broken — a build that will not read it, a file truncated by a bad copy, +an environment beyond recovery — the keys are usually still fine, and you can +take them out as text: + +``` +bitflash -dumpwallet=/path/to/keys.txt +bitflash -importwallet=/path/to/keys.txt # into any wallet, on any machine +``` + +One key per line with its address and label, no database and no environment. +Importing skips keys the wallet already holds, so running it twice is safe, and +an unreadable line is reported and stepped over rather than abandoning the rest. +Restart the node afterwards so it scans the chain for transactions belonging to +the new keys. + +**The dump is your private keys in the clear.** Anyone who reads that file can +spend those coins. It is written owner-only on Linux and macOS; on Windows it +inherits whatever the containing folder allows, so choose the folder carefully. +Move it somewhere safe and delete the copy. `-dumpwallet` will not overwrite an +existing file. + --- ## Mining diff --git a/src/db.cpp b/src/db.cpp index fc5f180..4cc54ac 100644 --- a/src/db.cpp +++ b/src/db.cpp @@ -708,3 +708,194 @@ bool BackupWallet(const string& strDest) printf("BackupWallet() : wrote %s\n", strDest.c_str()); return true; } + + +// ---- plain-text key export/import ------------------------------------------ +// +// The last door out. Everything else in this file assumes Berkeley DB will +// cooperate; these two do not. A wallet.dat can be unreadable for reasons that +// have nothing to do with your keys being gone -- a version this build does not +// understand, a file truncated by a bad copy, an environment that cannot be +// recovered -- and until now that meant the coins were unreachable even though +// the secrets were sitting right there. Issue #40 is one person's account of +// exactly that. +// +// The format is deliberately dull: a text file, one key per line, no database, +// no environment, no framing. Anything that can read a line can recover from it. + +static int HexDigitValue(char c) +{ + if (c >= '0' && c <= '9') return c - '0'; + if (c >= 'a' && c <= 'f') return c - 'a' + 10; + if (c >= 'A' && c <= 'F') return c - 'A' + 10; + return -1; +} + +static bool ParseHexInto(const string& str, vector& vchRet) +{ + vchRet.clear(); + if (str.empty() || (str.size() % 2) != 0) + return false; + for (size_t i = 0; i < str.size(); i += 2) + { + int hi = HexDigitValue(str[i]); + int lo = HexDigitValue(str[i + 1]); + if (hi < 0 || lo < 0) + return false; + vchRet.push_back((unsigned char)((hi << 4) | lo)); + } + return true; +} + +bool DumpWallet(const string& strDest) +{ + if (strDest.empty()) + return error("DumpWallet() : no destination given\n"); + + // Never over an existing file. The whole point is to hold the only copy of + // something irreplaceable, and a mistyped path must not eat one. + if (FileExists(strDest.c_str())) + return error("DumpWallet() : %s already exists -- refusing to overwrite\n", + strDest.c_str()); + + FILE* pf = fopen(strDest.c_str(), "w"); + if (!pf) + return error("DumpWallet() : cannot write %s\n", strDest.c_str()); +#ifndef _WIN32 + // Before a single key reaches it. + chmod(strDest.c_str(), S_IRUSR | S_IWUSR); +#endif + + fprintf(pf, "# Bitflash wallet key dump\n"); + fprintf(pf, "# %s\n", DateTimeStr(GetTime()).c_str()); + fprintf(pf, "#\n"); + fprintf(pf, "# THIS FILE CONTAINS PRIVATE KEYS IN PLAIN TEXT.\n"); + fprintf(pf, "# Anyone who reads it can spend these coins. Treat it as cash.\n"); + fprintf(pf, "#\n"); + fprintf(pf, "# One key per line:
[label]\n"); + fprintf(pf, "# The key is the DER encoding, hex; restore with -importwallet=FILE.\n"); + fprintf(pf, "#\n"); + + int nKeys = 0; + bool fOk = true; + CRITICAL_BLOCK(cs_mapKeys) + { + for (map, CPrivKey>::iterator mi = mapKeys.begin(); + mi != mapKeys.end(); ++mi) + { + const vector& vchPubKey = (*mi).first; + const CPrivKey& vchPrivKey = (*mi).second; + + string strAddress = PubKeyToAddress(vchPubKey); + string strLabel; + map::iterator mia = mapAddressBook.find(strAddress); + if (mia != mapAddressBook.end()) + strLabel = (*mia).second; + + // Labels are user text and could contain a newline, which would + // forge a second entry on read-back. + for (size_t i = 0; i < strLabel.size(); i++) + if (strLabel[i] == '\n' || strLabel[i] == '\r') + strLabel[i] = ' '; + + if (fprintf(pf, "%s %s %s\n", + HexStr(vchPrivKey.begin(), vchPrivKey.end(), false).c_str(), + strAddress.c_str(), + strLabel.c_str()) < 0) + { fOk = false; break; } + nKeys++; + } + } + + if (fclose(pf) != 0) + fOk = false; + if (!fOk) + { + remove(strDest.c_str()); + return error("DumpWallet() : writing %s failed\n", strDest.c_str()); + } + + printf("DumpWallet() : wrote %d key(s) to %s\n", nKeys, strDest.c_str()); + return true; +} + +bool ImportWallet(const string& strSrc, int& nAddedRet, int& nSkippedRet) +{ + nAddedRet = 0; + nSkippedRet = 0; + + FILE* pf = fopen(strSrc.c_str(), "r"); + if (!pf) + return error("ImportWallet() : cannot read %s\n", strSrc.c_str()); + + char buf[8192]; + int nLine = 0; + int nBad = 0; + while (fgets(buf, sizeof(buf), pf)) + { + nLine++; + string strLine = buf; + while (!strLine.empty() && (strLine[strLine.size()-1] == '\n' || + strLine[strLine.size()-1] == '\r')) + strLine.resize(strLine.size() - 1); + if (strLine.empty() || strLine[0] == '#') + continue; + + string strHex = strLine.substr(0, strLine.find(' ')); + vector vchPrivKey; + if (!ParseHexInto(strHex, vchPrivKey)) + { + printf("ImportWallet() : line %d is not a hex key, skipped\n", nLine); + nBad++; + continue; + } + + CKey key; + // A key this build cannot load is reported and stepped over rather than + // aborting the run: one damaged line in a salvage file must not cost + // the reader every other key in it. + try + { + CPrivKey vchSecure(vchPrivKey.begin(), vchPrivKey.end()); + if (!key.SetPrivKey(vchSecure)) + { + printf("ImportWallet() : line %d is not a usable key, skipped\n", nLine); + nBad++; + continue; + } + } + catch (const std::exception& e) + { + printf("ImportWallet() : line %d rejected (%s), skipped\n", nLine, e.what()); + nBad++; + continue; + } + + bool fHave = false; + CRITICAL_BLOCK(cs_mapKeys) + fHave = mapKeys.count(key.GetPubKey()) > 0; + if (fHave) + { + nSkippedRet++; // already ours; importing twice is not an error + continue; + } + + if (!AddKey(key)) + { + fclose(pf); + return error("ImportWallet() : failed to store key from line %d\n", nLine); + } + nAddedRet++; + } + fclose(pf); + + printf("ImportWallet() : %d added, %d already present, %d unreadable\n", + nAddedRet, nSkippedRet, nBad); + + // Imported keys are only useful once their transactions are noticed, and + // that scan is a full pass over the chain the node does at startup. + if (nAddedRet > 0) + printf("ImportWallet() : restart the node so it can find transactions " + "belonging to the new keys\n"); + return true; +} diff --git a/src/db.h b/src/db.h index 987786c..22efc77 100644 --- a/src/db.h +++ b/src/db.h @@ -409,6 +409,13 @@ bool LoadWallet(); // address created after this call are not spendable from the file it writes. bool BackupWallet(const string& strDest); +// Plain-text key export/import: no Berkeley DB, no environment, no version +// coupling. What you fall back on when wallet.dat itself will not cooperate. +// DumpWallet refuses to overwrite an existing file and writes owner-only on +// POSIX -- the output holds private keys in the clear. +bool DumpWallet(const string& strDest); +bool ImportWallet(const string& strSrc, int& nAddedRet, int& nSkippedRet); + inline bool SetAddressBookName(const string& strAddress, const string& strName) { return CWalletDB().WriteName(strAddress, strName); diff --git a/src/main_gui.cpp b/src/main_gui.cpp index 976cd42..2484592 100644 --- a/src/main_gui.cpp +++ b/src/main_gui.cpp @@ -70,6 +70,9 @@ static void PrintUsage() printf("Wallet:\n"); printf(" /backupwallet=FILE (write a wallet.dat that opens on its own,\n"); printf(" then exit; run it again after new addresses)\n"); + printf(" /dumpwallet=FILE (export private keys as text -- readable by\n"); + printf(" anyone, so guard it like cash)\n"); + printf(" /importwallet=FILE (load keys from such a file back in)\n"); printf("\n"); printf("Each option also accepts '-' instead of '/'.\n"); } @@ -245,6 +248,36 @@ int main(int argc, char* argv[]) return fOk ? 0 : 1; } + string strDump = argval2(argc, argv, "/dumpwallet", "-dumpwallet"); + if (!strDump.empty()) + { + bool fOk = DumpWallet(strDump); + if (fOk) + { + printf("Wrote %s\n", strDump.c_str()); + printf("It holds your private keys as readable text. Anyone with the file " + "can spend these coins -- keep it off shared storage and delete it " + "once you have it somewhere safe.\n"); + } + else + fprintf(stderr, "Export failed -- nothing was written.\n"); + DBFlush(true); + return fOk ? 0 : 1; + } + + string strImport = argval2(argc, argv, "/importwallet", "-importwallet"); + if (!strImport.empty()) + { + int nAdded = 0, nSkipped = 0; + bool fOk = ImportWallet(strImport, nAdded, nSkipped); + if (fOk) + printf("Imported %d key(s); %d were already here.\n", nAdded, nSkipped); + else + fprintf(stderr, "Import failed.\n"); + DBFlush(true); + return fOk ? 0 : 1; + } + ReacceptWalletTransactions(); if (!StartNode(strErrors)) { fprintf(stderr,"StartNode: %s\n",strErrors.c_str()); return 1; } -- cgit v1.2.3 From 225d0210f881acb10a3dfaf9bc5a23446bc12d21 Mon Sep 17 00:00:00 2001 From: Bitflash Date: Thu, 30 Jul 2026 14:07:50 -0300 Subject: mine: hash on every core instead of one (#52) Satoshi left a note above ThreadBitcoinMiner in 2009: //// todo: start one thread per processor, //// use getenv("NUMBER_OF_PROCESSORS") Nobody did, and it stopped being a nicety when the proof of work became RandomX. SHA-256d on a 2009 desktop lost little to a single thread. RandomX is built to be fed by every core at once, so one thread on a 32-core machine mines at a thirty-second of what the box can do -- and the only outward sign is a CPU that looks suspiciously idle. A user reported exactly that: 30 minutes of "Mining", no blocks, Bitflash sitting at 3.3% CPU while the 2 GB dataset was allocated and fast mode was active. Nothing was broken. It was hashing on one core. bitflash -gen # every core but one bitflash -gen -genproclimit=8 # exactly eight Automatic leaves one core to the rest of the node -- accepting blocks, talking to peers, drawing the window -- because a node that mines beautifully while falling behind the chain has won nothing. /genproclimit overrides it, and a count several times the core count is refused rather than quietly thrashing. The wrapper was already built for this: RandomXCreateMinerVM says "own VM, not shared", and the ~2 GB dataset is one allocation every thread reads. Only the 2 MB scratchpad multiplies, so sixteen threads cost about 32 MB more than one. The startup line says so, because "why is it using 2 GB" is the first question anyone asks. Two things had to be right for this to be safe: - Shutdown. Miners share slot 3 of vfThreadRunning and StopNode waits for every slot before DBFlush tears the database down. With several, the first one out announced that everyone had finished while the rest were still calling ProcessBlock, so they are counted now and only the last one clears the flag. The three CheckForShutdown(3) calls inside the miner became plain returns: _endthread() does not unwind on Windows, so the bookkeeping would have been skipped there. - Duplicated work. Each thread makes its own key, and the coinbase pays it, so every thread has a different merkle root and searches a different space. Block assembly was already under cs_main and cs_mapTransactions; AddKey and ProcessBlock take their own locks. Verified on both platforms: four distinct thread ids reach the hashing loop and all report back on shutdown, which exits 0 with DBFlush running. Auto picks cores-1 (1 on a 2-core box, correctly reported). Speedup itself needs a many-core machine to show and has not been measured here -- the claim in this message is about threads doing work, not a benchmark. Not touched: RandomX's memory. RANDOMX_DATASET_BASE_SIZE and SCRATCHPAD_L3 are part of the hash function, so a knob for "use more RAM" would either do nothing or fork the chain. Threads are the only real dial. --- src/gui.cpp | 3 ++- src/main.cpp | 51 +++++++++++++++++++++++++++++++++++++++++++------ src/main.h | 8 +++++++- src/main_gui.cpp | 25 ++++++++++++++++++++++-- src/net.cpp | 58 ++++++++++++++++++++++++++++++++++++++++++++++++++------ src/net.h | 2 ++ 6 files changed, 131 insertions(+), 16 deletions(-) (limited to 'src/main_gui.cpp') diff --git a/src/gui.cpp b/src/gui.cpp index fc6f1e1..825202b 100644 --- a/src/gui.cpp +++ b/src/gui.cpp @@ -32,6 +32,7 @@ int64 GetBalance(); string PubKeyToAddress(const std::vector& vchPubKey); bool AddressToHash160(const std::string& str, uint160& hash160Ret); void ThreadBitcoinMiner(void*); +int StartMinerThreads(); void ThreadRPCServer(void*); // --------------------------------------------------------------------------- @@ -762,7 +763,7 @@ static void DrawOptionsDialog() fGenerateBitcoins = 0; } else { fGenerateBitcoins = 1; - if (!vfThreadRunning[3]) _beginthread(ThreadBitcoinMiner, 0, NULL); + if (!vfThreadRunning[3]) StartMinerThreads(); } if (nMineMode == MINE_OPERATOR) { diff --git a/src/main.cpp b/src/main.cpp index c882b0b..d4fc6d0 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -72,6 +72,13 @@ bool fSoloMineTest = false; // /solomine: mine without requiring a peer (local t // Settings int fGenerateBitcoins; +// How many threads to hash with. 0 means "decide from the hardware" -- see +// MinerThreadCount(). This was effectively 1 forever, because Bitcoin 0.1.0 +// started exactly one miner thread and nothing here ever changed that. It made +// sense for SHA-256d in 2009; RandomX is built to be fed by every core at once, +// so a 32-core machine was mining at a thirty-second of its capacity and the +// only visible symptom was a suspiciously idle CPU. +int nMinerThreads = 0; int64 nTransactionFee = 0; CAddress addrIncoming; int nMineMode = MINE_RELAY; @@ -119,6 +126,26 @@ void GetParticipantMiningStats(uint64& sharesSent, uint64& sharesAccepted, doubl // mapKeys // +// Threads to hash with, resolving the automatic case. +// +// Automatic leaves one core to the rest of the node -- accepting blocks, +// talking to peers, serving the GUI -- because a machine that mines perfectly +// while falling behind the chain has won nothing. Anyone who disagrees can say +// so with /genproclimit and get exactly what they ask for. +// +// Memory is not the reason to hold back: the ~2 GB RandomX dataset is shared by +// every thread, and each one adds only its own 2 MB scratchpad. Sixteen threads +// cost about 32 MB more than one. +int MinerThreadCount() +{ + if (nMinerThreads > 0) + return nMinerThreads; + unsigned int nCores = std::thread::hardware_concurrency(); + if (nCores <= 1) + return 1; + return (int)(nCores - 1); +} + bool AddKey(const CKey& key) { CRITICAL_BLOCK(cs_mapKeys) @@ -2769,7 +2796,7 @@ static bool PoolParticipantMiner() return true; } -bool BitcoinMiner() +bool BitcoinMiner(int nThreadId) { // Relay mode: never mines, just relays/syncs. (Shouldn't normally get here // since the GUI hides Start Mining for Relay, but guard it regardless.) @@ -2809,11 +2836,17 @@ bool BitcoinMiner() while (fGenerateBitcoins) { Sleep(50); - CheckForShutdown(3); + // Return instead of _endthread()ing. With more than one miner thread the + // bookkeeping in ThreadBitcoinMiner has to run on the way out, and + // _endthread() does not unwind on Windows, so a normal return is the + // only exit that works the same on both platforms. + if (fShutdown) + return true; while (vNodes.empty() && !fSoloMineTest) { Sleep(1000); - CheckForShutdown(3); + if (fShutdown) + return true; } unsigned int nTransactionsUpdatedLast = nTransactionsUpdated; @@ -2908,8 +2941,11 @@ bool BitcoinMiner() Sleep(1000); continue; } - LogPrint("net", "BitcoinMiner: mining with RandomX (%s)\n", - RandomXFastReady() ? "fast 2GB" : "light 256MB"); + // The thread number is in the line on purpose: every miner would + // otherwise print the same text, and the dedup filter would fold them + // into one, leaving no way to tell four threads from one. + LogPrint("net", "BitcoinMiner: thread %d hashing with RandomX (%s)\n", + nThreadId, RandomXFastReady() ? "fast 2GB" : "light 256MB"); unsigned int nStart = GetTime(); uint256 hashTarget = CBigNum().SetCompact(pblock->nBits).getuint256(); @@ -2950,7 +2986,8 @@ bool BitcoinMiner() // ~256 hashes is already plenty of time) if ((++pblock->nNonce & 0xff) == 0) { - CheckForShutdown(3); + if (fShutdown) + break; // falls through to RandomXDestroyMinerVM below if (pblock->nNonce == 0) break; if (pindexPrev != pindexBest) @@ -2963,6 +3000,8 @@ bool BitcoinMiner() } } RandomXDestroyMinerVM(rxvm); + if (fShutdown) + break; } return true; diff --git a/src/main.h b/src/main.h index 7e5bdf2..750d3f1 100644 --- a/src/main.h +++ b/src/main.h @@ -73,6 +73,10 @@ extern string strPoolName; // operator: announced pool name extern string strPoolDashboardUrl; // operator: optional dashboard URL extern double dPoolFeePercent; // operator: announced fee percent extern bool fSoloMineTest; // /solomine: mine without requiring a peer (local test) +// Threads to hash with; 0 = decide from the hardware. Set by /genproclimit. +extern int nMinerThreads; +// Resolves the automatic case into a real count. +int MinerThreadCount(); extern bool gPoolServerRunning; // GUI/startup state extern volatile bool gPoolRunning; // runtime pool server loop flag (rpc.cpp) @@ -117,7 +121,9 @@ void PrintBlockTree(); void AddOrphanTx(const CDataStream& vMsg); void EraseOrphanTx(uint256 hash); void LimitOrphanTx(unsigned int nMaxOrphans); -bool BitcoinMiner(); +// nThreadId is 1..N, only for logging: it makes each miner's lines +// distinguishable, which the dedup filter would otherwise collapse into one. +bool BitcoinMiner(int nThreadId = 1); void ThreadRPCServer(void* parg); // rpc.cpp -- .btf pool server void GetParticipantMiningStats(uint64& sharesSent, uint64& sharesAccepted, double& hashRate); void SetParticipantMiningStatus(const std::string& status); diff --git a/src/main_gui.cpp b/src/main_gui.cpp index 2484592..852ad4d 100644 --- a/src/main_gui.cpp +++ b/src/main_gui.cpp @@ -1,6 +1,7 @@ // Bitflash entry point -- starts node threads then runs GUI (or headless). #include "headers_core.h" +#include // hardware_concurrency, to sanity-check /genproclimit #ifndef _WIN32 #include #endif @@ -52,6 +53,7 @@ static void PrintUsage() printf(" /operator\n"); printf(" /participant=POOL_BTF_ADDRESS\n"); printf(" /solomine\n"); + printf(" /genproclimit=N (mining threads; 0 or absent = every core but one)\n"); printf("\n"); printf("Pool operator announcement:\n"); printf(" /poolname=NAME\n"); @@ -99,6 +101,26 @@ static void ParseStartupArguments(int argc, char* argv[]) if (arg(argc,argv,"/solomine") || arg(argc,argv,"-solomine")) fSoloMineTest = true; + // /genproclimit=N -- threads to hash with. 0 or absent means automatic, + // which is every core but one. Named after Bitcoin's own option so it reads + // familiarly to anyone who has run one of these before. + string strProcLimit = argval2(argc, argv, "/genproclimit", "-genproclimit"); + if (!strProcLimit.empty()) + { + int n = atoi(strProcLimit.c_str()); + unsigned int nCores = std::thread::hardware_concurrency(); + if (n < 0) + fprintf(stderr, "Ignoring /genproclimit=%s: not a thread count\n", + strProcLimit.c_str()); + else if (nCores > 0 && n > (int)nCores * 4) + // Well past diminishing returns, and every thread still costs a + // scratchpad. Refuse rather than quietly thrash the machine. + fprintf(stderr, "Ignoring /genproclimit=%d: this machine has %u core(s)\n", + n, nCores); + else + nMinerThreads = n; // 0 stays automatic + } + if (arg(argc,argv,"/operator") || arg(argc,argv,"-operator")) nMineMode = MINE_OPERATOR; @@ -289,8 +311,7 @@ int main(int argc, char* argv[]) printf("Error: _beginthread(ThreadRPCServer) failed\n"); } if (fGenerateBitcoins) - if (_beginthread(ThreadBitcoinMiner, 0, NULL) == (uintptr_t)-1) - printf("Error: _beginthread(ThreadBitcoinMiner) failed\n"); + StartMinerThreads(); #ifdef BITFLASH_NO_GUI // Nothing else this binary can do; /nogui is accepted and redundant. diff --git a/src/net.cpp b/src/net.cpp index 2581578..9ee056f 100644 --- a/src/net.cpp +++ b/src/net.cpp @@ -10,6 +10,7 @@ #undef snprintf #endif #include "headers.h" +#include // hardware_concurrency, for the miner thread count #ifdef _WIN32 #include #endif @@ -1224,18 +1225,63 @@ void ThreadMessageHandler2(void* parg) -//// todo: start one thread per processor, use getenv("NUMBER_OF_PROCESSORS") +// Satoshi's "todo: start one thread per processor" stood here since 2009 and is +// what MinerThreadCount() and StartMinerThreads() below finally do -- by way of +// std::thread::hardware_concurrency rather than NUMBER_OF_PROCESSORS, which only +// ever existed on Windows. +// +// Miners share slot 3 of vfThreadRunning, and StopNode waits for every slot to +// go false before the node tears the database down. With one miner that was the +// same thing as "the miner finished"; with several, the first one out would +// announce that everybody had finished while the rest were still hashing and +// still calling ProcessBlock. So count them, and let only the last one leaving +// clear the flag. +static CCriticalSection cs_nMinersRunning; +static int nMinersRunning = 0; + void ThreadBitcoinMiner(void* parg) { - vfThreadRunning[3] = true; - CheckForShutdown(3); + int nThreadId = (int)(intptr_t)parg; + CRITICAL_BLOCK(cs_nMinersRunning) + { + nMinersRunning++; + vfThreadRunning[3] = true; + } try { - bool fRet = BitcoinMiner(); - printf("BitcoinMiner returned %s\n\n\n", fRet ? "true" : "false"); + bool fRet = BitcoinMiner(nThreadId); + printf("BitcoinMiner thread %d returned %s\n", nThreadId, fRet ? "true" : "false"); } CATCH_PRINT_EXCEPTION("BitcoinMiner()") - vfThreadRunning[3] = false; + CRITICAL_BLOCK(cs_nMinersRunning) + { + if (--nMinersRunning <= 0) + { + nMinersRunning = 0; + vfThreadRunning[3] = false; + } + } +} + +// Start the miners. Returns how many actually got off the ground. +int StartMinerThreads() +{ + int nThreads = MinerThreadCount(); + int nStarted = 0; + for (int i = 0; i < nThreads; i++) + { + if (_beginthread(ThreadBitcoinMiner, 0, (void*)(intptr_t)(i + 1)) == (uintptr_t)-1) + printf("Error: _beginthread(ThreadBitcoinMiner) failed on thread %d\n", i + 1); + else + nStarted++; + } + // The dataset is one allocation shared by all of them; only the 2 MB + // scratchpads multiply. Printed because "why is it using 2 GB" is the first + // question anyone asks. + printf("Mining with %d thread(s) of %u core(s) -- about %d MB " + "(~2080 MB shared RandomX dataset + ~2 MB per thread)\n", + nStarted, std::thread::hardware_concurrency(), 2080 + 2 * nStarted); + return nStarted; } diff --git a/src/net.h b/src/net.h index 4379d04..11c023e 100644 --- a/src/net.h +++ b/src/net.h @@ -58,6 +58,8 @@ int BtfPexAccept(const std::vector& vDesc); void AbandonRequests(void (*fn)(void*, CDataStream&), void* param1); bool AnySubscribed(unsigned int nChannel); void ThreadBitcoinMiner(void* parg); +// Starts MinerThreadCount() miners; returns how many came up. +int StartMinerThreads(); bool StartNode(string& strError=REF(string())); bool StopNode(); void CheckForShutdown(int n); -- cgit v1.2.3 From 3bbfb50ef85feeddad936eb1fe8cce232410b8a6 Mon Sep 17 00:00:00 2001 From: Bitflash Date: Fri, 31 Jul 2026 08:53:27 -0300 Subject: db: verify the tail of the chain on startup and back out of a bad tip (#61) A node that accepted blocks its current rules would reject -- because it was running an older build, or because the tip it landed on was never valid -- had no way to notice. It kept the branch, kept building on it, and kept mining coins the rest of the network throws away. The only cure was deleting blk*.dat by hand, which requires knowing something is wrong. A node here once mined a 323-block fork for five days that way. On load, walk back from the tip running CheckBlock on each block. If one fails, move the best chain back to its parent, disconnect what came after, and let the node re-download from peers. Satoshi added this after the block 74638 incident for the same reason: so it would not have needed anyone to delete files. Only the last 288 blocks are checked by default -- about ten hours at a two-minute target -- because every check costs a RandomX hash. /checkblocks=N changes it, 0 does the whole chain. The repair deliberately runs in LoadBlockIndex() in main.cpp rather than in CTxDB::LoadBlockIndex where the detection happens. CTxDB::LoadBlockIndex walks the index with a cursor it never closes, so it still holds read locks when it returns, and a write transaction opened alongside it waits on them forever. That is measured, not guessed: the first version came up, printed the diagnosis, and hung with nothing in any log. Closing that cursor is a separate fix worth making on its own. A failed rollback reports and starts anyway. Refusing to start would leave the operator with no node to read the message from. What this catches is what CheckBlock catches: proof of work, merkle root, duplicate transactions, sigops, timestamps. It does not re-verify signatures or spends, which would mean rebuilding the transaction index. Verified by forcing a block at tip-5 to fail: the node reported the bad block, moved back six, stayed up, and re-synced past its old tip. Without the forced failure it verifies 288 and says so. --- src/db.cpp | 41 +++++++++++++++++++++++++++++++ src/main.cpp | 75 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/main.h | 2 ++ src/main_gui.cpp | 5 ++++ 4 files changed, 123 insertions(+) (limited to 'src/main_gui.cpp') diff --git a/src/db.cpp b/src/db.cpp index 4cc54ac..ce7b875 100644 --- a/src/db.cpp +++ b/src/db.cpp @@ -435,6 +435,47 @@ bool CTxDB::LoadBlockIndex() nBestHeight = pindexBest->nHeight; printf("LoadBlockIndex(): hashBestChain=%s height=%d\n", hashBestChain.ToString().substr(0,14).c_str(), nBestHeight); + // Verify the tail of the best chain, and fall back to the last good block + // if any of it fails. + // + // A node that accepted blocks its current rules would reject -- because it + // was running an older build, or because the tip it landed on was never + // valid -- has no way to notice on its own. It keeps the branch, keeps + // building on it, and keeps mining coins that the rest of the network + // throws away. The only cure used to be deleting blk*.dat by hand, which + // requires knowing something is wrong in the first place. That is how a + // node here once mined a 323-block fork for five days. + // + // Checking every block on every start would mean a RandomX hash per block, + // so only the tail is checked by default. /checkblocks=0 does the lot. + int nCheckBlocks = nCheckBlocksOnLoad; + CBlockIndex* pindexFork = NULL; + int nChecked = 0; + for (CBlockIndex* pindex = pindexBest; pindex && pindex->pprev; pindex = pindex->pprev) + { + if (nCheckBlocks > 0 && nChecked >= nCheckBlocks) + break; + nChecked++; + CBlock block; + if (!block.ReadFromDisk(pindex->nFile, pindex->nBlockPos, true)) + return error("LoadBlockIndex() : ReadFromDisk failed at height %d", pindex->nHeight); + if (!block.CheckBlock()) + { + printf("LoadBlockIndex() : *** bad block at height %d, hash=%s\n", + pindex->nHeight, pindex->GetBlockHash().ToString().substr(0,14).c_str()); + // Keep going: an older bad block further back wins, because + // everything after it has to come off too. + pindexFork = pindex->pprev; + } + } + printf("LoadBlockIndex(): verified %d block(s)\n", nChecked); + + // The repair itself happens in the caller, after this handle is closed. + // The cursor above is still open and holds read locks, so a write + // transaction opened here waits on it forever -- measured: the node came + // up, printed the diagnosis, and hung with no error anywhere. + pindexBadChainFork = pindexFork; + return true; } diff --git a/src/main.cpp b/src/main.cpp index 15805ca..dbb2c41 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -49,6 +49,15 @@ uint256 hashGenesisMerkleRoot("0x1a45b4482532abb29b10e234d3f13132230525a339ecea9 CBlockIndex* pindexGenesisBlock = NULL; int nBestHeight = -1; uint256 hashBestChain = 0; +// Blocks at the tip to re-verify on startup. 0 means the whole chain. 288 is +// about ten hours at a two-minute target -- far enough back to catch a bad tip, +// short enough that startup does not pay a RandomX hash per block for the whole +// history. /checkblocks=N overrides it. +int nCheckBlocksOnLoad = 288; +// Set by CTxDB::LoadBlockIndex when the tail of the chain fails verification. +// The repair runs in LoadBlockIndex() below, once that read-only handle and its +// cursor are closed. +CBlockIndex* pindexBadChainFork = NULL; CBlockIndex* pindexBest = NULL; map mapOrphanBlocks; @@ -1577,6 +1586,72 @@ bool LoadBlockIndex(bool fAllowNew) return false; txdb.Close(); + // Repair a bad tip found during verification. + // + // This has to happen after the handle above is closed. CTxDB::LoadBlockIndex + // walks the index with a cursor it never closes, so it is still holding read + // locks -- a write transaction opened alongside it waits on them forever. + // Measured, not guessed: the node came up, printed the diagnosis, and hung + // there with nothing in any log. + if (pindexBadChainFork) + { + CBlockIndex* pindexFork = pindexBadChainFork; + pindexBadChainFork = NULL; + + printf("LoadBlockIndex() : *** moving best chain back to height %d, discarding %d block(s)\n", + pindexFork->nHeight, nBestHeight - pindexFork->nHeight); + + vector vDisconnect; + for (CBlockIndex* pindex = pindexBest; pindex && pindex != pindexFork; pindex = pindex->pprev) + vDisconnect.push_back(pindex); + + bool fRolledBack = false; + try + { + CTxDB txdbWrite; + txdbWrite.TxnBegin(); + foreach(CBlockIndex* pindex, vDisconnect) + { + CBlock block; + if (!block.ReadFromDisk(pindex->nFile, pindex->nBlockPos, true)) + throw runtime_error("ReadFromDisk for disconnect failed"); + if (!block.DisconnectBlock(txdbWrite, pindex)) + throw runtime_error("DisconnectBlock failed"); + } + if (!txdbWrite.WriteHashBestChain(pindexFork->GetBlockHash())) + throw runtime_error("WriteHashBestChain failed"); + txdbWrite.TxnCommit(); + txdbWrite.Close(); + fRolledBack = true; + } + catch (const std::exception& e) + { + printf("LoadBlockIndex() : rollback failed: %s\n", e.what()); + } + catch (...) + { + printf("LoadBlockIndex() : rollback failed\n"); + } + + if (fRolledBack) + { + foreach(CBlockIndex* pindex, vDisconnect) + if (pindex->pprev) + pindex->pprev->pnext = NULL; + + pindexBest = pindexFork; + hashBestChain = pindexBest->GetBlockHash(); + nBestHeight = pindexBest->nHeight; + printf("LoadBlockIndex() : now at height %d, will re-download from peers\n", nBestHeight); + } + else + { + // Start anyway. The operator needs a running node to read this from. + printf("LoadBlockIndex() : *** still on the bad chain at height %d -- back up the data directory and report this\n", + nBestHeight); + } + } + // // Init with genesis block // diff --git a/src/main.h b/src/main.h index 750d3f1..eea10b1 100644 --- a/src/main.h +++ b/src/main.h @@ -52,6 +52,8 @@ extern unsigned int GENESIS_NONCE; extern CBlockIndex* pindexGenesisBlock; extern int nBestHeight; extern uint256 hashBestChain; +extern int nCheckBlocksOnLoad; +extern CBlockIndex* pindexBadChainFork; extern CBlockIndex* pindexBest; extern unsigned int nTransactionsUpdated; extern string strSetDataDir; diff --git a/src/main_gui.cpp b/src/main_gui.cpp index 852ad4d..99bcb6f 100644 --- a/src/main_gui.cpp +++ b/src/main_gui.cpp @@ -54,6 +54,7 @@ static void PrintUsage() printf(" /participant=POOL_BTF_ADDRESS\n"); printf(" /solomine\n"); printf(" /genproclimit=N (mining threads; 0 or absent = every core but one)\n"); + printf(" /checkblocks=N (blocks re-verified at startup, default 288, 0 = all)\n"); printf("\n"); printf("Pool operator announcement:\n"); printf(" /poolname=NAME\n"); @@ -101,6 +102,10 @@ static void ParseStartupArguments(int argc, char* argv[]) if (arg(argc,argv,"/solomine") || arg(argc,argv,"-solomine")) fSoloMineTest = true; + string strCheckBlocks = argval2(argc, argv, "/checkblocks", "-checkblocks"); + if (!strCheckBlocks.empty()) + nCheckBlocksOnLoad = atoi(strCheckBlocks.c_str()); + // /genproclimit=N -- threads to hash with. 0 or absent means automatic, // which is every core but one. Named after Bitcoin's own option so it reads // familiarly to anyone who has run one of these before. -- cgit v1.2.3 From 7066648e6a871bd17984e02c04f22c2f22f2d744 Mon Sep 17 00:00:00 2001 From: Bitflash Date: Fri, 31 Jul 2026 08:53:31 -0300 Subject: wallet: believe the chain about what has already been spent (#62) fSpent lives in wallet.dat and is written when this node spends something. A wallet.dat restored from backup, or copied and used on another machine, carries whatever that flag was when the copy was taken -- so it can say "unspent" about coins the chain shows as gone. The balance reads high and nothing contradicts it until a send is attempted against coins that do not exist. #47 is about this shape of problem and #40 is someone living through it; Bitcoin fixed the same thing in 53d508072. On startup, after the block index is loaded and before anything reports a balance, walk the wallet and mark spent whatever the transaction index says is spent. One direction only. Marking spent when the chain says spent is safe; clearing the flag because the chain has not caught up would offer up coins already on their way out. fSpent is one flag per transaction rather than per output, which is how 0.1.0 has always worked -- CommitTransactionSpent already marks a whole previous transaction when it spends any part of it -- so this matches rather than changing the model. It reports every run, including the runs where it finds nothing, with a breakdown of why: RescanSpentFlags() : examined 17, already-spent 0, not-indexed 17, corrected 0 A check that speaks only when it finds something is indistinguishable from one that never ran, and that confusion has cost this project real time. The breakdown earned its place immediately: it is how the test below turned from a guess into an answer. Verified running against a wallet with 17 transactions and against an empty one, with no false corrections in either. The correcting branch itself is NOT exercised by a real case: on this chain no test wallet holds a transaction that is both in the transaction index and spent, and the counters show why -- all 17 were absent from the index, so the comparison never had anything to compare. --- src/main.cpp | 65 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/main.h | 2 ++ src/main_gui.cpp | 4 ++++ 3 files changed, 71 insertions(+) (limited to 'src/main_gui.cpp') diff --git a/src/main.cpp b/src/main.cpp index dbb2c41..85f89d9 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -693,6 +693,71 @@ bool CWalletTx::AcceptWalletTransaction(CTxDB& txdb, bool fCheckInputs) return true; } +int RescanSpentFlags() +{ + // Believe the chain, not the wallet, about what has already been spent. + // + // fSpent lives in wallet.dat and is written when this node spends + // something. A wallet.dat that was restored from backup, or copied and + // used on another machine, carries whatever that flag was at the moment + // the copy was taken -- so it can say "unspent" about coins the chain + // shows as gone. The balance then reads high, and the error only surfaces + // when a send is attempted against coins that no longer exist. + // + // Issue #47 is about this shape of problem and #40 reports living through + // it. Bitcoin fixed the same thing in 53d508072. + // + // One direction only. Marking spent when the chain says spent is safe; + // clearing the flag because the chain has not caught up yet would offer + // up coins that are already on their way out. + CTxDB txdb("r"); + int nCorrected = 0; + int nExamined = 0; + int nAlreadySpent = 0, nNotIndexed = 0; + CRITICAL_BLOCK(cs_mapWallet) + { + foreach(PAIRTYPE(const uint256, CWalletTx)& item, mapWallet) + { + CWalletTx& wtx = item.second; + nExamined++; + if (wtx.fSpent) + { nAlreadySpent++; continue; } + + CTxIndex txindex; + if (!txdb.ReadTxIndex(wtx.GetHash(), txindex)) + { nNotIndexed++; continue; } + + // fSpent is one flag for the whole transaction, not one per + // output -- CommitTransactionSpent already marks the entire + // previous transaction when it spends any part of it, so matching + // that here keeps the two consistent. + bool fSeenSpent = false; + for (int i = 0; i < (int)txindex.vSpent.size() && i < (int)wtx.vout.size(); i++) + if (!txindex.vSpent[i].IsNull() && wtx.vout[i].IsMine()) + fSeenSpent = true; + + if (fSeenSpent) + { + wtx.fSpent = true; + wtx.WriteToDisk(); + nCorrected++; + printf("RescanSpentFlags() : %s was spent on chain but the wallet did not know\n", + wtx.GetHash().ToString().substr(0,10).c_str()); + } + } + } + // Always say it ran. A check that reports only when it finds something is + // indistinguishable from a check that never executed, and this codebase has + // paid for that confusion more than once. + printf("RescanSpentFlags() : examined %d, already-spent %d, not-indexed %d, corrected %d\n", + nExamined, nAlreadySpent, nNotIndexed, nCorrected); + if (nCorrected) + printf("RescanSpentFlags() : this wallet was behind the chain about what it had already spent, " + "which is what a restored backup looks like\n"); + return nCorrected; +} + + void ReacceptWalletTransactions() { // Reaccept any txes of ours that aren't already in a block diff --git a/src/main.h b/src/main.h index eea10b1..2d5cb86 100644 --- a/src/main.h +++ b/src/main.h @@ -117,6 +117,8 @@ bool AddKey(const CKey& key); vector GenerateNewKey(); bool AddToWallet(const CWalletTx& wtxIn); void ReacceptWalletTransactions(); +// Returns how many wallet transactions the chain corrected. +int RescanSpentFlags(); void RelayWalletTransactions(); bool LoadBlockIndex(bool fAllowNew=true); void PrintBlockTree(); diff --git a/src/main_gui.cpp b/src/main_gui.cpp index 99bcb6f..c593162 100644 --- a/src/main_gui.cpp +++ b/src/main_gui.cpp @@ -243,6 +243,10 @@ int main(int argc, char* argv[]) try { if (!LoadWallet()) { fprintf(stderr, "LoadWallet failed\n"); return 1; } + + // After the block index, so there is a chain to compare the wallet + // against, and before anything reports a balance. + RescanSpentFlags(); } catch (const std::exception& e) { -- cgit v1.2.3 From b426948b966405b65f3ea6c1994164b462980694 Mon Sep 17 00:00:00 2001 From: Bitflash Date: Sat, 1 Aug 2026 12:44:04 -0300 Subject: mine: remember whether this node was mining, with the command line winning (#84) --- src/db.cpp | 39 +++++++++++++++++++++++++++------------ src/gui.cpp | 7 +++++++ src/main.cpp | 1 + src/main.h | 9 ++++++--- src/main_gui.cpp | 5 +++++ 5 files changed, 46 insertions(+), 15 deletions(-) (limited to 'src/main_gui.cpp') diff --git a/src/db.cpp b/src/db.cpp index 16f2e91..a503cae 100644 --- a/src/db.cpp +++ b/src/db.cpp @@ -642,16 +642,29 @@ bool CWalletDB::LoadWallet(vector& vchDefaultKeyRet) ssKey >> strKey; if (strKey == "nTransactionFee") ssValue >> nTransactionFee; if (strKey == "addrIncoming") ssValue >> addrIncoming; - // Mining-mode/pool settings (nMineMode, strParticipantPool, strPoolName, - // strPoolDashboardUrl, dPoolFeePercent, fGenerateBitcoins) are intentionally - // NOT restored here. They used to be, and LoadWallet() runs after CLI flags - // are parsed in main_gui.cpp, so a saved value would silently overwrite - // whatever was just requested on the command line or in a previous Options - // session -- no error, no log line. That's what caused pool name/address to - // show correctly in the UI but stay stale in the logs, and mode to change - // behavior across restarts. Mode/pool config is decided fresh every launch - // instead: CLI flags if given, otherwise the compiled-in default. Nothing to - // go stale, nothing to fight over load order. + + // Mining mode is restored, but only when no flag chose one. + // + // These settings used to be restored unconditionally. LoadWallet() + // runs after main_gui.cpp has parsed the command line, so a stored + // value silently overwrote what had just been asked for -- no error, + // no log line -- and the fix at the time was to stop restoring them + // at all. That traded a silent override for a different silent + // failure: a machine set to mine came back from every restart as a + // plain relay, looking exactly like one that had been asked to + // relay. A 32-core miner sat idle for hours that way, and the only + // evidence was memory usage that never climbed. + // + // Precedence is now explicit rather than accidental: a flag wins, + // and it says so in the log below. Only the two fields that decide + // whether this node mines are restored, plus the pool address a + // participant cannot work without. + if (!fMineModeFromCommandLine) + { + if (strKey == "nMineMode") ssValue >> nMineMode; + if (strKey == "fGenerateBitcoins") ssValue >> fGenerateBitcoins; + if (strKey == "strParticipantPool") ssValue >> strParticipantPool; + } } } } @@ -673,8 +686,10 @@ bool CWalletDB::LoadWallet(vector& vchDefaultKeyRet) printf("nTransactionFee = %lld\n", nTransactionFee); printf("addrIncoming = %s\n", addrIncoming.ToString().c_str()); - printf("nMineMode = %d, strParticipantPool = %s, fGenerateBitcoins = %d (from CLI/default, not wallet.dat)\n", - nMineMode, strParticipantPool.c_str(), fGenerateBitcoins); + printf("nMineMode = %d, strParticipantPool = %s, fGenerateBitcoins = %d (%s)\n", + nMineMode, strParticipantPool.c_str(), fGenerateBitcoins, + fMineModeFromCommandLine ? "from the command line, wallet.dat ignored" + : "remembered from wallet.dat, or the default"); printf("strPoolName = %s, dPoolFeePercent = %.2f, strPoolDashboardUrl = %s (from CLI/default, not wallet.dat)\n", strPoolName.c_str(), dPoolFeePercent, strPoolDashboardUrl.c_str()); diff --git a/src/gui.cpp b/src/gui.cpp index db6b69e..e2bb5cf 100644 --- a/src/gui.cpp +++ b/src/gui.cpp @@ -795,6 +795,13 @@ static void DrawOptionsDialog() if (!vfThreadRunning[3]) StartMinerThreads(); } + // Remember the choice. Without this the node came back from every + // restart as a plain relay, and a machine that had been mining for + // days looked identical to one that had never been asked to. + CWalletDB().WriteSetting("nMineMode", nMineMode); + CWalletDB().WriteSetting("fGenerateBitcoins", fGenerateBitcoins); + CWalletDB().WriteSetting("strParticipantPool", strParticipantPool); + if (nMineMode == MINE_OPERATOR) { if (!gPoolServerRunning) { gPoolServerRunning = true; diff --git a/src/main.cpp b/src/main.cpp index 1a4c9ea..f63f575 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -91,6 +91,7 @@ int nMinerThreads = 0; int64 nTransactionFee = 0; CAddress addrIncoming; int nMineMode = MINE_RELAY; +bool fMineModeFromCommandLine = false; string strParticipantPool; // participant mode: pool .btf address string strPoolName = "Bitflash Pool"; string strPoolDashboardUrl; diff --git a/src/main.h b/src/main.h index 7266e3d..a50485f 100644 --- a/src/main.h +++ b/src/main.h @@ -64,9 +64,8 @@ extern CBlockIndex* pindexBest; extern unsigned int nTransactionsUpdated; extern string strSetDataDir; extern int nDropMessagesTest; -// Mining mode -- persisted in wallet.dat -// Mining mode -- decided fresh every launch (CLI flags, else default). Never -// restored from wallet.dat; see LoadWallet() in db.cpp. +// Mining mode -- remembered in wallet.dat, but the command line always wins. +// See LoadWallet() in db.cpp for why the precedence has to be explicit. // MINE_RELAY (0): default. Node runs/syncs, never mines, no pool server. // MINE_SOLO (1): mine to own wallet, no pool server // MINE_OPERATOR (2): run pool server, mine to own wallet, distribute to miners @@ -76,6 +75,10 @@ extern int nDropMessagesTest; #define MINE_OPERATOR 2 #define MINE_PARTICIPANT 3 extern int nMineMode; +// Set by the startup parser when a flag chose the mode. LoadWallet leaves the +// stored mode alone when this is true, so a flag is never silently overruled by +// something a previous session wrote. +extern bool fMineModeFromCommandLine; extern string strParticipantPool; // participant: pool .btf address extern string strPoolName; // operator: announced pool name extern string strPoolDashboardUrl; // operator: optional dashboard URL diff --git a/src/main_gui.cpp b/src/main_gui.cpp index c593162..5c87fe0 100644 --- a/src/main_gui.cpp +++ b/src/main_gui.cpp @@ -97,6 +97,7 @@ static void ParseStartupArguments(int argc, char* argv[]) // their own mode below, so only the unqualified case is affected. if (nMineMode == MINE_RELAY) nMineMode = MINE_SOLO; + fMineModeFromCommandLine = true; } if (arg(argc,argv,"/solomine") || arg(argc,argv,"-solomine")) @@ -127,12 +128,16 @@ static void ParseStartupArguments(int argc, char* argv[]) } if (arg(argc,argv,"/operator") || arg(argc,argv,"-operator")) + { nMineMode = MINE_OPERATOR; + fMineModeFromCommandLine = true; + } if (arg(argc,argv,"/participant") || arg(argc,argv,"-participant")) { nMineMode = MINE_PARTICIPANT; strParticipantPool = argval2(argc, argv, "/participant", "-participant"); + fMineModeFromCommandLine = true; } string poolName = argval2(argc, argv, "/poolname", "-poolname"); -- cgit v1.2.3 From b65c19e559e2fec511f40a4d5481bcf6a35c311c Mon Sep 17 00:00:00 2001 From: Bitflash Date: Sat, 1 Aug 2026 14:48:27 -0300 Subject: wallet: walk the chain for coins a key owns but the wallet never saw (#88) A transaction only entered the wallet when its block arrived over the network, so a key added afterwards was invisible: coins on the chain, private key in wallet.dat, balance zero. /importwallet has always ended that way -- it writes the keys and the money never shows up, which looks exactly like a failed import. ScanForWalletTransactions walks the main chain and hands every transaction that IsMine() to AddToWalletIfMine, the same two primitives the live path uses. Wired into /importwallet, and offered as /rescan. It is also the half that any recovery-phrase feature has to stand on: deriving keys from twelve words is easy, and without this a restore restores nothing. --- src/main.cpp | 49 +++++++++++++++++++++++++++++++++++++++++++++++++ src/main.h | 5 +++++ src/main_gui.cpp | 26 ++++++++++++++++++++++++++ 3 files changed, 80 insertions(+) (limited to 'src/main_gui.cpp') diff --git a/src/main.cpp b/src/main.cpp index b027cef..68ae44c 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -305,6 +305,55 @@ bool AddToWalletIfMine(const CTransaction& tx, const CBlock* pblock) return true; } +int ScanForWalletTransactions(CBlockIndex* pindexStart) +{ + // Walk the chain looking for transactions that belong to keys this wallet + // holds now, whatever it held when those blocks arrived. + // + // Nothing in this tree could do that. A key added after the fact -- from + // /importwallet, or from a backup restored onto a node that had since + // moved on -- was simply invisible: the coins were on the chain, the + // private key was in wallet.dat, and the balance did not show them, + // because a transaction only ever entered the wallet at the moment its + // block arrived over the network. + // + // Bitcoin grew the same function for the same reason. It is also what any + // recovery-phrase feature has to stand on: deriving the keys is the easy + // half, and without this the other half does not exist. + int nFound = 0; + int nScanned = 0; + CBlockIndex* pindex = pindexStart; + CRITICAL_BLOCK(cs_main) + { + while (pindex) + { + CBlock block; + if (block.ReadFromDisk(pindex, true)) + { + foreach(CTransaction& tx, block.vtx) + { + // Ask before handing it over: AddToWalletIfMine returns + // true for a transaction that is none of our business, so + // its return value cannot be counted. + if (tx.IsMine()) + { + if (AddToWalletIfMine(tx, &block)) + nFound++; + } + } + } + nScanned++; + if ((nScanned % 500) == 0) + printf("ScanForWalletTransactions() : %d blocks scanned, %d transaction(s) found\n", + nScanned, nFound); + pindex = pindex->pnext; + } + } + printf("ScanForWalletTransactions() : %d block(s) scanned, %d transaction(s) added or updated\n", + nScanned, nFound); + return nFound; +} + bool EraseFromWallet(uint256 hash) { CRITICAL_BLOCK(cs_mapWallet) diff --git a/src/main.h b/src/main.h index 1d9efac..8d9b7e6 100644 --- a/src/main.h +++ b/src/main.h @@ -152,6 +152,11 @@ void TopUpKeyPool(); vector GetKeyFromPool(); bool AddToWallet(const CWalletTx& wtxIn); void ReacceptWalletTransactions(); +// Walk the chain from pindexStart forward, adding every transaction that pays +// a key this wallet holds. Returns how many were added or updated. Needed by +// anything that puts a key into the wallet after the fact -- an import, a +// restored backup, and later a recovery phrase. +int ScanForWalletTransactions(CBlockIndex* pindexStart); // Returns how many wallet transactions the chain corrected. int RescanSpentFlags(); void RelayWalletTransactions(); diff --git a/src/main_gui.cpp b/src/main_gui.cpp index 5c87fe0..c9e27a9 100644 --- a/src/main_gui.cpp +++ b/src/main_gui.cpp @@ -76,6 +76,8 @@ static void PrintUsage() printf(" /dumpwallet=FILE (export private keys as text -- readable by\n"); printf(" anyone, so guard it like cash)\n"); printf(" /importwallet=FILE (load keys from such a file back in)\n"); + printf(" /rescan (walk the chain for coins this wallet owns\n"); + printf(" but never recorded, then exit)\n"); printf("\n"); printf("Each option also accepts '-' instead of '/'.\n"); } @@ -307,13 +309,37 @@ int main(int argc, char* argv[]) int nAdded = 0, nSkipped = 0; bool fOk = ImportWallet(strImport, nAdded, nSkipped); if (fOk) + { printf("Imported %d key(s); %d were already here.\n", nAdded, nSkipped); + // Importing a key without looking for its coins leaves the user + // holding a wallet that says zero about money that is on the + // chain. That was the old behaviour and it looks exactly like the + // import having failed. + if (nAdded > 0) + { + printf("Looking through the chain for coins belonging to the imported key(s)...\n"); + int nFound = ScanForWalletTransactions(pindexGenesisBlock); + printf("Found %d transaction(s). Start the node normally to see the balance.\n", nFound); + } + } else fprintf(stderr, "Import failed.\n"); DBFlush(true); return fOk ? 0 : 1; } + // /rescan -- walk the chain and pick up anything the wallet's keys own but + // the wallet never recorded. Cheap to offer and the only recourse when a + // balance is wrong for this reason. + if (arg(argc,argv,"/rescan") || arg(argc,argv,"-rescan")) + { + printf("Rescanning the chain for this wallet's transactions...\n"); + int nFound = ScanForWalletTransactions(pindexGenesisBlock); + printf("Rescan done: %d transaction(s) added or updated.\n", nFound); + DBFlush(true); + return 0; + } + ReacceptWalletTransactions(); if (!StartNode(strErrors)) { fprintf(stderr,"StartNode: %s\n",strErrors.c_str()); return 1; } -- cgit v1.2.3 From a2726cad9e9e11c73ae341ec627a696cdeb63edc Mon Sep 17 00:00:00 2001 From: mtg0001 <106211915+mtg0001@users.noreply.github.com> Date: Sat, 1 Aug 2026 15:15:15 -0300 Subject: Add wallet keypool self-test (#91) Co-authored-by: Gilcimar Duarte --- src/Makefile | 9 +- src/main.cpp | 5 + src/main_gui.cpp | 6 ++ src/makefile.mingw | 9 +- src/selftest.cpp | 275 +++++++++++++++++++++++++++++++++++++++++++++++++++++ src/selftest.h | 11 +++ 6 files changed, 309 insertions(+), 6 deletions(-) create mode 100644 src/selftest.cpp create mode 100644 src/selftest.h (limited to 'src/main_gui.cpp') diff --git a/src/Makefile b/src/Makefile index 4e104c0..248e62c 100644 --- a/src/Makefile +++ b/src/Makefile @@ -41,14 +41,16 @@ LIBS = -L$(SECP_LIB) -lsecp256k1 \ HEADERS = headers_core.h headers.h util.h main.h serialize.h uint256.h \ key.h bignum.h script.h db.h base58.h net.h nostr.h \ - randomx_pow.h btfaddr.h btfchan.h btfrv.h btftunnel.h market.h compat.h + randomx_pow.h btfaddr.h btfchan.h btfrv.h btftunnel.h market.h \ + selftest.h compat.h IMGUI_OBJS = obj/imgui.o obj/imgui_draw.o obj/imgui_tables.o \ obj/imgui_widgets.o obj/imgui_impl_glfw.o obj/imgui_impl_opengl3.o NODE_OBJS = obj/util.o obj/script.o obj/db.o obj/net.o obj/main.o \ obj/market.o obj/sha.o obj/nostr.o obj/randomx_pow.o \ - obj/btfaddr.o obj/btfchan.o obj/btfrv.o obj/btftunnel.o obj/rpc.o + obj/btfaddr.o obj/btfchan.o obj/btfrv.o obj/btftunnel.o obj/rpc.o \ + obj/selftest.o GUI_OBJS = obj/gui.o obj/main_gui.o @@ -126,5 +128,6 @@ clean: test_bip32: test_bip32.cpp bip32.cpp bip32.h bip39_english.h $(CXX) $(CXXFLAGS) -o $@ test_bip32.cpp bip32.cpp -lssl -lcrypto -tests: test_bip32 +tests: test_bip32 bitflash-node ./test_bip32 + ./bitflash-node /selftest=wallet-keypool diff --git a/src/main.cpp b/src/main.cpp index 68ae44c..82a98d2 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -217,6 +217,11 @@ vector GetKeyFromPool() vector vchPubKey = mi->second; mapKeyPool.erase(mi); CWalletDB().ErasePool(nIndex); + // Leave the wallet with a full backup window after each draw too, + // not only immediately before a draw. If topping up fails, the key + // already chosen is still safe to use -- its private half was + // written before it entered the pool. + TopUpKeyPool(); return vchPubKey; } } diff --git a/src/main_gui.cpp b/src/main_gui.cpp index c9e27a9..2bcdb83 100644 --- a/src/main_gui.cpp +++ b/src/main_gui.cpp @@ -1,6 +1,7 @@ // Bitflash entry point -- starts node threads then runs GUI (or headless). #include "headers_core.h" +#include "selftest.h" #include // hardware_concurrency, to sanity-check /genproclimit #ifndef _WIN32 #include @@ -48,6 +49,7 @@ static void PrintUsage() printf(" /debug\n"); printf(" /gen\n"); printf(" /nogui or /daemon\n"); + printf(" /selftest=wallet-keypool\n"); printf("\n"); printf("Mining mode:\n"); printf(" /operator\n"); @@ -219,6 +221,10 @@ int main(int argc, char* argv[]) ParseStartupArguments(argc, argv); + string strSelfTest = argval2(argc, argv, "/selftest", "-selftest"); + if (!strSelfTest.empty()) + return RunSelfTest(strSelfTest); + // Berkeley DB reports failure by throwing, and CDB's constructor lets it // through. Nothing on this path caught anything, so an unreadable // blkindex.dat or wallet.dat -- one written by another platform's Berkeley diff --git a/src/makefile.mingw b/src/makefile.mingw index e4dee59..c263f4e 100644 --- a/src/makefile.mingw +++ b/src/makefile.mingw @@ -41,13 +41,15 @@ IMGUI_OBJS = obj/imgui.o obj/imgui_draw.o obj/imgui_tables.o \ NODE_OBJS = obj/util.o obj/script.o obj/db.o obj/net.o obj/main.o \ obj/market.o obj/sha.o obj/nostr.o obj/randomx_pow.o \ - obj/btfaddr.o obj/btfchan.o obj/btfrv.o obj/btftunnel.o obj/rpc.o + obj/btfaddr.o obj/btfchan.o obj/btfrv.o obj/btftunnel.o obj/rpc.o \ + obj/selftest.o GUI_OBJS = obj/gui.o obj/main_gui.o obj/bitflash_res.o HEADERS = headers_core.h headers.h util.h main.h serialize.h uint256.h \ key.h bignum.h script.h db.h base58.h net.h nostr.h \ - randomx_pow.h btfaddr.h btfchan.h btfrv.h btftunnel.h market.h + randomx_pow.h btfaddr.h btfchan.h btfrv.h btftunnel.h market.h \ + selftest.h all: bitflash.exe @@ -98,5 +100,6 @@ test_bip32.exe: test_bip32.cpp bip32.cpp bip32.h bip39_english.h $(CXX) $(CXXFLAGS) -o $@ test_bip32.cpp bip32.cpp -lssl -lcrypto -lcrypt32 @echo "Built: test_bip32.exe" -tests: test_bip32.exe +tests: test_bip32.exe bitflash.exe ./test_bip32.exe + ./bitflash.exe /selftest=wallet-keypool diff --git a/src/selftest.cpp b/src/selftest.cpp new file mode 100644 index 0000000..d649b23 --- /dev/null +++ b/src/selftest.cpp @@ -0,0 +1,275 @@ +// Copyright (c) 2026 Bitflash developers +// Distributed under the MIT/X11 software license. +// +// Integration self-tests that need the real wallet/database code linked into +// the node. These are intentionally run behind an explicit command-line flag +// and against a temporary data directory. + +#include "headers_core.h" +#include "selftest.h" + +#include +#include +#include + +#ifdef _WIN32 +#include +#else +#include +#include +#include +#include +#endif + +static std::string HexStrLocal(const std::vector& v) +{ + static const char* hex = "0123456789abcdef"; + std::string out; + out.reserve(v.size() * 2); + for (unsigned char c : v) + { + out.push_back(hex[c >> 4]); + out.push_back(hex[c & 0x0f]); + } + return out; +} + +static bool MakeTempDir(std::string& out) +{ +#ifdef _WIN32 + char base[MAX_PATH + 1] = {0}; + if (!GetTempPathA(MAX_PATH, base)) + return false; + for (int i = 0; i < 100; i++) + { + std::string path = strprintf("%sbitflash-selftest-%lu-%lld-%d", + base, (unsigned long)GetCurrentProcessId(), (long long)GetTime(), i); + if (CreateDirectoryA(path.c_str(), NULL)) + { + out = path; + return true; + } + } + return false; +#else + char tmpl[256]; + snprintf(tmpl, sizeof(tmpl), "/tmp/bitflash-selftest-%ld-XXXXXX", (long)getpid()); + char* p = mkdtemp(tmpl); + if (!p) + return false; + out = p; + return true; +#endif +} + +static bool GetCurrentDir(std::string& out) +{ +#ifdef _WIN32 + char buf[MAX_PATH + 1] = {0}; + DWORD n = GetCurrentDirectoryA(MAX_PATH, buf); + if (n == 0 || n > MAX_PATH) + return false; + out = buf; + return true; +#else + char buf[4096]; + if (!getcwd(buf, sizeof(buf))) + return false; + out = buf; + return true; +#endif +} + +static bool SetCurrentDir(const std::string& path) +{ +#ifdef _WIN32 + return SetCurrentDirectoryA(path.c_str()) != 0; +#else + return chdir(path.c_str()) == 0; +#endif +} + +static void RemoveTree(const std::string& path) +{ +#ifdef _WIN32 + DWORD attr = GetFileAttributesA(path.c_str()); + if (attr == INVALID_FILE_ATTRIBUTES) + return; + if (!(attr & FILE_ATTRIBUTE_DIRECTORY)) + { + DeleteFileA(path.c_str()); + return; + } + + WIN32_FIND_DATAA findData; + std::string pattern = path + "\\*"; + HANDLE hFind = FindFirstFileA(pattern.c_str(), &findData); + if (hFind != INVALID_HANDLE_VALUE) + { + do + { + std::string name = findData.cFileName; + if (name == "." || name == "..") + continue; + RemoveTree(path + "\\" + name); + } + while (FindNextFileA(hFind, &findData)); + FindClose(hFind); + } + RemoveDirectoryA(path.c_str()); +#else + struct stat st; + if (lstat(path.c_str(), &st) != 0) + return; + if (!S_ISDIR(st.st_mode)) + { + unlink(path.c_str()); + return; + } + + DIR* dir = opendir(path.c_str()); + if (dir) + { + struct dirent* ent; + while ((ent = readdir(dir)) != NULL) + { + std::string name = ent->d_name; + if (name == "." || name == "..") + continue; + RemoveTree(path + "/" + name); + } + closedir(dir); + } + rmdir(path.c_str()); +#endif +} + +static bool WalletHasPrivateKey(const std::vector& vchPubKey) +{ + CPrivKey priv; + return CWalletDB("r").ReadKey(vchPubKey, priv) && !priv.empty(); +} + +static bool Check(bool condition, const char* message) +{ + if (condition) + { + printf(" ok %s\n", message); + return true; + } + printf(" FAIL %s\n", message); + return false; +} + +static int RunWalletKeyPoolSelfTest() +{ + printf("wallet-keypool self-test\n"); + + std::string tmp; + std::string cwd; + if (!MakeTempDir(tmp)) + { + printf(" FAIL could not create a temporary data directory\n"); + return 1; + } + if (!GetCurrentDir(cwd) || !SetCurrentDir(tmp)) + { + printf(" FAIL could not move into the temporary data directory\n"); + RemoveTree(tmp); + return 1; + } + + int nFail = 0; + strSetDataDir = tmp; + printf(" temp datadir: %s\n", tmp.c_str()); + + try + { + if (!LoadWallet()) + throw std::runtime_error("LoadWallet failed"); + + TopUpKeyPool(); + int nInitialPoolSize = 0; + CRITICAL_BLOCK(cs_keyPool) + nInitialPoolSize = (int)mapKeyPool.size(); + nFail += Check(nInitialPoolSize == KEYPOOL_SIZE, "TopUpKeyPool fills the pool") ? 0 : 1; + + bool fPooledKeysStored = true; + CRITICAL_BLOCK(cs_keyPool) + { + for (map >::const_iterator mi = mapKeyPool.begin(); + mi != mapKeyPool.end(); ++mi) + if (!WalletHasPrivateKey(mi->second)) + fPooledKeysStored = false; + } + nFail += Check(fPooledKeysStored, "every pooled public key has a stored private key") ? 0 : 1; + + std::vector > drawn; + std::mutex drawnMutex; + const int nThreads = 8; + const int nDrawsPerThread = 32; + std::vector threads; + + for (int t = 0; t < nThreads; t++) + { + threads.emplace_back([&drawn, &drawnMutex, nDrawsPerThread]() { + for (int i = 0; i < nDrawsPerThread; i++) + { + std::vector key = GetKeyFromPool(); + std::lock_guard lock(drawnMutex); + drawn.push_back(key); + } + }); + } + for (std::thread& t : threads) + t.join(); + + nFail += Check((int)drawn.size() == nThreads * nDrawsPerThread, + "all concurrent draws returned a key") ? 0 : 1; + + std::set seen; + bool fUnique = true; + bool fAllStored = true; + for (const std::vector& key : drawn) + { + if (!seen.insert(HexStrLocal(key)).second) + fUnique = false; + if (!WalletHasPrivateKey(key)) + fAllStored = false; + } + + nFail += Check(fUnique, "concurrent draws never return the same key") ? 0 : 1; + nFail += Check(fAllStored, "each drawn key was already stored in wallet.dat") ? 0 : 1; + int nPoolSize = 0; + CRITICAL_BLOCK(cs_keyPool) + nPoolSize = (int)mapKeyPool.size(); + nFail += Check(nPoolSize == KEYPOOL_SIZE, "drawing leaves the key pool topped up") ? 0 : 1; + } + catch (const std::exception& e) + { + printf(" FAIL exception: %s\n", e.what()); + nFail++; + } + catch (...) + { + printf(" FAIL unknown exception\n"); + nFail++; + } + + DBFlush(true); + SetCurrentDir(cwd); + RemoveTree(tmp); + printf("%s (%d failure%s)\n", nFail == 0 ? "ALL TESTS PASSED" : "TESTS FAILED", + nFail, nFail == 1 ? "" : "s"); + return nFail == 0 ? 0 : 1; +} + +int RunSelfTest(const std::string& name) +{ + if (name == "wallet-keypool") + return RunWalletKeyPoolSelfTest(); + + printf("Unknown self-test '%s'\n", name.c_str()); + printf("Known self-tests: wallet-keypool\n"); + return 1; +} diff --git a/src/selftest.h b/src/selftest.h new file mode 100644 index 0000000..d3699c6 --- /dev/null +++ b/src/selftest.h @@ -0,0 +1,11 @@ +// Copyright (c) 2026 Bitflash developers +// Distributed under the MIT/X11 software license. + +#ifndef BITFLASH_SELFTEST_H +#define BITFLASH_SELFTEST_H + +#include + +int RunSelfTest(const std::string& name); + +#endif -- cgit v1.2.3 From ce59d8c72c667ce638bd12f37db26d8b3408a5d2 Mon Sep 17 00:00:00 2001 From: Bitflash Date: Sat, 1 Aug 2026 17:53:17 -0300 Subject: wallet: derive the key pool from a seed, so a phrase could bring it back (#93) Adds BIP32 derivation behind the wallet key pool, with no user-facing way to create a seed yet. wallet.dat gains hdmaster/hdchaincode/hdnext; the mnemonic is never stored. TopUpKeyPool derives m/n' when a seed exists and falls back to a random key, with a log line, if derivation fails. New -selftest=wallet-hd, wired into make tests on both platforms. --- src/Makefile | 3 +- src/db.cpp | 12 +++++ src/db.h | 27 ++++++++++ src/key.h | 34 ++++++++++++ src/main.cpp | 112 +++++++++++++++++++++++++++++++++++++++- src/main.h | 28 ++++++++++ src/main_gui.cpp | 18 +++++++ src/makefile.mingw | 3 +- src/selftest.cpp | 149 ++++++++++++++++++++++++++++++++++++++++++++++++++++- 9 files changed, 382 insertions(+), 4 deletions(-) (limited to 'src/main_gui.cpp') diff --git a/src/Makefile b/src/Makefile index d4b1837..bbdc9e2 100644 --- a/src/Makefile +++ b/src/Makefile @@ -50,7 +50,7 @@ IMGUI_OBJS = obj/imgui.o obj/imgui_draw.o obj/imgui_tables.o \ NODE_OBJS = obj/util.o obj/script.o obj/db.o obj/net.o obj/main.o \ obj/market.o obj/sha.o obj/nostr.o obj/randomx_pow.o \ obj/btfaddr.o obj/btfchan.o obj/btfrv.o obj/btftunnel.o obj/rpc.o \ - obj/selftest.o + obj/selftest.o obj/bip32.o GUI_OBJS = obj/gui.o obj/main_gui.o @@ -131,3 +131,4 @@ test_bip32: test_bip32.cpp bip32.cpp bip32.h bip39_english.h tests: test_bip32 bitflash-node ./test_bip32 ./bitflash-node -selftest=wallet-keypool + ./bitflash-node -selftest=wallet-hd diff --git a/src/db.cpp b/src/db.cpp index d482c5a..294920b 100644 --- a/src/db.cpp +++ b/src/db.cpp @@ -639,6 +639,18 @@ bool CWalletDB::LoadWallet(vector& vchDefaultKeyRet) { ssValue >> vchDefaultKeyRet; } + else if (strType == "hdmaster") + { + ssValue >> vchHDMaster; + } + else if (strType == "hdchaincode") + { + ssValue >> vchHDChainCode; + } + else if (strType == "hdnext") + { + ssValue >> nHDNext; + } else if (strType == "pool") { int64 nIndex; diff --git a/src/db.h b/src/db.h index f51047e..01b8583 100644 --- a/src/db.h +++ b/src/db.h @@ -386,6 +386,33 @@ public: return Write(string("defaultkey"), vchPubKey); } + // --- Deterministic (BIP32) seed --------------------------------------- + // + // Stored as the master key and chain code rather than the mnemonic: the + // twelve words are the user's to write down, and keeping a copy of them in + // the file they are meant to protect defeats the point of having them. + // + // "hdnext" is the next child index to derive, so the sequence a restore + // reproduces is the same one this wallet handed out. + bool ReadHDMaster(vector& vchMasterRet, vector& vchChainCodeRet) + { + vchMasterRet.clear(); + vchChainCodeRet.clear(); + return Read(string("hdmaster"), vchMasterRet) + && Read(string("hdchaincode"), vchChainCodeRet); + } + + bool WriteHDMaster(const vector& vchMaster, const vector& vchChainCode) + { + return Write(string("hdmaster"), vchMaster) + && Write(string("hdchaincode"), vchChainCode); + } + + bool WriteHDNext(unsigned int nNext) + { + return Write(string("hdnext"), nNext); + } + // Keys generated ahead of time and not yet handed out. The private key of // each is already stored under its own "key" record by AddKey; a "pool" // record only says that this one is still unspoken for. diff --git a/src/key.h b/src/key.h index 26e1ae2..6cc2a24 100644 --- a/src/key.h +++ b/src/key.h @@ -86,6 +86,40 @@ public: return true; } + // Load a raw 32-byte scalar, the form BIP32 derivation produces. + // + // SetPrivKey above takes DER and cannot be used for this: handing it a + // scalar makes d2i_ECPrivateKey fail, and if the caller ignores the return + // value -- as one attempt at this did -- the key is left with no material + // at all and the address it produces has no private half. Coins paid there + // are gone. Hence a separate entry point rather than a looser SetPrivKey. + // + // The public half is computed here rather than read from anywhere: + // pub = G * secret. + bool SetSecret(const std::vector& vchSecret) + { + if (vchSecret.size() != 32) + return false; + + bool fOk = false; + BIGNUM* bn = BN_bin2bn(&vchSecret[0], 32, NULL); + EC_POINT* pubkey = NULL; + if (bn && EC_KEY_set_private_key(pkey, bn)) + { + const EC_GROUP* group = EC_KEY_get0_group(pkey); + pubkey = EC_POINT_new(group); + if (pubkey && + EC_POINT_mul(group, pubkey, bn, NULL, NULL, NULL) && + EC_KEY_set_public_key(pkey, pubkey)) + fOk = true; + } + if (pubkey) + EC_POINT_free(pubkey); + if (bn) + BN_clear_free(bn); // it held the secret + return fOk; + } + CPrivKey GetPrivKey() const { unsigned int nSize = i2d_ECPrivateKey(pkey, NULL); diff --git a/src/main.cpp b/src/main.cpp index 82a98d2..e5754e6 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -14,6 +14,7 @@ #include #include #include +#include "bip32.h" #include "btfaddr.h" #include "btftunnel.h" #ifndef _WIN32 @@ -178,6 +179,66 @@ vector GenerateNewKey() CCriticalSection cs_keyPool; map > mapKeyPool; +vector vchHDMaster; +vector vchHDChainCode; +unsigned int nHDNext = 0; + +bool DeriveHDKey(unsigned int nIndex, CKey& keyRet, string& strErrorRet) +{ + if (!HaveHDSeed()) + { + strErrorRet = "no seed"; + return false; + } + + bitflash::BIP32PrivateNode parent; + parent.privateKey = vchHDMaster; + parent.chainCode = vchHDChainCode; + + bitflash::BIP32PrivateNode child; + if (!bitflash::BIP32DeriveHardenedChild(parent, nIndex, child, strErrorRet)) + return false; + + if (!keyRet.SetSecret(child.privateKey)) + { + strErrorRet = "derived scalar is not a usable key"; + return false; + } + return true; +} + +bool SetHDSeedFromMnemonic(const string& strMnemonic, string& strErrorRet) +{ + string strNormalized; + if (!bitflash::BIP39ValidateMnemonic(strMnemonic, strNormalized, strErrorRet)) + return false; + + vector vchSeed; + if (!bitflash::BIP39MnemonicToSeed(strNormalized, "", vchSeed, strErrorRet)) + return false; + + bitflash::BIP32PrivateNode master; + if (!bitflash::BIP32MasterFromSeed(vchSeed, master, strErrorRet)) + return false; + + CRITICAL_BLOCK(cs_keyPool) + { + // Written before the globals change, so a failed write leaves the + // wallet on the seed it already had rather than on one that exists + // only in memory. + if (!CWalletDB().WriteHDMaster(master.privateKey, master.chainCode) || + !CWalletDB().WriteHDNext(0)) + { + strErrorRet = "could not write the seed to wallet.dat"; + return false; + } + vchHDMaster = master.privateKey; + vchHDChainCode = master.chainCode; + nHDNext = 0; + } + return true; +} + void TopUpKeyPool() { CRITICAL_BLOCK(cs_keyPool) @@ -191,7 +252,30 @@ void TopUpKeyPool() nIndex = (--mapKeyPool.end())->first + 1; CKey key; - key.MakeNewKey(); + if (HaveHDSeed()) + { + // Derived, so a recovery phrase can reproduce this exact key. + // A derivation that fails is not a reason to stop filling the + // pool -- fall back to a random key, which is what a wallet + // without a seed uses anyway. It will not come back from the + // phrase, and that is better than a node that cannot mine. + string strError; + if (DeriveHDKey(nHDNext, key, strError)) + { + nHDNext++; + CWalletDB().WriteHDNext(nHDNext); + } + else + { + printf("TopUpKeyPool() : derivation at index %u failed (%s), " + "falling back to a random key\n", nHDNext, strError.c_str()); + key.MakeNewKey(); + } + } + else + { + key.MakeNewKey(); + } // AddKey writes the private key. Order matters: the key is in // wallet.dat before anything can point at it, so a crash between // the two writes costs an unused key, never a usable one. @@ -310,6 +394,32 @@ bool AddToWalletIfMine(const CTransaction& tx, const CBlock* pblock) return true; } +bool CanScanWalletTransactions(string& strErrorRet) +{ + strErrorRet.clear(); + + if (pindexGenesisBlock == NULL || pindexBest == NULL || hashBestChain == 0 || nBestHeight < 0) + { + strErrorRet = "The block index is not loaded, so the wallet scan cannot " + "prove whether imported or restored keys own coins."; + return false; + } + + if (nBestHeight == 0) + { + strErrorRet = "The local chain is only at height 0. A wallet scan would " + "inspect only the genesis block and could incorrectly " + "report zero transactions. Start the node and let it sync " + "before scanning. If this happened after copying a data " + "directory, keep wallet.dat safe and rebuild or re-download " + "the block data instead of trusting the copied database/ " + "environment."; + return false; + } + + return true; +} + int ScanForWalletTransactions(CBlockIndex* pindexStart) { // Walk the chain looking for transactions that belong to keys this wallet diff --git a/src/main.h b/src/main.h index 8d9b7e6..a04a4e3 100644 --- a/src/main.h +++ b/src/main.h @@ -141,6 +141,33 @@ static const int KEYPOOL_SIZE = 100; extern CCriticalSection cs_keyPool; extern map > mapKeyPool; +// --- Deterministic wallet (BIP32) ----------------------------------------- +// +// When a seed is present the key pool is derived from it -- m/0'/n, hardened, +// n increasing -- instead of being made of random keys. That is what lets a +// recovery phrase bring a wallet back: the same twelve words reproduce the same +// keys in the same order. +// +// A wallet without a seed keeps working exactly as before. Nothing here +// migrates an existing wallet on its own: the seed is created only when the +// user asks for a phrase and confirms they have written it down, because a +// phrase generated silently is a backup nobody has. +// +// Empty until a seed exists. +extern vector vchHDMaster; // 32-byte master private key (IL) +extern vector vchHDChainCode; // 32 bytes (IR) +extern unsigned int nHDNext; // next child index to derive +inline bool HaveHDSeed() { return vchHDMaster.size() == 32 && vchHDChainCode.size() == 32; } + +// Install a seed derived from a mnemonic, replacing any existing one, and reset +// the derivation counter. Writes to wallet.dat. Returns false and leaves the +// wallet untouched if the phrase is not valid. +bool SetHDSeedFromMnemonic(const string& strMnemonic, string& strErrorRet); + +// Derive the child at nIndex and return it as a key. Used by the key pool and +// by restore, which needs to run ahead of the pool. +bool DeriveHDKey(unsigned int nIndex, CKey& keyRet, string& strErrorRet); + // Fill the pool back up to KEYPOOL_SIZE. Every key it creates is written to // wallet.dat before it is offered to anybody. void TopUpKeyPool(); @@ -156,6 +183,7 @@ void ReacceptWalletTransactions(); // a key this wallet holds. Returns how many were added or updated. Needed by // anything that puts a key into the wallet after the fact -- an import, a // restored backup, and later a recovery phrase. +bool CanScanWalletTransactions(string& strErrorRet); int ScanForWalletTransactions(CBlockIndex* pindexStart); // Returns how many wallet transactions the chain corrected. int RescanSpentFlags(); diff --git a/src/main_gui.cpp b/src/main_gui.cpp index 2bcdb83..aa9c23d 100644 --- a/src/main_gui.cpp +++ b/src/main_gui.cpp @@ -323,6 +323,16 @@ int main(int argc, char* argv[]) // import having failed. if (nAdded > 0) { + string strScanError; + if (!CanScanWalletTransactions(strScanError)) + { + fprintf(stderr, "Cannot scan for imported coins: %s\n", + strScanError.c_str()); + fprintf(stderr, "The key import was written, but the wallet " + "balance was not proven against the chain.\n"); + DBFlush(true); + return 1; + } printf("Looking through the chain for coins belonging to the imported key(s)...\n"); int nFound = ScanForWalletTransactions(pindexGenesisBlock); printf("Found %d transaction(s). Start the node normally to see the balance.\n", nFound); @@ -339,6 +349,14 @@ int main(int argc, char* argv[]) // balance is wrong for this reason. if (arg(argc,argv,"/rescan") || arg(argc,argv,"-rescan")) { + string strScanError; + if (!CanScanWalletTransactions(strScanError)) + { + fprintf(stderr, "Cannot rescan wallet transactions: %s\n", + strScanError.c_str()); + DBFlush(true); + return 1; + } printf("Rescanning the chain for this wallet's transactions...\n"); int nFound = ScanForWalletTransactions(pindexGenesisBlock); printf("Rescan done: %d transaction(s) added or updated.\n", nFound); diff --git a/src/makefile.mingw b/src/makefile.mingw index 4f297d9..07ae034 100644 --- a/src/makefile.mingw +++ b/src/makefile.mingw @@ -42,7 +42,7 @@ IMGUI_OBJS = obj/imgui.o obj/imgui_draw.o obj/imgui_tables.o \ NODE_OBJS = obj/util.o obj/script.o obj/db.o obj/net.o obj/main.o \ obj/market.o obj/sha.o obj/nostr.o obj/randomx_pow.o \ obj/btfaddr.o obj/btfchan.o obj/btfrv.o obj/btftunnel.o obj/rpc.o \ - obj/selftest.o + obj/selftest.o obj/bip32.o GUI_OBJS = obj/gui.o obj/main_gui.o obj/bitflash_res.o @@ -103,3 +103,4 @@ test_bip32.exe: test_bip32.cpp bip32.cpp bip32.h bip39_english.h tests: test_bip32.exe bitflash.exe ./test_bip32.exe ./bitflash.exe -selftest=wallet-keypool + ./bitflash.exe -selftest=wallet-hd diff --git a/src/selftest.cpp b/src/selftest.cpp index abe1d15..6792ae7 100644 --- a/src/selftest.cpp +++ b/src/selftest.cpp @@ -314,14 +314,161 @@ static void AttachTerminal() #endif } +static int RunWalletHDSelfTest() +{ + fflush(stdout); + printf("wallet-hd self-test\n"); + + std::string tmp; + std::string cwd; + if (!MakeTempDir(tmp)) + { + printf(" FAIL could not create a temporary data directory\n"); + return 1; + } + if (!GetCurrentDir(cwd) || !SetCurrentDir(tmp)) + { + printf(" FAIL could not move into the temporary data directory\n"); + RemoveTree(tmp); + return 1; + } + + int nFail = 0; + strSetDataDir = tmp; + printf(" temp datadir: %s\n", tmp.c_str()); + + // Test-vector phrases, so nothing here depends on randomness. + const std::string strPhraseA = + "abandon abandon abandon abandon abandon abandon " + "abandon abandon abandon abandon abandon about"; + const std::string strPhraseB = + "legal winner thank year wave sausage worth useful " + "legal winner thank yellow"; + + try + { + if (!LoadWallet()) + throw std::runtime_error("LoadWallet failed"); + + std::string strError; + nFail += Check(SetHDSeedFromMnemonic(strPhraseA, strError), + "a valid phrase installs a seed") ? 0 : 1; + nFail += Check(HaveHDSeed(), "the wallet reports having a seed") ? 0 : 1; + + std::vector vchBefore = vchHDMaster; + nFail += Check(!SetHDSeedFromMnemonic("not a mnemonic at all", strError), + "an invalid phrase is refused") ? 0 : 1; + nFail += Check(vchHDMaster == vchBefore, + "a refused phrase leaves the existing seed alone") ? 0 : 1; + + // The property the whole feature exists for: the same words give back + // the same keys, in the same order. + std::vector first; + for (unsigned int i = 0; i < 5; i++) + { + CKey key; + if (!DeriveHDKey(i, key, strError)) + throw std::runtime_error("derivation failed: " + strError); + first.push_back(HexStrLocal(key.GetPubKey())); + } + + SetHDSeedFromMnemonic(strPhraseA, strError); + std::vector again; + for (unsigned int i = 0; i < 5; i++) + { + CKey key; + if (!DeriveHDKey(i, key, strError)) + throw std::runtime_error("derivation failed: " + strError); + again.push_back(HexStrLocal(key.GetPubKey())); + } + nFail += Check(first == again, + "the same phrase derives the same keys in the same order") ? 0 : 1; + + SetHDSeedFromMnemonic(strPhraseB, strError); + std::vector other; + for (unsigned int i = 0; i < 5; i++) + { + CKey key; + if (!DeriveHDKey(i, key, strError)) + throw std::runtime_error("derivation failed: " + strError); + other.push_back(HexStrLocal(key.GetPubKey())); + } + nFail += Check(first != other, "a different phrase derives different keys") ? 0 : 1; + + std::set distinct(first.begin(), first.end()); + nFail += Check(distinct.size() == first.size(), + "consecutive indices give distinct keys") ? 0 : 1; + + // With a seed installed the pool must be derived from it, and the + // counter must move exactly once per key. + SetHDSeedFromMnemonic(strPhraseA, strError); + CRITICAL_BLOCK(cs_keyPool) + mapKeyPool.clear(); + unsigned int nNextBefore = nHDNext; + TopUpKeyPool(); + + int nPool = 0; + CRITICAL_BLOCK(cs_keyPool) + nPool = (int)mapKeyPool.size(); + nFail += Check(nPool == KEYPOOL_SIZE, "the derived pool fills") ? 0 : 1; + nFail += Check(nHDNext == nNextBefore + (unsigned int)KEYPOOL_SIZE, + "the derivation counter advances once per pooled key") ? 0 : 1; + + std::set derived; + for (unsigned int i = nNextBefore; i < nHDNext; i++) + { + CKey key; + if (!DeriveHDKey(i, key, strError)) + throw std::runtime_error("derivation failed: " + strError); + derived.insert(HexStrLocal(key.GetPubKey())); + } + + bool fStored = true; + bool fFromSeed = true; + CRITICAL_BLOCK(cs_keyPool) + { + for (map >::const_iterator mi = mapKeyPool.begin(); + mi != mapKeyPool.end(); ++mi) + { + if (!WalletHasPrivateKey(mi->second)) + fStored = false; + if (!derived.count(HexStrLocal(mi->second))) + fFromSeed = false; + } + } + nFail += Check(fStored, "every derived pooled key is stored in wallet.dat") ? 0 : 1; + nFail += Check(fFromSeed, "every pooled key came from the seed, not from chance") ? 0 : 1; + } + catch (const std::exception& e) + { + printf(" FAIL exception: %s\n", e.what()); + nFail++; + } + catch (...) + { + printf(" FAIL unknown exception\n"); + nFail++; + } + + DBFlush(true); + SetCurrentDir(cwd); + RemoveTree(tmp); + printf("%s (%d failure%s)\n", nFail == 0 ? "ALL TESTS PASSED" : "TESTS FAILED", + nFail, nFail == 1 ? "" : "s"); + fflush(stdout); + return nFail == 0 ? 0 : 1; +} + int RunSelfTest(const std::string& name) { AttachTerminal(); if (name == "wallet-keypool") return RunWalletKeyPoolSelfTest(); + if (name == "wallet-hd") + return RunWalletHDSelfTest(); printf("Unknown self-test '%s'\n", name.c_str()); - printf("Known self-tests: wallet-keypool\n"); + printf("Known self-tests: wallet-keypool, wallet-hd\n"); return 1; } -- cgit v1.2.3 From 7de7ef89d3365f8f9c2eb2b6ed0193c3033e221c Mon Sep 17 00:00:00 2001 From: Bitflash Date: Sat, 1 Aug 2026 17:57:07 -0300 Subject: wallet: create and restore a recovery phrase (#96) Adds -newphrase, -restorephrase, -restoredepth, -newaddress and -showderived, plus the window buttons that reach the same code. Restore derives forward in batches of 100 and rescans after each, stopping when a whole batch turns up nothing; the phrase is shown on the terminal only, never in debug.log. Proved end to end on a live wallet: paid a derived address, deleted wallet.dat, restored from the twelve words and recovered the coin. Coins on the pre-seed default address do not come back, and the command says so. --- src/Makefile | 4 +- src/gui.cpp | 258 +++++++++++++++++++++++++++++++++++++++++++++++++++++ src/main_gui.cpp | 38 ++++++++ src/makefile.mingw | 4 +- src/selftest.cpp | 30 +------ src/util.h | 27 ++++++ src/walletcmd.cpp | 237 ++++++++++++++++++++++++++++++++++++++++++++++++ src/walletcmd.h | 43 +++++++++ 8 files changed, 610 insertions(+), 31 deletions(-) create mode 100644 src/walletcmd.cpp create mode 100644 src/walletcmd.h (limited to 'src/main_gui.cpp') diff --git a/src/Makefile b/src/Makefile index bbdc9e2..3eaf2ba 100644 --- a/src/Makefile +++ b/src/Makefile @@ -42,7 +42,7 @@ LIBS = -L$(SECP_LIB) -lsecp256k1 \ HEADERS = headers_core.h headers.h util.h main.h serialize.h uint256.h \ key.h bignum.h script.h db.h base58.h net.h nostr.h \ randomx_pow.h btfaddr.h btfchan.h btfrv.h btftunnel.h market.h \ - selftest.h compat.h + selftest.h walletcmd.h compat.h IMGUI_OBJS = obj/imgui.o obj/imgui_draw.o obj/imgui_tables.o \ obj/imgui_widgets.o obj/imgui_impl_glfw.o obj/imgui_impl_opengl3.o @@ -50,7 +50,7 @@ IMGUI_OBJS = obj/imgui.o obj/imgui_draw.o obj/imgui_tables.o \ NODE_OBJS = obj/util.o obj/script.o obj/db.o obj/net.o obj/main.o \ obj/market.o obj/sha.o obj/nostr.o obj/randomx_pow.o \ obj/btfaddr.o obj/btfchan.o obj/btfrv.o obj/btftunnel.o obj/rpc.o \ - obj/selftest.o obj/bip32.o + obj/selftest.o obj/bip32.o obj/walletcmd.o GUI_OBJS = obj/gui.o obj/main_gui.o diff --git a/src/gui.cpp b/src/gui.cpp index 51b5d5f..e800244 100644 --- a/src/gui.cpp +++ b/src/gui.cpp @@ -12,6 +12,11 @@ #include #include #include +#include +#include +#include +#include "bip32.h" +#include "walletcmd.h" #include "font_roboto.h" // --------------------------------------------------------------------------- @@ -910,6 +915,220 @@ static void DrawOptionsDialog() // --------------------------------------------------------------------------- // Wallet safety dialog // --------------------------------------------------------------------------- +// --------------------------------------------------------------------------- +// Recovery phrase +// +// The phrase is shown once, and the seed is installed only after the user says +// they have written it down. Generating it and storing it in the same breath +// would hand somebody a wallet whose only backup is twelve words they may have +// closed the window on -- which is worse than no phrase at all, because they +// would believe they had one. +// +// Nothing here writes the words to disk or to debug.log. They live in one +// std::string that is cleared the moment the dialog closes. +// --------------------------------------------------------------------------- +static bool g_showCreatePhrase = false; +static bool g_showRestorePhrase = false; +static std::string g_pendingMnemonic; +static bool g_phraseWrittenDown = false; +static std::string g_phraseStatus; + +static char g_restoreInput[600] = {}; +static std::string g_restoreStatus; +static std::atomic g_restoreRunning(false); +static std::atomic g_restoreDerived(0); +static std::atomic g_restoreRecovered(0); +static std::string g_restoreResult; +static std::mutex g_restoreResultMutex; + +static void RestoreProgress(void*, int nDerived, int nRecovered) +{ + g_restoreDerived.store(nDerived); + g_restoreRecovered.store(nRecovered); +} + +static void RestoreThread(std::string strPhrase) +{ + std::string strError; + int nRecovered = 0, nDerived = 0; + bool fOk = RestoreFromPhrase(strPhrase, 0, RestoreProgress, NULL, + strError, nRecovered, nDerived); + { + std::lock_guard lock(g_restoreResultMutex); + if (!fOk) + g_restoreResult = "Nothing was changed. " + strError; + else if (nRecovered > 0) + g_restoreResult = strprintf( + "Restored %d transaction(s) across %d derived addresses. " + "Restart the node to see the balance.", nRecovered, nDerived); + else + g_restoreResult = strprintf( + "No transactions found for the first %d addresses of that phrase. " + "Check the words and their order.", nDerived); + } + g_restoreRunning.store(false); + g_needRefresh = true; +} + +static void DrawCreatePhraseDialog() +{ + if (!g_showCreatePhrase) return; + + ImGui::SetNextWindowSize(ImVec2(640.0f, 340.0f), ImGuiCond_Always); + ImGui::SetNextWindowPos(ImGui::GetMainViewport()->GetCenter(), + ImGuiCond_Always, ImVec2(0.5f, 0.5f)); + if (ImGui::Begin("Recovery Phrase", &g_showCreatePhrase, + ImGuiWindowFlags_NoResize | ImGuiWindowFlags_NoCollapse)) + { + ImGui::TextColored(ImVec4(1.0f, 0.75f, 0.25f, 1.0f), + "Write these twelve words down, in order, on paper."); + ImGui::TextWrapped( + "They are the only thing that can rebuild this wallet if the file is lost. " + "Anyone who reads them can spend your coins. They are not stored anywhere, " + "and this is the only time they will be shown."); + ImGui::Spacing(); + + ImGui::PushStyleColor(ImGuiCol_ChildBg, ImVec4(0.10f, 0.10f, 0.10f, 1.0f)); + ImGui::BeginChild("##phrase", ImVec2(0.0f, 70.0f), true); + ImGui::PushTextWrapPos(0.0f); + ImGui::TextColored(ImVec4(0.55f, 1.0f, 0.6f, 1.0f), "%s", g_pendingMnemonic.c_str()); + ImGui::PopTextWrapPos(); + ImGui::EndChild(); + ImGui::PopStyleColor(); + + ImGui::Spacing(); + ImGui::TextWrapped( + "Keys already in this wallet are NOT covered by the phrase -- they existed " + "before it did. Keep your file backups as well."); + ImGui::Spacing(); + + ImGui::Checkbox("I have written these words down", &g_phraseWrittenDown); + ImGui::Spacing(); + + if (!g_phraseWrittenDown) + ImGui::BeginDisabled(); + if (ImGui::Button("Finish", ImVec2(120.0f, 0.0f))) + { + std::string strError; + if (SetHDSeedFromMnemonic(g_pendingMnemonic, strError)) + { + TopUpKeyPool(); + g_phraseStatus = "Recovery phrase created. New addresses come from it."; + } + else + { + g_phraseStatus = "Could not install the seed: " + strError; + } + g_pendingMnemonic.clear(); + g_phraseWrittenDown = false; + g_showCreatePhrase = false; + g_needRefresh = true; + } + if (!g_phraseWrittenDown) + ImGui::EndDisabled(); + + ImGui::SameLine(); + if (ImGui::Button("Cancel", ImVec2(120.0f, 0.0f))) + { + // Nothing was installed, so there is nothing to undo. The words go + // away with the string. + g_pendingMnemonic.clear(); + g_phraseWrittenDown = false; + g_showCreatePhrase = false; + g_phraseStatus = "Cancelled. No recovery phrase was created."; + } + } + ImGui::End(); + + // Closing with the window X is the same as cancelling. + if (!g_showCreatePhrase && !g_pendingMnemonic.empty()) + { + g_pendingMnemonic.clear(); + g_phraseWrittenDown = false; + g_phraseStatus = "Cancelled. No recovery phrase was created."; + } +} + +static void DrawRestorePhraseDialog() +{ + if (!g_showRestorePhrase) return; + + ImGui::SetNextWindowSize(ImVec2(640.0f, 330.0f), ImGuiCond_Always); + ImGui::SetNextWindowPos(ImGui::GetMainViewport()->GetCenter(), + ImGuiCond_Always, ImVec2(0.5f, 0.5f)); + if (ImGui::Begin("Restore From Phrase", &g_showRestorePhrase, + ImGuiWindowFlags_NoResize | ImGuiWindowFlags_NoCollapse)) + { + ImGui::TextWrapped( + "Type the twelve words, in order, separated by spaces. The node will " + "derive the addresses they describe and search the chain for their coins."); + ImGui::Spacing(); + + if (HaveHDSeed()) + { + ImGui::PushStyleColor(ImGuiCol_Text, ImVec4(1.0f, 0.75f, 0.25f, 1.0f)); + ImGui::TextWrapped( + "This wallet already has a recovery phrase. Restoring replaces it: " + "coins on addresses from the current phrase would no longer come back " + "from the words you wrote down for it. Back up wallet.dat first."); + ImGui::PopStyleColor(); + ImGui::Spacing(); + } + + ImGui::SetNextItemWidth(-1.0f); + ImGui::InputTextMultiline("##phrase-in", g_restoreInput, sizeof(g_restoreInput), + ImVec2(0.0f, 60.0f)); + + ImGui::Spacing(); + bool fBusy = g_restoreRunning.load(); + if (fBusy) + ImGui::BeginDisabled(); + if (ImGui::Button("Restore", ImVec2(140.0f, 0.0f))) + { + std::string strWhy; + if (!CanScanWalletTransactions(strWhy)) + { + std::lock_guard lock(g_restoreResultMutex); + g_restoreResult = strWhy; + } + else + { + { + std::lock_guard lock(g_restoreResultMutex); + g_restoreResult.clear(); + } + g_restoreDerived.store(0); + g_restoreRecovered.store(0); + g_restoreRunning.store(true); + // On its own thread: a restore walks the whole chain once per + // batch and would otherwise freeze the window for as long as it + // takes. + std::thread(RestoreThread, std::string(g_restoreInput)).detach(); + } + } + if (fBusy) + ImGui::EndDisabled(); + + ImGui::SameLine(); + if (ImGui::Button("Close", ImVec2(100.0f, 0.0f)) && !fBusy) + g_showRestorePhrase = false; + + ImGui::Spacing(); + if (fBusy) + { + ImGui::Text("Searching: %d addresses checked, %d transaction(s) so far", + g_restoreDerived.load(), g_restoreRecovered.load()); + } + else + { + std::lock_guard lock(g_restoreResultMutex); + if (!g_restoreResult.empty()) + ImGui::TextWrapped("%s", g_restoreResult.c_str()); + } + } + ImGui::End(); +} + static void DrawWalletSafetyDialog() { if (!g_showWalletSafety) return; @@ -993,6 +1212,43 @@ static void DrawWalletSafetyDialog() ImGui::PopStyleColor(); } + ImGui::Spacing(); + ImGui::SeparatorText("Recovery Phrase"); + if (HaveHDSeed()) + ImGui::TextColored(ImVec4(0.55f, 1.0f, 0.6f, 1.0f), + "This wallet has a recovery phrase."); + else + ImGui::TextColored(ImVec4(1.0f, 0.75f, 0.25f, 1.0f), + "No recovery phrase. Only a file backup can rebuild this wallet."); + + if (!HaveHDSeed()) + { + if (ImGui::Button("Create recovery phrase", ImVec2(200.0f, 0.0f))) + { + std::string strError; + if (bitflash::BIP39GenerateMnemonic(g_pendingMnemonic, strError)) + { + g_phraseWrittenDown = false; + g_phraseStatus.clear(); + g_showCreatePhrase = true; + } + else + { + g_phraseStatus = "Could not generate a phrase: " + strError; + } + } + ImGui::SameLine(); + } + if (ImGui::Button("Restore from phrase", ImVec2(190.0f, 0.0f))) + g_showRestorePhrase = true; + + if (!g_phraseStatus.empty()) + ImGui::TextWrapped("%s", g_phraseStatus.c_str()); + + ImGui::TextWrapped( + "A phrase covers addresses created after it. Keys that already existed " + "are not derived from it, so keep the file backups above as well."); + ImGui::Spacing(); ImGui::SeparatorText("Key Pool"); int nPool = KeyPoolCount(); @@ -1157,6 +1413,8 @@ int RunGUI(int argc, char* argv[]) DrawSendDialog(); DrawOptionsDialog(); DrawWalletSafetyDialog(); + DrawCreatePhraseDialog(); + DrawRestorePhraseDialog(); DrawDiagnosticsDialog(); DrawAboutDialog(); DrawStatusBar(); diff --git a/src/main_gui.cpp b/src/main_gui.cpp index aa9c23d..c9e7dee 100644 --- a/src/main_gui.cpp +++ b/src/main_gui.cpp @@ -2,6 +2,7 @@ #include "headers_core.h" #include "selftest.h" +#include "walletcmd.h" #include // hardware_concurrency, to sanity-check /genproclimit #ifndef _WIN32 #include @@ -78,6 +79,11 @@ static void PrintUsage() printf(" /dumpwallet=FILE (export private keys as text -- readable by\n"); printf(" anyone, so guard it like cash)\n"); printf(" /importwallet=FILE (load keys from such a file back in)\n"); + printf(" /newaddress (print the next receiving address, then exit)\n"); + printf(" /newphrase (create a twelve-word recovery phrase for a\n"); + printf(" wallet that has none, show it once, exit)\n"); + printf(" /restorephrase=\"WORDS\" (rebuild this wallet from a phrase and scan\n"); + printf(" the chain for its coins, then exit)\n"); printf(" /rescan (walk the chain for coins this wallet owns\n"); printf(" but never recorded, then exit)\n"); printf("\n"); @@ -347,6 +353,38 @@ int main(int argc, char* argv[]) // /rescan -- walk the chain and pick up anything the wallet's keys own but // the wallet never recorded. Cheap to offer and the only recourse when a // balance is wrong for this reason. + string strShow = argval2(argc, argv, "/showderived", "-showderived"); + if (!strShow.empty()) + { + int nRet = CmdShowDerived(atoi(strShow.c_str())); + DBFlush(true); + return nRet; + } + + if (arg(argc,argv,"/newaddress") || arg(argc,argv,"-newaddress")) + { + int nRet = CmdNewAddress(); + DBFlush(true); + return nRet; + } + + if (arg(argc,argv,"/newphrase") || arg(argc,argv,"-newphrase")) + { + int nRet = CmdNewPhrase(); + DBFlush(true); + return nRet; + } + + string strRestore = argval2(argc, argv, "/restorephrase", "-restorephrase"); + if (!strRestore.empty()) + { + string strDepth = argval2(argc, argv, "/restoredepth", "-restoredepth"); + int nMinDepth = strDepth.empty() ? 0 : atoi(strDepth.c_str()); + int nRet = CmdRestorePhrase(strRestore, nMinDepth); + DBFlush(true); + return nRet; + } + if (arg(argc,argv,"/rescan") || arg(argc,argv,"-rescan")) { string strScanError; diff --git a/src/makefile.mingw b/src/makefile.mingw index 07ae034..94d2146 100644 --- a/src/makefile.mingw +++ b/src/makefile.mingw @@ -42,14 +42,14 @@ IMGUI_OBJS = obj/imgui.o obj/imgui_draw.o obj/imgui_tables.o \ NODE_OBJS = obj/util.o obj/script.o obj/db.o obj/net.o obj/main.o \ obj/market.o obj/sha.o obj/nostr.o obj/randomx_pow.o \ obj/btfaddr.o obj/btfchan.o obj/btfrv.o obj/btftunnel.o obj/rpc.o \ - obj/selftest.o obj/bip32.o + obj/selftest.o obj/bip32.o obj/walletcmd.o GUI_OBJS = obj/gui.o obj/main_gui.o obj/bitflash_res.o HEADERS = headers_core.h headers.h util.h main.h serialize.h uint256.h \ key.h bignum.h script.h db.h base58.h net.h nostr.h \ randomx_pow.h btfaddr.h btfchan.h btfrv.h btftunnel.h market.h \ - selftest.h + selftest.h walletcmd.h all: bitflash.exe diff --git a/src/selftest.cpp b/src/selftest.cpp index 6792ae7..b733c60 100644 --- a/src/selftest.cpp +++ b/src/selftest.cpp @@ -286,33 +286,9 @@ static int RunWalletKeyPoolSelfTest() return nFail == 0 ? 0 : 1; } -// A -mwindows binary starts with no console attached, so stdout goes nowhere -// even when it was launched from a terminal. Borrow the caller's console for -// the duration of the self-test. Nothing to do on Linux, where stdout is -// already connected. -static void AttachTerminal() -{ -#ifdef _WIN32 - // Only when there is nowhere for stdout to go. If the caller redirected it - // to a file or a pipe -- which is what `make tests > log` and any CI does -- - // that handle is inherited and already works, and reopening it on CONOUT$ - // would take the output away from the file and put it on the screen. That - // was the first version of this fix, and it broke the one case that - // mattered. - HANDLE hOut = GetStdHandle(STD_OUTPUT_HANDLE); - if (hOut != NULL && hOut != INVALID_HANDLE_VALUE) - return; - - if (AttachConsole(ATTACH_PARENT_PROCESS)) - { - // Return values ignored on purpose: if the reopen fails there is - // nowhere left to report it, and the exit status still carries the - // result. - freopen("CONOUT$", "w", stdout); - freopen("CONOUT$", "w", stderr); - } -#endif -} +// AttachTerminal() moved to util.h: the wallet phrase commands need it too, +// and two copies of a console-attach that must get its one condition right is +// one copy too many. static int RunWalletHDSelfTest() { diff --git a/src/util.h b/src/util.h index 25ef118..d95d3ae 100644 --- a/src/util.h +++ b/src/util.h @@ -293,6 +293,33 @@ void PrintHex(const T pbegin, const T pend, const char* pszFormat="%s", bool fSp // Forward declaration -- defined in main.cpp string GetAppDir(); +// Give a command-line operation somewhere to print. +// +// The Windows binary is linked -mwindows and starts with no console, so stdout +// goes nowhere even when it was launched from a terminal. Commands that report +// to the person who typed them -- the self-tests, the wallet phrase commands -- +// call this first. +// +// Only when there is nowhere for stdout to go already: if the caller redirected +// it to a file or a pipe, that handle is inherited and works, and reopening it +// on CONOUT$ would take the output away from the file and put it on the screen. +inline void AttachTerminal() +{ +#ifdef _WIN32 + HANDLE hOut = GetStdHandle(STD_OUTPUT_HANDLE); + if (hOut != NULL && hOut != INVALID_HANDLE_VALUE) + return; + if (AttachConsole(ATTACH_PARENT_PROCESS)) + { + // Return values ignored on purpose: if the reopen fails there is + // nowhere left to report it, and the exit status still carries the + // result. + freopen("CONOUT$", "w", stdout); + freopen("CONOUT$", "w", stderr); + } +#endif +} + inline int OutputDebugStringF(const char* pszFormat, ...) BF_FORMAT(1, 2); inline int OutputDebugStringF(const char* pszFormat, ...) diff --git a/src/walletcmd.cpp b/src/walletcmd.cpp new file mode 100644 index 0000000..94e9d72 --- /dev/null +++ b/src/walletcmd.cpp @@ -0,0 +1,237 @@ +// Copyright (c) 2026 Bitflash developers +// Distributed under the MIT/X11 software license. +// +// Recovery-phrase commands, from the command line. +// +// The phrase reaches the person who typed the command and nowhere else. In this +// tree printf is OutputDebugStringF, which writes into the data directory's +// debug.log -- the file we routinely ask users to attach to issues. A wallet's +// twelve words must never go there, so this file takes the real printf and +// AttachTerminal() gives it somewhere to land on Windows. + +#include "headers_core.h" +#include "bip32.h" +#include "walletcmd.h" + +#undef printf + +// How far ahead to look for used addresses when restoring. +// +// A wallet is restored from a phrase alone, so the node has no idea how many +// addresses the old one handed out. It derives forward in batches and stops +// once a whole batch turns up unused. A fixed number would be simpler and wrong +// for exactly this network: a miner burns an address per block, so a node that +// mined for a week is thousands of keys deep, and stopping at a few hundred +// would restore a wallet that looks empty. +static const int RESTORE_BATCH = 100; +static const int RESTORE_MAX = 10000; + +int CmdNewPhrase() +{ + AttachTerminal(); + + if (HaveHDSeed()) + { + printf("This wallet already has a recovery phrase.\n"); + printf("Creating another would replace it, and coins on addresses derived from\n"); + printf("the current one would no longer come back from the phrase you write down.\n"); + printf("Nothing was changed.\n"); + return 1; + } + + std::string strMnemonic, strError; + if (!bitflash::BIP39GenerateMnemonic(strMnemonic, strError)) + { + fprintf(stderr, "Could not generate a phrase: %s\n", strError.c_str()); + return 1; + } + + if (!SetHDSeedFromMnemonic(strMnemonic, strError)) + { + fprintf(stderr, "Could not install the seed: %s\n", strError.c_str()); + return 1; + } + + // Fill the pool from the new seed straight away, so the addresses this + // wallet hands out next are ones the phrase can reproduce. + TopUpKeyPool(); + + printf("\n"); + printf("Write these twelve words down, in order, on paper.\n"); + printf("They are the only thing that can rebuild this wallet if the file is lost.\n"); + printf("Anyone who reads them can spend your coins. They are not stored anywhere,\n"); + printf("and this is the only time they will be shown.\n"); + printf("\n"); + printf(" %s\n", strMnemonic.c_str()); + printf("\n"); + printf("Keys already in this wallet are NOT covered by the phrase -- they existed\n"); + printf("before it did. Keep your file backups as well.\n"); + fflush(stdout); + return 0; +} + +bool RestoreFromPhrase(const std::string& strMnemonic, + int nMinDepth, + RestoreProgressFn fnProgress, + void* pArg, + std::string& strErrorRet, + int& nRecoveredRet, + int& nDerivedRet) +{ + strErrorRet.clear(); + nRecoveredRet = 0; + nDerivedRet = 0; + + // Refuse before touching the wallet, not after. + // + // Without this, a restore on a node that has not synced derives the right + // keys, scans a chain that is not there, and reports "no transactions were + // found" -- which reads as "your coins are gone" to the one person least + // able to argue with it. Measured while testing: a copied data directory + // whose Berkeley DB environment failed to load left the node at height 0, + // the scan walked one block, and the message said nothing was found across + // 600 addresses. + if (!CanScanWalletTransactions(strErrorRet)) + return false; + + if (!SetHDSeedFromMnemonic(strMnemonic, strErrorRet)) + return false; + + // Derive forward in batches, scanning after each, until a whole batch turns + // up nothing. Every derived key is written to the wallet before the scan, + // because the scan asks the wallet what belongs to it. + int nTotalDerived = 0; + while (nTotalDerived < RESTORE_MAX) + { + // Count wallet transactions, not scan hits. + // + // ScanForWalletTransactions reports what it added *or updated*, and it + // walks the whole chain every time, so a transaction found in the first + // batch is reported again by every batch after it. Testing this against + // a real payment produced exactly +1 per batch and a loop that ran to + // the ceiling, deriving ten thousand addresses to rediscover one coin. + size_t nWalletBefore = 0; + CRITICAL_BLOCK(cs_mapWallet) + nWalletBefore = mapWallet.size(); + + std::string strDeriveError; + for (int i = 0; i < RESTORE_BATCH; i++) + { + CKey key; + if (!DeriveHDKey(nHDNext, key, strDeriveError)) + break; + if (!AddKey(key)) + break; + nHDNext++; + nTotalDerived++; + } + CWalletDB().WriteHDNext(nHDNext); + + ScanForWalletTransactions(pindexGenesisBlock); + + size_t nWalletAfter = 0; + CRITICAL_BLOCK(cs_mapWallet) + nWalletAfter = mapWallet.size(); + + if (fnProgress) + fnProgress(pArg, nTotalDerived, (int)nWalletAfter); + + // A whole batch with nothing in it means far enough -- unless the + // caller asked to look deeper anyway. + if (nWalletAfter == nWalletBefore && nTotalDerived >= nMinDepth) + break; + } + + // The pool is rebuilt from where derivation stopped, so the next address + // this wallet hands out is one the phrase can reproduce as well. + CRITICAL_BLOCK(cs_keyPool) + mapKeyPool.clear(); + TopUpKeyPool(); + + CRITICAL_BLOCK(cs_mapWallet) + nRecoveredRet = (int)mapWallet.size(); + nDerivedRet = nTotalDerived; + return true; +} + +static void PrintRestoreProgress(void*, int nDerived, int nRecovered) +{ + printf(" %d addresses checked, %d transaction(s) recovered\n", nDerived, nRecovered); + fflush(stdout); +} + +int CmdRestorePhrase(const std::string& strMnemonic, int nMinDepth) +{ + AttachTerminal(); + + printf("Checking the phrase and the chain.\n"); + fflush(stdout); + + std::string strError; + int nRecovered = 0, nDerived = 0; + if (!RestoreFromPhrase(strMnemonic, nMinDepth, PrintRestoreProgress, NULL, + strError, nRecovered, nDerived)) + { + fprintf(stderr, "Cannot restore: %s\n", strError.c_str()); + fprintf(stderr, "Nothing was changed.\n"); + return 1; + } + + printf("\n"); + if (nRecovered > 0) + printf("Restored: %d transaction(s) across %d derived addresses.\n", + nRecovered, nDerived); + else + printf("No transactions were found for the first %d addresses of that phrase.\n" + "If you expected coins, check the words and the order.\n", nDerived); + printf("Start the node normally to see the balance.\n"); + fflush(stdout); + return 0; +} + +int CmdNewAddress() +{ + AttachTerminal(); + + std::vector vchPubKey = GetKeyFromPool(); + if (vchPubKey.empty()) + { + fprintf(stderr, "Could not get a key from the wallet.\n"); + return 1; + } + + printf("%s\n", PubKeyToAddress(vchPubKey).c_str()); + if (!HaveHDSeed()) + printf("(this wallet has no recovery phrase, so only a file backup covers this address)\n"); + fflush(stdout); + return 0; +} + +// Diagnostic: print the addresses a phrase produces, so "the restore found +// nothing" can be told apart from "that address was never derived". +int CmdShowDerived(int nCount) +{ + AttachTerminal(); + + if (!HaveHDSeed()) + { + fprintf(stderr, "This wallet has no recovery phrase installed.\n"); + return 1; + } + if (nCount <= 0) + nCount = 20; + + std::string strError; + for (int i = 0; i < nCount; i++) + { + CKey key; + if (!DeriveHDKey((unsigned int)i, key, strError)) + { + fprintf(stderr, "derivation failed at %d: %s\n", i, strError.c_str()); + return 1; + } + printf("%6d %s\n", i, PubKeyToAddress(key.GetPubKey()).c_str()); + } + fflush(stdout); + return 0; +} diff --git a/src/walletcmd.h b/src/walletcmd.h new file mode 100644 index 0000000..89834d8 --- /dev/null +++ b/src/walletcmd.h @@ -0,0 +1,43 @@ +// Copyright (c) 2026 Bitflash developers +// Distributed under the MIT/X11 software license. +// +// Recovery-phrase commands. Both run against a loaded wallet and then exit; +// neither starts the node. + +#ifndef BITFLASH_WALLETCMD_H +#define BITFLASH_WALLETCMD_H + +#include + +// Create a phrase for a wallet that does not have one, install the seed, and +// show the words once. Refuses if a phrase already exists. Returns 0 on success. +int CmdNewPhrase(); + +// Install the seed a phrase describes, derive forward looking for used +// addresses, and rescan the chain so their coins appear. Returns 0 on success. +int CmdRestorePhrase(const std::string& strMnemonic, int nMinDepth = 0); + +// Called once per batch with how many addresses have been derived so far and +// how many transactions the wallet now holds. +typedef void (*RestoreProgressFn)(void* pArg, int nDerived, int nRecovered); + +// The restore itself, shared by the command and the GUI so the two cannot +// drift apart. Returns false without touching the wallet if the chain is not +// loaded or the phrase is not valid. +bool RestoreFromPhrase(const std::string& strMnemonic, + int nMinDepth, + RestoreProgressFn fnProgress, + void* pArg, + std::string& strErrorRet, + int& nRecoveredRet, + int& nDerivedRet); + +// Take the next address from the key pool and print it. With a recovery phrase +// installed the address is derived, so the phrase can bring back whatever is +// paid to it. +int CmdNewAddress(); + +// Diagnostic: print the first nCount addresses this wallet's phrase derives. +int CmdShowDerived(int nCount); + +#endif -- cgit v1.2.3 From e9b3c1734aa274f801b4ffe7cfbc54d4c455f8e5 Mon Sep 17 00:00:00 2001 From: Bitflash Date: Sat, 1 Aug 2026 22:19:34 -0300 Subject: docs: bring the README up to the wallet that exists, and make -help print (#106) The README still said deterministic restore was an open issue, told people the mining mode was not remembered between restarts, and wrote every flag in the slash form that MSYS2 turns into a path before the node sees it. Rewritten around the phrase and the file as two backups covering different keys, the -restorephrase note for anyone who made a phrase on 1.2.12, -rescan, the diagnostics report, and the two upgrade reasons measured this week. Also: -help printed nothing at all. This tree remaps printf to OutputDebugStringF, so the usage page went into debug.log, and a -mwindows binary has no console to write to either way -- the one command whose job is to tell you something returned 0 and said nothing. It now takes the real printf and attaches the terminal. The help text was missing -restoredepth, -showderived and the wallet-hd self-test. --- README.md | 131 +++++++++++++++++++++++++++++++++++++++++++------------ src/main_gui.cpp | 19 +++++++- 2 files changed, 121 insertions(+), 29 deletions(-) (limited to 'src/main_gui.cpp') diff --git a/README.md b/README.md index 28030f1..787f58d 100644 --- a/README.md +++ b/README.md @@ -62,6 +62,17 @@ added a per-block signature-operation cap. A node on an older build will accept blocks that current nodes reject, which puts it on a different chain without any warning. +Two more reasons, both measured rather than theorised: + +- **Before 1.2.13** a node closed a disconnected peer's socket twice and left the + closed handle in its `select()` set, where it made `select()` fail on every + iteration. The node then spent its socket loop in an error path instead of + reading its peers. Two nodes side by side on the same machine, same network: + the one without the fix held 11 peers, the one with it held 21. +- **Before 1.2.11** a long-running Windows node accumulated sockets it never + released — 1262 of them in 26 hours on one machine, each holding an ephemeral + port. + Your wallet and chain data live in `%APPDATA%\Bitflash` (Windows) or `~/.bitflash` (Linux) and are shared by every version, so upgrading is just replacing the binary. Never delete that directory to "fix" something without a @@ -69,10 +80,47 @@ backup — it holds your keys. --- -## Backing up your wallet +## Your wallet: the phrase and the file + +Since 1.2.12 a wallet can hold twelve words that rebuild it. Since 1.2.13 those +words also cover the address the window shows you. **Both still matter** — the +phrase and the file back up different things, and the difference is where people +lose money. + +### The recovery phrase + +``` +bitflash -newphrase # create it, show it once, exit +bitflash -restorephrase="twelve words here" # rebuild a wallet from it +``` + +`-newphrase` installs a BIP32 seed, derives the wallet's key pool and default +receiving address from it, and prints the words once. It refuses if a phrase +already exists: replacing one silently would strand every coin on addresses the +written-down words no longer describe. + +`-restorephrase` installs the seed and walks forward in batches of a hundred +addresses, rescanning the chain after each and stopping when a whole batch turns +up nothing. `-restoredepth=N` looks further. `-showderived=N` lists the addresses +a phrase produces, so you can check one before trusting it. + +The same two operations are in the window, under **Wallet Safety**. + +**The words are printed to the terminal and nowhere else** — never to +`debug.log`, which is the file people are routinely asked to attach to an issue. -Two things about this wallet will cost you money if you do not know them. Both -are consequences of the 0.1.0 wallet format, and neither is obvious. +**What the phrase does not cover.** Keys that existed *before* the seed was +installed are random. They are not derived from it and they do not come back +from the words. The wallet names such an address `Your Address (created before +the recovery phrase)` so you can tell them apart. This is why file backups still +matter. + +> **If you created a phrase on 1.2.12, run `-restorephrase` with the same twelve +> words.** That release installed the seed but left the visible address and the +> key pool random, so the wallet went on handing out addresses the words cannot +> reproduce while the window said a phrase existed. Restoring repairs it. + +### The file **Copying `wallet.dat` on its own is not a backup.** Berkeley DB ties the file to the environment in the `database/` subdirectory beside it, so a lone @@ -88,20 +136,26 @@ It loads the wallet, writes the copy, and exits without starting the node. If you would rather copy by hand, shut the node down first and take the **whole** data directory, not just `wallet.dat`. -**Every backup is still a snapshot, but it now has a safety margin.** The wallet -keeps a pool of 100 pre-generated keys. A backup contains those keys, so it -covers the next 100 mining rewards or receive addresses the node hands out after -the backup. That makes a normal backup much safer than the original 0.1.0 -wallet, where the very next block could land on a key the backup did not have. +A file backup covers what a phrase cannot: keys from before the seed, and any +key the wallet acquired by import. It also has a margin of its own — the wallet +keeps a pool of 100 pre-generated keys, so a backup covers the next 100 mining +rewards or receive addresses. Take a fresh one after mining for a while, after +creating many receive addresses, and before moving the wallet to another machine. + +The GUI has a **Backup Wallet** button and a **Wallet Safety** view, which fills +the key pool before writing the backup. -It is not a seed phrase. Heavy use can drain the key pool, and deterministic -wallet restore is still tracked in [#47](https://github.com/Bitflash-sh/bitflash/issues/47). -Back up again after mining for a while, after creating many receive addresses, -and before moving the wallet to another machine. +### Finding coins the wallet never recorded + +``` +bitflash -rescan +``` -The GUI has a **Backup Wallet** button and a **Wallet Safety** view. Use that if -you do not want to run the command by hand; it fills the key pool before writing -the backup. +Walks the chain for coins a key of yours owns but the wallet has no record of — +after importing keys, or after restoring a file from another machine. +`-importwallet` runs this automatically. A scan asked for with no chain data +loaded is refused rather than reported as "no transactions found", which reads +as a verdict to somebody who has just lost a wallet. ### When wallet.dat itself will not open @@ -153,28 +207,48 @@ xmrig -a rx/0 -o POOL_BTF_ADDRESS -u YOUR_BTF_ADDRESS -p x ## Headless / server mode ```bash -./bitflash /nogui # node only -./bitflash /nogui /gen # node + solo mining -./bitflash /nogui /gen /operator # pool operator -./bitflash /nogui /gen /participant=POOL_BTF_ADDRESS # mine to pool +./bitflash -nogui # node only +./bitflash -nogui -gen # node + solo mining +./bitflash -nogui -gen -operator # pool operator +./bitflash -nogui -gen -participant=POOL_BTF_ADDRESS # mine to pool ``` -Mining is off unless you pass `/gen` — it is not remembered between restarts, so -put the flag in whatever starts the node rather than enabling it in the window. +Every option takes `-` or `/`. **Under MSYS2 use the `-` form** — the shell +rewrites a leading slash into a path before the node ever sees it. + +**The mining mode is remembered between restarts** since 1.2.11, so a node that +was mining comes back mining. A `-gen`, `-operator` or `-participant` flag always +wins over what was stored, and the log says on every start which of the two +decided. Passing the flag anyway is the safe habit: it survives a wallet that +came from an older build. Other options worth knowing: ```bash -/datadir=PATH # wallet and chain data elsewhere -/port=N # P2P listen port, default 8433 -/debug # verbose log; without it debug.log is nearly silent -/help # full list +-datadir=PATH # wallet and chain data elsewhere +-port=N # P2P listen port, default 8433 +-debug # verbose log; without it debug.log is nearly silent +-help # full list ``` -`/port` plus `/datadir` is what lets two nodes share one machine. Both are +`-port` plus `-datadir` is what lets two nodes share one machine. Both are needed — the data directory takes an exclusive lock, so a second node pointed at the same one will refuse to start. +### When something looks wrong + +**Menu > Diagnostics**, and the same report in `debug.log` every ten minutes: +peers held against peers `select()` is actually watching, blocks received and how +many arrived without a parent, the proof-of-work mode with the live miner thread +count, sockets by the part of the program that opened them, and per peer how long +since the last message each way. There is a copy button — if you open an issue, +paste that. + +It exists because every hard problem in this project so far was found from +*outside* the node: sockets read over WMI from another machine, memory compared +against a number in a README, a `grep` over somebody's log. In each case the node +knew and had no way to say so. + As a systemd service: ```ini @@ -183,7 +257,7 @@ Description=Bitflash node After=network.target [Service] -ExecStart=/opt/bitflash/bitflash /nogui /gen /operator +ExecStart=/opt/bitflash/bitflash -nogui -gen -operator Restart=always User=bitcoin WorkingDirectory=/opt/bitflash @@ -225,7 +299,7 @@ Relays are the meeting points that let nodes behind NAT connect to each other. M ```bash sudo bash relay/install-bitflash-relay.sh 8434 -./bitflash /nogui /announcerelay=YOUR_PUBLIC_IP:8434 +./bitflash -nogui -announcerelay=YOUR_PUBLIC_IP:8434 ``` Relays forward encrypted bytes and cannot read or modify traffic. @@ -265,6 +339,7 @@ Installs deps via pacman, produces `Bitflash-*-windows.zip`. | Addressing | `.btf` rendezvous — see the caveats above | | Premine | None | | Pool server | Built-in — `.btf` rendezvous only | +| Wallet recovery | Twelve-word phrase (BIP39 + BIP32), plus file backup | The halving interval is the number most people get wrong coming from Bitcoin. Same 210,000 blocks, but at two minutes instead of ten, so it arrives in about diff --git a/src/main_gui.cpp b/src/main_gui.cpp index c9e7dee..32fcdef 100644 --- a/src/main_gui.cpp +++ b/src/main_gui.cpp @@ -40,6 +40,12 @@ static string argval2(int argc, char* argv[], const char* keySlash, const char* return argval(argc, argv, keyDash); } +// The real printf, not the one util.h remaps to OutputDebugStringF. Help that +// goes to debug.log is help nobody asked for: `-help` printed a full page into +// the data directory and returned 0 with an empty terminal, which reads as a +// command that does nothing. Same reason walletcmd.cpp undefines it. +#undef printf + static void PrintUsage() { printf("Bitflash command-line options\n"); @@ -50,7 +56,7 @@ static void PrintUsage() printf(" /debug\n"); printf(" /gen\n"); printf(" /nogui or /daemon\n"); - printf(" /selftest=wallet-keypool\n"); + printf(" /selftest=wallet-keypool or /selftest=wallet-hd\n"); printf("\n"); printf("Mining mode:\n"); printf(" /operator\n"); @@ -84,12 +90,18 @@ static void PrintUsage() printf(" wallet that has none, show it once, exit)\n"); printf(" /restorephrase=\"WORDS\" (rebuild this wallet from a phrase and scan\n"); printf(" the chain for its coins, then exit)\n"); + printf(" /restoredepth=N (with /restorephrase: derive at least N\n"); + printf(" addresses before giving up)\n"); + printf(" /showderived=N (list the first N addresses a phrase\n"); + printf(" produces, without installing it)\n"); printf(" /rescan (walk the chain for coins this wallet owns\n"); printf(" but never recorded, then exit)\n"); printf("\n"); printf("Each option also accepts '-' instead of '/'.\n"); } +#define printf OutputDebugStringF + static void ParseStartupArguments(int argc, char* argv[]) { if (arg(argc,argv,"/datadir") || arg(argc,argv,"-datadir")) @@ -221,6 +233,11 @@ int main(int argc, char* argv[]) if (arg(argc,argv,"/help") || arg(argc,argv,"-help") || arg(argc,argv,"--help") || arg(argc,argv,"/?")) { + // Without this the help prints into nothing. A -mwindows binary starts + // with no console, so every one of those printfs went nowhere and the + // one command whose entire job is to tell you something told you + // nothing at all -- exit code 0, not a byte of output. + AttachTerminal(); PrintUsage(); return 0; } -- cgit v1.2.3 From 57441faffb02f4f2eea37d31b29728de25f18bef Mon Sep 17 00:00:00 2001 From: mtg0001 <106211915+mtg0001@users.noreply.github.com> Date: Mon, 3 Aug 2026 10:43:42 -0300 Subject: Add wallet recovery balance audit (#117) Co-authored-by: Gilcimar Duarte --- src/gui.cpp | 54 ++++++++++++++++++++++++++++++++-- src/main.cpp | 87 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/main.h | 31 ++++++++++++++++++++ src/main_gui.cpp | 12 +++++++- src/selftest.cpp | 36 +++++++++++++++++++++++ src/walletcmd.cpp | 59 +++++++++++++++++++++++++++++++++++++ src/walletcmd.h | 4 +++ 7 files changed, 280 insertions(+), 3 deletions(-) (limited to 'src/main_gui.cpp') diff --git a/src/gui.cpp b/src/gui.cpp index e3a8499..2335bf9 100644 --- a/src/gui.cpp +++ b/src/gui.cpp @@ -65,6 +65,8 @@ static std::string g_sendStatus; static char g_backupPath[512] = {}; static std::string g_backupStatus; static int64 g_lastWalletBackup = 0; +static WalletRecoveryAudit g_recoveryAudit; +static int64 g_recoveryAuditTime = 0; static bool g_walletSafetyLoaded = false; static char g_participantPool[256] = {}; static char g_poolName[128] = {}; @@ -184,6 +186,15 @@ static void LoadWalletSafetyState() g_walletSafetyLoaded = true; } +static void RefreshRecoveryAudit(bool fForce=false) +{ + int64 nNow = GetTime(); + if (!fForce && g_recoveryAuditTime != 0 && nNow - g_recoveryAuditTime < 5) + return; + g_recoveryAudit = GetWalletRecoveryAudit(); + g_recoveryAuditTime = nNow; +} + static int KeyPoolCount() { int n = 0; @@ -267,7 +278,11 @@ static void RefreshWallet() g_lastWalletRefresh = GetTime(); } -void MainFrameRepaint() { g_needRefresh = true; } +void MainFrameRepaint() +{ + g_needRefresh = true; + g_recoveryAuditTime = 0; +} // DateTimeStr moved to util.cpp: main.cpp logs block times with it, so it is // needed by builds that have no GUI at all. @@ -967,6 +982,7 @@ static void RestoreThread(std::string strPhrase) "Check the words and their order.", nDerived); } g_restoreRunning.store(false); + g_recoveryAuditTime = 0; g_needRefresh = true; } @@ -1021,6 +1037,7 @@ static void DrawCreatePhraseDialog() if (SetHDSeedFromMnemonic(g_pendingMnemonic, strError)) { TopUpKeyPool(); + g_recoveryAuditTime = 0; g_phraseStatus = "Recovery phrase created. New addresses come from it."; } else @@ -1141,8 +1158,9 @@ static void DrawWalletSafetyDialog() { if (!g_showWalletSafety) return; LoadWalletSafetyState(); + RefreshRecoveryAudit(); - ImGui::SetNextWindowSize(ImVec2(650.0f, 390.0f), ImGuiCond_Always); + ImGui::SetNextWindowSize(ImVec2(650.0f, 450.0f), ImGuiCond_Always); ImGui::SetNextWindowPos(ImGui::GetMainViewport()->GetCenter(), ImGuiCond_Always, ImVec2(0.5f, 0.5f)); if (ImGui::Begin("Wallet Safety", &g_showWalletSafety, @@ -1229,6 +1247,38 @@ static void DrawWalletSafetyDialog() ImGui::TextColored(ImVec4(1.0f, 0.75f, 0.25f, 1.0f), "No recovery phrase. Only a file backup can rebuild this wallet."); + ImGui::Text("Phrase-backed spendable balance: %s BTF", + FmtMoney(g_recoveryAudit.nRecoverableCredit).c_str()); + ImGui::Text("Wallet.dat-only spendable balance: %s BTF", + FmtMoney(g_recoveryAudit.nLegacyCredit).c_str()); + ImGui::Text("Phrase-backed immature mining rewards: %s BTF", + FmtMoney(g_recoveryAudit.nRecoverableImmatureCredit).c_str()); + ImGui::Text("Wallet.dat-only immature mining rewards: %s BTF", + FmtMoney(g_recoveryAudit.nLegacyImmatureCredit).c_str()); + if (!g_recoveryAudit.fDeriveComplete) + { + ImGui::PushStyleColor(ImGuiCol_Text, ImVec4(1.0f, 0.75f, 0.25f, 1.0f)); + ImGui::TextWrapped("%s", g_recoveryAudit.strDeriveError.c_str()); + ImGui::PopStyleColor(); + } + if (g_recoveryAudit.nLegacyCredit > 0 || + g_recoveryAudit.nLegacyImmatureCredit > 0) + { + ImGui::PushStyleColor(ImGuiCol_Text, ImVec4(1.0f, 0.75f, 0.25f, 1.0f)); + ImGui::TextWrapped( + "Some coins are on keys the phrase does not reproduce. " + "Keep wallet.dat backups until that balance has been moved to a " + "phrase-backed address."); + ImGui::PopStyleColor(); + } + else if (HaveHDSeed() && + g_recoveryAudit.nRecoverableCredit + + g_recoveryAudit.nRecoverableImmatureCredit > 0) + { + ImGui::TextColored(ImVec4(0.55f, 1.0f, 0.6f, 1.0f), + "All known wallet balance is covered by the phrase."); + } + if (!HaveHDSeed()) { if (ImGui::Button("Create recovery phrase", ImVec2(200.0f, 0.0f))) diff --git a/src/main.cpp b/src/main.cpp index 1b09a1a..75647ba 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -3660,6 +3660,93 @@ bool BitcoinMiner(int nThreadId) // +WalletRecoveryAudit GetWalletRecoveryAudit() +{ + WalletRecoveryAudit audit; + set > setDerivedPubKeys; + + CRITICAL_BLOCK(cs_keyPool) + { + audit.fHaveSeed = HaveHDSeed(); + audit.nDerivedKnown = nHDNext; + if (audit.fHaveSeed) + { + string strError; + for (unsigned int i = 0; i < nHDNext; i++) + { + CKey key; + if (!DeriveHDKey(i, key, strError)) + { + audit.fDeriveComplete = false; + audit.strDeriveError = strprintf("derivation failed at index %u: %s", + i, strError.c_str()); + break; + } + setDerivedPubKeys.insert(key.GetPubKey()); + } + } + } + + CRITICAL_BLOCK(cs_mapWallet) + { + for (map::iterator it = mapWallet.begin(); it != mapWallet.end(); ++it) + { + CWalletTx& wtx = (*it).second; + if (!wtx.IsFinal() || wtx.fSpent) + continue; + bool fImmature = wtx.IsCoinBase() && wtx.GetBlocksToMaturity() > 0; + + bool fTxRecoverable = false; + bool fTxLegacy = false; + for (int i = 0; i < (int)wtx.vout.size(); i++) + { + const CTxOut& txout = wtx.vout[i]; + if (!txout.IsMine()) + continue; + + vector vchPubKey; + bool fRecoverable = false; + if (audit.fHaveSeed && ExtractPubKey(txout.scriptPubKey, true, vchPubKey)) + fRecoverable = setDerivedPubKeys.count(vchPubKey) > 0; + + if (fRecoverable) + { + if (fImmature) + audit.nRecoverableImmatureCredit += txout.nValue; + else + audit.nRecoverableCredit += txout.nValue; + fTxRecoverable = true; + } + else + { + if (fImmature) + audit.nLegacyImmatureCredit += txout.nValue; + else + audit.nLegacyCredit += txout.nValue; + fTxLegacy = true; + } + } + + if (fTxRecoverable) + { + if (fImmature) + audit.nRecoverableImmatureTx++; + else + audit.nRecoverableTx++; + } + if (fTxLegacy) + { + if (fImmature) + audit.nLegacyImmatureTx++; + else + audit.nLegacyTx++; + } + } + } + + return audit; +} + int64 GetBalance() { int64 nStart, nEnd; diff --git a/src/main.h b/src/main.h index 768f1c7..4886f55 100644 --- a/src/main.h +++ b/src/main.h @@ -217,6 +217,37 @@ extern CCriticalSection cs_mapTransactions; bool ProcessMessage(CNode* pfrom, string strCommand, CDataStream& vRecv); bool SendMessages(CNode* pto); int64 GetBalance(); +struct WalletRecoveryAudit +{ + bool fHaveSeed; + bool fDeriveComplete; + unsigned int nDerivedKnown; + int nRecoverableTx; + int nLegacyTx; + int nRecoverableImmatureTx; + int nLegacyImmatureTx; + int64 nRecoverableCredit; + int64 nLegacyCredit; + int64 nRecoverableImmatureCredit; + int64 nLegacyImmatureCredit; + string strDeriveError; + + WalletRecoveryAudit() + { + fHaveSeed = false; + fDeriveComplete = true; + nDerivedKnown = 0; + nRecoverableTx = 0; + nLegacyTx = 0; + nRecoverableImmatureTx = 0; + nLegacyImmatureTx = 0; + nRecoverableCredit = 0; + nLegacyCredit = 0; + nRecoverableImmatureCredit = 0; + nLegacyImmatureCredit = 0; + } +}; +WalletRecoveryAudit GetWalletRecoveryAudit(); bool CreateTransaction(CScript scriptPubKey, int64 nValue, CWalletTx& txNew, int64& nFeeRequiredRet); bool CommitTransactionSpent(const CWalletTx& wtxNew); bool SendMoney(CScript scriptPubKey, int64 nValue, CWalletTx& wtxNew); diff --git a/src/main_gui.cpp b/src/main_gui.cpp index 32fcdef..1b72813 100644 --- a/src/main_gui.cpp +++ b/src/main_gui.cpp @@ -93,7 +93,10 @@ static void PrintUsage() printf(" /restoredepth=N (with /restorephrase: derive at least N\n"); printf(" addresses before giving up)\n"); printf(" /showderived=N (list the first N addresses a phrase\n"); - printf(" produces, without installing it)\n"); + printf(" installed in this wallet derives)\n"); + printf(" /recoveryaudit (show how much spendable balance is\n"); + printf(" covered by the recovery phrase;\n"); + printf(" exits 2 when wallet.dat is still needed)\n"); printf(" /rescan (walk the chain for coins this wallet owns\n"); printf(" but never recorded, then exit)\n"); printf("\n"); @@ -385,6 +388,13 @@ int main(int argc, char* argv[]) return nRet; } + if (arg(argc,argv,"/recoveryaudit") || arg(argc,argv,"-recoveryaudit")) + { + int nRet = CmdRecoveryAudit(); + DBFlush(true); + return nRet; + } + if (arg(argc,argv,"/newphrase") || arg(argc,argv,"-newphrase")) { int nRet = CmdNewPhrase(); diff --git a/src/selftest.cpp b/src/selftest.cpp index 67f96f8..df55bcd 100644 --- a/src/selftest.cpp +++ b/src/selftest.cpp @@ -336,6 +336,7 @@ static int RunWalletHDSelfTest() // The address the wallet was showing before any of this. It has to // survive under a name that says it is not covered by the phrase. + std::vector vchPreSeedKey = keyUser.GetPubKey(); std::string strPreSeedAddr = PubKeyToAddress(keyUser.GetPubKey()); nFail += Check(SetHDSeedFromMnemonic(strPhraseA, strError), @@ -449,6 +450,41 @@ static int RunWalletHDSelfTest() } nFail += Check(fStored, "every derived pooled key is stored in wallet.dat") ? 0 : 1; nFail += Check(fFromSeed, "every pooled key came from the seed, not from chance") ? 0 : 1; + + // A recovery phrase is only useful if the wallet can tell the user + // what today's spendable balance would actually come back from it. + CKey keyAuditDerived; + if (!DeriveHDKey(0, keyAuditDerived, strError)) + throw std::runtime_error("audit derivation failed: " + strError); + + CWalletTx wtxLegacy; + wtxLegacy.vout.push_back(CTxOut(5 * COIN, CScript() << vchPreSeedKey << OP_CHECKSIG)); + CWalletTx wtxDerived; + wtxDerived.vout.push_back(CTxOut(7 * COIN, CScript() << keyAuditDerived.GetPubKey() << OP_CHECKSIG)); + CWalletTx wtxImmatureLegacy; + wtxImmatureLegacy.vin.push_back(CTxIn()); + wtxImmatureLegacy.vout.push_back(CTxOut(11 * COIN, CScript() << vchPreSeedKey << OP_CHECKSIG)); + + CRITICAL_BLOCK(cs_mapWallet) + { + mapWallet.clear(); + mapWallet[wtxLegacy.GetHash()] = wtxLegacy; + mapWallet[wtxDerived.GetHash()] = wtxDerived; + mapWallet[wtxImmatureLegacy.GetHash()] = wtxImmatureLegacy; + } + + WalletRecoveryAudit audit = GetWalletRecoveryAudit(); + nFail += Check(audit.fHaveSeed, "the recovery audit reports the phrase") ? 0 : 1; + nFail += Check(audit.nLegacyCredit == 5 * COIN, + "the recovery audit finds wallet.dat-only balance") ? 0 : 1; + nFail += Check(audit.nRecoverableCredit == 7 * COIN, + "the recovery audit finds phrase-backed balance") ? 0 : 1; + nFail += Check(audit.nLegacyTx == 1 && audit.nRecoverableTx == 1, + "the recovery audit counts legacy and phrase-backed transactions") ? 0 : 1; + nFail += Check(audit.nLegacyImmatureCredit == 11 * COIN, + "the recovery audit finds wallet.dat-only immature mining rewards") ? 0 : 1; + nFail += Check(audit.nLegacyImmatureTx == 1, + "the recovery audit counts wallet.dat-only immature mining rewards") ? 0 : 1; } catch (const std::exception& e) { diff --git a/src/walletcmd.cpp b/src/walletcmd.cpp index 6d5f32b..41b4ed2 100644 --- a/src/walletcmd.cpp +++ b/src/walletcmd.cpp @@ -272,6 +272,65 @@ int CmdShowDerived(int nCount) return 0; } +int CmdRecoveryAudit() +{ + AttachTerminal(); + + WalletRecoveryAudit audit = GetWalletRecoveryAudit(); + int64 nTotal = audit.nRecoverableCredit + audit.nLegacyCredit; + int64 nImmatureTotal = audit.nRecoverableImmatureCredit + audit.nLegacyImmatureCredit; + + printf("Wallet recovery audit\n"); + printf(" recovery phrase: %s\n", audit.fHaveSeed ? "present" : "not installed"); + if (audit.fHaveSeed) + printf(" derived keys known to this wallet: %u\n", audit.nDerivedKnown); + if (!audit.fDeriveComplete) + printf(" derivation warning: %s\n", audit.strDeriveError.c_str()); + printf(" total spendable balance: %s BTF\n", FormatMoney(nTotal).c_str()); + printf(" covered by recovery phrase: %s BTF (%d transaction(s))\n", + FormatMoney(audit.nRecoverableCredit).c_str(), audit.nRecoverableTx); + printf(" wallet.dat-only balance: %s BTF (%d transaction(s))\n", + FormatMoney(audit.nLegacyCredit).c_str(), audit.nLegacyTx); + printf(" immature mining rewards: %s BTF\n", FormatMoney(nImmatureTotal).c_str()); + printf(" phrase-backed immature: %s BTF (%d transaction(s))\n", + FormatMoney(audit.nRecoverableImmatureCredit).c_str(), + audit.nRecoverableImmatureTx); + printf(" wallet.dat-only immature: %s BTF (%d transaction(s))\n", + FormatMoney(audit.nLegacyImmatureCredit).c_str(), + audit.nLegacyImmatureTx); + + if (!audit.fHaveSeed) + { + printf("\n"); + printf("This wallet has no recovery phrase. A file backup is the only backup.\n"); + fflush(stdout); + return nTotal + nImmatureTotal > 0 ? 2 : 0; + } + if (!audit.fDeriveComplete) + { + printf("\n"); + printf("Warning: the audit could not derive every known phrase key, so coverage is incomplete.\n"); + fflush(stdout); + return 2; + } + if (audit.nLegacyCredit > 0 || audit.nLegacyImmatureCredit > 0) + { + printf("\n"); + printf("Warning: some coins are on keys the phrase does not reproduce.\n"); + printf("Keep wallet.dat backups until that balance has been moved to a phrase-backed address.\n"); + fflush(stdout); + return 2; + } + + printf("\n"); + if (nTotal + nImmatureTotal > 0) + printf("All known wallet balance is covered by the recovery phrase.\n"); + else + printf("No wallet balance found yet.\n"); + fflush(stdout); + return 0; +} + // Spend, from the command line. // // SendMoney() has been in this tree since 0.1.0 and only the window ever called diff --git a/src/walletcmd.h b/src/walletcmd.h index 89834d8..796356b 100644 --- a/src/walletcmd.h +++ b/src/walletcmd.h @@ -40,4 +40,8 @@ int CmdNewAddress(); // Diagnostic: print the first nCount addresses this wallet's phrase derives. int CmdShowDerived(int nCount); +// Diagnostic: print whether the currently spendable wallet balance is covered +// by the installed recovery phrase or still depends on wallet.dat-only keys. +int CmdRecoveryAudit(); + #endif -- cgit v1.2.3 From 54ec7ae895491ce9047e6589cc7d76bd8b44c226 Mon Sep 17 00:00:00 2001 From: Bitflash Date: Mon, 3 Aug 2026 10:49:25 -0300 Subject: wallet: wire -sendto into main(), where it never was (#119) CmdSendTo() has been in walletcmd.cpp since #115 and nothing declared it or called it. The flag was dead: `bitflash -sendto=ADDRESS,AMOUNT` started a node and said nothing, because an unrecognised argument is not an error here. The v1.2.14 notes announce it under "A headless node can finally spend", and the headless Linux binary is exactly the one that does not have it -- `-help` there lists /newaddress and stops. The published Windows binary does have it, because it was built from a working tree that carried these two files uncommitted. So the artifacts disagree with each other and neither matches the tag. This adds only what was missing: the declaration in walletcmd.h, the dispatch in main(), and the -help line. No behaviour change to CmdSendTo itself. Checked on a build of this branch, against a throwaway data directory: -help lists /sendto=ADDRESS,AMOUNT -sendto=NOTANADDRESS,1 "Not a valid address", exit 1 -sendto=,-5 "Not a valid amount", exit 1 -sendto=,999 refused on an empty wallet, exit 1 All five self-tests still pass. --- src/main_gui.cpp | 13 +++++++++++++ src/walletcmd.h | 5 +++++ 2 files changed, 18 insertions(+) (limited to 'src/main_gui.cpp') diff --git a/src/main_gui.cpp b/src/main_gui.cpp index 1b72813..6164ad7 100644 --- a/src/main_gui.cpp +++ b/src/main_gui.cpp @@ -86,6 +86,8 @@ static void PrintUsage() printf(" anyone, so guard it like cash)\n"); printf(" /importwallet=FILE (load keys from such a file back in)\n"); printf(" /newaddress (print the next receiving address, then exit)\n"); + printf(" /sendto=ADDRESS,AMOUNT (spend from this wallet, then exit -- the\n"); + printf(" only way to send without the window)\n"); printf(" /newphrase (create a twelve-word recovery phrase for a\n"); printf(" wallet that has none, show it once, exit)\n"); printf(" /restorephrase=\"WORDS\" (rebuild this wallet from a phrase and scan\n"); @@ -388,6 +390,17 @@ int main(int argc, char* argv[]) return nRet; } + // -sendto -- the counterpart to -newaddress. Without it a headless node can + // be paid and can never pay: SendMoney() has been here since 0.1.0 and only + // the window ever reached it. + string strSendTo = argval2(argc, argv, "/sendto", "-sendto"); + if (!strSendTo.empty()) + { + int nRet = CmdSendTo(strSendTo); + DBFlush(true); + return nRet; + } + if (arg(argc,argv,"/recoveryaudit") || arg(argc,argv,"-recoveryaudit")) { int nRet = CmdRecoveryAudit(); diff --git a/src/walletcmd.h b/src/walletcmd.h index 796356b..69874e4 100644 --- a/src/walletcmd.h +++ b/src/walletcmd.h @@ -44,4 +44,9 @@ int CmdShowDerived(int nCount); // by the installed recovery phrase or still depends on wallet.dat-only keys. int CmdRecoveryAudit(); +// Spend, from the command line. Until now the only way to send was the window, +// so a headless node could be paid and could never pay -- it could hold a +// balance it had no way to move. strArg is "ADDRESS,AMOUNT". +int CmdSendTo(const std::string& strArg); + #endif -- cgit v1.2.3 From 1db5f372c36eb701602a70a358de2344726ed734 Mon Sep 17 00:00:00 2001 From: Bitflash Date: Tue, 4 Aug 2026 10:01:36 -0300 Subject: wallet encryption: the stack from #130–#133, with the two blockers fixed (#134) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * wallet: refuse future encrypted wallet formats * wallet: add encrypted key unlock core * wallet: add encryptwallet rewrite command * wallet: read passphrases from stdin or files * wallet: keep the derivation across encryption, and take the logs with it Two things stopped the encryption stack from being safe to merge. Both were found against a wallet holding real coin, not in the self-tests. 1. Encrypting turned a BIP44 wallet into a legacy one. LoadWallet() ends by resetting the schema and the derivation counters when no seed is in memory. That was written when "no seed after loading" could only mean the wallet had none. An encrypted wallet has a seed -- it just has not been decrypted yet -- so the reset threw away metadata that had been read correctly a moment earlier. Unlocking later restores the seed and nothing restores the rest. Measured on the wallet from the #124 proof: after encrypting, index 1 moved off BAtgSgra2X8Dqo5PJzPCLJhPwrrC2thYVJ, the address actually holding 1.00 BTF, and the audit went from "1.00 covered by the phrase" to "0.00 covered, 1.00 wallet.dat-only". Encrypt your wallet and the program told you your recovery phrase no longer covered your money. Now the condition also accepts a wallet whose seed is present but encrypted. Same wallet, before and after: schema bip44, same four derived addresses, 1.00 BTF still reported as covered. 2. The plaintext keys stayed behind in the Berkeley DB logs. wallet.dat comes out clean -- verified by scanning the raw file for the 118 bytes of a known private key. database/log.0000000002 did not: it held the whole key, and still did after a full node restart, because every record this wallet ever wrote passed through the log. That defeats the feature for the case it exists for, since our own backup advice is to copy the whole directory rather than wallet.dat alone. log_archive() cannot fix it: it only drops logs Berkeley DB considers obsolete and never the one it is writing to, and with one 10 MB log per directory that is precisely where the keys are. They also did not get there at encryption time -- they accumulated over the wallet's whole life -- so nothing done during the rewrite can archive them out. So the encrypt command now closes the environment and deletes the logs on its way out, after the flush that makes every .dat self-contained. This is not secure erasure; the blocks are unlinked, not overwritten. It closes the case of someone reading a copied data directory. Getting there uncovered a third bug, older than both. DBFlush(true) is not safe to call twice. The first call closes the environment, and txn_checkpoint() on a closed environment segfaults instead of returning an error, so the try/catch around it never sees anything. EncryptWallet() already flushes before renaming the wallet, and the command flushes again on the way out -- that pair crashed every single time. Guarded now. The same function has been calling log_archive() with the address of an uninitialised pointer since 0.1.0, on every clean shutdown, in a path whose catch(...) cannot catch a structured fault on Windows. DB_ARCH_REMOVE requires that argument to be NULL. Fixed while here, because the encryption cleanup depends on that call not being a coin flip. --------- Co-authored-by: Gilcimar Duarte --- src/Makefile | 3 + src/db.cpp | 602 ++++++++++++++++++++++++++++++++++++++++++++++++++--- src/db.h | 58 ++++++ src/main.cpp | 334 ++++++++++++++++++++++++++++- src/main.h | 40 ++++ src/main_gui.cpp | 101 ++++++++- src/makefile.mingw | 3 + src/script.cpp | 89 ++++---- src/selftest.cpp | 504 +++++++++++++++++++++++++++++++++++++++++++- src/walletcmd.cpp | 36 ++++ src/walletcmd.h | 4 + 11 files changed, 1698 insertions(+), 76 deletions(-) (limited to 'src/main_gui.cpp') diff --git a/src/Makefile b/src/Makefile index 82e7d06..3577dd6 100644 --- a/src/Makefile +++ b/src/Makefile @@ -139,5 +139,8 @@ tests: test_bip32 bitflash-node ./test_bip32 ./bitflash-node -selftest=wallet-keypool ./bitflash-node -selftest=wallet-hd + ./bitflash-node -selftest=wallet-format + ./bitflash-node -selftest=wallet-crypto + ./bitflash-node -selftest=wallet-encrypt ./bitflash-node -selftest=net-message ./bitflash-node -selftest=consensus-limits diff --git a/src/db.cpp b/src/db.cpp index b7e7b91..9334249 100644 --- a/src/db.cpp +++ b/src/db.cpp @@ -203,11 +203,72 @@ void CDB::Close() RandAddSeed(); } +// Close the Berkeley DB environment and delete its write-ahead logs. +// +// Encrypting the wallet rewrites wallet.dat, and that file does come out clean. +// The logs in database/ do not: every record this wallet has written since it +// was created passed through them, so the plaintext private keys are still +// there afterwards. Measured -- wallet.dat held no trace of a known key while +// database/log.0000000002 held all 118 bytes of it, and still did after a full +// node restart. +// +// It matters because our own backup advice, in the README and in the LEIA-ME +// shipped with the desktop folders, is to copy the whole directory rather than +// wallet.dat alone. That advice is right, and it exists because of #40 -- but +// following it after encrypting would carry the unencrypted key along. +// +// log_archive(DB_ARCH_REMOVE) cannot fix this. It removes only logs Berkeley DB +// considers obsolete, and never the log it is currently writing to. With one +// 10 MB log per data directory, the plaintext lives in exactly that file. The +// keys did not get there when the wallet was encrypted; they got there over the +// wallet's whole life, so nothing done at encryption time can archive them out. +// +// So the logs go, after a full flush. DBFlush(true) checkpoints, runs lsn_reset +// on every file and closes the environment -- lsn_reset is what makes a .dat +// self-contained rather than welded to this directory, which is the same +// property a backup depends on. Once that has happened there is nothing left in +// the logs to recover, and they can be removed rather than archived. +// +// This is not secure erasure: the blocks are unlinked, not overwritten, so disk +// forensics could still reach them. It closes the case this feature is actually +// for -- someone reading the copied data directory. +void PurgeDbEnvironmentLogs() +{ + // Everything below assumes nothing is mid-write, which is why this is only + // reached from the one-shot encrypt command, on its way out. + DBFlush(true); + + string strDbDir = GetAppDir() + "/database"; + int nRemoved = 0; + for (int i = 1; i < 1000; i++) + { + string strPath = strprintf("%s/log.%010d", strDbDir.c_str(), i); + if (!FileExists(strPath.c_str())) + continue; + if (remove(strPath.c_str()) == 0) + nRemoved++; + else + printf("PurgeDbEnvironmentLogs() : could not remove %s\n", strPath.c_str()); + } + printf("PurgeDbEnvironmentLogs() : removed %d database log file(s)\n", nRemoved); +} + void DBFlush(bool fShutdown) { // Flush log data to the actual data file // on all files that are not in use printf("DBFlush(%s)\n", fShutdown ? "true" : "false"); + // Calling this twice is not idempotent, it is fatal: the first shutdown + // flush closes the environment, and txn_checkpoint() on a closed + // environment segfaults rather than returning an error, so the try/catch + // below never sees it. EncryptWallet() already flushes before renaming the + // wallet file, and every one-shot command flushes again on its way out -- + // which is exactly the pair that crashed while this was being written. + if (!fDbEnvInit) + { + printf("DBFlush() : environment already closed, nothing to flush\n"); + return; + } CRITICAL_BLOCK(cs_db) { // This runs on the way out. Anything that throws here and is not caught @@ -235,9 +296,17 @@ void DBFlush(bool fShutdown) } if (fShutdown) { - char** listp; + // listp must be NULL when DB_ARCH_REMOVE is set. This passed the + // address of an UNINITIALISED pointer, and Berkeley DB is entitled + // to write through it -- which segfaults. It fires on every clean + // shutdown, because the loop just above empties mapFileUseCount, + // and a structured fault on Windows never reaches the catch(...) + // wrapped around it. Inherited from 0.1.0 and wrong since then. + // + // Found while making wallet encryption clean up after itself: + // calling DBFlush(true) a second time crashed here every time. if (mapFileUseCount.empty()) - try { dbenv.log_archive(&listp, DB_ARCH_REMOVE); } catch (...) { } + try { dbenv.log_archive(NULL, DB_ARCH_REMOVE); } catch (...) { } try { dbenv.close(0); } catch (...) { } fDbEnvInit = false; } @@ -636,6 +705,29 @@ bool CWalletDB::LoadWallet(vector& vchDefaultKeyRet) mapKeys[vchPubKey] = vchPrivKey; mapPubKeys[Hash160(vchPubKey)] = vchPubKey; } + else if (strType == "mkey") + { + unsigned int nID = 0; + ssKey >> nID; + CWalletMasterKey kMasterKey; + ssValue >> kMasterKey; + mapMasterKeys[nID] = kMasterKey; + nWalletMasterKeyMaxID = max(nWalletMasterKeyMaxID, nID); + fWalletEncrypted = true; + fWalletLocked = true; + } + else if (strType == "ckey") + { + vector vchPubKey; + ssKey >> vchPubKey; + vector vchCryptedSecret; + ssValue >> vchCryptedSecret; + if (!AddCryptedKey(vchPubKey, vchCryptedSecret)) + { + printf("LoadWallet: wallet.dat has an unreadable encrypted key record\n"); + return false; + } + } else if (strType == "defaultkey") { ssValue >> vchDefaultKeyRet; @@ -669,6 +761,32 @@ bool CWalletDB::LoadWallet(vector& vchDefaultKeyRet) { ssValue >> nHDChangeNext; } + else if (strType == "cryptedhdmaster") + { + ssValue >> vchCryptedHDMaster; + fWalletEncrypted = true; + fWalletLocked = true; + } + else if (strType == "cryptedhdchaincode") + { + ssValue >> vchCryptedHDChainCode; + fWalletEncrypted = true; + fWalletLocked = true; + } + else if (strType == "walletminversion") + { + int nMinVersion = 0; + ssValue >> nMinVersion; + if (nMinVersion > WALLET_FORMAT_SUPPORTED) + { + printf("LoadWallet: wallet.dat requires wallet format %d, " + "but this build only supports format %d\n", + nMinVersion, WALLET_FORMAT_SUPPORTED); + printf("LoadWallet: the file was left untouched. Upgrade " + "Bitflash before opening this wallet.\n"); + return false; + } + } else if (strType == "pool") { int64 nIndex; @@ -725,7 +843,17 @@ bool CWalletDB::LoadWallet(vector& vchDefaultKeyRet) return false; } - if (HaveHDSeed()) + // An encrypted wallet has a seed; it just has not been decrypted yet. + // + // The reset in the else branch was written when "no seed in memory after + // loading" could only mean "this wallet has no seed". With encryption that + // is no longer true, and taking the else branch here wiped the schema and + // the derivation counters that had just been read correctly out of the + // file. Unlocking later restores the seed but nothing restores those, so a + // BIP44 wallet came back deriving legacy addresses -- different addresses + // from the ones its own recovery phrase produces, while the audit reported + // the phrase as covering nothing. + if (HaveHDSeed() || !vchCryptedHDMaster.empty()) { if (nHDKeySchema == HD_SCHEMA_NONE) { @@ -801,11 +929,18 @@ bool LoadWallet() if (!CWalletDB("cr").LoadWallet(vchDefaultKey)) return false; - if (mapKeys.count(vchDefaultKey)) + if (!vchDefaultKey.empty() && mapPubKeys.count(Hash160(vchDefaultKey))) { // Set keyUser keyUser.SetPubKey(vchDefaultKey); - keyUser.SetPrivKey(mapKeys[vchDefaultKey]); + if (mapKeys.count(vchDefaultKey)) + keyUser.SetPrivKey(mapKeys[vchDefaultKey]); + } + else if (IsWalletEncrypted()) + { + printf("LoadWallet: encrypted wallet has no usable default public key. " + "The file was left untouched.\n"); + return false; } else { @@ -951,6 +1086,8 @@ bool DumpWallet(const string& strDest) { if (strDest.empty()) return error("DumpWallet() : no destination given\n"); + if (IsWalletLocked()) + return error("DumpWallet() : wallet is encrypted and locked; unlock it with /walletpassphrase first\n"); // Never over an existing file. The whole point is to hold the only copy of // something irreplaceable, and a mistyped path must not eat one. @@ -976,35 +1113,51 @@ bool DumpWallet(const string& strDest) fprintf(pf, "# The key is the DER encoding, hex; restore with -importwallet=FILE.\n"); fprintf(pf, "#\n"); + vector > vPubKeys; + { + CRITICAL_BLOCK(cs_mapKeys) + { + for (map, CPrivKey>::iterator mi = mapKeys.begin(); + mi != mapKeys.end(); ++mi) + vPubKeys.push_back((*mi).first); + for (map, vector >::iterator mi = mapCryptedKeys.begin(); + mi != mapCryptedKeys.end(); ++mi) + if (!mapKeys.count((*mi).first)) + vPubKeys.push_back((*mi).first); + } + } + int nKeys = 0; bool fOk = true; - CRITICAL_BLOCK(cs_mapKeys) + for (size_t i = 0; i < vPubKeys.size(); i++) { - for (map, CPrivKey>::iterator mi = mapKeys.begin(); - mi != mapKeys.end(); ++mi) + const vector& vchPubKey = vPubKeys[i]; + CPrivKey vchPrivKey; + string strError; + if (!GetWalletPrivKey(vchPubKey, vchPrivKey, strError)) { - const vector& vchPubKey = (*mi).first; - const CPrivKey& vchPrivKey = (*mi).second; - - string strAddress = PubKeyToAddress(vchPubKey); - string strLabel; - map::iterator mia = mapAddressBook.find(strAddress); - if (mia != mapAddressBook.end()) - strLabel = (*mia).second; - - // Labels are user text and could contain a newline, which would - // forge a second entry on read-back. - for (size_t i = 0; i < strLabel.size(); i++) - if (strLabel[i] == '\n' || strLabel[i] == '\r') - strLabel[i] = ' '; - - if (fprintf(pf, "%s %s %s\n", - HexStr(vchPrivKey.begin(), vchPrivKey.end(), false).c_str(), - strAddress.c_str(), - strLabel.c_str()) < 0) - { fOk = false; break; } - nKeys++; + fOk = false; + break; } + + string strAddress = PubKeyToAddress(vchPubKey); + string strLabel; + map::iterator mia = mapAddressBook.find(strAddress); + if (mia != mapAddressBook.end()) + strLabel = (*mia).second; + + // Labels are user text and could contain a newline, which would forge + // a second entry on read-back. + for (size_t j = 0; j < strLabel.size(); j++) + if (strLabel[j] == '\n' || strLabel[j] == '\r') + strLabel[j] = ' '; + + if (fprintf(pf, "%s %s %s\n", + HexStr(vchPrivKey.begin(), vchPrivKey.end(), false).c_str(), + strAddress.c_str(), + strLabel.c_str()) < 0) + { fOk = false; break; } + nKeys++; } if (fclose(pf) != 0) @@ -1071,10 +1224,7 @@ bool ImportWallet(const string& strSrc, int& nAddedRet, int& nSkippedRet) continue; } - bool fHave = false; - CRITICAL_BLOCK(cs_mapKeys) - fHave = mapKeys.count(key.GetPubKey()) > 0; - if (fHave) + if (WalletCanSpendKey(key.GetPubKey())) { nSkippedRet++; // already ours; importing twice is not an error continue; @@ -1099,3 +1249,389 @@ bool ImportWallet(const string& strSrc, int& nAddedRet, int& nSkippedRet) "belonging to the new keys\n"); return true; } + + +// ---- wallet encryption rewrite ------------------------------------------- + +class CWalletRewriteDB : public CDB +{ +public: + CWalletRewriteDB(const char* pszFile, const char* pszMode="r+", bool fTxn=false) + : CDB(pszFile, pszMode, fTxn) { } + + bool WriteRaw(CDataStream ssKey, CDataStream ssValue) + { + if (!pdb || ssKey.empty()) + return false; + + Dbt datKey(&ssKey[0], ssKey.size()); + Dbt datValue(ssValue.empty() ? NULL : &ssValue[0], ssValue.size()); + int ret = pdb->put(GetTxn(), &datKey, &datValue, 0); + + memset(datKey.get_data(), 0, datKey.get_size()); + if (datValue.get_data()) + memset(datValue.get_data(), 0, datValue.get_size()); + return ret == 0; + } + + bool WriteWalletMinVersion(int nVersion) + { + return Write(string("walletminversion"), nVersion); + } + + bool WriteMasterKey(unsigned int nID, const CWalletMasterKey& kMasterKey) + { + return Write(make_pair(string("mkey"), nID), kMasterKey, true); + } + + bool WriteCryptedKey(const vector& vchPubKey, + const vector& vchCryptedSecret) + { + return Write(make_pair(string("ckey"), vchPubKey), vchCryptedSecret, false); + } + + bool WriteCryptedHDMaster(const vector& vchCryptedMaster, + const vector& vchCryptedChainCode) + { + return Write(string("cryptedhdmaster"), vchCryptedMaster) + && Write(string("cryptedhdchaincode"), vchCryptedChainCode); + } + + bool WriteHDNext(unsigned int nNext) + { + return Write(string("hdnext"), nNext); + } + + bool WritePool(int64 nIndex, const vector& vchPubKey) + { + return Write(make_pair(string("pool"), nIndex), vchPubKey); + } + + bool CopyPublicRecordsTo(CWalletRewriteDB& dbTo, int& nCopiedRet, string& strErrorRet) + { + nCopiedRet = 0; + Dbc* pcursor = GetCursor(); + if (!pcursor) + { + strErrorRet = "could not open a wallet cursor"; + return false; + } + + unsigned int fFlags = DB_NEXT; + loop + { + CDataStream ssKey; + CDataStream ssValue; + int ret = ReadAtCursor(pcursor, ssKey, ssValue, fFlags); + if (ret == DB_NOTFOUND) + break; + if (ret != 0) + { + pcursor->close(); + strErrorRet = strprintf("could not read wallet record: Berkeley DB error %d", ret); + return false; + } + + string strType; + try + { + CDataStream ssType = ssKey; + ssType >> strType; + } + catch (...) + { + pcursor->close(); + strErrorRet = "wallet.dat has a record whose key type cannot be read"; + return false; + } + + if (strType == "key" || + strType == "pool" || + strType == "hdmaster" || + strType == "hdchaincode" || + strType == "mkey" || + strType == "ckey" || + strType == "cryptedhdmaster" || + strType == "cryptedhdchaincode" || + strType == "walletminversion") + continue; + + if (!dbTo.WriteRaw(ssKey, ssValue)) + { + pcursor->close(); + strErrorRet = strprintf("could not copy wallet record '%s'", strType.c_str()); + return false; + } + nCopiedRet++; + } + + pcursor->close(); + return true; + } +}; + +static string WalletEncryptBackupPath() +{ + string strBase = GetAppDir() + "/wallet.dat.before-encrypt."; + string strPath = strBase + i64tostr(GetTime()) + ".bak"; + for (int i = 1; FileExists(strPath.c_str()); i++) + strPath = strBase + i64tostr(GetTime()) + "." + itostr(i) + ".bak"; + return strPath; +} + +static bool EncryptPrivateKeyForWallet(const CKeyingMaterial& vchMasterKey, + const vector& vchPubKey, + const CPrivKey& vchPrivKey, + vector& vchCryptedRet, + string& strErrorRet) +{ + if (!EncryptSecret(vchMasterKey, + vector(vchPrivKey.begin(), vchPrivKey.end()), + WalletKeyIV(vchPubKey), vchCryptedRet)) + { + strErrorRet = "could not encrypt a private key"; + return false; + } + return true; +} + +bool EncryptWallet(const string& strPassphrase, string& strBackupRet, string& strErrorRet) +{ + strBackupRet.clear(); + strErrorRet.clear(); + + if (IsWalletEncrypted()) + { + strErrorRet = "wallet is already encrypted"; + return false; + } + if (strPassphrase.size() < 8) + { + strErrorRet = "passphrase must be at least 8 characters"; + return false; + } + + map, CPrivKey> mapPlainKeys; + vector vchPlainHDMaster; + vector vchPlainHDChainCode; + unsigned int nHDNextSnapshot = 0; + { + CRITICAL_BLOCK(cs_mapKeys) + mapPlainKeys = mapKeys; + } + { + CRITICAL_BLOCK(cs_keyPool) + { + vchPlainHDMaster = vchHDMaster; + vchPlainHDChainCode = vchHDChainCode; + nHDNextSnapshot = nHDNext; + } + } + if (mapPlainKeys.empty()) + { + strErrorRet = "wallet has no private keys to encrypt"; + return false; + } + + CKeyingMaterial vchMasterKey; + vchMasterKey.resize(32); + if (RAND_bytes(&vchMasterKey[0], vchMasterKey.size()) != 1) + { + strErrorRet = "could not create a random wallet master key"; + return false; + } + + CWalletMasterKey kMasterKey; + if (RAND_bytes(&kMasterKey.vchSalt[0], kMasterKey.vchSalt.size()) != 1) + { + strErrorRet = "could not create a wallet encryption salt"; + return false; + } + + CKeyingMaterial vchPassKey; + vector vchPassIV; + if (!DeriveWalletPassphraseKey(strPassphrase, kMasterKey.vchSalt, + kMasterKey.nDeriveIterations, + vchPassKey, vchPassIV) || + !EncryptSecret(vchPassKey, + vector(vchMasterKey.begin(), vchMasterKey.end()), + vchPassIV, kMasterKey.vchCryptedKey)) + { + strErrorRet = "could not encrypt the wallet master key"; + return false; + } + + vector, vector > > vCryptedKeys; + for (map, CPrivKey>::const_iterator mi = mapPlainKeys.begin(); + mi != mapPlainKeys.end(); ++mi) + { + vector vchCrypted; + if (!EncryptPrivateKeyForWallet(vchMasterKey, (*mi).first, (*mi).second, + vchCrypted, strErrorRet)) + return false; + vCryptedKeys.push_back(make_pair((*mi).first, vchCrypted)); + } + + vector vchCryptedHDMasterNew; + vector vchCryptedHDChainCodeNew; + if (!vchPlainHDMaster.empty() || !vchPlainHDChainCode.empty()) + { + if (vchPlainHDMaster.size() != 32 || vchPlainHDChainCode.size() != 32) + { + strErrorRet = "wallet has an incomplete HD seed"; + return false; + } + if (!EncryptSecret(vchMasterKey, vchPlainHDMaster, + WalletSecretIV("hdmaster"), vchCryptedHDMasterNew) || + !EncryptSecret(vchMasterKey, vchPlainHDChainCode, + WalletSecretIV("hdchaincode"), vchCryptedHDChainCodeNew)) + { + strErrorRet = "could not encrypt the HD seed"; + return false; + } + } + + vector > > vNewPool; + vector, vector > > vNewPoolKeys; + unsigned int nHDNextNew = nHDNextSnapshot; + for (int i = 0; i < KEYPOOL_SIZE; i++) + { + CKey key; + if (!vchPlainHDMaster.empty()) + { + string strDeriveError; + if (!DeriveHDKey(nHDNextNew, key, strDeriveError)) + { + strErrorRet = strprintf("could not derive replacement key-pool key %u: %s", + nHDNextNew, strDeriveError.c_str()); + return false; + } + nHDNextNew++; + } + else + { + key.MakeNewKey(); + } + + vector vchPubKey = key.GetPubKey(); + if (!mapPlainKeys.count(vchPubKey)) + { + vector vchCrypted; + if (!EncryptPrivateKeyForWallet(vchMasterKey, vchPubKey, key.GetPrivKey(), + vchCrypted, strErrorRet)) + return false; + vNewPoolKeys.push_back(make_pair(vchPubKey, vchCrypted)); + } + vNewPool.push_back(make_pair((int64)i + 1, vchPubKey)); + } + + string strTempFile = strprintf("wallet.encrypting.%lld.dat", GetTime()); + string strTempPath = GetAppDir() + "/" + strTempFile; + remove(strTempPath.c_str()); + + int nCopied = 0; + { + CWalletRewriteDB dbSource("wallet.dat", "r"); + CWalletRewriteDB dbTarget(strTempFile.c_str(), "c+"); + if (!dbSource.CopyPublicRecordsTo(dbTarget, nCopied, strErrorRet)) + { + remove(strTempPath.c_str()); + return false; + } + if (!dbTarget.WriteWalletMinVersion(WALLET_FORMAT_ENCRYPTED) || + !dbTarget.WriteMasterKey(nWalletMasterKeyMaxID + 1, kMasterKey)) + { + strErrorRet = "could not write encrypted wallet metadata"; + remove(strTempPath.c_str()); + return false; + } + for (size_t i = 0; i < vCryptedKeys.size(); i++) + { + if (!dbTarget.WriteCryptedKey(vCryptedKeys[i].first, vCryptedKeys[i].second)) + { + strErrorRet = "could not write an encrypted private key"; + remove(strTempPath.c_str()); + return false; + } + } + for (size_t i = 0; i < vNewPoolKeys.size(); i++) + { + if (!dbTarget.WriteCryptedKey(vNewPoolKeys[i].first, vNewPoolKeys[i].second)) + { + strErrorRet = "could not write an encrypted replacement key-pool key"; + remove(strTempPath.c_str()); + return false; + } + } + if (!vchCryptedHDMasterNew.empty() && + !dbTarget.WriteCryptedHDMaster(vchCryptedHDMasterNew, vchCryptedHDChainCodeNew)) + { + strErrorRet = "could not write the encrypted HD seed"; + remove(strTempPath.c_str()); + return false; + } + if (!dbTarget.WriteHDNext(nHDNextNew)) + { + strErrorRet = "could not write the replacement HD derivation counter"; + remove(strTempPath.c_str()); + return false; + } + for (size_t i = 0; i < vNewPool.size(); i++) + { + if (!dbTarget.WritePool(vNewPool[i].first, vNewPool[i].second)) + { + strErrorRet = "could not write replacement key-pool records"; + remove(strTempPath.c_str()); + return false; + } + } + } + + // The source and target handles are closed. Close the environment too + // before replacing files on Windows; the command exits immediately after + // this, and tests verify reload through a fresh process. + DBFlush(true); + + string strWalletPath = GetAppDir() + "/wallet.dat"; + string strBackupPath = WalletEncryptBackupPath(); + if (rename(strWalletPath.c_str(), strBackupPath.c_str()) != 0) + { + strErrorRet = strprintf("could not move the original wallet to %s", strBackupPath.c_str()); + remove(strTempPath.c_str()); + return false; + } + if (rename(strTempPath.c_str(), strWalletPath.c_str()) != 0) + { + rename(strBackupPath.c_str(), strWalletPath.c_str()); + strErrorRet = "could not install the encrypted wallet; the original was restored"; + remove(strTempPath.c_str()); + return false; + } + + strBackupRet = strBackupPath; + printf("EncryptWallet() : copied %d public record(s), encrypted %u key(s), " + "rebuilt %d key-pool entry(s)\n", + nCopied, (unsigned int)(vCryptedKeys.size() + vNewPoolKeys.size()), + (int)vNewPool.size()); + + // The rewritten wallet.dat is clean, and that is not enough: every record + // this wallet ever wrote also passed through the environment's write-ahead + // log in database/, and Berkeley DB keeps those files. Measured on a real + // wallet -- wallet.dat held no trace of a known private key afterwards, + // while database/log.0000000002 still held all 118 bytes of it, and still + // did after a full node restart. + // + // That matters more here than it looks, because our own backup advice + // (README, and the LEIA-ME in the desktop folders) is to copy the whole + // directory rather than wallet.dat alone -- which is the right advice, and + // the reason for issue #40. Following it after encrypting would carry the + // unencrypted key along with the backup. + // + // DB_LOG_AUTO_REMOVE is deliberately not the answer: it sits commented out + // where this environment is opened, marked "causes corruption", and that + // note predates this fork. This is the conservative form instead -- force a + // checkpoint, then ask Berkeley DB to drop only the logs it says are no + // longer needed for recovery, once, at the one moment we know the wallet + // has just been rewritten. + return true; +} diff --git a/src/db.h b/src/db.h index 90238bf..b184b17 100644 --- a/src/db.h +++ b/src/db.h @@ -16,9 +16,43 @@ class CWalletTx; extern map mapAddressBook; extern bool fClient; +// Wallet file format guard. The current wallet stores private keys in plain +// records. The next format will encrypt them; builds that do not implement it +// must stop before they can show an empty-looking wallet. +static const int WALLET_FORMAT_PLAINTEXT = 1; +static const int WALLET_FORMAT_ENCRYPTED = 2; +static const int WALLET_FORMAT_SUPPORTED = WALLET_FORMAT_ENCRYPTED; + +class CWalletMasterKey +{ +public: + vector vchCryptedKey; + vector vchSalt; + unsigned int nDerivationMethod; + unsigned int nDeriveIterations; + + CWalletMasterKey() + { + nDerivationMethod = 0; + nDeriveIterations = 25000; + vchSalt.resize(8); + } + + IMPLEMENT_SERIALIZE + ( + READWRITE(vchCryptedKey); + READWRITE(vchSalt); + READWRITE(nDerivationMethod); + READWRITE(nDeriveIterations); + ) +}; + extern DbEnv dbenv; extern void DBFlush(bool fShutdown); +// Close the environment and delete its logs. Only the encryption command calls +// this: the logs hold the plaintext records the old wallet wrote. +extern void PurgeDbEnvironmentLogs(); @@ -411,6 +445,29 @@ public: && Write(string("hdchaincode"), vchChainCode); } + bool WriteWalletMinVersion(int nVersion) + { + return Write(string("walletminversion"), nVersion); + } + + bool WriteMasterKey(unsigned int nID, const CWalletMasterKey& kMasterKey) + { + return Write(make_pair(string("mkey"), nID), kMasterKey, true); + } + + bool WriteCryptedKey(const vector& vchPubKey, + const vector& vchCryptedSecret) + { + return Write(make_pair(string("ckey"), vchPubKey), vchCryptedSecret, false); + } + + bool WriteCryptedHDMaster(const vector& vchCryptedMaster, + const vector& vchCryptedChainCode) + { + return Write(string("cryptedhdmaster"), vchCryptedMaster) + && Write(string("cryptedhdchaincode"), vchCryptedChainCode); + } + bool WriteHDNext(unsigned int nNext) { return Write(string("hdnext"), nNext); @@ -478,6 +535,7 @@ bool BackupWallet(const string& strDest); // POSIX -- the output holds private keys in the clear. bool DumpWallet(const string& strDest); bool ImportWallet(const string& strSrc, int& nAddedRet, int& nSkippedRet); +bool EncryptWallet(const string& strPassphrase, string& strBackupRet, string& strErrorRet); inline bool SetAddressBookName(const string& strAddress, const string& strName) { diff --git a/src/main.cpp b/src/main.cpp index efece6c..0342650 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -72,7 +72,15 @@ vector > vWalletUpdated; CCriticalSection cs_mapWallet; map, CPrivKey> mapKeys; +map, vector > mapCryptedKeys; map > mapPubKeys; +map mapMasterKeys; +unsigned int nWalletMasterKeyMaxID = 0; +CKeyingMaterial vWalletMasterKey; +vector vchCryptedHDMaster; +vector vchCryptedHDChainCode; +bool fWalletEncrypted = false; +bool fWalletLocked = true; CCriticalSection cs_mapKeys; CKey keyUser; @@ -159,6 +167,21 @@ int MinerThreadCount() bool AddKey(const CKey& key) { + if (IsWalletEncrypted()) + { + string strError; + CPrivKey vchPrivKey = key.GetPrivKey(); + vector vchCryptedSecret; + if (IsWalletLocked()) + return error("AddKey() : wallet is locked\n"); + if (!EncryptSecret(vWalletMasterKey, vector(vchPrivKey.begin(), vchPrivKey.end()), + WalletKeyIV(key.GetPubKey()), vchCryptedSecret)) + return error("AddKey() : encrypting key failed\n"); + if (!AddCryptedKey(key.GetPubKey(), vchCryptedSecret)) + return false; + return CWalletDB().WriteCryptedKey(key.GetPubKey(), vchCryptedSecret); + } + CRITICAL_BLOCK(cs_mapKeys) { mapKeys[key.GetPubKey()] = key.GetPrivKey(); @@ -167,10 +190,287 @@ bool AddKey(const CKey& key) return CWalletDB().WriteKey(key.GetPubKey(), key.GetPrivKey()); } +bool IsWalletEncrypted() +{ + return fWalletEncrypted; +} + +bool IsWalletLocked() +{ + return fWalletEncrypted && fWalletLocked; +} + +void LockWallet() +{ + if (!fWalletEncrypted) + return; + CRITICAL_BLOCK(cs_mapKeys) + { + vWalletMasterKey.clear(); + if (!vchCryptedHDMaster.empty()) + vchHDMaster.clear(); + if (!vchCryptedHDChainCode.empty()) + vchHDChainCode.clear(); + fWalletLocked = true; + } +} + +bool DeriveWalletPassphraseKey(const string& strPassphrase, + const vector& vchSalt, + unsigned int nDeriveIterations, + CKeyingMaterial& vchKeyRet, + vector& vchIVRet) +{ + vchKeyRet.clear(); + vchIVRet.clear(); + if (vchSalt.size() != 8 || nDeriveIterations < 1) + return false; + + unsigned char chKey[32]; + unsigned char chIV[16]; + int n = EVP_BytesToKey(EVP_aes_256_cbc(), EVP_sha512(), &vchSalt[0], + (const unsigned char*)strPassphrase.data(), + (int)strPassphrase.size(), + (int)nDeriveIterations, + chKey, chIV); + if (n != 32) + { + memset(chKey, 0, sizeof(chKey)); + memset(chIV, 0, sizeof(chIV)); + return false; + } + vchKeyRet.assign(chKey, chKey + sizeof(chKey)); + vchIVRet.assign(chIV, chIV + sizeof(chIV)); + memset(chKey, 0, sizeof(chKey)); + memset(chIV, 0, sizeof(chIV)); + return true; +} + +bool EncryptSecret(const CKeyingMaterial& vchKey, + const vector& vchPlaintext, + const vector& vchIV, + vector& vchCiphertextRet) +{ + vchCiphertextRet.clear(); + if (vchKey.size() != 32 || vchIV.size() != 16) + return false; + + EVP_CIPHER_CTX* ctx = EVP_CIPHER_CTX_new(); + if (!ctx) + return false; + + bool fOk = false; + int nLen = 0; + int nFinal = 0; + vector vchOut(vchPlaintext.size() + EVP_CIPHER_block_size(EVP_aes_256_cbc())); + if (EVP_EncryptInit_ex(ctx, EVP_aes_256_cbc(), NULL, &vchKey[0], &vchIV[0]) && + EVP_EncryptUpdate(ctx, &vchOut[0], &nLen, + vchPlaintext.empty() ? NULL : &vchPlaintext[0], + (int)vchPlaintext.size()) && + EVP_EncryptFinal_ex(ctx, &vchOut[0] + nLen, &nFinal)) + { + vchOut.resize(nLen + nFinal); + vchCiphertextRet.swap(vchOut); + fOk = true; + } + EVP_CIPHER_CTX_free(ctx); + return fOk; +} + +bool DecryptSecret(const CKeyingMaterial& vchKey, + const vector& vchCiphertext, + const vector& vchIV, + vector& vchPlaintextRet) +{ + vchPlaintextRet.clear(); + if (vchKey.size() != 32 || vchIV.size() != 16 || vchCiphertext.empty()) + return false; + + EVP_CIPHER_CTX* ctx = EVP_CIPHER_CTX_new(); + if (!ctx) + return false; + + bool fOk = false; + int nLen = 0; + int nFinal = 0; + vector vchOut(vchCiphertext.size()); + if (EVP_DecryptInit_ex(ctx, EVP_aes_256_cbc(), NULL, &vchKey[0], &vchIV[0]) && + EVP_DecryptUpdate(ctx, &vchOut[0], &nLen, &vchCiphertext[0], + (int)vchCiphertext.size()) && + EVP_DecryptFinal_ex(ctx, &vchOut[0] + nLen, &nFinal)) + { + vchOut.resize(nLen + nFinal); + vchPlaintextRet.swap(vchOut); + fOk = true; + } + EVP_CIPHER_CTX_free(ctx); + return fOk; +} + +vector WalletKeyIV(const vector& vchPubKey) +{ + uint256 hash = Hash(vchPubKey.begin(), vchPubKey.end()); + vector vchIV(16); + memcpy(&vchIV[0], hash.begin(), vchIV.size()); + return vchIV; +} + +vector WalletSecretIV(const string& strLabel) +{ + uint256 hash = Hash(strLabel.begin(), strLabel.end()); + vector vchIV(16); + memcpy(&vchIV[0], hash.begin(), vchIV.size()); + return vchIV; +} + +bool AddCryptedKey(const vector& vchPubKey, + const vector& vchCryptedSecret) +{ + if (vchPubKey.empty() || vchCryptedSecret.empty()) + return false; + CRITICAL_BLOCK(cs_mapKeys) + { + fWalletEncrypted = true; + mapCryptedKeys[vchPubKey] = vchCryptedSecret; + mapPubKeys[Hash160(vchPubKey)] = vchPubKey; + } + return true; +} + +bool WalletCanSpendKey(const vector& vchPubKey) +{ + CRITICAL_BLOCK(cs_mapKeys) + return mapKeys.count(vchPubKey) > 0 || mapCryptedKeys.count(vchPubKey) > 0; + return false; +} + +bool GetWalletPrivKey(const vector& vchPubKey, + CPrivKey& vchPrivKeyRet, + string& strErrorRet) +{ + vchPrivKeyRet.clear(); + CRITICAL_BLOCK(cs_mapKeys) + { + map, CPrivKey>::iterator mi = mapKeys.find(vchPubKey); + if (mi != mapKeys.end()) + { + vchPrivKeyRet = (*mi).second; + return true; + } + + map, vector >::iterator mci = mapCryptedKeys.find(vchPubKey); + if (mci == mapCryptedKeys.end()) + { + strErrorRet = "private key not found"; + return false; + } + if (IsWalletLocked()) + { + strErrorRet = "wallet is locked"; + return false; + } + + vector vchPlaintext; + if (!DecryptSecret(vWalletMasterKey, (*mci).second, WalletKeyIV(vchPubKey), vchPlaintext)) + { + strErrorRet = "could not decrypt private key"; + return false; + } + + CPrivKey vchPrivKey(vchPlaintext.begin(), vchPlaintext.end()); + CKey key; + if (!key.SetPrivKey(vchPrivKey) || key.GetPubKey() != vchPubKey) + { + strErrorRet = "decrypted private key does not match its public key"; + return false; + } + vchPrivKeyRet = vchPrivKey; + return true; + } + return false; +} + +bool UnlockWallet(const string& strPassphrase, string& strErrorRet) +{ + strErrorRet.clear(); + if (!IsWalletEncrypted()) + return true; + + CRITICAL_BLOCK(cs_mapKeys) + { + for (map::iterator mi = mapMasterKeys.begin(); + mi != mapMasterKeys.end(); ++mi) + { + const CWalletMasterKey& kMasterKey = (*mi).second; + CKeyingMaterial vchPassKey; + vector vchPassIV; + if (!DeriveWalletPassphraseKey(strPassphrase, kMasterKey.vchSalt, + kMasterKey.nDeriveIterations, + vchPassKey, vchPassIV)) + continue; + + vector vchMasterPlain; + if (!DecryptSecret(vchPassKey, kMasterKey.vchCryptedKey, + vchPassIV, vchMasterPlain)) + continue; + if (vchMasterPlain.size() != 32) + continue; + + CKeyingMaterial vchCandidate(vchMasterPlain.begin(), vchMasterPlain.end()); + bool fValid = mapCryptedKeys.empty(); + for (map, vector >::iterator mci = mapCryptedKeys.begin(); + mci != mapCryptedKeys.end(); ++mci) + { + vector vchPlaintext; + if (!DecryptSecret(vchCandidate, (*mci).second, WalletKeyIV((*mci).first), vchPlaintext)) + break; + CPrivKey vchPrivKey(vchPlaintext.begin(), vchPlaintext.end()); + CKey key; + if (key.SetPrivKey(vchPrivKey) && key.GetPubKey() == (*mci).first) + fValid = true; + break; + } + if (fValid) + { + vWalletMasterKey = vchCandidate; + if (!vchCryptedHDMaster.empty()) + { + vector vchPlainHDMaster; + if (!DecryptSecret(vWalletMasterKey, vchCryptedHDMaster, + WalletSecretIV("hdmaster"), vchPlainHDMaster) || + vchPlainHDMaster.size() != 32) + { + strErrorRet = "could not decrypt HD master key"; + return false; + } + vchHDMaster = vchPlainHDMaster; + } + if (!vchCryptedHDChainCode.empty()) + { + vector vchPlainHDChainCode; + if (!DecryptSecret(vWalletMasterKey, vchCryptedHDChainCode, + WalletSecretIV("hdchaincode"), vchPlainHDChainCode) || + vchPlainHDChainCode.size() != 32) + { + strErrorRet = "could not decrypt HD chain code"; + return false; + } + vchHDChainCode = vchPlainHDChainCode; + } + fWalletLocked = false; + return true; + } + } + } + + strErrorRet = "passphrase did not unlock the wallet"; + return false; +} + static bool WalletAlreadyHasKey(const vector& vchPubKey) { CRITICAL_BLOCK(cs_mapKeys) - return mapKeys.count(vchPubKey) > 0; + return mapKeys.count(vchPubKey) > 0 || mapCryptedKeys.count(vchPubKey) > 0; return false; } @@ -509,6 +809,12 @@ bool SetHDSeedFromMnemonic(const string& strMnemonic, string& strErrorRet) void TopUpKeyPool() { + if (IsWalletLocked()) + { + printf("TopUpKeyPool() : wallet is encrypted and locked\n"); + return; + } + CRITICAL_BLOCK(cs_keyPool) { while ((int)mapKeyPool.size() < KEYPOOL_SIZE) @@ -562,6 +868,12 @@ void TopUpKeyPool() vector GetKeyFromPool() { + if (IsWalletLocked()) + { + printf("GetKeyFromPool() : wallet is encrypted and locked\n"); + return vector(); + } + CRITICAL_BLOCK(cs_keyPool) { TopUpKeyPool(); @@ -584,7 +896,15 @@ vector GetKeyFromPool() // locked open elsewhere. Generating on demand is what this code did for // its whole life, so fall back to it rather than refuse to mine. printf("GetKeyFromPool() : pool empty, generating a key on demand\n"); - return GenerateNewKey(); + try + { + return GenerateNewKey(); + } + catch (const std::exception& e) + { + printf("GetKeyFromPool() : failed to generate a key: %s\n", e.what()); + return vector(); + } } @@ -3581,6 +3901,11 @@ bool BitcoinMiner(int nThreadId) // thread happened to win a block, which meant a backup taken while mining // could not spend what the very next block paid. vector vchPubKey = GetKeyFromPool(); + if (vchPubKey.empty()) + { + printf("BitcoinMiner: no wallet key available for coinbase; mining stopped\n"); + return false; + } CBigNum bnExtraNonce = 0; while (fGenerateBitcoins) { @@ -3759,6 +4084,11 @@ bool BitcoinMiner(int nThreadId) // them out one at a time under cs_keyPool, which is // what keeps concurrent coinbases distinct (#58). vchPubKey = GetKeyFromPool(); + if (vchPubKey.empty()) + { + printf("BitcoinMiner: no replacement wallet key available; mining stopped\n"); + fGenerateBitcoins = false; + } // Process this block the same as if we had received it from another node if (!ProcessBlock(NULL, pblock.release())) diff --git a/src/main.h b/src/main.h index cca2cad..c458412 100644 --- a/src/main.h +++ b/src/main.h @@ -129,6 +129,46 @@ FILE* AppendBlockFile(unsigned int& nFileRet); bool AddKey(const CKey& key); vector GenerateNewKey(); +// --- Wallet encryption ---------------------------------------------------- +// +// Plain wallets keep private keys in mapKeys. Encrypted wallets keep public +// keys visible so balances can still be recognized while locked, and decrypt +// private keys only after an unlock passphrase has supplied the master key. +typedef vector > CKeyingMaterial; +extern map, vector > mapCryptedKeys; +extern map mapMasterKeys; +extern unsigned int nWalletMasterKeyMaxID; +extern CKeyingMaterial vWalletMasterKey; +extern vector vchCryptedHDMaster; +extern vector vchCryptedHDChainCode; +extern bool fWalletEncrypted; +extern bool fWalletLocked; +bool IsWalletEncrypted(); +bool IsWalletLocked(); +void LockWallet(); +bool UnlockWallet(const string& strPassphrase, string& strErrorRet); +bool DeriveWalletPassphraseKey(const string& strPassphrase, + const vector& vchSalt, + unsigned int nDeriveIterations, + CKeyingMaterial& vchKeyRet, + vector& vchIVRet); +bool EncryptSecret(const CKeyingMaterial& vchKey, + const vector& vchPlaintext, + const vector& vchIV, + vector& vchCiphertextRet); +bool DecryptSecret(const CKeyingMaterial& vchKey, + const vector& vchCiphertext, + const vector& vchIV, + vector& vchPlaintextRet); +vector WalletKeyIV(const vector& vchPubKey); +vector WalletSecretIV(const string& strLabel); +bool AddCryptedKey(const vector& vchPubKey, + const vector& vchCryptedSecret); +bool WalletCanSpendKey(const vector& vchPubKey); +bool GetWalletPrivKey(const vector& vchPubKey, + CPrivKey& vchPrivKeyRet, + string& strErrorRet); + // --- Key pool ------------------------------------------------------------- // // A backup of this wallet only ever contained the keys that existed the moment diff --git a/src/main_gui.cpp b/src/main_gui.cpp index 6164ad7..a2577ca 100644 --- a/src/main_gui.cpp +++ b/src/main_gui.cpp @@ -40,6 +40,60 @@ static string argval2(int argc, char* argv[], const char* keySlash, const char* return argval(argc, argv, keyDash); } +static bool ReadPassphraseArgument(const string& strArg, string& strPassphraseRet, string& strErrorRet) +{ + strPassphraseRet.clear(); + strErrorRet.clear(); + if (strArg.empty()) + { + AttachTerminal(); + fprintf(stderr, "Enter wallet passphrase: "); + fflush(stderr); + char buf[4096]; + if (!fgets(buf, sizeof(buf), stdin)) + { + strErrorRet = "could not read passphrase from stdin"; + return false; + } + strPassphraseRet = buf; + } + else if (strArg[0] == '@') + { + string strPath = strArg.substr(1); + FILE* pf = fopen(strPath.c_str(), "rb"); + if (!pf) + { + strErrorRet = strprintf("could not read passphrase file %s", strPath.c_str()); + return false; + } + char buf[4096]; + if (!fgets(buf, sizeof(buf), pf)) + { + fclose(pf); + strErrorRet = strprintf("passphrase file %s is empty", strPath.c_str()); + return false; + } + fclose(pf); + strPassphraseRet = buf; + } + else + { + strErrorRet = "passphrase literals on the command line are unsafe; use the option without a value to read stdin, or use @FILE"; + return false; + } + + while (!strPassphraseRet.empty() && + (strPassphraseRet[strPassphraseRet.size() - 1] == '\n' || + strPassphraseRet[strPassphraseRet.size() - 1] == '\r')) + strPassphraseRet.resize(strPassphraseRet.size() - 1); + if (strPassphraseRet.empty()) + { + strErrorRet = "empty passphrase"; + return false; + } + return true; +} + // The real printf, not the one util.h remaps to OutputDebugStringF. Help that // goes to debug.log is help nobody asked for: `-help` printed a full page into // the data directory and returned 0 with an empty terminal, which reads as a @@ -56,7 +110,7 @@ static void PrintUsage() printf(" /debug\n"); printf(" /gen\n"); printf(" /nogui or /daemon\n"); - printf(" /selftest=wallet-keypool or /selftest=wallet-hd\n"); + printf(" /selftest=wallet-keypool, wallet-hd, wallet-format, wallet-crypto, or wallet-encrypt\n"); printf("\n"); printf("Mining mode:\n"); printf(" /operator\n"); @@ -85,6 +139,10 @@ static void PrintUsage() printf(" /dumpwallet=FILE (export private keys as text -- readable by\n"); printf(" anyone, so guard it like cash)\n"); printf(" /importwallet=FILE (load keys from such a file back in)\n"); + printf(" /encryptwallet[=@FILE] (rewrite wallet.dat with encrypted private keys\n"); + printf(" and encrypted HD seed, then exit)\n"); + printf(" /walletpassphrase[=@FILE] (unlock an encrypted wallet for one-shot\n"); + printf(" commands; without @FILE reads stdin)\n"); printf(" /newaddress (print the next receiving address, then exit)\n"); printf(" /sendto=ADDRESS,AMOUNT (spend from this wallet, then exit -- the\n"); printf(" only way to send without the window)\n"); @@ -299,6 +357,39 @@ int main(int argc, char* argv[]) } printf("Height=%d\n", nBestHeight); + bool fEncryptWallet = arg(argc, argv, "/encryptwallet") || arg(argc, argv, "-encryptwallet"); + string strEncryptWallet = argval2(argc, argv, "/encryptwallet", "-encryptwallet"); + if (fEncryptWallet) + { + string strPassphrase; + string strPassphraseError; + if (!ReadPassphraseArgument(strEncryptWallet, strPassphrase, strPassphraseError)) + { + fprintf(stderr, "Cannot read encryption passphrase: %s\n", strPassphraseError.c_str()); + DBFlush(true); + return 1; + } + int nRet = CmdEncryptWallet(strPassphrase); + if (nRet != 0) + DBFlush(true); + return nRet; + } + + bool fWalletPassphrase = arg(argc, argv, "/walletpassphrase") || arg(argc, argv, "-walletpassphrase"); + string strWalletPassphrase = argval2(argc, argv, "/walletpassphrase", "-walletpassphrase"); + if (fWalletPassphrase) + { + string strPassphrase; + string strUnlockError; + if (!ReadPassphraseArgument(strWalletPassphrase, strPassphrase, strUnlockError) || + !UnlockWallet(strPassphrase, strUnlockError)) + { + fprintf(stderr, "Cannot unlock wallet: %s\n", strUnlockError.c_str()); + DBFlush(true); + return 1; + } + } + // Before the node opens sockets or touches anything: the wallet is loaded, // which is all a backup needs, and finishing here means the copy is taken // from a quiet directory rather than from under a running node. @@ -442,6 +533,14 @@ int main(int argc, char* argv[]) return 0; } + if (fGenerateBitcoins && IsWalletLocked()) + { + fprintf(stderr, "Cannot mine while the encrypted wallet is locked. " + "Start with /walletpassphrase or /walletpassphrase=@FILE, or disable mining.\n"); + DBFlush(true); + return 1; + } + ReacceptWalletTransactions(); if (!StartNode(strErrors)) { fprintf(stderr,"StartNode: %s\n",strErrors.c_str()); return 1; } diff --git a/src/makefile.mingw b/src/makefile.mingw index 9bbfef1..e076c49 100644 --- a/src/makefile.mingw +++ b/src/makefile.mingw @@ -111,5 +111,8 @@ tests: test_bip32.exe bitflash.exe ./test_bip32.exe ./bitflash.exe -selftest=wallet-keypool ./bitflash.exe -selftest=wallet-hd + ./bitflash.exe -selftest=wallet-format + ./bitflash.exe -selftest=wallet-crypto + ./bitflash.exe -selftest=wallet-encrypt ./bitflash.exe -selftest=net-message ./bitflash.exe -selftest=consensus-limits diff --git a/src/script.cpp b/src/script.cpp index 6d4fb34..1e6e1ca 100644 --- a/src/script.cpp +++ b/src/script.cpp @@ -1020,43 +1020,54 @@ bool Solver(const CScript& scriptPubKey, uint256 hash, int nHashType, CScript& s if (!Solver(scriptPubKey, vSolution)) return false; - // Compile solution - CRITICAL_BLOCK(cs_mapKeys) + // Compile solution. Public keys stay available for encrypted wallets so the + // wallet can recognize its own outputs while locked; private keys are + // fetched only for an actual signature. + foreach(PAIRTYPE(opcodetype, valtype)& item, vSolution) { - foreach(PAIRTYPE(opcodetype, valtype)& item, vSolution) + if (item.first == OP_PUBKEY) { - if (item.first == OP_PUBKEY) + const valtype& vchPubKey = item.second; + if (!WalletCanSpendKey(vchPubKey)) + return false; + if (hash != 0) { - // Sign - const valtype& vchPubKey = item.second; - if (!mapKeys.count(vchPubKey)) + CPrivKey vchPrivKey; + string strError; + if (!GetWalletPrivKey(vchPubKey, vchPrivKey, strError)) + return false; + vector vchSig; + if (!CKey::Sign(vchPrivKey, hash, vchSig)) return false; - if (hash != 0) + vchSig.push_back((unsigned char)nHashType); + scriptSigRet << vchSig; + } + } + else if (item.first == OP_PUBKEYHASH) + { + vector vchPubKey; + { + CRITICAL_BLOCK(cs_mapKeys) { - vector vchSig; - if (!CKey::Sign(mapKeys[vchPubKey], hash, vchSig)) + map::iterator mi = mapPubKeys.find(uint160(item.second)); + if (mi == mapPubKeys.end()) return false; - vchSig.push_back((unsigned char)nHashType); - scriptSigRet << vchSig; + vchPubKey = (*mi).second; } } - else if (item.first == OP_PUBKEYHASH) + if (!WalletCanSpendKey(vchPubKey)) + return false; + if (hash != 0) { - // Sign and give pubkey - map::iterator mi = mapPubKeys.find(uint160(item.second)); - if (mi == mapPubKeys.end()) + CPrivKey vchPrivKey; + string strError; + if (!GetWalletPrivKey(vchPubKey, vchPrivKey, strError)) return false; - const vector& vchPubKey = (*mi).second; - if (!mapKeys.count(vchPubKey)) + vector vchSig; + if (!CKey::Sign(vchPrivKey, hash, vchSig)) return false; - if (hash != 0) - { - vector vchSig; - if (!CKey::Sign(mapKeys[vchPubKey], hash, vchSig)) - return false; - vchSig.push_back((unsigned char)nHashType); - scriptSigRet << vchSig << vchPubKey; - } + vchSig.push_back((unsigned char)nHashType); + scriptSigRet << vchSig << vchPubKey; } } } @@ -1080,27 +1091,27 @@ bool ExtractPubKey(const CScript& scriptPubKey, bool fMineOnly, vector::iterator mi = mapPubKeys.find(uint160(item.second)); if (mi == mapPubKeys.end()) continue; vchPubKey = (*mi).second; } - if (!fMineOnly || mapKeys.count(vchPubKey)) - { - vchPubKeyRet = vchPubKey; - return true; - } + } + if (!fMineOnly || WalletCanSpendKey(vchPubKey)) + { + vchPubKeyRet = vchPubKey; + return true; } } return false; diff --git a/src/selftest.cpp b/src/selftest.cpp index aee2951..ff0289b 100644 --- a/src/selftest.cpp +++ b/src/selftest.cpp @@ -33,6 +33,8 @@ #ifdef _WIN32 #include +#include +#include #else #include #include @@ -601,6 +603,500 @@ static int RunWalletHDSelfTest() return nFail == 0 ? 0 : 1; } +static int RunWalletFormatSelfTest() +{ + fflush(stdout); + printf("wallet-format self-test\n"); + + std::string tmp; + std::string cwd; + if (!MakeTempDir(tmp)) + { + printf(" FAIL could not create a temporary data directory\n"); + return 1; + } + if (!GetCurrentDir(cwd) || !SetCurrentDir(tmp)) + { + printf(" FAIL could not move into the temporary data directory\n"); + RemoveTree(tmp); + return 1; + } + + int nFail = 0; + strSetDataDir = tmp; + printf(" temp datadir: %s\n", tmp.c_str()); + + try + { + class CWalletDBRaw : public CWalletDB + { + public: + CWalletDBRaw(const char* pszMode="r+") : CWalletDB(pszMode) { } + bool WriteStringRecord(const string& strType, int nValue) + { + return Write(strType, nValue); + } + bool EraseStringRecord(const string& strType) + { + return Erase(strType); + } + }; + + if (!LoadWallet()) + throw std::runtime_error("LoadWallet failed"); + + { + CWalletDB walletdb; + nFail += Check(walletdb.WriteWalletMinVersion(WALLET_FORMAT_SUPPORTED), + "the current wallet format marker can be written") ? 0 : 1; + } + + std::vector vchDefaultKey; + nFail += Check(CWalletDB("r").LoadWallet(vchDefaultKey), + "the current wallet format marker loads") ? 0 : 1; + + { + CWalletDBRaw walletdb; + nFail += Check(walletdb.WriteStringRecord("mkey", 1), + "a malformed encrypted master-key record can be written") ? 0 : 1; + } + + vchDefaultKey.clear(); + nFail += Check(!CWalletDB("r").LoadWallet(vchDefaultKey), + "a malformed encrypted master-key record is refused") ? 0 : 1; + + { + CWalletDBRaw walletdb; + nFail += Check(walletdb.EraseStringRecord("mkey"), + "the malformed encrypted master-key record can be removed") ? 0 : 1; + } + + { + CWalletDB walletdb; + nFail += Check(walletdb.WriteWalletMinVersion(WALLET_FORMAT_SUPPORTED + 1), + "a future wallet format marker can be written") ? 0 : 1; + } + + vchDefaultKey.clear(); + nFail += Check(!CWalletDB("r").LoadWallet(vchDefaultKey), + "a future wallet format marker is refused") ? 0 : 1; + } + catch (const std::exception& e) + { + printf(" FAIL exception: %s\n", e.what()); + nFail++; + } + catch (...) + { + printf(" FAIL unknown exception\n"); + nFail++; + } + + DBFlush(true); + SetCurrentDir(cwd); + RemoveTree(tmp); + printf("%s (%d failure%s)\n", nFail == 0 ? "ALL TESTS PASSED" : "TESTS FAILED", + nFail, nFail == 1 ? "" : "s"); + fflush(stdout); + return nFail == 0 ? 0 : 1; +} + +static void ClearWalletRuntimeForTest() +{ + CRITICAL_BLOCK(cs_mapKeys) + { + mapKeys.clear(); + mapCryptedKeys.clear(); + mapPubKeys.clear(); + mapMasterKeys.clear(); + vWalletMasterKey.clear(); + vchCryptedHDMaster.clear(); + vchCryptedHDChainCode.clear(); + fWalletEncrypted = false; + fWalletLocked = true; + } + CRITICAL_BLOCK(cs_keyPool) + mapKeyPool.clear(); + vchHDMaster.clear(); + vchHDChainCode.clear(); + nHDNext = 0; +} + +static int RunWalletCryptoSelfTest() +{ + fflush(stdout); + printf("wallet-crypto self-test\n"); + + std::string tmp; + std::string cwd; + if (!MakeTempDir(tmp)) + { + printf(" FAIL could not create a temporary data directory\n"); + return 1; + } + if (!GetCurrentDir(cwd) || !SetCurrentDir(tmp)) + { + printf(" FAIL could not move into the temporary data directory\n"); + RemoveTree(tmp); + return 1; + } + + int nFail = 0; + strSetDataDir = tmp; + printf(" temp datadir: %s\n", tmp.c_str()); + + try + { + const string strPassphrase = "btf-test-passphrase"; + const string strWrongPassphrase = "btf-test-passphraser"; + + CKey key; + key.MakeNewKey(); + vector vchPubKey = key.GetPubKey(); + CPrivKey vchPrivKey = key.GetPrivKey(); + + CKeyingMaterial vchMasterKey; + for (int i = 0; i < 32; i++) + vchMasterKey.push_back((unsigned char)(i + 1)); + + CWalletMasterKey kMasterKey; + kMasterKey.vchSalt.clear(); + for (int i = 0; i < 8; i++) + kMasterKey.vchSalt.push_back((unsigned char)(0xa0 + i)); + kMasterKey.nDeriveIterations = 2500; + + CKeyingMaterial vchPassKey; + vector vchPassIV; + nFail += Check(DeriveWalletPassphraseKey(strPassphrase, kMasterKey.vchSalt, + kMasterKey.nDeriveIterations, + vchPassKey, vchPassIV), + "a passphrase key can be derived") ? 0 : 1; + nFail += Check(EncryptSecret(vchPassKey, + vector(vchMasterKey.begin(), vchMasterKey.end()), + vchPassIV, kMasterKey.vchCryptedKey), + "the wallet master key can be encrypted") ? 0 : 1; + + vector vchCryptedKey; + nFail += Check(EncryptSecret(vchMasterKey, + vector(vchPrivKey.begin(), vchPrivKey.end()), + WalletKeyIV(vchPubKey), vchCryptedKey), + "a private key can be encrypted under the wallet master key") ? 0 : 1; + + { + CWalletDB walletdb("cr"); + nFail += Check(walletdb.WriteWalletMinVersion(WALLET_FORMAT_ENCRYPTED), + "an encrypted wallet format marker can be written") ? 0 : 1; + nFail += Check(walletdb.WriteMasterKey(0, kMasterKey), + "the encrypted master key can be written") ? 0 : 1; + nFail += Check(walletdb.WriteCryptedKey(vchPubKey, vchCryptedKey), + "the encrypted private key can be written") ? 0 : 1; + nFail += Check(walletdb.WriteDefaultKey(vchPubKey), + "the encrypted wallet default key can be written") ? 0 : 1; + } + + ClearWalletRuntimeForTest(); + + vector vchDefaultKey; + nFail += Check(CWalletDB("r").LoadWallet(vchDefaultKey), + "an encrypted wallet can be loaded") ? 0 : 1; + nFail += Check(vchDefaultKey == vchPubKey, + "the encrypted wallet default public key loads") ? 0 : 1; + nFail += Check(IsWalletEncrypted() && IsWalletLocked(), + "an encrypted wallet loads locked") ? 0 : 1; + nFail += Check(WalletCanSpendKey(vchPubKey), + "an encrypted key is recognized as wallet-owned while locked") ? 0 : 1; + + CPrivKey vchDecrypted; + string strError; + nFail += Check(!GetWalletPrivKey(vchPubKey, vchDecrypted, strError), + "a locked encrypted key cannot be read") ? 0 : 1; + nFail += Check(!UnlockWallet(strWrongPassphrase, strError), + "the wrong-passphrase does not unlock") ? 0 : 1; + nFail += Check(UnlockWallet(strPassphrase, strError), + "the right passphrase unlocks") ? 0 : 1; + nFail += Check(!IsWalletLocked(), + "the wallet reports unlocked") ? 0 : 1; + nFail += Check(GetWalletPrivKey(vchPubKey, vchDecrypted, strError) && + vchDecrypted == vchPrivKey, + "an unlocked encrypted key decrypts to the original private key") ? 0 : 1; + LockWallet(); + nFail += Check(IsWalletLocked(), + "locking clears the unlocked state") ? 0 : 1; + } + catch (const std::exception& e) + { + printf(" FAIL exception: %s\n", e.what()); + nFail++; + } + catch (...) + { + printf(" FAIL unknown exception\n"); + nFail++; + } + + DBFlush(true); + SetCurrentDir(cwd); + RemoveTree(tmp); + printf("%s (%d failure%s)\n", nFail == 0 ? "ALL TESTS PASSED" : "TESTS FAILED", + nFail, nFail == 1 ? "" : "s"); + fflush(stdout); + return nFail == 0 ? 0 : 1; +} + +static bool FileContainsBytes(const string& strPath, const vector& vchNeedle) +{ + if (vchNeedle.empty()) + return false; + FILE* pf = fopen(strPath.c_str(), "rb"); + if (!pf) + return false; + + vector vchHaystack; + unsigned char buf[4096]; + size_t n = 0; + while ((n = fread(buf, 1, sizeof(buf), pf)) > 0) + vchHaystack.insert(vchHaystack.end(), buf, buf + n); + fclose(pf); + + if (vchHaystack.size() < vchNeedle.size()) + return false; + return search(vchHaystack.begin(), vchHaystack.end(), + vchNeedle.begin(), vchNeedle.end()) != vchHaystack.end(); +} + +static bool FileContainsText(const string& strPath, const string& strNeedle) +{ + FILE* pf = fopen(strPath.c_str(), "rb"); + if (!pf) + return false; + + string strHaystack; + char buf[4096]; + size_t n = 0; + while ((n = fread(buf, 1, sizeof(buf), pf)) > 0) + strHaystack.append(buf, buf + n); + fclose(pf); + + return strHaystack.find(strNeedle) != string::npos; +} + +static bool WriteTextFile(const string& strPath, const string& strText) +{ + FILE* pf = fopen(strPath.c_str(), "wb"); + if (!pf) + return false; + bool fOk = fwrite(strText.data(), 1, strText.size(), pf) == strText.size(); + if (fclose(pf) != 0) + fOk = false; + return fOk; +} + +static string QuoteCommandArg(const string& str) +{ + string out = "\""; + for (size_t i = 0; i < str.size(); i++) + { + if (str[i] == '"') + out += "\\\""; + else + out += str[i]; + } + out += "\""; + return out; +} + +static int RunBitflashChild(const string& strExe, + const vector& vArgs, + const string* pStdinFile = NULL) +{ +#ifdef _WIN32 + vector argv; + argv.push_back((char*)strExe.c_str()); + for (size_t i = 0; i < vArgs.size(); i++) + argv.push_back((char*)vArgs[i].c_str()); + argv.push_back(NULL); + + int nOldOut = _dup(1); + int nOldErr = _dup(2); + int nOldIn = _dup(0); + int nNull = _open("NUL", _O_WRONLY); + int nIn = -1; + if (nNull >= 0) + { + _dup2(nNull, 1); + _dup2(nNull, 2); + } + if (pStdinFile) + { + nIn = _open(pStdinFile->c_str(), _O_RDONLY); + if (nIn >= 0) + _dup2(nIn, 0); + } + int nRet = _spawnv(_P_WAIT, strExe.c_str(), &argv[0]); + if (nIn >= 0) + _close(nIn); + if (nNull >= 0) + _close(nNull); + if (nOldIn >= 0) + { + _dup2(nOldIn, 0); + _close(nOldIn); + } + if (nOldOut >= 0) + { + _dup2(nOldOut, 1); + _close(nOldOut); + } + if (nOldErr >= 0) + { + _dup2(nOldErr, 2); + _close(nOldErr); + } + return nRet; +#else + string strCmd = QuoteCommandArg(strExe); + for (size_t i = 0; i < vArgs.size(); i++) + strCmd += " " + QuoteCommandArg(vArgs[i]); + if (pStdinFile) + strCmd += " < " + QuoteCommandArg(*pStdinFile); + strCmd += " > /dev/null 2>&1"; + return system(strCmd.c_str()); +#endif +} + +static int RunWalletEncryptSelfTest() +{ + fflush(stdout); + printf("wallet-encrypt self-test\n"); + + std::string tmp; + std::string cwd; + if (!MakeTempDir(tmp)) + { + printf(" FAIL could not create a temporary data directory\n"); + return 1; + } + if (!GetCurrentDir(cwd) || !SetCurrentDir(tmp)) + { + printf(" FAIL could not move into the temporary data directory\n"); + RemoveTree(tmp); + return 1; + } + + int nFail = 0; + bool fWalletEnvClosed = false; + strSetDataDir = tmp; + printf(" temp datadir: %s\n", tmp.c_str()); + + try + { + if (!LoadWallet()) + throw std::runtime_error("LoadWallet failed"); + + string strError; + const string strMnemonic = + "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about"; + nFail += Check(SetHDSeedFromMnemonic(strMnemonic, strError), + "a phrase can be installed before encryption") ? 0 : 1; + TopUpKeyPool(); + + vector vchDefaultPubKey = keyUser.GetPubKey(); + CPrivKey vchDefaultPrivKey; + nFail += Check(GetWalletPrivKey(vchDefaultPubKey, vchDefaultPrivKey, strError), + "the default private key is readable before encryption") ? 0 : 1; + vector vchDefaultPrivBytes(vchDefaultPrivKey.begin(), vchDefaultPrivKey.end()); + vector vchPlainHDMaster = vchHDMaster; + vector vchPlainHDChainCode = vchHDChainCode; + + string strBackup; + nFail += Check(EncryptWallet("btf-test-passphrase", strBackup, strError), + "wallet.dat can be rewritten encrypted") ? 0 : 1; + fWalletEnvClosed = true; + nFail += Check(FileExists(strBackup.c_str()), + "the original wallet is preserved as an explicit backup") ? 0 : 1; + + string strWalletPath = GetAppDir() + "/wallet.dat"; + nFail += Check(!FileContainsBytes(strWalletPath, vchDefaultPrivBytes), + "the active wallet no longer contains the default private key bytes") ? 0 : 1; + nFail += Check(!FileContainsBytes(strWalletPath, vchPlainHDMaster), + "the active wallet no longer contains the HD master key bytes") ? 0 : 1; + nFail += Check(!FileContainsBytes(strWalletPath, vchPlainHDChainCode), + "the active wallet no longer contains the HD chain-code bytes") ? 0 : 1; + +#ifdef _WIN32 + string strExe = cwd + "\\bitflash.exe"; +#else + string strExe = cwd + "/bitflash-node"; +#endif + string strWrongDump = tmp + "/wrong-pass-dump.txt"; + string strRightDump = tmp + "/right-pass-dump.txt"; + string strLiteralDump = tmp + "/literal-pass-dump.txt"; + string strStdinDump = tmp + "/stdin-pass-dump.txt"; + string strWrongPassFile = tmp + "/wrong-pass.txt"; + string strRightPassFile = tmp + "/right-pass.txt"; + nFail += Check(WriteTextFile(strWrongPassFile, "wrong-passphrase\n") && + WriteTextFile(strRightPassFile, "btf-test-passphrase\n"), + "passphrase files can be written for restarted commands") ? 0 : 1; + + vector vWrongArgs; + vWrongArgs.push_back("-datadir=" + tmp); + vWrongArgs.push_back("-walletpassphrase=@" + strWrongPassFile); + vWrongArgs.push_back("-dumpwallet=" + strWrongDump); + vWrongArgs.push_back("-nogui"); + int nWrongRet = RunBitflashChild(strExe, vWrongArgs); + nFail += Check(nWrongRet != 0 && !FileExists(strWrongDump.c_str()), + "a restarted wallet rejects the wrong-passphrase") ? 0 : 1; + + vector vLiteralArgs; + vLiteralArgs.push_back("-datadir=" + tmp); + vLiteralArgs.push_back("-walletpassphrase=btf-test-passphrase"); + vLiteralArgs.push_back("-dumpwallet=" + strLiteralDump); + vLiteralArgs.push_back("-nogui"); + int nLiteralRet = RunBitflashChild(strExe, vLiteralArgs); + nFail += Check(nLiteralRet != 0 && !FileExists(strLiteralDump.c_str()), + "literal passphrases on the command line are refused") ? 0 : 1; + + vector vRightArgs; + vRightArgs.push_back("-datadir=" + tmp); + vRightArgs.push_back("-walletpassphrase=@" + strRightPassFile); + vRightArgs.push_back("-dumpwallet=" + strRightDump); + vRightArgs.push_back("-nogui"); + int nRightRet = RunBitflashChild(strExe, vRightArgs); + nFail += Check(nRightRet == 0 && FileExists(strRightDump.c_str()), + "a restarted wallet unlocks with the right passphrase") ? 0 : 1; + nFail += Check(FileContainsText(strRightDump, HexStrLocal(vchDefaultPrivBytes)), + "the restarted wallet can decrypt and dump the original key") ? 0 : 1; + + vector vStdinArgs; + vStdinArgs.push_back("-datadir=" + tmp); + vStdinArgs.push_back("-walletpassphrase"); + vStdinArgs.push_back("-dumpwallet=" + strStdinDump); + vStdinArgs.push_back("-nogui"); + int nStdinRet = RunBitflashChild(strExe, vStdinArgs, &strRightPassFile); + nFail += Check(nStdinRet == 0 && FileExists(strStdinDump.c_str()), + "a restarted wallet can read the passphrase from stdin") ? 0 : 1; + } + catch (const std::exception& e) + { + printf(" FAIL exception: %s\n", e.what()); + nFail++; + } + catch (...) + { + printf(" FAIL unknown exception\n"); + nFail++; + } + + if (!fWalletEnvClosed) + DBFlush(true); + SetCurrentDir(cwd); + RemoveTree(tmp); + printf("%s (%d failure%s)\n", nFail == 0 ? "ALL TESTS PASSED" : "TESTS FAILED", + nFail, nFail == 1 ? "" : "s"); + fflush(stdout); + return nFail == 0 ? 0 : 1; +} + static int RunNetMessageSelfTest() { fflush(stdout); @@ -728,12 +1224,18 @@ int RunSelfTest(const std::string& name) return RunWalletKeyPoolSelfTest(); if (name == "wallet-hd") return RunWalletHDSelfTest(); + if (name == "wallet-format") + return RunWalletFormatSelfTest(); + if (name == "wallet-crypto") + return RunWalletCryptoSelfTest(); + if (name == "wallet-encrypt") + return RunWalletEncryptSelfTest(); if (name == "net-message") return RunNetMessageSelfTest(); if (name == "consensus-limits") return RunConsensusLimitsSelfTest(); printf("Unknown self-test '%s'\n", name.c_str()); - printf("Known self-tests: wallet-keypool, wallet-hd, net-message, consensus-limits\n"); + printf("Known self-tests: wallet-keypool, wallet-hd, wallet-format, wallet-crypto, wallet-encrypt, net-message, consensus-limits\n"); return 1; } diff --git a/src/walletcmd.cpp b/src/walletcmd.cpp index b449df9..06f35a5 100644 --- a/src/walletcmd.cpp +++ b/src/walletcmd.cpp @@ -406,6 +406,36 @@ int CmdRecoveryAudit() return 0; } +int CmdEncryptWallet(const std::string& strPassphrase) +{ + AttachTerminal(); + + string strBackup; + string strError; + if (!EncryptWallet(strPassphrase, strBackup, strError)) + { + fprintf(stderr, "Cannot encrypt wallet: %s\n", strError.c_str()); + fprintf(stderr, "Nothing was changed.\n"); + return 1; + } + + printf("Wallet encrypted.\n"); + printf("The old unencrypted wallet was moved to:\n"); + printf(" %s\n", strBackup.c_str()); + printf("That backup still contains private keys in plain text. Move it offline or\n"); + printf("delete it after you have a safer backup plan.\n"); + printf("Restart Bitflash before using the wallet again.\n"); + fflush(stdout); + + // The rewritten wallet.dat is clean; the environment's write-ahead logs are + // not, because every plaintext record this wallet ever wrote passed through + // them. Closing the environment and dropping the logs is the last step of + // encrypting, not an optimisation -- our own backup advice is to copy the + // whole directory, and following it otherwise carries the keys along. + PurgeDbEnvironmentLogs(); + return 0; +} + // Spend, from the command line. // // SendMoney() has been in this tree since 0.1.0 and only the window ever called @@ -421,6 +451,12 @@ int CmdSendTo(const std::string& strArg) { AttachTerminal(); + if (IsWalletLocked()) + { + fprintf(stderr, "Wallet is encrypted and locked. Start with /walletpassphrase or /walletpassphrase=@FILE to spend.\n"); + return 1; + } + std::string::size_type comma = strArg.rfind(','); if (comma == std::string::npos) { diff --git a/src/walletcmd.h b/src/walletcmd.h index 32d3eef..04d3fb7 100644 --- a/src/walletcmd.h +++ b/src/walletcmd.h @@ -53,6 +53,10 @@ int CmdShowDerived(int nCount); // by the installed recovery phrase or still depends on wallet.dat-only keys. int CmdRecoveryAudit(); +// Rewrite wallet.dat so private keys and the HD seed are encrypted with the +// given passphrase. Returns 0 on success and exits without starting the node. +int CmdEncryptWallet(const std::string& strPassphrase); + // Spend, from the command line. Until now the only way to send was the window, // so a headless node could be paid and could never pay -- it could hold a // balance it had no way to move. strArg is "ADDRESS,AMOUNT". -- cgit v1.2.3 From 530f91910a4eceb6705d1212fa0e3ebe8418c6a3 Mon Sep 17 00:00:00 2001 From: Bitflash Date: Tue, 4 Aug 2026 10:13:41 -0300 Subject: gui: say why the wallet would not open, where the user can see it (#136) Every fatal startup path wrote to stderr and to printf, and on Windows neither reaches anybody. printf is remapped to OutputDebugStringF, so it lands in debug.log inside the data directory. A -mwindows binary started by double-clicking has no stderr at all, and AttachTerminal() cannot help: it attaches to a parent console, which a double-clicked exe does not have. So Bitflash exited silently. Someone whose wallet.dat needs a newer build -- exactly what the wallet format guard was added to tell them -- double-clicked and watched nothing happen. The guard was correct and mute, which is the failure it was written to prevent. Fatal startup errors now go through one reporter: debug.log as before, stderr as before, and a message box when there is a person in front of the screen. Applied to the three that mean "your data will not open": the block index failing to load, the block index throwing, and wallet.dat failing or throwing. The box is deliberately not shown for a headless node. Those run under scheduled tasks and services, where a modal dialog waits forever for a click nobody is there to make -- a hung process instead of an exit code. Headless keeps stderr and the log. LoadWallet() now also reports why it refused, in a sentence, instead of only logging it. The unsupported-format case is the one that has to be understood by whoever reads it, and "LoadWallet failed" is not that. Verified by breaking a throwaway wallet on purpose and starting both ways. Headless: exit code 1, no dialog, no process left behind. Windowed with no console: a box naming the file, the Berkeley DB error, the usual cause, and where to look for more. --- src/db.cpp | 19 +++++++++---- src/db.h | 3 ++ src/main_gui.cpp | 87 +++++++++++++++++++++++++++++++++++++++++++++++++------- 3 files changed, 94 insertions(+), 15 deletions(-) (limited to 'src/main_gui.cpp') diff --git a/src/db.cpp b/src/db.cpp index 9334249..c8642e9 100644 --- a/src/db.cpp +++ b/src/db.cpp @@ -48,6 +48,10 @@ private: static CCriticalSection cs_db; static bool fDbEnvInit = false; + +// Why LoadWallet() refused, in words meant for the person who has to act on it. +// Empty unless the reason is one we can name. +string strWalletLoadError; DbEnv dbenv(0u); static map mapFileUseCount; @@ -625,6 +629,7 @@ bool CWalletDB::LoadWallet(vector& vchDefaultKeyRet) bool fHaveStoredMineMode = false; bool fHaveStoredHDCoinType = false; vchDefaultKeyRet.clear(); + strWalletLoadError.clear(); // Satoshi's "todo: shouldn't we catch exceptions" sat here since 2009, and // it was not a nicety. Nothing on this path caught anything, so a record @@ -779,11 +784,15 @@ bool CWalletDB::LoadWallet(vector& vchDefaultKeyRet) ssValue >> nMinVersion; if (nMinVersion > WALLET_FORMAT_SUPPORTED) { - printf("LoadWallet: wallet.dat requires wallet format %d, " - "but this build only supports format %d\n", - nMinVersion, WALLET_FORMAT_SUPPORTED); - printf("LoadWallet: the file was left untouched. Upgrade " - "Bitflash before opening this wallet.\n"); + // Said out loud, not only into debug.log: this is the one + // refusal whose whole purpose is to be understood, and the + // caller puts it in front of the user. + strWalletLoadError = strprintf( + "This wallet.dat was written by a newer version of Bitflash " + "(wallet format %d; this build understands %d). Upgrade " + "Bitflash before opening it.", + nMinVersion, WALLET_FORMAT_SUPPORTED); + printf("LoadWallet: %s\n", strWalletLoadError.c_str()); return false; } } diff --git a/src/db.h b/src/db.h index b184b17..4fb5cfd 100644 --- a/src/db.h +++ b/src/db.h @@ -54,6 +54,9 @@ extern void DBFlush(bool fShutdown); // this: the logs hold the plaintext records the old wallet wrote. extern void PurgeDbEnvironmentLogs(); +// Set by LoadWallet() when it refuses for a reason worth telling the user. +extern string strWalletLoadError; + diff --git a/src/main_gui.cpp b/src/main_gui.cpp index a2577ca..875988f 100644 --- a/src/main_gui.cpp +++ b/src/main_gui.cpp @@ -291,6 +291,50 @@ static void ParseStartupArguments(int argc, char* argv[]) } } +// Report a startup failure somewhere the user will actually see it. +// +// Every fatal path here wrote to stderr and to printf, and on the platform most +// people use, neither reaches anybody. printf is remapped to OutputDebugStringF, +// so it lands in debug.log inside the data directory. A -mwindows binary +// launched by double-clicking has no stderr at all, and AttachTerminal() cannot +// help: it attaches to a *parent* console, which a double-clicked exe does not +// have. +// +// So the program exited silently. Someone whose wallet.dat needs a newer build +// -- which is exactly what the wallet format guard was added to tell them -- +// double-clicked Bitflash and watched nothing happen. +// +// The box is deliberately not shown for a headless node: those run under +// scheduled tasks and services, where a modal dialog waits forever for a click +// nobody is there to make. Headless already has a console when it has one, and +// its output goes to stderr and the log as before. +static void FatalStartupError(bool fHeadless, const string& strWhat, const string& strDetail) +{ + printf("FATAL: %s\n", strWhat.c_str()); + if (!strDetail.empty()) + printf("FATAL: %s\n", strDetail.c_str()); + + AttachTerminal(); + fprintf(stderr, "%s\n", strWhat.c_str()); + if (!strDetail.empty()) + fprintf(stderr, "%s\n", strDetail.c_str()); + fflush(stderr); + +#ifdef _WIN32 + if (!fHeadless && GetConsoleWindow() == NULL) + { + string strBody = strWhat; + if (!strDetail.empty()) + strBody += "\n\n" + strDetail; + strBody += "\n\nNothing was changed. More detail is in debug.log, " + "inside the data directory."; + MessageBoxA(NULL, strBody.c_str(), "Bitflash", MB_OK | MB_ICONERROR | MB_SETFOREGROUND); + } +#else + (void)fHeadless; +#endif +} + int main(int argc, char* argv[]) { if (arg(argc,argv,"/help") || arg(argc,argv,"-help") || @@ -323,25 +367,47 @@ int main(int argc, char* argv[]) // libdb_cxx-6.2.dll -> CDB::CDB(...) -> LoadBlockIndex(...) -> main() // // The program knew what had gone wrong and threw the reason away. + // Needed here, long before the headless branch runs, because whether a + // startup failure may pop a dialog depends on it. +#ifdef BITFLASH_NO_GUI + bool fHeadlessStartup = true; +#else + bool fHeadlessStartup = arg(argc,argv,"/nogui") || arg(argc,argv,"-nogui") || + arg(argc,argv,"/daemon") || arg(argc,argv,"-daemon"); +#endif + string strErrors; printf("Loading block index...\n"); try { - if (!LoadBlockIndex()) { fprintf(stderr, "LoadBlockIndex failed\n"); return 1; } + if (!LoadBlockIndex()) + { + FatalStartupError(fHeadlessStartup, "Cannot load the block index.", ""); + return 1; + } } catch (const std::exception& e) { - fprintf(stderr, "Cannot read the block index: %s\n", e.what()); - fprintf(stderr, "blkindex.dat and blk0001.dat may be from another machine or " - "incomplete. Deleting both is safe -- they are re-downloaded -- " - "but never delete wallet.dat, which holds your keys.\n"); + FatalStartupError(fHeadlessStartup, + strprintf("Cannot read the block index: %s", e.what()), + "blkindex.dat and blk0001.dat may be from another machine or " + "incomplete. Deleting both is safe -- they are re-downloaded -- " + "but never delete wallet.dat, which holds your keys."); return 1; } printf("Loading wallet...\n"); try { - if (!LoadWallet()) { fprintf(stderr, "LoadWallet failed\n"); return 1; } + if (!LoadWallet()) + { + // LoadWallet() explains itself into strWalletLoadError when it + // knows why -- an unsupported wallet format, for one, which is the + // case this whole path exists to make visible. + FatalStartupError(fHeadlessStartup, "Cannot open wallet.dat.", + strWalletLoadError); + return 1; + } // After the block index, so there is a chain to compare the wallet // against, and before anything reports a balance. @@ -349,10 +415,11 @@ int main(int argc, char* argv[]) } catch (const std::exception& e) { - fprintf(stderr, "Cannot read wallet.dat: %s\n", e.what()); - fprintf(stderr, "The file was left untouched. A wallet.dat written by a " - "different platform's Berkeley DB is the usual cause; back it " - "up before trying anything else.\n"); + FatalStartupError(fHeadlessStartup, + strprintf("Cannot read wallet.dat: %s", e.what()), + "The file was left untouched. A wallet.dat written by a " + "different platform's Berkeley DB is the usual cause; back it " + "up before trying anything else."); return 1; } printf("Height=%d\n", nBestHeight); -- cgit v1.2.3 From 5c2e66b70ab3c15603a6e2d7a4ea9e02f3cd0604 Mon Sep 17 00:00:00 2001 From: Nsable-sh Date: Fri, 7 Aug 2026 11:30:49 -0300 Subject: pool: write dashboard status JSON --- README.md | 6 ++++ src/main.cpp | 1 + src/main.h | 1 + src/main_gui.cpp | 5 ++++ src/rpc.cpp | 83 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 5 files changed, 96 insertions(+) (limited to 'src/main_gui.cpp') diff --git a/README.md b/README.md index 6746e02..af56081 100644 --- a/README.md +++ b/README.md @@ -219,6 +219,11 @@ Open **Options** from the menu bar. Under Mining Mode: Pool announcements are published on Nostr with live status fields, so external tools can query the latest pool state by `.btf` address. +Pool operators also write a local `pool_status.json` every ten seconds. By +default it is created in the data directory; use `-poolstatusfile=PATH` to write +it somewhere a dashboard or web sync can read. The file is informational only: +nodes still discover pools through Nostr and `.btf`, not through any web domain. + **Participant** — mine to someone else's pool. Enter or select the pool's `.btf` address and enable Start Mining. SRBMiner and XMRig connect to operator pools through the `.btf` path: @@ -236,6 +241,7 @@ xmrig -a rx/0 -o POOL_BTF_ADDRESS -u YOUR_BTF_ADDRESS -p x ./bitflash -nogui # node only ./bitflash -nogui -gen # node + solo mining ./bitflash -nogui -gen -operator # pool operator +./bitflash -nogui -gen -operator -poolstatusfile=/var/www/api/pools.json ./bitflash -nogui -gen -participant=POOL_BTF_ADDRESS # mine to pool ``` diff --git a/src/main.cpp b/src/main.cpp index 2610c45..6cb7353 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -104,6 +104,7 @@ bool fMineModeFromCommandLine = false; string strParticipantPool; // participant mode: pool .btf address string strPoolName = "Bitflash Pool"; string strPoolDashboardUrl; +string strPoolStatusFile; double dPoolFeePercent = 0.0; static std::atomic gParticipantSharesSent{0}; diff --git a/src/main.h b/src/main.h index c458412..3866855 100644 --- a/src/main.h +++ b/src/main.h @@ -86,6 +86,7 @@ extern bool fMineModeFromCommandLine; extern string strParticipantPool; // participant: pool .btf address extern string strPoolName; // operator: announced pool name extern string strPoolDashboardUrl; // operator: optional dashboard URL +extern string strPoolStatusFile; // operator: optional JSON status output path extern double dPoolFeePercent; // operator: announced fee percent extern bool fSoloMineTest; // /solomine: mine without requiring a peer (local test) // Threads to hash with; 0 = decide from the hardware. Set by /genproclimit. diff --git a/src/main_gui.cpp b/src/main_gui.cpp index 875988f..41661b9 100644 --- a/src/main_gui.cpp +++ b/src/main_gui.cpp @@ -123,6 +123,7 @@ static void PrintUsage() printf(" /poolname=NAME\n"); printf(" /poolfee=PCT\n"); printf(" /pooldashboard=URL (alias: /pooldash=URL)\n"); + printf(" /poolstatusfile=PATH (write pool status JSON for dashboards)\n"); printf("\n"); printf(".btf and rendezvous:\n"); printf(" /connectbtf=PEER_BTF_ADDRESS\n"); @@ -239,6 +240,10 @@ static void ParseStartupArguments(int argc, char* argv[]) if (!poolFee.empty()) dPoolFeePercent = atof(poolFee.c_str()); + string poolStatusFile = argval2(argc, argv, "/poolstatusfile", "-poolstatusfile"); + if (!poolStatusFile.empty()) + strPoolStatusFile = poolStatusFile; + string btfConnect = argval2(argc, argv, "/connectbtf", "-connectbtf"); if (!btfConnect.empty()) strBtfConnect = btfConnect; diff --git a/src/rpc.cpp b/src/rpc.cpp index 383769a..1aa4f9b 100644 --- a/src/rpc.cpp +++ b/src/rpc.cpp @@ -623,6 +623,7 @@ static StatsSnapshot gStats; static std::vector gWorkerStats; static std::vector gPayoutViews; static std::mutex gStatsMutex; +static int64 gLastPoolStatusWrite = 0; void GetPoolOperatorStats(int& authorizedMiners, int& blocksFound, uint64& roundShares, double& totalHashRate) { @@ -645,6 +646,82 @@ void GetPendingPayouts(std::vector& out) out = gPayoutViews; } +static std::string PoolStatusFilePath() +{ + if (!strPoolStatusFile.empty()) + return strPoolStatusFile; + return GetAppDir() + "/pool_status.json"; +} + +static void WritePoolStatusJsonLocked() +{ + nlohmann::json j; + j["schema"] = "bitflash-pool-status-1"; + j["network"] = "bitflash"; + j["updatedAt"] = GetTime(); + + j["operator"] = { + {"name", strPoolName}, + {"btfAddress", BtfLocalAddress()}, + {"feePercent", dPoolFeePercent}, + {"dashboardUrl", strPoolDashboardUrl} + }; + + j["node"] = { + {"height", nBestHeight}, + {"poolRunning", (bool)gPoolRunning}, + {"mode", nMineMode} + }; + + j["pool"] = { + {"authorizedMiners", gStats.authorizedMiners}, + {"blocksFoundSession", gStats.blocksFound}, + {"roundShares", gStats.roundShares}, + {"hashRate", gStats.totalHashRate} + }; + + j["workers"] = nlohmann::json::array(); + for (const PoolWorkerStatView& row : gWorkerStats) { + j["workers"].push_back({ + {"address", row.address}, + {"worker", row.worker}, + {"totalShares", row.totalShares}, + {"roundShares", row.roundShares}, + {"lastSeen", row.lastSeen}, + {"hashRate", row.hashRate} + }); + } + + j["pendingPayouts"] = nlohmann::json::array(); + for (const PendingPayoutView& row : gPayoutViews) { + j["pendingPayouts"].push_back({ + {"matureAtHeight", row.matureAtHeight}, + {"recipients", row.recipients}, + {"totalAmountSatoshis", row.totalAmount}, + {"totalAmountBtf", FormatMoney(row.totalAmount)} + }); + } + + std::string path = PoolStatusFilePath(); + std::string tmp = path + ".tmp"; + FILE* f = fopen(tmp.c_str(), "w"); + if (!f) { + LogPrint("pool", "[pool] WARNING: could not write status file %s\n", tmp.c_str()); + return; + } + std::string s = j.dump(2); + fwrite(s.c_str(), 1, s.size(), f); + if (fclose(f) != 0) { + LogPrint("pool", "[pool] WARNING: could not close status file %s\n", tmp.c_str()); + return; + } +#ifdef _WIN32 + remove(path.c_str()); +#endif + if (rename(tmp.c_str(), path.c_str()) != 0) + LogPrint("pool", "[pool] WARNING: could not replace status file %s\n", path.c_str()); +} + // --------------------------------------------------------------------------- // Miner state -- event loop thread only // --------------------------------------------------------------------------- @@ -1314,6 +1391,12 @@ static void RefreshStats(int blocksFoundThisSession, uint64 roundShareTotal) if (pv.recipients > 0) gPayoutViews.push_back(pv); } + + int64 now = GetTime(); + if (now - gLastPoolStatusWrite >= 10) { + WritePoolStatusJsonLocked(); + gLastPoolStatusWrite = now; + } } // --------------------------------------------------------------------------- -- cgit v1.2.3 From 8bf5b8cac5d91165b11d4ce65333b68edcc723b0 Mon Sep 17 00:00:00 2001 From: Nsable-sh Date: Fri, 7 Aug 2026 11:34:17 -0300 Subject: pool: add local Stratum bridge --- README.md | 9 ++- src/main.cpp | 186 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/main.h | 3 + src/main_gui.cpp | 22 +++++++ src/net.cpp | 2 +- src/readme.txt | 5 ++ src/sockcount.h | 1 + 7 files changed, 224 insertions(+), 4 deletions(-) (limited to 'src/main_gui.cpp') diff --git a/README.md b/README.md index af56081..7f4d9b0 100644 --- a/README.md +++ b/README.md @@ -226,11 +226,13 @@ nodes still discover pools through Nostr and `.btf`, not through any web domain. **Participant** — mine to someone else's pool. Enter or select the pool's `.btf` address and enable Start Mining. -SRBMiner and XMRig connect to operator pools through the `.btf` path: +The built-in participant miner connects to operator pools through the `.btf` +path. External miners can use Bitflash as a local Stratum bridge: ```bash -SRBMiner-MULTI --algorithm randomx --pool POOL_BTF_ADDRESS --wallet YOUR_BTF_ADDRESS --password x -xmrig -a rx/0 -o POOL_BTF_ADDRESS -u YOUR_BTF_ADDRESS -p x +./bitflash -nogui -stratumbridge=POOL_BTF_ADDRESS -stratumbridgeport=3333 +SRBMiner-MULTI --algorithm randomx --pool 127.0.0.1:3333 --wallet YOUR_BTF_ADDRESS --password x +xmrig -a rx/0 -o 127.0.0.1:3333 -u YOUR_BTF_ADDRESS -p x ``` --- @@ -243,6 +245,7 @@ xmrig -a rx/0 -o POOL_BTF_ADDRESS -u YOUR_BTF_ADDRESS -p x ./bitflash -nogui -gen -operator # pool operator ./bitflash -nogui -gen -operator -poolstatusfile=/var/www/api/pools.json ./bitflash -nogui -gen -participant=POOL_BTF_ADDRESS # mine to pool +./bitflash -nogui -stratumbridge=POOL_BTF_ADDRESS # local bridge for XMRig/SRBMiner ``` Every option takes `-` or `/`. **Under MSYS2 use the `-` form** — the shell diff --git a/src/main.cpp b/src/main.cpp index 6cb7353..2be9c54 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -106,6 +106,8 @@ string strPoolName = "Bitflash Pool"; string strPoolDashboardUrl; string strPoolStatusFile; double dPoolFeePercent = 0.0; +bool fStratumBridge = false; +int nStratumBridgePort = 3333; static std::atomic gParticipantSharesSent{0}; static std::atomic gParticipantSharesAccepted{0}; @@ -3873,6 +3875,190 @@ static bool PoolParticipantMiner() return true; } +// --------------------------------------------------------------------------- +// Local Stratum bridge -- lets external miners speak plain Stratum to +// 127.0.0.1 while Bitflash opens the .btf tunnel to the selected pool. +// --------------------------------------------------------------------------- + +struct StratumBridgeClientCtx +{ + SOCKET miner; + std::string pool; +}; + +static bool BridgeSendAll(SOCKET s, const char* p, int n) +{ + int off = 0; + while (off < n) + { + int r = send(s, p + off, n - off, 0); + if (r <= 0) + return false; + off += r; + } + return true; +} + +static void BridgePump(SOCKET a, SOCKET b) +{ + char buf[8192]; + while (!fShutdown) + { + fd_set rd; + FD_ZERO(&rd); + FD_SET(a, &rd); + FD_SET(b, &rd); + SOCKET maxfd = a > b ? a : b; + struct timeval tv = {1, 0}; + int r = select((int)maxfd + 1, &rd, NULL, NULL, &tv); + if (r <= 0) + continue; + + if (FD_ISSET(a, &rd)) + { + int n = recv(a, buf, sizeof(buf), 0); + if (n <= 0 || !BridgeSendAll(b, buf, n)) + break; + } + if (FD_ISSET(b, &rd)) + { + int n = recv(b, buf, sizeof(buf), 0); + if (n <= 0 || !BridgeSendAll(a, buf, n)) + break; + } + } +} + +static void ThreadStratumBridgeClient(void* arg) +{ + StratumBridgeClientCtx* ctx = (StratumBridgeClientCtx*)arg; + SOCKET miner = ctx->miner; + std::string pool = ctx->pool; + delete ctx; + + unsigned char target_pubkey[32]; + if (!btf::ParseAddress(pool, target_pubkey)) + { + LogPrint("worker", "[bridge] invalid pool .btf address: %s\n", pool.c_str()); + BtfCloseSocket(miner); + return; + } + + std::string meetingHostPort; + unsigned char service_enc_pub[32]; + if (!BtfResolve(pool, meetingHostPort, service_enc_pub)) + { + LogPrint("worker", "[bridge] failed to resolve pool %s\n", pool.c_str()); + BtfCloseSocket(miner); + return; + } + + size_t colon = meetingHostPort.rfind(':'); + if (colon == std::string::npos) + { + LogPrint("worker", "[bridge] bad pool endpoint '%s'\n", meetingHostPort.c_str()); + BtfCloseSocket(miner); + return; + } + std::string host = meetingHostPort.substr(0, colon); + int port = atoi(meetingHostPort.substr(colon + 1).c_str()); + if (port <= 0 || port > 65535) + { + LogPrint("worker", "[bridge] bad pool port in '%s'\n", meetingHostPort.c_str()); + BtfCloseSocket(miner); + return; + } + + SOCKET poolSock = btf::BtfClientTunnel(host.c_str(), (unsigned short)port, + target_pubkey, service_enc_pub); + if (poolSock == INVALID_SOCKET) + { + LogPrint("worker", "[bridge] tunnel connect failed for pool %s via %s\n", + pool.c_str(), meetingHostPort.c_str()); + BtfCloseSocket(miner); + return; + } + + LogPrint("worker", "[bridge] miner connected to pool %s via %s\n", + pool.c_str(), meetingHostPort.c_str()); + BridgePump(miner, poolSock); + BtfCloseSocket(poolSock); + BtfCloseSocket(miner); + LogPrint("worker", "[bridge] miner disconnected from pool %s\n", pool.c_str()); +} + +void ThreadStratumBridge(void*) +{ + if (strParticipantPool.empty()) + { + printf("Stratum bridge: no pool selected. Use /stratumbridge=POOL_BTF_ADDRESS\n"); + return; + } + + SOCKET listener = BtfSocketTag(socket(AF_INET, SOCK_STREAM, IPPROTO_TCP), + SOCK_STRATUM_BRIDGE); + if (listener == INVALID_SOCKET) + { + printf("Stratum bridge: could not create listen socket\n"); + return; + } + + int yes = 1; + setsockopt(listener, SOL_SOCKET, SO_REUSEADDR, (char*)&yes, sizeof(yes)); + + sockaddr_in addr; + memset(&addr, 0, sizeof(addr)); + addr.sin_family = AF_INET; + addr.sin_addr.s_addr = htonl(0x7f000001); + addr.sin_port = htons((unsigned short)nStratumBridgePort); + + if (bind(listener, (sockaddr*)&addr, sizeof(addr)) != 0 || + listen(listener, 16) != 0) + { + printf("Stratum bridge: could not listen on 127.0.0.1:%d\n", + nStratumBridgePort); + BtfCloseSocket(listener); + return; + } + + printf("Stratum bridge: listening on 127.0.0.1:%d for pool %s\n", + nStratumBridgePort, strParticipantPool.c_str()); + + while (!fShutdown) + { + fd_set rd; + FD_ZERO(&rd); + FD_SET(listener, &rd); + struct timeval tv = {1, 0}; + if (select((int)listener + 1, &rd, NULL, NULL, &tv) <= 0) + continue; + + sockaddr_in cli; +#ifdef _WIN32 + int len = sizeof(cli); +#else + socklen_t len = sizeof(cli); +#endif + SOCKET miner = BtfSocketTag(accept(listener, (sockaddr*)&cli, &len), + SOCK_STRATUM_BRIDGE); + if (miner == INVALID_SOCKET) + continue; + + StratumBridgeClientCtx* ctx = new StratumBridgeClientCtx(); + ctx->miner = miner; + ctx->pool = strParticipantPool; + if (_beginthread(ThreadStratumBridgeClient, 0, ctx) == (uintptr_t)-1) + { + delete ctx; + BtfCloseSocket(miner); + LogPrint("worker", "[bridge] failed to spawn client thread\n"); + } + } + + BtfCloseSocket(listener); + printf("Stratum bridge: stopped\n"); +} + bool BitcoinMiner(int nThreadId) { // Relay mode: never mines, just relays/syncs. (Shouldn't normally get here diff --git a/src/main.h b/src/main.h index 3866855..2786a4a 100644 --- a/src/main.h +++ b/src/main.h @@ -88,6 +88,8 @@ extern string strPoolName; // operator: announced pool name extern string strPoolDashboardUrl; // operator: optional dashboard URL extern string strPoolStatusFile; // operator: optional JSON status output path extern double dPoolFeePercent; // operator: announced fee percent +extern bool fStratumBridge; // participant helper: local Stratum bridge +extern int nStratumBridgePort; // local bridge listen port, default 3333 extern bool fSoloMineTest; // /solomine: mine without requiring a peer (local test) // Threads to hash with; 0 = decide from the hardware. Set by /genproclimit. extern int nMinerThreads; @@ -272,6 +274,7 @@ void LimitOrphanBlocks(unsigned int nMaxOrphans); // distinguishable, which the dedup filter would otherwise collapse into one. bool BitcoinMiner(int nThreadId = 1); void ThreadRPCServer(void* parg); // rpc.cpp -- .btf pool server +void ThreadStratumBridge(void* parg); // main.cpp -- local Stratum -> .btf bridge void GetParticipantMiningStats(uint64& sharesSent, uint64& sharesAccepted, double& hashRate); void SetParticipantMiningStatus(const std::string& status); std::string GetParticipantMiningStatus(); diff --git a/src/main_gui.cpp b/src/main_gui.cpp index 41661b9..ce6d2b5 100644 --- a/src/main_gui.cpp +++ b/src/main_gui.cpp @@ -115,6 +115,8 @@ static void PrintUsage() printf("Mining mode:\n"); printf(" /operator\n"); printf(" /participant=POOL_BTF_ADDRESS\n"); + printf(" /stratumbridge=POOL_BTF_ADDRESS\n"); + printf(" /stratumbridgeport=N (default 3333; listen on 127.0.0.1)\n"); printf(" /solomine\n"); printf(" /genproclimit=N (mining threads; 0 or absent = every core but one)\n"); printf(" /checkblocks=N (blocks re-verified at startup, default 288, 0 = all)\n"); @@ -226,6 +228,22 @@ static void ParseStartupArguments(int argc, char* argv[]) fMineModeFromCommandLine = true; } + if (arg(argc,argv,"/stratumbridge") || arg(argc,argv,"-stratumbridge")) + { + fStratumBridge = true; + string bridgePool = argval2(argc, argv, "/stratumbridge", "-stratumbridge"); + if (!bridgePool.empty()) + strParticipantPool = bridgePool; + } + + string bridgePort = argval2(argc, argv, "/stratumbridgeport", "-stratumbridgeport"); + if (!bridgePort.empty()) + { + int nPort = atoi(bridgePort.c_str()); + if (nPort > 0 && nPort <= 65535) + nStratumBridgePort = nPort; + } + string poolName = argval2(argc, argv, "/poolname", "-poolname"); if (!poolName.empty()) strPoolName = poolName; @@ -623,6 +641,10 @@ int main(int argc, char* argv[]) if (_beginthread(ThreadRPCServer, 0, NULL) == (uintptr_t)-1) printf("Error: _beginthread(ThreadRPCServer) failed\n"); } + if (fStratumBridge) { + if (_beginthread(ThreadStratumBridge, 0, NULL) == (uintptr_t)-1) + printf("Error: _beginthread(ThreadStratumBridge) failed\n"); + } if (fGenerateBitcoins) StartMinerThreads(); diff --git a/src/net.cpp b/src/net.cpp index 449d91c..b9bb95d 100644 --- a/src/net.cpp +++ b/src/net.cpp @@ -153,7 +153,7 @@ static const char* pszSockSite[SOCK_SITES] = { "external-ip probe", "listen socket", "inbound accept", "rendezvous dial", "rendezvous listen", "rendezvous accept", "loopback pair listener", "loopback pair app end", "loopback pair pump end", - "nostr relay" + "nostr relay", "stratum bridge" }; static string SockAccountingText() diff --git a/src/readme.txt b/src/readme.txt index 6af1694..fa58b6e 100644 --- a/src/readme.txt +++ b/src/readme.txt @@ -46,6 +46,11 @@ Command-line options (in addition to the original ones) /operator operator mode (run pool server) /participant=ADDR participant mode; mine to pool .btf address ADDR + /stratumbridge=ADDR + listen on 127.0.0.1:3333 and bridge external Stratum miners + to pool .btf address ADDR + /stratumbridgeport=N + local Stratum bridge port (default 3333) /poolname=NAME operator announcement pool name /poolfee=PCT operator announcement fee percent (example: 0.75) /pooldashboard=URL diff --git a/src/sockcount.h b/src/sockcount.h index 96b6efb..5147f55 100644 --- a/src/sockcount.h +++ b/src/sockcount.h @@ -63,6 +63,7 @@ enum SOCK_PAIR_APP, // loopback pair: the app end handed to CNode SOCK_PAIR_PUMP, // loopback pair: the pump end SOCK_NOSTR, // outbound to a Nostr relay + SOCK_STRATUM_BRIDGE, // local Stratum bridge listener/miner sockets SOCK_SITES }; -- cgit v1.2.3 From b1eb8b66b8d7b47c7fb2e85d5d4db86a11823366 Mon Sep 17 00:00:00 2001 From: Nsable-sh Date: Fri, 7 Aug 2026 17:14:36 -0300 Subject: pool: publish round proof ledger --- README.md | 14 +- docs/pool-directory.md | 12 +- scripts/build-pool-index.py | 18 ++- src/main.cpp | 1 + src/main.h | 1 + src/main_gui.cpp | 5 + src/readme.txt | 4 + src/rpc.cpp | 316 +++++++++++++++++++++++++++++++++++++++++++- 8 files changed, 359 insertions(+), 12 deletions(-) (limited to 'src/main_gui.cpp') diff --git a/README.md b/README.md index 7f4d9b0..ec8e9c4 100644 --- a/README.md +++ b/README.md @@ -219,10 +219,12 @@ Open **Options** from the menu bar. Under Mining Mode: Pool announcements are published on Nostr with live status fields, so external tools can query the latest pool state by `.btf` address. -Pool operators also write a local `pool_status.json` every ten seconds. By -default it is created in the data directory; use `-poolstatusfile=PATH` to write -it somewhere a dashboard or web sync can read. The file is informational only: -nodes still discover pools through Nostr and `.btf`, not through any web domain. +Pool operators also write a local `pool_status.json` every ten seconds and a +`pool_rounds.json` proof ledger whenever they find blocks or pay miners. By +default both are created in the data directory; use `-poolstatusfile=PATH` and +`-poolroundsfile=PATH` to write them somewhere a dashboard or web sync can read. +The files are informational only: nodes still discover pools through Nostr and +`.btf`, not through any web domain. **Participant** — mine to someone else's pool. Enter or select the pool's `.btf` address and enable Start Mining. @@ -243,7 +245,9 @@ xmrig -a rx/0 -o 127.0.0.1:3333 -u YOUR_BTF_ADDRESS -p x ./bitflash -nogui # node only ./bitflash -nogui -gen # node + solo mining ./bitflash -nogui -gen -operator # pool operator -./bitflash -nogui -gen -operator -poolstatusfile=/var/www/api/pools.json +./bitflash -nogui -gen -operator \ + -poolstatusfile=/var/www/pool_status.json \ + -poolroundsfile=/var/www/pool_rounds.json ./bitflash -nogui -gen -participant=POOL_BTF_ADDRESS # mine to pool ./bitflash -nogui -stratumbridge=POOL_BTF_ADDRESS # local bridge for XMRig/SRBMiner ``` diff --git a/docs/pool-directory.md b/docs/pool-directory.md index 6456084..76269b9 100644 --- a/docs/pool-directory.md +++ b/docs/pool-directory.md @@ -4,10 +4,13 @@ Nodes discover pools through Nostr and `.btf`; the website only helps humans compare operators. -Operators can publish the `pool_status.json` file produced by: +Operators can publish the `pool_status.json` and `pool_rounds.json` files +produced by: ```bash -bitflash -nogui -gen -operator -poolstatusfile=/var/www/pool_status.json +bitflash -nogui -gen -operator \ + -poolstatusfile=/var/www/pool_status.json \ + -poolroundsfile=/var/www/pool_rounds.json ``` Build a static directory from one or more status files: @@ -23,6 +26,11 @@ The generated output contains: - `index.html`: human-readable ranking page. - `pools.json`: machine-readable API for `api.bitflash.network/pools.json`. +When a source is an HTTP(S) `pool_status.json`, the directory links to a +`pool_rounds.json` proof file next to it. That file records the block height, +block hash, coinbase txid, coinbase value, pool fee, total shares, per-address +share counts, computed payouts, and payout txids once paid. + Ranking order is intentionally simple: 1. online pools first; diff --git a/scripts/build-pool-index.py b/scripts/build-pool-index.py index 382b6c3..d3eb386 100644 --- a/scripts/build-pool-index.py +++ b/scripts/build-pool-index.py @@ -8,6 +8,7 @@ import html import json import os import time +import urllib.parse import urllib.request @@ -23,6 +24,10 @@ def normalize_pool(source, obj, now): operator = obj.get("operator", {}) pool = obj.get("pool", {}) node = obj.get("node", {}) + rounds_file = operator.get("roundsFile") or "pool_rounds.json" + rounds_url = "" + if source.startswith("http://") or source.startswith("https://"): + rounds_url = urllib.parse.urljoin(source, rounds_file) updated = int(obj.get("updatedAt") or 0) age = max(0, now - updated) if updated else None online = bool(node.get("poolRunning")) and age is not None and age <= 120 @@ -39,6 +44,8 @@ def normalize_pool(source, obj, now): "blocksFoundSession": int(pool.get("blocksFoundSession") or 0), "roundShares": int(pool.get("roundShares") or 0), "hashRate": float(pool.get("hashRate") or 0.0), + "roundsUrl": rounds_url, + "recentRounds": obj.get("recentRounds", []), } @@ -56,6 +63,7 @@ def render_html(pools, generated_at): rows = [] for p in pools: dash = p["dashboardUrl"] + rounds = p.get("roundsUrl", "") # Only linkify http(s). The dashboard URL comes from an operator's # self-published status file, so a "javascript:" or "data:" scheme # would become a clickable script-injection on this public page. @@ -65,6 +73,12 @@ def render_html(pools, generated_at): html.escape(dash, quote=True)) else: dash_html = "-" + rounds_lower = rounds.lower() + if rounds_lower.startswith("http://") or rounds_lower.startswith("https://"): + rounds_html = 'proof'.format( + html.escape(rounds, quote=True)) + else: + rounds_html = "-" rows.append( "" "{status}" @@ -76,6 +90,7 @@ def render_html(pools, generated_at): "{height}" "{age}" "{dash}" + "{rounds}" "".format( status="online" if p["online"] else "stale", name=html.escape(p["name"]), @@ -87,6 +102,7 @@ def render_html(pools, generated_at): height=p["height"], age="{}s".format(p["ageSeconds"]) if p["ageSeconds"] is not None else "-", dash=dash_html, + rounds=rounds_html, ) ) @@ -110,7 +126,7 @@ def render_html(pools, generated_at):

This directory is informational. Bitflash nodes discover pools through Nostr and .btf; this page only helps humans compare public operators.

- + {rows} diff --git a/src/main.cpp b/src/main.cpp index 2be9c54..2eb0e3a 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -105,6 +105,7 @@ string strParticipantPool; // participant mode: pool .btf address string strPoolName = "Bitflash Pool"; string strPoolDashboardUrl; string strPoolStatusFile; +string strPoolRoundsFile; double dPoolFeePercent = 0.0; bool fStratumBridge = false; int nStratumBridgePort = 3333; diff --git a/src/main.h b/src/main.h index 2786a4a..eafbf16 100644 --- a/src/main.h +++ b/src/main.h @@ -87,6 +87,7 @@ extern string strParticipantPool; // participant: pool .btf address extern string strPoolName; // operator: announced pool name extern string strPoolDashboardUrl; // operator: optional dashboard URL extern string strPoolStatusFile; // operator: optional JSON status output path +extern string strPoolRoundsFile; // operator: optional public round proof path extern double dPoolFeePercent; // operator: announced fee percent extern bool fStratumBridge; // participant helper: local Stratum bridge extern int nStratumBridgePort; // local bridge listen port, default 3333 diff --git a/src/main_gui.cpp b/src/main_gui.cpp index ce6d2b5..7ad2075 100644 --- a/src/main_gui.cpp +++ b/src/main_gui.cpp @@ -126,6 +126,7 @@ static void PrintUsage() printf(" /poolfee=PCT\n"); printf(" /pooldashboard=URL (alias: /pooldash=URL)\n"); printf(" /poolstatusfile=PATH (write pool status JSON for dashboards)\n"); + printf(" /poolroundsfile=PATH (write public pool round proofs JSON)\n"); printf("\n"); printf(".btf and rendezvous:\n"); printf(" /connectbtf=PEER_BTF_ADDRESS\n"); @@ -262,6 +263,10 @@ static void ParseStartupArguments(int argc, char* argv[]) if (!poolStatusFile.empty()) strPoolStatusFile = poolStatusFile; + string poolRoundsFile = argval2(argc, argv, "/poolroundsfile", "-poolroundsfile"); + if (!poolRoundsFile.empty()) + strPoolRoundsFile = poolRoundsFile; + string btfConnect = argval2(argc, argv, "/connectbtf", "-connectbtf"); if (!btfConnect.empty()) strBtfConnect = btfConnect; diff --git a/src/readme.txt b/src/readme.txt index fa58b6e..b8b89c1 100644 --- a/src/readme.txt +++ b/src/readme.txt @@ -55,6 +55,10 @@ Command-line options (in addition to the original ones) /poolfee=PCT operator announcement fee percent (example: 0.75) /pooldashboard=URL operator announcement dashboard URL + /poolstatusfile=PATH + write pool status JSON for dashboards + /poolroundsfile=PATH + write public pool round proofs JSON /connectbtf=ADDR keep an outbound .btf connection to ADDR /rvrelay=HOST:PORT diff --git a/src/rpc.cpp b/src/rpc.cpp index 1aa4f9b..813f1d1 100644 --- a/src/rpc.cpp +++ b/src/rpc.cpp @@ -263,11 +263,47 @@ struct PendingPayout { static std::vector gPendingPayouts; static std::mutex gPayoutLedgerMutex; +struct PoolRoundProof { + std::string id; + int blockHeight; + int matureAtHeight; + int64 createdAt; + std::string blockHash; + std::string coinbaseTxid; + std::string foundBy; + double feePercent; + uint64 totalShares; + int64 coinbaseValue; + int64 subsidy; + int64 minerReward; + int64 operatorReward; + int skippedDust; + int64 forfeitedBadAddress; + std::map shares; + std::map payouts; + std::map paidTxids; + + PoolRoundProof() + : blockHeight(0), matureAtHeight(0), createdAt(0), feePercent(0.0), + totalShares(0), coinbaseValue(0), subsidy(0), minerReward(0), operatorReward(0), + skippedDust(0), forfeitedBadAddress(0) {} +}; +static std::vector gPoolRounds; +static std::mutex gPoolRoundsMutex; +static const size_t MAX_POOL_ROUNDS = 200; + static std::string PayoutFilePath() { return GetAppDir() + "/pending_payouts.json"; } +static std::string PoolRoundsFilePath() +{ + if (!strPoolRoundsFile.empty()) + return strPoolRoundsFile; + return GetAppDir() + "/pool_rounds.json"; +} + static std::string PayoutIdForBlock(int blockHeight) { return strprintf("block-%d", blockHeight); @@ -362,6 +398,167 @@ static void LoadPendingPayouts() } } +static nlohmann::json PoolRoundToJson(const PoolRoundProof& r) +{ + nlohmann::json j; + j["id"] = r.id; + j["blockHeight"] = r.blockHeight; + j["matureAtHeight"] = r.matureAtHeight; + j["createdAt"] = r.createdAt; + j["blockHash"] = r.blockHash; + j["coinbaseTxid"] = r.coinbaseTxid; + j["foundBy"] = r.foundBy; + j["feePercent"] = r.feePercent; + j["totalShares"] = r.totalShares; + j["coinbaseValueSatoshis"] = r.coinbaseValue; + j["coinbaseValueBtf"] = FormatMoney(r.coinbaseValue); + j["subsidySatoshis"] = r.subsidy; + j["subsidyBtf"] = FormatMoney(r.subsidy); + j["minerRewardSatoshis"] = r.minerReward; + j["minerRewardBtf"] = FormatMoney(r.minerReward); + j["operatorRewardSatoshis"] = r.operatorReward; + j["operatorRewardBtf"] = FormatMoney(r.operatorReward); + j["skippedDust"] = r.skippedDust; + j["forfeitedBadAddressSatoshis"] = r.forfeitedBadAddress; + j["forfeitedBadAddressBtf"] = FormatMoney(r.forfeitedBadAddress); + + j["shares"] = nlohmann::json::object(); + for (const auto& kv : r.shares) + j["shares"][kv.first] = kv.second; + + j["payouts"] = nlohmann::json::object(); + for (const auto& kv : r.payouts) { + nlohmann::json p; + p["amountSatoshis"] = kv.second; + p["amountBtf"] = FormatMoney(kv.second); + std::map::const_iterator tx = r.paidTxids.find(kv.first); + if (tx != r.paidTxids.end()) + p["txid"] = tx->second; + else + p["txid"] = nullptr; + j["payouts"][kv.first] = p; + } + return j; +} + +static void SavePoolRoundsLocked() +{ + nlohmann::json root; + root["schema"] = "bitflash-pool-rounds-1"; + root["updatedAt"] = GetTime(); + root["rounds"] = nlohmann::json::array(); + for (const PoolRoundProof& r : gPoolRounds) + root["rounds"].push_back(PoolRoundToJson(r)); + + std::string path = PoolRoundsFilePath(); + std::string tmp = path + ".tmp"; + FILE* f = fopen(tmp.c_str(), "w"); + if (!f) { + LogPrint("payout", "[payout] WARNING: could not write %s\n", tmp.c_str()); + return; + } + std::string s = root.dump(2); + fwrite(s.c_str(), 1, s.size(), f); + if (fclose(f) != 0) { + LogPrint("payout", "[payout] WARNING: could not close %s\n", tmp.c_str()); + return; + } +#ifdef _WIN32 + remove(path.c_str()); +#endif + if (rename(tmp.c_str(), path.c_str()) != 0) + LogPrint("payout", "[payout] WARNING: could not replace %s\n", path.c_str()); +} + +static void LoadPoolRounds() +{ + std::string path = PoolRoundsFilePath(); + FILE* f = fopen(path.c_str(), "r"); + if (!f) return; + fseek(f, 0, SEEK_END); + long sz = ftell(f); + fseek(f, 0, SEEK_SET); + std::string s(sz, '\0'); + fread(&s[0], 1, sz, f); + fclose(f); + + try { + nlohmann::json root = nlohmann::json::parse(s); + nlohmann::json arr = root.is_array() ? root : root["rounds"]; + std::lock_guard lk(gPoolRoundsMutex); + for (const auto& obj : arr) { + PoolRoundProof r; + if (obj.contains("id") && obj["id"].is_string()) r.id = obj["id"].get(); + if (obj.contains("blockHeight")) r.blockHeight = obj["blockHeight"].get(); + if (obj.contains("matureAtHeight")) r.matureAtHeight = obj["matureAtHeight"].get(); + if (obj.contains("createdAt")) r.createdAt = obj["createdAt"].get(); + if (obj.contains("blockHash") && obj["blockHash"].is_string()) r.blockHash = obj["blockHash"].get(); + if (obj.contains("coinbaseTxid") && obj["coinbaseTxid"].is_string()) r.coinbaseTxid = obj["coinbaseTxid"].get(); + if (obj.contains("foundBy") && obj["foundBy"].is_string()) r.foundBy = obj["foundBy"].get(); + if (obj.contains("feePercent")) r.feePercent = obj["feePercent"].get(); + if (obj.contains("totalShares")) r.totalShares = obj["totalShares"].get(); + if (obj.contains("coinbaseValueSatoshis")) r.coinbaseValue = obj["coinbaseValueSatoshis"].get(); + if (obj.contains("subsidySatoshis")) r.subsidy = obj["subsidySatoshis"].get(); + if (obj.contains("minerRewardSatoshis")) r.minerReward = obj["minerRewardSatoshis"].get(); + if (obj.contains("operatorRewardSatoshis")) r.operatorReward = obj["operatorRewardSatoshis"].get(); + if (obj.contains("skippedDust")) r.skippedDust = obj["skippedDust"].get(); + if (obj.contains("forfeitedBadAddressSatoshis")) r.forfeitedBadAddress = obj["forfeitedBadAddressSatoshis"].get(); + if (obj.contains("shares") && obj["shares"].is_object()) + for (auto it = obj["shares"].begin(); it != obj["shares"].end(); ++it) + r.shares[it.key()] = it.value().get(); + if (obj.contains("payouts") && obj["payouts"].is_object()) + for (auto it = obj["payouts"].begin(); it != obj["payouts"].end(); ++it) { + if (it.value().is_object()) { + r.payouts[it.key()] = it.value().value("amountSatoshis", (int64)0); + if (it.value().contains("txid") && it.value()["txid"].is_string()) + r.paidTxids[it.key()] = it.value()["txid"].get(); + } else if (it.value().is_number_integer()) { + r.payouts[it.key()] = it.value().get(); + } + } + if (!r.id.empty()) + gPoolRounds.push_back(r); + } + while (gPoolRounds.size() > MAX_POOL_ROUNDS) + gPoolRounds.erase(gPoolRounds.begin()); + LogPrint("payout", "[payout] loaded %zu pool round proof(s) from disk\n", + gPoolRounds.size()); + } catch (const std::exception& e) { + LogPrint("payout", "[payout] WARNING: failed to parse %s: %s\n", + path.c_str(), e.what()); + } +} + +static void RecordPoolRound(const PoolRoundProof& round) +{ + std::lock_guard lk(gPoolRoundsMutex); + for (PoolRoundProof& existing : gPoolRounds) { + if (existing.id == round.id) { + existing = round; + SavePoolRoundsLocked(); + return; + } + } + gPoolRounds.push_back(round); + while (gPoolRounds.size() > MAX_POOL_ROUNDS) + gPoolRounds.erase(gPoolRounds.begin()); + SavePoolRoundsLocked(); +} + +static void MarkPoolRoundPaid(const std::string& id, + const std::string& address, + const std::string& txid) +{ + std::lock_guard lk(gPoolRoundsMutex); + for (PoolRoundProof& r : gPoolRounds) { + if (r.id != id) + continue; + r.paidTxids[address] = txid; + SavePoolRoundsLocked(); + return; + } +} + // Payouts ready to execute are moved here under gPayoutExecMutex. // A dedicated thread drains this list so SendMoney (which takes cs_main) // never runs on the event loop thread and can't stall miner I/O. @@ -399,10 +596,28 @@ static bool FindRecordedPoolPayout(const std::string& id, } static void QueuePayouts(int blockHeight, + const std::string& blockHash, + const std::string& coinbaseTxid, + int64 coinbaseValue, + const std::string& foundBy, const std::map& shareCount, uint64 total) { + PoolRoundProof round; + round.id = PayoutIdForBlock(blockHeight); + round.blockHeight = blockHeight; + round.matureAtHeight = blockHeight + COINBASE_MATURITY; + round.createdAt = GetTime(); + round.blockHash = blockHash; + round.coinbaseTxid = coinbaseTxid; + round.coinbaseValue = coinbaseValue; + round.foundBy = foundBy; + round.feePercent = dPoolFeePercent; + round.totalShares = total; + round.shares = shareCount; + if (total == 0) { + RecordPoolRound(round); LogPrint("payout", "[payout] block %d: zero shares recorded -- nothing to pay out\n", blockHeight); return; @@ -418,9 +633,13 @@ static void QueuePayouts(int blockHeight, int64 nSubsidy = 50 * COIN; nSubsidy >>= (blockHeight / 210000); // halving schedule int64 minerReward = (int64)((double)nSubsidy * (1.0 - feeFrac)); + int64 operatorCut = nSubsidy - minerReward; + round.subsidy = nSubsidy; + round.minerReward = minerReward; + round.operatorReward = operatorCut; PendingPayout pp; - pp.id = PayoutIdForBlock(blockHeight); + pp.id = round.id; pp.blockHeight = blockHeight; pp.matureAtHeight = blockHeight + COINBASE_MATURITY; @@ -443,15 +662,18 @@ static void QueuePayouts(int blockHeight, continue; } pp.amounts[kv.first] = amount; + round.payouts[kv.first] = amount; } + round.skippedDust = skipped; + round.forfeitedBadAddress = burned; { std::lock_guard lk(gPayoutLedgerMutex); gPendingPayouts.push_back(pp); SavePendingPayoutsLocked(); } + RecordPoolRound(round); - int64 operatorCut = nSubsidy - minerReward; LogPrint("payout", "[payout] block %d: subsidy %s BTF, miners get %s" " (fee %.2f%% = %s to operator)," @@ -548,12 +770,14 @@ static void PayoutThreadFn(void*) LogPrint("payout", "[payout] recovered recorded payout %s to %s" " from wallet tx %s\n", FormatMoney(kv.second).c_str(), kv.first.c_str(), txid.c_str()); + MarkPoolRoundPaid(ppQueued.id, kv.first, txid); } else if (SendMoney(sc, kv.second, wtx)) { txid = wtx.GetHash().GetHex(); LogPrint("payout", "[payout] paid %s to %s\n", FormatMoney(kv.second).c_str(), kv.first.c_str()); + MarkPoolRoundPaid(ppQueued.id, kv.first, txid); } else { @@ -664,7 +888,8 @@ static void WritePoolStatusJsonLocked() {"name", strPoolName}, {"btfAddress", BtfLocalAddress()}, {"feePercent", dPoolFeePercent}, - {"dashboardUrl", strPoolDashboardUrl} + {"dashboardUrl", strPoolDashboardUrl}, + {"roundsFile", "pool_rounds.json"} }; j["node"] = { @@ -702,6 +927,30 @@ static void WritePoolStatusJsonLocked() }); } + j["recentRounds"] = nlohmann::json::array(); + { + std::lock_guard lk(gPoolRoundsMutex); + size_t start = gPoolRounds.size() > 10 ? gPoolRounds.size() - 10 : 0; + for (size_t i = start; i < gPoolRounds.size(); i++) { + const PoolRoundProof& r = gPoolRounds[i]; + int unpaid = 0; + for (const auto& kv : r.payouts) + if (r.paidTxids.count(kv.first) == 0) + unpaid++; + j["recentRounds"].push_back({ + {"id", r.id}, + {"blockHeight", r.blockHeight}, + {"blockHash", r.blockHash}, + {"coinbaseTxid", r.coinbaseTxid}, + {"totalShares", r.totalShares}, + {"payouts", (int)r.payouts.size()}, + {"unpaidPayouts", unpaid}, + {"coinbaseValueBtf", FormatMoney(r.coinbaseValue)}, + {"minerRewardBtf", FormatMoney(r.minerReward)} + }); + } + } + std::string path = PoolStatusFilePath(); std::string tmp = path + ".tmp"; FILE* f = fopen(tmp.c_str(), "w"); @@ -1231,7 +1480,9 @@ static bool HandleLine(Miner* m, const std::string& rawLine, if (accepted) { // Snapshot height before RebuildJob() (which increments it) int foundHeight = gCurrentJob.height; - QueuePayouts(foundHeight, roundShareCount, roundShareTotal); + QueuePayouts(foundHeight, b.GetHash().GetHex(), b.vtx[0].GetHash().GetHex(), + b.vtx[0].vout[0].nValue, m->address, + roundShareCount, roundShareTotal); // Reset round shares roundShareCount.clear(); roundShareTotal = 0; @@ -1333,6 +1584,62 @@ int RunPoolStratumSelfTest() HandleLine(&miner, stale.dump(), roundShareCount, roundShareTotal, blocksFound); check(roundShareTotal == 1, "stale submit does not increment shares"); + std::vector savedRounds = gPoolRounds; + std::string savedRoundsFile = strPoolRoundsFile; + std::string proofPath = "pool_rounds_selftest.json"; + remove(proofPath.c_str()); + remove((proofPath + ".tmp").c_str()); + gPoolRounds.clear(); + strPoolRoundsFile = proofPath; + + PoolRoundProof proof; + proof.id = "selftest-round"; + proof.blockHeight = 42; + proof.matureAtHeight = 42 + COINBASE_MATURITY; + proof.createdAt = GetTime(); + proof.blockHash = block.GetHash().GetHex(); + proof.coinbaseTxid = coinbase.GetHash().GetHex(); + proof.foundBy = miner.address; + proof.feePercent = 1.25; + proof.totalShares = 3; + proof.coinbaseValue = 50 * COIN; + proof.subsidy = 50 * COIN; + proof.minerReward = 49 * COIN; + proof.operatorReward = 1 * COIN; + proof.shares[miner.address] = 3; + proof.payouts[miner.address] = 49 * COIN; + RecordPoolRound(proof); + MarkPoolRoundPaid(proof.id, miner.address, "selftest-txid"); + + bool proofOk = false; + FILE* pf = fopen(proofPath.c_str(), "r"); + if (pf) { + fseek(pf, 0, SEEK_END); + long sz = ftell(pf); + fseek(pf, 0, SEEK_SET); + std::string s(sz, '\0'); + fread(&s[0], 1, sz, pf); + fclose(pf); + try { + json root = json::parse(s); + proofOk = + root.value("schema", "") == "bitflash-pool-rounds-1" && + root["rounds"].is_array() && + root["rounds"].size() == 1 && + root["rounds"][0].value("id", "") == proof.id && + root["rounds"][0]["shares"].value(miner.address, (uint64)0) == 3 && + root["rounds"][0]["payouts"][miner.address].value("txid", "") == "selftest-txid"; + } catch (...) { + proofOk = false; + } + } + check(proofOk, "pool round proof ledger is written and paid txid is recorded"); + + remove(proofPath.c_str()); + remove((proofPath + ".tmp").c_str()); + strPoolRoundsFile = savedRoundsFile; + gPoolRounds = savedRounds; + gCurrentJob = savedJob; gHaveJob = savedHaveJob; @@ -1754,6 +2061,7 @@ void ThreadRPCServer(void*) } LoadPendingPayouts(); + LoadPoolRounds(); _beginthread(AcceptLoopFn, 0, NULL); _beginthread(ThreadBtfPoolAnnouncer, 0, NULL); -- cgit v1.2.3 From 534cdc2675378a1213b4999fbd6b03f6f0b6ffbc Mon Sep 17 00:00:00 2001 From: Bitflash Date: Fri, 7 Aug 2026 18:40:19 -0300 Subject: shutdown: flush wallet.dat on Windows headless stop The headless loop installed SIGINT/SIGTERM handlers only under #ifndef _WIN32, so a headless node on Windows never reached the clean StopNode()+DBFlush() path: Ctrl-C, closing the console window, a service stop and OS shutdown all killed it mid-write, and a Berkeley DB wallet.dat left unflushed will not reopen. Install a SetConsoleCtrlHandler that mirrors the POSIX path: set fShutdown and, because the OS terminates us shortly after the handler returns on CLOSE/LOGOFF/SHUTDOWN, block until the main loop signals the flush is done. A 'taskkill /F' is still an uncatchable hard kill by design. Closes #16. --- src/main_gui.cpp | 39 ++++++++++++++++++++++++++++++++++++++- 1 file changed, 38 insertions(+), 1 deletion(-) (limited to 'src/main_gui.cpp') diff --git a/src/main_gui.cpp b/src/main_gui.cpp index 7ad2075..b036ac9 100644 --- a/src/main_gui.cpp +++ b/src/main_gui.cpp @@ -363,6 +363,36 @@ static void FatalStartupError(bool fHeadless, const string& strWhat, const strin #endif } +#ifdef _WIN32 +// Windows equivalent of the SIGINT/SIGTERM handler the headless loop installs on +// POSIX. Without it a headless node on Windows had no way to reach the clean +// StopNode()+DBFlush() path: Ctrl-C, closing the console window, a service stop +// and OS shutdown all killed it mid-write, and a wallet.dat left unflushed by +// Berkeley DB will not reopen. For CLOSE/LOGOFF/SHUTDOWN the OS terminates us +// shortly after the handler returns, so we set fShutdown and then block until the +// main loop signals the flush is done -- returning any sooner throws the flush +// away, which is the whole bug. (A `taskkill /F` is still an uncatchable hard +// kill; nothing can help there, and nothing should pretend to.) +static volatile bool g_fHeadlessShutdownDone = false; +static BOOL WINAPI HeadlessConsoleCtrlHandler(DWORD dwCtrlType) +{ + switch (dwCtrlType) + { + case CTRL_C_EVENT: + case CTRL_BREAK_EVENT: + case CTRL_CLOSE_EVENT: + case CTRL_LOGOFF_EVENT: + case CTRL_SHUTDOWN_EVENT: + fShutdown = true; + while (!g_fHeadlessShutdownDone) + Sleep(50); + return TRUE; + default: + return FALSE; + } +} +#endif + int main(int argc, char* argv[]) { if (arg(argc,argv,"/help") || arg(argc,argv,"-help") || @@ -662,7 +692,9 @@ int main(int argc, char* argv[]) #endif if (fHeadless) { -#ifndef _WIN32 +#ifdef _WIN32 + SetConsoleCtrlHandler(HeadlessConsoleCtrlHandler, TRUE); +#else auto sig=[](int){fShutdown=true;}; signal(SIGINT,sig); signal(SIGTERM,sig); #endif @@ -674,6 +706,11 @@ int main(int argc, char* argv[]) // called here, which is why a wallet.dat copied elsewhere would not // open -- issue #40. Not optional. DBFlush(true); +#ifdef _WIN32 + // wallet.dat is safely flushed and closed now; let a pending console + // control handler (CLOSE/LOGOFF/SHUTDOWN) return so the OS can finish. + g_fHeadlessShutdownDone = true; +#endif return 0; } -- cgit v1.2.3 From ff3829b28fd2ddc85a9a3bb4424cd5b4719854ab Mon Sep 17 00:00:00 2001 From: Nsable-sh Date: Tue, 11 Aug 2026 15:38:31 -0300 Subject: net: add optional SOCKS5 outbound proxy --- README.md | 6 ++ src/Makefile | 7 +- src/btfrv.cpp | 9 ++ src/main_gui.cpp | 16 +++- src/makefile.mingw | 7 +- src/net.cpp | 4 + src/nostr.cpp | 50 +---------- src/proxy.cpp | 254 +++++++++++++++++++++++++++++++++++++++++++++++++++++ src/proxy.h | 31 +++++++ src/readme.txt | 3 + src/selftest.cpp | 42 ++++++++- 11 files changed, 374 insertions(+), 55 deletions(-) create mode 100644 src/proxy.cpp create mode 100644 src/proxy.h (limited to 'src/main_gui.cpp') diff --git a/README.md b/README.md index c28b0f1..1b68ccd 100644 --- a/README.md +++ b/README.md @@ -310,6 +310,7 @@ Other options worth knowing: ```bash -datadir=PATH # wallet and chain data elsewhere -port=N # P2P listen port, default 8433 +-socks=HOST:PORT # SOCKS5 for outbound Nostr and .btf rendezvous dials -debug # verbose log; without it debug.log is nearly silent -help # full list ``` @@ -318,6 +319,11 @@ Other options worth knowing: needed — the data directory takes an exclusive lock, so a second node pointed at the same one will refuse to start. +`-socks=127.0.0.1:9050` routes outbound Nostr discovery and `.btf` rendezvous +dials through a local SOCKS5 proxy such as Tor. When it is set, the node skips +plain HTTP external-IP probes instead of leaking a direct request outside the +proxy. + ### When something looks wrong **Menu > Diagnostics**, and the same report in `debug.log` every ten minutes: diff --git a/src/Makefile b/src/Makefile index 00a8182..511fe79 100644 --- a/src/Makefile +++ b/src/Makefile @@ -30,7 +30,7 @@ WARNFLAGS = -Wformat -Werror=format \ -Wno-narrowing -Wno-unused-result CXXFLAGS = -std=gnu++14 -O2 $(WARNFLAGS) -fpermissive -pthread \ - -DSECP256K1_STATIC \ + -DSECP256K1_STATIC -DBITFLASH_NODE_BUILD \ -I$(SECP_INC) -I$(RANDOMX_DIR)/src \ -I/usr/include/nlohmann \ -I$(IMGUI_DIR) @@ -53,7 +53,7 @@ LIBS = -L$(SECP_LIB) -lsecp256k1 \ HEADERS = headers_core.h headers.h util.h main.h serialize.h uint256.h \ key.h bignum.h script.h db.h base58.h net.h nostr.h \ randomx_pow.h btfaddr.h btfchan.h btfrv.h btftunnel.h market.h \ - selftest.h walletcmd.h compat.h sockcount.h font_roboto.h + selftest.h walletcmd.h compat.h sockcount.h proxy.h font_roboto.h IMGUI_OBJS = obj/imgui.o obj/imgui_draw.o obj/imgui_tables.o \ obj/imgui_widgets.o obj/imgui_impl_glfw.o obj/imgui_impl_opengl3.o @@ -61,7 +61,7 @@ IMGUI_OBJS = obj/imgui.o obj/imgui_draw.o obj/imgui_tables.o \ NODE_OBJS = obj/util.o obj/script.o obj/db.o obj/net.o obj/main.o \ obj/market.o obj/sha.o obj/nostr.o obj/randomx_pow.o \ obj/btfaddr.o obj/btfchan.o obj/btfrv.o obj/btftunnel.o obj/rpc.o \ - obj/selftest.o obj/bip32.o obj/walletcmd.o + obj/selftest.o obj/bip32.o obj/walletcmd.o obj/proxy.o GUI_OBJS = obj/gui.o obj/main_gui.o @@ -193,5 +193,6 @@ tests: test_bip32 bitflash-node ./bitflash-node -selftest=consensus-limits ./bitflash-node -selftest=pool-stratum ./bitflash-node -selftest=parse-money + ./bitflash-node -selftest=socks5-proxy .PHONY: tests fuzz-net-message-smoke fuzz-script-smoke diff --git a/src/btfrv.cpp b/src/btfrv.cpp index 6ac3280..d4f079f 100644 --- a/src/btfrv.cpp +++ b/src/btfrv.cpp @@ -5,6 +5,9 @@ // Windows/Winsock implementation (Layer 6 will #ifdef this for Linux/BSD sockets). #include "btfrv.h" +#ifdef BITFLASH_NODE_BUILD +#include "proxy.h" +#endif // Portable sockets: Winsock on Windows, BSD sockets on Linux/POSIX (for the // headless relay `bitflashd` that runs on the VPS). @@ -92,6 +95,11 @@ static bool WriteN(SOCKET s, const void* buf, int n) static SOCKET ConnectTo(const char* host, unsigned short port) { +#ifdef BITFLASH_NODE_BUILD + SOCKET s = BtfConnectSocket(host, port, SOCK_RV_DIAL, 10); + if (s == INVALID_SOCKET) + return INVALID_SOCKET; +#else struct addrinfo hints, *res = NULL; memset(&hints, 0, sizeof(hints)); hints.ai_family = AF_INET; @@ -119,6 +127,7 @@ static SOCKET ConnectTo(const char* host, unsigned short port) CLOSESOCK(s); freeaddrinfo(res); return INVALID_SOCKET; } freeaddrinfo(res); +#endif // Clear the timeout -- the socket is now a live tunnel and must not // time out when idle. recv() will block until data arrives or peer closes. diff --git a/src/main_gui.cpp b/src/main_gui.cpp index b036ac9..90a6050 100644 --- a/src/main_gui.cpp +++ b/src/main_gui.cpp @@ -1,6 +1,7 @@ // Bitflash entry point -- starts node threads then runs GUI (or headless). #include "headers_core.h" +#include "proxy.h" #include "selftest.h" #include "walletcmd.h" #include // hardware_concurrency, to sanity-check /genproclimit @@ -110,7 +111,8 @@ static void PrintUsage() printf(" /debug\n"); printf(" /gen\n"); printf(" /nogui or /daemon\n"); - printf(" /selftest=wallet-keypool, wallet-hd, wallet-format, wallet-crypto, or wallet-encrypt\n"); + printf(" /selftest=wallet-keypool, wallet-hd, wallet-format, wallet-crypto, wallet-encrypt,\n"); + printf(" net-message, consensus-limits, pool-stratum, parse-money, or socks5-proxy\n"); printf("\n"); printf("Mining mode:\n"); printf(" /operator\n"); @@ -135,6 +137,7 @@ static void PrintUsage() printf("\n"); printf("Network:\n"); printf(" /port=N (P2P listen port, default 8433)\n"); + printf(" /socks=HOST:PORT (SOCKS5 proxy for outbound Nostr and .btf relay dials)\n"); printf(" /btfseed=ADDRESS:ENCHEX (extra bootstrap peer, repeatable)\n"); printf("\n"); printf("Wallet:\n"); @@ -282,6 +285,17 @@ static void ParseStartupArguments(int argc, char* argv[]) if (!announceRelay.empty()) strBtfAnnounceRelay = announceRelay; + string socksProxy = argval2(argc, argv, "/socks", "-socks"); + if (!socksProxy.empty()) + { + string err; + if (!BtfSetSocks5Proxy(socksProxy, err)) + fprintf(stderr, "Ignoring /socks=%s: %s\n", socksProxy.c_str(), err.c_str()); + else + fprintf(stderr, "SOCKS5 proxy enabled for outbound discovery/relay dials: %s\n", + BtfSocks5ProxyName().c_str()); + } + // /btfseed=ADDRESS:ENCHEX -- extra bootstrap peers, repeatable. Useful for // testing the seed path and for private networks that ship no compiled list. for (int i = 1; i < argc; i++) diff --git a/src/makefile.mingw b/src/makefile.mingw index b946e50..28e925c 100644 --- a/src/makefile.mingw +++ b/src/makefile.mingw @@ -21,7 +21,7 @@ WARNFLAGS = -Wformat -Werror=format \ # 64, which capped this node at 63 watchable peers no matter how many it had. CXXFLAGS = -std=gnu++14 -O2 $(WARNFLAGS) -fpermissive -pthread \ -DWIN32 -DNOPCH -DFD_SETSIZE=1024 \ - -DSECP256K1_STATIC \ + -DSECP256K1_STATIC -DBITFLASH_NODE_BUILD \ -I$(DEPS)/include -I$(RANDOMX)/src \ -I$(DEPS)/include/nlohmann \ -IC:/msys64/ucrt64/include \ @@ -42,7 +42,7 @@ IMGUI_OBJS = obj/imgui.o obj/imgui_draw.o obj/imgui_tables.o \ NODE_OBJS = obj/util.o obj/script.o obj/db.o obj/net.o obj/main.o \ obj/market.o obj/sha.o obj/nostr.o obj/randomx_pow.o \ obj/btfaddr.o obj/btfchan.o obj/btfrv.o obj/btftunnel.o obj/rpc.o \ - obj/selftest.o obj/bip32.o obj/walletcmd.o + obj/selftest.o obj/bip32.o obj/walletcmd.o obj/proxy.o GUI_OBJS = obj/gui.o obj/main_gui.o obj/bitflash_res.o @@ -56,7 +56,7 @@ GUI_OBJS = obj/gui.o obj/main_gui.o obj/bitflash_res.o HEADERS = headers_core.h headers.h util.h main.h serialize.h uint256.h \ key.h bignum.h script.h db.h base58.h net.h nostr.h \ randomx_pow.h btfaddr.h btfchan.h btfrv.h btftunnel.h market.h \ - selftest.h walletcmd.h sockcount.h compat.h font_roboto.h + selftest.h walletcmd.h sockcount.h proxy.h compat.h font_roboto.h all: bitflash.exe @@ -117,3 +117,4 @@ tests: test_bip32.exe bitflash.exe ./bitflash.exe -selftest=net-message ./bitflash.exe -selftest=consensus-limits ./bitflash.exe -selftest=pool-stratum + ./bitflash.exe -selftest=socks5-proxy diff --git a/src/net.cpp b/src/net.cpp index b9bb95d..fe9964e 100644 --- a/src/net.cpp +++ b/src/net.cpp @@ -17,6 +17,7 @@ #include #include "btfaddr.h" #include "btftunnel.h" +#include "proxy.h" void ThreadReconnectCachedBtfPeers(void* parg); void ThreadMessageHandler2(void* parg); @@ -361,6 +362,9 @@ string GetDiagnosticsText() bool GetMyExternalIP(unsigned int& ipRet) { + if (BtfSocks5ProxyEnabled()) + return error("GetMyExternalIP() skipped while SOCKS5 proxy is enabled\n"); + // Try several plain-text IP echo services in order. // Each returns just the IPv4 address as the first line of the HTTP body. struct { const char* host; const char* path; } services[] = { diff --git a/src/nostr.cpp b/src/nostr.cpp index 9bc538f..d286767 100644 --- a/src/nostr.cpp +++ b/src/nostr.cpp @@ -29,6 +29,7 @@ #include "btfaddr.h" #include "btfchan.h" +#include "proxy.h" using json = nlohmann::json; @@ -540,53 +541,8 @@ public: size_t colon = host.find(':'); if (colon != string::npos) { port = atoi(host.substr(colon + 1).c_str()); host = host.substr(0, colon); } - // Resolve and connect (TCP). AF_UNSPEC so we get both A and AAAA - // records back and try them in the order the resolver prefers, - // rather than hard-failing whenever the IPv4 path to a relay is - // filtered/rate-limited (increasingly common for CDN-fronted - // relays) while a working IPv6 route exists right there in the - // same getaddrinfo() result. - struct addrinfo hints, *res = NULL, *rp = NULL; - memset(&hints, 0, sizeof(hints)); - hints.ai_family = AF_UNSPEC; - hints.ai_socktype = SOCK_STREAM; - char portstr[16]; sprintf(portstr, "%d", port); - if (getaddrinfo(host.c_str(), portstr, &hints, &res) != 0 || !res) - return error("Nostr: getaddrinfo %s failed", host.c_str()); - - bool fConnected = false; - for (rp = res; rp != NULL; rp = rp->ai_next) - { - hSocket = BtfSocketTag(socket(rp->ai_family, rp->ai_socktype, rp->ai_protocol), SOCK_NOSTR); - if (hSocket == INVALID_SOCKET) - continue; - - // Socket receive/send timeout: Windows takes a DWORD of - // milliseconds; POSIX takes a struct timeval. -#ifdef _WIN32 - DWORD tv = timeoutSec * 1000; - setsockopt(hSocket, SOL_SOCKET, SO_RCVTIMEO, (char*)&tv, sizeof(tv)); - setsockopt(hSocket, SOL_SOCKET, SO_SNDTIMEO, (char*)&tv, sizeof(tv)); -#else - struct timeval tv; - tv.tv_sec = timeoutSec; - tv.tv_usec = 0; - setsockopt(hSocket, SOL_SOCKET, SO_RCVTIMEO, (char*)&tv, sizeof(tv)); - setsockopt(hSocket, SOL_SOCKET, SO_SNDTIMEO, (char*)&tv, sizeof(tv)); -#endif - - if (connect(hSocket, rp->ai_addr, (int)rp->ai_addrlen) == 0) - { - fConnected = true; - break; - } - - BtfCloseSocket(hSocket); - hSocket = INVALID_SOCKET; - } - freeaddrinfo(res); - - if (!fConnected) + hSocket = BtfConnectSocket(host, (unsigned short)port, SOCK_NOSTR, timeoutSec); + if (hSocket == INVALID_SOCKET) return error("Nostr: connect %s:%d failed", host.c_str(), port); // TLS diff --git a/src/proxy.cpp b/src/proxy.cpp new file mode 100644 index 0000000..1e62ff9 --- /dev/null +++ b/src/proxy.cpp @@ -0,0 +1,254 @@ +// Copyright (c) 2026 Bitflash developers +// Distributed under the MIT/X11 software license. + +#include "headers_core.h" +#include "proxy.h" +#include "sockcount.h" + +static CCriticalSection cs_socks5; +static bool g_fSocks5Proxy = false; +static std::string g_socks5Host; +static unsigned short g_socks5Port = 0; + +static void SetSocketTimeout(SOCKET s, int nTimeoutSecs) +{ + if (nTimeoutSecs <= 0) + return; +#ifdef _WIN32 + DWORD tv = (DWORD)nTimeoutSecs * 1000; + setsockopt(s, SOL_SOCKET, SO_RCVTIMEO, (const char*)&tv, sizeof(tv)); + setsockopt(s, SOL_SOCKET, SO_SNDTIMEO, (const char*)&tv, sizeof(tv)); +#else + struct timeval tv; + tv.tv_sec = nTimeoutSecs; + tv.tv_usec = 0; + setsockopt(s, SOL_SOCKET, SO_RCVTIMEO, &tv, sizeof(tv)); + setsockopt(s, SOL_SOCKET, SO_SNDTIMEO, &tv, sizeof(tv)); +#endif +} + +static bool ReadN(SOCKET s, void* buf, int n) +{ + char* p = (char*)buf; + int off = 0; + while (off < n) + { + int r = recv(s, p + off, n - off, 0); + if (r <= 0) + return false; + off += r; + } + return true; +} + +static bool WriteN(SOCKET s, const void* buf, int n) +{ + const char* p = (const char*)buf; + int off = 0; + while (off < n) + { + int r = send(s, p + off, n - off, 0); + if (r <= 0) + return false; + off += r; + } + return true; +} + +static bool ValidProxyHostChar(char c) +{ + return isalnum((unsigned char)c) || c == '.' || c == '-' || c == '_'; +} + +bool BtfParseSocks5Proxy(const std::string& spec, std::string& hostOut, + unsigned short& portOut, std::string& errOut) +{ + hostOut.clear(); + portOut = 0; + errOut.clear(); + + size_t colon = spec.rfind(':'); + if (colon == std::string::npos || colon == 0 || colon + 1 >= spec.size()) + { + errOut = "expected HOST:PORT"; + return false; + } + + std::string host = spec.substr(0, colon); + std::string port = spec.substr(colon + 1); + if (host.size() > 255) + { + errOut = "host is too long"; + return false; + } + for (size_t i = 0; i < host.size(); i++) + { + if (!ValidProxyHostChar(host[i])) + { + errOut = "host contains unsupported characters"; + return false; + } + } + for (size_t i = 0; i < port.size(); i++) + { + if (!isdigit((unsigned char)port[i])) + { + errOut = "port is not numeric"; + return false; + } + } + int nPort = atoi(port.c_str()); + if (nPort <= 0 || nPort > 65535) + { + errOut = "port is out of range"; + return false; + } + + hostOut = host; + portOut = (unsigned short)nPort; + return true; +} + +bool BtfSetSocks5Proxy(const std::string& spec, std::string& errOut) +{ + std::string host; + unsigned short port = 0; + if (!BtfParseSocks5Proxy(spec, host, port, errOut)) + return false; + CRITICAL_BLOCK(cs_socks5) + { + g_socks5Host = host; + g_socks5Port = port; + g_fSocks5Proxy = true; + } + return true; +} + +void BtfClearSocks5Proxy() +{ + CRITICAL_BLOCK(cs_socks5) + { + g_fSocks5Proxy = false; + g_socks5Host.clear(); + g_socks5Port = 0; + } +} + +bool BtfSocks5ProxyEnabled() +{ + CRITICAL_BLOCK(cs_socks5) + return g_fSocks5Proxy; + return false; +} + +std::string BtfSocks5ProxyName() +{ + CRITICAL_BLOCK(cs_socks5) + { + if (!g_fSocks5Proxy) + return ""; + return strprintf("%s:%u", g_socks5Host.c_str(), (unsigned)g_socks5Port); + } + return ""; +} + +static SOCKET ConnectDirect(const std::string& host, unsigned short port, + int nSockSite, int nTimeoutSecs) +{ + struct addrinfo hints, *res = NULL, *rp = NULL; + memset(&hints, 0, sizeof(hints)); + hints.ai_family = AF_UNSPEC; + hints.ai_socktype = SOCK_STREAM; + char portstr[16]; + sprintf(portstr, "%u", (unsigned)port); + if (getaddrinfo(host.c_str(), portstr, &hints, &res) != 0 || !res) + return INVALID_SOCKET; + + SOCKET s = INVALID_SOCKET; + for (rp = res; rp != NULL; rp = rp->ai_next) + { + s = BtfSocketTag(socket(rp->ai_family, rp->ai_socktype, rp->ai_protocol), nSockSite); + if (s == INVALID_SOCKET) + continue; + SetSocketTimeout(s, nTimeoutSecs); + if (connect(s, rp->ai_addr, (int)rp->ai_addrlen) == 0) + break; + BtfCloseSocket(s); + s = INVALID_SOCKET; + } + freeaddrinfo(res); + return s; +} + +static bool Socks5Connect(SOCKET s, const std::string& destHost, + unsigned short destPort) +{ + if (destHost.empty() || destHost.size() > 255) + return false; + + unsigned char hello[3] = { 0x05, 0x01, 0x00 }; // SOCKS5, one method, no auth + if (!WriteN(s, hello, sizeof(hello))) + return false; + unsigned char choice[2] = { 0, 0 }; + if (!ReadN(s, choice, sizeof(choice)) || choice[0] != 0x05 || choice[1] != 0x00) + return false; + + std::vector req; + req.reserve(7 + destHost.size()); + req.push_back(0x05); // version + req.push_back(0x01); // CONNECT + req.push_back(0x00); // reserved + req.push_back(0x03); // domain name; proxy resolves, avoiding local DNS leak + req.push_back((unsigned char)destHost.size()); + req.insert(req.end(), destHost.begin(), destHost.end()); + req.push_back((unsigned char)(destPort >> 8)); + req.push_back((unsigned char)(destPort & 0xff)); + if (!WriteN(s, &req[0], (int)req.size())) + return false; + + unsigned char rep[4] = {0,0,0,0}; + if (!ReadN(s, rep, sizeof(rep)) || rep[0] != 0x05 || rep[1] != 0x00) + return false; + int nAddr = 0; + if (rep[3] == 0x01) nAddr = 4; + else if (rep[3] == 0x04) nAddr = 16; + else if (rep[3] == 0x03) + { + unsigned char len = 0; + if (!ReadN(s, &len, 1)) + return false; + nAddr = len; + } + else + return false; + + std::vector discard(nAddr + 2); + return ReadN(s, &discard[0], (int)discard.size()); +} + +SOCKET BtfConnectSocket(const std::string& destHost, unsigned short destPort, + int nSockSite, int nTimeoutSecs) +{ + std::string proxyHost; + unsigned short proxyPort = 0; + bool fProxy = false; + CRITICAL_BLOCK(cs_socks5) + { + fProxy = g_fSocks5Proxy; + proxyHost = g_socks5Host; + proxyPort = g_socks5Port; + } + + if (!fProxy) + return ConnectDirect(destHost, destPort, nSockSite, nTimeoutSecs); + + SOCKET s = ConnectDirect(proxyHost, proxyPort, nSockSite, nTimeoutSecs); + if (s == INVALID_SOCKET) + return INVALID_SOCKET; + if (!Socks5Connect(s, destHost, destPort)) + { + BtfCloseSocket(s); + return INVALID_SOCKET; + } + return s; +} diff --git a/src/proxy.h b/src/proxy.h new file mode 100644 index 0000000..b5e69c8 --- /dev/null +++ b/src/proxy.h @@ -0,0 +1,31 @@ +// Copyright (c) 2026 Bitflash developers +// Distributed under the MIT/X11 software license. +// +// Optional SOCKS5 outbound proxy support. This is transport plumbing only: +// no consensus, wallet, mining, or .btf identity data depends on it. + +#ifndef BITFLASH_PROXY_H +#define BITFLASH_PROXY_H + +#ifdef _WIN32 +#ifndef WIN32_LEAN_AND_MEAN +#define WIN32_LEAN_AND_MEAN 1 +#endif +#include +#else +#include "compat.h" +#endif + +#include + +bool BtfParseSocks5Proxy(const std::string& spec, std::string& hostOut, + unsigned short& portOut, std::string& errOut); +bool BtfSetSocks5Proxy(const std::string& spec, std::string& errOut); +void BtfClearSocks5Proxy(); +bool BtfSocks5ProxyEnabled(); +std::string BtfSocks5ProxyName(); + +SOCKET BtfConnectSocket(const std::string& destHost, unsigned short destPort, + int nSockSite, int nTimeoutSecs); + +#endif diff --git a/src/readme.txt b/src/readme.txt index b8b89c1..96f32c3 100644 --- a/src/readme.txt +++ b/src/readme.txt @@ -42,6 +42,9 @@ The resulting executable is src/bitflash.exe. Command-line options (in addition to the original ones) ------------------------------------------------------- /port=N listen on P2P port N (default 8433) + /socks=HOST:PORT + route outbound Nostr and .btf rendezvous dials through a + SOCKS5 proxy, e.g. /socks=127.0.0.1:9050 for local Tor /solomine mine without requiring a connected peer (local testing) /operator operator mode (run pool server) /participant=ADDR diff --git a/src/selftest.cpp b/src/selftest.cpp index dcb9192..5b36b9d 100644 --- a/src/selftest.cpp +++ b/src/selftest.cpp @@ -7,6 +7,7 @@ #include "headers_core.h" #include "bip32.h" +#include "proxy.h" #include "selftest.h" #include "walletcmd.h" @@ -1253,6 +1254,43 @@ static int RunParseMoneySelfTest() return nFail == 0 ? 0 : 1; } +static int RunSocks5ProxySelfTest() +{ + printf("socks5-proxy self-test\n"); + int nFail = 0; + std::string host, err; + unsigned short port = 0; + + nFail += Check(BtfParseSocks5Proxy("127.0.0.1:9050", host, port, err) && + host == "127.0.0.1" && port == 9050, + "parses local Tor proxy endpoint") ? 0 : 1; + nFail += Check(BtfParseSocks5Proxy("localhost:9050", host, port, err) && + host == "localhost" && port == 9050, + "parses hostname proxy endpoint") ? 0 : 1; + nFail += Check(!BtfParseSocks5Proxy("localhost", host, port, err), + "rejects missing port") ? 0 : 1; + nFail += Check(!BtfParseSocks5Proxy("localhost:0", host, port, err), + "rejects zero port") ? 0 : 1; + nFail += Check(!BtfParseSocks5Proxy("localhost:70000", host, port, err), + "rejects out-of-range port") ? 0 : 1; + nFail += Check(!BtfParseSocks5Proxy("user:pass@localhost:9050", host, port, err), + "rejects unsupported authenticated proxy syntax") ? 0 : 1; + + BtfClearSocks5Proxy(); + nFail += Check(!BtfSocks5ProxyEnabled(), "starts disabled after clear") ? 0 : 1; + nFail += Check(BtfSetSocks5Proxy("127.0.0.1:9050", err) && + BtfSocks5ProxyEnabled() && + BtfSocks5ProxyName() == "127.0.0.1:9050", + "enables parsed SOCKS5 proxy") ? 0 : 1; + BtfClearSocks5Proxy(); + nFail += Check(!BtfSocks5ProxyEnabled(), "clear disables proxy") ? 0 : 1; + + printf("%s (%d failure%s)\n", nFail == 0 ? "ALL TESTS PASSED" : "TESTS FAILED", + nFail, nFail == 1 ? "" : "s"); + fflush(stdout); + return nFail == 0 ? 0 : 1; +} + int RunSelfTest(const std::string& name) { AttachTerminal(); @@ -1275,8 +1313,10 @@ int RunSelfTest(const std::string& name) return RunPoolStratumSelfTest(); if (name == "parse-money") return RunParseMoneySelfTest(); + if (name == "socks5-proxy") + return RunSocks5ProxySelfTest(); printf("Unknown self-test '%s'\n", name.c_str()); - printf("Known self-tests: wallet-keypool, wallet-hd, wallet-format, wallet-crypto, wallet-encrypt, net-message, consensus-limits, pool-stratum\n"); + printf("Known self-tests: wallet-keypool, wallet-hd, wallet-format, wallet-crypto, wallet-encrypt, net-message, consensus-limits, pool-stratum, parse-money, socks5-proxy\n"); return 1; } -- cgit v1.2.3 From 6092e1e7da199d6c15b9548d5d4cebcf8babfdcf Mon Sep 17 00:00:00 2001 From: Nsable-sh Date: Tue, 11 Aug 2026 16:03:12 -0300 Subject: net: add Tor mode for outbound discovery --- README.md | 9 +++++- docs/tor.md | 84 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/main_gui.cpp | 25 +++++++++++++---- src/proxy.cpp | 50 ++++++++++++++++++++++++++++++++- src/proxy.h | 3 ++ src/readme.txt | 3 ++ src/selftest.cpp | 25 +++++++++++++++++ 7 files changed, 192 insertions(+), 7 deletions(-) create mode 100644 docs/tor.md (limited to 'src/main_gui.cpp') diff --git a/README.md b/README.md index 353da25..66f3041 100644 --- a/README.md +++ b/README.md @@ -36,7 +36,8 @@ Worth being precise, because the difference matters if you are relying on it. **The node still listens on 8433.** Nothing dials by IP any more, but the listener is still there, so anyone who already knows your address and can reach that port may connect directly. If that matters to you, firewall it. -This is unlinkability between peers, not anonymity against a network observer. It is not Tor. +This is unlinkability between peers, not anonymity against a network observer. +For Tor routing, start the node with `-tor`; see [Tor mode](docs/tor.md). --- @@ -311,6 +312,7 @@ Other options worth knowing: -datadir=PATH # wallet and chain data elsewhere -port=N # P2P listen port, default 8433 -socks=HOST:PORT # SOCKS5 for outbound Nostr and .btf rendezvous dials +-tor[=HOST:PORT] # Tor mode; default local Tor SOCKS5 proxy is 127.0.0.1:9050 -debug # verbose log; without it debug.log is nearly silent -help # full list ``` @@ -324,6 +326,11 @@ dials through a local SOCKS5 proxy such as Tor. When it is set, the node skips plain HTTP external-IP probes instead of leaking a direct request outside the proxy. IPv6 proxy endpoints use brackets, for example `-socks=[::1]:9050`. +`-tor` is a privacy shorthand for the usual local Tor SOCKS5 listener at +`127.0.0.1:9050`. It routes outbound Nostr discovery and `.btf` rendezvous +dials through Tor, refuses direct `.onion` resolution, and keeps external-IP +probes disabled. Use `-tor=HOST:PORT` when Tor listens somewhere else. + ### When something looks wrong **Menu > Diagnostics**, and the same report in `debug.log` every ten minutes: diff --git a/docs/tor.md b/docs/tor.md new file mode 100644 index 0000000..087c822 --- /dev/null +++ b/docs/tor.md @@ -0,0 +1,84 @@ +# Tor mode + +Bitflash can route outbound discovery traffic through a local Tor SOCKS5 proxy. +This is transport plumbing only: it does not change consensus, wallet keys, +mining, `.btf` addresses, or the encrypted rendezvous protocol. + +## Client mode + +Start Tor locally, then start Bitflash with: + +```bash +bitflash -tor +``` + +`-tor` is shorthand for: + +```bash +bitflash -socks=127.0.0.1:9050 +``` + +If Tor listens somewhere else: + +```bash +bitflash -tor=127.0.0.1:9150 +bitflash -tor=[::1]:9050 +``` + +When Tor mode is enabled: + +- outbound Nostr relay dials use Tor; +- outbound `.btf` rendezvous relay dials use Tor; +- destination names are sent to Tor as SOCKS5 domain-name CONNECT requests, so + the local DNS resolver does not see them; +- direct `.onion` dials are refused unless a SOCKS5/Tor proxy is enabled; +- plain HTTP external-IP probes are skipped. + +## Onion rendezvous relay + +An operator can expose a Bitflash rendezvous relay as a Tor hidden service. The +node does not create hidden services itself; Tor owns that configuration. + +Example `torrc`: + +```text +HiddenServiceDir /var/lib/tor/bitflash-rendezvous/ +HiddenServicePort 8434 127.0.0.1:8434 +``` + +After restarting Tor, read the generated onion name: + +```bash +sudo cat /var/lib/tor/bitflash-rendezvous/hostname +``` + +Then run the relay on localhost/VPS as usual and announce the onion endpoint from +nodes that use Tor: + +```bash +bitflash -tor -rvrelay=exampleexampleexampleexampleexampleexampleexampleexample.onion:8434 +bitflash -tor -announcerelay=exampleexampleexampleexampleexampleexampleexampleexample.onion:8434 +``` + +For a pool operator that wants the pool descriptor to point at an onion +rendezvous relay, use both: + +```bash +bitflash -operator -tor \ + -rvrelay=exampleexampleexampleexampleexampleexampleexampleexample.onion:8434 \ + -announcerelay=exampleexampleexampleexampleexampleexampleexampleexample.onion:8434 +``` + +## Full-node hidden service + +The legacy P2P listener still binds to port `8433`. You can expose it through +Tor manually: + +```text +HiddenServiceDir /var/lib/tor/bitflash-node/ +HiddenServicePort 8433 127.0.0.1:8433 +``` + +Current automatic peer discovery is `.btf`/rendezvous based, not onion-address +gossip. Treat direct full-node onion access as an operator/admin tool unless a +future release adds first-class onion peer advertisements. diff --git a/src/main_gui.cpp b/src/main_gui.cpp index 90a6050..5194023 100644 --- a/src/main_gui.cpp +++ b/src/main_gui.cpp @@ -138,6 +138,7 @@ static void PrintUsage() printf("Network:\n"); printf(" /port=N (P2P listen port, default 8433)\n"); printf(" /socks=HOST:PORT (SOCKS5 proxy for outbound Nostr and .btf relay dials)\n"); + printf(" /tor[=HOST:PORT] (Tor mode; default SOCKS5 proxy is 127.0.0.1:9050)\n"); printf(" /btfseed=ADDRESS:ENCHEX (extra bootstrap peer, repeatable)\n"); printf("\n"); printf("Wallet:\n"); @@ -285,16 +286,30 @@ static void ParseStartupArguments(int argc, char* argv[]) if (!announceRelay.empty()) strBtfAnnounceRelay = announceRelay; - string socksProxy = argval2(argc, argv, "/socks", "-socks"); - if (!socksProxy.empty()) + bool fTorMode = arg(argc, argv, "/tor") || arg(argc, argv, "-tor"); + if (fTorMode) { + string torProxy = argval2(argc, argv, "/tor", "-tor"); string err; - if (!BtfSetSocks5Proxy(socksProxy, err)) - fprintf(stderr, "Ignoring /socks=%s: %s\n", socksProxy.c_str(), err.c_str()); + if (!BtfEnableTorProxy(torProxy, err)) + fprintf(stderr, "Ignoring /tor=%s: %s\n", torProxy.c_str(), err.c_str()); else - fprintf(stderr, "SOCKS5 proxy enabled for outbound discovery/relay dials: %s\n", + fprintf(stderr, "Tor mode enabled through SOCKS5 proxy %s\n", BtfSocks5ProxyName().c_str()); } + else + { + string socksProxy = argval2(argc, argv, "/socks", "-socks"); + if (!socksProxy.empty()) + { + string err; + if (!BtfSetSocks5Proxy(socksProxy, err)) + fprintf(stderr, "Ignoring /socks=%s: %s\n", socksProxy.c_str(), err.c_str()); + else + fprintf(stderr, "SOCKS5 proxy enabled for outbound discovery/relay dials: %s\n", + BtfSocks5ProxyName().c_str()); + } + } // /btfseed=ADDRESS:ENCHEX -- extra bootstrap peers, repeatable. Useful for // testing the seed path and for private networks that ship no compiled list. diff --git a/src/proxy.cpp b/src/proxy.cpp index d7dc324..7d30911 100644 --- a/src/proxy.cpp +++ b/src/proxy.cpp @@ -9,8 +9,10 @@ static CCriticalSection cs_socks5; static bool g_fSocks5Proxy = false; +static bool g_fTorProxy = false; static std::string g_socks5Host; static unsigned short g_socks5Port = 0; +static const char* DEFAULT_TOR_SOCKS5_PROXY = "127.0.0.1:9050"; static void SetSocketTimeout(SOCKET s, int nTimeoutSecs) { @@ -67,6 +69,24 @@ static bool ValidProxyIpv6HostChar(char c) return isxdigit((unsigned char)c) || c == ':' || c == '.'; } +static std::string LowerProxyHost(std::string host) +{ + if (!host.empty() && host[host.size() - 1] == '.') + host.erase(host.size() - 1); + for (size_t i = 0; i < host.size(); i++) + host[i] = (char)tolower((unsigned char)host[i]); + return host; +} + +bool BtfIsTorOnionHost(const std::string& host) +{ + std::string lower = LowerProxyHost(host); + static const char* suffix = ".onion"; + size_t suffixLen = strlen(suffix); + return lower.size() > suffixLen && + lower.compare(lower.size() - suffixLen, suffixLen, suffix) == 0; +} + static bool ParsePort(const std::string& port, unsigned short& portOut, std::string& errOut) { @@ -176,7 +196,8 @@ bool BtfParseSocks5Proxy(const std::string& spec, std::string& hostOut, return true; } -bool BtfSetSocks5Proxy(const std::string& spec, std::string& errOut) +static bool SetSocks5Proxy(const std::string& spec, bool fTorMode, + std::string& errOut) { std::string host; unsigned short port = 0; @@ -187,15 +208,27 @@ bool BtfSetSocks5Proxy(const std::string& spec, std::string& errOut) g_socks5Host = host; g_socks5Port = port; g_fSocks5Proxy = true; + g_fTorProxy = fTorMode; } return true; } +bool BtfSetSocks5Proxy(const std::string& spec, std::string& errOut) +{ + return SetSocks5Proxy(spec, false, errOut); +} + +bool BtfEnableTorProxy(const std::string& spec, std::string& errOut) +{ + return SetSocks5Proxy(spec.empty() ? DEFAULT_TOR_SOCKS5_PROXY : spec, true, errOut); +} + void BtfClearSocks5Proxy() { CRITICAL_BLOCK(cs_socks5) { g_fSocks5Proxy = false; + g_fTorProxy = false; g_socks5Host.clear(); g_socks5Port = 0; } @@ -208,6 +241,13 @@ bool BtfSocks5ProxyEnabled() return false; } +bool BtfTorProxyEnabled() +{ + CRITICAL_BLOCK(cs_socks5) + return g_fSocks5Proxy && g_fTorProxy; + return false; +} + std::string BtfSocks5ProxyName() { CRITICAL_BLOCK(cs_socks5) @@ -315,7 +355,15 @@ SOCKET BtfConnectSocket(const std::string& destHost, unsigned short destPort, } if (!fProxy) + { + if (BtfIsTorOnionHost(destHost)) + { + error("Refusing direct .onion connection to %s:%u; use /tor or /socks\n", + destHost.c_str(), (unsigned)destPort); + return INVALID_SOCKET; + } return ConnectDirect(destHost, destPort, nSockSite, nTimeoutSecs); + } SOCKET s = ConnectDirect(proxyHost, proxyPort, nSockSite, nTimeoutSecs); if (s == INVALID_SOCKET) diff --git a/src/proxy.h b/src/proxy.h index b5e69c8..6429cf2 100644 --- a/src/proxy.h +++ b/src/proxy.h @@ -21,8 +21,11 @@ bool BtfParseSocks5Proxy(const std::string& spec, std::string& hostOut, unsigned short& portOut, std::string& errOut); bool BtfSetSocks5Proxy(const std::string& spec, std::string& errOut); +bool BtfEnableTorProxy(const std::string& spec, std::string& errOut); void BtfClearSocks5Proxy(); bool BtfSocks5ProxyEnabled(); +bool BtfTorProxyEnabled(); +bool BtfIsTorOnionHost(const std::string& host); std::string BtfSocks5ProxyName(); SOCKET BtfConnectSocket(const std::string& destHost, unsigned short destPort, diff --git a/src/readme.txt b/src/readme.txt index 1fa77df..794fe20 100644 --- a/src/readme.txt +++ b/src/readme.txt @@ -46,6 +46,9 @@ Command-line options (in addition to the original ones) route outbound Nostr and .btf rendezvous dials through a SOCKS5 proxy, e.g. /socks=127.0.0.1:9050 for local Tor or /socks=[::1]:9050 for an IPv6 loopback proxy + /tor[=HOST:PORT] + Tor mode for outbound Nostr and .btf rendezvous dials; + defaults to the local Tor SOCKS5 proxy at 127.0.0.1:9050 /solomine mine without requiring a connected peer (local testing) /operator operator mode (run pool server) /participant=ADDR diff --git a/src/selftest.cpp b/src/selftest.cpp index aa4e528..cca5335 100644 --- a/src/selftest.cpp +++ b/src/selftest.cpp @@ -1474,6 +1474,11 @@ static int RunSocks5ProxySelfTest() "rejects overflowing port") ? 0 : 1; nFail += Check(!BtfParseSocks5Proxy("user:pass@localhost:9050", host, port, err), "rejects unsupported authenticated proxy syntax") ? 0 : 1; + nFail += Check(BtfIsTorOnionHost("abcdefghijklmnopqrstuvwxyzabcdefghijklmnopqrstuvwxyzabcd.onion") && + BtfIsTorOnionHost("Relay.Example.ONION.") && + !BtfIsTorOnionHost("relay.example") && + !BtfIsTorOnionHost(".onion"), + "detects .onion hosts without accepting lookalikes") ? 0 : 1; BtfClearSocks5Proxy(); nFail += Check(!BtfSocks5ProxyEnabled(), "starts disabled after clear") ? 0 : 1; @@ -1490,6 +1495,26 @@ static int RunSocks5ProxySelfTest() "enables bracketed IPv6 proxy endpoint") ? 0 : 1; BtfClearSocks5Proxy(); + nFail += Check(BtfEnableTorProxy("", err) && + BtfSocks5ProxyEnabled() && + BtfTorProxyEnabled() && + BtfSocks5ProxyName() == "127.0.0.1:9050", + "enables Tor mode on the default local SOCKS5 endpoint") ? 0 : 1; + BtfClearSocks5Proxy(); + + nFail += Check(BtfEnableTorProxy("[::1]:9050", err) && + BtfSocks5ProxyEnabled() && + BtfTorProxyEnabled() && + BtfSocks5ProxyName() == "[::1]:9050", + "enables Tor mode on a custom IPv6 endpoint") ? 0 : 1; + BtfClearSocks5Proxy(); + + nFail += Check(BtfSetSocks5Proxy("127.0.0.1:9051", err) && + BtfSocks5ProxyEnabled() && + !BtfTorProxyEnabled(), + "plain SOCKS5 mode is distinct from Tor mode") ? 0 : 1; + BtfClearSocks5Proxy(); + std::string handshakeErr; bool fHandshakeOk = RunSocks5HandshakeProbe(handshakeErr); nFail += Check(fHandshakeOk, handshakeErr.empty() ? -- cgit v1.2.3 From a0a300c240c91fa0c1cd1dc60ff5420a89cc33f8 Mon Sep 17 00:00:00 2001 From: Nsable-sh Date: Tue, 11 Aug 2026 16:12:25 -0300 Subject: net: warn when Tor mode overrides SOCKS --- src/main_gui.cpp | 5 +++++ 1 file changed, 5 insertions(+) (limited to 'src/main_gui.cpp') diff --git a/src/main_gui.cpp b/src/main_gui.cpp index 5194023..bef8503 100644 --- a/src/main_gui.cpp +++ b/src/main_gui.cpp @@ -289,6 +289,11 @@ static void ParseStartupArguments(int argc, char* argv[]) bool fTorMode = arg(argc, argv, "/tor") || arg(argc, argv, "-tor"); if (fTorMode) { + string socksProxy = argval2(argc, argv, "/socks", "-socks"); + if (!socksProxy.empty()) + fprintf(stderr, "Warning: /tor takes precedence over /socks=%s\n", + socksProxy.c_str()); + string torProxy = argval2(argc, argv, "/tor", "-tor"); string err; if (!BtfEnableTorProxy(torProxy, err)) -- cgit v1.2.3 From d2b86473a267c960a1f0c012a983176e5d962187 Mon Sep 17 00:00:00 2001 From: Nsable-sh Date: Thu, 13 Aug 2026 11:11:59 -0300 Subject: net: add signed direct onion peer endpoints --- README.md | 26 ++++++++----- docs/rendezvous.md | 70 +++++++++++++++++++++++++++++------ docs/tor.md | 39 ++++++++++++++++++-- src/btfaddr.cpp | 102 +++++++++++++++++++++++++++++++++++++++++++++++---- src/btfaddr.h | 8 +++- src/main_gui.cpp | 15 +++++++- src/net.cpp | 102 +++++++++++++++++++++++++++++++++++++++++---------- src/net.h | 3 +- src/nostr.cpp | 76 +++++++++++++++++++++++++++++++------- src/nostr.h | 12 +++++- src/readme.txt | 8 ++-- src/selftest.cpp | 9 +++++ src/sockcount.h | 1 + src/test_btfaddr.cpp | 22 +++++++++++ 14 files changed, 420 insertions(+), 73 deletions(-) (limited to 'src/main_gui.cpp') diff --git a/README.md b/README.md index e671302..181b6e8 100644 --- a/README.md +++ b/README.md @@ -22,7 +22,7 @@ Bitflash keeps Satoshi's original consensus rules and replaces two things: **RandomX proof of work.** Memory-hard algorithm used by Monero. A laptop competes equally with a server. ASICs and GPUs have no advantage. -**Anonymous addressing.** Every node has a `.btf` address derived from its public key, similar to a Tor `.onion`. Nodes reach each other through encrypted rendezvous tunnels, so no port forwarding is needed and a node behind CGNAT works normally. +**Anonymous addressing.** Every node has a `.btf` address derived from its public key, similar to a Tor `.onion`. Nodes reach each other through encrypted rendezvous tunnels by default, and can advertise a signed direct `.onion` endpoint for Tor peers. No premine. No ICO. 50 BTF per block, halving on schedule, 21M cap, ~2 minute blocks. @@ -30,14 +30,15 @@ No premine. No ICO. 50 BTF per block, halving on schedule, 21M cap, ~2 minute bl Worth being precise, because the difference matters if you are relying on it. -**A peer you reach over `.btf` does not learn your IP.** All outbound connections go through a rendezvous tunnel; there is no IP-based peer dialling left in the node. The relay forwards encrypted bytes and cannot read or alter them. +**A peer you reach over `.btf` does not learn your IP.** Outbound peer discovery uses rendezvous tunnels by default. If both sides use Tor and the peer advertises a signed `.onion` endpoint, the node connects directly to that hidden service instead. In both cases the peer does not see your clearnet IP. -**The rendezvous relay does see your IP.** It has to — it is the thing your TCP connection terminates on. Relays are run by volunteers, so treat that as a party who knows you are on the network. +**The rendezvous relay sees your IP unless you use Tor.** It has to — it is the thing your TCP connection terminates on. With `-tor`, the relay sees Tor instead. With a signed direct `.onion` peer endpoint, the relay is not on that connection path at all. **The node still listens on 8433.** Nothing dials by IP any more, but the listener is still there, so anyone who already knows your address and can reach that port may connect directly. If that matters to you, firewall it. This is unlinkability between peers, not anonymity against a network observer. -For Tor routing, start the node with `-tor`; see [Tor mode](docs/tor.md). +For Tor routing and direct onion peer endpoints, start the node with `-tor`; +see [Tor mode](docs/tor.md). For a plain SOCKS5 proxy without Tor-specific defaults, use `-socks=HOST:PORT`. @@ -336,8 +337,9 @@ Other options worth knowing: ```bash -datadir=PATH # wallet and chain data elsewhere -port=N # P2P listen port, default 8433 --socks=HOST:PORT # SOCKS5 for outbound Nostr and .btf rendezvous dials +-socks=HOST:PORT # SOCKS5 for outbound Nostr, .btf relay, and onion peer dials -tor[=HOST:PORT] # Tor mode; default local Tor SOCKS5 proxy is 127.0.0.1:9050 +-onionservice=HOST.onion:PORT # advertise this node's Tor hidden service -debug # verbose log; without it debug.log is nearly silent -help # full list ``` @@ -346,15 +348,19 @@ Other options worth knowing: needed — the data directory takes an exclusive lock, so a second node pointed at the same one will refuse to start. -`-socks=127.0.0.1:9050` routes outbound Nostr discovery and `.btf` rendezvous -dials through a local SOCKS5 proxy such as Tor. When it is set, the node skips +`-socks=127.0.0.1:9050` routes outbound Nostr discovery, `.btf` rendezvous +dials, and direct `.onion` peer dials through a local SOCKS5 proxy such as Tor. +When it is set, the node skips plain HTTP external-IP probes instead of leaking a direct request outside the proxy. IPv6 proxy endpoints use brackets, for example `-socks=[::1]:9050`. `-tor` is a privacy shorthand for the usual local Tor SOCKS5 listener at -`127.0.0.1:9050`. It routes outbound Nostr discovery and `.btf` rendezvous -dials through Tor, refuses direct `.onion` resolution, and keeps external-IP -probes disabled. Use `-tor=HOST:PORT` when Tor listens somewhere else. +`127.0.0.1:9050`. It routes outbound Nostr discovery, `.btf` rendezvous dials, +and signed direct `.onion` peer dials through Tor, and keeps external-IP probes +disabled. Use `-tor=HOST:PORT` when Tor listens somewhere else. To make your own +node reachable without a rendezvous relay, expose port `8433` as a Tor hidden +service and pass `-onionservice=HOST.onion:8433`; peers with `-tor` will try it +before falling back to rendezvous. ### When something looks wrong diff --git a/docs/rendezvous.md b/docs/rendezvous.md index ac8e598..e1888b9 100644 --- a/docs/rendezvous.md +++ b/docs/rendezvous.md @@ -18,11 +18,16 @@ A connection to a `.btf` service has three layers: 1. **Discovery (Nostr).** The service publishes a signed descriptor as a Nostr event. A caller fetches it by the service public key and verifies the signature. The descriptor says which rendezvous relay the service is waiting - at and which encryption key to use. + at, which encryption key to use, and optionally which Tor hidden service can + be dialed directly. 2. **Rendezvous (TCP).** Caller and service both connect out to the same relay and are paired by public key. Neither needs an inbound port or a public IP. After pairing the relay is a blind byte pipe. -3. **Encrypted channel (X25519 + XChaCha20-Poly1305).** Over the paired pipe the +3. **Direct onion fallback bypass.** If the descriptor carries a valid signed + `.onion:port` endpoint and the caller is running with Tor mode, the caller + tries that endpoint before using rendezvous. This removes the rendezvous + relay from the data path for peers that choose to run hidden services. +4. **Encrypted channel (X25519 + XChaCha20-Poly1305).** Over the paired pipe the caller and service run an ephemeral to static handshake and then exchange authenticated frames. The relay sees only ciphertext. @@ -107,6 +112,8 @@ The default rendezvous port is `8434`. The descriptor is a JSON object, signed with the identity key over a domain separated digest of its own fields. +Legacy descriptor: + ```json { "v": 2, @@ -118,7 +125,22 @@ separated digest of its own fields. } ``` -The signed digest is: +Onion-capable descriptor: + +```json +{ + "v": 3, + "pubkey": "", + "enc": "", + "meeting_node": "host:port", + "onion": "<56-char-v3-name>.onion:8433", + "created": , + "sig": "", + "sig3": "" +} +``` + +The legacy signed digest is: ``` digest = SHA256( ".btf-descriptor-v2" @@ -128,17 +150,37 @@ digest = SHA256( ".btf-descriptor-v2" || created (8 bytes, big endian) ) ``` -The signature is a deterministic BIP340 Schnorr signature (no auxiliary -randomness) over `digest` by the identity key. +`sig` is kept on v3 descriptors so old nodes can still resolve and dial through +the rendezvous relay. The onion-capable digest is: + +``` +digest3 = SHA256( ".btf-descriptor-v3-onion" + || pubkey[32] + || enc (raw bytes of the string) + || meeting_node (raw bytes of the string) + || onion (raw bytes of the normalized string) + || created (8 bytes, big endian) ) +``` + +`sig` and `sig3` are deterministic BIP340 Schnorr signatures (no auxiliary +randomness) over their respective digests by the identity key. **Verification** (untrusted input, must never throw): parse the JSON, require all fields with the right types, confirm `pubkey` equals the address being resolved, recompute the digest, and verify the Schnorr signature. Any failure rejects the descriptor. +If `onion` is present, readers accept it only when it is a normalized v3 onion +endpoint and `sig3` verifies. A bad or tampered onion field is ignored; the v2 +descriptor can still be used for rendezvous fallback if `sig` verifies. + `created` lets a reader prefer the freshest descriptor when more than one is -seen. A node only publishes a descriptor once it has actually registered at a -`meeting_node`, so it never advertises a relay it cannot be reached at. +seen. A node without a direct onion endpoint only publishes a descriptor once it +has actually registered at a `meeting_node`, so it never advertises a relay it +cannot be reached at. A node with a direct onion endpoint may publish before +rendezvous registration using `meeting_node = "rendezvous-pending"`; Tor peers +can still dial the signed onion endpoint, and rendezvous becomes fallback once +registration succeeds. ## 6. Rendezvous transport @@ -243,8 +285,9 @@ tunnel. not published anywhere. - **Forward secrecy.** The caller's per connection ephemeral key means a later compromise of the service encryption key does not decrypt past sessions. -- **IP privacy.** Neither side needs a public IP or an inbound port. The observable - network fact is that each side has a TCP connection to a relay. +- **IP privacy.** Neither side needs a public IP or an inbound port for + rendezvous. With a signed `.onion` endpoint, the reachable side exposes only a + Tor hidden service and the relay is not in the peer connection path. ## 10. Threat model and limitations @@ -265,6 +308,9 @@ tunnel. unreachable, discovery stalls until one is reachable again. Relay TLS is verified, which closes a man in the middle path on discovery, but availability still rests on the relay set. +- **Direct onion is not automatic Tor configuration.** Bitflash signs and + advertises a hidden-service endpoint, and Tor-mode peers can dial it. The Tor + daemon still owns hidden-service creation, private keys, rotation and uptime. ## 11. Wire reference @@ -273,9 +319,9 @@ tunnel. | `.btf` Base32 alphabet | `abcdefghijklmnopqrstuvwxyz234567`, no padding | | Address payload | `pubkey[32] || checksum[2]`, 55 Base32 chars | | Address checksum | `SHA256(".btf-checksum-v1" || pubkey)[0..2]` | -| Descriptor domain sep | `.btf-descriptor-v2` | -| Descriptor version | `2` | -| Descriptor signature | BIP340 Schnorr, deterministic (no aux rand) | +| Descriptor domain sep | `.btf-descriptor-v2`; optional onion sep `.btf-descriptor-v3-onion` | +| Descriptor version | `2`; `3` when a signed onion endpoint is present | +| Descriptor signature | BIP340 Schnorr, deterministic (no aux rand), v3 keeps `sig` plus `sig3` | | Nostr descriptor event | kind `38501`, `d = btf-descriptor-2` | | Nostr relay event | kind `38502`, `d = btf-relay-2` | | Nostr pool event | kind `38503`, `d = btf-pool-2` | diff --git a/docs/tor.md b/docs/tor.md index 69b29f1..b4f88f9 100644 --- a/docs/tor.md +++ b/docs/tor.md @@ -40,6 +40,8 @@ When Tor mode is enabled: - outbound Nostr relay dials use Tor; - outbound `.btf` rendezvous relay dials use Tor; +- outbound direct peer dials prefer signed `.onion` endpoints when a peer + advertises one; - destination names are sent to Tor as SOCKS5 domain-name CONNECT requests, so the local DNS resolver does not see them; - direct `.onion` dials are refused unless a SOCKS5/Tor proxy is enabled; @@ -98,7 +100,7 @@ bitflash -operator -tor \ -announcerelay=exampleexampleexampleexampleexampleexampleexampleexample.onion:8434 ``` -## Full-node hidden service +## Direct full-node hidden service The legacy P2P listener still binds to port `8433`. You can expose it through Tor manually: @@ -108,6 +110,35 @@ HiddenServiceDir /var/lib/tor/bitflash-node/ HiddenServicePort 8433 127.0.0.1:8433 ``` -Current automatic peer discovery is `.btf`/rendezvous based, not onion-address -gossip. Treat direct full-node onion access as an operator/admin tool unless a -future release adds first-class onion peer advertisements. +After restarting Tor, read the generated onion name: + +```bash +sudo cat /var/lib/tor/bitflash-node/hostname +``` + +Then start Bitflash with Tor outbound enabled and advertise that hidden service: + +```bash +bitflash -tor \ + -onionservice=exampleexampleexampleexampleexampleexampleexampleexample.onion:8433 +``` + +That onion endpoint is signed into this node's self-certifying `.btf` +descriptor. New peers that resolve the descriptor and also run with `-tor` try +the `.onion:8433` P2P connection first. If it fails, they fall back to the +ordinary encrypted rendezvous path. Older nodes ignore the onion field and still +use the rendezvous relay because the descriptor keeps the legacy signature too. + +If the node has `-onionservice` but has not registered at any rendezvous relay +yet, it can still publish an onion-capable descriptor with +`meeting_node = rendezvous-pending`. Tor-capable peers can dial the hidden +service directly from that descriptor. Rendezvous becomes a fallback, not a +precondition for being discoverable. + +This means rendezvous relays stop being the only way to reach a node that has +published a hidden service. They remain useful bootstrap/fallback infrastructure, +but an already-discovered onion-capable peer can be reached directly over Tor. + +The node does not create or rotate Tor hidden services itself. Tor owns the +private key under `HiddenServiceDir`; back it up if you want the onion name to +stay stable. diff --git a/src/btfaddr.cpp b/src/btfaddr.cpp index 6edcc91..ce2ac86 100644 --- a/src/btfaddr.cpp +++ b/src/btfaddr.cpp @@ -19,6 +19,8 @@ #include #include #include +#include +#include using json = nlohmann::json; @@ -166,10 +168,47 @@ static bool HexDecode(const std::string& s, unsigned char* out, size_t n) return true; } +bool NormalizeOnionEndpoint(const std::string& in, std::string& out) +{ + out.clear(); + std::string s = ToLowerTrim(in); + size_t colon = s.rfind(':'); + if (colon == std::string::npos || colon == 0 || colon + 1 >= s.size()) + return false; + + std::string host = s.substr(0, colon); + std::string port = s.substr(colon + 1); + while (!host.empty() && host.back() == '.') host.pop_back(); + static const char* suffix = ".onion"; + const size_t suffixLen = strlen(suffix); + if (host.size() != 56 + suffixLen) + return false; + if (host.compare(host.size() - suffixLen, suffixLen, suffix) != 0) + return false; + for (size_t i = 0; i < 56; i++) + { + char c = host[i]; + if (!((c >= 'a' && c <= 'z') || (c >= '2' && c <= '7'))) + return false; + } + for (size_t i = 0; i < port.size(); i++) + if (!isdigit((unsigned char)port[i])) + return false; + + errno = 0; + char* end = NULL; + long nPort = strtol(port.c_str(), &end, 10); + if (errno == ERANGE || end == NULL || *end != '\0' || nPort <= 0 || nPort > 65535) + return false; + + out = host + ":" + port; + return true; +} + // Digest signed over the descriptor fields (domain-separated). -static void DescriptorDigest(const unsigned char pubkey[32], const std::string& enc, - const std::string& meeting_node, uint64_t created, - unsigned char out[32]) +static void DescriptorDigestV2(const unsigned char pubkey[32], const std::string& enc, + const std::string& meeting_node, uint64_t created, + unsigned char out[32]) { static const char* DS = ".btf-descriptor-v2"; SHA256_CTX ctx; @@ -184,9 +223,27 @@ static void DescriptorDigest(const unsigned char pubkey[32], const std::string& SHA256_Final(out, &ctx); } +static void DescriptorDigestV3(const unsigned char pubkey[32], const std::string& enc, + const std::string& meeting_node, const std::string& onion, + uint64_t created, unsigned char out[32]) +{ + static const char* DS = ".btf-descriptor-v3-onion"; + SHA256_CTX ctx; + SHA256_Init(&ctx); + SHA256_Update(&ctx, (const unsigned char*)DS, strlen(DS)); + SHA256_Update(&ctx, pubkey, 32); + SHA256_Update(&ctx, (const unsigned char*)enc.data(), enc.size()); + SHA256_Update(&ctx, (const unsigned char*)meeting_node.data(), meeting_node.size()); + SHA256_Update(&ctx, (const unsigned char*)onion.data(), onion.size()); + unsigned char be[8]; + for (int i = 0; i < 8; i++) be[i] = (unsigned char)((created >> (8 * (7 - i))) & 0xff); + SHA256_Update(&ctx, be, 8); + SHA256_Final(out, &ctx); +} + std::string SignDescriptor(void* ctxv, const unsigned char seckey[32], const std::string& enc, const std::string& meeting_node, - uint64_t created) + uint64_t created, const std::string& onion) { secp256k1_context* ctx = (secp256k1_context*)ctxv; secp256k1_keypair kp; @@ -197,17 +254,28 @@ std::string SignDescriptor(void* ctxv, const unsigned char seckey[32], secp256k1_xonly_pubkey_serialize(ctx, pubkey, &xpub); unsigned char digest[32]; - DescriptorDigest(pubkey, enc, meeting_node, created, digest); + DescriptorDigestV2(pubkey, enc, meeting_node, created, digest); unsigned char sig[64]; // Deterministic signature (NULL aux_rand), matching Itzal's no-aux-rand signing. if (!secp256k1_schnorrsig_sign32(ctx, sig, digest, &kp, NULL)) return std::string(); json v = json::object(); - v["v"] = 2; + v["v"] = onion.empty() ? 2 : 3; v["pubkey"] = HexEncode(pubkey, 32); v["enc"] = enc; v["meeting_node"] = meeting_node; + if (!onion.empty()) + { + std::string onionNorm; + if (!NormalizeOnionEndpoint(onion, onionNorm)) return std::string(); + unsigned char digest3[32]; + DescriptorDigestV3(pubkey, enc, meeting_node, onionNorm, created, digest3); + unsigned char sig3[64]; + if (!secp256k1_schnorrsig_sign32(ctx, sig3, digest3, &kp, NULL)) return std::string(); + v["onion"] = onionNorm; + v["sig3"] = HexEncode(sig3, 64); + } v["created"] = created; v["sig"] = HexEncode(sig, 64); return v.dump(); @@ -242,15 +310,35 @@ bool VerifyDescriptor(void* ctxv, const std::string& jsonStr, if (!HexDecode(v["sig"].get(), sig, 64)) return false; unsigned char digest[32]; - DescriptorDigest(expect_pubkey, enc, meeting_node, created, digest); + DescriptorDigestV2(expect_pubkey, enc, meeting_node, created, digest); secp256k1_xonly_pubkey xpub; if (!secp256k1_xonly_pubkey_parse(ctx, &xpub, expect_pubkey)) return false; if (!secp256k1_schnorrsig_verify(ctx, sig, digest, 32, &xpub)) return false; + std::string onion; + if (v.contains("onion")) + { + if (v["onion"].is_string() && v.contains("sig3") && v["sig3"].is_string()) + { + std::string onionNorm; + std::string onionRaw = v["onion"].get(); + if (NormalizeOnionEndpoint(onionRaw, onionNorm) && onionNorm == onionRaw) + { + unsigned char sig3[64]; + unsigned char digest3[32]; + DescriptorDigestV3(expect_pubkey, enc, meeting_node, onionNorm, created, digest3); + if (HexDecode(v["sig3"].get(), sig3, 64) && + secp256k1_schnorrsig_verify(ctx, sig3, digest3, 32, &xpub)) + onion = onionNorm; + } + } + } + memcpy(out.pubkey, expect_pubkey, 32); out.enc = enc; out.meeting_node = meeting_node; + out.onion = onion; out.created = created; return true; } diff --git a/src/btfaddr.h b/src/btfaddr.h index 1addf8f..55a3ebd 100644 --- a/src/btfaddr.h +++ b/src/btfaddr.h @@ -52,15 +52,21 @@ struct Descriptor unsigned char pubkey[32]; std::string enc; // service x25519 public key (hex) for the E2E channel std::string meeting_node; // rendezvous node the service is registered at + std::string onion; // optional direct Tor hidden-service endpoint (host.onion:port) uint64_t created; }; +// Normalize and validate a direct Tor hidden-service endpoint. Only v3 onion +// names are accepted: 56 base32 chars plus ".onion", followed by ":PORT". +bool NormalizeOnionEndpoint(const std::string& in, std::string& out); + // Sign a descriptor. `ctx` is a secp256k1_context* (passed as void* to keep this // header free of secp headers); `seckey` is the 32-byte service secret key. // Returns the descriptor JSON as a string, or "" on failure. std::string SignDescriptor(void* ctx, const unsigned char seckey[32], const std::string& enc, const std::string& meeting_node, - uint64_t created); + uint64_t created, + const std::string& onion = std::string()); // Verify a descriptor is validly signed by `expect_pubkey` (the key the resolved // `.btf` address encodes). Returns true and fills `out` on success. diff --git a/src/main_gui.cpp b/src/main_gui.cpp index bef8503..239fb60 100644 --- a/src/main_gui.cpp +++ b/src/main_gui.cpp @@ -1,6 +1,7 @@ // Bitflash entry point -- starts node threads then runs GUI (or headless). #include "headers_core.h" +#include "nostr.h" #include "proxy.h" #include "selftest.h" #include "walletcmd.h" @@ -134,10 +135,11 @@ static void PrintUsage() printf(" /connectbtf=PEER_BTF_ADDRESS\n"); printf(" /rvrelay=HOST:PORT\n"); printf(" /announcerelay=HOST:PORT\n"); + printf(" /onionservice=HOST.onion:PORT (advertise this node's Tor hidden service)\n"); printf("\n"); printf("Network:\n"); printf(" /port=N (P2P listen port, default 8433)\n"); - printf(" /socks=HOST:PORT (SOCKS5 proxy for outbound Nostr and .btf relay dials)\n"); + printf(" /socks=HOST:PORT (SOCKS5 proxy for Nostr, .btf relay, and onion dials)\n"); printf(" /tor[=HOST:PORT] (Tor mode; default SOCKS5 proxy is 127.0.0.1:9050)\n"); printf(" /btfseed=ADDRESS:ENCHEX (extra bootstrap peer, repeatable)\n"); printf("\n"); @@ -286,6 +288,17 @@ static void ParseStartupArguments(int argc, char* argv[]) if (!announceRelay.empty()) strBtfAnnounceRelay = announceRelay; + string onionService = argval2(argc, argv, "/onionservice", "-onionservice"); + if (!onionService.empty()) + { + string err; + if (!BtfSetLocalOnionEndpoint(onionService, err)) + fprintf(stderr, "Ignoring /onionservice=%s: %s\n", onionService.c_str(), err.c_str()); + else + fprintf(stderr, "Direct onion peer endpoint advertised: %s\n", + BtfLocalOnionEndpoint().c_str()); + } + bool fTorMode = arg(argc, argv, "/tor") || arg(argc, argv, "-tor"); if (fTorMode) { diff --git a/src/net.cpp b/src/net.cpp index c24547e..ab17332 100644 --- a/src/net.cpp +++ b/src/net.cpp @@ -17,6 +17,8 @@ #include #endif #include +#include +#include #include "btfaddr.h" #include "btftunnel.h" #include "proxy.h" @@ -170,7 +172,7 @@ static const char* pszSockSite[SOCK_SITES] = { "external-ip probe", "listen socket", "inbound accept", "rendezvous dial", "rendezvous listen", "rendezvous accept", "loopback pair listener", "loopback pair app end", "loopback pair pump end", - "nostr relay", "stratum bridge" + "nostr relay", "direct onion peer", "stratum bridge" }; static string SockAccountingText() @@ -627,6 +629,7 @@ struct CachedBtfPeer { string btfAddr; string meeting; + string onion; string encHex; int64 lastSeen; // The peer's own signed descriptor, as it announced itself. Kept verbatim @@ -685,6 +688,8 @@ void LoadCachedBtfPeers(vector& out) CachedBtfPeer p; p.btfAddr = o["btf"].get(); p.meeting = o["meeting"].get(); + if (o.contains("onion") && o["onion"].is_string()) + btf::NormalizeOnionEndpoint(o["onion"].get(), p.onion); p.encHex = o["enc"].get(); p.lastSeen = o.value("seen", (int64)0); if (o.contains("desc") && o["desc"].is_string()) @@ -708,7 +713,8 @@ static CCriticalSection cs_btfPeerCache; // learned about by exchange must not cost us the ability to pass it on. static void RememberBtfPeer(const string& strBtfAddr, const string& strMeeting, const unsigned char enc_pub[32], - const string& strDesc = string()) + const string& strDesc = string(), + const string& strOnion = string()) { CRITICAL_BLOCK(cs_btfPeerCache) { @@ -716,11 +722,15 @@ static void RememberBtfPeer(const string& strBtfAddr, const string& strMeeting, LoadCachedBtfPeers(peers); string strKeepDesc = strDesc; + string strKeepOnion; + btf::NormalizeOnionEndpoint(strOnion, strKeepOnion); for (size_t i = 0; i < peers.size(); i++) if (peers[i].btfAddr == strBtfAddr) { if (strKeepDesc.empty()) strKeepDesc = peers[i].desc; + if (strKeepOnion.empty()) + strKeepOnion = peers[i].onion; peers.erase(peers.begin() + i); break; } @@ -728,6 +738,7 @@ static void RememberBtfPeer(const string& strBtfAddr, const string& strMeeting, CachedBtfPeer p; p.btfAddr = strBtfAddr; p.meeting = strMeeting; + p.onion = strKeepOnion; p.encHex = BytesToHex(enc_pub, 32); p.lastSeen = GetTime(); p.desc = strKeepDesc; @@ -742,6 +753,8 @@ static void RememberBtfPeer(const string& strBtfAddr, const string& strMeeting, nlohmann::json o; o["btf"] = q.btfAddr; o["meeting"] = q.meeting; + if (!q.onion.empty()) + o["onion"] = q.onion; o["enc"] = q.encHex; o["seen"] = q.lastSeen; if (!q.desc.empty()) @@ -830,7 +843,7 @@ static int TryBtfSeeds() foreach(const string& strRelay, relays) { if (fShutdown) return nConnected; - if (ConnectNodeBtfResolved(s.first, strRelay, enc)) + if (ConnectNodeBtfResolved(s.first, strRelay, "", enc)) { LogPrint("net", "btfseed: reached %s via %s\n", s.first.c_str(), strRelay.c_str()); @@ -868,7 +881,7 @@ void ThreadReconnectCachedBtfPeers(void* parg) if (fShutdown) return; unsigned char enc[32]; if (!HexToBytes(p.encHex, enc, 32)) continue; - if (ConnectNodeBtfResolved(p.btfAddr, p.meeting, enc)) + if (ConnectNodeBtfResolved(p.btfAddr, p.meeting, p.onion, enc)) { nConnected++; if (nConnected >= 8) break; // enough to bootstrap; the rest can wait @@ -959,14 +972,37 @@ int BtfPexAccept(const vector& vDesc) if (!HexToBytes(d.enc, enc, 32)) continue; - RememberBtfPeer(strAddr, d.meeting_node, enc, strDesc); + RememberBtfPeer(strAddr, d.meeting_node, enc, strDesc, d.onion); nKept++; } return nKept; } +static bool SplitHostPort(const string& strHostPort, string& strHost, unsigned short& nPort) +{ + size_t colon = strHostPort.rfind(':'); + if (colon == string::npos || colon == 0 || colon + 1 >= strHostPort.size()) + return false; + string port = strHostPort.substr(colon + 1); + for (size_t i = 0; i < port.size(); i++) + if (!isdigit((unsigned char)port[i])) + return false; + + errno = 0; + char* end = NULL; + long n = strtol(port.c_str(), &end, 10); + if (errno == ERANGE || end == NULL || *end != '\0' || n <= 0 || n > 65535) + return false; + + strHost = strHostPort.substr(0, colon); + nPort = (unsigned short)n; + return true; +} + static CNode* ConnectNodeBtfTail(const string& strBtfAddr, const unsigned char pk[32], - const string& strMeeting, const unsigned char enc_pub[32]) + const string& strMeeting, const string& strOnion, + const unsigned char enc_pub[32], + const string& strDesc = string()) { CAddress addr = BtfMarkerAddr(pk); CNode* pnode = FindNode(addr.ip); @@ -976,16 +1012,42 @@ static CNode* ConnectNodeBtfTail(const string& strBtfAddr, const unsigned char p return pnode; } - BtfChurnNoteDialAttempt(strBtfAddr, strMeeting); - size_t colon = strMeeting.rfind(':'); - if (colon == string::npos) + string strOnionNorm; + if (BtfSocks5ProxyEnabled() && btf::NormalizeOnionEndpoint(strOnion, strOnionNorm)) { - BtfChurnNoteDialResult(strBtfAddr, strMeeting, false); - return NULL; + string strOnionHost; + unsigned short nOnionPort = 0; + if (SplitHostPort(strOnionNorm, strOnionHost, nOnionPort)) + { + BtfChurnNoteDialAttempt(strBtfAddr, strOnionNorm); + SOCKET hOnionSocket = BtfConnectSocket(strOnionHost, nOnionPort, SOCK_ONION_PEER, 20); + if (hOnionSocket != INVALID_SOCKET) + { + if (fDebug) + LogPrint("net", "connected %s via direct onion %s\n", + strBtfAddr.c_str(), strOnionNorm.c_str()); + BtfChurnNoteDialResult(strBtfAddr, strOnionNorm, true); + RememberBtfPeer(strBtfAddr, strMeeting, enc_pub, strDesc, strOnionNorm); + + pnode = new CNode(hOnionSocket, addr, false); + pnode->strBtfAddr = strBtfAddr; + pnode->strBtfMeeting = string("onion ") + strOnionNorm; + pnode->AddRef(); + CRITICAL_BLOCK(cs_vNodes) + vNodes.push_back(pnode); + return pnode; + } + if (fDebug) + LogPrint("net", "ConnectNodeBtf: direct onion to %s at %s failed, falling back to rendezvous\n", + strBtfAddr.c_str(), strOnionNorm.c_str()); + BtfChurnNoteDialResult(strBtfAddr, strOnionNorm, false); + } } - string strHost = strMeeting.substr(0, colon); - int nPort = atoi(strMeeting.substr(colon + 1).c_str()); - if (nPort <= 0 || nPort > 65535) + + BtfChurnNoteDialAttempt(strBtfAddr, strMeeting); + string strHost; + unsigned short nPort = 0; + if (!SplitHostPort(strMeeting, strHost, nPort)) { BtfChurnNoteDialResult(strBtfAddr, strMeeting, false); return NULL; @@ -1005,7 +1067,7 @@ static CNode* ConnectNodeBtfTail(const string& strBtfAddr, const unsigned char p BtfChurnNoteDialResult(strBtfAddr, strMeeting, true); // This one answered -- worth trying first next time we start. - RememberBtfPeer(strBtfAddr, strMeeting, enc_pub); + RememberBtfPeer(strBtfAddr, strMeeting, enc_pub, strDesc, strOnionNorm); // Add node pnode = new CNode(hSocket, addr, false); @@ -1035,9 +1097,10 @@ CNode* ConnectNodeBtf(const string& strBtfAddr) LogPrint("net", "trying %s\n", strBtfAddr.c_str()); string strMeeting; + string strOnion; unsigned char enc_pub[32]; BtfChurnNoteResolveAttempt(); - if (!BtfResolve(strBtfAddr, strMeeting, enc_pub)) + if (!BtfResolve(strBtfAddr, strMeeting, enc_pub, &strOnion)) { BtfChurnNoteResolveResult(false); if (fDebug) @@ -1045,7 +1108,7 @@ CNode* ConnectNodeBtf(const string& strBtfAddr) return NULL; } BtfChurnNoteResolveResult(true); - return ConnectNodeBtfTail(strBtfAddr, pk, strMeeting, enc_pub); + return ConnectNodeBtfTail(strBtfAddr, pk, strMeeting, strOnion, enc_pub); } // Same as ConnectNodeBtf, but skips the Nostr resolve step because the caller @@ -1054,7 +1117,8 @@ CNode* ConnectNodeBtf(const string& strBtfAddr) // address). Makes zero relay connections -- only talks to the peer's // rendezvous meeting node. CNode* ConnectNodeBtfResolved(const string& strBtfAddr, const string& strMeeting, - const unsigned char enc_pub[32]) + const string& strOnion, const unsigned char enc_pub[32], + const string& strDesc) { unsigned char pk[32]; if (!btf::ParseAddress(strBtfAddr, pk)) @@ -1064,7 +1128,7 @@ CNode* ConnectNodeBtfResolved(const string& strBtfAddr, const string& strMeeting } if (BtfLocalAddress() == strBtfAddr) return NULL; // ourselves - return ConnectNodeBtfTail(strBtfAddr, pk, strMeeting, enc_pub); + return ConnectNodeBtfTail(strBtfAddr, pk, strMeeting, strOnion, enc_pub, strDesc); } // Anonymous inbound listener (this node's `.btf` hidden service). Registers at diff --git a/src/net.h b/src/net.h index feecf96..9300169 100644 --- a/src/net.h +++ b/src/net.h @@ -28,7 +28,8 @@ enum bool GetMyExternalIP(unsigned int& ipRet); CNode* ConnectNodeBtf(const string& strBtfAddr); CNode* ConnectNodeBtfResolved(const string& strBtfAddr, const string& strMeeting, - const unsigned char enc_pub[32]); + const string& strOnion, const unsigned char enc_pub[32], + const string& strDesc=string()); void ThreadBtfAccept(void* parg); void ThreadBtfConnect(void* parg); extern string strBtfConnect; diff --git a/src/nostr.cpp b/src/nostr.cpp index d286767..0c1b4b6 100644 --- a/src/nostr.cpp +++ b/src/nostr.cpp @@ -112,6 +112,8 @@ vector vBtfMeetingRelays = { }; string strBtfActiveRelay; static CCriticalSection cs_activeRelay; +static string strBtfOnionEndpoint; +static CCriticalSection cs_onionEndpoint; // Only the rendezvous we actually registered at (BtfSetActiveRelay, called from // ThreadBtfAccept once registration succeeds). Empty until then, on purpose: it @@ -133,6 +135,26 @@ void BtfSetActiveRelay(const string& relay) strBtfActiveRelay = relay; } +bool BtfSetLocalOnionEndpoint(const string& endpoint, string& errOut) +{ + string norm; + if (!btf::NormalizeOnionEndpoint(endpoint, norm)) + { + errOut = "expected v3 onion endpoint HOST.onion:PORT"; + return false; + } + CRITICAL_BLOCK(cs_onionEndpoint) + strBtfOnionEndpoint = norm; + return true; +} + +string BtfLocalOnionEndpoint() +{ + CRITICAL_BLOCK(cs_onionEndpoint) + return strBtfOnionEndpoint; + return ""; +} + // A relay this node advertises on Nostr for others to discover (/announcerelay). string strBtfAnnounceRelay; // Relays learned from other operators' Nostr announcements. @@ -370,10 +392,16 @@ std::string BtfLocalDescriptor() string meeting; CRITICAL_BLOCK(cs_activeRelay) meeting = strBtfActiveRelay; + string onion = BtfLocalOnionEndpoint(); if (meeting.empty()) - return ""; + { + if (onion.empty()) + return ""; + meeting = "rendezvous-pending"; + } return btf::SignDescriptor(g_nostrKey.ctx, g_nostrKey.seckey, - g_nostrKey.EncPubHex(), meeting, (uint64_t)GetTime()); + g_nostrKey.EncPubHex(), meeting, (uint64_t)GetTime(), + onion); } // The shared secp256k1 context, so net.cpp can verify descriptors that arrive @@ -806,17 +834,21 @@ static void PublishDescriptor(CWebSocket& ws, CNostrKey& key) // meeting_node is the rendezvous relay this node's hidden service // (ThreadBtfAccept in net.cpp) is registered at -- where clients dial us. string meeting_node = BtfActiveRelay(); + string onion = BtfLocalOnionEndpoint(); if (meeting_node.empty()) { - // Not registered anywhere yet. Publishing now would advertise a meeting - // node we can't be reached at, and peers would waste dials on it. The - // ThreadNostrSeed loop republishes on every pass, so skipping costs - // nothing but the delay until ThreadBtfAccept registers. - LogPrint("nostr", "Nostr: skipping descriptor publish, no rendezvous registered yet\n"); - return; + if (onion.empty()) + { + // Not registered anywhere yet, and no direct onion endpoint exists. + // Publishing now would advertise a meeting node we can't be reached at. + LogPrint("nostr", "Nostr: skipping descriptor publish, no rendezvous registered yet\n"); + return; + } + meeting_node = "rendezvous-pending"; } string desc = btf::SignDescriptor(key.ctx, key.seckey, key.EncPubHex(), - meeting_node, (uint64_t)GetTime()); + meeting_node, (uint64_t)GetTime(), + onion); if (desc.empty()) return; json tags = json::array({ json::array({ "d", BTF_DESC_DTAG }) }); @@ -1018,7 +1050,7 @@ static void HandlePoolAnnouncement(const json& ev) // Resolve a `.btf` address via a relay: fetch the descriptor published by that // address's key and verify it self-certifies under the decoded pubkey. static bool ResolveDescriptor(CWebSocket& ws, void* ctx, const string& btfAddr, - btf::Descriptor& out) + btf::Descriptor& out, string* rawOut = NULL) { unsigned char pk[32]; if (!btf::ParseAddress(btfAddr, pk)) @@ -1045,7 +1077,11 @@ static bool ResolveDescriptor(CWebSocket& ws, void* ctx, const string& btfAddr, const json& ev = j[2]; if (ev.contains("content") && btf::VerifyDescriptor(ctx, ev["content"].get(), pk, out)) + { + if (rawOut) + *rawOut = ev["content"].get(); return true; + } } else if (t == "EOSE") break; @@ -1057,8 +1093,10 @@ static bool ResolveDescriptor(CWebSocket& ws, void* ctx, const string& btfAddr, // public discovery relays for the owner's self-certified descriptor. Used by // ConnectNodeBtf in net.cpp. bool BtfResolve(const std::string& btfAddr, std::string& meetingHostPort, - unsigned char enc_pub[32]) + unsigned char enc_pub[32], std::string* onionHostPort) { + if (onionHostPort) + onionHostPort->clear(); if (!EnsureNostrKey()) return false; for (int i = 0; i < nNostrRelays && !fShutdown; i++) @@ -1076,10 +1114,15 @@ bool BtfResolve(const std::string& btfAddr, std::string& meetingHostPort, } lease.fOk = true; if (d.meeting_node.empty() || d.meeting_node == "rendezvous-pending") - continue; // descriptor predates the owner's relay config + { + if (!onionHostPort || d.onion.empty()) + continue; // caller needs rendezvous, or descriptor has no direct path + } if (!HexDecode(d.enc, enc_pub, 32)) continue; meetingHostPort = d.meeting_node; + if (onionHostPort) + *onionHostPort = d.onion; return true; } CATCH_PRINT_EXCEPTION("BtfResolve") @@ -1168,12 +1211,15 @@ bool BtfResolveMany(const std::vector& btfAddrs, btf::Descriptor d; if (!btf::VerifyDescriptor(g_nostrKey.ctx, ev["content"].get(), pk, d)) continue; - if (d.meeting_node.empty() || d.meeting_node == "rendezvous-pending") + if ((d.meeting_node.empty() || d.meeting_node == "rendezvous-pending") && + d.onion.empty()) continue; BtfResolvedPeer rp; if (!HexDecode(d.enc, rp.enc_pub, 32)) continue; rp.meetingHostPort = d.meeting_node; + rp.onionHostPort = d.onion; + rp.descriptor = ev["content"].get(); out[ai->second] = rp; pending.erase(ai->second); } @@ -1650,11 +1696,13 @@ static void ConnectDiscoveredBtfPeers() DialCtx* ctx = (DialCtx*)arg; string addr = ctx->addr; string meeting = ctx->rp.meetingHostPort; + string onion = ctx->rp.onionHostPort; + string desc = ctx->rp.descriptor; unsigned char enc_pub[32]; memcpy(enc_pub, ctx->rp.enc_pub, 32); delete ctx; - CNode* pnode = ConnectNodeBtfResolved(addr, meeting, enc_pub); + CNode* pnode = ConnectNodeBtfResolved(addr, meeting, onion, enc_pub, desc); CRITICAL_BLOCK(cs_btfPeerState) { if (pnode) diff --git a/src/nostr.h b/src/nostr.h index b5eb5e3..dc4288c 100644 --- a/src/nostr.h +++ b/src/nostr.h @@ -40,6 +40,13 @@ std::string BtfActiveRelay(); // Record the relay this node just registered its service at (thread-safe). void BtfSetActiveRelay(const std::string& relay); +// Optional Tor hidden-service endpoint for this node's normal P2P listener. +// Set by /onionservice=HOST.onion:PORT. The endpoint is signed into this node's +// .btf descriptor, so peers with -tor can dial it directly before falling back +// to rendezvous relays. +bool BtfSetLocalOnionEndpoint(const std::string& endpoint, std::string& errOut); +std::string BtfLocalOnionEndpoint(); + // If set (via /announcerelay=host:port), this node announces that relay on // Nostr so other nodes discover it automatically -- no manual seed-list edit. extern std::string strBtfAnnounceRelay; @@ -94,12 +101,15 @@ void* BtfSecpContext(); // self-certified descriptor and return its meeting node ("host:port") and // x25519 public key for the end-to-end channel. bool BtfResolve(const std::string& btfAddr, std::string& meetingHostPort, - unsigned char enc_pub[32]); + unsigned char enc_pub[32], + std::string* onionHostPort = NULL); // Result of resolving one .btf address via BtfResolveMany. struct BtfResolvedPeer { std::string meetingHostPort; + std::string onionHostPort; + std::string descriptor; unsigned char enc_pub[32]; }; diff --git a/src/readme.txt b/src/readme.txt index 794fe20..1e9cffc 100644 --- a/src/readme.txt +++ b/src/readme.txt @@ -43,11 +43,11 @@ Command-line options (in addition to the original ones) ------------------------------------------------------- /port=N listen on P2P port N (default 8433) /socks=HOST:PORT - route outbound Nostr and .btf rendezvous dials through a - SOCKS5 proxy, e.g. /socks=127.0.0.1:9050 for local Tor + route outbound Nostr, .btf rendezvous, and direct onion peer + dials through a SOCKS5 proxy, e.g. /socks=127.0.0.1:9050 for local Tor or /socks=[::1]:9050 for an IPv6 loopback proxy /tor[=HOST:PORT] - Tor mode for outbound Nostr and .btf rendezvous dials; + Tor mode for outbound Nostr, .btf rendezvous, and direct onion peer dials; defaults to the local Tor SOCKS5 proxy at 127.0.0.1:9050 /solomine mine without requiring a connected peer (local testing) /operator operator mode (run pool server) @@ -72,6 +72,8 @@ Command-line options (in addition to the original ones) override meeting relay list with one relay /announcerelay=HOST:PORT announce this relay on Nostr for discovery + /onionservice=HOST.onion:PORT + advertise this node's Tor hidden service in its signed .btf descriptor Roadmap diff --git a/src/selftest.cpp b/src/selftest.cpp index bcbf9d3..a0d9edd 100644 --- a/src/selftest.cpp +++ b/src/selftest.cpp @@ -6,6 +6,7 @@ // and against a temporary data directory. #include "headers_core.h" +#include "btfaddr.h" #include "bip32.h" #include "proxy.h" #include "selftest.h" @@ -1507,6 +1508,14 @@ static int RunSocks5ProxySelfTest() !BtfIsTorOnionHost("relay.example") && !BtfIsTorOnionHost(".onion"), "detects .onion hosts without accepting lookalikes") ? 0 : 1; + std::string onion; + nFail += Check(btf::NormalizeOnionEndpoint("ABCDEFGHIJKLMNOPQRSTUVWXYZABCDEFGHIJKLMNOPQRSTUVWXYZABCD.onion.:8433", onion) && + onion == "abcdefghijklmnopqrstuvwxyzabcdefghijklmnopqrstuvwxyzabcd.onion:8433", + "normalizes v3 onion peer endpoint") ? 0 : 1; + nFail += Check(!btf::NormalizeOnionEndpoint("short.onion:8433", onion) && + !btf::NormalizeOnionEndpoint("abcdefghijklmnopqrstuvwxyzabcdefghijklmnopqrstuvwxyzabcd.onion:0", onion) && + !btf::NormalizeOnionEndpoint("abcdefghijklmnopqrstuvwxyzabcdefghijklmnopqrstuvwxyzabc1.onion:8433", onion), + "rejects malformed onion peer endpoints") ? 0 : 1; BtfClearSocks5Proxy(); nFail += Check(!BtfSocks5ProxyEnabled(), "starts disabled after clear") ? 0 : 1; diff --git a/src/sockcount.h b/src/sockcount.h index 5147f55..a63c6be 100644 --- a/src/sockcount.h +++ b/src/sockcount.h @@ -63,6 +63,7 @@ enum SOCK_PAIR_APP, // loopback pair: the app end handed to CNode SOCK_PAIR_PUMP, // loopback pair: the pump end SOCK_NOSTR, // outbound to a Nostr relay + SOCK_ONION_PEER, // direct outbound to a peer's Tor hidden service SOCK_STRATUM_BRIDGE, // local Stratum bridge listener/miner sockets SOCK_SITES }; diff --git a/src/test_btfaddr.cpp b/src/test_btfaddr.cpp index b40dcc1..8de426e 100644 --- a/src/test_btfaddr.cpp +++ b/src/test_btfaddr.cpp @@ -86,6 +86,7 @@ int main() } const std::string ENC_HEX = "aabbccddeeff00112233445566778899aabbccddeeff00112233445566778899"; + const std::string ONION = "abcdefghijklmnopqrstuvwxyzabcdefghijklmnopqrstuvwxyzabcd.onion:8433"; // helper: fresh keypair -> (seckey, xonly pubkey) auto newKey = [&](unsigned char sk[32], unsigned char pk[32]) { @@ -108,6 +109,17 @@ int main() CHECK(!btf::VerifyDescriptor(ctx, d, pk2, bad), "different key rejected"); } + printf("descriptor_signs_optional_onion_endpoint\n"); + { + unsigned char sk[32], pk[32]; newKey(sk, pk); + std::string d = btf::SignDescriptor(ctx, sk, ENC_HEX, "meeting-node-abc", 1000, ONION); + CHECK(!d.empty(), "sign produced onion descriptor"); + btf::Descriptor got; + CHECK(btf::VerifyDescriptor(ctx, d, pk, got), "onion descriptor verifies"); + CHECK(got.meeting_node == "meeting-node-abc" && got.enc == ENC_HEX && + got.created == 1000 && got.onion == ONION, "onion fields match"); + } + printf("descriptor_rejects_tampering\n"); { unsigned char sk[32], pk[32]; newKey(sk, pk); @@ -117,6 +129,16 @@ int main() CHECK(!btf::VerifyDescriptor(ctx, j.dump(), pk, o), "tampered meeting_node rejected"); json j2 = json::parse(d); j2["enc"] = "00000000000000000000000000000000000000000000000000000000deadbeef"; CHECK(!btf::VerifyDescriptor(ctx, j2.dump(), pk, o), "tampered enc rejected"); + + std::string d3 = btf::SignDescriptor(ctx, sk, ENC_HEX, "node-1", 5, ONION); + json j3 = json::parse(d3); + j3["onion"] = "bcdefghijklmnopqrstuvwxyzabcdefghijklmnopqrstuvwxyzabcde.onion:8433"; + CHECK(btf::VerifyDescriptor(ctx, j3.dump(), pk, o) && o.onion.empty(), + "tampered onion ignored rather than trusted"); + json j4 = json::parse(d3); + j4["sig3"] = std::string(128, '0'); + CHECK(btf::VerifyDescriptor(ctx, j4.dump(), pk, o) && o.onion.empty(), + "bad onion signature ignored rather than trusted"); } printf("full_resolve_chain_from_address\n"); -- cgit v1.2.3 From 53cc17d621f55b8904812fae5df90ef089bf5aaf Mon Sep 17 00:00:00 2001 From: Nsable-sh Date: Thu, 13 Aug 2026 11:54:35 -0300 Subject: net: add managed Tor hidden service bootstrap --- README.md | 13 +- docs/rendezvous.md | 8 +- docs/tor.md | 52 +++++- src/Makefile | 5 +- src/main_gui.cpp | 65 ++++--- src/makefile.mingw | 5 +- src/net.cpp | 2 + src/readme.txt | 3 + src/selftest.cpp | 37 +++- src/tor.cpp | 492 +++++++++++++++++++++++++++++++++++++++++++++++++++++ src/tor.h | 22 +++ 11 files changed, 665 insertions(+), 39 deletions(-) create mode 100644 src/tor.cpp create mode 100644 src/tor.h (limited to 'src/main_gui.cpp') diff --git a/README.md b/README.md index 181b6e8..a7ce2b2 100644 --- a/README.md +++ b/README.md @@ -339,6 +339,7 @@ Other options worth knowing: -port=N # P2P listen port, default 8433 -socks=HOST:PORT # SOCKS5 for outbound Nostr, .btf relay, and onion peer dials -tor[=HOST:PORT] # Tor mode; default local Tor SOCKS5 proxy is 127.0.0.1:9050 +-managedtor[=PATH] # start Tor, create a hidden service, advertise its onion -onionservice=HOST.onion:PORT # advertise this node's Tor hidden service -debug # verbose log; without it debug.log is nearly silent -help # full list @@ -357,10 +358,14 @@ proxy. IPv6 proxy endpoints use brackets, for example `-socks=[::1]:9050`. `-tor` is a privacy shorthand for the usual local Tor SOCKS5 listener at `127.0.0.1:9050`. It routes outbound Nostr discovery, `.btf` rendezvous dials, and signed direct `.onion` peer dials through Tor, and keeps external-IP probes -disabled. Use `-tor=HOST:PORT` when Tor listens somewhere else. To make your own -node reachable without a rendezvous relay, expose port `8433` as a Tor hidden -service and pass `-onionservice=HOST.onion:8433`; peers with `-tor` will try it -before falling back to rendezvous. +disabled. Use `-tor=HOST:PORT` when Tor listens somewhere else. + +`-managedtor[=PATH]` starts a Tor process for this node, writes a local `torrc`, +creates a v3 hidden service for port `8433`, routes outbound discovery through +that Tor instance, and signs the generated `.onion:8433` endpoint into the +node's `.btf` descriptor. Without `PATH`, Bitflash looks for `tor/tor.exe`, +`tor.exe`, or `tor` beside the binary and then on `PATH`. The hidden-service key +is stored under the Bitflash data directory in `managed-tor/onion-service/`. ### When something looks wrong diff --git a/docs/rendezvous.md b/docs/rendezvous.md index e1888b9..68b5df3 100644 --- a/docs/rendezvous.md +++ b/docs/rendezvous.md @@ -308,9 +308,11 @@ tunnel. unreachable, discovery stalls until one is reachable again. Relay TLS is verified, which closes a man in the middle path on discovery, but availability still rests on the relay set. -- **Direct onion is not automatic Tor configuration.** Bitflash signs and - advertises a hidden-service endpoint, and Tor-mode peers can dial it. The Tor - daemon still owns hidden-service creation, private keys, rotation and uptime. +- **Direct onion needs Tor.** With `-managedtor`, Bitflash starts a Tor process, + writes a local hidden-service config, and advertises the generated onion + endpoint. With `-onionservice`, an operator can still run Tor manually and + provide the endpoint. In both cases Tor owns the hidden-service private key and + uptime. ## 11. Wire reference diff --git a/docs/tor.md b/docs/tor.md index b4f88f9..1c0f589 100644 --- a/docs/tor.md +++ b/docs/tor.md @@ -36,6 +36,47 @@ If both `-tor` and `-socks` are present, Tor mode wins and the node prints a warning. That keeps a command line with `-tor` from silently becoming a custom proxy route. +## Managed Tor mode + +`-managedtor` starts a Tor process for this node, writes a local `torrc`, creates +a v3 hidden service for the normal Bitflash P2P listener, routes outbound +discovery through that Tor instance, and signs the generated `.onion:8433` +endpoint into the node's `.btf` descriptor. + +```bash +bitflash -managedtor +``` + +Without a value, Bitflash looks for Tor in this order: + +1. `tor/tor.exe` or `tor/tor` beside the Bitflash binary; +2. `tor.exe` or `tor` beside the Bitflash binary; +3. `tor.exe` or `tor` on `PATH`. + +To point at a specific Tor executable: + +```bash +bitflash -managedtor=/opt/tor/bin/tor +bitflash -managedtor=C:\Tor Browser\Browser\TorBrowser\Tor\tor.exe +``` + +Managed Tor writes its state under the Bitflash data directory: + +```text +managed-tor/ + torrc + data/ + onion-service/ +``` + +The private key for the onion address lives in `managed-tor/onion-service/`. +Back up that directory if you want the same onion address after moving the node. +Delete it only if you intentionally want a fresh onion endpoint. + +`-managedtor` takes precedence over `-tor` and `-socks`, because it must choose +the local SOCKS port it starts. Diagnostics report the current managed Tor +state, including the SOCKS port and onion endpoint once Tor has generated it. + When Tor mode is enabled: - outbound Nostr relay dials use Tor; @@ -100,10 +141,10 @@ bitflash -operator -tor \ -announcerelay=exampleexampleexampleexampleexampleexampleexampleexample.onion:8434 ``` -## Direct full-node hidden service +## Manual direct full-node hidden service -The legacy P2P listener still binds to port `8433`. You can expose it through -Tor manually: +Managed Tor is the preferred path for ordinary nodes. Operators can still expose +the P2P listener through a separately managed Tor service: ```text HiddenServiceDir /var/lib/tor/bitflash-node/ @@ -139,6 +180,5 @@ This means rendezvous relays stop being the only way to reach a node that has published a hidden service. They remain useful bootstrap/fallback infrastructure, but an already-discovered onion-capable peer can be reached directly over Tor. -The node does not create or rotate Tor hidden services itself. Tor owns the -private key under `HiddenServiceDir`; back it up if you want the onion name to -stay stable. +Tor owns the private key under `HiddenServiceDir`; back it up if you want the +onion name to stay stable. diff --git a/src/Makefile b/src/Makefile index 511fe79..19ad33a 100644 --- a/src/Makefile +++ b/src/Makefile @@ -53,7 +53,7 @@ LIBS = -L$(SECP_LIB) -lsecp256k1 \ HEADERS = headers_core.h headers.h util.h main.h serialize.h uint256.h \ key.h bignum.h script.h db.h base58.h net.h nostr.h \ randomx_pow.h btfaddr.h btfchan.h btfrv.h btftunnel.h market.h \ - selftest.h walletcmd.h compat.h sockcount.h proxy.h font_roboto.h + selftest.h walletcmd.h compat.h sockcount.h proxy.h tor.h font_roboto.h IMGUI_OBJS = obj/imgui.o obj/imgui_draw.o obj/imgui_tables.o \ obj/imgui_widgets.o obj/imgui_impl_glfw.o obj/imgui_impl_opengl3.o @@ -61,7 +61,7 @@ IMGUI_OBJS = obj/imgui.o obj/imgui_draw.o obj/imgui_tables.o \ NODE_OBJS = obj/util.o obj/script.o obj/db.o obj/net.o obj/main.o \ obj/market.o obj/sha.o obj/nostr.o obj/randomx_pow.o \ obj/btfaddr.o obj/btfchan.o obj/btfrv.o obj/btftunnel.o obj/rpc.o \ - obj/selftest.o obj/bip32.o obj/walletcmd.o obj/proxy.o + obj/selftest.o obj/bip32.o obj/walletcmd.o obj/proxy.o obj/tor.o GUI_OBJS = obj/gui.o obj/main_gui.o @@ -194,5 +194,6 @@ tests: test_bip32 bitflash-node ./bitflash-node -selftest=pool-stratum ./bitflash-node -selftest=parse-money ./bitflash-node -selftest=socks5-proxy + ./bitflash-node -selftest=managed-tor .PHONY: tests fuzz-net-message-smoke fuzz-script-smoke diff --git a/src/main_gui.cpp b/src/main_gui.cpp index 239fb60..deed471 100644 --- a/src/main_gui.cpp +++ b/src/main_gui.cpp @@ -4,6 +4,7 @@ #include "nostr.h" #include "proxy.h" #include "selftest.h" +#include "tor.h" #include "walletcmd.h" #include // hardware_concurrency, to sanity-check /genproclimit #ifndef _WIN32 @@ -113,7 +114,7 @@ static void PrintUsage() printf(" /gen\n"); printf(" /nogui or /daemon\n"); printf(" /selftest=wallet-keypool, wallet-hd, wallet-format, wallet-crypto, wallet-encrypt,\n"); - printf(" net-message, consensus-limits, pool-stratum, parse-money, or socks5-proxy\n"); + printf(" net-message, consensus-limits, pool-stratum, parse-money, socks5-proxy, or managed-tor\n"); printf("\n"); printf("Mining mode:\n"); printf(" /operator\n"); @@ -141,6 +142,7 @@ static void PrintUsage() printf(" /port=N (P2P listen port, default 8433)\n"); printf(" /socks=HOST:PORT (SOCKS5 proxy for Nostr, .btf relay, and onion dials)\n"); printf(" /tor[=HOST:PORT] (Tor mode; default SOCKS5 proxy is 127.0.0.1:9050)\n"); + printf(" /managedtor[=PATH] (start Tor, create a hidden service, advertise its onion)\n"); printf(" /btfseed=ADDRESS:ENCHEX (extra bootstrap peer, repeatable)\n"); printf("\n"); printf("Wallet:\n"); @@ -284,6 +286,24 @@ static void ParseStartupArguments(int argc, char* argv[]) vBtfMeetingRelays.push_back(rvRelay); } + // net.cpp has described nListenPort as "tunable via /port" since it was + // written, but nothing ever read the option, so the port was fixed at 8433 + // and a second node could not start on a machine already running one. Parse + // it before Tor setup so a managed hidden service maps the real listener. + string strPort = argval2(argc, argv, "/port", "-port"); + if (!strPort.empty()) + { + int nPort = atoi(strPort.c_str()); + if (nPort <= 0 || nPort > 65535) + fprintf(stderr, "Ignoring /port=%s: not a port number\n", strPort.c_str()); + else + { + nListenPort = htons((unsigned short)nPort); + // Or we would announce a port we never bound. + addrLocalHost.port = nListenPort; + } + } + string announceRelay = argval2(argc, argv, "/announcerelay", "-announcerelay"); if (!announceRelay.empty()) strBtfAnnounceRelay = announceRelay; @@ -299,10 +319,23 @@ static void ParseStartupArguments(int argc, char* argv[]) BtfLocalOnionEndpoint().c_str()); } + bool fManagedTor = arg(argc, argv, "/managedtor") || arg(argc, argv, "-managedtor"); bool fTorMode = arg(argc, argv, "/tor") || arg(argc, argv, "-tor"); - if (fTorMode) + string socksProxy = argval2(argc, argv, "/socks", "-socks"); + if (fManagedTor) + { + if (fTorMode || !socksProxy.empty()) + fprintf(stderr, "Warning: /managedtor takes precedence over /tor and /socks\n"); + + string torPath = argval2(argc, argv, "/managedtor", "-managedtor"); + string err; + if (!BtfStartManagedTor(torPath, err)) + fprintf(stderr, "Ignoring /managedtor=%s: %s\n", torPath.c_str(), err.c_str()); + else + fprintf(stderr, "Managed Tor enabled: %s\n", BtfManagedTorStatus().c_str()); + } + else if (fTorMode) { - string socksProxy = argval2(argc, argv, "/socks", "-socks"); if (!socksProxy.empty()) fprintf(stderr, "Warning: /tor takes precedence over /socks=%s\n", socksProxy.c_str()); @@ -317,7 +350,6 @@ static void ParseStartupArguments(int argc, char* argv[]) } else { - string socksProxy = argval2(argc, argv, "/socks", "-socks"); if (!socksProxy.empty()) { string err; @@ -348,22 +380,6 @@ static void ParseStartupArguments(int argc, char* argv[]) vBtfExtraSeeds.push_back(make_pair(val.substr(0, colon), val.substr(colon + 1))); } - // net.cpp has described nListenPort as "tunable via /port" since it was - // written, but nothing ever read the option, so the port was fixed at 8433 - // and a second node could not start on a machine already running one. - string strPort = argval2(argc, argv, "/port", "-port"); - if (!strPort.empty()) - { - int nPort = atoi(strPort.c_str()); - if (nPort <= 0 || nPort > 65535) - fprintf(stderr, "Ignoring /port=%s: not a port number\n", strPort.c_str()); - else - { - nListenPort = htons((unsigned short)nPort); - // Or we would announce a port we never bound. - addrLocalHost.port = nListenPort; - } - } } // Report a startup failure somewhere the user will actually see it. @@ -715,7 +731,12 @@ int main(int argc, char* argv[]) ReacceptWalletTransactions(); - if (!StartNode(strErrors)) { fprintf(stderr,"StartNode: %s\n",strErrors.c_str()); return 1; } + if (!StartNode(strErrors)) + { + fprintf(stderr,"StartNode: %s\n",strErrors.c_str()); + BtfStopManagedTor(); + return 1; + } if (nMineMode == MINE_OPERATOR) { gPoolServerRunning = true; @@ -748,6 +769,7 @@ int main(int argc, char* argv[]) printf("Running headless. Ctrl-C to stop.\n"); while (!fShutdown) Sleep(500); StopNode(); + BtfStopManagedTor(); // Checkpoints, releases wallet.dat from this directory's Berkeley DB // environment, and closes it. Declared since 2009 and never once // called here, which is why a wallet.dat copied elsewhere would not @@ -767,6 +789,7 @@ int main(int argc, char* argv[]) int ret = RunGUI(argc, argv); fShutdown = true; StopNode(); + BtfStopManagedTor(); DBFlush(true); return ret; #endif diff --git a/src/makefile.mingw b/src/makefile.mingw index f82247a..333be96 100644 --- a/src/makefile.mingw +++ b/src/makefile.mingw @@ -37,7 +37,7 @@ IMGUI_OBJS = obj/imgui.o obj/imgui_draw.o obj/imgui_tables.o \ NODE_OBJS = obj/util.o obj/script.o obj/db.o obj/net.o obj/main.o \ obj/market.o obj/sha.o obj/nostr.o obj/randomx_pow.o \ obj/btfaddr.o obj/btfchan.o obj/btfrv.o obj/btftunnel.o obj/rpc.o \ - obj/selftest.o obj/bip32.o obj/walletcmd.o obj/proxy.o + obj/selftest.o obj/bip32.o obj/walletcmd.o obj/proxy.o obj/tor.o GUI_OBJS = obj/gui.o obj/main_gui.o obj/bitflash_res.o @@ -51,7 +51,7 @@ GUI_OBJS = obj/gui.o obj/main_gui.o obj/bitflash_res.o HEADERS = headers_core.h headers.h util.h main.h serialize.h uint256.h \ key.h bignum.h script.h db.h base58.h net.h nostr.h \ randomx_pow.h btfaddr.h btfchan.h btfrv.h btftunnel.h market.h \ - selftest.h walletcmd.h sockcount.h proxy.h compat.h font_roboto.h + selftest.h walletcmd.h sockcount.h proxy.h tor.h compat.h font_roboto.h all: bitflash.exe @@ -113,3 +113,4 @@ tests: test_bip32.exe bitflash.exe ./bitflash.exe -selftest=consensus-limits ./bitflash.exe -selftest=pool-stratum ./bitflash.exe -selftest=socks5-proxy + ./bitflash.exe -selftest=managed-tor diff --git a/src/net.cpp b/src/net.cpp index ab17332..6bc3c1c 100644 --- a/src/net.cpp +++ b/src/net.cpp @@ -22,6 +22,7 @@ #include "btfaddr.h" #include "btftunnel.h" #include "proxy.h" +#include "tor.h" void ThreadReconnectCachedBtfPeers(void* parg); void ThreadMessageHandler2(void* parg); @@ -296,6 +297,7 @@ string GetDiagnosticsText() str += strprintf(" peers watched %d of %d held by poll%s\n", nPeersWatched, nHeld, nHeld > nPeersWatched ? " <-- NOT ALL PEERS ARE BEING READ" : ""); + str += strprintf(" managed Tor %s\n", BtfManagedTorStatus().c_str()); if (nBlocksReceived > 0) str += strprintf(" blocks received %lld (%lld arrived without a parent, %.1f%%)\n", diff --git a/src/readme.txt b/src/readme.txt index 1e9cffc..9ddbc24 100644 --- a/src/readme.txt +++ b/src/readme.txt @@ -49,6 +49,9 @@ Command-line options (in addition to the original ones) /tor[=HOST:PORT] Tor mode for outbound Nostr, .btf rendezvous, and direct onion peer dials; defaults to the local Tor SOCKS5 proxy at 127.0.0.1:9050 + /managedtor[=PATH] + start Tor, create a v3 hidden service for this node, route outbound + discovery through it, and advertise the generated onion endpoint /solomine mine without requiring a connected peer (local testing) /operator operator mode (run pool server) /participant=ADDR diff --git a/src/selftest.cpp b/src/selftest.cpp index a0d9edd..4cb872e 100644 --- a/src/selftest.cpp +++ b/src/selftest.cpp @@ -11,6 +11,7 @@ #include "proxy.h" #include "selftest.h" #include "sockcount.h" +#include "tor.h" #include "walletcmd.h" extern int RunPoolStratumSelfTest(); @@ -1565,6 +1566,38 @@ static int RunSocks5ProxySelfTest() return nFail == 0 ? 0 : 1; } +static int RunManagedTorSelfTest() +{ + printf("managed-tor self-test\n"); + int nFail = 0; + + std::string torrc = BtfBuildManagedTorrcForTest("C:/Bitflash Managed Tor/data", + "C:/Bitflash Managed Tor/onion-service", + 19050, 8433); + nFail += Check(torrc.find("DataDirectory \"C:/Bitflash Managed Tor/data\"") != std::string::npos, + "quotes the Tor data directory") ? 0 : 1; + nFail += Check(torrc.find("SocksPort 127.0.0.1:19050") != std::string::npos, + "binds SOCKS5 to localhost only") ? 0 : 1; + nFail += Check(torrc.find("IsolateSOCKSAuth") != std::string::npos && + torrc.find("IsolateClientAddr") != std::string::npos && + torrc.find("IsolateDestAddr") != std::string::npos && + torrc.find("IsolateDestPort") != std::string::npos, + "enables strict Tor stream isolation") ? 0 : 1; + nFail += Check(torrc.find("HiddenServiceDir \"C:/Bitflash Managed Tor/onion-service\"") != std::string::npos, + "writes a hidden service directory") ? 0 : 1; + nFail += Check(torrc.find("HiddenServiceVersion 3") != std::string::npos, + "requests a v3 onion service") ? 0 : 1; + nFail += Check(torrc.find("HiddenServicePort 8433 127.0.0.1:8433") != std::string::npos, + "maps the onion service to the Bitflash P2P listener") ? 0 : 1; + nFail += Check(BtfManagedTorStatus() == "disabled", + "managed Tor starts disabled") ? 0 : 1; + + printf("%s (%d failure%s)\n", nFail == 0 ? "ALL TESTS PASSED" : "TESTS FAILED", + nFail, nFail == 1 ? "" : "s"); + fflush(stdout); + return nFail == 0 ? 0 : 1; +} + int RunSelfTest(const std::string& name) { AttachTerminal(); @@ -1589,8 +1622,10 @@ int RunSelfTest(const std::string& name) return RunParseMoneySelfTest(); if (name == "socks5-proxy") return RunSocks5ProxySelfTest(); + if (name == "managed-tor") + return RunManagedTorSelfTest(); printf("Unknown self-test '%s'\n", name.c_str()); - printf("Known self-tests: wallet-keypool, wallet-hd, wallet-format, wallet-crypto, wallet-encrypt, net-message, consensus-limits, pool-stratum, parse-money, socks5-proxy\n"); + printf("Known self-tests: wallet-keypool, wallet-hd, wallet-format, wallet-crypto, wallet-encrypt, net-message, consensus-limits, pool-stratum, parse-money, socks5-proxy, managed-tor\n"); return 1; } diff --git a/src/tor.cpp b/src/tor.cpp new file mode 100644 index 0000000..30a6f0b --- /dev/null +++ b/src/tor.cpp @@ -0,0 +1,492 @@ +// Copyright (c) 2026 Bitflash developers +// Distributed under the MIT/X11 software license. + +#include "headers_core.h" +#include "tor.h" +#include "nostr.h" +#include "proxy.h" + +#include + +#ifndef _WIN32 +#include +#include +#endif + +static CCriticalSection cs_managedTor; +static bool g_fManagedTor = false; +static bool g_fManagedTorStopping = false; +static unsigned short g_managedTorSocksPort = 0; +static unsigned short g_managedTorP2PPort = 0; +static std::string g_managedTorPath; +static std::string g_managedTorDataDir; +static std::string g_managedTorHiddenServiceDir; +static std::string g_managedTorOnion; +static std::string g_managedTorStatus; +#ifdef _WIN32 +static PROCESS_INFORMATION g_managedTorProcess; +#else +static pid_t g_managedTorPid = -1; +#endif + +static std::string PathJoin(const std::string& a, const std::string& b) +{ + if (a.empty()) + return b; + char last = a[a.size() - 1]; + if (last == '/' || last == '\\') + return a + b; + return a + "/" + b; +} + +static std::string NormalizeTorrcPath(std::string path) +{ + for (size_t i = 0; i < path.size(); i++) + if (path[i] == '\\') + path[i] = '/'; + return path; +} + +static std::string QuoteTorrcPath(const std::string& path) +{ + std::string out = "\""; + for (size_t i = 0; i < path.size(); i++) + { + if (path[i] == '"' || path[i] == '\\') + out += '\\'; + out += path[i]; + } + out += "\""; + return out; +} + +static std::string QuoteCommandArg(const std::string& arg) +{ + std::string out = "\""; + for (size_t i = 0; i < arg.size(); i++) + { + if (arg[i] == '"') + out += '\\'; + out += arg[i]; + } + out += "\""; + return out; +} + +static bool EnsureDir(const std::string& path, std::string& errOut) +{ + if (path.empty()) + { + errOut = "directory path is empty"; + return false; + } +#ifdef _WIN32 + DWORD attr = GetFileAttributesA(path.c_str()); + if (attr != INVALID_FILE_ATTRIBUTES) + { + if (attr & FILE_ATTRIBUTE_DIRECTORY) + return true; + errOut = strprintf("%s exists and is not a directory", path.c_str()); + return false; + } + if (CreateDirectoryA(path.c_str(), NULL)) + return true; + DWORD err = GetLastError(); + if (err == ERROR_ALREADY_EXISTS) + return true; + errOut = strprintf("could not create %s (error %lu)", path.c_str(), (unsigned long)err); + return false; +#else + struct stat st; + if (stat(path.c_str(), &st) == 0) + { + if (S_ISDIR(st.st_mode)) + return true; + errOut = strprintf("%s exists and is not a directory", path.c_str()); + return false; + } + if (mkdir(path.c_str(), 0700) == 0 || errno == EEXIST) + return true; + errOut = strprintf("could not create %s: %s", path.c_str(), strerror(errno)); + return false; +#endif +} + +static bool WriteTextFile(const std::string& path, const std::string& data, + std::string& errOut) +{ + FILE* f = fopen(path.c_str(), "wb"); + if (!f) + { + errOut = strprintf("could not write %s", path.c_str()); + return false; + } + bool ok = fwrite(data.data(), 1, data.size(), f) == data.size(); + if (fclose(f) != 0) + ok = false; + if (!ok) + { + errOut = strprintf("could not finish writing %s", path.c_str()); + return false; + } + return true; +} + +static bool ReadOneLine(const std::string& path, std::string& out) +{ + out.clear(); + FILE* f = fopen(path.c_str(), "rb"); + if (!f) + return false; + char buf[256] = {0}; + if (!fgets(buf, sizeof(buf), f)) + { + fclose(f); + return false; + } + fclose(f); + out = buf; + while (!out.empty() && (out[out.size() - 1] == '\n' || out[out.size() - 1] == '\r' || + out[out.size() - 1] == ' ' || out[out.size() - 1] == '\t')) + out.erase(out.size() - 1); + return !out.empty(); +} + +static bool PickLocalPort(unsigned short& portOut, std::string& errOut) +{ + portOut = 0; + SOCKET s = socket(AF_INET, SOCK_STREAM, IPPROTO_TCP); + if (s == INVALID_SOCKET) + { + errOut = "could not create local port probe socket"; + return false; + } + struct sockaddr_in addr; + memset(&addr, 0, sizeof(addr)); + addr.sin_family = AF_INET; + addr.sin_addr.s_addr = htonl(INADDR_LOOPBACK); + addr.sin_port = 0; + if (bind(s, (struct sockaddr*)&addr, sizeof(addr)) != 0) + { + closesocket(s); + errOut = "could not bind local port probe socket"; + return false; + } + socklen_t len = sizeof(addr); + if (getsockname(s, (struct sockaddr*)&addr, &len) != 0) + { + closesocket(s); + errOut = "could not read local port probe socket"; + return false; + } + portOut = ntohs(addr.sin_port); + closesocket(s); + if (portOut == 0) + { + errOut = "local port probe returned port zero"; + return false; + } + return true; +} + +static std::string ExecutableDir() +{ +#ifdef _WIN32 + char path[MAX_PATH + 1] = {0}; + DWORD n = GetModuleFileNameA(NULL, path, MAX_PATH); + if (n == 0 || n >= MAX_PATH) + return "."; + std::string s = path; + size_t slash = s.find_last_of("\\/"); + if (slash == std::string::npos) + return "."; + return s.substr(0, slash); +#else + char path[4096] = {0}; + ssize_t n = readlink("/proc/self/exe", path, sizeof(path) - 1); + if (n <= 0) + return "."; + path[n] = 0; + std::string s = path; + size_t slash = s.find_last_of('/'); + if (slash == std::string::npos) + return "."; + return s.substr(0, slash); +#endif +} + +static bool FileIsExecutableCandidate(const std::string& path) +{ +#ifdef _WIN32 + DWORD attr = GetFileAttributesA(path.c_str()); + return attr != INVALID_FILE_ATTRIBUTES && !(attr & FILE_ATTRIBUTE_DIRECTORY); +#else + return access(path.c_str(), X_OK) == 0; +#endif +} + +static std::string ResolveTorPath(const std::string& torPathOpt) +{ + if (!torPathOpt.empty()) + return torPathOpt; + + std::vector candidates; + std::string exeDir = ExecutableDir(); +#ifdef _WIN32 + candidates.push_back(PathJoin(exeDir, "tor/tor.exe")); + candidates.push_back(PathJoin(exeDir, "tor.exe")); + candidates.push_back("tor.exe"); +#else + candidates.push_back(PathJoin(exeDir, "tor/tor")); + candidates.push_back(PathJoin(exeDir, "tor")); + candidates.push_back("tor"); +#endif + for (size_t i = 0; i < candidates.size(); i++) + { + if (candidates[i].find('/') == std::string::npos && + candidates[i].find('\\') == std::string::npos) + return candidates[i]; // Let the OS search PATH. + if (FileIsExecutableCandidate(candidates[i])) + return candidates[i]; + } +#ifdef _WIN32 + return "tor.exe"; +#else + return "tor"; +#endif +} + +std::string BtfBuildManagedTorrcForTest(const std::string& dataDir, + const std::string& hiddenServiceDir, + unsigned short socksPort, + unsigned short p2pPort) +{ + std::string torData = NormalizeTorrcPath(dataDir); + std::string hsDir = NormalizeTorrcPath(hiddenServiceDir); + std::string logFile = NormalizeTorrcPath(PathJoin(dataDir, "tor.log")); + + std::string s; + s += "# Generated by Bitflash. Edit only while Bitflash is not managing Tor.\n"; + s += "DataDirectory " + QuoteTorrcPath(torData) + "\n"; + s += strprintf("SocksPort 127.0.0.1:%u IsolateSOCKSAuth IsolateClientAddr IsolateDestAddr IsolateDestPort\n", + (unsigned)socksPort); + s += "HiddenServiceDir " + QuoteTorrcPath(hsDir) + "\n"; + s += "HiddenServiceVersion 3\n"; + s += strprintf("HiddenServicePort %u 127.0.0.1:%u\n", + (unsigned)p2pPort, (unsigned)p2pPort); + s += "Log notice file " + QuoteTorrcPath(logFile) + "\n"; + return s; +} + +static bool StartTorProcess(const std::string& torPath, const std::string& torrcPath, + std::string& errOut) +{ +#ifdef _WIN32 + memset(&g_managedTorProcess, 0, sizeof(g_managedTorProcess)); + STARTUPINFOA si; + memset(&si, 0, sizeof(si)); + si.cb = sizeof(si); + std::string cmd = QuoteCommandArg(torPath) + " -f " + QuoteCommandArg(torrcPath); + std::vector mutableCmd(cmd.begin(), cmd.end()); + mutableCmd.push_back(0); + if (!CreateProcessA(NULL, &mutableCmd[0], NULL, NULL, FALSE, CREATE_NO_WINDOW, + NULL, NULL, &si, &g_managedTorProcess)) + { + errOut = strprintf("could not start Tor process %s (error %lu)", + torPath.c_str(), (unsigned long)GetLastError()); + return false; + } + return true; +#else + g_managedTorPid = fork(); + if (g_managedTorPid < 0) + { + errOut = strprintf("could not fork Tor process: %s", strerror(errno)); + return false; + } + if (g_managedTorPid == 0) + { + execlp(torPath.c_str(), torPath.c_str(), "-f", torrcPath.c_str(), (char*)NULL); + _exit(127); + } + return true; +#endif +} + +static void ThreadManagedTorHostname(void*) +{ + std::string hostnamePath; + unsigned short p2pPort = 0; + CRITICAL_BLOCK(cs_managedTor) + { + hostnamePath = PathJoin(g_managedTorHiddenServiceDir, "hostname"); + p2pPort = g_managedTorP2PPort; + } + + for (int i = 0; i < 90 && !fShutdown; i++) + { + std::string onion; + if (ReadOneLine(hostnamePath, onion)) + { + std::string endpoint = strprintf("%s:%u", onion.c_str(), (unsigned)p2pPort); + std::string err; + if (BtfSetLocalOnionEndpoint(endpoint, err)) + { + CRITICAL_BLOCK(cs_managedTor) + { + g_managedTorOnion = BtfLocalOnionEndpoint(); + g_managedTorStatus = strprintf("running, SOCKS5 127.0.0.1:%u, onion %s", + (unsigned)g_managedTorSocksPort, + g_managedTorOnion.c_str()); + } + printf("Managed Tor hidden service ready: %s\n", BtfLocalOnionEndpoint().c_str()); + return; + } + CRITICAL_BLOCK(cs_managedTor) + g_managedTorStatus = strprintf("running, but generated onion endpoint was rejected: %s", + err.c_str()); + return; + } + Sleep(1000); + } + + CRITICAL_BLOCK(cs_managedTor) + { + if (!g_fManagedTorStopping) + g_managedTorStatus = strprintf("running, SOCKS5 127.0.0.1:%u, waiting for hidden service hostname", + (unsigned)g_managedTorSocksPort); + } +} + +bool BtfStartManagedTor(const std::string& torPathOpt, std::string& errOut) +{ + CRITICAL_BLOCK(cs_managedTor) + { + if (g_fManagedTor) + { + errOut = "managed Tor is already running"; + return false; + } + } + + std::string root = PathJoin(GetAppDir(), "managed-tor"); + std::string dataDir = PathJoin(root, "data"); + std::string hsDir = PathJoin(root, "onion-service"); + std::string torrcPath = PathJoin(root, "torrc"); + if (!EnsureDir(root, errOut) || !EnsureDir(dataDir, errOut) || !EnsureDir(hsDir, errOut)) + return false; + + unsigned short socksPort = 0; + if (!PickLocalPort(socksPort, errOut)) + return false; + + unsigned short p2pPort = ntohs(nListenPort); + std::string torrc = BtfBuildManagedTorrcForTest(dataDir, hsDir, socksPort, p2pPort); + if (!WriteTextFile(torrcPath, torrc, errOut)) + return false; + + std::string torPath = ResolveTorPath(torPathOpt); + { + CRITICAL_BLOCK(cs_managedTor) + { + g_fManagedTor = true; + g_fManagedTorStopping = false; + g_managedTorPath = torPath; + g_managedTorDataDir = dataDir; + g_managedTorHiddenServiceDir = hsDir; + g_managedTorSocksPort = socksPort; + g_managedTorP2PPort = p2pPort; + g_managedTorOnion.clear(); + g_managedTorStatus = strprintf("starting %s, SOCKS5 127.0.0.1:%u", + torPath.c_str(), (unsigned)socksPort); + } + } + + if (!StartTorProcess(torPath, torrcPath, errOut)) + { + CRITICAL_BLOCK(cs_managedTor) + { + g_fManagedTor = false; + g_managedTorStatus = strprintf("failed: %s", errOut.c_str()); + } + return false; + } + + std::string proxyErr; + if (!BtfEnableTorProxy(strprintf("127.0.0.1:%u", (unsigned)socksPort), proxyErr)) + { + BtfStopManagedTor(); + errOut = strprintf("could not enable managed Tor SOCKS5 proxy: %s", proxyErr.c_str()); + return false; + } + + if (_beginthread(ThreadManagedTorHostname, 0, NULL) == (uintptr_t)-1) + { + BtfStopManagedTor(); + errOut = "could not start managed Tor hostname watcher"; + return false; + } + + return true; +} + +void BtfStopManagedTor() +{ + bool fWasRunning = false; + CRITICAL_BLOCK(cs_managedTor) + { + fWasRunning = g_fManagedTor; + if (!fWasRunning) + return; + g_fManagedTorStopping = true; + g_fManagedTor = false; + g_managedTorStatus = "stopping"; + } +#ifdef _WIN32 + if (g_managedTorProcess.hProcess) + { + TerminateProcess(g_managedTorProcess.hProcess, 0); + WaitForSingleObject(g_managedTorProcess.hProcess, 5000); + CloseHandle(g_managedTorProcess.hProcess); + CloseHandle(g_managedTorProcess.hThread); + memset(&g_managedTorProcess, 0, sizeof(g_managedTorProcess)); + } +#else + if (g_managedTorPid > 0) + { + kill(g_managedTorPid, SIGTERM); + for (int i = 0; i < 50; i++) + { + int status = 0; + pid_t r = waitpid(g_managedTorPid, &status, WNOHANG); + if (r == g_managedTorPid) + break; + Sleep(100); + } + g_managedTorPid = -1; + } +#endif + CRITICAL_BLOCK(cs_managedTor) + { + g_managedTorStatus = "stopped"; + g_managedTorOnion.clear(); + } +} + +bool BtfManagedTorEnabled() +{ + CRITICAL_BLOCK(cs_managedTor) + return g_fManagedTor; + return false; +} + +std::string BtfManagedTorStatus() +{ + CRITICAL_BLOCK(cs_managedTor) + { + if (!g_fManagedTor && g_managedTorStatus.empty()) + return "disabled"; + return g_managedTorStatus; + } + return "disabled"; +} diff --git a/src/tor.h b/src/tor.h new file mode 100644 index 0000000..482e773 --- /dev/null +++ b/src/tor.h @@ -0,0 +1,22 @@ +// Copyright (c) 2026 Bitflash developers +// Distributed under the MIT/X11 software license. +// +// Managed Tor process support. This is transport bootstrapping only: it does +// not touch consensus, wallet keys, mining, or the .btf identity. + +#ifndef BITFLASH_TOR_H +#define BITFLASH_TOR_H + +#include + +bool BtfStartManagedTor(const std::string& torPathOpt, std::string& errOut); +void BtfStopManagedTor(); +bool BtfManagedTorEnabled(); +std::string BtfManagedTorStatus(); + +std::string BtfBuildManagedTorrcForTest(const std::string& dataDir, + const std::string& hiddenServiceDir, + unsigned short socksPort, + unsigned short p2pPort); + +#endif -- cgit v1.2.3 From 5309170e567f4a8655b3b622e7b26b6d8b382942 Mon Sep 17 00:00:00 2001 From: Nsable-sh Date: Thu, 13 Aug 2026 15:24:10 -0300 Subject: net: add onion-only btf dial mode --- docs/tor.md | 28 ++++++ scripts/smoke-managed-tor-onion.ps1 | 174 ++++++++++++++++++++++++++++++++++++ src/main_gui.cpp | 4 + src/net.cpp | 25 +++++- src/net.h | 1 + src/nostr.cpp | 11 +-- src/proxy.cpp | 51 ++++++++++- 7 files changed, 283 insertions(+), 11 deletions(-) create mode 100644 scripts/smoke-managed-tor-onion.ps1 (limited to 'src/main_gui.cpp') diff --git a/docs/tor.md b/docs/tor.md index a408704..0016f8b 100644 --- a/docs/tor.md +++ b/docs/tor.md @@ -201,3 +201,31 @@ but an already-discovered onion-capable peer can be reached directly over Tor. Tor owns the private key under `HiddenServiceDir`; back it up if you want the onion name to stay stable. + +## Release smoke test + +After building a Windows binary with managed Tor support, run the two-node smoke +test before cutting a release: + +```powershell +powershell -ExecutionPolicy Bypass -File scripts\smoke-managed-tor-onion.ps1 ` + -Bitflash .\src\bitflash.exe ` + -Tor C:\path\to\tor.exe +``` + +The script creates two temporary datadirs, starts node A with `-managedtor`, +waits for it to publish its `.btf` identity and generated `.onion` endpoint, +then starts node B with its own `-managedtor`, `-oniononly`, and +`-connectbtf=`. It passes only when node B logs that it connected to +node A `via direct onion`; rendezvous fallback cannot satisfy the test. + +By default the script waits 120 seconds after node A's descriptor self-resolves +before it starts node B. Tor can create the local `hostname` before the hidden +service descriptor is visible enough for client circuits, and early dials often +fail with SOCKS5 reply `0x01`. Override this with `-OnionWarmupSec N` when +testing a slow or already-warmed Tor environment. + +This test still uses Nostr for descriptor discovery, because that is how nodes +learn each other's signed `.btf` descriptors. It does not require a Bitflash +rendezvous relay to carry the P2P connection: once the descriptor includes the +signed onion endpoint, the actual node connection goes directly through Tor. diff --git a/scripts/smoke-managed-tor-onion.ps1 b/scripts/smoke-managed-tor-onion.ps1 new file mode 100644 index 0000000..70658c7 --- /dev/null +++ b/scripts/smoke-managed-tor-onion.ps1 @@ -0,0 +1,174 @@ +param( + [string]$Bitflash, + [string]$Tor, + [string]$WorkDir, + [int]$TimeoutSec = 600, + [int]$OnionWarmupSec = 120 +) + +$ErrorActionPreference = "Stop" + +function Resolve-RepoRoot { + $scriptDir = Split-Path -Parent $PSCommandPath + return (Resolve-Path (Join-Path $scriptDir "..")).Path +} + +function Get-FreeTcpPort { + $listener = [Net.Sockets.TcpListener]::new([Net.IPAddress]::Loopback, 0) + $listener.Start() + try { + return ([Net.IPEndPoint]$listener.LocalEndpoint).Port + } + finally { + $listener.Stop() + } +} + +function Wait-RegexInFile { + param( + [string]$Path, + [string]$Pattern, + [datetime]$Deadline, + [string]$Label + ) + + while ((Get-Date) -lt $Deadline) { + if (Test-Path $Path) { + $text = Get-Content -Raw -ErrorAction SilentlyContinue $Path + if ($null -ne $text) { + $m = [regex]::Match($text, $Pattern) + if ($m.Success) { + return $m + } + } + } + Start-Sleep -Milliseconds 500 + } + throw "timed out waiting for $Label" +} + +function Stop-TestProcesses { + param( + [System.Diagnostics.Process[]]$Processes, + [string[]]$DataDirKeys + ) + + foreach ($p in $Processes) { + try { + $p.Refresh() + if (!$p.HasExited) { + Stop-Process -Id $p.Id -Force + } + } + catch {} + } + + foreach ($key in $DataDirKeys) { + if ([string]::IsNullOrWhiteSpace($key)) { + continue + } + try { + Get-CimInstance Win32_Process -Filter "name = 'tor.exe'" | + Where-Object { $_.CommandLine -like "*$key*" } | + ForEach-Object { Stop-Process -Id $_.ProcessId -Force } + } + catch {} + } +} + +$repo = Resolve-RepoRoot +if ([string]::IsNullOrWhiteSpace($Bitflash)) { + $Bitflash = Join-Path $repo "src\bitflash.exe" +} +if ([string]::IsNullOrWhiteSpace($Tor)) { + $candidates = @( + (Join-Path $repo "src\tor\tor.exe"), + (Join-Path $repo "Bitflash-$(Select-String -Path (Join-Path $repo 'Makefile') -Pattern '^VERSION[ ]*:=' | ForEach-Object { ($_.Line -split ':=')[1].Trim() })-windows\tor\tor.exe") + ) + foreach ($candidate in $candidates) { + if (Test-Path $candidate) { + $Tor = $candidate + break + } + } +} +if ([string]::IsNullOrWhiteSpace($WorkDir)) { + $WorkDir = Join-Path $env:TEMP ("bitflash-managed-tor-two-node-" + (Get-Date -Format "yyyyMMddHHmmss")) +} + +if (!(Test-Path $Bitflash)) { + throw "Bitflash binary not found: $Bitflash" +} +if (!(Test-Path $Tor)) { + throw "Tor binary not found. Pass -Tor or build the Windows-with-Tor package." +} + +New-Item -ItemType Directory -Force -Path $WorkDir | Out-Null +$dataA = Join-Path $WorkDir "node-a" +$dataB = Join-Path $WorkDir "node-b" +New-Item -ItemType Directory -Force -Path $dataA | Out-Null +New-Item -ItemType Directory -Force -Path $dataB | Out-Null + +$portA = Get-FreeTcpPort +$portB = Get-FreeTcpPort +$deadline = (Get-Date).AddSeconds($TimeoutSec) +$oldPath = $env:PATH +$env:PATH = "C:\msys64\ucrt64\bin;C:\msys64\usr\bin;" + $env:PATH + +$procA = $null +$procB = $null +$dataKeys = @((Split-Path $WorkDir -Leaf)) + +try { + $argsA = @( + "-datadir=$dataA", + "-port=$portA", + "-managedtor=$Tor", + "-nogui", + "-debug", + "-checkblocks=0" + ) + $procA = Start-Process -FilePath $Bitflash -ArgumentList $argsA -WorkingDirectory (Split-Path $Bitflash -Parent) -PassThru -WindowStyle Hidden + $logA = Join-Path $dataA "debug.log" + + $btfMatch = Wait-RegexInFile -Path $logA -Pattern "Nostr: node \.btf address = ([a-z2-7]+\.btf)" -Deadline $deadline -Label "node A .btf address" + $btfA = $btfMatch.Groups[1].Value + + $onionMatch = Wait-RegexInFile -Path $logA -Pattern "Managed Tor hidden service ready: ([a-z2-7]{56}\.onion:\d+)" -Deadline $deadline -Label "node A managed onion" + $onionA = $onionMatch.Groups[1].Value + + Wait-RegexInFile -Path $logA -Pattern "Nostr: \.btf self-resolve OK via .*" -Deadline $deadline -Label "node A descriptor self-resolve" | Out-Null + if ($OnionWarmupSec -gt 0) { + Write-Host "Waiting $OnionWarmupSec second(s) for Tor hidden-service descriptor propagation" + Start-Sleep -Seconds $OnionWarmupSec + } + + $argsB = @( + "-datadir=$dataB", + "-port=$portB", + "-managedtor=$Tor", + "-oniononly", + "-connectbtf=$btfA", + "-nogui", + "-debug", + "-checkblocks=0" + ) + $procB = Start-Process -FilePath $Bitflash -ArgumentList $argsB -WorkingDirectory (Split-Path $Bitflash -Parent) -PassThru -WindowStyle Hidden + $logB = Join-Path $dataB "debug.log" + + Wait-RegexInFile -Path $logB -Pattern ("connected " + [regex]::Escape($btfA) + " via direct onion ") -Deadline $deadline -Label "node B direct onion connection to node A" | Out-Null + + Write-Host "Managed Tor two-node onion smoke test passed" + Write-Host " node A .btf: $btfA" + Write-Host " node A onion: $onionA" + Write-Host " node A port: $portA" + Write-Host " node B port: $portB" + Write-Host " work dir: $WorkDir" +} +finally { + $env:PATH = $oldPath + $procs = @() + if ($null -ne $procA) { $procs += $procA } + if ($null -ne $procB) { $procs += $procB } + Stop-TestProcesses -Processes $procs -DataDirKeys $dataKeys +} diff --git a/src/main_gui.cpp b/src/main_gui.cpp index deed471..f0fe903 100644 --- a/src/main_gui.cpp +++ b/src/main_gui.cpp @@ -143,6 +143,7 @@ static void PrintUsage() printf(" /socks=HOST:PORT (SOCKS5 proxy for Nostr, .btf relay, and onion dials)\n"); printf(" /tor[=HOST:PORT] (Tor mode; default SOCKS5 proxy is 127.0.0.1:9050)\n"); printf(" /managedtor[=PATH] (start Tor, create a hidden service, advertise its onion)\n"); + printf(" /oniononly (do not fall back to rendezvous when a .btf onion dial fails)\n"); printf(" /btfseed=ADDRESS:ENCHEX (extra bootstrap peer, repeatable)\n"); printf("\n"); printf("Wallet:\n"); @@ -278,6 +279,7 @@ static void ParseStartupArguments(int argc, char* argv[]) string btfConnect = argval2(argc, argv, "/connectbtf", "-connectbtf"); if (!btfConnect.empty()) strBtfConnect = btfConnect; + fBtfOnionOnly = arg(argc, argv, "/oniononly") || arg(argc, argv, "-oniononly"); string rvRelay = argval2(argc, argv, "/rvrelay", "-rvrelay"); if (!rvRelay.empty()) @@ -360,6 +362,8 @@ static void ParseStartupArguments(int argc, char* argv[]) BtfSocks5ProxyName().c_str()); } } + if (fBtfOnionOnly && !BtfSocks5ProxyEnabled()) + fprintf(stderr, "Warning: /oniononly without /tor, /managedtor, or /socks cannot dial .onion peers\n"); // /btfseed=ADDRESS:ENCHEX -- extra bootstrap peers, repeatable. Useful for // testing the seed path and for private networks that ship no compiled list. diff --git a/src/net.cpp b/src/net.cpp index 6bc3c1c..997a2bd 100644 --- a/src/net.cpp +++ b/src/net.cpp @@ -65,6 +65,8 @@ deque > vRelayExpiration; CCriticalSection cs_mapRelay; map mapAlreadyAskedFor; string strBtfConnect; // .btf peer to keep connected to (from /connectbtf) +bool fBtfOnionOnly = false; // require direct onion transport for .btf peers +static const int BTF_ONION_CONNECT_TIMEOUT_SECS = 120; int nPeersWatched = 0; int64 nBlocksReceived = 0; @@ -1022,7 +1024,8 @@ static CNode* ConnectNodeBtfTail(const string& strBtfAddr, const unsigned char p if (SplitHostPort(strOnionNorm, strOnionHost, nOnionPort)) { BtfChurnNoteDialAttempt(strBtfAddr, strOnionNorm); - SOCKET hOnionSocket = BtfConnectSocket(strOnionHost, nOnionPort, SOCK_ONION_PEER, 20); + SOCKET hOnionSocket = BtfConnectSocket(strOnionHost, nOnionPort, SOCK_ONION_PEER, + BTF_ONION_CONNECT_TIMEOUT_SECS); if (hOnionSocket != INVALID_SOCKET) { if (fDebug) @@ -1040,12 +1043,23 @@ static CNode* ConnectNodeBtfTail(const string& strBtfAddr, const unsigned char p return pnode; } if (fDebug) - LogPrint("net", "ConnectNodeBtf: direct onion to %s at %s failed, falling back to rendezvous\n", - strBtfAddr.c_str(), strOnionNorm.c_str()); + LogPrint("net", "ConnectNodeBtf: direct onion to %s at %s failed%s\n", + strBtfAddr.c_str(), strOnionNorm.c_str(), + fBtfOnionOnly ? "" : ", falling back to rendezvous"); BtfChurnNoteDialResult(strBtfAddr, strOnionNorm, false); + if (fBtfOnionOnly) + return NULL; } } + if (fBtfOnionOnly) + { + if (fDebug) + LogPrint("net", "ConnectNodeBtf: onion-only mode refused rendezvous fallback for %s\n", + strBtfAddr.c_str()); + return NULL; + } + BtfChurnNoteDialAttempt(strBtfAddr, strMeeting); string strHost; unsigned short nPort = 0; @@ -2012,6 +2026,11 @@ bool StartNode(string& strError) // IP address, and port for the socket that is being bound int nRetryLimit = 15; struct sockaddr_in sockaddr = addrLocalHost.GetSockAddr(); + // Keep addrLocalHost as this node's advertised address, but listen on all + // local interfaces. Managed Tor forwards hidden-service traffic to + // 127.0.0.1:, and binding only to the LAN address made that onion + // endpoint unreachable even though the P2P listener was running. + sockaddr.sin_addr.s_addr = INADDR_ANY; if (bind(hListenSocket, (struct sockaddr*)&sockaddr, sizeof(sockaddr)) == SOCKET_ERROR) { int nErr = WSAGetLastError(); diff --git a/src/net.h b/src/net.h index 9300169..984588b 100644 --- a/src/net.h +++ b/src/net.h @@ -33,6 +33,7 @@ CNode* ConnectNodeBtfResolved(const string& strBtfAddr, const string& strMeeting void ThreadBtfAccept(void* parg); void ThreadBtfConnect(void* parg); extern string strBtfConnect; +extern bool fBtfOnionOnly; // Bootstrap seeds added at runtime with /btfseed=ADDRESS:ENCHEX (repeatable). // The compiled-in list lives in net.cpp; these are appended to it. extern std::vector > vBtfExtraSeeds; diff --git a/src/nostr.cpp b/src/nostr.cpp index 0c1b4b6..35fde43 100644 --- a/src/nostr.cpp +++ b/src/nostr.cpp @@ -1764,11 +1764,12 @@ void ThreadNostrSeed(void* parg) LogPrint("nostr", "Nostr: node pubkey = %s\n", key.PubKeyHex().c_str()); LogPrint("nostr", "Nostr: node .btf address = %s\n", key.BtfAddress().c_str()); - // The self-test below publishes a real descriptor, and PublishDescriptor now - // refuses to publish before ThreadBtfAccept has registered a rendezvous. Give - // registration a bounded head start so the test measures the publish/resolve - // chain instead of losing the race against it. - for (int i = 0; i < 60 && !fShutdown && BtfActiveRelay().empty(); i++) + // The self-test below publishes a real descriptor. Rendezvous-only nodes + // need ThreadBtfAccept to register first; Tor nodes can publish as soon as + // their signed onion endpoint exists, with rendezvous marked pending. + for (int i = 0; i < 60 && !fShutdown && + BtfActiveRelay().empty() && + BtfLocalOnionEndpoint().empty(); i++) Sleep(1000); // One-time self-test of the rendezvous: publish our descriptor to a relay, diff --git a/src/proxy.cpp b/src/proxy.cpp index 7d30911..eed054c 100644 --- a/src/proxy.cpp +++ b/src/proxy.cpp @@ -13,6 +13,7 @@ static bool g_fTorProxy = false; static std::string g_socks5Host; static unsigned short g_socks5Port = 0; static const char* DEFAULT_TOR_SOCKS5_PROXY = "127.0.0.1:9050"; +static const int TOR_CONNECT_TIMEOUT_SECS = 120; static void SetSocketTimeout(SOCKET s, int nTimeoutSecs) { @@ -297,10 +298,25 @@ static bool Socks5Connect(SOCKET s, const std::string& destHost, unsigned char hello[3] = { 0x05, 0x01, 0x00 }; // SOCKS5, one method, no auth if (!WriteN(s, hello, sizeof(hello))) + { + error("SOCKS5 hello write failed for %s:%u\n", + destHost.c_str(), (unsigned)destPort); return false; + } unsigned char choice[2] = { 0, 0 }; - if (!ReadN(s, choice, sizeof(choice)) || choice[0] != 0x05 || choice[1] != 0x00) + if (!ReadN(s, choice, sizeof(choice))) + { + error("SOCKS5 method selection read failed for %s:%u\n", + destHost.c_str(), (unsigned)destPort); + return false; + } + if (choice[0] != 0x05 || choice[1] != 0x00) + { + error("SOCKS5 proxy rejected no-auth method for %s:%u (version=0x%02x method=0x%02x)\n", + destHost.c_str(), (unsigned)destPort, + (unsigned)choice[0], (unsigned)choice[1]); return false; + } std::vector req; req.reserve(7 + destHost.size()); @@ -313,11 +329,25 @@ static bool Socks5Connect(SOCKET s, const std::string& destHost, req.push_back((unsigned char)(destPort >> 8)); req.push_back((unsigned char)(destPort & 0xff)); if (!WriteN(s, &req[0], (int)req.size())) + { + error("SOCKS5 CONNECT request write failed for %s:%u\n", + destHost.c_str(), (unsigned)destPort); return false; + } unsigned char rep[4] = {0,0,0,0}; - if (!ReadN(s, rep, sizeof(rep)) || rep[0] != 0x05) + if (!ReadN(s, rep, sizeof(rep))) + { + error("SOCKS5 CONNECT reply read failed for %s:%u\n", + destHost.c_str(), (unsigned)destPort); return false; + } + if (rep[0] != 0x05) + { + error("SOCKS5 CONNECT reply has invalid version 0x%02x for %s:%u\n", + (unsigned)rep[0], destHost.c_str(), (unsigned)destPort); + return false; + } if (rep[1] != 0x00) { error("SOCKS5 CONNECT to %s:%u failed with reply 0x%02x\n", @@ -335,10 +365,20 @@ static bool Socks5Connect(SOCKET s, const std::string& destHost, nAddr = len; } else + { + error("SOCKS5 CONNECT reply has invalid address type 0x%02x for %s:%u\n", + (unsigned)rep[3], destHost.c_str(), (unsigned)destPort); return false; + } std::vector discard(nAddr + 2); - return ReadN(s, &discard[0], (int)discard.size()); + if (!ReadN(s, &discard[0], (int)discard.size())) + { + error("SOCKS5 CONNECT bind address read failed for %s:%u\n", + destHost.c_str(), (unsigned)destPort); + return false; + } + return true; } SOCKET BtfConnectSocket(const std::string& destHost, unsigned short destPort, @@ -347,9 +387,11 @@ SOCKET BtfConnectSocket(const std::string& destHost, unsigned short destPort, std::string proxyHost; unsigned short proxyPort = 0; bool fProxy = false; + bool fTorProxy = false; CRITICAL_BLOCK(cs_socks5) { fProxy = g_fSocks5Proxy; + fTorProxy = g_fTorProxy; proxyHost = g_socks5Host; proxyPort = g_socks5Port; } @@ -365,6 +407,9 @@ SOCKET BtfConnectSocket(const std::string& destHost, unsigned short destPort, return ConnectDirect(destHost, destPort, nSockSite, nTimeoutSecs); } + if (fTorProxy && nTimeoutSecs > 0 && nTimeoutSecs < TOR_CONNECT_TIMEOUT_SECS) + nTimeoutSecs = TOR_CONNECT_TIMEOUT_SECS; + SOCKET s = ConnectDirect(proxyHost, proxyPort, nSockSite, nTimeoutSecs); if (s == INVALID_SOCKET) return INVALID_SOCKET; -- cgit v1.2.3 From a49ee4206735ac16876a01064e5438c719161b17 Mon Sep 17 00:00:00 2001 From: Nsable-sh Date: Thu, 13 Aug 2026 16:37:08 -0300 Subject: net: auto-start bundled managed Tor --- src/main_gui.cpp | 22 +++++++++++++++++----- src/readme.txt | 2 ++ src/tor.cpp | 15 +++++++++++++++ src/tor.h | 1 + 4 files changed, 35 insertions(+), 5 deletions(-) (limited to 'src/main_gui.cpp') diff --git a/src/main_gui.cpp b/src/main_gui.cpp index f0fe903..3f52e91 100644 --- a/src/main_gui.cpp +++ b/src/main_gui.cpp @@ -143,6 +143,7 @@ static void PrintUsage() printf(" /socks=HOST:PORT (SOCKS5 proxy for Nostr, .btf relay, and onion dials)\n"); printf(" /tor[=HOST:PORT] (Tor mode; default SOCKS5 proxy is 127.0.0.1:9050)\n"); printf(" /managedtor[=PATH] (start Tor, create a hidden service, advertise its onion)\n"); + printf(" /nomanagedtor (disable automatic bundled Tor startup)\n"); printf(" /oniononly (do not fall back to rendezvous when a .btf onion dial fails)\n"); printf(" /btfseed=ADDRESS:ENCHEX (extra bootstrap peer, repeatable)\n"); printf("\n"); @@ -322,19 +323,30 @@ static void ParseStartupArguments(int argc, char* argv[]) } bool fManagedTor = arg(argc, argv, "/managedtor") || arg(argc, argv, "-managedtor"); + bool fNoManagedTor = arg(argc, argv, "/nomanagedtor") || arg(argc, argv, "-nomanagedtor"); bool fTorMode = arg(argc, argv, "/tor") || arg(argc, argv, "-tor"); string socksProxy = argval2(argc, argv, "/socks", "-socks"); - if (fManagedTor) + string bundledTorPath; + bool fAutoManagedTor = !fManagedTor && !fNoManagedTor && !fTorMode && + socksProxy.empty() && BtfBundledTorPath(bundledTorPath); + if (fManagedTor || fAutoManagedTor) { - if (fTorMode || !socksProxy.empty()) + if (fManagedTor && (fTorMode || !socksProxy.empty())) fprintf(stderr, "Warning: /managedtor takes precedence over /tor and /socks\n"); - string torPath = argval2(argc, argv, "/managedtor", "-managedtor"); + string torPath = fManagedTor ? argval2(argc, argv, "/managedtor", "-managedtor") : bundledTorPath; string err; if (!BtfStartManagedTor(torPath, err)) - fprintf(stderr, "Ignoring /managedtor=%s: %s\n", torPath.c_str(), err.c_str()); + { + if (fAutoManagedTor) + fprintf(stderr, "Bundled Tor not enabled automatically: %s\n", err.c_str()); + else + fprintf(stderr, "Ignoring /managedtor=%s: %s\n", torPath.c_str(), err.c_str()); + } else - fprintf(stderr, "Managed Tor enabled: %s\n", BtfManagedTorStatus().c_str()); + fprintf(stderr, "Managed Tor enabled%s: %s\n", + fAutoManagedTor ? " automatically" : "", + BtfManagedTorStatus().c_str()); } else if (fTorMode) { diff --git a/src/readme.txt b/src/readme.txt index 9ddbc24..752be66 100644 --- a/src/readme.txt +++ b/src/readme.txt @@ -52,6 +52,8 @@ Command-line options (in addition to the original ones) /managedtor[=PATH] start Tor, create a v3 hidden service for this node, route outbound discovery through it, and advertise the generated onion endpoint + Windows release packages that include tor/tor.exe start this mode + automatically; use /nomanagedtor to force the legacy relay path /solomine mine without requiring a connected peer (local testing) /operator operator mode (run pool server) /participant=ADDR diff --git a/src/tor.cpp b/src/tor.cpp index e47ee44..20b4577 100644 --- a/src/tor.cpp +++ b/src/tor.cpp @@ -500,6 +500,21 @@ static std::string ResolveTorPath(const std::string& torPathOpt) #endif } +bool BtfBundledTorPath(std::string& torPathOut) +{ + torPathOut.clear(); + std::string exeDir = ExecutableDir(); +#ifdef _WIN32 + std::string path = PathJoin(exeDir, "tor/tor.exe"); +#else + std::string path = PathJoin(exeDir, "tor/tor"); +#endif + if (!FileIsExecutableCandidate(path)) + return false; + torPathOut = path; + return true; +} + std::string BtfBuildManagedTorrcForTest(const std::string& dataDir, const std::string& hiddenServiceDir, unsigned short socksPort, diff --git a/src/tor.h b/src/tor.h index 7c5c603..cfb87a5 100644 --- a/src/tor.h +++ b/src/tor.h @@ -13,6 +13,7 @@ bool BtfStartManagedTor(const std::string& torPathOpt, std::string& errOut); void BtfStopManagedTor(); bool BtfManagedTorEnabled(); std::string BtfManagedTorStatus(); +bool BtfBundledTorPath(std::string& torPathOut); std::string BtfBuildManagedTorrcForTest(const std::string& dataDir, const std::string& hiddenServiceDir, -- cgit v1.2.3 From 6f9e07186d06f772c93832f0dcbd4f9432b44579 Mon Sep 17 00:00:00 2001 From: Nsable-sh Date: Thu, 13 Aug 2026 17:19:37 -0300 Subject: tests: cover portable wallet copies --- src/main_gui.cpp | 3 +- src/selftest.cpp | 164 ++++++++++++++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 165 insertions(+), 2 deletions(-) (limited to 'src/main_gui.cpp') diff --git a/src/main_gui.cpp b/src/main_gui.cpp index 3f52e91..50c4f34 100644 --- a/src/main_gui.cpp +++ b/src/main_gui.cpp @@ -114,7 +114,8 @@ static void PrintUsage() printf(" /gen\n"); printf(" /nogui or /daemon\n"); printf(" /selftest=wallet-keypool, wallet-hd, wallet-format, wallet-crypto, wallet-encrypt,\n"); - printf(" net-message, consensus-limits, pool-stratum, parse-money, socks5-proxy, or managed-tor\n"); + printf(" wallet-portability, net-message, consensus-limits, pool-stratum,\n"); + printf(" parse-money, socks5-proxy, or managed-tor\n"); printf("\n"); printf("Mining mode:\n"); printf(" /operator\n"); diff --git a/src/selftest.cpp b/src/selftest.cpp index 79feddf..3f3cb16 100644 --- a/src/selftest.cpp +++ b/src/selftest.cpp @@ -34,6 +34,7 @@ extern int RunPoolStratumSelfTest(); #undef printf #include +#include #include #include @@ -925,6 +926,46 @@ static bool WriteTextFile(const string& strPath, const string& strText) return fOk; } +static bool MakeDirLocal(const string& strPath) +{ +#ifdef _WIN32 + return CreateDirectoryA(strPath.c_str(), NULL) || GetLastError() == ERROR_ALREADY_EXISTS; +#else + return mkdir(strPath.c_str(), 0700) == 0 || errno == EEXIST; +#endif +} + +static bool CopyFileLocal(const string& strSrc, const string& strDst) +{ + FILE* in = fopen(strSrc.c_str(), "rb"); + if (!in) + return false; + FILE* out = fopen(strDst.c_str(), "wb"); + if (!out) + { + fclose(in); + return false; + } + + bool fOk = true; + char buf[65536]; + size_t n = 0; + while ((n = fread(buf, 1, sizeof(buf), in)) > 0) + { + if (fwrite(buf, 1, n, out) != n) + { + fOk = false; + break; + } + } + if (ferror(in)) + fOk = false; + if (fclose(out) != 0) + fOk = false; + fclose(in); + return fOk; +} + static string QuoteCommandArg(const string& str) { string out = "\""; @@ -1169,6 +1210,125 @@ static int RunWalletEncryptSelfTest() return nFail == 0 ? 0 : 1; } +static int RunWalletPortabilitySelfTest() +{ + fflush(stdout); + printf("wallet-portability self-test\n"); + + std::string tmp; + std::string cwd; + if (!MakeTempDir(tmp)) + { + printf(" FAIL could not create a temporary data directory\n"); + return 1; + } + if (!GetCurrentDir(cwd)) + { + printf(" FAIL could not read current directory\n"); + RemoveTree(tmp); + return 1; + } + + int nFail = 0; + printf(" temp root: %s\n", tmp.c_str()); + + try + { +#ifdef _WIN32 + string strExe = cwd + "\\bitflash.exe"; +#else + string strExe = cwd + "/bitflash-node"; +#endif + string strOriginal = tmp + "/original"; + string strRawCopy = tmp + "/raw-copy"; + string strBackupCopy = tmp + "/backup-copy"; + nFail += Check(MakeDirLocal(strOriginal) && + MakeDirLocal(strRawCopy) && + MakeDirLocal(strBackupCopy), + "temporary wallet directories can be created") ? 0 : 1; + + string strCreateOut = tmp + "/create.txt"; + vector vCreateArgs; + vCreateArgs.push_back("-datadir=" + strOriginal); + vCreateArgs.push_back("-nomanagedtor"); + vCreateArgs.push_back("-nogui"); + vCreateArgs.push_back("-newphrase"); + int nCreateRet = RunBitflashChild(strExe, vCreateArgs, NULL, &strCreateOut); + nFail += Check(nCreateRet == 0 && + FileContainsText(strCreateOut, "Write these twelve words down"), + "a phrase wallet can be created in a fresh datadir") ? 0 : 1; + + string strOriginalAudit = tmp + "/original-audit.txt"; + vector vOriginalAuditArgs; + vOriginalAuditArgs.push_back("-datadir=" + strOriginal); + vOriginalAuditArgs.push_back("-nomanagedtor"); + vOriginalAuditArgs.push_back("-nogui"); + vOriginalAuditArgs.push_back("-recoveryaudit"); + int nOriginalAuditRet = RunBitflashChild(strExe, vOriginalAuditArgs, NULL, &strOriginalAudit); + nFail += Check(nOriginalAuditRet == 0 && + FileContainsText(strOriginalAudit, "recovery phrase: present") && + FileContainsText(strOriginalAudit, "SLIP-0044 BITFLASH"), + "the original wallet reports its recovery phrase") ? 0 : 1; + + string strPortableWallet = tmp + "/portable-wallet.dat"; + string strBackupOut = tmp + "/backupwallet.txt"; + vector vBackupArgs; + vBackupArgs.push_back("-datadir=" + strOriginal); + vBackupArgs.push_back("-nomanagedtor"); + vBackupArgs.push_back("-nogui"); + vBackupArgs.push_back("-backupwallet=" + strPortableWallet); + int nBackupRet = RunBitflashChild(strExe, vBackupArgs, NULL, &strBackupOut); + nFail += Check(nBackupRet == 0 && FileExists(strPortableWallet.c_str()), + "/backupwallet writes a portable wallet.dat") ? 0 : 1; + + nFail += Check(CopyFileLocal(strOriginal + "/wallet.dat", + strRawCopy + "/wallet.dat"), + "a raw wallet.dat can be copied without database logs") ? 0 : 1; + string strRawAudit = tmp + "/raw-copy-audit.txt"; + vector vRawAuditArgs; + vRawAuditArgs.push_back("-datadir=" + strRawCopy); + vRawAuditArgs.push_back("-nomanagedtor"); + vRawAuditArgs.push_back("-nogui"); + vRawAuditArgs.push_back("-recoveryaudit"); + int nRawAuditRet = RunBitflashChild(strExe, vRawAuditArgs, NULL, &strRawAudit); + nFail += Check(nRawAuditRet == 0 && + FileContainsText(strRawAudit, "recovery phrase: present") && + !FileContainsText(strRawAudit, "Cannot open wallet.dat"), + "a raw wallet.dat copy opens in a new datadir") ? 0 : 1; + + nFail += Check(CopyFileLocal(strPortableWallet, + strBackupCopy + "/wallet.dat"), + "the portable backup can be installed as wallet.dat") ? 0 : 1; + string strBackupAudit = tmp + "/backup-copy-audit.txt"; + vector vBackupAuditArgs; + vBackupAuditArgs.push_back("-datadir=" + strBackupCopy); + vBackupAuditArgs.push_back("-nomanagedtor"); + vBackupAuditArgs.push_back("-nogui"); + vBackupAuditArgs.push_back("-recoveryaudit"); + int nBackupAuditRet = RunBitflashChild(strExe, vBackupAuditArgs, NULL, &strBackupAudit); + nFail += Check(nBackupAuditRet == 0 && + FileContainsText(strBackupAudit, "recovery phrase: present") && + FileContainsText(strBackupAudit, "SLIP-0044 BITFLASH"), + "a /backupwallet copy opens in a new datadir") ? 0 : 1; + } + catch (const std::exception& e) + { + printf(" FAIL exception: %s\n", e.what()); + nFail++; + } + catch (...) + { + printf(" FAIL unknown exception\n"); + nFail++; + } + + RemoveTree(tmp); + printf("%s (%d failure%s)\n", nFail == 0 ? "ALL TESTS PASSED" : "TESTS FAILED", + nFail, nFail == 1 ? "" : "s"); + fflush(stdout); + return nFail == 0 ? 0 : 1; +} + static int RunNetMessageSelfTest() { fflush(stdout); @@ -1653,6 +1813,8 @@ int RunSelfTest(const std::string& name) return RunWalletCryptoSelfTest(); if (name == "wallet-encrypt") return RunWalletEncryptSelfTest(); + if (name == "wallet-portability") + return RunWalletPortabilitySelfTest(); if (name == "net-message") return RunNetMessageSelfTest(); if (name == "consensus-limits") @@ -1667,6 +1829,6 @@ int RunSelfTest(const std::string& name) return RunManagedTorSelfTest(); printf("Unknown self-test '%s'\n", name.c_str()); - printf("Known self-tests: wallet-keypool, wallet-hd, wallet-format, wallet-crypto, wallet-encrypt, net-message, consensus-limits, pool-stratum, parse-money, socks5-proxy, managed-tor\n"); + printf("Known self-tests: wallet-keypool, wallet-hd, wallet-format, wallet-crypto, wallet-encrypt, wallet-portability, net-message, consensus-limits, pool-stratum, parse-money, socks5-proxy, managed-tor\n"); return 1; } -- cgit v1.2.3 From 8e62e362e2fbe6fd6c5d66ddb354ec544f8cf0db Mon Sep 17 00:00:00 2001 From: Nsable-sh Date: Thu, 13 Aug 2026 17:36:09 -0300 Subject: wallet: add storage audit command --- src/main_gui.cpp | 9 +++ src/selftest.cpp | 32 ++++++++++ src/walletcmd.cpp | 178 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/walletcmd.h | 4 ++ 4 files changed, 223 insertions(+) (limited to 'src/main_gui.cpp') diff --git a/src/main_gui.cpp b/src/main_gui.cpp index 50c4f34..532bc3b 100644 --- a/src/main_gui.cpp +++ b/src/main_gui.cpp @@ -172,6 +172,8 @@ static void PrintUsage() printf(" /recoveryaudit (show how much spendable balance is\n"); printf(" covered by the recovery phrase;\n"); printf(" exits 2 when wallet.dat is still needed)\n"); + printf(" /walletstorageaudit (count wallet.dat record types without\n"); + printf(" printing wallet values, then exit)\n"); printf(" /rescan (walk the chain for coins this wallet owns\n"); printf(" but never recorded, then exit)\n"); printf("\n"); @@ -704,6 +706,13 @@ int main(int argc, char* argv[]) return nRet; } + if (arg(argc,argv,"/walletstorageaudit") || arg(argc,argv,"-walletstorageaudit")) + { + int nRet = CmdWalletStorageAudit(); + DBFlush(true); + return nRet; + } + if (arg(argc,argv,"/newphrase") || arg(argc,argv,"-newphrase")) { int nRet = CmdNewPhrase(); diff --git a/src/selftest.cpp b/src/selftest.cpp index 8a448f0..b112238 100644 --- a/src/selftest.cpp +++ b/src/selftest.cpp @@ -1314,6 +1314,21 @@ static int RunWalletPortabilitySelfTest() FileContainsText(strOriginalAudit, "SLIP-0044 BITFLASH"), "the original wallet reports its recovery phrase") ? 0 : 1; + string strOriginalStorageAudit = tmp + "/original-storage-audit.txt"; + vector vOriginalStorageAuditArgs; + vOriginalStorageAuditArgs.push_back("-datadir=" + strOriginal); + vOriginalStorageAuditArgs.push_back("-nomanagedtor"); + vOriginalStorageAuditArgs.push_back("-nogui"); + vOriginalStorageAuditArgs.push_back("-walletstorageaudit"); + int nOriginalStorageAuditRet = + RunBitflashChild(strExe, vOriginalStorageAuditArgs, NULL, &strOriginalStorageAudit); + nFail += Check(nOriginalStorageAuditRet == 0 && + FileContainsText(strOriginalStorageAudit, "Wallet storage audit") && + FileContainsText(strOriginalStorageAudit, "plain HD seed: complete") && + FileContainsText(strOriginalStorageAudit, "encrypted HD seed: none") && + FileContainsText(strOriginalStorageAudit, "malformed records: 0"), + "the storage audit recognizes a plaintext phrase wallet") ? 0 : 1; + string strPortableWallet = tmp + "/portable-wallet.dat"; string strBackupOut = tmp + "/backupwallet.txt"; vector vBackupArgs; @@ -1368,6 +1383,23 @@ static int RunWalletPortabilitySelfTest() nFail += Check(nEncryptRet == 0 && !DirectoryHasFileWithPrefix(strOriginal + "/database", "log."), "the encryptwallet command purges Berkeley DB environment logs") ? 0 : 1; + + string strEncryptedStorageAudit = tmp + "/encrypted-storage-audit.txt"; + vector vEncryptedStorageAuditArgs; + vEncryptedStorageAuditArgs.push_back("-datadir=" + strOriginal); + vEncryptedStorageAuditArgs.push_back("-nomanagedtor"); + vEncryptedStorageAuditArgs.push_back("-nogui"); + vEncryptedStorageAuditArgs.push_back("-walletstorageaudit"); + int nEncryptedStorageAuditRet = + RunBitflashChild(strExe, vEncryptedStorageAuditArgs, NULL, &strEncryptedStorageAudit); + nFail += Check(nEncryptedStorageAuditRet == 0 && + FileContainsText(strEncryptedStorageAudit, "plain private keys: 0") && + FileContainsText(strEncryptedStorageAudit, "encrypted private keys:") && + FileContainsText(strEncryptedStorageAudit, "plain HD seed: none") && + FileContainsText(strEncryptedStorageAudit, "encrypted HD seed: complete") && + FileContainsText(strEncryptedStorageAudit, "wallet minimum version: present") && + FileContainsText(strEncryptedStorageAudit, "malformed records: 0"), + "the storage audit recognizes an encrypted wallet") ? 0 : 1; } catch (const std::exception& e) { diff --git a/src/walletcmd.cpp b/src/walletcmd.cpp index eccb9b3..896d593 100644 --- a/src/walletcmd.cpp +++ b/src/walletcmd.cpp @@ -470,6 +470,184 @@ int CmdRecoveryAudit() return 0; } +struct WalletStorageAuditCounts +{ + unsigned int nTotal; + unsigned int nMalformed; + unsigned int nUnknown; + unsigned int nVersion; + unsigned int nNames; + unsigned int nTransactions; + unsigned int nPlainKeys; + unsigned int nEncryptedKeys; + unsigned int nMasterKeys; + unsigned int nDefaultKey; + unsigned int nPlainHDMaster; + unsigned int nPlainHDChainCode; + unsigned int nCryptedHDMaster; + unsigned int nCryptedHDChainCode; + unsigned int nWalletMinVersion; + unsigned int nPool; + unsigned int nSettings; + unsigned int nHDNext; + unsigned int nHDSchema; + unsigned int nHDCoinType; + unsigned int nHDReceiveNext; + unsigned int nHDChangeNext; + + WalletStorageAuditCounts() + { + memset(this, 0, sizeof(*this)); + } +}; + +static std::string HDSeedStorageState(unsigned int nMaster, unsigned int nChain) +{ + if (nMaster == 0 && nChain == 0) + return "none"; + if (nMaster > 0 && nChain > 0) + return "complete"; + return "incomplete"; +} + +static void CountWalletStorageType(const std::string& strType, + WalletStorageAuditCounts& c) +{ + if (strType == "version") + c.nVersion++; + else if (strType == "name") + c.nNames++; + else if (strType == "tx") + c.nTransactions++; + else if (strType == "key") + c.nPlainKeys++; + else if (strType == "ckey") + c.nEncryptedKeys++; + else if (strType == "mkey") + c.nMasterKeys++; + else if (strType == "defaultkey") + c.nDefaultKey++; + else if (strType == "hdmaster") + c.nPlainHDMaster++; + else if (strType == "hdchaincode") + c.nPlainHDChainCode++; + else if (strType == "cryptedhdmaster") + c.nCryptedHDMaster++; + else if (strType == "cryptedhdchaincode") + c.nCryptedHDChainCode++; + else if (strType == "walletminversion") + c.nWalletMinVersion++; + else if (strType == "pool") + c.nPool++; + else if (strType == "setting") + c.nSettings++; + else if (strType == "hdnext") + c.nHDNext++; + else if (strType == "hdschema") + c.nHDSchema++; + else if (strType == "hdcointype") + c.nHDCoinType++; + else if (strType == "hdreceivenext") + c.nHDReceiveNext++; + else if (strType == "hdchangenext") + c.nHDChangeNext++; + else + c.nUnknown++; +} + +static bool ReadWalletStorageCounts(WalletStorageAuditCounts& counts, + std::string& strError) +{ + class CWalletAuditDB : public CWalletDB + { + public: + CWalletAuditDB() : CWalletDB("r") { } + using CDB::GetCursor; + using CDB::ReadAtCursor; + }; + + CWalletAuditDB walletdb; + Dbc* pcursor = walletdb.GetCursor(); + if (!pcursor) + { + strError = "cannot open wallet.dat cursor"; + return false; + } + + for (;;) + { + CDataStream ssKey(SER_DISK); + CDataStream ssValue(SER_DISK); + int ret = walletdb.ReadAtCursor(pcursor, ssKey, ssValue); + if (ret == DB_NOTFOUND) + break; + if (ret != 0) + { + pcursor->close(); + strError = strprintf("Berkeley DB cursor read failed (%d)", ret); + return false; + } + + counts.nTotal++; + try + { + std::string strType; + ssKey >> strType; + CountWalletStorageType(strType, counts); + } + catch (...) + { + counts.nMalformed++; + } + } + + pcursor->close(); + return true; +} + +int CmdWalletStorageAudit() +{ + AttachTerminal(); + + WalletStorageAuditCounts counts; + std::string strError; + if (!ReadWalletStorageCounts(counts, strError)) + { + fprintf(stderr, "Cannot audit wallet storage: %s\n", strError.c_str()); + return 1; + } + + printf("Wallet storage audit\n"); + printf(" records total: %u\n", counts.nTotal); + printf(" malformed records: %u\n", counts.nMalformed); + printf(" unknown records: %u\n", counts.nUnknown); + printf(" plain private keys: %u\n", counts.nPlainKeys); + printf(" encrypted private keys: %u\n", counts.nEncryptedKeys); + printf(" encryption master keys: %u\n", counts.nMasterKeys); + printf(" default public key: %s\n", counts.nDefaultKey ? "present" : "none"); + printf(" plain HD seed: %s\n", + HDSeedStorageState(counts.nPlainHDMaster, + counts.nPlainHDChainCode).c_str()); + printf(" encrypted HD seed: %s\n", + HDSeedStorageState(counts.nCryptedHDMaster, + counts.nCryptedHDChainCode).c_str()); + printf(" wallet minimum version: %s\n", + counts.nWalletMinVersion ? "present" : "none"); + printf(" keypool entries: %u\n", counts.nPool); + printf(" wallet transactions: %u\n", counts.nTransactions); + printf(" address book labels: %u\n", counts.nNames); + printf(" settings: %u\n", counts.nSettings); + printf(" HD metadata records: %u\n", + counts.nHDNext + counts.nHDSchema + counts.nHDCoinType + + counts.nHDReceiveNext + counts.nHDChangeNext); + printf(" database version records: %u\n", counts.nVersion); + fflush(stdout); + + if (counts.nMalformed > 0) + return 2; + return 0; +} + int CmdEncryptWallet(const std::string& strPassphrase) { AttachTerminal(); diff --git a/src/walletcmd.h b/src/walletcmd.h index 007b8b5..4a703db 100644 --- a/src/walletcmd.h +++ b/src/walletcmd.h @@ -62,6 +62,10 @@ int CmdShowDerived(int nCount); // by the installed recovery phrase or still depends on wallet.dat-only keys. int CmdRecoveryAudit(); +// Diagnostic: count wallet.dat record types without printing keys, +// addresses, transaction ids, labels, or other wallet values. +int CmdWalletStorageAudit(); + // Rewrite wallet.dat so private keys and the HD seed are encrypted with the // given passphrase. Returns 0 on success and exits without starting the node. int CmdEncryptWallet(const std::string& strPassphrase); -- cgit v1.2.3 From 805c5757b801d80ea575c6a8b58e2855254f9046 Mon Sep 17 00:00:00 2001 From: Nsable-sh Date: Thu, 13 Aug 2026 17:40:20 -0300 Subject: wallet: export storage audit json --- src/main_gui.cpp | 10 ++++++-- src/selftest.cpp | 38 +++++++++++++++++++++++++++++ src/walletcmd.cpp | 71 ++++++++++++++++++++++++++++++++++++++++++++++++++++++- src/walletcmd.h | 2 +- 4 files changed, 117 insertions(+), 4 deletions(-) (limited to 'src/main_gui.cpp') diff --git a/src/main_gui.cpp b/src/main_gui.cpp index 532bc3b..395cc0f 100644 --- a/src/main_gui.cpp +++ b/src/main_gui.cpp @@ -174,6 +174,8 @@ static void PrintUsage() printf(" exits 2 when wallet.dat is still needed)\n"); printf(" /walletstorageaudit (count wallet.dat record types without\n"); printf(" printing wallet values, then exit)\n"); + printf(" /walletstorageauditjson=FILE\n"); + printf(" (write the same audit as deterministic JSON)\n"); printf(" /rescan (walk the chain for coins this wallet owns\n"); printf(" but never recorded, then exit)\n"); printf("\n"); @@ -706,9 +708,13 @@ int main(int argc, char* argv[]) return nRet; } - if (arg(argc,argv,"/walletstorageaudit") || arg(argc,argv,"-walletstorageaudit")) + string strWalletStorageAuditJson = + argval2(argc, argv, "/walletstorageauditjson", "-walletstorageauditjson"); + if (arg(argc,argv,"/walletstorageaudit") || + arg(argc,argv,"-walletstorageaudit") || + !strWalletStorageAuditJson.empty()) { - int nRet = CmdWalletStorageAudit(); + int nRet = CmdWalletStorageAudit(strWalletStorageAuditJson); DBFlush(true); return nRet; } diff --git a/src/selftest.cpp b/src/selftest.cpp index b112238..73c2935 100644 --- a/src/selftest.cpp +++ b/src/selftest.cpp @@ -1329,6 +1329,24 @@ static int RunWalletPortabilitySelfTest() FileContainsText(strOriginalStorageAudit, "malformed records: 0"), "the storage audit recognizes a plaintext phrase wallet") ? 0 : 1; + string strOriginalStorageAuditJson = tmp + "/original-storage-audit.json"; + vector vOriginalStorageAuditJsonArgs; + vOriginalStorageAuditJsonArgs.push_back("-datadir=" + strOriginal); + vOriginalStorageAuditJsonArgs.push_back("-nomanagedtor"); + vOriginalStorageAuditJsonArgs.push_back("-nogui"); + vOriginalStorageAuditJsonArgs.push_back("-walletstorageauditjson=" + + strOriginalStorageAuditJson); + int nOriginalStorageAuditJsonRet = + RunBitflashChild(strExe, vOriginalStorageAuditJsonArgs); + nFail += Check(nOriginalStorageAuditJsonRet == 0 && + FileContainsText(strOriginalStorageAuditJson, + "\"format\": \"bitflash-wallet-storage-audit-v1\"") && + FileContainsText(strOriginalStorageAuditJson, + "\"plain_hd_seed\": \"complete\"") && + FileContainsText(strOriginalStorageAuditJson, + "\"encrypted_hd_seed\": \"none\""), + "the storage audit can write plaintext-wallet JSON") ? 0 : 1; + string strPortableWallet = tmp + "/portable-wallet.dat"; string strBackupOut = tmp + "/backupwallet.txt"; vector vBackupArgs; @@ -1400,6 +1418,26 @@ static int RunWalletPortabilitySelfTest() FileContainsText(strEncryptedStorageAudit, "wallet minimum version: present") && FileContainsText(strEncryptedStorageAudit, "malformed records: 0"), "the storage audit recognizes an encrypted wallet") ? 0 : 1; + + string strEncryptedStorageAuditJson = tmp + "/encrypted-storage-audit.json"; + vector vEncryptedStorageAuditJsonArgs; + vEncryptedStorageAuditJsonArgs.push_back("-datadir=" + strOriginal); + vEncryptedStorageAuditJsonArgs.push_back("-nomanagedtor"); + vEncryptedStorageAuditJsonArgs.push_back("-nogui"); + vEncryptedStorageAuditJsonArgs.push_back("-walletstorageauditjson=" + + strEncryptedStorageAuditJson); + int nEncryptedStorageAuditJsonRet = + RunBitflashChild(strExe, vEncryptedStorageAuditJsonArgs); + nFail += Check(nEncryptedStorageAuditJsonRet == 0 && + FileContainsText(strEncryptedStorageAuditJson, + "\"plain_private_keys\": 0") && + FileContainsText(strEncryptedStorageAuditJson, + "\"plain_hd_seed\": \"none\"") && + FileContainsText(strEncryptedStorageAuditJson, + "\"encrypted_hd_seed\": \"complete\"") && + FileContainsText(strEncryptedStorageAuditJson, + "\"wallet_minimum_version\": \"present\""), + "the storage audit can write encrypted-wallet JSON") ? 0 : 1; } catch (const std::exception& e) { diff --git a/src/walletcmd.cpp b/src/walletcmd.cpp index 896d593..6f8258d 100644 --- a/src/walletcmd.cpp +++ b/src/walletcmd.cpp @@ -605,7 +605,60 @@ static bool ReadWalletStorageCounts(WalletStorageAuditCounts& counts, return true; } -int CmdWalletStorageAudit() +static std::string WalletStorageAuditJson(const WalletStorageAuditCounts& counts) +{ + std::string strJson; + strJson += "{\n"; + strJson += " \"format\": \"bitflash-wallet-storage-audit-v1\",\n"; + strJson += strprintf(" \"records_total\": %u,\n", counts.nTotal); + strJson += strprintf(" \"malformed_records\": %u,\n", counts.nMalformed); + strJson += strprintf(" \"unknown_records\": %u,\n", counts.nUnknown); + strJson += strprintf(" \"plain_private_keys\": %u,\n", counts.nPlainKeys); + strJson += strprintf(" \"encrypted_private_keys\": %u,\n", counts.nEncryptedKeys); + strJson += strprintf(" \"encryption_master_keys\": %u,\n", counts.nMasterKeys); + strJson += strprintf(" \"default_public_key\": \"%s\",\n", + counts.nDefaultKey ? "present" : "none"); + strJson += strprintf(" \"plain_hd_seed\": \"%s\",\n", + HDSeedStorageState(counts.nPlainHDMaster, + counts.nPlainHDChainCode).c_str()); + strJson += strprintf(" \"encrypted_hd_seed\": \"%s\",\n", + HDSeedStorageState(counts.nCryptedHDMaster, + counts.nCryptedHDChainCode).c_str()); + strJson += strprintf(" \"wallet_minimum_version\": \"%s\",\n", + counts.nWalletMinVersion ? "present" : "none"); + strJson += strprintf(" \"keypool_entries\": %u,\n", counts.nPool); + strJson += strprintf(" \"wallet_transactions\": %u,\n", counts.nTransactions); + strJson += strprintf(" \"address_book_labels\": %u,\n", counts.nNames); + strJson += strprintf(" \"settings\": %u,\n", counts.nSettings); + strJson += strprintf(" \"hd_metadata_records\": %u,\n", + counts.nHDNext + counts.nHDSchema + counts.nHDCoinType + + counts.nHDReceiveNext + counts.nHDChangeNext); + strJson += strprintf(" \"database_version_records\": %u\n", counts.nVersion); + strJson += "}\n"; + return strJson; +} + +static bool WriteAuditTextFile(const std::string& strPath, + const std::string& strText, + std::string& strError) +{ + FILE* pf = fopen(strPath.c_str(), "wb"); + if (!pf) + { + strError = "cannot open output file"; + return false; + } + size_t nWritten = fwrite(strText.data(), 1, strText.size(), pf); + bool fCloseOk = fclose(pf) == 0; + if (nWritten != strText.size() || !fCloseOk) + { + strError = "could not write the complete output file"; + return false; + } + return true; +} + +int CmdWalletStorageAudit(const std::string& strJsonOut) { AttachTerminal(); @@ -617,6 +670,22 @@ int CmdWalletStorageAudit() return 1; } + if (!strJsonOut.empty()) + { + std::string strWriteError; + if (!WriteAuditTextFile(strJsonOut, + WalletStorageAuditJson(counts), + strWriteError)) + { + fprintf(stderr, "Cannot write wallet storage audit JSON: %s\n", + strWriteError.c_str()); + return 1; + } + printf("Wallet storage audit written to %s\n", strJsonOut.c_str()); + fflush(stdout); + return counts.nMalformed > 0 ? 2 : 0; + } + printf("Wallet storage audit\n"); printf(" records total: %u\n", counts.nTotal); printf(" malformed records: %u\n", counts.nMalformed); diff --git a/src/walletcmd.h b/src/walletcmd.h index 4a703db..c6342a6 100644 --- a/src/walletcmd.h +++ b/src/walletcmd.h @@ -64,7 +64,7 @@ int CmdRecoveryAudit(); // Diagnostic: count wallet.dat record types without printing keys, // addresses, transaction ids, labels, or other wallet values. -int CmdWalletStorageAudit(); +int CmdWalletStorageAudit(const std::string& strJsonOut = ""); // Rewrite wallet.dat so private keys and the HD seed are encrypted with the // given passphrase. Returns 0 on success and exits without starting the node. -- cgit v1.2.3 From 540ed06974ff6b05364d4922895288744f076f6b Mon Sep 17 00:00:00 2001 From: Nsable-sh Date: Thu, 13 Aug 2026 18:11:30 -0300 Subject: wallet: add storage sanity check --- src/main_gui.cpp | 9 ++++ src/selftest.cpp | 27 +++++++++++ src/walletcmd.cpp | 137 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/walletcmd.h | 4 ++ 4 files changed, 177 insertions(+) (limited to 'src/main_gui.cpp') diff --git a/src/main_gui.cpp b/src/main_gui.cpp index 395cc0f..8763211 100644 --- a/src/main_gui.cpp +++ b/src/main_gui.cpp @@ -176,6 +176,8 @@ static void PrintUsage() printf(" printing wallet values, then exit)\n"); printf(" /walletstorageauditjson=FILE\n"); printf(" (write the same audit as deterministic JSON)\n"); + printf(" /walletstoragecheck (fail closed on inconsistent or unsafe\n"); + printf(" wallet.dat storage state)\n"); printf(" /rescan (walk the chain for coins this wallet owns\n"); printf(" but never recorded, then exit)\n"); printf("\n"); @@ -719,6 +721,13 @@ int main(int argc, char* argv[]) return nRet; } + if (arg(argc,argv,"/walletstoragecheck") || arg(argc,argv,"-walletstoragecheck")) + { + int nRet = CmdWalletStorageCheck(); + DBFlush(true); + return nRet; + } + if (arg(argc,argv,"/newphrase") || arg(argc,argv,"-newphrase")) { int nRet = CmdNewPhrase(); diff --git a/src/selftest.cpp b/src/selftest.cpp index 73c2935..5e5bab6 100644 --- a/src/selftest.cpp +++ b/src/selftest.cpp @@ -1329,6 +1329,19 @@ static int RunWalletPortabilitySelfTest() FileContainsText(strOriginalStorageAudit, "malformed records: 0"), "the storage audit recognizes a plaintext phrase wallet") ? 0 : 1; + string strOriginalStorageCheck = tmp + "/original-storage-check.txt"; + vector vOriginalStorageCheckArgs; + vOriginalStorageCheckArgs.push_back("-datadir=" + strOriginal); + vOriginalStorageCheckArgs.push_back("-nomanagedtor"); + vOriginalStorageCheckArgs.push_back("-nogui"); + vOriginalStorageCheckArgs.push_back("-walletstoragecheck"); + int nOriginalStorageCheckRet = + RunBitflashChild(strExe, vOriginalStorageCheckArgs, NULL, &strOriginalStorageCheck); + nFail += Check(nOriginalStorageCheckRet == 0 && + FileContainsText(strOriginalStorageCheck, "storage sanity: ok") && + FileContainsText(strOriginalStorageCheck, "plain HD seed: complete"), + "the storage sanity check accepts a plaintext phrase wallet") ? 0 : 1; + string strOriginalStorageAuditJson = tmp + "/original-storage-audit.json"; vector vOriginalStorageAuditJsonArgs; vOriginalStorageAuditJsonArgs.push_back("-datadir=" + strOriginal); @@ -1419,6 +1432,20 @@ static int RunWalletPortabilitySelfTest() FileContainsText(strEncryptedStorageAudit, "malformed records: 0"), "the storage audit recognizes an encrypted wallet") ? 0 : 1; + string strEncryptedStorageCheck = tmp + "/encrypted-storage-check.txt"; + vector vEncryptedStorageCheckArgs; + vEncryptedStorageCheckArgs.push_back("-datadir=" + strOriginal); + vEncryptedStorageCheckArgs.push_back("-nomanagedtor"); + vEncryptedStorageCheckArgs.push_back("-nogui"); + vEncryptedStorageCheckArgs.push_back("-walletstoragecheck"); + int nEncryptedStorageCheckRet = + RunBitflashChild(strExe, vEncryptedStorageCheckArgs, NULL, &strEncryptedStorageCheck); + nFail += Check(nEncryptedStorageCheckRet == 0 && + FileContainsText(strEncryptedStorageCheck, "storage sanity: ok") && + FileContainsText(strEncryptedStorageCheck, "plain private keys: 0") && + FileContainsText(strEncryptedStorageCheck, "encrypted HD seed: complete"), + "the storage sanity check accepts an encrypted wallet") ? 0 : 1; + string strEncryptedStorageAuditJson = tmp + "/encrypted-storage-audit.json"; vector vEncryptedStorageAuditJsonArgs; vEncryptedStorageAuditJsonArgs.push_back("-datadir=" + strOriginal); diff --git a/src/walletcmd.cpp b/src/walletcmd.cpp index 6f8258d..265febe 100644 --- a/src/walletcmd.cpp +++ b/src/walletcmd.cpp @@ -13,6 +13,10 @@ #include "bip32.h" #include "walletcmd.h" +#ifndef _WIN32 +#include +#endif + #undef printf // How far ahead to look for used addresses when restoring. @@ -658,6 +662,139 @@ static bool WriteAuditTextFile(const std::string& strPath, return true; } +static bool DirectoryHasFileWithPrefixLocal(const std::string& strDir, + const std::string& strPrefix) +{ +#ifdef _WIN32 + std::string pattern = strDir + "/*"; + WIN32_FIND_DATAA findData; + HANDLE hFind = FindFirstFileA(pattern.c_str(), &findData); + if (hFind == INVALID_HANDLE_VALUE) + return false; + bool fFound = false; + do + { + if ((findData.dwFileAttributes & FILE_ATTRIBUTE_DIRECTORY) == 0) + { + std::string name = findData.cFileName; + if (name.compare(0, strPrefix.size(), strPrefix) == 0) + { + fFound = true; + break; + } + } + } + while (FindNextFileA(hFind, &findData)); + FindClose(hFind); + return fFound; +#else + DIR* dir = opendir(strDir.c_str()); + if (!dir) + return false; + bool fFound = false; + struct dirent* ent; + while ((ent = readdir(dir)) != NULL) + { + std::string name = ent->d_name; + if (name.compare(0, strPrefix.size(), strPrefix) == 0) + { + fFound = true; + break; + } + } + closedir(dir); + return fFound; +#endif +} + +static void AddWalletStorageFailure(std::vector& vFailures, + const std::string& strFailure) +{ + vFailures.push_back(strFailure); +} + +static void BuildWalletStorageSanityFailures(const WalletStorageAuditCounts& counts, + std::vector& vFailures) +{ + bool fPlainHD = counts.nPlainHDMaster > 0 || counts.nPlainHDChainCode > 0; + bool fCryptedHD = counts.nCryptedHDMaster > 0 || counts.nCryptedHDChainCode > 0; + bool fEncryptedRecords = counts.nEncryptedKeys > 0 || + counts.nMasterKeys > 0 || + fCryptedHD; + + if (counts.nMalformed > 0) + AddWalletStorageFailure(vFailures, "wallet.dat contains malformed records"); + if (counts.nUnknown > 0) + AddWalletStorageFailure(vFailures, "wallet.dat contains unknown records"); + if ((counts.nPlainHDMaster > 0) != (counts.nPlainHDChainCode > 0)) + AddWalletStorageFailure(vFailures, "plain HD seed is incomplete"); + if ((counts.nCryptedHDMaster > 0) != (counts.nCryptedHDChainCode > 0)) + AddWalletStorageFailure(vFailures, "encrypted HD seed is incomplete"); + if (fPlainHD && fCryptedHD) + AddWalletStorageFailure(vFailures, "wallet.dat contains both plain and encrypted HD seed records"); + if (counts.nDefaultKey > 1) + AddWalletStorageFailure(vFailures, "wallet.dat contains more than one default public key record"); + if (counts.nWalletMinVersion > 1) + AddWalletStorageFailure(vFailures, "wallet.dat contains more than one minimum-version record"); + + if (fEncryptedRecords) + { + if (counts.nPlainKeys > 0) + AddWalletStorageFailure(vFailures, "encrypted wallet still contains plain private key records"); + if (fPlainHD) + AddWalletStorageFailure(vFailures, "encrypted wallet still contains plain HD seed records"); + if (counts.nMasterKeys == 0) + AddWalletStorageFailure(vFailures, "encrypted wallet has no encryption master key record"); + if (counts.nWalletMinVersion == 0) + AddWalletStorageFailure(vFailures, "encrypted wallet has no minimum-version record"); + if (DirectoryHasFileWithPrefixLocal(GetAppDir() + "/database", "log.")) + AddWalletStorageFailure(vFailures, "Berkeley DB log files are still present for an encrypted wallet"); + } +} + +int CmdWalletStorageCheck() +{ + AttachTerminal(); + + WalletStorageAuditCounts counts; + std::string strError; + if (!ReadWalletStorageCounts(counts, strError)) + { + fprintf(stderr, "Cannot check wallet storage: %s\n", strError.c_str()); + return 1; + } + + std::vector vFailures; + BuildWalletStorageSanityFailures(counts, vFailures); + + printf("Wallet storage sanity check\n"); + printf(" records total: %u\n", counts.nTotal); + printf(" encrypted records: %s\n", + (counts.nEncryptedKeys || counts.nMasterKeys || + counts.nCryptedHDMaster || counts.nCryptedHDChainCode) ? "yes" : "no"); + printf(" plain private keys: %u\n", counts.nPlainKeys); + printf(" encrypted private keys: %u\n", counts.nEncryptedKeys); + printf(" plain HD seed: %s\n", + HDSeedStorageState(counts.nPlainHDMaster, + counts.nPlainHDChainCode).c_str()); + printf(" encrypted HD seed: %s\n", + HDSeedStorageState(counts.nCryptedHDMaster, + counts.nCryptedHDChainCode).c_str()); + if (vFailures.empty()) + { + printf(" storage sanity: ok\n"); + fflush(stdout); + return 0; + } + + printf(" storage sanity: failed\n"); + for (std::vector::const_iterator it = vFailures.begin(); + it != vFailures.end(); ++it) + printf(" failure: %s\n", it->c_str()); + fflush(stdout); + return 2; +} + int CmdWalletStorageAudit(const std::string& strJsonOut) { AttachTerminal(); diff --git a/src/walletcmd.h b/src/walletcmd.h index c6342a6..6077669 100644 --- a/src/walletcmd.h +++ b/src/walletcmd.h @@ -66,6 +66,10 @@ int CmdRecoveryAudit(); // addresses, transaction ids, labels, or other wallet values. int CmdWalletStorageAudit(const std::string& strJsonOut = ""); +// Diagnostic: fail closed when wallet.dat contains an internally inconsistent +// or migration-unsafe mix of storage records. +int CmdWalletStorageCheck(); + // Rewrite wallet.dat so private keys and the HD seed are encrypted with the // given passphrase. Returns 0 on success and exits without starting the node. int CmdEncryptWallet(const std::string& strPassphrase); -- cgit v1.2.3 From 83b9c4feddeac14bbc5edf63b44d3a2013384ecc Mon Sep 17 00:00:00 2001 From: Nsable-sh Date: Fri, 14 Aug 2026 11:51:14 -0300 Subject: tests: prove wallet storage sanity failures --- src/main_gui.cpp | 50 +++++++++------ src/selftest.cpp | 187 +++++++++++++++++++++++++++++++++++++++++++++++++++++- src/selftest.h | 1 + src/walletcmd.cpp | 5 ++ 4 files changed, 222 insertions(+), 21 deletions(-) (limited to 'src/main_gui.cpp') diff --git a/src/main_gui.cpp b/src/main_gui.cpp index 8763211..feda428 100644 --- a/src/main_gui.cpp +++ b/src/main_gui.cpp @@ -113,8 +113,9 @@ static void PrintUsage() printf(" /debug\n"); printf(" /gen\n"); printf(" /nogui or /daemon\n"); - printf(" /selftest=wallet-keypool, wallet-hd, wallet-format, wallet-crypto, wallet-encrypt,\n"); - printf(" wallet-portability, net-message, consensus-limits, pool-stratum,\n"); + printf(" /selftest=wallet-keypool, wallet-hd, wallet-format, wallet-storage-sanity,\n"); + printf(" wallet-crypto, wallet-encrypt, wallet-portability, net-message,\n"); + printf(" consensus-limits, pool-stratum,\n"); printf(" parse-money, socks5-proxy, or managed-tor\n"); printf("\n"); printf("Mining mode:\n"); @@ -495,10 +496,37 @@ int main(int argc, char* argv[]) ParseStartupArguments(argc, argv); + // Hidden self-test helper. It must run before LoadWallet(): several + // storage-sanity scenarios deliberately make wallet.dat unsafe to load. + string strSelfTestMutateWallet = + argval2(argc, argv, "/selftestmutatewallet", "-selftestmutatewallet"); + if (!strSelfTestMutateWallet.empty()) + return RunSelfTestMutateWallet(strSelfTestMutateWallet); + string strSelfTest = argval2(argc, argv, "/selftest", "-selftest"); if (!strSelfTest.empty()) return RunSelfTest(strSelfTest); + // Storage diagnostics must run before LoadWallet(). Their job is to + // explain wallet.dat states that the normal wallet loader may refuse. + string strWalletStorageAuditJson = + argval2(argc, argv, "/walletstorageauditjson", "-walletstorageauditjson"); + if (arg(argc,argv,"/walletstorageaudit") || + arg(argc,argv,"-walletstorageaudit") || + !strWalletStorageAuditJson.empty()) + { + int nRet = CmdWalletStorageAudit(strWalletStorageAuditJson); + DBFlush(true); + return nRet; + } + + if (arg(argc,argv,"/walletstoragecheck") || arg(argc,argv,"-walletstoragecheck")) + { + int nRet = CmdWalletStorageCheck(); + DBFlush(true); + return nRet; + } + // Berkeley DB reports failure by throwing, and CDB's constructor lets it // through. Nothing on this path caught anything, so an unreadable // blkindex.dat or wallet.dat -- one written by another platform's Berkeley @@ -710,24 +738,6 @@ int main(int argc, char* argv[]) return nRet; } - string strWalletStorageAuditJson = - argval2(argc, argv, "/walletstorageauditjson", "-walletstorageauditjson"); - if (arg(argc,argv,"/walletstorageaudit") || - arg(argc,argv,"-walletstorageaudit") || - !strWalletStorageAuditJson.empty()) - { - int nRet = CmdWalletStorageAudit(strWalletStorageAuditJson); - DBFlush(true); - return nRet; - } - - if (arg(argc,argv,"/walletstoragecheck") || arg(argc,argv,"-walletstoragecheck")) - { - int nRet = CmdWalletStorageCheck(); - DBFlush(true); - return nRet; - } - if (arg(argc,argv,"/newphrase") || arg(argc,argv,"-newphrase")) { int nRet = CmdNewPhrase(); diff --git a/src/selftest.cpp b/src/selftest.cpp index 81c4439..81aa480 100644 --- a/src/selftest.cpp +++ b/src/selftest.cpp @@ -736,6 +736,189 @@ static int RunWalletFormatSelfTest() return nFail == 0 ? 0 : 1; } +static bool FileContainsText(const string& strPath, const string& strNeedle); +static bool MakeDirLocal(const string& strPath); +static int RunBitflashChild(const string& strExe, + const vector& vArgs, + const string* pStdinFile, + const string* pOutputFile); + +class CWalletStorageSanityRawDB : public CWalletDB +{ +public: + CWalletStorageSanityRawDB(const char* pszMode="r+") + : CWalletDB(pszMode) { } + + bool WriteUnknownRecord() + { + return Write(string("storage-sanity-unknown"), 1); + } + + bool WriteMalformedKeyRecord() + { + if (!pdb) + return false; + unsigned char chKey = 0xff; + unsigned char chValue = 0x01; + Dbt datKey(&chKey, 1); + Dbt datValue(&chValue, 1); + return pdb->put(GetTxn(), &datKey, &datValue, 0) == 0; + } + + bool MakePlainHDSeedIncomplete() + { + return Erase(string("hdchaincode")); + } + + bool AddEncryptedMarkerToPlainWallet() + { + CWalletMasterKey kMasterKey; + kMasterKey.vchCryptedKey.resize(48); + for (size_t i = 0; i < kMasterKey.vchCryptedKey.size(); i++) + kMasterKey.vchCryptedKey[i] = (unsigned char)(0x80 + (i & 0x3f)); + for (size_t i = 0; i < kMasterKey.vchSalt.size(); i++) + kMasterKey.vchSalt[i] = (unsigned char)(0x40 + i); + return WriteWalletMinVersion(WALLET_FORMAT_ENCRYPTED) && + WriteMasterKey(1, kMasterKey); + } +}; + +int RunSelfTestMutateWallet(const std::string& name) +{ + bool fOk = false; + try + { + CWalletStorageSanityRawDB walletdb; + if (name == "unknown-record") + fOk = walletdb.WriteUnknownRecord(); + else if (name == "malformed-key") + fOk = walletdb.WriteMalformedKeyRecord(); + else if (name == "incomplete-plain-hd") + fOk = walletdb.MakePlainHDSeedIncomplete(); + else if (name == "encrypted-marker-with-plain-keys") + fOk = walletdb.AddEncryptedMarkerToPlainWallet(); + else + { + fprintf(stderr, "Unknown wallet storage mutation '%s'\n", name.c_str()); + return 1; + } + } + catch (const std::exception& e) + { + fprintf(stderr, "Cannot mutate wallet storage: %s\n", e.what()); + return 1; + } + catch (...) + { + fprintf(stderr, "Cannot mutate wallet storage: unknown exception\n"); + return 1; + } + + DBFlush(true); + return fOk ? 0 : 1; +} + +static int RunWalletStorageSanitySelfTest() +{ + fflush(stdout); + printf("wallet-storage-sanity self-test\n"); + + std::string tmp; + std::string cwd; + if (!MakeTempDir(tmp)) + { + printf(" FAIL could not create a temporary data directory\n"); + return 1; + } + if (!GetCurrentDir(cwd)) + { + printf(" FAIL could not read current directory\n"); + RemoveTree(tmp); + return 1; + } + + int nFail = 0; + printf(" temp root: %s\n", tmp.c_str()); + + try + { +#ifdef _WIN32 + string strExe = cwd + "\\bitflash.exe"; +#else + string strExe = cwd + "/bitflash-node"; +#endif + + struct StorageScenario + { + const char* pszName; + const char* pszExpectedFailure; + }; + + StorageScenario scenarios[] = { + { "unknown-record", "wallet.dat contains unknown records" }, + { "malformed-key", "wallet.dat contains malformed records" }, + { "incomplete-plain-hd", "plain HD seed is incomplete" }, + { "encrypted-marker-with-plain-keys", + "encrypted wallet still contains plain private key records" }, + }; + + for (size_t i = 0; i < sizeof(scenarios)/sizeof(scenarios[0]); i++) + { + string strScenarioDir = tmp + "/" + scenarios[i].pszName; + nFail += Check(MakeDirLocal(strScenarioDir), + "scenario datadir can be created") ? 0 : 1; + + string strCreateOut = tmp + "/" + scenarios[i].pszName + "-create.txt"; + vector vCreateArgs; + vCreateArgs.push_back("-datadir=" + strScenarioDir); + vCreateArgs.push_back("-nomanagedtor"); + vCreateArgs.push_back("-nogui"); + vCreateArgs.push_back("-newphrase"); + int nCreateRet = RunBitflashChild(strExe, vCreateArgs, NULL, &strCreateOut); + nFail += Check(nCreateRet == 0 && + FileContainsText(strCreateOut, "Write these twelve words down"), + "scenario phrase wallet can be created") ? 0 : 1; + + string strMutateOut = tmp + "/" + scenarios[i].pszName + "-mutate.txt"; + vector vMutateArgs; + vMutateArgs.push_back("-datadir=" + strScenarioDir); + vMutateArgs.push_back("-nomanagedtor"); + vMutateArgs.push_back("-nogui"); + vMutateArgs.push_back("-selftestmutatewallet=" + string(scenarios[i].pszName)); + int nMutateRet = RunBitflashChild(strExe, vMutateArgs, NULL, &strMutateOut); + nFail += Check(nMutateRet == 0, scenarios[i].pszName) ? 0 : 1; + + string strCheckOut = tmp + "/" + scenarios[i].pszName + "-check.txt"; + vector vCheckArgs; + vCheckArgs.push_back("-datadir=" + strScenarioDir); + vCheckArgs.push_back("-nomanagedtor"); + vCheckArgs.push_back("-nogui"); + vCheckArgs.push_back("-walletstoragecheck"); + int nCheckRet = RunBitflashChild(strExe, vCheckArgs, NULL, &strCheckOut); + nFail += Check(nCheckRet == 2 && + FileContainsText(strCheckOut, "storage sanity: failed") && + FileContainsText(strCheckOut, scenarios[i].pszExpectedFailure), + scenarios[i].pszExpectedFailure) ? 0 : 1; + } + } + catch (const std::exception& e) + { + printf(" FAIL exception: %s\n", e.what()); + nFail++; + } + catch (...) + { + printf(" FAIL unknown exception\n"); + nFail++; + } + + RemoveTree(tmp); + printf("%s (%d failure%s)\n", nFail == 0 ? "ALL TESTS PASSED" : "TESTS FAILED", + nFail, nFail == 1 ? "" : "s"); + fflush(stdout); + return nFail == 0 ? 0 : 1; +} + static void ClearWalletRuntimeForTest() { CRITICAL_BLOCK(cs_mapKeys) @@ -1991,6 +2174,8 @@ int RunSelfTest(const std::string& name) return RunWalletHDSelfTest(); if (name == "wallet-format") return RunWalletFormatSelfTest(); + if (name == "wallet-storage-sanity") + return RunWalletStorageSanitySelfTest(); if (name == "wallet-crypto") return RunWalletCryptoSelfTest(); if (name == "wallet-encrypt") @@ -2011,6 +2196,6 @@ int RunSelfTest(const std::string& name) return RunManagedTorSelfTest(); printf("Unknown self-test '%s'\n", name.c_str()); - printf("Known self-tests: wallet-keypool, wallet-hd, wallet-format, wallet-crypto, wallet-encrypt, wallet-portability, net-message, consensus-limits, pool-stratum, parse-money, socks5-proxy, managed-tor\n"); + printf("Known self-tests: wallet-keypool, wallet-hd, wallet-format, wallet-storage-sanity, wallet-crypto, wallet-encrypt, wallet-portability, net-message, consensus-limits, pool-stratum, parse-money, socks5-proxy, managed-tor\n"); return 1; } diff --git a/src/selftest.h b/src/selftest.h index d3699c6..0ffe232 100644 --- a/src/selftest.h +++ b/src/selftest.h @@ -7,5 +7,6 @@ #include int RunSelfTest(const std::string& name); +int RunSelfTestMutateWallet(const std::string& name); #endif diff --git a/src/walletcmd.cpp b/src/walletcmd.cpp index 71f1ec3..6fb09a3 100644 --- a/src/walletcmd.cpp +++ b/src/walletcmd.cpp @@ -573,6 +573,11 @@ static bool ReadWalletStorageCounts(WalletStorageAuditCounts& counts, CDataStream ssKey = ssKeyIn; std::string strType; ssKey >> strType; + if (ssKey.fail()) + { + counts.nMalformed++; + return true; + } CountWalletStorageType(strType, counts); } catch (...) -- cgit v1.2.3 From 82c3e10da01260650ddd2944e3f895ccf2c07ffc Mon Sep 17 00:00:00 2001 From: Nsable-sh Date: Fri, 14 Aug 2026 12:59:03 -0300 Subject: db: recreate Berkeley DB environment after shutdown --- src/db.cpp | 93 +++++++++++++++++++++++++++++++++++++------------------- src/db.h | 4 +-- src/main_gui.cpp | 3 +- src/selftest.cpp | 66 +++++++++++++++++++++++++++++++++++++++- 4 files changed, 130 insertions(+), 36 deletions(-) (limited to 'src/main_gui.cpp') diff --git a/src/db.cpp b/src/db.cpp index 0ff58e7..8b82fe5 100644 --- a/src/db.cpp +++ b/src/db.cpp @@ -52,9 +52,34 @@ static bool fDbEnvInit = false; // Why LoadWallet() refused, in words meant for the person who has to act on it. // Empty unless the reason is one we can name. string strWalletLoadError; -DbEnv dbenv(0u); +static DbEnv* pdbenv = NULL; +static FILE* pdbErrFile = NULL; static map mapFileUseCount; +DbEnv& GetDbEnv() +{ + if (!pdbenv) + pdbenv = new DbEnv(0u); + return *pdbenv; +} + +static void CloseDbEnv() +{ + if (pdbenv) + { + try { pdbenv->close(0); } + catch (...) { } + delete pdbenv; + pdbenv = NULL; + } + if (pdbErrFile) + { + fclose(pdbErrFile); + pdbErrFile = NULL; + } + fDbEnvInit = false; +} + class CDBInit { public: @@ -64,11 +89,7 @@ public: ~CDBInit() { if (fDbEnvInit) - { - try { dbenv.close(0); } - catch (...) { } - fDbEnvInit = false; - } + CloseDbEnv(); } } instance_of_cdbinit; @@ -104,24 +125,30 @@ CDB::CDB(const char* pszFile, const char* pszMode, bool fTxn) : pdb(NULL) printf("dbenv.open strAppDir=%s\n", strAppDir.c_str()); - dbenv.set_lg_dir(strLogDir.c_str()); - dbenv.set_lg_max(10000000); - dbenv.set_lk_max_locks(10000); - dbenv.set_lk_max_objects(10000); - dbenv.set_errfile(fopen("db.log", "a")); /// debug - ///dbenv.log_set_config(DB_LOG_AUTO_REMOVE, 1); /// causes corruption - ret = dbenv.open(strAppDir.c_str(), - DB_CREATE | - DB_INIT_LOCK | - DB_INIT_LOG | - DB_INIT_MPOOL | - DB_INIT_TXN | - DB_THREAD | - DB_PRIVATE | - DB_RECOVER, - 0); + DbEnv& env = GetDbEnv(); + env.set_lg_dir(strLogDir.c_str()); + env.set_lg_max(10000000); + env.set_lk_max_locks(10000); + env.set_lk_max_objects(10000); + pdbErrFile = fopen("db.log", "a"); /// debug + if (pdbErrFile) + env.set_errfile(pdbErrFile); + ///env.log_set_config(DB_LOG_AUTO_REMOVE, 1); /// causes corruption + ret = env.open(strAppDir.c_str(), + DB_CREATE | + DB_INIT_LOCK | + DB_INIT_LOG | + DB_INIT_MPOOL | + DB_INIT_TXN | + DB_THREAD | + DB_PRIVATE | + DB_RECOVER, + 0); if (ret > 0) + { + CloseDbEnv(); throw runtime_error(strprintf("CDB() : error %d opening database environment\n", ret)); + } fDbEnvInit = true; // A Berkeley DB file records, in every page header, a log sequence @@ -151,7 +178,7 @@ CDB::CDB(const char* pszFile, const char* pszMode, bool fTxn) : pdb(NULL) continue; try { - if (dbenv.lsn_reset(pszName, 0) == 0) + if (env.lsn_reset(pszName, 0) == 0) printf("CDB() : adopted %s from another environment " "(log sequence numbers reset)\n", pszName); } @@ -164,7 +191,7 @@ CDB::CDB(const char* pszFile, const char* pszMode, bool fTxn) : pdb(NULL) ++mapFileUseCount[strFile]; } - pdb = new Db(&dbenv, 0); + pdb = new Db(&GetDbEnv(), 0); ret = pdb->open(NULL, // Txn pointer pszFile, // Filename @@ -199,7 +226,8 @@ void CDB::Close() try { pdb->close(0); } catch (...) { } delete pdb; pdb = NULL; - try { dbenv.txn_checkpoint(0, 0, 0); } catch (...) { } + if (fDbEnvInit) + try { GetDbEnv().txn_checkpoint(0, 0, 0); } catch (...) { } CRITICAL_BLOCK(cs_db) --mapFileUseCount[strFile]; @@ -279,7 +307,8 @@ void DBFlush(bool fShutdown) // takes the process down before the wallet has been put down cleanly, // which is the difference between a portable wallet.dat and one welded // to this directory -- the lsn_reset just below is what frees it. - try { dbenv.txn_checkpoint(0, 0, 0); } + DbEnv& env = GetDbEnv(); + try { env.txn_checkpoint(0, 0, 0); } catch (const std::exception& e) { printf("DBFlush() : checkpoint failed: %s\n", e.what()); } @@ -290,7 +319,7 @@ void DBFlush(bool fShutdown) int nRefCount = (*mi).second; if (nRefCount == 0) { - try { dbenv.lsn_reset(strFile.c_str(), 0); } + try { env.lsn_reset(strFile.c_str(), 0); } catch (const std::exception& e) { printf("DBFlush() : lsn_reset(%s) failed: %s\n", strFile.c_str(), e.what()); } mapFileUseCount.erase(mi++); @@ -310,9 +339,8 @@ void DBFlush(bool fShutdown) // Found while making wallet encryption clean up after itself: // calling DBFlush(true) a second time crashed here every time. if (mapFileUseCount.empty()) - try { dbenv.log_archive(NULL, DB_ARCH_REMOVE); } catch (...) { } - try { dbenv.close(0); } catch (...) { } - fDbEnvInit = false; + try { env.log_archive(NULL, DB_ARCH_REMOVE); } catch (...) { } + CloseDbEnv(); } } } @@ -1042,7 +1070,8 @@ bool BackupWallet(const string& strDest) // Committing first, so the copy is not missing the newest records. // Both calls can throw, and a failed backup must not take the node // down with it. - try { dbenv.txn_checkpoint(0, 0, 0); } + DbEnv& env = GetDbEnv(); + try { env.txn_checkpoint(0, 0, 0); } catch (const std::exception& e) { return error("BackupWallet() : checkpoint failed: %s\n", e.what()); } @@ -1077,7 +1106,7 @@ bool BackupWallet(const string& strDest) } // Without this the copy is welded to this node's database/ dir. - int ret = dbenv.lsn_reset(strTemp.c_str(), 0); + int ret = env.lsn_reset(strTemp.c_str(), 0); if (ret != 0) printf("BackupWallet() : warning -- lsn_reset returned %d; the copy " "may only open beside this node's database/ directory\n", ret); diff --git a/src/db.h b/src/db.h index 2816957..53bda59 100644 --- a/src/db.h +++ b/src/db.h @@ -48,7 +48,7 @@ public: }; -extern DbEnv dbenv; +extern DbEnv& GetDbEnv(); extern void DBFlush(bool fShutdown); // Close the environment and delete its logs. Only the encryption command calls // this: the logs hold the plaintext records the old wallet wrote. @@ -237,7 +237,7 @@ public: if (!pdb) return false; DbTxn* ptxn = NULL; - int ret = dbenv.txn_begin(GetTxn(), &ptxn, 0); + int ret = GetDbEnv().txn_begin(GetTxn(), &ptxn, 0); if (!ptxn || ret != 0) return false; vTxn.push_back(ptxn); diff --git a/src/main_gui.cpp b/src/main_gui.cpp index feda428..705b4ef 100644 --- a/src/main_gui.cpp +++ b/src/main_gui.cpp @@ -114,7 +114,8 @@ static void PrintUsage() printf(" /gen\n"); printf(" /nogui or /daemon\n"); printf(" /selftest=wallet-keypool, wallet-hd, wallet-format, wallet-storage-sanity,\n"); - printf(" wallet-crypto, wallet-encrypt, wallet-portability, net-message,\n"); + printf(" db-env-reopen, wallet-crypto, wallet-encrypt, wallet-portability,\n"); + printf(" net-message,\n"); printf(" consensus-limits, pool-stratum,\n"); printf(" parse-money, socks5-proxy, or managed-tor\n"); printf("\n"); diff --git a/src/selftest.cpp b/src/selftest.cpp index 54249b1..31c168f 100644 --- a/src/selftest.cpp +++ b/src/selftest.cpp @@ -968,6 +968,68 @@ static int RunWalletStorageSanitySelfTest() return nFail == 0 ? 0 : 1; } +static int RunDbEnvReopenSelfTest() +{ + fflush(stdout); + printf("db-env-reopen self-test\n"); + + std::string tmp; + if (!MakeTempDir(tmp)) + { + printf(" FAIL could not create a temporary data directory\n"); + return 1; + } + + int nFail = 0; + std::string strOldDataDir = strSetDataDir; + strSetDataDir = tmp; + printf(" temp datadir: %s\n", tmp.c_str()); + + try + { + for (int i = 0; i < 5; i++) + { + int64 nValue = 1000 + i; + { + CWalletDB walletdb("cr+"); + nFail += Check(walletdb.WriteSetting("db-env-reopen-cycle", nValue), + "wallet setting can be written before shutdown") ? 0 : 1; + } + + DBFlush(true); + + int64 nRead = 0; + { + CWalletDB walletdb("r"); + nFail += Check(walletdb.ReadSetting("db-env-reopen-cycle", nRead), + "wallet setting can be read after env reopen") ? 0 : 1; + } + nFail += Check(nRead == nValue, + "reopened environment reads the last committed value") ? 0 : 1; + + DBFlush(true); + } + } + catch (const std::exception& e) + { + printf(" FAIL exception: %s\n", e.what()); + nFail++; + } + catch (...) + { + printf(" FAIL unknown exception\n"); + nFail++; + } + + DBFlush(true); + strSetDataDir = strOldDataDir; + RemoveTree(tmp); + printf("%s (%d failure%s)\n", nFail == 0 ? "ALL TESTS PASSED" : "TESTS FAILED", + nFail, nFail == 1 ? "" : "s"); + fflush(stdout); + return nFail == 0 ? 0 : 1; +} + static void ClearWalletRuntimeForTest() { CRITICAL_BLOCK(cs_mapKeys) @@ -2225,6 +2287,8 @@ int RunSelfTest(const std::string& name) return RunWalletFormatSelfTest(); if (name == "wallet-storage-sanity") return RunWalletStorageSanitySelfTest(); + if (name == "db-env-reopen") + return RunDbEnvReopenSelfTest(); if (name == "wallet-crypto") return RunWalletCryptoSelfTest(); if (name == "wallet-encrypt") @@ -2245,6 +2309,6 @@ int RunSelfTest(const std::string& name) return RunManagedTorSelfTest(); printf("Unknown self-test '%s'\n", name.c_str()); - printf("Known self-tests: wallet-keypool, wallet-hd, wallet-format, wallet-storage-sanity, wallet-crypto, wallet-encrypt, wallet-portability, net-message, consensus-limits, pool-stratum, parse-money, socks5-proxy, managed-tor\n"); + printf("Known self-tests: wallet-keypool, wallet-hd, wallet-format, wallet-storage-sanity, db-env-reopen, wallet-crypto, wallet-encrypt, wallet-portability, net-message, consensus-limits, pool-stratum, parse-money, socks5-proxy, managed-tor\n"); return 1; } -- cgit v1.2.3 From c49b92a9a79a398b51200e69f7dde8a467b9c925 Mon Sep 17 00:00:00 2001 From: Nsable-sh Date: Fri, 14 Aug 2026 15:03:21 -0300 Subject: wallet: add experimental SQLite record store --- .github/workflows/ci.yml | 1 + Makefile | 8 +- docs/storage.md | 19 ++++ src/Makefile | 10 +- src/main_gui.cpp | 3 +- src/makefile.mingw | 8 +- src/selftest.cpp | 108 +++++++++++++++++++- src/wallet_sqlite.cpp | 254 +++++++++++++++++++++++++++++++++++++++++++++++ src/wallet_sqlite.h | 40 ++++++++ 9 files changed, 439 insertions(+), 12 deletions(-) create mode 100644 src/wallet_sqlite.cpp create mode 100644 src/wallet_sqlite.h (limited to 'src/main_gui.cpp') diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f4045f4..e22b5a4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -56,6 +56,7 @@ jobs: mingw-w64-ucrt-x86_64-glfw mingw-w64-ucrt-x86_64-openssl mingw-w64-ucrt-x86_64-db + mingw-w64-ucrt-x86_64-sqlite3 mingw-w64-ucrt-x86_64-libsodium mingw-w64-ucrt-x86_64-nlohmann-json mingw-w64-ucrt-x86_64-boost diff --git a/Makefile b/Makefile index 60d1227..fab2507 100644 --- a/Makefile +++ b/Makefile @@ -43,10 +43,11 @@ deps-apt: @if command -v apt-get >/dev/null 2>&1; then \ $(SUDO) apt-get install -y build-essential cmake git pkg-config autoconf \ libtool libssl-dev libdb5.3++-dev libsodium-dev nlohmann-json3-dev \ - libboost-system-dev libglfw3-dev libgl-dev python3-pil clang; \ + libboost-system-dev libglfw3-dev libgl-dev libsqlite3-dev \ + python3-pil clang; \ else \ echo "Not an apt system — install: g++ cmake git autoconf libtool"; \ - echo " libssl libdb++ libsodium nlohmann-json boost glfw3 opengl python3-pil"; \ + echo " libssl libdb++ libsodium nlohmann-json boost glfw3 opengl sqlite3 python3-pil"; \ fi deps-secp256k1: @@ -113,7 +114,7 @@ img = ico.convert('RGBA'); \ img.save('/tmp/bitflash.AppDir/bitflash.png'); \ import shutil; shutil.copy('/tmp/bitflash.AppDir/bitflash.png', \ '/tmp/bitflash.AppDir/usr/share/icons/hicolor/256x256/apps/bitflash.png')" - @for lib in libsodium.so.23 libdb_cxx-5.3.so libglfw.so.3; do \ + @for lib in libsodium.so.23 libdb_cxx-5.3.so libsqlite3.so.0 libglfw.so.3; do \ path=$$(ldconfig -p | grep " $$lib " | awk '{print $$NF}' | head -1); \ [ -z "$$path" ] && path=$$(find /usr/lib /lib -name "$$lib" 2>/dev/null | head -1); \ [ -n "$$path" ] && cp -L "$$path" /tmp/bitflash.AppDir/usr/lib/ && echo " bundled $$lib" || echo " missing $$lib"; \ @@ -131,6 +132,7 @@ deps-windows: mingw-w64-ucrt-x86_64-glfw \ mingw-w64-ucrt-x86_64-openssl \ mingw-w64-ucrt-x86_64-db \ + mingw-w64-ucrt-x86_64-sqlite3 \ mingw-w64-ucrt-x86_64-libsodium \ mingw-w64-ucrt-x86_64-nlohmann-json \ mingw-w64-ucrt-x86_64-boost \ diff --git a/docs/storage.md b/docs/storage.md index c1cda45..f75f9b9 100644 --- a/docs/storage.md +++ b/docs/storage.md @@ -85,6 +85,25 @@ A future wallet backend must keep these properties: - recovery phrase encrypted and locked, requiring unlock before coverage can be audited. +## Experimental SQLite Wallet Store + +The SQLite wallet path starts as a byte-preserving record store, not as a new +wallet format: + +```sql +CREATE TABLE wallet_records ( + key BLOB PRIMARY KEY NOT NULL, + value BLOB NOT NULL +); +PRAGMA user_version=1; +``` + +Keys and values are the same serialized `CDataStream` bytes used by `wallet.dat`. +The first implementation is exercised only by `-selftest=wallet-sqlite`; the +runtime wallet still opens Berkeley DB. The purpose is to prove schema creation, +raw record write/read, close/reopen behavior, and value preservation before any +BDB-to-SQLite migration or runtime backend flag exists. + ## `blkindex.dat` `blkindex.dat` is accessed through `CTxDB`. diff --git a/src/Makefile b/src/Makefile index 19ad33a..cfa40f6 100644 --- a/src/Makefile +++ b/src/Makefile @@ -40,7 +40,7 @@ FUZZ_LDFLAGS = -fsanitize=fuzzer,address,undefined LIBS = -L$(SECP_LIB) -lsecp256k1 \ -L$(RANDOMX_DIR)/build -lrandomx \ - -lsodium -lssl -lcrypto -ldb_cxx -lpthread \ + -lsodium -lssl -lcrypto -ldb_cxx -lsqlite3 -lpthread \ -lglfw -lGL # Every header a .cpp here includes has to be listed, not just the interesting @@ -53,7 +53,7 @@ LIBS = -L$(SECP_LIB) -lsecp256k1 \ HEADERS = headers_core.h headers.h util.h main.h serialize.h uint256.h \ key.h bignum.h script.h db.h base58.h net.h nostr.h \ randomx_pow.h btfaddr.h btfchan.h btfrv.h btftunnel.h market.h \ - selftest.h walletcmd.h compat.h sockcount.h proxy.h tor.h font_roboto.h + selftest.h walletcmd.h wallet_sqlite.h compat.h sockcount.h proxy.h tor.h font_roboto.h IMGUI_OBJS = obj/imgui.o obj/imgui_draw.o obj/imgui_tables.o \ obj/imgui_widgets.o obj/imgui_impl_glfw.o obj/imgui_impl_opengl3.o @@ -61,7 +61,8 @@ IMGUI_OBJS = obj/imgui.o obj/imgui_draw.o obj/imgui_tables.o \ NODE_OBJS = obj/util.o obj/script.o obj/db.o obj/net.o obj/main.o \ obj/market.o obj/sha.o obj/nostr.o obj/randomx_pow.o \ obj/btfaddr.o obj/btfchan.o obj/btfrv.o obj/btftunnel.o obj/rpc.o \ - obj/selftest.o obj/bip32.o obj/walletcmd.o obj/proxy.o obj/tor.o + obj/selftest.o obj/bip32.o obj/walletcmd.o obj/wallet_sqlite.o \ + obj/proxy.o obj/tor.o GUI_OBJS = obj/gui.o obj/main_gui.o @@ -116,7 +117,7 @@ bitflash: $(IMGUI_OBJS) $(NODE_OBJS) $(GUI_OBJS) NODE_LIBS = -L$(SECP_LIB) -lsecp256k1 \ -L$(RANDOMX_DIR)/build -lrandomx \ - -lsodium -lssl -lcrypto -ldb_cxx -lpthread + -lsodium -lssl -lcrypto -ldb_cxx -lsqlite3 -lpthread NODE_ONLY_OBJS = $(patsubst obj/%,obj-node/%,$(NODE_OBJS)) \ obj-node/main_gui.o obj-node/headless.o @@ -187,6 +188,7 @@ tests: test_bip32 bitflash-node ./bitflash-node -selftest=wallet-keypool ./bitflash-node -selftest=wallet-hd ./bitflash-node -selftest=wallet-format + ./bitflash-node -selftest=wallet-sqlite ./bitflash-node -selftest=wallet-crypto ./bitflash-node -selftest=wallet-encrypt ./bitflash-node -selftest=net-message diff --git a/src/main_gui.cpp b/src/main_gui.cpp index 705b4ef..965c818 100644 --- a/src/main_gui.cpp +++ b/src/main_gui.cpp @@ -114,7 +114,8 @@ static void PrintUsage() printf(" /gen\n"); printf(" /nogui or /daemon\n"); printf(" /selftest=wallet-keypool, wallet-hd, wallet-format, wallet-storage-sanity,\n"); - printf(" db-env-reopen, wallet-crypto, wallet-encrypt, wallet-portability,\n"); + printf(" db-env-reopen, wallet-sqlite, wallet-crypto, wallet-encrypt,\n"); + printf(" wallet-portability,\n"); printf(" net-message,\n"); printf(" consensus-limits, pool-stratum,\n"); printf(" parse-money, socks5-proxy, or managed-tor\n"); diff --git a/src/makefile.mingw b/src/makefile.mingw index 333be96..2ff4e11 100644 --- a/src/makefile.mingw +++ b/src/makefile.mingw @@ -24,7 +24,7 @@ CXXFLAGS = -std=gnu++14 -O2 $(WARNFLAGS) -fpermissive -pthread \ LIBS = -L$(DEPS)/lib -lsecp256k1 \ -L$(RANDOMX)/build -lrandomx \ - -lsodium -lssl -lcrypto -ldb_cxx -lpthread \ + -lsodium -lssl -lcrypto -ldb_cxx -lsqlite3 -lpthread \ -LC:/msys64/ucrt64/lib \ -lglfw3 -lopengl32 -lgdi32 \ -lkernel32 -luser32 -lcomdlg32 -lwinspool -lwinmm \ @@ -37,7 +37,8 @@ IMGUI_OBJS = obj/imgui.o obj/imgui_draw.o obj/imgui_tables.o \ NODE_OBJS = obj/util.o obj/script.o obj/db.o obj/net.o obj/main.o \ obj/market.o obj/sha.o obj/nostr.o obj/randomx_pow.o \ obj/btfaddr.o obj/btfchan.o obj/btfrv.o obj/btftunnel.o obj/rpc.o \ - obj/selftest.o obj/bip32.o obj/walletcmd.o obj/proxy.o obj/tor.o + obj/selftest.o obj/bip32.o obj/walletcmd.o obj/wallet_sqlite.o \ + obj/proxy.o obj/tor.o GUI_OBJS = obj/gui.o obj/main_gui.o obj/bitflash_res.o @@ -51,7 +52,7 @@ GUI_OBJS = obj/gui.o obj/main_gui.o obj/bitflash_res.o HEADERS = headers_core.h headers.h util.h main.h serialize.h uint256.h \ key.h bignum.h script.h db.h base58.h net.h nostr.h \ randomx_pow.h btfaddr.h btfchan.h btfrv.h btftunnel.h market.h \ - selftest.h walletcmd.h sockcount.h proxy.h tor.h compat.h font_roboto.h + selftest.h walletcmd.h wallet_sqlite.h sockcount.h proxy.h tor.h compat.h font_roboto.h all: bitflash.exe @@ -107,6 +108,7 @@ tests: test_bip32.exe bitflash.exe ./bitflash.exe -selftest=wallet-keypool ./bitflash.exe -selftest=wallet-hd ./bitflash.exe -selftest=wallet-format + ./bitflash.exe -selftest=wallet-sqlite ./bitflash.exe -selftest=wallet-crypto ./bitflash.exe -selftest=wallet-encrypt ./bitflash.exe -selftest=net-message diff --git a/src/selftest.cpp b/src/selftest.cpp index 31c168f..f9c4a8e 100644 --- a/src/selftest.cpp +++ b/src/selftest.cpp @@ -13,6 +13,7 @@ #include "sockcount.h" #include "tor.h" #include "walletcmd.h" +#include "wallet_sqlite.h" extern int RunPoolStratumSelfTest(); @@ -62,6 +63,11 @@ static std::string HexStrLocal(const std::vector& v) return out; } +static vector DataStreamBytes(const CDataStream& ss) +{ + return vector(ss.begin(), ss.end()); +} + static bool MakeTempDir(std::string& out) { #ifdef _WIN32 @@ -1030,6 +1036,104 @@ static int RunDbEnvReopenSelfTest() return nFail == 0 ? 0 : 1; } +static int RunWalletSQLiteSelfTest() +{ + fflush(stdout); + printf("wallet-sqlite self-test\n"); + + std::string tmp; + if (!MakeTempDir(tmp)) + { + printf(" FAIL could not create a temporary data directory\n"); + return 1; + } + + int nFail = 0; + string strPath = tmp + "/wallet.sqlite"; + string strError; + + try + { + CDataStream ssKeySetting(SER_DISK); + ssKeySetting << make_pair(string("setting"), string("sqlite-roundtrip")); + CDataStream ssValueSetting(SER_DISK); + ssValueSetting << (int64)424242; + + vector vchPubKey; + vchPubKey.push_back(0x04); + for (int i = 0; i < 64; i++) + vchPubKey.push_back((unsigned char)i); + CDataStream ssKeyDefault(SER_DISK); + ssKeyDefault << string("defaultkey"); + CDataStream ssValueDefault(SER_DISK); + ssValueDefault << vchPubKey; + + { + CWalletDBSQLite db; + nFail += Check(db.Open(strPath, strError), + "SQLite wallet opens and creates schema") ? 0 : 1; + nFail += Check(db.BeginTransaction(strError), + "SQLite wallet begins a write transaction") ? 0 : 1; + nFail += Check(db.WriteRecord(DataStreamBytes(ssKeySetting), + DataStreamBytes(ssValueSetting), + strError), + "SQLite wallet writes a serialized setting record") ? 0 : 1; + nFail += Check(db.WriteRecord(DataStreamBytes(ssKeyDefault), + DataStreamBytes(ssValueDefault), + strError), + "SQLite wallet writes a serialized defaultkey record") ? 0 : 1; + nFail += Check(db.CommitTransaction(strError), + "SQLite wallet commits a write transaction") ? 0 : 1; + int nCount = 0; + nFail += Check(db.CountRecords(nCount, strError) && nCount == 2, + "SQLite wallet counts raw records") ? 0 : 1; + } + + { + CWalletDBSQLite db; + nFail += Check(db.Open(strPath, strError), + "SQLite wallet reopens") ? 0 : 1; + vector vchRead; + nFail += Check(db.ReadRecord(DataStreamBytes(ssKeySetting), + vchRead, + strError), + "SQLite wallet reads a serialized setting record") ? 0 : 1; + int64 nReadValue = 0; + CDataStream ssRead(vchRead, SER_DISK); + ssRead >> nReadValue; + nFail += Check(!ssRead.fail() && nReadValue == 424242, + "SQLite wallet preserves serialized values byte-for-byte") ? 0 : 1; + + vchRead.clear(); + nFail += Check(db.ReadRecord(DataStreamBytes(ssKeyDefault), + vchRead, + strError), + "SQLite wallet reads a serialized defaultkey record") ? 0 : 1; + vector vchReadPubKey; + CDataStream ssReadDefault(vchRead, SER_DISK); + ssReadDefault >> vchReadPubKey; + nFail += Check(!ssReadDefault.fail() && vchReadPubKey == vchPubKey, + "SQLite wallet preserves public key blobs") ? 0 : 1; + } + } + catch (const std::exception& e) + { + printf(" FAIL exception: %s\n", e.what()); + nFail++; + } + catch (...) + { + printf(" FAIL unknown exception\n"); + nFail++; + } + + RemoveTree(tmp); + printf("%s (%d failure%s)\n", nFail == 0 ? "ALL TESTS PASSED" : "TESTS FAILED", + nFail, nFail == 1 ? "" : "s"); + fflush(stdout); + return nFail == 0 ? 0 : 1; +} + static void ClearWalletRuntimeForTest() { CRITICAL_BLOCK(cs_mapKeys) @@ -2289,6 +2393,8 @@ int RunSelfTest(const std::string& name) return RunWalletStorageSanitySelfTest(); if (name == "db-env-reopen") return RunDbEnvReopenSelfTest(); + if (name == "wallet-sqlite") + return RunWalletSQLiteSelfTest(); if (name == "wallet-crypto") return RunWalletCryptoSelfTest(); if (name == "wallet-encrypt") @@ -2309,6 +2415,6 @@ int RunSelfTest(const std::string& name) return RunManagedTorSelfTest(); printf("Unknown self-test '%s'\n", name.c_str()); - printf("Known self-tests: wallet-keypool, wallet-hd, wallet-format, wallet-storage-sanity, db-env-reopen, wallet-crypto, wallet-encrypt, wallet-portability, net-message, consensus-limits, pool-stratum, parse-money, socks5-proxy, managed-tor\n"); + printf("Known self-tests: wallet-keypool, wallet-hd, wallet-format, wallet-storage-sanity, db-env-reopen, wallet-sqlite, wallet-crypto, wallet-encrypt, wallet-portability, net-message, consensus-limits, pool-stratum, parse-money, socks5-proxy, managed-tor\n"); return 1; } diff --git a/src/wallet_sqlite.cpp b/src/wallet_sqlite.cpp new file mode 100644 index 0000000..4104ac6 --- /dev/null +++ b/src/wallet_sqlite.cpp @@ -0,0 +1,254 @@ +// Copyright (c) 2026 Bitflash developers +// Distributed under the MIT/X11 software license. + +#include "headers.h" +#include "wallet_sqlite.h" + +static void SetSQLiteError(sqlite3* pdb, const string& strContext, + string& strError) +{ + if (pdb) + strError = strContext + ": " + sqlite3_errmsg(pdb); + else + strError = strContext; +} + +CWalletDBSQLite::CWalletDBSQLite() : pdb(NULL) +{ +} + +CWalletDBSQLite::~CWalletDBSQLite() +{ + Close(); +} + +bool CWalletDBSQLite::Open(const string& strPath, string& strError) +{ + Close(); + + int ret = sqlite3_open_v2(strPath.c_str(), &pdb, + SQLITE_OPEN_READWRITE | + SQLITE_OPEN_CREATE | + SQLITE_OPEN_FULLMUTEX, + NULL); + if (ret != SQLITE_OK) + { + SetSQLiteError(pdb, "cannot open SQLite wallet", strError); + Close(); + return false; + } + + sqlite3_busy_timeout(pdb, 5000); + + if (!Exec("PRAGMA journal_mode=WAL;", strError)) + { + Close(); + return false; + } + if (!Exec("PRAGMA synchronous=FULL;", strError)) + { + Close(); + return false; + } + if (!InitSchema(strError)) + { + Close(); + return false; + } + return true; +} + +void CWalletDBSQLite::Close() +{ + if (pdb) + { + sqlite3_close(pdb); + pdb = NULL; + } +} + +bool CWalletDBSQLite::BeginTransaction(string& strError) +{ + if (!pdb) + { + strError = "SQLite wallet is not open"; + return false; + } + return Exec("BEGIN IMMEDIATE;", strError); +} + +bool CWalletDBSQLite::CommitTransaction(string& strError) +{ + if (!pdb) + { + strError = "SQLite wallet is not open"; + return false; + } + return Exec("COMMIT;", strError); +} + +bool CWalletDBSQLite::RollbackTransaction(string& strError) +{ + if (!pdb) + { + strError = "SQLite wallet is not open"; + return false; + } + return Exec("ROLLBACK;", strError); +} + +bool CWalletDBSQLite::Exec(const char* pszSql, string& strError) +{ + char* pszErr = NULL; + int ret = sqlite3_exec(pdb, pszSql, NULL, NULL, &pszErr); + if (ret != SQLITE_OK) + { + if (pszErr) + { + strError = pszErr; + sqlite3_free(pszErr); + } + else + SetSQLiteError(pdb, "SQLite exec failed", strError); + return false; + } + return true; +} + +bool CWalletDBSQLite::InitSchema(string& strError) +{ + return Exec("CREATE TABLE IF NOT EXISTS wallet_records (" + "key BLOB PRIMARY KEY NOT NULL," + "value BLOB NOT NULL" + ");", + strError) && + Exec("PRAGMA user_version=1;", strError); +} + +bool CWalletDBSQLite::WriteRecord(const vector& vchKey, + const vector& vchValue, + string& strError) +{ + if (!pdb) + { + strError = "SQLite wallet is not open"; + return false; + } + if (vchKey.empty()) + { + strError = "SQLite wallet record key is empty"; + return false; + } + + sqlite3_stmt* stmt = NULL; + const char* pszSql = + "INSERT OR REPLACE INTO wallet_records(key, value) VALUES(?, ?);"; + int ret = sqlite3_prepare_v2(pdb, pszSql, -1, &stmt, NULL); + if (ret != SQLITE_OK) + { + SetSQLiteError(pdb, "cannot prepare SQLite wallet write", strError); + return false; + } + + ret = sqlite3_bind_blob(stmt, 1, &vchKey[0], (int)vchKey.size(), + SQLITE_TRANSIENT); + if (ret == SQLITE_OK) + ret = sqlite3_bind_blob(stmt, 2, + vchValue.empty() ? NULL : &vchValue[0], + (int)vchValue.size(), + SQLITE_TRANSIENT); + if (ret == SQLITE_OK) + ret = sqlite3_step(stmt); + + bool fOk = (ret == SQLITE_DONE); + if (!fOk) + SetSQLiteError(pdb, "cannot write SQLite wallet record", strError); + sqlite3_finalize(stmt); + return fOk; +} + +bool CWalletDBSQLite::ReadRecord(const vector& vchKey, + vector& vchValueRet, + string& strError) +{ + vchValueRet.clear(); + if (!pdb) + { + strError = "SQLite wallet is not open"; + return false; + } + if (vchKey.empty()) + { + strError = "SQLite wallet record key is empty"; + return false; + } + + sqlite3_stmt* stmt = NULL; + const char* pszSql = "SELECT value FROM wallet_records WHERE key = ?;"; + int ret = sqlite3_prepare_v2(pdb, pszSql, -1, &stmt, NULL); + if (ret != SQLITE_OK) + { + SetSQLiteError(pdb, "cannot prepare SQLite wallet read", strError); + return false; + } + + ret = sqlite3_bind_blob(stmt, 1, &vchKey[0], (int)vchKey.size(), + SQLITE_TRANSIENT); + if (ret != SQLITE_OK) + { + SetSQLiteError(pdb, "cannot bind SQLite wallet key", strError); + sqlite3_finalize(stmt); + return false; + } + + ret = sqlite3_step(stmt); + if (ret == SQLITE_ROW) + { + const unsigned char* pch = + (const unsigned char*)sqlite3_column_blob(stmt, 0); + int nBytes = sqlite3_column_bytes(stmt, 0); + if (nBytes > 0 && pch) + vchValueRet.assign(pch, pch + nBytes); + sqlite3_finalize(stmt); + return true; + } + + if (ret == SQLITE_DONE) + strError = "SQLite wallet record not found"; + else + SetSQLiteError(pdb, "cannot read SQLite wallet record", strError); + sqlite3_finalize(stmt); + return false; +} + +bool CWalletDBSQLite::CountRecords(int& nRecordsRet, string& strError) +{ + nRecordsRet = 0; + if (!pdb) + { + strError = "SQLite wallet is not open"; + return false; + } + + sqlite3_stmt* stmt = NULL; + int ret = sqlite3_prepare_v2(pdb, + "SELECT COUNT(*) FROM wallet_records;", + -1, &stmt, NULL); + if (ret != SQLITE_OK) + { + SetSQLiteError(pdb, "cannot prepare SQLite wallet count", strError); + return false; + } + + ret = sqlite3_step(stmt); + if (ret != SQLITE_ROW) + { + SetSQLiteError(pdb, "cannot count SQLite wallet records", strError); + sqlite3_finalize(stmt); + return false; + } + + nRecordsRet = sqlite3_column_int(stmt, 0); + sqlite3_finalize(stmt); + return true; +} diff --git a/src/wallet_sqlite.h b/src/wallet_sqlite.h new file mode 100644 index 0000000..9812d3a --- /dev/null +++ b/src/wallet_sqlite.h @@ -0,0 +1,40 @@ +// Copyright (c) 2026 Bitflash developers +// Distributed under the MIT/X11 software license. + +#ifndef BITFLASH_WALLET_SQLITE_H +#define BITFLASH_WALLET_SQLITE_H + +#include + +class CWalletDBSQLite +{ +public: + CWalletDBSQLite(); + ~CWalletDBSQLite(); + + bool Open(const string& strPath, string& strError); + void Close(); + + bool BeginTransaction(string& strError); + bool CommitTransaction(string& strError); + bool RollbackTransaction(string& strError); + + bool WriteRecord(const vector& vchKey, + const vector& vchValue, + string& strError); + bool ReadRecord(const vector& vchKey, + vector& vchValueRet, + string& strError); + bool CountRecords(int& nRecordsRet, string& strError); + +private: + sqlite3* pdb; + + bool Exec(const char* pszSql, string& strError); + bool InitSchema(string& strError); + + CWalletDBSQLite(const CWalletDBSQLite&); + void operator=(const CWalletDBSQLite&); +}; + +#endif -- cgit v1.2.3 From 59b9d11015f0e3a2475d0750fc534583c2ca9f77 Mon Sep 17 00:00:00 2001 From: Nsable-sh Date: Fri, 14 Aug 2026 15:56:12 -0300 Subject: wallet: export BDB records to SQLite --- docs/storage.md | 6 ++ src/Makefile | 1 + src/main_gui.cpp | 15 ++++- src/makefile.mingw | 1 + src/selftest.cpp | 148 +++++++++++++++++++++++++++++++++++++++++++++++++- src/wallet_sqlite.cpp | 10 ++++ src/wallet_sqlite.h | 1 + src/walletcmd.cpp | 97 +++++++++++++++++++++++++++++++++ src/walletcmd.h | 5 ++ 9 files changed, 281 insertions(+), 3 deletions(-) (limited to 'src/main_gui.cpp') diff --git a/docs/storage.md b/docs/storage.md index f75f9b9..bb414b5 100644 --- a/docs/storage.md +++ b/docs/storage.md @@ -104,6 +104,12 @@ runtime wallet still opens Berkeley DB. The purpose is to prove schema creation, raw record write/read, close/reopen behavior, and value preservation before any BDB-to-SQLite migration or runtime backend flag exists. +`-walletsqliteexport=FILE` is the next staging tool. It streams every raw +`wallet.dat` record through `ScanWalletRecords()` and writes those same key and +value bytes into the SQLite table inside one transaction. It refuses to +overwrite an existing export and still does not change the runtime wallet +backend. + ## `blkindex.dat` `blkindex.dat` is accessed through `CTxDB`. diff --git a/src/Makefile b/src/Makefile index cfa40f6..b83ff58 100644 --- a/src/Makefile +++ b/src/Makefile @@ -189,6 +189,7 @@ tests: test_bip32 bitflash-node ./bitflash-node -selftest=wallet-hd ./bitflash-node -selftest=wallet-format ./bitflash-node -selftest=wallet-sqlite + ./bitflash-node -selftest=wallet-sqlite-migration ./bitflash-node -selftest=wallet-crypto ./bitflash-node -selftest=wallet-encrypt ./bitflash-node -selftest=net-message diff --git a/src/main_gui.cpp b/src/main_gui.cpp index 965c818..6e89270 100644 --- a/src/main_gui.cpp +++ b/src/main_gui.cpp @@ -114,8 +114,8 @@ static void PrintUsage() printf(" /gen\n"); printf(" /nogui or /daemon\n"); printf(" /selftest=wallet-keypool, wallet-hd, wallet-format, wallet-storage-sanity,\n"); - printf(" db-env-reopen, wallet-sqlite, wallet-crypto, wallet-encrypt,\n"); - printf(" wallet-portability,\n"); + printf(" db-env-reopen, wallet-sqlite, wallet-sqlite-migration,\n"); + printf(" wallet-crypto, wallet-encrypt, wallet-portability,\n"); printf(" net-message,\n"); printf(" consensus-limits, pool-stratum,\n"); printf(" parse-money, socks5-proxy, or managed-tor\n"); @@ -181,6 +181,8 @@ static void PrintUsage() printf(" (write the same audit as deterministic JSON)\n"); printf(" /walletstoragecheck (fail closed on inconsistent or unsafe\n"); printf(" wallet.dat storage state)\n"); + printf(" /walletsqliteexport=FILE (copy raw wallet.dat records into an\n"); + printf(" experimental SQLite store, then exit)\n"); printf(" /rescan (walk the chain for coins this wallet owns\n"); printf(" but never recorded, then exit)\n"); printf("\n"); @@ -529,6 +531,15 @@ int main(int argc, char* argv[]) return nRet; } + string strWalletSQLiteExport = + argval2(argc, argv, "/walletsqliteexport", "-walletsqliteexport"); + if (!strWalletSQLiteExport.empty()) + { + int nRet = CmdWalletSQLiteExport(strWalletSQLiteExport); + DBFlush(true); + return nRet; + } + // Berkeley DB reports failure by throwing, and CDB's constructor lets it // through. Nothing on this path caught anything, so an unreadable // blkindex.dat or wallet.dat -- one written by another platform's Berkeley diff --git a/src/makefile.mingw b/src/makefile.mingw index 2ff4e11..14e6b32 100644 --- a/src/makefile.mingw +++ b/src/makefile.mingw @@ -109,6 +109,7 @@ tests: test_bip32.exe bitflash.exe ./bitflash.exe -selftest=wallet-hd ./bitflash.exe -selftest=wallet-format ./bitflash.exe -selftest=wallet-sqlite + ./bitflash.exe -selftest=wallet-sqlite-migration ./bitflash.exe -selftest=wallet-crypto ./bitflash.exe -selftest=wallet-encrypt ./bitflash.exe -selftest=net-message diff --git a/src/selftest.cpp b/src/selftest.cpp index f9c4a8e..7a54271 100644 --- a/src/selftest.cpp +++ b/src/selftest.cpp @@ -1134,6 +1134,150 @@ static int RunWalletSQLiteSelfTest() return nFail == 0 ? 0 : 1; } +class CWalletRecordMapVisitor : public CWalletRecordVisitor +{ +public: + std::map, vector >& mapRecords; + + explicit CWalletRecordMapVisitor( + std::map, vector >& mapRecordsIn) + : mapRecords(mapRecordsIn) { } + + bool VisitWalletRecord(const CDataStream& ssKey, + const CDataStream& ssValue, + string& strErrorRet) + { + vector vchKey = DataStreamBytes(ssKey); + if (mapRecords.count(vchKey)) + { + strErrorRet = "duplicate serialized wallet key"; + return false; + } + mapRecords[vchKey] = DataStreamBytes(ssValue); + return true; + } +}; + +static int RunWalletSQLiteMigrationSelfTest() +{ + fflush(stdout); + printf("wallet-sqlite-migration self-test\n"); + + std::string tmp; + std::string cwd; + if (!MakeTempDir(tmp)) + { + printf(" FAIL could not create a temporary data directory\n"); + return 1; + } + if (!GetCurrentDir(cwd)) + { + printf(" FAIL could not read current directory\n"); + RemoveTree(tmp); + return 1; + } + + int nFail = 0; + printf(" temp root: %s\n", tmp.c_str()); + + try + { +#ifdef _WIN32 + string strExe = cwd + "\\bitflash.exe"; +#else + string strExe = cwd + "/bitflash-node"; +#endif + string strWalletDir = tmp + "/wallet"; + nFail += Check(MakeDirLocal(strWalletDir), + "temporary wallet directory can be created") ? 0 : 1; + + string strCreateOut = tmp + "/create.txt"; + vector vCreateArgs; + vCreateArgs.push_back("-datadir=" + strWalletDir); + vCreateArgs.push_back("-nomanagedtor"); + vCreateArgs.push_back("-nogui"); + vCreateArgs.push_back("-newphrase"); + int nCreateRet = RunBitflashChild(strExe, vCreateArgs, NULL, &strCreateOut); + nFail += Check(nCreateRet == 0 && + FileContainsText(strCreateOut, "Write these twelve words down"), + "a phrase wallet can be created for SQLite export") ? 0 : 1; + + string strSQLite = tmp + "/wallet.sqlite"; + string strExportOut = tmp + "/sqlite-export.txt"; + vector vExportArgs; + vExportArgs.push_back("-datadir=" + strWalletDir); + vExportArgs.push_back("-nomanagedtor"); + vExportArgs.push_back("-nogui"); + vExportArgs.push_back("-walletsqliteexport=" + strSQLite); + int nExportRet = RunBitflashChild(strExe, vExportArgs, NULL, &strExportOut); + nFail += Check(nExportRet == 0 && + FileExists(strSQLite.c_str()) && + FileContainsText(strExportOut, "SQLite wallet export written"), + "wallet.dat can be exported to SQLite") ? 0 : 1; + + string strOverwriteOut = tmp + "/sqlite-export-overwrite.txt"; + int nOverwriteRet = + RunBitflashChild(strExe, vExportArgs, NULL, &strOverwriteOut); + nFail += Check(nOverwriteRet != 0 && + FileContainsText(strOverwriteOut, + "Refusing to overwrite existing SQLite wallet export"), + "SQLite wallet export refuses to overwrite") ? 0 : 1; + remove((strSQLite + "-wal").c_str()); + remove((strSQLite + "-shm").c_str()); + + std::map, vector > mapBDB; + string strOldDataDir = strSetDataDir; + string strScanError; + strSetDataDir = strWalletDir; + CWalletRecordMapVisitor visitor(mapBDB); + bool fScanOk = ScanWalletRecords(visitor, strScanError); + DBFlush(true); + strSetDataDir = strOldDataDir; + nFail += Check(fScanOk && !mapBDB.empty(), + "wallet.dat records can be scanned for comparison") ? 0 : 1; + + CWalletDBSQLite db; + string strError; + nFail += Check(db.Open(strSQLite, strError), + "exported SQLite wallet opens") ? 0 : 1; + int nSQLiteRecords = 0; + nFail += Check(db.CountRecords(nSQLiteRecords, strError) && + nSQLiteRecords == (int)mapBDB.size(), + "SQLite export has the same record count as wallet.dat") ? 0 : 1; + + bool fAllMatch = true; + for (std::map, vector >::const_iterator it = + mapBDB.begin(); it != mapBDB.end(); ++it) + { + vector vchSQLiteValue; + if (!db.ReadRecord(it->first, vchSQLiteValue, strError) || + vchSQLiteValue != it->second) + { + fAllMatch = false; + break; + } + } + nFail += Check(fAllMatch, + "SQLite export preserves every wallet.dat key/value byte-for-byte") ? 0 : 1; + } + catch (const std::exception& e) + { + printf(" FAIL exception: %s\n", e.what()); + nFail++; + } + catch (...) + { + printf(" FAIL unknown exception\n"); + nFail++; + } + + RemoveTree(tmp); + printf("%s (%d failure%s)\n", nFail == 0 ? "ALL TESTS PASSED" : "TESTS FAILED", + nFail, nFail == 1 ? "" : "s"); + fflush(stdout); + return nFail == 0 ? 0 : 1; +} + static void ClearWalletRuntimeForTest() { CRITICAL_BLOCK(cs_mapKeys) @@ -2395,6 +2539,8 @@ int RunSelfTest(const std::string& name) return RunDbEnvReopenSelfTest(); if (name == "wallet-sqlite") return RunWalletSQLiteSelfTest(); + if (name == "wallet-sqlite-migration") + return RunWalletSQLiteMigrationSelfTest(); if (name == "wallet-crypto") return RunWalletCryptoSelfTest(); if (name == "wallet-encrypt") @@ -2415,6 +2561,6 @@ int RunSelfTest(const std::string& name) return RunManagedTorSelfTest(); printf("Unknown self-test '%s'\n", name.c_str()); - printf("Known self-tests: wallet-keypool, wallet-hd, wallet-format, wallet-storage-sanity, db-env-reopen, wallet-sqlite, wallet-crypto, wallet-encrypt, wallet-portability, net-message, consensus-limits, pool-stratum, parse-money, socks5-proxy, managed-tor\n"); + printf("Known self-tests: wallet-keypool, wallet-hd, wallet-format, wallet-storage-sanity, db-env-reopen, wallet-sqlite, wallet-sqlite-migration, wallet-crypto, wallet-encrypt, wallet-portability, net-message, consensus-limits, pool-stratum, parse-money, socks5-proxy, managed-tor\n"); return 1; } diff --git a/src/wallet_sqlite.cpp b/src/wallet_sqlite.cpp index 4104ac6..4369af6 100644 --- a/src/wallet_sqlite.cpp +++ b/src/wallet_sqlite.cpp @@ -97,6 +97,16 @@ bool CWalletDBSQLite::RollbackTransaction(string& strError) return Exec("ROLLBACK;", strError); } +bool CWalletDBSQLite::Checkpoint(string& strError) +{ + if (!pdb) + { + strError = "SQLite wallet is not open"; + return false; + } + return Exec("PRAGMA wal_checkpoint(TRUNCATE);", strError); +} + bool CWalletDBSQLite::Exec(const char* pszSql, string& strError) { char* pszErr = NULL; diff --git a/src/wallet_sqlite.h b/src/wallet_sqlite.h index 9812d3a..5ef862d 100644 --- a/src/wallet_sqlite.h +++ b/src/wallet_sqlite.h @@ -18,6 +18,7 @@ public: bool BeginTransaction(string& strError); bool CommitTransaction(string& strError); bool RollbackTransaction(string& strError); + bool Checkpoint(string& strError); bool WriteRecord(const vector& vchKey, const vector& vchValue, diff --git a/src/walletcmd.cpp b/src/walletcmd.cpp index 6fb09a3..20e7e33 100644 --- a/src/walletcmd.cpp +++ b/src/walletcmd.cpp @@ -12,6 +12,7 @@ #include "headers_core.h" #include "bip32.h" #include "walletcmd.h" +#include "wallet_sqlite.h" #undef printf @@ -790,6 +791,102 @@ int CmdWalletStorageAudit(const std::string& strJsonOut) return 0; } +static vector DataStreamBytesLocal(const CDataStream& ss) +{ + return vector(ss.begin(), ss.end()); +} + +class CWalletSQLiteExportVisitor : public CWalletRecordVisitor +{ +public: + CWalletDBSQLite& db; + int& nCopied; + + CWalletSQLiteExportVisitor(CWalletDBSQLite& dbIn, int& nCopiedIn) + : db(dbIn), nCopied(nCopiedIn) { } + + bool VisitWalletRecord(const CDataStream& ssKey, + const CDataStream& ssValue, + string& strErrorRet) + { + if (!db.WriteRecord(DataStreamBytesLocal(ssKey), + DataStreamBytesLocal(ssValue), + strErrorRet)) + return false; + nCopied++; + return true; + } +}; + +int CmdWalletSQLiteExport(const std::string& strDest) +{ + AttachTerminal(); + + if (strDest.empty()) + { + fprintf(stderr, "Missing SQLite wallet export path.\n"); + return 1; + } + if (FileExists(strDest.c_str())) + { + fprintf(stderr, "Refusing to overwrite existing SQLite wallet export: %s\n", + strDest.c_str()); + return 1; + } + + string strError; + CWalletDBSQLite db; + if (!db.Open(strDest, strError)) + { + fprintf(stderr, "Cannot open SQLite wallet export: %s\n", strError.c_str()); + return 1; + } + if (!db.BeginTransaction(strError)) + { + fprintf(stderr, "Cannot begin SQLite wallet export: %s\n", strError.c_str()); + return 1; + } + + int nCopied = 0; + CWalletSQLiteExportVisitor visitor(db, nCopied); + if (!ScanWalletRecords(visitor, strError)) + { + string strRollbackError; + db.RollbackTransaction(strRollbackError); + fprintf(stderr, "Cannot export wallet.dat to SQLite: %s\n", strError.c_str()); + return 1; + } + if (!db.CommitTransaction(strError)) + { + fprintf(stderr, "Cannot commit SQLite wallet export: %s\n", strError.c_str()); + return 1; + } + if (!db.Checkpoint(strError)) + { + fprintf(stderr, "Cannot checkpoint SQLite wallet export: %s\n", strError.c_str()); + return 1; + } + + int nCount = 0; + if (!db.CountRecords(nCount, strError)) + { + fprintf(stderr, "Cannot verify SQLite wallet export count: %s\n", + strError.c_str()); + return 1; + } + if (nCount != nCopied) + { + fprintf(stderr, "SQLite wallet export count mismatch: copied %d, stored %d\n", + nCopied, nCount); + return 1; + } + + printf("SQLite wallet export written to %s\n", strDest.c_str()); + printf(" records copied: %d\n", nCopied); + fflush(stdout); + return 0; +} + int CmdEncryptWallet(const std::string& strPassphrase) { AttachTerminal(); diff --git a/src/walletcmd.h b/src/walletcmd.h index 6077669..b1a29d2 100644 --- a/src/walletcmd.h +++ b/src/walletcmd.h @@ -70,6 +70,11 @@ int CmdWalletStorageAudit(const std::string& strJsonOut = ""); // or migration-unsafe mix of storage records. int CmdWalletStorageCheck(); +// Diagnostic/migration staging: copy every raw wallet.dat record into an +// experimental SQLite key/value store, preserving serialized bytes. This does +// not change which backend the runtime wallet opens. +int CmdWalletSQLiteExport(const std::string& strDest); + // Rewrite wallet.dat so private keys and the HD seed are encrypted with the // given passphrase. Returns 0 on success and exits without starting the node. int CmdEncryptWallet(const std::string& strPassphrase); -- cgit v1.2.3 From 99c6b5f2971da51cca1c739b5e982ae2022acc85 Mon Sep 17 00:00:00 2001 From: Nsable-sh Date: Fri, 14 Aug 2026 16:34:43 -0300 Subject: wallet: verify SQLite wallet exports --- docs/storage.md | 2 + src/main_gui.cpp | 11 ++++++ src/selftest.cpp | 41 +++++++++++++++++++++ src/walletcmd.cpp | 107 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/walletcmd.h | 4 ++ 5 files changed, 165 insertions(+) (limited to 'src/main_gui.cpp') diff --git a/docs/storage.md b/docs/storage.md index 2d748fa..0a441bc 100644 --- a/docs/storage.md +++ b/docs/storage.md @@ -111,6 +111,8 @@ overwrite an existing export and still does not change the runtime wallet backend. `CWalletDBSQLite::ScanRecords()` can stream those exported records back through the same visitor shape, so tests can compare BDB and SQLite record streams without introducing a SQLite runtime wallet loader yet. +`-walletsqliteverify=FILE` compares the current `wallet.dat` stream with a +SQLite export and prints only record counts and mismatch counts. ## `blkindex.dat` diff --git a/src/main_gui.cpp b/src/main_gui.cpp index 6e89270..caacf13 100644 --- a/src/main_gui.cpp +++ b/src/main_gui.cpp @@ -183,6 +183,8 @@ static void PrintUsage() printf(" wallet.dat storage state)\n"); printf(" /walletsqliteexport=FILE (copy raw wallet.dat records into an\n"); printf(" experimental SQLite store, then exit)\n"); + printf(" /walletsqliteverify=FILE (compare wallet.dat with a SQLite export,\n"); + printf(" printing counts only, then exit)\n"); printf(" /rescan (walk the chain for coins this wallet owns\n"); printf(" but never recorded, then exit)\n"); printf("\n"); @@ -540,6 +542,15 @@ int main(int argc, char* argv[]) return nRet; } + string strWalletSQLiteVerify = + argval2(argc, argv, "/walletsqliteverify", "-walletsqliteverify"); + if (!strWalletSQLiteVerify.empty()) + { + int nRet = CmdWalletSQLiteVerify(strWalletSQLiteVerify); + DBFlush(true); + return nRet; + } + // Berkeley DB reports failure by throwing, and CDB's constructor lets it // through. Nothing on this path caught anything, so an unreadable // blkindex.dat or wallet.dat -- one written by another platform's Berkeley diff --git a/src/selftest.cpp b/src/selftest.cpp index 408dd41..4ba90c3 100644 --- a/src/selftest.cpp +++ b/src/selftest.cpp @@ -1134,6 +1134,8 @@ static int RunWalletSQLiteSelfTest() return nFail == 0 ? 0 : 1; } +namespace { + class CWalletRecordMapVisitor : public CWalletRecordVisitor { public: @@ -1158,6 +1160,8 @@ public: } }; +} // namespace + static int RunWalletSQLiteMigrationSelfTest() { fflush(stdout); @@ -1215,6 +1219,17 @@ static int RunWalletSQLiteMigrationSelfTest() FileContainsText(strExportOut, "SQLite wallet export written"), "wallet.dat can be exported to SQLite") ? 0 : 1; + string strVerifyOut = tmp + "/sqlite-verify.txt"; + vector vVerifyArgs; + vVerifyArgs.push_back("-datadir=" + strWalletDir); + vVerifyArgs.push_back("-nomanagedtor"); + vVerifyArgs.push_back("-nogui"); + vVerifyArgs.push_back("-walletsqliteverify=" + strSQLite); + int nVerifyRet = RunBitflashChild(strExe, vVerifyArgs, NULL, &strVerifyOut); + nFail += Check(nVerifyRet == 0 && + FileContainsText(strVerifyOut, "verification: ok"), + "SQLite wallet export verifies against wallet.dat") ? 0 : 1; + string strOverwriteOut = tmp + "/sqlite-export-overwrite.txt"; int nOverwriteRet = RunBitflashChild(strExe, vExportArgs, NULL, &strOverwriteOut); @@ -1265,6 +1280,32 @@ static int RunWalletSQLiteMigrationSelfTest() nFail += Check(db.ScanRecords(sqliteVisitor, strError) && mapSQLite == mapBDB, "SQLite wallet scanner streams every exported record byte-for-byte") ? 0 : 1; + + db.Close(); + CWalletDBSQLite dbCorrupt; + vector vchBadValue; + vchBadValue.push_back(0xba); + vchBadValue.push_back(0xad); + nFail += Check(!mapBDB.empty() && + dbCorrupt.Open(strSQLite, strError) && + dbCorrupt.BeginTransaction(strError) && + dbCorrupt.WriteRecord(mapBDB.begin()->first, + vchBadValue, + strError) && + dbCorrupt.CommitTransaction(strError) && + dbCorrupt.Checkpoint(strError), + "SQLite export can be deliberately corrupted for verifier test") ? 0 : 1; + dbCorrupt.Close(); + + string strVerifyBadOut = tmp + "/sqlite-verify-bad.txt"; + int nVerifyBadRet = + RunBitflashChild(strExe, vVerifyArgs, NULL, &strVerifyBadOut); + nFail += Check(nVerifyBadRet == 2 && + FileContainsText(strVerifyBadOut, + "record value mismatches: 1") && + FileContainsText(strVerifyBadOut, + "verification: failed"), + "SQLite wallet verifier detects a mismatched record value") ? 0 : 1; } catch (const std::exception& e) { diff --git a/src/walletcmd.cpp b/src/walletcmd.cpp index 20e7e33..36f6783 100644 --- a/src/walletcmd.cpp +++ b/src/walletcmd.cpp @@ -796,6 +796,8 @@ static vector DataStreamBytesLocal(const CDataStream& ss) return vector(ss.begin(), ss.end()); } +namespace { + class CWalletSQLiteExportVisitor : public CWalletRecordVisitor { public: @@ -818,6 +820,32 @@ public: } }; +class CWalletRecordMapVisitor : public CWalletRecordVisitor +{ +public: + std::map, vector >& mapRecords; + + explicit CWalletRecordMapVisitor( + std::map, vector >& mapRecordsIn) + : mapRecords(mapRecordsIn) { } + + bool VisitWalletRecord(const CDataStream& ssKey, + const CDataStream& ssValue, + string& strErrorRet) + { + vector vchKey = DataStreamBytesLocal(ssKey); + if (mapRecords.count(vchKey)) + { + strErrorRet = "duplicate serialized wallet key"; + return false; + } + mapRecords[vchKey] = DataStreamBytesLocal(ssValue); + return true; + } +}; + +} // namespace + int CmdWalletSQLiteExport(const std::string& strDest) { AttachTerminal(); @@ -887,6 +915,85 @@ int CmdWalletSQLiteExport(const std::string& strDest) return 0; } +int CmdWalletSQLiteVerify(const std::string& strPath) +{ + AttachTerminal(); + + if (strPath.empty()) + { + fprintf(stderr, "Missing SQLite wallet export path.\n"); + return 1; + } + if (!FileExists(strPath.c_str())) + { + fprintf(stderr, "SQLite wallet export does not exist: %s\n", + strPath.c_str()); + return 1; + } + + std::map, vector > mapBDB; + std::map, vector > mapSQLite; + string strError; + + CWalletRecordMapVisitor bdbVisitor(mapBDB); + if (!ScanWalletRecords(bdbVisitor, strError)) + { + fprintf(stderr, "Cannot scan wallet.dat records: %s\n", strError.c_str()); + return 1; + } + + CWalletDBSQLite db; + if (!db.Open(strPath, strError)) + { + fprintf(stderr, "Cannot open SQLite wallet export: %s\n", strError.c_str()); + return 1; + } + CWalletRecordMapVisitor sqliteVisitor(mapSQLite); + if (!db.ScanRecords(sqliteVisitor, strError)) + { + fprintf(stderr, "Cannot scan SQLite wallet export: %s\n", strError.c_str()); + return 1; + } + + int nMissing = 0; + int nMismatched = 0; + for (std::map, vector >::const_iterator it = + mapBDB.begin(); it != mapBDB.end(); ++it) + { + std::map, vector >::const_iterator sit = + mapSQLite.find(it->first); + if (sit == mapSQLite.end()) + nMissing++; + else if (sit->second != it->second) + nMismatched++; + } + + int nExtra = 0; + for (std::map, vector >::const_iterator it = + mapSQLite.begin(); it != mapSQLite.end(); ++it) + { + if (!mapBDB.count(it->first)) + nExtra++; + } + + printf("SQLite wallet export verification\n"); + printf(" wallet.dat records: %u\n", (unsigned int)mapBDB.size()); + printf(" SQLite records: %u\n", (unsigned int)mapSQLite.size()); + printf(" missing records: %d\n", nMissing); + printf(" extra records: %d\n", nExtra); + printf(" record value mismatches: %d\n", nMismatched); + if (nMissing == 0 && nExtra == 0 && nMismatched == 0) + { + printf(" verification: ok\n"); + fflush(stdout); + return 0; + } + + printf(" verification: failed\n"); + fflush(stdout); + return 2; +} + int CmdEncryptWallet(const std::string& strPassphrase) { AttachTerminal(); diff --git a/src/walletcmd.h b/src/walletcmd.h index b1a29d2..784c74c 100644 --- a/src/walletcmd.h +++ b/src/walletcmd.h @@ -75,6 +75,10 @@ int CmdWalletStorageCheck(); // not change which backend the runtime wallet opens. int CmdWalletSQLiteExport(const std::string& strDest); +// Diagnostic/migration staging: compare a SQLite wallet export with the current +// wallet.dat raw records. Prints counts only, never record keys or values. +int CmdWalletSQLiteVerify(const std::string& strPath); + // Rewrite wallet.dat so private keys and the HD seed are encrypted with the // given passphrase. Returns 0 on success and exits without starting the node. int CmdEncryptWallet(const std::string& strPassphrase); -- cgit v1.2.3 From df129cc672b9b894ff2558d3e9361ddab082433d Mon Sep 17 00:00:00 2001 From: Nsable-sh Date: Fri, 14 Aug 2026 17:02:35 -0300 Subject: wallet: restore BDB wallets from SQLite exports --- docs/storage.md | 3 ++ src/main_gui.cpp | 11 +++++ src/selftest.cpp | 35 +++++++++++++++ src/walletcmd.cpp | 126 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/walletcmd.h | 4 ++ 5 files changed, 179 insertions(+) (limited to 'src/main_gui.cpp') diff --git a/docs/storage.md b/docs/storage.md index 0a441bc..4947575 100644 --- a/docs/storage.md +++ b/docs/storage.md @@ -113,6 +113,9 @@ through the same visitor shape, so tests can compare BDB and SQLite record streams without introducing a SQLite runtime wallet loader yet. `-walletsqliteverify=FILE` compares the current `wallet.dat` stream with a SQLite export and prints only record counts and mismatch counts. +`-walletsqliterestore=FILE` rebuilds `wallet.dat` from a SQLite export only in +an empty data directory, then the verifier can prove the restored BDB file still +matches the export byte-for-byte. ## `blkindex.dat` diff --git a/src/main_gui.cpp b/src/main_gui.cpp index caacf13..cf96822 100644 --- a/src/main_gui.cpp +++ b/src/main_gui.cpp @@ -185,6 +185,8 @@ static void PrintUsage() printf(" experimental SQLite store, then exit)\n"); printf(" /walletsqliteverify=FILE (compare wallet.dat with a SQLite export,\n"); printf(" printing counts only, then exit)\n"); + printf(" /walletsqliterestore=FILE (rebuild wallet.dat from a SQLite export in\n"); + printf(" an empty data directory, then exit)\n"); printf(" /rescan (walk the chain for coins this wallet owns\n"); printf(" but never recorded, then exit)\n"); printf("\n"); @@ -551,6 +553,15 @@ int main(int argc, char* argv[]) return nRet; } + string strWalletSQLiteRestore = + argval2(argc, argv, "/walletsqliterestore", "-walletsqliterestore"); + if (!strWalletSQLiteRestore.empty()) + { + int nRet = CmdWalletSQLiteRestore(strWalletSQLiteRestore); + DBFlush(true); + return nRet; + } + // Berkeley DB reports failure by throwing, and CDB's constructor lets it // through. Nothing on this path caught anything, so an unreadable // blkindex.dat or wallet.dat -- one written by another platform's Berkeley diff --git a/src/selftest.cpp b/src/selftest.cpp index 4ba90c3..7d3a96e 100644 --- a/src/selftest.cpp +++ b/src/selftest.cpp @@ -1230,6 +1230,41 @@ static int RunWalletSQLiteMigrationSelfTest() FileContainsText(strVerifyOut, "verification: ok"), "SQLite wallet export verifies against wallet.dat") ? 0 : 1; + string strRestoredDir = tmp + "/restored-wallet"; + nFail += Check(MakeDirLocal(strRestoredDir), + "restored wallet directory can be created") ? 0 : 1; + string strRestoreOut = tmp + "/sqlite-restore.txt"; + vector vRestoreArgs; + vRestoreArgs.push_back("-datadir=" + strRestoredDir); + vRestoreArgs.push_back("-nomanagedtor"); + vRestoreArgs.push_back("-nogui"); + vRestoreArgs.push_back("-walletsqliterestore=" + strSQLite); + int nRestoreRet = RunBitflashChild(strExe, vRestoreArgs, NULL, &strRestoreOut); + nFail += Check(nRestoreRet == 0 && + FileContainsText(strRestoreOut, "records restored:"), + "SQLite wallet export restores into a fresh wallet.dat") ? 0 : 1; + + string strVerifyRestoredOut = tmp + "/sqlite-verify-restored.txt"; + vector vVerifyRestoredArgs; + vVerifyRestoredArgs.push_back("-datadir=" + strRestoredDir); + vVerifyRestoredArgs.push_back("-nomanagedtor"); + vVerifyRestoredArgs.push_back("-nogui"); + vVerifyRestoredArgs.push_back("-walletsqliteverify=" + strSQLite); + int nVerifyRestoredRet = + RunBitflashChild(strExe, vVerifyRestoredArgs, NULL, &strVerifyRestoredOut); + nFail += Check(nVerifyRestoredRet == 0 && + FileContainsText(strVerifyRestoredOut, + "verification: ok"), + "restored wallet.dat verifies against the SQLite export") ? 0 : 1; + + string strRestoreOverwriteOut = tmp + "/sqlite-restore-overwrite.txt"; + int nRestoreOverwriteRet = + RunBitflashChild(strExe, vRestoreArgs, NULL, &strRestoreOverwriteOut); + nFail += Check(nRestoreOverwriteRet == 1 && + FileContainsText(strRestoreOverwriteOut, + "Refusing to overwrite existing wallet.dat"), + "SQLite wallet restore refuses to overwrite wallet.dat") ? 0 : 1; + string strOverwriteOut = tmp + "/sqlite-export-overwrite.txt"; int nOverwriteRet = RunBitflashChild(strExe, vExportArgs, NULL, &strOverwriteOut); diff --git a/src/walletcmd.cpp b/src/walletcmd.cpp index 36f6783..d1bad98 100644 --- a/src/walletcmd.cpp +++ b/src/walletcmd.cpp @@ -844,6 +844,52 @@ public: } }; +class CWalletRawRestoreDB : public CWalletDB +{ +public: + CWalletRawRestoreDB(const char* pszMode="cr+", bool fTxn=true) + : CWalletDB(pszMode, fTxn) { } + + bool WriteRaw(CDataStream ssKey, CDataStream ssValue) + { + if (!pdb || ssKey.empty()) + return false; + + Dbt datKey((void*)&ssKey[0], ssKey.size()); + Dbt datValue(ssValue.empty() ? NULL : (void*)&ssValue[0], + ssValue.size()); + int ret = pdb->put(GetTxn(), &datKey, &datValue, 0); + + memset(datKey.get_data(), 0, datKey.get_size()); + if (datValue.get_data()) + memset(datValue.get_data(), 0, datValue.get_size()); + return ret == 0; + } +}; + +class CWalletSQLiteRestoreVisitor : public CWalletRecordVisitor +{ +public: + CWalletRawRestoreDB& db; + int& nCopied; + + CWalletSQLiteRestoreVisitor(CWalletRawRestoreDB& dbIn, int& nCopiedIn) + : db(dbIn), nCopied(nCopiedIn) { } + + bool VisitWalletRecord(const CDataStream& ssKey, + const CDataStream& ssValue, + string& strErrorRet) + { + if (!db.WriteRaw(ssKey, ssValue)) + { + strErrorRet = "could not write raw wallet record"; + return false; + } + nCopied++; + return true; + } +}; + } // namespace int CmdWalletSQLiteExport(const std::string& strDest) @@ -994,6 +1040,86 @@ int CmdWalletSQLiteVerify(const std::string& strPath) return 2; } +int CmdWalletSQLiteRestore(const std::string& strPath) +{ + AttachTerminal(); + + if (strPath.empty()) + { + fprintf(stderr, "Missing SQLite wallet export path.\n"); + return 1; + } + if (!FileExists(strPath.c_str())) + { + fprintf(stderr, "SQLite wallet export does not exist: %s\n", + strPath.c_str()); + return 1; + } + + string strWalletPath = GetAppDir() + "/wallet.dat"; + if (FileExists(strWalletPath.c_str())) + { + fprintf(stderr, "Refusing to overwrite existing wallet.dat: %s\n", + strWalletPath.c_str()); + return 1; + } + + string strError; + CWalletDBSQLite sqlite; + if (!sqlite.Open(strPath, strError)) + { + fprintf(stderr, "Cannot open SQLite wallet export: %s\n", strError.c_str()); + return 1; + } + + int nSQLiteRecords = 0; + if (!sqlite.CountRecords(nSQLiteRecords, strError)) + { + fprintf(stderr, "Cannot count SQLite wallet export records: %s\n", + strError.c_str()); + return 1; + } + if (nSQLiteRecords <= 0) + { + fprintf(stderr, "SQLite wallet export contains no records.\n"); + return 2; + } + + CWalletRawRestoreDB bdb("cr+", true); + if (!bdb.TxnBegin()) + { + fprintf(stderr, "Cannot begin Berkeley DB restore transaction.\n"); + return 1; + } + + int nCopied = 0; + CWalletSQLiteRestoreVisitor visitor(bdb, nCopied); + if (!sqlite.ScanRecords(visitor, strError)) + { + bdb.TxnAbort(); + fprintf(stderr, "Cannot restore SQLite wallet export: %s\n", + strError.c_str()); + return 1; + } + if (nCopied != nSQLiteRecords) + { + bdb.TxnAbort(); + fprintf(stderr, "SQLite wallet restore copied %d records, expected %d.\n", + nCopied, nSQLiteRecords); + return 2; + } + if (!bdb.TxnCommit()) + { + fprintf(stderr, "Cannot commit Berkeley DB restore transaction.\n"); + return 1; + } + + printf("SQLite wallet export restored to wallet.dat\n"); + printf(" records restored: %d\n", nCopied); + fflush(stdout); + return 0; +} + int CmdEncryptWallet(const std::string& strPassphrase) { AttachTerminal(); diff --git a/src/walletcmd.h b/src/walletcmd.h index 784c74c..50dc69e 100644 --- a/src/walletcmd.h +++ b/src/walletcmd.h @@ -79,6 +79,10 @@ int CmdWalletSQLiteExport(const std::string& strDest); // wallet.dat raw records. Prints counts only, never record keys or values. int CmdWalletSQLiteVerify(const std::string& strPath); +// Diagnostic/migration staging: rebuild wallet.dat from a SQLite export in an +// empty data directory. Refuses to overwrite an existing wallet.dat. +int CmdWalletSQLiteRestore(const std::string& strPath); + // Rewrite wallet.dat so private keys and the HD seed are encrypted with the // given passphrase. Returns 0 on success and exits without starting the node. int CmdEncryptWallet(const std::string& strPassphrase); -- cgit v1.2.3 From 3e778dd6cc9045862e99503b5b9af80ec682826f Mon Sep 17 00:00:00 2001 From: Nsable-sh Date: Fri, 14 Aug 2026 17:44:09 -0300 Subject: wallet: check SQLite exports against loader types --- docs/storage.md | 3 + src/main_gui.cpp | 11 +++ src/selftest.cpp | 54 ++++++++++++++ src/walletcmd.cpp | 209 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/walletcmd.h | 4 ++ 5 files changed, 281 insertions(+) (limited to 'src/main_gui.cpp') diff --git a/docs/storage.md b/docs/storage.md index aca3f6e..07c7170 100644 --- a/docs/storage.md +++ b/docs/storage.md @@ -117,6 +117,9 @@ SQLite export and prints only record counts and mismatch counts. an empty data directory, then the verifier can prove the restored BDB file still matches the export byte-for-byte. Failed exports remove the incomplete SQLite file and failed restores remove the incomplete `wallet.dat`. +`-walletsqliteloadcheck=FILE` parses a SQLite export using the same record +types the wallet loader understands, so a migration can prove the export is not +just byte-preserving but loader-compatible before runtime activation. ## `blkindex.dat` diff --git a/src/main_gui.cpp b/src/main_gui.cpp index cf96822..c9aac7d 100644 --- a/src/main_gui.cpp +++ b/src/main_gui.cpp @@ -187,6 +187,8 @@ static void PrintUsage() printf(" printing counts only, then exit)\n"); printf(" /walletsqliterestore=FILE (rebuild wallet.dat from a SQLite export in\n"); printf(" an empty data directory, then exit)\n"); + printf(" /walletsqliteloadcheck=FILE\n"); + printf(" (parse a SQLite export like the wallet loader)\n"); printf(" /rescan (walk the chain for coins this wallet owns\n"); printf(" but never recorded, then exit)\n"); printf("\n"); @@ -562,6 +564,15 @@ int main(int argc, char* argv[]) return nRet; } + string strWalletSQLiteLoadCheck = + argval2(argc, argv, "/walletsqliteloadcheck", "-walletsqliteloadcheck"); + if (!strWalletSQLiteLoadCheck.empty()) + { + int nRet = CmdWalletSQLiteLoadCheck(strWalletSQLiteLoadCheck); + DBFlush(true); + return nRet; + } + // Berkeley DB reports failure by throwing, and CDB's constructor lets it // through. Nothing on this path caught anything, so an unreadable // blkindex.dat or wallet.dat -- one written by another platform's Berkeley diff --git a/src/selftest.cpp b/src/selftest.cpp index 343d9c5..feb4076 100644 --- a/src/selftest.cpp +++ b/src/selftest.cpp @@ -1195,6 +1195,28 @@ static bool CreateSQLiteWalletWithEmptyKey(const string& strPath) return fOk; } +static bool CreateSQLiteWalletWithFutureMinVersion(const string& strPath) +{ + string strError; + CWalletDBSQLite db; + CDataStream ssKey(SER_DISK); + ssKey << string("walletminversion"); + CDataStream ssValue(SER_DISK); + ssValue << (WALLET_FORMAT_SUPPORTED + 1); + + if (!db.Open(strPath, strError)) + return false; + if (!db.BeginTransaction(strError)) + return false; + if (!db.WriteRecord(DataStreamBytes(ssKey), DataStreamBytes(ssValue), strError)) + return false; + if (!db.CommitTransaction(strError)) + return false; + if (!db.Checkpoint(strError)) + return false; + return true; +} + static int RunWalletSQLiteMigrationSelfTest() { fflush(stdout); @@ -1263,6 +1285,19 @@ static int RunWalletSQLiteMigrationSelfTest() FileContainsText(strVerifyOut, "verification: ok"), "SQLite wallet export verifies against wallet.dat") ? 0 : 1; + string strLoadCheckOut = tmp + "/sqlite-loadcheck.txt"; + vector vLoadCheckArgs; + vLoadCheckArgs.push_back("-datadir=" + strWalletDir); + vLoadCheckArgs.push_back("-nomanagedtor"); + vLoadCheckArgs.push_back("-nogui"); + vLoadCheckArgs.push_back("-walletsqliteloadcheck=" + strSQLite); + int nLoadCheckRet = + RunBitflashChild(strExe, vLoadCheckArgs, NULL, &strLoadCheckOut); + nFail += Check(nLoadCheckRet == 0 && + FileContainsText(strLoadCheckOut, + "load check: ok"), + "SQLite wallet export passes the loader compatibility check") ? 0 : 1; + string strRestoredDir = tmp + "/restored-wallet"; nFail += Check(MakeDirLocal(strRestoredDir), "restored wallet directory can be created") ? 0 : 1; @@ -1318,6 +1353,25 @@ static int RunWalletSQLiteMigrationSelfTest() !FileExists((strBadRestoreDir + "/wallet.dat").c_str()), "failed SQLite wallet restore removes incomplete wallet.dat") ? 0 : 1; + string strFutureSQLite = tmp + "/future-wallet.sqlite"; + nFail += Check(CreateSQLiteWalletWithFutureMinVersion(strFutureSQLite), + "SQLite export with a future wallet format can be created") ? 0 : 1; + string strFutureLoadCheckOut = tmp + "/sqlite-loadcheck-future.txt"; + vector vFutureLoadCheckArgs; + vFutureLoadCheckArgs.push_back("-datadir=" + strWalletDir); + vFutureLoadCheckArgs.push_back("-nomanagedtor"); + vFutureLoadCheckArgs.push_back("-nogui"); + vFutureLoadCheckArgs.push_back("-walletsqliteloadcheck=" + strFutureSQLite); + int nFutureLoadCheckRet = + RunBitflashChild(strExe, vFutureLoadCheckArgs, NULL, + &strFutureLoadCheckOut); + nFail += Check(nFutureLoadCheckRet == 2 && + FileContainsText(strFutureLoadCheckOut, + "unsupported future wallet format") && + FileContainsText(strFutureLoadCheckOut, + "load check: failed"), + "SQLite wallet load check rejects future wallet formats") ? 0 : 1; + string strOverwriteOut = tmp + "/sqlite-export-overwrite.txt"; int nOverwriteRet = RunBitflashChild(strExe, vExportArgs, NULL, &strOverwriteOut); diff --git a/src/walletcmd.cpp b/src/walletcmd.cpp index 9e4a981..4dda8dd 100644 --- a/src/walletcmd.cpp +++ b/src/walletcmd.cpp @@ -903,6 +903,171 @@ public: } }; +class CWalletSQLiteLoadCheckVisitor : public CWalletRecordVisitor +{ +public: + int nRecords; + int nUnknown; + string strFailure; + + CWalletSQLiteLoadCheckVisitor() : nRecords(0), nUnknown(0) { } + + bool Fail(const string& strMessage, string& strErrorRet) + { + strFailure = strMessage; + strErrorRet = strMessage; + return false; + } + + bool VisitWalletRecord(const CDataStream& ssKeyIn, + const CDataStream& ssValueIn, + string& strErrorRet) + { + nRecords++; + try + { + CDataStream ssKey = ssKeyIn; + CDataStream ssValue = ssValueIn; + string strType; + ssKey >> strType; + if (ssKey.fail()) + return Fail("malformed wallet record key", strErrorRet); + + if (strType == "name") + { + string strAddress; + string strName; + ssKey >> strAddress; + ssValue >> strName; + } + else if (strType == "tx") + { + uint256 hash; + CWalletTx wtx; + ssKey >> hash; + ssValue >> wtx; + } + else if (strType == "key") + { + vector vchPubKey; + CPrivKey vchPrivKey; + ssKey >> vchPubKey; + ssValue >> vchPrivKey; + } + else if (strType == "mkey") + { + unsigned int nID = 0; + CWalletMasterKey kMasterKey; + ssKey >> nID; + ssValue >> kMasterKey; + } + else if (strType == "ckey") + { + vector vchPubKey; + vector vchCryptedSecret; + ssKey >> vchPubKey; + ssValue >> vchCryptedSecret; + } + else if (strType == "defaultkey" || + strType == "hdmaster" || + strType == "hdchaincode" || + strType == "cryptedhdmaster" || + strType == "cryptedhdchaincode") + { + vector vch; + ssValue >> vch; + } + else if (strType == "hdnext" || + strType == "hdreceivenext" || + strType == "hdchangenext" || + strType == "hdcointype") + { + unsigned int nValue = 0; + ssValue >> nValue; + } + else if (strType == "hdschema") + { + int nSchema = 0; + ssValue >> nSchema; + if (!ssValue.fail() && + nSchema != HD_SCHEMA_NONE && + nSchema != HD_SCHEMA_LEGACY && + nSchema != HD_SCHEMA_BIP44) + return Fail("unsupported deterministic wallet schema", + strErrorRet); + } + else if (strType == "walletminversion") + { + int nMinVersion = 0; + ssValue >> nMinVersion; + if (!ssValue.fail() && nMinVersion > WALLET_FORMAT_SUPPORTED) + return Fail("unsupported future wallet format", strErrorRet); + } + else if (strType == "pool") + { + int64 nIndex = 0; + vector vchPubKey; + ssKey >> nIndex; + ssValue >> vchPubKey; + } + else if (strType == "setting") + { + string strKey; + ssKey >> strKey; + if (strKey == "nTransactionFee") + { + int64 nValue = 0; + ssValue >> nValue; + } + else if (strKey == "addrIncoming") + { + CAddress addr; + ssValue >> addr; + } + else if (strKey == "nMineMode") + { + int nValue = 0; + ssValue >> nValue; + } + else if (strKey == "fGenerateBitcoins") + { + int fValue = 0; + ssValue >> fValue; + } + else if (strKey == "strParticipantPool") + { + string strValue; + ssValue >> strValue; + } + } + else if (strType == "version") + { + int nVersion = 0; + ssValue >> nVersion; + } + else + { + nUnknown++; + } + + if (ssKey.fail()) + return Fail("malformed wallet record key", strErrorRet); + if (ssValue.fail()) + return Fail("malformed wallet record value", strErrorRet); + } + catch (const std::exception& e) + { + return Fail(strprintf("wallet record parse exception: %s", e.what()), + strErrorRet); + } + catch (...) + { + return Fail("wallet record parse exception", strErrorRet); + } + return true; + } +}; + } // namespace int CmdWalletSQLiteExport(const std::string& strDest) @@ -1152,6 +1317,50 @@ int CmdWalletSQLiteRestore(const std::string& strPath) return 0; } +int CmdWalletSQLiteLoadCheck(const std::string& strPath) +{ + AttachTerminal(); + + if (strPath.empty()) + { + fprintf(stderr, "Missing SQLite wallet export path.\n"); + return 1; + } + if (!FileExists(strPath.c_str())) + { + fprintf(stderr, "SQLite wallet export does not exist: %s\n", + strPath.c_str()); + return 1; + } + + string strError; + CWalletDBSQLite sqlite; + if (!sqlite.Open(strPath, strError)) + { + fprintf(stderr, "Cannot open SQLite wallet export: %s\n", strError.c_str()); + return 1; + } + + CWalletSQLiteLoadCheckVisitor visitor; + if (!sqlite.ScanRecords(visitor, strError)) + { + printf("SQLite wallet load check\n"); + printf(" records checked: %d\n", visitor.nRecords); + printf(" unknown records: %d\n", visitor.nUnknown); + printf(" load check: failed\n"); + printf(" failure: %s\n", strError.c_str()); + fflush(stdout); + return 2; + } + + printf("SQLite wallet load check\n"); + printf(" records checked: %d\n", visitor.nRecords); + printf(" unknown records: %d\n", visitor.nUnknown); + printf(" load check: ok\n"); + fflush(stdout); + return 0; +} + int CmdEncryptWallet(const std::string& strPassphrase) { AttachTerminal(); diff --git a/src/walletcmd.h b/src/walletcmd.h index 50dc69e..d993856 100644 --- a/src/walletcmd.h +++ b/src/walletcmd.h @@ -83,6 +83,10 @@ int CmdWalletSQLiteVerify(const std::string& strPath); // empty data directory. Refuses to overwrite an existing wallet.dat. int CmdWalletSQLiteRestore(const std::string& strPath); +// Diagnostic/migration staging: parse a SQLite wallet export as the runtime +// wallet loader would. Prints counts only, never record keys or values. +int CmdWalletSQLiteLoadCheck(const std::string& strPath); + // Rewrite wallet.dat so private keys and the HD seed are encrypted with the // given passphrase. Returns 0 on success and exits without starting the node. int CmdEncryptWallet(const std::string& strPassphrase); -- cgit v1.2.3 From f9ee957bd6d3f28dfee166a82f389aad666a0119 Mon Sep 17 00:00:00 2001 From: Nsable-sh Date: Fri, 14 Aug 2026 18:17:23 -0300 Subject: wallet: stage SQLite runtime loading --- docs/storage.md | 4 + src/db.cpp | 361 +++++++++++++++++++++++++++++++++++++++++++++++--- src/db.h | 1 + src/main_gui.cpp | 22 +++ src/selftest.cpp | 29 ++++ src/wallet_sqlite.cpp | 19 +++ src/wallet_sqlite.h | 1 + 7 files changed, 420 insertions(+), 17 deletions(-) (limited to 'src/main_gui.cpp') diff --git a/docs/storage.md b/docs/storage.md index 07c7170..08c4a01 100644 --- a/docs/storage.md +++ b/docs/storage.md @@ -120,6 +120,10 @@ file and failed restores remove the incomplete `wallet.dat`. `-walletsqliteloadcheck=FILE` parses a SQLite export using the same record types the wallet loader understands, so a migration can prove the export is not just byte-preserving but loader-compatible before runtime activation. +`-walletsqlite=FILE` is the first runtime staging flag: it loads a SQLite export +through the startup wallet path, initializes the in-memory wallet state, reports +success, and exits before network, mining, GUI, or wallet mutation begins. It is +deliberately read-only until the write path is routed to SQLite as well. ## `blkindex.dat` diff --git a/src/db.cpp b/src/db.cpp index 8b82fe5..581e5a7 100644 --- a/src/db.cpp +++ b/src/db.cpp @@ -3,6 +3,7 @@ // file license.txt or http://www.opensource.org/licenses/mit-license.php. #include "headers.h" +#include "wallet_sqlite.h" @@ -650,6 +651,289 @@ bool CReviewDB::WriteReviews(uint256 hash, const vector& vReviews) // CWalletDB // +class CSQLiteWalletRuntimeLoadVisitor : public CWalletRecordVisitor +{ +public: + bool fHaveStoredMineMode; + bool fHaveStoredHDCoinType; + string strLastType; + + CSQLiteWalletRuntimeLoadVisitor() : + fHaveStoredMineMode(false), + fHaveStoredHDCoinType(false) + { + } + + bool VisitWalletRecord(const CDataStream& ssKeyIn, + const CDataStream& ssValueIn, + string& strErrorRet) + { + CDataStream ssKey = ssKeyIn; + CDataStream ssValue = ssValueIn; + string strType; + ssKey >> strType; + strLastType = strType; + + if (strType == "name") + { + string strAddress; + ssKey >> strAddress; + ssValue >> mapAddressBook[strAddress]; + } + else if (strType == "tx") + { + uint256 hash; + ssKey >> hash; + CWalletTx& wtx = mapWallet[hash]; + ssValue >> wtx; + + if (wtx.GetHash() != hash) + printf("Error in SQLite wallet export, hash mismatch\n"); + } + else if (strType == "key") + { + vector vchPubKey; + ssKey >> vchPubKey; + CPrivKey vchPrivKey; + ssValue >> vchPrivKey; + + mapKeys[vchPubKey] = vchPrivKey; + mapPubKeys[Hash160(vchPubKey)] = vchPubKey; + } + else if (strType == "mkey") + { + unsigned int nID = 0; + ssKey >> nID; + CWalletMasterKey kMasterKey; + ssValue >> kMasterKey; + mapMasterKeys[nID] = kMasterKey; + nWalletMasterKeyMaxID = max(nWalletMasterKeyMaxID, nID); + fWalletEncrypted = true; + fWalletLocked = true; + } + else if (strType == "ckey") + { + vector vchPubKey; + ssKey >> vchPubKey; + vector vchCryptedSecret; + ssValue >> vchCryptedSecret; + if (!AddCryptedKey(vchPubKey, vchCryptedSecret)) + { + strErrorRet = "SQLite wallet export has an unreadable encrypted key record"; + return false; + } + } + else if (strType == "defaultkey") + { + ssValue >> vchDefaultKey; + } + else if (strType == "hdmaster") + { + ssValue >> vchHDMaster; + } + else if (strType == "hdchaincode") + { + ssValue >> vchHDChainCode; + } + else if (strType == "hdnext") + { + ssValue >> nHDNext; + } + else if (strType == "hdschema") + { + ssValue >> nHDKeySchema; + } + else if (strType == "hdcointype") + { + ssValue >> nHDCoinType; + fHaveStoredHDCoinType = true; + } + else if (strType == "hdreceivenext") + { + ssValue >> nHDReceiveNext; + } + else if (strType == "hdchangenext") + { + ssValue >> nHDChangeNext; + } + else if (strType == "cryptedhdmaster") + { + ssValue >> vchCryptedHDMaster; + fWalletEncrypted = true; + fWalletLocked = true; + } + else if (strType == "cryptedhdchaincode") + { + ssValue >> vchCryptedHDChainCode; + fWalletEncrypted = true; + fWalletLocked = true; + } + else if (strType == "walletminversion") + { + int nMinVersion = 0; + ssValue >> nMinVersion; + if (nMinVersion > WALLET_FORMAT_SUPPORTED) + { + strWalletLoadError = strprintf( + "This SQLite wallet export was written by a newer version of " + "Bitflash (wallet format %d; this build understands %d). " + "Upgrade Bitflash before opening it.", + nMinVersion, WALLET_FORMAT_SUPPORTED); + strErrorRet = strWalletLoadError; + return false; + } + } + else if (strType == "pool") + { + int64 nIndex; + ssKey >> nIndex; + vector vchPubKey; + ssValue >> vchPubKey; + mapKeyPool[nIndex] = vchPubKey; + } + else if (strType == "setting") + { + string strKey; + ssKey >> strKey; + if (strKey == "nTransactionFee") ssValue >> nTransactionFee; + if (strKey == "addrIncoming") ssValue >> addrIncoming; + + if (!fMineModeFromCommandLine) + { + if (strKey == "nMineMode") { ssValue >> nMineMode; fHaveStoredMineMode = true; } + if (strKey == "fGenerateBitcoins") ssValue >> fGenerateBitcoins; + if (strKey == "strParticipantPool") ssValue >> strParticipantPool; + } + } + + if (ssKey.fail()) + { + strErrorRet = strprintf("malformed SQLite wallet record key while handling '%s'", + strLastType.c_str()); + return false; + } + if (ssValue.fail()) + { + strErrorRet = strprintf("malformed SQLite wallet record value while handling '%s'", + strLastType.c_str()); + return false; + } + return true; + } + + vector vchDefaultKey; +}; + +static bool FinishLoadedWallet(const vector& vchDefaultKey, + bool fMayCreateDefaultKey) +{ + if (!vchDefaultKey.empty() && mapPubKeys.count(Hash160(vchDefaultKey))) + { + keyUser.SetPubKey(vchDefaultKey); + if (mapKeys.count(vchDefaultKey)) + keyUser.SetPrivKey(mapKeys[vchDefaultKey]); + } + else if (IsWalletEncrypted()) + { + printf("LoadWallet: encrypted wallet has no usable default public key. " + "The file was left untouched.\n"); + return false; + } + else + { + if (!fMayCreateDefaultKey) + { + strWalletLoadError = + "SQLite wallet export has no usable default public key"; + printf("LoadWallet: %s\n", strWalletLoadError.c_str()); + return false; + } + + keyUser.MakeNewKey(); + if (!AddKey(keyUser)) + return false; + if (!SetAddressBookName(PubKeyToAddress(keyUser.GetPubKey()), "Your Address")) + return false; + CWalletDB().WriteDefaultKey(keyUser.GetPubKey()); + } + + return true; +} + +static bool ReconcileLoadedWalletMetadata(bool fHaveStoredMineMode, + bool fHaveStoredHDCoinType) +{ + // An encrypted wallet has a seed; it just has not been decrypted yet. + // + // The reset in the else branch was written when "no seed in memory after + // loading" could only mean "this wallet has no seed". With encryption that + // is no longer true, and taking the else branch here wiped the schema and + // the derivation counters that had just been read correctly out of the + // file. Unlocking later restores the seed but nothing restores those, so a + // BIP44 wallet came back deriving legacy addresses -- different addresses + // from the ones its own recovery phrase produces, while the audit reported + // the phrase as covering nothing. + if (HaveHDSeed() || !vchCryptedHDMaster.empty()) + { + if (nHDKeySchema == HD_SCHEMA_NONE) + { + nHDKeySchema = HD_SCHEMA_LEGACY; + printf("LoadWallet: deterministic seed has no schema metadata; " + "treating it as legacy m/index'\n"); + } + else if (nHDKeySchema != HD_SCHEMA_LEGACY && + nHDKeySchema != HD_SCHEMA_BIP44) + { + printf("LoadWallet: deterministic wallet schema %d (%s) is not " + "supported by this build\n", + nHDKeySchema, HDKeySchemaName(nHDKeySchema).c_str()); + return false; + } + if (!fHaveStoredHDCoinType) + { + nHDCoinType = HD_BIP44_COIN_TYPE_BITFLASH; + printf("LoadWallet: deterministic seed has no BIP44 coin type metadata; " + "using Bitflash coin type %u\n", nHDCoinType); + } + } + else + { + nHDKeySchema = HD_SCHEMA_NONE; + nHDNext = 0; + nHDReceiveNext = 0; + nHDChangeNext = 0; + nHDCoinType = HD_BIP44_COIN_TYPE_BITFLASH; + } + + // fGenerateBitcoins and nMineMode only mean anything together, and a + // wallet.dat can easily hold one without the other: Bitcoin 0.1.0 already + // wrote fGenerateBitcoins when mining was toggled, years before this fork + // had a mode at all. Restoring that lone flag put a node into the one state + // the miner cannot act on -- generate on, mode relay -- so every miner + // thread started and returned at the relay guard, and the machine sat there + // looking like it was mining while hashing nothing. That happened to a + // 32-core node on the first 1.2.11 start. + // + // So the flag is only believed when the mode it belongs to was stored with + // it, and the pair is made coherent either way. + if (!fMineModeFromCommandLine) + { + if (!fHaveStoredMineMode) + { + if (fGenerateBitcoins) + printf("LoadWallet: ignoring a stored fGenerateBitcoins with no mining mode " + "beside it -- it predates this setting and cannot be read on its own\n"); + fGenerateBitcoins = 0; + } + else + { + fGenerateBitcoins = (nMineMode != MINE_RELAY) ? 1 : 0; + } + } + + return true; +} + bool CWalletDB::LoadWallet(vector& vchDefaultKeyRet) { // Whether wallet.dat actually carried a mining mode, as opposed to just the @@ -1005,31 +1289,74 @@ bool LoadWallet() if (!CWalletDB("cr").LoadWallet(vchDefaultKey)) return false; - if (!vchDefaultKey.empty() && mapPubKeys.count(Hash160(vchDefaultKey))) + return FinishLoadedWallet(vchDefaultKey, true); +} + +bool LoadWalletFromSQLite(const string& strPath) +{ + strWalletLoadError.clear(); + + if (!FileExists(strPath.c_str())) { - // Set keyUser - keyUser.SetPubKey(vchDefaultKey); - if (mapKeys.count(vchDefaultKey)) - keyUser.SetPrivKey(mapKeys[vchDefaultKey]); + strWalletLoadError = strprintf("SQLite wallet export does not exist: %s", + strPath.c_str()); + printf("LoadWallet: %s\n", strWalletLoadError.c_str()); + return false; } - else if (IsWalletEncrypted()) + + CWalletDBSQLite sqlite; + string strError; + if (!sqlite.OpenReadOnly(strPath, strError)) { - printf("LoadWallet: encrypted wallet has no usable default public key. " - "The file was left untouched.\n"); + strWalletLoadError = strError; + printf("LoadWallet: cannot open SQLite wallet export: %s\n", + strError.c_str()); return false; } - else + + CSQLiteWalletRuntimeLoadVisitor visitor; + try { - // Create new keyUser and set as default key - keyUser.MakeNewKey(); - if (!AddKey(keyUser)) - return false; - if (!SetAddressBookName(PubKeyToAddress(keyUser.GetPubKey()), "Your Address")) - return false; - CWalletDB().WriteDefaultKey(keyUser.GetPubKey()); + CRITICAL_BLOCK(cs_mapKeys) + CRITICAL_BLOCK(cs_mapWallet) + { + if (!sqlite.ScanRecords(visitor, strError)) + { + if (strWalletLoadError.empty()) + strWalletLoadError = strError; + printf("LoadWallet: SQLite wallet export could not be read. " + "Failed while handling a '%s' record: %s\n", + visitor.strLastType.c_str(), strError.c_str()); + return false; + } + } + } + catch (const std::exception& e) + { + strWalletLoadError = strprintf( + "SQLite wallet export could not be read while handling a '%s' record: %s", + visitor.strLastType.c_str(), e.what()); + printf("LoadWallet: %s\n", strWalletLoadError.c_str()); + return false; + } + catch (...) + { + strWalletLoadError = strprintf( + "SQLite wallet export could not be read while handling a '%s' record", + visitor.strLastType.c_str()); + printf("LoadWallet: %s\n", strWalletLoadError.c_str()); + return false; } - return true; + if (!ReconcileLoadedWalletMetadata(visitor.fHaveStoredMineMode, + visitor.fHaveStoredHDCoinType)) + { + if (strWalletLoadError.empty()) + strWalletLoadError = "SQLite wallet metadata is not supported by this build"; + return false; + } + + return FinishLoadedWallet(visitor.vchDefaultKey, false); } diff --git a/src/db.h b/src/db.h index 53bda59..59ff24a 100644 --- a/src/db.h +++ b/src/db.h @@ -536,6 +536,7 @@ public: bool ScanWalletRecords(CWalletRecordVisitor& visitor, string& strErrorRet); bool LoadWallet(); +bool LoadWalletFromSQLite(const string& strPath); // Writes a wallet.dat that opens on its own, anywhere -- see the definition in // db.cpp for why a plain file copy does not. Still a point-in-time snapshot: diff --git a/src/main_gui.cpp b/src/main_gui.cpp index c9aac7d..ee875c7 100644 --- a/src/main_gui.cpp +++ b/src/main_gui.cpp @@ -189,6 +189,7 @@ static void PrintUsage() printf(" an empty data directory, then exit)\n"); printf(" /walletsqliteloadcheck=FILE\n"); printf(" (parse a SQLite export like the wallet loader)\n"); + printf(" /walletsqlite=FILE (stage-load a SQLite wallet export, then exit)\n"); printf(" /rescan (walk the chain for coins this wallet owns\n"); printf(" but never recorded, then exit)\n"); printf("\n"); @@ -617,6 +618,27 @@ int main(int argc, char* argv[]) printf("Loading wallet...\n"); try { + string strWalletSQLite = + argval2(argc, argv, "/walletsqlite", "-walletsqlite"); + if (!strWalletSQLite.empty()) + { + AttachTerminal(); + if (!LoadWalletFromSQLite(strWalletSQLite)) + { + FatalStartupError(fHeadlessStartup, + "Cannot open SQLite wallet export.", + strWalletLoadError); + return 1; + } + + fprintf(stderr, "SQLite wallet loaded: read-only staging backend\n"); + fprintf(stderr, "SQLite runtime writes are not enabled yet; exiting before " + "network, mining, GUI, or wallet mutation starts.\n"); + fflush(stderr); + DBFlush(true); + return 0; + } + if (!LoadWallet()) { // LoadWallet() explains itself into strWalletLoadError when it diff --git a/src/selftest.cpp b/src/selftest.cpp index feb4076..14bf45a 100644 --- a/src/selftest.cpp +++ b/src/selftest.cpp @@ -1298,6 +1298,19 @@ static int RunWalletSQLiteMigrationSelfTest() "load check: ok"), "SQLite wallet export passes the loader compatibility check") ? 0 : 1; + string strRuntimeOut = tmp + "/sqlite-runtime.txt"; + vector vRuntimeArgs; + vRuntimeArgs.push_back("-datadir=" + strWalletDir); + vRuntimeArgs.push_back("-nomanagedtor"); + vRuntimeArgs.push_back("-nogui"); + vRuntimeArgs.push_back("-walletsqlite=" + strSQLite); + int nRuntimeRet = + RunBitflashChild(strExe, vRuntimeArgs, NULL, &strRuntimeOut); + nFail += Check(nRuntimeRet == 0 && + FileContainsText(strRuntimeOut, + "SQLite wallet loaded: read-only staging backend"), + "SQLite wallet export loads through the runtime startup path") ? 0 : 1; + string strRestoredDir = tmp + "/restored-wallet"; nFail += Check(MakeDirLocal(strRestoredDir), "restored wallet directory can be created") ? 0 : 1; @@ -1372,6 +1385,22 @@ static int RunWalletSQLiteMigrationSelfTest() "load check: failed"), "SQLite wallet load check rejects future wallet formats") ? 0 : 1; + string strFutureRuntimeOut = tmp + "/sqlite-runtime-future.txt"; + vector vFutureRuntimeArgs; + vFutureRuntimeArgs.push_back("-datadir=" + strWalletDir); + vFutureRuntimeArgs.push_back("-nomanagedtor"); + vFutureRuntimeArgs.push_back("-nogui"); + vFutureRuntimeArgs.push_back("-walletsqlite=" + strFutureSQLite); + int nFutureRuntimeRet = + RunBitflashChild(strExe, vFutureRuntimeArgs, NULL, + &strFutureRuntimeOut); + nFail += Check(nFutureRuntimeRet == 1 && + FileContainsText(strFutureRuntimeOut, + "Cannot open SQLite wallet export") && + FileContainsText(strFutureRuntimeOut, + "newer version of Bitflash"), + "SQLite runtime startup rejects future wallet formats") ? 0 : 1; + string strOverwriteOut = tmp + "/sqlite-export-overwrite.txt"; int nOverwriteRet = RunBitflashChild(strExe, vExportArgs, NULL, &strOverwriteOut); diff --git a/src/wallet_sqlite.cpp b/src/wallet_sqlite.cpp index 8489cac..ca81573 100644 --- a/src/wallet_sqlite.cpp +++ b/src/wallet_sqlite.cpp @@ -58,6 +58,25 @@ bool CWalletDBSQLite::Open(const string& strPath, string& strError) return true; } +bool CWalletDBSQLite::OpenReadOnly(const string& strPath, string& strError) +{ + Close(); + + int ret = sqlite3_open_v2(strPath.c_str(), &pdb, + SQLITE_OPEN_READONLY | + SQLITE_OPEN_FULLMUTEX, + NULL); + if (ret != SQLITE_OK) + { + SetSQLiteError(pdb, "cannot open SQLite wallet read-only", strError); + Close(); + return false; + } + + sqlite3_busy_timeout(pdb, 5000); + return true; +} + void CWalletDBSQLite::Close() { if (pdb) diff --git a/src/wallet_sqlite.h b/src/wallet_sqlite.h index 385a340..5e6467b 100644 --- a/src/wallet_sqlite.h +++ b/src/wallet_sqlite.h @@ -15,6 +15,7 @@ public: ~CWalletDBSQLite(); bool Open(const string& strPath, string& strError); + bool OpenReadOnly(const string& strPath, string& strError); void Close(); bool BeginTransaction(string& strError); -- cgit v1.2.3 From 2a2b9d74f8fb6b5d86d750e6e5d18c942738f95c Mon Sep 17 00:00:00 2001 From: bitflash Date: Mon, 17 Aug 2026 17:45:01 -0300 Subject: wallet: run the node on a SQLite backend (opt-in) Adds -walletbackend=sqlite: an opt-in flag that runs the node on /wallet.sqlite instead of wallet.dat. It requires an export to exist first (no silent migration) and prints a step-by-step guide when one does not; wallet.dat is never opened or touched, so a plain restart goes back to Berkeley DB. - LoadWalletFromSQLiteRuntime opens the export read-write, keeps the handle open as the active backend, loads records into memory through the same visitor/reconcile/finish path as the read-only staging load, and never fabricates a default key. - Shutdown checkpoints and closes the SQLite backend; a kill before that loses nothing because WAL + synchronous=FULL already made every committed write durable. - Self-test proves runtime writes persist across a close and reopen. BDB stays the default: nothing activates the SQLite runtime unless the flag is given. One-shot audit/recovery commands still use Berkeley DB. --- src/db.cpp | 91 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/db.h | 1 + src/main_gui.cpp | 66 +++++++++++++++++++++++++++++++++++++--- src/selftest.cpp | 25 ++++++++++++++++ 4 files changed, 179 insertions(+), 4 deletions(-) (limited to 'src/main_gui.cpp') diff --git a/src/db.cpp b/src/db.cpp index 7cfb7c5..c4ea81e 100644 --- a/src/db.cpp +++ b/src/db.cpp @@ -124,6 +124,13 @@ void WalletSQLiteRuntimeClose() { if (pWalletSQLiteRuntime) { + // Fold the write-ahead log into the main file on the way out. WAL + + // synchronous=FULL already makes every committed write durable across a + // kill, so this is housekeeping, not the thing that protects the data: + // if it fails, or the node is killed before it runs, the wallet is + // still whole. Best effort, never throws. + string strCheckpointError; + pWalletSQLiteRuntime->Checkpoint(strCheckpointError); delete pWalletSQLiteRuntime; pWalletSQLiteRuntime = NULL; } @@ -1426,6 +1433,90 @@ bool LoadWalletFromSQLite(const string& strPath) return FinishLoadedWallet(visitor.vchDefaultKey, false); } +// Open a SQLite wallet read-write and keep it open as the active runtime +// backend, then load its records into memory the same way LoadWalletFromSQLite +// does. Unlike the read-only staging path, this leaves pWalletSQLiteRuntime set +// so every later CWalletDB read/write/erase routes to this file for the life of +// the process. The Berkeley DB wallet.dat is never opened. +bool LoadWalletFromSQLiteRuntime(const string& strPath) +{ + strWalletLoadError.clear(); + + if (!FileExists(strPath.c_str())) + { + strWalletLoadError = strprintf("SQLite wallet does not exist: %s", + strPath.c_str()); + printf("LoadWallet: %s\n", strWalletLoadError.c_str()); + return false; + } + + string strError; + if (!WalletSQLiteRuntimeOpen(strPath, strError)) + { + strWalletLoadError = strError; + printf("LoadWallet: cannot open SQLite wallet backend: %s\n", + strError.c_str()); + return false; + } + + CSQLiteWalletRuntimeLoadVisitor visitor; + try + { + CRITICAL_BLOCK(cs_mapKeys) + CRITICAL_BLOCK(cs_mapWallet) + { + if (!pWalletSQLiteRuntime->ScanRecords(visitor, strError)) + { + WalletSQLiteRuntimeClose(); + if (strWalletLoadError.empty()) + strWalletLoadError = strError; + printf("LoadWallet: SQLite wallet backend could not be read. " + "Failed while handling a '%s' record: %s\n", + visitor.strLastType.c_str(), strError.c_str()); + return false; + } + } + } + catch (const std::exception& e) + { + WalletSQLiteRuntimeClose(); + strWalletLoadError = strprintf( + "SQLite wallet backend could not be read while handling a '%s' record: %s", + visitor.strLastType.c_str(), e.what()); + printf("LoadWallet: %s\n", strWalletLoadError.c_str()); + return false; + } + catch (...) + { + WalletSQLiteRuntimeClose(); + strWalletLoadError = strprintf( + "SQLite wallet backend could not be read while handling a '%s' record", + visitor.strLastType.c_str()); + printf("LoadWallet: %s\n", strWalletLoadError.c_str()); + return false; + } + + if (!ReconcileLoadedWalletMetadata(visitor.fHaveStoredMineMode, + visitor.fHaveStoredHDCoinType)) + { + WalletSQLiteRuntimeClose(); + if (strWalletLoadError.empty()) + strWalletLoadError = "SQLite wallet metadata is not supported by this build"; + return false; + } + + // Never fabricate a default key: an export always carries one, and a file + // that does not is refused with the wallet left open to no writes rather + // than quietly minted into a new identity. + if (!FinishLoadedWallet(visitor.vchDefaultKey, false)) + { + WalletSQLiteRuntimeClose(); + return false; + } + + return true; +} + // Write a copy of wallet.dat that can be opened anywhere. // diff --git a/src/db.h b/src/db.h index 8fa4ea8..fee5a82 100644 --- a/src/db.h +++ b/src/db.h @@ -624,6 +624,7 @@ bool ScanWalletRecords(CWalletRecordVisitor& visitor, string& strErrorRet); bool LoadWallet(); bool LoadWalletFromSQLite(const string& strPath); +bool LoadWalletFromSQLiteRuntime(const string& strPath); // Writes a wallet.dat that opens on its own, anywhere -- see the definition in // db.cpp for why a plain file copy does not. Still a point-in-time snapshot: diff --git a/src/main_gui.cpp b/src/main_gui.cpp index ee875c7..4d77533 100644 --- a/src/main_gui.cpp +++ b/src/main_gui.cpp @@ -190,6 +190,8 @@ static void PrintUsage() printf(" /walletsqliteloadcheck=FILE\n"); printf(" (parse a SQLite export like the wallet loader)\n"); printf(" /walletsqlite=FILE (stage-load a SQLite wallet export, then exit)\n"); + printf(" /walletbackend=sqlite (opt-in: run the node on /wallet.sqlite\n"); + printf(" instead of wallet.dat; export one first)\n"); printf(" /rescan (walk the chain for coins this wallet owns\n"); printf(" but never recorded, then exit)\n"); printf("\n"); @@ -618,9 +620,59 @@ int main(int argc, char* argv[]) printf("Loading wallet...\n"); try { + string strWalletBackend = + argval2(argc, argv, "/walletbackend", "-walletbackend"); string strWalletSQLite = argval2(argc, argv, "/walletsqlite", "-walletsqlite"); - if (!strWalletSQLite.empty()) + + if (strWalletBackend == "sqlite") + { + // Opt-in, experimental. The node runs on /wallet.sqlite and + // never opens wallet.dat, but the user has to have exported one + // first -- there is no silent migration, and wallet.dat is left + // exactly where it is so a plain restart goes back to it. + AttachTerminal(); + string strSQLitePath = GetAppDir() + "/wallet.sqlite"; + if (!FileExists(strSQLitePath.c_str())) + { + string strGuide = strprintf( + "No SQLite wallet was found at\n" + " %s\n\n" + "The SQLite wallet backend is opt-in and needs an exported\n" + "wallet first. Your Berkeley DB wallet.dat is not touched by\n" + "any of this.\n\n" + " Step 1 Export your existing wallet to SQLite:\n" + " bitflash -walletsqliteexport=\"%s\"\n" + " Step 2 Start again with the SQLite backend:\n" + " bitflash -walletbackend=sqlite\n\n" + "To go back to Berkeley DB at any time, just start without\n" + "-walletbackend=sqlite. wallet.dat is still your wallet.", + strSQLitePath.c_str(), strSQLitePath.c_str()); + FatalStartupError(fHeadlessStartup, + "The SQLite wallet backend needs an exported wallet first.", + strGuide); + return 1; + } + + if (!LoadWalletFromSQLiteRuntime(strSQLitePath)) + { + FatalStartupError(fHeadlessStartup, + "Cannot open the SQLite wallet backend.", + strWalletLoadError); + return 1; + } + + fprintf(stderr, + "Wallet backend: SQLite (experimental)\n" + " %s\n" + "Your Berkeley DB wallet.dat is left untouched. Restart without\n" + "-walletbackend=sqlite to go back to it.\n", + strSQLitePath.c_str()); + fflush(stderr); + // Fall through and run the node normally: every CWalletDB read, + // write, and erase now routes to this SQLite file. + } + else if (!strWalletSQLite.empty()) { AttachTerminal(); if (!LoadWalletFromSQLite(strWalletSQLite)) @@ -638,8 +690,7 @@ int main(int argc, char* argv[]) DBFlush(true); return 0; } - - if (!LoadWallet()) + else if (!LoadWallet()) { // LoadWallet() explains itself into strWalletLoadError when it // knows why -- an unsupported wallet format, for one, which is the @@ -650,7 +701,9 @@ int main(int argc, char* argv[]) } // After the block index, so there is a chain to compare the wallet - // against, and before anything reports a balance. + // against, and before anything reports a balance. Works on either + // backend: RescanSpentFlags writes corrected flags through CWalletDB, + // which routes to SQLite when that backend is active. RescanSpentFlags(); } catch (const std::exception& e) @@ -894,6 +947,10 @@ int main(int argc, char* argv[]) // called here, which is why a wallet.dat copied elsewhere would not // open -- issue #40. Not optional. DBFlush(true); + // Checkpoint and close the SQLite wallet backend if one is active. A + // no-op on the Berkeley DB path. Safe to kill before this runs: WAL + + // synchronous=FULL already made every committed write durable. + WalletSQLiteRuntimeClose(); #ifdef _WIN32 // wallet.dat is safely flushed and closed now; let a pending console // control handler (CLOSE/LOGOFF/SHUTDOWN) return so the OS can finish. @@ -910,6 +967,7 @@ int main(int argc, char* argv[]) StopNode(); BtfStopManagedTor(); DBFlush(true); + WalletSQLiteRuntimeClose(); return ret; #endif } diff --git a/src/selftest.cpp b/src/selftest.cpp index 9fa0938..098eed6 100644 --- a/src/selftest.cpp +++ b/src/selftest.cpp @@ -1230,6 +1230,31 @@ static int RunWalletSQLiteSelfTest() nFail += Check(!FileExists((strRuntimeDir + "/wallet.dat").c_str()), "active SQLite runtime does not create wallet.dat") ? 0 : 1; + + // Durability: close the backend (checkpoints the WAL), reopen + // the same file, and confirm what CWalletDB wrote is still + // there. This is the write-survives-a-restart property the real + // -walletbackend=sqlite node depends on. + WalletSQLiteRuntimeClose(); + bool fReopened = + WalletSQLiteRuntimeOpen(strRuntimePath, strError); + nFail += Check(fReopened, + "SQLite runtime wallet reopens after close") ? 0 : 1; + if (fReopened) + { + int64 nPersisted = 0; + CPrivKey vchPersistedPrivKey; + vector vchPersistedDefaultKey; + nFail += Check(CWalletDB("r").ReadSetting("runtime-sqlite-setting", + nPersisted) && + nPersisted == 909090 && + CWalletDB("r").ReadDefaultKey(vchPersistedDefaultKey) && + vchPersistedDefaultKey == vchRuntimePubKey && + CWalletDB("r").ReadKey(vchRuntimePubKey, + vchPersistedPrivKey) && + vchPersistedPrivKey == vchRuntimePrivKey, + "SQLite runtime writes persist across a close and reopen") ? 0 : 1; + } } WalletSQLiteRuntimeClose(); -- cgit v1.2.3 From 00c7aaed74e57da18598faa6751b7720598de3bd Mon Sep 17 00:00:00 2001 From: bitflash Date: Tue, 18 Aug 2026 10:16:59 -0300 Subject: wallet: clearer refusal for an empty or misnamed SQLite backend Review follow-up on the SQLite activation. Two guards, both fail-closed; neither is a correctness fix -- FinishLoadedWallet already refuses a missing default key -- but they make the refusal say what happened: - an empty or non-wallet wallet.sqlite is refused with "no default key record; it may be empty or not a wallet export", instead of the generic "no usable default public key" that reads as corruption; - a misspelled -walletbackend value (e.g. sqlit) is refused instead of silently falling through to Berkeley DB; -walletbackend=bdb is accepted as the explicit default. --- src/db.cpp | 16 ++++++++++++++++ src/main_gui.cpp | 15 +++++++++++++++ 2 files changed, 31 insertions(+) (limited to 'src/main_gui.cpp') diff --git a/src/db.cpp b/src/db.cpp index c4ea81e..771a4ba 100644 --- a/src/db.cpp +++ b/src/db.cpp @@ -1496,6 +1496,22 @@ bool LoadWalletFromSQLiteRuntime(const string& strPath) return false; } + // Refuse an empty or non-wallet SQLite file up front, with a message that + // names the likely cause. FinishLoadedWallet below already rejects a + // missing default key (it is the fMayCreateDefaultKey=false path), but its + // wording -- "no usable default public key" -- reads as corruption when the + // real story is usually "this file was never an exported wallet". A blank + // wallet.sqlite left by a typo or a half-run export should say so. + if (visitor.vchDefaultKey.empty()) + { + WalletSQLiteRuntimeClose(); + strWalletLoadError = + "SQLite wallet has no default key record; it may be empty or not a " + "wallet export. Create it with -walletsqliteexport."; + printf("LoadWallet: %s\n", strWalletLoadError.c_str()); + return false; + } + if (!ReconcileLoadedWalletMetadata(visitor.fHaveStoredMineMode, visitor.fHaveStoredHDCoinType)) { diff --git a/src/main_gui.cpp b/src/main_gui.cpp index 4d77533..4be4995 100644 --- a/src/main_gui.cpp +++ b/src/main_gui.cpp @@ -625,6 +625,21 @@ int main(int argc, char* argv[]) string strWalletSQLite = argval2(argc, argv, "/walletsqlite", "-walletsqlite"); + // A misspelled backend must not fall through to Berkeley DB silently -- + // someone who typed -walletbackend=sqlit meant to run on SQLite, and + // opening wallet.dat instead of telling them is the wrong surprise. + if (!strWalletBackend.empty() && + strWalletBackend != "sqlite" && strWalletBackend != "bdb") + { + AttachTerminal(); + FatalStartupError(fHeadlessStartup, + strprintf("Unknown wallet backend '%s'.", strWalletBackend.c_str()), + "Use -walletbackend=sqlite for the experimental SQLite backend, " + "or omit -walletbackend (or -walletbackend=bdb) to use the " + "default Berkeley DB wallet.dat."); + return 1; + } + if (strWalletBackend == "sqlite") { // Opt-in, experimental. The node runs on /wallet.sqlite and -- cgit v1.2.3 From 84a60275569dca5d1fb12db38ce184caf7487ed6 Mon Sep 17 00:00:00 2001 From: bitflash Date: Tue, 18 Aug 2026 14:44:11 -0300 Subject: wallet: one-shot storage commands honour -walletbackend=sqlite -walletstorageaudit, -walletstoragecheck and -backupwallet all read wallet.dat regardless of the selected backend. Under -walletbackend=sqlite that is wrong, and for backup it is dangerous: it silently backs up the stale plaintext wallet.dat instead of the live wallet.sqlite. - ScanWalletRecords() routes to the active SQLite store instead of a Berkeley DB cursor (it returned an error under the runtime before). This is the single choke point audit and check scan through. - WalletSQLiteRuntimeOpenReadOnly() opens wallet.sqlite for scanning without loading or validating records, so the pre-load diagnostics can inspect a wallet the loader would refuse. main_gui opens it for audit/check when the SQLite backend is selected (and rejects an unknown backend value there too). - BackupWallet() copies wallet.sqlite (checkpoint, then the temp/old/rename dance; a checkpointed SQLite file is self-contained, no lsn_reset). wallet-sqlite-encrypt self-test proves routing on a datadir where the SQLite wallet is encrypted and wallet.dat beside it is still plaintext: audit with the flag reports the encrypted seed, without it reports the plaintext one; the SQLite backup carries no plaintext key and loadchecks as a standalone wallet. --- src/db.cpp | 112 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/db.h | 2 + src/main_gui.cpp | 50 ++++++++++++++++++++++--- src/selftest.cpp | 60 +++++++++++++++++++++++++++++ 4 files changed, 218 insertions(+), 6 deletions(-) (limited to 'src/main_gui.cpp') diff --git a/src/db.cpp b/src/db.cpp index c16edb4..aafc0f9 100644 --- a/src/db.cpp +++ b/src/db.cpp @@ -198,6 +198,38 @@ void WalletSQLiteRuntimeRollbackTxn() pWalletSQLiteRuntime->RollbackTransaction(strError); } +// Open the SQLite wallet read-only as the active runtime. Used by the one-shot +// storage diagnostics, which run before the normal wallet load and must be able +// to inspect a wallet that the loader would refuse -- so this opens the file for +// scanning without loading or validating its records. +bool WalletSQLiteRuntimeOpenReadOnly(const string& strPath, string& strErrorRet) +{ + strErrorRet.clear(); + if (!FileExists(strPath.c_str())) + { + strErrorRet = strprintf("SQLite wallet does not exist: %s", strPath.c_str()); + return false; + } + + WalletSQLiteRuntimeClose(); + std::auto_ptr db(new CWalletDBSQLite()); + if (!db->OpenReadOnly(strPath, strErrorRet)) + return false; + + pWalletSQLiteRuntime = db.release(); + return true; +} + +bool WalletSQLiteRuntimeCheckpoint(string& strErrorRet) +{ + if (!pWalletSQLiteRuntime) + { + strErrorRet = "SQLite wallet backend is not active"; + return false; + } + return pWalletSQLiteRuntime->Checkpoint(strErrorRet); +} + CDB::CDB(const char* pszFile, const char* pszMode, bool fTxn) : pdb(NULL) { @@ -1354,6 +1386,16 @@ bool CWalletDB::LoadWallet(vector& vchDefaultKeyRet) bool ScanWalletRecords(CWalletRecordVisitor& visitor, string& strErrorRet) { + // Under the SQLite backend there is no Berkeley DB cursor to walk; scan the + // active SQLite store instead. This is the single choke point every "read + // every wallet record" caller goes through (storage audit and check among + // them), so routing it here makes those honour -walletbackend=sqlite. + if (WalletSQLiteRuntimeActive()) + { + strErrorRet.clear(); + return pWalletSQLiteRuntime->ScanRecords(visitor, strErrorRet); + } + class CWalletScanDB : public CWalletDB { public: @@ -1584,11 +1626,81 @@ bool LoadWalletFromSQLiteRuntime(const string& strPath) // this file was written are not spendable from it. Backing up once is not // enough, and that is a property of the wallet format rather than of this // function. +// Back up the active SQLite wallet. The store is checkpointed so the WAL is +// folded into the main file, then the single self-contained wallet.sqlite is +// copied through a temp file and renamed into place (same temp/old/rename dance +// as the Berkeley DB path, for Windows' non-overwriting rename). No lsn_reset: +// a checkpointed SQLite file needs nothing beside it to open. +static bool BackupWalletSQLite(const string& strDest) +{ + string strSrc = GetAppDir() + "/wallet.sqlite"; + if (!FileExists(strSrc.c_str())) + return error("BackupWallet() : %s does not exist\n", strSrc.c_str()); + + string strCheckpointError; + if (!WalletSQLiteRuntimeCheckpoint(strCheckpointError)) + return error("BackupWallet() : checkpoint failed: %s\n", strCheckpointError.c_str()); + + string strTemp = strprintf("%s.tmp.%lld", strDest.c_str(), GetTime()); + for (int i = 1; FileExists(strTemp.c_str()); i++) + strTemp = strprintf("%s.tmp.%lld.%d", strDest.c_str(), GetTime(), i); + string strOld = strprintf("%s.old.%lld", strDest.c_str(), GetTime()); + for (int i = 1; FileExists(strOld.c_str()); i++) + strOld = strprintf("%s.old.%lld.%d", strDest.c_str(), GetTime(), i); + + FILE* pfIn = fopen(strSrc.c_str(), "rb"); + if (!pfIn) + return error("BackupWallet() : cannot read %s\n", strSrc.c_str()); + FILE* pfOut = fopen(strTemp.c_str(), "wb"); + if (!pfOut) + { + fclose(pfIn); + return error("BackupWallet() : cannot write %s\n", strTemp.c_str()); + } + + char buf[65536]; + size_t n; + bool fOk = true; + while ((n = fread(buf, 1, sizeof(buf), pfIn)) > 0) + if (fwrite(buf, 1, n, pfOut) != n) { fOk = false; break; } + if (ferror(pfIn)) + fOk = false; + fclose(pfIn); + if (fclose(pfOut) != 0) + fOk = false; + if (!fOk) + { + remove(strTemp.c_str()); // half a wallet is worse than none + return error("BackupWallet() : copy to %s failed\n", strTemp.c_str()); + } + + bool fHadOldDest = FileExists(strDest.c_str()); + if (fHadOldDest && rename(strDest.c_str(), strOld.c_str()) != 0) + { + remove(strTemp.c_str()); + return error("BackupWallet() : could not move old %s aside\n", strDest.c_str()); + } + if (rename(strTemp.c_str(), strDest.c_str()) != 0) + { + remove(strTemp.c_str()); + if (fHadOldDest) + rename(strOld.c_str(), strDest.c_str()); + return error("BackupWallet() : could not install %s\n", strDest.c_str()); + } + if (fHadOldDest) + remove(strOld.c_str()); + return true; +} + bool BackupWallet(const string& strDest) { if (strDest.empty()) return error("BackupWallet() : no destination given\n"); + // Under the SQLite backend the live wallet is wallet.sqlite, not wallet.dat. + if (WalletSQLiteRuntimeActive()) + return BackupWalletSQLite(strDest); + string strSrc = GetAppDir() + "/wallet.dat"; if (!FileExists(strSrc.c_str())) return error("BackupWallet() : %s does not exist\n", strSrc.c_str()); diff --git a/src/db.h b/src/db.h index 1a272b9..5bc94b1 100644 --- a/src/db.h +++ b/src/db.h @@ -368,6 +368,8 @@ bool WalletSQLiteRuntimeEraseRecord(const vector& vchKey); bool WalletSQLiteRuntimeBeginTxn(); bool WalletSQLiteRuntimeCommitTxn(); void WalletSQLiteRuntimeRollbackTxn(); +bool WalletSQLiteRuntimeOpenReadOnly(const string& strPath, string& strErrorRet); +bool WalletSQLiteRuntimeCheckpoint(string& strErrorRet); diff --git a/src/main_gui.cpp b/src/main_gui.cpp index 4be4995..af6053f 100644 --- a/src/main_gui.cpp +++ b/src/main_gui.cpp @@ -524,19 +524,57 @@ int main(int argc, char* argv[]) // explain wallet.dat states that the normal wallet loader may refuse. string strWalletStorageAuditJson = argval2(argc, argv, "/walletstorageauditjson", "-walletstorageauditjson"); - if (arg(argc,argv,"/walletstorageaudit") || - arg(argc,argv,"-walletstorageaudit") || - !strWalletStorageAuditJson.empty()) + bool fDiagWantsAudit = arg(argc,argv,"/walletstorageaudit") || + arg(argc,argv,"-walletstorageaudit") || + !strWalletStorageAuditJson.empty(); + bool fDiagWantsCheck = arg(argc,argv,"/walletstoragecheck") || + arg(argc,argv,"-walletstoragecheck"); + + // These diagnostics normally read wallet.dat. Under -walletbackend=sqlite + // the live wallet is wallet.sqlite, so open it read-only as the active + // store and let the same scan walk the right file. This runs before the + // normal backend resolution further down, so validate the value here too. + string strDiagBackend = argval2(argc, argv, "/walletbackend", "-walletbackend"); + if ((fDiagWantsAudit || fDiagWantsCheck) && !strDiagBackend.empty() && + strDiagBackend != "sqlite" && strDiagBackend != "bdb") + { + AttachTerminal(); + fprintf(stderr, "Unknown wallet backend '%s'. Use -walletbackend=sqlite " + "or -walletbackend=bdb.\n", strDiagBackend.c_str()); + return 1; + } + bool fDiagSQLite = (strDiagBackend == "sqlite") && + (fDiagWantsAudit || fDiagWantsCheck); + if (fDiagSQLite) + { + AttachTerminal(); + string strSQLitePath = GetAppDir() + "/wallet.sqlite"; + string strOpenErr; + if (!WalletSQLiteRuntimeOpenReadOnly(strSQLitePath, strOpenErr)) + { + fprintf(stderr, "Cannot open the SQLite wallet for diagnostics: %s\n", + strOpenErr.c_str()); + return 1; + } + } + + if (fDiagWantsAudit) { int nRet = CmdWalletStorageAudit(strWalletStorageAuditJson); - DBFlush(true); + if (fDiagSQLite) + WalletSQLiteRuntimeClose(); + else + DBFlush(true); return nRet; } - if (arg(argc,argv,"/walletstoragecheck") || arg(argc,argv,"-walletstoragecheck")) + if (fDiagWantsCheck) { int nRet = CmdWalletStorageCheck(); - DBFlush(true); + if (fDiagSQLite) + WalletSQLiteRuntimeClose(); + else + DBFlush(true); return nRet; } diff --git a/src/selftest.cpp b/src/selftest.cpp index 7ad5a53..90a30d2 100644 --- a/src/selftest.cpp +++ b/src/selftest.cpp @@ -2453,6 +2453,66 @@ static int RunWalletSQLiteEncryptSelfTest() nFail += Check(nAuditRet == 0 && FileContainsText(strAudit, "recovery phrase: present"), "the encrypted SQLite wallet still has its recovery phrase") ? 0 : 1; + + // One-shot storage commands honour -walletbackend. The SQLite wallet is + // encrypted; the wallet.dat beside it is still plaintext. Same datadir, + // opposite answers -> the flag routes the diagnostic to the right file. + string strSqliteAuditJson = tmp + "/sqlite-audit.json"; + vector vSqliteAuditArgs; + vSqliteAuditArgs.push_back("-datadir=" + tmp); + vSqliteAuditArgs.push_back("-walletbackend=sqlite"); + vSqliteAuditArgs.push_back("-walletstorageauditjson=" + strSqliteAuditJson); + vSqliteAuditArgs.push_back("-nogui"); + int nSqliteAuditRet = RunBitflashChild(strExe, vSqliteAuditArgs); + nFail += Check(nSqliteAuditRet == 0 && + FileContainsText(strSqliteAuditJson, "\"encrypted_hd_seed\": \"complete\"") && + FileContainsText(strSqliteAuditJson, "\"plain_hd_seed\": \"none\""), + "-walletstorageaudit under SQLite reads the encrypted wallet.sqlite") ? 0 : 1; + + string strBdbAuditJson = tmp + "/bdb-audit.json"; + vector vBdbAuditArgs; + vBdbAuditArgs.push_back("-datadir=" + tmp); + vBdbAuditArgs.push_back("-walletstorageauditjson=" + strBdbAuditJson); + vBdbAuditArgs.push_back("-nogui"); + int nBdbAuditRet = RunBitflashChild(strExe, vBdbAuditArgs); + nFail += Check(nBdbAuditRet == 0 && + FileContainsText(strBdbAuditJson, "\"plain_hd_seed\": \"complete\"") && + FileContainsText(strBdbAuditJson, "\"encrypted_hd_seed\": \"none\""), + "-walletstorageaudit without the flag still reads the plaintext wallet.dat") ? 0 : 1; + + string strSqliteCheckOut = tmp + "/sqlite-check.txt"; + vector vSqliteCheckArgs; + vSqliteCheckArgs.push_back("-datadir=" + tmp); + vSqliteCheckArgs.push_back("-walletbackend=sqlite"); + vSqliteCheckArgs.push_back("-walletstoragecheck"); + vSqliteCheckArgs.push_back("-nogui"); + int nSqliteCheckRet = RunBitflashChild(strExe, vSqliteCheckArgs, NULL, &strSqliteCheckOut); + nFail += Check(nSqliteCheckRet == 0, + "-walletstoragecheck under SQLite passes on the encrypted wallet.sqlite") ? 0 : 1; + + // Backup under SQLite copies wallet.sqlite, not the plaintext wallet.dat. + // The copy is a standalone SQLite wallet (loadcheck accepts it) and holds + // none of the plaintext key that wallet.dat still contains. + string strBackupDest = tmp + "/backup.sqlite"; + vector vBackupArgs; + vBackupArgs.push_back("-datadir=" + tmp); + vBackupArgs.push_back("-walletbackend=sqlite"); + vBackupArgs.push_back("-backupwallet=" + strBackupDest); + vBackupArgs.push_back("-nogui"); + int nBackupRet = RunBitflashChild(strExe, vBackupArgs); + nFail += Check(nBackupRet == 0 && FileExists(strBackupDest.c_str()), + "-backupwallet under SQLite writes a backup file") ? 0 : 1; + nFail += Check(!FileContainsBytes(strBackupDest, vchDefaultPrivBytes), + "the SQLite backup holds no plaintext key (it copied wallet.sqlite, not wallet.dat)") ? 0 : 1; + + string strBackupLoadCheck = tmp + "/backup-loadcheck.txt"; + vector vLoadCheckArgs; + vLoadCheckArgs.push_back("-datadir=" + tmp); + vLoadCheckArgs.push_back("-walletsqliteloadcheck=" + strBackupDest); + vLoadCheckArgs.push_back("-nogui"); + int nBackupLoadRet = RunBitflashChild(strExe, vLoadCheckArgs, NULL, &strBackupLoadCheck); + nFail += Check(nBackupLoadRet == 0, + "the SQLite backup is a valid standalone SQLite wallet") ? 0 : 1; } catch (const std::exception& e) { -- cgit v1.2.3 From 3f94ec0649f935d00de49ad7b55026317dc64a27 Mon Sep 17 00:00:00 2001 From: bitflash Date: Tue, 18 Aug 2026 16:14:19 -0300 Subject: wallet: make SQLite the default backend for new wallets A fresh install with no wallet.dat now starts on the SQLite backend and creates wallet.sqlite, instead of a Berkeley DB wallet.dat. The choice is recorded in a plain-text /wallet-backend marker so it survives a restart without a command-line flag. Backend resolution when no -walletbackend is given: an existing wallet.sqlite (or a "sqlite" marker) stays on SQLite; an existing wallet.dat stays on Berkeley DB -- nothing is converted behind the user; a fresh datadir starts on SQLite. An explicit -walletbackend=bdb or a "bdb" marker forces Berkeley DB and overrides the default. Adds CreateNewSQLiteWallet() (WalletSQLiteRuntimeOpen gains fMustExist so the create path can make a new file) which mints the first default key straight into the SQLite store via the same FinishLoadedWallet() call the Berkeley DB create path uses, then cleans up on failure. Marker read/write helpers are plain text, hand-editable. New wallet-backend-default self-test proves the resolution in child processes (fresh->SQLite+marker, existing wallet.dat->BDB, "bdb" marker->BDB, explicit flag->wallet.dat). The three wallet.dat-specific suites now ask for -walletbackend=bdb explicitly, since a flagless fresh datadir is SQLite now. All wallet suites pass. --- src/db.cpp | 91 +++++++++++++++++++++++++++++++++++- src/db.h | 5 +- src/main_gui.cpp | 80 ++++++++++++++++++++++++++++--- src/makefile.mingw | 1 + src/selftest.cpp | 135 ++++++++++++++++++++++++++++++++++++++++++++++++++++- 5 files changed, 301 insertions(+), 11 deletions(-) (limited to 'src/main_gui.cpp') diff --git a/src/db.cpp b/src/db.cpp index aafc0f9..949865f 100644 --- a/src/db.cpp +++ b/src/db.cpp @@ -101,10 +101,13 @@ bool WalletSQLiteRuntimeActive() return pWalletSQLiteRuntime != NULL; } -bool WalletSQLiteRuntimeOpen(const string& strPath, string& strErrorRet) +bool WalletSQLiteRuntimeOpen(const string& strPath, string& strErrorRet, bool fMustExist) { strErrorRet.clear(); - if (!FileExists(strPath.c_str())) + // Callers that load an existing wallet want the clear "does not exist" + // message rather than silently creating a blank one; the create path passes + // fMustExist=false so Open() (SQLITE_OPEN_CREATE) can make a fresh file. + if (fMustExist && !FileExists(strPath.c_str())) { strErrorRet = strprintf("SQLite wallet does not exist: %s", strPath.c_str()); @@ -230,6 +233,47 @@ bool WalletSQLiteRuntimeCheckpoint(string& strErrorRet) return pWalletSQLiteRuntime->Checkpoint(strErrorRet); } +// The persistent record of which backend this datadir uses, so the choice +// survives a restart without a command-line flag. A plain text file holding +// "sqlite" or "bdb" -- visible and hand-editable on purpose, no hidden state. +static string WalletBackendMarkerPath() +{ + return GetAppDir() + "/wallet-backend"; +} + +string ReadWalletBackendMarker() +{ + FILE* pf = fopen(WalletBackendMarkerPath().c_str(), "rb"); + if (!pf) + return ""; + char buf[16] = {0}; + size_t n = fread(buf, 1, sizeof(buf) - 1, pf); + fclose(pf); + string s(buf, n); + while (!s.empty() && isspace((unsigned char)s[s.size() - 1])) + s.erase(s.size() - 1); + while (!s.empty() && isspace((unsigned char)s[0])) + s.erase(0, 1); + if (s == "sqlite" || s == "bdb") + return s; + return ""; +} + +bool WriteWalletBackendMarker(const string& strBackend) +{ + if (strBackend != "sqlite" && strBackend != "bdb") + return false; + FILE* pf = fopen(WalletBackendMarkerPath().c_str(), "wb"); + if (!pf) + return false; + bool fOk = fwrite(strBackend.c_str(), 1, strBackend.size(), pf) == strBackend.size(); + if (fputc('\n', pf) == EOF) + fOk = false; + if (fclose(pf) != 0) + fOk = false; + return fOk; +} + CDB::CDB(const char* pszFile, const char* pszMode, bool fTxn) : pdb(NULL) { @@ -1609,6 +1653,49 @@ bool LoadWalletFromSQLiteRuntime(const string& strPath) return true; } +// Create a brand-new wallet directly in the SQLite backend. For a fresh install +// where SQLite is the default there is no wallet.dat to export from, so open a +// new wallet.sqlite (Open() creates the file and its schema) and let +// FinishLoadedWallet mint the first default key straight into it -- the same +// call the Berkeley DB create path uses, so a new wallet is identical either +// way. Refuses to touch an existing file, and cleans up on failure so a broken +// half-initialised wallet.sqlite is never left for the next start to load. +bool CreateNewSQLiteWallet(const string& strPath) +{ + strWalletLoadError.clear(); + + if (FileExists(strPath.c_str())) + { + strWalletLoadError = strprintf("SQLite wallet already exists: %s", + strPath.c_str()); + printf("CreateNewSQLiteWallet: %s\n", strWalletLoadError.c_str()); + return false; + } + + string strError; + if (!WalletSQLiteRuntimeOpen(strPath, strError, false)) + { + strWalletLoadError = strError; + printf("CreateNewSQLiteWallet: cannot create SQLite wallet: %s\n", + strError.c_str()); + return false; + } + + vector vchNoDefaultKey; + if (!FinishLoadedWallet(vchNoDefaultKey, true)) + { + WalletSQLiteRuntimeClose(); + remove(strPath.c_str()); + remove((strPath + "-wal").c_str()); + remove((strPath + "-shm").c_str()); + if (strWalletLoadError.empty()) + strWalletLoadError = "could not initialise the new SQLite wallet"; + return false; + } + + return true; +} + // Write a copy of wallet.dat that can be opened anywhere. // diff --git a/src/db.h b/src/db.h index 5bc94b1..c50065b 100644 --- a/src/db.h +++ b/src/db.h @@ -357,7 +357,7 @@ private: // peer discovery is entirely Nostr/.btf-based now. bool WalletSQLiteRuntimeActive(); -bool WalletSQLiteRuntimeOpen(const string& strPath, string& strErrorRet); +bool WalletSQLiteRuntimeOpen(const string& strPath, string& strErrorRet, bool fMustExist=true); void WalletSQLiteRuntimeClose(); bool WalletSQLiteRuntimeReadRecord(const vector& vchKey, vector& vchValueRet); @@ -370,6 +370,9 @@ bool WalletSQLiteRuntimeCommitTxn(); void WalletSQLiteRuntimeRollbackTxn(); bool WalletSQLiteRuntimeOpenReadOnly(const string& strPath, string& strErrorRet); bool WalletSQLiteRuntimeCheckpoint(string& strErrorRet); +string ReadWalletBackendMarker(); +bool WriteWalletBackendMarker(const string& strBackend); +bool CreateNewSQLiteWallet(const string& strPath); diff --git a/src/main_gui.cpp b/src/main_gui.cpp index af6053f..6cd8b75 100644 --- a/src/main_gui.cpp +++ b/src/main_gui.cpp @@ -743,14 +743,80 @@ int main(int argc, char* argv[]) DBFlush(true); return 0; } - else if (!LoadWallet()) + else if (strWalletBackend == "bdb") { - // LoadWallet() explains itself into strWalletLoadError when it - // knows why -- an unsupported wallet format, for one, which is the - // case this whole path exists to make visible. - FatalStartupError(fHeadlessStartup, "Cannot open wallet.dat.", - strWalletLoadError); - return 1; + // Explicit -walletbackend=bdb overrides the marker and the default: + // force Berkeley DB (LoadWallet creates wallet.dat if none exists). + if (!LoadWallet()) + { + FatalStartupError(fHeadlessStartup, "Cannot open wallet.dat.", + strWalletLoadError); + return 1; + } + } + else + { + // No explicit -walletbackend on the command line. Resolve it from + // the persistent marker, then defaults: an existing wallet.sqlite + // stays on SQLite, an existing wallet.dat stays on Berkeley DB (the + // GUI offers to convert), and a fresh install starts on SQLite -- + // the new default. wallet.dat is never touched by any of this. + string strSQLitePath = GetAppDir() + "/wallet.sqlite"; + bool fSQLiteExists = FileExists(strSQLitePath.c_str()); + bool fWalletDatExists = FileExists((GetAppDir() + "/wallet.dat").c_str()); + string strMarker = ReadWalletBackendMarker(); + + bool fUseSQLite = false; + bool fCreateSQLite = false; + if (strMarker == "sqlite") + { + if (fSQLiteExists) fUseSQLite = true; + else if (fWalletDatExists) fUseSQLite = false; // marker stale; real wallet is BDB + else { fUseSQLite = true; fCreateSQLite = true; } + } + else if (strMarker == "bdb") + { + fUseSQLite = false; + } + else // no marker recorded yet + { + if (fSQLiteExists && !fWalletDatExists) fUseSQLite = true; + else if (fWalletDatExists) fUseSQLite = false; + else { fUseSQLite = true; fCreateSQLite = true; } + } + + if (fUseSQLite) + { + AttachTerminal(); + bool fOk = fCreateSQLite + ? CreateNewSQLiteWallet(strSQLitePath) + : LoadWalletFromSQLiteRuntime(strSQLitePath); + if (!fOk) + { + FatalStartupError(fHeadlessStartup, + fCreateSQLite ? "Cannot create the SQLite wallet." + : "Cannot open the SQLite wallet.", + strWalletLoadError); + return 1; + } + // Record the choice so it is stable from here on. + if (ReadWalletBackendMarker() != "sqlite") + WriteWalletBackendMarker("sqlite"); + fprintf(stderr, "Wallet backend: SQLite\n %s\n%s", + strSQLitePath.c_str(), + fCreateSQLite ? "A new wallet was created here.\n" : ""); + fflush(stderr); + // Fall through and run the node; CWalletDB routes to this file. + } + else if (!LoadWallet()) + { + // LoadWallet() explains itself into strWalletLoadError when it + // knows why -- an unsupported wallet format, for one, which is + // the case this whole path exists to make visible. + FatalStartupError(fHeadlessStartup, "Cannot open wallet.dat.", + strWalletLoadError); + return 1; + } } // After the block index, so there is a chain to compare the wallet diff --git a/src/makefile.mingw b/src/makefile.mingw index 1cb47fb..06c9211 100644 --- a/src/makefile.mingw +++ b/src/makefile.mingw @@ -113,6 +113,7 @@ tests: test_bip32.exe bitflash.exe ./bitflash.exe -selftest=wallet-crypto ./bitflash.exe -selftest=wallet-encrypt ./bitflash.exe -selftest=wallet-sqlite-encrypt + ./bitflash.exe -selftest=wallet-backend-default ./bitflash.exe -selftest=net-message ./bitflash.exe -selftest=consensus-limits ./bitflash.exe -selftest=pool-stratum diff --git a/src/selftest.cpp b/src/selftest.cpp index 90a30d2..1960003 100644 --- a/src/selftest.cpp +++ b/src/selftest.cpp @@ -911,6 +911,7 @@ static int RunWalletStorageSanitySelfTest() vCreateArgs.push_back("-datadir=" + strScenarioDir); vCreateArgs.push_back("-nomanagedtor"); vCreateArgs.push_back("-nogui"); + vCreateArgs.push_back("-walletbackend=bdb"); // this suite is about wallet.dat vCreateArgs.push_back("-newphrase"); int nCreateRet = RunBitflashChild(strExe, vCreateArgs, NULL, &strCreateOut); nFail += Check(nCreateRet == 0 && @@ -1474,6 +1475,7 @@ static int RunWalletSQLiteMigrationSelfTest() vCreateArgs.push_back("-datadir=" + strWalletDir); vCreateArgs.push_back("-nomanagedtor"); vCreateArgs.push_back("-nogui"); + vCreateArgs.push_back("-walletbackend=bdb"); // migrate FROM a Berkeley DB wallet.dat vCreateArgs.push_back("-newphrase"); int nCreateRet = RunBitflashChild(strExe, vCreateArgs, NULL, &strCreateOut); nFail += Check(nCreateRet == 0 && @@ -2534,6 +2536,134 @@ static int RunWalletSQLiteEncryptSelfTest() return nFail == 0 ? 0 : 1; } +// Backend resolution when no -walletbackend flag is given: a fresh install +// starts on SQLite (the new default), an existing wallet.dat stays on Berkeley +// DB, the persistent marker overrides the default, and an explicit flag +// overrides everything. Each scenario runs a real child process. +static int RunWalletBackendDefaultSelfTest() +{ + fflush(stdout); + printf("wallet-backend-default self-test\n"); + + std::string tmp, cwd; + if (!MakeTempDir(tmp)) + { + printf(" FAIL could not create a temporary data directory\n"); + return 1; + } + if (!GetCurrentDir(cwd) || !SetCurrentDir(tmp)) + { + printf(" FAIL could not move into the temporary data directory\n"); + RemoveTree(tmp); + return 1; + } + + int nFail = 0; + strSetDataDir = tmp; + printf(" temp datadir: %s\n", tmp.c_str()); + +#ifdef _WIN32 + string strExe = cwd + "\\bitflash.exe"; +#else + string strExe = cwd + "/bitflash-node"; +#endif + + try + { + // 1. Fresh datadir, no flag -> new wallet.sqlite, marker "sqlite", no wallet.dat. + string d1 = tmp + "/fresh"; + nFail += Check(MakeDirLocal(d1), "fresh scenario dir can be created") ? 0 : 1; + vector a1; + a1.push_back("-datadir=" + d1); + a1.push_back("-nomanagedtor"); + a1.push_back("-nogui"); + a1.push_back("-newaddress"); + int r1 = RunBitflashChild(strExe, a1); + nFail += Check(r1 == 0 && + FileExists((d1 + "/wallet.sqlite").c_str()) && + !FileExists((d1 + "/wallet.dat").c_str()) && + FileContainsText(d1 + "/wallet-backend", "sqlite"), + "a fresh install starts on SQLite (new default) and records the marker") ? 0 : 1; + + // 1b. Second run reuses it, still SQLite, still no wallet.dat. + int r1b = RunBitflashChild(strExe, a1); + nFail += Check(r1b == 0 && + FileExists((d1 + "/wallet.sqlite").c_str()) && + !FileExists((d1 + "/wallet.dat").c_str()), + "the recorded SQLite choice survives a restart") ? 0 : 1; + + // 2. Existing wallet.dat, no flag -> stays on Berkeley DB. + string d2 = tmp + "/existing-bdb"; + nFail += Check(MakeDirLocal(d2), "existing-bdb scenario dir can be created") ? 0 : 1; + vector a2c; + a2c.push_back("-datadir=" + d2); + a2c.push_back("-nomanagedtor"); + a2c.push_back("-nogui"); + a2c.push_back("-walletbackend=bdb"); + a2c.push_back("-newphrase"); + RunBitflashChild(strExe, a2c); + vector a2; + a2.push_back("-datadir=" + d2); + a2.push_back("-nomanagedtor"); + a2.push_back("-nogui"); + a2.push_back("-newaddress"); + int r2 = RunBitflashChild(strExe, a2); + nFail += Check(r2 == 0 && + FileExists((d2 + "/wallet.dat").c_str()) && + !FileExists((d2 + "/wallet.sqlite").c_str()), + "an existing wallet.dat keeps a flagless start on Berkeley DB") ? 0 : 1; + + // 3. Marker "bdb" forces Berkeley DB even on a fresh datadir. + string d3 = tmp + "/marker-bdb"; + nFail += Check(MakeDirLocal(d3), "marker-bdb scenario dir can be created") ? 0 : 1; + nFail += Check(WriteTextFile(d3 + "/wallet-backend", "bdb\n"), + "a bdb marker can be written") ? 0 : 1; + vector a3; + a3.push_back("-datadir=" + d3); + a3.push_back("-nomanagedtor"); + a3.push_back("-nogui"); + a3.push_back("-newaddress"); + int r3 = RunBitflashChild(strExe, a3); + nFail += Check(r3 == 0 && + FileExists((d3 + "/wallet.dat").c_str()) && + !FileExists((d3 + "/wallet.sqlite").c_str()), + "a bdb marker forces Berkeley DB on a fresh datadir") ? 0 : 1; + + // 4. Explicit -walletbackend=bdb on a fresh datadir -> wallet.dat. + string d4 = tmp + "/explicit-bdb"; + nFail += Check(MakeDirLocal(d4), "explicit-bdb scenario dir can be created") ? 0 : 1; + vector a4; + a4.push_back("-datadir=" + d4); + a4.push_back("-nomanagedtor"); + a4.push_back("-nogui"); + a4.push_back("-walletbackend=bdb"); + a4.push_back("-newaddress"); + int r4 = RunBitflashChild(strExe, a4); + nFail += Check(r4 == 0 && + FileExists((d4 + "/wallet.dat").c_str()) && + !FileExists((d4 + "/wallet.sqlite").c_str()), + "an explicit -walletbackend=bdb creates wallet.dat on a fresh datadir") ? 0 : 1; + } + catch (const std::exception& e) + { + printf(" FAIL exception: %s\n", e.what()); + nFail++; + } + catch (...) + { + printf(" FAIL unknown exception\n"); + nFail++; + } + + DBFlush(true); + SetCurrentDir(cwd); + RemoveTree(tmp); + printf("%s (%d failure%s)\n", nFail == 0 ? "ALL TESTS PASSED" : "TESTS FAILED", + nFail, nFail == 1 ? "" : "s"); + fflush(stdout); + return nFail == 0 ? 0 : 1; +} + static int RunWalletPortabilitySelfTest() { fflush(stdout); @@ -2576,6 +2706,7 @@ static int RunWalletPortabilitySelfTest() vCreateArgs.push_back("-datadir=" + strOriginal); vCreateArgs.push_back("-nomanagedtor"); vCreateArgs.push_back("-nogui"); + vCreateArgs.push_back("-walletbackend=bdb"); // this suite is about wallet.dat portability vCreateArgs.push_back("-newphrase"); int nCreateRet = RunBitflashChild(strExe, vCreateArgs, NULL, &strCreateOut); nFail += Check(nCreateRet == 0 && @@ -3265,6 +3396,8 @@ int RunSelfTest(const std::string& name) return RunWalletEncryptSelfTest(); if (name == "wallet-sqlite-encrypt") return RunWalletSQLiteEncryptSelfTest(); + if (name == "wallet-backend-default") + return RunWalletBackendDefaultSelfTest(); if (name == "wallet-portability") return RunWalletPortabilitySelfTest(); if (name == "net-message") @@ -3281,6 +3414,6 @@ int RunSelfTest(const std::string& name) return RunManagedTorSelfTest(); printf("Unknown self-test '%s'\n", name.c_str()); - printf("Known self-tests: wallet-keypool, wallet-hd, wallet-format, wallet-storage-sanity, db-env-reopen, wallet-sqlite, wallet-sqlite-migration, wallet-crypto, wallet-encrypt, wallet-sqlite-encrypt, wallet-portability, net-message, consensus-limits, pool-stratum, parse-money, socks5-proxy, managed-tor\n"); + printf("Known self-tests: wallet-keypool, wallet-hd, wallet-format, wallet-storage-sanity, db-env-reopen, wallet-sqlite, wallet-sqlite-migration, wallet-crypto, wallet-encrypt, wallet-sqlite-encrypt, wallet-backend-default, wallet-portability, net-message, consensus-limits, pool-stratum, parse-money, socks5-proxy, managed-tor\n"); return 1; } -- cgit v1.2.3 From d4c49085a10c0f7c3f5a1dcc72184938dca38fea Mon Sep 17 00:00:00 2001 From: bitflash Date: Wed, 19 Aug 2026 09:48:07 -0300 Subject: gui: harden the restart relaunch (ultra edge cases) Follow-up to the Restart Now button. Addresses the edge cases the review flagged, and drops the fragile batch layer entirely: - Windows no longer writes a .bat and shells through cmd (which re-parsed the arguments and could leave a stray file on failure). It builds the command line once, quoted correctly (only backslashes before a quote are doubled, so ordinary paths are not mangled), and CreateProcess launches the new instance directly. - The delay moved into the new instance: a -restartwait flag makes it wait ~2.5s before it opens the network port and the block index, so the old process is gone first. No shell delay hack. Verified: -restartwait adds the wait (0.16s -> 2.67s). - Linux re-execs /proc/self/exe (absolute) and only falls back to argv[0], which may be relative or a bare PATH name that execv cannot resolve. - This instance now exits only if the relaunch actually started; on failure it keeps running and tells the user to restart manually, so a failed relaunch never leaves them with no node. GUI/process wiring only; wallet code unchanged, suites still green. --- src/gui.cpp | 91 +++++++++++++++++++++++++++++++++++++------------------- src/main_gui.cpp | 8 +++++ 2 files changed, 69 insertions(+), 30 deletions(-) (limited to 'src/main_gui.cpp') diff --git a/src/gui.cpp b/src/gui.cpp index 672f0ff..296d20c 100644 --- a/src/gui.cpp +++ b/src/gui.cpp @@ -1410,63 +1410,94 @@ static bool g_wizardConverted = false; static int g_restartArgc = 0; static char** g_restartArgv = NULL; +#ifdef _WIN32 +// Quote one argument so CommandLineToArgvW parses it back to exactly this +// string: only backslashes that immediately precede a double quote (including +// the closing one) are doubled, so ordinary Windows paths are not mangled. +static std::string QuoteArgWin(const std::string& a) +{ + if (!a.empty() && a.find_first_of(" \t\"") == std::string::npos) + return a; + std::string r = "\""; + size_t bs = 0; + for (size_t i = 0; i < a.size(); i++) + { + if (a[i] == '\\') { bs++; continue; } + if (a[i] == '"') { r.append(bs * 2 + 1, '\\'); r += '"'; bs = 0; continue; } + r.append(bs, '\\'); bs = 0; r += a[i]; + } + r.append(bs * 2, '\\'); + r += "\""; + return r; +} +#endif + // Relaunch Bitflash with the same arguments and datadir, then ask this instance -// to exit. A short delay lets the old process release the network port and the -// block-index files before the new one opens them, so mining resumes cleanly on -// the SQLite wallet instead of both instances fighting over the same files. +// to exit -- but only once the relaunch has actually started, so a failure never +// leaves the user with no node running. The new instance waits (-restartwait) +// before it opens the network port and the block index, so this one is gone +// first and they never fight over the same files. static void RestartApplication() { + bool fLaunched = false; #ifdef _WIN32 char exePath[MAX_PATH]; if (GetModuleFileNameA(NULL, exePath, MAX_PATH) == 0) return; - std::string strArgs; + std::string strCmd = QuoteArgWin(exePath); bool fHasDataDir = false; for (int i = 1; i < g_restartArgc; i++) { std::string a = g_restartArgv[i] ? g_restartArgv[i] : ""; if (a.find("-datadir=") == 0 || a.find("/datadir=") == 0) fHasDataDir = true; - strArgs += " \"" + a + "\""; + strCmd += " " + QuoteArgWin(a); } if (!fHasDataDir) - strArgs += " \"-datadir=" + GetAppDir() + "\""; - - std::string strBat = GetAppDir() + "\\bitflash-restart.bat"; - FILE* pf = fopen(strBat.c_str(), "wb"); - if (pf) + strCmd += " " + QuoteArgWin("-datadir=" + GetAppDir()); + strCmd += " -restartwait"; + + std::vector cmdBuf(strCmd.begin(), strCmd.end()); + cmdBuf.push_back('\0'); + STARTUPINFOA si; memset(&si, 0, sizeof(si)); si.cb = sizeof(si); + PROCESS_INFORMATION pi; memset(&pi, 0, sizeof(pi)); + if (CreateProcessA(NULL, &cmdBuf[0], NULL, NULL, FALSE, 0, NULL, NULL, &si, &pi)) { - // ping is the reliable no-console delay on Windows; timeout needs a tty. - fprintf(pf, - "@echo off\r\n" - "ping 127.0.0.1 -n 3 >nul\r\n" - "start \"\" \"%s\"%s\r\n" - "del \"%%~f0\"\r\n", - exePath, strArgs.c_str()); - fclose(pf); - std::string strCmd = "cmd /c \"" + strBat + "\""; - STARTUPINFOA si; memset(&si, 0, sizeof(si)); si.cb = sizeof(si); - PROCESS_INFORMATION pi; memset(&pi, 0, sizeof(pi)); - if (CreateProcessA(NULL, (char*)strCmd.c_str(), NULL, NULL, FALSE, - CREATE_NO_WINDOW, NULL, NULL, &si, &pi)) - { - CloseHandle(pi.hProcess); - CloseHandle(pi.hThread); - } + CloseHandle(pi.hProcess); + CloseHandle(pi.hThread); + fLaunched = true; } #else pid_t pid = fork(); if (pid == 0) { + // Child: wait for the parent to release the port and files, then re-exec. + // /proc/self/exe is the reliable absolute path; argv[0] may be relative + // or a bare name that execv() cannot resolve. sleep(2); + char exePath[4096]; + ssize_t len = readlink("/proc/self/exe", exePath, sizeof(exePath) - 1); + if (len > 0) + { + exePath[len] = '\0'; + execv(exePath, g_restartArgv); + } if (g_restartArgv) execv(g_restartArgv[0], g_restartArgv); _exit(127); } + fLaunched = (pid > 0); #endif - fShutdown = true; - if (glfwGetCurrentContext()) - glfwSetWindowShouldClose(glfwGetCurrentContext(), true); + + if (fLaunched) + { + fShutdown = true; + if (glfwGetCurrentContext()) + glfwSetWindowShouldClose(glfwGetCurrentContext(), true); + } + else + g_wizardStatus = "Could not relaunch automatically -- please restart " + "Bitflash yourself to switch to the SQLite wallet."; } static void DrawBackendWizardDialog() diff --git a/src/main_gui.cpp b/src/main_gui.cpp index 6cd8b75..55ca8c6 100644 --- a/src/main_gui.cpp +++ b/src/main_gui.cpp @@ -509,6 +509,14 @@ int main(int argc, char* argv[]) ParseStartupArguments(argc, argv); +#ifdef _WIN32 + // Set by the GUI "Restart Now" button: give the previous instance a moment + // to release the network port and the block-index files before this one + // opens them, so a relaunch does not race the process it is replacing. + if (arg(argc, argv, "/restartwait") || arg(argc, argv, "-restartwait")) + Sleep(2500); +#endif + // Hidden self-test helper. It must run before LoadWallet(): several // storage-sanity scenarios deliberately make wallet.dat unsafe to load. string strSelfTestMutateWallet = -- cgit v1.2.3 From 4c24a86a8aa01b02874e5bdb7856beed6e4cf6b6 Mon Sep 17 00:00:00 2001 From: bitflash Date: Wed, 19 Aug 2026 11:20:55 -0300 Subject: wallet/docs: SQLite is the default backend, not experimental Drop the "experimental" label from the wallet code and docs now that SQLite is the default backend for new wallets, is coin-proven, and carries encrypt, backup, atomic seed writes, and the storage diagnostics. - main_gui.cpp: -help text, the unknown-backend error (SQLite is no longer "experimental" and Berkeley DB is no longer "the default"), and the startup banner ("Wallet backend: SQLite"). - walletcmd.h: the export command comment. - docs/storage.md: rewrite the SQLite section from staging-only ("runtime still opens Berkeley DB", "no runtime backend flag exists yet") to the shipped state -- default backend, conversion wizard, per-backend ops -- and reframe the migration order as shipped in v1.2.20. --- docs/storage.md | 67 +++++++++++++++++++++++++++----------------------------- src/main_gui.cpp | 16 +++++++------- src/walletcmd.h | 6 ++--- 3 files changed, 43 insertions(+), 46 deletions(-) (limited to 'src/main_gui.cpp') diff --git a/docs/storage.md b/docs/storage.md index 08c4a01..b52a955 100644 --- a/docs/storage.md +++ b/docs/storage.md @@ -85,10 +85,11 @@ A future wallet backend must keep these properties: - recovery phrase encrypted and locked, requiring unlock before coverage can be audited. -## Experimental SQLite Wallet Store +## SQLite Wallet Store -The SQLite wallet path starts as a byte-preserving record store, not as a new -wallet format: +Since v1.2.20 SQLite is the default wallet backend. It is a byte-preserving +record store, not a new wallet format -- the same serialized `CDataStream` bytes +`wallet.dat` uses: ```sql CREATE TABLE wallet_records ( @@ -98,32 +99,26 @@ CREATE TABLE wallet_records ( PRAGMA user_version=1; ``` -Keys and values are the same serialized `CDataStream` bytes used by `wallet.dat`. -The first implementation is exercised only by `-selftest=wallet-sqlite`; the -runtime wallet still opens Berkeley DB. The purpose is to prove schema creation, -raw record write/read, close/reopen behavior, and value preservation before any -BDB-to-SQLite migration or runtime backend flag exists. - -`-walletsqliteexport=FILE` is the next staging tool. It streams every raw -`wallet.dat` record through `ScanWalletRecords()` and writes those same key and -value bytes into the SQLite table inside one transaction. It refuses to -overwrite an existing export and still does not change the runtime wallet -backend. `CWalletDBSQLite::ScanRecords()` can stream those exported records back -through the same visitor shape, so tests can compare BDB and SQLite record -streams without introducing a SQLite runtime wallet loader yet. -`-walletsqliteverify=FILE` compares the current `wallet.dat` stream with a -SQLite export and prints only record counts and mismatch counts. -`-walletsqliterestore=FILE` rebuilds `wallet.dat` from a SQLite export only in -an empty data directory, then the verifier can prove the restored BDB file still -matches the export byte-for-byte. Failed exports remove the incomplete SQLite -file and failed restores remove the incomplete `wallet.dat`. -`-walletsqliteloadcheck=FILE` parses a SQLite export using the same record -types the wallet loader understands, so a migration can prove the export is not -just byte-preserving but loader-compatible before runtime activation. -`-walletsqlite=FILE` is the first runtime staging flag: it loads a SQLite export -through the startup wallet path, initializes the in-memory wallet state, reports -success, and exits before network, mining, GUI, or wallet mutation begins. It is -deliberately read-only until the write path is routed to SQLite as well. +A fresh data directory starts on `wallet.sqlite`. An existing `wallet.dat` keeps +running on Berkeley DB until it is converted: the GUI offers a one-click +conversion on first run, and `-walletbackend=sqlite` / `-walletbackend=bdb` +(recorded in a plain-text `wallet-backend` marker file in the datadir) select a +backend explicitly. Conversion never modifies `wallet.dat`; it stays as a +fallback, and the change takes effect on restart. + +Every wallet operation runs on either backend: read, write, erase, key-pool and +HD-seed writes (the two halves of the seed bracketed in one SQLite transaction), +`-backupwallet` (checkpoint then copy the self-contained file), `-encryptwallet` +(an atomic rebuild-and-swap in one transaction), and the `-walletstorageaudit` / +`-walletstoragecheck` diagnostics, which read the active backend's file. + +The migration tooling remains available for scripted use. `-walletsqliteexport=FILE` +streams every raw `wallet.dat` record through `ScanWalletRecords()` into a SQLite +file in one transaction, refusing to overwrite. `-walletsqliteverify=FILE` +compares the two streams and prints only counts. `-walletsqliteloadcheck=FILE` +parses an export with the same record types the loader uses. +`-walletsqliterestore=FILE` rebuilds a `wallet.dat` from an export in an empty +data directory. Failed exports and restores remove their incomplete output. ## `blkindex.dat` @@ -150,12 +145,14 @@ conversion path. The old IP address database (`addr.dat`) has been removed. Peer discovery is handled by Nostr, `.btf`, rendezvous relays, and direct onion peers. -## Recommended Migration Order +## Migration Order (shipped in v1.2.20) -1. Keep adding storage self-tests around current BDB behavior. -2. Add an experimental wallet backend behind an explicit flag. -3. Implement BDB-to-new-wallet migration as a one-way copy with a backup. -4. Make the new wallet backend default only after cross-version restore, - encryption, backup, and recovery-audit tests pass. +1. Storage self-tests were added around Berkeley DB behavior first. +2. The SQLite backend landed behind an explicit flag, then behind a + `wallet-backend` marker and a GUI conversion wizard. +3. BDB-to-SQLite migration is a one-way copy that leaves `wallet.dat` as a backup. +4. SQLite became the default for new wallets only after cross-version restore, + encryption, backup, and recovery-audit tests passed -- and a live coin-proof + of receive, spend, restart, and encrypt on real mainnet funds. 5. Treat `blkindex.dat` separately; it is safer to rebuild than to migrate under pressure. diff --git a/src/main_gui.cpp b/src/main_gui.cpp index 55ca8c6..943799f 100644 --- a/src/main_gui.cpp +++ b/src/main_gui.cpp @@ -182,7 +182,7 @@ static void PrintUsage() printf(" /walletstoragecheck (fail closed on inconsistent or unsafe\n"); printf(" wallet.dat storage state)\n"); printf(" /walletsqliteexport=FILE (copy raw wallet.dat records into an\n"); - printf(" experimental SQLite store, then exit)\n"); + printf(" SQLite store, then exit)\n"); printf(" /walletsqliteverify=FILE (compare wallet.dat with a SQLite export,\n"); printf(" printing counts only, then exit)\n"); printf(" /walletsqliterestore=FILE (rebuild wallet.dat from a SQLite export in\n"); @@ -680,17 +680,17 @@ int main(int argc, char* argv[]) AttachTerminal(); FatalStartupError(fHeadlessStartup, strprintf("Unknown wallet backend '%s'.", strWalletBackend.c_str()), - "Use -walletbackend=sqlite for the experimental SQLite backend, " - "or omit -walletbackend (or -walletbackend=bdb) to use the " - "default Berkeley DB wallet.dat."); + "Use -walletbackend=sqlite for the SQLite backend, or " + "-walletbackend=bdb for the Berkeley DB wallet.dat."); return 1; } if (strWalletBackend == "sqlite") { - // Opt-in, experimental. The node runs on /wallet.sqlite and - // never opens wallet.dat, but the user has to have exported one - // first -- there is no silent migration, and wallet.dat is left + // Explicit opt-in via the flag. The node runs on + // /wallet.sqlite and never opens wallet.dat, but the user + // has to have exported one first -- there is no silent migration, + // and wallet.dat is left // exactly where it is so a plain restart goes back to it. AttachTerminal(); string strSQLitePath = GetAppDir() + "/wallet.sqlite"; @@ -724,7 +724,7 @@ int main(int argc, char* argv[]) } fprintf(stderr, - "Wallet backend: SQLite (experimental)\n" + "Wallet backend: SQLite\n" " %s\n" "Your Berkeley DB wallet.dat is left untouched. Restart without\n" "-walletbackend=sqlite to go back to it.\n", diff --git a/src/walletcmd.h b/src/walletcmd.h index 38cbfdb..7013d44 100644 --- a/src/walletcmd.h +++ b/src/walletcmd.h @@ -70,9 +70,9 @@ int CmdWalletStorageAudit(const std::string& strJsonOut = ""); // or migration-unsafe mix of storage records. int CmdWalletStorageCheck(); -// Diagnostic/migration staging: copy every raw wallet.dat record into an -// experimental SQLite key/value store, preserving serialized bytes. This does -// not change which backend the runtime wallet opens. +// Copy every raw wallet.dat record into a SQLite key/value store, preserving +// serialized bytes. A migration/scripting tool; it does not change which backend +// the runtime wallet opens. int CmdWalletSQLiteExport(const std::string& strDest); // Export the currently-loaded wallet to a SQLite file (shared by the command -- cgit v1.2.3 From 28a2656d22a193fcd92d225964da5a01e8183623 Mon Sep 17 00:00:00 2001 From: Nsable-sh Date: Thu, 20 Aug 2026 15:37:44 -0300 Subject: net: add isolated testnet parameters --- README.md | 3 ++- src/Makefile | 1 + src/main.cpp | 16 +++++++++--- src/main_gui.cpp | 7 ++++-- src/makefile.mingw | 1 + src/net.cpp | 32 +++++++++++++++++++++-- src/net.h | 20 +++++++++------ src/readme.txt | 3 ++- src/selftest.cpp | 74 +++++++++++++++++++++++++++++++++++++++++++++++++++++- 9 files changed, 140 insertions(+), 17 deletions(-) (limited to 'src/main_gui.cpp') diff --git a/README.md b/README.md index 94c727d..4843f5c 100644 --- a/README.md +++ b/README.md @@ -355,7 +355,8 @@ Other options worth knowing: ```bash -datadir=PATH # wallet and chain data elsewhere --port=N # P2P listen port, default 8433 +-testnet # isolated test network datadir, port 18433, and message magic +-port=N # P2P listen port, default 8433; testnet default 18433 -socks=HOST:PORT # SOCKS5 for outbound Nostr, .btf relay, and onion peer dials -tor[=HOST:PORT] # Tor mode; default local Tor SOCKS5 proxy is 127.0.0.1:9050 -managedtor[=PATH] # start Tor, create a hidden service, advertise its onion diff --git a/src/Makefile b/src/Makefile index b83ff58..ab0b307 100644 --- a/src/Makefile +++ b/src/Makefile @@ -193,6 +193,7 @@ tests: test_bip32 bitflash-node ./bitflash-node -selftest=wallet-crypto ./bitflash-node -selftest=wallet-encrypt ./bitflash-node -selftest=net-message + ./bitflash-node -selftest=network-params ./bitflash-node -selftest=consensus-limits ./bitflash-node -selftest=pool-stratum ./bitflash-node -selftest=parse-money diff --git a/src/main.cpp b/src/main.cpp index 5bd4028..6fd8c82 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -2497,11 +2497,21 @@ string GetAppDir() { return "."; } - static bool fMkdirDone; - if (!fMkdirDone) + static string strCreatedBaseDir; + if (strCreatedBaseDir != strDir) { - fMkdirDone = true; _mkdir(strDir.c_str()); + strCreatedBaseDir = strDir; + } + if (IsTestNet()) + { + strDir += "/testnet"; + static string strCreatedTestnetDir; + if (strCreatedTestnetDir != strDir) + { + _mkdir(strDir.c_str()); + strCreatedTestnetDir = strDir; + } } return strDir; } diff --git a/src/main_gui.cpp b/src/main_gui.cpp index 943799f..0a961bf 100644 --- a/src/main_gui.cpp +++ b/src/main_gui.cpp @@ -118,7 +118,7 @@ static void PrintUsage() printf(" wallet-crypto, wallet-encrypt, wallet-portability,\n"); printf(" net-message,\n"); printf(" consensus-limits, pool-stratum,\n"); - printf(" parse-money, socks5-proxy, or managed-tor\n"); + printf(" parse-money, network-params, socks5-proxy, or managed-tor\n"); printf("\n"); printf("Mining mode:\n"); printf(" /operator\n"); @@ -143,7 +143,8 @@ static void PrintUsage() printf(" /onionservice=HOST.onion:PORT (advertise this node's Tor hidden service)\n"); printf("\n"); printf("Network:\n"); - printf(" /port=N (P2P listen port, default 8433)\n"); + printf(" /testnet (isolated test network datadir, port, and magic)\n"); + printf(" /port=N (P2P listen port, default 8433; testnet 18433)\n"); printf(" /socks=HOST:PORT (SOCKS5 proxy for Nostr, .btf relay, and onion dials)\n"); printf(" /tor[=HOST:PORT] (Tor mode; default SOCKS5 proxy is 127.0.0.1:9050)\n"); printf(" /managedtor[=PATH] (start Tor, create a hidden service, advertise its onion)\n"); @@ -205,6 +206,8 @@ static void ParseStartupArguments(int argc, char* argv[]) if (arg(argc,argv,"/datadir") || arg(argc,argv,"-datadir")) strSetDataDir = argval2(argc, argv, "/datadir", "-datadir"); + SelectNetworkParams(arg(argc, argv, "/testnet") || arg(argc, argv, "-testnet")); + if (arg(argc,argv,"/debug") || arg(argc,argv,"-debug")) fDebug = true; diff --git a/src/makefile.mingw b/src/makefile.mingw index 55f186b..910e025 100644 --- a/src/makefile.mingw +++ b/src/makefile.mingw @@ -116,6 +116,7 @@ tests: test_bip32.exe bitflash.exe ./bitflash.exe -selftest=wallet-backend-default ./bitflash.exe -selftest=wallet-convert ./bitflash.exe -selftest=net-message + ./bitflash.exe -selftest=network-params ./bitflash.exe -selftest=consensus-limits ./bitflash.exe -selftest=pool-stratum ./bitflash.exe -selftest=socks5-proxy diff --git a/src/net.cpp b/src/net.cpp index 4603d2d..ecfd565 100644 --- a/src/net.cpp +++ b/src/net.cpp @@ -50,10 +50,14 @@ static int BtfPoll(BtfPollFd* pfd, unsigned int nfd, int nTimeoutMs) // // Global state variables // +static const char pchMainnetMessageStart[4] = { 0xbf, 0x20, 0x5c, 0xfd }; +static const char pchTestnetMessageStart[4] = { (char)0xce, (char)0xe2, (char)0xc0, (char)0xff }; +static bool fTestNet = false; +char pchMessageStart[4] = { 0xbf, 0x20, 0x5c, 0xfd }; bool fClient = false; uint64 nLocalServices = (fClient ? 0 : NODE_NETWORK); -CAddress addrLocalHost(0, DEFAULT_PORT, nLocalServices); -unsigned short nListenPort = DEFAULT_PORT; // local P2P port (tunable via /port) +CAddress addrLocalHost(0, GetDefaultPort(), nLocalServices); +unsigned short nListenPort = GetDefaultPort(); // local P2P port (tunable via /port) CNode nodeLocalHost(INVALID_SOCKET, CAddress("127.0.0.1", nLocalServices)); CNode* pnodeLocalHost = &nodeLocalHost; bool fShutdown = false; @@ -90,6 +94,29 @@ static int64 nBtfHandshakeSilent = 0; static string strBtfLastDialFail; static string strBtfLastHandshakeTimeout; +bool IsTestNet() +{ + return fTestNet; +} + +unsigned short GetDefaultPort() +{ + return htons(fTestNet ? TESTNET_PORT : MAINNET_PORT); +} + +void SelectNetworkParams(bool fTestNetIn) +{ + if (fTestNet == fTestNetIn) + return; + + fTestNet = fTestNetIn; + memcpy(pchMessageStart, + fTestNet ? pchTestnetMessageStart : pchMainnetMessageStart, + sizeof(pchMessageStart)); + nListenPort = GetDefaultPort(); + addrLocalHost.port = nListenPort; +} + void BtfChurnNoteResolveAttempt() { CRITICAL_BLOCK(cs_btfChurn) @@ -288,6 +315,7 @@ string GetDiagnosticsText() int nMedian = GetPeerMedianHeight(); str += "Bitflash node diagnostics\n"; + str += strprintf(" network %s\n", IsTestNet() ? "testnet" : "mainnet"); str += strprintf(" uptime %s\n", FormatAge(nNodeStartTime ? nNow - nNodeStartTime : -1).c_str()); if (nMedian < 0) diff --git a/src/net.h b/src/net.h index e3d4a93..56f1341 100644 --- a/src/net.h +++ b/src/net.h @@ -13,7 +13,8 @@ class CBlockIndex; -static const unsigned short DEFAULT_PORT = htons(8433); +static const unsigned short MAINNET_PORT = 8433; +static const unsigned short TESTNET_PORT = 18433; static const unsigned int PUBLISH_HOPS = 5; enum { @@ -26,6 +27,9 @@ enum bool GetMyExternalIP(unsigned int& ipRet); +bool IsTestNet(); +unsigned short GetDefaultPort(); +void SelectNetworkParams(bool fTestNetIn); CNode* ConnectNodeBtf(const string& strBtfAddr); CNode* ConnectNodeBtfResolved(const string& strBtfAddr, const string& strMeeting, const string& strOnion, const unsigned char enc_pub[32], @@ -219,15 +223,17 @@ void CheckForShutdown(int n); // The message start string is designed to be unlikely to occur in normal data. // The characters are rarely used upper ascii, not valid as UTF-8, and produce // a large 4-byte int at any alignment. -// Magic bytes unique to the Bitflash network (0xbf = "BF"). Second byte bumped -// to 0x20 for the 2026 stable relaunch so the old test chain can't interfere. -static const char pchMessageStart[4] = { 0xbf, 0x20, 0x5c, 0xfd }; +// Magic bytes unique to the selected Bitflash network. Mainnet uses 0xbf = +// "BF"; testnet swaps the whole sequence so a test node cannot handshake with +// mainnet before consensus-level testnet activation exists. +extern char pchMessageStart[4]; class CMessageHeader { public: enum { COMMAND_SIZE=12 }; - char pchMessageStart[sizeof(::pchMessageStart)]; + enum { MESSAGE_START_SIZE=4 }; + char pchMessageStart[MESSAGE_START_SIZE]; char pchCommand[COMMAND_SIZE]; unsigned int nMessageSize; @@ -318,7 +324,7 @@ public: nServices = 0; memcpy(pchReserved, pchIPv4, sizeof(pchReserved)); ip = 0; - port = DEFAULT_PORT; + port = GetDefaultPort(); nTime = GetAdjustedTime(); nLastFailed = 0; } @@ -348,7 +354,7 @@ public: nServices = nServicesIn; memcpy(pchReserved, pchIPv4, sizeof(pchReserved)); ip = 0; - port = DEFAULT_PORT; + port = GetDefaultPort(); nTime = GetAdjustedTime(); nLastFailed = 0; diff --git a/src/readme.txt b/src/readme.txt index 752be66..5d62791 100644 --- a/src/readme.txt +++ b/src/readme.txt @@ -41,7 +41,8 @@ The resulting executable is src/bitflash.exe. Command-line options (in addition to the original ones) ------------------------------------------------------- - /port=N listen on P2P port N (default 8433) + /testnet use isolated test network datadir, port 18433, and message magic + /port=N listen on P2P port N (default 8433; testnet default 18433) /socks=HOST:PORT route outbound Nostr, .btf rendezvous, and direct onion peer dials through a SOCKS5 proxy, e.g. /socks=127.0.0.1:9050 for local Tor diff --git a/src/selftest.cpp b/src/selftest.cpp index dc9936e..30bd28e 100644 --- a/src/selftest.cpp +++ b/src/selftest.cpp @@ -3076,6 +3076,76 @@ static int RunNetMessageSelfTest() return nFail == 0 ? 0 : 1; } +static int RunNetworkParamsSelfTest() +{ + fflush(stdout); + printf("network-params self-test\n"); + + int nFail = 0; + std::string strOldDataDir = strSetDataDir; + try + { + SelectNetworkParams(false); + nFail += Check(!IsTestNet(), "starts on mainnet after explicit selection") ? 0 : 1; + nFail += Check(ntohs(GetDefaultPort()) == MAINNET_PORT, + "mainnet default P2P port is 8433") ? 0 : 1; + CAddress mainAddr("127.0.0.1"); + nFail += Check(mainAddr.port == htons(MAINNET_PORT), + "mainnet address parsing uses the mainnet default port") ? 0 : 1; + CMessageHeader mainHeader("ping", 0); + char mainMagic[4]; + memcpy(mainMagic, mainHeader.pchMessageStart, sizeof(mainMagic)); + + SelectNetworkParams(true); + nFail += Check(IsTestNet(), "testnet selection is sticky") ? 0 : 1; + nFail += Check(ntohs(GetDefaultPort()) == TESTNET_PORT, + "testnet default P2P port is 18433") ? 0 : 1; + CAddress testAddr("127.0.0.1"); + nFail += Check(testAddr.port == htons(TESTNET_PORT), + "testnet address parsing uses the testnet default port") ? 0 : 1; + CMessageHeader testHeader("ping", 0); + nFail += Check(memcmp(mainMagic, testHeader.pchMessageStart, sizeof(mainMagic)) != 0, + "testnet message magic differs from mainnet") ? 0 : 1; + + std::string tmp; + if (!MakeTempDir(tmp)) + { + nFail += Check(false, "creates a temporary data directory") ? 0 : 1; + } + else + { + strSetDataDir = tmp; + SelectNetworkParams(false); + std::string mainDir = GetAppDir(); + SelectNetworkParams(true); + std::string testDir = GetAppDir(); + nFail += Check(mainDir == tmp, + "mainnet uses the selected data directory") ? 0 : 1; + nFail += Check(testDir == tmp + "/testnet", + "testnet stores data below a testnet subdirectory") ? 0 : 1; + RemoveTree(tmp); + } + } + catch (const std::exception& e) + { + printf(" FAIL exception: %s\n", e.what()); + nFail++; + } + catch (...) + { + printf(" FAIL unknown exception\n"); + nFail++; + } + + strSetDataDir = strOldDataDir; + SelectNetworkParams(false); + + printf("%s (%d failure%s)\n", nFail == 0 ? "ALL TESTS PASSED" : "TESTS FAILED", + nFail, nFail == 1 ? "" : "s"); + fflush(stdout); + return nFail == 0 ? 0 : 1; +} + static CBlock MakeSizedConsensusBlock(size_t nScriptBytes) { CTransaction tx; @@ -3517,6 +3587,8 @@ int RunSelfTest(const std::string& name) return RunWalletPortabilitySelfTest(); if (name == "net-message") return RunNetMessageSelfTest(); + if (name == "network-params") + return RunNetworkParamsSelfTest(); if (name == "consensus-limits") return RunConsensusLimitsSelfTest(); if (name == "pool-stratum") @@ -3529,6 +3601,6 @@ int RunSelfTest(const std::string& name) return RunManagedTorSelfTest(); printf("Unknown self-test '%s'\n", name.c_str()); - printf("Known self-tests: wallet-keypool, wallet-hd, wallet-format, wallet-storage-sanity, db-env-reopen, wallet-sqlite, wallet-sqlite-migration, wallet-crypto, wallet-encrypt, wallet-sqlite-encrypt, wallet-backend-default, wallet-convert, wallet-portability, net-message, consensus-limits, pool-stratum, parse-money, socks5-proxy, managed-tor\n"); + printf("Known self-tests: wallet-keypool, wallet-hd, wallet-format, wallet-storage-sanity, db-env-reopen, wallet-sqlite, wallet-sqlite-migration, wallet-crypto, wallet-encrypt, wallet-sqlite-encrypt, wallet-backend-default, wallet-convert, wallet-portability, net-message, network-params, consensus-limits, pool-stratum, parse-money, socks5-proxy, managed-tor\n"); return 1; } -- cgit v1.2.3 From 56be66dd66e64e6d21eadd80a808b4627921059f Mon Sep 17 00:00:00 2001 From: Nsable-sh Date: Thu, 20 Aug 2026 17:06:08 -0300 Subject: chain: add isolated testnet genesis --- README.md | 2 +- src/main.cpp | 103 +++++++++++++++++++++++++++++++++++++++++++------------ src/main.h | 2 ++ src/main_gui.cpp | 5 +-- src/readme.txt | 2 +- src/selftest.cpp | 21 +++++++++--- 6 files changed, 105 insertions(+), 30 deletions(-) (limited to 'src/main_gui.cpp') diff --git a/README.md b/README.md index 4843f5c..77227fa 100644 --- a/README.md +++ b/README.md @@ -355,7 +355,7 @@ Other options worth knowing: ```bash -datadir=PATH # wallet and chain data elsewhere --testnet # isolated test network datadir, port 18433, and message magic +-testnet # isolated test network genesis, datadir, port 18433, and magic -port=N # P2P listen port, default 8433; testnet default 18433 -socks=HOST:PORT # SOCKS5 for outbound Nostr, .btf relay, and onion peer dials -tor[=HOST:PORT] # Tor mode; default local Tor SOCKS5 proxy is 127.0.0.1:9050 diff --git a/src/main.cpp b/src/main.cpp index 6fd8c82..5d266ff 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -41,12 +41,30 @@ unsigned int nTransactionsUpdated = 0; map mapNextTx; map mapBlockIndex; -// Bitflash genesis -- mined under RandomX PoW on 2026-07-22 -// (nTime 1753142400, nBits 0x1f0fffff, RandomX key "Bitflash/RandomX/v1/...") -// Bitflash 2026 stable relaunch genesis (2-min blocks, RandomX). Mined values. -unsigned int GENESIS_NONCE = 3141; -uint256 hashGenesisBlock("0x5bd7cb255d814e48cebcdfb72da4dc87b34bd774227f8ceb546c5640f4bdc169"); -uint256 hashGenesisMerkleRoot("0x1a45b4482532abb29b10e234d3f13132230525a339ecea91658ffa675a5b1325"); +// Bitflash mainnet genesis -- mined under RandomX PoW for the 2026 stable +// relaunch. Testnet gets its own resettable genesis below so block indexes, +// headers, and wallet scans cannot be accidentally mixed across networks. +static const unsigned int MAINNET_GENESIS_TIME = 1753315200; // 2026-07-24 00:00:00 UTC +static const unsigned int MAINNET_GENESIS_NONCE = 3141; +static const char* MAINNET_GENESIS_TIMESTAMP = + "Bitflash 24/Jul/2026 Fair launch: one CPU one vote, no premine"; +static const char* MAINNET_GENESIS_HASH = + "0x5bd7cb255d814e48cebcdfb72da4dc87b34bd774227f8ceb546c5640f4bdc169"; +static const char* MAINNET_GENESIS_MERKLE = + "0x1a45b4482532abb29b10e234d3f13132230525a339ecea91658ffa675a5b1325"; + +static const unsigned int TESTNET_GENESIS_TIME = 1787184000; // 2026-08-20 00:00:00 UTC +static const unsigned int TESTNET_GENESIS_NONCE = 3199; +static const char* TESTNET_GENESIS_TIMESTAMP = + "Bitflash testnet 20/Aug/2026: resettable public test chain"; +static const char* TESTNET_GENESIS_HASH = + "0xa9d11c6d697bcb7aea0653bc088b12dcf9f44e732df205162cf085fd9d9963eb"; +static const char* TESTNET_GENESIS_MERKLE = + "0x510b293105e3ff85b8d4c3920e357f85d1a86bf199eb4e7d11f04f380030ec5c"; + +unsigned int GENESIS_NONCE = MAINNET_GENESIS_NONCE; +uint256 hashGenesisBlock(MAINNET_GENESIS_HASH); +uint256 hashGenesisMerkleRoot(MAINNET_GENESIS_MERKLE); CBlockIndex* pindexGenesisBlock = NULL; int nBestHeight = -1; uint256 hashBestChain = 0; @@ -117,6 +135,63 @@ static std::atomic gParticipantHashRateX1000{0}; static std::mutex gParticipantStatusMutex; static std::string gParticipantStatus; +void SelectGenesisParams(bool fTestNetIn) +{ + static bool fGenesisParamsSelected = false; + static bool fGenesisParamsTestNet = false; + if (fGenesisParamsSelected && fGenesisParamsTestNet == fTestNetIn) + return; + + fGenesisParamsSelected = true; + fGenesisParamsTestNet = fTestNetIn; + + if (fTestNetIn) + { + GENESIS_NONCE = TESTNET_GENESIS_NONCE; + hashGenesisBlock = uint256(TESTNET_GENESIS_HASH); + hashGenesisMerkleRoot = uint256(TESTNET_GENESIS_MERKLE); + } + else + { + GENESIS_NONCE = MAINNET_GENESIS_NONCE; + hashGenesisBlock = uint256(MAINNET_GENESIS_HASH); + hashGenesisMerkleRoot = uint256(MAINNET_GENESIS_MERKLE); + } +} + +void SelectChainParams(bool fTestNetIn) +{ + SelectNetworkParams(fTestNetIn); + SelectGenesisParams(fTestNetIn); +} + +static CBlock BuildGenesisBlock() +{ + const char* pszTimestamp = IsTestNet() ? + TESTNET_GENESIS_TIMESTAMP : MAINNET_GENESIS_TIMESTAMP; + + CTransaction txNew; + txNew.vin.resize(1); + txNew.vout.resize(1); + txNew.vin[0].scriptSig = CScript() << 486604799 << CBigNum(4) << + vector((unsigned char*)pszTimestamp, + (unsigned char*)pszTimestamp + strlen(pszTimestamp)); + txNew.vout[0].nValue = 50 * COIN; + txNew.vout[0].scriptPubKey = CScript() << + CBigNum("0x5F1DF16B2B704C8A578D0BBAF74D385CDE12C11EE50455F3C438EF4C3FBCF649B6DE611FEAE06279A60939E028A8D65C10B73071A6F16719274855FEB0FD8A6704") << + OP_CHECKSIG; + + CBlock block; + block.vtx.push_back(txNew); + block.hashPrevBlock = 0; + block.hashMerkleRoot = block.BuildMerkleTree(); + block.nVersion = 1; + block.nTime = IsTestNet() ? TESTNET_GENESIS_TIME : MAINNET_GENESIS_TIME; + block.nBits = 0x1f0fffff; // = bnProofOfWorkLimit (Bitflash RandomX floor) + block.nNonce = GENESIS_NONCE; + return block; +} + void SetParticipantMiningStatus(const std::string& status) { // Updates the GUI status string only. Logging is done at each call site @@ -2664,21 +2739,7 @@ bool LoadBlockIndex(bool fAllowNew) // vMerkleTree: 4a5e1e // Bitflash genesis block -- own header (fair launch, 2026 stable relaunch) - char* pszTimestamp = "Bitflash 24/Jul/2026 Fair launch: one CPU one vote, no premine"; - CTransaction txNew; - txNew.vin.resize(1); - txNew.vout.resize(1); - txNew.vin[0].scriptSig = CScript() << 486604799 << CBigNum(4) << vector((unsigned char*)pszTimestamp, (unsigned char*)pszTimestamp + strlen(pszTimestamp)); - txNew.vout[0].nValue = 50 * COIN; - txNew.vout[0].scriptPubKey = CScript() << CBigNum("0x5F1DF16B2B704C8A578D0BBAF74D385CDE12C11EE50455F3C438EF4C3FBCF649B6DE611FEAE06279A60939E028A8D65C10B73071A6F16719274855FEB0FD8A6704") << OP_CHECKSIG; - CBlock block; - block.vtx.push_back(txNew); - block.hashPrevBlock = 0; - block.hashMerkleRoot = block.BuildMerkleTree(); - block.nVersion = 1; - block.nTime = 1753315200; // 2026-07-24 00:00:00 UTC (stable relaunch) - block.nBits = 0x1f0fffff; // = bnProofOfWorkLimit (Bitflash RandomX floor) - block.nNonce = GENESIS_NONCE; + CBlock block = BuildGenesisBlock(); // If the nonce has not been found yet, mine the genesis once (RandomX) // and print the values to be hardcoded in the constants above. diff --git a/src/main.h b/src/main.h index dc5527e..793c4e8 100644 --- a/src/main.h +++ b/src/main.h @@ -59,6 +59,8 @@ extern map mapBlockIndex; extern uint256 hashGenesisBlock; extern uint256 hashGenesisMerkleRoot; extern unsigned int GENESIS_NONCE; +void SelectGenesisParams(bool fTestNetIn); +void SelectChainParams(bool fTestNetIn); extern CBlockIndex* pindexGenesisBlock; extern int nBestHeight; extern uint256 hashBestChain; diff --git a/src/main_gui.cpp b/src/main_gui.cpp index 0a961bf..9f0fec0 100644 --- a/src/main_gui.cpp +++ b/src/main_gui.cpp @@ -143,7 +143,7 @@ static void PrintUsage() printf(" /onionservice=HOST.onion:PORT (advertise this node's Tor hidden service)\n"); printf("\n"); printf("Network:\n"); - printf(" /testnet (isolated test network datadir, port, and magic)\n"); + printf(" /testnet (isolated test network genesis, datadir, port, and magic)\n"); printf(" /port=N (P2P listen port, default 8433; testnet 18433)\n"); printf(" /socks=HOST:PORT (SOCKS5 proxy for Nostr, .btf relay, and onion dials)\n"); printf(" /tor[=HOST:PORT] (Tor mode; default SOCKS5 proxy is 127.0.0.1:9050)\n"); @@ -206,7 +206,8 @@ static void ParseStartupArguments(int argc, char* argv[]) if (arg(argc,argv,"/datadir") || arg(argc,argv,"-datadir")) strSetDataDir = argval2(argc, argv, "/datadir", "-datadir"); - SelectNetworkParams(arg(argc, argv, "/testnet") || arg(argc, argv, "-testnet")); + bool fUseTestNet = arg(argc, argv, "/testnet") || arg(argc, argv, "-testnet"); + SelectChainParams(fUseTestNet); if (arg(argc,argv,"/debug") || arg(argc,argv,"-debug")) fDebug = true; diff --git a/src/readme.txt b/src/readme.txt index 5d62791..810e8b7 100644 --- a/src/readme.txt +++ b/src/readme.txt @@ -41,7 +41,7 @@ The resulting executable is src/bitflash.exe. Command-line options (in addition to the original ones) ------------------------------------------------------- - /testnet use isolated test network datadir, port 18433, and message magic + /testnet use isolated test network genesis, datadir, port 18433, and message magic /port=N listen on P2P port N (default 8433; testnet default 18433) /socks=HOST:PORT route outbound Nostr, .btf rendezvous, and direct onion peer diff --git a/src/selftest.cpp b/src/selftest.cpp index 30bd28e..1a72e3a 100644 --- a/src/selftest.cpp +++ b/src/selftest.cpp @@ -3085,7 +3085,7 @@ static int RunNetworkParamsSelfTest() std::string strOldDataDir = strSetDataDir; try { - SelectNetworkParams(false); + SelectChainParams(false); nFail += Check(!IsTestNet(), "starts on mainnet after explicit selection") ? 0 : 1; nFail += Check(ntohs(GetDefaultPort()) == MAINNET_PORT, "mainnet default P2P port is 8433") ? 0 : 1; @@ -3095,8 +3095,11 @@ static int RunNetworkParamsSelfTest() CMessageHeader mainHeader("ping", 0); char mainMagic[4]; memcpy(mainMagic, mainHeader.pchMessageStart, sizeof(mainMagic)); + uint256 mainGenesis = hashGenesisBlock; + uint256 mainMerkle = hashGenesisMerkleRoot; + unsigned int mainNonce = GENESIS_NONCE; - SelectNetworkParams(true); + SelectChainParams(true); nFail += Check(IsTestNet(), "testnet selection is sticky") ? 0 : 1; nFail += Check(ntohs(GetDefaultPort()) == TESTNET_PORT, "testnet default P2P port is 18433") ? 0 : 1; @@ -3106,6 +3109,14 @@ static int RunNetworkParamsSelfTest() CMessageHeader testHeader("ping", 0); nFail += Check(memcmp(mainMagic, testHeader.pchMessageStart, sizeof(mainMagic)) != 0, "testnet message magic differs from mainnet") ? 0 : 1; + nFail += Check(hashGenesisBlock != mainGenesis && + hashGenesisMerkleRoot != mainMerkle && + GENESIS_NONCE != mainNonce, + "testnet genesis constants differ from mainnet") ? 0 : 1; + nFail += Check(hashGenesisBlock == uint256("0xa9d11c6d697bcb7aea0653bc088b12dcf9f44e732df205162cf085fd9d9963eb") && + hashGenesisMerkleRoot == uint256("0x510b293105e3ff85b8d4c3920e357f85d1a86bf199eb4e7d11f04f380030ec5c") && + GENESIS_NONCE == 3199, + "testnet genesis constants are pinned") ? 0 : 1; std::string tmp; if (!MakeTempDir(tmp)) @@ -3115,9 +3126,9 @@ static int RunNetworkParamsSelfTest() else { strSetDataDir = tmp; - SelectNetworkParams(false); + SelectChainParams(false); std::string mainDir = GetAppDir(); - SelectNetworkParams(true); + SelectChainParams(true); std::string testDir = GetAppDir(); nFail += Check(mainDir == tmp, "mainnet uses the selected data directory") ? 0 : 1; @@ -3138,7 +3149,7 @@ static int RunNetworkParamsSelfTest() } strSetDataDir = strOldDataDir; - SelectNetworkParams(false); + SelectChainParams(false); printf("%s (%d failure%s)\n", nFail == 0 ? "ALL TESTS PASSED" : "TESTS FAILED", nFail, nFail == 1 ? "" : "s"); -- cgit v1.2.3 From 3a128281cd8fec1c698346191c240a771f80d72c Mon Sep 17 00:00:00 2001 From: mischief Date: Thu, 20 Aug 2026 17:57:27 -0300 Subject: randomx: optional 2 MB large pages for the cache, dataset and scratchpads RandomX reads its 256 MB cache, 2 GB dataset and per-VM scratchpads at random, so most accesses miss the TLB on 4 KB pages. Requesting 2 MB pages recovers roughly a tenth of the hash rate. Each allocation tries large pages first and falls back to normal pages when the pool is not reserved, so nothing regresses when hugepages are unavailable. -nolargepages opts out, and the diagnostics report which allocations got large pages. --- src/main_gui.cpp | 7 ++++ src/net.cpp | 1 + src/randomx_pow.cpp | 106 ++++++++++++++++++++++++++++++++++++++++++++++------ src/randomx_pow.h | 7 ++++ 4 files changed, 110 insertions(+), 11 deletions(-) (limited to 'src/main_gui.cpp') diff --git a/src/main_gui.cpp b/src/main_gui.cpp index 0a961bf..a01bdee 100644 --- a/src/main_gui.cpp +++ b/src/main_gui.cpp @@ -127,6 +127,8 @@ static void PrintUsage() printf(" /stratumbridgeport=N (default 3333; listen on 127.0.0.1)\n"); printf(" /solomine\n"); printf(" /genproclimit=N (mining threads; 0 or absent = every core but one)\n"); + printf(" /nolargepages (do not ask for 2 MB pages for the RandomX\n"); + printf(" cache, dataset and scratchpads)\n"); printf(" /checkblocks=N (blocks re-verified at startup, default 288, 0 = all)\n"); printf("\n"); printf("Pool operator announcement:\n"); @@ -230,6 +232,11 @@ static void ParseStartupArguments(int argc, char* argv[]) if (!strCheckBlocks.empty()) nCheckBlocksOnLoad = atoi(strCheckBlocks.c_str()); + // Must be read before LoadBlockIndex(), which is what first calls + // RandomXInit() and fixes how the cache is allocated. + if (arg(argc,argv,"/nolargepages") || arg(argc,argv,"-nolargepages")) + fRandomXLargePages = false; + // /genproclimit=N -- threads to hash with. 0 or absent means automatic, // which is every core but one. Named after Bitcoin's own option so it reads // familiarly to anyone who has run one of these before. diff --git a/src/net.cpp b/src/net.cpp index ecfd565..287a4bd 100644 --- a/src/net.cpp +++ b/src/net.cpp @@ -366,6 +366,7 @@ string GetDiagnosticsText() nMining, (RandomXFastReady() ? 2080 : 256) + 2 * nMining).c_str() : ", not mining"); + str += strprintf(" large pages %s\n", RandomXLargePagesStatus()); str += SockAccountingText(); diff --git a/src/randomx_pow.cpp b/src/randomx_pow.cpp index 1d058d4..5872090 100644 --- a/src/randomx_pow.cpp +++ b/src/randomx_pow.cpp @@ -23,6 +23,39 @@ static std::mutex g_csInit; static bool g_fInit = false; static bool g_fFast = false; +// RandomX reads a 256 MB cache, a 2 GB dataset and a 2 MB scratchpad per VM +// at random, so most reads miss the TLB on 4 KB pages. 2 MB pages recover +// roughly a tenth of the hash rate. +// +// The pool must be reserved first (vm.nr_hugepages), and RandomX returns NULL +// instead of falling back, so each allocation below retries without the flag. +bool fRandomXLargePages = true; // cleared by -nolargepages +static bool g_fLargeCache = false; +static bool g_fLargeDataset = false; +static bool g_fLargeAny = false; + +static randomx_flags WithLargePages(randomx_flags f) +{ + return (randomx_flags)(f | RANDOMX_FLAG_LARGE_PAGES); +} + +// Ask for a cache in large pages, then plain pages. Sets fLargeOut to say +// which one answered. +static randomx_cache* AllocCache(randomx_flags flags, bool& fLargeOut) +{ + fLargeOut = false; + if (fRandomXLargePages) + { + randomx_cache* c = randomx_alloc_cache(WithLargePages(flags)); + if (c) + { + fLargeOut = true; + return c; + } + } + return randomx_alloc_cache(flags); +} + bool RandomXInit() { @@ -36,28 +69,56 @@ bool RandomXInit() // Detect the best flags for this CPU (JIT, hardware AES, Argon2). g_flags = randomx_get_flags(); - g_cache = randomx_alloc_cache(g_flags); + g_cache = AllocCache(g_flags, g_fLargeCache); if (!g_cache) { // Try without JIT as a fallback g_flags = RANDOMX_FLAG_DEFAULT; - g_cache = randomx_alloc_cache(g_flags); + g_cache = AllocCache(g_flags, g_fLargeCache); if (!g_cache) return error("RandomX: failed to allocate cache (256 MB)"); } randomx_init_cache(g_cache, RANDOMX_KEY, strlen(RANDOMX_KEY)); - // Verification VM in light mode (cache only) - g_vmVerify = randomx_create_vm(g_flags, g_cache, NULL); + // Verification VM in light mode (cache only). The scratchpad is only 2 MB + // and this VM is created once, but it hashes every block the node ever + // validates, so it gets the same treatment. + bool fLargeVerify = false; + if (fRandomXLargePages) + { + g_vmVerify = randomx_create_vm(WithLargePages(g_flags), g_cache, NULL); + fLargeVerify = (g_vmVerify != NULL); + } + if (!g_vmVerify) + g_vmVerify = randomx_create_vm(g_flags, g_cache, NULL); if (!g_vmVerify) return error("RandomX: failed to create verification VM"); + g_fLargeAny = g_fLargeCache || fLargeVerify; g_fInit = true; - printf("RandomX: initialized (cache 256 MB, flags=%d)\n", (int)g_flags); + printf("RandomX: initialized (cache 256 MB, flags=%d, large pages: %s)\n", + (int)g_flags, RandomXLargePagesStatus()); + if (fRandomXLargePages && !g_fLargeCache) + printf("RandomX: large pages unavailable for the cache; " + "reserve them with sysctl vm.nr_hugepages=1280 for about 10%% more hash rate\n"); return true; } +const char* RandomXLargePagesStatus() +{ + if (!fRandomXLargePages) + return "off (-nolargepages)"; + if (g_fLargeCache && g_fLargeDataset) + return "cache + dataset"; + if (g_fLargeCache) + return "cache only"; + if (g_fLargeDataset) + return "dataset only"; + return g_fLargeAny ? "scratchpad only" : "unavailable"; +} + + // One dataset, once, no matter how many miners ask for it. // // Every miner thread calls this at startup. The g_fFast guard below is only @@ -87,8 +148,18 @@ bool RandomXInitDataset(int nThreads) if (g_fFast) return true; - randomx_flags fastFlags = (randomx_flags)(g_flags | RANDOMX_FLAG_FULL_MEM); - g_dataset = randomx_alloc_dataset(fastFlags); + // Only RANDOMX_FLAG_LARGE_PAGES means anything to randomx_alloc_dataset; + // the rest of g_flags belongs to the VMs that read it. + if (fRandomXLargePages) + { + g_dataset = randomx_alloc_dataset(WithLargePages(RANDOMX_FLAG_DEFAULT)); + g_fLargeDataset = (g_dataset != NULL); + if (!g_dataset) + printf("RandomX: large pages unavailable for the 2 GB dataset; " + "reserve 1280 with sysctl vm.nr_hugepages for about 10%% more hash rate\n"); + } + if (!g_dataset) + g_dataset = randomx_alloc_dataset(RANDOMX_FLAG_DEFAULT); if (!g_dataset) { printf("RandomX: not enough memory for the 2 GB dataset, staying in light mode\n"); @@ -142,13 +213,26 @@ void* RandomXCreateMinerVM() { if (!g_fInit && !RandomXInit()) return NULL; + + randomx_flags flags = g_flags; + randomx_cache* cache = g_cache; + randomx_dataset* dataset = NULL; if (g_fFast && g_dataset) { - randomx_flags fastFlags = (randomx_flags)(g_flags | RANDOMX_FLAG_FULL_MEM); - return randomx_create_vm(fastFlags, NULL, g_dataset); + flags = (randomx_flags)(g_flags | RANDOMX_FLAG_FULL_MEM); + cache = NULL; + dataset = g_dataset; + } + + // Each thread gets its own 2 MB scratchpad, so the large-page pool can run + // dry partway through a fleet of miners. Threads that miss out still run. + if (fRandomXLargePages) + { + randomx_vm* vm = randomx_create_vm(WithLargePages(flags), cache, dataset); + if (vm) + return vm; } - // Light mode: own VM sharing the cache - return randomx_create_vm(g_flags, g_cache, NULL); + return randomx_create_vm(flags, cache, dataset); } diff --git a/src/randomx_pow.h b/src/randomx_pow.h index 0027571..355b708 100644 --- a/src/randomx_pow.h +++ b/src/randomx_pow.h @@ -41,4 +41,11 @@ uint256 RandomXHashWithVM(void* vm, const void* pHeader, size_t nSize); // True when the 2 GB dataset is ready (fast mode active). bool RandomXFastReady(); +// Ask for large (2 MB) pages for the cache, dataset and scratchpads. Set false +// by -nolargepages. Must be set before the first RandomXInit(). +extern bool fRandomXLargePages; + +// Which allocations actually got large pages, for the status display. +const char* RandomXLargePagesStatus(); + #endif -- cgit v1.2.3 From 6e4865c14c858d00c6cfa6967e66a86b31cd12f8 Mon Sep 17 00:00:00 2001 From: bitflash-maintainer Date: Sat, 8 Aug 2026 02:29:26 +0000 Subject: pool: sync-gate job creation, retry failed payouts; widen nostr relay set - RebuildJob: no jobs with zero peers or a tip older than 30min; mining unsynced produced orphaned blocks and payouts that can never be paid (observed in production: 8 orphan 'found blocks') - payout exec: on SendMoney failure requeue with 60s backoff, up to 30 attempts, instead of silently forfeiting the payment - nostr: add oxtr.dev, nos.social, fmt.wiz.biz, current.fyi, nostr.bg (12 relays total) for announcement redundancy (cherry picked from commit f1986cd7e4a6f69469a2256eb96ff03f3f146792) --- src/btfrv.cpp | 41 ++++--- src/main.cpp | 318 +++++++++++++++++++++++++++++++++++++++++++++++-------- src/main.h | 3 + src/main_gui.cpp | 40 +++++++ src/net.cpp | 36 ++++++- src/nostr.cpp | 5 + src/rpc.cpp | 95 ++++++++++++++++- 7 files changed, 471 insertions(+), 67 deletions(-) (limited to 'src/main_gui.cpp') diff --git a/src/btfrv.cpp b/src/btfrv.cpp index 6ac3280..905717f 100644 --- a/src/btfrv.cpp +++ b/src/btfrv.cpp @@ -94,31 +94,42 @@ static SOCKET ConnectTo(const char* host, unsigned short port) { struct addrinfo hints, *res = NULL; memset(&hints, 0, sizeof(hints)); - hints.ai_family = AF_INET; + hints.ai_family = AF_UNSPEC; // IPv4 or IPv6 (some relays are v6-only) hints.ai_socktype = SOCK_STREAM; char portstr[16]; sprintf(portstr, "%u", (unsigned)port); if (getaddrinfo(host, portstr, &hints, &res) != 0 || !res) return INVALID_SOCKET; - SOCKET s = BtfSocketTag(socket(res->ai_family, res->ai_socktype, res->ai_protocol), SOCK_RV_DIAL); - if (s == INVALID_SOCKET) { freeaddrinfo(res); return INVALID_SOCKET; } - // Apply a short timeout only for the connect() and initial handshake so an - // unresponsive relay doesn't block the thread. Once the tunnel is live the - // timeout is cleared -- a forwarding pipe must block indefinitely on recv(). + // Try each resolved address until one connects. + SOCKET s = INVALID_SOCKET; + for (struct addrinfo* rp = res; rp != NULL; rp = rp->ai_next) + { + s = BtfSocketTag(socket(rp->ai_family, rp->ai_socktype, rp->ai_protocol), SOCK_RV_DIAL); + if (s == INVALID_SOCKET) + continue; + + // Apply a short timeout only for the connect() and initial handshake so an + // unresponsive relay doesn't block the thread. Once the tunnel is live the + // timeout is cleared -- a forwarding pipe must block indefinitely on recv(). #ifdef _WIN32 - DWORD tvShort = 10000; - setsockopt(s, SOL_SOCKET, SO_RCVTIMEO, (const char*)&tvShort, sizeof(tvShort)); - setsockopt(s, SOL_SOCKET, SO_SNDTIMEO, (const char*)&tvShort, sizeof(tvShort)); + DWORD tvShort = 10000; + setsockopt(s, SOL_SOCKET, SO_RCVTIMEO, (const char*)&tvShort, sizeof(tvShort)); + setsockopt(s, SOL_SOCKET, SO_SNDTIMEO, (const char*)&tvShort, sizeof(tvShort)); #else - struct timeval tvShort; tvShort.tv_sec = 10; tvShort.tv_usec = 0; - setsockopt(s, SOL_SOCKET, SO_RCVTIMEO, &tvShort, sizeof(tvShort)); - setsockopt(s, SOL_SOCKET, SO_SNDTIMEO, &tvShort, sizeof(tvShort)); + struct timeval tvShort; tvShort.tv_sec = 10; tvShort.tv_usec = 0; + setsockopt(s, SOL_SOCKET, SO_RCVTIMEO, (char*)&tvShort, sizeof(tvShort)); + setsockopt(s, SOL_SOCKET, SO_SNDTIMEO, (char*)&tvShort, sizeof(tvShort)); #endif - if (connect(s, res->ai_addr, (int)res->ai_addrlen) != 0) - { - CLOSESOCK(s); freeaddrinfo(res); return INVALID_SOCKET; + if (connect(s, rp->ai_addr, (int)rp->ai_addrlen) != 0) + { + CLOSESOCK(s); + s = INVALID_SOCKET; + continue; + } + break; } freeaddrinfo(res); + if (s == INVALID_SOCKET) return INVALID_SOCKET; // Clear the timeout -- the socket is now a live tunnel and must not // time out when idle. recv() will block until data arrives or peer closes. diff --git a/src/main.cpp b/src/main.cpp index 0342650..32ab952 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -102,6 +102,9 @@ CAddress addrIncoming; int nMineMode = MINE_RELAY; bool fMineModeFromCommandLine = false; string strParticipantPool; // participant mode: pool .btf address +string strPayoutAddress; // participant: /payoutaddr override (mine without a wallet) +string strPayoutFile; // participant: /payoutfile with one address per line, rotated per reconnect +int nHashThreads = 0; // participant: /hashthreads override (0 = cores-1) string strPoolName = "Bitflash Pool"; string strPoolDashboardUrl; double dPoolFeePercent = 0.0; @@ -3488,6 +3491,102 @@ static bool StratumRecvLine(SOCKET s, std::string& buf, // How long to wait for a mining.notify after authorize before giving up static const int64 STUCK_NO_JOB_TIMEOUT_SECS = 120; +// ------------------------------------------------------------------ +// Multi-threaded pool worker. The original loop hashed a single nonce +// per blocking 50ms socket poll, capping one instance at ~20 H/s no +// matter how many cores the machine has. The socket and job bookkeeping +// stay on the calling thread; N hash threads grind nonce ranges on their +// own RandomX VMs (all sharing the one ~2 GB dataset) and drop found +// shares on a queue the I/O thread drains and submits. +// ------------------------------------------------------------------ +struct ParticipantJobShared +{ + std::mutex mtx; + unsigned char header[80]; + uint256 target; + std::string jobId; + uint64 jobSeq; + bool haveJob; + bool stop; + ParticipantJobShared() : jobSeq(0), haveJob(false), stop(false) + { + memset(header, 0, sizeof(header)); + } +}; + +struct ParticipantShareHit +{ + uint64 jobSeq; + std::string jobId; + unsigned int nonce; +}; + +static void ParticipantHashWorker(ParticipantJobShared* sh, int idx, int nWorkers, + std::vector* hits, + std::mutex* hitsMtx) +{ + void* vm = RandomXCreateMinerVM(); + if (!vm) + { + LogPrint("worker", "[worker] hash thread %d: RandomX VM creation failed\n", idx); + return; + } + + unsigned char header[80]; + uint256 target; + std::string jobId; + uint64 mySeq = 0; + bool active = false; + unsigned int nNonce = 0; + uint64 localHashes = 0; + + while (!fShutdown) + { + { + std::lock_guard lk(sh->mtx); + if (sh->stop) + break; + if (sh->haveJob && sh->jobSeq != mySeq) + { + memcpy(header, sh->header, 80); + target = sh->target; + jobId = sh->jobId; + mySeq = sh->jobSeq; + nNonce = (unsigned int)(idx + 1); + active = true; + } + } + if (!active) + { + Sleep(50); + continue; + } + + memcpy(header + 76, &nNonce, 4); + uint256 hash = RandomXHashWithVM(vm, header, 80); + localHashes++; + if ((localHashes & 0x3ff) == 0) + gParticipantHashes.fetch_add(0x400); + + if (hash <= target) + { + LogPrint("worker", "[worker] share found! nonce=%u job=%s (thread %d)\n", + nNonce, jobId.c_str(), idx); + std::lock_guard lk(*hitsMtx); + hits->push_back(ParticipantShareHit{mySeq, jobId, nNonce}); + } + + unsigned int next = nNonce + (unsigned int)nWorkers; + if (next < nNonce) // wrapped past 2^32: range exhausted, wait for next job + active = false; + else + nNonce = next; + } + + gParticipantHashes.fetch_add(localHashes & 0x3ff); + RandomXDestroyMinerVM(vm); +} + static bool PoolParticipantMiner() { // ------------------------------------------------------------------ @@ -3502,6 +3601,51 @@ static bool PoolParticipantMiner() LogPrint("worker", "[worker] connecting to pool %s\n", strParticipantPool.c_str()); SetParticipantMiningStatus("resolving pool address"); + // ------------------------------------------------------------------ + // Steps 1-2: connect to the pool. Two address forms: + // tcp:HOST:PORT -- plain TCP straight to the pool's stratum port + // (e.g. our own operator node on this machine), + // no .btf resolution or rendezvous tunnel involved + // .btf -- resolve via Nostr, dial through a rendezvous relay + // ------------------------------------------------------------------ + btf_socket_t s = INVALID_SOCKET; + if (strParticipantPool.compare(0, 4, "tcp:") == 0) + { + std::string hp = strParticipantPool.substr(4); + size_t colon = hp.rfind(':'); + if (colon == std::string::npos) { + LogPrint("worker", "[worker] bad tcp pool address '%s' (want tcp:host:port)\n", + strParticipantPool.c_str()); + SetParticipantMiningStatus("bad pool address"); + return false; + } + std::string host = hp.substr(0, colon); + int port = atoi(hp.substr(colon + 1).c_str()); + unsigned long ip = inet_addr(host.c_str()); + if (port <= 0 || port > 65535 || ip == INADDR_NONE) { + LogPrint("worker", "[worker] bad tcp pool endpoint '%s'\n", hp.c_str()); + SetParticipantMiningStatus("bad pool endpoint"); + return false; + } + SetParticipantMiningStatus("connecting to pool (direct tcp)"); + struct sockaddr_in sa; + memset(&sa, 0, sizeof(sa)); + sa.sin_family = AF_INET; + sa.sin_port = htons((unsigned short)port); + sa.sin_addr.s_addr = ip; + SOCKET cs = socket(AF_INET, SOCK_STREAM, IPPROTO_TCP); + if (cs == INVALID_SOCKET || connect(cs, (struct sockaddr*)&sa, sizeof(sa)) == SOCKET_ERROR) { + LogPrint("worker", "[worker] direct connect to %s FAILED\n", hp.c_str()); + SetParticipantMiningStatus("pool connect failed"); + if (cs != INVALID_SOCKET) closesocket(cs); + return false; + } + LogPrint("worker", "[worker] connected directly to pool %s\n", hp.c_str()); + s = (btf_socket_t)cs; + goto connected; + } + + { unsigned char target_pubkey[32]; if (!btf::ParseAddress(strParticipantPool, target_pubkey)) { LogPrint("worker", "[worker] invalid .btf address: %s\n", strParticipantPool.c_str()); @@ -3536,11 +3680,11 @@ static bool PoolParticipantMiner() } // ------------------------------------------------------------------ - // Step 2: open encrypted tunnel to pool + // Step 2b: open encrypted tunnel to pool // ------------------------------------------------------------------ SetParticipantMiningStatus("opening tunnel"); - btf_socket_t s = btf::BtfClientTunnel(host.c_str(), (unsigned short)port, - target_pubkey, service_enc_pub); + s = btf::BtfClientTunnel(host.c_str(), (unsigned short)port, + target_pubkey, service_enc_pub); if (s == INVALID_SOCKET) { LogPrint("worker", "[worker] tunnel connect FAILED via %s" " (relay unreachable or pool not listening)\n", @@ -3550,11 +3694,48 @@ static bool PoolParticipantMiner() } LogPrint("worker", "[worker] tunnel open via %s\n", meetingHostPort.c_str()); SetParticipantMiningStatus("tunnel open"); + } + +connected: // ------------------------------------------------------------------ - // Step 3: determine our payout address (mining username) + // Step 3: determine our payout address (mining username). /payoutaddr + // wins -- it lets a machine mine to an address whose keys live + // elsewhere, so no wallet file is needed on the mining machine. + // /payoutfile rotates through a list of such addresses: one is picked + // per (re)connection, and the session self-limits (see rotation break + // in the main loop) so consecutive rounds land on fresh addresses. // ------------------------------------------------------------------ - std::string username; + static std::vector vPayoutAddrs; + static size_t nPayoutRot = 0; + if (!strPayoutFile.empty() && vPayoutAddrs.empty()) + { + FILE* f = fopen(strPayoutFile.c_str(), "r"); + if (f) + { + char line[256]; + while (fgets(line, sizeof(line), f)) + { + std::string a = line; + while (!a.empty() && (a.back() == '\n' || a.back() == '\r' || + a.back() == ' ' || a.back() == '\t')) + a.pop_back(); + if (!a.empty()) + vPayoutAddrs.push_back(a); + } + fclose(f); + LogPrint("worker", "[worker] loaded %zu payout addresses from %s\n", + vPayoutAddrs.size(), strPayoutFile.c_str()); + } + else + LogPrint("worker", "[worker] cannot open payout file %s\n", + strPayoutFile.c_str()); + } + + std::string username = strPayoutAddress; + if (username.empty() && !vPayoutAddrs.empty()) + username = vPayoutAddrs[nPayoutRot++ % vPayoutAddrs.size()]; + if (username.empty()) { std::vector vchPubKey; if (CWalletDB("r").ReadDefaultKey(vchPubKey)) @@ -3595,13 +3776,7 @@ static bool PoolParticipantMiner() unsigned int nThreads = std::thread::hardware_concurrency(); RandomXInitDataset(nThreads > 0 ? (int)nThreads : 1); } - void* rxvm = RandomXCreateMinerVM(); - if (!rxvm) { - LogPrint("worker", "[worker] failed to create RandomX VM\n"); - SetParticipantMiningStatus("RandomX init failed"); - closesocket(s); return false; - } - LogPrint("worker", "[worker] RandomX VM ready (%s)\n", + LogPrint("worker", "[worker] RandomX dataset ready (%s)\n", RandomXFastReady() ? "fast 2GB" : "light 256MB"); // ------------------------------------------------------------------ @@ -3619,7 +3794,6 @@ static bool PoolParticipantMiner() {"params",json::array()}})) { LogPrint("worker", "[worker] subscribe send FAILED -- pool dropped connection\n"); SetParticipantMiningStatus("subscribe send failed"); - RandomXDestroyMinerVM(rxvm); closesocket(s); return false; } @@ -3629,10 +3803,29 @@ static bool PoolParticipantMiner() {"params",json::array({username,"x"})}})) { LogPrint("worker", "[worker] authorize send FAILED -- pool dropped connection\n"); SetParticipantMiningStatus("authorize send failed"); - RandomXDestroyMinerVM(rxvm); closesocket(s); return false; } + // ------------------------------------------------------------------ + // Step 6: spawn hash threads (each with its own RandomX VM on the + // shared dataset), then run the I/O loop below: receive pool messages, + // publish jobs to the threads, submit the shares they find. + // ------------------------------------------------------------------ + int nWorkers = nHashThreads > 0 ? nHashThreads + : (int)std::thread::hardware_concurrency(); + if (nWorkers < 2) nWorkers = 2; + if (nHashThreads <= 0) nWorkers -= 1; // leave one core for node + I/O threads + ParticipantJobShared jobShared; + std::vector shareHits; + std::mutex shareHitsMtx; + std::vector hashThreads; + for (int i = 0; i < nWorkers; i++) + hashThreads.emplace_back(ParticipantHashWorker, &jobShared, i, nWorkers, + &shareHits, &shareHitsMtx); + LogPrint("worker", "[worker] spawned %d hash threads on shared RandomX dataset\n", + nWorkers); + const uint64 sessionStartHashes = gParticipantHashes.load(); + // ------------------------------------------------------------------ // Step 6: main loop -- receive messages, hash, submit shares // ------------------------------------------------------------------ @@ -3650,6 +3843,8 @@ static bool PoolParticipantMiner() uint64 sessionSharesSent = 0; // this connection only uint64 sessionSharesAccepted = 0; int64 sampleStart = GetTime(); + int64 connStart = GetTime(); + const int64 PAYOUT_ROTATE_SECS = 300; // reconnect with next /payoutfile address SetParticipantMiningStatus("waiting for pool response"); @@ -3757,6 +3952,14 @@ static bool PoolParticipantMiner() memcpy(&target, tgtBytes.data(), 32); haveJob = true; nNonce = 1; + { + std::lock_guard lk(jobShared.mtx); + memcpy(jobShared.header, header, 80); + jobShared.target = target; + jobShared.jobId = jobId; + jobShared.jobSeq++; + jobShared.haveJob = true; + } LogPrint("worker", "[pool->worker] new job %s received\n", jobId.c_str()); SetParticipantMiningStatus("hashing job " + jobId); @@ -3812,46 +4015,71 @@ static bool PoolParticipantMiner() } } + // -- Payout address rotation: end this session cleanly so the outer + // loop reconnects and authorizes the next /payoutfile address -- + if (!vPayoutAddrs.empty() && GetTime() - connStart > PAYOUT_ROTATE_SECS) + { + LogPrint("worker", "[worker] payout rotation interval reached" + " -- reconnecting with next address\n"); + break; + } + if (!haveJob) { Sleep(100); continue; } - // -- Hash one nonce -- - memcpy(header+76, &nNonce, 4); - uint256 hash = RandomXHashWithVM(rxvm, header, 80); - hashesSinceSample++; - gParticipantHashes.fetch_add(1); - if ((hashesSinceSample & 0x3ff) == 0) - UpdateParticipantHashRate(hashesSinceSample, sampleStart); - - if (hash <= target) { - LogPrint("worker", "[worker] share found! nonce=%u job=%s difficulty=%.4f\n", - nNonce, jobId.c_str(), currentDifficulty); - SetParticipantMiningStatus("share found; submitting"); - int submitId = msgId++; - pendingSubmitIds.insert(submitId); - gParticipantSharesSent.fetch_add(1); - sessionSharesSent++; - json submit = {{"id",submitId},{"method","mining.submit"}, - {"params",json::array({username, jobId, ToHexStr(&nNonce,4)})}}; - if (!StratumSendLine(s, submit)) { - LogPrint("worker", "[worker] share submit send FAILED" - " -- pool connection lost\n"); - break; - } - } + // -- Hash rate accounting (hashing happens on the worker threads) -- + hashesSinceSample = gParticipantHashes.load() - sessionStartHashes; + UpdateParticipantHashRate(hashesSinceSample, sampleStart); - nNonce++; - if (nNonce == 0) { - LogPrint("worker", "[worker] nonce space exhausted for job %s" - " -- waiting for new job\n", jobId.c_str()); - haveJob = false; + // -- Submit any shares the hash threads found -- + bool submitFailed = false; + { + std::vector pending; + { + std::lock_guard lk(shareHitsMtx); + pending.swap(shareHits); + } + for (ParticipantShareHit& hit : pending) + { + if (hit.jobId != jobId) + { + LogPrint("worker", "[worker] dropping stale share for old job %s\n", + hit.jobId.c_str()); + continue; + } + LogPrint("worker", "[worker] share found! nonce=%u job=%s difficulty=%.4f\n", + hit.nonce, hit.jobId.c_str(), currentDifficulty); + SetParticipantMiningStatus("share found; submitting"); + int submitId = msgId++; + pendingSubmitIds.insert(submitId); + gParticipantSharesSent.fetch_add(1); + sessionSharesSent++; + json submit = {{"id",submitId},{"method","mining.submit"}, + {"params",json::array({username, hit.jobId, + ToHexStr(&hit.nonce,4)})}}; + if (!StratumSendLine(s, submit)) { + LogPrint("worker", "[worker] share submit send FAILED" + " -- pool connection lost\n"); + submitFailed = true; + break; + } + } } + if (submitFailed) + break; } // ------------------------------------------------------------------ - // Cleanup + // Cleanup: stop the hash threads, then drop the pool connection // ------------------------------------------------------------------ - RandomXDestroyMinerVM(rxvm); + { + std::lock_guard lk(jobShared.mtx); + jobShared.stop = true; + jobShared.haveJob = false; + } + for (std::thread& t : hashThreads) + t.join(); closesocket(s); + hashesSinceSample = gParticipantHashes.load() - sessionStartHashes; UpdateParticipantHashRate(hashesSinceSample, sampleStart); SetParticipantMiningStatus("disconnected"); LogPrint("worker", "[worker] disconnected from pool" diff --git a/src/main.h b/src/main.h index c458412..307cee5 100644 --- a/src/main.h +++ b/src/main.h @@ -84,6 +84,9 @@ extern int nMineMode; // something a previous session wrote. extern bool fMineModeFromCommandLine; extern string strParticipantPool; // participant: pool .btf address +extern string strPayoutAddress; // participant: /payoutaddr override (mine without a wallet) +extern string strPayoutFile; // participant: /payoutfile address list, rotated per reconnect +extern int nHashThreads; // participant: /hashthreads override (0 = cores-1) extern string strPoolName; // operator: announced pool name extern string strPoolDashboardUrl; // operator: optional dashboard URL extern double dPoolFeePercent; // operator: announced fee percent diff --git a/src/main_gui.cpp b/src/main_gui.cpp index 875988f..0b4e091 100644 --- a/src/main_gui.cpp +++ b/src/main_gui.cpp @@ -115,6 +115,9 @@ static void PrintUsage() printf("Mining mode:\n"); printf(" /operator\n"); printf(" /participant=POOL_BTF_ADDRESS\n"); + printf(" /payoutaddr=BTF_ADDRESS (participant: pay shares to this address, no wallet needed)\n"); + printf(" /payoutfile=FILE (participant: rotate payout addresses from file)\n"); + printf(" /hashthreads=N (participant: mining threads, default cores-1)\n"); printf(" /solomine\n"); printf(" /genproclimit=N (mining threads; 0 or absent = every core but one)\n"); printf(" /checkblocks=N (blocks re-verified at startup, default 288, 0 = all)\n"); @@ -225,6 +228,18 @@ static void ParseStartupArguments(int argc, char* argv[]) fMineModeFromCommandLine = true; } + string payoutAddr = argval2(argc, argv, "/payoutaddr", "-payoutaddr"); + if (!payoutAddr.empty()) + strPayoutAddress = payoutAddr; + + string payoutFile = argval2(argc, argv, "/payoutfile", "-payoutfile"); + if (!payoutFile.empty()) + strPayoutFile = payoutFile; + + string hashThreads = argval2(argc, argv, "/hashthreads", "-hashthreads"); + if (!hashThreads.empty()) + nHashThreads = atoi(hashThreads.c_str()); + string poolName = argval2(argc, argv, "/poolname", "-poolname"); if (!poolName.empty()) strPoolName = poolName; @@ -610,6 +625,31 @@ int main(int argc, char* argv[]) ReacceptWalletTransactions(); + // /dumpaddresses=N:FILE -- generate N fresh wallet keys and write their + // addresses (one per line) to FILE, then exit. Keys stay in this wallet; + // the file alone is enough for a miner elsewhere to rotate payout + // addresses via /payoutfile. (No HD/xpub exists in this wallet format.) + string dumpSpec = argval2(argc, argv, "/dumpaddresses", "-dumpaddresses"); + if (!dumpSpec.empty()) + { + size_t colon = dumpSpec.rfind(':'); + if (colon == string::npos) { fprintf(stderr, "usage: /dumpaddresses=N:FILE\n"); return 1; } + int n = atoi(dumpSpec.substr(0, colon).c_str()); + string file = dumpSpec.substr(colon + 1); + FILE* f = fopen(file.c_str(), "w"); + if (!f) { fprintf(stderr, "cannot open %s\n", file.c_str()); return 1; } + for (int i = 0; i < n; i++) + { + CKey key; + key.MakeNewKey(); + if (!AddKey(key)) { fclose(f); fprintf(stderr, "AddKey failed at %d\n", i); return 1; } + fprintf(f, "%s\n", PubKeyToAddress(key.GetPubKey()).c_str()); + } + fclose(f); + printf("wrote %d fresh wallet addresses to %s\n", n, file.c_str()); + return 0; + } + if (!StartNode(strErrors)) { fprintf(stderr,"StartNode: %s\n",strErrors.c_str()); return 1; } if (nMineMode == MINE_OPERATOR) { diff --git a/src/net.cpp b/src/net.cpp index 449d91c..5cf2f8c 100644 --- a/src/net.cpp +++ b/src/net.cpp @@ -1054,6 +1054,18 @@ CNode* ConnectNodeBtfResolved(const string& strBtfAddr, const string& strMeeting void ThreadBtfAccept(void* parg) { printf("ThreadBtfAccept started\n"); + // In operator mode the pool server registers this same node identity as a + // stratum service at the rendezvous relays, and a relay only holds ONE + // service registration per pubkey -- peer-accept and pool-accept would + // kick each other off forever, breaking both. The pool's registration is + // the one that must be stable (that's what miners resolve and dial), so + // the node skips inbound rendezvous here and relies on outbound .btf + // connections for block sync. + if (nMineMode == MINE_OPERATOR) + { + printf("ThreadBtfAccept: operator mode -- skipping peer rendezvous registration\n"); + return; + } unsigned char pk[32]; unsigned char sk[32]; while (!BtfGetIdentity(pk, sk)) @@ -1928,11 +1940,29 @@ bool StartNode(string& strError) { int nErr = WSAGetLastError(); if (nErr == WSAEADDRINUSE) - strError = strprintf("Error: Unable to bind to port %s on this computer. The program is probably already running.", addrLocalHost.ToString().c_str()); + { + // Another local instance holds the default port. Fall back to an + // ephemeral port: inbound direct-IP peering is optional, and .btf + // rendezvous connectivity is unaffected by the local listen port. + sockaddr.sin_port = htons(0); + if (bind(hListenSocket, (struct sockaddr*)&sockaddr, sizeof(sockaddr)) == SOCKET_ERROR) + { + strError = strprintf("Error: Unable to bind any port on this computer (bind returned error %d)", WSAGetLastError()); + printf("%s\n", strError.c_str()); + return false; + } + struct sockaddr_in bound; + socklen_t nBoundLen = sizeof(bound); + if (getsockname(hListenSocket, (struct sockaddr*)&bound, &nBoundLen) == 0) + addrLocalHost.port = bound.sin_port; + printf("default port in use by another instance; bound to ephemeral %s instead\n", addrLocalHost.ToString().c_str()); + } else + { strError = strprintf("Error: Unable to bind to port %s on this computer (bind returned error %d)", addrLocalHost.ToString().c_str(), nErr); - printf("%s\n", strError.c_str()); - return false; + printf("%s\n", strError.c_str()); + return false; + } } printf("bound to addrLocalHost = %s\n\n", addrLocalHost.ToString().c_str()); diff --git a/src/nostr.cpp b/src/nostr.cpp index 1fd60ad..21104bc 100644 --- a/src/nostr.cpp +++ b/src/nostr.cpp @@ -92,6 +92,11 @@ const char* pszNostrRelays[] = { "wss://offchain.pub", "wss://nostr.mom", "wss://relay.primal.net", + "wss://nostr.oxtr.dev", + "wss://relay.nos.social", + "wss://nostr.fmt.wiz.biz", + "wss://relay.current.fyi", + "wss://relay.nostr.bg", }; const int nNostrRelays = ARRAYLEN(pszNostrRelays); diff --git a/src/rpc.cpp b/src/rpc.cpp index 41c0328..98b6208 100644 --- a/src/rpc.cpp +++ b/src/rpc.cpp @@ -143,6 +143,20 @@ static bool RebuildJob() return false; } + // Don't issue jobs until the chain is synced: mining against a stale + // tip produces blocks the network will orphan, and payout entries + // that can never be paid (their coinbases never exist on the main + // chain). Require at least one peer and a fresh tip. + if (vNodes.empty()) { + LogPrint("pool", "RebuildJob: no peers yet -- not issuing jobs\n"); + return false; + } + if (GetAdjustedTime() - (int64)pindexBest->nTime > 30*60) { + LogPrint("pool", "RebuildJob: tip is %llds old -- still syncing, not issuing jobs\n", + (long long)(GetAdjustedTime() - (int64)pindexBest->nTime)); + return false; + } + CBlockIndex* pindexPrev = pindexBest; unsigned int nBits = GetNextWorkRequired(pindexPrev); @@ -218,6 +232,8 @@ static bool RebuildJob() struct PendingPayout { int matureAtHeight; std::map amounts; // address -> satoshis + int retries = 0; // exec-thread only: SendMoney attempts so far + int64 retryAfter = 0; // exec-thread only: unix time, 0 = ready now }; static std::vector gPendingPayouts; @@ -403,6 +419,11 @@ static void PayoutThreadFn(void*) } for (auto& pp : batch) { + if (pp.retryAfter > GetTime()) { + std::lock_guard lk(gPayoutExecMutex); + gPayoutExecQueue.push_back(pp); + continue; + } LogPrint("payout", "[payout] executing payments for block matured at %d" " (%zu miners)\n", pp.matureAtHeight, pp.amounts.size()); for (auto& kv : pp.amounts) { @@ -418,13 +439,34 @@ static void PayoutThreadFn(void*) CWalletTx wtx; wtx.mapValue["comment"] = strprintf("Pool payout height %d", pp.matureAtHeight); - if (SendMoney(sc, kv.second, wtx)) + if (SendMoney(sc, kv.second, wtx)) { LogPrint("payout", "[payout] paid %s to %s\n", FormatMoney(kv.second).c_str(), kv.first.c_str()); - else + } else if (pp.retries < 30) { + // Transient failures (immature coinbase, reorg, locked + // wallet) must not silently burn the payment -- requeue + // with backoff. Permanent cases (orphaned coinbase) give + // up loudly after 30 tries (~30 min). + PendingPayout rp; + rp.matureAtHeight = pp.matureAtHeight; + rp.amounts[kv.first] = kv.second; + rp.retries = pp.retries + 1; + rp.retryAfter = GetTime() + 60; + { + std::lock_guard lk(gPayoutExecMutex); + gPayoutExecQueue.push_back(std::move(rp)); + } LogPrint("payout", "[payout] FAILED to pay %s to %s" - " (insufficient funds or wallet locked?)\n", - FormatMoney(kv.second).c_str(), kv.first.c_str()); + " -- retry %d/30 in 60s\n", + FormatMoney(kv.second).c_str(), kv.first.c_str(), + pp.retries + 1); + } else { + LogPrint("payout", "[payout] GIVING UP on %s to %s after %d" + " retries (orphaned coinbase or persistent wallet" + " error?) -- payment forfeited\n", + FormatMoney(kv.second).c_str(), kv.first.c_str(), + pp.retries); + } } } @@ -543,6 +585,45 @@ static void PushReady(btf_socket_t s) gReadyQueue.push_back(s); } +// Loopback stratum listener: lets a miner running on the SAME machine reach +// this pool over plain TCP (127.0.0.1:8334), skipping the .btf rendezvous +// entirely. The rendezvous relay only allows one service registration per +// pubkey, so the pool service and the node's own peer-accept fight over the +// node identity at the relay -- loopback avoids that fight for local miners. +static void LocalAcceptFn(void*) +{ + SOCKET ls = socket(AF_INET, SOCK_STREAM, IPPROTO_TCP); + if (ls == INVALID_SOCKET) + return; + int yes = 1; + setsockopt(ls, SOL_SOCKET, SO_REUSEADDR, (char*)&yes, sizeof(yes)); + struct sockaddr_in addr; + memset(&addr, 0, sizeof(addr)); + addr.sin_family = AF_INET; + addr.sin_addr.s_addr = htonl(INADDR_LOOPBACK); + addr.sin_port = htons(8334); + if (bind(ls, (struct sockaddr*)&addr, sizeof(addr)) != 0 || listen(ls, 16) != 0) + { + LogPrint("pool", "[pool] loopback stratum bind/listen failed on 127.0.0.1:8334\n"); + closesocket(ls); + return; + } + LogPrint("pool", "[pool] loopback stratum listening on 127.0.0.1:8334\n"); + while (!fShutdown && gPoolRunning) + { + SOCKET c = accept(ls, NULL, NULL); + if (c == INVALID_SOCKET) + { + if (fShutdown || !gPoolRunning) + break; + continue; + } + LogPrint("pool", "[pool] miner arrived via loopback -- pushing to event loop\n"); + PushReady((btf_socket_t)c); + } + closesocket(ls); +} + // --------------------------------------------------------------------------- // I/O helpers -- event loop thread only // --------------------------------------------------------------------------- @@ -1307,6 +1388,11 @@ static void AcceptOneFn(void* arg) LogPrint("pool", "[pool] accept thread started for relay %s\n", relay.c_str()); while (!fShutdown && gPoolRunning) { + // Advertise this relay as the pool's meeting node BEFORE registering: + // RvServiceRegister blocks until a miner pairs, and miners can only + // find us if the published descriptor already names a relay where the + // pool (not the node's peer-accept) holds the registration. + BtfSetActiveRelay(relay); // Blocks until a miner connects to us at this relay btf::RvSocket rv = btf::RvServiceRegister(host.c_str(), (unsigned short)port, pk); @@ -1408,6 +1494,7 @@ void ThreadRPCServer(void*) LoadPendingPayouts(); _beginthread(AcceptLoopFn, 0, NULL); + _beginthread(LocalAcceptFn, 0, NULL); _beginthread(ThreadBtfPoolAnnouncer, 0, NULL); _beginthread(PayoutThreadFn, 0, NULL); -- cgit v1.2.3 From 76f154a4c600082b346124e9e9af26ce401600da Mon Sep 17 00:00:00 2001 From: bitflash-maintainer Date: Sat, 8 Aug 2026 04:37:52 +0000 Subject: nostr: make /rvrelay a true pin -- never merge discovered relays A pool started with /rvrelay=ONE still spawned an accept thread for every Nostr-discovered relay (BtfAllRelays always merged g_discoveredRelays), and each thread optimistically overwrote the advertised meeting node before registering -- so a discovered Tailscale-only relay (a mesh host) flapped the published .btf descriptor and public miners could not connect. fBtfRelayPinned (set only when /rvrelay parses) makes BtfAllRelays return exactly the pinned list. Also validate the /rvrelay value at parse time: with the pin in place no discovered relay can mask a typo. (cherry picked from commit 0a4d2b02fa5382c5e788b5f0e2f8a844695dec47) --- src/main_gui.cpp | 16 ++++++++++++++-- src/nostr.cpp | 10 +++++++++- src/nostr.h | 8 ++++++++ 3 files changed, 31 insertions(+), 3 deletions(-) (limited to 'src/main_gui.cpp') diff --git a/src/main_gui.cpp b/src/main_gui.cpp index 0b4e091..d0969b5 100644 --- a/src/main_gui.cpp +++ b/src/main_gui.cpp @@ -261,8 +261,20 @@ static void ParseStartupArguments(int argc, char* argv[]) string rvRelay = argval2(argc, argv, "/rvrelay", "-rvrelay"); if (!rvRelay.empty()) { - vBtfMeetingRelays.clear(); - vBtfMeetingRelays.push_back(rvRelay); + if (!LooksLikeRelay(rvRelay)) + { + // A malformed pin must not replace the seed list: with the pin + // set, no discovered relay can ever mask the typo, and the pool + // would publish a garbage (or no) meeting node forever. + fprintf(stderr, "Ignoring /rvrelay=%s: expected host:port\n", + rvRelay.c_str()); + } + else + { + vBtfMeetingRelays.clear(); + vBtfMeetingRelays.push_back(rvRelay); + fBtfRelayPinned = true; + } } string announceRelay = argval2(argc, argv, "/announcerelay", "-announcerelay"); diff --git a/src/nostr.cpp b/src/nostr.cpp index 21104bc..6d85bf6 100644 --- a/src/nostr.cpp +++ b/src/nostr.cpp @@ -110,6 +110,12 @@ vector vBtfMeetingRelays = { "31.44.4.249:8434", // New Jersey, US "90.156.222.107:8434", // Almaty, KZ }; +// True when the operator pinned the relay list with /rvrelay: discovered +// relays must NOT be merged in (BtfAllRelays), otherwise one accept thread +// per discovered relay is spawned and each one optimistically overwrites the +// advertised meeting node (BtfSetActiveRelay) before registering -- a +// discovered but unreachable relay then flaps the published .btf descriptor. +bool fBtfRelayPinned = false; string strBtfActiveRelay; static CCriticalSection cs_activeRelay; @@ -144,7 +150,7 @@ static map g_discoveredPools; static CCriticalSection cs_discoveredPools; // Basic sanity: "host:port" with a numeric 1..65535 port and a plausible host. -static bool LooksLikeRelay(const string& r) +bool LooksLikeRelay(const string& r) { size_t colon = r.rfind(':'); if (colon == string::npos || colon == 0 || colon + 1 >= r.size()) @@ -172,6 +178,8 @@ static bool LooksLikeRelay(const string& r) vector BtfAllRelays() { vector all = vBtfMeetingRelays; + if (fBtfRelayPinned) + return all; CRITICAL_BLOCK(cs_discoveredRelays) foreach(const string& r, g_discoveredRelays) if (find(all.begin(), all.end(), r) == all.end()) diff --git a/src/nostr.h b/src/nostr.h index b5eb5e3..827b2f3 100644 --- a/src/nostr.h +++ b/src/nostr.h @@ -43,6 +43,14 @@ void BtfSetActiveRelay(const std::string& relay); // If set (via /announcerelay=host:port), this node announces that relay on // Nostr so other nodes discover it automatically -- no manual seed-list edit. extern std::string strBtfAnnounceRelay; +// True when /rvrelay pinned the meeting relay list: BtfAllRelays then returns +// ONLY vBtfMeetingRelays, never Nostr-discovered volunteer relays. Without +// this, a pool that pins one public relay still spawns an accept thread per +// discovered relay, and each thread's optimistic BtfSetActiveRelay makes the +// published descriptor flap to relays the pool may not be reachable at. +extern bool fBtfRelayPinned; +// Basic sanity check on a "host:port" relay address (numeric port 1..65535). +bool LooksLikeRelay(const std::string& r); // Every relay to try: curated seeds + relays discovered from Nostr announcements. std::vector BtfAllRelays(); -- cgit v1.2.3 From 7a8f7e7fa767d19f44f0a6a6db84de68f812bebe Mon Sep 17 00:00:00 2001 From: bitflash-maintainer Date: Sat, 8 Aug 2026 05:28:04 +0000 Subject: operator: /noannounce flag -- serve miners without publishing pool announcements Descriptor (38501) still published so miners can resolve the pool; only the public pool listing event (38503) is suppressed. (cherry picked from commit 17bbdae1ec97945c37c1d5b2486e5c01a4d7ad87) --- src/main.cpp | 1 + src/main.h | 1 + src/main_gui.cpp | 3 +++ src/nostr.cpp | 4 ++-- 4 files changed, 7 insertions(+), 2 deletions(-) (limited to 'src/main_gui.cpp') diff --git a/src/main.cpp b/src/main.cpp index 32ab952..69d9d30 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -87,6 +87,7 @@ CKey keyUser; string strSetDataDir; int nDropMessagesTest = 0; bool fSoloMineTest = false; // /solomine: mine without requiring a peer (local test) +bool fPoolNoAnnounce = false; // /noannounce: operator serves miners but never publishes pool announcements // Settings int fGenerateBitcoins; diff --git a/src/main.h b/src/main.h index 307cee5..22be3dc 100644 --- a/src/main.h +++ b/src/main.h @@ -86,6 +86,7 @@ extern bool fMineModeFromCommandLine; extern string strParticipantPool; // participant: pool .btf address extern string strPayoutAddress; // participant: /payoutaddr override (mine without a wallet) extern string strPayoutFile; // participant: /payoutfile address list, rotated per reconnect +extern bool fPoolNoAnnounce; // /noannounce: suppress pool announcements extern int nHashThreads; // participant: /hashthreads override (0 = cores-1) extern string strPoolName; // operator: announced pool name extern string strPoolDashboardUrl; // operator: optional dashboard URL diff --git a/src/main_gui.cpp b/src/main_gui.cpp index d0969b5..b1fe0af 100644 --- a/src/main_gui.cpp +++ b/src/main_gui.cpp @@ -250,6 +250,9 @@ static void ParseStartupArguments(int argc, char* argv[]) if (!poolDash.empty()) strPoolDashboardUrl = poolDash; + if (arg(argc,argv,"/noannounce") || arg(argc,argv,"-noannounce")) + fPoolNoAnnounce = true; + string poolFee = argval2(argc, argv, "/poolfee", "-poolfee"); if (!poolFee.empty()) dPoolFeePercent = atof(poolFee.c_str()); diff --git a/src/nostr.cpp b/src/nostr.cpp index 6d85bf6..47f001c 100644 --- a/src/nostr.cpp +++ b/src/nostr.cpp @@ -1405,7 +1405,7 @@ void ThreadBtfPoolAnnouncer(void*) LogPrint("pool", "ThreadBtfPoolAnnouncer started\n"); while (!fShutdown) { - if (nMineMode == MINE_OPERATOR && gPoolRunning) + if (nMineMode == MINE_OPERATOR && gPoolRunning && !fPoolNoAnnounce) { BtfPoolAnnouncement ann; ann.btfAddress = BtfLocalAddress(); @@ -1763,7 +1763,7 @@ void ThreadNostrSeed(void* parg) } CATCH_PRINT_EXCEPTION("ConnectDiscoveredBtfPeers") - if (nMineMode == MINE_OPERATOR && gPoolRunning) + if (nMineMode == MINE_OPERATOR && gPoolRunning && !fPoolNoAnnounce) { BtfPoolAnnouncement ann; ann.btfAddress = BtfLocalAddress(); -- cgit v1.2.3 From 3d7c30ebb6ac0c586635c693a40bc39180f539b9 Mon Sep 17 00:00:00 2001 From: bitflash-maintainer Date: Tue, 11 Aug 2026 20:52:09 +0000 Subject: pool: refuse jobs when no peer has reported a height for 20 minutes The peer-height gate replaced a tip-age check that was wrong in both directions. It is a strict improvement and it is still blind in the one case that matters most. When the node goes deaf -- peers still in vNodes, nothing arriving -- no peer announces anything, GetPeerMedianHeight() returns -1, and the fallback mines our best chain. That fallback is right for a node that has not been told yet and wrong for a node that has stopped being able to hear, and a height nobody sent cannot tell those apart. A wedged node is exactly a node with no peer height information, so the gate was silent for the whole of heights 3966-4085: 120 blocks, 5890.50 BTF voided. Time can tell them apart. GetPeerHeightAgeSecs() measures how long since any peer reported a height, counting from first use rather than the epoch so a starting node does not refuse jobs before its first handshake. Past 20 minutes the pool stops issuing jobs and says so unconditionally in the log. /poolmineblind overrides it: without an escape hatch a genuinely isolated pool that is 90% of the hashrate could never restart its own chain, which is the deadlock the tip-age check used to cause. Also stop nStartingHeight from going stale. It was a snapshot taken once during the version handshake and never touched again, so on a long-lived connection the median is built from heights the peers had hours ago, and the behind-the-network check drifts toward 'everyone is behind us' and quietly stops firing. A peer that hands us a block is demonstrably at least at that block's height. (cherry picked from commit 0800c18faf703b4acde1b131d0b742d37a86d92b) --- src/main.cpp | 15 +++++++++++++++ src/main.h | 1 + src/main_gui.cpp | 3 +++ src/net.cpp | 23 +++++++++++++++++++++++ src/net.h | 8 ++++++++ src/rpc.cpp | 32 ++++++++++++++++++++++++++++++++ 6 files changed, 82 insertions(+) (limited to 'src/main_gui.cpp') diff --git a/src/main.cpp b/src/main.cpp index 382a008..5d0b70a 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -88,6 +88,7 @@ string strSetDataDir; int nDropMessagesTest = 0; bool fSoloMineTest = false; // /solomine: mine without requiring a peer (local test) bool fPoolNoAnnounce = false; // /noannounce: operator serves miners but never publishes pool announcements +bool fPoolMineBlind = false; // /poolmineblind: issue jobs even when no peer has reported a height // Settings int fGenerateBitcoins; @@ -3172,7 +3173,21 @@ bool ProcessMessage(CNode* pfrom, string strCommand, CDataStream& vRecv) pfrom->AddInventoryKnown(inv); if (ProcessBlock(pfrom, pblock.release())) + { mapAlreadyAskedFor.erase(inv); + + // A peer that just handed us a block is demonstrably at least at + // that block's height. Without this, nStartingHeight is a snapshot + // taken once during the version handshake and never touched again, + // so on a long-lived connection it is the peer's height from + // however many hours ago it happened to connect. The pool's + // behind-the-network check compares against the median of those + // numbers, so as connections age it drifts toward "everyone is + // behind us" and quietly stops firing -- the opposite of its job. + map::iterator mi = mapBlockIndex.find(inv.hash); + if (mi != mapBlockIndex.end() && mi->second) + pfrom->nStartingHeight = max(pfrom->nStartingHeight, mi->second->nHeight); + } } diff --git a/src/main.h b/src/main.h index 6b1ff0a..f5409e9 100644 --- a/src/main.h +++ b/src/main.h @@ -93,6 +93,7 @@ extern string strParticipantPool; // participant: pool .btf address extern string strPayoutAddress; // participant: /payoutaddr override (mine without a wallet) extern string strPayoutFile; // participant: /payoutfile address list, rotated per reconnect extern bool fPoolNoAnnounce; // /noannounce: suppress pool announcements +extern bool fPoolMineBlind; // /poolmineblind: mine on even when deaf to every peer extern int nHashThreads; // participant: /hashthreads override (0 = cores-1) extern string strPoolName; // operator: announced pool name extern string strPoolDashboardUrl; // operator: optional dashboard URL diff --git a/src/main_gui.cpp b/src/main_gui.cpp index b1fe0af..c20ae46 100644 --- a/src/main_gui.cpp +++ b/src/main_gui.cpp @@ -253,6 +253,9 @@ static void ParseStartupArguments(int argc, char* argv[]) if (arg(argc,argv,"/noannounce") || arg(argc,argv,"-noannounce")) fPoolNoAnnounce = true; + if (arg(argc,argv,"/poolmineblind") || arg(argc,argv,"-poolmineblind")) + fPoolMineBlind = true; + string poolFee = argval2(argc, argv, "/poolfee", "-poolfee"); if (!poolFee.empty()) dPoolFeePercent = atof(poolFee.c_str()); diff --git a/src/net.cpp b/src/net.cpp index 5cf2f8c..964097b 100644 --- a/src/net.cpp +++ b/src/net.cpp @@ -1700,6 +1700,13 @@ void ThreadMessageHandler(void* parg) } } +// When any peer last told us a height. A wedged node -- one that still has +// peers but has stopped hearing them -- is exactly a node where this stops +// advancing, and that is the one thing the height comparison itself cannot +// see: there is no height to compare. +static int64 nLastPeerHeightSeen = 0; +static int64 nPeerHeightFirstAsked = 0; + int GetPeerMedianHeight() { vector vHeights; @@ -1711,10 +1718,26 @@ int GetPeerMedianHeight() if (vHeights.empty()) return -1; + nLastPeerHeightSeen = GetTime(); sort(vHeights.begin(), vHeights.end()); return vHeights[vHeights.size() / 2]; } +// Seconds since any peer last reported a height. +// +// Before ever hearing one, measure from the first time somebody asked rather +// than from the epoch -- otherwise a node refuses jobs the instant it starts, +// before it has had a chance to complete a single handshake. +int64 GetPeerHeightAgeSecs() +{ + int64 nNow = GetTime(); + if (nPeerHeightFirstAsked == 0) + nPeerHeightFirstAsked = nNow; + if (nLastPeerHeightSeen == 0) + return nNow - nPeerHeightFirstAsked; + return nNow - nLastPeerHeightSeen; +} + // Say out loud when we are behind the network. // diff --git a/src/net.h b/src/net.h index bcd0b2a..e4cfa69 100644 --- a/src/net.h +++ b/src/net.h @@ -120,6 +120,13 @@ static const unsigned int MAX_CONNECTIONS = 125; // this long regardless of what is still buffered. static const int64 DISCONNECT_DRAIN_SECS = 60; +// How long a pool may go without hearing a height from ANY peer before it +// stops issuing jobs. Long enough that ordinary peer churn and a quiet +// handshake window do not trip it; short enough that a deaf pool cannot build +// more than a few blocks of private fork before it is stopped. Heights +// 3966-4085 -- 120 blocks, 5890.50 BTF -- were mined in exactly this state. +static const int64 BTF_NO_PEER_HEIGHT_MAX_SECS = 20 * 60; + // Send a ping after this long with nothing to say, so a peer running the // inactivity check above does not mistake a quiet node for a dead one. Must // stay well under BTF_RECV_TIMEOUT_SECS. @@ -160,6 +167,7 @@ extern int nBestHeight; // has told us anything. Median rather than maximum so one peer claiming an // absurd height cannot move it. int GetPeerMedianHeight(); +int64 GetPeerHeightAgeSecs(); // Lightweight .btf churn accounting. These counters are diagnostics only: they // let an operator tell stale descriptors, relay pairing failures, and paired diff --git a/src/rpc.cpp b/src/rpc.cpp index 8a0d112..da22b1d 100644 --- a/src/rpc.cpp +++ b/src/rpc.cpp @@ -180,6 +180,38 @@ static bool RebuildJob() return false; } + // The comparison above is blind in the one case that matters most. + // + // When the node goes deaf -- peers still in vNodes, nothing arriving -- + // no peer announces anything, GetPeerMedianHeight() returns -1, and the + // fallback ("nobody has told us anything, so mine our best chain") let + // the pool build a private fork for as long as the deafness lasted. + // That fallback is right for a node that has simply not been told yet + // and wrong for a node that has stopped being able to hear, and a + // height nobody sent cannot tell those apart. Time can. + // + // Deliberately not behind a debug category, and deliberately not a + // silent refusal: an operator whose pool has stopped issuing jobs needs + // the reason in the log. /poolmineblind is the escape hatch for a node + // that really is alone -- without one, a genuinely isolated pool that is + // the majority of the hashrate could never restart its own chain, which + // is the deadlock the tip-age check used to cause. + if (nPeerHeight < 0 && !fPoolMineBlind) { + int64 nAge = GetPeerHeightAgeSecs(); + if (nAge >= BTF_NO_PEER_HEIGHT_MAX_SECS) { + static int64 nLastSaid = 0; + if (GetTime() - nLastSaid > 60) { + nLastSaid = GetTime(); + printf("RebuildJob: no peer has reported a height for %d minutes " + "-- not issuing jobs (our height %d, %d peers connected). " + "This node is deaf; mining now would fork it. " + "Pass /poolmineblind to override.\n", + (int)(nAge / 60), nBestHeight, (int)vNodes.size()); + } + return false; + } + } + CBlockIndex* pindexPrev = pindexBest; unsigned int nBits = GetNextWorkRequired(pindexPrev); -- cgit v1.2.3 From ee7613be2f1d679c7ea6e3948c44110313d230b1 Mon Sep 17 00:00:00 2001 From: bitflash-maintainer Date: Thu, 13 Aug 2026 17:29:10 +0000 Subject: size thread pools by the cores we are allowed, not the ones the host has std::thread::hardware_concurrency() reports the MACHINE's core count. Inside a container that is the wrong number: with --cpuset-cpus 0-5 on a 12-core host it still answers 12, and every caller that sized a pool from it oversubscribed 2x. Measured on the pool box: - the pool node logged "Mining with 11 thread(s) of 12 core(s)" while confined to 6 CPUs - a burst miner launched with /hashthreads=2 filled the ~2 GB RandomX dataset with 12 threads, alongside the pool node in the same 8 GB container AvailableCoreCount() asks sched_getaffinity what the cpuset actually permits, which is the number all of these meant. It cannot make the dataset build slower: the CPU budget is identical either way, so fewer threads on the same cores is the same work without the thrash. Falls back to hardware_concurrency where the call is unavailable, and never returns 0 because callers subtract from it. Not the cause of the burst miner dying -- that was a corrupt Berkeley DB environment in the worker datadir -- but it is why the failure landed on the dataset build, and it is wrong on its own terms. (cherry picked from commit b3bee16f3eef8982bff1174bd9d0a9c21473732c) --- src/main.cpp | 12 ++++++++---- src/main_gui.cpp | 2 +- src/net.cpp | 2 +- src/util.h | 35 +++++++++++++++++++++++++++++++++++ 4 files changed, 45 insertions(+), 6 deletions(-) (limited to 'src/main_gui.cpp') diff --git a/src/main.cpp b/src/main.cpp index 11eb11d..62068d2 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -164,7 +164,7 @@ int MinerThreadCount() { if (nMinerThreads > 0) return nMinerThreads; - unsigned int nCores = std::thread::hardware_concurrency(); + unsigned int nCores = AvailableCoreCount(); if (nCores <= 1) return 1; return (int)(nCores - 1); @@ -3827,7 +3827,11 @@ connected: // ------------------------------------------------------------------ if (!RandomXFastReady()) { SetParticipantMiningStatus("building RandomX dataset (~2GB, one-time)"); - unsigned int nThreads = std::thread::hardware_concurrency(); + // Fill with the cores we are permitted to use, not the ones the host + // happens to have. More threads than the cpuset allows does not build + // the dataset any faster -- the CPU budget is the same either way -- + // it just oversubscribes, and this runs alongside the pool node. + unsigned int nThreads = AvailableCoreCount(); RandomXInitDataset(nThreads > 0 ? (int)nThreads : 1); } LogPrint("worker", "[worker] RandomX dataset ready (%s)\n", @@ -3866,7 +3870,7 @@ connected: // publish jobs to the threads, submit the shares they find. // ------------------------------------------------------------------ int nWorkers = nHashThreads > 0 ? nHashThreads - : (int)std::thread::hardware_concurrency(); + : (int)AvailableCoreCount(); if (nHashThreads <= 0) { if (nWorkers < 2) nWorkers = 2; nWorkers -= 1; // leave one core for node + I/O threads @@ -4177,7 +4181,7 @@ bool BitcoinMiner(int nThreadId) // Build the fast (~2 GB dataset) RandomX mode once, up front, so we're // not stuck mining in the much slower light/cache-only mode forever. if (!RandomXFastReady()) { - unsigned int nThreads = std::thread::hardware_concurrency(); + unsigned int nThreads = AvailableCoreCount(); RandomXInitDataset(nThreads > 0 ? (int)nThreads : 1); } diff --git a/src/main_gui.cpp b/src/main_gui.cpp index c20ae46..bee4b95 100644 --- a/src/main_gui.cpp +++ b/src/main_gui.cpp @@ -202,7 +202,7 @@ static void ParseStartupArguments(int argc, char* argv[]) if (!strProcLimit.empty()) { int n = atoi(strProcLimit.c_str()); - unsigned int nCores = std::thread::hardware_concurrency(); + unsigned int nCores = AvailableCoreCount(); if (n < 0) fprintf(stderr, "Ignoring /genproclimit=%s: not a thread count\n", strProcLimit.c_str()); diff --git a/src/net.cpp b/src/net.cpp index 77f9acf..72a7fda 100644 --- a/src/net.cpp +++ b/src/net.cpp @@ -1910,7 +1910,7 @@ int StartMinerThreads() // question anyone asks. printf("Mining with %d thread(s) of %u core(s) -- about %d MB " "(~2080 MB shared RandomX dataset + ~2 MB per thread)\n", - nStarted, std::thread::hardware_concurrency(), 2080 + 2 * nStarted); + nStarted, AvailableCoreCount(), 2080 + 2 * nStarted); return nStarted; } diff --git a/src/util.h b/src/util.h index d95d3ae..d72518b 100644 --- a/src/util.h +++ b/src/util.h @@ -8,6 +8,13 @@ #include #endif +// For AvailableCoreCount() below: sched_getaffinity on Linux, and +// hardware_concurrency as the fallback everywhere else. +#include +#ifdef __linux__ +#include +#endif + #if defined(_MSC_VER) || defined(__BORLANDC__) typedef __int64 int64; typedef unsigned __int64 uint64; @@ -144,6 +151,34 @@ int64 GetTime(); int64 GetAdjustedTime(); void AddTimeData(unsigned int ip, int64 nTime); +// Cores this process is actually allowed to run on. +// +// std::thread::hardware_concurrency() reports the MACHINE's core count, which +// is not the same number inside a container. With --cpuset-cpus 0-5 on a +// 12-core host it still answers 12, so every caller that sized a thread pool +// from it oversubscribed by 2x and then wondered why the box was thrashing: +// the pool node started 11 miner threads on 6 permitted CPUs, and a burst +// miner asked for 2 cores filled the ~2 GB RandomX dataset with 12 threads. +// +// sched_getaffinity reports what the cpuset permits, which is the number every +// one of those callers meant. Falls back to hardware_concurrency where the +// call is unavailable, and never returns 0 -- callers divide by this. +inline unsigned int AvailableCoreCount() +{ +#ifdef __linux__ + cpu_set_t cpuset; + CPU_ZERO(&cpuset); + if (sched_getaffinity(0, sizeof(cpuset), &cpuset) == 0) + { + int nAllowed = CPU_COUNT(&cpuset); + if (nAllowed > 0) + return (unsigned int)nAllowed; + } +#endif + unsigned int nCores = std::thread::hardware_concurrency(); + return nCores > 0 ? nCores : 1; +} + -- cgit v1.2.3 From a1d867ecbcfc9e218408d0a11863a450243a01f9 Mon Sep 17 00:00:00 2001 From: bitflash-maintainer Date: Wed, 26 Aug 2026 22:46:22 +0000 Subject: build: make managed Tor opt-in, and ignore upstream's committed build artifacts Upstream 1.2.19 auto-starts a bundled tor whenever one sits next to the executable: silent transport change, a spawned process, a new hidden service, and descriptors going out with meeting_node="rendezvous-pending" -- on nodes that never asked, against this fork's pinned-relay posture. The behavior moves behind the explicit /automanagedtor; /managedtor stays as before. Also ignore build-*.log, __pycache__/ and *.pyc so the five artifacts stripped from the release merge cannot come back. --- .gitignore | 5 +++++ src/main_gui.cpp | 11 ++++++++++- 2 files changed, 15 insertions(+), 1 deletion(-) (limited to 'src/main_gui.cpp') diff --git a/.gitignore b/.gitignore index a74295a..b18859f 100644 --- a/.gitignore +++ b/.gitignore @@ -20,11 +20,16 @@ SHA256SUMS.asc # Runtime data / logs debug.log db.log +build-*.log *.dat wallet.dat nostr.key btf_enc.key +# Python cache (upstream's release commit accidentally shipped one) +__pycache__/ +*.pyc + # Third-party build trees (rebuilt by the build scripts) RandomX/ deps/ diff --git a/src/main_gui.cpp b/src/main_gui.cpp index e8dacd3..211e1e2 100644 --- a/src/main_gui.cpp +++ b/src/main_gui.cpp @@ -146,6 +146,7 @@ static void PrintUsage() printf(" /socks=HOST:PORT (SOCKS5 proxy for Nostr, .btf relay, and onion dials)\n"); printf(" /tor[=HOST:PORT] (Tor mode; default SOCKS5 proxy is 127.0.0.1:9050)\n"); printf(" /managedtor[=PATH] (start Tor, create a hidden service, advertise its onion)\n"); + printf(" /automanagedtor (auto-start a bundled Tor if one sits next to the executable)\n"); printf(" /nomanagedtor (disable automatic bundled Tor startup)\n"); printf(" /oniononly (do not fall back to rendezvous when a .btf onion dial fails)\n"); printf(" /btfseed=ADDRESS:ENCHEX (extra bootstrap peer, repeatable)\n"); @@ -360,8 +361,16 @@ static void ParseStartupArguments(int argc, char* argv[]) bool fTorMode = arg(argc, argv, "/tor") || arg(argc, argv, "-tor"); string socksProxy = argval2(argc, argv, "/socks", "-socks"); string bundledTorPath; + // Managed Tor is opt-in on this fork. Upstream 1.2.19 auto-starts a + // bundled tor whenever one sits next to the executable, which silently + // moves Nostr/.btf traffic onto Tor, creates a hidden service, and starts + // publishing descriptors with meeting_node="rendezvous-pending" on nodes + // that never asked for it -- against our pinned-relay posture. The + // upstream behavior remains available under the explicit /automanagedtor. bool fAutoManagedTor = !fManagedTor && !fNoManagedTor && !fTorMode && - socksProxy.empty() && BtfBundledTorPath(bundledTorPath); + socksProxy.empty() && + (arg(argc, argv, "/automanagedtor") || arg(argc, argv, "-automanagedtor")) && + BtfBundledTorPath(bundledTorPath); if (fManagedTor || fAutoManagedTor) { if (fManagedTor && (fTorMode || !socksProxy.empty())) -- cgit v1.2.3 From 1017644bc8d4bf183086ffb816c5e3f18068568e Mon Sep 17 00:00:00 2001 From: bitflash-maintainer Date: Tue, 1 Sep 2026 17:16:04 +0000 Subject: wallet: refuse to mint a wallet a headless node never asked for v1.2.20 makes SQLite the default backend for a datadir that holds no wallet. On a desktop that is a first run. On a headless node it is usually a fault -- the datadir is not mounted, or wallet.dat was caught mid-rename by a backup -- and the new default turns that fault into a silent loss of custody rather than a loud failure. The damage is not the empty wallet, it is the marker. Creating the wallet also writes /wallet-backend = sqlite, and every later start reads that marker first: putting the real wallet.dat back no longer does anything, because the node stays on the empty SQLite wallet it made. 1.2.19 had the same fail-open on a missing wallet, but it created a wallet.dat, and restoring the backup over it recovered the funds. That runbook stops working here. So refuse when the startup is headless and no wallet of either kind is present, and say which flag makes it deliberate: /createwallet to start a new one, or /walletbackend=bdb for the Berkeley DB path. A node that meant to create a wallet says so; a node that lost its datadir gets an error instead of a fresh identity and a re-paid ledger. Upstream keeps the old behaviour; this is a fork divergence, and the three findings that would gate enabling the SQLite backend at all -- the non-atomic backend marker, WriteHDMaster's connection-wide transaction, and the encrypt-swap crash window -- remain open against upstream. --- src/main_gui.cpp | 29 +++++++++++++++++++++++++++++ src/selftest.cpp | 26 +++++++++++++++++++++----- 2 files changed, 50 insertions(+), 5 deletions(-) (limited to 'src/main_gui.cpp') diff --git a/src/main_gui.cpp b/src/main_gui.cpp index 2743429..25cde1c 100644 --- a/src/main_gui.cpp +++ b/src/main_gui.cpp @@ -196,6 +196,9 @@ static void PrintUsage() printf(" /walletsqlite=FILE (stage-load a SQLite wallet export, then exit)\n"); printf(" /walletbackend=sqlite (opt-in: run the node on /wallet.sqlite\n"); printf(" instead of wallet.dat; export one first)\n"); + printf(" /createwallet (headless only: permit creating a new wallet in a\n"); + printf(" datadir that has none -- otherwise refused, since\n"); + printf(" a missing wallet is usually a mount/backup fault)\n"); printf(" /rescan (walk the chain for coins this wallet owns\n"); printf(" but never recorded, then exit)\n"); printf("\n"); @@ -835,6 +838,32 @@ int main(int argc, char* argv[]) else { fUseSQLite = true; fCreateSQLite = true; } } + // A headless node whose datadir holds no wallet of either kind is + // far more often an operations fault -- datadir not mounted, or + // wallet.dat caught mid-rename by a backup -- than a first run. + // Creating one here is not the harmless default it looks like: + // success also writes the backend marker, and from then on + // restoring the real wallet.dat no longer takes effect, because the + // marker keeps the node on the empty wallet it just made. Under + // 1.2.19 the same fault created a wallet.dat that a restore + // overwrote, so operators could recover by putting the file back. + // Refuse instead, and name the flag that makes it deliberate. + if (fCreateSQLite && fHeadlessStartup && + !arg(argc, argv, "/createwallet") && !arg(argc, argv, "-createwallet")) + { + AttachTerminal(); + fprintf(stderr, + "No wallet found in %s\n" + "Refusing to create one. On a headless node an absent wallet is\n" + "usually a mount or backup fault, and creating one would record a\n" + "backend marker that makes restoring the real wallet a no-op.\n" + "Pass -createwallet to start a new one deliberately, or\n" + "-walletbackend=bdb to run on a Berkeley DB wallet.dat.\n", + GetAppDir().c_str()); + fflush(stderr); + return 1; + } + if (fUseSQLite) { AttachTerminal(); diff --git a/src/selftest.cpp b/src/selftest.cpp index 2ada8f3..c62d70f 100644 --- a/src/selftest.cpp +++ b/src/selftest.cpp @@ -2580,7 +2580,11 @@ static int RunWalletBackendDefaultSelfTest() try { - // 1. Fresh datadir, no flag -> new wallet.sqlite, marker "sqlite", no wallet.dat. + // 1. Fresh datadir, no flag -> this fork refuses, where upstream creates. + // An empty datadir on a headless node is far more often a mount or + // backup fault than a first run, and creating a wallet also pins the + // backend marker, after which restoring the real wallet is a no-op. + // Nothing may be left behind: no wallet of either kind, no marker. string d1 = tmp + "/fresh"; nFail += Check(MakeDirLocal(d1), "fresh scenario dir can be created") ? 0 : 1; vector a1; @@ -2588,19 +2592,31 @@ static int RunWalletBackendDefaultSelfTest() a1.push_back("-nomanagedtor"); a1.push_back("-nogui"); a1.push_back("-newaddress"); - int r1 = RunBitflashChild(strExe, a1); + int r0 = RunBitflashChild(strExe, a1); + nFail += Check(r0 != 0 && + !FileExists((d1 + "/wallet.sqlite").c_str()) && + !FileExists((d1 + "/wallet.dat").c_str()) && + !FileExists((d1 + "/wallet-backend").c_str()), + "a flagless headless start refuses to create a wallet") ? 0 : 1; + + // 1a. -createwallet says it was meant: new wallet.sqlite, marker + // "sqlite", no wallet.dat. + vector a1c = a1; + a1c.push_back("-createwallet"); + int r1 = RunBitflashChild(strExe, a1c); nFail += Check(r1 == 0 && FileExists((d1 + "/wallet.sqlite").c_str()) && !FileExists((d1 + "/wallet.dat").c_str()) && FileContainsText(d1 + "/wallet-backend", "sqlite"), - "a fresh install starts on SQLite (new default) and records the marker") ? 0 : 1; + "-createwallet starts a fresh install on SQLite and records the marker") ? 0 : 1; - // 1b. Second run reuses it, still SQLite, still no wallet.dat. + // 1b. Second run reuses it without the flag -- the refusal only guards + // creation, so an existing wallet still starts flagless. int r1b = RunBitflashChild(strExe, a1); nFail += Check(r1b == 0 && FileExists((d1 + "/wallet.sqlite").c_str()) && !FileExists((d1 + "/wallet.dat").c_str()), - "the recorded SQLite choice survives a restart") ? 0 : 1; + "the recorded SQLite choice survives a flagless restart") ? 0 : 1; // 2. Existing wallet.dat, no flag -> stays on Berkeley DB. string d2 = tmp + "/existing-bdb"; -- cgit v1.2.3 From ae1c6a7b49d9b4f8cf7218c0baec84f348eb5ef6 Mon Sep 17 00:00:00 2001 From: bitflash-maintainer Date: Wed, 2 Sep 2026 05:26:51 +0000 Subject: log: cap debug.log in-process, and ship a logrotate policy debug.log grew without bound (production: 1.95 GiB, 51-129 MB/day). Nothing shrank it once the process was up -- there is no ShrinkDebugFile in this tree, and upstream's only ran at startup. OutputDebugStringF now rolls the file to debug.log.old when it passes -maxdebugfilesize (MB, default 1024, 0 disables). The write path already opens and closes the file per line under one lock, so the rename-and-reopen is a few lines inside that lock and no fd can be stranded mid-roll. contrib/logrotate/bitflash-debug.logrotate is the longer-term policy (size 1G, rotate 4, compress, missingok, notifempty, copytruncate); copytruncate keeps the writer's append handle valid across rotation. Closes #34 --- src/main_gui.cpp | 15 +++++++++++++++ src/util.cpp | 5 +++++ src/util.h | 29 +++++++++++++++++++++++++++++ 3 files changed, 49 insertions(+) (limited to 'src/main_gui.cpp') diff --git a/src/main_gui.cpp b/src/main_gui.cpp index 211e1e2..05f5ecf 100644 --- a/src/main_gui.cpp +++ b/src/main_gui.cpp @@ -111,6 +111,7 @@ static void PrintUsage() printf(" /help, -help, --help, /?\n"); printf(" /datadir=PATH\n"); printf(" /debug\n"); + printf(" /maxdebugfilesize=N (roll debug.log at N MB, default 1024; 0 = never)\n"); printf(" /gen\n"); printf(" /nogui or /daemon\n"); printf(" /selftest=wallet-keypool, wallet-hd, wallet-format, wallet-crypto, wallet-encrypt,\n"); @@ -191,6 +192,20 @@ static void ParseStartupArguments(int argc, char* argv[]) if (arg(argc,argv,"/debug") || arg(argc,argv,"-debug")) fDebug = true; + // /maxdebugfilesize=N -- roll debug.log to debug.log.old once it passes + // N MB, checked on each write. Default is 1024 (see util.cpp); 0 turns + // the check off and leaves rotation entirely to logrotate. + string strMaxDebugSize = argval2(argc, argv, "/maxdebugfilesize", "-maxdebugfilesize"); + if (!strMaxDebugSize.empty()) + { + int64 nMB = atoi64(strMaxDebugSize.c_str()); + if (nMB < 0) + fprintf(stderr, "Ignoring /maxdebugfilesize=%s: not a size in MB\n", + strMaxDebugSize.c_str()); + else + nMaxDebugFileSize = nMB * 1024 * 1024; + } + if (arg(argc,argv,"/gen") || arg(argc,argv,"-gen")) { fGenerateBitcoins = 1; diff --git a/src/util.cpp b/src/util.cpp index 0cd0133..52cda84 100644 --- a/src/util.cpp +++ b/src/util.cpp @@ -8,6 +8,11 @@ bool fDebug = false; +// In-process cap for debug.log, in bytes. Default matches the 1G trigger of +// the shipped logrotate policy so a long-running node stays bounded even +// without it; -maxdebugfilesize=N (MB) tunes it, 0 disables. +int64 nMaxDebugFileSize = 1024 * 1024 * (int64)1024; + // --------------------------------------------------------------------------- // Categorized, burst-collapsing logging -- see the LogPrint doc comment in // util.h for the problem this solves. diff --git a/src/util.h b/src/util.h index d72518b..f2ea23f 100644 --- a/src/util.h +++ b/src/util.h @@ -87,6 +87,11 @@ inline T& REF(const T& val) extern bool fDebug; +// debug.log is rolled to debug.log.old once it grows past this many bytes +// (set from -maxdebugfilesize, in MB; 0 disables the check). See the roll +// site in OutputDebugStringF() below. +extern int64 nMaxDebugFileSize; + // --------------------------------------------------------------------------- // Categorized, burst-collapsing logging // @@ -391,6 +396,30 @@ inline int OutputDebugStringF(const char* pszFormat, ...) strDebugFile = "debug.log"; } FILE* fileout = fopen(strDebugFile.c_str(), "a"); + if (fileout && nMaxDebugFileSize > 0) + { + // Roll an overgrown debug.log before adding to it. Nothing in the + // tree shrinks this file once the process is running (there is no + // ShrinkDebugFile, and even Bitcoin's only ran at startup), so a + // node that stays up for months grows the log without bound -- + // production hit ~2 GiB. The file is opened and closed per line, + // so a rename is safe here: the next line simply reopens the + // path. One generation is kept as debug.log.old; logrotate + // (contrib/logrotate/) handles anything longer-term. + int nSize = GetFilesize(fileout); + if (nSize >= 0 && (int64)nSize > nMaxDebugFileSize) + { + fclose(fileout); + string strOldFile = strDebugFile + ".old"; + remove(strOldFile.c_str()); // rename() won't clobber on Windows + rename(strDebugFile.c_str(), strOldFile.c_str()); + fileout = fopen(strDebugFile.c_str(), "a"); + if (fileout) + fprintf(fileout, + "debug.log grew past %lld MB; previous contents moved to debug.log.old (-maxdebugfilesize)\n", + (long long)(nMaxDebugFileSize / 1024 / 1024)); + } + } if (fileout) { va_list arg_ptr; -- cgit v1.2.3 From 2661de141e46dadfb2903bb12e72dc87c5a64375 Mon Sep 17 00:00:00 2001 From: bitflash-maintainer Date: Wed, 2 Sep 2026 05:31:22 +0000 Subject: log: demote the four lines that were 90% of debug.log behind /debugrelay A 13-hour production capture was ~71% "sending: tx", ~16% "Nostr: discovered .btf peer", ~5% "Relaying wtx", and ~43% of raw lines were CBlock/CTransaction dumps -- all already under -debug, which operators run permanently, so -debug was useless as a filter. The category mechanism gains a verbose tier: "relay" and "nostrdiscovery" are accepted only with the new /debugrelay flag (which implies /debug); everything else keeps its current gate. Demoted, with message text unchanged for anyone grepping it: net.h "sending: tx (N bytes)" (BeginMessage/EndMessage/ AbortMessage all pick the category from the command, so no orphaned "(N bytes)" fragments leak into a plain -debug log) main.cpp "Relaying wtx", "received block:" + CBlock::print, and the BitcoinMiner block dump nostr.cpp "Nostr: discovered .btf peer" -> "nostrdiscovery" Pool/worker/payout, [payout], BLOCK ORPHANED/ACCEPTED and share flow lines are untouched. Because LogPrint dedup keys on category, demoted lines also keep their burst collapse under /debugrelay. Closes #35 --- src/main.cpp | 6 +++--- src/main_gui.cpp | 12 ++++++++++++ src/net.h | 22 ++++++++++++++++++---- src/nostr.cpp | 2 +- src/util.cpp | 13 +++++++++++++ src/util.h | 4 ++++ 6 files changed, 51 insertions(+), 8 deletions(-) (limited to 'src/main_gui.cpp') diff --git a/src/main.cpp b/src/main.cpp index 7b3e555..80f5f31 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -1712,7 +1712,7 @@ void CWalletTx::RelayWalletTransaction(CTxDB& txdb) uint256 hash = GetHash(); if (!txdb.ContainsTx(hash)) { - if (LogAcceptsCategory("net")) printf("Relaying wtx %s\n", hash.ToString().substr(0,6).c_str()); + if (LogAcceptsCategory("relay")) printf("Relaying wtx %s\n", hash.ToString().substr(0,6).c_str()); RelayMessage(CInv(MSG_TX, hash), (CTransaction)*this); } } @@ -3263,7 +3263,7 @@ bool ProcessMessage(CNode* pfrom, string strCommand, CDataStream& vRecv) vRecv >> *pblock; //// debug print - if (LogAcceptsCategory("net")) { printf("received block:\n"); pblock->print(); } + if (LogAcceptsCategory("relay")) { printf("received block:\n"); pblock->print(); } CInv inv(MSG_BLOCK, pblock->GetHash()); pfrom->AddInventoryKnown(inv); @@ -4555,7 +4555,7 @@ bool BitcoinMiner(int nThreadId) //// debug print if (LogAcceptsCategory("net")) printf("BitcoinMiner:\n"); if (LogAcceptsCategory("net")) printf("RandomX proof-of-work found \n powhash: %s \ntarget: %s\n", hash.GetHex().c_str(), hashTarget.GetHex().c_str()); - if (LogAcceptsCategory("net")) pblock->print(); + if (LogAcceptsCategory("relay")) pblock->print(); SetThreadPriority(GetCurrentThread(), THREAD_PRIORITY_NORMAL); CRITICAL_BLOCK(cs_main) diff --git a/src/main_gui.cpp b/src/main_gui.cpp index 05f5ecf..163d78e 100644 --- a/src/main_gui.cpp +++ b/src/main_gui.cpp @@ -111,6 +111,7 @@ static void PrintUsage() printf(" /help, -help, --help, /?\n"); printf(" /datadir=PATH\n"); printf(" /debug\n"); + printf(" /debugrelay (/debug plus per-message relay chatter and block dumps)\n"); printf(" /maxdebugfilesize=N (roll debug.log at N MB, default 1024; 0 = never)\n"); printf(" /gen\n"); printf(" /nogui or /daemon\n"); @@ -192,6 +193,17 @@ static void ParseStartupArguments(int argc, char* argv[]) if (arg(argc,argv,"/debug") || arg(argc,argv,"-debug")) fDebug = true; + // /debugrelay -- everything /debug shows, plus the per-message firehose + // ("relay" and "nostrdiscovery" categories: sending: tx, Relaying wtx, + // raw CBlock dumps, per-peer discovery lines). That traffic was ~90% of + // a 13-hour production debug.log (issue #35), so plain /debug leaves it + // off; the message text itself is unchanged for anyone grepping it. + if (arg(argc,argv,"/debugrelay") || arg(argc,argv,"-debugrelay")) + { + fDebug = true; + fDebugRelay = true; + } + // /maxdebugfilesize=N -- roll debug.log to debug.log.old once it passes // N MB, checked on each write. Default is 1024 (see util.cpp); 0 turns // the check off and leaves rotation entirely to logrotate. diff --git a/src/net.h b/src/net.h index 9f3c7e4..1aee973 100644 --- a/src/net.h +++ b/src/net.h @@ -802,6 +802,18 @@ public: + // "sending: tx (N bytes)" alone was ~71% of a 13-hour production + // debug.log (issue #35), so tx sends are demoted to the /debugrelay-gated + // "relay" category; every other command's send trace stays on "net". + // The line is emitted in pieces across BeginMessage/EndMessage/ + // AbortMessage, so all three must agree on the category or orphaned + // "(N bytes)" fragments would leak into a plain -debug log. + // Valid only mid-message (nPushPos != -1). + const char* SendLogCategory() const + { + return strcmp(GetMessageCommand(), "tx") == 0 ? "relay" : "net"; + } + void BeginMessage(const char* pszCommand) { EnterCriticalSection(&cs_vSend); @@ -809,17 +821,19 @@ public: AbortMessage(); nPushPos = vSend.size(); vSend << CMessageHeader(pszCommand, 0); - if (LogAcceptsCategory("net")) printf("sending: %-12s ", pszCommand); + if (LogAcceptsCategory(SendLogCategory())) printf("sending: %-12s ", pszCommand); } void AbortMessage() { if (nPushPos == -1) return; + // Capture before nPushPos is cleared; GetMessageCommand() needs it. + const char* pszCategory = SendLogCategory(); vSend.resize(nPushPos); nPushPos = -1; LeaveCriticalSection(&cs_vSend); - if (LogAcceptsCategory("net")) printf("(aborted)\n"); + if (LogAcceptsCategory(pszCategory)) printf("(aborted)\n"); } void EndMessage() @@ -839,10 +853,10 @@ public: unsigned int nSize = vSend.size() - nPushPos - sizeof(CMessageHeader); memcpy((char*)&vSend[nPushPos] + offsetof(CMessageHeader, nMessageSize), &nSize, sizeof(nSize)); - if (LogAcceptsCategory("net")) printf("(%d bytes) ", nSize); + if (LogAcceptsCategory(SendLogCategory())) printf("(%d bytes) ", nSize); //for (int i = nPushPos+sizeof(CMessageHeader); i < min(vSend.size(), nPushPos+sizeof(CMessageHeader)+20U); i++) // printf("%02x ", vSend[i] & 0xff); - if (LogAcceptsCategory("net")) printf("\n"); + if (LogAcceptsCategory(SendLogCategory())) printf("\n"); nPushPos = -1; LeaveCriticalSection(&cs_vSend); diff --git a/src/nostr.cpp b/src/nostr.cpp index e2d839c..9bbf74e 100644 --- a/src/nostr.cpp +++ b/src/nostr.cpp @@ -1440,7 +1440,7 @@ static bool SeedFromRelay(CNostrKey& key, const string& relay) else if (createdAt > it->second) it->second = createdAt; // refresh } if (fNew && fDebug) - LogPrint("nostr", "Nostr: discovered .btf peer %s\n", peerAddr.c_str()); + LogPrint("nostrdiscovery", "Nostr: discovered .btf peer %s\n", peerAddr.c_str()); if (++nPeers > 500) break; } else if (type == "EOSE") diff --git a/src/util.cpp b/src/util.cpp index 52cda84..a5a4e80 100644 --- a/src/util.cpp +++ b/src/util.cpp @@ -7,6 +7,7 @@ bool fDebug = false; +bool fDebugRelay = false; // In-process cap for debug.log, in bytes. Default matches the 1G trigger of // the shipped logrotate policy so a long-running node stays bounded even @@ -31,10 +32,22 @@ static bool LogCategoryDefaultOn(const char* category) strcmp(category, "payout") == 0; } +// Categories so noisy they drown out everything else even under -debug: +// per-message send traces, wallet relay rebroadcasts, raw CBlock dumps and +// per-address discovery lines made up ~90% of a 13-hour production capture +// (issue #35). They need /debugrelay, not merely /debug. +static bool LogCategoryVerboseOnly(const char* category) +{ + return strcmp(category, "relay") == 0 || + strcmp(category, "nostrdiscovery") == 0; +} + bool LogAcceptsCategory(const char* category) { if (LogCategoryDefaultOn(category)) return true; + if (LogCategoryVerboseOnly(category)) + return fDebugRelay; return fDebug; } diff --git a/src/util.h b/src/util.h index f2ea23f..c47b8a7 100644 --- a/src/util.h +++ b/src/util.h @@ -87,6 +87,10 @@ inline T& REF(const T& val) extern bool fDebug; +// /debugrelay: the per-message firehose ("relay" and "nostrdiscovery" +// categories) that even -debug keeps off. See LogAcceptsCategory(). +extern bool fDebugRelay; + // debug.log is rolled to debug.log.old once it grows past this many bytes // (set from -maxdebugfilesize, in MB; 0 disables the check). See the roll // site in OutputDebugStringF() below. -- cgit v1.2.3 From e3cc976ae3f211727e3fae702776aacca46efb0c Mon Sep 17 00:00:00 2001 From: bitflash-maintainer Date: Wed, 2 Sep 2026 16:42:39 +0000 Subject: log: reject a -maxdebugfilesize that is not a plain count of MB atoi64() is strtoll() with the endptr discarded, so it yields 0 for anything it cannot read. -maxdebugfilesize=1G and -maxdebugfilesize=1024MB both parsed as 0, which the roll reads as "never roll" -- indistinguishable from requesting that deliberately, and silent. An operator who wrote a unit suffix got no rotation and no warning. Only a negative value said anything. The multiplication was unchecked as well: a large enough N made nMB * 1024 * 1024 overflow to a negative cap, which disables the roll too. Parse with strtoll() and its endptr, and reject trailing characters, ERANGE, negatives, and any value that would overflow the byte cap -- each reported with the reason and the cap being kept. 0 still means never roll and is still accepted without comment. --- src/main_gui.cpp | 31 +++++++++++++++++++++++++++---- 1 file changed, 27 insertions(+), 4 deletions(-) (limited to 'src/main_gui.cpp') diff --git a/src/main_gui.cpp b/src/main_gui.cpp index 163d78e..0e64985 100644 --- a/src/main_gui.cpp +++ b/src/main_gui.cpp @@ -210,10 +210,33 @@ static void ParseStartupArguments(int argc, char* argv[]) string strMaxDebugSize = argval2(argc, argv, "/maxdebugfilesize", "-maxdebugfilesize"); if (!strMaxDebugSize.empty()) { - int64 nMB = atoi64(strMaxDebugSize.c_str()); - if (nMB < 0) - fprintf(stderr, "Ignoring /maxdebugfilesize=%s: not a size in MB\n", - strMaxDebugSize.c_str()); + // Parsed with atoi64() before, which is strtoll() with the endptr + // thrown away: it returns 0 for anything it cannot read, so + // /maxdebugfilesize=1G and =1024MB both came out as 0 -- "never + // roll", identical to asking for that on purpose, with nothing said + // to the operator. The old code also multiplied without a range + // check, so a big enough N wrapped to a negative cap, which the roll + // reads as "off" too. Every rejection is now reported and leaves the + // default in place; the value is a plain count of MB, no suffix. + const char* psz = strMaxDebugSize.c_str(); + char* pszEnd = NULL; + errno = 0; + int64 nMB = strtoll(psz, &pszEnd, 10); + // INT64_MAX, without depending on which limits header this tree pulls in. + const int64 nMaxMB = (int64)((~(uint64)0) >> 1) / (1024 * 1024); + const char* pszWhy = NULL; + if (pszEnd == psz) + pszWhy = "not a number"; + else if (*pszEnd != '\0') + pszWhy = "trailing characters -- the value is a count of MB, with no unit suffix"; + else if (errno == ERANGE || nMB < 0) + pszWhy = "not a size in MB"; + else if (nMB > nMaxMB) + pszWhy = "larger than a file size can be"; + if (pszWhy) + fprintf(stderr, "Ignoring /maxdebugfilesize=%s: %s (keeping %lld MB)\n", + strMaxDebugSize.c_str(), pszWhy, + (long long)(nMaxDebugFileSize / 1024 / 1024)); else nMaxDebugFileSize = nMB * 1024 * 1024; } -- cgit v1.2.3
StatusPoolFeeMinersH/sBlocksHeightAgeLink
StatusPoolFeeMinersH/sBlocksHeightAgeLinkProof