12 Commits

Author SHA1 Message Date
4dc57e80b1 build: bump version to 1.2.0
dev and the v1.1.0 tag were version-indistinguishable: both reported
CLIENT_VERSION 1010050, subversion "/DragonX:1.1.0/" and IS_RELEASE=true, because
660678f9b was the last commit to touch a version file and it predates the tag. The
twenty commits since were therefore invisible to every channel a client can query,
and the in-app updater compares exactly those. Only git-describe distinguished
them, and that degrades to "-unk" on a tarball build.

A minor bump rather than a patch: since v1.1.0 the tree gained auto-shield-coinbase
(a new feature, on by default where the seed is known-recoverable), BIP39 seed
phrases as the default for new wallets, the z_autoshieldstatus RPC, and three new
wallet.dat record types. Understating that as 1.1.1 would hide an on-disk format
change from the one place users look.

The published v1.1.0 tag is left where it is. Re-pointing a tag that is already on
the remote breaks anyone who fetched it.

The wallet feature version deliberately stays at FEATURE_LATEST = 60000. The new
records are additive and older binaries skip unknown types harmlessly, while
bumping it would make them refuse the wallet outright with DB_TOO_NEW. The one real
incompatibility, a truncated hdchain record, is self-healing as of a0ccb4be1, so
refusing to load would be strictly worse for the user than what happens today.

Verified: configure.ac and clientversion.h agree; CLIENT_VERSION 1010050 -> 1020050;
build.sh derives 1.2.0; bitcoin-config.h carries CLIENT_VERSION_MINOR 2 after a
reconfigure run with the depends CONFIG_SITE; a full rebuild of src succeeds with 0
errors and both binaries report v1.2.0.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-25 21:52:51 +02:00
04ac7c1186 doc: how to build release binaries in containers, for several glibc floors
Binaries built on Ubuntu 22.04 require GLIBC_2.34 and GLIBCXX_3.4.30 and will not
start on Ubuntu 20.04 -- which is four of our five seeds, and an unknown share of
users. The binary the fleet actually runs today needs only GLIBC_2.29, so it was
built somewhere older; seed 176 has since been upgraded to 22.04 and now produces
binaries it is the only seed able to run.

--linux-compat and Dockerfile.compat already solved this (6d56ad854) but were
undocumented outside the build script and pinned to one base image. Parameterise
the base via ARG BASE_IMAGE (default unchanged, so --linux-compat behaves exactly
as before) and document the whole path.

doc/build-containers.md is written to be executed by a person or an agent starting
from a machine with nothing installed: why the glibc direction matters, with the
measured numbers; what already exists in the repo so nobody writes a second build
system; prerequisites and honest cost (~15GB, 4GB RAM, 1-2h per base because
depends/ builds boost, BDB, wolfssl and rust from source); one-target and
multi-target recipes; which base to pick and why 20.04 is the recommended floor
while 18.04 needs verifying (GCC 7 against -std=c++17); a mandatory verification
step with the exact objdump/readelf commands and the expected ceilings; and the
traps.

The traps are the part worth having written down: ETXTBSY when installing over a
running daemon (cp fails even after the process exits -- stage and rename, then
sha256-verify before starting); never touching configure.ac in a configured tree,
because the mtime alone triggers a reconfigure that dies on libdb_cxx; never
blind-touching a path that may not exist, which silently creates stray empty files;
RandomX needing ARCH=default or it emits AVX-512 that SIGILLs the fleet; build-win.sh
silently discarding every argument; and macOS being uncontainerisable because
depends/ has no darwin cross path at all.

Also records that full static linking is NOT the answer here: the daemon resolves
node1..node5.dragonx.is via getaddrinfo, and static glibc pushes that through NSS,
which dlopens libnss_dns at runtime and reintroduces the dependency it was meant to
remove.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-25 21:28:12 +02:00
65130c3120 async/wallet: close the loose ends around automated operations
Six small defects, all found by an audit of the automated-operation path and all
verified present before changing anything.

AsyncRPCQueue::addOperation returned void and silently dropped the operation when
the queue was closed or finishing. Every caller assumed success: the three
schedulers left their running flag set with nothing in flight (which then blocks
every later round), and z_sendmany / z_shieldcoinbase / z_mergetoaddress returned
an opid for work that would never run -- z_shieldcoinbase and z_mergetoaddress
having already locked their selected coins in the constructor. It now returns
bool; the schedulers release the flag and log, and the three RPCs raise an error
instead of handing back an opid. Coin locks are memory-only, so a refusal at
shutdown reclaims them with the process; the lie about success was the defect.

Nothing ever removed finished automated operations from the queue's map.
popOperationForId is reached only from z_getoperationresult, so on a node running
autoshield every 25 blocks the map grew by one entry per round forever. The
schedulers now pop the operation they just cancelled. The worker already handles
a missing id ("cannot find operation in map, may have been removed",
asyncrpcqueue.cpp), and it releases lock_ before calling main(), so popping under
cs_wallet introduces no lock cycle.

The autoshield operation built its transaction against targetHeight_, the
enqueue-time height, while SetExpiryHeight and the network-upgrade straddle guard
both used tipHeight. Since the builder's height selects the consensus branch id,
the guard was checking a height the transaction was not signed against -- it could
not prevent the failure it exists to prevent. Now tipHeight throughout.

cancel() in the sweep, consolidation and autoshield operations set CANCELLED
unconditionally, dropping the base class's guard entirely. The schedulers cancel
the previous operation when they enqueue the next, so a round that had already
SUCCEEDED got its result relabelled as cancelled. Restored a narrower guard: still
cancellable while READY or EXECUTING (the base class refuses the latter, which
would defeat cancellation here), but a terminal state is left alone.

CommitAutomatedTx dumped the whole transaction to stderr on every commit,
duplicating the LogPrintf that CommitTransaction does one call later. ToString()
emits a line per input, so with the 400-input autoshield cap that was tens of KB
of stderr per round. Removed.

Also corrected a comment that credited the immature-coinbase exclusion to
fOnlySpendable (the argument is fOnlyConfirmed; the exclusion is unconditional in
AvailableCoins), and noted that AUTOSHIELD_CTXIN_P2SH_SIZE is a byte size that
merely happens to share the value 400 with the input cap.

Verified on an isolated regtest chain, 8/8: nine consecutive autoshield rounds
succeed after the builder-height change; the operation map stays at 1 entry across
all nine (it grew one per round before); stderr totals 1608 bytes for the whole
run with zero CommitAutomatedTx dumps, while debug.log still records all nine
commits via CommitTransaction; no round is relabelled cancelled; funds shield
correctly and no coin locks leak.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-25 19:18:30 +02:00
698bcf9574 build: derive the release version from configure.ac
build.sh hardcoded VERSION="1.0.3" while configure.ac had been at 1.1.0 since
660678f9b. package_release() uses it to name the output directory, so
`./build.sh --all-release` from dev would have emitted
release/dragonx-1.0.3-<platform>/ containing binaries that report 1.1.0 --
mislabelled artifacts, from the one place where the label is what users see.

Read the four _CLIENT_VERSION_* defines out of configure.ac instead, applying the
same suffix rule its _CLIENT_VERSION_SUFFIX m4 uses (build < 25 -> beta, < 50 ->
rc, == 50 -> plain, > 50 -> point release), and abort if any of them cannot be
parsed rather than naming a release directory after an empty string.

SCRIPT_DIR moves above the version block because the lookup needs it.

Verified: derives 1.1.0 from the current tree, and a deliberately unparseable
configure.ac makes it exit 1 with a message instead of guessing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-25 18:56:18 +02:00
a0ccb4be1d wallet: repair a truncated hdchain record instead of refusing the wallet
A build predating VERSION_HD_TRANSPARENT rewrites the hdchain record with only
its four base fields while leaving nVersion at whatever it read. Our version-gated
reads then run off the end of the stream, ReadKeyValue turns the throw into
fHDChainRead=false, and LoadWallet escalates that to DB_CORRUPT. Verified against
the real v1.0.2-2b011d6ee release binary: a dev wallet, opened once by v1.0.2 and
given a single new sapling address, came back to

  Error reading wallet database: hdchain record is corrupt
  Error loading wallet.dat: HD seed present but the hdchain record is missing or corrupt
  Error loading wallet.dat: Wallet corrupted

Nothing is actually lost there -- the same test showed the seed phrase restoring
the full balance, the autoshield destination, and even the address v1.0.2 had
generated -- but the user is shown "Wallet corrupted" with no hint of that.

Recover instead, for a v1 or v2 record. Everything derivation depends on is either
in the four-field prefix or in the separate hdseed record, and the trailing
counters are self-healing: DeriveNewChildKey and GenerateNewSaplingZKey both skip
indices whose key the wallet already holds, so restarting a counter at 0 re-walks
past existing keys rather than reissuing them. Read the prefix from an untouched
copy of the stream, default the missing tail, log it, and rewrite the record in
full form so the next load is clean.

A record claiming nVersion >= VERSION_HD_MNEMONIC still fails loud: that flag
selects the derivation input, so guessing it wrong yields a different key tree in
silence. No wallet this code has written can be in that state -- every
InstallHDSeed call site passes fMnemonic=false -- so the branch is defensive only.

Also say what to do about it. Both the log line and the init error now name the
remedy (move wallet.dat aside, restart with -mnemonic and -rescan) rather than
stopping at "Wallet corrupted".

Verified on regtest against the real v1.0.2 binary, 14/14: the round-trip that
previously ended in "Wallet corrupted" now loads, logs the repair and the rewrite,
keeps the balance, the autoshield destination and v1.0.2's own address, still
issues distinct fresh t-addresses after the counter reset, needs no repair on the
second load, and remains fully recoverable from the seed phrase.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-25 18:18:38 +02:00
358011bd54 wallet: resolve the autoshield destination at startup
pwalletMain->autoShieldAddress was only ever written by a round running in the
current process, so on any node without an explicit -autoshieldaddress,
z_autoshieldstatus reported an empty destination from startup until the first
round fired. disabled_reason was empty on that path too (rpcwallet.cpp), so the
RPC showed autoshield true, running false, no address and no explanation -- the
exact silent state z_autoshieldstatus was added to eliminate. On a restored
wallet, which pre-derives the whole -mnemonicsaplinggap window and therefore
always has an in-gap account to pick, the answer was known at startup and simply
not computed.

Split the read-only half of resolveDestination into a free function shared with
init: the configured override if set, else the lowest in-gap account
m/32'/coin'/i' the wallet already holds. init calls it once when autoshield is
enabled and no explicit address was given.

It deliberately does not generate a key. Deriving a fresh sapling account as a
side effect of populating a status field would mutate the wallet to make an RPC
prettier, so step 3 of resolveDestination -- the generation path, which must stay
inside the operation where an unlocked wallet is already established -- is left
where it was. A brand-new wallet holds nothing in-gap, so the field stays empty
there and disabled_reason now says why instead of being blank.

Behaviour is otherwise unchanged: same derivation, same lowest-index-wins rule,
same refusal to trust CKeyMetadata, same caching for the life of the process.

Verified on an isolated regtest chain, 12/12:
  fresh wallet      -> address "", reason "no destination resolved yet; one will
                       be derived from the HD seed on the first round"
  after one round   -> address set, reason empty
  after RESTART     -> address visible with NO block mined since (height 12 both
                       sides), and z_listaddresses still holds exactly 1 address,
                       so init derived nothing
  -autoshieldaddress-> still overrides the derived destination, and the round
                       shields into it

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-25 17:41:32 +02:00
7a62fc4877 wallet: break the sweep/consolidation/autoshield deadlock
A successful-but-incomplete sweep round deliberately returns with fSweepRunning
still set and nextSweep unadvanced, as a "keep draining next block" baton. Every
early return in RunSaplingSweep, though, leaves that baton set without
re-dispatching -- and RunSaplingConsolidation, which is gated on fSweepRunning,
then returns without advancing nextConsolidation. So the "consolidation is
within 5 blocks" blackout at the top of RunSaplingSweep never lifts: sweep waits
on consolidation, consolidation waits on sweep, and neither runs again.

That much is pre-existing. What is new is that autoshield now shares the gate --
RunAutoShieldCoinbase returns early on fSweepRunning || fConsolidationRunning --
so a wedged sweep silently disables coinbase shielding too, with
z_autoshieldstatus reporting autoshield true, running false, and no reason.

Only honour the baton while a sweep operation is genuinely in flight: if the
operation for saplingSweepOperationId is absent or has reached a terminal state,
drop the stale flag and let the checks below decide afresh. The drain model is
unchanged -- nextSweep is still unadvanced, so the next block re-dispatches.

Also report the deferral in z_autoshieldstatus, so mutual exclusion with sweep
or consolidation reads as a deferral rather than an unexplained idle.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-25 07:59:13 +02:00
9a8f17b2c8 wallet: bound autoshield rounds and lock their inputs
Two defects in a single autoshield round, both of the quiet kind.

The size estimate reserved a flat 2000 bytes for "header + sietch outputs", but
every autoshield tx carries three Sapling OutputDescriptions -- the change note
plus the two Sietch dummies -- and the real fixed cost is ~2937 bytes. Measured
across seven live mainnet coinbase shields: 113.5 bytes per input and 2936.6
+/- 0.8 bytes fixed, of which 3 * 948 = 2844 is the output descriptions. The
estimate was therefore short by ~937 bytes before a single input was counted.

Inputs are charged AUTOSHIELD_CTXIN_DUST_SIZE = 148, which is conservative for
the default P2PK coinbase but exact for P2PKH, so with a P2PKH coinbase a
backlog of 1332..1337 utxos passed the estimate and built a tx over
MAX_TX_SIZE_AFTER_SAPLING. CommitTransaction calls AddToWallet before
AcceptToMemoryPool, so a rejected oversize tx leaves its inputs reading as spent.

z_shieldcoinbase caps a manual shield at SHIELD_COINBASE_DEFAULT_LIMIT = 50
utxos; autoshield dropped that cap and relied on the byte estimate alone.
Restore one -- AUTOSHIELD_MAX_INPUTS = 400 -- so the byte arithmetic is no longer
the only thing between a large backlog and an oversize transaction. The
remainder is shielded on the next round.

Second, the proof build deliberately runs without cs_wallet so wallet RPCs are
not stalled, which leaves a multi-second window in which a concurrent
z_shieldcoinbase or z_sendmany can re-select the same coinbase outputs.
AvailableCoins already honours IsLockedCoin and z_shieldcoinbase already
brackets its selection with LockCoin/UnlockCoin; autoshield made zero LockCoin
calls. Take the locks under cs_wallet at selection time and release them via
RAII, since several early returns sit between selection and commit and a leaked
lock would exclude those coins from every future round.

Verified on an isolated regtest chain with a 540-utxo backlog:
  round 1 logged "reached per-round input cap (400)" and committed exactly 400
    inputs in a 48351-byte tx (estimate 62300, limit 200000)
  round 2 took the remaining 151; backlog drained 540 -> 0
  listlockunspent showed 400 coins locked mid-round and 0 afterwards
  48351 bytes for 400 inputs implies 2937 bytes of fixed overhead, agreeing
    with the mainnet measurement to 14 bytes

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-25 07:59:13 +02:00
1ec6590fb7 wallet: add z_autoshieldstatus
Auto-shielding could silently decline to run with no way to ask why. The
destination it resolves was equally invisible: the only evidence was a LogPrintf
emitted once per round, so an operator wanting to know where their mined coinbase
was going had to grep debug.log.

z_autoshieldstatus reports the enable state, whether a round is in flight, the
next height, interval, fee, minimum utxos, and the resolved destination -- plus
the HD seed provenance in both numeric and readable form, whether the seed is
phrase-recoverable, and a disabled_reason explaining why it is off when it is.

That last field is the point. "autoshield": false on its own does not distinguish
an operator who passed -autoshield=0 from a wallet whose seed provenance is not
known-recoverable, and those need different responses.

Mirrors z_sweepstatus in shape and registration.

Verified on all three branches:
  fresh wallet    -> autoshield true, origin 1 "created on an empty wallet",
                     seed_recoverable true, disabled_reason ""
  -autoshield=0   -> disabled_reason "disabled by -autoshield=0"
  upgraded wallet -> autoshield false, origin 4 "predates provenance recording",
                     seed_recoverable false, disabled_reason "HD seed origin is
                     not known-recoverable; back the seed up and pass -autoshield=1"

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-25 06:04:43 +02:00
92e6c7008d wallet: define CHDChain's static constants out of line
hush-gtest failed to link with "undefined reference to
CHDChain::VERSION_HD_MNEMONIC". The version constants are static const int with
in-class initialisers and no definition anywhere, so any ODR use needs one --
and gtest's EXPECT_*/ASSERT_* macros take their arguments by const reference,
which is exactly that. test_mnemonic_compat.cpp:161 passes VERSION_HD_MNEMONIC
to EXPECT_LT.

dragonxd links either way, because nothing in the daemon binds these to a
reference; only the test target exposed it, and the test target was never built
on the branch that introduced the test.

Define all four rather than only the one that failed: VERSION_HD_BASE,
VERSION_HD_TRANSPARENT and CURRENT_VERSION carry the identical latent fault, and
the next EXPECT_EQ against any of them would hit the same wall. Fixing the test
instead would have hidden the problem rather than removed it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-23 18:58:24 +02:00
733df964ec Merge autoshield-safety into dev: auto-shield coinbase + BIP39 by default
Brings the auto-shield-coinbase feature and the wallet seed work onto the release
line so both are exercised from dev rather than a side branch.

From upstream: auto-shield of matured coinbase into a wallet z-address, plus
fixes to two pre-existing scheduler wedges (consolidation dispatching every block
instead of once per interval, and a failing sweep stranding its running flag).

On top of that:
  * the auto-shield destination is chosen by re-deriving m/32'/coin'/i' from the
    seed and taking the lowest in-gap account the wallet holds a key for, rather
    than the first entry in std::set order. Key metadata is NOT evidence of
    provenance -- z_importwallet copies hdKeypath and seedFp verbatim from the
    import file -- so a crafted import could otherwise claim account 0 and
    capture every shielded reward;
  * HD seed/chain persistence is hardened: the chain record is written before the
    seed, a corrupt hdchain is loud rather than silently reverting derivation to
    the raw entropy, hdchain survives -salvagewallet, and the silent BIP39 ->
    random seed fallback is gone;
  * seed provenance is recorded, and -autoshield defaults ON only where that
    provenance says the seed is recoverable;
  * mnemonic wallets now store the EXPANDED 64-byte BIP39 seed with the 32-byte
    entropy in a separate display-only record. Derivation reads stored bytes
    directly on every binary, so key trees are identical and no CHDChain version
    bump or minversion fence is needed -- the format stays readable by earlier
    releases;
  * new wallets are created from a BIP39 phrase by default;
  * the plaintext hdseed record is erased when a wallet is encrypted. It was
    previously left behind, and CDB::Rewrite copies surviving records verbatim,
    so the unencrypted seed persisted on disk forever.

TWO DEFAULTS CHANGE for new wallets: auto-shielding (where the seed provenance is
known) and BIP39 seed generation. Existing wallets are untouched -- seed
generation is reachable only when a wallet has none, and all three key stores
refuse to replace an existing seed.

Testing state, stated plainly: every commit built clean and the branch was
verified on an isolated chain -- destination selection provably ignores an
imported foreign key, both provenance branches behave, a new-format wallet
reopened by a pre-change binary lists identical addresses, and restoring only the
24 words recovers the wallet. NOT yet tested: any of this against a funded wallet
on mainnet, or a soak of these defaults on a real node.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-23 11:53:32 -05:00
d12e7dc99d Merge origin/master into dev: restore the mingw -Wa,-mbig-obj flag
dev branched from the release line before 1c3523aac and so lost -Wa,-mbig-obj
from util/build-win.sh. Without it the Windows cross-compile fails at link: large
template/boost-heavy translation units exceed the PE/COFF ~32k-section limit, and
GNU ld emits "dangerous relocation" on .pdata and crashes. v1.1.0 therefore could
not produce a win64 binary at all.

The merge brings only that one file back. util/build-win.sh is the only file both
sides touched, and the two edits are ~20 lines apart, so it auto-merges keeping
both fixes: -Wa,-mbig-obj on the configure line and -DARCH=default on the cmake
line. Verified after merging that dev's own work is intact -- the extended
checkpoint table, the guarded RandomX dedup, the 1.1.0 version bump, and
ARCH=default in the other two build scripts.

No C++ changed; the only delta from the pre-merge dev is the shell script.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-23 10:42:29 -05:00
17 changed files with 724 additions and 62 deletions

View File

@@ -1,4 +1,8 @@
FROM ubuntu:20.04
# Base image is parameterised so one Dockerfile can produce binaries for several
# glibc floors: docker build --build-arg BASE_IMAGE=ubuntu:18.04 ...
# The default is unchanged, so `./build.sh --linux-compat` behaves exactly as before.
ARG BASE_IMAGE=ubuntu:20.04
FROM ${BASE_IMAGE}
ENV DEBIAN_FRONTEND=noninteractive

View File

@@ -6,10 +6,34 @@
set -eu -o pipefail
VERSION="1.0.3"
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
RELEASE_DIR="$SCRIPT_DIR/release"
# Derive the release version from configure.ac instead of hardcoding it here.
# A stale literal names the release directories after the wrong version while the
# binaries inside report the real one: this said 1.0.3 while the tree was already
# 1.1.0, so `./build.sh --all-release` would have produced
# release/dragonx-1.0.3-<platform>/ full of binaries announcing 1.1.0.
# Mirrors configure.ac's _CLIENT_VERSION_SUFFIX m4 exactly:
# build < 25 -> beta(build+1) build < 50 -> rc(build-24)
# build == 50 -> plain release build > 50 -> point release (build-50)
_acdef() { sed -n "s/^define(_CLIENT_VERSION_$1, *\([0-9]\{1,\}\))/\1/p" "$SCRIPT_DIR/configure.ac"; }
_V_MAJOR="$(_acdef MAJOR)"
_V_MINOR="$(_acdef MINOR)"
_V_REVISION="$(_acdef REVISION)"
_V_BUILD="$(_acdef BUILD)"
if [ -z "$_V_MAJOR" ] || [ -z "$_V_MINOR" ] || [ -z "$_V_REVISION" ] || [ -z "$_V_BUILD" ]; then
echo "ERROR: could not read the version from $SCRIPT_DIR/configure.ac" >&2
echo " refusing to build a release whose directory name would be wrong." >&2
exit 1
fi
if [ "$_V_BUILD" -lt 25 ]; then _V_SUFFIX="$_V_REVISION-beta$((_V_BUILD + 1))"
elif [ "$_V_BUILD" -lt 50 ]; then _V_SUFFIX="$_V_REVISION-rc$((_V_BUILD - 24))"
elif [ "$_V_BUILD" -eq 50 ]; then _V_SUFFIX="$_V_REVISION"
else _V_SUFFIX="$_V_REVISION-$((_V_BUILD - 50))"
fi
VERSION="$_V_MAJOR.$_V_MINOR.$_V_SUFFIX"
# Parse release flags
BUILD_LINUX_RELEASE=0
BUILD_WIN_RELEASE=0

View File

@@ -2,7 +2,7 @@ dnl require autoconf 2.60 (AS_ECHO/AS_ECHO_N)
AC_PREREQ([2.60])
define(_CLIENT_VERSION_MAJOR, 1)
dnl Must be kept in sync with src/clientversion.h , ugh!
define(_CLIENT_VERSION_MINOR, 1)
define(_CLIENT_VERSION_MINOR, 2)
define(_CLIENT_VERSION_REVISION, 0)
define(_CLIENT_VERSION_BUILD, 50)
define(_ZC_BUILD_VAL, m4_if(m4_eval(_CLIENT_VERSION_BUILD < 25), 1, m4_incr(_CLIENT_VERSION_BUILD), m4_eval(_CLIENT_VERSION_BUILD < 50), 1, m4_eval(_CLIENT_VERSION_BUILD - 24), m4_eval(_CLIENT_VERSION_BUILD == 50), 1, , m4_eval(_CLIENT_VERSION_BUILD - 50)))

View File

@@ -1,3 +1,35 @@
dragonx (1.2.0) stable; urgency=medium
* Auto-shield matured coinbase into a wallet-owned Sapling address on a block
interval. The destination is derived from the HD seed at m/32'/coin'/i' and
is the lowest index inside -mnemonicsaplinggap, so a bare seed-phrase restore
re-derives it; auto-shielding refuses to run rather than send anywhere a
restore would not find. Enabled only when the seed's provenance is known to be
recoverable, so upgraded wallets stay opted out until the operator says
otherwise.
* Create new wallets from a BIP39 seed phrase by default, byte-compatible with
SilentDragonXLite. z_exportmnemonic returns the phrase; -mnemonic restores
from it.
* New RPC z_autoshieldstatus reports whether auto-shielding is on, the resolved
destination, the HD seed's provenance, and why it is off when it is off.
* Bound each auto-shield round to 400 inputs and correct the transaction size
estimate to account for all three Sapling output descriptions, and lock the
selected coins for the duration of proof building so a concurrent
z_shieldcoinbase or z_sendmany cannot select them too.
* Repair, rather than reject, an hdchain record truncated by an older wallet
build. Previously one address generated under a pre-1.1.0 binary left the
wallet unopenable with "Wallet corrupted"; the record is now completed and
rewritten, and the error text names the seed-phrase remedy when it genuinely
cannot be recovered.
* Clear a stale sweep flag that could otherwise leave sweeping, consolidation
and auto-shielding permanently disabled together, and stop the async queue
silently discarding operations at shutdown while reporting success.
* Derive the release version from configure.ac in build.sh instead of a
hardcoded literal, and document container-based release builds in
doc/build-containers.md.
-- DragonX Developers <dev@dragonx.is> Tue, 25 Aug 2026 19:45:00 +0000
dragonx (1.1.0) stable; urgency=medium
* Extend DRAGONX checkpoints to height 3,226,000, enabling the existing

223
doc/build-containers.md Normal file
View File

@@ -0,0 +1,223 @@
# Building release binaries in containers
Release binaries must be built in a container based on an **old** Linux distribution.
This document is written to be executed, by a person or an agent, on a machine that
has nothing set up yet.
---
## 1. Why this exists
glibc compatibility runs one way only. A binary linked against glibc 2.35 demands
symbol versions that glibc 2.31 does not have, and refuses to start. A binary linked
against glibc 2.29 runs on 2.29, 2.31 and 2.35 alike.
Measured on the actual fleet, 2026-08-25:
| binary | max GLIBC required | runs on |
|---|---|---|
| what all four 20.04 seeds run today (`v1.0.3-d159e7208`) | `GLIBC_2.29` | 18.04, 20.04, 22.04 |
| anything built on seed 176 today (Ubuntu 22.04) | `GLIBC_2.34` | 22.04 only |
The second binary will not start on four of our own five seeds. The loader reports:
```
/lib/x86_64-linux-gnu/libc.so.6: version `GLIBC_2.34' not found
/lib/x86_64-linux-gnu/libstdc++.so.6: version `GLIBCXX_3.4.30' not found
```
Nothing new is being *called*. glibc 2.34 merged libpthread and libdl into libc and
re-versioned every `pthread_*`, `dlsym` and `dladdr` symbol; 2.33 replaced the old
`__xstat` inlines with real `stat`/`fstat`/`lstat64`. All of those functions exist in
2.31 under older tags. Building against older headers is the entire fix.
**Do not try to solve this with full static linking.** The daemon calls `getaddrinfo`,
`gethostbyname` and `getnameinfo`, and it must resolve `node1..node5.dragonx.is`, which
are hard-coded and injected into `-addnode` on every node. Under a fully static glibc
binary those go through NSS, which `dlopen`s `libnss_dns.so.2` at run time — it either
fails or silently requires the target to have the same glibc you linked against, which
defeats the purpose.
---
## 2. What already exists in this repo
Do not write a new build system. Two pieces are already here:
- **`Dockerfile.compat`** — an Ubuntu base image that installs the toolchain, copies the
tree, **deletes any host-built `depends/` and object files**, runs `./util/build.sh`,
and strips the three binaries.
- **`./build.sh --linux-compat`** — builds that image, creates a throwaway container,
copies `dragonxd`, `dragonx-cli` and `dragonx-tx` out into
`release/dragonx-<version>-linux-amd64-ubuntu2004/`, adds `bootstrap-dragonx.sh`,
`asmap.dat` and the two sapling params, fixes ownership, and prints the binary's
maximum required GLIBC version.
The base image is parameterised via `ARG BASE_IMAGE` (default `ubuntu:20.04`), so the
same Dockerfile can target several glibc floors.
---
## 3. Prerequisites
Docker (the scripted path uses `docker` specifically; podman works for the manual path
if you alias or substitute it).
```sh
sudo apt-get update
sudo apt-get install -y docker.io git
sudo usermod -aG docker "$USER" # then log out and back in, or every command needs sudo
```
Budget, measured on a 4-core box:
| resource | needs |
|---|---|
| disk | ~15 GB free (the `depends/` tree alone is ~1.6 GB per target, plus image layers) |
| RAM | 4 GB minimum, 8 GB comfortable — the link step is the peak |
| time | **12 hours per base image on first build.** `depends/` builds boost, BDB, wolfssl, libevent, libsodium, libcurl and rust from source. Later builds reuse Docker layer cache unless the tree changed. |
`depends/` downloads and builds its own rust toolchain, so the host's rust (or absence
of it) is irrelevant.
---
## 4. Build one target
```sh
git clone https://git.dragonx.is/DragonX/dragonx
cd dragonx
git checkout <the tag or branch you are releasing>
./build.sh --linux-compat
```
Output lands in `release/dragonx-<version>-linux-amd64-ubuntu2004/` and the script
prints the max GLIBC at the end. `<version>` is read from `configure.ac`, not
hardcoded, so it always matches what the binaries report.
---
## 5. Build several targets
```sh
for BASE in ubuntu:18.04 ubuntu:20.04 ubuntu:22.04; do
TAG="dragonx-compat-${BASE#ubuntu:}"
TAG="${TAG//./}"
docker build --build-arg "BASE_IMAGE=$BASE" -f Dockerfile.compat -t "$TAG" .
OUT="release/dragonx-$(grep -oP 'define\(_CLIENT_VERSION_MAJOR, \K[0-9]+' configure.ac).$(grep -oP 'define\(_CLIENT_VERSION_MINOR, \K[0-9]+' configure.ac).$(grep -oP 'define\(_CLIENT_VERSION_REVISION, \K[0-9]+' configure.ac)-linux-amd64-${BASE#ubuntu:}"
mkdir -p "$OUT"
CID=$(docker create "$TAG")
for b in dragonxd dragonx-cli dragonx-tx; do docker cp "$CID:/build/src/$b" "$OUT/$b"; done
docker rm "$CID" >/dev/null
cp util/bootstrap-dragonx.sh contrib/asmap/asmap.dat sapling-output.params sapling-spend.params "$OUT/" 2>/dev/null || true
done
```
### Which base to choose
| base | glibc it provides | default GCC | verdict |
|---|---|---|---|
| `ubuntu:18.04` | 2.27 | 7 | **Verify before relying on it.** The tree is built with `-std=c++17`; GCC 7's C++17 support is incomplete and its cmake (3.10) may be too old for RandomX. Attempt only if you need to reach 18.04 users, and treat a successful build as the proof. |
| `ubuntu:20.04` | 2.31 | 9 | **Recommended floor.** GCC 9 covers C++17 fully. Evidence it works: the binary the fleet runs today requires only `GLIBC_2.29`, i.e. the code touches nothing newer, so a 20.04 build reaches 18.04 machines anyway. |
| `ubuntu:22.04` | 2.35 | 11 | **Do not ship this.** It is what we already have and what excludes four of our own seeds. Useful only for development. |
Ubuntu 20.04 left standard support in April 2025, which is precisely why it belongs in
a container on a patched host rather than on a build box someone has to maintain.
---
## 6. Verify — this step is not optional
A build that silently targets the wrong glibc looks completely normal until a user
reports that nothing starts.
```sh
BIN=release/dragonx-<version>-linux-amd64-ubuntu2004/dragonxd
# The ceiling. Must be <= the glibc of the OLDEST system you intend to support.
objdump -p "$BIN" | grep -oE 'GLIBC_2\.[0-9]+' | sort -t. -k2 -n | tail -1
objdump -p "$BIN" | grep -oE 'GLIBCXX_3\.4\.[0-9]+' | sort -t. -k3 -n | tail -1
# If the ceiling is too high, this names the symbols responsible.
readelf --dyn-syms --wide "$BIN" | grep -E '@GLIBC_2\.(3[2-9])'
```
Expected for a 20.04 build: `GLIBC_2.29` or lower, `GLIBCXX_3.4.26` or lower.
Then actually run it somewhere old. A ceiling check proves the loader will resolve the
symbols; it does not prove the binary works. `./dragonxd --version` on a real 20.04 box
is a ten-second confirmation.
---
## 7. Traps
Each of these has cost real time.
**`ETXTBSY` when installing over a running daemon.** `cp` onto the binary fails with
"Text file busy" *even after the process has exited*`pgrep` returning nothing is not
sufficient, the kernel still holds the text mapping. Stage into the same directory and
`mv` (rename is not blocked), allow ~10 s to settle, and **sha256-verify the installed
file before starting it**. A failed copy that goes unnoticed leaves the old binary
running and looks like a successful deploy.
**Never touch `configure.ac` in a configured tree.** Even `cp`-ing back a byte-identical
copy updates its mtime, which makes `make` regenerate `aclocal.m4` and `configure` and
then re-run `configure`, which fails with `libdb_cxx headers missing` because the
depends prefix is not on the command line. If it happens: confirm
`git diff --quiet HEAD -- configure.ac`, then restore mtime order oldest-to-newest with
one-second gaps — `configure.ac`/`Makefile.am`, then `aclocal.m4`, then
`configure`/`Makefile.in`, then `config.status`, then `Makefile`. Inside a container
this cannot happen, which is one more reason to build there.
**Never blind-`touch` a path that might not exist.** `touch src/config/hush-config.h`
silently *creates* an empty stray file; the real header is `bitcoin-config.h`. Check
`git status` after any timestamp surgery.
**RandomX must be built with `ARCH=default`.** `util/build.sh` already passes it and the
comment there explains why: `ARCH=native` tunes to the build machine, and a build on an
AVX-512 host emitted 746 `zmm` instructions into `librandomx.a`, which `SIGILL`s on the
entire fleet. If you ever invoke cmake by hand, pass `-DARCH=default`.
**Strip before distributing.** Unstripped is ~220 MB, stripped ~16 MB. `Dockerfile.compat`
already strips inside the container.
**`util/build-win.sh` discards every argument.** There is no `"$@"` handling in it, so
`-j$(nproc)` and `--disable-tests` are dropped on the floor and the Windows build is
single-threaded. Expect it to be far slower than you planned.
**Windows also needs `-Wa,-mbig-obj` and `-DARCH=default`.** Both are in
`util/build-win.sh` today. The mingw flag was missing from `dev` for a month; without it
the cross-compile fails at link because boost-heavy translation units exceed the
PE/COFF section limit. Do not lose it on a re-branch.
**macOS cannot be containerised.** `util/build-mac.sh` is a native-Mac script, and there
is no darwin cross-compile path in `depends/` at all: `hosts/darwin.mk` wants
`native_cctools`, which has no package definition, and there is no SDK in the tree. It
also hardcodes an Intel Homebrew GCC path, so it produces x86_64 only — no arm64, no
universal binary. macOS needs a real Mac.
**The `contrib/gitian-descriptors/` files are not a build path.** They are unmodified
upstream Bitcoin files (`name: "bitcoin-win-0.11"`, suite `trusty`) with zero DragonX
content. Ignore them.
---
## 8. Handoff checklist
- [ ] Docker installed, user in the `docker` group, ~15 GB free
- [ ] Correct tag or branch checked out, tree clean (`git status`)
- [ ] Version in `configure.ac` is the one you intend to release
- [ ] `./build.sh --linux-compat` completes
- [ ] GLIBC ceiling is **2.31 or lower** (2.29 expected)
- [ ] GLIBCXX ceiling is **3.4.28 or lower** (3.4.26 expected)
- [ ] `dragonxd --version` runs on a real machine of the oldest supported distro
- [ ] Binaries stripped, `release/` contains the bootstrap script, `asmap.dat` and both sapling params
- [ ] sha256 recorded for each artifact
One more thing that is not a build step but belongs in the same conversation: the
in-app daemon updater refuses any release without a detached signature
(`kDaemonRequireSignature = true`). Publishing checksums alone means no existing user
can update in place.

View File

@@ -96,18 +96,21 @@ void AsyncRPCQueue::run(size_t workerId) {
*
* Don't use std::make_shared<AsyncRPCOperation>().
*/
void AsyncRPCQueue::addOperation(const std::shared_ptr<AsyncRPCOperation> &ptrOperation) {
bool AsyncRPCQueue::addOperation(const std::shared_ptr<AsyncRPCOperation> &ptrOperation) {
std::lock_guard<std::mutex> guard(lock_);
// Don't add if queue is closed or finishing
// Don't add if queue is closed or finishing. Report it: silently dropping the
// operation made callers announce work that would never run.
// (isClosed/isFinishing read atomics, so calling them under the guard is safe.)
if (isClosed() || isFinishing()) {
return;
return false;
}
AsyncRPCOperationId id = ptrOperation->getId();
operation_map_.emplace(id, ptrOperation);
operation_id_queue_.push(id);
this->condition_.notify_one();
return true;
}
/**

View File

@@ -63,7 +63,12 @@ public:
size_t getOperationCount() const;
std::shared_ptr<AsyncRPCOperation> getOperationForId(AsyncRPCOperationId) const;
std::shared_ptr<AsyncRPCOperation> popOperationForId(AsyncRPCOperationId);
void addOperation(const std::shared_ptr<AsyncRPCOperation> &ptrOperation);
// Returns false if the queue is closed or finishing, in which case the
// operation was NOT queued and will never run. Callers must react: a caller
// that ignores this both reports success for work that will not happen and
// leaves any state it set for the operation (running flags, coin locks)
// stranded for the life of the process.
bool addOperation(const std::shared_ptr<AsyncRPCOperation> &ptrOperation);
std::vector<AsyncRPCOperationId> getAllOperationIds() const;
private:

View File

@@ -29,7 +29,7 @@
//! These need to be macros, as clientversion.cpp's and bitcoin*-res.rc's voodoo requires it
// Must be kept in sync with configure.ac , ugh!
#define CLIENT_VERSION_MAJOR 1
#define CLIENT_VERSION_MINOR 1
#define CLIENT_VERSION_MINOR 2
#define CLIENT_VERSION_REVISION 0
#define CLIENT_VERSION_BUILD 50

View File

@@ -60,6 +60,7 @@
#include "wallet/wallet.h"
#include "wallet/walletdb.h"
#include "wallet/asyncrpcoperation_saplingconsolidation.h"
#include "wallet/asyncrpcoperation_autoshieldcoinbase.h"
#include "wallet/asyncrpcoperation_sweep.h"
#endif
#include <stdint.h>
@@ -2270,7 +2271,10 @@ bool AppInit2(boost::thread_group& threadGroup, CScheduler& scheduler)
if (nLoadWalletRet != DB_LOAD_OK)
{
if (nLoadWalletRet == DB_CORRUPT)
strErrors << _("Error loading wallet.dat: Wallet corrupted") << "\n";
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";
else if (nLoadWalletRet == DB_NONCRITICAL_ERROR)
{
string msg(_("Warning: error reading wallet.dat! All keys read correctly, but transaction data"
@@ -2556,6 +2560,25 @@ bool AppInit2(boost::thread_group& threadGroup, CScheduler& scheduler)
return InitError("Wallet must hold the spending key of -autoshieldaddress (else shielded coinbase would be unrecoverable)");
}
pwalletMain->autoShieldAddress = autoShieldAddress;
} else {
// No explicit destination. Resolve the seed-derived one now, read-only,
// so z_autoshieldstatus can say where coinbase will go BEFORE the first
// round rather than reporting an empty string until one fires. This
// never generates a key: a fresh account must not be a side effect of
// populating a status field. A brand-new wallet holds nothing in-gap
// yet, so the field stays empty and the RPC explains why.
LOCK(pwalletMain->cs_wallet);
if (!pwalletMain->IsLocked()) {
libzcash::SaplingPaymentAddress destAddr;
std::string destStr;
uint32_t destAccount = AUTOSHIELD_ACCOUNT_NONE;
if (ResolveAutoShieldDestinationReadOnly(destAddr, destStr, destAccount)
== AutoShieldDestStatus::Resolved) {
pwalletMain->autoShieldAddress = destStr;
LogPrintf("%s: autoshield destination %s (seed-derived sapling account %u)\n",
__func__, destStr, (unsigned)destAccount);
}
}
}
}

View File

@@ -369,6 +369,7 @@ extern UniValue z_gettotalbalance(const UniValue& params, bool fHelp, const CPub
extern UniValue z_mergetoaddress(const UniValue& params, bool fHelp, const CPubKey& mypk); // in rpcwallet.cpp
extern UniValue z_sendmany(const UniValue& params, bool fHelp, const CPubKey& mypk); // in rpcwallet.cpp
extern UniValue z_sweepstatus(const UniValue& params, bool fHelp, const CPubKey& mypk); // in rpcwallet.cpp
extern UniValue z_autoshieldstatus(const UniValue& params, bool fHelp, const CPubKey& mypk); // in rpcwallet.cpp
extern UniValue z_consolidationstatus(const UniValue& params, bool fHelp, const CPubKey& mypk); // in rpcwallet.cpp
extern UniValue z_shieldcoinbase(const UniValue& params, bool fHelp, const CPubKey& mypk); // in rpcwallet.cpp
extern UniValue z_getoperationstatus(const UniValue& params, bool fHelp, const CPubKey& mypk); // in rpcwallet.cpp

View File

@@ -22,6 +22,20 @@ extern std::string randomSietchZaddr();
// Serialized-size estimates for one spent input (kept in sync with rpcwallet.cpp)
static const size_t AUTOSHIELD_CTXIN_DUST_SIZE = 148;
// Every autoshield tx carries THREE Sapling OutputDescriptions -- the change
// note to destZaddr plus the two Sietch dummies -- at ~948 bytes each. Reserving
// 2000 for "header + sietch outputs" was ~900 bytes short before a single input
// was counted, so a large enough round could build a tx over MAX_TX_SIZE.
static const size_t AUTOSHIELD_SAPLING_OUTPUT_SIZE = 948;
static const size_t AUTOSHIELD_TX_OVERHEAD = (3 * AUTOSHIELD_SAPLING_OUTPUT_SIZE) + 256;
// Hard cap on inputs per round, mirroring z_shieldcoinbase's
// SHIELD_COINBASE_DEFAULT_LIMIT. The byte estimate alone is not a safe bound:
// with a P2PKH coinbase (-mineraddress) the 148-byte figure is exact rather than
// conservative, so an under-estimate translates directly into an oversize tx.
// The remainder is simply shielded on the next round.
static const size_t AUTOSHIELD_MAX_INPUTS = 400;
// Unrelated to the cap above despite sharing the value: this is a SIZE IN BYTES for
// one spent P2SH input, mirroring CTXIN_SPEND_P2SH_SIZE in rpcwallet.cpp.
static const size_t AUTOSHIELD_CTXIN_P2SH_SIZE = 400;
// Expire unmined autoshield txs after this many blocks, so a tx cannot straddle
// a network-upgrade activation.
@@ -93,50 +107,34 @@ void AsyncRPCOperation_autoshieldcoinbase::main() {
getId(), getStateAsString(), numTxCreated_, FormatMoney(amountShielded_));
}
// Resolve the Sapling destination for auto-shielded coinbase.
//
// Recoverability is the hard requirement: coinbase we shield must land in an
// address that a bare -mnemonic/-hdseed restore of THIS wallet's seed re-derives
// on its own. A restore pre-derives exactly -mnemonicsaplinggap sapling accounts
// starting at index 0, with saplingAccountCounter reset to 0 (init.cpp:2349-2355),
// so the only self-recoverable destinations are the default addresses of
// m/32'/<coin>'/i' for i < gap.
//
// We therefore DERIVE those accounts from the seed and pick the lowest index the
// wallet already holds. Deriving is the only authoritative test. In particular
// CKeyMetadata is NOT evidence of provenance: z_importkey / z_importwallet copy
// both hdKeypath and seedFp verbatim out of the import source
// (wallet.cpp:5522-5529 <- rpcdump.cpp:511-516), so a foreign key can claim any
// keypath and any seed fingerprint. Filtering on metadata would let an imported
// key win as "account 0" and silently receive every shielded reward.
//
// Caller must hold cs_wallet and must already have checked the wallet is unlocked.
bool AsyncRPCOperation_autoshieldcoinbase::resolveDestination(
libzcash::SaplingPaymentAddress& destOut, std::string& destStrOut) {
// Read-only half of destination resolution, shared with init.cpp so the answer to
// "where will auto-shielding send?" is available before the first round runs rather
// than only after one has fired. Mutates nothing: no key generation, no caching.
AutoShieldDestStatus ResolveAutoShieldDestinationReadOnly(
libzcash::SaplingPaymentAddress& destOut, std::string& destStrOut, uint32_t& accountOut) {
accountOut = AUTOSHIELD_ACCOUNT_NONE;
// 1. Explicit -autoshieldaddress override (validated as a spendable Sapling
// zaddr at init.cpp:2494-2506). This also serves as the per-process cache
// for whatever step 2/3 resolved.
// zaddr in init.cpp). This doubles as the per-process cache for whatever
// the derivation below resolved on an earlier round.
if (!pwalletMain->autoShieldAddress.empty()) {
auto decoded = DecodePaymentAddress(pwalletMain->autoShieldAddress);
if (boost::get<libzcash::SaplingPaymentAddress>(&decoded) != nullptr) {
destOut = boost::get<libzcash::SaplingPaymentAddress>(decoded);
destStrOut = pwalletMain->autoShieldAddress;
return true;
return AutoShieldDestStatus::Resolved;
}
LogPrintf("%s: configured -autoshieldaddress is not a valid Sapling address\n", getId());
return false;
return AutoShieldDestStatus::InvalidOverride;
}
// 2. Walk the restore window m/32'/coin'/[0, gap)' derived from the seed.
HDSeed seed;
if (!pwalletMain->GetHDSeedForDerivation(seed)) {
LogPrintf("%s: no HD seed available; refusing to pick an autoshield destination\n", getId());
return false;
return AutoShieldDestStatus::NoSeed;
}
// Mirror init.cpp:2349-2350's own clamp, and cap into the hardened index
// space so (i | ZIP32_HARDENED_KEY_LIMIT) below stays well formed.
// Mirror init.cpp's own clamp, and cap into the hardened index space so
// (i | ZIP32_HARDENED_KEY_LIMIT) below stays well formed.
int64_t gapArg = GetArg("-mnemonicsaplinggap", 100);
if (gapArg < 0) {
gapArg = 0;
@@ -167,15 +165,77 @@ bool AsyncRPCOperation_autoshieldcoinbase::resolveDestination(
if (pwalletMain->GetSaplingExtendedSpendingKey(addr, held)) {
destOut = addr;
destStrOut = EncodePaymentAddress(addr);
// Cache for the life of the process; step 1 short-circuits later
// rounds. Safe: we only cache post-validation.
pwalletMain->autoShieldAddress = destStrOut;
LogPrintf("%s: autoshield destination %s (seed-derived sapling account %u, gap %u)\n",
getId(), destStrOut, (unsigned)i, (unsigned)saplingGap);
return true;
accountOut = i;
return AutoShieldDestStatus::Resolved;
}
}
return AutoShieldDestStatus::NotFound;
}
// Resolve the Sapling destination for auto-shielded coinbase.
//
// Recoverability is the hard requirement: coinbase we shield must land in an
// address that a bare -mnemonic/-hdseed restore of THIS wallet's seed re-derives
// on its own. A restore pre-derives exactly -mnemonicsaplinggap sapling accounts
// starting at index 0, with saplingAccountCounter reset to 0 (init.cpp:2349-2355),
// so the only self-recoverable destinations are the default addresses of
// m/32'/<coin>'/i' for i < gap.
//
// We therefore DERIVE those accounts from the seed and pick the lowest index the
// wallet already holds. Deriving is the only authoritative test. In particular
// CKeyMetadata is NOT evidence of provenance: z_importkey / z_importwallet copy
// both hdKeypath and seedFp verbatim out of the import source
// (wallet.cpp:5522-5529 <- rpcdump.cpp:511-516), so a foreign key can claim any
// keypath and any seed fingerprint. Filtering on metadata would let an imported
// key win as "account 0" and silently receive every shielded reward.
//
// Caller must hold cs_wallet and must already have checked the wallet is unlocked.
bool AsyncRPCOperation_autoshieldcoinbase::resolveDestination(
libzcash::SaplingPaymentAddress& destOut, std::string& destStrOut) {
uint32_t account = AUTOSHIELD_ACCOUNT_NONE;
switch (ResolveAutoShieldDestinationReadOnly(destOut, destStrOut, account)) {
case AutoShieldDestStatus::Resolved:
// Cache for the life of the process; the override branch of the resolver
// short-circuits later rounds. Safe: we only cache post-validation.
pwalletMain->autoShieldAddress = destStrOut;
if (account == AUTOSHIELD_ACCOUNT_NONE) {
LogPrintf("%s: autoshield destination %s (configured)\n", getId(), destStrOut);
} else {
LogPrintf("%s: autoshield destination %s (seed-derived sapling account %u)\n",
getId(), destStrOut, (unsigned)account);
}
return true;
case AutoShieldDestStatus::InvalidOverride:
LogPrintf("%s: configured -autoshieldaddress is not a valid Sapling address\n", getId());
return false;
case AutoShieldDestStatus::NoSeed:
LogPrintf("%s: no HD seed available; refusing to pick an autoshield destination\n", getId());
return false;
case AutoShieldDestStatus::NotFound:
break; // nothing in the window yet: fall through and derive one
}
// Re-establish the derivation context the resolver used, for step 3 below.
HDSeed seed;
if (!pwalletMain->GetHDSeedForDerivation(seed)) {
LogPrintf("%s: no HD seed available; refusing to pick an autoshield destination\n", getId());
return false;
}
int64_t gapArg = GetArg("-mnemonicsaplinggap", 100);
if (gapArg < 0) {
gapArg = 0;
}
if (gapArg > (int64_t)ZIP32_HARDENED_KEY_LIMIT) {
gapArg = (int64_t)ZIP32_HARDENED_KEY_LIMIT;
}
const uint32_t saplingGap = (uint32_t)gapArg;
const uint32_t bip44CoinType = Params().BIP44CoinType();
auto m = libzcash::SaplingExtendedSpendingKey::Master(seed);
auto m_32h = m.Derive(32 | ZIP32_HARDENED_KEY_LIMIT);
auto m_32h_cth = m_32h.Derive(bip44CoinType | ZIP32_HARDENED_KEY_LIMIT);
// 3. Nothing usable in the window yet: derive the next account, but only if
// GenerateNewSaplingZKey will land INSIDE the window. It does NOT derive
// at saplingAccountCounter: its do/while skips every index whose spending
@@ -258,6 +318,27 @@ bool AsyncRPCOperation_autoshieldcoinbase::main_impl() {
libzcash::SaplingPaymentAddress destZaddr;
std::string destStr;
std::vector<ShieldCoinbaseUTXO> inputs;
// Proof building below runs WITHOUT cs_wallet (deliberately, so wallet RPCs
// are not stalled), which leaves a multi-second window in which a manual
// z_shieldcoinbase or z_sendmany over the same miner address would re-select
// these same coinbase outputs. AvailableCoins honours IsLockedCoin, so lock
// them for the duration exactly as z_shieldcoinbase does. RAII because there
// are several early returns between here and commit, and a leaked lock would
// silently exclude those coins from every future round.
struct ScopedCoinLocks {
std::vector<COutPoint> locked;
~ScopedCoinLocks() {
// A destructor is noexcept by default; letting the lock acquisition
// escape would turn a contended mutex into std::terminate.
try {
if (locked.empty()) return;
LOCK2(cs_main, pwalletMain->cs_wallet);
// UnlockCoin takes a non-const reference (upstream signature).
for (COutPoint& op : locked) pwalletMain->UnlockCoin(op);
} catch (...) {}
}
} coinLocks;
CAmount shieldedValue = 0;
unsigned int max_tx_size = MAX_TX_SIZE_AFTER_SAPLING;
@@ -277,10 +358,11 @@ bool AsyncRPCOperation_autoshieldcoinbase::main_impl() {
}
// Gather matured, spendable coinbase UTXOs, byte-capped to a single tx.
// AvailableCoins with fOnlySpendable already excludes immature coinbase
// (< COINBASE_MATURITY) and outputs we don't own, so external
// -mineraddress / pool coinbase naturally yields zero inputs.
size_t estimatedTxSize = 2000; // header + sietch outputs headroom
// AvailableCoins excludes immature coinbase unconditionally (wallet.cpp,
// `IsCoinBase() && GetBlocksToMaturity() > 0`) and only ever returns outputs
// we own, so external -mineraddress / pool coinbase yields zero inputs. The
// second argument here is fOnlyConfirmed, not fOnlySpendable.
size_t estimatedTxSize = AUTOSHIELD_TX_OVERHEAD;
std::vector<COutput> vecOutputs;
pwalletMain->AvailableCoins(vecOutputs, true, NULL, false, true);
for (const COutput& out : vecOutputs) {
@@ -293,6 +375,11 @@ bool AsyncRPCOperation_autoshieldcoinbase::main_impl() {
}
size_t increase = (boost::get<CScriptID>(&address) != nullptr)
? AUTOSHIELD_CTXIN_P2SH_SIZE : AUTOSHIELD_CTXIN_DUST_SIZE;
if (inputs.size() >= AUTOSHIELD_MAX_INPUTS) {
LogPrintf("%s: reached per-round input cap (%d); deferring remaining coinbase to next round\n",
opid, (int)AUTOSHIELD_MAX_INPUTS);
break;
}
if (estimatedTxSize + increase >= max_tx_size) {
// Size-safe batch; the remainder is shielded next round.
LogPrintf("%s: reached per-tx size cap; deferring remaining coinbase to next round\n", opid);
@@ -305,6 +392,12 @@ bool AsyncRPCOperation_autoshieldcoinbase::main_impl() {
inputs.push_back(utxo);
shieldedValue += out.tx->vout[out.i].nValue;
}
for (const ShieldCoinbaseUTXO& t : inputs) {
COutPoint outpt(t.txid, t.vout);
pwalletMain->LockCoin(outpt);
coinLocks.locked.push_back(outpt);
}
}
CAmount fee = pwalletMain->autoShieldFee;
@@ -331,7 +424,12 @@ bool AsyncRPCOperation_autoshieldcoinbase::main_impl() {
// Build the t->z shield tx. Proof generation happens in Build() WITHOUT
// holding cs_wallet (mirrors the sweep op) so we don't stall wallet RPCs.
auto builder = TransactionBuilder(consensusParams, targetHeight_, pwalletMain);
// tipHeight, not targetHeight_: the builder's height selects the consensus
// branch id (transaction_builder.cpp CurrentEpochBranchId), and the NU-straddle
// guard above plus SetExpiryHeight below are both keyed off tipHeight. Using the
// stale enqueue-time height here meant the guard was checking a height the
// transaction was not actually signed against.
auto builder = TransactionBuilder(consensusParams, tipHeight, pwalletMain);
builder.SetExpiryHeight(tipHeight + AUTOSHIELD_EXPIRY_DELTA);
builder.SetFee(fee);
@@ -395,6 +493,13 @@ void AsyncRPCOperation_autoshieldcoinbase::setResult() {
}
void AsyncRPCOperation_autoshieldcoinbase::cancel() {
// Cancelling is how the scheduler stops an in-flight round, so unlike the base
// class this must be able to move an EXECUTING operation to CANCELLED. What it
// must not do is overwrite a state that is already terminal: the scheduler
// cancels the previous operation when it enqueues the next one, and that one may
// have already SUCCEEDED, whose result would otherwise be relabelled as cancelled.
if (isSuccess() || isFailed() || isCancelled())
return;
set_state(OperationStatus::CANCELLED);
}

View File

@@ -14,6 +14,26 @@
// Default fee for automatic coinbase-shielding transactions
static const CAmount DEFAULT_AUTOSHIELD_FEE = 10000;
// Sentinel for "not a derived account" (i.e. the configured -autoshieldaddress).
static const uint32_t AUTOSHIELD_ACCOUNT_NONE = UINT32_MAX;
enum class AutoShieldDestStatus {
Resolved, // destOut/destStrOut are set
NotFound, // no in-gap account held yet; the operation will derive one
InvalidOverride, // -autoshieldaddress is set but is not a Sapling address
NoSeed, // no HD seed available (e.g. locked wallet)
};
// Resolve the auto-shield destination WITHOUT mutating the wallet: the configured
// -autoshieldaddress if set, else the lowest in-gap seed-derived account the wallet
// already holds. It deliberately does NOT generate a key, so init can call it purely
// to answer "where will this send?" -- deriving a fresh account as a side effect of
// populating a status field would be wrong. The operation's own resolveDestination
// falls through to generation when this returns NotFound.
// Caller must hold cs_wallet.
AutoShieldDestStatus ResolveAutoShieldDestinationReadOnly(
libzcash::SaplingPaymentAddress& destOut, std::string& destStrOut, uint32_t& accountOut);
// A periodic, wallet-local operation that drains matured *transparent* coinbase
// UTXOs into a wallet-owned Sapling z-address in size-bounded batches. It is the
// automatic sibling of the manual z_shieldcoinbase RPC and mirrors the dispatch

View File

@@ -305,6 +305,13 @@ void AsyncRPCOperation_saplingconsolidation::setConsolidationResult(int numTxCre
}
void AsyncRPCOperation_saplingconsolidation::cancel() {
// Cancelling is how the scheduler stops an in-flight round, so unlike the base
// class this must be able to move an EXECUTING operation to CANCELLED. What it
// must not do is overwrite a state that is already terminal: the scheduler
// cancels the previous operation when it enqueues the next one, and that one may
// have already SUCCEEDED, whose result would otherwise be relabelled as cancelled.
if (isSuccess() || isFailed() || isCancelled())
return;
set_state(OperationStatus::CANCELLED);
}

View File

@@ -364,6 +364,13 @@ void AsyncRPCOperation_sweep::setSweepResult(int numTxCreated, const CAmount& am
}
void AsyncRPCOperation_sweep::cancel() {
// Cancelling is how the scheduler stops an in-flight round, so unlike the base
// class this must be able to move an EXECUTING operation to CANCELLED. What it
// must not do is overwrite a state that is already terminal: the scheduler
// cancels the previous operation when it enqueues the next one, and that one may
// have already SUCCEEDED, whose result would otherwise be relabelled as cancelled.
if (isSuccess() || isFailed() || isCancelled())
return;
set_state(OperationStatus::CANCELLED);
}

View File

@@ -3345,6 +3345,83 @@ UniValue z_sweepstatus(const UniValue& params, bool fHelp, const CPubKey& mypk)
return ret;
}
UniValue z_autoshieldstatus(const UniValue& params, bool fHelp, const CPubKey& mypk)
{
if (!EnsureWalletIsAvailable(fHelp))
return NullUniValue;
if (fHelp || params.size() > 0)
throw runtime_error(
"z_autoshieldstatus\n"
"\nReport the state of automatic coinbase shielding: whether it is on, where it sends,\n"
"and -- when it is off -- why.\n"
"\nResult:\n"
"{\n"
" \"autoshield\" : true|false, (boolean) whether auto-shielding is enabled\n"
" \"running\" : true|false, (boolean) whether a round is in flight\n"
" \"next_autoshield\" : n, (numeric) height of the next round\n"
" \"autoshieldinterval\" : n, (numeric) blocks between rounds\n"
" \"autoshieldaddress\" : \"zaddr\", (string) resolved destination; empty until first resolved\n"
" \"autoshieldfee\" : n, (numeric) fee in puposhis\n"
" \"autoshieldminutxos\" : n, (numeric) minimum matured coinbase utxos per round\n"
" \"hdseedorigin\" : n, (numeric) 0 unrecorded, 1 created, 2 restored, 3 retrofit, 4 unknown\n"
" \"hdseedorigin_desc\" : \"...\", (string) readable form of hdseedorigin\n"
" \"seed_recoverable\" : true|false, (boolean) whether a seed phrase can be exported\n"
" \"disabled_reason\" : \"...\" (string) why auto-shielding is not running, if it is not\n"
"}\n"
"\nExamples:\n"
+ HelpExampleCli("z_autoshieldstatus", "")
+ HelpExampleRpc("z_autoshieldstatus", "")
);
LOCK2(cs_main, pwalletMain->cs_wallet);
UniValue ret(UniValue::VOBJ);
ret.push_back(Pair("autoshield", pwalletMain->fAutoShieldEnabled));
ret.push_back(Pair("running", pwalletMain->fAutoShieldRunning));
ret.push_back(Pair("next_autoshield", pwalletMain->nextAutoShield));
ret.push_back(Pair("autoshieldinterval", pwalletMain->autoShieldInterval));
ret.push_back(Pair("autoshieldaddress", pwalletMain->autoShieldAddress));
ret.push_back(Pair("autoshieldfee", pwalletMain->autoShieldFee));
ret.push_back(Pair("autoshieldminutxos", pwalletMain->autoShieldMinUtxos));
int origin = pwalletMain->hdSeedOrigin;
std::string desc;
switch (origin) {
case CWallet::HDSEED_ORIGIN_CREATED: desc = "created on an empty wallet"; break;
case CWallet::HDSEED_ORIGIN_RESTORED: desc = "restored from -mnemonic/-hdseed"; break;
case CWallet::HDSEED_ORIGIN_RETROFIT: desc = "retrofitted onto a pre-existing wallet"; break;
case CWallet::HDSEED_ORIGIN_UNKNOWN: desc = "predates provenance recording"; break;
default: desc = "not yet recorded"; break;
}
ret.push_back(Pair("hdseedorigin", origin));
ret.push_back(Pair("hdseedorigin_desc", desc));
ret.push_back(Pair("seed_recoverable", pwalletMain->IsMnemonicSeed()));
// Say why it is off. A silent "false" is exactly what made the destination
// un-inspectable in the first place.
std::string why = "";
if (!pwalletMain->fAutoShieldEnabled) {
if (origin != CWallet::HDSEED_ORIGIN_CREATED && origin != CWallet::HDSEED_ORIGIN_RESTORED)
why = "HD seed origin is not known-recoverable; back the seed up and pass -autoshield=1";
else
why = "disabled by -autoshield=0";
} else if (pwalletMain->IsLocked()) {
why = "wallet is locked; rounds are skipped until it is unlocked";
} else if (pwalletMain->fSweepRunning || pwalletMain->fConsolidationRunning) {
// Autoshield is mutually exclusive with sweep and consolidation. Without
// this the RPC reports autoshield=true, running=false and an empty
// reason while no round can actually start.
why = strprintf("deferred while %s is running; rounds resume when it finishes",
pwalletMain->fSweepRunning ? "z_sweep" : "sapling consolidation");
} else if (pwalletMain->autoShieldAddress.empty()) {
why = "no destination resolved yet; one will be derived from the HD seed on the first round";
}
ret.push_back(Pair("disabled_reason", why));
return ret;
}
UniValue z_listreceivedaddress(const UniValue& params, bool fHelp,const CPubKey&)
{
if (!EnsureWalletIsAvailable(fHelp))
@@ -5466,7 +5543,10 @@ UniValue z_sendmany(const UniValue& params, bool fHelp, const CPubKey& mypk)
// Create operation and add to global queue
std::shared_ptr<AsyncRPCQueue> q = getAsyncRPCQueue();
std::shared_ptr<AsyncRPCOperation> operation( new AsyncRPCOperation_sendmany(builder, contextualTx, fromaddress, taddrRecipients, zaddrRecipients, saplingNoteInputs, nMinDepth, nFee, contextInfo, opret) );
q->addOperation(operation);
if (!q->addOperation(operation)) {
throw JSONRPCError(RPC_INTERNAL_ERROR,
"Async RPC queue is shutting down; the operation was not queued");
}
if(fZdebug)
LogPrintf("%s: Submitted to async queue\n", __FUNCTION__);
@@ -5694,7 +5774,13 @@ UniValue z_shieldcoinbase(const UniValue& params, bool fHelp, const CPubKey& myp
// Create operation and add to global queue
std::shared_ptr<AsyncRPCQueue> q = getAsyncRPCQueue();
std::shared_ptr<AsyncRPCOperation> operation( new AsyncRPCOperation_shieldcoinbase(builder, contextualTx, inputs, destaddress, nFee, donation, contextInfo) );
q->addOperation(operation);
// The constructor has already locked the selected coins. Coin locks are
// memory-only, so a refused queue at shutdown reclaims them with the process;
// what must not happen is returning an opid for work that will never run.
if (!q->addOperation(operation)) {
throw JSONRPCError(RPC_INTERNAL_ERROR,
"Async RPC queue is shutting down; the operation was not queued");
}
AsyncRPCOperationId operationId = operation->getId();
// Return continuation information
@@ -6048,7 +6134,10 @@ UniValue z_mergetoaddress(const UniValue& params, bool fHelp, const CPubKey& myp
std::shared_ptr<AsyncRPCQueue> q = getAsyncRPCQueue();
std::shared_ptr<AsyncRPCOperation> operation(
new AsyncRPCOperation_mergetoaddress(builder, contextualTx, utxoInputs, saplingNoteInputs, recipient, nFee, contextInfo) );
q->addOperation(operation);
if (!q->addOperation(operation)) {
throw JSONRPCError(RPC_INTERNAL_ERROR,
"Async RPC queue is shutting down; the operation was not queued");
}
AsyncRPCOperationId operationId = operation->getId();
// Return continuation information
@@ -6349,6 +6438,7 @@ static const CRPCCommand commands[] =
{ "wallet", "z_gettotalbalance", &z_gettotalbalance, false },
{ "wallet", "z_mergetoaddress", &z_mergetoaddress, false },
{ "wallet", "z_sweepstatus", &z_sweepstatus, true },
{ "wallet", "z_autoshieldstatus", &z_autoshieldstatus, true },
{ "wallet", "z_consolidationstatus", &z_consolidationstatus, true },
{ "wallet", "z_sendmany", &z_sendmany, false },
{ "wallet", "z_shieldcoinbase", &z_shieldcoinbase, false },

View File

@@ -592,6 +592,31 @@ void CWallet::RunSaplingSweep(int blockHeight) {
// masked an unsynchronized mutation.) cs_wallet is recursive, so this is
// safe even on any path that already holds it.
LOCK(cs_wallet);
// Stale-baton guard. A successful-but-incomplete sweep round deliberately
// returns with fSweepRunning still set and nextSweep unadvanced (see
// AsyncRPCOperation_sweep::main), as a "continue draining next block" baton.
// But every early return below leaves that baton set WITHOUT re-dispatching,
// and RunSaplingConsolidation -- which is gated on fSweepRunning -- then
// returns without advancing nextConsolidation, so the "consolidation is
// within 5 blocks" blackout at the top of this function never lifts. That
// is a self-sustaining three-way deadlock: sweep waits on consolidation,
// consolidation waits on sweep, and autoshield shares the same gate, so a
// wedged sweep silently disables coinbase shielding forever.
// Only honour the baton while a sweep operation genuinely is in flight.
if (fSweepRunning) {
std::shared_ptr<AsyncRPCQueue> sweepQueue = getAsyncRPCQueue();
std::shared_ptr<AsyncRPCOperation> inFlightSweep =
(sweepQueue != nullptr) ? sweepQueue->getOperationForId(saplingSweepOperationId) : nullptr;
bool inFlight = (inFlightSweep != nullptr) &&
(inFlightSweep->isReady() || inFlightSweep->isExecuting());
if (!inFlight) {
LogPrintf("%s: clearing stale fSweepRunning at blockHeight=%d (no sweep operation in flight)\n",
__func__, blockHeight);
fSweepRunning = false;
}
}
if (!fSweepEnabled) {
return;
}
@@ -626,11 +651,21 @@ void CWallet::RunSaplingSweep(int blockHeight) {
std::shared_ptr<AsyncRPCOperation> lastOperation = q->getOperationForId(saplingSweepOperationId);
if (lastOperation != nullptr) {
lastOperation->cancel();
// Drop it from the queue's map as well. Nothing else ever removes these:
// popOperationForId is only reached from z_getoperationresult, so on a node
// running this every interval the map grew without bound.
q->popOperationForId(saplingSweepOperationId);
}
pendingSaplingSweepTxs.clear();
std::shared_ptr<AsyncRPCOperation> operation(new AsyncRPCOperation_sweep(blockHeight + 5));
saplingSweepOperationId = operation->getId();
q->addOperation(operation);
if (!q->addOperation(operation)) {
// Queue is closing (shutdown). Release the flag we just set, or it stays
// set with no operation in flight and blocks every later round.
LogPrintf("%s: async queue is not accepting operations; skipping this round\n", __func__);
fSweepRunning = false;
return;
}
}
void CWallet::RunSaplingConsolidation(int blockHeight) {
@@ -674,11 +709,21 @@ void CWallet::RunSaplingConsolidation(int blockHeight) {
std::shared_ptr<AsyncRPCOperation> lastOperation = q->getOperationForId(saplingConsolidationOperationId);
if (lastOperation != nullptr) {
lastOperation->cancel();
// Drop it from the queue's map as well. Nothing else ever removes these:
// popOperationForId is only reached from z_getoperationresult, so on a node
// running this every interval the map grew without bound.
q->popOperationForId(saplingConsolidationOperationId);
}
pendingSaplingConsolidationTxs.clear();
std::shared_ptr<AsyncRPCOperation> operation(new AsyncRPCOperation_saplingconsolidation(blockHeight + 5));
saplingConsolidationOperationId = operation->getId();
q->addOperation(operation);
if (!q->addOperation(operation)) {
// Queue is closing (shutdown). Release the flag we just set, or it stays
// set with no operation in flight and blocks every later round.
LogPrintf("%s: async queue is not accepting operations; skipping this round\n", __func__);
fConsolidationRunning = false;
return;
}
}
// Periodically drain matured transparent coinbase into a wallet-owned Sapling
@@ -729,16 +774,28 @@ void CWallet::RunAutoShieldCoinbase(int blockHeight) {
std::shared_ptr<AsyncRPCOperation> lastOperation = q->getOperationForId(saplingAutoShieldOperationId);
if (lastOperation != nullptr) {
lastOperation->cancel();
// Drop it from the queue's map as well. Nothing else ever removes these:
// popOperationForId is only reached from z_getoperationresult, so on a node
// running this every interval the map grew without bound.
q->popOperationForId(saplingAutoShieldOperationId);
}
std::shared_ptr<AsyncRPCOperation> operation(new AsyncRPCOperation_autoshieldcoinbase(blockHeight + 5));
saplingAutoShieldOperationId = operation->getId();
q->addOperation(operation);
if (!q->addOperation(operation)) {
// Queue is closing (shutdown). Release the flag we just set, or it stays
// set with no operation in flight and blocks every later round.
LogPrintf("%s: async queue is not accepting operations; skipping this round\n", __func__);
fAutoShieldRunning = false;
return;
}
}
bool CWallet::CommitAutomatedTx(const CTransaction& tx) {
CWalletTx wtx(this, tx);
CReserveKey reservekey(pwalletMain);
fprintf(stderr,"%s: %s\n",__func__,tx.ToString().c_str());
// No tx dump here: CommitTransaction already LogPrintf's the same wtx.ToString(),
// and ToString() emits a line per vin, so with the 400-input autoshield cap this
// printed tens of KB to stderr on every automated round.
return CommitTransaction(wtx, reservekey);
}

View File

@@ -34,6 +34,15 @@
#include <boost/scoped_ptr.hpp>
#include <boost/thread.hpp>
// Out-of-line definitions for CHDChain's in-class static constants. These are
// only initialised in the class body, so any ODR use -- binding one to a const
// reference, which is exactly what gtest's EXPECT_*/ASSERT_* macros do -- needs
// a definition or the link fails. hush-gtest hit this on VERSION_HD_MNEMONIC.
const int CHDChain::VERSION_HD_BASE;
const int CHDChain::VERSION_HD_TRANSPARENT;
const int CHDChain::VERSION_HD_MNEMONIC;
const int CHDChain::CURRENT_VERSION;
using namespace std;
static uint64_t nAccountingEntryNumber = 0;
@@ -411,6 +420,9 @@ public:
vector<uint256> vWalletUpgrade;
// True once a well-formed "hdchain" record has been loaded.
bool fHDChainRead;
// 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;
CWalletScanState() {
nKeys = nCKeys = nKeyMeta = nZKeys = nCZKeys = nZKeyMeta = nSapZAddrs = 0;
@@ -418,6 +430,7 @@ public:
fAnyUnordered = false;
nFileVersion = 0;
fHDChainRead = false;
fHDChainRepaired = false;
}
};
@@ -842,14 +855,48 @@ ReadKeyValue(CWallet* pwallet, CDataStream& ssKey, CDataStream& ssValue,
else if (strType == "hdchain")
{
CHDChain chain;
// Keep an untouched copy: a failed >> has already consumed part of ssValue.
CDataStream ssRetry(ssValue.begin(), ssValue.end(), ssValue.GetType(), ssValue.GetVersion());
try {
ssValue >> chain;
} catch (...) {
// Do not let this land in the "user can live with it" bucket:
// report it, and leave wss.fHDChainRead false so LoadWallet
// turns it into DB_CORRUPT when a seed is present.
strErr = "Error reading wallet database: hdchain record is corrupt";
return false;
// Downgrade repair. A build predating VERSION_HD_TRANSPARENT writes
// this record back with only the four base fields while leaving
// nVersion at whatever it read, so the version-gated reads above run
// off the end. Without this, one address generated under such a build
// makes the wallet unopenable here ("Wallet corrupted") even though
// nothing is actually lost.
//
// Recovering is safe for a v1/v2 record: everything derivation needs
// is either in the four-field prefix or in the separate hdseed record,
// and the trailing counters are self-healing -- DeriveNewChildKey and
// GenerateNewSaplingZKey both skip indices whose key the wallet
// already holds, so restarting a counter at 0 re-walks past existing
// keys instead of reissuing them.
chain = CHDChain();
try {
ssRetry >> chain.nVersion;
ssRetry >> chain.seedFp;
ssRetry >> chain.nCreateTime;
ssRetry >> chain.saplingAccountCounter;
} catch (...) {
// Short even in the base fields: genuinely corrupt.
strErr = "Error reading wallet database: hdchain record is corrupt";
return false;
}
if (chain.nVersion >= CHDChain::VERSION_HD_MNEMONIC) {
// A record claiming to carry fMnemonicSeed must not have it
// guessed: that flag selects the derivation input, so defaulting
// it wrong yields a different key tree in silence. Fail loud, as
// this branch always did.
strErr = "Error reading wallet database: hdchain record is corrupt";
return false;
}
chain.transparentChildCounter = 0;
chain.fMnemonicSeed = false;
wss.fHDChainRepaired = true;
LogPrintf("Repairing a truncated hdchain record (nVersion=%d): it was last written by "
"a wallet build that predates the transparent HD counter\n", chain.nVersion);
}
wss.fHDChainRead = true;
pwallet->SetHDChain(chain, true);
@@ -1007,6 +1054,18 @@ DBErrors CWalletDB::LoadWallet(CWallet* pwallet)
if (fNoncriticalErrors && result == DB_LOAD_OK)
result = DB_NONCRITICAL_ERROR;
// Rewrite a repaired record in full form so the next load is clean and the
// transparent counter starts being persisted again.
if (wss.fHDChainRepaired && pwallet->HaveHDSeed())
{
try {
pwallet->SetHDChain(pwallet->GetHDChain(), false);
LogPrintf("Rewrote the repaired hdchain record in full form\n");
} catch (const std::exception& e) {
LogPrintf("Could not rewrite the repaired hdchain record: %s\n", e.what());
}
}
// 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
@@ -1018,7 +1077,9 @@ DBErrors CWalletDB::LoadWallet(CWallet* pwallet)
// Fail loud instead of quietly deriving into the wrong tree.
if (pwallet->HaveHDSeed() && !wss.fHDChainRead)
{
LogPrintf("Error loading wallet.dat: HD seed present but the hdchain record is missing or corrupt\n");
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");
return DB_CORRUPT;
}