Files
rippled/src/test/app/ConfidentialTransfer_test.cpp
2026-07-02 20:02:55 +00:00

8209 lines
292 KiB
C++
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#include <test/jtx/Account.h>
#include <test/jtx/ConfidentialTransfer.h>
#include <test/jtx/Env.h>
#include <test/jtx/amount.h>
#include <test/jtx/flags.h>
#include <test/jtx/mpt.h>
#include <test/jtx/pay.h>
#include <test/jtx/ter.h>
#include <test/jtx/vault.h>
#include <xrpl/basics/Buffer.h>
#include <xrpl/basics/Slice.h>
#include <xrpl/basics/base_uint.h>
#include <xrpl/basics/contract.h>
#include <xrpl/basics/strHex.h>
#include <xrpl/beast/unit_test/suite.h>
#include <xrpl/beast/utility/Journal.h>
#include <xrpl/core/ServiceRegistry.h>
#include <xrpl/json/json_value.h>
#include <xrpl/ledger/ApplyView.h>
#include <xrpl/ledger/OpenView.h>
#include <xrpl/protocol/AccountID.h>
#include <xrpl/protocol/ConfidentialTransfer.h>
#include <xrpl/protocol/Feature.h>
#include <xrpl/protocol/Indexes.h>
#include <xrpl/protocol/LedgerFormats.h>
#include <xrpl/protocol/Protocol.h>
#include <xrpl/protocol/SField.h>
#include <xrpl/protocol/STObject.h>
#include <xrpl/protocol/Serializer.h>
#include <xrpl/protocol/TER.h>
#include <xrpl/protocol/TxFlags.h>
#include <xrpl/protocol/UintTypes.h>
#include <xrpl/protocol/jss.h>
#include <xrpl/tx/apply.h>
#include <openssl/evp.h>
#include <utility/mpt_utility.h>
#include <secp256k1.h>
#include <secp256k1_mpt.h>
#include <algorithm>
#include <array>
#include <cstddef>
#include <cstdint>
#include <cstring>
#include <functional>
#include <initializer_list>
#include <limits>
#include <memory>
#include <optional>
#include <stdexcept>
#include <string>
#include <utility>
namespace xrpl {
class ConfidentialTransfer_test : public ConfidentialTransferTestBase
{
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 | tfMPTCanHoldConfidentialBalance,
});
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 | tfMPTCanHoldConfidentialBalance,
});
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: kMaxMpTokenAmount
// 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 | tfMPTCanHoldConfidentialBalance,
});
mptAlice.authorize({
.account = bob,
});
mptAlice.pay(alice, bob, kMaxMpTokenAmount);
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 kMaxMpTokenAmount using raw JSON
Buffer const blindingFactor = generateBlindingFactor();
auto const holderCiphertext =
mptAlice.encryptAmount(bob, kMaxMpTokenAmount, blindingFactor);
auto const issuerCiphertext =
mptAlice.encryptAmount(alice, kMaxMpTokenAmount, 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(kMaxMpTokenAmount);
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 | tfMPTCanHoldConfidentialBalance,
});
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 | tfMPTCanHoldConfidentialBalance,
});
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 = gMakeZeroBuffer(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 = kMaxMpTokenAmount + 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 = gMakeZeroBuffer(kEcPubKeyLength),
.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 | tfMPTCanHoldConfidentialBalance,
});
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 | tfMPTCanHoldConfidentialBalance,
});
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
testConvertInvalidProofContextBinding(FeatureBitset features)
{
testcase("Convert proof context binding");
using namespace test::jtx;
auto runBadProof = [&](auto makeContextHash) {
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 | tfMPTCanHoldConfidentialBalance,
});
mptAlice.authorize({.account = bob});
mptAlice.authorize({.account = carol});
mptAlice.pay(alice, bob, 100);
mptAlice.generateKeyPair(alice);
mptAlice.generateKeyPair(bob);
mptAlice.generateKeyPair(carol);
mptAlice.set({.account = alice, .issuerPubKey = mptAlice.getPubKey(alice)});
auto const proof =
mptAlice.getSchnorrProof(bob, makeContextHash(env, mptAlice, alice, bob, carol));
if (!BEAST_EXPECT(proof.has_value()))
return;
mptAlice.convert({
.account = bob,
.amt = 10,
.proof = strHex(requireOptional(proof, "Missing proof")),
.holderPubKey = mptAlice.getPubKey(bob),
.err = tecBAD_PROOF,
});
};
// Wrong account in the proof context.
runBadProof([&](Env& env,
MPTTester const& mpt,
Account const&,
Account const& bob,
Account const& carol) {
return getConvertContextHash(carol.id(), mpt.issuanceID(), env.seq(bob));
});
// Wrong issuance ID in the proof context.
runBadProof([&](Env& env,
MPTTester const&,
Account const& alice,
Account const& bob,
Account const&) {
return getConvertContextHash(
bob.id(), makeMptID(env.seq(alice) + 100, alice), env.seq(bob));
});
// Wrong transaction sequence in the proof context.
runBadProof([&](Env& env,
MPTTester const& mpt,
Account const&,
Account const& bob,
Account const&) {
return getConvertContextHash(bob.id(), mpt.issuanceID(), env.seq(bob) + 1);
});
}
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 | tfMPTCanHoldConfidentialBalance,
});
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 = tmfMPTSetCanHoldConfidentialBalance,
});
}
// 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 = tmfMPTSetCanHoldConfidentialBalance,
.issuerPubKey = mptAlice.getPubKey(alice),
.auditorPubKey = mptAlice.getPubKey(auditor),
});
// Verify lsfMPTCanHoldConfidentialBalance flag is set
BEAST_EXPECT(mptAlice.checkFlags(
lsfMPTCanTransfer | lsfMPTCanLock | lsfMPTCanHoldConfidentialBalance));
// Verify keys are persisted on the issuance
auto const sle = env.le(keylet::mptokenIssuance(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 | tfMPTCanHoldConfidentialBalance,
});
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 = gMakeZeroBuffer(kEcPubKeyLength),
.err = temMALFORMED,
});
// Auditor key is invalid length
mptAlice.set({
.account = alice,
.issuerPubKey = mptAlice.getPubKey(alice),
.auditorPubKey = gMakeZeroBuffer(10),
.err = temMALFORMED,
});
// Auditor key has correct length but invalid EC point data
mptAlice.set({
.account = alice,
.issuerPubKey = mptAlice.getPubKey(alice),
.auditorPubKey = gMakeZeroBuffer(kEcPubKeyLength),
.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 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 | tfMPTCanHoldConfidentialBalance,
});
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 | tfMPTCanHoldConfidentialBalance,
});
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 tmfMPTCannotEnableCanHoldConfidentialBalance
mptAlice.create({
.ownerCount = 1,
.flags = tfMPTCanTransfer | tfMPTCanLock,
.mutableFlags = tmfMPTCannotEnableCanHoldConfidentialBalance,
});
mptAlice.generateKeyPair(alice);
// Trying to enable confidential amounts and set keys fails
// because the issuance cannot mutate canConfidentialAmount
mptAlice.set({
.account = alice,
.mutableFlags = tmfMPTSetCanHoldConfidentialBalance,
.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 | tfMPTCanHoldConfidentialBalance,
});
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
testTransferFee(FeatureBitset features)
{
testcase("test transfer fee");
using namespace test::jtx;
// MPTokenIssuanceCreate: cannot create with both TransferFee > 0 and
// tfMPTCanHoldConfidentialBalance
{
Env env{*this, features};
Account const alice("alice");
MPTTester mptAlice(env, alice, {.holders = {}});
mptAlice.create({
.transferFee = 100,
.flags = tfMPTCanTransfer | tfMPTCanHoldConfidentialBalance,
.err = temBAD_TRANSFER_FEE,
});
// transferFee being 0 is allowed, even with tfMPTCanHoldConfidentialBalance
mptAlice.create({
.transferFee = 0,
.flags = tfMPTCanTransfer | tfMPTCanHoldConfidentialBalance,
});
}
// MPTokenIssuanceSet (preflight): cannot enable confidential amounts and
// set TransferFee > 0 in the same transaction
{
Env env{*this, features};
Account const alice("alice");
MPTTester mptAlice(env, alice, {.holders = {}});
mptAlice.create({
.ownerCount = 1,
.flags = tfMPTCanTransfer | tfMPTCanLock,
.mutableFlags = tmfMPTCanMutateTransferFee,
});
mptAlice.set({
.account = alice,
.mutableFlags = tmfMPTSetCanHoldConfidentialBalance,
.transferFee = 100,
.err = temBAD_TRANSFER_FEE,
});
}
// MPTokenIssuanceSet (preclaim): cannot enable confidential amounts on
// an issuance that already has a non-zero TransferFee
{
Env env{*this, features};
Account const alice("alice");
MPTTester mptAlice(env, alice, {.holders = {}});
mptAlice.create({
.transferFee = 100,
.ownerCount = 1,
.flags = tfMPTCanTransfer | tfMPTCanLock,
.mutableFlags = tmfMPTCanMutateTransferFee,
});
mptAlice.set({
.account = alice,
.mutableFlags = tmfMPTSetCanHoldConfidentialBalance,
.err = tecNO_PERMISSION,
});
}
// MPTokenIssuanceSet (preclaim): cannot set TransferFee > 0 on an
// issuance that already has lsfMPTCanHoldConfidentialBalance
{
Env env{*this, features};
Account const alice("alice");
MPTTester mptAlice(env, alice, {.holders = {}});
mptAlice.create({
.ownerCount = 1,
.flags = tfMPTCanTransfer | tfMPTCanLock | tfMPTCanHoldConfidentialBalance,
.mutableFlags = tmfMPTCanMutateTransferFee,
});
mptAlice.set({
.account = alice,
.transferFee = 100,
.err = tecNO_PERMISSION,
});
// Setting transfer fee to 0 is allowed, but have no effect.
mptAlice.set({
.account = alice,
.transferFee = 0,
});
}
}
void
testConvertPreclaim(FeatureBitset features)
{
testcase("Convert preclaim");
using namespace test::jtx;
// tfMPTCanHoldConfidentialBalance 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 | tfMPTCanHoldConfidentialBalance,
});
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 | tfMPTCanHoldConfidentialBalance,
});
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 | tfMPTCanHoldConfidentialBalance,
});
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");
Account const carol("carol");
MPTTester mptAlice(env, alice, {.holders = {bob, carol}});
mptAlice.create({
.ownerCount = 1,
.flags = tfMPTCanTransfer | tfMPTCanLock | tfMPTCanHoldConfidentialBalance,
});
mptAlice.authorize({
.account = bob,
});
mptAlice.pay(alice, bob, 100);
mptAlice.generateKeyPair(alice);
mptAlice.generateKeyPair(bob);
mptAlice.generateKeyPair(carol);
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,
});
std::uint64_t const amount = 10;
Buffer const blindingFactor = generateBlindingFactor();
Buffer const holderCiphertext = mptAlice.encryptAmount(bob, amount, blindingFactor);
// Holder ciphertext is valid for the amount and
// blinding factor, but the issuer ciphertext is encrypted under a
// different public key than the registered issuer key.
Buffer const wrongIssuerCiphertext =
mptAlice.encryptAmount(carol, amount, blindingFactor);
mptAlice.convert({
.account = bob,
.amt = amount,
.holderPubKey = mptAlice.getPubKey(bob),
.holderEncryptedAmt = holderCiphertext,
.issuerEncryptedAmt = wrongIssuerCiphertext,
.blindingFactor = blindingFactor,
.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 | tfMPTCanHoldConfidentialBalance,
});
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 | tfMPTCanHoldConfidentialBalance,
});
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 | tfMPTCanHoldConfidentialBalance,
});
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 |
tfMPTCanHoldConfidentialBalance,
});
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 | tfMPTCanHoldConfidentialBalance,
});
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 |
tfMPTCanHoldConfidentialBalance,
});
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 | tfMPTCanHoldConfidentialBalance,
});
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 | tfMPTCanHoldConfidentialBalance,
});
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 | tfMPTCanHoldConfidentialBalance,
});
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 | tfMPTCanHoldConfidentialBalance,
});
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(kEcSchnorrProofLength * 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 | tfMPTCanHoldConfidentialBalance,
});
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 | tfMPTCanHoldConfidentialBalance,
});
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;
// Merge with an empty inbox should succeed as a no-op.
{
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 | tfMPTCanHoldConfidentialBalance,
});
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 = 40,
.holderPubKey = mptAlice.getPubKey(bob),
});
mptAlice.mergeInbox({.account = bob});
// Inbox is empty after the first merge; the second merge is a no-op.
mptAlice.mergeInbox({.account = bob});
}
// Makes sure if merge inbox version is UINT32_MAX, the next merge wraps
// the version back to 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 | tfMPTCanHoldConfidentialBalance,
});
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 = 40,
.holderPubKey = mptAlice.getPubKey(bob),
});
// Force the on-ledger version to UINT32_MAX, then apply a merge and
// confirm the version wraps around to 0.
auto const wrappedFrom = std::numeric_limits<std::uint32_t>::max();
auto const jt = env.jt(mptAlice.mergeInboxJV({.account = bob}));
BEAST_EXPECT(env.app().getOpenLedger().modify([&](OpenView& view, beast::Journal) {
auto const sle = std::const_pointer_cast<SLE>(
view.read(keylet::mptoken(mptAlice.issuanceID(), bob.id())));
if (!sle)
return false;
(*sle)[sfConfidentialBalanceVersion] = wrappedFrom;
view.rawReplace(sle);
auto const result = xrpl::apply(env.app(), view, *jt.stx, TapNone, env.journal);
BEAST_EXPECT(result.ter == tesSUCCESS);
return result.applied;
}));
BEAST_EXPECT(mptAlice.getMPTokenVersion(bob) == 0);
}
}
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 | tfMPTCanHoldConfidentialBalance,
});
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 | tfMPTCanHoldConfidentialBalance,
});
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,
});
}
// tfMPTCanHoldConfidentialBalance 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 | tfMPTCanHoldConfidentialBalance,
});
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 | tfMPTCanHoldConfidentialBalance,
});
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 | tfMPTCanHoldConfidentialBalance,
});
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 | tfMPTCanHoldConfidentialBalance |
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"), bob("bob"), carol("carol");
ConfidentialEnv confEnv{
env,
alice,
{{.account = bob, .payAmount = 100, .convertAmount = 60},
{.account = carol, .payAmount = 50, .convertAmount = 20}}};
auto& mptAlice = confEnv.mpt;
// 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");
ConfidentialEnv confEnv{
env,
alice,
{{.account = bob, .payAmount = 100, .convertAmount = 60},
{.account = carol, .payAmount = 50, .convertAmount = 20}},
tfMPTCanTransfer | tfMPTCanLock | tfMPTCanHoldConfidentialBalance,
auditor};
auto& mptAlice = confEnv.mpt;
// 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 = gMakeZeroBuffer(kEcGamalEncryptedTotalLength),
.destEncryptedAmt = gMakeZeroBuffer(kEcGamalEncryptedTotalLength),
.issuerEncryptedAmt = gMakeZeroBuffer(kEcGamalEncryptedTotalLength),
.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 | tfMPTCanHoldConfidentialBalance,
});
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,
});
// can not send to issuer
mptAlice.send({
.account = bob,
.dest = alice,
.amt = 10,
.err = temMALFORMED,
});
// sender encrypted amount wrong length
mptAlice.send({
.account = bob,
.dest = carol,
.amt = 10,
.senderEncryptedAmt = gMakeZeroBuffer(10),
.err = temBAD_CIPHERTEXT,
});
// dest encrypted amount wrong length
mptAlice.send({
.account = bob,
.dest = carol,
.amt = 10,
.destEncryptedAmt = gMakeZeroBuffer(10),
.err = temBAD_CIPHERTEXT,
});
// issuer encrypted amount wrong length
mptAlice.send({
.account = bob,
.dest = carol,
.amt = 10,
.issuerEncryptedAmt = gMakeZeroBuffer(10),
.err = temBAD_CIPHERTEXT,
});
// sender encrypted amount malformed
mptAlice.send({
.account = bob,
.dest = carol,
.amt = 10,
.proof = getTrivialSendProofHex(),
.senderEncryptedAmt = gMakeZeroBuffer(kEcGamalEncryptedTotalLength),
.amountCommitment = getTrivialCommitment(),
.balanceCommitment = getTrivialCommitment(),
.err = temBAD_CIPHERTEXT,
});
// dest encrypted amount malformed
mptAlice.send({
.account = bob,
.dest = carol,
.amt = 10,
.proof = getTrivialSendProofHex(),
.destEncryptedAmt = gMakeZeroBuffer(kEcGamalEncryptedTotalLength),
.amountCommitment = getTrivialCommitment(),
.balanceCommitment = getTrivialCommitment(),
.err = temBAD_CIPHERTEXT,
});
// issuer encrypted amount malformed
mptAlice.send({
.account = bob,
.dest = carol,
.amt = 10,
.proof = getTrivialSendProofHex(),
.issuerEncryptedAmt = gMakeZeroBuffer(kEcGamalEncryptedTotalLength),
.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(),
.amountCommitment = gMakeZeroBuffer(100),
.balanceCommitment = getTrivialCommitment(),
.err = temMALFORMED,
});
// invalid balance Pedersen commitment length
mptAlice.send({
.account = bob,
.dest = carol,
.amt = 10,
.proof = getTrivialSendProofHex(),
.amountCommitment = getTrivialCommitment(),
.balanceCommitment = gMakeZeroBuffer(100),
.err = temMALFORMED,
});
// amount Pedersen commitment has correct length but invalid EC point data
mptAlice.send({
.account = bob,
.dest = carol,
.amt = 10,
.proof = getTrivialSendProofHex(),
.amountCommitment = gMakeZeroBuffer(kEcPedersenCommitmentLength),
.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(),
.amountCommitment = getTrivialCommitment(),
.balanceCommitment = gMakeZeroBuffer(kEcPedersenCommitmentLength),
.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 | tfMPTCanHoldConfidentialBalance,
});
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(),
.auditorEncryptedAmt = gMakeZeroBuffer(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(),
.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 |
tfMPTCanHoldConfidentialBalance,
});
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 | tfMPTCanHoldConfidentialBalance,
});
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();
env(jv, Ter(tecOBJECT_NOT_FOUND));
}
// destination does not exist
{
Account const unknown("unknown");
mptAlice.send({
.account = bob,
.dest = unknown,
.amt = 10,
.proof = getTrivialSendProofHex(),
.senderEncryptedAmt = getTrivialCiphertext(),
.destEncryptedAmt = getTrivialCiphertext(),
.issuerEncryptedAmt = getTrivialCiphertext(),
.amountCommitment = getTrivialCommitment(),
.balanceCommitment = getTrivialCommitment(),
.err = tecNO_TARGET,
});
}
// destination requires destination tag but none provided
{
env(fset(carol, asfRequireDest));
env.close();
mptAlice.send({
.account = bob,
.dest = carol,
.amt = 10,
.proof = getTrivialSendProofHex(),
.senderEncryptedAmt = getTrivialCiphertext(),
.destEncryptedAmt = getTrivialCiphertext(),
.issuerEncryptedAmt = getTrivialCiphertext(),
.amountCommitment = getTrivialCommitment(),
.balanceCommitment = getTrivialCommitment(),
.err = tecDST_TAG_NEEDED,
});
env(fclear(carol, asfRequireDest));
env.close();
}
// dave exists, but has no confidential fields (never converted)
{
mptAlice.send({
.account = bob,
.dest = dave,
.amt = 10,
.proof = getTrivialSendProofHex(),
.senderEncryptedAmt = getTrivialCiphertext(),
.destEncryptedAmt = getTrivialCiphertext(),
.issuerEncryptedAmt = getTrivialCiphertext(),
.amountCommitment = getTrivialCommitment(),
.balanceCommitment = getTrivialCommitment(),
.err = tecNO_PERMISSION,
});
mptAlice.send({
.account = dave,
.dest = carol,
.amt = 10,
.proof = getTrivialSendProofHex(),
.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(),
.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");
ConfidentialEnv confEnv{
env,
alice,
{{.account = bob, .payAmount = 100, .convertAmount = 60},
{.account = carol, .payAmount = 50, .convertAmount = 20}},
tfMPTCanLock | tfMPTCanHoldConfidentialBalance};
auto& mptAlice = confEnv.mpt;
// bob sends 10 to carol
mptAlice.send({
.account = bob,
.dest = carol,
.amt = 10, // will be encrypted internally
.err = tecNO_AUTH,
});
}
// Confidential MPTs should not have a transfer fee. Force malformed
// ledger state to cover the defensive preclaim check.
{
Env env{*this, features};
Account const alice("alice");
Account const bob("bob");
Account const carol("carol");
ConfidentialEnv confEnv{
env,
alice,
{{.account = bob, .payAmount = 100, .convertAmount = 60},
{.account = carol, .payAmount = 50, .convertAmount = 20}}};
auto& mptAlice = confEnv.mpt;
BEAST_EXPECT(env.app().getOpenLedger().modify([&](OpenView& view, beast::Journal) {
auto const issuance = std::const_pointer_cast<SLE>(
view.read(keylet::mptokenIssuance(mptAlice.issuanceID())));
if (!issuance)
return false;
issuance->setFieldU16(sfTransferFee, 1);
view.rawReplace(issuance);
return true;
}));
mptAlice.send({
.account = bob,
.dest = carol,
.amt = 10,
.proof = getTrivialSendProofHex(),
.err = tecNO_PERMISSION,
});
}
// bad proof
{
Env env{*this, features};
Account const alice("alice");
Account const bob("bob");
Account const carol("carol");
ConfidentialEnv confEnv{
env,
alice,
{{.account = bob, .payAmount = 100, .convertAmount = 60},
{.account = carol, .payAmount = 50, .convertAmount = 20}}};
auto& mptAlice = confEnv.mpt;
mptAlice.send({
.account = bob,
.dest = carol,
.amt = 10,
.proof = getTrivialSendProofHex(),
.err = tecBAD_PROOF,
});
}
// No Auditor key set, but auditor encrypted amt provided
{
mptAlice.send({
.account = bob,
.dest = carol,
.amt = 10,
.proof = getTrivialSendProofHex(),
.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 | tfMPTCanHoldConfidentialBalance,
});
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(),
.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"), bob("bob"), carol("carol");
ConfidentialEnv confEnv{
env,
alice,
{{.account = bob, .payAmount = 1000, .convertAmount = 60},
{.account = carol, .payAmount = 1000, .convertAmount = 50}}};
auto& mptAlice = confEnv.mpt;
{
// 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 = std::numeric_limits<std::uint64_t>::max(),
.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)
{
// Register keys only (amt=0) for both parties — spending stays 0.
Env env2{*this, features};
Account const alice2("alice"), bob2("bob"), carol2("carol");
ConfidentialEnv zeroEnv{
env2,
alice2,
{{.account = bob2, .payAmount = 100, .convertAmount = 0},
{.account = carol2, .payAmount = 50, .convertAmount = 0}}};
auto& mptAlice2 = zeroEnv.mpt;
// Trying to send any amount with 0 spending balance must fail:
// the range proof for < 0 is invalid.
mptAlice2.send({
.account = bob2,
.dest = carol2,
.amt = 1,
.err = tecBAD_PROOF,
});
BEAST_EXPECT(
mptAlice2.getDecryptedBalance(bob2, MPTTester::holderEncryptedSpending) == 0);
}
// todo: test m exceeding range, require using scala and refactor
}
/* 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 | tfMPTCanHoldConfidentialBalance,
});
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(),
.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(),
.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::holderEncryptedSpending) == 100);
BEAST_EXPECT(mptAlice.getDecryptedBalance(carol, MPTTester::holderEncryptedInbox) == 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 | tfMPTCanHoldConfidentialBalance,
});
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 | tfMPTCanHoldConfidentialBalance,
});
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 | tfMPTCanHoldConfidentialBalance,
});
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 | tfMPTCanHoldConfidentialBalance,
});
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");
ConfidentialEnv confEnv{
env, alice, {{.account = bob, .payAmount = 100, .convertAmount = 100}}};
auto& mptAlice = confEnv.mpt;
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);
auto const 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 lsfMPTCanHoldConfidentialBalance on the share issuance
// so the invariant allows encrypted fields on the MPToken
auto issuance =
std::const_pointer_cast<SLE>(view.read(keylet::mptokenIssuance(share)));
if (!issuance)
return false;
issuance->setFlag(lsfMPTCanHoldConfidentialBalance);
view.rawReplace(issuance);
auto const k = keylet::mptoken(share, depositor.id());
auto const sle = std::const_pointer_cast<SLE>(view.read(k));
if (!sle)
return false;
// Inject dummy confidential balance fields
Buffer dummyCiphertext(kEcGamalEncryptedTotalLength);
std::memset(dummyCiphertext.data(), 0, kEcGamalEncryptedTotalLength);
dummyCiphertext.data()[0] = kEcCompressedPrefixEvenY;
dummyCiphertext.data()[kEcCiphertextComponentLength] = kEcCompressedPrefixEvenY;
dummyCiphertext.data()[kEcCiphertextComponentLength - 1] = 0x01;
dummyCiphertext.data()[kEcGamalEncryptedTotalLength - 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");
ConfidentialEnv confEnv{
env, alice, {{.account = bob, .payAmount = 100, .convertAmount = 40}}};
auto& mptAlice = confEnv.mpt;
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");
ConfidentialEnv confEnv{
env, alice, {{.account = bob, .payAmount = 2, .convertAmount = 2}}};
auto& mptAlice = confEnv.mpt;
mptAlice.convertBack({
.account = bob,
.amt = 1,
});
}
// Edge case: kMaxMpTokenAmount
// 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 | tfMPTCanHoldConfidentialBalance,
});
mptAlice.authorize({
.account = bob,
});
mptAlice.pay(alice, bob, kMaxMpTokenAmount);
mptAlice.generateKeyPair(alice);
mptAlice.set({.account = alice, .issuerPubKey = mptAlice.getPubKey(alice)});
mptAlice.generateKeyPair(bob);
// Convert kMaxMpTokenAmount to confidential using raw JSON
Buffer const convertBlindingFactor = generateBlindingFactor();
auto const convertHolderCiphertext =
mptAlice.encryptAmount(bob, kMaxMpTokenAmount, convertBlindingFactor);
auto const convertIssuerCiphertext =
mptAlice.encryptAmount(alice, kMaxMpTokenAmount, convertBlindingFactor);
auto const convertContextHash =
getConvertContextHash(bob.id(), mptAlice.issuanceID(), env.seq(bob));
auto const schnorrProof = requireOptional(
mptAlice.getSchnorrProof(bob, convertContextHash), "Missing schnorr proof");
{
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(kMaxMpTokenAmount);
jv[sfHolderEncryptionKey.jsonName] =
strHex(requireOptional(mptAlice.getPubKey(bob), "Missing holder public key"));
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 kMaxMpTokenAmount - 1 using raw JSON
// After convert + merge, spending balance = kMaxMpTokenAmount
// We convert back kMaxMpTokenAmount - 1 to leave remainder of 1
std::uint64_t const convertBackAmt = kMaxMpTokenAmount - 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 = requireOptional(
mptAlice.getEncryptedBalance(bob, MPTTester::holderEncryptedSpending),
"Missing encrypted spending balance");
// Generate pedersen commitment for the known spending balance
Buffer const pcBlindingFactor = generateBlindingFactor();
Buffer const pedersenCommitment =
mptAlice.getPedersenCommitment(kMaxMpTokenAmount, 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 = kMaxMpTokenAmount,
.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");
ConfidentialEnv confEnv{
env,
alice,
{{.account = bob, .payAmount = 100, .convertAmount = 40}},
tfMPTCanTransfer | tfMPTCanLock | tfMPTCanHoldConfidentialBalance,
auditor};
auto& mptAlice = confEnv.mpt;
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");
ConfidentialEnv confEnv{
env, alice, {{.account = bob, .payAmount = 100, .convertAmount = 40}}};
auto& mptAlice = confEnv.mpt;
mptAlice.convertBack({
.account = alice,
.amt = 30,
.err = temMALFORMED,
});
mptAlice.convertBack({
.account = bob,
.amt = 0,
.err = temBAD_AMOUNT,
});
mptAlice.convertBack({
.account = bob,
.amt = kMaxMpTokenAmount + 1,
.err = temBAD_AMOUNT,
});
// Balance commitment has correct length but invalid EC point data
mptAlice.convertBack({
.account = bob,
.amt = 30,
.pedersenCommitment = gMakeZeroBuffer(kEcPedersenCommitmentLength),
.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 = gMakeZeroBuffer(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 = gMakeZeroBuffer(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 | tfMPTCanHoldConfidentialBalance,
});
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,
});
}
// tfMPTCanHoldConfidentialBalance 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 | tfMPTCanHoldConfidentialBalance,
});
mptAlice.generateKeyPair(alice);
mptAlice.generateKeyPair(bob);
mptAlice.set({.account = alice, .issuerPubKey = mptAlice.getPubKey(alice)});
mptAlice.convertBack({
.account = bob,
.amt = 30,
.err = tecOBJECT_NOT_FOUND,
});
}
// mptoken exists but lacks confidential fields
{
Env env{*this, features};
Account const alice("alice");
Account const bob("bob");
MPTTester mptAlice(env, alice, {.holders = {bob}});
mptAlice.create({
.ownerCount = 1,
.flags = tfMPTCanTransfer | tfMPTCanHoldConfidentialBalance,
});
mptAlice.authorize({
.account = bob,
});
mptAlice.pay(alice, bob, 100);
mptAlice.generateKeyPair(alice);
mptAlice.generateKeyPair(bob);
mptAlice.set({.account = alice, .issuerPubKey = mptAlice.getPubKey(alice)});
// Bob's MPToken lacks the confidential fields
auto const sleBobMpt = env.le(keylet::mptoken(mptAlice.issuanceID(), bob.id()));
BEAST_EXPECT(sleBobMpt);
BEAST_EXPECT(!sleBobMpt->isFieldPresent(sfHolderEncryptionKey));
BEAST_EXPECT(!sleBobMpt->isFieldPresent(sfConfidentialBalanceSpending));
BEAST_EXPECT(!sleBobMpt->isFieldPresent(sfIssuerEncryptedBalance));
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 | tfMPTCanHoldConfidentialBalance,
});
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 |
tfMPTCanHoldConfidentialBalance,
});
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");
ConfidentialEnv confEnv{
env, alice, {{.account = bob, .payAmount = 100, .convertAmount = 50}}};
auto& mptAlice = confEnv.mpt;
// 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");
ConfidentialEnv confEnv{
env, alice, {{.account = bob, .payAmount = 100, .convertAmount = 50}}};
auto& mptAlice = confEnv.mpt;
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");
ConfidentialEnv confEnv{
env,
alice,
{{.account = bob, .payAmount = 100, .convertAmount = 50}},
tfMPTCanTransfer | tfMPTCanLock | tfMPTCanHoldConfidentialBalance,
auditor};
auto& mptAlice = confEnv.mpt;
// 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
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 |
tfMPTCanHoldConfidentialBalance,
});
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,
});
// Confidential clawback is burn/reduce outstanding amount.
// The holder public balance is unchanged, and OA/COA decrease.
auto const preBobPublicBalance = mptAlice.getBalance(bob);
auto const preOutstandingAmount = mptAlice.getIssuanceOutstandingBalance();
auto const preConfidentialOutstandingAmount = mptAlice.getIssuanceConfidentialBalance();
BEAST_EXPECT(!env.le(keylet::mptoken(mptAlice.issuanceID(), alice.id())));
// 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,
});
BEAST_EXPECT(mptAlice.getBalance(bob) == preBobPublicBalance);
auto const postOutstandingAmount = mptAlice.getIssuanceOutstandingBalance();
BEAST_EXPECT(
preOutstandingAmount && postOutstandingAmount &&
*postOutstandingAmount == *preOutstandingAmount - 110);
BEAST_EXPECT(
mptAlice.getIssuanceConfidentialBalance() == preConfidentialOutstandingAmount - 110);
BEAST_EXPECT(!env.le(keylet::mptoken(mptAlice.issuanceID(), alice.id())));
// 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 |
tfMPTCanHoldConfidentialBalance,
});
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
testClawbackInvalidProofContextBinding(FeatureBitset features)
{
testcase("ConfidentialMPTClawback context binding");
using namespace test::jtx;
auto runBadProof = [&](auto makeContextHash) {
Env env{*this, features};
Account const alice("alice");
Account const bob("bob");
Account const carol("carol");
ConfidentialEnv confEnv{
env,
alice,
{{.account = bob, .payAmount = 100, .convertAmount = 60}},
tfMPTCanTransfer | tfMPTCanLock | tfMPTCanClawback |
tfMPTCanHoldConfidentialBalance};
auto& mptAlice = confEnv.mpt;
auto const privKey = mptAlice.getPrivKey(alice);
if (!BEAST_EXPECT(privKey.has_value()))
return;
auto const proof = mptAlice.getClawbackProof(
bob,
60,
requireOptionalRef(privKey, "Missing private key"),
makeContextHash(env, mptAlice, alice, bob, carol));
if (!BEAST_EXPECT(proof.has_value()))
return;
mptAlice.confidentialClaw({
.account = alice,
.holder = bob,
.amt = 60,
.proof = strHex(requireOptional(proof, "Missing proof")),
.err = tecBAD_PROOF,
});
};
// Wrong account (issuer) in the proof context.
runBadProof([&](Env& env,
MPTTester const& mpt,
Account const& alice,
Account const& bob,
Account const& carol) {
return getClawbackContextHash(carol.id(), mpt.issuanceID(), env.seq(alice), bob.id());
});
// Wrong issuance ID in the proof context.
runBadProof([&](Env& env,
MPTTester const&,
Account const& alice,
Account const& bob,
Account const&) {
return getClawbackContextHash(
alice.id(), makeMptID(env.seq(alice) + 100, alice), env.seq(alice), bob.id());
});
// Wrong transaction sequence in the proof context.
runBadProof([&](Env& env,
MPTTester const& mpt,
Account const& alice,
Account const& bob,
Account const&) {
return getClawbackContextHash(
alice.id(), mpt.issuanceID(), env.seq(alice) + 1, bob.id());
});
// Wrong holder in the proof context.
runBadProof([&](Env& env,
MPTTester const& mpt,
Account const& alice,
Account const&,
Account const& carol) {
return getClawbackContextHash(alice.id(), mpt.issuanceID(), env.seq(alice), carol.id());
});
}
// Bob creates the AMM, but Bob is not the MPT holder checked below.
// The AMM has its own pseudo-account (`ammHolder`) that can hold the
// public MPT pool balance. That pseudo-account cannot normally
// initialize confidential state because the confidential txn's must be
// signed by sfAccount, and the AMM pseudo-account has no signing key.
// So this is a construction/impossibility test: public AMM MPT state exists
// but the corresponding confidential AMM clawback flow is not normally reachable.
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 | tfMPTCanHoldConfidentialBalance,
});
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 |
tfMPTCanHoldConfidentialBalance,
});
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 | tfMPTCanHoldConfidentialBalance,
});
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 | tfMPTCanHoldConfidentialBalance,
});
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 | tfMPTCanHoldConfidentialBalance,
});
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(kEcClawbackProofLength * 2, '0');
jv[sfZKProof] = dummyProof;
jv[sfMPTokenIssuanceID] = to_string(mptAlice.issuanceID());
env(jv, Ter(tecOBJECT_NOT_FOUND));
}
// After setup, bob has confidential balance 60 in spending.
std::uint32_t const setupFlags = tfMPTCanTransfer | tfMPTCanClawback | tfMPTRequireAuth |
tfMPTCanLock | tfMPTCanHoldConfidentialBalance;
std::string const dummyClawbackProof(kEcClawbackProofLength * 2, '0');
auto removeMPTokenField =
[&](Env& env, MPTTester const& mpt, Account const& holder, SField const& field) {
BEAST_EXPECT(env.app().getOpenLedger().modify([&](OpenView& view, beast::Journal) {
auto const sle = std::const_pointer_cast<SLE>(
view.read(keylet::mptoken(mpt.issuanceID(), holder.id())));
if (!sle)
return false;
sle->makeFieldAbsent(field);
view.rawReplace(sle);
return true;
}));
};
// After global COA is drained to zero, a further confidential clawback
// fails because the amount exceeds the remaining confidential
// outstanding amount.
{
Env env{*this, features};
Account const alice("alice");
Account const bob("bob");
ConfidentialEnv confEnv{
env, alice, {{.account = bob, .payAmount = 100, .convertAmount = 60}}, setupFlags};
auto& mptAlice = confEnv.mpt;
mptAlice.confidentialClaw({
.account = alice,
.holder = bob,
.amt = 60,
});
mptAlice.confidentialClaw({
.account = alice,
.holder = bob,
.amt = 1,
.proof = dummyClawbackProof,
.err = tecINSUFFICIENT_FUNDS,
});
}
// Missing issuer encrypted balance should fail before proof
// verification.
{
Env env{*this, features};
Account const alice("alice");
Account const bob("bob");
ConfidentialEnv confEnv{
env, alice, {{.account = bob, .payAmount = 100, .convertAmount = 60}}, setupFlags};
auto& mptAlice = confEnv.mpt;
removeMPTokenField(env, mptAlice, bob, sfIssuerEncryptedBalance);
mptAlice.confidentialClaw({
.account = alice,
.holder = bob,
.amt = 60,
.proof = dummyClawbackProof,
.err = tecNO_PERMISSION,
});
}
// Missing holder encryption key should fail before proof verification.
{
Env env{*this, features};
Account const alice("alice");
Account const bob("bob");
ConfidentialEnv confEnv{
env, alice, {{.account = bob, .payAmount = 100, .convertAmount = 60}}, setupFlags};
auto& mptAlice = confEnv.mpt;
removeMPTokenField(env, mptAlice, bob, sfHolderEncryptionKey);
mptAlice.confidentialClaw({
.account = alice,
.holder = bob,
.amt = 60,
.proof = dummyClawbackProof,
.err = tecNO_PERMISSION,
});
}
// lock should not block clawback. lock bob individually
{
Env env{*this, features};
Account const alice("alice");
Account const bob("bob");
ConfidentialEnv confEnv{
env, alice, {{.account = bob, .payAmount = 100, .convertAmount = 60}}, setupFlags};
auto& mptAlice = confEnv.mpt;
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");
ConfidentialEnv confEnv{
env, alice, {{.account = bob, .payAmount = 100, .convertAmount = 60}}, setupFlags};
auto& mptAlice = confEnv.mpt;
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");
ConfidentialEnv confEnv{
env, alice, {{.account = bob, .payAmount = 100, .convertAmount = 60}}, setupFlags};
auto& mptAlice = confEnv.mpt;
// 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");
ConfidentialEnv confEnv{
env, alice, {{.account = bob, .payAmount = 100, .convertAmount = 60}}, setupFlags};
auto& mptAlice = confEnv.mpt;
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 | tfMPTCanHoldConfidentialBalance,
});
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<uint64_t> 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,
});
}
// SCENARIO 3: the clawback proof omits the holder's confidential
// balance version. A proof generated before the version advances is
// still accepted, because getClawbackContextHash has no version
// component.
{
Env env{*this, features};
auto mptAlice = setupEnv(env);
mptAlice.convert({.account = bob, .amt = 500, .holderPubKey = mptAlice.getPubKey(bob)});
mptAlice.mergeInbox({
.account = bob,
});
auto const privKey = mptAlice.getPrivKey(alice);
if (!BEAST_EXPECT(privKey.has_value()))
return;
auto const proof = mptAlice.getClawbackProof(
bob,
500,
requireOptionalRef(privKey, "Missing private key"),
getClawbackContextHash(
alice.id(), mptAlice.issuanceID(), env.seq(alice), bob.id()));
if (!BEAST_EXPECT(proof.has_value()))
return;
// Advance bob's balance version after the proof is generated. An
// empty-inbox merge leaves the balance unchanged but still bumps
// sfConfidentialBalanceVersion.
auto const versionBefore = mptAlice.getMPTokenVersion(bob);
mptAlice.mergeInbox({.account = bob});
BEAST_EXPECT(mptAlice.getMPTokenVersion(bob) != versionBefore);
// The stale-version proof is still accepted.
mptAlice.confidentialClaw({
.account = alice,
.holder = bob,
.amt = 500,
.proof = strHex(requireOptional(proof, "Missing proof")),
});
}
}
void
testPublicTransfersAfterClearingConfidentialFlag(FeatureBitset features)
{
testcase("Public transfers after clearing Confidential Flag");
using namespace test::jtx;
Account const alice("alice");
Account const bob("bob");
Account const carol("carol");
// After clearing the confidential flag, all four public MPT operations
// must succeed regardless of which confidential path left encrypted-zero
// fields on bob's MPToken.
auto runPublicPayments = [&](MPTTester& mpt) {
mpt.pay(bob, carol, 10);
mpt.pay(carol, bob, 5);
mpt.pay(alice, bob, 1);
mpt.pay(carol, alice, 5);
};
auto drainAndDeleteBobMPToken = [&](Env& env, MPTTester& mpt) {
auto const bobBalance = mpt.getBalance(bob);
BEAST_EXPECT(bobBalance > 0);
mpt.pay(bob, alice, bobBalance);
BEAST_EXPECT(mpt.getBalance(bob) == 0);
mpt.authorize({.account = bob, .flags = tfMPTUnauthorize});
BEAST_EXPECT(!env.le(keylet::mptoken(mpt.issuanceID(), bob.id())));
};
// Alice pays Bob 100 public, Bob converts 50 confidential
// Bob converts 50 back to public, and make sure can receive public payments
{
Env env{*this, features};
ConfidentialEnv ct{
env,
alice,
{{.account = bob, .payAmount = 100, .convertAmount = 50}},
tfMPTCanTransfer | tfMPTCanHoldConfidentialBalance};
env.fund(XRP(1'000), carol);
ct.mpt.authorize({.account = carol});
ct.mpt.pay(alice, carol, 50);
ct.mpt.convertBack({.account = bob, .amt = 50});
runPublicPayments(ct.mpt);
drainAndDeleteBobMPToken(env, ct.mpt);
}
// Same path as above but with Auditor
{
Env env{*this, features};
Account const auditor("auditor");
MPTTester mptAlice(env, alice, {.holders = {bob, carol}, .auditor = auditor});
mptAlice.create({
.ownerCount = 1,
.flags = tfMPTCanTransfer | tfMPTCanHoldConfidentialBalance,
});
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(auditor);
mptAlice.set(
{.account = alice,
.issuerPubKey = mptAlice.getPubKey(alice),
.auditorPubKey = mptAlice.getPubKey(auditor)});
mptAlice.convert({
.account = bob,
.amt = 50,
.holderPubKey = mptAlice.getPubKey(bob),
});
mptAlice.mergeInbox({.account = bob});
mptAlice.convertBack({.account = bob, .amt = 50});
runPublicPayments(mptAlice);
drainAndDeleteBobMPToken(env, mptAlice);
}
// Confidential clawback leaves encrypted-zero fields;
// the public balance remaining after the clawback must stay usable.
{
Env env{*this, features};
ConfidentialEnv ct{
env,
alice,
{{.account = bob, .payAmount = 100, .convertAmount = 50}},
tfMPTCanTransfer | tfMPTCanClawback | tfMPTCanHoldConfidentialBalance};
env.fund(XRP(1'000), carol);
ct.mpt.authorize({.account = carol});
ct.mpt.pay(alice, carol, 50);
ct.mpt.confidentialClaw({.account = alice, .holder = bob, .amt = 50});
runPublicPayments(ct.mpt);
drainAndDeleteBobMPToken(env, ct.mpt);
}
}
void
testMutatePrivacy(FeatureBitset features)
{
testcase("mutate lsfMPTCanHoldConfidentialBalance");
using namespace test::jtx;
// can not create mpt issuance with tmfMPTCannotEnableCanHoldConfidentialBalance
// 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 = tmfMPTCannotEnableCanHoldConfidentialBalance,
.err = temDISABLED,
});
}
// can not create mpt issuance with tmfMPTCannotEnableCanHoldConfidentialBalance 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 = tmfMPTCannotEnableCanHoldConfidentialBalance,
.err = temDISABLED,
});
}
// if lsmfMPTCannotEnableCanHoldConfidentialBalance is set, can not set/clear
// lsfMPTCanHoldConfidentialBalance
{
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 = tmfMPTCannotEnableCanHoldConfidentialBalance,
});
mptAlice.set({
.account = alice,
.mutableFlags = tmfMPTSetCanHoldConfidentialBalance,
.err = tecNO_PERMISSION,
});
}
// Toggle lsfMPTCanHoldConfidentialBalance
{
Env env{*this, features};
Account const alice("alice");
Account const bob("bob");
MPTTester mptAlice(env, alice, {.holders = {bob}});
mptAlice.create({
.ownerCount = 1,
.flags = tfMPTCanTransfer | tfMPTCanHoldConfidentialBalance,
.mutableFlags = tmfMPTCanEnableCanLock,
});
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 lsfMPTCanHoldConfidentialBalance, but no effect because
// lsfMPTCanHoldConfidentialBalance was already set
mptAlice.set({
.account = alice,
.mutableFlags = tmfMPTSetCanHoldConfidentialBalance,
});
verifyToggle(tesSUCCESS, 10);
// set tmfMPTSetCanHoldConfidentialBalance again
mptAlice.set({
.account = alice,
.mutableFlags = tmfMPTSetCanHoldConfidentialBalance,
});
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}});
// lsmfMPTCannotEnableCanHoldConfidentialBalance is false by default,
// so that lsfMPTCanHoldConfidentialBalance can be mutated
mptAlice.create({
.ownerCount = 1,
.flags = tfMPTCanTransfer | tfMPTCanHoldConfidentialBalance,
});
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 lsfMPTCanHoldConfidentialBalance should fail because of
// confidential outstanding balance
mptAlice.set({
.account = alice,
.mutableFlags = tmfMPTSetCanHoldConfidentialBalance,
.err = tecNO_PERMISSION,
});
}
}
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");
ConfidentialEnv confEnv{
env, alice, {{.account = bob, .payAmount = 100, .convertAmount = 40}}};
auto& mptAlice = confEnv.mpt;
// 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 = requireOptional(
mptAlice.getDecryptedBalance(bob, MPTTester::holderEncryptedSpending),
"Missing spending balance");
auto const encryptedSpendingBalance = requireOptional(
mptAlice.getEncryptedBalance(bob, MPTTester::holderEncryptedSpending),
"Missing encrypted spending balance");
BEAST_EXPECT(!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 compact ConvertBack proof validation
// correctly rejects proofs generated with incorrect parameters.
// The compact proof simultaneously verifies balance ownership,
// commitment linkage, and that remaining balance is non-negative.
// 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 badContextHash{1};
Buffer const proof = mptAlice.getConvertBackProof(
bob,
amt,
badContextHash, // wrong context hash
{
.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,
});
}
// 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");
ConfidentialEnv confEnv{
env, alice, {{.account = bob, .payAmount = 100, .convertAmount = 40}}};
auto& mptAlice = confEnv.mpt;
// 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 = requireOptional(
mptAlice.getDecryptedBalance(bob, MPTTester::holderEncryptedSpending),
"Missing spending balance");
auto const encryptedSpendingBalance = requireOptional(
mptAlice.getEncryptedBalance(bob, MPTTester::holderEncryptedSpending),
"Missing encrypted spending balance");
BEAST_EXPECT(!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 compact ConvertBack proof (sigma + bulletproof)
// correctly rejects proofs generated with incorrect parameters.
// The compact proof simultaneously verifies balance ownership, commitment
// linkage, and that the remaining balance is non-negative.
// Test 1: Proof generated with wrong balance value.
// The sigma proof claims balance=1 but the spending balance contains the
// actual balance. The compact proof's balance-linkage check fails.
{
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 (actual balance is ~40)
.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 compact sigma proof must use the same blinding factor (rho) as the
// Pedersen commitment PC = balance*G + rho*H. Using a different rho
// creates an inconsistency the verifier detects.
{
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 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 badContextHash{1};
Buffer const proof = mptAlice.getConvertBackProof(
bob,
amt,
badContextHash, // wrong context hash
{
.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,
});
}
// 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,
});
}
}
// A convert-back proof is bound to (account, issuance, sequence, version) via
// the Fiat-Shamir context hash. Crafting a proof against any single wrong
// variable and submitting it with the real parameters must be rejected
// with tecBAD_PROOF
void
testConvertBackInvalidProofContextBinding(FeatureBitset features)
{
testcase("ConvertBack proof context binding");
using namespace test::jtx;
auto runBadProof = [&](auto makeContextHash) {
Env env{*this, features};
Account const alice("alice");
Account const bob("bob");
Account const carol("carol");
ConfidentialEnv confEnv{
env, alice, {{.account = bob, .payAmount = 100, .convertAmount = 40}}};
auto& mptAlice = confEnv.mpt;
std::uint64_t const amt = 10;
Buffer const blindingFactor = generateBlindingFactor();
Buffer const pcBlindingFactor = generateBlindingFactor();
auto const spendingBalance =
mptAlice.getDecryptedBalance(bob, MPTTester::holderEncryptedSpending);
auto const encryptedSpendingBalance =
mptAlice.getEncryptedBalance(bob, MPTTester::holderEncryptedSpending);
if (!BEAST_EXPECT(spendingBalance && encryptedSpendingBalance))
return;
Buffer const pedersenCommitment = mptAlice.getPedersenCommitment(
requireOptional(spendingBalance, "Missing spending balance"), pcBlindingFactor);
Buffer const issuerCiphertext = mptAlice.encryptAmount(alice, amt, blindingFactor);
Buffer const bobCiphertext = mptAlice.encryptAmount(bob, amt, blindingFactor);
auto const version = mptAlice.getMPTokenVersion(bob);
Buffer const proof = mptAlice.getConvertBackProof(
bob,
amt,
makeContextHash(env, mptAlice, alice, bob, carol, version),
{
.pedersenCommitment = pedersenCommitment,
.amt = requireOptional(spendingBalance, "Missing spending balance"),
.encryptedAmt = requireOptionalRef(
encryptedSpendingBalance, "Missing encrypted spending balance"),
.blindingFactor = pcBlindingFactor,
});
mptAlice.convertBack({
.account = bob,
.amt = amt,
.proof = proof,
.holderEncryptedAmt = bobCiphertext,
.issuerEncryptedAmt = issuerCiphertext,
.blindingFactor = blindingFactor,
.pedersenCommitment = pedersenCommitment,
.err = tecBAD_PROOF,
});
};
// Wrong account in the proof context.
runBadProof([&](Env& env,
MPTTester const& mpt,
Account const&,
Account const& bob,
Account const& carol,
std::uint32_t version) {
return getConvertBackContextHash(carol.id(), mpt.issuanceID(), env.seq(bob), version);
});
// Wrong issuance ID in the proof context.
runBadProof([&](Env& env,
MPTTester const&,
Account const& alice,
Account const& bob,
Account const&,
std::uint32_t version) {
return getConvertBackContextHash(
bob.id(), makeMptID(env.seq(alice) + 100, alice), env.seq(bob), version);
});
// Wrong transaction sequence in the proof context.
runBadProof([&](Env& env,
MPTTester const& mpt,
Account const&,
Account const& bob,
Account const&,
std::uint32_t version) {
return getConvertBackContextHash(bob.id(), mpt.issuanceID(), env.seq(bob) + 1, version);
});
// Wrong balance version in the proof context.
runBadProof([&](Env& env,
MPTTester const& mpt,
Account const&,
Account const& bob,
Account const&,
std::uint32_t version) {
return getConvertBackContextHash(bob.id(), mpt.issuanceID(), env.seq(bob), version + 1);
});
}
// 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
testConvertBackProofCiphertextBinding(FeatureBitset features)
{
testcase("ConvertBack: proof ciphertext binding");
using namespace test::jtx;
Env env{*this, features};
Account const alice("alice"), bob("bob");
ConfidentialEnv confEnv{
env, alice, {{.account = bob, .payAmount = 100, .convertAmount = 50}}};
auto& mptAlice = confEnv.mpt;
auto const spendingBalance = requireOptional(
mptAlice.getDecryptedBalance(bob, MPTTester::holderEncryptedSpending),
"Missing spending balance");
auto const encryptedSpendingBalance = requireOptional(
mptAlice.getEncryptedBalance(bob, MPTTester::holderEncryptedSpending),
"Missing encrypted spending balance");
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
testConvertBackProofVersionMismatch(FeatureBitset features)
{
testcase("ConvertBack: proof version mismatch");
using namespace test::jtx;
Env env{*this, features};
Account const alice("alice"), bob("bob");
ConfidentialEnv confEnv{
env, alice, {{.account = bob, .payAmount = 1000, .convertAmount = 100}}};
auto& mptAlice = confEnv.mpt;
auto const versionV = mptAlice.getMPTokenVersion(bob);
auto const spendingBalanceV = requireOptional(
mptAlice.getDecryptedBalance(bob, MPTTester::holderEncryptedSpending),
"Missing spending balance");
auto const encryptedSpendingBalanceV = requireOptional(
mptAlice.getEncryptedBalance(bob, MPTTester::holderEncryptedSpending),
"Missing encrypted spending balance");
// 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
testConvertBackHomomorphicCiphertextModification(FeatureBitset features)
{
testcase("ConvertBack: homomorphic ciphertext modification");
using namespace test::jtx;
Env env{*this, features};
Account const alice("alice"), bob("bob");
ConfidentialEnv confEnv{
env, alice, {{.account = bob, .payAmount = 100, .convertAmount = 50}}};
auto& mptAlice = confEnv.mpt;
// 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)
Buffer tamperedHolderCipherText = requireOptional(
homomorphicAdd(holderCipherText, deltaCipherText), "Missing tampered ciphertext");
// Generate a valid proof for the ORIGINAL amount (10)
auto const spendingBal = requireOptional(
mptAlice.getDecryptedBalance(bob, MPTTester::holderEncryptedSpending),
"Missing spending balance");
auto const spendingBalEnc = requireOptional(
mptAlice.getEncryptedBalance(bob, MPTTester::holderEncryptedSpending),
"Missing encrypted spending balance");
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 xrpld 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 (kMaxMpTokenAmount) 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"), bob("bob"), carol("carol");
ConfidentialEnv confEnv{
env,
alice,
{{.account = bob, .payAmount = 100, .convertAmount = 100},
{.account = carol, .payAmount = 50, .convertAmount = 50}}};
auto& mptAlice = confEnv.mpt;
// Bob sends 10 to carol. The send amount (10) and Bob's remaining balance
// (90) are both within [0, kMaxMpTokenAmount]. 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::holderEncryptedSpending);
BEAST_EXPECT(bobSpendingBefore == 90);
// Construct Enc(kMaxMpTokenAmount) with Bob's public key.
Buffer const bf1 = generateBlindingFactor();
Buffer const encMax = mptAlice.encryptAmount(bob, kMaxMpTokenAmount, 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)
Buffer overflowedCt =
requireOptional(homomorphicAdd(encMax, encOne), "Missing overflowed ciphertext");
// Submit the send transaction with the tampered ciphertext.
// Setting amt = kMaxMpTokenAmount + 1 drives proof generation for the
// overflowed value. The bulletproof range check [0, kMaxMpTokenAmount]
// rejects MAX+1; the validator must return tecBAD_PROOF.
mptAlice.send({
.account = bob,
.dest = carol,
.amt = kMaxMpTokenAmount + 1,
.senderEncryptedAmt = overflowedCt,
.err = tecBAD_PROOF,
});
auto const bobSpendingAfter =
mptAlice.getDecryptedBalance(bob, MPTTester::holderEncryptedSpending);
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, kMaxMpTokenAmount],
* 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"), bob("bob");
ConfidentialEnv confEnv{
env, alice, {{.account = bob, .payAmount = 10, .convertAmount = 10}}};
auto& mptAlice = confEnv.mpt;
// Converting back 1 from 10 leaves remaining balance = 9 (non-negative).
// Range proof [0, kMaxMpTokenAmount] 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::holderEncryptedSpending);
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, kMaxMpTokenAmount].
Buffer underflowedCt =
requireOptional(homomorphicSubtract(encZero, encOne), "Missing underflowed ciphertext");
// 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, kMaxMpTokenAmount].
constexpr std::uint64_t kUnderflowedAmt =
static_cast<std::uint64_t>(0) - static_cast<std::uint64_t>(1);
Buffer const pcBf = generateBlindingFactor();
Buffer const pedersenCommitment = mptAlice.getPedersenCommitment(kUnderflowedAmt, 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 = kUnderflowedAmt,
.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::holderEncryptedSpending);
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.
{
Account const alice("alice"), bob("bob"), carol("carol");
Env env{*this, features};
ConfidentialEnv confEnv{
env,
alice,
{{.account = bob, .payAmount = 100, .convertAmount = 60},
{.account = carol, .payAmount = 50, .convertAmount = 30}}};
auto& mptAlice = confEnv.mpt;
// sender's encrypted amount has an invalid coordinate
mptAlice.send({
.account = bob,
.dest = carol,
.amt = 10,
.proof = getTrivialSendProofHex(),
.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(),
.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(),
.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(kEcPedersenCommitmentLength);
std::memset(badCommitment.data(), 0xFF, kEcPedersenCommitmentLength);
badCommitment.data()[0] = kEcCompressedPrefixEvenY;
mptAlice.send({
.account = bob,
.dest = carol,
.amt = 10,
.proof = getTrivialSendProofHex(),
.amountCommitment = badCommitment,
.balanceCommitment = getTrivialCommitment(),
.err = temMALFORMED,
});
mptAlice.send({
.account = bob,
.dest = carol,
.amt = 10,
.proof = getTrivialSendProofHex(),
.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.
{
Account const alice("alice"), bob("bob"), carol("carol");
Env env{*this, features};
ConfidentialEnv confEnv{
env,
alice,
{{.account = bob, .payAmount = 100, .convertAmount = 60},
{.account = carol, .payAmount = 50, .convertAmount = 30}}};
auto& mptAlice = confEnv.mpt;
Buffer badProof(kEcSendProofLength);
std::memset(badProof.data(), 0xFF, kEcSendProofLength);
badProof.data()[0] = kEcCompressedPrefixEvenY;
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.
{
Account const alice("alice"), bob("bob"), carol("carol");
Env env{*this, features};
ConfidentialEnv confEnv{
env,
alice,
{{.account = bob, .payAmount = 100, .convertAmount = 60},
{.account = carol, .payAmount = 50, .convertAmount = 30}}};
auto& mptAlice = confEnv.mpt;
// 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(kEcGamalEncryptedTotalLength);
std::memset(badC1goodC2.data(), 0xFF, kEcGamalEncryptedTotalLength);
badC1goodC2.data()[0] = kEcCompressedPrefixEvenY;
std::memcpy(
badC1goodC2.data() + kEcCiphertextComponentLength,
tc.data() + kEcCiphertextComponentLength,
kEcCiphertextComponentLength);
// C1 = valid trivial point, C2 = bad (0xFF...FF)
Buffer goodC1badC2(kEcGamalEncryptedTotalLength);
std::memset(goodC1badC2.data(), 0xFF, kEcGamalEncryptedTotalLength);
std::memcpy(goodC1badC2.data(), tc.data(), kEcCiphertextComponentLength);
goodC1badC2.data()[kEcCiphertextComponentLength] = kEcCompressedPrefixEvenY;
// sender's encrypted amount — bad C1
mptAlice.send({
.account = bob,
.dest = carol,
.amt = 10,
.proof = getTrivialSendProofHex(),
.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(),
.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(),
.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(),
.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");
ConfidentialEnv confEnv{
env,
alice,
{{.account = bob, .payAmount = 100, .convertAmount = 60},
{.account = carol, .payAmount = 50, .convertAmount = 30}}};
auto& mptAlice = confEnv.mpt;
// 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(kEcGamalEncryptedTotalLength);
wrongGroupCt.data()[0] = kEcCompressedPrefixEvenY;
std::memcpy(wrongGroupCt.data() + 1, kP256GeneratorX, 32);
wrongGroupCt.data()[kEcCiphertextComponentLength] = kEcCompressedPrefixEvenY;
std::memcpy(wrongGroupCt.data() + kEcCiphertextComponentLength + 1, kP256GeneratorX, 32);
// A 33-byte commitment using the same wrong-curve x-coordinate.
Buffer wrongGroupCommitment(kEcPedersenCommitmentLength);
wrongGroupCommitment.data()[0] = kEcCompressedPrefixEvenY;
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(),
.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(),
.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(),
.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(),
.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(),
.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
testConvertIdentityElementRejection(FeatureBitset features)
{
testcase("Convert: 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 = gMakeZeroBuffer(kEcPubKeyLength);
// 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 | tfMPTCanHoldConfidentialBalance,
});
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 | tfMPTCanHoldConfidentialBalance,
});
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"), bob("bob"), carol("carol");
ConfidentialEnv confEnv{
env,
alice,
{{.account = bob, .payAmount = 100, .convertAmount = 100},
{.account = carol, .payAmount = 50, .convertAmount = 50}}};
auto& mptAlice = confEnv.mpt;
auto const bobSpendingBefore =
mptAlice.getDecryptedBalance(bob, MPTTester::holderEncryptedSpending);
// 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::holderEncryptedSpending) ==
bobSpendingBefore);
BEAST_EXPECT(mptAlice.getDecryptedBalance(carol, MPTTester::holderEncryptedInbox) == 0);
}
// This test verifies that the compact AND-composed Send sigma proof
// enforces the shared-randomness invariant across participants.
void
testSendSharedRandomnessViolation(FeatureBitset features)
{
testcase("divergent C1 across participants in ConfidentialMPTSend");
using namespace test::jtx;
Env env{*this, features};
Account const alice("alice");
Account const bob("bob");
Account const carol("carol");
Account const auditor("auditor");
ConfidentialEnv confEnv{
env,
alice,
{{.account = bob, .payAmount = 100, .convertAmount = 50},
{.account = carol, .payAmount = 50, .convertAmount = 50}},
tfMPTCanLock | tfMPTCanHoldConfidentialBalance | tfMPTCanTransfer,
auditor};
auto& mptAlice = confEnv.mpt;
// Send amount is 10.
uint64_t const amt = 10;
enum class Participant { Sender, Dest, Issuer, Auditor };
// This lambda submits a send transaction where one of the four ciphertexts
// is encrypted with different randomness than the one used to build the proof.
// Note: When divergent is nullopt, all participants
// will use the same randomness and expected to succeed, this is the
// control case that confirms the test setup itself is sound, the bad proof
// is actually from divergent randomness, not other causes.
auto submitWithDivergentC1 = [&](std::optional<Participant> divergent) {
ConfidentialSendSetup setup(mptAlice, bob, carol, alice, amt, std::cref(auditor));
auto const proofOpt =
requireOptional(setup.generateProof(mptAlice, env, bob, carol), "Missing proof");
// Re-encrypt one participant's ciphertext with divergent randomness.
Buffer senderCt = setup.senderAmt;
Buffer destCt = setup.destAmt;
Buffer issuerCt = setup.issuerAmt;
Buffer auditorCt =
requireOptionalRef(setup.auditorAmt, "Missing auditor encrypted amount");
if (divergent)
{
Buffer const bfDivergent = generateBlindingFactor();
switch (*divergent)
{
case Participant::Sender:
senderCt = mptAlice.encryptAmount(bob, amt, bfDivergent);
break;
case Participant::Dest:
destCt = mptAlice.encryptAmount(carol, amt, bfDivergent);
break;
case Participant::Issuer:
issuerCt = mptAlice.encryptAmount(alice, amt, bfDivergent);
break;
case Participant::Auditor:
auditorCt = mptAlice.encryptAmount(auditor, amt, bfDivergent);
break;
}
}
TER const expectedErr = divergent ? TER{tecBAD_PROOF} : TER{tesSUCCESS};
mptAlice.send({
.account = bob,
.dest = carol,
.amt = amt,
.proof = strHex(proofOpt),
.senderEncryptedAmt = senderCt,
.destEncryptedAmt = destCt,
.issuerEncryptedAmt = issuerCt,
.auditorEncryptedAmt = auditorCt,
.blindingFactor = setup.blindingFactor,
.amountCommitment = setup.amountCommitment,
.balanceCommitment = setup.balanceCommitment,
.err = expectedErr,
});
// Verify balances.
auto const spendingAfter =
mptAlice.getDecryptedBalance(bob, MPTTester::holderEncryptedSpending);
if (divergent)
{
BEAST_EXPECT(spendingAfter == setup.prevSpending);
}
else
{
BEAST_EXPECT(spendingAfter == setup.prevSpending - amt);
}
};
// This confirms the test setup is sound, if any of the divergent cases below
// fail, it is due to the C1 mismatch and not a setup bug.
submitWithDivergentC1(std::nullopt);
// Divergent C1 for different participants should all fail with tecBAD_PROOF:
submitWithDivergentC1(Participant::Sender);
submitWithDivergentC1(Participant::Dest);
submitWithDivergentC1(Participant::Issuer);
submitWithDivergentC1(Participant::Auditor);
}
void
testConfidentialMPTBaseFee(FeatureBitset features)
{
testcase("test confidential transactions fee");
using namespace test::jtx;
auto setup =
[&](MPTTester& mpt, Account const& alice, Account const& bob, Account const& carol) {
mpt.create({
.ownerCount = 1,
.flags = tfMPTCanLock | tfMPTCanHoldConfidentialBalance | tfMPTCanTransfer |
tfMPTCanClawback,
});
mpt.authorize({.account = bob});
mpt.authorize({.account = carol});
mpt.pay(alice, bob, 100);
mpt.pay(alice, carol, 50);
mpt.generateKeyPair(alice);
mpt.generateKeyPair(bob);
mpt.generateKeyPair(carol);
mpt.set({.account = alice, .issuerPubKey = mpt.getPubKey(alice)});
};
// test expected base fee for confidential transactions
{
Env env{*this, features};
Account const alice("alice"), bob("bob"), carol("carol");
MPTTester mptAlice(env, alice, {.holders = {bob, carol}});
setup(mptAlice, alice, bob, carol);
auto const baseFee = env.current()->fees().base;
auto const expectedFee = baseFee * (kConfidentialFeeMultiplier + 1);
// lambda function to submit confidential transaction and check fee charged to the
// account
auto checkFee = [&](Account const& acct, auto&& submitFn) {
auto const before = env.balance(acct);
submitFn();
auto const after = env.balance(acct);
BEAST_EXPECT(before - after == expectedFee);
};
checkFee(bob, [&]() {
mptAlice.convert(
{.account = bob,
.amt = 50,
.holderPubKey = mptAlice.getPubKey(bob),
.fee = expectedFee});
});
checkFee(carol, [&]() {
mptAlice.convert(
{.account = carol,
.amt = 10,
.holderPubKey = mptAlice.getPubKey(carol),
.fee = expectedFee});
});
checkFee(bob, [&]() { mptAlice.mergeInbox({.account = bob, .fee = expectedFee}); });
checkFee(carol, [&]() { mptAlice.mergeInbox({.account = carol, .fee = expectedFee}); });
checkFee(bob, [&]() {
mptAlice.send({.account = bob, .dest = carol, .amt = 5, .fee = expectedFee});
});
checkFee(bob, [&]() {
mptAlice.convertBack({.account = bob, .amt = 5, .fee = expectedFee});
});
checkFee(alice, [&]() {
mptAlice.confidentialClaw(
{.account = alice, .holder = carol, .amt = 15, .fee = expectedFee});
});
}
// test insufficient fee for confidential transactions
{
Env env{*this, features};
Account const alice("alice"), bob("bob"), carol("carol");
MPTTester mptAlice(env, alice, {.holders = {bob, carol}});
setup(mptAlice, alice, bob, carol);
auto const baseFee = env.current()->fees().base;
auto const expectedFee = baseFee * (kConfidentialFeeMultiplier + 1);
mptAlice.convert(
{.account = bob,
.amt = 1,
.holderPubKey = mptAlice.getPubKey(bob),
.fee = expectedFee - 1,
.err = telINSUF_FEE_P});
mptAlice.mergeInbox({.account = bob, .fee = baseFee, .err = telINSUF_FEE_P});
mptAlice.send(
{.account = bob,
.dest = carol,
.amt = 1,
.fee = baseFee * kConfidentialFeeMultiplier,
.err = telINSUF_FEE_P});
mptAlice.convertBack({.account = bob, .amt = 1, .fee = baseFee, .err = telINSUF_FEE_P});
mptAlice.confidentialClaw(
{.account = alice,
.holder = carol,
.amt = 1,
.fee = baseFee,
.err = telINSUF_FEE_P});
}
// test excessive fee for confidential transactions
{
Env env{*this, features};
Account const alice("alice"), bob("bob"), carol("carol");
MPTTester mptAlice(env, alice, {.holders = {bob, carol}});
setup(mptAlice, alice, bob, carol);
auto const baseFee = env.current()->fees().base;
auto const highFee = baseFee * (kConfidentialFeeMultiplier + 1) * 2;
auto const bobBefore = env.balance(bob);
mptAlice.convert(
{.account = bob,
.amt = 1,
.holderPubKey = mptAlice.getPubKey(bob),
.fee = highFee});
BEAST_EXPECT(env.balance(bob) == bobBefore - highFee);
}
}
void
testSendForgedEqualityProof(FeatureBitset features)
{
testcase("Send: forged equality proof");
// Test that modifying a ciphertext after proof generation causes
// verification to fail. The Fiat-Shamir challenge binds ciphertexts
// to the proof, so any modification invalidates the proof.
using namespace test::jtx;
Env env{*this, features};
Account const alice("alice"), bob("bob"), carol("carol");
ConfidentialEnv confEnv{
env,
alice,
{{.account = bob}, {.account = carol, .payAmount = 1000, .convertAmount = 50}}};
auto& mptAlice = confEnv.mpt;
ConfidentialSendSetup const setup(mptAlice, bob, carol, alice, 10);
// Forge destination ciphertext (Enc(20) instead of Enc(10))
{
auto const proof = setup.generateProof(mptAlice, env, bob, carol);
if (!BEAST_EXPECT(proof.has_value()))
return;
Buffer const forgedBlindingFactor = generateBlindingFactor();
auto const forgedDestAmt = mptAlice.encryptAmount(carol, 20, forgedBlindingFactor);
auto args = setup.sendArgs(
bob, carol, requireOptionalRef(proof, "Missing proof"), tecBAD_PROOF);
args.destEncryptedAmt = forgedDestAmt;
mptAlice.send(args);
}
// Forge sender's ciphertext (Enc(5) instead of Enc(10))
{
auto const proof = setup.generateProof(mptAlice, env, bob, carol);
if (!BEAST_EXPECT(proof.has_value()))
return;
Buffer const forgedBlindingFactor = generateBlindingFactor();
auto const forgedSenderAmt = mptAlice.encryptAmount(bob, 5, forgedBlindingFactor);
auto args = setup.sendArgs(
bob, carol, requireOptionalRef(proof, "Missing proof"), tecBAD_PROOF);
args.senderEncryptedAmt = forgedSenderAmt;
mptAlice.send(args);
}
// Forge issuer's ciphertext (Enc(100) instead of Enc(10))
{
auto const proof = setup.generateProof(mptAlice, env, bob, carol);
if (!BEAST_EXPECT(proof.has_value()))
return;
Buffer const forgedBlindingFactor = generateBlindingFactor();
auto const forgedIssuerAmt = mptAlice.encryptAmount(alice, 100, forgedBlindingFactor);
auto args = setup.sendArgs(
bob, carol, requireOptionalRef(proof, "Missing proof"), tecBAD_PROOF);
args.issuerEncryptedAmt = forgedIssuerAmt;
mptAlice.send(args);
}
}
void
testSendForgedRangeProof(FeatureBitset features)
{
testcase("Send: forged range proof");
// Attack: send uint64_max tokens using Enc(uint64_max) ciphertexts
// and a corrupted bulletproof. Verifier rejects due to inner-product
// mismatch and Fiat-Shamir transcript divergence. Supply invariant
// is preserved.
using namespace test::jtx;
Env env{*this, features};
Account const alice("alice"), bob("bob"), carol("carol");
ConfidentialEnv confEnv{
env,
alice,
{{.account = bob}, {.account = carol, .payAmount = 1000, .convertAmount = 50}}};
auto& mptAlice = confEnv.mpt;
uint64_t const badAmount = std::numeric_limits<uint64_t>::max();
Buffer const blindingFactor = generateBlindingFactor();
// Construct Enc(uint64_max) ciphertexts and commitment.
auto const senderAmt = mptAlice.encryptAmount(bob, badAmount, blindingFactor);
auto const destAmt = mptAlice.encryptAmount(carol, badAmount, blindingFactor);
auto const issuerAmt = mptAlice.encryptAmount(alice, badAmount, blindingFactor);
auto const amountCommitment = mptAlice.getPedersenCommitment(badAmount, blindingFactor);
// Balance commitment for Bob's actual balance.
auto const prevSpending = requireOptional(
mptAlice.getDecryptedBalance(bob, MPTTester::holderEncryptedSpending),
"Missing previous spending balance");
auto const balanceBlindingFactor = generateBlindingFactor();
auto const balanceCommitment =
mptAlice.getPedersenCommitment(prevSpending, balanceBlindingFactor);
// Generate a valid proof for a legitimate amount, then corrupt
// the bulletproof segment to simulate a forged range proof.
ConfidentialSendSetup const setup(mptAlice, bob, carol, alice, 10);
auto const validProof = setup.generateProof(mptAlice, env, bob, carol);
if (!BEAST_EXPECT(validProof.has_value()))
return;
// Corrupt bulletproof bytes.
Buffer forgedProof = requireOptional(validProof, "Missing valid proof");
for (size_t i = kBulletproofOffset; i < forgedProof.size(); i += 7)
forgedProof.data()[i] ^= 0xFF;
// Submit — rejected due to commitment mismatch.
mptAlice.send(
{.account = bob,
.dest = carol,
.amt = badAmount,
.proof = strHex(forgedProof),
.senderEncryptedAmt = senderAmt,
.destEncryptedAmt = destAmt,
.issuerEncryptedAmt = issuerAmt,
.amountCommitment = amountCommitment,
.balanceCommitment = balanceCommitment,
.err = tecBAD_PROOF});
// Supply invariant: Bob's balance unchanged.
auto const postSpending = requireOptional(
mptAlice.getDecryptedBalance(bob, MPTTester::holderEncryptedSpending),
"Missing post spending balance");
BEAST_EXPECT(postSpending == prevSpending);
}
void
testSendNegativeValueMalleability(FeatureBitset features)
{
testcase("Send: negative value malleability");
// Attack: forge a bulletproof claiming remaining = (uint64_t)(-10).
// Bob has 10 tokens, sends 10. Honest remaining is 0, but the
// forged proof claims 0xFFFFFFFFFFFFFFF6. Rejected because
// PC(0) != PC(0xFFFFFFFFFFFFFFF6).
using namespace test::jtx;
// Bob converts exactly 10 tokens, leaving honest remaining = 0.
Env env{*this, features};
Account const alice("alice"), bob("bob"), carol("carol");
ConfidentialEnv confEnv{
env,
alice,
{{.account = bob, .payAmount = 1000, .convertAmount = 10},
{.account = carol, .payAmount = 1000, .convertAmount = 50}}};
auto& mptAlice = confEnv.mpt;
uint64_t const sendAmount = 10;
auto const negativeRemaining = static_cast<uint64_t>(-10); // 0xFFFFFFFFFFFFFFF6
ConfidentialSendSetup const setup(mptAlice, bob, carol, alice, sendAmount);
auto const ctxHash = getSendContextHash(
bob.id(), mptAlice.issuanceID(), env.seq(bob), carol.id(), setup.version);
auto const validProof = setup.generateProof(mptAlice, env, bob, carol);
if (!BEAST_EXPECT(validProof.has_value()))
return;
// Forge bulletproof for {10, 0xFFFFFFFFFFFFFFF6} and splice it in.
auto const forgedBulletproof = getForgedBulletproof(
{sendAmount, negativeRemaining},
{setup.amountBlindingFactor, setup.balanceBlindingFactor},
ctxHash);
Buffer forgedProof(requireOptionalRef(validProof, "Missing valid proof").size());
std::memcpy(
forgedProof.data(),
requireOptionalRef(validProof, "Missing valid proof").data(),
kBulletproofOffset);
std::memcpy(
forgedProof.data() + kBulletproofOffset,
forgedBulletproof.data(),
kEcDoubleBulletproofLength);
mptAlice.send(setup.sendArgs(bob, carol, forgedProof, tecBAD_PROOF));
// Supply invariant: Bob's balance unchanged.
auto const postSpending = requireOptional(
mptAlice.getDecryptedBalance(bob, MPTTester::holderEncryptedSpending),
"Missing post spending balance");
BEAST_EXPECT(postSpending == setup.prevSpending);
}
void
testSendInvalidProofContextBinding(FeatureBitset features)
{
testcase("Send proof context binding");
using namespace test::jtx;
auto runBadProof = [&](auto makeContextHash) {
Env env{*this, features};
Account const alice("alice");
Account const bob("bob");
Account const carol("carol");
ConfidentialEnv confEnv{
env,
alice,
{{.account = bob, .payAmount = 100, .convertAmount = 40}, {.account = carol}}};
auto& mptAlice = confEnv.mpt;
ConfidentialSendSetup const setup(mptAlice, bob, carol, alice, 10);
auto const proof = mptAlice.getConfidentialSendProof(
bob,
setup.sendAmount,
setup.recipients,
setup.blindingFactor,
makeContextHash(env, mptAlice, alice, bob, carol, setup.version),
{
.pedersenCommitment = setup.amountCommitment,
.amt = setup.sendAmount,
.encryptedAmt = setup.senderAmt,
.blindingFactor = setup.amountBlindingFactor,
},
{
.pedersenCommitment = setup.balanceCommitment,
.amt = setup.prevSpending,
.encryptedAmt = setup.prevEncryptedSpending,
.blindingFactor = setup.balanceBlindingFactor,
});
if (!BEAST_EXPECT(proof.has_value()))
return;
mptAlice.send(setup.sendArgs(
bob, carol, requireOptionalRef(proof, "Missing proof"), tecBAD_PROOF));
};
// Wrong sender account in the proof context.
runBadProof([&](Env& env,
MPTTester const& mpt,
Account const&,
Account const& bob,
Account const& carol,
std::uint32_t version) {
return getSendContextHash(
carol.id(), mpt.issuanceID(), env.seq(bob), carol.id(), version);
});
// Wrong issuance ID in the proof context.
runBadProof([&](Env& env,
MPTTester const&,
Account const& alice,
Account const& bob,
Account const& carol,
std::uint32_t version) {
return getSendContextHash(
bob.id(),
makeMptID(env.seq(alice) + 100, alice),
env.seq(bob),
carol.id(),
version);
});
// Wrong transaction sequence in the proof context.
runBadProof([&](Env& env,
MPTTester const& mpt,
Account const&,
Account const& bob,
Account const& carol,
std::uint32_t version) {
return getSendContextHash(
bob.id(), mpt.issuanceID(), env.seq(bob) + 1, carol.id(), version);
});
// Wrong destination in the proof context.
runBadProof([&](Env& env,
MPTTester const& mpt,
Account const&,
Account const& bob,
Account const&,
std::uint32_t version) {
return getSendContextHash(bob.id(), mpt.issuanceID(), env.seq(bob), bob.id(), version);
});
// Wrong balance version in the proof context.
runBadProof([&](Env& env,
MPTTester const& mpt,
Account const&,
Account const& bob,
Account const& carol,
std::uint32_t version) {
return getSendContextHash(
bob.id(), mpt.issuanceID(), env.seq(bob), carol.id(), version + 1);
});
}
void
testSendFiatShamirBinding(FeatureBitset features)
{
testcase("Send: Fiat-Shamir Binding");
using namespace test::jtx;
Env env{*this, features};
Account const alice("alice"), bob("bob"), carol("carol");
ConfidentialEnv confEnv{
env,
alice,
{{.account = bob}, {.account = carol, .payAmount = 1000, .convertAmount = 50}}};
auto& mptAlice = confEnv.mpt;
ConfidentialSendSetup const setup(mptAlice, bob, carol, alice, 10);
// Variant A: forged amount commitment.
{
auto const proof = setup.generateProof(mptAlice, env, bob, carol);
if (!BEAST_EXPECT(proof.has_value()))
return;
auto const forgedBlindingFactor = generateBlindingFactor();
auto const forgedCommitment =
mptAlice.getPedersenCommitment(setup.sendAmount + 5, forgedBlindingFactor);
auto args = setup.sendArgs(
bob, carol, requireOptionalRef(proof, "Missing proof"), tecBAD_PROOF);
args.amountCommitment = forgedCommitment;
mptAlice.send(args);
}
// Variant B: proof replay at a different sequence.
{
auto const proof = setup.generateProof(mptAlice, env, bob, carol);
if (!BEAST_EXPECT(proof.has_value()))
return;
mptAlice.pay(bob, carol, 1);
env.close();
mptAlice.send(setup.sendArgs(
bob, carol, requireOptionalRef(proof, "Missing proof"), tecBAD_PROOF));
}
// Variant C: tampered response scalars.
{
auto const proof = setup.generateProof(mptAlice, env, bob, carol);
if (!BEAST_EXPECT(proof.has_value()))
return;
auto const& proofRef = requireOptionalRef(proof, "Missing proof");
Buffer tamperedProof(proofRef.size());
std::memcpy(tamperedProof.data(), proofRef.data(), proofRef.size());
size_t const tamperOffset = tamperedProof.size() / 2;
tamperedProof.data()[tamperOffset] ^= 0xFF;
mptAlice.send(setup.sendArgs(bob, carol, tamperedProof, tecBAD_PROOF));
}
}
void
testSendProofComponentReuse(FeatureBitset features)
{
testcase("Send: Proof Component Reuse");
using namespace test::jtx;
Env env{*this, features};
Account const alice("alice"), bob("bob"), carol("carol"), dan("dan");
ConfidentialEnv confEnv{
env,
alice,
{{.account = bob},
{.account = carol, .payAmount = 1000, .convertAmount = 50},
{.account = dan, .payAmount = 1000, .convertAmount = 50}}};
auto& mptAlice = confEnv.mpt;
uint64_t const sendAmount = 10;
// Variant A: replay proof to same destination after sequence changes.
{
ConfidentialSendSetup const setup(mptAlice, bob, carol, alice, sendAmount);
auto const proof = setup.generateProof(mptAlice, env, bob, carol);
if (!BEAST_EXPECT(proof.has_value()))
return;
mptAlice.send(setup.sendArgs(bob, carol, requireOptionalRef(proof, "Missing proof")));
mptAlice.mergeInbox({.account = carol});
mptAlice.send(setup.sendArgs(
bob, carol, requireOptionalRef(proof, "Missing proof"), tecBAD_PROOF));
}
// Variant B: replay proof to a different destination.
{
ConfidentialSendSetup const setup(mptAlice, bob, carol, alice, sendAmount);
auto const proof = setup.generateProof(mptAlice, env, bob, carol);
if (!BEAST_EXPECT(proof.has_value()))
return;
mptAlice.send(setup.sendArgs(bob, carol, requireOptionalRef(proof, "Missing proof")));
mptAlice.mergeInbox({.account = carol});
auto const destAmtDan = mptAlice.encryptAmount(dan, sendAmount, setup.blindingFactor);
auto const issuerAmtDan =
mptAlice.encryptAmount(alice, sendAmount, setup.blindingFactor);
auto args =
setup.sendArgs(bob, dan, requireOptionalRef(proof, "Missing proof"), tecBAD_PROOF);
args.destEncryptedAmt = destAmtDan;
args.issuerEncryptedAmt = issuerAmtDan;
mptAlice.send(args);
}
}
void
testSendSpecialWitnessValues(FeatureBitset features)
{
testcase("Send: special witness values");
using namespace test::jtx;
Env env{*this, features};
Account const alice("alice"), bob("bob"), carol("carol");
ConfidentialEnv confEnv{
env,
alice,
{{.account = bob}, {.account = carol, .payAmount = 1000, .convertAmount = 50}}};
auto& mptAlice = confEnv.mpt;
ConfidentialSendSetup const setup(mptAlice, bob, carol, alice, 10);
// Variant A: zero-valued response scalars.
{
auto const proof = setup.generateProof(mptAlice, env, bob, carol);
if (!BEAST_EXPECT(proof.has_value()))
return;
Buffer forgedProof = requireOptionalRef(proof, "Missing proof");
static constexpr size_t kSigmaScalarSize = 32;
static constexpr size_t kChallengeOffset = 0;
static constexpr size_t kResponseOffset = kChallengeOffset + kSigmaScalarSize;
static constexpr size_t kResponseSize = 5 * kSigmaScalarSize; // z_m..z_sk
std::memset(forgedProof.data() + kResponseOffset, 0, kResponseSize);
mptAlice.send(setup.sendArgs(bob, carol, forgedProof, tecBAD_PROOF));
}
// Variant B: identity element in ciphertext.
{
auto const proof = setup.generateProof(mptAlice, env, bob, carol);
if (!BEAST_EXPECT(proof.has_value()))
return;
Buffer invalidCiphertext(kEcGamalEncryptedTotalLength);
std::memset(invalidCiphertext.data(), 0, kEcGamalEncryptedTotalLength);
auto args = setup.sendArgs(
bob, carol, requireOptionalRef(proof, "Missing proof"), temBAD_CIPHERTEXT);
args.senderEncryptedAmt = invalidCiphertext;
mptAlice.send(args);
}
// Variant B2: identity element in commitment.
{
auto const proof = setup.generateProof(mptAlice, env, bob, carol);
if (!BEAST_EXPECT(proof.has_value()))
return;
Buffer invalidCommitment(kEcPedersenCommitmentLength);
std::memset(invalidCommitment.data(), 0, kEcPedersenCommitmentLength);
auto args = setup.sendArgs(
bob, carol, requireOptionalRef(proof, "Missing proof"), temMALFORMED);
args.amountCommitment = invalidCommitment;
mptAlice.send(args);
}
// Variant C: boundary scalar (curve order).
{
auto const proof = setup.generateProof(mptAlice, env, bob, carol);
if (!BEAST_EXPECT(proof.has_value()))
return;
Buffer forgedProof = requireOptionalRef(proof, "Missing proof");
static constexpr unsigned char kCurveOrder[32] = {
0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, //
0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFE, //
0xBA, 0xAE, 0xDC, 0xE6, 0xAF, 0x48, 0xA0, 0x3B, //
0xBF, 0xD2, 0x5E, 0x8C, 0xD0, 0x36, 0x41, 0x41 //
};
std::memcpy(forgedProof.data() + 32, kCurveOrder, 32);
mptAlice.send(setup.sendArgs(bob, carol, forgedProof, tecBAD_PROOF));
}
// Variant C2: overflow scalar (curve order + 1).
{
auto const proof = setup.generateProof(mptAlice, env, bob, carol);
if (!BEAST_EXPECT(proof.has_value()))
return;
Buffer forgedProof = requireOptionalRef(proof, "Missing proof");
static constexpr unsigned char kOverflowScalar[32] = {
0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, //
0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFE, //
0xBA, 0xAE, 0xDC, 0xE6, 0xAF, 0x48, 0xA0, 0x3B, //
0xBF, 0xD2, 0x5E, 0x8C, 0xD0, 0x36, 0x41, 0x42 //
};
std::memcpy(forgedProof.data() + 32, kOverflowScalar, 32);
mptAlice.send(setup.sendArgs(bob, carol, forgedProof, tecBAD_PROOF));
}
}
void
testSendCrossStatementProofSubstitution(FeatureBitset features)
{
testcase("Send: cross-statement proof substitution");
// This test verifies that proofs generated for one protocol component
// cannot be used in place of another, and that proofs bound to
// different public parameters are rejected.
using namespace test::jtx;
Env env{*this, features};
Account const alice("alice"), bob("bob"), carol("carol");
ConfidentialEnv confEnv{
env,
alice,
{{.account = bob}, {.account = carol, .payAmount = 1000, .convertAmount = 50}},
tfMPTCanLock | tfMPTCanHoldConfidentialBalance | tfMPTCanTransfer | tfMPTCanClawback};
auto& mptAlice = confEnv.mpt;
uint64_t const sendAmount = 10;
// Variant A: Swap proof type (cross-statement substitution)
// -----------------------------------------------------------------
// Attack: Generate a valid convertBack proof (compact sigma +
// single bulletproof) and attempt to use it as the ZK proof in a
// ConfidentialMPTSend transaction.
//
// Expected: The send proof has a different structure
// (equality + 2×pedersen + double bulletproof). Even if sized to
// match, the domain-separated Fiat-Shamir transcript differs,
// so verification equations fail.
{
ConfidentialSendSetup const setup(mptAlice, bob, carol, alice, sendAmount);
// Generate a valid convertBack proof for bob
auto const spendingBalance = requireOptional(
mptAlice.getDecryptedBalance(bob, MPTTester::holderEncryptedSpending),
"Missing spending balance");
auto const encryptedSpending = requireOptional(
mptAlice.getEncryptedBalance(bob, MPTTester::holderEncryptedSpending),
"Missing encrypted spending balance");
Buffer const pcBlindingFactor = generateBlindingFactor();
Buffer const pedersenCommitment =
mptAlice.getPedersenCommitment(spendingBalance, pcBlindingFactor);
auto const version = mptAlice.getMPTokenVersion(bob);
uint256 const convertBackCtxHash =
getConvertBackContextHash(bob.id(), mptAlice.issuanceID(), env.seq(bob), version);
Buffer const convertBackProof = mptAlice.getConvertBackProof(
bob,
sendAmount,
convertBackCtxHash,
{
.pedersenCommitment = pedersenCommitment,
.amt = spendingBalance,
.encryptedAmt = encryptedSpending,
.blindingFactor = pcBlindingFactor,
});
// Resize the convertBack proof to match the expected send proof
// size so it passes preflight's size check and reaches the actual
// ZK verification in doApply.
auto const expectedSendSize = kEcSendProofLength;
Buffer resizedProof(expectedSendSize);
auto const copyLen = std::min(convertBackProof.size(), expectedSendSize);
std::memcpy(resizedProof.data(), convertBackProof.data(), copyLen);
// Zero-pad the rest (if convertBack proof is shorter)
if (copyLen < expectedSendSize)
std::memset(resizedProof.data() + copyLen, 0, expectedSendSize - copyLen);
mptAlice.send(setup.sendArgs(bob, carol, resizedProof, tecBAD_PROOF));
}
// Variant B: Valid proof bound to wrong public parameters
// -----------------------------------------------------------------
// Attack: Generate a valid send proof using a wrong context hash
// (computed with a different issuanceID). The proof is
// mathematically valid for the wrong statement, but when the
// verifier recomputes the Fiat-Shamir challenge using the correct
// issuanceID, the challenge differs and verification fails.
{
ConfidentialSendSetup const setup(mptAlice, bob, carol, alice, sendAmount);
// Compute context hash with a fabricated (wrong) issuanceID
uint192 const fakeIssuanceID{1};
auto const wrongCtxHash = getSendContextHash(
bob.id(), fakeIssuanceID, env.seq(bob), carol.id(), setup.version);
// Generate a proof that is valid for the wrong issuanceID
auto const wrongProof = mptAlice.getConfidentialSendProof(
bob,
sendAmount,
setup.recipients,
setup.blindingFactor,
wrongCtxHash,
{
.pedersenCommitment = setup.amountCommitment,
.amt = sendAmount,
.encryptedAmt = setup.senderAmt,
.blindingFactor = setup.amountBlindingFactor,
},
{
.pedersenCommitment = setup.balanceCommitment,
.amt = setup.prevSpending,
.encryptedAmt = setup.prevEncryptedSpending,
.blindingFactor = setup.balanceBlindingFactor,
});
if (!BEAST_EXPECT(wrongProof.has_value()))
return;
// Submit with the correct issuanceID — verifier recomputes
// the challenge using the real issuanceID, which differs from
// the one baked into the proof.
mptAlice.send(setup.sendArgs(
bob, carol, requireOptionalRef(wrongProof, "Missing wrong proof"), tecBAD_PROOF));
}
}
void
testSendCiphertextMalleability(FeatureBitset features)
{
testcase("Send: ciphertext malleability");
// Attack: replace ElGamal ciphertext Enc(m) with Enc(2m) to inflate
// the amount credited to the recipient. ElGamal is homomorphic, so
// scalar multiplication (C1, C2) → (k*C1, k*C2) decrypts to k*m.
using namespace test::jtx;
Env env{*this, features};
Account const alice("alice"), bob("bob"), carol("carol");
ConfidentialEnv confEnv{
env,
alice,
{{.account = bob}, {.account = carol, .payAmount = 1000, .convertAmount = 50}},
tfMPTCanTransfer | tfMPTCanHoldConfidentialBalance};
auto& mptAlice = confEnv.mpt;
uint64_t const sendAmount = 10;
// Variant A: Post-signature tampering.
// Build a valid signed transaction, then replace the destination
// ciphertext with Enc(2m) in the serialized blob. The original
// signature no longer covers the modified data.
{
auto const seq = env.seq(bob);
auto jv = mptAlice.sendJV({.account = bob, .dest = carol, .amt = sendAmount}, seq);
auto jtx = env.jt(jv);
BEAST_EXPECT(jtx.stx);
// Serialize signed tx, deserialize into mutable STObject
Serializer s;
jtx.stx->add(s);
SerialIter sit(s.slice());
STObject obj(sit, sfTransaction);
// Replace dest ciphertext with Enc(2m) — a valid EC point
// encrypting an inflated amount under carol's key
Buffer const bf = generateBlindingFactor();
auto const inflatedCiphertext = mptAlice.encryptAmount(carol, sendAmount * 2, bf);
obj.setFieldVL(sfDestinationEncryptedAmount, inflatedCiphertext);
// Re-serialize with the original (now-stale) signature
Serializer tampered;
obj.add(tampered);
// Signature verification fails — rejected before ZKP check
auto const jr = env.rpc("submit", strHex(tampered.slice()));
BEAST_EXPECT(jr[jss::result][jss::error] == "invalidTransaction");
}
// Variant B: Re-signed with inflated ciphertext.
// Generate a valid proof for amount m, then replace the destination
// ciphertext with Enc(2m) and re-sign. Signature passes, but the
// compact sigma proof fails: the proof binds Enc(m) to the Pedersen
// commitment PC(m, r), so substituting Enc(2m) breaks the linkage.
{
ConfidentialSendSetup const setup(mptAlice, bob, carol, alice, sendAmount);
auto const ctxHash = getSendContextHash(
bob.id(), mptAlice.issuanceID(), env.seq(bob), carol.id(), setup.version);
auto const validProof = mptAlice.getConfidentialSendProof(
bob,
sendAmount,
setup.recipients,
setup.blindingFactor,
ctxHash,
{
.pedersenCommitment = setup.amountCommitment,
.amt = sendAmount,
.encryptedAmt = setup.senderAmt,
.blindingFactor = setup.amountBlindingFactor,
},
{
.pedersenCommitment = setup.balanceCommitment,
.amt = setup.prevSpending,
.encryptedAmt = setup.prevEncryptedSpending,
.blindingFactor = setup.balanceBlindingFactor,
});
if (!BEAST_EXPECT(validProof.has_value()))
return;
// Replace dest ciphertext with Enc(2m) using the same blinding
// factor — even with matching randomness the proof rejects
// because the committed plaintext differs
auto const inflatedDestAmt =
mptAlice.encryptAmount(carol, sendAmount * 2, setup.blindingFactor);
auto args = setup.sendArgs(
bob, carol, requireOptionalRef(validProof, "Missing valid proof"), tecBAD_PROOF);
args.destEncryptedAmt = inflatedDestAmt;
mptAlice.send(args);
}
}
void
testSendCiphertextNegation(FeatureBitset features)
{
testcase("Send: ciphertext negation");
// Attack: negate ciphertext -Enc(m) = (-C1, -C2) to reverse the
// transaction direction. Negation decrypts to the group-level
// additive inverse of m*G, effectively turning a credit into a debit.
using namespace test::jtx;
Env env{*this, features};
Account const alice("alice"), bob("bob"), carol("carol");
ConfidentialEnv confEnv{
env,
alice,
{{.account = bob}, {.account = carol, .payAmount = 1000, .convertAmount = 50}},
tfMPTCanTransfer | tfMPTCanHoldConfidentialBalance};
auto& mptAlice = confEnv.mpt;
uint64_t const sendAmount = 10;
// Negate an ElGamal ciphertext by flipping the y-coordinate parity
// of both compressed EC points. For secp256k1 compressed form,
// prefix 0x02 means even-y and 0x03 means odd-y; negation
// swaps them: -P has the same x but opposite y.
auto negateCiphertext = [](Buffer const& ct) -> Buffer {
Buffer neg = ct;
neg.data()[0] ^= 0x01; // negate C1
neg.data()[kEcCiphertextComponentLength] ^= 0x01; // negate C2
return neg;
};
// Variant A: Post-signature negation.
// Negate the destination ciphertext in the signed blob.
// Signature no longer covers the modified field.
{
auto const seq = env.seq(bob);
auto jv = mptAlice.sendJV({.account = bob, .dest = carol, .amt = sendAmount}, seq);
auto jtx = env.jt(jv);
BEAST_EXPECT(jtx.stx);
Serializer s;
jtx.stx->add(s);
SerialIter sit(s.slice());
STObject obj(sit, sfTransaction);
auto const origDestAmt = obj.getFieldVL(sfDestinationEncryptedAmount);
Buffer const origBuf(origDestAmt.data(), origDestAmt.size());
auto const negDestAmt = negateCiphertext(origBuf);
obj.setFieldVL(
sfDestinationEncryptedAmount, Slice(negDestAmt.data(), negDestAmt.size()));
Serializer tampered;
obj.add(tampered);
auto const jr = env.rpc("submit", strHex(tampered.slice()));
BEAST_EXPECT(jr[jss::result][jss::error] == "invalidTransaction");
}
// Variant B: Re-signed with all negated ciphertexts.
// Signature passes, but the compact sigma proof fails — the proof
// was generated for Enc(m), not Enc(-m).
{
ConfidentialSendSetup const setup(mptAlice, bob, carol, alice, sendAmount);
auto const validProof = setup.generateProof(mptAlice, env, bob, carol);
if (!BEAST_EXPECT(validProof.has_value()))
return;
// Negate all three ciphertexts: Enc(m) -> Enc(-m)
auto const negSenderAmt = negateCiphertext(setup.senderAmt);
auto const negDestAmt = negateCiphertext(setup.destAmt);
auto const negIssuerAmt = negateCiphertext(setup.issuerAmt);
auto args = setup.sendArgs(
bob, carol, requireOptionalRef(validProof, "Missing valid proof"), tecBAD_PROOF);
args.senderEncryptedAmt = negSenderAmt;
args.destEncryptedAmt = negDestAmt;
args.issuerEncryptedAmt = negIssuerAmt;
mptAlice.send(args);
}
// Variant C: Negate only the sender ciphertext.
// The verifier uses the sender ciphertext to derive the remainder
// commitment: Enc(b) - Enc(m) becomes Enc(b) - (-Enc(m)) = Enc(b+m).
// The bulletproof was generated for (b - m), not (b + m), so the
// aggregated range proof fails.
{
ConfidentialSendSetup const setup(mptAlice, bob, carol, alice, sendAmount);
auto const validProof = setup.generateProof(mptAlice, env, bob, carol);
if (!BEAST_EXPECT(validProof.has_value()))
return;
auto const negSenderAmt = negateCiphertext(setup.senderAmt);
auto args = setup.sendArgs(
bob, carol, requireOptionalRef(validProof, "Missing valid proof"), tecBAD_PROOF);
args.senderEncryptedAmt = negSenderAmt;
mptAlice.send(args);
}
}
void
testSendCiphertextCombination(FeatureBitset features)
{
testcase("Send: ciphertext combination");
// Attack: exploit ElGamal homomorphism to combine ciphertexts
// Enc(m1) + Enc(m2) = Enc(m1+m2), inflating the credited amount
// without knowing the private keys.
using namespace test::jtx;
Env env{*this, features};
Account const alice("alice"), bob("bob"), carol("carol");
ConfidentialEnv confEnv{
env,
alice,
{{.account = bob, .payAmount = 1000, .convertAmount = 200},
{.account = carol, .payAmount = 1000, .convertAmount = 100}},
tfMPTCanTransfer | tfMPTCanHoldConfidentialBalance};
auto& mptAlice = confEnv.mpt;
uint64_t const m1 = 10;
uint64_t const m2 = 5;
// Variant A: Post-signature combination.
// Add Enc(m2) to the signed destination ciphertext Enc(m1).
// The original signature doesn't cover the combined ciphertext.
{
auto const seq = env.seq(bob);
auto jv = mptAlice.sendJV({.account = bob, .dest = carol, .amt = m1}, seq);
auto jtx = env.jt(jv);
BEAST_EXPECT(jtx.stx);
Serializer s;
jtx.stx->add(s);
SerialIter sit(s.slice());
STObject obj(sit, sfTransaction);
auto const origDestCt = obj.getFieldVL(sfDestinationEncryptedAmount);
// Homomorphically add Enc(m2) to the original Enc(m1)
Buffer const bf2 = generateBlindingFactor();
auto const encM2 = mptAlice.encryptAmount(carol, m2, bf2);
auto const combined = requireOptional(
homomorphicAdd(
Slice(origDestCt.data(), origDestCt.size()), Slice(encM2.data(), encM2.size())),
"Missing combined ciphertext");
obj.setFieldVL(sfDestinationEncryptedAmount, combined);
Serializer tampered;
obj.add(tampered);
auto const jr = env.rpc("submit", strHex(tampered.slice()));
BEAST_EXPECT(jr[jss::result][jss::error] == "invalidTransaction");
}
// Variant B: Re-signed with combined ciphertext.
// Generate a valid proof for m1, then replace dest ciphertext with
// Enc(m1) + Enc(m2). Sigma proof fails because the proof was
// generated for Enc(m1) only — the combined ciphertext has
// different randomness.
{
ConfidentialSendSetup const setup(mptAlice, bob, carol, alice, m1);
auto const validProof = setup.generateProof(mptAlice, env, bob, carol);
if (!BEAST_EXPECT(validProof.has_value()))
return;
// Homomorphically add Enc(m2) to the valid dest ciphertext
Buffer const bf2 = generateBlindingFactor();
auto const encM2 = mptAlice.encryptAmount(carol, m2, bf2);
auto const combinedDest = homomorphicAdd(setup.destAmt, encM2);
BEAST_EXPECT(combinedDest.has_value());
auto args = setup.sendArgs(
bob, carol, requireOptionalRef(validProof, "Missing valid proof"), tecBAD_PROOF);
args.destEncryptedAmt = combinedDest;
mptAlice.send(args);
}
// Variant C: Cross-transaction ciphertext reuse.
// Execute a valid send of m1, then build a new send for m2 using
// a combined ciphertext oldEnc(m1) + newEnc(m2) = Enc(m1+m2),
// where oldEnc(m1) is the actual ciphertext from the previous tx.
// The proof was generated for the new transaction's context, but
// the ciphertext includes stale randomness from the old Enc(m1).
{
// Execute a valid send of m1, capturing the actual ciphertext used
ConfidentialSendSetup const setup1(mptAlice, bob, carol, alice, m1);
auto const proof1 = setup1.generateProof(mptAlice, env, bob, carol);
if (!BEAST_EXPECT(proof1.has_value()))
return;
mptAlice.send(setup1.sendArgs(bob, carol, requireOptionalRef(proof1, "Missing proof")));
ConfidentialSendSetup const setup2(mptAlice, bob, carol, alice, m2);
auto const proof2 = setup2.generateProof(mptAlice, env, bob, carol);
if (!BEAST_EXPECT(proof2.has_value()))
return;
// Combine the actual prior-tx Enc(m1) with the new Enc(m2)
auto const crossCombined = homomorphicAdd(setup1.destAmt, setup2.destAmt);
BEAST_EXPECT(crossCombined.has_value());
auto args = setup2.sendArgs(
bob, carol, requireOptionalRef(proof2, "Missing proof"), tecBAD_PROOF);
args.destEncryptedAmt = crossCombined;
mptAlice.send(args);
}
}
void
testSendCiphertextRerandomization(FeatureBitset features)
{
testcase("Send: ciphertext rerandomization");
// Attack: substitute the randomness component C1 of an ElGamal
// ciphertext (C1, C2) while keeping the message component C2
// unchanged. This "rerandomizes" the ciphertext to break
// linkability or forge fresh-looking ciphertexts.
//
// The compact sigma proof binds C1 to the shared randomness used
// across all recipients, so any C1 substitution breaks the proof.
using namespace test::jtx;
Env env{*this, features};
Account const alice("alice"), bob("bob"), carol("carol");
ConfidentialEnv confEnv{
env,
alice,
{{.account = bob}, {.account = carol, .payAmount = 1000, .convertAmount = 50}},
tfMPTCanTransfer | tfMPTCanHoldConfidentialBalance};
auto& mptAlice = confEnv.mpt;
uint64_t const sendAmount = 10;
// Helper: replace C1 in a ciphertext with C1 from another
// ciphertext, keeping C2 unchanged. Returns a rerandomized
// ciphertext (C1', C2).
auto substituteC1 = [](Buffer const& target, Buffer const& source) -> Buffer {
Buffer result = target;
// Copy C1 (the first ciphertext component) from source.
std::memcpy(result.data(), source.data(), kEcCiphertextComponentLength);
return result;
};
// Variant A: Post-signature C1 substitution.
// Replace C1 in the dest ciphertext after signing.
// Signature no longer covers the modified ciphertext.
{
auto const seq = env.seq(bob);
auto jv = mptAlice.sendJV({.account = bob, .dest = carol, .amt = sendAmount}, seq);
auto jtx = env.jt(jv);
BEAST_EXPECT(jtx.stx);
Serializer s;
jtx.stx->add(s);
SerialIter sit(s.slice());
STObject obj(sit, sfTransaction);
// Generate a random C1' by encrypting a different amount
Buffer const bf2 = generateBlindingFactor();
auto const otherCt = mptAlice.encryptAmount(carol, 99, bf2);
// Replace C1 in the dest ciphertext
auto const origDestAmt = obj.getFieldVL(sfDestinationEncryptedAmount);
Buffer const origBuf(origDestAmt.data(), origDestAmt.size());
auto const rerandomized = substituteC1(origBuf, otherCt);
obj.setFieldVL(
sfDestinationEncryptedAmount, Slice(rerandomized.data(), rerandomized.size()));
Serializer tampered;
obj.add(tampered);
// Signature verification fails
auto const jr = env.rpc("submit", strHex(tampered.slice()));
BEAST_EXPECT(jr[jss::result][jss::error] == "invalidTransaction");
}
// Variant B: Re-signed C1 substitution.
// Replace C1 in the dest ciphertext with a fresh random point
// and re-sign. Sigma proof fails because the shared-randomness
// binding no longer holds — C1' wasn't generated with the same r
// used in the proof.
{
ConfidentialSendSetup const setup(mptAlice, bob, carol, alice, sendAmount);
auto const validProof = setup.generateProof(mptAlice, env, bob, carol);
if (!BEAST_EXPECT(validProof.has_value()))
return;
// Create a ciphertext with different randomness to get C1'
Buffer const bf2 = generateBlindingFactor();
auto const otherCt = mptAlice.encryptAmount(carol, sendAmount, bf2);
// Replace C1 in dest ciphertext, keep C2
auto const rerandomizedDest = substituteC1(setup.destAmt, otherCt);
auto args = setup.sendArgs(
bob, carol, requireOptionalRef(validProof, "Missing valid proof"), tecBAD_PROOF);
args.destEncryptedAmt = rerandomizedDest;
mptAlice.send(args);
}
}
void
testSendZeroRandomnessCiphertext(FeatureBitset features)
{
testcase("Send: zero randomness ciphertext");
// Setting r = 0 in ElGamal yields C1 = O (identity), C2 = mG —
// a deterministic ciphertext that reveals the plaintext.
using namespace test::jtx;
Env env{*this, features};
Account const alice("alice"), bob("bob"), carol("carol");
ConfidentialEnv confEnv{
env,
alice,
{{.account = bob}, {.account = carol, .payAmount = 1000, .convertAmount = 50}},
tfMPTCanTransfer | tfMPTCanHoldConfidentialBalance};
auto& mptAlice = confEnv.mpt;
uint64_t const sendAmount = 10;
// -----------------------------------------------------------------
// Variant A: Post-signature zero-randomness substitution
// -----------------------------------------------------------------
// Construct a valid ConfidentialMPTSend transaction with proper
// ciphertexts and ZKPs, sign it, then replace the sender ciphertext
// with a deterministic form (C1 = 0x00...00, C2 = arbitrary).
// Since the identity element has no valid compressed encoding,
// the modified blob fails deserialization / signature check.
{
auto const seq = env.seq(bob);
auto jv = mptAlice.sendJV({.account = bob, .dest = carol, .amt = sendAmount}, seq);
auto jtx = env.jt(jv);
BEAST_EXPECT(jtx.stx);
// Serialize the signed transaction
Serializer s;
jtx.stx->add(s);
SerialIter sit(s.slice());
STObject obj(sit, sfTransaction);
// Replace sender ciphertext with zero-randomness form:
// C1 = all zeros (identity element — invalid encoding)
// C2 = valid trivial point (simulating mG)
Buffer zeroCiphertext(kEcGamalEncryptedTotalLength);
std::memset(zeroCiphertext.data(), 0, kEcGamalEncryptedTotalLength);
// C2 half: use a valid point so only C1 is the problem
auto const& tc = getTrivialCiphertext();
std::memcpy(
zeroCiphertext.data() + kEcCiphertextComponentLength,
tc.data() + kEcCiphertextComponentLength,
kEcCiphertextComponentLength);
obj.setFieldVL(sfSenderEncryptedAmount, zeroCiphertext);
// Re-serialize with the original (now-stale) signature
Serializer tampered;
obj.add(tampered);
// Signature verification fails because ciphertext fields are
// signed — transaction rejected before ZKP verification.
auto const jr = env.rpc("submit", strHex(tampered.slice()));
BEAST_EXPECT(jr[jss::result][jss::error] == "invalidTransaction");
}
// -----------------------------------------------------------------
// Variant B: Re-signed zero-randomness ciphertext
// -----------------------------------------------------------------
// Same zero-randomness ciphertext as Variant A (C1 = 0, C2 = mG),
// but submitted normally via send() which re-signs the transaction.
// Signature verification passes, but preflight's isValidCiphertext
// rejects it: the identity element has no valid compressed encoding
// on secp256k1, so secp256k1_ec_pubkey_parse fails on C1 = 0.
{
// Build zero-randomness ciphertext: C1 = all zeros (identity),
// C2 = valid trivial point (simulating mG)
Buffer zeroCiphertext(kEcGamalEncryptedTotalLength);
std::memset(zeroCiphertext.data(), 0, kEcGamalEncryptedTotalLength);
auto const& tc = getTrivialCiphertext();
std::memcpy(
zeroCiphertext.data() + kEcCiphertextComponentLength,
tc.data() + kEcCiphertextComponentLength,
kEcCiphertextComponentLength);
mptAlice.send(
{.account = bob,
.dest = carol,
.amt = sendAmount,
.senderEncryptedAmt = zeroCiphertext,
.err = temBAD_CIPHERTEXT});
}
// -----------------------------------------------------------------
// Variant C: Deterministic ciphertext reuse across transactions
// -----------------------------------------------------------------
// Construct two transactions using identical deterministic
// ciphertexts (same fixed blinding factor). Even if a valid
// proof could be generated for one, it cannot be reused because
// the TransactionContextID (which includes account sequence)
// differs between transactions.
{
// First transaction: generate valid proof for sendAmount
ConfidentialSendSetup const setup1(mptAlice, bob, carol, alice, sendAmount);
auto const proof1 = setup1.generateProof(mptAlice, env, bob, carol);
if (!BEAST_EXPECT(proof1.has_value()))
return;
// Submit first transaction successfully
mptAlice.send(setup1.sendArgs(bob, carol, requireOptionalRef(proof1, "Missing proof")));
mptAlice.mergeInbox({.account = carol});
// Second transaction: reuse the same proof from tx1.
// The context hash includes the new account sequence, so the
// proof generated for the old sequence is invalid.
ConfidentialSendSetup const setup2(mptAlice, bob, carol, alice, sendAmount);
mptAlice.send(setup2.sendArgs(
bob, carol, requireOptionalRef(proof1, "Missing proof"), tecBAD_PROOF));
}
}
void
testSendRerandomizesRecipientInboxAgainstMergeCancellation(FeatureBitset features)
{
testcase("Send: recipient inbox rerandomization prevents merge cancellation");
using namespace test::jtx;
// Derive the deterministic canonical-zero randomness r0 used for
// Bob's first spending balance.
auto getCanonicalZeroBlindingFactor = [](AccountID const& account, MPTID const& mptID) {
Buffer scalar(kEcBlindingFactorLength);
std::array<unsigned char, 51> hashInput{};
std::memcpy(hashInput.data(), "EncZero", 7);
std::memcpy(hashInput.data() + 7, account.data(), account.size());
std::memcpy(hashInput.data() + 27, mptID.data(), mptID.size());
for (;;)
{
unsigned int mdLen = kEcBlindingFactorLength;
if (EVP_Digest(
hashInput.data(),
hashInput.size(),
scalar.data(),
&mdLen,
EVP_sha256(),
nullptr) != 1)
{
Throw<std::runtime_error>("Failed to derive canonical zero blinding factor");
}
if (secp256k1_ec_seckey_verify(mpt_secp256k1_context(), scalar.data()))
return scalar;
std::memcpy(hashInput.data(), scalar.data(), scalar.size());
}
};
// Pick randomness that would cancel Bob's MergeInbox C1 to infinity
// without receiver-side re-randomization.
auto negateScalarSum = [](Buffer const& lhs, Buffer const& rhs) {
Buffer sum(kEcBlindingFactorLength);
Buffer negated(kEcBlindingFactorLength);
secp256k1_mpt_scalar_add(sum.data(), lhs.data(), rhs.data());
secp256k1_mpt_scalar_negate(negated.data(), sum.data());
return negated;
};
// Without an auditor, target Bob's holder inbox. The crafted send
// randomness would make MergeInbox hit the point at infinity unless
// ConfidentialMPTSend re-randomizes the recipient ciphertext.
{
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 | tfMPTCanHoldConfidentialBalance,
});
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({.account = alice, .issuerPubKey = mptAlice.getPubKey(alice)});
mptAlice.convert({
.account = carol,
.amt = 50,
.holderPubKey = mptAlice.getPubKey(carol),
});
mptAlice.mergeInbox({.account = carol});
Buffer const convertBlindingFactor = generateBlindingFactor();
mptAlice.convert({
.account = bob,
.amt = 20,
.holderPubKey = mptAlice.getPubKey(bob),
.blindingFactor = convertBlindingFactor,
});
Buffer const canonicalZeroBlindingFactor =
getCanonicalZeroBlindingFactor(bob.id(), mptAlice.issuanceID());
// Holder inbox cancellation happens later in MergeInbox, when
// Bob's spending Enc(0; r0) is added to inbox Enc(25; -r0).
Buffer const maliciousSendBlindingFactor =
negateScalarSum(canonicalZeroBlindingFactor, convertBlindingFactor);
mptAlice.send({
.account = carol,
.dest = bob,
.amt = 5,
.blindingFactor = maliciousSendBlindingFactor,
});
mptAlice.mergeInbox({.account = bob});
auto const bobSpending =
mptAlice.getDecryptedBalance(bob, MPTTester::holderEncryptedSpending);
BEAST_EXPECT(bobSpending && *bobSpending == 25);
}
// With an auditor, verify the destination auditor balance is also
// re-randomized. Auditor balance is updated during send, and this crafted
// randomness would otherwise make that homomorphic sum hit infinity without
// re-randomization.
{
Env env{*this, features};
Account const alice("alice"), bob("bob"), carol("carol"), auditor("auditor");
MPTTester mptAlice(env, alice, {.holders = {bob, carol}, .auditor = auditor});
mptAlice.create({
.ownerCount = 1,
.flags = tfMPTCanTransfer | tfMPTCanLock | tfMPTCanHoldConfidentialBalance,
});
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({
.account = alice,
.issuerPubKey = mptAlice.getPubKey(alice),
.auditorPubKey = mptAlice.getPubKey(auditor),
});
mptAlice.convert({
.account = carol,
.amt = 50,
.holderPubKey = mptAlice.getPubKey(carol),
});
mptAlice.mergeInbox({.account = carol});
Buffer const convertBlindingFactor = generateBlindingFactor();
mptAlice.convert({
.account = bob,
.amt = 20,
.holderPubKey = mptAlice.getPubKey(bob),
.blindingFactor = convertBlindingFactor,
});
// This would make the homomorphic sum hit infinity.
Buffer const maliciousSendBlindingFactor =
negateScalarSum(gMakeZeroBuffer(kEcBlindingFactorLength), convertBlindingFactor);
mptAlice.send({
.account = carol,
.dest = bob,
.amt = 5,
.blindingFactor = maliciousSendBlindingFactor,
});
auto const bobAuditor =
mptAlice.getDecryptedBalance(bob, MPTTester::auditorEncryptedBalance);
BEAST_EXPECT(bobAuditor && *bobAuditor == 25);
}
}
void
testWithFeats(FeatureBitset features)
{
// ConfidentialMPTConvert
testConvert(features);
testConvertPreflight(features);
testConvertInvalidProofContextBinding(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);
testSendWithAuditor(features);
// ConfidentialMPTClawback
testClawback(features);
testClawbackPreflight(features);
testClawbackPreclaim(features);
testClawbackProof(features);
testClawbackWithAuditor(features);
testClawbackInvalidProofContextBinding(features);
testDelete(features);
// ConfidentialMPTConvertBack
testConvertBack(features);
testConvertBackPreflight(features);
testConvertBackPreclaim(features);
testConvertBackWithAuditor(features);
testConvertBackPedersenProof(features);
testConvertBackBulletproof(features);
// Homomorphic operation tests
testSendHomomorphicOverflow(features);
testConvertBackHomomorphicCiphertextModification(features);
testConvertBackHomomorphicUnderflow(features);
// Invalid curve points
testSendInvalidCurvePoints(features);
testSendWrongGroupPointInjection(features);
testConvertIdentityElementRejection(features);
testSendWrongIssuerPublicKey(features);
// public and private txns
testPublicTransfersAfterClearingConfidentialFlag(features);
// Replay tests
testMutatePrivacy(features);
testConvertBackInvalidProofContextBinding(features);
testConvertBackProofCiphertextBinding(features);
testConvertBackProofVersionMismatch(features);
// Crafted-proof Tests
testSendSharedRandomnessViolation(features);
// Transaction Fee Tests
testConfidentialMPTBaseFee(features);
// TransferFee (transfer rate) Tests
testTransferFee(features);
// Zero knowledge proof tests
testSendInvalidProofContextBinding(features);
testSendForgedEqualityProof(features);
testSendForgedRangeProof(features);
testSendNegativeValueMalleability(features);
testSendFiatShamirBinding(features);
testSendProofComponentReuse(features);
testSendSpecialWitnessValues(features);
testSendCrossStatementProofSubstitution(features);
// Ciphertext malleability tests
testSendCiphertextMalleability(features);
testSendCiphertextNegation(features);
testSendCiphertextCombination(features);
testSendCiphertextRerandomization(features);
testSendZeroRandomnessCiphertext(features);
testSendRerandomizesRecipientInboxAgainstMergeCancellation(features);
}
public:
void
run() override
{
using namespace test::jtx;
FeatureBitset const all{testableAmendments()};
testWithFeats(all);
}
};
BEAST_DEFINE_TESTSUITE(ConfidentialTransfer, app, xrpl);
} // namespace xrpl