Skip to content

Pool fixed - #21

Closed
Vic-Nas wants to merge 185 commits into
Bitflash-sh:mainfrom
Vic-Nas:main
Closed

Pool fixed#21
Vic-Nas wants to merge 185 commits into
Bitflash-sh:mainfrom
Vic-Nas:main

Conversation

@Vic-Nas

@Vic-Nas Vic-Nas commented Jul 28, 2026

Copy link
Copy Markdown
Contributor

The pool system is fragile.
I made a few fixes but there r still some connexions problems.

I merged your security fixes

Vic-Nas and others added 30 commits July 25, 2026 20:27
Vic-Nas added 11 commits July 29, 2026 11:18
Wrong target, previous commit: 'make windows' never calls build.sh at
all -- it goes deps-windows (root Makefile) -> cd src && make -f
makefile.mingw directly. build.sh is an orphaned script with a
hardcoded personal path (/c/Users/ti/Bitflash/src) that isn't part of
the real build chain; deleted it along with the pacman step I'd added
to it, since that step was in the wrong file.

Added libwebsockets to both real dependency lists instead:
- deps-windows (root Makefile): mingw-w64-ucrt-x86_64-libwebsockets
- deps-apt (root Makefile, Linux path): libwebsockets-dev

Fixed src/readme.txt, which documented the dead build.sh as the
Windows build instructions -- now points at 'make windows' from the
repo root, the path that actually runs.

secp256k1 and RandomX remain untouched in both dep lists -- deps-windows
and deps-linux already build those from source themselves (into
../deps and ../RandomX respectively), so nothing needed there.

Unverified: still no MSYS2/Windows environment available here to run
'make windows' end-to-end. Package names confirmed against the
official MSYS2 index; the full dependency chain (pacman install ->
secp256k1 build -> RandomX build -> make -f makefile.mingw -> zip) has
not been exercised.
…ay code

Hardening (src/btfrv.cpp), addressing all five original TODO items:
- 10s read timeout on the 33-byte pairing header. Previously unbounded --
  a peer that connects and never sends anything tied up a thread forever.
- Registration expiry: tuned TCP keepalive (60s idle / 10s interval / 3
  probes -- Linux's 2-hour default is useless here) on parked
  registrations, plus a 15-minute TTL reaper as a backstop for whatever
  keepalive still misses. Long/infrequent by design: a legitimately idle
  registration (e.g. a pool between miners) should be able to wait far
  longer than this without getting reaped.
- Connection cap (4096 concurrent) via an atomic counter checked at
  accept() time, closing the socket immediately rather than spawning a
  thread once at capacity.
- Backoff on accept() errors (100ms, 1s after 10 consecutive) instead of
  a tight busy-loop -- important specifically because fd exhaustion (the
  most likely cause of repeated accept() failures) gets worse, not
  better, if the response is spinning CPU at 100% retrying immediately.
- Logging added throughout: connects, pairs, rejects, cap hits, reaper
  sweeps.

Plus two related findings from auditing the client-side protocol
(btfrv.cpp/btftunnel.h) alongside this:
- Per-pubkey cap (8) on queued registrations. Registration has zero
  authentication -- anyone can register as 'S' for any pubkey -- so
  without this, flooding registrations for a target's pubkey can starve
  their real registrations out of the FIFO queue indefinitely. Doesn't
  fix the missing auth (that's a bigger design question -- the echan
  layer already prevents an imposter from reading real traffic, per the
  existing header comment), just bounds the blast radius of the
  griefing vector it enables.
- btftunnel.cpp: BtfServiceWrap()'s read of the client's handshake
  ephemeral pubkey had the same unbounded-wait bug as the header read
  above -- a peer that pairs (trivial, given no registration auth) and
  then never sends its handshake tied up that thread forever too. Same
  10s-timeout-then-clear pattern applied.

Verified: full node build clean; test_btfrv.cpp (register -> pair ->
encrypted round-trip -> reject-unknown-pubkey) passes repeatedly against
the hardened code. Confirmed a pre-existing shutdown-hang quirk in this
sandbox reproduces identically on unmodified code, ruling it out as a
regression. test_btftunnel.cpp couldn't be built here (unconditional
winsock2.h include, pre-existing Windows-only limitation in that test
file, unrelated to this change) -- validated the btftunnel.cpp fix by
review and clean compilation instead.

