diff --git a/hook/extern.h b/hook/extern.h index f527df2a4..278165998 100644 --- a/hook/extern.h +++ b/hook/extern.h @@ -350,7 +350,7 @@ xport( uint32_t read_len); extern int64_t -xport_cancel(uint32_t ticket_seq); +xport_cancel(uint32_t read_ptr, uint32_t read_len, uint32_t flags); /* Consensus entropy APIs. diff --git a/hook/genesis/headers/sfcodes.h b/hook/genesis/headers/sfcodes.h index 148560caf..516465f8a 100644 --- a/hook/genesis/headers/sfcodes.h +++ b/hook/genesis/headers/sfcodes.h @@ -27,6 +27,7 @@ #define sfHookExportCount ((1U << 16U) + 98U) #define sfEntropyCount ((1U << 16U) + 99U) #define sfEntropyDenominator ((1U << 16U) + 100U) +#define sfExportCount ((1U << 16U) + 101U) #define sfNetworkID ((2U << 16U) + 1U) #define sfFlags ((2U << 16U) + 2U) #define sfSourceTag ((2U << 16U) + 3U) @@ -85,7 +86,6 @@ #define sfRewardTime ((2U << 16U) + 98U) #define sfRewardLgrFirst ((2U << 16U) + 99U) #define sfRewardLgrLast ((2U << 16U) + 100U) -#define sfCancelTicketSequence ((2U << 16U) + 101U) #define sfIndexNext ((3U << 16U) + 1U) #define sfIndexPrevious ((3U << 16U) + 2U) #define sfBookNode ((3U << 16U) + 3U) @@ -111,6 +111,7 @@ #define sfMPTAmount ((3U << 16U) + 26U) #define sfIssuerNode ((3U << 16U) + 27U) #define sfSubjectNode ((3U << 16U) + 28U) +#define sfExportNode ((3U << 16U) + 29U) #define sfTouchCount ((3U << 16U) + 97U) #define sfAccountIndex ((3U << 16U) + 98U) #define sfAccountCount ((3U << 16U) + 99U) @@ -159,6 +160,7 @@ #define sfURITokenID ((5U << 16U) + 36U) #define sfDomainID ((5U << 16U) + 37U) #define sfExportSignatureHash ((5U << 16U) + 38U) +#define sfExportUniverseHash ((5U << 16U) + 39U) #define sfHookOnOutgoing ((5U << 16U) + 93U) #define sfHookOnIncoming ((5U << 16U) + 94U) #define sfCron ((5U << 16U) + 95U) @@ -229,6 +231,7 @@ #define sfMPTokenMetadata ((7U << 16U) + 31U) #define sfCredentialType ((7U << 16U) + 32U) #define sfEntropyContributors ((7U << 16U) + 33U) +#define sfExportCommittee ((7U << 16U) + 34U) #define sfHookName ((7U << 16U) + 97U) #define sfRemarkValue ((7U << 16U) + 98U) #define sfRemarkName ((7U << 16U) + 99U) @@ -281,6 +284,7 @@ #define sfEmitDetails ((14U << 16U) + 13U) #define sfHook ((14U << 16U) + 14U) #define sfSigner ((14U << 16U) + 16U) +#define sfExportSigner ((14U << 16U) + 17U) #define sfMajority ((14U << 16U) + 18U) #define sfDisabledValidator ((14U << 16U) + 19U) #define sfEmittedTxn ((14U << 16U) + 20U) @@ -324,6 +328,7 @@ #define sfHookGrants ((15U << 16U) + 20U) #define sfXChainClaimAttestations ((15U << 16U) + 21U) #define sfXChainCreateAccountAttestations ((15U << 16U) + 22U) +#define sfExportSigners ((15U << 16U) + 23U) #define sfPriceDataSeries ((15U << 16U) + 24U) #define sfAuthAccounts ((15U << 16U) + 25U) #define sfAuthorizeCredentials ((15U << 16U) + 26U) diff --git a/hook/sfcodes.h b/hook/sfcodes.h index 148560caf..516465f8a 100644 --- a/hook/sfcodes.h +++ b/hook/sfcodes.h @@ -27,6 +27,7 @@ #define sfHookExportCount ((1U << 16U) + 98U) #define sfEntropyCount ((1U << 16U) + 99U) #define sfEntropyDenominator ((1U << 16U) + 100U) +#define sfExportCount ((1U << 16U) + 101U) #define sfNetworkID ((2U << 16U) + 1U) #define sfFlags ((2U << 16U) + 2U) #define sfSourceTag ((2U << 16U) + 3U) @@ -85,7 +86,6 @@ #define sfRewardTime ((2U << 16U) + 98U) #define sfRewardLgrFirst ((2U << 16U) + 99U) #define sfRewardLgrLast ((2U << 16U) + 100U) -#define sfCancelTicketSequence ((2U << 16U) + 101U) #define sfIndexNext ((3U << 16U) + 1U) #define sfIndexPrevious ((3U << 16U) + 2U) #define sfBookNode ((3U << 16U) + 3U) @@ -111,6 +111,7 @@ #define sfMPTAmount ((3U << 16U) + 26U) #define sfIssuerNode ((3U << 16U) + 27U) #define sfSubjectNode ((3U << 16U) + 28U) +#define sfExportNode ((3U << 16U) + 29U) #define sfTouchCount ((3U << 16U) + 97U) #define sfAccountIndex ((3U << 16U) + 98U) #define sfAccountCount ((3U << 16U) + 99U) @@ -159,6 +160,7 @@ #define sfURITokenID ((5U << 16U) + 36U) #define sfDomainID ((5U << 16U) + 37U) #define sfExportSignatureHash ((5U << 16U) + 38U) +#define sfExportUniverseHash ((5U << 16U) + 39U) #define sfHookOnOutgoing ((5U << 16U) + 93U) #define sfHookOnIncoming ((5U << 16U) + 94U) #define sfCron ((5U << 16U) + 95U) @@ -229,6 +231,7 @@ #define sfMPTokenMetadata ((7U << 16U) + 31U) #define sfCredentialType ((7U << 16U) + 32U) #define sfEntropyContributors ((7U << 16U) + 33U) +#define sfExportCommittee ((7U << 16U) + 34U) #define sfHookName ((7U << 16U) + 97U) #define sfRemarkValue ((7U << 16U) + 98U) #define sfRemarkName ((7U << 16U) + 99U) @@ -281,6 +284,7 @@ #define sfEmitDetails ((14U << 16U) + 13U) #define sfHook ((14U << 16U) + 14U) #define sfSigner ((14U << 16U) + 16U) +#define sfExportSigner ((14U << 16U) + 17U) #define sfMajority ((14U << 16U) + 18U) #define sfDisabledValidator ((14U << 16U) + 19U) #define sfEmittedTxn ((14U << 16U) + 20U) @@ -324,6 +328,7 @@ #define sfHookGrants ((15U << 16U) + 20U) #define sfXChainClaimAttestations ((15U << 16U) + 21U) #define sfXChainCreateAccountAttestations ((15U << 16U) + 22U) +#define sfExportSigners ((15U << 16U) + 23U) #define sfPriceDataSeries ((15U << 16U) + 24U) #define sfAuthAccounts ((15U << 16U) + 25U) #define sfAuthorizeCredentials ((15U << 16U) + 26U) diff --git a/hook/tx_flags.h b/hook/tx_flags.h index 3be3e65a0..9076a66c8 100644 --- a/hook/tx_flags.h +++ b/hook/tx_flags.h @@ -116,6 +116,11 @@ enum BridgeModifyFlags : uint32_t { tfClearAccountCreateAmount = 0x00010000, }; +enum ExportFlags : uint32_t { + // Lifecycle control: erase the named latch and forfeit any later callback. + tfExportEraseLatch = 0x00010000, +}; + enum ConsensusEntropyFlags : uint32_t { tfEntropyCommit = 0x00000001, // entry is a commitment in commitSet tfEntropyReveal = 0x00000002, // entry is a reveal in entropySet diff --git a/include/xrpl/hook/hook_api.macro b/include/xrpl/hook/hook_api.macro index 273cf7f6a..ed4bac366 100644 --- a/include/xrpl/hook/hook_api.macro +++ b/include/xrpl/hook/hook_api.macro @@ -383,9 +383,9 @@ HOOK_API_DEFINITION( int64_t, xport, (uint32_t, uint32_t, uint32_t, uint32_t), featureExport) -// int64_t xport_cancel(uint32_t ticket_seq); +// int64_t xport_cancel(uint32_t read_ptr, uint32_t read_len, uint32_t flags); HOOK_API_DEFINITION( - int64_t, xport_cancel, (uint32_t), + int64_t, xport_cancel, (uint32_t, uint32_t, uint32_t), featureExport) // int64_t dice(uint32_t sides, uint32_t min_tier, uint32_t min_count); diff --git a/include/xrpl/protocol/TxFlags.h b/include/xrpl/protocol/TxFlags.h index 83f8e79c2..64a7fbd67 100644 --- a/include/xrpl/protocol/TxFlags.h +++ b/include/xrpl/protocol/TxFlags.h @@ -275,6 +275,14 @@ enum BridgeModifyFlags : uint32_t { }; constexpr std::uint32_t tfBridgeModifyMask = ~(tfUniversal | tfClearAccountCreateAmount); +// Export flags: +enum ExportFlags : uint32_t { + // Lifecycle control: erase the named latch and forfeit any later callback. + tfExportEraseLatch = 0x00010000, +}; +constexpr std::uint32_t tfExportMask = + ~(tfUniversal | tfExportEraseLatch); + // ConsensusEntropy flags (used on ttCONSENSUS_ENTROPY SHAMap entries): enum ConsensusEntropyFlags : uint32_t { tfEntropyCommit = 0x00000001, // entry is a commitment in commitSet diff --git a/include/xrpl/protocol/detail/sfields.macro b/include/xrpl/protocol/detail/sfields.macro index 353828cf9..eed7835a7 100644 --- a/include/xrpl/protocol/detail/sfields.macro +++ b/include/xrpl/protocol/detail/sfields.macro @@ -129,7 +129,6 @@ TYPED_SFIELD(sfImportSequence, UINT32, 97) TYPED_SFIELD(sfRewardTime, UINT32, 98) TYPED_SFIELD(sfRewardLgrFirst, UINT32, 99) TYPED_SFIELD(sfRewardLgrLast, UINT32, 100) -TYPED_SFIELD(sfCancelTicketSequence, UINT32, 101) // 64-bit integers (common) TYPED_SFIELD(sfIndexNext, UINT64, 1) diff --git a/include/xrpl/protocol/detail/transactions.macro b/include/xrpl/protocol/detail/transactions.macro index 1545fef07..fe0dc2f87 100644 --- a/include/xrpl/protocol/detail/transactions.macro +++ b/include/xrpl/protocol/detail/transactions.macro @@ -503,11 +503,11 @@ TRANSACTION(ttPERMISSIONED_DOMAIN_DELETE, 72, PermissionedDomainDelete, ({ //@@start export-transaction-types /* User-submittable export: creates a cross-chain transaction for post-validation committee signing and a durable callback latch. - Also supports Export latch cancellation via sfCancelTicketSequence. - At least one of sfExportedTxn or sfCancelTicketSequence must be present. */ + A lifecycle-control form names an existing Export by its source transaction + hash. Exactly one of sfExportedTxn or sfTransactionHash must be present. */ TRANSACTION(ttEXPORT, 91, Export, ({ {sfExportedTxn, soeOPTIONAL}, - {sfCancelTicketSequence, soeOPTIONAL}, + {sfTransactionHash, soeOPTIONAL}, {sfExportUniverseHash, soeOPTIONAL}, {sfExportCommittee, soeOPTIONAL}, })) diff --git a/src/test/app/ExportLatch_test.cpp b/src/test/app/ExportLatch_test.cpp index 92a1ef833..b73099ed6 100644 --- a/src/test/app/ExportLatch_test.cpp +++ b/src/test/app/ExportLatch_test.cpp @@ -234,9 +234,9 @@ struct ExportLatch_test : beast::unit_test::suite } void - testCanceledLatchAcceptsBothFactOrders() + testRetainedLatchAcceptsBothFactOrders() { - testcase("canceled latch accepts XPOP and witness in either order"); + testcase("retained latch accepts XPOP and witness in either order"); using namespace jtx; Account const alice{"alice"}; @@ -261,8 +261,13 @@ struct ExportLatch_test : beast::unit_test::suite auto const xpopFirstKey = keylet::unchecked(xpopFirst->key()); BEAST_EXPECT(isTesSuccess( ExportLedgerOps::insertPendingExportLatch(sb, sb, xpopFirst, j))); - BEAST_EXPECT(isTesSuccess(ExportLedgerOps::cancelExportLatch( - sb, sb, alice.id(), xpopFirst->getFieldU32(sfTicketSequence), j))); + BEAST_EXPECT(isTesSuccess(ExportLedgerOps::controlExportLatch( + sb, + sb, + alice.id(), + xpopFirst->getFieldH256(sfTransactionHash), + false, + j))); auto const canceled = sb.read(xpopFirstKey); BEAST_EXPECT(canceled); @@ -308,11 +313,12 @@ struct ExportLatch_test : beast::unit_test::suite auto const witnessFirstKey = keylet::unchecked(witnessFirst->key()); BEAST_EXPECT(isTesSuccess(ExportLedgerOps::insertPendingExportLatch( sb, sb, witnessFirst, j))); - BEAST_EXPECT(isTesSuccess(ExportLedgerOps::cancelExportLatch( + BEAST_EXPECT(isTesSuccess(ExportLedgerOps::controlExportLatch( sb, sb, alice.id(), - witnessFirst->getFieldU32(sfTicketSequence), + witnessFirst->getFieldH256(sfTransactionHash), + false, j))); // The accepted witness may already have been materialized before @@ -350,12 +356,97 @@ struct ExportLatch_test : beast::unit_test::suite BEAST_EXPECT(ExportLedgerOps::exportLatchCount(*rootAfterXpop) == 0); } + void + testExplicitEraseAndExpiryRetention() + { + testcase("explicit erase and expiry retention"); + + using namespace jtx; + Account const alice{"alice"}; + Env env{*this}; + env.fund(XRP(10'000), alice); + env.close(); + + beast::Journal j{beast::Journal::getNullSink()}; + Sandbox sb{env.closed().get(), tapNONE}; + auto const baselineAccount = sb.read(keylet::account(alice.id())); + auto const baselinePending = sb.read(keylet::pendingExports()); + BEAST_EXPECT(baselineAccount); + if (!baselineAccount) + return; + auto const baselineExportCount = + ExportLedgerOps::exportLatchCount(*baselineAccount); + auto const baselineOwnerCount = + baselineAccount->getFieldU32(sfOwnerCount); + auto const baselinePendingCount = baselinePending + ? ExportLedgerOps::exportLatchCount(*baselinePending) + : std::uint16_t{0}; + + auto erased = makeLatch(alice.id(), 0); + auto const erasedOrigin = erased->getFieldH256(sfTransactionHash); + auto const erasedKey = keylet::exportLatch(alice.id(), erasedOrigin); + BEAST_EXPECT(isTesSuccess( + ExportLedgerOps::insertPendingExportLatch(sb, sb, erased, j))); + BEAST_EXPECT(isTesSuccess(ExportLedgerOps::controlExportLatch( + sb, sb, alice.id(), erasedOrigin, true, j))); + BEAST_EXPECT(!sb.exists(erasedKey)); + + auto const afterEraseAccount = sb.read(keylet::account(alice.id())); + auto const afterErasePending = sb.read(keylet::pendingExports()); + BEAST_EXPECT(afterEraseAccount); + BEAST_EXPECT(afterErasePending); + if (!afterEraseAccount || !afterErasePending) + return; + BEAST_EXPECT( + ExportLedgerOps::exportLatchCount(*afterEraseAccount) == + baselineExportCount); + BEAST_EXPECT( + afterEraseAccount->getFieldU32(sfOwnerCount) == baselineOwnerCount); + BEAST_EXPECT( + ExportLedgerOps::exportLatchCount(*afterErasePending) == + baselinePendingCount); + + auto expired = makeLatch(alice.id(), 1); + expired->setFieldU32(sfLastLedgerSequence, 100); + auto const expiredOrigin = expired->getFieldH256(sfTransactionHash); + auto const expiredKey = keylet::exportLatch(alice.id(), expiredOrigin); + BEAST_EXPECT(isTesSuccess( + ExportLedgerOps::insertPendingExportLatch(sb, sb, expired, j))); + BEAST_EXPECT(isTesSuccess( + ExportLedgerOps::pruneExpiredExportLatches(sb, sb, 101, j))); + + auto const retained = sb.read(expiredKey); + auto const afterExpiryAccount = sb.read(keylet::account(alice.id())); + auto const afterExpiryPending = sb.read(keylet::pendingExports()); + BEAST_EXPECT(retained); + BEAST_EXPECT(afterExpiryAccount); + BEAST_EXPECT(afterExpiryPending); + if (!retained || !afterExpiryAccount || !afterExpiryPending) + return; + BEAST_EXPECT(!retained->isFieldPresent(sfExportNode)); + BEAST_EXPECT((retained->getFieldU32(sfFlags) & lsfExportCanceled) != 0); + BEAST_EXPECT( + ExportLedgerOps::exportLatchCount(*afterExpiryAccount) == + baselineExportCount + 1); + BEAST_EXPECT( + afterExpiryAccount->getFieldU32(sfOwnerCount) == + baselineOwnerCount + 1); + BEAST_EXPECT( + ExportLedgerOps::exportLatchCount(*afterExpiryPending) == + baselinePendingCount); + + BEAST_EXPECT(isTesSuccess(ExportLedgerOps::controlExportLatch( + sb, sb, alice.id(), expiredOrigin, true, j))); + BEAST_EXPECT(!sb.exists(expiredKey)); + } + void run() override { testDirectoryLifecycle(); testWitnessedLatchReleasesGlobalPendingCap(); - testCanceledLatchAcceptsBothFactOrders(); + testRetainedLatchAcceptsBothFactOrders(); + testExplicitEraseAndExpiryRetention(); } }; diff --git a/src/test/app/Export_test.cpp b/src/test/app/Export_test.cpp index 7d212c7a4..95c027c1b 100644 --- a/src/test/app/Export_test.cpp +++ b/src/test/app/Export_test.cpp @@ -1942,9 +1942,9 @@ struct Export_test : public beast::unit_test::suite } void - testCancelExportLatchViaTxn(FeatureBitset features) + testControlExportLatchViaTxn(FeatureBitset features) { - testcase("ttEXPORT cancels Export latch via sfCancelTicketSequence"); + testcase("ttEXPORT controls Export latch via origin W"); using namespace jtx; @@ -1961,13 +1961,22 @@ struct Export_test : public beast::unit_test::suite return; seedUNLReportLedger(env, {valKeys.keys->masterPublicKey}); - // Cancel non-existent ticket → tecNO_ENTRY - Json::Value jvCancel; - jvCancel[jss::TransactionType] = jss::Export; - jvCancel[jss::Account] = alice.human(); - jvCancel[sfCancelTicketSequence.jsonName] = 42; + // Control of a non-existent W returns tecNO_ENTRY. + Json::Value jvControl; + jvControl[jss::TransactionType] = jss::Export; + jvControl[jss::Account] = alice.human(); + jvControl[sfTransactionHash.jsonName] = to_string(uint256{42}); - env(jvCancel, fee(XRP(1)), ter(tecNO_ENTRY)); + env(jvControl, fee(XRP(1)), ter(tecNO_ENTRY)); + + auto zeroControl = jvControl; + zeroControl[sfTransactionHash.jsonName] = to_string(uint256{}); + env(zeroControl, fee(XRP(1)), ter(temMALFORMED)); + + Json::Value emptyControl; + emptyControl[jss::TransactionType] = jss::Export; + emptyControl[jss::Account] = alice.human(); + env(emptyControl, fee(XRP(1)), ter(temMALFORMED)); env.close(); std::uint32_t constexpr ticketSeq = 42; @@ -1986,6 +1995,18 @@ struct Export_test : public beast::unit_test::suite jvExport[sfExportedTxn.jsonName] = innerObj.getJson(JsonOptions::none); bindExportAuthority(env, jvExport); + auto mixed = jvExport; + mixed[sfTransactionHash.jsonName] = to_string(uint256{42}); + env(mixed, fee(XRP(1)), ter(temMALFORMED)); + + auto eraseCreation = jvExport; + eraseCreation[jss::Flags] = tfExportEraseLatch; + env(eraseCreation, fee(XRP(1)), ter(temMALFORMED)); + + auto unknownFlag = jvControl; + unknownFlag[jss::Flags] = 0x00020000; + env(unknownFlag, fee(XRP(1)), ter(temINVALID_FLAG)); + env(jvExport, fee(XRP(1)), ter(tesSUCCESS)); auto const origin = env.tx()->getTransactionID(); auto const exportMeta = env.meta(); @@ -2011,12 +2032,12 @@ struct Export_test : public beast::unit_test::suite pendingBeforeCancel->getFieldU16(sfExportCount); BEAST_EXPECT(pendingExportCount > 0); - Json::Value jvCancelExisting; - jvCancelExisting[jss::TransactionType] = jss::Export; - jvCancelExisting[jss::Account] = alice.human(); - jvCancelExisting[sfCancelTicketSequence.jsonName] = ticketSeq; + Json::Value jvRetain; + jvRetain[jss::TransactionType] = jss::Export; + jvRetain[jss::Account] = alice.human(); + jvRetain[sfTransactionHash.jsonName] = to_string(origin); - env(jvCancelExisting, fee(XRP(1)), ter(tesSUCCESS)); + env(jvRetain, fee(XRP(1)), ter(tesSUCCESS)); auto const cancelMeta = env.meta(); BEAST_EXPECT(cancelMeta); BEAST_EXPECT( @@ -2049,6 +2070,26 @@ struct Export_test : public beast::unit_test::suite pendingAfterCancel->getFieldU16(sfExportCount) == pendingExportCount - 1); + auto jvErase = jvRetain; + jvErase[jss::Flags] = tfExportEraseLatch; + env(jvErase, fee(XRP(1)), ter(tesSUCCESS)); + + auto const accountAfterErase = env.le(keylet::account(alice.id())); + auto const pendingAfterErase = env.le(keylet::pendingExports()); + BEAST_EXPECT(!env.le(latchKey)); + BEAST_EXPECT(accountAfterErase); + BEAST_EXPECT(pendingAfterErase); + if (!accountAfterErase || !pendingAfterErase) + return; + BEAST_EXPECT( + accountAfterErase->getFieldU16(sfExportCount) == + accountExportCount - 1); + BEAST_EXPECT( + accountAfterErase->getFieldU32(sfOwnerCount) == ownerCount - 1); + BEAST_EXPECT( + pendingAfterErase->getFieldU16(sfExportCount) == + pendingExportCount - 1); + env.close(); } @@ -2290,7 +2331,7 @@ struct Export_test : public beast::unit_test::suite Json::Value cancel; cancel[jss::TransactionType] = jss::Export; cancel[jss::Account] = alice.human(); - cancel[sfCancelTicketSequence.jsonName] = callback.ticketSeq; + cancel[sfTransactionHash.jsonName] = to_string(callback.originTxn); xahau(cancel, fee(XRP(1)), ter(tesSUCCESS)); xahau.close(); @@ -2406,7 +2447,7 @@ struct Export_test : public beast::unit_test::suite testOpenLedgerExportLimit(allWithExport); testExportLatchLimit(allWithExport); testExportLatchLifecycle(allWithExport); - testCancelExportLatchViaTxn(allWithExport); + testControlExportLatchViaTxn(allWithExport); testExportRejectsNoTicketSequence(allWithExport); testExportRejectsMissingLastLedgerSequence(allWithExport); testExportRejectsSignedInnerTransaction(allWithExport); diff --git a/src/test/app/HookAPI_test.cpp b/src/test/app/HookAPI_test.cpp index 1ac6cddfe..c3d74a921 100644 --- a/src/test/app/HookAPI_test.cpp +++ b/src/test/app/HookAPI_test.cpp @@ -24,6 +24,7 @@ #include #include #include +#include #include #include #include @@ -1201,12 +1202,22 @@ public: std::uint32_t const importingTicket = 7; std::uint32_t const otherTicket = 8; + uint256 const importingOrigin{1}; + uint256 const otherOrigin{2}; auto importingTx = makeExportedPayment(alice.id(), bob.id(), importingTicket); auto otherTx = makeExportedPayment(alice.id(), bob.id(), otherTicket); + auto const importingRelease = ExportOriginMemo::releaseForm( + importingTx, + ExportOriginMemo::Origin{21337, 0, importingOrigin}, + ExportOriginMemo::Anchor{1, uint256{99}}); + BEAST_EXPECT(importingRelease); + if (!importingRelease) + return; auto xpopJson = import::loadXpop(ImportTCAccountSet::w_seed); - xpopJson[jss::transaction][jss::blob] = strHex(serialize(importingTx)); + xpopJson[jss::transaction][jss::blob] = + strHex(serialize(importingRelease.value())); std::string const xpopStr = Json::FastWriter().write(xpopJson); STTx importTx = STTx(ttIMPORT, [&](STObject& obj) { obj.setAccountID(sfAccount, alice.id()); @@ -1232,8 +1243,6 @@ public: return ExportLedgerOps::insertPendingExportLatch( ctx.view(), ctx.rawView(), latch, env.journal); }; - uint256 const importingOrigin{1}; - uint256 const otherOrigin{2}; BEAST_EXPECT( isTesSuccess(insertLatch(applyCtx, importingTx, importingOrigin))); BEAST_EXPECT(isTesSuccess(insertLatch(applyCtx, otherTx, otherOrigin))); @@ -1242,13 +1251,17 @@ public: makeStubHookContext(applyCtx, alice.id(), alice.id(), {}); auto& api = hookCtx.api(); - auto const blocked = api.xport_cancel(importingTicket); + auto const blocked = api.xport_cancel(importingOrigin, 0); BEAST_EXPECT(!blocked.has_value()); BEAST_EXPECT(blocked.error() == PREREQUISITE_NOT_MET); BEAST_EXPECT(hookCtx.applyCtx.view().exists( keylet::exportLatch(alice.id(), importingOrigin))); - auto const cancelled = api.xport_cancel(otherTicket); + auto const invalidFlags = api.xport_cancel(otherOrigin, 0x00020000); + BEAST_EXPECT(!invalidFlags.has_value()); + BEAST_EXPECT(invalidFlags.error() == INVALID_ARGUMENT); + + auto const cancelled = api.xport_cancel(otherOrigin, 0); BEAST_EXPECT(cancelled.has_value()); auto const canceledLatch = hookCtx.applyCtx.view().read( keylet::exportLatch(alice.id(), otherOrigin)); @@ -1259,6 +1272,11 @@ public: BEAST_EXPECT( canceledLatch && !canceledLatch->isFieldPresent(sfExportNode)); + auto const erased = api.xport_cancel(otherOrigin, tfExportEraseLatch); + BEAST_EXPECT(erased.has_value()); + BEAST_EXPECT(!hookCtx.applyCtx.view().exists( + keylet::exportLatch(alice.id(), otherOrigin))); + auto exportTx = makeExportWrapper(alice.id(), importingTx); OpenView exportOv{*env.current()}; ApplyContext exportApplyCtx = @@ -1266,7 +1284,7 @@ public: std::uint32_t const exportOtherTicket = 9; auto exportOtherTx = makeExportedPayment(alice.id(), bob.id(), exportOtherTicket); - uint256 const exportImportingOrigin{3}; + auto const exportImportingOrigin = exportTx.getTransactionID(); uint256 const exportOtherOrigin{4}; BEAST_EXPECT(isTesSuccess( insertLatch(exportApplyCtx, importingTx, exportImportingOrigin))); @@ -1276,13 +1294,15 @@ public: makeStubHookContext(exportApplyCtx, alice.id(), alice.id(), {}); auto& exportApi = exportHookCtx.api(); - auto const exportBlocked = exportApi.xport_cancel(importingTicket); + auto const exportBlocked = + exportApi.xport_cancel(exportImportingOrigin, 0); BEAST_EXPECT(!exportBlocked.has_value()); BEAST_EXPECT(exportBlocked.error() == PREREQUISITE_NOT_MET); BEAST_EXPECT(exportHookCtx.applyCtx.view().exists( keylet::exportLatch(alice.id(), exportImportingOrigin))); - auto const exportCancelled = exportApi.xport_cancel(exportOtherTicket); + auto const exportCancelled = + exportApi.xport_cancel(exportOtherOrigin, 0); BEAST_EXPECT(exportCancelled.has_value()); auto const canceledExportLatch = exportHookCtx.applyCtx.view().read( keylet::exportLatch(alice.id(), exportOtherOrigin)); diff --git a/src/xrpld/app/consensus/ConsensusExtensionsDesign.md b/src/xrpld/app/consensus/ConsensusExtensionsDesign.md index 09b7af815..74d4e1ac0 100644 --- a/src/xrpld/app/consensus/ConsensusExtensionsDesign.md +++ b/src/xrpld/app/consensus/ConsensusExtensionsDesign.md @@ -571,15 +571,16 @@ later. Export-latch cancellation is non-revoking. While publication is pending it unlinks signing work and retains callback readiness because shares already -published to peers cannot be withdrawn. Once the latch is non-pending, an -explicit owner cleanup may erase it, release reserve, and knowingly accept -later target execution without callback readiness. Symmetric witness+XPOP +published to peers cannot be withdrawn. Lifecycle control names the exact +source issuance `W`: no flag always unlinks and retains in every state, while +`tfExportEraseLatch` explicitly erases that exact latch in any state, releases +reserve, and knowingly accepts later target execution without callback +readiness. Publication expiry follows the retain path. Symmetric witness+XPOP completion also erases. v1 has no automatic terminal-retirement clock, permanent tombstone, or paid-bump transition. -Implementation status: origin-keyed latch creation and symmetric witness/XPOP -recording are present. Explicit terminal cleanup and expiry-unlink behavior are -ratified policy still tracked for implementation. +Implementation status: origin-keyed creation, W-only lifecycle control, +expiry-unlink, and symmetric witness/XPOP recording are present. This is intentionally leaner than XPOP. XPOP carries its own UNL and manifest bundle so it can be independently verified as an external proof. Export witnesses diff --git a/src/xrpld/app/consensus/ExportIntent.md b/src/xrpld/app/consensus/ExportIntent.md index 34a3ad0b1..55f803e73 100644 --- a/src/xrpld/app/consensus/ExportIntent.md +++ b/src/xrpld/app/consensus/ExportIntent.md @@ -105,15 +105,18 @@ and ticket-keyed latch helper are not supported v1 compatibility paths. Witness and XPOP are independent monotonic facts. Whichever arrives second symmetrically erases the latch and releases reserve. Cancellation while -publication is pending is non-revoking: it unlinks work and retains callback -readiness because already-public signatures cannot be withdrawn. Once a latch -is non-pending, an explicit owner cleanup may erase it and knowingly forfeit a -later callback. v1 has neither an automatic terminal-retirement clock nor a -permanent tombstone graveyard. +publication is pending is non-revoking: a lifecycle-control `ttEXPORT` names +the exact source issuance `W` in `sfTransactionHash`, unlinks work, and retains +callback readiness because already-public signatures cannot be withdrawn. The +same bytes retain the latch in every state. `tfExportEraseLatch` is a separate, +explicit election that erases that exact latch in any state, releases reserve, +and knowingly forfeits a later callback. Publication expiry follows the +non-revoking retain path. The undeployed Hook API uses the same W-plus-flags +contract. v1 has neither an automatic terminal-retirement clock nor a permanent +tombstone graveyard. -Implementation status: origin-keyed latch creation and symmetric witness/XPOP -recording are present. Explicit terminal cleanup and expiry-unlink behavior are -ratified policy still tracked for implementation. +Implementation status: origin-keyed creation, W-only lifecycle control, +expiry-unlink, and symmetric witness/XPOP recording are present. **INV-8 — Export signatures are public capabilities.** Proposal-carried signature shares may be observed, assembled, and submitted as diff --git a/src/xrpld/app/hook/HookAPI.h b/src/xrpld/app/hook/HookAPI.h index 3f3000871..88cdea5c5 100644 --- a/src/xrpld/app/hook/HookAPI.h +++ b/src/xrpld/app/hook/HookAPI.h @@ -377,7 +377,7 @@ public: xport(Slice const& txBlob) const; Expected - xport_cancel(uint32_t ticketSeq) const; + xport_cancel(uint256 const& origin, uint32_t flags) const; /// float APIs Expected diff --git a/src/xrpld/app/hook/detail/HookAPI.cpp b/src/xrpld/app/hook/detail/HookAPI.cpp index 92711012f..0836961dc 100644 --- a/src/xrpld/app/hook/detail/HookAPI.cpp +++ b/src/xrpld/app/hook/detail/HookAPI.cpp @@ -7,7 +7,9 @@ #include #include #include +#include #include +#include namespace hook { @@ -1065,52 +1067,53 @@ HookAPI::xport(Slice const& txBlob) const } Expected -HookAPI::xport_cancel(uint32_t ticketSeq) const +HookAPI::xport_cancel(uint256 const& origin, uint32_t flags) const { + if (origin.isZero() || (flags != 0 && flags != tfExportEraseLatch)) + return Unexpected(INVALID_ARGUMENT); + auto& app = hookCtx.applyCtx.app; auto j = app.journal("View"); auto const& currentTx = hookCtx.applyCtx.tx; auto const& account = hookCtx.result.account; - auto matchesTicket = [&](STTx const& innerTx) { - return innerTx.isFieldPresent(sfTicketSequence) && - innerTx.getAccountID(sfAccount) == account && - innerTx.getFieldU32(sfTicketSequence) == ticketSeq; - }; - if (currentTx.getTxnType() == ttIMPORT) { auto const [innerTx, meta] = Import::getInnerTxn(currentTx, j); - if (innerTx && matchesTicket(*innerTx)) + if (innerTx && innerTx->isFieldPresent(sfAccount) && + innerTx->getAccountID(sfAccount) == account) { - // Import consumes this callback latch after strong hooks finish. - // Letting the hook pre-cancel it would make the Import fail after - // hook state already finalized. - return Unexpected(PREREQUISITE_NOT_MET); + auto const stamp = ExportOriginMemo::parse(*innerTx); + if (stamp && stamp.value().origin.transactionHash == origin) + { + // Import consumes this latch after strong hooks finish. + return Unexpected(PREREQUISITE_NOT_MET); + } } } else if (currentTx.getTxnType() == ttEXPORT) { - if (auto const innerTx = ExportLedgerOps::innerExportedTx(currentTx); - innerTx && matchesTicket(*innerTx)) + if (currentTx.isFieldPresent(sfAccount) && + currentTx.getAccountID(sfAccount) == account && + currentTx.getTransactionID() == origin) { - // Export creates this callback latch before post-apply hooks run. - // Letting the callback cancel it would make the later Import fail. + // Export creates this latch before post-apply hooks run. return Unexpected(PREREQUISITE_NOT_MET); } } - TER const ter = ExportLedgerOps::cancelExportLatch( + TER const ter = ExportLedgerOps::controlExportLatch( hookCtx.applyCtx.view(), hookCtx.applyCtx.rawView(), account, - ticketSeq, + origin, + flags == tfExportEraseLatch, j); if (!isTesSuccess(ter)) return Unexpected(DOESNT_EXIST); - return ticketSeq; + return 1; } uint32_t diff --git a/src/xrpld/app/hook/detail/applyHook.cpp b/src/xrpld/app/hook/detail/applyHook.cpp index 60e7bab4c..ad275563d 100644 --- a/src/xrpld/app/hook/detail/applyHook.cpp +++ b/src/xrpld/app/hook/detail/applyHook.cpp @@ -2912,11 +2912,22 @@ DEFINE_HOOK_FUNCTION(int64_t, xport_reserve, uint32_t count) HOOK_TEARDOWN(); } -DEFINE_HOOK_FUNCTION(int64_t, xport_cancel, uint32_t ticket_seq) +DEFINE_HOOK_FUNCTION( + int64_t, + xport_cancel, + uint32_t read_ptr, + uint32_t read_len, + uint32_t flags) { HOOK_SETUP(); - auto const result = api.xport_cancel(ticket_seq); + if (NOT_IN_BOUNDS(read_ptr, read_len, memory_length)) + return OUT_OF_BOUNDS; + if (read_len != uint256::bytes) + return INVALID_ARGUMENT; + + auto const result = + api.xport_cancel(uint256::fromVoid(memory + read_ptr), flags); if (!result) return result.error(); return result.value(); diff --git a/src/xrpld/app/tx/detail/Change.cpp b/src/xrpld/app/tx/detail/Change.cpp index 197d01003..32bb59e1e 100644 --- a/src/xrpld/app/tx/detail/Change.cpp +++ b/src/xrpld/app/tx/detail/Change.cpp @@ -376,7 +376,7 @@ Change::applyExportSignatures() auto const account = target->getAccountID(sfAccount); auto const latchKey = keylet::exportLatch(account, origin); auto const latch = view().read(latchKey); - // A concurrently ordered cancel/expiry may remove the latch after the + // A concurrently ordered explicit erase may remove the latch after the // accepted sidecar selected this witness. The historical evidence remains // valid, but there is no state transition left to perform. if (!latch) diff --git a/src/xrpld/app/tx/detail/Export.cpp b/src/xrpld/app/tx/detail/Export.cpp index 6083d69b4..593bf9310 100644 --- a/src/xrpld/app/tx/detail/Export.cpp +++ b/src/xrpld/app/tx/detail/Export.cpp @@ -31,11 +31,18 @@ Export::preflight(PreflightContext const& ctx) if (!isTesSuccess(ret)) return ret; - // Exactly one operation: export OR cancel, not both. - bool const hasExport = ctx.tx.isFieldPresent(sfExportedTxn); - bool const hasCancel = ctx.tx.isFieldPresent(sfCancelTicketSequence); + if (ctx.tx.getFlags() & tfExportMask) + return temINVALID_FLAG; - if (hasExport == hasCancel) // neither or both + // Exactly one operation: create an Export OR control an existing W. + bool const hasExport = ctx.tx.isFieldPresent(sfExportedTxn); + bool const hasControl = ctx.tx.isFieldPresent(sfTransactionHash); + + if (hasExport == hasControl) // neither or both + return temMALFORMED; + if (hasExport && (ctx.tx.getFlags() & tfExportEraseLatch) != 0) + return temMALFORMED; + if (hasControl && ctx.tx.getFieldH256(sfTransactionHash).isZero()) return temMALFORMED; if (hasExport) @@ -64,8 +71,7 @@ Export::preflight(PreflightContext const& ctx) } // Exported transactions can retry across consensus rounds; every retrying - // export needs an explicit outer expiry. Cancel-only exports are - // immediate. + // export needs an explicit outer expiry. Lifecycle controls are immediate. if (hasExport && !ctx.tx.isFieldPresent(sfLastLedgerSequence)) return temMALFORMED; @@ -127,12 +133,13 @@ Export::doApply() { auto const account = ctx_.tx.getAccountID(sfAccount); - // --- Export latch cancel path (mutually exclusive with export) --- - if (ctx_.tx.isFieldPresent(sfCancelTicketSequence)) + // --- Export latch control path (mutually exclusive with export) --- + if (ctx_.tx.isFieldPresent(sfTransactionHash)) { - auto const ticketSeq = ctx_.tx.getFieldU32(sfCancelTicketSequence); - return ExportLedgerOps::cancelExportLatch( - view(), ctx_.rawView(), account, ticketSeq, j_); + auto const origin = ctx_.tx.getFieldH256(sfTransactionHash); + bool const erase = (ctx_.tx.getFlags() & tfExportEraseLatch) != 0; + return ExportLedgerOps::controlExportLatch( + view(), ctx_.rawView(), account, origin, erase, j_); } // --- Export intent path --- diff --git a/src/xrpld/app/tx/detail/ExportLedgerOps.h b/src/xrpld/app/tx/detail/ExportLedgerOps.h index d10923ebc..857a32ecd 100644 --- a/src/xrpld/app/tx/detail/ExportLedgerOps.h +++ b/src/xrpld/app/tx/detail/ExportLedgerOps.h @@ -390,7 +390,86 @@ eraseExportLatch( return tesSUCCESS; } -/** Deterministically reclaim expired pending latches during paid Export work. +/// Stop publication while retaining callback readiness and owner reserve. +inline TER +stopExportPublication( + ApplyView& view, + RawView& rawView, + Keylet const& latchKey, + beast::Journal j) +{ + Sandbox sb{&view}; + auto latch = sb.peek(latchKey); + if (!latch || latch->getType() != ltEXPORT_LATCH || + !latch->isFieldPresent(sfAccount) || + !latch->isFieldPresent(sfTransactionHash)) + return tecNO_ENTRY; + + auto const account = latch->getAccountID(sfAccount); + auto const origin = latch->getFieldH256(sfTransactionHash); + if (keylet::exportLatch(account, origin).key != latchKey.key) + return tefBAD_LEDGER; + + if (auto const ter = removePendingExportLink(sb, latchKey, j); + !isTesSuccess(ter)) + return ter; + + latch = sb.peek(latchKey); + if (!latch) + return tefBAD_LEDGER; + auto const flags = latch->isFieldPresent(sfFlags) + ? latch->getFieldU32(sfFlags) + : std::uint32_t{0}; + latch->setFieldU32(sfFlags, flags | lsfExportCanceled); + sb.update(latch); + sb.apply(rawView); + return tesSUCCESS; +} + +/** Apply an owner-authorized lifecycle operation to one exact Export W. + + Retain mode stops publication but preserves callback readiness. Erase mode + removes the latch and knowingly forfeits any later callback. + */ +inline TER +controlExportLatch( + ApplyView& view, + RawView& rawView, + AccountID const& account, + uint256 const& origin, + bool erase, + beast::Journal j) +{ + auto const key = keylet::exportLatch(account, origin); + auto const latch = view.read(key); + if (!latch) + { + JLOG(j.warn()) << "ExportLedgerOps: no Export latch for " << account + << " origin=" << origin; + return tecNO_ENTRY; + } + if (latch->getType() != ltEXPORT_LATCH || + !latch->isFieldPresent(sfAccount) || + !latch->isFieldPresent(sfTransactionHash) || + latch->getAccountID(sfAccount) != account || + latch->getFieldH256(sfTransactionHash) != origin) + return tefBAD_LEDGER; + + auto const ter = erase ? eraseExportLatch(view, rawView, key, j) + : stopExportPublication(view, rawView, key, j); + if (isTesSuccess(ter)) + { + JLOG(j.debug()) << "ExportLedgerOps: " << (erase ? "erased" : "stopped") + << " Export latch for " << account + << " origin=" << origin; + } + return ter; +} + +/** Deterministically stop expired publication work during paid Export work. + + Expiry releases the global signing-work slot, but the latch and owner + reserve remain until symmetric completion or an explicit erase election. */ inline TER pruneExpiredExportLatches( @@ -416,7 +495,7 @@ pruneExpiredExportLatches( for (auto const& key : expired) { - auto const ter = eraseExportLatch(view, rawView, key, j); + auto const ter = stopExportPublication(view, rawView, key, j); if (!isTesSuccess(ter) && ter != tecNO_ENTRY) return ter; } @@ -646,80 +725,6 @@ validateTicketSequence(STTx const& stx, beast::Journal j) return tesSUCCESS; } -/// Cancel publication work for an account-owned Export latch. -/// The latch retains its owner link and reserve so an already-executed target -/// transaction may still complete its callback. -/// -/// @param view The apply view to modify -/// @param account The owning account -/// @param ticketSeq The ticket sequence to cancel -/// @param j Journal for logging -/// @return tesSUCCESS or tecNO_ENTRY -inline TER -cancelExportLatch( - ApplyView& view, - RawView& rawView, - AccountID const& account, - std::uint32_t ticketSeq, - beast::Journal j) -{ - //@@start current-export-latch-cancel - std::optional found; - bool ambiguous = false; - forEachItem(view, account, [&](std::shared_ptr const& item) { - if (!item || item->getType() != ltEXPORT_LATCH || - !item->isFieldPresent(sfTransactionHash) || - item->getFieldU32(sfTicketSequence) != ticketSeq) - return; - if (found) - ambiguous = true; - found = - keylet::exportLatch(account, item->getFieldH256(sfTransactionHash)); - }); - if (ambiguous) - return tefBAD_LEDGER; - - if (!found) - { - JLOG(j.warn()) << "ExportLedgerOps: no Export latch to cancel for " - << account << " seq=" << ticketSeq; - return tecNO_ENTRY; - } - - auto const key = *found; - auto const sle = view.read(key); - if (!sle || !sle->isFieldPresent(sfAccount) || - sle->getAccountID(sfAccount) != account) - { - JLOG(j.warn()) << "ExportLedgerOps: Export latch ownership mismatch"; - return tecNO_PERMISSION; - } - if (keylet::exportLatch(account, sle->getFieldH256(sfTransactionHash)) - .key != key.key) - return tefBAD_LEDGER; - - Sandbox sb{&view}; - if (auto const ter = removePendingExportLink(sb, key, j); - !isTesSuccess(ter)) - return ter; - - auto latch = sb.peek(key); - if (!latch) - return tefBAD_LEDGER; - auto const flags = latch->isFieldPresent(sfFlags) - ? latch->getFieldU32(sfFlags) - : std::uint32_t{0}; - latch->setFieldU32(sfFlags, flags | lsfExportCanceled); - sb.update(latch); - sb.apply(rawView); - - JLOG(j.debug()) << "ExportLedgerOps: canceled Export latch for " << account - << " seq=" << ticketSeq; - - //@@end current-export-latch-cancel - return tesSUCCESS; -} - } // namespace ExportLedgerOps } // namespace ripple diff --git a/src/xrpld/app/tx/detail/Import.cpp b/src/xrpld/app/tx/detail/Import.cpp index 5ceb4a43e..d179c236f 100644 --- a/src/xrpld/app/tx/detail/Import.cpp +++ b/src/xrpld/app/tx/detail/Import.cpp @@ -1429,8 +1429,8 @@ Import::doApply() return ter; // --------------------------------------------------------------- - // Export callback path: ticket-based import consumes the shadow - // ticket and fires hooks — no B2M crediting, no account creation. + // Export callback path: ticket-based target execution records the Export + // latch fact and fires hooks — no B2M crediting, no account creation. // The hook inspects the result via xpop_slot(). // --------------------------------------------------------------- if (path == ImportPath::exportCallback)