From c45363fd8b72b33e6eee4e331ff3ddccc65235c8 Mon Sep 17 00:00:00 2001 From: Peter Chen <34582813+PeterChen13579@users.noreply.github.com> Date: Fri, 2 Oct 2026 22:54:38 +0000 Subject: [PATCH] feat: Implement Confidential mpt holder key update (#8266) --- .cspell.config.yaml | 1 + include/xrpl/protocol/TxFlags.h | 6 + .../xrpl/protocol/detail/ledger_entries.macro | 1 + include/xrpl/protocol/detail/sfields.macro | 1 + .../xrpl/protocol/detail/transactions.macro | 17 +- .../protocol_autogen/ledger_entries/MPToken.h | 35 + .../ConfidentialMPTHolderKeyUpdate.h | 279 +++++++ .../transactions/TransactionProposalCreate.h | 2 +- .../token/ConfidentialMPTHolderKeyUpdate.h | 84 +++ src/libxrpl/tx/invariants/MPTInvariant.cpp | 9 +- .../token/ConfidentialMPTHolderKeyUpdate.cpp | 220 ++++++ .../app/ConfidentialMPTKeyRotation_test.cpp | 705 ++++++++++++++++++ src/test/jtx/ConfidentialTransfer.h | 12 + src/test/jtx/impl/ConfidentialTransfer.cpp | 25 + src/test/jtx/impl/mpt.cpp | 21 + src/test/jtx/impl/utility.cpp | 3 +- src/test/jtx/mpt.h | 23 + .../ledger_entries/MPTokenTests.cpp | 27 + .../ConfidentialMPTHolderKeyUpdateTests.cpp | 258 +++++++ 19 files changed, 1722 insertions(+), 7 deletions(-) create mode 100644 include/xrpl/protocol_autogen/transactions/ConfidentialMPTHolderKeyUpdate.h create mode 100644 include/xrpl/tx/transactors/token/ConfidentialMPTHolderKeyUpdate.h create mode 100644 src/libxrpl/tx/transactors/token/ConfidentialMPTHolderKeyUpdate.cpp create mode 100644 src/tests/libxrpl/protocol_autogen/transactions/ConfidentialMPTHolderKeyUpdateTests.cpp diff --git a/.cspell.config.yaml b/.cspell.config.yaml index 48afdc2b7a..9147b1be92 100644 --- a/.cspell.config.yaml +++ b/.cspell.config.yaml @@ -255,6 +255,7 @@ words: - queuable - Raphson - rcflags + - reencrypted - replayer - repodata - repomd diff --git a/include/xrpl/protocol/TxFlags.h b/include/xrpl/protocol/TxFlags.h index 40edf2239b..8ed9a43c2e 100644 --- a/include/xrpl/protocol/TxFlags.h +++ b/include/xrpl/protocol/TxFlags.h @@ -238,6 +238,12 @@ inline constexpr FlagValue tfUniversalMask = ~tfUniversal; TF_FLAG(tfSponsorshipEnd, 0x00010000) \ TF_FLAG(tfSponsorshipCreate, 0x00020000) \ TF_FLAG(tfSponsorshipReassign, 0x00040000), \ + MASK_ADJ(0)) \ + \ + TRANSACTION(ConfidentialMPTHolderKeyUpdate, \ + TF_FLAG(tfHolderKeyRotation, 0x00010000) \ + TF_FLAG(tfHolderKeyRecovery, 0x00020000) \ + TF_FLAG(tfCancelRecovery, 0x00040000), \ MASK_ADJ(0)) // clang-format on diff --git a/include/xrpl/protocol/detail/ledger_entries.macro b/include/xrpl/protocol/detail/ledger_entries.macro index e642c1e8c6..b332ce870d 100644 --- a/include/xrpl/protocol/detail/ledger_entries.macro +++ b/include/xrpl/protocol/detail/ledger_entries.macro @@ -439,6 +439,7 @@ LEDGER_ENTRY(ltMPTOKEN, 0x007f, MPToken, mptoken, ({ {sfIssuerKeyMirrorEpoch, SoeOptional}, {sfAuditorKeyMirrorEpoch, SoeOptional}, {sfHolderEncryptionKey, SoeOptional}, + {sfRecoveryKey, SoeOptional}, })) /** A ledger object which tracks Oracle diff --git a/include/xrpl/protocol/detail/sfields.macro b/include/xrpl/protocol/detail/sfields.macro index 14c86c9f79..8b6b1a8867 100644 --- a/include/xrpl/protocol/detail/sfields.macro +++ b/include/xrpl/protocol/detail/sfields.macro @@ -330,6 +330,7 @@ TYPED_SFIELD(sfAuditorEncryptionKey, VL, 44) TYPED_SFIELD(sfAmountCommitment, VL, 45) TYPED_SFIELD(sfBalanceCommitment, VL, 46) TYPED_SFIELD(sfBytecode, VL, 47) +TYPED_SFIELD(sfRecoveryKey, VL, 48) // account (common) TYPED_SFIELD(sfAccount, ACCOUNT, 1) diff --git a/include/xrpl/protocol/detail/transactions.macro b/include/xrpl/protocol/detail/transactions.macro index cc57af8d08..cf1617ec78 100644 --- a/include/xrpl/protocol/detail/transactions.macro +++ b/include/xrpl/protocol/detail/transactions.macro @@ -1147,12 +1147,27 @@ TRANSACTION(ttCONFIDENTIAL_MPT_MIRROR_UPDATE, 92, ConfidentialMPTMirrorUpdate, {sfZKProof, SoeRequired}, })) +/** This transaction rotates or recovers a confidential MPT holder's ElGamal encryption key, + or cancels a recovery. */ +#if TRANSACTION_INCLUDE +# include +#endif +TRANSACTION(ttCONFIDENTIAL_MPT_HOLDER_KEY_UPDATE, 93, ConfidentialMPTHolderKeyUpdate, + ({.amendment = featureConfidentialMPTKeyRotation}), + ({ + {sfMPTokenIssuanceID, SoeRequired}, + {sfHolderEncryptionKey, SoeOptional}, + {sfConfidentialBalanceSpending, SoeOptional}, + {sfConfidentialBalanceInbox, SoeOptional}, + {sfZKProof, SoeOptional}, +})) + /** This transaction posts an unsigned transaction on-ledger as a TransactionProposal, pending multi-signature collection. */ #if TRANSACTION_INCLUDE # include #endif -TRANSACTION(ttTRANSACTION_PROPOSAL_CREATE, 93, TransactionProposalCreate, +TRANSACTION(ttTRANSACTION_PROPOSAL_CREATE, 95, TransactionProposalCreate, ({.amendment = featureCosign}), ({ {sfProposedTransaction, SoeRequired}, diff --git a/include/xrpl/protocol_autogen/ledger_entries/MPToken.h b/include/xrpl/protocol_autogen/ledger_entries/MPToken.h index 1e0d5f8ab1..7c68dd973c 100644 --- a/include/xrpl/protocol_autogen/ledger_entries/MPToken.h +++ b/include/xrpl/protocol_autogen/ledger_entries/MPToken.h @@ -339,6 +339,30 @@ public: { return this->sle_->isFieldPresent(sfHolderEncryptionKey); } + + /** + * @brief Get sfRecoveryKey (SoeOptional) + * @return The field value, or std::nullopt if not present. + */ + [[nodiscard]] + protocol_autogen::Optional + getRecoveryKey() const + { + if (hasRecoveryKey()) + return this->sle_->at(sfRecoveryKey); + return std::nullopt; + } + + /** + * @brief Check if sfRecoveryKey is present. + * @return True if the field is present, false otherwise. + */ + [[nodiscard]] + bool + hasRecoveryKey() const + { + return this->sle_->isFieldPresent(sfRecoveryKey); + } }; /** @@ -552,6 +576,17 @@ public: return *this; } + /** + * @brief Set sfRecoveryKey (SoeOptional) + * @return Reference to this builder for method chaining. + */ + MPTokenBuilder& + setRecoveryKey(std::decay_t const& value) + { + object_[sfRecoveryKey] = value; + return *this; + } + /** * @brief Build and return the completed MPToken wrapper. * @param index The ledger entry index. diff --git a/include/xrpl/protocol_autogen/transactions/ConfidentialMPTHolderKeyUpdate.h b/include/xrpl/protocol_autogen/transactions/ConfidentialMPTHolderKeyUpdate.h new file mode 100644 index 0000000000..ef361d420e --- /dev/null +++ b/include/xrpl/protocol_autogen/transactions/ConfidentialMPTHolderKeyUpdate.h @@ -0,0 +1,279 @@ +// This file is auto-generated. Do not edit. +#pragma once + +#include +#include +#include +#include +#include +#include + +#include +#include + +namespace xrpl::transactions { + +class ConfidentialMPTHolderKeyUpdateBuilder; + +/** + * @brief Transaction: ConfidentialMPTHolderKeyUpdate + * + * Type: ttCONFIDENTIAL_MPT_HOLDER_KEY_UPDATE (93) + * Delegable: Delegation::NotDelegable + * Amendment: featureConfidentialMPTKeyRotation + * Privileges: Privilege::NoPriv + * + * Immutable wrapper around STTx providing type-safe field access. + * Use ConfidentialMPTHolderKeyUpdateBuilder to construct new transactions. + */ +class ConfidentialMPTHolderKeyUpdate : public TransactionBase +{ +public: + static constexpr xrpl::TxType txType = ttCONFIDENTIAL_MPT_HOLDER_KEY_UPDATE; + + /** + * @brief Construct a ConfidentialMPTHolderKeyUpdate transaction wrapper from an existing STTx object. + * @throws std::runtime_error if the transaction type doesn't match. + */ + explicit ConfidentialMPTHolderKeyUpdate(std::shared_ptr tx) + : TransactionBase(std::move(tx)) + { + // Verify transaction type + if (tx_->getTxnType() != txType) + { + throw std::runtime_error("Invalid transaction type for ConfidentialMPTHolderKeyUpdate"); + } + } + + // Transaction-specific field getters + + /** + * @brief Get sfMPTokenIssuanceID (SoeRequired) + * @return The field value. + */ + [[nodiscard]] + SF_UINT192::type::value_type + getMPTokenIssuanceID() const + { + return this->tx_->at(sfMPTokenIssuanceID); + } + + /** + * @brief Get sfHolderEncryptionKey (SoeOptional) + * @return The field value, or std::nullopt if not present. + */ + [[nodiscard]] + protocol_autogen::Optional + getHolderEncryptionKey() const + { + if (hasHolderEncryptionKey()) + { + return this->tx_->at(sfHolderEncryptionKey); + } + return std::nullopt; + } + + /** + * @brief Check if sfHolderEncryptionKey is present. + * @return True if the field is present, false otherwise. + */ + [[nodiscard]] + bool + hasHolderEncryptionKey() const + { + return this->tx_->isFieldPresent(sfHolderEncryptionKey); + } + + /** + * @brief Get sfConfidentialBalanceSpending (SoeOptional) + * @return The field value, or std::nullopt if not present. + */ + [[nodiscard]] + protocol_autogen::Optional + getConfidentialBalanceSpending() const + { + if (hasConfidentialBalanceSpending()) + { + return this->tx_->at(sfConfidentialBalanceSpending); + } + return std::nullopt; + } + + /** + * @brief Check if sfConfidentialBalanceSpending is present. + * @return True if the field is present, false otherwise. + */ + [[nodiscard]] + bool + hasConfidentialBalanceSpending() const + { + return this->tx_->isFieldPresent(sfConfidentialBalanceSpending); + } + + /** + * @brief Get sfConfidentialBalanceInbox (SoeOptional) + * @return The field value, or std::nullopt if not present. + */ + [[nodiscard]] + protocol_autogen::Optional + getConfidentialBalanceInbox() const + { + if (hasConfidentialBalanceInbox()) + { + return this->tx_->at(sfConfidentialBalanceInbox); + } + return std::nullopt; + } + + /** + * @brief Check if sfConfidentialBalanceInbox is present. + * @return True if the field is present, false otherwise. + */ + [[nodiscard]] + bool + hasConfidentialBalanceInbox() const + { + return this->tx_->isFieldPresent(sfConfidentialBalanceInbox); + } + + /** + * @brief Get sfZKProof (SoeOptional) + * @return The field value, or std::nullopt if not present. + */ + [[nodiscard]] + protocol_autogen::Optional + getZKProof() const + { + if (hasZKProof()) + { + return this->tx_->at(sfZKProof); + } + return std::nullopt; + } + + /** + * @brief Check if sfZKProof is present. + * @return True if the field is present, false otherwise. + */ + [[nodiscard]] + bool + hasZKProof() const + { + return this->tx_->isFieldPresent(sfZKProof); + } +}; + +/** + * @brief Builder for ConfidentialMPTHolderKeyUpdate transactions. + * + * Provides a fluent interface for constructing transactions with method chaining. + * Uses STObject internally for flexible transaction construction. + * Inherits common field setters from TransactionBuilderBase. + */ +class ConfidentialMPTHolderKeyUpdateBuilder : public TransactionBuilderBase +{ +public: + /** + * @brief Construct a new ConfidentialMPTHolderKeyUpdateBuilder with required fields. + * @param account The account initiating the transaction. + * @param mPTokenIssuanceID The sfMPTokenIssuanceID field value. + * @param sequence Optional sequence number for the transaction. + * @param fee Optional fee for the transaction. + */ + ConfidentialMPTHolderKeyUpdateBuilder(SF_ACCOUNT::type::value_type account, + std::decay_t const& mPTokenIssuanceID, std::optional sequence = std::nullopt, + std::optional fee = std::nullopt +) + : TransactionBuilderBase(ttCONFIDENTIAL_MPT_HOLDER_KEY_UPDATE, account, sequence, fee) + { + setMPTokenIssuanceID(mPTokenIssuanceID); + } + + /** + * @brief Construct a ConfidentialMPTHolderKeyUpdateBuilder from an existing STTx object. + * @param tx The existing transaction to copy from. + * @throws std::runtime_error if the transaction type doesn't match. + */ + ConfidentialMPTHolderKeyUpdateBuilder(std::shared_ptr tx) + { + if (tx->getTxnType() != ttCONFIDENTIAL_MPT_HOLDER_KEY_UPDATE) + { + throw std::runtime_error("Invalid transaction type for ConfidentialMPTHolderKeyUpdateBuilder"); + } + object_ = *tx; + } + + /** + * @brief Transaction-specific field setters + */ + + /** + * @brief Set sfMPTokenIssuanceID (SoeRequired) + * @return Reference to this builder for method chaining. + */ + ConfidentialMPTHolderKeyUpdateBuilder& + setMPTokenIssuanceID(std::decay_t const& value) + { + object_[sfMPTokenIssuanceID] = value; + return *this; + } + + /** + * @brief Set sfHolderEncryptionKey (SoeOptional) + * @return Reference to this builder for method chaining. + */ + ConfidentialMPTHolderKeyUpdateBuilder& + setHolderEncryptionKey(std::decay_t const& value) + { + object_[sfHolderEncryptionKey] = value; + return *this; + } + + /** + * @brief Set sfConfidentialBalanceSpending (SoeOptional) + * @return Reference to this builder for method chaining. + */ + ConfidentialMPTHolderKeyUpdateBuilder& + setConfidentialBalanceSpending(std::decay_t const& value) + { + object_[sfConfidentialBalanceSpending] = value; + return *this; + } + + /** + * @brief Set sfConfidentialBalanceInbox (SoeOptional) + * @return Reference to this builder for method chaining. + */ + ConfidentialMPTHolderKeyUpdateBuilder& + setConfidentialBalanceInbox(std::decay_t const& value) + { + object_[sfConfidentialBalanceInbox] = value; + return *this; + } + + /** + * @brief Set sfZKProof (SoeOptional) + * @return Reference to this builder for method chaining. + */ + ConfidentialMPTHolderKeyUpdateBuilder& + setZKProof(std::decay_t const& value) + { + object_[sfZKProof] = value; + return *this; + } + + /** + * @brief Build and return the ConfidentialMPTHolderKeyUpdate wrapper. + * @param publicKey The public key for signing. + * @param secretKey The secret key for signing. + * @return The constructed transaction wrapper. + */ + ConfidentialMPTHolderKeyUpdate + build(PublicKey const& publicKey, SecretKey const& secretKey) + { + sign(publicKey, secretKey); + return ConfidentialMPTHolderKeyUpdate{std::make_shared(std::move(object_))}; + } +}; + +} // namespace xrpl::transactions diff --git a/include/xrpl/protocol_autogen/transactions/TransactionProposalCreate.h b/include/xrpl/protocol_autogen/transactions/TransactionProposalCreate.h index 4da2deed00..2397d1a412 100644 --- a/include/xrpl/protocol_autogen/transactions/TransactionProposalCreate.h +++ b/include/xrpl/protocol_autogen/transactions/TransactionProposalCreate.h @@ -18,7 +18,7 @@ class TransactionProposalCreateBuilder; /** * @brief Transaction: TransactionProposalCreate * - * Type: ttTRANSACTION_PROPOSAL_CREATE (93) + * Type: ttTRANSACTION_PROPOSAL_CREATE (95) * Delegable: Delegation::NotDelegable * Amendment: featureCosign * Privileges: Privilege::NoPriv diff --git a/include/xrpl/tx/transactors/token/ConfidentialMPTHolderKeyUpdate.h b/include/xrpl/tx/transactors/token/ConfidentialMPTHolderKeyUpdate.h new file mode 100644 index 0000000000..94bb5b6d29 --- /dev/null +++ b/include/xrpl/tx/transactors/token/ConfidentialMPTHolderKeyUpdate.h @@ -0,0 +1,84 @@ +#pragma once + +#include +#include +#include +#include +#include +#include +#include +#include + +#include + +namespace xrpl { + +/** + * @brief Allows a confidential MPT holder to rotate or recover their ElGamal + * encryption key, or to cancel a pending recovery. + * + * Submitted by the holder. Exactly one of three modes must be selected via + * the transaction flags: + * + * - Rotation (tfHolderKeyRotation): the holder still has their current + * ElGamal private key. They provide a new public key along with their + * current spending/inbox balances re-encrypted under that new key. The + * holder's encryption key and confidential balances are updated + * immediately. + * + * - Recovery (tfHolderKeyRecovery): the holder has lost their current + * private key. They provide a new public key but cannot provide + * re-encrypted balances. The new key is recorded as a pending + * sfRecoveryKey; the confidential balances are left untouched. Completing + * the recovery (rewriting the balances) is done separately by the issuer + * via ConfidentialMPTRecoverBalance. + * + * - Cancel (tfCancelRecovery): the holder revokes a pending recovery + * authorization created by a prior Recovery-mode transaction. No key, + * balances, or proof are carried; authorization is via the holder's + * ordinary XRPL signature. sfRecoveryKey is removed and everything else + * is left untouched. Fails if no recovery is pending. + * + * @note Zero-knowledge proof verification for Rotation and Recovery is + * deferred to a follow-up once the mpt-crypto constructions are finalized. + */ +class ConfidentialMPTHolderKeyUpdate : public Transactor +{ +public: + static constexpr auto kConsequencesFactory = ConsequencesFactoryType::Normal; + + explicit ConfidentialMPTHolderKeyUpdate(ApplyContext& ctx) : Transactor(ctx) + { + } + + static bool + checkExtraFeatures(PreflightContext const& ctx); + + static std::uint32_t + getFlagsMask(PreflightContext const& ctx); + + static NotTEC + preflight(PreflightContext const& ctx); + + static XRPAmount + calculateBaseFee(ReadView const& view, STTx const& tx); + + static TER + preclaim(PreclaimContext const& ctx); + + TER + doApply() override; + + void + visitInvariantEntry(bool isDelete, SLE::ConstRef before, SLE::ConstRef after) override; + + [[nodiscard]] bool + finalizeInvariants( + STTx const& tx, + TER result, + XRPAmount fee, + ReadView const& view, + beast::Journal const& j) override; +}; + +} // namespace xrpl diff --git a/src/libxrpl/tx/invariants/MPTInvariant.cpp b/src/libxrpl/tx/invariants/MPTInvariant.cpp index 4f93f2d0a1..35507fe408 100644 --- a/src/libxrpl/tx/invariants/MPTInvariant.cpp +++ b/src/libxrpl/tx/invariants/MPTInvariant.cpp @@ -41,6 +41,7 @@ constexpr auto kConfidentialMptTxTypes = std::to_array({ ttCONFIDENTIAL_MPT_MERGE_INBOX, ttCONFIDENTIAL_MPT_CLAWBACK, ttCONFIDENTIAL_MPT_MIRROR_UPDATE, + ttCONFIDENTIAL_MPT_HOLDER_KEY_UPDATE, }); // Clamp to the cap (== INT64_MAX) before the signed conversion. Invariant @@ -825,10 +826,10 @@ ValidConfidentialMPToken::finalize( return false; } - // Among confidential MPT transactions, only ConfidentialMPTSend and - // ConfidentialMPTMergeInbox leave coaDelta unmodified. Therefore, if a confidential MPT - // transaction reaches here, it must be one of these two types, neither of which will - // modify sfOutstandingAmount + // Reaching here means this confidential MPT transaction left coaDelta + // unmodified (e.g. ConfidentialMPTSend, ConfidentialMPTMergeInbox, or + // ConfidentialMPTHolderKeyUpdate/ConfidentialMPTMirrorUpdate, none of which touch + // sfConfidentialOutstandingAmount), so it must not modify sfOutstandingAmount either. if (checks.outstandingDelta != 0) { JLOG(j.fatal()) << "Invariant failed: OutstandingAmount changed " diff --git a/src/libxrpl/tx/transactors/token/ConfidentialMPTHolderKeyUpdate.cpp b/src/libxrpl/tx/transactors/token/ConfidentialMPTHolderKeyUpdate.cpp new file mode 100644 index 0000000000..211e786cc2 --- /dev/null +++ b/src/libxrpl/tx/transactors/token/ConfidentialMPTHolderKeyUpdate.cpp @@ -0,0 +1,220 @@ +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include + +namespace xrpl { + +bool +ConfidentialMPTHolderKeyUpdate::checkExtraFeatures(PreflightContext const& ctx) +{ + // Holder key update is only meaningful when confidential transfers are enabled. + return ctx.rules.enabled(featureConfidentialTransfer); +} + +std::uint32_t +ConfidentialMPTHolderKeyUpdate::getFlagsMask(PreflightContext const& ctx) +{ + return tfConfidentialMPTHolderKeyUpdateMask; +} + +NotTEC +ConfidentialMPTHolderKeyUpdate::preflight(PreflightContext const& ctx) +{ + bool const rotation = ctx.tx.isFlag(tfHolderKeyRotation); + bool const recovery = ctx.tx.isFlag(tfHolderKeyRecovery); + bool const cancel = ctx.tx.isFlag(tfCancelRecovery); + + // Exactly one of the three mode flags must be set. + static constexpr auto modeFlags = tfHolderKeyRotation | tfHolderKeyRecovery | tfCancelRecovery; + if (std::popcount(ctx.tx.getFlags() & modeFlags) != 1) + return temINVALID_FLAG; + + // The issuer cannot hold confidential balances. + if (ctx.tx[sfAccount] == MPTIssue(ctx.tx[sfMPTokenIssuanceID]).getIssuer()) + return temMALFORMED; + + bool const hasHolderKey = ctx.tx.isFieldPresent(sfHolderEncryptionKey); + bool const hasSpending = ctx.tx.isFieldPresent(sfConfidentialBalanceSpending); + bool const hasInbox = ctx.tx.isFieldPresent(sfConfidentialBalanceInbox); + bool const hasProof = ctx.tx.isFieldPresent(sfZKProof); + + if (cancel) + { + // Cancel mode only revokes a pending recovery authorization; it + // carries no key material, balances, or proof. + if (hasHolderKey || hasSpending || hasInbox || hasProof) + return temMALFORMED; + + return tesSUCCESS; + } + + // Rotation and Recovery both require a holder key and a proof. + if (!hasHolderKey || !hasProof) + return temMALFORMED; + + // Rotation mode requires re-encrypted balances; Recovery mode must not + // provide them since the holder cannot decrypt the current ones. + if (rotation && (!hasSpending || !hasInbox)) + return temMALFORMED; + + if (recovery && (hasSpending || hasInbox)) + return temMALFORMED; + + if (hasSpending && + ctx.tx[sfConfidentialBalanceSpending].length() != kEcGamalEncryptedTotalLength) + return temBAD_CIPHERTEXT; + + if (hasInbox && ctx.tx[sfConfidentialBalanceInbox].length() != kEcGamalEncryptedTotalLength) + return temBAD_CIPHERTEXT; + + if (!isValidCompressedECPoint(ctx.tx[sfHolderEncryptionKey])) + return temMALFORMED; + + if (hasSpending && !isValidCiphertext(ctx.tx[sfConfidentialBalanceSpending])) + return temBAD_CIPHERTEXT; + + if (hasInbox && !isValidCiphertext(ctx.tx[sfConfidentialBalanceInbox])) + return temBAD_CIPHERTEXT; + + return tesSUCCESS; +} + +XRPAmount +ConfidentialMPTHolderKeyUpdate::calculateBaseFee(ReadView const& view, STTx const& tx) +{ + return Transactor::calculateBaseFee(view, tx, kConfidentialFeeMultiplier); +} + +TER +ConfidentialMPTHolderKeyUpdate::preclaim(PreclaimContext const& ctx) +{ + auto const account = ctx.tx[sfAccount]; + auto const mptIssuanceID = ctx.tx[sfMPTokenIssuanceID]; + + auto const sleIssuance = ctx.view.read(keylet::mptokenIssuance(mptIssuanceID)); + if (!sleIssuance) + return tecOBJECT_NOT_FOUND; + + if (!sleIssuance->isFlag(lsfMPTCanHoldConfidentialBalance)) + return tecNO_PERMISSION; + + auto const sleMptoken = ctx.view.read(keylet::mptoken(mptIssuanceID, account)); + if (!sleMptoken) + return tecOBJECT_NOT_FOUND; + + if (!sleMptoken->isFieldPresent(sfHolderEncryptionKey) || + !sleMptoken->isFieldPresent(sfConfidentialBalanceSpending) || + !sleMptoken->isFieldPresent(sfConfidentialBalanceInbox)) + { + return tecNO_PERMISSION; + } + + if (ctx.tx.isFlag(tfCancelRecovery)) + { + // Nothing to cancel if no recovery is pending. + if (!sleMptoken->isFieldPresent(sfRecoveryKey)) + return tecNO_PERMISSION; + + return tesSUCCESS; + } + + if (ctx.tx[sfHolderEncryptionKey] == (*sleMptoken)[sfHolderEncryptionKey]) + return tecDUPLICATE; + + // Recovery mode: reject if a recovery is already pending + if (ctx.tx.isFlag(tfHolderKeyRecovery) && sleMptoken->isFieldPresent(sfRecoveryKey)) + return tecNO_PERMISSION; + + return tesSUCCESS; +} + +TER +ConfidentialMPTHolderKeyUpdate::doApply() +{ + auto const mptIssuanceID = ctx_.tx[sfMPTokenIssuanceID]; + auto sleMptoken = view().peek(keylet::mptoken(mptIssuanceID, accountID_)); + if (!sleMptoken) + { + // LCOV_EXCL_START + UNREACHABLE( + "xrpl::ConfidentialMPTHolderKeyUpdate::doApply : preclaim already validated the " + "MPToken exists"); + return tecINTERNAL; + // LCOV_EXCL_STOP + } + + if (ctx_.tx.isFlag(tfCancelRecovery)) + { + // The holder revokes their pending recovery authorization; the + // current key and balances are left untouched. + sleMptoken->makeFieldAbsent(sfRecoveryKey); + view().update(sleMptoken); + return tesSUCCESS; + } + + auto const newPubKey = ctx_.tx[sfHolderEncryptionKey]; + + if (ctx_.tx.isFlag(tfHolderKeyRotation)) + { + // Replace the key and balances in rotation mode. Rotation proves the + // holder still has the old key, so any pending recovery is cancelled. + (*sleMptoken)[sfHolderEncryptionKey] = newPubKey; + (*sleMptoken)[sfConfidentialBalanceSpending] = ctx_.tx[sfConfidentialBalanceSpending]; + (*sleMptoken)[sfConfidentialBalanceInbox] = ctx_.tx[sfConfidentialBalanceInbox]; + sleMptoken->makeFieldAbsent(sfRecoveryKey); + incrementConfidentialVersion(*sleMptoken); + } + else if (ctx_.tx.isFlag(tfHolderKeyRecovery)) + { + // Recovery mode: the holder cannot decrypt their current balances, + // so only the pending recovery key is recorded. The balances are + // rewritten separately by the issuer via ConfidentialMPTRecoverBalance. + (*sleMptoken)[sfRecoveryKey] = newPubKey; + } + else + { + // LCOV_EXCL_START + UNREACHABLE("xrpl::ConfidentialMPTHolderKeyUpdate::doApply : invalid mode"); + return tecINTERNAL; + // LCOV_EXCL_STOP + } + + view().update(sleMptoken); + return tesSUCCESS; +} + +void +ConfidentialMPTHolderKeyUpdate::visitInvariantEntry(bool, SLE::ConstRef, SLE::ConstRef) +{ +} + +bool +ConfidentialMPTHolderKeyUpdate::finalizeInvariants( + STTx const&, + TER, + XRPAmount, + ReadView const&, + beast::Journal const&) +{ + return true; +} + +} // namespace xrpl diff --git a/src/test/app/ConfidentialMPTKeyRotation_test.cpp b/src/test/app/ConfidentialMPTKeyRotation_test.cpp index 0db9e29d27..3acb91e39a 100644 --- a/src/test/app/ConfidentialMPTKeyRotation_test.cpp +++ b/src/test/app/ConfidentialMPTKeyRotation_test.cpp @@ -1,6 +1,7 @@ #include #include #include +#include #include #include @@ -20,6 +21,7 @@ #include #include #include +#include #include namespace xrpl { @@ -2454,6 +2456,703 @@ class ConfidentialMPTKeyRotation_test : public ConfidentialTransferTestBase }); } + void + testConfidentialMPTHolderKeyUpdatePreflight(FeatureBitset features) + { + testcase("ConfidentialMPTHolderKeyUpdate preflight"); + using namespace test::jtx; + + Env env{*this, features}; + Account const alice("alice"); + Account const bob("bob"); + + // Both amendments are required: ConfidentialMPTKeyRotation and ConfidentialTransfer. + if (!features[featureConfidentialMPTKeyRotation] || !features[featureConfidentialTransfer]) + { + MPTTester mptAlice(env, alice, {.holders = {bob}}); + mptAlice.create({.ownerCount = 1, .flags = tfMPTCanTransfer}); + mptAlice.holderKeyUpdate({ + .account = bob, + .holderPubKey = gMakeZeroBuffer(kEcPubKeyLength), + .flags = tfHolderKeyRecovery, + .err = temDISABLED, + }); + return; + } + + ConfidentialEnv ct{env, alice, {{.account = bob, .payAmount = 100, .convertAmount = 40}}}; + + Account const bobNewKey("bobNewKey"); + ct.mpt.generateKeyPair(bobNewKey); + + // The flag contains a value outside the recognized mode bits. This is + // rejected by the flags-mask check + for (auto const flags : {0x00080000u, 0x00080000u | tfHolderKeyRecovery, 0x00100000u}) + { + ct.mpt.holderKeyUpdate({ + .account = bob, + .holderPubKey = ct.mpt.getPubKey(bobNewKey), + .proof = gMakeZeroBuffer(1), + .flags = flags, + .err = temINVALID_FLAG, + }); + } + + // Exactly one of Rotation, Recovery, and Cancel must be set. + for (auto const flags : + {0u, + tfHolderKeyRotation | tfHolderKeyRecovery, + tfHolderKeyRotation | tfCancelRecovery, + tfHolderKeyRecovery | tfCancelRecovery, + tfHolderKeyRotation | tfHolderKeyRecovery | tfCancelRecovery}) + { + ct.mpt.holderKeyUpdate({ + .account = bob, + .holderPubKey = ct.mpt.getPubKey(bobNewKey), + .proof = gMakeZeroBuffer(1), + .flags = flags, + .err = temINVALID_FLAG, + }); + } + + // The issuer cannot rotate or recover a confidential balance it cannot hold. + Account const aliceNewKey("aliceNewKey"); + ct.mpt.generateKeyPair(aliceNewKey); + ct.mpt.holderKeyUpdate({ + .account = alice, + .holderPubKey = ct.mpt.getPubKey(aliceNewKey), + .proof = gMakeZeroBuffer(1), + .flags = tfHolderKeyRecovery, + .err = temMALFORMED, + }); + + // HolderEncryptionKey one byte short of the required length. + ct.mpt.holderKeyUpdate({ + .account = bob, + .holderPubKey = gMakeZeroBuffer(kEcPubKeyLength - 1), + .proof = gMakeZeroBuffer(1), + .flags = tfHolderKeyRecovery, + .err = temMALFORMED, + }); + + // HolderEncryptionKey the correct length, but not a well-formed + // compressed secp256k1 point. + ct.mpt.holderKeyUpdate({ + .account = bob, + .holderPubKey = gMakeZeroBuffer(kEcPubKeyLength), + .proof = gMakeZeroBuffer(1), + .flags = tfHolderKeyRecovery, + .err = temMALFORMED, + }); + + // HolderEncryptionKey is entirely absent (as opposed to present with + // the wrong length or format). + ct.mpt.holderKeyUpdate({ + .account = bob, + .proof = gMakeZeroBuffer(1), + .flags = tfHolderKeyRecovery, + .err = temMALFORMED, + }); + + std::optional const cipher = getTrivialCiphertext(); + std::optional const none; + + // Rotation mode requires both the spending and inbox ciphertexts; + for (auto const& [spending, inbox] : + {std::pair{none, none}, std::pair{cipher, none}, std::pair{none, cipher}}) + { + ct.mpt.holderKeyUpdate({ + .account = bob, + .holderPubKey = ct.mpt.getPubKey(bobNewKey), + .spendingCiphertext = spending, + .inboxCiphertext = inbox, + .proof = gMakeZeroBuffer(1), + .flags = tfHolderKeyRotation, + .err = temMALFORMED, + }); + } + + // Recovery and Cancel modes must not include either. + for (auto const& [spending, inbox] : + {std::pair{cipher, none}, std::pair{none, cipher}, std::pair{cipher, cipher}}) + { + ct.mpt.holderKeyUpdate({ + .account = bob, + .holderPubKey = ct.mpt.getPubKey(bobNewKey), + .spendingCiphertext = spending, + .inboxCiphertext = inbox, + .proof = gMakeZeroBuffer(1), + .flags = tfHolderKeyRecovery, + .err = temMALFORMED, + }); + } + + for (auto const& [spending, inbox] : + {std::pair{cipher, none}, std::pair{none, cipher}, std::pair{cipher, cipher}}) + { + ct.mpt.holderKeyUpdate({ + .account = bob, + .spendingCiphertext = spending, + .inboxCiphertext = inbox, + .flags = tfCancelRecovery, + .err = temMALFORMED, + }); + } + + // Spending ciphertext has the wrong length. + ct.mpt.holderKeyUpdate({ + .account = bob, + .holderPubKey = ct.mpt.getPubKey(bobNewKey), + .spendingCiphertext = gMakeZeroBuffer(kEcGamalEncryptedTotalLength - 1), + .inboxCiphertext = getTrivialCiphertext(), + .proof = gMakeZeroBuffer(1), + .flags = tfHolderKeyRotation, + .err = temBAD_CIPHERTEXT, + }); + + // Inbox ciphertext has the wrong length. + ct.mpt.holderKeyUpdate({ + .account = bob, + .holderPubKey = ct.mpt.getPubKey(bobNewKey), + .spendingCiphertext = getTrivialCiphertext(), + .inboxCiphertext = gMakeZeroBuffer(kEcGamalEncryptedTotalLength - 1), + .proof = gMakeZeroBuffer(1), + .flags = tfHolderKeyRotation, + .err = temBAD_CIPHERTEXT, + }); + + // Spending ciphertext has the correct length, but is not a + // well-formed EC ElGamal ciphertext. + ct.mpt.holderKeyUpdate({ + .account = bob, + .holderPubKey = ct.mpt.getPubKey(bobNewKey), + .spendingCiphertext = getBadCiphertext(), + .inboxCiphertext = getTrivialCiphertext(), + .proof = gMakeZeroBuffer(1), + .flags = tfHolderKeyRotation, + .err = temBAD_CIPHERTEXT, + }); + + // Inbox ciphertext has the correct length, but is not a well-formed + // EC ElGamal ciphertext. + ct.mpt.holderKeyUpdate({ + .account = bob, + .holderPubKey = ct.mpt.getPubKey(bobNewKey), + .spendingCiphertext = getTrivialCiphertext(), + .inboxCiphertext = getBadCiphertext(), + .proof = gMakeZeroBuffer(1), + .flags = tfHolderKeyRotation, + .err = temBAD_CIPHERTEXT, + }); + + // Rotation/Recovery mode requires a ZKProof. + ct.mpt.holderKeyUpdate({ + .account = bob, + .holderPubKey = ct.mpt.getPubKey(bobNewKey), + .flags = tfHolderKeyRecovery, + .err = temMALFORMED, + }); + + // Cancel mode must not include HolderEncryptionKey. + ct.mpt.holderKeyUpdate({ + .account = bob, + .holderPubKey = ct.mpt.getPubKey(bobNewKey), + .flags = tfCancelRecovery, + .err = temMALFORMED, + }); + + // Cancel mode must not include a ZKProof. + ct.mpt.holderKeyUpdate({ + .account = bob, + .proof = gMakeZeroBuffer(1), + .flags = tfCancelRecovery, + .err = temMALFORMED, + }); + } + + void + testConfidentialMPTHolderKeyUpdatePreclaim(FeatureBitset features) + { + testcase("ConfidentialMPTHolderKeyUpdate preclaim"); + using namespace test::jtx; + + // The issuance does not exist. + { + 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, + }); + // Destroy the issuance to test issuance not found. + mptAlice.destroy(); + + mptAlice.holderKeyUpdate({ + .account = bob, + .flags = tfCancelRecovery, + .err = tecOBJECT_NOT_FOUND, + }); + } + + // The issuance has not enabled confidential balances. The holder is + // authorized so this reaches the confidential-balance-flag check + // rather than failing earlier on a missing MPToken. + { + Env env{*this, features}; + Account const alice("alice"); + Account const bob("bob"); + MPTTester mptAlice(env, alice, {.holders = {bob}}); + mptAlice.create({.ownerCount = 1, .flags = tfMPTCanTransfer}); + mptAlice.authorize({.account = bob}); + + mptAlice.holderKeyUpdate({ + .account = bob, + .flags = tfCancelRecovery, + .err = tecNO_PERMISSION, + }); + } + + // carol exists as an account but was never authorized to hold this + // issuance, so she has no MPToken for it at all. + { + Env env{*this, features}; + Account const alice("alice"); + Account const carol("carol"); + MPTTester mptAlice(env, alice); + mptAlice.create({ + .ownerCount = 1, + .flags = tfMPTCanTransfer | tfMPTCanHoldConfidentialBalance, + }); + env.fund(XRP(1000), carol); + env.close(); + + Account const carolNewKey("carolNewKey"); + mptAlice.generateKeyPair(carolNewKey); + + mptAlice.holderKeyUpdate({ + .account = carol, + .holderPubKey = mptAlice.getPubKey(carolNewKey), + .proof = gMakeZeroBuffer(1), + .flags = tfHolderKeyRecovery, + .err = tecOBJECT_NOT_FOUND, + }); + } + + // The holder has an MPToken but no confidential state yet - never + // registered a key or converted anything. + { + 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.authorize({.account = bob}); + mptAlice.pay(alice, bob, 100); + + Account const bobNewKey("bobNewKey"); + mptAlice.generateKeyPair(bobNewKey); + + mptAlice.holderKeyUpdate({ + .account = bob, + .holderPubKey = mptAlice.getPubKey(bobNewKey), + .proof = gMakeZeroBuffer(1), + .flags = tfHolderKeyRecovery, + .err = tecNO_PERMISSION, + }); + } + + // Submitting the holder's own current key as the "new" key is a no-op. + { + Env env{*this, features}; + Account const alice("alice"); + Account const bob("bob"); + ConfidentialEnv ct{ + env, alice, {{.account = bob, .payAmount = 100, .convertAmount = 40}}}; + + ct.mpt.holderKeyUpdate({ + .account = bob, + .holderPubKey = ct.mpt.getPubKey(bob), + .proof = gMakeZeroBuffer(1), + .flags = tfHolderKeyRecovery, + .err = tecDUPLICATE, + }); + } + + // A second Recovery-mode transaction must not silently overwrite an + // already-pending RecoveryKey. + { + Env env{*this, features}; + Account const alice("alice"); + Account const bob("bob"); + ConfidentialEnv ct{ + env, alice, {{.account = bob, .payAmount = 100, .convertAmount = 40}}}; + + Account const bobRecoveryKey("bobRecoveryKey"); + ct.mpt.generateKeyPair(bobRecoveryKey); + ct.mpt.holderKeyUpdate({ + .account = bob, + .holderPubKey = ct.mpt.getPubKey(bobRecoveryKey), + .proof = gMakeZeroBuffer(1), + .flags = tfHolderKeyRecovery, + }); + + Account const bobRecoveryKey2("bobRecoveryKey2"); + ct.mpt.generateKeyPair(bobRecoveryKey2); + ct.mpt.holderKeyUpdate({ + .account = bob, + .holderPubKey = ct.mpt.getPubKey(bobRecoveryKey2), + .proof = gMakeZeroBuffer(1), + .flags = tfHolderKeyRecovery, + .err = tecNO_PERMISSION, + }); + } + + // bob never submitted a Recovery-mode transaction, so there is no + // sfRecoveryKey to cancel. + { + Env env{*this, features}; + Account const alice("alice"); + Account const bob("bob"); + ConfidentialEnv ct{ + env, alice, {{.account = bob, .payAmount = 100, .convertAmount = 40}}}; + + ct.mpt.holderKeyUpdate({ + .account = bob, + .flags = tfCancelRecovery, + .err = tecNO_PERMISSION, + }); + } + + // Runs rotation, recovery, and cancel in sequence and expects each to + // succeed. + auto const allModesSucceed = [&](ConfidentialEnv& ct, Account const& bob) { + Account const bobNewKey("bobNewKey"); + ct.mpt.generateKeyPair(bobNewKey); + + auto const reEnc = reencryptHolderBalances(ct.mpt, bob, bob, bobNewKey); + BEAST_EXPECT(reEnc.has_value()); + if (!reEnc) + return; + + ct.mpt.holderKeyUpdate({ + .account = bob, + .holderPubKey = ct.mpt.getPubKey(bobNewKey), + .spendingCiphertext = reEnc->first, + .inboxCiphertext = reEnc->second, + .proof = gMakeZeroBuffer(1), + .flags = tfHolderKeyRotation, + }); + + Account const bobRecoveryKey("bobRecoveryKey"); + ct.mpt.generateKeyPair(bobRecoveryKey); + ct.mpt.holderKeyUpdate({ + .account = bob, + .holderPubKey = ct.mpt.getPubKey(bobRecoveryKey), + .proof = gMakeZeroBuffer(1), + .flags = tfHolderKeyRecovery, + }); + + ct.mpt.holderKeyUpdate({ + .account = bob, + .flags = tfCancelRecovery, + }); + }; + + // Individual (per-holder) lock. + { + Env env{*this, features}; + Account const alice("alice"); + Account const bob("bob"); + ConfidentialEnv ct{ + env, alice, {{.account = bob, .payAmount = 100, .convertAmount = 40}}}; + ct.mpt.set({.account = alice, .holder = bob, .flags = tfMPTLock}); + + allModesSucceed(ct, bob); + } + + // Global (issuance-wide) lock. + { + Env env{*this, features}; + Account const alice("alice"); + Account const bob("bob"); + ConfidentialEnv ct{ + env, alice, {{.account = bob, .payAmount = 100, .convertAmount = 40}}}; + ct.mpt.set({.account = alice, .flags = tfMPTLock}); + + allModesSucceed(ct, bob); + } + + // A stale mirror epoch does not block rotation, recovery, or cancel: + { + Env env{*this, features}; + Account const alice("alice"); + Account const bob("bob"); + ConfidentialEnv ct{ + env, alice, {{.account = bob, .payAmount = 100, .convertAmount = 40}}}; + + // Rotate the issuer's key without refreshing bob's mirror, + // leaving bob's mirror epoch stale relative to the issuance. + Account const aliceNewIssuerKey("aliceNewIssuerKey"); + ct.mpt.generateKeyPair(aliceNewIssuerKey); + ct.mpt.set({.account = alice, .issuerPubKey = ct.mpt.getPubKey(aliceNewIssuerKey)}); + BEAST_EXPECT(ct.mpt.checkKeyEpochs(1u, std::nullopt)); + BEAST_EXPECT(ct.mpt.checkMirrorEpochs(bob, std::nullopt, std::nullopt)); + + allModesSucceed(ct, bob); + } + } + + void + testConfidentialMPTHolderKeyUpdateDoApply(FeatureBitset features) + { + testcase("ConfidentialMPTHolderKeyUpdate doApply"); + using namespace test::jtx; + + // Rotation mode updates the key and re-encrypted balances, bumps the + // version, and clears any pending recovery. + { + Env env{*this, features}; + Account const alice("alice"); + Account const bob("bob"); + ConfidentialEnv ct{ + env, alice, {{.account = bob, .payAmount = 100, .convertAmount = 40}}}; + + auto const prevVersion = ct.mpt.getMPTokenVersion(bob); + + Account const bobNewKey("bobNewKey"); + ct.mpt.generateKeyPair(bobNewKey); + + auto const reEnc = reencryptHolderBalances(ct.mpt, bob, bob, bobNewKey); + BEAST_EXPECT(reEnc.has_value()); + if (!reEnc) + return; + + ct.mpt.holderKeyUpdate({ + .account = bob, + .holderPubKey = ct.mpt.getPubKey(bobNewKey), + .spendingCiphertext = reEnc->first, + .inboxCiphertext = reEnc->second, + .proof = gMakeZeroBuffer(1), + .flags = tfHolderKeyRotation, + }); + + auto const sleMptoken = env.le(keylet::mptoken(ct.mpt.issuanceID(), bob.id())); + if (!BEAST_EXPECT(sleMptoken)) + return; + + auto const newPubKey = ct.mpt.getPubKey(bobNewKey); + BEAST_EXPECT( + newPubKey && strHex((*sleMptoken)[sfHolderEncryptionKey]) == strHex(*newPubKey)); + BEAST_EXPECT(!sleMptoken->isFieldPresent(sfRecoveryKey)); + BEAST_EXPECT(ct.mpt.getMPTokenVersion(bob) == prevVersion + 1); + + // The rotated balances must still decrypt to the same amounts, + // but now only under the NEW private key. + auto const spendingCt = + ct.mpt.getEncryptedBalance(bob, MPTTester::holderEncryptedSpending); + auto const inboxCt = ct.mpt.getEncryptedBalance(bob, MPTTester::holderEncryptedInbox); + BEAST_EXPECT(spendingCt.has_value()); + BEAST_EXPECT(inboxCt.has_value()); + if (!spendingCt || !inboxCt) + return; + + auto const spendingAmt = ct.mpt.decryptAmount(bobNewKey, *spendingCt); + auto const inboxAmt = ct.mpt.decryptAmount(bobNewKey, *inboxCt); + BEAST_EXPECT(spendingAmt && *spendingAmt == 40); + BEAST_EXPECT(inboxAmt && *inboxAmt == 0); + } + + // Rotation mode re-encrypts both balances correctly when spending and + // inbox are both non-zero and differ from each other. + { + Env env{*this, features}; + Account const alice("alice"); + Account const bob("bob"); + Account const carol("carol"); + ConfidentialEnv ct{ + env, + alice, + {{.account = bob, .payAmount = 100, .convertAmount = 40}, + {.account = carol, .payAmount = 100, .convertAmount = 50}}}; + + // carol sends 15 into bob's inbox, which is not merged into + // spending, leaving bob with spending=40, inbox=15. + ct.mpt.send({.account = carol, .dest = bob, .amt = 15}); + + Account const bobNewKey("bobNewKey"); + ct.mpt.generateKeyPair(bobNewKey); + + auto const reEnc = reencryptHolderBalances(ct.mpt, bob, bob, bobNewKey); + BEAST_EXPECT(reEnc.has_value()); + if (!reEnc) + return; + + ct.mpt.holderKeyUpdate({ + .account = bob, + .holderPubKey = ct.mpt.getPubKey(bobNewKey), + .spendingCiphertext = reEnc->first, + .inboxCiphertext = reEnc->second, + .proof = gMakeZeroBuffer(1), + .flags = tfHolderKeyRotation, + }); + + auto const spendingCt = + ct.mpt.getEncryptedBalance(bob, MPTTester::holderEncryptedSpending); + auto const inboxCt = ct.mpt.getEncryptedBalance(bob, MPTTester::holderEncryptedInbox); + BEAST_EXPECT(spendingCt.has_value()); + BEAST_EXPECT(inboxCt.has_value()); + if (!spendingCt || !inboxCt) + return; + + auto const spendingAmt = ct.mpt.decryptAmount(bobNewKey, *spendingCt); + auto const inboxAmt = ct.mpt.decryptAmount(bobNewKey, *inboxCt); + BEAST_EXPECT(spendingAmt && *spendingAmt == 40); + BEAST_EXPECT(inboxAmt && *inboxAmt == 15); + } + + // Recovery mode only records the pending recovery key; the current + // key, balances, and version are untouched. + { + Env env{*this, features}; + Account const alice("alice"); + Account const bob("bob"); + ConfidentialEnv ct{ + env, alice, {{.account = bob, .payAmount = 100, .convertAmount = 40}}}; + + auto const prevVersion = ct.mpt.getMPTokenVersion(bob); + auto const prevSpending = + ct.mpt.getDecryptedBalance(bob, MPTTester::holderEncryptedSpending); + auto const prevInbox = ct.mpt.getDecryptedBalance(bob, MPTTester::holderEncryptedInbox); + auto const prevKey = ct.mpt.getPubKey(bob); + + Account const bobRecoveryKey("bobRecoveryKey"); + ct.mpt.generateKeyPair(bobRecoveryKey); + + ct.mpt.holderKeyUpdate({ + .account = bob, + .holderPubKey = ct.mpt.getPubKey(bobRecoveryKey), + .proof = gMakeZeroBuffer(1), + .flags = tfHolderKeyRecovery, + }); + + auto const sleMptoken = env.le(keylet::mptoken(ct.mpt.issuanceID(), bob.id())); + if (!BEAST_EXPECT(sleMptoken)) + return; + + auto const recoveryKey = ct.mpt.getPubKey(bobRecoveryKey); + BEAST_EXPECT( + sleMptoken->isFieldPresent(sfRecoveryKey) && recoveryKey && + strHex((*sleMptoken)[sfRecoveryKey]) == strHex(*recoveryKey)); + + BEAST_EXPECT( + prevKey && strHex((*sleMptoken)[sfHolderEncryptionKey]) == strHex(*prevKey)); + BEAST_EXPECT(ct.mpt.getMPTokenVersion(bob) == prevVersion); + BEAST_EXPECT( + ct.mpt.getDecryptedBalance(bob, MPTTester::holderEncryptedSpending) == + prevSpending); + BEAST_EXPECT( + ct.mpt.getDecryptedBalance(bob, MPTTester::holderEncryptedInbox) == prevInbox); + } + + // Cancel mode clears the pending recovery key only; the current key, + // balances, and version are untouched. + { + Env env{*this, features}; + Account const alice("alice"); + Account const bob("bob"); + ConfidentialEnv ct{ + env, alice, {{.account = bob, .payAmount = 100, .convertAmount = 40}}}; + + Account const bobRecoveryKey("bobRecoveryKey"); + ct.mpt.generateKeyPair(bobRecoveryKey); + + ct.mpt.holderKeyUpdate({ + .account = bob, + .holderPubKey = ct.mpt.getPubKey(bobRecoveryKey), + .proof = gMakeZeroBuffer(1), + .flags = tfHolderKeyRecovery, + }); + + auto const prevVersion = ct.mpt.getMPTokenVersion(bob); + auto const prevSpending = + ct.mpt.getDecryptedBalance(bob, MPTTester::holderEncryptedSpending); + auto const prevInbox = ct.mpt.getDecryptedBalance(bob, MPTTester::holderEncryptedInbox); + auto const prevKey = ct.mpt.getPubKey(bob); + + ct.mpt.holderKeyUpdate({ + .account = bob, + .flags = tfCancelRecovery, + }); + + auto const sleMptoken = env.le(keylet::mptoken(ct.mpt.issuanceID(), bob.id())); + if (!BEAST_EXPECT(sleMptoken)) + return; + + BEAST_EXPECT(!sleMptoken->isFieldPresent(sfRecoveryKey)); + BEAST_EXPECT( + prevKey && strHex((*sleMptoken)[sfHolderEncryptionKey]) == strHex(*prevKey)); + BEAST_EXPECT(ct.mpt.getMPTokenVersion(bob) == prevVersion); + BEAST_EXPECT( + ct.mpt.getDecryptedBalance(bob, MPTTester::holderEncryptedSpending) == + prevSpending); + BEAST_EXPECT( + ct.mpt.getDecryptedBalance(bob, MPTTester::holderEncryptedInbox) == prevInbox); + } + + // Rotation mode clears a pending recovery key. + { + Env env{*this, features}; + Account const alice("alice"); + Account const bob("bob"); + ConfidentialEnv ct{ + env, alice, {{.account = bob, .payAmount = 100, .convertAmount = 40}}}; + + Account const bobRecoveryKey("bobRecoveryKey"); + ct.mpt.generateKeyPair(bobRecoveryKey); + ct.mpt.holderKeyUpdate({ + .account = bob, + .holderPubKey = ct.mpt.getPubKey(bobRecoveryKey), + .proof = gMakeZeroBuffer(1), + .flags = tfHolderKeyRecovery, + }); + + { + auto const sleMptoken = env.le(keylet::mptoken(ct.mpt.issuanceID(), bob.id())); + if (!BEAST_EXPECT(sleMptoken)) + return; + BEAST_EXPECT(sleMptoken->isFieldPresent(sfRecoveryKey)); + } + + Account const bobNewKey("bobNewKey"); + ct.mpt.generateKeyPair(bobNewKey); + + auto const reEnc = reencryptHolderBalances(ct.mpt, bob, bob, bobNewKey); + BEAST_EXPECT(reEnc.has_value()); + if (!reEnc) + return; + + ct.mpt.holderKeyUpdate({ + .account = bob, + .holderPubKey = ct.mpt.getPubKey(bobNewKey), + .spendingCiphertext = reEnc->first, + .inboxCiphertext = reEnc->second, + .proof = gMakeZeroBuffer(1), + .flags = tfHolderKeyRotation, + }); + + auto const sleMptoken = env.le(keylet::mptoken(ct.mpt.issuanceID(), bob.id())); + if (!BEAST_EXPECT(sleMptoken)) + return; + BEAST_EXPECT(!sleMptoken->isFieldPresent(sfRecoveryKey)); + } + } + public: void testMPTokenIssuanceSetWithFeats(FeatureBitset features) @@ -2490,6 +3189,12 @@ public: testConfidentialMPTMirrorUpdateDoApply(all); testConfidentialMPTMirrorUpdateMultipleRotationsIssuerMode(all); testConfidentialMPTMirrorUpdateMultipleRotationsHolderMode(all); + + testConfidentialMPTHolderKeyUpdatePreflight(all); + testConfidentialMPTHolderKeyUpdatePreflight(all - featureConfidentialMPTKeyRotation); + testConfidentialMPTHolderKeyUpdatePreflight(all - featureConfidentialTransfer); + testConfidentialMPTHolderKeyUpdatePreclaim(all); + testConfidentialMPTHolderKeyUpdateDoApply(all); } }; diff --git a/src/test/jtx/ConfidentialTransfer.h b/src/test/jtx/ConfidentialTransfer.h index fcda397c0d..2ed11cb178 100644 --- a/src/test/jtx/ConfidentialTransfer.h +++ b/src/test/jtx/ConfidentialTransfer.h @@ -20,6 +20,7 @@ #include #include #include +#include #include namespace xrpl { @@ -188,6 +189,17 @@ protected: // Proof layout: [compact_sigma | bulletproof] static constexpr size_t kBulletproofOffset = kEcSendProofLength - kEcDoubleBulletproofLength; + // Decrypts holder's current spending/inbox balances under currentKey and + // re-encrypts them under newKey, returning {spendingCiphertext, + // inboxCiphertext}. Returns std::nullopt if either balance is missing or + // fails to decrypt. + static std::optional> + reencryptHolderBalances( + test::jtx::MPTTester& mpt, + test::jtx::Account const& holder, + test::jtx::Account const& currentKey, + test::jtx::Account const& newKey); + // Generate a forged aggregated bulletproof (double bulletproof) for // the given values and blinding factors. Used to test that splicing // a bulletproof claiming a different remaining balance is rejected. diff --git a/src/test/jtx/impl/ConfidentialTransfer.cpp b/src/test/jtx/impl/ConfidentialTransfer.cpp index d25e538eae..a684b1097f 100644 --- a/src/test/jtx/impl/ConfidentialTransfer.cpp +++ b/src/test/jtx/impl/ConfidentialTransfer.cpp @@ -27,6 +27,7 @@ #include #include #include +#include #include namespace xrpl { @@ -292,6 +293,30 @@ ConfidentialTransferTestBase::ConfidentialSendSetup::sendArgs( }; } +std::optional> +ConfidentialTransferTestBase::reencryptHolderBalances( + test::jtx::MPTTester& mpt, + test::jtx::Account const& holder, + test::jtx::Account const& currentKey, + test::jtx::Account const& newKey) +{ + auto const spendingCt = + mpt.getEncryptedBalance(holder, test::jtx::MPTTester::holderEncryptedSpending); + auto const inboxCt = + mpt.getEncryptedBalance(holder, test::jtx::MPTTester::holderEncryptedInbox); + if (!spendingCt || !inboxCt) + return std::nullopt; + + auto const spendingAmt = mpt.decryptAmount(currentKey, *spendingCt); + auto const inboxAmt = mpt.decryptAmount(currentKey, *inboxCt); + if (!spendingAmt || !inboxAmt) + return std::nullopt; + + return std::pair{ + mpt.encryptAmount(newKey, *spendingAmt, generateBlindingFactor()), + mpt.encryptAmount(newKey, *inboxAmt, generateBlindingFactor())}; +} + Buffer const& ConfidentialTransferTestBase::getBadCiphertext() { diff --git a/src/test/jtx/impl/mpt.cpp b/src/test/jtx/impl/mpt.cpp index 26a3f01246..d751cd10a3 100644 --- a/src/test/jtx/impl/mpt.cpp +++ b/src/test/jtx/impl/mpt.cpp @@ -2291,4 +2291,25 @@ MPTTester::mirrorUpdate(MPTMirrorUpdate const& arg, std::source_location const& submit(arg, {jv, loc}); } +void +MPTTester::holderKeyUpdate(MPTHolderKeyUpdate const& arg, std::source_location const& loc) +{ + json::Value jv; + jv[jss::TransactionType] = jss::ConfidentialMPTHolderKeyUpdate; + + setAccountField(jv, arg.account); + setIssuanceIdField(jv, arg.id); + + if (arg.holderPubKey) + jv[sfHolderEncryptionKey.jsonName] = strHex(*arg.holderPubKey); + if (arg.spendingCiphertext) + jv[sfConfidentialBalanceSpending.jsonName] = strHex(*arg.spendingCiphertext); + if (arg.inboxCiphertext) + jv[sfConfidentialBalanceInbox.jsonName] = strHex(*arg.inboxCiphertext); + if (arg.proof) + jv[sfZKProof.jsonName] = strHex(*arg.proof); + + submit(arg, {jv, loc}); +} + } // namespace xrpl::test::jtx diff --git a/src/test/jtx/impl/utility.cpp b/src/test/jtx/impl/utility.cpp index 6b2c9b69b9..431a82d09a 100644 --- a/src/test/jtx/impl/utility.cpp +++ b/src/test/jtx/impl/utility.cpp @@ -78,7 +78,8 @@ fillFee(json::Value& jv, ReadView const& view) auto const txType = jv[jss::TransactionType].asString(); if (txType == jss::ConfidentialMPTConvert || txType == jss::ConfidentialMPTConvertBack || txType == jss::ConfidentialMPTSend || txType == jss::ConfidentialMPTMergeInbox || - txType == jss::ConfidentialMPTClawback || txType == jss::ConfidentialMPTMirrorUpdate) + txType == jss::ConfidentialMPTClawback || txType == jss::ConfidentialMPTMirrorUpdate || + txType == jss::ConfidentialMPTHolderKeyUpdate) { jv[jss::Fee] = to_string(base * (kConfidentialFeeMultiplier + 1)); } diff --git a/src/test/jtx/mpt.h b/src/test/jtx/mpt.h index 17a52f58e3..6f5f4094c3 100644 --- a/src/test/jtx/mpt.h +++ b/src/test/jtx/mpt.h @@ -408,6 +408,24 @@ struct MPTMirrorUpdate std::optional err = std::nullopt; }; +/** + * @brief Arguments for building a ConfidentialMPTHolderKeyUpdate test transaction. + */ +struct MPTHolderKeyUpdate +{ + std::optional account = std::nullopt; + std::optional id = std::nullopt; + std::optional holderPubKey = std::nullopt; + std::optional spendingCiphertext = std::nullopt; + std::optional inboxCiphertext = std::nullopt; + std::optional proof = std::nullopt; + std::optional ownerCount = std::nullopt; + std::optional holderCount = std::nullopt; + std::optional flags = std::nullopt; + std::optional fee = std::nullopt; + std::optional err = std::nullopt; +}; + /** * @brief Stores the parameters that are exclusively used to generate a * Pedersen linkage proof. @@ -659,6 +677,11 @@ public: MPTMirrorUpdate const& arg = MPTMirrorUpdate{}, std::source_location const& loc = std::source_location::current()); + void + holderKeyUpdate( + MPTHolderKeyUpdate const& arg = MPTHolderKeyUpdate{}, + std::source_location const& loc = std::source_location::current()); + [[nodiscard]] bool checkDomainID(std::optional expected) const; diff --git a/src/tests/libxrpl/protocol_autogen/ledger_entries/MPTokenTests.cpp b/src/tests/libxrpl/protocol_autogen/ledger_entries/MPTokenTests.cpp index 338d9f9c7e..6b239f471f 100644 --- a/src/tests/libxrpl/protocol_autogen/ledger_entries/MPTokenTests.cpp +++ b/src/tests/libxrpl/protocol_autogen/ledger_entries/MPTokenTests.cpp @@ -35,6 +35,7 @@ TEST(MPTokenTests, BuilderSettersRoundTrip) auto const issuerKeyMirrorEpochValue = canonical_UINT32(); auto const auditorKeyMirrorEpochValue = canonical_UINT32(); auto const holderEncryptionKeyValue = canonical_VL(); + auto const recoveryKeyValue = canonical_VL(); MPTokenBuilder builder{ accountValue, @@ -54,6 +55,7 @@ TEST(MPTokenTests, BuilderSettersRoundTrip) builder.setIssuerKeyMirrorEpoch(issuerKeyMirrorEpochValue); builder.setAuditorKeyMirrorEpoch(auditorKeyMirrorEpochValue); builder.setHolderEncryptionKey(holderEncryptionKeyValue); + builder.setRecoveryKey(recoveryKeyValue); builder.setLedgerIndex(index); builder.setFlags(0x1u); @@ -174,6 +176,14 @@ TEST(MPTokenTests, BuilderSettersRoundTrip) EXPECT_TRUE(entry.hasHolderEncryptionKey()); } + { + auto const& expected = recoveryKeyValue; + auto const actualOpt = entry.getRecoveryKey(); + ASSERT_TRUE(actualOpt.has_value()); + expectEqualField(expected, *actualOpt, "sfRecoveryKey"); + EXPECT_TRUE(entry.hasRecoveryKey()); + } + EXPECT_TRUE(entry.hasLedgerIndex()); auto const ledgerIndex = entry.getLedgerIndex(); ASSERT_TRUE(ledgerIndex.has_value()); @@ -202,6 +212,7 @@ TEST(MPTokenTests, BuilderFromSleRoundTrip) auto const issuerKeyMirrorEpochValue = canonical_UINT32(); auto const auditorKeyMirrorEpochValue = canonical_UINT32(); auto const holderEncryptionKeyValue = canonical_VL(); + auto const recoveryKeyValue = canonical_VL(); auto sle = std::make_shared(MPToken::entryType, index); @@ -220,6 +231,7 @@ TEST(MPTokenTests, BuilderFromSleRoundTrip) sle->at(sfIssuerKeyMirrorEpoch) = issuerKeyMirrorEpochValue; sle->at(sfAuditorKeyMirrorEpoch) = auditorKeyMirrorEpochValue; sle->at(sfHolderEncryptionKey) = holderEncryptionKeyValue; + sle->at(sfRecoveryKey) = recoveryKeyValue; MPTokenBuilder builderFromSle{sle}; EXPECT_TRUE(builderFromSle.validate()); @@ -410,6 +422,19 @@ TEST(MPTokenTests, BuilderFromSleRoundTrip) expectEqualField(expected, *fromBuilderOpt, "sfHolderEncryptionKey"); } + { + auto const& expected = recoveryKeyValue; + + auto const fromSleOpt = entryFromSle.getRecoveryKey(); + auto const fromBuilderOpt = entryFromBuilder.getRecoveryKey(); + + ASSERT_TRUE(fromSleOpt.has_value()); + ASSERT_TRUE(fromBuilderOpt.has_value()); + + expectEqualField(expected, *fromSleOpt, "sfRecoveryKey"); + expectEqualField(expected, *fromBuilderOpt, "sfRecoveryKey"); + } + EXPECT_EQ(entryFromSle.getKey(), index); EXPECT_EQ(entryFromBuilder.getKey(), index); } @@ -492,5 +517,7 @@ TEST(MPTokenTests, OptionalFieldsReturnNullopt) EXPECT_FALSE(entry.getAuditorKeyMirrorEpoch().has_value()); EXPECT_FALSE(entry.hasHolderEncryptionKey()); EXPECT_FALSE(entry.getHolderEncryptionKey().has_value()); + EXPECT_FALSE(entry.hasRecoveryKey()); + EXPECT_FALSE(entry.getRecoveryKey().has_value()); } } diff --git a/src/tests/libxrpl/protocol_autogen/transactions/ConfidentialMPTHolderKeyUpdateTests.cpp b/src/tests/libxrpl/protocol_autogen/transactions/ConfidentialMPTHolderKeyUpdateTests.cpp new file mode 100644 index 0000000000..d22cccde6a --- /dev/null +++ b/src/tests/libxrpl/protocol_autogen/transactions/ConfidentialMPTHolderKeyUpdateTests.cpp @@ -0,0 +1,258 @@ +// Auto-generated unit tests for transaction ConfidentialMPTHolderKeyUpdate + + +#include + +#include + +#include +#include +#include +#include +#include + +#include + +namespace xrpl::transactions { + +// 1 & 4) Set fields via builder setters, build, then read them back via +// wrapper getters. After build(), validate() should succeed. +TEST(TransactionsConfidentialMPTHolderKeyUpdateTests, BuilderSettersRoundTrip) +{ + // Generate a deterministic keypair for signing + auto const [publicKey, secretKey] = + generateKeyPair(KeyType::Secp256k1, generateSeed("testConfidentialMPTHolderKeyUpdate")); + + // Common transaction fields + auto const accountValue = calcAccountID(publicKey); + std::uint32_t const sequenceValue = 1; + auto const feeValue = canonical_AMOUNT(); + + // Transaction-specific field values + auto const mPTokenIssuanceIDValue = canonical_UINT192(); + auto const holderEncryptionKeyValue = canonical_VL(); + auto const confidentialBalanceSpendingValue = canonical_VL(); + auto const confidentialBalanceInboxValue = canonical_VL(); + auto const zKProofValue = canonical_VL(); + + ConfidentialMPTHolderKeyUpdateBuilder builder{ + accountValue, + mPTokenIssuanceIDValue, + sequenceValue, + feeValue + }; + + // Set optional fields + builder.setHolderEncryptionKey(holderEncryptionKeyValue); + builder.setConfidentialBalanceSpending(confidentialBalanceSpendingValue); + builder.setConfidentialBalanceInbox(confidentialBalanceInboxValue); + builder.setZKProof(zKProofValue); + + auto tx = builder.build(publicKey, secretKey); + + std::string reason; + EXPECT_TRUE(tx.validate(reason)) << reason; + + // Verify signing was applied + EXPECT_FALSE(tx.getSigningPubKey().empty()); + EXPECT_TRUE(tx.hasTxnSignature()); + + // Verify common fields + EXPECT_EQ(tx.getAccount(), accountValue); + EXPECT_EQ(tx.getSequence(), sequenceValue); + EXPECT_EQ(tx.getFee(), feeValue); + + // Verify required fields + { + auto const& expected = mPTokenIssuanceIDValue; + auto const actual = tx.getMPTokenIssuanceID(); + expectEqualField(expected, actual, "sfMPTokenIssuanceID"); + } + + // Verify optional fields + { + auto const& expected = holderEncryptionKeyValue; + auto const actualOpt = tx.getHolderEncryptionKey(); + ASSERT_TRUE(actualOpt.has_value()) << "Optional field sfHolderEncryptionKey should be present"; + expectEqualField(expected, *actualOpt, "sfHolderEncryptionKey"); + EXPECT_TRUE(tx.hasHolderEncryptionKey()); + } + + { + auto const& expected = confidentialBalanceSpendingValue; + auto const actualOpt = tx.getConfidentialBalanceSpending(); + ASSERT_TRUE(actualOpt.has_value()) << "Optional field sfConfidentialBalanceSpending should be present"; + expectEqualField(expected, *actualOpt, "sfConfidentialBalanceSpending"); + EXPECT_TRUE(tx.hasConfidentialBalanceSpending()); + } + + { + auto const& expected = confidentialBalanceInboxValue; + auto const actualOpt = tx.getConfidentialBalanceInbox(); + ASSERT_TRUE(actualOpt.has_value()) << "Optional field sfConfidentialBalanceInbox should be present"; + expectEqualField(expected, *actualOpt, "sfConfidentialBalanceInbox"); + EXPECT_TRUE(tx.hasConfidentialBalanceInbox()); + } + + { + auto const& expected = zKProofValue; + auto const actualOpt = tx.getZKProof(); + ASSERT_TRUE(actualOpt.has_value()) << "Optional field sfZKProof should be present"; + expectEqualField(expected, *actualOpt, "sfZKProof"); + EXPECT_TRUE(tx.hasZKProof()); + } + +} + +// 2 & 4) Start from an STTx, construct a builder from it, build a new wrapper, +// and verify all fields match. +TEST(TransactionsConfidentialMPTHolderKeyUpdateTests, BuilderFromStTxRoundTrip) +{ + // Generate a deterministic keypair for signing + auto const [publicKey, secretKey] = + generateKeyPair(KeyType::Secp256k1, generateSeed("testConfidentialMPTHolderKeyUpdateFromTx")); + + // Common transaction fields + auto const accountValue = calcAccountID(publicKey); + std::uint32_t const sequenceValue = 2; + auto const feeValue = canonical_AMOUNT(); + + // Transaction-specific field values + auto const mPTokenIssuanceIDValue = canonical_UINT192(); + auto const holderEncryptionKeyValue = canonical_VL(); + auto const confidentialBalanceSpendingValue = canonical_VL(); + auto const confidentialBalanceInboxValue = canonical_VL(); + auto const zKProofValue = canonical_VL(); + + // Build an initial transaction + ConfidentialMPTHolderKeyUpdateBuilder initialBuilder{ + accountValue, + mPTokenIssuanceIDValue, + sequenceValue, + feeValue + }; + + initialBuilder.setHolderEncryptionKey(holderEncryptionKeyValue); + initialBuilder.setConfidentialBalanceSpending(confidentialBalanceSpendingValue); + initialBuilder.setConfidentialBalanceInbox(confidentialBalanceInboxValue); + initialBuilder.setZKProof(zKProofValue); + + auto initialTx = initialBuilder.build(publicKey, secretKey); + + // Create builder from existing STTx + ConfidentialMPTHolderKeyUpdateBuilder builderFromTx{initialTx.getSTTx()}; + + auto rebuiltTx = builderFromTx.build(publicKey, secretKey); + + std::string reason; + EXPECT_TRUE(rebuiltTx.validate(reason)) << reason; + + // Verify common fields + EXPECT_EQ(rebuiltTx.getAccount(), accountValue); + EXPECT_EQ(rebuiltTx.getSequence(), sequenceValue); + EXPECT_EQ(rebuiltTx.getFee(), feeValue); + + // Verify required fields + { + auto const& expected = mPTokenIssuanceIDValue; + auto const actual = rebuiltTx.getMPTokenIssuanceID(); + expectEqualField(expected, actual, "sfMPTokenIssuanceID"); + } + + // Verify optional fields + { + auto const& expected = holderEncryptionKeyValue; + auto const actualOpt = rebuiltTx.getHolderEncryptionKey(); + ASSERT_TRUE(actualOpt.has_value()) << "Optional field sfHolderEncryptionKey should be present"; + expectEqualField(expected, *actualOpt, "sfHolderEncryptionKey"); + } + + { + auto const& expected = confidentialBalanceSpendingValue; + auto const actualOpt = rebuiltTx.getConfidentialBalanceSpending(); + ASSERT_TRUE(actualOpt.has_value()) << "Optional field sfConfidentialBalanceSpending should be present"; + expectEqualField(expected, *actualOpt, "sfConfidentialBalanceSpending"); + } + + { + auto const& expected = confidentialBalanceInboxValue; + auto const actualOpt = rebuiltTx.getConfidentialBalanceInbox(); + ASSERT_TRUE(actualOpt.has_value()) << "Optional field sfConfidentialBalanceInbox should be present"; + expectEqualField(expected, *actualOpt, "sfConfidentialBalanceInbox"); + } + + { + auto const& expected = zKProofValue; + auto const actualOpt = rebuiltTx.getZKProof(); + ASSERT_TRUE(actualOpt.has_value()) << "Optional field sfZKProof should be present"; + expectEqualField(expected, *actualOpt, "sfZKProof"); + } + +} + +// 3) Verify wrapper throws when constructed from wrong transaction type. +TEST(TransactionsConfidentialMPTHolderKeyUpdateTests, WrapperThrowsOnWrongTxType) +{ + // Build a valid transaction of a different type + auto const [pk, sk] = + generateKeyPair(KeyType::Secp256k1, generateSeed("testWrongType")); + auto const account = calcAccountID(pk); + + AccountSetBuilder wrongBuilder{account, 1, canonical_AMOUNT()}; + auto wrongTx = wrongBuilder.build(pk, sk); + + EXPECT_THROW(ConfidentialMPTHolderKeyUpdate{wrongTx.getSTTx()}, std::runtime_error); +} + +// 4) Verify builder throws when constructed from wrong transaction type. +TEST(TransactionsConfidentialMPTHolderKeyUpdateTests, BuilderThrowsOnWrongTxType) +{ + // Build a valid transaction of a different type + auto const [pk, sk] = + generateKeyPair(KeyType::Secp256k1, generateSeed("testWrongTypeBuilder")); + auto const account = calcAccountID(pk); + + AccountSetBuilder wrongBuilder{account, 1, canonical_AMOUNT()}; + auto wrongTx = wrongBuilder.build(pk, sk); + + EXPECT_THROW(ConfidentialMPTHolderKeyUpdateBuilder{wrongTx.getSTTx()}, std::runtime_error); +} + +// 5) Build with only required fields and verify optional fields return nullopt. +TEST(TransactionsConfidentialMPTHolderKeyUpdateTests, OptionalFieldsReturnNullopt) +{ + // Generate a deterministic keypair for signing + auto const [publicKey, secretKey] = + generateKeyPair(KeyType::Secp256k1, generateSeed("testConfidentialMPTHolderKeyUpdateNullopt")); + + // Common transaction fields + auto const accountValue = calcAccountID(publicKey); + std::uint32_t const sequenceValue = 3; + auto const feeValue = canonical_AMOUNT(); + + // Transaction-specific required field values + auto const mPTokenIssuanceIDValue = canonical_UINT192(); + + ConfidentialMPTHolderKeyUpdateBuilder builder{ + accountValue, + mPTokenIssuanceIDValue, + sequenceValue, + feeValue + }; + + // Do NOT set optional fields + + auto tx = builder.build(publicKey, secretKey); + + // Verify optional fields are not present + EXPECT_FALSE(tx.hasHolderEncryptionKey()); + EXPECT_FALSE(tx.getHolderEncryptionKey().has_value()); + EXPECT_FALSE(tx.hasConfidentialBalanceSpending()); + EXPECT_FALSE(tx.getConfidentialBalanceSpending().has_value()); + EXPECT_FALSE(tx.hasConfidentialBalanceInbox()); + EXPECT_FALSE(tx.getConfidentialBalanceInbox().has_value()); + EXPECT_FALSE(tx.hasZKProof()); + EXPECT_FALSE(tx.getZKProof().has_value()); +} + +}