From fee81fde05d469c9ff252fa0af700100d33d00dd Mon Sep 17 00:00:00 2001 From: yinyiqian1 Date: Tue, 18 Aug 2026 11:44:59 -0400 Subject: [PATCH] support ConfidentialMPTMirrorUpdate --- .../xrpl/protocol/detail/ledger_entries.macro | 2 + include/xrpl/protocol/detail/sfields.macro | 1 + .../xrpl/protocol/detail/transactions.macro | 16 + .../protocol_autogen/ledger_entries/MPToken.h | 70 + .../ConfidentialMPTMirrorUpdate.h | 303 ++++ .../token/ConfidentialMPTMirrorUpdate.h | 101 ++ .../token/ConfidentialMPTMirrorUpdate.cpp | 304 ++++ .../app/ConfidentialMPTKeyRotation_test.cpp | 1306 ++++++++++++++++- src/test/jtx/impl/mpt.cpp | 48 + src/test/jtx/impl/utility.cpp | 2 +- src/test/jtx/mpt.h | 22 + .../ledger_entries/MPTokenTests.cpp | 54 + .../ConfidentialMPTMirrorUpdateTests.cpp | 276 ++++ 13 files changed, 2503 insertions(+), 2 deletions(-) create mode 100644 include/xrpl/protocol_autogen/transactions/ConfidentialMPTMirrorUpdate.h create mode 100644 include/xrpl/tx/transactors/token/ConfidentialMPTMirrorUpdate.h create mode 100644 src/libxrpl/tx/transactors/token/ConfidentialMPTMirrorUpdate.cpp create mode 100644 src/tests/libxrpl/protocol_autogen/transactions/ConfidentialMPTMirrorUpdateTests.cpp diff --git a/include/xrpl/protocol/detail/ledger_entries.macro b/include/xrpl/protocol/detail/ledger_entries.macro index be12ad6348..a3e62466d6 100644 --- a/include/xrpl/protocol/detail/ledger_entries.macro +++ b/include/xrpl/protocol/detail/ledger_entries.macro @@ -429,6 +429,8 @@ LEDGER_ENTRY(ltMPTOKEN, 0x007f, MPToken, mptoken, ({ {sfConfidentialBalanceVersion, SoeDefault}, {sfIssuerEncryptedBalance, SoeOptional}, {sfAuditorEncryptedBalance, SoeOptional}, + {sfIssuerKeyMirrorEpoch, SoeOptional}, + {sfAuditorKeyMirrorEpoch, SoeOptional}, {sfHolderEncryptionKey, SoeOptional}, })) diff --git a/include/xrpl/protocol/detail/sfields.macro b/include/xrpl/protocol/detail/sfields.macro index 0a88a6a191..a92fb6aee2 100644 --- a/include/xrpl/protocol/detail/sfields.macro +++ b/include/xrpl/protocol/detail/sfields.macro @@ -322,6 +322,7 @@ TYPED_SFIELD(sfAuditorEncryptedAmount, VL, 43) TYPED_SFIELD(sfAuditorEncryptionKey, VL, 44) TYPED_SFIELD(sfAmountCommitment, VL, 45) TYPED_SFIELD(sfBalanceCommitment, VL, 46) +TYPED_SFIELD(sfPreviousIssuerEncryptionKey, VL, 47) // account (common) TYPED_SFIELD(sfAccount, ACCOUNT, 1) diff --git a/include/xrpl/protocol/detail/transactions.macro b/include/xrpl/protocol/detail/transactions.macro index dbf9b66ac7..2ffee8972c 100644 --- a/include/xrpl/protocol/detail/transactions.macro +++ b/include/xrpl/protocol/detail/transactions.macro @@ -1131,6 +1131,22 @@ TRANSACTION(ttSPONSORSHIP_SET, 91, SponsorshipSet, {sfRemainingOwnerCountDelta, SoeOptional}, })) +#if TRANSACTION_INCLUDE +# include +#endif +TRANSACTION(ttCONFIDENTIAL_MPT_MIRROR_UPDATE, 92, ConfidentialMPTMirrorUpdate, + Delegation::NotDelegable, + featureConfidentialMPTKeyRotation, + NoPriv, + ({ + {sfMPTokenIssuanceID, SoeRequired}, + {sfHolder, SoeOptional}, + {sfIssuerEncryptedAmount, SoeOptional}, + {sfAuditorEncryptedAmount, SoeOptional}, + {sfPreviousIssuerEncryptionKey, SoeOptional}, + {sfZKProof, SoeRequired}, +})) + /** This system-generated transaction type is used to update the status of the various amendments. For details, see: https://xrpl.org/amendments.html diff --git a/include/xrpl/protocol_autogen/ledger_entries/MPToken.h b/include/xrpl/protocol_autogen/ledger_entries/MPToken.h index 874d779d09..c1601fc2fa 100644 --- a/include/xrpl/protocol_autogen/ledger_entries/MPToken.h +++ b/include/xrpl/protocol_autogen/ledger_entries/MPToken.h @@ -268,6 +268,54 @@ public: return this->sle_->isFieldPresent(sfAuditorEncryptedBalance); } + /** + * @brief Get sfIssuerKeyMirrorEpoch (SoeOptional) + * @return The field value, or std::nullopt if not present. + */ + [[nodiscard]] + protocol_autogen::Optional + getIssuerKeyMirrorEpoch() const + { + if (hasIssuerKeyMirrorEpoch()) + return this->sle_->at(sfIssuerKeyMirrorEpoch); + return std::nullopt; + } + + /** + * @brief Check if sfIssuerKeyMirrorEpoch is present. + * @return True if the field is present, false otherwise. + */ + [[nodiscard]] + bool + hasIssuerKeyMirrorEpoch() const + { + return this->sle_->isFieldPresent(sfIssuerKeyMirrorEpoch); + } + + /** + * @brief Get sfAuditorKeyMirrorEpoch (SoeOptional) + * @return The field value, or std::nullopt if not present. + */ + [[nodiscard]] + protocol_autogen::Optional + getAuditorKeyMirrorEpoch() const + { + if (hasAuditorKeyMirrorEpoch()) + return this->sle_->at(sfAuditorKeyMirrorEpoch); + return std::nullopt; + } + + /** + * @brief Check if sfAuditorKeyMirrorEpoch is present. + * @return True if the field is present, false otherwise. + */ + [[nodiscard]] + bool + hasAuditorKeyMirrorEpoch() const + { + return this->sle_->isFieldPresent(sfAuditorKeyMirrorEpoch); + } + /** * @brief Get sfHolderEncryptionKey (SoeOptional) * @return The field value, or std::nullopt if not present. @@ -471,6 +519,28 @@ public: return *this; } + /** + * @brief Set sfIssuerKeyMirrorEpoch (SoeOptional) + * @return Reference to this builder for method chaining. + */ + MPTokenBuilder& + setIssuerKeyMirrorEpoch(std::decay_t const& value) + { + object_[sfIssuerKeyMirrorEpoch] = value; + return *this; + } + + /** + * @brief Set sfAuditorKeyMirrorEpoch (SoeOptional) + * @return Reference to this builder for method chaining. + */ + MPTokenBuilder& + setAuditorKeyMirrorEpoch(std::decay_t const& value) + { + object_[sfAuditorKeyMirrorEpoch] = value; + return *this; + } + /** * @brief Set sfHolderEncryptionKey (SoeOptional) * @return Reference to this builder for method chaining. diff --git a/include/xrpl/protocol_autogen/transactions/ConfidentialMPTMirrorUpdate.h b/include/xrpl/protocol_autogen/transactions/ConfidentialMPTMirrorUpdate.h new file mode 100644 index 0000000000..fddd6f6b13 --- /dev/null +++ b/include/xrpl/protocol_autogen/transactions/ConfidentialMPTMirrorUpdate.h @@ -0,0 +1,303 @@ +// This file is auto-generated. Do not edit. +#pragma once + +#include +#include +#include +#include +#include +#include + +#include +#include + +namespace xrpl::transactions { + +class ConfidentialMPTMirrorUpdateBuilder; + +/** + * @brief Transaction: ConfidentialMPTMirrorUpdate + * + * Type: ttCONFIDENTIAL_MPT_MIRROR_UPDATE (92) + * Delegable: Delegation::NotDelegable + * Amendment: featureConfidentialMPTKeyRotation + * Privileges: NoPriv + * + * Immutable wrapper around STTx providing type-safe field access. + * Use ConfidentialMPTMirrorUpdateBuilder to construct new transactions. + */ +class ConfidentialMPTMirrorUpdate : public TransactionBase +{ +public: + static constexpr xrpl::TxType txType = ttCONFIDENTIAL_MPT_MIRROR_UPDATE; + + /** + * @brief Construct a ConfidentialMPTMirrorUpdate transaction wrapper from an existing STTx object. + * @throws std::runtime_error if the transaction type doesn't match. + */ + explicit ConfidentialMPTMirrorUpdate(std::shared_ptr tx) + : TransactionBase(std::move(tx)) + { + // Verify transaction type + if (tx_->getTxnType() != txType) + { + throw std::runtime_error("Invalid transaction type for ConfidentialMPTMirrorUpdate"); + } + } + + // 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 sfHolder (SoeOptional) + * @return The field value, or std::nullopt if not present. + */ + [[nodiscard]] + protocol_autogen::Optional + getHolder() const + { + if (hasHolder()) + { + return this->tx_->at(sfHolder); + } + return std::nullopt; + } + + /** + * @brief Check if sfHolder is present. + * @return True if the field is present, false otherwise. + */ + [[nodiscard]] + bool + hasHolder() const + { + return this->tx_->isFieldPresent(sfHolder); + } + + /** + * @brief Get sfIssuerEncryptedAmount (SoeOptional) + * @return The field value, or std::nullopt if not present. + */ + [[nodiscard]] + protocol_autogen::Optional + getIssuerEncryptedAmount() const + { + if (hasIssuerEncryptedAmount()) + { + return this->tx_->at(sfIssuerEncryptedAmount); + } + return std::nullopt; + } + + /** + * @brief Check if sfIssuerEncryptedAmount is present. + * @return True if the field is present, false otherwise. + */ + [[nodiscard]] + bool + hasIssuerEncryptedAmount() const + { + return this->tx_->isFieldPresent(sfIssuerEncryptedAmount); + } + + /** + * @brief Get sfAuditorEncryptedAmount (SoeOptional) + * @return The field value, or std::nullopt if not present. + */ + [[nodiscard]] + protocol_autogen::Optional + getAuditorEncryptedAmount() const + { + if (hasAuditorEncryptedAmount()) + { + return this->tx_->at(sfAuditorEncryptedAmount); + } + return std::nullopt; + } + + /** + * @brief Check if sfAuditorEncryptedAmount is present. + * @return True if the field is present, false otherwise. + */ + [[nodiscard]] + bool + hasAuditorEncryptedAmount() const + { + return this->tx_->isFieldPresent(sfAuditorEncryptedAmount); + } + + /** + * @brief Get sfPreviousIssuerEncryptionKey (SoeOptional) + * @return The field value, or std::nullopt if not present. + */ + [[nodiscard]] + protocol_autogen::Optional + getPreviousIssuerEncryptionKey() const + { + if (hasPreviousIssuerEncryptionKey()) + { + return this->tx_->at(sfPreviousIssuerEncryptionKey); + } + return std::nullopt; + } + + /** + * @brief Check if sfPreviousIssuerEncryptionKey is present. + * @return True if the field is present, false otherwise. + */ + [[nodiscard]] + bool + hasPreviousIssuerEncryptionKey() const + { + return this->tx_->isFieldPresent(sfPreviousIssuerEncryptionKey); + } + + /** + * @brief Get sfZKProof (SoeRequired) + * @return The field value. + */ + [[nodiscard]] + SF_VL::type::value_type + getZKProof() const + { + return this->tx_->at(sfZKProof); + } +}; + +/** + * @brief Builder for ConfidentialMPTMirrorUpdate 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 ConfidentialMPTMirrorUpdateBuilder : public TransactionBuilderBase +{ +public: + /** + * @brief Construct a new ConfidentialMPTMirrorUpdateBuilder with required fields. + * @param account The account initiating the transaction. + * @param mPTokenIssuanceID The sfMPTokenIssuanceID field value. + * @param zKProof The sfZKProof field value. + * @param sequence Optional sequence number for the transaction. + * @param fee Optional fee for the transaction. + */ + ConfidentialMPTMirrorUpdateBuilder(SF_ACCOUNT::type::value_type account, + std::decay_t const& mPTokenIssuanceID, std::decay_t const& zKProof, std::optional sequence = std::nullopt, + std::optional fee = std::nullopt +) + : TransactionBuilderBase(ttCONFIDENTIAL_MPT_MIRROR_UPDATE, account, sequence, fee) + { + setMPTokenIssuanceID(mPTokenIssuanceID); + setZKProof(zKProof); + } + + /** + * @brief Construct a ConfidentialMPTMirrorUpdateBuilder from an existing STTx object. + * @param tx The existing transaction to copy from. + * @throws std::runtime_error if the transaction type doesn't match. + */ + ConfidentialMPTMirrorUpdateBuilder(std::shared_ptr tx) + { + if (tx->getTxnType() != ttCONFIDENTIAL_MPT_MIRROR_UPDATE) + { + throw std::runtime_error("Invalid transaction type for ConfidentialMPTMirrorUpdateBuilder"); + } + object_ = *tx; + } + + /** + * @brief Transaction-specific field setters + */ + + /** + * @brief Set sfMPTokenIssuanceID (SoeRequired) + * @return Reference to this builder for method chaining. + */ + ConfidentialMPTMirrorUpdateBuilder& + setMPTokenIssuanceID(std::decay_t const& value) + { + object_[sfMPTokenIssuanceID] = value; + return *this; + } + + /** + * @brief Set sfHolder (SoeOptional) + * @return Reference to this builder for method chaining. + */ + ConfidentialMPTMirrorUpdateBuilder& + setHolder(std::decay_t const& value) + { + object_[sfHolder] = value; + return *this; + } + + /** + * @brief Set sfIssuerEncryptedAmount (SoeOptional) + * @return Reference to this builder for method chaining. + */ + ConfidentialMPTMirrorUpdateBuilder& + setIssuerEncryptedAmount(std::decay_t const& value) + { + object_[sfIssuerEncryptedAmount] = value; + return *this; + } + + /** + * @brief Set sfAuditorEncryptedAmount (SoeOptional) + * @return Reference to this builder for method chaining. + */ + ConfidentialMPTMirrorUpdateBuilder& + setAuditorEncryptedAmount(std::decay_t const& value) + { + object_[sfAuditorEncryptedAmount] = value; + return *this; + } + + /** + * @brief Set sfPreviousIssuerEncryptionKey (SoeOptional) + * @return Reference to this builder for method chaining. + */ + ConfidentialMPTMirrorUpdateBuilder& + setPreviousIssuerEncryptionKey(std::decay_t const& value) + { + object_[sfPreviousIssuerEncryptionKey] = value; + return *this; + } + + /** + * @brief Set sfZKProof (SoeRequired) + * @return Reference to this builder for method chaining. + */ + ConfidentialMPTMirrorUpdateBuilder& + setZKProof(std::decay_t const& value) + { + object_[sfZKProof] = value; + return *this; + } + + /** + * @brief Build and return the ConfidentialMPTMirrorUpdate wrapper. + * @param publicKey The public key for signing. + * @param secretKey The secret key for signing. + * @return The constructed transaction wrapper. + */ + ConfidentialMPTMirrorUpdate + build(PublicKey const& publicKey, SecretKey const& secretKey) + { + sign(publicKey, secretKey); + return ConfidentialMPTMirrorUpdate{std::make_shared(std::move(object_))}; + } +}; + +} // namespace xrpl::transactions diff --git a/include/xrpl/tx/transactors/token/ConfidentialMPTMirrorUpdate.h b/include/xrpl/tx/transactors/token/ConfidentialMPTMirrorUpdate.h new file mode 100644 index 0000000000..8414784d23 --- /dev/null +++ b/include/xrpl/tx/transactors/token/ConfidentialMPTMirrorUpdate.h @@ -0,0 +1,101 @@ +#pragma once + +#include +#include +#include +#include +#include +#include +#include +#include + +#include + +namespace xrpl { + +/** + * @brief Updates the encrypted mirror balances of a Confidential MPToken. + * + * @details + * This transaction updates a single holder's mirrored confidential balances + * (`sfIssuerEncryptedBalance` and/or `sfAuditorEncryptedBalance`) with the latest + * ElGamal public keys defined on the `MPTokenIssuance`. + * + * It supports both issuer and holder self-migration modes, each mode supports multiple flows: + * - Issuer mode: Submitted by the issuer. + * 1. Issuer Key Rotation Migration: Re-encrypts the + * holder's `sfIssuerEncryptedBalance` under the issuer's new ElGamal public key. + * + * 2. Auditor Key Rotation Migration: Re-encrypts the + * holder's `sfAuditorEncryptedBalance` under the auditor's new ElGamal public key. + * + * 3. Simultaneous Rotation Migration: Updates both the issuer + * and auditor encrypted balances in a single transaction to optimize network throughput. + * + * 4. Auditor Late-Registration Migration: When the issuer ElGamal + * public key is already registered on the `MPTokenIssuance` object, the issuer can + * register an auditor key at a later time through `MPTokenIssuanceSet`. Then the issuer uses this + * flow to set the holder's initial `sfAuditorEncryptedBalance` on `MPToken` object. + * + * - Holder self-migration mode: Submitted by the holder. This is the recovery + * path used when the issuer has permanently lost private key and can no longer perform + * active re-encryption. The holder decrypts their own + * `sfConfidentialBalanceSpending` with holder's private key to recover the balance and + * re-encrypts it under the relevant new ElGamal public key(s). + * @note All holder migration flows strictly require the holder's + * `sfConfidentialBalanceInbox` to be canonically zero; the holder must run + * `ConfidentialMPTMergeInbox` first so the spending balance reflects the + * full balance. + * + * 5. Holder Issuer-Mirror Migration: Re-encrypts the holder's + * `sfIssuerEncryptedBalance` under the issuer's new ElGamal public key. + * + * 6. Holder Auditor-Mirror Migration: Re-encrypts the holder's + * `sfAuditorEncryptedBalance` under the auditor's new ElGamal public key, or + * sets it for the first time when the auditor key was late-registered. Needed + * only because the issuer, having lost sk_I, can no longer perform the auditor + * re-encryption or initial registration in issuer mode (flows 2 and 4). + * + * 7. Simultaneous Holder Self-Migration: Updates both the issuer and auditor + * encrypted balances in a single transaction (both keys have rotated). + */ +class ConfidentialMPTMirrorUpdate : public Transactor +{ +public: + static constexpr auto kConsequencesFactory = ConsequencesFactoryType::Normal; + + explicit ConfidentialMPTMirrorUpdate(ApplyContext& ctx) : Transactor(ctx) + { + } + + static bool + checkExtraFeatures(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, + std::shared_ptr const& before, + std::shared_ptr const& 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/transactors/token/ConfidentialMPTMirrorUpdate.cpp b/src/libxrpl/tx/transactors/token/ConfidentialMPTMirrorUpdate.cpp new file mode 100644 index 0000000000..ef695dbed1 --- /dev/null +++ b/src/libxrpl/tx/transactors/token/ConfidentialMPTMirrorUpdate.cpp @@ -0,0 +1,304 @@ +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include + +namespace xrpl { + +bool +ConfidentialMPTMirrorUpdate::checkExtraFeatures(PreflightContext const& ctx) +{ + // Key rotation makes sense only when featureConfidentialTransfer is enabled. + return ctx.rules.enabled(featureConfidentialTransfer); +} + +NotTEC +ConfidentialMPTMirrorUpdate::preflight(PreflightContext const& ctx) +{ + auto const account = ctx.tx[sfAccount]; + auto const issuer = MPTIssue(ctx.tx[sfMPTokenIssuanceID]).getIssuer(); + auto const holder = ctx.tx[~sfHolder]; + bool const hasHolder = holder.has_value(); + + // The rotation mode is determined by the presence of the + // Holder field: Holder present is issuer mode, Holder absent is + // holder self-migration. + if (hasHolder) + { + // Issuer mode: account must be the issuer + if (account != issuer) + return temMALFORMED; + + if (account == *holder) + return temMALFORMED; + } + else + { + // Holder self-migration: the submitter is the holder, account must not be the issuer. + if (account == issuer) + return temMALFORMED; + } + + // At least one ciphertext will be updated. + bool const hasIssuerAmount = ctx.tx.isFieldPresent(sfIssuerEncryptedAmount); + bool const hasAuditorAmount = ctx.tx.isFieldPresent(sfAuditorEncryptedAmount); + if (!hasIssuerAmount && !hasAuditorAmount) + return temMALFORMED; + + // The previous issuer key is required exactly for an issuer-mode + // re-encryption of the issuer mirror,either issuer key rotation or + // issuer/auditor key simultaneous rotation. + bool const hasPreviousIssuerKey = ctx.tx.isFieldPresent(sfPreviousIssuerEncryptionKey); + bool const needsPreviousIssuerKey = hasHolder && hasIssuerAmount; + if (hasPreviousIssuerKey != needsPreviousIssuerKey) + return temMALFORMED; + + // Check the length of the encrypted amounts. Length check is cheaper than format check so put + // it before the format check. + if (hasIssuerAmount && ctx.tx[sfIssuerEncryptedAmount].length() != kEcGamalEncryptedTotalLength) + return temBAD_CIPHERTEXT; + + if (hasAuditorAmount && + ctx.tx[sfAuditorEncryptedAmount].length() != kEcGamalEncryptedTotalLength) + return temBAD_CIPHERTEXT; + + // Check the encrypted amount formats. It is more expensive so put it at the end of preflight. + if (hasIssuerAmount && !isValidCiphertext(ctx.tx[sfIssuerEncryptedAmount])) + return temBAD_CIPHERTEXT; + + if (hasAuditorAmount && !isValidCiphertext(ctx.tx[sfAuditorEncryptedAmount])) + return temBAD_CIPHERTEXT; + + // If previous issuer key is present, it must be a valid EC point. + if (hasPreviousIssuerKey && !isValidCompressedECPoint(ctx.tx[sfPreviousIssuerEncryptionKey])) + return temMALFORMED; + + // todo: check zkproof + + return tesSUCCESS; +} + +XRPAmount +ConfidentialMPTMirrorUpdate::calculateBaseFee(ReadView const& view, STTx const& tx) +{ + return Transactor::calculateBaseFee(view, tx, kConfidentialFeeMultiplier); +} + +TER +ConfidentialMPTMirrorUpdate::preclaim(PreclaimContext const& ctx) +{ + // Check if account exists + auto const account = ctx.tx[sfAccount]; + if (!ctx.view.exists(keylet::account(account))) + return terNO_ACCOUNT; // LCOV_EXCL_LINE + + // The issuance must exist and have confidential balances enabled with a + // registered issuer encryption key; otherwise there is no mirror to update. + auto const mptIssuanceID = ctx.tx[sfMPTokenIssuanceID]; + auto const sleIssuance = ctx.view.read(keylet::mptokenIssuance(mptIssuanceID)); + if (!sleIssuance) + return tecOBJECT_NOT_FOUND; + + // The issuance must have confidential balances enabled with a registered issuer encryption key. + if (!sleIssuance->isFlag(lsfMPTCanHoldConfidentialBalance) || + !sleIssuance->isFieldPresent(sfIssuerEncryptionKey)) + return tecNO_PERMISSION; + + // Sanity check: preflight already enforced the issuer holder combination + // under different rotation modes. + auto const holder = ctx.tx[~sfHolder]; + bool const hasHolder = holder.has_value(); + auto const issuer = sleIssuance->getAccountID(sfIssuer); + if (hasHolder ? (issuer != account) : (issuer == account)) + { + // LCOV_EXCL_START + UNREACHABLE( + "xrpl::ConfidentialMPTMirrorUpdate::preclaim : invalid issuer holder combination"); + return tefINTERNAL; + // LCOV_EXCL_STOP + } + + // The holder is sfHolder in issuer mode and is sfAccount in holder mode. + auto const holderID = hasHolder ? *holder : account; + + // In issuer mode, the holder must exist. In holder mode, the account existence was checked + // already. + if (hasHolder && !ctx.view.exists(keylet::account(holderID))) + return tecNO_TARGET; + + // In either issuer or holder mode, check the existence of the MPToken object. + auto const sleMptoken = ctx.view.read(keylet::mptoken(mptIssuanceID, holderID)); + if (!sleMptoken) + return tecOBJECT_NOT_FOUND; + + // The holder must already hold an issuer confidential balance. + if (!sleMptoken->isFieldPresent(sfIssuerEncryptedBalance)) + return tecNO_PERMISSION; + + bool const hasIssuerAmount = ctx.tx.isFieldPresent(sfIssuerEncryptedAmount); + bool const hasAuditorAmount = ctx.tx.isFieldPresent(sfAuditorEncryptedAmount); + + // Migrating the auditor mirror requires the issuance to have a registered + // auditor encryption key. + if (hasAuditorAmount && !sleIssuance->isFieldPresent(sfAuditorEncryptionKey)) + return tecNO_PERMISSION; + + // Epoch staleness. Mirror epochs count how many times the + // holder's mirrors have been re-encrypted; the issuance key epochs count + // how many times the keys have rotated. + std::uint32_t const issuerKeyEpoch = (*sleIssuance)[~sfIssuerKeyEpoch].value_or(0); + std::uint32_t const auditorKeyEpoch = (*sleIssuance)[~sfAuditorKeyEpoch].value_or(0); + std::uint32_t const issuerMirrorEpoch = (*sleMptoken)[~sfIssuerKeyMirrorEpoch].value_or(0); + std::uint32_t const auditorMirrorEpoch = (*sleMptoken)[~sfAuditorKeyMirrorEpoch].value_or(0); + + // The issuer mirror can only be re-encrypted while it is stale. + if (hasIssuerAmount && issuerMirrorEpoch >= issuerKeyEpoch) + return tecNO_PERMISSION; + + if (hasAuditorAmount) + { + // An issuer-mode auditor-only migration: the issuer mirror must already be up to date. + if (hasHolder && !hasIssuerAmount && issuerMirrorEpoch != issuerKeyEpoch) + return tecNO_PERMISSION; + + // The auditor mirror can only be re-encrypted while it is stale, unless + // this is its first-time registration (no auditor mirror yet). + bool const hasAuditorMirror = sleMptoken->isFieldPresent(sfAuditorEncryptedBalance); + if (hasAuditorMirror && auditorMirrorEpoch >= auditorKeyEpoch) + return tecNO_PERMISSION; + } + + // Holder self-migration re-encrypts the mirror from the holder's own + // spending balance, which reflects the holder's full balance only once the + // inbox has been merged into it. Require the inbox to be canonical zero, + // i.e. ConfidentialMPTMergeInbox has already been applied. + if (!hasHolder) + { + // Sanity check: a holder that already carries an issuer mirror + // necessarily has a holder encryption key and a spending balance + if (!sleMptoken->isFieldPresent(sfHolderEncryptionKey) || + !sleMptoken->isFieldPresent(sfConfidentialBalanceSpending)) + { + // LCOV_EXCL_START + UNREACHABLE( + "xrpl::ConfidentialMPTMirrorUpdate::preclaim : an issuer mirror implies a holder " + "key and spending balance"); + return tefINTERNAL; + // LCOV_EXCL_STOP + } + + auto const expectedZeroInbox = encryptCanonicalZeroAmount( + (*sleMptoken)[sfHolderEncryptionKey], holderID, mptIssuanceID); + if (!expectedZeroInbox) + { + // LCOV_EXCL_START + UNREACHABLE( + "xrpl::ConfidentialMPTMirrorUpdate::preclaim : canonical zero encryption cannot " + "fail for an already-valid holder public key"); + return tefINTERNAL; + // LCOV_EXCL_STOP + } + + bool const inboxIsCanonicalZero = sleMptoken->isFieldPresent(sfConfidentialBalanceInbox) && + Slice((*sleMptoken)[sfConfidentialBalanceInbox]) == Slice(*expectedZeroInbox); + if (!inboxIsCanonicalZero) + return tecNO_PERMISSION; + } + + // todo: check zkproof + + return tesSUCCESS; +} + +TER +ConfidentialMPTMirrorUpdate::doApply() +{ + auto const mptIssuanceID = ctx_.tx[sfMPTokenIssuanceID]; + + auto const sleIssuance = view().read(keylet::mptokenIssuance(mptIssuanceID)); + if (!sleIssuance) + { + // LCOV_EXCL_START + UNREACHABLE( + "xrpl::ConfidentialMPTMirrorUpdate::doApply : preclaim already validated the " + "issuance exists"); + return tefINTERNAL; + // LCOV_EXCL_STOP + } + + // The holderID is sfHolder in issuer mode and sfAccount in holder mode. + auto const holder = ctx_.tx[~sfHolder]; + auto const holderID = holder.value_or(accountID_); + + auto sleMptoken = view().peek(keylet::mptoken(mptIssuanceID, holderID)); + if (!sleMptoken) + { + // LCOV_EXCL_START + UNREACHABLE( + "xrpl::ConfidentialMPTMirrorUpdate::doApply : preclaim already validated the " + "MPToken exists"); + return tefINTERNAL; + // LCOV_EXCL_STOP + } + + // Re-encrypt the requested mirror(s) and advance the corresponding mirror + // epoch to match the issuance key epoch. Only + // set the epoch field when it is non-zero, matching the issuance convention + // that an absent epoch means zero. + if (ctx_.tx.isFieldPresent(sfIssuerEncryptedAmount)) + { + (*sleMptoken)[sfIssuerEncryptedBalance] = ctx_.tx[sfIssuerEncryptedAmount]; + std::uint32_t const issuerKeyEpoch = (*sleIssuance)[~sfIssuerKeyEpoch].value_or(0); + if (issuerKeyEpoch != 0) + (*sleMptoken)[sfIssuerKeyMirrorEpoch] = issuerKeyEpoch; + } + + if (ctx_.tx.isFieldPresent(sfAuditorEncryptedAmount)) + { + (*sleMptoken)[sfAuditorEncryptedBalance] = ctx_.tx[sfAuditorEncryptedAmount]; + std::uint32_t const auditorKeyEpoch = (*sleIssuance)[~sfAuditorKeyEpoch].value_or(0); + if (auditorKeyEpoch != 0) + (*sleMptoken)[sfAuditorKeyMirrorEpoch] = auditorKeyEpoch; + } + + view().update(sleMptoken); + return tesSUCCESS; +} + +void +ConfidentialMPTMirrorUpdate::visitInvariantEntry( + bool, + std::shared_ptr const&, + std::shared_ptr const&) +{ +} + +bool +ConfidentialMPTMirrorUpdate::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 c4e8e607da..5425bf5c0e 100644 --- a/src/test/app/ConfidentialMPTKeyRotation_test.cpp +++ b/src/test/app/ConfidentialMPTKeyRotation_test.cpp @@ -3,9 +3,11 @@ #include #include +#include #include #include #include +#include #include #include #include @@ -604,6 +606,1301 @@ class ConfidentialMPTKeyRotation_test : public ConfidentialTransferTestBase BEAST_EXPECT(mptAlice.checkKeyEpochs(kMaxKeyEpoch, kMaxKeyEpoch)); } + 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 + testConfidentialMPTMirrorUpdatePreflight(FeatureBitset features) + { + testcase("ConfidentialMPTMirrorUpdate preflight"); + 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, carol}}); + + // A well-formed 66-byte ElGamal ciphertext + Buffer const& validCipher = getTrivialCiphertext(); + + // Both amendments are required: ConfidentialMPTKeyRotation and ConfidentialTransfer. + if (!features[featureConfidentialMPTKeyRotation] || !features[featureConfidentialTransfer]) + { + mptAlice.create({.ownerCount = 1, .flags = tfMPTCanTransfer}); + mptAlice.mirrorUpdate({ + .account = bob, + .issuerEncryptedAmount = validCipher, + .err = temDISABLED, + }); + return; + } + + mptAlice.create({ + .ownerCount = 1, + .flags = tfMPTCanTransfer | tfMPTCanHoldConfidentialBalance, + }); + mptAlice.generateKeyPair(alice); + + // A valid EC point + auto const validKey = mptAlice.getPubKey(alice); + + // Issuer mode but account is not the issuer. + mptAlice.mirrorUpdate({ + .account = bob, + .holder = carol, + .issuerEncryptedAmount = validCipher, + .previousIssuerKey = validKey, + .err = temMALFORMED, + }); + + // Issuer mode but the holder is the same as the issuer. + mptAlice.mirrorUpdate({ + .account = alice, + .holder = alice, + .issuerEncryptedAmount = validCipher, + .previousIssuerKey = validKey, + .err = temMALFORMED, + }); + + // Issuer mode but holder is not provided. + mptAlice.mirrorUpdate({ + .account = alice, + .issuerEncryptedAmount = validCipher, + .err = temMALFORMED, + }); + + // At least one of issuer or auditor amount must be present. + mptAlice.mirrorUpdate({ + .account = alice, + .holder = bob, + .err = temMALFORMED, + }); + + // Holder mode, previousIssuerKey should not be present. + mptAlice.mirrorUpdate({ + .account = bob, + .issuerEncryptedAmount = validCipher, + .previousIssuerKey = validKey, + .err = temMALFORMED, + }); + + // Issuer mode, previousIssuerKey is provided but issuerEncryptedAmount is missing. + mptAlice.mirrorUpdate({ + .account = alice, + .holder = bob, + .auditorEncryptedAmount = validCipher, + .previousIssuerKey = validKey, + .err = temMALFORMED, + }); + + // Issuer mode, issuerEncryptedAmount is present but previousIssuerKey is missing. + mptAlice.mirrorUpdate({ + .account = alice, + .holder = bob, + .issuerEncryptedAmount = validCipher, + .err = temMALFORMED, + }); + + // Issuer amount has the wrong length. + mptAlice.mirrorUpdate({ + .account = alice, + .holder = bob, + .issuerEncryptedAmount = gMakeZeroBuffer(10), + .previousIssuerKey = validKey, + .err = temBAD_CIPHERTEXT, + }); + + // Auditor amount has the wrong length. + mptAlice.mirrorUpdate({ + .account = alice, + .holder = bob, + .auditorEncryptedAmount = gMakeZeroBuffer(10), + .err = temBAD_CIPHERTEXT, + }); + + // Issuer amount is the right length but not a valid ciphertext. + mptAlice.mirrorUpdate({ + .account = alice, + .holder = bob, + .issuerEncryptedAmount = getBadCiphertext(), + .previousIssuerKey = validKey, + .err = temBAD_CIPHERTEXT, + }); + + // Auditor amount is the right length but not a valid ciphertext. + mptAlice.mirrorUpdate({ + .account = alice, + .holder = bob, + .issuerEncryptedAmount = validCipher, + .auditorEncryptedAmount = getBadCiphertext(), + .previousIssuerKey = validKey, + .err = temBAD_CIPHERTEXT, + }); + + // previousIssuerKey is present but not a valid EC point. + mptAlice.mirrorUpdate({ + .account = alice, + .holder = bob, + .issuerEncryptedAmount = validCipher, + .previousIssuerKey = gMakeZeroBuffer(kEcPubKeyLength), + .err = temMALFORMED, + }); + } + + void + testConfidentialMPTMirrorUpdatePreclaim(FeatureBitset features) + { + testcase("ConfidentialMPTMirrorUpdate preclaim"); + using namespace test::jtx; + + Buffer const& validCipher = getTrivialCiphertext(); + Buffer const& validKey = getTrivialCommitment(); + + // 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({ + .flags = tfMPTCanTransfer | tfMPTCanHoldConfidentialBalance, + }); + // Destroy the issuance to test issuance not found. + mptAlice.destroy(); + + mptAlice.mirrorUpdate({ + .account = bob, + .issuerEncryptedAmount = validCipher, + .err = tecOBJECT_NOT_FOUND, + }); + } + + // The issuance have not enabled confidential balances. + { + 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.mirrorUpdate({ + .account = alice, + .holder = bob, + .issuerEncryptedAmount = validCipher, + .previousIssuerKey = validKey, + .err = tecNO_PERMISSION, + }); + } + + // The issuer encryption key was not already registered. + { + 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.mirrorUpdate({ + .account = alice, + .holder = bob, + .issuerEncryptedAmount = validCipher, + .previousIssuerKey = validKey, + .err = tecNO_PERMISSION, + }); + } + + // In issuer mode, the specified holder account does not exist. + { + Env env{*this, features}; + Account const alice("alice"); + Account const carol("carol"); + MPTTester mptAlice(env, alice); + mptAlice.create( + {.ownerCount = 1, .flags = tfMPTCanTransfer | tfMPTCanHoldConfidentialBalance}); + mptAlice.generateKeyPair(alice); + mptAlice.set({.account = alice, .issuerPubKey = mptAlice.getPubKey(alice)}); + + // Carol never got funded so it does not exist. + mptAlice.mirrorUpdate({ + .account = alice, + .holder = carol, + .issuerEncryptedAmount = validCipher, + .previousIssuerKey = validKey, + .err = tecNO_TARGET, + }); + } + + // The holder's MPToken does not exist (holder never authorized). + { + 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.set({.account = alice, .issuerPubKey = mptAlice.getPubKey(alice)}); + + mptAlice.mirrorUpdate({ + .account = alice, + .holder = bob, + .issuerEncryptedAmount = validCipher, + .previousIssuerKey = validKey, + .err = tecOBJECT_NOT_FOUND, + }); + } + + // The holder has an MPToken but no confidential issuer balance (sfIssuerEncryptedBalance). + { + 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.generateKeyPair(alice); + mptAlice.set({.account = alice, .issuerPubKey = mptAlice.getPubKey(alice)}); + + mptAlice.mirrorUpdate({ + .account = alice, + .holder = bob, + .issuerEncryptedAmount = validCipher, + .previousIssuerKey = validKey, + .err = tecNO_PERMISSION, + }); + } + + // Auditor mirror migration on an issuance with no auditor key. + { + Env env{*this, features}; + Account const alice("alice"); + Account const bob("bob"); + + // This setup has issuer key but no auditor key. + ConfidentialEnv ct{env, alice, {{.account = bob}}}; + + ct.mpt.mirrorUpdate({ + .account = alice, + .holder = bob, + .auditorEncryptedAmount = validCipher, + .err = tecNO_PERMISSION, + }); + } + + // Issuer mirror is already most up-to-date so + // there is nothing to migrate. + { + Env env{*this, features}; + Account const alice("alice"); + Account const bob("bob"); + ConfidentialEnv ct{env, alice, {{.account = bob}}}; + + ct.mpt.mirrorUpdate({ + .account = alice, + .holder = bob, + .issuerEncryptedAmount = validCipher, + .previousIssuerKey = validKey, + .err = tecNO_PERMISSION, + }); + } + + // Issuer-mode auditor-only migration while the issuer mirror is stale: + // the issuer mirror must be brought up to date before the auditor + // mirror can be migrated. + { + Env env{*this, features}; + Account const alice("alice"); + Account const bob("bob"); + Account const auditor("auditor"); + Account const newIssuerKey("newIssuerKey"); + + // Issuance has both an issuer key and an auditor key, and bob holds + // both mirrors at epoch 0. + ConfidentialEnv ct{ + env, + alice, + {{.account = bob}}, + tfMPTCanHoldConfidentialBalance | tfMPTCanTransfer, + auditor}; + + // Rotate the issuer key: issuer key epoch 0 -> 1, while bob's + // issuer-mirror epoch stays 0 (stale). + ct.mpt.generateKeyPair(newIssuerKey); + ct.mpt.set({.account = alice, .issuerPubKey = ct.mpt.getPubKey(newIssuerKey)}); + + ct.mpt.mirrorUpdate({ + .account = alice, + .holder = bob, + .auditorEncryptedAmount = validCipher, + .err = tecNO_PERMISSION, + }); + } + + // Auditor mirror is already current (the auditor key has not rotated), + // so there is nothing to migrate. + { + Env env{*this, features}; + Account const alice("alice"); + Account const bob("bob"); + Account const auditor("auditor"); + + // Issuance has both keys and bob holds both mirrors at epoch 0. + ConfidentialEnv ct{ + env, + alice, + {{.account = bob}}, + tfMPTCanHoldConfidentialBalance | tfMPTCanTransfer, + auditor}; + + // No key has rotated, so the auditor mirror is up to date + // so there is nothing to migrate. + ct.mpt.mirrorUpdate({ + .account = alice, + .holder = bob, + .auditorEncryptedAmount = validCipher, + .err = tecNO_PERMISSION, + }); + } + + // In an issuer-mode simultaneous migration, both mirrors must be stale. Here + // only the issuer key has rotated so its mirror is stale but the auditor mirror is not. + { + Env env{*this, features}; + Account const alice("alice"); + Account const bob("bob"); + Account const auditor("auditor"); + Account const newIssuerKey("newIssuerKey"); + + ConfidentialEnv ct{ + env, + alice, + {{.account = bob}}, + tfMPTCanHoldConfidentialBalance | tfMPTCanTransfer, + auditor}; + + // Rotate only the issuer key: issuer key epoch 0 -> 1, auditor key + // epoch stays 0. The issuer mirror is now stale but the auditor + // mirror is still current. + ct.mpt.generateKeyPair(newIssuerKey); + ct.mpt.set({.account = alice, .issuerPubKey = ct.mpt.getPubKey(newIssuerKey)}); + + ct.mpt.mirrorUpdate({ + .account = alice, + .holder = bob, + .issuerEncryptedAmount = validCipher, + .auditorEncryptedAmount = validCipher, + .previousIssuerKey = validKey, + .err = tecNO_PERMISSION, + }); + } + + // In an issuer-mode simultaneous migration, both mirrors must be stale. + // Here only the auditor key has rotated so its mirror is stale but the + // issuer mirror is not. + { + Env env{*this, features}; + Account const alice("alice"); + Account const bob("bob"); + Account const auditor("auditor"); + Account const newAuditorKey("newAuditorKey"); + + ConfidentialEnv ct{ + env, + alice, + {{.account = bob}}, + tfMPTCanHoldConfidentialBalance | tfMPTCanTransfer, + auditor}; + + // Rotate only the auditor key: auditor key epoch 0 -> 1, issuer key + // epoch stays 0. The auditor mirror is now stale but the issuer + // mirror is still current. + ct.mpt.generateKeyPair(newAuditorKey); + ct.mpt.set({.account = alice, .auditorPubKey = ct.mpt.getPubKey(newAuditorKey)}); + + ct.mpt.mirrorUpdate({ + .account = alice, + .holder = bob, + .issuerEncryptedAmount = validCipher, + .auditorEncryptedAmount = validCipher, + .previousIssuerKey = validKey, + .err = tecNO_PERMISSION, + }); + } + + // Holder self-migration mode runs the same staleness checks. + // No key has rotated, so the holder's own issuer mirror is current and + // there is nothing to migrate. + { + Env env{*this, features}; + Account const alice("alice"); + Account const bob("bob"); + ConfidentialEnv ct{env, alice, {{.account = bob}}}; + + ct.mpt.mirrorUpdate({ + .account = bob, + .issuerEncryptedAmount = validCipher, + .err = tecNO_PERMISSION, + }); + } + + // Holder self-migration mode, simultaneously migrating both keys: only the issuer key + // has rotated, so the holder's issuer mirror is stale but the auditor + // mirror is still current. + { + Env env{*this, features}; + Account const alice("alice"); + Account const bob("bob"); + Account const auditor("auditor"); + Account const newIssuerKey("newIssuerKey"); + + ConfidentialEnv ct{ + env, + alice, + {{.account = bob}}, + tfMPTCanHoldConfidentialBalance | tfMPTCanTransfer, + auditor}; + + // Rotate only the issuer key: issuer key epoch 0 -> 1, auditor key + // epoch stays 0. + ct.mpt.generateKeyPair(newIssuerKey); + ct.mpt.set({.account = alice, .issuerPubKey = ct.mpt.getPubKey(newIssuerKey)}); + + // Holder mode (no Holder field) needs no previous issuer key. + ct.mpt.mirrorUpdate({ + .account = bob, + .issuerEncryptedAmount = validCipher, + .auditorEncryptedAmount = validCipher, + .err = tecNO_PERMISSION, + }); + } + + // Holder self-migration mode, simultaneously migrating both keys: + // only the auditor key has rotated, so the holder's auditor mirror is stale but the issuer + // mirror is still current. + { + Env env{*this, features}; + Account const alice("alice"); + Account const bob("bob"); + Account const auditor("auditor"); + Account const newAuditorKey("newAuditorKey"); + + ConfidentialEnv ct{ + env, + alice, + {{.account = bob}}, + tfMPTCanHoldConfidentialBalance | tfMPTCanTransfer, + auditor}; + + // Rotate only the auditor key: auditor key epoch 0 -> 1, issuer key + // epoch stays 0. + ct.mpt.generateKeyPair(newAuditorKey); + ct.mpt.set({.account = alice, .auditorPubKey = ct.mpt.getPubKey(newAuditorKey)}); + + // Auditor mirror is stale but issuer mirror is current so this is rejected. + ct.mpt.mirrorUpdate({ + .account = bob, + .issuerEncryptedAmount = validCipher, + .auditorEncryptedAmount = validCipher, + .err = tecNO_PERMISSION, + }); + } + + // Holder self-migration requires the holder's inbox to be canonical + // zero, because the cross-key equality proof anchors on the spending + // balance, which only reflects the full balance after the inbox is + // merged. A holder with a non-zero inbox is rejected. + { + Env env{*this, features}; + Account const alice("alice"); + Account const bob("bob"); + Account const carol("carol"); + Account const newIssuerKey("newIssuerKey"); + + ConfidentialEnv ct{env, alice, {{.account = bob}, {.account = carol}}}; + + // Carol sends Bob a confidential amount; Bob does NOT merge it, so + // his inbox is no longer canonical zero. + ct.mpt.send({.account = carol, .dest = bob, .amt = 10}); + + // Rotate the issuer key so the issuer mirror is stale and the + // migration gets past the epoch check to reach the inbox check. + ct.mpt.generateKeyPair(newIssuerKey); + ct.mpt.set({.account = alice, .issuerPubKey = ct.mpt.getPubKey(newIssuerKey)}); + + ct.mpt.mirrorUpdate({ + .account = bob, + .issuerEncryptedAmount = validCipher, + .err = tecNO_PERMISSION, + }); + + // Merging the inbox makes the migration succeed. + ct.mpt.mergeInbox({.account = bob}); + ct.mpt.mirrorUpdate({ + .account = bob, + .issuerEncryptedAmount = validCipher, + .err = tesSUCCESS, + }); + } + } + + void + testConfidentialMPTMirrorUpdateDoApply(FeatureBitset features) + { + testcase("ConfidentialMPTMirrorUpdate doApply"); + using namespace test::jtx; + + // The holder's confidential balance, matching the ConfidentialEnv default + // convertAmount. The migration re-encrypts this amount under the new key. + std::uint64_t const amount = 100; + + // Issuer mode issuer-mirror migration. The new issuer mirror is written + // and the auissuerditor mirror epoch advances to the issuer key epoch. + { + Env env{*this, features}; + Account const alice("alice"); + Account const bob("bob"); + Account const newIssuerKey("newIssuerKey"); + ConfidentialEnv ct{env, alice, {{.account = bob}}}; + + // Rotate the issuer key: issuer key epoch 0 -> 1. + ct.mpt.generateKeyPair(newIssuerKey); + ct.mpt.set({.account = alice, .issuerPubKey = ct.mpt.getPubKey(newIssuerKey)}); + + // Re-encrypt Bob's balance under the new issuer key. + Buffer const newIssuerCipher = + ct.mpt.encryptAmount(newIssuerKey, amount, generateBlindingFactor()); + + // The previous issuer key is the pre-rotation issuer key (alice's), + // no longer on-ledger after the rotation, provide it in the transaction. + ct.mpt.mirrorUpdate({ + .account = alice, + .holder = bob, + .issuerEncryptedAmount = newIssuerCipher, + .previousIssuerKey = ct.mpt.getPubKey(alice), + }); + + auto const sle = env.le(keylet::mptoken(ct.mpt.issuanceID(), bob.id())); + if (!BEAST_EXPECT(sle)) + return; + + BEAST_EXPECT(strHex((*sle)[sfIssuerEncryptedBalance]) == strHex(newIssuerCipher)); + BEAST_EXPECT((*sle)[~sfIssuerKeyMirrorEpoch] == 1u); + + // The issuer mirror is now current, so re-migrating it is rejected. + ct.mpt.mirrorUpdate({ + .account = alice, + .holder = bob, + .issuerEncryptedAmount = newIssuerCipher, + .previousIssuerKey = ct.mpt.getPubKey(alice), + .err = tecNO_PERMISSION, + }); + } + + // Issuer mode auditor-mirror migration. The new auditor mirror is written + // and the auditor mirror epoch advances to the auditor key epoch. + { + Env env{*this, features}; + Account const alice("alice"); + Account const bob("bob"); + Account const auditor("auditor"); + Account const newAuditorKey("newAuditorKey"); + ConfidentialEnv ct{ + env, + alice, + {{.account = bob}}, + tfMPTCanHoldConfidentialBalance | tfMPTCanTransfer, + auditor}; + + // Rotate only the auditor key: auditor key epoch 0 -> 1. + ct.mpt.generateKeyPair(newAuditorKey); + ct.mpt.set({.account = alice, .auditorPubKey = ct.mpt.getPubKey(newAuditorKey)}); + + // Re-encrypt Bob's balance under the new auditor key. + Buffer const newAuditorCipher = + ct.mpt.encryptAmount(newAuditorKey, amount, generateBlindingFactor()); + + ct.mpt.mirrorUpdate({ + .account = alice, + .holder = bob, + .auditorEncryptedAmount = newAuditorCipher, + }); + + auto const sle = env.le(keylet::mptoken(ct.mpt.issuanceID(), bob.id())); + if (!BEAST_EXPECT(sle)) + return; + + BEAST_EXPECT(strHex((*sle)[sfAuditorEncryptedBalance]) == strHex(newAuditorCipher)); + BEAST_EXPECT((*sle)[~sfAuditorKeyMirrorEpoch] == 1u); + } + + // Issuer mode simultaneous migration: both mirrors are written in one transaction and + // both epochs advance. + { + Env env{*this, features}; + Account const alice("alice"); + Account const bob("bob"); + Account const auditor("auditor"); + Account const newIssuerKey("newIssuerKey"); + Account const newAuditorKey("newAuditorKey"); + ConfidentialEnv ct{ + env, + alice, + {{.account = bob}}, + tfMPTCanHoldConfidentialBalance | tfMPTCanTransfer, + auditor}; + + // Rotate both keys: both key epochs 0 -> 1. + ct.mpt.generateKeyPair(newIssuerKey); + ct.mpt.generateKeyPair(newAuditorKey); + ct.mpt.set({ + .account = alice, + .issuerPubKey = ct.mpt.getPubKey(newIssuerKey), + .auditorPubKey = ct.mpt.getPubKey(newAuditorKey), + }); + + // Re-encrypt Bob's balance under each new key. + Buffer const bf = generateBlindingFactor(); + Buffer const newIssuerCipher = ct.mpt.encryptAmount(newIssuerKey, amount, bf); + Buffer const newAuditorCipher = ct.mpt.encryptAmount(newAuditorKey, amount, bf); + + ct.mpt.mirrorUpdate({ + .account = alice, + .holder = bob, + .issuerEncryptedAmount = newIssuerCipher, + .auditorEncryptedAmount = newAuditorCipher, + .previousIssuerKey = ct.mpt.getPubKey(alice), + }); + + auto const sle = env.le(keylet::mptoken(ct.mpt.issuanceID(), bob.id())); + if (!BEAST_EXPECT(sle)) + return; + + BEAST_EXPECT(strHex((*sle)[sfIssuerEncryptedBalance]) == strHex(newIssuerCipher)); + BEAST_EXPECT(strHex((*sle)[sfAuditorEncryptedBalance]) == strHex(newAuditorCipher)); + BEAST_EXPECT((*sle)[~sfIssuerKeyMirrorEpoch] == 1u); + BEAST_EXPECT((*sle)[~sfAuditorKeyMirrorEpoch] == 1u); + } + + // Issuer mode auditor late-registration: the auditor key is registered for the first + // time (key epoch absent), so setting the initial auditor mirror leaves + // the auditor mirror epoch absent as well. + { + Env env{*this, features}; + Account const alice("alice"); + Account const bob("bob"); + Account const auditor("auditor"); + // No auditor in the confidential setup, so bob has no auditor mirror. + ConfidentialEnv ct{env, alice, {{.account = bob}}}; + + // Register an auditor key for the first time (auditor key epoch stays + // absent). + ct.mpt.generateKeyPair(auditor); + ct.mpt.set({.account = alice, .auditorPubKey = ct.mpt.getPubKey(auditor)}); + + // Encrypt Bob's balance under the newly registered auditor key. + Buffer const auditorCipher = + ct.mpt.encryptAmount(auditor, amount, generateBlindingFactor()); + + ct.mpt.mirrorUpdate({ + .account = alice, + .holder = bob, + .auditorEncryptedAmount = auditorCipher, + }); + + auto const sle = env.le(keylet::mptoken(ct.mpt.issuanceID(), bob.id())); + if (!BEAST_EXPECT(sle)) + return; + BEAST_EXPECT(strHex((*sle)[sfAuditorEncryptedBalance]) == strHex(auditorCipher)); + // First-time registration leaves the mirror epoch absent (== 0). + BEAST_EXPECT(!sle->isFieldPresent(sfAuditorKeyMirrorEpoch)); + } + + // Holder self-migration migrates from the holder's own spending balance + // (Holder being Account field, no Holder field, and no previous issuer key in any flow + // because the anchor is the spending balance, not the old issuer mirror). ConfidentialEnv + // already merged the inbox so the holder's inbox is canonical zero. + + // Holder issuer-mirror migration. + { + Env env{*this, features}; + Account const alice("alice"); + Account const bob("bob"); + Account const newIssuerKey("newIssuerKey"); + ConfidentialEnv ct{env, alice, {{.account = bob}}}; + + ct.mpt.generateKeyPair(newIssuerKey); + ct.mpt.set({.account = alice, .issuerPubKey = ct.mpt.getPubKey(newIssuerKey)}); + + // The holder re-encrypts their own balance under the new issuer key. + Buffer const newIssuerCipher = + ct.mpt.encryptAmount(newIssuerKey, amount, generateBlindingFactor()); + + ct.mpt.mirrorUpdate({ + .account = bob, + .issuerEncryptedAmount = newIssuerCipher, + }); + + auto const sle = env.le(keylet::mptoken(ct.mpt.issuanceID(), bob.id())); + if (!BEAST_EXPECT(sle)) + return; + BEAST_EXPECT(strHex((*sle)[sfIssuerEncryptedBalance]) == strHex(newIssuerCipher)); + BEAST_EXPECT((*sle)[~sfIssuerKeyMirrorEpoch] == 1u); + } + + // Holder auditor-mirror migration. + { + Env env{*this, features}; + Account const alice("alice"); + Account const bob("bob"); + Account const auditor("auditor"); + Account const newAuditorKey("newAuditorKey"); + ConfidentialEnv ct{ + env, + alice, + {{.account = bob}}, + tfMPTCanHoldConfidentialBalance | tfMPTCanTransfer, + auditor}; + + ct.mpt.generateKeyPair(newAuditorKey); + ct.mpt.set({.account = alice, .auditorPubKey = ct.mpt.getPubKey(newAuditorKey)}); + + // The holder re-encrypts their own balance under the new auditor key. + Buffer const newAuditorCipher = + ct.mpt.encryptAmount(newAuditorKey, amount, generateBlindingFactor()); + + ct.mpt.mirrorUpdate({ + .account = bob, + .auditorEncryptedAmount = newAuditorCipher, + }); + + auto const sle = env.le(keylet::mptoken(ct.mpt.issuanceID(), bob.id())); + if (!BEAST_EXPECT(sle)) + return; + BEAST_EXPECT(strHex((*sle)[sfAuditorEncryptedBalance]) == strHex(newAuditorCipher)); + BEAST_EXPECT((*sle)[~sfAuditorKeyMirrorEpoch] == 1u); + } + + // Holder simultaneous migration of both mirrors. + { + Env env{*this, features}; + Account const alice("alice"); + Account const bob("bob"); + Account const auditor("auditor"); + Account const newIssuerKey("newIssuerKey"); + Account const newAuditorKey("newAuditorKey"); + ConfidentialEnv ct{ + env, + alice, + {{.account = bob}}, + tfMPTCanHoldConfidentialBalance | tfMPTCanTransfer, + auditor}; + + ct.mpt.generateKeyPair(newIssuerKey); + ct.mpt.generateKeyPair(newAuditorKey); + ct.mpt.set({ + .account = alice, + .issuerPubKey = ct.mpt.getPubKey(newIssuerKey), + .auditorPubKey = ct.mpt.getPubKey(newAuditorKey), + }); + + Buffer const bf = generateBlindingFactor(); + Buffer const newIssuerCipher = ct.mpt.encryptAmount(newIssuerKey, amount, bf); + Buffer const newAuditorCipher = ct.mpt.encryptAmount(newAuditorKey, amount, bf); + + // Holder mode needs no previous issuer key even for the issuer mirror. + ct.mpt.mirrorUpdate({ + .account = bob, + .issuerEncryptedAmount = newIssuerCipher, + .auditorEncryptedAmount = newAuditorCipher, + }); + + auto const sle = env.le(keylet::mptoken(ct.mpt.issuanceID(), bob.id())); + if (!BEAST_EXPECT(sle)) + return; + BEAST_EXPECT(strHex((*sle)[sfIssuerEncryptedBalance]) == strHex(newIssuerCipher)); + BEAST_EXPECT(strHex((*sle)[sfAuditorEncryptedBalance]) == strHex(newAuditorCipher)); + BEAST_EXPECT((*sle)[~sfIssuerKeyMirrorEpoch] == 1u); + BEAST_EXPECT((*sle)[~sfAuditorKeyMirrorEpoch] == 1u); + } + } + + void + testConfidentialMPTMirrorUpdateMultipleRotationsIssuerMode(FeatureBitset features) + { + testcase("ConfidentialMPTMirrorUpdate issuer migrates after several rotations"); + using namespace test::jtx; + + std::uint64_t const amount = 100; + + Env env{*this, features}; + Account const alice("alice"); + Account const bob("bob"); + Account const auditor("auditor"); + Account const issuerKey1("issuerKey1"); + Account const issuerKey2("issuerKey2"); + Account const issuerKey3("issuerKey3"); + Account const issuerKey4("issuerKey4"); + Account const issuerKey5("issuerKey5"); + Account const auditorKey1("auditorKey1"); + Account const auditorKey2("auditorKey2"); + Account const auditorKey3("auditorKey3"); + Account const auditorKey4("auditorKey4"); + ConfidentialEnv ct{ + env, + alice, + {{.account = bob}}, + tfMPTCanHoldConfidentialBalance | tfMPTCanTransfer, + auditor}; + + // Rotate the issuer key three times: issuer key epoch 0 -> 3. Bob never + // migrates in between, so his issuer mirror stays at mirror epoch 0 and + // is still encrypted under the original issuer key (alice's). + ct.mpt.generateKeyPair(issuerKey1); + ct.mpt.generateKeyPair(issuerKey2); + ct.mpt.generateKeyPair(issuerKey3); + ct.mpt.set({.account = alice, .issuerPubKey = ct.mpt.getPubKey(issuerKey1)}); + ct.mpt.set({.account = alice, .issuerPubKey = ct.mpt.getPubKey(issuerKey2)}); + ct.mpt.set({.account = alice, .issuerPubKey = ct.mpt.getPubKey(issuerKey3)}); + + { + auto const sleIssuance = env.le(keylet::mptokenIssuance(ct.mpt.issuanceID())); + BEAST_EXPECT(sleIssuance && (*sleIssuance)[~sfIssuerKeyEpoch] == 3u); + } + + // A single migration re-encrypts the mirror under the newest key and + // jumps the mirror epoch straight to the current key epoch (3), rather + // than advancing one rotation at a time. The previous issuer key is the + // original key (alice's) that the stale mirror is still encrypted under, + // not any intermediate rotation. + Buffer const newIssuerCipher = + ct.mpt.encryptAmount(issuerKey3, amount, generateBlindingFactor()); + + ct.mpt.mirrorUpdate({ + .account = alice, + .holder = bob, + .issuerEncryptedAmount = newIssuerCipher, + .previousIssuerKey = ct.mpt.getPubKey(alice), + }); + + { + auto const sle = env.le(keylet::mptoken(ct.mpt.issuanceID(), bob.id())); + if (!BEAST_EXPECT(sle)) + return; + BEAST_EXPECT(strHex((*sle)[sfIssuerEncryptedBalance]) == strHex(newIssuerCipher)); + BEAST_EXPECT((*sle)[~sfIssuerKeyMirrorEpoch] == 3u); + } + + // The issuer mirror is now current (epoch 3 == key epoch 3), so a second + // issuer migration is rejected. + ct.mpt.mirrorUpdate({ + .account = alice, + .holder = bob, + .issuerEncryptedAmount = newIssuerCipher, + .previousIssuerKey = ct.mpt.getPubKey(alice), + .err = tecNO_PERMISSION, + }); + + // Now rotate the auditor key twice: auditor key epoch 0 -> 2. Bob's + // auditor mirror is still at mirror epoch 0, under the original auditor + // key. The issuer key and its epoch are untouched. + ct.mpt.generateKeyPair(auditorKey1); + ct.mpt.generateKeyPair(auditorKey2); + ct.mpt.set({.account = alice, .auditorPubKey = ct.mpt.getPubKey(auditorKey1)}); + ct.mpt.set({.account = alice, .auditorPubKey = ct.mpt.getPubKey(auditorKey2)}); + + { + auto const sleIssuance = env.le(keylet::mptokenIssuance(ct.mpt.issuanceID())); + BEAST_EXPECT(sleIssuance && (*sleIssuance)[~sfAuditorKeyEpoch] == 2u); + BEAST_EXPECT(sleIssuance && (*sleIssuance)[~sfIssuerKeyEpoch] == 3u); + } + + // A single auditor-only migration jumps the auditor mirror epoch straight + // to the current auditor key epoch (2). This is an issuer-mode + // auditor-only migration, which is allowed because the issuer mirror is + // already current; no previous issuer key is needed for an auditor + // migration. + Buffer const newAuditorCipher = + ct.mpt.encryptAmount(auditorKey2, amount, generateBlindingFactor()); + + ct.mpt.mirrorUpdate({ + .account = alice, + .holder = bob, + .auditorEncryptedAmount = newAuditorCipher, + }); + + { + auto const sle = env.le(keylet::mptoken(ct.mpt.issuanceID(), bob.id())); + if (!BEAST_EXPECT(sle)) + return; + BEAST_EXPECT(strHex((*sle)[sfAuditorEncryptedBalance]) == strHex(newAuditorCipher)); + BEAST_EXPECT((*sle)[~sfAuditorKeyMirrorEpoch] == 2u); + // The issuer mirror and its epoch are unaffected by the auditor + // migration. + BEAST_EXPECT(strHex((*sle)[sfIssuerEncryptedBalance]) == strHex(newIssuerCipher)); + BEAST_EXPECT((*sle)[~sfIssuerKeyMirrorEpoch] == 3u); + } + + // The auditor mirror is now current (epoch 2 == key epoch 2), so a second + // auditor migration is rejected. + ct.mpt.mirrorUpdate({ + .account = alice, + .holder = bob, + .auditorEncryptedAmount = newAuditorCipher, + .err = tecNO_PERMISSION, + }); + + // Now rotate BOTH keys together twice: issuer key epoch 3 -> 5, auditor + // key epoch 2 -> 4. Bob's mirrors stay at epoch 3 / 2 (stale again). + ct.mpt.generateKeyPair(issuerKey4); + ct.mpt.generateKeyPair(issuerKey5); + ct.mpt.generateKeyPair(auditorKey3); + ct.mpt.generateKeyPair(auditorKey4); + ct.mpt.set({ + .account = alice, + .issuerPubKey = ct.mpt.getPubKey(issuerKey4), + .auditorPubKey = ct.mpt.getPubKey(auditorKey3), + }); + ct.mpt.set({ + .account = alice, + .issuerPubKey = ct.mpt.getPubKey(issuerKey5), + .auditorPubKey = ct.mpt.getPubKey(auditorKey4), + }); + + { + auto const sleIssuance = env.le(keylet::mptokenIssuance(ct.mpt.issuanceID())); + BEAST_EXPECT(sleIssuance && (*sleIssuance)[~sfIssuerKeyEpoch] == 5u); + BEAST_EXPECT(sleIssuance && (*sleIssuance)[~sfAuditorKeyEpoch] == 4u); + } + + // A single simultaneous migration brings both mirrors current in one + // transaction: issuer mirror epoch 3 -> 5, auditor mirror epoch 2 -> 4. + // The previous issuer key is issuerKey3, which is the key Bob's current + // (stale) issuer mirror is encrypted under after the earlier issuer + // migration, not alice's original key nor any intermediate rotation. + Buffer const bothIssuerCipher = + ct.mpt.encryptAmount(issuerKey5, amount, generateBlindingFactor()); + Buffer const bothAuditorCipher = + ct.mpt.encryptAmount(auditorKey4, amount, generateBlindingFactor()); + + ct.mpt.mirrorUpdate({ + .account = alice, + .holder = bob, + .issuerEncryptedAmount = bothIssuerCipher, + .auditorEncryptedAmount = bothAuditorCipher, + .previousIssuerKey = ct.mpt.getPubKey(issuerKey3), + }); + + { + auto const sle = env.le(keylet::mptoken(ct.mpt.issuanceID(), bob.id())); + if (!BEAST_EXPECT(sle)) + return; + BEAST_EXPECT(strHex((*sle)[sfIssuerEncryptedBalance]) == strHex(bothIssuerCipher)); + BEAST_EXPECT(strHex((*sle)[sfAuditorEncryptedBalance]) == strHex(bothAuditorCipher)); + BEAST_EXPECT((*sle)[~sfIssuerKeyMirrorEpoch] == 5u); + BEAST_EXPECT((*sle)[~sfAuditorKeyMirrorEpoch] == 4u); + } + + // Both mirrors are current now, so a second simultaneous migration is + // rejected. + ct.mpt.mirrorUpdate({ + .account = alice, + .holder = bob, + .issuerEncryptedAmount = bothIssuerCipher, + .auditorEncryptedAmount = bothAuditorCipher, + .previousIssuerKey = ct.mpt.getPubKey(issuerKey5), + .err = tecNO_PERMISSION, + }); + } + + void + testConfidentialMPTMirrorUpdateMultipleRotationsHolderMode(FeatureBitset features) + { + testcase("ConfidentialMPTMirrorUpdate holder migrates after several rotations"); + using namespace test::jtx; + + std::uint64_t const amount = 100; + + Env env{*this, features}; + Account const alice("alice"); + Account const bob("bob"); + Account const auditor("auditor"); + Account const issuerKey1("issuerKey1"); + Account const issuerKey2("issuerKey2"); + Account const issuerKey3("issuerKey3"); + Account const issuerKey4("issuerKey4"); + Account const issuerKey5("issuerKey5"); + Account const auditorKey1("auditorKey1"); + Account const auditorKey2("auditorKey2"); + Account const auditorKey3("auditorKey3"); + Account const auditorKey4("auditorKey4"); + ConfidentialEnv ct{ + env, + alice, + {{.account = bob}}, + tfMPTCanHoldConfidentialBalance | tfMPTCanTransfer, + auditor}; + + // In holder self-migration mode the holder submits (account = bob, no + // Holder field) and never provides a previous issuer key. + // Bob's inbox is canonical zero after the ConfidentialEnv merge. + + // Rotate the issuer key three times: issuer key epoch 0 -> 3. + ct.mpt.generateKeyPair(issuerKey1); + ct.mpt.generateKeyPair(issuerKey2); + ct.mpt.generateKeyPair(issuerKey3); + ct.mpt.set({.account = alice, .issuerPubKey = ct.mpt.getPubKey(issuerKey1)}); + ct.mpt.set({.account = alice, .issuerPubKey = ct.mpt.getPubKey(issuerKey2)}); + ct.mpt.set({.account = alice, .issuerPubKey = ct.mpt.getPubKey(issuerKey3)}); + + // A single holder migration jumps the issuer mirror epoch straight to 3. + Buffer const newIssuerCipher = + ct.mpt.encryptAmount(issuerKey3, amount, generateBlindingFactor()); + + ct.mpt.mirrorUpdate({ + .account = bob, + .issuerEncryptedAmount = newIssuerCipher, + }); + + { + auto const sle = env.le(keylet::mptoken(ct.mpt.issuanceID(), bob.id())); + if (!BEAST_EXPECT(sle)) + return; + BEAST_EXPECT(strHex((*sle)[sfIssuerEncryptedBalance]) == strHex(newIssuerCipher)); + BEAST_EXPECT((*sle)[~sfIssuerKeyMirrorEpoch] == 3u); + } + + // The issuer mirror is current, so a second holder issuer migration is + // rejected. + ct.mpt.mirrorUpdate({ + .account = bob, + .issuerEncryptedAmount = newIssuerCipher, + .err = tecNO_PERMISSION, + }); + + // Rotate the auditor key twice: auditor key epoch 0 -> 2. + ct.mpt.generateKeyPair(auditorKey1); + ct.mpt.generateKeyPair(auditorKey2); + ct.mpt.set({.account = alice, .auditorPubKey = ct.mpt.getPubKey(auditorKey1)}); + ct.mpt.set({.account = alice, .auditorPubKey = ct.mpt.getPubKey(auditorKey2)}); + + // A single holder auditor migration jumps the auditor mirror epoch to 2. + Buffer const newAuditorCipher = + ct.mpt.encryptAmount(auditorKey2, amount, generateBlindingFactor()); + + ct.mpt.mirrorUpdate({ + .account = bob, + .auditorEncryptedAmount = newAuditorCipher, + }); + + { + auto const sle = env.le(keylet::mptoken(ct.mpt.issuanceID(), bob.id())); + if (!BEAST_EXPECT(sle)) + return; + BEAST_EXPECT(strHex((*sle)[sfAuditorEncryptedBalance]) == strHex(newAuditorCipher)); + BEAST_EXPECT((*sle)[~sfAuditorKeyMirrorEpoch] == 2u); + // The issuer mirror is unaffected. + BEAST_EXPECT(strHex((*sle)[sfIssuerEncryptedBalance]) == strHex(newIssuerCipher)); + BEAST_EXPECT((*sle)[~sfIssuerKeyMirrorEpoch] == 3u); + } + + // The auditor mirror is current, so a second holder auditor migration is + // rejected. + ct.mpt.mirrorUpdate({ + .account = bob, + .auditorEncryptedAmount = newAuditorCipher, + .err = tecNO_PERMISSION, + }); + + // Rotate both keys together twice: issuer key epoch 3 -> 5, auditor key + // epoch 2 -> 4. + ct.mpt.generateKeyPair(issuerKey4); + ct.mpt.generateKeyPair(issuerKey5); + ct.mpt.generateKeyPair(auditorKey3); + ct.mpt.generateKeyPair(auditorKey4); + ct.mpt.set({ + .account = alice, + .issuerPubKey = ct.mpt.getPubKey(issuerKey4), + .auditorPubKey = ct.mpt.getPubKey(auditorKey3), + }); + ct.mpt.set({ + .account = alice, + .issuerPubKey = ct.mpt.getPubKey(issuerKey5), + .auditorPubKey = ct.mpt.getPubKey(auditorKey4), + }); + + // A single holder migration brings both mirrors current: issuer mirror + // epoch 3 -> 5, auditor mirror epoch 2 -> 4. Still no previous issuer key. + Buffer const bothIssuerCipher = + ct.mpt.encryptAmount(issuerKey5, amount, generateBlindingFactor()); + Buffer const bothAuditorCipher = + ct.mpt.encryptAmount(auditorKey4, amount, generateBlindingFactor()); + + ct.mpt.mirrorUpdate({ + .account = bob, + .issuerEncryptedAmount = bothIssuerCipher, + .auditorEncryptedAmount = bothAuditorCipher, + }); + + { + auto const sle = env.le(keylet::mptoken(ct.mpt.issuanceID(), bob.id())); + if (!BEAST_EXPECT(sle)) + return; + BEAST_EXPECT(strHex((*sle)[sfIssuerEncryptedBalance]) == strHex(bothIssuerCipher)); + BEAST_EXPECT(strHex((*sle)[sfAuditorEncryptedBalance]) == strHex(bothAuditorCipher)); + BEAST_EXPECT((*sle)[~sfIssuerKeyMirrorEpoch] == 5u); + BEAST_EXPECT((*sle)[~sfAuditorKeyMirrorEpoch] == 4u); + } + + // Both mirrors are current, so a second holder migration is rejected. + ct.mpt.mirrorUpdate({ + .account = bob, + .issuerEncryptedAmount = bothIssuerCipher, + .auditorEncryptedAmount = bothAuditorCipher, + .err = tecNO_PERMISSION, + }); + } + +public: void testMPTokenIssuanceSetWithFeats(FeatureBitset features) { @@ -617,7 +1914,6 @@ class ConfidentialMPTKeyRotation_test : public ConfidentialTransferTestBase testMPTokenIssuanceSetKeyEpochAtMax(features); } -public: void run() override { @@ -626,6 +1922,14 @@ public: testMPTokenIssuanceSetWithFeats(all); testMPTokenIssuanceSetWithFeats(all - featureConfidentialMPTKeyRotation); + + testConfidentialMPTMirrorUpdatePreflight(all); + testConfidentialMPTMirrorUpdatePreflight(all - featureConfidentialMPTKeyRotation); + testConfidentialMPTMirrorUpdatePreflight(all - featureConfidentialTransfer); + testConfidentialMPTMirrorUpdatePreclaim(all); + testConfidentialMPTMirrorUpdateDoApply(all); + testConfidentialMPTMirrorUpdateMultipleRotationsIssuerMode(all); + testConfidentialMPTMirrorUpdateMultipleRotationsHolderMode(all); } }; diff --git a/src/test/jtx/impl/mpt.cpp b/src/test/jtx/impl/mpt.cpp index 0c1ff14eab..66a6333dca 100644 --- a/src/test/jtx/impl/mpt.cpp +++ b/src/test/jtx/impl/mpt.cpp @@ -2556,4 +2556,52 @@ MPTTester::convertBackJV(MPTConvertBack const& arg, std::uint32_t seq) return jv; } +void +MPTTester::mirrorUpdate(MPTMirrorUpdate const& arg) +{ + json::Value jv; + if (arg.account) + { + jv[sfAccount] = arg.account->human(); + } + else + { + Throw("Account not specified"); + } + + if (arg.id) + { + jv[sfMPTokenIssuanceID] = to_string(*arg.id); + } + else + { + if (!id_) + Throw("MPT has not been created"); + jv[sfMPTokenIssuanceID] = to_string(*id_); + } + + jv[sfTransactionType] = jss::ConfidentialMPTMirrorUpdate; + + if (arg.holder) + jv[sfHolder] = arg.holder->human(); + if (arg.issuerEncryptedAmount) + jv[sfIssuerEncryptedAmount] = strHex(*arg.issuerEncryptedAmount); + if (arg.auditorEncryptedAmount) + jv[sfAuditorEncryptedAmount] = strHex(*arg.auditorEncryptedAmount); + if (arg.previousIssuerKey) + jv[sfPreviousIssuerEncryptionKey] = strHex(*arg.previousIssuerKey); + + // Placeholder for proof, the logic will be added in the future + if (arg.zkProof) + { + jv[sfZKProof] = strHex(*arg.zkProof); + } + else + { + jv[sfZKProof] = strHex(gMakeZeroBuffer(kEcGamalEncryptedTotalLength)); + } + + submit(arg, jv); +} + } // namespace xrpl::test::jtx diff --git a/src/test/jtx/impl/utility.cpp b/src/test/jtx/impl/utility.cpp index f83cb7772c..6b2c9b69b9 100644 --- a/src/test/jtx/impl/utility.cpp +++ b/src/test/jtx/impl/utility.cpp @@ -78,7 +78,7 @@ 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::ConfidentialMPTClawback || txType == jss::ConfidentialMPTMirrorUpdate) { jv[jss::Fee] = to_string(base * (kConfidentialFeeMultiplier + 1)); } diff --git a/src/test/jtx/mpt.h b/src/test/jtx/mpt.h index 26329ad78c..bdcbb67beb 100644 --- a/src/test/jtx/mpt.h +++ b/src/test/jtx/mpt.h @@ -361,6 +361,25 @@ struct MPTConfidentialClawback std::optional err = std::nullopt; }; +/** + * @brief Arguments for building a ConfidentialMPTMirrorUpdate test transaction. + */ +struct MPTMirrorUpdate +{ + std::optional account = std::nullopt; + std::optional holder = std::nullopt; + std::optional id = std::nullopt; + std::optional issuerEncryptedAmount = std::nullopt; + std::optional auditorEncryptedAmount = std::nullopt; + std::optional previousIssuerKey = std::nullopt; + std::optional zkProof = std::nullopt; + std::optional fee = std::nullopt; + std::optional flags = std::nullopt; + std::optional ownerCount = std::nullopt; + std::optional holderCount = std::nullopt; + std::optional err = std::nullopt; +}; + /** * @brief Stores the parameters that are exclusively used to generate a * Pedersen linkage proof. @@ -581,6 +600,9 @@ public: void confidentialClaw(MPTConfidentialClawback const& arg = MPTConfidentialClawback{}); + void + mirrorUpdate(MPTMirrorUpdate const& arg = MPTMirrorUpdate{}); + [[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 7db4b638a7..41eff1cce7 100644 --- a/src/tests/libxrpl/protocol_autogen/ledger_entries/MPTokenTests.cpp +++ b/src/tests/libxrpl/protocol_autogen/ledger_entries/MPTokenTests.cpp @@ -32,6 +32,8 @@ TEST(MPTokenTests, BuilderSettersRoundTrip) auto const confidentialBalanceVersionValue = canonical_UINT32(); auto const issuerEncryptedBalanceValue = canonical_VL(); auto const auditorEncryptedBalanceValue = canonical_VL(); + auto const issuerKeyMirrorEpochValue = canonical_UINT32(); + auto const auditorKeyMirrorEpochValue = canonical_UINT32(); auto const holderEncryptionKeyValue = canonical_VL(); MPTokenBuilder builder{ @@ -49,6 +51,8 @@ TEST(MPTokenTests, BuilderSettersRoundTrip) builder.setConfidentialBalanceVersion(confidentialBalanceVersionValue); builder.setIssuerEncryptedBalance(issuerEncryptedBalanceValue); builder.setAuditorEncryptedBalance(auditorEncryptedBalanceValue); + builder.setIssuerKeyMirrorEpoch(issuerKeyMirrorEpochValue); + builder.setAuditorKeyMirrorEpoch(auditorKeyMirrorEpochValue); builder.setHolderEncryptionKey(holderEncryptionKeyValue); builder.setLedgerIndex(index); @@ -146,6 +150,22 @@ TEST(MPTokenTests, BuilderSettersRoundTrip) EXPECT_TRUE(entry.hasAuditorEncryptedBalance()); } + { + auto const& expected = issuerKeyMirrorEpochValue; + auto const actualOpt = entry.getIssuerKeyMirrorEpoch(); + ASSERT_TRUE(actualOpt.has_value()); + expectEqualField(expected, *actualOpt, "sfIssuerKeyMirrorEpoch"); + EXPECT_TRUE(entry.hasIssuerKeyMirrorEpoch()); + } + + { + auto const& expected = auditorKeyMirrorEpochValue; + auto const actualOpt = entry.getAuditorKeyMirrorEpoch(); + ASSERT_TRUE(actualOpt.has_value()); + expectEqualField(expected, *actualOpt, "sfAuditorKeyMirrorEpoch"); + EXPECT_TRUE(entry.hasAuditorKeyMirrorEpoch()); + } + { auto const& expected = holderEncryptionKeyValue; auto const actualOpt = entry.getHolderEncryptionKey(); @@ -179,6 +199,8 @@ TEST(MPTokenTests, BuilderFromSleRoundTrip) auto const confidentialBalanceVersionValue = canonical_UINT32(); auto const issuerEncryptedBalanceValue = canonical_VL(); auto const auditorEncryptedBalanceValue = canonical_VL(); + auto const issuerKeyMirrorEpochValue = canonical_UINT32(); + auto const auditorKeyMirrorEpochValue = canonical_UINT32(); auto const holderEncryptionKeyValue = canonical_VL(); auto sle = std::make_shared(MPToken::entryType, index); @@ -195,6 +217,8 @@ TEST(MPTokenTests, BuilderFromSleRoundTrip) sle->at(sfConfidentialBalanceVersion) = confidentialBalanceVersionValue; sle->at(sfIssuerEncryptedBalance) = issuerEncryptedBalanceValue; sle->at(sfAuditorEncryptedBalance) = auditorEncryptedBalanceValue; + sle->at(sfIssuerKeyMirrorEpoch) = issuerKeyMirrorEpochValue; + sle->at(sfAuditorKeyMirrorEpoch) = auditorKeyMirrorEpochValue; sle->at(sfHolderEncryptionKey) = holderEncryptionKeyValue; MPTokenBuilder builderFromSle{sle}; @@ -347,6 +371,32 @@ TEST(MPTokenTests, BuilderFromSleRoundTrip) expectEqualField(expected, *fromBuilderOpt, "sfAuditorEncryptedBalance"); } + { + auto const& expected = issuerKeyMirrorEpochValue; + + auto const fromSleOpt = entryFromSle.getIssuerKeyMirrorEpoch(); + auto const fromBuilderOpt = entryFromBuilder.getIssuerKeyMirrorEpoch(); + + ASSERT_TRUE(fromSleOpt.has_value()); + ASSERT_TRUE(fromBuilderOpt.has_value()); + + expectEqualField(expected, *fromSleOpt, "sfIssuerKeyMirrorEpoch"); + expectEqualField(expected, *fromBuilderOpt, "sfIssuerKeyMirrorEpoch"); + } + + { + auto const& expected = auditorKeyMirrorEpochValue; + + auto const fromSleOpt = entryFromSle.getAuditorKeyMirrorEpoch(); + auto const fromBuilderOpt = entryFromBuilder.getAuditorKeyMirrorEpoch(); + + ASSERT_TRUE(fromSleOpt.has_value()); + ASSERT_TRUE(fromBuilderOpt.has_value()); + + expectEqualField(expected, *fromSleOpt, "sfAuditorKeyMirrorEpoch"); + expectEqualField(expected, *fromBuilderOpt, "sfAuditorKeyMirrorEpoch"); + } + { auto const& expected = holderEncryptionKeyValue; @@ -436,6 +486,10 @@ TEST(MPTokenTests, OptionalFieldsReturnNullopt) EXPECT_FALSE(entry.getIssuerEncryptedBalance().has_value()); EXPECT_FALSE(entry.hasAuditorEncryptedBalance()); EXPECT_FALSE(entry.getAuditorEncryptedBalance().has_value()); + EXPECT_FALSE(entry.hasIssuerKeyMirrorEpoch()); + EXPECT_FALSE(entry.getIssuerKeyMirrorEpoch().has_value()); + EXPECT_FALSE(entry.hasAuditorKeyMirrorEpoch()); + EXPECT_FALSE(entry.getAuditorKeyMirrorEpoch().has_value()); EXPECT_FALSE(entry.hasHolderEncryptionKey()); EXPECT_FALSE(entry.getHolderEncryptionKey().has_value()); } diff --git a/src/tests/libxrpl/protocol_autogen/transactions/ConfidentialMPTMirrorUpdateTests.cpp b/src/tests/libxrpl/protocol_autogen/transactions/ConfidentialMPTMirrorUpdateTests.cpp new file mode 100644 index 0000000000..d3d99f94af --- /dev/null +++ b/src/tests/libxrpl/protocol_autogen/transactions/ConfidentialMPTMirrorUpdateTests.cpp @@ -0,0 +1,276 @@ +// Auto-generated unit tests for transaction ConfidentialMPTMirrorUpdate + + +#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(TransactionsConfidentialMPTMirrorUpdateTests, BuilderSettersRoundTrip) +{ + // Generate a deterministic keypair for signing + auto const [publicKey, secretKey] = + generateKeyPair(KeyType::Secp256k1, generateSeed("testConfidentialMPTMirrorUpdate")); + + // 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 holderValue = canonical_ACCOUNT(); + auto const issuerEncryptedAmountValue = canonical_VL(); + auto const auditorEncryptedAmountValue = canonical_VL(); + auto const previousIssuerEncryptionKeyValue = canonical_VL(); + auto const zKProofValue = canonical_VL(); + + ConfidentialMPTMirrorUpdateBuilder builder{ + accountValue, + mPTokenIssuanceIDValue, + zKProofValue, + sequenceValue, + feeValue + }; + + // Set optional fields + builder.setHolder(holderValue); + builder.setIssuerEncryptedAmount(issuerEncryptedAmountValue); + builder.setAuditorEncryptedAmount(auditorEncryptedAmountValue); + builder.setPreviousIssuerEncryptionKey(previousIssuerEncryptionKeyValue); + + 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"); + } + + { + auto const& expected = zKProofValue; + auto const actual = tx.getZKProof(); + expectEqualField(expected, actual, "sfZKProof"); + } + + // Verify optional fields + { + auto const& expected = holderValue; + auto const actualOpt = tx.getHolder(); + ASSERT_TRUE(actualOpt.has_value()) << "Optional field sfHolder should be present"; + expectEqualField(expected, *actualOpt, "sfHolder"); + EXPECT_TRUE(tx.hasHolder()); + } + + { + auto const& expected = issuerEncryptedAmountValue; + auto const actualOpt = tx.getIssuerEncryptedAmount(); + ASSERT_TRUE(actualOpt.has_value()) << "Optional field sfIssuerEncryptedAmount should be present"; + expectEqualField(expected, *actualOpt, "sfIssuerEncryptedAmount"); + EXPECT_TRUE(tx.hasIssuerEncryptedAmount()); + } + + { + auto const& expected = auditorEncryptedAmountValue; + auto const actualOpt = tx.getAuditorEncryptedAmount(); + ASSERT_TRUE(actualOpt.has_value()) << "Optional field sfAuditorEncryptedAmount should be present"; + expectEqualField(expected, *actualOpt, "sfAuditorEncryptedAmount"); + EXPECT_TRUE(tx.hasAuditorEncryptedAmount()); + } + + { + auto const& expected = previousIssuerEncryptionKeyValue; + auto const actualOpt = tx.getPreviousIssuerEncryptionKey(); + ASSERT_TRUE(actualOpt.has_value()) << "Optional field sfPreviousIssuerEncryptionKey should be present"; + expectEqualField(expected, *actualOpt, "sfPreviousIssuerEncryptionKey"); + EXPECT_TRUE(tx.hasPreviousIssuerEncryptionKey()); + } + +} + +// 2 & 4) Start from an STTx, construct a builder from it, build a new wrapper, +// and verify all fields match. +TEST(TransactionsConfidentialMPTMirrorUpdateTests, BuilderFromStTxRoundTrip) +{ + // Generate a deterministic keypair for signing + auto const [publicKey, secretKey] = + generateKeyPair(KeyType::Secp256k1, generateSeed("testConfidentialMPTMirrorUpdateFromTx")); + + // 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 holderValue = canonical_ACCOUNT(); + auto const issuerEncryptedAmountValue = canonical_VL(); + auto const auditorEncryptedAmountValue = canonical_VL(); + auto const previousIssuerEncryptionKeyValue = canonical_VL(); + auto const zKProofValue = canonical_VL(); + + // Build an initial transaction + ConfidentialMPTMirrorUpdateBuilder initialBuilder{ + accountValue, + mPTokenIssuanceIDValue, + zKProofValue, + sequenceValue, + feeValue + }; + + initialBuilder.setHolder(holderValue); + initialBuilder.setIssuerEncryptedAmount(issuerEncryptedAmountValue); + initialBuilder.setAuditorEncryptedAmount(auditorEncryptedAmountValue); + initialBuilder.setPreviousIssuerEncryptionKey(previousIssuerEncryptionKeyValue); + + auto initialTx = initialBuilder.build(publicKey, secretKey); + + // Create builder from existing STTx + ConfidentialMPTMirrorUpdateBuilder 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"); + } + + { + auto const& expected = zKProofValue; + auto const actual = rebuiltTx.getZKProof(); + expectEqualField(expected, actual, "sfZKProof"); + } + + // Verify optional fields + { + auto const& expected = holderValue; + auto const actualOpt = rebuiltTx.getHolder(); + ASSERT_TRUE(actualOpt.has_value()) << "Optional field sfHolder should be present"; + expectEqualField(expected, *actualOpt, "sfHolder"); + } + + { + auto const& expected = issuerEncryptedAmountValue; + auto const actualOpt = rebuiltTx.getIssuerEncryptedAmount(); + ASSERT_TRUE(actualOpt.has_value()) << "Optional field sfIssuerEncryptedAmount should be present"; + expectEqualField(expected, *actualOpt, "sfIssuerEncryptedAmount"); + } + + { + auto const& expected = auditorEncryptedAmountValue; + auto const actualOpt = rebuiltTx.getAuditorEncryptedAmount(); + ASSERT_TRUE(actualOpt.has_value()) << "Optional field sfAuditorEncryptedAmount should be present"; + expectEqualField(expected, *actualOpt, "sfAuditorEncryptedAmount"); + } + + { + auto const& expected = previousIssuerEncryptionKeyValue; + auto const actualOpt = rebuiltTx.getPreviousIssuerEncryptionKey(); + ASSERT_TRUE(actualOpt.has_value()) << "Optional field sfPreviousIssuerEncryptionKey should be present"; + expectEqualField(expected, *actualOpt, "sfPreviousIssuerEncryptionKey"); + } + +} + +// 3) Verify wrapper throws when constructed from wrong transaction type. +TEST(TransactionsConfidentialMPTMirrorUpdateTests, 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(ConfidentialMPTMirrorUpdate{wrongTx.getSTTx()}, std::runtime_error); +} + +// 4) Verify builder throws when constructed from wrong transaction type. +TEST(TransactionsConfidentialMPTMirrorUpdateTests, 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(ConfidentialMPTMirrorUpdateBuilder{wrongTx.getSTTx()}, std::runtime_error); +} + +// 5) Build with only required fields and verify optional fields return nullopt. +TEST(TransactionsConfidentialMPTMirrorUpdateTests, OptionalFieldsReturnNullopt) +{ + // Generate a deterministic keypair for signing + auto const [publicKey, secretKey] = + generateKeyPair(KeyType::Secp256k1, generateSeed("testConfidentialMPTMirrorUpdateNullopt")); + + // 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(); + auto const zKProofValue = canonical_VL(); + + ConfidentialMPTMirrorUpdateBuilder builder{ + accountValue, + mPTokenIssuanceIDValue, + zKProofValue, + sequenceValue, + feeValue + }; + + // Do NOT set optional fields + + auto tx = builder.build(publicKey, secretKey); + + // Verify optional fields are not present + EXPECT_FALSE(tx.hasHolder()); + EXPECT_FALSE(tx.getHolder().has_value()); + EXPECT_FALSE(tx.hasIssuerEncryptedAmount()); + EXPECT_FALSE(tx.getIssuerEncryptedAmount().has_value()); + EXPECT_FALSE(tx.hasAuditorEncryptedAmount()); + EXPECT_FALSE(tx.getAuditorEncryptedAmount().has_value()); + EXPECT_FALSE(tx.hasPreviousIssuerEncryptionKey()); + EXPECT_FALSE(tx.getPreviousIssuerEncryptionKey().has_value()); +} + +}