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)
This commit is contained in:
yinyiqian1
2026-02-20 14:18:34 -05:00
committed by GitHub
parent 94e911ed69
commit 6ad60d7141
6 changed files with 195 additions and 13 deletions

View File

@@ -54,6 +54,7 @@ words:
- autobridging
- bimap
- bindir
- blindings
- bookdir
- Bougalis
- Britto
@@ -249,6 +250,7 @@ words:
- stvar
- stvector
- stxchainattestations
- summands
- superpeer
- superpeers
- takergets

View File

@@ -469,6 +469,24 @@ verifyAggregatedBulletproof(
std::vector<Slice> 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.
*

View File

@@ -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)
{

View File

@@ -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);

View File

@@ -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));
}

View File

@@ -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<Slice> 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;