Compare commits

...

5 Commits

Author SHA1 Message Date
yinyiqian1
e680d64d66 Merge pull request #8034 from yinyiqian1/merge-keyrotate-develop
Merge develop
2026-08-14 17:59:39 -04:00
yinyiqian1
16fbb6ffa5 resolve conflicts 2026-08-14 14:51:15 -04:00
yinyiqian1
243c16aa06 Merge remote-tracking branch 'origin/develop' into merge-keyrotate-develop 2026-08-14 14:21:08 -04:00
yinyiqian1
7e5aa1f984 feat: Enable key rotation in MPTokenIssuanceSet (#7960) 2026-08-13 18:27:50 -04:00
yinyiqian1
8e54590852 feat: Add amendment and fields for key rotation 2026-08-11 14:12:28 -04:00
8 changed files with 767 additions and 43 deletions

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

@@ -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,45 @@ 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;
}
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 +276,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 +417,32 @@ MPTokenIssuanceSet::doApply()
}
}
if (auto const pubKey = ctx_.tx[~sfIssuerEncryptionKey])
{
// 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.
auto const setEncryptionKey = [&](SF_VL const& keyField, SF_UINT32 const& epochField) {
auto const pubKey = ctx_.tx[~keyField];
if (!pubKey)
return;
// This is enforced in preflight.
XRPL_ASSERT(
sle->getType() == ltMPTOKEN_ISSUANCE,
"MPTokenIssuanceSet::doApply : modifying MPTokenIssuance");
sle->setFieldVL(sfIssuerEncryptionKey, *pubKey);
}
// NOTE: presence must be checked before the key is overwritten below.
bool const isRotation = sle->isFieldPresent(keyField);
if (auto const pubKey = ctx_.tx[~sfAuditorEncryptionKey])
{
// This is enforced in preflight.
XRPL_ASSERT(
sle->getType() == ltMPTOKEN_ISSUANCE,
"MPTokenIssuanceSet::doApply : modifying MPTokenIssuance");
sle->setFieldVL(keyField, *pubKey);
sle->setFieldVL(sfAuditorEncryptionKey, *pubKey);
}
if (isRotation)
(*sle)[epochField] = (*sle)[~epochField].valueOr(0) + 1;
};
setEncryptionKey(sfIssuerEncryptionKey, sfIssuerKeyEpoch);
setEncryptionKey(sfAuditorEncryptionKey, sfAuditorKeyEpoch);
view().update(sle);

View File

