Skip to content

Commit 8612ca3

Browse files
Fix replay over empty-tx-set ledgers (#5355)
`ApplyCheckpointWork` contained a check that the transaction set hash of the transaction set to apply exactly matches the one specified in a ledger header. With CAP-83 however, there is one case where this is not true. An empty-tx-set ledger will have an all zeros hash in the ledger header, but the actual constructed empty transaction set will have a different hash. This PR fixes the check to properly handle empty-tx-set ledgers. It also adds a few additional checks that an empty-tx-set ledger is well-formed. I audited the code for other places where I may have missed these checks in the original CAP-83 PR, and this is the only one I found.
2 parents 350acec + 7db2b14 commit 8612ca3

4 files changed

Lines changed: 323 additions & 2 deletions

File tree

src/catchup/ApplyCheckpointWork.cpp

Lines changed: 39 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
#include "bucket/BucketManager.h"
77
#include "bucket/LiveBucketList.h"
88
#include "catchup/ApplyLedgerWork.h"
9+
#include "herder/Herder.h"
910
#include "history/FileTransferInfo.h"
1011
#include "history/HistoryManager.h"
1112
#include "history/HistoryUtils.h"
@@ -14,6 +15,7 @@
1415
#include "ledger/LedgerManager.h"
1516
#include "main/Application.h"
1617
#include "util/GlobalChecks.h"
18+
#include "util/ProtocolVersion.h"
1719
#include "util/XDRCereal.h"
1820
#include <Tracy.hpp>
1921
#include <fmt/format.h>
@@ -273,11 +275,46 @@ ApplyCheckpointWork::getNextLedgerCloseData()
273275
}
274276
#endif
275277

278+
#ifdef CAP_0083
279+
// Empty-tx-set values should have the empty-tx-set hash (and vice versa)
280+
if ((header.scpValue.txSetHash == Herder::EMPTY_TX_SET_HASH) !=
281+
(header.scpValue.ext.v() == STELLAR_VALUE_EMPTY_TX_SET))
282+
{
283+
throw std::runtime_error(fmt::format(
284+
FMT_STRING("ledger header for {:d} has mismatched empty-tx-set "
285+
"hash and StellarValue type {:d}"),
286+
header.ledgerSeq, static_cast<int32_t>(header.scpValue.ext.v())));
287+
}
288+
#endif // CAP_0083
289+
276290
// We've verified the ledgerHeader (in the "trusted part of history"
277291
// sense) in CATCHUP_VERIFY phase; we now need to check that the
278292
// txhash we're about to apply is the one denoted by that ledger
279-
// header.
280-
if (header.scpValue.txSetHash != txset->getContentsHash())
293+
// header
294+
if (header.scpValue.txSetHash == Herder::EMPTY_TX_SET_HASH)
295+
{
296+
// Should not see empty-tx-set values prior to the protocol version that
297+
// enables them
298+
if (!protocolVersionStartsFrom(lclHeader.header.ledgerVersion,
299+
EMPTY_TX_SET_PROTOCOL_VERSION))
300+
{
301+
throw std::runtime_error(fmt::format(
302+
FMT_STRING("ledger header for {:d} carries the empty-tx-set "
303+
"hash prior to protocol-level support"),
304+
header.ledgerSeq));
305+
}
306+
307+
// Empty-tx-set values must have empty tx sets
308+
if (txset->sizeTxTotal() != 0)
309+
{
310+
throw std::runtime_error(fmt::format(
311+
FMT_STRING("replay txset for {:d} contains {:d} transactions, "
312+
"but its ledger header carries the empty-tx-set "
313+
"hash"),
314+
header.ledgerSeq, txset->sizeTxTotal()));
315+
}
316+
}
317+
else if (header.scpValue.txSetHash != txset->getContentsHash())
281318
{
282319
throw std::runtime_error(
283320
fmt::format(FMT_STRING("replay txset hash differs from txset hash "

src/history/test/HistoryTests.cpp

Lines changed: 200 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,11 @@
44

55
#include "bucket/BucketManager.h"
66
#include "bucket/test/BucketTestUtils.h"
7+
#include "catchup/ApplyCheckpointWork.h"
78
#include "catchup/DownloadApplyTxsWork.h"
89
#include "catchup/LedgerApplyManagerImpl.h"
910
#include "catchup/test/CatchupWorkTests.h"
11+
#include "crypto/SHA.h"
1012
#include "herder/HerderPersistence.h"
1113
#include "history/CheckpointBuilder.h"
1214
#include "history/FileTransferInfo.h"
@@ -26,6 +28,7 @@
2628
#include "test/test.h"
2729
#include "util/Fs.h"
2830
#include "util/Logging.h"
31+
#include "util/ProtocolVersion.h"
2932
#include "work/WorkScheduler.h"
3033

3134
#include "historywork/BatchDownloadWork.h"
@@ -1127,6 +1130,203 @@ TEST_CASE("History catchup with extra validation", "[history][publish]")
11271130
REQUIRE(catchupSimulation.catchupOffline(app, checkpointLedger, true));
11281131
}
11291132

1133+
#ifdef CAP_0083
1134+
TEST_CASE("History catchup over empty-tx-set ledgers", "[history][catchup]")
1135+
{
1136+
CatchupSimulation catchupSimulation{};
1137+
1138+
// Generate a few random ledgers
1139+
while (
1140+
catchupSimulation.getApp().getLedgerManager().getLastClosedLedgerNum() <
1141+
8)
1142+
{
1143+
catchupSimulation.generateRandomLedger();
1144+
}
1145+
1146+
// One isolated empty-tx-set ledger, a normal ledger, then two consecutive
1147+
// empty-tx-set ledgers, all within the first published checkpoint.
1148+
catchupSimulation.generateEmptyTxSetLedger();
1149+
catchupSimulation.generateRandomLedger();
1150+
catchupSimulation.generateEmptyTxSetLedger();
1151+
catchupSimulation.generateEmptyTxSetLedger();
1152+
1153+
auto checkpointLedger = catchupSimulation.getLastCheckpointLedger(1);
1154+
catchupSimulation.ensureOnlineCatchupPossible(checkpointLedger, 5);
1155+
1156+
SECTION("offline")
1157+
{
1158+
auto app = catchupSimulation.createCatchupApplication(
1159+
std::numeric_limits<uint32_t>::max(),
1160+
Config::TESTDB_BUCKET_DB_PERSISTENT, "skip-offline");
1161+
REQUIRE(catchupSimulation.catchupOffline(app, checkpointLedger, true));
1162+
}
1163+
1164+
SECTION("online")
1165+
{
1166+
auto app = catchupSimulation.createCatchupApplication(
1167+
std::numeric_limits<uint32_t>::max(),
1168+
Config::TESTDB_BUCKET_DB_PERSISTENT, "skip-online");
1169+
REQUIRE(catchupSimulation.catchupOnline(app, checkpointLedger, 5));
1170+
}
1171+
}
1172+
1173+
TEST_CASE("History catchup rejects empty-tx-set ledgers with transactions",
1174+
"[history][catchup]")
1175+
{
1176+
CatchupSimulation catchupSimulation{};
1177+
auto& simApp = catchupSimulation.getApp();
1178+
1179+
while (simApp.getLedgerManager().getLastClosedLedgerNum() < 8)
1180+
{
1181+
catchupSimulation.generateRandomLedger();
1182+
}
1183+
catchupSimulation.generateEmptyTxSetLedger();
1184+
uint32_t const emptyTxSetSeq =
1185+
simApp.getLedgerManager().getLastClosedLedgerNum();
1186+
1187+
auto checkpointLedger = catchupSimulation.getLastCheckpointLedger(1);
1188+
catchupSimulation.ensureOfflineCatchupPossible(checkpointLedger);
1189+
1190+
// Forge a non-empty TransactionHistoryEntry for the empty-tx-set ledger
1191+
// into the published transactions file, keeping the file sorted by
1192+
// ledgerSeq.
1193+
std::string archiveDir =
1194+
catchupSimulation.getHistoryConfigurator().getArchiveDirName();
1195+
FileTransferInfo txFileInfo(FileType::HISTORY_FILE_TYPE_TRANSACTIONS,
1196+
checkpointLedger, simApp.getConfig());
1197+
std::string gzPath = archiveDir + "/" + txFileInfo.remoteName();
1198+
fs::checkGzipSuffix(gzPath);
1199+
auto nonGzPath = gzPath.substr(0, gzPath.size() - 3);
1200+
1201+
auto& wm = simApp.getWorkScheduler();
1202+
REQUIRE(wm.executeWork<GunzipFileWork>(gzPath)->getState() ==
1203+
BasicWork::State::WORK_SUCCESS);
1204+
{
1205+
XDRInputFileStream in;
1206+
in.open(nonGzPath);
1207+
std::vector<TransactionHistoryEntry> txs;
1208+
TransactionHistoryEntry tx;
1209+
while (in && in.readOne(tx))
1210+
{
1211+
txs.push_back(tx);
1212+
}
1213+
in.close();
1214+
REQUIRE(!txs.empty());
1215+
REQUIRE(std::filesystem::remove(nonGzPath));
1216+
1217+
// Copy an existing non-empty entry and retarget it at the empty-tx-set
1218+
// ledger
1219+
TransactionHistoryEntry forged = txs.front();
1220+
forged.ledgerSeq = emptyTxSetSeq;
1221+
auto insertAt =
1222+
std::find_if(txs.begin(), txs.end(), [&](auto const& e) {
1223+
return e.ledgerSeq > emptyTxSetSeq;
1224+
});
1225+
txs.insert(insertAt, forged);
1226+
1227+
XDROutputFileStream out(simApp.getClock().getIOContext(), true);
1228+
out.open(nonGzPath);
1229+
for (auto const& e : txs)
1230+
{
1231+
out.writeOne(e);
1232+
}
1233+
out.close();
1234+
}
1235+
REQUIRE(wm.executeWork<GzipFileWork>(nonGzPath)->getState() ==
1236+
BasicWork::State::WORK_SUCCESS);
1237+
1238+
// Catch up a fresh node through the tampered checkpoint, driving the
1239+
// works directly
1240+
auto app = catchupSimulation.createCatchupApplication(
1241+
std::numeric_limits<uint32_t>::max(),
1242+
Config::TESTDB_BUCKET_DB_PERSISTENT, "skip-tamper");
1243+
1244+
auto& appWm = app->getWorkScheduler();
1245+
auto tmpDir = app->getTmpDirManager().tmpDir("skip-tamper-download");
1246+
LedgerRange range = LedgerRange::inclusive(
1247+
LedgerManager::GENESIS_LEDGER_SEQ + 1, checkpointLedger);
1248+
CheckpointRange checkpointRange{range, app->getHistoryManager()};
1249+
1250+
auto downloadHeaders = appWm.executeWork<BatchDownloadWork>(
1251+
checkpointRange, FileType::HISTORY_FILE_TYPE_LEDGER, tmpDir);
1252+
REQUIRE(downloadHeaders->getState() == BasicWork::State::WORK_SUCCESS);
1253+
1254+
auto lastApplied = app->getLedgerManager().getLastClosedLedgerHeader();
1255+
auto work = appWm.executeWork<DownloadApplyTxsWork>(
1256+
tmpDir, range, lastApplied, /*waitForPublish=*/true, nullptr);
1257+
REQUIRE(work->getState() == BasicWork::State::WORK_FAILURE);
1258+
// Replay stops exactly at the skip ledger whose forged entry was rejected
1259+
REQUIRE(app->getLedgerManager().getLastClosedLedgerNum() ==
1260+
emptyTxSetSeq - 1);
1261+
}
1262+
1263+
TEST_CASE("ApplyCheckpointWork rejects malformed empty-tx-set ledger headers",
1264+
"[history][catchup]")
1265+
{
1266+
auto runWithFabricatedHeader =
1267+
[](uint32_t genesisVersion, std::function<void(StellarValue&)> mutate) {
1268+
Config cfg(getTestConfig(0));
1269+
cfg.TESTING_UPGRADE_LEDGER_PROTOCOL_VERSION = genesisVersion;
1270+
VirtualClock clock;
1271+
auto app = createTestApplication(clock, cfg);
1272+
auto const& lcl =
1273+
app->getLedgerManager().getLastClosedLedgerHeader();
1274+
1275+
LedgerHeaderHistoryEntry entry;
1276+
entry.header.ledgerSeq = lcl.header.ledgerSeq + 1;
1277+
entry.header.previousLedgerHash = lcl.hash;
1278+
entry.header.ledgerVersion = lcl.header.ledgerVersion;
1279+
mutate(entry.header.scpValue);
1280+
entry.hash = sha256(xdr::xdr_to_opaque(entry.header));
1281+
1282+
auto checkpoint = HistoryManager::checkpointContainingLedger(
1283+
entry.header.ledgerSeq, app->getConfig());
1284+
auto tmpDir =
1285+
app->getTmpDirManager().tmpDir("malformed-empty-tx-set-header");
1286+
FileTransferInfo hi(tmpDir, FileType::HISTORY_FILE_TYPE_LEDGER,
1287+
checkpoint);
1288+
{
1289+
XDROutputFileStream out(app->getClock().getIOContext(), true);
1290+
out.open(hi.localPath_nogz());
1291+
out.writeOne(entry);
1292+
}
1293+
// The transactions file must exist even when empty
1294+
FileTransferInfo ti(
1295+
tmpDir, FileType::HISTORY_FILE_TYPE_TRANSACTIONS, checkpoint);
1296+
{
1297+
XDROutputFileStream out(app->getClock().getIOContext(), true);
1298+
out.open(ti.localPath_nogz());
1299+
}
1300+
1301+
auto range = LedgerRange::inclusive(lcl.header.ledgerSeq,
1302+
entry.header.ledgerSeq);
1303+
auto w = app->getWorkScheduler().executeWork<ApplyCheckpointWork>(
1304+
tmpDir, range, OnFailureCallback{});
1305+
return w->getState();
1306+
};
1307+
1308+
SECTION("empty-tx-set hash without empty-tx-set value")
1309+
{
1310+
REQUIRE(runWithFabricatedHeader(Config::CURRENT_LEDGER_PROTOCOL_VERSION,
1311+
[](StellarValue& sv) {
1312+
sv.txSetHash =
1313+
Herder::EMPTY_TX_SET_HASH;
1314+
// ext stays STELLAR_VALUE_BASIC
1315+
}) == BasicWork::State::WORK_FAILURE);
1316+
}
1317+
1318+
SECTION("empty-tx-set value before protocol support")
1319+
{
1320+
REQUIRE(runWithFabricatedHeader(
1321+
static_cast<uint32_t>(EMPTY_TX_SET_PROTOCOL_VERSION) - 1,
1322+
[](StellarValue& sv) {
1323+
sv.txSetHash = Herder::EMPTY_TX_SET_HASH;
1324+
sv.ext.v(STELLAR_VALUE_EMPTY_TX_SET);
1325+
}) == BasicWork::State::WORK_FAILURE);
1326+
}
1327+
}
1328+
#endif // CAP_0083
1329+
11301330
TEST_CASE("Publish works correctly post shadow removal", "[history]")
11311331
{
11321332
// Given a HAS, verify that appropriate levels have "next" cleared, while

src/history/test/HistoryTestsUtils.cpp

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -757,6 +757,85 @@ CatchupSimulation::generateRandomLedger(uint32_t version)
757757
stroopySeqs.push_back(stroopy.loadSequenceNumber());
758758
}
759759

760+
#ifdef CAP_0083
761+
void
762+
CatchupSimulation::generateEmptyTxSetLedger()
763+
{
764+
auto& lm = getApp().getLedgerManager();
765+
auto const lcl = lm.getLastClosedLedgerHeader();
766+
uint32_t ledgerSeq = lcl.header.ledgerSeq + 1;
767+
uint64_t closeTime = 60 * 5 * ledgerSeq;
768+
769+
// An empty-tx-set value replaces a proposed value whose tx set the network
770+
// gave up waiting for. Craft such a proposal (the referenced tx set
771+
// deliberately does not exist anywhere) and convert it exactly as balloting
772+
// does when voting to drop the tx set.
773+
Hash proposedTxSetHash;
774+
proposedTxSetHash.fill(0xAB);
775+
StellarValue proposal = getApp().getHerder().makeStellarValue(
776+
proposedTxSetHash, closeTime, emptyUpgradeSteps,
777+
getApp().getConfig().NODE_SEED);
778+
779+
auto& herder = static_cast<HerderImpl&>(getApp().getHerder());
780+
Value emptyValue = herder.getHerderSCPDriver().makeEmptyTxSetValueFromValue(
781+
xdr::xdr_to_opaque(proposal));
782+
StellarValue sv;
783+
xdr::xdr_from_opaque(emptyValue, sv);
784+
785+
// The applied tx set is the empty set pinned to the current LCL
786+
TxSetXDRFrameConstPtr txSet = TxSetXDRFrame::makeEmpty(lcl);
787+
788+
CLOG_INFO(History, "Closing synthetic empty-tx-set ledger {}", ledgerSeq);
789+
790+
mLedgerCloseDatas.emplace_back(ledgerSeq, txSet, sv);
791+
792+
lm.applyLedger(mLedgerCloseDatas.back());
793+
794+
auto const& lclh = lm.getLastClosedLedgerHeader();
795+
// The header must record the empty-tx-set hash, not the applied
796+
// (empty) tx set's contents hash.
797+
REQUIRE(lclh.header.scpValue.txSetHash == Herder::EMPTY_TX_SET_HASH);
798+
REQUIRE(lclh.header.scpValue.ext.v() == STELLAR_VALUE_EMPTY_TX_SET);
799+
800+
auto root = getApp().getRoot();
801+
auto alice = TestAccount{getApp(), getAccount("alice")};
802+
auto bob = TestAccount{getApp(), getAccount("bob")};
803+
auto carol = TestAccount{getApp(), getAccount("carol")};
804+
auto eve = TestAccount{getApp(), getAccount("eve")};
805+
auto stroopy = TestAccount{getApp(), getAccount("stroopy")};
806+
807+
mLedgerSeqs.push_back(lclh.header.ledgerSeq);
808+
mLedgerHashes.push_back(lclh.hash);
809+
mBucketListHashes.push_back(lclh.header.bucketListHash);
810+
mBucket0Hashes.push_back(getApp()
811+
.getBucketManager()
812+
.getLiveBucketList()
813+
.getLevel(0)
814+
.getCurr()
815+
->getHash());
816+
mBucket1Hashes.push_back(getApp()
817+
.getBucketManager()
818+
.getLiveBucketList()
819+
.getLevel(2)
820+
.getCurr()
821+
->getHash());
822+
823+
rootBalances.push_back(root->getBalance());
824+
aliceBalances.push_back(alice.getBalance());
825+
bobBalances.push_back(bob.getBalance());
826+
carolBalances.push_back(carol.getBalance());
827+
eveBalances.push_back(eve.getBalance());
828+
stroopyBalances.push_back(stroopy.getBalance());
829+
830+
rootSeqs.push_back(root->loadSequenceNumber());
831+
aliceSeqs.push_back(alice.loadSequenceNumber());
832+
bobSeqs.push_back(bob.loadSequenceNumber());
833+
carolSeqs.push_back(carol.loadSequenceNumber());
834+
eveSeqs.push_back(eve.loadSequenceNumber());
835+
stroopySeqs.push_back(stroopy.loadSequenceNumber());
836+
}
837+
#endif // CAP_0083
838+
760839
void
761840
CatchupSimulation::setUpgradeLedger(uint32_t ledger,
762841
ProtocolVersion upgradeProtocolVersion)

src/history/test/HistoryTestsUtils.h

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -261,6 +261,11 @@ class CatchupSimulation
261261

262262
void generateRandomLedger(uint32_t version = 0);
263263

264+
#ifdef CAP_0083
265+
// Closes a CAP-0083 empty-tx-set ledger
266+
void generateEmptyTxSetLedger();
267+
#endif
268+
264269
void ensurePublishesComplete();
265270
void
266271
ensureLedgerAvailable(uint32_t targetLedger,

0 commit comments

Comments
 (0)