Deduplication: deleted relay/btfrv.cpp and relay/btfrv.h. Per git log,
both were forked from src/btfrv.cpp exactly once, in the initial
fair-launch release commit, and never touched again -- meanwhile
src/btfrv.cpp received four real fixes since (372530e multi-registration
FIFO fix, 0a67bba socket timeout, b80cb56 idle-disconnect fix, c9aed6b
bind fix) that never made it into the relay/ copy. Whatever's actually
deployed on the VPS via relay/ has been running unpatched code missing
those four fixes on top of never getting this hardening pass.

relay/build.sh now builds directly against ../src/btfrv.cpp -- single
source of truth, verified this compiles and runs.
relay/install-bitflash-relay.sh (the one-shot curl-installer) no longer
embeds a static heredoc copy of the source, since embedding a static
copy is exactly what caused the staleness bug above -- it now curls the
canonical src/btfrv.h, src/btfrv.cpp, and relay/bitflashd.cpp from the
repo at install time, so it structurally can't go stale the same way
again. Added a curl availability check matching the existing g++ one.
Both scripts pass bash -n.
error() was printing unconditionally on every single call, completely
bypassing the burst-collapse dedup LogPrint already had (and was
specifically widened to 5 minutes for -- see 8ce24a4). This is the
actual root cause of the debug.log noise reported: relay connect
failures go through 'return error(...)', so every retry in every
discovery cycle printed its own near-identical line with zero
collapsing. A short real run here went from dozens of near-duplicate
'ERROR: Nostr: TLS handshake with X failed' lines down to one, with a
periodic '(N similar in 300s, last: ...)' summary covering the rest --
same trade-off already accepted for LogPrint on this exact problem
shape, now applied consistently to error() too.

error() is now a macro (error(...) -> ErrorImpl(__FILE__, __LINE__,
...)), the same pattern as the LogPrint macro, since ErrorImpl needs
the caller's call site to key the dedup and a ~100-call-site plain
function can't see that on its own. Checked every call site in src/ for
macro-collision risk first (bare 'error' identifiers, .error() member
calls) -- found one, a lambda parameter named 'error' in rpc.cpp, which
is safe since it's never followed by '(' the way the macro requires to
expand.

Unlike LogPrint, error() is never gated by fDebug/LogAcceptsCategory --
an error is always worth seeing at least once; only true repeats from
the same call site collapse.

Extracted the dedup bookkeeping (window/suppressed-count/last-message
tracking) into a shared DedupDecide() so LogPrintDedup and ErrorImpl
share the exact same collapse logic and only differ in output
formatting (category bracket vs 'ERROR:' prefix).

Verified: full build clean; a live run reproducing the original
all-relays-unreachable scenario collapsed 'ERROR: Nostr: TLS handshake'
spam from dozens of lines to one, confirmed via direct log inspection.
… nodes

Instead of sending a peer the full set of transactions for a block --
almost all of which they already have in their mempool from normal tx
relay -- send a 48-bit keyed short ID per transaction instead. A peer
that already has every referenced transaction reconstructs the full
block locally without any tx bytes crossing the wire; only genuinely
missing ones (in this implementation, only ever the coinbase is
prefilled, everything else is a short ID) need a follow-up round trip.

Backward compatibility is the main design constraint, not an
afterthought:
- Old, unupgraded nodes are completely unaffected. ProcessMessage
  already ignores unknown commands (confirmed by reading it, not
  assumed), so they simply never see sendcmpct/cmpctblock/
  getblocktxn/blocktxn. They keep receiving today's full block
  message exactly as before -- that handler is untouched.
- A new node still fully understands and accepts full block messages
  from anyone, old or new -- nothing about receiving is gated behind
  compact-block support.
- The compact form is only ever sent to a peer that has itself first
  sent sendcmpct (right after the version handshake) -- capability is
  announced per-peer, not assumed. Old nodes never send it, so they
  never trigger the new code path on the sending side either.
- No coordinated network upgrade needed: this rolls out node-by-node,
  and any two upgraded nodes automatically get the bandwidth win the
  moment they connect to each other.

Scoped down from full BIP152 for this chain's scale/age: no
high-bandwidth (unsolicited push) mode, only the current chain tip is
ever offered in compact form (an older block is unlikely to still
overlap much with any peer's present-day mempool, so full block is
sent for those, same as today), and light/fClient peers are excluded
entirely (they keep no mempool to resolve short IDs against).

New files: compactblock.h/.cpp -- CCmpctBlock, short-ID computation
(SHA256-derived per-block key + libsodium's SipHash-2-4, so IDs can't
be precomputed before a block exists and differ block-to-block for the
same tx), BuildCompactBlock, and mempool-based reconstruction
(TryReconstructBlock/FillMissingAndFinish) with explicit 48-bit
short-ID collision handling (falls back to requesting both candidates
rather than risking a wrong match -- correct either way, only
occasionally not optimal).

net.h/net.cpp: CNode gets fWantsCmpctBlock plus pending-reconstruction
state for an in-flight cmpctblock awaiting getblocktxn. The pending
block is a raw CBlock* (not a member) because net.h is included before
main.h in this codebase's header chain, so CBlock isn't a complete type
there -- deletion happens in an out-of-line ~CNode() in net.cpp instead,
where it is.

main.cpp: sendcmpct sent after processing a peer's version (skipped for
fClient peers); getdata's MSG_BLOCK branch sends cmpctblock instead of
block only when the peer opted in and the block is the current tip;
three new handlers (cmpctblock/getblocktxn/blocktxn) mirroring the
existing inv/getdata/block request-response shape already in this file.

