Compare commits

...

14 Commits

Author SHA1 Message Date
Richard Holland
ce8a88db79 fix failing test 2026-08-25 13:34:35 +10:00
Richard Holland
87ae5dd28c fix makeSetManifestTx 2026-08-25 12:19:08 +10:00
Richard Holland
cbe0ac7240 crashbugs 2026-08-25 11:49:18 +10:00
Richard Holland
2d34b08ed4 more missing 2026-08-24 14:41:40 +10:00
Richard Holland
032bd6346d hook codes 2026-08-24 11:44:46 +10:00
Richard Holland
6eb99f8a03 clang 2026-08-24 11:23:52 +10:00
Richard Holland
a30ca9c5b4 compiling 2026-08-24 10:57:44 +10:00
Richard Holland
97140ce8cf add ephemeral key weak tsh, tests 2026-08-24 10:24:55 +10:00
Richard Holland
824bb00d8f constrain manifest gossiped and auto submit new manifests on-chain 2026-08-23 13:34:46 +10:00
Richard Holland
2ea4cf0bce claude patch 2026-08-23 12:22:33 +10:00
Richard Holland
34a4d3dd85 more 2026-08-22 16:48:06 +10:00
Richard Holland
abc4fbd204 compiling 2026-08-22 16:21:12 +10:00
Richard Holland
bf378592c9 more ocm 2026-08-10 14:18:31 +10:00
Richard Holland
d85bbe1a39 most of on-chain manifests, yet to compile or test 2026-08-10 12:33:05 +10:00
26 changed files with 1607 additions and 10 deletions

View File

@@ -152,6 +152,7 @@
#define sfEscrowID ((5U << 16U) + 35U)
#define sfURITokenID ((5U << 16U) + 36U)
#define sfDomainID ((5U << 16U) + 37U)
#define sfManifestID ((5U << 16U) + 91U)
#define sfHookOnOutgoing ((5U << 16U) + 93U)
#define sfHookOnIncoming ((5U << 16U) + 94U)
#define sfCron ((5U << 16U) + 95U)
@@ -288,6 +289,7 @@
#define sfXChainCreateAccountAttestationCollectionElement ((14U << 16U) + 31U)
#define sfPriceData ((14U << 16U) + 32U)
#define sfCredential ((14U << 16U) + 33U)
#define sfManifest ((14U << 16U) + 90U)
#define sfAmountEntry ((14U << 16U) + 91U)
#define sfMintURIToken ((14U << 16U) + 92U)
#define sfHookEmission ((14U << 16U) + 93U)

View File

@@ -61,6 +61,7 @@
#define ttNFTOKEN_MODIFY 70
#define ttPERMISSIONED_DOMAIN_SET 71
#define ttPERMISSIONED_DOMAIN_DELETE 72
#define ttMANIFEST_SET 91
#define ttCRON 92
#define ttCRON_SET 93
#define ttREMARKS_SET 94

View File

@@ -372,6 +372,10 @@ permissionedDomain(AccountID const& account, std::uint32_t seq) noexcept;
Keylet
permissionedDomain(uint256 const& domainID) noexcept;
Keylet
manifest(PublicKey const& pk) noexcept;
} // namespace keylet
// Everything below is deprecated and should be removed in favor of keylets:

View File

@@ -194,6 +194,8 @@ enum TEFcodes : TERUnderlyingType {
tefNONDIR_EMIT,
tefIMPORT_BLACKHOLED,
tefINVALID_LEDGER_FIX_TYPE,
tefPAST_MANIFEST_SEQ,
tefREVOKED_MANIFEST,
};
//------------------------------------------------------------------------------

View File

@@ -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(OnChainManifests, Supported::yes, VoteBehavior::DefaultNo)
XRPL_FIX (HookMap, Supported::yes, VoteBehavior::DefaultYes)
XRPL_FIX (GuardDepth32, Supported::yes, VoteBehavior::DefaultNo)
XRPL_FEATURE(NamedHooks, Supported::yes, VoteBehavior::DefaultNo)

View File

@@ -262,6 +262,7 @@ LEDGER_ENTRY(ltACCOUNT_ROOT, 0x0061, AccountRoot, account, ({
{sfHookStateScale, soeOPTIONAL},
{sfCron, soeOPTIONAL},
{sfAMMID, soeOPTIONAL},
{sfManifestID, soeOPTIONAL},
}))
/** A ledger object which contains a list of object identifiers.
@@ -592,6 +593,26 @@ LEDGER_ENTRY(ltDID, 0x008D, DID, did, ({
{sfPreviousTxnLgrSeq, soeREQUIRED},
}))
LEDGER_ENTRY(ltMANIFEST, 0x004D, Manifest, manifest_entry, ({
{sfAccount, soeREQUIRED},
{sfPublicKey, soeREQUIRED},
{sfSigningPubKey, soeOPTIONAL}, /* may be absent if the master is revoked */
{sfSequence, soeREQUIRED},
/* sfVersion defaults to 0 and is omitted from the signed payload when
absent. Storing it unconditionally would change the bytes and break
signature verification when the manifest is reconstructed. */
{sfVersion, soeOPTIONAL},
{sfDomain, soeOPTIONAL},
/* The signatures are mirrored so the object is a lossless copy of the
manifest and can be independently verified, and re-served to peers,
by any node reading it (see ManifestCache::applyLedger). */
{sfMasterSignature, soeREQUIRED},
{sfSignature, soeOPTIONAL}, /* absent if the master is revoked */
{sfManifestID, soeOPTIONAL}, /* pointer to the identical other copy on the other key */
{sfPreviousTxnID, soeREQUIRED},
{sfPreviousTxnLgrSeq, soeREQUIRED},
}))
#undef EXPAND
#undef LEDGER_ENTRY_DUPLICATE

View File

@@ -210,6 +210,7 @@ TYPED_SFIELD(sfOfferID, UINT256, 34)
TYPED_SFIELD(sfEscrowID, UINT256, 35)
TYPED_SFIELD(sfURITokenID, UINT256, 36)
TYPED_SFIELD(sfDomainID, UINT256, 37)
TYPED_SFIELD(sfManifestID, UINT256, 91)
TYPED_SFIELD(sfHookOnOutgoing, UINT256, 93)
TYPED_SFIELD(sfHookOnIncoming, UINT256, 94)
TYPED_SFIELD(sfCron, UINT256, 95)
@@ -382,6 +383,7 @@ UNTYPED_SFIELD(sfXChainClaimAttestationCollectionElement, OBJECT, 30)
UNTYPED_SFIELD(sfXChainCreateAccountAttestationCollectionElement, OBJECT, 31)
UNTYPED_SFIELD(sfPriceData, OBJECT, 32)
UNTYPED_SFIELD(sfCredential, OBJECT, 33)
UNTYPED_SFIELD(sfManifest, OBJECT, 90)
UNTYPED_SFIELD(sfAmountEntry, OBJECT, 91)
UNTYPED_SFIELD(sfMintURIToken, OBJECT, 92)
UNTYPED_SFIELD(sfHookEmission, OBJECT, 93)

View File

