diff --git a/include/xrpl/protocol/ConfidentialTransfer.h b/include/xrpl/protocol/ConfidentialTransfer.h index 67e8e52733..fd16f08d8c 100644 --- a/include/xrpl/protocol/ConfidentialTransfer.h +++ b/include/xrpl/protocol/ConfidentialTransfer.h @@ -1,6 +1,7 @@ #pragma once #include +#include #include #include #include diff --git a/src/test/app/ConfidentialTransferExtended_test.cpp b/src/test/app/ConfidentialTransferExtended_test.cpp new file mode 100644 index 0000000000..d2a2fa0967 --- /dev/null +++ b/src/test/app/ConfidentialTransferExtended_test.cpp @@ -0,0 +1,2586 @@ +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include + +namespace xrpl { + +// NOLINTBEGIN(misc-const-correctness, bugprone-unchecked-optional-access) +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{}, + .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 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 kCRED_IDX = + "48004829F915654A81B11C4AB8218D96FED67F209B58328A72314FB6EA288B" + "E4"; + + mpt.send({ + .account = carol, + .dest = bob, + .amt = 10, + .credentials = {{kCRED_IDX}}, + .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 = kMPT_DEX_FLAGS | tfMPTCanClawback | tfMPTCanConfidentialAmount, + }); + 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(kEC_CLAWBACK_PROOF_LENGTH)), + .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 | tfMPTCanConfidentialAmount, + }); + 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 | tfMPTCanConfidentialAmount, + }); + 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 = mptAlice.getSchnorrProof(bob, badCtxHash); + BEAST_EXPECT(badProof.has_value()); + + 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 | tfMPTCanConfidentialAmount}; + 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 | tfMPTCanConfidentialAmount, + }); + 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 | tfMPTCanConfidentialAmount, + }); + 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 delegates Convert, MergeInbox to dave. + env(delegate::set(bob, dave, {"ConfidentialMPTConvert", "ConfidentialMPTMergeInbox"})); + env.close(); + + // Carol has no permission from bob to convert on his behalf. + mptAlice.convert({ + .account = bob, + .amt = 10, + .holderPubKey = mptAlice.getPubKey(bob), + .delegate = carol, + .err = terNO_DELEGATE_PERMISSION, + }); + + // Dave executes Convert on behalf of bob, registering bob's key. + mptAlice.convert({ + .account = bob, + .amt = 100, + .holderPubKey = mptAlice.getPubKey(bob), + .delegate = dave, + }); + env.require(MptBalance(mptAlice, bob, 100)); + + // Dave executes Convert again on behalf of bob (no key registration). + mptAlice.convert({.account = bob, .amt = 50, .delegate = dave}); + + // 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, + {"ConfidentialMPTConvert", "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, + {"ConfidentialMPTConvert", + "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 | tfMPTCanConfidentialAmount, + }); + 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, {"ConfidentialMPTConvert", "ConfidentialMPTMergeInbox"})); + env.close(); + env.require(Owners(bob, bobOwnersBefore + 1)); + + // Carol converts and merge inbox on behalf of bob. + mptAlice.convert({ + .account = bob, + .amt = 50, + .holderPubKey = mptAlice.getPubKey(bob), + .delegate = carol, + }); + mptAlice.mergeInbox({.account = bob, .delegate = carol}); + + // Bob revokes all permissions, deletes the Delegate SLE, releasing the reserve. + env(delegate::set(bob, carol, std::vector{})); + env.close(); + env.require(Owners(bob, bobOwnersBefore)); + + // Carol can no longer convert on behalf of bob. + mptAlice.convert({ + .account = bob, + .amt = 30, + .delegate = carol, + .err = terNO_DELEGATE_PERMISSION, + }); + + // Bob can still convert by himself. + mptAlice.convert({.account = bob, .amt = 30}); + } + + // 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 | tfMPTCanConfidentialAmount, + }); + 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 Convert and Send permissions to dave. + env(delegate::set(bob, dave, {"ConfidentialMPTSend", "ConfidentialMPTConvert"})); + env.close(); + + // Dave converts on behalf of bob. + mptAlice.convert( + {.account = bob, .amt = 50, .holderPubKey = mptAlice.getPubKey(bob), .delegate = dave}); + 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 confEnv{ + env, + alice, + {{.account = bob, .payAmount = 100, .convertAmount = 50}, + {.account = carol, .payAmount = 100, .convertAmount = 100}}, + tfMPTCanTransfer | tfMPTCanClawback | tfMPTCanConfidentialAmount}; + 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(kEC_CLAWBACK_PROOF_LENGTH * 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(kEC_CLAWBACK_PROOF_LENGTH * 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, 0, 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), + 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, 0, 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), + 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, 1, 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), + 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, 1, 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), + 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 has delegation permission, but the inner Account is bob. + // Without bob's BatchSigner, the batch 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); + + 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(temBAD_SIGNER)); + env.close(); + + BEAST_EXPECT(mpt.getDecryptedBalance(bob, MPTTester::HolderEncryptedSpending) == 100); + BEAST_EXPECT(mpt.getDecryptedBalance(carol, MPTTester::HolderEncryptedInbox) == 0); + } + + // 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); + } + + // Verify the delegator Bob's BatchSigner does not bypass the missing delegation permission. + // The delegated inner send fails. + { + 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(tesSUCCESS)); + 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, {"ConfidentialMPTConvert", "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, 3, 6); + + // Dave submits the batch. Bob's convert and convertback use Dave as Delegate; + // 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 jv2 = mpt.convertJV({.account = bob, .amt = 20}, bobSeq + 1); + jv2[jss::Delegate] = dave.human(); + 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(bob, carol, frank), + 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. + 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 | tfMPTCanConfidentialAmount | tfMPTCanClawback, + }); + mptAlice.authorize({.account = bob}); + mptAlice.pay(alice, bob, 200); + + mptAlice.generateKeyPair(alice); + mptAlice.generateKeyPair(bob); + mptAlice.set({.issuerPubKey = mptAlice.getPubKey(alice)}); + + // Bob grants carol permissions. + env(delegate::set(bob, carol, {"ConfidentialMPTConvert"})); + env.close(); + + uint64_t const amt = 10; + auto const bf = generateBlindingFactor(); + auto const holderCt = mptAlice.encryptAmount(bob, amt, bf); + auto const issuerCt = mptAlice.encryptAmount(alice, amt, bf); + + // Invalid: proof built with wrong ticket sequence (ticketSeq + 1). + { + auto const ticketSeq = env.seq(bob) + 1; + env(ticket::create(bob, 1)); + + auto const badCtxHash = + getConvertContextHash(bob, mptAlice.issuanceID(), ticketSeq + 1); + auto const badProof = mptAlice.getSchnorrProof(bob, badCtxHash); + BEAST_EXPECT(badProof.has_value()); + + mptAlice.convert( + {.account = bob, + .amt = amt, + .proof = strHex(*badProof), + .holderPubKey = mptAlice.getPubKey(bob), + .holderEncryptedAmt = holderCt, + .issuerEncryptedAmt = issuerCt, + .blindingFactor = bf, + .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)); + auto const badCtxHash = getConvertContextHash(bob, mptAlice.issuanceID(), env.seq(bob)); + auto const badProof = mptAlice.getSchnorrProof(bob, badCtxHash); + BEAST_EXPECT(badProof.has_value()); + + mptAlice.convert( + {.account = bob, + .amt = amt, + .proof = strHex(*badProof), + .holderPubKey = mptAlice.getPubKey(bob), + .holderEncryptedAmt = holderCt, + .issuerEncryptedAmt = issuerCt, + .blindingFactor = bf, + .delegate = carol, + .ticketSeq = ticketSeq, + .err = tecBAD_PROOF}); + } + + // Invalid: ticket sequence is far in the future and hasn't been created yet. + { + mptAlice.convert({ + .account = bob, + .amt = amt, + .holderPubKey = mptAlice.getPubKey(bob), + .holderEncryptedAmt = holderCt, + .issuerEncryptedAmt = issuerCt, + .blindingFactor = bf, + .delegate = carol, + .ticketSeq = env.seq(bob) + 100, + .err = terPRE_TICKET, + }); + } + + // Invalid: ticket sequence is in the past but was never created. + { + mptAlice.convert({ + .account = bob, + .amt = amt, + .holderPubKey = mptAlice.getPubKey(bob), + .holderEncryptedAmt = holderCt, + .issuerEncryptedAmt = issuerCt, + .blindingFactor = bf, + .delegate = carol, + .ticketSeq = 1, + .err = tefNO_TICKET, + }); + } + + // Invalid: the delegated account, carol, creates a ticket and uses it. + { + auto const carolTicketSeq = env.seq(carol) + 1; + env(ticket::create(carol, 1)); + + mptAlice.convert( + {.account = bob, + .amt = amt, + .holderPubKey = mptAlice.getPubKey(bob), + .holderEncryptedAmt = holderCt, + .issuerEncryptedAmt = issuerCt, + .blindingFactor = bf, + .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)); + + // Build proof using ticketSeq. + auto const ctxHashForTicket = + getConvertContextHash(bob, mptAlice.issuanceID(), ticketSeq); + auto const proof = mptAlice.getSchnorrProof(bob, ctxHashForTicket); + BEAST_EXPECT(proof.has_value()); + + // Submit without ticket. + mptAlice.convert( + {.account = bob, + .amt = amt, + .proof = strHex(*proof), + .holderPubKey = mptAlice.getPubKey(bob), + .holderEncryptedAmt = holderCt, + .issuerEncryptedAmt = issuerCt, + .blindingFactor = bf, + .delegate = carol, + .err = tecBAD_PROOF}); + } + } + + // 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 | tfMPTCanConfidentialAmount | 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. + env(delegate::set( + bob, + dave, + {"ConfidentialMPTConvert", + "ConfidentialMPTMergeInbox", + "ConfidentialMPTSend", + "ConfidentialMPTConvertBack"})); + // Alice grants dave permission to clawback on her behalf. + env(delegate::set(alice, dave, {"ConfidentialMPTClawback"})); + env.close(); + + // Dave executes Convert on behalf of bob using 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), + .delegate = dave, + .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); + } +}; +// NOLINTEND(misc-const-correctness, bugprone-unchecked-optional-access) + +BEAST_DEFINE_TESTSUITE(ConfidentialTransferExtended, app, xrpl); + +} // namespace xrpl diff --git a/src/test/app/ConfidentialTransfer_test.cpp b/src/test/app/ConfidentialTransfer_test.cpp index 74f62d7277..f28570c3e3 100644 --- a/src/test/app/ConfidentialTransfer_test.cpp +++ b/src/test/app/ConfidentialTransfer_test.cpp @@ -1,23 +1,16 @@ -#include #include +#include #include -#include #include -#include -#include -#include -#include #include #include #include #include -#include #include #include #include #include -#include #include #include #include @@ -36,14 +29,8 @@ #include #include -#include - -#include -#include - #include #include -#include #include #include #include @@ -52,7 +39,6 @@ #include #include #include -#include #include #include #include @@ -60,298 +46,8 @@ namespace xrpl { // NOLINTBEGIN(misc-const-correctness, bugprone-unchecked-optional-access) -class ConfidentialTransfer_test : public beast::unit_test::Suite +class ConfidentialTransfer_test : public ConfidentialTransferTestBase { - // Offset where the bulletproof begins in a send proof blob. - // Proof layout: [compact_sigma | bulletproof] - static constexpr size_t kBULLETPROOF_OFFSET = - kEC_SEND_PROOF_LENGTH - kEC_DOUBLE_BULLETPROOF_LENGTH; - - // Generate a forged aggregated bulletproof (double bulletproof) for - // the given values and blinding factors. Used to test that splicing - // a bulletproof claiming a different remaining balance is rejected. - // secp256k1 convention: returns 1 on success, 0 on failure. - static Buffer - getForgedBulletproof( - std::array const& values, - std::array const& blindingFactors, - uint256 const& contextHash) - { - auto* const ctx = mpt_secp256k1_context(); - - secp256k1_pubkey h; - secp256k1_mpt_get_h_generator(ctx, &h); - - Buffer proof(kEC_DOUBLE_BULLETPROOF_LENGTH); - size_t proofLen = kEC_DOUBLE_BULLETPROOF_LENGTH; - - unsigned char blindings[64]; - std::memcpy(blindings, blindingFactors[0].data(), 32); - std::memcpy(blindings + 32, blindingFactors[1].data(), 32); - - if (secp256k1_bulletproof_prove_agg( - ctx, - proof.data(), - &proofLen, - values.data(), - blindings, - 2, - &h, - contextHash.data()) == 0) - Throw("Failed to generate forged bulletproof"); - - return proof; - } - - // Get a bad ciphertext with valid structure but cryptographic invalid for - // testing purposes. For preflight test purposes. - static Buffer const& - getBadCiphertext() - { - static Buffer const kBAD_CIPHERTEXT = []() { - Buffer buf(kEC_GAMAL_ENCRYPTED_TOTAL_LENGTH); - std::memset(buf.data(), 0xFF, kEC_GAMAL_ENCRYPTED_TOTAL_LENGTH); - - buf.data()[0] = kEC_COMPRESSED_PREFIX_EVEN_Y; - buf.data()[kEC_GAMAL_ENCRYPTED_LENGTH] = kEC_COMPRESSED_PREFIX_EVEN_Y; - return buf; - }(); - - return kBAD_CIPHERTEXT; - } - - // Get a trivial buffer that is structurally and mathematically valid, but - // contains invalid data that does not match the ledger state. For preclaim - // test purposes. - static Buffer const& - getTrivialCiphertext() - { - static Buffer const kTRIVIAL_CIPHERTEXT = []() { - Buffer buf(kEC_GAMAL_ENCRYPTED_TOTAL_LENGTH); - std::memset(buf.data(), 0, kEC_GAMAL_ENCRYPTED_TOTAL_LENGTH); - - buf.data()[0] = kEC_COMPRESSED_PREFIX_EVEN_Y; - buf.data()[kEC_GAMAL_ENCRYPTED_LENGTH] = kEC_COMPRESSED_PREFIX_EVEN_Y; - - buf.data()[kEC_GAMAL_ENCRYPTED_LENGTH - 1] = 0x01; - buf.data()[kEC_GAMAL_ENCRYPTED_TOTAL_LENGTH - 1] = 0x01; - - return buf; - }(); - - return kTRIVIAL_CIPHERTEXT; - } - - // Returns a valid compressed EC point (33 bytes) that can pass preflight - // validation but contains invalid data for preclaim test purposes. - static Buffer const& - getTrivialCommitment() - { - static Buffer const kTRIVIAL_COMMITMENT = []() { - Buffer buf(kEC_PEDERSEN_COMMITMENT_LENGTH); - std::memset(buf.data(), 0, kEC_PEDERSEN_COMMITMENT_LENGTH); - - buf.data()[0] = kEC_COMPRESSED_PREFIX_EVEN_Y; - // Set last byte to make it a valid x-coordinate on the curve - buf.data()[kEC_PEDERSEN_COMMITMENT_LENGTH - 1] = 0x01; - - return buf; - }(); - - return kTRIVIAL_COMMITMENT; - } - - static std::string - getTrivialSendProofHex() - { - Buffer buf(kEC_SEND_PROOF_LENGTH); - std::memset(buf.data(), 0, kEC_SEND_PROOF_LENGTH); - - for (std::size_t i = 0; i < kEC_SEND_PROOF_LENGTH; i += kEC_GAMAL_ENCRYPTED_LENGTH) - { - buf.data()[i] = kEC_COMPRESSED_PREFIX_EVEN_Y; - if (i + kEC_GAMAL_ENCRYPTED_LENGTH - 1 < kEC_SEND_PROOF_LENGTH) - buf.data()[i + kEC_GAMAL_ENCRYPTED_LENGTH - 1] = 0x01; - } - - return strHex(buf); - } - - // Helper struct to encapsulate common setup for integration tests. - struct ConfidentialSendSetup - { - // Constants - uint64_t sendAmount; - size_t nRecipients; - uint32_t version; - - // Blinding factors - Buffer blindingFactor; - Buffer amountBlindingFactor; - Buffer balanceBlindingFactor; - - // Encrypted amounts - Buffer senderAmt; - Buffer destAmt; - Buffer issuerAmt; - std::optional auditorAmt; - - // Commitments - Buffer amountCommitment; - - // Long-lived pub key buffers (to avoid dangling Slice) - Buffer senderPubKey; - Buffer destPubKey; - Buffer issuerPubKey; - std::optional auditorPubKey; - - // Balance data - uint64_t prevSpending; - Buffer prevEncryptedSpending; - - // Balance commitment (declared after prevSpending for init order) - Buffer balanceCommitment; - - // Recipients vector - std::vector recipients; - - // Constructor that performs all common setup - ConfidentialSendSetup( - test::jtx::MPTTester& mpt, - test::jtx::Account const& sender, - test::jtx::Account const& dest, - test::jtx::Account const& issuer, - uint64_t amount, - std::optional> auditor = std::nullopt) - : sendAmount(amount) - , nRecipients(auditor ? 4 : 3) - , version(mpt.getMPTokenVersion(sender)) - , blindingFactor(generateBlindingFactor()) - , amountBlindingFactor(blindingFactor) - , balanceBlindingFactor(generateBlindingFactor()) - , senderAmt(mpt.encryptAmount(sender, amount, blindingFactor)) - , destAmt(mpt.encryptAmount(dest, amount, blindingFactor)) - , issuerAmt(mpt.encryptAmount(issuer, amount, blindingFactor)) - , auditorAmt( - auditor ? std::optional( - mpt.encryptAmount(auditor->get(), amount, blindingFactor)) - : std::nullopt) - , amountCommitment(mpt.getPedersenCommitment(amount, amountBlindingFactor)) - , senderPubKey(*mpt.getPubKey(sender)) - , destPubKey(*mpt.getPubKey(dest)) - , issuerPubKey(*mpt.getPubKey(issuer)) - , auditorPubKey(auditor ? mpt.getPubKey(auditor->get()) : std::nullopt) - , prevSpending( - *mpt.getDecryptedBalance(sender, test::jtx::MPTTester::HolderEncryptedSpending)) - , prevEncryptedSpending( - *mpt.getEncryptedBalance(sender, test::jtx::MPTTester::HolderEncryptedSpending)) - , balanceCommitment(mpt.getPedersenCommitment(prevSpending, balanceBlindingFactor)) - { - recipients.push_back({.publicKey = Slice(senderPubKey), .encryptedAmount = senderAmt}); - recipients.push_back({.publicKey = Slice(destPubKey), .encryptedAmount = destAmt}); - recipients.push_back({.publicKey = Slice(issuerPubKey), .encryptedAmount = issuerAmt}); - if (auditor) - { - recipients.push_back( - {.publicKey = Slice(*auditorPubKey), .encryptedAmount = *auditorAmt}); - } - } - - // Generate proof with current account sequence - std::optional - generateProof( - test::jtx::MPTTester& mpt, - test::jtx::Env& env, - test::jtx::Account const& sender, - test::jtx::Account const& dest) const - { - auto const ctxHash = getSendContextHash( - sender.id(), mpt.issuanceID(), env.seq(sender), dest.id(), version); - - return mpt.getConfidentialSendProof( - sender, - sendAmount, - recipients, - blindingFactor, - ctxHash, - {.pedersenCommitment = amountCommitment, - .amt = sendAmount, - .encryptedAmt = senderAmt, - .blindingFactor = amountBlindingFactor}, - {.pedersenCommitment = balanceCommitment, - .amt = prevSpending, - .encryptedAmt = prevEncryptedSpending, - .blindingFactor = balanceBlindingFactor}); - } - }; - - // Helper that wraps the boilerplate setup: Env + MPT creation, funding, key - // generation, and seeding each holder with a confidential balance. - // The caller supplies the issuer and any number of holders. - struct ConfidentialEnv - { - // Per-holder configuration: the account, how much MPT to fund it - // with, and how much of that to convert to a confidential balance. - struct HolderInit - { - test::jtx::Account account; - std::uint64_t payAmount = 1000; - std::uint64_t convertAmount = 100; - }; - - test::jtx::MPTTester mpt; - - ConfidentialEnv( - test::jtx::Env& env, - test::jtx::Account const& issuer, - std::vector const& holders, - std::uint32_t flags = tfMPTCanLock | tfMPTCanConfidentialAmount | tfMPTCanTransfer, - std::optional auditor = std::nullopt) - : mpt{env, issuer, {.holders = extractAccounts(holders), .auditor = auditor}} - { - mpt.create({.ownerCount = 1, .flags = flags}); - - for (auto const& h : holders) - { - mpt.authorize({.account = h.account}); - if ((flags & tfMPTRequireAuth) != 0) - mpt.authorize({.account = issuer, .holder = h.account}); - mpt.pay(issuer, h.account, h.payAmount); - } - - mpt.generateKeyPair(issuer); - for (auto const& h : holders) - mpt.generateKeyPair(h.account); - if (auditor) - mpt.generateKeyPair(*auditor); - - mpt.set( - {.account = issuer, - .issuerPubKey = mpt.getPubKey(issuer), - .auditorPubKey = auditor ? mpt.getPubKey(*auditor) : std::optional{}}); - - for (auto const& h : holders) - { - mpt.convert( - {.account = h.account, - .amt = h.convertAmount, - .holderPubKey = mpt.getPubKey(h.account)}); - mpt.mergeInbox({.account = h.account}); - } - } - - private: - static std::vector - extractAccounts(std::vector const& holders) - { - std::vector accounts; - accounts.reserve(holders.size()); - for (auto const& h : holders) - accounts.push_back(h.account); - return accounts; - } - }; - void testConvert(FeatureBitset features) { @@ -1918,7 +1614,8 @@ class ConfidentialTransfer_test : public beast::unit_test::Suite Env env{*this, features}; Account const alice("alice"); Account const bob("bob"); - ConfidentialEnv const confEnv{env, alice, {{bob, 100, 40}}}; + ConfidentialEnv const confEnv{ + env, alice, {{.account = bob, .payAmount = 100, .convertAmount = 40}}}; } void @@ -2242,7 +1939,8 @@ class ConfidentialTransfer_test : public beast::unit_test::Suite ConfidentialEnv confEnv{ env, alice, - {{bob, 100, 60}, {carol, 50, 20}}, + {{.account = bob, .payAmount = 100, .convertAmount = 60}, + {.account = carol, .payAmount = 50, .convertAmount = 20}}, tfMPTCanTransfer | tfMPTCanLock | tfMPTCanConfidentialAmount, auditor}; auto& mptAlice = confEnv.mpt; @@ -2902,7 +2600,8 @@ class ConfidentialTransfer_test : public beast::unit_test::Suite ConfidentialEnv confEnv{ env, alice, - {{bob, 100, 60}, {carol, 50, 20}}, + {{.account = bob, .payAmount = 100, .convertAmount = 60}, + {.account = carol, .payAmount = 50, .convertAmount = 20}}, tfMPTCanLock | tfMPTCanConfidentialAmount}; auto& mptAlice = confEnv.mpt; @@ -2921,7 +2620,11 @@ class ConfidentialTransfer_test : public beast::unit_test::Suite Account const alice("alice"); Account const bob("bob"); Account const carol("carol"); - ConfidentialEnv confEnv{env, alice, {{bob, 100, 60}, {carol, 50, 20}}}; + ConfidentialEnv confEnv{ + env, + alice, + {{.account = bob, .payAmount = 100, .convertAmount = 60}, + {.account = carol, .payAmount = 50, .convertAmount = 20}}}; auto& mptAlice = confEnv.mpt; mptAlice.send({ @@ -3365,7 +3068,8 @@ class ConfidentialTransfer_test : public beast::unit_test::Suite Env env{*this, features}; Account const alice("alice"); Account const bob("bob"); - ConfidentialEnv confEnv{env, alice, {{bob, 100, 100}}}; + ConfidentialEnv confEnv{ + env, alice, {{.account = bob, .payAmount = 100, .convertAmount = 100}}}; auto& mptAlice = confEnv.mpt; mptAlice.convertBack({ @@ -3473,7 +3177,8 @@ class ConfidentialTransfer_test : public beast::unit_test::Suite Env env{*this, features}; Account const alice("alice"); Account const bob("bob"); - ConfidentialEnv confEnv{env, alice, {{bob, 100, 40}}}; + ConfidentialEnv confEnv{ + env, alice, {{.account = bob, .payAmount = 100, .convertAmount = 40}}}; auto& mptAlice = confEnv.mpt; mptAlice.convertBack({ @@ -3492,7 +3197,8 @@ class ConfidentialTransfer_test : public beast::unit_test::Suite Env env{*this, features}; Account const alice("alice"); Account const bob("bob"); - ConfidentialEnv confEnv{env, alice, {{bob, 2, 2}}}; + ConfidentialEnv confEnv{ + env, alice, {{.account = bob, .payAmount = 2, .convertAmount = 2}}}; auto& mptAlice = confEnv.mpt; mptAlice.convertBack({ @@ -3633,7 +3339,7 @@ class ConfidentialTransfer_test : public beast::unit_test::Suite ConfidentialEnv confEnv{ env, alice, - {{bob, 100, 40}}, + {{.account = bob, .payAmount = 100, .convertAmount = 40}}, tfMPTCanTransfer | tfMPTCanLock | tfMPTCanConfidentialAmount, auditor}; auto& mptAlice = confEnv.mpt; @@ -3680,7 +3386,8 @@ class ConfidentialTransfer_test : public beast::unit_test::Suite Env env{*this, features}; Account const alice("alice"); Account const bob("bob"); - ConfidentialEnv confEnv{env, alice, {{bob, 100, 40}}}; + ConfidentialEnv confEnv{ + env, alice, {{.account = bob, .payAmount = 100, .convertAmount = 40}}}; auto& mptAlice = confEnv.mpt; mptAlice.convertBack({ @@ -4030,7 +3737,8 @@ class ConfidentialTransfer_test : public beast::unit_test::Suite Env env{*this, features}; Account const alice("alice"); Account const bob("bob"); - ConfidentialEnv confEnv{env, alice, {{bob, 100, 50}}}; + 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 @@ -4058,7 +3766,8 @@ class ConfidentialTransfer_test : public beast::unit_test::Suite Env env{*this, features}; Account const alice("alice"); Account const bob("bob"); - ConfidentialEnv confEnv{env, alice, {{bob, 100, 50}}}; + ConfidentialEnv confEnv{ + env, alice, {{.account = bob, .payAmount = 100, .convertAmount = 50}}}; auto& mptAlice = confEnv.mpt; mptAlice.convertBack({ @@ -4079,7 +3788,7 @@ class ConfidentialTransfer_test : public beast::unit_test::Suite ConfidentialEnv confEnv{ env, alice, - {{bob, 100, 50}}, + {{.account = bob, .payAmount = 100, .convertAmount = 50}}, tfMPTCanTransfer | tfMPTCanLock | tfMPTCanConfidentialAmount, auditor}; auto& mptAlice = confEnv.mpt; @@ -4103,496 +3812,6 @@ class ConfidentialTransfer_test : public beast::unit_test::Suite } } - 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, {{bob, 100, 50}, {carol, 100, 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, {{bob, 100, 50}, {carol, 100, 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, {{bob, 100, 50}, {carol, 100, 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, {{bob, 100, 50}, {carol, 100, 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, {{bob, 100, 50}, {carol, 100, 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, {{bob, 100, 50}, {carol, 100, 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, {{bob, 100, 50}, {carol, 100, 50}}}; - auto& mpt = confEnv.mpt; - - mpt.send({ - .account = carol, - .dest = bob, - .amt = 10, - .credentials = std::vector{}, - .err = temMALFORMED, - }); - } - - // TEST 2: Preflight - Credentials Array Too Large - { - Env env(*this, features); - ConfidentialEnv confEnv{env, alice, {{bob, 100, 50}, {carol, 100, 50}}}; - auto& mpt = confEnv.mpt; - - std::vector 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, {{bob, 100, 50}, {carol, 100, 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, {{bob, 100, 50}, {carol, 100, 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, {{bob, 100, 50}, {carol, 100, 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, {{bob, 100, 50}, {carol, 100, 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, {{bob, 100, 50}, {carol, 100, 50}}}; - auto& mpt = confEnv.mpt; - - auto constexpr kCRED_IDX = - "48004829F915654A81B11C4AB8218D96FED67F209B58328A72314FB6EA288B" - "E4"; - - mpt.send({ - .account = carol, - .dest = bob, - .amt = 10, - .credentials = {{kCRED_IDX}}, - .err = temDISABLED, - }); - } - } - void testClawback(FeatureBitset features) { @@ -4832,58 +4051,6 @@ class ConfidentialTransfer_test : public beast::unit_test::Suite // 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 = kMPT_DEX_FLAGS | tfMPTCanClawback | tfMPTCanConfidentialAmount, - }); - 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(kEC_CLAWBACK_PROOF_LENGTH)), - .err = tecNO_PERMISSION, - }); - } - } - void testClawbackPreflight(FeatureBitset features) { @@ -5157,7 +4324,8 @@ class ConfidentialTransfer_test : public beast::unit_test::Suite Env env{*this, features}; Account const alice("alice"); Account const bob("bob"); - ConfidentialEnv confEnv{env, alice, {{bob, 100, 60}}, setupFlags}; + ConfidentialEnv confEnv{ + env, alice, {{.account = bob, .payAmount = 100, .convertAmount = 60}}, setupFlags}; auto& mptAlice = confEnv.mpt; mptAlice.set({ .account = alice, @@ -5178,7 +4346,8 @@ class ConfidentialTransfer_test : public beast::unit_test::Suite Env env{*this, features}; Account const alice("alice"); Account const bob("bob"); - ConfidentialEnv confEnv{env, alice, {{bob, 100, 60}}, setupFlags}; + ConfidentialEnv confEnv{ + env, alice, {{.account = bob, .payAmount = 100, .convertAmount = 60}}, setupFlags}; auto& mptAlice = confEnv.mpt; mptAlice.set({ .account = alice, @@ -5198,7 +4367,8 @@ class ConfidentialTransfer_test : public beast::unit_test::Suite Env env{*this, features}; Account const alice("alice"); Account const bob("bob"); - ConfidentialEnv confEnv{env, alice, {{bob, 100, 60}}, setupFlags}; + ConfidentialEnv confEnv{ + env, alice, {{.account = bob, .payAmount = 100, .convertAmount = 60}}, setupFlags}; auto& mptAlice = confEnv.mpt; // unauthorize bob @@ -5221,7 +4391,8 @@ class ConfidentialTransfer_test : public beast::unit_test::Suite Env env{*this, features}; Account const alice("alice"); Account const bob("bob"); - ConfidentialEnv confEnv{env, alice, {{bob, 100, 60}}, setupFlags}; + ConfidentialEnv confEnv{ + env, alice, {{.account = bob, .payAmount = 100, .convertAmount = 60}}, setupFlags}; auto& mptAlice = confEnv.mpt; mptAlice.confidentialClaw({ @@ -5783,7 +4954,8 @@ class ConfidentialTransfer_test : public beast::unit_test::Suite Env env{*this, features}; Account const alice("alice"); Account const bob("bob"); - ConfidentialEnv confEnv{env, alice, {{bob, 100, 40}}}; + 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 @@ -6001,7 +5173,8 @@ class ConfidentialTransfer_test : public beast::unit_test::Suite Env env{*this, features}; Account const alice("alice"); Account const bob("bob"); - ConfidentialEnv confEnv{env, alice, {{bob, 100, 40}}}; + 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 @@ -7099,7 +6272,8 @@ class ConfidentialTransfer_test : public beast::unit_test::Suite ConfidentialEnv confEnv{ env, alice, - {{bob, 100, 50}, {carol, 50, 50}}, + {{.account = bob, .payAmount = 100, .convertAmount = 50}, + {.account = carol, .payAmount = 50, .convertAmount = 50}}, tfMPTCanLock | tfMPTCanConfidentialAmount | tfMPTCanTransfer, auditor}; auto& mptAlice = confEnv.mpt; @@ -7306,1959 +6480,6 @@ class ConfidentialTransfer_test : public beast::unit_test::Suite } } - // 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 | tfMPTCanConfidentialAmount, - }); - 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 | tfMPTCanConfidentialAmount, - }); - 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 = mptAlice.getSchnorrProof(bob, badCtxHash); - BEAST_EXPECT(badProof.has_value()); - - 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 | tfMPTCanConfidentialAmount}; - 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 | tfMPTCanConfidentialAmount, - }); - 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}); - } - - // Set up an MPT environment suitable for batch testing. - // alice is issuer; bob has 'bobAmt' in confidential spending; carol has - // 'carolAmt' in confidential spending; dave is initialised with pubkey but - // zero spending/inbox. - static void - setupBatchEnv( - test::jtx::MPTTester& mpt, - test::jtx::Account const& alice, - test::jtx::Account const& bob, - test::jtx::Account const& carol, - test::jtx::Account const& dave, - std::uint64_t bobAmt, - std::uint64_t carolAmt) - { - using namespace test::jtx; - mpt.create({ - .ownerCount = 1, - .flags = tfMPTCanTransfer | tfMPTCanLock | tfMPTCanConfidentialAmount, - }); - mpt.authorize({.account = bob}); - mpt.authorize({.account = carol}); - mpt.authorize({.account = dave}); - - if (bobAmt > 0) - mpt.pay(alice, bob, bobAmt); - if (carolAmt > 0) - mpt.pay(alice, carol, carolAmt); - - mpt.generateKeyPair(alice); - mpt.generateKeyPair(bob); - mpt.generateKeyPair(carol); - mpt.generateKeyPair(dave); - - mpt.set({.account = alice, .issuerPubKey = mpt.getPubKey(alice)}); - - if (bobAmt > 0) - { - mpt.convert({.account = bob, .amt = bobAmt, .holderPubKey = mpt.getPubKey(bob)}); - mpt.mergeInbox({.account = bob}); - } - else - { - mpt.convert({.account = bob, .amt = 0, .holderPubKey = mpt.getPubKey(bob)}); - } - - if (carolAmt > 0) - { - mpt.convert({.account = carol, .amt = carolAmt, .holderPubKey = mpt.getPubKey(carol)}); - mpt.mergeInbox({.account = carol}); - } - else - { - mpt.convert({.account = carol, .amt = 0, .holderPubKey = mpt.getPubKey(carol)}); - } - - // dave: register pubkey only (0 spending/inbox) - mpt.convert({.account = dave, .amt = 0, .holderPubKey = mpt.getPubKey(dave)}); - } - - // 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 - 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 - 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 - 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 | tfMPTCanConfidentialAmount, - }); - 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 delegates Convert, MergeInbox to dave. - env(delegate::set(bob, dave, {"ConfidentialMPTConvert", "ConfidentialMPTMergeInbox"})); - env.close(); - - // Carol has no permission from bob to convert on his behalf. - mptAlice.convert({ - .account = bob, - .amt = 10, - .holderPubKey = mptAlice.getPubKey(bob), - .delegate = carol, - .err = terNO_DELEGATE_PERMISSION, - }); - - // Dave executes Convert on behalf of bob, registering bob's key. - mptAlice.convert({ - .account = bob, - .amt = 100, - .holderPubKey = mptAlice.getPubKey(bob), - .delegate = dave, - }); - env.require(MptBalance(mptAlice, bob, 100)); - - // Dave executes Convert again on behalf of bob (no key registration). - mptAlice.convert({.account = bob, .amt = 50, .delegate = dave}); - - // 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, - {"ConfidentialMPTConvert", "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, - {"ConfidentialMPTConvert", - "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 | tfMPTCanConfidentialAmount, - }); - 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, {"ConfidentialMPTConvert", "ConfidentialMPTMergeInbox"})); - env.close(); - env.require(Owners(bob, bobOwnersBefore + 1)); - - // Carol converts and merge inbox on behalf of bob. - mptAlice.convert({ - .account = bob, - .amt = 50, - .holderPubKey = mptAlice.getPubKey(bob), - .delegate = carol, - }); - mptAlice.mergeInbox({.account = bob, .delegate = carol}); - - // Bob revokes all permissions, deletes the Delegate SLE, releasing the reserve. - env(delegate::set(bob, carol, std::vector{})); - env.close(); - env.require(Owners(bob, bobOwnersBefore)); - - // Carol can no longer convert on behalf of bob. - mptAlice.convert({ - .account = bob, - .amt = 30, - .delegate = carol, - .err = terNO_DELEGATE_PERMISSION, - }); - - // Bob can still convert by himself. - mptAlice.convert({.account = bob, .amt = 30}); - } - - // 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 | tfMPTCanConfidentialAmount, - }); - 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 Convert and Send permissions to dave. - env(delegate::set(bob, dave, {"ConfidentialMPTSend", "ConfidentialMPTConvert"})); - env.close(); - - // Dave converts on behalf of bob. - mptAlice.convert( - {.account = bob, .amt = 50, .holderPubKey = mptAlice.getPubKey(bob), .delegate = dave}); - 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 confEnv{ - env, - alice, - {{bob, 100, 50}, {carol, 100, 100}}, - tfMPTCanTransfer | tfMPTCanClawback | tfMPTCanConfidentialAmount}; - 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(kEC_CLAWBACK_PROOF_LENGTH * 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(kEC_CLAWBACK_PROOF_LENGTH * 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, 0, 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), - 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, 0, 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), - 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, 1, 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), - 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, 1, 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), - 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 has delegation permission, but the inner Account is bob. - // Without bob's BatchSigner, the batch 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); - - 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(temBAD_SIGNER)); - env.close(); - - BEAST_EXPECT(mpt.getDecryptedBalance(bob, MPTTester::HolderEncryptedSpending) == 100); - BEAST_EXPECT(mpt.getDecryptedBalance(carol, MPTTester::HolderEncryptedInbox) == 0); - } - - // 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); - } - - // Verify the delegator Bob's BatchSigner does not bypass the missing delegation permission. - // The delegated inner send fails. - { - 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(tesSUCCESS)); - 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, {"ConfidentialMPTConvert", "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, 3, 6); - - // Dave submits the batch. Bob's convert and convertback use Dave as Delegate; - // 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 jv2 = mpt.convertJV({.account = bob, .amt = 20}, bobSeq + 1); - jv2[jss::Delegate] = dave.human(); - 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(bob, carol, frank), - 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. - 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 | tfMPTCanConfidentialAmount | tfMPTCanClawback, - }); - mptAlice.authorize({.account = bob}); - mptAlice.pay(alice, bob, 200); - - mptAlice.generateKeyPair(alice); - mptAlice.generateKeyPair(bob); - mptAlice.set({.issuerPubKey = mptAlice.getPubKey(alice)}); - - // Bob grants carol permissions. - env(delegate::set(bob, carol, {"ConfidentialMPTConvert"})); - env.close(); - - uint64_t const amt = 10; - auto const bf = generateBlindingFactor(); - auto const holderCt = mptAlice.encryptAmount(bob, amt, bf); - auto const issuerCt = mptAlice.encryptAmount(alice, amt, bf); - - // Invalid: proof built with wrong ticket sequence (ticketSeq + 1). - { - auto const ticketSeq = env.seq(bob) + 1; - env(ticket::create(bob, 1)); - - auto const badCtxHash = - getConvertContextHash(bob, mptAlice.issuanceID(), ticketSeq + 1); - auto const badProof = mptAlice.getSchnorrProof(bob, badCtxHash); - BEAST_EXPECT(badProof.has_value()); - - mptAlice.convert( - {.account = bob, - .amt = amt, - .proof = strHex(*badProof), - .holderPubKey = mptAlice.getPubKey(bob), - .holderEncryptedAmt = holderCt, - .issuerEncryptedAmt = issuerCt, - .blindingFactor = bf, - .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)); - auto const badCtxHash = getConvertContextHash(bob, mptAlice.issuanceID(), env.seq(bob)); - auto const badProof = mptAlice.getSchnorrProof(bob, badCtxHash); - BEAST_EXPECT(badProof.has_value()); - - mptAlice.convert( - {.account = bob, - .amt = amt, - .proof = strHex(*badProof), - .holderPubKey = mptAlice.getPubKey(bob), - .holderEncryptedAmt = holderCt, - .issuerEncryptedAmt = issuerCt, - .blindingFactor = bf, - .delegate = carol, - .ticketSeq = ticketSeq, - .err = tecBAD_PROOF}); - } - - // Invalid: ticket sequence is far in the future and hasn't been created yet. - { - mptAlice.convert({ - .account = bob, - .amt = amt, - .holderPubKey = mptAlice.getPubKey(bob), - .holderEncryptedAmt = holderCt, - .issuerEncryptedAmt = issuerCt, - .blindingFactor = bf, - .delegate = carol, - .ticketSeq = env.seq(bob) + 100, - .err = terPRE_TICKET, - }); - } - - // Invalid: ticket sequence is in the past but was never created. - { - mptAlice.convert({ - .account = bob, - .amt = amt, - .holderPubKey = mptAlice.getPubKey(bob), - .holderEncryptedAmt = holderCt, - .issuerEncryptedAmt = issuerCt, - .blindingFactor = bf, - .delegate = carol, - .ticketSeq = 1, - .err = tefNO_TICKET, - }); - } - - // Invalid: the delegated account, carol, creates a ticket and uses it. - { - auto const carolTicketSeq = env.seq(carol) + 1; - env(ticket::create(carol, 1)); - - mptAlice.convert( - {.account = bob, - .amt = amt, - .holderPubKey = mptAlice.getPubKey(bob), - .holderEncryptedAmt = holderCt, - .issuerEncryptedAmt = issuerCt, - .blindingFactor = bf, - .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)); - - // Build proof using ticketSeq. - auto const ctxHashForTicket = - getConvertContextHash(bob, mptAlice.issuanceID(), ticketSeq); - auto const proof = mptAlice.getSchnorrProof(bob, ctxHashForTicket); - BEAST_EXPECT(proof.has_value()); - - // Submit without ticket. - mptAlice.convert( - {.account = bob, - .amt = amt, - .proof = strHex(*proof), - .holderPubKey = mptAlice.getPubKey(bob), - .holderEncryptedAmt = holderCt, - .issuerEncryptedAmt = issuerCt, - .blindingFactor = bf, - .delegate = carol, - .err = tecBAD_PROOF}); - } - } - - // 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 | tfMPTCanConfidentialAmount | 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. - env(delegate::set( - bob, - dave, - {"ConfidentialMPTConvert", - "ConfidentialMPTMergeInbox", - "ConfidentialMPTSend", - "ConfidentialMPTConvertBack"})); - // Alice grants dave permission to clawback on her behalf. - env(delegate::set(alice, dave, {"ConfidentialMPTClawback"})); - env.close(); - - // Dave executes Convert on behalf of bob using 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), - .delegate = dave, - .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 testForgedEqualityProof(FeatureBitset features) { @@ -9288,17 +6509,9 @@ class ConfidentialTransfer_test : public beast::unit_test::Suite Buffer const forgedBlindingFactor = generateBlindingFactor(); auto const forgedDestAmt = mptAlice.encryptAmount(carol, 20, forgedBlindingFactor); - mptAlice.send( - {.account = bob, - .dest = carol, - .amt = setup.sendAmount, - .proof = strHex(*proof), - .senderEncryptedAmt = setup.senderAmt, - .destEncryptedAmt = forgedDestAmt, - .issuerEncryptedAmt = setup.issuerAmt, - .amountCommitment = setup.amountCommitment, - .balanceCommitment = setup.balanceCommitment, - .err = tecBAD_PROOF}); + auto args = setup.sendArgs(bob, carol, *proof, tecBAD_PROOF); + args.destEncryptedAmt = forgedDestAmt; + mptAlice.send(args); } // Forge sender's ciphertext (Enc(5) instead of Enc(10)) @@ -9310,17 +6523,9 @@ class ConfidentialTransfer_test : public beast::unit_test::Suite Buffer const forgedBlindingFactor = generateBlindingFactor(); auto const forgedSenderAmt = mptAlice.encryptAmount(bob, 5, forgedBlindingFactor); - mptAlice.send( - {.account = bob, - .dest = carol, - .amt = setup.sendAmount, - .proof = strHex(*proof), - .senderEncryptedAmt = forgedSenderAmt, - .destEncryptedAmt = setup.destAmt, - .issuerEncryptedAmt = setup.issuerAmt, - .amountCommitment = setup.amountCommitment, - .balanceCommitment = setup.balanceCommitment, - .err = tecBAD_PROOF}); + auto args = setup.sendArgs(bob, carol, *proof, tecBAD_PROOF); + args.senderEncryptedAmt = forgedSenderAmt; + mptAlice.send(args); } // Forge issuer's ciphertext (Enc(100) instead of Enc(10)) @@ -9332,17 +6537,9 @@ class ConfidentialTransfer_test : public beast::unit_test::Suite Buffer const forgedBlindingFactor = generateBlindingFactor(); auto const forgedIssuerAmt = mptAlice.encryptAmount(alice, 100, forgedBlindingFactor); - mptAlice.send( - {.account = bob, - .dest = carol, - .amt = setup.sendAmount, - .proof = strHex(*proof), - .senderEncryptedAmt = setup.senderAmt, - .destEncryptedAmt = setup.destAmt, - .issuerEncryptedAmt = forgedIssuerAmt, - .amountCommitment = setup.amountCommitment, - .balanceCommitment = setup.balanceCommitment, - .err = tecBAD_PROOF}); + auto args = setup.sendArgs(bob, carol, *proof, tecBAD_PROOF); + args.issuerEncryptedAmt = forgedIssuerAmt; + mptAlice.send(args); } } @@ -9460,18 +6657,7 @@ class ConfidentialTransfer_test : public beast::unit_test::Suite forgedBulletproof.data(), kEC_DOUBLE_BULLETPROOF_LENGTH); - // Rejected — commitment mismatch. - mptAlice.send( - {.account = bob, - .dest = carol, - .amt = setup.sendAmount, - .proof = strHex(forgedProof), - .senderEncryptedAmt = setup.senderAmt, - .destEncryptedAmt = setup.destAmt, - .issuerEncryptedAmt = setup.issuerAmt, - .amountCommitment = setup.amountCommitment, - .balanceCommitment = setup.balanceCommitment, - .err = tecBAD_PROOF}); + mptAlice.send(setup.sendArgs(bob, carol, forgedProof, tecBAD_PROOF)); // Supply invariant: Bob's balance unchanged. auto const postSpending = @@ -9485,9 +6671,6 @@ class ConfidentialTransfer_test : public beast::unit_test::Suite { testcase("test Fiat-Shamir Binding"); - // Verify that modifying any transcript-bound input (ciphertexts, - // commitments, context) after proof generation invalidates the proof. - using namespace test::jtx; Env env{*this, features}; Account const alice("alice"), bob("bob"), carol("carol"); @@ -9499,15 +6682,7 @@ class ConfidentialTransfer_test : public beast::unit_test::Suite ConfidentialSendSetup const setup(mptAlice, bob, carol, alice, 10); - // Variant A: Modify transcript input (commitment) after proof generation - // ----------------------------------------------------------------- - // Attack: Generate valid proof, then swap the amount commitment - // with a forged one committing to a different value. - // - // Why it fails: The compact sigma proof binds the commitment to - // the proof. When the verifier recomputes the challenge using the - // forged commitment, it gets a different challenge than what the - // prover used, causing the response scalars to fail verification. + // Variant A: forged amount commitment. { auto const proof = setup.generateProof(mptAlice, env, bob, carol); if (!BEAST_EXPECT(proof.has_value())) @@ -9517,63 +6692,24 @@ class ConfidentialTransfer_test : public beast::unit_test::Suite auto const forgedCommitment = mptAlice.getPedersenCommitment(setup.sendAmount + 5, forgedBlindingFactor); - mptAlice.send( - {.account = bob, - .dest = carol, - .amt = setup.sendAmount, - .proof = strHex(*proof), - .senderEncryptedAmt = setup.senderAmt, - .destEncryptedAmt = setup.destAmt, - .issuerEncryptedAmt = setup.issuerAmt, - .amountCommitment = forgedCommitment, - .balanceCommitment = setup.balanceCommitment, - .err = tecBAD_PROOF}); + auto args = setup.sendArgs(bob, carol, *proof, tecBAD_PROOF); + args.amountCommitment = forgedCommitment; + mptAlice.send(args); } - // Variant B: Proof replay under different TransactionContextID - // ----------------------------------------------------------------- - // Attack: Generate valid proof at sequence N, then attempt to - // replay the same proof after the sequence has changed to N+1. - // - // Why it fails: The context hash (account, sequence, issuance ID, - // destination) is included in the Fiat-Shamir challenge. When the - // verifier computes the challenge with the new sequence, it differs - // from the challenge the prover used, invalidating the proof. - // This prevents replay attacks across different transactions. + // Variant B: proof replay at a different sequence. { - auto const proof = - setup.generateProof(mptAlice, env, bob, carol); // Generated at sequence N + auto const proof = setup.generateProof(mptAlice, env, bob, carol); if (!BEAST_EXPECT(proof.has_value())) return; - // Submit a different transaction to increment Bob's sequence - mptAlice.pay(bob, carol, 1); // Sequence N -> N+1 + mptAlice.pay(bob, carol, 1); env.close(); - // Attempt to replay proof (bound to sequence N) at sequence N+1 - mptAlice.send( - {.account = bob, - .dest = carol, - .amt = setup.sendAmount, - .proof = strHex(*proof), - .senderEncryptedAmt = setup.senderAmt, - .destEncryptedAmt = setup.destAmt, - .issuerEncryptedAmt = setup.issuerAmt, - .amountCommitment = setup.amountCommitment, - .balanceCommitment = setup.balanceCommitment, - .err = tecBAD_PROOF}); + mptAlice.send(setup.sendArgs(bob, carol, *proof, tecBAD_PROOF)); } - // Variant C: Tamper with challenge-dependent response scalars - // ----------------------------------------------------------------- - // Attack: Generate valid proof, then flip bits in the middle of - // the proof data, corrupting the response scalars (s-values). - // - // Why it fails: In a Schnorr-style proof, the response scalar is - // computed as s = r + c * secret, where c is the challenge. Even - // if the challenge matches, corrupted s-values won't satisfy the - // verification equation: s*G == R + c*PubKey. The verifier detects - // this inconsistency and rejects the proof. + // Variant C: tampered response scalars. { auto const proof = setup.generateProof(mptAlice, env, bob, carol); if (!BEAST_EXPECT(proof.has_value())) @@ -9584,17 +6720,7 @@ class ConfidentialTransfer_test : public beast::unit_test::Suite size_t const tamperOffset = tamperedProof.size() / 2; tamperedProof.data()[tamperOffset] ^= 0xFF; - mptAlice.send( - {.account = bob, - .dest = carol, - .amt = setup.sendAmount, - .proof = strHex(tamperedProof), - .senderEncryptedAmt = setup.senderAmt, - .destEncryptedAmt = setup.destAmt, - .issuerEncryptedAmt = setup.issuerAmt, - .amountCommitment = setup.amountCommitment, - .balanceCommitment = setup.balanceCommitment, - .err = tecBAD_PROOF}); + mptAlice.send(setup.sendArgs(bob, carol, tamperedProof, tecBAD_PROOF)); } } @@ -9603,9 +6729,6 @@ class ConfidentialTransfer_test : public beast::unit_test::Suite { testcase("test Proof Component Reuse"); - // Verify that extracting proof components from one transaction and - // reusing them in a different context fails verification. - using namespace test::jtx; Env env{*this, features}; Account const alice("alice"), bob("bob"), carol("carol"), dan("dan"); @@ -9619,97 +6742,39 @@ class ConfidentialTransfer_test : public beast::unit_test::Suite uint64_t const sendAmount = 10; - // Variant A: Replay proof to same destination (sequence changed) - // ----------------------------------------------------------------- - // Attack: Extract proof from a successful Bob->Carol transaction, - // then replay it to the same destination (Carol) in a new transaction. - // - // Why it fails: The context hash includes the account sequence. - // After the first transaction, Bob's sequence increments from N to N+1. - // The proof was bound to sequence N, but verifier computes challenge - // with sequence N+1, causing a mismatch. + // Variant A: replay proof to same destination after sequence changes. { - // Fresh setup for Variant A ConfidentialSendSetup const setup(mptAlice, bob, carol, alice, sendAmount); auto const proof = setup.generateProof(mptAlice, env, bob, carol); if (!BEAST_EXPECT(proof.has_value())) return; - // Submit successful transaction Bob -> Carol (sequence N) - mptAlice.send( - {.account = bob, - .dest = carol, - .amt = setup.sendAmount, - .proof = strHex(*proof), - .senderEncryptedAmt = setup.senderAmt, - .destEncryptedAmt = setup.destAmt, - .issuerEncryptedAmt = setup.issuerAmt, - .amountCommitment = setup.amountCommitment, - .balanceCommitment = setup.balanceCommitment}); + mptAlice.send(setup.sendArgs(bob, carol, *proof)); mptAlice.mergeInbox({.account = carol}); - // Attempt to replay exact same proof to same destination (sequence N+1) - // Uses identical ciphertexts and commitments - only sequence differs - mptAlice.send( - {.account = bob, - .dest = carol, - .amt = setup.sendAmount, - .proof = strHex(*proof), - .senderEncryptedAmt = setup.senderAmt, - .destEncryptedAmt = setup.destAmt, - .issuerEncryptedAmt = setup.issuerAmt, - .amountCommitment = setup.amountCommitment, - .balanceCommitment = setup.balanceCommitment, - .err = tecBAD_PROOF}); + mptAlice.send(setup.sendArgs(bob, carol, *proof, tecBAD_PROOF)); } - // Variant B: Replay proof to different destination - // ----------------------------------------------------------------- - // Attack: Extract proof from a successful Bob->Carol transaction, - // then attempt to reuse it for Bob->Dan (different destination). - // - // Why it fails: The context hash includes the destination account. - // When verifier recomputes challenge with Dan's address instead of - // Carol's, it gets a different challenge than what the prover used. + // Variant B: replay proof to a different destination. { - // Fresh setup for Variant B (balance changed after Variant A) ConfidentialSendSetup const setup(mptAlice, bob, carol, alice, sendAmount); auto const proof = setup.generateProof(mptAlice, env, bob, carol); if (!BEAST_EXPECT(proof.has_value())) return; - // Submit successful transaction Bob -> Carol - mptAlice.send( - {.account = bob, - .dest = carol, - .amt = setup.sendAmount, - .proof = strHex(*proof), - .senderEncryptedAmt = setup.senderAmt, - .destEncryptedAmt = setup.destAmt, - .issuerEncryptedAmt = setup.issuerAmt, - .amountCommitment = setup.amountCommitment, - .balanceCommitment = setup.balanceCommitment}); + mptAlice.send(setup.sendArgs(bob, carol, *proof)); mptAlice.mergeInbox({.account = carol}); - // Generate fresh ciphertexts for Dan (different destination) auto const destAmtDan = mptAlice.encryptAmount(dan, sendAmount, setup.blindingFactor); auto const issuerAmtDan = mptAlice.encryptAmount(alice, sendAmount, setup.blindingFactor); - // Attempt to reuse proof extracted from Bob->Carol for Bob->Dan - mptAlice.send( - {.account = bob, - .dest = dan, - .amt = setup.sendAmount, - .proof = strHex(*proof), - .senderEncryptedAmt = setup.senderAmt, - .destEncryptedAmt = destAmtDan, - .issuerEncryptedAmt = issuerAmtDan, - .amountCommitment = setup.amountCommitment, - .balanceCommitment = setup.balanceCommitment, - .err = tecBAD_PROOF}); + auto args = setup.sendArgs(bob, dan, *proof, tecBAD_PROOF); + args.destEncryptedAmt = destAmtDan; + args.issuerEncryptedAmt = issuerAmtDan; + mptAlice.send(args); } } @@ -9718,10 +6783,6 @@ class ConfidentialTransfer_test : public beast::unit_test::Suite { testcase("test Special Witness Values"); - // This test verifies that the ZKP verification correctly rejects - // proofs containing degenerate or edge-case values that an attacker - // might use to forge proofs. - using namespace test::jtx; Env env{*this, features}; Account const alice("alice"), bob("bob"), carol("carol"); @@ -9733,106 +6794,52 @@ class ConfidentialTransfer_test : public beast::unit_test::Suite ConfidentialSendSetup const setup(mptAlice, bob, carol, alice, 10); - // Variant A: Zero-valued response scalars - // ----------------------------------------------------------------- - // Attack: Construct a proof where response scalars are set to zero. - // This attempts to trivially satisfy verification equations. - // - // Why it fails: The Fiat-Shamir challenge is derived from the - // transcript including commitments. Zero responses cannot satisfy - // c * x + r = 0 for non-zero challenge c and secret x. + // Variant A: zero-valued response scalars. { auto const proof = setup.generateProof(mptAlice, env, bob, carol); if (!BEAST_EXPECT(proof.has_value())) return; - // Create a forged proof with scalar responses set to zero Buffer forgedProof = *proof; - // Compact sigma proof: 6 consecutive 32-byte scalars - // (e, z_m, z_r, z_b, z_rho, z_sk) = 192 bytes, fixed size. - // Zero out all response scalars (z_m through z_sk, bytes 32..191). static constexpr size_t kSIGMA_SCALAR_SIZE = 32; static constexpr size_t kCHALLENGE_OFFSET = 0; static constexpr size_t kRESPONSE_OFFSET = kCHALLENGE_OFFSET + kSIGMA_SCALAR_SIZE; static constexpr size_t kRESPONSE_SIZE = 5 * kSIGMA_SCALAR_SIZE; // z_m..z_sk std::memset(forgedProof.data() + kRESPONSE_OFFSET, 0, kRESPONSE_SIZE); - mptAlice.send( - {.account = bob, - .dest = carol, - .amt = setup.sendAmount, - .proof = strHex(forgedProof), - .senderEncryptedAmt = setup.senderAmt, - .destEncryptedAmt = setup.destAmt, - .issuerEncryptedAmt = setup.issuerAmt, - .amountCommitment = setup.amountCommitment, - .balanceCommitment = setup.balanceCommitment, - .err = tecBAD_PROOF}); + mptAlice.send(setup.sendArgs(bob, carol, forgedProof, tecBAD_PROOF)); } - // Variant B: Identity element (point at infinity) in ciphertext - // ----------------------------------------------------------------- - // Attack: Replace a ciphertext component with the identity element. - // The identity element has no valid compressed representation on - // secp256k1 (it cannot be encoded as 02/03 + x-coordinate). - // - // Why it fails: secp256k1_ec_pubkey_parse rejects invalid encodings. - // The all-zeros encoding is not a valid compressed point. + // Variant B: identity element in ciphertext. { auto const proof = setup.generateProof(mptAlice, env, bob, carol); if (!BEAST_EXPECT(proof.has_value())) return; - // Create invalid ciphertext with identity-like encoding - // All zeros is not valid on secp256k1 curve Buffer invalidCiphertext(kEC_GAMAL_ENCRYPTED_TOTAL_LENGTH); std::memset(invalidCiphertext.data(), 0, kEC_GAMAL_ENCRYPTED_TOTAL_LENGTH); - mptAlice.send( - {.account = bob, - .dest = carol, - .amt = setup.sendAmount, - .proof = strHex(*proof), - .senderEncryptedAmt = invalidCiphertext, - .destEncryptedAmt = setup.destAmt, - .issuerEncryptedAmt = setup.issuerAmt, - .amountCommitment = setup.amountCommitment, - .balanceCommitment = setup.balanceCommitment, - .err = temBAD_CIPHERTEXT}); + auto args = setup.sendArgs(bob, carol, *proof, temBAD_CIPHERTEXT); + args.senderEncryptedAmt = invalidCiphertext; + mptAlice.send(args); } - // Variant B2: Identity element in commitment - // ----------------------------------------------------------------- + // Variant B2: identity element in commitment. { auto const proof = setup.generateProof(mptAlice, env, bob, carol); if (!BEAST_EXPECT(proof.has_value())) return; - // Create invalid commitment with identity-like encoding Buffer invalidCommitment(kEC_PEDERSEN_COMMITMENT_LENGTH); std::memset(invalidCommitment.data(), 0, kEC_PEDERSEN_COMMITMENT_LENGTH); - mptAlice.send( - {.account = bob, - .dest = carol, - .amt = setup.sendAmount, - .proof = strHex(*proof), - .senderEncryptedAmt = setup.senderAmt, - .destEncryptedAmt = setup.destAmt, - .issuerEncryptedAmt = setup.issuerAmt, - .amountCommitment = invalidCommitment, - .balanceCommitment = setup.balanceCommitment, - .err = temMALFORMED}); + auto args = setup.sendArgs(bob, carol, *proof, temMALFORMED); + args.amountCommitment = invalidCommitment; + mptAlice.send(args); } - // Variant C: Boundary scalar (curve order) - // ----------------------------------------------------------------- - // Attack: Use scalar values equal to the curve order n. - // On secp256k1, scalars are reduced mod n, so n ≡ 0 (mod n). - // - // Why it fails: Setting scalar = curve_order effectively sets it to 0, - // which fails the verification equations as in Variant A. + // Variant C: boundary scalar (curve order). { auto const proof = setup.generateProof(mptAlice, env, bob, carol); if (!BEAST_EXPECT(proof.has_value())) @@ -9840,7 +6847,6 @@ class ConfidentialTransfer_test : public beast::unit_test::Suite Buffer forgedProof = *proof; - // secp256k1 curve order n (big-endian) static constexpr unsigned char kCURVE_ORDER[32] = { 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, // 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFE, // @@ -9848,30 +6854,12 @@ class ConfidentialTransfer_test : public beast::unit_test::Suite 0xBF, 0xD2, 0x5E, 0x8C, 0xD0, 0x36, 0x41, 0x41 // }; - // Compact sigma proof: 6 consecutive 32-byte scalars. - // Overwrite the first response scalar (z_m at byte 32) with - // the curve order, which reduces to 0 mod n. std::memcpy(forgedProof.data() + 32, kCURVE_ORDER, 32); - mptAlice.send( - {.account = bob, - .dest = carol, - .amt = setup.sendAmount, - .proof = strHex(forgedProof), - .senderEncryptedAmt = setup.senderAmt, - .destEncryptedAmt = setup.destAmt, - .issuerEncryptedAmt = setup.issuerAmt, - .amountCommitment = setup.amountCommitment, - .balanceCommitment = setup.balanceCommitment, - .err = tecBAD_PROOF}); + mptAlice.send(setup.sendArgs(bob, carol, forgedProof, tecBAD_PROOF)); } - // Variant C2: Overflow scalar (curve order + 1) - // ----------------------------------------------------------------- - // Attack: Use scalar values greater than the curve order. - // - // Why it fails: Values > n are either rejected or reduced mod n, - // resulting in small values that don't satisfy verification. + // Variant C2: overflow scalar (curve order + 1). { auto const proof = setup.generateProof(mptAlice, env, bob, carol); if (!BEAST_EXPECT(proof.has_value())) @@ -9879,7 +6867,6 @@ class ConfidentialTransfer_test : public beast::unit_test::Suite Buffer forgedProof = *proof; - // curve_order + 1 (big-endian) static constexpr unsigned char kOVERFLOW_SCALAR[32] = { 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, // 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFE, // @@ -9887,22 +6874,9 @@ class ConfidentialTransfer_test : public beast::unit_test::Suite 0xBF, 0xD2, 0x5E, 0x8C, 0xD0, 0x36, 0x41, 0x42 // }; - // Compact sigma proof: 6 consecutive 32-byte scalars. - // Overwrite the first response scalar (z_m at byte 32) with - // curve_order + 1, which reduces to 1 mod n. std::memcpy(forgedProof.data() + 32, kOVERFLOW_SCALAR, 32); - mptAlice.send( - {.account = bob, - .dest = carol, - .amt = setup.sendAmount, - .proof = strHex(forgedProof), - .senderEncryptedAmt = setup.senderAmt, - .destEncryptedAmt = setup.destAmt, - .issuerEncryptedAmt = setup.issuerAmt, - .amountCommitment = setup.amountCommitment, - .balanceCommitment = setup.balanceCommitment, - .err = tecBAD_PROOF}); + mptAlice.send(setup.sendArgs(bob, carol, forgedProof, tecBAD_PROOF)); } } @@ -9976,17 +6950,7 @@ class ConfidentialTransfer_test : public beast::unit_test::Suite if (copyLen < expectedSendSize) std::memset(resizedProof.data() + copyLen, 0, expectedSendSize - copyLen); - mptAlice.send( - {.account = bob, - .dest = carol, - .amt = setup.sendAmount, - .proof = strHex(resizedProof), - .senderEncryptedAmt = setup.senderAmt, - .destEncryptedAmt = setup.destAmt, - .issuerEncryptedAmt = setup.issuerAmt, - .amountCommitment = setup.amountCommitment, - .balanceCommitment = setup.balanceCommitment, - .err = tecBAD_PROOF}); + mptAlice.send(setup.sendArgs(bob, carol, resizedProof, tecBAD_PROOF)); } // Variant B: Valid proof bound to wrong public parameters @@ -10026,17 +6990,7 @@ class ConfidentialTransfer_test : public beast::unit_test::Suite // Submit with the correct issuanceID — verifier recomputes // the challenge using the real issuanceID, which differs from // the one baked into the proof. - mptAlice.send( - {.account = bob, - .dest = carol, - .amt = setup.sendAmount, - .proof = strHex(*wrongProof), - .senderEncryptedAmt = setup.senderAmt, - .destEncryptedAmt = setup.destAmt, - .issuerEncryptedAmt = setup.issuerAmt, - .amountCommitment = setup.amountCommitment, - .balanceCommitment = setup.balanceCommitment, - .err = tecBAD_PROOF}); + mptAlice.send(setup.sendArgs(bob, carol, *wrongProof, tecBAD_PROOF)); } } @@ -10127,17 +7081,9 @@ class ConfidentialTransfer_test : public beast::unit_test::Suite auto const inflatedDestAmt = mptAlice.encryptAmount(carol, sendAmount * 2, setup.blindingFactor); - mptAlice.send( - {.account = bob, - .dest = carol, - .amt = setup.sendAmount, - .proof = strHex(*validProof), - .senderEncryptedAmt = setup.senderAmt, - .destEncryptedAmt = inflatedDestAmt, - .issuerEncryptedAmt = setup.issuerAmt, - .amountCommitment = setup.amountCommitment, - .balanceCommitment = setup.balanceCommitment, - .err = tecBAD_PROOF}); + auto args = setup.sendArgs(bob, carol, *validProof, tecBAD_PROOF); + args.destEncryptedAmt = inflatedDestAmt; + mptAlice.send(args); } } @@ -10216,17 +7162,11 @@ class ConfidentialTransfer_test : public beast::unit_test::Suite auto const negDestAmt = negateCiphertext(setup.destAmt); auto const negIssuerAmt = negateCiphertext(setup.issuerAmt); - mptAlice.send( - {.account = bob, - .dest = carol, - .amt = setup.sendAmount, - .proof = strHex(*validProof), - .senderEncryptedAmt = negSenderAmt, - .destEncryptedAmt = negDestAmt, - .issuerEncryptedAmt = negIssuerAmt, - .amountCommitment = setup.amountCommitment, - .balanceCommitment = setup.balanceCommitment, - .err = tecBAD_PROOF}); + auto args = setup.sendArgs(bob, carol, *validProof, tecBAD_PROOF); + args.senderEncryptedAmt = negSenderAmt; + args.destEncryptedAmt = negDestAmt; + args.issuerEncryptedAmt = negIssuerAmt; + mptAlice.send(args); } // Variant C: Negate only the sender ciphertext. @@ -10243,17 +7183,9 @@ class ConfidentialTransfer_test : public beast::unit_test::Suite auto const negSenderAmt = negateCiphertext(setup.senderAmt); - mptAlice.send( - {.account = bob, - .dest = carol, - .amt = setup.sendAmount, - .proof = strHex(*validProof), - .senderEncryptedAmt = negSenderAmt, - .destEncryptedAmt = setup.destAmt, - .issuerEncryptedAmt = setup.issuerAmt, - .amountCommitment = setup.amountCommitment, - .balanceCommitment = setup.balanceCommitment, - .err = tecBAD_PROOF}); + auto args = setup.sendArgs(bob, carol, *validProof, tecBAD_PROOF); + args.senderEncryptedAmt = negSenderAmt; + mptAlice.send(args); } } @@ -10331,17 +7263,9 @@ class ConfidentialTransfer_test : public beast::unit_test::Suite auto const combinedDest = homomorphicAdd(setup.destAmt, encM2); BEAST_EXPECT(combinedDest.has_value()); - mptAlice.send( - {.account = bob, - .dest = carol, - .amt = setup.sendAmount, - .proof = strHex(*validProof), - .senderEncryptedAmt = setup.senderAmt, - .destEncryptedAmt = combinedDest, - .issuerEncryptedAmt = setup.issuerAmt, - .amountCommitment = setup.amountCommitment, - .balanceCommitment = setup.balanceCommitment, - .err = tecBAD_PROOF}); + auto args = setup.sendArgs(bob, carol, *validProof, tecBAD_PROOF); + args.destEncryptedAmt = combinedDest; + mptAlice.send(args); } // Variant C: Cross-transaction ciphertext reuse. @@ -10356,16 +7280,7 @@ class ConfidentialTransfer_test : public beast::unit_test::Suite auto const proof1 = setup1.generateProof(mptAlice, env, bob, carol); if (!BEAST_EXPECT(proof1.has_value())) return; - mptAlice.send( - {.account = bob, - .dest = carol, - .amt = setup1.sendAmount, - .proof = strHex(*proof1), - .senderEncryptedAmt = setup1.senderAmt, - .destEncryptedAmt = setup1.destAmt, - .issuerEncryptedAmt = setup1.issuerAmt, - .amountCommitment = setup1.amountCommitment, - .balanceCommitment = setup1.balanceCommitment}); + mptAlice.send(setup1.sendArgs(bob, carol, *proof1)); ConfidentialSendSetup const setup2(mptAlice, bob, carol, alice, m2); @@ -10377,17 +7292,9 @@ class ConfidentialTransfer_test : public beast::unit_test::Suite auto const crossCombined = homomorphicAdd(setup1.destAmt, setup2.destAmt); BEAST_EXPECT(crossCombined.has_value()); - mptAlice.send( - {.account = bob, - .dest = carol, - .amt = setup2.sendAmount, - .proof = strHex(*proof2), - .senderEncryptedAmt = setup2.senderAmt, - .destEncryptedAmt = crossCombined, - .issuerEncryptedAmt = setup2.issuerAmt, - .amountCommitment = setup2.amountCommitment, - .balanceCommitment = setup2.balanceCommitment, - .err = tecBAD_PROOF}); + auto args = setup2.sendArgs(bob, carol, *proof2, tecBAD_PROOF); + args.destEncryptedAmt = crossCombined; + mptAlice.send(args); } } @@ -10478,17 +7385,9 @@ class ConfidentialTransfer_test : public beast::unit_test::Suite // Replace C1 in dest ciphertext, keep C2 auto const rerandomizedDest = substituteC1(setup.destAmt, otherCt); - mptAlice.send( - {.account = bob, - .dest = carol, - .amt = setup.sendAmount, - .proof = strHex(*validProof), - .senderEncryptedAmt = setup.senderAmt, - .destEncryptedAmt = rerandomizedDest, - .issuerEncryptedAmt = setup.issuerAmt, - .amountCommitment = setup.amountCommitment, - .balanceCommitment = setup.balanceCommitment, - .err = tecBAD_PROOF}); + auto args = setup.sendArgs(bob, carol, *validProof, tecBAD_PROOF); + args.destEncryptedAmt = rerandomizedDest; + mptAlice.send(args); } } @@ -10599,16 +7498,7 @@ class ConfidentialTransfer_test : public beast::unit_test::Suite return; // Submit first transaction successfully - mptAlice.send( - {.account = bob, - .dest = carol, - .amt = setup1.sendAmount, - .proof = strHex(*proof1), - .senderEncryptedAmt = setup1.senderAmt, - .destEncryptedAmt = setup1.destAmt, - .issuerEncryptedAmt = setup1.issuerAmt, - .amountCommitment = setup1.amountCommitment, - .balanceCommitment = setup1.balanceCommitment}); + mptAlice.send(setup1.sendArgs(bob, carol, *proof1)); mptAlice.mergeInbox({.account = carol}); @@ -10617,17 +7507,7 @@ class ConfidentialTransfer_test : public beast::unit_test::Suite // proof generated for the old sequence is invalid. ConfidentialSendSetup const setup2(mptAlice, bob, carol, alice, sendAmount); - mptAlice.send( - {.account = bob, - .dest = carol, - .amt = setup2.sendAmount, - .proof = strHex(*proof1), // reuse proof from tx1 - .senderEncryptedAmt = setup2.senderAmt, - .destEncryptedAmt = setup2.destAmt, - .issuerEncryptedAmt = setup2.issuerAmt, - .amountCommitment = setup2.amountCommitment, - .balanceCommitment = setup2.balanceCommitment, - .err = tecBAD_PROOF}); + mptAlice.send(setup2.sendArgs(bob, carol, *proof1, tecBAD_PROOF)); } } @@ -10656,10 +7536,7 @@ class ConfidentialTransfer_test : public beast::unit_test::Suite testSendRangeProof(features); // testSendZeroAmount(features); - testSendDepositPreauth(features); - testSendCredentialValidation(features); testSendWithAuditor(features); - testDestinationTag(features); // ConfidentialMPTClawback testClawback(features); @@ -10667,7 +7544,6 @@ class ConfidentialTransfer_test : public beast::unit_test::Suite testClawbackPreclaim(features); testClawbackProof(features); testClawbackWithAuditor(features); - testAMMHolderCannotHaveConfidentialStateClawback(features); testDelete(features); @@ -10708,37 +7584,6 @@ class ConfidentialTransfer_test : public beast::unit_test::Suite // TransferFee (transfer rate) Tests testTransferFee(features); - // Ticket Tests - testWithTickets(features); - testConvertTicketProofBinding(features); - testTicketErrors(features); - - // Batch Tests - testBatchConfidentialSend(features); - testBatchConfidentialConvertAndConvertBack(features); - testBatchConfidentialMixTransactions(features); - testBatchAllOrNothing(features); - testBatchOnlyOne(features); - testBatchUntilFailure(features); - testBatchIndependent(features); - testBatchWithTickets(features); - - // Delegation Tests - testConfidentialDelegation(features); - testDelegationRevocation(features); - testDelegationWithAuditor(features); - testDelegationClawbackIssuerOnly(features); - - // Batch + Delegation Combination Tests - testBatchDelegatedSend(features); - testBatchDelegationMissingPermission(features); - testBatchDelegatedSendWithDelegateAsOuterAccount(features); - testBatchDelegatedConfidentialMix(features); - - // Delegation with Tickets Tests - testInvalidDelegationWithTickets(features); - testDelegationWithTickets(features); - // Zero knowledge proof tests testForgedEqualityProof(features); testForgedRangeProof(features); @@ -10769,4 +7614,5 @@ public: // NOLINTEND(misc-const-correctness, bugprone-unchecked-optional-access) BEAST_DEFINE_TESTSUITE(ConfidentialTransfer, app, xrpl); + } // namespace xrpl diff --git a/src/test/jtx/ConfidentialTransfer.h b/src/test/jtx/ConfidentialTransfer.h new file mode 100644 index 0000000000..cf358bef76 --- /dev/null +++ b/src/test/jtx/ConfidentialTransfer.h @@ -0,0 +1,434 @@ +#pragma once + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include + +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace xrpl { + +// NOLINTBEGIN(misc-const-correctness, bugprone-unchecked-optional-access) +class ConfidentialTransferTestBase : public beast::unit_test::Suite +{ +protected: + // Offset where the bulletproof begins in a send proof blob. + // Proof layout: [compact_sigma | bulletproof] + static constexpr size_t kBULLETPROOF_OFFSET = + kEC_SEND_PROOF_LENGTH - kEC_DOUBLE_BULLETPROOF_LENGTH; + + // Generate a forged aggregated bulletproof (double bulletproof) for + // the given values and blinding factors. Used to test that splicing + // a bulletproof claiming a different remaining balance is rejected. + // secp256k1 convention: returns 1 on success, 0 on failure. + static Buffer + getForgedBulletproof( + std::array const& values, + std::array const& blindingFactors, + uint256 const& contextHash) + { + auto* const ctx = mpt_secp256k1_context(); + + secp256k1_pubkey h; + secp256k1_mpt_get_h_generator(ctx, &h); + + Buffer proof(kEC_DOUBLE_BULLETPROOF_LENGTH); + size_t proofLen = kEC_DOUBLE_BULLETPROOF_LENGTH; + + unsigned char blindings[64]; + std::memcpy(blindings, blindingFactors[0].data(), 32); + std::memcpy(blindings + 32, blindingFactors[1].data(), 32); + + if (secp256k1_bulletproof_prove_agg( + ctx, + proof.data(), + &proofLen, + values.data(), + blindings, + 2, + &h, + contextHash.data()) == 0) + Throw("Failed to generate forged bulletproof"); + + return proof; + } + + // Get a bad ciphertext with valid structure but cryptographic invalid for + // testing purposes. For preflight test purposes. + static Buffer const& + getBadCiphertext() + { + static Buffer const kBAD_CIPHERTEXT = []() { + Buffer buf(kEC_GAMAL_ENCRYPTED_TOTAL_LENGTH); + std::memset(buf.data(), 0xFF, kEC_GAMAL_ENCRYPTED_TOTAL_LENGTH); + + buf.data()[0] = kEC_COMPRESSED_PREFIX_EVEN_Y; + buf.data()[kEC_GAMAL_ENCRYPTED_LENGTH] = kEC_COMPRESSED_PREFIX_EVEN_Y; + return buf; + }(); + + return kBAD_CIPHERTEXT; + } + + // Get a trivial buffer that is structurally and mathematically valid, but + // contains invalid data that does not match the ledger state. For preclaim + // test purposes. + static Buffer const& + getTrivialCiphertext() + { + static Buffer const kTRIVIAL_CIPHERTEXT = []() { + Buffer buf(kEC_GAMAL_ENCRYPTED_TOTAL_LENGTH); + std::memset(buf.data(), 0, kEC_GAMAL_ENCRYPTED_TOTAL_LENGTH); + + buf.data()[0] = kEC_COMPRESSED_PREFIX_EVEN_Y; + buf.data()[kEC_GAMAL_ENCRYPTED_LENGTH] = kEC_COMPRESSED_PREFIX_EVEN_Y; + + buf.data()[kEC_GAMAL_ENCRYPTED_LENGTH - 1] = 0x01; + buf.data()[kEC_GAMAL_ENCRYPTED_TOTAL_LENGTH - 1] = 0x01; + + return buf; + }(); + + return kTRIVIAL_CIPHERTEXT; + } + + // Returns a valid compressed EC point (33 bytes) that can pass preflight + // validation but contains invalid data for preclaim test purposes. + static Buffer const& + getTrivialCommitment() + { + static Buffer const kTRIVIAL_COMMITMENT = []() { + Buffer buf(kEC_PEDERSEN_COMMITMENT_LENGTH); + std::memset(buf.data(), 0, kEC_PEDERSEN_COMMITMENT_LENGTH); + + buf.data()[0] = kEC_COMPRESSED_PREFIX_EVEN_Y; + // Set last byte to make it a valid x-coordinate on the curve + buf.data()[kEC_PEDERSEN_COMMITMENT_LENGTH - 1] = 0x01; + + return buf; + }(); + + return kTRIVIAL_COMMITMENT; + } + + static std::string + getTrivialSendProofHex() + { + Buffer buf(kEC_SEND_PROOF_LENGTH); + std::memset(buf.data(), 0, kEC_SEND_PROOF_LENGTH); + + for (std::size_t i = 0; i < kEC_SEND_PROOF_LENGTH; i += kEC_GAMAL_ENCRYPTED_LENGTH) + { + buf.data()[i] = kEC_COMPRESSED_PREFIX_EVEN_Y; + if (i + kEC_GAMAL_ENCRYPTED_LENGTH - 1 < kEC_SEND_PROOF_LENGTH) + buf.data()[i + kEC_GAMAL_ENCRYPTED_LENGTH - 1] = 0x01; + } + + return strHex(buf); + } + + // Helper struct to encapsulate common setup for integration tests. + struct ConfidentialSendSetup + { + // Constants + uint64_t sendAmount; + size_t nRecipients; + uint32_t version; + + // Blinding factors + Buffer blindingFactor; + Buffer amountBlindingFactor; + Buffer balanceBlindingFactor; + + // Encrypted amounts + Buffer senderAmt; + Buffer destAmt; + Buffer issuerAmt; + std::optional auditorAmt; + + // Commitments + Buffer amountCommitment; + + // Long-lived pub key buffers (to avoid dangling Slice) + Buffer senderPubKey; + Buffer destPubKey; + Buffer issuerPubKey; + std::optional auditorPubKey; + + // Balance data + uint64_t prevSpending; + Buffer prevEncryptedSpending; + + // Balance commitment (declared after prevSpending for init order) + Buffer balanceCommitment; + + // Recipients vector + std::vector recipients; + + // Constructor that performs all common setup + ConfidentialSendSetup( + test::jtx::MPTTester& mpt, + test::jtx::Account const& sender, + test::jtx::Account const& dest, + test::jtx::Account const& issuer, + uint64_t amount, + std::optional> auditor = std::nullopt) + : sendAmount(amount) + , nRecipients(auditor ? 4 : 3) + , version(mpt.getMPTokenVersion(sender)) + , blindingFactor(generateBlindingFactor()) + , amountBlindingFactor(blindingFactor) + , balanceBlindingFactor(generateBlindingFactor()) + , senderAmt(mpt.encryptAmount(sender, amount, blindingFactor)) + , destAmt(mpt.encryptAmount(dest, amount, blindingFactor)) + , issuerAmt(mpt.encryptAmount(issuer, amount, blindingFactor)) + , auditorAmt( + auditor ? std::optional( + mpt.encryptAmount(auditor->get(), amount, blindingFactor)) + : std::nullopt) + , amountCommitment(mpt.getPedersenCommitment(amount, amountBlindingFactor)) + , senderPubKey(*mpt.getPubKey(sender)) + , destPubKey(*mpt.getPubKey(dest)) + , issuerPubKey(*mpt.getPubKey(issuer)) + , auditorPubKey(auditor ? mpt.getPubKey(auditor->get()) : std::nullopt) + , prevSpending( + *mpt.getDecryptedBalance(sender, test::jtx::MPTTester::HolderEncryptedSpending)) + , prevEncryptedSpending( + *mpt.getEncryptedBalance(sender, test::jtx::MPTTester::HolderEncryptedSpending)) + , balanceCommitment(mpt.getPedersenCommitment(prevSpending, balanceBlindingFactor)) + { + recipients.push_back({.publicKey = Slice(senderPubKey), .encryptedAmount = senderAmt}); + recipients.push_back({.publicKey = Slice(destPubKey), .encryptedAmount = destAmt}); + recipients.push_back({.publicKey = Slice(issuerPubKey), .encryptedAmount = issuerAmt}); + if (auditor) + { + recipients.push_back( + {.publicKey = Slice(*auditorPubKey), .encryptedAmount = *auditorAmt}); + } + } + + // Generate proof with current account sequence + std::optional + generateProof( + test::jtx::MPTTester& mpt, + test::jtx::Env& env, + test::jtx::Account const& sender, + test::jtx::Account const& dest) const + { + auto const ctxHash = getSendContextHash( + sender.id(), mpt.issuanceID(), env.seq(sender), dest.id(), version); + + return mpt.getConfidentialSendProof( + sender, + sendAmount, + recipients, + blindingFactor, + ctxHash, + {.pedersenCommitment = amountCommitment, + .amt = sendAmount, + .encryptedAmt = senderAmt, + .blindingFactor = amountBlindingFactor}, + {.pedersenCommitment = balanceCommitment, + .amt = prevSpending, + .encryptedAmt = prevEncryptedSpending, + .blindingFactor = balanceBlindingFactor}); + } + + [[nodiscard]] test::jtx::MPTConfidentialSend + sendArgs( + test::jtx::Account const& sender, + test::jtx::Account const& dest, + Buffer const& proof, + std::optional err = std::nullopt) const + { + return { + .account = sender, + .dest = dest, + .amt = sendAmount, + .proof = strHex(proof), + .senderEncryptedAmt = senderAmt, + .destEncryptedAmt = destAmt, + .issuerEncryptedAmt = issuerAmt, + .auditorEncryptedAmt = auditorAmt, + .amountCommitment = amountCommitment, + .balanceCommitment = balanceCommitment, + .err = err}; + } + }; + + // Helper that wraps the boilerplate setup: Env + MPT creation, funding, key + // generation, and seeding each holder with a confidential balance. + // The caller supplies the issuer and any number of holders. + struct ConfidentialEnv + { + // Per-holder configuration: the account, how much MPT to fund it + // with, and how much of that to convert to a confidential balance. + struct HolderInit + { + test::jtx::Account account; + std::uint64_t payAmount = 1000; + std::uint64_t convertAmount = 100; + }; + + test::jtx::MPTTester mpt; + + ConfidentialEnv( + test::jtx::Env& env, + test::jtx::Account const& issuer, + std::vector const& holders, + std::uint32_t flags = tfMPTCanLock | tfMPTCanConfidentialAmount | tfMPTCanTransfer, + std::optional auditor = std::nullopt) + : mpt{env, issuer, {.holders = extractAccounts(holders), .auditor = auditor}} + { + mpt.create({.ownerCount = 1, .flags = flags}); + + for (auto const& h : holders) + { + mpt.authorize({.account = h.account}); + if ((flags & tfMPTRequireAuth) != 0) + mpt.authorize({.account = issuer, .holder = h.account}); + mpt.pay(issuer, h.account, h.payAmount); + } + + mpt.generateKeyPair(issuer); + for (auto const& h : holders) + mpt.generateKeyPair(h.account); + if (auditor) + mpt.generateKeyPair(*auditor); + + mpt.set( + {.account = issuer, + .issuerPubKey = mpt.getPubKey(issuer), + .auditorPubKey = auditor ? mpt.getPubKey(*auditor) : std::optional{}}); + + for (auto const& h : holders) + { + mpt.convert( + {.account = h.account, + .amt = h.convertAmount, + .holderPubKey = mpt.getPubKey(h.account)}); + mpt.mergeInbox({.account = h.account}); + } + } + + private: + static std::vector + extractAccounts(std::vector const& holders) + { + std::vector accounts; + accounts.reserve(holders.size()); + for (auto const& h : holders) + accounts.push_back(h.account); + return accounts; + } + }; + + // Set up an MPT environment suitable for batch testing. + // alice is issuer; bob has 'bobAmt' in confidential spending; carol has + // 'carolAmt' in confidential spending; dave is initialised with pubkey but + // zero spending/inbox. + static void + setupBatchEnv( + test::jtx::MPTTester& mpt, + test::jtx::Account const& alice, + test::jtx::Account const& bob, + test::jtx::Account const& carol, + test::jtx::Account const& dave, + std::uint64_t bobAmt, + std::uint64_t carolAmt) + { + using namespace test::jtx; + mpt.create({ + .ownerCount = 1, + .flags = tfMPTCanTransfer | tfMPTCanLock | tfMPTCanConfidentialAmount, + }); + mpt.authorize({.account = bob}); + mpt.authorize({.account = carol}); + mpt.authorize({.account = dave}); + + if (bobAmt > 0) + mpt.pay(alice, bob, bobAmt); + if (carolAmt > 0) + mpt.pay(alice, carol, carolAmt); + + mpt.generateKeyPair(alice); + mpt.generateKeyPair(bob); + mpt.generateKeyPair(carol); + mpt.generateKeyPair(dave); + + mpt.set({.account = alice, .issuerPubKey = mpt.getPubKey(alice)}); + + if (bobAmt > 0) + { + mpt.convert({.account = bob, .amt = bobAmt, .holderPubKey = mpt.getPubKey(bob)}); + mpt.mergeInbox({.account = bob}); + } + else + { + mpt.convert({.account = bob, .amt = 0, .holderPubKey = mpt.getPubKey(bob)}); + } + + if (carolAmt > 0) + { + mpt.convert({.account = carol, .amt = carolAmt, .holderPubKey = mpt.getPubKey(carol)}); + mpt.mergeInbox({.account = carol}); + } + else + { + mpt.convert({.account = carol, .amt = 0, .holderPubKey = mpt.getPubKey(carol)}); + } + + // dave: register pubkey only (0 spending/inbox) + mpt.convert({.account = dave, .amt = 0, .holderPubKey = mpt.getPubKey(dave)}); + } +}; +// NOLINTEND(misc-const-correctness, bugprone-unchecked-optional-access) + +} // namespace xrpl