Add Range Proof Verification to ConvertBack (#6377)

This commit is contained in:
Shawn Xie
2026-02-19 19:22:48 -05:00
committed by GitHub
parent b2c434dd73
commit 94e911ed69
13 changed files with 949 additions and 165 deletions

View File

@@ -292,10 +292,12 @@ verifyElGamalEncryption(
* @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.
* 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, or an error code otherwise.
* @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);
@@ -449,4 +451,35 @@ verifyBalancePcmLinkage(
Slice const& pcmSlice,
uint256 const& contextHash);
/**
* @brief Verifies an aggregated Bulletproof range proof.
*
* This function verifies that all commitments in commitment_C_vec commit
* to values within the valid 64-bit range [0, 2^64 - 1].
*
* @param proof The serialized Bulletproof proof.
* @param compressedCommitments Vector of compressed Pedersen commitments (each 33 bytes).
* @param contextHash The unique context hash for this transaction.
* @return tesSUCCESS if the proof is valid, tecBAD_PROOF if verification
* fails, or tecINTERNAL for internal errors.
*/
TER
verifyAggregatedBulletproof(
Slice const& proof,
std::vector<Slice> const& compressedCommitments,
uint256 const& contextHash);
/**
* @brief Computes the remainder commitment for ConvertBack.
*
* Given a Pedersen commitment PC = m*G + rho*H, this function computes
* PC_rem = PC - amount*G = (m - amount)*G + rho*H
*
* @param commitment The compressed Pedersen commitment (33 bytes).
* @param amount The amount to subtract (must be non-zero).
* @param out Output buffer for the resulting commitment (33 bytes).
* @return tesSUCCESS on success, tecINTERNAL on failure or if amount is 0.
*/
TER
computeConvertBackRemainder(Slice const& commitment, std::uint64_t amount, Buffer& out);
} // namespace xrpl

View File