@@ -500,6 +500,12 @@ TRANSACTION(ttPERMISSIONED_DOMAIN_DELETE, 72, PermissionedDomainDelete, ({
{sfDomainID, soeREQUIRED},
}))
/* sfAccount is supplied by TxFormats::commonFields; listing it here as well
makes the SOTemplate reject the format at construction. */
TRANSACTION(ttMANIFEST_SET, 91, SetManifest, ({
{sfManifest, soeREQUIRED},
}))
/* A pseudo-txn alarm signal for invoking a hook, emitted by validators after alarm set conditions are met */
TRANSACTION(ttCRON, 92, Cron, ({
{sfOwner, soeREQUIRED},

View File

@@ -89,6 +89,7 @@ enum class LedgerNameSpace : std::uint16_t {
ORACLE = LEDGER_NAMESPACE2(0x01, 'R'),
MPTOKEN_ISSUANCE = '~',
MPTOKEN = 't',
MANIFEST = 'M',
CREDENTIAL = LEDGER_NAMESPACE2(0x01, 'D'),
PERMISSIONED_DOMAIN = 'm',
@@ -670,6 +671,12 @@ permissionedDomain(uint256 const& domainID) noexcept
return {ltPERMISSIONED_DOMAIN, domainID};
}
Keylet
manifest(PublicKey const& pk) noexcept
{
return {ltMANIFEST, indexHash(LedgerNameSpace::MANIFEST, pk.slice())};
}
} // namespace keylet
} // namespace ripple

View File

@@ -151,6 +151,8 @@ transResults()
MAKE_ERROR(tefNONDIR_EMIT, "An emitted txn was injected into the ledger without a corresponding directory entry."),
MAKE_ERROR(tefIMPORT_BLACKHOLED, "Cannot import keying because target account is blackholed."),
MAKE_ERROR(tefINVALID_LEDGER_FIX_TYPE, "The LedgerFixType field has an invalid value."),
MAKE_ERROR(tefPAST_MANIFEST_SEQ, "The submitted manifest's sequence is not newer than the current."),
MAKE_ERROR(tefREVOKED_MANIFEST, "The submitted manifest is for a revoked master key."),
MAKE_ERROR(telLOCAL_ERROR, "Local failure."),
MAKE_ERROR(telBAD_DOMAIN, "Domain too long."),

View File

@@ -30,7 +30,10 @@
#include <xrpl/basics/StringUtilities.h>
#include <xrpl/hook/Enum.h>
#include <xrpl/protocol/Feature.h>
#include <xrpl/protocol/HashPrefix.h>
#include <xrpl/protocol/PayChan.h>
#include <xrpl/protocol/PublicKey.h>
#include <xrpl/protocol/Sign.h>
#include <xrpl/protocol/jss.h>
#include <vector>
@@ -8445,6 +8448,150 @@ private:
}
}
// Builds a manifest signed by `master`, nominating `ephemeral` as the
// signing key. A sequence of UINT32_MAX makes it a revocation, which by
// definition carries no signing key.
static std::string
makeManifestString(
jtx::Account const& master,
jtx::Account const& ephemeral,
std::uint32_t seq)
{
STObject st(sfGeneric);
st[sfSequence] = seq;
st[sfPublicKey] = master.pk();
if (seq != std::numeric_limits<std::uint32_t>::max())
{
st[sfSigningPubKey] = ephemeral.pk();
sign(
st,
HashPrefix::manifest,
*publicKeyType(ephemeral.pk()),
ephemeral.sk());
}
sign(
st,
HashPrefix::manifest,
*publicKeyType(master.pk()),
master.sk(),
sfMasterSignature);
Serializer s;
st.add(s);
return std::string(static_cast<char const*>(s.data()), s.size());
}
// A manifest transaction carries no account signature, so it cannot be
// submitted through env() the way a signed transaction can. Returns the
// resulting transaction id so the caller can inspect its metadata.
uint256
submitManifest(jtx::Env& env, std::string const& manifest)
{
Json::Value params;
params[jss::manifest] = strHex(manifest);
auto const jrr = env.rpc("json", "submit", to_string(params));
auto const& result = jrr[jss::result];
if (!BEAST_EXPECT(
result[jss::engine_result].asString() == "tesSUCCESS"))
{
log << "submitManifest: " << to_string(jrr) << std::endl;
return beast::zero;
}
// An error response carries no tx_json, and strUnHex("") yields an
// engaged but empty Blob, so fromVoid() would memcpy from nullptr.
auto const blob = strUnHex(result[jss::tx_json][jss::hash].asString());
auto const hash =
blob ? uint256::fromVoidChecked(*blob) : std::optional<uint256>{};
if (!BEAST_EXPECT(hash.has_value()))
{
log << "submitManifest: " << to_string(jrr) << std::endl;
return beast::zero;
}
return *hash;
}
// SetManifest
// | otxn | tsh | manifest |
// | M | M | N/A |
// | M | E | W | ephemeral key's logical account
void
testSetManifestTSH(FeatureBitset features)
{
using namespace test::jtx;
using namespace std::literals;
testcase("set manifest TSH");
if (!features[featureOnChainManifests])
return;
// otxn: master
// tsh: ephemeral
// w/s: weak
//
// The ephemeral account is only named by the manifest, so it may
// observe the transaction but not rollback it. It therefore fires only
// when it has asked to collect.
for (bool const testStrong : {true, false})
{
test::jtx::Env env{
*this,
network::makeNetworkConfig(21337, "10", "1000000", "200000"),
features};
auto const master = Account("master", KeyType::ed25519);
auto const ephemeral = Account("ephemeral", KeyType::ed25519);
env.fund(XRP(1000), master, ephemeral);
env.close();
if (!testStrong)
addWeakTSH(env, ephemeral);
setTSHHook(env, ephemeral, testStrong);
auto const txHash =
submitManifest(env, makeManifestString(master, ephemeral, 1));
env.close();
// A strong hook on a weak stake holder is never reached.
auto const expected = testStrong ? tshNONE : tshWEAK;
testTSHStrongWeak(env, txHash, expected, __LINE__);
}
// A revocation names no signing key, so there is no ephemeral stake
// holder to notify at all.
{
test::jtx::Env env{
*this,
network::makeNetworkConfig(21337, "10", "1000000", "200000"),
features};
auto const master = Account("master", KeyType::ed25519);
auto const ephemeral = Account("ephemeral", KeyType::ed25519);
env.fund(XRP(1000), master, ephemeral);
env.close();
addWeakTSH(env, ephemeral);
setTSHHook(env, ephemeral, false);
auto const txHash = submitManifest(
env,
makeManifestString(
master,
ephemeral,
std::numeric_limits<std::uint32_t>::max()));
env.close();
testTSHStrongWeak(env, txHash, tshNONE, __LINE__);
}
}
void
testTSH(FeatureBitset features)
{

View File

@@ -0,0 +1,400 @@
//------------------------------------------------------------------------------
/*
This file is part of rippled: https://github.com/ripple/rippled
Copyright (c) 2025 Ripple Labs Inc.
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 <test/jtx/network.h>
#include <xrpld/app/misc/Manifest.h>
#include <xrpld/core/Config.h>
#include <xrpl/basics/StringUtilities.h>
#include <xrpl/basics/strHex.h>
#include <xrpl/json/to_string.h>
#include <xrpl/protocol/Feature.h>
#include <xrpl/protocol/HashPrefix.h>
#include <xrpl/protocol/Indexes.h>
#include <xrpl/protocol/PublicKey.h>
#include <xrpl/protocol/Sign.h>
#include <xrpl/protocol/jss.h>
#include <xrpl/protocol/st.h>
#include <limits>
namespace ripple {
namespace test {
/** Tests the OnChainManifests amendment: the SetManifest transactor, the
submit RPC path that builds its transaction, and reading manifests back out
of the ledger into the manifest cache.
*/
struct SetManifest_test : public beast::unit_test::suite
{
// A manifest transaction is unsigned, so the network id is mandatory and
// the network must be one that requires it (id > 1024).
static std::unique_ptr<Config>
makeConfig()
{
return jtx::network::makeNetworkConfig(
21337, "10", "1000000", "200000");
}
/** Builds a manifest signed by `master` nominating `ephemeral`.
A sequence of UINT32_MAX makes it a revocation, which by definition
names no signing key and carries no ephemeral signature.
*/
static std::string
makeManifest(
jtx::Account const& master,
jtx::Account const& ephemeral,
std::uint32_t seq)
{
STObject st(sfGeneric);
st[sfSequence] = seq;
st[sfPublicKey] = master.pk();
if (seq != std::numeric_limits<std::uint32_t>::max())
{
st[sfSigningPubKey] = ephemeral.pk();
sign(
st,
HashPrefix::manifest,
*publicKeyType(ephemeral.pk()),
ephemeral.sk());
}
sign(
st,
HashPrefix::manifest,
*publicKeyType(master.pk()),
master.sk(),
sfMasterSignature);
Serializer s;
st.add(s);
return std::string(static_cast<char const*>(s.data()), s.size());
}
/** Submits a manifest through the submit RPC.
The transaction is unsigned -- authority comes from the manifest's own
master and ephemeral signatures -- so it cannot be submitted through
env() the way a signed transaction can.
*/
static Json::Value
submit(jtx::Env& env, std::string const& manifest)
{
Json::Value params;
params[jss::manifest] = strHex(manifest);
return env.rpc("json", "submit", to_string(params))[jss::result];
}
static std::string
engineResult(Json::Value const& result)
{
return result[jss::engine_result].asString();
}
void
testSubmission(FeatureBitset features)
{
testcase("submission");
using namespace jtx;
Env env{*this, makeConfig(), features};
auto const master = Account("master", KeyType::ed25519);
auto const ephemeral = Account("ephemeral", KeyType::ed25519);
env.fund(XRP(1000), master);
env.close();
BEAST_EXPECT(
engineResult(submit(env, makeManifest(master, ephemeral, 1))) ==
"tesSUCCESS");
env.close();
// A manifest is written twice so it can be found from either key, and
// each copy points at the other.
auto const byMaster = env.le(keylet::manifest(master.pk()));
auto const byEphemeral = env.le(keylet::manifest(ephemeral.pk()));
if (!BEAST_EXPECT(byMaster))
return;
if (!BEAST_EXPECT(byEphemeral))
return;
BEAST_EXPECT(byMaster->getAccountID(sfAccount) == master.id());
BEAST_EXPECT(byEphemeral->getAccountID(sfAccount) == master.id());
BEAST_EXPECT(byMaster->getFieldU32(sfSequence) == 1);
BEAST_EXPECT(
byMaster->getFieldH256(sfManifestID) ==
keylet::manifest(ephemeral.pk()).key);
BEAST_EXPECT(
byEphemeral->getFieldH256(sfManifestID) ==
keylet::manifest(master.pk()).key);
// Both signatures are mirrored so the object can be verified, and
// re-served, by any node that reads it.
BEAST_EXPECT(byMaster->isFieldPresent(sfMasterSignature));
BEAST_EXPECT(byMaster->isFieldPresent(sfSignature));
// The account root points at the master key's copy.
auto const sleAcct = env.le(master);
if (!BEAST_EXPECT(sleAcct))
return;
BEAST_EXPECT(
sleAcct->getFieldH256(sfManifestID) ==
keylet::manifest(master.pk()).key);
// The account sequence must be untouched: the transaction is unsigned
// and pinned to sequence 0, so consuming a sequence would let a third
// party burn the validator's sequence numbers -- and writing seq + 1
// would reset the account to 1.
BEAST_EXPECT(sleAcct->getFieldU32(sfSequence) == env.seq(master));
}
void
testUpdate(FeatureBitset features)
{
testcase("update and stale rejection");
using namespace jtx;
Env env{*this, makeConfig(), features};
auto const master = Account("master", KeyType::ed25519);
auto const eph1 = Account("eph1", KeyType::ed25519);
auto const eph2 = Account("eph2", KeyType::ed25519);
env.fund(XRP(1000), master);
env.close();
submit(env, makeManifest(master, eph1, 1));
env.close();
// Rotating the ephemeral key erases both old copies and writes two
// new ones, so the two can never drift apart.
BEAST_EXPECT(
engineResult(submit(env, makeManifest(master, eph2, 2))) ==
"tesSUCCESS");
env.close();
BEAST_EXPECT(!env.le(keylet::manifest(eph1.pk())));
if (!BEAST_EXPECT(env.le(keylet::manifest(eph2.pk()))))
return;
BEAST_EXPECT(
env.le(keylet::manifest(master.pk()))->getFieldU32(sfSequence) ==
2);
// Replaying the manifest we just applied, and anything older, is
// rejected on sequence. This is what prevents replay: the transaction
// is unsigned, so nothing else would.
BEAST_EXPECT(
engineResult(submit(env, makeManifest(master, eph2, 2))) ==
"tefPAST_MANIFEST_SEQ");
BEAST_EXPECT(
engineResult(submit(env, makeManifest(master, eph1, 1))) ==
"tefPAST_MANIFEST_SEQ");
}
void
testRevocation(FeatureBitset features)
{
testcase("revocation");
using namespace jtx;
Env env{*this, makeConfig(), features};
auto const master = Account("master", KeyType::ed25519);
auto const ephemeral = Account("ephemeral", KeyType::ed25519);
env.fund(XRP(1000), master);
env.close();
submit(env, makeManifest(master, ephemeral, 1));
env.close();
BEAST_EXPECT(
engineResult(submit(
env,
makeManifest(
master,
ephemeral,
std::numeric_limits<std::uint32_t>::max()))) ==
"tesSUCCESS");
env.close();
// A revocation has no signing key, so only the master key's copy
// exists and it points at nothing.
auto const byMaster = env.le(keylet::manifest(master.pk()));
if (!BEAST_EXPECT(byMaster))
return;
BEAST_EXPECT(!env.le(keylet::manifest(ephemeral.pk())));
BEAST_EXPECT(!byMaster->isFieldPresent(sfManifestID));
BEAST_EXPECT(!byMaster->isFieldPresent(sfSigningPubKey));
BEAST_EXPECT(
byMaster->getFieldU32(sfSequence) ==
std::numeric_limits<std::uint32_t>::max());
// Nothing supersedes a revocation.
BEAST_EXPECT(
engineResult(submit(env, makeManifest(master, ephemeral, 2))) ==
"tefREVOKED_MANIFEST");
}
void
testRetrieval(FeatureBitset features)
{
testcase("retrieval into the manifest cache");
using namespace jtx;
Env env{*this, makeConfig(), features};
auto const master = Account("master", KeyType::ed25519);
auto const eph1 = Account("eph1", KeyType::ed25519);
auto const eph2 = Account("eph2", KeyType::ed25519);
env.fund(XRP(1000), master);
env.close();
submit(env, makeManifest(master, eph1, 1));
env.close();
auto& cache = env.app().validatorManifests();
// Nothing has fed the cache yet, so an unknown key maps to itself.
BEAST_EXPECT(cache.getMasterKey(eph1.pk()) == eph1.pk());
// Reading the ledger resolves both directions of the mapping. The
// manifest is reconstructed from the ledger object and verified, so a
// lossy round trip would fail here rather than be accepted.
BEAST_EXPECT(cache.applyLedger(*env.closed(), {master.pk()}) == 1);
BEAST_EXPECT(cache.getMasterKey(eph1.pk()) == master.pk());
BEAST_EXPECT(cache.getSigningKey(master.pk()) == eph1.pk());
BEAST_EXPECT(cache.getSequence(master.pk()) == 1);
// Applying the same ledger again is a no-op: the cache is already at
// that sequence.
BEAST_EXPECT(cache.applyLedger(*env.closed(), {master.pk()}) == 0);
// A rotation on-ledger is picked up, and the superseded ephemeral key
// stops resolving.
submit(env, makeManifest(master, eph2, 2));
env.close();
BEAST_EXPECT(cache.applyLedger(*env.closed(), {master.pk()}) == 1);
BEAST_EXPECT(cache.getSigningKey(master.pk()) == eph2.pk());
BEAST_EXPECT(cache.getMasterKey(eph1.pk()) == eph1.pk());
// A key we never ask about is never read.
BEAST_EXPECT(cache.applyLedger(*env.closed(), {eph2.pk()}) == 0);
// A revocation reaches the cache too, and is the one thing that must
// never be lost: forgetting it would mean trusting the old key again.
submit(
env,
makeManifest(
master, eph2, std::numeric_limits<std::uint32_t>::max()));
env.close();
BEAST_EXPECT(cache.applyLedger(*env.closed(), {master.pk()}) == 1);
BEAST_EXPECT(cache.revoked(master.pk()));
}
void
testMalformed(FeatureBitset features)
{
testcase("malformed submissions");
using namespace jtx;
Env env{*this, makeConfig(), features};
auto const master = Account("master", KeyType::ed25519);
auto const other = Account("other", KeyType::ed25519);
auto const ephemeral = Account("ephemeral", KeyType::ed25519);
env.fund(XRP(1000), master, other);
env.close();
// Not a manifest at all.
BEAST_EXPECT(
submit(env, "not a manifest")[jss::error].asString() ==
"invalidManifest");
// A manifest whose ephemeral signature does not check out.
{
auto blob = makeManifest(master, ephemeral, 1);
blob[blob.size() - 1] ^= 0xFF;
BEAST_EXPECT(
submit(env, blob)[jss::error].asString() == "invalidManifest");
}
// The master key's account must exist: it pays the fee and holds the
// pointer to the manifest.
auto const unfunded = Account("unfunded", KeyType::ed25519);
BEAST_EXPECT(
engineResult(submit(env, makeManifest(unfunded, ephemeral, 1))) ==
"terNO_ACCOUNT");
// An ephemeral key already claimed by a different account would
// collide with -- and clobber -- that account's manifest object.
submit(env, makeManifest(master, ephemeral, 1));
env.close();
BEAST_EXPECT(
engineResult(submit(env, makeManifest(other, ephemeral, 1))) ==
"tecDUPLICATE");
}
void
testDisabled(FeatureBitset features)
{
testcase("amendment gate");
using namespace jtx;
Env env{*this, makeConfig(), features - featureOnChainManifests};
auto const master = Account("master", KeyType::ed25519);
auto const ephemeral = Account("ephemeral", KeyType::ed25519);
env.fund(XRP(1000), master);
env.close();
// The submitted transaction carries no account signature, so with the
// amendment off it would otherwise be rejected as unsigned and never
// reach preflight's temDISABLED. The RPC therefore refuses it up
// front, and says why.
auto const result = submit(env, makeManifest(master, ephemeral, 1));
BEAST_EXPECT(result[jss::error].asString() == "notEnabled");
BEAST_EXPECT(!result.isMember(jss::engine_result));
env.close();
BEAST_EXPECT(!env.le(keylet::manifest(master.pk())));
}
public:
void
run() override
{
using namespace test::jtx;
auto const sa = supported_amendments();
testSubmission(sa);
testUpdate(sa);
testRevocation(sa);
testRetrieval(sa);
testMalformed(sa);
testDisabled(sa);
}
};
BEAST_DEFINE_TESTSUITE(SetManifest, app, ripple);
} // namespace test
} // namespace ripple

