Compare commits

...
52 changed files with 3857 additions and 61 deletions

No files matched your search

+32
View File
@@ -899,6 +899,38 @@
#
#
#
# [export_key_file]
#
# Where a validator keeps its export keys (featureExport), the keys it uses
# to multisign transactions hooks export to other networks. Defaults to
# export_keys.txt in [database_path]. The validator generates and rotates
# the keys itself, keeping each for as long as its account lists it on
# ledger, so this file is as sensitive as the validator token and should be
# backed up. Servers that take turns validating with the same token must
# share it, or each will replace the other's current export key.
#
# Example:
# /var/lib/xahaud/export_keys.txt
#
#
#
# [xrpl_relay]
#
# Carries exports (featureExport) to the XRPL and their results back: one
# XRPL peer per "host [port]" line (port defaults to 51235). Exports whose
# UNL signatures meet the exporter's quorum are sent to XRPL, and XRPL
# transactions returning an export are imported here as XPOPs. The XRPL UNL
# comes from validator lists signed by an [import_vl_keys] or voted import
# key, relayed by the peer or fetched from the optional vl_url. Set
# network_id when the XRPL network is not mainnet.
#
# Example:
# s1.ripple.com 51235
# s2.ripple.com
# vl_url=https://vl.ripple.com
#
#
#
# [validator_key_revocation]
#
# If a validator's secret key has been compromised, a revocation must be
+7
View File
@@ -345,6 +345,13 @@ dice(uint32_t sides);
extern int64_t
util_random(uint32_t write_ptr, uint32_t write_len);
extern int64_t
xport(
uint32_t write_ptr,
uint32_t write_len,
uint32_t read_ptr,
uint32_t read_len);
#ifdef __cplusplus
}
#endif
+8
View File
@@ -74,6 +74,7 @@
#define sfLockCount ((2U << 16U) + 49U)
#define sfFirstNFTokenSequence ((2U << 16U) + 50U)
#define sfOracleDocumentID ((2U << 16U) + 51U)
#define sfExportKeysSeq ((2U << 16U) + 92U)
#define sfStartTime ((2U << 16U) + 93U)
#define sfRepeatCount ((2U << 16U) + 94U)
#define sfDelaySeconds ((2U << 16U) + 95U)
@@ -227,6 +228,8 @@
#define sfProvider ((7U << 16U) + 30U)
#define sfMPTokenMetadata ((7U << 16U) + 31U)
#define sfCredentialType ((7U << 16U) + 32U)
#define sfExportKey ((7U << 16U) + 94U)
#define sfExportKeyProof ((7U << 16U) + 95U)
#define sfAppLoader ((7U << 16U) + 96U)
#define sfHookName ((7U << 16U) + 97U)
#define sfRemarkValue ((7U << 16U) + 98U)
@@ -295,7 +298,11 @@
#define sfXChainCreateAccountAttestationCollectionElement ((14U << 16U) + 31U)
#define sfPriceData ((14U << 16U) + 32U)
#define sfCredential ((14U << 16U) + 33U)
#define sfExportKeyEntry ((14U << 16U) + 85U)
#define sfExportSignerList ((14U << 16U) + 86U)
#define sfExportKeyReport ((14U << 16U) + 87U)
#define sfRandomDigestEntry ((14U << 16U) + 88U)
#define sfExportedTxn ((14U << 16U) + 89U)
#define sfManifest ((14U << 16U) + 90U)
#define sfAmountEntry ((14U << 16U) + 91U)
#define sfMintURIToken ((14U << 16U) + 92U)
@@ -328,6 +335,7 @@
#define sfAuthorizeCredentials ((15U << 16U) + 26U)
#define sfUnauthorizeCredentials ((15U << 16U) + 27U)
#define sfAcceptedCredentials ((15U << 16U) + 28U)
#define sfExportKeys ((15U << 16U) + 90U)
#define sfRandomDigests ((15U << 16U) + 91U)
#define sfAmounts ((15U << 16U) + 92U)
#define sfHookEmissions ((15U << 16U) + 93U)
+3
View File
@@ -61,6 +61,9 @@
#define ttNFTOKEN_MODIFY 70
#define ttPERMISSIONED_DOMAIN_SET 71
#define ttPERMISSIONED_DOMAIN_DELETE 72
#define ttEXPORT 88
#define ttEXPORT_SIGN 87
#define ttEXPORT_FINAL 90
#define ttENTROPY 89
#define ttMANIFEST_SET 91
#define ttCRON 92
+2
View File
@@ -106,6 +106,8 @@ enum ClaimRewardFlags : uint32_t {
enum CronSetFlags : uint32_t {
tfCronUnset = 0x00000001,
// featureExport: fire after each export key rotation (see Export.h)
tfCronExportRotation = 0x00000002,
};
enum AMMClawbackFlags : uint32_t {
+1
View File
@@ -18,6 +18,7 @@
#define featureRNG "1"
#define fix20250131 "1"
#define fixGuardDepth32 "1"
#define featureExport "1"
namespace hook_api {
struct Rules
{
+4
View File
@@ -306,4 +306,8 @@ HOOK_API_DEFINITION(
HOOK_API_DEFINITION(
int64_t, util_random, (uint32_t write_ptr, uint32_t write_len),
featureRNG)
HOOK_API_DEFINITION(
int64_t, xport, (uint32_t write_ptr, uint32_t write_len, uint32_t read_ptr, uint32_t read_len),
featureExport)
// clang-format on
+3
View File
@@ -96,6 +96,9 @@ enum class HashPrefix : std::uint32_t {
/** Credentials signature */
credential = detail::make_hash_prefix('C', 'R', 'D'),
/** Proof that a validator holds the export key it nominates */
exportKeyProof = detail::make_hash_prefix('E', 'K', 'P'),
};
template <class Hasher>
+8
View File
@@ -398,6 +398,14 @@ manifest(PublicKey const& pk) noexcept;
Keylet const&
manifestDir() noexcept;
/** A pending export, ordered by creation ledger so TxQ can walk them. */
Keylet
exportedTxn(std::uint32_t seq, uint256 const& txid) noexcept;
/** The shadow ticket for an export using `ticketSeq` on the other network. */
Keylet
shadowTicket(AccountID const& account, std::uint32_t ticketSeq) noexcept;
} // namespace keylet
// Everything below is deprecated and should be removed in favor of keylets:
+4 -1
View File
@@ -242,8 +242,11 @@ constexpr std::uint32_t const tfClawbackMask = ~tfUniversal;
// CronSet Flags:
enum CronSetFlags : uint32_t {
tfCronUnset = 0x00000001,
// featureExport: fire after each export key rotation (see Export.h)
tfCronExportRotation = 0x00000002,
};
constexpr std::uint32_t const tfCronSetMask = ~(tfUniversal | tfCronUnset);
constexpr std::uint32_t const tfCronSetMask =
~(tfUniversal | tfCronUnset | tfCronExportRotation);
// AMM Flags:
constexpr std::uint32_t tfLPToken = 0x00010000;
@@ -34,6 +34,7 @@
// If you add an amendment here, then do not forget to increment `numFeatures`
// in include/xrpl/protocol/Feature.h.
XRPL_FEATURE(Export, Supported::yes, VoteBehavior::DefaultNo)
XRPL_FEATURE(RNG, Supported::yes, VoteBehavior::DefaultNo)
XRPL_FEATURE(PWALoader, Supported::yes, VoteBehavior::DefaultNo)
XRPL_FIX (20261005, Supported::yes, VoteBehavior::DefaultYes)
@@ -109,6 +109,44 @@ LEDGER_ENTRY(ltHOOK_DEFINITION, 'D', HookDefinition, hook_definition, ({
{sfPreviousTxnLgrSeq, soeOPTIONAL},
}))
/** A pending export: the transaction being multisigned for another network,
gathering UNL signatures in its sfSigners until ttEXPORT_FINAL deletes it,
with the exporter's signer list there when it was created. sfOwner is the
exporting account, so the signatures and the result are attributed to it
in metadata. sfEmitHookHash is the hook to call back if it closes below
quorum.
\sa keylet::exportedTxn
*/
LEDGER_ENTRY(ltEXPORTED_TXN, 'X', ExportedTxn, exported_txn, ({
{sfOwner, soeREQUIRED},
{sfExportedTxn, soeREQUIRED},
{sfTransactionHash, soeREQUIRED},
{sfLedgerSequence, soeREQUIRED},
{sfSignerEntries, soeREQUIRED},
{sfSignerQuorum, soeREQUIRED},
{sfEmitHookHash, soeOPTIONAL},
}))
/** A shadow ticket: lets one XPOP of a ticketed export that is bound back to
this network (OperationLimit == NETWORK_ID) be imported, which consumes it.
Only ttEXPORT creates these. They are not hook state because a hook can
write any namespace of its own account's state, so it could forge one and
import the same XPOP again and again. sfEmitHookHash is the hook whose
cbak the Import calls.
\sa keylet::shadowTicket
*/
LEDGER_ENTRY(ltSHADOW_TICKET, 'Y', ShadowTicket, shadow_ticket, ({
{sfAccount, soeREQUIRED},
{sfTicketSequence, soeREQUIRED},
{sfTransactionHash, soeREQUIRED},
{sfEmitHookHash, soeOPTIONAL},
{sfOwnerNode, soeREQUIRED},
{sfPreviousTxnID, soeREQUIRED},
{sfPreviousTxnLgrSeq, soeREQUIRED},
}))
/** A ledger object containing a hook-emitted transaction from a previous hook execution.
\sa keylet::emitted
@@ -192,6 +230,12 @@ LEDGER_ENTRY(ltNFTOKEN_PAGE, 0x0050, NFTokenPage, nft_page, ({
LEDGER_ENTRY_DUPLICATE(ltUNL_REPORT, 0x0052, UNLReport, unl_report, ({
{sfImportVLKeys, soeOPTIONAL},
{sfActiveValidators, soeOPTIONAL},
/* featureExport: the ledger of the last ActiveValidator/ImportVLKey
report, which decides when the arrays above are reset. Export key
reports also modify this object, so PreviousTxnLgrSeq no longer can. */
{sfLedgerSequence, soeOPTIONAL},
/* featureExport: bumped whenever a validator's export key changes. */
{sfExportKeysSeq, soeOPTIONAL},
{sfPreviousTxnID, soeREQUIRED},
{sfPreviousTxnLgrSeq, soeREQUIRED},
}))
@@ -300,6 +344,11 @@ LEDGER_ENTRY(ltACCOUNT_ROOT, 0x0061, AccountRoot, account, ({
{sfAMMID, soeOPTIONAL},
{sfAppLoaderSequence, soeOPTIONAL},
{sfManifestID, soeOPTIONAL},
/* featureExport: a validator's export keys, newest first */
{sfExportKeys, soeOPTIONAL},
/* featureExport: the signer list this account holds on the network it
exports to, as last proven by an Import of its SignerListSet */
{sfExportSignerList, soeOPTIONAL},
}))
/** A ledger object which contains a list of object identifiers.
@@ -117,6 +117,7 @@ TYPED_SFIELD(sfLockCount, UINT32, 49)
TYPED_SFIELD(sfFirstNFTokenSequence, UINT32, 50)
TYPED_SFIELD(sfOracleDocumentID, UINT32, 51)
TYPED_SFIELD(sfExportKeysSeq, UINT32, 92)
TYPED_SFIELD(sfStartTime, UINT32, 93)
TYPED_SFIELD(sfRepeatCount, UINT32, 94)
TYPED_SFIELD(sfDelaySeconds, UINT32, 95)
@@ -300,6 +301,8 @@ TYPED_SFIELD(sfAssetClass, VL, 29)
TYPED_SFIELD(sfProvider, VL, 30)
TYPED_SFIELD(sfMPTokenMetadata, VL, 31)
TYPED_SFIELD(sfCredentialType, VL, 32)
TYPED_SFIELD(sfExportKey, VL, 94)
TYPED_SFIELD(sfExportKeyProof, VL, 95)
TYPED_SFIELD(sfAppLoader, VL, 96)
TYPED_SFIELD(sfHookName, VL, 97)
TYPED_SFIELD(sfRemarkValue, VL, 98)
@@ -390,7 +393,11 @@ UNTYPED_SFIELD(sfXChainClaimAttestationCollectionElement, OBJECT, 30)
UNTYPED_SFIELD(sfXChainCreateAccountAttestationCollectionElement, OBJECT, 31)
UNTYPED_SFIELD(sfPriceData, OBJECT, 32)
UNTYPED_SFIELD(sfCredential, OBJECT, 33)
UNTYPED_SFIELD(sfExportKeyEntry, OBJECT, 85)
UNTYPED_SFIELD(sfExportSignerList, OBJECT, 86)
UNTYPED_SFIELD(sfExportKeyReport, OBJECT, 87)
UNTYPED_SFIELD(sfRandomDigestEntry, OBJECT, 88)
UNTYPED_SFIELD(sfExportedTxn, OBJECT, 89, SField::sMD_Create|SField::sMD_DeleteFinal)
UNTYPED_SFIELD(sfManifest, OBJECT, 90)
UNTYPED_SFIELD(sfAmountEntry, OBJECT, 91)
UNTYPED_SFIELD(sfMintURIToken, OBJECT, 92)
@@ -431,6 +438,7 @@ UNTYPED_SFIELD(sfAuthAccounts, ARRAY, 25)
UNTYPED_SFIELD(sfAuthorizeCredentials, ARRAY, 26)
UNTYPED_SFIELD(sfUnauthorizeCredentials, ARRAY, 27)
UNTYPED_SFIELD(sfAcceptedCredentials, ARRAY, 28)
UNTYPED_SFIELD(sfExportKeys, ARRAY, 90)
// RNG validator commitments: never written to metadata (see featureRNG)
UNTYPED_SFIELD(sfRandomDigests, ARRAY, 91, SField::sMD_Never)
UNTYPED_SFIELD(sfAmounts, ARRAY, 92)
@@ -501,6 +501,28 @@ TRANSACTION(ttPERMISSIONED_DOMAIN_DELETE, 72, PermissionedDomainDelete, ({
{sfDomainID, soeREQUIRED},
}))
/** Emitted by a hook (see xport()): have the export keys in the account's
sfExportSignerList multisign sfExportedTxn, a transaction for another
network from the hook's account. See Export.h. */
TRANSACTION(ttEXPORT, 88, Export, ({
{sfExportedTxn, soeREQUIRED},
}))
/** A UNL validator's multisignature over a pending export. Deliberately not a
pseudo-txn: disputed pseudo-txns are discarded, these are retried. */
TRANSACTION(ttEXPORT_SIGN, 87, ExportSign, ({
{sfLedgerSequence, soeREQUIRED},
{sfTransactionHash, soeREQUIRED},
{sfSigner, soeREQUIRED},
}))
/** A pseudo-txn closing an export's signing window. */
TRANSACTION(ttEXPORT_FINAL, 90, ExportFinal, ({
{sfOwner, soeREQUIRED},
{sfLedgerSequence, soeREQUIRED},
{sfTransactionHash, soeREQUIRED},
}))
/** featureRNG: a validator's entropy contribution for one ledger.
Each proposing UNLReport validator places exactly one of these in its own
@@ -628,4 +650,5 @@ TRANSACTION(ttUNL_REPORT, 104, UNLReport, ({
{sfLedgerSequence, soeREQUIRED},
{sfActiveValidator, soeOPTIONAL},
{sfImportVLKey, soeOPTIONAL},
{sfExportKeyReport, soeOPTIONAL},
}))
+23
View File
@@ -95,6 +95,8 @@ enum class LedgerNameSpace : std::uint16_t {
CREDENTIAL = LEDGER_NAMESPACE2(0x01, 'D'),
PERMISSIONED_DOMAIN = 'm',
RANDOM = 0x526E, // Rn
EXPORTED_TXN = 'X',
SHADOW_TICKET = 'Y',
// No longer used or supported. Left here to reserve the space
// to avoid accidental reuse.
@@ -701,6 +703,27 @@ manifestDir() noexcept
return ret;
}
Keylet
exportedTxn(std::uint32_t seq, uint256 const& txid) noexcept
{
// like cron: 8 bytes of namespace, 4 bytes BE ledger seq, 20 of the txid
static uint256 const ns = indexHash(LedgerNameSpace::EXPORTED_TXN);
uint256 k;
std::memcpy(k.data(), ns.data(), 8);
for (int i = 0; i < 4; ++i)
k.data()[8 + i] = static_cast<std::uint8_t>(seq >> (24 - 8 * i));
std::memcpy(k.data() + 12, txid.data(), 20);
return {ltEXPORTED_TXN, k};
}
Keylet
shadowTicket(AccountID const& account, std::uint32_t ticketSeq) noexcept
{
return {
ltSHADOW_TICKET,
indexHash(LedgerNameSpace::SHADOW_TICKET, account, ticketSeq)};
}
} // namespace keylet
} // namespace ripple
@@ -151,6 +151,34 @@ InnerObjectFormats::InnerObjectFormats()
{sfLedgerSequence, soeREQUIRED},
});
// featureExport: one validator's export key, as reported by the UNL
add(sfExportKeyReport.jsonName,
sfExportKeyReport.getCode(),
{
{sfPublicKey, soeREQUIRED},
{sfExportKey, soeREQUIRED},
{sfExportKeyProof, soeREQUIRED},
});
// featureExport: an account's signer list on the network it exports to,
// and the (ledger, index) there of the SignerListSet that set it
add(sfExportSignerList.jsonName,
sfExportSignerList.getCode(),
{
{sfSignerQuorum, soeREQUIRED},
{sfSignerEntries, soeREQUIRED},
{sfLedgerSequence, soeREQUIRED},
{sfTransactionIndex, soeREQUIRED},
});
// featureExport: an entry in a validator account's sfExportKeys
add(sfExportKeyEntry.jsonName,
sfExportKeyEntry.getCode(),
{
{sfExportKey, soeREQUIRED},
{sfCloseTime, soeREQUIRED},
});
add(sfAmountEntry.jsonName,
sfAmountEntry.getCode(),
{
+1 -1
View File
@@ -685,7 +685,7 @@ isPseudoTx(STObject const& tx)
auto tt = safe_cast<TxType>(*t);
return tt == ttAMENDMENT || tt == ttFEE || tt == ttUNL_MODIFY ||
tt == ttEMIT_FAILURE || tt == ttUNL_REPORT || tt == ttCRON ||
tt == ttENTROPY;
tt == ttENTROPY || tt == ttEXPORT_FINAL;
}
} // namespace ripple
+3
View File
@@ -66,6 +66,9 @@ STValidation::validationFormat()
{sfBaseFeeDrops, soeOPTIONAL},
{sfReserveBaseDrops, soeOPTIONAL},
{sfReserveIncrementDrops, soeOPTIONAL},
// featureExport: sent on flag ledger validations only
{sfExportKey, soeOPTIONAL},
{sfExportKeyProof, soeOPTIONAL},
};
// clang-format on
+562
View File
@@ -0,0 +1,562 @@
//------------------------------------------------------------------------------
/*
This file is part of rippled: https://github.com/ripple/rippled
Copyright (c) 2026 XRPL-Labs
Permission to use, copy, modify, and/or distribute this software for any
purpose with or without fee is hereby granted, provided that the above
copyright notice and this permission notice appear in all copies.
THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
ANY SPECIAL , DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
*/
//==============================================================================
#include <test/jtx.h>
#include <xrpld/app/ledger/LedgerMaster.h>
#include <xrpld/app/misc/HashRouter.h>
#include <xrpld/app/tx/applySteps.h>
#include <xrpld/app/tx/detail/Export.h>
#include <xrpl/basics/StringUtilities.h>
#include <xrpl/json/json_reader.h>
#include <xrpl/protocol/Feature.h>
#include <xrpl/protocol/Indexes.h>
#include <xrpl/protocol/STValidation.h>
#include <xrpl/protocol/jss.h>
namespace ripple {
namespace test {
/** Export keys: their nomination by UNL report, rotation on the account,
crons following the rotation and the export_setup RPC (see
Export.h). */
class ExportKeys_test : public beast::unit_test::suite
{
using KeyPair = std::pair<PublicKey, SecretKey>;
static Blob
blob(PublicKey const& pk)
{
return Blob(pk.begin(), pk.end());
}
static std::uint32_t
closeTime(jtx::Env& env)
{
return env.closed()->info().closeTime.time_since_epoch().count();
}
static void
closeAt(jtx::Env& env, std::uint32_t t)
{
env.close(NetClock::time_point{std::chrono::seconds{t}});
}
// the ttUNL_REPORT the UNL makes of `master`'s nomination of `ek`
static STTx
exportKeyReport(
LedgerIndex seq,
PublicKey const& master,
KeyPair const& ek,
std::uint32_t networkID,
std::optional<PublicKey> provenFor = std::nullopt)
{
auto const proof = sign(
ek.first,
ek.second,
exportKeyProofData(provenFor.value_or(master), ek.first, networkID)
.slice());
return STTx(ttUNL_REPORT, [&](auto& obj) {
obj.setFieldU32(sfLedgerSequence, seq);
auto& r = obj.peekFieldObject(sfExportKeyReport);
r.setFieldVL(sfPublicKey, master);
r.setFieldVL(sfExportKey, ek.first);
r.setFieldVL(sfExportKeyProof, proof);
});
}
static STTx
activeValidatorReport(LedgerIndex seq, PublicKey const& master)
{
return STTx(ttUNL_REPORT, [&](auto& obj) {
obj.setFieldU32(sfLedgerSequence, seq);
obj.peekFieldObject(sfActiveValidator)
.setFieldVL(sfPublicKey, master);
});
}
// put pseudo-txns in the open ledger, as consensus would, and close it
static void
inject(jtx::Env& env, std::vector<STTx> const& txs)
{
env.app().openLedger().modify([&](OpenView& view, beast::Journal) {
for (auto const& tx : txs)
{
auto const id = tx.getTransactionID();
auto s = std::make_shared<Serializer>();
tx.add(*s);
env.app().getHashRouter().setFlags(id, SF_PRIVATE2);
view.rawTxInsert(id, std::move(s), nullptr);
}
return true;
});
env.close();
}
static void
nominate(jtx::Env& env, PublicKey const& master, KeyPair const& ek)
{
inject(
env,
{exportKeyReport(
env.current()->seq(),
master,
ek,
env.app().config().NETWORK_ID)});
}
static std::vector<Blob>
keysOf(jtx::Env& env, jtx::Account const& acc)
{
std::vector<Blob> out;
if (auto const sle = env.le(acc);
sle && sle->isFieldPresent(sfExportKeys))
for (auto const& e : sle->getFieldArray(sfExportKeys))
out.push_back(e.getFieldVL(sfExportKey));
return out;
}
static std::uint32_t
keysSeq(jtx::Env& env)
{
auto const unl = env.le(keylet::UNLReport());
return unl ? (*unl)[~sfExportKeysSeq].value_or(0) : 0;
}
void
testValidation()
{
testcase("validation carries a nomination");
auto const vk = randomKeyPair(KeyType::secp256k1);
auto const ek = randomKeyPair(KeyType::ed25519);
auto const v = std::make_shared<STValidation>(
NetClock::time_point{std::chrono::seconds{1000}},
vk.first,
vk.second,
calcNodeID(vk.first),
[&](STValidation& v) {
v.setFieldH256(sfLedgerHash, uint256{7});
v.setFieldU32(sfLedgerSequence, 512);
v.setFlag(vfFullValidation);
v.setFieldVL(sfExportKey, ek.first.slice());
v.setFieldVL(sfExportKeyProof, Blob{1, 2, 3});
});
Serializer s;
v->add(s);
SerialIter sit(s.slice());
STValidation const back(
sit, [](PublicKey const& pk) { return calcNodeID(pk); }, true);
BEAST_EXPECT(back.isValid());
BEAST_EXPECT(back.getFieldVL(sfExportKey) == blob(ek.first));
BEAST_EXPECT(back.getFieldVL(sfExportKeyProof) == (Blob{1, 2, 3}));
}
void
testPreflight()
{
testcase("export key report preflight");
using namespace jtx;
{
Env env{*this, supported_amendments() | featureExport};
auto const nid = env.app().config().NETWORK_ID;
auto const rules = env.current()->rules();
Account const val{"val"};
auto const ek = randomKeyPair(KeyType::ed25519);
auto const seq = env.current()->seq();
auto const pf = [&](STTx const& tx) {
return preflight(env.app(), rules, tx, tapNONE, env.journal)
.ter;
};
BEAST_EXPECT(
pf(exportKeyReport(seq, val.pk(), ek, nid)) == tesSUCCESS);
// proof for another master, or another network
BEAST_EXPECT(
pf(exportKeyReport(seq, val.pk(), ek, nid, ek.first)) ==
temBAD_SIGNATURE);
BEAST_EXPECT(
pf(exportKeyReport(seq, val.pk(), ek, nid + 1)) ==
temBAD_SIGNATURE);
// the master key itself is not an export key
auto const mk = randomKeyPair(KeyType::ed25519);
BEAST_EXPECT(
pf(exportKeyReport(seq, mk.first, mk, nid)) == temMALFORMED);
// export key reports travel alone
STObject obj = exportKeyReport(seq, val.pk(), ek, nid);
obj.peekFieldObject(sfActiveValidator)
.setFieldVL(sfPublicKey, val.pk());
Serializer s;
obj.add(s);
SerialIter sit(s.slice());
BEAST_EXPECT(pf(STTx(sit)) == temMALFORMED);
}
{
Env env{*this, supported_amendments() - featureExport};
Account const val{"val"};
BEAST_EXPECT(
preflight(
env.app(),
env.current()->rules(),
exportKeyReport(
env.current()->seq(),
val.pk(),
randomKeyPair(KeyType::ed25519),
env.app().config().NETWORK_ID),
tapNONE,
env.journal)
.ter == temDISABLED);
}
}
void
testRotation()
{
testcase("export key rotation");
using namespace jtx;
Env env{*this, supported_amendments() | featureExport};
Account const val{"val"};
Account const unfunded{"unfunded"};
env.fund(XRP(10000), val);
env.close();
auto const P = Export::keyRotationPeriod;
auto const epochOf = [&](std::uint32_t t) { return t / P; };
auto const headTime = [&]() {
return env.le(val)->getFieldArray(sfExportKeys)[0].getFieldU32(
sfCloseTime);
};
// start just inside an epoch
std::uint32_t const b0 = (closeTime(env) / P + 1) * P;
closeAt(env, b0 + 100);
// first nomination recorded, stamped with the close time
auto const k1 = randomKeyPair(KeyType::ed25519);
nominate(env, val.pk(), k1);
BEAST_EXPECT(keysOf(env, val) == std::vector<Blob>{blob(k1.first)});
BEAST_EXPECT(epochOf(headTime()) == epochOf(b0));
BEAST_EXPECT(keysSeq(env) == 1);
// reported again every flag ledger: no change
nominate(env, val.pk(), k1);
BEAST_EXPECT(keysOf(env, val) == std::vector<Blob>{blob(k1.first)});
BEAST_EXPECT(keysSeq(env) == 1);
// a new key in the same epoch replaces the head
auto const k2 = randomKeyPair(KeyType::ed25519);
nominate(env, val.pk(), k2);
BEAST_EXPECT(keysOf(env, val) == std::vector<Blob>{blob(k2.first)});
BEAST_EXPECT(keysSeq(env) == 2);
// a new key in a later epoch is pushed
closeAt(env, b0 + P + 100);
auto const k3 = randomKeyPair(KeyType::ed25519);
nominate(env, val.pk(), k3);
BEAST_EXPECT(
keysOf(env, val) ==
(std::vector<Blob>{blob(k3.first), blob(k2.first)}));
BEAST_EXPECT(keysSeq(env) == 3);
// an older key never comes back
nominate(env, val.pk(), k2);
BEAST_EXPECT(keysOf(env, val).front() == blob(k3.first));
BEAST_EXPECT(keysSeq(env) == 3);
// at most maxExportKeys, newest first
std::vector<KeyPair> later;
for (std::uint32_t i = 0; i < 20; ++i)
{
closeAt(env, b0 + (2 + i) * P + 100);
later.push_back(randomKeyPair(KeyType::ed25519));
nominate(env, val.pk(), later.back());
}
auto const keys = keysOf(env, val);
BEAST_EXPECT(keys.size() == Export::maxExportKeys);
BEAST_EXPECT(keys.front() == blob(later.back().first));
BEAST_EXPECT(keys.back() == blob(later[later.size() - 16].first));
BEAST_EXPECT(keysSeq(env) == 23);
// the account must exist: nothing is created for it
nominate(env, unfunded.pk(), randomKeyPair(KeyType::ed25519));
BEAST_EXPECT(!env.le(unfunded));
BEAST_EXPECT(keysSeq(env) == 23);
}
void
testReportReset()
{
testcase("export key reports do not suppress the UNLReport reset");
using namespace jtx;
Env env{*this, supported_amendments() | featureExport};
Account const a{"a"}, b{"b"};
env.fund(XRP(10000), a, b);
env.close();
auto const avs = [&]() {
std::vector<PublicKey> out;
for (auto const& v :
env.le(keylet::UNLReport())->getFieldArray(sfActiveValidators))
out.emplace_back(v[sfPublicKey]);
return out;
};
inject(
env,
{activeValidatorReport(env.current()->seq(), a.pk()),
activeValidatorReport(env.current()->seq(), b.pk())});
BEAST_EXPECT(avs().size() == 2);
// a later round with only `a` active, its export key report applied
// in the same ledger: the array must still be rebuilt, not appended
auto const seq = env.current()->seq();
inject(
env,
{exportKeyReport(
seq,
a.pk(),
randomKeyPair(KeyType::ed25519),
env.app().config().NETWORK_ID),
activeValidatorReport(seq, a.pk())});
BEAST_EXPECT(avs() == std::vector<PublicKey>{a.pk()});
BEAST_EXPECT(keysSeq(env) == 1);
}
void
testRotationCron()
{
testcase("cron after each export key rotation");
using namespace jtx;
auto const P = Export::keyRotationPeriod;
Account const alice{"alice"};
{
Env env{
*this, supported_amendments() | featureExport | featureCron};
env.fund(XRP(10000), alice);
env.close();
// the ledger picks the times
env(cron::set(alice),
txflags(tfCronExportRotation),
cron::startTime(0),
fee(XRP(1)),
ter(temMALFORMED));
env(cron::set(alice),
txflags(tfCronExportRotation),
cron::delay(P),
cron::repeat(2),
fee(XRP(1)),
ter(temMALFORMED));
env(cron::set(alice),
txflags(tfCronExportRotation | tfCronUnset),
fee(XRP(1)),
ter(temMALFORMED));
env(cron::set(alice),
txflags(tfCronExportRotation),
cron::repeat(257),
fee(XRP(1)),
ter(temMALFORMED));
auto const now = static_cast<std::uint32_t>(
env.current()->parentCloseTime().time_since_epoch().count());
env(cron::set(alice),
txflags(tfCronExportRotation),
cron::repeat(4),
fee(XRP(1)));
env.close();
auto const cronOf = [&]() {
return env.le(
keylet::child(env.le(alice)->getFieldH256(sfCron)));
};
auto const c = cronOf();
BEAST_EXPECT(c);
if (!c)
return;
// just after the next rotation has settled, then every rotation
auto const start = c->getFieldU32(sfStartTime);
BEAST_EXPECT(start == Export::rotationCronTime(now, alice.id()));
BEAST_EXPECT(start > now);
BEAST_EXPECT(
start % P >= Export::rotationSettle &&
start % P < Export::rotationSettle + Export::rotationSpread);
BEAST_EXPECT(c->getFieldU32(sfDelaySeconds) == P);
BEAST_EXPECT(c->getFieldU32(sfRepeatCount) == 4);
// fires once that time has passed, and rearms a rotation later
closeAt(env, start + 100);
env.close();
bool fired = false;
for (auto const& [tx, meta] : env.closed()->txs)
if (tx->getTxnType() == ttCRON && (*tx)[sfOwner] == alice.id())
fired = true;
BEAST_EXPECT(fired);
auto const next = cronOf();
BEAST_EXPECT(next);
if (next)
{
BEAST_EXPECT(next->getFieldU32(sfStartTime) == start + P);
BEAST_EXPECT(next->getFieldU32(sfRepeatCount) == 3);
}
}
{
Env env{
*this, (supported_amendments() | featureCron) - featureExport};
env.fund(XRP(10000), alice);
env.close();
env(cron::set(alice),
txflags(tfCronExportRotation),
fee(XRP(1)),
ter(temINVALID_FLAG));
}
}
void
testSignerListRPC()
{
testcase("export_setup");
using namespace jtx;
Env env{*this, supported_amendments() | featureExport};
Account const v1{"v1"}, v2{"v2"}, v3{"v3"};
env.fund(XRP(10000), v1, v2, v3);
env.close();
auto const P = Export::keyRotationPeriod;
std::uint32_t const b0 = (closeTime(env) / P + 1) * P;
closeAt(env, b0 + 100);
// v1 and v2 active and nominating, v3 active without a key
auto const seq = env.current()->seq();
auto const nid = env.app().config().NETWORK_ID;
auto const k1 = randomKeyPair(KeyType::ed25519);
auto const k2a = randomKeyPair(KeyType::ed25519);
inject(
env,
{activeValidatorReport(seq, v1.pk()),
activeValidatorReport(seq, v2.pk()),
activeValidatorReport(seq, v3.pk()),
exportKeyReport(seq, v1.pk(), k1, nid),
exportKeyReport(seq, v2.pk(), k2a, nid)});
// v2 rotates an epoch later
closeAt(env, b0 + P + 100);
auto const k2b = randomKeyPair(KeyType::ed25519);
nominate(env, v2.pk(), k2b);
auto const accountsOf = [](Json::Value const& r) {
std::set<std::string> out;
for (auto const& e : r[jss::tx_json][sfSignerEntries.jsonName])
out.insert(
e[sfSignerEntry.jsonName][sfAccount.jsonName].asString());
return out;
};
auto const acc = [](KeyPair const& kp) {
return toBase58(calcAccountID(kp.first));
};
{
auto const r = env.rpc("json", "export_setup", "{}")[jss::result];
BEAST_EXPECT(
r[jss::tx_json][jss::TransactionType] == "SignerListSet");
BEAST_EXPECT(
accountsOf(r) == (std::set<std::string>{acc(k1), acc(k2b)}));
BEAST_EXPECT(r[jss::tx_json][sfSignerQuorum.jsonName] == 2);
BEAST_EXPECT(r[jss::tx_json][sfOperationLimit.jsonName] == nid);
BEAST_EXPECT(r["export_keys_seq"] == 3);
// a Xaman link to the same transaction
std::string const prefix = "https://xaman.app/detect/";
auto const url = r["xaman_url"].asString();
BEAST_EXPECT(url.starts_with(prefix));
auto const json = strUnHex(url.substr(prefix.size()));
Json::Value tx;
BEAST_EXPECT(
json &&
Json::Reader().parse(
std::string(json->begin(), json->end()), tx) &&
tx == r[jss::tx_json]);
}
{
// as it was in the first epoch
Json::Value p;
p["as_of"] = b0 + 1000;
p["quorum"] = 1;
p[jss::account] = v1.human();
auto const r =
env.rpc("json", "export_setup", to_string(p))[jss::result];
BEAST_EXPECT(
accountsOf(r) == (std::set<std::string>{acc(k1), acc(k2a)}));
BEAST_EXPECT(r[jss::tx_json][sfSignerQuorum.jsonName] == 1);
BEAST_EXPECT(r[jss::tx_json][jss::Account] == v1.human());
}
{
// before any key, and a bad quorum
Json::Value p;
p["as_of"] = b0 - 1;
BEAST_EXPECT(
env.rpc("json", "export_setup", to_string(p))[jss::result]
.isMember(jss::error));
p["as_of"] = b0 + 1000;
p["quorum"] = 3;
BEAST_EXPECT(
env.rpc("json", "export_setup", to_string(p))[jss::result]
.isMember(jss::error));
}
{
// the command line form
auto const r =
env.rpc("export_setup", v1.human(), "1")[jss::result];
BEAST_EXPECT(r[jss::tx_json][jss::Account] == v1.human());
BEAST_EXPECT(r[jss::tx_json][sfSignerQuorum.jsonName] == 1);
}
}
public:
void
run() override
{
testValidation();
testPreflight();
testRotation();
testReportReset();
testRotationCron();
testSignerListRPC();
}
};
BEAST_DEFINE_TESTSUITE(ExportKeys, app, ripple);
} // namespace test
} // namespace ripple
+627
View File
@@ -0,0 +1,627 @@
//------------------------------------------------------------------------------
/*
This file is part of rippled: https://github.com/ripple/rippled
Copyright (c) 2026 XRPL-Labs
Permission to use, copy, modify, and/or distribute this software for any
purpose with or without fee is hereby granted, provided that the above
copyright notice and this permission notice appear in all copies.
THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
ANY SPECIAL , DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
*/
//==============================================================================
#include <test/jtx.h>
#include <xrpld/app/tx/apply.h>
#include <xrpld/app/tx/applySteps.h>
#include <xrpld/app/tx/detail/ApplyContext.h>
#include <xrpld/app/tx/detail/Export.h>
#include <xrpld/ledger/OpenView.h>
#include <xrpl/protocol/Feature.h>
#include <xrpl/protocol/Indexes.h>
#include <xrpl/protocol/Sign.h>
namespace ripple {
namespace test {
/** The export transactors (see Export.h): ttEXPORT's checks, its use of the
account's signer list and shadow tickets, and validators signing and
finalizing pending exports.
ttEXPORT is hook-emitted, so its preflight runs as emit() runs it and its
doApply runs directly; a full hook round trip and the Import of a ticketed
XPOP are left to the hook and XPOP suites. */
class Export_test : public beast::unit_test::suite
{
using KeyPair = std::pair<PublicKey, SecretKey>;
// a payment from `from` on the other network, ready to be multisigned
static STObject
payment(
AccountID const& from,
std::uint32_t drops,
std::optional<std::uint32_t> ticket = std::nullopt,
std::optional<std::uint32_t> boundTo = std::nullopt)
{
STObject t(sfExportedTxn);
t.setFieldU16(sfTransactionType, ttPAYMENT);
t.setAccountID(sfAccount, from);
t.setAccountID(sfDestination, noAccount());
t.setFieldAmount(sfAmount, XRPAmount{drops});
t.setFieldAmount(sfFee, XRPAmount{1000});
t.setFieldU32(sfSequence, ticket ? 0 : 5);
t.setFieldVL(sfSigningPubKey, Slice{});
if (ticket)
t.setFieldU32(sfTicketSequence, *ticket);
if (boundTo)
t.setFieldU32(sfOperationLimit, *boundTo);
return t;
}
static uint256
txid(STObject const& inner)
{
Serializer s;
inner.add(s);
return STTx{SerialIter{s.slice()}}.getTransactionID();
}
static STArray
signerEntries(std::vector<AccountID> const& accounts)
{
STArray a(sfSignerEntries);
for (auto const& acc : accounts)
{
STObject e(sfSignerEntry);
e.setAccountID(sfAccount, acc);
e.setFieldU16(sfSignerWeight, 1);
a.push_back(std::move(e));
}
return a;
}
// `acc`'s signer list on the other network, as Import records it
static void
setSignerList(
OpenView& view,
AccountID const& acc,
STArray const& entries,
std::uint32_t quorum = 1)
{
auto sle = std::make_shared<SLE>(*view.read(keylet::account(acc)));
auto l = STObject::makeInnerObject(sfExportSignerList);
l[sfSignerQuorum] = quorum;
l.setFieldArray(sfSignerEntries, entries);
l[sfLedgerSequence] = 1;
l[sfTransactionIndex] = 0;
sle->peekFieldObject(sfExportSignerList) = std::move(l);
view.rawReplace(sle);
}
// ttEXPORT's doApply, as the hook's emission would reach it
static TER
applyExport(jtx::Env& env, OpenView& view, STTx const& tx)
{
ApplyContext ctx(
env.app(),
view,
tx,
tesSUCCESS,
XRPAmount{0},
tapNONE,
env.journal);
Export ex(ctx);
auto const ter = ex.doApply();
if (isTesSuccess(ter))
ctx.apply(ter);
return ter;
}
// ttEXPORT as a hook would emit it
static STTx
exportTx(
jtx::Env& env,
AccountID const& from,
STObject const& inner,
std::uint32_t flags = 0,
bool emitted = true)
{
auto const nid = env.app().config().NETWORK_ID;
auto const seq = env.current()->seq();
return STTx(ttEXPORT, [&](STObject& o) {
o.setAccountID(sfAccount, from);
o.setFieldAmount(sfFee, XRPAmount{100000});
o.setFieldU32(sfSequence, 0);
o.setFieldVL(sfSigningPubKey, Slice{});
o.setFieldU32(sfFirstLedgerSequence, seq);
o.setFieldU32(sfLastLedgerSequence, seq + 5);
if (flags)
o.setFieldU32(sfFlags, flags);
if (nid > 1024)
o.setFieldU32(sfNetworkID, nid);
o.set(std::make_unique<STObject>(inner));
if (emitted)
{
auto d = std::make_unique<STObject>(sfEmitDetails);
d->setFieldU32(sfEmitGeneration, 1);
d->setFieldU64(sfEmitBurden, 1);
d->setFieldH256(sfEmitParentTxnID, uint256{1});
d->setFieldH256(sfEmitNonce, uint256{2});
d->setFieldH256(sfEmitHookHash, uint256{3});
d->setAccountID(sfEmitCallback, from);
o.set(std::move(d));
}
});
}
static STTx
signTx(
std::uint32_t nid,
std::uint32_t created,
uint256 const& id,
KeyPair const& kp,
Slice const& data)
{
auto const acc = calcAccountID(kp.first);
return STTx(ttEXPORT_SIGN, [&](STObject& o) {
o[sfAccount] = AccountID();
if (nid > 1024)
o[sfNetworkID] = nid;
o[sfLedgerSequence] = created;
o[sfTransactionHash] = id;
auto& s = o.peekFieldObject(sfSigner);
s[sfAccount] = acc;
s[sfSigningPubKey] = kp.first.slice();
s[sfTxnSignature] = sign(kp.first, kp.second, data);
});
}
// a pending export, as ttEXPORT would have left it in ledger `created`
static Keylet
pending(
OpenView& view,
AccountID const& owner,
STObject const& inner,
std::uint32_t created,
STArray const& entries)
{
auto const k = keylet::exportedTxn(created, txid(inner));
auto const sle = std::make_shared<SLE>(k);
sle->setAccountID(sfOwner, owner);
sle->set(std::make_unique<STObject>(inner));
sle->setFieldH256(sfTransactionHash, txid(inner));
sle->setFieldU32(sfLedgerSequence, created);
sle->setFieldArray(sfSignerEntries, entries);
sle->setFieldU32(sfSignerQuorum, 1);
view.rawInsert(sle);
return k;
}
static std::vector<AccountID>
signersOf(ReadView const& view, Keylet const& k)
{
std::vector<AccountID> out;
auto const sle = view.read(k);
if (!sle)
return out;
auto const& inner =
sle->peekAtField(sfExportedTxn).downcast<STObject>();
if (inner.isFieldPresent(sfSigners))
for (auto const& s : inner.getFieldArray(sfSigners))
out.push_back(s[sfAccount]);
return out;
}
// the metadata node an export transaction left on ltEXPORTED_TXN
static std::optional<STObject>
exportNode(OpenView const& view, TxType type, SField const& nodeType)
{
for (auto const& [tx, meta] : view.txs)
{
if (tx->getTxnType() != type || !meta)
continue;
for (auto const& n : meta->getFieldArray(sfAffectedNodes))
if (n.getFName() == nodeType &&
n.getFieldU16(sfLedgerEntryType) == ltEXPORTED_TXN)
return n;
}
return std::nullopt;
}
void
testPreflight()
{
testcase("export preflight");
using namespace jtx;
Env env{*this, supported_amendments() | featureExport};
Account const alice{"alice"};
auto const nid = env.app().config().NETWORK_ID;
auto const a = calcAccountID(randomKeyPair(KeyType::ed25519).first);
auto const b = calcAccountID(randomKeyPair(KeyType::ed25519).first);
auto const pf = [&](STTx const& tx) {
return preflight(
env.app(),
env.current()->rules(),
tx,
tapPREFLIGHT_EMIT,
env.journal)
.ter;
};
auto const good = payment(alice.id(), 10);
BEAST_EXPECT(pf(exportTx(env, alice.id(), good)) == tesSUCCESS);
// emitted only
BEAST_EXPECT(
isTemMalformed(pf(exportTx(env, alice.id(), good, 0, false))));
BEAST_EXPECT(
pf(exportTx(env, alice.id(), good, 0x00010000)) == temINVALID_FLAG);
// the inner transaction: from the hook account, unsigned, elsewhere
BEAST_EXPECT(
pf(exportTx(env, alice.id(), payment(a, 10))) == temMALFORMED);
{
auto signed_ = good;
signed_.setFieldVL(sfTxnSignature, Blob{1, 2, 3});
BEAST_EXPECT(
pf(exportTx(env, alice.id(), signed_)) == temMALFORMED);
}
{
auto here = good;
here.setFieldU32(sfNetworkID, nid);
BEAST_EXPECT(pf(exportTx(env, alice.id(), here)) == temMALFORMED);
}
// a SignerListSet must come back, or the list here would go stale
{
STObject sls(sfExportedTxn);
sls.setFieldU16(sfTransactionType, ttSIGNER_LIST_SET);
sls.setAccountID(sfAccount, alice.id());
sls.setFieldAmount(sfFee, XRPAmount{1000});
sls.setFieldU32(sfSequence, 0);
sls.setFieldU32(sfSignerQuorum, 0);
sls.setFieldVL(sfSigningPubKey, Slice{});
BEAST_EXPECT(pf(exportTx(env, alice.id(), sls)) == temMALFORMED);
sls.setFieldU32(sfTicketSequence, 3);
BEAST_EXPECT(pf(exportTx(env, alice.id(), sls)) == temMALFORMED);
sls.setFieldU32(sfOperationLimit, nid);
BEAST_EXPECT(pf(exportTx(env, alice.id(), sls)) == tesSUCCESS);
}
// one base fee per signer in the account's list
{
env.fund(XRP(10000), alice);
env.close();
OpenView view(open_ledger, env.closed()->rules(), env.closed());
auto const fee = [&](std::vector<AccountID> const& signers) {
setSignerList(view, alice.id(), signerEntries(signers));
return Export::calculateBaseFee(
view, exportTx(env, alice.id(), good));
};
BEAST_EXPECT(fee({a, b}) - fee({a}) == view.fees().base);
// one bound back pays for the free Import that returns it
setSignerList(view, alice.id(), signerEntries({a}));
BEAST_EXPECT(
Export::calculateBaseFee(
view,
exportTx(env, alice.id(), payment(alice.id(), 1, 7, nid))) -
fee({a}) ==
view.fees().base * 10);
}
{
Env off{*this, supported_amendments() - featureExport};
BEAST_EXPECT(
preflight(
off.app(),
off.current()->rules(),
exportTx(off, alice.id(), good),
tapPREFLIGHT_EMIT,
off.journal)
.ter == temDISABLED);
}
}
void
testShadowTicket()
{
testcase("shadow tickets");
using namespace jtx;
Env env{*this, supported_amendments() | featureExport};
Account const alice{"alice"};
env.fund(XRP(10000), alice);
env.close();
auto const nid = env.app().config().NETWORK_ID;
OpenView view(open_ledger, env.closed()->rules(), env.closed());
setSignerList(
view,
alice.id(),
signerEntries(
{calcAccountID(randomKeyPair(KeyType::ed25519).first)}));
auto const owners = [&]() {
return view.read(keylet::account(alice.id()))
->getFieldU32(sfOwnerCount);
};
auto const apply = [&](STObject const& inner) {
return applyExport(env, view, exportTx(env, alice.id(), inner));
};
auto const ticket = [&](std::uint32_t t) {
return view.read(keylet::shadowTicket(alice.id(), t));
};
auto const before = owners();
// not ticketed, or ticketed but not bound back here: no ticket
BEAST_EXPECT(apply(payment(alice.id(), 1)) == tesSUCCESS);
BEAST_EXPECT(apply(payment(alice.id(), 2, 7)) == tesSUCCESS);
BEAST_EXPECT(apply(payment(alice.id(), 3, 7, nid + 1)) == tesSUCCESS);
BEAST_EXPECT(!ticket(7));
BEAST_EXPECT(owners() == before);
// ticketed and bound back: a ticket, held against the reserve
auto const first = payment(alice.id(), 4, 7, nid);
BEAST_EXPECT(apply(first) == tesSUCCESS);
BEAST_EXPECT(ticket(7));
BEAST_EXPECT(
ticket(7) && (*ticket(7))[sfTransactionHash] == txid(first));
BEAST_EXPECT(owners() == before + 1);
// both name the hook its Import (or a close below quorum) calls back
BEAST_EXPECT(ticket(7) && (*ticket(7))[~sfEmitHookHash] == uint256{3});
// the pending export is attributed to the exporter
auto const k = keylet::exportedTxn(view.seq(), txid(first));
BEAST_EXPECT(view.read(k) && (*view.read(k))[sfOwner] == alice.id());
BEAST_EXPECT(
view.read(k) && (*view.read(k))[~sfEmitHookHash] == uint256{3});
// an export not bound back calls back now, so names no hook
auto const k2 =
keylet::exportedTxn(view.seq(), txid(payment(alice.id(), 1)));
BEAST_EXPECT(
view.read(k2) && !view.read(k2)->isFieldPresent(sfEmitHookHash));
// the same export again in this ledger
BEAST_EXPECT(apply(first) == tecDUPLICATE);
// exporting again with the ticket repoints it
auto const second = payment(alice.id(), 5, 7, nid);
BEAST_EXPECT(apply(second) == tesSUCCESS);
BEAST_EXPECT(
ticket(7) && (*ticket(7))[sfTransactionHash] == txid(second));
BEAST_EXPECT(owners() == before + 1);
}
void
testSignerList()
{
testcase("export signer list");
using namespace jtx;
Env env{*this, supported_amendments() | featureExport};
Account const alice{"alice"}, bob{"bob"};
env.fund(XRP(10000), alice, bob);
env.close();
auto const nid = env.app().config().NETWORK_ID;
auto const a = calcAccountID(randomKeyPair(KeyType::ed25519).first);
OpenView view(open_ledger, env.closed()->rules(), env.closed());
setSignerList(view, alice.id(), signerEntries({a}), 1);
// only an account whose signer list is known can export
auto const preclaim = [&](AccountID const& from) {
return Export::preclaim(PreclaimContext(
env.app(),
view,
tesSUCCESS,
exportTx(env, from, payment(from, 1)),
tapNONE));
};
BEAST_EXPECT(preclaim(alice.id()) == tesSUCCESS);
BEAST_EXPECT(preclaim(bob.id()) == tecNO_TARGET);
// the pending export snapshots it
auto const pay = payment(alice.id(), 1);
BEAST_EXPECT(
applyExport(env, view, exportTx(env, alice.id(), pay)) ==
tesSUCCESS);
if (auto const p =
view.read(keylet::exportedTxn(view.seq(), txid(pay)));
BEAST_EXPECT(p))
{
BEAST_EXPECT(p->getFieldArray(sfSignerEntries).size() == 1);
BEAST_EXPECT((*p)[sfSignerQuorum] == 1);
}
// one UNL validator with an export key on its account
auto const master = randomKeyPair(KeyType::secp256k1);
auto const key = randomKeyPair(KeyType::ed25519);
{
auto unl = std::make_shared<SLE>(keylet::UNLReport());
STArray avs(sfActiveValidators);
avs.push_back(STObject::makeInnerObject(sfActiveValidator));
avs.back()[sfPublicKey] = master.first.slice();
unl->setFieldArray(sfActiveValidators, avs);
if (view.exists(keylet::UNLReport()))
view.rawReplace(unl);
else
view.rawInsert(unl);
auto const id = calcAccountID(master.first);
auto acc = std::make_shared<SLE>(keylet::account(id));
acc->setAccountID(sfAccount, id);
acc->setFieldAmount(sfBalance, XRPAmount{100'000'000});
acc->setFieldU32(sfSequence, 1);
STArray keys(sfExportKeys);
keys.push_back(STObject::makeInnerObject(sfExportKeyEntry));
keys.back().setFieldVL(sfExportKey, key.first.slice());
keys.back().setFieldU32(sfCloseTime, 0);
acc->setFieldArray(sfExportKeys, keys);
view.rawInsert(acc);
}
// a SignerListSet without entries moves onto the current export keys
STObject sls(sfExportedTxn);
sls.setFieldU16(sfTransactionType, ttSIGNER_LIST_SET);
sls.setAccountID(sfAccount, alice.id());
sls.setFieldAmount(sfFee, XRPAmount{1000});
sls.setFieldU32(sfSequence, 0);
sls.setFieldU32(sfTicketSequence, 3);
sls.setFieldU32(sfOperationLimit, nid);
sls.setFieldU32(sfSignerQuorum, 0);
sls.setFieldVL(sfSigningPubKey, Slice{});
BEAST_EXPECT(
applyExport(env, view, exportTx(env, alice.id(), sls)) ==
tesSUCCESS);
auto const st = view.read(keylet::shadowTicket(alice.id(), 3));
auto const p = st ? view.read(keylet::exportedTxn(
view.seq(), (*st)[sfTransactionHash]))
: nullptr;
BEAST_EXPECT(p);
if (!p)
return;
auto const& inner = p->peekAtField(sfExportedTxn).downcast<STObject>();
BEAST_EXPECT(inner[sfSignerQuorum] == 1);
BEAST_EXPECT(
inner.getFieldArray(sfSignerEntries).size() == 1 &&
inner.getFieldArray(sfSignerEntries)[0][sfAccount] ==
calcAccountID(key.first));
// signed by the list the account holds now, not the new one
BEAST_EXPECT(
p->getFieldArray(sfSignerEntries).size() == 1 &&
p->getFieldArray(sfSignerEntries)[0][sfAccount] == a);
}
void
testSignAndFinal()
{
testcase("export sign and final");
using namespace jtx;
Env env{
*this,
envconfig(validator, ""),
supported_amendments() | featureExport};
Account const alice{"alice"};
env.fund(XRP(10000), alice);
for (int i = 0; i < 6; ++i)
env.close();
auto const nid = env.app().config().NETWORK_ID;
// this validator's export key, and another listed one held here
auto const nominee = env.app().getExportKeys().nominate(*env.closed());
BEAST_EXPECT(nominee);
if (!nominee)
return;
auto const ours = calcAccountID(nominee->first);
auto const other = randomKeyPair(KeyType::ed25519);
auto const stranger = randomKeyPair(KeyType::ed25519);
auto const entries = signerEntries({ours, calcAccountID(other.first)});
OpenView view(open_ledger, env.closed()->rules(), env.closed());
auto const created = env.closed()->seq();
auto const inner = payment(alice.id(), 10);
auto const id = txid(inner);
auto const k = pending(view, alice.id(), inner, created, entries);
auto const apply = [&](STTx const& tx) {
return ripple::apply(env.app(), view, tx, tapNONE, env.journal).ter;
};
auto const data = [&](KeyPair const& kp) {
return buildMultiSigningData(inner, calcAccountID(kp.first));
};
// the validator signs once
BEAST_EXPECT(Export::accept(env.app(), view, env.journal));
BEAST_EXPECT(signersOf(view, k) == std::vector<AccountID>{ours});
BEAST_EXPECT(!Export::accept(env.app(), view, env.journal));
// and its signature is attributed to the exporter
auto const signNode = exportNode(view, ttEXPORT_SIGN, sfModifiedNode);
BEAST_EXPECT(
signNode &&
signNode->peekAtField(sfFinalFields)
.downcast<STObject>()
.getAccountID(sfOwner) == alice.id());
// only listed accounts, with good signatures, once each
BEAST_EXPECT(
apply(signTx(nid, created, id, stranger, data(stranger).slice())) ==
tefBAD_AUTH);
BEAST_EXPECT(
apply(signTx(nid, created, id, other, data(stranger).slice())) ==
tefBAD_SIGNATURE);
BEAST_EXPECT(
apply(signTx(nid, created, id, other, data(other).slice())) ==
tesSUCCESS);
BEAST_EXPECT(
apply(signTx(nid, created, id, other, data(other).slice())) ==
tefALREADY);
// kept sorted, as the other network requires
auto sorted = std::vector<AccountID>{ours, calcAccountID(other.first)};
std::sort(sorted.begin(), sorted.end());
BEAST_EXPECT(signersOf(view, k) == sorted);
// `window` ledgers on, every node finalizes it
auto const old = payment(alice.id(), 20);
auto const createdOld = view.seq() - Export::window;
auto const kOld = pending(view, alice.id(), old, createdOld, entries);
auto const finalTx = [&](AccountID const& owner) {
return STTx(ttEXPORT_FINAL, [&](STObject& o) {
o[sfAccount] = AccountID();
o[sfOwner] = owner;
o[sfLedgerSequence] = createdOld;
o[sfTransactionHash] = txid(old);
});
};
BEAST_EXPECT(apply(finalTx(ours)) == tefFAILURE);
BEAST_EXPECT(view.exists(kOld));
BEAST_EXPECT(Export::accept(env.app(), view, env.journal));
BEAST_EXPECT(!view.exists(kOld));
BEAST_EXPECT(view.exists(k));
// the result is in the DeletedNode, attributed to the exporter
auto const finalNode = exportNode(view, ttEXPORT_FINAL, sfDeletedNode);
BEAST_EXPECT(finalNode);
if (finalNode)
{
auto const& ff =
finalNode->peekAtField(sfFinalFields).downcast<STObject>();
BEAST_EXPECT(ff.getAccountID(sfOwner) == alice.id());
BEAST_EXPECT(ff.isFieldPresent(sfExportedTxn));
}
}
public:
void
run() override
{
testPreflight();
testShadowTicket();
testSignerList();
testSignAndFinal();
}
};
BEAST_DEFINE_TESTSUITE(Export, app, ripple);
} // namespace test
} // namespace ripple
+31
View File
@@ -3104,6 +3104,37 @@ private:
pass();
}
// Export, ExportSign, ExportFinal
// No TSHs: the exporting hook learns the outcome of its exports through
// cbak, not hook(). See getTransactionalStakeHolders and Export_test.
void
testExportTSH(FeatureBitset features)
{
testcase("export tsh");
// hook-emitted transaction
pass();
}
void
testExportSignTSH(FeatureBitset features)
{
testcase("export sign tsh");
// validator-injected transaction
pass();
}
void
testExportFinalTSH(FeatureBitset features)
{
testcase("export final tsh");
// pseudo transaction
pass();
}
// GenesisMint
// | otxn | tsh | mint |
// | A | A | S |
+134
View File
@@ -26,6 +26,8 @@
#include <xrpld/app/misc/NegativeUNLVote.h>
#include <xrpld/app/misc/ValidatorList.h>
#include <xrpld/app/tx/apply.h>
#include <xrpld/app/tx/applySteps.h>
#include <xrpld/app/tx/detail/Export.h>
#include <xrpld/core/ConfigSections.h>
#include <xrpld/ledger/View.h>
#include <xrpl/basics/Log.h>
@@ -1293,7 +1295,139 @@ class UNLReportVoteLocalReliability_test : public beast::unit_test::suite
}
};
/*
* featureExport: validators nominating export keys in their validations get
* one ttUNL_REPORT ExportKeyReport each, carrying their key and proof.
*/
class UNLReportVoteExportKeys_test : public beast::unit_test::suite
{
void
testDoVoting()
{
testcase("Export key reports");
auto const numNodes = 10;
URNetworkHistory history = {
*this, {numNodes, 0, false, false, false, {}}};
BEAST_EXPECT(history.goodHistory);
if (!history.goodHistory)
return;
auto& env = history.env;
auto const nid = env.app().config().NETWORK_ID;
auto const signer = randomKeyPair(KeyType::secp256k1);
// every second validator nominates a key
std::vector<std::optional<std::pair<PublicKey, SecretKey>>> eks;
for (int i = 0; i < numNodes; ++i)
eks.push_back(
i % 2 ? std::nullopt
: std::optional(randomKeyPair(KeyType::ed25519)));
auto const proofOf = [&](int i) {
auto const& [pk, sk] = *eks[i];
auto const p = sign(
pk,
sk,
exportKeyProofData(history.UNLKeys[i], pk, nid).slice());
return Blob(p.begin(), p.end());
};
// as walkHistoryAndAddValidations, with the nominations attached
auto const& ledgers = history.history;
std::size_t curr = ledgers.size() > 257 ? ledgers.size() - 257 : 0;
for (; curr != ledgers.size(); ++curr)
{
auto const& l = ledgers[curr];
for (int i = 0; i < numNodes; ++i)
{
RCLValidation v(std::make_shared<STValidation>(
env.app().timeKeeper().now(),
signer.first,
signer.second,
history.UNLNodeIDs[i],
[&](STValidation& sv) {
sv.setFieldH256(sfLedgerHash, l->info().hash);
sv.setFieldU32(sfLedgerSequence, l->seq());
sv.setFlag(vfFullValidation);
if (eks[i])
{
sv.setFieldVL(sfExportKey, eks[i]->first.slice());
sv.setFieldVL(sfExportKeyProof, proofOf(i));
}
}));
v.setTrusted();
history.validations.add(history.UNLNodeIDs[i], v);
}
}
NegativeUNLVote vote(history.UNLNodeIDs[0], env.journal, env.app());
auto txSet = std::make_shared<SHAMap>(
SHAMapType::TRANSACTION, env.app().getNodeFamily());
vote.doVoting(
history.lastLedger(),
history.UNLKeySet,
history.validations,
txSet);
std::size_t active = 0, reports = 0;
for (auto i = txSet->begin(); i != txSet->end(); ++i)
{
SerialIter sit(i->slice());
STTx const tx(sit);
if (tx.getTxnType() != ttUNL_REPORT)
continue;
if (tx.isFieldPresent(sfActiveValidator))
++active;
if (!tx.isFieldPresent(sfExportKeyReport))
continue;
++reports;
BEAST_EXPECT(!tx.isFieldPresent(sfActiveValidator));
BEAST_EXPECT(!tx.isFieldPresent(sfImportVLKey));
auto const& r =
tx.peekAtField(sfExportKeyReport).downcast<STObject>();
auto const it = std::find(
history.UNLKeys.begin(),
history.UNLKeys.end(),
PublicKey(r[sfPublicKey]));
BEAST_EXPECT(it != history.UNLKeys.end());
if (it == history.UNLKeys.end())
continue;
auto const idx = it - history.UNLKeys.begin();
BEAST_EXPECT(eks[idx].has_value());
if (!eks[idx])
continue;
BEAST_EXPECT(
r.getFieldVL(sfExportKey) ==
Blob(eks[idx]->first.begin(), eks[idx]->first.end()));
BEAST_EXPECT(r.getFieldVL(sfExportKeyProof) == proofOf(idx));
// and it passes preflight, proof and all
BEAST_EXPECT(
preflight(
env.app(),
history.lastLedger()->rules(),
tx,
tapNONE,
env.journal)
.ter == tesSUCCESS);
}
BEAST_EXPECT(active == numNodes);
BEAST_EXPECT(reports == numNodes / 2);
}
void
run() override
{
testDoVoting();
}
};
BEAST_DEFINE_TESTSUITE(UNLReport, ledger, ripple);
BEAST_DEFINE_TESTSUITE(UNLReportVoteExportKeys, consensus, ripple);
BEAST_DEFINE_TESTSUITE(UNLReportNoAmendment, ledger, ripple);
BEAST_DEFINE_TESTSUITE(UNLReportFork, consensus, ripple);
BEAST_DEFINE_TESTSUITE_PRIO(UNLReportVoteGoodScore, consensus, ripple, 1);
+26
View File
@@ -35,6 +35,7 @@
#include <xrpld/app/misc/TxQ.h>
#include <xrpld/app/misc/ValidatorKeys.h>
#include <xrpld/app/misc/ValidatorList.h>
#include <xrpld/app/tx/detail/Export.h>
#include <xrpld/consensus/LedgerTiming.h>
#include <xrpld/overlay/Overlay.h>
#include <xrpld/overlay/predicates.h>
@@ -193,6 +194,18 @@ RCLConsensus::Adaptor::share(RCLCxPeerPos const& peerPos)
void
RCLConsensus::Adaptor::share(RCLCxTx const& tx)
{
// Export signatures can only be checked against the ledger, so peers
// would take them for badly signed transactions. They need no relay:
// peers have them from the proposed set (see Export.h).
try
{
if (STTx(SerialIter{tx.tx_->slice()}).getTxnType() == ttEXPORT_SIGN)
return;
}
catch (std::exception const&)
{
}
// If we didn't relay this transaction recently, relay it to all peers
if (app_.getHashRouter().shouldRelay(tx.id()))
{
@@ -1005,6 +1018,19 @@ RCLConsensus::Adaptor::validate(
v.setFieldU32(sfLoadFee, fee);
}
// Nominate our export key on flag ledgers, which the next flag
// ledger's UNL report window covers (see NegativeUNLVote).
if (ledger.ledger_->isFlagLedger() &&
ledger.ledger_->rules().enabled(featureExport))
{
if (auto const n =
app_.getExportKeys().nominate(*ledger.ledger_))
{
v.setFieldVL(sfExportKey, n->first.slice());
v.setFieldVL(sfExportKeyProof, n->second);
}
}
// If the next ledger is a flag ledger, suggest fee changes and
// new features:
if (ledger.ledger_->isVotingLedger())
+5
View File
@@ -351,6 +351,11 @@ public:
Expected<std::shared_ptr<Transaction>, HookReturnCode>
emit(Slice const& txBlob) const;
// featureExport: emit a ttEXPORT carrying txBlob, a transaction for the
// other network (Account and SigningPubKey filled in if absent)
Expected<std::shared_ptr<Transaction>, HookReturnCode>
xport(Slice const& txBlob) const;
Expected<uint64_t, HookReturnCode>
etxn_burden() const;
+29
View File
@@ -848,6 +848,35 @@ HookAPI::emit(Slice const& txBlob) const
return tpTrans;
}
Expected<std::shared_ptr<Transaction>, HookReturnCode>
HookAPI::xport(Slice const& txBlob) const
{
Serializer s;
try
{
SerialIter sit{txBlob};
STObject inner{sit, sfExportedTxn};
if (!inner.isFieldPresent(sfAccount))
inner[sfAccount] = hookCtx.result.account;
if (!inner.isFieldPresent(sfSigningPubKey))
inner[sfSigningPubKey] = Slice{};
STObject outer{sfGeneric};
outer[sfTransactionType] = static_cast<std::uint16_t>(ttEXPORT);
outer.emplace_back(std::move(inner));
outer.add(s);
}
catch (std::exception const&)
{
return Unexpected(INVALID_ARGUMENT);
}
// prepare() fills in what emit() requires, including the export's fee
auto const blob = prepare(s.slice());
if (!blob)
return Unexpected(blob.error());
return emit(makeSlice(*blob));
}
Expected<uint64_t, HookReturnCode>
HookAPI::etxn_burden() const
{
+37
View File
@@ -77,6 +77,12 @@ getTransactionalStakeHolders(STTx const& tx, ReadView const& rv)
break;
}
// the exporter hears of its exports through cbak, not hook()
case ttEXPORT:
case ttEXPORT_SIGN:
case ttEXPORT_FINAL:
break;
case ttREMIT: {
if (destAcc)
ADD_TSH(*destAcc, tshSTRONG);
@@ -2707,6 +2713,37 @@ DEFINE_HOOK_FUNCTION(
HOOK_TEARDOWN();
}
/* Export a transaction for the other network: wrap it in a ttEXPORT and emit
* that. Writes the ttEXPORT's id. */
DEFINE_HOOK_FUNCTION(
int64_t,
xport,
uint32_t write_ptr,
uint32_t write_len,
uint32_t read_ptr,
uint32_t read_len)
{
HOOK_SETUP(); // populates memory_ctx, memory, memory_length, applyCtx,
// hookCtx on current stack
if (NOT_IN_BOUNDS(read_ptr, read_len, memory_length) ||
NOT_IN_BOUNDS(write_ptr, write_len, memory_length))
return OUT_OF_BOUNDS;
if (write_len < 32)
return TOO_SMALL;
auto const res = api.xport(ripple::Slice{memory + read_ptr, read_len});
if (!res)
return res.error();
std::memcpy(memory + write_ptr, (*res)->getID().data(), 32);
hookCtx.result.emittedTxn.push(*res);
return uint64_t{32};
HOOK_TEARDOWN();
}
/* Emit a transaction from this hook. Transaction must be in STObject form,
* fully formed and valid. XRPLD does not modify transactions it only checks
* them for validity. */
+4 -2
View File
@@ -128,8 +128,10 @@ OpenLedger::accept(
auto const& tx = txpair.first;
auto const txId = tx->getTransactionID();
// skip emitted txns
if (tx->isFieldPresent(sfEmitDetails))
// skip emitted txns, and export signatures, which peers can't
// validate and which reach every open ledger as disputes anyway
if (tx->isFieldPresent(sfEmitDetails) ||
tx->getTxnType() == ttEXPORT_SIGN)
continue;
if (auto const toSkip = app.getHashRouter().shouldRelay(txId))
+15
View File
@@ -49,6 +49,7 @@
#include <xrpld/app/rdb/RelationalDatabase.h>
#include <xrpld/app/rdb/Wallet.h>
#include <xrpld/app/tx/apply.h>
#include <xrpld/app/tx/detail/Export.h>
#include <xrpld/core/DatabaseCon.h>
#include <xrpld/nodestore/DummyScheduler.h>
#include <xrpld/overlay/Cluster.h>
@@ -189,6 +190,7 @@ public:
CachedSLEs cachedSLEs_;
std::optional<std::pair<PublicKey, SecretKey>> nodeIdentity_;
ValidatorKeys const validatorKeys_;
ExportKeys exportKeys_;
std::unique_ptr<Resource::Manager> m_resourceManager;
@@ -238,6 +240,9 @@ public:
std::unique_ptr<GRPCServer> grpcServer_;
// last, so its threads stop before anything they use is destroyed
std::unique_ptr<ExportRelay> exportRelay_;
//--------------------------------------------------------------------------
static std::size_t
@@ -345,6 +350,8 @@ public:
, validatorKeys_(*config_, m_journal)
, exportKeys_(*config_, validatorKeys_, logs_->journal("ExportKeys"))
, m_resourceManager(Resource::make_Manager(
m_collectorManager->collector(),
logs_->journal("Resource")))
@@ -579,6 +586,12 @@ public:
return validatorKeys_.keys->publicKey;
}
ExportKeys&
getExportKeys() override
{
return exportKeys_;
}
NetworkOPs&
getOPs() override
{
@@ -1567,6 +1580,7 @@ ApplicationImp::start(bool withTimers)
ledgerCleaner_->start();
perfLog_->start();
exportRelay_ = makeExportRelay(*this);
}
void
@@ -1633,6 +1647,7 @@ ApplicationImp::run()
mValidations.flush();
validatorSites_->stop();
exportRelay_.reset();
// TODO Store manifests in manifests.sqlite instead of wallet.db
validatorManifests_->save(
+4
View File
@@ -82,6 +82,7 @@ class LedgerReplayer;
class LoadManager;
class ManifestCache;
class ValidatorKeys;
class ExportKeys;
class NetworkOPs;
class OpenLedger;
class OrderBookDB;
@@ -237,6 +238,9 @@ public:
virtual std::optional<PublicKey const>
getValidationPublicKey() const = 0;
virtual ExportKeys&
getExportKeys() = 0;
virtual Resource::Manager&
getResourceManager() = 0;
virtual PathRequests&
+665
View File
@@ -0,0 +1,665 @@
//------------------------------------------------------------------------------
/*
This file is part of rippled: https://github.com/ripple/rippled
Copyright (c) 2026 XRPL-Labs
Permission to use, copy, modify, and/or distribute this software for any
purpose with or without fee is hereby granted, provided that the above
copyright notice and this permission notice appear in all copies.
THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
ANY SPECIAL , DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
*/
//==============================================================================
#include <xrpld/app/ledger/Ledger.h>
#include <xrpld/app/ledger/LedgerMaster.h>
#include <xrpld/app/ledger/OpenLedger.h>
#include <xrpld/app/main/Application.h>
#include <xrpld/app/misc/Manifest.h>
#include <xrpld/app/misc/NetworkOPs.h>
#include <xrpld/app/tx/detail/Export.h>
#include <xrpld/core/ConfigSections.h>
#include <xrpld/overlay/Message.h>
#include <xrpld/overlay/detail/Handshake.h>
#include <xrpld/shamap/SHAMapInnerNode.h>
#include <xrpld/shamap/SHAMapLeafNode.h>
#include <xrpl/basics/base64.h>
#include <xrpl/basics/make_SSLContext.h>
#include <xrpl/json/json_reader.h>
#include <xrpl/json/to_string.h>
#include <xrpl/protocol/STValidation.h>
#include <xrpl/protocol/Seed.h>
#include <xrpl/protocol/jss.h>
#include <xrpl/protocol/messages.h>
#include <boost/beast/core/flat_buffer.hpp>
#include <boost/beast/http.hpp>
#include <regex>
#include <thread>
/* [xrpl_relay]: one XRPL peer connection per "host [port]" line, plus
optional vl_url= and network_id= (the XRPL's, default 0).
XRPL to Xahau. The UNL is the newest validator list signed by a key this
network trusts for Import ([import_vl_keys] or UNLReport ImportVLKeys),
from the peer or vl_url. Import consults only the manifests embedded in
it, so manifest gossip is not needed. When a ledger has validations from
80% of it, its header and transaction tree are fetched, and every txn in
it returning a pending export (its shadow ticket exists) is imported.
Xahau to XRPL. On each validated ledger, pending exports whose signatures
meet the exporter's quorum are sent to the peer.
*/
namespace ripple {
namespace {
namespace asio = boost::asio;
namespace http = boost::beast::http;
using namespace std::chrono_literals;
// Every relayer signs the Import bringing an export back with this published
// key, so all build the same bytes and the network keeps one. Import never
// trusts the outer signature of a ticketed XPOP.
std::pair<PublicKey, SecretKey> const&
relayKey()
{
static auto const k =
generateKeyPair(KeyType::ed25519, generateSeed("xahau export relay"));
return k;
}
struct Config
{
Application& app;
std::string vlURL;
std::optional<std::uint32_t> nid;
std::atomic<bool> stop{false};
};
// A TLS connection whose operations are bounded by a deadline and stop
struct Conn
{
asio::io_context ioc;
std::shared_ptr<asio::ssl::context> ctx = make_SSLContext("");
stream_type s{ioc, *ctx};
asio::ip::tcp::endpoint ep;
std::atomic<bool> const& stop;
Conn(
std::string const& host,
std::string const& port,
std::atomic<bool> const& stop)
: stop(stop)
{
// basic_resolver_results inherits its iterator privately (Boost 1.87+),
// so take the first entry explicitly
auto const r = asio::ip::tcp::resolver(ioc).resolve(host, port);
if (r.empty())
Throw<std::runtime_error>("no address for " + host);
ep = r.begin()->endpoint();
io([&](auto h) {
boost::beast::get_lowest_layer(s).async_connect(ep, h);
});
// peers self-sign and validator lists sign themselves
s.set_verify_mode(asio::ssl::verify_none);
SSL_set_tlsext_host_name(s.native_handle(), host.c_str());
io([&](auto h) {
s.async_handshake(asio::ssl::stream_base::client, h);
});
}
std::size_t
io(auto&& start, int secs = 30)
{
boost::system::error_code ec;
std::size_t n = 0;
bool done = false;
start([&](boost::system::error_code e, std::size_t k = 0) {
ec = e, n = k, done = true;
});
ioc.restart();
for (auto const end = std::chrono::steady_clock::now() + 1s * secs;
!done && !stop && std::chrono::steady_clock::now() < end;)
ioc.run_for(200ms);
if (!done)
Throw<std::runtime_error>("timed out");
if (ec)
Throw<boost::system::system_error>(ec);
return n;
}
};
class Peer
{
struct Ledger
{
LedgerIndex seq = 0;
Json::Value vals{Json::objectValue}; // nodepub -> validation hex
int state = 0; // 0 validating, 1 fetching, 2 done
int pending = 0, rounds = 0;
std::optional<LedgerInfo> info;
std::map<uint256, std::shared_ptr<SHAMapTreeNode>> nodes;
};
Config& cfg_;
Application& app_;
beast::Journal const j_;
std::string const host_, port_;
std::function<void(::google::protobuf::Message const&, int)> send_;
Json::Value unl_; // the XPOPs' validation.unl
std::uint32_t vlSeq_ = 0; // its sequence
std::set<PublicKey> keys_; // its validators' signing keys
std::size_t quorum_ = 0;
std::map<uint256, Ledger> ledgers_;
LedgerIndex xahau_ = 0; // the last validated ledger whose exports went
std::chrono::steady_clock::time_point nextVL_{};
public:
Peer(Config& cfg, std::string host, std::string port)
: cfg_(cfg)
, app_(cfg.app)
, j_(app_.journal("ExportRelay"))
, host_(std::move(host))
, port_(std::move(port))
{
}
void
run()
{
while (!cfg_.stop)
{
try
{
session();
}
catch (std::exception const& e)
{
JLOG(j_.warn()) << host_ << ": " << e.what();
}
for (int i = 0; i < 50 && !cfg_.stop; ++i)
std::this_thread::sleep_for(200ms);
}
}
private:
void
session()
{
Conn c(host_, port_, cfg_.stop);
auto const sv = makeSharedValue(c.s, j_);
if (!sv)
Throw<std::runtime_error>("no shared value");
auto req = makeRequest(false, false, false, false, false);
buildHandshake(req, *sv, cfg_.nid, {}, c.ep.address(), app_);
c.io([&](auto h) { http::async_write(c.s, req, h); });
boost::beast::flat_buffer buf;
http_response_type res;
c.io([&](auto h) { http::async_read(c.s, buf, res, h); });
if (res.result() != http::status::switching_protocols)
Throw<std::runtime_error>("refused: " + std::string(res.reason()));
verifyHandshake(res, *sv, cfg_.nid, {}, c.ep.address(), app_);
JLOG(j_.info()) << "connected to " << host_;
send_ = [&](::google::protobuf::Message const& m, int type) {
Message msg(m, type);
auto const& b = msg.getBuffer(compression::Compressed::Off);
c.io([&](auto h) { asio::async_write(c.s, asio::buffer(b), h); });
};
while (!cfg_.stop)
{
if (!cfg_.vlURL.empty() &&
std::chrono::steady_clock::now() >= nextVL_)
fetchVL();
exports();
// frames: 4 byte size (uncompressed: top 6 bits clear), 2 byte type
for (std::uint8_t const* p; buf.size() >= 6; buf.consume(6 + n_))
{
p = static_cast<std::uint8_t const*>(buf.data().data());
n_ = (std::size_t(p[0]) << 24) | (p[1] << 16) | (p[2] << 8) |
p[3];
if (n_ > (64 << 20))
Throw<std::runtime_error>("bad frame");
if (buf.size() < 6 + n_)
break;
dispatch((p[4] << 8) | p[5], p + 6);
}
buf.commit(c.io(
[&](auto h) { c.s.async_read_some(buf.prepare(1 << 16), h); },
90));
}
}
std::size_t n_ = 0; // size of the frame being dispatched
void
dispatch(int type, std::uint8_t const* data)
{
auto const parse = [&](auto& m) {
if (!m.ParseFromArray(data, n_))
Throw<std::runtime_error>("bad message");
return true;
};
protocol::TMPing ping;
protocol::TMValidatorList vl;
protocol::TMValidatorListCollection vls;
protocol::TMValidation val;
protocol::TMLedgerData ld;
switch (type)
{
case protocol::mtPING:
if (parse(ping) && ping.type() == protocol::TMPing::ptPING)
{
ping.set_type(protocol::TMPing::ptPONG);
send_(ping, protocol::mtPING);
}
break;
case protocol::mtVALIDATORLIST:
if (parse(vl))
onVL(
vl.manifest(), vl.blob(), vl.signature(), vl.version());
break;
case protocol::mtVALIDATORLISTCOLLECTION:
if (parse(vls))
for (auto const& b : vls.blobs())
onVL(
b.has_manifest() ? b.manifest() : vls.manifest(),
b.blob(),
b.signature(),
vls.version());
break;
case protocol::mtVALIDATION:
if (parse(val))
onValidation(val.validation());
break;
case protocol::mtLEDGER_DATA:
if (parse(ld))
onLedgerData(ld);
break;
}
}
void
fetchVL()
{
nextVL_ = std::chrono::steady_clock::now() + 1h;
try
{
static std::regex const re{R"(https://([^/:]+)(?::(\d+))?(/.*)?)"};
std::smatch u;
if (!std::regex_match(cfg_.vlURL, u, re))
Throw<std::runtime_error>("not an https URL");
Conn c(u[1].str(), u[2].matched ? u[2].str() : "443", cfg_.stop);
http::request<http::empty_body> req{
http::verb::get, u[3].matched ? u[3].str() : "/", 11};
req.set(http::field::host, u[1].str());
c.io([&](auto h) { http::async_write(c.s, req, h); });
boost::beast::flat_buffer buf;
http::response<http::string_body> res;
c.io([&](auto h) { http::async_read(c.s, buf, res, h); });
// v2 lists carry blobs_v2, v1 lists are their own single blob
Json::Value v, blobs;
if (!Json::Reader().parse(res.body(), v) || !v.isObject())
Throw<std::runtime_error>("not a validator list");
if (!(blobs = v["blobs_v2"]).isArray() || blobs.size() == 0)
(blobs = Json::arrayValue).append(v);
for (auto const& b : blobs)
onVL(
(b.isMember(jss::manifest) ? b : v)[jss::manifest]
.asString(),
b[jss::blob].asString(),
b[jss::signature].asString(),
v[jss::version].asUInt());
}
catch (std::exception const& e)
{
JLOG(j_.warn()) << cfg_.vlURL << ": " << e.what();
}
}
// the key as Import will look it up, if it may sign an XPOP's VL
std::optional<std::string>
trusted(PublicKey const& pk) const
{
for (auto const& [hex, k] : app_.config().IMPORT_VL_KEYS)
if (k == pk)
return hex;
auto const l = app_.getLedgerMaster().getValidatedLedger();
auto const unl = l ? l->read(keylet::UNLReport()) : nullptr;
if (unl && unl->isFieldPresent(sfImportVLKeys))
for (auto const& k : unl->getFieldArray(sfImportVLKeys))
if (PublicKey(k[sfPublicKey]) == pk)
return strHex(pk);
return std::nullopt;
}
void
onVL(
std::string const& manifest,
std::string const& blob,
std::string const& sig,
std::uint32_t version)
{
auto const m = deserializeManifest(base64_decode(manifest));
auto const pk = m ? trusted(m->masterKey) : std::nullopt;
auto const data = base64_decode(blob);
auto const s = strUnHex(sig);
auto const now = app_.timeKeeper().now().time_since_epoch().count();
Json::Value list;
if (!pk || !m->signingKey || !m->verify() || !s ||
!verify(*m->signingKey, makeSlice(data), makeSlice(*s)) ||
!Json::Reader().parse(data, list) ||
!list[jss::validators].isArray() ||
list[jss::sequence].asUInt() <= vlSeq_ ||
list[jss::expiration].asUInt() <= now ||
list[jss::effective].asUInt() > now)
return;
keys_.clear();
for (auto const& v : list[jss::validators])
if (auto const vm = deserializeManifest(
base64_decode(v[jss::manifest].asString()));
vm && vm->signingKey && vm->verify())
keys_.insert(*vm->signingKey);
// counted as Import counts it
quorum_ =
std::max<std::size_t>(1, list[jss::validators].size() * 4 / 5);
vlSeq_ = list[jss::sequence].asUInt();
unl_ = Json::objectValue;
unl_[jss::public_key] = *pk;
unl_[jss::manifest] = manifest;
unl_[jss::blob] = blob;
unl_[jss::signature] = sig;
unl_[jss::version] = version;
JLOG(j_.info()) << "VL " << vlSeq_ << ", " << keys_.size() << " keys";
}
void
request(
uint256 const& h,
protocol::TMLedgerInfoType type,
std::vector<SHAMapNodeID> const& ids = {})
{
protocol::TMGetLedger m;
m.set_itype(type);
m.set_ledgerhash(h.data(), h.size());
for (auto const& id : ids)
m.add_nodeids(id.getRawString());
if (type == protocol::liTX_NODE)
m.set_querydepth(3), ++ledgers_[h].pending;
send_(m, protocol::mtGET_LEDGER);
}
void
onValidation(std::string const& blob)
{
std::optional<STValidation> v;
try
{
SerialIter sit(makeSlice(blob));
v.emplace(
sit, [](PublicKey const& pk) { return calcNodeID(pk); }, false);
if (!keys_.count(v->getSignerPublic()) || !v->isValid())
return;
}
catch (std::exception const&)
{
return;
}
auto const h = v->getLedgerHash();
auto& l = ledgers_[h];
l.seq = (*v)[~sfLedgerSequence].value_or(0);
l.vals[toBase58(TokenType::NodePublic, v->getSignerPublic())] =
strHex(blob);
if (l.state == 0 && l.vals.size() >= quorum_)
{
l.state = 1;
request(h, protocol::liBASE);
request(h, protocol::liTX_NODE, {SHAMapNodeID{}});
}
std::erase_if(ledgers_, [seq = l.seq](auto const& e) {
return e.second.seq + 32 < seq;
});
}
void
onLedgerData(protocol::TMLedgerData const& m)
{
auto const it = m.ledgerhash().size() == 32
? ledgers_.find(uint256::fromVoid(m.ledgerhash().data()))
: ledgers_.end();
if (it == ledgers_.end() || it->second.state != 1)
return;
auto& [h, l] = *it;
try
{
if (m.type() == protocol::liBASE && m.nodes_size() > 0)
{
auto const info =
deserializeHeader(makeSlice(m.nodes(0).nodedata()));
if (calculateLedgerHash(info) == h)
l.info = info;
}
else if (m.type() == protocol::liTX_NODE)
for (l.pending -= l.pending > 0; auto const& n : m.nodes())
if (auto const node = SHAMapTreeNode::makeFromWire(
makeSlice(n.nodedata())))
l.nodes[node->getHash().as_uint256()] = node;
}
catch (std::exception const&)
{
}
if (!l.info || l.pending > 0)
return;
std::vector<SHAMapNodeID> missing;
std::vector<SHAMapLeafNode const*> leaves;
walk(l, l.info->txHash, {}, missing, leaves);
if (missing.empty())
{
l.state = 2;
for (auto const* leaf : leaves)
relay(l, *leaf);
}
else if (++l.rounds < 16)
request(h, protocol::liTX_NODE, missing);
else
l.state = 2;
}
static void
walk(
Ledger const& l,
uint256 const& h,
SHAMapNodeID const& id,
std::vector<SHAMapNodeID>& missing,
std::vector<SHAMapLeafNode const*>& leaves)
{
auto const n = l.nodes.find(h);
if (h == beast::zero)
return;
if (n == l.nodes.end())
return missing.push_back(id);
if (auto const in = dynamic_cast<SHAMapInnerNode const*>(&*n->second))
for (int b = 0; b < 16; ++b)
walk(
l,
in->getChildHash(b).as_uint256(),
id.getChildNodeID(b),
missing,
leaves);
else if (
auto const f = dynamic_cast<SHAMapLeafNode const*>(&*n->second))
leaves.push_back(f);
}
// XPOP proof, list form: each inner node on the path to `key` as its 16
// child hashes, the one on the path expanded unless it is the leaf
static Json::Value
proof(Ledger const& l, uint256 const& h, uint256 const& key, int depth = 0)
{
Json::Value a{Json::arrayValue};
auto const& in = dynamic_cast<SHAMapInnerNode const&>(*l.nodes.at(h));
int const path = (key.data()[depth / 2] >> (depth % 2 ? 0 : 4)) & 0xF;
for (int b = 0; b < 16; ++b)
{
auto const c = in.getChildHash(b).as_uint256();
if (b == path && l.nodes.at(c)->isInner())
a.append(proof(l, c, key, depth + 1));
else
a.append(to_string(c));
}
return a;
}
void
relay(Ledger const& l, SHAMapLeafNode const& leaf)
{
if (leaf.getType() != SHAMapNodeType::tnTRANSACTION_MD)
return;
auto const nid = app_.config().NETWORK_ID;
auto const& item = leaf.peekItem();
try
{
SerialIter sit(item->slice());
auto const tx = sit.getVL();
auto const meta = sit.getVL();
SerialIter st(makeSlice(tx));
STTx const t(st);
auto const ticket = t[~sfTicketSequence];
auto const view = app_.openLedger().current();
// only exports coming back: other imports need their owner's key
if (!ticket || t[~sfOperationLimit] != nid ||
!view->exists(keylet::shadowTicket(t[sfAccount], *ticket)))
return;
auto const& i = *l.info;
Json::Value x;
auto& lg = x[jss::ledger];
lg[jss::index] = i.seq;
lg[jss::coins] = std::to_string(i.drops.drops());
lg[jss::phash] = to_string(i.parentHash);
lg[jss::txroot] = to_string(i.txHash);
lg[jss::acroot] = to_string(i.accountHash);
lg[jss::pclose] = i.parentCloseTime.time_since_epoch().count();
lg[jss::close] = i.closeTime.time_since_epoch().count();
lg[jss::cres] = i.closeTimeResolution.count();
lg[jss::flags] = i.closeFlags;
auto& xt = x[jss::transaction];
xt[jss::blob] = strHex(tx);
xt[jss::meta] = strHex(meta);
xt[jss::proof] = proof(l, i.txHash, item->key());
x[jss::validation][jss::data] = l.vals;
x[jss::validation][jss::unl] = unl_;
auto const xpop = to_string(x);
// not a structured binding: the lambda below captures these
PublicKey const& pk = relayKey().first;
SecretKey const& sk = relayKey().second;
// free and sequence 0: the export paid for it (see isLoopback)
STTx imp(ttIMPORT, [&](auto& o) {
o[sfAccount] = t[sfAccount];
o[sfSequence] = 0;
o[sfFee] = XRPAmount{0};
o[sfSigningPubKey] = pk.slice();
o[sfBlob] = makeSlice(xpop);
if (nid > 1024)
o[sfNetworkID] = nid;
});
imp.sign(pk, sk);
app_.getOPs().submitTransaction(
std::make_shared<STTx const>(std::move(imp)));
JLOG(j_.info()) << "importing " << t.getTransactionID();
}
catch (std::exception const& e)
{
JLOG(j_.debug()) << "skipping " << item->key() << ": " << e.what();
}
}
// on each validated ledger, the pending exports that meet quorum
void
exports()
{
auto const l = app_.getLedgerMaster().getValidatedLedger();
if (!l || l->info().seq == xahau_ || !l->rules().enabled(featureExport))
return;
xahau_ = l->info().seq;
auto const end = keylet::exportedTxn(xahau_ + 1, beast::zero).key;
for (auto k = keylet::exportedTxn(0, beast::zero).key;
auto const next = l->succ(k, end);
k = *next)
{
auto const sle = l->read(Keylet{ltEXPORTED_TXN, *next});
if (!sle)
continue;
if (exportWeight(*sle) < (*sle)[sfSignerQuorum])
continue;
auto const& in =
sle->peekAtField(sfExportedTxn).downcast<STObject>();
Serializer s;
in.add(s);
protocol::TMTransaction m;
m.set_rawtransaction(s.data(), s.size());
m.set_status(protocol::tsNEW);
send_(m, protocol::mtTRANSACTION);
}
}
};
class Relay final : public ExportRelay
{
Config cfg_;
std::vector<std::thread> threads_;
public:
explicit Relay(Application& app, Section const& sec)
: cfg_{app, sec.get<std::string>("vl_url").value_or(""), std::nullopt}
{
if (auto const nid = sec.get<std::uint32_t>("network_id").value_or(0))
cfg_.nid = nid;
for (auto const& line : sec.values())
{
std::istringstream ss(line);
std::string host, port = "51235";
ss >> host >> port;
threads_.emplace_back(
[this, host, port] { Peer(cfg_, host, port).run(); });
}
}
~Relay() override
{
cfg_.stop = true;
for (auto& t : threads_)
t.join();
}
};
} // namespace
std::unique_ptr<ExportRelay>
makeExportRelay(Application& app)
{
if (app.config().standalone() || !app.config().exists(SECTION_XRPL_RELAY))
return nullptr;
return std::make_unique<Relay>(
app, app.config().section(SECTION_XRPL_RELAY));
}
} // namespace ripple
+58 -3
View File
@@ -61,8 +61,12 @@ NegativeUNLVote::doVoting(
// Build a reliability score table of validators. This only fails if
// there isn't enough ledger history; it does NOT apply the local
// reliability gate (that is N-UNL specific, see below).
auto const scoreTable =
buildRawScoreTable(prevLedger, unlNodeIDs, validations);
ExportKeyMap exportKeys;
auto const scoreTable = buildRawScoreTable(
prevLedger,
unlNodeIDs,
validations,
prevLedger->rules().enabled(featureExport) ? &exportKeys : nullptr);
if (!scoreTable)
return;
@@ -137,6 +141,9 @@ NegativeUNLVote::doVoting(
{
addReportingTx(seq, *scoreTable, nidToKeyMap, initialSet);
addImportVLTx(seq, initialSet);
if (!exportKeys.empty())
addExportKeyTx(
seq, *scoreTable, nidToKeyMap, exportKeys, initialSet);
}
else
{
@@ -196,6 +203,43 @@ NegativeUNLVote::addReportingTx(
}
}
void
NegativeUNLVote::addExportKeyTx(
LedgerIndex seq,
hash_map<NodeID, std::uint32_t> const& scoreTable,
hash_map<NodeID, PublicKey> const& nidToKeyMap,
ExportKeyMap const& exportKeys,
std::shared_ptr<SHAMap> const& initalSet)
{
// One report per active validator, kept apart from its ActiveValidator
// report so that disagreement over its export key (some proposers missing
// its validation) can never cost it that entry.
for (auto const& [nid, score] : scoreTable)
{
auto const ek = exportKeys.find(nid);
if (score <= (FLAG_LEDGER_INTERVAL >> 1) || ek == exportKeys.end())
continue;
STObject report(sfExportKeyReport);
report.setFieldVL(sfPublicKey, nidToKeyMap.at(nid));
report.setFieldVL(sfExportKey, ek->second.first);
report.setFieldVL(sfExportKeyProof, ek->second.second);
STTx const tx(ttUNL_REPORT, [&](auto& obj) {
obj.peekFieldObject(sfExportKeyReport) = std::move(report);
obj.setFieldU32(sfLedgerSequence, seq);
});
Serializer s;
tx.add(s);
bool const added = initalSet->addGiveItem(
SHAMapNodeType::tnTRANSACTION_NM,
make_shamapitem(tx.getTransactionID(), s.slice()));
JLOG(added ? j_.debug() : j_.warn())
<< "R-UNL: ledger seq=" << seq << ", add ttUNL_REPORT (export_key) "
<< (added ? "tx " : "tx failed ") << tx.getTransactionID();
}
}
std::vector<STTx>
NegativeUNLVote::generateImportVLVoteTx(
std::map<std::string, PublicKey> const& importVLKeys,
@@ -308,7 +352,8 @@ std::optional<hash_map<NodeID, std::uint32_t>>
NegativeUNLVote::buildRawScoreTable(
std::shared_ptr<Ledger const> const& prevLedger,
hash_set<NodeID> const& unl,
RCLValidations& validations)
RCLValidations& validations,
ExportKeyMap* exportKeys)
{
// Find agreed validation messages received for
// the last FLAG_LEDGER_INTERVAL (i.e. 256) ledgers,
@@ -351,7 +396,17 @@ NegativeUNLVote::buildRawScoreTable(
ledgerAncestors[numAncestors - 1 - i], seq - 2 - i))
{
if (scoreTable.count(v->getNodeID()))
{
++scoreTable[v->getNodeID()];
// newest first, so the first nomination seen is the latest
if (exportKeys && v->isFieldPresent(sfExportKey) &&
v->isFieldPresent(sfExportKeyProof))
exportKeys->try_emplace(
v->getNodeID(),
v->getFieldVL(sfExportKey),
v->getFieldVL(sfExportKeyProof));
}
}
}
+17 -1
View File
@@ -180,6 +180,21 @@ private:
void
addImportVLTx(LedgerIndex seq, std::shared_ptr<SHAMap> const& initalSet);
/** Export key and proof of possession, per validator, as nominated in
its flag ledger validation (featureExport). */
using ExportKeyMap = hash_map<NodeID, std::pair<Blob, Blob>>;
/**
* As above, but report each active validator's nominated export key.
*/
void
addExportKeyTx(
LedgerIndex seq,
hash_map<NodeID, std::uint32_t> const& scoreTable,
hash_map<NodeID, PublicKey> const& nidToKeyMap,
ExportKeyMap const& exportKeys,
std::shared_ptr<SHAMap> const& initalSet);
/**
* Pick one candidate from a vector of candidates.
*
@@ -201,7 +216,8 @@ private:
buildRawScoreTable(
std::shared_ptr<Ledger const> const& prevLedger,
hash_set<NodeID> const& unl,
RCLValidations& validations);
RCLValidations& validations,
ExportKeyMap* exportKeys = nullptr);
/**
* True if the local node issued enough validations in the score table
+5
View File
@@ -1137,6 +1137,11 @@ NetworkOPsImp::submitTransaction(std::shared_ptr<STTx const> const& iTrans)
return;
}
// Export signatures come this way from acquired proposed sets. They can
// only be checked against the ledger, so they are left to consensus.
if (iTrans->getTxnType() == ttEXPORT_SIGN)
return;
// this is an asynchronous interface
auto const trans = sterilize(*iTrans);
+7 -1
View File
@@ -23,6 +23,7 @@
#include <xrpld/app/misc/LoadFeeTrack.h>
#include <xrpld/app/misc/TxQ.h>
#include <xrpld/app/tx/apply.h>
#include <xrpld/app/tx/detail/Export.h>
#include <xrpl/basics/mulDiv.h>
#include <xrpl/protocol/Feature.h>
#include <xrpl/protocol/jss.h>
@@ -1560,6 +1561,8 @@ TxQ::accept(Application& app, OpenView& view)
}
}
ledgerChanged |= Export::accept(app, view, j_);
// Inject emitted transactions if any
if (view.rules().enabled(featureHooks))
do
@@ -1960,7 +1963,10 @@ TxQ::tryDirectApply(
const bool isManifest = view.rules().enabled(featureOnChainManifests) &&
tx->getTxnType() == ttMANIFEST_SET;
const bool bypassQueue = isFirstImport || isManifest;
// An export coming back is pinned to sfSequence 0 too (isLoopback).
const bool loopback = sleAccount && isLoopback(view.rules(), *tx);
const bool bypassQueue = isFirstImport || isManifest || loopback;
// Don't attempt to direct apply if the account is not in the ledger.
if (!sleAccount && !isFirstImport)
+127 -8
View File
@@ -23,6 +23,7 @@
#include <xrpld/app/misc/AmendmentTable.h>
#include <xrpld/app/misc/NetworkOPs.h>
#include <xrpld/app/tx/detail/Change.h>
#include <xrpld/app/tx/detail/Export.h>
#include <xrpld/app/tx/detail/SetHook.h>
#include <xrpld/app/tx/detail/SetSignerList.h>
#include <xrpld/app/tx/detail/XahauGenesis.h>
@@ -91,11 +92,51 @@ Change::preflight(PreflightContext const& ctx)
return temDISABLED;
}
if (!ctx.tx.isFieldPresent(sfActiveValidator) &&
!ctx.tx.isFieldPresent(sfImportVLKey))
bool const hasAV = ctx.tx.isFieldPresent(sfActiveValidator);
bool const hasVL = ctx.tx.isFieldPresent(sfImportVLKey);
if (ctx.tx.isFieldPresent(sfExportKeyReport))
{
if (!ctx.rules.enabled(featureExport))
{
JLOG(ctx.j.warn()) << "Change: Export is not enabled.";
return temDISABLED;
}
// reported on their own, see NegativeUNLVote::addExportKeyTx
if (hasAV || hasVL)
{
JLOG(ctx.j.warn()) << "Change: sfExportKeyReport must be the "
"only report in a UNLReport";
return temMALFORMED;
}
auto const& r =
ctx.tx.peekAtField(sfExportKeyReport).downcast<STObject>();
auto const master = r.getFieldVL(sfPublicKey);
auto const key = r.getFieldVL(sfExportKey);
if (!publicKeyType(makeSlice(master)) ||
!publicKeyType(makeSlice(key)) || master == key)
return temMALFORMED;
// the nominating validator holds the key
PublicKey const m(makeSlice(master)), k(makeSlice(key));
if (!verify(
k,
exportKeyProofData(m, k, ctx.app.config().NETWORK_ID)
.slice(),
makeSlice(r.getFieldVL(sfExportKeyProof)),
true))
{
JLOG(ctx.j.warn()) << "Change: bad export key proof";
return temBAD_SIGNATURE;
}
}
else if (!hasAV && !hasVL)
{
JLOG(ctx.j.warn()) << "Change: UNLReport must specify at least one "
"of sfImportVLKey, sfActiveValidator";
"of sfImportVLKey, sfActiveValidator, "
"sfExportKeyReport";
return temMALFORMED;
}
}
@@ -387,9 +428,79 @@ Change::applyEntropy()
return tesSUCCESS;
}
TER
Change::applyExportKeyReport()
{
auto const& r = ctx_.tx.peekAtField(sfExportKeyReport).downcast<STObject>();
PublicKey const master(makeSlice(r.getFieldVL(sfPublicKey)));
auto const key = r.getFieldVL(sfExportKey);
// Recorded on the validator's master account, which must exist: the
// first ttGENESIS_MINT after a flag ledger funds missing ones. Until
// then the report is simply repeated each flag ledger.
auto acc = view().peek(keylet::account(calcAccountID(master)));
if (!acc)
return tesSUCCESS;
STArray keys = acc->isFieldPresent(sfExportKeys)
? acc->getFieldArray(sfExportKeys)
: STArray(sfExportKeys);
// already the current key, or an older one, and keys never come back
for (auto const& e : keys)
if (e.getFieldVL(sfExportKey) == key)
return tesSUCCESS;
auto const now = static_cast<std::uint32_t>(
view().parentCloseTime().time_since_epoch().count());
STObject entry = STObject::makeInnerObject(sfExportKeyEntry);
entry.setFieldVL(sfExportKey, key);
entry.setFieldU32(sfCloseTime, now);
// At most one key per epoch: a later epoch pushes, the same epoch (a lost
// key file, an emergency rotation) replaces the current key. So however
// often a validator rekeys, the array spans maxExportKeys epochs.
if (!keys.empty() &&
Export::rotationEpoch(now) <=
Export::rotationEpoch(keys[0].getFieldU32(sfCloseTime)))
keys[0] = std::move(entry);
else
{
STArray next(sfExportKeys);
next.push_back(std::move(entry));
for (auto const& e : keys)
{
if (next.size() >= Export::maxExportKeys)
break;
next.push_back(e);
}
keys = std::move(next);
}
acc->setFieldArray(sfExportKeys, keys);
view().update(acc);
// tell exporters their signer lists may need updating
auto unl = view().peek(keylet::UNLReport());
bool const created = !unl;
if (created)
unl = std::make_shared<SLE>(keylet::UNLReport());
unl->setFieldU32(sfExportKeysSeq, (*unl)[~sfExportKeysSeq].value_or(0) + 1);
if (created)
view().insert(unl);
else
view().update(unl);
return tesSUCCESS;
}
TER
Change::applyUNLReport()
{
if (ctx_.tx.isFieldPresent(sfExportKeyReport))
return applyExportKeyReport();
auto sle = view().peek(keylet::UNLReport());
auto const seq = view().info().seq;
@@ -399,8 +510,17 @@ Change::applyUNLReport()
if (created)
sle = std::make_shared<SLE>(keylet::UNLReport());
bool const reset = sle->isFieldPresent(sfPreviousTxnLgrSeq) &&
sle->getFieldU32(sfPreviousTxnLgrSeq) < seq;
// The first report of a ledger resets the array it carries. That was
// judged by PreviousTxnLgrSeq, but export key reports modify this object
// too, so under featureExport only these reports move a marker. Without
// one, the arrays predate the amendment and so this ledger.
bool const exportOn = view().rules().enabled(featureExport);
bool const reset = exportOn
? (*sle)[~sfLedgerSequence].value_or(0) < seq
: (*sle)[~sfPreviousTxnLgrSeq].value_or(seq) < seq;
if (exportOn)
sle->setFieldU32(sfLedgerSequence, seq);
auto canonicalize = [&](SField const& arrayType,
SField const& objType) -> std::vector<STObject> {
@@ -573,9 +693,8 @@ normalizeXahauGenesis(
}
amounts.emplace_back(idStr, x);
JLOG(j.warn()) << "featureXahauGenesis: "
<< "initial validator: " << rn
<< " =>accid: " << idStr;
JLOG(j.warn()) << "featureXahauGenesis: " << "initial validator: "
<< rn << " =>accid: " << idStr;
// initial member enumeration
params.emplace_back(
+3
View File
@@ -79,6 +79,9 @@ private:
TER
applyEntropy();
TER
applyExportKeyReport();
};
using EnableAmendment = Change;
+36 -3
View File
@@ -18,6 +18,7 @@
//==============================================================================
#include <xrpld/app/tx/detail/CronSet.h>
#include <xrpld/app/tx/detail/Export.h>
#include <xrpld/ledger/View.h>
#include <xrpl/basics/Log.h>
#include <xrpl/protocol/Feature.h>
@@ -39,7 +40,10 @@ CronSet::preflight(PreflightContext const& ctx)
auto& tx = ctx.tx;
auto& j = ctx.j;
if (tx.getFlags() & tfCronSetMask)
auto const mask = ctx.rules.enabled(featureExport)
? tfCronSetMask
: tfCronSetMask | tfCronExportRotation;
if (tx.getFlags() & mask)
{
JLOG(j.warn()) << "CronSet: Invalid flags set.";
return temINVALID_FLAG;
@@ -58,7 +62,25 @@ CronSet::preflight(PreflightContext const& ctx)
bool const hasRepeat = tx.isFieldPresent(sfRepeatCount);
bool const hasStartTime = tx.isFieldPresent(sfStartTime);
if (tx.isFlag(tfCronUnset))
if (tx.isFlag(tfCronExportRotation))
{
// The ledger picks StartTime (just after the next export key rotation
// has settled) and, if repeating, DelaySeconds (one rotation period).
if (tx.isFlag(tfCronUnset) || hasStartTime || hasDelay)
{
JLOG(j.debug()) << "CronSet: tfCronExportRotation cannot be used "
"with tfCronUnset, StartTime or DelaySeconds.";
return temMALFORMED;
}
if (hasRepeat &&
(tx.getFieldU32(sfRepeatCount) == 0 ||
tx.getFieldU32(sfRepeatCount) > 256))
{
JLOG(j.debug()) << "CronSet: RepeatCount must be 1 to 256.";
return temMALFORMED;
}
}
else if (tx.isFlag(tfCronUnset))
{
// delete operation
if (hasDelay || hasRepeat || hasStartTime)
@@ -170,7 +192,18 @@ CronSet::doApply()
uint32_t recur{0};
uint32_t startTime{0};
if (!isDelete)
if (tx.isFlag(tfCronExportRotation))
{
if (tx.isFieldPresent(sfRepeatCount))
{
recur = tx.getFieldU32(sfRepeatCount);
delay = Export::keyRotationPeriod;
}
startTime = Export::rotationCronTime(
view.parentCloseTime().time_since_epoch().count(),
tx.getAccountID(sfAccount));
}
else if (!isDelete)
{
if (tx.isFieldPresent(sfDelaySeconds))
delay = tx.getFieldU32(sfDelaySeconds);
+653
View File
@@ -0,0 +1,653 @@
//------------------------------------------------------------------------------
/*
This file is part of rippled: https://github.com/ripple/rippled
Copyright (c) 2026 XRPL-Labs
Permission to use, copy, modify, and/or distribute this software for any
purpose with or without fee is hereby granted, provided that the above
copyright notice and this permission notice appear in all copies.
THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
ANY SPECIAL , DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
*/
//==============================================================================
#include <xrpld/app/hook/applyHook.h>
#include <xrpld/app/main/Application.h>
#include <xrpld/app/misc/HashRouter.h>
#include <xrpld/app/misc/ValidatorKeys.h>
#include <xrpld/app/tx/apply.h>
#include <xrpld/app/tx/detail/Export.h>
#include <xrpld/core/Config.h>
#include <xrpld/core/ConfigSections.h>
#include <xrpld/ledger/View.h>
#include <xrpl/basics/StringUtilities.h>
#include <xrpl/protocol/Feature.h>
#include <xrpl/protocol/HashPrefix.h>
#include <xrpl/protocol/Sign.h>
#include <xrpl/protocol/TxFlags.h>
#include <xrpl/protocol/st.h>
#include <boost/algorithm/string/trim.hpp>
#include <fstream>
namespace ripple {
static STObject const&
obj(STObject const& o, SField const& f)
{
return o.peekAtField(f).downcast<STObject>();
}
// `inner` as a transaction, if it parses under this network's formats and is
// shaped for multisigning by the UNL: from `account`, unsigned, not emitted,
// not a pseudo-txn, not replayable here. SignerListSets must come back.
static std::optional<STTx>
exportedTx(STObject const& inner, AccountID const& account, std::uint32_t nid)
{
try
{
Serializer s;
inner.add(s);
STTx const t{SerialIter{s.slice()}};
if (t[sfAccount] == account && t.getSigningPubKey().empty() &&
!t.isFieldPresent(sfTxnSignature) && !t.isFieldPresent(sfSigners) &&
!t.isFieldPresent(sfEmitDetails) && !isPseudoTx(t) &&
t[~sfNetworkID] != nid &&
(t.getTxnType() != ttSIGNER_LIST_SET ||
(t.isFieldPresent(sfTicketSequence) &&
t[~sfOperationLimit] == nid)))
return t;
}
catch (std::exception const&)
{
}
return std::nullopt;
}
static Keylet
exportKeylet(STTx const& tx)
{
return keylet::exportedTxn(tx[sfLedgerSequence], tx[sfTransactionHash]);
}
static bool
listed(STArray const& entries, AccountID const& acc)
{
return std::any_of(entries.begin(), entries.end(), [&](STObject const& e) {
return e[sfAccount] == acc;
});
}
// A ticketed export bound back here may return once via Import, which
// consumes this and calls back `hook`. Exporting again with the same ticket
// repoints it: only one transaction can use the ticket on the other network.
static TER
setShadowTicket(
ApplyView& view,
AccountID const& acc,
std::uint32_t ticket,
uint256 const& txid,
std::optional<uint256> const& hook,
beast::Journal j)
{
auto const k = keylet::shadowTicket(acc, ticket);
if (auto const st = view.peek(k))
{
st->setFieldH256(sfTransactionHash, txid);
(*st)[~sfEmitHookHash] = hook;
view.update(st);
return tesSUCCESS;
}
auto const sle = view.peek(keylet::account(acc));
if (!sle)
return tefINTERNAL;
if (sle->getFieldAmount(sfBalance).xrp() <
view.fees().accountReserve((*sle)[sfOwnerCount] + 1))
return tecINSUFFICIENT_RESERVE;
auto const page =
view.dirInsert(keylet::ownerDir(acc), k, describeOwnerDir(acc));
if (!page)
return tecDIR_FULL;
auto const st = std::make_shared<SLE>(k);
st->setAccountID(sfAccount, acc);
st->setFieldU32(sfTicketSequence, ticket);
st->setFieldH256(sfTransactionHash, txid);
(*st)[~sfEmitHookHash] = hook;
st->setFieldU64(sfOwnerNode, *page);
view.insert(st);
adjustOwnerCount(view, sle, 1, j);
return tesSUCCESS;
}
Serializer
exportKeyProofData(
PublicKey const& master,
PublicKey const& exportKey,
std::uint32_t networkID)
{
Serializer s;
s.add32(HashPrefix::exportKeyProof);
s.addVL(master.slice());
s.addVL(exportKey.slice());
s.add32(networkID);
return s;
}
// f(master, entry) for each export key of each UNLReport validator, newest
// first, until it returns true
static void
forEachExportKey(ReadView const& view, auto&& f)
{
auto const unl = view.read(keylet::UNLReport());
if (!unl || !unl->isFieldPresent(sfActiveValidators))
return;
for (auto const& v : unl->getFieldArray(sfActiveValidators))
{
PublicKey const master(v[sfPublicKey]);
auto const acc = view.read(keylet::account(calcAccountID(master)));
if (acc && acc->isFieldPresent(sfExportKeys))
for (auto const& e : acc->getFieldArray(sfExportKeys))
if (f(master, e))
break;
}
}
std::vector<ExportSigner>
exportSigners(ReadView const& view, std::optional<std::uint32_t> asOf)
{
std::vector<ExportSigner> out;
forEachExportKey(view, [&](PublicKey const& master, STObject const& e) {
auto const t = e.getFieldU32(sfCloseTime);
if (asOf && t > *asOf)
return false;
// the newest at or before asOf; the same key under two validators
// would list one account twice
PublicKey const pk(makeSlice(e.getFieldVL(sfExportKey)));
if (std::none_of(out.begin(), out.end(), [&](auto const& s) {
return s.key == pk;
}))
out.push_back({master, pk, t, calcAccountID(pk)});
return true;
});
std::sort(out.begin(), out.end(), [](auto const& a, auto const& b) {
return a.account < b.account;
});
if (auto const max = STTx::maxMultiSigners(&view.rules()); out.size() > max)
out.erase(out.begin() + max, out.end());
return out;
}
bool
listsExportKey(ReadView const& view, STArray const& entries)
{
bool found = false;
forEachExportKey(view, [&](PublicKey const&, STObject const& e) {
PublicKey const pk(makeSlice(e.getFieldVL(sfExportKey)));
return found = listed(entries, calcAccountID(pk));
});
return found;
}
std::uint32_t
exportWeight(SLE const& exported)
{
std::uint32_t weight = 0;
auto const& inner = obj(exported, sfExportedTxn);
if (inner.isFieldPresent(sfSigners))
for (auto const& s : inner.getFieldArray(sfSigners))
for (auto const& e : exported.getFieldArray(sfSignerEntries))
if (e[sfAccount] == s[sfAccount])
weight += e[sfSignerWeight];
return weight;
}
// the exporter's signer list on the other network
static STObject const*
signerList(ReadView const& view, AccountID const& acc)
{
auto const sle = view.read(keylet::account(acc));
return sle && sle->isFieldPresent(sfExportSignerList)
? &obj(*sle, sfExportSignerList)
: nullptr;
}
XRPAmount
Export::calculateBaseFee(ReadView const& view, STTx const& tx)
{
if (tx.getTxnType() != ttEXPORT)
return XRPAmount{0};
// an export fans out into one signature per listed signer plus a final,
// and one bound back pays for the free Import that returns it
auto const list = signerList(view, tx[sfAccount]);
std::int64_t const n =
list ? list->getFieldArray(sfSignerEntries).size() : 0;
auto const& inner = obj(tx, sfExportedTxn);
std::int64_t const back = inner.isFieldPresent(sfTicketSequence) &&
inner.isFieldPresent(sfOperationLimit)
? 10
: 0;
return Transactor::calculateBaseFee(view, tx) +
view.fees().base * (n + 1 + back);
}
NotTEC
Export::preflight(PreflightContext const& ctx)
{
auto const& tx = ctx.tx;
if (!ctx.rules.enabled(featureExport))
return temDISABLED;
if (tx.getTxnType() == ttEXPORT)
{
if (auto const ret = preflight1(ctx); !isTesSuccess(ret))
return ret;
if (tx.getFlags() & tfUniversalMask)
return temINVALID_FLAG;
if (!hook::isEmittedTxn(tx) ||
!exportedTx(
obj(tx, sfExportedTxn),
tx[sfAccount],
ctx.app.config().NETWORK_ID))
return temMALFORMED;
return preflight2(ctx);
}
if (auto const ret = preflight0(ctx); !isTesSuccess(ret))
return ret;
// network generated: account zero, free, unsigned, unsequenced, no flags
if (tx[sfAccount] != beast::zero || tx[sfFee] != beast::zero ||
tx[sfSequence] != 0 || tx.getFlags() != 0 ||
!tx.getSigningPubKey().empty() || tx.isFieldPresent(sfTxnSignature) ||
tx.isFieldPresent(sfSigners) || tx.isFieldPresent(sfTicketSequence) ||
tx.isFieldPresent(sfPreviousTxnID))
return temMALFORMED;
if (tx.getTxnType() == ttEXPORT_SIGN)
{
auto const& s = obj(tx, sfSigner);
auto const pk = s[sfSigningPubKey];
if (!publicKeyType(pk) || s[sfAccount] != calcAccountID(PublicKey(pk)))
return temMALFORMED;
}
return tesSUCCESS;
}
TER
Export::preclaim(PreclaimContext const& ctx)
{
auto const& tx = ctx.tx;
if (tx.getTxnType() == ttEXPORT)
return signerList(ctx.view, tx[sfAccount]) ? TER{tesSUCCESS}
: TER{tecNO_TARGET};
auto const sle = ctx.view.read(exportKeylet(tx));
if (!sle)
return tefFAILURE;
if (tx.getTxnType() == ttEXPORT_FINAL)
return tx[sfOwner] == (*sle)[sfOwner] ? TER{tesSUCCESS}
: TER{tefFAILURE};
// Only listed accounts may sign: one foreign signer and the other network
// rejects the whole transaction. Nothing else about the key matters here.
// Signatures authenticate themselves, sign txns only reach a ledger
// through trusted proposals, and the list was proven by Import.
auto const& s = obj(tx, sfSigner);
PublicKey const pk(s[sfSigningPubKey]);
if (!listed(sle->getFieldArray(sfSignerEntries), s[sfAccount]))
return tefBAD_AUTH;
// Signers are unique and listed, so never more than SignerEntries holds.
auto const& inner = obj(*sle, sfExportedTxn);
if (inner.isFieldPresent(sfSigners) &&
listed(inner.getFieldArray(sfSigners), s[sfAccount]))
return tefALREADY;
// XRPL requires fully canonical signatures, which also stops malleated
// copies of one signature from becoming distinct transactions.
if (!verify(
pk,
buildMultiSigningData(inner, s[sfAccount]).slice(),
s[sfTxnSignature],
true))
return tefBAD_SIGNATURE;
return tesSUCCESS;
}
TER
Export::doApply()
{
auto& view = ctx_.view();
auto const& tx = ctx_.tx;
auto const nid = ctx_.app.config().NETWORK_ID;
if (tx.getTxnType() == ttEXPORT)
{
auto const list = signerList(view, account_);
STObject inner = obj(tx, sfExportedTxn);
// a SignerListSet without entries moves the account onto the current
// export keys: its old list signs, so keys rotate without lockout
if (inner.getFieldU16(sfTransactionType) == ttSIGNER_LIST_SET &&
!inner.isFieldPresent(sfSignerEntries))
{
auto const signers = exportSigners(view);
if (signers.empty())
return tecNO_TARGET;
STArray entries(sfSignerEntries);
for (auto const& s : signers)
{
entries.push_back(STObject::makeInnerObject(sfSignerEntry));
entries.back()[sfAccount] = s.account;
entries.back()[sfSignerWeight] = 1;
}
inner.setFieldArray(sfSignerEntries, entries);
inner[sfSignerQuorum] =
static_cast<std::uint32_t>((signers.size() * 4 + 4) / 5);
}
auto const t = exportedTx(inner, account_, nid);
if (!t || !list)
return tefINTERNAL;
auto const id = t->getTransactionID();
auto const k = keylet::exportedTxn(view.seq(), id);
if (view.exists(k))
return tecDUPLICATE;
// Bound back here, the export calls its hook back when it returns or
// closes below quorum, rather than now
auto const ticket = (*t)[~sfTicketSequence];
bool const bound = ticket && (*t)[~sfOperationLimit] == nid;
std::optional<uint256> hook;
if (auto const& d = obj(tx, sfEmitDetails); bound &&
d.isFieldPresent(sfEmitCallback) &&
d.getAccountID(sfEmitCallback) == account_)
hook = d.getFieldH256(sfEmitHookHash);
if (bound)
{
if (auto const ter =
setShadowTicket(view, account_, *ticket, id, hook, j_);
!isTesSuccess(ter))
return ter;
}
auto const sle = std::make_shared<SLE>(k);
sle->setAccountID(sfOwner, account_);
sle->peekFieldObject(sfExportedTxn) = inner;
sle->setFieldH256(sfTransactionHash, id);
sle->setFieldU32(sfLedgerSequence, view.seq());
sle->setFieldArray(
sfSignerEntries, list->getFieldArray(sfSignerEntries));
sle->setFieldU32(sfSignerQuorum, (*list)[sfSignerQuorum]);
(*sle)[~sfEmitHookHash] = hook;
view.insert(sle);
if (bound)
callback_ = HookCallback{account_, uint256{}, 0};
return tesSUCCESS;
}
auto const sle = view.peek(exportKeylet(tx));
if (!sle)
return tefINTERNAL;
if (tx.getTxnType() == ttEXPORT_FINAL)
{
// Below quorum it was never submitted. Tell the hook, as for an
// emitted txn that failed, but keep the shadow ticket: identical
// content exported again could still land.
if (sle->isFieldPresent(sfEmitHookHash) &&
exportWeight(*sle) < (*sle)[sfSignerQuorum])
callback_ =
HookCallback{(*sle)[sfOwner], (*sle)[sfEmitHookHash], 1};
view.erase(sle);
return tesSUCCESS;
}
// XRPL requires Signers sorted by account
auto& inner = sle->peekFieldObject(sfExportedTxn);
STArray signers = inner.isFieldPresent(sfSigners)
? inner.getFieldArray(sfSigners)
: STArray(sfSigners);
signers.push_back(obj(tx, sfSigner));
signers.sort([](STObject const& a, STObject const& b) {
return a[sfAccount] < b[sfAccount];
});
inner.setFieldArray(sfSigners, signers);
view.update(sle);
return tesSUCCESS;
}
bool
Export::accept(Application& app, OpenView& view, beast::Journal j)
{
if (!view.rules().enabled(featureExport))
return false;
auto const seq = view.seq();
auto const nid = app.config().NETWORK_ID;
bool changed = false;
// flagged like other injected txns so OpenLedger does not relay them
auto const inject = [&](TxType type, auto&& assemble) {
STTx const tx(type, assemble);
app.getHashRouter().setFlags(tx.getTransactionID(), SF_EMITTED);
changed |= ripple::apply(app, view, tx, tapNONE, j).applied;
};
// Only exports from closed ledgers: their keys sort below this ledger's.
// Applying (rather than raw-inserting) lets preclaim reject duplicates.
auto const end = keylet::exportedTxn(seq, beast::zero).key;
for (auto k = keylet::exportedTxn(0, beast::zero).key;
auto const next = view.succ(k, end);
k = *next)
{
auto const sle = view.read(Keylet{ltEXPORTED_TXN, *next});
if (!sle)
continue;
auto const created = (*sle)[sfLedgerSequence];
auto const id = (*sle)[sfTransactionHash];
auto const& inner = obj(*sle, sfExportedTxn);
if (seq >= created + window)
{
inject(ttEXPORT_FINAL, [&](STObject& o) {
o[sfAccount] = AccountID();
o[sfOwner] = (*sle)[sfOwner];
o[sfLedgerSequence] = created;
o[sfTransactionHash] = id;
});
continue;
}
// possibly several, if the exporter listed more than one of ours
for (auto const& key : app.getExportKeys().signersFor(
sle->getFieldArray(sfSignerEntries)))
{
// not a structured binding: the lambda below captures these
PublicKey const& pk = key.first;
SecretKey const& sk = key.second;
auto const acc = calcAccountID(pk);
if (inner.isFieldPresent(sfSigners) &&
listed(inner.getFieldArray(sfSigners), acc))
continue;
inject(ttEXPORT_SIGN, [&](STObject& o) {
o[sfAccount] = AccountID();
if (nid > 1024)
o[sfNetworkID] = nid;
o[sfLedgerSequence] = created;
o[sfTransactionHash] = id;
auto& s = o.peekFieldObject(sfSigner);
s[sfAccount] = acc;
s[sfSigningPubKey] = pk.slice();
s[sfTxnSignature] =
sign(pk, sk, buildMultiSigningData(inner, acc).slice());
});
}
}
return changed;
}
//------------------------------------------------------------------------------
ExportKeys::ExportKeys(
Config const& config,
ValidatorKeys const& vk,
beast::Journal j)
: j_(j), networkID_(config.NETWORK_ID)
{
if (!vk.keys)
return;
master_ = vk.keys->masterPublicKey;
if (auto const& v = config.section(SECTION_EXPORT_KEY_FILE).values();
!v.empty())
file_ = boost::filesystem::path(v.front());
else if (auto const db = config.legacy("database_path"); !db.empty())
file_ = boost::filesystem::path(db) / "export_keys.txt";
else
JLOG(j_.warn()) << "ExportKeys: no [export_key_file] or "
"database_path, export keys will not persist";
boost::system::error_code ec;
if (!file_ || !boost::filesystem::exists(*file_, ec))
return;
std::ifstream in(file_->string());
for (std::string line; std::getline(in, line);)
{
boost::algorithm::trim(line);
if (line.empty())
continue;
auto const raw = strUnHex(line);
if (!raw || raw->size() != 32)
Throw<std::runtime_error>(
"ExportKeys: malformed key in " + file_->string());
keys_.push_back(make(SecretKey(makeSlice(*raw))));
}
JLOG(j_.info()) << "ExportKeys: loaded " << keys_.size();
}
ExportKeys::Key
ExportKeys::make(SecretKey const& sk) const
{
auto const pk = derivePublicKey(KeyType::ed25519, sk);
auto const proof =
sign(pk, sk, exportKeyProofData(*master_, pk, networkID_).slice());
return Key{pk, sk, Blob(proof.begin(), proof.end())};
}
void
ExportKeys::save() const
{
if (!file_)
return;
// write then rename, so a crash never leaves a truncated file
auto const tmp = boost::filesystem::path(file_->string() + ".tmp");
boost::system::error_code ec;
{
std::ofstream out(tmp.string(), std::ios::trunc);
boost::filesystem::permissions(
tmp,
boost::filesystem::owner_read | boost::filesystem::owner_write,
ec);
for (auto const& k : keys_)
out << strHex(k.sk.data(), k.sk.data() + k.sk.size()) << "\n";
if (!out.flush())
{
JLOG(j_.error()) << "ExportKeys: cannot write " << tmp;
return;
}
}
boost::filesystem::rename(tmp, *file_, ec);
if (ec)
{
JLOG(j_.error()) << "ExportKeys: cannot replace " << *file_ << ": "
<< ec.message();
}
}
std::optional<std::pair<PublicKey, Blob>>
ExportKeys::nominate(ReadView const& ledger)
{
if (!master_)
return std::nullopt;
std::lock_guard lock(mutex_);
// the account's keys, newest first, and when the newest was recorded
auto const sle = ledger.read(keylet::account(calcAccountID(*master_)));
std::vector<Blob> onLedger;
std::uint32_t headTime = 0;
if (sle && sle->isFieldPresent(sfExportKeys))
for (auto const& e : sle->getFieldArray(sfExportKeys))
{
if (onLedger.empty())
headTime = e.getFieldU32(sfCloseTime);
onLedger.push_back(e.getFieldVL(sfExportKey));
}
auto const at = [&](PublicKey const& pk) {
return std::find(
onLedger.begin(), onLedger.end(), Blob(pk.begin(), pk.end()));
};
bool rotate = keys_.empty();
if (!rotate && !onLedger.empty())
{
auto const it = at(keys_.back().pk);
auto const now = static_cast<std::uint32_t>(
ledger.info().closeTime.time_since_epoch().count());
// the ledger moved past ours (an older backup), or a new epoch
rotate = (it != onLedger.end() && it != onLedger.begin()) ||
(it == onLedger.begin() &&
Export::rotationEpoch(now) > Export::rotationEpoch(headTime));
}
auto const n = keys_.size();
if (rotate)
{
keys_.push_back(make(randomKeyPair(KeyType::ed25519).second));
JLOG(j_.info()) << "ExportKeys: new export key "
<< strHex(keys_.back().pk);
}
// forget keys the account no longer lists, except the nominee
if (sle)
keys_.erase(
std::remove_if(
keys_.begin(),
std::prev(keys_.end()),
[&](Key const& k) { return at(k.pk) == onLedger.end(); }),
std::prev(keys_.end()));
if (rotate || keys_.size() != n)
save();
return std::make_pair(keys_.back().pk, keys_.back().proof);
}
std::vector<std::pair<PublicKey, SecretKey>>
ExportKeys::signersFor(STArray const& entries) const
{
std::vector<std::pair<PublicKey, SecretKey>> ret;
std::lock_guard lock(mutex_);
for (auto const& k : keys_)
if (listed(entries, calcAccountID(k.pk)))
ret.emplace_back(k.pk, k.sk);
return ret;
}
} // namespace ripple
+258
View File
@@ -0,0 +1,258 @@
//------------------------------------------------------------------------------
/*
This file is part of rippled: https://github.com/ripple/rippled
Copyright (c) 2026 XRPL-Labs
Permission to use, copy, modify, and/or distribute this software for any
purpose with or without fee is hereby granted, provided that the above
copyright notice and this permission notice appear in all copies.
THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
ANY SPECIAL , DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
*/
//==============================================================================
#ifndef RIPPLE_TX_EXPORT_H_INCLUDED
#define RIPPLE_TX_EXPORT_H_INCLUDED
#include <xrpld/app/tx/detail/Transactor.h>
#include <xrpl/protocol/Feature.h>
#include <xrpl/protocol/Indexes.h>
#include <xrpl/protocol/Rules.h>
#include <boost/filesystem.hpp>
#include <mutex>
namespace ripple {
class ValidatorKeys;
/** Export: UNL validators multisign transactions for another network on
behalf of a hook's account, which has the same AccountID there.
Export keys. Every UNLReport validator nominates its current export key,
with a proof of possession, in its flag ledger validations. The UNL
reports it (ttUNL_REPORT ExportKeyReport) and it is recorded, newest first,
in sfExportKeys on the validator's account: one key per keyRotationPeriod
epoch, at most maxExportKeys. Each change bumps UNLReport.ExportKeysSeq.
A validator whose account does not exist yet gets one, with
validatorFundingDrops, from the next ttGENESIS_MINT.
Signer list. sfExportSignerList on an account is the signer list it holds
on the other network, as last proven by an Import of its SignerListSet.
Hooks never need to track it.
1. A hook calls xport() (or emits a ttEXPORT) with the transaction.
Applying it copies the account's sfExportSignerList into a new
ltEXPORTED_TXN and, if the transaction uses a ticket and is bound back
here (OperationLimit == NETWORK_ID), an ltSHADOW_TICKET: the XPOP of
whichever transaction uses that ticket there can be imported once.
Exported SignerListSets must be so bound, so the list here cannot go
stale, and one without SignerEntries is completed with the current
export keys at an 80% quorum. The ttEXPORT pays for that Import too.
2. Every validator holding a listed export key applies a ttEXPORT_SIGN to
its next open ledger. Each is proposed by one validator only, so it
loses its first round, but not being a pseudo-txn it is retried into
every node's next open ledger and lands one ledger later. A signature
can only be checked against the ledger, so it is never relayed as a
transaction: peers have it from the proposed set. Signers are kept
sorted, so the object always holds a submittable transaction.
3. `window` ledgers after creation every node injects ttEXPORT_FINAL,
which deletes the object: the DeletedNode's FinalFields are the result.
4. The XPOP of the transaction that used the ticket there is imported
here by anyone (see isLoopback): free, consuming no sequence and
running none of the account's hooks, it consumes the shadow ticket.
Callbacks. A hook hears of its export as it would of an emitted txn, by
cbak, once: what == 2 when the Import of step 4 lands (xpop_slot() gives
the transaction there and its meta, which may be another export that used
the same ticket), or what == 1 if the export closes below quorum in step 3.
The latter is a prompt, not a verdict: identical content exported again
could still land, so the shadow ticket stays, and a definite outcome
needs the ticket used, by a retry or a no-op. An export that is not
bound back calls back as soon as the ttEXPORT applies, as does one that
fails. A transaction the other network rejects outright never comes
back, so no callback follows.
Servers with [xrpl_relay] submit exports that reach quorum to XRPL and
import their XPOPs back (see ExportRelay.cpp).
*/
class Export : public Transactor
{
public:
static constexpr ConsequencesFactoryType ConsequencesFactory{Normal};
static constexpr std::uint32_t window = 4;
/** Export keys rotate once per epoch of this many seconds */
static constexpr std::uint32_t keyRotationPeriod = 90 * 24 * 60 * 60;
/** The most export keys kept on a validator's account */
static constexpr std::size_t maxExportKeys = 16;
/** A tfCronExportRotation cron fires this long after each epoch begins,
plus up to rotationSpread more, fixed per account, so validators
offline at the boundary have rotated and exporters do not fan out at
once */
static constexpr std::uint32_t rotationSettle = 24 * 60 * 60;
static constexpr std::uint32_t rotationSpread = 24 * 60 * 60;
/** A UNLReport validator whose account does not exist yet is created with
this many drops by the next ttGENESIS_MINT after a flag ledger */
static constexpr std::int64_t validatorFundingDrops = 100'000'000;
static constexpr std::uint32_t
rotationEpoch(std::uint32_t closeTime)
{
return closeTime / keyRotationPeriod;
}
/** When `account`'s tfCronExportRotation cron next fires after `now` */
static std::uint32_t
rotationCronTime(std::uint32_t now, AccountID const& account)
{
auto const a = account.data();
std::uint32_t const spread =
((std::uint32_t(a[0]) << 24) | (std::uint32_t(a[1]) << 16) |
(std::uint32_t(a[2]) << 8) | std::uint32_t(a[3])) %
rotationSpread;
std::uint32_t const t =
rotationEpoch(now) * keyRotationPeriod + rotationSettle + spread;
return t > now ? t : t + keyRotationPeriod;
}
explicit Export(ApplyContext& ctx) : Transactor(ctx)
{
}
static XRPAmount
calculateBaseFee(ReadView const& view, STTx const& tx);
static NotTEC
preflight(PreflightContext const& ctx);
static TER
preclaim(PreclaimContext const& ctx);
TER
doApply() override;
void
preCompute() override
{
// ttEXPORT_SIGN and ttEXPORT_FINAL have no account
}
/** Sign (if holding a listed export key) and finalize pending exports.
Called from TxQ::accept; returns true if the open view changed. */
static bool
accept(Application& app, OpenView& view, beast::Journal j);
};
using ExportSign = Export;
using ExportFinal = Export;
/** Whether `tx` is an Import bringing an export back, on an account that
exists: Sequence 0 and no ticket. (A first Import also has Sequence 0,
but no account yet.) Its shadow ticket is its replay protection and the
export paid for it, so it consumes no sequence, pays no fee, and runs
none of the account's hooks: the exporting hook's cbak runs instead. */
inline bool
isLoopback(Rules const& rules, STTx const& tx)
{
return rules.enabled(featureExport) && tx.getTxnType() == ttIMPORT &&
tx[sfSequence] == 0 && !tx.isFieldPresent(sfTicketSequence);
}
/** Whether `entries` lists the account of any UNLReport validator's export
key. Importing such a SignerListSet records it as the account's list
there but never installs it here, as that would let the UNL sign for
the account on this network. */
bool
listsExportKey(ReadView const& view, STArray const& entries);
/** The weight of the signatures a pending export has gathered */
std::uint32_t
exportWeight(SLE const& exported);
/** What an export key signs to prove that its holder nominated it */
Serializer
exportKeyProofData(
PublicKey const& master,
PublicKey const& exportKey,
std::uint32_t networkID);
/** One export key per UNLReport validator: its newest recorded at or before
`asOf` (default: now), sorted by account, at most maxMultiSigners. */
struct ExportSigner
{
PublicKey validator;
PublicKey key;
std::uint32_t closeTime;
AccountID account;
};
std::vector<ExportSigner>
exportSigners(ReadView const& view, std::optional<std::uint32_t> asOf = {});
/** A validator's export keys: independent random ed25519 keys, kept for as
long as its account lists them. They live in [export_key_file] (default
export_keys.txt in the database path), one hex secret per line, oldest
first, the last being the nominee. As sensitive as the validator token:
without it the validator cannot sign for exporters listing older keys.
Independent keys make rotation worth something: a compromise that has
ended does not reach keys generated after it. */
class ExportKeys
{
public:
ExportKeys(Config const& config, ValidatorKeys const& vk, beast::Journal j);
/** The key and proof of possession to nominate in the validation of
flag ledger `ledger`. Rotates if there is no key, a new epoch began,
or `ledger` moved past ours (an older backup); forgets keys `ledger`
no longer lists. Empty unless this server is a validator. */
std::optional<std::pair<PublicKey, Blob>>
nominate(ReadView const& ledger);
/** The held keys whose accounts are listed in sfSignerEntries `entries` */
std::vector<std::pair<PublicKey, SecretKey>>
signersFor(STArray const& entries) const;
private:
struct Key
{
PublicKey pk;
SecretKey sk;
Blob proof;
};
Key
make(SecretKey const& sk) const;
void
save() const;
beast::Journal const j_;
std::optional<PublicKey> master_;
std::uint32_t const networkID_;
std::optional<boost::filesystem::path> file_;
mutable std::mutex mutex_;
std::vector<Key> keys_; // oldest first, back() is the nominee
};
/** [xrpl_relay]: carries exports to XRPL and their XPOPs back. Stops and
joins on destruction. Null unless configured. */
struct ExportRelay
{
virtual ~ExportRelay() = default;
};
std::unique_ptr<ExportRelay>
makeExportRelay(Application& app);
} // namespace ripple
#endif
+32
View File
@@ -17,6 +17,7 @@
*/
//==============================================================================
#include <xrpld/app/tx/detail/Export.h>
#include <xrpld/app/tx/detail/GenesisMint.h>
#include <xrpld/app/tx/detail/Import.h>
#include <xrpld/ledger/View.h>
@@ -280,6 +281,37 @@ GenesisMint::doApply()
view().update(sle);
}
// featureExport: UNLReport validators without an account get one, so
// their export keys have somewhere to live. Reports only change on flag
// ledgers, so in practice this is the first mint after each of those.
if (auto const unl = view().read(keylet::UNLReport());
view().rules().enabled(featureExport) && unl &&
unl->isFieldPresent(sfActiveValidators))
{
XRPAmount const funding{Export::validatorFundingDrops};
for (auto const& v : unl->getFieldArray(sfActiveValidators))
{
auto const id = calcAccountID(PublicKey(v[sfPublicKey]));
auto const k = keylet::account(id);
if (view().exists(k))
continue;
if (funding + dropsAdded.xrp() + view().info().drops <
view().info().drops)
return tecINTERNAL;
auto const sle = std::make_shared<SLE>(k);
sle->setAccountID(sfAccount, id);
sle->setFieldU32(sfSequence, newAccountSeqNo(view()));
sle->setFieldAmount(sfBalance, funding);
view().insert(sle);
dropsAdded += funding;
JLOG(ctx_.journal.info())
<< "GenesisMint: funded UNLReport validator " << id;
}
}
if (dropsAdded > beast::zero)
ctx_.rawView().rawDestroyXRP(-dropsAdded.xrp());
+90 -11
View File
@@ -18,6 +18,7 @@
//==============================================================================
#include <xrpld/app/misc/Manifest.h>
#include <xrpld/app/tx/detail/Export.h>
#include <xrpld/app/tx/detail/Import.h>
#include <xrpld/app/tx/detail/SetSignerList.h>
#include <xrpld/ledger/View.h>
@@ -197,12 +198,24 @@ Import::preflight(PreflightContext const& ctx)
if (!stpTrans || !meta)
return temMALFORMED;
if (stpTrans->isFieldPresent(sfTicketSequence))
// A ticketed XPOP can only be an exported txn coming back. It is
// authorised by its shadow ticket (preclaim), not by the outer signature:
// nobody could co-sign it with the UNL's keys, so anyone may relay it.
bool const shadow = stpTrans->isFieldPresent(sfTicketSequence);
if (shadow && !ctx.rules.enabled(featureExport))
{
JLOG(ctx.j.warn()) << "Import: cannot use TicketSequence XPOP.";
return temMALFORMED;
}
// and the export paid for it, so it is free and uses no sequence here
if (shadow && (!isLoopback(ctx.rules, tx) || tx[sfFee] != beast::zero))
{
JLOG(ctx.j.warn()) << "Import: a ticketed XPOP needs Sequence and "
"Fee 0.";
return temMALFORMED;
}
// check if txn is emitted or a psuedo
if (isPseudoTx(*stpTrans) || stpTrans->isFieldPresent(sfEmitDetails))
{
@@ -280,6 +293,7 @@ Import::preflight(PreflightContext const& ctx)
// check if the inner transaction is signed using the same keying as the
// outer txn
if (!shadow)
{
auto outer = tx.getSigningPubKey();
auto inner = stpTrans->getSigningPubKey();
@@ -988,7 +1002,15 @@ Import::preclaim(PreclaimContext const& ctx)
} while (0);
}
if (sle && sle->isFieldPresent(sfImportSequence))
if (auto const t = (*stpTrans)[~sfTicketSequence])
{
if (!ctx.view.exists(keylet::shadowTicket(ctx.tx[sfAccount], *t)))
return tefNO_TICKET;
}
// other Imports by an existing account use its sequence
else if (sle && isLoopback(ctx.view.rules(), ctx.tx))
return tefPAST_SEQ;
else if (sle && sle->isFieldPresent(sfImportSequence))
{
uint32_t sleImportSequence = sle->getFieldU32(sfImportSequence);
@@ -1131,9 +1153,9 @@ Import::doSignerList(std::shared_ptr<SLE>& sle, STTx const& stpTrans)
// validate signer list
//
JLOG(ctx_.journal.warn()) << "Import: actioning SignerListSet "
<< "quorum: " << quorum << " "
<< "size: " << signers.size();
JLOG(ctx_.journal.warn())
<< "Import: actioning SignerListSet " << "quorum: " << quorum << " "
<< "size: " << signers.size();
if (SetSignerList::validateQuorumAndSignerEntries(
quorum, signers, id, ctx_.journal, ctx_.view().rules()) !=
@@ -1190,8 +1212,8 @@ Import::doRegularKey(std::shared_ptr<SLE>& sle, STTx const& stpTrans)
if (!stpTrans.isFieldPresent(sfRegularKey))
{
// delete op
JLOG(ctx_.journal.trace()) << "Import: clearing SetRegularKey "
<< " acc: " << id;
JLOG(ctx_.journal.trace())
<< "Import: clearing SetRegularKey " << " acc: " << id;
if (sle->isFieldPresent(sfRegularKey))
sle->makeFieldAbsent(sfRegularKey);
return;
@@ -1301,8 +1323,9 @@ Import::doApply()
uint32_t importSequence = stpTrans->getFieldU32(sfSequence);
auto const id = ctx_.tx[sfAccount];
auto sle = view().peek(keylet::account(id));
auto const ticket = (*stpTrans)[~sfTicketSequence];
if (sle && sle->getFieldU32(sfImportSequence) >= importSequence)
if (!ticket && sle && sle->getFieldU32(sfImportSequence) >= importSequence)
{
// make double sure import seq hasn't passed
JLOG(ctx_.journal.warn()) << "Import: ImportSequence passed";
@@ -1390,9 +1413,41 @@ Import::doApply()
}
}
sle->setFieldU32(sfImportSequence, importSequence);
if (!ticket)
sle->setFieldU32(sfImportSequence, importSequence);
sle->setFieldAmount(sfBalance, finalBal);
// featureExport: remember the signer list the account now holds there,
// unless a later SignerListSet (by ledger, then index) was already seen
if (view().rules().enabled(featureExport) &&
stpTrans->getTxnType() == ttSIGNER_LIST_SET &&
isTesSuccess(meta->getFieldU8(sfTransactionResult)))
{
std::pair const at{
(*xpop)[jss::ledger][jss::index].asUInt(),
(*meta)[~sfTransactionIndex].value_or(0)};
auto const old = sle->isFieldPresent(sfExportSignerList)
? &sle->peekAtField(sfExportSignerList).downcast<STObject>()
: nullptr;
if (!old ||
std::pair{(*old)[sfLedgerSequence], (*old)[sfTransactionIndex]} <
at)
{
if (!stpTrans->isFieldPresent(sfSignerEntries))
sle->makeFieldAbsent(sfExportSignerList);
else
{
auto l = STObject::makeInnerObject(sfExportSignerList);
l[sfSignerQuorum] = (*stpTrans)[sfSignerQuorum];
l.setFieldArray(
sfSignerEntries, stpTrans->getFieldArray(sfSignerEntries));
l[sfLedgerSequence] = at.first;
l[sfTransactionIndex] = at.second;
sle->peekFieldObject(sfExportSignerList) = std::move(l);
}
}
}
if (create)
{
view().insert(sle);
@@ -1402,14 +1457,34 @@ Import::doApply()
else
view().update(sle);
// each ticketed export comes back at most once
if (ticket)
{
auto const k = keylet::shadowTicket(id, *ticket);
auto const st = view().peek(k);
if (!st ||
!view().dirRemove(
keylet::ownerDir(id), (*st)[sfOwnerNode], k, false))
return tefBAD_LEDGER;
// the exporting hook's callback, deferred until now (see Export.h)
if (st->isFieldPresent(sfEmitHookHash))
callback_ = HookCallback{id, (*st)[sfEmitHookHash], 2};
view().erase(st);
adjustOwnerCount(view(), sle, -1, ctx_.journal);
}
//
// Handle any key imports, but only if a tes code
// these functions update the sle on their own
// (never for exports: that would hand the UNL's keys the account here)
//
if (isTesSuccess(meta->getFieldU8(sfTransactionResult)))
if (!ticket && isTesSuccess(meta->getFieldU8(sfTransactionResult)))
{
auto const tt = stpTrans->getTxnType();
if (tt == ttSIGNER_LIST_SET)
if (tt == ttSIGNER_LIST_SET &&
!(view().rules().enabled(featureExport) &&
stpTrans->isFieldPresent(sfSignerEntries) &&
listsExportKey(view(), stpTrans->getFieldArray(sfSignerEntries))))
doSignerList(sle, *stpTrans);
else if (tt == ttREGULAR_KEY_SET)
doRegularKey(sle, *stpTrans);
@@ -1429,6 +1504,10 @@ Import::calculateBaseFee(ReadView const& view, STTx const& tx)
!tx.isFieldPresent(sfIssuer))
return XRPAmount{0};
// an export coming back was paid for by the export
if (isLoopback(view.rules(), tx))
return XRPAmount{0};
return Transactor::calculateBaseFee(view, tx);
}
+33 -2
View File
@@ -23,6 +23,7 @@
#include <xrpld/app/tx/detail/InvariantCheck.h>
#include <xrpld/app/misc/CredentialHelpers.h>
#include <xrpld/app/tx/detail/Export.h>
#include <xrpld/app/tx/detail/Import.h>
#include <xrpld/app/tx/detail/NFTokenUtils.h>
#include <xrpld/app/tx/detail/PermissionedDomainSet.h>
@@ -145,7 +146,7 @@ XRPNotCreated::visitEntry(
}
if (!before && after->getType() == ltACCOUNT_ROOT)
accountsCreated_++;
created_.push_back((*after)[sfAccount]);
}
bool
@@ -178,7 +179,7 @@ XRPNotCreated::finalize(
? inner->getFieldAmount(sfFee).xrp() // burned in PoB
: beast::zero; // if the txn didnt burn a fee we add nothing
if (accountsCreated_ == 1)
if (created_.size() == 1)
maxDropsAdded += Import::computeStartingBonus(view);
JLOG(j.trace()) << "Invariant XRPNotCreated Import: "
@@ -220,6 +221,34 @@ XRPNotCreated::finalize(
for (auto const& dest : dests)
dropsAdded += dest.getFieldAmount(sfAmount).xrp();
// featureExport: accounts created outside the destinations must be
// UNLReport validators, each funded with validatorFundingDrops
auto const unl = view.read(keylet::UNLReport());
for (auto const& id : created_)
{
if (std::any_of(dests.begin(), dests.end(), [&](auto const& d) {
return d.getAccountID(sfDestination) == id;
}))
continue;
if (!view.rules().enabled(featureExport) || !unl ||
!unl->isFieldPresent(sfActiveValidators))
return false;
auto const& avs = unl->getFieldArray(sfActiveValidators);
if (std::none_of(avs.begin(), avs.end(), [&](auto const& v) {
return calcAccountID(PublicKey(v[sfPublicKey])) == id;
}))
{
JLOG(j.fatal()) << "Invariant failed GenesisMint: created "
<< id << " which is neither a destination "
<< "nor a UNLReport validator";
return false;
}
dropsAdded += XRPAmount{Export::validatorFundingDrops};
}
JLOG(j.trace()) << "Invariant XRPNotCreated GenesisMint: "
<< "dropsAdded: " << dropsAdded
<< " fee.drops(): " << fee.drops()
@@ -617,6 +646,8 @@ LedgerEntryTypesMatch::visitEntry(
case ltPERMISSIONED_DOMAIN:
case ltAPP_LOADER:
case ltMANIFEST:
case ltEXPORTED_TXN:
case ltSHADOW_TICKET:
break;
default:
invalidTypeAdded_ = true;
+1 -1
View File
@@ -121,7 +121,7 @@ class XRPNotCreated
// Full-width running total so a wrap of a 64-bit accumulator cannot
// hide XRP creation.
boost::multiprecision::int128_t drops_ = 0;
std::uint32_t accountsCreated_ = 0;
std::vector<AccountID> created_;
public:
void
+33 -25
View File
@@ -23,6 +23,7 @@
#include <xrpld/app/misc/HashRouter.h>
#include <xrpld/app/misc/LoadFeeTrack.h>
#include <xrpld/app/tx/apply.h>
#include <xrpld/app/tx/detail/Export.h>
#include <xrpld/app/tx/detail/NFTokenUtils.h>
#include <xrpld/app/tx/detail/SetHook.h>
#include <xrpld/app/tx/detail/SignerEntries.h>
@@ -605,6 +606,11 @@ Transactor::checkSeqProxy(
tx.getTxnType() == ttMANIFEST_SET)
return tesSUCCESS;
// An export coming back is replay protected by its shadow ticket
// (Import::preclaim), so it is likewise pinned to 0 and not consumed.
if (isLoopback(view.rules(), tx))
return tesSUCCESS;
SeqProxy const a_seq = SeqProxy::sequence((*sle)[sfSequence]);
// pass all emitted tx provided their seq is 0
@@ -765,6 +771,8 @@ Transactor::consumeSeqProxy(SLE::pointer const& sleAccount)
if (view().rules().enabled(featureOnChainManifests) &&
ctx_.tx.getTxnType() == ttMANIFEST_SET)
return tesSUCCESS;
if (isLoopback(view().rules(), ctx_.tx))
return tesSUCCESS;
SeqProxy const seqProx = ctx_.tx.getSeqProxy();
if (seqProx.isSeq())
@@ -1531,28 +1539,15 @@ Transactor::executeHookChain(
void
Transactor::doHookCallback(
std::shared_ptr<STObject const> const& provisionalMeta)
std::shared_ptr<STObject const> const& provisionalMeta,
HookCallback const& cb)
{
// Finally check if there is a callback
if (!ctx_.tx.isFieldPresent(sfEmitDetails))
return;
auto const& emitDetails = const_cast<ripple::STTx&>(ctx_.tx)
.getField(sfEmitDetails)
.downcast<STObject>();
// callbacks are optional so if there isn't a callback then skip
if (!emitDetails.isFieldPresent(sfEmitCallback))
return;
bool const isEmitFailure = ctx_.tx.getTxnType() == ttEMIT_FAILURE;
// fix20261005: an emit failure callback is only meaningful with the
// original emitted transaction. If it wasn't in the ledger when this
// pseudo was applied there is nothing to report on, so skip rather than
// read the (already removed) entry.
if (isEmitFailure && view().rules().enabled(fix20261005) &&
!emitFailureTxn_)
if (ctx_.tx.getTxnType() == ttEMIT_FAILURE &&
view().rules().enabled(fix20261005) && !emitFailureTxn_)
{
JLOG(j_.warn()) << "HookError["
<< ctx_.tx.getFieldH256(sfTransactionHash)
@@ -1560,9 +1555,8 @@ Transactor::doHookCallback(
return;
}
AccountID const& callbackAccountID =
emitDetails.getAccountID(sfEmitCallback);
uint256 const& callbackHookHash = emitDetails.getFieldH256(sfEmitHookHash);
AccountID const& callbackAccountID = cb.account;
uint256 const& callbackHookHash = cb.hook;
auto const& hooksCallback = view().peek(keylet::hook(callbackAccountID));
auto const& hookDef = view().peek(keylet::hookDefinition(callbackHookHash));
@@ -1643,7 +1637,7 @@ Transactor::doHookCallback(
true,
true,
false,
isEmitFailure ? 1UL : 0UL,
cb.what,
hook_no - 1,
provisionalMeta,
emitFailureTxn_);
@@ -2061,8 +2055,9 @@ Transactor::operator()()
auto const& hooksOriginator = view().read(keylet::hook(accountID));
// First check if the Sending account has any hooks that can be fired
// (an export coming back calls back the exporting hook instead)
if (hooksOriginator && hooksOriginator->isFieldPresent(sfHooks) &&
!ctx_.isEmittedTxn())
!ctx_.isEmittedTxn() && !isLoopback(view().rules(), ctx_.tx))
result = executeHookChain(
hooksOriginator,
stateMap,
@@ -2490,9 +2485,22 @@ Transactor::finishApply(
std::shared_ptr<STObject const> proMeta =
std::make_shared<STObject const>(meta.getAsObject());
// perform callback logic if applicable
if (ctx_.tx.isFieldPresent(sfEmitDetails))
doHookCallback(proMeta);
// perform callback logic if applicable: doApply's redirect, if it
// succeeded, else the emitting hook's callback
if (!isTesSuccess(result))
callback_.reset();
if (!callback_ && ctx_.tx.isFieldPresent(sfEmitDetails))
{
auto const& d =
ctx_.tx.peekAtField(sfEmitDetails).downcast<STObject>();
if (d.isFieldPresent(sfEmitCallback))
callback_ = HookCallback{
d.getAccountID(sfEmitCallback),
d.getFieldH256(sfEmitHookHash),
ctx_.tx.getTxnType() == ttEMIT_FAILURE ? 1u : 0u};
}
if (callback_ && callback_->hook != beast::zero)
doHookCallback(proMeta, *callback_);
// remove emission entry if this is an emitted transaction
hook::removeEmissionEntry(ctx_);
+15 -1
View File
@@ -183,8 +183,18 @@ public:
bool collectCallsOnly = false);
protected:
/** A hook callback: the hook `hook` on `account`, cbak(`what`) */
struct HookCallback
{
AccountID account;
uint256 hook;
std::uint32_t what;
};
void
doHookCallback(std::shared_ptr<STObject const> const& provisionalMeta);
doHookCallback(
std::shared_ptr<STObject const> const& provisionalMeta,
HookCallback const& cb);
TER
doTSH(
@@ -237,6 +247,10 @@ protected:
// featureHooks is enabled
int executedHookCount_ =
0; // record how many hooks have executed across the whole transactor
// The callback made once this txn has applied with tesSUCCESS. If unset,
// an emitted txn calls back the hook that emitted it. doApply may
// redirect that, or defer it with a zero hook (see Export.h).
std::optional<HookCallback> callback_;
std::set<AccountID>
additionalWeakTSH_; // any TSH that needs weak hook execution at the
// end of the transactor, who isn't able to be
+1
View File
@@ -43,6 +43,7 @@
#include <xrpld/app/tx/detail/DeleteOracle.h>
#include <xrpld/app/tx/detail/DepositPreauth.h>
#include <xrpld/app/tx/detail/Escrow.h>
#include <xrpld/app/tx/detail/Export.h>
#include <xrpld/app/tx/detail/GenesisMint.h>
#include <xrpld/app/tx/detail/Import.h>
#include <xrpld/app/tx/detail/Invoke.h>
+2
View File
@@ -101,6 +101,8 @@ struct ConfigSection
#define SECTION_VALIDATOR_LIST_THRESHOLD "validator_list_threshold"
#define SECTION_VALIDATORS "validators"
#define SECTION_VALIDATOR_TOKEN "validator_token"
#define SECTION_EXPORT_KEY_FILE "export_key_file"
#define SECTION_XRPL_RELAY "xrpl_relay"
#define SECTION_VETO_AMENDMENTS "veto_amendments"
#define SECTION_WORKERS "workers"
+13
View File
@@ -475,6 +475,18 @@ private:
return rpcError(rpcNO_EVENTS);
}
// export_setup [<account> [<quorum>]]
Json::Value
parseExportSetup(Json::Value const& jvParams)
{
Json::Value jvRequest(Json::objectValue);
if (jvParams.size() > 0)
jvRequest[jss::account] = jvParams[0u].asString();
if (jvParams.size() > 1)
jvRequest["quorum"] = jvParams[1u].asUInt();
return jvRequest;
}
// feature [<feature>] [accept|reject]
Json::Value
parseFeature(Json::Value const& jvParams)
@@ -1475,6 +1487,7 @@ public:
{"connect", &RPCParser::parseConnect, 1, 2},
{"consensus_info", &RPCParser::parseAsIs, 0, 0},
{"deposit_authorized", &RPCParser::parseDepositAuthorized, 2, 11},
{"export_setup", &RPCParser::parseExportSetup, 0, 2},
{"feature", &RPCParser::parseFeature, 0, 2},
{"fee", &RPCParser::parseFee, 0, 1},
{"fetch_info", &RPCParser::parseFetchInfo, 0, 1},
+3 -1
View File
@@ -2936,7 +2936,9 @@ PeerImp::checkTransaction(
return;
}
if (isPseudoTx(*stx))
// Export signatures are likewise network generated and can only be
// checked against the ledger. Honest peers don't relay them.
if (isPseudoTx(*stx) || stx->getTxnType() == ttEXPORT_SIGN)
{
// Don't do anything with pseudo transactions except put them in the
// TransactionMaster cache
+1
View File
@@ -105,6 +105,7 @@ Handler const handlerArray[]{
byRef(&doDepositAuthorized),
Role::USER,
NO_CONDITION},
{"export_setup", byRef(&doExportSetup), Role::USER, NO_CONDITION},
{"feature", byRef(&doFeature), Role::USER, NO_CONDITION},
{"fee", byRef(&doFee), Role::USER, NEEDS_CURRENT_LEDGER},
{"fetch_info", byRef(&doFetchInfo), Role::ADMIN, NO_CONDITION},
+120
View File
@@ -0,0 +1,120 @@
//------------------------------------------------------------------------------
/*
This file is part of rippled: https://github.com/ripple/rippled
Copyright (c) 2026 XRPL-Labs
Permission to use, copy, modify, and/or distribute this software for any
purpose with or without fee is hereby granted, provided that the above
copyright notice and this permission notice appear in all copies.
THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
ANY SPECIAL , DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
*/
//==============================================================================
#include <xrpld/app/main/Application.h>
#include <xrpld/app/tx/detail/Export.h>
#include <xrpld/core/Config.h>
#include <xrpld/ledger/ReadView.h>
#include <xrpld/rpc/Context.h>
#include <xrpld/rpc/detail/RPCHelpers.h>
#include <xrpl/basics/StringUtilities.h>
#include <xrpl/json/to_string.h>
#include <xrpl/protocol/ErrorCodes.h>
#include <xrpl/protocol/jss.h>
namespace ripple {
/** export_setup: the SignerListSet that sets an account up on the network
it exports to (see Export.h), as a sign request for Xaman.
Takes the usual ledger selectors, plus optional:
account the account, filled into tx_json
quorum SignerQuorum (default: 80% of the signers, rounded up)
as_of ripple epoch seconds: each validator's newest export key
recorded at or before then (default: the current keys)
Returns:
tx_json the SignerListSet. Xaman fills in Fee and Sequence.
OperationLimit is this network's ID, so the
transaction's XPOP can be imported here, which is
what records the list for xport().
signers the export keys it lists, and their validators
export_keys_seq UNLReport.ExportKeysSeq; the list needs updating
when this changes
xaman_url a link Xaman opens tx_json from; render it as a
QR code to sign from a phone
Hooks need none of this: xport() a SignerListSet without SignerEntries.
*/
Json::Value
doExportSetup(RPC::JsonContext& context)
{
auto const& params = context.params;
if (params.isMember("as_of") && !params["as_of"].isIntegral())
return RPC::invalid_field_error("as_of");
std::optional<AccountID> account;
if (params.isMember(jss::account) &&
(!params[jss::account].isString() ||
!(account = parseBase58<AccountID>(params[jss::account].asString()))))
return RPC::invalid_field_error(jss::account);
std::shared_ptr<ReadView const> ledger;
auto result = RPC::lookupLedger(ledger, context);
if (!ledger)
return result;
auto const unl = ledger->read(keylet::UNLReport());
result["export_keys_seq"] = unl ? (*unl)[~sfExportKeysSeq].value_or(0) : 0;
auto const signers = exportSigners(
*ledger,
params.isMember("as_of")
? std::optional<std::uint32_t>(params["as_of"].asUInt())
: std::nullopt);
if (signers.empty())
return RPC::make_error(
rpcOBJECT_NOT_FOUND, "No export keys for the requested time.");
std::uint32_t quorum = (signers.size() * 4 + 4) / 5;
if (params.isMember("quorum"))
{
if (!params["quorum"].isIntegral() || params["quorum"].asUInt() == 0 ||
params["quorum"].asUInt() > signers.size())
return RPC::invalid_field_error("quorum");
quorum = params["quorum"].asUInt();
}
Json::Value& tx = result[jss::tx_json] = Json::objectValue;
tx[jss::TransactionType] = jss::SignerListSet;
if (account)
tx[jss::Account] = toBase58(*account);
tx[sfSignerQuorum.jsonName] = quorum;
tx[sfOperationLimit.jsonName] = context.app.config().NETWORK_ID;
Json::Value& entries = tx[sfSignerEntries.jsonName] = Json::arrayValue;
Json::Value& list = result["signers"] = Json::arrayValue;
for (auto const& s : signers)
{
Json::Value& o = list.append(Json::objectValue);
o["validator"] = toBase58(TokenType::NodePublic, s.validator);
o["export_key"] = strHex(s.key);
o["close_time"] = s.closeTime;
o[jss::account] = toBase58(s.account);
Json::Value& e = entries.append(Json::objectValue);
e[sfSignerEntry.jsonName][sfAccount.jsonName] = toBase58(s.account);
e[sfSignerEntry.jsonName][sfSignerWeight.jsonName] = 1;
}
// Xaman reads a hex JSON template from its detect link
result["xaman_url"] = "https://xaman.app/detect/" + strHex(to_string(tx));
return result;
}
} // namespace ripple
+2
View File
@@ -63,6 +63,8 @@ doConsensusInfo(RPC::JsonContext&);
Json::Value
doDepositAuthorized(RPC::JsonContext&);
Json::Value
doExportSetup(RPC::JsonContext&);
Json::Value
doFeature(RPC::JsonContext&);
Json::Value
doFee(RPC::JsonContext&);