@@ -169,6 +169,8 @@ enum LedgerSpecificFlags {
lsfMPTCanClawback = 0x00000040,
lsfMPTCanPrivacy = 0x00000080,
// Mutable flags (lsmf prefix) control whether the issuer can change
// corresponding feature flags after issuance via MPTokenIssuanceSet.
lsmfMPTCanMutateCanLock = 0x00000002,
lsmfMPTCanMutateRequireAuth = 0x00000004,
lsmfMPTCanMutateCanEscrow = 0x00000008,
@@ -177,7 +179,11 @@ enum LedgerSpecificFlags {
lsmfMPTCanMutateCanClawback = 0x00000040,
lsmfMPTCanMutateMetadata = 0x00010000,
lsmfMPTCanMutateTransferFee = 0x00020000,
// if set, lsfMPTCanPrivacy can not be mutated
// Controls mutability of lsfMPTCanPrivacy. Note the inverted naming:
// - Other mutable flags: "CanMutate" means issuer CAN change the setting
// - This flag: "CannotMutate" means issuer CANNOT change the setting
// By default (flag not set), issuer can toggle lsfMPTCanPrivacy on/off.
// If set, lsfMPTCanPrivacy is permanently locked to its creation value.
lsmfMPTCannotMutatePrivacy = 0x00040000,
// ltMPTOKEN

View File

@@ -302,7 +302,7 @@ std::size_t constexpr ecGamalEncryptedLength = 33;
/** EC ElGamal ciphertext length: two 33-byte components concatenated */
std::size_t constexpr ecGamalEncryptedTotalLength = 66;
/** Length of equality ZKProof */
/** Length of equality ZKProof in bytes */
std::size_t constexpr ecEqualityProofLength = 98;
/** Length of EC point (compressed) */
@@ -311,22 +311,28 @@ std::size_t constexpr compressedECPointLength = 33;
/** Length of EC public key (compressed) */
std::size_t constexpr ecPubKeyLength = compressedECPointLength;
/** Length of EC private key */
/** Length of EC private key in bytes */
std::size_t constexpr ecPrivKeyLength = 32;
/** Length of the EC blinding factor */
/** Length of the EC blinding factor in bytes */
std::size_t constexpr ecBlindingFactorLength = 32;
/** Length of Schnorr ZKProof for public key registration */
/** Length of Schnorr ZKProof for public key registration in bytes */
std::size_t constexpr ecSchnorrProofLength = 65;
/** Length of ElGamal ciphertext equality proof */
/** Length of ElGamal ciphertext equality proof in bytes */
std::size_t constexpr ecCiphertextEqualityProofLength = 261;
/** Length of ElGamal Pedersen linkage proof */
/** 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;
} // namespace xrpl

View File

@@ -1,6 +1,8 @@
#include <xrpl/protocol/ConfidentialTransfer.h>
#include <xrpl/protocol/Protocol.h>
#include <boost/endian/conversion.hpp>
#include <openssl/rand.h>
#include <openssl/sha.h>
@@ -407,6 +409,12 @@ verifyClawbackEqualityProof(
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;
@@ -504,4 +512,113 @@ verifyBalancePcmLinkage(
return tesSUCCESS;
}
TER
verifyAggregatedBulletproof(
Slice const& proof,
std::vector<Slice> const& compressedCommitments,
uint256 const& contextHash)
{
// 1. Validate Aggregation Factor (m), m to be a power of 2
std::size_t const m = compressedCommitments.size();
if (m == 0 || (m & (m - 1)) != 0)
return tecINTERNAL; // LCOV_EXCL_LINE
// 2. Prepare Pedersen Commitments, parse from compressed format
auto const ctx = secp256k1Context();
std::vector<secp256k1_pubkey> commitments(m);
for (size_t i = 0; i < m; ++i)
{
// Sanity check length
if (compressedCommitments[i].size() != ecPedersenCommitmentLength)
return tecINTERNAL; // LCOV_EXCL_LINE
if (secp256k1_ec_pubkey_parse(
ctx, &commitments[i], compressedCommitments[i].data(), ecPedersenCommitmentLength) != 1)
return tecINTERNAL; // LCOV_EXCL_LINE
}
// 3. Prepare Generator Vectors (G_vec, H_vec)
// The range proof requires vectors of size 64 * m
std::size_t const n = 64 * m;
std::vector<secp256k1_pubkey> G_vec(n);
std::vector<secp256k1_pubkey> H_vec(n);
// Retrieve deterministic generators "G" and "H"
if (secp256k1_mpt_get_generator_vector(ctx, G_vec.data(), n, (unsigned char const*)"G", 1) != 1)
{
return tecINTERNAL; // LCOV_EXCL_LINE
}
if (secp256k1_mpt_get_generator_vector(ctx, H_vec.data(), n, (unsigned char const*)"H", 1) != 1)
{
return tecINTERNAL; // LCOV_EXCL_LINE
}
// 4. Prepare Base Generator (pk_base / H)
secp256k1_pubkey pk_base;
if (secp256k1_mpt_get_h_generator(ctx, &pk_base) != 1)
{
return tecINTERNAL; // LCOV_EXCL_LINE
}
// 5. Verify the Proof
int const result = secp256k1_bulletproof_verify_agg(
ctx,
G_vec.data(),
H_vec.data(),
reinterpret_cast<unsigned char const*>(proof.data()),
proof.size(),
commitments.data(),
m,
&pk_base,
contextHash.data());
if (result != 1)
return tecBAD_PROOF;
return tesSUCCESS;
}
TER
computeConvertBackRemainder(Slice const& commitment, std::uint64_t amount, Buffer& out)
{
if (commitment.size() != ecPedersenCommitmentLength || amount == 0)
return tecINTERNAL; // LCOV_EXCL_LINE
auto const ctx = secp256k1Context();
// Parse commitment from compressed format
secp256k1_pubkey pcBalance;
if (secp256k1_ec_pubkey_parse(ctx, &pcBalance, commitment.data(), ecPedersenCommitmentLength) != 1)
return tecINTERNAL; // LCOV_EXCL_LINE
// Convert amount to 32-byte big-endian scalar
unsigned char mScalar[32] = {0};
std::uint64_t amountBigEndian = boost::endian::native_to_big(amount);
std::memcpy(&mScalar[24], &amountBigEndian, sizeof(amountBigEndian));
// Compute mG = amount * G
secp256k1_pubkey mG;
if (!secp256k1_ec_pubkey_create(ctx, &mG, mScalar))
return tecINTERNAL; // LCOV_EXCL_LINE
// Negate mG to get -mG
if (!secp256k1_ec_pubkey_negate(ctx, &mG))
return tecINTERNAL; // LCOV_EXCL_LINE
// Compute pcRem = pcBalance + (-mG)
secp256k1_pubkey const* summands[2] = {&pcBalance, &mG};
secp256k1_pubkey pcRem;
if (!secp256k1_ec_pubkey_combine(ctx, &pcRem, summands, 2))
return tecINTERNAL; // LCOV_EXCL_LINE
// 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 ||
outLen != ecPedersenCommitmentLength)
return tecINTERNAL; // LCOV_EXCL_LINE
return tesSUCCESS;
}
} // namespace xrpl

View File

@@ -93,42 +93,119 @@ class ConfidentialTransfer_test : public beast::unit_test::suite
testcase("Convert");
using namespace test::jtx;
Env env{*this, features};
Account const alice("alice");
Account const bob("bob");
MPTTester mptAlice(env, alice, {.holders = {bob}});
// 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 | tfMPTCanPrivacy});
mptAlice.create({.ownerCount = 1, .flags = tfMPTCanTransfer | tfMPTCanLock | tfMPTCanPrivacy});
mptAlice.authorize({.account = bob});
mptAlice.pay(alice, bob, 100);
mptAlice.authorize({.account = bob});
mptAlice.pay(alice, bob, 100);
mptAlice.generateKeyPair(alice);
mptAlice.generateKeyPair(alice);
mptAlice.set({.account = alice, .issuerPubKey = mptAlice.getPubKey(alice)});
mptAlice.set({.account = alice, .issuerPubKey = mptAlice.getPubKey(alice)});
mptAlice.generateKeyPair(bob);
mptAlice.generateKeyPair(bob);
mptAlice.convert({
.account = bob,
.amt = 0,
.holderPubKey = mptAlice.getPubKey(bob),
});
mptAlice.convert({
.account = bob,
.amt = 0,
.holderPubKey = mptAlice.getPubKey(bob),
});
mptAlice.convert({
.account = bob,
.amt = 20,
});
mptAlice.convert({
.account = bob,
.amt = 20,
});
mptAlice.convert({
.account = bob,
.amt = 40,
});
mptAlice.convert({
.account = bob,
.amt = 40,
});
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 | tfMPTCanPrivacy});
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 | tfMPTCanPrivacy});
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
@@ -1781,36 +1858,187 @@ class ConfidentialTransfer_test : public beast::unit_test::suite
testcase("Convert back");
using namespace test::jtx;
Env env{*this, features};
Account const alice("alice");
Account const bob("bob");
MPTTester mptAlice(env, alice, {.holders = {bob}});
// 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 | tfMPTCanPrivacy});
mptAlice.create({.ownerCount = 1, .flags = tfMPTCanTransfer | tfMPTCanLock | tfMPTCanPrivacy});
mptAlice.authorize({.account = bob});
mptAlice.pay(alice, bob, 100);
mptAlice.authorize({.account = bob});
mptAlice.pay(alice, bob, 100);
mptAlice.generateKeyPair(alice);
mptAlice.generateKeyPair(alice);
mptAlice.set({.account = alice, .issuerPubKey = mptAlice.getPubKey(alice)});
mptAlice.set({.account = alice, .issuerPubKey = mptAlice.getPubKey(alice)});
mptAlice.generateKeyPair(bob);
mptAlice.generateKeyPair(bob);
mptAlice.convert({
.account = bob,
.amt = 40,
.holderPubKey = mptAlice.getPubKey(bob),
});
mptAlice.convert({
.account = bob,
.amt = 40,
.holderPubKey = mptAlice.getPubKey(bob),
});
mptAlice.mergeInbox({
.account = bob,
});
mptAlice.mergeInbox({
.account = bob,
});
mptAlice.convertBack({
.account = bob,
.amt = 30,
});
mptAlice.convertBack({
.account = bob,
.amt = 30,
});
// todo: this test fails because proof generation for convertback fails if remainder amount is 0
// 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 | tfMPTCanPrivacy});
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 | tfMPTCanPrivacy});
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(), env.seq(bob), mptAlice.issuanceID(), maxMPTokenAmount);
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[sfHolderElGamalPublicKey.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(), env.seq(bob), mptAlice.issuanceID(), convertBackAmt, 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
@@ -1937,6 +2165,11 @@ class ConfidentialTransfer_test : public beast::unit_test::suite
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 = Buffer(100), .err = temMALFORMED});
}
}
@@ -2990,9 +3223,9 @@ class ConfidentialTransfer_test : public beast::unit_test::suite
}
void
testConvertBackProof(FeatureBitset features)
testConvertBackPedersenProof(FeatureBitset features)
{
testcase("Convert back proof");
testcase("Convert back pedersen proof");
using namespace test::jtx;
Env env{*this, features};
@@ -3000,6 +3233,7 @@ class ConfidentialTransfer_test : public beast::unit_test::suite
Account const bob("bob");
MPTTester mptAlice(env, alice, {.holders = {bob}});
// --------------- Setup test --------------- //
mptAlice.create(
{.ownerCount = 1, .holderCount = 0, .flags = tfMPTCanTransfer | tfMPTCanLock | tfMPTCanPrivacy});
@@ -3038,7 +3272,28 @@ class ConfidentialTransfer_test : public beast::unit_test::suite
Buffer const bobCiphertext = mptAlice.encryptAmount(bob, amt, blindingFactor);
auto const version = mptAlice.getMPTokenVersion(bob);
// generate a proof using a pedersen commitment using the wrong value
// --------------- Finish setup --------------- //
// 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, env.seq(bob), mptAlice.issuanceID(), amt, version);
@@ -3047,12 +3302,8 @@ class ConfidentialTransfer_test : public beast::unit_test::suite
bob,
amt,
contextHash,
bobCiphertext,
issuerCiphertext,
{},
blindingFactor,
{
.pedersenCommitment = badPedersenCommitment, // bad pedersen commitment
.pedersenCommitment = badPedersenCommitment, // wrong pedersen commitment
.amt = *spendingBalance,
.encryptedAmt = *encryptedSpendingBalance,
.blindingFactor = pcBlindingFactor,
@@ -3069,23 +3320,76 @@ class ConfidentialTransfer_test : public beast::unit_test::suite
.err = tecBAD_PROOF});
}
// test when the pedersen commitment is wrong while the proof is
// right
// 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.
{
// generate the context hash again because bob's sequence
// incremented from prev txn
uint256 const contextHash =
getConvertBackContextHash(bob, env.seq(bob), mptAlice.issuanceID(), amt, 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, env.seq(bob), mptAlice.issuanceID(), amt, 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, env.seq(bob), mptAlice.issuanceID(), amt, version);
Buffer const badPedersenCommitment = mptAlice.getPedersenCommitment(1, pcBlindingFactor);
Buffer const proof = mptAlice.getConvertBackProof(
bob,
amt,
contextHash,
bobCiphertext,
issuerCiphertext,
{},
blindingFactor,
{
.pedersenCommitment = pedersenCommitment,
.amt = *spendingBalance,
@@ -3100,33 +3404,35 @@ class ConfidentialTransfer_test : public beast::unit_test::suite
.holderEncryptedAmt = bobCiphertext,
.issuerEncryptedAmt = issuerCiphertext,
.blindingFactor = blindingFactor,
.pedersenCommitment = badPedersenCommitment, // wrong pc used here
.pedersenCommitment = badPedersenCommitment, // wrong pedersen commitment
.err = tecBAD_PROOF});
}
// the pc blinding factor for generating the pc is different from the
// one used to generate pedersen 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.
{
// generate the context hash again because bob's sequence
// incremented from prev txn
uint256 const contextHash =
getConvertBackContextHash(bob, env.seq(bob), mptAlice.issuanceID(), amt, version);
Buffer const proof = mptAlice.getConvertBackProof(
uint256 const badContextHash{1};
Buffer const pedersenProof = mptAlice.getBalanceLinkageProof(
bob,
amt,
contextHash,
bobCiphertext,
issuerCiphertext,
{},
blindingFactor,
badContextHash, // wrong context hash
*holderPubKey,
{
.pedersenCommitment = pedersenCommitment,
.amt = *spendingBalance,
.encryptedAmt = *encryptedSpendingBalance,
.blindingFactor = generateBlindingFactor(), // bad blinding factor
.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,
@@ -3138,10 +3444,201 @@ class ConfidentialTransfer_test : public beast::unit_test::suite
.err = tecBAD_PROOF});
}
// a correct 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, env.seq(bob), mptAlice.issuanceID(), amt, 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}});
// --------------- Setup test --------------- //
mptAlice.create(
{.ownerCount = 1, .holderCount = 0, .flags = tfMPTCanTransfer | tfMPTCanLock | tfMPTCanPrivacy});
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);
// --------------- Finish setup --------------- //
// 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, env.seq(bob), mptAlice.issuanceID(), amt, 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, env.seq(bob), mptAlice.issuanceID(), amt, 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, env.seq(bob), mptAlice.issuanceID(), amt, 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.
{
// generate the context hash again because bob's sequence
// incremented from prev txn
uint256 const contextHash =
getConvertBackContextHash(bob, env.seq(bob), mptAlice.issuanceID(), amt, version);
@@ -3149,10 +3646,6 @@ class ConfidentialTransfer_test : public beast::unit_test::suite
bob,
amt,
contextHash,
bobCiphertext,
issuerCiphertext,
{},
blindingFactor,
{
.pedersenCommitment = pedersenCommitment,
.amt = *spendingBalance,
@@ -3206,9 +3699,11 @@ class ConfidentialTransfer_test : public beast::unit_test::suite
testConvertBackPreflight(features);
testConvertBackPreclaim(features);
testConvertBackWithAuditor(features);
testConvertBackProof(features);
testConvertBackPedersenProof(features);
testConvertBackBulletproof(features);
testMutatePrivacy(features);
// todo: this test fails because proof generation for convertback fails if remainder amount is 0
// testMutatePrivacy(features);
}
public:

View File

@@ -882,26 +882,33 @@ MPTTester::getConvertBackProof(
Account const& holder,
std::uint64_t const amount,
uint256 const& contextHash,
Buffer const& holderCiphertext,
Buffer const& issuerCiphertext,
std::optional<Buffer> const& auditorCiphertext,
Buffer const& blindingFactor,
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 Buffer{};
return Buffer(expectedProofLength);
auto const holderPubKey = getPubKey(holder);
if (holderPubKey)
{
Buffer const pedersenProof = getBalanceLinkageProof(holder, contextHash, *holderPubKey, pcParams);
// todo: incorporate range proof
return pedersenProof;
}
if (!holderPubKey)
return Buffer(expectedProofLength);
return Buffer{};
Buffer const pedersenProof = getBalanceLinkageProof(holder, contextHash, *holderPubKey, pcParams);
// Generate bulletproof for the remaining balance (balance - amount)
// Use the same blinding factor as the one used to generate the PC_balance
std::uint64_t const remainingBalance = pcParams.amt - amount;
Buffer const bulletproof = getBulletproof({remainingBalance}, {pcParams.blindingFactor}, contextHash);
// Combine pedersen proof and 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;
}
std::optional<Buffer>
@@ -1076,7 +1083,7 @@ MPTTester::convert(MPTConvert const& arg)
auto const postAuditorBalance = getDecryptedBalance(*arg.account, AUDITOR_ENCRYPTED_BALANCE);
if (!postAuditorBalance)
Throw<std::runtime_error>("Failed to get post-convert balance");
Throw<std::runtime_error>("Failed to get post-convert auditor balance");
// auditor's encrypted balance is updated correctly
env_.require(requireAny([&]() -> bool { return *prevAuditorBalance + *arg.amt == *postAuditorBalance; }));
@@ -1687,17 +1694,13 @@ MPTTester::convertBack(MPTConvertBack const& arg)
// generate a dummy proof if no encrypted amount field, so that other
// preflight/preclaim are checked
if (!prevEncryptedSpendingBalance)
proof = Buffer();
proof = Buffer(ecPedersenProofLength + ecSingleBulletproofLength);
else
{
proof = getConvertBackProof(
*arg.account,
*arg.amt,
contextHash,
holderCiphertext,
issuerCiphertext,
auditorCiphertext,
blindingFactor,
{
.pedersenCommitment = pedersenCommitment,
.amt = *prevSpendingBalance,
@@ -1826,7 +1829,7 @@ MPTTester::getBalanceLinkageProof(
!secp256k1_ec_pubkey_parse(
ctx, &c2, params.encryptedAmt.data() + ecGamalEncryptedLength, ecGamalEncryptedLength))
{
return Buffer();
return Buffer(ecPedersenProofLength);
}
secp256k1_pubkey pk;
@@ -1859,6 +1862,57 @@ MPTTester::getBalanceLinkageProof(
return proof;
}
Buffer
MPTTester::getBulletproof(
std::vector<std::uint64_t> const& values,
std::vector<Buffer> const& blindingFactors,
uint256 const& contextHash) const
{
std::size_t const m = values.size();
if (m == 0 || m > 2 || m != blindingFactors.size())
Throw<std::runtime_error>("getBulletproof: invalid input parameters");
for (auto const& bf : blindingFactors)
{
if (bf.size() != ecBlindingFactorLength)
Throw<std::runtime_error>("Invalid blinding factor length");
}
// Flatten blinding factors into contiguous memory (m * 32 bytes)
std::vector<unsigned char> 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<std::runtime_error>("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<std::runtime_error>("Bulletproof generation failed");
}
std::size_t const expectedLen = (m == 1) ? ecSingleBulletproofLength : ecDoubleBulletproofLength;
if (proofLen != expectedLen)
Throw<std::runtime_error>("Unexpected bulletproof length");
return Buffer(bulletproof.data(), proofLen);
}
} // namespace jtx
} // namespace test
} // namespace xrpl

View File

@@ -454,10 +454,6 @@ public:
Account const& holder,
std::uint64_t const amount,
uint256 const& contextHash,
Buffer const& holderCiphertext,
Buffer const& issuerCiphertext,
std::optional<Buffer> const& auditorCiphertext,
Buffer const& blindingFactor,
PedersenProofParams const& pcParams) const;
std::uint32_t
@@ -477,6 +473,12 @@ public:
Buffer const& pubKey,
PedersenProofParams const& params) const;
Buffer
getBulletproof(
std::vector<std::uint64_t> const& values,
std::vector<Buffer> const& blindingFactors,
uint256 const& contextHash) const;
Buffer
getPedersenCommitment(std::uint64_t const amount, Buffer const& pedersenBlindingFactor);

View File

@@ -51,6 +51,11 @@ public:
ttLOAN_DELETE,
ttLOAN_MANAGE,
ttLOAN_PAY,
ttCONFIDENTIAL_MPT_SEND,
ttCONFIDENTIAL_MPT_CONVERT,
ttCONFIDENTIAL_MPT_CONVERT_BACK,
ttCONFIDENTIAL_MPT_MERGE_INBOX,
ttCONFIDENTIAL_MPT_CLAWBACK,
});
};

View File

@@ -105,7 +105,7 @@ ConfidentialMPTClawback::doApply()
auto sleHolderMPToken = view().peek(keylet::mptoken(mptIssuanceID, holder));
if (!sleIssuance || !sleHolderMPToken)
return tecINTERNAL;
return tecINTERNAL; // LCOV_EXCL_LINE
auto const clawAmount = ctx_.tx[sfMPTAmount];

View File

@@ -155,11 +155,11 @@ ConfidentialMPTConvert::doApply()
auto sleMptoken = view().peek(keylet::mptoken(mptIssuanceID, account_));
if (!sleMptoken)
return tecINTERNAL;
return tecINTERNAL; // LCOV_EXCL_LINE
auto sleIssuance = view().peek(keylet::mptIssuance(mptIssuanceID));
if (!sleIssuance)
return tecINTERNAL;
return tecINTERNAL; // LCOV_EXCL_LINE
auto const amtToConvert = ctx_.tx[sfMPTAmount];
auto const amt = (*sleMptoken)[~sfMPTAmount].value_or(0);
@@ -167,6 +167,8 @@ ConfidentialMPTConvert::doApply()
if (ctx_.tx.isFieldPresent(sfHolderElGamalPublicKey))
(*sleMptoken)[sfHolderElGamalPublicKey] = ctx_.tx[sfHolderElGamalPublicKey];
// Converting decreases regular balance and increases confidential outstanding.
// The confidential outstanding tracks total tokens in confidential form globally.
(*sleMptoken)[sfMPTAmount] = amt - amtToConvert;
(*sleIssuance)[sfConfidentialOutstandingAmount] =
(*sleIssuance)[~sfConfidentialOutstandingAmount].value_or(0) + amtToConvert;
@@ -176,18 +178,19 @@ ConfidentialMPTConvert::doApply()
auto const auditorEc = ctx_.tx[~sfAuditorEncryptedAmount];
// todo: we should check sfConfidentialBalanceSpending depending on
// if we encrypt zero amount
// 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))
{
// homomorphically add holder's encrypted balance
// Case 1: Add to existing inbox balance (holder will merge later)
{
Buffer sum(ecGamalEncryptedTotalLength);
if (TER const ter = homomorphicAdd(holderEc, (*sleMptoken)[sfConfidentialBalanceInbox], sum);
!isTesSuccess(ter))
return tecINTERNAL;
return tecINTERNAL; // LCOV_EXCL_LINE
(*sleMptoken)[sfConfidentialBalanceInbox] = sum;
}
@@ -197,7 +200,7 @@ ConfidentialMPTConvert::doApply()
Buffer sum(ecGamalEncryptedTotalLength);
if (TER const ter = homomorphicAdd(issuerEc, (*sleMptoken)[sfIssuerEncryptedBalance], sum);
!isTesSuccess(ter))
return tecINTERNAL;
return tecINTERNAL; // LCOV_EXCL_LINE
(*sleMptoken)[sfIssuerEncryptedBalance] = sum;
}
@@ -208,7 +211,7 @@ ConfidentialMPTConvert::doApply()
Buffer sum(ecGamalEncryptedTotalLength);
if (TER const ter = homomorphicAdd(*auditorEc, (*sleMptoken)[sfAuditorEncryptedBalance], sum);
!isTesSuccess(ter))
return tecINTERNAL;
return tecINTERNAL; // LCOV_EXCL_LINE
(*sleMptoken)[sfAuditorEncryptedBalance] = sum;
}
@@ -218,6 +221,7 @@ ConfidentialMPTConvert::doApply()
!sleMptoken->isFieldPresent(sfConfidentialBalanceInbox) &&
!sleMptoken->isFieldPresent(sfConfidentialBalanceSpending))
{
// Case 2: First-time convert - initialize all confidential fields
(*sleMptoken)[sfConfidentialBalanceInbox] = holderEc;
(*sleMptoken)[sfIssuerEncryptedBalance] = issuerEc;
(*sleMptoken)[sfConfidentialBalanceVersion] = 0;
@@ -225,7 +229,8 @@ ConfidentialMPTConvert::doApply()
if (auditorEc)
(*sleMptoken)[sfAuditorEncryptedBalance] = *auditorEc;
// encrypt sfConfidentialBalanceSpending with zero balance
// Spending balance starts at zero. Must use canonical zero encryption
// (deterministic ciphertext) so the ledger state is reproducible.
auto const zeroBalance =
encryptCanonicalZeroAmount((*sleMptoken)[sfHolderElGamalPublicKey], account_, mptIssuanceID);

View File

@@ -36,10 +36,27 @@ ConfidentialMPTConvertBack::preflight(PreflightContext const& ctx)
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;
}
TER
/**
* 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<SLE const> const& issuance, std::shared_ptr<SLE const> const& mptoken)
{
if (!mptoken->isFieldPresent(sfHolderElGamalPublicKey))
@@ -62,6 +79,11 @@ verifyProofs(STTx const& tx, std::shared_ptr<SLE const> const& issuance, std::sh
auditor.emplace(ConfidentialRecipient{(*issuance)[sfAuditorElGamalPublicKey], tx[sfAuditorEncryptedAmount]});
}
// Run all verifications before returning any error to prevent timing attacks
// that could reveal which proof failed.
bool valid = true;
// verify revealed amount
if (auto const ter = verifyRevealedAmount(
amount,
blindingFactor,
@@ -70,31 +92,66 @@ verifyProofs(STTx const& tx, std::shared_ptr<SLE const> const& issuance, std::sh
auditor);
!isTesSuccess(ter))
{
return ter;
valid = false;
}
// Use a pointer to parse each proof component
Buffer zkps = Buffer(tx[sfZKProof].data(), tx[sfZKProof].size());
std::uint8_t* ptr = zkps.data();
// Parse proof components using offset
auto const proof = tx[sfZKProof];
size_t remainingLength = proof.size();
size_t currentOffset = 0;
// Extract Pedersen linkage proof
if (remainingLength < ecPedersenProofLength)
return tecINTERNAL; // LCOV_EXCL_LINE
auto const pedersenProof = proof.substr(currentOffset, ecPedersenProofLength);
currentOffset += ecPedersenProofLength;
remainingLength -= ecPedersenProofLength;
// Extract bulletproof
if (remainingLength < ecSingleBulletproofLength)
return tecINTERNAL; // LCOV_EXCL_LINE
auto const bulletproof = proof.substr(currentOffset, ecSingleBulletproofLength);
currentOffset += ecSingleBulletproofLength;
remainingLength -= ecSingleBulletproofLength;
if (remainingLength != 0)
return tecINTERNAL; // LCOV_EXCL_LINE
// verify el gamal pedersen linkage
if (auto const ter = verifyBalancePcmLinkage(
pedersenProof,
(*mptoken)[sfConfidentialBalanceSpending],
holderPubKey,
tx[sfBalanceCommitment],
contextHash);
!isTesSuccess(ter))
{
Buffer const pedersen{ptr, ecPedersenProofLength};
if (auto const ter = verifyBalancePcmLinkage(
pedersen,
(*mptoken)[sfConfidentialBalanceSpending],
holderPubKey,
tx[sfBalanceCommitment],
contextHash);
!isTesSuccess(ter))
valid = false;
}
// verify bullet proof
{
// Compute PC_rem = PC_balance - mG (the commitment to the remaining balance)
Buffer pcRem;
if (auto const ter = computeConvertBackRemainder(tx[sfBalanceCommitment], amount, pcRem); !isTesSuccess(ter))
{
return ter;
valid = false;
}
// increment pointer
ptr += ecPedersenProofLength;
// The bulletproof verifies that the remaining balance is non-negative
std::vector<Slice> commitments{Slice(pcRem.data(), pcRem.size())};
if (auto const ter = verifyAggregatedBulletproof(bulletproof, commitments, contextHash); !isTesSuccess(ter))
{
valid = false;
}
}
if (!valid)
return tecBAD_PROOF;
return tesSUCCESS;
}
@@ -171,15 +228,17 @@ ConfidentialMPTConvertBack::doApply()
auto sleMptoken = view().peek(keylet::mptoken(mptIssuanceID, account_));
if (!sleMptoken)
return tecINTERNAL;
return tecINTERNAL; // LCOV_EXCL_LINE
auto sleIssuance = view().peek(keylet::mptIssuance(mptIssuanceID));
if (!sleIssuance)
return tecINTERNAL;
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;
@@ -192,7 +251,7 @@ ConfidentialMPTConvertBack::doApply()
if (TER const ter = homomorphicSubtract(
(*sleMptoken)[sfConfidentialBalanceSpending], ctx_.tx[sfHolderEncryptedAmount], res);
!isTesSuccess(ter))
return tecINTERNAL;
return tecINTERNAL; // LCOV_EXCL_LINE
(*sleMptoken)[sfConfidentialBalanceSpending] = res;
}
@@ -203,7 +262,7 @@ ConfidentialMPTConvertBack::doApply()
if (TER const ter =
homomorphicSubtract((*sleMptoken)[sfIssuerEncryptedBalance], ctx_.tx[sfIssuerEncryptedAmount], res);
!isTesSuccess(ter))
return tecINTERNAL;
return tecINTERNAL; // LCOV_EXCL_LINE
(*sleMptoken)[sfIssuerEncryptedBalance] = res;
}
@@ -214,12 +273,11 @@ ConfidentialMPTConvertBack::doApply()
if (TER const ter =
homomorphicSubtract((*sleMptoken)[sfAuditorEncryptedBalance], ctx_.tx[sfAuditorEncryptedAmount], res);
!isTesSuccess(ter))
return tecINTERNAL;
return tecINTERNAL; // LCOV_EXCL_LINE
(*sleMptoken)[sfAuditorEncryptedBalance] = res;
}
// increment version
incrementConfidentialVersion(*sleMptoken);
view().update(sleIssuance);

View File

@@ -55,25 +55,29 @@ ConfidentialMPTMergeInbox::doApply()
auto const mptIssuanceID = ctx_.tx[sfMPTokenIssuanceID];
auto sleMptoken = view().peek(keylet::mptoken(mptIssuanceID, account_));
if (!sleMptoken)
return tecINTERNAL;
return tecINTERNAL; // LCOV_EXCL_LINE
// sanity check
if (!sleMptoken->isFieldPresent(sfConfidentialBalanceSpending) ||
!sleMptoken->isFieldPresent(sfConfidentialBalanceInbox) ||
!sleMptoken->isFieldPresent(sfHolderElGamalPublicKey))
{
return tecINTERNAL;
return tecINTERNAL; // LCOV_EXCL_LINE
}
// homomorphically add holder's encrypted balance
// 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.
Buffer sum(ecGamalEncryptedTotalLength);
if (TER const ter = homomorphicAdd(
(*sleMptoken)[sfConfidentialBalanceSpending], (*sleMptoken)[sfConfidentialBalanceInbox], sum);
!isTesSuccess(ter))
return tecINTERNAL;
return tecINTERNAL; // LCOV_EXCL_LINE
(*sleMptoken)[sfConfidentialBalanceSpending] = sum;
// Reset inbox to encrypted zero. Must use canonical zero encryption
// (deterministic ciphertext) so the ledger state is reproducible.
auto const zeroEncryption =
encryptCanonicalZeroAmount((*sleMptoken)[sfHolderElGamalPublicKey], account_, mptIssuanceID);
@@ -82,8 +86,7 @@ ConfidentialMPTMergeInbox::doApply()
(*sleMptoken)[sfConfidentialBalanceInbox] = *zeroEncryption;
// it's fine if it reaches max uint32, it just resets to 0
(*sleMptoken)[sfConfidentialBalanceVersion] = (*sleMptoken)[~sfConfidentialBalanceVersion].value_or(0u) + 1u;
incrementConfidentialVersion(*sleMptoken);
view().update(sleMptoken);
return tesSUCCESS;

View File

@@ -269,7 +269,7 @@ ConfidentialMPTSend::doApply()
auto sleDestAcct = view().peek(keylet::account(destination));
if (!sleSenderMPToken || !sleDestinationMPToken || !sleDestAcct)
return tecINTERNAL;
return tecINTERNAL; // LCOV_EXCL_LINE
if (auto err = verifyDepositPreauth(ctx_.tx, ctx_.view(), account_, destination, sleDestAcct, ctx_.journal);
!isTesSuccess(err))
@@ -287,7 +287,7 @@ ConfidentialMPTSend::doApply()
Buffer newSpending(ecGamalEncryptedTotalLength);
if (TER const ter = homomorphicSubtract(curSpending, senderEc, newSpending); !isTesSuccess(ter))
return tecINTERNAL;
return tecINTERNAL; // LCOV_EXCL_LINE
(*sleSenderMPToken)[sfConfidentialBalanceSpending] = newSpending;
}
@@ -298,7 +298,7 @@ ConfidentialMPTSend::doApply()
Buffer newIssuerEnc(ecGamalEncryptedTotalLength);
if (TER const ter = homomorphicSubtract(curIssuerEnc, issuerEc, newIssuerEnc); !isTesSuccess(ter))
return tecINTERNAL;
return tecINTERNAL; // LCOV_EXCL_LINE
(*sleSenderMPToken)[sfIssuerEncryptedBalance] = newIssuerEnc;
}
@@ -310,7 +310,7 @@ ConfidentialMPTSend::doApply()
Buffer newAuditorEnc(ecGamalEncryptedTotalLength);
if (TER const ter = homomorphicSubtract(curAuditorEnc, *auditorEc, newAuditorEnc); !isTesSuccess(ter))
return tecINTERNAL;
return tecINTERNAL; // LCOV_EXCL_LINE
(*sleSenderMPToken)[sfAuditorEncryptedBalance] = newAuditorEnc;
}
@@ -321,7 +321,7 @@ ConfidentialMPTSend::doApply()
Buffer newInbox(ecGamalEncryptedTotalLength);
if (TER const ter = homomorphicAdd(curInbox, destEc, newInbox); !isTesSuccess(ter))
return tecINTERNAL;
return tecINTERNAL; // LCOV_EXCL_LINE
(*sleDestinationMPToken)[sfConfidentialBalanceInbox] = newInbox;
}
@@ -332,7 +332,7 @@ ConfidentialMPTSend::doApply()
Buffer newIssuerEnc(ecGamalEncryptedTotalLength);
if (TER const ter = homomorphicAdd(curIssuerEnc, issuerEc, newIssuerEnc); !isTesSuccess(ter))
return tecINTERNAL;
return tecINTERNAL; // LCOV_EXCL_LINE
(*sleDestinationMPToken)[sfIssuerEncryptedBalance] = newIssuerEnc;
}
@@ -344,7 +344,7 @@ ConfidentialMPTSend::doApply()
Buffer newAuditorEnc(ecGamalEncryptedTotalLength);
if (TER const ter = homomorphicAdd(curAuditorEnc, *auditorEc, newAuditorEnc); !isTesSuccess(ter))
return tecINTERNAL;
return tecINTERNAL; // LCOV_EXCL_LINE
(*sleDestinationMPToken)[sfAuditorEncryptedBalance] = newAuditorEnc;
}