@@ -0,0 +1,548 @@
#include <test/jtx/Account.h>
#include <test/jtx/ConfidentialTransfer.h>
#include <test/jtx/Env.h>
#include <test/jtx/mpt.h>
#include <xrpl/basics/strHex.h>
#include <xrpl/beast/unit_test/suite.h>
#include <xrpl/protocol/Feature.h>
#include <xrpl/protocol/Indexes.h>
#include <xrpl/protocol/SField.h>
#include <xrpl/protocol/TER.h>
#include <xrpl/protocol/TxFlags.h>
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.
{
auto const sleIssuance = env.le(keylet::mptokenIssuance(mptAlice.issuanceID()));
BEAST_EXPECT(sleIssuance);
BEAST_EXPECT(sleIssuance && !sleIssuance->isFieldPresent(sfIssuerKeyEpoch));
BEAST_EXPECT(sleIssuance && !sleIssuance->isFieldPresent(sfAuditorKeyEpoch));
}
// 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),
});
{
auto const sleIssuance = env.le(keylet::mptokenIssuance(mptAlice.issuanceID()));
if (!BEAST_EXPECT(sleIssuance))
return;
auto const expectedKey =
rotationEnabled ? mptAlice.getPubKey(bob) : mptAlice.getPubKey(alice);
BEAST_EXPECT(
expectedKey &&
strHex((*sleIssuance)[sfIssuerEncryptionKey]) == strHex(*expectedKey));
// Rotating the issuer key bumps the epoch.
if (rotationEnabled)
{
BEAST_EXPECT((*sleIssuance)[~sfIssuerKeyEpoch] == 1u);
}
else
{
BEAST_EXPECT(!sleIssuance->isFieldPresent(sfIssuerKeyEpoch));
}
BEAST_EXPECT(!sleIssuance->isFieldPresent(sfAuditorKeyEpoch));
}
if (rotationEnabled)
{
// A second rotation increments the epoch again
mptAlice.set({
.account = alice,
.issuerPubKey = mptAlice.getPubKey(alice),
});
auto const sleIssuance = env.le(keylet::mptokenIssuance(mptAlice.issuanceID()));
BEAST_EXPECT(sleIssuance && (*sleIssuance)[~sfIssuerKeyEpoch] == 2u);
}
}
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.
{
auto const sleIssuance = env.le(keylet::mptokenIssuance(mptAlice.issuanceID()));
BEAST_EXPECT(sleIssuance && !sleIssuance->isFieldPresent(sfIssuerKeyEpoch));
BEAST_EXPECT(sleIssuance && !sleIssuance->isFieldPresent(sfAuditorKeyEpoch));
}
// Rotating both keys, it 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),
});
auto const sleIssuance = env.le(keylet::mptokenIssuance(mptAlice.issuanceID()));
if (!BEAST_EXPECT(sleIssuance))
return;
auto const expectedIssuerKey =
rotationEnabled ? mptAlice.getPubKey(bob) : mptAlice.getPubKey(alice);
auto const expectedAuditorKey =
rotationEnabled ? mptAlice.getPubKey(alice) : mptAlice.getPubKey(auditor);
BEAST_EXPECT(
expectedIssuerKey &&
strHex((*sleIssuance)[sfIssuerEncryptionKey]) == strHex(*expectedIssuerKey));
BEAST_EXPECT(
expectedAuditorKey &&
strHex((*sleIssuance)[sfAuditorEncryptionKey]) == strHex(*expectedAuditorKey));
if (rotationEnabled)
{
BEAST_EXPECT((*sleIssuance)[~sfIssuerKeyEpoch] == 1u);
BEAST_EXPECT((*sleIssuance)[~sfAuditorKeyEpoch] == 1u);
}
else
{
BEAST_EXPECT(!sleIssuance->isFieldPresent(sfIssuerKeyEpoch));
BEAST_EXPECT(!sleIssuance->isFieldPresent(sfAuditorKeyEpoch));
}
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
{
auto const sleIssuance = env.le(keylet::mptokenIssuance(mptAlice.issuanceID()));
BEAST_EXPECT(sleIssuance && (*sleIssuance)[~sfIssuerKeyEpoch] == 1u);
BEAST_EXPECT(sleIssuance && (*sleIssuance)[~sfAuditorKeyEpoch] == 1u);
}
// A second rotation increments both epochs again
mptAlice.set({
.account = alice,
.issuerPubKey = mptAlice.getPubKey(alice),
.auditorPubKey = mptAlice.getPubKey(auditor),
});
auto const sleIssuance = env.le(keylet::mptokenIssuance(mptAlice.issuanceID()));
BEAST_EXPECT(sleIssuance && (*sleIssuance)[~sfIssuerKeyEpoch] == 2u);
BEAST_EXPECT(sleIssuance && (*sleIssuance)[~sfAuditorKeyEpoch] == 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),
});
auto const sleIssuance = env.le(keylet::mptokenIssuance(mptAlice.issuanceID()));
if (!BEAST_EXPECT(sleIssuance))
return;
// The issuer key keeps unchanged.
auto const issuerKey = mptAlice.getPubKey(alice);
BEAST_EXPECT(
issuerKey && strHex((*sleIssuance)[sfIssuerEncryptionKey]) == strHex(*issuerKey));
BEAST_EXPECT(!sleIssuance->isFieldPresent(sfIssuerKeyEpoch));
auto const expectedAuditorKey =
rotationEnabled ? mptAlice.getPubKey(bob) : mptAlice.getPubKey(auditor);
BEAST_EXPECT(
expectedAuditorKey &&
strHex((*sleIssuance)[sfAuditorEncryptionKey]) == strHex(*expectedAuditorKey));
// Rotating the auditor key bumps its epoch.
if (rotationEnabled)
{
BEAST_EXPECT((*sleIssuance)[~sfAuditorKeyEpoch] == 1u);
}
else
{
BEAST_EXPECT(!sleIssuance->isFieldPresent(sfAuditorKeyEpoch));
}
if (rotationEnabled)
{
// A second rotation increments the epoch again
mptAlice.set({
.account = alice,
.auditorPubKey = mptAlice.getPubKey(auditor),
});
auto const sleIssuance = env.le(keylet::mptokenIssuance(mptAlice.issuanceID()));
BEAST_EXPECT(sleIssuance && (*sleIssuance)[~sfAuditorKeyEpoch] == 2u);
// The issuer key epoch is still untouched.
BEAST_EXPECT(sleIssuance && !sleIssuance->isFieldPresent(sfIssuerKeyEpoch));
}
}
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),
});
auto const sleIssuance = env.le(keylet::mptokenIssuance(mptAlice.issuanceID()));
if (!BEAST_EXPECT(sleIssuance))
return;
BEAST_EXPECT(sleIssuance->isFieldPresent(sfAuditorEncryptionKey) == rotationEnabled);
BEAST_EXPECT(!sleIssuance->isFieldPresent(sfIssuerKeyEpoch));
BEAST_EXPECT(!sleIssuance->isFieldPresent(sfAuditorKeyEpoch));
}
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(sleIssuance->isFieldPresent(sfAuditorEncryptionKey) == rotationEnabled);
BEAST_EXPECT(!sleIssuance->isFieldPresent(sfIssuerKeyEpoch));
BEAST_EXPECT(!sleIssuance->isFieldPresent(sfAuditorKeyEpoch));
// 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),
});
auto const sleIssuance = env.le(keylet::mptokenIssuance(mptAlice.issuanceID()));
BEAST_EXPECT(sleIssuance && !sleIssuance->isFieldPresent(sfAuditorEncryptionKey));
}
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;
auto const expectedKey =
rotationEnabled ? mptAlice.getPubKey(carol) : mptAlice.getPubKey(alice);
BEAST_EXPECT(
expectedKey && strHex((*sleIssuance)[sfIssuerEncryptionKey]) == strHex(*expectedKey));
if (rotationEnabled)
{
BEAST_EXPECT((*sleIssuance)[~sfIssuerKeyEpoch] == 1u);
}
else
{
BEAST_EXPECT(!sleIssuance->isFieldPresent(sfIssuerKeyEpoch));
}
// 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
testMPTokenIssuanceSetWithFeats(FeatureBitset features)
{
testMPTokenIssuanceSetRotateIssuerKey(features);
testMPTokenIssuanceSetRotateBothKeys(features);
testMPTokenIssuanceSetRotateAuditorKeyOnly(features);
testMPTokenIssuanceSetRegisterAuditorKeyLater(features);
testMPTokenIssuanceSetRegisterAuditorKeyLaterWithCOA(features);
testMPTokenIssuanceSetAuditorKeyWithoutIssuerKey(features);
testMPTokenIssuanceSetRotateWithCOA(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

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