From f2ca499c97b3d23bfe8ee3f13ddb517745389bae Mon Sep 17 00:00:00 2001 From: Nicholas Dudfield Date: Tue, 17 Mar 2026 12:13:41 +0700 Subject: [PATCH] feat(export): add ltSHADOW_TICKET and xport_cancel hook API Introduce shadow tickets for export replay protection: - ltSHADOW_TICKET ledger entry: account-owned, keyed by account + ticket sequence. Fields: sfAccount, sfTicketSequence, sfTransactionHash, sfLedgerSequence, sfOwnerNode. - ExportLedgerOps::createShadowTicket(): creates shadow ticket when exported tx has sfTicketSequence. Charges owner reserve. Called from both hook xport() path and Export transactor. - ExportLedgerOps::cancelShadowTicket(): deletes shadow ticket, frees reserve. Used by xport_cancel hook API. - xport_cancel(ticket_seq) hook API: allows hooks to cancel shadow tickets for exports that will never get a callback. - InvariantCheck: add ltSHADOW_TICKET to valid entry types. - Test: verify shadow ticket creation with correct fields and owner count bump via ttEXPORT with TicketSequence. --- hook/extern.h | 3 + include/xrpl/hook/hook_api.macro | 5 + include/xrpl/protocol/Indexes.h | 3 + .../xrpl/protocol/detail/ledger_entries.macro | 15 +++ src/libxrpl/protocol/Indexes.cpp | 14 ++- src/test/app/Export_test.cpp | 52 ++++++++ src/xrpld/app/hook/HookAPI.h | 3 + src/xrpld/app/hook/detail/HookAPI.cpp | 15 +++ src/xrpld/app/hook/detail/applyHook.cpp | 20 ++- src/xrpld/app/tx/detail/Export.cpp | 9 +- src/xrpld/app/tx/detail/ExportLedgerOps.h | 118 ++++++++++++++++++ src/xrpld/app/tx/detail/InvariantCheck.cpp | 1 + 12 files changed, 254 insertions(+), 4 deletions(-) diff --git a/hook/extern.h b/hook/extern.h index 755cb1b69..ffe15de77 100644 --- a/hook/extern.h +++ b/hook/extern.h @@ -346,6 +346,9 @@ xport( uint32_t read_ptr, uint32_t read_len); +extern int64_t +xport_cancel(uint32_t ticket_seq); + extern int64_t dice(uint32_t sides); diff --git a/include/xrpl/hook/hook_api.macro b/include/xrpl/hook/hook_api.macro index 1a1ab3e2c..c324da98d 100644 --- a/include/xrpl/hook/hook_api.macro +++ b/include/xrpl/hook/hook_api.macro @@ -383,6 +383,11 @@ HOOK_API_DEFINITION( int64_t, xport, (uint32_t, uint32_t, uint32_t, uint32_t), featureExport) +// int64_t xport_cancel(uint32_t ticket_seq); +HOOK_API_DEFINITION( + int64_t, xport_cancel, (uint32_t), + featureExport) + // int64_t dice(uint32_t sides); HOOK_API_DEFINITION( int64_t, dice, (uint32_t), diff --git a/include/xrpl/protocol/Indexes.h b/include/xrpl/protocol/Indexes.h index d419911e7..8fd036a70 100644 --- a/include/xrpl/protocol/Indexes.h +++ b/include/xrpl/protocol/Indexes.h @@ -68,6 +68,9 @@ emittedTxn(uint256 const& id) noexcept; Keylet exportedTxn(uint256 const& id) noexcept; +Keylet +shadowTicket(AccountID const& account, std::uint32_t ticketSeq) noexcept; + Keylet hookDefinition(uint256 const& hash) noexcept; diff --git a/include/xrpl/protocol/detail/ledger_entries.macro b/include/xrpl/protocol/detail/ledger_entries.macro index 5f18dd378..c928170c1 100644 --- a/include/xrpl/protocol/detail/ledger_entries.macro +++ b/include/xrpl/protocol/detail/ledger_entries.macro @@ -615,5 +615,20 @@ LEDGER_ENTRY(ltEXPORTED_TXN, 0x4578, ExportedTxn, exported_txn, ({ {sfTransactionHash, soeREQUIRED}, })) +/** A shadow ticket for export replay protection. + + Created when a transaction is exported. Consumed when + proof-of-execution is imported back. Account-owned (pays reserve). + + \sa keylet::shadowTicket + */ +LEDGER_ENTRY(ltSHADOW_TICKET, 0x5374, ShadowTicket, shadow_ticket, ({ + {sfAccount, soeREQUIRED}, + {sfTicketSequence, soeREQUIRED}, + {sfTransactionHash, soeREQUIRED}, + {sfLedgerSequence, soeREQUIRED}, + {sfOwnerNode, soeREQUIRED}, +})) + #undef EXPAND #undef LEDGER_ENTRY_DUPLICATE diff --git a/src/libxrpl/protocol/Indexes.cpp b/src/libxrpl/protocol/Indexes.cpp index 8075184a1..d7cb13cf7 100644 --- a/src/libxrpl/protocol/Indexes.cpp +++ b/src/libxrpl/protocol/Indexes.cpp @@ -72,8 +72,9 @@ enum class LedgerNameSpace : std::uint16_t { HOOK_DEFINITION = 'D', EMITTED_TXN = 'E', EMITTED_DIR = 'F', - EXPORTED_TXN = 0x4578, // Ex - EXPORTED_DIR = 0x4564, // Ed + EXPORTED_TXN = 0x4578, // Ex + EXPORTED_DIR = 0x4564, // Ed + SHADOW_TICKET = 0x5374, // St NFTOKEN_OFFER = 'q', NFTOKEN_BUY_OFFERS = 'h', NFTOKEN_SELL_OFFERS = 'i', @@ -203,6 +204,15 @@ exportedTxn(uint256 const& id) noexcept return {ltEXPORTED_TXN, indexHash(LedgerNameSpace::EXPORTED_TXN, id)}; } +Keylet +shadowTicket(AccountID const& account, std::uint32_t ticketSeq) noexcept +{ + return { + ltSHADOW_TICKET, + indexHash( + LedgerNameSpace::SHADOW_TICKET, account, std::uint32_t(ticketSeq))}; +} + Keylet hook(AccountID const& id) noexcept { diff --git a/src/test/app/Export_test.cpp b/src/test/app/Export_test.cpp index 3d5c05f2b..b767807c3 100644 --- a/src/test/app/Export_test.cpp +++ b/src/test/app/Export_test.cpp @@ -593,6 +593,57 @@ struct Export_test : public beast::unit_test::suite BEAST_EXPECT(!dirIsEmpty(*env.current(), exportedDirKey)); } + void + testShadowTicketLifecycle(FeatureBitset features) + { + testcase("Shadow ticket create and cancel via ttEXPORT"); + + using namespace jtx; + + Env env{*this, exportTestConfig(), features}; + + Account const alice{"alice"}; + Account const carol{"carol"}; + + env.fund(XRP(10000), alice, carol); + env.close(); + + auto const seq = env.current()->seq(); + + // Build exported payment with TicketSequence + auto innerObj = + buildExportedPayment(alice.id(), carol.id(), seq + 1, seq + 5); + innerObj.setFieldU32(sfTicketSequence, 42); + + // Submit ttEXPORT — should create both ltEXPORTED_TXN and + // ltSHADOW_TICKET + Json::Value jv; + jv[jss::TransactionType] = jss::Export; + jv[jss::Account] = alice.human(); + jv[sfExportedTxn.jsonName] = innerObj.getJson(JsonOptions::none); + + auto const ownerCountBefore = env.le(alice)->getFieldU32(sfOwnerCount); + + env(jv, fee(XRP(1)), ter(tesSUCCESS)); + env.close(); + + // Verify ltEXPORTED_TXN was created + BEAST_EXPECT(!dirIsEmpty(*env.current(), keylet::exportedDir())); + + // Verify shadow ticket exists + auto const stKey = keylet::shadowTicket(alice.id(), 42); + BEAST_EXPECT(env.current()->exists(stKey)); + + // Verify owner count bumped (reserve charged) + auto const ownerCountAfter = env.le(alice)->getFieldU32(sfOwnerCount); + BEAST_EXPECT(ownerCountAfter == ownerCountBefore + 1); + + // Verify shadow ticket fields + auto const stSle = env.current()->read(stKey); + BEAST_EXPECT(stSle->getAccountID(sfAccount) == alice.id()); + BEAST_EXPECT(stSle->getFieldU32(sfTicketSequence) == 42); + } + void testStaleSignatureCleanup(FeatureBitset features) { @@ -635,6 +686,7 @@ struct Export_test : public beast::unit_test::suite testXportRejectsLocalNetworkID(allWithExport); testXportRejectsUnconfiguredNetworkID(allWithExport); testExportTxn(allWithExport); + testShadowTicketLifecycle(allWithExport); testStaleSignatureCleanup(allWithExport); } }; diff --git a/src/xrpld/app/hook/HookAPI.h b/src/xrpld/app/hook/HookAPI.h index 08d18fdfb..c446bdb07 100644 --- a/src/xrpld/app/hook/HookAPI.h +++ b/src/xrpld/app/hook/HookAPI.h @@ -89,6 +89,9 @@ public: Expected, HookReturnCode> xport(Slice const& txBlob) const; + Expected + xport_cancel(uint32_t ticketSeq) const; + /// float APIs Expected float_set(int32_t exponent, int64_t mantissa) const; diff --git a/src/xrpld/app/hook/detail/HookAPI.cpp b/src/xrpld/app/hook/detail/HookAPI.cpp index be2a28036..f56d9910c 100644 --- a/src/xrpld/app/hook/detail/HookAPI.cpp +++ b/src/xrpld/app/hook/detail/HookAPI.cpp @@ -1289,6 +1289,21 @@ HookAPI::xport(Slice const& txBlob) const return tpTrans; } +Expected +HookAPI::xport_cancel(uint32_t ticketSeq) const +{ + auto& app = hookCtx.applyCtx.app; + auto j = app.journal("View"); + + TER const ter = ExportLedgerOps::cancelShadowTicket( + hookCtx.applyCtx.view(), hookCtx.result.account, ticketSeq, j); + + if (!isTesSuccess(ter)) + return Unexpected(DOESNT_EXIST); + + return ticketSeq; +} + uint32_t HookAPI::etxn_generation() const { diff --git a/src/xrpld/app/hook/detail/applyHook.cpp b/src/xrpld/app/hook/detail/applyHook.cpp index 526e4c9bf..173927e45 100644 --- a/src/xrpld/app/hook/detail/applyHook.cpp +++ b/src/xrpld/app/hook/detail/applyHook.cpp @@ -1730,12 +1730,18 @@ hook::finalizeHookResult( std::shared_ptr ptr = tpTrans->getSTransaction(); - TER const ter = ExportLedgerOps::createExportedTxn( + TER ter = ExportLedgerOps::createExportedTxn( applyCtx.view(), applyCtx.app, *ptr, id, j); if (!isTesSuccess(ter)) return ter; + ter = ExportLedgerOps::createShadowTicket( + applyCtx.view(), hookResult.account, *ptr, id, j); + + if (!isTesSuccess(ter)) + return ter; + exported_txnid.emplace_back(id); } } @@ -3072,6 +3078,18 @@ DEFINE_HOOK_FUNCTION(int64_t, xport_reserve, uint32_t count) HOOK_TEARDOWN(); } +DEFINE_HOOK_FUNCTION(int64_t, xport_cancel, uint32_t ticket_seq) +{ + HOOK_SETUP(); + + auto const result = api.xport_cancel(ticket_seq); + if (!result) + return result.error(); + return result.value(); + + HOOK_TEARDOWN(); +} + // Compute the burden of an emitted transaction based on a number of factors DEFINE_HOOK_FUNCTION(int64_t, etxn_burden) { diff --git a/src/xrpld/app/tx/detail/Export.cpp b/src/xrpld/app/tx/detail/Export.cpp index d0c0aa589..2219d42e1 100644 --- a/src/xrpld/app/tx/detail/Export.cpp +++ b/src/xrpld/app/tx/detail/Export.cpp @@ -71,8 +71,15 @@ Export::doApply() STTx exportedTx(std::ref(sit)); uint256 const txnId = exportedTx.getTransactionID(); - return ExportLedgerOps::createExportedTxn( + auto const account = ctx_.tx.getAccountID(sfAccount); + + TER ter = ExportLedgerOps::createExportedTxn( view(), ctx_.app, exportedTx, txnId, j_); + if (!isTesSuccess(ter)) + return ter; + + return ExportLedgerOps::createShadowTicket( + view(), account, exportedTx, txnId, j_); } } // namespace ripple diff --git a/src/xrpld/app/tx/detail/ExportLedgerOps.h b/src/xrpld/app/tx/detail/ExportLedgerOps.h index 8445fb111..88e8e6b59 100644 --- a/src/xrpld/app/tx/detail/ExportLedgerOps.h +++ b/src/xrpld/app/tx/detail/ExportLedgerOps.h @@ -159,6 +159,124 @@ createExportedTxn( return tecDIR_FULL; } +/// Create an ltSHADOW_TICKET in the account's owner directory. +/// Only created if the exported transaction has sfTicketSequence. +/// +/// @param view The apply view to modify +/// @param account The exporting account (pays reserve) +/// @param stx The exported transaction (checked for sfTicketSequence) +/// @param txnId Hash of the exported transaction +/// @param j Journal for logging +/// @return tesSUCCESS, tecDIR_FULL, or tefINTERNAL +inline TER +createShadowTicket( + ApplyView& view, + AccountID const& account, + STTx const& stx, + uint256 const& txnId, + beast::Journal j) +{ + if (!stx.isFieldPresent(sfTicketSequence)) + return tesSUCCESS; // No ticket sequence → no shadow ticket needed. + + auto const ticketSeq = stx.getFieldU32(sfTicketSequence); + auto const key = keylet::shadowTicket(account, ticketSeq); + + if (view.exists(key)) + { + JLOG(j.warn()) << "ExportLedgerOps: shadow ticket already exists for " + << account << " seq=" << ticketSeq; + return tefINTERNAL; + } + + auto sle = std::make_shared(key); + sle->setAccountID(sfAccount, account); + sle->setFieldU32(sfTicketSequence, ticketSeq); + sle->setFieldH256(sfTransactionHash, txnId); + sle->setFieldU32(sfLedgerSequence, view.info().seq); + + auto page = view.dirInsert( + keylet::ownerDir(account), key, describeOwnerDir(account)); + + if (!page) + { + JLOG(j.warn()) + << "ExportLedgerOps: owner dir full for shadow ticket, account=" + << account; + return tecDIR_FULL; + } + + sle->setFieldU64(sfOwnerNode, *page); + view.insert(sle); + + // Bump owner count for reserve. + auto sleAccount = view.peek(keylet::account(account)); + if (sleAccount) + adjustOwnerCount(view, sleAccount, 1, j); + + JLOG(j.debug()) << "ExportLedgerOps: created shadow ticket for " << account + << " seq=" << ticketSeq << " tx=" << txnId; + + return tesSUCCESS; +} + +/// Cancel (delete) an ltSHADOW_TICKET. Frees the owner reserve. +/// The account must own the shadow ticket. +/// +/// @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 +cancelShadowTicket( + ApplyView& view, + AccountID const& account, + std::uint32_t ticketSeq, + beast::Journal j) +{ + auto const key = keylet::shadowTicket(account, ticketSeq); + auto sle = view.peek(key); + + if (!sle) + { + JLOG(j.warn()) << "ExportLedgerOps: no shadow ticket to cancel for " + << account << " seq=" << ticketSeq; + return tecNO_ENTRY; + } + + // Verify ownership. + if (sle->getAccountID(sfAccount) != account) + { + JLOG(j.warn()) << "ExportLedgerOps: shadow ticket ownership mismatch"; + return tecNO_PERMISSION; + } + + // Remove from owner directory. + if (!view.dirRemove( + keylet::ownerDir(account), + sle->getFieldU64(sfOwnerNode), + key, + false)) + { + JLOG(j.warn()) + << "ExportLedgerOps: failed to remove shadow ticket from owner dir"; + return tefBAD_LEDGER; + } + + view.erase(sle); + + // Decrement owner count to free reserve. + auto sleAccount = view.peek(keylet::account(account)); + if (sleAccount) + adjustOwnerCount(view, sleAccount, -1, j); + + JLOG(j.debug()) << "ExportLedgerOps: cancelled shadow ticket for " + << account << " seq=" << ticketSeq; + + return tesSUCCESS; +} + } // namespace ExportLedgerOps } // namespace ripple diff --git a/src/xrpld/app/tx/detail/InvariantCheck.cpp b/src/xrpld/app/tx/detail/InvariantCheck.cpp index 52ed8e344..69b36f563 100644 --- a/src/xrpld/app/tx/detail/InvariantCheck.cpp +++ b/src/xrpld/app/tx/detail/InvariantCheck.cpp @@ -604,6 +604,7 @@ LedgerEntryTypesMatch::visitEntry( case ltUNL_REPORT: case ltCONSENSUS_ENTROPY: case ltEXPORTED_TXN: + case ltSHADOW_TICKET: case ltAMM: case ltBRIDGE: case ltXCHAIN_OWNED_CLAIM_ID: