diff --git a/include/xrpl/protocol/Protocol.h b/include/xrpl/protocol/Protocol.h index ec9b9ba70a..1b88eea456 100644 --- a/include/xrpl/protocol/Protocol.h +++ b/include/xrpl/protocol/Protocol.h @@ -12,6 +12,7 @@ #include #include #include +#include namespace xrpl { @@ -544,6 +545,11 @@ constexpr std::size_t kEcClawbackProofLength = SECP256K1_COMPACT_CLAWBACK_PROOF_ */ constexpr std::uint32_t kConfidentialFeeMultiplier = 9; +/** + * Maximum value a confidential MPT key epoch may reach. + */ +constexpr std::uint32_t kMaxKeyEpoch = std::numeric_limits::max(); + /** * Compressed EC point prefix for even y-coordinate */ diff --git a/include/xrpl/protocol/detail/features.macro b/include/xrpl/protocol/detail/features.macro index ae696a4ea4..fe49a0230f 100644 --- a/include/xrpl/protocol/detail/features.macro +++ b/include/xrpl/protocol/detail/features.macro @@ -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) diff --git a/include/xrpl/protocol/detail/ledger_entries.macro b/include/xrpl/protocol/detail/ledger_entries.macro index f166473d7f..be12ad6348 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/detail/sfields.macro b/include/xrpl/protocol/detail/sfields.macro index ec05804253..0a88a6a191 100644 --- a/include/xrpl/protocol/detail/sfields.macro +++ b/include/xrpl/protocol/detail/sfields.macro @@ -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) diff --git a/include/xrpl/protocol_autogen/ledger_entries/MPTokenIssuance.h b/include/xrpl/protocol_autogen/ledger_entries/MPTokenIssuance.h index 6a2caf52ae..74b5e3c4eb 100644 --- a/include/xrpl/protocol_autogen/ledger_entries/MPTokenIssuance.h +++ b/include/xrpl/protocol_autogen/ledger_entries/MPTokenIssuance.h @@ -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 + 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 @@ public: return *this; } + /** + * @brief Set sfIssuerKeyEpoch (SoeOptional) + * @return Reference to this builder for method chaining. + */ + MPTokenIssuanceBuilder& + setIssuerKeyEpoch(std::decay_t 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/protocol/BuildInfo.cpp b/src/libxrpl/protocol/BuildInfo.cpp index 0bc02f98ae..bf67defa3b 100644 --- a/src/libxrpl/protocol/BuildInfo.cpp +++ b/src/libxrpl/protocol/BuildInfo.cpp @@ -23,7 +23,7 @@ namespace { //------------------------------------------------------------------------------ // clang-format off // NOLINTNEXTLINE(readability-identifier-naming) -char const* const versionString = "3.4.0-b3" +char const* const versionString = "3.4.0-rc1" // clang-format on ; diff --git a/src/libxrpl/tx/transactors/token/MPTokenIssuanceSet.cpp b/src/libxrpl/tx/transactors/token/MPTokenIssuanceSet.cpp index e8fd2e22b6..6bd7140631 100644 --- a/src/libxrpl/tx/transactors/token/MPTokenIssuanceSet.cpp +++ b/src/libxrpl/tx/transactors/token/MPTokenIssuanceSet.cpp @@ -119,7 +119,15 @@ MPTokenIssuanceSet::preflight(PreflightContext const& ctx) if (hasHolder && (hasIssuerElGamalKey || hasAuditorElGamalKey)) return temMALFORMED; - if (hasAuditorElGamalKey && !hasIssuerElGamalKey) + // Pre-ConfidentialMPTKeyRotation amendment, the auditor key could not be + // registered independently of the issuer key. The issuer could either: + // - Register only the issuer key (in which case an auditor key could not be added later), or + // - Register both the issuer and auditor keys simultaneously. + // + // Post-ConfidentialMPTKeyRotation amendment, the auditor key can be + // registered after the issuer key has already been registered. + if (hasAuditorElGamalKey && !hasIssuerElGamalKey && + !ctx.rules.enabled(featureConfidentialMPTKeyRotation)) return temMALFORMED; if (hasIssuerElGamalKey && !isValidCompressedECPoint(ctx.tx[sfIssuerEncryptionKey])) @@ -219,18 +227,57 @@ MPTokenIssuanceSet::preclaim(PreclaimContext const& ctx) return tecNO_PERMISSION; } - // cannot update issuer public key - if (ctx.tx.isFieldPresent(sfIssuerEncryptionKey) && - sleMptIssuance->isFieldPresent(sfIssuerEncryptionKey)) - { - return tecNO_PERMISSION; - } + // Updating an existing encryption key requires the + // ConfidentialMPTKeyRotation amendment. + bool const canRotateKey = ctx.view.rules().enabled(featureConfidentialMPTKeyRotation); - // cannot update auditor public key - if (ctx.tx.isFieldPresent(sfAuditorEncryptionKey) && - sleMptIssuance->isFieldPresent(sfAuditorEncryptionKey)) + bool const txHasIssuerKey = ctx.tx.isFieldPresent(sfIssuerEncryptionKey); + bool const txHasAuditorKey = ctx.tx.isFieldPresent(sfAuditorEncryptionKey); + bool const sleHasIssuerKey = sleMptIssuance->isFieldPresent(sfIssuerEncryptionKey); + bool const sleHasAuditorKey = sleMptIssuance->isFieldPresent(sfAuditorEncryptionKey); + + if (canRotateKey) { - return tecNO_PERMISSION; // LCOV_EXCL_LINE + // Post-ConfidentialMPTKeyRotation amendment, the encryption keys can be updated. + // A first-time auditor key registration requires an issuer key, + // either already on the issuance or set by the same transaction. + bool const registersAuditorKey = txHasAuditorKey && !sleHasAuditorKey; + bool const issuerKeyExists = sleHasIssuerKey || txHasIssuerKey; + if (registersAuditorKey && !issuerKeyExists) + return tecNO_PERMISSION; + + // Rotating a key to its current value is not permitted: a key epoch + // increment must always correspond to an actual key change. + if (txHasIssuerKey && sleHasIssuerKey && + ctx.tx[sfIssuerEncryptionKey] == (*sleMptIssuance)[sfIssuerEncryptionKey]) + return tecDUPLICATE; + + if (txHasAuditorKey && sleHasAuditorKey && + ctx.tx[sfAuditorEncryptionKey] == (*sleMptIssuance)[sfAuditorEncryptionKey]) + return tecDUPLICATE; + + // Key epochs must never wrap. Epoch 0 serves as the sentinel for "never + // rotated." Holders' mirror epochs are checked against it for equality, + // so a wrap would cause stale mirror ciphertexts to appear valid instead + // of failing loudly. + if (txHasIssuerKey && sleHasIssuerKey && + (*sleMptIssuance)[~sfIssuerKeyEpoch].value_or(0) == kMaxKeyEpoch) + return tecNO_PERMISSION; + + if (txHasAuditorKey && sleHasAuditorKey && + (*sleMptIssuance)[~sfAuditorKeyEpoch].value_or(0) == kMaxKeyEpoch) + return tecNO_PERMISSION; + } + else + { + // Pre-ConfidentialMPTKeyRotation amendment, the encryption keys can not be updated. + // cannot update issuer public key + if (txHasIssuerKey && sleHasIssuerKey) + return tecNO_PERMISSION; + + // cannot update auditor public key + if (txHasAuditorKey && sleHasAuditorKey) + return tecNO_PERMISSION; // LCOV_EXCL_LINE } auto const enablesConfidentialBalance = @@ -241,25 +288,30 @@ MPTokenIssuanceSet::preclaim(PreclaimContext const& ctx) // Encryption keys can only be set if confidential amounts are already // enabled on the issuance OR if the transaction is enabling it - if (ctx.tx.isFieldPresent(sfIssuerEncryptionKey) && - !sleMptIssuance->isFlag(lsfMPTCanHoldConfidentialBalance) && !enablesConfidentialBalance) + if (txHasIssuerKey && !sleMptIssuance->isFlag(lsfMPTCanHoldConfidentialBalance) && + !enablesConfidentialBalance) { return tecNO_PERMISSION; } - if (ctx.tx.isFieldPresent(sfAuditorEncryptionKey) && - !sleMptIssuance->isFlag(lsfMPTCanHoldConfidentialBalance) && !enablesConfidentialBalance) + if (txHasAuditorKey && !sleMptIssuance->isFlag(lsfMPTCanHoldConfidentialBalance) && + !enablesConfidentialBalance) { return tecNO_PERMISSION; } - // cannot upload key if there's circulating supply of COA - if ((ctx.tx.isFieldPresent(sfIssuerEncryptionKey) || - ctx.tx.isFieldPresent(sfAuditorEncryptionKey) || enablesConfidentialBalance) && - (*sleMptIssuance)[~sfConfidentialOutstandingAmount].value_or(0) > 0) - { + bool const hasConfidentialOA = + (*sleMptIssuance)[~sfConfidentialOutstandingAmount].value_or(0) > 0; + + // Pre-ConfidentialMPTKeyRotation amendment, keys cannot be uploaded while + // COA > 0. Post-amendment they can be uploaded even if COA > 0. + if (!canRotateKey && (txHasIssuerKey || txHasAuditorKey) && hasConfidentialOA) return tecNO_PERMISSION; // LCOV_EXCL_LINE - } + + // Enabling confidential balances when COA > 0 is not permitted, regardless of + // ConfidentialMPTKeyRotation. + if (enablesConfidentialBalance && hasConfidentialOA) + return tecNO_PERMISSION; return tesSUCCESS; } @@ -377,25 +429,69 @@ MPTokenIssuanceSet::doApply() } } - if (auto const pubKey = ctx_.tx[~sfIssuerEncryptionKey]) - { - // This is enforced in preflight. + // Sets an encryption key on the issuance. Overwriting an existing key + // (a rotation) increments the corresponding key epoch; a first-time + // registration leaves the epoch absent (epoch 0), matching issuances + // whose keys were registered before the ConfidentialMPTKeyRotation + // amendment. + bool const canRotateKey = view().rules().enabled(featureConfidentialMPTKeyRotation); + auto const setEncryptionKey = [&](SF_VL const& keyField, SF_UINT32 const& epochField) -> TER { + auto const pubKey = ctx_.tx[~keyField]; + if (!pubKey) + return tesSUCCESS; + + // This is enforced in preflight, which rejects a transaction carrying + // both sfHolder and an encryption key. XRPL_ASSERT( sle->getType() == ltMPTOKEN_ISSUANCE, "MPTokenIssuanceSet::doApply : modifying MPTokenIssuance"); - sle->setFieldVL(sfIssuerEncryptionKey, *pubKey); - } + // Add sanity check under the amendment ConfidentialMPTKeyRotation. + // Pre-confidentialMPTKeyRotation did not return tecINTERNAL so + // this should be under the amendment guard. + if (canRotateKey && sle->getType() != ltMPTOKEN_ISSUANCE) + return tecINTERNAL; // LCOV_EXCL_LINE - if (auto const pubKey = ctx_.tx[~sfAuditorEncryptionKey]) - { - // This is enforced in preflight. - XRPL_ASSERT( - sle->getType() == ltMPTOKEN_ISSUANCE, - "MPTokenIssuanceSet::doApply : modifying MPTokenIssuance"); + // NOTE: presence must be checked before the key is overwritten below. + bool const isRotation = sle->isFieldPresent(keyField); + sle->setFieldVL(keyField, *pubKey); - sle->setFieldVL(sfAuditorEncryptionKey, *pubKey); - } + if (isRotation) + { + // Preclaim rejects overwriting an existing key unless the amendment is + // enabled. + if (!canRotateKey) + { + // LCOV_EXCL_START + UNREACHABLE("xrpl::MPTokenIssuanceSet::doApply : rotation without amendment"); + return tecINTERNAL; + // LCOV_EXCL_STOP + } + + auto const epoch = (*sle)[~epochField].valueOr(0); + + // Preclaim rejects a rotation that would wrap the epoch. So this should never happen. + if (epoch >= kMaxKeyEpoch) + { + // LCOV_EXCL_START + UNREACHABLE("xrpl::MPTokenIssuanceSet::doApply : key epoch overflow"); + return tecINTERNAL; + // LCOV_EXCL_STOP + } + + (*sle)[epochField] = epoch + 1; + } + + return tesSUCCESS; + }; + + if (auto const ter = setEncryptionKey(sfIssuerEncryptionKey, sfIssuerKeyEpoch); + !isTesSuccess(ter)) + return ter; // LCOV_EXCL_LINE + + if (auto const ter = setEncryptionKey(sfAuditorEncryptionKey, sfAuditorKeyEpoch); + !isTesSuccess(ter)) + return ter; // LCOV_EXCL_LINE view().update(sle); diff --git a/src/test/app/ConfidentialMPTKeyRotation_test.cpp b/src/test/app/ConfidentialMPTKeyRotation_test.cpp new file mode 100644 index 0000000000..c4e8e607da --- /dev/null +++ b/src/test/app/ConfidentialMPTKeyRotation_test.cpp @@ -0,0 +1,634 @@ +#include +#include +#include +#include + +#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. + BEAST_EXPECT(mptAlice.checkKeyEpochs(std::nullopt, std::nullopt)); + + // Rotating the issuer key requires the key rotation amendment + bool const rotationEnabled = features[featureConfidentialMPTKeyRotation]; + mptAlice.set({ + .account = alice, + .issuerPubKey = mptAlice.getPubKey(bob), + .err = rotationEnabled ? TER(tesSUCCESS) : TER(tecNO_PERMISSION), + }); + + // A rotation replaces the issuer key and bumps its epoch. The auditor + // key was never registered, so it and its epoch stay absent. + if (rotationEnabled) + { + BEAST_EXPECT(mptAlice.checkEncryptionKeys(bob, std::nullopt)); + BEAST_EXPECT(mptAlice.checkKeyEpochs(1u, std::nullopt)); + } + else + { + BEAST_EXPECT(mptAlice.checkEncryptionKeys(alice, std::nullopt)); + BEAST_EXPECT(mptAlice.checkKeyEpochs(std::nullopt, std::nullopt)); + } + + if (rotationEnabled) + { + // A second rotation increments the epoch again + mptAlice.set({ + .account = alice, + .issuerPubKey = mptAlice.getPubKey(alice), + }); + + BEAST_EXPECT(mptAlice.checkKeyEpochs(2u, std::nullopt)); + } + } + + void + testMPTokenIssuanceSetRotateBothKeys(FeatureBitset features) + { + testcase("MPTokenIssuanceSet rotate both issuer and auditor keys"); + using namespace test::jtx; + + Env env{*this, features}; + Account const alice("alice"); + Account const bob("bob"); + Account const auditor("auditor"); + MPTTester mptAlice(env, alice, {.holders = {bob}}); + + mptAlice.create({ + .ownerCount = 1, + .flags = tfMPTCanTransfer | tfMPTCanHoldConfidentialBalance, + }); + + mptAlice.generateKeyPair(alice); + mptAlice.generateKeyPair(bob); + mptAlice.generateKeyPair(auditor); + + // Register both keys together. + mptAlice.set({ + .account = alice, + .issuerPubKey = mptAlice.getPubKey(alice), + .auditorPubKey = mptAlice.getPubKey(auditor), + }); + + // Verify that no epochs are set when registering for the first time. + BEAST_EXPECT(mptAlice.checkKeyEpochs(std::nullopt, std::nullopt)); + + // Rotating both keys requires the amendment + bool const rotationEnabled = features[featureConfidentialMPTKeyRotation]; + mptAlice.set({ + .account = alice, + .issuerPubKey = mptAlice.getPubKey(bob), + .auditorPubKey = mptAlice.getPubKey(alice), + .err = rotationEnabled ? TER(tesSUCCESS) : TER(tecNO_PERMISSION), + }); + + if (rotationEnabled) + { + BEAST_EXPECT(mptAlice.checkEncryptionKeys(bob, alice)); + BEAST_EXPECT(mptAlice.checkKeyEpochs(1u, 1u)); + } + else + { + BEAST_EXPECT(mptAlice.checkEncryptionKeys(alice, auditor)); + BEAST_EXPECT(mptAlice.checkKeyEpochs(std::nullopt, std::nullopt)); + } + + if (rotationEnabled) + { + // Rotating the issuer key to its current value fails. + // Current issuer key is bob, duplicate. + mptAlice.set({ + .account = alice, + .issuerPubKey = mptAlice.getPubKey(bob), + .err = tecDUPLICATE, + }); + + // Rotating the auditor key to its current value fails. + // Current auditor key is alice, duplicate. + mptAlice.set({ + .account = alice, + .auditorPubKey = mptAlice.getPubKey(alice), + .err = tecDUPLICATE, + }); + + // The whole transaction fails when one key is unchanged, even if + // the other key is rotated to a new value. + // Current issuer key is bob, duplicate. + mptAlice.set({ + .account = alice, + .issuerPubKey = mptAlice.getPubKey(bob), + .auditorPubKey = mptAlice.getPubKey(auditor), + .err = tecDUPLICATE, + }); + + // Current auditor key is alice, duplicate. + mptAlice.set({ + .account = alice, + .issuerPubKey = mptAlice.getPubKey(auditor), + .auditorPubKey = mptAlice.getPubKey(alice), + .err = tecDUPLICATE, + }); + + // Nothing changed: keys and epochs are untouched + BEAST_EXPECT(mptAlice.checkKeyEpochs(1u, 1u)); + + // A second rotation increments both epochs again + mptAlice.set({ + .account = alice, + .issuerPubKey = mptAlice.getPubKey(alice), + .auditorPubKey = mptAlice.getPubKey(auditor), + }); + + BEAST_EXPECT(mptAlice.checkKeyEpochs(2u, 2u)); + } + } + + void + testMPTokenIssuanceSetRotateAuditorKeyOnly(FeatureBitset features) + { + testcase("MPTokenIssuanceSet rotate auditor key only"); + using namespace test::jtx; + + Env env{*this, features}; + Account const alice("alice"); + Account const bob("bob"); + Account const auditor("auditor"); + MPTTester mptAlice(env, alice, {.holders = {bob}}); + + mptAlice.create({ + .ownerCount = 1, + .flags = tfMPTCanTransfer | tfMPTCanHoldConfidentialBalance, + }); + + mptAlice.generateKeyPair(alice); + mptAlice.generateKeyPair(bob); + mptAlice.generateKeyPair(auditor); + + // Register both keys together. + mptAlice.set({ + .account = alice, + .issuerPubKey = mptAlice.getPubKey(alice), + .auditorPubKey = mptAlice.getPubKey(auditor), + }); + + // A transaction carrying only the auditor key fails preflight + // pre-ConfidentialMPTKeyRotation; post-ConfidentialMPTKeyRotation it rotates the auditor + // key + bool const rotationEnabled = features[featureConfidentialMPTKeyRotation]; + mptAlice.set({ + .account = alice, + .auditorPubKey = mptAlice.getPubKey(bob), + .err = rotationEnabled ? TER(tesSUCCESS) : TER(temMALFORMED), + }); + + // The issuer key keeps unchanged, and rotating only the auditor key + // bumps only its epoch. + if (rotationEnabled) + { + BEAST_EXPECT(mptAlice.checkEncryptionKeys(alice, bob)); + BEAST_EXPECT(mptAlice.checkKeyEpochs(std::nullopt, 1u)); + } + else + { + BEAST_EXPECT(mptAlice.checkEncryptionKeys(alice, auditor)); + BEAST_EXPECT(mptAlice.checkKeyEpochs(std::nullopt, std::nullopt)); + } + + if (rotationEnabled) + { + // A second rotation increments the epoch again + mptAlice.set({ + .account = alice, + .auditorPubKey = mptAlice.getPubKey(auditor), + }); + + // The issuer key epoch is still untouched. + BEAST_EXPECT(mptAlice.checkKeyEpochs(std::nullopt, 2u)); + } + } + + void + testMPTokenIssuanceSetRegisterAuditorKeyLater(FeatureBitset features) + { + testcase("MPTokenIssuanceSet register auditor key after issuer key"); + using namespace test::jtx; + + Env env{*this, features}; + Account const alice("alice"); + Account const auditor("auditor"); + MPTTester mptAlice(env, alice); + + mptAlice.create({ + .ownerCount = 1, + .flags = tfMPTCanTransfer | tfMPTCanHoldConfidentialBalance, + }); + + mptAlice.generateKeyPair(alice); + mptAlice.generateKeyPair(auditor); + + // Register the issuer key first. We'll register the auditor key in a separate transaction. + mptAlice.set({ + .account = alice, + .issuerPubKey = mptAlice.getPubKey(alice), + }); + + // Register the auditor key separately. + // pre-ConfidentialMPTKeyRotation it fails preflight; post-ConfidentialMPTKeyRotation it + // succeeds without touching any epoch because it's a first-time registration. + bool const rotationEnabled = features[featureConfidentialMPTKeyRotation]; + mptAlice.set({ + .account = alice, + .auditorPubKey = mptAlice.getPubKey(auditor), + .err = rotationEnabled ? TER(tesSUCCESS) : TER(temMALFORMED), + }); + + BEAST_EXPECT(mptAlice.checkEncryptionKeys( + alice, rotationEnabled ? std::optional(auditor) : std::nullopt)); + BEAST_EXPECT(mptAlice.checkKeyEpochs(std::nullopt, std::nullopt)); + } + + void + testMPTokenIssuanceSetRegisterAuditorKeyLaterWithCOA(FeatureBitset features) + { + testcase("MPTokenIssuanceSet register auditor key later with circulating supply"); + using namespace test::jtx; + + Env env{*this, features}; + Account const alice("alice"); + Account const bob("bob"); + Account const auditor("auditor"); + MPTTester mptAlice(env, alice, {.holders = {bob}}); + + mptAlice.create({ + .ownerCount = 1, + .flags = tfMPTCanTransfer | tfMPTCanHoldConfidentialBalance, + }); + + mptAlice.authorize({.account = bob}); + mptAlice.pay(alice, bob, 100); + + mptAlice.generateKeyPair(alice); + mptAlice.generateKeyPair(bob); + mptAlice.generateKeyPair(auditor); + + mptAlice.set({ + .account = alice, + .issuerPubKey = mptAlice.getPubKey(alice), + }); + + // Convert some of bob's balance so that COA > 0 + mptAlice.convert({ + .account = bob, + .amt = 50, + .holderPubKey = mptAlice.getPubKey(bob), + }); + + auto const sleIssuanceBefore = env.le(keylet::mptokenIssuance(mptAlice.issuanceID())); + if (!BEAST_EXPECT(sleIssuanceBefore)) + return; + auto const coaBefore = (*sleIssuanceBefore)[~sfConfidentialOutstandingAmount].value_or(0); + BEAST_EXPECT(coaBefore > 0); + + // Registering the auditor key for the first time while confidential + // supply is circulating: pre-ConfidentialMPTKeyRotation an auditor-only + // transaction fails preflight; post-ConfidentialMPTKeyRotation it + // succeeds as a first-time late-registration even COA > 0. + bool const rotationEnabled = features[featureConfidentialMPTKeyRotation]; + mptAlice.set({ + .account = alice, + .auditorPubKey = mptAlice.getPubKey(auditor), + .err = rotationEnabled ? TER(tesSUCCESS) : TER(temMALFORMED), + }); + + auto const sleIssuance = env.le(keylet::mptokenIssuance(mptAlice.issuanceID())); + if (!BEAST_EXPECT(sleIssuance)) + return; + BEAST_EXPECT(mptAlice.checkEncryptionKeys( + alice, rotationEnabled ? std::optional(auditor) : std::nullopt)); + BEAST_EXPECT(mptAlice.checkKeyEpochs(std::nullopt, std::nullopt)); + + // The circulating supply itself is not affected. + BEAST_EXPECT((*sleIssuance)[~sfConfidentialOutstandingAmount].value_or(0) == coaBefore); + } + + void + testMPTokenIssuanceSetAuditorKeyWithoutIssuerKey(FeatureBitset features) + { + testcase("MPTokenIssuanceSet auditor key requires issuer key"); + using namespace test::jtx; + + Env env{*this, features}; + Account const alice("alice"); + Account const auditor("auditor"); + MPTTester mptAlice(env, alice); + + mptAlice.create({ + .ownerCount = 1, + .flags = tfMPTCanTransfer | tfMPTCanHoldConfidentialBalance, + }); + + mptAlice.generateKeyPair(auditor); + // The issuer key was never registered. pre-ConfidentialMPTKeyRotation an auditor-only + // transaction fails preflight; post-ConfidentialMPTKeyRotation it passes preflight + // but preclaim rejects registering an auditor key on an issuance + // without an issuer key. + bool const rotationEnabled = features[featureConfidentialMPTKeyRotation]; + mptAlice.set({ + .account = alice, + .auditorPubKey = mptAlice.getPubKey(auditor), + .err = rotationEnabled ? TER(tecNO_PERMISSION) : TER(temMALFORMED), + }); + + // The rejected transaction leaves the issuance without either key. + BEAST_EXPECT(mptAlice.checkEncryptionKeys(std::nullopt, std::nullopt)); + BEAST_EXPECT(mptAlice.checkKeyEpochs(std::nullopt, std::nullopt)); + } + + void + testMPTokenIssuanceSetRotateWithCOA(FeatureBitset features) + { + testcase("MPTokenIssuanceSet rotate with circulating confidential supply"); + using namespace test::jtx; + + Env env{*this, features}; + Account const alice("alice"); + Account const bob("bob"); + Account const carol("carol"); + MPTTester mptAlice(env, alice, {.holders = {bob}}); + + mptAlice.create({ + .ownerCount = 1, + .flags = tfMPTCanTransfer | tfMPTCanHoldConfidentialBalance, + }); + + mptAlice.authorize({.account = bob}); + mptAlice.pay(alice, bob, 100); + + mptAlice.generateKeyPair(alice); + mptAlice.generateKeyPair(bob); + mptAlice.generateKeyPair(carol); + + mptAlice.set({ + .account = alice, + .issuerPubKey = mptAlice.getPubKey(alice), + }); + + // Convert some of bob's balance to confidential spending, so that the + // issuance has confidential supply. COA > 0. + mptAlice.convert({ + .account = bob, + .amt = 50, + .holderPubKey = mptAlice.getPubKey(bob), + }); + + auto const sleIssuanceBeforeRotation = + env.le(keylet::mptokenIssuance(mptAlice.issuanceID())); + if (!BEAST_EXPECT(sleIssuanceBeforeRotation)) + return; + auto const coaBeforeRotation = + (*sleIssuanceBeforeRotation)[~sfConfidentialOutstandingAmount].value_or(0); + BEAST_EXPECT(coaBeforeRotation > 0); + + // Rotating key requires the + // amendment. + bool const rotationEnabled = features[featureConfidentialMPTKeyRotation]; + mptAlice.set({ + .account = alice, + .issuerPubKey = mptAlice.getPubKey(carol), + .err = rotationEnabled ? TER(tesSUCCESS) : TER(tecNO_PERMISSION), + }); + + auto const sleIssuance = env.le(keylet::mptokenIssuance(mptAlice.issuanceID())); + if (!BEAST_EXPECT(sleIssuance)) + return; + if (rotationEnabled) + { + BEAST_EXPECT(mptAlice.checkEncryptionKeys(carol, std::nullopt)); + BEAST_EXPECT(mptAlice.checkKeyEpochs(1u, std::nullopt)); + } + else + { + BEAST_EXPECT(mptAlice.checkEncryptionKeys(alice, std::nullopt)); + BEAST_EXPECT(mptAlice.checkKeyEpochs(std::nullopt, std::nullopt)); + } + + // The confidential outstanding amount is not affected by the rotation + BEAST_EXPECT( + (*sleIssuance)[~sfConfidentialOutstandingAmount].value_or(0) == coaBeforeRotation); + + // Re-enabling confidential balances while supply is circulating is + // rejected regardless of the ConfidentialMPTKeyRotation amendment. + mptAlice.set({ + .account = alice, + .flags = tfMPTSetCanHoldConfidentialBalance, + .err = tecNO_PERMISSION, + }); + } + + void + testMPTokenIssuanceSetKeyEpochAtMax(FeatureBitset features) + { + using namespace test::jtx; + if (!features[featureConfidentialMPTKeyRotation]) + return; + + testcase("MPTokenIssuanceSet key epoch cannot wrap"); + + Env env{*this, features}; + Account const alice("alice"); + Account const bob("bob"); + Account const carol("carol"); + Account const auditor("auditor"); + + // Keep the ledger open so that we can write the key epochs directly into it. + MPTTester mptAlice(env, alice, {.holders = {bob}, .close = false}); + + mptAlice.create({ + .ownerCount = 1, + .flags = tfMPTCanTransfer | tfMPTCanHoldConfidentialBalance, + }); + + mptAlice.generateKeyPair(alice); + mptAlice.generateKeyPair(bob); + mptAlice.generateKeyPair(carol); + mptAlice.generateKeyPair(auditor); + + mptAlice.set({ + .account = alice, + .issuerPubKey = mptAlice.getPubKey(alice), + .auditorPubKey = mptAlice.getPubKey(auditor), + }); + + auto const issuanceKeylet = keylet::mptokenIssuance(mptAlice.issuanceID()); + + // Writes the supplied key epochs straight into the open ledger so that + // the maximum epoch is reachable without submitting four billion + // rotations. + auto setEpochs = [&](std::optional const& issuerKeyEpoch, + std::optional const& auditorKeyEpoch) { + env.app().getOpenLedger().modify([&](OpenView& view, beast::Journal) { + auto const sle = view.read(issuanceKeylet); + if (!sle) + return false; // LCOV_EXCL_LINE + + auto replacement = std::make_shared(*sle); + if (issuerKeyEpoch) + (*replacement)[sfIssuerKeyEpoch] = *issuerKeyEpoch; + if (auditorKeyEpoch) + (*replacement)[sfAuditorKeyEpoch] = *auditorKeyEpoch; + view.rawReplace(replacement); + return true; + }); + }; + + BEAST_EXPECT(mptAlice.checkEncryptionKeys(alice, auditor)); + BEAST_EXPECT(mptAlice.checkKeyEpochs(std::nullopt, std::nullopt)); + + // Increment the auditor epoch to kMaxKeyEpoch - 1, leaving the issuer epoch absent. + setEpochs(std::nullopt, kMaxKeyEpoch - 1); + BEAST_EXPECT(mptAlice.checkEncryptionKeys(alice, auditor)); + BEAST_EXPECT(mptAlice.checkKeyEpochs(std::nullopt, kMaxKeyEpoch - 1)); + + // Rotating the auditor key to kMaxKeyEpoch succeeds. + mptAlice.set({ + .account = alice, + .auditorPubKey = mptAlice.getPubKey(carol), + }); + + BEAST_EXPECT(mptAlice.checkEncryptionKeys(alice, carol)); + BEAST_EXPECT(mptAlice.checkKeyEpochs(std::nullopt, kMaxKeyEpoch)); + + // A further auditor rotation is rejected because the epoch is exhausted. + mptAlice.set({ + .account = alice, + .auditorPubKey = mptAlice.getPubKey(bob), + .err = tecNO_PERMISSION, + }); + + // Rotating both keys at once is rejected as a whole because the auditor + // epoch is exhausted. + mptAlice.set({ + .account = alice, + .issuerPubKey = mptAlice.getPubKey(auditor), + .auditorPubKey = mptAlice.getPubKey(bob), + .err = tecNO_PERMISSION, + }); + + // Both rejections leave every key and epoch as it was. + BEAST_EXPECT(mptAlice.checkEncryptionKeys(alice, carol)); + BEAST_EXPECT(mptAlice.checkKeyEpochs(std::nullopt, kMaxKeyEpoch)); + + // The issuer key is unaffected by the exhausted auditor epoch. + mptAlice.set({ + .account = alice, + .issuerPubKey = mptAlice.getPubKey(bob), + }); + + BEAST_EXPECT(mptAlice.checkEncryptionKeys(bob, carol)); + BEAST_EXPECT(mptAlice.checkKeyEpochs(1u, kMaxKeyEpoch)); + + // Increment the issuer epoch to kMaxKeyEpoch - 1. + setEpochs(kMaxKeyEpoch - 1, std::nullopt); + BEAST_EXPECT(mptAlice.checkEncryptionKeys(bob, carol)); + BEAST_EXPECT(mptAlice.checkKeyEpochs(kMaxKeyEpoch - 1, kMaxKeyEpoch)); + + // Rotating the issuer key to kMaxKeyEpoch succeeds. + mptAlice.set({ + .account = alice, + .issuerPubKey = mptAlice.getPubKey(auditor), + }); + + BEAST_EXPECT(mptAlice.checkEncryptionKeys(auditor, carol)); + BEAST_EXPECT(mptAlice.checkKeyEpochs(kMaxKeyEpoch, kMaxKeyEpoch)); + + // With both epochs exhausted neither key can be rotated again. + mptAlice.set({ + .account = alice, + .issuerPubKey = mptAlice.getPubKey(alice), + .err = tecNO_PERMISSION, + }); + mptAlice.set({ + .account = alice, + .auditorPubKey = mptAlice.getPubKey(bob), + .err = tecNO_PERMISSION, + }); + mptAlice.set({ + .account = alice, + .issuerPubKey = mptAlice.getPubKey(alice), + .auditorPubKey = mptAlice.getPubKey(bob), + .err = tecNO_PERMISSION, + }); + + BEAST_EXPECT(mptAlice.checkEncryptionKeys(auditor, carol)); + BEAST_EXPECT(mptAlice.checkKeyEpochs(kMaxKeyEpoch, kMaxKeyEpoch)); + } + + void + testMPTokenIssuanceSetWithFeats(FeatureBitset features) + { + testMPTokenIssuanceSetRotateIssuerKey(features); + testMPTokenIssuanceSetRotateBothKeys(features); + testMPTokenIssuanceSetRotateAuditorKeyOnly(features); + testMPTokenIssuanceSetRegisterAuditorKeyLater(features); + testMPTokenIssuanceSetRegisterAuditorKeyLaterWithCOA(features); + testMPTokenIssuanceSetAuditorKeyWithoutIssuerKey(features); + testMPTokenIssuanceSetRotateWithCOA(features); + testMPTokenIssuanceSetKeyEpochAtMax(features); + } + +public: + void + run() override + { + using namespace test::jtx; + FeatureBitset const all{testableAmendments()}; + + testMPTokenIssuanceSetWithFeats(all); + testMPTokenIssuanceSetWithFeats(all - featureConfidentialMPTKeyRotation); + } +}; + +BEAST_DEFINE_TESTSUITE(ConfidentialMPTKeyRotation, app, xrpl); + +} // namespace xrpl diff --git a/src/test/app/ConfidentialTransfer_test.cpp b/src/test/app/ConfidentialTransfer_test.cpp index 6450ceeb61..a964193c1a 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/test/jtx/impl/mpt.cpp b/src/test/jtx/impl/mpt.cpp index 2743084beb..0c1ff14eab 100644 --- a/src/test/jtx/impl/mpt.cpp +++ b/src/test/jtx/impl/mpt.cpp @@ -648,6 +648,38 @@ MPTTester::checkImmutableFlags(std::uint32_t expectedFlags) const }); } +[[nodiscard]] bool +MPTTester::checkKeyEpochs( + std::optional issuerKeyEpoch, + std::optional auditorKeyEpoch) const +{ + return forObject([&](SLEP const& sle) -> bool { + return (*sle)[~sfIssuerKeyEpoch] == issuerKeyEpoch && + (*sle)[~sfAuditorKeyEpoch] == auditorKeyEpoch; + }); +} + +[[nodiscard]] bool +MPTTester::checkEncryptionKeys( + std::optional const& issuerKeyOwner, + std::optional const& auditorKeyOwner) const +{ + auto const matches = + [this](SLEP const& sle, SF_VL const& field, std::optional const& owner) { + if (!owner) + return !sle->isFieldPresent(field); + + auto const expected = getPubKey(*owner); + return expected && sle->isFieldPresent(field) && + strHex((*sle)[field]) == strHex(*expected); + }; + + return forObject([&](SLEP const& sle) -> bool { + return matches(sle, sfIssuerEncryptionKey, issuerKeyOwner) && + matches(sle, sfAuditorEncryptionKey, auditorKeyOwner); + }); +} + void MPTTester::pay( Account const& src, diff --git a/src/test/jtx/mpt.h b/src/test/jtx/mpt.h index 35ab7264bd..26329ad78c 100644 --- a/src/test/jtx/mpt.h +++ b/src/test/jtx/mpt.h @@ -612,6 +612,21 @@ public: [[nodiscard]] bool checkImmutableFlags(std::uint32_t expectedFlags) const; + // Checks both key epochs on the issuance. Pass std::nullopt for an epoch + // that is expected to be absent, which means the key is never rotated. + [[nodiscard]] bool + checkKeyEpochs( + std::optional issuerKeyEpoch, + std::optional auditorKeyEpoch) const; + + // Checks that the issuance carries the encryption keys of the given + // accounts. Pass std::nullopt for a key that is expected to be absent, + // which means the key is never registered. + [[nodiscard]] bool + checkEncryptionKeys( + std::optional const& issuerKeyOwner, + std::optional const& auditorKeyOwner) const; + [[nodiscard]] Account const& issuer() const { diff --git a/src/tests/libxrpl/protocol_autogen/ledger_entries/MPTokenIssuanceTests.cpp b/src/tests/libxrpl/protocol_autogen/ledger_entries/MPTokenIssuanceTests.cpp index 974d0e81d7..e8c1b645d8 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()); }