mirror of
https://github.com/Xahau/xahaud.git
synced 2026-04-29 15:37:46 +00:00
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:
@@ -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);
|
||||
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
{
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
{
|
||||
|
||||
@@ -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)
|
||||
{
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user