From 0219c01b33f2e24da7305d42fbfe93d237dfa465 Mon Sep 17 00:00:00 2001 From: yinyiqian1 Date: Tue, 22 Sep 2026 17:02:59 +0000 Subject: [PATCH 01/10] fix: Check zero object id in SponsorshipTransfer preflight (#8254) Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- .../sponsor/SponsorshipTransfer.cpp | 8 ++++++++ src/test/app/Sponsor_test.cpp | 19 +++++++++++++++++++ 2 files changed, 27 insertions(+) diff --git a/src/libxrpl/tx/transactors/sponsor/SponsorshipTransfer.cpp b/src/libxrpl/tx/transactors/sponsor/SponsorshipTransfer.cpp index c3131714f8..642a415be7 100644 --- a/src/libxrpl/tx/transactors/sponsor/SponsorshipTransfer.cpp +++ b/src/libxrpl/tx/transactors/sponsor/SponsorshipTransfer.cpp @@ -2,6 +2,7 @@ #include #include +#include #include #include #include @@ -206,6 +207,13 @@ SponsorshipTransfer::preflight(PreflightContext const& ctx) return temMALFORMED; } + if (auto const objectID = ctx.tx[~sfObjectID]; + ctx.rules.enabled(fixCleanup3_5_0) && objectID && *objectID == beast::kZero) + { + JLOG(ctx.j.debug()) << "preflight: sfObjectID must not be zero"; + return temMALFORMED; + } + return tesSUCCESS; } diff --git a/src/test/app/Sponsor_test.cpp b/src/test/app/Sponsor_test.cpp index 71d968014f..22b5d0bcdc 100644 --- a/src/test/app/Sponsor_test.cpp +++ b/src/test/app/Sponsor_test.cpp @@ -1163,6 +1163,25 @@ public: sponsor::SponseeAcc(alice), Ter(temMALFORMED)); } + + // Post-fixCleanup3_5_0, a zero ObjectID is malformed. + // Pre-fixCleanup3_5_0 path is unreachable so it is not testable. + if (features[fixCleanup3_5_0]) + { + uint256 const zeroObjectID{}; + + env(sponsor::transfer(alice, tfSponsorshipEnd, zeroObjectID), Ter(temMALFORMED)); + + env(sponsor::transfer(alice, tfSponsorshipCreate, zeroObjectID), + sponsor::As(sponsor, spfSponsorReserve), + Sig(sfSponsorSignature, sponsor), + Ter(temMALFORMED)); + + env(sponsor::transfer(alice, tfSponsorshipReassign, zeroObjectID), + sponsor::As(sponsor, spfSponsorReserve), + Sig(sfSponsorSignature, sponsor), + Ter(temMALFORMED)); + } } { From 8b1a2282c3e19e495f4812623e07cf85bcc68427 Mon Sep 17 00:00:00 2001 From: yinyiqian1 Date: Tue, 22 Sep 2026 17:56:52 +0000 Subject: [PATCH 02/10] feat: Support ConfidentialMPTMirrorUpdate (#8192) --- include/xrpl/protocol/ConfidentialTransfer.h | 24 + include/xrpl/protocol/Protocol.h | 5 + .../xrpl/protocol/detail/transactions.macro | 13 + .../ConfidentialMPTMirrorUpdate.h | 266 ++++ .../token/ConfidentialMPTMirrorUpdate.h | 98 ++ src/libxrpl/protocol/ConfidentialTransfer.cpp | 36 +- src/libxrpl/tx/invariants/MPTInvariant.cpp | 1 + .../token/ConfidentialMPTMirrorUpdate.cpp | 273 ++++ .../app/ConfidentialMPTKeyRotation_test.cpp | 1204 ++++++++++++++++- src/test/app/ConfidentialTransfer_test.cpp | 18 + src/test/app/Delegate_test.cpp | 2 +- src/test/jtx/impl/mpt.cpp | 29 + src/test/jtx/impl/utility.cpp | 2 +- src/test/jtx/mpt.h | 21 + .../ConfidentialMPTMirrorUpdateTests.cpp | 255 ++++ 15 files changed, 2236 insertions(+), 11 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/ConfidentialTransfer.h b/include/xrpl/protocol/ConfidentialTransfer.h index 3706ef94e3..5c52fb0ba3 100644 --- a/include/xrpl/protocol/ConfidentialTransfer.h +++ b/include/xrpl/protocol/ConfidentialTransfer.h @@ -347,6 +347,30 @@ isAuditorMirrorCurrent(SLE const& issuance, SLE const& mptoken); [[nodiscard]] bool areMirrorsCurrent(SLE const& issuance, SLE const& mptoken); +/** + * @brief Set the holder's issuer mirror epoch to match the issuance's current issuer key epoch. + * + * Call this after writing the issuer mirror ciphertext under the issuance's + * currently registered issuer key, so that the mirror reads as current afterwards. + * + * @param issuance The MPTokenIssuance ledger object. + * @param mptoken The holder's MPToken ledger entry to update. + */ +void +setIssuerMirrorEpoch(SLE const& issuance, SLE& mptoken); + +/** + * @brief Set the holder's auditor mirror epoch to match the issuance's current auditor key epoch. + * + * Call this after writing the auditor mirror ciphertext under the issuance's + * currently registered auditor key. Does nothing when the holder has no auditor mirror. + * + * @param issuance The MPTokenIssuance ledger object. + * @param mptoken The holder's MPToken ledger entry to update. + */ +void +setAuditorMirrorEpoch(SLE const& issuance, SLE& mptoken); + /** * @brief Set the holder's MPToken mirror epochs to match the issuance's current key epochs. * diff --git a/include/xrpl/protocol/Protocol.h b/include/xrpl/protocol/Protocol.h index 0170cbb88a..61f246c752 100644 --- a/include/xrpl/protocol/Protocol.h +++ b/include/xrpl/protocol/Protocol.h @@ -540,6 +540,11 @@ constexpr std::size_t kEcConvertBackProofLength = */ constexpr std::size_t kEcClawbackProofLength = SECP256K1_COMPACT_CLAWBACK_PROOF_SIZE; +/** + * Length of compact equality proof. + */ +constexpr std::size_t kEcEqualityProofLength = 128; + /** * Extra base fee multiplier charged to confidential MPT transactions. */ diff --git a/include/xrpl/protocol/detail/transactions.macro b/include/xrpl/protocol/detail/transactions.macro index 454aa85ffd..cb3d5fd2b1 100644 --- a/include/xrpl/protocol/detail/transactions.macro +++ b/include/xrpl/protocol/detail/transactions.macro @@ -1134,6 +1134,19 @@ TRANSACTION(ttSPONSORSHIP_SET, 91, SponsorshipSet, {sfRemainingOwnerCountDelta, SoeOptional}, })) +#if TRANSACTION_INCLUDE +# include +#endif +TRANSACTION(ttCONFIDENTIAL_MPT_MIRROR_UPDATE, 92, ConfidentialMPTMirrorUpdate, + ({.delegable = Delegation::Delegable, .amendment = featureConfidentialMPTKeyRotation}), + ({ + {sfMPTokenIssuanceID, SoeRequired}, + {sfHolder, SoeOptional}, + {sfIssuerEncryptedAmount, SoeOptional}, + {sfAuditorEncryptedAmount, 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/transactions/ConfidentialMPTMirrorUpdate.h b/include/xrpl/protocol_autogen/transactions/ConfidentialMPTMirrorUpdate.h new file mode 100644 index 0000000000..1fc25bca99 --- /dev/null +++ b/include/xrpl/protocol_autogen/transactions/ConfidentialMPTMirrorUpdate.h @@ -0,0 +1,266 @@ +// 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::Delegable + * Amendment: featureConfidentialMPTKeyRotation + * Privileges: Privilege::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 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 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..12ad5c8f28 --- /dev/null +++ b/include/xrpl/tx/transactors/token/ConfidentialMPTMirrorUpdate.h @@ -0,0 +1,98 @@ +#pragma once + +#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. 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). This mode is always + * available to the holder and is not conditioned on the issuer being unable to migrate + * them: the ledger cannot verify whether an issuer has really lost its private key. That + * loss is only the expected motivation, since an issuer that still holds its key can + * migrate holders itself in issuer mode. + * @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. This is the + * holder-driven counterpart to flows 2 and 4, for when the issuer does not migrate + * the holder itself. + * + * 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, SLE::const_ref before, SLE::const_ref 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/protocol/ConfidentialTransfer.cpp b/src/libxrpl/protocol/ConfidentialTransfer.cpp index 99ae066475..a3e48b5f31 100644 --- a/src/libxrpl/protocol/ConfidentialTransfer.cpp +++ b/src/libxrpl/protocol/ConfidentialTransfer.cpp @@ -435,21 +435,41 @@ areMirrorsCurrent(SLE const& issuance, SLE const& mptoken) } void -setMirrorEpochs(SLE const& issuance, SLE& mptoken) +setIssuerMirrorEpoch(SLE const& issuance, SLE& mptoken) { XRPL_ASSERT( issuance.getType() == ltMPTOKEN_ISSUANCE, - "xrpl::setMirrorEpochs : issuance MPTokenIssuance object"); - XRPL_ASSERT(mptoken.getType() == ltMPTOKEN, "xrpl::setMirrorEpochs : mptoken MPToken object"); + "xrpl::setIssuerMirrorEpoch : issuance MPTokenIssuance object"); + XRPL_ASSERT( + mptoken.getType() == ltMPTOKEN, "xrpl::setIssuerMirrorEpoch : mptoken MPToken object"); + // Unlike the auditor mirror, the issuer mirror is not optional: every + // confidential MPToken carries one, so there is no existence check here. if (auto const epoch = issuance[~sfIssuerKeyEpoch].value_or(0); epoch != 0) mptoken[sfIssuerKeyMirrorEpoch] = epoch; +} - if (mptoken.isFieldPresent(sfAuditorEncryptedBalance)) - { - if (auto const epoch = issuance[~sfAuditorKeyEpoch].value_or(0); epoch != 0) - mptoken[sfAuditorKeyMirrorEpoch] = epoch; - } +void +setAuditorMirrorEpoch(SLE const& issuance, SLE& mptoken) +{ + XRPL_ASSERT( + issuance.getType() == ltMPTOKEN_ISSUANCE, + "xrpl::setAuditorMirrorEpoch : issuance MPTokenIssuance object"); + XRPL_ASSERT( + mptoken.getType() == ltMPTOKEN, "xrpl::setAuditorMirrorEpoch : mptoken MPToken object"); + + if (!mptoken.isFieldPresent(sfAuditorEncryptedBalance)) + return; + + if (auto const epoch = issuance[~sfAuditorKeyEpoch].value_or(0); epoch != 0) + mptoken[sfAuditorKeyMirrorEpoch] = epoch; +} + +void +setMirrorEpochs(SLE const& issuance, SLE& mptoken) +{ + setIssuerMirrorEpoch(issuance, mptoken); + setAuditorMirrorEpoch(issuance, mptoken); } TER diff --git a/src/libxrpl/tx/invariants/MPTInvariant.cpp b/src/libxrpl/tx/invariants/MPTInvariant.cpp index 46d1037acf..060673c6e1 100644 --- a/src/libxrpl/tx/invariants/MPTInvariant.cpp +++ b/src/libxrpl/tx/invariants/MPTInvariant.cpp @@ -40,6 +40,7 @@ constexpr auto kConfidentialMptTxTypes = std::to_array({ ttCONFIDENTIAL_MPT_CONVERT_BACK, ttCONFIDENTIAL_MPT_MERGE_INBOX, ttCONFIDENTIAL_MPT_CLAWBACK, + ttCONFIDENTIAL_MPT_MIRROR_UPDATE, }); // Clamp to the cap (== INT64_MAX) before the signed conversion. Invariant diff --git a/src/libxrpl/tx/transactors/token/ConfidentialMPTMirrorUpdate.cpp b/src/libxrpl/tx/transactors/token/ConfidentialMPTMirrorUpdate.cpp new file mode 100644 index 0000000000..c00486e5e6 --- /dev/null +++ b/src/libxrpl/tx/transactors/token/ConfidentialMPTMirrorUpdate.cpp @@ -0,0 +1,273 @@ +#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; + + // 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 proof length. + if (ctx.tx[sfZKProof].length() != kEcEqualityProofLength) + return temMALFORMED; + + // 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; + + 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; + + // An issuer mirror may only be re-encrypted while it is stale, reject if it is already current. + if (hasIssuerAmount && isIssuerMirrorCurrent(*sleIssuance, *sleMptoken)) + return tecNO_PERMISSION; + + if (hasAuditorAmount) + { + // An issuer-mode auditor-only migration: the issuer mirror must already be up to date. + if (hasHolder && !hasIssuerAmount && !isIssuerMirrorCurrent(*sleIssuance, *sleMptoken)) + return tecNO_PERMISSION; + + // An auditor mirror may only be re-encrypted while it is stale, reject if it is already + // current. isAuditorMirrorCurrent reports an absent auditor mirror as stale, which is what + // allows an auditor-only migration to create one for the first time. + if (isAuditorMirrorCurrent(*sleIssuance, *sleMptoken)) + 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; + } + + 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 tecINTERNAL; + // 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 tecINTERNAL; + // LCOV_EXCL_STOP + } + + // Re-encrypt the requested mirror(s) and advance the corresponding mirror + // epoch to match the issuance key epoch. Each mirror is stamped separately + // because this transaction may migrate either one or both. + if (ctx_.tx.isFieldPresent(sfIssuerEncryptedAmount)) + { + (*sleMptoken)[sfIssuerEncryptedBalance] = ctx_.tx[sfIssuerEncryptedAmount]; + setIssuerMirrorEpoch(*sleIssuance, *sleMptoken); + } + + if (ctx_.tx.isFieldPresent(sfAuditorEncryptedAmount)) + { + (*sleMptoken)[sfAuditorEncryptedBalance] = ctx_.tx[sfAuditorEncryptedAmount]; + setAuditorMirrorEpoch(*sleIssuance, *sleMptoken); + } + + view().update(sleMptoken); + return tesSUCCESS; +} + +void +ConfidentialMPTMirrorUpdate::visitInvariantEntry(bool, SLE::const_ref, SLE::const_ref) +{ +} + +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 3f0f64d89d..0db9e29d27 100644 --- a/src/test/app/ConfidentialMPTKeyRotation_test.cpp +++ b/src/test/app/ConfidentialMPTKeyRotation_test.cpp @@ -3,9 +3,12 @@ #include #include +#include +#include #include #include #include +#include #include #include #include @@ -1259,6 +1262,1198 @@ class ConfidentialMPTKeyRotation_test : public ConfidentialTransferTestBase } } + 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, + }); + // Issuer mode but account is not the issuer. + mptAlice.mirrorUpdate({ + .account = bob, + .holder = carol, + .issuerEncryptedAmount = validCipher, + .err = temMALFORMED, + }); + + // Issuer mode but the holder is the same as the issuer. + mptAlice.mirrorUpdate({ + .account = alice, + .holder = alice, + .issuerEncryptedAmount = validCipher, + .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, + }); + + // Issuer amount has the wrong length. + mptAlice.mirrorUpdate({ + .account = alice, + .holder = bob, + .issuerEncryptedAmount = gMakeZeroBuffer(10), + .err = temBAD_CIPHERTEXT, + }); + + // Auditor amount has the wrong length. + mptAlice.mirrorUpdate({ + .account = alice, + .holder = bob, + .auditorEncryptedAmount = gMakeZeroBuffer(10), + .err = temBAD_CIPHERTEXT, + }); + + // The proof has the wrong length. + mptAlice.mirrorUpdate({ + .account = alice, + .holder = bob, + .issuerEncryptedAmount = validCipher, + .zkProof = gMakeZeroBuffer(kEcEqualityProofLength - 1), + .err = temMALFORMED, + }); + + // Issuer amount is the right length but not a valid ciphertext. + mptAlice.mirrorUpdate({ + .account = alice, + .holder = bob, + .issuerEncryptedAmount = getBadCiphertext(), + .err = temBAD_CIPHERTEXT, + }); + + // Auditor amount is the right length but not a valid ciphertext. + mptAlice.mirrorUpdate({ + .account = alice, + .holder = bob, + .issuerEncryptedAmount = validCipher, + .auditorEncryptedAmount = getBadCiphertext(), + .err = temBAD_CIPHERTEXT, + }); + } + + void + testConfidentialMPTMirrorUpdatePreclaim(FeatureBitset features) + { + testcase("ConfidentialMPTMirrorUpdate preclaim"); + using namespace test::jtx; + + Buffer const& validCipher = getTrivialCiphertext(); + + // 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 has 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, + .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, + .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, + .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, + .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, + .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, + .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, + .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, + .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, + }); + } + + // A lock does not block a migration. + { + Env env{*this, features}; + Account const alice("alice"); + Account const bob("bob"); + Account const carol("carol"); + Account const newIssuerKey("newIssuerKey"); + Account const newerIssuerKey("newerIssuerKey"); + + ConfidentialEnv ct{env, alice, {{.account = bob}, {.account = carol}}}; + ct.mpt.set({.account = alice, .holder = bob, .flags = tfMPTLock}); + ct.mpt.set({.account = alice, .holder = carol, .flags = tfMPTLock}); + + // Rotate the issuer key so both holders' mirrors are stale. + ct.mpt.generateKeyPair(newIssuerKey); + ct.mpt.set({.account = alice, .issuerPubKey = ct.mpt.getPubKey(newIssuerKey)}); + + // The issuer migrates an individually locked holder. + ct.mpt.mirrorUpdate({ + .account = alice, + .holder = bob, + .issuerEncryptedAmount = validCipher, + .err = tesSUCCESS, + }); + + // An individually locked holder migrates itself. + ct.mpt.mirrorUpdate({ + .account = carol, + .issuerEncryptedAmount = validCipher, + .err = tesSUCCESS, + }); + + // Release the individual locks and lock the whole issuance instead. Rotate again so + // both mirrors are stale once more. + ct.mpt.set({.account = alice, .holder = bob, .flags = tfMPTUnlock}); + ct.mpt.set({.account = alice, .holder = carol, .flags = tfMPTUnlock}); + ct.mpt.set({.account = alice, .flags = tfMPTLock}); + ct.mpt.generateKeyPair(newerIssuerKey); + ct.mpt.set({.account = alice, .issuerPubKey = ct.mpt.getPubKey(newerIssuerKey)}); + + ct.mpt.mirrorUpdate({ + .account = alice, + .holder = bob, + .issuerEncryptedAmount = validCipher, + .err = tesSUCCESS, + }); + + ct.mpt.mirrorUpdate({ + .account = carol, + .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 auditor 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, + }); + + 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, + .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, + }); + + 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); + } + + // Holder auditor late-registration: the auditor key is registered for the first time (key + // epoch absent), so the holder setting their 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)}); + + // The holder encrypts their own balance under the newly registered auditor key. + Buffer const auditorCipher = + ct.mpt.encryptAmount(auditor, amount, generateBlindingFactor()); + + ct.mpt.mirrorUpdate({ + .account = 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. + BEAST_EXPECT(!sle->isFieldPresent(sfAuditorKeyMirrorEpoch)); + } + } + + 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, + }); + + { + 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, + .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, + }); + + { + 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, + .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) @@ -1273,7 +2468,6 @@ public: testMPTokenIssuanceSetKeyEpochAtMax(features); } -public: void run() override { @@ -1288,6 +2482,14 @@ public: testConfidentialMPTSendEpoch(all); testConfidentialMPTConvertBackEpoch(all); testConfidentialMPTClawbackEpoch(all); + + testConfidentialMPTMirrorUpdatePreflight(all); + testConfidentialMPTMirrorUpdatePreflight(all - featureConfidentialMPTKeyRotation); + testConfidentialMPTMirrorUpdatePreflight(all - featureConfidentialTransfer); + testConfidentialMPTMirrorUpdatePreclaim(all); + testConfidentialMPTMirrorUpdateDoApply(all); + testConfidentialMPTMirrorUpdateMultipleRotationsIssuerMode(all); + testConfidentialMPTMirrorUpdateMultipleRotationsHolderMode(all); } }; diff --git a/src/test/app/ConfidentialTransfer_test.cpp b/src/test/app/ConfidentialTransfer_test.cpp index 9a0bf08660..9ff9270a7d 100644 --- a/src/test/app/ConfidentialTransfer_test.cpp +++ b/src/test/app/ConfidentialTransfer_test.cpp @@ -7131,6 +7131,18 @@ class ConfidentialTransfer_test : public ConfidentialTransferTestBase mptAlice.confidentialClaw( {.account = alice, .holder = carol, .amt = 15, .fee = expectedFee}); }); + + // Check fee for the mirror update transaction. + Account const newIssuerKey("newIssuerKey"); + mptAlice.generateKeyPair(newIssuerKey); + mptAlice.set({.account = alice, .issuerPubKey = mptAlice.getPubKey(newIssuerKey)}); + checkFee(alice, [&]() { + mptAlice.mirrorUpdate( + {.account = alice, + .holder = bob, + .issuerEncryptedAmount = getTrivialCiphertext(), + .fee = expectedFee}); + }); } // test insufficient fee for confidential transactions @@ -7162,6 +7174,12 @@ class ConfidentialTransfer_test : public ConfidentialTransferTestBase .amt = 1, .fee = baseFee, .err = telINSUF_FEE_P}); + mptAlice.mirrorUpdate( + {.account = alice, + .holder = bob, + .issuerEncryptedAmount = getTrivialCiphertext(), + .fee = baseFee, + .err = telINSUF_FEE_P}); } // test excessive fee for confidential transactions diff --git a/src/test/app/Delegate_test.cpp b/src/test/app/Delegate_test.cpp index 5414269333..5f57cfa21b 100644 --- a/src/test/app/Delegate_test.cpp +++ b/src/test/app/Delegate_test.cpp @@ -2838,7 +2838,7 @@ class Delegate_test : public beast::unit_test::Suite // DO NOT modify expectedDelegableCount unless all scenarios, including // edge cases, have been fully tested and verified. // ==================================================================== - std::size_t const expectedDelegableCount = 56; + std::size_t const expectedDelegableCount = 57; BEAST_EXPECTS( delegableCount == expectedDelegableCount, diff --git a/src/test/jtx/impl/mpt.cpp b/src/test/jtx/impl/mpt.cpp index 512257cdc5..542043015b 100644 --- a/src/test/jtx/impl/mpt.cpp +++ b/src/test/jtx/impl/mpt.cpp @@ -2171,4 +2171,33 @@ MPTTester::convertBackJV(MPTConvertBack const& arg, std::uint32_t seq) return jv; } +void +MPTTester::mirrorUpdate(MPTMirrorUpdate const& arg) +{ + json::Value jv; + jv[jss::TransactionType] = jss::ConfidentialMPTMirrorUpdate; + + setAccountField(jv, arg.account); + setIssuanceIdField(jv, arg.id); + + if (arg.holder) + jv[sfHolder] = arg.holder->human(); + if (arg.issuerEncryptedAmount) + jv[sfIssuerEncryptedAmount] = strHex(*arg.issuerEncryptedAmount); + if (arg.auditorEncryptedAmount) + jv[sfAuditorEncryptedAmount] = strHex(*arg.auditorEncryptedAmount); + + // Placeholder for proof, the logic will be added in the future + if (arg.zkProof) + { + jv[sfZKProof] = strHex(*arg.zkProof); + } + else + { + jv[sfZKProof] = strHex(gMakeZeroBuffer(kEcEqualityProofLength)); + } + + 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 cefdd2cdca..a737e76320 100644 --- a/src/test/jtx/mpt.h +++ b/src/test/jtx/mpt.h @@ -362,6 +362,24 @@ 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 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. @@ -584,6 +602,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/transactions/ConfidentialMPTMirrorUpdateTests.cpp b/src/tests/libxrpl/protocol_autogen/transactions/ConfidentialMPTMirrorUpdateTests.cpp new file mode 100644 index 0000000000..6cbeb008bd --- /dev/null +++ b/src/tests/libxrpl/protocol_autogen/transactions/ConfidentialMPTMirrorUpdateTests.cpp @@ -0,0 +1,255 @@ +// 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 zKProofValue = canonical_VL(); + + ConfidentialMPTMirrorUpdateBuilder builder{ + accountValue, + mPTokenIssuanceIDValue, + zKProofValue, + sequenceValue, + feeValue + }; + + // Set optional fields + builder.setHolder(holderValue); + builder.setIssuerEncryptedAmount(issuerEncryptedAmountValue); + builder.setAuditorEncryptedAmount(auditorEncryptedAmountValue); + + 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()); + } + +} + +// 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 zKProofValue = canonical_VL(); + + // Build an initial transaction + ConfidentialMPTMirrorUpdateBuilder initialBuilder{ + accountValue, + mPTokenIssuanceIDValue, + zKProofValue, + sequenceValue, + feeValue + }; + + initialBuilder.setHolder(holderValue); + initialBuilder.setIssuerEncryptedAmount(issuerEncryptedAmountValue); + initialBuilder.setAuditorEncryptedAmount(auditorEncryptedAmountValue); + + 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"); + } + +} + +// 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()); +} + +} From b07203aea01cd35c81f8766628ebf8328694c7da Mon Sep 17 00:00:00 2001 From: Pratik Mankawde <3397372+pratikmankawde@users.noreply.github.com> Date: Tue, 22 Sep 2026 20:28:27 +0100 Subject: [PATCH 03/10] docs(telemetry): Use real line breaks in the span-link diagram labels Four labels in a flowchart wrote a backslash and an n to break a line. Mermaid has no escape for that, so it printed the two characters inside the node instead of wrapping. The labels are now markdown strings carrying a real line break, which also auto-wrap. The
tags elsewhere in these diagrams are left alone. Mermaid enables HTML labels by default and this repository overrides no Mermaid config, so they render correctly. --- OpenTelemetryPlan/00-tracing-fundamentals.md | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/OpenTelemetryPlan/00-tracing-fundamentals.md b/OpenTelemetryPlan/00-tracing-fundamentals.md index 1c7675243a..44ff504bcc 100644 --- a/OpenTelemetryPlan/00-tracing-fundamentals.md +++ b/OpenTelemetryPlan/00-tracing-fundamentals.md @@ -209,7 +209,9 @@ flowchart LR subgraph links["Span Links"] direction TB - X["Span X\n(Trace 1)"] -.-|link| Y["Span Y\n(Trace 2)"] + X["`Span X +(Trace 1)`"] -.-|link| Y["`Span Y +(Trace 2)`"] end parent_child ~~~ follows_from ~~~ links @@ -354,9 +356,11 @@ flowchart TB Fn["trace_id = f(ledger_hash)"]:::note --> F1["fetch.request"] --> F2["fetch.receive"] --> F3["fetch.apply"] end - C1 -.-|"span link\n(tx traces)"| T3 + C1 -.-|"`span link +(tx traces)`"| T3 C3 --> V1 - F1 -.-|"span link\n(target ledger)"| C3 + F1 -.-|"`span link +(target ledger)`"| C3 classDef note fill:none,stroke:#888,stroke-dasharray:5 5,color:#333,font-style:italic style T1 fill:#0d47a1,stroke:#082f6a,color:#ffffff From 5e266e0e046f175cb6762f1899ffaeb13cc1f411 Mon Sep 17 00:00:00 2001 From: Pratik Mankawde <3397372+pratikmankawde@users.noreply.github.com> Date: Tue, 22 Sep 2026 20:29:39 +0100 Subject: [PATCH 04/10] fix(telemetry): Do not build a string_view from a null attribute pointer The C-string overload of setAttribute handed its pointer straight to std::string_view, whose pointer constructor reads the pointer to find the length. A null one is undefined behaviour. A null pointer carries no text, and an empty value already means something on this class, so nothing is recorded rather than an empty string. The overload itself has to stay. Without it a string literal would bind to the bool overload, because pointer-to-bool is a standard conversion and beats the string_view one, so every string attribute would silently record true. The header now records that. --- include/xrpl/telemetry/SpanGuard.h | 18 ++++++++++++++++-- src/libxrpl/telemetry/SpanGuard.cpp | 6 +++++- 2 files changed, 21 insertions(+), 3 deletions(-) diff --git a/include/xrpl/telemetry/SpanGuard.h b/include/xrpl/telemetry/SpanGuard.h index 46f1dcb63b..bdf8a4779e 100644 --- a/include/xrpl/telemetry/SpanGuard.h +++ b/include/xrpl/telemetry/SpanGuard.h @@ -447,7 +447,14 @@ public: setAttribute(std::string_view key, std::string_view value) noexcept; /** - * Set a string attribute (C-string overload). No-op on a null guard. + * Set a string attribute from a C string. No-op on a null guard. + * + * @param key Attribute key. + * @param value Null-terminated text. A null pointer records nothing, since + * an empty value is already a meaningful value here. + * @note This overload is required, not a convenience. Without it a string + * literal binds to the bool overload, because pointer-to-bool is a standard + * conversion and beats the std::string_view one. */ void setAttribute(std::string_view key, char const* value) noexcept; @@ -739,7 +746,14 @@ public: setAttribute(std::string_view key, std::string_view value) noexcept; /** - * Set a string attribute (C-string overload). No-op on a null guard. + * Set a string attribute from a C string. No-op on a null guard. + * + * @param key Attribute key. + * @param value Null-terminated text. A null pointer records nothing, since + * an empty value is already a meaningful value here. + * @note This overload is required, not a convenience. Without it a string + * literal binds to the bool overload, because pointer-to-bool is a standard + * conversion and beats the std::string_view one. */ void setAttribute(std::string_view key, char const* value) noexcept; diff --git a/src/libxrpl/telemetry/SpanGuard.cpp b/src/libxrpl/telemetry/SpanGuard.cpp index 28d1572531..0069c3e464 100644 --- a/src/libxrpl/telemetry/SpanGuard.cpp +++ b/src/libxrpl/telemetry/SpanGuard.cpp @@ -408,7 +408,11 @@ SpanGuard::setAttribute(std::string_view key, std::string_view value) noexcept void SpanGuard::setAttribute(std::string_view key, char const* value) noexcept { - setAttribute(key, std::string_view(value)); + // A std::string_view built from a pointer reads that pointer to find its + // length, so a null one is undefined behaviour. A null pointer carries no + // text, and an empty value already means something here, so record nothing. + if (value != nullptr) + setAttribute(key, std::string_view(value)); } void From 09a017d01e9aca9317210cee623d9246e65e7725 Mon Sep 17 00:00:00 2001 From: Pratik Mankawde <3397372+pratikmankawde@users.noreply.github.com> Date: Tue, 22 Sep 2026 20:29:41 +0100 Subject: [PATCH 05/10] fix(build): Export XRPL_ENABLE_TELEMETRY to consumers of libxrpl CMake sets the define with add_compile_definitions, which is scoped to this build, so it reached no consumer. Four public telemetry headers change class layout on it: with the define SpanGuard holds a unique_ptr and its methods are out of line in the library, without it SpanGuard is empty and all-inline. A package compiling against the installed headers therefore built a different SpanGuard than the one it linked. Exporting the define through cpp_info keeps the two in step, and it stays inside the existing telemetry option so the Conan option remains the only switch. --- conanfile.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/conanfile.py b/conanfile.py index 1fd1ff842e..5a8c2318d0 100644 --- a/conanfile.py +++ b/conanfile.py @@ -239,3 +239,7 @@ class Xrpl(ConanFile): libxrpl.requires.append("rocksdb::librocksdb") if self.options.telemetry: libxrpl.requires.append("opentelemetry-cpp::opentelemetry-cpp") + # The public telemetry headers pick their class layout on this + # define, so a consumer that does not see it compiles a different + # SpanGuard than the one inside the library it links. + libxrpl.defines.append("XRPL_ENABLE_TELEMETRY") From d8870162eb03650e0f04e16d2e0e462db83f15e8 Mon Sep 17 00:00:00 2001 From: Bart Date: Tue, 22 Sep 2026 20:03:55 +0000 Subject: [PATCH 06/10] test: Share peer test doubles across the overlay and app suites (#8246) Co-authored-by: Bart <11445373+bthomee@users.noreply.github.com> --- .../scripts/levelization/results/ordering.txt | 1 + src/test/app/LedgerReplay_test.cpp | 130 +-------- src/test/jtx/PeerStub.h | 185 +++++++++++++ src/test/overlay/CapturePeer.h | 252 ++++++++++++++++++ src/test/overlay/PeerTest.cpp | 166 ------------ src/test/overlay/PeerTest.h | 108 -------- src/test/overlay/TMGetLedger_test.cpp | 46 ++-- src/test/overlay/TMGetObjectByHash_test.cpp | 34 +-- src/test/overlay/TMTransaction_test.cpp | 21 +- src/test/overlay/TMTransactions_test.cpp | 32 +-- src/test/overlay/reduce_relay_test.cpp | 138 +--------- src/test/overlay/tx_reduce_relay_test.cpp | 208 +++++++-------- src/xrpld/overlay/detail/ProtocolVersion.cpp | 8 + src/xrpld/overlay/detail/ProtocolVersion.h | 9 + 14 files changed, 622 insertions(+), 716 deletions(-) create mode 100644 src/test/jtx/PeerStub.h create mode 100644 src/test/overlay/CapturePeer.h delete mode 100644 src/test/overlay/PeerTest.cpp delete mode 100644 src/test/overlay/PeerTest.h diff --git a/.github/scripts/levelization/results/ordering.txt b/.github/scripts/levelization/results/ordering.txt index 2b696f56f8..284fc9cdb7 100644 --- a/.github/scripts/levelization/results/ordering.txt +++ b/.github/scripts/levelization/results/ordering.txt @@ -112,6 +112,7 @@ test.jtx > xrpl.config test.jtx > xrpl.core test.jtx > xrpld.app test.jtx > xrpld.core +test.jtx > xrpld.overlay test.jtx > xrpld.rpc test.jtx > xrpl.json test.jtx > xrpl.ledger diff --git a/src/test/app/LedgerReplay_test.cpp b/src/test/app/LedgerReplay_test.cpp index 0853affab7..bb0c790e37 100644 --- a/src/test/app/LedgerReplay_test.cpp +++ b/src/test/app/LedgerReplay_test.cpp @@ -1,5 +1,6 @@ #include #include +#include #include #include #include @@ -21,7 +22,6 @@ #include #include #include -#include #include #include #include @@ -29,19 +29,14 @@ #include #include #include -#include #include #include #include #include #include -#include -#include #include -#include #include #include -#include #include #include @@ -268,136 +263,33 @@ enum class PeerFeature { * Simulate a network peer. * Depending on the configured PeerFeature, * it either supports the ProtocolFeature::LedgerReplay or not + * + * `PeerStub` supplies the rest of the `Peer` interface as no-ops. */ -class TestPeer : public Peer +class TestPeer : public PeerStub { public: - TestPeer(bool enableLedgerReplay) - : ledgerReplayEnabled_(enableLedgerReplay) - , nodePublicKey_(derivePublicKey(KeyType::Ed25519, randomSecretKey())) + // Arbitrary but fixed: the replay code only compares ids. + explicit TestPeer(bool enableLedgerReplay) + : PeerStub(1234), ledgerReplayEnabled_(enableLedgerReplay) { } - void - send(std::shared_ptr const& m) override - { - } - [[nodiscard]] beast::ip::Endpoint - getRemoteAddress() const override - { - return {}; - } - void - charge(resource::Charge const& fee, std::string const& context = {}) override - { - } - [[nodiscard]] id_t - id() const override - { - return 1234; - } - [[nodiscard]] bool - cluster() const override - { - return false; - } - [[nodiscard]] bool - isHighLatency() const override - { - return false; - } - [[nodiscard]] int - getScore(bool) const override - { - return 0; - } - [[nodiscard]] PublicKey const& - getNodePublic() const override - { - return nodePublicKey_; - } - json::Value - json() override - { - return {}; - } [[nodiscard]] bool supportsFeature(ProtocolFeature f) const override { return f == ProtocolFeature::LedgerReplay && ledgerReplayEnabled_; } - [[nodiscard]] std::optional - publisherListSequence(PublicKey const&) const override - { - return {}; - } - void - setPublisherListSequence(PublicKey const&, std::size_t const) override - { - } - [[nodiscard]] uint256 - getClosedLedgerHash() const override - { - static uint256 const kHash{}; - return kHash; - } + + // The replay code only asks peers that already have the ledger. [[nodiscard]] bool - hasLedger(uint256 const& hash, std::uint32_t seq) const override + hasLedger(uint256 const&, std::uint32_t) const override { return true; } - void - ledgerRange(std::uint32_t& minSeq, std::uint32_t& maxSeq) const override - { - } - [[nodiscard]] bool - hasTxSet(uint256 const& hash) const override - { - return false; - } - void - cycleStatus() override - { - } - bool - hasRange(std::uint32_t uMin, std::uint32_t uMax) override - { - return false; - } - [[nodiscard]] bool - compressionEnabled() const override - { - return false; - } - void - sendTxQueue() override - { - } - void - addTxQueue(uint256 const&) override - { - } - void - removeTxQueue(uint256 const&) override - { - } - [[nodiscard]] bool - txReduceRelayEnabled() const override - { - return false; - } - [[nodiscard]] std::string const& - fingerprint() const override - { - return fingerprint_; - } - - // NOLINTBEGIN(readability-identifier-naming) - std::string fingerprint_; +private: bool ledgerReplayEnabled_; - PublicKey nodePublicKey_; - // NOLINTEND(readability-identifier-naming) }; enum class PeerSetBehavior { diff --git a/src/test/jtx/PeerStub.h b/src/test/jtx/PeerStub.h new file mode 100644 index 0000000000..cabe94f351 --- /dev/null +++ b/src/test/jtx/PeerStub.h @@ -0,0 +1,185 @@ +#pragma once + +#include +#include + +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include + +namespace xrpl::test { + +/** + * A `Peer` whose every method is a no-op returning a default. + * + * Derive from this and override only the methods a test cares about. Adding a + * method to `Peer` then costs one stub here, not one per double. + * + * The id and the node public key are real, because the code under test routes + * and deduplicates on both. + */ +class PeerStub : public Peer +{ +public: + /** + * @param id The connection id reported by `id()`. + */ + explicit PeerStub(id_t id = 0) + : id_(id), nodePublicKey_(derivePublicKey(KeyType::Ed25519, randomSecretKey())) + { + } + + ~PeerStub() override = default; + + void + send(std::shared_ptr const&) override + { + } + + [[nodiscard]] beast::ip::Endpoint + getRemoteAddress() const override + { + return {}; + } + + void + sendTxQueue() override + { + } + + void + addTxQueue(uint256 const&) override + { + } + + void + removeTxQueue(uint256 const&) override + { + } + + void + charge(resource::Charge const&, std::string const&) override + { + } + + [[nodiscard]] id_t + id() const override + { + return id_; + } + + [[nodiscard]] bool + cluster() const override + { + return false; + } + + [[nodiscard]] bool + isHighLatency() const override + { + return false; + } + + [[nodiscard]] int + getScore(bool) const override + { + return 0; + } + + [[nodiscard]] PublicKey const& + getNodePublic() const override + { + return nodePublicKey_; + } + + json::Value + json() override + { + return {}; + } + + [[nodiscard]] bool + supportsFeature(ProtocolFeature) const override + { + return false; + } + + [[nodiscard]] std::optional + publisherListSequence(PublicKey const&) const override + { + return {}; + } + + void + setPublisherListSequence(PublicKey const&, std::size_t const) override + { + } + + [[nodiscard]] std::string const& + fingerprint() const override + { + return fingerprint_; + } + + [[nodiscard]] uint256 + getClosedLedgerHash() const override + { + return {}; + } + + [[nodiscard]] bool + hasLedger(uint256 const&, std::uint32_t) const override + { + return false; + } + + void + ledgerRange(std::uint32_t&, std::uint32_t&) const override + { + } + + [[nodiscard]] bool + hasTxSet(uint256 const&) const override + { + return false; + } + + void + cycleStatus() override + { + } + + bool + hasRange(std::uint32_t, std::uint32_t) override + { + return false; + } + + [[nodiscard]] bool + compressionEnabled() const override + { + return false; + } + + [[nodiscard]] bool + txReduceRelayEnabled() const override + { + return false; + } + +private: + id_t const id_; + PublicKey const nodePublicKey_; + std::string const fingerprint_; +}; + +} // namespace xrpl::test diff --git a/src/test/overlay/CapturePeer.h b/src/test/overlay/CapturePeer.h new file mode 100644 index 0000000000..28a26d01e7 --- /dev/null +++ b/src/test/overlay/CapturePeer.h @@ -0,0 +1,252 @@ +#pragma once + +#include + +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include + +namespace xrpl::test { + +/** + * A real `PeerImp` that captures the messages it would have sent. + * + * Only `send` and `run` are overridden, so `onMessage` runs production code. + * Derive from this to reach a `protected` `PeerImp` member. + */ +class CapturePeer : public PeerImp +{ +public: + using MiddleType = boost::beast::tcp_stream; + using StreamType = boost::beast::ssl_stream; + using SocketType = boost::asio::ip::tcp::socket; + + /** + * Takes `PeerImp`'s two rvalue-reference parameters by value instead, so a + * derived double can inherit this constructor without a never-moved-from + * warning. + * + * @param app The application owning the peer. + * @param id The connection id, unique among the overlay's peers. + * @param slot The peer finder slot; must be seated. + * @param request The handshake request. + * @param publicKey The peer's node public key. + * @param protocol The negotiated protocol version. + * @param consumer The resource manager endpoint for the peer. + * @param streamPtr The connection's ssl stream. + * @param overlay The overlay to register with. + */ + CapturePeer( + Application& app, + Peer::id_t id, + std::shared_ptr const& slot, + http_request_type request, + PublicKey const& publicKey, + ProtocolVersion protocol, + resource::Consumer consumer, + std::unique_ptr streamPtr, + OverlayImpl& overlay) + : PeerImp( + app, + id, + slot, + std::move(request), + publicKey, + protocol, + consumer, // copy-only, so `std::move` would be a copy anyway + std::move(streamPtr), + overlay) + { + } + + ~CapturePeer() override = default; + + /** + * Does nothing, so the peer stays registered. The real `run()` reaches + * `PeerImp::doAccept`, which fails on an unconnected socket and detaches. + */ + void + run() override + { + } + + /** + * Captures the message instead of writing it, so replies are observable. + */ + void + send(std::shared_ptr const& m) override + { + sent_.push_back(m); + } + + /** + * @return Every message sent to this peer, in order. + */ + std::vector> const& + sent() const + { + return sent_; + } + + /** + * @return The most recent message sent, or null if there was none. + */ + std::shared_ptr + lastSent() const + { + return sent_.empty() ? nullptr : sent_.back(); + } + + /** + * Reads the accumulated charge without draining it through `charge()`. + * + * @return The charge accumulated on the peer so far. + */ + resource::Charge + feeCharge() const + { + return currentFeeCharge(); + } + +private: + std::vector> sent_; +}; + +namespace detail { + +// `inline` so the functions below name one entity across translation units. +inline constexpr std::uint16_t kCapturePeerPort = 51235; + +/** + * Non-template, so all `makeCapturePeer` instantiations share one counter. A + * per-instantiation counter would give two peer types the same id, and + * `addActive` would silently drop the second from `ids_`. + * + * @return The next unused connection id. + */ +inline Peer::id_t +nextCapturePeerId() +{ + static Peer::id_t id{0}; + return ++id; +} + +/** + * Non-template for the same reason as `nextCapturePeerId`. Each peer needs its + * own address, not just its own port: the peer finder caps inbound connections + * per address at `ipLimit`, which is at most 2 unless configured. + * + * @return The next unused remote endpoint. + */ +inline beast::ip::Endpoint +nextCapturePeerRemote() +{ + // From 172.2.0.1 upward, so ~900k fit before reaching 172.16/12, where the + // peer finder would treat them as private rather than as real inbound. + static std::uint32_t next{0xAC020001}; + return beast::ip::Endpoint(boost::asio::ip::address_v4(next++), kCapturePeerPort); +} + +/** + * Outlives every peer, whose stream keeps a reference to it. `PeerImp::charge` + * posts a handler holding the peer, so a peer can outlive its caller's scope. + * + * @return The ssl context every test peer's stream is built on. + */ +inline boost::asio::ssl::context& +capturePeerSslContext() +{ + static std::shared_ptr const kContext{makeSslContext("")}; + return *kContext; +} + +} // namespace detail + +/** + * Build an active `CapturePeer` and register it with the overlay. + * + * @tparam PeerType The peer class to build; must derive from `CapturePeer` and + * inherit its constructor. + * @param env The environment owning the overlay. + * @param key The peer's node public key, or unseated for a fresh random + * one. + * @param request The handshake request. `PeerImp` reads its `X-Protocol-Ctl` + * header in the constructor to negotiate features. + * @return The peer, already registered with the overlay. Throws if the peer + * finder refused a slot. + */ +template +std::shared_ptr +makeCapturePeer( + jtx::Env& env, + std::optional key = std::nullopt, + http_request_type request = {}) +{ + auto& overlay = dynamic_cast(env.app().getOverlay()); + auto streamPtr = std::make_unique( + CapturePeer::SocketType(env.app().getIOContext()), detail::capturePeerSslContext()); + + beast::ip::Endpoint const local( + boost::asio::ip::make_address("172.1.1.1"), detail::kCapturePeerPort); + auto const remote = detail::nextCapturePeerRemote(); + + auto consumer = overlay.resourceManager().newInboundEndpoint(remote); + auto [slot, _] = overlay.peerFinder().newInboundSlot(local, remote); + + // Unseated when the endpoint is already connected or at the per-address + // limit. `PeerImp` dereferences the slot, so fail here, not there. + if (!slot) + { + Throw("makeCapturePeer: no slot for " + to_string(remote)); + } + + if (!key) + key = PublicKey(std::get<0>(randomKeyPair(KeyType::Ed25519))); + + auto peer = std::make_shared( + env.app(), + detail::nextCapturePeerId(), + slot, + std::move(request), + *key, + // An unsupported version fails every `supportsFeature` test, so a + // version-gated reply would only ever take its legacy branch. + newestSupportedProtocolVersion(), + consumer, + std::move(streamPtr), + overlay); + + overlay.addActive(peer); + return peer; +} + +} // namespace xrpl::test diff --git a/src/test/overlay/PeerTest.cpp b/src/test/overlay/PeerTest.cpp deleted file mode 100644 index 341febb25b..0000000000 --- a/src/test/overlay/PeerTest.cpp +++ /dev/null @@ -1,166 +0,0 @@ -#include - -#include - -#include -#include -#include -#include -#include -#include -#include - -#include -#include -#include -#include -#include -#include -#include -#include -#include - -#include -#include -#include -#include -#include - -#include - -#include -#include -#include -#include - -namespace xrpl::test { - -PeerTest::PeerTest( - Application& app, - std::shared_ptr const& slot, - http_request_type&& request, - PublicKey const& publicKey, - ProtocolVersion protocol, - resource::Consumer consumer, - std::unique_ptr&& streamPtr, - OverlayImpl& overlay) - : PeerImp{ - app, - id++, - slot, - std::move(request), - publicKey, - protocol, - consumer, - std::move(streamPtr), - overlay} -{ -} - -void -PeerTest::run() -{ -} - -void -PeerTest::send(std::shared_ptr const& message) -{ - lastSentMessage_ = message; -} - -std::shared_ptr -PeerTest::getLastSentMessage() const -{ - return lastSentMessage_; -} - -void -PeerTest::runProcessGetObjectByHash(std::shared_ptr const& message) -{ - PeerImp::processGetObjectByHash(message); -} - -void -PeerTest::runProcessLedgerRequest( - std::shared_ptr const& message, - std::vector nodeIDs) -{ - PeerImp::processLedgerRequest(message, std::move(nodeIDs)); -} - -resource::Charge -PeerTest::getCurrentFeeCharge() const -{ - return PeerImp::currentFeeCharge(); -} - -void -PeerTest::resetId() -{ - id = 0; -} - -bool -PeerTest::compressionEnabled() const -{ - if (compressionEnabled_.has_value()) - { - return *compressionEnabled_; - } - return PeerImp::compressionEnabled(); -} - -void -PeerTest::compressionEnabled(std::optional enabled) -{ - compressionEnabled_ = enabled; -} - -bool -PeerTest::txReduceRelayEnabled() const -{ - if (reduceRelayEnabled_.has_value()) - { - return *reduceRelayEnabled_; - } - return PeerImp::txReduceRelayEnabled(); -} - -void -PeerTest::txReduceRelayEnabled(std::optional enabled) -{ - reduceRelayEnabled_ = enabled; -} - -std::shared_ptr -makePeerTest(jtx::Env& env, PeerTest::SharedContext const& context, ProtocolVersion protocolVersion) -{ - using SocketType = boost::asio::ip::tcp::socket; - - auto& overlay = dynamic_cast(env.app().getOverlay()); - boost::beast::http::request request; - auto streamPtr = - std::make_unique(SocketType(env.app().getIOContext()), *context); - - beast::ip::Endpoint const local(boost::asio::ip::make_address("172.1.1.1"), 51235); - beast::ip::Endpoint const remote(boost::asio::ip::make_address("172.1.1.2"), 51235); - - PublicKey const key{std::get<0>(randomKeyPair(KeyType::Ed25519))}; - auto consumer = overlay.resourceManager().newInboundEndpoint(remote); - auto [slot, _] = overlay.peerFinder().newInboundSlot(local, remote); - - auto peer = std::make_shared( - env.app(), - slot, - std::move(request), - key, - protocolVersion, - consumer, - std::move(streamPtr), - overlay); - - overlay.addActive(peer); - return peer; -} - -} // namespace xrpl::test diff --git a/src/test/overlay/PeerTest.h b/src/test/overlay/PeerTest.h deleted file mode 100644 index f7b2815da3..0000000000 --- a/src/test/overlay/PeerTest.h +++ /dev/null @@ -1,108 +0,0 @@ -#pragma once - -#include - -#include -#include -#include -#include -#include -#include -#include - -#include -#include -#include -#include -#include -#include - -#include -#include -#include -#include -#include - -#include - -#include -#include -#include - -namespace xrpl::test { - -/** - * Test peer that captures sent messages for verification. - */ -class PeerTest : public PeerImp -{ - inline static Peer::id_t id{}; - std::shared_ptr lastSentMessage_; - std::optional compressionEnabled_; - std::optional reduceRelayEnabled_; - -public: - using MiddleType = boost::beast::tcp_stream; - using SharedContext = std::shared_ptr; - using StreamType = boost::beast::ssl_stream; - - PeerTest( - Application& app, - std::shared_ptr const& slot, - http_request_type&& request, - PublicKey const& publicKey, - ProtocolVersion protocol, - resource::Consumer consumer, - std::unique_ptr&& streamPtr, - OverlayImpl& overlay); - - ~PeerTest() override = default; - - void - run() override; - - void - send(std::shared_ptr const& m) override; - - std::shared_ptr - getLastSentMessage() const; - - // Synchronous test access to the JobQueue-dispatched processor. - // The production path runs this on JtLedgerReq; tests need a - // synchronous entry point to inspect the reply via send(). - // PeerImp::processGetObjectByHash is `protected` so the derived - // test subclass can call it directly. - void - runProcessGetObjectByHash(std::shared_ptr const& m); - - void - runProcessLedgerRequest( - std::shared_ptr const& m, - std::vector nodeIDs); - - resource::Charge - getCurrentFeeCharge() const; - - static void - resetId(); - - bool - compressionEnabled() const override; - - void - compressionEnabled(std::optional enabled); - - bool - txReduceRelayEnabled() const override; - - void - txReduceRelayEnabled(std::optional enabled); -}; - -std::shared_ptr -makePeerTest( - jtx::Env& env, - PeerTest::SharedContext const& context, - ProtocolVersion protocolVersion); - -} // namespace xrpl::test diff --git a/src/test/overlay/TMGetLedger_test.cpp b/src/test/overlay/TMGetLedger_test.cpp index 9088e6fa65..eac1a24e24 100644 --- a/src/test/overlay/TMGetLedger_test.cpp +++ b/src/test/overlay/TMGetLedger_test.cpp @@ -1,30 +1,19 @@ #include -#include +#include #include -#include -#include -#include -#include #include #include -#include #include -#include #include #include -#include -#include -#include -#include -#include - #include #include #include +#include #include namespace xrpl::test { @@ -33,8 +22,23 @@ using namespace jtx; class TMGetLedger_test : public beast::unit_test::Suite { - PeerTest::SharedContext context_{makeSslContext("")}; - ProtocolVersion protocolVersion_{1, 7}; + /** + * Calls the JtLedgerReq-dispatched processor synchronously, so the reply is + * visible through `lastSent()`. + */ + class GetLedgerPeer : public CapturePeer + { + public: + using CapturePeer::CapturePeer; + + void + runProcessLedgerRequest( + std::shared_ptr const& m, + std::vector nodeIDs) + { + processLedgerRequest(m, std::move(nodeIDs)); + } + }; // Build a well-formed TMGetLedger node request carrying `numNodeIds` node // IDs. @@ -64,17 +68,16 @@ class TMGetLedger_test : public beast::unit_test::Suite testcase("Node ID Count Accepted"); Env env{*this}; - PeerTest::resetId(); - auto peer = makePeerTest(env, context_, protocolVersion_); + auto peer = makeCapturePeer(env); peer->onMessage(createRequest(numNodeIds)); // A request outside the accepted node-ID count is charged kFeeInvalidData; one inside // it is not. The JobQueue handler may run concurrently and update the fee in the // accepted case. BEAST_EXPECT( - expectRejected ? (peer->getCurrentFeeCharge() == resource::kFeeInvalidData) - : !(peer->getCurrentFeeCharge() == resource::kFeeInvalidData)); + expectRejected ? (peer->feeCharge() == resource::kFeeInvalidData) + : !(peer->feeCharge() == resource::kFeeInvalidData)); } void @@ -84,9 +87,8 @@ class TMGetLedger_test : public beast::unit_test::Suite Env env{*this}; env.close(); - PeerTest::resetId(); - auto peer = makePeerTest(env, context_, protocolVersion_); + auto peer = makeCapturePeer(env); // Ask for the account-state root node of the closed ledger. auto request = createRequest(numNodeIds); @@ -96,7 +98,7 @@ class TMGetLedger_test : public beast::unit_test::Suite peer->runProcessLedgerRequest(request, std::vector(numNodeIds)); - auto sentMessage = peer->getLastSentMessage(); + auto sentMessage = peer->lastSent(); BEAST_EXPECT(sentMessage != nullptr); if (!sentMessage) { diff --git a/src/test/overlay/TMGetObjectByHash_test.cpp b/src/test/overlay/TMGetObjectByHash_test.cpp index 84495fec37..1aafd77f2f 100644 --- a/src/test/overlay/TMGetObjectByHash_test.cpp +++ b/src/test/overlay/TMGetObjectByHash_test.cpp @@ -1,26 +1,17 @@ #include -#include +#include #include #include #include -#include -#include #include #include #include -#include #include #include #include -#include -#include -#include -#include -#include - #include #include @@ -41,8 +32,21 @@ using namespace jtx; */ class TMGetObjectByHash_test : public beast::unit_test::Suite { - PeerTest::SharedContext context_{makeSslContext("")}; - ProtocolVersion protocolVersion_{1, 7}; + /** + * Calls the JtLedgerReq-dispatched processor synchronously, so the reply is + * visible through `sent()`. + */ + class GetObjectPeer : public CapturePeer + { + public: + using CapturePeer::CapturePeer; + + void + runProcessGetObjectByHash(std::shared_ptr const& m) + { + processGetObjectByHash(m); + } + }; static std::shared_ptr createRequest(size_t const numObjects, Env& env) @@ -90,15 +94,13 @@ class TMGetObjectByHash_test : public beast::unit_test::Suite testcase("Reply Object Count"); Env env(*this); - PeerTest::resetId(); - - auto peer = makePeerTest(env, context_, protocolVersion_); + auto peer = makeCapturePeer(env); auto request = createRequest(numObjects, env); peer->runProcessGetObjectByHash(request); // Verify that a reply was sent - auto sentMessage = peer->getLastSentMessage(); + auto sentMessage = peer->lastSent(); BEAST_EXPECT(sentMessage != nullptr); // Parse the reply message diff --git a/src/test/overlay/TMTransaction_test.cpp b/src/test/overlay/TMTransaction_test.cpp index b208b3d81b..5a23e25005 100644 --- a/src/test/overlay/TMTransaction_test.cpp +++ b/src/test/overlay/TMTransaction_test.cpp @@ -1,21 +1,10 @@ #include #include -#include +#include -#include -#include -#include - -#include #include #include -#include -#include -#include -#include -#include - #include #include @@ -26,18 +15,14 @@ using namespace jtx; class TMTransaction_test : public beast::unit_test::Suite { - PeerTest::SharedContext context_{makeSslContext("")}; - ProtocolVersion protocolVersion_{1, 7}; - void testFailureDeserializingTransactionIsCharged() { testcase("Undeserializable Transaction Is Charged"); Env env{*this, envconfig()}; - PeerTest::resetId(); - auto peer = makePeerTest(env, context_, protocolVersion_); + auto peer = makeCapturePeer(env); auto tx = std::make_shared(); tx->set_status(protocol::tsNEW); @@ -45,7 +30,7 @@ class TMTransaction_test : public beast::unit_test::Suite tx->set_rawtransaction("\x01\x02\x03", 3); peer->onMessage(tx); - BEAST_EXPECT(peer->getCurrentFeeCharge() == resource::kFeeInvalidData); + BEAST_EXPECT(peer->feeCharge() == resource::kFeeInvalidData); } void diff --git a/src/test/overlay/TMTransactions_test.cpp b/src/test/overlay/TMTransactions_test.cpp index 67d36cc04b..87c09498f2 100644 --- a/src/test/overlay/TMTransactions_test.cpp +++ b/src/test/overlay/TMTransactions_test.cpp @@ -1,28 +1,21 @@ #include #include #include -#include +#include #include -#include -#include -#include -#include +#include -#include #include #include - -#include -#include -#include -#include -#include +#include #include #include #include +#include +#include namespace xrpl::test { @@ -30,9 +23,6 @@ using namespace jtx; class TMTransactions_test : public beast::unit_test::Suite { - PeerTest::SharedContext context_{makeSslContext("")}; - ProtocolVersion protocolVersion_{1, 7}; - static std::shared_ptr createRequest(std::size_t const numTransactions) { @@ -55,13 +45,17 @@ class TMTransactions_test : public beast::unit_test::Suite *this, envconfig(), std::make_unique(kLimitExceededMessage, &foundExpectedLog)}; - PeerTest::resetId(); - auto peer = makePeerTest(env, context_, protocolVersion_); - peer->txReduceRelayEnabled(true); + // `PeerImp` decides `txReduceRelayEnabled()` in its constructor, from + // the config and the handshake header, so set this first. + env.app().config().txReduceRelayEnable = true; + http_request_type request; + request.insert("X-Protocol-Ctl", makeFeaturesRequestHeader(false, false, true, false)); + + auto peer = makeCapturePeer(env, std::nullopt, std::move(request)); peer->onMessage(createRequest(numTransactions)); - auto fee = peer->getCurrentFeeCharge(); + auto fee = peer->feeCharge(); if (expectRejected) { BEAST_EXPECT(fee == resource::kFeeMalformedRequest); diff --git a/src/test/overlay/reduce_relay_test.cpp b/src/test/overlay/reduce_relay_test.cpp index 4091efa0ca..ff3eb51a4d 100644 --- a/src/test/overlay/reduce_relay_test.cpp +++ b/src/test/overlay/reduce_relay_test.cpp @@ -1,4 +1,5 @@ #include +#include #include #include @@ -12,10 +13,8 @@ #include #include #include -#include #include #include -#include #include #include #include @@ -27,7 +26,6 @@ #include #include #include -#include #include #include #include @@ -67,16 +65,16 @@ static constexpr std::uint32_t kMaxMessages = 200000; /** * Simulate two entities - peer directly connected to the server * (via squelch in PeerSim) and PeerImp (via Overlay) + * + * `PeerStub` supplies the rest of the `Peer` interface as no-ops. */ -class PeerPartial : public Peer +class PeerPartial : public PeerStub { public: - PeerPartial() : nodePublicKey(derivePublicKey(KeyType::Ed25519, randomSecretKey())) - { - } + using PeerStub::PeerStub; + // Keep the base overload visible; the one below would otherwise hide it. + using PeerStub::send; - PublicKey nodePublicKey; - ~PeerPartial() override = default; virtual void onMessage(MessageSPtr const& m, SquelchCB f) = 0; virtual void @@ -86,111 +84,6 @@ public: { onMessage(squelch); } - - // dummy implementation - void - send(std::shared_ptr const& m) override - { - } - [[nodiscard]] beast::ip::Endpoint - getRemoteAddress() const override - { - return {}; - } - void - charge(resource::Charge const& fee, std::string const& context = {}) override - { - } - [[nodiscard]] bool - cluster() const override - { - return false; - } - [[nodiscard]] bool - isHighLatency() const override - { - return false; - } - [[nodiscard]] int - getScore(bool) const override - { - return 0; - } - [[nodiscard]] PublicKey const& - getNodePublic() const override - { - return nodePublicKey; - } - json::Value - json() override - { - return {}; - } - [[nodiscard]] bool - supportsFeature(ProtocolFeature f) const override - { - return false; - } - [[nodiscard]] std::optional - publisherListSequence(PublicKey const&) const override - { - return {}; - } - void - setPublisherListSequence(PublicKey const&, std::size_t const) override - { - } - [[nodiscard]] uint256 - getClosedLedgerHash() const override - { - static uint256 const kHash{}; - return kHash; - } - [[nodiscard]] bool - hasLedger(uint256 const& hash, std::uint32_t seq) const override - { - return false; - } - void - ledgerRange(std::uint32_t& minSeq, std::uint32_t& maxSeq) const override - { - } - [[nodiscard]] bool - hasTxSet(uint256 const& hash) const override - { - return false; - } - void - cycleStatus() override - { - } - bool - hasRange(std::uint32_t uMin, std::uint32_t uMax) override - { - return false; - } - [[nodiscard]] bool - compressionEnabled() const override - { - return false; - } - [[nodiscard]] bool - txReduceRelayEnabled() const override - { - return false; - } - void - sendTxQueue() override - { - } - void - addTxQueue(uint256 const&) override - { - } - void - removeTxQueue(uint256 const&) override - { - } }; /** @@ -466,24 +359,13 @@ class PeerSim : public PeerPartial, public std::enable_shared_from_this { public: using id_t = Peer::id_t; - PeerSim(Overlay& overlay, beast::Journal journal) : overlay_(overlay), squelch_(journal) + PeerSim(Overlay& overlay, beast::Journal journal) + : PeerPartial(sid++), overlay_(overlay), squelch_(journal) { } ~PeerSim() override = default; - id_t - id() const override - { - return id_; - } - - std::string const& - fingerprint() const override - { - return fingerprint_; - } - static void resetId() { @@ -525,8 +407,6 @@ public: private: inline static id_t sid = 0; - std::string fingerprint_; - id_t id_{sid++}; Overlay& overlay_; reduce_relay::Squelch squelch_; }; diff --git a/src/test/overlay/tx_reduce_relay_test.cpp b/src/test/overlay/tx_reduce_relay_test.cpp index 8626d3e19c..e97fba88e5 100644 --- a/src/test/overlay/tx_reduce_relay_test.cpp +++ b/src/test/overlay/tx_reduce_relay_test.cpp @@ -1,38 +1,24 @@ #include #include +#include #include #include -#include #include #include #include #include -#include #include -#include -#include #include -#include #include #include #include #include -#include #include -#include -#include -#include -#include -#include -#include -#include - #include -#include #include #include #include @@ -47,13 +33,6 @@ namespace xrpl::test { class tx_reduce_relay_test : public beast::unit_test::Suite { -public: - using socket_type = boost::asio::ip::tcp::socket; - using middle_type = boost::beast::tcp_stream; - using stream_type = boost::beast::ssl_stream; - using shared_context = std::shared_ptr; - -private: void doTest(std::string const& msg, bool log, std::function f) { @@ -116,107 +95,83 @@ private: }); } - class PeerTest : public PeerImp + /** + * Counts queued transaction hashes. Relayed messages are counted through + * the inherited `sent()`. + */ + class TxReducePeer : public CapturePeer { public: - PeerTest( - Application& app, - std::shared_ptr const& slot, - http_request_type&& request, - PublicKey const& publicKey, - ProtocolVersion protocol, - resource::Consumer consumer, - std::unique_ptr&& streamPtr, - OverlayImpl& overlay) - : PeerImp( - app, - sid, - slot, - std::move(request), - publicKey, - protocol, - consumer, - std::move(streamPtr), - overlay) - { - sid++; - } - ~PeerTest() override = default; + using CapturePeer::CapturePeer; void - run() override + addTxQueue(uint256 const&) override { + ++queued_; } - void - send(std::shared_ptr const&) override + + /** + * @return The number of transaction hashes queued for this peer. + */ + std::size_t + queued() const { - sendTx++; + return queued_; } - void - addTxQueue(uint256 const& hash) override - { - queueTx++; - } - static void - init() - { - queueTx = 0; - sendTx = 0; - sid = 0; - } - inline static std::size_t sid = 0; - inline static std::uint16_t queueTx = 0; - inline static std::uint16_t sendTx = 0; + + private: + std::size_t queued_{0}; }; - std::uint16_t lid_{0}; - std::uint16_t rid_{1}; - shared_context context_; - ProtocolVersion protocolVersion_; - boost::beast::multi_buffer readBuf_; - -public: - tx_reduce_relay_test() : context_(makeSslContext("")), protocolVersion_{1, 7} - { - } - -private: + /** + * Build one peer and register it with the overlay. + * + * The first `nDisabled` peers get no `X-Protocol-Ctl` header, which leaves + * tx reduce-relay disabled on them. Built first, they sit at the front of + * `peers`, where `testRelay`'s skip set expects them. + * + * @param env The environment owning the overlay. + * @param peers Receives the peer; the overlay holds only a weak + * pointer, so the caller keeps it alive. + * @param nDisabled How many more peers to leave disabled; decremented + * per peer built. + */ void - addPeer(jtx::Env& env, std::vector>& peers, std::uint16_t& nDisabled) + addPeer( + jtx::Env& env, + std::vector>& peers, + std::uint16_t& nDisabled) { auto& overlay = dynamic_cast(env.app().getOverlay()); - boost::beast::http::request request; - (nDisabled == 0) - ? request.insert("X-Protocol-Ctl", makeFeaturesRequestHeader(false, false, true, false)) - : (void)nDisabled--; - auto streamPtr = std::make_unique( - socket_type(std::forward(env.app().getIOContext())), - *context_); - beast::ip::Endpoint const local( - boost::asio::ip::make_address("172.1.1." + std::to_string(lid_))); - beast::ip::Endpoint const remote( - boost::asio::ip::make_address("172.1.1." + std::to_string(rid_))); PublicKey const key(std::get<0>(randomKeyPair(KeyType::Ed25519))); - auto consumer = overlay.resourceManager().newInboundEndpoint(remote); - auto [slot, _] = overlay.peerFinder().newInboundSlot(local, remote); - auto const peer = std::make_shared( - env.app(), - slot, - std::move(request), - key, - protocolVersion_, - consumer, - std::move(streamPtr), - overlay); + + bool const disabled = nDisabled > 0; + if (disabled) + --nDisabled; + + http_request_type request; + if (!disabled) + request.insert("X-Protocol-Ctl", makeFeaturesRequestHeader(false, false, true, false)); + BEAST_EXPECT(overlay.findPeerByPublicKey(key) == std::shared_ptr{}); - overlay.addActive(peer); + auto const peer = makeCapturePeer(env, key, std::move(request)); BEAST_EXPECT(overlay.findPeerByPublicKey(key) == peer); - peers.emplace_back(peer); // overlay stores week ptr to PeerImp - lid_ += 2; - rid_ += 2; - assert(lid_ <= 254); + peers.emplace_back(peer); } + /** + * Relay one transaction to `nPeers` peers and check the split. + * + * @param test The testcase name. + * @param txRREnabled The `tx_enable` config value. + * @param nPeers How many peers to attach to the overlay. + * @param nDisabled How many of those peers have reduce-relay disabled. + * @param minPeers The `tx_min_peers` config value. + * @param relayPercentage The `tx_relay_percentage` config value. + * @param expectRelay The expected number of peers relayed to. + * @param expectQueue The expected number of peers queued for. + * @param nSkip How many of the first-built peers to skip. + */ void testRelay( std::string const& test, @@ -227,20 +182,30 @@ private: std::uint16_t relayPercentage, std::uint16_t expectRelay, std::uint16_t expectQueue, - std::set const& toSkip = {}) + std::size_t nSkip = 0) { testcase(test); jtx::Env env(*this); - std::vector> peers; + std::vector> peers; + // `PeerImp` decides `txReduceRelayEnabled()` in its constructor, from + // the config and the handshake header, so set these first. env.app().config().txReduceRelayEnable = txRREnabled; env.app().config().txReduceRelayMinPeers = minPeers; env.app().config().txRelayPercentage = relayPercentage; - PeerTest::init(); - lid_ = 0; - rid_ = 0; for (int i = 0; i < nPeers; i++) addPeer(env, peers, nDisabled); + // An under-filled skip set would also fail the relay counts below, for + // a reason that looks unrelated. + if (!BEAST_EXPECT(nSkip <= peers.size())) + return; + + // Skip the peers built first, so the skip set overlaps the disabled + // peers as the expected counts assume. + std::set toSkip; + for (std::size_t i = 0; i < nSkip; ++i) + toSkip.insert(peers[i]->id()); + auto const jtx = env.jt(noop(env.master)); if (BEAST_EXPECT(jtx.stx)) { @@ -251,7 +216,15 @@ private: m.set_deferred(false); m.set_status(protocol::TransactionStatus::tsNEW); env.app().getOverlay().relay(uint256{0}, m, toSkip); - BEAST_EXPECT(PeerTest::sendTx == expectRelay && PeerTest::queueTx == expectQueue); + + std::size_t sendTx = 0; + std::size_t queueTx = 0; + for (auto const& peer : peers) + { + sendTx += peer->sent().size(); + queueTx += peer->queued(); + } + BEAST_EXPECT(sendTx == expectRelay && queueTx == expectQueue); } } @@ -259,12 +232,11 @@ private: run() override { bool const log = false; - std::set skip = {0, 1, 2, 3, 4}; testConfig(log); // relay to all peers, no hash queue testRelay("feature disabled", false, 10, 0, 10, 25, 10, 0); // relay to nPeers - skip (10-5=5) - testRelay("feature disabled & skip", false, 10, 0, 10, 25, 5, 0, skip); + testRelay("feature disabled & skip", false, 10, 0, 10, 25, 5, 0, 5); // relay to all peers because min is greater than nPeers testRelay("relay all 1", true, 10, 0, 20, 25, 10, 0); // relay to all peers because min + disabled is greater thant nPeers @@ -275,24 +247,22 @@ private: // relay to minPeers + 25% of (nPeers - nPeers) - skip // (20+0.25*(60-20)-5=25), queue the rest, skip counts towards relayed // (60-25-5=30) - testRelay("skip", true, 60, 0, 20, 25, 25, 30, skip); + testRelay("skip", true, 60, 0, 20, 25, 25, 30, 5); // relay to minPeers + disabled + 25% of (nPeers - minPeers - disabled) // (20+10+0.25*(70-20-10)=40), queue the rest (30) testRelay("disabled", true, 70, 10, 20, 25, 40, 30); // relay to minPeers + disabled-not-in-skip + 25% of (nPeers - minPeers // - disabled) (20+5+0.25*(70-20-10)=35), queue the rest, skip counts // towards relayed (70-35-5=30)) - testRelay("disabled & skip", true, 70, 10, 20, 25, 35, 30, skip); + testRelay("disabled & skip", true, 70, 10, 20, 25, 35, 30, 5); // relay to minPeers + disabled + 25% of (nPeers - minPeers - disabled) // - skip (10+5+0.25*(15-10-5)-10=5), queue the rest, skip counts // towards relayed (15-5-10=0) - skip = {0, 1, 2, 3, 4, 5, 6, 7, 8, 9}; - testRelay("disabled & skip, no queue", true, 15, 5, 10, 25, 5, 0, skip); + testRelay("disabled & skip, no queue", true, 15, 5, 10, 25, 5, 0, 10); // relay to minPeers + disabled + 25% of (nPeers - minPeers - disabled) // - skip (10+2+0.25*(20-10-2)-14=0), queue the rest, skip counts // towards relayed (20-14=6) - skip = {0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13}; - testRelay("disabled & skip, no relay", true, 20, 2, 10, 25, 0, 6, skip); + testRelay("disabled & skip, no relay", true, 20, 2, 10, 25, 0, 6, 14); } }; diff --git a/src/xrpld/overlay/detail/ProtocolVersion.cpp b/src/xrpld/overlay/detail/ProtocolVersion.cpp index 74dad61828..d24928a4ec 100644 --- a/src/xrpld/overlay/detail/ProtocolVersion.cpp +++ b/src/xrpld/overlay/detail/ProtocolVersion.cpp @@ -151,4 +151,12 @@ isProtocolSupported(ProtocolVersion const& v) return std::end(kSupportedProtocolList) != std::ranges::find(kSupportedProtocolList, v); } +ProtocolVersion +newestSupportedProtocolVersion() +{ + // Scans rather than reading the sorted list's last entry, so it does not + // depend on an invariant kept elsewhere. + return *std::ranges::max_element(kSupportedProtocolList); +} + } // namespace xrpl diff --git a/src/xrpld/overlay/detail/ProtocolVersion.h b/src/xrpld/overlay/detail/ProtocolVersion.h index 5c05f63e2a..a4342c2453 100644 --- a/src/xrpld/overlay/detail/ProtocolVersion.h +++ b/src/xrpld/overlay/detail/ProtocolVersion.h @@ -68,4 +68,13 @@ supportedProtocolVersions(); bool isProtocolSupported(ProtocolVersion const& v); +/** + * The version negotiated with a peer that speaks everything we speak, so also + * the one that enables every version-gated feature. + * + * @return The largest version in the list of supported protocol versions. + */ +ProtocolVersion +newestSupportedProtocolVersion(); + } // namespace xrpl From 8f4e9c25d892767727ca5af09599df2b47890f9a Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Tue, 22 Sep 2026 20:19:05 +0000 Subject: [PATCH 07/10] fix: Add CTID to ledger command expanded transactions (#6401) Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: Mayukha Vadari Co-authored-by: Mayukha Vadari Co-authored-by: xrplf-ai-reviewer[bot] <266832837+xrplf-ai-reviewer[bot]@users.noreply.github.com> Co-authored-by: Timur Yalymov <36795566+tyalymov@users.noreply.github.com> Co-authored-by: Cursor Co-authored-by: Vito Tumas <5780819+Tapanito@users.noreply.github.com> Co-authored-by: Ayaz Salikhov --- src/test/rpc/LedgerRPC_test.cpp | 94 ++++++++++++++++++++ src/xrpld/app/ledger/detail/LedgerToJson.cpp | 16 ++++ 2 files changed, 110 insertions(+) diff --git a/src/test/rpc/LedgerRPC_test.cpp b/src/test/rpc/LedgerRPC_test.cpp index e7c5dd4a80..deb19e3a3f 100644 --- a/src/test/rpc/LedgerRPC_test.cpp +++ b/src/test/rpc/LedgerRPC_test.cpp @@ -13,17 +13,21 @@ #include #include +#include #include #include #include +#include #include #include #include +#include #include #include #include +#include #include #include #include @@ -807,6 +811,95 @@ class LedgerRPC_test : public beast::unit_test::Suite } } + void + testLedgerExpandedTransactionsCTID() + { + testcase("Expanded Transactions CTID"); + using namespace test::jtx; + + Env env{*this}; + Account const alice{"alice"}; + env.fund(XRP(10000), alice); + env.close(); + + uint32_t const netID = env.app().getNetworkIDService().getNetworkID(); + + // API v2 non-binary: CTID present + { + json::Value jvParams; + jvParams[jss::ledger_index] = "validated"; + jvParams[jss::transactions] = true; + jvParams[jss::expand] = true; + jvParams[jss::api_version] = 2; + auto const jrr = env.rpc("json", "ledger", to_string(jvParams))[jss::result]; + BEAST_EXPECT(jrr[jss::status] == "success"); + auto const& txns = jrr[jss::ledger][jss::transactions]; + BEAST_EXPECT(txns.isArray() && txns.size() > 0); + for (auto const& txn : txns) + { + BEAST_EXPECT(txn.isMember(jss::ctid)); + auto const expectedCtid = rpc::encodeCTID( + jrr[jss::ledger][jss::ledger_index].asUInt(), + txn[jss::meta][sfTransactionIndex.jsonName].asUInt(), + netID); + // NOLINTBEGIN(bugprone-unchecked-optional-access) + if (BEAST_EXPECT(expectedCtid.has_value())) + BEAST_EXPECT(txn[jss::ctid] == expectedCtid.value()); + // NOLINTEND(bugprone-unchecked-optional-access) + } + } + + // API v1 non-binary: CTID present + { + json::Value jvParams; + jvParams[jss::ledger_index] = "validated"; + jvParams[jss::transactions] = true; + jvParams[jss::expand] = true; + auto const jrr = env.rpc("json", "ledger", to_string(jvParams))[jss::result]; + BEAST_EXPECT(jrr[jss::status] == "success"); + auto const& txns = jrr[jss::ledger][jss::transactions]; + BEAST_EXPECT(txns.isArray() && txns.size() > 0); + for (auto const& txn : txns) + { + BEAST_EXPECT(txn.isMember(jss::ctid)); + } + } + + // Binary expanded: CTID present + { + json::Value jvParams; + jvParams[jss::ledger_index] = "validated"; + jvParams[jss::transactions] = true; + jvParams[jss::expand] = true; + jvParams[jss::binary] = true; + jvParams[jss::api_version] = 2; + auto const jrr = env.rpc("json", "ledger", to_string(jvParams))[jss::result]; + BEAST_EXPECT(jrr[jss::status] == "success"); + auto const& txns = jrr[jss::ledger][jss::transactions]; + BEAST_EXPECT(txns.isArray() && txns.size() > 0); + for (auto const& txn : txns) + { + BEAST_EXPECT(txn.isMember(jss::ctid)); + } + } + + // Non-expanded: transactions are plain hash strings, no CTID + { + json::Value jvParams; + jvParams[jss::ledger_index] = "validated"; + jvParams[jss::transactions] = true; + jvParams[jss::api_version] = 2; + auto const jrr = env.rpc("json", "ledger", to_string(jvParams))[jss::result]; + BEAST_EXPECT(jrr[jss::status] == "success"); + auto const& txns = jrr[jss::ledger][jss::transactions]; + BEAST_EXPECT(txns.isArray() && txns.size() > 0); + for (auto const& txn : txns) + { + BEAST_EXPECT(txn.isString()); + } + } + } + public: void run() override @@ -822,6 +915,7 @@ public: testNoQueue(); testQueue(); testLedgerAccountsOption(); + testLedgerExpandedTransactionsCTID(); } }; diff --git a/src/xrpld/app/ledger/detail/LedgerToJson.cpp b/src/xrpld/app/ledger/detail/LedgerToJson.cpp index 1d789aa604..419da2a995 100644 --- a/src/xrpld/app/ledger/detail/LedgerToJson.cpp +++ b/src/xrpld/app/ledger/detail/LedgerToJson.cpp @@ -3,6 +3,7 @@ #include #include #include +#include #include #include @@ -11,6 +12,7 @@ #include #include #include +#include #include #include #include @@ -27,6 +29,7 @@ #include #include +#include #include #include #include @@ -179,6 +182,19 @@ fillJsonTx( } } + // compute outgoing CTID + if (stMeta && stMeta->isFieldPresent(sfTransactionIndex)) + { + uint32_t const lgrSeq = fill.ledger.seq(); + uint32_t const txnIdx = stMeta->getFieldU32(sfTransactionIndex); + uint32_t netID = fill.context->app.getNetworkIDService().getNetworkID(); + if (txn->isFieldPresent(sfNetworkID)) + netID = txn->getFieldU32(sfNetworkID); + + if (auto ctid = rpc::encodeCTID(lgrSeq, txnIdx, netID)) + txJson[jss::ctid] = *ctid; + } + if (((fill.options & static_cast(LedgerFill::Options::OwnerFunds)) != 0) && txn->getTxnType() == ttOFFER_CREATE) { From 5a5ad8673ab7dc4572bbd703d802dfe423b711f9 Mon Sep 17 00:00:00 2001 From: Alex Kremer Date: Tue, 22 Sep 2026 22:39:23 +0000 Subject: [PATCH 08/10] style: Precommit hook for gtest naming (#8026) Co-authored-by: Bart --- .pre-commit-config.yaml | 5 + bin/pre-commit/fix_gtest_names.py | 144 ++++++++++ bin/pre-commit/test_fix_gtest_names.py | 259 ++++++++++++++++++ src/tests/libxrpl/basics/MallocTrim.cpp | 10 +- src/tests/libxrpl/basics/RangeSet.cpp | 6 +- src/tests/libxrpl/basics/StringUtilities.cpp | 6 +- src/tests/libxrpl/basics/base64.cpp | 2 +- src/tests/libxrpl/basics/base_uint.cpp | 2 +- src/tests/libxrpl/basics/contract.cpp | 2 +- src/tests/libxrpl/basics/mulDiv.cpp | 2 +- src/tests/libxrpl/basics/scope.cpp | 6 +- src/tests/libxrpl/basics/tagged_integer.cpp | 8 +- src/tests/libxrpl/crypto/csprng.cpp | 2 +- src/tests/libxrpl/json/Value.cpp | 40 +-- src/tests/libxrpl/ledger/AMMEntry.cpp | 2 +- src/tests/libxrpl/ledger/AccountRootEntry.cpp | 2 +- src/tests/libxrpl/ledger/AmendmentsEntry.cpp | 2 +- src/tests/libxrpl/ledger/BridgeEntry.cpp | 2 +- src/tests/libxrpl/ledger/CheckEntry.cpp | 2 +- src/tests/libxrpl/ledger/CredentialEntry.cpp | 2 +- src/tests/libxrpl/ledger/DIDEntry.cpp | 2 +- src/tests/libxrpl/ledger/DelegateEntry.cpp | 2 +- .../libxrpl/ledger/DepositPreauthEntry.cpp | 2 +- .../libxrpl/ledger/DirectoryNodeEntry.cpp | 2 +- src/tests/libxrpl/ledger/EscrowEntry.cpp | 2 +- src/tests/libxrpl/ledger/FeeSettingsEntry.cpp | 2 +- .../libxrpl/ledger/LedgerHashesEntry.cpp | 2 +- src/tests/libxrpl/ledger/LoanBrokerEntry.cpp | 2 +- src/tests/libxrpl/ledger/LoanEntry.cpp | 2 +- src/tests/libxrpl/ledger/MPTokenEntry.cpp | 2 +- .../libxrpl/ledger/MPTokenIssuanceEntry.cpp | 2 +- .../libxrpl/ledger/NFTokenOfferEntry.cpp | 2 +- src/tests/libxrpl/ledger/NFTokenPageEntry.cpp | 2 +- src/tests/libxrpl/ledger/NegativeUNLEntry.cpp | 2 +- src/tests/libxrpl/ledger/OfferEntry.cpp | 2 +- src/tests/libxrpl/ledger/OracleEntry.cpp | 2 +- src/tests/libxrpl/ledger/PayChannelEntry.cpp | 2 +- .../ledger/PermissionedDomainEntry.cpp | 2 +- src/tests/libxrpl/ledger/RippleStateEntry.cpp | 2 +- src/tests/libxrpl/ledger/SLEBase.cpp | 18 +- src/tests/libxrpl/ledger/SignerListEntry.cpp | 2 +- src/tests/libxrpl/ledger/SponsorshipEntry.cpp | 2 +- src/tests/libxrpl/ledger/TicketEntry.cpp | 2 +- src/tests/libxrpl/ledger/VaultEntry.cpp | 2 +- .../ledger/XChainOwnedClaimIDEntry.cpp | 2 +- .../XChainOwnedCreateAccountClaimIDEntry.cpp | 2 +- src/tests/libxrpl/protocol/STXChainBridge.cpp | 4 +- src/tests/libxrpl/server/InfoSub.cpp | 6 +- src/tests/libxrpl/tx/AccountSet.cpp | 28 +- 49 files changed, 510 insertions(+), 102 deletions(-) create mode 100755 bin/pre-commit/fix_gtest_names.py create mode 100755 bin/pre-commit/test_fix_gtest_names.py diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 8721ad7863..7212214f01 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -53,6 +53,11 @@ repos: entry: ./bin/pre-commit/check_doxygen_style.py language: python types_or: [c++, c] + - id: fix-gtest-names + name: "fix gtest names: CamelCase suite, snake_case test case" + entry: ./bin/pre-commit/fix_gtest_names.py + language: python + types_or: [c++, c] - repo: https://github.com/pre-commit/mirrors-clang-format rev: f4d7745e17a28aad7eed2f4874ca8d1568c11c4c # frozen: v22.1.8 diff --git a/bin/pre-commit/fix_gtest_names.py b/bin/pre-commit/fix_gtest_names.py new file mode 100755 index 0000000000..7dbbdf63ba --- /dev/null +++ b/bin/pre-commit/fix_gtest_names.py @@ -0,0 +1,144 @@ +#!/usr/bin/env python3 + +""" +Rewrites gtest names to the required style in this project: the suite name is +CamelCase, the test-case name is snake_case. + + TEST(SuiteName, test_case_name) + +The gtest `DISABLED_` prefix is kept verbatim on either name. + +Both conversions fold acronyms the way a reader expects: +`SetAndResetAccountTxnID` -> `set_and_reset_account_txn_id`, not +`set_and_reset_account_txn_i_d`. + +The first argument of `TEST_F`, `TEST_P`, `TYPED_TEST` and `TYPED_TEST_P` is a +fixture class rather than a free identifier, so rewriting it here would leave +the class it names behind. Those are reported for a human to rename (clang-tidy +checks the class declaration itself, via readability-identifier-naming). + +Usage: ./bin/pre-commit/fix_gtest_names.py ... +""" + +import re +import sys +from collections import Counter +from pathlib import Path + +# A test-case definition, `MACRO(SuiteOrFixture, TestName)`, anchored at the +# start of a line so that commented-out definitions and project macros that +# merely look similar (`TEST_EXPECT(...)`) are left alone. The `\s*` between +# arguments allows for a definition clang-format wrapped over several lines. +PATTERN = re.compile( + r"(?P^[ \t]*(?PTYPED_TEST_P|TYPED_TEST|TEST_F|TEST_P|TEST)\s*\(\s*)" + r"(?P\w+)(?P\s*,\s*)(?P\w+)(?P\s*\))", + re.MULTILINE, +) + +# The macros whose first argument names a fixture class, not a free identifier. +FIXTURE_MACROS = ("TEST_F", "TEST_P", "TYPED_TEST", "TYPED_TEST_P") + +DISABLED = "DISABLED_" + +ACRONYM_BOUNDARY = re.compile(r"([A-Z]+)([A-Z][a-z])") +WORD_BOUNDARY = re.compile(r"([a-z\d])([A-Z])") + + +def _split_disabled(name: str) -> tuple[str, str]: + """Splits off gtest's `DISABLED_` prefix, which is kept verbatim.""" + if name.startswith(DISABLED): + return DISABLED, name[len(DISABLED) :] + return "", name + + +def snake_case(name: str) -> str: + """Returns the name in snake_case, leaving acronyms whole. + + `SetAndResetAccountTxnID` -> `set_and_reset_account_txn_id`, + `parseStatRSSkB` -> `parse_stat_rs_sk_b`. + """ + prefix, core = _split_disabled(name) + core = ACRONYM_BOUNDARY.sub(r"\1_\2", core) + return prefix + WORD_BOUNDARY.sub(r"\1_\2", core).lower() + + +def camel_case(name: str) -> str: + """Returns the name in CamelCase, capitalizing each underscored word. + + Only the letters that have to change are touched, so acronyms survive: a + conversion that went via snake_case would turn `SHAMapTest` into + `ShaMapTest`, whereas here it is already CamelCase and stays put. + `json_value` -> `JsonValue`, `parseStatRSSkB` -> `ParseStatRSSkB`. + """ + prefix, core = _split_disabled(name) + return prefix + "".join(w[:1].upper() + w[1:] for w in core.split("_") if w) + + +def _corrected(match: re.Match) -> tuple[str, str]: + """Returns the suite and test-case names this definition should end up with.""" + suite = match["suite"] + return ( + suite if match["macro"] in FIXTURE_MACROS else camel_case(suite), + snake_case(match["name"]), + ) + + +def fix_source(text: str) -> tuple[str, list[str]]: + """Returns the corrected text and one `line: message` report per bad name.""" + # gtest joins the suite and test names into one class name, so two test + # cases whose joined names agree cannot coexist: `TEST(a, b_c)` and + # `TEST(a_b, c)` both define `a_b_c_Test`. A rename that would introduce + # such a clash is reported for a human instead of applied. + joined = Counter("_".join(_corrected(m)) for m in PATTERN.finditer(text)) + reports = [] + + def rewrite(match: re.Match) -> str: + suite, name = match["suite"], match["name"] + new_suite, new_name = _corrected(match) + line = text.count("\n", 0, match.start()) + 1 + + if match["macro"] in FIXTURE_MACROS and camel_case(suite) != suite: + reports.append( + f"{line}: fixture '{suite}' is not CamelCase: rename the class " + f"to '{camel_case(suite)}' by hand" + ) + if (new_suite, new_name) == (suite, name): + return match[0] + if joined[f"{new_suite}_{new_name}"] > 1: + reports.append( + f"{line}: cannot rename '{suite}, {name}' to '{new_suite}, " + f"{new_name}': another test case already generates that name" + ) + return match[0] + if new_suite != suite: + reports.append(f"{line}: renamed suite '{suite}' to '{new_suite}'") + if new_name != name: + reports.append(f"{line}: renamed test case '{name}' to '{new_name}'") + return match["head"] + new_suite + match["mid"] + new_name + match["tail"] + + return PATTERN.sub(rewrite, text), reports + + +def fix_names(path: Path) -> bool: + """Corrects one file's gtest names, reporting each on stdout.""" + original = path.read_text(encoding="utf-8") + fixed, reports = fix_source(original) + for report in reports: + print(f"{path}:{report}") + if fixed != original: + path.write_text(fixed, encoding="utf-8") + return not reports + + +def main() -> int: + files = [Path(f) for f in sys.argv[1:]] + success = True + + for path in files: + success &= fix_names(path) + + return 0 if success else 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/bin/pre-commit/test_fix_gtest_names.py b/bin/pre-commit/test_fix_gtest_names.py new file mode 100755 index 0000000000..aa0481f795 --- /dev/null +++ b/bin/pre-commit/test_fix_gtest_names.py @@ -0,0 +1,259 @@ +#!/usr/bin/env python3 +""" +Tests for fix_gtest_names.py. + +Run directly (no test framework needed): + ./bin/pre-commit/test_fix_gtest_names.py +or under pytest: + pytest bin/pre-commit/test_fix_gtest_names.py +""" + +import sys +import textwrap + +from fix_gtest_names import camel_case, fix_source, snake_case + + +def dedent(text: str) -> str: + """Removes a fixture's common indentation and its leading newline. + + Lets fixtures be written as indented triple-quoted here-docs while keeping + honest 1-based line numbers. + """ + return textwrap.dedent(text).lstrip("\n") + + +def fixed(text: str) -> str: + return fix_source(dedent(text))[0] + + +def reports(text: str) -> list[str]: + return fix_source(dedent(text))[1] + + +# --- conversion -------------------------------------------------------------- + + +def test_snake_case_conversion() -> None: + assert snake_case("BadInputs") == "bad_inputs" + assert snake_case("mulDiv") == "mul_div" + assert snake_case("already_snake") == "already_snake" + assert snake_case("base64") == "base64" + + +def test_snake_case_keeps_acronyms_whole() -> None: + assert snake_case("SetAndResetAccountTxnID") == "set_and_reset_account_txn_id" + assert snake_case("XRPToIOU") == "xrp_to_iou" + assert snake_case("STAmountMath") == "st_amount_math" + + +def test_camel_case_conversion() -> None: + assert camel_case("json_value") == "JsonValue" + assert camel_case("mulDiv") == "MulDiv" + assert camel_case("scope") == "Scope" + assert camel_case("base64") == "Base64" + + +def test_camel_case_leaves_acronyms_alone() -> None: + # A snake_case round-trip would give `ShaMapTest` / `ParseStatmRsSkB` here. + assert camel_case("SHAMapTest") == "SHAMapTest" + assert camel_case("parseStatmRSSkB") == "ParseStatmRSSkB" + assert camel_case("XRPAmount") == "XRPAmount" + assert camel_case("CSPRNG") == "CSPRNG" + + +def test_disabled_prefix_preserved() -> None: + assert snake_case("DISABLED_FooBar") == "DISABLED_foo_bar" + assert snake_case("DISABLED_foo_bar") == "DISABLED_foo_bar" + assert snake_case("DISABLED_") == "DISABLED_" + assert camel_case("DISABLED_foo_bar") == "DISABLED_FooBar" + assert camel_case("DISABLED_") == "DISABLED_" + + +# --- what counts as a test definition --------------------------------------- + + +def test_all_macros_recognized() -> None: + code = """ + TEST(Suite, oneName) + TEST_F(Fixture, twoName) + TEST_P(Fixture, threeName) + TYPED_TEST(Fixture, fourName) + TYPED_TEST_P(Fixture, fiveName) + """ + assert fixed(code) == dedent(""" + TEST(Suite, one_name) + TEST_F(Fixture, two_name) + TEST_P(Fixture, three_name) + TYPED_TEST(Fixture, four_name) + TYPED_TEST_P(Fixture, five_name) + """) + + +def test_conforming_definitions_untouched() -> None: + code = """ + TEST(AccountSet, bad_inputs) + TEST_F(MutexMakeTest, default_constructor) + TEST(SHAMap, DISABLED_slow_path) + """ + assert reports(code) == [] + assert fixed(code) == dedent(code) + + +def test_lookalikes_ignored() -> None: + code = """ + // TEST(Suite, notATest) + TEST_EXPECT(someCall()) + TEST_EXPECTS(amount == value, amount.getText()) + INSTANTIATE_TEST_SUITE_P(Prefix, Fixture, testValues()); + auto x = TEST(Suite, notATest); + TYPED_TEST_SUITE(Fixture, MyTypes); + """ + assert reports(code) == [] + assert fixed(code) == dedent(code) + + +def test_indented_and_wrapped_definitions() -> None: + code = """ + namespace ripple { + TEST(Suite, indentedName) + } + TEST_F( + SomeVeryLongFixtureName, + wrappedName) + """ + assert fixed(code) == dedent(""" + namespace ripple { + TEST(Suite, indented_name) + } + TEST_F( + SomeVeryLongFixtureName, + wrapped_name) + """) + + +# --- rewriting -------------------------------------------------------------- + + +def test_only_the_two_names_are_rewritten() -> None: + code = """ + TEST(mulDiv, mulDiv) + { + auto const mulDiv = 1; // mulDiv stays + } + """ + assert fixed(code) == dedent(""" + TEST(MulDiv, mul_div) + { + auto const mulDiv = 1; // mulDiv stays + } + """) + + +def test_suite_name_camel_cased() -> None: + code = """ + TEST(json_value, limits) + TEST(scope, ScopeExit) + """ + assert reports(code) == [ + "1: renamed suite 'json_value' to 'JsonValue'", + "2: renamed suite 'scope' to 'Scope'", + "2: renamed test case 'ScopeExit' to 'scope_exit'", + ] + assert fixed(code) == dedent(""" + TEST(JsonValue, limits) + TEST(Scope, scope_exit) + """) + + +def test_fixture_reported_but_not_renamed() -> None: + # The first argument names a class, so only a human (or clang-tidy) can + # rename it; the test-case name is still fixed. + code = """ + TEST_F(my_fixture, someTest) + """ + assert reports(code) == [ + "1: fixture 'my_fixture' is not CamelCase: rename the class to " + "'MyFixture' by hand", + "1: renamed test case 'someTest' to 'some_test'", + ] + assert fixed(code) == dedent(""" + TEST_F(my_fixture, some_test) + """) + + +def test_reports_carry_line_numbers() -> None: + code = """ + #include + + TEST(Suite, firstName) + + TEST(Suite, secondName) + """ + assert reports(code) == [ + "3: renamed test case 'firstName' to 'first_name'", + "5: renamed test case 'secondName' to 'second_name'", + ] + + +# --- collisions ------------------------------------------------------------- + + +def test_collision_reported_and_not_applied() -> None: + # Both would define `Suite_mul_div_Test`. + code = """ + TEST(Suite, mulDiv) + TEST(Suite, mul_div) + """ + assert reports(code) == [ + "1: cannot rename 'Suite, mulDiv' to 'Suite, mul_div': another test " + "case already generates that name" + ] + assert fixed(code) == dedent(code) + + +def test_collision_between_converging_suites() -> None: + # Both suites camel-case to `SuiteA`, so both would define + # `SuiteA_one_test_Test`. + code = """ + TEST(SuiteA, oneTest) + TEST(Suite_a, one_test) + """ + assert [r.split(":")[1].strip() for r in reports(code)] == [ + "cannot rename 'SuiteA, oneTest' to 'SuiteA, one_test'", + "cannot rename 'Suite_a, one_test' to 'SuiteA, one_test'", + ] + assert fixed(code) == dedent(code) + + +def test_same_name_in_different_suites_is_not_a_collision() -> None: + code = """ + TEST(SuiteOne, mulDiv) + TEST(SuiteTwo, mulDiv) + """ + assert fixed(code) == dedent(""" + TEST(SuiteOne, mul_div) + TEST(SuiteTwo, mul_div) + """) + + +def main() -> int: + tests = sorted( + (name, fn) + for name, fn in globals().items() + if name.startswith("test_") and callable(fn) + ) + failed = 0 + for name, fn in tests: + try: + fn() + print(f"PASS {name}") + except AssertionError as exc: + failed += 1 + print(f"FAIL {name}: {exc!r}") + print(f"\n{len(tests) - failed}/{len(tests)} passed") + return 1 if failed else 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/src/tests/libxrpl/basics/MallocTrim.cpp b/src/tests/libxrpl/basics/MallocTrim.cpp index 52151262b0..0f98a156a5 100644 --- a/src/tests/libxrpl/basics/MallocTrim.cpp +++ b/src/tests/libxrpl/basics/MallocTrim.cpp @@ -49,7 +49,7 @@ TEST(MallocTrimReport, structure) } #if defined(__GLIBC__) && BOOST_OS_LINUX -TEST(parseStatmRSSkB, standard_format) +TEST(ParseStatmRSSkB, standard_format) { using xrpl::detail::parseStatmRSSkB; @@ -121,7 +121,7 @@ TEST(parseStatmRSSkB, standard_format) } #endif -TEST(mallocTrim, without_debug_logging) +TEST(MallocTrim, without_debug_logging) { beast::Journal const journal{beast::Journal::getNullSink()}; @@ -144,7 +144,7 @@ TEST(mallocTrim, without_debug_logging) #endif } -TEST(mallocTrim, empty_tag) +TEST(MallocTrim, empty_tag) { beast::Journal const journal{beast::Journal::getNullSink()}; MallocTrimReport const report = mallocTrim("", journal); @@ -157,7 +157,7 @@ TEST(mallocTrim, empty_tag) #endif } -TEST(mallocTrim, with_debug_logging) +TEST(MallocTrim, with_debug_logging) { struct DebugSink : public beast::Journal::Sink { @@ -194,7 +194,7 @@ TEST(mallocTrim, with_debug_logging) #endif } -TEST(mallocTrim, repeated_calls) +TEST(MallocTrim, repeated_calls) { beast::Journal const journal{beast::Journal::getNullSink()}; diff --git a/src/tests/libxrpl/basics/RangeSet.cpp b/src/tests/libxrpl/basics/RangeSet.cpp index 44b13ad581..2e224c79f7 100644 --- a/src/tests/libxrpl/basics/RangeSet.cpp +++ b/src/tests/libxrpl/basics/RangeSet.cpp @@ -10,7 +10,7 @@ using namespace xrpl; -TEST(RangeSet, prevMissing) +TEST(RangeSet, prev_missing) { // Set will include: // [ 0, 5] @@ -36,7 +36,7 @@ TEST(RangeSet, prevMissing) } } -TEST(RangeSet, toString) +TEST(RangeSet, to_string) { RangeSet set; EXPECT_EQ(to_string(set), "empty"); @@ -54,7 +54,7 @@ TEST(RangeSet, toString) EXPECT_EQ(to_string(set), "1-2,6"); } -TEST(RangeSet, fromString) +TEST(RangeSet, from_string) { RangeSet set; diff --git a/src/tests/libxrpl/basics/StringUtilities.cpp b/src/tests/libxrpl/basics/StringUtilities.cpp index 0180e25db0..1859de2cc1 100644 --- a/src/tests/libxrpl/basics/StringUtilities.cpp +++ b/src/tests/libxrpl/basics/StringUtilities.cpp @@ -290,7 +290,7 @@ TEST_F(StringUtilitiesTest, to_string) EXPECT_EQ(result, "hello"); } -TEST_F(StringUtilitiesTest, trimWhitespace) +TEST_F(StringUtilitiesTest, trim_whitespace) { EXPECT_EQ(trimWhitespace(""), ""); EXPECT_EQ(trimWhitespace(" "), ""); @@ -303,7 +303,7 @@ TEST_F(StringUtilitiesTest, trimWhitespace) EXPECT_EQ(trimWhitespace(" a b\tc "), "a b\tc"); } -TEST_F(StringUtilitiesTest, toLower) +TEST_F(StringUtilitiesTest, to_lower) { EXPECT_EQ(toLower(""), ""); EXPECT_EQ(toLower("ABC"), "abc"); @@ -318,7 +318,7 @@ TEST_F(StringUtilitiesTest, toLower) // Both helpers are documented as depending only on their input. Guard that by // checking the bytes just outside ASCII, which a locale-aware isspace/tolower // could classify differently. -TEST_F(StringUtilitiesTest, trimAndLowerIgnoreLocale) +TEST_F(StringUtilitiesTest, trim_and_lower_ignore_locale) { // 0xA0 is NO-BREAK SPACE in Latin-1 and is whitespace to some locales. std::string const nbsp("\xA0", 1); diff --git a/src/tests/libxrpl/basics/base64.cpp b/src/tests/libxrpl/basics/base64.cpp index d26d23700a..c9f8331c98 100644 --- a/src/tests/libxrpl/basics/base64.cpp +++ b/src/tests/libxrpl/basics/base64.cpp @@ -14,7 +14,7 @@ check(std::string const& in, std::string const& out) EXPECT_EQ(base64Decode(encoded), in); } -TEST(base64, base64) +TEST(Base64, base64) { // cspell: disable check("", ""); diff --git a/src/tests/libxrpl/basics/base_uint.cpp b/src/tests/libxrpl/basics/base_uint.cpp index 969705b5b7..4783820205 100644 --- a/src/tests/libxrpl/basics/base_uint.cpp +++ b/src/tests/libxrpl/basics/base_uint.cpp @@ -128,7 +128,7 @@ struct BaseUintTest : public ::testing::Test using BaseUintDeathTest = BaseUintTest; -TEST_F(BaseUintDeathTest, fromRaw_size_mismatch) +TEST_F(BaseUintDeathTest, from_raw_size_mismatch) { // ENABLE_VOIDSTAR is a debug build, but does not crash on failed asserts. Rather than twist // these tests into knots to make them work, just skip them. diff --git a/src/tests/libxrpl/basics/contract.cpp b/src/tests/libxrpl/basics/contract.cpp index 0c6b32a7ad..e9404da78b 100644 --- a/src/tests/libxrpl/basics/contract.cpp +++ b/src/tests/libxrpl/basics/contract.cpp @@ -6,7 +6,7 @@ using namespace xrpl; -TEST(contract, contract) +TEST(Contract, contract) { try { diff --git a/src/tests/libxrpl/basics/mulDiv.cpp b/src/tests/libxrpl/basics/mulDiv.cpp index 725bef399e..cdc672a444 100644 --- a/src/tests/libxrpl/basics/mulDiv.cpp +++ b/src/tests/libxrpl/basics/mulDiv.cpp @@ -7,7 +7,7 @@ using namespace xrpl; -TEST(mulDiv, mulDiv) +TEST(MulDiv, mul_div) { auto const max = std::numeric_limits::max(); std::uint64_t const max32 = std::numeric_limits::max(); diff --git a/src/tests/libxrpl/basics/scope.cpp b/src/tests/libxrpl/basics/scope.cpp index dc5623e967..71186d3d90 100644 --- a/src/tests/libxrpl/basics/scope.cpp +++ b/src/tests/libxrpl/basics/scope.cpp @@ -6,7 +6,7 @@ using namespace xrpl; -TEST(scope, ScopeExit) +TEST(Scope, scope_exit) { // ScopeExit always executes the functor on destruction, // unless release() is called @@ -56,7 +56,7 @@ TEST(scope, ScopeExit) EXPECT_EQ(i, 5); } -TEST(scope, ScopeFail) +TEST(Scope, scope_fail) { // ScopeFail executes the functor on destruction only // if an exception is unwinding, unless release() is called @@ -106,7 +106,7 @@ TEST(scope, ScopeFail) EXPECT_EQ(i, 5); } -TEST(scope, ScopeSuccess) +TEST(Scope, scope_success) { // ScopeSuccess executes the functor on destruction only // if an exception is not unwinding, unless release() is called diff --git a/src/tests/libxrpl/basics/tagged_integer.cpp b/src/tests/libxrpl/basics/tagged_integer.cpp index 7382527d4d..dc553d4c0f 100644 --- a/src/tests/libxrpl/basics/tagged_integer.cpp +++ b/src/tests/libxrpl/basics/tagged_integer.cpp @@ -105,7 +105,7 @@ static_assert( using TagInt = TaggedInteger; -TEST(tagged_integer, comparison_operators) +TEST(TaggedInteger, comparison_operators) { TagInt const zero(0); TagInt const one(1); @@ -131,7 +131,7 @@ TEST(tagged_integer, comparison_operators) EXPECT_FALSE(one <= zero); } -TEST(tagged_integer, increment_decrement_operators) +TEST(TaggedInteger, increment_decrement_operators) { TagInt const zero(0); TagInt const one(1); @@ -146,7 +146,7 @@ TEST(tagged_integer, increment_decrement_operators) EXPECT_EQ(a, zero); } -TEST(tagged_integer, arithmetic_operators) +TEST(TaggedInteger, arithmetic_operators) { TagInt const a{-2}; EXPECT_EQ(+a, TagInt{-2}); @@ -166,7 +166,7 @@ TEST(tagged_integer, arithmetic_operators) EXPECT_EQ((TagInt{16} >> TagInt{2}), TagInt{4}); } -TEST(tagged_integer, assignment_operators) +TEST(TaggedInteger, assignment_operators) { TagInt a{-2}; TagInt b{0}; diff --git a/src/tests/libxrpl/crypto/csprng.cpp b/src/tests/libxrpl/crypto/csprng.cpp index 957f7f5c56..71e20f2ddc 100644 --- a/src/tests/libxrpl/crypto/csprng.cpp +++ b/src/tests/libxrpl/crypto/csprng.cpp @@ -6,7 +6,7 @@ using namespace xrpl; -TEST(csprng, get_values) +TEST(Csprng, get_values) { auto& engine = cryptoPrng(); auto randVal = engine(); diff --git a/src/tests/libxrpl/json/Value.cpp b/src/tests/libxrpl/json/Value.cpp index a58a5df9fd..1aa0756419 100644 --- a/src/tests/libxrpl/json/Value.cpp +++ b/src/tests/libxrpl/json/Value.cpp @@ -21,7 +21,7 @@ namespace xrpl { -TEST(json_value, limits) +TEST(JsonValue, limits) { using namespace json; static_assert(Value::kMinInt == Int(~(UInt(-1) / 2))); @@ -29,7 +29,7 @@ TEST(json_value, limits) static_assert(Value::kMaxUInt == UInt(-1)); } -TEST(json_value, construct_and_compare_Json_StaticString) +TEST(JsonValue, construct_and_compare_json_static_string) { static constexpr char kSample[]{"Contents of a json::StaticString"}; @@ -52,7 +52,7 @@ TEST(json_value, construct_and_compare_Json_StaticString) EXPECT_NE(kTest3, str); } -TEST(json_value, different_types) +TEST(JsonValue, different_types) { // Exercise ValueType constructor static constexpr json::StaticString kStaticStr{"staticStr"}; @@ -206,7 +206,7 @@ TEST(json_value, different_types) } } -TEST(json_value, compare_strings) +TEST(JsonValue, compare_strings) { auto doCompare = [&](json::Value const& lhs, json::Value const& rhs, @@ -560,7 +560,7 @@ TEST(json_value, compare_strings) #pragma pop_macro("DO_COMPARE") } -TEST(json_value, bool) +TEST(JsonValue, bool) { EXPECT_FALSE(json::Value()); @@ -583,7 +583,7 @@ TEST(json_value, bool) EXPECT_TRUE(bool(object)); } -TEST(json_value, bad_json) +TEST(JsonValue, bad_json) { char const* s(R"({"method":"ledger","params":[{"ledger_index":1e300}]})"); @@ -607,7 +607,7 @@ parseValue(std::string const& doc) } // namespace -TEST(json_value, parse_double_valid) +TEST(JsonValue, parse_double_valid) { // 1e300 is large but still representable, so it parses (unlike the out-of-range cases below). for (auto const& [text, expected] : @@ -627,14 +627,14 @@ TEST(json_value, parse_double_valid) } } -TEST(json_value, parse_double_out_of_range) +TEST(JsonValue, parse_double_out_of_range) { // Magnitudes with no finite double representation are rejected. for (char const* oor : {"1e400", "-1e400", "0.001e500", "1e-400", "-1e-400", "123e-500"}) EXPECT_FALSE(parseValue(oor).has_value()) << oor; } -TEST(json_value, parse_double_malformed) +TEST(JsonValue, parse_double_malformed) { // readNumber() collects any run of digits and '.eE+-' into a single Double // token, so these malformed tokens reach decodeDouble. Each has a valid @@ -644,7 +644,7 @@ TEST(json_value, parse_double_malformed) EXPECT_FALSE(parseValue(bad).has_value()) << bad; } -TEST(json_value, edge_cases) +TEST(JsonValue, edge_cases) { std::uint32_t const maxUInt = std::numeric_limits::max(); std::int32_t const maxInt = std::numeric_limits::max(); @@ -791,7 +791,7 @@ TEST(json_value, edge_cases) } } -TEST(json_value, copy) +TEST(JsonValue, copy) { json::Value v1{2.5}; EXPECT_TRUE(v1.isDouble()); @@ -812,7 +812,7 @@ TEST(json_value, copy) EXPECT_EQ(v1, v2); } -TEST(json_value, move) +TEST(JsonValue, move) { json::Value v1{2.5}; EXPECT_TRUE(v1.isDouble()); @@ -831,7 +831,7 @@ TEST(json_value, move) EXPECT_NE(v1, v2); // NOLINT(bugprone-use-after-move) } -TEST(json_value, comparisons) +TEST(JsonValue, comparisons) { json::Value a, b; auto testEquals = [&](std::string const& name) { @@ -886,7 +886,7 @@ TEST(json_value, comparisons) testGreaterThan("big"); } -TEST(json_value, compact) +TEST(JsonValue, compact) { json::Value j; json::Reader r; @@ -909,7 +909,7 @@ TEST(json_value, compact) } } -TEST(json_value, conversions) +TEST(JsonValue, conversions) { // We have json::ValueType::Real but json::Value::asDouble. // TODO: What's the thinking here? @@ -1125,7 +1125,7 @@ TEST(json_value, conversions) } } -TEST(json_value, access_members) +TEST(JsonValue, access_members) { json::Value val; EXPECT_EQ(val.type(), json::ValueType::Null); @@ -1218,7 +1218,7 @@ TEST(json_value, access_members) } } -TEST(json_value, remove_members) +TEST(JsonValue, remove_members) { json::Value val; EXPECT_EQ(val.removeMember(std::string("member")).type(), json::ValueType::Null); @@ -1245,7 +1245,7 @@ TEST(json_value, remove_members) EXPECT_EQ(val.size(), 0); } -TEST(json_value, iterator) +TEST(JsonValue, iterator) { { // Iterating an array. @@ -1331,7 +1331,7 @@ TEST(json_value, iterator) } } -TEST(json_value, nest_limits) +TEST(JsonValue, nest_limits) { json::Reader r; { @@ -1377,7 +1377,7 @@ TEST(json_value, nest_limits) } } -TEST(json_value, memory_leak) +TEST(JsonValue, memory_leak) { // When run with the address sanitizer, this test confirms there is no // memory leak with the scenarios below. diff --git a/src/tests/libxrpl/ledger/AMMEntry.cpp b/src/tests/libxrpl/ledger/AMMEntry.cpp index 6189d2b3e6..6013d0f38f 100644 --- a/src/tests/libxrpl/ledger/AMMEntry.cpp +++ b/src/tests/libxrpl/ledger/AMMEntry.cpp @@ -10,7 +10,7 @@ namespace xrpl::test { -TEST(AMMEntryTests, Constructors) +TEST(AMMEntryTests, constructors) { EntryTestEnv e; diff --git a/src/tests/libxrpl/ledger/AccountRootEntry.cpp b/src/tests/libxrpl/ledger/AccountRootEntry.cpp index 964d0f2f43..4c8f18337e 100644 --- a/src/tests/libxrpl/ledger/AccountRootEntry.cpp +++ b/src/tests/libxrpl/ledger/AccountRootEntry.cpp @@ -8,7 +8,7 @@ namespace xrpl::test { -TEST(AccountRootEntryTests, Constructors) +TEST(AccountRootEntryTests, constructors) { EntryTestEnv e; diff --git a/src/tests/libxrpl/ledger/AmendmentsEntry.cpp b/src/tests/libxrpl/ledger/AmendmentsEntry.cpp index ec45b291ec..3f296759f3 100644 --- a/src/tests/libxrpl/ledger/AmendmentsEntry.cpp +++ b/src/tests/libxrpl/ledger/AmendmentsEntry.cpp @@ -7,7 +7,7 @@ namespace xrpl::test { -TEST(AmendmentsEntryTests, Constructors) +TEST(AmendmentsEntryTests, constructors) { EntryTestEnv e; diff --git a/src/tests/libxrpl/ledger/BridgeEntry.cpp b/src/tests/libxrpl/ledger/BridgeEntry.cpp index bae65ea8b5..6e6a4394f8 100644 --- a/src/tests/libxrpl/ledger/BridgeEntry.cpp +++ b/src/tests/libxrpl/ledger/BridgeEntry.cpp @@ -11,7 +11,7 @@ namespace xrpl::test { -TEST(BridgeEntryTests, Constructors) +TEST(BridgeEntryTests, constructors) { EntryTestEnv e; diff --git a/src/tests/libxrpl/ledger/CheckEntry.cpp b/src/tests/libxrpl/ledger/CheckEntry.cpp index da51da047e..58f6250b20 100644 --- a/src/tests/libxrpl/ledger/CheckEntry.cpp +++ b/src/tests/libxrpl/ledger/CheckEntry.cpp @@ -8,7 +8,7 @@ namespace xrpl::test { -TEST(CheckEntryTests, Constructors) +TEST(CheckEntryTests, constructors) { EntryTestEnv e; diff --git a/src/tests/libxrpl/ledger/CredentialEntry.cpp b/src/tests/libxrpl/ledger/CredentialEntry.cpp index ce3f80dca0..16b877858b 100644 --- a/src/tests/libxrpl/ledger/CredentialEntry.cpp +++ b/src/tests/libxrpl/ledger/CredentialEntry.cpp @@ -11,7 +11,7 @@ namespace xrpl::test { -TEST(CredentialEntryTests, Constructors) +TEST(CredentialEntryTests, constructors) { EntryTestEnv e; diff --git a/src/tests/libxrpl/ledger/DIDEntry.cpp b/src/tests/libxrpl/ledger/DIDEntry.cpp index 41b27a486c..ff0017caad 100644 --- a/src/tests/libxrpl/ledger/DIDEntry.cpp +++ b/src/tests/libxrpl/ledger/DIDEntry.cpp @@ -7,7 +7,7 @@ namespace xrpl::test { -TEST(DIDEntryTests, Constructors) +TEST(DIDEntryTests, constructors) { EntryTestEnv e; diff --git a/src/tests/libxrpl/ledger/DelegateEntry.cpp b/src/tests/libxrpl/ledger/DelegateEntry.cpp index a27299df46..3ffc3c73ed 100644 --- a/src/tests/libxrpl/ledger/DelegateEntry.cpp +++ b/src/tests/libxrpl/ledger/DelegateEntry.cpp @@ -8,7 +8,7 @@ namespace xrpl::test { -TEST(DelegateEntryTests, Constructors) +TEST(DelegateEntryTests, constructors) { EntryTestEnv e; diff --git a/src/tests/libxrpl/ledger/DepositPreauthEntry.cpp b/src/tests/libxrpl/ledger/DepositPreauthEntry.cpp index bba2a58c8a..115baa741d 100644 --- a/src/tests/libxrpl/ledger/DepositPreauthEntry.cpp +++ b/src/tests/libxrpl/ledger/DepositPreauthEntry.cpp @@ -14,7 +14,7 @@ namespace xrpl::test { -TEST(DepositPreauthEntryTests, Constructors) +TEST(DepositPreauthEntryTests, constructors) { EntryTestEnv e; diff --git a/src/tests/libxrpl/ledger/DirectoryNodeEntry.cpp b/src/tests/libxrpl/ledger/DirectoryNodeEntry.cpp index 0efcb365f6..41349ea427 100644 --- a/src/tests/libxrpl/ledger/DirectoryNodeEntry.cpp +++ b/src/tests/libxrpl/ledger/DirectoryNodeEntry.cpp @@ -10,7 +10,7 @@ namespace xrpl::test { -TEST(DirectoryNodeEntryTests, Constructors) +TEST(DirectoryNodeEntryTests, constructors) { EntryTestEnv e; diff --git a/src/tests/libxrpl/ledger/EscrowEntry.cpp b/src/tests/libxrpl/ledger/EscrowEntry.cpp index 35ee0d4ad5..2430589641 100644 --- a/src/tests/libxrpl/ledger/EscrowEntry.cpp +++ b/src/tests/libxrpl/ledger/EscrowEntry.cpp @@ -8,7 +8,7 @@ namespace xrpl::test { -TEST(EscrowEntryTests, Constructors) +TEST(EscrowEntryTests, constructors) { EntryTestEnv e; diff --git a/src/tests/libxrpl/ledger/FeeSettingsEntry.cpp b/src/tests/libxrpl/ledger/FeeSettingsEntry.cpp index 2528cdbca5..b7b8325736 100644 --- a/src/tests/libxrpl/ledger/FeeSettingsEntry.cpp +++ b/src/tests/libxrpl/ledger/FeeSettingsEntry.cpp @@ -7,7 +7,7 @@ namespace xrpl::test { -TEST(FeeSettingsEntryTests, Constructors) +TEST(FeeSettingsEntryTests, constructors) { EntryTestEnv e; diff --git a/src/tests/libxrpl/ledger/LedgerHashesEntry.cpp b/src/tests/libxrpl/ledger/LedgerHashesEntry.cpp index e5c635c92c..94cb6b1db6 100644 --- a/src/tests/libxrpl/ledger/LedgerHashesEntry.cpp +++ b/src/tests/libxrpl/ledger/LedgerHashesEntry.cpp @@ -7,7 +7,7 @@ namespace xrpl::test { -TEST(LedgerHashesEntryTests, Constructors) +TEST(LedgerHashesEntryTests, constructors) { EntryTestEnv e; diff --git a/src/tests/libxrpl/ledger/LoanBrokerEntry.cpp b/src/tests/libxrpl/ledger/LoanBrokerEntry.cpp index e3a180c21b..ab0ced00c0 100644 --- a/src/tests/libxrpl/ledger/LoanBrokerEntry.cpp +++ b/src/tests/libxrpl/ledger/LoanBrokerEntry.cpp @@ -8,7 +8,7 @@ namespace xrpl::test { -TEST(LoanBrokerEntryTests, Constructors) +TEST(LoanBrokerEntryTests, constructors) { EntryTestEnv e; diff --git a/src/tests/libxrpl/ledger/LoanEntry.cpp b/src/tests/libxrpl/ledger/LoanEntry.cpp index c91063c73f..23bc03bc0c 100644 --- a/src/tests/libxrpl/ledger/LoanEntry.cpp +++ b/src/tests/libxrpl/ledger/LoanEntry.cpp @@ -9,7 +9,7 @@ namespace xrpl::test { -TEST(LoanEntryTests, Constructors) +TEST(LoanEntryTests, constructors) { EntryTestEnv e; diff --git a/src/tests/libxrpl/ledger/MPTokenEntry.cpp b/src/tests/libxrpl/ledger/MPTokenEntry.cpp index f93b210c87..fe89555c7e 100644 --- a/src/tests/libxrpl/ledger/MPTokenEntry.cpp +++ b/src/tests/libxrpl/ledger/MPTokenEntry.cpp @@ -8,7 +8,7 @@ namespace xrpl::test { -TEST(MPTokenEntryTests, Constructors) +TEST(MPTokenEntryTests, constructors) { EntryTestEnv e; diff --git a/src/tests/libxrpl/ledger/MPTokenIssuanceEntry.cpp b/src/tests/libxrpl/ledger/MPTokenIssuanceEntry.cpp index 2553f29435..825f75debe 100644 --- a/src/tests/libxrpl/ledger/MPTokenIssuanceEntry.cpp +++ b/src/tests/libxrpl/ledger/MPTokenIssuanceEntry.cpp @@ -10,7 +10,7 @@ namespace xrpl::test { -TEST(MPTokenIssuanceEntryTests, Constructors) +TEST(MPTokenIssuanceEntryTests, constructors) { EntryTestEnv e; diff --git a/src/tests/libxrpl/ledger/NFTokenOfferEntry.cpp b/src/tests/libxrpl/ledger/NFTokenOfferEntry.cpp index 728977cab4..2ad676e220 100644 --- a/src/tests/libxrpl/ledger/NFTokenOfferEntry.cpp +++ b/src/tests/libxrpl/ledger/NFTokenOfferEntry.cpp @@ -8,7 +8,7 @@ namespace xrpl::test { -TEST(NFTokenOfferEntryTests, Constructors) +TEST(NFTokenOfferEntryTests, constructors) { EntryTestEnv e; diff --git a/src/tests/libxrpl/ledger/NFTokenPageEntry.cpp b/src/tests/libxrpl/ledger/NFTokenPageEntry.cpp index e5a5a1d7fd..e392cc52af 100644 --- a/src/tests/libxrpl/ledger/NFTokenPageEntry.cpp +++ b/src/tests/libxrpl/ledger/NFTokenPageEntry.cpp @@ -8,7 +8,7 @@ namespace xrpl::test { -TEST(NFTokenPageEntryTests, Constructors) +TEST(NFTokenPageEntryTests, constructors) { EntryTestEnv e; diff --git a/src/tests/libxrpl/ledger/NegativeUNLEntry.cpp b/src/tests/libxrpl/ledger/NegativeUNLEntry.cpp index 2a4bc0b59b..6a15578b3e 100644 --- a/src/tests/libxrpl/ledger/NegativeUNLEntry.cpp +++ b/src/tests/libxrpl/ledger/NegativeUNLEntry.cpp @@ -7,7 +7,7 @@ namespace xrpl::test { -TEST(NegativeUNLEntryTests, Constructors) +TEST(NegativeUNLEntryTests, constructors) { EntryTestEnv e; diff --git a/src/tests/libxrpl/ledger/OfferEntry.cpp b/src/tests/libxrpl/ledger/OfferEntry.cpp index dc32679138..17f98b69c2 100644 --- a/src/tests/libxrpl/ledger/OfferEntry.cpp +++ b/src/tests/libxrpl/ledger/OfferEntry.cpp @@ -8,7 +8,7 @@ namespace xrpl::test { -TEST(OfferEntryTests, Constructors) +TEST(OfferEntryTests, constructors) { EntryTestEnv e; diff --git a/src/tests/libxrpl/ledger/OracleEntry.cpp b/src/tests/libxrpl/ledger/OracleEntry.cpp index a505017e1f..f467056c1e 100644 --- a/src/tests/libxrpl/ledger/OracleEntry.cpp +++ b/src/tests/libxrpl/ledger/OracleEntry.cpp @@ -9,7 +9,7 @@ namespace xrpl::test { -TEST(OracleEntryTests, Constructors) +TEST(OracleEntryTests, constructors) { EntryTestEnv e; diff --git a/src/tests/libxrpl/ledger/PayChannelEntry.cpp b/src/tests/libxrpl/ledger/PayChannelEntry.cpp index 7866f34be0..82dcdb3be8 100644 --- a/src/tests/libxrpl/ledger/PayChannelEntry.cpp +++ b/src/tests/libxrpl/ledger/PayChannelEntry.cpp @@ -9,7 +9,7 @@ namespace xrpl::test { -TEST(PayChannelEntryTests, Constructors) +TEST(PayChannelEntryTests, constructors) { EntryTestEnv e; diff --git a/src/tests/libxrpl/ledger/PermissionedDomainEntry.cpp b/src/tests/libxrpl/ledger/PermissionedDomainEntry.cpp index cbaa2f0300..feb3002c4e 100644 --- a/src/tests/libxrpl/ledger/PermissionedDomainEntry.cpp +++ b/src/tests/libxrpl/ledger/PermissionedDomainEntry.cpp @@ -8,7 +8,7 @@ namespace xrpl::test { -TEST(PermissionedDomainEntryTests, Constructors) +TEST(PermissionedDomainEntryTests, constructors) { EntryTestEnv e; diff --git a/src/tests/libxrpl/ledger/RippleStateEntry.cpp b/src/tests/libxrpl/ledger/RippleStateEntry.cpp index c46921bced..721dc09157 100644 --- a/src/tests/libxrpl/ledger/RippleStateEntry.cpp +++ b/src/tests/libxrpl/ledger/RippleStateEntry.cpp @@ -10,7 +10,7 @@ namespace xrpl::test { -TEST(RippleStateEntryTests, Constructors) +TEST(RippleStateEntryTests, constructors) { EntryTestEnv e; diff --git a/src/tests/libxrpl/ledger/SLEBase.cpp b/src/tests/libxrpl/ledger/SLEBase.cpp index 910cb27f09..f721ea760d 100644 --- a/src/tests/libxrpl/ledger/SLEBase.cpp +++ b/src/tests/libxrpl/ledger/SLEBase.cpp @@ -149,7 +149,7 @@ protected: } }; -TEST_F(SLEBaseTests, ReadOnly) +TEST_F(SLEBaseTests, read_only) { AccountRootEntryR const absent(bob_.id(), env_.getClosedLedger()); EXPECT_FALSE(absent.exists()); @@ -168,7 +168,7 @@ TEST_F(SLEBaseTests, ReadOnly) EXPECT_EQ(&present.readView(), &env_.getClosedLedger()); } -TEST_F(SLEBaseTests, AdoptSLE) +TEST_F(SLEBaseTests, adopt_sle) { auto const sle = env_.getClosedLedger().read(keylet::account(alice_.id())); ASSERT_NE(sle, nullptr); @@ -201,7 +201,7 @@ TEST_F(SLEBaseTests, AdoptSLE) "writable entries must not be constructible from a bare SLE"); } -TEST_F(SLEBaseTests, WritableAccessors) +TEST_F(SLEBaseTests, writable_accessors) { ApplyViewImpl av(&env_.getClosedLedger(), TapNone); beast::Journal const j{beast::Journal::getNullSink()}; @@ -236,7 +236,7 @@ TEST_F(SLEBaseTests, WritableAccessors) !HasApplyView, "applyView() must not exist on a read-only entry"); } -TEST_F(SLEBaseTests, ApplyViewContextCtor) +TEST_F(SLEBaseTests, apply_view_context_ctor) { ApplyViewImpl av(&env_.getClosedLedger(), TapNone); beast::Journal const j{beast::Journal::getNullSink()}; @@ -260,7 +260,7 @@ TEST_F(SLEBaseTests, ApplyViewContextCtor) EXPECT_EQ(fromCtx.rawSle(), fromView.rawSle()); } -TEST_F(SLEBaseTests, WritableLifecycle) +TEST_F(SLEBaseTests, writable_lifecycle) { // A view we never apply, so nothing here reaches the ledger. ApplyViewImpl av(&env_.getClosedLedger(), TapNone); @@ -318,7 +318,7 @@ TEST_F(SLEBaseTests, WritableLifecycle) } } -TEST_F(SLEBaseTests, Conversion) +TEST_F(SLEBaseTests, conversion) { ApplyViewImpl av(&env_.getClosedLedger(), TapNone); @@ -336,7 +336,7 @@ TEST_F(SLEBaseTests, Conversion) EXPECT_EQ(generic.type(), ltACCOUNT_ROOT); } -TEST_F(SLEBaseTests, ResolveEntryPeeks) +TEST_F(SLEBaseTests, resolve_entry_peeks) { // getOpenLedger() is an OpenView, which derives from ReadView but not // from ApplyView, so resolveEntry's dynamic_cast fails and this takes @@ -368,7 +368,7 @@ TEST_F(SLEBaseTests, ResolveEntryPeeks) EXPECT_EQ(readOnly->getFieldU32(sfSequence), bumped); } -TEST_F(SLEBaseTests, ThrowsOnMissingEntry) +TEST_F(SLEBaseTests, throws_on_missing_entry) { // A generic read-only entry has no static type to fall back on, so // type() must read it off the (absent) SLE and throw. @@ -389,7 +389,7 @@ TEST_F(SLEBaseTests, ThrowsOnMissingEntry) EXPECT_THROW(std::ignore = (*missing).getType(), std::logic_error); } -TEST_F(SLEBaseTests, ThrowsOnMissingWritableEntry) +TEST_F(SLEBaseTests, throws_on_missing_writable_entry) { // A view we never apply, so nothing here reaches the ledger. ApplyViewImpl av(&env_.getClosedLedger(), TapNone); diff --git a/src/tests/libxrpl/ledger/SignerListEntry.cpp b/src/tests/libxrpl/ledger/SignerListEntry.cpp index 8f0e8f5d2d..db65af2f02 100644 --- a/src/tests/libxrpl/ledger/SignerListEntry.cpp +++ b/src/tests/libxrpl/ledger/SignerListEntry.cpp @@ -7,7 +7,7 @@ namespace xrpl::test { -TEST(SignerListEntryTests, Constructors) +TEST(SignerListEntryTests, constructors) { EntryTestEnv e; diff --git a/src/tests/libxrpl/ledger/SponsorshipEntry.cpp b/src/tests/libxrpl/ledger/SponsorshipEntry.cpp index c5004b8d32..735c3e202d 100644 --- a/src/tests/libxrpl/ledger/SponsorshipEntry.cpp +++ b/src/tests/libxrpl/ledger/SponsorshipEntry.cpp @@ -8,7 +8,7 @@ namespace xrpl::test { -TEST(SponsorshipEntryTests, Constructors) +TEST(SponsorshipEntryTests, constructors) { EntryTestEnv e; diff --git a/src/tests/libxrpl/ledger/TicketEntry.cpp b/src/tests/libxrpl/ledger/TicketEntry.cpp index e09af46cbf..fa26b1a937 100644 --- a/src/tests/libxrpl/ledger/TicketEntry.cpp +++ b/src/tests/libxrpl/ledger/TicketEntry.cpp @@ -8,7 +8,7 @@ namespace xrpl::test { -TEST(TicketEntryTests, Constructors) +TEST(TicketEntryTests, constructors) { EntryTestEnv e; diff --git a/src/tests/libxrpl/ledger/VaultEntry.cpp b/src/tests/libxrpl/ledger/VaultEntry.cpp index 0cff87bbe9..61f24db410 100644 --- a/src/tests/libxrpl/ledger/VaultEntry.cpp +++ b/src/tests/libxrpl/ledger/VaultEntry.cpp @@ -8,7 +8,7 @@ namespace xrpl::test { -TEST(VaultEntryTests, Constructors) +TEST(VaultEntryTests, constructors) { EntryTestEnv e; diff --git a/src/tests/libxrpl/ledger/XChainOwnedClaimIDEntry.cpp b/src/tests/libxrpl/ledger/XChainOwnedClaimIDEntry.cpp index 1833c68f79..4145471a27 100644 --- a/src/tests/libxrpl/ledger/XChainOwnedClaimIDEntry.cpp +++ b/src/tests/libxrpl/ledger/XChainOwnedClaimIDEntry.cpp @@ -12,7 +12,7 @@ namespace xrpl::test { -TEST(XChainOwnedClaimIDEntryTests, Constructors) +TEST(XChainOwnedClaimIDEntryTests, constructors) { EntryTestEnv e; diff --git a/src/tests/libxrpl/ledger/XChainOwnedCreateAccountClaimIDEntry.cpp b/src/tests/libxrpl/ledger/XChainOwnedCreateAccountClaimIDEntry.cpp index d109000f77..93851deedf 100644 --- a/src/tests/libxrpl/ledger/XChainOwnedCreateAccountClaimIDEntry.cpp +++ b/src/tests/libxrpl/ledger/XChainOwnedCreateAccountClaimIDEntry.cpp @@ -13,7 +13,7 @@ namespace xrpl::test { -TEST(XChainOwnedCreateAccountClaimIDEntryTests, Constructors) +TEST(XChainOwnedCreateAccountClaimIDEntryTests, constructors) { EntryTestEnv e; diff --git a/src/tests/libxrpl/protocol/STXChainBridge.cpp b/src/tests/libxrpl/protocol/STXChainBridge.cpp index f4e6e60cc9..22f26d7ea0 100644 --- a/src/tests/libxrpl/protocol/STXChainBridge.cpp +++ b/src/tests/libxrpl/protocol/STXChainBridge.cpp @@ -28,7 +28,7 @@ account(std::string_view hex) // getText() builds its string from eight substitutions of the same type, so a // transposed pair would still compile and still type check. Pin the output so // the field/value pairing is actually verified. -TEST(STXChainBridge, getTextPairsEachFieldWithItsValue) +TEST(STXChainBridge, get_text_pairs_each_field_with_its_value) { auto const lockingDoor = account("0102030405060708090A0B0C0D0E0F1011121314"); auto const issuingDoor = account("14131211100F0E0D0C0B0A090807060504030201"); @@ -46,7 +46,7 @@ TEST(STXChainBridge, getTextPairsEachFieldWithItsValue) EXPECT_EQ(bridge.getText(), expected); } -TEST(STXChainBridge, getTextOnADefaultBridge) +TEST(STXChainBridge, get_text_on_a_default_bridge) { STXChainBridge const bridge; auto const text = bridge.getText(); diff --git a/src/tests/libxrpl/server/InfoSub.cpp b/src/tests/libxrpl/server/InfoSub.cpp index 6913812a92..08698ae3ac 100644 --- a/src/tests/libxrpl/server/InfoSub.cpp +++ b/src/tests/libxrpl/server/InfoSub.cpp @@ -12,7 +12,7 @@ using namespace xrpl; // by subscribing the real cap through a WebSocket, which would exceed the frame // limit and drop the connection before the check runs) lets the boundary be // asserted exactly. -TEST(InfoSubSubscriptionCap, Boundary) +TEST(InfoSubSubscriptionCap, boundary) { constexpr std::size_t cap = kMaxSubscriptionsPerConnection; @@ -30,7 +30,7 @@ TEST(InfoSubSubscriptionCap, Boundary) EXPECT_TRUE(exceedsSubscriptionCap(cap - 1, 2)); } -TEST(InfoSubSubscriptionCap, NoOverflow) +TEST(InfoSubSubscriptionCap, no_overflow) { constexpr std::size_t cap = kMaxSubscriptionsPerConnection; constexpr std::size_t max = std::numeric_limits::max(); @@ -41,7 +41,7 @@ TEST(InfoSubSubscriptionCap, NoOverflow) EXPECT_TRUE(exceedsSubscriptionCap(cap, max)); } -TEST(InfoSubSubscriptionCap, ExplicitCap) +TEST(InfoSubSubscriptionCap, explicit_cap) { // A configured override is honored: the boundary tracks the passed cap, not // the built-in default. This is the seam doSubscribe uses to enforce a diff --git a/src/tests/libxrpl/tx/AccountSet.cpp b/src/tests/libxrpl/tx/AccountSet.cpp index ae291791d4..fe6818670e 100644 --- a/src/tests/libxrpl/tx/AccountSet.cpp +++ b/src/tests/libxrpl/tx/AccountSet.cpp @@ -43,7 +43,7 @@ namespace xrpl::test { -TEST(AccountSet, NullAccountSet) +TEST(AccountSet, null_account_set) { TxTest env; @@ -60,7 +60,7 @@ TEST(AccountSet, NullAccountSet) EXPECT_EQ(accountRoot.getFlags(), 0); } -TEST(AccountSet, MostFlags) +TEST(AccountSet, most_flags) { Account const alice("alice"); @@ -175,7 +175,7 @@ TEST(AccountSet, MostFlags) }); } -TEST(AccountSet, SetAndResetAccountTxnID) +TEST(AccountSet, set_and_reset_account_txn_id) { TxTest env; Account const alice("alice"); @@ -206,7 +206,7 @@ TEST(AccountSet, SetAndResetAccountTxnID) EXPECT_EQ(nowFlags, origFlags); } -TEST(AccountSet, SetNoFreeze) +TEST(AccountSet, set_no_freeze) { TxTest env; Account const alice("alice"); @@ -249,7 +249,7 @@ TEST(AccountSet, SetNoFreeze) EXPECT_TRUE(env.getAccountRoot(alice).isFlag(lsfNoFreeze)); } -TEST(AccountSet, Domain) +TEST(AccountSet, domain) { TxTest env; Account const alice("alice"); @@ -317,7 +317,7 @@ TEST(AccountSet, Domain) } } -TEST(AccountSet, MessageKey) +TEST(AccountSet, message_key) { TxTest env; Account const alice("alice"); @@ -358,7 +358,7 @@ TEST(AccountSet, MessageKey) telBAD_PUBLIC_KEY); } -TEST(AccountSet, WalletID) +TEST(AccountSet, wallet_id) { TxTest env; Account const alice("alice"); @@ -391,7 +391,7 @@ TEST(AccountSet, WalletID) EXPECT_FALSE(env.getAccountRoot(alice).hasWalletLocator()); } -TEST(AccountSet, EmailHash) +TEST(AccountSet, email_hash) { TxTest env; Account const alice("alice"); @@ -422,7 +422,7 @@ TEST(AccountSet, EmailHash) EXPECT_FALSE(env.getAccountRoot(alice).hasEmailHash()); } -TEST(AccountSet, TransferRate) +TEST(AccountSet, transfer_rate) { struct TestCase { @@ -473,7 +473,7 @@ TEST(AccountSet, TransferRate) } } -TEST(AccountSet, BadInputs) +TEST(AccountSet, bad_inputs) { TxTest env; Account const alice("alice"); @@ -553,7 +553,7 @@ TEST(AccountSet, BadInputs) tecNO_ALTERNATIVE_KEY); } -TEST(AccountSet, RequireAuthWithDir) +TEST(AccountSet, require_auth_with_dir) { TxTest env; Account const alice("alice"); @@ -601,7 +601,7 @@ TEST(AccountSet, RequireAuthWithDir) tesSUCCESS); } -TEST(AccountSet, Ticket) +TEST(AccountSet, ticket) { TxTest env; Account const alice("alice"); @@ -660,7 +660,7 @@ TEST(AccountSet, Ticket) tefNO_TICKET); } -TEST(AccountSet, BadSigningKey) +TEST(AccountSet, bad_signing_key) { TxTest env; Account const alice("alice"); @@ -684,7 +684,7 @@ TEST(AccountSet, BadSigningKey) EXPECT_FALSE(result.applied); } -TEST(AccountSet, Gateway) +TEST(AccountSet, gateway) { Account const alice("alice"); Account const bob("bob"); From d38e82b7d3ee42a3c3c38aae42051a98179f7922 Mon Sep 17 00:00:00 2001 From: Pratik Mankawde <3397372+pratikmankawde@users.noreply.github.com> Date: Wed, 23 Sep 2026 14:29:21 +0100 Subject: [PATCH 09/10] fix(telemetry): Clear the global instance only from the object that set it All three stop() overrides cleared the global Telemetry instance unconditionally, which assumes one live Application per process. The unit-test binary breaks that whenever two environments run, and makeTelemetry() hands it a NullTelemetryOtel, so the null implementations matter as much as the real one. Compare against this before clearing, in all three. --- src/libxrpl/telemetry/NullTelemetry.cpp | 8 +++++++- src/libxrpl/telemetry/Telemetry.cpp | 16 +++++++++++++--- 2 files changed, 20 insertions(+), 4 deletions(-) diff --git a/src/libxrpl/telemetry/NullTelemetry.cpp b/src/libxrpl/telemetry/NullTelemetry.cpp index 81ad9e108a..7f65e980f9 100644 --- a/src/libxrpl/telemetry/NullTelemetry.cpp +++ b/src/libxrpl/telemetry/NullTelemetry.cpp @@ -64,7 +64,13 @@ public: void stop() override { - Telemetry::setInstance(nullptr); + // Clear the global instance only if this object is the one that + // published it. A process with two of these, as the test binary has, + // would otherwise let one unregister the other. + if (Telemetry::getInstance() == this) + { + Telemetry::setInstance(nullptr); + } } [[nodiscard]] bool diff --git a/src/libxrpl/telemetry/Telemetry.cpp b/src/libxrpl/telemetry/Telemetry.cpp index 6c72b2ed20..bf098445e6 100644 --- a/src/libxrpl/telemetry/Telemetry.cpp +++ b/src/libxrpl/telemetry/Telemetry.cpp @@ -176,7 +176,13 @@ public: void stop() override { - Telemetry::setInstance(nullptr); + // Clear the global instance only if this object is the one that + // published it. A process with two of these, as the test binary has, + // would otherwise let one unregister the other. + if (Telemetry::getInstance() == this) + { + Telemetry::setInstance(nullptr); + } } [[nodiscard]] bool @@ -369,8 +375,12 @@ public: { JLOG(journal_.info()) << "Telemetry stopping"; - // Unregister global instance before tearing down the pipeline. - Telemetry::setInstance(nullptr); + // Unregister global instance before tearing down the pipeline, but only + // if this object is the one that published it. + if (Telemetry::getInstance() == this) + { + Telemetry::setInstance(nullptr); + } if (sdkProvider_) { From 30640a626fe8727974cf0a5afa69e89a5abe58c7 Mon Sep 17 00:00:00 2001 From: yinyiqian1 Date: Wed, 23 Sep 2026 16:24:41 +0000 Subject: [PATCH 10/10] test: Clean up ConfidentialTransfer test helpers (#8265) --- src/test/jtx/ConfidentialTransfer.h | 688 ++++----------------- src/test/jtx/impl/ConfidentialTransfer.cpp | 550 ++++++++++++++++ 2 files changed, 663 insertions(+), 575 deletions(-) diff --git a/src/test/jtx/ConfidentialTransfer.h b/src/test/jtx/ConfidentialTransfer.h index 5c1b90328b..21e1929902 100644 --- a/src/test/jtx/ConfidentialTransfer.h +++ b/src/test/jtx/ConfidentialTransfer.h @@ -3,28 +3,19 @@ #include #include #include -#include #include -#include #include #include -#include #include #include #include #include #include -#include - -#include -#include - #include #include #include -#include #include #include #include @@ -54,237 +45,66 @@ protected: return *value; } - // Offset where the bulletproof begins in a send proof blob. - // Proof layout: [compact_sigma | bulletproof] - static constexpr size_t kBulletproofOffset = kEcSendProofLength - kEcDoubleBulletproofLength; - - // 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. - // secp256k1 convention: returns 1 on success, 0 on failure. - static Buffer - getForgedBulletproof( - std::array const& values, - std::array const& blindingFactors, - uint256 const& contextHash) + // Creates the MPT issuance on the given Env, authorizes and funds each + // holder, generates keys for the issuer, holders and optional auditor, + // registers the issuer/auditor keys, and converts part of each holder's + // balance to a confidential balance. + struct ConfidentialEnv { - auto* const ctx = mpt_secp256k1_context(); + // Per-holder configuration: the account, how much MPT to fund it + // with, and how much of that to convert to a confidential balance. + struct HolderInit + { + test::jtx::Account account; + std::uint64_t payAmount = 1000; + std::uint64_t convertAmount = 100; + }; - secp256k1_pubkey h; - secp256k1_mpt_get_h_generator(ctx, &h); + test::jtx::MPTTester mpt; - Buffer proof(kEcDoubleBulletproofLength); - size_t proofLen = kEcDoubleBulletproofLength; + ConfidentialEnv( + test::jtx::Env& env, + test::jtx::Account const& issuer, + std::vector const& holders, + std::uint32_t flags = tfMPTCanLock | tfMPTCanHoldConfidentialBalance | tfMPTCanTransfer, + std::optional auditor = std::nullopt); - unsigned char blindings[64]; - std::memcpy(blindings, blindingFactors[0].data(), 32); - std::memcpy(blindings + 32, blindingFactors[1].data(), 32); + private: + static std::vector + extractAccounts(std::vector const& holders); + }; - if (secp256k1_bulletproof_prove_agg( - ctx, - proof.data(), - &proofLen, - values.data(), - blindings, - 2, - &h, - contextHash.data()) == 0) - Throw("Failed to generate forged bulletproof"); - - return proof; - } - - // Generate a forged single bulletproof for a single value and blinding factor. - // Used to test ConvertBack overdraft prevention via bulletproof verification. - static Buffer - getForgedSingleBulletproof( - uint64_t value, - Buffer const& blindingFactor, - uint256 const& contextHash) - { - auto* const ctx = mpt_secp256k1_context(); - - secp256k1_pubkey h; - secp256k1_mpt_get_h_generator(ctx, &h); - - Buffer proof(kEcSingleBulletproofLength); - size_t proofLen = kEcSingleBulletproofLength; - - if (secp256k1_bulletproof_prove_agg( - ctx, - proof.data(), - &proofLen, - &value, - blindingFactor.data(), - 1, // m = 1 (single bulletproof) - &h, - contextHash.data()) == 0) - Throw("Failed to generate forged single bulletproof"); - - return proof; - } - - // Forges a ConvertBack proof (compact sigma + single bulletproof) whose - // sigma component claims claimedBalance (which may be wrong) while binding - // to the real pedersen commitment and encrypted spending balance - // ciphertext already on the ledger. The bulletproof component is built - // from realBalance so it stays honest. - // mpt_get_convert_back_proof does not allow to build a proof whose amount - // exceeds the holder's claimed balance. - static Buffer - getForgedConvertBackProof( + // Create an issuance that can hold confidential balances, with the listed + // holders funded and authorized, and a key pair generated for the issuer, + // every holder, and every extra key owner. The keys are + // generated but not registered. + static void + setupConfidentialIssuance( test::jtx::MPTTester& mpt, - test::jtx::Account const& holder, - uint64_t claimedBalance, - uint64_t realBalance, - uint64_t amt, - Buffer const& pedersenCommitment, - Buffer const& encryptedSpendingBalance, - Buffer const& pcBlindingFactor, - uint256 const& contextHash) - { - if (pedersenCommitment.size() != kCompressedEcPointLength) - Throw("getForgedConvertBackProof: bad pedersenCommitment length"); - if (encryptedSpendingBalance.size() != kEcGamalEncryptedTotalLength) - { - Throw( - "getForgedConvertBackProof: bad encryptedSpendingBalance length"); - } - if (amt > realBalance) - Throw("getForgedConvertBackProof: amt exceeds realBalance"); + test::jtx::Account const& issuer, + std::vector const& holders, + std::vector const& keyOwners = {}, + std::uint32_t flags = tfMPTCanTransfer | tfMPTCanHoldConfidentialBalance); - auto* const ctx = mpt_secp256k1_context(); - auto const holderPubKey = requireOptional(mpt.getPubKey(holder), "Missing holder pubkey"); - auto const holderPrivKey = - requireOptional(mpt.getPrivKey(holder), "Missing holder privkey"); - - secp256k1_pubkey pkHolder; - if (secp256k1_ec_pubkey_parse( - ctx, &pkHolder, holderPubKey.data(), kCompressedEcPointLength) != 1) - Throw("Failed to parse holder's public key"); - - secp256k1_pubkey pcB; - if (secp256k1_ec_pubkey_parse( - ctx, &pcB, pedersenCommitment.data(), kCompressedEcPointLength) != 1) - Throw("Failed to parse pedersen commitment"); - - secp256k1_pubkey b1, b2; - if (secp256k1_ec_pubkey_parse( - ctx, &b1, encryptedSpendingBalance.data(), kCompressedEcPointLength) != 1 || - secp256k1_ec_pubkey_parse( - ctx, - &b2, - encryptedSpendingBalance.data() + kCompressedEcPointLength, - kCompressedEcPointLength) != 1) - Throw("Failed to parse balance ciphertext"); - - Buffer sigmaProof(SECP256K1_COMPACT_CONVERTBACK_PROOF_SIZE); - if (secp256k1_compact_convertback_prove( - ctx, - sigmaProof.data(), - claimedBalance, - holderPrivKey.data(), - pcBlindingFactor.data(), - &pkHolder, - &b1, - &b2, - &pcB, - contextHash.data()) != 1) - Throw("Failed to generate convertback sigma proof"); - - auto const forgedBulletproof = - getForgedSingleBulletproof(realBalance - amt, pcBlindingFactor, contextHash); - - Buffer proof(kEcConvertBackProofLength); - std::memcpy(proof.data(), sigmaProof.data(), SECP256K1_COMPACT_CONVERTBACK_PROOF_SIZE); - std::memcpy( - proof.data() + SECP256K1_COMPACT_CONVERTBACK_PROOF_SIZE, - forgedBulletproof.data(), - kEcSingleBulletproofLength); - - return proof; - } - - // Get a bad ciphertext with valid structure but cryptographic invalid for - // testing purposes. For preflight test purposes. - static Buffer const& - getBadCiphertext() - { - static Buffer const kBadCiphertext = []() { - Buffer buf(kEcGamalEncryptedTotalLength); - std::memset(buf.data(), 0xFF, kEcGamalEncryptedTotalLength); - - buf.data()[0] = kEcCompressedPrefixEvenY; - buf.data()[kEcCiphertextComponentLength] = kEcCompressedPrefixEvenY; - return buf; - }(); - - return kBadCiphertext; - } - - // Get a trivial buffer that is structurally and mathematically valid, but - // contains invalid data that does not match the ledger state. For preclaim - // test purposes. - static Buffer const& - getTrivialCiphertext() - { - static Buffer const kTrivialCiphertext = []() { - Buffer buf(kEcGamalEncryptedTotalLength); - std::memset(buf.data(), 0, kEcGamalEncryptedTotalLength); - - buf.data()[0] = kEcCompressedPrefixEvenY; - buf.data()[kEcCiphertextComponentLength] = kEcCompressedPrefixEvenY; - - buf.data()[kEcCiphertextComponentLength - 1] = 0x01; - buf.data()[kEcGamalEncryptedTotalLength - 1] = 0x01; - - return buf; - }(); - - return kTrivialCiphertext; - } - - // Returns a valid compressed EC point (33 bytes) that can pass preflight - // validation but contains invalid data for preclaim test purposes. - static Buffer const& - getTrivialCommitment() - { - static Buffer const kTrivialCommitment = []() { - Buffer buf(kEcPedersenCommitmentLength); - std::memset(buf.data(), 0, kEcPedersenCommitmentLength); - - buf.data()[0] = kEcCompressedPrefixEvenY; - // Set last byte to make it a valid x-coordinate on the curve - buf.data()[kEcPedersenCommitmentLength - 1] = 0x01; - - return buf; - }(); - - return kTrivialCommitment; - } - - static std::string - getTrivialSendProofHex() - { - Buffer buf(kEcSendProofLength); - std::memset(buf.data(), 0, kEcSendProofLength); - - for (std::size_t i = 0; i < kEcSendProofLength; i += kEcCiphertextComponentLength) - { - buf.data()[i] = kEcCompressedPrefixEvenY; - if (i + kEcCiphertextComponentLength - 1 < kEcSendProofLength) - buf.data()[i + kEcCiphertextComponentLength - 1] = 0x01; - } - - return strHex(buf); - } + // Set up an MPT environment suitable for batch testing. + // alice is issuer; bob has 'bobAmt' in confidential spending; carol has + // 'carolAmt' in confidential spending; dave is initialised with pubkey but + // zero spending/inbox. + static void + setupBatchEnv( + test::jtx::MPTTester& mpt, + test::jtx::Account const& alice, + test::jtx::Account const& bob, + test::jtx::Account const& carol, + test::jtx::Account const& dave, + std::uint64_t bobAmt, + std::uint64_t carolAmt); // Helper struct to encapsulate common setup for integration tests. struct ConfidentialSendSetup { // Constants uint64_t sendAmount; - size_t nRecipients; uint32_t version; // Blinding factors @@ -324,55 +144,7 @@ protected: test::jtx::Account const& dest, test::jtx::Account const& issuer, uint64_t amount, - std::optional> auditor = std::nullopt) - : sendAmount(amount) - , nRecipients(auditor ? 4 : 3) - , version(mpt.getMPTokenVersion(sender)) - , blindingFactor(generateBlindingFactor()) - , amountBlindingFactor(blindingFactor) - , balanceBlindingFactor(generateBlindingFactor()) - , senderAmt(mpt.encryptAmount(sender, amount, blindingFactor)) - , destAmt(mpt.encryptAmount(dest, amount, blindingFactor)) - , issuerAmt(mpt.encryptAmount(issuer, amount, blindingFactor)) - , auditorAmt( - auditor ? std::optional( - mpt.encryptAmount(auditor->get(), amount, blindingFactor)) - : std::nullopt) - , amountCommitment(mpt.getPedersenCommitment(amount, amountBlindingFactor)) - , senderPubKey(requireOptional(mpt.getPubKey(sender), "Missing sender public key")) - , destPubKey(requireOptional(mpt.getPubKey(dest), "Missing destination public key")) - , issuerPubKey(requireOptional(mpt.getPubKey(issuer), "Missing issuer public key")) - , auditorPubKey(auditor ? mpt.getPubKey(auditor->get()) : std::nullopt) - , prevSpending(requireOptional( - mpt.getDecryptedBalance(sender, test::jtx::MPTTester::holderEncryptedSpending), - "Missing sender spending balance")) - , prevEncryptedSpending(requireOptional( - mpt.getEncryptedBalance(sender, test::jtx::MPTTester::holderEncryptedSpending), - "Missing sender encrypted spending balance")) - , balanceCommitment(mpt.getPedersenCommitment(prevSpending, balanceBlindingFactor)) - { - recipients.push_back({ - .publicKey = Slice(senderPubKey), - .encryptedAmount = senderAmt, - }); - recipients.push_back({ - .publicKey = Slice(destPubKey), - .encryptedAmount = destAmt, - }); - recipients.push_back({ - .publicKey = Slice(issuerPubKey), - .encryptedAmount = issuerAmt, - }); - if (auditor) - { - recipients.push_back({ - .publicKey = - Slice(requireOptionalRef(auditorPubKey, "Missing auditor public key")), - .encryptedAmount = - requireOptionalRef(auditorAmt, "Missing auditor encrypted amount"), - }); - } - } + std::optional> auditor = std::nullopt); // Generate proof with current account sequence std::optional @@ -380,54 +152,78 @@ protected: test::jtx::MPTTester& mpt, test::jtx::Env& env, test::jtx::Account const& sender, - test::jtx::Account const& dest) const - { - auto const ctxHash = getSendContextHash( - sender.id(), mpt.issuanceID(), env.seq(sender), dest.id(), version); - - return mpt.getConfidentialSendProof( - sender, - sendAmount, - recipients, - blindingFactor, - ctxHash, - { - .pedersenCommitment = amountCommitment, - .amt = sendAmount, - .encryptedAmt = senderAmt, - .blindingFactor = amountBlindingFactor, - }, - { - .pedersenCommitment = balanceCommitment, - .amt = prevSpending, - .encryptedAmt = prevEncryptedSpending, - .blindingFactor = balanceBlindingFactor, - }); - } + test::jtx::Account const& dest) const; [[nodiscard]] test::jtx::MPTConfidentialSend sendArgs( test::jtx::Account const& sender, test::jtx::Account const& dest, Buffer const& proof, - std::optional err = std::nullopt) const - { - return { - .account = sender, - .dest = dest, - .amt = sendAmount, - .proof = strHex(proof), - .senderEncryptedAmt = senderAmt, - .destEncryptedAmt = destAmt, - .issuerEncryptedAmt = issuerAmt, - .auditorEncryptedAmt = auditorAmt, - .amountCommitment = amountCommitment, - .balanceCommitment = balanceCommitment, - .err = err, - }; - } + std::optional err = std::nullopt) const; }; + // Get a bad ciphertext with valid structure but cryptographic invalid for + // testing purposes. For preflight test purposes. + static Buffer const& + getBadCiphertext(); + + // Get a trivial buffer that is structurally and mathematically valid, but + // contains invalid data that does not match the ledger state. For preclaim + // test purposes. + static Buffer const& + getTrivialCiphertext(); + + // Returns a valid compressed EC point (33 bytes) that can pass preflight + // validation but contains invalid data for preclaim test purposes. + static Buffer const& + getTrivialCommitment(); + + // Returns a hex-encoded send proof of the correct length filled with + // placeholder data. It passes the proof length check in preflight but + // fails proof verification. + static std::string + getTrivialSendProofHex(); + + // Offset where the bulletproof begins in a send proof blob. + // Proof layout: [compact_sigma | bulletproof] + static constexpr size_t kBulletproofOffset = kEcSendProofLength - kEcDoubleBulletproofLength; + + // 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. + static Buffer + getForgedBulletproof( + std::array const& values, + std::array const& blindingFactors, + uint256 const& contextHash); + + // Generate a forged single bulletproof for a single value and blinding factor. + // Used to test ConvertBack overdraft prevention via bulletproof verification. + static Buffer + getForgedSingleBulletproof( + uint64_t value, + Buffer const& blindingFactor, + uint256 const& contextHash); + + // Forges a ConvertBack proof (compact sigma + single bulletproof) whose + // sigma component claims claimedBalance (which may be wrong) while binding + // to the real pedersen commitment and to the encrypted spending balance + // already on the ledger. The bulletproof component is built from the real + // remaining balance (realBalance - amt) so it stays honest. + // mpt_get_convert_back_proof validates its inputs before proving, so it + // cannot be used to build such an inconsistent proof. + static Buffer + getForgedConvertBackProof( + test::jtx::MPTTester& mpt, + test::jtx::Account const& holder, + uint64_t claimedBalance, + uint64_t realBalance, + uint64_t amt, + Buffer const& pedersenCommitment, + Buffer const& encryptedSpendingBalance, + Buffer const& pcBlindingFactor, + uint256 const& contextHash); + // Forges a ConfidentialMPTSend proof (compact sigma + double bulletproof) // for setup.sendAmount against setup's real balance commitment/ciphertext. // mpt_get_confidential_send_proof does not allow to build a proof whose amount @@ -438,265 +234,7 @@ protected: test::jtx::Env& env, test::jtx::Account const& sender, test::jtx::Account const& dest, - ConfidentialSendSetup const& setup) - { - auto* const ctx = mpt_secp256k1_context(); - - secp256k1_pubkey c1; - std::vector c2Vec(setup.recipients.size()); - std::vector pkVec(setup.recipients.size()); - for (std::size_t i = 0; i < setup.recipients.size(); ++i) - { - auto const& r = setup.recipients[i]; - if (i == 0 && - secp256k1_ec_pubkey_parse( - ctx, &c1, r.encryptedAmount.data(), kCompressedEcPointLength) != 1) - Throw("Failed to parse C1"); - if (secp256k1_ec_pubkey_parse( - ctx, - &c2Vec[i], - r.encryptedAmount.data() + kCompressedEcPointLength, - kCompressedEcPointLength) != 1) - Throw("Failed to parse C2"); - if (secp256k1_ec_pubkey_parse( - ctx, &pkVec[i], r.publicKey.data(), kCompressedEcPointLength) != 1) - Throw("Failed to parse recipient pubkey"); - } - - secp256k1_pubkey pkSender, pcAmount, pcBalance, b1, b2; - if (secp256k1_ec_pubkey_parse( - ctx, &pkSender, setup.senderPubKey.data(), kCompressedEcPointLength) != 1 || - secp256k1_ec_pubkey_parse( - ctx, &pcAmount, setup.amountCommitment.data(), kCompressedEcPointLength) != 1 || - secp256k1_ec_pubkey_parse( - ctx, &pcBalance, setup.balanceCommitment.data(), kCompressedEcPointLength) != 1 || - secp256k1_ec_pubkey_parse( - ctx, &b1, setup.prevEncryptedSpending.data(), kCompressedEcPointLength) != 1 || - secp256k1_ec_pubkey_parse( - ctx, - &b2, - setup.prevEncryptedSpending.data() + kCompressedEcPointLength, - kCompressedEcPointLength) != 1) - Throw("Failed to parse commitments/ciphertext"); - - Buffer const senderPrivKey = - requireOptional(mpt.getPrivKey(sender), "Missing sender privkey"); - auto const ctxHash = getSendContextHash( - sender.id(), mpt.issuanceID(), env.seq(sender), dest.id(), setup.version); - - Buffer sigmaProof(SECP256K1_COMPACT_STANDARD_PROOF_SIZE); - if (secp256k1_compact_standard_prove( - ctx, - sigmaProof.data(), - setup.sendAmount, - setup.prevSpending, - setup.blindingFactor.data(), - senderPrivKey.data(), - setup.balanceBlindingFactor.data(), - setup.recipients.size(), - &c1, - c2Vec.data(), - pkVec.data(), - &pcAmount, - &pkSender, - &pcBalance, - &b1, - &b2, - ctxHash.data()) != 1) - Throw("Failed to generate sigma proof"); - - // Wraps (mod 2^64) for overdrafts, unlike the ledger's own homomorphic - // commitment subtraction (mod the curve order) — that mismatch is - // exactly what makes the forged proof fail verification. - // Computed without a wrapping `uint64` subtract: Clang UBSan treats - // unsigned overflow as fatal (see incrementConfidentialVersion). - std::uint64_t const remaining = setup.sendAmount <= setup.prevSpending - ? setup.prevSpending - setup.sendAmount - : ~setup.sendAmount + setup.prevSpending + 1; - - Buffer negAmountBf(kEcBlindingFactorLength); - Buffer remainingBf(kEcBlindingFactorLength); - secp256k1_mpt_scalar_negate(negAmountBf.data(), setup.amountBlindingFactor.data()); - secp256k1_mpt_scalar_add( - remainingBf.data(), setup.balanceBlindingFactor.data(), negAmountBf.data()); - - auto const forgedBulletproof = getForgedBulletproof( - {setup.sendAmount, remaining}, {setup.amountBlindingFactor, remainingBf}, ctxHash); - - Buffer combinedProof(kEcSendProofLength); - std::memcpy(combinedProof.data(), sigmaProof.data(), SECP256K1_COMPACT_STANDARD_PROOF_SIZE); - std::memcpy( - combinedProof.data() + SECP256K1_COMPACT_STANDARD_PROOF_SIZE, - forgedBulletproof.data(), - kEcDoubleBulletproofLength); - - return combinedProof; - } - - // Helper that wraps the boilerplate setup: Env + MPT creation, funding, key - // generation, and seeding each holder with a confidential balance. - // The caller supplies the issuer and any number of holders. - struct ConfidentialEnv - { - // Per-holder configuration: the account, how much MPT to fund it - // with, and how much of that to convert to a confidential balance. - struct HolderInit - { - test::jtx::Account account; - std::uint64_t payAmount = 1000; - std::uint64_t convertAmount = 100; - }; - - test::jtx::MPTTester mpt; - - ConfidentialEnv( - test::jtx::Env& env, - test::jtx::Account const& issuer, - std::vector const& holders, - std::uint32_t flags = tfMPTCanLock | tfMPTCanHoldConfidentialBalance | tfMPTCanTransfer, - std::optional auditor = std::nullopt) - : mpt{env, issuer, {.holders = extractAccounts(holders), .auditor = auditor}} - { - mpt.create({.ownerCount = 1, .flags = flags}); - - for (auto const& h : holders) - { - mpt.authorize({.account = h.account}); - if ((flags & tfMPTRequireAuth) != 0) - mpt.authorize({.account = issuer, .holder = h.account}); - mpt.pay(issuer, h.account, h.payAmount); - } - - mpt.generateKeyPair(issuer); - for (auto const& h : holders) - mpt.generateKeyPair(h.account); - if (auditor) - mpt.generateKeyPair(requireOptionalRef(auditor, "Missing auditor")); - - mpt.set({ - .account = issuer, - .issuerPubKey = mpt.getPubKey(issuer), - .auditorPubKey = auditor - ? mpt.getPubKey(requireOptionalRef(auditor, "Missing auditor")) - : std::optional{}, - }); - - for (auto const& h : holders) - { - mpt.convert({ - .account = h.account, - .amt = h.convertAmount, - .holderPubKey = mpt.getPubKey(h.account), - }); - mpt.mergeInbox({.account = h.account}); - } - } - - private: - static std::vector - extractAccounts(std::vector const& holders) - { - std::vector accounts; - accounts.reserve(holders.size()); - for (auto const& h : holders) - accounts.push_back(h.account); - return accounts; - } - }; - - // Create an issuance that can hold confidential balances, with the listed - // holders funded and authorized, and a key pair generated for the issuer, - // every holder, and every extra key owner. The keys are - // generated but not registered. - static void - setupConfidentialIssuance( - test::jtx::MPTTester& mpt, - test::jtx::Account const& issuer, - std::vector const& holders, - std::vector const& keyOwners = {}, - std::uint32_t flags = tfMPTCanTransfer | tfMPTCanHoldConfidentialBalance); - - // Set up an MPT environment suitable for batch testing. - // alice is issuer; bob has 'bobAmt' in confidential spending; carol has - // 'carolAmt' in confidential spending; dave is initialised with pubkey but - // zero spending/inbox. - static void - setupBatchEnv( - test::jtx::MPTTester& mpt, - test::jtx::Account const& alice, - test::jtx::Account const& bob, - test::jtx::Account const& carol, - test::jtx::Account const& dave, - std::uint64_t bobAmt, - std::uint64_t carolAmt) - { - using namespace test::jtx; - mpt.create({ - .ownerCount = 1, - .flags = tfMPTCanTransfer | tfMPTCanLock | tfMPTCanHoldConfidentialBalance, - }); - mpt.authorize({.account = bob}); - mpt.authorize({.account = carol}); - mpt.authorize({.account = dave}); - - if (bobAmt > 0) - mpt.pay(alice, bob, bobAmt); - if (carolAmt > 0) - mpt.pay(alice, carol, carolAmt); - - mpt.generateKeyPair(alice); - mpt.generateKeyPair(bob); - mpt.generateKeyPair(carol); - mpt.generateKeyPair(dave); - - mpt.set({ - .account = alice, - .issuerPubKey = mpt.getPubKey(alice), - }); - - if (bobAmt > 0) - { - mpt.convert({ - .account = bob, - .amt = bobAmt, - .holderPubKey = mpt.getPubKey(bob), - }); - mpt.mergeInbox({.account = bob}); - } - else - { - mpt.convert({ - .account = bob, - .amt = 0, - .holderPubKey = mpt.getPubKey(bob), - }); - } - - if (carolAmt > 0) - { - mpt.convert({ - .account = carol, - .amt = carolAmt, - .holderPubKey = mpt.getPubKey(carol), - }); - mpt.mergeInbox({.account = carol}); - } - else - { - mpt.convert({ - .account = carol, - .amt = 0, - .holderPubKey = mpt.getPubKey(carol), - }); - } - - // dave: register pubkey only (0 spending/inbox) - mpt.convert({ - .account = dave, - .amt = 0, - .holderPubKey = mpt.getPubKey(dave), - }); - } + ConfidentialSendSetup const& setup); }; } // namespace xrpl diff --git a/src/test/jtx/impl/ConfidentialTransfer.cpp b/src/test/jtx/impl/ConfidentialTransfer.cpp index 6c1538316c..0bc98778f6 100644 --- a/src/test/jtx/impl/ConfidentialTransfer.cpp +++ b/src/test/jtx/impl/ConfidentialTransfer.cpp @@ -1,13 +1,89 @@ #include #include +#include #include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include + +#include +#include + +#include +#include #include +#include +#include +#include +#include +#include #include namespace xrpl { +ConfidentialTransferTestBase::ConfidentialEnv::ConfidentialEnv( + test::jtx::Env& env, + test::jtx::Account const& issuer, + std::vector const& holders, + std::uint32_t flags, + std::optional auditor) + : mpt{env, issuer, {.holders = extractAccounts(holders), .auditor = auditor}} +{ + mpt.create({.ownerCount = 1, .flags = flags}); + + for (auto const& h : holders) + { + mpt.authorize({.account = h.account}); + if ((flags & tfMPTRequireAuth) != 0) + mpt.authorize({.account = issuer, .holder = h.account}); + mpt.pay(issuer, h.account, h.payAmount); + } + + mpt.generateKeyPair(issuer); + for (auto const& h : holders) + mpt.generateKeyPair(h.account); + if (auditor) + mpt.generateKeyPair(requireOptionalRef(auditor, "Missing auditor")); + + mpt.set({ + .account = issuer, + .issuerPubKey = mpt.getPubKey(issuer), + .auditorPubKey = auditor ? mpt.getPubKey(requireOptionalRef(auditor, "Missing auditor")) + : std::optional{}, + }); + + for (auto const& h : holders) + { + mpt.convert({ + .account = h.account, + .amt = h.convertAmount, + .holderPubKey = mpt.getPubKey(h.account), + }); + mpt.mergeInbox({.account = h.account}); + } +} + +std::vector +ConfidentialTransferTestBase::ConfidentialEnv::extractAccounts( + std::vector const& holders) +{ + std::vector accounts; + accounts.reserve(holders.size()); + for (auto const& h : holders) + accounts.push_back(h.account); + return accounts; +} + void ConfidentialTransferTestBase::setupConfidentialIssuance( test::jtx::MPTTester& mpt, @@ -34,4 +110,478 @@ ConfidentialTransferTestBase::setupConfidentialIssuance( mpt.generateKeyPair(keyOwner); } +void +ConfidentialTransferTestBase::setupBatchEnv( + test::jtx::MPTTester& mpt, + test::jtx::Account const& alice, + test::jtx::Account const& bob, + test::jtx::Account const& carol, + test::jtx::Account const& dave, + std::uint64_t bobAmt, + std::uint64_t carolAmt) +{ + using namespace test::jtx; + mpt.create({ + .ownerCount = 1, + .flags = tfMPTCanTransfer | tfMPTCanLock | tfMPTCanHoldConfidentialBalance, + }); + mpt.authorize({.account = bob}); + mpt.authorize({.account = carol}); + mpt.authorize({.account = dave}); + + if (bobAmt > 0) + mpt.pay(alice, bob, bobAmt); + if (carolAmt > 0) + mpt.pay(alice, carol, carolAmt); + + mpt.generateKeyPair(alice); + mpt.generateKeyPair(bob); + mpt.generateKeyPair(carol); + mpt.generateKeyPair(dave); + + mpt.set({ + .account = alice, + .issuerPubKey = mpt.getPubKey(alice), + }); + + if (bobAmt > 0) + { + mpt.convert({ + .account = bob, + .amt = bobAmt, + .holderPubKey = mpt.getPubKey(bob), + }); + mpt.mergeInbox({.account = bob}); + } + else + { + mpt.convert({ + .account = bob, + .amt = 0, + .holderPubKey = mpt.getPubKey(bob), + }); + } + + if (carolAmt > 0) + { + mpt.convert({ + .account = carol, + .amt = carolAmt, + .holderPubKey = mpt.getPubKey(carol), + }); + mpt.mergeInbox({.account = carol}); + } + else + { + mpt.convert({ + .account = carol, + .amt = 0, + .holderPubKey = mpt.getPubKey(carol), + }); + } + + // dave: register pubkey only (0 spending/inbox) + mpt.convert({ + .account = dave, + .amt = 0, + .holderPubKey = mpt.getPubKey(dave), + }); +} + +ConfidentialTransferTestBase::ConfidentialSendSetup::ConfidentialSendSetup( + test::jtx::MPTTester& mpt, + test::jtx::Account const& sender, + test::jtx::Account const& dest, + test::jtx::Account const& issuer, + uint64_t amount, + std::optional> auditor) + : sendAmount(amount) + , version(mpt.getMPTokenVersion(sender)) + , blindingFactor(generateBlindingFactor()) + , amountBlindingFactor(blindingFactor) + , balanceBlindingFactor(generateBlindingFactor()) + , senderAmt(mpt.encryptAmount(sender, amount, blindingFactor)) + , destAmt(mpt.encryptAmount(dest, amount, blindingFactor)) + , issuerAmt(mpt.encryptAmount(issuer, amount, blindingFactor)) + , auditorAmt( + auditor ? std::optional(mpt.encryptAmount(auditor->get(), amount, blindingFactor)) + : std::nullopt) + , amountCommitment(mpt.getPedersenCommitment(amount, amountBlindingFactor)) + , senderPubKey(requireOptional(mpt.getPubKey(sender), "Missing sender public key")) + , destPubKey(requireOptional(mpt.getPubKey(dest), "Missing destination public key")) + , issuerPubKey(requireOptional(mpt.getPubKey(issuer), "Missing issuer public key")) + , auditorPubKey(auditor ? mpt.getPubKey(auditor->get()) : std::nullopt) + , prevSpending(requireOptional( + mpt.getDecryptedBalance(sender, test::jtx::MPTTester::holderEncryptedSpending), + "Missing sender spending balance")) + , prevEncryptedSpending(requireOptional( + mpt.getEncryptedBalance(sender, test::jtx::MPTTester::holderEncryptedSpending), + "Missing sender encrypted spending balance")) + , balanceCommitment(mpt.getPedersenCommitment(prevSpending, balanceBlindingFactor)) +{ + recipients.push_back({ + .publicKey = Slice(senderPubKey), + .encryptedAmount = senderAmt, + }); + recipients.push_back({ + .publicKey = Slice(destPubKey), + .encryptedAmount = destAmt, + }); + recipients.push_back({ + .publicKey = Slice(issuerPubKey), + .encryptedAmount = issuerAmt, + }); + if (auditor) + { + recipients.push_back({ + .publicKey = Slice(requireOptionalRef(auditorPubKey, "Missing auditor public key")), + .encryptedAmount = requireOptionalRef(auditorAmt, "Missing auditor encrypted amount"), + }); + } +} + +std::optional +ConfidentialTransferTestBase::ConfidentialSendSetup::generateProof( + test::jtx::MPTTester& mpt, + test::jtx::Env& env, + test::jtx::Account const& sender, + test::jtx::Account const& dest) const +{ + auto const ctxHash = + getSendContextHash(sender.id(), mpt.issuanceID(), env.seq(sender), dest.id(), version); + + return mpt.getConfidentialSendProof( + sender, + sendAmount, + recipients, + blindingFactor, + ctxHash, + { + .pedersenCommitment = amountCommitment, + .amt = sendAmount, + .encryptedAmt = senderAmt, + .blindingFactor = amountBlindingFactor, + }, + { + .pedersenCommitment = balanceCommitment, + .amt = prevSpending, + .encryptedAmt = prevEncryptedSpending, + .blindingFactor = balanceBlindingFactor, + }); +} + +test::jtx::MPTConfidentialSend +ConfidentialTransferTestBase::ConfidentialSendSetup::sendArgs( + test::jtx::Account const& sender, + test::jtx::Account const& dest, + Buffer const& proof, + std::optional err) const +{ + return { + .account = sender, + .dest = dest, + .amt = sendAmount, + .proof = strHex(proof), + .senderEncryptedAmt = senderAmt, + .destEncryptedAmt = destAmt, + .issuerEncryptedAmt = issuerAmt, + .auditorEncryptedAmt = auditorAmt, + .amountCommitment = amountCommitment, + .balanceCommitment = balanceCommitment, + .err = err, + }; +} + +Buffer const& +ConfidentialTransferTestBase::getBadCiphertext() +{ + static Buffer const kBadCiphertext = []() { + Buffer buf(kEcGamalEncryptedTotalLength); + std::memset(buf.data(), 0xFF, kEcGamalEncryptedTotalLength); + + buf.data()[0] = kEcCompressedPrefixEvenY; + buf.data()[kEcCiphertextComponentLength] = kEcCompressedPrefixEvenY; + return buf; + }(); + + return kBadCiphertext; +} + +Buffer const& +ConfidentialTransferTestBase::getTrivialCiphertext() +{ + static Buffer const kTrivialCiphertext = []() { + Buffer buf(kEcGamalEncryptedTotalLength); + std::memset(buf.data(), 0, kEcGamalEncryptedTotalLength); + + buf.data()[0] = kEcCompressedPrefixEvenY; + buf.data()[kEcCiphertextComponentLength] = kEcCompressedPrefixEvenY; + + buf.data()[kEcCiphertextComponentLength - 1] = 0x01; + buf.data()[kEcGamalEncryptedTotalLength - 1] = 0x01; + + return buf; + }(); + + return kTrivialCiphertext; +} + +Buffer const& +ConfidentialTransferTestBase::getTrivialCommitment() +{ + static Buffer const kTrivialCommitment = []() { + Buffer buf(kEcPedersenCommitmentLength); + std::memset(buf.data(), 0, kEcPedersenCommitmentLength); + + buf.data()[0] = kEcCompressedPrefixEvenY; + // Set last byte to make it a valid x-coordinate on the curve + buf.data()[kEcPedersenCommitmentLength - 1] = 0x01; + + return buf; + }(); + + return kTrivialCommitment; +} + +std::string +ConfidentialTransferTestBase::getTrivialSendProofHex() +{ + Buffer buf(kEcSendProofLength); + std::memset(buf.data(), 0, kEcSendProofLength); + + for (std::size_t i = 0; i < kEcSendProofLength; i += kEcCiphertextComponentLength) + { + buf.data()[i] = kEcCompressedPrefixEvenY; + if (i + kEcCiphertextComponentLength - 1 < kEcSendProofLength) + buf.data()[i + kEcCiphertextComponentLength - 1] = 0x01; + } + + return strHex(buf); +} + +Buffer +ConfidentialTransferTestBase::getForgedBulletproof( + std::array const& values, + std::array const& blindingFactors, + uint256 const& contextHash) +{ + auto* const ctx = mpt_secp256k1_context(); + + secp256k1_pubkey h; + secp256k1_mpt_get_h_generator(ctx, &h); + + Buffer proof(kEcDoubleBulletproofLength); + size_t proofLen = kEcDoubleBulletproofLength; + + unsigned char blindings[64]; + std::memcpy(blindings, blindingFactors[0].data(), 32); + std::memcpy(blindings + 32, blindingFactors[1].data(), 32); + + if (secp256k1_bulletproof_prove_agg( + ctx, proof.data(), &proofLen, values.data(), blindings, 2, &h, contextHash.data()) == 0) + Throw("Failed to generate forged bulletproof"); + + return proof; +} + +Buffer +ConfidentialTransferTestBase::getForgedSingleBulletproof( + uint64_t value, + Buffer const& blindingFactor, + uint256 const& contextHash) +{ + auto* const ctx = mpt_secp256k1_context(); + + secp256k1_pubkey h; + secp256k1_mpt_get_h_generator(ctx, &h); + + Buffer proof(kEcSingleBulletproofLength); + size_t proofLen = kEcSingleBulletproofLength; + + if (secp256k1_bulletproof_prove_agg( + ctx, + proof.data(), + &proofLen, + &value, + blindingFactor.data(), + 1, // m = 1 (single bulletproof) + &h, + contextHash.data()) == 0) + Throw("Failed to generate forged single bulletproof"); + + return proof; +} + +Buffer +ConfidentialTransferTestBase::getForgedConvertBackProof( + test::jtx::MPTTester& mpt, + test::jtx::Account const& holder, + uint64_t claimedBalance, + uint64_t realBalance, + uint64_t amt, + Buffer const& pedersenCommitment, + Buffer const& encryptedSpendingBalance, + Buffer const& pcBlindingFactor, + uint256 const& contextHash) +{ + if (pedersenCommitment.size() != kCompressedEcPointLength) + Throw("getForgedConvertBackProof: bad pedersenCommitment length"); + if (encryptedSpendingBalance.size() != kEcGamalEncryptedTotalLength) + { + Throw("getForgedConvertBackProof: bad encryptedSpendingBalance length"); + } + if (amt > realBalance) + Throw("getForgedConvertBackProof: amt exceeds realBalance"); + + auto* const ctx = mpt_secp256k1_context(); + auto const holderPubKey = requireOptional(mpt.getPubKey(holder), "Missing holder pubkey"); + auto const holderPrivKey = requireOptional(mpt.getPrivKey(holder), "Missing holder privkey"); + + secp256k1_pubkey pkHolder; + if (secp256k1_ec_pubkey_parse(ctx, &pkHolder, holderPubKey.data(), kCompressedEcPointLength) != + 1) + Throw("Failed to parse holder's public key"); + + secp256k1_pubkey pcB; + if (secp256k1_ec_pubkey_parse(ctx, &pcB, pedersenCommitment.data(), kCompressedEcPointLength) != + 1) + Throw("Failed to parse pedersen commitment"); + + secp256k1_pubkey b1, b2; + if (secp256k1_ec_pubkey_parse( + ctx, &b1, encryptedSpendingBalance.data(), kCompressedEcPointLength) != 1 || + secp256k1_ec_pubkey_parse( + ctx, + &b2, + encryptedSpendingBalance.data() + kCompressedEcPointLength, + kCompressedEcPointLength) != 1) + Throw("Failed to parse balance ciphertext"); + + Buffer sigmaProof(SECP256K1_COMPACT_CONVERTBACK_PROOF_SIZE); + if (secp256k1_compact_convertback_prove( + ctx, + sigmaProof.data(), + claimedBalance, + holderPrivKey.data(), + pcBlindingFactor.data(), + &pkHolder, + &b1, + &b2, + &pcB, + contextHash.data()) != 1) + Throw("Failed to generate convertback sigma proof"); + + auto const forgedBulletproof = + getForgedSingleBulletproof(realBalance - amt, pcBlindingFactor, contextHash); + + Buffer proof(kEcConvertBackProofLength); + std::memcpy(proof.data(), sigmaProof.data(), SECP256K1_COMPACT_CONVERTBACK_PROOF_SIZE); + std::memcpy( + proof.data() + SECP256K1_COMPACT_CONVERTBACK_PROOF_SIZE, + forgedBulletproof.data(), + kEcSingleBulletproofLength); + + return proof; +} + +Buffer +ConfidentialTransferTestBase::getForgedSendProof( + test::jtx::MPTTester& mpt, + test::jtx::Env& env, + test::jtx::Account const& sender, + test::jtx::Account const& dest, + ConfidentialSendSetup const& setup) +{ + auto* const ctx = mpt_secp256k1_context(); + + secp256k1_pubkey c1; + std::vector c2Vec(setup.recipients.size()); + std::vector pkVec(setup.recipients.size()); + for (std::size_t i = 0; i < setup.recipients.size(); ++i) + { + auto const& r = setup.recipients[i]; + if (i == 0 && + secp256k1_ec_pubkey_parse( + ctx, &c1, r.encryptedAmount.data(), kCompressedEcPointLength) != 1) + Throw("Failed to parse C1"); + if (secp256k1_ec_pubkey_parse( + ctx, + &c2Vec[i], + r.encryptedAmount.data() + kCompressedEcPointLength, + kCompressedEcPointLength) != 1) + Throw("Failed to parse C2"); + if (secp256k1_ec_pubkey_parse( + ctx, &pkVec[i], r.publicKey.data(), kCompressedEcPointLength) != 1) + Throw("Failed to parse recipient pubkey"); + } + + secp256k1_pubkey pkSender, pcAmount, pcBalance, b1, b2; + if (secp256k1_ec_pubkey_parse( + ctx, &pkSender, setup.senderPubKey.data(), kCompressedEcPointLength) != 1 || + secp256k1_ec_pubkey_parse( + ctx, &pcAmount, setup.amountCommitment.data(), kCompressedEcPointLength) != 1 || + secp256k1_ec_pubkey_parse( + ctx, &pcBalance, setup.balanceCommitment.data(), kCompressedEcPointLength) != 1 || + secp256k1_ec_pubkey_parse( + ctx, &b1, setup.prevEncryptedSpending.data(), kCompressedEcPointLength) != 1 || + secp256k1_ec_pubkey_parse( + ctx, + &b2, + setup.prevEncryptedSpending.data() + kCompressedEcPointLength, + kCompressedEcPointLength) != 1) + Throw("Failed to parse commitments/ciphertext"); + + Buffer const senderPrivKey = requireOptional(mpt.getPrivKey(sender), "Missing sender privkey"); + auto const ctxHash = getSendContextHash( + sender.id(), mpt.issuanceID(), env.seq(sender), dest.id(), setup.version); + + Buffer sigmaProof(SECP256K1_COMPACT_STANDARD_PROOF_SIZE); + if (secp256k1_compact_standard_prove( + ctx, + sigmaProof.data(), + setup.sendAmount, + setup.prevSpending, + setup.blindingFactor.data(), + senderPrivKey.data(), + setup.balanceBlindingFactor.data(), + setup.recipients.size(), + &c1, + c2Vec.data(), + pkVec.data(), + &pcAmount, + &pkSender, + &pcBalance, + &b1, + &b2, + ctxHash.data()) != 1) + Throw("Failed to generate sigma proof"); + + // Wraps (mod 2^64) for overdrafts, unlike the ledger's own homomorphic + // commitment subtraction (mod the curve order) — that mismatch is + // exactly what makes the forged proof fail verification. + // Computed without a wrapping `uint64` subtract: Clang UBSan treats + // unsigned overflow as fatal (see incrementConfidentialVersion). + std::uint64_t const remaining = setup.sendAmount <= setup.prevSpending + ? setup.prevSpending - setup.sendAmount + : ~setup.sendAmount + setup.prevSpending + 1; + + Buffer negAmountBf(kEcBlindingFactorLength); + Buffer remainingBf(kEcBlindingFactorLength); + secp256k1_mpt_scalar_negate(negAmountBf.data(), setup.amountBlindingFactor.data()); + secp256k1_mpt_scalar_add( + remainingBf.data(), setup.balanceBlindingFactor.data(), negAmountBf.data()); + + auto const forgedBulletproof = getForgedBulletproof( + {setup.sendAmount, remaining}, {setup.amountBlindingFactor, remainingBf}, ctxHash); + + Buffer combinedProof(kEcSendProofLength); + std::memcpy(combinedProof.data(), sigmaProof.data(), SECP256K1_COMPACT_STANDARD_PROOF_SIZE); + std::memcpy( + combinedProof.data() + SECP256K1_COMPACT_STANDARD_PROOF_SIZE, + forgedBulletproof.data(), + kEcDoubleBulletproofLength); + + return combinedProof; +} + } // namespace xrpl