Verified: full node build clean (Linux); node starts and runs normally.
Wrote test_compactblock.cpp, a standalone unit test (not wired into the
P2P layer, which would need the rendezvous relay in the loop to test
end-to-end now that the direct-IP listener is gone) -- 17 checks, all
passing: full-mempool reconstruction produces a byte-identical block
hash to the original; partial-mempool reconstruction correctly reports
missing indices and completes via the getblocktxn/blocktxn round trip;
a mismatched blocktxn response is correctly rejected rather than
silently corrupting the block; and short IDs for the same transaction
differ across different simulated blocks and nonces, confirming the
per-block keying actually works rather than degenerating to a
txid-only hash.
nostr.cpp requires cacert.pem next to the exe for TLS on Windows (no
system trust store access via OpenSSL there). The zip target built
the exe but never copied the CA bundle alongside it.
…rsion

- Root VERSION file replaces the hardcoded 'VERSION := 1.2.2' in the Makefile
- Both linux and windows sub-makes now receive VERSION and define
  BITFLASH_APP_VERSION at compile time, so the running binary knows its
  own version (needed for the upcoming auto-update check).
- update.h/update.cpp: checks releases/latest on Vic-Nas/bitflash-mod
  (UPDATE_REPO, fixed at build time), compares dotted version numbers,
  downloads platform asset via libcurl, replaces the running binary
  (rename() on Linux AppImage, detached cmd helper + move on Windows
  since it can't self-overwrite), relaunches.
- Makefiles: link libcurl, add -DUPDATE_REPO, add curl to apt/pacman deps.
- .github/workflows/release.yml: on push to main touching VERSION (or
  manual dispatch), builds Linux AppImage + Windows zip in parallel,
  generates a short changelog (top commits by diff size since last tag),
  publishes one GitHub Release with both assets.
… 'Bitflash'; fix missing pillow dep for AppImage icon step
@Bitflash-sh

Copy link
Copy Markdown
Owner

Thanks for this, and sorry for the slow review — there is a lot of real work in
here. I went through it properly rather than skimming.

The headline number is misleading: 84 files and +73k lines, but most of that is
vendored ImGui, bitmaps and content already on main. The actual source diff is
24 files, about +2000/-1000, and that part is good work. The compact block
subsystem with its own test, the Stratum share fixes, the connectivity changes —
those are worth having.

Two things block it as it stands. Neither is a criticism of the code you wrote.

1. It would revert the 1.2.2 hardening

You mentioned merging the security fixes, and the 1.2.1 ones are all here — I
checked by content, not by history, since you reapplied them under your own
commits. OP_RETURN, the OP_CAT group, the duplicate-transaction check, the
halving-by-height fix: all present and correct.

The 1.2.2 set is not, and the timing explains why — you opened this at 18:09 and
that batch merged at 18:25, so you could not have had it:

present
#16 orphan cap no
#17 Nostr frame bounds no
#18 string unserialize no — still str.resize(nSize)
#19 Stratum bounds no
#20 sigops cap (consensus) no

The last one is the problem. MAX_BLOCK_SIGOPS and GetSigOpCount do not exist
in this branch, and CBlock::CheckBlock differs from main by exactly that. A
node built from here accepts blocks that 1.2.2 rejects, which means a chain
split. Everything else in consensus — ConnectBlock, AcceptBlock,
ConnectInputs, GetBlockValue — is byte-identical, and compactblock does not
touch validation at all. So a rebase on current main should fix this cleanly.

2. update.cpp cannot go in without signature verification

The self-updater downloads a release asset and replaces the running executable.
There is no signature check and no checksum check — the header says it plainly:

Downloads info.asset_url to a temp file, verifies it's non-empty, then
atomically swaps it in for the running executable and relaunches

Non-empty is the only validation before swapping the binary that holds users'
private keys. Anyone who compromises the release, or the account that publishes
it, gets code execution on every node that updates.

I want to be clear that I am not reading anything into this. It is not wired up —
nothing calls it outside the file itself — and it reads as a convenience feature.
But it does compile into the binary, so it is one line away from being live, and
UPDATE_REPO is a build flag whose documented example points at a different repo.

If you want to pursue it: sign the release artifacts and verify the signature
against a key baked into the binary before the swap. Then it becomes a genuinely
useful feature. Until then it cannot merge.

What I would suggest

Split it up, and I will review each part quickly:

  1. Pool and Stratum fixes — rebased on current main. This is the one I would
    take first; the share-rejection fixes in particular sound like they matter.
  2. compactblock — self-contained, has a test, easy to review on its own.
  3. The updater — separately, and only with signature verification.

Happy to help with the rebase if the conflicts are annoying — main has moved
quite a bit (1.2.1, 1.2.2, and three changes today).

One unrelated question while I have you: 62b41c8e stopped persisting the mining
mode across restarts and b00bba93 made Relay the startup default. That is on
main now and it caught me out twice today — a node restarts and quietly stops
mining. What was the reasoning? If it was working around something specific I
would rather fix that than revert your change.

Vic-Nas and others added 8 commits July 29, 2026 15:20
A transaction naming a parent the node does not have is never validated,
just stored in mapOrphanTransactions -- and there was no ceiling. Sending
them costs a peer nothing: no proof of work, no valid signature, no fee,
and the node keeps every one. Any peer could grow that map until the node
died of memory exhaustion.

Cap at 100, evicting at random rather than in map order so an attacker
cannot keep its own orphans resident by choosing hashes that sort low.

Local policy, not consensus -- no effect on block or transaction
validity.
…itflash-sh#18)

The vector unserializer grows in 5 MB steps so a bogus length cannot make
it allocate more than has actually been received. The string one resized
straight to whatever ReadCompactSize returned, and ReadCompactSize has no
ceiling -- nine bytes on the wire can name four gigabytes.

Not reachable from the network today: the only string on a serialized
type is CWalletTx::mapValue, and RelayMessage casts to CTransaction
before sending, so it never leaves disk. This is a guard against the next
network message that carries a string -- Bitcoin's own version message
carries strSubVer -- rather than a fix for a live hole.
Everything an unauthenticated socket can reach in the pool server, in one
pass.

A line that never ends. RecvLine appended to the per-miner buffer and
looked for a newline; a peer that sends bytes and no newline grew that
buffer for as long as it kept writing, before authorizing or subscribing.
Capped at 64 KB, which drops the miner.

No limit on connections. Every accepted socket became a Miner with its
own buffer and nothing bounded the list. Capped at 1000.

Malformed requests crashed the node, not just the pool. ThreadRPCServer
has no exception handler, so anything thrown from line handling reached
the top of the thread and terminated the process. Three ways in, all
valid JSON that parses cleanly:

  [1,2,3]                      -- value() on a non-object throws
  {"method": 1}                -- value() with the wrong type throws
  submit with params [1,2,3]   -- get<std::string>() on a number throws

mining.authorize had the subtler shape of the same thing: it tested
p.size() without testing p.is_array(), and indexing a json object with a
number throws while size() happily returns its key count.

Each is now rejected on its own terms, and the call is wrapped so a throw
drops that miner instead of the node.
MAX_SIZE bounds a block's bytes, not what it costs to check. Every
OP_CHECKSIG is an elliptic-curve verification, and blocks may be 32 MB
here, so one packed with signature operations takes every node minutes of
CPU to reject -- while at this chain's difficulty it stays cheap to mine.
There was no counter at all.

CheckBlock now sums signature operations across the block and rejects
past 20000, the figure Bitcoin settled on. OP_CHECKMULTISIG counts as 20
since it may verify that many.

Consensus change. Real blocks here carry one or two operations each --
coinbase pay-to-pubkey and standard pay-to-pubkey-hash spends -- so the
ceiling is four orders of magnitude above anything in the chain, but it
still needs verifying against the full history before merge.
…ling; sign release assets in CI

Addresses the gap flagged in the bitflash-sh PR review: the updater
previously only checked that the download was non-empty. It now refuses
to install anything not signed by the release key. Also fixes the same
unchecked-j[0] JSON crash pattern in nostr.cpp that was fixed upstream
in Bitflash-sh#17 (the frame-size-cap part of that fix doesn't apply here since our
nostr.cpp uses libwebsockets, not a hand-rolled reader).
@Bitflash-sh