View File

@@ -9,7 +9,9 @@
#include <xrpld/app/tx/detail/NFTokenUtils.h>
#include <xrpl/basics/Log.h>
#include <xrpl/basics/Slice.h>
#include <xrpl/protocol/AccountID.h>
#include <xrpl/protocol/ErrorCodes.h>
#include <xrpl/protocol/PublicKey.h>
#include <xrpl/protocol/TxFlags.h>
#include <xrpl/protocol/st.h>
#include <xrpl/protocol/tokens.h>
@@ -513,6 +515,26 @@ getTransactionalStakeHolders(STTx const& tx, ReadView const& rv)
case ttCRON_SET: {
break;
}
case ttMANIFEST_SET: {
// The ephemeral key's logical account, meaning the r-address its
// public key hashes to, is a weak stake holder: the manifest names
// that key but nothing is done to the account, so it may observe
// but not rollback. Usually no such account exists, in which case
// nothing executes.
if (!tx.isFieldPresent(sfManifest))
break;
STObject const& man =
const_cast<STTx&>(tx).getField(sfManifest).downcast<STObject>();
if (!man.isFieldPresent(sfSigningPubKey))
break;
auto const spk = man.getFieldVL(sfSigningPubKey);
if (publicKeyType(makeSlice(spk)))
ADD_TSH(calcAccountID(PublicKey(makeSlice(spk))), tshWEAK);
break;
}
case ttAMM_CREATE:
case ttAMM_DEPOSIT:
case ttAMM_WITHDRAW:

