diff --git a/include/xrpl/protocol/ExportLimits.h b/include/xrpl/protocol/ExportLimits.h index 09f098bea..fbdb22d2f 100644 --- a/include/xrpl/protocol/ExportLimits.h +++ b/include/xrpl/protocol/ExportLimits.h @@ -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 diff --git a/src/test/app/Export_test.cpp b/src/test/app/Export_test.cpp index a989e4a8b..3a5c2597d 100644 --- a/src/test/app/Export_test.cpp +++ b/src/test/app/Export_test.cpp @@ -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 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); diff --git a/src/test/app/HookAPI_test.cpp b/src/test/app/HookAPI_test.cpp index 84a03cd72..2e03a8658 100644 --- a/src/test/app/HookAPI_test.cpp +++ b/src/test/app/HookAPI_test.cpp @@ -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); } diff --git a/src/test/app/XportWrapperBuilder_test.cpp b/src/test/app/XportWrapperBuilder_test.cpp index 5b12e2543..a245e844a 100644 --- a/src/test/app/XportWrapperBuilder_test.cpp +++ b/src/test/app/XportWrapperBuilder_test.cpp @@ -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( diff --git a/src/xrpld/app/consensus/ConsensusExtensionsDesign.md b/src/xrpld/app/consensus/ConsensusExtensionsDesign.md index 8c0528edd..afa163559 100644 --- a/src/xrpld/app/consensus/ConsensusExtensionsDesign.md +++ b/src/xrpld/app/consensus/ConsensusExtensionsDesign.md @@ -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 diff --git a/src/xrpld/app/consensus/ExportIntent.md b/src/xrpld/app/consensus/ExportIntent.md index 0b699a689..d9b548ab8 100644 --- a/src/xrpld/app/consensus/ExportIntent.md +++ b/src/xrpld/app/consensus/ExportIntent.md @@ -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 diff --git a/src/xrpld/app/hook/detail/HookAPI.cpp b/src/xrpld/app/hook/detail/HookAPI.cpp index 997be77af..5c8c89cc6 100644 --- a/src/xrpld/app/hook/detail/HookAPI.cpp +++ b/src/xrpld/app/hook/detail/HookAPI.cpp @@ -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); } diff --git a/src/xrpld/app/hook/detail/XportWrapperBuilder.cpp b/src/xrpld/app/hook/detail/XportWrapperBuilder.cpp index d26287593..fb0744579 100644 --- a/src/xrpld/app/hook/detail/XportWrapperBuilder.cpp +++ b/src/xrpld/app/hook/detail/XportWrapperBuilder.cpp @@ -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); diff --git a/src/xrpld/app/tx/detail/Export.cpp b/src/xrpld/app/tx/detail/Export.cpp index f699593c0..00a0c169d 100644 --- a/src/xrpld/app/tx/detail/Export.cpp +++ b/src/xrpld/app/tx/detail/Export.cpp @@ -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; diff --git a/src/xrpld/app/tx/detail/ExportLedgerOps.h b/src/xrpld/app/tx/detail/ExportLedgerOps.h index e6a1ea4df..a4e0549fa 100644 --- a/src/xrpld/app/tx/detail/ExportLedgerOps.h +++ b/src/xrpld/app/tx/detail/ExportLedgerOps.h @@ -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(ledgerSeq) + ExportLimits::maxRetryLedgers; + auto const maxLLS = static_cast(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; }