refactor(export): remove legacy collector lane

This commit is contained in:
Nicholas Dudfield
2026-07-16 15:44:21 +07:00
parent c021365516
commit 4f9d58eb46
10 changed files with 618 additions and 2215 deletions

View File

@@ -1,271 +0,0 @@
//------------------------------------------------------------------------------
/*
This file is part of rippled: https://github.com/ripple/rippled
Permission to use, copy, modify, and/or distribute this software for any
purpose with or without fee is hereby granted, provided that the above
copyright notice and this permission notice appear in all copies.
THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES WHATSOEVER
RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF
CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN
CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
*/
//==============================================================================
#include <xrpld/app/misc/ExportSigCollectorV2.h>
#include <xrpl/basics/StringUtilities.h>
#include <xrpl/beast/unit_test.h>
#include <xrpl/protocol/SecretKey.h>
#include <xrpl/protocol/Serializer.h>
#include <xrpl/protocol/digest.h>
namespace ripple {
namespace test {
namespace {
uint256
origin(std::uint32_t value)
{
Serializer s;
s.add32(value);
return sha512Half(s.slice());
}
PublicKey
publicKey(char const* hex)
{
auto const raw = strUnHex(hex);
return PublicKey{makeSlice(*raw)};
}
Buffer
signature(std::uint8_t value)
{
std::uint8_t bytes[] = {value, std::uint8_t(value + 1)};
return Buffer{bytes, sizeof(bytes)};
}
} // namespace
class ExportSigCollectorV2_test : public beast::unit_test::suite
{
PublicKey const keyA_ = publicKey(
"0388935426E0D08083314842EDFBB2D517BD47699F9A4527318A8E10468C97C05"
"2");
PublicKey const keyB_ = randomKeyPair(KeyType::secp256k1).first;
static ExportSigCollectorV2::Contribution
contribution(
ExportSigCollectorV2::Position position,
PublicKey const& key,
std::uint8_t sig)
{
return {position, key, signature(sig)};
}
public:
void
testAdmissionAndConflict()
{
testcase("v2 admission and absorbing conflict");
ExportSigCollectorV2 collector;
auto const w = origin(1);
auto const publication = collector.reopenPublication(w, w, 9);
BEAST_EXPECT(publication.has_value());
auto first = collector.beginAttributedAdmission(
w, contribution(7, keyA_, 1), 10);
BEAST_EXPECT(first.result == ExportSigCollectorV2::BeginResult::verify);
BEAST_EXPECT(first.ticket.has_value());
BEAST_EXPECT(collector.fullUnionSnapshot().empty());
if (!first.ticket)
return;
auto accepted =
collector.admitContribution(std::move(*first.ticket), true, 10);
BEAST_EXPECT(
accepted.result == ExportSigCollectorV2::AdmitResult::accepted);
auto snapshot = collector.fullUnionSnapshot();
BEAST_EXPECT(snapshot.at(w).size() == 1);
BEAST_EXPECT(snapshot.at(w).front().position == 7);
auto duplicate = collector.beginAttributedAdmission(
w, contribution(7, keyA_, 1), 11);
BEAST_EXPECT(
duplicate.result == ExportSigCollectorV2::BeginResult::duplicate);
auto second = collector.beginAttributedAdmission(
w, contribution(7, keyB_, 2), 11);
auto third = collector.beginAttributedAdmission(
w, contribution(7, keyA_, 3), 11);
BEAST_EXPECT(second.ticket.has_value());
BEAST_EXPECT(
third.result == ExportSigCollectorV2::BeginResult::capacity);
if (!second.ticket)
return;
auto conflicted =
collector.admitContribution(std::move(*second.ticket), true, 11);
BEAST_EXPECT(
conflicted.result == ExportSigCollectorV2::AdmitResult::conflicted);
BEAST_EXPECT(conflicted.priorContribution.has_value());
BEAST_EXPECT(conflicted.conflictingContribution.has_value());
BEAST_EXPECT(
collector.positionStatus(w, 7) ==
ExportSigCollectorV2::PositionStatus::conflicted);
BEAST_EXPECT(collector.fullUnionSnapshot().empty());
BEAST_EXPECT(
collector.beginAttributedAdmission(w, contribution(7, keyA_, 4), 12)
.result == ExportSigCollectorV2::BeginResult::conflicted);
}
void
testReservationAndPublicationLifecycle()
{
testcase("v2 verification reservations and publication reopening");
ExportSigCollectorV2 collector;
auto const w = origin(2);
auto publication = collector.reopenPublication(w, w, 19);
BEAST_EXPECT(publication.has_value());
auto invalid = collector.beginAttributedAdmission(
w, contribution(3, keyA_, 10), 20);
BEAST_EXPECT(invalid.ticket.has_value());
if (!invalid.ticket)
return;
BEAST_EXPECT(
collector.admitContribution(std::move(*invalid.ticket), false, 20)
.result == ExportSigCollectorV2::AdmitResult::invalid);
auto valid = collector.beginAttributedAdmission(
w, contribution(3, keyA_, 11), 20);
BEAST_EXPECT(valid.ticket.has_value());
if (!valid.ticket)
return;
BEAST_EXPECT(
collector.admitContribution(std::move(*valid.ticket), true, 20)
.result == ExportSigCollectorV2::AdmitResult::accepted);
auto other = collector.beginAttributedAdmission(
w, contribution(4, keyB_, 12), 20);
BEAST_EXPECT(other.ticket.has_value());
if (!other.ticket)
return;
BEAST_EXPECT(
collector.admitContribution(std::move(*other.ticket), true, 20)
.result == ExportSigCollectorV2::AdmitResult::accepted);
BEAST_EXPECT(publication.has_value());
if (!publication)
return;
BEAST_EXPECT(collector.claimPublication(*publication, 3, 2));
BEAST_EXPECT(collector.publicationGeneration(w) == 1);
BEAST_EXPECT(!collector.claimPublication(*publication, 3, 2));
// Reopening the same trigger is idempotent and does not reset slots.
auto samePublication = collector.reopenPublication(w, w, 21);
BEAST_EXPECT(samePublication.has_value());
if (!samePublication)
return;
BEAST_EXPECT(collector.publicationGeneration(w) == 1);
BEAST_EXPECT(!collector.claimPublication(*samePublication, 3, 2));
auto const nextTrigger = origin(22);
auto nextPublication = collector.reopenPublication(w, nextTrigger, 22);
BEAST_EXPECT(nextPublication.has_value());
if (!nextPublication)
return;
BEAST_EXPECT(collector.publicationGeneration(w) == 2);
BEAST_EXPECT(!collector.claimPublication(*publication, 4, 2));
BEAST_EXPECT(collector.claimPublication(*nextPublication, 4, 1));
BEAST_EXPECT(!collector.reopenPublication(w, w, 23));
BEAST_EXPECT(
collector.positionStatus(w, 3) ==
ExportSigCollectorV2::PositionStatus::unique);
BEAST_EXPECT(collector.fullUnionSnapshot().at(w).size() == 2);
collector.cleanupStale(278);
BEAST_EXPECT(!collector.fullUnionSnapshot().empty());
collector.cleanupStale(279);
BEAST_EXPECT(collector.fullUnionSnapshot().empty());
}
void
testMalformedBoundaries()
{
testcase("v2 malformed contribution boundaries");
ExportSigCollectorV2 collector;
auto good = contribution(0, keyA_, 1);
BEAST_EXPECT(
collector.beginAttributedAdmission(uint256{}, good).result ==
ExportSigCollectorV2::BeginResult::malformed);
auto const w = origin(3);
BEAST_EXPECT(
collector.beginAttributedAdmission(w, good, 1).result ==
ExportSigCollectorV2::BeginResult::unknownOrigin);
BEAST_EXPECT(collector.reopenPublication(w, w, 1).has_value());
good.position = ExportLimits::maxCommitteeMembers;
BEAST_EXPECT(
collector.beginAttributedAdmission(w, good, 1).result ==
ExportSigCollectorV2::BeginResult::malformed);
good.position = 0;
good.signature = Buffer{};
BEAST_EXPECT(
collector.beginAttributedAdmission(w, good, 1).result ==
ExportSigCollectorV2::BeginResult::malformed);
std::vector<std::uint8_t> oversized(
ExportSigCollectorV2::maxSignatureBytes + 1, 0xAB);
good.signature = Buffer{oversized.data(), oversized.size()};
BEAST_EXPECT(
collector.beginAttributedAdmission(w, good, 1).result ==
ExportSigCollectorV2::BeginResult::malformed);
good = contribution(5, keyA_, 5);
auto abandoned = collector.beginAttributedAdmission(w, good, 2);
BEAST_EXPECT(abandoned.ticket.has_value());
if (!abandoned.ticket)
return;
BEAST_EXPECT(collector.cancelAdmission(std::move(*abandoned.ticket)));
BEAST_EXPECT(
collector.beginAttributedAdmission(w, good, 2).result ==
ExportSigCollectorV2::BeginResult::verify);
auto expired =
collector.beginAttributedAdmission(w, contribution(6, keyA_, 6), 3);
BEAST_EXPECT(expired.ticket.has_value());
BEAST_EXPECT(
collector.beginAttributedAdmission(w, contribution(6, keyB_, 7), 5)
.result == ExportSigCollectorV2::BeginResult::verify);
auto oldToken = collector.reopenPublication(w, origin(30), 6);
BEAST_EXPECT(oldToken.has_value());
collector.clear(w);
auto newToken = collector.reopenPublication(w, origin(31), 7);
BEAST_EXPECT(newToken.has_value());
if (oldToken)
BEAST_EXPECT(!collector.claimPublication(*oldToken, 0, 1));
}
void
run() override
{
testAdmissionAndConflict();
testReservationAndPublicationLifecycle();
testMalformedBoundaries();
}
};
BEAST_DEFINE_TESTSUITE(ExportSigCollectorV2, app, ripple);
} // namespace test
} // namespace ripple

View File