View File

@@ -25,12 +25,18 @@
#include <xrpl/protocol/PublicKey.h>
#include <xrpl/protocol/SecretKey.h>
#include <algorithm>
#include <atomic>
#include <mutex>
#include <optional>
#include <shared_mutex>
#include <string>
#include <vector>
namespace ripple {
class ReadView;
/*
Validator key manifests
-----------------------
@@ -172,6 +178,16 @@ deserializeManifest(
return deserializeManifest(makeSlice(s), journal);
}
inline std::optional<Manifest>
deserializeManifest(
STObject const& st,
beast::Journal journal = beast::Journal(beast::Journal::getNullSink()))
{
Serializer s;
st.add(s);
return deserializeManifest(makeSlice(s.peekData()), journal);
}
template <
class T,
class = std::enable_if_t<
@@ -264,9 +280,41 @@ private:
/** Master public keys stored by current ephemeral public key. */
hash_map<PublicKey, PublicKey> signingToMasterKeys_;
/** Master keys always offered to a peer, whatever their recency.
Set by pin(); in practice the master keys on the configured validator
lists, which are the manifests consensus actually depends on.
*/
hash_set<PublicKey> pinned_;
/** Recency of use, keyed by master public key.
The structure of this map is guarded by mutex_ exactly as map_ is:
entries are created next to it in applyManifest() and are never
removed. The counters themselves are atomic, so recording a hit is a
write to an atomic rather than a structural modification and is legal
while only a shared lock is held.
This is deliberately not a second mutex. A second mutex would have to
be ordered against mutex_, and that ordering would be an unenforced
invariant that any future caller could invert.
*/
hash_map<PublicKey, std::atomic<std::uint64_t>> mutable lastUsed_;
std::atomic<std::uint64_t> mutable tick_{0};
/** Record that a manifest was looked up.
@pre The caller holds mutex_, shared or exclusive.
*/
void
touch(PublicKey const& masterKey) const;
std::atomic<std::uint32_t> seq_{0};
public:
/** Ceiling on the unpinned manifests offered to a newly connected peer. */
static constexpr std::size_t gossipLimit = 64;
explicit ManifestCache(
beast::Journal j = beast::Journal(beast::Journal::getNullSink()))
: j_(j)
@@ -355,6 +403,61 @@ public:
ManifestDisposition
applyManifest(Manifest m);
/** Set the master keys that are always offered to peers.
Replaces any previous set. Bumps sequence() when the set actually
changes, so a cached gossip message built from it is rebuilt.
@param keys Master public keys to pin
@par Thread Safety
May be called concurrently
*/
void
pin(hash_set<PublicKey> keys);
/** Returns the sequence and serialized form of a held manifest.
Unlike getManifest() and getSequence(), a revoked master key is
reported rather than skipped. Those two answer "what should I trust",
for which a revocation is correctly nothing; a caller republishing what
it holds needs the revocation most of all.
@param pk Master public key
@par Thread Safety
May be called concurrently
*/
std::optional<std::pair<std::uint32_t, std::string>>
getRawManifest(PublicKey const& pk) const;
/** Ingest manifests published on-ledger.
Reads keylet::manifest() for each supplied master key, reconstructs any
manifest found and feeds it through applyManifest(), so an on-chain
manifest is subject to exactly the same staleness, revocation and
key-reuse rules -- and the same signature check -- as one arriving by
peer gossip or in a published list. It is a third source of manifests,
not a more trusted one.
Probes a known key set rather than scanning the ledger's transactions:
this costs one SHAMap read per key, and picks up manifests published in
ledgers this node never saw.
@param view Ledger to read from
@param masterKeys Master public keys to probe for
@return the number of manifests newly accepted
@par Thread Safety
May be called concurrently
*/
std::size_t
applyLedger(ReadView const& view, hash_set<PublicKey> const& masterKeys);
/** Populate manifest cache with manifests in database and config.
@param dbCon Database connection with dbTable
@@ -460,6 +563,70 @@ public:
f(manifest);
}
}
/** Invokes the callback for the manifests worth offering a new peer.
Offering the whole cache means offering everything the node has ever
seen, which grows without bound and is mostly of no use to the peer.
This offers the pinned set plus up to gossipLimit further manifests,
most recently used first. A manifest left out still reaches the peer by
ordinary relay if it turns out to be needed.
@note Do not call ManifestCache member functions from within the
callback. This can re-lock the mutex from the same thread, which is UB.
@note Do not write ManifestCache member variables from
within the callback. This can lead to data races.
@param pf Pre-function called with the maximum number of times f will be
called (useful for memory allocations)
@param f Function called for each manifest
@par Thread Safety
May be called concurrently
*/
template <class PreFun, class EachFun>
void
for_each_gossip_manifest(PreFun&& pf, EachFun&& f) const
{
std::shared_lock lock{mutex_};
// Rank the unpinned entries by recency and keep the head. Pointers
// into map_ stay valid: it is node based and the shared lock is held
// throughout.
std::vector<std::pair<std::uint64_t, PublicKey const*>> ranked;
ranked.reserve(map_.size());
for (auto const& [key, manifest] : map_)
{
(void)manifest;
if (pinned_.count(key))
continue;
auto const used = lastUsed_.find(key);
ranked.emplace_back(
used == lastUsed_.end()
? 0
: used->second.load(std::memory_order_relaxed),
&key);
}
auto const keep = std::min(gossipLimit, ranked.size());
std::partial_sort(
ranked.begin(),
ranked.begin() + keep,
ranked.end(),
[](auto const& a, auto const& b) { return a.first > b.first; });
// An upper bound: a pinned key need not have a manifest yet.
pf(pinned_.size() + keep);
for (auto const& key : pinned_)
if (auto const iter = map_.find(key); iter != map_.end())
f(iter->second);
for (std::size_t i = 0; i < keep; ++i)
f(map_.find(*ranked[i].second)->second);
}
};
} // namespace ripple

View File

