refactor(export): name admission lifecycle precisely

This commit is contained in:
Nicholas Dudfield
2026-07-16 17:36:03 +07:00
parent 4b229bda82
commit f0e36c0ba4
10 changed files with 68 additions and 40 deletions

View File

@@ -54,7 +54,7 @@ struct ExportLimits
// Maximum admission window requested through the mandatory outer
// LastLedgerSequence. This bounds how long an Export may remain queued
// before entering a ledger; it does not bound post-validation release.
static constexpr std::uint32_t maxRetryLedgers = 5;
static constexpr std::uint32_t maxAdmissionWindowLedgers = 5;
// Fixed source-ledger window for post-validation share publication and
// witness materialization, measured from the ledger that admits the

View File

@@ -132,7 +132,7 @@ struct Export_test : public beast::unit_test::suite
jvExport[jss::TransactionType] = jss::Export;
jvExport[jss::Account] = alice.human();
jvExport[jss::LastLedgerSequence] =
xahau.current()->seq() + ExportLimits::maxRetryLedgers;
xahau.current()->seq() + ExportLimits::maxAdmissionWindowLedgers;
jvExport[sfExportedTxn.jsonName] = innerObj.getJson(JsonOptions::none);
auto const& valKeys = xahau.app().getValidatorKeys();
@@ -884,7 +884,7 @@ struct Export_test : public beast::unit_test::suite
BEAST_EXPECT(it->second.size() == 1);
std::shared_ptr<STTx const> witness;
for (std::size_t attempt = 0;
attempt < ExportLimits::maxRetryLedgers && !witness;
attempt < ExportLimits::maxAdmissionWindowLedgers && !witness;
++attempt)
{
env.close();
@@ -1160,12 +1160,13 @@ struct Export_test : public beast::unit_test::suite
alice.id(),
carol.id(),
seq + 1,
seq + ExportLimits::maxRetryLedgers);
seq + ExportLimits::maxAdmissionWindowLedgers);
Json::Value jv;
jv[jss::TransactionType] = jss::Export;
jv[jss::Account] = alice.human();
jv[jss::LastLedgerSequence] = seq + ExportLimits::maxRetryLedgers;
jv[jss::LastLedgerSequence] =
seq + ExportLimits::maxAdmissionWindowLedgers;
bindExportAuthority(env, jv);
jv[sfExportedTxn.jsonName] = innerObj.getJson(JsonOptions::none);
@@ -1200,7 +1201,7 @@ struct Export_test : public beast::unit_test::suite
auto const seq = env.current()->seq();
auto const ticketSeq = std::uint32_t{1};
auto const lls = seq + ExportLimits::maxRetryLedgers;
auto const lls = seq + ExportLimits::maxAdmissionWindowLedgers;
auto innerObj = buildExportedPayment(
alice.id(), carol.id(), seq + 1, lls, ticketSeq);
@@ -1817,11 +1818,12 @@ struct Export_test : public beast::unit_test::suite
alice.id(),
carol.id(),
seq + 1,
seq + ExportLimits::maxRetryLedgers);
seq + ExportLimits::maxAdmissionWindowLedgers);
Json::Value intent;
intent[jss::TransactionType] = jss::Export;
intent[jss::Account] = alice.human();
intent[jss::LastLedgerSequence] = seq + ExportLimits::maxRetryLedgers;
intent[jss::LastLedgerSequence] =
seq + ExportLimits::maxAdmissionWindowLedgers;
intent[sfExportedTxn.jsonName] = innerObj.getJson(JsonOptions::none);
intent[sfExportCommitteeHash.jsonName] = to_string(digest);
env(intent, fee(XRP(1)), ter(tecEXPORT_COMMITTEE_UNAVAILABLE));
@@ -1847,7 +1849,7 @@ struct Export_test : public beast::unit_test::suite
auto const seq = env.current()->seq();
auto const ticketSeq = std::uint32_t{1};
auto const lls = seq + ExportLimits::maxRetryLedgers;
auto const lls = seq + ExportLimits::maxAdmissionWindowLedgers;
auto innerObj = buildExportedPayment(
alice.id(), carol.id(), seq + 1, lls, ticketSeq);
auto jt = makeExportJTx(env, alice, innerObj, lls);
@@ -1944,13 +1946,14 @@ struct Export_test : public beast::unit_test::suite
alice.id(),
carol.id(),
seq + 1,
seq + ExportLimits::maxRetryLedgers,
seq + ExportLimits::maxAdmissionWindowLedgers,
ticketSeq);
Json::Value jv;
jv[jss::TransactionType] = jss::Export;
jv[jss::Account] = alice.human();
jv[jss::LastLedgerSequence] = seq + ExportLimits::maxRetryLedgers;
jv[jss::LastLedgerSequence] =
seq + ExportLimits::maxAdmissionWindowLedgers;
jv[sfExportedTxn.jsonName] = innerObj.getJson(JsonOptions::none);
bindExportAuthority(env, jv);
@@ -1994,13 +1997,14 @@ struct Export_test : public beast::unit_test::suite
alice.id(),
carol.id(),
seq + 1,
seq + ExportLimits::maxRetryLedgers,
seq + ExportLimits::maxAdmissionWindowLedgers,
ticketSeq);
Json::Value jv;
jv[jss::TransactionType] = jss::Export;
jv[jss::Account] = alice.human();
jv[jss::LastLedgerSequence] = seq + ExportLimits::maxRetryLedgers;
jv[jss::LastLedgerSequence] =
seq + ExportLimits::maxAdmissionWindowLedgers;
jv[sfExportedTxn.jsonName] = innerObj.getJson(JsonOptions::none);
bindExportAuthority(env, jv);
@@ -2058,13 +2062,14 @@ struct Export_test : public beast::unit_test::suite
alice.id(),
carol.id(),
seq + 1,
seq + ExportLimits::maxRetryLedgers,
seq + ExportLimits::maxAdmissionWindowLedgers,
ticketSeq);
Json::Value jv;
jv[jss::TransactionType] = jss::Export;
jv[jss::Account] = alice.human();
jv[jss::LastLedgerSequence] = seq + ExportLimits::maxRetryLedgers;
jv[jss::LastLedgerSequence] =
seq + ExportLimits::maxAdmissionWindowLedgers;
jv[sfExportedTxn.jsonName] = innerObj.getJson(JsonOptions::none);
bindExportAuthority(env, jv);
@@ -2145,13 +2150,14 @@ struct Export_test : public beast::unit_test::suite
alice.id(),
carol.id(),
seq + 1,
seq + ExportLimits::maxRetryLedgers,
seq + ExportLimits::maxAdmissionWindowLedgers,
ticketSeq);
Json::Value jvExport;
jvExport[jss::TransactionType] = jss::Export;
jvExport[jss::Account] = alice.human();
jvExport[jss::LastLedgerSequence] = seq + ExportLimits::maxRetryLedgers;
jvExport[jss::LastLedgerSequence] =
seq + ExportLimits::maxAdmissionWindowLedgers;
jvExport[sfExportedTxn.jsonName] = innerObj.getJson(JsonOptions::none);
auto const committeeRoster = defaultExportCommittee(env);
auto const committeeDigest =
@@ -2288,13 +2294,14 @@ struct Export_test : public beast::unit_test::suite
alice.id(),
carol.id(),
seq + 1,
seq + ExportLimits::maxRetryLedgers,
seq + ExportLimits::maxAdmissionWindowLedgers,
std::nullopt);
Json::Value jv;
jv[jss::TransactionType] = jss::Export;
jv[jss::Account] = alice.human();
jv[jss::LastLedgerSequence] = seq + ExportLimits::maxRetryLedgers;
jv[jss::LastLedgerSequence] =
seq + ExportLimits::maxAdmissionWindowLedgers;
jv[sfExportedTxn.jsonName] = innerObj.getJson(JsonOptions::none);
bindExportAuthority(env, jv);
@@ -2326,7 +2333,7 @@ struct Export_test : public beast::unit_test::suite
alice.id(),
carol.id(),
seq + 1,
seq + ExportLimits::maxRetryLedgers);
seq + ExportLimits::maxAdmissionWindowLedgers);
Json::Value jv;
jv[jss::TransactionType] = jss::Export;
@@ -2356,13 +2363,14 @@ struct Export_test : public beast::unit_test::suite
alice.id(),
carol.id(),
seq + 1,
seq + ExportLimits::maxRetryLedgers);
seq + ExportLimits::maxAdmissionWindowLedgers);
innerObj.setFieldVL(sfSigningPubKey, alice.pk().slice());
Json::Value jv;
jv[jss::TransactionType] = jss::Export;
jv[jss::Account] = alice.human();
jv[jss::LastLedgerSequence] = seq + ExportLimits::maxRetryLedgers;
jv[jss::LastLedgerSequence] =
seq + ExportLimits::maxAdmissionWindowLedgers;
jv[sfExportedTxn.jsonName] = innerObj.getJson(JsonOptions::none);
bindExportAuthority(env, jv);
@@ -2385,7 +2393,7 @@ struct Export_test : public beast::unit_test::suite
env.close();
auto const seq = env.current()->seq();
auto const lls = seq + ExportLimits::maxRetryLedgers + 1;
auto const lls = seq + ExportLimits::maxAdmissionWindowLedgers + 1;
auto innerObj =
buildExportedPayment(alice.id(), carol.id(), seq + 1, lls);

View File

@@ -212,7 +212,7 @@ public:
BEAST_EXPECT(st.getFieldU32(sfFirstLedgerSequence) == seq + 1);
BEAST_EXPECT(
st.getFieldU32(sfLastLedgerSequence) ==
seq + ExportLimits::maxRetryLedgers);
seq + ExportLimits::maxAdmissionWindowLedgers);
BEAST_EXPECT(st.isFieldPresent(sfEmitDetails));
auto const result2 =
@@ -243,7 +243,7 @@ public:
obj[sfSigningPubKey] = Slice{};
obj[sfFirstLedgerSequence] = env.closed()->seq() + 1;
obj[sfLastLedgerSequence] =
env.closed()->seq() + ExportLimits::maxRetryLedgers;
env.closed()->seq() + ExportLimits::maxAdmissionWindowLedgers;
obj[sfFee] = env.closed()->fees().base;
auto& emitDetails = obj.peekFieldObject(sfEmitDetails);
@@ -260,7 +260,7 @@ public:
obj[sfSigningPubKey] = Slice{};
obj[sfFirstLedgerSequence] = env.closed()->seq() + 1;
obj[sfLastLedgerSequence] =
env.closed()->seq() + ExportLimits::maxRetryLedgers;
env.closed()->seq() + ExportLimits::maxAdmissionWindowLedgers;
obj[sfFee] = env.closed()->fees().base;
STObject hookobj(sfHook);
auto& hooks = obj.peekFieldArray(sfHooks);
@@ -602,7 +602,7 @@ public:
auto const currentSeq = applyCtx.view().info().seq;
tx.setFieldU32(
sfLastLedgerSequence,
currentSeq + ExportLimits::maxRetryLedgers + 1);
currentSeq + ExportLimits::maxAdmissionWindowLedgers + 1);
auto const result = api.emit(tx.getSerializer().slice());
BEAST_EXPECT(result.error() == EMISSION_FAILURE);
}

