refactor(export): key lifecycle control by origin

This commit is contained in:
Nicholas Dudfield
2026-07-16 09:41:56 +07:00
parent 81b46570b8
commit ffd50fc180
20 changed files with 369 additions and 165 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -24,6 +24,7 @@
#include <xrpl/beast/unit_test/suite.h>
#include <xrpl/json/json_writer.h>
#include <xrpl/protocol/ExportLimits.h>
#include <xrpl/protocol/ExportOriginMemo.h>
#include <xrpl/protocol/SField.h>
#include <xrpl/protocol/STAccount.h>
#include <xrpl/protocol/TxFlags.h>
@@ -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));

View File

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

View File

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

View File

@@ -377,7 +377,7 @@ public:
xport(Slice const& txBlob) const;
Expected<uint64_t, HookReturnCode>
xport_cancel(uint32_t ticketSeq) const;
xport_cancel(uint256 const& origin, uint32_t flags) const;
/// float APIs
Expected<uint64_t, HookReturnCode>

View File

@@ -7,7 +7,9 @@
#include <xrpld/app/tx/detail/ExportLedgerOps.h>
#include <xrpld/app/tx/detail/Import.h>
#include <xrpl/protocol/ExportLimits.h>
#include <xrpl/protocol/ExportOriginMemo.h>
#include <xrpl/protocol/STParsedJSON.h>
#include <xrpl/protocol/TxFlags.h>
namespace hook {
@@ -1065,52 +1067,53 @@ HookAPI::xport(Slice const& txBlob) const
}
Expected<uint64_t, HookReturnCode>
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

View File

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

View File

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

View File

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

View File

@@ -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<Keylet> found;
bool ambiguous = false;
forEachItem(view, account, [&](std::shared_ptr<SLE const> 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

View File

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