Copy link
Copy Markdown
Owner

That was fast, thank you. I reviewed the code rather than the commit messages,
and both blockers are properly closed.

Consensus: MAX_BLOCK_SIGOPS is back and CBlock::CheckBlock matches main
again, so the chain-split risk is gone. #16, #18 and #19 are in as well.

The updater: this is a real fix, not a gesture. Compiled-in 32-byte Ed25519
key, .sig companion fetched separately, EVP_DigestVerify before anything is
swapped, and it fails closed — your own comment says missing signature, network
error and mismatch are all treated the same, which is exactly right. I confirmed
VerifyDownloadedAsset is called on both the Windows and the Linux path before
the swap, and that the CI step fails the whole release when the signing secret is
absent, so an unsigned release can't be published by accident. Good work.

Three things left.

1. The signing key has to be this project's

kUpdatePubKey is your key, and the private half is a secret in your CI. If this
merges as-is, whoever holds that key can sign an update that every node installs.

Nothing about that is aimed at you — it is simply the wrong place for the key to
live, in both directions. It makes you a single point of compromise for a project
you contribute to rather than run, and it is a burden you did not sign up for.

So: I will generate a keypair for the project, hold the private half as a secret
here, and send you the 32 bytes to put in the constant. Nothing else in your code
changes.