View File

@@ -169,7 +169,7 @@ public:
BEAST_EXPECT(wrapper.getFieldU32(sfFirstLedgerSequence) == 11);
BEAST_EXPECT(
wrapper.getFieldU32(sfLastLedgerSequence) ==
10 + ExportLimits::maxRetryLedgers);
10 + ExportLimits::maxAdmissionWindowLedgers);
BEAST_EXPECT(wrapper.getFieldAmount(sfFee) == STAmount{12345});
BEAST_EXPECT(wrapper.getFieldVL(sfSigningPubKey).empty());
BEAST_EXPECT(

View File

@@ -35,8 +35,9 @@ agreement is needed. The result is explicitly labeled
(`EntropyTier = consensus_fallback`, `EntropyCount = 0`) — it is
user-influenceable via transaction submission and must never be presented
under validator-entropy semantics. Export has no equivalent fallback value:
without quorum-aligned verified export signatures, the export must not be
treated as complete and must retry or expire under transaction rules.
without quorum-aligned verified export signatures, the admitted latch remains
pending until a later witness or publication expiry. The source transaction
does not retry.
The fallback/non-fallback decision is itself ledger-defining. A local node may
diagnose that progress looks unlikely from its current peer view, but it must
@@ -502,6 +503,13 @@ link, and pending-work link. Lack of signatures does not make the source
transaction retry. The pending directory is durable scheduling state across
rounds and restarts.
An owner may have at most one live latch for a destination TicketSequence,
regardless of `W`. Flagless control and publication expiry retain the latch, so
they also retain that one-shot destination-authority slot. Reissuing the same
normalized target after terminal erasure creates a new outer transaction and a
new `W`; it does not recreate the old latch. Clients must not reissue a target
TicketSequence that the destination chain has already consumed.
Honest signature release is keyed to fully validated history, not to the open
ledger or current transaction candidate set. When an exact origin ledger becomes
validated, eligible committee members reconstruct the target payload, append

View File

@@ -132,6 +132,14 @@ the normalized identity-form target, not a signer-subset-dependent final target
transaction ID. Any valid qC subset for the exact stamped intent may therefore
execute and return without orphaning source state.
At most one live latch may name a given `(owner, destination TicketSequence)`,
even when distinct outer transactions would produce distinct `W` values. A
retained canceled or publication-expired latch therefore blocks another
issuance for that TicketSequence until terminal erasure. Reissuing the same
normalized intent after erasure creates a new outer transaction and new `W`;
it never recreates the old latch. A client must not reissue a destination Ticket
that has already been consumed.
Witness and XPOP are independent monotonic facts. Whichever arrives second
symmetrically erases the latch and releases reserve. Flagless lifecycle control
names exact `W`, unlinks pending work, and retains callback readiness because

View File

@@ -431,7 +431,7 @@ HookAPI::prepare(Slice const& txBlob) const
if (!json.isMember(jss::LastLedgerSequence))
json[jss::LastLedgerSequence] =
Json::Value(seq + ExportLimits::maxRetryLedgers);
Json::Value(seq + ExportLimits::maxAdmissionWindowLedgers);
uint8_t details[512];
if (!json.isMember(jss::EmitDetails))
@@ -745,12 +745,12 @@ HookAPI::emit(Slice const& txBlob) const
return Unexpected(EMISSION_FAILURE);
}
if (tx_lls > ledgerSeq + ExportLimits::maxRetryLedgers)
if (tx_lls > ledgerSeq + ExportLimits::maxAdmissionWindowLedgers)
{
JLOG(j.trace())
<< "HookEmit[" << HC_ACC()
<< "]: sfLastLedgerSequence cannot be greater than current seq + "
<< ExportLimits::maxRetryLedgers;
<< ExportLimits::maxAdmissionWindowLedgers;
return Unexpected(EMISSION_FAILURE);
}

View File

@@ -78,7 +78,7 @@ build(Input const& input)
exportObj.setFieldVL(sfSigningPubKey, Blob{});
exportObj[sfFirstLedgerSequence] = input.ledgerSeq + 1;
exportObj[sfLastLedgerSequence] =
input.ledgerSeq + ExportLimits::maxRetryLedgers;
input.ledgerSeq + ExportLimits::maxAdmissionWindowLedgers;
exportObj[sfFee] = STAmount{0};
exportObj.setFieldH256(sfExportCommitteeHash, input.committeeHash);

View File

@@ -138,8 +138,8 @@ Export::preclaim(PreclaimContext const& ctx)
!isTesSuccess(ter))
return ter;
if (auto ter =
ExportLedgerOps::validateRetryWindow(ctx.tx, ctx.view.seq(), ctx.j);
if (auto ter = ExportLedgerOps::validateAdmissionWindow(
ctx.tx, ctx.view.seq(), ctx.j);
!isTesSuccess(ter))
return ter;

View File

@@ -720,7 +720,10 @@ checkExportTxnLimit(ReadView const& view, beast::Journal j)
}
inline TER
validateRetryWindow(STTx const& stx, LedgerIndex ledgerSeq, beast::Journal j)
validateAdmissionWindow(
STTx const& stx,
LedgerIndex ledgerSeq,
beast::Journal j)
{
if (!stx.isFieldPresent(sfLastLedgerSequence))
{
@@ -730,14 +733,15 @@ validateRetryWindow(STTx const& stx, LedgerIndex ledgerSeq, beast::Journal j)
}
auto const lls = stx.getFieldU32(sfLastLedgerSequence);
auto const maxLLS =
static_cast<std::uint64_t>(ledgerSeq) + ExportLimits::maxRetryLedgers;
auto const maxLLS = static_cast<std::uint64_t>(ledgerSeq) +
ExportLimits::maxAdmissionWindowLedgers;
if (lls > maxLLS)
{
JLOG(j.warn()) << "ExportLedgerOps: export LastLedgerSequence too far "
"ahead ledgerSeq="
<< ledgerSeq << " lastLedgerSequence=" << lls
<< " maxRetryLedgers=" << ExportLimits::maxRetryLedgers;
<< " maxAdmissionWindowLedgers="
<< ExportLimits::maxAdmissionWindowLedgers;
return temMALFORMED;
}