From 401ad21d6e7a0bad13d4f5c5f97c88a81217b23e Mon Sep 17 00:00:00 2001 From: yinyiqian1 Date: Wed, 5 Aug 2026 16:33:18 -0400 Subject: [PATCH 1/3] enable key update --- .../xrpl/protocol/detail/ledger_entries.macro | 2 + .../ledger_entries/MPTokenIssuance.h | 70 +++ .../transactors/token/MPTokenIssuanceSet.cpp | 78 ++- src/test/app/ConfidentialKeyRotation_test.cpp | 496 ++++++++++++++++++ src/test/app/ConfidentialTransfer_test.cpp | 18 +- .../ledger_entries/MPTokenIssuanceTests.cpp | 54 ++ 6 files changed, 689 insertions(+), 29 deletions(-) create mode 100644 src/test/app/ConfidentialKeyRotation_test.cpp diff --git a/include/xrpl/protocol/detail/ledger_entries.macro b/include/xrpl/protocol/detail/ledger_entries.macro index b6408581a90..e4f8dac06d2 100644 --- a/include/xrpl/protocol/detail/ledger_entries.macro +++ b/include/xrpl/protocol/detail/ledger_entries.macro @@ -408,6 +408,8 @@ LEDGER_ENTRY(ltMPTOKEN_ISSUANCE, 0x007e, MPTokenIssuance, mpt_issuance, ({ {sfReferenceHolding, SoeOptional}, {sfIssuerEncryptionKey, SoeOptional}, {sfAuditorEncryptionKey, SoeOptional}, + {sfIssuerKeyEpoch, SoeOptional}, + {sfAuditorKeyEpoch, SoeOptional}, {sfConfidentialOutstandingAmount, SoeDefault}, })) diff --git a/include/xrpl/protocol_autogen/ledger_entries/MPTokenIssuance.h b/include/xrpl/protocol_autogen/ledger_entries/MPTokenIssuance.h index 8518a0fe14c..ee7e8c2a740 100644 --- a/include/xrpl/protocol_autogen/ledger_entries/MPTokenIssuance.h +++ b/include/xrpl/protocol_autogen/ledger_entries/MPTokenIssuance.h @@ -351,6 +351,54 @@ class MPTokenIssuance : public LedgerEntryBase return this->sle_->isFieldPresent(sfAuditorEncryptionKey); } + /** + * @brief Get sfIssuerKeyEpoch (SoeOptional) + * @return The field value, or std::nullopt if not present. + */ + [[nodiscard]] + protocol_autogen::Optional + 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 + 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 @@ class MPTokenIssuanceBuilder : public LedgerEntryBuilderBase const& value) + { + object_[sfIssuerKeyEpoch] = value; + return *this; + } + + /** + * @brief Set sfAuditorKeyEpoch (SoeOptional) + * @return Reference to this builder for method chaining. + */ + MPTokenIssuanceBuilder& + setAuditorKeyEpoch(std::decay_t const& value) + { + object_[sfAuditorKeyEpoch] = value; + return *this; + } + /** * @brief Set sfConfidentialOutstandingAmount (SoeDefault) * @return Reference to this builder for method chaining. diff --git a/src/libxrpl/tx/transactors/token/MPTokenIssuanceSet.cpp b/src/libxrpl/tx/transactors/token/MPTokenIssuanceSet.cpp index d5262510696..0d7669e7ba7 100644 --- a/src/libxrpl/tx/transactors/token/MPTokenIssuanceSet.cpp +++ b/src/libxrpl/tx/transactors/token/MPTokenIssuanceSet.cpp @@ -145,7 +145,15 @@ MPTokenIssuanceSet::preflight(PreflightContext const& ctx) if (hasHolder && (hasIssuerElGamalKey || hasAuditorElGamalKey)) return temMALFORMED; - if (hasAuditorElGamalKey && !hasIssuerElGamalKey) + // Pre-ConfidentialKeyRotation 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-ConfidentialKeyRotation amendment, the auditor key can be + // registered after the issuer key has already been registered. + if (hasAuditorElGamalKey && !hasIssuerElGamalKey && + !ctx.rules.enabled(featureConfidentialKeyRotation)) return temMALFORMED; if (hasIssuerElGamalKey && !isValidCompressedECPoint(ctx.tx[sfIssuerEncryptionKey])) @@ -252,19 +260,30 @@ MPTokenIssuanceSet::preclaim(PreclaimContext const& ctx) return tecNO_PERMISSION; } + // Updating an existing encryption key requires the + // ConfidentialKeyRotation amendment. + bool const canRotateKey = ctx.view.rules().enabled(featureConfidentialKeyRotation); + + 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); + // cannot update issuer public key - if (ctx.tx.isFieldPresent(sfIssuerEncryptionKey) && - sleMptIssuance->isFieldPresent(sfIssuerEncryptionKey)) - { + if (!canRotateKey && txHasIssuerKey && sleHasIssuerKey) return tecNO_PERMISSION; - } // cannot update auditor public key - if (ctx.tx.isFieldPresent(sfAuditorEncryptionKey) && - sleMptIssuance->isFieldPresent(sfAuditorEncryptionKey)) - { + if (!canRotateKey && txHasAuditorKey && sleHasAuditorKey) return tecNO_PERMISSION; // LCOV_EXCL_LINE - } + + // 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 (canRotateKey && registersAuditorKey && !issuerKeyExists) + return tecNO_PERMISSION; if (enablesConfidentialAmount && sleMptIssuance->isFieldPresent(sfTransferFee) && (*sleMptIssuance)[sfTransferFee] > 0u) @@ -272,25 +291,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) && !enablesConfidentialAmount) + if (txHasIssuerKey && !sleMptIssuance->isFlag(lsfMPTCanHoldConfidentialBalance) && + !enablesConfidentialAmount) { return tecNO_PERMISSION; } - if (ctx.tx.isFieldPresent(sfAuditorEncryptionKey) && - !sleMptIssuance->isFlag(lsfMPTCanHoldConfidentialBalance) && !enablesConfidentialAmount) + if (txHasAuditorKey && !sleMptIssuance->isFlag(lsfMPTCanHoldConfidentialBalance) && + !enablesConfidentialAmount) { return tecNO_PERMISSION; } - // cannot upload key if there's circulating supply of COA - if ((ctx.tx.isFieldPresent(sfIssuerEncryptionKey) || - ctx.tx.isFieldPresent(sfAuditorEncryptionKey) || enablesConfidentialAmount) && - (*sleMptIssuance)[~sfConfidentialOutstandingAmount].value_or(0) > 0) - { + bool const hasConfidentialOA = + (*sleMptIssuance)[~sfConfidentialOutstandingAmount].value_or(0) > 0; + + // Pre-ConfidentialKeyRotation 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 + // ConfidentialKeyRotation. + if (enablesConfidentialAmount && hasConfidentialOA) + return tecNO_PERMISSION; return tesSUCCESS; } @@ -397,7 +421,16 @@ MPTokenIssuanceSet::doApply() sle->getType() == ltMPTOKEN_ISSUANCE, "MPTokenIssuanceSet::doApply : modifying MPTokenIssuance"); + // Only increment the issuer key epoch on a rotation. A first-time registration leaves + // it absent (epoch 0), matching issuances whose keys were registered + // before the ConfidentialKeyRotation amendment. + // NOTE: presence must be checked before the key is overwritten below. + bool const isRotation = sle->isFieldPresent(sfIssuerEncryptionKey); + sle->setFieldVL(sfIssuerEncryptionKey, *pubKey); + + if (isRotation) + (*sle)[sfIssuerKeyEpoch] = (*sle)[~sfIssuerKeyEpoch].valueOr(0) + 1; } if (auto const pubKey = ctx_.tx[~sfAuditorEncryptionKey]) @@ -407,7 +440,14 @@ MPTokenIssuanceSet::doApply() sle->getType() == ltMPTOKEN_ISSUANCE, "MPTokenIssuanceSet::doApply : modifying MPTokenIssuance"); + // Only increment the auditor key epoch on a rotation. First time registration leaves it + // absent. + bool const isRotation = sle->isFieldPresent(sfAuditorEncryptionKey); + sle->setFieldVL(sfAuditorEncryptionKey, *pubKey); + + if (isRotation) + (*sle)[sfAuditorKeyEpoch] = (*sle)[~sfAuditorKeyEpoch].valueOr(0) + 1; } view().update(sle); diff --git a/src/test/app/ConfidentialKeyRotation_test.cpp b/src/test/app/ConfidentialKeyRotation_test.cpp new file mode 100644 index 00000000000..614d65bb0f1 --- /dev/null +++ b/src/test/app/ConfidentialKeyRotation_test.cpp @@ -0,0 +1,496 @@ +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace xrpl { + +class ConfidentialKeyRotation_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[featureConfidentialKeyRotation]; + 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[featureConfidentialKeyRotation]; + 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) + { + // 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-ConfidentialKeyRotation; post-ConfidentialKeyRotation it rotates the auditor key + bool const rotationEnabled = features[featureConfidentialKeyRotation]; + 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-ConfidentialKeyRotation it fails preflight; post-ConfidentialKeyRotation it succeeds + // without touching any epoch because it's a first-time registration. + bool const rotationEnabled = features[featureConfidentialKeyRotation]; + 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-ConfidentialKeyRotation an auditor-only + // transaction fails preflight; post-ConfidentialKeyRotation it + // succeeds as a first-time late-registration even COA > 0. + bool const rotationEnabled = features[featureConfidentialKeyRotation]; + 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-ConfidentialKeyRotation an auditor-only + // transaction fails preflight; post-ConfidentialKeyRotation it passes preflight + // but preclaim rejects registering an auditor key on an issuance + // without an issuer key. + bool const rotationEnabled = features[featureConfidentialKeyRotation]; + 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[featureConfidentialKeyRotation]; + 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 ConfidentialKeyRotation amendment. + mptAlice.set({ + .account = alice, + .mutableFlags = tmfMPTSetCanHoldConfidentialBalance, + .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 - featureConfidentialKeyRotation); + } +}; + +BEAST_DEFINE_TESTSUITE(ConfidentialKeyRotation, app, xrpl); + +} // namespace xrpl diff --git a/src/test/app/ConfidentialTransfer_test.cpp b/src/test/app/ConfidentialTransfer_test.cpp index 0fc5d6f845a..7b91058ff03 100644 --- a/src/test/app/ConfidentialTransfer_test.cpp +++ b/src/test/app/ConfidentialTransfer_test.cpp @@ -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 + // ConfidentialKeyRotation) is covered in ConfidentialKeyRotation_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-ConfidentialKeyRotation behavior) { - Env env{*this, features}; + Env env{*this, features - featureConfidentialKeyRotation}; 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-ConfidentialKeyRotation behavior) { - Env env{*this, features}; + Env env{*this, features - featureConfidentialKeyRotation}; 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-ConfidentialKeyRotation behavior) { - Env env{*this, features}; + Env env{*this, features - featureConfidentialKeyRotation}; Account const alice("alice"); Account const auditor("auditor"); MPTTester mptAlice(env, alice, {.holders = {}, .auditor = auditor}); diff --git a/src/tests/libxrpl/protocol_autogen/ledger_entries/MPTokenIssuanceTests.cpp b/src/tests/libxrpl/protocol_autogen/ledger_entries/MPTokenIssuanceTests.cpp index 8dc5960ee0d..539ffff9b77 100644 --- a/src/tests/libxrpl/protocol_autogen/ledger_entries/MPTokenIssuanceTests.cpp +++ b/src/tests/libxrpl/protocol_autogen/ledger_entries/MPTokenIssuanceTests.cpp @@ -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(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()); } From 4cbc7fefdeb43260df758fbe4c42ca7f7c321412 Mon Sep 17 00:00:00 2001 From: yinyiqian1 Date: Tue, 11 Aug 2026 12:17:02 -0400 Subject: [PATCH 2/3] combine amendment gates --- .../transactors/token/MPTokenIssuanceSet.cpp | 34 +++++++++++-------- 1 file changed, 20 insertions(+), 14 deletions(-) diff --git a/src/libxrpl/tx/transactors/token/MPTokenIssuanceSet.cpp b/src/libxrpl/tx/transactors/token/MPTokenIssuanceSet.cpp index 0d7669e7ba7..be6a55bef8e 100644 --- a/src/libxrpl/tx/transactors/token/MPTokenIssuanceSet.cpp +++ b/src/libxrpl/tx/transactors/token/MPTokenIssuanceSet.cpp @@ -269,21 +269,27 @@ MPTokenIssuanceSet::preclaim(PreclaimContext const& ctx) bool const sleHasIssuerKey = sleMptIssuance->isFieldPresent(sfIssuerEncryptionKey); bool const sleHasAuditorKey = sleMptIssuance->isFieldPresent(sfAuditorEncryptionKey); - // cannot update issuer public key - if (!canRotateKey && txHasIssuerKey && sleHasIssuerKey) - return tecNO_PERMISSION; - - // cannot update auditor public key - if (!canRotateKey && txHasAuditorKey && sleHasAuditorKey) - return tecNO_PERMISSION; // LCOV_EXCL_LINE + if (canRotateKey) + { + // Post-ConfidentialKeyRotation 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; + } + else + { + // Pre-ConfidentialKeyRotation amendment, the encryption keys can not be updated. + // cannot update issuer public key + if (txHasIssuerKey && sleHasIssuerKey) + return tecNO_PERMISSION; - // 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 (canRotateKey && registersAuditorKey && !issuerKeyExists) - return tecNO_PERMISSION; + // cannot update auditor public key + if (txHasAuditorKey && sleHasAuditorKey) + return tecNO_PERMISSION; // LCOV_EXCL_LINE + } if (enablesConfidentialAmount && sleMptIssuance->isFieldPresent(sfTransferFee) && (*sleMptIssuance)[sfTransferFee] > 0u) From 5c2a1300076fde68613d0b81b7f1773551e0e85f Mon Sep 17 00:00:00 2001 From: yinyiqian1 Date: Tue, 11 Aug 2026 12:29:20 -0400 Subject: [PATCH 3/3] add set encryption key lambda --- .../transactors/token/MPTokenIssuanceSet.cpp | 41 ++++++++----------- 1 file changed, 16 insertions(+), 25 deletions(-) diff --git a/src/libxrpl/tx/transactors/token/MPTokenIssuanceSet.cpp b/src/libxrpl/tx/transactors/token/MPTokenIssuanceSet.cpp index be6a55bef8e..3ec5b39f6e3 100644 --- a/src/libxrpl/tx/transactors/token/MPTokenIssuanceSet.cpp +++ b/src/libxrpl/tx/transactors/token/MPTokenIssuanceSet.cpp @@ -420,41 +420,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 ConfidentialKeyRotation + // 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"); - // Only increment the issuer key epoch on a rotation. A first-time registration leaves - // it absent (epoch 0), matching issuances whose keys were registered - // before the ConfidentialKeyRotation amendment. // NOTE: presence must be checked before the key is overwritten below. - bool const isRotation = sle->isFieldPresent(sfIssuerEncryptionKey); + bool const isRotation = sle->isFieldPresent(keyField); - sle->setFieldVL(sfIssuerEncryptionKey, *pubKey); + sle->setFieldVL(keyField, *pubKey); if (isRotation) - (*sle)[sfIssuerKeyEpoch] = (*sle)[~sfIssuerKeyEpoch].valueOr(0) + 1; - } - - if (auto const pubKey = ctx_.tx[~sfAuditorEncryptionKey]) - { - // This is enforced in preflight. - XRPL_ASSERT( - sle->getType() == ltMPTOKEN_ISSUANCE, - "MPTokenIssuanceSet::doApply : modifying MPTokenIssuance"); - - // Only increment the auditor key epoch on a rotation. First time registration leaves it - // absent. - bool const isRotation = sle->isFieldPresent(sfAuditorEncryptionKey); - - sle->setFieldVL(sfAuditorEncryptionKey, *pubKey); + (*sle)[epochField] = (*sle)[~epochField].valueOr(0) + 1; + }; - if (isRotation) - (*sle)[sfAuditorKeyEpoch] = (*sle)[~sfAuditorKeyEpoch].valueOr(0) + 1; - } + setEncryptionKey(sfIssuerEncryptionKey, sfIssuerKeyEpoch); + setEncryptionKey(sfAuditorEncryptionKey, sfAuditorKeyEpoch); view().update(sle);