diff --git a/CMakeLists.txt b/CMakeLists.txt index 33f68451c5..fa17892e37 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -88,6 +88,7 @@ find_package(ed25519 REQUIRED) find_package(gRPC REQUIRED) find_package(LibArchive REQUIRED) find_package(lz4 REQUIRED) +find_package(mpt-crypto REQUIRED) find_package(nudb REQUIRED) find_package(OpenSSL REQUIRED) find_package(secp256k1 REQUIRED) @@ -100,6 +101,7 @@ target_link_libraries( INTERFACE ed25519::ed25519 lz4::lz4 + mpt-crypto::mpt-crypto OpenSSL::Crypto OpenSSL::SSL secp256k1::secp256k1 diff --git a/conan.lock b/conan.lock index f1d6ed3fa5..3616e4a8ec 100644 --- a/conan.lock +++ b/conan.lock @@ -12,6 +12,7 @@ "protobuf/6.33.5#d96d52ba5baaaa532f47bda866ad87a5%1774467363.12", "openssl/3.6.1#e6399de266349245a4542fc5f6c71552%1774458290.139", "nudb/2.0.9#11149c73f8f2baff9a0198fe25971fc7%1774883011.384", + "mpt-crypto/0.2.0-rc1#ed3f241f69d8b9ebf80069d1923d93a8%1773853481.755", "lz4/1.10.0#59fc63cac7f10fbe8e05c7e62c2f3504%1765850143.914", "libiconv/1.17#1e65319e945f2d31941a9d28cc13c058%1765842973.492", "libbacktrace/cci.20210118#a7691bfccd8caaf66309df196790a5a1%1765842973.03", @@ -34,6 +35,7 @@ "msys2/cci.latest#d22fe7b2808f5fd34d0a7923ace9c54f%1770657326.649", "m4/1.4.19#5d7a4994e5875d76faf7acf3ed056036%1774365463.87", "cmake/4.3.0#b939a42e98f593fb34d3a8c5cc860359%1774439249.183", + "cmake/3.31.10#313d16a1aa16bbdb2ca0792467214b76%1765850153.479", "b2/5.4.2#ffd6084a119587e70f11cd45d1a386e2%1774439233.447", "automake/1.16.5#b91b7c384c3deaa9d535be02da14d04f%1755524470.56", "autoconf/2.71#51077f068e61700d65bb05541ea1e4b0%1731054366.86", @@ -58,6 +60,12 @@ ], "lz4/[>=1.9.4 <2]": [ "lz4/1.10.0#59fc63cac7f10fbe8e05c7e62c2f3504" + ], + "openssl/3.5.5": [ + "openssl/3.6.1" + ], + "openssl/[>=3 <4]": [ + "openssl/3.6.1" ] }, "config_requires": [] diff --git a/conanfile.py b/conanfile.py index 4949516bfe..9809c753b3 100644 --- a/conanfile.py +++ b/conanfile.py @@ -31,6 +31,7 @@ class Xrpl(ConanFile): "ed25519/2015.03", "grpc/1.78.1", "libarchive/3.8.1", + "mpt-crypto/0.2.0-rc1", "nudb/2.0.9", "openssl/3.6.1", "secp256k1/0.7.1", @@ -214,6 +215,7 @@ class Xrpl(ConanFile): "grpc::grpc++", "libarchive::libarchive", "lz4::lz4", + "mpt-crypto::mpt-crypto", "nudb::nudb", "openssl::crypto", "protobuf::libprotobuf", diff --git a/cspell.config.yaml b/cspell.config.yaml index 028f02191e..b1566ea505 100644 --- a/cspell.config.yaml +++ b/cspell.config.yaml @@ -59,6 +59,7 @@ words: - autobridging - bimap - bindir + - blindings - bookdir - Bougalis - Britto @@ -91,6 +92,7 @@ words: - daria - dcmake - dearmor + - decryptor - deleteme - demultiplexer - deserializaton @@ -100,6 +102,7 @@ words: - distro - doxyfile - dxrpl + - elgamal - enabled - endmacro - exceptioned @@ -110,6 +113,7 @@ words: - fmtdur - fsanitize - funclets + - Gamal - gcov - gcovr - ghead @@ -199,6 +203,7 @@ words: - partitioner - paychan - paychans + - Pedersen - permdex - perminute - permissioned @@ -235,6 +240,7 @@ words: - sahyadri - Satoshi - scons + - Schnorr - secp - sendq - seqit @@ -262,6 +268,7 @@ words: - stvar - stvector - stxchainattestations + - summands - superpeer - superpeers - takergets diff --git a/include/xrpl/protocol/ConfidentialTransfer.h b/include/xrpl/protocol/ConfidentialTransfer.h new file mode 100644 index 0000000000..59be56911e --- /dev/null +++ b/include/xrpl/protocol/ConfidentialTransfer.h @@ -0,0 +1,460 @@ +#pragma once + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include + +namespace xrpl { + +/** + * @brief Bundles an ElGamal public key with its associated encrypted amount. + * + * Used to represent a recipient in confidential transfers, containing both + * the recipient's ElGamal public key and the ciphertext encrypting the + * transfer amount under that key. + */ +struct ConfidentialRecipient +{ + Slice publicKey; ///< The recipient's ElGamal public key (size=xrpl::ecPubKeyLength). + Slice encryptedAmount; ///< The encrypted amount ciphertext + ///< (size=xrpl::ecGamalEncryptedTotalLength). +}; + +/// Holds two secp256k1 public key components representing an ElGamal ciphertext (C1, C2). +struct EcPair +{ + secp256k1_pubkey c1; + secp256k1_pubkey c2; +}; + +/** + * @brief Increments the confidential balance version counter on an MPToken. + * + * The version counter is used to prevent replay attacks by binding proofs + * to a specific state of the account's confidential balance. Wraps to 0 + * on overflow (defined behavior for unsigned integers). + * + * @param mptoken The MPToken ledger entry to update. + */ +inline void +incrementConfidentialVersion(STObject& mptoken) +{ + // Retrieve current version and increment. + // Unsigned integer overflow is defined behavior in C++ (wraps to 0), + // which is acceptable here. + mptoken[sfConfidentialBalanceVersion] = + mptoken[~sfConfidentialBalanceVersion].value_or(0u) + 1u; +} + +/** + * @brief Generates the context hash for ConfidentialMPTSend transactions. + * + * Creates a unique 256-bit hash that binds the zero-knowledge proofs to + * this specific send transaction, preventing proof reuse across transactions. + * + * @param account The sender's account ID. + * @param issuanceID The MPToken Issuance ID. + * @param sequence The transaction sequence number or ticket number. + * @param destination The destination account ID. + * @param version The sender's confidential balance version. + * @return A 256-bit context hash unique to this transaction. + */ +uint256 +getSendContextHash( + AccountID const& account, + uint192 const& issuanceID, + std::uint32_t sequence, + AccountID const& destination, + std::uint32_t version); + +/** + * @brief Generates the context hash for ConfidentialMPTClawback transactions. + * + * Creates a unique 256-bit hash that binds the equality proof to this + * specific clawback transaction. + * + * @param account The issuer's account ID. + * @param issuanceID The MPToken Issuance ID. + * @param sequence The transaction sequence number or ticket number. + * @param holder The holder's account ID being clawed back from. + * @return A 256-bit context hash unique to this transaction. + */ +uint256 +getClawbackContextHash( + AccountID const& account, + uint192 const& issuanceID, + std::uint32_t sequence, + AccountID const& holder); + +/** + * @brief Generates the context hash for ConfidentialMPTConvert transactions. + * + * Creates a unique 256-bit hash that binds the Schnorr proof (for key + * registration) to this specific convert transaction. + * + * @param account The holder's account ID. + * @param issuanceID The MPToken Issuance ID. + * @param sequence The transaction sequence number or a ticket number. + * @return A 256-bit context hash unique to this transaction. + */ +uint256 +getConvertContextHash(AccountID const& account, uint192 const& issuanceID, std::uint32_t sequence); + +/** + * @brief Generates the context hash for ConfidentialMPTConvertBack transactions. + * + * Creates a unique 256-bit hash that binds the zero-knowledge proofs to + * this specific convert-back transaction. + * + * @param account The holder's account ID. + * @param issuanceID The MPToken Issuance ID. + * @param sequence The transaction sequence number or a ticket number. + * @param version The holder's confidential balance version. + * @return A 256-bit context hash unique to this transaction. + */ +uint256 +getConvertBackContextHash( + AccountID const& account, + uint192 const& issuanceID, + std::uint32_t sequence, + std::uint32_t version); + +/** + * @brief Parses an ElGamal ciphertext into two secp256k1 public key components. + * + * Breaks a 66-byte encrypted amount (two 33-byte compressed EC points) into + * a pair containing (C1, C2) for use in cryptographic operations. + * + * @param buffer The 66-byte buffer containing the compressed ciphertext. + * @return The parsed pair (c1, c2) if successful, std::nullopt if the buffer is invalid. + */ +std::optional +makeEcPair(Slice const& buffer); + +/** + * @brief Serializes an EcPair into compressed form. + * + * Converts an EcPair (C1, C2) back into a 66-byte buffer containing + * two 33-byte compressed EC points. + * + * @param pair The EcPair to serialize. + * @return The 66-byte buffer, or std::nullopt if serialization fails. + */ +std::optional +serializeEcPair(EcPair const& pair); + +/** + * @brief Verifies that a buffer contains two valid, parsable EC public keys. + * + * @param buffer The input buffer containing two concatenated components. + * @return true if both components can be parsed successfully, false otherwise. + */ +bool +isValidCiphertext(Slice const& buffer); + +/** + * @brief Verifies that a buffer contains a valid, parsable compressed EC point. + * + * Can be used to validate both compressed public keys and Pedersen commitments. + * Fails early if the prefix byte is not 0x02 or 0x03. + * + * @param buffer The input buffer containing a compressed EC point (33 bytes). + * @return true if the point can be parsed successfully, false otherwise. + */ +bool +isValidCompressedECPoint(Slice const& buffer); + +/** + * @brief Homomorphically adds two ElGamal ciphertexts. + * + * Uses the additive homomorphic property of ElGamal encryption to compute + * Enc(a + b) from Enc(a) and Enc(b) without decryption. + * + * @param a The first ciphertext (66 bytes). + * @param b The second ciphertext (66 bytes). + * @return The resulting ciphertext Enc(a + b), or std::nullopt on failure. + */ +std::optional +homomorphicAdd(Slice const& a, Slice const& b); + +/** + * @brief Homomorphically subtracts two ElGamal ciphertexts. + * + * Uses the additive homomorphic property of ElGamal encryption to compute + * Enc(a - b) from Enc(a) and Enc(b) without decryption. + * + * @param a The minuend ciphertext (66 bytes). + * @param b The subtrahend ciphertext (66 bytes). + * @return The resulting ciphertext Enc(a - b), or std::nullopt on failure. + */ +std::optional +homomorphicSubtract(Slice const& a, Slice const& b); + +/** + * @brief Encrypts an amount using ElGamal encryption. + * + * Produces a ciphertext C = (C1, C2) where C1 = r*G and C2 = m*G + r*Pk, + * using the provided blinding factor r. + * + * @param amt The plaintext amount to encrypt. + * @param pubKeySlice The recipient's ElGamal public key (size=xrpl::ecPubKeyLength). + * @param blindingFactor The randomness used as blinding factor r + * (size=xrpl::ecBlindingFactorLength). + * @return The ciphertext (size=xrpl::ecGamalEncryptedTotalLength), or std::nullopt on failure. + */ +std::optional +encryptAmount(uint64_t const amt, Slice const& pubKeySlice, Slice const& blindingFactor); + +/** + * @brief Generates the canonical zero encryption for a specific MPToken. + * + * Creates a deterministic encryption of zero that is unique to the account + * and MPT issuance. Used to initialize confidential balance fields. + * + * @param pubKeySlice The holder's ElGamal public key (size=xrpl::ecPubKeyLength). + * @param account The account ID of the token holder. + * @param mptId The MPToken Issuance ID. + * @return The canonical zero ciphertext (size=xrpl::ecGamalEncryptedTotalLength), or std::nullopt + * on failure. + */ +std::optional +encryptCanonicalZeroAmount(Slice const& pubKeySlice, AccountID const& account, MPTID const& mptId); + +/** + * @brief Verifies a Schnorr proof of knowledge of an ElGamal private key. + * + * Proves that the submitter knows the secret key corresponding to the + * provided public key, without revealing the secret key itself. + * + * @param pubKeySlice The ElGamal public key (size=xrpl::ecPubKeyLength). + * @param proofSlice The Schnorr proof (size=xrpl::ecSchnorrProofLength). + * @param contextHash The 256-bit context hash binding the proof. + * @return tesSUCCESS if valid, or an error code otherwise. + */ +TER +verifySchnorrProof(Slice const& pubKeySlice, Slice const& proofSlice, uint256 const& contextHash); + +/** + * @brief Validates the format of encrypted amount fields in a transaction. + * + * Checks that all ciphertext fields in the transaction object have the + * correct length and contain valid EC points. This function is only used + * by ConfidentialMPTConvert and ConfidentialMPTConvertBack transactions. + * + * @param object The transaction object containing encrypted amount fields. + * @return tesSUCCESS if all formats are valid, temMALFORMED if required fields + * are missing, or temBAD_CIPHERTEXT if format validation fails. + */ +NotTEC +checkEncryptedAmountFormat(STObject const& object); + +/** + * @brief Verifies revealed amount encryptions for all recipients. + * + * Validates that the same amount was correctly encrypted for the holder, + * issuer, and optionally the auditor using their respective public keys. + * + * @param amount The revealed plaintext amount. + * @param blindingFactor The blinding factor used in all encryptions + * (size=xrpl::ecBlindingFactorLength). + * @param holder The holder's public key and encrypted amount. + * @param issuer The issuer's public key and encrypted amount. + * @param auditor Optional auditor's public key and encrypted amount. + * @return tesSUCCESS if all encryptions are valid, or an error code otherwise. + */ +TER +verifyRevealedAmount( + uint64_t const amount, + Slice const& blindingFactor, + ConfidentialRecipient const& holder, + ConfidentialRecipient const& issuer, + std::optional const& auditor); + +/** + * @brief Returns the number of recipients in a confidential transfer. + * + * Returns 4 if an auditor is present (sender, destination, issuer, auditor), + * or 3 if no auditor (sender, destination, issuer). + * + * @param hasAuditor Whether the issuance has an auditor configured. + * @return The number of recipients (3 or 4). + */ +constexpr std::size_t +getConfidentialRecipientCount(bool hasAuditor) +{ + return hasAuditor ? 4 : 3; +} + +/** + * @brief Returns the size of a multi-ciphertext equality proof. + * + * Computes the byte size required for a zero-knowledge proof that demonstrates + * multiple ciphertexts encrypt the same plaintext value. The size depends on + * the number of recipients. + * + * @param nRecipients The number of recipients (typically 3 or 4). + * @return The proof size in bytes. + */ +inline std::size_t +getEqualityProofSize(std::size_t nRecipients) +{ + return secp256k1_mpt_proof_equality_shared_r_size(nRecipients); +} + +/** + * @brief Verifies a clawback equality proof. + * + * Proves that the issuer knows the exact amount encrypted in the holder's + * balance ciphertext. Used in ConfidentialMPTClawback to verify the issuer + * can decrypt the balance using their private key. + * + * @param amount The revealed plaintext amount. + * @param proof The zero-knowledge proof bytes. + * @param pubKeySlice The issuer's ElGamal public key (64 bytes). + * @param ciphertext The issuer's encrypted balance on the holder's account (66 bytes). + * @param contextHash The 256-bit context hash binding the proof. + * @return tesSUCCESS if the proof is valid, or an error code otherwise. + */ +TER +verifyClawbackEqualityProof( + uint64_t const amount, + Slice const& proof, + Slice const& pubKeySlice, + Slice const& ciphertext, + uint256 const& contextHash); + +/** + * @brief Generates a cryptographically secure 32-byte blinding factor. + * + * Produces random bytes suitable for use as an ElGamal blinding factor + * or Pedersen commitment randomness. + * + * @return A 32-byte buffer containing the random blinding factor. + */ +Buffer +generateBlindingFactor(); + +/** + * @brief Verifies all zero-knowledge proofs for a ConfidentialMPTSend transaction. + * + * This function calls mpt_verify_send_proof API in the mpt-crypto utility lib, which verifies the + * equality proof, amount linkage, balance linkage, and range proof. + * Equality proof: Proves the same value is encrypted for the sender, receiver, issuer, and auditor. + * Amount linkage: Proves the send amount matches the amount Pedersen commitment. + * Balance linkage: Proves the sender's balance matches the balance Pedersen + * commitment. + * Range proof: Proves the amount and the remaining balance are within range [0, 2^64-1]. + * + * @param proof The full proof blob. + * @param sender The sender's public key and encrypted amount. + * @param destination The destination's public key and encrypted amount. + * @param issuer The issuer's public key and encrypted amount. + * @param auditor The auditor's public key and encrypted amount if present. + * @param spendingBalance The sender's current spending balance ciphertext. + * @param amountCommitment The Pedersen commitment to the send amount. + * @param balanceCommitment The Pedersen commitment to the sender's balance. + * @param contextHash The context hash binding the proof. + * @return tesSUCCESS if all proofs are valid, or an error code otherwise. + */ +TER +verifySendProof( + Slice const& proof, + ConfidentialRecipient const& sender, + ConfidentialRecipient const& destination, + ConfidentialRecipient const& issuer, + std::optional const& auditor, + Slice const& spendingBalance, + Slice const& amountCommitment, + Slice const& balanceCommitment, + uint256 const& contextHash); + +/** + * @brief Verifies all zero-knowledge proofs for a ConfidentialMPTConvertBack transaction. + * + * This function calls mpt_verify_convert_back_proof API in the mpt-crypto utility lib, which + * verifies the balance linkage proof and range proof. Balance linkage proof: proves the balance + * commitment matches the spending ciphertext. Range proof: proves the remaining balance after + * convert back is within range [0, 2^64-1]. + * + * @param proof The full proof blob. + * @param pubKeySlice The holder's public key. + * @param spendingBalance The holder's spending balance ciphertext. + * @param balanceCommitment The Pedersen commitment to the balance. + * @param amount The amount being converted back to public. + * @param contextHash The context hash binding the proof. + * @return tesSUCCESS if all proofs are valid, or an error code otherwise. + */ +TER +verifyConvertBackProof( + Slice const& proof, + Slice const& pubKeySlice, + Slice const& spendingBalance, + Slice const& balanceCommitment, + uint64_t amount, + uint256 const& contextHash); + +/** + * @brief Sequential reader for extracting proof components from a ZKProof blob. + * + * Encapsulates the offset-based arithmetic for slicing a concatenated proof + * blob into its individual components (equality proofs, Pedersen linkage + * proofs, bulletproofs, etc.). Performs bounds checking on every read and + * tracks whether the entire blob has been consumed. + * + * Usage: + * @code + * ProofReader reader(tx[sfZKProof]); + * auto equalityProof = reader.read(sizeEquality); + * auto pedersenProof = reader.read(ecPedersenProofLength); + * if (!equalityProof || !pedersenProof || !reader.done()) + * return tecINTERNAL; + * @endcode + */ +class ProofReader +{ + Slice data_; + std::size_t offset_ = 0; + +public: + explicit ProofReader(Slice data) : data_(data) + { + } + + /** + * @brief Read the next @p length bytes from the proof blob. + * + * @param length Number of bytes to read. + * @return A Slice of the requested bytes, or std::nullopt if there are + * not enough remaining bytes. + */ + [[nodiscard]] std::optional + read(std::size_t length) + { + if (offset_ + length > data_.size()) + return std::nullopt; + auto result = data_.substr(offset_, length); + offset_ += length; + return result; + } + + /** + * @brief Returns true when every byte has been consumed. + */ + [[nodiscard]] bool + done() const + { + return offset_ == data_.size(); + } +}; + +} // namespace xrpl diff --git a/include/xrpl/protocol/LedgerFormats.h b/include/xrpl/protocol/LedgerFormats.h index 06fd1040e1..dd25aaeffe 100644 --- a/include/xrpl/protocol/LedgerFormats.h +++ b/include/xrpl/protocol/LedgerFormats.h @@ -173,7 +173,8 @@ enum LedgerEntryType : std::uint16_t { LSF_FLAG(lsfMPTCanEscrow, 0x00000008) \ LSF_FLAG(lsfMPTCanTrade, 0x00000010) \ LSF_FLAG(lsfMPTCanTransfer, 0x00000020) \ - LSF_FLAG(lsfMPTCanClawback, 0x00000040)) \ + LSF_FLAG(lsfMPTCanClawback, 0x00000040) \ + LSF_FLAG(lsfMPTCanConfidentialAmount, 0x00000080)) \ \ LEDGER_OBJECT(MPTokenIssuanceMutable, \ LSF_FLAG(lsmfMPTCanMutateCanLock, 0x00000002) \ @@ -183,7 +184,8 @@ enum LedgerEntryType : std::uint16_t { LSF_FLAG(lsmfMPTCanMutateCanTransfer, 0x00000020) \ LSF_FLAG(lsmfMPTCanMutateCanClawback, 0x00000040) \ LSF_FLAG(lsmfMPTCanMutateMetadata, 0x00010000) \ - LSF_FLAG(lsmfMPTCanMutateTransferFee, 0x00020000)) \ + LSF_FLAG(lsmfMPTCanMutateTransferFee, 0x00020000) \ + LSF_FLAG(lsmfMPTCannotMutateCanConfidentialAmount, 0x00040000)) \ \ LEDGER_OBJECT(MPToken, \ LSF_FLAG2(lsfMPTLocked, 0x00000001) \ diff --git a/include/xrpl/protocol/Protocol.h b/include/xrpl/protocol/Protocol.h index 0db7b217f0..4cab26301b 100644 --- a/include/xrpl/protocol/Protocol.h +++ b/include/xrpl/protocol/Protocol.h @@ -307,4 +307,46 @@ std::size_t constexpr permissionMaxSize = 10; /** The maximum number of transactions that can be in a batch. */ std::size_t constexpr maxBatchTxCount = 8; +/** Length of one component of EC ElGamal ciphertext */ +std::size_t constexpr ecGamalEncryptedLength = 33; + +/** EC ElGamal ciphertext length: two 33-byte components concatenated */ +std::size_t constexpr ecGamalEncryptedTotalLength = ecGamalEncryptedLength * 2; + +/** Length of equality ZKProof in bytes */ +std::size_t constexpr ecEqualityProofLength = 98; + +/** Length of EC point (compressed) */ +std::size_t constexpr compressedECPointLength = 33; + +/** Length of EC public key (compressed) */ +std::size_t constexpr ecPubKeyLength = compressedECPointLength; + +/** Length of EC private key in bytes */ +std::size_t constexpr ecPrivKeyLength = 32; + +/** Length of the EC blinding factor in bytes */ +std::size_t constexpr ecBlindingFactorLength = 32; + +/** Length of Schnorr ZKProof for public key registration in bytes */ +std::size_t constexpr ecSchnorrProofLength = 65; + +/** Length of ElGamal Pedersen linkage proof in bytes */ +std::size_t constexpr ecPedersenProofLength = 195; + +/** Length of Pedersen Commitment (compressed) */ +std::size_t constexpr ecPedersenCommitmentLength = compressedECPointLength; + +/** Length of single bulletproof (range proof for 1 commitment) in bytes */ +std::size_t constexpr ecSingleBulletproofLength = 688; + +/** Length of double bulletproof (range proof for 2 commitments) in bytes */ +std::size_t constexpr ecDoubleBulletproofLength = 754; + +/** Compressed EC point prefix for even y-coordinate */ +std::uint8_t constexpr ecCompressedPrefixEvenY = 0x02; + +/** Compressed EC point prefix for odd y-coordinate */ +std::uint8_t constexpr ecCompressedPrefixOddY = 0x03; + } // namespace xrpl diff --git a/include/xrpl/protocol/TER.h b/include/xrpl/protocol/TER.h index b0dafecf1c..5d15cf1ad7 100644 --- a/include/xrpl/protocol/TER.h +++ b/include/xrpl/protocol/TER.h @@ -122,6 +122,7 @@ enum TEMcodes : TERUnderlyingType { temBAD_TRANSFER_FEE, temINVALID_INNER_BATCH, temBAD_MPT, + temBAD_CIPHERTEXT, }; //------------------------------------------------------------------------------ @@ -344,6 +345,11 @@ enum TECcodes : TERUnderlyingType { tecLIMIT_EXCEEDED = 195, tecPSEUDO_ACCOUNT = 196, tecPRECISION_LOSS = 197, + // DEPRECATED: This error code tecNO_DELEGATE_PERMISSION is reserved for + // backward compatibility with historical data on non-prod networks, can be + // reclaimed after those networks reset. + tecNO_DELEGATE_PERMISSION = 198, + tecBAD_PROOF = 199, }; //------------------------------------------------------------------------------ diff --git a/include/xrpl/protocol/TxFlags.h b/include/xrpl/protocol/TxFlags.h index 7c2085109f..b90e48a3b8 100644 --- a/include/xrpl/protocol/TxFlags.h +++ b/include/xrpl/protocol/TxFlags.h @@ -138,7 +138,8 @@ inline constexpr FlagValue tfUniversalMask = ~tfUniversal; TF_FLAG(tfMPTCanEscrow, lsfMPTCanEscrow) \ TF_FLAG(tfMPTCanTrade, lsfMPTCanTrade) \ TF_FLAG(tfMPTCanTransfer, lsfMPTCanTransfer) \ - TF_FLAG(tfMPTCanClawback, lsfMPTCanClawback), \ + TF_FLAG(tfMPTCanClawback, lsfMPTCanClawback) \ + TF_FLAG(tfMPTCanConfidentialAmount, lsfMPTCanConfidentialAmount), \ MASK_ADJ(0)) \ \ TRANSACTION(MPTokenAuthorize, \ @@ -347,10 +348,12 @@ inline constexpr FlagValue tmfMPTCanMutateCanTransfer = lsmfMPTCanMutateCanTrans inline constexpr FlagValue tmfMPTCanMutateCanClawback = lsmfMPTCanMutateCanClawback; inline constexpr FlagValue tmfMPTCanMutateMetadata = lsmfMPTCanMutateMetadata; inline constexpr FlagValue tmfMPTCanMutateTransferFee = lsmfMPTCanMutateTransferFee; -inline constexpr FlagValue tmfMPTokenIssuanceCreateMutableMask = - ~(tmfMPTCanMutateCanLock | tmfMPTCanMutateRequireAuth | tmfMPTCanMutateCanEscrow | - tmfMPTCanMutateCanTrade | tmfMPTCanMutateCanTransfer | tmfMPTCanMutateCanClawback | - tmfMPTCanMutateMetadata | tmfMPTCanMutateTransferFee); +inline constexpr FlagValue tmfMPTCannotMutateCanConfidentialAmount = + lsmfMPTCannotMutateCanConfidentialAmount; +inline constexpr FlagValue tmfMPTokenIssuanceCreateMutableMask = ~( + tmfMPTCanMutateCanLock | tmfMPTCanMutateRequireAuth | tmfMPTCanMutateCanEscrow | + tmfMPTCanMutateCanTrade | tmfMPTCanMutateCanTransfer | tmfMPTCanMutateCanClawback | + tmfMPTCanMutateMetadata | tmfMPTCanMutateTransferFee | tmfMPTCannotMutateCanConfidentialAmount); // MPTokenIssuanceSet MutableFlags: // Set or Clear flags. @@ -367,10 +370,13 @@ inline constexpr FlagValue tmfMPTSetCanTransfer = 0x00000100; inline constexpr FlagValue tmfMPTClearCanTransfer = 0x00000200; inline constexpr FlagValue tmfMPTSetCanClawback = 0x00000400; inline constexpr FlagValue tmfMPTClearCanClawback = 0x00000800; -inline constexpr FlagValue tmfMPTokenIssuanceSetMutableMask = ~( - tmfMPTSetCanLock | tmfMPTClearCanLock | tmfMPTSetRequireAuth | tmfMPTClearRequireAuth | - tmfMPTSetCanEscrow | tmfMPTClearCanEscrow | tmfMPTSetCanTrade | tmfMPTClearCanTrade | - tmfMPTSetCanTransfer | tmfMPTClearCanTransfer | tmfMPTSetCanClawback | tmfMPTClearCanClawback); +inline constexpr FlagValue tmfMPTSetCanConfidentialAmount = 0x00001000; +inline constexpr FlagValue tmfMPTClearCanConfidentialAmount = 0x00002000; +inline constexpr FlagValue tmfMPTokenIssuanceSetMutableMask = + ~(tmfMPTSetCanLock | tmfMPTClearCanLock | tmfMPTSetRequireAuth | tmfMPTClearRequireAuth | + tmfMPTSetCanEscrow | tmfMPTClearCanEscrow | tmfMPTSetCanTrade | tmfMPTClearCanTrade | + tmfMPTSetCanTransfer | tmfMPTClearCanTransfer | tmfMPTSetCanClawback | + tmfMPTClearCanClawback | tmfMPTSetCanConfidentialAmount | tmfMPTClearCanConfidentialAmount); // Prior to fixRemoveNFTokenAutoTrustLine, transfer of an NFToken between accounts allowed a // TrustLine to be added to the issuer of that token without explicit permission from that issuer. diff --git a/include/xrpl/protocol/detail/features.macro b/include/xrpl/protocol/detail/features.macro index 494b3fa6cd..1173f83e0e 100644 --- a/include/xrpl/protocol/detail/features.macro +++ b/include/xrpl/protocol/detail/features.macro @@ -15,6 +15,7 @@ // Add new amendments to the top of this list. // Keep it sorted in reverse chronological order. +XRPL_FEATURE(ConfidentialTransfer, Supported::no, VoteBehavior::DefaultNo) XRPL_FEATURE(MPTokensV2, Supported::no, VoteBehavior::DefaultNo) XRPL_FIX (Security3_1_3, Supported::no, VoteBehavior::DefaultNo) XRPL_FIX (PermissionedDomainInvariant, Supported::yes, VoteBehavior::DefaultNo) diff --git a/include/xrpl/protocol/detail/ledger_entries.macro b/include/xrpl/protocol/detail/ledger_entries.macro index e4182f0cba..3048422dfd 100644 --- a/include/xrpl/protocol/detail/ledger_entries.macro +++ b/include/xrpl/protocol/detail/ledger_entries.macro @@ -387,32 +387,41 @@ LEDGER_ENTRY(ltAMM, 0x0079, AMM, amm, ({ \sa keylet::mptIssuance */ LEDGER_ENTRY(ltMPTOKEN_ISSUANCE, 0x007e, MPTokenIssuance, mpt_issuance, ({ - {sfIssuer, soeREQUIRED}, - {sfSequence, soeREQUIRED}, - {sfTransferFee, soeDEFAULT}, - {sfOwnerNode, soeREQUIRED}, - {sfAssetScale, soeDEFAULT}, - {sfMaximumAmount, soeOPTIONAL}, - {sfOutstandingAmount, soeREQUIRED}, - {sfLockedAmount, soeOPTIONAL}, - {sfMPTokenMetadata, soeOPTIONAL}, - {sfPreviousTxnID, soeREQUIRED}, - {sfPreviousTxnLgrSeq, soeREQUIRED}, - {sfDomainID, soeOPTIONAL}, - {sfMutableFlags, soeDEFAULT}, + {sfIssuer, soeREQUIRED}, + {sfSequence, soeREQUIRED}, + {sfTransferFee, soeDEFAULT}, + {sfOwnerNode, soeREQUIRED}, + {sfAssetScale, soeDEFAULT}, + {sfMaximumAmount, soeOPTIONAL}, + {sfOutstandingAmount, soeREQUIRED}, + {sfLockedAmount, soeOPTIONAL}, + {sfMPTokenMetadata, soeOPTIONAL}, + {sfPreviousTxnID, soeREQUIRED}, + {sfPreviousTxnLgrSeq, soeREQUIRED}, + {sfDomainID, soeOPTIONAL}, + {sfMutableFlags, soeDEFAULT}, + {sfIssuerEncryptionKey, soeOPTIONAL}, + {sfAuditorEncryptionKey, soeOPTIONAL}, + {sfConfidentialOutstandingAmount, soeDEFAULT}, })) /** A ledger object which tracks MPToken \sa keylet::mptoken */ LEDGER_ENTRY(ltMPTOKEN, 0x007f, MPToken, mptoken, ({ - {sfAccount, soeREQUIRED}, - {sfMPTokenIssuanceID, soeREQUIRED}, - {sfMPTAmount, soeDEFAULT}, - {sfLockedAmount, soeOPTIONAL}, - {sfOwnerNode, soeREQUIRED}, - {sfPreviousTxnID, soeREQUIRED}, - {sfPreviousTxnLgrSeq, soeREQUIRED}, + {sfAccount, soeREQUIRED}, + {sfMPTokenIssuanceID, soeREQUIRED}, + {sfMPTAmount, soeDEFAULT}, + {sfLockedAmount, soeOPTIONAL}, + {sfOwnerNode, soeREQUIRED}, + {sfPreviousTxnID, soeREQUIRED}, + {sfPreviousTxnLgrSeq, soeREQUIRED}, + {sfConfidentialBalanceInbox, soeOPTIONAL}, + {sfConfidentialBalanceSpending, soeOPTIONAL}, + {sfConfidentialBalanceVersion, soeDEFAULT}, + {sfIssuerEncryptedBalance, soeOPTIONAL}, + {sfAuditorEncryptedBalance, soeOPTIONAL}, + {sfHolderEncryptionKey, soeOPTIONAL}, })) /** A ledger object which tracks Oracle diff --git a/include/xrpl/protocol/detail/sfields.macro b/include/xrpl/protocol/detail/sfields.macro index 4f21207831..1bbfadc093 100644 --- a/include/xrpl/protocol/detail/sfields.macro +++ b/include/xrpl/protocol/detail/sfields.macro @@ -113,6 +113,7 @@ TYPED_SFIELD(sfInterestRate, UINT32, 65) // 1/10 basis points (bi TYPED_SFIELD(sfLateInterestRate, UINT32, 66) // 1/10 basis points (bips) TYPED_SFIELD(sfCloseInterestRate, UINT32, 67) // 1/10 basis points (bips) TYPED_SFIELD(sfOverpaymentInterestRate, UINT32, 68) // 1/10 basis points (bips) +TYPED_SFIELD(sfConfidentialBalanceVersion, UINT32, 69) // 64-bit integers (common) TYPED_SFIELD(sfIndexNext, UINT64, 1) @@ -146,6 +147,7 @@ TYPED_SFIELD(sfSubjectNode, UINT64, 28) TYPED_SFIELD(sfLockedAmount, UINT64, 29, SField::sMD_BaseTen|SField::sMD_Default) TYPED_SFIELD(sfVaultNode, UINT64, 30) TYPED_SFIELD(sfLoanBrokerNode, UINT64, 31) +TYPED_SFIELD(sfConfidentialOutstandingAmount, UINT64, 32, SField::sMD_BaseTen|SField::sMD_Default) // 128-bit TYPED_SFIELD(sfEmailHash, UINT128, 1) @@ -205,6 +207,7 @@ TYPED_SFIELD(sfParentBatchID, UINT256, 36) TYPED_SFIELD(sfLoanBrokerID, UINT256, 37, SField::sMD_PseudoAccount | SField::sMD_Default) TYPED_SFIELD(sfLoanID, UINT256, 38) +TYPED_SFIELD(sfBlindingFactor, UINT256, 39) // number (common) TYPED_SFIELD(sfNumber, NUMBER, 1) @@ -298,6 +301,21 @@ TYPED_SFIELD(sfAssetClass, VL, 28) TYPED_SFIELD(sfProvider, VL, 29) TYPED_SFIELD(sfMPTokenMetadata, VL, 30) TYPED_SFIELD(sfCredentialType, VL, 31) +TYPED_SFIELD(sfConfidentialBalanceInbox, VL, 32) +TYPED_SFIELD(sfConfidentialBalanceSpending, VL, 33) +TYPED_SFIELD(sfIssuerEncryptedBalance, VL, 34) +TYPED_SFIELD(sfIssuerEncryptionKey, VL, 35) +TYPED_SFIELD(sfHolderEncryptionKey, VL, 36) +TYPED_SFIELD(sfZKProof, VL, 37) +TYPED_SFIELD(sfHolderEncryptedAmount, VL, 38) +TYPED_SFIELD(sfIssuerEncryptedAmount, VL, 39) +TYPED_SFIELD(sfSenderEncryptedAmount, VL, 40) +TYPED_SFIELD(sfDestinationEncryptedAmount, VL, 41) +TYPED_SFIELD(sfAuditorEncryptedBalance, VL, 42) +TYPED_SFIELD(sfAuditorEncryptedAmount, VL, 43) +TYPED_SFIELD(sfAuditorEncryptionKey, VL, 44) +TYPED_SFIELD(sfAmountCommitment, VL, 45) +TYPED_SFIELD(sfBalanceCommitment, VL, 46) // account (common) TYPED_SFIELD(sfAccount, ACCOUNT, 1) diff --git a/include/xrpl/protocol/detail/transactions.macro b/include/xrpl/protocol/detail/transactions.macro index e52d28ce2a..b6eb3892e0 100644 --- a/include/xrpl/protocol/detail/transactions.macro +++ b/include/xrpl/protocol/detail/transactions.macro @@ -734,6 +734,8 @@ TRANSACTION(ttMPTOKEN_ISSUANCE_SET, 56, MPTokenIssuanceSet, {sfMPTokenMetadata, soeOPTIONAL}, {sfTransferFee, soeOPTIONAL}, {sfMutableFlags, soeOPTIONAL}, + {sfIssuerEncryptionKey, soeOPTIONAL}, + {sfAuditorEncryptionKey, soeOPTIONAL}, })) /** This transaction type authorizes a MPToken instance */ @@ -1076,6 +1078,90 @@ TRANSACTION(ttLOAN_PAY, 84, LoanPay, {sfAmount, soeREQUIRED, soeMPTSupported}, })) +/** This transaction type converts into confidential MPT balance. */ +#if TRANSACTION_INCLUDE +#include +#endif +TRANSACTION(ttCONFIDENTIAL_MPT_CONVERT, 85, ConfidentialMPTConvert, + Delegation::delegable, + featureConfidentialTransfer, + noPriv, + ({ + {sfMPTokenIssuanceID, soeREQUIRED}, + {sfMPTAmount, soeREQUIRED}, + {sfHolderEncryptionKey, soeOPTIONAL}, + {sfHolderEncryptedAmount, soeREQUIRED}, + {sfIssuerEncryptedAmount, soeREQUIRED}, + {sfAuditorEncryptedAmount, soeOPTIONAL}, + {sfBlindingFactor, soeREQUIRED}, + {sfZKProof, soeOPTIONAL}, +})) + +/** This transaction type merges MPT inbox. */ +#if TRANSACTION_INCLUDE +#include +#endif +TRANSACTION(ttCONFIDENTIAL_MPT_MERGE_INBOX, 86, ConfidentialMPTMergeInbox, + Delegation::delegable, + featureConfidentialTransfer, + noPriv, + ({ + {sfMPTokenIssuanceID, soeREQUIRED}, +})) + +/** This transaction type converts back into public MPT balance. */ +#if TRANSACTION_INCLUDE +#include +#endif +TRANSACTION(ttCONFIDENTIAL_MPT_CONVERT_BACK, 87, ConfidentialMPTConvertBack, + Delegation::delegable, + featureConfidentialTransfer, + noPriv, + ({ + {sfMPTokenIssuanceID, soeREQUIRED}, + {sfMPTAmount, soeREQUIRED}, + {sfHolderEncryptedAmount, soeREQUIRED}, + {sfIssuerEncryptedAmount, soeREQUIRED}, + {sfAuditorEncryptedAmount, soeOPTIONAL}, + {sfBlindingFactor, soeREQUIRED}, + {sfZKProof, soeREQUIRED}, + {sfBalanceCommitment, soeREQUIRED}, +})) + +#if TRANSACTION_INCLUDE +#include +#endif +TRANSACTION(ttCONFIDENTIAL_MPT_SEND, 88, ConfidentialMPTSend, + Delegation::delegable, + featureConfidentialTransfer, + noPriv, + ({ + {sfMPTokenIssuanceID, soeREQUIRED}, + {sfDestination, soeREQUIRED}, + {sfSenderEncryptedAmount, soeREQUIRED}, + {sfDestinationEncryptedAmount, soeREQUIRED}, + {sfIssuerEncryptedAmount, soeREQUIRED}, + {sfAuditorEncryptedAmount, soeOPTIONAL}, + {sfZKProof, soeREQUIRED}, + {sfAmountCommitment, soeREQUIRED}, + {sfBalanceCommitment, soeREQUIRED}, + {sfCredentialIDs, soeOPTIONAL}, +})) + +#if TRANSACTION_INCLUDE +#include +#endif +TRANSACTION(ttCONFIDENTIAL_MPT_CLAWBACK, 89, ConfidentialMPTClawback, + Delegation::delegable, + featureConfidentialTransfer, + noPriv, + ({ + {sfMPTokenIssuanceID, soeREQUIRED}, + {sfHolder, soeREQUIRED}, + {sfMPTAmount, soeREQUIRED}, + {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/jss.h b/include/xrpl/protocol/jss.h index a22810ac8a..366a75df76 100644 --- a/include/xrpl/protocol/jss.h +++ b/include/xrpl/protocol/jss.h @@ -137,6 +137,7 @@ JSS(authorized_credentials); // in: ledger_entry DepositPreauth JSS(auth_accounts); // out: amm_info JSS(auth_change); // out: AccountInfo JSS(auth_change_queued); // out: AccountInfo +JSS(auditor_encrypted_balance); // out: mpt_holders (confidential MPT) JSS(available); // out: ValidatorList JSS(avg_bps_recv); // out: Peers JSS(avg_bps_sent); // out: Peers @@ -161,9 +162,6 @@ JSS(build_path); // in: TransactionSign JSS(build_version); // out: NetworkOPs JSS(cancel_after); // out: AccountChannels JSS(can_delete); // out: CanDelete -JSS(mpt_amount); // out: mpt_holders -JSS(mpt_issuance_id); // in: Payment, mpt_holders -JSS(mptoken_index); // out: mpt_holders JSS(changes); // out: BookChanges JSS(channel_id); // out: AccountChannels JSS(channels); // out: AccountChannels @@ -185,165 +183,170 @@ JSS(command); // in: RPCHandler JSS(common); // out: RPC server_definitions JSS(complete); // out: NetworkOPs, InboundLedger JSS(complete_ledgers); // out: NetworkOPs, PeerImp -JSS(consensus); // out: NetworkOPs, LedgerConsensus -JSS(converge_time); // out: NetworkOPs -JSS(converge_time_s); // out: NetworkOPs -JSS(cookie); // out: NetworkOPs -JSS(count); // in: AccountTx*, ValidatorList -JSS(counters); // in/out: retrieve counters -JSS(credentials); // in: deposit_authorized -JSS(credential_type); // in: LedgerEntry DepositPreauth -JSS(ctid); // in/out: Tx RPC -JSS(currency_a); // out: BookChanges -JSS(currency_b); // out: BookChanges -JSS(currency); // in: paths/PathRequest, STAmount - // out: STPathSet, STAmount, AccountLines -JSS(current); // out: OwnerInfo -JSS(current_activities); // -JSS(current_ledger_size); // out: TxQ -JSS(current_queue_size); // out: TxQ -JSS(data); // out: LedgerData -JSS(date); // out: tx/Transaction, NetworkOPs -JSS(dbKBLedger); // out: getCounts -JSS(dbKBTotal); // out: getCounts -JSS(dbKBTransaction); // out: getCounts -JSS(debug_signing); // in: TransactionSign -JSS(deletion_blockers_only); // in: AccountObjects -JSS(delivered_amount); // out: insertDeliveredAmount -JSS(deposit_authorized); // out: deposit_authorized -JSS(deprecated); // -JSS(descending); // in: AccountTx* -JSS(description); // in/out: Reservations -JSS(destination); // in: nft_buy_offers, nft_sell_offers -JSS(destination_account); // in: PathRequest, RipplePathFind, account_lines - // out: AccountChannels -JSS(destination_amount); // in: PathRequest, RipplePathFind -JSS(destination_currencies); // in: PathRequest, RipplePathFind -JSS(destination_tag); // in: PathRequest - // out: AccountChannels -JSS(details); // out: Manifest, server_info -JSS(dir_entry); // out: DirectoryEntryIterator -JSS(dir_index); // out: DirectoryEntryIterator -JSS(dir_root); // out: DirectoryEntryIterator -JSS(discounted_fee); // out: amm_info -JSS(domain); // out: ValidatorInfo, Manifest -JSS(drops); // out: TxQ -JSS(duration_us); // out: NetworkOPs -JSS(effective); // out: ValidatorList - // in: UNL -JSS(enabled); // out: AmendmentTable -JSS(engine_result); // out: NetworkOPs, TransactionSign, Submit -JSS(engine_result_code); // out: NetworkOPs, TransactionSign, Submit -JSS(engine_result_message); // out: NetworkOPs, TransactionSign, Submit -JSS(entire_set); // out: get_aggregate_price -JSS(ephemeral_key); // out: ValidatorInfo - // in/out: Manifest -JSS(error); // out: error -JSS(errored); // -JSS(error_code); // out: error -JSS(error_exception); // out: Submit -JSS(error_message); // out: error -JSS(expand); // in: handler/Ledger -JSS(expected_date); // out: any (warnings) -JSS(expected_date_UTC); // out: any (warnings) -JSS(expected_ledger_size); // out: TxQ -JSS(expiration); // out: AccountOffers, AccountChannels, ValidatorList, amm_info -JSS(fail_hard); // in: Sign, Submit -JSS(failed); // out: InboundLedger -JSS(feature); // in: Feature -JSS(features); // out: Feature -JSS(fee_base); // out: NetworkOPs -JSS(fee_div_max); // in: TransactionSign -JSS(fee_level); // out: AccountInfo -JSS(fee_mult_max); // in: TransactionSign -JSS(fee_ref); // out: NetworkOPs, DEPRECATED -JSS(fetch_pack); // out: NetworkOPs -JSS(FIELDS); // out: RPC server_definitions - // matches definitions.json format -JSS(first); // out: rpc/Version -JSS(finished); // -JSS(fix_txns); // in: LedgerCleaner -JSS(flags); // out: AccountOffers, NetworkOPs -JSS(forward); // in: AccountTx -JSS(freeze); // out: AccountLines -JSS(freeze_peer); // out: AccountLines -JSS(deep_freeze); // out: AccountLines -JSS(deep_freeze_peer); // out: AccountLines -JSS(frozen_balances); // out: GatewayBalances -JSS(full); // in: LedgerClearer, handlers/Ledger -JSS(full_reply); // out: PathFind -JSS(fullbelow_size); // out: GetCounts -JSS(git); // out: server_info -JSS(good); // out: RPCVersion -JSS(hash); // out: NetworkOPs, InboundLedger, LedgerToJson, STTx; field -JSS(have_header); // out: InboundLedger -JSS(have_state); // out: InboundLedger -JSS(have_transactions); // out: InboundLedger -JSS(high); // out: BookChanges -JSS(highest_sequence); // out: AccountInfo -JSS(highest_ticket); // out: AccountInfo -JSS(historical_perminute); // historical_perminute. -JSS(holders); // out: MPTHolders -JSS(hostid); // out: NetworkOPs -JSS(hotwallet); // in: GatewayBalances -JSS(id); // websocket. -JSS(ident); // in: AccountCurrencies, AccountInfo, OwnerInfo -JSS(ignore_default); // in: AccountLines -JSS(in); // out: OverlayImpl -JSS(inLedger); // out: tx/Transaction -JSS(inbound); // out: PeerImp -JSS(index); // in: LedgerEntry - // out: STLedgerEntry, LedgerEntry, TxHistory, LedgerData -JSS(info); // out: ServerInfo, ConsensusInfo, FetchInfo -JSS(initial_sync_duration_us); // -JSS(internal_command); // in: Internal -JSS(invalid_API_version); // out: Many, when a request has an invalid version -JSS(io_latency_ms); // out: NetworkOPs -JSS(ip); // in: Connect, out: OverlayImpl -JSS(is_burned); // out: nft_info (clio) -JSS(isSerialized); // out: RPC server_definitions - // matches definitions.json format -JSS(isSigningField); // out: RPC server_definitions - // matches definitions.json format -JSS(isVLEncoded); // out: RPC server_definitions - // matches definitions.json format -JSS(issuer); // in: RipplePathFind, Subscribe, Unsubscribe, BookOffers - // out: STPathSet, STAmount -JSS(job); // -JSS(job_queue); // -JSS(jobs); // -JSS(jsonrpc); // json version -JSS(jq_trans_overflow); // JobQueue transaction limit overflow. -JSS(kept); // out: SubmitTransaction -JSS(key); // out -JSS(key_type); // in/out: WalletPropose, TransactionSign -JSS(latency); // out: PeerImp -JSS(last); // out: RPCVersion -JSS(last_close); // out: NetworkOPs -JSS(last_refresh_time); // out: ValidatorSite -JSS(last_refresh_status); // out: ValidatorSite -JSS(last_refresh_message); // out: ValidatorSite -JSS(ledger); // in: NetworkOPs, LedgerCleaner, RPCHelpers - // out: NetworkOPs, PeerImp -JSS(ledger_current_index); // out: NetworkOPs, RPCHelpers, LedgerCurrent, LedgerAccept, - // AccountLines -JSS(ledger_data); // out: LedgerHeader -JSS(ledger_hash); // in: RPCHelpers, LedgerRequest, RipplePathFind, - // TransactionEntry, handlers/Ledger - // out: NetworkOPs, RPCHelpers, LedgerClosed, LedgerData, - // AccountLines -JSS(ledger_hit_rate); // out: GetCounts -JSS(ledger_index); // in/out: many -JSS(ledger_index_max); // in, out: AccountTx* -JSS(ledger_index_min); // in, out: AccountTx* -JSS(ledger_max); // in, out: AccountTx* -JSS(ledger_min); // in, out: AccountTx* -JSS(ledger_time); // out: NetworkOPs -JSS(LEDGER_ENTRY_TYPES); // out: RPC server_definitions - // matches definitions.json format -JSS(LEDGER_ENTRY_FLAGS); // out: RPC server_definitions -JSS(LEDGER_ENTRY_FORMATS); // out: RPC server_definitions -JSS(levels); // LogLevels +JSS(confidential_balance_inbox); // out: mpt_holders (confidential MPT) +JSS(confidential_balance_spending); // out: mpt_holders (confidential MPT) +JSS(confidential_balance_version); // out: mpt_holders (confidential MPT) +JSS(consensus); // out: NetworkOPs, LedgerConsensus +JSS(converge_time); // out: NetworkOPs +JSS(converge_time_s); // out: NetworkOPs +JSS(cookie); // out: NetworkOPs +JSS(count); // in: AccountTx*, ValidatorList +JSS(counters); // in/out: retrieve counters +JSS(credentials); // in: deposit_authorized +JSS(credential_type); // in: LedgerEntry DepositPreauth +JSS(ctid); // in/out: Tx RPC +JSS(currency_a); // out: BookChanges +JSS(currency_b); // out: BookChanges +JSS(currency); // in: paths/PathRequest, STAmount + // out: STPathSet, STAmount, AccountLines +JSS(current); // out: OwnerInfo +JSS(current_activities); // +JSS(current_ledger_size); // out: TxQ +JSS(current_queue_size); // out: TxQ +JSS(data); // out: LedgerData +JSS(date); // out: tx/Transaction, NetworkOPs +JSS(dbKBLedger); // out: getCounts +JSS(dbKBTotal); // out: getCounts +JSS(dbKBTransaction); // out: getCounts +JSS(debug_signing); // in: TransactionSign +JSS(deletion_blockers_only); // in: AccountObjects +JSS(delivered_amount); // out: insertDeliveredAmount +JSS(deposit_authorized); // out: deposit_authorized +JSS(deprecated); // +JSS(descending); // in: AccountTx* +JSS(description); // in/out: Reservations +JSS(destination); // in: nft_buy_offers, nft_sell_offers +JSS(destination_account); // in: PathRequest, RipplePathFind, account_lines + // out: AccountChannels +JSS(destination_amount); // in: PathRequest, RipplePathFind +JSS(destination_currencies); // in: PathRequest, RipplePathFind +JSS(destination_tag); // in: PathRequest + // out: AccountChannels +JSS(details); // out: Manifest, server_info +JSS(dir_entry); // out: DirectoryEntryIterator +JSS(dir_index); // out: DirectoryEntryIterator +JSS(dir_root); // out: DirectoryEntryIterator +JSS(discounted_fee); // out: amm_info +JSS(domain); // out: ValidatorInfo, Manifest +JSS(drops); // out: TxQ +JSS(duration_us); // out: NetworkOPs +JSS(effective); // out: ValidatorList + // in: UNL +JSS(enabled); // out: AmendmentTable +JSS(engine_result); // out: NetworkOPs, TransactionSign, Submit +JSS(engine_result_code); // out: NetworkOPs, TransactionSign, Submit +JSS(engine_result_message); // out: NetworkOPs, TransactionSign, Submit +JSS(entire_set); // out: get_aggregate_price +JSS(ephemeral_key); // out: ValidatorInfo + // in/out: Manifest +JSS(error); // out: error +JSS(errored); // +JSS(error_code); // out: error +JSS(error_exception); // out: Submit +JSS(error_message); // out: error +JSS(expand); // in: handler/Ledger +JSS(expected_date); // out: any (warnings) +JSS(expected_date_UTC); // out: any (warnings) +JSS(expected_ledger_size); // out: TxQ +JSS(expiration); // out: AccountOffers, AccountChannels, ValidatorList, amm_info +JSS(fail_hard); // in: Sign, Submit +JSS(failed); // out: InboundLedger +JSS(feature); // in: Feature +JSS(features); // out: Feature +JSS(fee_base); // out: NetworkOPs +JSS(fee_div_max); // in: TransactionSign +JSS(fee_level); // out: AccountInfo +JSS(fee_mult_max); // in: TransactionSign +JSS(fee_ref); // out: NetworkOPs, DEPRECATED +JSS(fetch_pack); // out: NetworkOPs +JSS(FIELDS); // out: RPC server_definitions + // matches definitions.json format +JSS(first); // out: rpc/Version +JSS(finished); // +JSS(fix_txns); // in: LedgerCleaner +JSS(flags); // out: AccountOffers, NetworkOPs +JSS(forward); // in: AccountTx +JSS(freeze); // out: AccountLines +JSS(freeze_peer); // out: AccountLines +JSS(deep_freeze); // out: AccountLines +JSS(deep_freeze_peer); // out: AccountLines +JSS(frozen_balances); // out: GatewayBalances +JSS(full); // in: LedgerClearer, handlers/Ledger +JSS(full_reply); // out: PathFind +JSS(fullbelow_size); // out: GetCounts +JSS(git); // out: server_info +JSS(good); // out: RPCVersion +JSS(hash); // out: NetworkOPs, InboundLedger, LedgerToJson, STTx; field +JSS(have_header); // out: InboundLedger +JSS(have_state); // out: InboundLedger +JSS(have_transactions); // out: InboundLedger +JSS(high); // out: BookChanges +JSS(highest_sequence); // out: AccountInfo +JSS(highest_ticket); // out: AccountInfo +JSS(historical_perminute); // historical_perminute. +JSS(holders); // out: MPTHolders +JSS(holder_encryption_key); // out: mpt_holders (confidential MPT) +JSS(hostid); // out: NetworkOPs +JSS(hotwallet); // in: GatewayBalances +JSS(id); // websocket. +JSS(ident); // in: AccountCurrencies, AccountInfo, OwnerInfo +JSS(ignore_default); // in: AccountLines +JSS(in); // out: OverlayImpl +JSS(inLedger); // out: tx/Transaction +JSS(inbound); // out: PeerImp +JSS(index); // in: LedgerEntry + // out: STLedgerEntry, LedgerEntry, TxHistory, LedgerData +JSS(info); // out: ServerInfo, ConsensusInfo, FetchInfo +JSS(initial_sync_duration_us); // +JSS(internal_command); // in: Internal +JSS(invalid_API_version); // out: Many, when a request has an invalid version +JSS(io_latency_ms); // out: NetworkOPs +JSS(ip); // in: Connect, out: OverlayImpl +JSS(is_burned); // out: nft_info (clio) +JSS(isSerialized); // out: RPC server_definitions + // matches definitions.json format +JSS(isSigningField); // out: RPC server_definitions + // matches definitions.json format +JSS(isVLEncoded); // out: RPC server_definitions + // matches definitions.json format +JSS(issuer); // in: RipplePathFind, Subscribe, Unsubscribe, BookOffers + // out: STPathSet, STAmount +JSS(issuer_encrypted_balance); // out: mpt_holders (confidential MPT) +JSS(job); // +JSS(job_queue); // +JSS(jobs); // +JSS(jsonrpc); // json version +JSS(jq_trans_overflow); // JobQueue transaction limit overflow. +JSS(kept); // out: SubmitTransaction +JSS(key); // out +JSS(key_type); // in/out: WalletPropose, TransactionSign +JSS(latency); // out: PeerImp +JSS(last); // out: RPCVersion +JSS(last_close); // out: NetworkOPs +JSS(last_refresh_time); // out: ValidatorSite +JSS(last_refresh_status); // out: ValidatorSite +JSS(last_refresh_message); // out: ValidatorSite +JSS(ledger); // in: NetworkOPs, LedgerCleaner, RPCHelpers + // out: NetworkOPs, PeerImp +JSS(ledger_current_index); // out: NetworkOPs, RPCHelpers, LedgerCurrent, LedgerAccept, + // AccountLines +JSS(ledger_data); // out: LedgerHeader +JSS(ledger_hash); // in: RPCHelpers, LedgerRequest, RipplePathFind, + // TransactionEntry, handlers/Ledger + // out: NetworkOPs, RPCHelpers, LedgerClosed, LedgerData, + // AccountLines +JSS(ledger_hit_rate); // out: GetCounts +JSS(ledger_index); // in/out: many +JSS(ledger_index_max); // in, out: AccountTx* +JSS(ledger_index_min); // in, out: AccountTx* +JSS(ledger_max); // in, out: AccountTx* +JSS(ledger_min); // in, out: AccountTx* +JSS(ledger_time); // out: NetworkOPs +JSS(LEDGER_ENTRY_TYPES); // out: RPC server_definitions + // matches definitions.json format +JSS(LEDGER_ENTRY_FLAGS); // out: RPC server_definitions +JSS(LEDGER_ENTRY_FORMATS); // out: RPC server_definitions +JSS(levels); // LogLevels JSS(limit); // in/out: AccountTx*, AccountOffers, AccountLines, AccountObjects // in: LedgerData, BookOffers JSS(limit_peer); // out: AccountLines @@ -401,6 +404,9 @@ JSS(min_ledger); // in: LedgerCleaner JSS(minimum_fee); // out: TxQ JSS(minimum_level); // out: TxQ JSS(missingCommand); // error +JSS(mpt_amount); // out: mpt_holders +JSS(mpt_issuance_id); // in: Payment, mpt_holders +JSS(mptoken_index); // out: mpt_holders JSS(mpt_issuance_id_a); // out: BookChanges JSS(mpt_issuance_id_b); // out: BookChanges JSS(name); // out: AmendmentTableImpl, PeerImp diff --git a/include/xrpl/protocol_autogen/ledger_entries/MPToken.h b/include/xrpl/protocol_autogen/ledger_entries/MPToken.h index d11a8effd3..848b1d22f8 100644 --- a/include/xrpl/protocol_autogen/ledger_entries/MPToken.h +++ b/include/xrpl/protocol_autogen/ledger_entries/MPToken.h @@ -147,6 +147,150 @@ public: { return this->sle_->at(sfPreviousTxnLgrSeq); } + + /** + * @brief Get sfConfidentialBalanceInbox (soeOPTIONAL) + * @return The field value, or std::nullopt if not present. + */ + [[nodiscard]] + protocol_autogen::Optional + getConfidentialBalanceInbox() const + { + if (hasConfidentialBalanceInbox()) + return this->sle_->at(sfConfidentialBalanceInbox); + return std::nullopt; + } + + /** + * @brief Check if sfConfidentialBalanceInbox is present. + * @return True if the field is present, false otherwise. + */ + [[nodiscard]] + bool + hasConfidentialBalanceInbox() const + { + return this->sle_->isFieldPresent(sfConfidentialBalanceInbox); + } + + /** + * @brief Get sfConfidentialBalanceSpending (soeOPTIONAL) + * @return The field value, or std::nullopt if not present. + */ + [[nodiscard]] + protocol_autogen::Optional + getConfidentialBalanceSpending() const + { + if (hasConfidentialBalanceSpending()) + return this->sle_->at(sfConfidentialBalanceSpending); + return std::nullopt; + } + + /** + * @brief Check if sfConfidentialBalanceSpending is present. + * @return True if the field is present, false otherwise. + */ + [[nodiscard]] + bool + hasConfidentialBalanceSpending() const + { + return this->sle_->isFieldPresent(sfConfidentialBalanceSpending); + } + + /** + * @brief Get sfConfidentialBalanceVersion (soeDEFAULT) + * @return The field value, or std::nullopt if not present. + */ + [[nodiscard]] + protocol_autogen::Optional + getConfidentialBalanceVersion() const + { + if (hasConfidentialBalanceVersion()) + return this->sle_->at(sfConfidentialBalanceVersion); + return std::nullopt; + } + + /** + * @brief Check if sfConfidentialBalanceVersion is present. + * @return True if the field is present, false otherwise. + */ + [[nodiscard]] + bool + hasConfidentialBalanceVersion() const + { + return this->sle_->isFieldPresent(sfConfidentialBalanceVersion); + } + + /** + * @brief Get sfIssuerEncryptedBalance (soeOPTIONAL) + * @return The field value, or std::nullopt if not present. + */ + [[nodiscard]] + protocol_autogen::Optional + getIssuerEncryptedBalance() const + { + if (hasIssuerEncryptedBalance()) + return this->sle_->at(sfIssuerEncryptedBalance); + return std::nullopt; + } + + /** + * @brief Check if sfIssuerEncryptedBalance is present. + * @return True if the field is present, false otherwise. + */ + [[nodiscard]] + bool + hasIssuerEncryptedBalance() const + { + return this->sle_->isFieldPresent(sfIssuerEncryptedBalance); + } + + /** + * @brief Get sfAuditorEncryptedBalance (soeOPTIONAL) + * @return The field value, or std::nullopt if not present. + */ + [[nodiscard]] + protocol_autogen::Optional + getAuditorEncryptedBalance() const + { + if (hasAuditorEncryptedBalance()) + return this->sle_->at(sfAuditorEncryptedBalance); + return std::nullopt; + } + + /** + * @brief Check if sfAuditorEncryptedBalance is present. + * @return True if the field is present, false otherwise. + */ + [[nodiscard]] + bool + hasAuditorEncryptedBalance() const + { + return this->sle_->isFieldPresent(sfAuditorEncryptedBalance); + } + + /** + * @brief Get sfHolderEncryptionKey (soeOPTIONAL) + * @return The field value, or std::nullopt if not present. + */ + [[nodiscard]] + protocol_autogen::Optional + getHolderEncryptionKey() const + { + if (hasHolderEncryptionKey()) + return this->sle_->at(sfHolderEncryptionKey); + return std::nullopt; + } + + /** + * @brief Check if sfHolderEncryptionKey is present. + * @return True if the field is present, false otherwise. + */ + [[nodiscard]] + bool + hasHolderEncryptionKey() const + { + return this->sle_->isFieldPresent(sfHolderEncryptionKey); + } }; /** @@ -270,6 +414,72 @@ public: return *this; } + /** + * @brief Set sfConfidentialBalanceInbox (soeOPTIONAL) + * @return Reference to this builder for method chaining. + */ + MPTokenBuilder& + setConfidentialBalanceInbox(std::decay_t const& value) + { + object_[sfConfidentialBalanceInbox] = value; + return *this; + } + + /** + * @brief Set sfConfidentialBalanceSpending (soeOPTIONAL) + * @return Reference to this builder for method chaining. + */ + MPTokenBuilder& + setConfidentialBalanceSpending(std::decay_t const& value) + { + object_[sfConfidentialBalanceSpending] = value; + return *this; + } + + /** + * @brief Set sfConfidentialBalanceVersion (soeDEFAULT) + * @return Reference to this builder for method chaining. + */ + MPTokenBuilder& + setConfidentialBalanceVersion(std::decay_t const& value) + { + object_[sfConfidentialBalanceVersion] = value; + return *this; + } + + /** + * @brief Set sfIssuerEncryptedBalance (soeOPTIONAL) + * @return Reference to this builder for method chaining. + */ + MPTokenBuilder& + setIssuerEncryptedBalance(std::decay_t const& value) + { + object_[sfIssuerEncryptedBalance] = value; + return *this; + } + + /** + * @brief Set sfAuditorEncryptedBalance (soeOPTIONAL) + * @return Reference to this builder for method chaining. + */ + MPTokenBuilder& + setAuditorEncryptedBalance(std::decay_t const& value) + { + object_[sfAuditorEncryptedBalance] = value; + return *this; + } + + /** + * @brief Set sfHolderEncryptionKey (soeOPTIONAL) + * @return Reference to this builder for method chaining. + */ + MPTokenBuilder& + setHolderEncryptionKey(std::decay_t const& value) + { + object_[sfHolderEncryptionKey] = value; + return *this; + } + /** * @brief Build and return the completed MPToken wrapper. * @param index The ledger entry index. diff --git a/include/xrpl/protocol_autogen/ledger_entries/MPTokenIssuance.h b/include/xrpl/protocol_autogen/ledger_entries/MPTokenIssuance.h index 23b8a05015..714e69fe6f 100644 --- a/include/xrpl/protocol_autogen/ledger_entries/MPTokenIssuance.h +++ b/include/xrpl/protocol_autogen/ledger_entries/MPTokenIssuance.h @@ -278,6 +278,78 @@ public: { return this->sle_->isFieldPresent(sfMutableFlags); } + + /** + * @brief Get sfIssuerEncryptionKey (soeOPTIONAL) + * @return The field value, or std::nullopt if not present. + */ + [[nodiscard]] + protocol_autogen::Optional + getIssuerEncryptionKey() const + { + if (hasIssuerEncryptionKey()) + return this->sle_->at(sfIssuerEncryptionKey); + return std::nullopt; + } + + /** + * @brief Check if sfIssuerEncryptionKey is present. + * @return True if the field is present, false otherwise. + */ + [[nodiscard]] + bool + hasIssuerEncryptionKey() const + { + return this->sle_->isFieldPresent(sfIssuerEncryptionKey); + } + + /** + * @brief Get sfAuditorEncryptionKey (soeOPTIONAL) + * @return The field value, or std::nullopt if not present. + */ + [[nodiscard]] + protocol_autogen::Optional + getAuditorEncryptionKey() const + { + if (hasAuditorEncryptionKey()) + return this->sle_->at(sfAuditorEncryptionKey); + return std::nullopt; + } + + /** + * @brief Check if sfAuditorEncryptionKey is present. + * @return True if the field is present, false otherwise. + */ + [[nodiscard]] + bool + hasAuditorEncryptionKey() const + { + return this->sle_->isFieldPresent(sfAuditorEncryptionKey); + } + + /** + * @brief Get sfConfidentialOutstandingAmount (soeDEFAULT) + * @return The field value, or std::nullopt if not present. + */ + [[nodiscard]] + protocol_autogen::Optional + getConfidentialOutstandingAmount() const + { + if (hasConfidentialOutstandingAmount()) + return this->sle_->at(sfConfidentialOutstandingAmount); + return std::nullopt; + } + + /** + * @brief Check if sfConfidentialOutstandingAmount is present. + * @return True if the field is present, false otherwise. + */ + [[nodiscard]] + bool + hasConfidentialOutstandingAmount() const + { + return this->sle_->isFieldPresent(sfConfidentialOutstandingAmount); + } }; /** @@ -469,6 +541,39 @@ public: return *this; } + /** + * @brief Set sfIssuerEncryptionKey (soeOPTIONAL) + * @return Reference to this builder for method chaining. + */ + MPTokenIssuanceBuilder& + setIssuerEncryptionKey(std::decay_t const& value) + { + object_[sfIssuerEncryptionKey] = value; + return *this; + } + + /** + * @brief Set sfAuditorEncryptionKey (soeOPTIONAL) + * @return Reference to this builder for method chaining. + */ + MPTokenIssuanceBuilder& + setAuditorEncryptionKey(std::decay_t const& value) + { + object_[sfAuditorEncryptionKey] = value; + return *this; + } + + /** + * @brief Set sfConfidentialOutstandingAmount (soeDEFAULT) + * @return Reference to this builder for method chaining. + */ + MPTokenIssuanceBuilder& + setConfidentialOutstandingAmount(std::decay_t const& value) + { + object_[sfConfidentialOutstandingAmount] = value; + return *this; + } + /** * @brief Build and return the completed MPTokenIssuance wrapper. * @param index The ledger entry index. diff --git a/include/xrpl/protocol_autogen/transactions/ConfidentialMPTClawback.h b/include/xrpl/protocol_autogen/transactions/ConfidentialMPTClawback.h new file mode 100644 index 0000000000..544a2cf75c --- /dev/null +++ b/include/xrpl/protocol_autogen/transactions/ConfidentialMPTClawback.h @@ -0,0 +1,201 @@ +// This file is auto-generated. Do not edit. +#pragma once + +#include +#include +#include +#include +#include +#include + +#include +#include + +namespace xrpl::transactions { + +class ConfidentialMPTClawbackBuilder; + +/** + * @brief Transaction: ConfidentialMPTClawback + * + * Type: ttCONFIDENTIAL_MPT_CLAWBACK (89) + * Delegable: Delegation::delegable + * Amendment: featureConfidentialTransfer + * Privileges: noPriv + * + * Immutable wrapper around STTx providing type-safe field access. + * Use ConfidentialMPTClawbackBuilder to construct new transactions. + */ +class ConfidentialMPTClawback : public TransactionBase +{ +public: + static constexpr xrpl::TxType txType = ttCONFIDENTIAL_MPT_CLAWBACK; + + /** + * @brief Construct a ConfidentialMPTClawback transaction wrapper from an existing STTx object. + * @throws std::runtime_error if the transaction type doesn't match. + */ + explicit ConfidentialMPTClawback(std::shared_ptr tx) + : TransactionBase(std::move(tx)) + { + // Verify transaction type + if (tx_->getTxnType() != txType) + { + throw std::runtime_error("Invalid transaction type for ConfidentialMPTClawback"); + } + } + + // 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 (soeREQUIRED) + * @return The field value. + */ + [[nodiscard]] + SF_ACCOUNT::type::value_type + getHolder() const + { + return this->tx_->at(sfHolder); + } + + /** + * @brief Get sfMPTAmount (soeREQUIRED) + * @return The field value. + */ + [[nodiscard]] + SF_UINT64::type::value_type + getMPTAmount() const + { + return this->tx_->at(sfMPTAmount); + } + + /** + * @brief Get sfZKProof (soeREQUIRED) + * @return The field value. + */ + [[nodiscard]] + SF_VL::type::value_type + getZKProof() const + { + return this->tx_->at(sfZKProof); + } +}; + +/** + * @brief Builder for ConfidentialMPTClawback transactions. + * + * Provides a fluent interface for constructing transactions with method chaining. + * Uses Json::Value internally for flexible transaction construction. + * Inherits common field setters from TransactionBuilderBase. + */ +class ConfidentialMPTClawbackBuilder : public TransactionBuilderBase +{ +public: + /** + * @brief Construct a new ConfidentialMPTClawbackBuilder with required fields. + * @param account The account initiating the transaction. + * @param mPTokenIssuanceID The sfMPTokenIssuanceID field value. + * @param holder The sfHolder field value. + * @param mPTAmount The sfMPTAmount field value. + * @param zKProof The sfZKProof field value. + * @param sequence Optional sequence number for the transaction. + * @param fee Optional fee for the transaction. + */ + ConfidentialMPTClawbackBuilder(SF_ACCOUNT::type::value_type account, + std::decay_t const& mPTokenIssuanceID, std::decay_t const& holder, std::decay_t const& mPTAmount, std::decay_t const& zKProof, std::optional sequence = std::nullopt, + std::optional fee = std::nullopt +) + : TransactionBuilderBase(ttCONFIDENTIAL_MPT_CLAWBACK, account, sequence, fee) + { + setMPTokenIssuanceID(mPTokenIssuanceID); + setHolder(holder); + setMPTAmount(mPTAmount); + setZKProof(zKProof); + } + + /** + * @brief Construct a ConfidentialMPTClawbackBuilder from an existing STTx object. + * @param tx The existing transaction to copy from. + * @throws std::runtime_error if the transaction type doesn't match. + */ + ConfidentialMPTClawbackBuilder(std::shared_ptr tx) + { + if (tx->getTxnType() != ttCONFIDENTIAL_MPT_CLAWBACK) + { + throw std::runtime_error("Invalid transaction type for ConfidentialMPTClawbackBuilder"); + } + object_ = *tx; + } + + /** @brief Transaction-specific field setters */ + + /** + * @brief Set sfMPTokenIssuanceID (soeREQUIRED) + * @return Reference to this builder for method chaining. + */ + ConfidentialMPTClawbackBuilder& + setMPTokenIssuanceID(std::decay_t const& value) + { + object_[sfMPTokenIssuanceID] = value; + return *this; + } + + /** + * @brief Set sfHolder (soeREQUIRED) + * @return Reference to this builder for method chaining. + */ + ConfidentialMPTClawbackBuilder& + setHolder(std::decay_t const& value) + { + object_[sfHolder] = value; + return *this; + } + + /** + * @brief Set sfMPTAmount (soeREQUIRED) + * @return Reference to this builder for method chaining. + */ + ConfidentialMPTClawbackBuilder& + setMPTAmount(std::decay_t const& value) + { + object_[sfMPTAmount] = value; + return *this; + } + + /** + * @brief Set sfZKProof (soeREQUIRED) + * @return Reference to this builder for method chaining. + */ + ConfidentialMPTClawbackBuilder& + setZKProof(std::decay_t const& value) + { + object_[sfZKProof] = value; + return *this; + } + + /** + * @brief Build and return the ConfidentialMPTClawback wrapper. + * @param publicKey The public key for signing. + * @param secretKey The secret key for signing. + * @return The constructed transaction wrapper. + */ + ConfidentialMPTClawback + build(PublicKey const& publicKey, SecretKey const& secretKey) + { + sign(publicKey, secretKey); + return ConfidentialMPTClawback{std::make_shared(std::move(object_))}; + } +}; + +} // namespace xrpl::transactions diff --git a/include/xrpl/protocol_autogen/transactions/ConfidentialMPTConvert.h b/include/xrpl/protocol_autogen/transactions/ConfidentialMPTConvert.h new file mode 100644 index 0000000000..38491d405c --- /dev/null +++ b/include/xrpl/protocol_autogen/transactions/ConfidentialMPTConvert.h @@ -0,0 +1,336 @@ +// This file is auto-generated. Do not edit. +#pragma once + +#include +#include +#include +#include +#include +#include + +#include +#include + +namespace xrpl::transactions { + +class ConfidentialMPTConvertBuilder; + +/** + * @brief Transaction: ConfidentialMPTConvert + * + * Type: ttCONFIDENTIAL_MPT_CONVERT (85) + * Delegable: Delegation::delegable + * Amendment: featureConfidentialTransfer + * Privileges: noPriv + * + * Immutable wrapper around STTx providing type-safe field access. + * Use ConfidentialMPTConvertBuilder to construct new transactions. + */ +class ConfidentialMPTConvert : public TransactionBase +{ +public: + static constexpr xrpl::TxType txType = ttCONFIDENTIAL_MPT_CONVERT; + + /** + * @brief Construct a ConfidentialMPTConvert transaction wrapper from an existing STTx object. + * @throws std::runtime_error if the transaction type doesn't match. + */ + explicit ConfidentialMPTConvert(std::shared_ptr tx) + : TransactionBase(std::move(tx)) + { + // Verify transaction type + if (tx_->getTxnType() != txType) + { + throw std::runtime_error("Invalid transaction type for ConfidentialMPTConvert"); + } + } + + // 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 sfMPTAmount (soeREQUIRED) + * @return The field value. + */ + [[nodiscard]] + SF_UINT64::type::value_type + getMPTAmount() const + { + return this->tx_->at(sfMPTAmount); + } + + /** + * @brief Get sfHolderEncryptionKey (soeOPTIONAL) + * @return The field value, or std::nullopt if not present. + */ + [[nodiscard]] + protocol_autogen::Optional + getHolderEncryptionKey() const + { + if (hasHolderEncryptionKey()) + { + return this->tx_->at(sfHolderEncryptionKey); + } + return std::nullopt; + } + + /** + * @brief Check if sfHolderEncryptionKey is present. + * @return True if the field is present, false otherwise. + */ + [[nodiscard]] + bool + hasHolderEncryptionKey() const + { + return this->tx_->isFieldPresent(sfHolderEncryptionKey); + } + + /** + * @brief Get sfHolderEncryptedAmount (soeREQUIRED) + * @return The field value. + */ + [[nodiscard]] + SF_VL::type::value_type + getHolderEncryptedAmount() const + { + return this->tx_->at(sfHolderEncryptedAmount); + } + + /** + * @brief Get sfIssuerEncryptedAmount (soeREQUIRED) + * @return The field value. + */ + [[nodiscard]] + SF_VL::type::value_type + getIssuerEncryptedAmount() const + { + return this->tx_->at(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 sfBlindingFactor (soeREQUIRED) + * @return The field value. + */ + [[nodiscard]] + SF_UINT256::type::value_type + getBlindingFactor() const + { + return this->tx_->at(sfBlindingFactor); + } + + /** + * @brief Get sfZKProof (soeOPTIONAL) + * @return The field value, or std::nullopt if not present. + */ + [[nodiscard]] + protocol_autogen::Optional + getZKProof() const + { + if (hasZKProof()) + { + return this->tx_->at(sfZKProof); + } + return std::nullopt; + } + + /** + * @brief Check if sfZKProof is present. + * @return True if the field is present, false otherwise. + */ + [[nodiscard]] + bool + hasZKProof() const + { + return this->tx_->isFieldPresent(sfZKProof); + } +}; + +/** + * @brief Builder for ConfidentialMPTConvert transactions. + * + * Provides a fluent interface for constructing transactions with method chaining. + * Uses Json::Value internally for flexible transaction construction. + * Inherits common field setters from TransactionBuilderBase. + */ +class ConfidentialMPTConvertBuilder : public TransactionBuilderBase +{ +public: + /** + * @brief Construct a new ConfidentialMPTConvertBuilder with required fields. + * @param account The account initiating the transaction. + * @param mPTokenIssuanceID The sfMPTokenIssuanceID field value. + * @param mPTAmount The sfMPTAmount field value. + * @param holderEncryptedAmount The sfHolderEncryptedAmount field value. + * @param issuerEncryptedAmount The sfIssuerEncryptedAmount field value. + * @param blindingFactor The sfBlindingFactor field value. + * @param sequence Optional sequence number for the transaction. + * @param fee Optional fee for the transaction. + */ + ConfidentialMPTConvertBuilder(SF_ACCOUNT::type::value_type account, + std::decay_t const& mPTokenIssuanceID, std::decay_t const& mPTAmount, std::decay_t const& holderEncryptedAmount, std::decay_t const& issuerEncryptedAmount, std::decay_t const& blindingFactor, std::optional sequence = std::nullopt, + std::optional fee = std::nullopt +) + : TransactionBuilderBase(ttCONFIDENTIAL_MPT_CONVERT, account, sequence, fee) + { + setMPTokenIssuanceID(mPTokenIssuanceID); + setMPTAmount(mPTAmount); + setHolderEncryptedAmount(holderEncryptedAmount); + setIssuerEncryptedAmount(issuerEncryptedAmount); + setBlindingFactor(blindingFactor); + } + + /** + * @brief Construct a ConfidentialMPTConvertBuilder from an existing STTx object. + * @param tx The existing transaction to copy from. + * @throws std::runtime_error if the transaction type doesn't match. + */ + ConfidentialMPTConvertBuilder(std::shared_ptr tx) + { + if (tx->getTxnType() != ttCONFIDENTIAL_MPT_CONVERT) + { + throw std::runtime_error("Invalid transaction type for ConfidentialMPTConvertBuilder"); + } + object_ = *tx; + } + + /** @brief Transaction-specific field setters */ + + /** + * @brief Set sfMPTokenIssuanceID (soeREQUIRED) + * @return Reference to this builder for method chaining. + */ + ConfidentialMPTConvertBuilder& + setMPTokenIssuanceID(std::decay_t const& value) + { + object_[sfMPTokenIssuanceID] = value; + return *this; + } + + /** + * @brief Set sfMPTAmount (soeREQUIRED) + * @return Reference to this builder for method chaining. + */ + ConfidentialMPTConvertBuilder& + setMPTAmount(std::decay_t const& value) + { + object_[sfMPTAmount] = value; + return *this; + } + + /** + * @brief Set sfHolderEncryptionKey (soeOPTIONAL) + * @return Reference to this builder for method chaining. + */ + ConfidentialMPTConvertBuilder& + setHolderEncryptionKey(std::decay_t const& value) + { + object_[sfHolderEncryptionKey] = value; + return *this; + } + + /** + * @brief Set sfHolderEncryptedAmount (soeREQUIRED) + * @return Reference to this builder for method chaining. + */ + ConfidentialMPTConvertBuilder& + setHolderEncryptedAmount(std::decay_t const& value) + { + object_[sfHolderEncryptedAmount] = value; + return *this; + } + + /** + * @brief Set sfIssuerEncryptedAmount (soeREQUIRED) + * @return Reference to this builder for method chaining. + */ + ConfidentialMPTConvertBuilder& + setIssuerEncryptedAmount(std::decay_t const& value) + { + object_[sfIssuerEncryptedAmount] = value; + return *this; + } + + /** + * @brief Set sfAuditorEncryptedAmount (soeOPTIONAL) + * @return Reference to this builder for method chaining. + */ + ConfidentialMPTConvertBuilder& + setAuditorEncryptedAmount(std::decay_t const& value) + { + object_[sfAuditorEncryptedAmount] = value; + return *this; + } + + /** + * @brief Set sfBlindingFactor (soeREQUIRED) + * @return Reference to this builder for method chaining. + */ + ConfidentialMPTConvertBuilder& + setBlindingFactor(std::decay_t const& value) + { + object_[sfBlindingFactor] = value; + return *this; + } + + /** + * @brief Set sfZKProof (soeOPTIONAL) + * @return Reference to this builder for method chaining. + */ + ConfidentialMPTConvertBuilder& + setZKProof(std::decay_t const& value) + { + object_[sfZKProof] = value; + return *this; + } + + /** + * @brief Build and return the ConfidentialMPTConvert wrapper. + * @param publicKey The public key for signing. + * @param secretKey The secret key for signing. + * @return The constructed transaction wrapper. + */ + ConfidentialMPTConvert + build(PublicKey const& publicKey, SecretKey const& secretKey) + { + sign(publicKey, secretKey); + return ConfidentialMPTConvert{std::make_shared(std::move(object_))}; + } +}; + +} // namespace xrpl::transactions diff --git a/include/xrpl/protocol_autogen/transactions/ConfidentialMPTConvertBack.h b/include/xrpl/protocol_autogen/transactions/ConfidentialMPTConvertBack.h new file mode 100644 index 0000000000..a1a89a74dc --- /dev/null +++ b/include/xrpl/protocol_autogen/transactions/ConfidentialMPTConvertBack.h @@ -0,0 +1,310 @@ +// This file is auto-generated. Do not edit. +#pragma once + +#include +#include +#include +#include +#include +#include + +#include +#include + +namespace xrpl::transactions { + +class ConfidentialMPTConvertBackBuilder; + +/** + * @brief Transaction: ConfidentialMPTConvertBack + * + * Type: ttCONFIDENTIAL_MPT_CONVERT_BACK (87) + * Delegable: Delegation::delegable + * Amendment: featureConfidentialTransfer + * Privileges: noPriv + * + * Immutable wrapper around STTx providing type-safe field access. + * Use ConfidentialMPTConvertBackBuilder to construct new transactions. + */ +class ConfidentialMPTConvertBack : public TransactionBase +{ +public: + static constexpr xrpl::TxType txType = ttCONFIDENTIAL_MPT_CONVERT_BACK; + + /** + * @brief Construct a ConfidentialMPTConvertBack transaction wrapper from an existing STTx object. + * @throws std::runtime_error if the transaction type doesn't match. + */ + explicit ConfidentialMPTConvertBack(std::shared_ptr tx) + : TransactionBase(std::move(tx)) + { + // Verify transaction type + if (tx_->getTxnType() != txType) + { + throw std::runtime_error("Invalid transaction type for ConfidentialMPTConvertBack"); + } + } + + // 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 sfMPTAmount (soeREQUIRED) + * @return The field value. + */ + [[nodiscard]] + SF_UINT64::type::value_type + getMPTAmount() const + { + return this->tx_->at(sfMPTAmount); + } + + /** + * @brief Get sfHolderEncryptedAmount (soeREQUIRED) + * @return The field value. + */ + [[nodiscard]] + SF_VL::type::value_type + getHolderEncryptedAmount() const + { + return this->tx_->at(sfHolderEncryptedAmount); + } + + /** + * @brief Get sfIssuerEncryptedAmount (soeREQUIRED) + * @return The field value. + */ + [[nodiscard]] + SF_VL::type::value_type + getIssuerEncryptedAmount() const + { + return this->tx_->at(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 sfBlindingFactor (soeREQUIRED) + * @return The field value. + */ + [[nodiscard]] + SF_UINT256::type::value_type + getBlindingFactor() const + { + return this->tx_->at(sfBlindingFactor); + } + + /** + * @brief Get sfZKProof (soeREQUIRED) + * @return The field value. + */ + [[nodiscard]] + SF_VL::type::value_type + getZKProof() const + { + return this->tx_->at(sfZKProof); + } + + /** + * @brief Get sfBalanceCommitment (soeREQUIRED) + * @return The field value. + */ + [[nodiscard]] + SF_VL::type::value_type + getBalanceCommitment() const + { + return this->tx_->at(sfBalanceCommitment); + } +}; + +/** + * @brief Builder for ConfidentialMPTConvertBack transactions. + * + * Provides a fluent interface for constructing transactions with method chaining. + * Uses Json::Value internally for flexible transaction construction. + * Inherits common field setters from TransactionBuilderBase. + */ +class ConfidentialMPTConvertBackBuilder : public TransactionBuilderBase +{ +public: + /** + * @brief Construct a new ConfidentialMPTConvertBackBuilder with required fields. + * @param account The account initiating the transaction. + * @param mPTokenIssuanceID The sfMPTokenIssuanceID field value. + * @param mPTAmount The sfMPTAmount field value. + * @param holderEncryptedAmount The sfHolderEncryptedAmount field value. + * @param issuerEncryptedAmount The sfIssuerEncryptedAmount field value. + * @param blindingFactor The sfBlindingFactor field value. + * @param zKProof The sfZKProof field value. + * @param balanceCommitment The sfBalanceCommitment field value. + * @param sequence Optional sequence number for the transaction. + * @param fee Optional fee for the transaction. + */ + ConfidentialMPTConvertBackBuilder(SF_ACCOUNT::type::value_type account, + std::decay_t const& mPTokenIssuanceID, std::decay_t const& mPTAmount, std::decay_t const& holderEncryptedAmount, std::decay_t const& issuerEncryptedAmount, std::decay_t const& blindingFactor, std::decay_t const& zKProof, std::decay_t const& balanceCommitment, std::optional sequence = std::nullopt, + std::optional fee = std::nullopt +) + : TransactionBuilderBase(ttCONFIDENTIAL_MPT_CONVERT_BACK, account, sequence, fee) + { + setMPTokenIssuanceID(mPTokenIssuanceID); + setMPTAmount(mPTAmount); + setHolderEncryptedAmount(holderEncryptedAmount); + setIssuerEncryptedAmount(issuerEncryptedAmount); + setBlindingFactor(blindingFactor); + setZKProof(zKProof); + setBalanceCommitment(balanceCommitment); + } + + /** + * @brief Construct a ConfidentialMPTConvertBackBuilder from an existing STTx object. + * @param tx The existing transaction to copy from. + * @throws std::runtime_error if the transaction type doesn't match. + */ + ConfidentialMPTConvertBackBuilder(std::shared_ptr tx) + { + if (tx->getTxnType() != ttCONFIDENTIAL_MPT_CONVERT_BACK) + { + throw std::runtime_error("Invalid transaction type for ConfidentialMPTConvertBackBuilder"); + } + object_ = *tx; + } + + /** @brief Transaction-specific field setters */ + + /** + * @brief Set sfMPTokenIssuanceID (soeREQUIRED) + * @return Reference to this builder for method chaining. + */ + ConfidentialMPTConvertBackBuilder& + setMPTokenIssuanceID(std::decay_t const& value) + { + object_[sfMPTokenIssuanceID] = value; + return *this; + } + + /** + * @brief Set sfMPTAmount (soeREQUIRED) + * @return Reference to this builder for method chaining. + */ + ConfidentialMPTConvertBackBuilder& + setMPTAmount(std::decay_t const& value) + { + object_[sfMPTAmount] = value; + return *this; + } + + /** + * @brief Set sfHolderEncryptedAmount (soeREQUIRED) + * @return Reference to this builder for method chaining. + */ + ConfidentialMPTConvertBackBuilder& + setHolderEncryptedAmount(std::decay_t const& value) + { + object_[sfHolderEncryptedAmount] = value; + return *this; + } + + /** + * @brief Set sfIssuerEncryptedAmount (soeREQUIRED) + * @return Reference to this builder for method chaining. + */ + ConfidentialMPTConvertBackBuilder& + setIssuerEncryptedAmount(std::decay_t const& value) + { + object_[sfIssuerEncryptedAmount] = value; + return *this; + } + + /** + * @brief Set sfAuditorEncryptedAmount (soeOPTIONAL) + * @return Reference to this builder for method chaining. + */ + ConfidentialMPTConvertBackBuilder& + setAuditorEncryptedAmount(std::decay_t const& value) + { + object_[sfAuditorEncryptedAmount] = value; + return *this; + } + + /** + * @brief Set sfBlindingFactor (soeREQUIRED) + * @return Reference to this builder for method chaining. + */ + ConfidentialMPTConvertBackBuilder& + setBlindingFactor(std::decay_t const& value) + { + object_[sfBlindingFactor] = value; + return *this; + } + + /** + * @brief Set sfZKProof (soeREQUIRED) + * @return Reference to this builder for method chaining. + */ + ConfidentialMPTConvertBackBuilder& + setZKProof(std::decay_t const& value) + { + object_[sfZKProof] = value; + return *this; + } + + /** + * @brief Set sfBalanceCommitment (soeREQUIRED) + * @return Reference to this builder for method chaining. + */ + ConfidentialMPTConvertBackBuilder& + setBalanceCommitment(std::decay_t const& value) + { + object_[sfBalanceCommitment] = value; + return *this; + } + + /** + * @brief Build and return the ConfidentialMPTConvertBack wrapper. + * @param publicKey The public key for signing. + * @param secretKey The secret key for signing. + * @return The constructed transaction wrapper. + */ + ConfidentialMPTConvertBack + build(PublicKey const& publicKey, SecretKey const& secretKey) + { + sign(publicKey, secretKey); + return ConfidentialMPTConvertBack{std::make_shared(std::move(object_))}; + } +}; + +} // namespace xrpl::transactions diff --git a/include/xrpl/protocol_autogen/transactions/ConfidentialMPTMergeInbox.h b/include/xrpl/protocol_autogen/transactions/ConfidentialMPTMergeInbox.h new file mode 100644 index 0000000000..c7a9e78d7a --- /dev/null +++ b/include/xrpl/protocol_autogen/transactions/ConfidentialMPTMergeInbox.h @@ -0,0 +1,129 @@ +// This file is auto-generated. Do not edit. +#pragma once + +#include +#include +#include +#include +#include +#include + +#include +#include + +namespace xrpl::transactions { + +class ConfidentialMPTMergeInboxBuilder; + +/** + * @brief Transaction: ConfidentialMPTMergeInbox + * + * Type: ttCONFIDENTIAL_MPT_MERGE_INBOX (86) + * Delegable: Delegation::delegable + * Amendment: featureConfidentialTransfer + * Privileges: noPriv + * + * Immutable wrapper around STTx providing type-safe field access. + * Use ConfidentialMPTMergeInboxBuilder to construct new transactions. + */ +class ConfidentialMPTMergeInbox : public TransactionBase +{ +public: + static constexpr xrpl::TxType txType = ttCONFIDENTIAL_MPT_MERGE_INBOX; + + /** + * @brief Construct a ConfidentialMPTMergeInbox transaction wrapper from an existing STTx object. + * @throws std::runtime_error if the transaction type doesn't match. + */ + explicit ConfidentialMPTMergeInbox(std::shared_ptr tx) + : TransactionBase(std::move(tx)) + { + // Verify transaction type + if (tx_->getTxnType() != txType) + { + throw std::runtime_error("Invalid transaction type for ConfidentialMPTMergeInbox"); + } + } + + // 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 Builder for ConfidentialMPTMergeInbox transactions. + * + * Provides a fluent interface for constructing transactions with method chaining. + * Uses Json::Value internally for flexible transaction construction. + * Inherits common field setters from TransactionBuilderBase. + */ +class ConfidentialMPTMergeInboxBuilder : public TransactionBuilderBase +{ +public: + /** + * @brief Construct a new ConfidentialMPTMergeInboxBuilder with required fields. + * @param account The account initiating the transaction. + * @param mPTokenIssuanceID The sfMPTokenIssuanceID field value. + * @param sequence Optional sequence number for the transaction. + * @param fee Optional fee for the transaction. + */ + ConfidentialMPTMergeInboxBuilder(SF_ACCOUNT::type::value_type account, + std::decay_t const& mPTokenIssuanceID, std::optional sequence = std::nullopt, + std::optional fee = std::nullopt +) + : TransactionBuilderBase(ttCONFIDENTIAL_MPT_MERGE_INBOX, account, sequence, fee) + { + setMPTokenIssuanceID(mPTokenIssuanceID); + } + + /** + * @brief Construct a ConfidentialMPTMergeInboxBuilder from an existing STTx object. + * @param tx The existing transaction to copy from. + * @throws std::runtime_error if the transaction type doesn't match. + */ + ConfidentialMPTMergeInboxBuilder(std::shared_ptr tx) + { + if (tx->getTxnType() != ttCONFIDENTIAL_MPT_MERGE_INBOX) + { + throw std::runtime_error("Invalid transaction type for ConfidentialMPTMergeInboxBuilder"); + } + object_ = *tx; + } + + /** @brief Transaction-specific field setters */ + + /** + * @brief Set sfMPTokenIssuanceID (soeREQUIRED) + * @return Reference to this builder for method chaining. + */ + ConfidentialMPTMergeInboxBuilder& + setMPTokenIssuanceID(std::decay_t const& value) + { + object_[sfMPTokenIssuanceID] = value; + return *this; + } + + /** + * @brief Build and return the ConfidentialMPTMergeInbox wrapper. + * @param publicKey The public key for signing. + * @param secretKey The secret key for signing. + * @return The constructed transaction wrapper. + */ + ConfidentialMPTMergeInbox + build(PublicKey const& publicKey, SecretKey const& secretKey) + { + sign(publicKey, secretKey); + return ConfidentialMPTMergeInbox{std::make_shared(std::move(object_))}; + } +}; + +} // namespace xrpl::transactions diff --git a/include/xrpl/protocol_autogen/transactions/ConfidentialMPTSend.h b/include/xrpl/protocol_autogen/transactions/ConfidentialMPTSend.h new file mode 100644 index 0000000000..03a848cc73 --- /dev/null +++ b/include/xrpl/protocol_autogen/transactions/ConfidentialMPTSend.h @@ -0,0 +1,371 @@ +// This file is auto-generated. Do not edit. +#pragma once + +#include +#include +#include +#include +#include +#include + +#include +#include + +namespace xrpl::transactions { + +class ConfidentialMPTSendBuilder; + +/** + * @brief Transaction: ConfidentialMPTSend + * + * Type: ttCONFIDENTIAL_MPT_SEND (88) + * Delegable: Delegation::delegable + * Amendment: featureConfidentialTransfer + * Privileges: noPriv + * + * Immutable wrapper around STTx providing type-safe field access. + * Use ConfidentialMPTSendBuilder to construct new transactions. + */ +class ConfidentialMPTSend : public TransactionBase +{ +public: + static constexpr xrpl::TxType txType = ttCONFIDENTIAL_MPT_SEND; + + /** + * @brief Construct a ConfidentialMPTSend transaction wrapper from an existing STTx object. + * @throws std::runtime_error if the transaction type doesn't match. + */ + explicit ConfidentialMPTSend(std::shared_ptr tx) + : TransactionBase(std::move(tx)) + { + // Verify transaction type + if (tx_->getTxnType() != txType) + { + throw std::runtime_error("Invalid transaction type for ConfidentialMPTSend"); + } + } + + // 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 sfDestination (soeREQUIRED) + * @return The field value. + */ + [[nodiscard]] + SF_ACCOUNT::type::value_type + getDestination() const + { + return this->tx_->at(sfDestination); + } + + /** + * @brief Get sfSenderEncryptedAmount (soeREQUIRED) + * @return The field value. + */ + [[nodiscard]] + SF_VL::type::value_type + getSenderEncryptedAmount() const + { + return this->tx_->at(sfSenderEncryptedAmount); + } + + /** + * @brief Get sfDestinationEncryptedAmount (soeREQUIRED) + * @return The field value. + */ + [[nodiscard]] + SF_VL::type::value_type + getDestinationEncryptedAmount() const + { + return this->tx_->at(sfDestinationEncryptedAmount); + } + + /** + * @brief Get sfIssuerEncryptedAmount (soeREQUIRED) + * @return The field value. + */ + [[nodiscard]] + SF_VL::type::value_type + getIssuerEncryptedAmount() const + { + return this->tx_->at(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 Get sfAmountCommitment (soeREQUIRED) + * @return The field value. + */ + [[nodiscard]] + SF_VL::type::value_type + getAmountCommitment() const + { + return this->tx_->at(sfAmountCommitment); + } + + /** + * @brief Get sfBalanceCommitment (soeREQUIRED) + * @return The field value. + */ + [[nodiscard]] + SF_VL::type::value_type + getBalanceCommitment() const + { + return this->tx_->at(sfBalanceCommitment); + } + + /** + * @brief Get sfCredentialIDs (soeOPTIONAL) + * @return The field value, or std::nullopt if not present. + */ + [[nodiscard]] + protocol_autogen::Optional + getCredentialIDs() const + { + if (hasCredentialIDs()) + { + return this->tx_->at(sfCredentialIDs); + } + return std::nullopt; + } + + /** + * @brief Check if sfCredentialIDs is present. + * @return True if the field is present, false otherwise. + */ + [[nodiscard]] + bool + hasCredentialIDs() const + { + return this->tx_->isFieldPresent(sfCredentialIDs); + } +}; + +/** + * @brief Builder for ConfidentialMPTSend transactions. + * + * Provides a fluent interface for constructing transactions with method chaining. + * Uses Json::Value internally for flexible transaction construction. + * Inherits common field setters from TransactionBuilderBase. + */ +class ConfidentialMPTSendBuilder : public TransactionBuilderBase +{ +public: + /** + * @brief Construct a new ConfidentialMPTSendBuilder with required fields. + * @param account The account initiating the transaction. + * @param mPTokenIssuanceID The sfMPTokenIssuanceID field value. + * @param destination The sfDestination field value. + * @param senderEncryptedAmount The sfSenderEncryptedAmount field value. + * @param destinationEncryptedAmount The sfDestinationEncryptedAmount field value. + * @param issuerEncryptedAmount The sfIssuerEncryptedAmount field value. + * @param zKProof The sfZKProof field value. + * @param amountCommitment The sfAmountCommitment field value. + * @param balanceCommitment The sfBalanceCommitment field value. + * @param sequence Optional sequence number for the transaction. + * @param fee Optional fee for the transaction. + */ + ConfidentialMPTSendBuilder(SF_ACCOUNT::type::value_type account, + std::decay_t const& mPTokenIssuanceID, std::decay_t const& destination, std::decay_t const& senderEncryptedAmount, std::decay_t const& destinationEncryptedAmount, std::decay_t const& issuerEncryptedAmount, std::decay_t const& zKProof, std::decay_t const& amountCommitment, std::decay_t const& balanceCommitment, std::optional sequence = std::nullopt, + std::optional fee = std::nullopt +) + : TransactionBuilderBase(ttCONFIDENTIAL_MPT_SEND, account, sequence, fee) + { + setMPTokenIssuanceID(mPTokenIssuanceID); + setDestination(destination); + setSenderEncryptedAmount(senderEncryptedAmount); + setDestinationEncryptedAmount(destinationEncryptedAmount); + setIssuerEncryptedAmount(issuerEncryptedAmount); + setZKProof(zKProof); + setAmountCommitment(amountCommitment); + setBalanceCommitment(balanceCommitment); + } + + /** + * @brief Construct a ConfidentialMPTSendBuilder from an existing STTx object. + * @param tx The existing transaction to copy from. + * @throws std::runtime_error if the transaction type doesn't match. + */ + ConfidentialMPTSendBuilder(std::shared_ptr tx) + { + if (tx->getTxnType() != ttCONFIDENTIAL_MPT_SEND) + { + throw std::runtime_error("Invalid transaction type for ConfidentialMPTSendBuilder"); + } + object_ = *tx; + } + + /** @brief Transaction-specific field setters */ + + /** + * @brief Set sfMPTokenIssuanceID (soeREQUIRED) + * @return Reference to this builder for method chaining. + */ + ConfidentialMPTSendBuilder& + setMPTokenIssuanceID(std::decay_t const& value) + { + object_[sfMPTokenIssuanceID] = value; + return *this; + } + + /** + * @brief Set sfDestination (soeREQUIRED) + * @return Reference to this builder for method chaining. + */ + ConfidentialMPTSendBuilder& + setDestination(std::decay_t const& value) + { + object_[sfDestination] = value; + return *this; + } + + /** + * @brief Set sfSenderEncryptedAmount (soeREQUIRED) + * @return Reference to this builder for method chaining. + */ + ConfidentialMPTSendBuilder& + setSenderEncryptedAmount(std::decay_t const& value) + { + object_[sfSenderEncryptedAmount] = value; + return *this; + } + + /** + * @brief Set sfDestinationEncryptedAmount (soeREQUIRED) + * @return Reference to this builder for method chaining. + */ + ConfidentialMPTSendBuilder& + setDestinationEncryptedAmount(std::decay_t const& value) + { + object_[sfDestinationEncryptedAmount] = value; + return *this; + } + + /** + * @brief Set sfIssuerEncryptedAmount (soeREQUIRED) + * @return Reference to this builder for method chaining. + */ + ConfidentialMPTSendBuilder& + setIssuerEncryptedAmount(std::decay_t const& value) + { + object_[sfIssuerEncryptedAmount] = value; + return *this; + } + + /** + * @brief Set sfAuditorEncryptedAmount (soeOPTIONAL) + * @return Reference to this builder for method chaining. + */ + ConfidentialMPTSendBuilder& + setAuditorEncryptedAmount(std::decay_t const& value) + { + object_[sfAuditorEncryptedAmount] = value; + return *this; + } + + /** + * @brief Set sfZKProof (soeREQUIRED) + * @return Reference to this builder for method chaining. + */ + ConfidentialMPTSendBuilder& + setZKProof(std::decay_t const& value) + { + object_[sfZKProof] = value; + return *this; + } + + /** + * @brief Set sfAmountCommitment (soeREQUIRED) + * @return Reference to this builder for method chaining. + */ + ConfidentialMPTSendBuilder& + setAmountCommitment(std::decay_t const& value) + { + object_[sfAmountCommitment] = value; + return *this; + } + + /** + * @brief Set sfBalanceCommitment (soeREQUIRED) + * @return Reference to this builder for method chaining. + */ + ConfidentialMPTSendBuilder& + setBalanceCommitment(std::decay_t const& value) + { + object_[sfBalanceCommitment] = value; + return *this; + } + + /** + * @brief Set sfCredentialIDs (soeOPTIONAL) + * @return Reference to this builder for method chaining. + */ + ConfidentialMPTSendBuilder& + setCredentialIDs(std::decay_t const& value) + { + object_[sfCredentialIDs] = value; + return *this; + } + + /** + * @brief Build and return the ConfidentialMPTSend wrapper. + * @param publicKey The public key for signing. + * @param secretKey The secret key for signing. + * @return The constructed transaction wrapper. + */ + ConfidentialMPTSend + build(PublicKey const& publicKey, SecretKey const& secretKey) + { + sign(publicKey, secretKey); + return ConfidentialMPTSend{std::make_shared(std::move(object_))}; + } +}; + +} // namespace xrpl::transactions diff --git a/include/xrpl/protocol_autogen/transactions/MPTokenIssuanceSet.h b/include/xrpl/protocol_autogen/transactions/MPTokenIssuanceSet.h index 429b252e95..3de3fcfbfb 100644 --- a/include/xrpl/protocol_autogen/transactions/MPTokenIssuanceSet.h +++ b/include/xrpl/protocol_autogen/transactions/MPTokenIssuanceSet.h @@ -187,6 +187,58 @@ public: { return this->tx_->isFieldPresent(sfMutableFlags); } + + /** + * @brief Get sfIssuerEncryptionKey (soeOPTIONAL) + * @return The field value, or std::nullopt if not present. + */ + [[nodiscard]] + protocol_autogen::Optional + getIssuerEncryptionKey() const + { + if (hasIssuerEncryptionKey()) + { + return this->tx_->at(sfIssuerEncryptionKey); + } + return std::nullopt; + } + + /** + * @brief Check if sfIssuerEncryptionKey is present. + * @return True if the field is present, false otherwise. + */ + [[nodiscard]] + bool + hasIssuerEncryptionKey() const + { + return this->tx_->isFieldPresent(sfIssuerEncryptionKey); + } + + /** + * @brief Get sfAuditorEncryptionKey (soeOPTIONAL) + * @return The field value, or std::nullopt if not present. + */ + [[nodiscard]] + protocol_autogen::Optional + getAuditorEncryptionKey() const + { + if (hasAuditorEncryptionKey()) + { + return this->tx_->at(sfAuditorEncryptionKey); + } + return std::nullopt; + } + + /** + * @brief Check if sfAuditorEncryptionKey is present. + * @return True if the field is present, false otherwise. + */ + [[nodiscard]] + bool + hasAuditorEncryptionKey() const + { + return this->tx_->isFieldPresent(sfAuditorEncryptionKey); + } }; /** @@ -297,6 +349,28 @@ public: return *this; } + /** + * @brief Set sfIssuerEncryptionKey (soeOPTIONAL) + * @return Reference to this builder for method chaining. + */ + MPTokenIssuanceSetBuilder& + setIssuerEncryptionKey(std::decay_t const& value) + { + object_[sfIssuerEncryptionKey] = value; + return *this; + } + + /** + * @brief Set sfAuditorEncryptionKey (soeOPTIONAL) + * @return Reference to this builder for method chaining. + */ + MPTokenIssuanceSetBuilder& + setAuditorEncryptionKey(std::decay_t const& value) + { + object_[sfAuditorEncryptionKey] = value; + return *this; + } + /** * @brief Build and return the MPTokenIssuanceSet wrapper. * @param publicKey The public key for signing. diff --git a/include/xrpl/tx/invariants/InvariantCheck.h b/include/xrpl/tx/invariants/InvariantCheck.h index ad4c5e16c4..24ce288b1a 100644 --- a/include/xrpl/tx/invariants/InvariantCheck.h +++ b/include/xrpl/tx/invariants/InvariantCheck.h @@ -399,6 +399,7 @@ using InvariantChecks = std::tuple< ValidLoanBroker, ValidLoan, ValidVault, + ValidConfidentialMPToken, ValidMPTPayment>; /** diff --git a/include/xrpl/tx/invariants/MPTInvariant.h b/include/xrpl/tx/invariants/MPTInvariant.h index dd064af396..7f7eb27007 100644 --- a/include/xrpl/tx/invariants/MPTInvariant.h +++ b/include/xrpl/tx/invariants/MPTInvariant.h @@ -56,4 +56,47 @@ public: finalize(STTx const&, TER const, XRPAmount const, ReadView const&, beast::Journal const&); }; +/** + * @brief Invariants: Confidential MPToken consistency + * + * - Convert/ConvertBack symmetry: + * Regular MPToken balance change (±X) == COA (Confidential Outstanding Amount) change (∓X) + * - Cannot delete MPToken with non-zero confidential state: + * Cannot delete if sfIssuerEncryptedBalance exists + * Cannot delete if sfConfidentialBalanceInbox and sfConfidentialBalanceSpending exist + * - Privacy flag consistency: + * MPToken can only have encrypted fields if lsfMPTCanConfidentialAmount is set on + * issuance. + * - Encrypted field existence consistency: + * If sfConfidentialBalanceSpending/sfConfidentialBalanceInbox exists, then + * sfIssuerEncryptedBalance must also exist (and vice versa). + * - COA <= OutstandingAmount: + * Confidential outstanding balance cannot exceed total outstanding. + * - Verifies sfConfidentialBalanceVersion is changed whenever sfConfidentialBalanceSpending is + * modified on an MPToken. + */ +class ValidConfidentialMPToken +{ + struct Changes + { + std::int64_t mptAmountDelta = 0; + std::int64_t coaDelta = 0; + std::int64_t outstandingDelta = 0; + SLE::const_pointer issuance; + bool deletedWithEncrypted = false; + bool badConsistency = false; + bool badCOA = false; + bool requiresPrivacyFlag = false; + bool badVersion = false; + }; + std::map changes_; + +public: + void + visitEntry(bool, std::shared_ptr const&, std::shared_ptr const&); + + bool + finalize(STTx const&, TER const, XRPAmount const, ReadView const&, beast::Journal const&); +}; + } // namespace xrpl diff --git a/include/xrpl/tx/transactors/system/Batch.h b/include/xrpl/tx/transactors/system/Batch.h index 0861deb094..5f7a6d04e2 100644 --- a/include/xrpl/tx/transactors/system/Batch.h +++ b/include/xrpl/tx/transactors/system/Batch.h @@ -33,23 +33,22 @@ public: TER doApply() override; - static constexpr auto disabledTxTypes = std::to_array({ - ttVAULT_CREATE, - ttVAULT_SET, - ttVAULT_DELETE, - ttVAULT_DEPOSIT, - ttVAULT_WITHDRAW, - ttVAULT_CLAWBACK, - ttLOAN_BROKER_SET, - ttLOAN_BROKER_DELETE, - ttLOAN_BROKER_COVER_DEPOSIT, - ttLOAN_BROKER_COVER_WITHDRAW, - ttLOAN_BROKER_COVER_CLAWBACK, - ttLOAN_SET, - ttLOAN_DELETE, - ttLOAN_MANAGE, - ttLOAN_PAY, - }); + static constexpr auto disabledTxTypes = std::to_array( + {ttVAULT_CREATE, + ttVAULT_SET, + ttVAULT_DELETE, + ttVAULT_DEPOSIT, + ttVAULT_WITHDRAW, + ttVAULT_CLAWBACK, + ttLOAN_BROKER_SET, + ttLOAN_BROKER_DELETE, + ttLOAN_BROKER_COVER_DEPOSIT, + ttLOAN_BROKER_COVER_WITHDRAW, + ttLOAN_BROKER_COVER_CLAWBACK, + ttLOAN_SET, + ttLOAN_DELETE, + ttLOAN_MANAGE, + ttLOAN_PAY}); }; } // namespace xrpl diff --git a/include/xrpl/tx/transactors/token/ConfidentialMPTClawback.h b/include/xrpl/tx/transactors/token/ConfidentialMPTClawback.h new file mode 100644 index 0000000000..779d372a48 --- /dev/null +++ b/include/xrpl/tx/transactors/token/ConfidentialMPTClawback.h @@ -0,0 +1,42 @@ +#pragma once + +#include + +namespace xrpl { + +/** + * @brief Allows an MPT issuer to clawback confidential balances from a holder. + * + * This transaction enables the issuer of an MPToken Issuance (with clawback + * enabled) to reclaim confidential tokens from a holder's account. Unlike + * regular clawback, the issuer cannot see the holder's balance directly. + * Instead, the issuer must provide a zero-knowledge proof that demonstrates + * they know the exact encrypted balance amount. + * + * @par Cryptographic Operations: + * - **Equality Proof Verification**: Verifies that the issuer's revealed + * amount matches the holder's encrypted balance using the issuer's + * ElGamal private key. + * + * @see ConfidentialMPTSend, ConfidentialMPTConvert + */ +class ConfidentialMPTClawback : public Transactor +{ +public: + static constexpr ConsequencesFactoryType ConsequencesFactory{Normal}; + + explicit ConfidentialMPTClawback(ApplyContext& ctx) : Transactor(ctx) + { + } + + static NotTEC + preflight(PreflightContext const& ctx); + + static TER + preclaim(PreclaimContext const& ctx); + + TER + doApply() override; +}; + +} // namespace xrpl diff --git a/include/xrpl/tx/transactors/token/ConfidentialMPTConvert.h b/include/xrpl/tx/transactors/token/ConfidentialMPTConvert.h new file mode 100644 index 0000000000..1f939b9037 --- /dev/null +++ b/include/xrpl/tx/transactors/token/ConfidentialMPTConvert.h @@ -0,0 +1,44 @@ +#pragma once + +#include + +namespace xrpl { + +/** + * @brief Converts public (plaintext) MPT balance to confidential (encrypted) + * balance. + * + * This transaction allows a token holder to convert their publicly visible + * MPToken balance into an encrypted confidential balance. Once converted, + * the balance can only be spent using ConfidentialMPTSend transactions and + * remains hidden from public view on the ledger. + * + * @par Cryptographic Operations: + * - **Schnorr Proof Verification**: When registering a new ElGamal public key, + * verifies proof of knowledge of the corresponding private key. + * - **Revealed Amount Verification**: Verifies that the provided encrypted + * amounts (for holder, issuer, and optionally auditor) all encrypt the + * same plaintext amount using the provided blinding factor. + * + * @see ConfidentialMPTConvertBack, ConfidentialMPTSend + */ +class ConfidentialMPTConvert : public Transactor +{ +public: + static constexpr ConsequencesFactoryType ConsequencesFactory{Normal}; + + explicit ConfidentialMPTConvert(ApplyContext& ctx) : Transactor(ctx) + { + } + + static NotTEC + preflight(PreflightContext const& ctx); + + static TER + preclaim(PreclaimContext const& ctx); + + TER + doApply() override; +}; + +} // namespace xrpl diff --git a/include/xrpl/tx/transactors/token/ConfidentialMPTConvertBack.h b/include/xrpl/tx/transactors/token/ConfidentialMPTConvertBack.h new file mode 100644 index 0000000000..e0a59f78e5 --- /dev/null +++ b/include/xrpl/tx/transactors/token/ConfidentialMPTConvertBack.h @@ -0,0 +1,45 @@ +#pragma once + +#include + +namespace xrpl { + +/** + * @brief Converts confidential (encrypted) MPT balance back to public + * (plaintext) balance. + * + * This transaction allows a token holder to convert their encrypted + * confidential balance back into a publicly visible MPToken balance. The + * holder must prove they have sufficient confidential balance without + * revealing the actual balance amount. + * + * @par Cryptographic Operations: + * - **Revealed Amount Verification**: Verifies that the provided encrypted + * amounts correctly encrypt the conversion amount. + * - **Pedersen Linkage Proof**: Verifies that the provided balance commitment + * correctly links to the holder's encrypted spending balance. + * - **Bulletproof Range Proof**: Verifies that the remaining balance (after + * conversion) is non-negative, ensuring the holder has sufficient funds. + * + * @see ConfidentialMPTConvert, ConfidentialMPTSend + */ +class ConfidentialMPTConvertBack : public Transactor +{ +public: + static constexpr ConsequencesFactoryType ConsequencesFactory{Normal}; + + explicit ConfidentialMPTConvertBack(ApplyContext& ctx) : Transactor(ctx) + { + } + + static NotTEC + preflight(PreflightContext const& ctx); + + static TER + preclaim(PreclaimContext const& ctx); + + TER + doApply() override; +}; + +} // namespace xrpl diff --git a/include/xrpl/tx/transactors/token/ConfidentialMPTMergeInbox.h b/include/xrpl/tx/transactors/token/ConfidentialMPTMergeInbox.h new file mode 100644 index 0000000000..58ff9489cc --- /dev/null +++ b/include/xrpl/tx/transactors/token/ConfidentialMPTMergeInbox.h @@ -0,0 +1,46 @@ +#pragma once + +#include + +namespace xrpl { + +/** + * @brief Merges the confidential inbox balance into the spending balance. + * + * In the confidential transfer system, incoming funds are deposited into an + * "inbox" balance that the recipient cannot immediately spend. This prevents + * front-running attacks where an attacker could invalidate a pending + * transaction by sending funds to the sender. This transaction merges the + * inbox into the spending balance, making those funds available for spending. + * + * @par Cryptographic Operations: + * - **Homomorphic Addition**: Adds the encrypted inbox balance to the + * encrypted spending balance using ElGamal homomorphic properties. + * - **Zero Encryption**: Resets the inbox to an encryption of zero. + * + * @note This transaction requires no zero-knowledge proofs because it only + * combines encrypted values that the holder already owns. The + * homomorphic properties of ElGamal encryption ensure correctness. + * + * @see ConfidentialMPTSend, ConfidentialMPTConvert + */ +class ConfidentialMPTMergeInbox : public Transactor +{ +public: + static constexpr ConsequencesFactoryType ConsequencesFactory{Normal}; + + explicit ConfidentialMPTMergeInbox(ApplyContext& ctx) : Transactor(ctx) + { + } + + static NotTEC + preflight(PreflightContext const& ctx); + + static TER + preclaim(PreclaimContext const& ctx); + + TER + doApply() override; +}; + +} // namespace xrpl diff --git a/include/xrpl/tx/transactors/token/ConfidentialMPTSend.h b/include/xrpl/tx/transactors/token/ConfidentialMPTSend.h new file mode 100644 index 0000000000..0d844a3ea5 --- /dev/null +++ b/include/xrpl/tx/transactors/token/ConfidentialMPTSend.h @@ -0,0 +1,55 @@ +#pragma once + +#include + +namespace xrpl { + +/** + * @brief Transfers confidential MPT tokens between holders privately. + * + * This transaction enables private token transfers where the transfer amount + * is hidden from public view. Both sender and recipient must have initialized + * confidential balances. The transaction provides encrypted amounts for all + * parties (sender, destination, issuer, and optionally auditor) along with + * zero-knowledge proofs that verify correctness without revealing the amount. + * + * @par Cryptographic Operations: + * - **Multi-Ciphertext Equality Proof**: Verifies that all encrypted amounts + * (sender, destination, issuer, auditor) encrypt the same plaintext value. + * - **Amount Pedersen Linkage Proof**: Verifies that the amount commitment + * correctly links to the sender's encrypted amount. + * - **Balance Pedersen Linkage Proof**: Verifies that the balance commitment + * correctly links to the sender's encrypted spending balance. + * - **Bulletproof Range Proof**: Verifies remaining balance and + * transfer amount are non-negative. + * + * @note Funds are deposited into the destination's inbox, not spending + * balance. The recipient must call ConfidentialMPTMergeInbox to make + * received funds spendable. + * + * @see ConfidentialMPTMergeInbox, ConfidentialMPTConvert, + * ConfidentialMPTConvertBack + */ +class ConfidentialMPTSend : public Transactor +{ + /// Size of two Pedersen linkage proofs (amount + balance) + static constexpr std::size_t doublePedersenProofLength = 2 * ecPedersenProofLength; + +public: + static constexpr ConsequencesFactoryType ConsequencesFactory{Normal}; + + explicit ConfidentialMPTSend(ApplyContext& ctx) : Transactor(ctx) + { + } + + static NotTEC + preflight(PreflightContext const& ctx); + + static TER + preclaim(PreclaimContext const& ctx); + + TER + doApply() override; +}; + +} // namespace xrpl diff --git a/src/libxrpl/basics/Log.cpp b/src/libxrpl/basics/Log.cpp index f0a546ee75..0278aa6aed 100644 --- a/src/libxrpl/basics/Log.cpp +++ b/src/libxrpl/basics/Log.cpp @@ -59,7 +59,8 @@ Logs::File::open(boost::filesystem::path const& path) bool wasOpened = false; // VFALCO TODO Make this work with Unicode file paths - std::unique_ptr stream(new std::ofstream(path.c_str(), std::fstream::app)); + std::unique_ptr stream = + std::make_unique(path.c_str(), std::fstream::app); if (stream->good()) { diff --git a/src/libxrpl/ledger/helpers/MPTokenHelpers.cpp b/src/libxrpl/ledger/helpers/MPTokenHelpers.cpp index 3cc359408a..53f5fabafb 100644 --- a/src/libxrpl/ledger/helpers/MPTokenHelpers.cpp +++ b/src/libxrpl/ledger/helpers/MPTokenHelpers.cpp @@ -258,6 +258,13 @@ removeEmptyHolding( (view.rules().enabled(fixSecurity3_1_3) && (*mptoken)[~sfLockedAmount].value_or(0) != 0)) return tecHAS_OBLIGATIONS; + // Don't delete if the token still has confidential balances + if (mptoken->isFieldPresent(sfConfidentialBalanceInbox) || + mptoken->isFieldPresent(sfConfidentialBalanceSpending)) + { + return tecHAS_OBLIGATIONS; + } + return authorizeMPToken( view, {}, // priorBalance diff --git a/src/libxrpl/ledger/helpers/TokenHelpers.cpp b/src/libxrpl/ledger/helpers/TokenHelpers.cpp index ec9ccaa7ae..962ac10e56 100644 --- a/src/libxrpl/ledger/helpers/TokenHelpers.cpp +++ b/src/libxrpl/ledger/helpers/TokenHelpers.cpp @@ -313,7 +313,8 @@ accountHolds( // Only if auth check is needed, as it needs to do an additional read // operation. Note featureSingleAssetVault will affect error codes. if (zeroIfUnauthorized == ahZERO_IF_UNAUTHORIZED && - view.rules().enabled(featureSingleAssetVault)) + (view.rules().enabled(featureSingleAssetVault) || + view.rules().enabled(featureConfidentialTransfer))) { if (auto const err = requireAuth(view, mptIssue, account, AuthType::StrongAuth); !isTesSuccess(err)) diff --git a/src/libxrpl/protocol/ConfidentialTransfer.cpp b/src/libxrpl/protocol/ConfidentialTransfer.cpp new file mode 100644 index 0000000000..473af293ea --- /dev/null +++ b/src/libxrpl/protocol/ConfidentialTransfer.cpp @@ -0,0 +1,445 @@ +#include +#include + +#include +#include + +namespace xrpl { + +/** + * @brief Converts an XRPL AccountID to mpt-crypto lib C struct. + * + * @param account The AccountID. + * @return The equivalent mpt-crypto lib account_id struct. + */ +account_id +toAccountId(AccountID const& account) +{ + account_id res; + std::memcpy(res.bytes, account.data(), kMPT_ACCOUNT_ID_SIZE); + return res; +} + +/** + * @brief Converts an XRPL uint192 to mpt-crypto lib C struct. + * + * @param i The XRPL MPTokenIssuance ID. + * @return The equivalent mpt-crypto lib mpt_issuance_id struct. + */ +mpt_issuance_id +toIssuanceId(uint192 const& issuance) +{ + mpt_issuance_id res; + std::memcpy(res.bytes, issuance.data(), kMPT_ISSUANCE_ID_SIZE); + return res; +} + +uint256 +getSendContextHash( + AccountID const& account, + uint192 const& issuanceID, + std::uint32_t sequence, + AccountID const& destination, + std::uint32_t version) +{ + uint256 result; + mpt_get_send_context_hash( + toAccountId(account), + toIssuanceId(issuanceID), + sequence, + toAccountId(destination), + version, + result.data()); + return result; +} + +uint256 +getClawbackContextHash( + AccountID const& account, + uint192 const& issuanceID, + std::uint32_t sequence, + AccountID const& holder) +{ + uint256 result; + mpt_get_clawback_context_hash( + toAccountId(account), + toIssuanceId(issuanceID), + sequence, + toAccountId(holder), + result.data()); + return result; +} + +uint256 +getConvertContextHash(AccountID const& account, uint192 const& issuanceID, std::uint32_t sequence) +{ + uint256 result; + mpt_get_convert_context_hash( + toAccountId(account), toIssuanceId(issuanceID), sequence, result.data()); + return result; +} + +uint256 +getConvertBackContextHash( + AccountID const& account, + uint192 const& issuanceID, + std::uint32_t sequence, + std::uint32_t version) +{ + uint256 result; + mpt_get_convert_back_context_hash( + toAccountId(account), toIssuanceId(issuanceID), sequence, version, result.data()); + return result; +} + +std::optional +makeEcPair(Slice const& buffer) +{ + if (buffer.length() != 2 * ecGamalEncryptedLength) + return std::nullopt; // LCOV_EXCL_LINE + + auto parsePubKey = [](Slice const& slice, secp256k1_pubkey& out) { + return secp256k1_ec_pubkey_parse( + secp256k1Context(), + &out, + reinterpret_cast(slice.data()), + slice.length()); + }; + + Slice const s1{buffer.data(), ecGamalEncryptedLength}; + Slice const s2{buffer.data() + ecGamalEncryptedLength, ecGamalEncryptedLength}; + + EcPair pair{}; + if (parsePubKey(s1, pair.c1) != 1 || parsePubKey(s2, pair.c2) != 1) + return std::nullopt; + + return pair; +} + +std::optional +serializeEcPair(EcPair const& pair) +{ + auto serializePubKey = [](secp256k1_pubkey const& pub, unsigned char* out) { + size_t outLen = ecGamalEncryptedLength; // 33 bytes + int const ret = secp256k1_ec_pubkey_serialize( + secp256k1Context(), out, &outLen, &pub, SECP256K1_EC_COMPRESSED); + return ret == 1 && outLen == ecGamalEncryptedLength; + }; + + Buffer buffer(ecGamalEncryptedTotalLength); + unsigned char* ptr = buffer.data(); + bool const res1 = serializePubKey(pair.c1, ptr); + bool const res2 = serializePubKey(pair.c2, ptr + ecGamalEncryptedLength); + + if (!res1 || !res2) + return std::nullopt; + + return buffer; +} + +bool +isValidCiphertext(Slice const& buffer) +{ + return makeEcPair(buffer).has_value(); +} + +bool +isValidCompressedECPoint(Slice const& buffer) +{ + if (buffer.size() != compressedECPointLength) + return false; + + // Compressed EC points must start with 0x02 or 0x03 + if (buffer[0] != ecCompressedPrefixEvenY && buffer[0] != ecCompressedPrefixOddY) + return false; + + secp256k1_pubkey point; + return secp256k1_ec_pubkey_parse(secp256k1Context(), &point, buffer.data(), buffer.size()) == 1; +} + +std::optional +homomorphicAdd(Slice const& a, Slice const& b) +{ + if (a.length() != ecGamalEncryptedTotalLength || b.length() != ecGamalEncryptedTotalLength) + return std::nullopt; + + auto const pairA = makeEcPair(a); + auto const pairB = makeEcPair(b); + + if (!pairA || !pairB) + return std::nullopt; + + EcPair sum{}; + if (auto res = secp256k1_elgamal_add( + secp256k1Context(), &sum.c1, &sum.c2, &pairA->c1, &pairA->c2, &pairB->c1, &pairB->c2); + res != 1) + { + return std::nullopt; + } + + return serializeEcPair(sum); +} + +std::optional +homomorphicSubtract(Slice const& a, Slice const& b) +{ + if (a.length() != ecGamalEncryptedTotalLength || b.length() != ecGamalEncryptedTotalLength) + return std::nullopt; + + auto const pairA = makeEcPair(a); + auto const pairB = makeEcPair(b); + + if (!pairA || !pairB) + return std::nullopt; + + EcPair diff{}; + if (auto res = secp256k1_elgamal_subtract( + secp256k1Context(), &diff.c1, &diff.c2, &pairA->c1, &pairA->c2, &pairB->c1, &pairB->c2); + res != 1) + { + return std::nullopt; + } + + return serializeEcPair(diff); +} + +Buffer +generateBlindingFactor() +{ + unsigned char blindingFactor[ecBlindingFactorLength]; + + // todo: might need to be updated using another RNG + if (RAND_bytes(blindingFactor, ecBlindingFactorLength) != 1) + Throw("Failed to generate random number"); + + return Buffer(blindingFactor, ecBlindingFactorLength); +} + +std::optional +encryptAmount(uint64_t const amt, Slice const& pubKeySlice, Slice const& blindingFactor) +{ + if (blindingFactor.size() != ecBlindingFactorLength || pubKeySlice.size() != ecPubKeyLength) + return std::nullopt; + + Buffer out(ecGamalEncryptedTotalLength); + if (mpt_encrypt_amount(amt, pubKeySlice.data(), blindingFactor.data(), out.data()) != 0) + return std::nullopt; + + return out; +} + +std::optional +encryptCanonicalZeroAmount(Slice const& pubKeySlice, AccountID const& account, MPTID const& mptId) +{ + if (pubKeySlice.size() != ecPubKeyLength) + return std::nullopt; // LCOV_EXCL_LINE + + EcPair pair{}; + secp256k1_pubkey pubKey; + if (auto res = secp256k1_ec_pubkey_parse( + secp256k1Context(), &pubKey, pubKeySlice.data(), ecPubKeyLength); + res != 1) + { + return std::nullopt; // LCOV_EXCL_LINE + } + + if (auto res = generate_canonical_encrypted_zero( + secp256k1Context(), &pair.c1, &pair.c2, &pubKey, account.data(), mptId.data()); + res != 1) + { + return std::nullopt; // LCOV_EXCL_LINE + } + + return serializeEcPair(pair); +} + +TER +verifyRevealedAmount( + uint64_t const amount, + Slice const& blindingFactor, + ConfidentialRecipient const& holder, + ConfidentialRecipient const& issuer, + std::optional const& auditor) +{ + if (blindingFactor.size() != ecBlindingFactorLength || + holder.publicKey.size() != ecPubKeyLength || + holder.encryptedAmount.size() != ecGamalEncryptedTotalLength || + issuer.publicKey.size() != ecPubKeyLength || + issuer.encryptedAmount.size() != ecGamalEncryptedTotalLength) + return tecINTERNAL; // LCOV_EXCL_LINE + + auto toParticipant = [](ConfidentialRecipient const& r) { + mpt_confidential_participant p; + std::memcpy(p.pubkey, r.publicKey.data(), kMPT_PUBKEY_SIZE); + std::memcpy(p.ciphertext, r.encryptedAmount.data(), kMPT_ELGAMAL_TOTAL_SIZE); + return p; + }; + + auto const holderP = toParticipant(holder); + auto const issuerP = toParticipant(issuer); + + mpt_confidential_participant auditorP; + mpt_confidential_participant const* auditorPtr = nullptr; + if (auditor) + { + if (auditor->publicKey.size() != ecPubKeyLength || + auditor->encryptedAmount.size() != ecGamalEncryptedTotalLength) + return tecINTERNAL; // LCOV_EXCL_LINE + auditorP = toParticipant(*auditor); + auditorPtr = &auditorP; + } + + if (mpt_verify_revealed_amount(amount, blindingFactor.data(), &holderP, &issuerP, auditorPtr) != + 0) + return tecBAD_PROOF; + + return tesSUCCESS; +} + +NotTEC +checkEncryptedAmountFormat(STObject const& object) +{ + // Current usage of this function is only for ConfidentialMPTConvert and + // ConfidentialMPTConvertBack transactions, which already enforce that these fields + // are present. + if (!object.isFieldPresent(sfHolderEncryptedAmount) || + !object.isFieldPresent(sfIssuerEncryptedAmount)) + return temMALFORMED; // LCOV_EXCL_LINE + + if (object[sfHolderEncryptedAmount].length() != ecGamalEncryptedTotalLength || + object[sfIssuerEncryptedAmount].length() != ecGamalEncryptedTotalLength) + return temBAD_CIPHERTEXT; + + bool const hasAuditor = object.isFieldPresent(sfAuditorEncryptedAmount); + if (hasAuditor && object[sfAuditorEncryptedAmount].length() != ecGamalEncryptedTotalLength) + return temBAD_CIPHERTEXT; + + if (!isValidCiphertext(object[sfHolderEncryptedAmount]) || + !isValidCiphertext(object[sfIssuerEncryptedAmount])) + return temBAD_CIPHERTEXT; + + if (hasAuditor && !isValidCiphertext(object[sfAuditorEncryptedAmount])) + return temBAD_CIPHERTEXT; + + return tesSUCCESS; +} + +TER +verifySchnorrProof(Slice const& pubKeySlice, Slice const& proofSlice, uint256 const& contextHash) +{ + if (proofSlice.size() != ecSchnorrProofLength || pubKeySlice.size() != ecPubKeyLength) + return tecINTERNAL; // LCOV_EXCL_LINE + + if (mpt_verify_convert_proof(proofSlice.data(), pubKeySlice.data(), contextHash.data()) != 0) + return tecBAD_PROOF; + + return tesSUCCESS; +} + +TER +verifyClawbackEqualityProof( + uint64_t const amount, + Slice const& proof, + Slice const& pubKeySlice, + Slice const& ciphertext, + uint256 const& contextHash) +{ + if (ciphertext.size() != ecGamalEncryptedTotalLength || pubKeySlice.size() != ecPubKeyLength || + proof.size() != ecEqualityProofLength) + return tecINTERNAL; // LCOV_EXCL_LINE + + if (mpt_verify_clawback_proof( + proof.data(), amount, pubKeySlice.data(), ciphertext.data(), contextHash.data()) != 0) + return tecBAD_PROOF; + + return tesSUCCESS; +} + +TER +verifySendProof( + Slice const& proof, + ConfidentialRecipient const& sender, + ConfidentialRecipient const& destination, + ConfidentialRecipient const& issuer, + std::optional const& auditor, + Slice const& spendingBalance, + Slice const& amountCommitment, + Slice const& balanceCommitment, + uint256 const& contextHash) +{ + auto const recipientCount = getConfidentialRecipientCount(auditor.has_value()); + auto const expectedProofSize = getEqualityProofSize(recipientCount) + + 2 * ecPedersenProofLength + ecDoubleBulletproofLength; + + if (proof.size() != expectedProofSize || sender.publicKey.size() != ecPubKeyLength || + sender.encryptedAmount.size() != ecGamalEncryptedTotalLength || + destination.publicKey.size() != ecPubKeyLength || + destination.encryptedAmount.size() != ecGamalEncryptedTotalLength || + issuer.publicKey.size() != ecPubKeyLength || + issuer.encryptedAmount.size() != ecGamalEncryptedTotalLength || + spendingBalance.size() != ecGamalEncryptedTotalLength || + amountCommitment.size() != ecPedersenCommitmentLength || + balanceCommitment.size() != ecPedersenCommitmentLength) + return tecINTERNAL; // LCOV_EXCL_LINE + + auto makeParticipant = [](ConfidentialRecipient const& r) { + mpt_confidential_participant p; + std::memcpy(p.pubkey, r.publicKey.data(), kMPT_PUBKEY_SIZE); + std::memcpy(p.ciphertext, r.encryptedAmount.data(), kMPT_ELGAMAL_TOTAL_SIZE); + return p; + }; + + std::vector participants(recipientCount); + participants[0] = makeParticipant(sender); + participants[1] = makeParticipant(destination); + participants[2] = makeParticipant(issuer); + if (auditor) + { + if (auditor->publicKey.size() != ecPubKeyLength || + auditor->encryptedAmount.size() != ecGamalEncryptedTotalLength) + return tecINTERNAL; + participants[3] = makeParticipant(*auditor); + } + + if (mpt_verify_send_proof( + proof.data(), + proof.size(), + participants.data(), + static_cast(recipientCount), + spendingBalance.data(), + amountCommitment.data(), + balanceCommitment.data(), + contextHash.data()) != 0) + return tecBAD_PROOF; + + return tesSUCCESS; +} + +TER +verifyConvertBackProof( + Slice const& proof, + Slice const& pubKeySlice, + Slice const& spendingBalance, + Slice const& balanceCommitment, + uint64_t amount, + uint256 const& contextHash) +{ + if (proof.size() != ecPedersenProofLength + ecSingleBulletproofLength || + pubKeySlice.size() != ecPubKeyLength || + spendingBalance.size() != ecGamalEncryptedTotalLength || + balanceCommitment.size() != ecPedersenCommitmentLength) + return tecINTERNAL; // LCOV_EXCL_LINE + + if (mpt_verify_convert_back_proof( + proof.data(), + pubKeySlice.data(), + spendingBalance.data(), + balanceCommitment.data(), + amount, + contextHash.data()) != 0) + return tecBAD_PROOF; + + return tesSUCCESS; +} + +} // namespace xrpl diff --git a/src/libxrpl/protocol/PublicKey.cpp b/src/libxrpl/protocol/PublicKey.cpp index fc63edd2fc..9e61e9cbac 100644 --- a/src/libxrpl/protocol/PublicKey.cpp +++ b/src/libxrpl/protocol/PublicKey.cpp @@ -3,6 +3,7 @@ #include #include #include +#include #include #include #include @@ -208,7 +209,7 @@ publicKeyType(Slice const& slice) if (slice[0] == 0xED) return KeyType::ed25519; - if (slice[0] == 0x02 || slice[0] == 0x03) + if (slice[0] == ecCompressedPrefixEvenY || slice[0] == ecCompressedPrefixOddY) return KeyType::secp256k1; } diff --git a/src/libxrpl/protocol/TER.cpp b/src/libxrpl/protocol/TER.cpp index 1e3f64cc22..bd3acfee64 100644 --- a/src/libxrpl/protocol/TER.cpp +++ b/src/libxrpl/protocol/TER.cpp @@ -106,6 +106,7 @@ transResults() MAKE_ERROR(tecLIMIT_EXCEEDED, "Limit exceeded."), MAKE_ERROR(tecPSEUDO_ACCOUNT, "This operation is not allowed against a pseudo-account."), MAKE_ERROR(tecPRECISION_LOSS, "The amounts used by the transaction cannot interact."), + MAKE_ERROR(tecBAD_PROOF, "Proof cannot be verified"), MAKE_ERROR(tefALREADY, "The exact transaction was already in this ledger."), MAKE_ERROR(tefBAD_ADD_AUTH, "Not authorized to add account."), @@ -199,6 +200,7 @@ transResults() MAKE_ERROR(temARRAY_TOO_LARGE, "Malformed: Array is too large."), MAKE_ERROR(temBAD_TRANSFER_FEE, "Malformed: Transfer fee is outside valid range."), MAKE_ERROR(temINVALID_INNER_BATCH, "Malformed: Invalid inner batch transaction."), + MAKE_ERROR(temBAD_CIPHERTEXT, "Malformed: Invalid ciphertext."), MAKE_ERROR(terRETRY, "Retry transaction."), MAKE_ERROR(terFUNDS_SPENT, "DEPRECATED."), diff --git a/src/libxrpl/tx/invariants/MPTInvariant.cpp b/src/libxrpl/tx/invariants/MPTInvariant.cpp index de7bcb790a..29a9204d7d 100644 --- a/src/libxrpl/tx/invariants/MPTInvariant.cpp +++ b/src/libxrpl/tx/invariants/MPTInvariant.cpp @@ -9,8 +9,19 @@ #include #include +#include +#include + namespace xrpl { +static constexpr auto confidentialMPTTxTypes = std::to_array({ + ttCONFIDENTIAL_MPT_SEND, + ttCONFIDENTIAL_MPT_CONVERT, + ttCONFIDENTIAL_MPT_CONVERT_BACK, + ttCONFIDENTIAL_MPT_MERGE_INBOX, + ttCONFIDENTIAL_MPT_CLAWBACK, +}); + void ValidMPTIssuance::visitEntry( bool isDelete, @@ -341,6 +352,14 @@ ValidMPTPayment::finalize( { if (isTesSuccess(result)) { + // Confidential transactions are validated by ValidConfidentialMPToken. + // They modify encrypted fields and sfConfidentialOutstandingAmount + // rather than sfMPTAmount/sfOutstandingAmount in the standard way, + // so ValidMPTPayment's accounting does not apply to them. + if (std::ranges::find(confidentialMPTTxTypes, tx.getTxnType()) != + confidentialMPTTxTypes.end()) + return true; + bool const enforce = view.rules().enabled(featureMPTokensV2); if (overflow_) { @@ -369,4 +388,208 @@ ValidMPTPayment::finalize( return true; } +void +ValidConfidentialMPToken::visitEntry( + bool isDelete, + std::shared_ptr const& before, + std::shared_ptr const& after) +{ + // Helper to get MPToken Issuance ID safely + auto const getMptID = [](std::shared_ptr const& sle) -> uint192 { + if (!sle) + return beast::zero; + if (sle->getType() == ltMPTOKEN) + return sle->getFieldH192(sfMPTokenIssuanceID); + if (sle->getType() == ltMPTOKEN_ISSUANCE) + return makeMptID(sle->getFieldU32(sfSequence), sle->getAccountID(sfIssuer)); + return beast::zero; + }; + + if (before && before->getType() == ltMPTOKEN) + { + uint192 const id = getMptID(before); + changes_[id].mptAmountDelta -= before->getFieldU64(sfMPTAmount); + + // Cannot delete MPToken with non-zero confidential state or non-zero public amount + if (isDelete) + { + bool const hasPublicBalance = before->getFieldU64(sfMPTAmount) > 0; + bool const hasEncryptedFields = before->isFieldPresent(sfConfidentialBalanceSpending) || + before->isFieldPresent(sfConfidentialBalanceInbox) || + before->isFieldPresent(sfIssuerEncryptedBalance); + + if (hasPublicBalance || hasEncryptedFields) + changes_[id].deletedWithEncrypted = true; + } + } + + if (after && after->getType() == ltMPTOKEN) + { + uint192 const id = getMptID(after); + changes_[id].mptAmountDelta += after->getFieldU64(sfMPTAmount); + + // Encrypted field existence consistency + bool const hasIssuerBalance = after->isFieldPresent(sfIssuerEncryptedBalance); + bool const hasHolderInbox = after->isFieldPresent(sfConfidentialBalanceInbox); + bool const hasHolderSpending = after->isFieldPresent(sfConfidentialBalanceSpending); + + bool const hasAnyHolder = hasHolderInbox || hasHolderSpending; + + if (hasAnyHolder != hasIssuerBalance) + { + changes_[id].badConsistency = true; + } + + // Privacy flag consistency + bool const hasEncrypted = hasAnyHolder || hasIssuerBalance; + if (hasEncrypted) + changes_[id].requiresPrivacyFlag = true; + } + + if (before && before->getType() == ltMPTOKEN_ISSUANCE) + { + uint192 const id = getMptID(before); + if (before->isFieldPresent(sfConfidentialOutstandingAmount)) + changes_[id].coaDelta -= before->getFieldU64(sfConfidentialOutstandingAmount); + changes_[id].outstandingDelta -= before->getFieldU64(sfOutstandingAmount); + } + + if (after && after->getType() == ltMPTOKEN_ISSUANCE) + { + uint192 const id = getMptID(after); + auto& change = changes_[id]; + + bool const hasCOA = after->isFieldPresent(sfConfidentialOutstandingAmount); + std::uint64_t const coa = (*after)[~sfConfidentialOutstandingAmount].value_or(0); + std::uint64_t const oa = after->getFieldU64(sfOutstandingAmount); + + if (hasCOA) + change.coaDelta += coa; + + change.outstandingDelta += oa; + change.issuance = after; + + // COA <= OutstandingAmount + if (coa > oa) + change.badCOA = true; + } + + if (before && after && before->getType() == ltMPTOKEN && after->getType() == ltMPTOKEN) + { + uint192 const id = getMptID(after); + + // sfConfidentialBalanceVersion must change when spending changes + auto const spendingBefore = (*before)[~sfConfidentialBalanceSpending]; + auto const spendingAfter = (*after)[~sfConfidentialBalanceSpending]; + auto const versionBefore = (*before)[~sfConfidentialBalanceVersion]; + auto const versionAfter = (*after)[~sfConfidentialBalanceVersion]; + + if (spendingBefore.has_value() && spendingBefore != spendingAfter) + { + if (versionBefore == versionAfter) + { + changes_[id].badVersion = true; + } + } + } +} + +bool +ValidConfidentialMPToken::finalize( + STTx const& tx, + TER const result, + XRPAmount const, + ReadView const& view, + beast::Journal const& j) +{ + if (result != tesSUCCESS) + return true; + + for (auto const& [id, checks] : changes_) + { + // Find the MPTokenIssuance + auto const issuance = [&]() -> std::shared_ptr { + if (checks.issuance) + return checks.issuance; + return view.read(keylet::mptIssuance(id)); + }(); + + // Skip all invariance checks if issuance doesn't exist because that means the MPT has been + // deleted + if (!issuance) + continue; + + // Cannot delete MPToken with non-zero confidential state + if (checks.deletedWithEncrypted) + { + if ((*issuance)[~sfConfidentialOutstandingAmount].value_or(0) > 0) + { + JLOG(j.fatal()) + << "Invariant failed: MPToken deleted with encrypted fields while COA > 0"; + return false; + } + } + + // Encrypted field existence consistency + if (checks.badConsistency) + { + JLOG(j.fatal()) << "Invariant failed: MPToken encrypted field " + "existence inconsistency"; + return false; + } + + // COA <= OutstandingAmount + if (checks.badCOA) + { + JLOG(j.fatal()) << "Invariant failed: Confidential outstanding amount " + "exceeds total outstanding amount"; + return false; + } + + // Privacy flag consistency + if (checks.requiresPrivacyFlag) + { + if (!issuance->isFlag(lsfMPTCanConfidentialAmount)) + { + JLOG(j.fatal()) << "Invariant failed: MPToken has encrypted " + "fields but Issuance does not have " + "lsfMPTCanConfidentialAmount set"; + return false; + } + } + + // We only enforce this when Confidential Outstanding Amount changes (Convert, ConvertBack, + // ConfidentialClawback). This avoids falsely failing on Escrow or AMM operations that lock + // public tokens outside of ltMPTOKEN. Convert / ConvertBack: + // - COA and MPTAmount must have opposite deltas, which cancel each other out to zero. + // - OA remains unchanged. + // - Therefore, the net delta on both sides of the equation is zero. + // + // Clawback: + // - MPTAmount remains unchanged. + // - COA and OA must have identical deltas (mirrored on each side). + // - The equation remains balanced as both sides have equal offsets. + if (checks.coaDelta != 0) + { + if (checks.mptAmountDelta + checks.coaDelta != checks.outstandingDelta) + { + JLOG(j.fatal()) << "Invariant failed: Token conservation " + "violation for MPT " + << to_string(id); + return false; + } + } + + if (checks.badVersion) + { + JLOG(j.fatal()) + << "Invariant failed: MPToken sfConfidentialBalanceVersion not updated when " + "sfConfidentialBalanceSpending changed"; + return false; + } + } + + return true; +} + } // namespace xrpl diff --git a/src/libxrpl/tx/transactors/token/ConfidentialMPTClawback.cpp b/src/libxrpl/tx/transactors/token/ConfidentialMPTClawback.cpp new file mode 100644 index 0000000000..4afcb5d29a --- /dev/null +++ b/src/libxrpl/tx/transactors/token/ConfidentialMPTClawback.cpp @@ -0,0 +1,168 @@ +#include +#include +#include +#include +#include +#include +#include +#include + +namespace xrpl { + +NotTEC +ConfidentialMPTClawback::preflight(PreflightContext const& ctx) +{ + if (!ctx.rules.enabled(featureConfidentialTransfer)) + return temDISABLED; + + auto const account = ctx.tx[sfAccount]; + + // Only issuer can clawback + if (account != MPTIssue(ctx.tx[sfMPTokenIssuanceID]).getIssuer()) + return temMALFORMED; + + // Cannot clawback from self + if (account == ctx.tx[sfHolder]) + return temMALFORMED; + + // Check invalid claw amount + auto const clawAmount = ctx.tx[sfMPTAmount]; + if (clawAmount == 0 || clawAmount > maxMPTokenAmount) + return temBAD_AMOUNT; + + // Verify proof length + if (ctx.tx[sfZKProof].length() != ecEqualityProofLength) + return temMALFORMED; + + return tesSUCCESS; +} + +TER +ConfidentialMPTClawback::preclaim(PreclaimContext const& ctx) +{ + // Check if sender account exists + auto const account = ctx.tx[sfAccount]; + if (!ctx.view.exists(keylet::account(account))) + return terNO_ACCOUNT; + + // Check if holder account exists + auto const holder = ctx.tx[sfHolder]; + if (!ctx.view.exists(keylet::account(holder))) + return tecNO_TARGET; + + // Check if MPT issuance exists + auto const mptIssuanceID = ctx.tx[sfMPTokenIssuanceID]; + auto const sleIssuance = ctx.view.read(keylet::mptIssuance(mptIssuanceID)); + if (!sleIssuance) + return tecOBJECT_NOT_FOUND; + + // Sanity check: account must be the same as issuer + if (sleIssuance->getAccountID(sfIssuer) != account) + return tefINTERNAL; // LCOV_EXCL_LINE + + // Check if issuance has issuer ElGamal public key + if (!sleIssuance->isFieldPresent(sfIssuerEncryptionKey)) + return tecNO_PERMISSION; + + // Check if clawback is allowed + if (!sleIssuance->isFlag(lsfMPTCanClawback)) + return tecNO_PERMISSION; + + // Check if issuance allows confidential transfer + if (!sleIssuance->isFlag(lsfMPTCanConfidentialAmount)) + return tecNO_PERMISSION; // LCOV_EXCL_LINE + + // Check holder's MPToken + auto const sleHolderMPToken = ctx.view.read(keylet::mptoken(mptIssuanceID, holder)); + if (!sleHolderMPToken) + return tecOBJECT_NOT_FOUND; + + // Check if holder has confidential balances to claw back + if (!sleHolderMPToken->isFieldPresent(sfIssuerEncryptedBalance)) + return tecNO_PERMISSION; // LCOV_EXCL_LINE + + // Check if Holder has ElGamal public Key + if (!sleHolderMPToken->isFieldPresent(sfHolderEncryptionKey)) + return tecNO_PERMISSION; // LCOV_EXCL_LINE + + // Sanity check: claw amount can not exceed confidential outstanding amount + auto const amount = ctx.tx[sfMPTAmount]; + if (amount > (*sleIssuance)[~sfConfidentialOutstandingAmount].value_or(0)) + return tecINSUFFICIENT_FUNDS; + + auto const contextHash = + getClawbackContextHash(account, mptIssuanceID, ctx.tx.getSeqProxy().value(), holder); + + // Verify the revealed confidential amount by the issuer matches the exact + // confidential balance of the holder. + return verifyClawbackEqualityProof( + amount, + ctx.tx[sfZKProof], + (*sleIssuance)[sfIssuerEncryptionKey], + (*sleHolderMPToken)[sfIssuerEncryptedBalance], + contextHash); +} + +TER +ConfidentialMPTClawback::doApply() +{ + auto const mptIssuanceID = ctx_.tx[sfMPTokenIssuanceID]; + auto const holder = ctx_.tx[sfHolder]; + + auto sleIssuance = view().peek(keylet::mptIssuance(mptIssuanceID)); + auto sleHolderMPToken = view().peek(keylet::mptoken(mptIssuanceID, holder)); + + if (!sleIssuance || !sleHolderMPToken) + return tecINTERNAL; // LCOV_EXCL_LINE + + auto const clawAmount = ctx_.tx[sfMPTAmount]; + + Slice const holderPubKey = (*sleHolderMPToken)[sfHolderEncryptionKey]; + Slice const issuerPubKey = (*sleIssuance)[sfIssuerEncryptionKey]; + + // After clawback, the balance should be encrypted zero. + auto const encZeroForHolder = encryptCanonicalZeroAmount(holderPubKey, holder, mptIssuanceID); + if (!encZeroForHolder) + return tecINTERNAL; // LCOV_EXCL_LINE + + auto encZeroForIssuer = encryptCanonicalZeroAmount(issuerPubKey, holder, mptIssuanceID); + if (!encZeroForIssuer) + return tecINTERNAL; // LCOV_EXCL_LINE + + // Set holder's confidential balances to encrypted zero + (*sleHolderMPToken)[sfConfidentialBalanceInbox] = *encZeroForHolder; + (*sleHolderMPToken)[sfConfidentialBalanceSpending] = *encZeroForHolder; + (*sleHolderMPToken)[sfIssuerEncryptedBalance] = std::move(*encZeroForIssuer); + incrementConfidentialVersion(*sleHolderMPToken); + + if (sleHolderMPToken->isFieldPresent(sfAuditorEncryptedBalance)) + { + // Sanity check: the issuance must have an auditor public key if + // auditing is enabled. + if (!sleIssuance->isFieldPresent(sfAuditorEncryptionKey)) + return tecINTERNAL; // LCOV_EXCL_LINE + + Slice const auditorPubKey = (*sleIssuance)[sfAuditorEncryptionKey]; + + auto encZeroForAuditor = encryptCanonicalZeroAmount(auditorPubKey, holder, mptIssuanceID); + + if (!encZeroForAuditor) + return tecINTERNAL; // LCOV_EXCL_LINE + + (*sleHolderMPToken)[sfAuditorEncryptedBalance] = std::move(*encZeroForAuditor); + } + + // Decrease Global Confidential Outstanding Amount + auto const oldCOA = (*sleIssuance)[sfConfidentialOutstandingAmount]; + (*sleIssuance)[sfConfidentialOutstandingAmount] = oldCOA - clawAmount; + + // Decrease Global Total Outstanding Amount + auto const oldOA = (*sleIssuance)[sfOutstandingAmount]; + (*sleIssuance)[sfOutstandingAmount] = oldOA - clawAmount; + + view().update(sleHolderMPToken); + view().update(sleIssuance); + + return tesSUCCESS; +} +} // namespace xrpl diff --git a/src/libxrpl/tx/transactors/token/ConfidentialMPTConvert.cpp b/src/libxrpl/tx/transactors/token/ConfidentialMPTConvert.cpp new file mode 100644 index 0000000000..eca20414de --- /dev/null +++ b/src/libxrpl/tx/transactors/token/ConfidentialMPTConvert.cpp @@ -0,0 +1,281 @@ +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace xrpl { + +NotTEC +ConfidentialMPTConvert::preflight(PreflightContext const& ctx) +{ + if (!ctx.rules.enabled(featureConfidentialTransfer)) + return temDISABLED; + + // issuer cannot convert + if (MPTIssue(ctx.tx[sfMPTokenIssuanceID]).getIssuer() == ctx.tx[sfAccount]) + return temMALFORMED; + + if (ctx.tx[sfMPTAmount] > maxMPTokenAmount) + return temBAD_AMOUNT; + + if (ctx.tx.isFieldPresent(sfHolderEncryptionKey)) + { + if (!isValidCompressedECPoint(ctx.tx[sfHolderEncryptionKey])) + return temMALFORMED; + + // proof of knowledge of the secret key corresponding to the provided + // public key is needed when holder ec public key is being set. + if (!ctx.tx.isFieldPresent(sfZKProof)) + return temMALFORMED; + + // verify schnorr proof length when registering holder ec public key + if (ctx.tx[sfZKProof].size() != ecSchnorrProofLength) + return temMALFORMED; + } + else + { + // Either both sfHolderEncryptionKey and sfZKProof should be present, or both should be + // absent. + if (ctx.tx.isFieldPresent(sfZKProof)) + return temMALFORMED; + } + + // check encrypted amount format after the above basic checks + // this check is more expensive so put it at the end + if (auto const res = checkEncryptedAmountFormat(ctx.tx); !isTesSuccess(res)) + return res; + + return tesSUCCESS; +} + +TER +ConfidentialMPTConvert::preclaim(PreclaimContext const& ctx) +{ + auto const account = ctx.tx[sfAccount]; + auto const issuanceID = ctx.tx[sfMPTokenIssuanceID]; + auto const amount = ctx.tx[sfMPTAmount]; + + // ensure that issuance exists + auto const sleIssuance = ctx.view.read(keylet::mptIssuance(issuanceID)); + if (!sleIssuance) + return tecOBJECT_NOT_FOUND; + + if (!sleIssuance->isFlag(lsfMPTCanConfidentialAmount) || + !sleIssuance->isFieldPresent(sfIssuerEncryptionKey)) + return tecNO_PERMISSION; + + // already checked in preflight, but should also check that issuer on the + // issuance isn't the account either + if (sleIssuance->getAccountID(sfIssuer) == account) + return tefINTERNAL; // LCOV_EXCL_LINE + + bool const hasAuditor = ctx.tx.isFieldPresent(sfAuditorEncryptedAmount); + bool const requiresAuditor = sleIssuance->isFieldPresent(sfAuditorEncryptionKey); + + // tx must include auditor ciphertext if the issuance has enabled + // auditing, and must not include it if auditing is not enabled + if (requiresAuditor != hasAuditor) + return tecNO_PERMISSION; + + auto const sleMptoken = ctx.view.read(keylet::mptoken(issuanceID, account)); + if (!sleMptoken) + return tecOBJECT_NOT_FOUND; + + auto const mptIssue = MPTIssue{issuanceID}; + + // Explicit freeze and auth checks are required because accountHolds + // with fhZERO_IF_FROZEN/ahZERO_IF_UNAUTHORIZED only implicitly rejects + // non-zero amounts. A zero-amount convert would bypass those implicit + // checks, allowing frozen or unauthorized accounts to register ElGamal + // keys and initialize confidential balance fields. + + // Check lock + if (auto const ter = checkFrozen(ctx.view, account, mptIssue); !isTesSuccess(ter)) + return ter; + + // Check auth + if (auto const ter = requireAuth(ctx.view, mptIssue, account); !isTesSuccess(ter)) + return ter; + + STAmount const mptAmount = + STAmount(MPTAmount{static_cast(amount)}, mptIssue); + if (accountHolds( + ctx.view, + account, + mptIssue, + FreezeHandling::fhZERO_IF_FROZEN, + AuthHandling::ahZERO_IF_UNAUTHORIZED, + ctx.j) < mptAmount) + { + return tecINSUFFICIENT_FUNDS; + } + + auto const hasHolderKeyOnLedger = sleMptoken->isFieldPresent(sfHolderEncryptionKey); + auto const hasHolderKeyInTx = ctx.tx.isFieldPresent(sfHolderEncryptionKey); + + // must have pk to convert + if (!hasHolderKeyOnLedger && !hasHolderKeyInTx) + return tecNO_PERMISSION; + + // can't update if there's already a pk + if (hasHolderKeyOnLedger && hasHolderKeyInTx) + return tecDUPLICATE; + + // Run all verifications before returning any error to prevent timing attacks + // that could reveal which proof failed. + bool valid = true; + + Slice holderPubKey; + if (hasHolderKeyInTx) + { + holderPubKey = ctx.tx[sfHolderEncryptionKey]; + + auto const contextHash = + getConvertContextHash(account, issuanceID, ctx.tx.getSeqProxy().value()); + + if (auto const ter = verifySchnorrProof(holderPubKey, ctx.tx[sfZKProof], contextHash); + !isTesSuccess(ter)) + { + valid = false; + } + } + else + { + holderPubKey = (*sleMptoken)[sfHolderEncryptionKey]; + } + + std::optional auditor; + if (hasAuditor) + { + auditor.emplace( + ConfidentialRecipient{ + (*sleIssuance)[sfAuditorEncryptionKey], ctx.tx[sfAuditorEncryptedAmount]}); + } + + auto const blindingFactor = ctx.tx[sfBlindingFactor]; + if (auto const ter = verifyRevealedAmount( + amount, + Slice(blindingFactor.data(), blindingFactor.size()), + {holderPubKey, ctx.tx[sfHolderEncryptedAmount]}, + {(*sleIssuance)[sfIssuerEncryptionKey], ctx.tx[sfIssuerEncryptedAmount]}, + auditor); + !isTesSuccess(ter)) + { + valid = false; + } + + if (!valid) + return tecBAD_PROOF; + + return tesSUCCESS; +} + +TER +ConfidentialMPTConvert::doApply() +{ + auto const mptIssuanceID = ctx_.tx[sfMPTokenIssuanceID]; + + auto sleMptoken = view().peek(keylet::mptoken(mptIssuanceID, account_)); + if (!sleMptoken) + return tecINTERNAL; // LCOV_EXCL_LINE + + auto sleIssuance = view().peek(keylet::mptIssuance(mptIssuanceID)); + if (!sleIssuance) + return tecINTERNAL; // LCOV_EXCL_LINE + + auto const amtToConvert = ctx_.tx[sfMPTAmount]; + auto const amt = (*sleMptoken)[~sfMPTAmount].value_or(0); + + if (ctx_.tx.isFieldPresent(sfHolderEncryptionKey)) + (*sleMptoken)[sfHolderEncryptionKey] = ctx_.tx[sfHolderEncryptionKey]; + + // Converting decreases regular balance and increases confidential outstanding. + // The confidential outstanding tracks total tokens in confidential form globally. + auto const currentCOA = (*sleIssuance)[~sfConfidentialOutstandingAmount].value_or(0); + if (amtToConvert > maxMPTokenAmount - currentCOA) + return tecINTERNAL; // LCOV_EXCL_LINE + + (*sleMptoken)[sfMPTAmount] = amt - amtToConvert; + (*sleIssuance)[sfConfidentialOutstandingAmount] = currentCOA + amtToConvert; + + Slice const holderEc = ctx_.tx[sfHolderEncryptedAmount]; + Slice const issuerEc = ctx_.tx[sfIssuerEncryptedAmount]; + + auto const auditorEc = ctx_.tx[~sfAuditorEncryptedAmount]; + + // Two cases for Convert: + // 1. Holder already has confidential balances -> homomorphically add to inbox + // 2. First-time convert -> initialize all confidential balance fields + if (sleMptoken->isFieldPresent(sfIssuerEncryptedBalance) && + sleMptoken->isFieldPresent(sfConfidentialBalanceInbox) && + sleMptoken->isFieldPresent(sfConfidentialBalanceSpending)) + { + // Case 1: Add to existing inbox balance (holder will merge later) + { + auto sum = homomorphicAdd(holderEc, (*sleMptoken)[sfConfidentialBalanceInbox]); + if (!sum) + return tecINTERNAL; // LCOV_EXCL_LINE + + (*sleMptoken)[sfConfidentialBalanceInbox] = std::move(*sum); + } + + // homomorphically add issuer's encrypted balance + { + auto sum = homomorphicAdd(issuerEc, (*sleMptoken)[sfIssuerEncryptedBalance]); + if (!sum) + return tecINTERNAL; // LCOV_EXCL_LINE + + (*sleMptoken)[sfIssuerEncryptedBalance] = std::move(*sum); + } + + // homomorphically add auditor's encrypted balance + if (auditorEc) + { + auto sum = homomorphicAdd(*auditorEc, (*sleMptoken)[sfAuditorEncryptedBalance]); + if (!sum) + return tecINTERNAL; // LCOV_EXCL_LINE + + (*sleMptoken)[sfAuditorEncryptedBalance] = std::move(*sum); + } + } + else if ( + !sleMptoken->isFieldPresent(sfIssuerEncryptedBalance) && + !sleMptoken->isFieldPresent(sfConfidentialBalanceInbox) && + !sleMptoken->isFieldPresent(sfConfidentialBalanceSpending)) + { + // Case 2: First-time convert - initialize all confidential fields + (*sleMptoken)[sfConfidentialBalanceInbox] = holderEc; + (*sleMptoken)[sfIssuerEncryptedBalance] = issuerEc; + (*sleMptoken)[sfConfidentialBalanceVersion] = 0; + + if (auditorEc) + (*sleMptoken)[sfAuditorEncryptedBalance] = *auditorEc; + + // Spending balance starts at zero. Must use canonical zero encryption + // (deterministic ciphertext) so the ledger state is reproducible. + auto zeroBalance = encryptCanonicalZeroAmount( + (*sleMptoken)[sfHolderEncryptionKey], account_, mptIssuanceID); + + if (!zeroBalance) + return tecINTERNAL; // LCOV_EXCL_LINE + + (*sleMptoken)[sfConfidentialBalanceSpending] = std::move(*zeroBalance); + } + else + { + // both sfIssuerEncryptedBalance and sfConfidentialBalanceInbox should + // exist together + return tecINTERNAL; // LCOV_EXCL_LINE + } + + view().update(sleIssuance); + view().update(sleMptoken); + return tesSUCCESS; +} + +} // namespace xrpl diff --git a/src/libxrpl/tx/transactors/token/ConfidentialMPTConvertBack.cpp b/src/libxrpl/tx/transactors/token/ConfidentialMPTConvertBack.cpp new file mode 100644 index 0000000000..43c32516d6 --- /dev/null +++ b/src/libxrpl/tx/transactors/token/ConfidentialMPTConvertBack.cpp @@ -0,0 +1,247 @@ +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include + +namespace xrpl { + +NotTEC +ConfidentialMPTConvertBack::preflight(PreflightContext const& ctx) +{ + if (!ctx.rules.enabled(featureConfidentialTransfer)) + return temDISABLED; + + // issuer cannot convert back + if (MPTIssue(ctx.tx[sfMPTokenIssuanceID]).getIssuer() == ctx.tx[sfAccount]) + return temMALFORMED; + + if (ctx.tx[sfMPTAmount] == 0 || ctx.tx[sfMPTAmount] > maxMPTokenAmount) + return temBAD_AMOUNT; + + if (!isValidCompressedECPoint(ctx.tx[sfBalanceCommitment])) + return temMALFORMED; + + // check encrypted amount format after the above basic checks + // this check is more expensive so put it at the end + if (auto const res = checkEncryptedAmountFormat(ctx.tx); !isTesSuccess(res)) + return res; + + // ConvertBack proof = pedersen linkage proof + single bulletproof + if (ctx.tx[sfZKProof].size() != ecPedersenProofLength + ecSingleBulletproofLength) + return temMALFORMED; + + return tesSUCCESS; +} + +/** + * Verifies the cryptographic proofs for a ConvertBack transaction. + * + * This function verifies three proofs: + * 1. Revealed amount proof: verifies the encrypted amounts (holder, issuer, + * auditor) all encrypt the same revealed amount using the blinding factor. + * 2. Pedersen linkage proof: verifies the balance commitment is derived from + * the holder's encrypted spending balance. + * 3. Bulletproof (range proof): verifies the remaining balance (balance - amount) + * is non-negative, preventing overdrafts. + * + * All proofs are verified before returning any error to prevent timing attacks. + */ +static TER +verifyProofs( + STTx const& tx, + std::shared_ptr const& issuance, + std::shared_ptr const& mptoken) +{ + if (!mptoken->isFieldPresent(sfHolderEncryptionKey)) + return tecINTERNAL; // LCOV_EXCL_LINE + + auto const mptIssuanceID = tx[sfMPTokenIssuanceID]; + auto const account = tx[sfAccount]; + auto const amount = tx[sfMPTAmount]; + auto const blindingFactor = tx[sfBlindingFactor]; + auto const holderPubKey = (*mptoken)[sfHolderEncryptionKey]; + + auto const contextHash = getConvertBackContextHash( + account, + mptIssuanceID, + tx.getSeqProxy().value(), + (*mptoken)[~sfConfidentialBalanceVersion].value_or(0)); + + // Prepare Auditor Info + std::optional auditor; + bool const hasAuditor = issuance->isFieldPresent(sfAuditorEncryptionKey); + if (hasAuditor) + { + auditor.emplace( + ConfidentialRecipient{ + (*issuance)[sfAuditorEncryptionKey], tx[sfAuditorEncryptedAmount]}); + } + + // Run all verifications before returning any error to prevent timing attacks + // that could reveal which proof failed. + bool valid = true; + + if (auto const ter = verifyRevealedAmount( + amount, + Slice(blindingFactor.data(), blindingFactor.size()), + {holderPubKey, tx[sfHolderEncryptedAmount]}, + {(*issuance)[sfIssuerEncryptionKey], tx[sfIssuerEncryptedAmount]}, + auditor); + !isTesSuccess(ter)) + { + valid = false; + } + + if (auto const ter = verifyConvertBackProof( + tx[sfZKProof], + holderPubKey, + (*mptoken)[sfConfidentialBalanceSpending], + tx[sfBalanceCommitment], + amount, + contextHash); + !isTesSuccess(ter)) + { + valid = false; + } + + if (!valid) + return tecBAD_PROOF; + + return tesSUCCESS; +} + +TER +ConfidentialMPTConvertBack::preclaim(PreclaimContext const& ctx) +{ + auto const mptIssuanceID = ctx.tx[sfMPTokenIssuanceID]; + auto const account = ctx.tx[sfAccount]; + auto const amount = ctx.tx[sfMPTAmount]; + + // ensure that issuance exists + auto const sleIssuance = ctx.view.read(keylet::mptIssuance(mptIssuanceID)); + if (!sleIssuance) + return tecOBJECT_NOT_FOUND; + + if (!sleIssuance->isFlag(lsfMPTCanConfidentialAmount)) + return tecNO_PERMISSION; + + bool const hasAuditor = ctx.tx.isFieldPresent(sfAuditorEncryptedAmount); + bool const requiresAuditor = sleIssuance->isFieldPresent(sfAuditorEncryptionKey); + + // tx must include auditor ciphertext if the issuance has enabled + // auditing + if (requiresAuditor && !hasAuditor) + return tecNO_PERMISSION; + + // if auditing is not supported then user should not upload auditor + // ciphertext + if (!requiresAuditor && hasAuditor) + return tecNO_PERMISSION; + + // already checked in preflight, but should also check that issuer on + // the issuance isn't the account either + if (sleIssuance->getAccountID(sfIssuer) == account) + return tefINTERNAL; // LCOV_EXCL_LINE + + auto const sleMptoken = ctx.view.read(keylet::mptoken(mptIssuanceID, account)); + if (!sleMptoken) + return tecOBJECT_NOT_FOUND; + + if (!sleMptoken->isFieldPresent(sfConfidentialBalanceSpending) || + !sleMptoken->isFieldPresent(sfHolderEncryptionKey)) + { + return tecNO_PERMISSION; + } + + // if the total circulating confidential balance is smaller than what the + // holder is trying to convert back, we know for sure this txn should + // fail + if ((*sleIssuance)[~sfConfidentialOutstandingAmount].value_or(0) < amount) + { + return tecINSUFFICIENT_FUNDS; + } + + // Check lock + MPTIssue const mptIssue(mptIssuanceID); + if (auto const ter = checkFrozen(ctx.view, account, mptIssue); !isTesSuccess(ter)) + return ter; + + // Check auth + if (auto const ter = requireAuth(ctx.view, mptIssue, account); !isTesSuccess(ter)) + return ter; + + if (TER const res = verifyProofs(ctx.tx, sleIssuance, sleMptoken); !isTesSuccess(res)) + return res; + + return tesSUCCESS; +} + +TER +ConfidentialMPTConvertBack::doApply() +{ + auto const mptIssuanceID = ctx_.tx[sfMPTokenIssuanceID]; + + auto sleMptoken = view().peek(keylet::mptoken(mptIssuanceID, account_)); + if (!sleMptoken) + return tecINTERNAL; // LCOV_EXCL_LINE + + auto sleIssuance = view().peek(keylet::mptIssuance(mptIssuanceID)); + if (!sleIssuance) + return tecINTERNAL; // LCOV_EXCL_LINE + + auto const amtToConvertBack = ctx_.tx[sfMPTAmount]; + auto const amt = (*sleMptoken)[~sfMPTAmount].value_or(0); + + // Converting back increases regular balance and decreases confidential + // outstanding. This is the inverse of Convert. + (*sleMptoken)[sfMPTAmount] = amt + amtToConvertBack; + (*sleIssuance)[sfConfidentialOutstandingAmount] = + (*sleIssuance)[sfConfidentialOutstandingAmount] - amtToConvertBack; + + std::optional const auditorEc = ctx_.tx[~sfAuditorEncryptedAmount]; + + // homomorphically subtract holder's encrypted balance + { + auto res = homomorphicSubtract( + (*sleMptoken)[sfConfidentialBalanceSpending], ctx_.tx[sfHolderEncryptedAmount]); + if (!res) + return tecINTERNAL; // LCOV_EXCL_LINE + + (*sleMptoken)[sfConfidentialBalanceSpending] = std::move(*res); + } + + // homomorphically subtract issuer's encrypted balance + { + auto res = homomorphicSubtract( + (*sleMptoken)[sfIssuerEncryptedBalance], ctx_.tx[sfIssuerEncryptedAmount]); + if (!res) + return tecINTERNAL; // LCOV_EXCL_LINE + + (*sleMptoken)[sfIssuerEncryptedBalance] = std::move(*res); + } + + if (auditorEc) + { + auto res = homomorphicSubtract( + (*sleMptoken)[sfAuditorEncryptedBalance], ctx_.tx[sfAuditorEncryptedAmount]); + if (!res) + return tecINTERNAL; // LCOV_EXCL_LINE + + (*sleMptoken)[sfAuditorEncryptedBalance] = std::move(*res); + } + + incrementConfidentialVersion(*sleMptoken); + + view().update(sleIssuance); + view().update(sleMptoken); + return tesSUCCESS; +} + +} // namespace xrpl diff --git a/src/libxrpl/tx/transactors/token/ConfidentialMPTMergeInbox.cpp b/src/libxrpl/tx/transactors/token/ConfidentialMPTMergeInbox.cpp new file mode 100644 index 0000000000..a133bb934a --- /dev/null +++ b/src/libxrpl/tx/transactors/token/ConfidentialMPTMergeInbox.cpp @@ -0,0 +1,106 @@ +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace xrpl { + +NotTEC +ConfidentialMPTMergeInbox::preflight(PreflightContext const& ctx) +{ + if (!ctx.rules.enabled(featureConfidentialTransfer)) + return temDISABLED; + + // issuer cannot merge + if (MPTIssue(ctx.tx[sfMPTokenIssuanceID]).getIssuer() == ctx.tx[sfAccount]) + return temMALFORMED; + + return tesSUCCESS; +} + +TER +ConfidentialMPTMergeInbox::preclaim(PreclaimContext const& ctx) +{ + auto const sleIssuance = ctx.view.read(keylet::mptIssuance(ctx.tx[sfMPTokenIssuanceID])); + if (!sleIssuance) + return tecOBJECT_NOT_FOUND; + + if (!sleIssuance->isFlag(lsfMPTCanConfidentialAmount)) + return tecNO_PERMISSION; + + // already checked in preflight, but should also check that issuer on the + // issuance isn't the account either + if (sleIssuance->getAccountID(sfIssuer) == ctx.tx[sfAccount]) + return tefINTERNAL; // LCOV_EXCL_LINE + + auto const sleMptoken = + ctx.view.read(keylet::mptoken(ctx.tx[sfMPTokenIssuanceID], ctx.tx[sfAccount])); + if (!sleMptoken) + return tecOBJECT_NOT_FOUND; + + if (!sleMptoken->isFieldPresent(sfConfidentialBalanceInbox) || + !sleMptoken->isFieldPresent(sfConfidentialBalanceSpending) || + !sleMptoken->isFieldPresent(sfHolderEncryptionKey)) + return tecNO_PERMISSION; + + // Check lock + auto const account = ctx.tx[sfAccount]; + MPTIssue const mptIssue(ctx.tx[sfMPTokenIssuanceID]); + if (auto const ter = checkFrozen(ctx.view, account, mptIssue); !isTesSuccess(ter)) + return ter; + + // Check auth + if (auto const ter = requireAuth(ctx.view, mptIssue, account); !isTesSuccess(ter)) + return ter; + + return tesSUCCESS; +} + +TER +ConfidentialMPTMergeInbox::doApply() +{ + auto const mptIssuanceID = ctx_.tx[sfMPTokenIssuanceID]; + auto sleMptoken = view().peek(keylet::mptoken(mptIssuanceID, account_)); + if (!sleMptoken) + return tecINTERNAL; // LCOV_EXCL_LINE + + // sanity check + if (!sleMptoken->isFieldPresent(sfConfidentialBalanceSpending) || + !sleMptoken->isFieldPresent(sfConfidentialBalanceInbox) || + !sleMptoken->isFieldPresent(sfHolderEncryptionKey)) + { + return tecINTERNAL; // LCOV_EXCL_LINE + } + + // Merge inbox into spending: spending = spending + inbox + // This allows holder to use received funds. Without merging, incoming + // transfers sit in inbox and cannot be spent or converted back. + auto sum = homomorphicAdd( + (*sleMptoken)[sfConfidentialBalanceSpending], (*sleMptoken)[sfConfidentialBalanceInbox]); + if (!sum) + return tecINTERNAL; // LCOV_EXCL_LINE + + (*sleMptoken)[sfConfidentialBalanceSpending] = std::move(*sum); + + // Reset inbox to encrypted zero. Must use canonical zero encryption + // (deterministic ciphertext) so the ledger state is reproducible. + auto zeroEncryption = + encryptCanonicalZeroAmount((*sleMptoken)[sfHolderEncryptionKey], account_, mptIssuanceID); + + if (!zeroEncryption) + return tecINTERNAL; // LCOV_EXCL_LINE + + (*sleMptoken)[sfConfidentialBalanceInbox] = std::move(*zeroEncryption); + + incrementConfidentialVersion(*sleMptoken); + + view().update(sleMptoken); + return tesSUCCESS; +} + +} // namespace xrpl diff --git a/src/libxrpl/tx/transactors/token/ConfidentialMPTSend.cpp b/src/libxrpl/tx/transactors/token/ConfidentialMPTSend.cpp new file mode 100644 index 0000000000..e5e5df47a2 --- /dev/null +++ b/src/libxrpl/tx/transactors/token/ConfidentialMPTSend.cpp @@ -0,0 +1,298 @@ +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace xrpl { + +NotTEC +ConfidentialMPTSend::preflight(PreflightContext const& ctx) +{ + if (!ctx.rules.enabled(featureConfidentialTransfer)) + return temDISABLED; + + auto const account = ctx.tx[sfAccount]; + auto const issuer = MPTIssue(ctx.tx[sfMPTokenIssuanceID]).getIssuer(); + + // ConfidentialMPTSend only allows holder to holder, holder to second account, + // and second account to holder transfers. So issuer cannot be the sender. + if (account == issuer) + return temMALFORMED; + + // Can not send to self + if (account == ctx.tx[sfDestination]) + return temMALFORMED; + + // Check the length of the encrypted amounts + if (ctx.tx[sfSenderEncryptedAmount].length() != ecGamalEncryptedTotalLength || + ctx.tx[sfDestinationEncryptedAmount].length() != ecGamalEncryptedTotalLength || + ctx.tx[sfIssuerEncryptedAmount].length() != ecGamalEncryptedTotalLength) + return temBAD_CIPHERTEXT; + + bool const hasAuditor = ctx.tx.isFieldPresent(sfAuditorEncryptedAmount); + if (hasAuditor && ctx.tx[sfAuditorEncryptedAmount].length() != ecGamalEncryptedTotalLength) + return temBAD_CIPHERTEXT; + + // Check the length of the ZKProof + auto const recipientCount = getConfidentialRecipientCount(hasAuditor); + auto const sizeEquality = getEqualityProofSize(recipientCount); + + if (ctx.tx[sfZKProof].length() != + sizeEquality + doublePedersenProofLength + ecDoubleBulletproofLength) + return temMALFORMED; + + // Check the Pedersen commitments are valid + if (!isValidCompressedECPoint(ctx.tx[sfBalanceCommitment]) || + !isValidCompressedECPoint(ctx.tx[sfAmountCommitment])) + return temMALFORMED; + + // Check the encrypted amount formats, this is more expensive so put it at + // the end + if (!isValidCiphertext(ctx.tx[sfSenderEncryptedAmount]) || + !isValidCiphertext(ctx.tx[sfDestinationEncryptedAmount]) || + !isValidCiphertext(ctx.tx[sfIssuerEncryptedAmount])) + return temBAD_CIPHERTEXT; + + if (hasAuditor && !isValidCiphertext(ctx.tx[sfAuditorEncryptedAmount])) + return temBAD_CIPHERTEXT; + + if (auto const err = credentials::checkFields(ctx.tx, ctx.j); !isTesSuccess(err)) + return err; + + return tesSUCCESS; +} + +TER +verifySendProofs( + PreclaimContext const& ctx, + std::shared_ptr const& sleSenderMPToken, + std::shared_ptr const& sleDestinationMPToken, + std::shared_ptr const& sleIssuance) +{ + // Sanity check + if (!sleSenderMPToken || !sleDestinationMPToken || !sleIssuance) + return tecINTERNAL; // LCOV_EXCL_LINE + + auto const hasAuditor = ctx.tx.isFieldPresent(sfAuditorEncryptedAmount); + + std::optional auditor; + if (hasAuditor) + auditor.emplace( + ConfidentialRecipient{ + (*sleIssuance)[sfAuditorEncryptionKey], ctx.tx[sfAuditorEncryptedAmount]}); + + auto const contextHash = getSendContextHash( + ctx.tx[sfAccount], + ctx.tx[sfMPTokenIssuanceID], + ctx.tx.getSeqProxy().value(), + ctx.tx[sfDestination], + (*sleSenderMPToken)[~sfConfidentialBalanceVersion].value_or(0)); + + return verifySendProof( + ctx.tx[sfZKProof], + {(*sleSenderMPToken)[sfHolderEncryptionKey], ctx.tx[sfSenderEncryptedAmount]}, + {(*sleDestinationMPToken)[sfHolderEncryptionKey], ctx.tx[sfDestinationEncryptedAmount]}, + {(*sleIssuance)[sfIssuerEncryptionKey], ctx.tx[sfIssuerEncryptedAmount]}, + auditor, + (*sleSenderMPToken)[sfConfidentialBalanceSpending], + ctx.tx[sfAmountCommitment], + ctx.tx[sfBalanceCommitment], + contextHash); +} + +TER +ConfidentialMPTSend::preclaim(PreclaimContext const& ctx) +{ + // Check if sender account exists + auto const account = ctx.tx[sfAccount]; + if (!ctx.view.exists(keylet::account(account))) + return terNO_ACCOUNT; + + // Check if destination account exists + auto const destination = ctx.tx[sfDestination]; + if (!ctx.view.exists(keylet::account(destination))) + return tecNO_TARGET; + + // Check if MPT issuance exists + auto const mptIssuanceID = ctx.tx[sfMPTokenIssuanceID]; + auto const sleIssuance = ctx.view.read(keylet::mptIssuance(mptIssuanceID)); + if (!sleIssuance) + return tecOBJECT_NOT_FOUND; + + // Check if the issuance allows transfer + if (!sleIssuance->isFlag(lsfMPTCanTransfer)) + return tecNO_AUTH; + + // Check if issuance allows confidential transfer + if (!sleIssuance->isFlag(lsfMPTCanConfidentialAmount)) + return tecNO_PERMISSION; + + // Check if issuance has issuer ElGamal public key + if (!sleIssuance->isFieldPresent(sfIssuerEncryptionKey)) + return tecNO_PERMISSION; + + bool const hasAuditor = ctx.tx.isFieldPresent(sfAuditorEncryptedAmount); + bool const requiresAuditor = sleIssuance->isFieldPresent(sfAuditorEncryptionKey); + + // Tx must include auditor ciphertext if the issuance has enabled + // auditing, and must not include it if auditing is not enabled + if (requiresAuditor != hasAuditor) + return tecNO_PERMISSION; + + // Sanity check: issuer isn't the sender + if (sleIssuance->getAccountID(sfIssuer) == ctx.tx[sfAccount]) + return tefINTERNAL; // LCOV_EXCL_LINE + + // Check sender's MPToken existence + auto const sleSenderMPToken = ctx.view.read(keylet::mptoken(mptIssuanceID, account)); + if (!sleSenderMPToken) + return tecOBJECT_NOT_FOUND; + + // Check sender's MPToken has necessary fields for confidential send + if (!sleSenderMPToken->isFieldPresent(sfHolderEncryptionKey) || + !sleSenderMPToken->isFieldPresent(sfConfidentialBalanceSpending) || + !sleSenderMPToken->isFieldPresent(sfIssuerEncryptedBalance)) + return tecNO_PERMISSION; + + // Check destination's MPToken existence + auto const sleDestinationMPToken = ctx.view.read(keylet::mptoken(mptIssuanceID, destination)); + if (!sleDestinationMPToken) + return tecOBJECT_NOT_FOUND; + + // Check destination's MPToken has necessary fields for confidential send + if (!sleDestinationMPToken->isFieldPresent(sfHolderEncryptionKey) || + !sleDestinationMPToken->isFieldPresent(sfConfidentialBalanceInbox) || + !sleDestinationMPToken->isFieldPresent(sfIssuerEncryptedBalance)) + return tecNO_PERMISSION; + + // Sanity check: Both MPTokens' auditor fields must be present if auditing + // is enabled + if (requiresAuditor && + (!sleSenderMPToken->isFieldPresent(sfAuditorEncryptedBalance) || + !sleDestinationMPToken->isFieldPresent(sfAuditorEncryptedBalance))) + return tefINTERNAL; // LCOV_EXCL_LINE + + // Check lock + MPTIssue const mptIssue(mptIssuanceID); + if (auto const ter = checkFrozen(ctx.view, account, mptIssue); !isTesSuccess(ter)) + return ter; + + if (auto const ter = checkFrozen(ctx.view, destination, mptIssue); !isTesSuccess(ter)) + return ter; + + // Check auth + if (auto const ter = requireAuth(ctx.view, mptIssue, account); !isTesSuccess(ter)) + return ter; + + if (auto const ter = requireAuth(ctx.view, mptIssue, destination); !isTesSuccess(ter)) + return ter; + + if (auto const err = credentials::valid(ctx.tx, ctx.view, ctx.tx[sfAccount], ctx.j); + !isTesSuccess(err)) + return err; + + return verifySendProofs(ctx, sleSenderMPToken, sleDestinationMPToken, sleIssuance); +} + +TER +ConfidentialMPTSend::doApply() +{ + auto const mptIssuanceID = ctx_.tx[sfMPTokenIssuanceID]; + auto const destination = ctx_.tx[sfDestination]; + + auto sleSenderMPToken = view().peek(keylet::mptoken(mptIssuanceID, account_)); + auto sleDestinationMPToken = view().peek(keylet::mptoken(mptIssuanceID, destination)); + + auto sleDestAcct = view().peek(keylet::account(destination)); + + if (!sleSenderMPToken || !sleDestinationMPToken || !sleDestAcct) + return tecINTERNAL; // LCOV_EXCL_LINE + + if (auto err = verifyDepositPreauth( + ctx_.tx, ctx_.view(), account_, destination, sleDestAcct, ctx_.journal); + !isTesSuccess(err)) + return err; + + Slice const senderEc = ctx_.tx[sfSenderEncryptedAmount]; + Slice const destEc = ctx_.tx[sfDestinationEncryptedAmount]; + Slice const issuerEc = ctx_.tx[sfIssuerEncryptedAmount]; + + auto const auditorEc = ctx_.tx[~sfAuditorEncryptedAmount]; + + // Subtract from sender's spending balance + { + Slice const curSpending = (*sleSenderMPToken)[sfConfidentialBalanceSpending]; + auto newSpending = homomorphicSubtract(curSpending, senderEc); + if (!newSpending) + return tecINTERNAL; // LCOV_EXCL_LINE + + (*sleSenderMPToken)[sfConfidentialBalanceSpending] = std::move(*newSpending); + } + + // Subtract from issuer's balance + { + Slice const curIssuerEnc = (*sleSenderMPToken)[sfIssuerEncryptedBalance]; + auto newIssuerEnc = homomorphicSubtract(curIssuerEnc, issuerEc); + if (!newIssuerEnc) + return tecINTERNAL; // LCOV_EXCL_LINE + + (*sleSenderMPToken)[sfIssuerEncryptedBalance] = std::move(*newIssuerEnc); + } + + // Subtract from auditor's balance if present + if (auditorEc) + { + Slice const curAuditorEnc = (*sleSenderMPToken)[sfAuditorEncryptedBalance]; + auto newAuditorEnc = homomorphicSubtract(curAuditorEnc, *auditorEc); + if (!newAuditorEnc) + return tecINTERNAL; // LCOV_EXCL_LINE + + (*sleSenderMPToken)[sfAuditorEncryptedBalance] = std::move(*newAuditorEnc); + } + + // Add to destination's inbox balance + { + Slice const curInbox = (*sleDestinationMPToken)[sfConfidentialBalanceInbox]; + auto newInbox = homomorphicAdd(curInbox, destEc); + if (!newInbox) + return tecINTERNAL; // LCOV_EXCL_LINE + + (*sleDestinationMPToken)[sfConfidentialBalanceInbox] = std::move(*newInbox); + } + + // Add to issuer's balance + { + Slice const curIssuerEnc = (*sleDestinationMPToken)[sfIssuerEncryptedBalance]; + auto newIssuerEnc = homomorphicAdd(curIssuerEnc, issuerEc); + if (!newIssuerEnc) + return tecINTERNAL; // LCOV_EXCL_LINE + + (*sleDestinationMPToken)[sfIssuerEncryptedBalance] = std::move(*newIssuerEnc); + } + + // Add to auditor's balance if present + if (auditorEc) + { + Slice const curAuditorEnc = (*sleDestinationMPToken)[sfAuditorEncryptedBalance]; + auto newAuditorEnc = homomorphicAdd(curAuditorEnc, *auditorEc); + if (!newAuditorEnc) + return tecINTERNAL; // LCOV_EXCL_LINE + + (*sleDestinationMPToken)[sfAuditorEncryptedBalance] = std::move(*newAuditorEnc); + } + + // increment sender version only; receiver version is not modified by incoming sends + incrementConfidentialVersion(*sleSenderMPToken); + + view().update(sleSenderMPToken); + view().update(sleDestinationMPToken); + return tesSUCCESS; +} + +} // namespace xrpl diff --git a/src/libxrpl/tx/transactors/token/MPTokenAuthorize.cpp b/src/libxrpl/tx/transactors/token/MPTokenAuthorize.cpp index 3519ad0db1..195df60de4 100644 --- a/src/libxrpl/tx/transactors/token/MPTokenAuthorize.cpp +++ b/src/libxrpl/tx/transactors/token/MPTokenAuthorize.cpp @@ -75,6 +75,25 @@ MPTokenAuthorize::preclaim(PreclaimContext const& ctx) if (ctx.view.rules().enabled(featureSingleAssetVault) && sleMpt->isFlag(lsfMPTLocked)) return tecNO_PERMISSION; + if (ctx.view.rules().enabled(featureConfidentialTransfer)) + { + auto const sleMptIssuance = + ctx.view.read(keylet::mptIssuance(ctx.tx[sfMPTokenIssuanceID])); + + // if there still existing encrypted balances of MPT in + // circulation + if (sleMptIssuance && + (*sleMptIssuance)[~sfConfidentialOutstandingAmount].value_or(0) != 0) + { + // this MPT still has encrypted balance, since we don't know + // if it's non-zero or not, we won't allow deletion of + // MPToken + if (sleMpt->isFieldPresent(sfConfidentialBalanceInbox) || + sleMpt->isFieldPresent(sfConfidentialBalanceSpending)) + return tecHAS_OBLIGATIONS; + } + } + return tesSUCCESS; } diff --git a/src/libxrpl/tx/transactors/token/MPTokenIssuanceCreate.cpp b/src/libxrpl/tx/transactors/token/MPTokenIssuanceCreate.cpp index 6bcb1175e8..c90d1f8f1c 100644 --- a/src/libxrpl/tx/transactors/token/MPTokenIssuanceCreate.cpp +++ b/src/libxrpl/tx/transactors/token/MPTokenIssuanceCreate.cpp @@ -18,6 +18,16 @@ MPTokenIssuanceCreate::checkExtraFeatures(PreflightContext const& ctx) if (ctx.tx.isFieldPresent(sfMutableFlags) && !ctx.rules.enabled(featureDynamicMPT)) return false; + if (ctx.tx.isFlag(tfMPTCanConfidentialAmount) && + !ctx.rules.enabled(featureConfidentialTransfer)) + return false; + + // can not set tmfMPTCannotMutateCanConfidentialAmount without featureConfidentialTransfer + auto const mutableFlags = ctx.tx[~sfMutableFlags]; + if (mutableFlags && (*mutableFlags & tmfMPTCannotMutateCanConfidentialAmount) && + !ctx.rules.enabled(featureConfidentialTransfer)) + return false; + return true; } diff --git a/src/libxrpl/tx/transactors/token/MPTokenIssuanceSet.cpp b/src/libxrpl/tx/transactors/token/MPTokenIssuanceSet.cpp index 67e0d9077d..9b4d288de8 100644 --- a/src/libxrpl/tx/transactors/token/MPTokenIssuanceSet.cpp +++ b/src/libxrpl/tx/transactors/token/MPTokenIssuanceSet.cpp @@ -1,4 +1,5 @@ #include +#include #include #include #include @@ -27,16 +28,23 @@ struct MPTMutabilityFlags { std::uint32_t setFlag; std::uint32_t clearFlag; - std::uint32_t canMutateFlag; + std::uint32_t mutabilityFlag; + std::uint32_t targetFlag; + bool isCannotMutate = false; // if true, cannot mutate by default. }; -static constexpr std::array mptMutabilityFlags = { - {{tmfMPTSetCanLock, tmfMPTClearCanLock, lsmfMPTCanMutateCanLock}, - {tmfMPTSetRequireAuth, tmfMPTClearRequireAuth, lsmfMPTCanMutateRequireAuth}, - {tmfMPTSetCanEscrow, tmfMPTClearCanEscrow, lsmfMPTCanMutateCanEscrow}, - {tmfMPTSetCanTrade, tmfMPTClearCanTrade, lsmfMPTCanMutateCanTrade}, - {tmfMPTSetCanTransfer, tmfMPTClearCanTransfer, lsmfMPTCanMutateCanTransfer}, - {tmfMPTSetCanClawback, tmfMPTClearCanClawback, lsmfMPTCanMutateCanClawback}}}; +static constexpr std::array mptMutabilityFlags = { + {{tmfMPTSetCanLock, tmfMPTClearCanLock, lsmfMPTCanMutateCanLock, lsfMPTCanLock}, + {tmfMPTSetRequireAuth, tmfMPTClearRequireAuth, lsmfMPTCanMutateRequireAuth, lsfMPTRequireAuth}, + {tmfMPTSetCanEscrow, tmfMPTClearCanEscrow, lsmfMPTCanMutateCanEscrow, lsfMPTCanEscrow}, + {tmfMPTSetCanTrade, tmfMPTClearCanTrade, lsmfMPTCanMutateCanTrade, lsfMPTCanTrade}, + {tmfMPTSetCanTransfer, tmfMPTClearCanTransfer, lsmfMPTCanMutateCanTransfer, lsfMPTCanTransfer}, + {tmfMPTSetCanClawback, tmfMPTClearCanClawback, lsmfMPTCanMutateCanClawback, lsfMPTCanClawback}, + {tmfMPTSetCanConfidentialAmount, + tmfMPTClearCanConfidentialAmount, + lsmfMPTCannotMutateCanConfidentialAmount, + lsfMPTCanConfidentialAmount, + true}}}; NotTEC MPTokenIssuanceSet::preflight(PreflightContext const& ctx) @@ -45,14 +53,28 @@ MPTokenIssuanceSet::preflight(PreflightContext const& ctx) auto const metadata = ctx.tx[~sfMPTokenMetadata]; auto const transferFee = ctx.tx[~sfTransferFee]; auto const isMutate = mutableFlags || metadata || transferFee; + auto const hasIssuerElGamalKey = ctx.tx.isFieldPresent(sfIssuerEncryptionKey); + auto const hasAuditorElGamalKey = ctx.tx.isFieldPresent(sfAuditorEncryptionKey); + auto const txFlags = ctx.tx.getFlags(); + + auto const mutatePrivacy = mutableFlags && + ((*mutableFlags & (tmfMPTSetCanConfidentialAmount | tmfMPTClearCanConfidentialAmount))); + + auto const hasDomain = ctx.tx.isFieldPresent(sfDomainID); + auto const hasHolder = ctx.tx.isFieldPresent(sfHolder); if (isMutate && !ctx.rules.enabled(featureDynamicMPT)) return temDISABLED; - if (ctx.tx.isFieldPresent(sfDomainID) && ctx.tx.isFieldPresent(sfHolder)) + if ((hasIssuerElGamalKey || hasAuditorElGamalKey || mutatePrivacy) && + !ctx.rules.enabled(featureConfidentialTransfer)) + return temDISABLED; + + if (hasDomain && hasHolder) return temMALFORMED; - auto const txFlags = ctx.tx.getFlags(); + if (mutatePrivacy && hasHolder) + return temMALFORMED; // fails if both flags are set if (((txFlags & tfMPTLock) != 0u) && ((txFlags & tfMPTUnlock) != 0u)) @@ -63,10 +85,12 @@ MPTokenIssuanceSet::preflight(PreflightContext const& ctx) if (holderID && accountID == holderID) return temMALFORMED; - if (ctx.rules.enabled(featureSingleAssetVault) || ctx.rules.enabled(featureDynamicMPT)) + if (ctx.rules.enabled(featureSingleAssetVault) || ctx.rules.enabled(featureDynamicMPT) || + ctx.rules.enabled(featureConfidentialTransfer)) { // Is this transaction actually changing anything ? - if (txFlags == 0 && !ctx.tx.isFieldPresent(sfDomainID) && !isMutate) + if (txFlags == 0 && !hasDomain && !hasIssuerElGamalKey && !hasAuditorElGamalKey && + !isMutate) return temMALFORMED; } @@ -107,6 +131,23 @@ MPTokenIssuanceSet::preflight(PreflightContext const& ctx) } } + if (hasHolder && (hasIssuerElGamalKey || hasAuditorElGamalKey)) + return temMALFORMED; + + if (hasAuditorElGamalKey && !hasIssuerElGamalKey) + return temMALFORMED; + + // Cannot set keys while clearing confidential amount + if ((hasIssuerElGamalKey || hasAuditorElGamalKey) && mutableFlags && + (*mutableFlags & tmfMPTClearCanConfidentialAmount)) + return temINVALID_FLAG; + + if (hasIssuerElGamalKey && !isValidCompressedECPoint(ctx.tx[sfIssuerEncryptionKey])) + return temMALFORMED; + + if (hasAuditorElGamalKey && !isValidCompressedECPoint(ctx.tx[sfAuditorEncryptionKey])) + return temMALFORMED; + return tesSUCCESS; } @@ -203,16 +244,30 @@ MPTokenIssuanceSet::preclaim(PreclaimContext const& ctx) return currentMutableFlags & mutableFlag; }; - if (auto const mutableFlags = ctx.tx[~sfMutableFlags]) + auto const mutableFlags = ctx.tx[~sfMutableFlags]; + if (mutableFlags) { if (std::any_of( mptMutabilityFlags.begin(), mptMutabilityFlags.end(), [mutableFlags, &isMutableFlag](auto const& f) { - return !isMutableFlag(f.canMutateFlag) && - ((*mutableFlags & (f.setFlag | f.clearFlag))); + bool const canMutate = f.isCannotMutate ? isMutableFlag(f.mutabilityFlag) + : !isMutableFlag(f.mutabilityFlag); + return canMutate && (*mutableFlags & (f.setFlag | f.clearFlag)); })) return tecNO_PERMISSION; + + if ((*mutableFlags & tmfMPTSetCanConfidentialAmount) || + (*mutableFlags & tmfMPTClearCanConfidentialAmount)) + { + std::uint64_t const confidentialOA = + (*sleMptIssuance)[~sfConfidentialOutstandingAmount].value_or(0); + + // If there's any confidential outstanding amount, disallow toggling + // the lsfMPTCanConfidentialAmount flag + if (confidentialOA > 0) + return tecNO_PERMISSION; + } } if (!isMutableFlag(lsmfMPTCanMutateMetadata) && ctx.tx.isFieldPresent(sfMPTokenMetadata)) @@ -231,6 +286,46 @@ MPTokenIssuanceSet::preclaim(PreclaimContext const& ctx) return tecNO_PERMISSION; } + // cannot update issuer public key + if (ctx.tx.isFieldPresent(sfIssuerEncryptionKey) && + sleMptIssuance->isFieldPresent(sfIssuerEncryptionKey)) + { + return tecNO_PERMISSION; + } + + // cannot update auditor public key + if (ctx.tx.isFieldPresent(sfAuditorEncryptionKey) && + sleMptIssuance->isFieldPresent(sfAuditorEncryptionKey)) + { + return tecNO_PERMISSION; // LCOV_EXCL_LINE + } + + // Check if the transaction is enabling confidential amounts + bool const enablesConfidentialAmount = + mutableFlags && (*mutableFlags & tmfMPTSetCanConfidentialAmount); + + // Encryption keys can only be set if confidential amounts are already + // enabled on the issuance OR if the transaction is enabling it + if (ctx.tx.isFieldPresent(sfIssuerEncryptionKey) && + !sleMptIssuance->isFlag(lsfMPTCanConfidentialAmount) && !enablesConfidentialAmount) + { + return tecNO_PERMISSION; + } + + if (ctx.tx.isFieldPresent(sfAuditorEncryptionKey) && + !sleMptIssuance->isFlag(lsfMPTCanConfidentialAmount) && !enablesConfidentialAmount) + { + return tecNO_PERMISSION; + } + + // cannot upload key if there's circulating supply of COA + if ((ctx.tx.isFieldPresent(sfIssuerEncryptionKey) || + ctx.tx.isFieldPresent(sfAuditorEncryptionKey)) && + sleMptIssuance->isFieldPresent(sfConfidentialOutstandingAmount)) + { + return tecNO_PERMISSION; // LCOV_EXCL_LINE + } + return tesSUCCESS; } @@ -273,11 +368,11 @@ MPTokenIssuanceSet::doApply() { if ((mutableFlags & f.setFlag) != 0u) { - flagsOut |= f.canMutateFlag; + flagsOut |= f.targetFlag; } else if ((mutableFlags & f.clearFlag) != 0u) { - flagsOut &= ~f.canMutateFlag; + flagsOut &= ~f.targetFlag; } } @@ -338,6 +433,26 @@ MPTokenIssuanceSet::doApply() } } + if (auto const pubKey = ctx_.tx[~sfIssuerEncryptionKey]) + { + // This is enforced in preflight. + XRPL_ASSERT( + sle->getType() == ltMPTOKEN_ISSUANCE, + "MPTokenIssuanceSet::doApply : modifying MPTokenIssuance"); + + sle->setFieldVL(sfIssuerEncryptionKey, *pubKey); + } + + if (auto const pubKey = ctx_.tx[~sfAuditorEncryptionKey]) + { + // This is enforced in preflight. + XRPL_ASSERT( + sle->getType() == ltMPTOKEN_ISSUANCE, + "MPTokenIssuanceSet::doApply : modifying MPTokenIssuance"); + + sle->setFieldVL(sfAuditorEncryptionKey, *pubKey); + } + view().update(sle); return tesSUCCESS; diff --git a/src/test/app/ConfidentialTransfer_test.cpp b/src/test/app/ConfidentialTransfer_test.cpp new file mode 100644 index 0000000000..0c7c01b33e --- /dev/null +++ b/src/test/app/ConfidentialTransfer_test.cpp @@ -0,0 +1,8784 @@ +#include +#include +#include +#include + +#include +#include + +#include + +namespace xrpl { + +class ConfidentialTransfer_test : public beast::unit_test::suite +{ + // Get a bad ciphertext with valid structure but cryptographic invalid for + // testing purposes. For preflight test purposes. + static Buffer const& + getBadCiphertext() + { + static Buffer const badCiphertext = []() { + Buffer buf(ecGamalEncryptedTotalLength); + std::memset(buf.data(), 0xFF, ecGamalEncryptedTotalLength); + + buf.data()[0] = ecCompressedPrefixEvenY; + buf.data()[ecGamalEncryptedLength] = ecCompressedPrefixEvenY; + return buf; + }(); + + return badCiphertext; + } + + // 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 trivialCiphertext = []() { + Buffer buf(ecGamalEncryptedTotalLength); + std::memset(buf.data(), 0, ecGamalEncryptedTotalLength); + + buf.data()[0] = ecCompressedPrefixEvenY; + buf.data()[ecGamalEncryptedLength] = ecCompressedPrefixEvenY; + + buf.data()[ecGamalEncryptedLength - 1] = 0x01; + buf.data()[ecGamalEncryptedTotalLength - 1] = 0x01; + + return buf; + }(); + + return trivialCiphertext; + } + + // 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 trivialCommitment = []() { + Buffer buf(ecPedersenCommitmentLength); + std::memset(buf.data(), 0, ecPedersenCommitmentLength); + + buf.data()[0] = ecCompressedPrefixEvenY; + // Set last byte to make it a valid x-coordinate on the curve + buf.data()[ecPedersenCommitmentLength - 1] = 0x01; + + return buf; + }(); + + return trivialCommitment; + } + + std::string + getTrivialSendProofHex(size_t nRecipients) + { + size_t const sizeEquality = getEqualityProofSize(nRecipients); + size_t const totalSize = + sizeEquality + (2 * ecPedersenProofLength) + ecDoubleBulletproofLength; + + Buffer buf(totalSize); + std::memset(buf.data(), 0, totalSize); + + for (std::size_t i = 0; i < totalSize; i += ecGamalEncryptedLength) + { + buf.data()[i] = ecCompressedPrefixEvenY; + if (i + ecGamalEncryptedLength - 1 < totalSize) + buf.data()[i + ecGamalEncryptedLength - 1] = 0x01; + } + + return strHex(buf); + } + + void + testConvert(FeatureBitset features) + { + testcase("Convert"); + using namespace test::jtx; + + // Basic convert test + { + Env env{*this, features}; + Account const alice("alice"); + Account const bob("bob"); + MPTTester mptAlice(env, alice, {.holders = {bob}}); + + mptAlice.create({ + .ownerCount = 1, + .flags = tfMPTCanTransfer | tfMPTCanLock | tfMPTCanConfidentialAmount, + }); + + mptAlice.authorize({ + .account = bob, + }); + mptAlice.pay(alice, bob, 100); + + mptAlice.generateKeyPair(alice); + + mptAlice.set({.account = alice, .issuerPubKey = mptAlice.getPubKey(alice)}); + + mptAlice.generateKeyPair(bob); + + mptAlice.convert({ + .account = bob, + .amt = 0, + .holderPubKey = mptAlice.getPubKey(bob), + }); + + mptAlice.convert({ + .account = bob, + .amt = 20, + }); + + mptAlice.convert({ + .account = bob, + .amt = 40, + }); + + mptAlice.convert({ + .account = bob, + .amt = 40, + }); + } + + // Edge case: minimum amount (1) + { + Env env{*this, features}; + Account const alice("alice"); + Account const bob("bob"); + MPTTester mptAlice(env, alice, {.holders = {bob}}); + + mptAlice.create({ + .ownerCount = 1, + .flags = tfMPTCanTransfer | tfMPTCanLock | tfMPTCanConfidentialAmount, + }); + + mptAlice.authorize({ + .account = bob, + }); + mptAlice.pay(alice, bob, 1); + + mptAlice.generateKeyPair(alice); + mptAlice.set({.account = alice, .issuerPubKey = mptAlice.getPubKey(alice)}); + + mptAlice.generateKeyPair(bob); + mptAlice.convert({ + .account = bob, + .amt = 0, + .holderPubKey = mptAlice.getPubKey(bob), + }); + + mptAlice.convert({ + .account = bob, + .amt = 1, + }); + } + + // Edge case: maxMPTokenAmount + // Using raw JSON to avoid automatic decryption checks in MPTTester + // which don't work for very large amounts (brute-force decryption is slow) + { + Env env{*this, features}; + Account const alice("alice"); + Account const bob("bob"); + MPTTester mptAlice(env, alice, {.holders = {bob}}); + + mptAlice.create({ + .ownerCount = 1, + .flags = tfMPTCanTransfer | tfMPTCanLock | tfMPTCanConfidentialAmount, + }); + + mptAlice.authorize({ + .account = bob, + }); + mptAlice.pay(alice, bob, maxMPTokenAmount); + + mptAlice.generateKeyPair(alice); + mptAlice.set({.account = alice, .issuerPubKey = mptAlice.getPubKey(alice)}); + + mptAlice.generateKeyPair(bob); + + // First convert with amt=0 to register public key (uses MPTTester) + mptAlice.convert({ + .account = bob, + .amt = 0, + .holderPubKey = mptAlice.getPubKey(bob), + }); + + // Second convert with maxMPTokenAmount using raw JSON + Buffer const blindingFactor = generateBlindingFactor(); + auto const holderCiphertext = + mptAlice.encryptAmount(bob, maxMPTokenAmount, blindingFactor); + auto const issuerCiphertext = + mptAlice.encryptAmount(alice, maxMPTokenAmount, blindingFactor); + + Json::Value jv; + jv[jss::Account] = bob.human(); + jv[jss::TransactionType] = jss::ConfidentialMPTConvert; + jv[sfMPTokenIssuanceID] = to_string(mptAlice.issuanceID()); + jv[sfMPTAmount.jsonName] = std::to_string(maxMPTokenAmount); + jv[sfHolderEncryptedAmount.jsonName] = strHex(holderCiphertext); + jv[sfIssuerEncryptedAmount.jsonName] = strHex(issuerCiphertext); + jv[sfBlindingFactor.jsonName] = strHex(blindingFactor); + + env(jv, ter(tesSUCCESS)); + + // Verify the public balance was reduced + env.require(mptbalance(mptAlice, bob, 0)); + } + } + + void + testConvertWithAuditor(FeatureBitset features) + { + testcase("Convert with auditor"); + using namespace test::jtx; + + Env env{*this, features}; + Account const alice("alice"); + Account const bob("bob"); + Account const auditor("auditor"); + MPTTester mptAlice( + env, + alice, + { + .holders = {bob}, + .auditor = auditor, + }); + + mptAlice.create({ + .ownerCount = 1, + .flags = tfMPTCanTransfer | tfMPTCanLock | tfMPTCanConfidentialAmount, + }); + + mptAlice.authorize({ + .account = bob, + }); + mptAlice.pay(alice, bob, 100); + + mptAlice.generateKeyPair(alice); + mptAlice.generateKeyPair(auditor); + + mptAlice.set( + {.account = alice, + .issuerPubKey = mptAlice.getPubKey(alice), + .auditorPubKey = mptAlice.getPubKey(auditor)}); + + mptAlice.generateKeyPair(bob); + + mptAlice.convert({ + .account = bob, + .amt = 0, + .holderPubKey = mptAlice.getPubKey(bob), + }); + + mptAlice.convert({ + .account = bob, + .amt = 20, + }); + + mptAlice.convert({ + .account = bob, + .amt = 30, + }); + } + + void + testConvertPreflight(FeatureBitset features) + { + testcase("Convert preflight"); + using namespace test::jtx; + + // Alice (issuer) tries to convert her own tokens - should fail + { + Env env{*this, features}; + Account const alice("alice"); + MPTTester mptAlice(env, alice); + + mptAlice.create({ + .flags = tfMPTCanTransfer | tfMPTCanLock | tfMPTCanConfidentialAmount, + }); + mptAlice.generateKeyPair(alice); + + mptAlice.convert({ + .account = alice, + .amt = 10, + .holderPubKey = mptAlice.getPubKey(alice), + .err = temMALFORMED, + }); + } + + { + Env env{*this, features - featureConfidentialTransfer}; + Account const alice("alice"); + Account const bob("bob"); + MPTTester mptAlice(env, alice, {.holders = {bob}}); + + mptAlice.create({ + .ownerCount = 1, + .flags = tfMPTCanTransfer | tfMPTCanLock, + }); + + mptAlice.authorize({ + .account = bob, + }); + mptAlice.pay(alice, bob, 100); + + mptAlice.generateKeyPair(alice); + mptAlice.generateKeyPair(bob); + + mptAlice.set({ + .account = alice, + .issuerPubKey = mptAlice.getPubKey(alice), + .err = temDISABLED, + }); + + mptAlice.convert({ + .account = bob, + .amt = 10, + .holderPubKey = mptAlice.getPubKey(bob), + .err = temDISABLED, + }); + } + + { + Env env{*this, features}; + Account const alice("alice"); + Account const bob("bob"); + MPTTester mptAlice(env, alice, {.holders = {bob}}); + + mptAlice.create({ + .ownerCount = 1, + .flags = tfMPTCanTransfer | tfMPTCanLock, + }); + + mptAlice.authorize({ + .account = bob, + }); + mptAlice.pay(alice, bob, 100); + + mptAlice.generateKeyPair(alice); + mptAlice.generateKeyPair(bob); + + mptAlice.convert({ + .account = alice, + .amt = 10, + .holderPubKey = mptAlice.getPubKey(bob), + .err = temMALFORMED, + }); + + // Holder encrypted amount is empty (length 0) + mptAlice.convert({ + .account = bob, + .amt = 10, + .holderPubKey = mptAlice.getPubKey(bob), + .holderEncryptedAmt = Buffer{}, + .err = temBAD_CIPHERTEXT, + }); + + // Issuer encrypted amount is empty (length 0) + mptAlice.convert({ + .account = bob, + .amt = 10, + .holderPubKey = mptAlice.getPubKey(bob), + .issuerEncryptedAmt = Buffer{}, + .err = temBAD_CIPHERTEXT, + }); + + // Auditor encrypted amount has invalid length (must be 66 bytes) + mptAlice.convert({ + .account = bob, + .amt = 10, + .holderPubKey = mptAlice.getPubKey(bob), + .auditorEncryptedAmt = makeZeroBuffer(10), + .err = temBAD_CIPHERTEXT, + }); + + // Auditor encrypted amount has correct length but invalid data + mptAlice.convert({ + .account = bob, + .amt = 10, + .holderPubKey = mptAlice.getPubKey(bob), + .auditorEncryptedAmt = getBadCiphertext(), + .err = temBAD_CIPHERTEXT, + }); + + // Amount exceeds maximum allowed MPT amount + mptAlice.convert({ + .account = bob, + .amt = maxMPTokenAmount + 1, + .holderPubKey = mptAlice.getPubKey(bob), + .err = temBAD_AMOUNT, + }); + + // Holder encrypted amount has correct length but invalid data + mptAlice.convert({ + .account = bob, + .amt = 1, + .holderPubKey = mptAlice.getPubKey(bob), + .holderEncryptedAmt = getBadCiphertext(), + .err = temBAD_CIPHERTEXT, + }); + + // Issuer encrypted amount has correct length but invalid data (not + // a valid EC point) + mptAlice.convert({ + .account = bob, + .amt = 1, + .holderPubKey = mptAlice.getPubKey(bob), + .issuerEncryptedAmt = getBadCiphertext(), + .err = temBAD_CIPHERTEXT, + }); + + // Holder public key is invalid (empty buffer) + mptAlice.convert({ + .account = bob, + .amt = 10, + .holderPubKey = Buffer{}, + .err = temMALFORMED, + }); + + // Holder public key has correct length but invalid EC point data + mptAlice.convert({ + .account = bob, + .amt = 10, + .holderPubKey = makeZeroBuffer(ecPubKeyLength), + .err = temMALFORMED, + }); + } + + // when registering holder pub key, the transaction must include a + // Schnorr proof of knowledge for the corresponding secret key + { + Env env{*this, features}; + Account const alice("alice"); + Account const bob("bob"); + MPTTester mptAlice(env, alice, {.holders = {bob}}); + + mptAlice.create({ + .ownerCount = 1, + .flags = tfMPTCanTransfer | tfMPTCanLock | tfMPTCanConfidentialAmount, + }); + + mptAlice.authorize({ + .account = bob, + }); + mptAlice.pay(alice, bob, 100); + + mptAlice.generateKeyPair(alice); + mptAlice.generateKeyPair(bob); + + mptAlice.set({.account = alice, .issuerPubKey = mptAlice.getPubKey(alice)}); + + mptAlice.convert({ + .account = bob, + .amt = 10, + .fillSchnorrProof = false, + .holderPubKey = mptAlice.getPubKey(bob), + .err = temMALFORMED, + }); + + mptAlice.convert({ + .account = bob, + .amt = 0, + .fillSchnorrProof = false, + .holderPubKey = mptAlice.getPubKey(bob), + .err = temMALFORMED, + }); + + // proof length is invalid + mptAlice.convert({ + .account = bob, + .amt = 10, + .proof = std::string(10, 'A'), + .holderPubKey = mptAlice.getPubKey(bob), + .err = temMALFORMED, + }); + } + + // when holder pub key already registered, Schnorr proof must not be + // provided + { + Env env{*this, features}; + Account const alice("alice"); + Account const bob("bob"); + MPTTester mptAlice(env, alice, {.holders = {bob}}); + + mptAlice.create({ + .ownerCount = 1, + .flags = tfMPTCanTransfer | tfMPTCanLock | tfMPTCanConfidentialAmount, + }); + + mptAlice.authorize({ + .account = bob, + }); + mptAlice.pay(alice, bob, 100); + + mptAlice.generateKeyPair(alice); + mptAlice.generateKeyPair(bob); + + mptAlice.set({.account = alice, .issuerPubKey = mptAlice.getPubKey(alice)}); + + // this will register bob's pub key, + // and convert 10 to confidential balance + mptAlice.convert({ + .account = bob, + .amt = 10, + .holderPubKey = mptAlice.getPubKey(bob), + }); + + // proof must not be provided after pub key was registered + mptAlice.convert({ + .account = bob, + .amt = 20, + .fillSchnorrProof = true, + .err = temMALFORMED, + }); + } + } + + void + testSet(FeatureBitset features) + { + testcase("Set"); + using namespace test::jtx; + + // Set keys on issuance that already has confidential amounts enabled + { + Env env{*this, features}; + Account const alice("alice"); + Account const auditor("auditor"); + MPTTester mptAlice(env, alice, {.holders = {}, .auditor = auditor}); + + mptAlice.create({ + .ownerCount = 1, + .flags = tfMPTCanTransfer | tfMPTCanLock | tfMPTCanConfidentialAmount, + }); + + mptAlice.generateKeyPair(alice); + mptAlice.generateKeyPair(auditor); + + mptAlice.set({ + .account = alice, + .issuerPubKey = mptAlice.getPubKey(alice), + .auditorPubKey = mptAlice.getPubKey(auditor), + }); + } + + // Enable confidential amounts flag only (no keys) + { + Env env{*this, features}; + Account const alice("alice"); + MPTTester mptAlice(env, alice, {.holders = {}}); + + mptAlice.create({ + .ownerCount = 1, + .flags = tfMPTCanTransfer | tfMPTCanLock, + }); + + mptAlice.set({ + .account = alice, + .mutableFlags = tmfMPTSetCanConfidentialAmount, + }); + } + + // Set keys when enabling confidential amounts in the same tx + { + Env env{*this, features}; + Account const alice("alice"); + Account const auditor("auditor"); + MPTTester mptAlice(env, alice, {.holders = {}, .auditor = auditor}); + + mptAlice.create({ + .ownerCount = 1, + .flags = tfMPTCanTransfer | tfMPTCanLock, + }); + + mptAlice.generateKeyPair(alice); + mptAlice.generateKeyPair(auditor); + + mptAlice.set({ + .account = alice, + .mutableFlags = tmfMPTSetCanConfidentialAmount, + .issuerPubKey = mptAlice.getPubKey(alice), + .auditorPubKey = mptAlice.getPubKey(auditor), + }); + + // Verify lsfMPTCanConfidentialAmount flag is set + BEAST_EXPECT(mptAlice.checkFlags( + lsfMPTCanTransfer | lsfMPTCanLock | lsfMPTCanConfidentialAmount)); + + // Verify keys are persisted on the issuance + auto const sle = env.le(keylet::mptIssuance(mptAlice.issuanceID())); + BEAST_EXPECT(sle); + BEAST_EXPECT(sle->isFieldPresent(sfIssuerEncryptionKey)); + BEAST_EXPECT(sle->isFieldPresent(sfAuditorEncryptionKey)); + } + } + + void + testSetPreflight(FeatureBitset features) + { + testcase("Set preflight"); + using namespace test::jtx; + + { + Env env{*this, features - featureConfidentialTransfer}; + Account const alice("alice"); + Account const bob("bob"); + MPTTester mptAlice(env, alice, {.holders = {bob}}); + + mptAlice.create({ + .ownerCount = 1, + .flags = tfMPTCanTransfer | tfMPTCanLock, + }); + + mptAlice.authorize({ + .account = bob, + }); + mptAlice.pay(alice, bob, 100); + + mptAlice.generateKeyPair(alice); + mptAlice.generateKeyPair(bob); + + mptAlice.set({ + .account = alice, + .issuerPubKey = mptAlice.getPubKey(alice), + .err = temDISABLED, + }); + } + + // pub key is invalid + { + Env env{*this, features}; + Account const alice("alice"); + Account const bob("bob"); + MPTTester mptAlice(env, alice, {.holders = {bob}}); + + mptAlice.create({ + .ownerCount = 1, + .flags = tfMPTCanTransfer | tfMPTCanLock | tfMPTCanConfidentialAmount, + }); + + mptAlice.authorize({ + .account = bob, + }); + mptAlice.pay(alice, bob, 100); + + mptAlice.generateKeyPair(alice); + mptAlice.generateKeyPair(bob); + + // Issuer pub key is invalid (empty) + mptAlice.set({ + .account = alice, + .issuerPubKey = Buffer{}, + .err = temMALFORMED, + }); + + // Issuer pub key has correct length but invalid EC point data + mptAlice.set({ + .account = alice, + .issuerPubKey = makeZeroBuffer(ecPubKeyLength), + .err = temMALFORMED, + }); + + // Auditor key is invalid length + mptAlice.set({ + .account = alice, + .issuerPubKey = mptAlice.getPubKey(alice), + .auditorPubKey = makeZeroBuffer(10), + .err = temMALFORMED, + }); + + // Auditor key has correct length but invalid EC point data + mptAlice.set({ + .account = alice, + .issuerPubKey = mptAlice.getPubKey(alice), + .auditorPubKey = makeZeroBuffer(ecPubKeyLength), + .err = temMALFORMED, + }); + + // Cannot set auditor key without issuer key + mptAlice.set({ + .account = alice, + .auditorPubKey = mptAlice.getPubKey(alice), + .err = temMALFORMED, + }); + + // Cannot set Holder and issuer Keys in the same transaction + mptAlice.set({ + .account = alice, + .holder = bob, + .issuerPubKey = mptAlice.getPubKey(alice), + .err = temMALFORMED, + }); + + // Cannot set keys while clearing confidential amount + mptAlice.set({ + .account = alice, + .mutableFlags = tmfMPTClearCanConfidentialAmount, + .issuerPubKey = mptAlice.getPubKey(alice), + .err = temINVALID_FLAG, + }); + + // Cannot set Holder and auditor Keys in the same transaction + mptAlice.set({ + .account = alice, + .holder = bob, + .auditorPubKey = mptAlice.getPubKey(alice), + .err = temMALFORMED, + }); + } + } + + void + testSetPreclaim(FeatureBitset features) + { + testcase("Set preclaim"); + using namespace test::jtx; + + // Cannot set issuer key if confidential amounts not enabled + { + Env env{*this, features}; + Account const alice("alice"); + MPTTester mptAlice(env, alice, {.holders = {}}); + + mptAlice.create({ + .ownerCount = 1, + .flags = tfMPTCanTransfer | tfMPTCanLock, + }); + + mptAlice.generateKeyPair(alice); + + mptAlice.set({ + .account = alice, + .issuerPubKey = mptAlice.getPubKey(alice), + .err = tecNO_PERMISSION, + }); + } + + // Cannot update issuer public key once set + { + Env env{*this, features}; + Account const alice("alice"); + Account const bob("bob"); + MPTTester mptAlice(env, alice, {.holders = {bob}}); + + mptAlice.create({ + .ownerCount = 1, + .flags = tfMPTCanTransfer | tfMPTCanLock | tfMPTCanConfidentialAmount, + }); + + mptAlice.generateKeyPair(alice); + mptAlice.generateKeyPair(bob); + + // First set issuer key - should succeed + mptAlice.set({ + .account = alice, + .issuerPubKey = mptAlice.getPubKey(alice), + }); + + // Try to update issuer key - should fail + mptAlice.set({ + .account = alice, + .issuerPubKey = mptAlice.getPubKey(bob), + .err = tecNO_PERMISSION, + }); + } + + // Cannot update issuer and auditor public keys once set + // Note: trying to set only auditor key fails in preflight (temMALFORMED) + // so we must provide both keys, which fails on issuer key check first + { + Env env{*this, features}; + Account const alice("alice"); + Account const bob("bob"); + Account const auditor("auditor"); + MPTTester mptAlice(env, alice, {.holders = {bob}, .auditor = auditor}); + + mptAlice.create({ + .ownerCount = 1, + .flags = tfMPTCanTransfer | tfMPTCanLock | tfMPTCanConfidentialAmount, + }); + + mptAlice.generateKeyPair(alice); + mptAlice.generateKeyPair(bob); + mptAlice.generateKeyPair(auditor); + + // Set issuer and auditor keys - should succeed + mptAlice.set({ + .account = alice, + .issuerPubKey = mptAlice.getPubKey(alice), + .auditorPubKey = mptAlice.getPubKey(auditor), + }); + + // Try to update both keys - fails on issuer key check first + mptAlice.set({ + .account = alice, + .issuerPubKey = mptAlice.getPubKey(bob), + .auditorPubKey = mptAlice.getPubKey(alice), + .err = tecNO_PERMISSION, + }); + } + + // Cannot set auditor key if confidential amounts not enabled + { + Env env{*this, features}; + Account const alice("alice"); + Account const auditor("auditor"); + MPTTester mptAlice(env, alice, {.holders = {}, .auditor = auditor}); + + mptAlice.create({ + .ownerCount = 1, + .flags = tfMPTCanTransfer | tfMPTCanLock, + }); + + mptAlice.generateKeyPair(alice); + mptAlice.generateKeyPair(auditor); + + mptAlice.set({ + .account = alice, + .issuerPubKey = mptAlice.getPubKey(alice), + .auditorPubKey = mptAlice.getPubKey(auditor), + .err = tecNO_PERMISSION, + }); + } + + // Cannot set keys when mutation of canConfidentialAmount is disallowed + { + Env env{*this, features}; + Account const alice("alice"); + MPTTester mptAlice(env, alice, {.holders = {}}); + + // Create with tmfMPTCannotMutateCanConfidentialAmount + mptAlice.create({ + .ownerCount = 1, + .flags = tfMPTCanTransfer | tfMPTCanLock, + .mutableFlags = tmfMPTCannotMutateCanConfidentialAmount, + }); + + mptAlice.generateKeyPair(alice); + + // Trying to enable confidential amounts and set keys fails + // because the issuance cannot mutate canConfidentialAmount + mptAlice.set({ + .account = alice, + .mutableFlags = tmfMPTSetCanConfidentialAmount, + .issuerPubKey = mptAlice.getPubKey(alice), + .err = tecNO_PERMISSION, + }); + } + + // Set issuer key first, then auditor key in a separate tx + { + Env env{*this, features}; + Account const alice("alice"); + Account const auditor("auditor"); + MPTTester mptAlice(env, alice, {.holders = {}, .auditor = auditor}); + + mptAlice.create({ + .ownerCount = 1, + .flags = tfMPTCanTransfer | tfMPTCanLock | tfMPTCanConfidentialAmount, + }); + + mptAlice.generateKeyPair(alice); + mptAlice.generateKeyPair(auditor); + + // Set issuer key only + mptAlice.set({ + .account = alice, + .issuerPubKey = mptAlice.getPubKey(alice), + }); + + // Set auditor key in a separate tx - requires issuer key in tx + // (preflight enforces auditor key requires issuer key) + // This fails because issuer key is already set on ledger + mptAlice.set({ + .account = alice, + .issuerPubKey = mptAlice.getPubKey(alice), + .auditorPubKey = mptAlice.getPubKey(auditor), + .err = tecNO_PERMISSION, + }); + } + } + + void + testConvertPreclaim(FeatureBitset features) + { + testcase("Convert preclaim"); + using namespace test::jtx; + + // tfMPTCanConfidentialAmount is not set on issuance + { + Env env{*this, features}; + Account const alice("alice"); + Account const bob("bob"); + MPTTester mptAlice(env, alice, {.holders = {bob}}); + + mptAlice.create({ + .ownerCount = 1, + .flags = tfMPTCanTransfer | tfMPTCanLock, + }); + + mptAlice.authorize({ + .account = bob, + }); + mptAlice.pay(alice, bob, 100); + + mptAlice.generateKeyPair(alice); + mptAlice.generateKeyPair(bob); + + mptAlice.convert({ + .account = bob, + .amt = 10, + .holderPubKey = mptAlice.getPubKey(bob), + .err = tecNO_PERMISSION, + }); + } + + // issuer has not uploaded their sfIssuerEncryptionKey + { + Env env{*this, features}; + Account const alice("alice"); + Account const bob("bob"); + MPTTester mptAlice(env, alice, {.holders = {bob}}); + + mptAlice.create({ + .ownerCount = 1, + .flags = tfMPTCanTransfer | tfMPTCanLock | tfMPTCanConfidentialAmount, + }); + + mptAlice.authorize({ + .account = bob, + }); + mptAlice.pay(alice, bob, 100); + + mptAlice.generateKeyPair(alice); + mptAlice.generateKeyPair(bob); + + mptAlice.convert({ + .account = bob, + .amt = 10, + .holderPubKey = mptAlice.getPubKey(bob), + .err = tecNO_PERMISSION, + }); + } + + // issuance does not exist + { + Env env{*this, features}; + Account const alice("alice"); + Account const bob("bob"); + MPTTester mptAlice(env, alice, {.holders = {bob}}); + + mptAlice.create({ + .ownerCount = 1, + .flags = tfMPTCanTransfer | tfMPTCanLock | tfMPTCanConfidentialAmount, + }); + + mptAlice.authorize({ + .account = bob, + }); + mptAlice.generateKeyPair(alice); + + mptAlice.set({.account = alice, .issuerPubKey = mptAlice.getPubKey(alice)}); + + mptAlice.destroy(); + mptAlice.generateKeyPair(bob); + + mptAlice.convert({ + .account = bob, + .amt = 10, + .holderPubKey = mptAlice.getPubKey(bob), + .err = tecOBJECT_NOT_FOUND, + }); + } + + // bob has not created MPToken + { + Env env{*this, features}; + Account const alice("alice"); + Account const bob("bob"); + MPTTester mptAlice(env, alice, {.holders = {bob}}); + + mptAlice.create({ + .ownerCount = 1, + .flags = tfMPTCanTransfer | tfMPTCanLock | tfMPTCanConfidentialAmount, + }); + + mptAlice.generateKeyPair(alice); + mptAlice.generateKeyPair(bob); + + mptAlice.set({.account = alice, .issuerPubKey = mptAlice.getPubKey(alice)}); + + mptAlice.convert({ + .account = bob, + .amt = 10, + .holderPubKey = mptAlice.getPubKey(bob), + .err = tecOBJECT_NOT_FOUND, + }); + } + + // Verification of Issuer and and holder ciphertexts + { + Env env{*this, features}; + Account const alice("alice"); + Account const bob("bob"); + MPTTester mptAlice(env, alice, {.holders = {bob}}); + + mptAlice.create({ + .ownerCount = 1, + .flags = tfMPTCanTransfer | tfMPTCanLock | tfMPTCanConfidentialAmount, + }); + + mptAlice.authorize({ + .account = bob, + }); + mptAlice.pay(alice, bob, 100); + + mptAlice.generateKeyPair(alice); + mptAlice.generateKeyPair(bob); + + mptAlice.set({.account = alice, .issuerPubKey = mptAlice.getPubKey(alice)}); + + mptAlice.convert({ + .account = bob, + .amt = 10, + .holderPubKey = mptAlice.getPubKey(bob), + .holderEncryptedAmt = getTrivialCiphertext(), + .err = tecBAD_PROOF, + }); + + mptAlice.convert({ + .account = bob, + .amt = 10, + .holderPubKey = mptAlice.getPubKey(bob), + .issuerEncryptedAmt = getTrivialCiphertext(), + .err = tecBAD_PROOF, + }); + } + + // trying to convert more than what bob has + { + Env env{*this, features}; + Account const alice("alice"); + Account const bob("bob"); + MPTTester mptAlice(env, alice, {.holders = {bob}}); + + mptAlice.create({ + .ownerCount = 1, + .flags = tfMPTCanTransfer | tfMPTCanLock | tfMPTCanConfidentialAmount, + }); + + mptAlice.authorize({ + .account = bob, + }); + mptAlice.pay(alice, bob, 100); + + mptAlice.generateKeyPair(alice); + + mptAlice.set({.account = alice, .issuerPubKey = mptAlice.getPubKey(alice)}); + + mptAlice.generateKeyPair(bob); + + mptAlice.convert({ + .account = bob, + .amt = 200, + .holderPubKey = mptAlice.getPubKey(bob), + .err = tecINSUFFICIENT_FUNDS, + }); + } + + // holder cannot upload pk again + { + Env env{*this, features}; + Account const alice("alice"); + Account const bob("bob"); + MPTTester mptAlice(env, alice, {.holders = {bob}}); + + mptAlice.create({ + .ownerCount = 1, + .flags = tfMPTCanTransfer | tfMPTCanLock | tfMPTCanConfidentialAmount, + }); + + mptAlice.authorize({ + .account = bob, + }); + mptAlice.pay(alice, bob, 100); + + mptAlice.generateKeyPair(alice); + + mptAlice.set({.account = alice, .issuerPubKey = mptAlice.getPubKey(alice)}); + + mptAlice.generateKeyPair(bob); + + mptAlice.convert({.account = bob, .amt = 10, .holderPubKey = mptAlice.getPubKey(bob)}); + + // cannot upload pk again + mptAlice.convert({ + .account = bob, + .amt = 10, + .holderPubKey = mptAlice.getPubKey(bob), + .err = tecDUPLICATE, + }); + } + + // cannot convert if locked + { + Env env{*this, features}; + Account const alice("alice"); + Account const bob("bob"); + MPTTester mptAlice(env, alice, {.holders = {bob}}); + + mptAlice.create({ + .ownerCount = 1, + .flags = tfMPTCanTransfer | tfMPTCanLock | tfMPTCanConfidentialAmount, + }); + + mptAlice.authorize({ + .account = bob, + }); + mptAlice.pay(alice, bob, 100); + + mptAlice.generateKeyPair(alice); + + mptAlice.set({.account = alice, .issuerPubKey = mptAlice.getPubKey(alice)}); + + mptAlice.set({ + .account = alice, + .holder = bob, + .flags = tfMPTLock, + }); + + mptAlice.generateKeyPair(bob); + + mptAlice.convert({ + .account = bob, + .amt = 10, + .holderPubKey = mptAlice.getPubKey(bob), + .err = tecLOCKED, + }); + + mptAlice.set({ + .account = alice, + .holder = bob, + .flags = tfMPTUnlock, + }); + + mptAlice.convert({ + .account = bob, + .amt = 10, + .holderPubKey = mptAlice.getPubKey(bob), + }); + } + + // cannot convert if unauth + { + Env env{*this, features}; + Account const alice("alice"); + Account const bob("bob"); + MPTTester mptAlice(env, alice, {.holders = {bob}}); + + mptAlice.create({ + .ownerCount = 1, + .flags = + tfMPTCanTransfer | tfMPTCanLock | tfMPTRequireAuth | tfMPTCanConfidentialAmount, + }); + + mptAlice.authorize({ + .account = bob, + }); + mptAlice.authorize({ + .account = alice, + .holder = bob, + }); + mptAlice.pay(alice, bob, 100); + + mptAlice.generateKeyPair(alice); + + mptAlice.set({.account = alice, .issuerPubKey = mptAlice.getPubKey(alice)}); + + mptAlice.generateKeyPair(bob); + + // Unauthorize bob + mptAlice.authorize({ + .account = alice, + .holder = bob, + .flags = tfMPTUnauthorize, + }); + + mptAlice.convert({ + .account = bob, + .amt = 10, + .holderPubKey = mptAlice.getPubKey(bob), + .err = tecNO_AUTH, + }); + + // auth bob + mptAlice.authorize({ + .account = alice, + .holder = bob, + }); + + mptAlice.convert({ + .account = bob, + .amt = 10, + .holderPubKey = mptAlice.getPubKey(bob), + }); + } + + // frozen account cannot bypass freeze check with amount=0 + { + Env env{*this, features}; + Account const alice("alice"); + Account const bob("bob"); + MPTTester mptAlice(env, alice, {.holders = {bob}}); + + mptAlice.create({ + .ownerCount = 1, + .flags = tfMPTCanTransfer | tfMPTCanLock | tfMPTCanConfidentialAmount, + }); + + mptAlice.authorize({ + .account = bob, + }); + mptAlice.pay(alice, bob, 100); + + mptAlice.generateKeyPair(alice); + + mptAlice.set({.account = alice, .issuerPubKey = mptAlice.getPubKey(alice)}); + + // lock bob + mptAlice.set({ + .account = alice, + .holder = bob, + .flags = tfMPTLock, + }); + + mptAlice.generateKeyPair(bob); + + // amount=0 should still be rejected when locked + mptAlice.convert({ + .account = bob, + .amt = 0, + .holderPubKey = mptAlice.getPubKey(bob), + .err = tecLOCKED, + }); + } + + // unauthorized account cannot bypass auth check with amount=0 + { + Env env{*this, features}; + Account const alice("alice"); + Account const bob("bob"); + MPTTester mptAlice(env, alice, {.holders = {bob}}); + + mptAlice.create({ + .ownerCount = 1, + .flags = + tfMPTCanTransfer | tfMPTCanLock | tfMPTRequireAuth | tfMPTCanConfidentialAmount, + }); + + mptAlice.authorize({ + .account = bob, + }); + mptAlice.authorize({ + .account = alice, + .holder = bob, + }); + mptAlice.pay(alice, bob, 100); + + mptAlice.generateKeyPair(alice); + + mptAlice.set({.account = alice, .issuerPubKey = mptAlice.getPubKey(alice)}); + + mptAlice.generateKeyPair(bob); + + // Unauthorize bob + mptAlice.authorize({ + .account = alice, + .holder = bob, + .flags = tfMPTUnauthorize, + }); + + // amount=0 should still be rejected when unauthorized + mptAlice.convert({ + .account = bob, + .amt = 0, + .holderPubKey = mptAlice.getPubKey(bob), + .err = tecNO_AUTH, + }); + } + + // cannot convert if auditor key is set, but auditor amount is not + // provided + { + Env env{*this, features}; + Account const alice("alice"); + Account const bob("bob"); + Account const auditor("auditor"); + MPTTester mptAlice( + env, + alice, + { + .holders = {bob}, + .auditor = auditor, + }); + + mptAlice.create({ + .ownerCount = 1, + .flags = tfMPTCanTransfer | tfMPTCanLock | tfMPTCanConfidentialAmount, + }); + + mptAlice.authorize({ + .account = bob, + }); + mptAlice.pay(alice, bob, 100); + + mptAlice.generateKeyPair(alice); + mptAlice.generateKeyPair(bob); + mptAlice.generateKeyPair(auditor); + + mptAlice.set( + {.account = alice, + .issuerPubKey = mptAlice.getPubKey(alice), + .auditorPubKey = mptAlice.getPubKey(auditor)}); + + // no auditor encrypted amt provided + mptAlice.convert({ + .account = bob, + .amt = 10, + .fillAuditorEncryptedAmt = false, + .holderPubKey = mptAlice.getPubKey(bob), + .err = tecNO_PERMISSION, + }); + } + + // cannot convert if tx include auditor ciphertext, but does not have + // auditing enabled + { + Env env{*this, features}; + Account const alice("alice"); + Account const bob("bob"); + MPTTester mptAlice(env, alice, {.holders = {bob}}); + + mptAlice.create({ + .ownerCount = 1, + .flags = tfMPTCanTransfer | tfMPTCanLock | tfMPTCanConfidentialAmount, + }); + + mptAlice.authorize({ + .account = bob, + }); + mptAlice.pay(alice, bob, 100); + + mptAlice.generateKeyPair(alice); + mptAlice.generateKeyPair(bob); + + // there is no auditor key set + mptAlice.set({.account = alice, .issuerPubKey = mptAlice.getPubKey(alice)}); + + mptAlice.convert({ + .account = bob, + .amt = 10, + .holderPubKey = mptAlice.getPubKey(bob), + .auditorEncryptedAmt = getTrivialCiphertext(), + .err = tecNO_PERMISSION, + }); + } + + // Auditor key set successfully, auditor ciphertext mathematically + // correct, but contains invalid data (mismatching amount). + { + Env env{*this, features}; + Account const alice("alice"); + Account const bob("bob"); + Account const auditor("auditor"); + MPTTester mptAlice( + env, + alice, + { + .holders = {bob}, + .auditor = auditor, + }); + + mptAlice.create({ + .flags = tfMPTCanTransfer | tfMPTCanLock | tfMPTCanConfidentialAmount, + }); + + mptAlice.authorize({ + .account = bob, + }); + mptAlice.pay(alice, bob, 100); + + mptAlice.generateKeyPair(alice); + mptAlice.generateKeyPair(bob); + mptAlice.generateKeyPair(auditor); + + mptAlice.set( + {.account = alice, + .issuerPubKey = mptAlice.getPubKey(alice), + .auditorPubKey = mptAlice.getPubKey(auditor)}); + + mptAlice.convert({ + .account = bob, + .amt = 10, + .holderPubKey = mptAlice.getPubKey(bob), + .auditorEncryptedAmt = getTrivialCiphertext(), + .err = tecBAD_PROOF, + }); + } + + // invalid proof when registering holder pub key + { + Env env{*this, features}; + Account const alice("alice"); + Account const bob("bob"); + MPTTester mptAlice(env, alice, {.holders = {bob}}); + + mptAlice.create({ + .ownerCount = 1, + .flags = tfMPTCanTransfer | tfMPTCanLock | tfMPTCanConfidentialAmount, + }); + + mptAlice.authorize({ + .account = bob, + }); + mptAlice.pay(alice, bob, 100); + + mptAlice.generateKeyPair(alice); + mptAlice.generateKeyPair(bob); + + mptAlice.set({.account = alice, .issuerPubKey = mptAlice.getPubKey(alice)}); + + mptAlice.convert({ + .account = bob, + .amt = 10, + .proof = std::string(ecSchnorrProofLength * 2, 'A'), + .holderPubKey = mptAlice.getPubKey(bob), + .err = tecBAD_PROOF, + }); + } + + // no holder key on ledger and no key in tx + { + Env env{*this, features}; + Account const alice("alice"); + Account const bob("bob"); + MPTTester mptAlice(env, alice, {.holders = {bob}}); + + mptAlice.create({ + .ownerCount = 1, + .flags = tfMPTCanTransfer | tfMPTCanLock | tfMPTCanConfidentialAmount, + }); + + mptAlice.authorize({ + .account = bob, + }); + mptAlice.pay(alice, bob, 100); + + mptAlice.generateKeyPair(alice); + mptAlice.generateKeyPair(bob); + + mptAlice.set({.account = alice, .issuerPubKey = mptAlice.getPubKey(alice)}); + + // bob has not registered a holder key, and doesn't provide one + mptAlice.convert({ + .account = bob, + .amt = 10, + .err = tecNO_PERMISSION, + }); + } + + // all public balance already converted, try to convert more + { + Env env{*this, features}; + Account const alice("alice"); + Account const bob("bob"); + MPTTester mptAlice(env, alice, {.holders = {bob}}); + + mptAlice.create({ + .ownerCount = 1, + .flags = tfMPTCanTransfer | tfMPTCanLock | tfMPTCanConfidentialAmount, + }); + + mptAlice.authorize({ + .account = bob, + }); + mptAlice.pay(alice, bob, 100); + + mptAlice.generateKeyPair(alice); + + mptAlice.set({.account = alice, .issuerPubKey = mptAlice.getPubKey(alice)}); + + mptAlice.generateKeyPair(bob); + + // convert entire public balance + mptAlice.convert({ + .account = bob, + .amt = 100, + .holderPubKey = mptAlice.getPubKey(bob), + }); + + env.require(mptbalance(mptAlice, bob, 0)); + + // try to convert 1 more — no public balance left + mptAlice.convert({ + .account = bob, + .amt = 1, + .err = tecINSUFFICIENT_FUNDS, + }); + } + } + + void + testMergeInbox(FeatureBitset features) + { + testcase("Merge inbox"); + using namespace test::jtx; + Env env{*this, features}; + Account const alice("alice"); + Account const bob("bob"); + MPTTester mptAlice(env, alice, {.holders = {bob}}); + + mptAlice.create({ + .ownerCount = 1, + .flags = tfMPTCanTransfer | tfMPTCanLock | tfMPTCanConfidentialAmount, + }); + + mptAlice.authorize({ + .account = bob, + }); + mptAlice.pay(alice, bob, 100); + + mptAlice.generateKeyPair(alice); + + mptAlice.set({.account = alice, .issuerPubKey = mptAlice.getPubKey(alice)}); + + mptAlice.generateKeyPair(bob); + + mptAlice.convert({ + .account = bob, + .amt = 40, + .holderPubKey = mptAlice.getPubKey(bob), + }); + + mptAlice.mergeInbox({ + .account = bob, + }); + } + + void + testMergeInboxPreflight(FeatureBitset features) + { + testcase("Merge inbox preflight"); + using namespace test::jtx; + Env env{*this, features}; + Account const alice("alice"); + Account const bob("bob"); + MPTTester mptAlice(env, alice, {.holders = {bob}}); + + mptAlice.create({ + .ownerCount = 1, + .flags = tfMPTCanTransfer | tfMPTCanLock | tfMPTCanConfidentialAmount, + }); + + mptAlice.authorize({ + .account = bob, + }); + mptAlice.pay(alice, bob, 100); + + mptAlice.generateKeyPair(alice); + + mptAlice.set({.account = alice, .issuerPubKey = mptAlice.getPubKey(alice)}); + + mptAlice.generateKeyPair(bob); + + mptAlice.convert({ + .account = bob, + .amt = 40, + .holderPubKey = mptAlice.getPubKey(bob), + }); + + mptAlice.mergeInbox({ + .account = alice, + .err = temMALFORMED, + }); + + env.disableFeature(featureConfidentialTransfer); + env.close(); + + mptAlice.mergeInbox({ + .account = bob, + .err = temDISABLED, + }); + } + + void + testMergeInboxPreclaim(FeatureBitset features) + { + testcase("Merge inbox preclaim"); + using namespace test::jtx; + + // issuance does not exist + { + Env env{*this, features}; + Account const alice("alice"); + Account const bob("bob"); + MPTTester mptAlice(env, alice, {.holders = {bob}}); + + mptAlice.create({ + .ownerCount = 1, + .flags = tfMPTCanTransfer | tfMPTCanLock | tfMPTCanConfidentialAmount, + }); + + mptAlice.authorize({ + .account = bob, + }); + mptAlice.generateKeyPair(alice); + + mptAlice.set({.account = alice, .issuerPubKey = mptAlice.getPubKey(alice)}); + + mptAlice.destroy(); + mptAlice.generateKeyPair(bob); + + mptAlice.mergeInbox({ + .account = bob, + .err = tecOBJECT_NOT_FOUND, + }); + } + + // tfMPTCanConfidentialAmount is not set on issuance + { + Env env{*this, features}; + Account const alice("alice"); + Account const bob("bob"); + MPTTester mptAlice(env, alice, {.holders = {bob}}); + + mptAlice.create({ + .ownerCount = 1, + .flags = tfMPTCanTransfer | tfMPTCanLock, + }); + + mptAlice.authorize({ + .account = bob, + }); + mptAlice.pay(alice, bob, 100); + + mptAlice.generateKeyPair(alice); + mptAlice.generateKeyPair(bob); + + mptAlice.mergeInbox({ + .account = bob, + .err = tecNO_PERMISSION, + }); + } + + // no mptoken + { + Env env{*this, features}; + Account const alice("alice"); + Account const bob("bob"); + MPTTester mptAlice(env, alice, {.holders = {bob}}); + + mptAlice.create({ + .ownerCount = 1, + .flags = tfMPTCanTransfer | tfMPTCanLock | tfMPTCanConfidentialAmount, + }); + + mptAlice.generateKeyPair(alice); + + mptAlice.set({.account = alice, .issuerPubKey = mptAlice.getPubKey(alice)}); + + mptAlice.mergeInbox({ + .account = bob, + .err = tecOBJECT_NOT_FOUND, + }); + } + + // bob doesn't have encrypted 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 | tfMPTCanLock | tfMPTCanConfidentialAmount, + }); + + mptAlice.authorize({ + .account = bob, + }); + mptAlice.pay(alice, bob, 100); + + mptAlice.generateKeyPair(alice); + + mptAlice.set({.account = alice, .issuerPubKey = mptAlice.getPubKey(alice)}); + + mptAlice.generateKeyPair(bob); + + mptAlice.mergeInbox({ + .account = bob, + .err = tecNO_PERMISSION, + }); + } + + // holder is locked + { + Env env{*this, features}; + Account const alice("alice"); + Account const bob("bob"); + MPTTester mptAlice(env, alice, {.holders = {bob}}); + + mptAlice.create({ + .ownerCount = 1, + .flags = tfMPTCanTransfer | tfMPTCanLock | tfMPTCanConfidentialAmount, + }); + + mptAlice.authorize({ + .account = bob, + }); + mptAlice.pay(alice, bob, 100); + + mptAlice.generateKeyPair(alice); + mptAlice.generateKeyPair(bob); + + mptAlice.set({.account = alice, .issuerPubKey = mptAlice.getPubKey(alice)}); + + mptAlice.convert({ + .account = bob, + .amt = 50, + .holderPubKey = mptAlice.getPubKey(bob), + }); + + // lock bob + mptAlice.set({ + .account = alice, + .holder = bob, + .flags = tfMPTLock, + }); + + mptAlice.mergeInbox({ + .account = bob, + .err = tecLOCKED, + }); + + // unlock bob + mptAlice.set({ + .account = alice, + .holder = bob, + .flags = tfMPTUnlock, + }); + + // should succeed now + mptAlice.mergeInbox({ + .account = bob, + }); + } + + // holder not 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 | tfMPTCanLock | tfMPTCanConfidentialAmount | tfMPTRequireAuth, + }); + + mptAlice.authorize({ + .account = bob, + }); + mptAlice.authorize({ + .account = alice, + .holder = bob, + }); + mptAlice.pay(alice, bob, 100); + + mptAlice.generateKeyPair(alice); + mptAlice.generateKeyPair(bob); + + mptAlice.set({.account = alice, .issuerPubKey = mptAlice.getPubKey(alice)}); + + mptAlice.convert({ + .account = bob, + .amt = 50, + .holderPubKey = mptAlice.getPubKey(bob), + }); + + // unauthorize bob + mptAlice.authorize({ + .account = alice, + .holder = bob, + .flags = tfMPTUnauthorize, + }); + + mptAlice.mergeInbox({ + .account = bob, + .err = tecNO_AUTH, + }); + + // authorize bob again + mptAlice.authorize({ + .account = alice, + .holder = bob, + }); + + // should succeed now + mptAlice.mergeInbox({ + .account = bob, + }); + } + } + + void + testSend(FeatureBitset features) + { + testcase("test confidential send"); + 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}}); + + mptAlice.create({ + .ownerCount = 1, + .flags = tfMPTCanTransfer | tfMPTCanLock | tfMPTCanConfidentialAmount, + }); + + mptAlice.authorize({ + .account = bob, + }); + mptAlice.authorize({ + .account = carol, + }); + + mptAlice.pay(alice, bob, 100); + mptAlice.pay(alice, carol, 50); + + mptAlice.generateKeyPair(alice); + mptAlice.generateKeyPair(bob); + mptAlice.generateKeyPair(carol); + + mptAlice.set({.account = alice, .issuerPubKey = mptAlice.getPubKey(alice)}); + + // Convert 60 out of 100 + mptAlice.convert({.account = bob, .amt = 60, .holderPubKey = mptAlice.getPubKey(bob)}); + + // bob merge inbox + mptAlice.mergeInbox({ + .account = bob, + }); + + // carol convert 20 to confidential + mptAlice.convert({.account = carol, .amt = 20, .holderPubKey = mptAlice.getPubKey(carol)}); + + // carol merge inbox + mptAlice.mergeInbox({ + .account = carol, + }); + + // bob sends 10 to carol + mptAlice.send({ + .account = bob, + .dest = carol, + .amt = 10, + }); + + // bob sends 1 to carol again + mptAlice.send({ + .account = bob, + .dest = carol, + .amt = 1, + }); + + mptAlice.mergeInbox({ + .account = carol, + }); + + // carol sends 15 back to bob + mptAlice.send({ + .account = carol, + .dest = bob, + .amt = 15, + }); + } + + void + testSendWithAuditor(FeatureBitset features) + { + testcase("test confidential send with auditor"); + using namespace test::jtx; + Env env{*this, features}; + Account const alice("alice"); + Account const bob("bob"); + Account const carol("carol"); + Account const auditor("auditor"); + MPTTester mptAlice( + env, + alice, + { + .holders = {bob, carol}, + .auditor = auditor, + }); + + mptAlice.create({ + .ownerCount = 1, + .flags = tfMPTCanTransfer | tfMPTCanLock | tfMPTCanConfidentialAmount, + }); + + mptAlice.authorize({ + .account = bob, + }); + mptAlice.authorize({ + .account = carol, + }); + + mptAlice.pay(alice, bob, 100); + mptAlice.pay(alice, carol, 50); + + mptAlice.generateKeyPair(alice); + mptAlice.generateKeyPair(bob); + mptAlice.generateKeyPair(carol); + mptAlice.generateKeyPair(auditor); + + mptAlice.set( + {.account = alice, + .issuerPubKey = mptAlice.getPubKey(alice), + .auditorPubKey = mptAlice.getPubKey(auditor)}); + + // Convert 60 out of 100 + mptAlice.convert({.account = bob, .amt = 60, .holderPubKey = mptAlice.getPubKey(bob)}); + + // bob merge inbox + mptAlice.mergeInbox({ + .account = bob, + }); + + mptAlice.convert({.account = carol, .amt = 20, .holderPubKey = mptAlice.getPubKey(carol)}); + + // carol merge inbox + mptAlice.mergeInbox({ + .account = carol, + }); + + // bob sends 10 to carol + mptAlice.send({ + .account = bob, + .dest = carol, + .amt = 10, + }); + + // bob sends 1 to carol again + mptAlice.send({ + .account = bob, + .dest = carol, + .amt = 1, + }); + + mptAlice.mergeInbox({ + .account = carol, + }); + + // carol sends 15 back to bob + mptAlice.send({ + .account = carol, + .dest = bob, + .amt = 15, + }); + } + + void + testSendPreflight(FeatureBitset features) + { + testcase("test ConfidentialMPTSend Preflight"); + using namespace test::jtx; + + // test disabled + { + Env env{*this, features - featureConfidentialTransfer}; + Account const alice("alice"); + Account const bob("bob"); + Account const carol("carol"); + MPTTester mptAlice(env, alice, {.holders = {bob, carol}}); + + mptAlice.create(); + mptAlice.authorize({ + .account = bob, + }); + mptAlice.authorize({ + .account = carol, + }); + + mptAlice.send({ + .account = bob, + .dest = carol, + .amt = 10, + .senderEncryptedAmt = makeZeroBuffer(ecGamalEncryptedTotalLength), + .destEncryptedAmt = makeZeroBuffer(ecGamalEncryptedTotalLength), + .issuerEncryptedAmt = makeZeroBuffer(ecGamalEncryptedTotalLength), + .err = temDISABLED, + }); + } + + // test malformed + { + Env env{*this, features}; + Account const alice("alice"); + Account const bob("bob"); + Account const carol("carol"); + MPTTester mptAlice(env, alice, {.holders = {bob, carol}}); + + mptAlice.create({ + .ownerCount = 1, + .flags = tfMPTCanTransfer | tfMPTCanConfidentialAmount, + }); + + mptAlice.authorize({ + .account = bob, + }); + mptAlice.authorize({ + .account = carol, + }); + mptAlice.generateKeyPair(alice); + mptAlice.generateKeyPair(bob); + mptAlice.generateKeyPair(carol); + mptAlice.set({.account = alice, .issuerPubKey = mptAlice.getPubKey(alice)}); + mptAlice.pay(alice, bob, 100); + mptAlice.pay(alice, carol, 50); + + mptAlice.convert({ + .account = bob, + .amt = 50, + .holderPubKey = mptAlice.getPubKey(bob), + }); + + mptAlice.convert({ + .account = carol, + .amt = 40, + .holderPubKey = mptAlice.getPubKey(carol), + }); + + // issuer can not be the same as sender + mptAlice.send({ + .account = alice, + .dest = carol, + .amt = 10, + .err = temMALFORMED, + }); + + // can not send to self + mptAlice.send({ + .account = bob, + .dest = bob, + .amt = 10, + .err = temMALFORMED, + }); + + // sender encrypted amount wrong length + mptAlice.send({ + .account = bob, + .dest = carol, + .amt = 10, + .senderEncryptedAmt = makeZeroBuffer(10), + .err = temBAD_CIPHERTEXT, + }); + + // dest encrypted amount wrong length + mptAlice.send({ + .account = bob, + .dest = carol, + .amt = 10, + .destEncryptedAmt = makeZeroBuffer(10), + .err = temBAD_CIPHERTEXT, + }); + + // issuer encrypted amount wrong length + mptAlice.send({ + .account = bob, + .dest = carol, + .amt = 10, + .issuerEncryptedAmt = makeZeroBuffer(10), + .err = temBAD_CIPHERTEXT, + }); + + // sender encrypted amount malformed + mptAlice.send({ + .account = bob, + .dest = carol, + .amt = 10, + .proof = getTrivialSendProofHex(3), + .senderEncryptedAmt = makeZeroBuffer(ecGamalEncryptedTotalLength), + .amountCommitment = getTrivialCommitment(), + .balanceCommitment = getTrivialCommitment(), + .err = temBAD_CIPHERTEXT, + }); + + // dest encrypted amount malformed + mptAlice.send({ + .account = bob, + .dest = carol, + .amt = 10, + .proof = getTrivialSendProofHex(3), + .destEncryptedAmt = makeZeroBuffer(ecGamalEncryptedTotalLength), + .amountCommitment = getTrivialCommitment(), + .balanceCommitment = getTrivialCommitment(), + .err = temBAD_CIPHERTEXT, + }); + + // issuer encrypted amount malformed + mptAlice.send({ + .account = bob, + .dest = carol, + .amt = 10, + .proof = getTrivialSendProofHex(3), + .issuerEncryptedAmt = makeZeroBuffer(ecGamalEncryptedTotalLength), + .amountCommitment = getTrivialCommitment(), + .balanceCommitment = getTrivialCommitment(), + .err = temBAD_CIPHERTEXT, + }); + + // invalid proof length + mptAlice.send({ + .account = bob, + .dest = carol, + .amt = 10, + .proof = std::string(10, 'A'), + .amountCommitment = getTrivialCommitment(), + .balanceCommitment = getTrivialCommitment(), + .err = temMALFORMED, + }); + + // invalid amount Pedersen commitment length + mptAlice.send({ + .account = bob, + .dest = carol, + .amt = 10, + .proof = getTrivialSendProofHex(3), + .amountCommitment = makeZeroBuffer(100), + .balanceCommitment = getTrivialCommitment(), + .err = temMALFORMED, + }); + + // invalid balance Pedersen commitment length + mptAlice.send({ + .account = bob, + .dest = carol, + .amt = 10, + .proof = getTrivialSendProofHex(3), + .amountCommitment = getTrivialCommitment(), + .balanceCommitment = makeZeroBuffer(100), + .err = temMALFORMED, + }); + + // amount Pedersen commitment has correct length but invalid EC point data + mptAlice.send({ + .account = bob, + .dest = carol, + .amt = 10, + .proof = getTrivialSendProofHex(3), + .amountCommitment = makeZeroBuffer(ecPedersenCommitmentLength), + .balanceCommitment = getTrivialCommitment(), + .err = temMALFORMED, + }); + + // balance Pedersen commitment has correct length but invalid EC point data + mptAlice.send({ + .account = bob, + .dest = carol, + .amt = 10, + .proof = getTrivialSendProofHex(3), + .amountCommitment = getTrivialCommitment(), + .balanceCommitment = makeZeroBuffer(ecPedersenCommitmentLength), + .err = temMALFORMED, + }); + } + + // test bad ciphertext + { + Env env{*this, features}; + Account const alice("alice"); + Account const bob("bob"); + Account const carol("carol"); + Account const auditor("auditor"); + MPTTester mptAlice( + env, + alice, + { + .holders = {bob, carol}, + .auditor = auditor, + }); + + mptAlice.create({ + .ownerCount = 1, + .flags = tfMPTCanTransfer | tfMPTCanConfidentialAmount, + }); + + mptAlice.authorize({ + .account = bob, + }); + mptAlice.authorize({ + .account = carol, + }); + mptAlice.generateKeyPair(alice); + mptAlice.generateKeyPair(bob); + mptAlice.generateKeyPair(carol); + mptAlice.generateKeyPair(auditor); + + mptAlice.set( + {.account = alice, + .issuerPubKey = mptAlice.getPubKey(alice), + .auditorPubKey = mptAlice.getPubKey(auditor)}); + mptAlice.pay(alice, bob, 100); + mptAlice.pay(alice, carol, 50); + + mptAlice.convert({ + .account = bob, + .amt = 50, + .holderPubKey = mptAlice.getPubKey(bob), + }); + + mptAlice.convert({ + .account = carol, + .amt = 40, + .holderPubKey = mptAlice.getPubKey(carol), + }); + + // auditor encrypted amount wrong length + mptAlice.send({ + .account = bob, + .dest = carol, + .amt = 10, + .proof = getTrivialSendProofHex(4), + .auditorEncryptedAmt = makeZeroBuffer(10), + .amountCommitment = getTrivialCommitment(), + .balanceCommitment = getTrivialCommitment(), + .err = temBAD_CIPHERTEXT, + }); + + // auditor encrypted amount (correct length, invalid data) + mptAlice.send({ + .account = bob, + .dest = carol, + .amt = 10, + .proof = getTrivialSendProofHex(4), + .auditorEncryptedAmt = getBadCiphertext(), + .amountCommitment = getTrivialCommitment(), + .balanceCommitment = getTrivialCommitment(), + .err = temBAD_CIPHERTEXT, + }); + } + } + + void + testSendPreclaim(FeatureBitset features) + { + testcase("test ConfidentialMPTSend Preclaim"); + + using namespace test::jtx; + Env env{*this, features}; + Account const alice("alice"); + Account const bob("bob"); + Account const carol("carol"); + Account const dave("dave"); + Account const eve("eve"); + MPTTester mptAlice(env, alice, {.holders = {bob, carol, dave, eve}}); + + // authorize bob, carol, dave (not eve) + mptAlice.create({ + .flags = + tfMPTCanTransfer | tfMPTCanLock | tfMPTRequireAuth | tfMPTCanConfidentialAmount, + }); + mptAlice.authorize({ + .account = bob, + }); + mptAlice.authorize({ + .account = alice, + .holder = bob, + }); + mptAlice.authorize({ + .account = carol, + }); + mptAlice.authorize({ + .account = alice, + .holder = carol, + }); + mptAlice.authorize({ + .account = dave, + }); + mptAlice.authorize({ + .account = alice, + .holder = dave, + }); + + // fund bob, carol (not dave or eve) + mptAlice.pay(alice, bob, 100); + mptAlice.pay(alice, carol, 50); + + mptAlice.generateKeyPair(alice); + mptAlice.generateKeyPair(bob); + mptAlice.generateKeyPair(carol); + mptAlice.generateKeyPair(dave); + mptAlice.set({.account = alice, .issuerPubKey = mptAlice.getPubKey(alice)}); + + // bob and carol convert some funds to confidential + mptAlice.convert({ + .account = bob, + .amt = 60, + .holderPubKey = mptAlice.getPubKey(bob), + .err = tesSUCCESS, + }); + mptAlice.convert({ + .account = carol, + .amt = 20, + .holderPubKey = mptAlice.getPubKey(carol), + .err = tesSUCCESS, + }); + + // bob and carol merge inbox + mptAlice.mergeInbox({ + .account = bob, + }); + mptAlice.mergeInbox({ + .account = carol, + }); + + // issuance not found + { + Env env{*this, features}; + Account const alice("alice"); + Account const bob("bob"); + Account const carol("carol"); + MPTTester mptAlice(env, alice, {.holders = {bob, carol}}); + + mptAlice.create({ + .flags = tfMPTCanTransfer | tfMPTCanConfidentialAmount, + }); + mptAlice.authorize({ + .account = bob, + }); + mptAlice.authorize({ + .account = carol, + }); + mptAlice.generateKeyPair(alice); + mptAlice.set({.account = alice, .issuerPubKey = mptAlice.getPubKey(alice)}); + + // destroy the issuance + mptAlice.destroy(); + + Json::Value jv; + jv[jss::Account] = bob.human(); + jv[jss::Destination] = carol.human(); + jv[jss::TransactionType] = jss::ConfidentialMPTSend; + jv[sfMPTokenIssuanceID] = to_string(mptAlice.issuanceID()); + jv[sfSenderEncryptedAmount] = strHex(getTrivialCiphertext()); + jv[sfDestinationEncryptedAmount] = strHex(getTrivialCiphertext()); + jv[sfIssuerEncryptedAmount] = strHex(getTrivialCiphertext()); + jv[sfAmountCommitment] = strHex(getTrivialCommitment()); + jv[sfBalanceCommitment] = strHex(getTrivialCommitment()); + jv[sfZKProof] = getTrivialSendProofHex(3); + + env(jv, ter(tecOBJECT_NOT_FOUND)); + } + + // destination does not exist + { + Account const unknown("unknown"); + mptAlice.send({ + .account = bob, + .dest = unknown, + .amt = 10, + .proof = getTrivialSendProofHex(3), + .senderEncryptedAmt = getTrivialCiphertext(), + .destEncryptedAmt = getTrivialCiphertext(), + .issuerEncryptedAmt = getTrivialCiphertext(), + .amountCommitment = getTrivialCommitment(), + .balanceCommitment = getTrivialCommitment(), + .err = tecNO_TARGET, + }); + } + + // dave exists, but has no confidential fields (never converted) + { + mptAlice.send({ + .account = bob, + .dest = dave, + .amt = 10, + .proof = getTrivialSendProofHex(3), + .senderEncryptedAmt = getTrivialCiphertext(), + .destEncryptedAmt = getTrivialCiphertext(), + .issuerEncryptedAmt = getTrivialCiphertext(), + .amountCommitment = getTrivialCommitment(), + .balanceCommitment = getTrivialCommitment(), + .err = tecNO_PERMISSION, + }); + mptAlice.send({ + .account = dave, + .dest = carol, + .amt = 10, + .proof = getTrivialSendProofHex(3), + .senderEncryptedAmt = getTrivialCiphertext(), + .destEncryptedAmt = getTrivialCiphertext(), + .issuerEncryptedAmt = getTrivialCiphertext(), + .amountCommitment = getTrivialCommitment(), + .balanceCommitment = getTrivialCommitment(), + .err = tecNO_PERMISSION, + }); + } + + // destination exists but has no MPT object. + { + mptAlice.send({ + .account = bob, + .dest = eve, + .amt = 10, + .proof = getTrivialSendProofHex(3), + .senderEncryptedAmt = getTrivialCiphertext(), + .destEncryptedAmt = getTrivialCiphertext(), + .issuerEncryptedAmt = getTrivialCiphertext(), + .amountCommitment = getTrivialCommitment(), + .balanceCommitment = getTrivialCommitment(), + .err = tecOBJECT_NOT_FOUND, + }); + } + + // issuance is locked globally + { + // lock issuance + mptAlice.set({ + .account = alice, + .flags = tfMPTLock, + }); + mptAlice.send({ + .account = bob, + .dest = carol, + .amt = 10, + .err = tecLOCKED, + }); + // unlock issuance + mptAlice.set({ + .account = alice, + .flags = tfMPTUnlock, + }); + // now can send + mptAlice.send({ + .account = bob, + .dest = carol, + .amt = 1, + }); + } + + // sender is locked + { + // lock bob + mptAlice.set({ + .account = alice, + .holder = bob, + .flags = tfMPTLock, + }); + mptAlice.send({ + .account = bob, + .dest = carol, + .amt = 10, + .err = tecLOCKED, + }); + // unlock bob + mptAlice.set({ + .account = alice, + .holder = bob, + .flags = tfMPTUnlock, + }); + // now can send + mptAlice.send({ + .account = bob, + .dest = carol, + .amt = 2, + }); + } + + // destination is locked + { + // lock carol + mptAlice.set({ + .account = alice, + .holder = carol, + .flags = tfMPTLock, + }); + mptAlice.send({ + .account = bob, + .dest = carol, + .amt = 10, + .err = tecLOCKED, + }); + // unlock carol + mptAlice.set({ + .account = alice, + .holder = carol, + .flags = tfMPTUnlock, + }); + // now can send + mptAlice.send({ + .account = bob, + .dest = carol, + .amt = 3, + }); + } + + // sender not authorized + { + // unauthorize bob + mptAlice.authorize({ + .account = alice, + .holder = bob, + .flags = tfMPTUnauthorize, + }); + mptAlice.send({ + .account = bob, + .dest = carol, + .amt = 10, + .err = tecNO_AUTH, + }); + // authorize bob again + mptAlice.authorize({ + .account = alice, + .holder = bob, + }); + // now can send + mptAlice.send({ + .account = bob, + .dest = carol, + .amt = 4, + }); + } + + // destination not authorized + { + // unauthorize carol + mptAlice.authorize({ + .account = alice, + .holder = carol, + .flags = tfMPTUnauthorize, + }); + mptAlice.send({ + .account = bob, + .dest = carol, + .amt = 10, + .err = tecNO_AUTH, + }); + // authorize carol again + mptAlice.authorize({ + .account = alice, + .holder = carol, + }); + // now can send + mptAlice.send({ + .account = bob, + .dest = carol, + .amt = 5, + }); + } + + // cannot send when MPTCanTransfer is not set + { + Env env{*this, features}; + Account const alice("alice"); + Account const bob("bob"); + Account const carol("carol"); + MPTTester mptAlice(env, alice, {.holders = {bob, carol}}); + + mptAlice.create({ + .ownerCount = 1, + .flags = tfMPTCanLock | tfMPTCanConfidentialAmount, + }); + + mptAlice.authorize({ + .account = bob, + }); + mptAlice.authorize({ + .account = carol, + }); + + mptAlice.pay(alice, bob, 100); + mptAlice.pay(alice, carol, 50); + + mptAlice.generateKeyPair(alice); + mptAlice.generateKeyPair(bob); + mptAlice.generateKeyPair(carol); + + mptAlice.set({.account = alice, .issuerPubKey = mptAlice.getPubKey(alice)}); + + // Convert 60 out of 100 + mptAlice.convert({ + .account = bob, + .amt = 60, + .holderPubKey = mptAlice.getPubKey(bob), + .err = tesSUCCESS, + }); + + // bob merge inbox + mptAlice.mergeInbox({ + .account = bob, + }); + + mptAlice.convert({ + .account = carol, + .amt = 20, + .holderPubKey = mptAlice.getPubKey(carol), + .err = tesSUCCESS, + }); + + // carol merge inbox + mptAlice.mergeInbox({ + .account = carol, + }); + + // bob sends 10 to carol + mptAlice.send({ + .account = bob, + .dest = carol, + .amt = 10, // will be encrypted internally + .err = tecNO_AUTH, + }); + } + + // bad proof + { + Env env{*this, features}; + Account const alice("alice"); + Account const bob("bob"); + Account const carol("carol"); + MPTTester mptAlice(env, alice, {.holders = {bob, carol}}); + + mptAlice.create({ + .ownerCount = 1, + .flags = tfMPTCanLock | tfMPTCanConfidentialAmount | tfMPTCanTransfer, + }); + + mptAlice.authorize({ + .account = bob, + }); + mptAlice.authorize({ + .account = carol, + }); + + mptAlice.pay(alice, bob, 100); + mptAlice.pay(alice, carol, 50); + + mptAlice.generateKeyPair(alice); + mptAlice.generateKeyPair(bob); + mptAlice.generateKeyPair(carol); + + mptAlice.set({.account = alice, .issuerPubKey = mptAlice.getPubKey(alice)}); + + mptAlice.convert({ + .account = bob, + .amt = 60, + .holderPubKey = mptAlice.getPubKey(bob), + .err = tesSUCCESS, + }); + + mptAlice.mergeInbox({ + .account = bob, + }); + + mptAlice.convert({ + .account = carol, + .amt = 20, + .holderPubKey = mptAlice.getPubKey(carol), + .err = tesSUCCESS, + }); + + mptAlice.mergeInbox({ + .account = carol, + }); + + mptAlice.send({ + .account = bob, + .dest = carol, + .amt = 10, + .proof = getTrivialSendProofHex(3), + .err = tecBAD_PROOF, + }); + } + + // No Auditor key set, but auditor encrypted amt provided + { + mptAlice.send({ + .account = bob, + .dest = carol, + .amt = 10, + .proof = getTrivialSendProofHex(4), + .auditorEncryptedAmt = getTrivialCiphertext(), + .err = tecNO_PERMISSION, + }); + } + + // Auditor CipherText is Valid, but does not match the Txn Amount + { + Env env{*this, features}; + Account const alice("alice"); + Account const bob("bob"); + Account const carol("carol"); + Account const auditor("auditor"); + MPTTester mptAlice( + env, + alice, + { + .holders = {bob, carol}, + .auditor = auditor, + }); + + mptAlice.create({ + .ownerCount = 1, + .flags = tfMPTCanTransfer | tfMPTCanConfidentialAmount, + }); + + mptAlice.authorize({ + .account = bob, + }); + mptAlice.authorize({ + .account = carol, + }); + mptAlice.generateKeyPair(alice); + mptAlice.generateKeyPair(bob); + mptAlice.generateKeyPair(carol); + mptAlice.generateKeyPair(auditor); + + mptAlice.set( + {.account = alice, + .issuerPubKey = mptAlice.getPubKey(alice), + .auditorPubKey = mptAlice.getPubKey(auditor)}); + mptAlice.pay(alice, bob, 100); + mptAlice.pay(alice, carol, 50); + + mptAlice.convert({ + .account = bob, + .amt = 50, + .holderPubKey = mptAlice.getPubKey(bob), + }); + + mptAlice.convert({ + .account = carol, + .amt = 40, + .holderPubKey = mptAlice.getPubKey(carol), + }); + + mptAlice.send({ + .account = bob, + .dest = carol, + .amt = 10, + .proof = getTrivialSendProofHex(4), + .auditorEncryptedAmt = getTrivialCiphertext(), + .amountCommitment = getTrivialCommitment(), + .balanceCommitment = getTrivialCommitment(), + .err = tecBAD_PROOF, + }); + } + } + + void + testSendRangeProof(FeatureBitset features) + { + testcase("test ConfidentialMPTSend Range Proof"); + + 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}}); + + mptAlice.create({ + .ownerCount = 1, + .flags = tfMPTCanLock | tfMPTCanConfidentialAmount | tfMPTCanTransfer, + }); + mptAlice.authorize({ + .account = bob, + }); + mptAlice.authorize({ + .account = carol, + }); + + mptAlice.pay(alice, bob, 1000); + mptAlice.pay(alice, carol, 1000); + + mptAlice.generateKeyPair(alice); + mptAlice.generateKeyPair(bob); + mptAlice.generateKeyPair(carol); + + mptAlice.set({.account = alice, .issuerPubKey = mptAlice.getPubKey(alice)}); + + { + // Bob converts 60 + mptAlice.convert({.account = bob, .amt = 60, .holderPubKey = mptAlice.getPubKey(bob)}); + mptAlice.mergeInbox({ + .account = bob, + }); + + mptAlice.convert( + {.account = carol, .amt = 50, .holderPubKey = mptAlice.getPubKey(carol)}); + mptAlice.mergeInbox({ + .account = carol, + }); + + // Bob has 60, tries to send 70. Invalid remaining balance. + mptAlice.send({ + .account = bob, + .dest = carol, + .amt = 70, + .err = tecBAD_PROOF, + }); + + // Bob has 60, tries to send 61. Invalid remaining balance. + mptAlice.send({ + .account = bob, + .dest = carol, + .amt = 61, + .err = tecBAD_PROOF, + }); + + // Bob has 60, sends 60. Remainder is exactly 0. Valid remaining balance. + mptAlice.send({ + .account = bob, + .dest = carol, + .amt = 60, + .err = tesSUCCESS, + }); + } + + { + // Bob converts 100. + mptAlice.convert({ + .account = bob, + .amt = 100, + }); + mptAlice.mergeInbox({ + .account = bob, + }); + + // Bob has 100, tries to send 2^64-1. Invalid remaining balance. + mptAlice.send({ + .account = bob, + .dest = carol, + .amt = 0xFFFFFFFFFFFFFFFF, // Max uint64 + .err = tecBAD_PROOF, + }); + + // Bob sends 1, remaining 99. + mptAlice.send({ + .account = bob, + .dest = carol, + .amt = 1, + .err = tesSUCCESS, + }); + + // Bob sends 100, but only has 99. Invalid remaining balance. + mptAlice.send({ + .account = bob, + .dest = carol, + .amt = 100, + .err = tecBAD_PROOF, + }); + } + + // send when spending balance is 0 (key registered, inbox merged, but nothing converted) + { + Env env{*this, features}; + Account const alice("alice"); + Account const bob("bob"); + Account const carol("carol"); + MPTTester mptAlice(env, alice, {.holders = {bob, carol}}); + + mptAlice.create({ + .ownerCount = 1, + .flags = tfMPTCanTransfer | tfMPTCanLock | tfMPTCanConfidentialAmount, + }); + mptAlice.authorize({.account = bob}); + mptAlice.authorize({.account = carol}); + mptAlice.pay(alice, bob, 100); + mptAlice.pay(alice, carol, 50); + + mptAlice.generateKeyPair(alice); + mptAlice.generateKeyPair(bob); + mptAlice.generateKeyPair(carol); + + mptAlice.set({.account = alice, .issuerPubKey = mptAlice.getPubKey(alice)}); + + // Register keys only (amt=0) for both parties, then merge — spending stays 0. + mptAlice.convert({.account = bob, .amt = 0, .holderPubKey = mptAlice.getPubKey(bob)}); + mptAlice.mergeInbox({.account = bob}); + mptAlice.convert( + {.account = carol, .amt = 0, .holderPubKey = mptAlice.getPubKey(carol)}); + + // Trying to send any amount with 0 spending balance must fail: + // the range proof for < 0 is invalid. + mptAlice.send({ + .account = bob, + .dest = carol, + .amt = 1, + .err = tecBAD_PROOF, + }); + + BEAST_EXPECT( + mptAlice.getDecryptedBalance(bob, MPTTester::HOLDER_ENCRYPTED_SPENDING) == 0); + } + + // todo: test m exceeding range, require using scala and refactor + } + + /* TODO: uncomment when MPT crypto supports proof generation with value 0 + * Tests verifier behavior when the send amount is 0. + * + * The equality proof library and range proof library do not + * support generating proofs for amt=0 (they require a positive witness). + * To test the VERIFIER without crashing the helper, we bypass normal proof + * generation by supplying explicit ciphertexts, commitments, and a dummy + * (all-zero) proof. The preflight has no temBAD_AMOUNT guard for + * ConfidentialMPTSend, so all validation occurs in verifySendProofs. + */ + /*void + testSendZeroAmount(FeatureBitset features) + { + testcase("Send: zero amount — equality and range proof verifier behavior"); + 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}}); + + mptAlice.create({ + .ownerCount = 1, + .flags = tfMPTCanTransfer | tfMPTCanLock | tfMPTCanConfidentialAmount, + }); + mptAlice.authorize({.account = bob}); + mptAlice.authorize({.account = carol}); + mptAlice.pay(alice, bob, 100); + mptAlice.pay(alice, carol, 50); + + mptAlice.generateKeyPair(alice); + mptAlice.generateKeyPair(bob); + mptAlice.generateKeyPair(carol); + + mptAlice.set({.account = alice, .issuerPubKey = mptAlice.getPubKey(alice)}); + + mptAlice.convert({.account = bob, .amt = 100, .holderPubKey = mptAlice.getPubKey(bob)}); + mptAlice.mergeInbox({.account = bob}); + + mptAlice.convert({.account = carol, .amt = 50, .holderPubKey = mptAlice.getPubKey(carol)}); + mptAlice.mergeInbox({.account = carol}); + + Buffer const bf = generateBlindingFactor(); + + // equality proof verification for amt=0. + // Encrypt 0 under each participant's key. The amount commitment is + // getTrivialCommitment() — a valid EC point that passes preflight's + // isValidCompressedECPoint check but is not the true PC for amt=0. + // The dummy ZKProof's equality component must be rejected by + // verifyMultiCiphertextEqualityProof. + mptAlice.send({ + .account = bob, + .dest = carol, + .amt = 0, + .proof = getTrivialSendProofHex(3), + .senderEncryptedAmt = mptAlice.encryptAmount(bob, 0, bf), + .destEncryptedAmt = mptAlice.encryptAmount(carol, 0, bf), + .issuerEncryptedAmt = mptAlice.encryptAmount(alice, 0, bf), + .amountCommitment = getTrivialCommitment(), + .balanceCommitment = getTrivialCommitment(), + .err = tecBAD_PROOF, + }); + + // range proof verification for amt=0. + // Identical construction; focuses on the bulletproof range check + // embedded in ZKProof. The range proof for amount=0 with a dummy + // (all-zero) proof must also be rejected. + Buffer const bf2 = generateBlindingFactor(); + mptAlice.send({ + .account = bob, + .dest = carol, + .amt = 0, + .proof = getTrivialSendProofHex(3), + .senderEncryptedAmt = mptAlice.encryptAmount(bob, 0, bf2), + .destEncryptedAmt = mptAlice.encryptAmount(carol, 0, bf2), + .issuerEncryptedAmt = mptAlice.encryptAmount(alice, 0, bf2), + .amountCommitment = getTrivialCommitment(), + .balanceCommitment = getTrivialCommitment(), + .err = tecBAD_PROOF, + }); + + // All rejected sends must leave balances unchanged. + BEAST_EXPECT( + mptAlice.getDecryptedBalance(bob, MPTTester::HOLDER_ENCRYPTED_SPENDING) == 100); + BEAST_EXPECT( + mptAlice.getDecryptedBalance(carol, MPTTester::HOLDER_ENCRYPTED_INBOX) == 0); + }*/ + + void + testDelete(FeatureBitset features) + { + testcase("Delete"); + using namespace test::jtx; + + // cannot delete mptoken where it has encrypted balance + { + Env env{*this, features}; + Account const alice("alice"); + Account const bob("bob"); + MPTTester mptAlice(env, alice, {.holders = {bob}}); + + mptAlice.create({ + .ownerCount = 1, + .flags = tfMPTCanTransfer | tfMPTCanLock | tfMPTCanConfidentialAmount, + }); + + mptAlice.authorize({ + .account = bob, + }); + mptAlice.pay(alice, bob, 100); + + mptAlice.generateKeyPair(alice); + + mptAlice.set({.account = alice, .issuerPubKey = mptAlice.getPubKey(alice)}); + + mptAlice.generateKeyPair(bob); + + mptAlice.convert({ + .account = bob, + .amt = 100, + .holderPubKey = mptAlice.getPubKey(bob), + }); + + mptAlice.authorize({ + .account = bob, + .flags = tfMPTUnauthorize, + .err = tecHAS_OBLIGATIONS, + }); + } + + // cannot delete mptoken where it has encrypted balance + { + Env env{*this, features}; + Account const alice("alice"); + Account const bob("bob"); + Account const carol("carol"); + MPTTester mptAlice(env, alice, {.holders = {bob, carol}}); + + mptAlice.create({ + .ownerCount = 1, + .flags = tfMPTCanTransfer | tfMPTCanLock | tfMPTCanConfidentialAmount, + }); + + mptAlice.authorize({ + .account = bob, + }); + mptAlice.authorize({ + .account = carol, + }); + mptAlice.pay(alice, bob, 100); + + mptAlice.generateKeyPair(alice); + + mptAlice.set({.account = alice, .issuerPubKey = mptAlice.getPubKey(alice)}); + + mptAlice.generateKeyPair(bob); + mptAlice.generateKeyPair(carol); + + mptAlice.convert({ + .account = bob, + .amt = 100, + .holderPubKey = mptAlice.getPubKey(bob), + }); + + mptAlice.convert({ + .account = carol, + .amt = 0, + .holderPubKey = mptAlice.getPubKey(carol), + }); + + // carol cannot delete even if he has encrypted zero amount + mptAlice.authorize({ + .account = carol, + .flags = tfMPTUnauthorize, + .err = tecHAS_OBLIGATIONS, + }); + } + + // can delete mptoken if outstanding confidential balance is zero + { + Env env{*this, features}; + Account const alice("alice"); + Account const bob("bob"); + MPTTester mptAlice(env, alice, {.holders = {bob}}); + + mptAlice.create({ + .ownerCount = 1, + .flags = tfMPTCanTransfer | tfMPTCanLock | tfMPTCanConfidentialAmount, + }); + + mptAlice.authorize({ + .account = bob, + }); + mptAlice.generateKeyPair(alice); + + mptAlice.set({.account = alice, .issuerPubKey = mptAlice.getPubKey(alice)}); + + mptAlice.generateKeyPair(bob); + + mptAlice.convert({ + .account = bob, + .amt = 0, + .holderPubKey = mptAlice.getPubKey(bob), + }); + + mptAlice.authorize({ + .account = bob, + .flags = tfMPTUnauthorize, + }); + } + + // can delete mptoken if issuance has been destroyed and has + // encrypted zero balance + { + Env env{*this, features}; + Account const alice("alice"); + Account const bob("bob"); + MPTTester mptAlice(env, alice, {.holders = {bob}}); + + mptAlice.create({ + .ownerCount = 1, + .flags = tfMPTCanTransfer | tfMPTCanLock | tfMPTCanConfidentialAmount, + }); + + mptAlice.authorize({ + .account = bob, + }); + mptAlice.generateKeyPair(alice); + + mptAlice.set({.account = alice, .issuerPubKey = mptAlice.getPubKey(alice)}); + + mptAlice.generateKeyPair(bob); + + mptAlice.convert({ + .account = bob, + .amt = 0, + .holderPubKey = mptAlice.getPubKey(bob), + }); + + mptAlice.destroy(); + + mptAlice.authorize({ + .account = bob, + .flags = tfMPTUnauthorize, + }); + } + // test with convert back and delete + // can delete mptoken if converted back (COA returns to zero) + { + Env env{*this, features}; + Account const alice("alice"); + Account const bob("bob"); + MPTTester mptAlice(env, alice, {.holders = {bob}}); + + mptAlice.create({ + .ownerCount = 1, + .flags = tfMPTCanTransfer | tfMPTCanLock | tfMPTCanConfidentialAmount, + }); + + mptAlice.authorize({ + .account = bob, + }); + mptAlice.pay(alice, bob, 100); + + mptAlice.generateKeyPair(alice); + mptAlice.set({.account = alice, .issuerPubKey = mptAlice.getPubKey(alice)}); + mptAlice.generateKeyPair(bob); + + mptAlice.convert({ + .account = bob, + .amt = 100, + .holderPubKey = mptAlice.getPubKey(bob), + }); + + mptAlice.mergeInbox({ + .account = bob, + }); + + mptAlice.convertBack({ + .account = bob, + .amt = 100, + }); + + mptAlice.pay(bob, alice, 100); + + // Should be able to delete as Confidential Outstanding amount is 0 + mptAlice.authorize({ + .account = bob, + .flags = tfMPTUnauthorize, + }); + } + + // removeEmptyHolding: vault share MPToken with confidential balance + // fields should not be deleted on VaultWithdraw + { + Env env{*this, features | featureSingleAssetVault}; + Account const issuer("issuer"); + Account const owner("owner"); + Account const depositor("depositor"); + + MPTTester mptt{env, issuer, {.holders = {owner, depositor}}}; + mptt.create({ + .flags = tfMPTCanTransfer | tfMPTCanLock | tfMPTCanClawback, + }); + PrettyAsset const asset = mptt.issuanceID(); + mptt.authorize({.account = owner}); + mptt.authorize({.account = depositor}); + env(pay(issuer, depositor, asset(1000))); + env.close(); + + test::jtx::Vault const vault{env}; + auto [tx, vaultKeylet] = vault.create({.owner = owner, .asset = asset}); + env(tx); + env.close(); + + // Get the share MPTID from vault + auto const vaultSle = env.le(vaultKeylet); + BEAST_EXPECT(vaultSle != nullptr); + MPTID share = vaultSle->at(sfShareMPTID); + + // Depositor deposits into vault + tx = vault.deposit( + {.depositor = depositor, .id = vaultKeylet.key, .amount = asset(100)}); + env(tx); + env.close(); + + // Verify depositor has share tokens + auto shareMpt = env.le(keylet::mptoken(share, depositor.id())); + BEAST_EXPECT(shareMpt != nullptr); + + // Inject confidential balance fields on the share MPToken + // to simulate a scenario where vault shares somehow have + // confidential balances + env.app().getOpenLedger().modify([&](OpenView& view, beast::Journal) { + // Set lsfMPTCanConfidentialAmount on the share issuance + // so the invariant allows encrypted fields on the MPToken + auto issuance = std::const_pointer_cast(view.read(keylet::mptIssuance(share))); + if (!issuance) + return false; + issuance->setFlag(lsfMPTCanConfidentialAmount); + view.rawReplace(issuance); + + auto const k = keylet::mptoken(share, depositor.id()); + auto sle = std::const_pointer_cast(view.read(k)); + if (!sle) + return false; + // Inject dummy confidential balance fields + Buffer dummyCiphertext(ecGamalEncryptedTotalLength); + std::memset(dummyCiphertext.data(), 0, ecGamalEncryptedTotalLength); + dummyCiphertext.data()[0] = ecCompressedPrefixEvenY; + dummyCiphertext.data()[ecGamalEncryptedLength] = ecCompressedPrefixEvenY; + dummyCiphertext.data()[ecGamalEncryptedLength - 1] = 0x01; + dummyCiphertext.data()[ecGamalEncryptedTotalLength - 1] = 0x01; + sle->setFieldVL(sfConfidentialBalanceSpending, dummyCiphertext); + sle->setFieldVL(sfConfidentialBalanceInbox, dummyCiphertext); + sle->setFieldVL(sfIssuerEncryptedBalance, dummyCiphertext); + view.rawReplace(sle); + return true; + }); + + // Withdraw everything - which should fail because of the confidential balance fields + tx = vault.withdraw( + {.depositor = depositor, .id = vaultKeylet.key, .amount = asset(100)}); + env(tx); + + // The share MPToken should still exist because the + // withdrawal failed due to confidential balance obligations + shareMpt = env.le(keylet::mptoken(share, depositor.id())); + BEAST_EXPECT(shareMpt != nullptr); + } + } + + void + testConvertBack(FeatureBitset features) + { + testcase("Convert back"); + using namespace test::jtx; + + // Basic convert back test + { + Env env{*this, features}; + Account const alice("alice"); + Account const bob("bob"); + MPTTester mptAlice(env, alice, {.holders = {bob}}); + + mptAlice.create({ + .ownerCount = 1, + .flags = tfMPTCanTransfer | tfMPTCanLock | tfMPTCanConfidentialAmount, + }); + + mptAlice.authorize({ + .account = bob, + }); + mptAlice.pay(alice, bob, 100); + + mptAlice.generateKeyPair(alice); + + mptAlice.set({.account = alice, .issuerPubKey = mptAlice.getPubKey(alice)}); + + mptAlice.generateKeyPair(bob); + + mptAlice.convert({ + .account = bob, + .amt = 40, + .holderPubKey = mptAlice.getPubKey(bob), + }); + + mptAlice.mergeInbox({ + .account = bob, + }); + + mptAlice.convertBack({ + .account = bob, + .amt = 30, + }); + + mptAlice.convertBack({ + .account = bob, + .amt = 10, + }); + } + + // Edge case: minimum amount (1) + { + Env env{*this, features}; + Account const alice("alice"); + Account const bob("bob"); + MPTTester mptAlice(env, alice, {.holders = {bob}}); + + mptAlice.create({ + .ownerCount = 1, + .flags = tfMPTCanTransfer | tfMPTCanLock | tfMPTCanConfidentialAmount, + }); + + mptAlice.authorize({ + .account = bob, + }); + mptAlice.pay(alice, bob, 2); + + mptAlice.generateKeyPair(alice); + mptAlice.set({.account = alice, .issuerPubKey = mptAlice.getPubKey(alice)}); + + mptAlice.generateKeyPair(bob); + + mptAlice.convert({ + .account = bob, + .amt = 2, + .holderPubKey = mptAlice.getPubKey(bob), + }); + + mptAlice.mergeInbox({ + .account = bob, + }); + + mptAlice.convertBack({ + .account = bob, + .amt = 1, + }); + } + + // Edge case: maxMPTokenAmount + // Using raw JSON to avoid automatic decryption checks in MPTTester + // which don't work for very large amounts (brute-force decryption is slow) + // TODO: improve this test once there is bounded decryption or optimized decryption for + // large amounts + { + Env env{*this, features}; + Account const alice("alice"); + Account const bob("bob"); + MPTTester mptAlice(env, alice, {.holders = {bob}}); + + mptAlice.create({ + .ownerCount = 1, + .flags = tfMPTCanTransfer | tfMPTCanLock | tfMPTCanConfidentialAmount, + }); + + mptAlice.authorize({ + .account = bob, + }); + mptAlice.pay(alice, bob, maxMPTokenAmount); + + mptAlice.generateKeyPair(alice); + mptAlice.set({.account = alice, .issuerPubKey = mptAlice.getPubKey(alice)}); + + mptAlice.generateKeyPair(bob); + + // Convert maxMPTokenAmount to confidential using raw JSON + Buffer const convertBlindingFactor = generateBlindingFactor(); + auto const convertHolderCiphertext = + mptAlice.encryptAmount(bob, maxMPTokenAmount, convertBlindingFactor); + auto const convertIssuerCiphertext = + mptAlice.encryptAmount(alice, maxMPTokenAmount, convertBlindingFactor); + auto const convertContextHash = + getConvertContextHash(bob.id(), mptAlice.issuanceID(), env.seq(bob)); + auto const schnorrProof = mptAlice.getSchnorrProof(bob, convertContextHash); + BEAST_EXPECT(schnorrProof.has_value()); + + { + Json::Value jv; + jv[jss::Account] = bob.human(); + jv[jss::TransactionType] = jss::ConfidentialMPTConvert; + jv[sfMPTokenIssuanceID] = to_string(mptAlice.issuanceID()); + jv[sfMPTAmount.jsonName] = std::to_string(maxMPTokenAmount); + jv[sfHolderEncryptionKey.jsonName] = strHex(*mptAlice.getPubKey(bob)); + jv[sfHolderEncryptedAmount.jsonName] = strHex(convertHolderCiphertext); + jv[sfIssuerEncryptedAmount.jsonName] = strHex(convertIssuerCiphertext); + jv[sfBlindingFactor.jsonName] = strHex(convertBlindingFactor); + jv[sfZKProof.jsonName] = strHex(*schnorrProof); + + env(jv, ter(tesSUCCESS)); + } + + // Merge inbox using raw JSON - moves funds from inbox to spending balance + { + Json::Value jv; + jv[jss::Account] = bob.human(); + jv[jss::TransactionType] = jss::ConfidentialMPTMergeInbox; + jv[sfMPTokenIssuanceID] = to_string(mptAlice.issuanceID()); + + env(jv, ter(tesSUCCESS)); + } + + // ConvertBack maxMPTokenAmount - 1 using raw JSON + // After convert + merge, spending balance = maxMPTokenAmount + // We convert back maxMPTokenAmount - 1 to leave remainder of 1 + std::uint64_t const convertBackAmt = maxMPTokenAmount - 1; + + Buffer const convertBackBlindingFactor = generateBlindingFactor(); + auto const convertBackHolderCiphertext = + mptAlice.encryptAmount(bob, convertBackAmt, convertBackBlindingFactor); + auto const convertBackIssuerCiphertext = + mptAlice.encryptAmount(alice, convertBackAmt, convertBackBlindingFactor); + + // Get the encrypted spending balance from ledger (no decryption needed) + auto const encryptedSpendingBalance = + mptAlice.getEncryptedBalance(bob, MPTTester::HOLDER_ENCRYPTED_SPENDING); + BEAST_EXPECT(encryptedSpendingBalance.has_value()); + + // Generate pedersen commitment for the known spending balance + Buffer const pcBlindingFactor = generateBlindingFactor(); + Buffer const pedersenCommitment = + mptAlice.getPedersenCommitment(maxMPTokenAmount, pcBlindingFactor); + + // Generate the proof using known spending balance value + auto const version = mptAlice.getMPTokenVersion(bob); + uint256 const convertBackContextHash = + getConvertBackContextHash(bob.id(), mptAlice.issuanceID(), env.seq(bob), version); + + Buffer const proof = mptAlice.getConvertBackProof( + bob, + convertBackAmt, + convertBackContextHash, + { + .pedersenCommitment = pedersenCommitment, + .amt = maxMPTokenAmount, + .encryptedAmt = *encryptedSpendingBalance, + .blindingFactor = pcBlindingFactor, + }); + + { + Json::Value jv; + jv[jss::Account] = bob.human(); + jv[jss::TransactionType] = jss::ConfidentialMPTConvertBack; + jv[sfMPTokenIssuanceID] = to_string(mptAlice.issuanceID()); + jv[sfMPTAmount.jsonName] = std::to_string(convertBackAmt); + jv[sfHolderEncryptedAmount.jsonName] = strHex(convertBackHolderCiphertext); + jv[sfIssuerEncryptedAmount.jsonName] = strHex(convertBackIssuerCiphertext); + jv[sfBlindingFactor.jsonName] = strHex(convertBackBlindingFactor); + jv[sfBalanceCommitment.jsonName] = strHex(pedersenCommitment); + jv[sfZKProof.jsonName] = strHex(proof); + + env(jv, ter(tesSUCCESS)); + } + + // Verify the public balance was restored (minus 1 remaining in confidential) + env.require(mptbalance(mptAlice, bob, convertBackAmt)); + } + } + + void + testConvertBackWithAuditor(FeatureBitset features) + { + testcase("Convert back with auditor"); + using namespace test::jtx; + + Env env{*this, features}; + Account const alice("alice"); + Account const bob("bob"); + Account const auditor("auditor"); + MPTTester mptAlice( + env, + alice, + { + .holders = {bob}, + .auditor = auditor, + }); + + mptAlice.create({ + .ownerCount = 1, + .flags = tfMPTCanTransfer | tfMPTCanLock | tfMPTCanConfidentialAmount, + }); + + mptAlice.authorize({ + .account = bob, + }); + mptAlice.pay(alice, bob, 100); + + mptAlice.generateKeyPair(alice); + mptAlice.generateKeyPair(auditor); + + mptAlice.set( + {.account = alice, + .issuerPubKey = mptAlice.getPubKey(alice), + .auditorPubKey = mptAlice.getPubKey(auditor)}); + + mptAlice.generateKeyPair(bob); + + mptAlice.convert({ + .account = bob, + .amt = 40, + .holderPubKey = mptAlice.getPubKey(bob), + }); + + mptAlice.mergeInbox({ + .account = bob, + }); + + mptAlice.convertBack({ + .account = bob, + .amt = 30, + }); + } + + void + testConvertBackPreflight(FeatureBitset features) + { + testcase("Convert back preflight"); + using namespace test::jtx; + + { + Env env{*this, features - featureConfidentialTransfer}; + Account const alice("alice"); + Account const bob("bob"); + MPTTester mptAlice(env, alice, {.holders = {bob}}); + + mptAlice.create({ + .ownerCount = 1, + .flags = tfMPTCanTransfer | tfMPTCanLock, + }); + + mptAlice.authorize({ + .account = bob, + }); + mptAlice.pay(alice, bob, 100); + + mptAlice.generateKeyPair(alice); + mptAlice.generateKeyPair(bob); + + mptAlice.convertBack({ + .account = bob, + .amt = 30, + .err = temDISABLED, + }); + } + + { + Env env{*this, features}; + Account const alice("alice"); + Account const bob("bob"); + MPTTester mptAlice(env, alice, {.holders = {bob}}); + + mptAlice.create({ + .ownerCount = 1, + .flags = tfMPTCanTransfer | tfMPTCanLock | tfMPTCanConfidentialAmount, + }); + + mptAlice.authorize({ + .account = bob, + }); + mptAlice.pay(alice, bob, 100); + + mptAlice.generateKeyPair(alice); + + mptAlice.set({.account = alice, .issuerPubKey = mptAlice.getPubKey(alice)}); + + mptAlice.generateKeyPair(bob); + + mptAlice.convert({ + .account = bob, + .amt = 40, + .holderPubKey = mptAlice.getPubKey(bob), + }); + + mptAlice.mergeInbox({ + .account = bob, + }); + + mptAlice.convertBack({ + .account = alice, + .amt = 30, + .err = temMALFORMED, + }); + + mptAlice.convertBack({ + .account = bob, + .amt = 0, + .err = temBAD_AMOUNT, + }); + + mptAlice.convertBack({ + .account = bob, + .amt = maxMPTokenAmount + 1, + .err = temBAD_AMOUNT, + }); + + // Balance commitment has correct length but invalid EC point data + mptAlice.convertBack({ + .account = bob, + .amt = 30, + .pedersenCommitment = makeZeroBuffer(ecPedersenCommitmentLength), + .err = temMALFORMED, + }); + + mptAlice.convertBack({ + .account = bob, + .amt = 30, + .holderEncryptedAmt = Buffer{}, + .err = temBAD_CIPHERTEXT, + }); + + mptAlice.convertBack({ + .account = bob, + .amt = 30, + .issuerEncryptedAmt = Buffer{}, + .err = temBAD_CIPHERTEXT, + }); + + mptAlice.convertBack({ + .account = bob, + .amt = 30, + .holderEncryptedAmt = getBadCiphertext(), + .err = temBAD_CIPHERTEXT, + }); + + mptAlice.convertBack({ + .account = bob, + .amt = 30, + .issuerEncryptedAmt = getBadCiphertext(), + .err = temBAD_CIPHERTEXT, + }); + + mptAlice.convertBack({ + .account = bob, + .amt = 30, + .auditorEncryptedAmt = makeZeroBuffer(10), + .err = temBAD_CIPHERTEXT, + }); + + mptAlice.convertBack({ + .account = bob, + .amt = 30, + .auditorEncryptedAmt = getBadCiphertext(), + .err = temBAD_CIPHERTEXT, + }); + + // invalid proof length + mptAlice.convertBack({ + .account = bob, + .amt = 30, + .proof = Buffer{}, + .err = temMALFORMED, + }); + + mptAlice.convertBack({ + .account = bob, + .amt = 30, + .proof = makeZeroBuffer(100), + .err = temMALFORMED, + }); + } + } + + void + testConvertBackPreclaim(FeatureBitset features) + { + testcase("Convert back preclaim"); + using namespace test::jtx; + + // issuance does not exist + { + Env env{*this, features}; + Account const alice("alice"); + Account const bob("bob"); + MPTTester mptAlice(env, alice, {.holders = {bob}}); + + mptAlice.create({ + .ownerCount = 1, + .flags = tfMPTCanTransfer | tfMPTCanLock | tfMPTCanConfidentialAmount, + }); + + mptAlice.authorize({ + .account = bob, + }); + mptAlice.generateKeyPair(alice); + + mptAlice.set({.account = alice, .issuerPubKey = mptAlice.getPubKey(alice)}); + + mptAlice.destroy(); + mptAlice.generateKeyPair(bob); + + mptAlice.convertBack({ + .account = bob, + .amt = 30, + .err = tecOBJECT_NOT_FOUND, + }); + } + + // tfMPTCanConfidentialAmount is not set on issuance + { + Env env{*this, features}; + Account const alice("alice"); + Account const bob("bob"); + MPTTester mptAlice(env, alice, {.holders = {bob}}); + + mptAlice.create({ + .ownerCount = 1, + .flags = tfMPTCanTransfer | tfMPTCanLock, + }); + + mptAlice.authorize({ + .account = bob, + }); + mptAlice.pay(alice, bob, 100); + + mptAlice.generateKeyPair(alice); + mptAlice.generateKeyPair(bob); + + mptAlice.convertBack({ + .account = bob, + .amt = 30, + .err = tecNO_PERMISSION, + }); + } + + // no mptoken + { + Env env{*this, features}; + Account const alice("alice"); + Account const bob("bob"); + MPTTester mptAlice(env, alice, {.holders = {bob}}); + + mptAlice.create({ + .ownerCount = 1, + .flags = tfMPTCanTransfer | tfMPTCanLock | tfMPTCanConfidentialAmount, + }); + + mptAlice.generateKeyPair(alice); + mptAlice.generateKeyPair(bob); + + mptAlice.set({.account = alice, .issuerPubKey = mptAlice.getPubKey(alice)}); + + mptAlice.convertBack({ + .account = bob, + .amt = 30, + .err = tecOBJECT_NOT_FOUND, + }); + } + + // bob doesn't have encrypted 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 | tfMPTCanLock | tfMPTCanConfidentialAmount, + }); + + mptAlice.authorize({ + .account = bob, + }); + mptAlice.pay(alice, bob, 100); + + mptAlice.generateKeyPair(alice); + + mptAlice.set({.account = alice, .issuerPubKey = mptAlice.getPubKey(alice)}); + + mptAlice.generateKeyPair(bob); + + mptAlice.convertBack({ + .account = bob, + .amt = 30, + .err = tecNO_PERMISSION, + }); + } + + // bob tries to convert back more than COA + { + Env env{*this, features}; + Account const alice("alice"); + Account const bob("bob"); + Account const carol("carol"); + MPTTester mptAlice(env, alice, {.holders = {bob, carol}}); + + mptAlice.create({ + .ownerCount = 1, + .flags = tfMPTCanTransfer | tfMPTCanLock | tfMPTCanConfidentialAmount, + }); + + mptAlice.authorize({ + .account = bob, + }); + mptAlice.authorize({ + .account = carol, + }); + mptAlice.pay(alice, bob, 100); + mptAlice.pay(alice, carol, 100); + + mptAlice.generateKeyPair(alice); + + mptAlice.set({.account = alice, .issuerPubKey = mptAlice.getPubKey(alice)}); + + mptAlice.generateKeyPair(bob); + mptAlice.generateKeyPair(carol); + + mptAlice.convert({ + .account = bob, + .amt = 40, + .holderPubKey = mptAlice.getPubKey(bob), + }); + + mptAlice.mergeInbox({ + .account = bob, + }); + + mptAlice.convert({ + .account = carol, + .amt = 40, + .holderPubKey = mptAlice.getPubKey(carol), + }); + + mptAlice.convertBack({ + .account = bob, + .amt = 300, + .err = tecINSUFFICIENT_FUNDS, + }); + } + + // cannot convert if locked or unauth + { + Env env{*this, features}; + Account const alice("alice"); + Account const bob("bob"); + MPTTester mptAlice(env, alice, {.holders = {bob}}); + + mptAlice.create({ + .ownerCount = 1, + .flags = + tfMPTCanTransfer | tfMPTCanLock | tfMPTRequireAuth | tfMPTCanConfidentialAmount, + }); + + mptAlice.authorize({ + .account = bob, + }); + mptAlice.authorize({ + .account = alice, + .holder = bob, + }); + mptAlice.pay(alice, bob, 100); + + mptAlice.generateKeyPair(alice); + + mptAlice.set({.account = alice, .issuerPubKey = mptAlice.getPubKey(alice)}); + + mptAlice.generateKeyPair(bob); + + mptAlice.convert({ + .account = bob, + .amt = 40, + .holderPubKey = mptAlice.getPubKey(bob), + }); + + mptAlice.mergeInbox({ + .account = bob, + }); + + mptAlice.set({ + .account = alice, + .holder = bob, + .flags = tfMPTLock, + }); + + mptAlice.convertBack({ + .account = bob, + .amt = 10, + .err = tecLOCKED, + }); + + mptAlice.set({ + .account = alice, + .holder = bob, + .flags = tfMPTUnlock, + }); + + mptAlice.convertBack({ + .account = bob, + .amt = 10, + }); + + mptAlice.authorize({ + .account = alice, + .holder = bob, + .flags = tfMPTUnauthorize, + }); + + mptAlice.convertBack({ + .account = bob, + .amt = 10, + .err = tecNO_AUTH, + }); + + mptAlice.authorize({ + .account = alice, + .holder = bob, + }); + + mptAlice.convertBack({ + .account = bob, + .amt = 10, + }); + } + + // Verification of holder and issuer ciphertexts during convertBack + { + Env env{*this, features}; + Account const alice("alice"); + Account const bob("bob"); + MPTTester mptAlice(env, alice, {.holders = {bob}}); + + mptAlice.create({ + .ownerCount = 1, + .flags = tfMPTCanTransfer | tfMPTCanLock | tfMPTCanConfidentialAmount, + }); + mptAlice.authorize({ + .account = bob, + }); + mptAlice.pay(alice, bob, 100); + + mptAlice.generateKeyPair(alice); + mptAlice.generateKeyPair(bob); + + mptAlice.set({.account = alice, .issuerPubKey = mptAlice.getPubKey(alice)}); + + mptAlice.convert({.account = bob, .amt = 50, .holderPubKey = mptAlice.getPubKey(bob)}); + mptAlice.mergeInbox({ + .account = bob, + }); + + // Holder encrypted amount is valid format but mathematically incorrect for this + // convertBack + mptAlice.convertBack({ + .account = bob, + .amt = 10, + .holderEncryptedAmt = getTrivialCiphertext(), + .err = tecBAD_PROOF, + }); + + // Issuer encrypted amount is valid format but mathematically incorrect for this + // convertBack + mptAlice.convertBack({ + .account = bob, + .amt = 10, + .issuerEncryptedAmt = getTrivialCiphertext(), + .err = tecBAD_PROOF, + }); + } + + // Alice has NOT set an auditor key, but Bob provides + // auditorEncryptedAmt + { + Env env{*this, features}; + Account const alice("alice"); + Account const bob("bob"); + MPTTester mptAlice(env, alice, {.holders = {bob}}); + + mptAlice.create({ + .ownerCount = 1, + .flags = tfMPTCanTransfer | tfMPTCanLock | tfMPTCanConfidentialAmount, + }); + mptAlice.authorize({ + .account = bob, + }); + mptAlice.pay(alice, bob, 100); + + mptAlice.generateKeyPair(alice); + mptAlice.generateKeyPair(bob); + mptAlice.set({.account = alice, .issuerPubKey = mptAlice.getPubKey(alice)}); + + // Bob converts funds to confidential so he has something to convert + // back + mptAlice.convert({.account = bob, .amt = 50, .holderPubKey = mptAlice.getPubKey(bob)}); + mptAlice.mergeInbox({ + .account = bob, + }); + + mptAlice.convertBack({ + .account = bob, + .amt = 10, + // Provide valid ciphertext to pass preflight + .auditorEncryptedAmt = getTrivialCiphertext(), + .err = tecNO_PERMISSION, + }); + } + + // we set the auditor key, but convertBack omits auditorEncryptedAmt + { + Env env{*this, features}; + Account const alice("alice"); + Account const bob("bob"); + Account const auditor("auditor"); + MPTTester mptAlice( + env, + alice, + { + .holders = {bob}, + .auditor = auditor, + }); + + mptAlice.create({ + .flags = tfMPTCanTransfer | tfMPTCanLock | tfMPTCanConfidentialAmount, + }); + mptAlice.authorize({ + .account = bob, + }); + mptAlice.pay(alice, bob, 100); + + mptAlice.generateKeyPair(alice); + mptAlice.generateKeyPair(bob); + mptAlice.generateKeyPair(auditor); + mptAlice.set( + {.account = alice, + .issuerPubKey = mptAlice.getPubKey(alice), + .auditorPubKey = mptAlice.getPubKey(auditor)}); + + // Convert funds so Bob has a balance + mptAlice.convert({ + .account = bob, + .amt = 50, + .holderPubKey = mptAlice.getPubKey(bob), + }); + mptAlice.mergeInbox({ + .account = bob, + }); + + // ConvertBack WITHOUT auditorEncryptedAmt + mptAlice.convertBack({ + .account = bob, + .amt = 10, + .fillAuditorEncryptedAmt = false, + .err = tecNO_PERMISSION, + }); + + // ConvertBack where auditor ciphertext mathematically + // correct, but contains invalid data (mismatching amount). + mptAlice.convertBack({ + .account = bob, + .amt = 10, + .auditorEncryptedAmt = getTrivialCiphertext(), + .err = tecBAD_PROOF, + }); + } + } + + void + testSendDepositPreauth(FeatureBitset features) + { + testcase("Send deposit preauth"); + using namespace test::jtx; + + // When an account enables lsfDepositAuth (via asfDepositAuth flag), + // it requires explicit authorization before accepting incoming payments. + // + // There are two authorization mechanisms: + // + // 1. DIRECT ACCOUNT AUTHORIZATION (deposit::auth) + // - Bob directly authorizes Carol: deposit::auth(bob, carol) + // - Simple 1-to-1 trust relationship + // - Carol can send to Bob without credentials + // + // 2. CREDENTIAL-BASED AUTHORIZATION (deposit::authCredentials) + // - A trusted third party (dpIssuer) issues credentials + // - Bob authorizes a credential TYPE from an issuer + // - Anyone holding that credential can send to Bob + // - Requires sender to include credential ID in transaction + + Account const alice("alice"); + Account const bob("bob"); + Account const carol("carol"); + Account const dpIssuer("dpIssuer"); + char const credType[] = "KYC_VERIFIED"; + + // Common setup: create MPT with privacy, convert both carol and bob + auto setupMPT = [&](Env& env, MPTTester& mpt) { + mpt.create({ + .ownerCount = 1, + .flags = tfMPTCanTransfer | tfMPTCanLock | tfMPTCanConfidentialAmount, + }); + mpt.authorize({ + .account = bob, + }); + mpt.authorize({ + .account = carol, + }); + mpt.pay(alice, bob, 100); + mpt.pay(alice, carol, 100); + + mpt.generateKeyPair(alice); + mpt.generateKeyPair(bob); + mpt.generateKeyPair(carol); + mpt.set({.account = alice, .issuerPubKey = mpt.getPubKey(alice)}); + + mpt.convert({.account = carol, .amt = 50, .holderPubKey = mpt.getPubKey(carol)}); + mpt.convert({.account = bob, .amt = 50, .holderPubKey = mpt.getPubKey(bob)}); + mpt.mergeInbox({ + .account = carol, + }); + mpt.mergeInbox({ + .account = bob, + }); + + env(fset(bob, asfDepositAuth)); + env.close(); + }; + + // Create and accept credential for an account + auto createCredential = [&](Env& env, Account const& subject) -> std::string { + env(credentials::create(subject, dpIssuer, credType)); + env.close(); + env(credentials::accept(subject, dpIssuer, credType)); + env.close(); + auto const jv = credentials::ledgerEntry(env, subject, dpIssuer, credType); + return jv[jss::result][jss::index].asString(); + }; + + // TEST 1: Direct Account Authorization + { + Env env(*this, features); + MPTTester mpt(env, alice, {.holders = {bob, carol}}); + setupMPT(env, mpt); + + // Carol cannot send to Bob without authorization + mpt.send({ + .account = carol, + .dest = bob, + .amt = 10, + .err = tecNO_PERMISSION, + }); + + // Bob directly authorizes Carol + env(deposit::auth(bob, carol)); + env.close(); + + // Now Carol can send to Bob + mpt.send({ + .account = carol, + .dest = bob, + .amt = 10, + }); + mpt.mergeInbox({ + .account = bob, + }); + + // Bob revokes Carol's authorization + env(deposit::unauth(bob, carol)); + env.close(); + + // Carol can no longer send to Bob + mpt.send({ + .account = carol, + .dest = bob, + .amt = 10, + .err = tecNO_PERMISSION, + }); + } + + // TEST 2: Credential-Based Authorization + { + Env env(*this, features); + env.fund(XRP(50000), dpIssuer); + env.close(); + + MPTTester mpt(env, alice, {.holders = {bob, carol}}); + setupMPT(env, mpt); + + auto const credIdx = createCredential(env, carol); + + // Carol cannot send yet - Bob hasn't authorized this credential type + mpt.send({ + .account = carol, + .dest = bob, + .amt = 10, + .credentials = {{credIdx}}, + .err = tecNO_PERMISSION, + }); + + // Bob authorizes the credential type from dpIssuer + env(deposit::authCredentials(bob, {{dpIssuer, credType}})); + env.close(); + + // Carol still cannot send without including credential + mpt.send({ + .account = carol, + .dest = bob, + .amt = 10, + .err = tecNO_PERMISSION, + }); + + // Carol CAN send when including her credential + mpt.send({.account = carol, .dest = bob, .amt = 10, .credentials = {{credIdx}}}); + mpt.mergeInbox({ + .account = bob, + }); + } + + // TEST 3: Direct Auth Takes Precedence Over Credentials + { + Env env(*this, features); + env.fund(XRP(50000), dpIssuer); + env.close(); + + MPTTester mpt(env, alice, {.holders = {bob, carol}}); + setupMPT(env, mpt); + + auto const credIdx = createCredential(env, carol); + + // Bob directly authorizes Carol (no credential needed) + env(deposit::auth(bob, carol)); + env.close(); + + // Carol can send without credentials (direct auth) + mpt.send({ + .account = carol, + .dest = bob, + .amt = 10, + }); + mpt.mergeInbox({ + .account = bob, + }); + + // Carol can also send WITH credentials (still works) + mpt.send({.account = carol, .dest = bob, .amt = 10, .credentials = {{credIdx}}}); + mpt.mergeInbox({ + .account = bob, + }); + + // Bob revokes direct authorization + env(deposit::unauth(bob, carol)); + env.close(); + + // Carol cannot send without credentials anymore + mpt.send({ + .account = carol, + .dest = bob, + .amt = 10, + .err = tecNO_PERMISSION, + }); + + // But credential-based auth not set up, so this also fails + mpt.send({ + .account = carol, + .dest = bob, + .amt = 10, + .credentials = {{credIdx}}, + .err = tecNO_PERMISSION, + }); + + // Bob authorizes the credential type + env(deposit::authCredentials(bob, {{dpIssuer, credType}})); + env.close(); + + // Now Carol can send with credentials + mpt.send({.account = carol, .dest = bob, .amt = 10, .credentials = {{credIdx}}}); + } + } + + void + testSendCredentialValidation(FeatureBitset features) + { + testcase("Send credential validation"); + using namespace test::jtx; + + // Tests for credentials::checkFields (preflight) and + // credentials::valid (preclaim) validation. + // + // Preflight checks (temMALFORMED): + // - Empty credentials array + // - Array size exceeds maxCredentialsArraySize (8) + // - Duplicate credential IDs in array + // + // Preclaim checks (tecBAD_CREDENTIALS): + // - Credential doesn't exist + // - Credential doesn't belong to source account + // - Credential not accepted (lsfAccepted flag not set) + + Account const alice("alice"); + Account const bob("bob"); + Account const carol("carol"); + Account const dpIssuer("dpIssuer"); + char const credType[] = "KYC"; + + // Common setup: create MPT with privacy, convert carol and bob to confidential + auto setupBasic = [&](Env& env, MPTTester& mpt) { + mpt.create({ + .ownerCount = 1, + .flags = tfMPTCanTransfer | tfMPTCanLock | tfMPTCanConfidentialAmount, + }); + mpt.authorize({ + .account = bob, + }); + mpt.authorize({ + .account = carol, + }); + mpt.pay(alice, bob, 100); + mpt.pay(alice, carol, 100); + + mpt.generateKeyPair(alice); + mpt.generateKeyPair(bob); + mpt.generateKeyPair(carol); + mpt.set({.account = alice, .issuerPubKey = mpt.getPubKey(alice)}); + + mpt.convert({.account = carol, .amt = 50, .holderPubKey = mpt.getPubKey(carol)}); + mpt.convert({.account = bob, .amt = 50, .holderPubKey = mpt.getPubKey(bob)}); + mpt.mergeInbox({ + .account = carol, + }); + mpt.mergeInbox({ + .account = bob, + }); + }; + + // TEST 1: Preflight - Empty Credentials Array + { + Env env(*this, features); + MPTTester mpt(env, alice, {.holders = {bob, carol}}); + setupBasic(env, mpt); + + mpt.send({ + .account = carol, + .dest = bob, + .amt = 10, + .credentials = std::vector{}, + .err = temMALFORMED, + }); + } + + // TEST 2: Preflight - Credentials Array Too Large + { + Env env(*this, features); + MPTTester mpt(env, alice, {.holders = {bob, carol}}); + setupBasic(env, mpt); + + std::vector tooManyCredentials; + tooManyCredentials.reserve(9); + for (int i = 0; i < 9; ++i) + tooManyCredentials.push_back(to_string(uint256(i))); + + mpt.send({ + .account = carol, + .dest = bob, + .amt = 10, + .credentials = tooManyCredentials, + .err = temMALFORMED, + }); + } + + // TEST 3: Preflight - Duplicate Credentials + { + Env env(*this, features); + env.fund(XRP(50000), dpIssuer); + env.close(); + MPTTester mpt(env, alice, {.holders = {bob, carol}}); + setupBasic(env, mpt); + + env(credentials::create(carol, dpIssuer, credType)); + env.close(); + env(credentials::accept(carol, dpIssuer, credType)); + env.close(); + + auto const jv = credentials::ledgerEntry(env, carol, dpIssuer, credType); + std::string const credIdx = jv[jss::result][jss::index].asString(); + + mpt.send({ + .account = carol, + .dest = bob, + .amt = 10, + .credentials = {{credIdx, credIdx}}, + .err = temMALFORMED, + }); + } + + // TEST 4: Preclaim - Credential Doesn't Exist + { + Env env(*this, features); + MPTTester mpt(env, alice, {.holders = {bob, carol}}); + setupBasic(env, mpt); + + std::string const fakeCredIdx = to_string(uint256(999)); + mpt.send({ + .account = carol, + .dest = bob, + .amt = 10, + .credentials = {{fakeCredIdx}}, + .err = tecBAD_CREDENTIALS, + }); + } + + // TEST 5: Preclaim - Credential Doesn't Belong to Source Account + { + Env env(*this, features); + env.fund(XRP(50000), dpIssuer); + env.close(); + MPTTester mpt(env, alice, {.holders = {bob, carol}}); + setupBasic(env, mpt); + + // Create credential for BOB (not carol) + env(credentials::create(bob, dpIssuer, credType)); + env.close(); + env(credentials::accept(bob, dpIssuer, credType)); + env.close(); + + auto const jv = credentials::ledgerEntry(env, bob, dpIssuer, credType); + std::string const credIdx = jv[jss::result][jss::index].asString(); + + mpt.send({ + .account = carol, + .dest = bob, + .amt = 10, + .credentials = {{credIdx}}, + .err = tecBAD_CREDENTIALS, + }); + } + + // TEST 6: Preclaim - Credential Not Accepted + { + Env env(*this, features); + env.fund(XRP(50000), dpIssuer); + env.close(); + MPTTester mpt(env, alice, {.holders = {bob, carol}}); + setupBasic(env, mpt); + + // Create credential but DON'T accept it + env(credentials::create(carol, dpIssuer, credType)); + env.close(); + + auto const jv = credentials::ledgerEntry(env, carol, dpIssuer, credType); + std::string const credIdx = jv[jss::result][jss::index].asString(); + + mpt.send({ + .account = carol, + .dest = bob, + .amt = 10, + .credentials = {{credIdx}}, + .err = tecBAD_CREDENTIALS, + }); + } + } + + void + testClawback(FeatureBitset features) + { + testcase("test ConfidentialMPTClawback"); + using namespace test::jtx; + + Env env{*this, features}; + Account const alice("alice"); + Account const bob("bob"); + Account const carol("carol"); + Account const dave("dave"); + MPTTester mptAlice(env, alice, {.holders = {bob, carol, dave}}); + + mptAlice.create({ + .flags = + tfMPTCanTransfer | tfMPTCanLock | tfMPTCanClawback | tfMPTCanConfidentialAmount, + }); + mptAlice.authorize({ + .account = bob, + }); + mptAlice.pay(alice, bob, 100); + mptAlice.authorize({ + .account = carol, + }); + mptAlice.pay(alice, carol, 200); + mptAlice.authorize({ + .account = dave, + }); + mptAlice.pay(alice, dave, 300); + + mptAlice.generateKeyPair(alice); + mptAlice.generateKeyPair(bob); + mptAlice.generateKeyPair(carol); + mptAlice.generateKeyPair(dave); + mptAlice.set({.account = alice, .issuerPubKey = mptAlice.getPubKey(alice)}); + + // setup bob. + // after setup, bob's spending balance is 60, inbox balance is 0. + { + // bob converts 60 to confidential + mptAlice.convert({.account = bob, .amt = 60, .holderPubKey = mptAlice.getPubKey(bob)}); + + // bob merge inbox + mptAlice.mergeInbox({ + .account = bob, + }); + } + + // setup carol. + // after setup, carol's spending balance is 120, inbox balance is 0. + { + // carol converts 120 to confidential + mptAlice.convert( + {.account = carol, .amt = 120, .holderPubKey = mptAlice.getPubKey(carol)}); + + // carol merge inbox + mptAlice.mergeInbox({ + .account = carol, + }); + } + + // setup dave. + // dave will not merge inbox. + // after setup, dave's inbox balance is 200, spending balance is 0. + mptAlice.convert({.account = dave, .amt = 200, .holderPubKey = mptAlice.getPubKey(dave)}); + + // setup: carol confidential send 50 to bob. + // after send, bob's inbox balance is 50, spending balance + // remains 60. carol's inbox balance remains 0, spending balance + // drops to 70. + mptAlice.send({ + .account = carol, + .dest = bob, + .amt = 50, + }); + + // alice clawback all confidential balance from bob, 110 in total. + // bob has balance in both inbox and spending. These balances should + // become zero after clawback, which is verified in the + // confidentialClaw function. + mptAlice.confidentialClaw({ + .account = alice, + .holder = bob, + .amt = 110, + }); + + // alice clawback all confidential balance from carol, which is 70. + // carol only has balance in spending. + mptAlice.confidentialClaw({ + .account = alice, + .holder = carol, + .amt = 70, + }); + + // alice clawback all confidential balance from dave, which is 200. + // dave only has balance in inbox. + mptAlice.confidentialClaw({ + .account = alice, + .holder = dave, + .amt = 200, + }); + } + + void + testClawbackWithAuditor(FeatureBitset features) + { + testcase("test ConfidentialMPTClawback with auditor"); + using namespace test::jtx; + + Env env{*this, features}; + Account const alice("alice"); + Account const bob("bob"); + Account const carol("carol"); + Account const dave("dave"); + Account const auditor("auditor"); + MPTTester mptAlice( + env, + alice, + { + .holders = {bob, carol, dave}, + .auditor = auditor, + }); + + mptAlice.create({ + .flags = + tfMPTCanTransfer | tfMPTCanLock | tfMPTCanClawback | tfMPTCanConfidentialAmount, + }); + mptAlice.authorize({ + .account = bob, + }); + mptAlice.pay(alice, bob, 100); + mptAlice.authorize({ + .account = carol, + }); + mptAlice.pay(alice, carol, 200); + mptAlice.authorize({ + .account = dave, + }); + mptAlice.pay(alice, dave, 300); + + mptAlice.generateKeyPair(alice); + mptAlice.generateKeyPair(bob); + mptAlice.generateKeyPair(carol); + mptAlice.generateKeyPair(dave); + mptAlice.generateKeyPair(auditor); + mptAlice.set( + {.account = alice, + .issuerPubKey = mptAlice.getPubKey(alice), + .auditorPubKey = mptAlice.getPubKey(auditor)}); + + // setup bob. + // after setup, bob's spending balance is 60, inbox balance is 0. + { + // bob converts 60 to confidential + mptAlice.convert({.account = bob, .amt = 60, .holderPubKey = mptAlice.getPubKey(bob)}); + + // bob merge inbox + mptAlice.mergeInbox({ + .account = bob, + }); + } + + // setup carol. + // after setup, carol's spending balance is 120, inbox balance is 0. + { + // carol converts 120 to confidential + mptAlice.convert( + {.account = carol, .amt = 120, .holderPubKey = mptAlice.getPubKey(carol)}); + + // carol merge inbox + mptAlice.mergeInbox({ + .account = carol, + }); + } + + // setup dave. + // dave will not merge inbox. + // after setup, dave's inbox balance is 200, spending balance is 0. + mptAlice.convert({.account = dave, .amt = 200, .holderPubKey = mptAlice.getPubKey(dave)}); + + // setup: carol confidential send 50 to bob. + // after send, bob's inbox balance is 50, spending balance + // remains 60. carol's inbox balance remains 0, spending balance + // drops to 70. + mptAlice.send({ + .account = carol, + .dest = bob, + .amt = 50, + }); + + // alice clawback all confidential balance from bob, 110 in total. + // bob has balance in both inbox and spending. These balances should + // become zero after clawback, which is verified in the + // confidentialClaw function. + mptAlice.confidentialClaw({ + .account = alice, + .holder = bob, + .amt = 110, + }); + + // alice clawback all confidential balance from carol, which is 70. + // carol only has balance in spending. + mptAlice.confidentialClaw({ + .account = alice, + .holder = carol, + .amt = 70, + }); + + // alice clawback all confidential balance from dave, which is 200. + // dave only has balance in inbox. + mptAlice.confidentialClaw({ + .account = alice, + .holder = dave, + .amt = 200, + }); + } + + void + testClawbackPreflight(FeatureBitset features) + { + testcase("test ConfidentialMPTClawback Preflight"); + using namespace test::jtx; + + // test feature disabled + { + Env env{*this, features - featureConfidentialTransfer}; + Account const alice("alice"); + Account const bob("bob"); + MPTTester mptAlice(env, alice, {.holders = {bob}}); + + mptAlice.create(); + mptAlice.authorize({ + .account = bob, + }); + + mptAlice.confidentialClaw({ + .account = alice, + .holder = bob, + .amt = 10, + .proof = "123", + .err = temDISABLED, + }); + } + + // test malformed + { + // set up + Env env{*this, features}; + Account const alice("alice"); + Account const bob("bob"); + Account const carol("carol"); + MPTTester mptAlice(env, alice, {.holders = {bob, carol}}); + + mptAlice.create({ + .ownerCount = 1, + .flags = tfMPTCanTransfer | tfMPTCanLock | tfMPTCanConfidentialAmount, + }); + + mptAlice.authorize({ + .account = bob, + }); + mptAlice.authorize({ + .account = carol, + }); + mptAlice.generateKeyPair(alice); + mptAlice.generateKeyPair(bob); + mptAlice.generateKeyPair(carol); + mptAlice.set({.account = alice, .issuerPubKey = mptAlice.getPubKey(alice)}); + mptAlice.pay(alice, bob, 100); + mptAlice.pay(alice, carol, 50); + + // only issuer can clawback + mptAlice.confidentialClaw({ + .account = carol, + .holder = bob, + .amt = 10, + .err = temMALFORMED, + }); + + // invalid issuance ID, whose issuer is not alice + { + Json::Value jv; + jv[jss::Account] = alice.human(); + jv[sfHolder] = bob.human(); + jv[jss::TransactionType] = jss::ConfidentialMPTClawback; + jv[sfMPTAmount] = std::to_string(10); + jv[sfZKProof] = "123"; + + // wrong issuance ID + jv[sfMPTokenIssuanceID] = "00000004AE123A8556F3CF91154711376AFB0F894F832B3E"; + + env(jv, ter(temMALFORMED)); + } + + // issuer cannot clawback from self + mptAlice.confidentialClaw({ + .account = alice, + .holder = alice, + .amt = 10, + .err = temMALFORMED, + }); + + // invalid amount + mptAlice.confidentialClaw({ + .account = alice, + .holder = bob, + .amt = 0, + .err = temBAD_AMOUNT, + }); + + // invalid proof length + mptAlice.confidentialClaw({ + .account = alice, + .holder = bob, + .amt = 10, + .proof = "123", + .err = temMALFORMED, + }); + } + } + + void + testClawbackPreclaim(FeatureBitset features) + { + testcase("Clawback Preclaim Errors"); + using namespace test::jtx; + + { + // set up, alice is the issuer, bob and carol are authorized + // holders. dave is not authorized. bob has confidential + // balance, carol does not. + Env env{*this, features}; + Account const alice("alice"); + Account const bob("bob"); + Account const carol("carol"); + Account const dave("dave"); + MPTTester mptAlice(env, alice, {.holders = {bob, carol, dave}}); + + mptAlice.create({ + .flags = tfMPTCanTransfer | tfMPTCanClawback | tfMPTRequireAuth | + tfMPTCanConfidentialAmount, + }); + mptAlice.authorize({ + .account = bob, + }); + mptAlice.authorize({ + .account = alice, + .holder = bob, + }); + mptAlice.authorize({ + .account = carol, + }); + mptAlice.authorize({ + .account = alice, + .holder = carol, + }); + + mptAlice.pay(alice, bob, 100); + mptAlice.pay(alice, carol, 50); + mptAlice.generateKeyPair(alice); + mptAlice.generateKeyPair(bob); + mptAlice.generateKeyPair(carol); + mptAlice.set({.account = alice, .issuerPubKey = mptAlice.getPubKey(alice)}); + + mptAlice.convert({ + .account = bob, + .amt = 60, + .holderPubKey = mptAlice.getPubKey(bob), + }); + mptAlice.mergeInbox({ + .account = bob, + }); + + // holder does not exist + { + Account const unknown("unknown"); + mptAlice.confidentialClaw({ + .account = alice, + .holder = unknown, + .amt = 10, + .err = tecNO_TARGET, + }); + } + + // dave does not hold mpt at all, no MPT object + { + mptAlice.confidentialClaw({ + .account = alice, + .holder = dave, + .amt = 10, + .err = tecOBJECT_NOT_FOUND, + }); + } + + // carol has no confidential balance + { + mptAlice.confidentialClaw({ + .account = alice, + .holder = carol, + .amt = 10, + .err = tecNO_PERMISSION, + }); + } + } + + // lsfMPTCanClawback not set + { + Env env{*this, features}; + Account const alice("alice"); + Account const bob("bob"); + MPTTester mptAlice(env, alice, {.holders = {bob}}); + + mptAlice.create({ + .flags = tfMPTCanTransfer | tfMPTCanConfidentialAmount, + }); + mptAlice.authorize({ + .account = bob, + }); + mptAlice.generateKeyPair(alice); + mptAlice.set({.account = alice, .issuerPubKey = mptAlice.getPubKey(alice)}); + + mptAlice.confidentialClaw({ + .account = alice, + .holder = bob, + .amt = 10, + .err = tecNO_PERMISSION, + }); + } + + // no issuer key + { + Env env{*this, features}; + Account const alice("alice"); + Account const bob("bob"); + MPTTester mptAlice(env, alice, {.holders = {bob}}); + mptAlice.create({ + .flags = tfMPTCanClawback | tfMPTCanConfidentialAmount, + }); + mptAlice.authorize({ + .account = bob, + }); + mptAlice.generateKeyPair(alice); + + mptAlice.confidentialClaw({ + .account = alice, + .holder = bob, + .amt = 10, + .err = tecNO_PERMISSION, + }); + } + + // issuance not found + { + Env env{*this, features}; + Account const alice("alice"); + Account const bob("bob"); + MPTTester mptAlice(env, alice, {.holders = {bob}}); + mptAlice.create({ + .flags = tfMPTCanClawback | tfMPTCanConfidentialAmount, + }); + mptAlice.authorize({ + .account = bob, + }); + mptAlice.generateKeyPair(alice); + mptAlice.set({.account = alice, .issuerPubKey = mptAlice.getPubKey(alice)}); + + // destroy the issuance + mptAlice.destroy(); + + Json::Value jv; + jv[jss::Account] = alice.human(); + jv[sfHolder] = bob.human(); + jv[jss::TransactionType] = jss::ConfidentialMPTClawback; + jv[sfMPTAmount] = std::to_string(10); + std::string const dummyProof(196, '0'); + jv[sfZKProof] = dummyProof; + jv[sfMPTokenIssuanceID] = to_string(mptAlice.issuanceID()); + + env(jv, ter(tecOBJECT_NOT_FOUND)); + } + + // helper function to set up accounts to test lock and unauthorize + // cases. after set up, bob has confidential balance 60 in spending. + auto setupAccounts = [&](Env& env, Account const& alice, Account const& bob) -> MPTTester { + MPTTester mptAlice(env, alice, {.holders = {bob}}); + + mptAlice.create({ + .flags = tfMPTCanTransfer | tfMPTCanClawback | tfMPTRequireAuth | tfMPTCanLock | + tfMPTCanConfidentialAmount, + }); + mptAlice.authorize({ + .account = bob, + }); + mptAlice.authorize({ + .account = alice, + .holder = bob, + }); + mptAlice.pay(alice, bob, 100); + mptAlice.generateKeyPair(alice); + mptAlice.generateKeyPair(bob); + mptAlice.set({.account = alice, .issuerPubKey = mptAlice.getPubKey(alice)}); + mptAlice.convert({.account = bob, .amt = 60, .holderPubKey = mptAlice.getPubKey(bob)}); + mptAlice.mergeInbox({ + .account = bob, + }); + + return mptAlice; + }; + + // lock should not block clawback. lock bob individually + { + Env env{*this, features}; + Account const alice("alice"); + Account const bob("bob"); + MPTTester mptAlice = setupAccounts(env, alice, bob); + mptAlice.set({ + .account = alice, + .holder = bob, + .flags = tfMPTLock, + }); + + // clawback should still work + mptAlice.confidentialClaw({ + .account = alice, + .holder = bob, + .amt = 60, + }); + } + + // lock globally + { + Env env{*this, features}; + Account const alice("alice"); + Account const bob("bob"); + MPTTester mptAlice = setupAccounts(env, alice, bob); + mptAlice.set({ + .account = alice, + .flags = tfMPTLock, + }); + + // clawback should still work + mptAlice.confidentialClaw({ + .account = alice, + .holder = bob, + .amt = 60, + }); + } + + // unauthorize should not block clawback + { + Env env{*this, features}; + Account const alice("alice"); + Account const bob("bob"); + MPTTester mptAlice = setupAccounts(env, alice, bob); + + // unauthorize bob + mptAlice.authorize({ + .account = alice, + .holder = bob, + .flags = tfMPTUnauthorize, + }); + // clawback should still work + mptAlice.confidentialClaw({ + .account = alice, + .holder = bob, + .amt = 60, + }); + } + + // insufficient funds, clawback amount exceeding confidential + // outstanding amount + { + Env env{*this, features}; + Account const alice("alice"); + Account const bob("bob"); + MPTTester mptAlice = setupAccounts(env, alice, bob); + + mptAlice.confidentialClaw({ + .account = alice, + .holder = bob, + .amt = 10000, + .err = tecINSUFFICIENT_FUNDS, + }); + } + } + + void + testClawbackProof(FeatureBitset features) + { + testcase("ConfidentialMPTClawback Proof"); + using namespace test::jtx; + + Account const alice("alice"); + Account const bob("bob"); + Account const carol("carol"); + + // lambda function to set up MPT with alice as issuer, bob and carol + // as authorized holders, and fund 1000 mpt to bob and 2000 mpt to + // carol. + auto setupEnv = [&](Env& env) -> MPTTester { + MPTTester mptAlice(env, alice, {.holders = {bob, carol}}); + + mptAlice.create({ + .flags = tfMPTCanTransfer | tfMPTCanClawback | tfMPTCanConfidentialAmount, + }); + + for (auto const& [acct, amt] : {std::pair{bob, 1000}, {carol, 2000}}) + { + mptAlice.authorize({ + .account = acct, + }); + mptAlice.pay(alice, acct, amt); + mptAlice.generateKeyPair(acct); + } + + mptAlice.generateKeyPair(alice); + mptAlice.set({.account = alice, .issuerPubKey = mptAlice.getPubKey(alice)}); + + return mptAlice; + }; + + // lambda function to test a set of bad clawback amounts that should + // return tecBAD_PROOF + auto checkBadProofs = + [&](MPTTester& mpt, Account const& holder, std::initializer_list amts) { + for (auto const badAmt : amts) + { + mpt.confidentialClaw({ + .account = alice, + .holder = holder, + .amt = badAmt, + .err = tecBAD_PROOF, + }); + } + }; + + // SCENARIO 1: clawback from inbox only or spending only balances. + // bob converts 500 and merge inbox, + // carol converts 1000, but not merge inbox. + // after setup, bob has 500 in spending, carol has 1000 in inbox. + { + Env env{*this, features}; + auto mptAlice = setupEnv(env); + + // bob converts and merges + mptAlice.convert({.account = bob, .amt = 500, .holderPubKey = mptAlice.getPubKey(bob)}); + mptAlice.mergeInbox({ + .account = bob, + }); + // carol converts without merge + mptAlice.convert( + {.account = carol, .amt = 1000, .holderPubKey = mptAlice.getPubKey(carol)}); + + // verify proof fails with invalid clawback amount + // bob: 500 in Spending, 0 in Inbox + checkBadProofs( + mptAlice, + bob, + { + 1, + 10, + 70, + 100, + 110, + 200, + 499, + 501, + 600, + }); + + // carol: 1000 in Inbox, 0 in Spending + checkBadProofs( + mptAlice, + carol, + { + 1, + 10, + 50, + 500, + 777, + 850, + 999, + 1001, + 1200, + }); + + // clawback with correct amount that passes proof verification + mptAlice.confidentialClaw({ + .account = alice, + .holder = bob, + .amt = 500, + }); + mptAlice.confidentialClaw({ + .account = alice, + .holder = carol, + .amt = 1000, + }); + } + + // SCENARIO 2: clawback from mixed inbox and spending balances. + // bob converts 300 to confidential and merge inbox, + // carol converts 400 to confidential and merge inbox, + // bob sends 100 to carol, carol sends 100 to bob. + // After setup, bob has 100 in inbox and 200 in spending; + // carol has 100 in inbox and 300 in spending. + { + Env env{*this, features}; + auto mptAlice = setupEnv(env); + + mptAlice.convert({.account = bob, .amt = 300, .holderPubKey = mptAlice.getPubKey(bob)}); + mptAlice.mergeInbox({ + .account = bob, + }); + mptAlice.convert( + {.account = carol, .amt = 400, .holderPubKey = mptAlice.getPubKey(carol)}); + mptAlice.mergeInbox({ + .account = carol, + }); + mptAlice.send({ + .account = bob, + .dest = carol, + .amt = 100, + }); + mptAlice.send({ + .account = carol, + .dest = bob, + .amt = 100, + }); + + // verify proof fails with invalid clawback amount + // bob: 100 in inbox, 200 in spending + checkBadProofs( + mptAlice, + bob, + { + 1, + 10, + 50, + 100, + 200, + 299, + 301, + 400, + }); + + // proof failure for incorrect amount when clawbacking from + // carol carol: 100 in inbox, 300 in spending + checkBadProofs( + mptAlice, + carol, + { + 1, + 10, + 50, + 100, + 300, + 399, + 401, + 501, + }); + + // clawback with correct amount that passes proof verification + mptAlice.confidentialClaw({ + .account = alice, + .holder = bob, + .amt = 300, + }); + mptAlice.confidentialClaw({ + .account = alice, + .holder = carol, + .amt = 400, + }); + } + } + + void + testMutatePrivacy(FeatureBitset features) + { + testcase("mutate lsfMPTCanConfidentialAmount"); + using namespace test::jtx; + + // can not create mpt issuance with tmfMPTCannotMutateCanConfidentialAmount + // when featureDynamicMPT is disabled + { + Env env{*this, features - featureDynamicMPT}; + Account const alice("alice"); + Account const bob("bob"); + MPTTester mptAlice(env, alice, {.holders = {bob}}); + + mptAlice.create({ + .ownerCount = 0, + .mutableFlags = tmfMPTCannotMutateCanConfidentialAmount, + .err = temDISABLED, + }); + } + + // can not create mpt issuance with tmfMPTCannotMutateCanConfidentialAmount when + // featureConfidentialTransfer is disabled + { + Env env{*this, features - featureConfidentialTransfer}; + Account const alice("alice"); + Account const bob("bob"); + MPTTester mptAlice(env, alice, {.holders = {bob}}); + + mptAlice.create({ + .ownerCount = 0, + .mutableFlags = tmfMPTCannotMutateCanConfidentialAmount, + .err = temDISABLED, + }); + } + + // if lsmfMPTCannotMutateCanConfidentialAmount is set, can not set/clear + // lsfMPTCanConfidentialAmount + { + Env env{*this, features}; + Account const alice("alice"); + Account const bob("bob"); + MPTTester mptAlice(env, alice, {.holders = {bob}}); + + mptAlice.create({ + .ownerCount = 1, + .flags = tfMPTCanTransfer, + .mutableFlags = tmfMPTCannotMutateCanConfidentialAmount, + }); + + mptAlice.set({ + .account = alice, + .mutableFlags = tmfMPTSetCanConfidentialAmount, + .err = tecNO_PERMISSION, + }); + + mptAlice.set({ + .account = alice, + .mutableFlags = tmfMPTClearCanConfidentialAmount, + .err = tecNO_PERMISSION, + }); + } + + // Toggle lsfMPTCanConfidentialAmount + { + Env env{*this, features}; + Account const alice("alice"); + Account const bob("bob"); + MPTTester mptAlice(env, alice, {.holders = {bob}}); + + mptAlice.create({ + .ownerCount = 1, + .flags = tfMPTCanTransfer | tfMPTCanConfidentialAmount, + .mutableFlags = tmfMPTCanMutateCanLock, + }); + + mptAlice.authorize({ + .account = bob, + }); + mptAlice.pay(alice, bob, 100); + + mptAlice.generateKeyPair(alice); + mptAlice.generateKeyPair(bob); + mptAlice.set({.account = alice, .issuerPubKey = mptAlice.getPubKey(alice)}); + + auto holderPubKeySet = false; + auto verifyToggle = [&](TER expectedResult, uint64_t amt) { + if (!holderPubKeySet) + mptAlice.convert({ + .account = bob, + .amt = amt, + .holderPubKey = mptAlice.getPubKey(bob), + .err = expectedResult, + }); + else + mptAlice.convert({ + .account = bob, + .amt = amt, + .err = expectedResult, + }); + + if (expectedResult == tesSUCCESS) + { + holderPubKeySet = true; + mptAlice.mergeInbox({ + .account = bob, + }); + + // make sure there's no confidential outstanding balance + // for the next toggle test + mptAlice.convertBack({ + .account = bob, + .amt = amt, + }); + } + }; + + // set lsfMPTCanConfidentialAmount, but no effect because lsfMPTCanConfidentialAmount + // was already set + mptAlice.set({ + .account = alice, + .mutableFlags = tmfMPTSetCanConfidentialAmount, + }); + verifyToggle(tesSUCCESS, 10); + + // clear lsfMPTCanConfidentialAmount + mptAlice.set({ + .account = alice, + .mutableFlags = tmfMPTClearCanConfidentialAmount, + }); + verifyToggle(tecNO_PERMISSION, 10); + + // can clear lsfMPTCanConfidentialAmount again but has no effect + // for privacy settings + mptAlice.set({ + .account = alice, + .mutableFlags = tmfMPTClearCanConfidentialAmount | tmfMPTSetCanLock, + }); + verifyToggle(tecNO_PERMISSION, 20); + + // set lsfMPTCanConfidentialAmount again + mptAlice.set({ + .account = alice, + .mutableFlags = tmfMPTSetCanConfidentialAmount, + }); + verifyToggle(tesSUCCESS, 30); + } + + // can not mutate lsfPrivacy when there's confidential + // outstanding amount + { + Env env{*this, features}; + Account const alice("alice"); + Account const bob("bob"); + MPTTester mptAlice(env, alice, {.holders = {bob}}); + + // lsmfMPTCannotMutateCanConfidentialAmount is false by default, + // so that lsfMPTCanConfidentialAmount can be mutated + mptAlice.create({ + .ownerCount = 1, + .flags = tfMPTCanTransfer | tfMPTCanConfidentialAmount, + }); + + mptAlice.authorize({ + .account = bob, + }); + mptAlice.pay(alice, bob, 100); + + mptAlice.generateKeyPair(alice); + mptAlice.generateKeyPair(bob); + mptAlice.set({.account = alice, .issuerPubKey = mptAlice.getPubKey(alice)}); + + // bob convert 50 to confidential + mptAlice.convert({.account = bob, .amt = 50, .holderPubKey = mptAlice.getPubKey(bob)}); + + // set or clear lsfMPTCanConfidentialAmount should fail because of + // confidential outstanding balance + mptAlice.set({ + .account = alice, + .mutableFlags = tmfMPTSetCanConfidentialAmount, + .err = tecNO_PERMISSION, + }); + mptAlice.set({ + .account = alice, + .mutableFlags = tmfMPTClearCanConfidentialAmount, + .err = tecNO_PERMISSION, + }); + + // bob merge inbox + mptAlice.mergeInbox({ + .account = bob, + }); + + // bob convert back all confidential balance + mptAlice.convertBack({ + .account = bob, + .amt = 50, + }); + + // now clear lsfMPTCanConfidentialAmount should succeed, + // because there's no confidential outstanding balance + mptAlice.set({ + .account = alice, + .mutableFlags = tmfMPTClearCanConfidentialAmount, + }); + + // bob can not convert because lsfMPTCanConfidentialAmount was cleared + // successfully + mptAlice.convert({ + .account = bob, + .amt = 10, + .holderPubKey = mptAlice.getPubKey(bob), + .err = tecNO_PERMISSION, + }); + + // can set lsfMPTCanConfidentialAmount again when there's no confidential + // outstanding balance + mptAlice.set({ + .account = alice, + .mutableFlags = tmfMPTSetCanConfidentialAmount, + }); + mptAlice.convert({ + .account = bob, + .amt = 10, + }); + } + } + + void + testConvertBackPedersenProof(FeatureBitset features) + { + testcase("Convert back pedersen proof"); + using namespace test::jtx; + + Env env{*this, features}; + Account const alice("alice"); + Account const bob("bob"); + MPTTester mptAlice(env, alice, {.holders = {bob}}); + + mptAlice.create({ + .ownerCount = 1, + .holderCount = 0, + .flags = tfMPTCanTransfer | tfMPTCanLock | tfMPTCanConfidentialAmount, + }); + + mptAlice.authorize({ + .account = bob, + }); + mptAlice.pay(alice, bob, 100); + + mptAlice.generateKeyPair(alice); + + mptAlice.set({.account = alice, .issuerPubKey = mptAlice.getPubKey(alice)}); + + mptAlice.generateKeyPair(bob); + + mptAlice.convert({ + .account = bob, + .amt = 40, + .holderPubKey = mptAlice.getPubKey(bob), + }); + + mptAlice.mergeInbox({ + .account = bob, + }); + + // for ease of understanding, generate all the fields here instead of + // autofilling + uint64_t const amt = 10; + Buffer const blindingFactor = generateBlindingFactor(); + Buffer const pcBlindingFactor = generateBlindingFactor(); + + auto const spendingBalance = + mptAlice.getDecryptedBalance(bob, MPTTester::HOLDER_ENCRYPTED_SPENDING); + BEAST_EXPECT(spendingBalance.has_value()); + auto const encryptedSpendingBalance = + mptAlice.getEncryptedBalance(bob, MPTTester::HOLDER_ENCRYPTED_SPENDING); + BEAST_EXPECT(encryptedSpendingBalance.has_value() && !encryptedSpendingBalance->empty()); + + Buffer const pedersenCommitment = + mptAlice.getPedersenCommitment(*spendingBalance, pcBlindingFactor); + Buffer const issuerCiphertext = mptAlice.encryptAmount(alice, amt, blindingFactor); + Buffer const bobCiphertext = mptAlice.encryptAmount(bob, amt, blindingFactor); + auto const version = mptAlice.getMPTokenVersion(bob); + + // These tests verify that the pedersen linkage proof validation + // correctly rejects proofs generated with incorrect parameters. + // The pedersen linkage proof proves that the balance commitment + // PC = balance*G + rho*H is derived from the holder's encrypted + // spending balance. + + // Helper to combine pedersen proof and bulletproof + auto const combineProofs = [](Buffer const& pedersenProof, Buffer const& bulletproof) { + Buffer combinedProof(pedersenProof.size() + bulletproof.size()); + std::memcpy(combinedProof.data(), pedersenProof.data(), pedersenProof.size()); + std::memcpy( + combinedProof.data() + pedersenProof.size(), + bulletproof.data(), + bulletproof.size()); + return combinedProof; + }; + + auto const holderPubKey = mptAlice.getPubKey(bob); + BEAST_EXPECT(holderPubKey.has_value()); + + // Test 1: Proof generated with wrong pedersen commitment value. + // The proof uses PC(1, rho) but the transaction submits PC(balance, rho). + // Verification fails because the proof doesn't match the submitted commitment. + { + uint256 const contextHash = + getConvertBackContextHash(bob, mptAlice.issuanceID(), env.seq(bob), version); + Buffer const badPedersenCommitment = + mptAlice.getPedersenCommitment(1, pcBlindingFactor); + Buffer const proof = mptAlice.getConvertBackProof( + bob, + amt, + contextHash, + { + .pedersenCommitment = badPedersenCommitment, // wrong pedersen commitment + .amt = *spendingBalance, + .encryptedAmt = *encryptedSpendingBalance, + .blindingFactor = pcBlindingFactor, + }); + + mptAlice.convertBack({ + .account = bob, + .amt = amt, + .proof = proof, + .holderEncryptedAmt = bobCiphertext, + .issuerEncryptedAmt = issuerCiphertext, + .blindingFactor = blindingFactor, + .pedersenCommitment = pedersenCommitment, + .err = tecBAD_PROOF, + }); + } + + // Test 2: Proof generated with wrong blinding factor (rho). + // The pedersen commitment PC = balance*G + rho*H requires the same rho + // used in proof generation. Using a different rho breaks the linkage. + { + uint256 const contextHash = + getConvertBackContextHash(bob, mptAlice.issuanceID(), env.seq(bob), version); + + Buffer const proof = mptAlice.getConvertBackProof( + bob, + amt, + contextHash, + { + .pedersenCommitment = pedersenCommitment, + .amt = *spendingBalance, + .encryptedAmt = *encryptedSpendingBalance, + .blindingFactor = generateBlindingFactor(), // wrong blinding factor + }); + + mptAlice.convertBack({ + .account = bob, + .amt = amt, + .proof = proof, + .holderEncryptedAmt = bobCiphertext, + .issuerEncryptedAmt = issuerCiphertext, + .blindingFactor = blindingFactor, + .pedersenCommitment = pedersenCommitment, + .err = tecBAD_PROOF, + }); + } + + // Test 3: Proof generated with wrong balance value. + // The proof claims balance=1 but the encrypted spending balance contains + // the actual balance. Verification fails because the values don't match. + { + uint256 const contextHash = + getConvertBackContextHash(bob, mptAlice.issuanceID(), env.seq(bob), version); + + Buffer const proof = mptAlice.getConvertBackProof( + bob, + amt, + contextHash, + { + .pedersenCommitment = pedersenCommitment, + .amt = 1, // wrong balance + .encryptedAmt = *encryptedSpendingBalance, + .blindingFactor = pcBlindingFactor, + }); + + mptAlice.convertBack({ + .account = bob, + .amt = amt, + .proof = proof, + .holderEncryptedAmt = bobCiphertext, + .issuerEncryptedAmt = issuerCiphertext, + .blindingFactor = blindingFactor, + .pedersenCommitment = pedersenCommitment, + .err = tecBAD_PROOF, + }); + } + + // Test 4: Correct proof but wrong pedersen commitment in transaction. + // The proof is generated correctly, but the transaction submits a + // different pedersen commitment. Verification fails because the + // submitted commitment doesn't match what the proof was generated for. + { + uint256 const contextHash = + getConvertBackContextHash(bob, mptAlice.issuanceID(), env.seq(bob), version); + Buffer const badPedersenCommitment = + mptAlice.getPedersenCommitment(1, pcBlindingFactor); + Buffer const proof = mptAlice.getConvertBackProof( + bob, + amt, + contextHash, + { + .pedersenCommitment = pedersenCommitment, + .amt = *spendingBalance, + .encryptedAmt = *encryptedSpendingBalance, + .blindingFactor = pcBlindingFactor, + }); + + mptAlice.convertBack({ + .account = bob, + .amt = amt, + .proof = proof, + .holderEncryptedAmt = bobCiphertext, + .issuerEncryptedAmt = issuerCiphertext, + .blindingFactor = blindingFactor, + .pedersenCommitment = badPedersenCommitment, // wrong pedersen commitment + .err = tecBAD_PROOF, + }); + } + + // Test 5: Proof generated with wrong context hash. + // The context hash binds the proof to a specific transaction (account, + // sequence, issuanceID, amount, version). Using a different context hash + // makes the proof invalid for this transaction, preventing replay attacks. + { + uint256 const contextHash = + getConvertBackContextHash(bob, mptAlice.issuanceID(), env.seq(bob), version); + uint256 const badContextHash{1}; + Buffer const pedersenProof = mptAlice.getBalanceLinkageProof( + bob, + badContextHash, // wrong context hash + *holderPubKey, + { + .pedersenCommitment = pedersenCommitment, + .amt = *spendingBalance, + .encryptedAmt = *encryptedSpendingBalance, + .blindingFactor = pcBlindingFactor, + }); + + // Bulletproof uses correct context hash so only pedersen proof fails + Buffer const bulletproof = + mptAlice.getBulletproof({*spendingBalance - amt}, {pcBlindingFactor}, contextHash); + + Buffer const proof = combineProofs(pedersenProof, bulletproof); + + mptAlice.convertBack({ + .account = bob, + .amt = amt, + .proof = proof, + .holderEncryptedAmt = bobCiphertext, + .issuerEncryptedAmt = issuerCiphertext, + .blindingFactor = blindingFactor, + .pedersenCommitment = pedersenCommitment, + .err = tecBAD_PROOF, + }); + } + + // Test 6: Correct proof to verify the test setup is valid. + // All parameters are correct, so the transaction should succeed. + { + uint256 const contextHash = + getConvertBackContextHash(bob, mptAlice.issuanceID(), env.seq(bob), version); + + Buffer const proof = mptAlice.getConvertBackProof( + bob, + amt, + contextHash, + { + .pedersenCommitment = pedersenCommitment, + .amt = *spendingBalance, + .encryptedAmt = *encryptedSpendingBalance, + .blindingFactor = pcBlindingFactor, + }); + + mptAlice.convertBack({ + .account = bob, + .amt = amt, + .proof = proof, + .holderEncryptedAmt = bobCiphertext, + .issuerEncryptedAmt = issuerCiphertext, + .blindingFactor = blindingFactor, + .pedersenCommitment = pedersenCommitment, + }); + } + } + + void + testConvertBackBulletproof(FeatureBitset features) + { + testcase("Convert back bulletproof"); + using namespace test::jtx; + + Env env{*this, features}; + Account const alice("alice"); + Account const bob("bob"); + MPTTester mptAlice(env, alice, {.holders = {bob}}); + + mptAlice.create({ + .ownerCount = 1, + .holderCount = 0, + .flags = tfMPTCanTransfer | tfMPTCanLock | tfMPTCanConfidentialAmount, + }); + + mptAlice.authorize({ + .account = bob, + }); + mptAlice.pay(alice, bob, 100); + + mptAlice.generateKeyPair(alice); + + mptAlice.set({.account = alice, .issuerPubKey = mptAlice.getPubKey(alice)}); + + mptAlice.generateKeyPair(bob); + + mptAlice.convert({ + .account = bob, + .amt = 40, + .holderPubKey = mptAlice.getPubKey(bob), + }); + + mptAlice.mergeInbox({ + .account = bob, + }); + + // for ease of understanding, generate all the fields here instead of + // autofilling + uint64_t const amt = 10; + Buffer const blindingFactor = generateBlindingFactor(); + Buffer const pcBlindingFactor = generateBlindingFactor(); + + auto const spendingBalance = + mptAlice.getDecryptedBalance(bob, MPTTester::HOLDER_ENCRYPTED_SPENDING); + BEAST_EXPECT(spendingBalance.has_value()); + auto const encryptedSpendingBalance = + mptAlice.getEncryptedBalance(bob, MPTTester::HOLDER_ENCRYPTED_SPENDING); + BEAST_EXPECT(encryptedSpendingBalance.has_value() && !encryptedSpendingBalance->empty()); + + Buffer const pedersenCommitment = + mptAlice.getPedersenCommitment(*spendingBalance, pcBlindingFactor); + Buffer const issuerCiphertext = mptAlice.encryptAmount(alice, amt, blindingFactor); + Buffer const bobCiphertext = mptAlice.encryptAmount(bob, amt, blindingFactor); + auto const version = mptAlice.getMPTokenVersion(bob); + + // These tests verify that the bulletproof (range proof) validation + // correctly rejects proofs generated with incorrect parameters. + // The bulletproof proves that the remaining balance (balance - amount) + // is non-negative, i.e., in the range [0, 2^64-1]. This prevents + // overdrafts where a user tries to convert back more than they have. + + // Helper to combine pedersen proof and bulletproof + auto const combineProofs = [](Buffer const& pedersenProof, Buffer const& bulletproof) { + Buffer combinedProof(pedersenProof.size() + bulletproof.size()); + std::memcpy(combinedProof.data(), pedersenProof.data(), pedersenProof.size()); + std::memcpy( + combinedProof.data() + pedersenProof.size(), + bulletproof.data(), + bulletproof.size()); + return combinedProof; + }; + + auto const holderPubKey = mptAlice.getPubKey(bob); + BEAST_EXPECT(holderPubKey.has_value()); + + // Helper to generate pedersen proof with correct parameters. + // The pedersen proof links the encrypted balance to the pedersen commitment. + auto const getPedersenProof = [&](uint256 const& contextHash) { + return mptAlice.getBalanceLinkageProof( + bob, + contextHash, + *holderPubKey, + { + .pedersenCommitment = pedersenCommitment, + .amt = *spendingBalance, + .encryptedAmt = *encryptedSpendingBalance, + .blindingFactor = pcBlindingFactor, + }); + }; + + // Test 1: Bulletproof generated with wrong remaining balance. + // The bulletproof claims remaining balance is 1, but the pedersen + // commitment was created with (balance - amount). The verifier computes + // PC_rem = PC - amount*G and checks if the bulletproof matches, which fails. + { + uint256 const contextHash = + getConvertBackContextHash(bob, mptAlice.issuanceID(), env.seq(bob), version); + + Buffer const bulletproof = mptAlice.getBulletproof( + {1}, // wrong remaining balance + {pcBlindingFactor}, + contextHash); + + Buffer const proof = combineProofs(getPedersenProof(contextHash), bulletproof); + + mptAlice.convertBack({ + .account = bob, + .amt = amt, + .proof = proof, + .holderEncryptedAmt = bobCiphertext, + .issuerEncryptedAmt = issuerCiphertext, + .blindingFactor = blindingFactor, + .pedersenCommitment = pedersenCommitment, + .err = tecBAD_PROOF, + }); + } + + // Test 2: Bulletproof generated with wrong blinding factor. + // The bulletproof must use the same blinding factor (rho) as the pedersen + // commitment PC = (balance - amount)*G + rho*H. Using a different rho + // creates a commitment mismatch and verification fails. + { + uint256 const contextHash = + getConvertBackContextHash(bob, mptAlice.issuanceID(), env.seq(bob), version); + + Buffer const bulletproof = mptAlice.getBulletproof( + {*spendingBalance - amt}, + {generateBlindingFactor()}, // wrong blinding factor + contextHash); + + Buffer const proof = combineProofs(getPedersenProof(contextHash), bulletproof); + + mptAlice.convertBack({ + .account = bob, + .amt = amt, + .proof = proof, + .holderEncryptedAmt = bobCiphertext, + .issuerEncryptedAmt = issuerCiphertext, + .blindingFactor = blindingFactor, + .pedersenCommitment = pedersenCommitment, + .err = tecBAD_PROOF, + }); + } + + // Test 3: Bulletproof generated with wrong context hash. + // The context hash binds the proof to a specific transaction (account, + // sequence, issuanceID, amount, version). Using a different context hash + // makes the proof invalid for this transaction, preventing replay attacks. + { + uint256 const contextHash = + getConvertBackContextHash(bob, mptAlice.issuanceID(), env.seq(bob), version); + + uint256 const badContextHash{1}; + Buffer const bulletproof = mptAlice.getBulletproof( + {*spendingBalance - amt}, + {pcBlindingFactor}, + badContextHash); // wrong context hash + + Buffer const proof = combineProofs(getPedersenProof(contextHash), bulletproof); + + mptAlice.convertBack({ + .account = bob, + .amt = amt, + .proof = proof, + .holderEncryptedAmt = bobCiphertext, + .issuerEncryptedAmt = issuerCiphertext, + .blindingFactor = blindingFactor, + .pedersenCommitment = pedersenCommitment, + .err = tecBAD_PROOF, + }); + } + + // Test 4: Correct proof to verify the test setup is valid. + // All parameters are correct, so the transaction should succeed. + { + uint256 const contextHash = + getConvertBackContextHash(bob, mptAlice.issuanceID(), env.seq(bob), version); + + Buffer const proof = mptAlice.getConvertBackProof( + bob, + amt, + contextHash, + { + .pedersenCommitment = pedersenCommitment, + .amt = *spendingBalance, + .encryptedAmt = *encryptedSpendingBalance, + .blindingFactor = pcBlindingFactor, + }); + + mptAlice.convertBack({ + .account = bob, + .amt = amt, + .proof = proof, + .holderEncryptedAmt = bobCiphertext, + .issuerEncryptedAmt = issuerCiphertext, + .blindingFactor = blindingFactor, + .pedersenCommitment = pedersenCommitment, + }); + } + } + + // This test verifies that proofs are non-replayable by simulating replays + // with an outdated ledger version or an old sequence number. + // It confirms that the validator detects the resulting ContextID mismatch + // and rejects the transaction with tecBAD_PROOF. + void + testProofContextBinding(FeatureBitset features) + { + testcase("Proof context binding (Sequence and Version)"); + using namespace test::jtx; + + Env env{*this, features}; + Account const alice("alice"); + Account const bob("bob"); + MPTTester mptAlice(env, alice, {.holders = {bob}}); + + mptAlice.create({ + .ownerCount = 1, + .holderCount = 0, + .flags = tfMPTCanTransfer | tfMPTCanLock | tfMPTCanConfidentialAmount, + }); + + mptAlice.authorize({ + .account = bob, + }); + mptAlice.pay(alice, bob, 100); + + mptAlice.generateKeyPair(alice); + + mptAlice.set({.account = alice, .issuerPubKey = mptAlice.getPubKey(alice)}); + + mptAlice.generateKeyPair(bob); + + mptAlice.convert({ + .account = bob, + .amt = 40, + .holderPubKey = mptAlice.getPubKey(bob), + }); + + mptAlice.mergeInbox({ + .account = bob, + }); + + uint64_t const amt = 10; + Buffer const blindingFactor = generateBlindingFactor(); + Buffer const pcBlindingFactor = generateBlindingFactor(); + + auto const spendingBalance = + mptAlice.getDecryptedBalance(bob, MPTTester::HOLDER_ENCRYPTED_SPENDING); + BEAST_EXPECT( + spendingBalance.has_value() && *spendingBalance == 40); // because bob encrypted 40 + auto const encryptedSpendingBalance = + mptAlice.getEncryptedBalance(bob, MPTTester::HOLDER_ENCRYPTED_SPENDING); + BEAST_EXPECT(encryptedSpendingBalance.has_value() && !encryptedSpendingBalance->empty()); + + Buffer const pedersenCommitment = + mptAlice.getPedersenCommitment(*spendingBalance, pcBlindingFactor); + Buffer const issuerCiphertext = mptAlice.encryptAmount(alice, amt, blindingFactor); + Buffer const bobCiphertext = mptAlice.encryptAmount(bob, amt, blindingFactor); + + auto const currentVersion = mptAlice.getMPTokenVersion(bob); + + // Invalid Version Binding + // Simulates replaying a full transaction after the ledger's version + // has updated. We simulate this by attempting to use a proof built + // using an older version but with the current valid sequence. + { + uint32_t const seqA = env.seq(bob); + uint32_t const oldVersion = currentVersion - 1; + uint256 const badContextHash = + getConvertBackContextHash(bob, mptAlice.issuanceID(), seqA, oldVersion); + + Buffer const proof = mptAlice.getConvertBackProof( + bob, + amt, + badContextHash, + { + .pedersenCommitment = pedersenCommitment, + .amt = *spendingBalance, + .encryptedAmt = *encryptedSpendingBalance, + .blindingFactor = pcBlindingFactor, + }); + + mptAlice.convertBack({ + .account = bob, + .amt = amt, + .proof = proof, + .holderEncryptedAmt = bobCiphertext, + .issuerEncryptedAmt = issuerCiphertext, + .blindingFactor = blindingFactor, + .pedersenCommitment = pedersenCommitment, + .err = tecBAD_PROOF, + }); + } + + // Invalid Sequence Binding + // Simulates submitting a new transaction (with a new, valid signature + // and sequence) but reusing a ZKP from a previous sequence number. + { + // Fetch updated sequence, as the tecBAD_PROOF above consumed one + uint32_t const seqB = env.seq(bob); + uint32_t const oldSeq = seqB - 1; + uint256 const badContextHash = + getConvertBackContextHash(bob, mptAlice.issuanceID(), oldSeq, currentVersion); + + Buffer const proof = mptAlice.getConvertBackProof( + bob, + amt, + badContextHash, + { + .pedersenCommitment = pedersenCommitment, + .amt = *spendingBalance, + .encryptedAmt = *encryptedSpendingBalance, + .blindingFactor = pcBlindingFactor, + }); + + mptAlice.convertBack({ + .account = bob, + .amt = amt, + .proof = proof, + .holderEncryptedAmt = bobCiphertext, + .issuerEncryptedAmt = issuerCiphertext, + .blindingFactor = blindingFactor, + .pedersenCommitment = pedersenCommitment, + .err = tecBAD_PROOF, + }); + } + + // Verify Correct Proof Passes + // Ensure the test setup was correct and functions when no replay is attempted. + { + // Fetch updated sequence once more + uint32_t const seqC = env.seq(bob); + uint256 const goodContextHash = + getConvertBackContextHash(bob, mptAlice.issuanceID(), seqC, currentVersion); + + Buffer const proof = mptAlice.getConvertBackProof( + bob, + amt, + goodContextHash, + { + .pedersenCommitment = pedersenCommitment, + .amt = *spendingBalance, + .encryptedAmt = *encryptedSpendingBalance, + .blindingFactor = pcBlindingFactor, + }); + + mptAlice.convertBack({ + .account = bob, + .amt = amt, + .proof = proof, + .holderEncryptedAmt = bobCiphertext, + .issuerEncryptedAmt = issuerCiphertext, + .blindingFactor = blindingFactor, + .pedersenCommitment = pedersenCommitment, + }); + } + } + + // This test simulates a valid proof π extracted from a transaction + // for amount m1 is reused in a new transaction for a different + // amount m2 with different ciphertexts. It confirms the context hash + // recomputation fails due to the ciphertext binding mismatch, resulting + // in tecBAD_PROOF. + void + testProofCiphertextBinding(FeatureBitset features) + { + testcase("Proof ciphertext binding"); + using namespace test::jtx; + + Env env{*this, features}; + Account const alice("alice"); + Account const bob("bob"); + MPTTester mptAlice(env, alice, {.holders = {bob}}); + + mptAlice.create({ + .ownerCount = 1, + .holderCount = 0, + .flags = tfMPTCanTransfer | tfMPTCanLock | tfMPTCanConfidentialAmount, + }); + + mptAlice.authorize({ + .account = bob, + }); + mptAlice.pay(alice, bob, 100); + + mptAlice.generateKeyPair(alice); + mptAlice.set({.account = alice, .issuerPubKey = mptAlice.getPubKey(alice)}); + + mptAlice.generateKeyPair(bob); + mptAlice.convert({ + .account = bob, + .amt = 50, + .holderPubKey = mptAlice.getPubKey(bob), + }); + + mptAlice.mergeInbox({ + .account = bob, + }); + + auto const spendingBalance = + mptAlice.getDecryptedBalance(bob, MPTTester::HOLDER_ENCRYPTED_SPENDING); + auto const encryptedSpendingBalance = + mptAlice.getEncryptedBalance(bob, MPTTester::HOLDER_ENCRYPTED_SPENDING); + auto const version = mptAlice.getMPTokenVersion(bob); + Buffer const pcBlindingFactor = generateBlindingFactor(); + Buffer const pedersenCommitment = + mptAlice.getPedersenCommitment(*spendingBalance, pcBlindingFactor); + + // Generate a valid proof pi for Amount m1 = 10 + uint64_t const amtA = 10; + uint32_t const currentSeq = env.seq(bob); + uint256 const contextHashA = + getConvertBackContextHash(bob, mptAlice.issuanceID(), currentSeq, version); + + Buffer const proofA = mptAlice.getConvertBackProof( + bob, + amtA, + contextHashA, + { + .pedersenCommitment = pedersenCommitment, + .amt = *spendingBalance, + .encryptedAmt = *encryptedSpendingBalance, + .blindingFactor = pcBlindingFactor, + }); + + // Construct Transaction B with Amount m2 = 20 and attach Proof pi + uint64_t const amtB = 20; + Buffer const blindingFactorB = generateBlindingFactor(); + Buffer const bobCiphertextB = mptAlice.encryptAmount(bob, amtB, blindingFactorB); + Buffer const issuerCiphertextB = mptAlice.encryptAmount(alice, amtB, blindingFactorB); + + // We attempt to verify the proof pi (for amt 10) against the new ciphertexts (for amt 20). + mptAlice.convertBack( + {.account = bob, + .amt = amtB, + .proof = proofA, // Extracted/Reused proof from Transaction A + .holderEncryptedAmt = bobCiphertextB, + .issuerEncryptedAmt = issuerCiphertextB, + .blindingFactor = blindingFactorB, + .pedersenCommitment = pedersenCommitment, + .err = tecBAD_PROOF}); // Expected failure + } + + // This test simulates a valid proof π and ciphertext are + // tied to version v, but are reused after an inbox merge has incremented + // the CBS version to v+1. It confirms the validator rejects the transaction + // before acceptance due to the ContextID mismatch. + void + testProofVersionMismatch(FeatureBitset features) + { + testcase("Proof version mismatch"); + using namespace test::jtx; + + Env env{*this, features}; + Account const alice("alice"); + Account const bob("bob"); + MPTTester mptAlice(env, alice, {.holders = {bob}}); + + mptAlice.create({ + .ownerCount = 1, + .holderCount = 0, + .flags = tfMPTCanTransfer | tfMPTCanLock | tfMPTCanConfidentialAmount, + }); + + mptAlice.authorize({ + .account = bob, + }); + mptAlice.pay(alice, bob, 1000); + + mptAlice.generateKeyPair(alice); + mptAlice.set({.account = alice, .issuerPubKey = mptAlice.getPubKey(alice)}); + + mptAlice.generateKeyPair(bob); + + // Initial state: Version v + // Convert and merge to establish a spending balance and initial version + mptAlice.convert({ + .account = bob, + .amt = 100, + .holderPubKey = mptAlice.getPubKey(bob), + }); + mptAlice.mergeInbox({ + .account = bob, + }); + + auto const versionV = mptAlice.getMPTokenVersion(bob); + auto const spendingBalanceV = + mptAlice.getDecryptedBalance(bob, MPTTester::HOLDER_ENCRYPTED_SPENDING); + auto const encryptedSpendingBalanceV = + mptAlice.getEncryptedBalance(bob, MPTTester::HOLDER_ENCRYPTED_SPENDING); + + // Parameters for the intended ConvertBack transaction + uint64_t const amt = 10; + Buffer const blindingFactor = generateBlindingFactor(); + Buffer const pcBlindingFactor = generateBlindingFactor(); + Buffer const pedersenCommitment = + mptAlice.getPedersenCommitment(*spendingBalanceV, pcBlindingFactor); + Buffer const issuerCiphertext = mptAlice.encryptAmount(alice, amt, blindingFactor); + Buffer const bobCiphertext = mptAlice.encryptAmount(bob, amt, blindingFactor); + + // State Change: Increment version to v+1 + // Converting more funds and merging increments the sfConfidentialBalanceVersion + mptAlice.convert({ + .account = bob, + .amt = 50, + }); + mptAlice.mergeInbox({ + .account = bob, + }); + + BEAST_EXPECT(mptAlice.getMPTokenVersion(bob) > versionV); + + // Attack: Attempt to reuse proof tied to Version v at ledger Version v+1 + uint32_t const currentSeq = env.seq(bob); + // Proof is explicitly generated using the outdated Version v + uint256 const oldContextHash = + getConvertBackContextHash(bob, mptAlice.issuanceID(), currentSeq, versionV); + + Buffer const oldProof = mptAlice.getConvertBackProof( + bob, + amt, + oldContextHash, + { + .pedersenCommitment = pedersenCommitment, + .amt = *spendingBalanceV, + .encryptedAmt = *encryptedSpendingBalanceV, + .blindingFactor = pcBlindingFactor, + }); + + // Submit and verify failure + mptAlice.convertBack( + {.account = bob, + .amt = amt, + .proof = oldProof, + .holderEncryptedAmt = bobCiphertext, + .issuerEncryptedAmt = issuerCiphertext, + .blindingFactor = blindingFactor, + .pedersenCommitment = pedersenCommitment, + .err = tecBAD_PROOF}); // Fails because TransactionContextID differs + } + + /* This test simulates an attack where the holder ciphertext is modified + * via homomorphic addition (adding Encrypted_amt(1)) while leaving the issuer + * ciphertext unchanged. It confirms that the validator detects the + * mismatch between the re-computed ciphertexts and the submitted ones, + * resulting in tecBAD_PROOF. */ + void + testHomomorphicCiphertextModification(FeatureBitset features) + { + testcase("Homomorphic ciphertext modification"); + using namespace test::jtx; + + Env env{*this, features}; + Account const alice("alice"); + Account const bob("bob"); + MPTTester mptAlice(env, alice, {.holders = {bob}}); + + mptAlice.create( + {.ownerCount = 1, + .holderCount = 0, + .flags = tfMPTCanTransfer | tfMPTCanLock | tfMPTCanConfidentialAmount}); + + mptAlice.authorize({.account = bob}); + mptAlice.pay(alice, bob, 100); + + mptAlice.generateKeyPair(alice); + mptAlice.set({.account = alice, .issuerPubKey = mptAlice.getPubKey(alice)}); + + mptAlice.generateKeyPair(bob); + + // Bob converts 50 to confidential balance + mptAlice.convert({ + .account = bob, + .amt = 50, + .holderPubKey = mptAlice.getPubKey(bob), + }); + mptAlice.mergeInbox({.account = bob}); + + // Prepare valid parameters for a ConvertBack of 10 + uint64_t const amt = 10; + Buffer const bf = generateBlindingFactor(); + + auto const holderCipherText = mptAlice.encryptAmount(bob, amt, bf); + auto const issuerCipherText = mptAlice.encryptAmount(alice, amt, bf); + + // Generate a "Delta" ciphertext (Encrypting 1) + // We use Bob's key because we are tampering with Bob's (Holder's) field + Buffer const deltaBf = generateBlindingFactor(); + auto const deltaCipherText = mptAlice.encryptAmount(bob, 1, deltaBf); + + // Homomorphically add Delta to HolderCipherText: Tampered = Enc(10) + Enc(1) = Enc(11) + auto tamperedOpt = homomorphicAdd(holderCipherText, deltaCipherText); + BEAST_EXPECT(tamperedOpt.has_value()); + Buffer tamperedHolderCipherText = std::move(*tamperedOpt); + + // Generate a valid proof for the ORIGINAL amount (10) + auto const spendingBal = + mptAlice.getDecryptedBalance(bob, MPTTester::HOLDER_ENCRYPTED_SPENDING); + auto const spendingBalEnc = + mptAlice.getEncryptedBalance(bob, MPTTester::HOLDER_ENCRYPTED_SPENDING); + Buffer const pcBf = generateBlindingFactor(); + auto const pedersenCommitment = mptAlice.getPedersenCommitment(*spendingBal, pcBf); + + auto const currentVersion = mptAlice.getMPTokenVersion(bob); + // Uses the new signature: Account, IssuanceID, Sequence, Version + uint256 const contextHash = + getConvertBackContextHash(bob, mptAlice.issuanceID(), env.seq(bob), currentVersion); + + Buffer const proof = mptAlice.getConvertBackProof( + bob, + amt, + contextHash, + { + .pedersenCommitment = pedersenCommitment, + .amt = *spendingBal, + .encryptedAmt = *spendingBalEnc, + .blindingFactor = pcBf, + }); + + // Submit transaction with Divergent Ciphertexts + // Holder Ciphertext encrypts 11. Issuer Ciphertext encrypts 10. + // The consistency check (re-encryption of `amt` with `bf`) will match Issuer but FAIL for + // Holder. + mptAlice.convertBack( + {.account = bob, + .amt = amt, + .proof = proof, + .holderEncryptedAmt = tamperedHolderCipherText, // Tampered (11) + .issuerEncryptedAmt = issuerCipherText, // Original (10) + .blindingFactor = bf, + .pedersenCommitment = pedersenCommitment, + .err = tecBAD_PROOF}); + } + + /* This test verifies that rippled correctly rejects attempts to + * overflow the maximum allowable token amount via homomorphic manipulation. + * It simulates an attack where an individual takes a valid ciphertext encrypting + * the maximum amount (maxMPTokenAmount) and homomorphically adds an encryption of + * 1 to it, producing a ciphertext for MAX+1. The test confirms that the Bulletproof + * range proof or inner-product constraints detect this overflow and invalidate the + * transaction, preserving the supply invariant. */ + void + testSendHomomorphicOverflow(FeatureBitset features) + { + testcase("Send: homomorphic overflow attack via Enc(MAX) + Enc(1)"); + 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}}); + + mptAlice.create( + {.ownerCount = 1, + .flags = tfMPTCanLock | tfMPTCanConfidentialAmount | tfMPTCanTransfer}); + mptAlice.authorize({.account = bob}); + mptAlice.authorize({.account = carol}); + + mptAlice.pay(alice, bob, 100); + mptAlice.pay(alice, carol, 50); + + mptAlice.generateKeyPair(alice); + mptAlice.generateKeyPair(bob); + mptAlice.generateKeyPair(carol); + + mptAlice.set({.account = alice, .issuerPubKey = mptAlice.getPubKey(alice)}); + + mptAlice.convert({.account = bob, .amt = 100, .holderPubKey = mptAlice.getPubKey(bob)}); + mptAlice.mergeInbox({.account = bob}); + + mptAlice.convert({.account = carol, .amt = 50, .holderPubKey = mptAlice.getPubKey(carol)}); + mptAlice.mergeInbox({.account = carol}); + + // Bob sends 10 to carol. The send amount (10) and Bob's remaining balance + // (90) are both within [0, maxMPTokenAmount]. Range proof passes. + mptAlice.send({.account = bob, .dest = carol, .amt = 10}); + + // Bob's spending balance is 90 after the baseline send. + auto const bobSpendingBefore = + mptAlice.getDecryptedBalance(bob, MPTTester::HOLDER_ENCRYPTED_SPENDING); + BEAST_EXPECT(bobSpendingBefore == 90); + + // Construct Enc(maxMPTokenAmount) with Bob's public key. + Buffer const bf1 = generateBlindingFactor(); + Buffer const encMax = mptAlice.encryptAmount(bob, maxMPTokenAmount, bf1); + + // Construct Enc(1) with a separate blinding factor. + Buffer const bf2 = generateBlindingFactor(); + Buffer const encOne = mptAlice.encryptAmount(bob, 1, bf2); + + // Homomorphically add to produce CB_S_holder' = Enc(MAX) + Enc(1) + auto overflowedOpt = homomorphicAdd(encMax, encOne); + BEAST_EXPECT(overflowedOpt.has_value()); + Buffer overflowedCt = std::move(*overflowedOpt); + + // Submit the send transaction with the tampered ciphertext. + // Setting amt = maxMPTokenAmount + 1 drives proof generation for the + // overflowed value. The bulletproof range check [0, maxMPTokenAmount] + // rejects MAX+1; the validator must return tecBAD_PROOF. + mptAlice.send({ + .account = bob, + .dest = carol, + .amt = maxMPTokenAmount + 1, + .senderEncryptedAmt = overflowedCt, + .err = tecBAD_PROOF, + }); + + auto const bobSpendingAfter = + mptAlice.getDecryptedBalance(bob, MPTTester::HOLDER_ENCRYPTED_SPENDING); + BEAST_EXPECT(bobSpendingBefore == bobSpendingAfter); + } + + /* This test ensures that the system prevents underflow attacks where a user + * attempts to create a negative balance through homomorphic subtraction. It + * simulates a scenario where an attacker takes a ciphertext encrypting zero + * and subtracts an encryption of 1, resulting in a value of -1. + * The test asserts that the range proof verification fails because the resulting + * value falls outside the valid non-negative range [0, maxMPTokenAmount], + * causing the validator to reject the transaction with tecBAD_PROOF. */ + void + testConvertBackHomomorphicUnderflow(FeatureBitset features) + { + testcase("ConvertBack: homomorphic underflow attack via Enc(0) - Enc(1)"); + using namespace test::jtx; + + Env env{*this, features}; + Account const alice("alice"); + Account const bob("bob"); + MPTTester mptAlice(env, alice, {.holders = {bob}}); + + mptAlice.create( + {.ownerCount = 1, + .holderCount = 0, + .flags = tfMPTCanTransfer | tfMPTCanLock | tfMPTCanConfidentialAmount}); + + mptAlice.authorize({.account = bob}); + mptAlice.pay(alice, bob, 10); + + mptAlice.generateKeyPair(alice); + mptAlice.generateKeyPair(bob); + + mptAlice.set({.account = alice, .issuerPubKey = mptAlice.getPubKey(alice)}); + + mptAlice.convert({.account = bob, .amt = 10, .holderPubKey = mptAlice.getPubKey(bob)}); + mptAlice.mergeInbox({.account = bob}); + + // Converting back 1 from 10 leaves remaining balance = 9 (non-negative). + // Range proof [0, maxMPTokenAmount] passes. + mptAlice.convertBack({.account = bob, .amt = 1}); + + // Bob's spending balance is now 9; public balance is 1. + auto const bobSpendingBefore = + mptAlice.getDecryptedBalance(bob, MPTTester::HOLDER_ENCRYPTED_SPENDING); + BEAST_EXPECT(bobSpendingBefore == 9); + auto const bobPublicBefore = mptAlice.getBalance(bob); + BEAST_EXPECT(bobPublicBefore == 1); + + // Construct Enc(0) — the zero encrypted balance using Bob's key. + Buffer const bf1 = generateBlindingFactor(); + Buffer const encZero = mptAlice.encryptAmount(bob, 0, bf1); + + // Construct Enc(1) with a separate blinding factor. + Buffer const bf2 = generateBlindingFactor(); + Buffer const encOne = mptAlice.encryptAmount(bob, 1, bf2); + + // Homomorphically subtract to produce CB_S_holder' = Enc(0) − Enc(1) + // = Enc(−1), which lies below [0, maxMPTokenAmount]. + auto underflowedOpt = homomorphicSubtract(encZero, encOne); + BEAST_EXPECT(underflowedOpt.has_value()); + Buffer underflowedCt = std::move(*underflowedOpt); + + // The underflowed value as uint64_t: 0 - 1 wraps to 0xFFFFFFFFFFFFFFFF. + // Generate a real proof using this wrapped value. The validator must still reject it + // because 0xFFFFFFFFFFFFFFFE (remaining balance) is outside [0, maxMPTokenAmount]. + constexpr std::uint64_t underflowedAmt = + static_cast(0) - static_cast(1); + + Buffer const pcBf = generateBlindingFactor(); + Buffer const pedersenCommitment = mptAlice.getPedersenCommitment(underflowedAmt, pcBf); + + auto const currentVersion = mptAlice.getMPTokenVersion(bob); + uint256 const contextHash = + getConvertBackContextHash(bob, mptAlice.issuanceID(), env.seq(bob), currentVersion); + + Buffer const proof = mptAlice.getConvertBackProof( + bob, + 1, + contextHash, + { + .pedersenCommitment = pedersenCommitment, + .amt = underflowedAmt, + .encryptedAmt = underflowedCt, + .blindingFactor = pcBf, + }); + + mptAlice.convertBack({ + .account = bob, + .amt = 1, + .proof = proof, + .holderEncryptedAmt = underflowedCt, + .pedersenCommitment = pedersenCommitment, + .err = tecBAD_PROOF, + }); + + // Supply invariant: both public and confidential balances must be unchanged + // after the rejected attack. + BEAST_EXPECT(mptAlice.getBalance(bob) == bobPublicBefore); + auto const bobSpendingAfter = + mptAlice.getDecryptedBalance(bob, MPTTester::HOLDER_ENCRYPTED_SPENDING); + BEAST_EXPECT(bobSpendingBefore == bobSpendingAfter); + } + + // Confidential sends carry encrypted amounts and a zero-knowledge proof. + // Both are built from elliptic-curve math, so every coordinate in the + // transaction must be a real point on the secp256k1 curve. These three + // variants confirm the validator rejects garbage coordinates at the right + // stage before any expensive cryptographic verification runs. + void + testSendInvalidCurvePoints(FeatureBitset features) + { + testcase("Send: off-curve EC points"); + using namespace test::jtx; + + // Variant A: garbage coordinate in ciphertext / commitment fields + // getBadCiphertext() looks structurally valid (correct length, right + // prefix byte 0x02) but its x-coordinate is 0xFF...FF, which does not + // lie on secp256k1. Preflight must reject before any ledger access. + { + Env env{*this, features}; + Account const alice("alice"), bob("bob"), carol("carol"); + MPTTester mptAlice(env, alice, {.holders = {bob, carol}}); + mptAlice.create({ + .ownerCount = 1, + .flags = tfMPTCanTransfer | tfMPTCanLock | tfMPTCanConfidentialAmount, + }); + mptAlice.authorize({.account = bob}); + mptAlice.authorize({.account = carol}); + mptAlice.pay(alice, bob, 100); + mptAlice.pay(alice, carol, 50); + mptAlice.generateKeyPair(alice); + mptAlice.generateKeyPair(bob); + mptAlice.generateKeyPair(carol); + mptAlice.set({.account = alice, .issuerPubKey = mptAlice.getPubKey(alice)}); + mptAlice.convert({.account = bob, .amt = 60, .holderPubKey = mptAlice.getPubKey(bob)}); + mptAlice.convert( + {.account = carol, .amt = 30, .holderPubKey = mptAlice.getPubKey(carol)}); + + // sender's encrypted amount has an invalid coordinate + mptAlice.send({ + .account = bob, + .dest = carol, + .amt = 10, + .proof = getTrivialSendProofHex(3), + .senderEncryptedAmt = getBadCiphertext(), + .amountCommitment = getTrivialCommitment(), + .balanceCommitment = getTrivialCommitment(), + .err = temBAD_CIPHERTEXT, + }); + + // recipient's encrypted amount has an invalid coordinate + mptAlice.send({ + .account = bob, + .dest = carol, + .amt = 10, + .proof = getTrivialSendProofHex(3), + .destEncryptedAmt = getBadCiphertext(), + .amountCommitment = getTrivialCommitment(), + .balanceCommitment = getTrivialCommitment(), + .err = temBAD_CIPHERTEXT, + }); + + // issuer's encrypted amount has an invalid coordinate + mptAlice.send({ + .account = bob, + .dest = carol, + .amt = 10, + .proof = getTrivialSendProofHex(3), + .issuerEncryptedAmt = getBadCiphertext(), + .amountCommitment = getTrivialCommitment(), + .balanceCommitment = getTrivialCommitment(), + .err = temBAD_CIPHERTEXT, + }); + + // The amount and balance commitments are single curve coordinates + // used to tie the proof to the transfer amount and sender balance. + // A commitment with a valid-looking prefix but an impossible + // x-coordinate must also be rejected. + Buffer badCommitment(ecPedersenCommitmentLength); + std::memset(badCommitment.data(), 0xFF, ecPedersenCommitmentLength); + badCommitment.data()[0] = ecCompressedPrefixEvenY; + + mptAlice.send({ + .account = bob, + .dest = carol, + .amt = 10, + .proof = getTrivialSendProofHex(3), + .amountCommitment = badCommitment, + .balanceCommitment = getTrivialCommitment(), + .err = temMALFORMED, + }); + + mptAlice.send({ + .account = bob, + .dest = carol, + .amt = 10, + .proof = getTrivialSendProofHex(3), + .amountCommitment = getTrivialCommitment(), + .balanceCommitment = badCommitment, + .err = temMALFORMED, + }); + } + + // Variant B: garbage coordinates inside the ZKP proof blob + // The proof blob has the right total byte length (so it passes the + // length check at preflight), but every embedded coordinate is + // 0xFF...FF — impossible on secp256k1. The proof verifier must detect + // this and return tecBAD_PROOF without crashing. + { + Env env{*this, features}; + Account const alice("alice"), bob("bob"), carol("carol"); + MPTTester mptAlice(env, alice, {.holders = {bob, carol}}); + mptAlice.create({ + .ownerCount = 1, + .flags = tfMPTCanTransfer | tfMPTCanLock | tfMPTCanConfidentialAmount, + }); + mptAlice.authorize({.account = bob}); + mptAlice.authorize({.account = carol}); + mptAlice.pay(alice, bob, 100); + mptAlice.pay(alice, carol, 50); + mptAlice.generateKeyPair(alice); + mptAlice.generateKeyPair(bob); + mptAlice.generateKeyPair(carol); + mptAlice.set({.account = alice, .issuerPubKey = mptAlice.getPubKey(alice)}); + mptAlice.convert({.account = bob, .amt = 60, .holderPubKey = mptAlice.getPubKey(bob)}); + mptAlice.mergeInbox({.account = bob}); + mptAlice.convert( + {.account = carol, .amt = 30, .holderPubKey = mptAlice.getPubKey(carol)}); + mptAlice.mergeInbox({.account = carol}); + + size_t const proofSize = + getEqualityProofSize(3) + 2 * ecPedersenProofLength + ecDoubleBulletproofLength; + Buffer badProof(proofSize); + std::memset(badProof.data(), 0xFF, proofSize); + badProof.data()[0] = ecCompressedPrefixEvenY; + + mptAlice.send({ + .account = bob, + .dest = carol, + .amt = 10, + .proof = strHex(badProof), + .err = tecBAD_PROOF, + }); + } + + // Variant C: only one of the two ciphertext coordinates is bad + // Each encrypted amount is two coordinates back-to-back: C1 then C2. + // Both must be valid. These tests corrupt only one at a time to + // confirm both are checked independently. + { + Env env{*this, features}; + Account const alice("alice"), bob("bob"), carol("carol"); + MPTTester mptAlice(env, alice, {.holders = {bob, carol}}); + mptAlice.create({ + .ownerCount = 1, + .flags = tfMPTCanTransfer | tfMPTCanLock | tfMPTCanConfidentialAmount, + }); + mptAlice.authorize({.account = bob}); + mptAlice.authorize({.account = carol}); + mptAlice.pay(alice, bob, 100); + mptAlice.pay(alice, carol, 50); + mptAlice.generateKeyPair(alice); + mptAlice.generateKeyPair(bob); + mptAlice.generateKeyPair(carol); + mptAlice.set({.account = alice, .issuerPubKey = mptAlice.getPubKey(alice)}); + mptAlice.convert({.account = bob, .amt = 60, .holderPubKey = mptAlice.getPubKey(bob)}); + mptAlice.convert( + {.account = carol, .amt = 30, .holderPubKey = mptAlice.getPubKey(carol)}); + + // getTrivialCiphertext() has both C1 and C2 as valid (but trivial) + // curve coordinates. We replace one half at a time with 0xFF...FF. + auto const& tc = getTrivialCiphertext(); + + // C1 = bad (0xFF...FF), C2 = valid trivial point + Buffer badC1goodC2(ecGamalEncryptedTotalLength); + std::memset(badC1goodC2.data(), 0xFF, ecGamalEncryptedTotalLength); + badC1goodC2.data()[0] = ecCompressedPrefixEvenY; + std::memcpy( + badC1goodC2.data() + ecGamalEncryptedLength, + tc.data() + ecGamalEncryptedLength, + ecGamalEncryptedLength); + + // C1 = valid trivial point, C2 = bad (0xFF...FF) + Buffer goodC1badC2(ecGamalEncryptedTotalLength); + std::memset(goodC1badC2.data(), 0xFF, ecGamalEncryptedTotalLength); + std::memcpy(goodC1badC2.data(), tc.data(), ecGamalEncryptedLength); + goodC1badC2.data()[ecGamalEncryptedLength] = ecCompressedPrefixEvenY; + + // sender's encrypted amount — bad C1 + mptAlice.send({ + .account = bob, + .dest = carol, + .amt = 10, + .proof = getTrivialSendProofHex(3), + .senderEncryptedAmt = badC1goodC2, + .amountCommitment = getTrivialCommitment(), + .balanceCommitment = getTrivialCommitment(), + .err = temBAD_CIPHERTEXT, + }); + + // sender's encrypted amount — bad C2 + mptAlice.send({ + .account = bob, + .dest = carol, + .amt = 10, + .proof = getTrivialSendProofHex(3), + .senderEncryptedAmt = goodC1badC2, + .amountCommitment = getTrivialCommitment(), + .balanceCommitment = getTrivialCommitment(), + .err = temBAD_CIPHERTEXT, + }); + + // recipient's encrypted amount — bad C1 + mptAlice.send({ + .account = bob, + .dest = carol, + .amt = 10, + .proof = getTrivialSendProofHex(3), + .destEncryptedAmt = badC1goodC2, + .amountCommitment = getTrivialCommitment(), + .balanceCommitment = getTrivialCommitment(), + .err = temBAD_CIPHERTEXT, + }); + + // recipient's encrypted amount — bad C2 + mptAlice.send({ + .account = bob, + .dest = carol, + .amt = 10, + .proof = getTrivialSendProofHex(3), + .destEncryptedAmt = goodC1badC2, + .amountCommitment = getTrivialCommitment(), + .balanceCommitment = getTrivialCommitment(), + .err = temBAD_CIPHERTEXT, + }); + } + } + + // Reject points from the wrong elliptic curve (wrong-group injection). + // + // An attacker might submit coordinates that come from a completely + // different elliptic curve, for example, the one used in TLS + // certificates (NIST P-256). If those coordinates happen to also be + // valid points on secp256k1 (which is possible since both curves use + // 256-bit fields), the format check at preflight will pass. However, + // the zero-knowledge proof is built specifically for secp256k1: the + // math inside the proof only holds for the right curve, so any + // transaction carrying cross-curve data will still be rejected at + // proof verification (tecBAD_PROOF). + void + testSendWrongGroupPointInjection(FeatureBitset features) + { + testcase("Send: wrong-group point injection rejected"); + using namespace test::jtx; + + Env env{*this, features}; + Account const alice("alice"), bob("bob"), carol("carol"); + MPTTester mptAlice(env, alice, {.holders = {bob, carol}}); + + mptAlice.create({ + .ownerCount = 1, + .flags = tfMPTCanTransfer | tfMPTCanLock | tfMPTCanConfidentialAmount, + }); + mptAlice.authorize({.account = bob}); + mptAlice.authorize({.account = carol}); + mptAlice.pay(alice, bob, 100); + mptAlice.pay(alice, carol, 50); + mptAlice.generateKeyPair(alice); + mptAlice.generateKeyPair(bob); + mptAlice.generateKeyPair(carol); + mptAlice.set({.account = alice, .issuerPubKey = mptAlice.getPubKey(alice)}); + mptAlice.convert({.account = bob, .amt = 60, .holderPubKey = mptAlice.getPubKey(bob)}); + mptAlice.mergeInbox({.account = bob}); + mptAlice.convert({.account = carol, .amt = 30, .holderPubKey = mptAlice.getPubKey(carol)}); + mptAlice.mergeInbox({.account = carol}); + + // The x-coordinate of the NIST P-256 generator point — a real, + // well-known value from a different elliptic curve (used in TLS + // and certificates). This x-coordinate is also a valid secp256k1 + // point, so it passes preflight. Rejection happens at proof + // verification because the ZKP is secp256k1-specific. + // + // P-256 generator x: + // 6B17D1F2E12C4247F8BCE6E563A440F277037D812DEB33A0F4A13945D898C296 + static constexpr std::uint8_t kP256GeneratorX[32] = { + 0x6B, 0x17, 0xD1, 0xF2, 0xE1, 0x2C, 0x42, 0x47, 0xF8, 0xBC, 0xE6, + 0xE5, 0x63, 0xA4, 0x40, 0xF2, 0x77, 0x03, 0x7D, 0x81, 0x2D, 0xEB, + 0x33, 0xA0, 0xF4, 0xA1, 0x39, 0x45, 0xD8, 0x98, 0xC2, 0x96, + }; + + // A 66-byte encrypted amount using the P-256 x-coordinate for both halves. + Buffer wrongGroupCt(ecGamalEncryptedTotalLength); + wrongGroupCt.data()[0] = ecCompressedPrefixEvenY; + std::memcpy(wrongGroupCt.data() + 1, kP256GeneratorX, 32); + wrongGroupCt.data()[ecGamalEncryptedLength] = ecCompressedPrefixEvenY; + std::memcpy(wrongGroupCt.data() + ecGamalEncryptedLength + 1, kP256GeneratorX, 32); + + // A 33-byte commitment using the same wrong-curve x-coordinate. + Buffer wrongGroupCommitment(ecPedersenCommitmentLength); + wrongGroupCommitment.data()[0] = ecCompressedPrefixEvenY; + std::memcpy(wrongGroupCommitment.data() + 1, kP256GeneratorX, 32); + + // sender's encrypted amount uses a coordinate from the wrong curve + mptAlice.send({ + .account = bob, + .dest = carol, + .amt = 10, + .proof = getTrivialSendProofHex(3), + .senderEncryptedAmt = wrongGroupCt, + .amountCommitment = getTrivialCommitment(), + .balanceCommitment = getTrivialCommitment(), + .err = tecBAD_PROOF, + }); + + // recipient's encrypted amount uses a coordinate from the wrong curve + mptAlice.send({ + .account = bob, + .dest = carol, + .amt = 10, + .proof = getTrivialSendProofHex(3), + .destEncryptedAmt = wrongGroupCt, + .amountCommitment = getTrivialCommitment(), + .balanceCommitment = getTrivialCommitment(), + .err = tecBAD_PROOF, + }); + + // issuer's encrypted amount uses a coordinate from the wrong curve + mptAlice.send({ + .account = bob, + .dest = carol, + .amt = 10, + .proof = getTrivialSendProofHex(3), + .issuerEncryptedAmt = wrongGroupCt, + .amountCommitment = getTrivialCommitment(), + .balanceCommitment = getTrivialCommitment(), + .err = tecBAD_PROOF, + }); + + // amount commitment uses a coordinate from the wrong curve + mptAlice.send({ + .account = bob, + .dest = carol, + .amt = 10, + .proof = getTrivialSendProofHex(3), + .amountCommitment = wrongGroupCommitment, + .balanceCommitment = getTrivialCommitment(), + .err = tecBAD_PROOF, + }); + + // balance commitment uses a coordinate from the wrong curve + mptAlice.send({ + .account = bob, + .dest = carol, + .amt = 10, + .proof = getTrivialSendProofHex(3), + .amountCommitment = getTrivialCommitment(), + .balanceCommitment = wrongGroupCommitment, + .err = tecBAD_PROOF, + }); + } + + // Reject an all-zero "null" public key. + // + // Every account in a confidential transfer needs a real public key — + // a specific point on the secp256k1 curve derived from a secret number + // only that account knows. An all-zero key (33 bytes of 0x00) is not + // a real key. It has no secret behind it, and encrypting data to it + // would not actually hide anything. The validator must reject it at + // preflight so no account can ever register a broken key. + void + testIdentityElementRejection(FeatureBitset features) + { + testcase("Send: all-zero public key rejected"); + using namespace test::jtx; + + // 33 zero bytes — not a real public key; no valid secret maps to this. + Buffer const nullKey = makeZeroBuffer(ecPubKeyLength); + + // Recipient (holder) tries to register an all-zero key. + // Must be rejected so no account ends up with an unprotected balance. + { + Env env{*this, features}; + Account const alice("alice"), bob("bob"), carol("carol"); + MPTTester mptAlice(env, alice, {.holders = {bob, carol}}); + mptAlice.create({ + .ownerCount = 1, + .flags = tfMPTCanTransfer | tfMPTCanLock | tfMPTCanConfidentialAmount, + }); + mptAlice.authorize({.account = bob}); + mptAlice.authorize({.account = carol}); + mptAlice.pay(alice, bob, 100); + mptAlice.pay(alice, carol, 50); + mptAlice.generateKeyPair(alice); + mptAlice.generateKeyPair(bob); + mptAlice.generateKeyPair(carol); + mptAlice.set({.account = alice, .issuerPubKey = mptAlice.getPubKey(alice)}); + + // recipient (carol) tries to register an all-zero key + mptAlice.convert({ + .account = carol, + .amt = 10, + .holderPubKey = nullKey, + .err = temMALFORMED, + }); + + // sender (bob) tries to register an all-zero key + mptAlice.convert({ + .account = bob, + .amt = 10, + .holderPubKey = nullKey, + .err = temMALFORMED, + }); + } + + // Issuer tries to register an all-zero key. + // The issuer's key is used to encrypt the issuer's copy of every + // transfer amount. + { + Env env{*this, features}; + Account const alice("alice"), bob("bob"); + MPTTester mptAlice(env, alice, {.holders = {bob}}); + mptAlice.create({ + .ownerCount = 1, + .flags = tfMPTCanTransfer | tfMPTCanLock | tfMPTCanConfidentialAmount, + }); + mptAlice.authorize({.account = bob}); + mptAlice.pay(alice, bob, 100); + mptAlice.generateKeyPair(alice); + mptAlice.generateKeyPair(bob); + + mptAlice.set({ + .account = alice, + .issuerPubKey = nullKey, + .err = temMALFORMED, + }); + } + } + + /* This test ensures that when sending confidential tokens, the encrypted + * amounts are securely locked to the correct accounts' official public keys. + * + * Attack scenario — Encrypting the issuer's copy with the wrong key: + * A sender correctly encrypts the hidden transfer amount for themselves + * and the receiver. However, they intentionally encrypt the issuer's + * copy of the data using the wrong public key (for example, using the + * receiver's key instead of the official issuer's key). */ + void + testSendWrongIssuerPublicKey(FeatureBitset features) + { + testcase("Send: issuer ciphertext encrypted under wrong public key"); + 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}}); + + mptAlice.create( + {.ownerCount = 1, + .flags = tfMPTCanTransfer | tfMPTCanLock | tfMPTCanConfidentialAmount}); + mptAlice.authorize({.account = bob}); + mptAlice.authorize({.account = carol}); + mptAlice.pay(alice, bob, 100); + mptAlice.pay(alice, carol, 50); + + mptAlice.generateKeyPair(alice); + mptAlice.generateKeyPair(bob); + mptAlice.generateKeyPair(carol); + + mptAlice.set({.account = alice, .issuerPubKey = mptAlice.getPubKey(alice)}); + + mptAlice.convert({.account = bob, .amt = 100, .holderPubKey = mptAlice.getPubKey(bob)}); + mptAlice.mergeInbox({.account = bob}); + + mptAlice.convert({.account = carol, .amt = 50, .holderPubKey = mptAlice.getPubKey(carol)}); + mptAlice.mergeInbox({.account = carol}); + + auto const bobSpendingBefore = + mptAlice.getDecryptedBalance(bob, MPTTester::HOLDER_ENCRYPTED_SPENDING); + + // issuer ciphertext encrypted under carol's holder key + // (should be under alice's registered issuer key). + { + Buffer const bf = generateBlindingFactor(); + Buffer const wrongIssuerCt = mptAlice.encryptAmount(carol, 10, bf); + + mptAlice.send({ + .account = bob, + .dest = carol, + .amt = 10, + .issuerEncryptedAmt = wrongIssuerCt, + .err = tecBAD_PROOF, + }); + } + + // issuer ciphertext encrypted under bob's holder key + // (the sender's own key — still not the registered issuer key). + { + Buffer const bf = generateBlindingFactor(); + Buffer const wrongIssuerCt = mptAlice.encryptAmount(bob, 10, bf); + + mptAlice.send({ + .account = bob, + .dest = carol, + .amt = 10, + .issuerEncryptedAmt = wrongIssuerCt, + .err = tecBAD_PROOF, + }); + } + + // all balances unchanged + BEAST_EXPECT( + mptAlice.getDecryptedBalance(bob, MPTTester::HOLDER_ENCRYPTED_SPENDING) == + bobSpendingBefore); + BEAST_EXPECT(mptAlice.getDecryptedBalance(carol, MPTTester::HOLDER_ENCRYPTED_INBOX) == 0); + } + + // Exercises every Confidential Transfer transaction type (MPTokenIssuanceSet, + // Convert, MergeInbox, Send, ConvertBack) using tickets instead of regular account + // sequence numbers. + void + testWithTickets(FeatureBitset features) + { + testcase("Confidential transfer with tickets"); + 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}}); + + mptAlice.create({ + .ownerCount = 1, + .flags = tfMPTCanTransfer | tfMPTCanLock | tfMPTCanConfidentialAmount, + }); + mptAlice.authorize({.account = bob}); + mptAlice.authorize({.account = carol}); + mptAlice.pay(alice, bob, 100); + mptAlice.pay(alice, carol, 100); + + mptAlice.generateKeyPair(alice); + mptAlice.generateKeyPair(bob); + mptAlice.generateKeyPair(carol); + + // MPTokenIssuanceSet with ticket, registers alice's issuer key. + { + std::uint32_t const ticketSeq = env.seq(alice) + 1; + env(ticket::create(alice, 1)); + mptAlice.set({.issuerPubKey = mptAlice.getPubKey(alice), .ticketSeq = ticketSeq}); + } + + // ConfidentialMPTConvert with ticket, first convert registers bob's key. + { + std::uint32_t const ticketSeq = env.seq(bob) + 1; + env(ticket::create(bob, 1)); + mptAlice.convert( + {.account = bob, + .amt = 50, + .holderPubKey = mptAlice.getPubKey(bob), + .ticketSeq = ticketSeq}); + env.require(mptbalance(mptAlice, bob, 50)); + } + + // ConfidentialMPTConvert with ticket + { + std::uint32_t const ticketSeq = env.seq(bob) + 1; + env(ticket::create(bob, 1)); + mptAlice.convert({.account = bob, .amt = 20, .ticketSeq = ticketSeq}); + env.require(mptbalance(mptAlice, bob, 30)); + } + + // ConfidentialMPTMergeInbox with ticket. + { + std::uint32_t const ticketSeq = env.seq(bob) + 1; + env(ticket::create(bob, 1)); + mptAlice.mergeInbox({.account = bob, .ticketSeq = ticketSeq}); + } + + mptAlice.convert({.account = carol, .amt = 50, .holderPubKey = mptAlice.getPubKey(carol)}); + mptAlice.mergeInbox({.account = carol}); + + // ConfidentialMPTSend with ticket. + { + std::uint32_t const ticketSeq = env.seq(bob) + 1; + env(ticket::create(bob, 1)); + mptAlice.send({.account = bob, .dest = carol, .amt = 10, .ticketSeq = ticketSeq}); + } + + // Merge carol's inbox so her spending balance includes the received send. + mptAlice.mergeInbox({.account = carol}); + + // ConfidentialMPTConvertBack with ticket. + // The convertBack proof context hash must use the ticket sequence. + { + std::uint32_t const ticketSeq = env.seq(carol) + 1; + env(ticket::create(carol, 1)); + mptAlice.convertBack({.account = carol, .amt = 10, .ticketSeq = ticketSeq}); + // carol converted 50, received 10 from bob, then converted back 10 → public 60 + env.require(mptbalance(mptAlice, carol, 60)); + } + } + + // Verifies that cryptographic proofs in Convert transactions are bound to + // the ticket sequence rather than the account sequence. + // A proof built with the ticket sequence passes. + void + testConvertTicketProofBinding(FeatureBitset features) + { + testcase("Convert proof binds to ticket sequence"); + using namespace test::jtx; + + Env env{*this, features}; + Account const alice("alice"); + Account const bob("bob"); + MPTTester mptAlice(env, alice, {.holders = {bob}}); + + mptAlice.create({ + .ownerCount = 1, + .holderCount = 0, + .flags = tfMPTCanTransfer | tfMPTCanLock | tfMPTCanConfidentialAmount, + }); + mptAlice.authorize({.account = bob}); + mptAlice.pay(alice, bob, 100); + + mptAlice.generateKeyPair(alice); + mptAlice.set({.account = alice, .issuerPubKey = mptAlice.getPubKey(alice)}); + mptAlice.generateKeyPair(bob); + + uint64_t const amt = 30; + Buffer const bf = generateBlindingFactor(); + Buffer const holderCt = mptAlice.encryptAmount(bob, amt, bf); + Buffer const issuerCt = mptAlice.encryptAmount(alice, amt, bf); + + std::uint32_t const ticketSeq1 = env.seq(bob) + 1; + env(ticket::create(bob, 1)); + + // Invalid: Schnorr proof built with the account seq (env.seq(bob)) rather + // than the ticket seq (ticketSeq1). + { + BEAST_EXPECT(env.seq(bob) != ticketSeq1); + uint256 const badCtxHash = + getConvertContextHash(bob, mptAlice.issuanceID(), env.seq(bob)); + auto const badProof = mptAlice.getSchnorrProof(bob, badCtxHash); + BEAST_EXPECT(badProof.has_value()); + + mptAlice.convert({ + .account = bob, + .amt = amt, + .proof = strHex(*badProof), + .holderPubKey = mptAlice.getPubKey(bob), + .holderEncryptedAmt = holderCt, + .issuerEncryptedAmt = issuerCt, + .blindingFactor = bf, + .ticketSeq = ticketSeq1, + .err = tecBAD_PROOF, + }); + } + + std::uint32_t const ticketSeq2 = env.seq(bob) + 1; + env(ticket::create(bob, 1)); + + // Valid: proof auto-generated by convert() using ticketSeq2; context hashes match. + mptAlice.convert({ + .account = bob, + .amt = amt, + .holderPubKey = mptAlice.getPubKey(bob), + .holderEncryptedAmt = holderCt, + .issuerEncryptedAmt = issuerCt, + .blindingFactor = bf, + .ticketSeq = ticketSeq2, + }); + env.require(mptbalance(mptAlice, bob, 70)); + } + + // Exercises ticket-specific error codes for confidential transfer transactions: + // terPRE_TICKET when the ticket doesn't exist yet, and tefNO_TICKET when + // the ticket has already been consumed or was never created. + void + testTicketErrors(FeatureBitset features) + { + testcase("Confidential transfer ticket errors"); + using namespace test::jtx; + + Env env{*this, features}; + Account const alice("alice"); + Account const bob("bob"); + MPTTester mptAlice(env, alice, {.holders = {bob}}); + + mptAlice.create({ + .ownerCount = 1, + .holderCount = 0, + .flags = tfMPTCanTransfer | tfMPTCanLock | tfMPTCanConfidentialAmount, + }); + mptAlice.authorize({.account = bob}); + mptAlice.pay(alice, bob, 100); + + mptAlice.generateKeyPair(alice); + mptAlice.set({.account = alice, .issuerPubKey = mptAlice.getPubKey(alice)}); + mptAlice.generateKeyPair(bob); + + // Give bob an inbox balance so MergeInbox has something to merge. + mptAlice.convert({.account = bob, .amt = 10, .holderPubKey = mptAlice.getPubKey(bob)}); + + // Use MergeInbox as the confidential transfer transaction under test + // so that ticket errors are isolated from cryptographic verification. + + // terPRE_TICKET: ticket sequence is far in the future and hasn't been created. + mptAlice.mergeInbox( + {.account = bob, .ticketSeq = env.seq(bob) + 100, .err = terPRE_TICKET}); + + // Create one ticket and use it successfully. + std::uint32_t const ticketSeq = env.seq(bob) + 1; + env(ticket::create(bob, 1)); + mptAlice.mergeInbox({.account = bob, .ticketSeq = ticketSeq}); + + // tefNO_TICKET: attempt to reuse the same (already-consumed) ticket. + mptAlice.mergeInbox({.account = bob, .ticketSeq = ticketSeq, .err = tefNO_TICKET}); + + // tefNO_TICKET: ticket sequence is in the past but was never created. + mptAlice.mergeInbox({.account = bob, .ticketSeq = 1, .err = tefNO_TICKET}); + } + + // 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. + 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 | tfMPTCanConfidentialAmount, + }); + 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)}); + } + + // Bob sends 100 MPT to Carol. Carol Merge Inbox. Carol sends 50 MPT to Dave. + // Inner 3rd txn (Carol sends to Dave) fails because the proof is built with + // when Carols's spending balance is 0. (before she received funds from Bob) + // + // Also tests Bob sending to two recipients (Carol and Dave) in a single + // batch. Even though Bob has enough balance for both, the second send's + // balance-linkage proof becomes incorrect once inner 1 updates Bob's encrypted + // spending, so fails + void + testBatchConfidentialSend(FeatureBitset features) + { + testcase("Batch confidential send - merge inbox dependency"); + using namespace test::jtx; + + { + Env env{*this, features}; + Account const alice("alice"); + Account const bob("bob"); + Account const carol("carol"); + Account const dave("dave"); + + MPTTester mpt(env, alice, {.holders = {bob, carol, dave}}); + // bob = A (100 spending), carol = B (0), dave = C (0) + setupBatchEnv(mpt, alice, bob, carol, dave, 100, 0); + + // Build the batch: + // Batch Txn 1 bob -> carol 100 : valid proof, bob spending=100 + // Batch Txn 2 carol -> mergeInbox : valid JV + // Batch Txn 3 carol->dave 50 : Invalid + auto const bobSeq = env.seq(bob); + auto const carolSeq = env.seq(carol); + // 3 signers, Bob, Carol, Dave + auto const batchFee = batch::calcBatchFee(env, 1, 3); + + auto const jv1 = mpt.sendJV({.account = bob, .dest = carol, .amt = 100}, bobSeq + 1); + auto const jv2 = mpt.mergeInboxJV({.account = carol}); + auto const jv3 = mpt.sendJV({.account = carol, .dest = dave, .amt = 50}, carolSeq + 1); + + env(batch::outer(bob, bobSeq, batchFee, tfAllOrNothing), + batch::inner(jv1, bobSeq + 1), + batch::inner(jv2, carolSeq), + batch::inner(jv3, carolSeq + 1), + batch::sig(carol), + ter(tesSUCCESS)); + env.close(); + + // AllOrNothing: inner 3 fails + // bob's spending must remain 100; carol's inbox must remain 0. + BEAST_EXPECT(mpt.getDecryptedBalance(bob, MPTTester::HOLDER_ENCRYPTED_SPENDING) == 100); + BEAST_EXPECT(mpt.getDecryptedBalance(carol, MPTTester::HOLDER_ENCRYPTED_INBOX) == 0); + } + + // Bob sends to two recipients (Carol and Dave) in one batch. + // Bob has 150, enough for both sends individually. However, batch txn 1 + // changes Bob's encrypted spending on the ledger; batch txn 2 was built + // against the old enc(150) so its balance-linkage proof is stale. + { + Env env{*this, features}; + Account const alice("alice"); + Account const bob("bob"); + Account const carol("carol"); + Account const dave("dave"); + + MPTTester mpt(env, alice, {.holders = {bob, carol, dave}}); + setupBatchEnv(mpt, alice, bob, carol, dave, 150, 0); + + // tfAllOrNothing — rejects the whole batch as 2nd txn proof is incorrect + { + auto const bobSeq = env.seq(bob); + auto const batchFee = batch::calcBatchFee(env, 0, 2); + + auto const jv1 = mpt.sendJV({.account = bob, .dest = carol, .amt = 50}, bobSeq + 1); + auto const jv2 = mpt.sendJV({.account = bob, .dest = dave, .amt = 60}, bobSeq + 2); + + env(batch::outer(bob, bobSeq, batchFee, tfAllOrNothing), + batch::inner(jv1, bobSeq + 1), + batch::inner(jv2, bobSeq + 2), + ter(tesSUCCESS)); + env.close(); + + // Nothing applied: bob stays 150, carol and dave inbox stay 0. + BEAST_EXPECT( + mpt.getDecryptedBalance(bob, MPTTester::HOLDER_ENCRYPTED_SPENDING) == 150); + BEAST_EXPECT( + mpt.getDecryptedBalance(carol, MPTTester::HOLDER_ENCRYPTED_INBOX) == 0); + BEAST_EXPECT(mpt.getDecryptedBalance(dave, MPTTester::HOLDER_ENCRYPTED_INBOX) == 0); + } + + // If we change batch mode to be tfIndependent — txn 1 applies, inner 2 fails. + { + auto const bobSeq = env.seq(bob); + auto const batchFee = batch::calcBatchFee(env, 0, 2); + + auto const jv1 = mpt.sendJV({.account = bob, .dest = carol, .amt = 50}, bobSeq + 1); + auto const jv2 = mpt.sendJV({.account = bob, .dest = dave, .amt = 60}, bobSeq + 2); + + env(batch::outer(bob, bobSeq, batchFee, tfIndependent), + batch::inner(jv1, bobSeq + 1), + batch::inner(jv2, bobSeq + 2), + ter(tesSUCCESS)); + env.close(); + + // bob 150→100, carol inbox 0→50 + BEAST_EXPECT( + mpt.getDecryptedBalance(bob, MPTTester::HOLDER_ENCRYPTED_SPENDING) == 100); + BEAST_EXPECT( + mpt.getDecryptedBalance(carol, MPTTester::HOLDER_ENCRYPTED_INBOX) == 50); + // dave gets nothing + BEAST_EXPECT(mpt.getDecryptedBalance(dave, MPTTester::HOLDER_ENCRYPTED_INBOX) == 0); + } + } + + // Now, Bob sends Confidential MPT to 2 accounts in one batch. + // However this time, the second txn proof is calculated using the + // correct encrypted(spending) proof, so it should pass. + { + // bob has exactly enough for both sends. + Env env{*this, features}; + Account const alice("alice"); + Account const bob("bob"); + Account const carol("carol"); + Account const dave("dave"); + + MPTTester mpt(env, alice, {.holders = {bob, carol, dave}}); + setupBatchEnv(mpt, alice, bob, carol, dave, 200, 0); + + { + auto const bobSeq = env.seq(bob); + auto const batchFee = batch::calcBatchFee(env, 0, 2); + + // jv1 is built against the current ledger state (spending=200). + auto const jv1 = + mpt.sendJV({.account = bob, .dest = carol, .amt = 100}, bobSeq + 1); + + // Compute post-jv1 state without touching the ledger. + auto const chain1 = mpt.chainAfterSend(bob, 100, jv1); + + // jv2 proof is built against predicted spending=100, version=N+1. + auto const jv2 = + mpt.sendJV({.account = bob, .dest = dave, .amt = 100}, bobSeq + 2, chain1); + + env(batch::outer(bob, bobSeq, batchFee, tfAllOrNothing), + batch::inner(jv1, bobSeq + 1), + batch::inner(jv2, bobSeq + 2), + ter(tesSUCCESS)); + env.close(); + + // Both txns applied: bob 200→0, carol inbox=100, dave inbox=100. + BEAST_EXPECT( + mpt.getDecryptedBalance(bob, MPTTester::HOLDER_ENCRYPTED_SPENDING) == 0); + BEAST_EXPECT( + mpt.getDecryptedBalance(carol, MPTTester::HOLDER_ENCRYPTED_INBOX) == 100); + BEAST_EXPECT( + mpt.getDecryptedBalance(dave, MPTTester::HOLDER_ENCRYPTED_INBOX) == 100); + } + + // Now Bob has 150, but triees to send two 100 in one batch. + // This fails because Bob doesn't have enough MPT balance. + { + Env env2{*this, features}; + Account const alice2("alice"); + Account const bob2("bob"); + Account const carol2("carol"); + Account const dave2("dave"); + + MPTTester mpt2(env2, alice2, {.holders = {bob2, carol2, dave2}}); + setupBatchEnv(mpt2, alice2, bob2, carol2, dave2, 150, 0); + + auto const bobSeq = env2.seq(bob2); + auto const batchFee = batch::calcBatchFee(env2, 0, 2); + + auto const jv1 = + mpt2.sendJV({.account = bob2, .dest = carol2, .amt = 100}, bobSeq + 1); + auto const chain1 = mpt2.chainAfterSend(bob2, 100, jv1); + + auto const jv2 = + mpt2.sendJV({.account = bob2, .dest = dave2, .amt = 100}, bobSeq + 2, chain1); + + env2( + batch::outer(bob2, bobSeq, batchFee, tfAllOrNothing), + batch::inner(jv1, bobSeq + 1), + batch::inner(jv2, bobSeq + 2), + ter(tesSUCCESS)); + env2.close(); + + // AllOrNothing: inner 2 fails → nothing applied. + BEAST_EXPECT( + mpt2.getDecryptedBalance(bob2, MPTTester::HOLDER_ENCRYPTED_SPENDING) == 150); + BEAST_EXPECT( + mpt2.getDecryptedBalance(carol2, MPTTester::HOLDER_ENCRYPTED_INBOX) == 0); + BEAST_EXPECT( + mpt2.getDecryptedBalance(dave2, MPTTester::HOLDER_ENCRYPTED_INBOX) == 0); + } + } + } + + void + testBatchAllOrNothing(FeatureBitset features) + { + testcase("Batch confidential MPT - all or nothing"); + using namespace test::jtx; + + Env env{*this, features}; + Account const alice("alice"); + Account const bob("bob"); + Account const carol("carol"); + Account const dave("dave"); + + MPTTester mpt(env, alice, {.holders = {bob, carol, dave}}); + // bob=100 spending, carol=60 spending, dave=0 + setupBatchEnv(mpt, alice, bob, carol, dave, 100, 60); + + // bob sends dave 10, carol sends dave 5, independent, both valid. + { + auto const bobSeq = env.seq(bob); + auto const carolSeq = env.seq(carol); + auto const batchFee = batch::calcBatchFee(env, 1, 2); + + auto const jv1 = mpt.sendJV({.account = bob, .dest = dave, .amt = 10}, bobSeq + 1); + auto const jv2 = mpt.sendJV({.account = carol, .dest = dave, .amt = 5}, carolSeq); + + env(batch::outer(bob, bobSeq, batchFee, tfAllOrNothing), + batch::inner(jv1, bobSeq + 1), + batch::inner(jv2, carolSeq), + batch::sig(carol), + ter(tesSUCCESS)); + env.close(); + + // Both txn applied: bob's balance 100→90, carol 60→55, dave inbox 0→15 + BEAST_EXPECT(mpt.getDecryptedBalance(bob, MPTTester::HOLDER_ENCRYPTED_SPENDING) == 90); + BEAST_EXPECT( + mpt.getDecryptedBalance(carol, MPTTester::HOLDER_ENCRYPTED_SPENDING) == 55); + BEAST_EXPECT(mpt.getDecryptedBalance(dave, MPTTester::HOLDER_ENCRYPTED_INBOX) == 15); + } + } + + void + testBatchOnlyOne(FeatureBitset features) + { + testcase("Batch confidential MPT - only one"); + using namespace test::jtx; + + Env env{*this, features}; + Account const alice("alice"); + Account const bob("bob"); + Account const carol("carol"); + Account const dave("dave"); + + MPTTester mpt(env, alice, {.holders = {bob, carol, dave}}); + // bob=100 spending, carol=60 spending, dave=0 + setupBatchEnv(mpt, alice, bob, carol, dave, 100, 60); + + // bob sends dave 200 (invalid), carol sends dave 300 (invalid) + { + auto const bobSeq = env.seq(bob); + auto const carolSeq = env.seq(carol); + auto const batchFee = batch::calcBatchFee(env, 1, 2); + + // Both proofs fail range check (amount > balance) + auto const jv1 = mpt.sendJV({.account = bob, .dest = dave, .amt = 200}, bobSeq + 1); + auto const jv2 = mpt.sendJV({.account = carol, .dest = dave, .amt = 300}, carolSeq); + + env(batch::outer(bob, bobSeq, batchFee, tfOnlyOne), + batch::inner(jv1, bobSeq + 1), + batch::inner(jv2, carolSeq), + batch::sig(carol), + ter(tesSUCCESS)); + env.close(); + + // No success found → nothing applied; balances unchanged + BEAST_EXPECT(mpt.getDecryptedBalance(bob, MPTTester::HOLDER_ENCRYPTED_SPENDING) == 100); + BEAST_EXPECT( + mpt.getDecryptedBalance(carol, MPTTester::HOLDER_ENCRYPTED_SPENDING) == 60); + BEAST_EXPECT(mpt.getDecryptedBalance(dave, MPTTester::HOLDER_ENCRYPTED_INBOX) == 0); + } + + // bob sends dave 200 (invalid), carol sends dave 5 (valid) + { + auto const bobSeq = env.seq(bob); + auto const carolSeq = env.seq(carol); + auto const batchFee = batch::calcBatchFee(env, 1, 2); + + auto jv1 = mpt.sendJV({.account = bob, .dest = dave, .amt = 200}, bobSeq + 1); + auto jv2 = mpt.sendJV({.account = carol, .dest = dave, .amt = 5}, carolSeq); + + env(batch::outer(bob, bobSeq, batchFee, tfOnlyOne), + batch::inner(jv1, bobSeq + 1), + batch::inner(jv2, carolSeq), + batch::sig(carol), + ter(tesSUCCESS)); + env.close(); + + // Only carol's send applied: carol 60→55, dave inbox 0→5, bob unchanged + BEAST_EXPECT(mpt.getDecryptedBalance(bob, MPTTester::HOLDER_ENCRYPTED_SPENDING) == 100); + BEAST_EXPECT( + mpt.getDecryptedBalance(carol, MPTTester::HOLDER_ENCRYPTED_SPENDING) == 55); + BEAST_EXPECT(mpt.getDecryptedBalance(dave, MPTTester::HOLDER_ENCRYPTED_INBOX) == 5); + } + } + + void + testBatchUntilFailure(FeatureBitset features) + { + testcase("Batch confidential MPT - until failure"); + using namespace test::jtx; + + Env env{*this, features}; + Account const alice("alice"); + Account const bob("bob"); + Account const carol("carol"); + Account const dave("dave"); + + MPTTester mpt(env, alice, {.holders = {bob, carol, dave}}); + // bob=100 spending, carol=60 spending, dave=0 + setupBatchEnv(mpt, alice, bob, carol, dave, 100, 60); + + // first fails → none applied + // Bob sends Dave 200 (invalid — stops immediately) + { + auto const bobSeq = env.seq(bob); + auto const carolSeq = env.seq(carol); + auto const batchFee = batch::calcBatchFee(env, 1, 2); + + auto const jv1 = mpt.sendJV({.account = bob, .dest = dave, .amt = 200}, bobSeq + 1); + auto const jv2 = mpt.sendJV({.account = carol, .dest = dave, .amt = 5}, carolSeq); + + env(batch::outer(bob, bobSeq, batchFee, tfUntilFailure), + batch::inner(jv1, bobSeq + 1), + batch::inner(jv2, carolSeq), + batch::sig(carol), + ter(tesSUCCESS)); + env.close(); + + BEAST_EXPECT(mpt.getDecryptedBalance(bob, MPTTester::HOLDER_ENCRYPTED_SPENDING) == 100); + BEAST_EXPECT( + mpt.getDecryptedBalance(carol, MPTTester::HOLDER_ENCRYPTED_SPENDING) == 60); + } + + // Bob sends dave 10, Carol sends dave 5 — both valid and independent + { + auto const bobSeq = env.seq(bob); + auto const carolSeq = env.seq(carol); + auto const batchFee = batch::calcBatchFee(env, 1, 2); + + auto const jv1 = mpt.sendJV({.account = bob, .dest = dave, .amt = 10}, bobSeq + 1); + auto const jv2 = mpt.sendJV({.account = carol, .dest = dave, .amt = 5}, carolSeq); + + env(batch::outer(bob, bobSeq, batchFee, tfUntilFailure), + batch::inner(jv1, bobSeq + 1), + batch::inner(jv2, carolSeq), + batch::sig(carol), + ter(tesSUCCESS)); + env.close(); + + // Both applied: bob 100→90, carol 60→55, dave inbox 0→15 + BEAST_EXPECT(mpt.getDecryptedBalance(bob, MPTTester::HOLDER_ENCRYPTED_SPENDING) == 90); + BEAST_EXPECT( + mpt.getDecryptedBalance(carol, MPTTester::HOLDER_ENCRYPTED_SPENDING) == 55); + BEAST_EXPECT(mpt.getDecryptedBalance(dave, MPTTester::HOLDER_ENCRYPTED_INBOX) == 15); + } + } + + void + testBatchIndependent(FeatureBitset features) + { + testcase("Batch confidential MPT - independent"); + using namespace test::jtx; + + Env env{*this, features}; + Account const alice("alice"); + Account const bob("bob"); + Account const carol("carol"); + Account const dave("dave"); + + MPTTester mpt(env, alice, {.holders = {bob, carol, dave}}); + // bob=100 spending, carol=60 spending, dave=0 + setupBatchEnv(mpt, alice, bob, carol, dave, 100, 60); + + // Bob sends dave 10 (valid), Carol sends dave 300 + // (invalid), Carol sends Dave 5 (valid). Carol's + // balance is still 60 because the preceding send failed). + { + auto const bobSeq = env.seq(bob); + auto const carolSeq = env.seq(carol); + auto const batchFee = batch::calcBatchFee(env, 1, 3); + + auto const jv1 = mpt.sendJV({.account = bob, .dest = dave, .amt = 10}, bobSeq + 1); + + // Carol trying to send dave 300 but own balance only 60 + auto const jv2 = mpt.sendJV({.account = carol, .dest = dave, .amt = 300}, carolSeq); + auto const jv3 = mpt.sendJV({.account = carol, .dest = dave, .amt = 5}, carolSeq + 1); + + env(batch::outer(bob, bobSeq, batchFee, tfIndependent), + batch::inner(jv1, bobSeq + 1), + batch::inner(jv2, carolSeq), + batch::inner(jv3, carolSeq + 1), + batch::sig(carol), + ter(tesSUCCESS)); + env.close(); + + // inner 1 (bob→dave 10) applied: bob 100→90 + BEAST_EXPECT(mpt.getDecryptedBalance(bob, MPTTester::HOLDER_ENCRYPTED_SPENDING) == 90); + // inner 2 failed (carol not changed), inner 3 applied: carol 60→55 + BEAST_EXPECT( + mpt.getDecryptedBalance(carol, MPTTester::HOLDER_ENCRYPTED_SPENDING) == 55); + // dave inbox: 10 (from bob) + 5 (from carol inner 3) = 15 + BEAST_EXPECT(mpt.getDecryptedBalance(dave, MPTTester::HOLDER_ENCRYPTED_INBOX) == 15); + } + } + + // Tests batching ConfidentialMPTConvert and a ConfidentialMPTConvertBack + // in the same batch transaction. Because Convert only modifies the inbox + // (never the spending balance or the version counter), a ConvertBack proof + // built against the pre-batch spending balance is still valid when both + // appear in the same batch. + void + testBatchConfidentialConvertAndConvertBack(FeatureBitset features) + { + testcase("Batch confidential convert and convertBack"); + using namespace test::jtx; + + // convert + convertBack in one AllOrNothing batch, both valid. + // + // Bob has regular=50, spending=100. + // jv1: convert 50 regular → inbox (Schnorr proof; does NOT touch spending/version) + // jv2: convertBack 30 spending → regular (proof against spending=100, version=V) + // + // Since jv1 leaves spending and version unchanged, jv2's proof is still + // valid when it executes, so both inner txns succeed. + { + Env env{*this, features}; + Account const alice("alice"); + Account const bob("bob"); + Account const carol("carol"); + Account const dave("dave"); + + MPTTester mpt(env, alice, {.holders = {bob, carol, dave}}); + // bob: spending=100, regular=0 after setupBatchEnv; + // pay 50 more to give bob regular MPT to convert in the batch. + setupBatchEnv(mpt, alice, bob, carol, dave, 100, 0); + mpt.pay(alice, bob, 50); + + auto const bobSeq = env.seq(bob); + auto const batchFee = batch::calcBatchFee(env, 0, 2); + + // jv1: convert 50 regular MPT into confidential inbox + auto const jv1 = mpt.convertJV({.account = bob, .amt = 50}, bobSeq + 1); + // jv2: convert 30 spending back to regular MPT + auto const jv2 = mpt.convertBackJV({.account = bob, .amt = 30}, bobSeq + 2); + + env(batch::outer(bob, bobSeq, batchFee, tfAllOrNothing), + batch::inner(jv1, bobSeq + 1), + batch::inner(jv2, bobSeq + 2), + ter(tesSUCCESS)); + env.close(); + + // regular (mptAmount): 50 (pre) - 50 (convert) + 30 (convertBack) = 30 + // spending balance: 100 - 30 = 70 + // inbox: 0 + 50 (from convert) = 50 + env.require(mptbalance(mpt, bob, 30)); + BEAST_EXPECT(mpt.getDecryptedBalance(bob, MPTTester::HOLDER_ENCRYPTED_SPENDING) == 70); + BEAST_EXPECT(mpt.getDecryptedBalance(bob, MPTTester::HOLDER_ENCRYPTED_INBOX) == 50); + } + + // convert + mergeInbox + convertBack, stale convertBack proof. + // + // jv1: convert 50 regular → inbox + // jv2: mergeInbox (inbox 50 → spending, version V → V+1) + // jv3: convertBack 30 (proof built against spending=100, version=V) + // + // After jv2 applies, spending=150 and version=V+1, so jv3's + // proof is stale. AllOrNothing rejects the whole batch. + { + Env env{*this, features}; + Account const alice("alice"); + Account const bob("bob"); + Account const carol("carol"); + Account const dave("dave"); + + MPTTester mpt(env, alice, {.holders = {bob, carol, dave}}); + setupBatchEnv(mpt, alice, bob, carol, dave, 100, 0); + mpt.pay(alice, bob, 50); + + auto const bobSeq = env.seq(bob); + auto const batchFee = batch::calcBatchFee(env, 0, 3); + + auto const jv1 = mpt.convertJV({.account = bob, .amt = 50}, bobSeq + 1); + auto const jv2 = mpt.mergeInboxJV({.account = bob}); + // jv3 proof is built against spending=100, version=V (pre-batch) + auto const jv3 = mpt.convertBackJV({.account = bob, .amt = 30}, bobSeq + 3); + + env(batch::outer(bob, bobSeq, batchFee, tfAllOrNothing), + batch::inner(jv1, bobSeq + 1), + batch::inner(jv2, bobSeq + 2), + batch::inner(jv3, bobSeq + 3), + ter(tesSUCCESS)); + env.close(); + + // jv3 fails so nothing is applied. + env.require(mptbalance(mpt, bob, 50)); + BEAST_EXPECT(mpt.getDecryptedBalance(bob, MPTTester::HOLDER_ENCRYPTED_SPENDING) == 100); + BEAST_EXPECT(mpt.getDecryptedBalance(bob, MPTTester::HOLDER_ENCRYPTED_INBOX) == 0); + } + } + + // Tests a batch containing all four confidential MPT operations, Send, + // Convert, ConvertBack, and MergeInbox in a single AllOrNothing batch. + void + testBatchConfidentialMixTransactions(FeatureBitset features) + { + testcase("Batch confidential mixed operations"); + using namespace test::jtx; + + // send(bob→carol) + convert(carol) + convertBack(dave) + // + mergeInbox(carol) in one AllOrNothing batch. + // + // Setup: + // bob: spending=100, regular=0 + // carol: spending=0, regular=50 + // dave: spending=50, regular=0 + // + // After the batch: + // bob spending: 100 -> 70 (sent 30 to carol) + // carol inbox: 0+30(send)+50(convert)=80 -> merged -> spending=80, inbox=0 + // dave spending: 50 -> 30; regular: 0 -> 20 + { + Env env{*this, features}; + Account const alice("alice"); + Account const bob("bob"); + Account const carol("carol"); + Account const dave("dave"); + + MPTTester mpt(env, alice, {.holders = {bob, carol, dave}}); + // bob: spending=100. carol: key registered, spending=0. + // dave: key registered, spending=0 initially. + setupBatchEnv(mpt, alice, bob, carol, dave, 100, 0); + // Give carol 50 regular MPT to convert in the batch. + mpt.pay(alice, carol, 50); + // Give dave 50 regular MPT then convert to confidential spending. + mpt.pay(alice, dave, 50); + mpt.convert({.account = dave, .amt = 50}); + mpt.mergeInbox({.account = dave}); + + auto const bobSeq = env.seq(bob); + auto const carolSeq = env.seq(carol); + auto const daveSeq = env.seq(dave); + // 2 extra signers (carol, dave), 4 inner txns + auto const batchFee = batch::calcBatchFee(env, 2, 4); + + // jv1: bob sends 30 to carol + auto const jv1 = mpt.sendJV({.account = bob, .dest = carol, .amt = 30}, bobSeq + 1); + // jv2: carol converts her 50 regular MPT to confidential + auto const jv2 = mpt.convertJV({.account = carol, .amt = 50}, carolSeq); + // jv3: dave converts 20 spending back to regular MPT + auto const jv3 = mpt.convertBackJV({.account = dave, .amt = 20}, daveSeq); + // jv4: carol merges inbox into spending + // (inbox = 30 from jv1 + 50 from jv2 = 80 at execution time) + auto const jv4 = mpt.mergeInboxJV({.account = carol}); + + env(batch::outer(bob, bobSeq, batchFee, tfAllOrNothing), + batch::inner(jv1, bobSeq + 1), + batch::inner(jv2, carolSeq), + batch::inner(jv3, daveSeq), + batch::inner(jv4, carolSeq + 1), + batch::sig(carol, dave), + ter(tesSUCCESS)); + env.close(); + + // All four applied: + BEAST_EXPECT(mpt.getDecryptedBalance(bob, MPTTester::HOLDER_ENCRYPTED_SPENDING) == 70); + // carol's inbox was merged: spending=80, inbox=0 + BEAST_EXPECT( + mpt.getDecryptedBalance(carol, MPTTester::HOLDER_ENCRYPTED_SPENDING) == 80); + BEAST_EXPECT(mpt.getDecryptedBalance(carol, MPTTester::HOLDER_ENCRYPTED_INBOX) == 0); + // dave: spending=30, regular=20 + BEAST_EXPECT(mpt.getDecryptedBalance(dave, MPTTester::HOLDER_ENCRYPTED_SPENDING) == 30); + env.require(mptbalance(mpt, dave, 20)); + } + + // bob send + bob convertBack in one AllOrNothing batch. + // + // The Send applies first and increments Bob's version counter. + // The ConvertBack proof was built against the pre-Send (spending=100, + // version=V), so batch txn is rejected. + { + Env env{*this, features}; + Account const alice("alice"); + Account const bob("bob"); + Account const carol("carol"); + Account const dave("dave"); + + MPTTester mpt(env, alice, {.holders = {bob, carol, dave}}); + setupBatchEnv(mpt, alice, bob, carol, dave, 100, 0); + + auto const bobSeq = env.seq(bob); + auto const batchFee = batch::calcBatchFee(env, 0, 2); + + // jv1: bob sends 30 to carol (spending 100->70, version V->V+1) + auto const jv1 = mpt.sendJV({.account = bob, .dest = carol, .amt = 30}, bobSeq + 1); + // jv2: bob convertBack 40 , proof built against spending=100, version=V + auto const jv2 = mpt.convertBackJV({.account = bob, .amt = 40}, bobSeq + 2); + + env(batch::outer(bob, bobSeq, batchFee, tfAllOrNothing), + batch::inner(jv1, bobSeq + 1), + batch::inner(jv2, bobSeq + 2), + ter(tesSUCCESS)); + env.close(); + + // AllOrNothing: jv2 fails (stale proof) → nothing applied. + BEAST_EXPECT(mpt.getDecryptedBalance(bob, MPTTester::HOLDER_ENCRYPTED_SPENDING) == 100); + BEAST_EXPECT(mpt.getDecryptedBalance(carol, MPTTester::HOLDER_ENCRYPTED_INBOX) == 0); + } + } + + // Verifies that batch transactions work correctly when tickets are used instead + // of sequence numbers + void + testBatchWithTickets(FeatureBitset features) + { + testcase("Batch confidential MPT with tickets"); + using namespace test::jtx; + + // outer batch uses a ticket. + // The inner send proofs are still bound to regular account sequences. + { + Env env{*this, features}; + Account const alice("alice"); + Account const bob("bob"); + Account const carol("carol"); + Account const dave("dave"); + + MPTTester mpt(env, alice, {.holders = {bob, carol, dave}}); + setupBatchEnv(mpt, alice, bob, carol, dave, 100, 0); + + // Bob creates one ticket to use for the outer batch. + std::uint32_t const outerTicketSeq = env.seq(bob) + 1; + env(ticket::create(bob, 1)); + env.close(); + + auto const bobSeq = env.seq(bob); + // 0 extra signers: all inner txns are from bob; + auto const batchFee = batch::calcBatchFee(env, 0, 2); + + // When the outer uses a ticket (seq=0), inner txns start from bobSeq, bobSeq+1. + // jv2 must use chain state predicted after jv1 since both sends are from bob. + auto const jv1 = mpt.sendJV({.account = bob, .dest = carol, .amt = 40}, bobSeq); + auto const chain1 = mpt.chainAfterSend(bob, 40, jv1); + auto const jv2 = + mpt.sendJV({.account = bob, .dest = dave, .amt = 20}, bobSeq + 1, chain1); + + env(batch::outer(bob, 0, batchFee, tfAllOrNothing), + batch::inner(jv1, bobSeq), + batch::inner(jv2, bobSeq + 1), + ticket::use(outerTicketSeq), + ter(tesSUCCESS)); + env.close(); + + // Both sends applied: bob 100→40, carol inbox=40, dave inbox=20. + BEAST_EXPECT(mpt.getDecryptedBalance(bob, MPTTester::HOLDER_ENCRYPTED_SPENDING) == 40); + BEAST_EXPECT(mpt.getDecryptedBalance(carol, MPTTester::HOLDER_ENCRYPTED_INBOX) == 40); + BEAST_EXPECT(mpt.getDecryptedBalance(dave, MPTTester::HOLDER_ENCRYPTED_INBOX) == 20); + } + + // inner transactions each consume their own ticket. + // The send proof context hash must be bound to the ticket sequence, not the + // account sequence. sendJV receives the ticket seq as its `seq` parameter. + { + Env env{*this, features}; + Account const alice("alice"); + Account const bob("bob"); + Account const carol("carol"); + Account const dave("dave"); + + MPTTester mpt(env, alice, {.holders = {bob, carol, dave}}); + setupBatchEnv(mpt, alice, bob, carol, dave, 100, 0); + + // Bob creates two tickets for the two inner sends. + std::uint32_t const ticketSeq1 = env.seq(bob) + 1; + std::uint32_t const ticketSeq2 = env.seq(bob) + 2; + env(ticket::create(bob, 2)); + env.close(); + + auto const bobSeq = env.seq(bob); + auto const batchFee = batch::calcBatchFee(env, 0, 2); + + // jv1: proof bound to ticketSeq1. + auto const jv1 = mpt.sendJV({.account = bob, .dest = carol, .amt = 40}, ticketSeq1); + // jv2: proof bound to ticketSeq2, spending state predicted after jv1. + auto const chain1 = mpt.chainAfterSend(bob, 40, jv1); + auto const jv2 = + mpt.sendJV({.account = bob, .dest = dave, .amt = 30}, ticketSeq2, chain1); + + env(batch::outer(bob, bobSeq, batchFee, tfAllOrNothing), + batch::inner(jv1, 0, ticketSeq1), + batch::inner(jv2, 0, ticketSeq2), + ter(tesSUCCESS)); + env.close(); + + // Both sends applied: bob 100→30, carol inbox=40, dave inbox=30. + BEAST_EXPECT(mpt.getDecryptedBalance(bob, MPTTester::HOLDER_ENCRYPTED_SPENDING) == 30); + BEAST_EXPECT(mpt.getDecryptedBalance(carol, MPTTester::HOLDER_ENCRYPTED_INBOX) == 40); + BEAST_EXPECT(mpt.getDecryptedBalance(dave, MPTTester::HOLDER_ENCRYPTED_INBOX) == 30); + } + + // inner send uses wrong sequence (account seq instead of ticket seq) + { + Env env{*this, features}; + Account const alice("alice"); + Account const bob("bob"); + Account const carol("carol"); + Account const dave("dave"); + + MPTTester mpt(env, alice, {.holders = {bob, carol, dave}}); + setupBatchEnv(mpt, alice, bob, carol, dave, 100, 0); + + std::uint32_t const ticketSeq = env.seq(bob) + 1; + env(ticket::create(bob, 1)); + env.close(); + + auto const bobSeq = env.seq(bob); + auto const batchFee = batch::calcBatchFee(env, 0, 2); + + // Proof intentionally built with account seq (bobSeq+1) instead of ticketSeq. + auto const badJV = mpt.sendJV({.account = bob, .dest = carol, .amt = 40}, bobSeq + 1); + auto const jv2 = mpt.mergeInboxJV({.account = bob}); + + env(batch::outer(bob, bobSeq, batchFee, tfAllOrNothing), + batch::inner(badJV, 0, ticketSeq), + batch::inner(jv2, bobSeq + 1), + ter(tesSUCCESS)); + env.close(); + + BEAST_EXPECT(mpt.getDecryptedBalance(bob, MPTTester::HOLDER_ENCRYPTED_SPENDING) == 100); + BEAST_EXPECT(mpt.getDecryptedBalance(carol, MPTTester::HOLDER_ENCRYPTED_INBOX) == 0); + } + } + + // Basic tests of confidential transfer through delegation. Verifies that a delegated account + // with the appropriate permissions can execute confidential transfer transactions + // on behalf of the delegator. + void + testConfidentialDelegation(FeatureBitset features) + { + testcase("Confidential transfers through delegation"); + using namespace test::jtx; + + Env env{*this, features}; + Account const alice{"alice"}; + Account const bob{"bob"}; + Account const carol{"carol"}; + Account const dave{"dave"}; + + MPTTester mptAlice(env, alice, {.holders = {bob, carol}}); + env.fund(XRP(10000), dave); + env.close(); + + mptAlice.create({ + .ownerCount = 1, + .flags = + tfMPTCanTransfer | tfMPTCanLock | tfMPTCanClawback | tfMPTCanConfidentialAmount, + }); + mptAlice.authorize({.account = bob}); + mptAlice.authorize({.account = carol}); + mptAlice.pay(alice, bob, 200); + mptAlice.pay(alice, carol, 100); + + mptAlice.generateKeyPair(alice); + mptAlice.generateKeyPair(bob); + mptAlice.generateKeyPair(carol); + mptAlice.set({.issuerPubKey = mptAlice.getPubKey(alice)}); + + // Bob delegates Convert, MergeInbox to dave. + env(delegate::set(bob, dave, {"ConfidentialMPTConvert", "ConfidentialMPTMergeInbox"})); + env.close(); + + // Carol has no permission from bob to convert on his behalf. + mptAlice.convert({ + .account = bob, + .amt = 10, + .holderPubKey = mptAlice.getPubKey(bob), + .delegate = carol, + .err = terNO_DELEGATE_PERMISSION, + }); + + // Dave executes Convert on behalf of bob, registering bob's key. + mptAlice.convert({ + .account = bob, + .amt = 100, + .holderPubKey = mptAlice.getPubKey(bob), + .delegate = dave, + }); + env.require(mptbalance(mptAlice, bob, 100)); + + // Dave executes Convert again on behalf of bob (no key registration). + mptAlice.convert({.account = bob, .amt = 50, .delegate = dave}); + + // Dave executes MergeInbox on behalf of bob. + mptAlice.mergeInbox({.account = bob, .delegate = dave}); + + // Carol converts and merge inbox. + mptAlice.convert({ + .account = carol, + .amt = 100, + .holderPubKey = mptAlice.getPubKey(carol), + }); + mptAlice.mergeInbox({.account = carol}); + + // Dave does not have permission to send on behalf of bob. + mptAlice.send( + {.account = bob, + .dest = carol, + .amt = 10, + .delegate = dave, + .err = terNO_DELEGATE_PERMISSION}); + + // Bob delegates ConfidentialMPTSend to dave. + env(delegate::set( + bob, + dave, + {"ConfidentialMPTConvert", "ConfidentialMPTMergeInbox", "ConfidentialMPTSend"})); + env.close(); + + // Dave executes Send on behalf of bob. + mptAlice.send({.account = bob, .dest = carol, .amt = 10, .delegate = dave}); + mptAlice.mergeInbox({.account = carol}); + + // Dave does not have permission to convert back on behalf of bob. + mptAlice.convertBack( + {.account = bob, .amt = 10, .delegate = dave, .err = terNO_DELEGATE_PERMISSION}); + + // Bob delegates ConfidentialMPTConvertBack to dave. + env(delegate::set( + bob, + dave, + {"ConfidentialMPTConvert", + "ConfidentialMPTMergeInbox", + "ConfidentialMPTSend", + "ConfidentialMPTConvertBack"})); + env.close(); + + // Dave executes ConvertBack on behalf of bob. + mptAlice.convertBack({.account = bob, .amt = 10, .delegate = dave}); + + // Dave does not have permission to clawback on behalf of alice. + mptAlice.confidentialClaw( + {.holder = bob, .amt = 130, .delegate = dave, .err = terNO_DELEGATE_PERMISSION}); + + // Alice delegates ConfidentialMPTClawback to dave. + env(delegate::set(alice, dave, {"ConfidentialMPTClawback"})); + env.close(); + + // Dave executes Clawback on behalf of alice. + mptAlice.confidentialClaw({.holder = bob, .amt = 130, .delegate = dave}); + } + + // Verifies that revoking delegation prevents further delegated operations. + void + testDelegationRevocation(FeatureBitset features) + { + testcase("Confidential delegation revocation"); + 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}}); + env.fund(XRP(10000), carol); + env.close(); + + mptAlice.create({ + .ownerCount = 1, + .flags = tfMPTCanTransfer | tfMPTCanConfidentialAmount, + }); + mptAlice.authorize({.account = bob}); + mptAlice.pay(alice, bob, 100); + + mptAlice.generateKeyPair(alice); + mptAlice.generateKeyPair(bob); + mptAlice.set({.issuerPubKey = mptAlice.getPubKey(alice)}); + + // Creating the Delegate SLE consumes one owner reserve slot for bob. + auto const bobOwnersBefore = ownerCount(env, bob); + env(delegate::set(bob, carol, {"ConfidentialMPTConvert", "ConfidentialMPTMergeInbox"})); + env.close(); + env.require(owners(bob, bobOwnersBefore + 1)); + + // Carol converts and merge inbox on behalf of bob. + mptAlice.convert({ + .account = bob, + .amt = 50, + .holderPubKey = mptAlice.getPubKey(bob), + .delegate = carol, + }); + mptAlice.mergeInbox({.account = bob, .delegate = carol}); + + // Bob revokes all permissions, deletes the Delegate SLE, releasing the reserve. + env(delegate::set(bob, carol, std::vector{})); + env.close(); + env.require(owners(bob, bobOwnersBefore)); + + // Carol can no longer convert on behalf of bob. + mptAlice.convert({ + .account = bob, + .amt = 30, + .delegate = carol, + .err = terNO_DELEGATE_PERMISSION, + }); + + // Bob can still convert by himself. + mptAlice.convert({.account = bob, .amt = 30}); + } + + // Verifies that a delegated confidential transfer works correctly when an + // auditor is configured on the issuance. + void + testDelegationWithAuditor(FeatureBitset features) + { + testcase("Confidential delegation with auditor"); + using namespace test::jtx; + + Env env{*this, features}; + Account const alice{"alice"}; + Account const bob{"bob"}; + Account const carol{"carol"}; + Account const dave{"dave"}; + Account const auditor{"auditor"}; + + MPTTester mptAlice(env, alice, {.holders = {bob, carol}, .auditor = auditor}); + env.fund(XRP(10000), dave); + env.close(); + + mptAlice.create({ + .ownerCount = 1, + .flags = tfMPTCanTransfer | tfMPTCanConfidentialAmount, + }); + mptAlice.authorize({.account = bob}); + mptAlice.authorize({.account = carol}); + mptAlice.pay(alice, bob, 100); + mptAlice.pay(alice, carol, 100); + + mptAlice.generateKeyPair(alice); + mptAlice.generateKeyPair(bob); + mptAlice.generateKeyPair(carol); + mptAlice.generateKeyPair(auditor); + mptAlice.set( + {.issuerPubKey = mptAlice.getPubKey(alice), + .auditorPubKey = mptAlice.getPubKey(auditor)}); + + // Bob delegates Convert and Send permissions to dave. + env(delegate::set(bob, dave, {"ConfidentialMPTSend", "ConfidentialMPTConvert"})); + env.close(); + + // Dave converts on behalf of bob. + mptAlice.convert( + {.account = bob, .amt = 50, .holderPubKey = mptAlice.getPubKey(bob), .delegate = dave}); + mptAlice.mergeInbox({.account = bob}); + + mptAlice.convert({ + .account = carol, + .amt = 50, + .holderPubKey = mptAlice.getPubKey(carol), + }); + mptAlice.mergeInbox({.account = carol}); + + // Dave sends on behalf of bob. + mptAlice.send({.account = bob, .dest = carol, .amt = 20, .delegate = dave}); + mptAlice.send({.account = bob, .dest = carol, .amt = 10, .delegate = dave}); + + // Bob delegates ConvertBack and Send permissions to auditor. + env(delegate::set(bob, auditor, {"ConfidentialMPTSend", "ConfidentialMPTConvertBack"})); + env.close(); + + // auditor can send and convert back on behalf of bob as well. + mptAlice.send({.account = bob, .dest = carol, .amt = 10, .delegate = auditor}); + mptAlice.convertBack({.account = bob, .amt = 10, .delegate = auditor}); + } + + // Verifies that a non-issuer delegating clawback to a third party does not + // allow that party to execute clawback, since clawback is issuer-only. + void + testDelegationClawbackIssuerOnly(FeatureBitset features) + { + testcase("Confidential clawback delegation requires issuer"); + using namespace test::jtx; + + Env env{*this, features}; + Account const alice{"alice"}; + Account const bob{"bob"}; + Account const carol{"carol"}; + Account const dave{"dave"}; + + MPTTester mptAlice(env, alice, {.holders = {bob, carol}}); + env.fund(XRP(10000), dave); + env.close(); + + mptAlice.create({ + .ownerCount = 1, + .flags = tfMPTCanTransfer | tfMPTCanClawback | tfMPTCanConfidentialAmount, + }); + mptAlice.authorize({.account = bob}); + mptAlice.authorize({.account = carol}); + mptAlice.pay(alice, bob, 100); + mptAlice.pay(alice, carol, 100); + + mptAlice.generateKeyPair(alice); + mptAlice.generateKeyPair(bob); + mptAlice.generateKeyPair(carol); + mptAlice.set({.issuerPubKey = mptAlice.getPubKey(alice)}); + + mptAlice.convert({ + .account = bob, + .amt = 50, + .holderPubKey = mptAlice.getPubKey(bob), + }); + mptAlice.mergeInbox({.account = bob}); + + mptAlice.convert({ + .account = carol, + .amt = 100, + .holderPubKey = mptAlice.getPubKey(carol), + }); + mptAlice.mergeInbox({.account = carol}); + + // Bob delegates Clawback permission to dave. + env(delegate::set(bob, dave, {"ConfidentialMPTClawback"})); + env.close(); + + // Dave attempts clawback on behalf of bob targetting bob, but since bob is not the issuer, + // the transaction should be rejected. + { + Json::Value jv; + jv[jss::Account] = bob.human(); + jv[jss::TransactionType] = jss::ConfidentialMPTClawback; + jv[sfMPTokenIssuanceID] = to_string(mptAlice.issuanceID()); + jv[sfHolder] = bob.human(); + jv[sfMPTAmount.jsonName] = "50"; + jv[sfZKProof.jsonName] = std::string(ecEqualityProofLength * 2, '0'); + env(jv, delegate::as(dave), ter(temMALFORMED)); + } + + // Dave attempts clawback on behalf of bob targeting carol, but since bob is not the issuer, + // the transaction should be rejected. + { + Json::Value jv; + jv[jss::Account] = bob.human(); + jv[jss::TransactionType] = jss::ConfidentialMPTClawback; + jv[sfMPTokenIssuanceID] = to_string(mptAlice.issuanceID()); + jv[sfHolder] = carol.human(); + jv[sfMPTAmount.jsonName] = "100"; + jv[sfZKProof.jsonName] = std::string(ecEqualityProofLength * 2, '0'); + env(jv, delegate::as(dave), ter(temMALFORMED)); + } + } + + // Test invalid scenarios for delegation with tickets. + void + testInvalidDelegationWithTickets(FeatureBitset features) + { + testcase("Invalid cases for delegation with tickets"); + 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}}); + env.fund(XRP(10000), carol); + env.close(); + + mptAlice.create({ + .ownerCount = 1, + .flags = tfMPTCanTransfer | tfMPTCanConfidentialAmount | tfMPTCanClawback, + }); + mptAlice.authorize({.account = bob}); + mptAlice.pay(alice, bob, 200); + + mptAlice.generateKeyPair(alice); + mptAlice.generateKeyPair(bob); + mptAlice.set({.issuerPubKey = mptAlice.getPubKey(alice)}); + + // Bob grants carol permissions. + env(delegate::set(bob, carol, {"ConfidentialMPTConvert"})); + env.close(); + + uint64_t const amt = 10; + auto const bf = generateBlindingFactor(); + auto const holderCt = mptAlice.encryptAmount(bob, amt, bf); + auto const issuerCt = mptAlice.encryptAmount(alice, amt, bf); + + // Invalid: proof built with wrong ticket sequence (ticketSeq + 1). + { + auto const ticketSeq = env.seq(bob) + 1; + env(ticket::create(bob, 1)); + + auto const badCtxHash = + getConvertContextHash(bob, mptAlice.issuanceID(), ticketSeq + 1); + auto const badProof = mptAlice.getSchnorrProof(bob, badCtxHash); + BEAST_EXPECT(badProof.has_value()); + + mptAlice.convert( + {.account = bob, + .amt = amt, + .proof = strHex(*badProof), + .holderPubKey = mptAlice.getPubKey(bob), + .holderEncryptedAmt = holderCt, + .issuerEncryptedAmt = issuerCt, + .blindingFactor = bf, + .delegate = carol, + .ticketSeq = ticketSeq, + .err = tecBAD_PROOF}); + } + + // Invalid: proof built with account sequence instead of ticket sequence. + { + auto const ticketSeq = env.seq(bob) + 1; + env(ticket::create(bob, 1)); + auto const badCtxHash = getConvertContextHash(bob, mptAlice.issuanceID(), env.seq(bob)); + auto const badProof = mptAlice.getSchnorrProof(bob, badCtxHash); + BEAST_EXPECT(badProof.has_value()); + + mptAlice.convert( + {.account = bob, + .amt = amt, + .proof = strHex(*badProof), + .holderPubKey = mptAlice.getPubKey(bob), + .holderEncryptedAmt = holderCt, + .issuerEncryptedAmt = issuerCt, + .blindingFactor = bf, + .delegate = carol, + .ticketSeq = ticketSeq, + .err = tecBAD_PROOF}); + } + + // Invalid: ticket sequence is far in the future and hasn't been created yet. + { + mptAlice.convert({ + .account = bob, + .amt = amt, + .holderPubKey = mptAlice.getPubKey(bob), + .holderEncryptedAmt = holderCt, + .issuerEncryptedAmt = issuerCt, + .blindingFactor = bf, + .delegate = carol, + .ticketSeq = env.seq(bob) + 100, + .err = terPRE_TICKET, + }); + } + + // Invalid: ticket sequence is in the past but was never created. + { + mptAlice.convert({ + .account = bob, + .amt = amt, + .holderPubKey = mptAlice.getPubKey(bob), + .holderEncryptedAmt = holderCt, + .issuerEncryptedAmt = issuerCt, + .blindingFactor = bf, + .delegate = carol, + .ticketSeq = 1, + .err = tefNO_TICKET, + }); + } + + // Invalid: the delegated account, carol, creates a ticket and uses it. + { + auto const carolTicketSeq = env.seq(carol) + 1; + env(ticket::create(carol, 1)); + + mptAlice.convert( + {.account = bob, + .amt = amt, + .holderPubKey = mptAlice.getPubKey(bob), + .holderEncryptedAmt = holderCt, + .issuerEncryptedAmt = issuerCt, + .blindingFactor = bf, + .delegate = carol, + .ticketSeq = carolTicketSeq, + .err = tefNO_TICKET}); + } + + // Invalid: proof bound to a ticket sequence but submitted without a ticket, + // using account sequence. + { + auto const ticketSeq = env.seq(bob) + 1; + env(ticket::create(bob, 1)); + + // Build proof using ticketSeq. + auto const ctxHashForTicket = + getConvertContextHash(bob, mptAlice.issuanceID(), ticketSeq); + auto const proof = mptAlice.getSchnorrProof(bob, ctxHashForTicket); + BEAST_EXPECT(proof.has_value()); + + // Submit without ticket. + mptAlice.convert( + {.account = bob, + .amt = amt, + .proof = strHex(*proof), + .holderPubKey = mptAlice.getPubKey(bob), + .holderEncryptedAmt = holderCt, + .issuerEncryptedAmt = issuerCt, + .blindingFactor = bf, + .delegate = carol, + .err = tecBAD_PROOF}); + } + } + + // Verifies that delegation works correctly when the delegating account uses + // tickets instead of regular sequence numbers. The proof must bind to the + // ticket sequence, not the account sequence. + void + testDelegationWithTickets(FeatureBitset features) + { + testcase("Confidential delegation with tickets"); + using namespace test::jtx; + + Env env{*this, features}; + Account const alice("alice"); + Account const bob("bob"); + Account const carol("carol"); + Account const dave("dave"); + MPTTester mptAlice(env, alice, {.holders = {bob, carol}}); + env.fund(XRP(10000), dave); + env.close(); + + mptAlice.create({ + .ownerCount = 1, + .flags = tfMPTCanTransfer | tfMPTCanConfidentialAmount | tfMPTCanClawback, + }); + mptAlice.authorize({.account = bob}); + mptAlice.authorize({.account = carol}); + mptAlice.pay(alice, bob, 200); + mptAlice.pay(alice, carol, 100); + + mptAlice.generateKeyPair(alice); + mptAlice.generateKeyPair(bob); + mptAlice.generateKeyPair(carol); + mptAlice.set({.issuerPubKey = mptAlice.getPubKey(alice)}); + + // Bob grants dave permissions. + env(delegate::set( + bob, + dave, + {"ConfidentialMPTConvert", + "ConfidentialMPTMergeInbox", + "ConfidentialMPTSend", + "ConfidentialMPTConvertBack"})); + // Alice grants dave permission to clawback on her behalf. + env(delegate::set(alice, dave, {"ConfidentialMPTClawback"})); + env.close(); + + // Dave executes Convert on behalf of bob using ticket. + auto ticketSeq = env.seq(bob) + 1; + env(ticket::create(bob, 1)); + BEAST_EXPECT(env.seq(bob) != ticketSeq); + mptAlice.convert({ + .account = bob, + .amt = 100, + .holderPubKey = mptAlice.getPubKey(bob), + .delegate = dave, + .ticketSeq = ticketSeq, + }); + env.require(mptbalance(mptAlice, bob, 100)); + + // MergeInbox using ticket with delegation. + ticketSeq = env.seq(bob) + 1; + env(ticket::create(bob, 1)); + BEAST_EXPECT(env.seq(bob) != ticketSeq); + mptAlice.mergeInbox({.account = bob, .delegate = dave, .ticketSeq = ticketSeq}); + + // Carol converts and merges inbox to receive from bob. + mptAlice.convert({ + .account = carol, + .amt = 50, + .holderPubKey = mptAlice.getPubKey(carol), + }); + mptAlice.mergeInbox({.account = carol}); + + // Send using ticket with delegation. + ticketSeq = env.seq(bob) + 1; + env(ticket::create(bob, 1)); + BEAST_EXPECT(env.seq(bob) != ticketSeq); + mptAlice.send({ + .account = bob, + .dest = carol, + .amt = 20, + .delegate = dave, + .ticketSeq = ticketSeq, + }); + + // ConvertBack using ticket with delegation. + ticketSeq = env.seq(bob) + 1; + env(ticket::create(bob, 1)); + BEAST_EXPECT(env.seq(bob) != ticketSeq); + mptAlice.convertBack({ + .account = bob, + .amt = 10, + .delegate = dave, + .ticketSeq = ticketSeq, + }); + + // Clawback using ticket with delegation. + ticketSeq = env.seq(alice) + 1; + env(ticket::create(alice, 1)); + BEAST_EXPECT(env.seq(alice) != ticketSeq); + mptAlice.confidentialClaw({ + .holder = bob, + .amt = 70, + .delegate = dave, + .ticketSeq = ticketSeq, + }); + } + + void + testWithFeats(FeatureBitset features) + { + // ConfidentialMPTConvert + testConvert(features); + testConvertPreflight(features); + testConvertPreclaim(features); + testConvertWithAuditor(features); + + // ConfidentialMPTMergeInbox + testMergeInbox(features); + testMergeInboxPreflight(features); + testMergeInboxPreclaim(features); + + testSet(features); + testSetPreflight(features); + testSetPreclaim(features); + + // ConfidentialMPTSend + testSend(features); + testSendPreflight(features); + testSendPreclaim(features); + testSendRangeProof(features); + // testSendZeroAmount(features); + testSendDepositPreauth(features); + testSendCredentialValidation(features); + testSendWithAuditor(features); + + // ConfidentialMPTClawback + testClawback(features); + testClawbackPreflight(features); + testClawbackPreclaim(features); + testClawbackProof(features); + testClawbackWithAuditor(features); + + testDelete(features); + + // ConfidentialMPTConvertBack + testConvertBack(features); + testConvertBackPreflight(features); + testConvertBackPreclaim(features); + testConvertBackWithAuditor(features); + testConvertBackPedersenProof(features); + testConvertBackBulletproof(features); + + // Homomorphic operation tests + testSendHomomorphicOverflow(features); + testHomomorphicCiphertextModification(features); + testConvertBackHomomorphicUnderflow(features); + + // invalid curve points + testSendInvalidCurvePoints(features); + testSendWrongGroupPointInjection(features); + testIdentityElementRejection(features); + testSendWrongIssuerPublicKey(features); + + // Replay Tests + testMutatePrivacy(features); + testProofContextBinding(features); + testProofCiphertextBinding(features); + testProofVersionMismatch(features); + + // Ticket Tests + testWithTickets(features); + testConvertTicketProofBinding(features); + testTicketErrors(features); + + // Batch Tests + testBatchConfidentialSend(features); + testBatchConfidentialConvertAndConvertBack(features); + testBatchConfidentialMixTransactions(features); + testBatchAllOrNothing(features); + testBatchOnlyOne(features); + testBatchUntilFailure(features); + testBatchIndependent(features); + testBatchWithTickets(features); + + // Delegation Tests + testConfidentialDelegation(features); + testDelegationRevocation(features); + testDelegationWithAuditor(features); + testDelegationClawbackIssuerOnly(features); + + // Delegation with Tickets Tests + testInvalidDelegationWithTickets(features); + testDelegationWithTickets(features); + } + +public: + void + run() override + { + using namespace test::jtx; + FeatureBitset const all{testable_amendments()}; + + testWithFeats(all); + } +}; + +BEAST_DEFINE_TESTSUITE(ConfidentialTransfer, app, xrpl); +} // namespace xrpl diff --git a/src/test/app/Delegate_test.cpp b/src/test/app/Delegate_test.cpp index 1d036266ad..0710ddf2d1 100644 --- a/src/test/app/Delegate_test.cpp +++ b/src/test/app/Delegate_test.cpp @@ -1846,7 +1846,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 = 75; + std::size_t const expectedDelegableCount = 80; BEAST_EXPECTS( delegableCount == expectedDelegableCount, diff --git a/src/test/app/Invariants_test.cpp b/src/test/app/Invariants_test.cpp index 7473ce6de8..e9d3948202 100644 --- a/src/test/app/Invariants_test.cpp +++ b/src/test/app/Invariants_test.cpp @@ -4003,6 +4003,195 @@ class Invariants_test : public beast::unit_test::suite } } + void + testConfidentialMPTTransfer() + { + using namespace test::jtx; + testcase << "ValidConfidentialMPToken"; + + MPTID mptID; + + // Generate an MPT with privacy, issue 100 tokens to A2. + // Perform a confidential conversion to populate encrypted state. + auto const precloseConfidential = + [&mptID](Account const& A1, Account const& A2, Env& env) -> bool { + MPTTester mpt(env, A1, {.holders = {A2}, .fund = false}); + mpt.create({.flags = tfMPTCanTransfer | tfMPTCanConfidentialAmount}); + mptID = mpt.issuanceID(); + + mpt.authorize({.account = A2}); + mpt.pay(A1, A2, 100); + + mpt.generateKeyPair(A1); + mpt.set({.account = A1, .issuerPubKey = mpt.getPubKey(A1)}); + + mpt.generateKeyPair(A2); + mpt.convert({ + .account = A2, + .amt = 100, + .holderPubKey = mpt.getPubKey(A2), + }); + return true; + }; + + // badDelete + doInvariantCheck( + {"MPToken deleted with encrypted fields while COA > 0"}, + [&mptID](Account const& A1, Account const& A2, ApplyContext& ac) { + auto sleToken = ac.view().peek(keylet::mptoken(mptID, A2.id())); + if (!sleToken) + return false; + // Force an erase of the object while the COA remains 100 + ac.view().erase(sleToken); + return true; + }, + XRPAmount{}, + STTx{ttMPTOKEN_AUTHORIZE, [](STObject&) {}}, + {tecINVARIANT_FAILED, tefINVARIANT_FAILED}, + precloseConfidential); + + // badConsistency + doInvariantCheck( + {"MPToken encrypted field existence inconsistency"}, + [&mptID](Account const& A1, Account const& A2, ApplyContext& ac) { + auto sleToken = ac.view().peek(keylet::mptoken(mptID, A2.id())); + if (!sleToken) + return false; + // Remove one of the required encrypted fields to create a mismatch + sleToken->makeFieldAbsent(sfIssuerEncryptedBalance); + ac.view().update(sleToken); + return true; + }, + XRPAmount{}, + STTx{ttMPTOKEN_AUTHORIZE, [](STObject&) {}}, + {tecINVARIANT_FAILED, tecINVARIANT_FAILED}, + precloseConfidential); + + // requiresPrivacyFlag + auto const precloseNoPrivacy = [&mptID]( + Account const& A1, Account const& A2, Env& env) -> bool { + MPTTester mpt(env, A1, {.holders = {A2}, .fund = false}); + // completely omitted the tfMPTCanConfidentialAmount flag here. + mpt.create({.flags = tfMPTCanTransfer}); + mptID = mpt.issuanceID(); + mpt.authorize({.account = A2}); + mpt.pay(A1, A2, 100); + return true; + }; + + doInvariantCheck( + {"MPToken has encrypted fields but Issuance does not have lsfMPTCanConfidentialAmount " + "set"}, + [&mptID](Account const& A1, Account const& A2, ApplyContext& ac) { + auto sleToken = ac.view().peek(keylet::mptoken(mptID, A2.id())); + if (!sleToken) + return false; + // Inject fields correctly, but the Issuance was built without the privacy flag. + sleToken->setFieldVL(sfConfidentialBalanceInbox, Blob{0x00}); + sleToken->setFieldVL(sfIssuerEncryptedBalance, Blob{0x00}); + ac.view().update(sleToken); + return true; + }, + XRPAmount{}, + STTx{ttMPTOKEN_AUTHORIZE, [](STObject&) {}}, + {tecINVARIANT_FAILED, tecINVARIANT_FAILED}, + precloseNoPrivacy); + + // badCOA + doInvariantCheck( + {"Confidential outstanding amount exceeds total outstanding amount"}, + [&mptID](Account const& A1, Account const& A2, ApplyContext& ac) { + auto sleIssuance = ac.view().peek(keylet::mptIssuance(mptID)); + if (!sleIssuance) + return false; + // Total outstanding is natively 100; bloat the COA over 100 + sleIssuance->setFieldU64(sfConfidentialOutstandingAmount, 200); + ac.view().update(sleIssuance); + return true; + }, + XRPAmount{}, + STTx{ttMPTOKEN_ISSUANCE_SET, [](STObject&) {}}, + {tecINVARIANT_FAILED, tecINVARIANT_FAILED}, + precloseConfidential); + + // Conservation Violation + doInvariantCheck( + {"Token conservation violation for MPT"}, + [&mptID](Account const& A1, Account const& A2, ApplyContext& ac) { + auto sleIssuance = ac.view().peek(keylet::mptIssuance(mptID)); + if (!sleIssuance) + return false; + + sleIssuance->setFieldU64( + sfConfidentialOutstandingAmount, + sleIssuance->getFieldU64(sfConfidentialOutstandingAmount) - 10); + ac.view().update(sleIssuance); + + return true; + }, + XRPAmount{}, + STTx{ttMPTOKEN_AUTHORIZE, [](STObject&) {}}, + {tecINVARIANT_FAILED, tecINVARIANT_FAILED}, + precloseConfidential); + + // badVersion + doInvariantCheck( + {"MPToken sfConfidentialBalanceVersion not updated when sfConfidentialBalanceSpending " + "changed"}, + [&mptID](Account const& A1, Account const& A2, ApplyContext& ac) { + auto sleToken = ac.view().peek(keylet::mptoken(mptID, A2.id())); + if (!sleToken) + return false; + sleToken->setFieldVL(sfConfidentialBalanceSpending, Blob{0xBA, 0xDD}); + + // DO NOT update sfConfidentialBalanceVersion + ac.view().update(sleToken); + return true; + }, + XRPAmount{}, + STTx{ttMPTOKEN_AUTHORIZE, [](STObject&) {}}, + {tecINVARIANT_FAILED, tecINVARIANT_FAILED}, + precloseConfidential); + + // Skipping Deleted MPTs (Issuance deleted) + auto const precloseOrphan = [&mptID]( + Account const& A1, Account const& A2, Env& env) -> bool { + MPTTester mpt(env, A1, {.holders = {A2}, .fund = false}); + mpt.create({.flags = tfMPTCanTransfer | tfMPTCanConfidentialAmount}); + mptID = mpt.issuanceID(); + mpt.authorize({.account = A2}); + + // Generate privacy keys and convert 0 amount so Bob has the encrypted fields + mpt.generateKeyPair(A1); + mpt.set({.account = A1, .issuerPubKey = mpt.getPubKey(A1)}); + mpt.generateKeyPair(A2); + mpt.convert({ + .account = A2, + .amt = 0, + .holderPubKey = mpt.getPubKey(A2), + }); + + // Immediately destroy the issuance. A2's empty, encrypted token object lives on. + mpt.destroy(); + return true; + }; + + doInvariantCheck( + {}, + [&mptID](Account const& A1, Account const& A2, ApplyContext& ac) { + auto sleToken = ac.view().peek(keylet::mptoken(mptID, A2.id())); + if (!sleToken) + return false; + // Safely able to erase the deleted token. + ac.view().erase(sleToken); + return true; + }, + XRPAmount{}, + STTx{ttMPTOKEN_AUTHORIZE, [](STObject&) {}}, + {tesSUCCESS, tesSUCCESS}, + precloseOrphan); + } + public: void run() override @@ -4028,6 +4217,7 @@ public: testValidPseudoAccounts(); testValidLoanBroker(); testVault(); + testConfidentialMPTTransfer(); testMPT(); } }; diff --git a/src/test/app/MPToken_test.cpp b/src/test/app/MPToken_test.cpp index 69dd397210..4ec6162ea1 100644 --- a/src/test/app/MPToken_test.cpp +++ b/src/test/app/MPToken_test.cpp @@ -545,7 +545,8 @@ class MPToken_test : public beast::unit_test::suite // (2) mptAlice.set({.account = alice, .flags = 0x00000008, .err = temINVALID_FLAG}); - if (!features[featureSingleAssetVault] && !features[featureDynamicMPT]) + if (!features[featureSingleAssetVault] && !features[featureDynamicMPT] && + !features[featureConfidentialTransfer]) { // test invalid flags - nothing is being changed mptAlice.set({.account = alice, .flags = 0x00000000, .err = tecNO_PERMISSION}); @@ -2615,6 +2616,7 @@ class MPToken_test : public beast::unit_test::suite tmfMPTSetCanTrade | tmfMPTClearCanTrade, tmfMPTSetCanTransfer | tmfMPTClearCanTransfer, tmfMPTSetCanClawback | tmfMPTClearCanClawback, + tmfMPTSetCanConfidentialAmount | tmfMPTClearCanConfidentialAmount, tmfMPTSetCanLock | tmfMPTClearCanLock | tmfMPTClearCanTrade, tmfMPTSetCanTransfer | tmfMPTClearCanTransfer | tmfMPTSetCanEscrow | tmfMPTClearCanClawback}; diff --git a/src/test/jtx/impl/mpt.cpp b/src/test/jtx/impl/mpt.cpp index c26f051797..32e1d3c275 100644 --- a/src/test/jtx/impl/mpt.cpp +++ b/src/test/jtx/impl/mpt.cpp @@ -1,13 +1,39 @@ #include +#include #include +#include +#include #include #include +#include + +#include +#include + namespace xrpl { namespace test { namespace jtx { +/** + * @brief Helper function to convert a PedersenProofParams into the C library struct. + * + * @param params The Pedersen commitment proof parameters. + * @return The equivalent mpt_pedersen_proof_params for use with the C library. + */ +static mpt_pedersen_proof_params +makePedersenParams(PedersenProofParams const& params) +{ + mpt_pedersen_proof_params res{}; + std::memcpy( + res.pedersen_commitment, params.pedersenCommitment.data(), kMPT_PEDERSEN_COMMIT_SIZE); + res.amount = params.amt; + std::memcpy(res.ciphertext, params.encryptedAmt.data(), kMPT_ELGAMAL_TOTAL_SIZE); + std::memcpy(res.blinding_factor, params.blindingFactor.data(), kMPT_BLINDING_FACTOR_SIZE); + return res; +} + void mptflags::operator()(Env& env) const { @@ -40,13 +66,20 @@ MPTTester::makeHolders(std::vector const& holders) } MPTTester::MPTTester(Env& env, Account const& issuer, MPTInit const& arg) - : env_(env), issuer_(issuer), holders_(makeHolders(arg.holders)), close_(arg.close) + : env_(env) + , issuer_(issuer) + , holders_(makeHolders(arg.holders)) + , auditor_(arg.auditor) + , close_(arg.close) { if (arg.fund) { env_.fund(arg.xrp, issuer_); for (auto const& it : holders_) env_.fund(arg.xrpHolders, it.second); + + if (arg.auditor) + env_.fund(arg.xrp, *arg.auditor); } if (close_) env.close(); @@ -59,6 +92,9 @@ MPTTester::MPTTester(Env& env, Account const& issuer, MPTInit const& arg) Throw("Issuer can't be holder"); env_.require(owners(it.second, 0)); } + + if (arg.auditor) + env_.require(owners(*arg.auditor, 0)); } if (arg.create) create(*arg.create); @@ -100,7 +136,12 @@ MPTTester::MPTTester(MPTInitDef const& arg) : MPTTester{ arg.env, arg.issuer, - MPTInit{.fund = arg.fund, .close = arg.close, .create = makeMPTCreate(arg)}} + MPTInit{ + .auditor = arg.auditor, + .fund = arg.fund, + .close = arg.close, + .create = makeMPTCreate(arg), + }} { } @@ -353,6 +394,10 @@ MPTTester::setJV(MPTSet const& arg) jv[sfTransferFee] = *arg.transferFee; if (arg.metadata) jv[sfMPTokenMetadata] = strHex(*arg.metadata); + if (arg.issuerPubKey) + jv[sfIssuerEncryptionKey] = strHex(*arg.issuerPubKey); + if (arg.auditorPubKey) + jv[sfAuditorEncryptionKey] = strHex(*arg.auditorPubKey); jv[sfTransactionType] = jss::MPTokenIssuanceSet; return jv; @@ -371,88 +416,141 @@ MPTTester::set(MPTSet const& arg) .transferFee = arg.transferFee, .metadata = arg.metadata, .delegate = arg.delegate, - .domainID = arg.domainID}); + .domainID = arg.domainID, + .issuerPubKey = arg.issuerPubKey, + .auditorPubKey = arg.auditorPubKey}); if (submit(arg, jv) == tesSUCCESS && ((arg.flags.value_or(0) != 0u) || arg.mutableFlags)) { - auto require = [&](std::optional const& holder, bool unchanged) { - auto flags = getFlags(holder); - if (!unchanged) + if ((arg.flags.value_or(0) || arg.mutableFlags)) + { + auto require = [&](std::optional const& holder, bool unchanged) { + auto flags = getFlags(holder); + if (!unchanged) + { + if (arg.flags) + { + if (*arg.flags & tfMPTLock) + { + flags |= lsfMPTLocked; + } + else if (*arg.flags & tfMPTUnlock) + { + flags &= ~lsfMPTLocked; + } + } + + if (arg.mutableFlags) + { + if (*arg.mutableFlags & tmfMPTSetCanLock) + { + flags |= lsfMPTCanLock; + } + else if (*arg.mutableFlags & tmfMPTClearCanLock) + { + flags &= ~lsfMPTCanLock; + } + + if (*arg.mutableFlags & tmfMPTSetRequireAuth) + { + flags |= lsfMPTRequireAuth; + } + else if (*arg.mutableFlags & tmfMPTClearRequireAuth) + { + flags &= ~lsfMPTRequireAuth; + } + + if (*arg.mutableFlags & tmfMPTSetCanEscrow) + { + flags |= lsfMPTCanEscrow; + } + else if (*arg.mutableFlags & tmfMPTClearCanEscrow) + { + flags &= ~lsfMPTCanEscrow; + } + + if (*arg.mutableFlags & tmfMPTSetCanClawback) + { + flags |= lsfMPTCanClawback; + } + else if (*arg.mutableFlags & tmfMPTClearCanClawback) + { + flags &= ~lsfMPTCanClawback; + } + + if (*arg.mutableFlags & tmfMPTSetCanTrade) + { + flags |= lsfMPTCanTrade; + } + else if (*arg.mutableFlags & tmfMPTClearCanTrade) + { + flags &= ~lsfMPTCanTrade; + } + + if (*arg.mutableFlags & tmfMPTSetCanTransfer) + { + flags |= lsfMPTCanTransfer; + } + else if (*arg.mutableFlags & tmfMPTClearCanTransfer) + { + flags &= ~lsfMPTCanTransfer; + } + + if (*arg.mutableFlags & tmfMPTSetCanConfidentialAmount) + { + flags |= lsfMPTCanConfidentialAmount; + } + else if (*arg.mutableFlags & tmfMPTClearCanConfidentialAmount) + { + flags &= ~lsfMPTCanConfidentialAmount; + } + } + } + env_.require(mptflags(*this, flags, holder)); + }; + if (arg.account) + require(std::nullopt, arg.holder.has_value()); + if (auto const account = (arg.holder ? std::get_if(&(*arg.holder)) : nullptr)) + require(*account, false); + + if (arg.issuerPubKey) { - if (arg.flags) - { - if (*arg.flags & tfMPTLock) - { - flags |= lsfMPTLocked; - } - else if (*arg.flags & tfMPTUnlock) - { - flags &= ~lsfMPTLocked; - } - } + env_.require(requireAny([&]() -> bool { + return forObject([&](SLEP const& sle) -> bool { + if (sle) + { + auto const issuerPubKey = getPubKey(issuer_); + if (!issuerPubKey) + Throw( + "MPTTester::set: issuer's pubkey is not set"); - if (arg.mutableFlags) - { - if (*arg.mutableFlags & tmfMPTSetCanLock) - { - flags |= lsfMPTCanLock; - } - else if (*arg.mutableFlags & tmfMPTClearCanLock) - { - flags &= ~lsfMPTCanLock; - } - - if (*arg.mutableFlags & tmfMPTSetRequireAuth) - { - flags |= lsfMPTRequireAuth; - } - else if (*arg.mutableFlags & tmfMPTClearRequireAuth) - { - flags &= ~lsfMPTRequireAuth; - } - - if (*arg.mutableFlags & tmfMPTSetCanEscrow) - { - flags |= lsfMPTCanEscrow; - } - else if (*arg.mutableFlags & tmfMPTClearCanEscrow) - { - flags &= ~lsfMPTCanEscrow; - } - - if (*arg.mutableFlags & tmfMPTSetCanClawback) - { - flags |= lsfMPTCanClawback; - } - else if (*arg.mutableFlags & tmfMPTClearCanClawback) - { - flags &= ~lsfMPTCanClawback; - } - - if (*arg.mutableFlags & tmfMPTSetCanTrade) - { - flags |= lsfMPTCanTrade; - } - else if (*arg.mutableFlags & tmfMPTClearCanTrade) - { - flags &= ~lsfMPTCanTrade; - } - - if (*arg.mutableFlags & tmfMPTSetCanTransfer) - { - flags |= lsfMPTCanTransfer; - } - else if (*arg.mutableFlags & tmfMPTClearCanTransfer) - { - flags &= ~lsfMPTCanTransfer; - } - } + return strHex((*sle)[sfIssuerEncryptionKey]) == strHex(*issuerPubKey); + } + return false; + }); + })); } - env_.require(mptflags(*this, flags, holder)); - }; - if (arg.account) - require(std::nullopt, arg.holder.has_value()); - if (auto const account = (arg.holder ? std::get_if(&(*arg.holder)) : nullptr)) - require(*account, false); + + if (arg.auditorPubKey) + { + env_.require(requireAny([&]() -> bool { + return forObject([&](SLEP const& sle) -> bool { + if (sle) + { + if (!auditor_.has_value()) + Throw("MPTTester::set: auditor is not set"); + + auto const auditorPubKey = getPubKey(*auditor_); + if (!auditorPubKey) + Throw( + "MPTTester::set: auditor's pubkey is not set"); + + return strHex((*sle)[sfAuditorEncryptionKey]) == strHex(*auditorPubKey); + } + return false; + }); + })); + } + } } } @@ -479,6 +577,17 @@ MPTTester::checkDomainID(std::optional expected) const }); } +[[nodiscard]] bool +MPTTester::printMPT(Account const& holder_) const +{ + return forObject( + [&](SLEP const& sle) -> bool { + std::cout << "\n" << sle->getJson(); + return true; + }, + holder_); +} + [[nodiscard]] bool MPTTester::checkMPTokenAmount(Account const& holder_, std::int64_t expectedAmount) const { @@ -493,6 +602,14 @@ MPTTester::checkMPTokenOutstandingAmount(std::int64_t expectedAmount) const [&](SLEP const& sle) { return expectedAmount == (*sle)[sfOutstandingAmount]; }); } +[[nodiscard]] bool +MPTTester::checkIssuanceConfidentialBalance(std::int64_t expectedAmount) const +{ + return forObject([&](SLEP const& sle) { + return expectedAmount == (*sle)[~sfConfidentialOutstandingAmount].value_or(0); + }); +} + [[nodiscard]] bool MPTTester::checkFlags(uint32_t const expectedFlags, std::optional const& holder) const { @@ -639,6 +756,220 @@ MPTTester::getBalance(Account const& account) const return 0; } +std::int64_t +MPTTester::getIssuanceConfidentialBalance() const +{ + if (!id_) + Throw("MPT has not been created"); + + if (auto const sle = env_.le(keylet::mptIssuance(*id_))) + return (*sle)[~sfConfidentialOutstandingAmount].value_or(0); + + return 0; +} + +std::optional +MPTTester::getClawbackProof( + Account const& holder, + std::uint64_t amount, + Buffer const& privateKey, + uint256 const& contextHash) const +{ + if (!id_) + Throw("MPT has not been created"); + + auto const sleHolder = env_.le(keylet::mptoken(*id_, holder.id())); + auto const sleIssuance = env_.le(keylet::mptIssuance(*id_)); + + if (!sleHolder || !sleIssuance) + return std::nullopt; + + auto const ciphertextBlob = sleHolder->getFieldVL(sfIssuerEncryptedBalance); + if (ciphertextBlob.size() != ecGamalEncryptedTotalLength) + return std::nullopt; + + auto const pubKeyBlob = sleIssuance->getFieldVL(sfIssuerEncryptionKey); + if (pubKeyBlob.size() != ecPubKeyLength) + return std::nullopt; + + Buffer proof(ecEqualityProofLength); + + if (mpt_get_clawback_proof( + privateKey.data(), + pubKeyBlob.data(), + contextHash.data(), + amount, + ciphertextBlob.data(), + proof.data()) != 0) + { + return std::nullopt; + } + + return proof; +} + +std::optional +MPTTester::getSchnorrProof(Account const& account, uint256 const& ctxHash) const +{ + auto const pubKey = getPubKey(account); + if (!pubKey || pubKey->size() != ecPubKeyLength) + return std::nullopt; + + auto const privKey = getPrivKey(account); + if (privKey->size() != ecPrivKeyLength) + return std::nullopt; + + Buffer proof(ecSchnorrProofLength); + + if (mpt_get_convert_proof(pubKey->data(), privKey->data(), ctxHash.data(), proof.data()) != 0) + return std::nullopt; + + return proof; +} + +std::optional +MPTTester::getConfidentialSendProof( + Account const& sender, + std::uint64_t const amount, + std::vector const& recipients, + Slice const& blindingFactor, + std::size_t const nRecipients, + uint256 const& contextHash, + PedersenProofParams const& amountParams, + PedersenProofParams const& balanceParams) const +{ + auto const pedersenAmountParams = makePedersenParams(amountParams); + auto const pedersenBalanceParams = makePedersenParams(balanceParams); + if (recipients.size() != nRecipients) + return std::nullopt; + + if (blindingFactor.size() != ecBlindingFactorLength) + return std::nullopt; + + auto const senderPrivKey = getPrivKey(sender); + if (!senderPrivKey) + return std::nullopt; + + // Build mpt_confidential_participant array + std::vector participants(nRecipients); + for (size_t i = 0; i < nRecipients; ++i) + { + auto const& r = recipients[i]; + if (r.encryptedAmount.size() != ecGamalEncryptedTotalLength || + r.publicKey.size() != ecPubKeyLength) + return std::nullopt; + std::memcpy(participants[i].pubkey, r.publicKey.data(), kMPT_PUBKEY_SIZE); + std::memcpy(participants[i].ciphertext, r.encryptedAmount.data(), kMPT_ELGAMAL_TOTAL_SIZE); + } + + size_t proofLen = get_confidential_send_proof_size(nRecipients); + Buffer proof(proofLen); + + if (mpt_get_confidential_send_proof( + senderPrivKey->data(), + amount, + participants.data(), + nRecipients, + blindingFactor.data(), + contextHash.data(), + &pedersenAmountParams, + &pedersenBalanceParams, + proof.data(), + &proofLen) != 0) + return std::nullopt; + + return proof; +} + +Buffer +MPTTester::getPedersenCommitment(std::uint64_t const amount, Buffer const& pedersenBlindingFactor) +{ + // Blinding factor (rho) must be a 32-byte scalar + if (pedersenBlindingFactor.size() != ecBlindingFactorLength) + Throw("Invalid blinding factor size"); + + // secp256k1_mpt_pedersen_commit doesn't handle amount 0, return a trivial + // valid commitment for test purposes + if (amount == 0) + { + Buffer buf(ecPedersenCommitmentLength); + std::memset(buf.data(), 0, ecPedersenCommitmentLength); + buf.data()[0] = ecCompressedPrefixEvenY; + buf.data()[ecPedersenCommitmentLength - 1] = 0x01; + return buf; + } + + Buffer buf(ecPedersenCommitmentLength); + + if (mpt_get_pedersen_commitment(amount, pedersenBlindingFactor.data(), buf.data()) != 0) + Throw("Pedersen commitment generation failed"); + + return buf; +} + +Buffer +MPTTester::getConvertBackProof( + Account const& holder, + std::uint64_t const amount, + uint256 const& contextHash, + PedersenProofParams const& pcParams) const +{ + // Expected total proof length: pedersen proof + single bulletproof + std::size_t constexpr expectedProofLength = ecPedersenProofLength + ecSingleBulletproofLength; + + auto const sleMptoken = env_.le(keylet::mptoken(*id_, holder.id())); + if (!sleMptoken || !sleMptoken->isFieldPresent(sfConfidentialBalanceSpending)) + return makeZeroBuffer(expectedProofLength); + + auto const holderPubKey = getPubKey(holder); + auto const holderPrivKey = getPrivKey(holder); + + if (!holderPubKey || !holderPrivKey) + return makeZeroBuffer(expectedProofLength); + + auto const pedersenParams = makePedersenParams(pcParams); + Buffer proof(expectedProofLength); + + if (mpt_get_convert_back_proof( + holderPrivKey->data(), + holderPubKey->data(), + contextHash.data(), + amount, + &pedersenParams, + proof.data()) != 0) + return makeZeroBuffer(expectedProofLength); + + return proof; +} + +std::optional +MPTTester::getEncryptedBalance(Account const& account, EncryptedBalanceType option) const +{ + if (!id_) + Throw("MPT has not been created"); + + if (auto const sle = env_.le(keylet::mptoken(*id_, account.id()))) + { + if (option == HOLDER_ENCRYPTED_INBOX && sle->isFieldPresent(sfConfidentialBalanceInbox)) + return Buffer( + (*sle)[sfConfidentialBalanceInbox].data(), + (*sle)[sfConfidentialBalanceInbox].size()); + if (option == HOLDER_ENCRYPTED_SPENDING && + sle->isFieldPresent(sfConfidentialBalanceSpending)) + return Buffer( + (*sle)[sfConfidentialBalanceSpending].data(), + (*sle)[sfConfidentialBalanceSpending].size()); + if (option == ISSUER_ENCRYPTED_BALANCE && sle->isFieldPresent(sfIssuerEncryptedBalance)) + return Buffer( + (*sle)[sfIssuerEncryptedBalance].data(), (*sle)[sfIssuerEncryptedBalance].size()); + if (option == AUDITOR_ENCRYPTED_BALANCE && sle->isFieldPresent(sfAuditorEncryptedBalance)) + return Buffer( + (*sle)[sfAuditorEncryptedBalance].data(), (*sle)[sfAuditorEncryptedBalance].size()); + } + + return {}; +} + std::uint32_t MPTTester::getFlags(std::optional const& holder) const { @@ -665,6 +996,1331 @@ MPTTester::operator()(std::int64_t amount) const return MPT("", issuanceID())(amount); } +template +void +MPTTester::fillConversionCiphertexts( + T const& arg, + Json::Value& jv, + Buffer& holderCiphertext, + Buffer& issuerCiphertext, + std::optional& auditorCiphertext, + Buffer& blindingFactor) const +{ + blindingFactor = arg.blindingFactor ? *arg.blindingFactor : generateBlindingFactor(); + + // Handle Holder + if (arg.holderEncryptedAmt) + holderCiphertext = *arg.holderEncryptedAmt; + else + holderCiphertext = encryptAmount(*arg.account, *arg.amt, blindingFactor); + + jv[sfHolderEncryptedAmount.jsonName] = strHex(holderCiphertext); + + // Handle Issuer + if (arg.issuerEncryptedAmt) + issuerCiphertext = *arg.issuerEncryptedAmt; + else + issuerCiphertext = encryptAmount(issuer_, *arg.amt, blindingFactor); + + jv[sfIssuerEncryptedAmount.jsonName] = strHex(issuerCiphertext); + + // Handle Auditor + if (arg.auditorEncryptedAmt) + auditorCiphertext = *arg.auditorEncryptedAmt; + else if (auditor_.has_value() && *arg.fillAuditorEncryptedAmt) + auditorCiphertext = encryptAmount(*auditor_, *arg.amt, blindingFactor); + + // Update auditor JSON only if ciphertext exists + if (auditorCiphertext) + jv[sfAuditorEncryptedAmount.jsonName] = strHex(*auditorCiphertext); +} + +void +MPTTester::convert(MPTConvert const& arg) +{ + Json::Value jv; + if (arg.account) + jv[sfAccount] = arg.account->human(); + else + Throw("Account not specified"); + + jv[jss::TransactionType] = jss::ConfidentialMPTConvert; + if (arg.id) + jv[sfMPTokenIssuanceID] = to_string(*arg.id); + else + { + if (!id_) + Throw("MPT has not been created"); + jv[sfMPTokenIssuanceID] = to_string(*id_); + } + + if (arg.amt) + jv[sfMPTAmount.jsonName] = std::to_string(*arg.amt); + if (arg.holderPubKey) + jv[sfHolderEncryptionKey.jsonName] = strHex(*arg.holderPubKey); + + Buffer holderCiphertext; + Buffer issuerCiphertext; + std::optional auditorCiphertext; + Buffer blindingFactor; + + fillConversionCiphertexts( + arg, jv, holderCiphertext, issuerCiphertext, auditorCiphertext, blindingFactor); + + jv[sfBlindingFactor.jsonName] = strHex(blindingFactor); + if (arg.proof) + jv[sfZKProof.jsonName] = *arg.proof; + else if (arg.fillSchnorrProof.value_or(arg.holderPubKey.has_value())) + { + // whether to automatically generate and attach a Schnorr proof: + // if fillSchnorrProof is explicitly set, follow its value; + // otherwise, default to generating the proof only if holder pub key is + // present. + auto const seq = arg.ticketSeq.value_or(env_.seq(*arg.account)); + auto const contextHash = getConvertContextHash(arg.account->id(), *id_, seq); + + auto const proof = getSchnorrProof(*arg.account, contextHash); + if (proof) + jv[sfZKProof.jsonName] = strHex(*proof); + else + jv[sfZKProof.jsonName] = strHex(makeZeroBuffer(ecSchnorrProofLength)); + } + + auto const holderAmt = getBalance(*arg.account); + auto const prevConfidentialOutstanding = getIssuanceConfidentialBalance(); + + auto const prevInboxBalance = getDecryptedBalance(*arg.account, HOLDER_ENCRYPTED_INBOX); + auto const prevSpendingBalance = getDecryptedBalance(*arg.account, HOLDER_ENCRYPTED_SPENDING); + auto const prevIssuerBalance = getDecryptedBalance(*arg.account, ISSUER_ENCRYPTED_BALANCE); + + if (!prevInboxBalance || !prevSpendingBalance || !prevIssuerBalance) + Throw("Failed to get Pre-convert balance"); + + std::optional prevAuditorBalance; + if (arg.auditorEncryptedAmt || auditor_) + { + prevAuditorBalance = getDecryptedBalance(*arg.account, AUDITOR_ENCRYPTED_BALANCE); + if (!prevAuditorBalance) + Throw("Failed to get Pre-convert balance"); + } + + if (submit(arg, jv) == tesSUCCESS) + { + auto const postConfidentialOutstanding = getIssuanceConfidentialBalance(); + env_.require(mptbalance(*this, *arg.account, holderAmt - *arg.amt)); + env_.require(requireAny([&]() -> bool { + return prevConfidentialOutstanding + *arg.amt == postConfidentialOutstanding; + })); + + auto const postInboxBalance = getDecryptedBalance(*arg.account, HOLDER_ENCRYPTED_INBOX); + auto const postIssuerBalance = getDecryptedBalance(*arg.account, ISSUER_ENCRYPTED_BALANCE); + auto const postSpendingBalance = + getDecryptedBalance(*arg.account, HOLDER_ENCRYPTED_SPENDING); + + if (!postInboxBalance || !postIssuerBalance || !postSpendingBalance) + Throw("Failed to get post-convert balance"); + + if (arg.auditorEncryptedAmt || auditor_) + { + auto const postAuditorBalance = + getDecryptedBalance(*arg.account, AUDITOR_ENCRYPTED_BALANCE); + + if (!postAuditorBalance) + Throw("Failed to get post-convert auditor balance"); + + // auditor's encrypted balance is updated correctly + env_.require(requireAny( + [&]() -> bool { return *prevAuditorBalance + *arg.amt == *postAuditorBalance; })); + } + // spending balance should not change + env_.require( + requireAny([&]() -> bool { return *postSpendingBalance == *prevSpendingBalance; })); + + // issuer's encrypted balance is updated correctly + env_.require(requireAny( + [&]() -> bool { return *prevIssuerBalance + *arg.amt == *postIssuerBalance; })); + + // holder's inbox balance is updated correctly + env_.require(requireAny( + [&]() -> bool { return *prevInboxBalance + *arg.amt == *postInboxBalance; })); + + // sum of holder's inbox and spending balance should equal to issuer's + // encrypted balance + env_.require(requireAny([&]() -> bool { + return *postInboxBalance + *postSpendingBalance == *postIssuerBalance; + })); + + if (arg.holderPubKey) + { + env_.require(requireAny([&]() -> bool { + return forObject( + [&](SLEP const& sle) -> bool { + if (sle) + { + auto const holderPubKey = getPubKey(*arg.account); + if (!holderPubKey) + Throw( + "MPTTester::convert: holder's pubkey is " + "not set"); + + return strHex((*sle)[sfHolderEncryptionKey]) == strHex(*holderPubKey); + } + return false; + }, + arg.account); + })); + } + } +} + +Json::Value +MPTTester::convertJV(MPTConvert const& arg, std::uint32_t seq) +{ + Json::Value jv; + if (arg.account) + jv[sfAccount] = arg.account->human(); + else + Throw("Account not specified"); + + jv[jss::TransactionType] = jss::ConfidentialMPTConvert; + if (arg.id) + jv[sfMPTokenIssuanceID] = to_string(*arg.id); + else + { + if (!id_) + Throw("MPT has not been created"); + jv[sfMPTokenIssuanceID] = to_string(*id_); + } + + if (arg.amt) + jv[sfMPTAmount.jsonName] = std::to_string(*arg.amt); + if (arg.holderPubKey) + jv[sfHolderEncryptionKey.jsonName] = strHex(*arg.holderPubKey); + + Buffer holderCiphertext; + Buffer issuerCiphertext; + std::optional auditorCiphertext; + Buffer blindingFactor; + + fillConversionCiphertexts( + arg, jv, holderCiphertext, issuerCiphertext, auditorCiphertext, blindingFactor); + + jv[sfBlindingFactor.jsonName] = strHex(blindingFactor); + + if (arg.proof) + jv[sfZKProof.jsonName] = *arg.proof; + else if (arg.fillSchnorrProof.value_or(arg.holderPubKey.has_value())) + { + auto const contextHash = getConvertContextHash(arg.account->id(), *id_, seq); + auto const proof = getSchnorrProof(*arg.account, contextHash); + if (proof) + jv[sfZKProof.jsonName] = strHex(*proof); + else + jv[sfZKProof.jsonName] = strHex(makeZeroBuffer(ecSchnorrProofLength)); + } + + return jv; +} + +void +MPTTester::send(MPTConfidentialSend const& arg) +{ + Json::Value jv; + jv[jss::TransactionType] = jss::ConfidentialMPTSend; + + if (arg.account) + jv[sfAccount] = arg.account->human(); + else + Throw("Account not specified"); + + if (arg.dest) + jv[sfDestination] = arg.dest->human(); + else + Throw("Destination not specified"); + + if (!arg.amt) + Throw("Amount not specified for testing purposes"); + + if (arg.id) + jv[sfMPTokenIssuanceID] = to_string(*arg.id); + else + { + if (!id_) + Throw("MPT has not been created"); + jv[sfMPTokenIssuanceID] = to_string(*id_); + } + + Buffer const blindingFactor = + arg.blindingFactor ? *arg.blindingFactor : generateBlindingFactor(); + + // fill in the encrypted amounts if not provided + auto const senderAmt = arg.senderEncryptedAmt + ? *arg.senderEncryptedAmt + : encryptAmount(*arg.account, *arg.amt, blindingFactor); + auto const destAmt = arg.destEncryptedAmt ? *arg.destEncryptedAmt + : encryptAmount(*arg.dest, *arg.amt, blindingFactor); + auto const issuerAmt = arg.issuerEncryptedAmt + ? *arg.issuerEncryptedAmt + : encryptAmount(issuer_, *arg.amt, blindingFactor); + + std::optional auditorAmt; + if (arg.auditorEncryptedAmt) + auditorAmt = arg.auditorEncryptedAmt; + else if (auditor_.has_value() && *arg.fillAuditorEncryptedAmt) + auditorAmt = encryptAmount(*auditor_, *arg.amt, blindingFactor); + + jv[sfSenderEncryptedAmount] = strHex(senderAmt); + jv[sfDestinationEncryptedAmount] = strHex(destAmt); + jv[sfIssuerEncryptedAmount] = strHex(issuerAmt); + if (auditorAmt) + jv[sfAuditorEncryptedAmount] = strHex(*auditorAmt); + + if (arg.credentials) + { + auto& arr(jv[sfCredentialIDs.jsonName] = Json::arrayValue); + for (auto const& hash : *arg.credentials) + arr.append(hash); + } + + // Version counters before send + auto const prevSenderVersion = getMPTokenVersion(*arg.account); + auto const prevDestVersion = getMPTokenVersion(*arg.dest); + + // Sender's previous confidential state + auto const prevSenderInbox = getDecryptedBalance(*arg.account, HOLDER_ENCRYPTED_INBOX); + auto const prevSenderSpending = getDecryptedBalance(*arg.account, HOLDER_ENCRYPTED_SPENDING); + auto const prevSenderIssuer = getDecryptedBalance(*arg.account, ISSUER_ENCRYPTED_BALANCE); + if (!prevSenderInbox || !prevSenderSpending || !prevSenderIssuer) + Throw("Failed to get Pre-send balance"); + + std::optional prevSenderAuditor; + if (arg.auditorEncryptedAmt || auditor_) + { + prevSenderAuditor = getDecryptedBalance(*arg.account, AUDITOR_ENCRYPTED_BALANCE); + if (!prevSenderAuditor) + Throw("Failed to get Pre-send balance"); + } + + // Destination's previous confidential state + auto const prevDestInbox = getDecryptedBalance(*arg.dest, HOLDER_ENCRYPTED_INBOX); + auto const prevDestSpending = getDecryptedBalance(*arg.dest, HOLDER_ENCRYPTED_SPENDING); + auto const prevDestIssuer = getDecryptedBalance(*arg.dest, ISSUER_ENCRYPTED_BALANCE); + if (!prevDestInbox || !prevDestSpending || !prevDestIssuer) + Throw("Failed to get Pre-send balance"); + + std::optional prevDestAuditor; + if (arg.auditorEncryptedAmt || auditor_) + { + prevDestAuditor = getDecryptedBalance(*arg.dest, AUDITOR_ENCRYPTED_BALANCE); + if (!prevDestAuditor) + Throw("Failed to get Pre-send balance"); + } + + // Fill in the commitment if not provided + Buffer amountCommitment, balanceCommitment; + auto const amountBlindingFactor = generateBlindingFactor(); + if (arg.amountCommitment) + amountCommitment = *arg.amountCommitment; + else + amountCommitment = getPedersenCommitment(*arg.amt, amountBlindingFactor); + + jv[sfAmountCommitment] = strHex(amountCommitment); + + auto const balanceBlindingFactor = generateBlindingFactor(); + if (arg.balanceCommitment) + balanceCommitment = *arg.balanceCommitment; + else + balanceCommitment = getPedersenCommitment(*prevSenderSpending, balanceBlindingFactor); + + jv[sfBalanceCommitment] = strHex(balanceCommitment); + + // Fill in the proof if not provided + if (arg.proof) + jv[sfZKProof] = *arg.proof; + else + { + auto const version = getMPTokenVersion(*arg.account); + auto const seq = arg.ticketSeq.value_or(env_.seq(*arg.account)); + auto const ctxHash = + getSendContextHash(arg.account->id(), *id_, seq, arg.dest->id(), version); + + auto const nRecipients = getConfidentialRecipientCount(auditorAmt.has_value()); + std::vector recipients; + + auto const senderPubKey = getPubKey(*arg.account); + auto const destPubKey = getPubKey(*arg.dest); + auto const issuerPubKey = getPubKey(issuer_); + + // If a key is missing, we skip adding the recipient. This intentionally + // causes proof generation to fail (due to recipient count mismatch), + // triggering the dummy proof fallback. + if (senderPubKey) + recipients.push_back({Slice(*senderPubKey), senderAmt}); + if (destPubKey) + recipients.push_back({Slice(*destPubKey), destAmt}); + if (issuerPubKey) + recipients.push_back({Slice(*issuerPubKey), issuerAmt}); + + std::optional auditorPubKey; + if (auditorAmt) + { + if (!auditor_) + Throw("Auditor not registered"); + + auditorPubKey = getPubKey(*auditor_); + if (auditorPubKey) + recipients.push_back({Slice(*auditorPubKey), *auditorAmt}); + } + + auto const prevEncryptedSenderSpending = + getEncryptedBalance(*arg.account, HOLDER_ENCRYPTED_SPENDING); + + std::optional proof; + + // Skip proof generation if encrypted balance is missing (e.g., + // feature disabled), when the sender and destination are the same + // (malformed case causing pcm to be zero), or when spending balance + // is 0 + if (arg.account != arg.dest && prevEncryptedSenderSpending && *prevSenderSpending > 0) + { + proof = getConfidentialSendProof( + *arg.account, + *arg.amt, + recipients, + blindingFactor, + nRecipients, + ctxHash, + {amountCommitment, *arg.amt, senderAmt, amountBlindingFactor}, + {balanceCommitment, + *prevSenderSpending, + *prevEncryptedSenderSpending, + balanceBlindingFactor}); + } + + if (proof) + jv[sfZKProof.jsonName] = strHex(*proof); + else + { + size_t const sizeEquality = secp256k1_mpt_proof_equality_shared_r_size(nRecipients); + size_t const dummySize = + sizeEquality + 2 * ecPedersenProofLength + ecDoubleBulletproofLength; + + jv[sfZKProof.jsonName] = strHex(makeZeroBuffer(dummySize)); + } + } + + auto const senderPubAmt = getBalance(*arg.account); + auto const destPubAmt = getBalance(*arg.dest); + auto const prevCOA = getIssuanceConfidentialBalance(); + auto const prevOA = getIssuanceOutstandingBalance(); + + if (submit(arg, jv) == tesSUCCESS) + { + auto const postCOA = getIssuanceConfidentialBalance(); + auto const postOA = getIssuanceOutstandingBalance(); + + // Sender's post confidential state + auto const postSenderInbox = getDecryptedBalance(*arg.account, HOLDER_ENCRYPTED_INBOX); + auto const postSenderSpending = + getDecryptedBalance(*arg.account, HOLDER_ENCRYPTED_SPENDING); + auto const postSenderIssuer = getDecryptedBalance(*arg.account, ISSUER_ENCRYPTED_BALANCE); + + if (!postSenderInbox || !postSenderSpending || !postSenderIssuer) + Throw("Failed to get Post-send balance"); + + // Destination's post confidential state + auto const postDestInbox = getDecryptedBalance(*arg.dest, HOLDER_ENCRYPTED_INBOX); + auto const postDestSpending = getDecryptedBalance(*arg.dest, HOLDER_ENCRYPTED_SPENDING); + auto const postDestIssuer = getDecryptedBalance(*arg.dest, ISSUER_ENCRYPTED_BALANCE); + + if (!postDestInbox || !postDestSpending || !postDestIssuer) + Throw("Failed to get Post-send balance"); + + // Public balances unchanged + env_.require(mptbalance(*this, *arg.account, senderPubAmt)); + env_.require(mptbalance(*this, *arg.dest, destPubAmt)); + + // OA and COA unchanged + env_.require(requireAny([&]() -> bool { return prevOA == postOA; })); + env_.require(requireAny([&]() -> bool { return prevCOA == postCOA; })); + + // Verify sender changes + env_.require(requireAny([&]() -> bool { + return *prevSenderSpending >= *arg.amt && + *postSenderSpending == *prevSenderSpending - *arg.amt; + })); + env_.require(requireAny([&]() -> bool { return postSenderInbox == prevSenderInbox; })); + env_.require(requireAny([&]() -> bool { + return *prevSenderIssuer >= *arg.amt && + *postSenderIssuer == *prevSenderIssuer - *arg.amt; + })); + + // Verify destination changes + env_.require( + requireAny([&]() -> bool { return *postDestInbox == *prevDestInbox + *arg.amt; })); + env_.require(requireAny([&]() -> bool { return *postDestSpending == *prevDestSpending; })); + env_.require( + requireAny([&]() -> bool { return *postDestIssuer == *prevDestIssuer + *arg.amt; })); + + // Cross checks + env_.require(requireAny( + [&]() -> bool { return *postSenderInbox + *postSenderSpending == *postSenderIssuer; })); + env_.require(requireAny( + [&]() -> bool { return *postDestInbox + *postDestSpending == *postDestIssuer; })); + + // Version: sender increments by 1; receiver version is unchanged by incoming sends + env_.require(requireAny( + [&]() -> bool { return getMPTokenVersion(*arg.account) == prevSenderVersion + 1; })); + env_.require( + requireAny([&]() -> bool { return getMPTokenVersion(*arg.dest) == prevDestVersion; })); + + if (arg.auditorEncryptedAmt || auditor_) + { + auto const postSenderAuditor = + getDecryptedBalance(*arg.account, AUDITOR_ENCRYPTED_BALANCE); + auto const postDestAuditor = getDecryptedBalance(*arg.dest, AUDITOR_ENCRYPTED_BALANCE); + if (!postSenderAuditor || !postDestAuditor) + Throw("Failed to get Post-send balance"); + + env_.require(requireAny([&]() -> bool { + return *postSenderAuditor == *postSenderIssuer && + *postDestAuditor == *postDestIssuer; + })); + + // verify sender + env_.require(requireAny([&]() -> bool { + return prevSenderAuditor >= *arg.amt && + *postSenderAuditor == *prevSenderAuditor - *arg.amt; + })); + + // verify dest + env_.require(requireAny( + [&]() -> bool { return *postDestAuditor == *prevDestAuditor + *arg.amt; })); + } + } +} + +Json::Value +MPTTester::sendJV( + MPTConfidentialSend const& arg, + std::uint32_t seq, + std::optional chain) +{ + Json::Value jv; + jv[jss::TransactionType] = jss::ConfidentialMPTSend; + + if (arg.account) + jv[sfAccount] = arg.account->human(); + else + Throw("Account not specified"); + + if (arg.dest) + jv[sfDestination] = arg.dest->human(); + else + Throw("Destination not specified"); + + if (!arg.amt) + Throw("Amount not specified for testing purposes"); + + if (arg.id) + jv[sfMPTokenIssuanceID] = to_string(*arg.id); + else + { + if (!id_) + Throw("MPT has not been created"); + jv[sfMPTokenIssuanceID] = to_string(*id_); + } + + Buffer const blindingFactor = + arg.blindingFactor ? *arg.blindingFactor : generateBlindingFactor(); + + auto const senderAmt = arg.senderEncryptedAmt + ? *arg.senderEncryptedAmt + : encryptAmount(*arg.account, *arg.amt, blindingFactor); + auto const destAmt = arg.destEncryptedAmt ? *arg.destEncryptedAmt + : encryptAmount(*arg.dest, *arg.amt, blindingFactor); + auto const issuerAmt = arg.issuerEncryptedAmt + ? *arg.issuerEncryptedAmt + : encryptAmount(issuer_, *arg.amt, blindingFactor); + + std::optional auditorAmt; + if (arg.auditorEncryptedAmt) + auditorAmt = arg.auditorEncryptedAmt; + else if (auditor_.has_value() && *arg.fillAuditorEncryptedAmt) + auditorAmt = encryptAmount(*auditor_, *arg.amt, blindingFactor); + + jv[sfSenderEncryptedAmount] = strHex(senderAmt); + jv[sfDestinationEncryptedAmount] = strHex(destAmt); + jv[sfIssuerEncryptedAmount] = strHex(issuerAmt); + if (auditorAmt) + jv[sfAuditorEncryptedAmount] = strHex(*auditorAmt); + + if (arg.credentials) + { + auto& arr(jv[sfCredentialIDs.jsonName] = Json::arrayValue); + for (auto const& hash : *arg.credentials) + arr.append(hash); + } + + std::uint64_t prevSenderSpending = 0; + std::optional prevEncryptedSenderSpending; + std::uint32_t version = 0; + if (chain) + { + prevSenderSpending = chain->spending; + prevEncryptedSenderSpending = chain->encSpending; + version = chain->version; + } + else + { + auto const ledgerSpending = getDecryptedBalance(*arg.account, HOLDER_ENCRYPTED_SPENDING); + if (!ledgerSpending) + Throw("Failed to get sender spending balance"); + prevSenderSpending = *ledgerSpending; + prevEncryptedSenderSpending = getEncryptedBalance(*arg.account, HOLDER_ENCRYPTED_SPENDING); + version = getMPTokenVersion(*arg.account); + } + + Buffer amountCommitment, balanceCommitment; + auto const amountBlindingFactor = generateBlindingFactor(); + if (arg.amountCommitment) + amountCommitment = *arg.amountCommitment; + else + amountCommitment = getPedersenCommitment(*arg.amt, amountBlindingFactor); + + jv[sfAmountCommitment] = strHex(amountCommitment); + + auto const balanceBlindingFactor = generateBlindingFactor(); + if (arg.balanceCommitment) + balanceCommitment = *arg.balanceCommitment; + else + balanceCommitment = getPedersenCommitment(prevSenderSpending, balanceBlindingFactor); + + jv[sfBalanceCommitment] = strHex(balanceCommitment); + + if (arg.proof) + jv[sfZKProof.jsonName] = *arg.proof; + else + { + auto const ctxHash = + getSendContextHash(arg.account->id(), *id_, seq, arg.dest->id(), version); + + auto const nRecipients = getConfidentialRecipientCount(auditorAmt.has_value()); + std::vector recipients; + + auto const senderPubKey = getPubKey(*arg.account); + auto const destPubKey = getPubKey(*arg.dest); + auto const issuerPubKey = getPubKey(issuer_); + + if (senderPubKey) + recipients.push_back({Slice(*senderPubKey), senderAmt}); + if (destPubKey) + recipients.push_back({Slice(*destPubKey), destAmt}); + if (issuerPubKey) + recipients.push_back({Slice(*issuerPubKey), issuerAmt}); + + std::optional auditorPubKey; + if (auditorAmt) + { + if (!auditor_) + Throw("Auditor not registered"); + auditorPubKey = getPubKey(*auditor_); + if (auditorPubKey) + recipients.push_back({Slice(*auditorPubKey), *auditorAmt}); + } + + std::optional proof; + + // Skip proof generation when spending balance is 0 + if (arg.account != arg.dest && prevEncryptedSenderSpending && prevSenderSpending > 0) + { + proof = getConfidentialSendProof( + *arg.account, + *arg.amt, + recipients, + blindingFactor, + nRecipients, + ctxHash, + {amountCommitment, *arg.amt, senderAmt, amountBlindingFactor}, + {balanceCommitment, + prevSenderSpending, + *prevEncryptedSenderSpending, + balanceBlindingFactor}); + } + + if (proof) + jv[sfZKProof.jsonName] = strHex(*proof); + else + { + size_t const sizeEquality = secp256k1_mpt_proof_equality_shared_r_size(nRecipients); + size_t const dummySize = + sizeEquality + 2 * ecPedersenProofLength + ecDoubleBulletproofLength; + jv[sfZKProof.jsonName] = strHex(makeZeroBuffer(dummySize)); + } + } + + return jv; +} + +static Buffer +parseSenderEncAmt(Json::Value const& jv) +{ + auto const hexStr = jv[sfSenderEncryptedAmount.jsonName].asString(); + auto const bytes = strUnHex(hexStr); + if (!bytes) + Throw("chainAfterSend: invalid hex in sfSenderEncryptedAmount"); + return Buffer(bytes->data(), bytes->size()); +} + +ConfidentialSendChainState +MPTTester::chainAfterSend(Account const& sender, std::uint64_t sendAmt, Json::Value const& jv) const +{ + auto const prevSpending = getDecryptedBalance(sender, HOLDER_ENCRYPTED_SPENDING); + auto const prevEncSpending = getEncryptedBalance(sender, HOLDER_ENCRYPTED_SPENDING); + auto const prevVersion = getMPTokenVersion(sender); + + if (!prevSpending || !prevEncSpending) + Throw("chainAfterSend: failed to read sender state from ledger"); + + Buffer const senderEncAmt = parseSenderEncAmt(jv); + auto chain = computeNextSendChainState( + *prevSpending, Slice(*prevEncSpending), prevVersion, sendAmt, Slice(senderEncAmt)); + if (!chain) + Throw("chainAfterSend: computeNextSendChainState failed"); + return std::move(*chain); +} + +std::optional +computeNextSendChainState( + std::uint64_t currentSpending, + Slice const& currentEncSpending, + std::uint32_t currentVersion, + std::uint64_t sendAmt, + Slice const& senderEncAmt) +{ + if (sendAmt > currentSpending) + return std::nullopt; // LCOV_EXCL_LINE + + auto newEncSpending = homomorphicSubtract(currentEncSpending, senderEncAmt); + if (!newEncSpending) + return std::nullopt; // LCOV_EXCL_LINE + + return ConfidentialSendChainState{ + .spending = currentSpending - sendAmt, + .encSpending = std::move(*newEncSpending), + .version = currentVersion + 1, + }; +} + +void +MPTTester::confidentialClaw(MPTConfidentialClawback const& arg) +{ + Json::Value jv; + auto const account = arg.account ? *arg.account : issuer_; + jv[sfAccount] = account.human(); + + if (arg.holder) + jv[sfHolder] = arg.holder->human(); + else + Throw("Holder not specified"); + + jv[jss::TransactionType] = jss::ConfidentialMPTClawback; + if (arg.id) + jv[sfMPTokenIssuanceID] = to_string(*arg.id); + else if (id_) + jv[sfMPTokenIssuanceID] = to_string(*id_); + else + Throw("MPT has not been created"); + + if (arg.amt) + jv[sfMPTAmount] = std::to_string(*arg.amt); + + if (arg.proof) + jv[sfZKProof] = *arg.proof; + else + { + auto const seq = arg.ticketSeq ? *arg.ticketSeq : env_.seq(account); + auto const contextHash = getClawbackContextHash(account.id(), *id_, seq, arg.holder->id()); + + auto const privKey = getPrivKey(account); + if (!privKey || privKey->size() != ecPrivKeyLength) + Throw("Failed to get clawback private key"); + + auto const proof = getClawbackProof(*arg.holder, *arg.amt, *privKey, contextHash); + + if (proof) + jv[sfZKProof] = strHex(*proof); + else + jv[sfZKProof] = strHex(makeZeroBuffer(ecEqualityProofLength)); + } + + auto const holderPubAmt = getBalance(*arg.holder); + auto const prevCOA = getIssuanceConfidentialBalance(); + auto const prevOA = getIssuanceOutstandingBalance(); + auto const prevVersion = getMPTokenVersion(*arg.holder); + + if (submit(arg, jv) == tesSUCCESS) + { + auto const postCOA = getIssuanceConfidentialBalance(); + auto const postOA = getIssuanceOutstandingBalance(); + auto const postVersion = getMPTokenVersion(*arg.holder); + + // Verify holder's public balance is unchanged + env_.require(mptbalance(*this, *arg.holder, holderPubAmt)); + + // Verify COA and OA are reduced correctly + env_.require(requireAny( + [&]() -> bool { return prevCOA >= *arg.amt && postCOA == prevCOA - *arg.amt; })); + env_.require(requireAny( + [&]() -> bool { return prevOA >= *arg.amt && postOA == prevOA - *arg.amt; })); + + // Verify holder's confidential balances are zeroed out + env_.require(requireAny([&]() -> bool { + return getDecryptedBalance(*arg.holder, HOLDER_ENCRYPTED_INBOX) == 0; + })); + env_.require(requireAny([&]() -> bool { + return getDecryptedBalance(*arg.holder, HOLDER_ENCRYPTED_SPENDING) == 0; + })); + env_.require(requireAny([&]() -> bool { + return getDecryptedBalance(*arg.holder, ISSUER_ENCRYPTED_BALANCE) == 0; + })); + env_.require(requireAny([&]() -> bool { + return getDecryptedBalance(*arg.holder, AUDITOR_ENCRYPTED_BALANCE) == 0; + })); + + // Verify version is incremented + env_.require(requireAny([&]() -> bool { return postVersion == prevVersion + 1; })); + } +} + +void +MPTTester::generateKeyPair(Account const& account) +{ + unsigned char privKey[ecPrivKeyLength]; + secp256k1_pubkey pubKey; + if (!secp256k1_elgamal_generate_keypair(secp256k1Context(), privKey, &pubKey)) + Throw("failed to generate key pair"); + + // Serialize public key to compressed format (33 bytes) + unsigned char compressedPubKey[ecPubKeyLength]; + size_t outLen = ecPubKeyLength; + if (secp256k1_ec_pubkey_serialize( + secp256k1Context(), compressedPubKey, &outLen, &pubKey, SECP256K1_EC_COMPRESSED) != 1 || + outLen != ecPubKeyLength) + Throw("failed to serialize public key"); + + pubKeys.insert({account.id(), Buffer{compressedPubKey, ecPubKeyLength}}); + privKeys.insert({account.id(), Buffer{privKey, ecPrivKeyLength}}); +} + +std::optional +MPTTester::getPubKey(Account const& account) const +{ + auto it = pubKeys.find(account.id()); + if (it != pubKeys.end()) + { + return it->second; + } + + return std::nullopt; +} + +std::optional +MPTTester::getPrivKey(Account const& account) const +{ + auto it = privKeys.find(account.id()); + if (it != privKeys.end()) + { + return it->second; + } + + return std::nullopt; +} + +Buffer +MPTTester::encryptAmount(Account const& account, uint64_t const amt, Buffer const& blindingFactor) + const +{ + if (auto const pubKey = getPubKey(account)) + { + if (auto const result = xrpl::encryptAmount(amt, *pubKey, blindingFactor)) + return *result; + } + + // Return a dummy buffer on failure to allow testing of + // failures that occur prior to encryption. + return makeZeroBuffer(ecGamalEncryptedTotalLength); +} + +std::optional +MPTTester::decryptAmount(Account const& account, Buffer const& amt) const +{ + if (amt.size() != ecGamalEncryptedTotalLength) + return std::nullopt; + + auto const pair = makeEcPair(amt); + if (!pair) + return std::nullopt; + + auto const privKey = getPrivKey(account); + if (!privKey || privKey->size() != ecPrivKeyLength) + return std::nullopt; + + uint64_t decryptedAmt = 0; + if (!secp256k1_elgamal_decrypt( + secp256k1Context(), &decryptedAmt, &pair->c1, &pair->c2, privKey->data())) + { + return std::nullopt; + } + + return decryptedAmt; +} + +std::optional +MPTTester::getDecryptedBalance(Account const& account, EncryptedBalanceType balanceType) const + +{ + auto encryptedAmt = getEncryptedBalance(account, balanceType); + + // Return zero to test cases like Feature Disabled, where the ledger object + // does not exist. + if (!encryptedAmt) + return 0; + + Account decryptor = account; + + if (balanceType == ISSUER_ENCRYPTED_BALANCE) + decryptor = issuer_; + else if (balanceType == AUDITOR_ENCRYPTED_BALANCE) + { + if (!auditor_) + return std::nullopt; + decryptor = *auditor_; + } + + return decryptAmount(decryptor, *encryptedAmt); +}; + +Json::Value +MPTTester::mergeInboxJV(MPTMergeInbox const& arg) const +{ + Json::Value jv; + if (arg.account) + jv[sfAccount] = arg.account->human(); + else + Throw("Account not specified"); + if (arg.id) + jv[sfMPTokenIssuanceID] = to_string(*arg.id); + else + { + if (!id_) + Throw("MPT has not been created"); + jv[sfMPTokenIssuanceID] = to_string(*id_); + } + jv[sfTransactionType] = jss::ConfidentialMPTMergeInbox; + return jv; +} + +void +MPTTester::mergeInbox(MPTMergeInbox const& arg) +{ + Json::Value jv; + if (arg.account) + jv[sfAccount] = arg.account->human(); + else + Throw("Account not specified"); + if (arg.id) + jv[sfMPTokenIssuanceID] = to_string(*arg.id); + else + { + if (!id_) + Throw("MPT has not been created"); + jv[sfMPTokenIssuanceID] = to_string(*id_); + } + + jv[sfTransactionType] = jss::ConfidentialMPTMergeInbox; + auto const prevInboxBalance = getDecryptedBalance(*arg.account, HOLDER_ENCRYPTED_INBOX); + auto const prevSpendingBalance = getDecryptedBalance(*arg.account, HOLDER_ENCRYPTED_SPENDING); + auto const prevIssuerBalance = getDecryptedBalance(*arg.account, ISSUER_ENCRYPTED_BALANCE); + + if (!prevInboxBalance || !prevSpendingBalance || !prevIssuerBalance) + Throw("Failed to get pre-mergeInbox balances"); + + if (submit(arg, jv) == tesSUCCESS) + { + auto const postInboxBalance = getDecryptedBalance(*arg.account, HOLDER_ENCRYPTED_INBOX); + auto const postSpendingBalance = + getDecryptedBalance(*arg.account, HOLDER_ENCRYPTED_SPENDING); + auto const postIssuerBalance = getDecryptedBalance(*arg.account, ISSUER_ENCRYPTED_BALANCE); + + if (!postInboxBalance || !postSpendingBalance || !postIssuerBalance) + Throw("Failed to get post-mergeInbox balances"); + + env_.require(requireAny([&]() -> bool { + return *postSpendingBalance == *prevInboxBalance + *prevSpendingBalance && + *postInboxBalance == 0; + })); + + env_.require( + requireAny([&]() -> bool { return *prevIssuerBalance == *postIssuerBalance; })); + + env_.require(requireAny([&]() -> bool { + return *postSpendingBalance + *postInboxBalance == *postIssuerBalance; + })); + } +} + +std::int64_t +MPTTester::getIssuanceOutstandingBalance() const +{ + if (!id_) + Throw("Issuance ID does not exist"); + + auto const sle = env_.current()->read(keylet::mptIssuance(*id_)); + + if (!sle || !sle->isFieldPresent(sfOutstandingAmount)) + Throw("Issuance object does not contain outstanding amount"); + + return (*sle)[sfOutstandingAmount]; +} + +std::uint32_t +MPTTester::getMPTokenVersion(Account const account) const +{ + if (!id_) + Throw("Issuance ID does not exist"); + + auto const sle = env_.current()->read(keylet::mptoken(*id_, account)); + + // return 0 here instead of throwing an exception since tests for + // preclaim will check if the MPToken exists + if (!sle) + return 0; + + return (*sle)[~sfConfidentialBalanceVersion].value_or(0); +} + +void +MPTTester::convertBack(MPTConvertBack const& arg) +{ + Json::Value jv; + if (arg.account) + jv[sfAccount] = arg.account->human(); + else + Throw("Account not specified"); + + jv[jss::TransactionType] = jss::ConfidentialMPTConvertBack; + if (arg.id) + jv[sfMPTokenIssuanceID] = to_string(*arg.id); + else + { + if (!id_) + Throw("MPT has not been created"); + jv[sfMPTokenIssuanceID] = to_string(*id_); + } + + if (arg.amt) + jv[sfMPTAmount.jsonName] = std::to_string(*arg.amt); + + Buffer holderCiphertext; + Buffer issuerCiphertext; + std::optional auditorCiphertext; + Buffer blindingFactor; + + fillConversionCiphertexts( + arg, jv, holderCiphertext, issuerCiphertext, auditorCiphertext, blindingFactor); + + jv[sfBlindingFactor] = strHex(blindingFactor); + + auto const prevInboxBalance = getDecryptedBalance(*arg.account, HOLDER_ENCRYPTED_INBOX); + auto const prevSpendingBalance = getDecryptedBalance(*arg.account, HOLDER_ENCRYPTED_SPENDING); + auto const prevIssuerBalance = getDecryptedBalance(*arg.account, ISSUER_ENCRYPTED_BALANCE); + + if (!prevInboxBalance || !prevSpendingBalance || !prevIssuerBalance) + Throw("Failed to get Pre-convertBack balance"); + + Buffer pedersenCommitment; + Buffer const pcBlindingFactor = generateBlindingFactor(); + if (arg.pedersenCommitment) + pedersenCommitment = *arg.pedersenCommitment; + else + pedersenCommitment = getPedersenCommitment(*prevSpendingBalance, pcBlindingFactor); + + jv[sfBalanceCommitment] = strHex(pedersenCommitment); + + if (arg.proof) + jv[sfZKProof.jsonName] = strHex(*arg.proof); + else + { + auto const version = getMPTokenVersion(*arg.account); + + // if the caller generated ciphertexts themselves, they should also + // generate the proof themselves from the blinding factor + auto const seq = arg.ticketSeq.value_or(env_.seq(*arg.account)); + uint256 const contextHash = + getConvertBackContextHash(arg.account->id(), *id_, seq, version); + auto const prevEncryptedSpendingBalance = + getEncryptedBalance(*arg.account, HOLDER_ENCRYPTED_SPENDING); + + Buffer proof; + // generate a dummy proof if no encrypted amount field, so that other + // preflight/preclaim are checked + if (!prevEncryptedSpendingBalance) + proof = makeZeroBuffer(ecPedersenProofLength + ecSingleBulletproofLength); + else + { + proof = getConvertBackProof( + *arg.account, + *arg.amt, + contextHash, + { + .pedersenCommitment = pedersenCommitment, + .amt = *prevSpendingBalance, + .encryptedAmt = *prevEncryptedSpendingBalance, + .blindingFactor = pcBlindingFactor, + }); + } + jv[sfZKProof] = strHex(proof); + } + + auto const holderAmt = getBalance(*arg.account); + auto const prevConfidentialOutstanding = getIssuanceConfidentialBalance(); + + std::optional prevAuditorBalance; + if (arg.auditorEncryptedAmt || auditor_) + { + prevAuditorBalance = getDecryptedBalance(*arg.account, AUDITOR_ENCRYPTED_BALANCE); + if (!prevAuditorBalance) + Throw("Failed to get Pre-convertBack balance"); + } + + if (submit(arg, jv) == tesSUCCESS) + { + auto const postConfidentialOutstanding = getIssuanceConfidentialBalance(); + env_.require(mptbalance(*this, *arg.account, holderAmt + *arg.amt)); + env_.require(requireAny([&]() -> bool { + return prevConfidentialOutstanding - *arg.amt == postConfidentialOutstanding; + })); + + auto const postInboxBalance = getDecryptedBalance(*arg.account, HOLDER_ENCRYPTED_INBOX); + auto const postIssuerBalance = getDecryptedBalance(*arg.account, ISSUER_ENCRYPTED_BALANCE); + auto const postSpendingBalance = + getDecryptedBalance(*arg.account, HOLDER_ENCRYPTED_SPENDING); + + if (!postInboxBalance || !postIssuerBalance || !postSpendingBalance) + Throw("Failed to get post-convertBack balance"); + + if (arg.auditorEncryptedAmt || auditor_) + { + auto const postAuditorBalance = + getDecryptedBalance(*arg.account, AUDITOR_ENCRYPTED_BALANCE); + + if (!postAuditorBalance) + Throw("Failed to get post-convertBack balance"); + + // auditor's encrypted balance is updated correctly + env_.require(requireAny( + [&]() -> bool { return *prevAuditorBalance - *arg.amt == *postAuditorBalance; })); + } + + // inbox balance should not change + env_.require(requireAny([&]() -> bool { return *postInboxBalance == *prevInboxBalance; })); + + // issuer's encrypted balance is updated correctly + env_.require(requireAny( + [&]() -> bool { return *prevIssuerBalance - *arg.amt == *postIssuerBalance; })); + + // holder's spending balance is updated correctly + env_.require(requireAny( + [&]() -> bool { return *prevSpendingBalance - *arg.amt == *postSpendingBalance; })); + + // sum of holder's inbox and spending balance should equal to issuer's + // encrypted balance + env_.require(requireAny([&]() -> bool { + return *postInboxBalance + *postSpendingBalance == *postIssuerBalance; + })); + } +} + +Json::Value +MPTTester::convertBackJV(MPTConvertBack const& arg, std::uint32_t seq) +{ + Json::Value jv; + if (arg.account) + jv[sfAccount] = arg.account->human(); + else + Throw("Account not specified"); + + jv[jss::TransactionType] = jss::ConfidentialMPTConvertBack; + if (arg.id) + jv[sfMPTokenIssuanceID] = to_string(*arg.id); + else + { + if (!id_) + Throw("MPT has not been created"); + jv[sfMPTokenIssuanceID] = to_string(*id_); + } + + if (arg.amt) + jv[sfMPTAmount.jsonName] = std::to_string(*arg.amt); + + Buffer holderCiphertext; + Buffer issuerCiphertext; + std::optional auditorCiphertext; + Buffer blindingFactor; + + fillConversionCiphertexts( + arg, jv, holderCiphertext, issuerCiphertext, auditorCiphertext, blindingFactor); + + jv[sfBlindingFactor] = strHex(blindingFactor); + + auto const prevSpendingBalance = getDecryptedBalance(*arg.account, HOLDER_ENCRYPTED_SPENDING); + if (!prevSpendingBalance) + Throw("convertBackJV: failed to read spending balance from ledger"); + + Buffer pedersenCommitment; + Buffer const pcBlindingFactor = generateBlindingFactor(); + if (arg.pedersenCommitment) + pedersenCommitment = *arg.pedersenCommitment; + else + pedersenCommitment = getPedersenCommitment(*prevSpendingBalance, pcBlindingFactor); + + jv[sfBalanceCommitment] = strHex(pedersenCommitment); + + if (arg.proof) + jv[sfZKProof.jsonName] = strHex(*arg.proof); + else + { + auto const version = getMPTokenVersion(*arg.account); + auto const prevEncSpending = getEncryptedBalance(*arg.account, HOLDER_ENCRYPTED_SPENDING); + uint256 const contextHash = + getConvertBackContextHash(arg.account->id(), *id_, seq, version); + + Buffer proof; + if (!prevEncSpending) + proof = makeZeroBuffer(ecPedersenProofLength + ecSingleBulletproofLength); + else + proof = getConvertBackProof( + *arg.account, + *arg.amt, + contextHash, + { + .pedersenCommitment = pedersenCommitment, + .amt = *prevSpendingBalance, + .encryptedAmt = *prevEncSpending, + .blindingFactor = pcBlindingFactor, + }); + + jv[sfZKProof] = strHex(proof); + } + + return jv; +} + +Buffer +MPTTester::getAmountLinkageProof( + Buffer const& pubKey, + Buffer const& blindingFactor, + uint256 const& contextHash, + PedersenProofParams const& params) const +{ + if (pubKey.size() != ecPubKeyLength || blindingFactor.size() != ecBlindingFactorLength) + return makeZeroBuffer(ecPedersenProofLength); + + auto const pedersenParams = makePedersenParams(params); + Buffer proof(ecPedersenProofLength); + + if (mpt_get_amount_linkage_proof( + pubKey.data(), + blindingFactor.data(), + contextHash.data(), + &pedersenParams, + proof.data()) != 0) + { + Throw("Amount Linkage Proof generation failed"); + } + + return proof; +} + +Buffer +MPTTester::getBalanceLinkageProof( + Account const& account, + uint256 const& contextHash, + Buffer const& pubKey, + PedersenProofParams const& params) const +{ + if (pubKey.size() != ecPubKeyLength) + return makeZeroBuffer(ecPedersenProofLength); + + auto const privKey = getPrivKey(account); + if (!privKey || privKey->size() != ecPrivKeyLength) + Throw("Failed to get Pedersen proof private key"); + + auto const pedersenParams = makePedersenParams(params); + Buffer proof(ecPedersenProofLength); + + if (mpt_get_balance_linkage_proof( + privKey->data(), pubKey.data(), contextHash.data(), &pedersenParams, proof.data()) != 0) + Throw("Pedersen proof generation failed"); + + return proof; +} + +Buffer +MPTTester::getBulletproof( + std::vector const& values, + std::vector const& blindingFactors, + uint256 const& contextHash) const +{ + std::size_t const m = values.size(); + + if (m == 0 || m > 2 || m != blindingFactors.size()) + Throw("getBulletproof: invalid input parameters"); + + for (auto const& bf : blindingFactors) + { + if (bf.size() != ecBlindingFactorLength) + Throw("Invalid blinding factor length"); + } + + // Flatten blinding factors into contiguous memory (m * 32 bytes) + std::vector blindingsFlat(m * ecBlindingFactorLength); + for (std::size_t i = 0; i < m; ++i) + std::memcpy( + blindingsFlat.data() + i * ecBlindingFactorLength, + blindingFactors[i].data(), + ecBlindingFactorLength); + + secp256k1_pubkey pk_base; + if (secp256k1_mpt_get_h_generator(secp256k1Context(), &pk_base) != 1) + Throw("Failed to get H generator"); + + // Proof size scales with m; use safe upper bound + Buffer bulletproof(4096); + std::size_t proofLen = 4096; + + if (secp256k1_bulletproof_prove_agg( + secp256k1Context(), + bulletproof.data(), + &proofLen, + values.data(), + blindingsFlat.data(), + m, + &pk_base, + contextHash.data()) != 1) + { + Throw("Bulletproof generation failed"); + } + + std::size_t const expectedLen = + (m == 1) ? ecSingleBulletproofLength : ecDoubleBulletproofLength; + if (proofLen != expectedLen) + Throw("Unexpected bulletproof length"); + + return Buffer(bulletproof.data(), proofLen); +} + } // namespace jtx } // namespace test } // namespace xrpl diff --git a/src/test/jtx/mpt.h b/src/test/jtx/mpt.h index 96fbf30d90..ac409d5e36 100644 --- a/src/test/jtx/mpt.h +++ b/src/test/jtx/mpt.h @@ -2,12 +2,17 @@ #include #include +#include #include +#include #include +#include #include #include +#include + namespace xrpl { namespace test { namespace jtx { @@ -16,6 +21,22 @@ class MPTTester; auto const MPTDEXFlags = tfMPTCanTrade | tfMPTCanTransfer; +/*Helper lambda to create a zero-initialized buffer. +WHY THIS IS NEEDED: In C++, xrpl::Buffer(size) allocates uninitialized heap memory. +Because CI runs unit tests sequentially in the same process, uninitialized memory +often recycles "ghost data" (like valid SECP256k1 keys or Pedersen commitments) +left over from previously executed tests. +When testing malformed cryptography paths, passing uninitialized memory might +accidentally supply a valid curve point, causing the ledger's preflight checks +to falsely succeed and return tecBAD_PROOF instead of the expected temMALFORMED. +Explicitly zeroing the buffer guarantees it fails structural validation. */ +static auto makeZeroBuffer = [](size_t size) { + Buffer b(size); + if (size > 0) + std::memset(b.data(), 0, size); + return b; +}; + // Check flags settings on MPT create class mptflags { @@ -97,6 +118,7 @@ struct MPTCreate struct MPTInit { Holders holders = {}; + std::optional auditor = std::nullopt; PrettyAmount const xrp = XRP(10'000); PrettyAmount const xrpHolders = XRP(10'000); bool fund = true; @@ -111,6 +133,7 @@ struct MPTInitDef Env& env; Account issuer; Holders holders = {}; + std::optional auditor = std::nullopt; std::uint16_t transferFee = 0; std::optional pay = std::nullopt; std::uint32_t flags = MPTDEXFlags; @@ -156,18 +179,182 @@ struct MPTSet std::optional metadata = std::nullopt; std::optional delegate = std::nullopt; std::optional domainID = std::nullopt; + std::optional issuerPubKey = std::nullopt; + std::optional auditorPubKey = std::nullopt; + std::optional ticketSeq = std::nullopt; std::optional err = std::nullopt; }; +struct MPTConvert +{ + std::optional account = std::nullopt; + std::optional id = std::nullopt; + std::optional amt = std::nullopt; + std::optional proof = std::nullopt; + std::optional fillAuditorEncryptedAmt = true; + // indicates whether to autofill schnorr proof. + // default : auto generate proof if holderPubKey is present. + // true: force proof generation. + // false: force proof omission. + std::optional fillSchnorrProof = std::nullopt; + std::optional holderPubKey = std::nullopt; + std::optional holderEncryptedAmt = std::nullopt; + std::optional issuerEncryptedAmt = std::nullopt; + std::optional auditorEncryptedAmt = std::nullopt; + + std::optional blindingFactor = std::nullopt; + std::optional delegate = std::nullopt; + std::optional ticketSeq = std::nullopt; + std::optional ownerCount = std::nullopt; + std::optional holderCount = std::nullopt; + std::optional flags = std::nullopt; + std::optional err = std::nullopt; +}; + +struct MPTMergeInbox +{ + std::optional account = std::nullopt; + std::optional id = std::nullopt; + std::optional delegate = std::nullopt; + std::optional ticketSeq = std::nullopt; + std::optional ownerCount = std::nullopt; + std::optional holderCount = std::nullopt; + std::optional flags = std::nullopt; + std::optional err = std::nullopt; +}; + +struct MPTConfidentialSend +{ + std::optional account = std::nullopt; + std::optional dest = std::nullopt; + std::optional id = std::nullopt; + // amt is to generate encrypted amounts for testing purposes + std::optional amt = std::nullopt; + std::optional proof = std::nullopt; + std::optional senderEncryptedAmt = std::nullopt; + std::optional destEncryptedAmt = std::nullopt; + std::optional issuerEncryptedAmt = std::nullopt; + std::optional auditorEncryptedAmt = std::nullopt; + std::optional fillAuditorEncryptedAmt = true; + std::optional> credentials = std::nullopt; + // not an txn param, only used for autofilling + std::optional blindingFactor = std::nullopt; + std::optional amountCommitment = std::nullopt; + std::optional balanceCommitment = std::nullopt; + std::optional delegate = std::nullopt; + std::optional ticketSeq = std::nullopt; + std::optional ownerCount = std::nullopt; + std::optional holderCount = std::nullopt; + std::optional flags = std::nullopt; + std::optional err = std::nullopt; +}; + +struct MPTConvertBack +{ + std::optional account = std::nullopt; + std::optional id = std::nullopt; + std::optional amt = std::nullopt; + std::optional proof = std::nullopt; + std::optional holderEncryptedAmt = std::nullopt; + std::optional issuerEncryptedAmt = std::nullopt; + std::optional auditorEncryptedAmt = std::nullopt; + std::optional fillAuditorEncryptedAmt = true; + // not an txn param, only used for autofilling + std::optional blindingFactor = std::nullopt; + std::optional pedersenCommitment = std::nullopt; + std::optional delegate = std::nullopt; + std::optional ticketSeq = std::nullopt; + std::optional ownerCount = std::nullopt; + std::optional holderCount = std::nullopt; + std::optional flags = std::nullopt; + std::optional err = std::nullopt; +}; + +struct MPTConfidentialClawback +{ + std::optional account = std::nullopt; + std::optional holder = std::nullopt; + std::optional id = std::nullopt; + std::optional amt = std::nullopt; + std::optional proof = std::nullopt; + std::optional delegate = std::nullopt; + std::optional ticketSeq = std::nullopt; + std::optional ownerCount = std::nullopt; + std::optional holderCount = std::nullopt; + std::optional flags = std::nullopt; + std::optional err = std::nullopt; +}; + +/** + * @brief Stores the parameters that are exclusively used to generate a + * pedersen linkage proof + */ +struct PedersenProofParams +{ + Buffer const pedersenCommitment; + uint64_t const amt; // either spending balance or value to be transferred + Buffer const encryptedAmt; + Buffer const blindingFactor; +}; + +/** + * @brief When building multiple confidential sends from the same account inside a + * single batch transaction, pass this state to the transaction builder for + * each subsequent send so that its proof references the post previous-send + * encrypted balance rather than the stale pre-send ledger state. + * + * The fields mirror what the ledger will contain after the preceding send's + * doApply() completes: + * encSpending = homomorphicSubtract(prevEncSpending, senderEncAmt) + * version = prevVersion + 1 + */ +struct ConfidentialSendChainState +{ + std::uint64_t spending; // Decrypted spending balance after the previous send. + Buffer encSpending; // Encrypted spending balance after the previous send. + std::uint32_t version; // sfConfidentialBalanceVersion after the previous send. +}; + +/** + * @brief Use this when building a second (or later) confidential send from the same + * account in the same batch. Pass the state to the chain aware + * transaction builder so that the next proof is constructed against the + * correct post-send encrypted balance and version. + * + * @param currentSpending Decrypted spending balance before the send. + * @param currentEncSpending sfConfidentialBalanceSpending before the send. + * @param currentVersion sfConfidentialBalanceVersion before the send. + * @param sendAmt Plaintext amount being sent. + * @param senderEncAmt sfSenderEncryptedAmount from the send transaction. + * @return The predicted chain state, or std::nullopt if homomorphic subtraction fails + */ +std::optional +computeNextSendChainState( + std::uint64_t currentSpending, + Slice const& currentEncSpending, + std::uint32_t currentVersion, + std::uint64_t sendAmt, + Slice const& senderEncAmt); + class MPTTester { Env& env_; Account const issuer_; std::unordered_map const holders_; + std::optional const auditor_; std::optional id_; bool close_; + std::unordered_map pubKeys; + std::unordered_map privKeys; public: + enum EncryptedBalanceType { + ISSUER_ENCRYPTED_BALANCE, + HOLDER_ENCRYPTED_INBOX, + HOLDER_ENCRYPTED_SPENDING, + AUDITOR_ENCRYPTED_BALANCE, + }; + MPTTester(Env& env, Account const& issuer, MPTInit const& constr = {}); MPTTester(MPTInitDef const& constr); MPTTester( @@ -205,6 +392,60 @@ public: static Json::Value setJV(MPTSet const& set = {}); + void + convert(MPTConvert const& arg = MPTConvert{}); + + // Build a confidential convert JV without submitting. 'seq' is the inner + // transaction sequence used in the Schnorr proof context hash. + Json::Value + convertJV(MPTConvert const& arg, std::uint32_t seq); + + void + mergeInbox(MPTMergeInbox const& arg = MPTMergeInbox{}); + + Json::Value + mergeInboxJV(MPTMergeInbox const& arg = MPTMergeInbox{}) const; + + void + send(MPTConfidentialSend const& arg = MPTConfidentialSend{}); + + // Build a confidential send JV. When 'chain' is provided the sender's + // proof parameters are taken from it instead of the ledger, enabling + // correct proof generation for a second (or later) send from the same + // account inside a single batch. + Json::Value + sendJV( + MPTConfidentialSend const& arg, + std::uint32_t seq, + std::optional chain = std::nullopt); + + // Compute the projected sender state after a confidential send in a batch. + // + // Each confidential send requires a ZK proof that the sender's spending + // balance covers the transfer. In a batch, if there are more than one + // Confidential Send, the 2nd onwards send requires a proof that includes the + // updated spending balance. + // + // Example: Bob has 200, batches send 100 to Carol then 50 to Dave: + // jv1 = sendJV({bob->carol, 100}, seq1) + // chain = chainAfterSend(bob, 100, jv1) // projected balance after jv1 = 100 + // jv2 = sendJV({bob->dave, 50}, seq2, chain) + ConfidentialSendChainState + chainAfterSend(Account const& sender, std::uint64_t sendAmt, Json::Value const& jv) const; + + void + convertBack(MPTConvertBack const& arg = MPTConvertBack{}); + + // Build a confidential convertBack JV without submitting. 'seq' is the + // inner transaction sequence used in the proof context hash. Reads the + // current encrypted spending balance and version from the ledger, so call + // this before the batch is submitted. + Json::Value + convertBackJV(MPTConvertBack const& arg, std::uint32_t seq); + + void + confidentialClaw(MPTConfidentialClawback const& arg = MPTConfidentialClawback{}); + [[nodiscard]] bool checkDomainID(std::optional expected) const; @@ -214,6 +455,9 @@ public: [[nodiscard]] bool checkMPTokenOutstandingAmount(std::int64_t expectedAmount) const; + [[nodiscard]] bool + checkIssuanceConfidentialBalance(std::int64_t expectedAmount) const; + [[nodiscard]] bool checkFlags(uint32_t const expectedFlags, std::optional const& holder = std::nullopt) const; @@ -235,6 +479,7 @@ public: { return issuer_; } + Account const& holder(std::string const& h) const; @@ -266,6 +511,14 @@ public: std::int64_t getBalance(Account const& account) const; + std::int64_t + getIssuanceConfidentialBalance() const; + + std::optional + getEncryptedBalance( + Account const& account, + EncryptedBalanceType option = HOLDER_ENCRYPTED_INBOX) const; + MPT operator[](std::string const& name) const; @@ -274,6 +527,84 @@ public: operator Asset() const; + bool + printMPT(Account const& holder_) const; + + void + generateKeyPair(Account const& account); + + std::optional + getPubKey(Account const& account) const; + + std::optional + getPrivKey(Account const& account) const; + + Buffer + encryptAmount(Account const& account, uint64_t const amt, Buffer const& blindingFactor) const; + + std::optional + decryptAmount(Account const& account, Buffer const& amt) const; + + std::optional + getDecryptedBalance(Account const& account, EncryptedBalanceType balanceType) const; + + std::int64_t + getIssuanceOutstandingBalance() const; + + std::optional + getClawbackProof( + Account const& holder, + std::uint64_t amount, + Buffer const& privateKey, + uint256 const& txHash) const; + + std::optional + getSchnorrProof(Account const& account, uint256 const& ctxHash) const; + + std::optional + getConfidentialSendProof( + Account const& sender, + std::uint64_t const amount, + std::vector const& recipients, + Slice const& blindingFactor, + std::size_t const nRecipients, + uint256 const& contextHash, + PedersenProofParams const& amountParams, + PedersenProofParams const& balanceParams) const; + + Buffer + getConvertBackProof( + Account const& holder, + std::uint64_t const amount, + uint256 const& contextHash, + PedersenProofParams const& pcParams) const; + + std::uint32_t + getMPTokenVersion(Account const account) const; + + Buffer + getAmountLinkageProof( + Buffer const& pubKey, + Buffer const& blindingFactor, + uint256 const& contextHash, + PedersenProofParams const& params) const; + + Buffer + getBalanceLinkageProof( + Account const& account, + uint256 const& contextHash, + Buffer const& pubKey, + PedersenProofParams const& params) const; + + Buffer + getBulletproof( + std::vector const& values, + std::vector const& blindingFactors, + uint256 const& contextHash) const; + + Buffer + getPedersenCommitment(std::uint64_t const amount, Buffer const& pedersenBlindingFactor); + friend BookSpec operator~(MPTTester const& mpt) { @@ -291,7 +622,30 @@ private: TER submit(A const& arg, Json::Value const& jv) { - env_(jv, txflags(arg.flags.value_or(0)), ter(arg.err.value_or(tesSUCCESS))); + auto const expectedFlags = txflags(arg.flags.value_or(0)); + auto const expectedTer = ter(arg.err.value_or(tesSUCCESS)); + + std::optional ticketSeq; + if constexpr (requires { arg.ticketSeq; }) + ticketSeq = arg.ticketSeq; + + std::optional delegateAcct; + if constexpr (requires { arg.delegate; }) + delegateAcct = arg.delegate; + + if (ticketSeq && delegateAcct) + env_( + jv, + expectedFlags, + expectedTer, + ticket::use(*ticketSeq), + delegate::as(*delegateAcct)); + else if (ticketSeq) + env_(jv, expectedFlags, expectedTer, ticket::use(*ticketSeq)); + else if (delegateAcct) + env_(jv, expectedFlags, expectedTer, delegate::as(*delegateAcct)); + else + env_(jv, expectedFlags, expectedTer); auto const err = env_.ter(); if (close_) env_.close(); @@ -310,6 +664,16 @@ private: std::uint32_t getFlags(std::optional const& holder) const; + + template + void + fillConversionCiphertexts( + T const& arg, + Json::Value& jv, + Buffer& holderCiphertext, + Buffer& issuerCiphertext, + std::optional& auditorCiphertext, + Buffer& blindingFactor) const; }; } // namespace jtx diff --git a/src/tests/libxrpl/protocol_autogen/ledger_entries/MPTokenIssuanceTests.cpp b/src/tests/libxrpl/protocol_autogen/ledger_entries/MPTokenIssuanceTests.cpp index e74af94a5f..4936a3babc 100644 --- a/src/tests/libxrpl/protocol_autogen/ledger_entries/MPTokenIssuanceTests.cpp +++ b/src/tests/libxrpl/protocol_autogen/ledger_entries/MPTokenIssuanceTests.cpp @@ -33,6 +33,9 @@ TEST(MPTokenIssuanceTests, BuilderSettersRoundTrip) auto const previousTxnLgrSeqValue = canonical_UINT32(); auto const domainIDValue = canonical_UINT256(); auto const mutableFlagsValue = canonical_UINT32(); + auto const issuerEncryptionKeyValue = canonical_VL(); + auto const auditorEncryptionKeyValue = canonical_VL(); + auto const confidentialOutstandingAmountValue = canonical_UINT64(); MPTokenIssuanceBuilder builder{ issuerValue, @@ -50,6 +53,9 @@ TEST(MPTokenIssuanceTests, BuilderSettersRoundTrip) builder.setMPTokenMetadata(mPTokenMetadataValue); builder.setDomainID(domainIDValue); builder.setMutableFlags(mutableFlagsValue); + builder.setIssuerEncryptionKey(issuerEncryptionKeyValue); + builder.setAuditorEncryptionKey(auditorEncryptionKeyValue); + builder.setConfidentialOutstandingAmount(confidentialOutstandingAmountValue); builder.setLedgerIndex(index); builder.setFlags(0x1u); @@ -152,6 +158,30 @@ TEST(MPTokenIssuanceTests, BuilderSettersRoundTrip) EXPECT_TRUE(entry.hasMutableFlags()); } + { + auto const& expected = issuerEncryptionKeyValue; + auto const actualOpt = entry.getIssuerEncryptionKey(); + ASSERT_TRUE(actualOpt.has_value()); + expectEqualField(expected, *actualOpt, "sfIssuerEncryptionKey"); + EXPECT_TRUE(entry.hasIssuerEncryptionKey()); + } + + { + auto const& expected = auditorEncryptionKeyValue; + auto const actualOpt = entry.getAuditorEncryptionKey(); + ASSERT_TRUE(actualOpt.has_value()); + expectEqualField(expected, *actualOpt, "sfAuditorEncryptionKey"); + EXPECT_TRUE(entry.hasAuditorEncryptionKey()); + } + + { + auto const& expected = confidentialOutstandingAmountValue; + auto const actualOpt = entry.getConfidentialOutstandingAmount(); + ASSERT_TRUE(actualOpt.has_value()); + expectEqualField(expected, *actualOpt, "sfConfidentialOutstandingAmount"); + EXPECT_TRUE(entry.hasConfidentialOutstandingAmount()); + } + EXPECT_TRUE(entry.hasLedgerIndex()); auto const ledgerIndex = entry.getLedgerIndex(); ASSERT_TRUE(ledgerIndex.has_value()); @@ -178,6 +208,9 @@ TEST(MPTokenIssuanceTests, BuilderFromSleRoundTrip) auto const previousTxnLgrSeqValue = canonical_UINT32(); auto const domainIDValue = canonical_UINT256(); auto const mutableFlagsValue = canonical_UINT32(); + auto const issuerEncryptionKeyValue = canonical_VL(); + auto const auditorEncryptionKeyValue = canonical_VL(); + auto const confidentialOutstandingAmountValue = canonical_UINT64(); auto sle = std::make_shared(MPTokenIssuance::entryType, index); @@ -194,6 +227,9 @@ TEST(MPTokenIssuanceTests, BuilderFromSleRoundTrip) sle->at(sfPreviousTxnLgrSeq) = previousTxnLgrSeqValue; sle->at(sfDomainID) = domainIDValue; sle->at(sfMutableFlags) = mutableFlagsValue; + sle->at(sfIssuerEncryptionKey) = issuerEncryptionKeyValue; + sle->at(sfAuditorEncryptionKey) = auditorEncryptionKeyValue; + sle->at(sfConfidentialOutstandingAmount) = confidentialOutstandingAmountValue; MPTokenIssuanceBuilder builderFromSle{sle}; EXPECT_TRUE(builderFromSle.validate()); @@ -355,6 +391,45 @@ TEST(MPTokenIssuanceTests, BuilderFromSleRoundTrip) expectEqualField(expected, *fromBuilderOpt, "sfMutableFlags"); } + { + auto const& expected = issuerEncryptionKeyValue; + + auto const fromSleOpt = entryFromSle.getIssuerEncryptionKey(); + auto const fromBuilderOpt = entryFromBuilder.getIssuerEncryptionKey(); + + ASSERT_TRUE(fromSleOpt.has_value()); + ASSERT_TRUE(fromBuilderOpt.has_value()); + + expectEqualField(expected, *fromSleOpt, "sfIssuerEncryptionKey"); + expectEqualField(expected, *fromBuilderOpt, "sfIssuerEncryptionKey"); + } + + { + auto const& expected = auditorEncryptionKeyValue; + + auto const fromSleOpt = entryFromSle.getAuditorEncryptionKey(); + auto const fromBuilderOpt = entryFromBuilder.getAuditorEncryptionKey(); + + ASSERT_TRUE(fromSleOpt.has_value()); + ASSERT_TRUE(fromBuilderOpt.has_value()); + + expectEqualField(expected, *fromSleOpt, "sfAuditorEncryptionKey"); + expectEqualField(expected, *fromBuilderOpt, "sfAuditorEncryptionKey"); + } + + { + auto const& expected = confidentialOutstandingAmountValue; + + auto const fromSleOpt = entryFromSle.getConfidentialOutstandingAmount(); + auto const fromBuilderOpt = entryFromBuilder.getConfidentialOutstandingAmount(); + + ASSERT_TRUE(fromSleOpt.has_value()); + ASSERT_TRUE(fromBuilderOpt.has_value()); + + expectEqualField(expected, *fromSleOpt, "sfConfidentialOutstandingAmount"); + expectEqualField(expected, *fromBuilderOpt, "sfConfidentialOutstandingAmount"); + } + EXPECT_EQ(entryFromSle.getKey(), index); EXPECT_EQ(entryFromBuilder.getKey(), index); } @@ -433,5 +508,11 @@ TEST(MPTokenIssuanceTests, OptionalFieldsReturnNullopt) EXPECT_FALSE(entry.getDomainID().has_value()); EXPECT_FALSE(entry.hasMutableFlags()); EXPECT_FALSE(entry.getMutableFlags().has_value()); + EXPECT_FALSE(entry.hasIssuerEncryptionKey()); + EXPECT_FALSE(entry.getIssuerEncryptionKey().has_value()); + EXPECT_FALSE(entry.hasAuditorEncryptionKey()); + EXPECT_FALSE(entry.getAuditorEncryptionKey().has_value()); + EXPECT_FALSE(entry.hasConfidentialOutstandingAmount()); + EXPECT_FALSE(entry.getConfidentialOutstandingAmount().has_value()); } } diff --git a/src/tests/libxrpl/protocol_autogen/ledger_entries/MPTokenTests.cpp b/src/tests/libxrpl/protocol_autogen/ledger_entries/MPTokenTests.cpp index c104e7b365..7db4b638a7 100644 --- a/src/tests/libxrpl/protocol_autogen/ledger_entries/MPTokenTests.cpp +++ b/src/tests/libxrpl/protocol_autogen/ledger_entries/MPTokenTests.cpp @@ -27,6 +27,12 @@ TEST(MPTokenTests, BuilderSettersRoundTrip) auto const ownerNodeValue = canonical_UINT64(); auto const previousTxnIDValue = canonical_UINT256(); auto const previousTxnLgrSeqValue = canonical_UINT32(); + auto const confidentialBalanceInboxValue = canonical_VL(); + auto const confidentialBalanceSpendingValue = canonical_VL(); + auto const confidentialBalanceVersionValue = canonical_UINT32(); + auto const issuerEncryptedBalanceValue = canonical_VL(); + auto const auditorEncryptedBalanceValue = canonical_VL(); + auto const holderEncryptionKeyValue = canonical_VL(); MPTokenBuilder builder{ accountValue, @@ -38,6 +44,12 @@ TEST(MPTokenTests, BuilderSettersRoundTrip) builder.setMPTAmount(mPTAmountValue); builder.setLockedAmount(lockedAmountValue); + builder.setConfidentialBalanceInbox(confidentialBalanceInboxValue); + builder.setConfidentialBalanceSpending(confidentialBalanceSpendingValue); + builder.setConfidentialBalanceVersion(confidentialBalanceVersionValue); + builder.setIssuerEncryptedBalance(issuerEncryptedBalanceValue); + builder.setAuditorEncryptedBalance(auditorEncryptedBalanceValue); + builder.setHolderEncryptionKey(holderEncryptionKeyValue); builder.setLedgerIndex(index); builder.setFlags(0x1u); @@ -94,6 +106,54 @@ TEST(MPTokenTests, BuilderSettersRoundTrip) EXPECT_TRUE(entry.hasLockedAmount()); } + { + auto const& expected = confidentialBalanceInboxValue; + auto const actualOpt = entry.getConfidentialBalanceInbox(); + ASSERT_TRUE(actualOpt.has_value()); + expectEqualField(expected, *actualOpt, "sfConfidentialBalanceInbox"); + EXPECT_TRUE(entry.hasConfidentialBalanceInbox()); + } + + { + auto const& expected = confidentialBalanceSpendingValue; + auto const actualOpt = entry.getConfidentialBalanceSpending(); + ASSERT_TRUE(actualOpt.has_value()); + expectEqualField(expected, *actualOpt, "sfConfidentialBalanceSpending"); + EXPECT_TRUE(entry.hasConfidentialBalanceSpending()); + } + + { + auto const& expected = confidentialBalanceVersionValue; + auto const actualOpt = entry.getConfidentialBalanceVersion(); + ASSERT_TRUE(actualOpt.has_value()); + expectEqualField(expected, *actualOpt, "sfConfidentialBalanceVersion"); + EXPECT_TRUE(entry.hasConfidentialBalanceVersion()); + } + + { + auto const& expected = issuerEncryptedBalanceValue; + auto const actualOpt = entry.getIssuerEncryptedBalance(); + ASSERT_TRUE(actualOpt.has_value()); + expectEqualField(expected, *actualOpt, "sfIssuerEncryptedBalance"); + EXPECT_TRUE(entry.hasIssuerEncryptedBalance()); + } + + { + auto const& expected = auditorEncryptedBalanceValue; + auto const actualOpt = entry.getAuditorEncryptedBalance(); + ASSERT_TRUE(actualOpt.has_value()); + expectEqualField(expected, *actualOpt, "sfAuditorEncryptedBalance"); + EXPECT_TRUE(entry.hasAuditorEncryptedBalance()); + } + + { + auto const& expected = holderEncryptionKeyValue; + auto const actualOpt = entry.getHolderEncryptionKey(); + ASSERT_TRUE(actualOpt.has_value()); + expectEqualField(expected, *actualOpt, "sfHolderEncryptionKey"); + EXPECT_TRUE(entry.hasHolderEncryptionKey()); + } + EXPECT_TRUE(entry.hasLedgerIndex()); auto const ledgerIndex = entry.getLedgerIndex(); ASSERT_TRUE(ledgerIndex.has_value()); @@ -114,6 +174,12 @@ TEST(MPTokenTests, BuilderFromSleRoundTrip) auto const ownerNodeValue = canonical_UINT64(); auto const previousTxnIDValue = canonical_UINT256(); auto const previousTxnLgrSeqValue = canonical_UINT32(); + auto const confidentialBalanceInboxValue = canonical_VL(); + auto const confidentialBalanceSpendingValue = canonical_VL(); + auto const confidentialBalanceVersionValue = canonical_UINT32(); + auto const issuerEncryptedBalanceValue = canonical_VL(); + auto const auditorEncryptedBalanceValue = canonical_VL(); + auto const holderEncryptionKeyValue = canonical_VL(); auto sle = std::make_shared(MPToken::entryType, index); @@ -124,6 +190,12 @@ TEST(MPTokenTests, BuilderFromSleRoundTrip) sle->at(sfOwnerNode) = ownerNodeValue; sle->at(sfPreviousTxnID) = previousTxnIDValue; sle->at(sfPreviousTxnLgrSeq) = previousTxnLgrSeqValue; + sle->at(sfConfidentialBalanceInbox) = confidentialBalanceInboxValue; + sle->at(sfConfidentialBalanceSpending) = confidentialBalanceSpendingValue; + sle->at(sfConfidentialBalanceVersion) = confidentialBalanceVersionValue; + sle->at(sfIssuerEncryptedBalance) = issuerEncryptedBalanceValue; + sle->at(sfAuditorEncryptedBalance) = auditorEncryptedBalanceValue; + sle->at(sfHolderEncryptionKey) = holderEncryptionKeyValue; MPTokenBuilder builderFromSle{sle}; EXPECT_TRUE(builderFromSle.validate()); @@ -210,6 +282,84 @@ TEST(MPTokenTests, BuilderFromSleRoundTrip) expectEqualField(expected, *fromBuilderOpt, "sfLockedAmount"); } + { + auto const& expected = confidentialBalanceInboxValue; + + auto const fromSleOpt = entryFromSle.getConfidentialBalanceInbox(); + auto const fromBuilderOpt = entryFromBuilder.getConfidentialBalanceInbox(); + + ASSERT_TRUE(fromSleOpt.has_value()); + ASSERT_TRUE(fromBuilderOpt.has_value()); + + expectEqualField(expected, *fromSleOpt, "sfConfidentialBalanceInbox"); + expectEqualField(expected, *fromBuilderOpt, "sfConfidentialBalanceInbox"); + } + + { + auto const& expected = confidentialBalanceSpendingValue; + + auto const fromSleOpt = entryFromSle.getConfidentialBalanceSpending(); + auto const fromBuilderOpt = entryFromBuilder.getConfidentialBalanceSpending(); + + ASSERT_TRUE(fromSleOpt.has_value()); + ASSERT_TRUE(fromBuilderOpt.has_value()); + + expectEqualField(expected, *fromSleOpt, "sfConfidentialBalanceSpending"); + expectEqualField(expected, *fromBuilderOpt, "sfConfidentialBalanceSpending"); + } + + { + auto const& expected = confidentialBalanceVersionValue; + + auto const fromSleOpt = entryFromSle.getConfidentialBalanceVersion(); + auto const fromBuilderOpt = entryFromBuilder.getConfidentialBalanceVersion(); + + ASSERT_TRUE(fromSleOpt.has_value()); + ASSERT_TRUE(fromBuilderOpt.has_value()); + + expectEqualField(expected, *fromSleOpt, "sfConfidentialBalanceVersion"); + expectEqualField(expected, *fromBuilderOpt, "sfConfidentialBalanceVersion"); + } + + { + auto const& expected = issuerEncryptedBalanceValue; + + auto const fromSleOpt = entryFromSle.getIssuerEncryptedBalance(); + auto const fromBuilderOpt = entryFromBuilder.getIssuerEncryptedBalance(); + + ASSERT_TRUE(fromSleOpt.has_value()); + ASSERT_TRUE(fromBuilderOpt.has_value()); + + expectEqualField(expected, *fromSleOpt, "sfIssuerEncryptedBalance"); + expectEqualField(expected, *fromBuilderOpt, "sfIssuerEncryptedBalance"); + } + + { + auto const& expected = auditorEncryptedBalanceValue; + + auto const fromSleOpt = entryFromSle.getAuditorEncryptedBalance(); + auto const fromBuilderOpt = entryFromBuilder.getAuditorEncryptedBalance(); + + ASSERT_TRUE(fromSleOpt.has_value()); + ASSERT_TRUE(fromBuilderOpt.has_value()); + + expectEqualField(expected, *fromSleOpt, "sfAuditorEncryptedBalance"); + expectEqualField(expected, *fromBuilderOpt, "sfAuditorEncryptedBalance"); + } + + { + auto const& expected = holderEncryptionKeyValue; + + auto const fromSleOpt = entryFromSle.getHolderEncryptionKey(); + auto const fromBuilderOpt = entryFromBuilder.getHolderEncryptionKey(); + + ASSERT_TRUE(fromSleOpt.has_value()); + ASSERT_TRUE(fromBuilderOpt.has_value()); + + expectEqualField(expected, *fromSleOpt, "sfHolderEncryptionKey"); + expectEqualField(expected, *fromBuilderOpt, "sfHolderEncryptionKey"); + } + EXPECT_EQ(entryFromSle.getKey(), index); EXPECT_EQ(entryFromBuilder.getKey(), index); } @@ -276,5 +426,17 @@ TEST(MPTokenTests, OptionalFieldsReturnNullopt) EXPECT_FALSE(entry.getMPTAmount().has_value()); EXPECT_FALSE(entry.hasLockedAmount()); EXPECT_FALSE(entry.getLockedAmount().has_value()); + EXPECT_FALSE(entry.hasConfidentialBalanceInbox()); + EXPECT_FALSE(entry.getConfidentialBalanceInbox().has_value()); + EXPECT_FALSE(entry.hasConfidentialBalanceSpending()); + EXPECT_FALSE(entry.getConfidentialBalanceSpending().has_value()); + EXPECT_FALSE(entry.hasConfidentialBalanceVersion()); + EXPECT_FALSE(entry.getConfidentialBalanceVersion().has_value()); + EXPECT_FALSE(entry.hasIssuerEncryptedBalance()); + EXPECT_FALSE(entry.getIssuerEncryptedBalance().has_value()); + EXPECT_FALSE(entry.hasAuditorEncryptedBalance()); + EXPECT_FALSE(entry.getAuditorEncryptedBalance().has_value()); + EXPECT_FALSE(entry.hasHolderEncryptionKey()); + EXPECT_FALSE(entry.getHolderEncryptionKey().has_value()); } } diff --git a/src/tests/libxrpl/protocol_autogen/transactions/ConfidentialMPTClawbackTests.cpp b/src/tests/libxrpl/protocol_autogen/transactions/ConfidentialMPTClawbackTests.cpp new file mode 100644 index 0000000000..a13d288bf8 --- /dev/null +++ b/src/tests/libxrpl/protocol_autogen/transactions/ConfidentialMPTClawbackTests.cpp @@ -0,0 +1,194 @@ +// Auto-generated unit tests for transaction ConfidentialMPTClawback + + +#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(TransactionsConfidentialMPTClawbackTests, BuilderSettersRoundTrip) +{ + // Generate a deterministic keypair for signing + auto const [publicKey, secretKey] = + generateKeyPair(KeyType::secp256k1, generateSeed("testConfidentialMPTClawback")); + + // 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 mPTAmountValue = canonical_UINT64(); + auto const zKProofValue = canonical_VL(); + + ConfidentialMPTClawbackBuilder builder{ + accountValue, + mPTokenIssuanceIDValue, + holderValue, + mPTAmountValue, + zKProofValue, + sequenceValue, + feeValue + }; + + // Set optional fields + + 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 = holderValue; + auto const actual = tx.getHolder(); + expectEqualField(expected, actual, "sfHolder"); + } + + { + auto const& expected = mPTAmountValue; + auto const actual = tx.getMPTAmount(); + expectEqualField(expected, actual, "sfMPTAmount"); + } + + { + auto const& expected = zKProofValue; + auto const actual = tx.getZKProof(); + expectEqualField(expected, actual, "sfZKProof"); + } + + // Verify optional fields +} + +// 2 & 4) Start from an STTx, construct a builder from it, build a new wrapper, +// and verify all fields match. +TEST(TransactionsConfidentialMPTClawbackTests, BuilderFromStTxRoundTrip) +{ + // Generate a deterministic keypair for signing + auto const [publicKey, secretKey] = + generateKeyPair(KeyType::secp256k1, generateSeed("testConfidentialMPTClawbackFromTx")); + + // 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 mPTAmountValue = canonical_UINT64(); + auto const zKProofValue = canonical_VL(); + + // Build an initial transaction + ConfidentialMPTClawbackBuilder initialBuilder{ + accountValue, + mPTokenIssuanceIDValue, + holderValue, + mPTAmountValue, + zKProofValue, + sequenceValue, + feeValue + }; + + + auto initialTx = initialBuilder.build(publicKey, secretKey); + + // Create builder from existing STTx + ConfidentialMPTClawbackBuilder 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 = holderValue; + auto const actual = rebuiltTx.getHolder(); + expectEqualField(expected, actual, "sfHolder"); + } + + { + auto const& expected = mPTAmountValue; + auto const actual = rebuiltTx.getMPTAmount(); + expectEqualField(expected, actual, "sfMPTAmount"); + } + + { + auto const& expected = zKProofValue; + auto const actual = rebuiltTx.getZKProof(); + expectEqualField(expected, actual, "sfZKProof"); + } + + // Verify optional fields +} + +// 3) Verify wrapper throws when constructed from wrong transaction type. +TEST(TransactionsConfidentialMPTClawbackTests, 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(ConfidentialMPTClawback{wrongTx.getSTTx()}, std::runtime_error); +} + +// 4) Verify builder throws when constructed from wrong transaction type. +TEST(TransactionsConfidentialMPTClawbackTests, 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(ConfidentialMPTClawbackBuilder{wrongTx.getSTTx()}, std::runtime_error); +} + + +} diff --git a/src/tests/libxrpl/protocol_autogen/transactions/ConfidentialMPTConvertBackTests.cpp b/src/tests/libxrpl/protocol_autogen/transactions/ConfidentialMPTConvertBackTests.cpp new file mode 100644 index 0000000000..746ca2e010 --- /dev/null +++ b/src/tests/libxrpl/protocol_autogen/transactions/ConfidentialMPTConvertBackTests.cpp @@ -0,0 +1,303 @@ +// Auto-generated unit tests for transaction ConfidentialMPTConvertBack + + +#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(TransactionsConfidentialMPTConvertBackTests, BuilderSettersRoundTrip) +{ + // Generate a deterministic keypair for signing + auto const [publicKey, secretKey] = + generateKeyPair(KeyType::secp256k1, generateSeed("testConfidentialMPTConvertBack")); + + // 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 mPTAmountValue = canonical_UINT64(); + auto const holderEncryptedAmountValue = canonical_VL(); + auto const issuerEncryptedAmountValue = canonical_VL(); + auto const auditorEncryptedAmountValue = canonical_VL(); + auto const blindingFactorValue = canonical_UINT256(); + auto const zKProofValue = canonical_VL(); + auto const balanceCommitmentValue = canonical_VL(); + + ConfidentialMPTConvertBackBuilder builder{ + accountValue, + mPTokenIssuanceIDValue, + mPTAmountValue, + holderEncryptedAmountValue, + issuerEncryptedAmountValue, + blindingFactorValue, + zKProofValue, + balanceCommitmentValue, + sequenceValue, + feeValue + }; + + // Set optional fields + 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 = mPTAmountValue; + auto const actual = tx.getMPTAmount(); + expectEqualField(expected, actual, "sfMPTAmount"); + } + + { + auto const& expected = holderEncryptedAmountValue; + auto const actual = tx.getHolderEncryptedAmount(); + expectEqualField(expected, actual, "sfHolderEncryptedAmount"); + } + + { + auto const& expected = issuerEncryptedAmountValue; + auto const actual = tx.getIssuerEncryptedAmount(); + expectEqualField(expected, actual, "sfIssuerEncryptedAmount"); + } + + { + auto const& expected = blindingFactorValue; + auto const actual = tx.getBlindingFactor(); + expectEqualField(expected, actual, "sfBlindingFactor"); + } + + { + auto const& expected = zKProofValue; + auto const actual = tx.getZKProof(); + expectEqualField(expected, actual, "sfZKProof"); + } + + { + auto const& expected = balanceCommitmentValue; + auto const actual = tx.getBalanceCommitment(); + expectEqualField(expected, actual, "sfBalanceCommitment"); + } + + // Verify optional fields + { + 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(TransactionsConfidentialMPTConvertBackTests, BuilderFromStTxRoundTrip) +{ + // Generate a deterministic keypair for signing + auto const [publicKey, secretKey] = + generateKeyPair(KeyType::secp256k1, generateSeed("testConfidentialMPTConvertBackFromTx")); + + // 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 mPTAmountValue = canonical_UINT64(); + auto const holderEncryptedAmountValue = canonical_VL(); + auto const issuerEncryptedAmountValue = canonical_VL(); + auto const auditorEncryptedAmountValue = canonical_VL(); + auto const blindingFactorValue = canonical_UINT256(); + auto const zKProofValue = canonical_VL(); + auto const balanceCommitmentValue = canonical_VL(); + + // Build an initial transaction + ConfidentialMPTConvertBackBuilder initialBuilder{ + accountValue, + mPTokenIssuanceIDValue, + mPTAmountValue, + holderEncryptedAmountValue, + issuerEncryptedAmountValue, + blindingFactorValue, + zKProofValue, + balanceCommitmentValue, + sequenceValue, + feeValue + }; + + initialBuilder.setAuditorEncryptedAmount(auditorEncryptedAmountValue); + + auto initialTx = initialBuilder.build(publicKey, secretKey); + + // Create builder from existing STTx + ConfidentialMPTConvertBackBuilder 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 = mPTAmountValue; + auto const actual = rebuiltTx.getMPTAmount(); + expectEqualField(expected, actual, "sfMPTAmount"); + } + + { + auto const& expected = holderEncryptedAmountValue; + auto const actual = rebuiltTx.getHolderEncryptedAmount(); + expectEqualField(expected, actual, "sfHolderEncryptedAmount"); + } + + { + auto const& expected = issuerEncryptedAmountValue; + auto const actual = rebuiltTx.getIssuerEncryptedAmount(); + expectEqualField(expected, actual, "sfIssuerEncryptedAmount"); + } + + { + auto const& expected = blindingFactorValue; + auto const actual = rebuiltTx.getBlindingFactor(); + expectEqualField(expected, actual, "sfBlindingFactor"); + } + + { + auto const& expected = zKProofValue; + auto const actual = rebuiltTx.getZKProof(); + expectEqualField(expected, actual, "sfZKProof"); + } + + { + auto const& expected = balanceCommitmentValue; + auto const actual = rebuiltTx.getBalanceCommitment(); + expectEqualField(expected, actual, "sfBalanceCommitment"); + } + + // Verify optional fields + { + 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(TransactionsConfidentialMPTConvertBackTests, 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(ConfidentialMPTConvertBack{wrongTx.getSTTx()}, std::runtime_error); +} + +// 4) Verify builder throws when constructed from wrong transaction type. +TEST(TransactionsConfidentialMPTConvertBackTests, 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(ConfidentialMPTConvertBackBuilder{wrongTx.getSTTx()}, std::runtime_error); +} + +// 5) Build with only required fields and verify optional fields return nullopt. +TEST(TransactionsConfidentialMPTConvertBackTests, OptionalFieldsReturnNullopt) +{ + // Generate a deterministic keypair for signing + auto const [publicKey, secretKey] = + generateKeyPair(KeyType::secp256k1, generateSeed("testConfidentialMPTConvertBackNullopt")); + + // 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 mPTAmountValue = canonical_UINT64(); + auto const holderEncryptedAmountValue = canonical_VL(); + auto const issuerEncryptedAmountValue = canonical_VL(); + auto const blindingFactorValue = canonical_UINT256(); + auto const zKProofValue = canonical_VL(); + auto const balanceCommitmentValue = canonical_VL(); + + ConfidentialMPTConvertBackBuilder builder{ + accountValue, + mPTokenIssuanceIDValue, + mPTAmountValue, + holderEncryptedAmountValue, + issuerEncryptedAmountValue, + blindingFactorValue, + zKProofValue, + balanceCommitmentValue, + sequenceValue, + feeValue + }; + + // Do NOT set optional fields + + auto tx = builder.build(publicKey, secretKey); + + // Verify optional fields are not present + EXPECT_FALSE(tx.hasAuditorEncryptedAmount()); + EXPECT_FALSE(tx.getAuditorEncryptedAmount().has_value()); +} + +} diff --git a/src/tests/libxrpl/protocol_autogen/transactions/ConfidentialMPTConvertTests.cpp b/src/tests/libxrpl/protocol_autogen/transactions/ConfidentialMPTConvertTests.cpp new file mode 100644 index 0000000000..ca1f2dbff5 --- /dev/null +++ b/src/tests/libxrpl/protocol_autogen/transactions/ConfidentialMPTConvertTests.cpp @@ -0,0 +1,309 @@ +// Auto-generated unit tests for transaction ConfidentialMPTConvert + + +#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(TransactionsConfidentialMPTConvertTests, BuilderSettersRoundTrip) +{ + // Generate a deterministic keypair for signing + auto const [publicKey, secretKey] = + generateKeyPair(KeyType::secp256k1, generateSeed("testConfidentialMPTConvert")); + + // 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 mPTAmountValue = canonical_UINT64(); + auto const holderEncryptionKeyValue = canonical_VL(); + auto const holderEncryptedAmountValue = canonical_VL(); + auto const issuerEncryptedAmountValue = canonical_VL(); + auto const auditorEncryptedAmountValue = canonical_VL(); + auto const blindingFactorValue = canonical_UINT256(); + auto const zKProofValue = canonical_VL(); + + ConfidentialMPTConvertBuilder builder{ + accountValue, + mPTokenIssuanceIDValue, + mPTAmountValue, + holderEncryptedAmountValue, + issuerEncryptedAmountValue, + blindingFactorValue, + sequenceValue, + feeValue + }; + + // Set optional fields + builder.setHolderEncryptionKey(holderEncryptionKeyValue); + builder.setAuditorEncryptedAmount(auditorEncryptedAmountValue); + builder.setZKProof(zKProofValue); + + auto tx = builder.build(publicKey, secretKey); + + std::string reason; + EXPECT_TRUE(tx.validate(reason)) << reason; + + // Verify signing was applied + EXPECT_FALSE(tx.getSigningPubKey().empty()); + EXPECT_TRUE(tx.hasTxnSignature()); + + // Verify common fields + EXPECT_EQ(tx.getAccount(), accountValue); + EXPECT_EQ(tx.getSequence(), sequenceValue); + EXPECT_EQ(tx.getFee(), feeValue); + + // Verify required fields + { + auto const& expected = mPTokenIssuanceIDValue; + auto const actual = tx.getMPTokenIssuanceID(); + expectEqualField(expected, actual, "sfMPTokenIssuanceID"); + } + + { + auto const& expected = mPTAmountValue; + auto const actual = tx.getMPTAmount(); + expectEqualField(expected, actual, "sfMPTAmount"); + } + + { + auto const& expected = holderEncryptedAmountValue; + auto const actual = tx.getHolderEncryptedAmount(); + expectEqualField(expected, actual, "sfHolderEncryptedAmount"); + } + + { + auto const& expected = issuerEncryptedAmountValue; + auto const actual = tx.getIssuerEncryptedAmount(); + expectEqualField(expected, actual, "sfIssuerEncryptedAmount"); + } + + { + auto const& expected = blindingFactorValue; + auto const actual = tx.getBlindingFactor(); + expectEqualField(expected, actual, "sfBlindingFactor"); + } + + // Verify optional fields + { + auto const& expected = holderEncryptionKeyValue; + auto const actualOpt = tx.getHolderEncryptionKey(); + ASSERT_TRUE(actualOpt.has_value()) << "Optional field sfHolderEncryptionKey should be present"; + expectEqualField(expected, *actualOpt, "sfHolderEncryptionKey"); + EXPECT_TRUE(tx.hasHolderEncryptionKey()); + } + + { + auto const& expected = auditorEncryptedAmountValue; + auto const actualOpt = tx.getAuditorEncryptedAmount(); + ASSERT_TRUE(actualOpt.has_value()) << "Optional field sfAuditorEncryptedAmount should be present"; + expectEqualField(expected, *actualOpt, "sfAuditorEncryptedAmount"); + EXPECT_TRUE(tx.hasAuditorEncryptedAmount()); + } + + { + auto const& expected = zKProofValue; + auto const actualOpt = tx.getZKProof(); + ASSERT_TRUE(actualOpt.has_value()) << "Optional field sfZKProof should be present"; + expectEqualField(expected, *actualOpt, "sfZKProof"); + EXPECT_TRUE(tx.hasZKProof()); + } + +} + +// 2 & 4) Start from an STTx, construct a builder from it, build a new wrapper, +// and verify all fields match. +TEST(TransactionsConfidentialMPTConvertTests, BuilderFromStTxRoundTrip) +{ + // Generate a deterministic keypair for signing + auto const [publicKey, secretKey] = + generateKeyPair(KeyType::secp256k1, generateSeed("testConfidentialMPTConvertFromTx")); + + // 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 mPTAmountValue = canonical_UINT64(); + auto const holderEncryptionKeyValue = canonical_VL(); + auto const holderEncryptedAmountValue = canonical_VL(); + auto const issuerEncryptedAmountValue = canonical_VL(); + auto const auditorEncryptedAmountValue = canonical_VL(); + auto const blindingFactorValue = canonical_UINT256(); + auto const zKProofValue = canonical_VL(); + + // Build an initial transaction + ConfidentialMPTConvertBuilder initialBuilder{ + accountValue, + mPTokenIssuanceIDValue, + mPTAmountValue, + holderEncryptedAmountValue, + issuerEncryptedAmountValue, + blindingFactorValue, + sequenceValue, + feeValue + }; + + initialBuilder.setHolderEncryptionKey(holderEncryptionKeyValue); + initialBuilder.setAuditorEncryptedAmount(auditorEncryptedAmountValue); + initialBuilder.setZKProof(zKProofValue); + + auto initialTx = initialBuilder.build(publicKey, secretKey); + + // Create builder from existing STTx + ConfidentialMPTConvertBuilder 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 = mPTAmountValue; + auto const actual = rebuiltTx.getMPTAmount(); + expectEqualField(expected, actual, "sfMPTAmount"); + } + + { + auto const& expected = holderEncryptedAmountValue; + auto const actual = rebuiltTx.getHolderEncryptedAmount(); + expectEqualField(expected, actual, "sfHolderEncryptedAmount"); + } + + { + auto const& expected = issuerEncryptedAmountValue; + auto const actual = rebuiltTx.getIssuerEncryptedAmount(); + expectEqualField(expected, actual, "sfIssuerEncryptedAmount"); + } + + { + auto const& expected = blindingFactorValue; + auto const actual = rebuiltTx.getBlindingFactor(); + expectEqualField(expected, actual, "sfBlindingFactor"); + } + + // Verify optional fields + { + auto const& expected = holderEncryptionKeyValue; + auto const actualOpt = rebuiltTx.getHolderEncryptionKey(); + ASSERT_TRUE(actualOpt.has_value()) << "Optional field sfHolderEncryptionKey should be present"; + expectEqualField(expected, *actualOpt, "sfHolderEncryptionKey"); + } + + { + auto const& expected = auditorEncryptedAmountValue; + auto const actualOpt = rebuiltTx.getAuditorEncryptedAmount(); + ASSERT_TRUE(actualOpt.has_value()) << "Optional field sfAuditorEncryptedAmount should be present"; + expectEqualField(expected, *actualOpt, "sfAuditorEncryptedAmount"); + } + + { + auto const& expected = zKProofValue; + auto const actualOpt = rebuiltTx.getZKProof(); + ASSERT_TRUE(actualOpt.has_value()) << "Optional field sfZKProof should be present"; + expectEqualField(expected, *actualOpt, "sfZKProof"); + } + +} + +// 3) Verify wrapper throws when constructed from wrong transaction type. +TEST(TransactionsConfidentialMPTConvertTests, 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(ConfidentialMPTConvert{wrongTx.getSTTx()}, std::runtime_error); +} + +// 4) Verify builder throws when constructed from wrong transaction type. +TEST(TransactionsConfidentialMPTConvertTests, 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(ConfidentialMPTConvertBuilder{wrongTx.getSTTx()}, std::runtime_error); +} + +// 5) Build with only required fields and verify optional fields return nullopt. +TEST(TransactionsConfidentialMPTConvertTests, OptionalFieldsReturnNullopt) +{ + // Generate a deterministic keypair for signing + auto const [publicKey, secretKey] = + generateKeyPair(KeyType::secp256k1, generateSeed("testConfidentialMPTConvertNullopt")); + + // 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 mPTAmountValue = canonical_UINT64(); + auto const holderEncryptedAmountValue = canonical_VL(); + auto const issuerEncryptedAmountValue = canonical_VL(); + auto const blindingFactorValue = canonical_UINT256(); + + ConfidentialMPTConvertBuilder builder{ + accountValue, + mPTokenIssuanceIDValue, + mPTAmountValue, + holderEncryptedAmountValue, + issuerEncryptedAmountValue, + blindingFactorValue, + sequenceValue, + feeValue + }; + + // Do NOT set optional fields + + auto tx = builder.build(publicKey, secretKey); + + // Verify optional fields are not present + EXPECT_FALSE(tx.hasHolderEncryptionKey()); + EXPECT_FALSE(tx.getHolderEncryptionKey().has_value()); + EXPECT_FALSE(tx.hasAuditorEncryptedAmount()); + EXPECT_FALSE(tx.getAuditorEncryptedAmount().has_value()); + EXPECT_FALSE(tx.hasZKProof()); + EXPECT_FALSE(tx.getZKProof().has_value()); +} + +} diff --git a/src/tests/libxrpl/protocol_autogen/transactions/ConfidentialMPTMergeInboxTests.cpp b/src/tests/libxrpl/protocol_autogen/transactions/ConfidentialMPTMergeInboxTests.cpp new file mode 100644 index 0000000000..53a311ba28 --- /dev/null +++ b/src/tests/libxrpl/protocol_autogen/transactions/ConfidentialMPTMergeInboxTests.cpp @@ -0,0 +1,146 @@ +// Auto-generated unit tests for transaction ConfidentialMPTMergeInbox + + +#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(TransactionsConfidentialMPTMergeInboxTests, BuilderSettersRoundTrip) +{ + // Generate a deterministic keypair for signing + auto const [publicKey, secretKey] = + generateKeyPair(KeyType::secp256k1, generateSeed("testConfidentialMPTMergeInbox")); + + // 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(); + + ConfidentialMPTMergeInboxBuilder builder{ + accountValue, + mPTokenIssuanceIDValue, + sequenceValue, + feeValue + }; + + // Set optional fields + + auto tx = builder.build(publicKey, secretKey); + + std::string reason; + EXPECT_TRUE(tx.validate(reason)) << reason; + + // Verify signing was applied + EXPECT_FALSE(tx.getSigningPubKey().empty()); + EXPECT_TRUE(tx.hasTxnSignature()); + + // Verify common fields + EXPECT_EQ(tx.getAccount(), accountValue); + EXPECT_EQ(tx.getSequence(), sequenceValue); + EXPECT_EQ(tx.getFee(), feeValue); + + // Verify required fields + { + auto const& expected = mPTokenIssuanceIDValue; + auto const actual = tx.getMPTokenIssuanceID(); + expectEqualField(expected, actual, "sfMPTokenIssuanceID"); + } + + // Verify optional fields +} + +// 2 & 4) Start from an STTx, construct a builder from it, build a new wrapper, +// and verify all fields match. +TEST(TransactionsConfidentialMPTMergeInboxTests, BuilderFromStTxRoundTrip) +{ + // Generate a deterministic keypair for signing + auto const [publicKey, secretKey] = + generateKeyPair(KeyType::secp256k1, generateSeed("testConfidentialMPTMergeInboxFromTx")); + + // 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(); + + // Build an initial transaction + ConfidentialMPTMergeInboxBuilder initialBuilder{ + accountValue, + mPTokenIssuanceIDValue, + sequenceValue, + feeValue + }; + + + auto initialTx = initialBuilder.build(publicKey, secretKey); + + // Create builder from existing STTx + ConfidentialMPTMergeInboxBuilder builderFromTx{initialTx.getSTTx()}; + + auto rebuiltTx = builderFromTx.build(publicKey, secretKey); + + std::string reason; + EXPECT_TRUE(rebuiltTx.validate(reason)) << reason; + + // Verify common fields + EXPECT_EQ(rebuiltTx.getAccount(), accountValue); + EXPECT_EQ(rebuiltTx.getSequence(), sequenceValue); + EXPECT_EQ(rebuiltTx.getFee(), feeValue); + + // Verify required fields + { + auto const& expected = mPTokenIssuanceIDValue; + auto const actual = rebuiltTx.getMPTokenIssuanceID(); + expectEqualField(expected, actual, "sfMPTokenIssuanceID"); + } + + // Verify optional fields +} + +// 3) Verify wrapper throws when constructed from wrong transaction type. +TEST(TransactionsConfidentialMPTMergeInboxTests, 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(ConfidentialMPTMergeInbox{wrongTx.getSTTx()}, std::runtime_error); +} + +// 4) Verify builder throws when constructed from wrong transaction type. +TEST(TransactionsConfidentialMPTMergeInboxTests, 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(ConfidentialMPTMergeInboxBuilder{wrongTx.getSTTx()}, std::runtime_error); +} + + +} diff --git a/src/tests/libxrpl/protocol_autogen/transactions/ConfidentialMPTSendTests.cpp b/src/tests/libxrpl/protocol_autogen/transactions/ConfidentialMPTSendTests.cpp new file mode 100644 index 0000000000..2a51bdc64b --- /dev/null +++ b/src/tests/libxrpl/protocol_autogen/transactions/ConfidentialMPTSendTests.cpp @@ -0,0 +1,342 @@ +// Auto-generated unit tests for transaction ConfidentialMPTSend + + +#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(TransactionsConfidentialMPTSendTests, BuilderSettersRoundTrip) +{ + // Generate a deterministic keypair for signing + auto const [publicKey, secretKey] = + generateKeyPair(KeyType::secp256k1, generateSeed("testConfidentialMPTSend")); + + // 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 destinationValue = canonical_ACCOUNT(); + auto const senderEncryptedAmountValue = canonical_VL(); + auto const destinationEncryptedAmountValue = canonical_VL(); + auto const issuerEncryptedAmountValue = canonical_VL(); + auto const auditorEncryptedAmountValue = canonical_VL(); + auto const zKProofValue = canonical_VL(); + auto const amountCommitmentValue = canonical_VL(); + auto const balanceCommitmentValue = canonical_VL(); + auto const credentialIDsValue = canonical_VECTOR256(); + + ConfidentialMPTSendBuilder builder{ + accountValue, + mPTokenIssuanceIDValue, + destinationValue, + senderEncryptedAmountValue, + destinationEncryptedAmountValue, + issuerEncryptedAmountValue, + zKProofValue, + amountCommitmentValue, + balanceCommitmentValue, + sequenceValue, + feeValue + }; + + // Set optional fields + builder.setAuditorEncryptedAmount(auditorEncryptedAmountValue); + builder.setCredentialIDs(credentialIDsValue); + + 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 = destinationValue; + auto const actual = tx.getDestination(); + expectEqualField(expected, actual, "sfDestination"); + } + + { + auto const& expected = senderEncryptedAmountValue; + auto const actual = tx.getSenderEncryptedAmount(); + expectEqualField(expected, actual, "sfSenderEncryptedAmount"); + } + + { + auto const& expected = destinationEncryptedAmountValue; + auto const actual = tx.getDestinationEncryptedAmount(); + expectEqualField(expected, actual, "sfDestinationEncryptedAmount"); + } + + { + auto const& expected = issuerEncryptedAmountValue; + auto const actual = tx.getIssuerEncryptedAmount(); + expectEqualField(expected, actual, "sfIssuerEncryptedAmount"); + } + + { + auto const& expected = zKProofValue; + auto const actual = tx.getZKProof(); + expectEqualField(expected, actual, "sfZKProof"); + } + + { + auto const& expected = amountCommitmentValue; + auto const actual = tx.getAmountCommitment(); + expectEqualField(expected, actual, "sfAmountCommitment"); + } + + { + auto const& expected = balanceCommitmentValue; + auto const actual = tx.getBalanceCommitment(); + expectEqualField(expected, actual, "sfBalanceCommitment"); + } + + // Verify optional fields + { + auto const& expected = auditorEncryptedAmountValue; + auto const actualOpt = tx.getAuditorEncryptedAmount(); + ASSERT_TRUE(actualOpt.has_value()) << "Optional field sfAuditorEncryptedAmount should be present"; + expectEqualField(expected, *actualOpt, "sfAuditorEncryptedAmount"); + EXPECT_TRUE(tx.hasAuditorEncryptedAmount()); + } + + { + auto const& expected = credentialIDsValue; + auto const actualOpt = tx.getCredentialIDs(); + ASSERT_TRUE(actualOpt.has_value()) << "Optional field sfCredentialIDs should be present"; + expectEqualField(expected, *actualOpt, "sfCredentialIDs"); + EXPECT_TRUE(tx.hasCredentialIDs()); + } + +} + +// 2 & 4) Start from an STTx, construct a builder from it, build a new wrapper, +// and verify all fields match. +TEST(TransactionsConfidentialMPTSendTests, BuilderFromStTxRoundTrip) +{ + // Generate a deterministic keypair for signing + auto const [publicKey, secretKey] = + generateKeyPair(KeyType::secp256k1, generateSeed("testConfidentialMPTSendFromTx")); + + // 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 destinationValue = canonical_ACCOUNT(); + auto const senderEncryptedAmountValue = canonical_VL(); + auto const destinationEncryptedAmountValue = canonical_VL(); + auto const issuerEncryptedAmountValue = canonical_VL(); + auto const auditorEncryptedAmountValue = canonical_VL(); + auto const zKProofValue = canonical_VL(); + auto const amountCommitmentValue = canonical_VL(); + auto const balanceCommitmentValue = canonical_VL(); + auto const credentialIDsValue = canonical_VECTOR256(); + + // Build an initial transaction + ConfidentialMPTSendBuilder initialBuilder{ + accountValue, + mPTokenIssuanceIDValue, + destinationValue, + senderEncryptedAmountValue, + destinationEncryptedAmountValue, + issuerEncryptedAmountValue, + zKProofValue, + amountCommitmentValue, + balanceCommitmentValue, + sequenceValue, + feeValue + }; + + initialBuilder.setAuditorEncryptedAmount(auditorEncryptedAmountValue); + initialBuilder.setCredentialIDs(credentialIDsValue); + + auto initialTx = initialBuilder.build(publicKey, secretKey); + + // Create builder from existing STTx + ConfidentialMPTSendBuilder 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 = destinationValue; + auto const actual = rebuiltTx.getDestination(); + expectEqualField(expected, actual, "sfDestination"); + } + + { + auto const& expected = senderEncryptedAmountValue; + auto const actual = rebuiltTx.getSenderEncryptedAmount(); + expectEqualField(expected, actual, "sfSenderEncryptedAmount"); + } + + { + auto const& expected = destinationEncryptedAmountValue; + auto const actual = rebuiltTx.getDestinationEncryptedAmount(); + expectEqualField(expected, actual, "sfDestinationEncryptedAmount"); + } + + { + auto const& expected = issuerEncryptedAmountValue; + auto const actual = rebuiltTx.getIssuerEncryptedAmount(); + expectEqualField(expected, actual, "sfIssuerEncryptedAmount"); + } + + { + auto const& expected = zKProofValue; + auto const actual = rebuiltTx.getZKProof(); + expectEqualField(expected, actual, "sfZKProof"); + } + + { + auto const& expected = amountCommitmentValue; + auto const actual = rebuiltTx.getAmountCommitment(); + expectEqualField(expected, actual, "sfAmountCommitment"); + } + + { + auto const& expected = balanceCommitmentValue; + auto const actual = rebuiltTx.getBalanceCommitment(); + expectEqualField(expected, actual, "sfBalanceCommitment"); + } + + // Verify optional fields + { + auto const& expected = auditorEncryptedAmountValue; + auto const actualOpt = rebuiltTx.getAuditorEncryptedAmount(); + ASSERT_TRUE(actualOpt.has_value()) << "Optional field sfAuditorEncryptedAmount should be present"; + expectEqualField(expected, *actualOpt, "sfAuditorEncryptedAmount"); + } + + { + auto const& expected = credentialIDsValue; + auto const actualOpt = rebuiltTx.getCredentialIDs(); + ASSERT_TRUE(actualOpt.has_value()) << "Optional field sfCredentialIDs should be present"; + expectEqualField(expected, *actualOpt, "sfCredentialIDs"); + } + +} + +// 3) Verify wrapper throws when constructed from wrong transaction type. +TEST(TransactionsConfidentialMPTSendTests, 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(ConfidentialMPTSend{wrongTx.getSTTx()}, std::runtime_error); +} + +// 4) Verify builder throws when constructed from wrong transaction type. +TEST(TransactionsConfidentialMPTSendTests, 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(ConfidentialMPTSendBuilder{wrongTx.getSTTx()}, std::runtime_error); +} + +// 5) Build with only required fields and verify optional fields return nullopt. +TEST(TransactionsConfidentialMPTSendTests, OptionalFieldsReturnNullopt) +{ + // Generate a deterministic keypair for signing + auto const [publicKey, secretKey] = + generateKeyPair(KeyType::secp256k1, generateSeed("testConfidentialMPTSendNullopt")); + + // 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 destinationValue = canonical_ACCOUNT(); + auto const senderEncryptedAmountValue = canonical_VL(); + auto const destinationEncryptedAmountValue = canonical_VL(); + auto const issuerEncryptedAmountValue = canonical_VL(); + auto const zKProofValue = canonical_VL(); + auto const amountCommitmentValue = canonical_VL(); + auto const balanceCommitmentValue = canonical_VL(); + + ConfidentialMPTSendBuilder builder{ + accountValue, + mPTokenIssuanceIDValue, + destinationValue, + senderEncryptedAmountValue, + destinationEncryptedAmountValue, + issuerEncryptedAmountValue, + zKProofValue, + amountCommitmentValue, + balanceCommitmentValue, + sequenceValue, + feeValue + }; + + // Do NOT set optional fields + + auto tx = builder.build(publicKey, secretKey); + + // Verify optional fields are not present + EXPECT_FALSE(tx.hasAuditorEncryptedAmount()); + EXPECT_FALSE(tx.getAuditorEncryptedAmount().has_value()); + EXPECT_FALSE(tx.hasCredentialIDs()); + EXPECT_FALSE(tx.getCredentialIDs().has_value()); +} + +} diff --git a/src/tests/libxrpl/protocol_autogen/transactions/MPTokenIssuanceSetTests.cpp b/src/tests/libxrpl/protocol_autogen/transactions/MPTokenIssuanceSetTests.cpp index 2ed9e42184..57138bd9dc 100644 --- a/src/tests/libxrpl/protocol_autogen/transactions/MPTokenIssuanceSetTests.cpp +++ b/src/tests/libxrpl/protocol_autogen/transactions/MPTokenIssuanceSetTests.cpp @@ -35,6 +35,8 @@ TEST(TransactionsMPTokenIssuanceSetTests, BuilderSettersRoundTrip) auto const mPTokenMetadataValue = canonical_VL(); auto const transferFeeValue = canonical_UINT16(); auto const mutableFlagsValue = canonical_UINT32(); + auto const issuerEncryptionKeyValue = canonical_VL(); + auto const auditorEncryptionKeyValue = canonical_VL(); MPTokenIssuanceSetBuilder builder{ accountValue, @@ -49,6 +51,8 @@ TEST(TransactionsMPTokenIssuanceSetTests, BuilderSettersRoundTrip) builder.setMPTokenMetadata(mPTokenMetadataValue); builder.setTransferFee(transferFeeValue); builder.setMutableFlags(mutableFlagsValue); + builder.setIssuerEncryptionKey(issuerEncryptionKeyValue); + builder.setAuditorEncryptionKey(auditorEncryptionKeyValue); auto tx = builder.build(publicKey, secretKey); @@ -112,6 +116,22 @@ TEST(TransactionsMPTokenIssuanceSetTests, BuilderSettersRoundTrip) EXPECT_TRUE(tx.hasMutableFlags()); } + { + auto const& expected = issuerEncryptionKeyValue; + auto const actualOpt = tx.getIssuerEncryptionKey(); + ASSERT_TRUE(actualOpt.has_value()) << "Optional field sfIssuerEncryptionKey should be present"; + expectEqualField(expected, *actualOpt, "sfIssuerEncryptionKey"); + EXPECT_TRUE(tx.hasIssuerEncryptionKey()); + } + + { + auto const& expected = auditorEncryptionKeyValue; + auto const actualOpt = tx.getAuditorEncryptionKey(); + ASSERT_TRUE(actualOpt.has_value()) << "Optional field sfAuditorEncryptionKey should be present"; + expectEqualField(expected, *actualOpt, "sfAuditorEncryptionKey"); + EXPECT_TRUE(tx.hasAuditorEncryptionKey()); + } + } // 2 & 4) Start from an STTx, construct a builder from it, build a new wrapper, @@ -134,6 +154,8 @@ TEST(TransactionsMPTokenIssuanceSetTests, BuilderFromStTxRoundTrip) auto const mPTokenMetadataValue = canonical_VL(); auto const transferFeeValue = canonical_UINT16(); auto const mutableFlagsValue = canonical_UINT32(); + auto const issuerEncryptionKeyValue = canonical_VL(); + auto const auditorEncryptionKeyValue = canonical_VL(); // Build an initial transaction MPTokenIssuanceSetBuilder initialBuilder{ @@ -148,6 +170,8 @@ TEST(TransactionsMPTokenIssuanceSetTests, BuilderFromStTxRoundTrip) initialBuilder.setMPTokenMetadata(mPTokenMetadataValue); initialBuilder.setTransferFee(transferFeeValue); initialBuilder.setMutableFlags(mutableFlagsValue); + initialBuilder.setIssuerEncryptionKey(issuerEncryptionKeyValue); + initialBuilder.setAuditorEncryptionKey(auditorEncryptionKeyValue); auto initialTx = initialBuilder.build(publicKey, secretKey); @@ -207,6 +231,20 @@ TEST(TransactionsMPTokenIssuanceSetTests, BuilderFromStTxRoundTrip) expectEqualField(expected, *actualOpt, "sfMutableFlags"); } + { + auto const& expected = issuerEncryptionKeyValue; + auto const actualOpt = rebuiltTx.getIssuerEncryptionKey(); + ASSERT_TRUE(actualOpt.has_value()) << "Optional field sfIssuerEncryptionKey should be present"; + expectEqualField(expected, *actualOpt, "sfIssuerEncryptionKey"); + } + + { + auto const& expected = auditorEncryptionKeyValue; + auto const actualOpt = rebuiltTx.getAuditorEncryptionKey(); + ASSERT_TRUE(actualOpt.has_value()) << "Optional field sfAuditorEncryptionKey should be present"; + expectEqualField(expected, *actualOpt, "sfAuditorEncryptionKey"); + } + } // 3) Verify wrapper throws when constructed from wrong transaction type. @@ -274,6 +312,10 @@ TEST(TransactionsMPTokenIssuanceSetTests, OptionalFieldsReturnNullopt) EXPECT_FALSE(tx.getTransferFee().has_value()); EXPECT_FALSE(tx.hasMutableFlags()); EXPECT_FALSE(tx.getMutableFlags().has_value()); + EXPECT_FALSE(tx.hasIssuerEncryptionKey()); + EXPECT_FALSE(tx.getIssuerEncryptionKey().has_value()); + EXPECT_FALSE(tx.hasAuditorEncryptionKey()); + EXPECT_FALSE(tx.getAuditorEncryptionKey().has_value()); } }