diff options
| author | mtg0001 <106211915+mtg0001@users.noreply.github.com> | 2026-08-02 10:56:50 -0300 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2026-08-02 10:56:50 -0300 |
| commit | 89f6d045c8e3dacbfbb369a3ac89c0c33de22b4c (patch) | |
| tree | 3ff7c5ed2c0814a51e5746320f42bfe9c893c56b | |
| parent | ee340c535dd26c034e18c4d97bf225a56bf91f6f (diff) | |
Keep HD change recoverable and bound message stalls (#109)
Change from an HD wallet now goes to a newly derived phrase-backed key instead of reusing a spent input's key. Reusing an input key silently moved value back to a key the phrase cannot reproduce; if derivation fails the transaction is refused rather than creating unrecoverable change. Legacy wallets with no seed keep the 0.1.0 behaviour. Restore also scans past the pre-generated key-pool window before treating an empty batch as the end.
The message parser no longer consumes a header it cannot complete: it reads from a copy, so the O(n) reinsert and the 100 ms sleep inside the shared message thread are gone, and a peer that sits on an incomplete payload is disconnected after two minutes. The wire cap drops from 256 MB to MAX_SIZE. New net-message self-test covers complete, incomplete, stale and oversized headers.
Verified here: clean rebuild, all self-tests pass, lock order checked (no path takes cs_keyPool before cs_mapWallet), and a node built from this branch synced from genesis to the chain tip on the live network with zero rejections.
| -rw-r--r-- | src/Makefile | 1 | ||||
| -rw-r--r-- | src/main.cpp | 104 | ||||
| -rw-r--r-- | src/makefile.mingw | 1 | ||||
| -rw-r--r-- | src/net.h | 15 | ||||
| -rw-r--r-- | src/selftest.cpp | 65 | ||||
| -rw-r--r-- | src/walletcmd.cpp | 7 |
6 files changed, 176 insertions, 17 deletions
diff --git a/src/Makefile b/src/Makefile index 5ded7d6..88dc08f 100644 --- a/src/Makefile +++ b/src/Makefile | |||
| @@ -139,3 +139,4 @@ tests: test_bip32 bitflash-node | |||
| 139 | ./test_bip32 | 139 | ./test_bip32 |
| 140 | ./bitflash-node -selftest=wallet-keypool | 140 | ./bitflash-node -selftest=wallet-keypool |
| 141 | ./bitflash-node -selftest=wallet-hd | 141 | ./bitflash-node -selftest=wallet-hd |
| 142 | ./bitflash-node -selftest=net-message | ||
diff --git a/src/main.cpp b/src/main.cpp index 9082bd5..9bb3c24 100644 --- a/src/main.cpp +++ b/src/main.cpp | |||
| @@ -181,6 +181,39 @@ static bool AddKeyIfMissing(const CKey& key) | |||
| 181 | return AddKey(key); | 181 | return AddKey(key); |
| 182 | } | 182 | } |
| 183 | 183 | ||
| 184 | static bool DeriveNextHDWalletKey(vector<unsigned char>& vchPubKeyRet, string& strErrorRet) | ||
| 185 | { | ||
| 186 | vchPubKeyRet.clear(); | ||
| 187 | CRITICAL_BLOCK(cs_keyPool) | ||
| 188 | { | ||
| 189 | if (!HaveHDSeed()) | ||
| 190 | { | ||
| 191 | strErrorRet = "no seed"; | ||
| 192 | return false; | ||
| 193 | } | ||
| 194 | |||
| 195 | CKey key; | ||
| 196 | if (!DeriveHDKey(nHDNext, key, strErrorRet)) | ||
| 197 | return false; | ||
| 198 | if (!AddKeyIfMissing(key)) | ||
| 199 | { | ||
| 200 | strErrorRet = "could not write the derived key to wallet.dat"; | ||
| 201 | return false; | ||
| 202 | } | ||
| 203 | |||
| 204 | unsigned int nNext = nHDNext + 1; | ||
| 205 | if (!CWalletDB().WriteHDNext(nNext)) | ||
| 206 | { | ||
| 207 | strErrorRet = "could not advance the derivation counter"; | ||
| 208 | return false; | ||
| 209 | } | ||
| 210 | |||
| 211 | nHDNext = nNext; | ||
| 212 | vchPubKeyRet = key.GetPubKey(); | ||
| 213 | } | ||
| 214 | return true; | ||
| 215 | } | ||
| 216 | |||
| 184 | vector<unsigned char> GenerateNewKey() | 217 | vector<unsigned char> GenerateNewKey() |
| 185 | { | 218 | { |
| 186 | CKey key; | 219 | CKey key; |
| @@ -2310,27 +2343,55 @@ bool ProcessMessages(CNode* pfrom) | |||
| 2310 | if (LogAcceptsCategory("net")) printf("\n\nPROCESSMESSAGE SKIPPED %d BYTES\n\n", (int)(pstart - vRecv.begin())); | 2343 | if (LogAcceptsCategory("net")) printf("\n\nPROCESSMESSAGE SKIPPED %d BYTES\n\n", (int)(pstart - vRecv.begin())); |
| 2311 | vRecv.erase(vRecv.begin(), pstart); | 2344 | vRecv.erase(vRecv.begin(), pstart); |
| 2312 | 2345 | ||
| 2313 | // Read header | 2346 | // Read the header from a copy first. The old path consumed the header, |
| 2347 | // noticed the payload was incomplete, inserted the header back at the | ||
| 2348 | // front of the receive buffer, then slept inside the shared message | ||
| 2349 | // thread. A peer could announce a large payload and drip bytes forever, | ||
| 2350 | // making every pass pay for an O(n) insert and a 100 ms stall. | ||
| 2314 | CMessageHeader hdr; | 2351 | CMessageHeader hdr; |
| 2315 | vRecv >> hdr; | 2352 | CDataStream vHeader(vRecv.begin(), vRecv.begin() + sizeof(CMessageHeader), |
| 2353 | vRecv.nType, vRecv.nVersion); | ||
| 2354 | vHeader >> hdr; | ||
| 2316 | if (!hdr.IsValid()) | 2355 | if (!hdr.IsValid()) |
| 2317 | { | 2356 | { |
| 2318 | if (LogAcceptsCategory("net")) printf("\n\nPROCESSMESSAGE: ERRORS IN HEADER %s\n\n\n", hdr.GetCommand().c_str()); | 2357 | if (LogAcceptsCategory("net")) printf("\n\nPROCESSMESSAGE: ERRORS IN HEADER %s\n\n\n", hdr.GetCommand().c_str()); |
| 2358 | if (hdr.nMessageSize > MAX_PROTOCOL_MESSAGE_SIZE) | ||
| 2359 | pfrom->fDisconnect = true; | ||
| 2360 | pfrom->nIncompleteMessageStart = 0; | ||
| 2361 | vRecv.ignore(sizeof(CMessageHeader)); | ||
| 2319 | continue; | 2362 | continue; |
| 2320 | } | 2363 | } |
| 2321 | string strCommand = hdr.GetCommand(); | 2364 | string strCommand = hdr.GetCommand(); |
| 2322 | 2365 | ||
| 2323 | // Message size | 2366 | // Message size |
| 2324 | unsigned int nMessageSize = hdr.nMessageSize; | 2367 | unsigned int nMessageSize = hdr.nMessageSize; |
| 2325 | if (nMessageSize > vRecv.size()) | 2368 | unsigned int nPayloadAvailable = vRecv.size() - sizeof(CMessageHeader); |
| 2369 | if (nMessageSize > nPayloadAvailable) | ||
| 2326 | { | 2370 | { |
| 2327 | // Rewind and wait for rest of message | 2371 | if (pfrom->nIncompleteMessageStart == 0 || |
| 2328 | ///// need a mechanism to give up waiting for overlong message size error | 2372 | pfrom->nIncompleteMessageSize != nMessageSize || |
| 2329 | if (LogAcceptsCategory("net")) printf("MESSAGE-BREAK 2\n"); | 2373 | pfrom->strIncompleteMessageCommand != strCommand) |
| 2330 | vRecv.insert(vRecv.begin(), BEGIN(hdr), END(hdr)); | 2374 | { |
| 2331 | Sleep(100); | 2375 | pfrom->nIncompleteMessageStart = GetTime(); |
| 2376 | pfrom->nIncompleteMessageSize = nMessageSize; | ||
| 2377 | pfrom->strIncompleteMessageCommand = strCommand; | ||
| 2378 | } | ||
| 2379 | else if (GetTime() - pfrom->nIncompleteMessageStart > | ||
| 2380 | BTF_INCOMPLETE_MESSAGE_TIMEOUT_SECS) | ||
| 2381 | { | ||
| 2382 | LogPrint("net", | ||
| 2383 | "disconnecting %s: incomplete %s message, %u of %u bytes after %d seconds\n", | ||
| 2384 | pfrom->addr.ToString().c_str(), strCommand.c_str(), | ||
| 2385 | nPayloadAvailable, nMessageSize, | ||
| 2386 | BTF_INCOMPLETE_MESSAGE_TIMEOUT_SECS); | ||
| 2387 | pfrom->fDisconnect = true; | ||
| 2388 | } | ||
| 2332 | break; | 2389 | break; |
| 2333 | } | 2390 | } |
| 2391 | pfrom->nIncompleteMessageStart = 0; | ||
| 2392 | pfrom->nIncompleteMessageSize = 0; | ||
| 2393 | pfrom->strIncompleteMessageCommand.clear(); | ||
| 2394 | vRecv.ignore(sizeof(CMessageHeader)); | ||
| 2334 | 2395 | ||
| 2335 | // Copy message to its own buffer | 2396 | // Copy message to its own buffer |
| 2336 | CDataStream vMsg(vRecv.begin(), vRecv.begin() + nMessageSize, vRecv.nType, vRecv.nVersion); | 2397 | CDataStream vMsg(vRecv.begin(), vRecv.begin() + nMessageSize, vRecv.nType, vRecv.nVersion); |
| @@ -3774,13 +3835,28 @@ bool CreateTransaction(CScript scriptPubKey, int64 nValue, CWalletTx& wtxNew, in | |||
| 3774 | // Fill vout[1] back to self with any change | 3835 | // Fill vout[1] back to self with any change |
| 3775 | if (nValueIn > nTotalValue) | 3836 | if (nValueIn > nTotalValue) |
| 3776 | { | 3837 | { |
| 3777 | // Use the same key as one of the coins | ||
| 3778 | vector<unsigned char> vchPubKey; | 3838 | vector<unsigned char> vchPubKey; |
| 3779 | CTransaction& txFirst = *(*setCoins.begin()); | 3839 | if (HaveHDSeed()) |
| 3780 | foreach(const CTxOut& txout, txFirst.vout) | 3840 | { |
| 3781 | if (txout.IsMine()) | 3841 | string strError; |
| 3782 | if (ExtractPubKey(txout.scriptPubKey, true, vchPubKey)) | 3842 | if (!DeriveNextHDWalletKey(vchPubKey, strError)) |
| 3783 | break; | 3843 | { |
| 3844 | printf("CreateTransaction() : could not derive a phrase-backed change key: %s\n", | ||
| 3845 | strError.c_str()); | ||
| 3846 | return false; | ||
| 3847 | } | ||
| 3848 | } | ||
| 3849 | else | ||
| 3850 | { | ||
| 3851 | // Legacy wallets have no deterministic seed. Preserve | ||
| 3852 | // the 0.1.0 behaviour for them: return change to a key | ||
| 3853 | // already proven to belong to this wallet. | ||
| 3854 | CTransaction& txFirst = *(*setCoins.begin()); | ||
| 3855 | foreach(const CTxOut& txout, txFirst.vout) | ||
| 3856 | if (txout.IsMine()) | ||
| 3857 | if (ExtractPubKey(txout.scriptPubKey, true, vchPubKey)) | ||
| 3858 | break; | ||
| 3859 | } | ||
| 3784 | if (vchPubKey.empty()) | 3860 | if (vchPubKey.empty()) |
| 3785 | return false; | 3861 | return false; |
| 3786 | 3862 | ||
diff --git a/src/makefile.mingw b/src/makefile.mingw index 5be3704..5025acb 100644 --- a/src/makefile.mingw +++ b/src/makefile.mingw | |||
| @@ -111,3 +111,4 @@ tests: test_bip32.exe bitflash.exe | |||
| 111 | ./test_bip32.exe | 111 | ./test_bip32.exe |
| 112 | ./bitflash.exe -selftest=wallet-keypool | 112 | ./bitflash.exe -selftest=wallet-keypool |
| 113 | ./bitflash.exe -selftest=wallet-hd | 113 | ./bitflash.exe -selftest=wallet-hd |
| 114 | ./bitflash.exe -selftest=net-message | ||
| @@ -67,6 +67,13 @@ static const int BTF_RECV_TIMEOUT_SECS = 30 * 60; | |||
| 67 | static const int BTF_SEND_STALL_SECS = 10 * 60; | 67 | static const int BTF_SEND_STALL_SECS = 10 * 60; |
| 68 | static const int BTF_HANDSHAKE_GRACE_SECS = 60; | 68 | static const int BTF_HANDSHAKE_GRACE_SECS = 60; |
| 69 | 69 | ||
| 70 | // A serialized object cannot be larger than MAX_SIZE, so no valid network | ||
| 71 | // message needs the old 256 MB header allowance. Keep the wire cap aligned with | ||
| 72 | // what the deserializer can accept and bound how long one peer may sit on a | ||
| 73 | // partial payload after a complete header has arrived. | ||
| 74 | static const unsigned int MAX_PROTOCOL_MESSAGE_SIZE = MAX_SIZE; | ||
| 75 | static const int BTF_INCOMPLETE_MESSAGE_TIMEOUT_SECS = 2 * 60; | ||
| 76 | |||
| 70 | // Ceiling on simultaneous connections. | 77 | // Ceiling on simultaneous connections. |
| 71 | // | 78 | // |
| 72 | // ThreadSocketHandler watches every peer through one select(), and select() | 79 | // ThreadSocketHandler watches every peer through one select(), and select() |
| @@ -225,7 +232,7 @@ public: | |||
| 225 | } | 232 | } |
| 226 | 233 | ||
| 227 | // Message size | 234 | // Message size |
| 228 | if (nMessageSize > 0x10000000) | 235 | if (nMessageSize > MAX_PROTOCOL_MESSAGE_SIZE) |
| 229 | { | 236 | { |
| 230 | if (LogAcceptsCategory("net")) printf("CMessageHeader::IsValid() : nMessageSize too large %u\n", nMessageSize); | 237 | if (LogAcceptsCategory("net")) printf("CMessageHeader::IsValid() : nMessageSize too large %u\n", nMessageSize); |
| 231 | return false; | 238 | return false; |
| @@ -596,6 +603,9 @@ public: | |||
| 596 | int64 nLastSend; | 603 | int64 nLastSend; |
| 597 | int64 nLastRecv; | 604 | int64 nLastRecv; |
| 598 | int64 nLastSendEmpty; | 605 | int64 nLastSendEmpty; |
| 606 | int64 nIncompleteMessageStart; | ||
| 607 | unsigned int nIncompleteMessageSize; | ||
| 608 | string strIncompleteMessageCommand; | ||
| 599 | 609 | ||
| 600 | // Height this peer announced in its version message, or -1 if it sent a | 610 | // Height this peer announced in its version message, or -1 if it sent a |
| 601 | // version message that predates the field. Knowing how far along everyone | 611 | // version message that predates the field. Knowing how far along everyone |
| @@ -626,6 +636,9 @@ public: | |||
| 626 | nLastSend = 0; | 636 | nLastSend = 0; |
| 627 | nLastRecv = 0; | 637 | nLastRecv = 0; |
| 628 | nLastSendEmpty = GetTime(); | 638 | nLastSendEmpty = GetTime(); |
| 639 | nIncompleteMessageStart = 0; | ||
| 640 | nIncompleteMessageSize = 0; | ||
| 641 | strIncompleteMessageCommand.clear(); | ||
| 629 | nStartingHeight = -1; | 642 | nStartingHeight = -1; |
| 630 | vfSubscribe.assign(256, false); | 643 | vfSubscribe.assign(256, false); |
| 631 | 644 | ||
diff --git a/src/selftest.cpp b/src/selftest.cpp index 88e9863..c04adf4 100644 --- a/src/selftest.cpp +++ b/src/selftest.cpp | |||
| @@ -470,6 +470,67 @@ static int RunWalletHDSelfTest() | |||
| 470 | return nFail == 0 ? 0 : 1; | 470 | return nFail == 0 ? 0 : 1; |
| 471 | } | 471 | } |
| 472 | 472 | ||
| 473 | static int RunNetMessageSelfTest() | ||
| 474 | { | ||
| 475 | fflush(stdout); | ||
| 476 | printf("net-message self-test\n"); | ||
| 477 | |||
| 478 | int nFail = 0; | ||
| 479 | try | ||
| 480 | { | ||
| 481 | CNode complete(INVALID_SOCKET, CAddress("127.0.0.1")); | ||
| 482 | complete.nVersion = VERSION; | ||
| 483 | complete.vRecv << CMessageHeader("ping", 0); | ||
| 484 | nFail += Check(ProcessMessages(&complete), "a complete empty message processes") ? 0 : 1; | ||
| 485 | nFail += Check(complete.vRecv.empty(), "a complete message is consumed") ? 0 : 1; | ||
| 486 | nFail += Check(!complete.fDisconnect, "a valid ping does not disconnect the peer") ? 0 : 1; | ||
| 487 | |||
| 488 | CNode partial(INVALID_SOCKET, CAddress("127.0.0.1")); | ||
| 489 | partial.nVersion = VERSION; | ||
| 490 | partial.vRecv << CMessageHeader("block", 100); | ||
| 491 | unsigned int nPartialBefore = partial.vRecv.size(); | ||
| 492 | nFail += Check(ProcessMessages(&partial), "an incomplete message returns cleanly") ? 0 : 1; | ||
| 493 | nFail += Check(partial.vRecv.size() == nPartialBefore, | ||
| 494 | "an incomplete message keeps one header in the buffer") ? 0 : 1; | ||
| 495 | nFail += Check(partial.nIncompleteMessageStart != 0, | ||
| 496 | "an incomplete message starts a timeout clock") ? 0 : 1; | ||
| 497 | nFail += Check(!partial.fDisconnect, | ||
| 498 | "a fresh incomplete message does not disconnect immediately") ? 0 : 1; | ||
| 499 | |||
| 500 | CNode stale(INVALID_SOCKET, CAddress("127.0.0.1")); | ||
| 501 | stale.nVersion = VERSION; | ||
| 502 | stale.vRecv << CMessageHeader("block", 100); | ||
| 503 | stale.nIncompleteMessageStart = GetTime() - BTF_INCOMPLETE_MESSAGE_TIMEOUT_SECS - 1; | ||
| 504 | stale.nIncompleteMessageSize = 100; | ||
| 505 | stale.strIncompleteMessageCommand = "block"; | ||
| 506 | ProcessMessages(&stale); | ||
| 507 | nFail += Check(stale.fDisconnect, | ||
| 508 | "a stale incomplete message disconnects the peer") ? 0 : 1; | ||
| 509 | |||
| 510 | CNode oversized(INVALID_SOCKET, CAddress("127.0.0.1")); | ||
| 511 | oversized.nVersion = VERSION; | ||
| 512 | oversized.vRecv << CMessageHeader("block", MAX_PROTOCOL_MESSAGE_SIZE + 1); | ||
| 513 | ProcessMessages(&oversized); | ||
| 514 | nFail += Check(oversized.fDisconnect, | ||
| 515 | "an oversized message header disconnects the peer") ? 0 : 1; | ||
| 516 | } | ||
| 517 | catch (const std::exception& e) | ||
| 518 | { | ||
| 519 | printf(" FAIL exception: %s\n", e.what()); | ||
| 520 | nFail++; | ||
| 521 | } | ||
| 522 | catch (...) | ||
| 523 | { | ||
| 524 | printf(" FAIL unknown exception\n"); | ||
| 525 | nFail++; | ||
| 526 | } | ||
| 527 | |||
| 528 | printf("%s (%d failure%s)\n", nFail == 0 ? "ALL TESTS PASSED" : "TESTS FAILED", | ||
| 529 | nFail, nFail == 1 ? "" : "s"); | ||
| 530 | fflush(stdout); | ||
| 531 | return nFail == 0 ? 0 : 1; | ||
| 532 | } | ||
| 533 | |||
| 473 | int RunSelfTest(const std::string& name) | 534 | int RunSelfTest(const std::string& name) |
| 474 | { | 535 | { |
| 475 | AttachTerminal(); | 536 | AttachTerminal(); |
| @@ -478,8 +539,10 @@ int RunSelfTest(const std::string& name) | |||
| 478 | return RunWalletKeyPoolSelfTest(); | 539 | return RunWalletKeyPoolSelfTest(); |
| 479 | if (name == "wallet-hd") | 540 | if (name == "wallet-hd") |
| 480 | return RunWalletHDSelfTest(); | 541 | return RunWalletHDSelfTest(); |
| 542 | if (name == "net-message") | ||
| 543 | return RunNetMessageSelfTest(); | ||
| 481 | 544 | ||
| 482 | printf("Unknown self-test '%s'\n", name.c_str()); | 545 | printf("Unknown self-test '%s'\n", name.c_str()); |
| 483 | printf("Known self-tests: wallet-keypool, wallet-hd\n"); | 546 | printf("Known self-tests: wallet-keypool, wallet-hd, net-message\n"); |
| 484 | return 1; | 547 | return 1; |
| 485 | } | 548 | } |
diff --git a/src/walletcmd.cpp b/src/walletcmd.cpp index 8eb3592..fae2095 100644 --- a/src/walletcmd.cpp +++ b/src/walletcmd.cpp | |||
| @@ -25,6 +25,10 @@ | |||
| 25 | // would restore a wallet that looks empty. | 25 | // would restore a wallet that looks empty. |
| 26 | static const int RESTORE_BATCH = 100; | 26 | static const int RESTORE_BATCH = 100; |
| 27 | static const int RESTORE_MAX = 10000; | 27 | static const int RESTORE_MAX = 10000; |
| 28 | // The wallet may have a full unused key pool in front of a change address | ||
| 29 | // created while spending pre-phrase coins. Looking only one empty batch ahead | ||
| 30 | // can stop just before that change output. | ||
| 31 | static const int RESTORE_MIN_SCAN = KEYPOOL_SIZE + RESTORE_BATCH; | ||
| 28 | 32 | ||
| 29 | int CmdNewPhrase() | 33 | int CmdNewPhrase() |
| 30 | { | 34 | { |
| @@ -105,6 +109,7 @@ bool RestoreFromPhrase(const std::string& strMnemonic, | |||
| 105 | // up nothing. Every derived key is written to the wallet before the scan, | 109 | // up nothing. Every derived key is written to the wallet before the scan, |
| 106 | // because the scan asks the wallet what belongs to it. | 110 | // because the scan asks the wallet what belongs to it. |
| 107 | int nTotalDerived = (int)nHDNext; | 111 | int nTotalDerived = (int)nHDNext; |
| 112 | int nStopDepth = max(nMinDepth, RESTORE_MIN_SCAN); | ||
| 108 | while (nTotalDerived < RESTORE_MAX) | 113 | while (nTotalDerived < RESTORE_MAX) |
| 109 | { | 114 | { |
| 110 | // Count wallet transactions, not scan hits. | 115 | // Count wallet transactions, not scan hits. |
| @@ -146,7 +151,7 @@ bool RestoreFromPhrase(const std::string& strMnemonic, | |||
| 146 | 151 | ||
| 147 | // A whole batch with nothing in it means far enough -- unless the | 152 | // A whole batch with nothing in it means far enough -- unless the |
| 148 | // caller asked to look deeper anyway. | 153 | // caller asked to look deeper anyway. |
| 149 | if (nWalletAfter == nWalletBefore && nTotalDerived >= nMinDepth) | 154 | if (nWalletAfter == nWalletBefore && nTotalDerived >= nStopDepth) |
| 150 | break; | 155 | break; |
| 151 | } | 156 | } |
| 152 | 157 | ||