@@ -3,16 +3,16 @@
This file is part of rippled: https://github.com/ripple/rippled
Permission to use, copy, modify, and/or distribute this software for any
purpose with or without fee is hereby granted, provided that the above
purpose with or without fee is hereby granted, provided that the above
copyright notice and this permission notice appear in all copies.
THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES WHATSOEVER
RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF
CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN
CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
*/
//==============================================================================
@@ -22,7 +22,6 @@
#include <xrpl/protocol/SecretKey.h>
#include <xrpl/protocol/Serializer.h>
#include <xrpl/protocol/digest.h>
#include <cstring>
namespace ripple {
namespace test {
@@ -30,444 +29,238 @@ namespace test {
namespace {
uint256
makeHash(char const* label)
{
return sha512Half(Slice(label, std::strlen(label)));
}
uint256
makeHashFromSeed(std::uint32_t seed)
origin(std::uint32_t value)
{
Serializer s;
s.add32(seed);
s.add32(value);
return sha512Half(s.slice());
}
PublicKey
makePublicKey(char const* hex)
publicKey(char const* hex)
{
auto const raw = strUnHex(hex);
return PublicKey{makeSlice(*raw)};
}
Buffer
makeSignature(std::uint8_t seed)
signature(std::uint8_t value)
{
std::uint8_t bytes[] = {
seed,
static_cast<std::uint8_t>(seed + 1),
static_cast<std::uint8_t>(seed + 2)};
return Buffer(bytes, sizeof(bytes));
std::uint8_t bytes[] = {value, std::uint8_t(value + 1)};
return Buffer{bytes, sizeof(bytes)};
}
} // namespace
class ExportSigCollector_test : public beast::unit_test::suite
{
PublicKey const validator_ = makePublicKey(
PublicKey const keyA_ = publicKey(
"0388935426E0D08083314842EDFBB2D517BD47699F9A4527318A8E10468C97C05"
"2");
PublicKey const keyB_ = randomKeyPair(KeyType::secp256k1).first;
static ExportSigCollector::Contribution
contribution(
ExportSigCollector::Position position,
PublicKey const& key,
std::uint8_t sig)
{
return {position, key, signature(sig)};
}
public:
void
testCleanupUsesFirstSeenSeq()
testAdmissionAndConflict()
{
testcase("cleanup uses first seen sequence");
testcase("admission and absorbing conflict");
ExportSigCollector collector;
auto const tx = makeHash("cleanup-verified");
auto const sig = makeSignature(1);
auto const w = origin(1);
auto const publication = collector.reopenPublication(w, w, 9);
BEAST_EXPECT(publication.has_value());
auto first = collector.beginAttributedAdmission(
w, contribution(7, keyA_, 1), 10);
BEAST_EXPECT(first.result == ExportSigCollector::BeginResult::verify);
BEAST_EXPECT(first.ticket.has_value());
BEAST_EXPECT(collector.fullUnionSnapshot().empty());
if (!first.ticket)
return;
collector.addVerifiedSignature(tx, validator_, sig, 10);
BEAST_EXPECT(collector.signatureCount(tx) == 1);
auto accepted =
collector.admitContribution(std::move(*first.ticket), true, 10);
BEAST_EXPECT(
accepted.result == ExportSigCollector::AdmitResult::accepted);
auto snapshot = collector.fullUnionSnapshot();
BEAST_EXPECT(snapshot.at(w).size() == 1);
BEAST_EXPECT(snapshot.at(w).front().position == 7);
collector.cleanupStale(266);
BEAST_EXPECT(collector.signatureCount(tx) == 1);
auto duplicate = collector.beginAttributedAdmission(
w, contribution(7, keyA_, 1), 11);
BEAST_EXPECT(
duplicate.result == ExportSigCollector::BeginResult::duplicate);
collector.cleanupStale(267);
BEAST_EXPECT(collector.signatureCount(tx) == 0);
auto second = collector.beginAttributedAdmission(
w, contribution(7, keyB_, 2), 11);
auto third = collector.beginAttributedAdmission(
w, contribution(7, keyA_, 3), 11);
BEAST_EXPECT(second.ticket.has_value());
BEAST_EXPECT(third.result == ExportSigCollector::BeginResult::capacity);
if (!second.ticket)
return;
auto conflicted =
collector.admitContribution(std::move(*second.ticket), true, 11);
BEAST_EXPECT(
conflicted.result == ExportSigCollector::AdmitResult::conflicted);
BEAST_EXPECT(conflicted.priorContribution.has_value());
BEAST_EXPECT(conflicted.conflictingContribution.has_value());
BEAST_EXPECT(
collector.positionStatus(w, 7) ==
ExportSigCollector::PositionStatus::conflicted);
BEAST_EXPECT(collector.fullUnionSnapshot().empty());
BEAST_EXPECT(
collector.beginAttributedAdmission(w, contribution(7, keyA_, 4), 12)
.result == ExportSigCollector::BeginResult::conflicted);
}
void
testUpgradeSetsFirstSeenSeq()
testReservationAndPublicationLifecycle()
{
testcase("upgrade sets first seen sequence");
testcase("verification reservations and publication reopening");
ExportSigCollector collector;
auto const tx = makeHash("cleanup-upgraded");
auto const sig = makeSignature(5);
auto const w = origin(2);
auto publication = collector.reopenPublication(w, w, 19);
BEAST_EXPECT(publication.has_value());
auto invalid = collector.beginAttributedAdmission(
w, contribution(3, keyA_, 10), 20);
BEAST_EXPECT(invalid.ticket.has_value());
if (!invalid.ticket)
return;
BEAST_EXPECT(
collector.admitContribution(std::move(*invalid.ticket), false, 20)
.result == ExportSigCollector::AdmitResult::invalid);
collector.addUnverifiedSignature(tx, validator_, sig);
BEAST_EXPECT(collector.hasUnverifiedSignatures());
auto valid = collector.beginAttributedAdmission(
w, contribution(3, keyA_, 11), 20);
BEAST_EXPECT(valid.ticket.has_value());
if (!valid.ticket)
return;
BEAST_EXPECT(
collector.admitContribution(std::move(*valid.ticket), true, 20)
.result == ExportSigCollector::AdmitResult::accepted);
collector.upgradeSignature(tx, validator_, sig, 10);
BEAST_EXPECT(!collector.hasUnverifiedSignatures());
BEAST_EXPECT(collector.signatureCount(tx) == 1);
auto other = collector.beginAttributedAdmission(
w, contribution(4, keyB_, 12), 20);
BEAST_EXPECT(other.ticket.has_value());
if (!other.ticket)
return;
BEAST_EXPECT(
collector.admitContribution(std::move(*other.ticket), true, 20)
.result == ExportSigCollector::AdmitResult::accepted);
collector.cleanupStale(266);
BEAST_EXPECT(collector.signatureCount(tx) == 1);
BEAST_EXPECT(publication.has_value());
if (!publication)
return;
BEAST_EXPECT(collector.claimPublication(*publication, 3, 2));
BEAST_EXPECT(collector.publicationGeneration(w) == 1);
BEAST_EXPECT(!collector.claimPublication(*publication, 3, 2));
collector.cleanupStale(267);
BEAST_EXPECT(collector.signatureCount(tx) == 0);
// Reopening the same trigger is idempotent and does not reset slots.
auto samePublication = collector.reopenPublication(w, w, 21);
BEAST_EXPECT(samePublication.has_value());
if (!samePublication)
return;
BEAST_EXPECT(collector.publicationGeneration(w) == 1);
BEAST_EXPECT(!collector.claimPublication(*samePublication, 3, 2));
auto const nextTrigger = origin(22);
auto nextPublication = collector.reopenPublication(w, nextTrigger, 22);
BEAST_EXPECT(nextPublication.has_value());
if (!nextPublication)
return;
BEAST_EXPECT(collector.publicationGeneration(w) == 2);
BEAST_EXPECT(!collector.claimPublication(*publication, 4, 2));
BEAST_EXPECT(collector.claimPublication(*nextPublication, 4, 1));
BEAST_EXPECT(!collector.reopenPublication(w, w, 23));
BEAST_EXPECT(
collector.positionStatus(w, 3) ==
ExportSigCollector::PositionStatus::unique);
BEAST_EXPECT(collector.fullUnionSnapshot().at(w).size() == 2);
collector.cleanupStale(278);
BEAST_EXPECT(!collector.fullUnionSnapshot().empty());
collector.cleanupStale(279);
BEAST_EXPECT(collector.fullUnionSnapshot().empty());
}
void
testRemoveInvalidUnverifiedSignature()
testMalformedBoundaries()
{
testcase("remove invalid unverified signature");
testcase("malformed contribution boundaries");
ExportSigCollector collector;
auto const tx = makeHash("remove-invalid");
auto const sig = makeSignature(9);
auto const otherSig = makeSignature(10);
auto good = contribution(0, keyA_, 1);
BEAST_EXPECT(
collector.beginAttributedAdmission(uint256{}, good).result ==
ExportSigCollector::BeginResult::malformed);
collector.addUnverifiedSignature(tx, validator_, sig, 10);
BEAST_EXPECT(collector.hasUnverifiedSignatures());
auto const w = origin(3);
BEAST_EXPECT(
collector.beginAttributedAdmission(w, good, 1).result ==
ExportSigCollector::BeginResult::unknownOrigin);
BEAST_EXPECT(collector.reopenPublication(w, w, 1).has_value());
BEAST_EXPECT(!collector.removeSignature(tx, validator_, otherSig));
BEAST_EXPECT(collector.hasUnverifiedSignatures());
good.position = ExportLimits::maxCommitteeMembers;
BEAST_EXPECT(
collector.beginAttributedAdmission(w, good, 1).result ==
ExportSigCollector::BeginResult::malformed);
BEAST_EXPECT(collector.removeSignature(tx, validator_, sig));
BEAST_EXPECT(!collector.hasUnverifiedSignatures());
BEAST_EXPECT(collector.signatureCount(tx) == 0);
}
good.position = 0;
good.signature = Buffer{};
BEAST_EXPECT(
collector.beginAttributedAdmission(w, good, 1).result ==
ExportSigCollector::BeginResult::malformed);
void
testSnapshotsAndFilteredCounts()
{
testcase("snapshots and filtered counts use verified signatures only");
std::vector<std::uint8_t> oversized(
ExportSigCollector::maxSignatureBytes + 1, 0xAB);
good.signature = Buffer{oversized.data(), oversized.size()};
BEAST_EXPECT(
collector.beginAttributedAdmission(w, good, 1).result ==
ExportSigCollector::BeginResult::malformed);
auto const other = randomKeyPair(KeyType::secp256k1).first;
ExportSigCollector collector;
auto const tx = makeHash("snapshot-filtered");
auto const verifiedSig = makeSignature(20);
auto const unverifiedSig = makeSignature(30);
good = contribution(5, keyA_, 5);
auto abandoned = collector.beginAttributedAdmission(w, good, 2);
BEAST_EXPECT(abandoned.ticket.has_value());
if (!abandoned.ticket)
return;
BEAST_EXPECT(collector.cancelAdmission(std::move(*abandoned.ticket)));
BEAST_EXPECT(
collector.beginAttributedAdmission(w, good, 2).result ==
ExportSigCollector::BeginResult::verify);
BEAST_EXPECT(!collector.hasVerifiedSignature(tx, validator_));
BEAST_EXPECT(collector.unverifiedSignatures(tx).empty());
BEAST_EXPECT(!collector.checkQuorumAndSnapshot(tx, 1));
auto expired =
collector.beginAttributedAdmission(w, contribution(6, keyA_, 6), 3);
BEAST_EXPECT(expired.ticket.has_value());
BEAST_EXPECT(
collector.beginAttributedAdmission(w, contribution(6, keyB_, 7), 5)
.result == ExportSigCollector::BeginResult::verify);
collector.addVerifiedSignature(tx, validator_, verifiedSig, 10);
collector.addUnverifiedSignature(tx, other, unverifiedSig, 11);
BEAST_EXPECT(collector.hasVerifiedSignature(tx, validator_));
BEAST_EXPECT(!collector.hasVerifiedSignature(tx, other));
BEAST_EXPECT(collector.signatureCount(tx) == 1);
BEAST_EXPECT(collector.signatureCount(tx, [&](PublicKey const& pk) {
return pk == validator_;
}) == 1);
BEAST_EXPECT(collector.signatureCount(tx, [&](PublicKey const& pk) {
return pk == other;
}) == 0);
auto unverified = collector.unverifiedSignatures(tx);
BEAST_EXPECT(unverified.size() == 1);
BEAST_EXPECT(unverified.count(other) == 1);
auto snapshot = collector.snapshot();
BEAST_EXPECT(snapshot.size() == 1);
BEAST_EXPECT(snapshot[tx].count(validator_) == 1);
BEAST_EXPECT(snapshot[tx].count(other) == 0);
auto sigSnapshot = collector.snapshotWithSigs();
BEAST_EXPECT(sigSnapshot[tx].size() == 1);
BEAST_EXPECT(sigSnapshot[tx][validator_] == verifiedSig);
auto filteredSnapshot = collector.snapshotWithSigs(
[&](PublicKey const& pk) { return pk == other; });
BEAST_EXPECT(filteredSnapshot.empty());
BEAST_EXPECT(!collector.checkQuorumAndSnapshot(tx, 2));
auto quorum = collector.checkQuorumAndSnapshot(tx, 1);
BEAST_EXPECT(quorum.has_value());
if (quorum)
{
BEAST_EXPECT(quorum->size() == 1);
BEAST_EXPECT((*quorum)[validator_] == verifiedSig);
}
collector.upgradeSignature(tx, other, makeSignature(31), 12);
BEAST_EXPECT(collector.signatureCount(tx) == 1);
collector.upgradeSignature(tx, other, unverifiedSig, 12);
BEAST_EXPECT(!collector.hasUnverifiedSignatures());
BEAST_EXPECT(collector.signatureCount(tx) == 2);
auto filteredQuorum = collector.checkQuorumAndSnapshot(
tx, 1, [&](PublicKey const& pk) { return pk == other; });
BEAST_EXPECT(filteredQuorum.has_value());
if (filteredQuorum)
BEAST_EXPECT((*filteredQuorum)[other] == unverifiedSig);
collector.clear(tx);
BEAST_EXPECT(collector.signatureCount(tx) == 0);
BEAST_EXPECT(collector.snapshot().empty());
}
void
testLegacyReplacementSemantics()
{
testcase("legacy replacement semantics");
auto const sigA = makeSignature(60);
auto const sigB = makeSignature(70);
{
ExportSigCollector collector;
auto const tx = makeHash("unverified-replacement");
collector.addUnverifiedSignature(tx, validator_, sigA, 10);
collector.addUnverifiedSignature(tx, validator_, sigB, 20);
BEAST_EXPECT(
collector.unverifiedSignatures(tx).at(validator_) == sigB);
// Replacement does not refresh firstSeenSeq.
collector.cleanupStale(266);
BEAST_EXPECT(collector.hasUnverifiedSignatures());
collector.cleanupStale(267);
BEAST_EXPECT(!collector.hasUnverifiedSignatures());
}
{
ExportSigCollector collector;
auto const tx = makeHash("verified-replacement");
collector.addVerifiedSignature(tx, validator_, sigA, 10);
collector.addVerifiedSignature(tx, validator_, sigB, 20);
BEAST_EXPECT(
collector.snapshotWithSigs().at(tx).at(validator_) == sigB);
// Unverified input never overwrites an already verified value.
collector.addUnverifiedSignature(tx, validator_, sigA, 30);
BEAST_EXPECT(!collector.hasUnverifiedSignatures());
BEAST_EXPECT(
collector.snapshotWithSigs().at(tx).at(validator_) == sigB);
collector.cleanupStale(266);
BEAST_EXPECT(collector.signatureCount(tx) == 1);
collector.cleanupStale(267);
BEAST_EXPECT(collector.signatureCount(tx) == 0);
}
{
ExportSigCollector collector;
auto const tx = makeHash("unverified-to-verified");
collector.addUnverifiedSignature(tx, validator_, sigA, 10);
collector.addVerifiedSignature(tx, validator_, sigB, 20);
BEAST_EXPECT(!collector.hasUnverifiedSignatures());
BEAST_EXPECT(
collector.snapshotWithSigs().at(tx).at(validator_) == sigB);
collector.cleanupStale(266);
BEAST_EXPECT(collector.signatureCount(tx) == 1);
collector.cleanupStale(267);
BEAST_EXPECT(collector.signatureCount(tx) == 0);
}
{
ExportSigCollector collector;
auto const tx = makeHash("unverified-to-standalone");
collector.addUnverifiedSignature(tx, validator_, sigA, 10);
collector.addStandaloneSignature(tx, validator_, 20);
BEAST_EXPECT(!collector.hasUnverifiedSignatures());
BEAST_EXPECT(
collector.snapshotWithSigs().at(tx).at(validator_) == sigA);
collector.cleanupStale(266);
BEAST_EXPECT(collector.signatureCount(tx) == 1);
collector.cleanupStale(267);
BEAST_EXPECT(collector.signatureCount(tx) == 0);
}
{
ExportSigCollector collector;
auto const tx = makeHash("standalone-preserves-buffer");
collector.addVerifiedSignature(tx, validator_, sigB, 10);
collector.addStandaloneSignature(tx, validator_, 20);
BEAST_EXPECT(
collector.snapshotWithSigs().at(tx).at(validator_) == sigB);
}
{
ExportSigCollector collector;
auto const tx = makeHash("standalone-empty-transitions");
collector.addStandaloneSignature(tx, validator_, 10);
collector.addUnverifiedSignature(tx, validator_, sigA, 20);
BEAST_EXPECT(!collector.hasUnverifiedSignatures());
BEAST_EXPECT(
collector.snapshotWithSigs().at(tx).at(validator_).empty());
collector.addVerifiedSignature(tx, validator_, sigB, 30);
BEAST_EXPECT(
collector.snapshotWithSigs().at(tx).at(validator_) == sigB);
}
{
ExportSigCollector collector;
auto const tx = makeHash("upgrade-preserves-age");
collector.addUnverifiedSignature(tx, validator_, sigA, 10);
collector.upgradeSignature(tx, validator_, sigA, 20);
BEAST_EXPECT(collector.signatureCount(tx) == 1);
collector.cleanupStale(266);
BEAST_EXPECT(collector.signatureCount(tx) == 1);
collector.cleanupStale(267);
BEAST_EXPECT(collector.signatureCount(tx) == 0);
}
{
ExportSigCollector collector;
auto const tx = makeHash("ignored-unverified-initializes-age");
collector.addVerifiedSignature(tx, validator_, sigA);
collector.addUnverifiedSignature(tx, validator_, sigB, 10);
BEAST_EXPECT(
collector.snapshotWithSigs().at(tx).at(validator_) == sigA);
collector.cleanupStale(266);
BEAST_EXPECT(collector.signatureCount(tx) == 1);
collector.cleanupStale(267);
BEAST_EXPECT(collector.signatureCount(tx) == 0);
}
}
void
testStandaloneAndRoundState()
{
testcase("standalone signatures and round state");
ExportSigCollector collector;
auto const tx = makeHash("standalone-round");
collector.addStandaloneSignature(tx, validator_, 10);
BEAST_EXPECT(collector.hasVerifiedSignature(tx, validator_));
BEAST_EXPECT(collector.signatureCount(tx) == 1);
BEAST_EXPECT(!collector.hasUnverifiedSignatures());
auto snapshot = collector.snapshot();
BEAST_EXPECT(snapshot.size() == 1);
BEAST_EXPECT(snapshot[tx].count(validator_) == 1);
auto sigSnapshot = collector.snapshotWithSigs();
BEAST_EXPECT(sigSnapshot.size() == 1);
BEAST_EXPECT(sigSnapshot[tx].count(validator_) == 1);
BEAST_EXPECT(sigSnapshot[tx][validator_].empty());
BEAST_EXPECT(collector.markSent(tx));
BEAST_EXPECT(!collector.markSent(tx));
collector.clearRound();
BEAST_EXPECT(collector.markSent(tx));
auto const tx2 = makeHash("standalone-round-2");
auto const tx3 = makeHash("standalone-round-3");
BEAST_EXPECT(collector.markSent(tx2, 2));
BEAST_EXPECT(!collector.markSent(tx3, 2));
BEAST_EXPECT(!collector.markSent(tx2, 2));
collector.clearRound();
BEAST_EXPECT(collector.markSent(tx3, 2));
}
void
testClearAll()
{
testcase("clear all signatures and round state");
ExportSigCollector collector;
auto const verifiedTx = makeHash("clear-all-verified");
auto const unverifiedTx = makeHash("clear-all-unverified");
auto const sig = makeSignature(12);
collector.addVerifiedSignature(verifiedTx, validator_, sig, 10);
collector.addUnverifiedSignature(unverifiedTx, validator_, sig, 10);
BEAST_EXPECT(collector.signatureCount(verifiedTx) == 1);
BEAST_EXPECT(collector.hasUnverifiedSignatures());
BEAST_EXPECT(collector.markSent(verifiedTx));
BEAST_EXPECT(!collector.markSent(verifiedTx));
collector.clearAll();
BEAST_EXPECT(collector.signatureCount(verifiedTx) == 0);
BEAST_EXPECT(!collector.hasUnverifiedSignatures());
BEAST_EXPECT(collector.markSent(verifiedTx));
}
void
testUnverifiedCacheCap()
{
testcase("unverified cache cap");
constexpr std::size_t maxTrackedTxns = 4096;
ExportSigCollector collector;
auto const sig = makeSignature(50);
for (std::uint32_t i = 0; i < maxTrackedTxns; ++i)
collector.addUnverifiedSignature(
makeHashFromSeed(i), validator_, sig, 10);
auto const firstTx = makeHashFromSeed(0);
auto const lastTrackedTx =
makeHashFromSeed(static_cast<std::uint32_t>(maxTrackedTxns - 1));
auto const rejectedTx =
makeHashFromSeed(static_cast<std::uint32_t>(maxTrackedTxns));
auto const verifiedTx =
makeHashFromSeed(static_cast<std::uint32_t>(maxTrackedTxns + 1));
auto const other = randomKeyPair(KeyType::secp256k1).first;
BEAST_EXPECT(collector.hasUnverifiedSignatures());
BEAST_EXPECT(collector.unverifiedSignatures(firstTx).size() == 1);
BEAST_EXPECT(collector.unverifiedSignatures(lastTrackedTx).size() == 1);
collector.addUnverifiedSignature(rejectedTx, validator_, sig, 10);
BEAST_EXPECT(collector.unverifiedSignatures(rejectedTx).empty());
// Existing entries remain updateable at the cap.
collector.addUnverifiedSignature(firstTx, other, sig, 10);
BEAST_EXPECT(collector.unverifiedSignatures(firstTx).size() == 2);
// Verified entries are real in-ledger exports and are not gated by the
// unverified relay-ordering cache cap.
collector.addVerifiedSignature(verifiedTx, validator_, sig, 10);
BEAST_EXPECT(collector.signatureCount(verifiedTx) == 1);
}
void
testDefensiveNoOps()
{
testcase("defensive no-op paths");
ExportSigCollector collector;
auto const missingTx = makeHash("missing-defensive");
auto const standaloneTx = makeHash("standalone-defensive");
auto const sig = makeSignature(40);
collector.upgradeSignature(missingTx, validator_, sig, 10);
BEAST_EXPECT(collector.signatureCount(missingTx) == 0);
BEAST_EXPECT(!collector.removeSignature(missingTx, validator_, sig));
BEAST_EXPECT(!collector.checkQuorumAndSnapshot(missingTx, 1));
BEAST_EXPECT(collector.signatureCount(missingTx, [](PublicKey const&) {
return true;
}) == 0);
collector.addStandaloneSignature(standaloneTx, validator_, 10);
collector.upgradeSignature(standaloneTx, validator_, Buffer{}, 11);
BEAST_EXPECT(collector.signatureCount(standaloneTx) == 1);
BEAST_EXPECT(collector.snapshotWithSigs()
.at(standaloneTx)
.at(validator_)
.empty());
auto filtered =
collector.snapshotWithSigs([](PublicKey const&) { return false; });
BEAST_EXPECT(filtered.empty());
BEAST_EXPECT(!collector.checkQuorumAndSnapshot(
standaloneTx, 1, [](PublicKey const&) { return false; }));
auto oldToken = collector.reopenPublication(w, origin(30), 6);
BEAST_EXPECT(oldToken.has_value());
collector.clear(w);
auto newToken = collector.reopenPublication(w, origin(31), 7);
BEAST_EXPECT(newToken.has_value());
if (oldToken)
BEAST_EXPECT(!collector.claimPublication(*oldToken, 0, 1));
}
void
run() override
{
testCleanupUsesFirstSeenSeq();
testUpgradeSetsFirstSeenSeq();
testRemoveInvalidUnverifiedSignature();
testSnapshotsAndFilteredCounts();
testLegacyReplacementSemantics();
testStandaloneAndRoundState();
testClearAll();
testUnverifiedCacheCap();
testDefensiveNoOps();
testAdmissionAndConflict();
testReservationAndPublicationLifecycle();
testMalformedBoundaries();
}
};

View File

@@ -1,400 +0,0 @@
//------------------------------------------------------------------------------
/*
This file is part of rippled: https://github.com/ripple/rippled
Permission to use, copy, modify, and/or distribute this software for any
purpose with or without fee is hereby granted, provided that the above
copyright notice and this permission notice appear in all copies.
THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
*/
//==============================================================================
#include <test/unit_test/SuiteJournal.h>
#include <xrpld/app/consensus/ExportSignatureHarvester.h>
#include <xrpl/basics/StringUtilities.h>
#include <xrpl/beast/unit_test.h>
#include <xrpl/protocol/STAmount.h>
#include <xrpl/protocol/STObject.h>
#include <xrpl/protocol/STTx.h>
#include <xrpl/protocol/SecretKey.h>
#include <xrpl/protocol/Sign.h>
#include <xrpl/protocol/TxFlags.h>
#include <xrpl/protocol/TxFormats.h>
#include <xrpl/protocol/digest.h>
#include <cstring>
#include <memory>
namespace ripple {
namespace test {
namespace {
uint256
makeHash(char const* label)
{
return sha512Half(Slice(label, std::strlen(label)));
}
STTx
makeSTTx(STObject const& obj)
{
Serializer s;
obj.add(s);
SerialIter sit{s.slice()};
return STTx{std::ref(sit)};
}
STObject
makeExportedPayment(AccountID const& src, AccountID const& dst)
{
STObject obj(sfExportedTxn);
obj.setFieldU16(sfTransactionType, ttPAYMENT);
obj.setFieldU32(sfFlags, tfFullyCanonicalSig);
obj.setFieldU32(sfSequence, 0);
obj.setFieldU32(sfTicketSequence, 1);
obj.setFieldU32(sfFirstLedgerSequence, 2);
obj.setFieldU32(sfLastLedgerSequence, 6);
obj.setFieldAmount(sfAmount, XRPAmount{1000000});
obj.setFieldAmount(sfFee, XRPAmount{10});
obj.setFieldVL(sfSigningPubKey, Blob{});
obj.setAccountID(sfAccount, src);
obj.setAccountID(sfDestination, dst);
return obj;
}
std::shared_ptr<STTx const>
makeExportTx(STObject const& inner, AccountID const& account)
{
STObject exportObj(sfGeneric);
exportObj.setFieldU16(sfTransactionType, ttEXPORT);
exportObj.setAccountID(sfAccount, account);
exportObj.setFieldU32(sfSequence, 0);
exportObj.setFieldVL(sfSigningPubKey, Blob{});
exportObj.setFieldU32(sfFirstLedgerSequence, 2);
exportObj.setFieldU32(sfLastLedgerSequence, 6);
exportObj.setFieldAmount(sfFee, XRPAmount{0});
exportObj.set(std::make_unique<STObject>(inner));
return std::make_shared<STTx const>(makeSTTx(exportObj));
}
std::shared_ptr<STTx const>
makeExportTxWithoutInner(AccountID const& account)
{
STObject exportObj(sfGeneric);
exportObj.setFieldU16(sfTransactionType, ttEXPORT);
exportObj.setAccountID(sfAccount, account);
exportObj.setFieldU32(sfSequence, 0);
exportObj.setFieldVL(sfSigningPubKey, Blob{});
exportObj.setFieldU32(sfFirstLedgerSequence, 2);
exportObj.setFieldU32(sfLastLedgerSequence, 6);
exportObj.setFieldAmount(sfFee, XRPAmount{0});
return std::make_shared<STTx const>(makeSTTx(exportObj));
}
std::string
makeBlob(uint256 const& txHash, PublicKey const& pk, Slice sig)
{
std::string blob;
blob.append(reinterpret_cast<char const*>(txHash.data()), txHash.size());
blob.append(reinterpret_cast<char const*>(pk.data()), pk.size());
blob.append(reinterpret_cast<char const*>(sig.data()), sig.size());
return blob;
}
std::string
makeBlob(uint256 const& txHash, PublicKey const& pk, Buffer const& sig)
{
return makeBlob(txHash, pk, Slice(sig.data(), sig.size()));
}
} // namespace
class ExportSignatureHarvester_test : public beast::unit_test::suite
{
SuiteJournal journal_{"ExportSignatureHarvester_test", *this};
std::pair<PublicKey, SecretKey> const sender_ =
randomKeyPair(KeyType::secp256k1);
std::pair<PublicKey, SecretKey> const other_ =
randomKeyPair(KeyType::secp256k1);
uint256 const prevLedger_ = makeHash("export-harvester-prev-ledger");
char const* source_ = "unit-test";
ExportSignatureHarvestInput
makeInput(
std::vector<std::string> const& blobs,
ExportTxnLookup const& exportTxns,
bool active = true,
std::optional<uint256> sourceLedgerHash = std::nullopt,
PublicKey const* sender = nullptr,
std::size_t maxEntries = 2) const
{
return ExportSignatureHarvestInput{
sender ? *sender : sender_.first,
prevLedger_,
blobs,
sourceLedgerHash,
[active](PublicKey const&) { return active; },
exportTxns,
42,
source_,
maxEntries};
}
beast::Journal&
journal()
{
return journal_;
}
public:
void
testRejectsTooManyEntries()
{
testcase("rejects too many entries");
auto const txHash = makeHash("too-many");
auto const blob = makeBlob(txHash, sender_.first, Slice("sig", 3));
std::vector<std::string> const blobs{blob, blob, blob};
ExportTxnLookup lookup;
ExportSigCollector collector;
auto input = makeInput(blobs, lookup);
BEAST_EXPECT(harvestExportSignatures(input, collector, journal()) == 0);
BEAST_EXPECT(!collector.hasUnverifiedSignatures());
BEAST_EXPECT(collector.signatureCount(txHash) == 0);
}
void
testEmptyInputAndDirectVerification()
{
testcase("empty input and direct verification");
std::vector<std::string> const empty;
ExportTxnLookup lookup;
ExportSigCollector collector;
auto input = makeInput(empty, lookup, true, prevLedger_);
BEAST_EXPECT(harvestExportSignatures(input, collector, journal()) == 0);
auto const senderAccount = calcAccountID(sender_.first);
auto const dstAccount = calcAccountID(other_.first);
auto const innerObj = makeExportedPayment(senderAccount, dstAccount);
auto const innerTx = makeSTTx(innerObj);
auto const sigData = buildMultiSigningData(innerTx, senderAccount);
auto const sig = sign(sender_.first, sender_.second, sigData.slice());
auto const exportTx = makeExportTx(innerObj, senderAccount);
auto const txHash = exportTx->getTransactionID();
BEAST_EXPECT(verifyExportSignatureAgainstTx(
*exportTx,
sender_.first,
Slice(sig.data(), sig.size()),
txHash,
journal(),
source_));
BEAST_EXPECT(!verifyExportSignatureAgainstTx(
*exportTx,
sender_.first,
Slice("bad-sig", 7),
txHash,
journal(),
source_));
auto const noInner = makeExportTxWithoutInner(senderAccount);
BEAST_EXPECT(!verifyExportSignatureAgainstTx(
*noInner,
sender_.first,
Slice(sig.data(), sig.size()),
noInner->getTransactionID(),
journal(),
source_));
}
void
testIgnoresEmptyAndMalformedEntries()
{
testcase("ignores empty and malformed entries");
auto const txHash = makeHash("malformed");
std::string invalidPubkeyBlob;
invalidPubkeyBlob.append(
reinterpret_cast<char const*>(txHash.data()), txHash.size());
invalidPubkeyBlob.append(33, '\0');
invalidPubkeyBlob.append("sig", 3);
std::vector<std::string> const blobs{
"",
std::string(64, 'x'),
makeBlob(txHash, sender_.first, Slice{}),
invalidPubkeyBlob};
ExportTxnLookup lookup;
ExportSigCollector collector;
auto input =
makeInput(blobs, lookup, true, prevLedger_, nullptr, blobs.size());
BEAST_EXPECT(harvestExportSignatures(input, collector, journal()) == 0);
BEAST_EXPECT(!collector.hasUnverifiedSignatures());
BEAST_EXPECT(collector.signatureCount(txHash) == 0);
}
void
testRejectsInactiveOrWrongParent()
{
testcase("rejects inactive or wrong-parent senders");
auto const txHash = makeHash("inactive");
std::vector<std::string> const blobs{
makeBlob(txHash, sender_.first, Slice("sig", 3))};
ExportTxnLookup lookup;
ExportSigCollector collector;
auto inactive = makeInput(blobs, lookup, false);
BEAST_EXPECT(
harvestExportSignatures(inactive, collector, journal()) == 0);
BEAST_EXPECT(!collector.hasUnverifiedSignatures());
auto wrongParent =
makeInput(blobs, lookup, true, makeHash("different-parent"));
BEAST_EXPECT(
harvestExportSignatures(wrongParent, collector, journal()) == 0);
BEAST_EXPECT(!collector.hasUnverifiedSignatures());
}
void
testRejectsPubkeyMismatchAtomically()
{
testcase("rejects embedded pubkey mismatch atomically");
auto const txHash = makeHash("mismatch");
std::vector<std::string> const blobs{
makeBlob(txHash, sender_.first, Slice("sig-a", 5)),
makeBlob(txHash, other_.first, Slice("sig-b", 5))};
ExportTxnLookup lookup;
ExportSigCollector collector;
auto input = makeInput(blobs, lookup, true, prevLedger_);
BEAST_EXPECT(harvestExportSignatures(input, collector, journal()) == 0);
BEAST_EXPECT(!collector.hasUnverifiedSignatures());
BEAST_EXPECT(collector.signatureCount(txHash) == 0);
}
void
testMissingTxStoresUnverified()
{
testcase("missing tx stores unverified");
auto const txHash = makeHash("missing-tx");
std::vector<std::string> const blobs{
makeBlob(txHash, sender_.first, Slice("sig", 3))};
ExportTxnLookup lookup;
ExportSigCollector collector;
auto input = makeInput(blobs, lookup, true, prevLedger_);
BEAST_EXPECT(harvestExportSignatures(input, collector, journal()) == 1);
BEAST_EXPECT(collector.hasUnverifiedSignatures());
BEAST_EXPECT(collector.signatureCount(txHash) == 0);
}
void
testOpenLedgerTxStoresVerifiedAndSkipsDuplicate()
{
testcase("open-ledger tx stores verified and skips duplicate");
auto const senderAccount = calcAccountID(sender_.first);
auto const dstAccount = calcAccountID(other_.first);
auto const innerObj = makeExportedPayment(senderAccount, dstAccount);
auto const innerTx = makeSTTx(innerObj);
auto const sigData = buildMultiSigningData(innerTx, senderAccount);
auto const sig = sign(sender_.first, sender_.second, sigData.slice());
auto const exportTx = makeExportTx(innerObj, senderAccount);
auto const txHash = exportTx->getTransactionID();
ExportTxnLookup lookup;
lookup.emplace(txHash, exportTx);
std::vector<std::string> const blobs{
makeBlob(txHash, sender_.first, sig)};
ExportSigCollector collector;
auto input = makeInput(blobs, lookup, true, prevLedger_);
BEAST_EXPECT(harvestExportSignatures(input, collector, journal()) == 1);
BEAST_EXPECT(!collector.hasUnverifiedSignatures());
BEAST_EXPECT(collector.signatureCount(txHash) == 1);
BEAST_EXPECT(harvestExportSignatures(input, collector, journal()) == 0);
BEAST_EXPECT(collector.signatureCount(txHash) == 1);
}
void
testRejectsInvalidOpenLedgerSignatures()
{
testcase("rejects invalid open-ledger signatures");
auto const senderAccount = calcAccountID(sender_.first);
auto const dstAccount = calcAccountID(other_.first);
auto const innerObj = makeExportedPayment(senderAccount, dstAccount);
auto const exportTx = makeExportTx(innerObj, senderAccount);
auto const txHash = exportTx->getTransactionID();
ExportTxnLookup lookup;
lookup.emplace(txHash, exportTx);
std::vector<std::string> const blobs{
makeBlob(txHash, sender_.first, Slice("bad-sig", 7))};
ExportSigCollector collector;
auto input = makeInput(blobs, lookup, true, prevLedger_);
BEAST_EXPECT(harvestExportSignatures(input, collector, journal()) == 0);
BEAST_EXPECT(!collector.hasUnverifiedSignatures());
BEAST_EXPECT(collector.signatureCount(txHash) == 0);
}
void
testRejectsOpenLedgerTxWithoutExportedTxn()
{
testcase("rejects open-ledger tx without exported transaction");
auto const senderAccount = calcAccountID(sender_.first);
auto const exportTx = makeExportTxWithoutInner(senderAccount);
auto const txHash = exportTx->getTransactionID();
ExportTxnLookup lookup;
lookup.emplace(txHash, exportTx);
std::vector<std::string> const blobs{
makeBlob(txHash, sender_.first, Slice("sig", 3))};
ExportSigCollector collector;
auto input = makeInput(blobs, lookup, true, prevLedger_);
BEAST_EXPECT(harvestExportSignatures(input, collector, journal()) == 0);
BEAST_EXPECT(!collector.hasUnverifiedSignatures());
BEAST_EXPECT(collector.signatureCount(txHash) == 0);
}
void
run() override
{
testRejectsTooManyEntries();
testEmptyInputAndDirectVerification();
testIgnoresEmptyAndMalformedEntries();
testRejectsInactiveOrWrongParent();
testRejectsPubkeyMismatchAtomically();
testMissingTxStoresUnverified();
testOpenLedgerTxStoresVerifiedAndSkipsDuplicate();
testRejectsInvalidOpenLedgerSignatures();
testRejectsOpenLedgerTxWithoutExportedTxn();
}
};
BEAST_DEFINE_TESTSUITE(ExportSignatureHarvester, app, ripple);
} // namespace test
} // namespace ripple

View File

@@ -1431,15 +1431,15 @@ class ConsensusExtensions_test : public beast::unit_test::suite
origin, trigger, 10));
auto admission =
ce.postValidationExportSigCollector().beginAttributedAdmission(
origin, ExportSigCollectorV2::Contribution{0, pk, sig}, 10);
origin, ExportSigCollector::Contribution{0, pk, sig}, 10);
BEAST_EXPECT(
admission.result == ExportSigCollectorV2::BeginResult::verify);
admission.result == ExportSigCollector::BeginResult::verify);
BEAST_EXPECT(admission.ticket);
if (admission.ticket)
BEAST_EXPECT(
ce.postValidationExportSigCollector()
.admitContribution(std::move(*admission.ticket), true, 10)
.result == ExportSigCollectorV2::AdmitResult::accepted);
.result == ExportSigCollector::AdmitResult::accepted);
BEAST_EXPECT(
ce.postValidationExportSigCollector().fullUnionSnapshot().size() ==
1);
@@ -2340,17 +2340,17 @@ class ConsensusExtensions_test : public beast::unit_test::suite
}
void
testExportV2CollectorBuildsAttributedUnion()
testExportCollectorBuildsAttributedUnion()
{
testcase("Export V2 collector builds attributed union");
testcase("Export collector builds attributed union");
using namespace jtx;
Env env{
*this, envconfig(validator, ""), supported_amendments(), nullptr};
ConsensusExtensions ce{env.app(), activeNoopJournal()};
auto& collector = ce.postValidationExportSigCollector();
auto const origin = makeHash("v2-attributed-origin");
auto const trigger = makeHash("v2-attributed-trigger");
auto const origin = makeHash("attributed-origin");
auto const trigger = makeHash("attributed-trigger");
auto const signerA = randomKeyPair(KeyType::secp256k1).first;
auto const signerB = randomKeyPair(KeyType::secp256k1).first;
std::uint8_t const signatureABytes[] = {1, 2};
@@ -2359,22 +2359,22 @@ class ConsensusExtensions_test : public beast::unit_test::suite
Buffer const signatureB{signatureBBytes, sizeof(signatureBBytes)};
BEAST_EXPECT(collector.reopenPublication(origin, trigger, 10));
auto admit = [&](ExportSigCollectorV2::Position position,
auto admit = [&](ExportSigCollector::Position position,
PublicKey const& key,
Buffer const& signature) {
auto admission = collector.beginAttributedAdmission(
origin,
ExportSigCollectorV2::Contribution{position, key, signature},
ExportSigCollector::Contribution{position, key, signature},
10);
BEAST_EXPECT(
admission.result == ExportSigCollectorV2::BeginResult::verify);
admission.result == ExportSigCollector::BeginResult::verify);
BEAST_EXPECT(admission.ticket);
if (!admission.ticket)
return;
BEAST_EXPECT(
collector
.admitContribution(std::move(*admission.ticket), true, 10)
.result == ExportSigCollectorV2::AdmitResult::accepted);
.result == ExportSigCollector::AdmitResult::accepted);
};
admit(0, signerA, signatureA);
admit(2, signerB, signatureB);
@@ -2453,7 +2453,7 @@ class ConsensusExtensions_test : public beast::unit_test::suite
BEAST_EXPECT(collector.reopenPublication(origin, origin, deadline));
auto admission = collector.beginAttributedAdmission(
origin,
ExportSigCollectorV2::Contribution{0, signer, signature},
ExportSigCollector::Contribution{0, signer, signature},
deadline);
BEAST_EXPECT(admission.ticket);
if (!admission.ticket)
@@ -2461,7 +2461,7 @@ class ConsensusExtensions_test : public beast::unit_test::suite
BEAST_EXPECT(
collector
.admitContribution(std::move(*admission.ticket), true, deadline)
.result == ExportSigCollectorV2::AdmitResult::accepted);
.result == ExportSigCollector::AdmitResult::accepted);
auto const leafCount = [&](uint256 const& hash) {
auto const map =
@@ -2587,7 +2587,7 @@ class ConsensusExtensions_test : public beast::unit_test::suite
auto const dst = calcAccountID(randomKeyPair(KeyType::secp256k1).first);
auto const releaseTarget =
makeSTTx(makeExportedPayment(calcAccountID(signerA.first), dst));
auto const origin = makeHash("agreed-export-v2-origin");
auto const origin = makeHash("agreed-export-origin");
Blob const contributors{0x07};
constexpr std::size_t committeeSize = 3;
auto const threshold = ExportLimits::committeeQuorumThreshold(3);
@@ -3664,7 +3664,7 @@ class ConsensusExtensions_test : public beast::unit_test::suite
origin, trigger, 10));
auto admission =
ce.postValidationExportSigCollector().beginAttributedAdmission(
origin, ExportSigCollectorV2::Contribution{0, pk, sig}, 10);
origin, ExportSigCollector::Contribution{0, pk, sig}, 10);
BEAST_EXPECT(admission.ticket);
if (admission.ticket)
ce.postValidationExportSigCollector().admitContribution(
@@ -4377,7 +4377,7 @@ public:
testProposalProofRoundTrip();
testProposalPrecheckUsesExportShareRelayLimits();
testHarvestRngDataReplacementAndRejection();
testExportV2CollectorBuildsAttributedUnion();
testExportCollectorBuildsAttributedUnion();
testExportSidecarCandidateDeadline();
testTransactionAcquireRejectsSidecarWireNodes();
testAcquiredSetsRejectConsensusExtensionPseudos();

View File

@@ -18,7 +18,6 @@
//==============================================================================
#include <xrpld/app/consensus/ConsensusExtensions.h>
#include <xrpld/app/consensus/ExportSignatureHarvester.h>
#include <xrpld/app/ledger/InboundTransactions.h>
#include <xrpld/app/ledger/Ledger.h>
#include <xrpld/app/ledger/LedgerMaster.h>
@@ -238,7 +237,7 @@ resolveExportShare(
ExportShare
withContribution(
ExportShare const& context,
ExportSigCollectorV2::Contribution const& contribution)
ExportSigCollector::Contribution const& contribution)
{
return ExportShare{
context.version,
@@ -470,28 +469,28 @@ ConsensusExtensions::admitExportShare(
share.originTxn, share.triggerTxn, validated->info().seq))
return {ExportShareDisposition::deferred, ExportShareCharge::none};
ExportSigCollectorV2::Contribution contribution{
ExportSigCollector::Contribution contribution{
share.committeePosition, share.signingKey, share.signature};
auto admission = postValidationExportSigCollector_.beginAttributedAdmission(
share.originTxn, std::move(contribution), validated->info().seq);
if (admission.result != ExportSigCollectorV2::BeginResult::verify ||
if (admission.result != ExportSigCollector::BeginResult::verify ||
!admission.ticket)
{
switch (admission.result)
{
case ExportSigCollectorV2::BeginResult::duplicate:
case ExportSigCollectorV2::BeginResult::conflicted:
case ExportSigCollector::BeginResult::duplicate:
case ExportSigCollector::BeginResult::conflicted:
return {
ExportShareDisposition::duplicate, ExportShareCharge::none};
case ExportSigCollectorV2::BeginResult::unknownOrigin:
case ExportSigCollectorV2::BeginResult::capacity:
case ExportSigCollector::BeginResult::unknownOrigin:
case ExportSigCollector::BeginResult::capacity:
return {
ExportShareDisposition::deferred, ExportShareCharge::none};
case ExportSigCollectorV2::BeginResult::malformed:
case ExportSigCollector::BeginResult::malformed:
return {
ExportShareDisposition::invalid,
ExportShareCharge::invalidData};
case ExportSigCollectorV2::BeginResult::verify:
case ExportSigCollector::BeginResult::verify:
break;
}
return {
@@ -507,7 +506,7 @@ ConsensusExtensions::admitExportShare(
Slice{share.signature.data(), share.signature.size()});
auto outcome = postValidationExportSigCollector_.admitContribution(
std::move(*admission.ticket), signatureVerified, validated->info().seq);
if (outcome.result == ExportSigCollectorV2::AdmitResult::accepted)
if (outcome.result == ExportSigCollector::AdmitResult::accepted)
{
std::lock_guard streamLock(exportStreamMutex_);
if (!exportShareServiceStarted_.load(std::memory_order_acquire))
@@ -519,11 +518,11 @@ ConsensusExtensions::admitExportShare(
share, latest->info().seq, latest->info().hash);
return {ExportShareDisposition::accepted, ExportShareCharge::none};
}
if (outcome.result == ExportSigCollectorV2::AdmitResult::invalid)
if (outcome.result == ExportSigCollector::AdmitResult::invalid)
return {
ExportShareDisposition::invalid,
ExportShareCharge::invalidSignature};
if (outcome.result != ExportSigCollectorV2::AdmitResult::conflicted ||
if (outcome.result != ExportSigCollector::AdmitResult::conflicted ||
!outcome.priorContribution || !outcome.conflictingContribution)
return {ExportShareDisposition::duplicate, ExportShareCharge::none};
@@ -681,7 +680,7 @@ ConsensusExtensions::onValidatedLedger(
return;
if (postValidationExportSigCollector_.positionStatus(
origin, *position) !=
ExportSigCollectorV2::PositionStatus::empty)
ExportSigCollector::PositionStatus::empty)
return;
auto const originLedger =
@@ -2092,8 +2091,7 @@ ConsensusExtensions::agreedExportWitness(
auto const data =
buildMultiSigningData(exportSigningPayload, signer);
if (signature.empty() ||
signature.size() >
ExportSigCollectorV2::maxSignatureBytes ||
signature.size() > ExportSigCollector::maxSignatureBytes ||
!verify(key, data.slice(), makeSlice(signature)))
{
invalid = true;
@@ -3248,7 +3246,7 @@ ConsensusExtensions::attachExportSignatures(
if (contribution.signingKey != keys.keys->publicKey)
continue;
auto const identity =
std::pair<uint256, ExportSigCollectorV2::Position>{
std::pair<uint256, ExportSigCollector::Position>{
origin, contribution.position};
if (proposalPublishedExportShares_.count(identity) != 0)
continue;

View File

@@ -5,7 +5,7 @@
#include <xrpld/app/consensus/RCLCxLedger.h>
#include <xrpld/app/consensus/RCLCxPeerPos.h>
#include <xrpld/app/consensus/RCLCxTx.h>
#include <xrpld/app/misc/ExportSigCollectorV2.h>
#include <xrpld/app/misc/ExportSigCollector.h>
#include <xrpld/app/tx/detail/ExportResultBuilder.h>
#include <xrpld/consensus/ConsensusParms.h>
#include <xrpld/consensus/ConsensusTypes.h>
@@ -54,12 +54,12 @@ class ConsensusExtensions
friend class test::ConsensusExtensions_test;
Application& app_;
ExportSigCollectorV2 postValidationExportSigCollector_;
ExportSigCollector postValidationExportSigCollector_;
std::mutex exportStreamMutex_;
LedgerIndex exportStreamEmissionSeq_{0};
std::set<std::pair<uint256, ExportSigCollectorV2::Position>>
std::set<std::pair<uint256, ExportSigCollector::Position>>
exportStreamEmittedShares_;
std::set<std::pair<uint256, ExportSigCollectorV2::Position>>
std::set<std::pair<uint256, ExportSigCollector::Position>>
proposalPublishedExportShares_;
std::atomic<bool> exportShareServiceStarted_{false};
std::atomic<LedgerIndex> lastExportReplaySeq_{0};
@@ -223,13 +223,13 @@ private:
public:
ConsensusExtensions(Application& app, beast::Journal j);
ExportSigCollectorV2&
ExportSigCollector&
postValidationExportSigCollector()
{
return postValidationExportSigCollector_;
}
ExportSigCollectorV2 const&
ExportSigCollector const&
postValidationExportSigCollector() const
{
return postValidationExportSigCollector_;

View File

@@ -1,211 +0,0 @@
//------------------------------------------------------------------------------
/*
This file is part of rippled: https://github.com/XRPLF/rippled
Copyright 2026 Xahau
Permission to use, copy, modify, and/or distribute this software for any
purpose with or without fee is hereby granted, provided that the above
copyright notice and this permission notice appear in all copies.
THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
*/
//==============================================================================
#include <xrpld/app/consensus/ExportSignatureHarvester.h>
#include <xrpld/app/tx/detail/ExportLedgerOps.h>
#include <xrpl/protocol/AccountID.h>
#include <xrpl/protocol/ExportLimits.h>
#include <xrpl/protocol/SField.h>
#include <xrpl/protocol/STObject.h>
#include <xrpl/protocol/STTx.h>
#include <xrpl/protocol/Sign.h>
#include <cstring>
namespace ripple {
bool
verifyExportSignatureAgainstTx(
STTx const& exportTx,
PublicKey const& validator,
Slice sigSlice,
uint256 const& txHash,
beast::Journal j,
char const* source)
{
if (!exportTx.isFieldPresent(sfExportedTxn))
{
JLOG(j.warn()) << "Export: cannot verify sig"
<< " txHash=" << txHash << " source=" << source
<< " reason=missing-sfExportedTxn";
return false;
}
auto embeddedTarget = ExportLedgerOps::embeddedExportedTxn(exportTx);
if (!embeddedTarget)
{
JLOG(j.warn()) << "Export: failed to verify sig"
<< " txHash=" << txHash << " source=" << source
<< " validator=" << calcNodeID(validator)
<< " reason=inner-tx-parse-failed";
return false;
}
try
{
auto const signerAcctID = calcAccountID(validator);
auto const sigData =
buildMultiSigningData(*embeddedTarget, signerAcctID);
if (!verify(validator, sigData.slice(), sigSlice))
{
JLOG(j.warn()) << "Export: invalid multisign sig"
<< " txHash=" << txHash << " source=" << source
<< " validator=" << calcNodeID(validator)
<< " reason=signature-verify-failed";
return false;
}
}
catch (std::exception const& e)
{
JLOG(j.warn()) << "Export: failed to verify sig"
<< " txHash=" << txHash << " source=" << source
<< " validator=" << calcNodeID(validator)
<< " error=" << e.what();
return false;
}
return true;
}
std::size_t
harvestExportSignatures(
ExportSignatureHarvestInput const& input,
ExportSigCollector& collector,
beast::Journal j)
{
if (input.exportSignatures.empty())
return 0;
if (input.exportSignatures.size() > input.maxEntries)
{
JLOG(j.warn()) << "Export: rejecting proposal signatures"
<< " reason=too-many"
<< " source=" << input.source
<< " count=" << input.exportSignatures.size()
<< " max=" << input.maxEntries
<< " sender=" << calcNodeID(input.senderPK)
<< " prevLedger=" << input.proposalPrevLedger;
return 0;
}
if (!input.isActiveSigner(input.senderPK))
return 0;
if (input.activeViewSourceLedgerHash)
{
if (input.proposalPrevLedger != *input.activeViewSourceLedgerHash)
return 0;
}
for (auto const& blob : input.exportSignatures)
{
if (blob.size() < 65)
continue;
auto const pkSlice = makeSlice(blob).substr(32, 33);
if (!publicKeyType(pkSlice))
continue;
if (PublicKey{pkSlice} != input.senderPK)
{
JLOG(j.warn()) << "Export: rejecting proposal signatures"
<< " reason=embedded-pubkey-mismatch"
<< " source=" << input.source
<< " sender=" << calcNodeID(input.senderPK)
<< " embedded=" << calcNodeID(PublicKey{pkSlice})
<< " prevLedger=" << input.proposalPrevLedger;
return 0;
}
}
std::size_t stored = 0;
for (auto const& blob : input.exportSignatures)
{
if (blob.size() < 65)
continue;
// Defense-in-depth: ProposalPrecheck already rejects oversized blobs at
// ingress, but bound the stored signature size here too so the merge/
// sidecar path can never cache an over-large buffer.
if (blob.size() > ExportLimits::maxExportSignatureBytes)
continue;
uint256 txHash;
std::memcpy(txHash.data(), blob.data(), 32);
if (blob.size() <= 65)
continue;
if (collector.hasVerifiedSignature(txHash, input.senderPK))
continue;
auto const fullSlice = makeSlice(blob);
auto const pkSlice = fullSlice.substr(32, 33);
if (!publicKeyType(pkSlice))
continue;
auto const sigSlice = fullSlice.substr(65);
auto const txIt = input.exportTxns.find(txHash);
if (txIt == input.exportTxns.end())
{
JLOG(j.debug())
<< "Export: storing unverified sig"
<< " txHash=" << txHash << " source=" << input.source
<< " signer=" << calcNodeID(input.senderPK)
<< " reason=tx-not-in-open-ledger"
<< " currentClosedSeq=" << input.currentClosedSeq;
Buffer sigBuf(sigSlice.data(), sigSlice.size());
collector.addUnverifiedSignature(
txHash, input.senderPK, sigBuf, input.currentClosedSeq);
++stored;
continue;
}
if (!verifyExportSignatureAgainstTx(
*txIt->second,
input.senderPK,
sigSlice,
txHash,
j,
"open ledger"))
{
continue;
}
Buffer sigBuf(sigSlice.data(), sigSlice.size());
collector.addVerifiedSignature(
txHash, input.senderPK, sigBuf, input.currentClosedSeq);
++stored;
}
if (stored > 0)
{
JLOG(j.debug()) << "Export: harvested proposal signatures"
<< " stored=" << stored
<< " advertised=" << input.exportSignatures.size()
<< " source=" << input.source
<< " sender=" << calcNodeID(input.senderPK)
<< " currentClosedSeq=" << input.currentClosedSeq;
}
return stored;
}
} // namespace ripple

View File

@@ -1,73 +0,0 @@
//------------------------------------------------------------------------------
/*
This file is part of rippled: https://github.com/XRPLF/rippled
Copyright 2026 Xahau
Permission to use, copy, modify, and/or distribute this software for any
purpose with or without fee is hereby granted, provided that the above
copyright notice and this permission notice appear in all copies.
THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
*/
//==============================================================================
#ifndef RIPPLE_APP_CONSENSUS_EXPORTSIGNATUREHARVESTER_H_INCLUDED
#define RIPPLE_APP_CONSENSUS_EXPORTSIGNATUREHARVESTER_H_INCLUDED
#include <xrpld/app/misc/ExportSigCollector.h>
#include <xrpl/basics/Buffer.h>
#include <xrpl/basics/Log.h>
#include <xrpl/basics/UnorderedContainers.h>
#include <xrpl/protocol/Protocol.h>
#include <xrpl/protocol/PublicKey.h>
#include <xrpl/protocol/STTx.h>
#include <xrpl/protocol/UintTypes.h>
#include <cstddef>
#include <functional>
#include <memory>
#include <optional>
#include <string>
#include <vector>
namespace ripple {
using ExportTxnLookup = hash_map<uint256, std::shared_ptr<STTx const>>;
struct ExportSignatureHarvestInput
{
PublicKey const& senderPK;
uint256 const& proposalPrevLedger;
std::vector<std::string> const& exportSignatures;
std::optional<uint256> activeViewSourceLedgerHash;
std::function<bool(PublicKey const&)> isActiveSigner;
ExportTxnLookup const& exportTxns;
LedgerIndex currentClosedSeq = 0;
char const* source = "unknown";
std::size_t maxEntries = 0;
};
bool
verifyExportSignatureAgainstTx(
STTx const& exportTx,
PublicKey const& validator,
Slice sigSlice,
uint256 const& txHash,
beast::Journal j,
char const* source);
std::size_t
harvestExportSignatures(
ExportSignatureHarvestInput const& input,
ExportSigCollector& collector,
beast::Journal j);
} // namespace ripple
#endif

View File

@@ -2,429 +2,471 @@
#define RIPPLE_APP_MISC_EXPORTSIGCOLLECTOR_H_INCLUDED
#include <xrpl/basics/Buffer.h>
#include <xrpl/basics/contract.h>
#include <xrpl/protocol/ExportLimits.h>
#include <xrpl/protocol/PublicKey.h>
#include <algorithm>
#include <cstdint>
#include <map>
#include <mutex>
#include <optional>
#include <set>
#include <unordered_map>
#include <utility>
#include <vector>
namespace ripple {
/// Export signature collector for the retriable export approach.
///
/// Stores multisign signatures from validators for pending ttEXPORT
/// transactions. Signatures arrive via proposal ingestion
/// (onTrustedPeerMessage) after proposal signature verification; they are
/// sender-bound and, when possible, multisign-verified.
///
/// Signatures are either **verified** (cryptographically checked against
/// buildMultiSigningData) or **unverified** (stored on proposal-level
/// trust alone, e.g. when the ttEXPORT tx isn't in the open ledger yet
/// due to relay ordering).
///
/// Only verified signatures count toward quorum, appear in the local export
/// signature snapshot, and are assembled into the final export blob.
/// Unverified sigs are a local cache that can be upgraded to verified
/// via `upgradeSignature()` when the tx becomes available (e.g. in
/// Export::doApply which always has the tx).
///
//@@start export-sig-collector-mutex
/// Thread-safe.
/** Post-validation Export contribution collector.
Contribution identity is the immutable Export origin plus the position in
that origin's immutable validator committee. Publication attempts may
reopen, but never reset admitted contributions or conflict state.
*/
class ExportSigCollector
{
mutable std::mutex mutex_;
//@@end export-sig-collector-mutex
public:
using Position = std::uint16_t;
struct SigEntry
struct Contribution
{
/// All validators that have contributed (verified or unverified).
std::set<PublicKey> validators;
/// Actual multisign signature bytes keyed by validator pubkey.
/// Empty buffers mean pubkey-only (standalone mode).
std::map<PublicKey, Buffer> signatures;
/// Validators whose sigs have been cryptographically verified.
/// Only these count toward quorum and appear in SHAMap/snapshot.
std::set<PublicKey> verified;
std::uint32_t firstSeenSeq{0};
Position position;
PublicKey signingKey;
Buffer signature;
friend bool
operator==(Contribution const& lhs, Contribution const& rhs)
{
return lhs.position == rhs.position &&
lhs.signingKey == rhs.signingKey &&
lhs.signature == rhs.signature;
}
};
std::unordered_map<uint256, SigEntry> sigs_;
std::set<uint256> sentThisRound_;
enum class BeginResult {
verify,
duplicate,
conflicted,
unknownOrigin,
capacity,
malformed
};
static constexpr std::uint32_t maxStaleLedgers = 256;
enum class AdmitResult { accepted, duplicate, conflicted, invalid, stale };
// Cap on distinct tracked export txns. Bounds the unverified cache: a
// malicious trusted validator can advertise proposal sigs with arbitrary
// txHashes for txns not in our open ledger (stored unverified, only TTL-
// evicted). Verified entries (real in-ledger exports) are never gated by
// this cap, so legitimate quorum collection is unaffected.
static constexpr std::size_t maxTrackedTxns = 4096;
enum class PositionStatus { empty, unique, conflicted };
void
touchSeq(SigEntry& entry, std::uint32_t seq)
class AdmissionTicket
{
if (entry.firstSeenSeq == 0 && seq > 0)
entry.firstSeenSeq = seq;
friend class ExportSigCollector;
uint256 origin_;
std::uint64_t reservation_;
Contribution contribution_;
AdmissionTicket(
uint256 const& origin,
std::uint64_t reservation,
Contribution contribution)
: origin_(origin)
, reservation_(reservation)
, contribution_(std::move(contribution))
{
}
public:
uint256 const&
origin() const
{
return origin_;
}
Contribution const&
contribution() const
{
return contribution_;
}
};
struct Admission
{
BeginResult result;
std::optional<AdmissionTicket> ticket;
};
class PublicationToken
{
friend class ExportSigCollector;
uint256 origin_;
std::uint64_t generation_;
PublicationToken(uint256 const& origin, std::uint64_t generation)
: origin_(origin), generation_(generation)
{
}
};
struct AdmitOutcome
{
AdmitResult result;
// Present when this admission observed the second valid encoding.
std::optional<Contribution> priorContribution;
std::optional<Contribution> conflictingContribution;
};
using UnionSnapshot = std::map<uint256, std::vector<Contribution>>;
// Conservative local bounds. These are tuning values, not wire-format
// dimensions, and must be reviewed before feature activation.
static constexpr std::size_t maxTrackedOrigins = 4096;
static constexpr std::uint32_t maxStaleLedgers = 256;
static constexpr std::uint32_t maxReservationLedgers = 1;
static constexpr std::size_t maxPublicationTriggers = 64;
static constexpr std::size_t maxSignatureBytes = 72;
private:
struct Reservation
{
Contribution contribution;
std::uint32_t reservedAtSeq;
};
struct PositionEntry
{
std::optional<Contribution> unique;
bool conflicted{false};
std::map<std::uint64_t, Reservation> reservations;
};
struct OriginEntry
{
std::map<Position, PositionEntry> positions;
std::set<Position> published;
std::set<uint256> publicationTriggers;
uint256 publicationTrigger;
std::uint64_t publicationGeneration{0};
std::uint32_t lastTouchedSeq{0};
};
mutable std::mutex mutex_;
std::unordered_map<uint256, OriginEntry> origins_;
std::uint64_t nextReservation_{1};
std::uint64_t nextPublicationGeneration_{1};
static bool
sameEncoding(Contribution const& lhs, Contribution const& rhs)
{
return lhs.signingKey == rhs.signingKey &&
lhs.signature == rhs.signature;
}
static void
touch(OriginEntry& entry, std::uint32_t currentSeq)
{
if (currentSeq > entry.lastTouchedSeq)
entry.lastTouchedSeq = currentSeq;
}
static void
pruneReservations(PositionEntry& position, std::uint32_t currentSeq)
{
for (auto it = position.reservations.begin();
it != position.reservations.end();)
{
auto const reserved = it->second.reservedAtSeq;
if (currentSeq > reserved &&
currentSeq - reserved > maxReservationLedgers)
it = position.reservations.erase(it);
else
++it;
}
}
static void
eraseEmptyPosition(OriginEntry& origin, Position position)
{
auto const it = origin.positions.find(position);
if (it != origin.positions.end() && !it->second.unique &&
!it->second.conflicted && it->second.reservations.empty())
origin.positions.erase(it);
}
std::uint64_t
nextReservation()
{
auto const result = nextReservation_++;
if (nextReservation_ == 0)
nextReservation_ = 1;
return result;
}
std::uint64_t
nextPublicationGeneration()
{
auto const result = nextPublicationGeneration_++;
if (nextPublicationGeneration_ == 0)
nextPublicationGeneration_ = 1;
return result;
}
public:
/// Store a signature that has been cryptographically verified
/// against buildMultiSigningData + verify().
void
addVerifiedSignature(
uint256 const& txnHash,
PublicKey const& validator,
Buffer const& signature,
/** Reserve one contribution encoding for verification.
The caller must first attribute the signing key to this exact selected
committee position using the validated latch and current manifest. The
reservation is then the cryptographic pre-verification DoS boundary.
At most two
distinct encodings can be in-flight or admitted at a position. The
caller performs cryptographic verification only for `verify`, then
returns the ticket through `admitContribution`.
*/
Admission
beginAttributedAdmission(
uint256 const& origin,
Contribution contribution,
std::uint32_t currentSeq = 0)
{
XRPL_ASSERT(
signature.size() > 0,
"ripple::ExportSigCollector::addVerifiedSignature : "
"non-empty signature");
if (origin.isZero() || currentSeq == 0 ||
contribution.position >= ExportLimits::maxCommitteeMembers ||
contribution.signature.empty() ||
contribution.signature.size() > maxSignatureBytes)
return {BeginResult::malformed, std::nullopt};
std::lock_guard lock(mutex_);
auto& entry = sigs_[txnHash];
entry.validators.insert(validator);
entry.signatures[validator] = signature;
entry.verified.insert(validator);
touchSeq(entry, currentSeq);
auto originIt = origins_.find(origin);
if (originIt == origins_.end())
return {BeginResult::unknownOrigin, std::nullopt};
auto& originEntry = originIt->second;
auto& position = originEntry.positions[contribution.position];
pruneReservations(position, currentSeq);
if (position.conflicted)
return {BeginResult::conflicted, std::nullopt};
if (position.unique && sameEncoding(*position.unique, contribution))
return {BeginResult::duplicate, std::nullopt};
for (auto const& [_, pending] : position.reservations)
{
if (sameEncoding(pending.contribution, contribution))
return {BeginResult::duplicate, std::nullopt};
}
auto const distinct = position.reservations.size() +
static_cast<std::size_t>(position.unique.has_value());
if (distinct >= 2)
return {BeginResult::capacity, std::nullopt};
auto const reservation = nextReservation();
position.reservations.emplace(
reservation, Reservation{contribution, currentSeq});
return {
BeginResult::verify,
AdmissionTicket{origin, reservation, std::move(contribution)}};
}
/// Store a signature from a trusted source (checkSign + sender
/// binding passed) but without multisign content verification.
/// Used when the ttEXPORT tx isn't in the open ledger yet due
/// to relay ordering. Will be upgraded to verified via
/// upgradeSignature() when the tx becomes available.
///
/// Does NOT count toward quorum or appear in SHAMap/snapshot.
void
addUnverifiedSignature(
uint256 const& txnHash,
PublicKey const& validator,
Buffer const& signature,
std::uint32_t currentSeq = 0)
{
XRPL_ASSERT(
signature.size() > 0,
"ripple::ExportSigCollector::addUnverifiedSignature : "
"non-empty signature");
std::lock_guard lock(mutex_);
// Bound the unverified cache (see maxTrackedTxns). Only gate NEW
// txHashes; existing entries and the verified path are never blocked,
// so real exports still reach quorum.
if (sigs_.find(txnHash) == sigs_.end() &&
sigs_.size() >= maxTrackedTxns)
return;
auto& entry = sigs_[txnHash];
entry.validators.insert(validator);
// Don't overwrite a verified sig with an unverified one.
if (entry.verified.find(validator) == entry.verified.end())
entry.signatures[validator] = signature;
touchSeq(entry, currentSeq);
}
/// Upgrade a previously unverified sig to verified.
/// Called from Export::doApply after verifying against the inner tx.
/// The caller passes the exact buffer it verified; we only promote
/// if the stored buffer still matches (guards against concurrent
/// overwrites between unverifiedSignatures() and this call).
void
upgradeSignature(
uint256 const& txnHash,
PublicKey const& validator,
Buffer const& verifiedBuf,
std::uint32_t currentSeq = 0)
{
std::lock_guard lock(mutex_);
auto it = sigs_.find(txnHash);
if (it == sigs_.end())
return;
auto sit = it->second.signatures.find(validator);
if (sit == it->second.signatures.end() || sit->second.size() == 0)
return;
// Only promote if the stored buffer is the same one we verified.
if (!(sit->second == verifiedBuf))
return;
it->second.verified.insert(validator);
touchSeq(it->second, currentSeq);
}
/// Remove a signature if the stored buffer still matches the caller's
/// verified-invalid buffer. This keeps stale unverified data from being
/// retried forever while avoiding races with a newer replacement.
/** Cancel a reservation when verification cannot be completed. */
bool
removeSignature(
uint256 const& txnHash,
PublicKey const& validator,
Buffer const& expectedBuf)
cancelAdmission(AdmissionTicket ticket)
{
std::lock_guard lock(mutex_);
auto it = sigs_.find(txnHash);
if (it == sigs_.end())
auto originIt = origins_.find(ticket.origin_);
if (originIt == origins_.end())
return false;
auto& entry = it->second;
auto sit = entry.signatures.find(validator);
if (sit == entry.signatures.end() || !(sit->second == expectedBuf))
auto positionIt =
originIt->second.positions.find(ticket.contribution_.position);
if (positionIt == originIt->second.positions.end())
return false;
entry.signatures.erase(sit);
entry.validators.erase(validator);
entry.verified.erase(validator);
if (entry.signatures.empty() && entry.validators.empty() &&
entry.verified.empty())
sigs_.erase(it);
auto reservationIt =
positionIt->second.reservations.find(ticket.reservation_);
if (reservationIt == positionIt->second.reservations.end() ||
!(reservationIt->second.contribution == ticket.contribution_))
return false;
positionIt->second.reservations.erase(reservationIt);
eraseEmptyPosition(originIt->second, ticket.contribution_.position);
return true;
}
/// Store a pubkey-only entry (no real signature). Used in
/// standalone mode where quorum counting is sufficient.
/// Treated as verified (standalone has no consensus to verify against).
void
addStandaloneSignature(
uint256 const& txnHash,
PublicKey const& validator,
/** Complete a reserved admission after signature verification. */
AdmitOutcome
admitContribution(
AdmissionTicket ticket,
bool signatureVerified,
std::uint32_t currentSeq = 0)
{
std::lock_guard lock(mutex_);
auto& entry = sigs_[txnHash];
entry.validators.insert(validator);
if (entry.signatures.find(validator) == entry.signatures.end())
entry.signatures[validator] = Buffer{};
entry.verified.insert(validator);
touchSeq(entry, currentSeq);
}
auto originIt = origins_.find(ticket.origin_);
if (originIt == origins_.end())
return {AdmitResult::stale, std::nullopt, std::nullopt};
/// Check if a cryptographically verified signature exists.
/// Used to skip redundant verify() calls when the same sig
/// arrives more than once through proposal relay.
bool
hasVerifiedSignature(uint256 const& txnHash, PublicKey const& validator)
const
{
std::lock_guard lock(mutex_);
auto it = sigs_.find(txnHash);
if (it == sigs_.end())
return false;
return it->second.verified.count(validator) > 0;
}
auto& originEntry = originIt->second;
auto positionIt =
originEntry.positions.find(ticket.contribution_.position);
if (positionIt == originEntry.positions.end())
return {AdmitResult::stale, std::nullopt, std::nullopt};
/// Return unverified validators for a given txHash.
/// Used by Export::doApply to find sigs that need verification.
std::map<PublicKey, Buffer>
unverifiedSignatures(uint256 const& txnHash) const
{
std::lock_guard lock(mutex_);
std::map<PublicKey, Buffer> result;
auto it = sigs_.find(txnHash);
if (it == sigs_.end())
return result;
for (auto const& [pk, buf] : it->second.signatures)
auto& position = positionIt->second;
auto reservationIt = position.reservations.find(ticket.reservation_);
if (reservationIt == position.reservations.end() ||
!(reservationIt->second.contribution == ticket.contribution_))
return {AdmitResult::stale, std::nullopt, std::nullopt};
position.reservations.erase(reservationIt);
if (!signatureVerified)
{
if (buf.size() > 0 && it->second.verified.count(pk) == 0)
result[pk] = buf;
eraseEmptyPosition(originEntry, ticket.contribution_.position);
return {AdmitResult::invalid, std::nullopt, std::nullopt};
}
if (position.conflicted)
return {AdmitResult::stale, std::nullopt, std::nullopt};
if (!position.unique)
{
position.unique = std::move(ticket.contribution_);
touch(originEntry, currentSeq);
return {AdmitResult::accepted, std::nullopt, std::nullopt};
}
if (sameEncoding(*position.unique, ticket.contribution_))
return {AdmitResult::duplicate, std::nullopt, std::nullopt};
auto prior = std::move(position.unique);
auto conflicting = std::move(ticket.contribution_);
position.unique.reset();
position.conflicted = true;
position.reservations.clear();
touch(originEntry, currentSeq);
return {
AdmitResult::conflicted, std::move(prior), std::move(conflicting)};
}
/** Reopen per-attempt publication without changing contribution state. */
std::optional<PublicationToken>
reopenPublication(
uint256 const& origin,
uint256 const& trigger,
std::uint32_t currentSeq)
{
if (origin.isZero() || trigger.isZero() || currentSeq == 0)
return std::nullopt;
std::lock_guard lock(mutex_);
auto it = origins_.find(origin);
if (it == origins_.end())
{
if (origins_.size() >= maxTrackedOrigins)
return std::nullopt;
it = origins_.emplace(origin, OriginEntry{}).first;
}
auto& entry = it->second;
if (entry.publicationGeneration != 0 &&
entry.publicationTrigger == trigger)
return PublicationToken{origin, entry.publicationGeneration};
if (entry.publicationTriggers.count(trigger) != 0 ||
entry.publicationTriggers.size() >= maxPublicationTriggers)
return std::nullopt;
entry.published.clear();
entry.publicationTrigger = trigger;
entry.publicationTriggers.insert(trigger);
entry.publicationGeneration = nextPublicationGeneration();
touch(entry, currentSeq);
return PublicationToken{origin, entry.publicationGeneration};
}
/** Claim one position's publication slot in the current attempt. */
bool
claimPublication(
PublicationToken const& token,
Position position,
std::size_t maxDistinct)
{
std::lock_guard lock(mutex_);
auto it = origins_.find(token.origin_);
if (it == origins_.end() ||
it->second.publicationGeneration != token.generation_)
return false;
auto const positionIt = it->second.positions.find(position);
if (positionIt == it->second.positions.end() ||
!positionIt->second.unique || positionIt->second.conflicted)
return false;
if (it->second.published.count(position) != 0 ||
it->second.published.size() >= maxDistinct)
return false;
it->second.published.insert(position);
return true;
}
PositionStatus
positionStatus(uint256 const& origin, Position position) const
{
std::lock_guard lock(mutex_);
auto const originIt = origins_.find(origin);
if (originIt == origins_.end())
return PositionStatus::empty;
auto const positionIt = originIt->second.positions.find(position);
if (positionIt == originIt->second.positions.end())
return PositionStatus::empty;
if (positionIt->second.conflicted)
return PositionStatus::conflicted;
return positionIt->second.unique ? PositionStatus::unique
: PositionStatus::empty;
}
std::uint64_t
publicationGeneration(uint256 const& origin) const
{
std::lock_guard lock(mutex_);
auto const it = origins_.find(origin);
return it == origins_.end() ? 0 : it->second.publicationGeneration;
}
/** Deterministic full union of every unique, verified contribution. */
UnionSnapshot
fullUnionSnapshot() const
{
std::lock_guard lock(mutex_);
UnionSnapshot result;
for (auto const& [origin, entry] : origins_)
{
std::vector<Contribution> contributions;
for (auto const& [_, position] : entry.positions)
{
if (!position.conflicted && position.unique)
contributions.push_back(*position.unique);
}
if (!contributions.empty())
result.emplace(origin, std::move(contributions));
}
return result;
}
bool
hasUnverifiedSignatures() const
{
std::lock_guard lock(mutex_);
for (auto const& [_, entry] : sigs_)
{
for (auto const& [pk, buf] : entry.signatures)
{
if (buf.size() > 0 && entry.verified.count(pk) == 0)
return true;
}
}
return false;
}
/// Count of VERIFIED signatures only.
template <class IncludeValidator>
std::size_t
signatureCount(uint256 const& txnHash, IncludeValidator includeValidator)
const
{
std::lock_guard lock(mutex_);
auto it = sigs_.find(txnHash);
if (it == sigs_.end())
return 0;
return std::count_if(
it->second.verified.begin(),
it->second.verified.end(),
includeValidator);
}
/// Count of VERIFIED signatures only.
std::size_t
signatureCount(uint256 const& txnHash) const
{
return signatureCount(txnHash, [](PublicKey const&) { return true; });
}
void
clear(uint256 const& txnHash)
clear(uint256 const& origin)
{
std::lock_guard lock(mutex_);
sigs_.erase(txnHash);
origins_.erase(origin);
}
void
clearAll()
{
std::lock_guard lock(mutex_);
sigs_.clear();
sentThisRound_.clear();
origins_.clear();
}
/// Get a snapshot of VERIFIED sigs (pubkeys only) for building
/// the SHAMap. Only verified sigs appear in convergence.
std::unordered_map<uint256, std::set<PublicKey>>
snapshot() const
{
std::lock_guard lock(mutex_);
std::unordered_map<uint256, std::set<PublicKey>> result;
for (auto const& [hash, entry] : sigs_)
{
if (!entry.verified.empty())
result[hash] = entry.verified;
}
return result;
}
/// Get a snapshot of VERIFIED signatures including sig buffers.
template <class IncludeValidator>
std::unordered_map<uint256, std::map<PublicKey, Buffer>>
snapshotWithSigs(IncludeValidator includeValidator) const
{
std::lock_guard lock(mutex_);
std::unordered_map<uint256, std::map<PublicKey, Buffer>> result;
for (auto const& [hash, entry] : sigs_)
{
std::map<PublicKey, Buffer> verifiedSigs;
for (auto const& pk : entry.verified)
{
if (!includeValidator(pk))
continue;
auto sit = entry.signatures.find(pk);
if (sit != entry.signatures.end())
verifiedSigs[pk] = sit->second;
}
if (!verifiedSigs.empty())
result[hash] = std::move(verifiedSigs);
}
return result;
}
/// Get a snapshot of VERIFIED signatures including sig buffers.
std::unordered_map<uint256, std::map<PublicKey, Buffer>>
snapshotWithSigs() const
{
return snapshotWithSigs([](PublicKey const&) { return true; });
}
/// Atomic quorum check + snapshot for a single txHash.
/// Returns VERIFIED signatures if quorum is met, nullopt otherwise.
template <class IncludeValidator>
std::optional<std::map<PublicKey, Buffer>>
checkQuorumAndSnapshot(
uint256 const& txnHash,
std::size_t threshold,
IncludeValidator includeValidator) const
{
std::lock_guard lock(mutex_);
auto it = sigs_.find(txnHash);
if (it == sigs_.end())
return std::nullopt;
std::map<PublicKey, Buffer> result;
for (auto const& pk : it->second.verified)
{
if (!includeValidator(pk))
continue;
auto sit = it->second.signatures.find(pk);
XRPL_ASSERT(
sit != it->second.signatures.end(),
"ripple::ExportSigCollector::checkQuorumAndSnapshot : "
"verified key must exist in signatures map");
XRPL_ASSERT(
sit->second.size() > 0,
"ripple::ExportSigCollector::checkQuorumAndSnapshot : "
"verified signature must be non-empty");
if (sit != it->second.signatures.end())
result[pk] = sit->second;
}
if (result.size() < threshold)
return std::nullopt;
return result;
}
/// Atomic quorum check + snapshot for a single txHash.
/// Returns VERIFIED signatures if quorum is met, nullopt otherwise.
std::optional<std::map<PublicKey, Buffer>>
checkQuorumAndSnapshot(uint256 const& txnHash, std::size_t threshold) const
{
return checkQuorumAndSnapshot(
txnHash, threshold, [](PublicKey const&) { return true; });
}
/// Remove entries older than maxStaleLedgers.
void
cleanupStale(std::uint32_t currentSeq)
{
std::lock_guard lock(mutex_);
for (auto it = sigs_.begin(); it != sigs_.end();)
for (auto it = origins_.begin(); it != origins_.end();)
{
if (it->second.firstSeenSeq > 0 &&
currentSeq > it->second.firstSeenSeq + maxStaleLedgers)
it = sigs_.erase(it);
auto const last = it->second.lastTouchedSeq;
if (last > 0 && currentSeq > last &&
currentSeq - last > maxStaleLedgers)
it = origins_.erase(it);
else
++it;
}
}
/// Returns true if we haven't sent our sig for this tx yet this round.
/// Marks it as sent on first call.
bool
markSent(uint256 const& txnHash)
{
std::lock_guard lock(mutex_);
return sentThisRound_.insert(txnHash).second;
}
/// Returns true if this is a new tx for the round and the distinct sent
/// count remains below the caller's per-round publication cap.
bool
markSent(uint256 const& txnHash, std::size_t maxDistinct)
{
std::lock_guard lock(mutex_);
if (sentThisRound_.find(txnHash) != sentThisRound_.end())
return false;
if (sentThisRound_.size() >= maxDistinct)
return false;
sentThisRound_.insert(txnHash);
return true;
}
/// Clear per-round state. Call at the start of each consensus round.
void
clearRound()
{
std::lock_guard lock(mutex_);
sentThisRound_.clear();
}
};
} // namespace ripple

View File

@@ -1,475 +0,0 @@
#ifndef RIPPLE_APP_MISC_EXPORTSIGCOLLECTORV2_H_INCLUDED
#define RIPPLE_APP_MISC_EXPORTSIGCOLLECTORV2_H_INCLUDED
#include <xrpl/basics/Buffer.h>
#include <xrpl/protocol/ExportLimits.h>
#include <xrpl/protocol/PublicKey.h>
#include <cstdint>
#include <map>
#include <mutex>
#include <optional>
#include <set>
#include <unordered_map>
#include <utility>
#include <vector>
namespace ripple {
/** Post-validation Export contribution collector.
This class is intentionally separate from the live legacy collector. Its
contribution identity is the immutable Export origin plus the position in
that origin's immutable validator committee. Publication attempts may
reopen, but never reset admitted contributions or conflict state.
*/
class ExportSigCollectorV2
{
public:
using Position = std::uint16_t;
struct Contribution
{
Position position;
PublicKey signingKey;
Buffer signature;
friend bool
operator==(Contribution const& lhs, Contribution const& rhs)
{
return lhs.position == rhs.position &&
lhs.signingKey == rhs.signingKey &&
lhs.signature == rhs.signature;
}
};
enum class BeginResult {
verify,
duplicate,
conflicted,
unknownOrigin,
capacity,
malformed
};
enum class AdmitResult { accepted, duplicate, conflicted, invalid, stale };
enum class PositionStatus { empty, unique, conflicted };
class AdmissionTicket
{
friend class ExportSigCollectorV2;
uint256 origin_;
std::uint64_t reservation_;
Contribution contribution_;
AdmissionTicket(
uint256 const& origin,
std::uint64_t reservation,
Contribution contribution)
: origin_(origin)
, reservation_(reservation)
, contribution_(std::move(contribution))
{
}
public:
uint256 const&
origin() const
{
return origin_;
}
Contribution const&
contribution() const
{
return contribution_;
}
};
struct Admission
{
BeginResult result;
std::optional<AdmissionTicket> ticket;
};
class PublicationToken
{
friend class ExportSigCollectorV2;
uint256 origin_;
std::uint64_t generation_;
PublicationToken(uint256 const& origin, std::uint64_t generation)
: origin_(origin), generation_(generation)
{
}
};
struct AdmitOutcome
{
AdmitResult result;
// Present when this admission observed the second valid encoding.
std::optional<Contribution> priorContribution;
std::optional<Contribution> conflictingContribution;
};
using UnionSnapshot = std::map<uint256, std::vector<Contribution>>;
// Conservative local bounds. These are tuning values, not wire-format
// dimensions, and must be reviewed before feature activation.
static constexpr std::size_t maxTrackedOrigins = 4096;
static constexpr std::uint32_t maxStaleLedgers = 256;
static constexpr std::uint32_t maxReservationLedgers = 1;
static constexpr std::size_t maxPublicationTriggers = 64;
static constexpr std::size_t maxSignatureBytes = 72;
private:
struct Reservation
{
Contribution contribution;
std::uint32_t reservedAtSeq;
};
struct PositionEntry
{
std::optional<Contribution> unique;
bool conflicted{false};
std::map<std::uint64_t, Reservation> reservations;
};
struct OriginEntry
{
std::map<Position, PositionEntry> positions;
std::set<Position> published;
std::set<uint256> publicationTriggers;
uint256 publicationTrigger;
std::uint64_t publicationGeneration{0};
std::uint32_t lastTouchedSeq{0};
};
mutable std::mutex mutex_;
std::unordered_map<uint256, OriginEntry> origins_;
std::uint64_t nextReservation_{1};
std::uint64_t nextPublicationGeneration_{1};
static bool
sameEncoding(Contribution const& lhs, Contribution const& rhs)
{
return lhs.signingKey == rhs.signingKey &&
lhs.signature == rhs.signature;
}
static void
touch(OriginEntry& entry, std::uint32_t currentSeq)
{
if (currentSeq > entry.lastTouchedSeq)
entry.lastTouchedSeq = currentSeq;
}
static void
pruneReservations(PositionEntry& position, std::uint32_t currentSeq)
{
for (auto it = position.reservations.begin();
it != position.reservations.end();)
{
auto const reserved = it->second.reservedAtSeq;
if (currentSeq > reserved &&
currentSeq - reserved > maxReservationLedgers)
it = position.reservations.erase(it);
else
++it;
}
}
static void
eraseEmptyPosition(OriginEntry& origin, Position position)
{
auto const it = origin.positions.find(position);
if (it != origin.positions.end() && !it->second.unique &&
!it->second.conflicted && it->second.reservations.empty())
origin.positions.erase(it);
}
std::uint64_t
nextReservation()
{
auto const result = nextReservation_++;
if (nextReservation_ == 0)
nextReservation_ = 1;
return result;
}
std::uint64_t
nextPublicationGeneration()
{
auto const result = nextPublicationGeneration_++;
if (nextPublicationGeneration_ == 0)
nextPublicationGeneration_ = 1;
return result;
}
public:
/** Reserve one contribution encoding for verification.
The caller must first attribute the signing key to this exact selected
committee position using the validated latch and current manifest. The
reservation is then the cryptographic pre-verification DoS boundary.
At most two
distinct encodings can be in-flight or admitted at a position. The
caller performs cryptographic verification only for `verify`, then
returns the ticket through `admitContribution`.
*/
Admission
beginAttributedAdmission(
uint256 const& origin,
Contribution contribution,
std::uint32_t currentSeq = 0)
{
if (origin.isZero() || currentSeq == 0 ||
contribution.position >= ExportLimits::maxCommitteeMembers ||
contribution.signature.empty() ||
contribution.signature.size() > maxSignatureBytes)
return {BeginResult::malformed, std::nullopt};
std::lock_guard lock(mutex_);
auto originIt = origins_.find(origin);
if (originIt == origins_.end())
return {BeginResult::unknownOrigin, std::nullopt};
auto& originEntry = originIt->second;
auto& position = originEntry.positions[contribution.position];
pruneReservations(position, currentSeq);
if (position.conflicted)
return {BeginResult::conflicted, std::nullopt};
if (position.unique && sameEncoding(*position.unique, contribution))
return {BeginResult::duplicate, std::nullopt};
for (auto const& [_, pending] : position.reservations)
{
if (sameEncoding(pending.contribution, contribution))
return {BeginResult::duplicate, std::nullopt};
}
auto const distinct = position.reservations.size() +
static_cast<std::size_t>(position.unique.has_value());
if (distinct >= 2)
return {BeginResult::capacity, std::nullopt};
auto const reservation = nextReservation();
position.reservations.emplace(
reservation, Reservation{contribution, currentSeq});
return {
BeginResult::verify,
AdmissionTicket{origin, reservation, std::move(contribution)}};
}
/** Cancel a reservation when verification cannot be completed. */
bool
cancelAdmission(AdmissionTicket ticket)
{
std::lock_guard lock(mutex_);
auto originIt = origins_.find(ticket.origin_);
if (originIt == origins_.end())
return false;
auto positionIt =
originIt->second.positions.find(ticket.contribution_.position);
if (positionIt == originIt->second.positions.end())
return false;
auto reservationIt =
positionIt->second.reservations.find(ticket.reservation_);
if (reservationIt == positionIt->second.reservations.end() ||
!(reservationIt->second.contribution == ticket.contribution_))
return false;
positionIt->second.reservations.erase(reservationIt);
eraseEmptyPosition(originIt->second, ticket.contribution_.position);
return true;
}
/** Complete a reserved admission after signature verification. */
AdmitOutcome
admitContribution(
AdmissionTicket ticket,
bool signatureVerified,
std::uint32_t currentSeq = 0)
{
std::lock_guard lock(mutex_);
auto originIt = origins_.find(ticket.origin_);
if (originIt == origins_.end())
return {AdmitResult::stale, std::nullopt, std::nullopt};
auto& originEntry = originIt->second;
auto positionIt =
originEntry.positions.find(ticket.contribution_.position);
if (positionIt == originEntry.positions.end())
return {AdmitResult::stale, std::nullopt, std::nullopt};
auto& position = positionIt->second;
auto reservationIt = position.reservations.find(ticket.reservation_);
if (reservationIt == position.reservations.end() ||
!(reservationIt->second.contribution == ticket.contribution_))
return {AdmitResult::stale, std::nullopt, std::nullopt};
position.reservations.erase(reservationIt);
if (!signatureVerified)
{
eraseEmptyPosition(originEntry, ticket.contribution_.position);
return {AdmitResult::invalid, std::nullopt, std::nullopt};
}
if (position.conflicted)
return {AdmitResult::stale, std::nullopt, std::nullopt};
if (!position.unique)
{
position.unique = std::move(ticket.contribution_);
touch(originEntry, currentSeq);
return {AdmitResult::accepted, std::nullopt, std::nullopt};
}
if (sameEncoding(*position.unique, ticket.contribution_))
return {AdmitResult::duplicate, std::nullopt, std::nullopt};
auto prior = std::move(position.unique);
auto conflicting = std::move(ticket.contribution_);
position.unique.reset();
position.conflicted = true;
position.reservations.clear();
touch(originEntry, currentSeq);
return {
AdmitResult::conflicted, std::move(prior), std::move(conflicting)};
}
/** Reopen per-attempt publication without changing contribution state. */
std::optional<PublicationToken>
reopenPublication(
uint256 const& origin,
uint256 const& trigger,
std::uint32_t currentSeq)
{
if (origin.isZero() || trigger.isZero() || currentSeq == 0)
return std::nullopt;
std::lock_guard lock(mutex_);
auto it = origins_.find(origin);
if (it == origins_.end())
{
if (origins_.size() >= maxTrackedOrigins)
return std::nullopt;
it = origins_.emplace(origin, OriginEntry{}).first;
}
auto& entry = it->second;
if (entry.publicationGeneration != 0 &&
entry.publicationTrigger == trigger)
return PublicationToken{origin, entry.publicationGeneration};
if (entry.publicationTriggers.count(trigger) != 0 ||
entry.publicationTriggers.size() >= maxPublicationTriggers)
return std::nullopt;
entry.published.clear();
entry.publicationTrigger = trigger;
entry.publicationTriggers.insert(trigger);
entry.publicationGeneration = nextPublicationGeneration();
touch(entry, currentSeq);
return PublicationToken{origin, entry.publicationGeneration};
}
/** Claim one position's publication slot in the current attempt. */
bool
claimPublication(
PublicationToken const& token,
Position position,
std::size_t maxDistinct)
{
std::lock_guard lock(mutex_);
auto it = origins_.find(token.origin_);
if (it == origins_.end() ||
it->second.publicationGeneration != token.generation_)
return false;
auto const positionIt = it->second.positions.find(position);
if (positionIt == it->second.positions.end() ||
!positionIt->second.unique || positionIt->second.conflicted)
return false;
if (it->second.published.count(position) != 0 ||
it->second.published.size() >= maxDistinct)
return false;
it->second.published.insert(position);
return true;
}
PositionStatus
positionStatus(uint256 const& origin, Position position) const
{
std::lock_guard lock(mutex_);
auto const originIt = origins_.find(origin);
if (originIt == origins_.end())
return PositionStatus::empty;
auto const positionIt = originIt->second.positions.find(position);
if (positionIt == originIt->second.positions.end())
return PositionStatus::empty;
if (positionIt->second.conflicted)
return PositionStatus::conflicted;
return positionIt->second.unique ? PositionStatus::unique
: PositionStatus::empty;
}
std::uint64_t
publicationGeneration(uint256 const& origin) const
{
std::lock_guard lock(mutex_);
auto const it = origins_.find(origin);
return it == origins_.end() ? 0 : it->second.publicationGeneration;
}
/** Deterministic full union of every unique, verified contribution. */
UnionSnapshot
fullUnionSnapshot() const
{
std::lock_guard lock(mutex_);
UnionSnapshot result;
for (auto const& [origin, entry] : origins_)
{
std::vector<Contribution> contributions;
for (auto const& [_, position] : entry.positions)
{
if (!position.conflicted && position.unique)
contributions.push_back(*position.unique);
}
if (!contributions.empty())
result.emplace(origin, std::move(contributions));
}
return result;
}
void
clear(uint256 const& origin)
{
std::lock_guard lock(mutex_);
origins_.erase(origin);
}
void
clearAll()
{
std::lock_guard lock(mutex_);
origins_.clear();
}
void
cleanupStale(std::uint32_t currentSeq)
{
std::lock_guard lock(mutex_);
for (auto it = origins_.begin(); it != origins_.end();)
{
auto const last = it->second.lastTouchedSeq;
if (last > 0 && currentSeq > last &&
currentSeq - last > maxStaleLedgers)
it = origins_.erase(it);
else
++it;
}
}
};
} // namespace ripple
#endif