Files
rippled/src/test/app/ConfidentialTransferExtended_test.cpp
2026-07-17 16:06:17 -04:00

2607 lines
103 KiB
C++

#include <test/jtx/AMM.h>
#include <test/jtx/Account.h>
#include <test/jtx/ConfidentialTransfer.h>
#include <test/jtx/Env.h>
#include <test/jtx/TestHelpers.h>
#include <test/jtx/amount.h>
#include <test/jtx/batch.h>
#include <test/jtx/credentials.h>
#include <test/jtx/delegate.h>
#include <test/jtx/deposit.h>
#include <test/jtx/flags.h>
#include <test/jtx/mpt.h>
#include <test/jtx/owners.h>
#include <test/jtx/ter.h>
#include <test/jtx/ticket.h>
#include <xrpl/basics/Buffer.h>
#include <xrpl/basics/base_uint.h>
#include <xrpl/basics/strHex.h>
#include <xrpl/beast/unit_test/suite.h>
#include <xrpl/json/json_value.h>
#include <xrpl/protocol/ConfidentialTransfer.h>
#include <xrpl/protocol/Feature.h>
#include <xrpl/protocol/Indexes.h>
#include <xrpl/protocol/Protocol.h>
#include <xrpl/protocol/SField.h>
#include <xrpl/protocol/TER.h>
#include <xrpl/protocol/TxFlags.h>
#include <xrpl/protocol/jss.h>
#include <chrono>
#include <cstdint>
#include <string>
#include <vector>
namespace xrpl {
class ConfidentialTransferExtended_test : public ConfidentialTransferTestBase
{
void
testSendDepositPreauth(FeatureBitset features)
{
testcase("Send deposit preauth");
using namespace test::jtx;
// When an account enables lsfDepositAuth (via asfDepositAuth flag),
// it requires explicit authorization before accepting incoming payments.
//
// There are two authorization mechanisms:
//
// 1. DIRECT ACCOUNT AUTHORIZATION (deposit::auth)
// - Bob directly authorizes Carol: deposit::auth(bob, carol)
// - Simple 1-to-1 trust relationship
// - Carol can send to Bob without credentials
//
// 2. CREDENTIAL-BASED AUTHORIZATION (deposit::authCredentials)
// - A trusted third party (dpIssuer) issues credentials
// - Bob authorizes a credential TYPE from an issuer
// - Anyone holding that credential can send to Bob
// - Requires sender to include credential ID in transaction
Account const alice("alice");
Account const bob("bob");
Account const carol("carol");
Account const dpIssuer("dpIssuer");
char const credType[] = "KYC_VERIFIED";
// Create and accept credential for an account
auto createCredential = [&](Env& env, Account const& subject) -> std::string {
env(credentials::create(subject, dpIssuer, credType));
env.close();
env(credentials::accept(subject, dpIssuer, credType));
env.close();
auto const jv = credentials::ledgerEntry(env, subject, dpIssuer, credType);
return jv[jss::result][jss::index].asString();
};
// TEST 1: Direct Account Authorization
{
Env env(*this, features);
ConfidentialEnv confEnv{
env,
alice,
{{.account = bob, .payAmount = 100, .convertAmount = 50},
{.account = carol, .payAmount = 100, .convertAmount = 50}}};
auto& mpt = confEnv.mpt;
env(fset(bob, asfDepositAuth));
env.close();
// Carol cannot send to Bob without authorization
mpt.send({
.account = carol,
.dest = bob,
.amt = 10,
.err = tecNO_PERMISSION,
});
// Bob directly authorizes Carol
env(deposit::auth(bob, carol));
env.close();
// Now Carol can send to Bob
mpt.send({
.account = carol,
.dest = bob,
.amt = 10,
});
mpt.mergeInbox({
.account = bob,
});
// Bob revokes Carol's authorization
env(deposit::unauth(bob, carol));
env.close();
// Carol can no longer send to Bob
mpt.send({
.account = carol,
.dest = bob,
.amt = 10,
.err = tecNO_PERMISSION,
});
}
// TEST 2: Credential-Based Authorization
{
Env env(*this, features);
env.fund(XRP(50000), dpIssuer);
env.close();
ConfidentialEnv confEnv{
env,
alice,
{{.account = bob, .payAmount = 100, .convertAmount = 50},
{.account = carol, .payAmount = 100, .convertAmount = 50}}};
auto& mpt = confEnv.mpt;
env(fset(bob, asfDepositAuth));
env.close();
auto const credIdx = createCredential(env, carol);
// Carol cannot send yet - Bob hasn't authorized this credential type
mpt.send({
.account = carol,
.dest = bob,
.amt = 10,
.credentials = {{credIdx}},
.err = tecNO_PERMISSION,
});
// Bob authorizes the credential type from dpIssuer
env(deposit::authCredentials(bob, {{.issuer = dpIssuer, .credType = credType}}));
env.close();
// Carol still cannot send without including credential
mpt.send({
.account = carol,
.dest = bob,
.amt = 10,
.err = tecNO_PERMISSION,
});
// Carol CAN send when including her credential
mpt.send({.account = carol, .dest = bob, .amt = 10, .credentials = {{credIdx}}});
mpt.mergeInbox({
.account = bob,
});
}
// TEST 3: Direct Auth Takes Precedence Over Credentials
{
Env env(*this, features);
env.fund(XRP(50000), dpIssuer);
env.close();
ConfidentialEnv confEnv{
env,
alice,
{{.account = bob, .payAmount = 100, .convertAmount = 50},
{.account = carol, .payAmount = 100, .convertAmount = 50}}};
auto& mpt = confEnv.mpt;
env(fset(bob, asfDepositAuth));
env.close();
auto const credIdx = createCredential(env, carol);
// Bob directly authorizes Carol (no credential needed)
env(deposit::auth(bob, carol));
env.close();
// Carol can send without credentials (direct auth)
mpt.send({
.account = carol,
.dest = bob,
.amt = 10,
});
mpt.mergeInbox({
.account = bob,
});
// Carol can also send WITH credentials (still works)
mpt.send({.account = carol, .dest = bob, .amt = 10, .credentials = {{credIdx}}});
mpt.mergeInbox({
.account = bob,
});
// Bob revokes direct authorization
env(deposit::unauth(bob, carol));
env.close();
// Carol cannot send without credentials anymore
mpt.send({
.account = carol,
.dest = bob,
.amt = 10,
.err = tecNO_PERMISSION,
});
// But credential-based auth not set up, so this also fails
mpt.send({
.account = carol,
.dest = bob,
.amt = 10,
.credentials = {{credIdx}},
.err = tecNO_PERMISSION,
});
// Bob authorizes the credential type
env(deposit::authCredentials(bob, {{.issuer = dpIssuer, .credType = credType}}));
env.close();
// Now Carol can send with credentials
mpt.send({.account = carol, .dest = bob, .amt = 10, .credentials = {{credIdx}}});
}
auto const expireTime = 30;
// Lambda function that returns the credential index after creating a
// credential that expires shortly after the current ledger time.
auto createExpiringCredential = [&](Env& env, Account const& subject) -> std::string {
auto jv = credentials::create(subject, dpIssuer, credType);
auto const expiry =
env.current()->header().parentCloseTime.time_since_epoch().count() + expireTime;
jv[sfExpiration.jsonName] = expiry;
env(jv);
env.close();
env(credentials::accept(subject, dpIssuer, credType));
env.close();
auto const credentials = credentials::ledgerEntry(env, subject, dpIssuer, credType);
return credentials[jss::result][jss::index].asString();
};
auto credentialDeleted = [&](Env& env, Account const& subject) -> bool {
auto const credentials = credentials::ledgerEntry(env, subject, dpIssuer, credType);
return credentials[jss::result].isMember(jss::error) &&
credentials[jss::result][jss::error] == "entryNotFound";
};
// TEST 4: Expired credential with matching depositPreauth entry.
// checkDepositPreauth in preclaim returns tesSUCCESS (the expired
// credential still exists and matches the depositPreauth key), so ZK
// proofs run. cleanupExpiredCredentials in doApply then removes the
// expired credential and returns tecEXPIRED.
{
Env env(*this, features);
env.fund(XRP(50000), dpIssuer);
env.close();
ConfidentialEnv confEnv{
env,
alice,
{{.account = bob, .payAmount = 100, .convertAmount = 50},
{.account = carol, .payAmount = 100, .convertAmount = 50}}};
auto& mpt = confEnv.mpt;
env(fset(bob, asfDepositAuth));
env.close();
auto const credIdx = createExpiringCredential(env, carol);
// Bob authorizes carol's credential type
env(deposit::authCredentials(bob, {{.issuer = dpIssuer, .credType = credType}}));
env.close();
// Advance ledger past credential expiration
env.close(std::chrono::seconds(expireTime));
// Send fails with tecEXPIRED; the expired credential is cleaned up
mpt.send({
.account = carol,
.dest = bob,
.amt = 10,
.credentials = {{credIdx}},
.err = tecEXPIRED,
});
env.close();
BEAST_EXPECT(credentialDeleted(env, carol));
}
// TEST 5: Expired credential, destination has no depositAuth.
// checkDepositPreauth in preclaim returns tesSUCCESS even with expired credentials,
// because we want to keep the checkDepositPreauth part before the expensive proof
// verification. cleanupExpiredCredentials in doApply removes the expired credential and
// returns tecEXPIRED.
{
Env env(*this, features);
env.fund(XRP(50000), dpIssuer);
env.close();
ConfidentialEnv confEnv{
env,
alice,
{{.account = bob, .payAmount = 100, .convertAmount = 50},
{.account = carol, .payAmount = 100, .convertAmount = 50}}};
auto& mpt = confEnv.mpt;
auto const credIdx = createExpiringCredential(env, carol);
// Advance ledger past credential expiration
env.close(std::chrono::seconds(expireTime));
// Send fails with tecEXPIRED; the expired credential is cleaned up
mpt.send({
.account = carol,
.dest = bob,
.amt = 10,
.credentials = {{credIdx}},
.err = tecEXPIRED,
});
env.close();
BEAST_EXPECT(credentialDeleted(env, carol));
}
// TEST 6: Expired credential, depositAuth enabled but credential
// not authorized by bob.
// checkDepositPreauth in preclaim calls checkDepositPreauth which
// finds no match and returns tecNO_PERMISSION. doApply never runs, so
// the expired credential is not cleaned up by this transaction. This is
// a deliberate tradeoff: allowing doApply to run solely for cleanup
// would require bypassing the preclaim short-circuit, forcing every
// validator to run the expensive ZK proof verification before
// discovering the authorization failure. Expired credentials here will
// be cleaned up opportunistically by a future transaction that
// references them.
{
Env env(*this, features);
env.fund(XRP(50000), dpIssuer);
env.close();
ConfidentialEnv confEnv{
env,
alice,
{{.account = bob, .payAmount = 100, .convertAmount = 50},
{.account = carol, .payAmount = 100, .convertAmount = 50}}};
auto& mpt = confEnv.mpt;
env(fset(bob, asfDepositAuth));
env.close();
auto const credIdx = createExpiringCredential(env, carol);
// Advance ledger past credential expiration
env.close(std::chrono::seconds(expireTime));
// Fails with tecNO_PERMISSION.
mpt.send({
.account = carol,
.dest = bob,
.amt = 10,
.credentials = {{credIdx}},
.err = tecNO_PERMISSION,
});
env.close();
// Expired credential is not deleted
BEAST_EXPECT(!credentialDeleted(env, carol));
}
}
void
testSendCredentialValidation(FeatureBitset features)
{
testcase("Send credential validation");
using namespace test::jtx;
// Tests for credentials::checkFields (preflight) and
// credentials::valid (preclaim) validation.
//
// Preflight checks (temMALFORMED):
// - Empty credentials array
// - Array size exceeds maxCredentialsArraySize (8)
// - Duplicate credential IDs in array
//
// Preclaim checks (tecBAD_CREDENTIALS):
// - Credential doesn't exist
// - Credential doesn't belong to source account
// - Credential not accepted (lsfAccepted flag not set)
Account const alice("alice");
Account const bob("bob");
Account const carol("carol");
Account const dpIssuer("dpIssuer");
char const credType[] = "KYC";
// TEST 1: Preflight - Empty Credentials Array
{
Env env(*this, features);
ConfidentialEnv confEnv{
env,
alice,
{{.account = bob, .payAmount = 100, .convertAmount = 50},
{.account = carol, .payAmount = 100, .convertAmount = 50}}};
auto& mpt = confEnv.mpt;
mpt.send({
.account = carol,
.dest = bob,
.amt = 10,
.credentials = std::vector<std::string>{},
.err = temMALFORMED,
});
}
// TEST 2: Preflight - Credentials Array Too Large
{
Env env(*this, features);
ConfidentialEnv confEnv{
env,
alice,
{{.account = bob, .payAmount = 100, .convertAmount = 50},
{.account = carol, .payAmount = 100, .convertAmount = 50}}};
auto& mpt = confEnv.mpt;
std::vector<std::string> tooManyCredentials;
tooManyCredentials.reserve(9);
for (int i = 0; i < 9; ++i)
tooManyCredentials.push_back(to_string(uint256(i)));
mpt.send({
.account = carol,
.dest = bob,
.amt = 10,
.credentials = tooManyCredentials,
.err = temMALFORMED,
});
}
// TEST 3: Preflight - Duplicate Credentials
{
Env env(*this, features);
env.fund(XRP(50000), dpIssuer);
env.close();
ConfidentialEnv confEnv{
env,
alice,
{{.account = bob, .payAmount = 100, .convertAmount = 50},
{.account = carol, .payAmount = 100, .convertAmount = 50}}};
auto& mpt = confEnv.mpt;
env(credentials::create(carol, dpIssuer, credType));
env.close();
env(credentials::accept(carol, dpIssuer, credType));
env.close();
auto const jv = credentials::ledgerEntry(env, carol, dpIssuer, credType);
std::string const credIdx = jv[jss::result][jss::index].asString();
mpt.send({
.account = carol,
.dest = bob,
.amt = 10,
.credentials = {{credIdx, credIdx}},
.err = temMALFORMED,
});
}
// TEST 4: Preclaim - Credential Doesn't Exist
{
Env env(*this, features);
ConfidentialEnv confEnv{
env,
alice,
{{.account = bob, .payAmount = 100, .convertAmount = 50},
{.account = carol, .payAmount = 100, .convertAmount = 50}}};
auto& mpt = confEnv.mpt;
std::string const fakeCredIdx = to_string(uint256(999));
mpt.send({
.account = carol,
.dest = bob,
.amt = 10,
.credentials = {{fakeCredIdx}},
.err = tecBAD_CREDENTIALS,
});
}
// TEST 5: Preclaim - Credential Doesn't Belong to Source Account
{
Env env(*this, features);
env.fund(XRP(50000), dpIssuer);
env.close();
ConfidentialEnv confEnv{
env,
alice,
{{.account = bob, .payAmount = 100, .convertAmount = 50},
{.account = carol, .payAmount = 100, .convertAmount = 50}}};
auto& mpt = confEnv.mpt;
// Create credential for BOB (not carol)
env(credentials::create(bob, dpIssuer, credType));
env.close();
env(credentials::accept(bob, dpIssuer, credType));
env.close();
auto const jv = credentials::ledgerEntry(env, bob, dpIssuer, credType);
std::string const credIdx = jv[jss::result][jss::index].asString();
mpt.send({
.account = carol,
.dest = bob,
.amt = 10,
.credentials = {{credIdx}},
.err = tecBAD_CREDENTIALS,
});
}
// TEST 6: Preclaim - Credential Not Accepted
{
Env env(*this, features);
env.fund(XRP(50000), dpIssuer);
env.close();
ConfidentialEnv confEnv{
env,
alice,
{{.account = bob, .payAmount = 100, .convertAmount = 50},
{.account = carol, .payAmount = 100, .convertAmount = 50}}};
auto& mpt = confEnv.mpt;
// Create credential but DON'T accept it
env(credentials::create(carol, dpIssuer, credType));
env.close();
auto const jv = credentials::ledgerEntry(env, carol, dpIssuer, credType);
std::string const credIdx = jv[jss::result][jss::index].asString();
mpt.send({
.account = carol,
.dest = bob,
.amt = 10,
.credentials = {{credIdx}},
.err = tecBAD_CREDENTIALS,
});
}
// TEST 7: Preflight - sfCredentialIDs requires featureCredentials.
// Even with featureConfidentialTransfer enabled, supplying
// CredentialIDs while featureCredentials is disabled must be
// rejected in preflight via checkExtraFeatures.
{
Env env(*this, features - featureCredentials);
ConfidentialEnv confEnv{
env,
alice,
{{.account = bob, .payAmount = 100, .convertAmount = 50},
{.account = carol, .payAmount = 100, .convertAmount = 50}}};
auto& mpt = confEnv.mpt;
auto constexpr kCredIdx =
"48004829F915654A81B11C4AB8218D96FED67F209B58328A72314FB6EA288BE4";
mpt.send({
.account = carol,
.dest = bob,
.amt = 10,
.credentials = {{kCredIdx}},
.err = temDISABLED,
});
}
}
// 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
testAMMHolderCannotHaveConfidentialStateClawback(FeatureBitset features)
{
testcase("AMM holder cannot have confidential state");
using namespace test::jtx;
Account const alice("alice");
Account const bob("bob");
for (bool const enablePseudoAccount : {false, true})
{
Env env{
*this,
enablePseudoAccount ? features | featureSingleAssetVault
: features - featureSingleAssetVault};
MPTTester mptAlice(env, alice, {.holders = {bob}});
mptAlice.create({
.flags = kMptDexFlags | tfMPTCanClawback | tfMPTCanHoldConfidentialBalance,
});
mptAlice.authorize({.account = bob});
mptAlice.pay(alice, bob, 1'000);
mptAlice.generateKeyPair(alice);
mptAlice.set({.account = alice, .issuerPubKey = mptAlice.getPubKey(alice)});
AMM const amm(env, bob, XRP(100), mptAlice(100));
Account const ammHolder("amm", amm.ammAccount());
auto const ammSle = env.le(keylet::account(ammHolder.id()));
BEAST_EXPECT(ammSle && ammSle->isFieldPresent(sfAMMID));
BEAST_EXPECT(mptAlice.getBalance(ammHolder) == 100);
BEAST_EXPECT(!mptAlice.getEncryptedBalance(ammHolder, MPTTester::holderEncryptedInbox));
BEAST_EXPECT(
!mptAlice.getEncryptedBalance(ammHolder, MPTTester::holderEncryptedSpending));
BEAST_EXPECT(
!mptAlice.getEncryptedBalance(ammHolder, MPTTester::issuerEncryptedBalance));
BEAST_EXPECT(
!mptAlice.getEncryptedBalance(ammHolder, MPTTester::auditorEncryptedBalance));
mptAlice.confidentialClaw({
.account = alice,
.holder = ammHolder,
.amt = 100,
.proof = strHex(gMakeZeroBuffer(kEcClawbackProofLength)),
.err = tecNO_PERMISSION,
});
}
}
// Exercises every Confidential Transfer transaction type (MPTokenIssuanceSet,
// Convert, MergeInbox, Send, ConvertBack) using tickets instead of regular account
// sequence numbers.
void
testWithTickets(FeatureBitset features)
{
testcase("Confidential transfer with tickets");
using namespace test::jtx;
Env env{*this, features};
Account const alice("alice");
Account const bob("bob");
Account const carol("carol");
MPTTester mptAlice(env, alice, {.holders = {bob, carol}});
mptAlice.create({
.ownerCount = 1,
.flags = tfMPTCanTransfer | tfMPTCanLock | 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);
// MPTokenIssuanceSet with ticket, registers alice's issuer key.
{
std::uint32_t const ticketSeq = env.seq(alice) + 1;
env(ticket::create(alice, 1));
mptAlice.set({.issuerPubKey = mptAlice.getPubKey(alice), .ticketSeq = ticketSeq});
}
// ConfidentialMPTConvert with ticket, first convert registers bob's key.
{
std::uint32_t const ticketSeq = env.seq(bob) + 1;
env(ticket::create(bob, 1));
mptAlice.convert({
.account = bob,
.amt = 50,
.holderPubKey = mptAlice.getPubKey(bob),
.ticketSeq = ticketSeq,
});
env.require(MptBalance(mptAlice, bob, 50));
}
// ConfidentialMPTConvert with ticket
{
std::uint32_t const ticketSeq = env.seq(bob) + 1;
env(ticket::create(bob, 1));
mptAlice.convert({.account = bob, .amt = 20, .ticketSeq = ticketSeq});
env.require(MptBalance(mptAlice, bob, 30));
}
// ConfidentialMPTMergeInbox with ticket.
{
std::uint32_t const ticketSeq = env.seq(bob) + 1;
env(ticket::create(bob, 1));
mptAlice.mergeInbox({.account = bob, .ticketSeq = ticketSeq});
}
mptAlice.convert({.account = carol, .amt = 50, .holderPubKey = mptAlice.getPubKey(carol)});
mptAlice.mergeInbox({.account = carol});
// ConfidentialMPTSend with ticket.
{
std::uint32_t const ticketSeq = env.seq(bob) + 1;
env(ticket::create(bob, 1));
mptAlice.send({.account = bob, .dest = carol, .amt = 10, .ticketSeq = ticketSeq});
}
// Merge carol's inbox so her spending balance includes the received send.
mptAlice.mergeInbox({.account = carol});
// ConfidentialMPTConvertBack with ticket.
// The convertBack proof context hash must use the ticket sequence.
{
std::uint32_t const ticketSeq = env.seq(carol) + 1;
env(ticket::create(carol, 1));
mptAlice.convertBack({.account = carol, .amt = 10, .ticketSeq = ticketSeq});
// carol converted 50, received 10 from bob, then converted back 10 → public 60
env.require(MptBalance(mptAlice, carol, 60));
}
}
// Verifies that cryptographic proofs in Convert transactions are bound to
// the ticket sequence rather than the account sequence.
// A proof built with the ticket sequence passes.
void
testConvertTicketProofBinding(FeatureBitset features)
{
testcase("Convert proof binds to ticket sequence");
using namespace test::jtx;
Env env{*this, features};
Account const alice("alice");
Account const bob("bob");
MPTTester mptAlice(env, alice, {.holders = {bob}});
mptAlice.create({
.ownerCount = 1,
.holderCount = 0,
.flags = tfMPTCanTransfer | tfMPTCanLock | tfMPTCanHoldConfidentialBalance,
});
mptAlice.authorize({.account = bob});
mptAlice.pay(alice, bob, 100);
mptAlice.generateKeyPair(alice);
mptAlice.set({.account = alice, .issuerPubKey = mptAlice.getPubKey(alice)});
mptAlice.generateKeyPair(bob);
uint64_t const amt = 30;
Buffer const bf = generateBlindingFactor();
Buffer const holderCt = mptAlice.encryptAmount(bob, amt, bf);
Buffer const issuerCt = mptAlice.encryptAmount(alice, amt, bf);
std::uint32_t const ticketSeq1 = env.seq(bob) + 1;
env(ticket::create(bob, 1));
// Invalid: Schnorr proof built with the account seq (env.seq(bob)) rather
// than the ticket seq (ticketSeq1).
{
BEAST_EXPECT(env.seq(bob) != ticketSeq1);
uint256 const badCtxHash =
getConvertContextHash(bob, mptAlice.issuanceID(), env.seq(bob));
auto const badProof = requireOptional(
mptAlice.getSchnorrProof(bob, badCtxHash), "Missing Schnorr Proof.");
mptAlice.convert({
.account = bob,
.amt = amt,
.proof = strHex(badProof),
.holderPubKey = mptAlice.getPubKey(bob),
.holderEncryptedAmt = holderCt,
.issuerEncryptedAmt = issuerCt,
.blindingFactor = bf,
.ticketSeq = ticketSeq1,
.err = tecBAD_PROOF,
});
}
std::uint32_t const ticketSeq2 = env.seq(bob) + 1;
env(ticket::create(bob, 1));
// Valid: proof auto-generated by convert() using ticketSeq2; context hashes match.
mptAlice.convert({
.account = bob,
.amt = amt,
.holderPubKey = mptAlice.getPubKey(bob),
.holderEncryptedAmt = holderCt,
.issuerEncryptedAmt = issuerCt,
.blindingFactor = bf,
.ticketSeq = ticketSeq2,
});
env.require(MptBalance(mptAlice, bob, 70));
}
// Exercises ticket-specific error codes for confidential transfer transactions:
void
testDestinationTag(FeatureBitset features)
{
testcase("test Destination Tag");
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;
// Set RequireDest on carol
env(fset(carol, asfRequireDest));
env.close();
// Send without destination tag — rejected
mptAlice.send({
.account = bob,
.dest = carol,
.amt = 10,
.proof = getTrivialSendProofHex(),
.senderEncryptedAmt = getTrivialCiphertext(),
.destEncryptedAmt = getTrivialCiphertext(),
.issuerEncryptedAmt = getTrivialCiphertext(),
.amountCommitment = getTrivialCommitment(),
.balanceCommitment = getTrivialCommitment(),
.err = tecDST_TAG_NEEDED,
});
// Send with destination tag — succeeds (passes preclaim,
// reaches ZKP verification with the real proof)
mptAlice.send({.account = bob, .dest = carol, .amt = 10, .destinationTag = 42});
// Verify the destination tag is in the confirmed transaction
auto const tx = env.tx();
BEAST_EXPECT(tx);
BEAST_EXPECT(tx->isFieldPresent(sfDestinationTag));
BEAST_EXPECT((*tx)[sfDestinationTag] == 42);
env(fclear(carol, asfRequireDest));
env.close();
// Send without destination tag when not required — succeeds
mptAlice.mergeInbox({.account = carol});
mptAlice.send({.account = bob, .dest = carol, .amt = 10});
}
// terPRE_TICKET when the ticket doesn't exist yet, and tefNO_TICKET when
// the ticket has already been consumed or was never created.
void
testTicketErrors(FeatureBitset features)
{
testcase("Confidential transfer ticket errors");
using namespace test::jtx;
Env env{*this, features};
Account const alice("alice");
Account const bob("bob");
MPTTester mptAlice(env, alice, {.holders = {bob}});
mptAlice.create({
.ownerCount = 1,
.holderCount = 0,
.flags = tfMPTCanTransfer | tfMPTCanLock | tfMPTCanHoldConfidentialBalance,
});
mptAlice.authorize({.account = bob});
mptAlice.pay(alice, bob, 100);
mptAlice.generateKeyPair(alice);
mptAlice.set({.account = alice, .issuerPubKey = mptAlice.getPubKey(alice)});
mptAlice.generateKeyPair(bob);
// Give bob an inbox balance so MergeInbox has something to merge.
mptAlice.convert({.account = bob, .amt = 10, .holderPubKey = mptAlice.getPubKey(bob)});
// Use MergeInbox as the confidential transfer transaction under test
// so that ticket errors are isolated from cryptographic verification.
// terPRE_TICKET: ticket sequence is far in the future and hasn't been created.
mptAlice.mergeInbox(
{.account = bob, .ticketSeq = env.seq(bob) + 100, .err = terPRE_TICKET});
// Create one ticket and use it successfully.
std::uint32_t const ticketSeq = env.seq(bob) + 1;
env(ticket::create(bob, 1));
mptAlice.mergeInbox({.account = bob, .ticketSeq = ticketSeq});
// tefNO_TICKET: attempt to reuse the same (already-consumed) ticket.
mptAlice.mergeInbox({.account = bob, .ticketSeq = ticketSeq, .err = tefNO_TICKET});
// tefNO_TICKET: ticket sequence is in the past but was never created.
mptAlice.mergeInbox({.account = bob, .ticketSeq = 1, .err = tefNO_TICKET});
}
// Bob sends 100 MPT to Carol. Carol Merge Inbox. Carol sends 50 MPT to Dave.
// Inner 3rd txn (Carol sends to Dave) fails because the proof is built with
// when Carols's spending balance is 0. (before she received funds from Bob)
//
// Also tests Bob sending to two recipients (Carol and Dave) in a single
// batch. Even though Bob has enough balance for both, the second send's
// balance-linkage proof becomes incorrect once inner 1 updates Bob's encrypted
// spending, so fails
void
testBatchConfidentialSend(FeatureBitset features)
{
testcase("Batch confidential send - merge inbox dependency");
using namespace test::jtx;
{
Env env{*this, features};
Account const alice("alice");
Account const bob("bob");
Account const carol("carol");
Account const dave("dave");
MPTTester mpt(env, alice, {.holders = {bob, carol, dave}});
// bob = A (100 spending), carol = B (0), dave = C (0)
setupBatchEnv(mpt, alice, bob, carol, dave, 100, 0);
// Build the batch:
// Batch Txn 1 bob -> carol 100 : valid proof, bob spending=100
// Batch Txn 2 carol -> mergeInbox : valid JV
// Batch Txn 3 carol->dave 50 : Invalid
auto const bobSeq = env.seq(bob);
auto const carolSeq = env.seq(carol);
// 3 signers, Bob, Carol, Dave
auto const batchFee = batch::calcConfidentialBatchFee(env, 1, 3);
auto const jv1 = mpt.sendJV({.account = bob, .dest = carol, .amt = 100}, bobSeq + 1);
auto const jv2 = mpt.mergeInboxJV({.account = carol});
auto const jv3 = mpt.sendJV({.account = carol, .dest = dave, .amt = 50}, carolSeq + 1);
env(batch::outer(bob, bobSeq, batchFee, tfAllOrNothing),
batch::Inner(jv1, bobSeq + 1),
batch::Inner(jv2, carolSeq),
batch::Inner(jv3, carolSeq + 1),
batch::Sig(carol),
Ter(tesSUCCESS));
env.close();
// AllOrNothing: inner 3 fails
// bob's spending must remain 100; carol's inbox must remain 0.
BEAST_EXPECT(mpt.getDecryptedBalance(bob, MPTTester::holderEncryptedSpending) == 100);
BEAST_EXPECT(mpt.getDecryptedBalance(carol, MPTTester::holderEncryptedInbox) == 0);
}
// Bob sends to two recipients (Carol and Dave) in one batch.
// Bob has 150, enough for both sends individually. However, batch txn 1
// changes Bob's encrypted spending on the ledger; batch txn 2 was built
// against the old enc(150) so its balance-linkage proof is stale.
{
Env env{*this, features};
Account const alice("alice");
Account const bob("bob");
Account const carol("carol");
Account const dave("dave");
MPTTester mpt(env, alice, {.holders = {bob, carol, dave}});
setupBatchEnv(mpt, alice, bob, carol, dave, 150, 0);
// tfAllOrNothing — rejects the whole batch as 2nd txn proof is incorrect
{
auto const bobSeq = env.seq(bob);
auto const batchFee = batch::calcConfidentialBatchFee(env, 0, 2);
auto const jv1 = mpt.sendJV({.account = bob, .dest = carol, .amt = 50}, bobSeq + 1);
auto const jv2 = mpt.sendJV({.account = bob, .dest = dave, .amt = 60}, bobSeq + 2);
env(batch::outer(bob, bobSeq, batchFee, tfAllOrNothing),
batch::Inner(jv1, bobSeq + 1),
batch::Inner(jv2, bobSeq + 2),
Ter(tesSUCCESS));
env.close();
// Nothing applied: bob stays 150, carol and dave inbox stay 0.
BEAST_EXPECT(
mpt.getDecryptedBalance(bob, MPTTester::holderEncryptedSpending) == 150);
BEAST_EXPECT(mpt.getDecryptedBalance(carol, MPTTester::holderEncryptedInbox) == 0);
BEAST_EXPECT(mpt.getDecryptedBalance(dave, MPTTester::holderEncryptedInbox) == 0);
}
// If we change batch mode to be tfIndependent — txn 1 applies, inner 2 fails.
{
auto const bobSeq = env.seq(bob);
auto const batchFee = batch::calcConfidentialBatchFee(env, 0, 2);
auto const jv1 = mpt.sendJV({.account = bob, .dest = carol, .amt = 50}, bobSeq + 1);
auto const jv2 = mpt.sendJV({.account = bob, .dest = dave, .amt = 60}, bobSeq + 2);
env(batch::outer(bob, bobSeq, batchFee, tfIndependent),
batch::Inner(jv1, bobSeq + 1),
batch::Inner(jv2, bobSeq + 2),
Ter(tesSUCCESS));
env.close();
// bob 150→100, carol inbox 0→50
BEAST_EXPECT(
mpt.getDecryptedBalance(bob, MPTTester::holderEncryptedSpending) == 100);
BEAST_EXPECT(mpt.getDecryptedBalance(carol, MPTTester::holderEncryptedInbox) == 50);
// dave gets nothing
BEAST_EXPECT(mpt.getDecryptedBalance(dave, MPTTester::holderEncryptedInbox) == 0);
}
}
// Now, Bob sends Confidential MPT to 2 accounts in one batch.
// However this time, the second txn proof is calculated using the
// correct encrypted(spending) proof, so it should pass.
{
// bob has exactly enough for both sends.
Env env{*this, features};
Account const alice("alice");
Account const bob("bob");
Account const carol("carol");
Account const dave("dave");
MPTTester mpt(env, alice, {.holders = {bob, carol, dave}});
setupBatchEnv(mpt, alice, bob, carol, dave, 200, 0);
{
auto const bobSeq = env.seq(bob);
auto const batchFee = batch::calcConfidentialBatchFee(env, 0, 2);
// jv1 is built against the current ledger state (spending=200).
auto const jv1 =
mpt.sendJV({.account = bob, .dest = carol, .amt = 100}, bobSeq + 1);
// Compute post-jv1 state without touching the ledger.
auto const chain1 = mpt.chainAfterSend(bob, 100, jv1);
// jv2 proof is built against predicted spending=100, version=N+1.
auto const jv2 =
mpt.sendJV({.account = bob, .dest = dave, .amt = 100}, bobSeq + 2, chain1);
env(batch::outer(bob, bobSeq, batchFee, tfAllOrNothing),
batch::Inner(jv1, bobSeq + 1),
batch::Inner(jv2, bobSeq + 2),
Ter(tesSUCCESS));
env.close();
// Both txns applied: bob 200→0, carol inbox=100, dave inbox=100.
BEAST_EXPECT(mpt.getDecryptedBalance(bob, MPTTester::holderEncryptedSpending) == 0);
BEAST_EXPECT(
mpt.getDecryptedBalance(carol, MPTTester::holderEncryptedInbox) == 100);
BEAST_EXPECT(mpt.getDecryptedBalance(dave, MPTTester::holderEncryptedInbox) == 100);
}
// Now Bob has 150, but tries to send two 100 in one batch.
// This fails because Bob doesn't have enough MPT balance.
{
Env env2{*this, features};
Account const alice2("alice");
Account const bob2("bob");
Account const carol2("carol");
Account const dave2("dave");
MPTTester mpt2(env2, alice2, {.holders = {bob2, carol2, dave2}});
setupBatchEnv(mpt2, alice2, bob2, carol2, dave2, 150, 0);
auto const bobSeq = env2.seq(bob2);
auto const batchFee = batch::calcConfidentialBatchFee(env2, 0, 2);
auto const jv1 =
mpt2.sendJV({.account = bob2, .dest = carol2, .amt = 100}, bobSeq + 1);
auto const chain1 = mpt2.chainAfterSend(bob2, 100, jv1);
auto const jv2 =
mpt2.sendJV({.account = bob2, .dest = dave2, .amt = 100}, bobSeq + 2, chain1);
env2(
batch::outer(bob2, bobSeq, batchFee, tfAllOrNothing),
batch::Inner(jv1, bobSeq + 1),
batch::Inner(jv2, bobSeq + 2),
Ter(tesSUCCESS));
env2.close();
// AllOrNothing: inner 2 fails → nothing applied.
BEAST_EXPECT(
mpt2.getDecryptedBalance(bob2, MPTTester::holderEncryptedSpending) == 150);
BEAST_EXPECT(
mpt2.getDecryptedBalance(carol2, MPTTester::holderEncryptedInbox) == 0);
BEAST_EXPECT(mpt2.getDecryptedBalance(dave2, MPTTester::holderEncryptedInbox) == 0);
}
}
}
void
testBatchConfidentialConvertAndConvertBack(FeatureBitset features)
{
testcase("Batch confidential convert and convertBack");
using namespace test::jtx;
// convert + convertBack in one AllOrNothing batch, both valid.
//
// Bob has regular=50, spending=100.
// jv1: convert 50 regular → inbox (Schnorr proof; does NOT touch spending/version)
// jv2: convertBack 30 spending → regular (proof against spending=100, version=V)
//
// Since jv1 leaves spending and version unchanged, jv2's proof is still
// valid when it executes, so both inner txns succeed.
{
Env env{*this, features};
Account const alice("alice");
Account const bob("bob");
Account const carol("carol");
Account const dave("dave");
MPTTester mpt(env, alice, {.holders = {bob, carol, dave}});
// bob: spending=100, regular=0 after setupBatchEnv;
// pay 50 more to give bob regular MPT to convert in the batch.
setupBatchEnv(mpt, alice, bob, carol, dave, 100, 0);
mpt.pay(alice, bob, 50);
auto const bobSeq = env.seq(bob);
auto const batchFee = batch::calcConfidentialBatchFee(env, 0, 2);
// jv1: convert 50 regular MPT into confidential inbox
auto const jv1 = mpt.convertJV({.account = bob, .amt = 50}, bobSeq + 1);
// jv2: convert 30 spending back to regular MPT
auto const jv2 = mpt.convertBackJV({.account = bob, .amt = 30}, bobSeq + 2);
env(batch::outer(bob, bobSeq, batchFee, tfAllOrNothing),
batch::Inner(jv1, bobSeq + 1),
batch::Inner(jv2, bobSeq + 2),
Ter(tesSUCCESS));
env.close();
// regular (mptAmount): 50 (pre) - 50 (convert) + 30 (convertBack) = 30
// spending balance: 100 - 30 = 70
// inbox: 0 + 50 (from convert) = 50
env.require(MptBalance(mpt, bob, 30));
BEAST_EXPECT(mpt.getDecryptedBalance(bob, MPTTester::holderEncryptedSpending) == 70);
BEAST_EXPECT(mpt.getDecryptedBalance(bob, MPTTester::holderEncryptedInbox) == 50);
}
// convert + mergeInbox + convertBack, stale convertBack proof.
//
// jv1: convert 50 regular → inbox
// jv2: mergeInbox (inbox 50 → spending, version V → V+1)
// jv3: convertBack 30 (proof built against spending=100, version=V)
//
// After jv2 applies, spending=150 and version=V+1, so jv3's
// proof is stale. AllOrNothing rejects the whole batch.
{
Env env{*this, features};
Account const alice("alice");
Account const bob("bob");
Account const carol("carol");
Account const dave("dave");
MPTTester mpt(env, alice, {.holders = {bob, carol, dave}});
setupBatchEnv(mpt, alice, bob, carol, dave, 100, 0);
mpt.pay(alice, bob, 50);
auto const bobSeq = env.seq(bob);
auto const batchFee = batch::calcConfidentialBatchFee(env, 0, 3);
auto const jv1 = mpt.convertJV({.account = bob, .amt = 50}, bobSeq + 1);
auto const jv2 = mpt.mergeInboxJV({.account = bob});
// jv3 proof is built against spending=100, version=V (pre-batch)
auto const jv3 = mpt.convertBackJV({.account = bob, .amt = 30}, bobSeq + 3);
env(batch::outer(bob, bobSeq, batchFee, tfAllOrNothing),
batch::Inner(jv1, bobSeq + 1),
batch::Inner(jv2, bobSeq + 2),
batch::Inner(jv3, bobSeq + 3),
Ter(tesSUCCESS));
env.close();
// jv3 fails so nothing is applied.
env.require(MptBalance(mpt, bob, 50));
BEAST_EXPECT(mpt.getDecryptedBalance(bob, MPTTester::holderEncryptedSpending) == 100);
BEAST_EXPECT(mpt.getDecryptedBalance(bob, MPTTester::holderEncryptedInbox) == 0);
}
}
// Tests a batch containing all four confidential MPT operations, Send,
// Convert, ConvertBack, and MergeInbox in a single AllOrNothing batch.
void
testBatchConfidentialMixTransactions(FeatureBitset features)
{
testcase("Batch confidential mixed operations");
using namespace test::jtx;
// send(bob→carol) + convert(carol) + convertBack(dave)
// + mergeInbox(carol) in one AllOrNothing batch.
//
// Setup:
// bob: spending=100, regular=0
// carol: spending=0, regular=50
// dave: spending=50, regular=0
//
// After the batch:
// bob spending: 100 -> 70 (sent 30 to carol)
// carol inbox: 0+30(send)+50(convert)=80 -> merged -> spending=80, inbox=0
// dave spending: 50 -> 30; regular: 0 -> 20
{
Env env{*this, features};
Account const alice("alice");
Account const bob("bob");
Account const carol("carol");
Account const dave("dave");
MPTTester mpt(env, alice, {.holders = {bob, carol, dave}});
// bob: spending=100. carol: key registered, spending=0.
// dave: key registered, spending=0 initially.
setupBatchEnv(mpt, alice, bob, carol, dave, 100, 0);
// Give carol 50 regular MPT to convert in the batch.
mpt.pay(alice, carol, 50);
// Give dave 50 regular MPT then convert to confidential spending.
mpt.pay(alice, dave, 50);
mpt.convert({.account = dave, .amt = 50});
mpt.mergeInbox({.account = dave});
auto const bobSeq = env.seq(bob);
auto const carolSeq = env.seq(carol);
auto const daveSeq = env.seq(dave);
// 2 extra signers (carol, dave), 4 inner txns
auto const batchFee = batch::calcConfidentialBatchFee(env, 2, 4);
// jv1: bob sends 30 to carol
auto const jv1 = mpt.sendJV({.account = bob, .dest = carol, .amt = 30}, bobSeq + 1);
// jv2: carol converts her 50 regular MPT to confidential
auto const jv2 = mpt.convertJV({.account = carol, .amt = 50}, carolSeq);
// jv3: dave converts 20 spending back to regular MPT
auto const jv3 = mpt.convertBackJV({.account = dave, .amt = 20}, daveSeq);
// jv4: carol merges inbox into spending
// (inbox = 30 from jv1 + 50 from jv2 = 80 at execution time)
auto const jv4 = mpt.mergeInboxJV({.account = carol});
env(batch::outer(bob, bobSeq, batchFee, tfAllOrNothing),
batch::Inner(jv1, bobSeq + 1),
batch::Inner(jv2, carolSeq),
batch::Inner(jv3, daveSeq),
batch::Inner(jv4, carolSeq + 1),
batch::Sig(carol, dave),
Ter(tesSUCCESS));
env.close();
// All four applied:
BEAST_EXPECT(mpt.getDecryptedBalance(bob, MPTTester::holderEncryptedSpending) == 70);
// carol's inbox was merged: spending=80, inbox=0
BEAST_EXPECT(mpt.getDecryptedBalance(carol, MPTTester::holderEncryptedSpending) == 80);
BEAST_EXPECT(mpt.getDecryptedBalance(carol, MPTTester::holderEncryptedInbox) == 0);
// dave: spending=30, regular=20
BEAST_EXPECT(mpt.getDecryptedBalance(dave, MPTTester::holderEncryptedSpending) == 30);
env.require(MptBalance(mpt, dave, 20));
}
// bob send + bob convertBack in one AllOrNothing batch.
//
// The Send applies first and increments Bob's version counter.
// The ConvertBack proof was built against the pre-Send (spending=100,
// version=V), so batch txn is rejected.
{
Env env{*this, features};
Account const alice("alice");
Account const bob("bob");
Account const carol("carol");
Account const dave("dave");
MPTTester mpt(env, alice, {.holders = {bob, carol, dave}});
setupBatchEnv(mpt, alice, bob, carol, dave, 100, 0);
auto const bobSeq = env.seq(bob);
auto const batchFee = batch::calcConfidentialBatchFee(env, 0, 2);
// jv1: bob sends 30 to carol (spending 100->70, version V->V+1)
auto const jv1 = mpt.sendJV({.account = bob, .dest = carol, .amt = 30}, bobSeq + 1);
// jv2: bob convertBack 40 , proof built against spending=100, version=V
auto const jv2 = mpt.convertBackJV({.account = bob, .amt = 40}, bobSeq + 2);
env(batch::outer(bob, bobSeq, batchFee, tfAllOrNothing),
batch::Inner(jv1, bobSeq + 1),
batch::Inner(jv2, bobSeq + 2),
Ter(tesSUCCESS));
env.close();
// AllOrNothing: jv2 fails (stale proof) → nothing applied.
BEAST_EXPECT(mpt.getDecryptedBalance(bob, MPTTester::holderEncryptedSpending) == 100);
BEAST_EXPECT(mpt.getDecryptedBalance(carol, MPTTester::holderEncryptedInbox) == 0);
}
}
// Verifies that batch transactions work correctly when tickets are used instead
// of sequence numbers
void
testBatchAllOrNothing(FeatureBitset features)
{
testcase("Batch confidential MPT - all or nothing");
using namespace test::jtx;
Env env{*this, features};
Account const alice("alice");
Account const bob("bob");
Account const carol("carol");
Account const dave("dave");
MPTTester mpt(env, alice, {.holders = {bob, carol, dave}});
// bob=100 spending, carol=60 spending, dave=0
setupBatchEnv(mpt, alice, bob, carol, dave, 100, 60);
// bob sends dave 10, carol sends dave 5, independent, both valid.
{
auto const bobSeq = env.seq(bob);
auto const carolSeq = env.seq(carol);
auto const batchFee = batch::calcConfidentialBatchFee(env, 1, 2);
auto const jv1 = mpt.sendJV({.account = bob, .dest = dave, .amt = 10}, bobSeq + 1);
auto const jv2 = mpt.sendJV({.account = carol, .dest = dave, .amt = 5}, carolSeq);
env(batch::outer(bob, bobSeq, batchFee, tfAllOrNothing),
batch::Inner(jv1, bobSeq + 1),
batch::Inner(jv2, carolSeq),
batch::Sig(carol),
Ter(tesSUCCESS));
env.close();
// Both txn applied: bob's balance 100→90, carol 60→55, dave inbox 0→15
BEAST_EXPECT(mpt.getDecryptedBalance(bob, MPTTester::holderEncryptedSpending) == 90);
BEAST_EXPECT(mpt.getDecryptedBalance(carol, MPTTester::holderEncryptedSpending) == 55);
BEAST_EXPECT(mpt.getDecryptedBalance(dave, MPTTester::holderEncryptedInbox) == 15);
}
}
void
testBatchOnlyOne(FeatureBitset features)
{
testcase("Batch confidential MPT - only one");
using namespace test::jtx;
Env env{*this, features};
Account const alice("alice");
Account const bob("bob");
Account const carol("carol");
Account const dave("dave");
MPTTester mpt(env, alice, {.holders = {bob, carol, dave}});
// bob=100 spending, carol=60 spending, dave=0
setupBatchEnv(mpt, alice, bob, carol, dave, 100, 60);
// bob sends dave 200 (invalid), carol sends dave 300 (invalid)
{
auto const bobSeq = env.seq(bob);
auto const carolSeq = env.seq(carol);
auto const batchFee = batch::calcConfidentialBatchFee(env, 1, 2);
// Both proofs fail range check (amount > balance)
auto const jv1 = mpt.sendJV({.account = bob, .dest = dave, .amt = 200}, bobSeq + 1);
auto const jv2 = mpt.sendJV({.account = carol, .dest = dave, .amt = 300}, carolSeq);
env(batch::outer(bob, bobSeq, batchFee, tfOnlyOne),
batch::Inner(jv1, bobSeq + 1),
batch::Inner(jv2, carolSeq),
batch::Sig(carol),
Ter(tesSUCCESS));
env.close();
// No success found → nothing applied; balances unchanged
BEAST_EXPECT(mpt.getDecryptedBalance(bob, MPTTester::holderEncryptedSpending) == 100);
BEAST_EXPECT(mpt.getDecryptedBalance(carol, MPTTester::holderEncryptedSpending) == 60);
BEAST_EXPECT(mpt.getDecryptedBalance(dave, MPTTester::holderEncryptedInbox) == 0);
}
// bob sends dave 200 (invalid), carol sends dave 5 (valid)
{
auto const bobSeq = env.seq(bob);
auto const carolSeq = env.seq(carol);
auto const batchFee = batch::calcConfidentialBatchFee(env, 1, 2);
auto jv1 = mpt.sendJV({.account = bob, .dest = dave, .amt = 200}, bobSeq + 1);
auto jv2 = mpt.sendJV({.account = carol, .dest = dave, .amt = 5}, carolSeq);
env(batch::outer(bob, bobSeq, batchFee, tfOnlyOne),
batch::Inner(jv1, bobSeq + 1),
batch::Inner(jv2, carolSeq),
batch::Sig(carol),
Ter(tesSUCCESS));
env.close();
// Only carol's send applied: carol 60→55, dave inbox 0→5, bob unchanged
BEAST_EXPECT(mpt.getDecryptedBalance(bob, MPTTester::holderEncryptedSpending) == 100);
BEAST_EXPECT(mpt.getDecryptedBalance(carol, MPTTester::holderEncryptedSpending) == 55);
BEAST_EXPECT(mpt.getDecryptedBalance(dave, MPTTester::holderEncryptedInbox) == 5);
}
}
void
testBatchUntilFailure(FeatureBitset features)
{
testcase("Batch confidential MPT - until failure");
using namespace test::jtx;
Env env{*this, features};
Account const alice("alice");
Account const bob("bob");
Account const carol("carol");
Account const dave("dave");
MPTTester mpt(env, alice, {.holders = {bob, carol, dave}});
// bob=100 spending, carol=60 spending, dave=0
setupBatchEnv(mpt, alice, bob, carol, dave, 100, 60);
// first fails → none applied
// Bob sends Dave 200 (invalid — stops immediately)
{
auto const bobSeq = env.seq(bob);
auto const carolSeq = env.seq(carol);
auto const batchFee = batch::calcConfidentialBatchFee(env, 1, 2);
auto const jv1 = mpt.sendJV({.account = bob, .dest = dave, .amt = 200}, bobSeq + 1);
auto const jv2 = mpt.sendJV({.account = carol, .dest = dave, .amt = 5}, carolSeq);
env(batch::outer(bob, bobSeq, batchFee, tfUntilFailure),
batch::Inner(jv1, bobSeq + 1),
batch::Inner(jv2, carolSeq),
batch::Sig(carol),
Ter(tesSUCCESS));
env.close();
BEAST_EXPECT(mpt.getDecryptedBalance(bob, MPTTester::holderEncryptedSpending) == 100);
BEAST_EXPECT(mpt.getDecryptedBalance(carol, MPTTester::holderEncryptedSpending) == 60);
}
// Bob sends dave 10, Carol sends dave 5 — both valid and independent
{
auto const bobSeq = env.seq(bob);
auto const carolSeq = env.seq(carol);
auto const batchFee = batch::calcConfidentialBatchFee(env, 1, 2);
auto const jv1 = mpt.sendJV({.account = bob, .dest = dave, .amt = 10}, bobSeq + 1);
auto const jv2 = mpt.sendJV({.account = carol, .dest = dave, .amt = 5}, carolSeq);
env(batch::outer(bob, bobSeq, batchFee, tfUntilFailure),
batch::Inner(jv1, bobSeq + 1),
batch::Inner(jv2, carolSeq),
batch::Sig(carol),
Ter(tesSUCCESS));
env.close();
// Both applied: bob 100→90, carol 60→55, dave inbox 0→15
BEAST_EXPECT(mpt.getDecryptedBalance(bob, MPTTester::holderEncryptedSpending) == 90);
BEAST_EXPECT(mpt.getDecryptedBalance(carol, MPTTester::holderEncryptedSpending) == 55);
BEAST_EXPECT(mpt.getDecryptedBalance(dave, MPTTester::holderEncryptedInbox) == 15);
}
}
void
testBatchIndependent(FeatureBitset features)
{
testcase("Batch confidential MPT - independent");
using namespace test::jtx;
Env env{*this, features};
Account const alice("alice");
Account const bob("bob");
Account const carol("carol");
Account const dave("dave");
MPTTester mpt(env, alice, {.holders = {bob, carol, dave}});
// bob=100 spending, carol=60 spending, dave=0
setupBatchEnv(mpt, alice, bob, carol, dave, 100, 60);
// Bob sends dave 10 (valid), Carol sends dave 300
// (invalid), Carol sends Dave 5 (valid). Carol's
// balance is still 60 because the preceding send failed).
{
auto const bobSeq = env.seq(bob);
auto const carolSeq = env.seq(carol);
auto const batchFee = batch::calcConfidentialBatchFee(env, 1, 3);
auto const jv1 = mpt.sendJV({.account = bob, .dest = dave, .amt = 10}, bobSeq + 1);
// Carol trying to send dave 300 but own balance only 60
auto const jv2 = mpt.sendJV({.account = carol, .dest = dave, .amt = 300}, carolSeq);
auto const jv3 = mpt.sendJV({.account = carol, .dest = dave, .amt = 5}, carolSeq + 1);
env(batch::outer(bob, bobSeq, batchFee, tfIndependent),
batch::Inner(jv1, bobSeq + 1),
batch::Inner(jv2, carolSeq),
batch::Inner(jv3, carolSeq + 1),
batch::Sig(carol),
Ter(tesSUCCESS));
env.close();
// inner 1 (bob→dave 10) applied: bob 100→90
BEAST_EXPECT(mpt.getDecryptedBalance(bob, MPTTester::holderEncryptedSpending) == 90);
// inner 2 failed (carol not changed), inner 3 applied: carol 60→55
BEAST_EXPECT(mpt.getDecryptedBalance(carol, MPTTester::holderEncryptedSpending) == 55);
// dave inbox: 10 (from bob) + 5 (from carol inner 3) = 15
BEAST_EXPECT(mpt.getDecryptedBalance(dave, MPTTester::holderEncryptedInbox) == 15);
}
}
// Tests batching ConfidentialMPTConvert and a ConfidentialMPTConvertBack
// in the same batch transaction. Because Convert only modifies the inbox
// (never the spending balance or the version counter), a ConvertBack proof
// built against the pre-batch spending balance is still valid when both
// appear in the same batch.
void
testBatchWithTickets(FeatureBitset features)
{
testcase("Batch confidential MPT with tickets");
using namespace test::jtx;
// outer batch uses a ticket.
// The inner send proofs are still bound to regular account sequences.
{
Env env{*this, features};
Account const alice("alice");
Account const bob("bob");
Account const carol("carol");
Account const dave("dave");
MPTTester mpt(env, alice, {.holders = {bob, carol, dave}});
setupBatchEnv(mpt, alice, bob, carol, dave, 100, 0);
// Bob creates one ticket to use for the outer batch.
std::uint32_t const outerTicketSeq = env.seq(bob) + 1;
env(ticket::create(bob, 1));
env.close();
auto const bobSeq = env.seq(bob);
// 0 extra signers: all inner txns are from bob;
auto const batchFee = batch::calcConfidentialBatchFee(env, 0, 2);
// When the outer uses a ticket (seq=0), inner txns start from bobSeq, bobSeq+1.
// jv2 must use chain state predicted after jv1 since both sends are from bob.
auto const jv1 = mpt.sendJV({.account = bob, .dest = carol, .amt = 40}, bobSeq);
auto const chain1 = mpt.chainAfterSend(bob, 40, jv1);
auto const jv2 =
mpt.sendJV({.account = bob, .dest = dave, .amt = 20}, bobSeq + 1, chain1);
env(batch::outer(bob, 0, batchFee, tfAllOrNothing),
batch::Inner(jv1, bobSeq),
batch::Inner(jv2, bobSeq + 1),
ticket::Use(outerTicketSeq),
Ter(tesSUCCESS));
env.close();
// Both sends applied: bob 100→40, carol inbox=40, dave inbox=20.
BEAST_EXPECT(mpt.getDecryptedBalance(bob, MPTTester::holderEncryptedSpending) == 40);
BEAST_EXPECT(mpt.getDecryptedBalance(carol, MPTTester::holderEncryptedInbox) == 40);
BEAST_EXPECT(mpt.getDecryptedBalance(dave, MPTTester::holderEncryptedInbox) == 20);
}
// inner transactions each consume their own ticket.
// The send proof context hash must be bound to the ticket sequence, not the
// account sequence. sendJV receives the ticket seq as its `seq` parameter.
{
Env env{*this, features};
Account const alice("alice");
Account const bob("bob");
Account const carol("carol");
Account const dave("dave");
MPTTester mpt(env, alice, {.holders = {bob, carol, dave}});
setupBatchEnv(mpt, alice, bob, carol, dave, 100, 0);
// Bob creates two tickets for the two inner sends.
std::uint32_t const ticketSeq1 = env.seq(bob) + 1;
std::uint32_t const ticketSeq2 = env.seq(bob) + 2;
env(ticket::create(bob, 2));
env.close();
auto const bobSeq = env.seq(bob);
auto const batchFee = batch::calcConfidentialBatchFee(env, 0, 2);
// jv1: proof bound to ticketSeq1.
auto const jv1 = mpt.sendJV({.account = bob, .dest = carol, .amt = 40}, ticketSeq1);
// jv2: proof bound to ticketSeq2, spending state predicted after jv1.
auto const chain1 = mpt.chainAfterSend(bob, 40, jv1);
auto const jv2 =
mpt.sendJV({.account = bob, .dest = dave, .amt = 30}, ticketSeq2, chain1);
env(batch::outer(bob, bobSeq, batchFee, tfAllOrNothing),
batch::Inner(jv1, 0, ticketSeq1),
batch::Inner(jv2, 0, ticketSeq2),
Ter(tesSUCCESS));
env.close();
// Both sends applied: bob 100→30, carol inbox=40, dave inbox=30.
BEAST_EXPECT(mpt.getDecryptedBalance(bob, MPTTester::holderEncryptedSpending) == 30);
BEAST_EXPECT(mpt.getDecryptedBalance(carol, MPTTester::holderEncryptedInbox) == 40);
BEAST_EXPECT(mpt.getDecryptedBalance(dave, MPTTester::holderEncryptedInbox) == 30);
}
// inner send uses wrong sequence (account seq instead of ticket seq)
{
Env env{*this, features};
Account const alice("alice");
Account const bob("bob");
Account const carol("carol");
Account const dave("dave");
MPTTester mpt(env, alice, {.holders = {bob, carol, dave}});
setupBatchEnv(mpt, alice, bob, carol, dave, 100, 0);
std::uint32_t const ticketSeq = env.seq(bob) + 1;
env(ticket::create(bob, 1));
env.close();
auto const bobSeq = env.seq(bob);
auto const batchFee = batch::calcConfidentialBatchFee(env, 0, 2);
// Proof intentionally built with account seq (bobSeq+1) instead of ticketSeq.
auto const badJV = mpt.sendJV({.account = bob, .dest = carol, .amt = 40}, bobSeq + 1);
auto const jv2 = mpt.mergeInboxJV({.account = bob});
env(batch::outer(bob, bobSeq, batchFee, tfAllOrNothing),
batch::Inner(badJV, 0, ticketSeq),
batch::Inner(jv2, bobSeq + 1),
Ter(tesSUCCESS));
env.close();
BEAST_EXPECT(mpt.getDecryptedBalance(bob, MPTTester::holderEncryptedSpending) == 100);
BEAST_EXPECT(mpt.getDecryptedBalance(carol, MPTTester::holderEncryptedInbox) == 0);
}
}
// Basic tests of confidential transfer through delegation. Verifies that a delegated account
// with the appropriate permissions can execute confidential transfer transactions
// on behalf of the delegator.
void
testConfidentialDelegation(FeatureBitset features)
{
testcase("Confidential transfers through delegation");
using namespace test::jtx;
Env env{*this, features};
Account const alice{"alice"};
Account const bob{"bob"};
Account const carol{"carol"};
Account const dave{"dave"};
MPTTester mptAlice(env, alice, {.holders = {bob, carol}});
env.fund(XRP(10000), dave);
env.close();
mptAlice.create({
.ownerCount = 1,
.flags = tfMPTCanTransfer | tfMPTCanLock | tfMPTCanClawback |
tfMPTCanHoldConfidentialBalance,
});
mptAlice.authorize({.account = bob});
mptAlice.authorize({.account = carol});
mptAlice.pay(alice, bob, 200);
mptAlice.pay(alice, carol, 100);
mptAlice.generateKeyPair(alice);
mptAlice.generateKeyPair(bob);
mptAlice.generateKeyPair(carol);
mptAlice.set({.issuerPubKey = mptAlice.getPubKey(alice)});
// ConfidentialMPTConvert is not delegable: attempting to grant it as a
// delegated permission is rejected at preflight of DelegateSet.
env(delegate::set(bob, dave, {"ConfidentialMPTConvert"}), Ter(temMALFORMED));
env.close();
// Bob delegates MergeInbox to dave.
env(delegate::set(bob, dave, {"ConfidentialMPTMergeInbox"}));
env.close();
// A Convert carrying a Delegate is rejected at preflight because the
// transaction type is not delegable at all.
mptAlice.convert({
.account = bob,
.amt = 10,
.holderPubKey = mptAlice.getPubKey(bob),
.delegate = dave,
.err = temINVALID,
});
// Bob converts, registering bob's key.
mptAlice.convert({
.account = bob,
.amt = 100,
.holderPubKey = mptAlice.getPubKey(bob),
});
env.require(MptBalance(mptAlice, bob, 100));
// Bob converts again (no key registration).
mptAlice.convert({.account = bob, .amt = 50});
// Dave executes MergeInbox on behalf of bob.
mptAlice.mergeInbox({.account = bob, .delegate = dave});
// Carol converts and merge inbox.
mptAlice.convert({
.account = carol,
.amt = 100,
.holderPubKey = mptAlice.getPubKey(carol),
});
mptAlice.mergeInbox({.account = carol});
// Dave does not have permission to send on behalf of bob.
mptAlice.send(
{.account = bob,
.dest = carol,
.amt = 10,
.delegate = dave,
.err = terNO_DELEGATE_PERMISSION});
// Bob delegates ConfidentialMPTSend to dave.
env(delegate::set(bob, dave, {"ConfidentialMPTMergeInbox", "ConfidentialMPTSend"}));
env.close();
// Dave executes Send on behalf of bob.
mptAlice.send({.account = bob, .dest = carol, .amt = 10, .delegate = dave});
mptAlice.mergeInbox({.account = carol});
// Dave does not have permission to convert back on behalf of bob.
mptAlice.convertBack(
{.account = bob, .amt = 10, .delegate = dave, .err = terNO_DELEGATE_PERMISSION});
// Bob delegates ConfidentialMPTConvertBack to dave.
env(delegate::set(
bob,
dave,
{"ConfidentialMPTMergeInbox", "ConfidentialMPTSend", "ConfidentialMPTConvertBack"}));
env.close();
// Dave executes ConvertBack on behalf of bob.
mptAlice.convertBack({.account = bob, .amt = 10, .delegate = dave});
// Dave does not have permission to clawback on behalf of alice.
mptAlice.confidentialClaw(
{.holder = bob, .amt = 130, .delegate = dave, .err = terNO_DELEGATE_PERMISSION});
// Alice delegates ConfidentialMPTClawback to dave.
env(delegate::set(alice, dave, {"ConfidentialMPTClawback"}));
env.close();
// Dave executes Clawback on behalf of alice.
mptAlice.confidentialClaw({.holder = bob, .amt = 130, .delegate = dave});
}
// Verifies that revoking delegation prevents further delegated operations.
void
testDelegationRevocation(FeatureBitset features)
{
testcase("Confidential delegation revocation");
using namespace test::jtx;
Env env{*this, features};
Account const alice{"alice"};
Account const bob{"bob"};
Account const carol{"carol"};
MPTTester mptAlice(env, alice, {.holders = {bob}});
env.fund(XRP(10000), carol);
env.close();
mptAlice.create({
.ownerCount = 1,
.flags = tfMPTCanTransfer | tfMPTCanHoldConfidentialBalance,
});
mptAlice.authorize({.account = bob});
mptAlice.pay(alice, bob, 100);
mptAlice.generateKeyPair(alice);
mptAlice.generateKeyPair(bob);
mptAlice.set({.issuerPubKey = mptAlice.getPubKey(alice)});
// Creating the Delegate SLE consumes one owner reserve slot for bob.
auto const bobOwnersBefore = ownerCount(env, bob);
env(delegate::set(bob, carol, {"ConfidentialMPTMergeInbox"}));
env.close();
env.require(Owners(bob, bobOwnersBefore + 1));
// Bob converts; carol merges inbox on behalf of bob.
mptAlice.convert({
.account = bob,
.amt = 50,
.holderPubKey = mptAlice.getPubKey(bob),
});
mptAlice.mergeInbox({.account = bob, .delegate = carol});
// Bob revokes all permissions, deletes the Delegate SLE, releasing the reserve.
env(delegate::set(bob, carol, std::vector<std::string>{}));
env.close();
env.require(Owners(bob, bobOwnersBefore));
// Bob converts again to populate a fresh inbox.
mptAlice.convert({.account = bob, .amt = 30});
// Carol can no longer merge inbox on behalf of bob.
mptAlice.mergeInbox({
.account = bob,
.delegate = carol,
.err = terNO_DELEGATE_PERMISSION,
});
// Bob can still merge his inbox.
mptAlice.mergeInbox({.account = bob});
}
// Verifies that a delegated confidential transfer works correctly when an
// auditor is configured on the issuance.
void
testDelegationWithAuditor(FeatureBitset features)
{
testcase("Confidential delegation with auditor");
using namespace test::jtx;
Env env{*this, features};
Account const alice{"alice"};
Account const bob{"bob"};
Account const carol{"carol"};
Account const dave{"dave"};
Account const auditor{"auditor"};
MPTTester mptAlice(env, alice, {.holders = {bob, carol}, .auditor = auditor});
env.fund(XRP(10000), dave);
env.close();
mptAlice.create({
.ownerCount = 1,
.flags = tfMPTCanTransfer | 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({
.issuerPubKey = mptAlice.getPubKey(alice),
.auditorPubKey = mptAlice.getPubKey(auditor),
});
// Bob delegates Send permission to dave (Convert is not delegable).
env(delegate::set(bob, dave, {"ConfidentialMPTSend"}));
env.close();
// Bob converts.
mptAlice.convert({
.account = bob,
.amt = 50,
.holderPubKey = mptAlice.getPubKey(bob),
});
mptAlice.mergeInbox({.account = bob});
mptAlice.convert({
.account = carol,
.amt = 50,
.holderPubKey = mptAlice.getPubKey(carol),
});
mptAlice.mergeInbox({.account = carol});
// Dave sends on behalf of bob.
mptAlice.send({.account = bob, .dest = carol, .amt = 20, .delegate = dave});
mptAlice.send({.account = bob, .dest = carol, .amt = 10, .delegate = dave});
// Bob delegates ConvertBack and Send permissions to auditor.
env(delegate::set(bob, auditor, {"ConfidentialMPTSend", "ConfidentialMPTConvertBack"}));
env.close();
// auditor can send and convert back on behalf of bob as well.
mptAlice.send({.account = bob, .dest = carol, .amt = 10, .delegate = auditor});
mptAlice.convertBack({.account = bob, .amt = 10, .delegate = auditor});
}
// Verifies that a non-issuer delegating clawback to a third party does not
// allow that party to execute clawback, since clawback is issuer-only.
void
testDelegationClawbackIssuerOnly(FeatureBitset features)
{
testcase("Confidential clawback delegation requires issuer");
using namespace test::jtx;
Env env{*this, features};
Account const alice{"alice"};
Account const bob{"bob"};
Account const carol{"carol"};
Account const dave{"dave"};
ConfidentialEnv const confEnv{
env,
alice,
{{.account = bob, .payAmount = 100, .convertAmount = 50},
{.account = carol, .payAmount = 100, .convertAmount = 100}},
tfMPTCanTransfer | tfMPTCanClawback | tfMPTCanHoldConfidentialBalance};
auto& mptAlice = confEnv.mpt;
env.fund(XRP(10000), dave);
env.close();
// Bob delegates Clawback permission to dave.
env(delegate::set(bob, dave, {"ConfidentialMPTClawback"}));
env.close();
// Dave attempts clawback on behalf of bob targetting bob, but since bob is not the issuer,
// the transaction should be rejected.
{
json::Value jv;
jv[jss::Account] = bob.human();
jv[jss::TransactionType] = jss::ConfidentialMPTClawback;
jv[sfMPTokenIssuanceID] = to_string(mptAlice.issuanceID());
jv[sfHolder] = bob.human();
jv[sfMPTAmount.jsonName] = "50";
jv[sfZKProof.jsonName] = std::string(kEcClawbackProofLength * 2, '0');
env(jv, delegate::As(dave), Ter(temMALFORMED));
}
// Dave attempts clawback on behalf of bob targeting carol, but since bob is not the issuer,
// the transaction should be rejected.
{
json::Value jv;
jv[jss::Account] = bob.human();
jv[jss::TransactionType] = jss::ConfidentialMPTClawback;
jv[sfMPTokenIssuanceID] = to_string(mptAlice.issuanceID());
jv[sfHolder] = carol.human();
jv[sfMPTAmount.jsonName] = "100";
jv[sfZKProof.jsonName] = std::string(kEcClawbackProofLength * 2, '0');
env(jv, delegate::As(dave), Ter(temMALFORMED));
}
}
// Batch with delegated ConfidentialMPTSend txs, covering stale and updated inner
// send proofs.
void
testBatchDelegatedSend(FeatureBitset features)
{
testcase("Batch ConfidentialMPTSend with delegation");
using namespace test::jtx;
// AllOrNothing: two delegated sends from bob via dave, second proof is
// stale once the first send updates bob's spending, whole batch rolls back.
{
Env env{*this, features};
Account const alice("alice");
Account const bob("bob");
Account const carol("carol");
Account const dave("dave");
MPTTester mpt(env, alice, {.holders = {bob, carol, dave}});
setupBatchEnv(mpt, alice, bob, carol, dave, 100, 0);
env(delegate::set(bob, dave, {"ConfidentialMPTSend"}));
env.close();
auto const bobSeq = env.seq(bob);
auto const batchFee = batch::calcConfidentialBatchFee(env, 1, 2);
// jv1: proof against spending balance 100
auto jv1 = mpt.sendJV({.account = bob, .dest = carol, .amt = 60}, bobSeq + 1);
jv1[jss::Delegate] = dave.human();
// jv2: proof also against spending balance 100, which is stale once jv1 applies
auto jv2 = mpt.sendJV({.account = bob, .dest = dave, .amt = 60}, bobSeq + 2);
jv2[jss::Delegate] = dave.human();
env(batch::outer(bob, bobSeq, batchFee, tfAllOrNothing),
batch::Inner(jv1, bobSeq + 1),
batch::Inner(jv2, bobSeq + 2),
batch::Sig(dave),
Ter(tesSUCCESS));
env.close();
// Stale proof on jv2, AllOrNothing rolls back everything.
BEAST_EXPECT(mpt.getDecryptedBalance(bob, MPTTester::holderEncryptedSpending) == 100);
BEAST_EXPECT(mpt.getDecryptedBalance(carol, MPTTester::holderEncryptedInbox) == 0);
BEAST_EXPECT(mpt.getDecryptedBalance(dave, MPTTester::holderEncryptedInbox) == 0);
}
// AllOrNothing: two delegated sends with correctly chained proofs both apply.
{
Env env{*this, features};
Account const alice("alice");
Account const bob("bob");
Account const carol("carol");
Account const dave("dave");
MPTTester mpt(env, alice, {.holders = {bob, carol, dave}});
setupBatchEnv(mpt, alice, bob, carol, dave, 100, 0);
env(delegate::set(bob, dave, {"ConfidentialMPTSend"}));
env.close();
auto const bobSeq = env.seq(bob);
auto const batchFee = batch::calcConfidentialBatchFee(env, 1, 2);
// jv1: proof against spending balance 100.
auto jv1 = mpt.sendJV({.account = bob, .dest = carol, .amt = 40}, bobSeq + 1);
jv1[jss::Delegate] = dave.human();
auto const chain1 = mpt.chainAfterSend(bob, 40, jv1);
// jv2: proof against predicted spending balance 60.
auto jv2 = mpt.sendJV({.account = bob, .dest = dave, .amt = 40}, bobSeq + 2, chain1);
jv2[jss::Delegate] = dave.human();
env(batch::outer(bob, bobSeq, batchFee, tfAllOrNothing),
batch::Inner(jv1, bobSeq + 1),
batch::Inner(jv2, bobSeq + 2),
batch::Sig(dave),
Ter(tesSUCCESS));
env.close();
// Both inner tx applied
BEAST_EXPECT(mpt.getDecryptedBalance(bob, MPTTester::holderEncryptedSpending) == 20);
BEAST_EXPECT(mpt.getDecryptedBalance(carol, MPTTester::holderEncryptedInbox) == 40);
BEAST_EXPECT(mpt.getDecryptedBalance(dave, MPTTester::holderEncryptedInbox) == 40);
}
}
// Test missing delegation permission inside a batch.
void
testBatchDelegationMissingPermission(FeatureBitset features)
{
testcase("Batch delegation missing permission");
using namespace test::jtx;
// AllOrNothing: dave has no Send permission from bob, so the delegated
// inner send fails. The whole batch rolls back.
{
Env env{*this, features};
Account const alice("alice");
Account const bob("bob");
Account const carol("carol");
Account const dave("dave");
MPTTester mpt(env, alice, {.holders = {bob, carol, dave}});
setupBatchEnv(mpt, alice, bob, carol, dave, 100, 60);
// Bob grants dave only MergeInbox, not Send.
env(delegate::set(bob, dave, {"ConfidentialMPTMergeInbox"}));
env.close();
auto const bobSeq = env.seq(bob);
auto const carolSeq = env.seq(carol);
auto const batchFee = batch::calcConfidentialBatchFee(env, 2, 2);
// jv1: direct send from carol (valid proof).
auto const jv1 = mpt.sendJV({.account = carol, .dest = dave, .amt = 30}, carolSeq);
// jv2: delegated send, fails because dave has no Send permission.
auto jv2 = mpt.sendJV({.account = bob, .dest = carol, .amt = 50}, bobSeq + 1);
jv2[jss::Delegate] = dave.human();
env(batch::outer(bob, bobSeq, batchFee, tfAllOrNothing),
batch::Inner(jv1, carolSeq),
batch::Inner(jv2, bobSeq + 1),
batch::Sig(carol, dave),
Ter(tesSUCCESS));
env.close();
// jv1 applied in the batch view, then jv2 failed, so
// AllOrNothing discards both inner effects.
BEAST_EXPECT(mpt.getDecryptedBalance(bob, MPTTester::holderEncryptedSpending) == 100);
BEAST_EXPECT(mpt.getDecryptedBalance(carol, MPTTester::holderEncryptedSpending) == 60);
BEAST_EXPECT(mpt.getDecryptedBalance(dave, MPTTester::holderEncryptedInbox) == 0);
}
// Independent: the delegated confidential send is skipped because lack of permission. The
// send from carol still applies.
{
Env env{*this, features};
Account const alice("alice");
Account const bob("bob");
Account const carol("carol");
Account const dave("dave");
MPTTester mpt(env, alice, {.holders = {bob, carol, dave}});
setupBatchEnv(mpt, alice, bob, carol, dave, 100, 60);
// Bob does not grant dave any permissions.
auto const bobSeq = env.seq(bob);
auto const carolSeq = env.seq(carol);
auto const batchFee = batch::calcConfidentialBatchFee(env, 2, 2);
auto jv1 = mpt.sendJV({.account = bob, .dest = carol, .amt = 50}, bobSeq + 1);
jv1[jss::Delegate] = dave.human();
auto const jv2 = mpt.sendJV({.account = carol, .dest = dave, .amt = 30}, carolSeq);
env(batch::outer(bob, bobSeq, batchFee, tfIndependent),
batch::Inner(jv1, bobSeq + 1),
batch::Inner(jv2, carolSeq),
batch::Sig(carol, dave),
Ter(tesSUCCESS));
env.close();
// jv1 failed and jv2 applied.
BEAST_EXPECT(mpt.getDecryptedBalance(bob, MPTTester::holderEncryptedSpending) == 100);
BEAST_EXPECT(mpt.getDecryptedBalance(carol, MPTTester::holderEncryptedSpending) == 30);
BEAST_EXPECT(mpt.getDecryptedBalance(dave, MPTTester::holderEncryptedInbox) == 30);
}
}
// Test batch outer signer is the delegated account.
void
testBatchDelegatedSendWithDelegateAsOuterAccount(FeatureBitset features)
{
testcase("Test batch delegated send with delegate as outer account");
using namespace test::jtx;
// Dave holds bob's ConfidentialMPTSend delegation and is the outer batch
// signer, so dave's outer signature consents to the delegated inner.
// The batch applies.
{
Env env{*this, features};
Account const alice("alice");
Account const bob("bob");
Account const carol("carol");
Account const dave("dave");
MPTTester mpt(env, alice, {.holders = {bob, carol, dave}});
setupBatchEnv(mpt, alice, bob, carol, dave, 100, 0);
env(delegate::set(bob, dave, {"ConfidentialMPTSend"}));
env.close();
auto const daveSeq = env.seq(dave);
auto const bobSeq = env.seq(bob);
auto const batchFee = batch::calcConfidentialBatchFee(env, 0, 2);
auto jv1 = mpt.sendJV({.account = bob, .dest = carol, .amt = 40}, bobSeq);
jv1[jss::Delegate] = dave.human();
auto const jv2 = mpt.mergeInboxJV({.account = dave});
env(batch::outer(dave, daveSeq, batchFee, tfAllOrNothing),
batch::Inner(jv1, bobSeq),
batch::Inner(jv2, daveSeq + 1),
Ter(tesSUCCESS));
env.close();
BEAST_EXPECT(mpt.getDecryptedBalance(bob, MPTTester::holderEncryptedSpending) == 60);
BEAST_EXPECT(mpt.getDecryptedBalance(carol, MPTTester::holderEncryptedInbox) == 40);
}
// Dave submits a mixed batch: bob signs inner tx1, and
// dave is the Delegate account signing for inner tx2.
{
Env env{*this, features};
Account const alice("alice");
Account const bob("bob");
Account const carol("carol");
Account const dave("dave");
MPTTester mpt(env, alice, {.holders = {bob, carol, dave}});
setupBatchEnv(mpt, alice, bob, carol, dave, 100, 0);
env(delegate::set(bob, dave, {"ConfidentialMPTSend"}));
env.close();
auto const daveSeq = env.seq(dave);
auto const bobSeq = env.seq(bob);
auto const batchFee = batch::calcConfidentialBatchFee(env, 1, 2);
auto const jv1 = mpt.sendJV({.account = bob, .dest = carol, .amt = 40}, bobSeq);
auto const chain1 = mpt.chainAfterSend(bob, 40, jv1);
auto jv2 = mpt.sendJV({.account = bob, .dest = carol, .amt = 30}, bobSeq + 1, chain1);
jv2[jss::Delegate] = dave.human();
// Dave is outer; bob signs because his account appears in inner txns.
env(batch::outer(dave, daveSeq, batchFee, tfAllOrNothing),
batch::Inner(jv1, bobSeq),
batch::Inner(jv2, bobSeq + 1),
batch::Sig(bob),
Ter(tesSUCCESS));
env.close();
// Both sends applied: bob 100→30, carol inbox=70.
BEAST_EXPECT(mpt.getDecryptedBalance(bob, MPTTester::holderEncryptedSpending) == 30);
BEAST_EXPECT(mpt.getDecryptedBalance(carol, MPTTester::holderEncryptedInbox) == 70);
}
// The delegated inner's required signer is the delegate (dave), not bob.
// Bob signs but is not a required signer, so the batch is rejected as an
// extra signer. The delegator's signature cannot stand in for the
// delegate's.
{
Env env{*this, features};
Account const alice("alice");
Account const bob("bob");
Account const carol("carol");
Account const dave("dave");
MPTTester mpt(env, alice, {.holders = {bob, carol, dave}});
setupBatchEnv(mpt, alice, bob, carol, dave, 100, 60);
// Bob does not grant dave any permissions.
auto const daveSeq = env.seq(dave);
auto const bobSeq = env.seq(bob);
auto const carolSeq = env.seq(carol);
auto const batchFee = batch::calcConfidentialBatchFee(env, 2, 2);
auto jv1 = mpt.sendJV({.account = bob, .dest = carol, .amt = 50}, bobSeq);
jv1[jss::Delegate] = dave.human();
auto const jv2 = mpt.sendJV({.account = carol, .dest = dave, .amt = 30}, carolSeq);
env(batch::outer(dave, daveSeq, batchFee, tfAllOrNothing),
batch::Inner(jv1, bobSeq),
batch::Inner(jv2, carolSeq),
batch::Sig(bob, carol),
Ter(temBAD_SIGNER));
env.close();
// jv1 fails before jv2 is attempted.
BEAST_EXPECT(mpt.getDecryptedBalance(bob, MPTTester::holderEncryptedSpending) == 100);
BEAST_EXPECT(mpt.getDecryptedBalance(carol, MPTTester::holderEncryptedSpending) == 60);
BEAST_EXPECT(mpt.getDecryptedBalance(dave, MPTTester::holderEncryptedInbox) == 0);
}
}
// Mixed batch with delegated and non-delegated inner confidential MPT transactions.
void
testBatchDelegatedConfidentialMix(FeatureBitset features)
{
testcase("Batch delegated confidential multiple operations");
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 erin("erin");
Account const frank("frank");
MPTTester mpt(env, alice, {.holders = {bob, carol, dave, frank}});
setupBatchEnv(mpt, alice, bob, carol, dave, 100, 60);
mpt.pay(alice, bob, 50);
env.fund(XRP(10000), erin);
env.close();
mpt.authorize({.account = frank});
mpt.pay(alice, frank, 40);
mpt.generateKeyPair(frank);
env(delegate::set(bob, dave, {"ConfidentialMPTConvertBack"}));
env(delegate::set(carol, erin, {"ConfidentialMPTSend"}));
env(delegate::set(bob, erin, {"ConfidentialMPTMergeInbox"}));
env.close();
auto const daveSeq = env.seq(dave);
auto const bobSeq = env.seq(bob);
auto const carolSeq = env.seq(carol);
auto const frankSeq = env.seq(frank);
auto const batchFee = batch::calcConfidentialBatchFee(env, 4, 6);
// Dave submits the batch. Bob's convertback uses Dave as Delegate;
// Convert is not delegable, so Bob signs his own convert inner tx.
// Carol's send and Bob's mergeInbox use Erin as Delegate. Frank's
// convert and mergeInbox are non-delegated.
auto jv1 = mpt.convertBackJV({.account = bob, .amt = 30}, bobSeq);
jv1[jss::Delegate] = dave.human();
auto const jv2 = mpt.convertJV({.account = bob, .amt = 20}, bobSeq + 1);
auto jv3 = mpt.sendJV({.account = carol, .dest = bob, .amt = 15}, carolSeq);
jv3[jss::Delegate] = erin.human();
auto const jv4 = mpt.convertJV(
{.account = frank, .amt = 25, .holderPubKey = mpt.getPubKey(frank)}, frankSeq);
auto const jv5 = mpt.mergeInboxJV({.account = frank});
auto jv6 = mpt.mergeInboxJV({.account = bob});
jv6[jss::Delegate] = erin.human();
env(batch::outer(dave, daveSeq, batchFee, tfAllOrNothing),
batch::Inner(jv1, bobSeq),
batch::Inner(jv2, bobSeq + 1),
batch::Inner(jv3, carolSeq),
batch::Inner(jv4, frankSeq),
batch::Inner(jv5, frankSeq + 1),
batch::Inner(jv6, bobSeq + 2),
batch::Sig(erin, frank, bob),
Ter(tesSUCCESS));
env.close();
env.require(MptBalance(mpt, bob, 60));
BEAST_EXPECT(mpt.getDecryptedBalance(bob, MPTTester::holderEncryptedSpending) == 105);
BEAST_EXPECT(mpt.getDecryptedBalance(bob, MPTTester::holderEncryptedInbox) == 0);
env.require(MptBalance(mpt, carol, 0));
BEAST_EXPECT(mpt.getDecryptedBalance(carol, MPTTester::holderEncryptedSpending) == 45);
BEAST_EXPECT(mpt.getDecryptedBalance(carol, MPTTester::holderEncryptedInbox) == 0);
env.require(MptBalance(mpt, frank, 15));
BEAST_EXPECT(mpt.getDecryptedBalance(frank, MPTTester::holderEncryptedSpending) == 25);
BEAST_EXPECT(mpt.getDecryptedBalance(frank, MPTTester::holderEncryptedInbox) == 0);
env.require(MptBalance(mpt, dave, 0));
BEAST_EXPECT(mpt.getDecryptedBalance(dave, MPTTester::holderEncryptedSpending) == 0);
BEAST_EXPECT(mpt.getDecryptedBalance(dave, MPTTester::holderEncryptedInbox) == 0);
auto const outstandingBalance = mpt.getIssuanceOutstandingBalance();
BEAST_EXPECT(outstandingBalance && *outstandingBalance == 250);
BEAST_EXPECT(mpt.getIssuanceConfidentialBalance() == 175);
}
// Test invalid scenarios for delegation with tickets. ConfidentialMPTConvert
// is not delegable, so ConfidentialMPTConvertBack (which is delegable and
// whose ZK proof also binds to the transaction/ticket sequence) is used as
// the delegated operation. Carol acts as bob's delegate throughout.
void
testInvalidDelegationWithTickets(FeatureBitset features)
{
testcase("Invalid cases for delegation with tickets");
using namespace test::jtx;
Env env{*this, features};
Account const alice("alice");
Account const bob("bob");
Account const carol("carol");
MPTTester mptAlice(env, alice, {.holders = {bob}});
env.fund(XRP(10000), carol);
env.close();
mptAlice.create({
.ownerCount = 1,
.flags = tfMPTCanTransfer | tfMPTCanHoldConfidentialBalance | tfMPTCanClawback,
});
mptAlice.authorize({.account = bob});
mptAlice.pay(alice, bob, 200);
mptAlice.generateKeyPair(alice);
mptAlice.generateKeyPair(bob);
mptAlice.set({.issuerPubKey = mptAlice.getPubKey(alice)});
// Give bob a confidential spending balance to convert back from.
mptAlice.convert({.account = bob, .amt = 100, .holderPubKey = mptAlice.getPubKey(bob)});
mptAlice.mergeInbox({.account = bob});
// Bob delegates ConfidentialMPTConvertBack to carol.
env(delegate::set(bob, carol, {"ConfidentialMPTConvertBack"}));
env.close();
uint64_t const amt = 10;
// Every case below fails, so bob's spending balance and version never
// change; capture the crypto material needed to build proofs once.
auto const spendingBalance = requireOptional(
mptAlice.getDecryptedBalance(bob, MPTTester::holderEncryptedSpending),
"Missing spending balance.");
auto const encSpending = requireOptional(
mptAlice.getEncryptedBalance(bob, MPTTester::holderEncryptedSpending),
"Missing encrypted spending balance.");
auto const version = mptAlice.getMPTokenVersion(bob);
auto const pcBf = generateBlindingFactor();
auto const pc = mptAlice.getPedersenCommitment(spendingBalance, pcBf);
// Build a ConvertBack proof bound to a given sequence.
auto proofForSeq = [&](std::uint32_t seq) {
return mptAlice.getConvertBackProof(
bob,
amt,
getConvertBackContextHash(bob, mptAlice.issuanceID(), seq, version),
{
.pedersenCommitment = pc,
.amt = spendingBalance,
.encryptedAmt = encSpending,
.blindingFactor = pcBf,
});
};
// Invalid: proof built with wrong ticket sequence (ticketSeq + 1).
{
auto const ticketSeq = env.seq(bob) + 1;
env(ticket::create(bob, 1));
mptAlice.convertBack({
.account = bob,
.amt = amt,
.proof = proofForSeq(ticketSeq + 1),
.pedersenCommitment = pc,
.delegate = carol,
.ticketSeq = ticketSeq,
.err = tecBAD_PROOF,
});
}
// Invalid: proof built with account sequence instead of ticket sequence.
{
auto const ticketSeq = env.seq(bob) + 1;
env(ticket::create(bob, 1));
mptAlice.convertBack({
.account = bob,
.amt = amt,
.proof = proofForSeq(env.seq(bob)),
.pedersenCommitment = pc,
.delegate = carol,
.ticketSeq = ticketSeq,
.err = tecBAD_PROOF,
});
}
// Invalid: ticket sequence is far in the future and hasn't been created yet.
{
mptAlice.convertBack({
.account = bob,
.amt = amt,
.delegate = carol,
.ticketSeq = env.seq(bob) + 100,
.err = terPRE_TICKET,
});
}
// Invalid: ticket sequence is in the past but was never created.
{
mptAlice.convertBack({
.account = bob,
.amt = amt,
.delegate = carol,
.ticketSeq = 1,
.err = tefNO_TICKET,
});
}
// Invalid: the delegated account, carol, creates a ticket and uses it.
// The ticket must belong to the delegator (bob), not the delegate.
{
auto const carolTicketSeq = env.seq(carol) + 1;
env(ticket::create(carol, 1));
mptAlice.convertBack({
.account = bob,
.amt = amt,
.delegate = carol,
.ticketSeq = carolTicketSeq,
.err = tefNO_TICKET,
});
}
// Invalid: proof bound to a ticket sequence but submitted without a ticket,
// using account sequence.
{
auto const ticketSeq = env.seq(bob) + 1;
env(ticket::create(bob, 1));
// Submit without a ticket; proof is bound to ticketSeq.
mptAlice.convertBack({
.account = bob,
.amt = amt,
.proof = proofForSeq(ticketSeq),
.pedersenCommitment = pc,
.delegate = carol,
.err = tecBAD_PROOF,
});
}
// Valid: carol converts back on bob's behalf using a ticket owned by bob,
// with a proof correctly bound to that ticket sequence. bob's spending
// balance drops from 100 to 90.
{
auto const ticketSeq = env.seq(bob) + 1;
env(ticket::create(bob, 1));
mptAlice.convertBack({
.account = bob,
.amt = amt,
.delegate = carol,
.ticketSeq = ticketSeq,
});
}
}
// Verifies that delegation works correctly when the delegating account uses
// tickets instead of regular sequence numbers. The proof must bind to the
// ticket sequence, not the account sequence.
void
testDelegationWithTickets(FeatureBitset features)
{
testcase("Confidential delegation with tickets");
using namespace test::jtx;
Env env{*this, features};
Account const alice("alice");
Account const bob("bob");
Account const carol("carol");
Account const dave("dave");
MPTTester mptAlice(env, alice, {.holders = {bob, carol}});
env.fund(XRP(10000), dave);
env.close();
mptAlice.create({
.ownerCount = 1,
.flags = tfMPTCanTransfer | tfMPTCanHoldConfidentialBalance | tfMPTCanClawback,
});
mptAlice.authorize({.account = bob});
mptAlice.authorize({.account = carol});
mptAlice.pay(alice, bob, 200);
mptAlice.pay(alice, carol, 100);
mptAlice.generateKeyPair(alice);
mptAlice.generateKeyPair(bob);
mptAlice.generateKeyPair(carol);
mptAlice.set({.issuerPubKey = mptAlice.getPubKey(alice)});
// Bob grants dave permissions (Convert is not delegable).
env(delegate::set(
bob,
dave,
{"ConfidentialMPTMergeInbox", "ConfidentialMPTSend", "ConfidentialMPTConvertBack"}));
// Alice grants dave permission to clawback on her behalf.
env(delegate::set(alice, dave, {"ConfidentialMPTClawback"}));
env.close();
// Bob converts using a ticket.
auto ticketSeq = env.seq(bob) + 1;
env(ticket::create(bob, 1));
BEAST_EXPECT(env.seq(bob) != ticketSeq);
mptAlice.convert({
.account = bob,
.amt = 100,
.holderPubKey = mptAlice.getPubKey(bob),
.ticketSeq = ticketSeq,
});
env.require(MptBalance(mptAlice, bob, 100));
// MergeInbox using ticket with delegation.
ticketSeq = env.seq(bob) + 1;
env(ticket::create(bob, 1));
BEAST_EXPECT(env.seq(bob) != ticketSeq);
mptAlice.mergeInbox({.account = bob, .delegate = dave, .ticketSeq = ticketSeq});
// Carol converts and merges inbox to receive from bob.
mptAlice.convert({
.account = carol,
.amt = 50,
.holderPubKey = mptAlice.getPubKey(carol),
});
mptAlice.mergeInbox({.account = carol});
// Send using ticket with delegation.
ticketSeq = env.seq(bob) + 1;
env(ticket::create(bob, 1));
BEAST_EXPECT(env.seq(bob) != ticketSeq);
mptAlice.send({
.account = bob,
.dest = carol,
.amt = 20,
.delegate = dave,
.ticketSeq = ticketSeq,
});
// ConvertBack using ticket with delegation.
ticketSeq = env.seq(bob) + 1;
env(ticket::create(bob, 1));
BEAST_EXPECT(env.seq(bob) != ticketSeq);
mptAlice.convertBack({
.account = bob,
.amt = 10,
.delegate = dave,
.ticketSeq = ticketSeq,
});
// Clawback using ticket with delegation.
ticketSeq = env.seq(alice) + 1;
env(ticket::create(alice, 1));
BEAST_EXPECT(env.seq(alice) != ticketSeq);
mptAlice.confidentialClaw({
.holder = bob,
.amt = 70,
.delegate = dave,
.ticketSeq = ticketSeq,
});
}
void
testWithFeats(FeatureBitset features)
{
// DepositAuth, credentials, and destination tag interactions.
testSendDepositPreauth(features);
testSendCredentialValidation(features);
testDestinationTag(features);
// AMM/pseudo-account interaction.
testAMMHolderCannotHaveConfidentialStateClawback(features);
// Ticket interactions.
testWithTickets(features);
testConvertTicketProofBinding(features);
testTicketErrors(features);
// Batch interactions.
testBatchConfidentialSend(features);
testBatchConfidentialConvertAndConvertBack(features);
testBatchConfidentialMixTransactions(features);
testBatchAllOrNothing(features);
testBatchOnlyOne(features);
testBatchUntilFailure(features);
testBatchIndependent(features);
testBatchWithTickets(features);
// Permission delegation interactions.
testConfidentialDelegation(features);
testDelegationRevocation(features);
testDelegationWithAuditor(features);
testDelegationClawbackIssuerOnly(features);
testBatchDelegatedSend(features);
testBatchDelegationMissingPermission(features);
testBatchDelegatedSendWithDelegateAsOuterAccount(features);
testBatchDelegatedConfidentialMix(features);
testInvalidDelegationWithTickets(features);
testDelegationWithTickets(features);
}
public:
void
run() override
{
using namespace test::jtx;
FeatureBitset const all{testableAmendments()};
testWithFeats(all);
}
};
BEAST_DEFINE_TESTSUITE(ConfidentialTransferExtended, app, xrpl);
} // namespace xrpl