2. When it gets wired up, it must ask every time

The updater is not called from anywhere yet, so how it surfaces is still open.
Your header already describes the right flow — show version and notes, then apply
only if the user accepts — and I would like that to stay a hard rule rather than a
default.

A signed auto-updater on a consensus system is a channel for changing the rules on
every node at once. Signature verification makes that channel authentic; it does
not make it reviewed. Explicit consent per update is what keeps a release an offer
instead of an instruction. Check and apply being separate functions already gets
this right — I just want it stated so nobody wires them together later.

3. Two smaller things

  • nostr: bound what a relay can make the node allocate #17 did not come along. The Nostr relay bounds are still missing on this
    branch: a 64-bit WebSocket frame length going straight into a resize, no ceiling
    on reassembly, and six get<string>() calls without a type check — a relay
    answering [1] takes the thread down. Same rebase should pick it up.

  • CURLOPT_CAINFO, "cacert.pem" is a relative path, at three call sites. It
    resolves against the working directory, so a node launched from anywhere other
    than its own folder can't find the CA bundle, TLS fails, and the updater quietly
    never works. It fails closed, so it is not dangerous — just invisible. Worth
    resolving against the executable's directory.

The offer to split this into separate PRs still stands and I still think it would
be easier on both of us, but if you would rather keep it as one I will review it
as one. Either way, tell me when you want the public key.

