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.
This commit is contained in:
Nicholas Dudfield
2026-03-17 12:13:41 +07:00
parent bd68364f25
commit f2ca499c97
12 changed files with 254 additions and 4 deletions

View File

@@ -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);

View File

@@ -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),

View File

@@ -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;

View File

@@ -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

View File

@@ -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
{

View File

@@ -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);
}
};

View File

@@ -89,6 +89,9 @@ public:
Expected<std::shared_ptr<Transaction>, HookReturnCode>
xport(Slice const& txBlob) const;
Expected<uint64_t, HookReturnCode>
xport_cancel(uint32_t ticketSeq) const;
/// float APIs
Expected<uint64_t, HookReturnCode>
float_set(int32_t exponent, int64_t mantissa) const;

View File

@@ -1289,6 +1289,21 @@ HookAPI::xport(Slice const& txBlob) const
return tpTrans;
}
Expected<uint64_t, HookReturnCode>
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
{

View File

@@ -1730,12 +1730,18 @@ hook::finalizeHookResult(
std::shared_ptr<const ripple::STTx> 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)
{

View File

@@ -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

View File

@@ -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<SLE>(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

View File

@@ -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: