From 6ad60d71419a742e24163699df11eadefa6e218d Mon Sep 17 00:00:00 2001 From: yinyiqian1 Date: Fri, 20 Feb 2026 14:18:34 -0500 Subject: [PATCH] Support Range Proof for ConfidentialMPTSend (#6404) - proving send amount m is in the range [0, 2^64) - proving remaining balance b-m is in the range [0, 2^64) --- cspell.config.yaml | 2 + include/xrpl/protocol/ConfidentialTransfer.h | 18 +++++ src/libxrpl/protocol/ConfidentialTransfer.cpp | 35 +++++++++ src/test/app/ConfidentialTransfer_test.cpp | 71 ++++++++++++++++++- src/test/jtx/impl/mpt.cpp | 25 ++++++- .../app/tx/detail/ConfidentialMPTSend.cpp | 57 ++++++++++++--- 6 files changed, 195 insertions(+), 13 deletions(-) diff --git a/cspell.config.yaml b/cspell.config.yaml index 8e756ffc93..25d97d7c39 100644 --- a/cspell.config.yaml +++ b/cspell.config.yaml @@ -54,6 +54,7 @@ words: - autobridging - bimap - bindir + - blindings - bookdir - Bougalis - Britto @@ -249,6 +250,7 @@ words: - stvar - stvector - stxchainattestations + - summands - superpeer - superpeers - takergets diff --git a/include/xrpl/protocol/ConfidentialTransfer.h b/include/xrpl/protocol/ConfidentialTransfer.h index 5698577d20..a7f59773f4 100644 --- a/include/xrpl/protocol/ConfidentialTransfer.h +++ b/include/xrpl/protocol/ConfidentialTransfer.h @@ -469,6 +469,24 @@ verifyAggregatedBulletproof( std::vector const& compressedCommitments, uint256 const& contextHash); +/** + * @brief Computes the remainder commitment for ConfidentialMPTSend. + * + * Given a balance commitment PC_bal = m_bal*G + rho_bal*H and an amount + * commitment PC_amt = m_amt*G + rho_amt*H, this function computes: + * PC_rem = PC_bal - PC_amt = (m_bal - m_amt)*G + (rho_bal - rho_amt)*H + * + * This derived commitment is used in an aggregated range proof to ensure + * the sender maintains a non-negative balance (m_bal - m_amt >= 0). + * + * @param balanceCommitment The compressed Pedersen commitment to the balance (33 bytes). + * @param amountCommitment The compressed Pedersen commitment to the amount (33 bytes). + * @param out Output buffer for the resulting remainder commitment (33 bytes). + * @return tesSUCCESS on success, tecINTERNAL on failure. + */ +TER +computeSendRemainder(Slice const& balanceCommitment, Slice const& amountCommitment, Buffer& out); + /** * @brief Computes the remainder commitment for ConvertBack. * diff --git a/src/libxrpl/protocol/ConfidentialTransfer.cpp b/src/libxrpl/protocol/ConfidentialTransfer.cpp index a50eb8b265..cf4503d1f0 100644 --- a/src/libxrpl/protocol/ConfidentialTransfer.cpp +++ b/src/libxrpl/protocol/ConfidentialTransfer.cpp @@ -579,6 +579,41 @@ verifyAggregatedBulletproof( return tesSUCCESS; } +TER +computeSendRemainder(Slice const& balanceCommitment, Slice const& amountCommitment, Buffer& out) +{ + if (balanceCommitment.size() != ecPedersenCommitmentLength || amountCommitment.size() != ecPedersenCommitmentLength) + return tecINTERNAL; + + auto const ctx = secp256k1Context(); + + secp256k1_pubkey pcBalance; + if (secp256k1_ec_pubkey_parse(ctx, &pcBalance, balanceCommitment.data(), ecPedersenCommitmentLength) != 1) + return tecINTERNAL; + + secp256k1_pubkey pcAmount; + if (secp256k1_ec_pubkey_parse(ctx, &pcAmount, amountCommitment.data(), ecPedersenCommitmentLength) != 1) + return tecINTERNAL; + + // Negate PC_amount point to get -PC_amount + if (!secp256k1_ec_pubkey_negate(ctx, &pcAmount)) + return tecINTERNAL; + + // Compute pcRem = pcBalance + (-pcAmount) + secp256k1_pubkey const* summands[2] = {&pcBalance, &pcAmount}; + secp256k1_pubkey pcRem; + if (!secp256k1_ec_pubkey_combine(ctx, &pcRem, summands, 2)) + return tecINTERNAL; + + // Serialize result to compressed format + out.alloc(ecPedersenCommitmentLength); + size_t outLen = ecPedersenCommitmentLength; + if (secp256k1_ec_pubkey_serialize(ctx, out.data(), &outLen, &pcRem, SECP256K1_EC_COMPRESSED) != 1) + return tecINTERNAL; + + return tesSUCCESS; +} + TER computeConvertBackRemainder(Slice const& commitment, std::uint64_t amount, Buffer& out) { diff --git a/src/test/app/ConfidentialTransfer_test.cpp b/src/test/app/ConfidentialTransfer_test.cpp index 5671d64d1c..c8e9adeed3 100644 --- a/src/test/app/ConfidentialTransfer_test.cpp +++ b/src/test/app/ConfidentialTransfer_test.cpp @@ -72,7 +72,7 @@ class ConfidentialTransfer_test : public beast::unit_test::suite getTrivialSendProofHex(size_t nRecipients) { size_t const sizeEquality = getMultiCiphertextEqualityProofSize(nRecipients); - size_t const totalSize = sizeEquality + (2 * ecPedersenProofLength); + size_t const totalSize = sizeEquality + (2 * ecPedersenProofLength) + ecDoubleBulletproofLength; Buffer buf(totalSize); std::memset(buf.data(), 0, totalSize); @@ -1721,6 +1721,71 @@ class ConfidentialTransfer_test : public beast::unit_test::suite } } + 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 | tfMPTCanPrivacy | 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}); + } + + // todo: test m exceeding range, require using scala and refactor + } + void testDelete(FeatureBitset features) { @@ -3668,11 +3733,13 @@ class ConfidentialTransfer_test : public beast::unit_test::suite void testWithFeats(FeatureBitset features) { + // ConfidentialMPTConvert testConvert(features); testConvertPreflight(features); testConvertPreclaim(features); testConvertWithAuditor(features); + // ConfidentialMPTMergeInbox testMergeInbox(features); testMergeInboxPreflight(features); testMergeInboxPreclaim(features); @@ -3683,6 +3750,7 @@ class ConfidentialTransfer_test : public beast::unit_test::suite testSend(features); testSendPreflight(features); testSendPreclaim(features); + testSendRangeProof(features); testSendDepositPreauth(features); testSendWithAuditor(features); @@ -3695,6 +3763,7 @@ class ConfidentialTransfer_test : public beast::unit_test::suite testDelete(features); + // ConfidentialMPTConvertBack testConvertBack(features); testConvertBackPreflight(features); testConvertBackPreclaim(features); diff --git a/src/test/jtx/impl/mpt.cpp b/src/test/jtx/impl/mpt.cpp index 7f02601907..30a276a6ab 100644 --- a/src/test/jtx/impl/mpt.cpp +++ b/src/test/jtx/impl/mpt.cpp @@ -820,10 +820,27 @@ MPTTester::getConfidentialSendProof( auto const balanceLinkageProof = getBalanceLinkageProof(sender, contextHash, *senderPubKey, balanceParams); + std::uint64_t const remainingBalance = balanceParams.amt - amount; + + // Compute the blinding factor for the remaining balance: rho_rem = rho_balance - rho_amount + unsigned char rho_rem[32]; + unsigned char neg_rho_m[32]; + + secp256k1_mpt_scalar_negate(neg_rho_m, amountParams.blindingFactor.data()); + secp256k1_mpt_scalar_add(rho_rem, balanceParams.blindingFactor.data(), neg_rho_m); + + // Generate bulletproof for the amount and remaining balance + Buffer const bulletproof = + getBulletproof({amount, remainingBalance}, {amountParams.blindingFactor, Buffer(rho_rem, 32)}, contextHash); + + OPENSSL_cleanse(neg_rho_m, 32); + OPENSSL_cleanse(rho_rem, 32); + auto const sizeAmountLinkage = amountLinkageProof.size(); auto const sizeBalanceLinkage = balanceLinkageProof.size(); + auto const sizeBulletproof = bulletproof.size(); - size_t const proofSize = sizeEquality + sizeAmountLinkage + sizeBalanceLinkage; + size_t const proofSize = sizeEquality + sizeAmountLinkage + sizeBalanceLinkage + sizeBulletproof; Buffer proof(proofSize); auto ptr = proof.data(); @@ -834,6 +851,9 @@ MPTTester::getConfidentialSendProof( ptr += sizeAmountLinkage; std::memcpy(ptr, balanceLinkageProof.data(), sizeBalanceLinkage); + ptr += sizeBalanceLinkage; + + std::memcpy(ptr, bulletproof.data(), sizeBulletproof); return proof; } @@ -1298,7 +1318,8 @@ MPTTester::send(MPTConfidentialSend const& arg) jv[sfZKProof.jsonName] = strHex(*proof); else { - size_t const dummySize = secp256k1_mpt_prove_same_plaintext_multi_size(nRecipients); + size_t const sizeEquality = secp256k1_mpt_prove_same_plaintext_multi_size(nRecipients); + size_t const dummySize = sizeEquality + 2 * ecPedersenProofLength + ecDoubleBulletproofLength; jv[sfZKProof.jsonName] = strHex(Buffer(dummySize)); } diff --git a/src/xrpld/app/tx/detail/ConfidentialMPTSend.cpp b/src/xrpld/app/tx/detail/ConfidentialMPTSend.cpp index 921d1c6999..46cfbdfdfc 100644 --- a/src/xrpld/app/tx/detail/ConfidentialMPTSend.cpp +++ b/src/xrpld/app/tx/detail/ConfidentialMPTSend.cpp @@ -44,7 +44,7 @@ ConfidentialMPTSend::preflight(PreflightContext const& ctx) auto const sizeEquality = getMultiCiphertextEqualityProofSize(recipientCount); auto const sizePedersenLinkage = 2 * ecPedersenProofLength; - if (ctx.tx[sfZKProof].length() != sizeEquality + sizePedersenLinkage) + if (ctx.tx[sfZKProof].length() != sizeEquality + sizePedersenLinkage + ecDoubleBulletproofLength) return temMALFORMED; // Check the Pedersen commitments are valid @@ -105,7 +105,14 @@ verifySendProofs( currentOffset += ecPedersenProofLength; remainingLength -= ecPedersenProofLength; - // todo: Extract range proof once the lib is ready + // Extract range proof + if (remainingLength < ecDoubleBulletproofLength) + return tecINTERNAL; + + auto const rangeProof = proof.substr(currentOffset, ecDoubleBulletproofLength); + currentOffset += ecDoubleBulletproofLength; + remainingLength -= ecDoubleBulletproofLength; + if (remainingLength != 0) return tecINTERNAL; // LCOV_EXCL_LINE @@ -114,9 +121,7 @@ verifySendProofs( recipients.reserve(recipientCount); recipients.push_back({(*sleSenderMPToken)[sfHolderElGamalPublicKey], ctx.tx[sfSenderEncryptedAmount]}); - recipients.push_back({(*sleDestinationMPToken)[sfHolderElGamalPublicKey], ctx.tx[sfDestinationEncryptedAmount]}); - recipients.push_back({(*sleIssuance)[sfIssuerElGamalPublicKey], ctx.tx[sfIssuerEncryptedAmount]}); if (hasAuditor) @@ -132,12 +137,15 @@ verifySendProofs( ctx.tx[sfDestination], (*sleSenderMPToken)[~sfConfidentialBalanceVersion].value_or(0)); + // Use a boolean flag to track validity instead of returning early on failure to prevent leaking information about + // which proof failed through timing differences + bool valid = true; + // Verify the multi-ciphertext equality proof if (auto const ter = verifyMultiCiphertextEqualityProof(equalityProof, recipients, recipientCount, contextHash); !isTesSuccess(ter)) { - JLOG(ctx.j.trace()) << "ConfidentialMPTSend: Equality proof failed."; - return ter; + valid = false; } // Verify amount linkage @@ -149,8 +157,7 @@ verifySendProofs( contextHash); !isTesSuccess(ter)) { - JLOG(ctx.j.trace()) << "ConfidentialMPTSend: Amount linkage proof failed."; - return ter; + valid = false; } // Verify balance linkage @@ -162,8 +169,38 @@ verifySendProofs( contextHash); !isTesSuccess(ter)) { - JLOG(ctx.j.trace()) << "ConfidentialMPTSend: Balance linkage proof failed."; - return ter; + valid = false; + } + + // Verify Range Proof + { + Buffer pcRem; + + // Derive PC_rem = PC_balance - PC_amount + if (auto const ter = computeSendRemainder(ctx.tx[sfBalanceCommitment], ctx.tx[sfAmountCommitment], pcRem); + !isTesSuccess(ter)) + { + valid = false; + } + else + { + // Aggregated commitments: [PC_amount, PC_rem] + // Prove that both the transfer amount and the remaining balance are in range + std::vector commitments; + commitments.push_back(ctx.tx[sfAmountCommitment]); + commitments.push_back(Slice{pcRem.data(), pcRem.size()}); + + if (auto const ter = verifyAggregatedBulletproof(rangeProof, commitments, contextHash); !isTesSuccess(ter)) + { + valid = false; + } + } + } + + if (!valid) + { + JLOG(ctx.j.trace()) << "ConfidentialMPTSend: One or more cryptographic proofs failed."; + return tecBAD_PROOF; } return tesSUCCESS;