@@ -33,6 +33,7 @@
#include <xrpld/app/misc/DeliverMax.h>
#include <xrpld/app/misc/HashRouter.h>
#include <xrpld/app/misc/LoadFeeTrack.h>
#include <xrpld/app/misc/Manifest.h>
#include <xrpld/app/misc/NetworkOPs.h>
#include <xrpld/app/misc/StateAccounting.h>
#include <xrpld/app/misc/Transaction.h>
@@ -42,6 +43,7 @@
#include <xrpld/app/misc/detail/AccountTxPaging.h>
#include <xrpld/app/rdb/backend/SQLiteDatabase.h>
#include <xrpld/app/tx/apply.h>
#include <xrpld/app/tx/detail/SetManifest.h> // makeSetManifestTx
#include <xrpld/consensus/Consensus.h>
#include <xrpld/consensus/ConsensusParms.h>
#include <xrpld/overlay/Cluster.h>
@@ -54,6 +56,7 @@
#include <xrpld/rpc/MPTokenIssuanceID.h>
#include <xrpld/rpc/ServerHandler.h>
#include <xrpld/rpc/detail/UDPInfoSub.h>
#include <xrpl/basics/StringUtilities.h>
#include <xrpl/basics/UptimeClock.h>
#include <xrpl/basics/mulDiv.h>
#include <xrpl/basics/safe_cast.h>
@@ -68,6 +71,7 @@
#include <xrpl/protocol/MultiApiJson.h>
#include <xrpl/protocol/RPCErr.h>
#include <xrpl/protocol/STParsedJSON.h>
#include <xrpl/protocol/Serializer.h>
#include <xrpl/protocol/jss.h>
#include <xrpl/resource/Fees.h>
#include <xrpl/resource/ResourceManager.h>
@@ -311,6 +315,8 @@ private:
switchLastClosedLedger(std::shared_ptr<Ledger const> const& newLCL);
bool
checkLastClosedLedger(const Overlay::PeerSequence&, uint256& networkClosed);
void
publishNewerManifests(ReadView const& ledger);
public:
bool
@@ -1156,6 +1162,72 @@ NetworkOPsImp::submitTransaction(std::shared_ptr<STTx const> const& iTrans)
});
}
void
NetworkOPsImp::publishNewerManifests(ReadView const& ledger)
{
for (auto const& pk : app_.validators().getTrustedMasterKeys())
{
// Only for validators that have opted in by publishing on-ledger
// already. Submitting spends the master key account's balance, so an
// account that has never used the feature is left alone.
auto const sleMan = ledger.read(keylet::manifest(pk));
if (!sleMan)
continue;
auto const held = app_.validatorManifests().getRawManifest(pk);
if (!held || held->first <= sleMan->getFieldU32(sfSequence))
continue;
auto const hex = makeSetManifestTx(
makeSlice(held->second),
app_.config().NETWORK_ID,
*app_.openLedger().current(),
app_.journal("Manifest"));
auto const blob = hex ? strUnHex(*hex) : std::nullopt;
if (!blob || blob->empty())
continue;
std::shared_ptr<STTx const> stTx;
std::string reason;
std::shared_ptr<Transaction> tx;
try
{
SerialIter sit{makeSlice(*blob)};
stTx = std::make_shared<STTx const>(std::ref(sit));
tx = std::make_shared<Transaction>(stTx, reason, app_);
}
catch (std::exception const& e)
{
JLOG(m_journal.warn())
<< "publishNewerManifests: " << e.what() << " " << reason;
continue;
}
if (tx->getStatus() != NEW)
continue;
// The master key account pays. Skip rather than submit something that
// can only fail: this runs every ledger, so an unfunded validator
// would otherwise be retried forever.
auto const sleAcct = ledger.read(keylet::account(calcAccountID(pk)));
if (!sleAcct ||
sleAcct->getFieldAmount(sfBalance).xrp() < (*stTx)[sfFee].xrp())
continue;
JLOG(m_journal.info())
<< "publishNewerManifests: publishing manifest seq " << held->first
<< " for " << toBase58(TokenType::NodePublic, pk);
// Submitted from the job queue because this runs on the consensus
// thread, which must not block on transaction processing.
m_job_queue.addJob(jtTRANSACTION, "publishManifest", [this, tx]() {
auto t = tx;
processTransaction(t, false, false, FailHard::no);
});
}
}
void
NetworkOPsImp::processTransaction(
std::shared_ptr<Transaction>& transaction,
@@ -1874,6 +1946,23 @@ NetworkOPsImp::beginConsensus(
if (prevLedger->rules().enabled(featureNegativeUNL))
app_.validators().setNegativeUNL(prevLedger->negativeUNL());
// Pull in any manifests published on-ledger before the trusted set is
// recomputed, so a validator that rotated its ephemeral key on-chain is
// resolved to the new signing key in this same round. The master keys come
// from the published lists, so this needs no bootstrap: only the ephemeral
// half of the mapping ever comes from a manifest.
if (prevLedger->rules().enabled(featureOnChainManifests))
{
app_.validatorManifests().applyLedger(
*prevLedger, app_.validators().getTrustedMasterKeys());
// The reverse of applyLedger above. Manifests reach us by peer gossip
// and in published validator lists, both of which can arrive before
// the validator gets around to publishing on-chain, so the cache may
// hold something newer than the ledger does.
publishNewerManifests(*prevLedger);
}
TrustChanges const changes = app_.validators().updateTrusted(
app_.getValidations().getCurrentNodeIDs(),
closingInfo.parentCloseTime,
@@ -1881,6 +1970,10 @@ NetworkOPsImp::beginConsensus(
app_.overlay(),
app_.getHashRouter());
// Pin the trusted master keys so they are always offered to a new peer and
// cannot be crowded out of the gossip set by more recently used manifests.
app_.validatorManifests().pin(app_.validators().getTrustedMasterKeys());
if (!changes.added.empty() || !changes.removed.empty())
{
app_.getValidations().trustChanged(changes.added, changes.removed);

View File

@@ -20,10 +20,12 @@
#include <xrpld/app/misc/Manifest.h>
#include <xrpld/app/rdb/Wallet.h>
#include <xrpld/core/DatabaseCon.h>
#include <xrpld/ledger/ReadView.h>
#include <xrpl/basics/Log.h>
#include <xrpl/basics/StringUtilities.h>
#include <xrpl/basics/base64.h>
#include <xrpl/json/json_reader.h>
#include <xrpl/protocol/Indexes.h>
#include <xrpl/protocol/PublicKey.h>
#include <xrpl/protocol/Sign.h>
@@ -54,7 +56,6 @@ deserializeManifest(Slice s, beast::Journal journal)
{
if (s.empty())
return std::nullopt;
static SOTemplate const manifestFormat{
// A manifest must include:
// - the master public key
@@ -301,7 +302,10 @@ ManifestCache::getSigningKey(PublicKey const& pk) const
auto const iter = map_.find(pk);
if (iter != map_.end() && !iter->second.revoked())
{
touch(pk);
return iter->second.signingKey;
}
return pk;
}
@@ -313,7 +317,10 @@ ManifestCache::getMasterKey(PublicKey const& pk) const
if (auto const iter = signingToMasterKeys_.find(pk);
iter != signingToMasterKeys_.end())
{
touch(iter->second);
return iter->second;
}
return pk;
}
@@ -349,7 +356,10 @@ ManifestCache::getManifest(PublicKey const& pk) const
auto const iter = map_.find(pk);
if (iter != map_.end() && !iter->second.revoked())
{
touch(pk);
return iter->second.serialized;
}
return std::nullopt;
}
@@ -361,11 +371,99 @@ ManifestCache::revoked(PublicKey const& pk) const
auto const iter = map_.find(pk);
if (iter != map_.end())
{
touch(pk);
return iter->second.revoked();
}
return false;
}
std::optional<std::pair<std::uint32_t, std::string>>
ManifestCache::getRawManifest(PublicKey const& pk) const
{
std::shared_lock lock{mutex_};
if (auto const iter = map_.find(pk); iter != map_.end())
{
touch(pk);
return std::make_pair(iter->second.sequence, iter->second.serialized);
}
return std::nullopt;
}
void
ManifestCache::touch(PublicKey const& masterKey) const
{
// find() rather than operator[]: inserting here would be a structural
// modification, and callers hold mutex_ only in shared mode. The entry is
// created in applyManifest() alongside the manifest itself, so a lookup
// that hit map_ always finds one here too.
if (auto const iter = lastUsed_.find(masterKey); iter != lastUsed_.end())
iter->second.store(++tick_, std::memory_order_relaxed);
}
void
ManifestCache::pin(hash_set<PublicKey> keys)
{
std::lock_guard lock{mutex_};
if (keys == pinned_)
return;
pinned_ = std::move(keys);
// The pinned set is part of what a gossip message contains, so a change to
// it has to invalidate any message cached against this sequence.
++seq_;
}
std::size_t
ManifestCache::applyLedger(
ReadView const& view,
hash_set<PublicKey> const& masterKeys)
{
std::size_t accepted = 0;
for (auto const& pk : masterKeys)
{
auto const sle = view.read(keylet::manifest(pk));
if (!sle)
continue;
// Cheap reject before rebuilding: applyManifest() would call this
// stale anyway, and the signature check is the expensive part.
if (auto const seq = getSequence(pk);
seq && *seq >= sle->getFieldU32(sfSequence))
continue;
// Rebuild the manifest exactly as it was signed. The ledger object is a
// lossless mirror written by SetManifest::doApply, so this round-trip
// is byte-identical to the blob the master key signed and verify()
// succeeds or the manifest is discarded. Presence matters: sfVersion is
// soeDEFAULT in the manifest format and must not be materialised.
STObject st{sfGeneric};
st.setFieldU32(sfSequence, sle->getFieldU32(sfSequence));
st.setFieldVL(sfPublicKey, sle->getFieldVL(sfPublicKey));
st.setFieldVL(sfMasterSignature, sle->getFieldVL(sfMasterSignature));
for (auto const& sf :
{std::cref(sfSigningPubKey),
std::cref(sfSignature),
std::cref(sfDomain)})
if (sle->isFieldPresent(sf.get()))
st.setFieldVL(sf.get(), sle->getFieldVL(sf.get()));
if (sle->isFieldPresent(sfVersion))
st.setFieldU16(sfVersion, sle->getFieldU16(sfVersion));
if (auto mo = deserializeManifest(st, j_); mo &&
applyManifest(std::move(*mo)) == ManifestDisposition::accepted)
++accepted;
}
return accepted;
}
ManifestDisposition
ManifestCache::applyManifest(Manifest m)
{
@@ -499,6 +597,10 @@ ManifestCache::applyManifest(Manifest m)
if (!revoked)
signingToMasterKeys_.emplace(*m.signingKey, m.masterKey);
// Kept in step with map_ so touch() never has to insert; see
// lastUsed_.
lastUsed_.try_emplace(m.masterKey, 0);
auto masterKey = m.masterKey;
map_.emplace(std::move(masterKey), std::move(m));

View File

@@ -1953,13 +1953,23 @@ TxQ::tryDirectApply(
const bool isFirstImport = !sleAccount &&
view.rules().enabled(featureImport) && tx->getTxnType() == ttIMPORT;
// A manifest txn is pinned to sfSequence 0 (Transactor::checkSeqProxy), so
// it can never match the account sequence. Direct-apply it like a first
// Import: letting it fall through to the queue would reject it outright on
// sequence rather than hold it. Manifests are therefore exempt from fee
// escalation, since requiredFeeLevel is not consulted for them.
const bool isManifest = view.rules().enabled(featureOnChainManifests) &&
tx->getTxnType() == ttMANIFEST_SET;
const bool bypassQueue = isFirstImport || isManifest;
// Don't attempt to direct apply if the account is not in the ledger.
if (!sleAccount && !isFirstImport)
return {};
std::optional<SeqProxy> txSeqProx;
if (!isFirstImport)
if (!bypassQueue)
{
SeqProxy const acctSeqProx =
SeqProxy::sequence((*sleAccount)[sfSequence]);
@@ -1972,7 +1982,7 @@ TxQ::tryDirectApply(
}
FeeLevel64 const requiredFeeLevel =
isFirstImport ? FeeLevel64{0} : [this, &view, flags]() {
bypassQueue ? FeeLevel64{0} : [this, &view, flags]() {
std::lock_guard lock(mutex_);
return getRequiredFeeLevel(
view, flags, feeMetrics_.getSnapshot(), lock);

View File

@@ -2033,9 +2033,11 @@ ValidatorList::updateTrusted(
trustChanges.added.insert(calcNodeID(val.first));
}
// If there were any changes, we need to update the ephemeral signing
// keys:
if (!trustChanges.added.empty() || !trustChanges.removed.empty())
// Rebuilt on every call rather than only when membership changed: a
// manifest that rotates a validator's ephemeral key changes no membership
// at all, so a membership-gated rebuild would leave trustedSigningKeys_
// stale and the rotated validator's validations would stop counting. This
// is a handful of map lookups over the UNL.
{
trustedSigningKeys_.clear();

View File

@@ -612,6 +612,7 @@ LedgerEntryTypesMatch::visitEntry(
case ltMPTOKEN:
case ltCREDENTIAL:
case ltPERMISSIONED_DOMAIN:
case ltMANIFEST:
break;
default:
invalidTypeAdded_ = true;

View File

@@ -0,0 +1,404 @@
//------------------------------------------------------------------------------
/*
This file is part of rippled: https://github.com/ripple/rippled
Copyright (c) 2024 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/misc/Manifest.h>
#include <xrpld/app/tx/detail/SetManifest.h>
#include <xrpld/core/Config.h>
#include <xrpld/ledger/View.h>
#include <xrpl/basics/Log.h>
#include <xrpl/basics/StringUtilities.h> // strUnHex
#include <xrpl/protocol/Feature.h>
#include <xrpl/protocol/Indexes.h>
#include <xrpl/protocol/PublicKey.h>
#include <xrpl/protocol/Quality.h>
#include <xrpl/protocol/TxFlags.h>
#include <xrpl/protocol/XRPAmount.h> // mulRatio
#include <xrpl/protocol/serialize.h>
#include <xrpl/protocol/st.h>
namespace ripple {
TxConsequences
SetManifest::makeTxConsequences(PreflightContext const& ctx)
{
return TxConsequences{ctx.tx, TxConsequences::normal};
}
NotTEC
SetManifest::preflight(PreflightContext const& ctx)
{
if (!ctx.rules.enabled(featureOnChainManifests))
return temDISABLED;
if (auto const ret = preflight1(ctx); !isTesSuccess(ret))
return ret;
auto& tx = ctx.tx;
auto& j = ctx.j;
if (tx.getFlags() & tfUniversalMask)
{
JLOG(j.warn()) << "SetManifest: Invalid flags set.";
return temINVALID_FLAG;
}
// rules:
// 1. sfManifest must match the manifest template and be validly signed
// 2. the signingpubkey must match the master r-address
// 3. manifest must not be already revoked
STObject const& obj =
const_cast<ripple::STTx&>(tx).getField(sfManifest).downcast<STObject>();
// 1. sfManifest must match the manifest template and be validly signed
auto manifest = deserializeManifest(obj, j);
if (!manifest.has_value())
{
JLOG(j.warn())
<< "SetManifest: invalid manifest passed (parseManifest failed).";
return temMALFORMED;
}
if (!manifest->verify())
{
JLOG(j.warn())
<< "SetManifest: invalid manifest passed (manifest.verify failed).";
return temMALFORMED;
}
// 2. master must match r-address
auto wantID = calcAccountID(manifest->masterKey);
if (ctx.tx.getAccountID(sfAccount) != wantID)
{
JLOG(j.warn())
<< "SetManifest: master key must match sfAccount (r-address).";
return temMALFORMED;
}
// 3. not already revoked will be checked in preclaim because it depends on
// lgr state
// 4. the envelope carries no account signature: authority comes solely
// from the manifest's own master/ephemeral signatures, which do not cover
// the envelope. Pin every envelope field a relayer could otherwise choose.
// The shape below must match the one checkValidity() recognises, or the
// txn falls through to the ordinary signature path and is rejected there.
// sfFee cannot be bounded here because the computed base fee is not in
// scope until preclaim; checkFee() bounds it instead.
if (!tx.isFieldPresent(sfSigningPubKey) || !tx.getSigningPubKey().empty() ||
!tx.isFieldPresent(sfTxnSignature) || !tx.getSignature().empty() ||
tx.isFieldPresent(sfSigners) || tx.isFieldPresent(sfAccountTxnID) ||
tx.isFieldPresent(sfTicketSequence) || tx.getFieldU32(sfSequence) != 0)
{
JLOG(j.warn())
<< "SetManifest: envelope must be unsigned with Sequence 0.";
return temMALFORMED;
}
return preflight2(ctx);
}
TER
SetManifest::preclaim(PreclaimContext const& ctx)
{
if (!ctx.view.rules().enabled(featureOnChainManifests))
return temDISABLED;
auto const id = ctx.tx[sfAccount];
// The account must exist: it pays the fee and anchors sfManifestID.
auto const sle = ctx.view.read(keylet::account(id));
if (!sle)
return terNO_ACCOUNT;
STObject const& newObj = const_cast<ripple::STTx&>(ctx.tx)
.getField(sfManifest)
.downcast<STObject>();
auto const newManifest = deserializeManifest(newObj, ctx.j);
if (!newManifest)
return tefINTERNAL; // preflight already parsed this successfully
// Replay protection. A byte-identical resubmission is rejected as
// tefALREADY by checkPriorTxAndLastLedger, but sfFee may vary within the
// band checkFee() allows, so the same manifest can also arrive under a
// different txid. The strictly-increasing sequence test below is what
// covers that, both within this ledger and in every later one. Either
// result is tef, so a replay is never included and never claims a fee.
if (sle->isFieldPresent(sfManifestID))
{
// A dangling sfManifestID is a corrupt ledger; doApply reports it.
if (auto const sleOld = ctx.view.read(
Keylet{ltMANIFEST, sle->getFieldH256(sfManifestID)}))
{
if (sleOld->getFieldU32(sfSequence) ==
std::numeric_limits<std::uint32_t>::max())
{
JLOG(ctx.j.warn()) << "SetManifest: New manifest submitted for "
"revoked master. "
<< id;
return tefREVOKED_MANIFEST;
}
if (newManifest->sequence <= sleOld->getFieldU32(sfSequence))
{
JLOG(ctx.j.warn())
<< "SetManifest: Manifest sequence already passed. " << id;
return tefPAST_MANIFEST_SEQ;
}
}
}
// On-chain equivalent of the badMasterKey/badEphemeralKey sanity checks in
// ManifestCache::applyManifest. keylet::manifest(masterKey) is 1:1 with the
// account, but keylet::manifest(signingKey) is not: without this, a
// manifest naming another validator's key as its ephemeral key would
// collide with -- and clobber -- that validator's object.
if (newManifest->signingKey)
{
auto const sleEph =
ctx.view.read(keylet::manifest(*newManifest->signingKey));
if (sleEph && sleEph->getAccountID(sfAccount) != id)
{
JLOG(ctx.j.warn())
<< "SetManifest: Ephemeral key already claimed by another "
"account. "
<< id;
return tecDUPLICATE;
}
}
return tesSUCCESS;
}
TER
SetManifest::doApply()
{
auto sle = view().peek(keylet::account(account_));
if (!sle)
return tefINTERNAL;
STObject const& obj = const_cast<ripple::STTx&>(ctx_.tx)
.getField(sfManifest)
.downcast<STObject>();
auto const manifest = deserializeManifest(obj, j_);
// Both of these were established in preflight.
if (!manifest || calcAccountID(manifest->masterKey) != account_)
return tefINTERNAL;
// A manifest is stored twice so it can be found from either key:
// keylet::manifest(masterKey) -> obj1, sfManifestID -> obj2
// keylet::manifest(signingKey) -> obj2, sfManifestID -> obj1
// A revoked manifest has no signing key, so it exists only as obj1 with no
// sfManifestID. Both copies are erased and rewritten on every update so
// they can never drift apart.
if (sle->isFieldPresent(sfManifestID))
{
uint256 const firstID = sle->getFieldH256(sfManifestID);
auto const sleMan1 = view().peek(Keylet{ltMANIFEST, firstID});
if (!sleMan1 || sleMan1->getAccountID(sfAccount) != account_)
{
JLOG(j_.error()) << "SetManifest: Old manifest object missing or "
"misowned (ID1) !! "
<< strHex(firstID);
return tefBAD_LEDGER;
}
// Absent when the previous manifest was a revocation.
if (sleMan1->isFieldPresent(sfManifestID))
{
uint256 const secondID = sleMan1->getFieldH256(sfManifestID);
auto const sleMan2 = view().peek(Keylet{ltMANIFEST, secondID});
if (secondID == firstID || !sleMan2 ||
sleMan2->getAccountID(sfAccount) != account_)
{
JLOG(j_.error())
<< "SetManifest: Old manifest object missing, misowned or "
"self-referential (ID2) !! "
<< strHex(secondID);
return tefBAD_LEDGER;
}
view().erase(sleMan2);
}
view().erase(sleMan1);
}
Keylet const klMan1 = keylet::manifest(manifest->masterKey);
std::optional<Keylet> klMan2;
if (!manifest->revoked() && manifest->signingKey)
klMan2 = keylet::manifest(*manifest->signingKey);
// Neither key may still be occupied: preclaim rejects an ephemeral key held
// by another account, and the block above cleared this account's own
// copies.
if (view().exists(klMan1) || (klMan2 && view().exists(*klMan2)))
{
JLOG(j_.error()) << "SetManifest: Manifest keylet already occupied !! "
<< strHex(klMan1.key);
return tefBAD_LEDGER;
}
// Mirror the manifest losslessly, signatures included, so any node can
// reconstruct and independently verify it (ManifestCache::applyLedger).
// Field *presence* is copied faithfully: sfVersion is soeDEFAULT in the
// manifest format, so materialising an absent one would alter the signed
// payload and break verification.
auto const write = [&](Keylet const& kl,
std::optional<uint256> const& other) {
auto sleMan = std::make_shared<SLE>(kl);
sleMan->setAccountID(sfAccount, account_);
sleMan->setFieldU32(sfSequence, obj.getFieldU32(sfSequence));
sleMan->setFieldVL(sfPublicKey, obj.getFieldVL(sfPublicKey));
sleMan->setFieldVL(
sfMasterSignature, obj.getFieldVL(sfMasterSignature));
if (obj.isFieldPresent(sfVersion))
sleMan->setFieldU16(sfVersion, obj.getFieldU16(sfVersion));
if (obj.isFieldPresent(sfSigningPubKey))
sleMan->setFieldVL(
sfSigningPubKey, obj.getFieldVL(sfSigningPubKey));
if (obj.isFieldPresent(sfSignature))
sleMan->setFieldVL(sfSignature, obj.getFieldVL(sfSignature));
if (obj.isFieldPresent(sfDomain))
sleMan->setFieldVL(sfDomain, obj.getFieldVL(sfDomain));
if (other)
sleMan->setFieldH256(sfManifestID, *other);
view().insert(sleMan);
};
write(klMan1, klMan2 ? std::optional<uint256>{klMan2->key} : std::nullopt);
if (klMan2)
write(*klMan2, klMan1.key);
sle->setFieldH256(sfManifestID, klMan1.key);
view().update(sle);
return tesSUCCESS;
}
XRPAmount
SetManifest::calculateBaseFee(ReadView const& view, STTx const& tx)
{
XRPAmount manifestFee{0};
if (tx.isFieldPresent(sfManifest))
{
STObject const& obj = const_cast<ripple::STTx&>(tx)
.getField(sfManifest)
.downcast<STObject>();
// one drop per byte
manifestFee = XRPAmount{obj.getSerializer().getDataLength()};
}
return Transactor::calculateBaseFee(view, tx) + manifestFee;
}
/** The most sfFee may be: the same 1.2x headroom Submit applies.
Kept in one place so the value Submit writes and the value preclaim will
accept cannot drift apart.
*/
static XRPAmount
manifestFeeCeiling(XRPAmount baseFee)
{
return mulRatio(baseFee, 12, 10, /*roundUp*/ true);
}
TER
SetManifest::checkFee(PreclaimContext const& ctx, XRPAmount baseFee)
{
// A ceiling is required because the envelope carries no account signature,
// so sfFee is chosen by whoever relays the txn -- and manifests are public:
// they are gossiped over the peer protocol and embedded in published UNLs,
// so the relayer need not be the master key holder. Uncapped, any observer
// of a not-yet-recorded manifest could wrap it with sfFee set to that
// validator's entire balance. The 20% band is headroom against a fee floor
// that has risen since the txn was built, and bounds what an attacker can
// burn to the same 20%.
if (ctx.tx[sfFee].xrp() > manifestFeeCeiling(baseFee))
{
JLOG(ctx.j.trace()) << "SetManifest: fee above ceiling: "
<< to_string(ctx.tx[sfFee].xrp());
return temBAD_FEE;
}
// Floor and balance are the ordinary rules.
return Transactor::checkFee(ctx, baseFee);
}
std::optional<std::string>
makeSetManifestTx(
Slice const& manifest,
std::uint32_t networkID,
ReadView const& openView,
beast::Journal j)
{
try
{
auto const man = deserializeManifest(manifest, j);
if (!man || !man->verify())
return std::nullopt;
auto const encode = [&](XRPAmount fee) {
return serializeHex(STTx(ttMANIFEST_SET, [&](STObject& obj) {
obj.setAccountID(sfAccount, calcAccountID(man->masterKey));
obj.setFieldU32(sfSequence, 0);
obj.setFieldU32(sfNetworkID, networkID);
obj.setFieldAmount(sfFee, fee);
obj.setFieldVL(sfSigningPubKey, std::vector<std::uint8_t>{});
obj.setFieldVL(sfTxnSignature, std::vector<std::uint8_t>{});
// sfManifest is soeREQUIRED, so STObject::set(SOTemplate) has
// already materialised it as a present, empty object. Fill
// that one in: emitting a second is a duplicate field, which
// STObject::set(SerialIter&) rejects on the way back in.
SerialIter mit{manifest};
obj.peekFieldObject(sfManifest).set(mit);
}));
};
// calculateBaseFee() takes a parsed transaction, so encode once with a
// placeholder fee purely to have something to price. The resulting fee
// does not depend on the placeholder: it is derived from the length of
// the manifest object and the ledger's base fee.
auto const priced = strUnHex(encode(XRPAmount{0}));
if (!priced || priced->empty())
return std::nullopt;
SerialIter sit{makeSlice(*priced)};
STTx const probe{std::ref(sit)};
// Submit the ceiling exactly. preclaim rejects anything above it, and
// the floor rises with network load, so the ceiling is both always
// acceptable and the value most likely to still clear the floor by the
// time the transaction is applied.
return encode(
manifestFeeCeiling(SetManifest::calculateBaseFee(openView, probe)));
}
catch (std::exception const& e)
{
JLOG(j.warn()) << "makeSetManifestTx: " << e.what();
return std::nullopt;
}
}
} // namespace ripple

View File

@@ -0,0 +1,90 @@
//------------------------------------------------------------------------------
/*
This file is part of rippled: https://github.com/ripple/rippled
Copyright (c) 2024 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_SETMANIFEST_H_INCLUDED
#define RIPPLE_TX_SETMANIFEST_H_INCLUDED
#include <xrpld/app/tx/detail/Transactor.h>
#include <xrpld/core/Config.h>
#include <xrpl/basics/Log.h>
#include <xrpl/protocol/Indexes.h>
namespace ripple {
/** Encode the transaction that publishes `manifest` on-ledger.
A manifest transaction carries no account signature, so the protocol pins
the whole envelope: Sequence must be 0, SigningPubKey and TxnSignature must
be empty, and Fee must fall between the computed base fee and a ceiling
above it. SetManifest::preflight and SetManifest::checkFee reject anything
else. Every caller that submits a manifest builds it here so those rules
cannot drift apart from the ones the transactor enforces.
Returns hex rather than an STTx because the manifest is appended to the
encoded transaction verbatim, behind its object marker, instead of being
parsed and re-emitted: the bytes the master key signed survive untouched,
and a future change to the manifest format needs no change here.
@param manifest Serialized manifest
@param networkID Network the transaction is for
@param openView Ledger the fee is priced against
@param j Journal
@return the hex-encoded transaction, or nullopt if the manifest does not
parse or does not verify
*/
std::optional<std::string>
makeSetManifestTx(
Slice const& manifest,
std::uint32_t networkID,
ReadView const& openView,
beast::Journal j);
class SetManifest : public Transactor
{
public:
static constexpr ConsequencesFactoryType ConsequencesFactory{Custom};
explicit SetManifest(ApplyContext& ctx) : Transactor(ctx)
{
}
static XRPAmount
calculateBaseFee(ReadView const& view, STTx const& tx);
// Hides Transactor::checkFee; applySteps dispatches this as T::checkFee.
static TER
checkFee(PreclaimContext const& ctx, XRPAmount baseFee);
static TxConsequences
makeTxConsequences(PreflightContext const& ctx);
static NotTEC
preflight(PreflightContext const& ctx);
static TER
preclaim(PreclaimContext const&);
TER
doApply() override;
};
} // namespace ripple
#endif

View File

@@ -604,6 +604,13 @@ Transactor::checkSeqProxy(
return terNO_ACCOUNT;
}
// A manifest txn is derived deterministically from the manifest alone, so
// it cannot depend on account state: preflight pins sfSequence to 0 and the
// account sequence is neither checked here nor consumed below.
if (view.rules().enabled(featureOnChainManifests) &&
tx.getTxnType() == ttMANIFEST_SET)
return tesSUCCESS;
SeqProxy const a_seq = SeqProxy::sequence((*sle)[sfSequence]);
// pass all emitted tx provided their seq is 0
@@ -755,6 +762,17 @@ Transactor::consumeSeqProxy(SLE::pointer const& sleAccount)
if (ctx_.isEmittedTxn())
return tesSUCCESS;
// Manifest txns get the same treatment: pinned to sfSequence 0 and not
// signed by the account, so they neither consume nor reset its sequence.
// Doing so would be actively harmful -- the write below is
// seqProx.value() + 1, which for a seq-0 txn sets the account sequence to
// 1 and makes every previously used sequence replayable. Handling it here
// rather than in apply() also covers reset(), which re-consumes on the
// tec / failed-invariant path.
if (view().rules().enabled(featureOnChainManifests) &&
ctx_.tx.getTxnType() == ttMANIFEST_SET)
return tesSUCCESS;
SeqProxy const seqProx = ctx_.tx.getSeqProxy();
if (seqProx.isSeq())
{
@@ -898,6 +916,12 @@ Transactor::checkSign(PreclaimContext const& ctx)
ctx.tx.getTxnType() == ttIMPORT)
return tesSUCCESS;
// pass ttMANIFEST_SETs, their signatures are checked in preflight against
// the manifest's internal key logic
if (ctx.view.rules().enabled(featureOnChainManifests) &&
ctx.tx.getTxnType() == ttMANIFEST_SET)
return tesSUCCESS;
if (ctx.flags & tapDRY_RUN)
{
// This code must be different for `simulate`

View File

@@ -18,6 +18,7 @@
//==============================================================================
#include <xrpld/app/misc/HashRouter.h>
#include <xrpld/app/misc/Manifest.h>
#include <xrpld/app/tx/apply.h>
#include <xrpld/app/tx/applySteps.h>
#include <xrpl/basics/Log.h>
@@ -73,6 +74,29 @@ checkValidity(
return {Validity::Valid, ""};
}
if (rules.enabled(featureOnChainManifests) &&
tx.getTxnType() == ttMANIFEST_SET &&
tx.isFieldPresent(sfTxnSignature) &&
tx.getFieldVL(sfTxnSignature).empty() &&
tx.isFieldPresent(sfSigningPubKey) &&
tx.getFieldVL(sfSigningPubKey).empty() && tx.isFieldPresent(sfManifest))
{
// perform alternative signature check over manifest
STObject const& manObj = const_cast<ripple::STTx&>(tx)
.getField(sfManifest)
.downcast<STObject>();
auto man = deserializeManifest(manObj);
if (!man.has_value() || !man->verify())
return {Validity::SigBad, "Manifest signature is bad"};
std::string reason;
if (!passesLocalChecks(tx, reason))
return {Validity::SigGoodOnly, reason};
return {Validity::Valid, ""};
}
if (flags & SF_SIGBAD)
// Signature is known bad
return {Validity::SigBad, "Transaction has bad signature."};

View File

@@ -64,6 +64,7 @@
#include <xrpld/app/tx/detail/Remit.h>
#include <xrpld/app/tx/detail/SetAccount.h>
#include <xrpld/app/tx/detail/SetHook.h>
#include <xrpld/app/tx/detail/SetManifest.h>
#include <xrpld/app/tx/detail/SetOracle.h>
#include <xrpld/app/tx/detail/SetRegularKey.h>
#include <xrpld/app/tx/detail/SetRemarks.h>

View File

@@ -1190,7 +1190,13 @@ OverlayImpl::getManifestsMessage()
{
protocol::TMManifests tm;
app_.validatorManifests().for_each_manifest(
// A bounded subset of the cache rather than all of it; see
// ManifestCache::for_each_gossip_manifest for what is selected.
// This message is only rebuilt when the cache sequence changes, so a
// shift in which manifests are the most recently used does not by
// itself refresh it. That is acceptable: the pinned manifests are the
// ones a peer needs, and they are always included.
app_.validatorManifests().for_each_gossip_manifest(
[&tm](std::size_t s) { tm.mutable_list()->Reserve(s); },
[&tm, &hr = app_.getHashRouter()](Manifest const& manifest) {
tm.add_list()->set_stobject(

View File

@@ -18,15 +18,20 @@
//==============================================================================
#include <xrpld/app/ledger/LedgerMaster.h>
#include <xrpld/app/ledger/OpenLedger.h>
#include <xrpld/app/misc/HashRouter.h>
#include <xrpld/app/misc/Manifest.h>
#include <xrpld/app/misc/Transaction.h>
#include <xrpld/app/misc/TxQ.h>
#include <xrpld/app/tx/apply.h>
#include <xrpld/app/tx/detail/SetManifest.h> // makeSetManifestTx
#include <xrpld/rpc/Context.h>
#include <xrpld/rpc/GRPCHandlers.h>
#include <xrpld/rpc/detail/RPCHelpers.h>
#include <xrpld/rpc/detail/TransactionSign.h>
#include <xrpl/basics/strHex.h>
#include <xrpl/protocol/ErrorCodes.h>
#include <xrpl/protocol/Feature.h>
#include <xrpl/protocol/RPCErr.h>
#include <xrpl/resource/Fees.h>
@@ -89,9 +94,20 @@ doInject(RPC::JsonContext& context)
Json::Value
doSubmit(RPC::JsonContext& context)
{
Json::Value jvResult;
context.loadType = Resource::feeMediumBurdenRPC;
if (!context.params.isMember(jss::tx_blob))
bool const hasManifest = context.params.isMember(jss::manifest);
bool const hasTxBlob = context.params.isMember(jss::tx_blob);
if (hasManifest && hasTxBlob)
{
return RPC::make_error(
rpcINVALID_PARAMS,
"Specify exactly one of either `tx_blob` or `manifest`");
}
else if (!hasTxBlob && !hasManifest)
{
auto const failType = getFailHard(context);
@@ -117,9 +133,49 @@ doSubmit(RPC::JsonContext& context)
return ret;
}
Json::Value jvResult;
std::string txBlob =
hasTxBlob ? context.params[jss::tx_blob].asString() : "";
auto ret = strUnHex(context.params[jss::tx_blob].asString());
if (hasManifest)
{
// OnChainManifests amendment accepts a manifest submission here; turn
// it into the transaction that carries it and drop through to normal
// tx_blob processing below.
auto const view = context.app.openLedger().current();
// The transaction built below carries no account signature; its
// authority is the manifest's own master and ephemeral signatures,
// which checkValidity() only honours once the amendment is active.
// Without this the submitter is told their transaction is unsigned,
// which reads as their mistake. It isn't -- the feature is not live
// yet -- so say so before touching the manifest at all.
if (!view->rules().enabled(featureOnChainManifests))
return RPC::make_error(
rpcNOT_ENABLED,
"The OnChainManifests amendment is not enabled on this "
"network. Manifest submission will work once it activates; "
"nothing is wrong with this request.");
auto const raw = strUnHex(context.params[jss::manifest].asString());
if (!raw || raw->empty())
return rpcError(rpcINVALID_PARAMS);
auto const hex = makeSetManifestTx(
makeSlice(*raw),
context.app.config().NETWORK_ID,
*view,
context.app.journal("Submit"));
if (!hex)
{
jvResult[jss::error] = "invalidManifest";
return jvResult;
}
txBlob = *hex;
}
auto ret = strUnHex(txBlob);
if (!ret || !ret->size())
return rpcError(rpcINVALID_PARAMS);