2 Commits
Author SHA1 Message Date
DanSandClaude Opus 5 5634aed750 wallet: open salvaged wallets in a degraded mode, and detect a mismatched hdchain
A wallet.dat repaired by v1.0.3-or-earlier -salvagewallet could not be opened by
this build at all. Those builds' IsKeyType has no "hdchain" case, so salvage
dropped the record; LoadWallet then returned DB_CORRUPT and told the user to
restore from a seed phrase. There is no such recovery path: the node aborts
before the RPC server exists, -usemnemonic defaulted to 0 in v1.0.3 so many of
these wallets never had a phrase, and SetHDSeedFromMnemonic refuses a non-empty
wallet, so "move wallet.dat aside" discards every non-HD key salvage preserved.

The guard itself was right to refuse to DERIVE -- a missing hdchain falls back to
SetNull defaults, clearing fMnemonicSeed and switching derivation from the
64-byte BIP39 seed to the raw 32-byte entropy, an entirely different key tree.
It was wrong to refuse to OPEN. Split the two:

  - "hdchain" record ABSENT (the salvage signature, tracked by a new
    wss.fHDChainSeen set before any parse attempt) -> open DEGRADED.
  - "hdchain" record PRESENT but unreadable -> still DB_CORRUPT; that signals
    wider file damage.

Degraded means the wallet spends, receives and rescans normally but refuses to
derive any new HD key. Gated at the single choke point GetHDSeedForDerivation
rather than at the two generators, so z_getnewaddress/sendmany/shieldcoinbase
raise a clean error and autoshield skips. hdChain.seedFp is deliberately left
null, which keeps IsHDTransparentEnabled false so getnewaddress falls back to the
legacy random-key path -- gating it instead would break TopUpKeyPool, change
addresses and block templates. -rescan is soft-set because an old salvage also
dropped defaultkey and bestblock, which would otherwise look like a first run and
skip the rescan, leaving a permanently zero balance.

NEW DETECTOR, covering strictly more damage than the guard beside it. A wallet
salvaged by v1.0.3 and then USED on v1.0.3 persists a SetNull-derived hdchain
carrying seedFp=null. On this build fHDChainRead is then true, the guard never
fires, the node starts perfectly clean -- and derives into the wrong key tree
forever, silently. That state is unforgeable by any legitimate writer, since
InstallHDSeed always stores seedFp = seed.Fingerprint(), so compare the loaded
chain's seedFp against the fingerprint taken from the hdseed/chdseed record KEY
(which works while an encrypted wallet is locked) and degrade on mismatch.

Also:
  - init.cpp: abort in the DB_CORRUPT branch itself, as DB_NEED_REWRITE already
    does. Falling through ran several hundred more lines against a wallet just
    declared corrupt, including SetHDSeedOrigin(), which WRITES to it. Error text
    no longer advises -mnemonic (wrong twice over, see above).
  - rpcdump.cpp: z_exportwallet discarded GetHDSeedForDerivation's return and
    emitted the line regardless, so a failure wrote a blank seed next to a
    legitimate-looking BLAKE2b-of-empty fingerprint -- a backup that looks valid
    and restores nothing.
  - Corrected the guard's comment: the saplingAccountCounter half was overstated.
    Both generators loop while(Have...Key(...)), so a low counter walks forward
    past existing accounts rather than colliding with them.

qa/r4-salvaged-wallet-harness.sh builds the victim wallets with the on-disk
v1.0.1 release binary and asserts the behaviour end to end. It REFUSES to run
outside a network namespace and re-checks getconnectioncount==0, because those
old binaries predate the regtest seed-injection fix and would otherwise dial the
live network. 11/11 pass:

  C healthy wallet opens and is NOT flagged   (no false positives)
  A salvaged wallet opens, flagged, persists no hdchain, z_getnewaddress refused,
    getnewaddress still works, z_exportwallet emits no bogus HDSeed line
  B poisoned wallet opens and the seedFp detector fires

Note the harness asserts "no hdchain synthesised", NOT "wallet.dat unchanged":
a control run showed normal startup rewrites wallet.dat for healthy wallets too.

This is the access/detection half. Proving fMnemonicSeed by re-deriving against
held keys, and repairing the record, is deliberately left out: it requires a
write, and the affected population is unmeasured. dev's own salvage already
preserves hdchain, so the class is closed going forward.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FU87LdsJZiZkfq1eXubpeo
2026-08-31 13:29:43 -05:00
DanSandClaude Opus 5 db42091ce3 rpc/net: gate and harden stratummine, keep regtest off the live network, init nSPV filter
stratummine (rpc/mining.cpp), a test-only reference miner, was registered
unconditionally behind a bare #ifndef WIN32 with okSafeMode=true, so it shipped
as a live RPC on every non-Windows release build. Four fixes:

  - Gated behind an explicit, default-off -stratummine, with regtest exempt so
    qa/rpc-tests can still drive it. Deliberately NOT gated on fExperimentalMode:
    that defaults to TRUE (init.cpp:1195), so such a gate is a no-op -- an error
    made and caught while testing this change.
  - mining.notify's three hash fields went straight into uint256(ParseHex(...)).
    uint256's vector ctor asserts on a wrong size (uint256.cpp:30) and NDEBUG is
    defined nowhere in this build, so that assert is live in release; ParseHex
    also truncates silently at the first non-hex character. A short or garbled
    field therefore ABORTED THE DAEMON. Added StratumHex256(), which requires 64
    hex chars and a 32-byte result, and all three fields are validated before any
    is committed so a bad job is rejected rather than half-applied.
  - processLine ran inside the window where the RandomX cache, the VM and the
    socket are live, all released on the normal path only, and every get_str()
    throws on a type mismatch -- so a malformed message leaked 256 MB and the fd.
    Body wrapped in try/catch: ignore the line, keep mining.
  - Caller-supplied timeout clamped to [1, 3600]; it was unbounded, pinning an
    RPC worker and the cache indefinitely. okSafeMode -> false.

Verified against a hostile stratum server on regtest:
  - 4 malformed mining.notify payloads (short hex, non-hex, wrong JSON type,
    31 bytes) -> all rejected, daemon alive, 0 assertions. The 31-byte case is
    the one that previously hit the uint256 assert.
  - valid job first (so RandomX actually allocates) then garbage mid-mine ->
    RSS delta +2.2 MB, i.e. cache and VM released, not the ~256 MB a leak leaves.
  - regtest exemption confirmed: stratummine runs past the gate there.
  The non-regtest refusal path is by code reading only -- a testnet node on this
  host collides with the production daemon's RPC port, so it was not exercised.

hush_utils.h: stop injecting the mainnet node1-node10.dragonx.is addnode seeds
when -regtest or -testnet is set. regtest reuses mainnet's network magic, so a
supposedly isolated node was handshaking production peers and pulling their
headers into its own index. Gated at the injection site only -- isdragonx itself
must stay true, because it also selects ac_private, ac_algo, blocktime and the
reward/halving schedule (an earlier version of this patch gated isdragonx itself
and silently turned ac_private off on regtest). hush_args() runs between
ParseParameters() and ReadConfigFile() (bitcoind.cpp:115/144/158), so this sees a
command-line -regtest, as qa/rpc-tests uses, but not a config-file regtest=1.

hush_nSPV_fullnode.h: initialize `filter` at both sites. It was assigned only on
the len-11 request form; the other two passed uninitialized stack memory to
NSPV_getaddressutxos/NSPV_getaddresstxids, remotely reachable since
HUSH_NSPV_FULLNODE is on by default.

getblocktemplate_proposals.py still passes.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FU87LdsJZiZkfq1eXubpeo
2026-08-31 02:38:38 -05:00
9 changed files with 273 additions and 31 deletions
+87
View File
@@ -0,0 +1,87 @@
#!/usr/bin/env bash
# r4 now-tier acceptance harness. MUST run inside a network namespace: the v1.0.x binaries
# predate the regtest seed-injection fix and would otherwise dial the live DragonX network.
set -u
OLD=/home/dev/dragonx/release/dragonx-1.0.1-linux-amd64
NEW=/home/dev/dragonx-dev/src
ROOT=/tmp/claude-1000/-home-dev/45a644d6-ea0b-4b7c-8ec7-6ccb1a64afa7/scratchpad/r4lab
PASS=0; FAIL=0
ok(){ echo " PASS $1"; PASS=$((PASS+1)); }
no(){ echo " FAIL $1"; FAIL=$((FAIL+1)); }
# --- hard guardrail: refuse to run unisolated ---
if [ "$(ip route show 2>/dev/null | wc -l)" != "0" ]; then
echo "REFUSING: not in an isolated netns (routes present). Run under: unshare -rn"; exit 90
fi
ip link set lo up 2>/dev/null
echo "isolation: $(ip route show | wc -l) routes, $(ip -o link | wc -l) interface(s)"
conf(){ printf 'regtest=1\nrpcuser=t\nrpcpassword=t\nlisten=0\ndnsseed=0\n' > "$1/DRAGONX.conf"; }
start(){ # $1=bindir $2=datadir $3=extra
"$1/dragonxd" -regtest -datadir="$2" -connect=0 -listen=0 -dnsseed=0 $3 -daemon >/dev/null 2>&1
for i in $(seq 40); do "$1/dragonx-cli" -regtest -datadir="$2" -rpcuser=t -rpcpassword=t getblockcount >/dev/null 2>&1 && return 0; sleep 2; done
return 1; }
cli(){ "$1/dragonx-cli" -regtest -datadir="$2" -rpcuser=t -rpcpassword=t "${@:3}" 2>&1; }
stopn(){ cli "$1" "$2" stop >/dev/null 2>&1; sleep 6; }
rm -rf "$ROOT"; mkdir -p "$ROOT"
echo; echo "### build victim wallets with the OLD binary (v1.0.1) ###"
mkdir -p "$ROOT/base/regtest"; conf "$ROOT/base/regtest"
start "$OLD" "$ROOT/base/regtest" "" || { echo "old node failed to start"; exit 91; }
[ "$(cli "$OLD" "$ROOT/base/regtest" getconnectioncount)" = "0" ] && ok "victim-maker has 0 peers (isolated)" || no "victim-maker NOT isolated -- ABORT"
cli "$OLD" "$ROOT/base/regtest" getnewaddress >/dev/null
cli "$OLD" "$ROOT/base/regtest" z_getnewaddress >/dev/null
ZBEFORE=$(cli "$OLD" "$ROOT/base/regtest" z_listaddresses | tr -d ' \n')
stopn "$OLD" "$ROOT/base/regtest"
for v in C A B; do cp -a "$ROOT/base" "$ROOT/$v"; done
# A = salvaged by v1.0.1 (drops hdchain). B = A then USED on v1.0.1 (persists a bogus chain).
start "$OLD" "$ROOT/A/regtest" "-salvagewallet" && stopn "$OLD" "$ROOT/A/regtest"
start "$OLD" "$ROOT/B/regtest" "-salvagewallet" && stopn "$OLD" "$ROOT/B/regtest"
start "$OLD" "$ROOT/B/regtest" "" && { cli "$OLD" "$ROOT/B/regtest" z_getnewaddress >/dev/null; stopn "$OLD" "$ROOT/B/regtest"; }
echo " A hdchain records: $(strings "$ROOT/A/regtest/regtest/wallet.dat" | grep -c hdchain) (expect 0)"
echo " B hdchain records: $(strings "$ROOT/B/regtest/regtest/wallet.dat" | grep -c hdchain) (expect >=1)"
echo; echo "### open each on the NEW binary ###"
for v in C A B; do
D="$ROOT/$v/regtest"; L="$D/regtest/debug.log"
WDAT="$D/regtest/wallet.dat"
[ -f "$WDAT" ] || { echo " FAIL $v: wallet.dat not found at $WDAT"; exit 92; }
# NOT a byte-identical check: normal startup (keypool top-up, bestblock) rewrites wallet.dat for
# ANY wallet, healthy ones included -- verified with a control. The precise claim is that the
# degraded path never SYNTHESISES an hdchain record, so count that instead.
HD1=$(strings "$WDAT" | grep -c hdchain)
: > "$L" 2>/dev/null
if start "$NEW" "$D" "-exportdir=$D/exp"; then
STARTED=yes; DEG=$(grep -c "DEGRADED" "$L" 2>/dev/null)
MISS=$(grep -c "hdchain record is missing" "$L" 2>/dev/null)
MISM=$(grep -c "does not belong to this wallet" "$L" 2>/dev/null)
if [ "$v" = "A" ]; then
mkdir -p "$D/exp"; EXP=$(cli "$NEW" "$D" z_exportwallet r4dump 2>&1 | head -1)
DUMP=$(find "$D" -name 'r4dump' 2>/dev/null | head -1)
ZNEW=$(cli "$NEW" "$D" z_getnewaddress); TNEW=$(cli "$NEW" "$D" getnewaddress)
fi
stopn "$NEW" "$D"
else STARTED=no; DEG=0; MISS=0; MISM=0; fi
HD2=$(strings "$WDAT" | grep -c hdchain)
case $v in
C) [ "$STARTED" = yes ] && ok "C healthy wallet opens" || no "C healthy wallet failed to open"
[ "$DEG" = "0" ] && ok "C no false positive (not flagged degraded)" || no "C FALSE POSITIVE: healthy wallet flagged" ;;
A) [ "$STARTED" = yes ] && ok "A salvaged wallet opens (was DB_CORRUPT before)" || no "A salvaged wallet still refuses to open"
[ "$MISS" -ge 1 ] && ok "A flagged: hdchain missing" || no "A not flagged as missing-hdchain"
[ "$HD1" = "0" ] && [ "$HD2" = "0" ] && ok "A no hdchain synthesised (degraded path persists nothing)" || no "A hdchain record appeared ($HD1 -> $HD2)"
echo "$ZNEW" | grep -qi 'error' && ok "A z_getnewaddress refused cleanly (derivation gated)" || no "A z_getnewaddress derived anyway: $ZNEW"
echo "$TNEW" | grep -qiE '^R[a-zA-Z0-9]+$' && ok "A getnewaddress still works (legacy random t-key)" || no "A getnewaddress broke: $TNEW"
if [ -n "${DUMP:-}" ] && [ -f "$DUMP" ]; then
grep -qE '^# HDSeed=[0-9a-f]' "$DUMP" && no "E z_exportwallet emitted an HDSeed line on a degraded wallet" || ok "E z_exportwallet emitted no bogus HDSeed line"
else echo " SKIP E (no dump produced: $EXP)"; fi ;;
B) [ "$STARTED" = yes ] && ok "B poisoned wallet opens" || no "B poisoned wallet failed to open"
[ "$MISM" -ge 1 ] && ok "B CASE-3 DETECTOR FIRED (seedFp mismatch)" || no "B case-3 detector did NOT fire" ;;
esac
done
echo; echo "### $PASS passed, $FAIL failed ###"
exit $FAIL
+2 -2
View File
@@ -660,7 +660,7 @@ void hush_nSPVreq(CNode *pfrom,std::vector<uint8_t> request) // received a reque
struct NSPV_utxosresp U;
if ( len >= 2 && len < 64+5 && request[1] < 64 && (request[1] == len-3 || request[1] == len-7 || request[1] == len-11) )
{
int32_t skipcount = 0; char coinaddr[64]; uint32_t filter; uint8_t isCC = 0;
int32_t skipcount = 0; char coinaddr[64]; uint32_t filter = 0; uint8_t isCC = 0; // only assigned on the len-11 form; the other two passed it on uninitialized
memcpy(coinaddr,&request[2],request[1]); // request[1] < 64 bounds the copy + the terminator write
coinaddr[request[1]] = 0;
if ( request[1] == len-3 )
@@ -698,7 +698,7 @@ void hush_nSPVreq(CNode *pfrom,std::vector<uint8_t> request) // received a reque
struct NSPV_txidsresp T;
if ( len >= 2 && len < 64+5 && request[1] < 64 && (request[1] == len-3 || request[1] == len-7 || request[1] == len-11) )
{
int32_t skipcount = 0; char coinaddr[64]; uint32_t filter; uint8_t isCC = 0;
int32_t skipcount = 0; char coinaddr[64]; uint32_t filter = 0; uint8_t isCC = 0; // only assigned on the len-11 form; the other two passed it on uninitialized
memcpy(coinaddr,&request[2],request[1]); // request[1] < 64 bounds the copy + the terminator write
coinaddr[request[1]] = 0;
if ( request[1] == len-3 )
+13 -3
View File
@@ -1783,11 +1783,21 @@ void hush_args(char *argv0)
fprintf(stderr,".oO Starting %s Full Node (Extreme Privacy!) with genproc=%d notary=%d\n",name.c_str(),HUSH_MININGTHREADS, IS_HUSH_NOTARY);
vector<string> DRAGONX_nodes = {};
// Only DRAGONX connects to these by default, other chains must opt-in via -connect/-addnode
// Only DRAGONX connects to these by default, other chains must opt-in via -connect/-addnode.
// Never on regtest or testnet: regtest reuses mainnet's network magic, so injecting the mainnet
// seeds here makes a supposedly isolated node handshake production peers and pull their headers
// into its own index -- which is exactly what it did, and why multi-node rpc-tests were talking
// to the live chain. NOTE: hush_args() runs between ParseParameters() and ReadConfigFile()
// (bitcoind.cpp:115/144/158), so this sees a command-line -regtest (how qa/rpc-tests starts
// nodes) but NOT a bare "regtest=1" in the config file.
const bool isdragonx = strncmp(name.c_str(), "DRAGONX",7) == 0 ? true : false;
LogPrint("net", "%s: isdragonx=%d\n", __func__, isdragonx);
if (isdragonx) {
// Seed injection only -- isdragonx itself must stay true here, because it also selects
// ac_private, ac_algo, blocktime and the reward/halving schedule below.
const bool isnotmainnet = GetBoolArg("-regtest", false) || GetBoolArg("-testnet", false);
LogPrint("net", "%s: isdragonx=%d isnotmainnet=%d\n", __func__, isdragonx, isnotmainnet);
if (isdragonx && !isnotmainnet) {
// node8-node10 are PLACEHOLDERS with no DNS records yet. A hostname that
// does not resolve is harmless here: ThreadOpenAddedConnections just fails
// to open the connection and retries on its 2-minute cycle. Reserving the
+16 -4
View File
@@ -2260,10 +2260,22 @@ bool AppInit2(boost::thread_group& threadGroup, CScheduler& scheduler)
if (nLoadWalletRet != DB_LOAD_OK)
{
if (nLoadWalletRet == DB_CORRUPT)
strErrors << _("Error loading wallet.dat: Wallet corrupted. If this wallet was last opened "
"by an older version, move wallet.dat aside and restore from your seed "
"phrase with -mnemonic=\"<your seed phrase>\" -rescan (see debug.log for "
"the specific record at fault).") << "\n";
{
// Abort HERE, as the DB_NEED_REWRITE branch below already does. Falling through
// runs several hundred more lines of initialisation against a wallet we have just
// declared corrupt -- including SetHDSeedOrigin(), which WRITES to it, and the
// rescan and SetBestChain that follow.
//
// The old text advised restoring with -mnemonic. That is wrong twice over:
// -usemnemonic defaulted to 0 in v1.0.3 so many such wallets never had a phrase,
// and SetHDSeedFromMnemonic refuses a non-empty wallet, so "move wallet.dat aside"
// would discard every non-HD key the salvage preserved.
strErrors << _("Error loading wallet.dat: the wallet database is corrupt. Your keys may "
"still be intact -- do NOT delete or replace wallet.dat. Back it up now, "
"and see debug.log for the specific record at fault.") << "\n";
LogPrintf("%s", strErrors.str());
return InitError(strErrors.str());
}
else if (nLoadWalletRet == DB_NONCRITICAL_ERROR)
{
string msg(_("Warning: error reading wallet.dat! All keys read correctly, but transaction data"
+55 -5
View File
@@ -1112,6 +1112,27 @@ static bool StratumMinerRecvLines(int fd, std::string& buf, int timeout_ms, std:
// authorize, receive work + the per-height RandomX key, then vary the block nNonce, hash with
// RandomX (byte-identical to CheckRandomXSolution via GetRandomXInput), and submit a 32-byte
// solution when the block hash meets target. Exists to validate the -stratum RandomX pool path.
//! Upper bound on how long stratummine will hold an RPC worker thread (and a 256 MB RandomX
//! cache). An unbounded caller-supplied deadline pins both indefinitely.
static const int64_t MAX_STRATUMMINE_TIMEOUT = 3600;
//! Parse a 64-char hex field from mining.notify into a uint256.
//! These fields come from whatever host the operator pointed us at, and both primitives below are
//! unforgiving: uint256's vector constructor asserts on a wrong-size input (uint256.cpp:30, and
//! NDEBUG is never defined for this build so the assert is live in release), while ParseHex
//! silently truncates at the first non-hex character. A short or garbled field would therefore
//! abort the daemon rather than be rejected. Returns false instead.
static bool StratumHex256(const std::string& hex, uint256& out)
{
if (hex.size() != 64 || !IsHex(hex))
return false;
std::vector<unsigned char> v = ParseHex(hex);
if (v.size() != 32)
return false;
out = uint256(v);
return true;
}
UniValue stratummine(const UniValue& params, bool fHelp, const CPubKey& mypk)
{
if (fHelp || params.size() < 2 || params.size() > 4)
@@ -1129,10 +1150,24 @@ UniValue stratummine(const UniValue& params, bool fHelp, const CPubKey& mypk)
if (ASSETCHAINS_ALGO != ASSETCHAINS_RANDOMX)
throw JSONRPCError(RPC_MISC_ERROR, "stratummine only supports RandomX chains");
// This is a reference miner for exercising -stratum, not a production facility: it dials an
// operator-supplied host, blocks an RPC worker for the whole run, and parses whatever that host
// chooses to send back. Require an explicit opt-in, and exempt regtest so the test suite can
// still drive it. NOTE: do NOT gate this on fExperimentalMode -- that defaults to TRUE
// (init.cpp:1195), so it would leave the RPC exposed on every node and the gate would be a no-op.
if (!GetBoolArg("-stratummine", false) && Params().NetworkIDString() != "regtest")
throw JSONRPCError(RPC_MISC_ERROR,
"stratummine is a test-only reference miner and is disabled by default; "
"restart with -stratummine to enable it");
const std::string host = params[0].get_str();
const int port = params[1].get_int();
const std::string addr = params.size() > 2 ? params[2].get_str() : "x";
const int64_t timeout = params.size() > 3 ? params[3].get_int64() : 120;
int64_t timeout = params.size() > 3 ? params[3].get_int64() : 120;
if (timeout < 1)
timeout = 1;
if (timeout > MAX_STRATUMMINE_TIMEOUT)
timeout = MAX_STRATUMMINE_TIMEOUT;
const int64_t deadline = GetTime() + timeout;
// connect (blocking TCP)
@@ -1166,6 +1201,11 @@ UniValue stratummine(const UniValue& params, bool fHelp, const CPubKey& mypk)
uint256 hashPrevBlock, hashMerkleRoot, hashReserved;
auto processLine = [&](const std::string& line) {
// Every get_str()/get_int() below throws on a type mismatch, and this lambda runs inside the
// window where the RandomX cache and VM are allocated and the socket is open -- all of which
// are released only on the normal path. A malformed server message must therefore never
// escape from here, or it leaks 256 MB and the fd on its way out.
try {
UniValue v;
if (!v.read(line)) return;
const UniValue& id = find_value(v, "id");
@@ -1185,16 +1225,26 @@ UniValue stratummine(const UniValue& params, bool fHelp, const CPubKey& mypk)
poolTarget = UintToArith256(uint256S(p[0].get_str()));
haveTarget = true;
} else if (m == "mining.notify" && p.size() >= 7) {
// Validate every fixed-width field before committing any of it, so a malformed job is
// ignored outright rather than half-applied over the previous one.
uint256 prev, merkle, reserved;
if (!StratumHex256(p[2].get_str(), prev) ||
!StratumHex256(p[3].get_str(), merkle) ||
!StratumHex256(p[4].get_str(), reserved))
return;
jobId = p[0].get_str();
nVersion = bswap_32((uint32_t)strtoul(p[1].get_str().c_str(), NULL, 16));
hashPrevBlock = uint256(ParseHex(p[2].get_str()));
hashMerkleRoot = uint256(ParseHex(p[3].get_str()));
hashReserved = uint256(ParseHex(p[4].get_str()));
hashPrevBlock = prev;
hashMerkleRoot = merkle;
hashReserved = reserved;
timeHex = p[5].get_str();
nTime = bswap_32((uint32_t)strtoul(timeHex.c_str(), NULL, 16));
nBits = bswap_32((uint32_t)strtoul(p[6].get_str().c_str(), NULL, 16));
haveJob = true;
}
} catch (const std::exception&) {
// Malformed message from the server: ignore the line and keep mining.
}
};
std::string buf;
@@ -1302,7 +1352,7 @@ static const CRPCCommand commands[] =
{ // category name actor (function) okSafeMode
// --------------------- ------------------------ ----------------------- ----------
#ifndef WIN32
{ "mining", "stratummine", &stratummine, true },
{ "mining", "stratummine", &stratummine, false },
#endif
{ "mining", "getlocalsolps", &getlocalsolps, true },
{ "mining", "getnetworksolps", &getnetworksolps, true },
+11 -4
View File
@@ -746,10 +746,17 @@ UniValue dumpwallet_impl(const UniValue& params, bool fHelp, bool fDumpZKeys)
HDSeed hdSeed;
// Dump the 64-byte derivation seed (for mnemonic wallets this is the
// expanded BIP39 seed), so re-importing the hex reproduces the same keys.
pwalletMain->GetHDSeedForDerivation(hdSeed);
auto rawSeed = hdSeed.RawSeed();
file << strprintf("# HDSeed=%s fingerprint=%s", HexStr(rawSeed.begin(), rawSeed.end()), hdSeed.Fingerprint().GetHex());
file << "\n";
// The return MUST be checked: on failure hdSeed is default-constructed, and emitting it
// anyway writes a blank seed next to a legitimate-looking BLAKE2b-of-empty fingerprint --
// a backup that looks valid and restores nothing. The per-key dump below is still a
// complete backup without this line.
if (pwalletMain->GetHDSeedForDerivation(hdSeed)) {
auto rawSeed = hdSeed.RawSeed();
file << strprintf("# HDSeed=%s fingerprint=%s", HexStr(rawSeed.begin(), rawSeed.end()), hdSeed.Fingerprint().GetHex());
file << "\n";
} else {
file << "# HDSeed unavailable (wallet locked, no HD seed, or hdchain unproven)\n";
}
}
file << "\n";
for (std::vector<std::pair<int64_t, CKeyID> >::const_iterator it = vKeyBirth.begin(); it != vKeyBirth.end(); it++) {
+9
View File
@@ -2813,6 +2813,15 @@ bool CWallet::SetHDSeedFromMnemonic(const std::string& phrase)
bool CWallet::GetHDSeedForDerivation(HDSeed& seedOut) const
{
// Single choke point for every HD derivation in the wallet. When the hdchain record could not
// be trusted at load time we do not know whether fMnemonicSeed should be true, and guessing
// wrong derives into an entirely different key tree -- so refuse rather than guess. Callers
// surface this as a clean error (z_getnewaddress, sendmany, shieldcoinbase) or skip
// (autoshield). Transparent address generation falls back to the legacy random-key path,
// because hdChain.seedFp stays null and so IsHDTransparentEnabled() is false.
if (fHDChainUnproven)
return false;
HDSeed stored;
if (!GetHDSeed(stored))
return false;
+11
View File
@@ -961,6 +961,11 @@ protected:
/* the hd chain data model (chain counters) */
CHDChain hdChain;
//! Set at load time when hdChain cannot be trusted to describe this wallet's HD seed -- the
//! record was missing, or its seedFp does not match the seed actually loaded. While true the
//! wallet is fully usable for existing keys but refuses to DERIVE new ones, because it cannot
//! tell which key tree it belongs to. Never serialized; recomputed on every load.
bool fHDChainUnproven = false;
public:
/*
@@ -1415,6 +1420,12 @@ public:
void SetHDChain(const CHDChain& chain, bool memonly);
const CHDChain& GetHDChain() const { return hdChain; }
//! Mark hdChain as untrustworthy for derivation (see the member's declaration). Set only by
//! CWalletDB::LoadWallet; there is deliberately no way to clear it short of reloading, so a
//! degraded wallet cannot be talked back into deriving without a real repair.
void SetHDChainUnproven() { fHDChainUnproven = true; }
bool IsHDChainUnproven() const { return fHDChainUnproven; }
/* Record (in memory and in wallet.dat) how this wallet's HD seed came to
exist. Best-effort: a failed write is logged, not fatal — the next start
simply re-classifies, and re-classification always errs toward
+69 -13
View File
@@ -421,6 +421,15 @@ public:
// True when that record had to be repaired on read (see the "hdchain" case
// in ReadKeyValue); LoadWallet rewrites it in full form afterwards.
bool fHDChainRepaired;
// True once an "hdchain" record was ENCOUNTERED, whether or not it parsed. This is what
// separates the two failure shapes: a v1.0.3-or-earlier -salvagewallet drops the record
// entirely (its IsKeyType has no "hdchain" case), so absent == salvaged and recoverable,
// while present-but-unreadable means wider file damage.
bool fHDChainSeen;
// Fingerprint taken from the KEY of the hdseed/chdseed record. Available even for an
// encrypted wallet, where the seed itself cannot be read at load time.
bool fHDSeedSeen;
uint256 hdSeedFpSeen;
CWalletScanState() {
nKeys = nCKeys = nKeyMeta = nZKeys = nCZKeys = nZKeyMeta = nSapZAddrs = 0;
@@ -429,6 +438,8 @@ public:
nFileVersion = 0;
fHDChainRead = false;
fHDChainRepaired = false;
fHDChainSeen = false;
fHDSeedSeen = false;
}
};
@@ -836,6 +847,8 @@ ReadKeyValue(CWallet* pwallet, CDataStream& ssKey, CDataStream& ssValue,
strErr = "Error reading wallet database: LoadHDSeed failed";
return false;
}
wss.fHDSeedSeen = true;
wss.hdSeedFpSeen = seedFp;
}
else if (strType == "chdseed")
{
@@ -849,9 +862,15 @@ ReadKeyValue(CWallet* pwallet, CDataStream& ssKey, CDataStream& ssValue,
return false;
}
wss.fIsEncrypted = true;
// The fingerprint is the plaintext KEY of the chdseed record, so this works while
// the wallet is locked and the seed itself is unreadable.
wss.fHDSeedSeen = true;
wss.hdSeedFpSeen = seedFp;
}
else if (strType == "hdchain")
{
// Record the ENCOUNTER before any parsing can fail.
wss.fHDChainSeen = true;
CHDChain chain;
// Keep an untouched copy: a failed >> has already consumed part of ssValue.
CDataStream ssRetry(ssValue.begin(), ssValue.end(), ssValue.GetType(), ssValue.GetVersion());
@@ -1046,23 +1065,60 @@ DBErrors CWalletDB::LoadWallet(CWallet* pwallet)
}
}
// A wallet that holds an HD seed but whose hdchain record is missing or
// unreadable is NOT safe to run. hdChain would fall back to its SetNull
// defaults (walletdb.h:105-113), which (a) clears fMnemonicSeed, switching
// HD derivation from the 64-byte BIP39 seed to the raw 32-byte entropy
// (CWallet::GetHDSeedForDerivation, wallet.cpp:2615-2633) -> an entirely
// different key tree, and (b) resets saplingAccountCounter to 0, so the
// next GenerateNewSaplingZKey walks back over accounts that already exist.
// Both are silent today (a bad hdchain read is only DB_NONCRITICAL_ERROR).
// Fail loud instead of quietly deriving into the wrong tree.
if (pwallet->HaveHDSeed() && !wss.fHDChainRead)
// A wallet that holds an HD seed but no usable hdchain record cannot safely DERIVE: hdChain
// falls back to its SetNull defaults, clearing fMnemonicSeed and so switching derivation from
// the 64-byte BIP39 seed to the raw 32-byte entropy -- an entirely different key tree.
//
// saplingAccountCounter was previously listed here as a second hazard. It is not one:
// GenerateNewSaplingZKey loops `do {...} while (HaveSaplingSpendingKey(...))` and
// DeriveNewChildKey loops `while (HaveKey(...))`, so a counter that starts low walks forward
// past accounts that already exist rather than colliding with them.
//
// Two shapes reach here and only one is real corruption:
// (a) the record is ABSENT -- the signature of a -salvagewallet run by v1.0.3 or earlier,
// whose IsKeyType has no "hdchain" case, so salvage dropped it. The keys are intact.
// Refusing strands the wallet with no way back: the node aborts before the RPC server
// exists, and -mnemonic refuses a non-empty wallet, so there is no user-executable
// recovery path at all.
// (b) the record is PRESENT but unreadable -- wider file damage. Keep refusing.
if (pwallet->HaveHDSeed() && !wss.fHDChainRead && wss.fHDChainSeen)
{
LogPrintf("Error loading wallet.dat: HD seed present but the hdchain record is missing or corrupt. "
"Recover by restoring from the seed phrase: move wallet.dat aside and start with "
"-mnemonic=\"<your seed phrase>\" -rescan\n");
LogPrintf("Error loading wallet.dat: the hdchain record is present but unreadable. Your keys "
"are intact -- do NOT delete or replace wallet.dat. Back it up and see debug.log.\n");
return DB_CORRUPT;
}
{
const char* pszDegraded = NULL;
if (pwallet->HaveHDSeed() && !wss.fHDChainRead)
{
pszDegraded = "the hdchain record is missing (an older -salvagewallet drops it)";
}
// A chain WAS read, but it does not belong to the seed we loaded. No legitimate writer can
// produce that -- InstallHDSeed always stores seedFp = seed.Fingerprint(). It is the mark
// of a wallet that lost its hdchain to an old salvage and was then USED on that old build,
// which persists a SetNull-derived chain carrying a null seedFp. Such a wallet otherwise
// starts up perfectly clean and derives into the WRONG TREE forever, silently -- strictly
// worse than failing to open, which is why it is worth detecting here.
else if (pwallet->HaveHDSeed() && wss.fHDSeedSeen &&
pwallet->GetHDChain().seedFp != wss.hdSeedFpSeen)
{
pszDegraded = "the hdchain record does not belong to this wallet's HD seed";
}
if (pszDegraded != NULL)
{
pwallet->SetHDChainUnproven();
LogPrintf("Wallet opened in DEGRADED mode: %s. Existing keys are intact, spendable and "
"receivable, but no NEW HD-derived key can be generated and new transparent "
"addresses will not be recoverable from a seed phrase. Back up wallet.dat now "
"and do NOT delete or replace it.\n", pszDegraded);
// An old salvage also dropped defaultkey and bestblock, which would make this look like
// a first run and skip the rescan, leaving a permanently zero balance.
SoftSetBoolArg("-rescan", true);
}
}
// Any wallet corruption at all: skip any rewriting or
// upgrading, we don't want to make it worse.
if (result != DB_LOAD_OK)