Merge remote-tracking branch 'upstream/develop' into copilot/add-augmented-submit-fields

This commit is contained in:
Mayukha Vadari
2026-09-04 10:45:14 -04:00
12 changed files with 957 additions and 45 deletions

View File

@@ -12,6 +12,7 @@
#include <chrono>
#include <cstddef>
#include <cstdint>
#include <limits>
namespace xrpl {
@@ -544,6 +545,11 @@ constexpr std::size_t kEcClawbackProofLength = SECP256K1_COMPACT_CLAWBACK_PROOF_
*/
constexpr std::uint32_t kConfidentialFeeMultiplier = 9;
/**
* Maximum value a confidential MPT key epoch may reach.
*/
constexpr std::uint32_t kMaxKeyEpoch = std::numeric_limits<std::uint32_t>::max();
/**
* Compressed EC point prefix for even y-coordinate
*/

View File

@@ -15,6 +15,7 @@
// Add new amendments to the top of this list.
// Keep it sorted in reverse chronological order.
XRPL_FEATURE(ConfidentialMPTKeyRotation, Supported::No, VoteBehavior::DefaultNo)
XRPL_FIX (Cleanup3_4_0, Supported::Yes, VoteBehavior::DefaultNo)
XRPL_FEATURE(Sponsor, Supported::Yes, VoteBehavior::DefaultNo)
XRPL_FEATURE(BatchV1_1, Supported::Yes, VoteBehavior::DefaultNo)

View File

@@ -408,6 +408,8 @@ LEDGER_ENTRY(ltMPTOKEN_ISSUANCE, 0x007e, MPTokenIssuance, mpt_issuance, ({
{sfReferenceHolding, SoeOptional},
{sfIssuerEncryptionKey, SoeOptional},
{sfAuditorEncryptionKey, SoeOptional},
{sfIssuerKeyEpoch, SoeOptional},
{sfAuditorKeyEpoch, SoeOptional},
{sfConfidentialOutstandingAmount, SoeDefault},
}))

View File

@@ -119,6 +119,10 @@ TYPED_SFIELD(sfRemainingOwnerCount, UINT32, 73)
TYPED_SFIELD(sfSponsorFlags, UINT32, 74)
TYPED_SFIELD(sfSubscriptionDate, UINT32, 75)
TYPED_SFIELD(sfRedemptionDate, UINT32, 76)
TYPED_SFIELD(sfIssuerKeyEpoch, UINT32, 77)
TYPED_SFIELD(sfAuditorKeyEpoch, UINT32, 78)
TYPED_SFIELD(sfIssuerKeyMirrorEpoch, UINT32, 79)
TYPED_SFIELD(sfAuditorKeyMirrorEpoch, UINT32, 80)
// 64-bit integers (common)
TYPED_SFIELD(sfIndexNext, UINT64, 1)

View File

@@ -351,6 +351,54 @@ public:
return this->sle_->isFieldPresent(sfAuditorEncryptionKey);
}
/**
* @brief Get sfIssuerKeyEpoch (SoeOptional)
* @return The field value, or std::nullopt if not present.
*/
[[nodiscard]]
protocol_autogen::Optional<SF_UINT32::type::value_type>
getIssuerKeyEpoch() const
{
if (hasIssuerKeyEpoch())
return this->sle_->at(sfIssuerKeyEpoch);
return std::nullopt;
}
/**
* @brief Check if sfIssuerKeyEpoch is present.
* @return True if the field is present, false otherwise.
*/
[[nodiscard]]
bool
hasIssuerKeyEpoch() const
{
return this->sle_->isFieldPresent(sfIssuerKeyEpoch);
}
/**
* @brief Get sfAuditorKeyEpoch (SoeOptional)
* @return The field value, or std::nullopt if not present.
*/
[[nodiscard]]
protocol_autogen::Optional<SF_UINT32::type::value_type>
getAuditorKeyEpoch() const
{
if (hasAuditorKeyEpoch())
return this->sle_->at(sfAuditorKeyEpoch);
return std::nullopt;
}
/**
* @brief Check if sfAuditorKeyEpoch is present.
* @return True if the field is present, false otherwise.
*/
[[nodiscard]]
bool
hasAuditorKeyEpoch() const
{
return this->sle_->isFieldPresent(sfAuditorKeyEpoch);
}
/**
* @brief Get sfConfidentialOutstandingAmount (SoeDefault)
* @return The field value, or std::nullopt if not present.
@@ -600,6 +648,28 @@ public:
return *this;
}
/**
* @brief Set sfIssuerKeyEpoch (SoeOptional)
* @return Reference to this builder for method chaining.
*/
MPTokenIssuanceBuilder&
setIssuerKeyEpoch(std::decay_t<typename SF_UINT32::type::value_type> const& value)
{
object_[sfIssuerKeyEpoch] = value;
return *this;
}
/**
* @brief Set sfAuditorKeyEpoch (SoeOptional)
* @return Reference to this builder for method chaining.
*/
MPTokenIssuanceBuilder&
setAuditorKeyEpoch(std::decay_t<typename SF_UINT32::type::value_type> const& value)
{
object_[sfAuditorKeyEpoch] = value;
return *this;
}
/**
* @brief Set sfConfidentialOutstandingAmount (SoeDefault)
* @return Reference to this builder for method chaining.

View File

@@ -23,7 +23,7 @@ namespace {
//------------------------------------------------------------------------------
// clang-format off
// NOLINTNEXTLINE(readability-identifier-naming)
char const* const versionString = "3.4.0-b3"
char const* const versionString = "3.4.0-rc1"
// clang-format on
;

View File

@@ -119,7 +119,15 @@ MPTokenIssuanceSet::preflight(PreflightContext const& ctx)
if (hasHolder && (hasIssuerElGamalKey || hasAuditorElGamalKey))
return temMALFORMED;
if (hasAuditorElGamalKey && !hasIssuerElGamalKey)
// Pre-ConfidentialMPTKeyRotation amendment, the auditor key could not be
// registered independently of the issuer key. The issuer could either:
// - Register only the issuer key (in which case an auditor key could not be added later), or
// - Register both the issuer and auditor keys simultaneously.
//
// Post-ConfidentialMPTKeyRotation amendment, the auditor key can be
// registered after the issuer key has already been registered.
if (hasAuditorElGamalKey && !hasIssuerElGamalKey &&
!ctx.rules.enabled(featureConfidentialMPTKeyRotation))
return temMALFORMED;
if (hasIssuerElGamalKey && !isValidCompressedECPoint(ctx.tx[sfIssuerEncryptionKey]))
@@ -219,18 +227,57 @@ MPTokenIssuanceSet::preclaim(PreclaimContext const& ctx)
return tecNO_PERMISSION;
}
// cannot update issuer public key
if (ctx.tx.isFieldPresent(sfIssuerEncryptionKey) &&
sleMptIssuance->isFieldPresent(sfIssuerEncryptionKey))
{
return tecNO_PERMISSION;
}
// Updating an existing encryption key requires the
// ConfidentialMPTKeyRotation amendment.
bool const canRotateKey = ctx.view.rules().enabled(featureConfidentialMPTKeyRotation);
// cannot update auditor public key
if (ctx.tx.isFieldPresent(sfAuditorEncryptionKey) &&
sleMptIssuance->isFieldPresent(sfAuditorEncryptionKey))
bool const txHasIssuerKey = ctx.tx.isFieldPresent(sfIssuerEncryptionKey);
bool const txHasAuditorKey = ctx.tx.isFieldPresent(sfAuditorEncryptionKey);
bool const sleHasIssuerKey = sleMptIssuance->isFieldPresent(sfIssuerEncryptionKey);
bool const sleHasAuditorKey = sleMptIssuance->isFieldPresent(sfAuditorEncryptionKey);
if (canRotateKey)
{
return tecNO_PERMISSION; // LCOV_EXCL_LINE
// Post-ConfidentialMPTKeyRotation amendment, the encryption keys can be updated.
// A first-time auditor key registration requires an issuer key,
// either already on the issuance or set by the same transaction.
bool const registersAuditorKey = txHasAuditorKey && !sleHasAuditorKey;
bool const issuerKeyExists = sleHasIssuerKey || txHasIssuerKey;
if (registersAuditorKey && !issuerKeyExists)
return tecNO_PERMISSION;
// Rotating a key to its current value is not permitted: a key epoch
// increment must always correspond to an actual key change.
if (txHasIssuerKey && sleHasIssuerKey &&
ctx.tx[sfIssuerEncryptionKey] == (*sleMptIssuance)[sfIssuerEncryptionKey])
return tecDUPLICATE;
if (txHasAuditorKey && sleHasAuditorKey &&
ctx.tx[sfAuditorEncryptionKey] == (*sleMptIssuance)[sfAuditorEncryptionKey])
return tecDUPLICATE;
// Key epochs must never wrap. Epoch 0 serves as the sentinel for "never
// rotated." Holders' mirror epochs are checked against it for equality,
// so a wrap would cause stale mirror ciphertexts to appear valid instead
// of failing loudly.
if (txHasIssuerKey && sleHasIssuerKey &&
(*sleMptIssuance)[~sfIssuerKeyEpoch].value_or(0) == kMaxKeyEpoch)
return tecNO_PERMISSION;
if (txHasAuditorKey && sleHasAuditorKey &&
(*sleMptIssuance)[~sfAuditorKeyEpoch].value_or(0) == kMaxKeyEpoch)
return tecNO_PERMISSION;
}
else
{
// Pre-ConfidentialMPTKeyRotation amendment, the encryption keys can not be updated.
// cannot update issuer public key
if (txHasIssuerKey && sleHasIssuerKey)
return tecNO_PERMISSION;
// cannot update auditor public key
if (txHasAuditorKey && sleHasAuditorKey)
return tecNO_PERMISSION; // LCOV_EXCL_LINE
}
auto const enablesConfidentialBalance =
@@ -241,25 +288,30 @@ MPTokenIssuanceSet::preclaim(PreclaimContext const& ctx)
// Encryption keys can only be set if confidential amounts are already
// enabled on the issuance OR if the transaction is enabling it
if (ctx.tx.isFieldPresent(sfIssuerEncryptionKey) &&
!sleMptIssuance->isFlag(lsfMPTCanHoldConfidentialBalance) && !enablesConfidentialBalance)
if (txHasIssuerKey && !sleMptIssuance->isFlag(lsfMPTCanHoldConfidentialBalance) &&
!enablesConfidentialBalance)
{
return tecNO_PERMISSION;
}
if (ctx.tx.isFieldPresent(sfAuditorEncryptionKey) &&
!sleMptIssuance->isFlag(lsfMPTCanHoldConfidentialBalance) && !enablesConfidentialBalance)
if (txHasAuditorKey && !sleMptIssuance->isFlag(lsfMPTCanHoldConfidentialBalance) &&
!enablesConfidentialBalance)
{
return tecNO_PERMISSION;
}
// cannot upload key if there's circulating supply of COA
if ((ctx.tx.isFieldPresent(sfIssuerEncryptionKey) ||
ctx.tx.isFieldPresent(sfAuditorEncryptionKey) || enablesConfidentialBalance) &&
(*sleMptIssuance)[~sfConfidentialOutstandingAmount].value_or(0) > 0)
{
bool const hasConfidentialOA =
(*sleMptIssuance)[~sfConfidentialOutstandingAmount].value_or(0) > 0;
// Pre-ConfidentialMPTKeyRotation amendment, keys cannot be uploaded while
// COA > 0. Post-amendment they can be uploaded even if COA > 0.
if (!canRotateKey && (txHasIssuerKey || txHasAuditorKey) && hasConfidentialOA)
return tecNO_PERMISSION; // LCOV_EXCL_LINE
}
// Enabling confidential balances when COA > 0 is not permitted, regardless of
// ConfidentialMPTKeyRotation.
if (enablesConfidentialBalance && hasConfidentialOA)
return tecNO_PERMISSION;
return tesSUCCESS;
}
@@ -377,25 +429,69 @@ MPTokenIssuanceSet::doApply()
}
}
if (auto const pubKey = ctx_.tx[~sfIssuerEncryptionKey])
{
// This is enforced in preflight.
// Sets an encryption key on the issuance. Overwriting an existing key
// (a rotation) increments the corresponding key epoch; a first-time
// registration leaves the epoch absent (epoch 0), matching issuances
// whose keys were registered before the ConfidentialMPTKeyRotation
// amendment.
bool const canRotateKey = view().rules().enabled(featureConfidentialMPTKeyRotation);
auto const setEncryptionKey = [&](SF_VL const& keyField, SF_UINT32 const& epochField) -> TER {
auto const pubKey = ctx_.tx[~keyField];
if (!pubKey)
return tesSUCCESS;
// This is enforced in preflight, which rejects a transaction carrying
// both sfHolder and an encryption key.
XRPL_ASSERT(
sle->getType() == ltMPTOKEN_ISSUANCE,
"MPTokenIssuanceSet::doApply : modifying MPTokenIssuance");
sle->setFieldVL(sfIssuerEncryptionKey, *pubKey);
}
// Add sanity check under the amendment ConfidentialMPTKeyRotation.
// Pre-confidentialMPTKeyRotation did not return tecINTERNAL so
// this should be under the amendment guard.
if (canRotateKey && sle->getType() != ltMPTOKEN_ISSUANCE)
return tecINTERNAL; // LCOV_EXCL_LINE
if (auto const pubKey = ctx_.tx[~sfAuditorEncryptionKey])
{
// This is enforced in preflight.
XRPL_ASSERT(
sle->getType() == ltMPTOKEN_ISSUANCE,
"MPTokenIssuanceSet::doApply : modifying MPTokenIssuance");
// NOTE: presence must be checked before the key is overwritten below.
bool const isRotation = sle->isFieldPresent(keyField);
sle->setFieldVL(keyField, *pubKey);
sle->setFieldVL(sfAuditorEncryptionKey, *pubKey);
}
if (isRotation)
{
// Preclaim rejects overwriting an existing key unless the amendment is
// enabled.
if (!canRotateKey)
{
// LCOV_EXCL_START
UNREACHABLE("xrpl::MPTokenIssuanceSet::doApply : rotation without amendment");
return tecINTERNAL;
// LCOV_EXCL_STOP
}
auto const epoch = (*sle)[~epochField].valueOr(0);
// Preclaim rejects a rotation that would wrap the epoch. So this should never happen.
if (epoch >= kMaxKeyEpoch)
{
// LCOV_EXCL_START
UNREACHABLE("xrpl::MPTokenIssuanceSet::doApply : key epoch overflow");
return tecINTERNAL;
// LCOV_EXCL_STOP
}
(*sle)[epochField] = epoch + 1;
}
return tesSUCCESS;
};
if (auto const ter = setEncryptionKey(sfIssuerEncryptionKey, sfIssuerKeyEpoch);
!isTesSuccess(ter))
return ter; // LCOV_EXCL_LINE
if (auto const ter = setEncryptionKey(sfAuditorEncryptionKey, sfAuditorKeyEpoch);
!isTesSuccess(ter))
return ter; // LCOV_EXCL_LINE
view().update(sle);

View File

@@ -0,0 +1,634 @@
#include <test/jtx/Account.h>
#include <test/jtx/ConfidentialTransfer.h>
#include <test/jtx/Env.h>
#include <test/jtx/mpt.h>
#include <xrpl/beast/unit_test/suite.h>
#include <xrpl/beast/utility/Journal.h>
#include <xrpl/ledger/OpenView.h>
#include <xrpl/protocol/Feature.h>
#include <xrpl/protocol/Indexes.h>
#include <xrpl/protocol/Protocol.h>
#include <xrpl/protocol/SField.h>
#include <xrpl/protocol/STLedgerEntry.h>
#include <xrpl/protocol/TER.h>
#include <xrpl/protocol/TxFlags.h>
#include <cstdint>
#include <memory>
#include <optional>
namespace xrpl {
class ConfidentialMPTKeyRotation_test : public ConfidentialTransferTestBase
{
void
testMPTokenIssuanceSetRotateIssuerKey(FeatureBitset features)
{
testcase("MPTokenIssuanceSet rotate issuer key");
using namespace test::jtx;
Env env{*this, features};
Account const alice("alice");
Account const bob("bob");
MPTTester mptAlice(env, alice, {.holders = {bob}});
mptAlice.create({
.ownerCount = 1,
.flags = tfMPTCanTransfer | tfMPTCanHoldConfidentialBalance,
});
mptAlice.generateKeyPair(alice);
mptAlice.generateKeyPair(bob);
// First-time registration.
mptAlice.set({
.account = alice,
.issuerPubKey = mptAlice.getPubKey(alice),
});
// Verify that no epochs are set when registering for the first time.
BEAST_EXPECT(mptAlice.checkKeyEpochs(std::nullopt, std::nullopt));
// Rotating the issuer key requires the key rotation amendment
bool const rotationEnabled = features[featureConfidentialMPTKeyRotation];
mptAlice.set({
.account = alice,
.issuerPubKey = mptAlice.getPubKey(bob),
.err = rotationEnabled ? TER(tesSUCCESS) : TER(tecNO_PERMISSION),
});
// A rotation replaces the issuer key and bumps its epoch. The auditor
// key was never registered, so it and its epoch stay absent.
if (rotationEnabled)
{
BEAST_EXPECT(mptAlice.checkEncryptionKeys(bob, std::nullopt));
BEAST_EXPECT(mptAlice.checkKeyEpochs(1u, std::nullopt));
}
else
{
BEAST_EXPECT(mptAlice.checkEncryptionKeys(alice, std::nullopt));
BEAST_EXPECT(mptAlice.checkKeyEpochs(std::nullopt, std::nullopt));
}
if (rotationEnabled)
{
// A second rotation increments the epoch again
mptAlice.set({
.account = alice,
.issuerPubKey = mptAlice.getPubKey(alice),
});
BEAST_EXPECT(mptAlice.checkKeyEpochs(2u, std::nullopt));
}
}
void
testMPTokenIssuanceSetRotateBothKeys(FeatureBitset features)
{
testcase("MPTokenIssuanceSet rotate both issuer and auditor keys");
using namespace test::jtx;
Env env{*this, features};
Account const alice("alice");
Account const bob("bob");
Account const auditor("auditor");
MPTTester mptAlice(env, alice, {.holders = {bob}});
mptAlice.create({
.ownerCount = 1,
.flags = tfMPTCanTransfer | tfMPTCanHoldConfidentialBalance,
});
mptAlice.generateKeyPair(alice);
mptAlice.generateKeyPair(bob);
mptAlice.generateKeyPair(auditor);
// Register both keys together.
mptAlice.set({
.account = alice,
.issuerPubKey = mptAlice.getPubKey(alice),
.auditorPubKey = mptAlice.getPubKey(auditor),
});
// Verify that no epochs are set when registering for the first time.
BEAST_EXPECT(mptAlice.checkKeyEpochs(std::nullopt, std::nullopt));
// Rotating both keys requires the amendment
bool const rotationEnabled = features[featureConfidentialMPTKeyRotation];
mptAlice.set({
.account = alice,
.issuerPubKey = mptAlice.getPubKey(bob),
.auditorPubKey = mptAlice.getPubKey(alice),
.err = rotationEnabled ? TER(tesSUCCESS) : TER(tecNO_PERMISSION),
});
if (rotationEnabled)
{
BEAST_EXPECT(mptAlice.checkEncryptionKeys(bob, alice));
BEAST_EXPECT(mptAlice.checkKeyEpochs(1u, 1u));
}
else
{
BEAST_EXPECT(mptAlice.checkEncryptionKeys(alice, auditor));
BEAST_EXPECT(mptAlice.checkKeyEpochs(std::nullopt, std::nullopt));
}
if (rotationEnabled)
{
// Rotating the issuer key to its current value fails.
// Current issuer key is bob, duplicate.
mptAlice.set({
.account = alice,
.issuerPubKey = mptAlice.getPubKey(bob),
.err = tecDUPLICATE,
});
// Rotating the auditor key to its current value fails.
// Current auditor key is alice, duplicate.
mptAlice.set({
.account = alice,
.auditorPubKey = mptAlice.getPubKey(alice),
.err = tecDUPLICATE,
});
// The whole transaction fails when one key is unchanged, even if
// the other key is rotated to a new value.
// Current issuer key is bob, duplicate.
mptAlice.set({
.account = alice,
.issuerPubKey = mptAlice.getPubKey(bob),
.auditorPubKey = mptAlice.getPubKey(auditor),
.err = tecDUPLICATE,
});
// Current auditor key is alice, duplicate.
mptAlice.set({
.account = alice,
.issuerPubKey = mptAlice.getPubKey(auditor),
.auditorPubKey = mptAlice.getPubKey(alice),
.err = tecDUPLICATE,
});
// Nothing changed: keys and epochs are untouched
BEAST_EXPECT(mptAlice.checkKeyEpochs(1u, 1u));
// A second rotation increments both epochs again
mptAlice.set({
.account = alice,
.issuerPubKey = mptAlice.getPubKey(alice),
.auditorPubKey = mptAlice.getPubKey(auditor),
});
BEAST_EXPECT(mptAlice.checkKeyEpochs(2u, 2u));
}
}
void
testMPTokenIssuanceSetRotateAuditorKeyOnly(FeatureBitset features)
{
testcase("MPTokenIssuanceSet rotate auditor key only");
using namespace test::jtx;
Env env{*this, features};
Account const alice("alice");
Account const bob("bob");
Account const auditor("auditor");
MPTTester mptAlice(env, alice, {.holders = {bob}});
mptAlice.create({
.ownerCount = 1,
.flags = tfMPTCanTransfer | tfMPTCanHoldConfidentialBalance,
});
mptAlice.generateKeyPair(alice);
mptAlice.generateKeyPair(bob);
mptAlice.generateKeyPair(auditor);
// Register both keys together.
mptAlice.set({
.account = alice,
.issuerPubKey = mptAlice.getPubKey(alice),
.auditorPubKey = mptAlice.getPubKey(auditor),
});
// A transaction carrying only the auditor key fails preflight
// pre-ConfidentialMPTKeyRotation; post-ConfidentialMPTKeyRotation it rotates the auditor
// key
bool const rotationEnabled = features[featureConfidentialMPTKeyRotation];
mptAlice.set({
.account = alice,
.auditorPubKey = mptAlice.getPubKey(bob),
.err = rotationEnabled ? TER(tesSUCCESS) : TER(temMALFORMED),
});
// The issuer key keeps unchanged, and rotating only the auditor key
// bumps only its epoch.
if (rotationEnabled)
{
BEAST_EXPECT(mptAlice.checkEncryptionKeys(alice, bob));
BEAST_EXPECT(mptAlice.checkKeyEpochs(std::nullopt, 1u));
}
else
{
BEAST_EXPECT(mptAlice.checkEncryptionKeys(alice, auditor));
BEAST_EXPECT(mptAlice.checkKeyEpochs(std::nullopt, std::nullopt));
}
if (rotationEnabled)
{
// A second rotation increments the epoch again
mptAlice.set({
.account = alice,
.auditorPubKey = mptAlice.getPubKey(auditor),
});
// The issuer key epoch is still untouched.
BEAST_EXPECT(mptAlice.checkKeyEpochs(std::nullopt, 2u));
}
}
void
testMPTokenIssuanceSetRegisterAuditorKeyLater(FeatureBitset features)
{
testcase("MPTokenIssuanceSet register auditor key after issuer key");
using namespace test::jtx;
Env env{*this, features};
Account const alice("alice");
Account const auditor("auditor");
MPTTester mptAlice(env, alice);
mptAlice.create({
.ownerCount = 1,
.flags = tfMPTCanTransfer | tfMPTCanHoldConfidentialBalance,
});
mptAlice.generateKeyPair(alice);
mptAlice.generateKeyPair(auditor);
// Register the issuer key first. We'll register the auditor key in a separate transaction.
mptAlice.set({
.account = alice,
.issuerPubKey = mptAlice.getPubKey(alice),
});
// Register the auditor key separately.
// pre-ConfidentialMPTKeyRotation it fails preflight; post-ConfidentialMPTKeyRotation it
// succeeds without touching any epoch because it's a first-time registration.
bool const rotationEnabled = features[featureConfidentialMPTKeyRotation];
mptAlice.set({
.account = alice,
.auditorPubKey = mptAlice.getPubKey(auditor),
.err = rotationEnabled ? TER(tesSUCCESS) : TER(temMALFORMED),
});
BEAST_EXPECT(mptAlice.checkEncryptionKeys(
alice, rotationEnabled ? std::optional<Account>(auditor) : std::nullopt));
BEAST_EXPECT(mptAlice.checkKeyEpochs(std::nullopt, std::nullopt));
}
void
testMPTokenIssuanceSetRegisterAuditorKeyLaterWithCOA(FeatureBitset features)
{
testcase("MPTokenIssuanceSet register auditor key later with circulating supply");
using namespace test::jtx;
Env env{*this, features};
Account const alice("alice");
Account const bob("bob");
Account const auditor("auditor");
MPTTester mptAlice(env, alice, {.holders = {bob}});
mptAlice.create({
.ownerCount = 1,
.flags = tfMPTCanTransfer | tfMPTCanHoldConfidentialBalance,
});
mptAlice.authorize({.account = bob});
mptAlice.pay(alice, bob, 100);
mptAlice.generateKeyPair(alice);
mptAlice.generateKeyPair(bob);
mptAlice.generateKeyPair(auditor);
mptAlice.set({
.account = alice,
.issuerPubKey = mptAlice.getPubKey(alice),
});
// Convert some of bob's balance so that COA > 0
mptAlice.convert({
.account = bob,
.amt = 50,
.holderPubKey = mptAlice.getPubKey(bob),
});
auto const sleIssuanceBefore = env.le(keylet::mptokenIssuance(mptAlice.issuanceID()));
if (!BEAST_EXPECT(sleIssuanceBefore))
return;
auto const coaBefore = (*sleIssuanceBefore)[~sfConfidentialOutstandingAmount].value_or(0);
BEAST_EXPECT(coaBefore > 0);
// Registering the auditor key for the first time while confidential
// supply is circulating: pre-ConfidentialMPTKeyRotation an auditor-only
// transaction fails preflight; post-ConfidentialMPTKeyRotation it
// succeeds as a first-time late-registration even COA > 0.
bool const rotationEnabled = features[featureConfidentialMPTKeyRotation];
mptAlice.set({
.account = alice,
.auditorPubKey = mptAlice.getPubKey(auditor),
.err = rotationEnabled ? TER(tesSUCCESS) : TER(temMALFORMED),
});
auto const sleIssuance = env.le(keylet::mptokenIssuance(mptAlice.issuanceID()));
if (!BEAST_EXPECT(sleIssuance))
return;
BEAST_EXPECT(mptAlice.checkEncryptionKeys(
alice, rotationEnabled ? std::optional<Account>(auditor) : std::nullopt));
BEAST_EXPECT(mptAlice.checkKeyEpochs(std::nullopt, std::nullopt));
// The circulating supply itself is not affected.
BEAST_EXPECT((*sleIssuance)[~sfConfidentialOutstandingAmount].value_or(0) == coaBefore);
}
void
testMPTokenIssuanceSetAuditorKeyWithoutIssuerKey(FeatureBitset features)
{
testcase("MPTokenIssuanceSet auditor key requires issuer key");
using namespace test::jtx;
Env env{*this, features};
Account const alice("alice");
Account const auditor("auditor");
MPTTester mptAlice(env, alice);
mptAlice.create({
.ownerCount = 1,
.flags = tfMPTCanTransfer | tfMPTCanHoldConfidentialBalance,
});
mptAlice.generateKeyPair(auditor);
// The issuer key was never registered. pre-ConfidentialMPTKeyRotation an auditor-only
// transaction fails preflight; post-ConfidentialMPTKeyRotation it passes preflight
// but preclaim rejects registering an auditor key on an issuance
// without an issuer key.
bool const rotationEnabled = features[featureConfidentialMPTKeyRotation];
mptAlice.set({
.account = alice,
.auditorPubKey = mptAlice.getPubKey(auditor),
.err = rotationEnabled ? TER(tecNO_PERMISSION) : TER(temMALFORMED),
});
// The rejected transaction leaves the issuance without either key.
BEAST_EXPECT(mptAlice.checkEncryptionKeys(std::nullopt, std::nullopt));
BEAST_EXPECT(mptAlice.checkKeyEpochs(std::nullopt, std::nullopt));
}
void
testMPTokenIssuanceSetRotateWithCOA(FeatureBitset features)
{
testcase("MPTokenIssuanceSet rotate with circulating confidential supply");
using namespace test::jtx;
Env env{*this, features};
Account const alice("alice");
Account const bob("bob");
Account const carol("carol");
MPTTester mptAlice(env, alice, {.holders = {bob}});
mptAlice.create({
.ownerCount = 1,
.flags = tfMPTCanTransfer | tfMPTCanHoldConfidentialBalance,
});
mptAlice.authorize({.account = bob});
mptAlice.pay(alice, bob, 100);
mptAlice.generateKeyPair(alice);
mptAlice.generateKeyPair(bob);
mptAlice.generateKeyPair(carol);
mptAlice.set({
.account = alice,
.issuerPubKey = mptAlice.getPubKey(alice),
});
// Convert some of bob's balance to confidential spending, so that the
// issuance has confidential supply. COA > 0.
mptAlice.convert({
.account = bob,
.amt = 50,
.holderPubKey = mptAlice.getPubKey(bob),
});
auto const sleIssuanceBeforeRotation =
env.le(keylet::mptokenIssuance(mptAlice.issuanceID()));
if (!BEAST_EXPECT(sleIssuanceBeforeRotation))
return;
auto const coaBeforeRotation =
(*sleIssuanceBeforeRotation)[~sfConfidentialOutstandingAmount].value_or(0);
BEAST_EXPECT(coaBeforeRotation > 0);
// Rotating key requires the
// amendment.
bool const rotationEnabled = features[featureConfidentialMPTKeyRotation];
mptAlice.set({
.account = alice,
.issuerPubKey = mptAlice.getPubKey(carol),
.err = rotationEnabled ? TER(tesSUCCESS) : TER(tecNO_PERMISSION),
});
auto const sleIssuance = env.le(keylet::mptokenIssuance(mptAlice.issuanceID()));
if (!BEAST_EXPECT(sleIssuance))
return;
if (rotationEnabled)
{
BEAST_EXPECT(mptAlice.checkEncryptionKeys(carol, std::nullopt));
BEAST_EXPECT(mptAlice.checkKeyEpochs(1u, std::nullopt));
}
else
{
BEAST_EXPECT(mptAlice.checkEncryptionKeys(alice, std::nullopt));
BEAST_EXPECT(mptAlice.checkKeyEpochs(std::nullopt, std::nullopt));
}
// The confidential outstanding amount is not affected by the rotation
BEAST_EXPECT(
(*sleIssuance)[~sfConfidentialOutstandingAmount].value_or(0) == coaBeforeRotation);
// Re-enabling confidential balances while supply is circulating is
// rejected regardless of the ConfidentialMPTKeyRotation amendment.
mptAlice.set({
.account = alice,
.flags = tfMPTSetCanHoldConfidentialBalance,
.err = tecNO_PERMISSION,
});
}
void
testMPTokenIssuanceSetKeyEpochAtMax(FeatureBitset features)
{
using namespace test::jtx;
if (!features[featureConfidentialMPTKeyRotation])
return;
testcase("MPTokenIssuanceSet key epoch cannot wrap");
Env env{*this, features};
Account const alice("alice");
Account const bob("bob");
Account const carol("carol");
Account const auditor("auditor");
// Keep the ledger open so that we can write the key epochs directly into it.
MPTTester mptAlice(env, alice, {.holders = {bob}, .close = false});
mptAlice.create({
.ownerCount = 1,
.flags = tfMPTCanTransfer | tfMPTCanHoldConfidentialBalance,
});
mptAlice.generateKeyPair(alice);
mptAlice.generateKeyPair(bob);
mptAlice.generateKeyPair(carol);
mptAlice.generateKeyPair(auditor);
mptAlice.set({
.account = alice,
.issuerPubKey = mptAlice.getPubKey(alice),
.auditorPubKey = mptAlice.getPubKey(auditor),
});
auto const issuanceKeylet = keylet::mptokenIssuance(mptAlice.issuanceID());
// Writes the supplied key epochs straight into the open ledger so that
// the maximum epoch is reachable without submitting four billion
// rotations.
auto setEpochs = [&](std::optional<std::uint32_t> const& issuerKeyEpoch,
std::optional<std::uint32_t> const& auditorKeyEpoch) {
env.app().getOpenLedger().modify([&](OpenView& view, beast::Journal) {
auto const sle = view.read(issuanceKeylet);
if (!sle)
return false; // LCOV_EXCL_LINE
auto replacement = std::make_shared<SLE>(*sle);
if (issuerKeyEpoch)
(*replacement)[sfIssuerKeyEpoch] = *issuerKeyEpoch;
if (auditorKeyEpoch)
(*replacement)[sfAuditorKeyEpoch] = *auditorKeyEpoch;
view.rawReplace(replacement);
return true;
});
};
BEAST_EXPECT(mptAlice.checkEncryptionKeys(alice, auditor));
BEAST_EXPECT(mptAlice.checkKeyEpochs(std::nullopt, std::nullopt));
// Increment the auditor epoch to kMaxKeyEpoch - 1, leaving the issuer epoch absent.
setEpochs(std::nullopt, kMaxKeyEpoch - 1);
BEAST_EXPECT(mptAlice.checkEncryptionKeys(alice, auditor));
BEAST_EXPECT(mptAlice.checkKeyEpochs(std::nullopt, kMaxKeyEpoch - 1));
// Rotating the auditor key to kMaxKeyEpoch succeeds.
mptAlice.set({
.account = alice,
.auditorPubKey = mptAlice.getPubKey(carol),
});
BEAST_EXPECT(mptAlice.checkEncryptionKeys(alice, carol));
BEAST_EXPECT(mptAlice.checkKeyEpochs(std::nullopt, kMaxKeyEpoch));
// A further auditor rotation is rejected because the epoch is exhausted.
mptAlice.set({
.account = alice,
.auditorPubKey = mptAlice.getPubKey(bob),
.err = tecNO_PERMISSION,
});
// Rotating both keys at once is rejected as a whole because the auditor
// epoch is exhausted.
mptAlice.set({
.account = alice,
.issuerPubKey = mptAlice.getPubKey(auditor),
.auditorPubKey = mptAlice.getPubKey(bob),
.err = tecNO_PERMISSION,
});
// Both rejections leave every key and epoch as it was.
BEAST_EXPECT(mptAlice.checkEncryptionKeys(alice, carol));
BEAST_EXPECT(mptAlice.checkKeyEpochs(std::nullopt, kMaxKeyEpoch));
// The issuer key is unaffected by the exhausted auditor epoch.
mptAlice.set({
.account = alice,
.issuerPubKey = mptAlice.getPubKey(bob),
});
BEAST_EXPECT(mptAlice.checkEncryptionKeys(bob, carol));
BEAST_EXPECT(mptAlice.checkKeyEpochs(1u, kMaxKeyEpoch));
// Increment the issuer epoch to kMaxKeyEpoch - 1.
setEpochs(kMaxKeyEpoch - 1, std::nullopt);
BEAST_EXPECT(mptAlice.checkEncryptionKeys(bob, carol));
BEAST_EXPECT(mptAlice.checkKeyEpochs(kMaxKeyEpoch - 1, kMaxKeyEpoch));
// Rotating the issuer key to kMaxKeyEpoch succeeds.
mptAlice.set({
.account = alice,
.issuerPubKey = mptAlice.getPubKey(auditor),
});
BEAST_EXPECT(mptAlice.checkEncryptionKeys(auditor, carol));
BEAST_EXPECT(mptAlice.checkKeyEpochs(kMaxKeyEpoch, kMaxKeyEpoch));
// With both epochs exhausted neither key can be rotated again.
mptAlice.set({
.account = alice,
.issuerPubKey = mptAlice.getPubKey(alice),
.err = tecNO_PERMISSION,
});
mptAlice.set({
.account = alice,
.auditorPubKey = mptAlice.getPubKey(bob),
.err = tecNO_PERMISSION,
});
mptAlice.set({
.account = alice,
.issuerPubKey = mptAlice.getPubKey(alice),
.auditorPubKey = mptAlice.getPubKey(bob),
.err = tecNO_PERMISSION,
});
BEAST_EXPECT(mptAlice.checkEncryptionKeys(auditor, carol));
BEAST_EXPECT(mptAlice.checkKeyEpochs(kMaxKeyEpoch, kMaxKeyEpoch));
}
void
testMPTokenIssuanceSetWithFeats(FeatureBitset features)
{
testMPTokenIssuanceSetRotateIssuerKey(features);
testMPTokenIssuanceSetRotateBothKeys(features);
testMPTokenIssuanceSetRotateAuditorKeyOnly(features);
testMPTokenIssuanceSetRegisterAuditorKeyLater(features);
testMPTokenIssuanceSetRegisterAuditorKeyLaterWithCOA(features);
testMPTokenIssuanceSetAuditorKeyWithoutIssuerKey(features);
testMPTokenIssuanceSetRotateWithCOA(features);
testMPTokenIssuanceSetKeyEpochAtMax(features);
}
public:
void
run() override
{
using namespace test::jtx;
FeatureBitset const all{testableAmendments()};
testMPTokenIssuanceSetWithFeats(all);
testMPTokenIssuanceSetWithFeats(all - featureConfidentialMPTKeyRotation);
}
};
BEAST_DEFINE_TESTSUITE(ConfidentialMPTKeyRotation, app, xrpl);
} // namespace xrpl

View File

@@ -736,12 +736,8 @@ class ConfidentialTransfer_test : public ConfidentialTransferTestBase
.err = temMALFORMED,
});
// Cannot set auditor key without issuer key
mptAlice.set({
.account = alice,
.auditorPubKey = mptAlice.getPubKey(alice),
.err = temMALFORMED,
});
// Note: "auditor key without issuer key" (temMALFORMED before
// ConfidentialMPTKeyRotation) is covered in ConfidentialMPTKeyRotation_test
// Cannot set Holder and issuer Keys in the same transaction
mptAlice.set({
@@ -787,9 +783,9 @@ class ConfidentialTransfer_test : public ConfidentialTransferTestBase
});
}
// Cannot update issuer public key once set
// Cannot update issuer public key once set (pre-ConfidentialMPTKeyRotation behavior)
{
Env env{*this, features};
Env env{*this, features - featureConfidentialMPTKeyRotation};
Account const alice("alice");
Account const bob("bob");
MPTTester mptAlice(env, alice, {.holders = {bob}});
@@ -819,8 +815,9 @@ class ConfidentialTransfer_test : public ConfidentialTransferTestBase
// Cannot update issuer and auditor public keys once set
// Note: trying to set only auditor key fails in preflight (temMALFORMED)
// so we must provide both keys, which fails on issuer key check first
// (pre-ConfidentialMPTKeyRotation behavior)
{
Env env{*this, features};
Env env{*this, features - featureConfidentialMPTKeyRotation};
Account const alice("alice");
Account const bob("bob");
Account const auditor("auditor");
@@ -900,8 +897,9 @@ class ConfidentialTransfer_test : public ConfidentialTransferTestBase
}
// Set issuer key first, then auditor key in a separate tx
// (pre-ConfidentialMPTKeyRotation behavior)
{
Env env{*this, features};
Env env{*this, features - featureConfidentialMPTKeyRotation};
Account const alice("alice");
Account const auditor("auditor");
MPTTester mptAlice(env, alice, {.holders = {}, .auditor = auditor});

View File

@@ -648,6 +648,38 @@ MPTTester::checkImmutableFlags(std::uint32_t expectedFlags) const
});
}
[[nodiscard]] bool
MPTTester::checkKeyEpochs(
std::optional<std::uint32_t> issuerKeyEpoch,
std::optional<std::uint32_t> auditorKeyEpoch) const
{
return forObject([&](SLEP const& sle) -> bool {
return (*sle)[~sfIssuerKeyEpoch] == issuerKeyEpoch &&
(*sle)[~sfAuditorKeyEpoch] == auditorKeyEpoch;
});
}
[[nodiscard]] bool
MPTTester::checkEncryptionKeys(
std::optional<Account> const& issuerKeyOwner,
std::optional<Account> const& auditorKeyOwner) const
{
auto const matches =
[this](SLEP const& sle, SF_VL const& field, std::optional<Account> const& owner) {
if (!owner)
return !sle->isFieldPresent(field);
auto const expected = getPubKey(*owner);
return expected && sle->isFieldPresent(field) &&
strHex((*sle)[field]) == strHex(*expected);
};
return forObject([&](SLEP const& sle) -> bool {
return matches(sle, sfIssuerEncryptionKey, issuerKeyOwner) &&
matches(sle, sfAuditorEncryptionKey, auditorKeyOwner);
});
}
void
MPTTester::pay(
Account const& src,

View File

@@ -612,6 +612,21 @@ public:
[[nodiscard]] bool
checkImmutableFlags(std::uint32_t expectedFlags) const;
// Checks both key epochs on the issuance. Pass std::nullopt for an epoch
// that is expected to be absent, which means the key is never rotated.
[[nodiscard]] bool
checkKeyEpochs(
std::optional<std::uint32_t> issuerKeyEpoch,
std::optional<std::uint32_t> auditorKeyEpoch) const;
// Checks that the issuance carries the encryption keys of the given
// accounts. Pass std::nullopt for a key that is expected to be absent,
// which means the key is never registered.
[[nodiscard]] bool
checkEncryptionKeys(
std::optional<Account> const& issuerKeyOwner,
std::optional<Account> const& auditorKeyOwner) const;
[[nodiscard]] Account const&
issuer() const
{

View File

@@ -36,6 +36,8 @@ TEST(MPTokenIssuanceTests, BuilderSettersRoundTrip)
auto const referenceHoldingValue = canonical_UINT256();
auto const issuerEncryptionKeyValue = canonical_VL();
auto const auditorEncryptionKeyValue = canonical_VL();
auto const issuerKeyEpochValue = canonical_UINT32();
auto const auditorKeyEpochValue = canonical_UINT32();
auto const confidentialOutstandingAmountValue = canonical_UINT64();
MPTokenIssuanceBuilder builder{
@@ -57,6 +59,8 @@ TEST(MPTokenIssuanceTests, BuilderSettersRoundTrip)
builder.setReferenceHolding(referenceHoldingValue);
builder.setIssuerEncryptionKey(issuerEncryptionKeyValue);
builder.setAuditorEncryptionKey(auditorEncryptionKeyValue);
builder.setIssuerKeyEpoch(issuerKeyEpochValue);
builder.setAuditorKeyEpoch(auditorKeyEpochValue);
builder.setConfidentialOutstandingAmount(confidentialOutstandingAmountValue);
builder.setLedgerIndex(index);
@@ -184,6 +188,22 @@ TEST(MPTokenIssuanceTests, BuilderSettersRoundTrip)
EXPECT_TRUE(entry.hasAuditorEncryptionKey());
}
{
auto const& expected = issuerKeyEpochValue;
auto const actualOpt = entry.getIssuerKeyEpoch();
ASSERT_TRUE(actualOpt.has_value());
expectEqualField(expected, *actualOpt, "sfIssuerKeyEpoch");
EXPECT_TRUE(entry.hasIssuerKeyEpoch());
}
{
auto const& expected = auditorKeyEpochValue;
auto const actualOpt = entry.getAuditorKeyEpoch();
ASSERT_TRUE(actualOpt.has_value());
expectEqualField(expected, *actualOpt, "sfAuditorKeyEpoch");
EXPECT_TRUE(entry.hasAuditorKeyEpoch());
}
{
auto const& expected = confidentialOutstandingAmountValue;
auto const actualOpt = entry.getConfidentialOutstandingAmount();
@@ -221,6 +241,8 @@ TEST(MPTokenIssuanceTests, BuilderFromSleRoundTrip)
auto const referenceHoldingValue = canonical_UINT256();
auto const issuerEncryptionKeyValue = canonical_VL();
auto const auditorEncryptionKeyValue = canonical_VL();
auto const issuerKeyEpochValue = canonical_UINT32();
auto const auditorKeyEpochValue = canonical_UINT32();
auto const confidentialOutstandingAmountValue = canonical_UINT64();
auto sle = std::make_shared<SLE>(MPTokenIssuance::entryType, index);
@@ -241,6 +263,8 @@ TEST(MPTokenIssuanceTests, BuilderFromSleRoundTrip)
sle->at(sfReferenceHolding) = referenceHoldingValue;
sle->at(sfIssuerEncryptionKey) = issuerEncryptionKeyValue;
sle->at(sfAuditorEncryptionKey) = auditorEncryptionKeyValue;
sle->at(sfIssuerKeyEpoch) = issuerKeyEpochValue;
sle->at(sfAuditorKeyEpoch) = auditorKeyEpochValue;
sle->at(sfConfidentialOutstandingAmount) = confidentialOutstandingAmountValue;
MPTokenIssuanceBuilder builderFromSle{sle};
@@ -442,6 +466,32 @@ TEST(MPTokenIssuanceTests, BuilderFromSleRoundTrip)
expectEqualField(expected, *fromBuilderOpt, "sfAuditorEncryptionKey");
}
{
auto const& expected = issuerKeyEpochValue;
auto const fromSleOpt = entryFromSle.getIssuerKeyEpoch();
auto const fromBuilderOpt = entryFromBuilder.getIssuerKeyEpoch();
ASSERT_TRUE(fromSleOpt.has_value());
ASSERT_TRUE(fromBuilderOpt.has_value());
expectEqualField(expected, *fromSleOpt, "sfIssuerKeyEpoch");
expectEqualField(expected, *fromBuilderOpt, "sfIssuerKeyEpoch");
}
{
auto const& expected = auditorKeyEpochValue;
auto const fromSleOpt = entryFromSle.getAuditorKeyEpoch();
auto const fromBuilderOpt = entryFromBuilder.getAuditorKeyEpoch();
ASSERT_TRUE(fromSleOpt.has_value());
ASSERT_TRUE(fromBuilderOpt.has_value());
expectEqualField(expected, *fromSleOpt, "sfAuditorKeyEpoch");
expectEqualField(expected, *fromBuilderOpt, "sfAuditorKeyEpoch");
}
{
auto const& expected = confidentialOutstandingAmountValue;
@@ -539,6 +589,10 @@ TEST(MPTokenIssuanceTests, OptionalFieldsReturnNullopt)
EXPECT_FALSE(entry.getIssuerEncryptionKey().has_value());
EXPECT_FALSE(entry.hasAuditorEncryptionKey());
EXPECT_FALSE(entry.getAuditorEncryptionKey().has_value());
EXPECT_FALSE(entry.hasIssuerKeyEpoch());
EXPECT_FALSE(entry.getIssuerKeyEpoch().has_value());
EXPECT_FALSE(entry.hasAuditorKeyEpoch());
EXPECT_FALSE(entry.getAuditorKeyEpoch().has_value());
EXPECT_FALSE(entry.hasConfidentialOutstandingAmount());
EXPECT_FALSE(entry.getConfidentialOutstandingAmount().has_value());
}