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..bc6d16311e1 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-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])) @@ -252,18 +260,45 @@ MPTokenIssuanceSet::preclaim(PreclaimContext const& ctx) return tecNO_PERMISSION; } - // cannot update issuer public key - if (ctx.tx.isFieldPresent(sfIssuerEncryptionKey) && - sleMptIssuance->isFieldPresent(sfIssuerEncryptionKey)) + // Updating an existing encryption key requires the + // ConfidentialMPTKeyRotation amendment. + bool const canRotateKey = ctx.view.rules().enabled(featureConfidentialMPTKeyRotation); + + 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; - } + // 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; - // cannot update auditor public key - if (ctx.tx.isFieldPresent(sfAuditorEncryptionKey) && - sleMptIssuance->isFieldPresent(sfAuditorEncryptionKey)) + if (txHasAuditorKey && sleHasAuditorKey && + ctx.tx[sfAuditorEncryptionKey] == (*sleMptIssuance)[sfAuditorEncryptionKey]) + return tecDUPLICATE; + } + else { - return tecNO_PERMISSION; // LCOV_EXCL_LINE + // 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 } if (enablesConfidentialAmount && sleMptIssuance->isFieldPresent(sfTransferFee) && @@ -272,25 +307,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-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 (enablesConfidentialAmount && hasConfidentialOA) + return tecNO_PERMISSION; return tesSUCCESS; } @@ -390,25 +430,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); diff --git a/src/test/app/ConfidentialMPTKeyRotation_test.cpp b/src/test/app/ConfidentialMPTKeyRotation_test.cpp new file mode 100644 index 00000000000..3bfc0792014 --- /dev/null +++ b/src/test/app/ConfidentialMPTKeyRotation_test.cpp @@ -0,0 +1,538 @@ +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +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, + .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 - featureConfidentialMPTKeyRotation); + } +}; + +BEAST_DEFINE_TESTSUITE(ConfidentialMPTKeyRotation, app, xrpl); + +} // namespace xrpl diff --git a/src/test/app/ConfidentialTransfer_test.cpp b/src/test/app/ConfidentialTransfer_test.cpp index 0fc5d6f845a..22dc46c70fb 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 + // 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}); 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()); }