CURLOPT_CAINFO was a bare relative path at all three call sites in
update.cpp. Fine when launched from its own folder, silently broken
(fails closed, TLS just never connects) from anywhere else.
@Bitflash-sh

Copy link
Copy Markdown
Owner

Here is the project's update signing key. Replacing the constant in update.cpp
with these bytes is the last thing blocking the updater from my side:

// Ed25519 public key that release signatures are checked against. The
// matching private key never touches this repo; it lives only as a CI
// secret and is used to sign each release asset (see release.yml).
const unsigned char kUpdatePubKey[32] = {
    0x73, 0x59, 0x32, 0xc7, 0x06, 0x69, 0x3b, 0xb4, 0x0f, 0x3b, 0x3c, 0x3a,
    0xd7, 0x41, 0x3e, 0x3f, 0x69, 0x73, 0xb8, 0x6d, 0x6a, 0xc5, 0x63, 0xd1,
    0x60, 0xff, 0x6a, 0x6d, 0x43, 0x28, 0xb2, 0xb2
};

The private half is an UPDATE_SIGNING_KEY secret on this repository and is not
held by either of us personally in any form that could sign a release from a
laptop. Your release.yml already fails the build when that secret is missing,
so this repo cannot publish an unsigned release by accident — that check is doing
real work now rather than being theoretical.

Your comment above it can stay exactly as written; it describes the arrangement
correctly.

Still open from my last review, all minor next to what you have already done:

Two things landed on main meanwhile that will touch your rebase, both small:

  • /port now exists and is parsed (main_gui: implement /port, which net.cpp already documented #28). net.cpp had documented it for a long
    time without anything reading it, so two nodes could never share a machine.
  • OutputDebugStringF was dropping log lines under concurrency (util: stop debug.log dropping lines under concurrency #29). The path
    was resolved lazily without a lock, so fopen could get a torn path and the
    line vanished silently. Unconditional startup lines were reaching the file in
    as few as 2 runs out of 6. If you have ever chased something in this codebase
    that "never ran" because it was absent from debug.log, that may be why — it
    cost me an afternoon.

@Bitflash-sh

Copy link
Copy Markdown
Owner

Correction to my comment above — you fixed the cacert.pem path while I was writing it. CaInfoPath() at all three call sites, resolved against the executable's own directory. Disregard that bullet; #17 is the only one of the three still outstanding.

Vic-Nas added 2 commits July 29, 2026 15:51
Checks on launch and via File > Check for Updates, both in the
background. Never applies automatically -- Update_ApplyAndRelaunch is
only ever called from the dialog's 'Update Now' button, once per
prompt, exactly the check/apply split the header already documented.
Skip just dismisses; the check/apply functions stay decoupled so
nothing can wire them together silently later.
@Vic-Nas Vic-Nas closed this Jul 29, 2026
@Vic-Nas Vic-Nas reopened this Jul 29, 2026
@Vic-Nas Vic-Nas closed this Jul 29, 2026
@Bitflash-sh

Copy link
Copy Markdown
Owner

Saw you closed this — no problem, and no need to explain.

Just so it is said plainly: the invitation stands, with no deadline and no
particular shape. If you ever want to bring any of it over, split or whole, I
will review it. If you would rather keep building on your own fork, that is
entirely legitimate and I hope it goes well.

Either way, today was good work. You closed a consensus revert and built proper
signed-update verification in about two hours, from a review that landed on you
without warning. That is not a small thing.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants