Skip to content

Commit ffbfb06

Browse files
fix(wallet): never prune the just-written backup on clock skew
Retention ranked backups purely by the timestamp in their filenames and protected the newest -createwalletbackups of them. After the system clock moved backward, every backup written before the jump sorted ahead of the one just created, which could then fall outside the count window and be deleted as a non-oldest member of its age range, right after AutoBackupWallet() had written it and reset nKeysLeftSinceAutoBackup. Pass the time of the backup that triggered pruning into GetBackupsToDelete() and consider only backups dated at or before it. The just-written backup is therefore always the newest candidate, anchors the age ranges and sits inside the count window. Backups dated after it cannot be placed relative to it, so they are left untouched until the clock catches up rather than being ranked ahead of the new backup or pruned against it. The time is floored to the minute to match the precision ParseBackupFileTime() reads back from the name. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
1 parent b735b3a commit ffbfb06

4 files changed

Lines changed: 70 additions & 25 deletions

File tree

‎doc/managing-wallets.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -110,7 +110,7 @@ Non-HD wallets must be backed up every 1000 keys used since the previous backup,
110110

111111
For legacy wallets, Dash Core automatically creates a backup in the `backups` directory inside the data directory on every startup and whenever the keypool is replenished. Each file is named after the wallet with the backup time appended, e.g. `wallet.dat.2026-08-02-14-30`.
112112

113-
Older backups are pruned as new ones are made. The most recent `-createwalletbackups` backups (default: 10, max: 20) are always kept. Beyond those, one backup is kept from each widening age range — 1-2 hours old, 2-4 hours, 4-8 hours, and so on, each range twice as long as the one before (512-1024 hours is about 3-6 weeks) — up to `-maxwalletbackups` files in total (default: 30). This preserves restore points going back months, even for a wallet that writes many backups a day while mixing, while keeping the size of the `backups` directory predictable. Several backups made within the same hour do not each get their own range: beyond the most recent `-createwalletbackups`, only the oldest backup in each range is kept.
113+
Older backups are pruned as new ones are made. The most recent `-createwalletbackups` backups (default: 10, max: 20) are always kept. Beyond those, one backup is kept from each widening age range — 1-2 hours old, 2-4 hours, 4-8 hours, and so on, each range twice as long as the one before (512-1024 hours is about 3-6 weeks) — up to `-maxwalletbackups` files in total (default: 30). This preserves restore points going back months, even for a wallet that writes many backups a day while mixing, while keeping the size of the `backups` directory predictable. Several backups made within the same hour do not each get their own range: beyond the most recent `-createwalletbackups`, only the oldest backup in each range is kept. The backup just written is never pruned, and if the system clock has been set back, backups dated later than it are left untouched until the clock catches up with them.
114114

115115
Setting either option to `0` disables automatic backups, in which case existing backups are left untouched. Renaming a backup also excludes it from pruning, which is a simple way to keep one indefinitely.
116116

‎src/wallet/test/backup_tests.cpp‎

Lines changed: 50 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -42,15 +42,15 @@ BOOST_AUTO_TEST_CASE(time_based_exponential_retention)
4242
for (int i = 0; i < 5; ++i) {
4343
backups.insert({MakeBackupTime(i), MakeBackupPath(i)});
4444
}
45-
auto to_delete = GetBackupsToDelete(backups, 10, 50);
45+
auto to_delete = GetBackupsToDelete(backups, BACKUP_ANCHOR, 10, 50);
4646
BOOST_CHECK(to_delete.empty());
4747

4848
// Case 2: Exactly nWalletBackups (10)
4949
backups.clear();
5050
for (int i = 0; i < 10; ++i) {
5151
backups.insert({MakeBackupTime(i), MakeBackupPath(i)});
5252
}
53-
to_delete = GetBackupsToDelete(backups, 10, 50);
53+
to_delete = GetBackupsToDelete(backups, BACKUP_ANCHOR, 10, 50);
5454
BOOST_CHECK(to_delete.empty());
5555

5656
// Case 3: 11 backups - all taken at the same time
@@ -60,7 +60,7 @@ BOOST_AUTO_TEST_CASE(time_based_exponential_retention)
6060
for (int i = 0; i < 11; ++i) {
6161
backups.insert({MakeBackupTime(0), MakeBackupPath(0, i)});
6262
}
63-
to_delete = GetBackupsToDelete(backups, 10, 50);
63+
to_delete = GetBackupsToDelete(backups, BACKUP_ANCHOR, 10, 50);
6464
// All backups are 0 hours old, so none fall into [1,2) or later ranges
6565
// Keep only latest 10, delete 1
6666
BOOST_CHECK_EQUAL(to_delete.size(), 1);
@@ -83,7 +83,7 @@ BOOST_AUTO_TEST_CASE(time_based_exponential_retention)
8383
backups.insert({MakeBackupTime(25), MakeBackupPath(25)}); // [512,1024) hours
8484
backups.insert({MakeBackupTime(30), MakeBackupPath(30)}); // [512,1024) hours
8585

86-
to_delete = GetBackupsToDelete(backups, 10, 50);
86+
to_delete = GetBackupsToDelete(backups, BACKUP_ANCHOR, 10, 50);
8787

8888
// Should keep:
8989
// - Latest 10 (by count): all backups from today
@@ -108,7 +108,7 @@ BOOST_AUTO_TEST_CASE(time_based_exponential_retention)
108108
backups.insert({MakeBackupTime(i), MakeBackupPath(i)});
109109
}
110110

111-
to_delete = GetBackupsToDelete(backups, 10, 50);
111+
to_delete = GetBackupsToDelete(backups, BACKUP_ANCHOR, 10, 50);
112112

113113
// Should keep:
114114
// - Latest 10 by count and the oldest backup in each exponential time range
@@ -139,7 +139,7 @@ BOOST_AUTO_TEST_CASE(hard_max_limit)
139139
backups.insert({MakeBackupTime(i), MakeBackupPath(i)});
140140
}
141141

142-
auto to_delete = GetBackupsToDelete(backups, 10, 15);
142+
auto to_delete = GetBackupsToDelete(backups, BACKUP_ANCHOR, 10, 15);
143143

144144
// Without maxBackups limit, we'd keep 15 backups (see Case 5 above)
145145
// With maxBackups=15, we still keep all 15 (at the limit)
@@ -160,7 +160,7 @@ BOOST_AUTO_TEST_CASE(hard_max_limit)
160160
BOOST_CHECK(expected_kept_15 == actual_kept_15);
161161

162162
// Now test with maxBackups=12 (less than natural retention)
163-
to_delete = GetBackupsToDelete(backups, 10, 12);
163+
to_delete = GetBackupsToDelete(backups, BACKUP_ANCHOR, 10, 12);
164164

165165
// Should cap at 12 backups: keep latest 10 + the 2 most recent time ranges
166166
// Total: 12 kept, 88 deleted
@@ -202,7 +202,7 @@ BOOST_AUTO_TEST_CASE(irregular_backup_schedule)
202202
// Day 20: 1 backup (gap)
203203
backups.insert({MakeBackupTime(20), MakeBackupPath(20)});
204204

205-
auto to_delete = GetBackupsToDelete(backups, 10, 50);
205+
auto to_delete = GetBackupsToDelete(backups, BACKUP_ANCHOR, 10, 50);
206206

207207
// Should keep:
208208
// - Latest 10 (5 from day 0, 3 from day 1, 2 from day 2)
@@ -234,7 +234,7 @@ BOOST_AUTO_TEST_CASE(long_inactivity_period)
234234
// New backup today
235235
backups.insert({MakeBackupTime(0), MakeBackupPath(0)});
236236

237-
auto to_delete = GetBackupsToDelete(backups, 10, 50);
237+
auto to_delete = GetBackupsToDelete(backups, BACKUP_ANCHOR, 10, 50);
238238

239239
// Should keep:
240240
// - Latest 10 (1 from today, 9 from 60 days ago)
@@ -259,6 +259,40 @@ BOOST_AUTO_TEST_CASE(long_inactivity_period)
259259
BOOST_CHECK(expected_kept == actual_kept);
260260
}
261261

262+
BOOST_AUTO_TEST_CASE(clock_moved_backward)
263+
{
264+
std::multimap<std::chrono::system_clock::time_point, fs::path> backups;
265+
266+
// 40 daily backups written with a correct clock, then the clock jumps back 20 days
267+
// and a new backup is written half an hour after the existing day-20 one.
268+
for (int i = 0; i < 40; ++i) {
269+
backups.insert({MakeBackupTime(i), MakeBackupPath(i)});
270+
}
271+
const auto backup_time{MakeBackupTime(20) + std::chrono::minutes{30}};
272+
const fs::path just_written{MakeBackupPath(20, 30)};
273+
backups.insert({backup_time, just_written});
274+
275+
const auto to_delete = GetBackupsToDelete(backups, backup_time, 10, 50);
276+
const std::set<fs::path> actual_deletions{to_delete.begin(), to_delete.end()};
277+
278+
// Ranked by filename time alone, the new backup would fall behind the 20 later-dated
279+
// files, out of the count window, and be deleted as a non-oldest member of its range.
280+
BOOST_CHECK(!actual_deletions.count(just_written));
281+
282+
// The later-dated backups can't be placed relative to the new one, so none are touched.
283+
for (int i = 0; i < 20; ++i) {
284+
BOOST_CHECK(!actual_deletions.count(MakeBackupPath(i)));
285+
}
286+
287+
// Everything else is pruned as if the new backup were the newest: it and days 20-28
288+
// fill the count window, and days 30 and 39 are the oldest in the [128,256) and
289+
// [256,512) hour ranges before it.
290+
const std::set<fs::path> expected_deletions{MakeBackupPath(29), MakeBackupPath(31), MakeBackupPath(32),
291+
MakeBackupPath(33), MakeBackupPath(34), MakeBackupPath(35),
292+
MakeBackupPath(36), MakeBackupPath(37), MakeBackupPath(38)};
293+
BOOST_CHECK(expected_deletions == actual_deletions);
294+
}
295+
262296
BOOST_AUTO_TEST_CASE(frequent_backups_keep_history)
263297
{
264298
// A wallet that mixes all day can write more backups a day than the count window
@@ -275,7 +309,7 @@ BOOST_AUTO_TEST_CASE(frequent_backups_keep_history)
275309
backups.insert({backup_time, fs::u8path(strprintf("wallet.dat.%04i-%02u-%02u-%02i-%02i", int(ymd.year()),
276310
unsigned(ymd.month()), unsigned(ymd.day()),
277311
hms.hours().count(), hms.minutes().count()))});
278-
for (const auto& path : GetBackupsToDelete(backups, 10, 30)) {
312+
for (const auto& path : GetBackupsToDelete(backups, backup_time, 10, 30)) {
279313
std::erase_if(backups, [&](const auto& entry) { return entry.second == path; });
280314
}
281315
BOOST_REQUIRE_LE(backups.size(), 30U);
@@ -300,9 +334,9 @@ BOOST_AUTO_TEST_CASE(non_positive_max_backups)
300334
}
301335

302336
// maxBackups <= 0 means "delete nothing", regardless of nWalletBackups
303-
BOOST_CHECK(GetBackupsToDelete(backups, 10, 0).empty());
304-
BOOST_CHECK(GetBackupsToDelete(backups, 10, -1).empty());
305-
BOOST_CHECK(GetBackupsToDelete(backups, 0, 0).empty());
337+
BOOST_CHECK(GetBackupsToDelete(backups, BACKUP_ANCHOR, 10, 0).empty());
338+
BOOST_CHECK(GetBackupsToDelete(backups, BACKUP_ANCHOR, 10, -1).empty());
339+
BOOST_CHECK(GetBackupsToDelete(backups, BACKUP_ANCHOR, 0, 0).empty());
306340
}
307341

308342
BOOST_AUTO_TEST_CASE(count_window_boundaries)
@@ -314,7 +348,7 @@ BOOST_AUTO_TEST_CASE(count_window_boundaries)
314348

315349
// maxBackups == nWalletBackups leaves no room for time buckets, degrading to
316350
// the pre-exponential "keep the N most recent" policy.
317-
auto to_delete = GetBackupsToDelete(backups, 10, 10);
351+
auto to_delete = GetBackupsToDelete(backups, BACKUP_ANCHOR, 10, 10);
318352
BOOST_CHECK_EQUAL(to_delete.size(), 10);
319353
std::set<fs::path> expected_deleted;
320354
for (int i = 10; i < 20; ++i) {
@@ -325,7 +359,7 @@ BOOST_AUTO_TEST_CASE(count_window_boundaries)
325359
// An empty count window leaves retention to the time buckets: the newest backup is
326360
// kept as their anchor, then the oldest in each of the [16,32), [32,64), [64,128),
327361
// [128,256) and [256,512) hour ranges.
328-
to_delete = GetBackupsToDelete(backups, 0, 30);
362+
to_delete = GetBackupsToDelete(backups, BACKUP_ANCHOR, 0, 30);
329363
std::set<fs::path> expected_kept{MakeBackupPath(0), MakeBackupPath(1), MakeBackupPath(2),
330364
MakeBackupPath(5), MakeBackupPath(10), MakeBackupPath(19)};
331365
std::set<fs::path> actual_kept;
@@ -337,7 +371,7 @@ BOOST_AUTO_TEST_CASE(count_window_boundaries)
337371
BOOST_CHECK(expected_kept == actual_kept);
338372

339373
// Negative nWalletBackups is treated the same as an empty count window.
340-
BOOST_CHECK(GetBackupsToDelete(backups, -1, 30) == to_delete);
374+
BOOST_CHECK(GetBackupsToDelete(backups, BACKUP_ANCHOR, -1, 30) == to_delete);
341375
}
342376

343377
BOOST_AUTO_TEST_CASE(init_auto_backup_clamping)

‎src/wallet/wallet.cpp‎

Lines changed: 13 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -3663,18 +3663,22 @@ void CWallet::postInitProcess()
36633663
}
36643664

36653665
std::vector<fs::path> GetBackupsToDelete(const std::multimap<std::chrono::system_clock::time_point, fs::path>& backups,
3666-
int nWalletBackups, int maxBackups)
3666+
std::chrono::system_clock::time_point backup_time, int nWalletBackups,
3667+
int maxBackups)
36673668
{
36683669
if (maxBackups <= 0) return {};
36693670

36703671
// CWallet::nWalletBackups doubles as an error status and can be negative, so
36713672
// treat anything below zero as an empty count window rather than wrapping the cast.
36723673
const size_t keep_by_count{nWalletBackups > 0 ? static_cast<size_t>(nWalletBackups) : 0};
3673-
if (backups.size() <= keep_by_count) return {};
36743674

3675-
// Newest first, so age ascends with the index.
3676-
const std::vector<std::pair<std::chrono::system_clock::time_point, fs::path>> sorted_backups(backups.rbegin(),
3677-
backups.rend());
3675+
// Backups dated after the one just written can only come from a clock that has since
3676+
// moved backward. They can't be placed relative to it, and ranking them as newer would
3677+
// push it out of the count window, so leave them alone. Newest first, so age ascends
3678+
// with the index.
3679+
const std::vector<std::pair<std::chrono::system_clock::time_point, fs::path>> sorted_backups(
3680+
std::make_reverse_iterator(backups.upper_bound(backup_time)), backups.rend());
3681+
if (sorted_backups.size() <= keep_by_count) return {};
36783682
const auto newest = sorted_backups[0].first;
36793683

36803684
// Always keep the newest backup, which anchors the age ranges, plus the count window.
@@ -3806,8 +3810,8 @@ bool CWallet::AutoBackupWallet(const fs::path& wallet_path, bilingual_str& error
38063810
}
38073811

38083812
// Create backup of the ...
3813+
const std::chrono::sys_seconds secs{GetTime<std::chrono::seconds>()};
38093814
std::string dateTimeStr = [&]() {
3810-
const std::chrono::sys_seconds secs{GetTime<std::chrono::seconds>()};
38113815
const auto days{std::chrono::floor<std::chrono::days>(secs)};
38123816
const std::chrono::year_month_day ymd{days};
38133817
const std::chrono::hh_mm_ss hms{secs - days};
@@ -3877,7 +3881,9 @@ bool CWallet::AutoBackupWallet(const fs::path& wallet_path, bilingual_str& error
38773881
}
38783882
}
38793883

3880-
std::vector<fs::path> backupsToDelete = GetBackupsToDelete(folder_set, nWalletBackups, nMaxWalletBackups);
3884+
// The filename only carries minutes, so compare at that precision.
3885+
std::vector<fs::path> backupsToDelete = GetBackupsToDelete(folder_set, std::chrono::floor<std::chrono::minutes>(secs),
3886+
nWalletBackups, nMaxWalletBackups);
38813887
for (const auto& path : backupsToDelete) {
38823888
try {
38833889
fs::remove(path);

‎src/wallet/wallet.h‎

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -100,9 +100,14 @@ static constexpr int MAX_N_WALLET_BACKUPS = 20;
100100
* maxBackups <= 0 deletes nothing, and a non-positive nWalletBackups leaves retention
101101
* entirely to the age ranges. Callers must keep nWalletBackups <= maxBackups for the
102102
* total to hold; InitAutoBackup() enforces that.
103+
*
104+
* backup_time is when the backup that triggered pruning was written. Backups dated after
105+
* it, which only a clock that moved backward can produce, are never deleted and take no
106+
* part in retention, so the backup just written is always the newest one and is kept.
103107
*/
104108
std::vector<fs::path> GetBackupsToDelete(const std::multimap<std::chrono::system_clock::time_point, fs::path>& backups,
105-
int nWalletBackups, int maxBackups = DEFAULT_MAX_BACKUPS);
109+
std::chrono::system_clock::time_point backup_time, int nWalletBackups,
110+
int maxBackups = DEFAULT_MAX_BACKUPS);
106111

107112
/**
108113
* Read back the UTC timestamp AutoBackupWallet() embeds in backup filenames (e.g.

0 commit comments

Comments
 (0)