upleb.uk

Public git repos — served from a NIP-34 GRASP relay at git.upleb.uk

summaryrefslogtreecommitdiff
AgeCommit message (Collapse)Author
2026-09-06Merge branch 'chore/main-catchup-2026-08-26' into 'main'HEADmainbitflash-maintainer
Bring main onto the line this project actually ships (incl. the 1.2.23 compactblock port) Closes #6, #7, #8, #11, #35, #34, #29, #28, #27, and #16 See merge request !16
2026-09-05Bring main onto the line this project actually shipsbitflash-maintainer
main had drifted to a history with no ancestor in common with the tree we build and deploy. It sat at be63e83 (2026-08-09) with 28 commits; the shipped line, deploy/soak-2026-08-21, had 383. The two shared nothing -- git merge-base returns no result at all -- so anyone cloning this repository landed on a default branch that was neither current nor related to the running node. The 28 commits on main were audited before this merge: every one is already present in the tree by content, including the two that are hardest to see because they were rewritten afterwards -- the payout reorg-safety work (PayoutBlockStillOnChain, since evolved to re-check on every retry rather than once at maturity) and the nostr relay certificate verification (SSL_set1_host, now with a Windows root-store path). Nothing on main is lost by this merge. Recorded as a merge rather than a force-push: main only ever moves forward, its old history stays reachable as the first parent, and this is revertible. The tree is taken wholesale from the merged branch, so main's content is now exactly what was built and tested. What this brings onto main: - compactblock: BIP152-style compact block relay ported from upstream 1.2.23, primitives and unit test only, with the RAND_bytes failure path closed and the reconstruction allocation bounded. - upstream/main 17741d9: isolated testnet parameters and genesis, optional 2 MB large pages for RandomX. Mainnet genesis verified byte-identical through upstream's BuildGenesisBlock() refactor. - deploy: every image built on bookworm so binaries run on Debian 12, and the container build now honours the RandomX and libsecp256k1 pins it had been silently ignoring since before 1.2.15. Gates at this tree: 15/15 selftests, compactblock's 17 checks, consensus constants unchanged against e4f898c apart from the pre-existing MAX_BLOCK_SIZE_GEN, mainnet genesis intact, money-log check ok.
2026-09-05Merge upstream/main into the forkbitflash-maintainer
Brings the nine commits that are actually the latest on our lineage: isolated testnet parameters and genesis, optional 2 MB large pages for RandomX, and five README changes. This is what "up to date" means here -- not the 1.2.2x tags, which carry higher version numbers but were cut 2026-07-30, three weeks before the line we ship, and which upstream/main does not contain. Three conflicts, all resolved toward upstream's structure while keeping what this fork knows: main.cpp -- upstream moved genesis construction into BuildGenesisBlock() so mainnet and the new testnet can share it. Verified byte-for-byte before taking it: MAINNET_GENESIS_TIMESTAMP, _TIME (1753315200), _NONCE (3141), _HASH, _MERKLE, the scriptPubKey, nBits and nVersion all equal what this tree built inline. The mainnet genesis hash is unchanged and the asserts still guard it. Kept our warning about never editing those inputs, and kept our note that 1753315200 is 2025-07-24, not 2026 -- upstream's comment on that constant states the wrong year. net.h -- DEFAULT_PORT = htons(8433) becomes MAINNET_PORT/TESTNET_PORT in host order. Nothing is lost: net.cpp's GetDefaultPort() applies htons() itself and is what every caller uses, so the value reaching a sockaddr is unchanged and mainnet stays on 8433. selftest.cpp -- the known-tests string had diverged in both directions. Took the union: upstream's network-params alongside our payout-ledger, rather than dropping either. Gates: 15/15 selftests (our 14 plus upstream's network-params), consensus constants unchanged against e4f898c except the pre-existing MAX_BLOCK_SIZE_GEN, mainnet genesis intact, compactblock's 17 checks still pass, money-log check ok, GNU_STACK RW.
2026-09-05compactblock: fail closed on RAND_bytes error, bound reconstruction allocationbitflash-maintainer
Two fixes to the file imported in the previous commit. Kept separate so the deviation from upstream's bytes is reviewable on its own. BuildCompactBlock ignored the RAND_bytes return value. On failure nonceBytes is left uninitialised and an indeterminate shortIdNonce is memcpy'd out and sent on the wire -- and that nonce is exactly what the file relies on being unpredictable so short IDs cannot be precomputed before a block exists or ground across blocks. Every other RAND_bytes call site in this tree (bip32.cpp:203, db.cpp:2462, db.cpp:2469) checks for != 1 and fails closed; this one now does too. The caller falls back to full-block relay. TryReconstructBlock sized a vector directly from the wire. nTotal is the sum of two deserialised vector lengths, and serialize.h bounds a vector only by MAX_SIZE (0x02000000), so once the P2P handlers are wired a single small message could ask this node to default-construct ~33M CTransactions. The smallest a transaction can serialise to is 10 bytes, so no block within MAX_BLOCK_SIZE holds more than MAX_BLOCK_SIZE/10 of them; anything above that is not a block this node could accept under any circumstance. Rejecting falls back to full-block relay, which is always correct. Neither is reachable today -- nothing feeds these functions from the network yet -- but both are latent the moment the message handlers land, which is the wrong time to be finding them. test_compactblock (17 checks) and all 14 selftests pass with both in place.
2026-09-05compactblock: import BIP152-style compact block relay from upstream 1.2.23bitflash-maintainer
Brings src/compactblock.{cpp,h} and test_compactblock.cpp across verbatim from the upstream-v1.2.23 tag, and gives them a build. 1.2.23 is not a newer release of the line this fork ships. It is a lightweight tag (7ab5a8c) cut 2026-07-30, on a lineage that upstream/main (e0be70e, 2026-08-20) does not contain and no remote branch carries -- 272 commits that are not ours, against 376 of ours that are not theirs, from a merge-base at 56df983. Merging that tag would have deleted 18 files from src/, selftest.{cpp,h} and the whole 14-test suite among them, so the content is ported file by file instead. Compact block relay is the piece worth having: a peer that advertises sendcmpct gets 48-bit keyed short IDs per transaction rather than the transaction bytes, and reconstructs the block from its own mempool. It is purely additive on the wire -- a peer that never sends sendcmpct keeps receiving full block messages, so no coordinated upgrade is needed. This commit is the primitives and their unit test only. Nothing calls BuildCompactBlock or TryReconstructBlock yet: the P2P message handlers (sendcmpct/cmpctblock/getblocktxn/blocktxn) live in 1.2.23's main.cpp and are deliberately left for a follow-up, so that no attacker-controlled bytes reach this code until that wiring is reviewed on its own. The files needed no API adaptation -- mapTransactions, BEGIN/END and IMPLEMENT_SERIALIZE are unchanged across the divergence, and it compiled clean first time. Upstream never wired test_compactblock.cpp into its own Makefile, so it had never been built there; it links standalone the way test_bip32 does, supplying its own mapTransactions and GetAppDir to keep main.cpp out of the link. TEST_LIBS is overridable for the same reason NODE_LIBS is: the pinned dep tree ships libsodium as a static archive only. All 17 checks in test_compactblock pass, all 14 selftests still pass, and the consensus constants are unchanged against e4f898c.
2026-09-04Merge branch 'deploy/all-2026-08-24' into 'deploy/soak-2026-08-21'bitflash-maintainer
deploy: land MRs 1-12 and upstream 1.2.20, as shipped in relay component See merge request !13
2026-09-03merge: upstream 1.2.20 into deploy/all-2026-08-24bitflash-maintainer
# Conflicts: # Makefile
2026-09-03merge: !11 fix/block-assembly-size into deploy/all-2026-08-24bitflash-maintainer
2026-09-03merge: !9 fix/payout-reconcile into deploy/all-2026-08-24bitflash-maintainer
2026-09-03merge: !6 fix/wallet-locked-payouts into deploy/all-2026-08-24bitflash-maintainer
2026-09-03merge: !3 fix/pool-accounting into deploy/all-2026-08-24bitflash-maintainer
2026-09-03merge: !8 fix/tunnel-retry into deploy/all-2026-08-24bitflash-maintainer
2026-09-03merge: !7 fix/block-fetch-failover into deploy/all-2026-08-24bitflash-maintainer
2026-09-03merge: !10 fix/small-fixes into deploy/all-2026-08-24bitflash-maintainer
2026-09-03merge: !2 fix/log-rotation into deploy/all-2026-08-24bitflash-maintainer
2026-09-03merge: !12 fix/rvrelay-pin into deploy/all-2026-08-24bitflash-maintainer
2026-09-03merge: !5 fix/participant-prevhash into deploy/all-2026-08-24bitflash-maintainer
2026-09-03merge: !4 fix/robustness into deploy/all-2026-08-24bitflash-maintainer
2026-09-03merge: !1 fix/build-system into deploy/all-2026-08-24bitflash-maintainer
2026-09-03payout: fail review when a money line lands on collapsing LogPrintbitflash-maintainer
scripts/check-money-log-lines.sh flattens each LogPrint call in src/rpc.cpp and fails if a money-moving phrase appears in a plain LogPrint call. A new line that reports money moving needs LogPrintAlways and one more phrase in the list.
2026-09-03payout: move the last two burst-capable money lines to LogPrintAlwaysbitflash-maintainer
Integration review found two more payout lines still on plain LogPrint, both fired per record inside the batch loop in PayoutThreadFn -- the same one-window burst shape as the four already converted. The "writing off" line in ForfeitPayoutRecipient and the "SKIPPED bad address" line at the write-off in PayoutThreadFn both report obligations being destroyed, which is the class of line one summary must never stand in for (issue #3). Message text is unchanged.
2026-09-02worker: only look up a job's chain position when it changesbitflash-maintainer
2a804c2 resolves a job's prevhash against mapBlockIndex under cs_main so the worker log names the chain the pool is building on (#15). It did that on every mining.notify, unconditionally, before the LogPrint that consumes the answer -- and LogPrint's category gate and its 3-second dedup both run after the lookup has already happened. The dedup bounds the log line. It never bounded the work. That left an unbounded cs_main acquisition rate on the participant's socket-reading thread, driven by the remote pool. Block validation can hold cs_main for a long time, and this thread is the one that handles mining.set_difficulty and share-submission replies, so a pool sending rapid notifies could stall the responses a miner is paid for. Hashing was never at risk -- jobShared is published before the lookup -- but the reader is not free to block. Most notifies in a round are a new job on the same tip, so the answer is now cached and re-resolved only when it could come out differently: a prevhash we have not resolved, or one we could not resolve last time while the chain has since moved. A block's height is fixed once mapBlockIndex knows it, so a cached "known" cannot go stale; an unknown is re-checked whenever nBestHeight changes, which is what catches a pool we were behind once we have caught up. The worst case -- a different prevhash every notify -- degrades to exactly the old cost, never worse. Measured over a 10-tip round of 30 jobs each, 300 acquisitions become 10; over 1000 notifies on a single fork we do not have, 1000 become 1. The log line is unchanged and still prints in full for every job, cached or not: prevhash plus height-or-"unknown to our chain". Nothing #15 asked for is lost, and a fork we cannot resolve keeps saying so on every job. The lookup is also wrapped in LogAcceptsCategory("worker") so the work follows the gate deciding whether anyone reads it. That is a no-op today -- "worker" is default-on in LogCategoryDefaultOn() -- and is there so the coupling survives the category ever moving behind -debug. The caching, not the gate, is what removes the acquisitions. The cache is four locals in the session scope, not statics: participant mode runs exactly one such thread (StartMinerThreads forces nThreads to 1 for MINE_PARTICIPANT), and session scope additionally means a reconnect re-resolves rather than trusting an answer from before a possible change of pool.
2026-09-02gui: check the two thread spawns the GUI target still leaves unguardedbitflash-maintainer
gui.cpp is not in NODE_OBJS, so bitflash-node never sees it -- but it is in GUI_OBJS and `all: bitflash` is the default target, so this is what an operator who runs the windowed build is running. Two spawns there were still unchecked. Enabling pool mode from the options dialog set gPoolServerRunning = true and then called _beginthread without looking at the result, so a refused spawn left the operator with a pool that reports itself running, no RPC server for miners to reach, and nothing in the log. The flag latches, so the branch is never retried either. The restore dialog called std::thread(RestoreThread).detach() bare, which on a refused spawn is an uncaught std::system_error and takes the window down -- the same failure issue #18 was about, in the same tree, one target over. The busy flag stays where it was, set before the spawn and rolled back in the catch. It cannot move after the spawn: RestoreThread clears it itself on the way out, and a restore that finishes quickly would have its `false` overwritten by ours, leaving the dialog greyed out over a search that is not running. Verified by type-checking gui.cpp under the Makefile's exact flags against a stub GLFW/GL header, since this host has neither. That is a syntax and type check, not a link: gui.cpp reaches GLFW through 13 functions and three GL calls, all declared with their real signatures, but the GUI target has not been built here.
2026-09-02tunnel: give both pumps shared ownership of the sockets they closebitflash-maintainer
PumpIn's wrapper closed pumpEnd and the rendezvous socket the moment it exited, without waiting for PumpOut, which then called shutdown() on both at its own exit. Which of the two got there first decided whether that shutdown addressed a live descriptor or a number the kernel had already handed back out -- in which case it tears down an unrelated connection. The inbound pump wins that race whenever the rendezvous socket dies before the app socket, which is the ordinary shape of a relay restart or a far side hanging up. The failure path added in 0b566b4 has the same hole. Its catch shuts down rv to wake an inbound pump it assumes is parked in RvReadN. PumpIn does not park when the rendezvous connection is already dead: it returns on its first length read and its wrapper closes both descriptors. A refused pthread_create and a dead rv are not independent -- both are symptoms of a loaded process -- so that is the likely case, not the exotic one. Both descriptors now belong to a shared_ptr the two pumps hold for the whole of their bodies, so the close happens after the last pump has stopped touching them and no shutdown can precede it. appEnd is unchanged: the caller gets it on success, StartTunnel closes it on every failure path, no pump ever touches it. 0b566b4's message says of that catch that "closing those descriptors from the failure path instead would race it". True as far as it goes, but it reads as though what the code did instead was safe, and it was not -- the shutdown raced the same close. There was no ordering there to prefer, only a missing owner. Measured against the real code with the relay in a separate process, every close() and shutdown() traced and pthread_create refused on demand: normal teardown, 25 tunnels 29 shutdowns on a closed fd -> 0 refused outbound spawn, 25 25 shutdowns on a closed fd -> 0 Close counts are identical before and after (8 and 5 per tunnel, every one accounted for) and live descriptors return to their starting count, so the defect was only ever the ordering, never a leak or a double close.
2026-09-02worker: count a session as productive only if the pool sent a jobbitflash-maintainer
PoolParticipantMiner returned true from its normal cleanup path whether or not the pool had ever sent work, so a pool that completed the tunnel and then dropped was indistinguishable from a healthy one. The caller reset nRetrySecs to 10 on every such "session", pinning the reconnect at a flat 10s and defeating the backoff in precisely the case it was added for. It cleared nFailingSince as well, so the "failing for N minutes" line never printed either -- the worker went back to idling silently. Return true only once at least one well-formed mining.notify has arrived. The existing early returns are unaffected: they already mean "no session", which stays the right answer for any path that leaves the worker without work, a failed hash-thread spawn included.
2026-09-02worker: let a stop request interrupt the reconnect backoffbitflash-maintainer
The retry sleep polled only fShutdown, while the loop around it polls fShutdown, fGenerateBitcoins and nMineMode. That was survivable at a flat 10s. Raising the ceiling to 120s made it expensive: after an operator cleared Generate or switched to Relay mode, the participant session -- and the vfThreadRunning[3] slot that gates restarting it -- stayed held for up to two minutes. Measured with the loop shape replicated and the stop condition flipped 1s into a 120s sleep: 119.01s to notice before, 1.00s after, via either fGenerateBitcoins or nMineMode. The fShutdown path is unchanged at 1.00s. It also narrows the window on a pre-existing unsynchronised read of vfThreadRunning[3] at gui.cpp:901, where a stop-then-start inside it leaves mining silently not restarted.
2026-09-02build: say so when apt-get update fails instead of passing over itbitflash-maintainer
34cb6e6 added `apt-get update;` before the install. The semicolon means a failed update does not stop the install, which is the behaviour we want -- one unreachable repository should not block an install the lists already on disk can satisfy -- but it was punctuation rather than a decision, and it was silent. The install then failed several seconds later with "unable to locate package" and nothing tied the two together. Kept it non-fatal, made it explicit with `|| echo`, and wrote the reasoning down next to it. The install itself is still the last command in the branch, so a failed install is still the target's status. Measured all four combinations with apt-get shimmed: a failed update now warns and carries on, a failed install still aborts.
2026-09-02build: let a failed pin check decide the exit status everywherebitflash-maintainer
fd627b1's message said the rewrite hardened a pin check that used `;` where it needed `&&`. It hardened one of four. Three recipes still ended their chain with `echo "==> ... done"; ` after a semicolon, so a mismatched pin failed the `test`, skipped every build step, and then ran the echo anyway -- exit 0, and make announced a pinned dependency that nothing had verified. deps-windows also had the same break after mkdir and after `make randomx`. The one the earlier message did not mention is deps-secp256k1, on the Linux path: libsecp256k1 is signature verification, so that was the worst of the three to leave reporting success on an unverified checkout. Joined each chain with && the whole way to the closing echo, in deps-secp256k1, and in both halves of deps-windows. Verified by extracting each recipe as make expands it, splitting it into logical recipe lines and running each in its own shell the way make does, with git reporting a sha that does not match the pin: all three previously exited 0 and now exit 1, and each deps-windows half fails independently rather than relying on the other. With a matching sha the recipes still exit 0, still print "done", and still write the marker. The Windows path was not executed -- there is no MSYS2 here -- so pacman was shimmed and only the shell logic below it was measured.
2026-09-02build: quote the RandomX path so the guard cannot be stepped aroundbitflash-maintainer
The marker guard added in fd627b1 used $(RANDOMX_DIR) unquoted, so an operator-supplied value that expands to anything other than one word walked straight past it. With a glob matching two directories, `[ -d $(RANDOMX_DIR) ]` became a two-argument test, died with "unexpected operator" rather than answering, and short-circuited the && before the marker was ever looked for -- leaving `rm -rf` to run against every match. A value holding a space or a `&&` broke the outer test the same way and reported "already built", and an apostrophe made the recipe unparseable. So the commit's claim to refuse any directory lacking the marker was falsifiable. Quote every use, and reject up front the values that cannot name one directory: empty and / now refuse deliberately rather than by way of the one-argument `[ -d ]` quirk that happened to catch them, and whitespace and glob characters are refused outright because src/Makefile interpolates the same value into -I and -L unquoted, where neither survives. A relative value is refused too: it named a different directory here than in src/, and cd'ing into it made the later mkdir -p resolve against the new cwd, so the build landed in randomx-build/randomx-build/build and cmake found nothing. Verified against the recipe text as make expands it, with rm replaced by an echo, over empty, /, globs matching one and two directories, spaces, tabs, relative paths, symlinks, apostrophes, semicolons, backticks and newlines, under both dash and bash: every run either refuses with a message or passes rm exactly one argument, identical to the path the guard tested.
2026-09-02log: do not latch the roll off when logrotate won the racebitflash-maintainer
The latch added in 456b3b4 treats every rename() failure as permanent. One is not: if an external rotator moves debug.log between the size check and the rename, the rename fails ENOENT. Nothing is wrong -- the file has just been rotated by someone else, and the reopen above has already recreated it -- but latching there would switch the cap off for the rest of the run on a race the operator cannot see or fix. Treat ENOENT as "already rotated" and carry on. It cannot loop: the reopen leaves an empty file, so the next line's size check does not fire.
2026-09-02net: gate the dropped-send line on the category of the send it dropsbitflash-maintainer
2661de1 said all the "sending: " emission sites agree on the category. There is a fourth, and it did not: EndMessage()'s -dropmessagestest path hardcoded "net", so under plain -debug a dropped tx announced its own drop while the "sending: tx" it interrupts was gated off under "relay". The knob is test-only, so nothing operational rode on it, but the claim was false as written. Derive it from SendLogCategory() like the other three. nPushPos may be -1 at this point, and GetMessageCommand() returns "" there, so the category is "net" exactly as before when there is no message in progress.
2026-09-02log: reject a -maxdebugfilesize that is not a plain count of MBbitflash-maintainer
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.
2026-09-02log: stop a failed debug.log roll from repeating on every linebitflash-maintainer
Neither remove() nor rename() was checked. When the rename fails -- a read-only mount, permissions, EXDEV across a bind mount -- fopen() reopens the same oversized file and writes the "grew past N MB" marker anyway. The size never drops, so the next line takes the same branch, and every line after that: the roll's own failure notice becomes unbounded growth, added to the growth of issue #34 rather than replacing it. Check rename(), and on failure latch the roll off for the rest of the run after reporting it once -- on stderr as well as in the log, since whatever blocked the rename may equally block the log write. An operator is told the cap has stopped being enforced instead of learning it from the disk.
2026-09-02log: measure the debug log with a size type that can hold itbitflash-maintainer
GetFilesize() returned int while ftell() returns long, so a debug.log of 2 GiB or more came back negative. Its only caller is the -maxdebugfilesize roll added in ae1c6a7, which skips the roll on a negative size: the rotation written for issue #34 would have switched itself off permanently at exactly the size it exists to prevent. Production was at 1.95 GiB growing 51-129 MB/day, about a day short of crossing 2 GiB. The same truncation capped the comparison at INT_MAX (2047.99 MB), so -maxdebugfilesize=2048 or higher could never be exceeded and disabled rotation outright. Both failures were silent. Widen the return to int64 and seek with ftello/fseeko (_ftelli64 and _fseeki64 on Windows). The roll in util.h is the function's only caller in the tree, so no other behaviour changes. A 32-bit off_t would reintroduce the same truncation, so its width is now a compile-time assertion rather than a silent runtime surprise.
2026-09-02net: stop the failover log naming a peer on every re-requestbitflash-maintainer
The line was unconditional on the argument that failover firing is the pathology of #33 and nobody could otherwise see it. That much is right. Printing a peer address every time it fires is not: a peer decides how often the line is written, by announcing hashes it will not serve, and an address is the one thing the .btf transport otherwise takes trouble not to spread around. Sustained, that was tens of lines a minute, each naming a peer, driven entirely from outside. Split it. The per-peer detail -- which block, how long, which peer is being tried next -- moves behind the net category, where the rest of the getdata logging already is. What stays unconditional is a count with no addresses in it, over a window no peer can shorten: the first failover after a quiet spell still prints immediately, because the change from "blocks arrive" to "blocks are announced and not served" is the event worth seeing, and everything after it is summarised once a minute. Worst case is now one line per minute regardless of what a peer does, instead of one per re-request.
2026-09-02net: only use the 15s block retry spacing at the tipbitflash-maintainer
nRequestTime lives in mapAlreadyAskedFor, which is global to the inv, so the spacing AskFor applies is not per-peer pacing: it is what strings the getdatas for one block across its announcers. Shortening it to 15s was right at the tip and wrong everywhere else, and the eight-entry cap that made the failover table safe for catch-up does not cover this at all -- it is a different mechanism. At two minutes a second announcer's queued request almost always fired after the block had arrived, so AlreadyHave dropped it. At fifteen seconds, with a peer working through a 500-deep queue and block N legitimately minutes away, the second announcer is asked at +15s, the third at +30s, and the bulk download arrives once per announcer -- up to eight times over, with nothing bounding it. So the fast spacing is now conditional on tracking the tip, measured off the age of our own best block. Not off a peer-reported height: nStartingHeight is optional in the version message and every node released so far sends the short form, so it is -1 for essentially every peer, and where it does get filled in later it comes from the height of a block that peer served us, which during catch-up tracks our own height and would answer "at the tip" for the entire download. Block timestamps are the one clock a peer cannot move, being bounded above by CheckBlock and below by AcceptBlock's median-time rule. Thirty block intervals is the threshold: far longer than any stall worth failing over from, far shorter than catching up. Failover at the tip does not rest on this in any case -- mapBlockFetch re-issues a stalled getdata to the next peer on its own 15s timer, and that is what actually answers #33.
2026-09-02net: make room in the block fetch table instead of refusing new blocksbitflash-maintainer
Nothing on the path from inv to insertion can tell a real block hash from thirty-two random bytes. AlreadyHave only asks whether we already hold the block, and with no header there is no work to check. So one inv carrying eight hashes that never existed filled all eight slots, and because a full table refused new entries outright, no genuinely new block was tracked again until the give-up timer expired ten minutes later. One message per ten minutes from a single connection switched failover off for the whole node -- not eight wasted slots, an off-switch. A full table now evicts. Eviction on its own would just be the same attack in a new shape, so the victim is chosen by occupancy: a peer may only take a slot from a peer holding strictly more slots than itself, and otherwise recycles one of its own oldest. A peer that has filled the table is the heaviest holder by definition, so its own entries are the only ones it can displace; a peer announcing its first block holds nothing, so it always evicts from the heaviest. A genuine announcement is therefore never refused a slot, whatever an attacker holds or across however many connections it holds it. Entries carry the announcing peer for this, set once on creation -- the failover sweep asks other peers later and none of them chose to announce the hash, so none of them should be charged for it. The two timestamps also get member initialisers. The insertion path uses operator[] and tests nTimeFirstAsked == 0 to spot a fresh entry, which worked on value-initialisation alone, but only until somebody gave the struct a constructor -- and this commit gives it a member that wants one.
2026-09-02net: keep a block tracked until its body actually validatesbitflash-maintainer
The mapBlockFetch entry was erased before ProcessBlock ran, on the reasoning that the body had arrived so the fetch was over either way. That reasoning does not survive the fact that GetHash() covers the 80-byte header and nothing else. Any peer that saw X announced holds X's header. It can answer our getdata with that header and a transaction list of its own invention; the reply still hashes to X, so the entry was erased, and CheckBlock then rejected the body on hashMerkleRoot and stored nothing. AlreadyHave stayed false, the mapAskFor entry had already been consumed when the getdata went out, and nothing recreated the tracking -- so X was never requested from anybody again unless some peer happened to re-announce it. That is the #33 stall, reachable on demand for one message. The erase now sits inside the success branch, beside the mapAlreadyAskedFor erase it belongs with. ProcessBlock returns true in exactly two cases, block accepted and block parked as an orphan, and both store it where AlreadyHave will find it, so one erase covers both and neither needs different treatment. Of the false cases, the two already-have ones are cleared by the sweep's own AlreadyHave check on its next pass, and the rest are rejections that must keep the entry. A mismatched merkle root is also now grounds to drop the peer. Nothing honest produces it: peer traffic rides the .btf channel, which is XChaCha20-Poly1305, so a body that arrives at all arrived intact, and the mismatch is the peer's own construction. Checking it in the handler also rejects a forged body before CheckBlock spends a RandomX hash on it. The unconditional log line fires once per connection, because ProcessMessages keeps draining the receive buffer after fDisconnect is set and a peer that packs many forged bodies into one buffer must not get a line for each.
2026-09-02pool: compute a miner's faded hashrate instead of decaying it in placebitflash-maintainer
2bca62a decayed m->hashRate from the event loop's miner sweep, which turned a figure that is only ever displayed into an accumulator, and got the curve wrong at both ends. Each pass recomputed `expected` -- and so `grace` -- from the estimate it had just decayed. Every decay raised `expected`, which raised the grace that gates the next decay, so `grace` chased `silent` upward and the reported rate settled into a 1/t crawl instead of the half-life the commit's own comment documents: 291x too high at ten half-lives past the grace, and still announcing 0.4% of baseline four hours into a silence. #7 is about the pool over-reporting its hashrate, and this under-corrected it for hours. The far end was worse. Once `expected` reached HASHRATE_MAX_INTERVAL it could not rise any further, the feedback that had been gating each pass was gone, and the decay did begin to compound -- once a second, against the total elapsed silence, because lastShareTime does not advance while a miner is quiet and nothing recorded when the decay had last run. A miner four hours silent fell from 0.4% of its baseline to 1e-11 of it over the next hundred seconds, with nothing in the code or the announced number to explain the cliff. Neither behaviour is worth tuning, so drop the sweep. hashRate is read in exactly one place -- RefreshStats, building the per-worker rows and the total that the pool status and the nostr announcement publish -- and vardiff retargets from share timing alone and never reads it. So leave the stored value as the sample it always was and have RefreshStats ask MinerReportedHashRate() what to publish: a pure function of that sample and the absolute silence since the last share. With no stored decay state there is nothing for a tick rate, a missed pass or a repeated pass to perturb, and the answer is bit-identical from 1/60 Hz to 1000 Hz because nothing accumulates, not because the arithmetic was written carefully. It also keeps the fade out of the estimator: the EWMA on the share path still blends against the value it last computed rather than a faded copy of it, so a miner that resumes recovers exactly as it did before any of this, and there is no decay timestamp for a newly accepted or newly authorised connection to be caught holding unset. RefreshStats takes one GetTime() for the whole sweep, so the rows and the total they are summed into describe a single instant rather than a smear across it. The clamp is written !(expected > MIN) so a NaN lands on the floor instead of sailing through; a negative `silent` from a clock stepping backwards reports the sample unchanged rather than scaling it up; and past HASHRATE_DECAY_CUTOFF half-lives the answer is a flat zero rather than pow() walking down into the denormals. This also drops the std::max(m->hashRate, 1e-9) the old pass carried, which the `hashRate <= 0.0` guard immediately above it had already made dead. pool-stratum gains ten checks over the policy: the full estimate inside the grace, a thousand samples at one instant returning one value and leaving the estimate untouched, a few percent lost one second past the grace rather than the whole estimate, exactly half at one expected interval past it, zero after an hour of silence, no inflation from a backwards clock, zero for a miner that has no rate sample yet, and nothing non-finite anywhere across a long silence.
2026-09-02pool: advertise a rendezvous only once registered, and log the pinbitflash-maintainer
Issue #2 reported that /rvrelay does not govern registration. It does: BtfAllRelays() returns vBtfMeetingRelays alone when fBtfRelayPinned, and every relay-choosing site -- the pool's AcceptLoopFn, the node's ThreadBtfAccept, and the dial path -- draws from it. vBtfMeetingRelays has exactly one writer, the pin itself. A pinned pool therefore spawns one accept thread, at the pin, and no discovered relay is registered at. The registration the incident actually caught was not the pool's. The "registered at" line it quotes exists only in ThreadBtfAccept, and 90.156.222.107:8434 is not a stranger's relay -- it is the third hardcoded seed, one of the three ThreadBtfAccept picks between at random when no pin is in force. That relay is only reachable with the pin absent, so the binary that logged it did not have one; both the pin and the operator-mode skip that keeps a pool out of that thread landed two days before the observation. What was genuinely wrong is that none of this could be read off a log. The pool announced no successful registration at all, and retried failures at one flat line, so "is this pool registered where its descriptor says" had no answer. So: - Advertise the relay after registering at it, not before. The optimistic order published a meeting node we were not yet listed at, and a miner resolving the descriptor inside that window spent its dial on a relay holding no registration for us. ThreadBtfAccept has always ordered it this way; the pool had not. - Log the relay registration succeeds at, name the pin when one is in force, and name each discovered relay the pin excludes. - Escalate when a pinned relay keeps refusing: a pin has no fallback, so an unregistered pin means the pool is unreachable, and that now says so instead of scrolling past. - Refuse to advertise a non-pinned relay in BtfSetActiveRelay. No caller can reach it today, but the descriptor is the one thing a miner obeys, so the invariant is enforced at the sink rather than retrusted to every future caller. Does not touch the registration-consumption race: the pool still gives up its registration on each pairing and re-registers after wrap-and-push. Moving the advertise call inside that loop does not widen the window, it only stops the descriptor naming a relay we never reached.
2026-09-02docs: third-party RandomX miners cannot mine Bitflash poolsbitflash-maintainer
The README offered XMRig and SRBMiner command lines pointed at the local Stratum bridge. Neither can work. In RandomX mode both target the Monero-style JSON-RPC protocol (login/job/submit) while the pool speaks Bitcoin-style Stratum (mining.subscribe/notify/submit); both place the nonce at byte 39 of the job blob, which in an 80-byte Bitflash header is inside the merkle root, not the nonce at byte 76; and both require a 32-byte seed hash as 64 hex characters, where randomx_pow.cpp keys RandomX from a fixed 36-byte string that is not a hash at all. None of it is configurable. -stratumbridge is a byte relay between a local socket and the .btf tunnel (BridgePump, main.cpp) -- it fixes reachability, the one blocker an operator could otherwise do something about, and translates nothing. Say that where the commands used to be, so the question gets answered instead of asked again. Also correct the magic bytes in src/readme.txt, which documented bf 1a 5c fd; net.h has bf 20 5c fd.
2026-09-02genesis: say that the 2025 nTime is known and must not be "fixed"bitflash-maintainer
nTime is 1753315200, which is 2025-07-24T00:00:00Z. The comment beside it claimed 2026-07-24, and the headline in pszTimestamp reads 24/Jul/2026, so the constant looked like a typo somebody ought to correct. Nothing is wrong with the data. nTime matters only as an input to the genesis hash, the block hashes to hashGenesisBlock with it, the asserts pass, and the whole network agrees. The hazard is entirely in acting on the mismatch. Editing nTime alone trips the assert -- loud, and it reverts. Editing it and regenerating GENESIS_NONCE and hashGenesisBlock to match is the quiet one: that node starts without complaint, builds its own chain from height 0, and rejects every real peer, and the re-mining branch a few lines below prints exactly the constants needed to do it. So: correct the comment rather than the constant, state the discrepancy as known and uncorrectable, and have the re-mining printout say what those constants are and are not for. No value changes -- pszTimestamp, the scriptPubKey, nTime, nBits, nNonce, GENESIS_NONCE, hashGenesisBlock and hashGenesisMerkleRoot are all byte-identical.
2026-09-02selftest: cover the assembly half of the block limitsbitflash-maintainer
consensus-limits only ever asked what CheckBlock() refuses. Nothing asked what the assemblers produce, which is why a miner able to overshoot the cap by an entire mempool sat here unnoticed through both the deployed and candidate builds. Adds the other half: a template built from a 3 MB mempool stays inside the budget and passes CheckSizeLimits(); a transaction too big for the budget is skipped while the ones that fit are still taken; sigop-heavy transactions stop selection at MAX_BLOCK_SIGOPS while the block is still small. Plus the rule's edges, including that sigops exactly on the limit fit -- CheckBlock() fails on `>`, so assembly must accept what validation accepts. The test drives BlockTemplateFits the way the assemblers drive it rather than calling them: BitcoinMiner needs a CTxDB, a wallet key and cs_main, and RebuildJob is static in rpc.cpp. What that proves is the shared rule and the property it exists to give, which is where the defect was -- the budget was right and the enforcement was not. Checked against the bug: with the fit test neutered, eight of these fail, including both templates that CheckBlock() would have rejected. With the sigop comparison stricter than CheckBlock's by one, the boundary case fails.
2026-09-02mine: count sigops while selecting, not only when validatingbitflash-maintainer
CheckBlock() caps a block at MAX_BLOCK_SIGOPS and neither assembler counted against it. Bytes do not bound what a block costs to verify: every OP_CHECKSIG is an elliptic-curve verification, so a template comfortably inside the size budget can still be one the network refuses for demanding more work than a block is allowed to. Counted with CTransaction::GetSigOpCount -- the same function CheckBlock() sums, not a second implementation of it -- and seeded from the coinbase, because CheckBlock() sums over all of vtx. The comparison matches too: CheckBlock() fails on `> MAX_BLOCK_SIGOPS`, so a block exactly on the limit is valid and selection accepts it. Assembly and validation differing by one is the shape of the bug this series started with. Unlike the size budget this takes no margin. There is no use for headroom in a sigop count, so assembly holds itself to the consensus number exactly. Assembly only; MAX_BLOCK_SIGOPS and the CheckBlock() test are unchanged.
2026-09-02payout: never collapse a log line that reports money movingbitflash-maintainer
LogPrint()'s burst collapse is right for share and peer chatter and wrong for anything that reports an irreversible change to money. A reorg voided 100 ledger entries totalling 4,950 BTF and the log carried one line, naming one block and 49.49 BTF. The other 99 were counted and never printed, and no "(N similar)" summary appeared either -- a summary was only ever flushed when that same call site fired again, and after the reorg it never did. The missing 4,950 BTF was found by counting entries in pending_payouts.json. Add LogPrintAlways(), which skips the collapse entirely, and move the four payout lines that report money to it: an obligation voided, an obligation dropped, a payment sent, a payment that failed. All four are bursty by nature -- one reorg voids every entry funded by the orphaned block, from a single source line -- which is precisely why one line must not stand in for all of them. Their volume is bounded by how much money moved, which is a number worth the lines. Also sweep expired windows for every call site rather than only the one currently logging, so a burst that stops is still summarised, and report the window's real elapsed time instead of a hardcoded 3s.
2026-09-02mine: ask whether a transaction fits before appending itbitflash-maintainer
The node miner tested its size budget only on the outer while loop. The inner for walked all of mapTransactions with no test, so a single pass could append the entire mempool -- the overshoot was bounded by mempool size, not by one transaction as the comment claimed. The pool builder did test, but only after the push, which bounds the overshoot at one transaction. Nothing caps how big one transaction may be: neither CheckTransaction nor AcceptTransaction limits it, so the mempool will hold one of any size the wire allows and "one transaction" is worth up to MAX_SIZE. A lone 1.5 MB transaction offering a large fee is therefore enough to make every template unmineable, and the fee is never paid because the block never confirms. Both now ask before appending and skip what does not fit, so the overshoot is zero and an oversized transaction costs the template nothing rather than invalidating it. The budget moves to MAX_BLOCK_SIZE_GEN so the two assemblers cannot drift on it again -- drift is what produced this. The test is ordered before ConnectInputs on purpose: ConnectInputs credits nFees by reference on success, so declining a transaction after calling it would leave the coinbase claiming a fee the block does not collect. Assembly only. CheckBlock, CheckTransaction and AcceptTransaction are untouched: this changes what we produce, not what the network accepts.
2026-09-02worker: back off failed reconnects and say aloud how long the pool has been ↵bitflash-maintainer
failing
2026-09-02worker: fail over across seed relays when the advertised meeting node keeps ↵bitflash-maintainer
failing
2026-09-02tunnel: report which stage failed instead of guessing the causebitflash-maintainer
2026-09-02payout: treat a malformed ledger block hash as corruption, not as a hashbitflash-maintainer
SetHex() parses whatever hex prefix it finds and stops, so a corrupt blockHash loaded as a DIFFERENT hash: garbage with a hex-looking prefix became a hash no block ever had, which the orphan check reads as an orphaned block -- and the next flush voided the debt on the strength of file corruption. Garbage with no hex prefix became 0 and skipped the orphan check instead. A field that is not the 64-digit hex string this tree writes now fails the load, and the file is quarantined like any other parse error.
2026-09-02payout: refuse comment-only evidence for a round that has a proofbitflash-maintainer
A pre-upgrade payout comment names only the height, so when a height is won, orphaned, and won again, the old transaction matches the new round's (height, address, amount) exactly and the miner is never paid. Round proofs decide the ambiguity where it is decidable: a proof is written when a round is created, and structured payout metadata (upstream 0b01d6c) predates round proofs (upstream b1eb8b6), so a round with a proof was created by a build whose every payment names its round -- a comment-only match can only be another round's money. Skipping the fallback cannot double-pay: any real payment for such a round is already found by the structured probe. Proofless rounds keep the documented bias.
2026-09-02payout: record the full funding block hash and require it to agreebitflash-maintainer
pool_payout_id keeps only the first sixteen hex digits of the block hash, which is the end proof-of-work fills with zeros -- so twin rounds at one height can carry the SAME id, and one twin's payment then settles the other's debt through the structured probe: the same-height collision the comment match had, one layer down. New sends record the full hash as pool_payout_block and a structured match must agree with it. A transaction without the key predates it and is matched by id as before; anything stricter would make every payment already sent look unpaid and pay it again.
2026-09-02payout: name the funding block in new payout commentsbitflash-maintainer
A height the pool wins, loses to a reorg, and wins again yields two rounds whose height-only comments are identical -- and identical amounts too when the same miner held both rounds. New sends now write the funding block hash into the comment. The legacy fallback's needle stays frozen at the pre-upgrade format: it exists precisely for transactions that carry nothing else, and a needle in any other format would make every one of those payments look unpaid and pay it again.
2026-09-02net: fail over a stalled block fetch to another peerbitflash-maintainer
The 15s retry spacing only helps when several peers announce the block: the queued retries are created by inv handling, so a block announced by a single peer that then goes quiet was re-requested from nobody, and the tip stayed frozen until somebody happened to re-announce (#33). Track every block getdata in mapBlockFetch, erase the entry when the body arrives, and let SendMessages re-issue a request that has gone unanswered for BLOCK_FETCH_RETRY_SECS to the next peer that has not had a turn this round, restarting the round once every connected peer has. Client peers are skipped -- they declared they cannot serve bodies. Tracking is capped at 8 in-flight blocks so initial download, where hundreds are legitimately outstanding to one peer, is not duplicated to everybody else, and an entry is dropped after 10 minutes so a hash that was announced and never existed cannot be chased forever. The failover log line is deliberately not behind a debug category: it firing means a peer announces blocks it does not serve, which is the pathology being diagnosed.
2026-09-02net: retry block fetches every 15s, not every two minutesbitflash-maintainer
AskFor keys its retry spacing off mapAlreadyAskedFor, which is global to the inv, so the two-minute gap does more than pace re-requests to one peer: it strings the getdatas for one block across its announcers two minutes apart. When the first peer asked does not serve the body, the second candidate is not asked for 120s and the ninth for 960s -- the observed 16-minute tip stall of #33, during which the pool kept broadcasting jobs against the stale tip and 5 of 12 found blocks orphaned. A block body is at most 1 MB and somebody demonstrably has it -- they announced it -- so on this timescale "not here yet" means "not coming from that peer". Blocks now retry on BLOCK_FETCH_RETRY_SECS (15s); transactions keep the two-minute pacing, which for them is the point.
2026-09-02pool: refuse to build a job whose coinbase would burn the subsidybitflash-maintainer
GetKeyFromPool() returns an empty vector when the wallet is encrypted and locked, and when key generation itself fails. RebuildJob() fed whatever came back straight into the coinbase, and CScript::operator<< serialises an empty vector as a zero-length push, so the template's coinbase script became `<empty> OP_CHECKSIG` -- satisfiable by nobody. Every block the pool's miners found against such a job burned its whole subsidy: the coins exist and can never move, while QueuePayouts records the full obligation and the miners are then paid out of coin that block never contributed. An operator who locks the wallet overnight while the miners keep finding blocks pays twice for every one of them. BitcoinMiner has exactly this guard ("no wallet key available for coinbase; mining stopped"); this builder was missed. Mirror it: log the reason and return false before any job state is touched. Every caller already treats a false return as "no job this round" -- the submit handler and the new-block path log a warning and leave gHaveJob false, the no-job watchdog goes on retrying every few seconds, and the periodic nTime refresh keeps broadcasting the old job, whose key was drawn while the wallet could still produce one. Once the wallet can hand out a key again the next retry issues a spendable job unprompted.
2026-09-02net: a refused thread spawn drops one peer, not the nodebitflash-maintainer
std::thread throws std::system_error when pthread_create is refused (RLIMIT_NPROC, a container's pids cgroup, memory pressure), and nothing caught it, so the node died by std::terminate. Threads are spawned per peer connection, so the pressure that triggers this scales with peer count, which is attacker-influenced. Observed killing a healthy participant worker mid-mining. Every spawn site in the node build, and what a failure now does: - btftunnel.cpp: both tunnel pumps are guarded. The inbound pump starts first because it owns cleanup of the pump end and rendezvous socket; if the second spawn is refused, shutdown() wakes the first out of its read and it cleans up itself -- closing those descriptors from the failure path instead would race it onto reused descriptors. Both paths return INVALID_SOCKET, which every caller already treats as "this connection failed". - btfrv.cpp: the relay's accept loop drops the connection it cannot serve and stops accepting for a moment -- longer after consecutive failures, capped at 5s -- instead of dying at an arbitrary one. The per-pair second pump now runs on the handling thread itself, leaving exactly one spawn to guard and nothing running yet to race. - main.cpp: a participant that cannot spawn its hash workers stops the ones that did start, drops the pool connection, and returns to the reconnect loop, the same way an ordinary disconnect does. - randomx_pow.cpp: dataset init joins the workers it got, releases the dataset, and stays in light mode -- the same trade the allocation failure path above it already makes. - rpc.cpp, net.cpp: the remaining unchecked _beginthread calls (which report failure by value, they cannot throw) now say precisely what did not start. The pool's accept and payout threads failing silently left the pool running deaf -- nobody accepts, nobody gets paid, and nothing in the log says why. Left alone deliberately: the per-relay accept spawns (rpc.cpp) already check, log and retry on the next poll; startup-critical spawns in net.cpp keep failing hard; the selftest-only std::thread uses may abort a selftest run, which is acceptable where dying over one peer is not. Verified: full node rebuilds clean and links; pool-stratum selftest passes; a scratch harness under RLIMIT_NPROC=1 confirms _beginthread reports -1 and std::thread throws system_error, the premises of every path above.
2026-09-02util: initialise CCriticalSection's diagnostic fields at constructionbitflash-maintainer
pszFile/nLine were first written by CRITICAL_BLOCK on entry, so an instance embedded in a dynamically allocated object (CNode::cs_vSend, cs_vRecv, cs_inventory) held indeterminate values until its first lock -- anything reading them for diagnostics dereferenced a wild pointer.
2026-09-02worker: log the chain a pool job builds on, not just its idbitflash-maintainer
2026-09-02build: keep the stack non-executable, whoever links thisbitflash-maintainer
RandomX links jit_compiler_x86_static.S, a hand-written assembly stub with no .note.GNU-stack marker. binutils reads a missing marker as a request for an executable stack, and older linkers grant it for the whole binary: NX off, process-wide, for a node that parses attacker-supplied blocks and messages. The same source therefore produced different security properties depending on where it was built. Ubuntu 26.04 gave GNU_STACK RW; Debian 12 -- the build target for the deploy image, because it is the oldest glibc we ship to and the last Debian that still packages the Berkeley DB C++ binding -- gave RWE. The only announcement was a linker warning in the middle of an otherwise clean build. -z noexecstack settles it in the tree instead of in the toolchain. RandomX's JIT maps its own W^X pages and never executes from the stack, so nothing that works today stops working. Both link lines take it, GUI and headless. Verify: readelf -lW bitflash-node | grep -A1 GNU_STACK # expect RW
2026-09-02pool: reap miners that have said nothing for ten minutesbitflash-maintainer
A Miner left gMiners in exactly one place: RecvLine reporting a clean disconnect. Half-open TCP sessions send no FIN and raise no error, so they lived forever -- on the live pool authorize outpaced disconnect 2:1, over half the table never submitted a share, and the leak grew toward MAX_POOL_MINERS, past which the accept path refuses real miners. Two changes. First, an idle reap in the event loop's miner sweep: any miner whose last activity is older than POOL_IDLE_TIMEOUT_SECS (600s) is closed and erased through exactly the bookkeeping the clean-disconnect path uses -- log, sock_close, erase via std::find, delete -- collected into a side vector first so the sweep's iteration stays valid. Shares an idle miner already earned stay in roundShareCount, just as they do when a miner cleanly disconnects mid-round. To give the reap something honest to measure, lastSeen is now stamped on every complete line received (authorize and valid shares already stamped it; a miner that only subscribes, or sends unrecognised methods, previously looked dead from birth), with connectTime standing in for a connection that never completed a line. Ten minutes is far beyond the 15s vardiff share target and the 45s stall watchdog, so no miner that is actually hashing comes close. Second, SO_KEEPALIVE on sockets accepted by the loopback stratum listener, so a half-open session eventually errors out of recv and lands in the reap path on its own. This is a backstop only: the default cadence before the first probe is hours on most stacks, so the idle timeout fires long before keepalive does. Miners arriving via a relay ride a loopback socket pair whose real transport is the rendezvous socket owned by the tunnel pump threads, which keepalive cannot reach across -- for those the idle reap is also what finally tears the tunnel down, since closing the app end unblocks the pumps. Closes #6
2026-09-02pool: decay a miner's hashrate estimate when its shares stop arrivingbitflash-maintainer
m->hashRate is an EWMA re-sampled only on the valid-share path, with no time-based term: a miner that stops hashing keeps its last estimate for as long as the connection stays open, and RefreshStats sums every such stale estimate into gStats.totalHashRate, which is what the pool status and the nostr announcement publish. The announced pool hashrate ran several times over ground truth because miners that had gone quiet were still contributing their last sample. Decay on time in the event loop's miner sweep. Per miner, compute the expected share interval -- ExpectedHashesForDifficulty(m->difficulty) divided by its current rate estimate -- and give it a grace of four such intervals with no share (at the vardiff target of one share per 15s that is about a minute of silence). Past the grace, decay the estimate with a half-life of one more expected interval, computed from absolute time so the curve does not depend on the loop's tick rate. The interval is clamped to [1s, 1h] so a fast miner at the vardiff floor, whose expected interval is milliseconds, is not decayed for ordinary share-delivery jitter, and a very slow miner's grace cannot stretch past the point where its estimate means anything. Per-share crediting and ExpectedHashesForDifficulty are untouched: this changes only what is reported about a miner that has stopped reporting itself. Closes #7
2026-09-02pool: refuse a (job, nonce) pair this connection already submittedbitflash-maintainer
mining.submit validated the nonce, rebuilt the block, re-hashed it with RandomX -- and never asked whether it had seen this exact (job_id, nonce) pair before. Nothing in that pipeline distinguishes a replay from a first submission: a duplicated valid share passes every check and lands in roundShareCount[address]++ a second time, and roundShareCount is the divisor the payout split uses, so replaying one valid share N times multiplies that address's cut of the round by N. Keep a per-connection set of credited nonces on the Miner, reject a repeat with a "Duplicate share" stratum error before the rebuild and hash it would waste, and record the nonce at the moment the share is credited. The set is keyed to the job id the miner is currently working and cleared inside SendJob -- the single funnel every new-job path (initial authorize, block found, new block, 60s refresh, no-job watchdog, vardiff retargets) goes through -- so legitimate resubmissions across jobs are never refused. The clear is keyed on the id rather than unconditional because the vardiff retarget and stall watchdog re-send the CURRENT job, and an unconditional clear there would reopen the very replay window this closes. Job ids come from a monotonic atomic counter in RebuildJob, so an id is never recycled within a process. Closes #8
2026-09-02pool: guard the share-target floor where the old cap branch was deadbitflash-maintainer
ShareTargetFromDifficulty ended with if (t < diff1 && difficulty < 1.0) t = ~uint256(0); which can never fire: t = diff1/difficulty, so difficulty < 1.0 implies t > diff1 and the two conditions exclude each other. The branch was reaching for a real hazard, though, so say the hazard directly instead of deleting the guard: diff1 is ~uint256(0) >> 12, a hair under 2^244, so the true share target stops fitting in a uint256 once difficulty reaches 2^-12 (1/4096), and the fixed-point divisor (uint64)(difficulty * 65536.0) truncates to zero -- a division by zero -- for any difficulty under 2^-16. Past the first threshold getuint256() would return the low 256 bits of an oversized quotient, a wrapped target that is arbitrarily HARDER than difficulty 1. Both thresholds sit far below VARDIFF_MIN (0.001), so nothing live comes near this; the guard exists for any caller that bypasses vardiff. At those difficulties every hash is meant to be a share, so return all-ones, the easiest representable target. Boundary verified numerically against the exact fixed-point algorithm: at difficulty exactly 1/4096 the quotient is 2^256 - 4096 (fits), one ulp below it overflows, and below 2^-16 the divisor is zero. Closes #11
2026-09-02log: demote the four lines that were 90% of debug.log behind /debugrelaybitflash-maintainer
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
2026-09-02log: cap debug.log in-process, and ship a logrotate policybitflash-maintainer
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
2026-09-02build: let appimagetool's exit status decide, and check the artefactbitflash-maintainer
The appimage recipe piped the tool through grep ... || true, so a failed run still ended with an unconditional "Built:" echo and make reported success with no AppImage on disk. Run appimagetool bare so its status propagates, then require the artefact to exist, be non-empty, and be newer than src/bitflash before announcing it. Closes #29
2026-09-02build: ask apt for libboost-dev, and update the package lists firstbitflash-maintainer
libboost-system-dev is gone from Ubuntu 26.04 -- Boost.System has been header-only since 1.69, and this tree only includes two boost headers and links no boost library. The recipe also installed without an apt-get update, so a fresh image failed on stale or empty lists. Closes #28
2026-09-02build: stop rm -rf'ing a RandomX tree the build did not createbitflash-maintainer
deps-randomx built into $(HOME)/RandomX and deleted that path whenever the library was missing, taking out hand-managed checkouts the build never owned. Default the build to the in-tree $(ROOT)/randomx-build, like secp256k1-build, overridable via RANDOMX_DIR and exported so src/Makefile's own fallback resolves to the same tree. As a backstop, the recipe writes a .bitflash-built marker after a successful build and refuses to delete any directory that lacks it, so a user-supplied RANDOMX_DIR pointing at a pre-existing tree is never removed. Closes #27
2026-09-02build: keep the stack non-executable, whoever links thisbitflash-maintainer
RandomX links jit_compiler_x86_static.S, a hand-written assembly stub with no .note.GNU-stack marker. binutils reads a missing marker as a request for an executable stack, and older linkers grant it for the whole binary: NX off, process-wide, for a node that parses attacker-supplied blocks and messages. The same source therefore produced different security properties depending on where it was built. Ubuntu 26.04 gave GNU_STACK RW; Debian 12 -- the build target for the deploy image, because it is the oldest glibc we ship to and the last Debian that still packages the Berkeley DB C++ binding -- gave RWE. The only announcement was a linker warning in the middle of an otherwise clean build. -z noexecstack settles it in the tree instead of in the toolchain. RandomX's JIT maps its own W^X pages and never executes from the stack, so nothing that works today stops working. Both link lines take it, GUI and headless. Verify: readelf -lW bitflash-node | grep -A1 GNU_STACK # expect RW
2026-09-01docs: this tree has no marketplace stores to documentbitflash-maintainer
2026-09-01wallet: refuse to mint a wallet a headless node never asked forbitflash-maintainer
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 <datadir>/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.
2026-09-01Merge upstream v1.2.20 into the forkbitflash-maintainer
Merges upstream tag v1.2.20 (6aa3dade) into our 1.2.19+sec tree. The merge base is bc3de30's descendant 9e39a50 -- upstream's own v1.2.19 -- so this is an ordinary three-way merge, not a replay. Five conflict blocks in four files, all resolved below. No consensus change. CheckBlock, ConnectBlock, DisconnectBlock, ConnectInputs, AcceptBlock, AcceptTransaction, GetNextWorkRequired and GetBlockValue hash identical to v1.2.19; script.cpp and script.h are byte-identical on both sides. main.cpp is +3 lines and main.h +2, all wallet-backend plumbing. rpc.cpp is untouched, so the payout ledger arrives unchanged. Makefile, VERSION. OURS, renumbered. 1.2.20+sec, for the same reason 1.2.19 carried +sec: this tree is not the upstream release of that number and the string names every release artifact. Makefile, AppImage recipe. OURS for the AppDir path, THEIRS for the bundled library. Upstream's hunk reverts the icon paths to a hardcoded /tmp/bitflash.AppDir, which is the local-user pre-placement hazard the fork moved out of /tmp in the first place; taking their side wholesale would have reintroduced it silently, inside a conflict about something else. The AppDir stays $(APPDIR), and libsqlite3.so.0 joins the bundled list, which the new backend genuinely needs. src/Makefile, src/makefile.mingw, HEADERS. BOTH. Ours drops market.h, theirs adds wallet_sqlite.h; the resolution does both. NODE_OBJS auto-merged correctly in both files -- obj/wallet_sqlite.o present, obj/market.o still gone -- and was verified rather than assumed, because that line has trapped this fork before. src/selftest.cpp, known-tests list. BOTH, union. Upstream adds eight wallet-storage tests, we keep payout-ledger. The dispatch chain itself auto-merged and still routes payout-ledger to RunPayoutLedgerSelfTest. Hardening verified intact after merge: the TLS 1.2 floor in nostr.cpp, the /rvrelay pin, opt-in managed Tor, strict sig3-on-onion descriptor verification, and the marketplace removal. Upstream shipped no build logs or __pycache__ this time; the three added files are docs/storage.md and src/wallet_sqlite.{cpp,h}. The SQLite backend this release introduces is NOT enabled here. An existing wallet.dat resolves to Berkeley DB on every branch of the new selection logic, and the conversion wizard is GUI-only and not compiled into the headless node. The review findings that gate ever turning it on -- the non-atomic backend marker, the connection-wide WriteHDMaster transaction, and the encrypt-swap crash window -- are recorded on the tracker, not fixed here.
2026-09-01pool: refuse a miner the select() loop cannot representbitflash-maintainer
The operator event loop still builds an fd_set by hand (#26), and FD_SET on a descriptor the set cannot represent is undefined -- it writes past the bitmap. MAX_POOL_MINERS caps the miner list at 1000, but that is a count, and a count does not bound descriptor values: a Miner is removed only on clean disconnect, so half-open sessions hold their fds while the OS keeps issuing higher ones. Refuse the connection instead, and refuse on the limit that actually applies. On POSIX that is the descriptor value against FD_SETSIZE. On Windows an fd_set is a counted array of SOCKET handles whose values are arbitrary -- often large from the first accept -- so comparing a handle against FD_SETSIZE there would reject nearly every miner; the bound is how many sockets are held. This is a behaviour change: a pool at the limit now turns miners away with a log line rather than corrupting memory quietly. The real fix is poll/epoll, which #26 tracks.
2026-09-01pool: log a connection census, not just the broadcast countbitflash-maintainer
"broadcast to N miner(s)" counts the authorized sockets that were sent a job. It says nothing about the rest of the connection table, so a pool holding a pile of half-open sessions and one real miner reads the same as a pool with N miners. #19 was opened on exactly that confusion: ~55 connections served per job while ~2 outside miners actually submitted shares. Log the breakdown every 60s instead: connected, subscribed (completed mining.subscribe), authorized, active (authorized and seen within one payout-rotate cycle), and never-auth-idle (past the grace period without authorizing). The pool category is on by default, so this reaches a production log without /debug, and the burst-collapse window is 3s, so a 60s cadence is never the duplicate this tree has swallowed before. The census also reports how close the single select() loop is to running out of room, which is not the same question on both platforms. On POSIX FD_SETSIZE bounds the value of any descriptor handed to FD_SET, so the highest fd we hold is what matters; on Windows an fd_set is a counted array of SOCKET handles whose values are arbitrary, so the count is what matters. FD_SETSIZE comes from the platform headers either way -- this file must not define it, because redefining it after <sys/select.h> or winsock2.h changes nothing at all.
2026-08-26build: make managed Tor opt-in, and ignore upstream's committed build artifactsbitflash-maintainer
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.
2026-08-26tor: stop racing the watchdog for the managed-Tor pidbitflash-maintainer
BtfStopManagedTor read, waited on and signalled g_managedTorPid without holding cs_managedTor, while the watchdog concurrently reaps the same child and resets it. If the watchdog won, the stop path spun ten seconds on ECHILD and then sent SIGKILL to a stale, potentially recycled pid. Snapshot the pid under the lock, treat ECHILD as already-exited, and only clear the global if it still holds our pid.
2026-08-26net: bind INADDR_ANY only when this node advertises an onion endpointbitflash-maintainer
Upstream 1.2.19 made the P2P listener wildcard-bind unconditionally because managed Tor forwards hidden-service traffic to 127.0.0.1, which a LAN-only bind cannot receive. That also widened every non-Tor node to listen on VPN tunnels, docker bridges and secondary NICs. The onion endpoint is always configured (or the managed-Tor hidden service established) before StartNode, so the predicate is ready when the bind happens.
2026-08-26btfaddr: reject descriptors whose onion endpoint is not signedbitflash-maintainer
Upstream 1.2.19 signs the onion endpoint only under the optional sig3, and VerifyDescriptor treated sig3 failure as 'drop the onion, accept the rest'. Any relay could therefore strip an onion endpoint down to a rendezvous-only descriptor (or, under /oniononly, cut the peer off) by deleting one field. Now an onion field must arrive with a valid sig3 over the V3 digest or the whole descriptor is rejected. Honest 1.2.19 writers always emit both fields, so interop is preserved. The full two-field strip still verifies -- it is indistinguishable from a peer that never had an onion -- and needs the onion bound into the primary sig, which only upstream can do without breaking every 1.2.18/1.2.19 verifier; reported as an upstream protocol defect. test_btfaddr flips the two upstream checks that enshrined the soft drop and adds unsigned-onion and full-strip cases.
2026-08-26Merge upstream v1.2.19 into the forkbitflash-maintainer
Merges upstream tag v1.2.19 (9e39a50) into merge/upstream-1.2.18 (991853f). Unlike the 1.2.15->1.2.18 replay, bc3de30 is a true ancestor of our branch via a9b4f89, so this is an ordinary three-way merge with merge-base bc3de30. Delta: 36 files, +3265/-163, the Tor onion transport (managed Tor, signed direct onion endpoints, onion-only dial mode) and the nHDNext restore fix. src/main.cpp, main.h, script.cpp/h and db.cpp/h are byte-identical between the two tags, and the poll() socket loop carries no diff hunks, so the merge is consensus-neutral and the soak-tested socket paths are unchanged. relay/ is untouched; the payout ledger (rpc.cpp) is untouched. 7 conflict blocks in 6 files, resolved as follows. Makefile -- VERSION. Ours: stays 1.2.19+sec with the naming comment updated, so artifacts from this tree cannot be mistaken for the upstream release. src/Makefile, src/makefile.mingw -- HEADERS list. Ours (no market.h) plus their tor.h. NODE_OBJS auto-merged correctly: no market.o, tor.o present. src/selftest.cpp -- known-tests list. Union: payout-ledger (ours) and managed-tor (theirs). src/main_gui.cpp -- one block. Our /dumpaddresses kept; their BtfStopManagedTor() on the StartNode failure path taken. The /rvrelay pin, /noannounce and the marketplace removal auto-merged and were verified intact. src/nostr.cpp -- two blocks in ResolveDescriptor. Signature takes both out-params (our fExchangeOk, their rawOut). The body keeps our drain-to-EOSE read -- returning early poisons the pooled socket for the next caller -- and adds their rawOut capture. Our TLS 1.2 floor (SSL_CTX_set_min_proto_version) is untouched by this merge; upstream 1.2.19 still has no floor of its own. Upstream's release commit also added four build logs and a compiled Python cache (build-1219.log, build-17.log, build-appimage.log, build-node.log, scripts/__pycache__/bitflash_chain.cpython-313.pyc). Removed here; they are build output, not source.
2026-08-23payout: qualify the single-writer claim for pool_status.jsonbitflash-maintainer
The comment justifying the absence of a version guard said RefreshStats "runs only on the event loop". That was true when it was written and is no longer: the payout-ledger self-test drives RefreshStats directly, to check that no payout mutex is held across the write. The conclusion still holds -- the test calls it from one thread at a time with the pool not running, so pool_status.json still has a single writer -- but the reason a reader would check is now stated, along with what would invalidate it. A claim in a comment that has quietly stopped being true is how 33f2e8a came to assert that no thread waits on I/O holding gPayoutLedgerMutex on the commit that made it do so. Property: comment only.
2026-08-23selftest: make a torn publish structurally invalid, not merely unluckybitflash-maintainer
Mutation-tested, the concurrency test did not test concurrency. With the per-file write mutex removed from PublishVersioned, `make tests` still went green 2 runs out of 5, and the assertion named "four threads publishing the same file never leave it unparseable" fired 0 times out of 5. It was asserting something true for reasons unrelated to the code it was pointed at. Two causes. The images were 64 arrays of 200 entries, identical in structure and within bytes of each other in length, so an interleaving of any two of them was still a valid JSON array. There was nothing structural for a torn write to break. They now alternate between one entry and five thousand: a write landing on top of another leaves either a truncated array or a small image followed by the tail of a large one, and neither parses. Worse, almost nothing was being written. Each thread walked a permutation of the images and PublishVersioned drops any blob not newer than what is on disk, so only the record highs in each permutation ever reached the disk -- about ten writes in total across four threads, which is not enough contention to produce a race however the images are shaped. Every publish now stamps its sequence number from a shared counter at the moment it is made, so every publish is newer than the last one issued and every publish writes: 400 of them, half of them 600 KB. And the file is now watched while the writers run rather than inspected once after they stop. "The published file is never observed unparseable" is the real invariant -- rename(2) is atomic, so a reader can only ever see a completely written image -- and sampling it only at the end looks at the one moment a torn file is least likely to have survived. Measured on the build host, five runs with the file mutex removed: 5 detections out of 5, every one of them on the unparseable-file assertion, which is the assertion that used to fire never. Five runs with the mutex in place: 5 passes. The whole payout-ledger self-test takes 1.4 s. Property: removing the serialisation this test exists to check fails the test, on every run.
2026-08-23payout: say why a grandfathered pair is safe, since it is not the orphan checkbitflash-maintainer
The comment on FindRecordedPoolPayout justified the legacy fallback by saying that where two pre-upgrade rounds share a height, the losing one's ledger entry is voided by the orphan check before it can be paid. That check is PayoutBlockStillOnChain(), which returns true for a zero hash -- and every pre-upgrade entry has a zero hash, because they were written before payouts recorded one. So it is a no-op for exactly the population the fallback exists to serve. The stated reason for a safety property was not the reason. The property does hold, by a different mechanism. SamePayoutRound() treats two zero-hash entries at the same height as the same round, so the pair is indistinguishable to every consumer, not just to this one: FlushMaturePayouts explodes one and skips the other as already queued, PaymentInFlightLocked refuses a second record for that (round, address), and RecordPayoutTxid settles against the first match. The pair presents as one debt and can be paid at most once. The cost of that -- only one of a genuine same-height pair would ever be paid -- is now written down too, rather than left as an implication. Both halves are asserted rather than reasoned about: the self-test checks that the orphan check passes a zero hash, and that two identical zero-hash entries at one height produce exactly one in-flight record. Property: the comment describes the mechanism that is actually load-bearing, and a test fails if either half of it stops being true.
2026-08-23payout: drop a disjunct in the forfeit path that decides nothingbitflash-maintainer
ForfeitPayoutRecipient retired an entry on `it->amounts.empty() || PayoutFullyPaid(*it)`. PayoutFullyPaid iterates amounts looking for a recipient with no txid, so it returns true for an empty map: the first test can only be true where the second already is. Left in, it reads as though the two cases differ, which invites someone to make them differ. The condition is unchanged; only the claim about it is. Property: unchanged behaviour, and the code now says only what it does.
2026-08-23payout: take the mutex that guards the in-flight list while loading itbitflash-maintainer
LoadPaying appends to gPaying and increments gPayingNextId, and both are annotated as guarded by gPayoutExecMutex. It took neither. Nothing is racing it today: it is called from the pool startup path before the payout thread is spawned, and the self-test calls it on one thread. But that is a fact about its callers, not about the function, and the annotation is what the next person to add a caller will read. The lock is uncontended here by construction, so there is nothing to weigh against taking it. Property: every mutation of gPaying and gPayingNextId in this file happens under the mutex they are documented to be guarded by.
2026-08-23payout: refuse to publish a snapshot that was never stampedbitflash-maintainer
PublishVersioned drops a default-constructed VersionedBlob only by accident. Every serialiser stamps `++gSeq`, so it never produces 0, and 0 then loses the `seq <= publishedSeq` comparison because publishedSeq starts at 0. Both halves of that are incidental. A serialiser written as `seq = gSeq++` -- one character different, and the natural way to write it -- publishes seq 0 for its first snapshot, which would sail past the comparison on a fresh process and write an empty ledger over a real one. There is also a live path that produces an unstamped blob today: FlushMaturePayouts declares `VersionedBlob ledgerSnap` and only fills it if something was voided, then publishes unconditionally. It is harmless for exactly the reasons above, which is to say for no reason that is written down anywhere. One line, checked explicitly, so the guarantee does not depend on how a future serialiser spells its increment. Property: a blob that was never stamped with a version is never written to disk.
2026-08-23payout: quarantine a ledger that failed to load instead of overwriting itbitflash-maintainer
All three loaders push entries into the live structure inside the try block, so an exception part way down a file leaves a PARTIAL picture in memory: the entries before the bad one, and nothing after it. Both loaders then return normally, having logged one line under a debug category, and the node carries on as though that were the whole ledger. The first publish afterwards writes the partial state over the file. ReconcilePayouts publishes unconditionally during startup, so that is milliseconds later -- before an operator could see the warning, let alone act on it. The only durable record of what the pool owed is then gone, and it is gone in the direction that costs miners money: the entries that failed to parse are the ones that vanish. 86b39b9's own commit message names this failure mode and then fixes only the write side. On any parse exception the file is now renamed to <path>.bad.<timestamp>, the path is added to a refusal set, and PublishVersioned declines to write it for the rest of the process. A node that has lost its ledger now comes up visibly missing it rather than quietly appearing to owe nobody anything, and the bytes are still there to reconcile against. Restarting into the same fault does not consume the evidence, because the quarantine name carries the time. Logged with printf rather than LogPrint: an operator who has just lost a payout ledger has to be told at whatever settings they were already running, and the name the file was moved to is the only route back to the data. Property: a payout file that did not parse completely is never overwritten by what did parse.
2026-08-23payout: require a confirmation before reading a pre-upgrade comment as paymentbitflash-maintainer
FindRecordedPoolPayout's legacy branch matched any wallet transaction carrying this height's payout comment and an output of exactly this amount to this address, and never asked whether that transaction was in the chain. A pre-upgrade payout that never confirmed is evidence of NON-payment, and it was being read as payment: the recipient is dropped from the in-flight list, a txid is recorded against the ledger entry, and the debt retires having never been settled. The miner is not paid and nothing on disk says they were owed. That is not hypothetical here. This tree has had a 280-block reorg (see the note in FlushMaturePayouts). A payout invalidated by one is still sitting in mapWallet with its comment intact, and the first boot after this upgrade -- the boot where the binary is swapped after an unclean shutdown with payments in flight -- is precisely when the legacy probe runs against it. The structured branch is deliberately left alone. This process sent those transactions moments earlier, and a send that failed leaves nothing behind: SendMoney calls RollbackTransactionSpent, which calls EraseFromWallet. Depth there would only reject payments that really were made. The check cannot be done inside the wallet scan. GetDepthInMainChain() reads mapBlockIndex and pindexBest, which belong to cs_main, and cs_main is taken before cs_mapWallet everywhere else in this tree -- CreateTransaction, ConnectBlock -- so testing it under cs_mapWallet would invert the lock order in the payout thread. Comment matches are collected in the scan and adjudicated afterwards under cs_main then cs_mapWallet, in that order. That also fixes a second thing: the old code stopped at the first comment match, so one unconfirmed transaction could hide a confirmed one behind it. A rejection logs the txid, height, amount and depth, and says the recipient will be paid, because this is the one decision where being wrong is unrecoverable in the direction that costs a miner money. Property: a payment is only ever inferred from a pre-upgrade comment when the transaction carrying it is in the main chain.
2026-08-23payout: ask the wallet why a send failed instead of guessing from the balancebitflash-maintainer
A payment that the pool could not fund was being recorded as a fault against the payment, and 1440 faults erased the debt with the coin still in the operator's wallet. The classifier read GetBalance() after the failure and asked whether it covered `amount + nTransactionFee + CENT`. Every term of that was wrong. nTransactionFee is 0 on this pool -- it is only ever assigned from the GUI preferences dialog, which a headless node does not have -- so the entire reserve was one CENT. The fee it is meant to reserve for is GetMinFee(fDiscount=true), which is a step function of the serialised size: 0 below 10,000 bytes and (1 + bytes/1000) * CENT at or above it. There is no required-fee value between 1 and 11 CENT, so a one-CENT reserve is never the right number -- below the step nothing needs reserving, at the step it is short by a factor of eleven. Worse, the trigger correlates with the failure. A pool wallet close to broke selects many small inputs BECAUSE it is close to broke, and about 56 signed P2PKH inputs is 10 KB, which a pool that has been paying out accumulates as change. So: balance of amount + 0.05, the pre-send gate passes, CreateTransaction builds a transaction over 10 KB, the fee becomes 0.11, SelectCoins(amount + 0.11) fails, SendMoney returns false -- and (amount + 0.05) < (amount + 0 + 0.01) is false, so "funded", so a fault. The condition is stable, so it recurred every 60 s until the budget ran out and ForfeitPayoutRecipient erased the miner's money. Three changes. The wallet now says what it did. CreateTransaction takes an optional WalletSendResult out-parameter and sets WALLET_SEND_INSUFFICIENT_FUNDS on the one return that means a funding shortfall -- SelectCoins failing to cover nValue plus whatever fee that pass is working with, which on the second and later passes is GetMinFee() of the transaction the first pass actually built. SendMoney gains a four-argument overload returning that plus the fee that was required. The three-argument SendMoney stays, as a separate declaration rather than a default argument, because gui.cpp declares it itself instead of including main.h; walletcmd.cpp and the GUI are unchanged. These are wallet functions, not consensus. ClassifyPayoutSendFailure() then classifies the answer rather than guessing: locked or short of coin defers, anything else is a fault. There is no reserve left to get wrong, and the size-dependent fee is accounted for exactly, because the wallet computed it. And the give-up branch is gone. 1440 failures is not a decidable state, it is a count of how long we have been asking, and this pool reached it by misclassifying a permanent funding shortfall. A payment that keeps failing now stays in flight, keeps its ledger entry, keeps being reported as owed in pool_status.json, and keeps retrying once a minute. A debt that is never written off cannot be written off wrongly. The remaining call to ForfeitPayoutRecipient is the one decidable case -- the ledger names a string that is not a valid address, so no transaction can pay it -- which previously only dropped the in-flight record and left the ledger and the round proof claiming the debt forever. Tested against a real wallet and the real CreateTransaction, not against injected values, because the honest question is whether a wallet holding many small coins reports the state this code assumes. 60 coins of 1 BTF, balance 60.00, amount 59.95: CreateTransaction returns false with WALLET_SEND_INSUFFICIENT_FUNDS and a required fee of 0.11 -- and since GetMinFee returns 0 below 10,000 bytes, a required fee of 11 CENT is itself the measurement that the transaction crossed the step. The same test asserts that the old reserve judged that state "funded", and that the same wallet pays a 3-input transaction with a required fee of 0.00, which is why no fixed reserve is right for both. Property: a send that failed because the wallet could not fund it is deferred, whatever the transaction's size, and no number of failures erases a debt.
2026-08-23payout: build pool_status.json under the locks, write it after releasing thembitflash-maintainer
RefreshStats runs on the event loop -- the one thread that reads and writes every miner's socket -- and took gStatsMutex and then gPayoutLedgerMutex at function scope, then called WritePoolStatusJsonLocked() with both still held. Since 86b39b9 that write ends in fsync() on the file and fsync() on its directory. So every ten seconds the event loop performed two synchronous disk flushes holding the mutex the payout thread must take to record that a miner has been paid, and the payout thread waited behind them. Measured here on ext4/LVM/SSD: 3.78 ms mean per publish. On the pool's shared virtual disk it is higher and it has no upper bound under load. That is worse than the code the merge replaced, which wrote the same file under the same locks but buffered and without fsync. It also made 33f2e8a's headline claim -- "no thread waits on I/O holding gPayoutLedgerMutex, gPayoutExecMutex or gPoolRoundsMutex" -- false on the commit that asserted it. Split the same way as the three ledgers: SerializePoolStatusLocked() returns the image, PublishPoolStatus() writes it, and RefreshStats calls the second one after leaving the scope that holds the first. The ledger lock is also scoped to the loop that actually needs it, which is the gPayoutViews rebuild; the serialiser never needed it, since those rows were already copied. No version guard is added and none is needed: RefreshStats is still the only caller of either half, it still runs only on the event loop, and it now calls them in sequence on that one thread, so pool_status.json still has a single writer by construction. The comment saying so has been rechecked against the new shape rather than carried over. The claim is now tested rather than asserted. The self-test points strPoolStatusFile at a path whose ".tmp" is a FIFO, so the publish parks in open(2) until the test chooses to read, and every mutex the write must not be holding is required to be acquirable while it is parked. Against the previous code that test cannot pass: the writer sits inside fopen() holding gStatsMutex and gPayoutLedgerMutex, and nothing releases them until the write is allowed to finish. Verified by reintroducing the defect -- two assertions fail, deterministically. Property: pool_status.json is serialised under the locks and written with all four released, so no payout mutex is ever held across I/O.
2026-08-23payout: leave no temporary file behind, and say so where it will be readbitflash-maintainer
Every failure path in WriteFileAtomic returned without removing "<path>.tmp". A disk that filled up once, or a single EIO, left a short file called pending_payouts.json.tmp sitting next to a good ledger for as long as the node ran. That file is worse than no file: it carries the name of a half-finished save, and the operator cleaning up after the incident is precisely the person who will move it into place and publish a ledger missing whatever had not been written yet. The failures also logged under LogPrint("payout", ...). pool_status.json is written through the same function but everything else about it is logged under "pool", so an operator running -debug=pool was told nothing when the status file could not be replaced -- the message existed and was discarded for having the wrong category. They now print unconditionally: a write failure is not a trace of normal operation, it is the file that is about to be read not being there, and gating it on a category chosen before the failure happened is how it went missing. The brief's alternative, a category argument, would still have required the right guess in advance. Properties: no failure of WriteFileAtomic leaves a .tmp behind, and no failure of it is invisible at default log settings.
2026-08-23payout: indent the blocks introduced by the publish/serialise splitbitflash-maintainer
No behaviour change. Moving the file writes out from under the data mutexes put a new scope around the locked sections of ReconcilePayouts and FlushMaturePayouts, and their bodies were left at the old indentation, which reads as if the lock is not held. Re-indented, with the comments and the concatenated log strings inside them re-wrapped to fit -- `git show -w` on this commit is comments and string line breaks only, and the strings concatenate to the same text.
2026-08-23selftest: leave no scratch directory behind after payout-ledgerbitflash-maintainer
LogPrint binds debug.log to whatever GetAppDir() resolves to the first time it fires, which for this test is its own scratch data directory -- so removing the files the test knew it had written was never going to empty the directory, and `make tests` left one behind in src/ on every run. Sweep the directory instead of naming its contents, and take the process id the same way the other self-tests do rather than assuming getpid() exists on every target.
2026-08-23selftest: cover the payout ledger properties this series fixesbitflash-maintainer
A new `payout-ledger` self-test, 40 assertions, wired into RunSelfTest and into `make -C src tests`. It runs against a scratch data directory and an in-memory wallet map: it never opens wallet.dat, never touches a real datadir, and never sends anything. What it asserts, defect by defect: - a restart does not duplicate an in-flight record. A matured round is exploded, the process state is thrown away and reloaded from the files twice, and the in-flight list must still hold exactly one record per recipient. Both guards are checked separately -- once with the round-level set restored, and once with it deliberately emptied so only the per-recipient check against gPaying can save it. A paying file already containing the duplicate the old build wrote must collapse on load, with the attempt counts merged upwards rather than reset. - state updates address a slot, not a value. Two records equal in every field -- exactly what the old code produced -- and rescheduling or forgetting one must land on the one it was given. - the ledger file cannot be published from a stale snapshot. An older snapshot published after a newer one must not appear on disk, and four threads publishing 64 large images of the same file in different orders must leave it parseable and equal to the highest version, whatever order they finish in. - a payment with only a legacy comment is recognised as already paid, and the fallback's limits hold: it requires the right recipient, the exact amount and the right height; one legacy transaction cannot settle a second debt; a transaction carrying another round's structured metadata is never accepted as legacy evidence; and the structured match still wins where it applies. - grandfathered zero-hash rounds do not answer for each other, both as a direct test of the round comparison and end to end -- recording a txid against one zero-hash round must leave the other still owed. - the retry clock that is actually used survives a restart. - a forfeit is published rather than silently dropped: the ledger loses that recipient and only that recipient, and the round proof records it with a reason instead of going on advertising the address as owed. - on-disk compatibility, checked against the real shapes: the live production pending_payouts.json (amounts / blockHash / blockHeight / matureAtHeight and nothing else), the merged build's shape including the retryAfter this build no longer writes, and a round trip through this build. Each assertion was verified to fail when its defect is put back: seven mutations were applied one at a time to a scratch copy and every one was caught. Notably, re-introducing either half of the duplicate-record fix alone is caught by its own assertion while the other half still prevents the duplicate -- which is the defence in depth this series was asked for, checked rather than asserted.
2026-08-23payout: flush the ledger to the device before the rename that publishes itbitflash-maintainer
WritePendingPayoutsFile, SavePayingLocked and SavePoolRoundsLocked all wrote a temp file and renamed it, with no fsync anywhere. That is atomic against the process dying -- which is what the crash test exercised -- and not against the machine dying. A power cut or a hard VM reset can land the rename on the platter ahead of the data it points at, and what comes back is a short or empty file. For pending_payouts.json that is not a corrupt ledger, it is an absent one: LoadPendingPayouts catches the parse error, logs one warning, and the node starts up owing nobody anything. Total silent loss of the unpaid ledger, from a clean-looking boot. WriteFileAtomic now fflush()es and fsync()s the temp file before renaming it, and fsync()s the containing directory afterwards so the new directory entry is durable as well. If the data cannot be made durable the rename does not happen at all: the previous file is still intact and still correct, and the next write will try again. Ordering the sync before the rename is the point -- publishing a name that points at nothing is the exact failure being prevented. The directory sync is best effort; a filesystem that will not let the directory be opened for it is not a reason to refuse to write the ledger. This is only affordable because the preceding commit moved all three writes out from under gPayoutLedgerMutex / gPayoutExecMutex / gPoolRoundsMutex. An fsync under any of those would park the pool event loop on the payout thread's disk, which is the contention the merge was right to remove. Applies to upstream too and is being reported there. No on-disk format change.
2026-08-23payout: keep one retry clock, the one that is used, and persist itbitflash-maintainer
There were two, and each was broken in the opposite direction. PendingPayout::retryAfter was declared, serialised into pending_payouts.json, loaded back, and tested at the flush gate -- and the only assignment to it anywhere in the tree was `= 0`. The gate could never fire. PendingPayout's `retries` and `deferrals` were the same: carried, never written. Dead fields next to live ones in a structure this one is easy to reach for by mistake, and a gate that looks like backoff but is not is worse than no gate at all. PayingRecord::retryAfter is the clock the payout thread actually uses -- and SavePayingLocked did not write it. Every restart therefore reset every backing-off payment to "ready now" and put the entire in-flight set into the very next 500 ms batch instead of once a minute. On a node restarting in a loop that is a continuous retry storm against a wallet that has already said no, each pass taking cs_main. The dead copy is deleted, along with the flush gate that read it and the one `retryAfter = 0` in RecordPayoutTxid. The live one is now persisted and restored. Retry state stays where the failure happens -- per (round, recipient), which is the granularity SendMoney works at. On disk, both directions still read: - pending_payouts.json no longer has "retryAfter" written into it, and no longer reads one. Files that carry it (everything the merged build wrote, always as 0) load unchanged, the key simply ignored. A build that expects it defaults an absent key to 0, which is the only value it has ever held. - paying_payouts.json gains "retryAfter". Older builds ignore unknown keys; this build defaults an absent one to 0, which is exactly the behaviour a file written without it used to get.
2026-08-23payout: do not write off a debt the wallet is only temporarily unable to paybitflash-maintainer
ForfeitPayoutRecipient permanently erases a miner's money: the amount leaves the ledger, the operator keeps the coin, and nothing on disk records that it was ever owed. It was reachable from two entirely ordinary conditions. The deferral gate compared GetBalance() against r.amount, ignoring the fee. A balance anywhere between amount and amount+fee therefore passed the gate, failed inside SendMoney, and was counted as a send FAILURE -- a retry. At one a minute, 1440 of those is 24 hours, and then the recipient is gone. A wallet left locked overnight did exactly the same thing: every attempt fails because nothing can be signed, and each failure spent budget. Three changes. The gate now requires amount + nTransactionFee, which is the part of the fee knowable before the transaction exists. It deliberately does not try to reserve GetMinFee(), which depends on the serialised size and so on how many inputs SelectCoins happens to pick: refusing to attempt a payment the wallet could actually have made is its own way of not paying a miner. A locked wallet is now a deferral at the same gate, for the same reason maturity is. Failures are classified after the fact instead of all counting as faults. The balance and lock state are re-read after SendMoney returns -- it holds cs_main and can block for a long time, so both may have moved -- and if the wallet is locked, or the balance no longer covers amount + fee + one fee tier of headroom, the attempt is recorded as a deferral. That headroom is where the size-dependent part of the fee lands, so a fee shortfall is a wait rather than a fault, which is what it is. The retry budget therefore now only counts failures that happened while the payment was funded, the wallet was unlocked, and the funding block was on the main chain. Reaching 1440 of those means the wallet refuses a payment it has every means to make, which is the only state in which the payment can be called impossible rather than merely blocked. The give-up log says which conditions held, rather than implying it. Finally, the forfeit is reconciled with the public record. Dropping the amount from the ledger while pool_rounds.json goes on advertising the address as owed with "txid": null left the pool's two accounts of the same round permanently disagreeing, with the operator holding the difference. PoolRoundProof gains a per-address forfeited/forfeitReason, written by MarkPoolRoundForfeited when the ledger writes the debt off, and pool_status.json counts those separately from genuinely unpaid ones instead of reporting them as still pending. On-disk: pool_rounds.json gains optional "forfeited" and "forfeitReason" keys inside a payout object, and pool_status.json gains "forfeitedPayouts" per round. Both are emitted only when a forfeit has actually happened, both are read back with defaults, and files written by any earlier build load unchanged.
2026-08-23payout: recognise a payment a pre-upgrade build already madebitflash-maintainer
FindRecordedPoolPayout matches on pool_payout_id / pool_payout_address / pool_payout_amount. That metadata is written only by this build. Every payout the pool has actually sent, in the wallet right now, carries nothing but a "comment". So the first boot after this upgrade is the dangerous one: swap the binary after an unclean shutdown with payments in flight, and the recovery probe finds no evidence for any of them and pays every one of those recipients a second time. The deleted WalletAlreadyPaid() matched on what the payment WAS -- the comment plus an output paying exactly that amount to exactly that script -- and that evidence is still sitting in the wallet. It just stopped being read. The probe now answers with which kind of evidence it found, and the legacy form is restored as a fallback under three constraints that keep it from answering for a round that is not the one being asked about: 1. it is consulted only when the structured lookup found nothing, so the exact path stays primary and strictly better going forward; 2. any transaction carrying pool_payout_id is excluded from it outright. That key means this build wrote the transaction, and this build's transactions are adjudicated exactly -- so a newer round's payment can never be offered as proof for an older one. Only pre-upgrade transactions, which is the entire population the fallback exists for, are eligible; 3. a txid already credited to a DIFFERENT (round, address) is excluded -- from the ledger's paidTxids, which is durable across restarts, and from what this process has already claimed. One legacy transaction therefore settles at most one debt, so N transactions can never discharge more than N obligations. Two debts with one transaction pays the second, which is the correct reading of the evidence. What cannot be resolved from a comment alone is two PRE-UPGRADE rounds at the same height paying the same address the same amount, because the comment carries only the height. That requires a reorg, and the losing round's ledger entry is voided by the orphan check before it can be paid, so the two cannot both be live. Where the evidence genuinely runs out the fallback does not match and the payment is made: paying a debt that cannot be proven settled is the correct default for a pool, and it is the direction that does not keep a miner's money. Recovery on legacy evidence logs loudly and says exactly what it matched on, because it is a judgement made about a binary that is no longer running. The two lookups share one pass over mapWallet. The structured path used to scan alone; adding a second scan would have doubled a linear walk taken under cs_mapWallet, which the whole node contends on. No on-disk format change.
2026-08-23payout: one in-flight record per payment, and give it a real identitybitflash-maintainer
A restart duplicated every in-flight payment, deterministically, and the duplicates were load-bearing. gQueuedPayouts is memory-only. ReconcilePayouts restored gPaying from disk but left that set empty, and FlushMaturePayouts deduped only against it and against the ledger's paidTxids -- never against gPaying itself. So a round that had been exploded but not yet paid got a SECOND PayingRecord for every unpaid recipient on the first flush after every restart. Another restart added a third. The crash test saw exactly that: two byte-identical records for one (block, address). ForgetPayment and ReschedulePayment then found their record with SamePayment(), a field-by-field comparison, which stops being an identity the moment two records are equal. Every update landed in the first slot and the second was never touched: - the duplicate's retryAfter stayed 0, so it ran in every 500 ms batch instead of once a minute; - each pass wrote retries = 0+1 into the FIRST slot, resetting its count, so the 1440-retry budget never completed and forfeit was unreachable; - the duplicate's fProbeWallet was never cleared, so FindRecordedPoolPayout -- a full mapWallet scan under cs_mapWallet -- ran twice a second per stuck recipient, forever, along with GetBalance() and, whenever funds allowed, SendMoney/CreateTransaction under cs_main. That is a permanent hot loop holding cs_mapWallet and cs_main on a pool node, starving RebuildJob() so job refresh and block submission stall. It is reached by the ordinary state of a payment waiting for coin or for an unlocked wallet. It did not double-pay only because a slot can reach SendMoney unprobed only when fProbeWallet is already false, and any successful payment calls ForgetPayment, which erases exactly the one slot that could be in that state. That invariant is emergent, undocumented, and dies the moment anyone erases by index or gives the record a stable identity -- which is precisely what this commit does, so it is replaced by a structural one. Both halves are fixed. Not creating duplicates: - ReconcilePayouts rebuilds gQueuedPayouts from the restored gPaying, so the first flush after a restart no longer re-explodes rounds already in flight; - FlushMaturePayouts additionally refuses to append a record for a (round, address) that gPaying already holds. That check is the invariant proper -- the round-level set is an optimisation that can be stale, gPaying is the list being protected; - LoadPaying collapses duplicates already on disk as it reads them, merging the retry and deferral counts upwards, so a file written by the broken build does not inherit the pathology into the fixed one. Real identity: PayingRecord gains an id, assigned under gPayoutExecMutex, and ForgetPayment/ReschedulePayment match on it instead of on values. SamePayment is deleted. The id is process-local and deliberately not persisted -- gPaying is rebuilt from disk at every start, so it only has to name a slot within one run, and reading ids back would mean trusting a file to contain no duplicates. The exactly-once property is now the conjunction of four checks that each hold on their own, rather than one accident: 1. at most one gPaying record per (round, address), enforced at insert; 2. the ledger's paidTxids, re-checked at the top of every attempt; 3. the wallet probe, once per record, before its first send; 4. state updates addressed by record id, so no slot can be skipped. No on-disk format change: the id is not written, and duplicate records in an existing file are read and collapsed.
2026-08-23payout: stop grandfathered zero-hash rounds answering for each otherbitflash-maintainer
Every ledger lookup identified an entry by its block hash alone -- ReconcilePayouts, the payout thread's already-paid check, RecordPayoutTxid, ForfeitPayoutRecipient, ForgetPayment's still-in-flight test, and SamePayment. Entries restored from a file written before payouts were keyed by hash all carry hash 0. Two of them therefore compare equal, and the first one found answers for the second: - RecordPayoutTxid writes a txid against the wrong round. That round can then satisfy PayoutFullyPaid and be erased from the ledger while the debt it recorded is still owed, and nothing on disk remembers it. - ReconcilePayouts drops a live in-flight record on startup because a different zero-hash round had already paid that same address. SamePayoutRound() decides it properly: a non-zero hash is unique and settles the question on its own, a zero hash falls back to the block height (which LoadPendingPayouts supplies for any entry able to name its own maturity), and a zero hash never matches a real one. Refusing to match is the safe direction at all six sites, because each treats a match as "this debt is already handled". The orphan-void scan is deliberately left alone: PayoutBlockStillOnChain returns true for a zero hash, so a grandfathered entry can never enter the orphaned list and there is nothing there to cross-match. For the residual case where an entry has neither hash nor height -- which no file this tree has written can produce -- FlushMaturePayouts now refuses to explode it and logs why, rather than paying against an identity that could belong to another round. Not paying is recoverable; recording a payment against the wrong debt is not. PayoutIdForBlock's grandfathered "block-<height>" fallback is unchanged and still safe: that id is only ever matched against pool_payout_id, which no pre-upgrade transaction carries. No on-disk format change.