mirror of
https://github.com/Xahau/xahaud.git
synced 2026-10-22 11:47:09 +00:00
Compare commits
12
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dfbde1587e | ||
|
|
4f3007863c | ||
|
|
dad3b787e5 | ||
|
|
e511eefb73 | ||
|
|
22989a3206 | ||
|
|
16be7688d0 | ||
|
|
754de04d8d | ||
|
|
6ecd63fc8b | ||
|
|
0021e6b990 | ||
|
|
95980fe14f | ||
|
|
76d75d3198 | ||
|
|
f34825a79a |
No files matched your search
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
#define featureRNG "1"
|
||||
#define fix20250131 "1"
|
||||
#define fixGuardDepth32 "1"
|
||||
#define featureExport "1"
|
||||
namespace hook_api {
|
||||
struct Rules
|
||||
{
|
||||
|
||||
@@ -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
|
||||
@@ -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>
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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},
|
||||
}))
|
||||
@@ -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(),
|
||||
{
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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 |
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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
|
||||
{
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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&
|
||||
|
||||
@@ -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
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -79,6 +79,9 @@ private:
|
||||
|
||||
TER
|
||||
applyEntropy();
|
||||
|
||||
TER
|
||||
applyExportKeyReport();
|
||||
};
|
||||
|
||||
using EnableAmendment = Change;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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());
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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_);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
@@ -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},
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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},
|
||||
|
||||
@@ -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
|
||||
@@ -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&);
|
||||
|
||||
Reference in new issue
Block a user