test(export): pin expiry and no-veto boundaries

This commit is contained in:
Nicholas Dudfield
2026-06-23 16:42:02 +07:00
parent 639e153f3b
commit 8bf1ece0a0
5 changed files with 111 additions and 5 deletions

View File

@@ -70,9 +70,12 @@ async def scenario(ctx, log):
"(need 4 for 80% quorum) -- check runtime_config no_export_sig"
)
# Should be tecEXPORT_EXPIRED (LLS reached without quorum)
# Should be tecEXPORT_EXPIRED (LLS reached without quorum). Be exact here:
# any other non-success means the retry/expiry boundary regressed.
if engine_result != "tecEXPORT_EXPIRED":
log(f"WARNING: expected tecEXPORT_EXPIRED, got {engine_result}")
raise AssertionError(
f"Expected tecEXPORT_EXPIRED below quorum, got {engine_result}"
)
log(f"Export failed as expected ({engine_result})")

View File

@@ -84,6 +84,11 @@ async def scenario(ctx, log, expect_success=True):
raise AssertionError(
"Export should NOT have succeeded below active-view quorum"
)
if engine_result != "tecEXPORT_EXPIRED":
raise AssertionError(
"Expected tecEXPORT_EXPIRED below active-view quorum, "
f"got {engine_result}"
)
log(f"Export failed as expected ({engine_result})")
# No shadow ticket should exist

View File

@@ -58,8 +58,13 @@ async def scenario(ctx, log):
"Export should not succeed without a ledger-anchored UNLReport view"
)
# Be exact: without a UNLReport view the export should retry until LLS and
# expire, not fail by some unrelated terminal code.
if engine_result != "tecEXPORT_EXPIRED":
log(f"WARNING: expected tecEXPORT_EXPIRED, got {engine_result}")
raise AssertionError(
"Expected tecEXPORT_EXPIRED without UNLReport view, "
f"got {engine_result}"
)
warning_logs = ctx.assert_log(
r"Export: retrying without ledger-anchored validator view",

View File

@@ -908,6 +908,98 @@ struct Export_test : public beast::unit_test::suite
BEAST_EXPECT(!next->read(keylet::shadowTicket(alice.id(), ticketSeq)));
}
void
testExportNetworkLastLedgerSequenceBoundary(FeatureBitset features)
{
testcase("ttEXPORT network mode LastLedgerSequence boundary");
using namespace jtx;
auto applyAtLastLedger = [&](bool withQuorum) {
Env env{*this, exportTestConfig(), features};
Account const alice{"alice"};
Account const carol{"carol"};
env.fund(XRP(10000), alice, carol);
env.close();
auto const& valKeys = env.app().getValidatorKeys();
BEAST_EXPECT(valKeys.keys);
if (!valKeys.keys)
return;
auto const& valPK = valKeys.keys->publicKey;
auto const& valSK = valKeys.keys->secretKey;
seedUNLReportLedger(env, {valPK});
forceNonStandalone(env.app());
BEAST_EXPECT(!env.app().config().standalone());
auto const parent = env.app().getLedgerMaster().getClosedLedger();
auto const applySeq = parent->seq() + 1;
auto const ticketSeq =
withQuorum ? std::uint32_t{1} : std::uint32_t{2};
auto innerObj = buildExportedPayment(
alice.id(), carol.id(), applySeq, applySeq, ticketSeq);
auto const innerTx = makeSTTx(innerObj);
auto jt = makeExportJTx(env, alice, innerObj, applySeq);
auto const exportTx = jt.stx;
BEAST_EXPECT(exportTx);
if (!exportTx)
return;
auto const txHash = exportTx->getTransactionID();
auto& ce = env.app().getConsensusExtensions();
ce.setExportEnabledThisRound(true);
ce.cacheUNLReport(parent);
auto const view = ce.activeValidatorView();
BEAST_EXPECT(view->fromUNLReport);
ce.cacheConsensusTxSet(makeRCLTxSet(env.app(), {exportTx}));
if (withQuorum)
{
auto const sig =
ExportResultBuilder::signExportedTxn(innerTx, valPK, valSK);
ce.exportSigCollector().addVerifiedSignature(
txHash, valPK, sig, applySeq);
auto const agreedHash = ce.buildExportSigSet(applySeq);
BEAST_EXPECT(ce.isSidecarSet(agreedHash));
}
auto next = std::make_shared<Ledger>(
*parent, env.app().timeKeeper().closeTime());
BEAST_EXPECT(next->seq() == applySeq);
OpenView accum(&*next);
auto const result = ripple::apply(
env.app(), accum, *exportTx, tapNONE, env.journal);
if (withQuorum)
{
BEAST_EXPECT(result.ter == tesSUCCESS);
BEAST_EXPECT(result.applied);
accum.apply(*next);
BEAST_EXPECT(
next->read(keylet::shadowTicket(alice.id(), ticketSeq)));
}
else
{
BEAST_EXPECT(result.ter == tecEXPORT_EXPIRED);
// tecEXPORT_EXPIRED is a terminal tec result: it applies to
// consume the sequence/fee, but must not create export state.
BEAST_EXPECT(result.applied);
accum.apply(*next);
BEAST_EXPECT(
!next->read(keylet::shadowTicket(alice.id(), ticketSeq)));
}
};
// Quorum at ledger == LastLedgerSequence still succeeds; no quorum at
// the same boundary expires cleanly instead of falling through to a
// later tefMAX_LEDGER preclaim.
applyAtLastLedger(true);
applyAtLastLedger(false);
}
void
testOpenLedgerExportLimit(FeatureBitset features)
{
@@ -1358,6 +1450,7 @@ struct Export_test : public beast::unit_test::suite
testExportNetworkRetryWithoutQuorum(allWithExport);
testExportNetworkApplyUsesAgreedSidecar(allWithExport);
testExportNetworkRetryWithoutUNLReport(allWithExport);
testExportNetworkLastLedgerSequenceBoundary(allWithExport);
testOpenLedgerExportLimit(allWithExport);
testShadowTicketLimit(allWithExport);
testShadowTicketLifecycle(allWithExport);

View File

@@ -1124,7 +1124,7 @@ extensionsTick(Ext& ext, Ctx const& ctx)
exportState = inspectExportPeers(ctx.getPosition(), true);
}
//@@start export-no-veto-quorum-branch
//@@start export-no-veto-quorum-branches
if (exportState.conflict && quorumAligned())
{
// Export sidecar roots are signed through ExtendedPosition
@@ -1142,7 +1142,6 @@ extensionsTick(Ext& ext, Ctx const& ctx)
<< " peersSeen=" << exportState.peersSeen
<< " txConverged=" << exportState.txConverged;
}
//@@end export-no-veto-quorum-branch
else if (quorumAligned() && !exportState.fullObservation())
{
JLOG(ext.j_.info())
@@ -1156,6 +1155,7 @@ extensionsTick(Ext& ext, Ctx const& ctx)
<< " peersSeen=" << exportState.peersSeen
<< " txConverged=" << exportState.txConverged;
}
//@@end export-no-veto-quorum-branches
else if (exportState.conflict || !quorumAligned())
{
auto const elapsed =