#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 #include #include #include namespace xrpl { class ConfidentialTransfer_test : public ConfidentialTransferTestBase { void testConvert(FeatureBitset features) { testcase("Convert"); using namespace test::jtx; // Basic convert test { Env env{*this, features}; Account const alice("alice"); Account const bob("bob"); MPTTester mptAlice(env, alice, {.holders = {bob}}); mptAlice.create({ .ownerCount = 1, .flags = tfMPTCanTransfer | tfMPTCanLock | tfMPTCanHoldConfidentialBalance, }); mptAlice.authorize({ .account = bob, }); mptAlice.pay(alice, bob, 100); mptAlice.generateKeyPair(alice); mptAlice.set({.account = alice, .issuerPubKey = mptAlice.getPubKey(alice)}); mptAlice.generateKeyPair(bob); mptAlice.convert({ .account = bob, .amt = 0, .holderPubKey = mptAlice.getPubKey(bob), }); mptAlice.convert({ .account = bob, .amt = 20, }); mptAlice.convert({ .account = bob, .amt = 40, }); mptAlice.convert({ .account = bob, .amt = 40, }); } // Edge case: minimum amount (1) { Env env{*this, features}; Account const alice("alice"); Account const bob("bob"); MPTTester mptAlice(env, alice, {.holders = {bob}}); mptAlice.create({ .ownerCount = 1, .flags = tfMPTCanTransfer | tfMPTCanLock | tfMPTCanHoldConfidentialBalance, }); mptAlice.authorize({ .account = bob, }); mptAlice.pay(alice, bob, 1); mptAlice.generateKeyPair(alice); mptAlice.set({.account = alice, .issuerPubKey = mptAlice.getPubKey(alice)}); mptAlice.generateKeyPair(bob); mptAlice.convert({ .account = bob, .amt = 0, .holderPubKey = mptAlice.getPubKey(bob), }); mptAlice.convert({ .account = bob, .amt = 1, }); } // Edge case: kMaxMpTokenAmount // Using raw JSON to avoid automatic decryption checks in MPTTester // which don't work for very large amounts (brute-force decryption is slow) { Env env{*this, features}; Account const alice("alice"); Account const bob("bob"); MPTTester mptAlice(env, alice, {.holders = {bob}}); mptAlice.create({ .ownerCount = 1, .flags = tfMPTCanTransfer | tfMPTCanLock | tfMPTCanHoldConfidentialBalance, }); mptAlice.authorize({ .account = bob, }); mptAlice.pay(alice, bob, kMaxMpTokenAmount); mptAlice.generateKeyPair(alice); mptAlice.set({.account = alice, .issuerPubKey = mptAlice.getPubKey(alice)}); mptAlice.generateKeyPair(bob); // First convert with amt=0 to register public key (uses MPTTester) mptAlice.convert({ .account = bob, .amt = 0, .holderPubKey = mptAlice.getPubKey(bob), }); // Second convert with kMaxMpTokenAmount using raw JSON Buffer const blindingFactor = generateBlindingFactor(); auto const holderCiphertext = mptAlice.encryptAmount(bob, kMaxMpTokenAmount, blindingFactor); auto const issuerCiphertext = mptAlice.encryptAmount(alice, kMaxMpTokenAmount, blindingFactor); json::Value jv; jv[jss::Account] = bob.human(); jv[jss::TransactionType] = jss::ConfidentialMPTConvert; jv[sfMPTokenIssuanceID] = to_string(mptAlice.issuanceID()); jv[sfMPTAmount.jsonName] = std::to_string(kMaxMpTokenAmount); jv[sfHolderEncryptedAmount.jsonName] = strHex(holderCiphertext); jv[sfIssuerEncryptedAmount.jsonName] = strHex(issuerCiphertext); jv[sfBlindingFactor.jsonName] = strHex(blindingFactor); env(jv, Ter(tesSUCCESS)); // Verify the public balance was reduced env.require(MptBalance(mptAlice, bob, 0)); } } void testConvertWithAuditor(FeatureBitset features) { testcase("Convert with auditor"); using namespace test::jtx; Env env{*this, features}; Account const alice("alice"); Account const bob("bob"); Account const auditor("auditor"); MPTTester mptAlice( env, alice, { .holders = {bob}, .auditor = auditor, }); mptAlice.create({ .ownerCount = 1, .flags = tfMPTCanTransfer | tfMPTCanLock | tfMPTCanHoldConfidentialBalance, }); mptAlice.authorize({ .account = bob, }); mptAlice.pay(alice, bob, 100); mptAlice.generateKeyPair(alice); mptAlice.generateKeyPair(auditor); mptAlice.set({ .account = alice, .issuerPubKey = mptAlice.getPubKey(alice), .auditorPubKey = mptAlice.getPubKey(auditor), }); mptAlice.generateKeyPair(bob); mptAlice.convert({ .account = bob, .amt = 0, .holderPubKey = mptAlice.getPubKey(bob), }); mptAlice.convert({ .account = bob, .amt = 20, }); mptAlice.convert({ .account = bob, .amt = 30, }); } void testConvertPreflight(FeatureBitset features) { testcase("Convert preflight"); using namespace test::jtx; // Alice (issuer) tries to convert her own tokens - should fail { Env env{*this, features}; Account const alice("alice"); MPTTester mptAlice(env, alice); mptAlice.create({ .flags = tfMPTCanTransfer | tfMPTCanLock | tfMPTCanHoldConfidentialBalance, }); mptAlice.generateKeyPair(alice); mptAlice.convert({ .account = alice, .amt = 10, .holderPubKey = mptAlice.getPubKey(alice), .err = temMALFORMED, }); } { Env env{*this, features - featureConfidentialTransfer}; Account const alice("alice"); Account const bob("bob"); MPTTester mptAlice(env, alice, {.holders = {bob}}); mptAlice.create({ .ownerCount = 1, .flags = tfMPTCanTransfer | tfMPTCanLock, }); mptAlice.authorize({ .account = bob, }); mptAlice.pay(alice, bob, 100); mptAlice.generateKeyPair(alice); mptAlice.generateKeyPair(bob); mptAlice.set({ .account = alice, .issuerPubKey = mptAlice.getPubKey(alice), .err = temDISABLED, }); mptAlice.convert({ .account = bob, .amt = 10, .holderPubKey = mptAlice.getPubKey(bob), .err = temDISABLED, }); } { Env env{*this, features}; Account const alice("alice"); Account const bob("bob"); MPTTester mptAlice(env, alice, {.holders = {bob}}); mptAlice.create({ .ownerCount = 1, .flags = tfMPTCanTransfer | tfMPTCanLock, }); mptAlice.authorize({ .account = bob, }); mptAlice.pay(alice, bob, 100); mptAlice.generateKeyPair(alice); mptAlice.generateKeyPair(bob); mptAlice.convert({ .account = alice, .amt = 10, .holderPubKey = mptAlice.getPubKey(bob), .err = temMALFORMED, }); // Holder encrypted amount is empty (length 0) mptAlice.convert({ .account = bob, .amt = 10, .holderPubKey = mptAlice.getPubKey(bob), .holderEncryptedAmt = Buffer{}, .err = temBAD_CIPHERTEXT, }); // Issuer encrypted amount is empty (length 0) mptAlice.convert({ .account = bob, .amt = 10, .holderPubKey = mptAlice.getPubKey(bob), .issuerEncryptedAmt = Buffer{}, .err = temBAD_CIPHERTEXT, }); // Auditor encrypted amount has invalid length (must be 66 bytes) mptAlice.convert({ .account = bob, .amt = 10, .holderPubKey = mptAlice.getPubKey(bob), .auditorEncryptedAmt = gMakeZeroBuffer(10), .err = temBAD_CIPHERTEXT, }); // Auditor encrypted amount has correct length but invalid data mptAlice.convert({ .account = bob, .amt = 10, .holderPubKey = mptAlice.getPubKey(bob), .auditorEncryptedAmt = getBadCiphertext(), .err = temBAD_CIPHERTEXT, }); // Amount exceeds maximum allowed MPT amount mptAlice.convert({ .account = bob, .amt = kMaxMpTokenAmount + 1, .holderPubKey = mptAlice.getPubKey(bob), .err = temBAD_AMOUNT, }); // Holder encrypted amount has correct length but invalid data mptAlice.convert({ .account = bob, .amt = 1, .holderPubKey = mptAlice.getPubKey(bob), .holderEncryptedAmt = getBadCiphertext(), .err = temBAD_CIPHERTEXT, }); // Issuer encrypted amount has correct length but invalid data (not // a valid EC point) mptAlice.convert({ .account = bob, .amt = 1, .holderPubKey = mptAlice.getPubKey(bob), .issuerEncryptedAmt = getBadCiphertext(), .err = temBAD_CIPHERTEXT, }); // Holder public key is invalid (empty buffer) mptAlice.convert({ .account = bob, .amt = 10, .holderPubKey = Buffer{}, .err = temMALFORMED, }); // Holder public key has correct length but invalid EC point data mptAlice.convert({ .account = bob, .amt = 10, .holderPubKey = gMakeZeroBuffer(kEcPubKeyLength), .err = temMALFORMED, }); } // when registering holder pub key, the transaction must include a // Schnorr proof of knowledge for the corresponding secret key { Env env{*this, features}; Account const alice("alice"); Account const bob("bob"); MPTTester mptAlice(env, alice, {.holders = {bob}}); mptAlice.create({ .ownerCount = 1, .flags = tfMPTCanTransfer | tfMPTCanLock | tfMPTCanHoldConfidentialBalance, }); mptAlice.authorize({ .account = bob, }); mptAlice.pay(alice, bob, 100); mptAlice.generateKeyPair(alice); mptAlice.generateKeyPair(bob); mptAlice.set({.account = alice, .issuerPubKey = mptAlice.getPubKey(alice)}); mptAlice.convert({ .account = bob, .amt = 10, .fillSchnorrProof = false, .holderPubKey = mptAlice.getPubKey(bob), .err = temMALFORMED, }); mptAlice.convert({ .account = bob, .amt = 0, .fillSchnorrProof = false, .holderPubKey = mptAlice.getPubKey(bob), .err = temMALFORMED, }); // proof length is invalid mptAlice.convert({ .account = bob, .amt = 10, .proof = std::string(10, 'A'), .holderPubKey = mptAlice.getPubKey(bob), .err = temMALFORMED, }); } // when holder pub key already registered, Schnorr proof must not be // provided { Env env{*this, features}; Account const alice("alice"); Account const bob("bob"); MPTTester mptAlice(env, alice, {.holders = {bob}}); mptAlice.create({ .ownerCount = 1, .flags = tfMPTCanTransfer | tfMPTCanLock | tfMPTCanHoldConfidentialBalance, }); mptAlice.authorize({ .account = bob, }); mptAlice.pay(alice, bob, 100); mptAlice.generateKeyPair(alice); mptAlice.generateKeyPair(bob); mptAlice.set({.account = alice, .issuerPubKey = mptAlice.getPubKey(alice)}); // this will register bob's pub key, // and convert 10 to confidential balance mptAlice.convert({ .account = bob, .amt = 10, .holderPubKey = mptAlice.getPubKey(bob), }); // proof must not be provided after pub key was registered mptAlice.convert({ .account = bob, .amt = 20, .fillSchnorrProof = true, .err = temMALFORMED, }); } } void testConvertInvalidProofContextBinding(FeatureBitset features) { testcase("Convert proof context binding"); using namespace test::jtx; auto runBadProof = [&](auto makeContextHash) { Env env{*this, features}; Account const alice("alice"); Account const bob("bob"); Account const carol("carol"); MPTTester mptAlice(env, alice, {.holders = {bob, carol}}); mptAlice.create({ .ownerCount = 1, .flags = tfMPTCanTransfer | tfMPTCanLock | tfMPTCanHoldConfidentialBalance, }); mptAlice.authorize({.account = bob}); mptAlice.authorize({.account = carol}); mptAlice.pay(alice, bob, 100); mptAlice.generateKeyPair(alice); mptAlice.generateKeyPair(bob); mptAlice.generateKeyPair(carol); mptAlice.set({.account = alice, .issuerPubKey = mptAlice.getPubKey(alice)}); auto const proof = mptAlice.getSchnorrProof(bob, makeContextHash(env, mptAlice, alice, bob, carol)); if (!BEAST_EXPECT(proof.has_value())) return; mptAlice.convert({ .account = bob, .amt = 10, .proof = strHex(requireOptional(proof, "Missing proof")), .holderPubKey = mptAlice.getPubKey(bob), .err = tecBAD_PROOF, }); }; // Wrong account in the proof context. runBadProof([&](Env& env, MPTTester const& mpt, Account const&, Account const& bob, Account const& carol) { return getConvertContextHash(carol.id(), mpt.issuanceID(), env.seq(bob)); }); // Wrong issuance ID in the proof context. runBadProof([&](Env& env, MPTTester const&, Account const& alice, Account const& bob, Account const&) { return getConvertContextHash( bob.id(), makeMptID(env.seq(alice) + 100, alice), env.seq(bob)); }); // Wrong transaction sequence in the proof context. runBadProof([&](Env& env, MPTTester const& mpt, Account const&, Account const& bob, Account const&) { return getConvertContextHash(bob.id(), mpt.issuanceID(), env.seq(bob) + 1); }); } void testSet(FeatureBitset features) { testcase("Set"); using namespace test::jtx; // Set keys on issuance that already has confidential amounts enabled { Env env{*this, features}; Account const alice("alice"); Account const auditor("auditor"); MPTTester mptAlice(env, alice, {.holders = {}, .auditor = auditor}); mptAlice.create({ .ownerCount = 1, .flags = tfMPTCanTransfer | tfMPTCanLock | tfMPTCanHoldConfidentialBalance, }); mptAlice.generateKeyPair(alice); mptAlice.generateKeyPair(auditor); mptAlice.set({ .account = alice, .issuerPubKey = mptAlice.getPubKey(alice), .auditorPubKey = mptAlice.getPubKey(auditor), }); } // Enable confidential amounts flag only (no keys) { Env env{*this, features}; Account const alice("alice"); MPTTester mptAlice(env, alice, {.holders = {}}); mptAlice.create({ .ownerCount = 1, .flags = tfMPTCanTransfer | tfMPTCanLock, }); mptAlice.set({ .account = alice, .mutableFlags = tmfMPTSetCanHoldConfidentialBalance, }); } // Set keys when enabling confidential amounts in the same tx { Env env{*this, features}; Account const alice("alice"); Account const auditor("auditor"); MPTTester mptAlice(env, alice, {.holders = {}, .auditor = auditor}); mptAlice.create({ .ownerCount = 1, .flags = tfMPTCanTransfer | tfMPTCanLock, }); mptAlice.generateKeyPair(alice); mptAlice.generateKeyPair(auditor); mptAlice.set({ .account = alice, .mutableFlags = tmfMPTSetCanHoldConfidentialBalance, .issuerPubKey = mptAlice.getPubKey(alice), .auditorPubKey = mptAlice.getPubKey(auditor), }); // Verify lsfMPTCanHoldConfidentialBalance flag is set BEAST_EXPECT(mptAlice.checkFlags( lsfMPTCanTransfer | lsfMPTCanLock | lsfMPTCanHoldConfidentialBalance)); // Verify keys are persisted on the issuance auto const sle = env.le(keylet::mptokenIssuance(mptAlice.issuanceID())); BEAST_EXPECT(sle); BEAST_EXPECT(sle->isFieldPresent(sfIssuerEncryptionKey)); BEAST_EXPECT(sle->isFieldPresent(sfAuditorEncryptionKey)); } } void testSetPreflight(FeatureBitset features) { testcase("Set preflight"); using namespace test::jtx; { Env env{*this, features - featureConfidentialTransfer}; Account const alice("alice"); Account const bob("bob"); MPTTester mptAlice(env, alice, {.holders = {bob}}); mptAlice.create({ .ownerCount = 1, .flags = tfMPTCanTransfer | tfMPTCanLock, }); mptAlice.authorize({ .account = bob, }); mptAlice.pay(alice, bob, 100); mptAlice.generateKeyPair(alice); mptAlice.generateKeyPair(bob); mptAlice.set({ .account = alice, .issuerPubKey = mptAlice.getPubKey(alice), .err = temDISABLED, }); } // pub key is invalid { Env env{*this, features}; Account const alice("alice"); Account const bob("bob"); MPTTester mptAlice(env, alice, {.holders = {bob}}); mptAlice.create({ .ownerCount = 1, .flags = tfMPTCanTransfer | tfMPTCanLock | tfMPTCanHoldConfidentialBalance, }); mptAlice.authorize({ .account = bob, }); mptAlice.pay(alice, bob, 100); mptAlice.generateKeyPair(alice); mptAlice.generateKeyPair(bob); // Issuer pub key is invalid (empty) mptAlice.set({ .account = alice, .issuerPubKey = Buffer{}, .err = temMALFORMED, }); // Issuer pub key has correct length but invalid EC point data mptAlice.set({ .account = alice, .issuerPubKey = gMakeZeroBuffer(kEcPubKeyLength), .err = temMALFORMED, }); // Auditor key is invalid length mptAlice.set({ .account = alice, .issuerPubKey = mptAlice.getPubKey(alice), .auditorPubKey = gMakeZeroBuffer(10), .err = temMALFORMED, }); // Auditor key has correct length but invalid EC point data mptAlice.set({ .account = alice, .issuerPubKey = mptAlice.getPubKey(alice), .auditorPubKey = gMakeZeroBuffer(kEcPubKeyLength), .err = temMALFORMED, }); // Cannot set auditor key without issuer key mptAlice.set({ .account = alice, .auditorPubKey = mptAlice.getPubKey(alice), .err = temMALFORMED, }); // Cannot set Holder and issuer Keys in the same transaction mptAlice.set({ .account = alice, .holder = bob, .issuerPubKey = mptAlice.getPubKey(alice), .err = temMALFORMED, }); // Cannot set Holder and auditor Keys in the same transaction mptAlice.set({ .account = alice, .holder = bob, .auditorPubKey = mptAlice.getPubKey(alice), .err = temMALFORMED, }); } } void testSetPreclaim(FeatureBitset features) { testcase("Set preclaim"); using namespace test::jtx; // Cannot set issuer key if confidential amounts not enabled { Env env{*this, features}; Account const alice("alice"); MPTTester mptAlice(env, alice, {.holders = {}}); mptAlice.create({ .ownerCount = 1, .flags = tfMPTCanTransfer | tfMPTCanLock, }); mptAlice.generateKeyPair(alice); mptAlice.set({ .account = alice, .issuerPubKey = mptAlice.getPubKey(alice), .err = tecNO_PERMISSION, }); } // Cannot update issuer public key once set { Env env{*this, features}; Account const alice("alice"); Account const bob("bob"); MPTTester mptAlice(env, alice, {.holders = {bob}}); mptAlice.create({ .ownerCount = 1, .flags = tfMPTCanTransfer | tfMPTCanLock | tfMPTCanHoldConfidentialBalance, }); mptAlice.generateKeyPair(alice); mptAlice.generateKeyPair(bob); // First set issuer key - should succeed mptAlice.set({ .account = alice, .issuerPubKey = mptAlice.getPubKey(alice), }); // Try to update issuer key - should fail mptAlice.set({ .account = alice, .issuerPubKey = mptAlice.getPubKey(bob), .err = tecNO_PERMISSION, }); } // Cannot update issuer and auditor public keys once set // Note: trying to set only auditor key fails in preflight (temMALFORMED) // so we must provide both keys, which fails on issuer key check first { Env env{*this, features}; Account const alice("alice"); Account const bob("bob"); Account const auditor("auditor"); MPTTester mptAlice(env, alice, {.holders = {bob}, .auditor = auditor}); mptAlice.create({ .ownerCount = 1, .flags = tfMPTCanTransfer | tfMPTCanLock | tfMPTCanHoldConfidentialBalance, }); mptAlice.generateKeyPair(alice); mptAlice.generateKeyPair(bob); mptAlice.generateKeyPair(auditor); // Set issuer and auditor keys - should succeed mptAlice.set({ .account = alice, .issuerPubKey = mptAlice.getPubKey(alice), .auditorPubKey = mptAlice.getPubKey(auditor), }); // Try to update both keys - fails on issuer key check first mptAlice.set({ .account = alice, .issuerPubKey = mptAlice.getPubKey(bob), .auditorPubKey = mptAlice.getPubKey(alice), .err = tecNO_PERMISSION, }); } // Cannot set auditor key if confidential amounts not enabled { Env env{*this, features}; Account const alice("alice"); Account const auditor("auditor"); MPTTester mptAlice(env, alice, {.holders = {}, .auditor = auditor}); mptAlice.create({ .ownerCount = 1, .flags = tfMPTCanTransfer | tfMPTCanLock, }); mptAlice.generateKeyPair(alice); mptAlice.generateKeyPair(auditor); mptAlice.set({ .account = alice, .issuerPubKey = mptAlice.getPubKey(alice), .auditorPubKey = mptAlice.getPubKey(auditor), .err = tecNO_PERMISSION, }); } // Cannot set keys when mutation of canConfidentialAmount is disallowed { Env env{*this, features}; Account const alice("alice"); MPTTester mptAlice(env, alice, {.holders = {}}); // Create with tmfMPTCannotEnableCanHoldConfidentialBalance mptAlice.create({ .ownerCount = 1, .flags = tfMPTCanTransfer | tfMPTCanLock, .mutableFlags = tmfMPTCannotEnableCanHoldConfidentialBalance, }); mptAlice.generateKeyPair(alice); // Trying to enable confidential amounts and set keys fails // because the issuance cannot mutate canConfidentialAmount mptAlice.set({ .account = alice, .mutableFlags = tmfMPTSetCanHoldConfidentialBalance, .issuerPubKey = mptAlice.getPubKey(alice), .err = tecNO_PERMISSION, }); } // Set issuer key first, then auditor key in a separate tx { Env env{*this, features}; Account const alice("alice"); Account const auditor("auditor"); MPTTester mptAlice(env, alice, {.holders = {}, .auditor = auditor}); mptAlice.create({ .ownerCount = 1, .flags = tfMPTCanTransfer | tfMPTCanLock | tfMPTCanHoldConfidentialBalance, }); mptAlice.generateKeyPair(alice); mptAlice.generateKeyPair(auditor); // Set issuer key only mptAlice.set({ .account = alice, .issuerPubKey = mptAlice.getPubKey(alice), }); // Set auditor key in a separate tx - requires issuer key in tx // (preflight enforces auditor key requires issuer key) // This fails because issuer key is already set on ledger mptAlice.set({ .account = alice, .issuerPubKey = mptAlice.getPubKey(alice), .auditorPubKey = mptAlice.getPubKey(auditor), .err = tecNO_PERMISSION, }); } } void testTransferFee(FeatureBitset features) { testcase("test transfer fee"); using namespace test::jtx; // MPTokenIssuanceCreate: cannot create with both TransferFee > 0 and // tfMPTCanHoldConfidentialBalance { Env env{*this, features}; Account const alice("alice"); MPTTester mptAlice(env, alice, {.holders = {}}); mptAlice.create({ .transferFee = 100, .flags = tfMPTCanTransfer | tfMPTCanHoldConfidentialBalance, .err = temBAD_TRANSFER_FEE, }); // transferFee being 0 is allowed, even with tfMPTCanHoldConfidentialBalance mptAlice.create({ .transferFee = 0, .flags = tfMPTCanTransfer | tfMPTCanHoldConfidentialBalance, }); } // MPTokenIssuanceSet (preflight): cannot enable confidential amounts and // set TransferFee > 0 in the same transaction { Env env{*this, features}; Account const alice("alice"); MPTTester mptAlice(env, alice, {.holders = {}}); mptAlice.create({ .ownerCount = 1, .flags = tfMPTCanTransfer | tfMPTCanLock, .mutableFlags = tmfMPTCanMutateTransferFee, }); mptAlice.set({ .account = alice, .mutableFlags = tmfMPTSetCanHoldConfidentialBalance, .transferFee = 100, .err = temBAD_TRANSFER_FEE, }); } // MPTokenIssuanceSet (preclaim): cannot enable confidential amounts on // an issuance that already has a non-zero TransferFee { Env env{*this, features}; Account const alice("alice"); MPTTester mptAlice(env, alice, {.holders = {}}); mptAlice.create({ .transferFee = 100, .ownerCount = 1, .flags = tfMPTCanTransfer | tfMPTCanLock, .mutableFlags = tmfMPTCanMutateTransferFee, }); mptAlice.set({ .account = alice, .mutableFlags = tmfMPTSetCanHoldConfidentialBalance, .err = tecNO_PERMISSION, }); } // MPTokenIssuanceSet (preclaim): cannot set TransferFee > 0 on an // issuance that already has lsfMPTCanHoldConfidentialBalance { Env env{*this, features}; Account const alice("alice"); MPTTester mptAlice(env, alice, {.holders = {}}); mptAlice.create({ .ownerCount = 1, .flags = tfMPTCanTransfer | tfMPTCanLock | tfMPTCanHoldConfidentialBalance, .mutableFlags = tmfMPTCanMutateTransferFee, }); mptAlice.set({ .account = alice, .transferFee = 100, .err = tecNO_PERMISSION, }); // Setting transfer fee to 0 is allowed, but have no effect. mptAlice.set({ .account = alice, .transferFee = 0, }); } } void testConvertPreclaim(FeatureBitset features) { testcase("Convert preclaim"); using namespace test::jtx; // tfMPTCanHoldConfidentialBalance is not set on issuance { Env env{*this, features}; Account const alice("alice"); Account const bob("bob"); MPTTester mptAlice(env, alice, {.holders = {bob}}); mptAlice.create({ .ownerCount = 1, .flags = tfMPTCanTransfer | tfMPTCanLock, }); mptAlice.authorize({ .account = bob, }); mptAlice.pay(alice, bob, 100); mptAlice.generateKeyPair(alice); mptAlice.generateKeyPair(bob); mptAlice.convert({ .account = bob, .amt = 10, .holderPubKey = mptAlice.getPubKey(bob), .err = tecNO_PERMISSION, }); } // issuer has not uploaded their sfIssuerEncryptionKey { Env env{*this, features}; Account const alice("alice"); Account const bob("bob"); MPTTester mptAlice(env, alice, {.holders = {bob}}); mptAlice.create({ .ownerCount = 1, .flags = tfMPTCanTransfer | tfMPTCanLock | tfMPTCanHoldConfidentialBalance, }); mptAlice.authorize({ .account = bob, }); mptAlice.pay(alice, bob, 100); mptAlice.generateKeyPair(alice); mptAlice.generateKeyPair(bob); mptAlice.convert({ .account = bob, .amt = 10, .holderPubKey = mptAlice.getPubKey(bob), .err = tecNO_PERMISSION, }); } // issuance does not exist { Env env{*this, features}; Account const alice("alice"); Account const bob("bob"); MPTTester mptAlice(env, alice, {.holders = {bob}}); mptAlice.create({ .ownerCount = 1, .flags = tfMPTCanTransfer | tfMPTCanLock | tfMPTCanHoldConfidentialBalance, }); mptAlice.authorize({ .account = bob, }); mptAlice.generateKeyPair(alice); mptAlice.set({.account = alice, .issuerPubKey = mptAlice.getPubKey(alice)}); mptAlice.destroy(); mptAlice.generateKeyPair(bob); mptAlice.convert({ .account = bob, .amt = 10, .holderPubKey = mptAlice.getPubKey(bob), .err = tecOBJECT_NOT_FOUND, }); } // bob has not created MPToken { Env env{*this, features}; Account const alice("alice"); Account const bob("bob"); MPTTester mptAlice(env, alice, {.holders = {bob}}); mptAlice.create({ .ownerCount = 1, .flags = tfMPTCanTransfer | tfMPTCanLock | tfMPTCanHoldConfidentialBalance, }); mptAlice.generateKeyPair(alice); mptAlice.generateKeyPair(bob); mptAlice.set({.account = alice, .issuerPubKey = mptAlice.getPubKey(alice)}); mptAlice.convert({ .account = bob, .amt = 10, .holderPubKey = mptAlice.getPubKey(bob), .err = tecOBJECT_NOT_FOUND, }); } // Verification of Issuer and and holder ciphertexts { Env env{*this, features}; Account const alice("alice"); Account const bob("bob"); Account const carol("carol"); MPTTester mptAlice(env, alice, {.holders = {bob, carol}}); mptAlice.create({ .ownerCount = 1, .flags = tfMPTCanTransfer | tfMPTCanLock | tfMPTCanHoldConfidentialBalance, }); mptAlice.authorize({ .account = bob, }); mptAlice.pay(alice, bob, 100); mptAlice.generateKeyPair(alice); mptAlice.generateKeyPair(bob); mptAlice.generateKeyPair(carol); mptAlice.set({.account = alice, .issuerPubKey = mptAlice.getPubKey(alice)}); mptAlice.convert({ .account = bob, .amt = 10, .holderPubKey = mptAlice.getPubKey(bob), .holderEncryptedAmt = getTrivialCiphertext(), .err = tecBAD_PROOF, }); mptAlice.convert({ .account = bob, .amt = 10, .holderPubKey = mptAlice.getPubKey(bob), .issuerEncryptedAmt = getTrivialCiphertext(), .err = tecBAD_PROOF, }); std::uint64_t const amount = 10; Buffer const blindingFactor = generateBlindingFactor(); Buffer const holderCiphertext = mptAlice.encryptAmount(bob, amount, blindingFactor); // Holder ciphertext is valid for the amount and // blinding factor, but the issuer ciphertext is encrypted under a // different public key than the registered issuer key. Buffer const wrongIssuerCiphertext = mptAlice.encryptAmount(carol, amount, blindingFactor); mptAlice.convert({ .account = bob, .amt = amount, .holderPubKey = mptAlice.getPubKey(bob), .holderEncryptedAmt = holderCiphertext, .issuerEncryptedAmt = wrongIssuerCiphertext, .blindingFactor = blindingFactor, .err = tecBAD_PROOF, }); } // trying to convert more than what bob has { Env env{*this, features}; Account const alice("alice"); Account const bob("bob"); MPTTester mptAlice(env, alice, {.holders = {bob}}); mptAlice.create({ .ownerCount = 1, .flags = tfMPTCanTransfer | tfMPTCanLock | tfMPTCanHoldConfidentialBalance, }); mptAlice.authorize({ .account = bob, }); mptAlice.pay(alice, bob, 100); mptAlice.generateKeyPair(alice); mptAlice.set({.account = alice, .issuerPubKey = mptAlice.getPubKey(alice)}); mptAlice.generateKeyPair(bob); mptAlice.convert({ .account = bob, .amt = 200, .holderPubKey = mptAlice.getPubKey(bob), .err = tecINSUFFICIENT_FUNDS, }); } // holder cannot upload pk again { Env env{*this, features}; Account const alice("alice"); Account const bob("bob"); MPTTester mptAlice(env, alice, {.holders = {bob}}); mptAlice.create({ .ownerCount = 1, .flags = tfMPTCanTransfer | tfMPTCanLock | tfMPTCanHoldConfidentialBalance, }); mptAlice.authorize({ .account = bob, }); mptAlice.pay(alice, bob, 100); mptAlice.generateKeyPair(alice); mptAlice.set({.account = alice, .issuerPubKey = mptAlice.getPubKey(alice)}); mptAlice.generateKeyPair(bob); mptAlice.convert({.account = bob, .amt = 10, .holderPubKey = mptAlice.getPubKey(bob)}); // cannot upload pk again mptAlice.convert({ .account = bob, .amt = 10, .holderPubKey = mptAlice.getPubKey(bob), .err = tecDUPLICATE, }); } // cannot convert if locked { Env env{*this, features}; Account const alice("alice"); Account const bob("bob"); MPTTester mptAlice(env, alice, {.holders = {bob}}); mptAlice.create({ .ownerCount = 1, .flags = tfMPTCanTransfer | tfMPTCanLock | tfMPTCanHoldConfidentialBalance, }); mptAlice.authorize({ .account = bob, }); mptAlice.pay(alice, bob, 100); mptAlice.generateKeyPair(alice); mptAlice.set({.account = alice, .issuerPubKey = mptAlice.getPubKey(alice)}); mptAlice.set({ .account = alice, .holder = bob, .flags = tfMPTLock, }); mptAlice.generateKeyPair(bob); mptAlice.convert({ .account = bob, .amt = 10, .holderPubKey = mptAlice.getPubKey(bob), .err = tecLOCKED, }); mptAlice.set({ .account = alice, .holder = bob, .flags = tfMPTUnlock, }); mptAlice.convert({ .account = bob, .amt = 10, .holderPubKey = mptAlice.getPubKey(bob), }); } // cannot convert if unauth { Env env{*this, features}; Account const alice("alice"); Account const bob("bob"); MPTTester mptAlice(env, alice, {.holders = {bob}}); mptAlice.create({ .ownerCount = 1, .flags = tfMPTCanTransfer | tfMPTCanLock | tfMPTRequireAuth | tfMPTCanHoldConfidentialBalance, }); mptAlice.authorize({ .account = bob, }); mptAlice.authorize({ .account = alice, .holder = bob, }); mptAlice.pay(alice, bob, 100); mptAlice.generateKeyPair(alice); mptAlice.set({.account = alice, .issuerPubKey = mptAlice.getPubKey(alice)}); mptAlice.generateKeyPair(bob); // Unauthorize bob mptAlice.authorize({ .account = alice, .holder = bob, .flags = tfMPTUnauthorize, }); mptAlice.convert({ .account = bob, .amt = 10, .holderPubKey = mptAlice.getPubKey(bob), .err = tecNO_AUTH, }); // auth bob mptAlice.authorize({ .account = alice, .holder = bob, }); mptAlice.convert({ .account = bob, .amt = 10, .holderPubKey = mptAlice.getPubKey(bob), }); } // frozen account cannot bypass freeze check with amount=0 { Env env{*this, features}; Account const alice("alice"); Account const bob("bob"); MPTTester mptAlice(env, alice, {.holders = {bob}}); mptAlice.create({ .ownerCount = 1, .flags = tfMPTCanTransfer | tfMPTCanLock | tfMPTCanHoldConfidentialBalance, }); mptAlice.authorize({ .account = bob, }); mptAlice.pay(alice, bob, 100); mptAlice.generateKeyPair(alice); mptAlice.set({.account = alice, .issuerPubKey = mptAlice.getPubKey(alice)}); // lock bob mptAlice.set({ .account = alice, .holder = bob, .flags = tfMPTLock, }); mptAlice.generateKeyPair(bob); // amount=0 should still be rejected when locked mptAlice.convert({ .account = bob, .amt = 0, .holderPubKey = mptAlice.getPubKey(bob), .err = tecLOCKED, }); } // unauthorized account cannot bypass auth check with amount=0 { Env env{*this, features}; Account const alice("alice"); Account const bob("bob"); MPTTester mptAlice(env, alice, {.holders = {bob}}); mptAlice.create({ .ownerCount = 1, .flags = tfMPTCanTransfer | tfMPTCanLock | tfMPTRequireAuth | tfMPTCanHoldConfidentialBalance, }); mptAlice.authorize({ .account = bob, }); mptAlice.authorize({ .account = alice, .holder = bob, }); mptAlice.pay(alice, bob, 100); mptAlice.generateKeyPair(alice); mptAlice.set({.account = alice, .issuerPubKey = mptAlice.getPubKey(alice)}); mptAlice.generateKeyPair(bob); // Unauthorize bob mptAlice.authorize({ .account = alice, .holder = bob, .flags = tfMPTUnauthorize, }); // amount=0 should still be rejected when unauthorized mptAlice.convert({ .account = bob, .amt = 0, .holderPubKey = mptAlice.getPubKey(bob), .err = tecNO_AUTH, }); } // cannot convert if auditor key is set, but auditor amount is not // provided { Env env{*this, features}; Account const alice("alice"); Account const bob("bob"); Account const auditor("auditor"); MPTTester mptAlice( env, alice, { .holders = {bob}, .auditor = auditor, }); mptAlice.create({ .ownerCount = 1, .flags = tfMPTCanTransfer | tfMPTCanLock | tfMPTCanHoldConfidentialBalance, }); mptAlice.authorize({ .account = bob, }); mptAlice.pay(alice, bob, 100); mptAlice.generateKeyPair(alice); mptAlice.generateKeyPair(bob); mptAlice.generateKeyPair(auditor); mptAlice.set( {.account = alice, .issuerPubKey = mptAlice.getPubKey(alice), .auditorPubKey = mptAlice.getPubKey(auditor)}); // no auditor encrypted amt provided mptAlice.convert({ .account = bob, .amt = 10, .fillAuditorEncryptedAmt = false, .holderPubKey = mptAlice.getPubKey(bob), .err = tecNO_PERMISSION, }); } // cannot convert if tx include auditor ciphertext, but does not have // auditing enabled { Env env{*this, features}; Account const alice("alice"); Account const bob("bob"); MPTTester mptAlice(env, alice, {.holders = {bob}}); mptAlice.create({ .ownerCount = 1, .flags = tfMPTCanTransfer | tfMPTCanLock | tfMPTCanHoldConfidentialBalance, }); mptAlice.authorize({ .account = bob, }); mptAlice.pay(alice, bob, 100); mptAlice.generateKeyPair(alice); mptAlice.generateKeyPair(bob); // there is no auditor key set mptAlice.set({.account = alice, .issuerPubKey = mptAlice.getPubKey(alice)}); mptAlice.convert({ .account = bob, .amt = 10, .holderPubKey = mptAlice.getPubKey(bob), .auditorEncryptedAmt = getTrivialCiphertext(), .err = tecNO_PERMISSION, }); } // Auditor key set successfully, auditor ciphertext mathematically // correct, but contains invalid data (mismatching amount). { Env env{*this, features}; Account const alice("alice"); Account const bob("bob"); Account const auditor("auditor"); MPTTester mptAlice( env, alice, { .holders = {bob}, .auditor = auditor, }); mptAlice.create({ .flags = tfMPTCanTransfer | tfMPTCanLock | tfMPTCanHoldConfidentialBalance, }); mptAlice.authorize({ .account = bob, }); mptAlice.pay(alice, bob, 100); mptAlice.generateKeyPair(alice); mptAlice.generateKeyPair(bob); mptAlice.generateKeyPair(auditor); mptAlice.set( {.account = alice, .issuerPubKey = mptAlice.getPubKey(alice), .auditorPubKey = mptAlice.getPubKey(auditor)}); mptAlice.convert({ .account = bob, .amt = 10, .holderPubKey = mptAlice.getPubKey(bob), .auditorEncryptedAmt = getTrivialCiphertext(), .err = tecBAD_PROOF, }); } // invalid proof when registering holder pub key { Env env{*this, features}; Account const alice("alice"); Account const bob("bob"); MPTTester mptAlice(env, alice, {.holders = {bob}}); mptAlice.create({ .ownerCount = 1, .flags = tfMPTCanTransfer | tfMPTCanLock | tfMPTCanHoldConfidentialBalance, }); mptAlice.authorize({ .account = bob, }); mptAlice.pay(alice, bob, 100); mptAlice.generateKeyPair(alice); mptAlice.generateKeyPair(bob); mptAlice.set({.account = alice, .issuerPubKey = mptAlice.getPubKey(alice)}); mptAlice.convert({ .account = bob, .amt = 10, .proof = std::string(kEcSchnorrProofLength * 2, 'A'), .holderPubKey = mptAlice.getPubKey(bob), .err = tecBAD_PROOF, }); } // no holder key on ledger and no key in tx { Env env{*this, features}; Account const alice("alice"); Account const bob("bob"); MPTTester mptAlice(env, alice, {.holders = {bob}}); mptAlice.create({ .ownerCount = 1, .flags = tfMPTCanTransfer | tfMPTCanLock | tfMPTCanHoldConfidentialBalance, }); mptAlice.authorize({ .account = bob, }); mptAlice.pay(alice, bob, 100); mptAlice.generateKeyPair(alice); mptAlice.generateKeyPair(bob); mptAlice.set({.account = alice, .issuerPubKey = mptAlice.getPubKey(alice)}); // bob has not registered a holder key, and doesn't provide one mptAlice.convert({ .account = bob, .amt = 10, .err = tecNO_PERMISSION, }); } // all public balance already converted, try to convert more { Env env{*this, features}; Account const alice("alice"); Account const bob("bob"); MPTTester mptAlice(env, alice, {.holders = {bob}}); mptAlice.create({ .ownerCount = 1, .flags = tfMPTCanTransfer | tfMPTCanLock | tfMPTCanHoldConfidentialBalance, }); mptAlice.authorize({ .account = bob, }); mptAlice.pay(alice, bob, 100); mptAlice.generateKeyPair(alice); mptAlice.set({.account = alice, .issuerPubKey = mptAlice.getPubKey(alice)}); mptAlice.generateKeyPair(bob); // convert entire public balance mptAlice.convert({ .account = bob, .amt = 100, .holderPubKey = mptAlice.getPubKey(bob), }); env.require(MptBalance(mptAlice, bob, 0)); // try to convert 1 more — no public balance left mptAlice.convert({ .account = bob, .amt = 1, .err = tecINSUFFICIENT_FUNDS, }); } } void testMergeInbox(FeatureBitset features) { testcase("Merge inbox"); using namespace test::jtx; // Merge with an empty inbox should succeed as a no-op. { Env env{*this, features}; Account const alice("alice"); Account const bob("bob"); MPTTester mptAlice(env, alice, {.holders = {bob}}); mptAlice.create({ .ownerCount = 1, .flags = tfMPTCanTransfer | tfMPTCanLock | tfMPTCanHoldConfidentialBalance, }); mptAlice.authorize({.account = bob}); mptAlice.pay(alice, bob, 100); mptAlice.generateKeyPair(alice); mptAlice.generateKeyPair(bob); mptAlice.set({ .account = alice, .issuerPubKey = mptAlice.getPubKey(alice), }); mptAlice.convert({ .account = bob, .amt = 40, .holderPubKey = mptAlice.getPubKey(bob), }); mptAlice.mergeInbox({.account = bob}); // Inbox is empty after the first merge; the second merge is a no-op. mptAlice.mergeInbox({.account = bob}); } // Makes sure if merge inbox version is UINT32_MAX, the next merge wraps // the version back to 0. { Env env{*this, features}; Account const alice("alice"); Account const bob("bob"); MPTTester mptAlice(env, alice, {.holders = {bob}}); mptAlice.create({ .ownerCount = 1, .flags = tfMPTCanTransfer | tfMPTCanLock | tfMPTCanHoldConfidentialBalance, }); mptAlice.authorize({.account = bob}); mptAlice.pay(alice, bob, 100); mptAlice.generateKeyPair(alice); mptAlice.generateKeyPair(bob); mptAlice.set({ .account = alice, .issuerPubKey = mptAlice.getPubKey(alice), }); mptAlice.convert({ .account = bob, .amt = 40, .holderPubKey = mptAlice.getPubKey(bob), }); // Force the on-ledger version to UINT32_MAX, then apply a merge and // confirm the version wraps around to 0. auto const wrappedFrom = std::numeric_limits::max(); auto const jt = env.jt(mptAlice.mergeInboxJV({.account = bob})); BEAST_EXPECT(env.app().getOpenLedger().modify([&](OpenView& view, beast::Journal) { auto const sle = std::const_pointer_cast( view.read(keylet::mptoken(mptAlice.issuanceID(), bob.id()))); if (!sle) return false; (*sle)[sfConfidentialBalanceVersion] = wrappedFrom; view.rawReplace(sle); auto const result = xrpl::apply(env.app(), view, *jt.stx, TapNone, env.journal); BEAST_EXPECT(result.ter == tesSUCCESS); return result.applied; })); BEAST_EXPECT(mptAlice.getMPTokenVersion(bob) == 0); } } void testMergeInboxPreflight(FeatureBitset features) { testcase("Merge inbox preflight"); using namespace test::jtx; Env env{*this, features}; Account const alice("alice"); Account const bob("bob"); MPTTester mptAlice(env, alice, {.holders = {bob}}); mptAlice.create({ .ownerCount = 1, .flags = tfMPTCanTransfer | tfMPTCanLock | tfMPTCanHoldConfidentialBalance, }); mptAlice.authorize({ .account = bob, }); mptAlice.pay(alice, bob, 100); mptAlice.generateKeyPair(alice); mptAlice.set({.account = alice, .issuerPubKey = mptAlice.getPubKey(alice)}); mptAlice.generateKeyPair(bob); mptAlice.convert({ .account = bob, .amt = 40, .holderPubKey = mptAlice.getPubKey(bob), }); mptAlice.mergeInbox({ .account = alice, .err = temMALFORMED, }); env.disableFeature(featureConfidentialTransfer); env.close(); mptAlice.mergeInbox({ .account = bob, .err = temDISABLED, }); } void testMergeInboxPreclaim(FeatureBitset features) { testcase("Merge inbox preclaim"); using namespace test::jtx; // issuance does not exist { Env env{*this, features}; Account const alice("alice"); Account const bob("bob"); MPTTester mptAlice(env, alice, {.holders = {bob}}); mptAlice.create({ .ownerCount = 1, .flags = tfMPTCanTransfer | tfMPTCanLock | tfMPTCanHoldConfidentialBalance, }); mptAlice.authorize({ .account = bob, }); mptAlice.generateKeyPair(alice); mptAlice.set({.account = alice, .issuerPubKey = mptAlice.getPubKey(alice)}); mptAlice.destroy(); mptAlice.generateKeyPair(bob); mptAlice.mergeInbox({ .account = bob, .err = tecOBJECT_NOT_FOUND, }); } // tfMPTCanHoldConfidentialBalance is not set on issuance { Env env{*this, features}; Account const alice("alice"); Account const bob("bob"); MPTTester mptAlice(env, alice, {.holders = {bob}}); mptAlice.create({ .ownerCount = 1, .flags = tfMPTCanTransfer | tfMPTCanLock, }); mptAlice.authorize({ .account = bob, }); mptAlice.pay(alice, bob, 100); mptAlice.generateKeyPair(alice); mptAlice.generateKeyPair(bob); mptAlice.mergeInbox({ .account = bob, .err = tecNO_PERMISSION, }); } // no mptoken { Env env{*this, features}; Account const alice("alice"); Account const bob("bob"); MPTTester mptAlice(env, alice, {.holders = {bob}}); mptAlice.create({ .ownerCount = 1, .flags = tfMPTCanTransfer | tfMPTCanLock | tfMPTCanHoldConfidentialBalance, }); mptAlice.generateKeyPair(alice); mptAlice.set({.account = alice, .issuerPubKey = mptAlice.getPubKey(alice)}); mptAlice.mergeInbox({ .account = bob, .err = tecOBJECT_NOT_FOUND, }); } // bob doesn't have encrypted balances { Env env{*this, features}; Account const alice("alice"); Account const bob("bob"); MPTTester mptAlice(env, alice, {.holders = {bob}}); mptAlice.create({ .ownerCount = 1, .flags = tfMPTCanTransfer | tfMPTCanLock | tfMPTCanHoldConfidentialBalance, }); mptAlice.authorize({ .account = bob, }); mptAlice.pay(alice, bob, 100); mptAlice.generateKeyPair(alice); mptAlice.set({.account = alice, .issuerPubKey = mptAlice.getPubKey(alice)}); mptAlice.generateKeyPair(bob); mptAlice.mergeInbox({ .account = bob, .err = tecNO_PERMISSION, }); } // holder is locked { Env env{*this, features}; Account const alice("alice"); Account const bob("bob"); MPTTester mptAlice(env, alice, {.holders = {bob}}); mptAlice.create({ .ownerCount = 1, .flags = tfMPTCanTransfer | tfMPTCanLock | tfMPTCanHoldConfidentialBalance, }); mptAlice.authorize({ .account = bob, }); mptAlice.pay(alice, bob, 100); mptAlice.generateKeyPair(alice); mptAlice.generateKeyPair(bob); mptAlice.set({.account = alice, .issuerPubKey = mptAlice.getPubKey(alice)}); mptAlice.convert({ .account = bob, .amt = 50, .holderPubKey = mptAlice.getPubKey(bob), }); // lock bob mptAlice.set({ .account = alice, .holder = bob, .flags = tfMPTLock, }); mptAlice.mergeInbox({ .account = bob, .err = tecLOCKED, }); // unlock bob mptAlice.set({ .account = alice, .holder = bob, .flags = tfMPTUnlock, }); // should succeed now mptAlice.mergeInbox({ .account = bob, }); } // holder not authorized { Env env{*this, features}; Account const alice("alice"); Account const bob("bob"); MPTTester mptAlice(env, alice, {.holders = {bob}}); mptAlice.create({ .ownerCount = 1, .flags = tfMPTCanTransfer | tfMPTCanLock | tfMPTCanHoldConfidentialBalance | tfMPTRequireAuth, }); mptAlice.authorize({ .account = bob, }); mptAlice.authorize({ .account = alice, .holder = bob, }); mptAlice.pay(alice, bob, 100); mptAlice.generateKeyPair(alice); mptAlice.generateKeyPair(bob); mptAlice.set({.account = alice, .issuerPubKey = mptAlice.getPubKey(alice)}); mptAlice.convert({ .account = bob, .amt = 50, .holderPubKey = mptAlice.getPubKey(bob), }); // unauthorize bob mptAlice.authorize({ .account = alice, .holder = bob, .flags = tfMPTUnauthorize, }); mptAlice.mergeInbox({ .account = bob, .err = tecNO_AUTH, }); // authorize bob again mptAlice.authorize({ .account = alice, .holder = bob, }); // should succeed now mptAlice.mergeInbox({ .account = bob, }); } } void testSend(FeatureBitset features) { testcase("test confidential send"); using namespace test::jtx; Env env{*this, features}; Account const alice("alice"), bob("bob"), carol("carol"); ConfidentialEnv confEnv{ env, alice, {{.account = bob, .payAmount = 100, .convertAmount = 60}, {.account = carol, .payAmount = 50, .convertAmount = 20}}}; auto& mptAlice = confEnv.mpt; // bob sends 10 to carol mptAlice.send({ .account = bob, .dest = carol, .amt = 10, }); // bob sends 1 to carol again mptAlice.send({ .account = bob, .dest = carol, .amt = 1, }); mptAlice.mergeInbox({ .account = carol, }); // carol sends 15 back to bob mptAlice.send({ .account = carol, .dest = bob, .amt = 15, }); } void testSendWithAuditor(FeatureBitset features) { testcase("test confidential send with auditor"); using namespace test::jtx; Env env{*this, features}; Account const alice("alice"); Account const bob("bob"); Account const carol("carol"); Account const auditor("auditor"); ConfidentialEnv confEnv{ env, alice, {{.account = bob, .payAmount = 100, .convertAmount = 60}, {.account = carol, .payAmount = 50, .convertAmount = 20}}, tfMPTCanTransfer | tfMPTCanLock | tfMPTCanHoldConfidentialBalance, auditor}; auto& mptAlice = confEnv.mpt; // bob sends 10 to carol mptAlice.send({ .account = bob, .dest = carol, .amt = 10, }); // bob sends 1 to carol again mptAlice.send({ .account = bob, .dest = carol, .amt = 1, }); mptAlice.mergeInbox({ .account = carol, }); // carol sends 15 back to bob mptAlice.send({ .account = carol, .dest = bob, .amt = 15, }); } void testSendPreflight(FeatureBitset features) { testcase("test ConfidentialMPTSend Preflight"); using namespace test::jtx; // test disabled { Env env{*this, features - featureConfidentialTransfer}; Account const alice("alice"); Account const bob("bob"); Account const carol("carol"); MPTTester mptAlice(env, alice, {.holders = {bob, carol}}); mptAlice.create(); mptAlice.authorize({ .account = bob, }); mptAlice.authorize({ .account = carol, }); mptAlice.send({ .account = bob, .dest = carol, .amt = 10, .senderEncryptedAmt = gMakeZeroBuffer(kEcGamalEncryptedTotalLength), .destEncryptedAmt = gMakeZeroBuffer(kEcGamalEncryptedTotalLength), .issuerEncryptedAmt = gMakeZeroBuffer(kEcGamalEncryptedTotalLength), .err = temDISABLED, }); } // test malformed { Env env{*this, features}; Account const alice("alice"); Account const bob("bob"); Account const carol("carol"); MPTTester mptAlice(env, alice, {.holders = {bob, carol}}); mptAlice.create({ .ownerCount = 1, .flags = tfMPTCanTransfer | tfMPTCanHoldConfidentialBalance, }); mptAlice.authorize({ .account = bob, }); mptAlice.authorize({ .account = carol, }); mptAlice.generateKeyPair(alice); mptAlice.generateKeyPair(bob); mptAlice.generateKeyPair(carol); mptAlice.set({.account = alice, .issuerPubKey = mptAlice.getPubKey(alice)}); mptAlice.pay(alice, bob, 100); mptAlice.pay(alice, carol, 50); mptAlice.convert({ .account = bob, .amt = 50, .holderPubKey = mptAlice.getPubKey(bob), }); mptAlice.convert({ .account = carol, .amt = 40, .holderPubKey = mptAlice.getPubKey(carol), }); // issuer can not be the same as sender mptAlice.send({ .account = alice, .dest = carol, .amt = 10, .err = temMALFORMED, }); // can not send to self mptAlice.send({ .account = bob, .dest = bob, .amt = 10, .err = temMALFORMED, }); // can not send to issuer mptAlice.send({ .account = bob, .dest = alice, .amt = 10, .err = temMALFORMED, }); // sender encrypted amount wrong length mptAlice.send({ .account = bob, .dest = carol, .amt = 10, .senderEncryptedAmt = gMakeZeroBuffer(10), .err = temBAD_CIPHERTEXT, }); // dest encrypted amount wrong length mptAlice.send({ .account = bob, .dest = carol, .amt = 10, .destEncryptedAmt = gMakeZeroBuffer(10), .err = temBAD_CIPHERTEXT, }); // issuer encrypted amount wrong length mptAlice.send({ .account = bob, .dest = carol, .amt = 10, .issuerEncryptedAmt = gMakeZeroBuffer(10), .err = temBAD_CIPHERTEXT, }); // sender encrypted amount malformed mptAlice.send({ .account = bob, .dest = carol, .amt = 10, .proof = getTrivialSendProofHex(), .senderEncryptedAmt = gMakeZeroBuffer(kEcGamalEncryptedTotalLength), .amountCommitment = getTrivialCommitment(), .balanceCommitment = getTrivialCommitment(), .err = temBAD_CIPHERTEXT, }); // dest encrypted amount malformed mptAlice.send({ .account = bob, .dest = carol, .amt = 10, .proof = getTrivialSendProofHex(), .destEncryptedAmt = gMakeZeroBuffer(kEcGamalEncryptedTotalLength), .amountCommitment = getTrivialCommitment(), .balanceCommitment = getTrivialCommitment(), .err = temBAD_CIPHERTEXT, }); // issuer encrypted amount malformed mptAlice.send({ .account = bob, .dest = carol, .amt = 10, .proof = getTrivialSendProofHex(), .issuerEncryptedAmt = gMakeZeroBuffer(kEcGamalEncryptedTotalLength), .amountCommitment = getTrivialCommitment(), .balanceCommitment = getTrivialCommitment(), .err = temBAD_CIPHERTEXT, }); // invalid proof length mptAlice.send({ .account = bob, .dest = carol, .amt = 10, .proof = std::string(10, 'A'), .amountCommitment = getTrivialCommitment(), .balanceCommitment = getTrivialCommitment(), .err = temMALFORMED, }); // invalid amount Pedersen commitment length mptAlice.send({ .account = bob, .dest = carol, .amt = 10, .proof = getTrivialSendProofHex(), .amountCommitment = gMakeZeroBuffer(100), .balanceCommitment = getTrivialCommitment(), .err = temMALFORMED, }); // invalid balance Pedersen commitment length mptAlice.send({ .account = bob, .dest = carol, .amt = 10, .proof = getTrivialSendProofHex(), .amountCommitment = getTrivialCommitment(), .balanceCommitment = gMakeZeroBuffer(100), .err = temMALFORMED, }); // amount Pedersen commitment has correct length but invalid EC point data mptAlice.send({ .account = bob, .dest = carol, .amt = 10, .proof = getTrivialSendProofHex(), .amountCommitment = gMakeZeroBuffer(kEcPedersenCommitmentLength), .balanceCommitment = getTrivialCommitment(), .err = temMALFORMED, }); // balance Pedersen commitment has correct length but invalid EC point data mptAlice.send({ .account = bob, .dest = carol, .amt = 10, .proof = getTrivialSendProofHex(), .amountCommitment = getTrivialCommitment(), .balanceCommitment = gMakeZeroBuffer(kEcPedersenCommitmentLength), .err = temMALFORMED, }); } // test bad ciphertext { Env env{*this, features}; Account const alice("alice"); Account const bob("bob"); Account const carol("carol"); Account const auditor("auditor"); MPTTester mptAlice( env, alice, { .holders = {bob, carol}, .auditor = auditor, }); mptAlice.create({ .ownerCount = 1, .flags = tfMPTCanTransfer | tfMPTCanHoldConfidentialBalance, }); mptAlice.authorize({ .account = bob, }); mptAlice.authorize({ .account = carol, }); mptAlice.generateKeyPair(alice); mptAlice.generateKeyPair(bob); mptAlice.generateKeyPair(carol); mptAlice.generateKeyPair(auditor); mptAlice.set( {.account = alice, .issuerPubKey = mptAlice.getPubKey(alice), .auditorPubKey = mptAlice.getPubKey(auditor)}); mptAlice.pay(alice, bob, 100); mptAlice.pay(alice, carol, 50); mptAlice.convert({ .account = bob, .amt = 50, .holderPubKey = mptAlice.getPubKey(bob), }); mptAlice.convert({ .account = carol, .amt = 40, .holderPubKey = mptAlice.getPubKey(carol), }); // auditor encrypted amount wrong length mptAlice.send({ .account = bob, .dest = carol, .amt = 10, .proof = getTrivialSendProofHex(), .auditorEncryptedAmt = gMakeZeroBuffer(10), .amountCommitment = getTrivialCommitment(), .balanceCommitment = getTrivialCommitment(), .err = temBAD_CIPHERTEXT, }); // auditor encrypted amount (correct length, invalid data) mptAlice.send({ .account = bob, .dest = carol, .amt = 10, .proof = getTrivialSendProofHex(), .auditorEncryptedAmt = getBadCiphertext(), .amountCommitment = getTrivialCommitment(), .balanceCommitment = getTrivialCommitment(), .err = temBAD_CIPHERTEXT, }); } } void testSendPreclaim(FeatureBitset features) { testcase("test ConfidentialMPTSend Preclaim"); using namespace test::jtx; Env env{*this, features}; Account const alice("alice"); Account const bob("bob"); Account const carol("carol"); Account const dave("dave"); Account const eve("eve"); MPTTester mptAlice(env, alice, {.holders = {bob, carol, dave, eve}}); // authorize bob, carol, dave (not eve) mptAlice.create({ .flags = tfMPTCanTransfer | tfMPTCanLock | tfMPTRequireAuth | tfMPTCanHoldConfidentialBalance, }); mptAlice.authorize({ .account = bob, }); mptAlice.authorize({ .account = alice, .holder = bob, }); mptAlice.authorize({ .account = carol, }); mptAlice.authorize({ .account = alice, .holder = carol, }); mptAlice.authorize({ .account = dave, }); mptAlice.authorize({ .account = alice, .holder = dave, }); // fund bob, carol (not dave or eve) mptAlice.pay(alice, bob, 100); mptAlice.pay(alice, carol, 50); mptAlice.generateKeyPair(alice); mptAlice.generateKeyPair(bob); mptAlice.generateKeyPair(carol); mptAlice.generateKeyPair(dave); mptAlice.set({.account = alice, .issuerPubKey = mptAlice.getPubKey(alice)}); // bob and carol convert some funds to confidential mptAlice.convert({ .account = bob, .amt = 60, .holderPubKey = mptAlice.getPubKey(bob), .err = tesSUCCESS, }); mptAlice.convert({ .account = carol, .amt = 20, .holderPubKey = mptAlice.getPubKey(carol), .err = tesSUCCESS, }); // bob and carol merge inbox mptAlice.mergeInbox({ .account = bob, }); mptAlice.mergeInbox({ .account = carol, }); // issuance not found { Env env{*this, features}; Account const alice("alice"); Account const bob("bob"); Account const carol("carol"); MPTTester mptAlice(env, alice, {.holders = {bob, carol}}); mptAlice.create({ .flags = tfMPTCanTransfer | tfMPTCanHoldConfidentialBalance, }); mptAlice.authorize({ .account = bob, }); mptAlice.authorize({ .account = carol, }); mptAlice.generateKeyPair(alice); mptAlice.set({.account = alice, .issuerPubKey = mptAlice.getPubKey(alice)}); // destroy the issuance mptAlice.destroy(); json::Value jv; jv[jss::Account] = bob.human(); jv[jss::Destination] = carol.human(); jv[jss::TransactionType] = jss::ConfidentialMPTSend; jv[sfMPTokenIssuanceID] = to_string(mptAlice.issuanceID()); jv[sfSenderEncryptedAmount] = strHex(getTrivialCiphertext()); jv[sfDestinationEncryptedAmount] = strHex(getTrivialCiphertext()); jv[sfIssuerEncryptedAmount] = strHex(getTrivialCiphertext()); jv[sfAmountCommitment] = strHex(getTrivialCommitment()); jv[sfBalanceCommitment] = strHex(getTrivialCommitment()); jv[sfZKProof] = getTrivialSendProofHex(); env(jv, Ter(tecOBJECT_NOT_FOUND)); } // destination does not exist { Account const unknown("unknown"); mptAlice.send({ .account = bob, .dest = unknown, .amt = 10, .proof = getTrivialSendProofHex(), .senderEncryptedAmt = getTrivialCiphertext(), .destEncryptedAmt = getTrivialCiphertext(), .issuerEncryptedAmt = getTrivialCiphertext(), .amountCommitment = getTrivialCommitment(), .balanceCommitment = getTrivialCommitment(), .err = tecNO_TARGET, }); } // destination requires destination tag but none provided { env(fset(carol, asfRequireDest)); env.close(); mptAlice.send({ .account = bob, .dest = carol, .amt = 10, .proof = getTrivialSendProofHex(), .senderEncryptedAmt = getTrivialCiphertext(), .destEncryptedAmt = getTrivialCiphertext(), .issuerEncryptedAmt = getTrivialCiphertext(), .amountCommitment = getTrivialCommitment(), .balanceCommitment = getTrivialCommitment(), .err = tecDST_TAG_NEEDED, }); env(fclear(carol, asfRequireDest)); env.close(); } // dave exists, but has no confidential fields (never converted) { mptAlice.send({ .account = bob, .dest = dave, .amt = 10, .proof = getTrivialSendProofHex(), .senderEncryptedAmt = getTrivialCiphertext(), .destEncryptedAmt = getTrivialCiphertext(), .issuerEncryptedAmt = getTrivialCiphertext(), .amountCommitment = getTrivialCommitment(), .balanceCommitment = getTrivialCommitment(), .err = tecNO_PERMISSION, }); mptAlice.send({ .account = dave, .dest = carol, .amt = 10, .proof = getTrivialSendProofHex(), .senderEncryptedAmt = getTrivialCiphertext(), .destEncryptedAmt = getTrivialCiphertext(), .issuerEncryptedAmt = getTrivialCiphertext(), .amountCommitment = getTrivialCommitment(), .balanceCommitment = getTrivialCommitment(), .err = tecNO_PERMISSION, }); } // destination exists but has no MPT object. { mptAlice.send({ .account = bob, .dest = eve, .amt = 10, .proof = getTrivialSendProofHex(), .senderEncryptedAmt = getTrivialCiphertext(), .destEncryptedAmt = getTrivialCiphertext(), .issuerEncryptedAmt = getTrivialCiphertext(), .amountCommitment = getTrivialCommitment(), .balanceCommitment = getTrivialCommitment(), .err = tecOBJECT_NOT_FOUND, }); } // issuance is locked globally { // lock issuance mptAlice.set({ .account = alice, .flags = tfMPTLock, }); mptAlice.send({ .account = bob, .dest = carol, .amt = 10, .err = tecLOCKED, }); // unlock issuance mptAlice.set({ .account = alice, .flags = tfMPTUnlock, }); // now can send mptAlice.send({ .account = bob, .dest = carol, .amt = 1, }); } // sender is locked { // lock bob mptAlice.set({ .account = alice, .holder = bob, .flags = tfMPTLock, }); mptAlice.send({ .account = bob, .dest = carol, .amt = 10, .err = tecLOCKED, }); // unlock bob mptAlice.set({ .account = alice, .holder = bob, .flags = tfMPTUnlock, }); // now can send mptAlice.send({ .account = bob, .dest = carol, .amt = 2, }); } // destination is locked { // lock carol mptAlice.set({ .account = alice, .holder = carol, .flags = tfMPTLock, }); mptAlice.send({ .account = bob, .dest = carol, .amt = 10, .err = tecLOCKED, }); // unlock carol mptAlice.set({ .account = alice, .holder = carol, .flags = tfMPTUnlock, }); // now can send mptAlice.send({ .account = bob, .dest = carol, .amt = 3, }); } // sender not authorized { // unauthorize bob mptAlice.authorize({ .account = alice, .holder = bob, .flags = tfMPTUnauthorize, }); mptAlice.send({ .account = bob, .dest = carol, .amt = 10, .err = tecNO_AUTH, }); // authorize bob again mptAlice.authorize({ .account = alice, .holder = bob, }); // now can send mptAlice.send({ .account = bob, .dest = carol, .amt = 4, }); } // destination not authorized { // unauthorize carol mptAlice.authorize({ .account = alice, .holder = carol, .flags = tfMPTUnauthorize, }); mptAlice.send({ .account = bob, .dest = carol, .amt = 10, .err = tecNO_AUTH, }); // authorize carol again mptAlice.authorize({ .account = alice, .holder = carol, }); // now can send mptAlice.send({ .account = bob, .dest = carol, .amt = 5, }); } // cannot send when MPTCanTransfer is not set { Env env{*this, features}; Account const alice("alice"); Account const bob("bob"); Account const carol("carol"); ConfidentialEnv confEnv{ env, alice, {{.account = bob, .payAmount = 100, .convertAmount = 60}, {.account = carol, .payAmount = 50, .convertAmount = 20}}, tfMPTCanLock | tfMPTCanHoldConfidentialBalance}; auto& mptAlice = confEnv.mpt; // bob sends 10 to carol mptAlice.send({ .account = bob, .dest = carol, .amt = 10, // will be encrypted internally .err = tecNO_AUTH, }); } // Confidential MPTs should not have a transfer fee. Force malformed // ledger state to cover the defensive preclaim check. { Env env{*this, features}; Account const alice("alice"); Account const bob("bob"); Account const carol("carol"); ConfidentialEnv confEnv{ env, alice, {{.account = bob, .payAmount = 100, .convertAmount = 60}, {.account = carol, .payAmount = 50, .convertAmount = 20}}}; auto& mptAlice = confEnv.mpt; BEAST_EXPECT(env.app().getOpenLedger().modify([&](OpenView& view, beast::Journal) { auto const issuance = std::const_pointer_cast( view.read(keylet::mptokenIssuance(mptAlice.issuanceID()))); if (!issuance) return false; issuance->setFieldU16(sfTransferFee, 1); view.rawReplace(issuance); return true; })); mptAlice.send({ .account = bob, .dest = carol, .amt = 10, .proof = getTrivialSendProofHex(), .err = tecNO_PERMISSION, }); } // bad proof { Env env{*this, features}; Account const alice("alice"); Account const bob("bob"); Account const carol("carol"); ConfidentialEnv confEnv{ env, alice, {{.account = bob, .payAmount = 100, .convertAmount = 60}, {.account = carol, .payAmount = 50, .convertAmount = 20}}}; auto& mptAlice = confEnv.mpt; mptAlice.send({ .account = bob, .dest = carol, .amt = 10, .proof = getTrivialSendProofHex(), .err = tecBAD_PROOF, }); } // No Auditor key set, but auditor encrypted amt provided { mptAlice.send({ .account = bob, .dest = carol, .amt = 10, .proof = getTrivialSendProofHex(), .auditorEncryptedAmt = getTrivialCiphertext(), .err = tecNO_PERMISSION, }); } // Auditor CipherText is Valid, but does not match the Txn Amount { Env env{*this, features}; Account const alice("alice"); Account const bob("bob"); Account const carol("carol"); Account const auditor("auditor"); MPTTester mptAlice( env, alice, { .holders = {bob, carol}, .auditor = auditor, }); mptAlice.create({ .ownerCount = 1, .flags = tfMPTCanTransfer | tfMPTCanHoldConfidentialBalance, }); mptAlice.authorize({ .account = bob, }); mptAlice.authorize({ .account = carol, }); mptAlice.generateKeyPair(alice); mptAlice.generateKeyPair(bob); mptAlice.generateKeyPair(carol); mptAlice.generateKeyPair(auditor); mptAlice.set( {.account = alice, .issuerPubKey = mptAlice.getPubKey(alice), .auditorPubKey = mptAlice.getPubKey(auditor)}); mptAlice.pay(alice, bob, 100); mptAlice.pay(alice, carol, 50); mptAlice.convert({ .account = bob, .amt = 50, .holderPubKey = mptAlice.getPubKey(bob), }); mptAlice.convert({ .account = carol, .amt = 40, .holderPubKey = mptAlice.getPubKey(carol), }); mptAlice.send({ .account = bob, .dest = carol, .amt = 10, .proof = getTrivialSendProofHex(), .auditorEncryptedAmt = getTrivialCiphertext(), .amountCommitment = getTrivialCommitment(), .balanceCommitment = getTrivialCommitment(), .err = tecBAD_PROOF, }); } } void testSendRangeProof(FeatureBitset features) { testcase("test ConfidentialMPTSend Range Proof"); using namespace test::jtx; Env env{*this, features}; Account const alice("alice"), bob("bob"), carol("carol"); ConfidentialEnv confEnv{ env, alice, {{.account = bob, .payAmount = 1000, .convertAmount = 60}, {.account = carol, .payAmount = 1000, .convertAmount = 50}}}; auto& mptAlice = confEnv.mpt; { // Bob has 60, tries to send 70. Invalid remaining balance. mptAlice.send({ .account = bob, .dest = carol, .amt = 70, .err = tecBAD_PROOF, }); // Bob has 60, tries to send 61. Invalid remaining balance. mptAlice.send({ .account = bob, .dest = carol, .amt = 61, .err = tecBAD_PROOF, }); // Bob has 60, sends 60. Remainder is exactly 0. Valid remaining balance. mptAlice.send({ .account = bob, .dest = carol, .amt = 60, .err = tesSUCCESS, }); } { // Bob converts 100. mptAlice.convert({ .account = bob, .amt = 100, }); mptAlice.mergeInbox({ .account = bob, }); // Bob has 100, tries to send 2^64-1. Invalid remaining balance. mptAlice.send({ .account = bob, .dest = carol, .amt = std::numeric_limits::max(), .err = tecBAD_PROOF, }); // Bob sends 1, remaining 99. mptAlice.send({ .account = bob, .dest = carol, .amt = 1, .err = tesSUCCESS, }); // Bob sends 100, but only has 99. Invalid remaining balance. mptAlice.send({ .account = bob, .dest = carol, .amt = 100, .err = tecBAD_PROOF, }); } // send when spending balance is 0 (key registered, inbox merged, but nothing converted) { // Register keys only (amt=0) for both parties — spending stays 0. Env env2{*this, features}; Account const alice2("alice"), bob2("bob"), carol2("carol"); ConfidentialEnv zeroEnv{ env2, alice2, {{.account = bob2, .payAmount = 100, .convertAmount = 0}, {.account = carol2, .payAmount = 50, .convertAmount = 0}}}; auto& mptAlice2 = zeroEnv.mpt; // Trying to send any amount with 0 spending balance must fail: // the range proof for < 0 is invalid. mptAlice2.send({ .account = bob2, .dest = carol2, .amt = 1, .err = tecBAD_PROOF, }); BEAST_EXPECT( mptAlice2.getDecryptedBalance(bob2, MPTTester::holderEncryptedSpending) == 0); } // todo: test m exceeding range, require using scala and refactor } /* The equality proof library and range proof library do not * support generating proofs for amt=0 (they require a positive witness). * To test the VERIFIER without crashing the helper, we bypass normal proof * generation by supplying explicit ciphertexts, commitments, and a dummy * (all-zero) proof. The preflight has no temBAD_AMOUNT guard for * ConfidentialMPTSend, so all validation occurs in verifySendProofs. */ void testSendZeroAmount(FeatureBitset features) { testcase("Send: zero amount — equality and range proof verifier behavior"); using namespace test::jtx; Env env{*this, features}; Account const alice("alice"); Account const bob("bob"); Account const carol("carol"); MPTTester mptAlice(env, alice, {.holders = {bob, carol}}); mptAlice.create({ .ownerCount = 1, .flags = tfMPTCanTransfer | tfMPTCanLock | tfMPTCanHoldConfidentialBalance, }); mptAlice.authorize({.account = bob}); mptAlice.authorize({.account = carol}); mptAlice.pay(alice, bob, 100); mptAlice.pay(alice, carol, 50); mptAlice.generateKeyPair(alice); mptAlice.generateKeyPair(bob); mptAlice.generateKeyPair(carol); mptAlice.set({.account = alice, .issuerPubKey = mptAlice.getPubKey(alice)}); mptAlice.convert({.account = bob, .amt = 100, .holderPubKey = mptAlice.getPubKey(bob)}); mptAlice.mergeInbox({.account = bob}); mptAlice.convert({.account = carol, .amt = 50, .holderPubKey = mptAlice.getPubKey(carol)}); mptAlice.mergeInbox({.account = carol}); Buffer const bf = generateBlindingFactor(); // equality proof verification for amt=0. // Encrypt 0 under each participant's key. The amount commitment is // getTrivialCommitment() — a valid EC point that passes preflight's // isValidCompressedECPoint check but is not the true PC for amt=0. // The dummy ZKProof's equality component must be rejected by // verifyMultiCiphertextEqualityProof. mptAlice.send({ .account = bob, .dest = carol, .amt = 0, .proof = getTrivialSendProofHex(), .senderEncryptedAmt = mptAlice.encryptAmount(bob, 0, bf), .destEncryptedAmt = mptAlice.encryptAmount(carol, 0, bf), .issuerEncryptedAmt = mptAlice.encryptAmount(alice, 0, bf), .amountCommitment = getTrivialCommitment(), .balanceCommitment = getTrivialCommitment(), .err = tecBAD_PROOF, }); // range proof verification for amt=0. // Identical construction; focuses on the bulletproof range check // embedded in ZKProof. The range proof for amount=0 with a dummy // (all-zero) proof must also be rejected. Buffer const bf2 = generateBlindingFactor(); mptAlice.send({ .account = bob, .dest = carol, .amt = 0, .proof = getTrivialSendProofHex(), .senderEncryptedAmt = mptAlice.encryptAmount(bob, 0, bf2), .destEncryptedAmt = mptAlice.encryptAmount(carol, 0, bf2), .issuerEncryptedAmt = mptAlice.encryptAmount(alice, 0, bf2), .amountCommitment = getTrivialCommitment(), .balanceCommitment = getTrivialCommitment(), .err = tecBAD_PROOF, }); // All rejected sends must leave balances unchanged. BEAST_EXPECT(mptAlice.getDecryptedBalance(bob, MPTTester::holderEncryptedSpending) == 100); BEAST_EXPECT(mptAlice.getDecryptedBalance(carol, MPTTester::holderEncryptedInbox) == 0); } void testDelete(FeatureBitset features) { testcase("Delete"); using namespace test::jtx; // cannot delete mptoken where it has encrypted balance { Env env{*this, features}; Account const alice("alice"); Account const bob("bob"); MPTTester mptAlice(env, alice, {.holders = {bob}}); mptAlice.create({ .ownerCount = 1, .flags = tfMPTCanTransfer | tfMPTCanLock | tfMPTCanHoldConfidentialBalance, }); mptAlice.authorize({ .account = bob, }); mptAlice.pay(alice, bob, 100); mptAlice.generateKeyPair(alice); mptAlice.set({.account = alice, .issuerPubKey = mptAlice.getPubKey(alice)}); mptAlice.generateKeyPair(bob); mptAlice.convert({ .account = bob, .amt = 100, .holderPubKey = mptAlice.getPubKey(bob), }); mptAlice.authorize({ .account = bob, .flags = tfMPTUnauthorize, .err = tecHAS_OBLIGATIONS, }); } // cannot delete mptoken where it has encrypted balance { Env env{*this, features}; Account const alice("alice"); Account const bob("bob"); Account const carol("carol"); MPTTester mptAlice(env, alice, {.holders = {bob, carol}}); mptAlice.create({ .ownerCount = 1, .flags = tfMPTCanTransfer | tfMPTCanLock | tfMPTCanHoldConfidentialBalance, }); mptAlice.authorize({ .account = bob, }); mptAlice.authorize({ .account = carol, }); mptAlice.pay(alice, bob, 100); mptAlice.generateKeyPair(alice); mptAlice.set({.account = alice, .issuerPubKey = mptAlice.getPubKey(alice)}); mptAlice.generateKeyPair(bob); mptAlice.generateKeyPair(carol); mptAlice.convert({ .account = bob, .amt = 100, .holderPubKey = mptAlice.getPubKey(bob), }); mptAlice.convert({ .account = carol, .amt = 0, .holderPubKey = mptAlice.getPubKey(carol), }); // carol cannot delete even if he has encrypted zero amount mptAlice.authorize({ .account = carol, .flags = tfMPTUnauthorize, .err = tecHAS_OBLIGATIONS, }); } // can delete mptoken if outstanding confidential balance is zero { Env env{*this, features}; Account const alice("alice"); Account const bob("bob"); MPTTester mptAlice(env, alice, {.holders = {bob}}); mptAlice.create({ .ownerCount = 1, .flags = tfMPTCanTransfer | tfMPTCanLock | tfMPTCanHoldConfidentialBalance, }); mptAlice.authorize({ .account = bob, }); mptAlice.generateKeyPair(alice); mptAlice.set({.account = alice, .issuerPubKey = mptAlice.getPubKey(alice)}); mptAlice.generateKeyPair(bob); mptAlice.convert({ .account = bob, .amt = 0, .holderPubKey = mptAlice.getPubKey(bob), }); mptAlice.authorize({ .account = bob, .flags = tfMPTUnauthorize, }); } // can delete mptoken if issuance has been destroyed and has // encrypted zero balance { Env env{*this, features}; Account const alice("alice"); Account const bob("bob"); MPTTester mptAlice(env, alice, {.holders = {bob}}); mptAlice.create({ .ownerCount = 1, .flags = tfMPTCanTransfer | tfMPTCanLock | tfMPTCanHoldConfidentialBalance, }); mptAlice.authorize({ .account = bob, }); mptAlice.generateKeyPair(alice); mptAlice.set({.account = alice, .issuerPubKey = mptAlice.getPubKey(alice)}); mptAlice.generateKeyPair(bob); mptAlice.convert({ .account = bob, .amt = 0, .holderPubKey = mptAlice.getPubKey(bob), }); mptAlice.destroy(); mptAlice.authorize({ .account = bob, .flags = tfMPTUnauthorize, }); } // test with convert back and delete // can delete mptoken if converted back (COA returns to zero) { Env env{*this, features}; Account const alice("alice"); Account const bob("bob"); ConfidentialEnv confEnv{ env, alice, {{.account = bob, .payAmount = 100, .convertAmount = 100}}}; auto& mptAlice = confEnv.mpt; mptAlice.convertBack({ .account = bob, .amt = 100, }); mptAlice.pay(bob, alice, 100); // Should be able to delete as Confidential Outstanding amount is 0 mptAlice.authorize({ .account = bob, .flags = tfMPTUnauthorize, }); } // removeEmptyHolding: vault share MPToken with confidential balance // fields should not be deleted on VaultWithdraw { Env env{*this, features | featureSingleAssetVault}; Account const issuer("issuer"); Account const owner("owner"); Account const depositor("depositor"); MPTTester mptt{env, issuer, {.holders = {owner, depositor}}}; mptt.create({ .flags = tfMPTCanTransfer | tfMPTCanLock | tfMPTCanClawback, }); PrettyAsset const asset = mptt.issuanceID(); mptt.authorize({.account = owner}); mptt.authorize({.account = depositor}); env(pay(issuer, depositor, asset(1000))); env.close(); test::jtx::Vault const vault{env}; auto [tx, vaultKeylet] = vault.create({.owner = owner, .asset = asset}); env(tx); env.close(); // Get the share MPTID from vault auto const vaultSle = env.le(vaultKeylet); BEAST_EXPECT(vaultSle != nullptr); auto const share = vaultSle->at(sfShareMPTID); // Depositor deposits into vault tx = vault.deposit( {.depositor = depositor, .id = vaultKeylet.key, .amount = asset(100)}); env(tx); env.close(); // Verify depositor has share tokens auto shareMpt = env.le(keylet::mptoken(share, depositor.id())); BEAST_EXPECT(shareMpt != nullptr); // Inject confidential balance fields on the share MPToken // to simulate a scenario where vault shares somehow have // confidential balances env.app().getOpenLedger().modify([&](OpenView& view, beast::Journal) { // Set lsfMPTCanHoldConfidentialBalance on the share issuance // so the invariant allows encrypted fields on the MPToken auto issuance = std::const_pointer_cast(view.read(keylet::mptokenIssuance(share))); if (!issuance) return false; issuance->setFlag(lsfMPTCanHoldConfidentialBalance); view.rawReplace(issuance); auto const k = keylet::mptoken(share, depositor.id()); auto const sle = std::const_pointer_cast(view.read(k)); if (!sle) return false; // Inject dummy confidential balance fields Buffer dummyCiphertext(kEcGamalEncryptedTotalLength); std::memset(dummyCiphertext.data(), 0, kEcGamalEncryptedTotalLength); dummyCiphertext.data()[0] = kEcCompressedPrefixEvenY; dummyCiphertext.data()[kEcCiphertextComponentLength] = kEcCompressedPrefixEvenY; dummyCiphertext.data()[kEcCiphertextComponentLength - 1] = 0x01; dummyCiphertext.data()[kEcGamalEncryptedTotalLength - 1] = 0x01; sle->setFieldVL(sfConfidentialBalanceSpending, dummyCiphertext); sle->setFieldVL(sfConfidentialBalanceInbox, dummyCiphertext); sle->setFieldVL(sfIssuerEncryptedBalance, dummyCiphertext); view.rawReplace(sle); return true; }); // Withdraw everything - which should fail because of the confidential balance fields tx = vault.withdraw( {.depositor = depositor, .id = vaultKeylet.key, .amount = asset(100)}); env(tx); // The share MPToken should still exist because the // withdrawal failed due to confidential balance obligations shareMpt = env.le(keylet::mptoken(share, depositor.id())); BEAST_EXPECT(shareMpt != nullptr); } } void testConvertBack(FeatureBitset features) { testcase("Convert back"); using namespace test::jtx; // Basic convert back test { Env env{*this, features}; Account const alice("alice"); Account const bob("bob"); ConfidentialEnv confEnv{ env, alice, {{.account = bob, .payAmount = 100, .convertAmount = 40}}}; auto& mptAlice = confEnv.mpt; mptAlice.convertBack({ .account = bob, .amt = 30, }); mptAlice.convertBack({ .account = bob, .amt = 10, }); } // Edge case: minimum amount (1) { Env env{*this, features}; Account const alice("alice"); Account const bob("bob"); ConfidentialEnv confEnv{ env, alice, {{.account = bob, .payAmount = 2, .convertAmount = 2}}}; auto& mptAlice = confEnv.mpt; mptAlice.convertBack({ .account = bob, .amt = 1, }); } // Edge case: kMaxMpTokenAmount // Using raw JSON to avoid automatic decryption checks in MPTTester // which don't work for very large amounts (brute-force decryption is slow) // TODO: improve this test once there is bounded decryption or optimized decryption for // large amounts { Env env{*this, features}; Account const alice("alice"); Account const bob("bob"); MPTTester mptAlice(env, alice, {.holders = {bob}}); mptAlice.create({ .ownerCount = 1, .flags = tfMPTCanTransfer | tfMPTCanLock | tfMPTCanHoldConfidentialBalance, }); mptAlice.authorize({ .account = bob, }); mptAlice.pay(alice, bob, kMaxMpTokenAmount); mptAlice.generateKeyPair(alice); mptAlice.set({.account = alice, .issuerPubKey = mptAlice.getPubKey(alice)}); mptAlice.generateKeyPair(bob); // Convert kMaxMpTokenAmount to confidential using raw JSON Buffer const convertBlindingFactor = generateBlindingFactor(); auto const convertHolderCiphertext = mptAlice.encryptAmount(bob, kMaxMpTokenAmount, convertBlindingFactor); auto const convertIssuerCiphertext = mptAlice.encryptAmount(alice, kMaxMpTokenAmount, convertBlindingFactor); auto const convertContextHash = getConvertContextHash(bob.id(), mptAlice.issuanceID(), env.seq(bob)); auto const schnorrProof = requireOptional( mptAlice.getSchnorrProof(bob, convertContextHash), "Missing schnorr proof"); { json::Value jv; jv[jss::Account] = bob.human(); jv[jss::TransactionType] = jss::ConfidentialMPTConvert; jv[sfMPTokenIssuanceID] = to_string(mptAlice.issuanceID()); jv[sfMPTAmount.jsonName] = std::to_string(kMaxMpTokenAmount); jv[sfHolderEncryptionKey.jsonName] = strHex(requireOptional(mptAlice.getPubKey(bob), "Missing holder public key")); jv[sfHolderEncryptedAmount.jsonName] = strHex(convertHolderCiphertext); jv[sfIssuerEncryptedAmount.jsonName] = strHex(convertIssuerCiphertext); jv[sfBlindingFactor.jsonName] = strHex(convertBlindingFactor); jv[sfZKProof.jsonName] = strHex(schnorrProof); env(jv, Ter(tesSUCCESS)); } // Merge inbox using raw JSON - moves funds from inbox to spending balance { json::Value jv; jv[jss::Account] = bob.human(); jv[jss::TransactionType] = jss::ConfidentialMPTMergeInbox; jv[sfMPTokenIssuanceID] = to_string(mptAlice.issuanceID()); env(jv, Ter(tesSUCCESS)); } // ConvertBack kMaxMpTokenAmount - 1 using raw JSON // After convert + merge, spending balance = kMaxMpTokenAmount // We convert back kMaxMpTokenAmount - 1 to leave remainder of 1 std::uint64_t const convertBackAmt = kMaxMpTokenAmount - 1; Buffer const convertBackBlindingFactor = generateBlindingFactor(); auto const convertBackHolderCiphertext = mptAlice.encryptAmount(bob, convertBackAmt, convertBackBlindingFactor); auto const convertBackIssuerCiphertext = mptAlice.encryptAmount(alice, convertBackAmt, convertBackBlindingFactor); // Get the encrypted spending balance from ledger (no decryption needed) auto const encryptedSpendingBalance = requireOptional( mptAlice.getEncryptedBalance(bob, MPTTester::holderEncryptedSpending), "Missing encrypted spending balance"); // Generate pedersen commitment for the known spending balance Buffer const pcBlindingFactor = generateBlindingFactor(); Buffer const pedersenCommitment = mptAlice.getPedersenCommitment(kMaxMpTokenAmount, pcBlindingFactor); // Generate the proof using known spending balance value auto const version = mptAlice.getMPTokenVersion(bob); uint256 const convertBackContextHash = getConvertBackContextHash(bob.id(), mptAlice.issuanceID(), env.seq(bob), version); Buffer const proof = mptAlice.getConvertBackProof( bob, convertBackAmt, convertBackContextHash, { .pedersenCommitment = pedersenCommitment, .amt = kMaxMpTokenAmount, .encryptedAmt = encryptedSpendingBalance, .blindingFactor = pcBlindingFactor, }); { json::Value jv; jv[jss::Account] = bob.human(); jv[jss::TransactionType] = jss::ConfidentialMPTConvertBack; jv[sfMPTokenIssuanceID] = to_string(mptAlice.issuanceID()); jv[sfMPTAmount.jsonName] = std::to_string(convertBackAmt); jv[sfHolderEncryptedAmount.jsonName] = strHex(convertBackHolderCiphertext); jv[sfIssuerEncryptedAmount.jsonName] = strHex(convertBackIssuerCiphertext); jv[sfBlindingFactor.jsonName] = strHex(convertBackBlindingFactor); jv[sfBalanceCommitment.jsonName] = strHex(pedersenCommitment); jv[sfZKProof.jsonName] = strHex(proof); env(jv, Ter(tesSUCCESS)); } // Verify the public balance was restored (minus 1 remaining in confidential) env.require(MptBalance(mptAlice, bob, convertBackAmt)); } } void testConvertBackWithAuditor(FeatureBitset features) { testcase("Convert back with auditor"); using namespace test::jtx; Env env{*this, features}; Account const alice("alice"); Account const bob("bob"); Account const auditor("auditor"); ConfidentialEnv confEnv{ env, alice, {{.account = bob, .payAmount = 100, .convertAmount = 40}}, tfMPTCanTransfer | tfMPTCanLock | tfMPTCanHoldConfidentialBalance, auditor}; auto& mptAlice = confEnv.mpt; mptAlice.convertBack({ .account = bob, .amt = 30, }); } void testConvertBackPreflight(FeatureBitset features) { testcase("Convert back preflight"); using namespace test::jtx; { Env env{*this, features - featureConfidentialTransfer}; Account const alice("alice"); Account const bob("bob"); MPTTester mptAlice(env, alice, {.holders = {bob}}); mptAlice.create({ .ownerCount = 1, .flags = tfMPTCanTransfer | tfMPTCanLock, }); mptAlice.authorize({ .account = bob, }); mptAlice.pay(alice, bob, 100); mptAlice.generateKeyPair(alice); mptAlice.generateKeyPair(bob); mptAlice.convertBack({ .account = bob, .amt = 30, .err = temDISABLED, }); } { Env env{*this, features}; Account const alice("alice"); Account const bob("bob"); ConfidentialEnv confEnv{ env, alice, {{.account = bob, .payAmount = 100, .convertAmount = 40}}}; auto& mptAlice = confEnv.mpt; mptAlice.convertBack({ .account = alice, .amt = 30, .err = temMALFORMED, }); mptAlice.convertBack({ .account = bob, .amt = 0, .err = temBAD_AMOUNT, }); mptAlice.convertBack({ .account = bob, .amt = kMaxMpTokenAmount + 1, .err = temBAD_AMOUNT, }); // Balance commitment has correct length but invalid EC point data mptAlice.convertBack({ .account = bob, .amt = 30, .pedersenCommitment = gMakeZeroBuffer(kEcPedersenCommitmentLength), .err = temMALFORMED, }); mptAlice.convertBack({ .account = bob, .amt = 30, .holderEncryptedAmt = Buffer{}, .err = temBAD_CIPHERTEXT, }); mptAlice.convertBack({ .account = bob, .amt = 30, .issuerEncryptedAmt = Buffer{}, .err = temBAD_CIPHERTEXT, }); mptAlice.convertBack({ .account = bob, .amt = 30, .holderEncryptedAmt = getBadCiphertext(), .err = temBAD_CIPHERTEXT, }); mptAlice.convertBack({ .account = bob, .amt = 30, .issuerEncryptedAmt = getBadCiphertext(), .err = temBAD_CIPHERTEXT, }); mptAlice.convertBack({ .account = bob, .amt = 30, .auditorEncryptedAmt = gMakeZeroBuffer(10), .err = temBAD_CIPHERTEXT, }); mptAlice.convertBack({ .account = bob, .amt = 30, .auditorEncryptedAmt = getBadCiphertext(), .err = temBAD_CIPHERTEXT, }); // invalid proof length mptAlice.convertBack({ .account = bob, .amt = 30, .proof = Buffer{}, .err = temMALFORMED, }); mptAlice.convertBack({ .account = bob, .amt = 30, .proof = gMakeZeroBuffer(100), .err = temMALFORMED, }); } } void testConvertBackPreclaim(FeatureBitset features) { testcase("Convert back preclaim"); using namespace test::jtx; // issuance does not exist { Env env{*this, features}; Account const alice("alice"); Account const bob("bob"); MPTTester mptAlice(env, alice, {.holders = {bob}}); mptAlice.create({ .ownerCount = 1, .flags = tfMPTCanTransfer | tfMPTCanLock | tfMPTCanHoldConfidentialBalance, }); mptAlice.authorize({ .account = bob, }); mptAlice.generateKeyPair(alice); mptAlice.set({.account = alice, .issuerPubKey = mptAlice.getPubKey(alice)}); mptAlice.destroy(); mptAlice.generateKeyPair(bob); mptAlice.convertBack({ .account = bob, .amt = 30, .err = tecOBJECT_NOT_FOUND, }); } // tfMPTCanHoldConfidentialBalance is not set on issuance { Env env{*this, features}; Account const alice("alice"); Account const bob("bob"); MPTTester mptAlice(env, alice, {.holders = {bob}}); mptAlice.create({ .ownerCount = 1, .flags = tfMPTCanTransfer | tfMPTCanLock, }); mptAlice.authorize({ .account = bob, }); mptAlice.pay(alice, bob, 100); mptAlice.generateKeyPair(alice); mptAlice.generateKeyPair(bob); mptAlice.convertBack({ .account = bob, .amt = 30, .err = tecNO_PERMISSION, }); } // no mptoken { Env env{*this, features}; Account const alice("alice"); Account const bob("bob"); MPTTester mptAlice(env, alice, {.holders = {bob}}); mptAlice.create({ .ownerCount = 1, .flags = tfMPTCanTransfer | tfMPTCanLock | tfMPTCanHoldConfidentialBalance, }); mptAlice.generateKeyPair(alice); mptAlice.generateKeyPair(bob); mptAlice.set({.account = alice, .issuerPubKey = mptAlice.getPubKey(alice)}); mptAlice.convertBack({ .account = bob, .amt = 30, .err = tecOBJECT_NOT_FOUND, }); } // mptoken exists but lacks confidential fields { Env env{*this, features}; Account const alice("alice"); Account const bob("bob"); MPTTester mptAlice(env, alice, {.holders = {bob}}); mptAlice.create({ .ownerCount = 1, .flags = tfMPTCanTransfer | tfMPTCanHoldConfidentialBalance, }); mptAlice.authorize({ .account = bob, }); mptAlice.pay(alice, bob, 100); mptAlice.generateKeyPair(alice); mptAlice.generateKeyPair(bob); mptAlice.set({.account = alice, .issuerPubKey = mptAlice.getPubKey(alice)}); // Bob's MPToken lacks the confidential fields auto const sleBobMpt = env.le(keylet::mptoken(mptAlice.issuanceID(), bob.id())); BEAST_EXPECT(sleBobMpt); BEAST_EXPECT(!sleBobMpt->isFieldPresent(sfHolderEncryptionKey)); BEAST_EXPECT(!sleBobMpt->isFieldPresent(sfConfidentialBalanceSpending)); BEAST_EXPECT(!sleBobMpt->isFieldPresent(sfIssuerEncryptedBalance)); mptAlice.convertBack({ .account = bob, .amt = 30, .err = tecNO_PERMISSION, }); } // bob tries to convert back more than COA { Env env{*this, features}; Account const alice("alice"); Account const bob("bob"); Account const carol("carol"); MPTTester mptAlice(env, alice, {.holders = {bob, carol}}); mptAlice.create({ .ownerCount = 1, .flags = tfMPTCanTransfer | tfMPTCanLock | tfMPTCanHoldConfidentialBalance, }); mptAlice.authorize({ .account = bob, }); mptAlice.authorize({ .account = carol, }); mptAlice.pay(alice, bob, 100); mptAlice.pay(alice, carol, 100); mptAlice.generateKeyPair(alice); mptAlice.set({.account = alice, .issuerPubKey = mptAlice.getPubKey(alice)}); mptAlice.generateKeyPair(bob); mptAlice.generateKeyPair(carol); mptAlice.convert({ .account = bob, .amt = 40, .holderPubKey = mptAlice.getPubKey(bob), }); mptAlice.mergeInbox({ .account = bob, }); mptAlice.convert({ .account = carol, .amt = 40, .holderPubKey = mptAlice.getPubKey(carol), }); mptAlice.convertBack({ .account = bob, .amt = 300, .err = tecINSUFFICIENT_FUNDS, }); } // cannot convert if locked or unauth { Env env{*this, features}; Account const alice("alice"); Account const bob("bob"); MPTTester mptAlice(env, alice, {.holders = {bob}}); mptAlice.create({ .ownerCount = 1, .flags = tfMPTCanTransfer | tfMPTCanLock | tfMPTRequireAuth | tfMPTCanHoldConfidentialBalance, }); mptAlice.authorize({ .account = bob, }); mptAlice.authorize({ .account = alice, .holder = bob, }); mptAlice.pay(alice, bob, 100); mptAlice.generateKeyPair(alice); mptAlice.set({.account = alice, .issuerPubKey = mptAlice.getPubKey(alice)}); mptAlice.generateKeyPair(bob); mptAlice.convert({ .account = bob, .amt = 40, .holderPubKey = mptAlice.getPubKey(bob), }); mptAlice.mergeInbox({ .account = bob, }); mptAlice.set({ .account = alice, .holder = bob, .flags = tfMPTLock, }); mptAlice.convertBack({ .account = bob, .amt = 10, .err = tecLOCKED, }); mptAlice.set({ .account = alice, .holder = bob, .flags = tfMPTUnlock, }); mptAlice.convertBack({ .account = bob, .amt = 10, }); mptAlice.authorize({ .account = alice, .holder = bob, .flags = tfMPTUnauthorize, }); mptAlice.convertBack({ .account = bob, .amt = 10, .err = tecNO_AUTH, }); mptAlice.authorize({ .account = alice, .holder = bob, }); mptAlice.convertBack({ .account = bob, .amt = 10, }); } // Verification of holder and issuer ciphertexts during convertBack { Env env{*this, features}; Account const alice("alice"); Account const bob("bob"); ConfidentialEnv confEnv{ env, alice, {{.account = bob, .payAmount = 100, .convertAmount = 50}}}; auto& mptAlice = confEnv.mpt; // Holder encrypted amount is valid format but mathematically incorrect for this // convertBack mptAlice.convertBack({ .account = bob, .amt = 10, .holderEncryptedAmt = getTrivialCiphertext(), .err = tecBAD_PROOF, }); // Issuer encrypted amount is valid format but mathematically incorrect for this // convertBack mptAlice.convertBack({ .account = bob, .amt = 10, .issuerEncryptedAmt = getTrivialCiphertext(), .err = tecBAD_PROOF, }); } // Alice has NOT set an auditor key, but Bob provides // auditorEncryptedAmt { Env env{*this, features}; Account const alice("alice"); Account const bob("bob"); ConfidentialEnv confEnv{ env, alice, {{.account = bob, .payAmount = 100, .convertAmount = 50}}}; auto& mptAlice = confEnv.mpt; mptAlice.convertBack({ .account = bob, .amt = 10, // Provide valid ciphertext to pass preflight .auditorEncryptedAmt = getTrivialCiphertext(), .err = tecNO_PERMISSION, }); } // we set the auditor key, but convertBack omits auditorEncryptedAmt { Env env{*this, features}; Account const alice("alice"); Account const bob("bob"); Account const auditor("auditor"); ConfidentialEnv confEnv{ env, alice, {{.account = bob, .payAmount = 100, .convertAmount = 50}}, tfMPTCanTransfer | tfMPTCanLock | tfMPTCanHoldConfidentialBalance, auditor}; auto& mptAlice = confEnv.mpt; // ConvertBack WITHOUT auditorEncryptedAmt mptAlice.convertBack({ .account = bob, .amt = 10, .fillAuditorEncryptedAmt = false, .err = tecNO_PERMISSION, }); // ConvertBack where auditor ciphertext mathematically // correct, but contains invalid data (mismatching amount). mptAlice.convertBack({ .account = bob, .amt = 10, .auditorEncryptedAmt = getTrivialCiphertext(), .err = tecBAD_PROOF, }); } } void testClawback(FeatureBitset features) { testcase("test ConfidentialMPTClawback"); using namespace test::jtx; Env env{*this, features}; Account const alice("alice"); Account const bob("bob"); Account const carol("carol"); Account const dave("dave"); MPTTester mptAlice(env, alice, {.holders = {bob, carol, dave}}); mptAlice.create({ .flags = tfMPTCanTransfer | tfMPTCanLock | tfMPTCanClawback | tfMPTCanHoldConfidentialBalance, }); mptAlice.authorize({ .account = bob, }); mptAlice.pay(alice, bob, 100); mptAlice.authorize({ .account = carol, }); mptAlice.pay(alice, carol, 200); mptAlice.authorize({ .account = dave, }); mptAlice.pay(alice, dave, 300); mptAlice.generateKeyPair(alice); mptAlice.generateKeyPair(bob); mptAlice.generateKeyPair(carol); mptAlice.generateKeyPair(dave); mptAlice.set({.account = alice, .issuerPubKey = mptAlice.getPubKey(alice)}); // setup bob. // after setup, bob's spending balance is 60, inbox balance is 0. { // bob converts 60 to confidential mptAlice.convert({.account = bob, .amt = 60, .holderPubKey = mptAlice.getPubKey(bob)}); // bob merge inbox mptAlice.mergeInbox({ .account = bob, }); } // setup carol. // after setup, carol's spending balance is 120, inbox balance is 0. { // carol converts 120 to confidential mptAlice.convert( {.account = carol, .amt = 120, .holderPubKey = mptAlice.getPubKey(carol)}); // carol merge inbox mptAlice.mergeInbox({ .account = carol, }); } // setup dave. // dave will not merge inbox. // after setup, dave's inbox balance is 200, spending balance is 0. mptAlice.convert({.account = dave, .amt = 200, .holderPubKey = mptAlice.getPubKey(dave)}); // setup: carol confidential send 50 to bob. // after send, bob's inbox balance is 50, spending balance // remains 60. carol's inbox balance remains 0, spending balance // drops to 70. mptAlice.send({ .account = carol, .dest = bob, .amt = 50, }); // Confidential clawback is burn/reduce outstanding amount. // The holder public balance is unchanged, and OA/COA decrease. auto const preBobPublicBalance = mptAlice.getBalance(bob); auto const preOutstandingAmount = mptAlice.getIssuanceOutstandingBalance(); auto const preConfidentialOutstandingAmount = mptAlice.getIssuanceConfidentialBalance(); BEAST_EXPECT(!env.le(keylet::mptoken(mptAlice.issuanceID(), alice.id()))); // alice clawback all confidential balance from bob, 110 in total. // bob has balance in both inbox and spending. These balances should // become zero after clawback, which is verified in the // confidentialClaw function. mptAlice.confidentialClaw({ .account = alice, .holder = bob, .amt = 110, }); BEAST_EXPECT(mptAlice.getBalance(bob) == preBobPublicBalance); auto const postOutstandingAmount = mptAlice.getIssuanceOutstandingBalance(); BEAST_EXPECT( preOutstandingAmount && postOutstandingAmount && *postOutstandingAmount == *preOutstandingAmount - 110); BEAST_EXPECT( mptAlice.getIssuanceConfidentialBalance() == preConfidentialOutstandingAmount - 110); BEAST_EXPECT(!env.le(keylet::mptoken(mptAlice.issuanceID(), alice.id()))); // alice clawback all confidential balance from carol, which is 70. // carol only has balance in spending. mptAlice.confidentialClaw({ .account = alice, .holder = carol, .amt = 70, }); // alice clawback all confidential balance from dave, which is 200. // dave only has balance in inbox. mptAlice.confidentialClaw({ .account = alice, .holder = dave, .amt = 200, }); } void testClawbackWithAuditor(FeatureBitset features) { testcase("test ConfidentialMPTClawback with auditor"); using namespace test::jtx; Env env{*this, features}; Account const alice("alice"); Account const bob("bob"); Account const carol("carol"); Account const dave("dave"); Account const auditor("auditor"); MPTTester mptAlice( env, alice, { .holders = {bob, carol, dave}, .auditor = auditor, }); mptAlice.create({ .flags = tfMPTCanTransfer | tfMPTCanLock | tfMPTCanClawback | tfMPTCanHoldConfidentialBalance, }); mptAlice.authorize({ .account = bob, }); mptAlice.pay(alice, bob, 100); mptAlice.authorize({ .account = carol, }); mptAlice.pay(alice, carol, 200); mptAlice.authorize({ .account = dave, }); mptAlice.pay(alice, dave, 300); mptAlice.generateKeyPair(alice); mptAlice.generateKeyPair(bob); mptAlice.generateKeyPair(carol); mptAlice.generateKeyPair(dave); mptAlice.generateKeyPair(auditor); mptAlice.set( {.account = alice, .issuerPubKey = mptAlice.getPubKey(alice), .auditorPubKey = mptAlice.getPubKey(auditor)}); // setup bob. // after setup, bob's spending balance is 60, inbox balance is 0. { // bob converts 60 to confidential mptAlice.convert({.account = bob, .amt = 60, .holderPubKey = mptAlice.getPubKey(bob)}); // bob merge inbox mptAlice.mergeInbox({ .account = bob, }); } // setup carol. // after setup, carol's spending balance is 120, inbox balance is 0. { // carol converts 120 to confidential mptAlice.convert( {.account = carol, .amt = 120, .holderPubKey = mptAlice.getPubKey(carol)}); // carol merge inbox mptAlice.mergeInbox({ .account = carol, }); } // setup dave. // dave will not merge inbox. // after setup, dave's inbox balance is 200, spending balance is 0. mptAlice.convert({.account = dave, .amt = 200, .holderPubKey = mptAlice.getPubKey(dave)}); // setup: carol confidential send 50 to bob. // after send, bob's inbox balance is 50, spending balance // remains 60. carol's inbox balance remains 0, spending balance // drops to 70. mptAlice.send({ .account = carol, .dest = bob, .amt = 50, }); // alice clawback all confidential balance from bob, 110 in total. // bob has balance in both inbox and spending. These balances should // become zero after clawback, which is verified in the // confidentialClaw function. mptAlice.confidentialClaw({ .account = alice, .holder = bob, .amt = 110, }); // alice clawback all confidential balance from carol, which is 70. // carol only has balance in spending. mptAlice.confidentialClaw({ .account = alice, .holder = carol, .amt = 70, }); // alice clawback all confidential balance from dave, which is 200. // dave only has balance in inbox. mptAlice.confidentialClaw({ .account = alice, .holder = dave, .amt = 200, }); } void testClawbackInvalidProofContextBinding(FeatureBitset features) { testcase("ConfidentialMPTClawback context binding"); using namespace test::jtx; auto runBadProof = [&](auto makeContextHash) { Env env{*this, features}; Account const alice("alice"); Account const bob("bob"); Account const carol("carol"); ConfidentialEnv confEnv{ env, alice, {{.account = bob, .payAmount = 100, .convertAmount = 60}}, tfMPTCanTransfer | tfMPTCanLock | tfMPTCanClawback | tfMPTCanHoldConfidentialBalance}; auto& mptAlice = confEnv.mpt; auto const privKey = mptAlice.getPrivKey(alice); if (!BEAST_EXPECT(privKey.has_value())) return; auto const proof = mptAlice.getClawbackProof( bob, 60, requireOptionalRef(privKey, "Missing private key"), makeContextHash(env, mptAlice, alice, bob, carol)); if (!BEAST_EXPECT(proof.has_value())) return; mptAlice.confidentialClaw({ .account = alice, .holder = bob, .amt = 60, .proof = strHex(requireOptional(proof, "Missing proof")), .err = tecBAD_PROOF, }); }; // Wrong account (issuer) in the proof context. runBadProof([&](Env& env, MPTTester const& mpt, Account const& alice, Account const& bob, Account const& carol) { return getClawbackContextHash(carol.id(), mpt.issuanceID(), env.seq(alice), bob.id()); }); // Wrong issuance ID in the proof context. runBadProof([&](Env& env, MPTTester const&, Account const& alice, Account const& bob, Account const&) { return getClawbackContextHash( alice.id(), makeMptID(env.seq(alice) + 100, alice), env.seq(alice), bob.id()); }); // Wrong transaction sequence in the proof context. runBadProof([&](Env& env, MPTTester const& mpt, Account const& alice, Account const& bob, Account const&) { return getClawbackContextHash( alice.id(), mpt.issuanceID(), env.seq(alice) + 1, bob.id()); }); // Wrong holder in the proof context. runBadProof([&](Env& env, MPTTester const& mpt, Account const& alice, Account const&, Account const& carol) { return getClawbackContextHash(alice.id(), mpt.issuanceID(), env.seq(alice), carol.id()); }); } // Bob creates the AMM, but Bob is not the MPT holder checked below. // The AMM has its own pseudo-account (`ammHolder`) that can hold the // public MPT pool balance. That pseudo-account cannot normally // initialize confidential state because the confidential txn's must be // signed by sfAccount, and the AMM pseudo-account has no signing key. // So this is a construction/impossibility test: public AMM MPT state exists // but the corresponding confidential AMM clawback flow is not normally reachable. void testClawbackPreflight(FeatureBitset features) { testcase("test ConfidentialMPTClawback Preflight"); using namespace test::jtx; // test feature disabled { Env env{*this, features - featureConfidentialTransfer}; Account const alice("alice"); Account const bob("bob"); MPTTester mptAlice(env, alice, {.holders = {bob}}); mptAlice.create(); mptAlice.authorize({ .account = bob, }); mptAlice.confidentialClaw({ .account = alice, .holder = bob, .amt = 10, .proof = "123", .err = temDISABLED, }); } // test malformed { // set up Env env{*this, features}; Account const alice("alice"); Account const bob("bob"); Account const carol("carol"); MPTTester mptAlice(env, alice, {.holders = {bob, carol}}); mptAlice.create({ .ownerCount = 1, .flags = tfMPTCanTransfer | tfMPTCanLock | tfMPTCanHoldConfidentialBalance, }); mptAlice.authorize({ .account = bob, }); mptAlice.authorize({ .account = carol, }); mptAlice.generateKeyPair(alice); mptAlice.generateKeyPair(bob); mptAlice.generateKeyPair(carol); mptAlice.set({.account = alice, .issuerPubKey = mptAlice.getPubKey(alice)}); mptAlice.pay(alice, bob, 100); mptAlice.pay(alice, carol, 50); // only issuer can clawback mptAlice.confidentialClaw({ .account = carol, .holder = bob, .amt = 10, .err = temMALFORMED, }); // invalid issuance ID, whose issuer is not alice { json::Value jv; jv[jss::Account] = alice.human(); jv[sfHolder] = bob.human(); jv[jss::TransactionType] = jss::ConfidentialMPTClawback; jv[sfMPTAmount] = std::to_string(10); jv[sfZKProof] = "123"; // wrong issuance ID jv[sfMPTokenIssuanceID] = "00000004AE123A8556F3CF91154711376AFB0F894F832B3E"; env(jv, Ter(temMALFORMED)); } // issuer cannot clawback from self mptAlice.confidentialClaw({ .account = alice, .holder = alice, .amt = 10, .err = temMALFORMED, }); // invalid amount mptAlice.confidentialClaw({ .account = alice, .holder = bob, .amt = 0, .err = temBAD_AMOUNT, }); // invalid proof length mptAlice.confidentialClaw({ .account = alice, .holder = bob, .amt = 10, .proof = "123", .err = temMALFORMED, }); } } void testClawbackPreclaim(FeatureBitset features) { testcase("Clawback Preclaim Errors"); using namespace test::jtx; { // set up, alice is the issuer, bob and carol are authorized // holders. dave is not authorized. bob has confidential // balance, carol does not. Env env{*this, features}; Account const alice("alice"); Account const bob("bob"); Account const carol("carol"); Account const dave("dave"); MPTTester mptAlice(env, alice, {.holders = {bob, carol, dave}}); mptAlice.create({ .flags = tfMPTCanTransfer | tfMPTCanClawback | tfMPTRequireAuth | tfMPTCanHoldConfidentialBalance, }); mptAlice.authorize({ .account = bob, }); mptAlice.authorize({ .account = alice, .holder = bob, }); mptAlice.authorize({ .account = carol, }); mptAlice.authorize({ .account = alice, .holder = carol, }); mptAlice.pay(alice, bob, 100); mptAlice.pay(alice, carol, 50); mptAlice.generateKeyPair(alice); mptAlice.generateKeyPair(bob); mptAlice.generateKeyPair(carol); mptAlice.set({.account = alice, .issuerPubKey = mptAlice.getPubKey(alice)}); mptAlice.convert({ .account = bob, .amt = 60, .holderPubKey = mptAlice.getPubKey(bob), }); mptAlice.mergeInbox({ .account = bob, }); // holder does not exist { Account const unknown("unknown"); mptAlice.confidentialClaw({ .account = alice, .holder = unknown, .amt = 10, .err = tecNO_TARGET, }); } // dave does not hold mpt at all, no MPT object { mptAlice.confidentialClaw({ .account = alice, .holder = dave, .amt = 10, .err = tecOBJECT_NOT_FOUND, }); } // carol has no confidential balance { mptAlice.confidentialClaw({ .account = alice, .holder = carol, .amt = 10, .err = tecNO_PERMISSION, }); } } // lsfMPTCanClawback not set { Env env{*this, features}; Account const alice("alice"); Account const bob("bob"); MPTTester mptAlice(env, alice, {.holders = {bob}}); mptAlice.create({ .flags = tfMPTCanTransfer | tfMPTCanHoldConfidentialBalance, }); mptAlice.authorize({ .account = bob, }); mptAlice.generateKeyPair(alice); mptAlice.set({.account = alice, .issuerPubKey = mptAlice.getPubKey(alice)}); mptAlice.confidentialClaw({ .account = alice, .holder = bob, .amt = 10, .err = tecNO_PERMISSION, }); } // no issuer key { Env env{*this, features}; Account const alice("alice"); Account const bob("bob"); MPTTester mptAlice(env, alice, {.holders = {bob}}); mptAlice.create({ .flags = tfMPTCanClawback | tfMPTCanHoldConfidentialBalance, }); mptAlice.authorize({ .account = bob, }); mptAlice.generateKeyPair(alice); mptAlice.confidentialClaw({ .account = alice, .holder = bob, .amt = 10, .err = tecNO_PERMISSION, }); } // issuance not found { Env env{*this, features}; Account const alice("alice"); Account const bob("bob"); MPTTester mptAlice(env, alice, {.holders = {bob}}); mptAlice.create({ .flags = tfMPTCanClawback | tfMPTCanHoldConfidentialBalance, }); mptAlice.authorize({ .account = bob, }); mptAlice.generateKeyPair(alice); mptAlice.set({.account = alice, .issuerPubKey = mptAlice.getPubKey(alice)}); // destroy the issuance mptAlice.destroy(); json::Value jv; jv[jss::Account] = alice.human(); jv[sfHolder] = bob.human(); jv[jss::TransactionType] = jss::ConfidentialMPTClawback; jv[sfMPTAmount] = std::to_string(10); std::string const dummyProof(kEcClawbackProofLength * 2, '0'); jv[sfZKProof] = dummyProof; jv[sfMPTokenIssuanceID] = to_string(mptAlice.issuanceID()); env(jv, Ter(tecOBJECT_NOT_FOUND)); } // After setup, bob has confidential balance 60 in spending. std::uint32_t const setupFlags = tfMPTCanTransfer | tfMPTCanClawback | tfMPTRequireAuth | tfMPTCanLock | tfMPTCanHoldConfidentialBalance; std::string const dummyClawbackProof(kEcClawbackProofLength * 2, '0'); auto removeMPTokenField = [&](Env& env, MPTTester const& mpt, Account const& holder, SField const& field) { BEAST_EXPECT(env.app().getOpenLedger().modify([&](OpenView& view, beast::Journal) { auto const sle = std::const_pointer_cast( view.read(keylet::mptoken(mpt.issuanceID(), holder.id()))); if (!sle) return false; sle->makeFieldAbsent(field); view.rawReplace(sle); return true; })); }; // After global COA is drained to zero, a further confidential clawback // fails because the amount exceeds the remaining confidential // outstanding amount. { Env env{*this, features}; Account const alice("alice"); Account const bob("bob"); ConfidentialEnv confEnv{ env, alice, {{.account = bob, .payAmount = 100, .convertAmount = 60}}, setupFlags}; auto& mptAlice = confEnv.mpt; mptAlice.confidentialClaw({ .account = alice, .holder = bob, .amt = 60, }); mptAlice.confidentialClaw({ .account = alice, .holder = bob, .amt = 1, .proof = dummyClawbackProof, .err = tecINSUFFICIENT_FUNDS, }); } // Missing issuer encrypted balance should fail before proof // verification. { Env env{*this, features}; Account const alice("alice"); Account const bob("bob"); ConfidentialEnv confEnv{ env, alice, {{.account = bob, .payAmount = 100, .convertAmount = 60}}, setupFlags}; auto& mptAlice = confEnv.mpt; removeMPTokenField(env, mptAlice, bob, sfIssuerEncryptedBalance); mptAlice.confidentialClaw({ .account = alice, .holder = bob, .amt = 60, .proof = dummyClawbackProof, .err = tecNO_PERMISSION, }); } // Missing holder encryption key should fail before proof verification. { Env env{*this, features}; Account const alice("alice"); Account const bob("bob"); ConfidentialEnv confEnv{ env, alice, {{.account = bob, .payAmount = 100, .convertAmount = 60}}, setupFlags}; auto& mptAlice = confEnv.mpt; removeMPTokenField(env, mptAlice, bob, sfHolderEncryptionKey); mptAlice.confidentialClaw({ .account = alice, .holder = bob, .amt = 60, .proof = dummyClawbackProof, .err = tecNO_PERMISSION, }); } // lock should not block clawback. lock bob individually { Env env{*this, features}; Account const alice("alice"); Account const bob("bob"); ConfidentialEnv confEnv{ env, alice, {{.account = bob, .payAmount = 100, .convertAmount = 60}}, setupFlags}; auto& mptAlice = confEnv.mpt; mptAlice.set({ .account = alice, .holder = bob, .flags = tfMPTLock, }); // clawback should still work mptAlice.confidentialClaw({ .account = alice, .holder = bob, .amt = 60, }); } // lock globally { Env env{*this, features}; Account const alice("alice"); Account const bob("bob"); ConfidentialEnv confEnv{ env, alice, {{.account = bob, .payAmount = 100, .convertAmount = 60}}, setupFlags}; auto& mptAlice = confEnv.mpt; mptAlice.set({ .account = alice, .flags = tfMPTLock, }); // clawback should still work mptAlice.confidentialClaw({ .account = alice, .holder = bob, .amt = 60, }); } // unauthorize should not block clawback { Env env{*this, features}; Account const alice("alice"); Account const bob("bob"); ConfidentialEnv confEnv{ env, alice, {{.account = bob, .payAmount = 100, .convertAmount = 60}}, setupFlags}; auto& mptAlice = confEnv.mpt; // unauthorize bob mptAlice.authorize({ .account = alice, .holder = bob, .flags = tfMPTUnauthorize, }); // clawback should still work mptAlice.confidentialClaw({ .account = alice, .holder = bob, .amt = 60, }); } // insufficient funds, clawback amount exceeding confidential // outstanding amount { Env env{*this, features}; Account const alice("alice"); Account const bob("bob"); ConfidentialEnv confEnv{ env, alice, {{.account = bob, .payAmount = 100, .convertAmount = 60}}, setupFlags}; auto& mptAlice = confEnv.mpt; mptAlice.confidentialClaw({ .account = alice, .holder = bob, .amt = 10000, .err = tecINSUFFICIENT_FUNDS, }); } } void testClawbackProof(FeatureBitset features) { testcase("ConfidentialMPTClawback Proof"); using namespace test::jtx; Account const alice("alice"); Account const bob("bob"); Account const carol("carol"); // lambda function to set up MPT with alice as issuer, bob and carol // as authorized holders, and fund 1000 mpt to bob and 2000 mpt to // carol. auto setupEnv = [&](Env& env) -> MPTTester { MPTTester mptAlice(env, alice, {.holders = {bob, carol}}); mptAlice.create({ .flags = tfMPTCanTransfer | tfMPTCanClawback | tfMPTCanHoldConfidentialBalance, }); for (auto const& [acct, amt] : {std::pair{bob, 1000}, {carol, 2000}}) { mptAlice.authorize({ .account = acct, }); mptAlice.pay(alice, acct, amt); mptAlice.generateKeyPair(acct); } mptAlice.generateKeyPair(alice); mptAlice.set({.account = alice, .issuerPubKey = mptAlice.getPubKey(alice)}); return mptAlice; }; // lambda function to test a set of bad clawback amounts that should // return tecBAD_PROOF auto checkBadProofs = [&](MPTTester& mpt, Account const& holder, std::initializer_list amts) { for (auto const badAmt : amts) { mpt.confidentialClaw({ .account = alice, .holder = holder, .amt = badAmt, .err = tecBAD_PROOF, }); } }; // SCENARIO 1: clawback from inbox only or spending only balances. // bob converts 500 and merge inbox, // carol converts 1000, but not merge inbox. // after setup, bob has 500 in spending, carol has 1000 in inbox. { Env env{*this, features}; auto mptAlice = setupEnv(env); // bob converts and merges mptAlice.convert({.account = bob, .amt = 500, .holderPubKey = mptAlice.getPubKey(bob)}); mptAlice.mergeInbox({ .account = bob, }); // carol converts without merge mptAlice.convert( {.account = carol, .amt = 1000, .holderPubKey = mptAlice.getPubKey(carol)}); // verify proof fails with invalid clawback amount // bob: 500 in Spending, 0 in Inbox checkBadProofs( mptAlice, bob, { 1, 10, 70, 100, 110, 200, 499, 501, 600, }); // carol: 1000 in Inbox, 0 in Spending checkBadProofs( mptAlice, carol, { 1, 10, 50, 500, 777, 850, 999, 1001, 1200, }); // clawback with correct amount that passes proof verification mptAlice.confidentialClaw({ .account = alice, .holder = bob, .amt = 500, }); mptAlice.confidentialClaw({ .account = alice, .holder = carol, .amt = 1000, }); } // SCENARIO 2: clawback from mixed inbox and spending balances. // bob converts 300 to confidential and merge inbox, // carol converts 400 to confidential and merge inbox, // bob sends 100 to carol, carol sends 100 to bob. // After setup, bob has 100 in inbox and 200 in spending; // carol has 100 in inbox and 300 in spending. { Env env{*this, features}; auto mptAlice = setupEnv(env); mptAlice.convert({.account = bob, .amt = 300, .holderPubKey = mptAlice.getPubKey(bob)}); mptAlice.mergeInbox({ .account = bob, }); mptAlice.convert( {.account = carol, .amt = 400, .holderPubKey = mptAlice.getPubKey(carol)}); mptAlice.mergeInbox({ .account = carol, }); mptAlice.send({ .account = bob, .dest = carol, .amt = 100, }); mptAlice.send({ .account = carol, .dest = bob, .amt = 100, }); // verify proof fails with invalid clawback amount // bob: 100 in inbox, 200 in spending checkBadProofs( mptAlice, bob, { 1, 10, 50, 100, 200, 299, 301, 400, }); // proof failure for incorrect amount when clawbacking from // carol carol: 100 in inbox, 300 in spending checkBadProofs( mptAlice, carol, { 1, 10, 50, 100, 300, 399, 401, 501, }); // clawback with correct amount that passes proof verification mptAlice.confidentialClaw({ .account = alice, .holder = bob, .amt = 300, }); mptAlice.confidentialClaw({ .account = alice, .holder = carol, .amt = 400, }); } // SCENARIO 3: the clawback proof omits the holder's confidential // balance version. A proof generated before the version advances is // still accepted, because getClawbackContextHash has no version // component. { Env env{*this, features}; auto mptAlice = setupEnv(env); mptAlice.convert({.account = bob, .amt = 500, .holderPubKey = mptAlice.getPubKey(bob)}); mptAlice.mergeInbox({ .account = bob, }); auto const privKey = mptAlice.getPrivKey(alice); if (!BEAST_EXPECT(privKey.has_value())) return; auto const proof = mptAlice.getClawbackProof( bob, 500, requireOptionalRef(privKey, "Missing private key"), getClawbackContextHash( alice.id(), mptAlice.issuanceID(), env.seq(alice), bob.id())); if (!BEAST_EXPECT(proof.has_value())) return; // Advance bob's balance version after the proof is generated. An // empty-inbox merge leaves the balance unchanged but still bumps // sfConfidentialBalanceVersion. auto const versionBefore = mptAlice.getMPTokenVersion(bob); mptAlice.mergeInbox({.account = bob}); BEAST_EXPECT(mptAlice.getMPTokenVersion(bob) != versionBefore); // The stale-version proof is still accepted. mptAlice.confidentialClaw({ .account = alice, .holder = bob, .amt = 500, .proof = strHex(requireOptional(proof, "Missing proof")), }); } } void testPublicTransfersAfterClearingConfidentialFlag(FeatureBitset features) { testcase("Public transfers after clearing Confidential Flag"); using namespace test::jtx; Account const alice("alice"); Account const bob("bob"); Account const carol("carol"); // After clearing the confidential flag, all four public MPT operations // must succeed regardless of which confidential path left encrypted-zero // fields on bob's MPToken. auto runPublicPayments = [&](MPTTester& mpt) { mpt.pay(bob, carol, 10); mpt.pay(carol, bob, 5); mpt.pay(alice, bob, 1); mpt.pay(carol, alice, 5); }; auto drainAndDeleteBobMPToken = [&](Env& env, MPTTester& mpt) { auto const bobBalance = mpt.getBalance(bob); BEAST_EXPECT(bobBalance > 0); mpt.pay(bob, alice, bobBalance); BEAST_EXPECT(mpt.getBalance(bob) == 0); mpt.authorize({.account = bob, .flags = tfMPTUnauthorize}); BEAST_EXPECT(!env.le(keylet::mptoken(mpt.issuanceID(), bob.id()))); }; // Alice pays Bob 100 public, Bob converts 50 confidential // Bob converts 50 back to public, and make sure can receive public payments { Env env{*this, features}; ConfidentialEnv ct{ env, alice, {{.account = bob, .payAmount = 100, .convertAmount = 50}}, tfMPTCanTransfer | tfMPTCanHoldConfidentialBalance}; env.fund(XRP(1'000), carol); ct.mpt.authorize({.account = carol}); ct.mpt.pay(alice, carol, 50); ct.mpt.convertBack({.account = bob, .amt = 50}); runPublicPayments(ct.mpt); drainAndDeleteBobMPToken(env, ct.mpt); } // Same path as above but with Auditor { Env env{*this, features}; Account const auditor("auditor"); MPTTester mptAlice(env, alice, {.holders = {bob, carol}, .auditor = auditor}); mptAlice.create({ .ownerCount = 1, .flags = tfMPTCanTransfer | tfMPTCanHoldConfidentialBalance, }); mptAlice.authorize({.account = bob}); mptAlice.authorize({.account = carol}); mptAlice.pay(alice, bob, 100); mptAlice.pay(alice, carol, 50); mptAlice.generateKeyPair(alice); mptAlice.generateKeyPair(bob); mptAlice.generateKeyPair(auditor); mptAlice.set( {.account = alice, .issuerPubKey = mptAlice.getPubKey(alice), .auditorPubKey = mptAlice.getPubKey(auditor)}); mptAlice.convert({ .account = bob, .amt = 50, .holderPubKey = mptAlice.getPubKey(bob), }); mptAlice.mergeInbox({.account = bob}); mptAlice.convertBack({.account = bob, .amt = 50}); runPublicPayments(mptAlice); drainAndDeleteBobMPToken(env, mptAlice); } // Confidential clawback leaves encrypted-zero fields; // the public balance remaining after the clawback must stay usable. { Env env{*this, features}; ConfidentialEnv ct{ env, alice, {{.account = bob, .payAmount = 100, .convertAmount = 50}}, tfMPTCanTransfer | tfMPTCanClawback | tfMPTCanHoldConfidentialBalance}; env.fund(XRP(1'000), carol); ct.mpt.authorize({.account = carol}); ct.mpt.pay(alice, carol, 50); ct.mpt.confidentialClaw({.account = alice, .holder = bob, .amt = 50}); runPublicPayments(ct.mpt); drainAndDeleteBobMPToken(env, ct.mpt); } } void testMutatePrivacy(FeatureBitset features) { testcase("mutate lsfMPTCanHoldConfidentialBalance"); using namespace test::jtx; // can not create mpt issuance with tmfMPTCannotEnableCanHoldConfidentialBalance // when featureDynamicMPT is disabled { Env env{*this, features - featureDynamicMPT}; Account const alice("alice"); Account const bob("bob"); MPTTester mptAlice(env, alice, {.holders = {bob}}); mptAlice.create({ .ownerCount = 0, .mutableFlags = tmfMPTCannotEnableCanHoldConfidentialBalance, .err = temDISABLED, }); } // can not create mpt issuance with tmfMPTCannotEnableCanHoldConfidentialBalance when // featureConfidentialTransfer is disabled { Env env{*this, features - featureConfidentialTransfer}; Account const alice("alice"); Account const bob("bob"); MPTTester mptAlice(env, alice, {.holders = {bob}}); mptAlice.create({ .ownerCount = 0, .mutableFlags = tmfMPTCannotEnableCanHoldConfidentialBalance, .err = temDISABLED, }); } // if lsmfMPTCannotEnableCanHoldConfidentialBalance is set, can not set/clear // lsfMPTCanHoldConfidentialBalance { Env env{*this, features}; Account const alice("alice"); Account const bob("bob"); MPTTester mptAlice(env, alice, {.holders = {bob}}); mptAlice.create({ .ownerCount = 1, .flags = tfMPTCanTransfer, .mutableFlags = tmfMPTCannotEnableCanHoldConfidentialBalance, }); mptAlice.set({ .account = alice, .mutableFlags = tmfMPTSetCanHoldConfidentialBalance, .err = tecNO_PERMISSION, }); } // Toggle lsfMPTCanHoldConfidentialBalance { Env env{*this, features}; Account const alice("alice"); Account const bob("bob"); MPTTester mptAlice(env, alice, {.holders = {bob}}); mptAlice.create({ .ownerCount = 1, .flags = tfMPTCanTransfer | tfMPTCanHoldConfidentialBalance, .mutableFlags = tmfMPTCanEnableCanLock, }); mptAlice.authorize({ .account = bob, }); mptAlice.pay(alice, bob, 100); mptAlice.generateKeyPair(alice); mptAlice.generateKeyPair(bob); mptAlice.set({.account = alice, .issuerPubKey = mptAlice.getPubKey(alice)}); auto holderPubKeySet = false; auto verifyToggle = [&](TER expectedResult, uint64_t amt) { if (!holderPubKeySet) { mptAlice.convert({ .account = bob, .amt = amt, .holderPubKey = mptAlice.getPubKey(bob), .err = expectedResult, }); } else { mptAlice.convert({ .account = bob, .amt = amt, .err = expectedResult, }); } if (expectedResult == tesSUCCESS) { holderPubKeySet = true; mptAlice.mergeInbox({ .account = bob, }); // make sure there's no confidential outstanding balance // for the next toggle test mptAlice.convertBack({ .account = bob, .amt = amt, }); } }; // set lsfMPTCanHoldConfidentialBalance, but no effect because // lsfMPTCanHoldConfidentialBalance was already set mptAlice.set({ .account = alice, .mutableFlags = tmfMPTSetCanHoldConfidentialBalance, }); verifyToggle(tesSUCCESS, 10); // set tmfMPTSetCanHoldConfidentialBalance again mptAlice.set({ .account = alice, .mutableFlags = tmfMPTSetCanHoldConfidentialBalance, }); verifyToggle(tesSUCCESS, 30); } // can not mutate lsfPrivacy when there's confidential // outstanding amount { Env env{*this, features}; Account const alice("alice"); Account const bob("bob"); MPTTester mptAlice(env, alice, {.holders = {bob}}); // lsmfMPTCannotEnableCanHoldConfidentialBalance is false by default, // so that lsfMPTCanHoldConfidentialBalance can be mutated mptAlice.create({ .ownerCount = 1, .flags = tfMPTCanTransfer | tfMPTCanHoldConfidentialBalance, }); mptAlice.authorize({ .account = bob, }); mptAlice.pay(alice, bob, 100); mptAlice.generateKeyPair(alice); mptAlice.generateKeyPair(bob); mptAlice.set({.account = alice, .issuerPubKey = mptAlice.getPubKey(alice)}); // bob convert 50 to confidential mptAlice.convert({.account = bob, .amt = 50, .holderPubKey = mptAlice.getPubKey(bob)}); // set lsfMPTCanHoldConfidentialBalance should fail because of // confidential outstanding balance mptAlice.set({ .account = alice, .mutableFlags = tmfMPTSetCanHoldConfidentialBalance, .err = tecNO_PERMISSION, }); } } void testConvertBackPedersenProof(FeatureBitset features) { testcase("Convert back pedersen proof"); using namespace test::jtx; Env env{*this, features}; Account const alice("alice"); Account const bob("bob"); ConfidentialEnv confEnv{ env, alice, {{.account = bob, .payAmount = 100, .convertAmount = 40}}}; auto& mptAlice = confEnv.mpt; // for ease of understanding, generate all the fields here instead of // autofilling uint64_t const amt = 10; Buffer const blindingFactor = generateBlindingFactor(); Buffer const pcBlindingFactor = generateBlindingFactor(); auto const spendingBalance = requireOptional( mptAlice.getDecryptedBalance(bob, MPTTester::holderEncryptedSpending), "Missing spending balance"); auto const encryptedSpendingBalance = requireOptional( mptAlice.getEncryptedBalance(bob, MPTTester::holderEncryptedSpending), "Missing encrypted spending balance"); BEAST_EXPECT(!encryptedSpendingBalance.empty()); Buffer const pedersenCommitment = mptAlice.getPedersenCommitment(spendingBalance, pcBlindingFactor); Buffer const issuerCiphertext = mptAlice.encryptAmount(alice, amt, blindingFactor); Buffer const bobCiphertext = mptAlice.encryptAmount(bob, amt, blindingFactor); auto const version = mptAlice.getMPTokenVersion(bob); // These tests verify that the compact ConvertBack proof validation // correctly rejects proofs generated with incorrect parameters. // The compact proof simultaneously verifies balance ownership, // commitment linkage, and that remaining balance is non-negative. // Test 1: Proof generated with wrong pedersen commitment value. // The proof uses PC(1, rho) but the transaction submits PC(balance, rho). // Verification fails because the proof doesn't match the submitted commitment. { uint256 const contextHash = getConvertBackContextHash(bob, mptAlice.issuanceID(), env.seq(bob), version); Buffer const badPedersenCommitment = mptAlice.getPedersenCommitment(1, pcBlindingFactor); Buffer const proof = mptAlice.getConvertBackProof( bob, amt, contextHash, { .pedersenCommitment = badPedersenCommitment, // wrong pedersen commitment .amt = spendingBalance, .encryptedAmt = encryptedSpendingBalance, .blindingFactor = pcBlindingFactor, }); mptAlice.convertBack({ .account = bob, .amt = amt, .proof = proof, .holderEncryptedAmt = bobCiphertext, .issuerEncryptedAmt = issuerCiphertext, .blindingFactor = blindingFactor, .pedersenCommitment = pedersenCommitment, .err = tecBAD_PROOF, }); } // Test 2: Proof generated with wrong blinding factor (rho). // The pedersen commitment PC = balance*G + rho*H requires the same rho // used in proof generation. Using a different rho breaks the linkage. { uint256 const contextHash = getConvertBackContextHash(bob, mptAlice.issuanceID(), env.seq(bob), version); Buffer const proof = mptAlice.getConvertBackProof( bob, amt, contextHash, { .pedersenCommitment = pedersenCommitment, .amt = spendingBalance, .encryptedAmt = encryptedSpendingBalance, .blindingFactor = generateBlindingFactor(), // wrong blinding factor }); mptAlice.convertBack({ .account = bob, .amt = amt, .proof = proof, .holderEncryptedAmt = bobCiphertext, .issuerEncryptedAmt = issuerCiphertext, .blindingFactor = blindingFactor, .pedersenCommitment = pedersenCommitment, .err = tecBAD_PROOF, }); } // Test 3: Proof generated with wrong balance value. // The proof claims balance=1 but the encrypted spending balance contains // the actual balance. Verification fails because the values don't match. { uint256 const contextHash = getConvertBackContextHash(bob, mptAlice.issuanceID(), env.seq(bob), version); Buffer const proof = mptAlice.getConvertBackProof( bob, amt, contextHash, { .pedersenCommitment = pedersenCommitment, .amt = 1, // wrong balance .encryptedAmt = encryptedSpendingBalance, .blindingFactor = pcBlindingFactor, }); mptAlice.convertBack({ .account = bob, .amt = amt, .proof = proof, .holderEncryptedAmt = bobCiphertext, .issuerEncryptedAmt = issuerCiphertext, .blindingFactor = blindingFactor, .pedersenCommitment = pedersenCommitment, .err = tecBAD_PROOF, }); } // Test 4: Correct proof but wrong pedersen commitment in transaction. // The proof is generated correctly, but the transaction submits a // different pedersen commitment. Verification fails because the // submitted commitment doesn't match what the proof was generated for. { uint256 const contextHash = getConvertBackContextHash(bob, mptAlice.issuanceID(), env.seq(bob), version); Buffer const badPedersenCommitment = mptAlice.getPedersenCommitment(1, pcBlindingFactor); Buffer const proof = mptAlice.getConvertBackProof( bob, amt, contextHash, { .pedersenCommitment = pedersenCommitment, .amt = spendingBalance, .encryptedAmt = encryptedSpendingBalance, .blindingFactor = pcBlindingFactor, }); mptAlice.convertBack({ .account = bob, .amt = amt, .proof = proof, .holderEncryptedAmt = bobCiphertext, .issuerEncryptedAmt = issuerCiphertext, .blindingFactor = blindingFactor, .pedersenCommitment = badPedersenCommitment, // wrong pedersen commitment .err = tecBAD_PROOF, }); } // Test 5: Proof generated with wrong context hash. // The context hash binds the proof to a specific transaction (account, // sequence, issuanceID, amount, version). Using a different context hash // makes the proof invalid for this transaction, preventing replay attacks. { uint256 const badContextHash{1}; Buffer const proof = mptAlice.getConvertBackProof( bob, amt, badContextHash, // wrong context hash { .pedersenCommitment = pedersenCommitment, .amt = spendingBalance, .encryptedAmt = encryptedSpendingBalance, .blindingFactor = pcBlindingFactor, }); mptAlice.convertBack({ .account = bob, .amt = amt, .proof = proof, .holderEncryptedAmt = bobCiphertext, .issuerEncryptedAmt = issuerCiphertext, .blindingFactor = blindingFactor, .pedersenCommitment = pedersenCommitment, .err = tecBAD_PROOF, }); } // Test 6: Correct proof to verify the test setup is valid. // All parameters are correct, so the transaction should succeed. { uint256 const contextHash = getConvertBackContextHash(bob, mptAlice.issuanceID(), env.seq(bob), version); Buffer const proof = mptAlice.getConvertBackProof( bob, amt, contextHash, { .pedersenCommitment = pedersenCommitment, .amt = spendingBalance, .encryptedAmt = encryptedSpendingBalance, .blindingFactor = pcBlindingFactor, }); mptAlice.convertBack({ .account = bob, .amt = amt, .proof = proof, .holderEncryptedAmt = bobCiphertext, .issuerEncryptedAmt = issuerCiphertext, .blindingFactor = blindingFactor, .pedersenCommitment = pedersenCommitment, }); } } void testConvertBackBulletproof(FeatureBitset features) { testcase("Convert back bulletproof"); using namespace test::jtx; Env env{*this, features}; Account const alice("alice"); Account const bob("bob"); ConfidentialEnv confEnv{ env, alice, {{.account = bob, .payAmount = 100, .convertAmount = 40}}}; auto& mptAlice = confEnv.mpt; // for ease of understanding, generate all the fields here instead of // autofilling uint64_t const amt = 10; Buffer const blindingFactor = generateBlindingFactor(); Buffer const pcBlindingFactor = generateBlindingFactor(); auto const spendingBalance = requireOptional( mptAlice.getDecryptedBalance(bob, MPTTester::holderEncryptedSpending), "Missing spending balance"); auto const encryptedSpendingBalance = requireOptional( mptAlice.getEncryptedBalance(bob, MPTTester::holderEncryptedSpending), "Missing encrypted spending balance"); BEAST_EXPECT(!encryptedSpendingBalance.empty()); Buffer const pedersenCommitment = mptAlice.getPedersenCommitment(spendingBalance, pcBlindingFactor); Buffer const issuerCiphertext = mptAlice.encryptAmount(alice, amt, blindingFactor); Buffer const bobCiphertext = mptAlice.encryptAmount(bob, amt, blindingFactor); auto const version = mptAlice.getMPTokenVersion(bob); // These tests verify that the compact ConvertBack proof (sigma + bulletproof) // correctly rejects proofs generated with incorrect parameters. // The compact proof simultaneously verifies balance ownership, commitment // linkage, and that the remaining balance is non-negative. // Test 1: Proof generated with wrong balance value. // The sigma proof claims balance=1 but the spending balance contains the // actual balance. The compact proof's balance-linkage check fails. { uint256 const contextHash = getConvertBackContextHash(bob, mptAlice.issuanceID(), env.seq(bob), version); Buffer const proof = mptAlice.getConvertBackProof( bob, amt, contextHash, { .pedersenCommitment = pedersenCommitment, .amt = 1, // wrong balance (actual balance is ~40) .encryptedAmt = encryptedSpendingBalance, .blindingFactor = pcBlindingFactor, }); mptAlice.convertBack({ .account = bob, .amt = amt, .proof = proof, .holderEncryptedAmt = bobCiphertext, .issuerEncryptedAmt = issuerCiphertext, .blindingFactor = blindingFactor, .pedersenCommitment = pedersenCommitment, .err = tecBAD_PROOF, }); } // Test 2: Proof generated with wrong blinding factor (rho). // The compact sigma proof must use the same blinding factor (rho) as the // Pedersen commitment PC = balance*G + rho*H. Using a different rho // creates an inconsistency the verifier detects. { uint256 const contextHash = getConvertBackContextHash(bob, mptAlice.issuanceID(), env.seq(bob), version); Buffer const proof = mptAlice.getConvertBackProof( bob, amt, contextHash, { .pedersenCommitment = pedersenCommitment, .amt = spendingBalance, .encryptedAmt = encryptedSpendingBalance, .blindingFactor = generateBlindingFactor(), // wrong blinding factor }); mptAlice.convertBack({ .account = bob, .amt = amt, .proof = proof, .holderEncryptedAmt = bobCiphertext, .issuerEncryptedAmt = issuerCiphertext, .blindingFactor = blindingFactor, .pedersenCommitment = pedersenCommitment, .err = tecBAD_PROOF, }); } // Test 3: Proof generated with wrong context hash. // The context hash binds the proof to a specific transaction (account, // sequence, issuanceID, amount, version). Using a different context hash // makes the proof invalid for this transaction, preventing replay attacks. { uint256 const badContextHash{1}; Buffer const proof = mptAlice.getConvertBackProof( bob, amt, badContextHash, // wrong context hash { .pedersenCommitment = pedersenCommitment, .amt = spendingBalance, .encryptedAmt = encryptedSpendingBalance, .blindingFactor = pcBlindingFactor, }); mptAlice.convertBack({ .account = bob, .amt = amt, .proof = proof, .holderEncryptedAmt = bobCiphertext, .issuerEncryptedAmt = issuerCiphertext, .blindingFactor = blindingFactor, .pedersenCommitment = pedersenCommitment, .err = tecBAD_PROOF, }); } // Test 4: Correct proof to verify the test setup is valid. // All parameters are correct, so the transaction should succeed. { uint256 const contextHash = getConvertBackContextHash(bob, mptAlice.issuanceID(), env.seq(bob), version); Buffer const proof = mptAlice.getConvertBackProof( bob, amt, contextHash, { .pedersenCommitment = pedersenCommitment, .amt = spendingBalance, .encryptedAmt = encryptedSpendingBalance, .blindingFactor = pcBlindingFactor, }); mptAlice.convertBack({ .account = bob, .amt = amt, .proof = proof, .holderEncryptedAmt = bobCiphertext, .issuerEncryptedAmt = issuerCiphertext, .blindingFactor = blindingFactor, .pedersenCommitment = pedersenCommitment, }); } } // A convert-back proof is bound to (account, issuance, sequence, version) via // the Fiat-Shamir context hash. Crafting a proof against any single wrong // variable and submitting it with the real parameters must be rejected // with tecBAD_PROOF void testConvertBackInvalidProofContextBinding(FeatureBitset features) { testcase("ConvertBack proof context binding"); using namespace test::jtx; auto runBadProof = [&](auto makeContextHash) { Env env{*this, features}; Account const alice("alice"); Account const bob("bob"); Account const carol("carol"); ConfidentialEnv confEnv{ env, alice, {{.account = bob, .payAmount = 100, .convertAmount = 40}}}; auto& mptAlice = confEnv.mpt; std::uint64_t const amt = 10; Buffer const blindingFactor = generateBlindingFactor(); Buffer const pcBlindingFactor = generateBlindingFactor(); auto const spendingBalance = mptAlice.getDecryptedBalance(bob, MPTTester::holderEncryptedSpending); auto const encryptedSpendingBalance = mptAlice.getEncryptedBalance(bob, MPTTester::holderEncryptedSpending); if (!BEAST_EXPECT(spendingBalance && encryptedSpendingBalance)) return; Buffer const pedersenCommitment = mptAlice.getPedersenCommitment( requireOptional(spendingBalance, "Missing spending balance"), pcBlindingFactor); Buffer const issuerCiphertext = mptAlice.encryptAmount(alice, amt, blindingFactor); Buffer const bobCiphertext = mptAlice.encryptAmount(bob, amt, blindingFactor); auto const version = mptAlice.getMPTokenVersion(bob); Buffer const proof = mptAlice.getConvertBackProof( bob, amt, makeContextHash(env, mptAlice, alice, bob, carol, version), { .pedersenCommitment = pedersenCommitment, .amt = requireOptional(spendingBalance, "Missing spending balance"), .encryptedAmt = requireOptionalRef( encryptedSpendingBalance, "Missing encrypted spending balance"), .blindingFactor = pcBlindingFactor, }); mptAlice.convertBack({ .account = bob, .amt = amt, .proof = proof, .holderEncryptedAmt = bobCiphertext, .issuerEncryptedAmt = issuerCiphertext, .blindingFactor = blindingFactor, .pedersenCommitment = pedersenCommitment, .err = tecBAD_PROOF, }); }; // Wrong account in the proof context. runBadProof([&](Env& env, MPTTester const& mpt, Account const&, Account const& bob, Account const& carol, std::uint32_t version) { return getConvertBackContextHash(carol.id(), mpt.issuanceID(), env.seq(bob), version); }); // Wrong issuance ID in the proof context. runBadProof([&](Env& env, MPTTester const&, Account const& alice, Account const& bob, Account const&, std::uint32_t version) { return getConvertBackContextHash( bob.id(), makeMptID(env.seq(alice) + 100, alice), env.seq(bob), version); }); // Wrong transaction sequence in the proof context. runBadProof([&](Env& env, MPTTester const& mpt, Account const&, Account const& bob, Account const&, std::uint32_t version) { return getConvertBackContextHash(bob.id(), mpt.issuanceID(), env.seq(bob) + 1, version); }); // Wrong balance version in the proof context. runBadProof([&](Env& env, MPTTester const& mpt, Account const&, Account const& bob, Account const&, std::uint32_t version) { return getConvertBackContextHash(bob.id(), mpt.issuanceID(), env.seq(bob), version + 1); }); } // This test simulates a valid proof π extracted from a transaction // for amount m1 is reused in a new transaction for a different // amount m2 with different ciphertexts. It confirms the context hash // recomputation fails due to the ciphertext binding mismatch, resulting // in tecBAD_PROOF. void testConvertBackProofCiphertextBinding(FeatureBitset features) { testcase("ConvertBack: proof ciphertext binding"); using namespace test::jtx; Env env{*this, features}; Account const alice("alice"), bob("bob"); ConfidentialEnv confEnv{ env, alice, {{.account = bob, .payAmount = 100, .convertAmount = 50}}}; auto& mptAlice = confEnv.mpt; auto const spendingBalance = requireOptional( mptAlice.getDecryptedBalance(bob, MPTTester::holderEncryptedSpending), "Missing spending balance"); auto const encryptedSpendingBalance = requireOptional( mptAlice.getEncryptedBalance(bob, MPTTester::holderEncryptedSpending), "Missing encrypted spending balance"); auto const version = mptAlice.getMPTokenVersion(bob); Buffer const pcBlindingFactor = generateBlindingFactor(); Buffer const pedersenCommitment = mptAlice.getPedersenCommitment(spendingBalance, pcBlindingFactor); // Generate a valid proof pi for Amount m1 = 10 uint64_t const amtA = 10; uint32_t const currentSeq = env.seq(bob); uint256 const contextHashA = getConvertBackContextHash(bob, mptAlice.issuanceID(), currentSeq, version); Buffer const proofA = mptAlice.getConvertBackProof( bob, amtA, contextHashA, { .pedersenCommitment = pedersenCommitment, .amt = spendingBalance, .encryptedAmt = encryptedSpendingBalance, .blindingFactor = pcBlindingFactor, }); // Construct Transaction B with Amount m2 = 20 and attach Proof pi uint64_t const amtB = 20; Buffer const blindingFactorB = generateBlindingFactor(); Buffer const bobCiphertextB = mptAlice.encryptAmount(bob, amtB, blindingFactorB); Buffer const issuerCiphertextB = mptAlice.encryptAmount(alice, amtB, blindingFactorB); // We attempt to verify the proof pi (for amt 10) against the new ciphertexts (for amt 20). mptAlice.convertBack({ .account = bob, .amt = amtB, .proof = proofA, // Extracted/Reused proof from Transaction A .holderEncryptedAmt = bobCiphertextB, .issuerEncryptedAmt = issuerCiphertextB, .blindingFactor = blindingFactorB, .pedersenCommitment = pedersenCommitment, .err = tecBAD_PROOF, // Expected failure }); } // This test simulates a valid proof π and ciphertext are // tied to version v, but are reused after an inbox merge has incremented // the CBS version to v+1. It confirms the validator rejects the transaction // before acceptance due to the ContextID mismatch. void testConvertBackProofVersionMismatch(FeatureBitset features) { testcase("ConvertBack: proof version mismatch"); using namespace test::jtx; Env env{*this, features}; Account const alice("alice"), bob("bob"); ConfidentialEnv confEnv{ env, alice, {{.account = bob, .payAmount = 1000, .convertAmount = 100}}}; auto& mptAlice = confEnv.mpt; auto const versionV = mptAlice.getMPTokenVersion(bob); auto const spendingBalanceV = requireOptional( mptAlice.getDecryptedBalance(bob, MPTTester::holderEncryptedSpending), "Missing spending balance"); auto const encryptedSpendingBalanceV = requireOptional( mptAlice.getEncryptedBalance(bob, MPTTester::holderEncryptedSpending), "Missing encrypted spending balance"); // Parameters for the intended ConvertBack transaction uint64_t const amt = 10; Buffer const blindingFactor = generateBlindingFactor(); Buffer const pcBlindingFactor = generateBlindingFactor(); Buffer const pedersenCommitment = mptAlice.getPedersenCommitment(spendingBalanceV, pcBlindingFactor); Buffer const issuerCiphertext = mptAlice.encryptAmount(alice, amt, blindingFactor); Buffer const bobCiphertext = mptAlice.encryptAmount(bob, amt, blindingFactor); // State Change: Increment version to v+1 // Converting more funds and merging increments the sfConfidentialBalanceVersion mptAlice.convert({ .account = bob, .amt = 50, }); mptAlice.mergeInbox({ .account = bob, }); BEAST_EXPECT(mptAlice.getMPTokenVersion(bob) > versionV); // Attack: Attempt to reuse proof tied to Version v at ledger Version v+1 uint32_t const currentSeq = env.seq(bob); // Proof is explicitly generated using the outdated Version v uint256 const oldContextHash = getConvertBackContextHash(bob, mptAlice.issuanceID(), currentSeq, versionV); Buffer const oldProof = mptAlice.getConvertBackProof( bob, amt, oldContextHash, { .pedersenCommitment = pedersenCommitment, .amt = spendingBalanceV, .encryptedAmt = encryptedSpendingBalanceV, .blindingFactor = pcBlindingFactor, }); // Submit and verify failure mptAlice.convertBack({ .account = bob, .amt = amt, .proof = oldProof, .holderEncryptedAmt = bobCiphertext, .issuerEncryptedAmt = issuerCiphertext, .blindingFactor = blindingFactor, .pedersenCommitment = pedersenCommitment, .err = tecBAD_PROOF, // Fails because TransactionContextID differs }); } /* This test simulates an attack where the holder ciphertext is modified * via homomorphic addition (adding Encrypted_amt(1)) while leaving the issuer * ciphertext unchanged. It confirms that the validator detects the * mismatch between the re-computed ciphertexts and the submitted ones, * resulting in tecBAD_PROOF. */ void testConvertBackHomomorphicCiphertextModification(FeatureBitset features) { testcase("ConvertBack: homomorphic ciphertext modification"); using namespace test::jtx; Env env{*this, features}; Account const alice("alice"), bob("bob"); ConfidentialEnv confEnv{ env, alice, {{.account = bob, .payAmount = 100, .convertAmount = 50}}}; auto& mptAlice = confEnv.mpt; // Prepare valid parameters for a ConvertBack of 10 uint64_t const amt = 10; Buffer const bf = generateBlindingFactor(); auto const holderCipherText = mptAlice.encryptAmount(bob, amt, bf); auto const issuerCipherText = mptAlice.encryptAmount(alice, amt, bf); // Generate a "Delta" ciphertext (Encrypting 1) // We use Bob's key because we are tampering with Bob's (Holder's) field Buffer const deltaBf = generateBlindingFactor(); auto const deltaCipherText = mptAlice.encryptAmount(bob, 1, deltaBf); // Homomorphically add Delta to HolderCipherText: Tampered = Enc(10) + Enc(1) = Enc(11) Buffer tamperedHolderCipherText = requireOptional( homomorphicAdd(holderCipherText, deltaCipherText), "Missing tampered ciphertext"); // Generate a valid proof for the ORIGINAL amount (10) auto const spendingBal = requireOptional( mptAlice.getDecryptedBalance(bob, MPTTester::holderEncryptedSpending), "Missing spending balance"); auto const spendingBalEnc = requireOptional( mptAlice.getEncryptedBalance(bob, MPTTester::holderEncryptedSpending), "Missing encrypted spending balance"); Buffer const pcBf = generateBlindingFactor(); auto const pedersenCommitment = mptAlice.getPedersenCommitment(spendingBal, pcBf); auto const currentVersion = mptAlice.getMPTokenVersion(bob); // Uses the new signature: Account, IssuanceID, Sequence, Version uint256 const contextHash = getConvertBackContextHash(bob, mptAlice.issuanceID(), env.seq(bob), currentVersion); Buffer const proof = mptAlice.getConvertBackProof( bob, amt, contextHash, { .pedersenCommitment = pedersenCommitment, .amt = spendingBal, .encryptedAmt = spendingBalEnc, .blindingFactor = pcBf, }); // Submit transaction with Divergent Ciphertexts // Holder Ciphertext encrypts 11. Issuer Ciphertext encrypts 10. // The consistency check (re-encryption of `amt` with `bf`) will match Issuer but FAIL for // Holder. mptAlice.convertBack({ .account = bob, .amt = amt, .proof = proof, .holderEncryptedAmt = tamperedHolderCipherText, // Tampered (11) .issuerEncryptedAmt = issuerCipherText, // Original (10) .blindingFactor = bf, .pedersenCommitment = pedersenCommitment, .err = tecBAD_PROOF, }); } /* This test verifies that xrpld correctly rejects attempts to * overflow the maximum allowable token amount via homomorphic manipulation. * It simulates an attack where an individual takes a valid ciphertext encrypting * the maximum amount (kMaxMpTokenAmount) and homomorphically adds an encryption of * 1 to it, producing a ciphertext for MAX+1. The test confirms that the Bulletproof * range proof or inner-product constraints detect this overflow and invalidate the * transaction, preserving the supply invariant. */ void testSendHomomorphicOverflow(FeatureBitset features) { testcase("Send: homomorphic overflow attack via Enc(MAX) + Enc(1)"); using namespace test::jtx; Env env{*this, features}; Account const alice("alice"), bob("bob"), carol("carol"); ConfidentialEnv confEnv{ env, alice, {{.account = bob, .payAmount = 100, .convertAmount = 100}, {.account = carol, .payAmount = 50, .convertAmount = 50}}}; auto& mptAlice = confEnv.mpt; // Bob sends 10 to carol. The send amount (10) and Bob's remaining balance // (90) are both within [0, kMaxMpTokenAmount]. Range proof passes. mptAlice.send({.account = bob, .dest = carol, .amt = 10}); // Bob's spending balance is 90 after the baseline send. auto const bobSpendingBefore = mptAlice.getDecryptedBalance(bob, MPTTester::holderEncryptedSpending); BEAST_EXPECT(bobSpendingBefore == 90); // Construct Enc(kMaxMpTokenAmount) with Bob's public key. Buffer const bf1 = generateBlindingFactor(); Buffer const encMax = mptAlice.encryptAmount(bob, kMaxMpTokenAmount, bf1); // Construct Enc(1) with a separate blinding factor. Buffer const bf2 = generateBlindingFactor(); Buffer const encOne = mptAlice.encryptAmount(bob, 1, bf2); // Homomorphically add to produce CB_S_holder' = Enc(MAX) + Enc(1) Buffer overflowedCt = requireOptional(homomorphicAdd(encMax, encOne), "Missing overflowed ciphertext"); // Submit the send transaction with the tampered ciphertext. // Setting amt = kMaxMpTokenAmount + 1 drives proof generation for the // overflowed value. The bulletproof range check [0, kMaxMpTokenAmount] // rejects MAX+1; the validator must return tecBAD_PROOF. mptAlice.send({ .account = bob, .dest = carol, .amt = kMaxMpTokenAmount + 1, .senderEncryptedAmt = overflowedCt, .err = tecBAD_PROOF, }); auto const bobSpendingAfter = mptAlice.getDecryptedBalance(bob, MPTTester::holderEncryptedSpending); BEAST_EXPECT(bobSpendingBefore == bobSpendingAfter); } /* This test ensures that the system prevents underflow attacks where a user * attempts to create a negative balance through homomorphic subtraction. It * simulates a scenario where an attacker takes a ciphertext encrypting zero * and subtracts an encryption of 1, resulting in a value of -1. * The test asserts that the range proof verification fails because the resulting * value falls outside the valid non-negative range [0, kMaxMpTokenAmount], * causing the validator to reject the transaction with tecBAD_PROOF. */ void testConvertBackHomomorphicUnderflow(FeatureBitset features) { testcase("ConvertBack: homomorphic underflow attack via Enc(0) - Enc(1)"); using namespace test::jtx; Env env{*this, features}; Account const alice("alice"), bob("bob"); ConfidentialEnv confEnv{ env, alice, {{.account = bob, .payAmount = 10, .convertAmount = 10}}}; auto& mptAlice = confEnv.mpt; // Converting back 1 from 10 leaves remaining balance = 9 (non-negative). // Range proof [0, kMaxMpTokenAmount] passes. mptAlice.convertBack({.account = bob, .amt = 1}); // Bob's spending balance is now 9; public balance is 1. auto const bobSpendingBefore = mptAlice.getDecryptedBalance(bob, MPTTester::holderEncryptedSpending); BEAST_EXPECT(bobSpendingBefore == 9); auto const bobPublicBefore = mptAlice.getBalance(bob); BEAST_EXPECT(bobPublicBefore == 1); // Construct Enc(0) — the zero encrypted balance using Bob's key. Buffer const bf1 = generateBlindingFactor(); Buffer const encZero = mptAlice.encryptAmount(bob, 0, bf1); // Construct Enc(1) with a separate blinding factor. Buffer const bf2 = generateBlindingFactor(); Buffer const encOne = mptAlice.encryptAmount(bob, 1, bf2); // Homomorphically subtract to produce CB_S_holder' = Enc(0) − Enc(1) // = Enc(−1), which lies below [0, kMaxMpTokenAmount]. Buffer underflowedCt = requireOptional(homomorphicSubtract(encZero, encOne), "Missing underflowed ciphertext"); // The underflowed value as uint64_t: 0 - 1 wraps to 0xFFFFFFFFFFFFFFFF. // Generate a real proof using this wrapped value. The validator must still reject it // because 0xFFFFFFFFFFFFFFFE (remaining balance) is outside [0, kMaxMpTokenAmount]. constexpr std::uint64_t kUnderflowedAmt = static_cast(0) - static_cast(1); Buffer const pcBf = generateBlindingFactor(); Buffer const pedersenCommitment = mptAlice.getPedersenCommitment(kUnderflowedAmt, pcBf); auto const currentVersion = mptAlice.getMPTokenVersion(bob); uint256 const contextHash = getConvertBackContextHash(bob, mptAlice.issuanceID(), env.seq(bob), currentVersion); Buffer const proof = mptAlice.getConvertBackProof( bob, 1, contextHash, { .pedersenCommitment = pedersenCommitment, .amt = kUnderflowedAmt, .encryptedAmt = underflowedCt, .blindingFactor = pcBf, }); mptAlice.convertBack({ .account = bob, .amt = 1, .proof = proof, .holderEncryptedAmt = underflowedCt, .pedersenCommitment = pedersenCommitment, .err = tecBAD_PROOF, }); // Supply invariant: both public and confidential balances must be unchanged // after the rejected attack. BEAST_EXPECT(mptAlice.getBalance(bob) == bobPublicBefore); auto const bobSpendingAfter = mptAlice.getDecryptedBalance(bob, MPTTester::holderEncryptedSpending); BEAST_EXPECT(bobSpendingBefore == bobSpendingAfter); } // Confidential sends carry encrypted amounts and a zero-knowledge proof. // Both are built from elliptic-curve math, so every coordinate in the // transaction must be a real point on the secp256k1 curve. These three // variants confirm the validator rejects garbage coordinates at the right // stage before any expensive cryptographic verification runs. void testSendInvalidCurvePoints(FeatureBitset features) { testcase("Send: off-curve EC points"); using namespace test::jtx; // Variant A: garbage coordinate in ciphertext / commitment fields // getBadCiphertext() looks structurally valid (correct length, right // prefix byte 0x02) but its x-coordinate is 0xFF...FF, which does not // lie on secp256k1. Preflight must reject before any ledger access. { Account const alice("alice"), bob("bob"), carol("carol"); Env env{*this, features}; ConfidentialEnv confEnv{ env, alice, {{.account = bob, .payAmount = 100, .convertAmount = 60}, {.account = carol, .payAmount = 50, .convertAmount = 30}}}; auto& mptAlice = confEnv.mpt; // sender's encrypted amount has an invalid coordinate mptAlice.send({ .account = bob, .dest = carol, .amt = 10, .proof = getTrivialSendProofHex(), .senderEncryptedAmt = getBadCiphertext(), .amountCommitment = getTrivialCommitment(), .balanceCommitment = getTrivialCommitment(), .err = temBAD_CIPHERTEXT, }); // recipient's encrypted amount has an invalid coordinate mptAlice.send({ .account = bob, .dest = carol, .amt = 10, .proof = getTrivialSendProofHex(), .destEncryptedAmt = getBadCiphertext(), .amountCommitment = getTrivialCommitment(), .balanceCommitment = getTrivialCommitment(), .err = temBAD_CIPHERTEXT, }); // issuer's encrypted amount has an invalid coordinate mptAlice.send({ .account = bob, .dest = carol, .amt = 10, .proof = getTrivialSendProofHex(), .issuerEncryptedAmt = getBadCiphertext(), .amountCommitment = getTrivialCommitment(), .balanceCommitment = getTrivialCommitment(), .err = temBAD_CIPHERTEXT, }); // The amount and balance commitments are single curve coordinates // used to tie the proof to the transfer amount and sender balance. // A commitment with a valid-looking prefix but an impossible // x-coordinate must also be rejected. Buffer badCommitment(kEcPedersenCommitmentLength); std::memset(badCommitment.data(), 0xFF, kEcPedersenCommitmentLength); badCommitment.data()[0] = kEcCompressedPrefixEvenY; mptAlice.send({ .account = bob, .dest = carol, .amt = 10, .proof = getTrivialSendProofHex(), .amountCommitment = badCommitment, .balanceCommitment = getTrivialCommitment(), .err = temMALFORMED, }); mptAlice.send({ .account = bob, .dest = carol, .amt = 10, .proof = getTrivialSendProofHex(), .amountCommitment = getTrivialCommitment(), .balanceCommitment = badCommitment, .err = temMALFORMED, }); } // Variant B: garbage coordinates inside the ZKP proof blob // The proof blob has the right total byte length (so it passes the // length check at preflight), but every embedded coordinate is // 0xFF...FF — impossible on secp256k1. The proof verifier must detect // this and return tecBAD_PROOF without crashing. { Account const alice("alice"), bob("bob"), carol("carol"); Env env{*this, features}; ConfidentialEnv confEnv{ env, alice, {{.account = bob, .payAmount = 100, .convertAmount = 60}, {.account = carol, .payAmount = 50, .convertAmount = 30}}}; auto& mptAlice = confEnv.mpt; Buffer badProof(kEcSendProofLength); std::memset(badProof.data(), 0xFF, kEcSendProofLength); badProof.data()[0] = kEcCompressedPrefixEvenY; mptAlice.send({ .account = bob, .dest = carol, .amt = 10, .proof = strHex(badProof), .err = tecBAD_PROOF, }); } // Variant C: only one of the two ciphertext coordinates is bad // Each encrypted amount is two coordinates back-to-back: C1 then C2. // Both must be valid. These tests corrupt only one at a time to // confirm both are checked independently. { Account const alice("alice"), bob("bob"), carol("carol"); Env env{*this, features}; ConfidentialEnv confEnv{ env, alice, {{.account = bob, .payAmount = 100, .convertAmount = 60}, {.account = carol, .payAmount = 50, .convertAmount = 30}}}; auto& mptAlice = confEnv.mpt; // getTrivialCiphertext() has both C1 and C2 as valid (but trivial) // curve coordinates. We replace one half at a time with 0xFF...FF. auto const& tc = getTrivialCiphertext(); // C1 = bad (0xFF...FF), C2 = valid trivial point Buffer badC1goodC2(kEcGamalEncryptedTotalLength); std::memset(badC1goodC2.data(), 0xFF, kEcGamalEncryptedTotalLength); badC1goodC2.data()[0] = kEcCompressedPrefixEvenY; std::memcpy( badC1goodC2.data() + kEcCiphertextComponentLength, tc.data() + kEcCiphertextComponentLength, kEcCiphertextComponentLength); // C1 = valid trivial point, C2 = bad (0xFF...FF) Buffer goodC1badC2(kEcGamalEncryptedTotalLength); std::memset(goodC1badC2.data(), 0xFF, kEcGamalEncryptedTotalLength); std::memcpy(goodC1badC2.data(), tc.data(), kEcCiphertextComponentLength); goodC1badC2.data()[kEcCiphertextComponentLength] = kEcCompressedPrefixEvenY; // sender's encrypted amount — bad C1 mptAlice.send({ .account = bob, .dest = carol, .amt = 10, .proof = getTrivialSendProofHex(), .senderEncryptedAmt = badC1goodC2, .amountCommitment = getTrivialCommitment(), .balanceCommitment = getTrivialCommitment(), .err = temBAD_CIPHERTEXT, }); // sender's encrypted amount — bad C2 mptAlice.send({ .account = bob, .dest = carol, .amt = 10, .proof = getTrivialSendProofHex(), .senderEncryptedAmt = goodC1badC2, .amountCommitment = getTrivialCommitment(), .balanceCommitment = getTrivialCommitment(), .err = temBAD_CIPHERTEXT, }); // recipient's encrypted amount — bad C1 mptAlice.send({ .account = bob, .dest = carol, .amt = 10, .proof = getTrivialSendProofHex(), .destEncryptedAmt = badC1goodC2, .amountCommitment = getTrivialCommitment(), .balanceCommitment = getTrivialCommitment(), .err = temBAD_CIPHERTEXT, }); // recipient's encrypted amount — bad C2 mptAlice.send({ .account = bob, .dest = carol, .amt = 10, .proof = getTrivialSendProofHex(), .destEncryptedAmt = goodC1badC2, .amountCommitment = getTrivialCommitment(), .balanceCommitment = getTrivialCommitment(), .err = temBAD_CIPHERTEXT, }); } } // Reject points from the wrong elliptic curve (wrong-group injection). // // An attacker might submit coordinates that come from a completely // different elliptic curve, for example, the one used in TLS // certificates (NIST P-256). If those coordinates happen to also be // valid points on secp256k1 (which is possible since both curves use // 256-bit fields), the format check at preflight will pass. However, // the zero-knowledge proof is built specifically for secp256k1: the // math inside the proof only holds for the right curve, so any // transaction carrying cross-curve data will still be rejected at // proof verification (tecBAD_PROOF). void testSendWrongGroupPointInjection(FeatureBitset features) { testcase("Send: wrong-group point injection rejected"); using namespace test::jtx; Env env{*this, features}; Account const alice("alice"), bob("bob"), carol("carol"); ConfidentialEnv confEnv{ env, alice, {{.account = bob, .payAmount = 100, .convertAmount = 60}, {.account = carol, .payAmount = 50, .convertAmount = 30}}}; auto& mptAlice = confEnv.mpt; // The x-coordinate of the NIST P-256 generator point — a real, // well-known value from a different elliptic curve (used in TLS // and certificates). This x-coordinate is also a valid secp256k1 // point, so it passes preflight. Rejection happens at proof // verification because the ZKP is secp256k1-specific. // // P-256 generator x: // 6B17D1F2E12C4247F8BCE6E563A440F277037D812DEB33A0F4A13945D898C296 static constexpr std::uint8_t kP256GeneratorX[32] = { 0x6B, 0x17, 0xD1, 0xF2, 0xE1, 0x2C, 0x42, 0x47, 0xF8, 0xBC, 0xE6, 0xE5, 0x63, 0xA4, 0x40, 0xF2, 0x77, 0x03, 0x7D, 0x81, 0x2D, 0xEB, 0x33, 0xA0, 0xF4, 0xA1, 0x39, 0x45, 0xD8, 0x98, 0xC2, 0x96, }; // A 66-byte encrypted amount using the P-256 x-coordinate for both halves. Buffer wrongGroupCt(kEcGamalEncryptedTotalLength); wrongGroupCt.data()[0] = kEcCompressedPrefixEvenY; std::memcpy(wrongGroupCt.data() + 1, kP256GeneratorX, 32); wrongGroupCt.data()[kEcCiphertextComponentLength] = kEcCompressedPrefixEvenY; std::memcpy(wrongGroupCt.data() + kEcCiphertextComponentLength + 1, kP256GeneratorX, 32); // A 33-byte commitment using the same wrong-curve x-coordinate. Buffer wrongGroupCommitment(kEcPedersenCommitmentLength); wrongGroupCommitment.data()[0] = kEcCompressedPrefixEvenY; std::memcpy(wrongGroupCommitment.data() + 1, kP256GeneratorX, 32); // sender's encrypted amount uses a coordinate from the wrong curve mptAlice.send({ .account = bob, .dest = carol, .amt = 10, .proof = getTrivialSendProofHex(), .senderEncryptedAmt = wrongGroupCt, .amountCommitment = getTrivialCommitment(), .balanceCommitment = getTrivialCommitment(), .err = tecBAD_PROOF, }); // recipient's encrypted amount uses a coordinate from the wrong curve mptAlice.send({ .account = bob, .dest = carol, .amt = 10, .proof = getTrivialSendProofHex(), .destEncryptedAmt = wrongGroupCt, .amountCommitment = getTrivialCommitment(), .balanceCommitment = getTrivialCommitment(), .err = tecBAD_PROOF, }); // issuer's encrypted amount uses a coordinate from the wrong curve mptAlice.send({ .account = bob, .dest = carol, .amt = 10, .proof = getTrivialSendProofHex(), .issuerEncryptedAmt = wrongGroupCt, .amountCommitment = getTrivialCommitment(), .balanceCommitment = getTrivialCommitment(), .err = tecBAD_PROOF, }); // amount commitment uses a coordinate from the wrong curve mptAlice.send({ .account = bob, .dest = carol, .amt = 10, .proof = getTrivialSendProofHex(), .amountCommitment = wrongGroupCommitment, .balanceCommitment = getTrivialCommitment(), .err = tecBAD_PROOF, }); // balance commitment uses a coordinate from the wrong curve mptAlice.send({ .account = bob, .dest = carol, .amt = 10, .proof = getTrivialSendProofHex(), .amountCommitment = getTrivialCommitment(), .balanceCommitment = wrongGroupCommitment, .err = tecBAD_PROOF, }); } // Reject an all-zero "null" public key. // // Every account in a confidential transfer needs a real public key — // a specific point on the secp256k1 curve derived from a secret number // only that account knows. An all-zero key (33 bytes of 0x00) is not // a real key. It has no secret behind it, and encrypting data to it // would not actually hide anything. The validator must reject it at // preflight so no account can ever register a broken key. void testConvertIdentityElementRejection(FeatureBitset features) { testcase("Convert: all-zero public key rejected"); using namespace test::jtx; // 33 zero bytes — not a real public key; no valid secret maps to this. Buffer const nullKey = gMakeZeroBuffer(kEcPubKeyLength); // Recipient (holder) tries to register an all-zero key. // Must be rejected so no account ends up with an unprotected balance. { Env env{*this, features}; Account const alice("alice"), bob("bob"), carol("carol"); MPTTester mptAlice(env, alice, {.holders = {bob, carol}}); mptAlice.create({ .ownerCount = 1, .flags = tfMPTCanTransfer | tfMPTCanLock | tfMPTCanHoldConfidentialBalance, }); mptAlice.authorize({.account = bob}); mptAlice.authorize({.account = carol}); mptAlice.pay(alice, bob, 100); mptAlice.pay(alice, carol, 50); mptAlice.generateKeyPair(alice); mptAlice.generateKeyPair(bob); mptAlice.generateKeyPair(carol); mptAlice.set({.account = alice, .issuerPubKey = mptAlice.getPubKey(alice)}); // recipient (carol) tries to register an all-zero key mptAlice.convert({ .account = carol, .amt = 10, .holderPubKey = nullKey, .err = temMALFORMED, }); // sender (bob) tries to register an all-zero key mptAlice.convert({ .account = bob, .amt = 10, .holderPubKey = nullKey, .err = temMALFORMED, }); } // Issuer tries to register an all-zero key. // The issuer's key is used to encrypt the issuer's copy of every // transfer amount. { Env env{*this, features}; Account const alice("alice"), bob("bob"); MPTTester mptAlice(env, alice, {.holders = {bob}}); mptAlice.create({ .ownerCount = 1, .flags = tfMPTCanTransfer | tfMPTCanLock | tfMPTCanHoldConfidentialBalance, }); mptAlice.authorize({.account = bob}); mptAlice.pay(alice, bob, 100); mptAlice.generateKeyPair(alice); mptAlice.generateKeyPair(bob); mptAlice.set({ .account = alice, .issuerPubKey = nullKey, .err = temMALFORMED, }); } } /* This test ensures that when sending confidential tokens, the encrypted * amounts are securely locked to the correct accounts' official public keys. * * Attack scenario — Encrypting the issuer's copy with the wrong key: * A sender correctly encrypts the hidden transfer amount for themselves * and the receiver. However, they intentionally encrypt the issuer's * copy of the data using the wrong public key (for example, using the * receiver's key instead of the official issuer's key). */ void testSendWrongIssuerPublicKey(FeatureBitset features) { testcase("Send: issuer ciphertext encrypted under wrong public key"); using namespace test::jtx; Env env{*this, features}; Account const alice("alice"), bob("bob"), carol("carol"); ConfidentialEnv confEnv{ env, alice, {{.account = bob, .payAmount = 100, .convertAmount = 100}, {.account = carol, .payAmount = 50, .convertAmount = 50}}}; auto& mptAlice = confEnv.mpt; auto const bobSpendingBefore = mptAlice.getDecryptedBalance(bob, MPTTester::holderEncryptedSpending); // issuer ciphertext encrypted under carol's holder key // (should be under alice's registered issuer key). { Buffer const bf = generateBlindingFactor(); Buffer const wrongIssuerCt = mptAlice.encryptAmount(carol, 10, bf); mptAlice.send({ .account = bob, .dest = carol, .amt = 10, .issuerEncryptedAmt = wrongIssuerCt, .err = tecBAD_PROOF, }); } // issuer ciphertext encrypted under bob's holder key // (the sender's own key — still not the registered issuer key). { Buffer const bf = generateBlindingFactor(); Buffer const wrongIssuerCt = mptAlice.encryptAmount(bob, 10, bf); mptAlice.send({ .account = bob, .dest = carol, .amt = 10, .issuerEncryptedAmt = wrongIssuerCt, .err = tecBAD_PROOF, }); } // all balances unchanged BEAST_EXPECT( mptAlice.getDecryptedBalance(bob, MPTTester::holderEncryptedSpending) == bobSpendingBefore); BEAST_EXPECT(mptAlice.getDecryptedBalance(carol, MPTTester::holderEncryptedInbox) == 0); } // This test verifies that the compact AND-composed Send sigma proof // enforces the shared-randomness invariant across participants. void testSendSharedRandomnessViolation(FeatureBitset features) { testcase("divergent C1 across participants in ConfidentialMPTSend"); using namespace test::jtx; Env env{*this, features}; Account const alice("alice"); Account const bob("bob"); Account const carol("carol"); Account const auditor("auditor"); ConfidentialEnv confEnv{ env, alice, {{.account = bob, .payAmount = 100, .convertAmount = 50}, {.account = carol, .payAmount = 50, .convertAmount = 50}}, tfMPTCanLock | tfMPTCanHoldConfidentialBalance | tfMPTCanTransfer, auditor}; auto& mptAlice = confEnv.mpt; // Send amount is 10. uint64_t const amt = 10; enum class Participant { Sender, Dest, Issuer, Auditor }; // This lambda submits a send transaction where one of the four ciphertexts // is encrypted with different randomness than the one used to build the proof. // Note: When divergent is nullopt, all participants // will use the same randomness and expected to succeed, this is the // control case that confirms the test setup itself is sound, the bad proof // is actually from divergent randomness, not other causes. auto submitWithDivergentC1 = [&](std::optional divergent) { ConfidentialSendSetup setup(mptAlice, bob, carol, alice, amt, std::cref(auditor)); auto const proofOpt = requireOptional(setup.generateProof(mptAlice, env, bob, carol), "Missing proof"); // Re-encrypt one participant's ciphertext with divergent randomness. Buffer senderCt = setup.senderAmt; Buffer destCt = setup.destAmt; Buffer issuerCt = setup.issuerAmt; Buffer auditorCt = requireOptionalRef(setup.auditorAmt, "Missing auditor encrypted amount"); if (divergent) { Buffer const bfDivergent = generateBlindingFactor(); switch (*divergent) { case Participant::Sender: senderCt = mptAlice.encryptAmount(bob, amt, bfDivergent); break; case Participant::Dest: destCt = mptAlice.encryptAmount(carol, amt, bfDivergent); break; case Participant::Issuer: issuerCt = mptAlice.encryptAmount(alice, amt, bfDivergent); break; case Participant::Auditor: auditorCt = mptAlice.encryptAmount(auditor, amt, bfDivergent); break; } } TER const expectedErr = divergent ? TER{tecBAD_PROOF} : TER{tesSUCCESS}; mptAlice.send({ .account = bob, .dest = carol, .amt = amt, .proof = strHex(proofOpt), .senderEncryptedAmt = senderCt, .destEncryptedAmt = destCt, .issuerEncryptedAmt = issuerCt, .auditorEncryptedAmt = auditorCt, .blindingFactor = setup.blindingFactor, .amountCommitment = setup.amountCommitment, .balanceCommitment = setup.balanceCommitment, .err = expectedErr, }); // Verify balances. auto const spendingAfter = mptAlice.getDecryptedBalance(bob, MPTTester::holderEncryptedSpending); if (divergent) { BEAST_EXPECT(spendingAfter == setup.prevSpending); } else { BEAST_EXPECT(spendingAfter == setup.prevSpending - amt); } }; // This confirms the test setup is sound, if any of the divergent cases below // fail, it is due to the C1 mismatch and not a setup bug. submitWithDivergentC1(std::nullopt); // Divergent C1 for different participants should all fail with tecBAD_PROOF: submitWithDivergentC1(Participant::Sender); submitWithDivergentC1(Participant::Dest); submitWithDivergentC1(Participant::Issuer); submitWithDivergentC1(Participant::Auditor); } void testConfidentialMPTBaseFee(FeatureBitset features) { testcase("test confidential transactions fee"); using namespace test::jtx; auto setup = [&](MPTTester& mpt, Account const& alice, Account const& bob, Account const& carol) { mpt.create({ .ownerCount = 1, .flags = tfMPTCanLock | tfMPTCanHoldConfidentialBalance | tfMPTCanTransfer | tfMPTCanClawback, }); mpt.authorize({.account = bob}); mpt.authorize({.account = carol}); mpt.pay(alice, bob, 100); mpt.pay(alice, carol, 50); mpt.generateKeyPair(alice); mpt.generateKeyPair(bob); mpt.generateKeyPair(carol); mpt.set({.account = alice, .issuerPubKey = mpt.getPubKey(alice)}); }; // test expected base fee for confidential transactions { Env env{*this, features}; Account const alice("alice"), bob("bob"), carol("carol"); MPTTester mptAlice(env, alice, {.holders = {bob, carol}}); setup(mptAlice, alice, bob, carol); auto const baseFee = env.current()->fees().base; auto const expectedFee = baseFee * (kConfidentialFeeMultiplier + 1); // lambda function to submit confidential transaction and check fee charged to the // account auto checkFee = [&](Account const& acct, auto&& submitFn) { auto const before = env.balance(acct); submitFn(); auto const after = env.balance(acct); BEAST_EXPECT(before - after == expectedFee); }; checkFee(bob, [&]() { mptAlice.convert( {.account = bob, .amt = 50, .holderPubKey = mptAlice.getPubKey(bob), .fee = expectedFee}); }); checkFee(carol, [&]() { mptAlice.convert( {.account = carol, .amt = 10, .holderPubKey = mptAlice.getPubKey(carol), .fee = expectedFee}); }); checkFee(bob, [&]() { mptAlice.mergeInbox({.account = bob, .fee = expectedFee}); }); checkFee(carol, [&]() { mptAlice.mergeInbox({.account = carol, .fee = expectedFee}); }); checkFee(bob, [&]() { mptAlice.send({.account = bob, .dest = carol, .amt = 5, .fee = expectedFee}); }); checkFee(bob, [&]() { mptAlice.convertBack({.account = bob, .amt = 5, .fee = expectedFee}); }); checkFee(alice, [&]() { mptAlice.confidentialClaw( {.account = alice, .holder = carol, .amt = 15, .fee = expectedFee}); }); } // test insufficient fee for confidential transactions { Env env{*this, features}; Account const alice("alice"), bob("bob"), carol("carol"); MPTTester mptAlice(env, alice, {.holders = {bob, carol}}); setup(mptAlice, alice, bob, carol); auto const baseFee = env.current()->fees().base; auto const expectedFee = baseFee * (kConfidentialFeeMultiplier + 1); mptAlice.convert( {.account = bob, .amt = 1, .holderPubKey = mptAlice.getPubKey(bob), .fee = expectedFee - 1, .err = telINSUF_FEE_P}); mptAlice.mergeInbox({.account = bob, .fee = baseFee, .err = telINSUF_FEE_P}); mptAlice.send( {.account = bob, .dest = carol, .amt = 1, .fee = baseFee * kConfidentialFeeMultiplier, .err = telINSUF_FEE_P}); mptAlice.convertBack({.account = bob, .amt = 1, .fee = baseFee, .err = telINSUF_FEE_P}); mptAlice.confidentialClaw( {.account = alice, .holder = carol, .amt = 1, .fee = baseFee, .err = telINSUF_FEE_P}); } // test excessive fee for confidential transactions { Env env{*this, features}; Account const alice("alice"), bob("bob"), carol("carol"); MPTTester mptAlice(env, alice, {.holders = {bob, carol}}); setup(mptAlice, alice, bob, carol); auto const baseFee = env.current()->fees().base; auto const highFee = baseFee * (kConfidentialFeeMultiplier + 1) * 2; auto const bobBefore = env.balance(bob); mptAlice.convert( {.account = bob, .amt = 1, .holderPubKey = mptAlice.getPubKey(bob), .fee = highFee}); BEAST_EXPECT(env.balance(bob) == bobBefore - highFee); } } void testSendForgedEqualityProof(FeatureBitset features) { testcase("Send: forged equality proof"); // Test that modifying a ciphertext after proof generation causes // verification to fail. The Fiat-Shamir challenge binds ciphertexts // to the proof, so any modification invalidates the proof. using namespace test::jtx; Env env{*this, features}; Account const alice("alice"), bob("bob"), carol("carol"); ConfidentialEnv confEnv{ env, alice, {{.account = bob}, {.account = carol, .payAmount = 1000, .convertAmount = 50}}}; auto& mptAlice = confEnv.mpt; ConfidentialSendSetup const setup(mptAlice, bob, carol, alice, 10); // Forge destination ciphertext (Enc(20) instead of Enc(10)) { auto const proof = setup.generateProof(mptAlice, env, bob, carol); if (!BEAST_EXPECT(proof.has_value())) return; Buffer const forgedBlindingFactor = generateBlindingFactor(); auto const forgedDestAmt = mptAlice.encryptAmount(carol, 20, forgedBlindingFactor); auto args = setup.sendArgs( bob, carol, requireOptionalRef(proof, "Missing proof"), tecBAD_PROOF); args.destEncryptedAmt = forgedDestAmt; mptAlice.send(args); } // Forge sender's ciphertext (Enc(5) instead of Enc(10)) { auto const proof = setup.generateProof(mptAlice, env, bob, carol); if (!BEAST_EXPECT(proof.has_value())) return; Buffer const forgedBlindingFactor = generateBlindingFactor(); auto const forgedSenderAmt = mptAlice.encryptAmount(bob, 5, forgedBlindingFactor); auto args = setup.sendArgs( bob, carol, requireOptionalRef(proof, "Missing proof"), tecBAD_PROOF); args.senderEncryptedAmt = forgedSenderAmt; mptAlice.send(args); } // Forge issuer's ciphertext (Enc(100) instead of Enc(10)) { auto const proof = setup.generateProof(mptAlice, env, bob, carol); if (!BEAST_EXPECT(proof.has_value())) return; Buffer const forgedBlindingFactor = generateBlindingFactor(); auto const forgedIssuerAmt = mptAlice.encryptAmount(alice, 100, forgedBlindingFactor); auto args = setup.sendArgs( bob, carol, requireOptionalRef(proof, "Missing proof"), tecBAD_PROOF); args.issuerEncryptedAmt = forgedIssuerAmt; mptAlice.send(args); } } void testSendForgedRangeProof(FeatureBitset features) { testcase("Send: forged range proof"); // Attack: send uint64_max tokens using Enc(uint64_max) ciphertexts // and a corrupted bulletproof. Verifier rejects due to inner-product // mismatch and Fiat-Shamir transcript divergence. Supply invariant // is preserved. using namespace test::jtx; Env env{*this, features}; Account const alice("alice"), bob("bob"), carol("carol"); ConfidentialEnv confEnv{ env, alice, {{.account = bob}, {.account = carol, .payAmount = 1000, .convertAmount = 50}}}; auto& mptAlice = confEnv.mpt; uint64_t const badAmount = std::numeric_limits::max(); Buffer const blindingFactor = generateBlindingFactor(); // Construct Enc(uint64_max) ciphertexts and commitment. auto const senderAmt = mptAlice.encryptAmount(bob, badAmount, blindingFactor); auto const destAmt = mptAlice.encryptAmount(carol, badAmount, blindingFactor); auto const issuerAmt = mptAlice.encryptAmount(alice, badAmount, blindingFactor); auto const amountCommitment = mptAlice.getPedersenCommitment(badAmount, blindingFactor); // Balance commitment for Bob's actual balance. auto const prevSpending = requireOptional( mptAlice.getDecryptedBalance(bob, MPTTester::holderEncryptedSpending), "Missing previous spending balance"); auto const balanceBlindingFactor = generateBlindingFactor(); auto const balanceCommitment = mptAlice.getPedersenCommitment(prevSpending, balanceBlindingFactor); // Generate a valid proof for a legitimate amount, then corrupt // the bulletproof segment to simulate a forged range proof. ConfidentialSendSetup const setup(mptAlice, bob, carol, alice, 10); auto const validProof = setup.generateProof(mptAlice, env, bob, carol); if (!BEAST_EXPECT(validProof.has_value())) return; // Corrupt bulletproof bytes. Buffer forgedProof = requireOptional(validProof, "Missing valid proof"); for (size_t i = kBulletproofOffset; i < forgedProof.size(); i += 7) forgedProof.data()[i] ^= 0xFF; // Submit — rejected due to commitment mismatch. mptAlice.send( {.account = bob, .dest = carol, .amt = badAmount, .proof = strHex(forgedProof), .senderEncryptedAmt = senderAmt, .destEncryptedAmt = destAmt, .issuerEncryptedAmt = issuerAmt, .amountCommitment = amountCommitment, .balanceCommitment = balanceCommitment, .err = tecBAD_PROOF}); // Supply invariant: Bob's balance unchanged. auto const postSpending = requireOptional( mptAlice.getDecryptedBalance(bob, MPTTester::holderEncryptedSpending), "Missing post spending balance"); BEAST_EXPECT(postSpending == prevSpending); } void testSendNegativeValueMalleability(FeatureBitset features) { testcase("Send: negative value malleability"); // Attack: forge a bulletproof claiming remaining = (uint64_t)(-10). // Bob has 10 tokens, sends 10. Honest remaining is 0, but the // forged proof claims 0xFFFFFFFFFFFFFFF6. Rejected because // PC(0) != PC(0xFFFFFFFFFFFFFFF6). using namespace test::jtx; // Bob converts exactly 10 tokens, leaving honest remaining = 0. Env env{*this, features}; Account const alice("alice"), bob("bob"), carol("carol"); ConfidentialEnv confEnv{ env, alice, {{.account = bob, .payAmount = 1000, .convertAmount = 10}, {.account = carol, .payAmount = 1000, .convertAmount = 50}}}; auto& mptAlice = confEnv.mpt; uint64_t const sendAmount = 10; auto const negativeRemaining = static_cast(-10); // 0xFFFFFFFFFFFFFFF6 ConfidentialSendSetup const setup(mptAlice, bob, carol, alice, sendAmount); auto const ctxHash = getSendContextHash( bob.id(), mptAlice.issuanceID(), env.seq(bob), carol.id(), setup.version); auto const validProof = setup.generateProof(mptAlice, env, bob, carol); if (!BEAST_EXPECT(validProof.has_value())) return; // Forge bulletproof for {10, 0xFFFFFFFFFFFFFFF6} and splice it in. auto const forgedBulletproof = getForgedBulletproof( {sendAmount, negativeRemaining}, {setup.amountBlindingFactor, setup.balanceBlindingFactor}, ctxHash); Buffer forgedProof(requireOptionalRef(validProof, "Missing valid proof").size()); std::memcpy( forgedProof.data(), requireOptionalRef(validProof, "Missing valid proof").data(), kBulletproofOffset); std::memcpy( forgedProof.data() + kBulletproofOffset, forgedBulletproof.data(), kEcDoubleBulletproofLength); mptAlice.send(setup.sendArgs(bob, carol, forgedProof, tecBAD_PROOF)); // Supply invariant: Bob's balance unchanged. auto const postSpending = requireOptional( mptAlice.getDecryptedBalance(bob, MPTTester::holderEncryptedSpending), "Missing post spending balance"); BEAST_EXPECT(postSpending == setup.prevSpending); } void testSendInvalidProofContextBinding(FeatureBitset features) { testcase("Send proof context binding"); using namespace test::jtx; auto runBadProof = [&](auto makeContextHash) { Env env{*this, features}; Account const alice("alice"); Account const bob("bob"); Account const carol("carol"); ConfidentialEnv confEnv{ env, alice, {{.account = bob, .payAmount = 100, .convertAmount = 40}, {.account = carol}}}; auto& mptAlice = confEnv.mpt; ConfidentialSendSetup const setup(mptAlice, bob, carol, alice, 10); auto const proof = mptAlice.getConfidentialSendProof( bob, setup.sendAmount, setup.recipients, setup.blindingFactor, makeContextHash(env, mptAlice, alice, bob, carol, setup.version), { .pedersenCommitment = setup.amountCommitment, .amt = setup.sendAmount, .encryptedAmt = setup.senderAmt, .blindingFactor = setup.amountBlindingFactor, }, { .pedersenCommitment = setup.balanceCommitment, .amt = setup.prevSpending, .encryptedAmt = setup.prevEncryptedSpending, .blindingFactor = setup.balanceBlindingFactor, }); if (!BEAST_EXPECT(proof.has_value())) return; mptAlice.send(setup.sendArgs( bob, carol, requireOptionalRef(proof, "Missing proof"), tecBAD_PROOF)); }; // Wrong sender account in the proof context. runBadProof([&](Env& env, MPTTester const& mpt, Account const&, Account const& bob, Account const& carol, std::uint32_t version) { return getSendContextHash( carol.id(), mpt.issuanceID(), env.seq(bob), carol.id(), version); }); // Wrong issuance ID in the proof context. runBadProof([&](Env& env, MPTTester const&, Account const& alice, Account const& bob, Account const& carol, std::uint32_t version) { return getSendContextHash( bob.id(), makeMptID(env.seq(alice) + 100, alice), env.seq(bob), carol.id(), version); }); // Wrong transaction sequence in the proof context. runBadProof([&](Env& env, MPTTester const& mpt, Account const&, Account const& bob, Account const& carol, std::uint32_t version) { return getSendContextHash( bob.id(), mpt.issuanceID(), env.seq(bob) + 1, carol.id(), version); }); // Wrong destination in the proof context. runBadProof([&](Env& env, MPTTester const& mpt, Account const&, Account const& bob, Account const&, std::uint32_t version) { return getSendContextHash(bob.id(), mpt.issuanceID(), env.seq(bob), bob.id(), version); }); // Wrong balance version in the proof context. runBadProof([&](Env& env, MPTTester const& mpt, Account const&, Account const& bob, Account const& carol, std::uint32_t version) { return getSendContextHash( bob.id(), mpt.issuanceID(), env.seq(bob), carol.id(), version + 1); }); } void testSendFiatShamirBinding(FeatureBitset features) { testcase("Send: Fiat-Shamir Binding"); using namespace test::jtx; Env env{*this, features}; Account const alice("alice"), bob("bob"), carol("carol"); ConfidentialEnv confEnv{ env, alice, {{.account = bob}, {.account = carol, .payAmount = 1000, .convertAmount = 50}}}; auto& mptAlice = confEnv.mpt; ConfidentialSendSetup const setup(mptAlice, bob, carol, alice, 10); // Variant A: forged amount commitment. { auto const proof = setup.generateProof(mptAlice, env, bob, carol); if (!BEAST_EXPECT(proof.has_value())) return; auto const forgedBlindingFactor = generateBlindingFactor(); auto const forgedCommitment = mptAlice.getPedersenCommitment(setup.sendAmount + 5, forgedBlindingFactor); auto args = setup.sendArgs( bob, carol, requireOptionalRef(proof, "Missing proof"), tecBAD_PROOF); args.amountCommitment = forgedCommitment; mptAlice.send(args); } // Variant B: proof replay at a different sequence. { auto const proof = setup.generateProof(mptAlice, env, bob, carol); if (!BEAST_EXPECT(proof.has_value())) return; mptAlice.pay(bob, carol, 1); env.close(); mptAlice.send(setup.sendArgs( bob, carol, requireOptionalRef(proof, "Missing proof"), tecBAD_PROOF)); } // Variant C: tampered response scalars. { auto const proof = setup.generateProof(mptAlice, env, bob, carol); if (!BEAST_EXPECT(proof.has_value())) return; auto const& proofRef = requireOptionalRef(proof, "Missing proof"); Buffer tamperedProof(proofRef.size()); std::memcpy(tamperedProof.data(), proofRef.data(), proofRef.size()); size_t const tamperOffset = tamperedProof.size() / 2; tamperedProof.data()[tamperOffset] ^= 0xFF; mptAlice.send(setup.sendArgs(bob, carol, tamperedProof, tecBAD_PROOF)); } } void testSendProofComponentReuse(FeatureBitset features) { testcase("Send: Proof Component Reuse"); using namespace test::jtx; Env env{*this, features}; Account const alice("alice"), bob("bob"), carol("carol"), dan("dan"); ConfidentialEnv confEnv{ env, alice, {{.account = bob}, {.account = carol, .payAmount = 1000, .convertAmount = 50}, {.account = dan, .payAmount = 1000, .convertAmount = 50}}}; auto& mptAlice = confEnv.mpt; uint64_t const sendAmount = 10; // Variant A: replay proof to same destination after sequence changes. { ConfidentialSendSetup const setup(mptAlice, bob, carol, alice, sendAmount); auto const proof = setup.generateProof(mptAlice, env, bob, carol); if (!BEAST_EXPECT(proof.has_value())) return; mptAlice.send(setup.sendArgs(bob, carol, requireOptionalRef(proof, "Missing proof"))); mptAlice.mergeInbox({.account = carol}); mptAlice.send(setup.sendArgs( bob, carol, requireOptionalRef(proof, "Missing proof"), tecBAD_PROOF)); } // Variant B: replay proof to a different destination. { ConfidentialSendSetup const setup(mptAlice, bob, carol, alice, sendAmount); auto const proof = setup.generateProof(mptAlice, env, bob, carol); if (!BEAST_EXPECT(proof.has_value())) return; mptAlice.send(setup.sendArgs(bob, carol, requireOptionalRef(proof, "Missing proof"))); mptAlice.mergeInbox({.account = carol}); auto const destAmtDan = mptAlice.encryptAmount(dan, sendAmount, setup.blindingFactor); auto const issuerAmtDan = mptAlice.encryptAmount(alice, sendAmount, setup.blindingFactor); auto args = setup.sendArgs(bob, dan, requireOptionalRef(proof, "Missing proof"), tecBAD_PROOF); args.destEncryptedAmt = destAmtDan; args.issuerEncryptedAmt = issuerAmtDan; mptAlice.send(args); } } void testSendSpecialWitnessValues(FeatureBitset features) { testcase("Send: special witness values"); using namespace test::jtx; Env env{*this, features}; Account const alice("alice"), bob("bob"), carol("carol"); ConfidentialEnv confEnv{ env, alice, {{.account = bob}, {.account = carol, .payAmount = 1000, .convertAmount = 50}}}; auto& mptAlice = confEnv.mpt; ConfidentialSendSetup const setup(mptAlice, bob, carol, alice, 10); // Variant A: zero-valued response scalars. { auto const proof = setup.generateProof(mptAlice, env, bob, carol); if (!BEAST_EXPECT(proof.has_value())) return; Buffer forgedProof = requireOptionalRef(proof, "Missing proof"); static constexpr size_t kSigmaScalarSize = 32; static constexpr size_t kChallengeOffset = 0; static constexpr size_t kResponseOffset = kChallengeOffset + kSigmaScalarSize; static constexpr size_t kResponseSize = 5 * kSigmaScalarSize; // z_m..z_sk std::memset(forgedProof.data() + kResponseOffset, 0, kResponseSize); mptAlice.send(setup.sendArgs(bob, carol, forgedProof, tecBAD_PROOF)); } // Variant B: identity element in ciphertext. { auto const proof = setup.generateProof(mptAlice, env, bob, carol); if (!BEAST_EXPECT(proof.has_value())) return; Buffer invalidCiphertext(kEcGamalEncryptedTotalLength); std::memset(invalidCiphertext.data(), 0, kEcGamalEncryptedTotalLength); auto args = setup.sendArgs( bob, carol, requireOptionalRef(proof, "Missing proof"), temBAD_CIPHERTEXT); args.senderEncryptedAmt = invalidCiphertext; mptAlice.send(args); } // Variant B2: identity element in commitment. { auto const proof = setup.generateProof(mptAlice, env, bob, carol); if (!BEAST_EXPECT(proof.has_value())) return; Buffer invalidCommitment(kEcPedersenCommitmentLength); std::memset(invalidCommitment.data(), 0, kEcPedersenCommitmentLength); auto args = setup.sendArgs( bob, carol, requireOptionalRef(proof, "Missing proof"), temMALFORMED); args.amountCommitment = invalidCommitment; mptAlice.send(args); } // Variant C: boundary scalar (curve order). { auto const proof = setup.generateProof(mptAlice, env, bob, carol); if (!BEAST_EXPECT(proof.has_value())) return; Buffer forgedProof = requireOptionalRef(proof, "Missing proof"); static constexpr unsigned char kCurveOrder[32] = { 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, // 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFE, // 0xBA, 0xAE, 0xDC, 0xE6, 0xAF, 0x48, 0xA0, 0x3B, // 0xBF, 0xD2, 0x5E, 0x8C, 0xD0, 0x36, 0x41, 0x41 // }; std::memcpy(forgedProof.data() + 32, kCurveOrder, 32); mptAlice.send(setup.sendArgs(bob, carol, forgedProof, tecBAD_PROOF)); } // Variant C2: overflow scalar (curve order + 1). { auto const proof = setup.generateProof(mptAlice, env, bob, carol); if (!BEAST_EXPECT(proof.has_value())) return; Buffer forgedProof = requireOptionalRef(proof, "Missing proof"); static constexpr unsigned char kOverflowScalar[32] = { 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, // 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFE, // 0xBA, 0xAE, 0xDC, 0xE6, 0xAF, 0x48, 0xA0, 0x3B, // 0xBF, 0xD2, 0x5E, 0x8C, 0xD0, 0x36, 0x41, 0x42 // }; std::memcpy(forgedProof.data() + 32, kOverflowScalar, 32); mptAlice.send(setup.sendArgs(bob, carol, forgedProof, tecBAD_PROOF)); } } void testSendCrossStatementProofSubstitution(FeatureBitset features) { testcase("Send: cross-statement proof substitution"); // This test verifies that proofs generated for one protocol component // cannot be used in place of another, and that proofs bound to // different public parameters are rejected. using namespace test::jtx; Env env{*this, features}; Account const alice("alice"), bob("bob"), carol("carol"); ConfidentialEnv confEnv{ env, alice, {{.account = bob}, {.account = carol, .payAmount = 1000, .convertAmount = 50}}, tfMPTCanLock | tfMPTCanHoldConfidentialBalance | tfMPTCanTransfer | tfMPTCanClawback}; auto& mptAlice = confEnv.mpt; uint64_t const sendAmount = 10; // Variant A: Swap proof type (cross-statement substitution) // ----------------------------------------------------------------- // Attack: Generate a valid convertBack proof (compact sigma + // single bulletproof) and attempt to use it as the ZK proof in a // ConfidentialMPTSend transaction. // // Expected: The send proof has a different structure // (equality + 2×pedersen + double bulletproof). Even if sized to // match, the domain-separated Fiat-Shamir transcript differs, // so verification equations fail. { ConfidentialSendSetup const setup(mptAlice, bob, carol, alice, sendAmount); // Generate a valid convertBack proof for bob auto const spendingBalance = requireOptional( mptAlice.getDecryptedBalance(bob, MPTTester::holderEncryptedSpending), "Missing spending balance"); auto const encryptedSpending = requireOptional( mptAlice.getEncryptedBalance(bob, MPTTester::holderEncryptedSpending), "Missing encrypted spending balance"); Buffer const pcBlindingFactor = generateBlindingFactor(); Buffer const pedersenCommitment = mptAlice.getPedersenCommitment(spendingBalance, pcBlindingFactor); auto const version = mptAlice.getMPTokenVersion(bob); uint256 const convertBackCtxHash = getConvertBackContextHash(bob.id(), mptAlice.issuanceID(), env.seq(bob), version); Buffer const convertBackProof = mptAlice.getConvertBackProof( bob, sendAmount, convertBackCtxHash, { .pedersenCommitment = pedersenCommitment, .amt = spendingBalance, .encryptedAmt = encryptedSpending, .blindingFactor = pcBlindingFactor, }); // Resize the convertBack proof to match the expected send proof // size so it passes preflight's size check and reaches the actual // ZK verification in doApply. auto const expectedSendSize = kEcSendProofLength; Buffer resizedProof(expectedSendSize); auto const copyLen = std::min(convertBackProof.size(), expectedSendSize); std::memcpy(resizedProof.data(), convertBackProof.data(), copyLen); // Zero-pad the rest (if convertBack proof is shorter) if (copyLen < expectedSendSize) std::memset(resizedProof.data() + copyLen, 0, expectedSendSize - copyLen); mptAlice.send(setup.sendArgs(bob, carol, resizedProof, tecBAD_PROOF)); } // Variant B: Valid proof bound to wrong public parameters // ----------------------------------------------------------------- // Attack: Generate a valid send proof using a wrong context hash // (computed with a different issuanceID). The proof is // mathematically valid for the wrong statement, but when the // verifier recomputes the Fiat-Shamir challenge using the correct // issuanceID, the challenge differs and verification fails. { ConfidentialSendSetup const setup(mptAlice, bob, carol, alice, sendAmount); // Compute context hash with a fabricated (wrong) issuanceID uint192 const fakeIssuanceID{1}; auto const wrongCtxHash = getSendContextHash( bob.id(), fakeIssuanceID, env.seq(bob), carol.id(), setup.version); // Generate a proof that is valid for the wrong issuanceID auto const wrongProof = mptAlice.getConfidentialSendProof( bob, sendAmount, setup.recipients, setup.blindingFactor, wrongCtxHash, { .pedersenCommitment = setup.amountCommitment, .amt = sendAmount, .encryptedAmt = setup.senderAmt, .blindingFactor = setup.amountBlindingFactor, }, { .pedersenCommitment = setup.balanceCommitment, .amt = setup.prevSpending, .encryptedAmt = setup.prevEncryptedSpending, .blindingFactor = setup.balanceBlindingFactor, }); if (!BEAST_EXPECT(wrongProof.has_value())) return; // Submit with the correct issuanceID — verifier recomputes // the challenge using the real issuanceID, which differs from // the one baked into the proof. mptAlice.send(setup.sendArgs( bob, carol, requireOptionalRef(wrongProof, "Missing wrong proof"), tecBAD_PROOF)); } } void testSendCiphertextMalleability(FeatureBitset features) { testcase("Send: ciphertext malleability"); // Attack: replace ElGamal ciphertext Enc(m) with Enc(2m) to inflate // the amount credited to the recipient. ElGamal is homomorphic, so // scalar multiplication (C1, C2) → (k*C1, k*C2) decrypts to k*m. using namespace test::jtx; Env env{*this, features}; Account const alice("alice"), bob("bob"), carol("carol"); ConfidentialEnv confEnv{ env, alice, {{.account = bob}, {.account = carol, .payAmount = 1000, .convertAmount = 50}}, tfMPTCanTransfer | tfMPTCanHoldConfidentialBalance}; auto& mptAlice = confEnv.mpt; uint64_t const sendAmount = 10; // Variant A: Post-signature tampering. // Build a valid signed transaction, then replace the destination // ciphertext with Enc(2m) in the serialized blob. The original // signature no longer covers the modified data. { auto const seq = env.seq(bob); auto jv = mptAlice.sendJV({.account = bob, .dest = carol, .amt = sendAmount}, seq); auto jtx = env.jt(jv); BEAST_EXPECT(jtx.stx); // Serialize signed tx, deserialize into mutable STObject Serializer s; jtx.stx->add(s); SerialIter sit(s.slice()); STObject obj(sit, sfTransaction); // Replace dest ciphertext with Enc(2m) — a valid EC point // encrypting an inflated amount under carol's key Buffer const bf = generateBlindingFactor(); auto const inflatedCiphertext = mptAlice.encryptAmount(carol, sendAmount * 2, bf); obj.setFieldVL(sfDestinationEncryptedAmount, inflatedCiphertext); // Re-serialize with the original (now-stale) signature Serializer tampered; obj.add(tampered); // Signature verification fails — rejected before ZKP check auto const jr = env.rpc("submit", strHex(tampered.slice())); BEAST_EXPECT(jr[jss::result][jss::error] == "invalidTransaction"); } // Variant B: Re-signed with inflated ciphertext. // Generate a valid proof for amount m, then replace the destination // ciphertext with Enc(2m) and re-sign. Signature passes, but the // compact sigma proof fails: the proof binds Enc(m) to the Pedersen // commitment PC(m, r), so substituting Enc(2m) breaks the linkage. { ConfidentialSendSetup const setup(mptAlice, bob, carol, alice, sendAmount); auto const ctxHash = getSendContextHash( bob.id(), mptAlice.issuanceID(), env.seq(bob), carol.id(), setup.version); auto const validProof = mptAlice.getConfidentialSendProof( bob, sendAmount, setup.recipients, setup.blindingFactor, ctxHash, { .pedersenCommitment = setup.amountCommitment, .amt = sendAmount, .encryptedAmt = setup.senderAmt, .blindingFactor = setup.amountBlindingFactor, }, { .pedersenCommitment = setup.balanceCommitment, .amt = setup.prevSpending, .encryptedAmt = setup.prevEncryptedSpending, .blindingFactor = setup.balanceBlindingFactor, }); if (!BEAST_EXPECT(validProof.has_value())) return; // Replace dest ciphertext with Enc(2m) using the same blinding // factor — even with matching randomness the proof rejects // because the committed plaintext differs auto const inflatedDestAmt = mptAlice.encryptAmount(carol, sendAmount * 2, setup.blindingFactor); auto args = setup.sendArgs( bob, carol, requireOptionalRef(validProof, "Missing valid proof"), tecBAD_PROOF); args.destEncryptedAmt = inflatedDestAmt; mptAlice.send(args); } } void testSendCiphertextNegation(FeatureBitset features) { testcase("Send: ciphertext negation"); // Attack: negate ciphertext -Enc(m) = (-C1, -C2) to reverse the // transaction direction. Negation decrypts to the group-level // additive inverse of m*G, effectively turning a credit into a debit. using namespace test::jtx; Env env{*this, features}; Account const alice("alice"), bob("bob"), carol("carol"); ConfidentialEnv confEnv{ env, alice, {{.account = bob}, {.account = carol, .payAmount = 1000, .convertAmount = 50}}, tfMPTCanTransfer | tfMPTCanHoldConfidentialBalance}; auto& mptAlice = confEnv.mpt; uint64_t const sendAmount = 10; // Negate an ElGamal ciphertext by flipping the y-coordinate parity // of both compressed EC points. For secp256k1 compressed form, // prefix 0x02 means even-y and 0x03 means odd-y; negation // swaps them: -P has the same x but opposite y. auto negateCiphertext = [](Buffer const& ct) -> Buffer { Buffer neg = ct; neg.data()[0] ^= 0x01; // negate C1 neg.data()[kEcCiphertextComponentLength] ^= 0x01; // negate C2 return neg; }; // Variant A: Post-signature negation. // Negate the destination ciphertext in the signed blob. // Signature no longer covers the modified field. { auto const seq = env.seq(bob); auto jv = mptAlice.sendJV({.account = bob, .dest = carol, .amt = sendAmount}, seq); auto jtx = env.jt(jv); BEAST_EXPECT(jtx.stx); Serializer s; jtx.stx->add(s); SerialIter sit(s.slice()); STObject obj(sit, sfTransaction); auto const origDestAmt = obj.getFieldVL(sfDestinationEncryptedAmount); Buffer const origBuf(origDestAmt.data(), origDestAmt.size()); auto const negDestAmt = negateCiphertext(origBuf); obj.setFieldVL( sfDestinationEncryptedAmount, Slice(negDestAmt.data(), negDestAmt.size())); Serializer tampered; obj.add(tampered); auto const jr = env.rpc("submit", strHex(tampered.slice())); BEAST_EXPECT(jr[jss::result][jss::error] == "invalidTransaction"); } // Variant B: Re-signed with all negated ciphertexts. // Signature passes, but the compact sigma proof fails — the proof // was generated for Enc(m), not Enc(-m). { ConfidentialSendSetup const setup(mptAlice, bob, carol, alice, sendAmount); auto const validProof = setup.generateProof(mptAlice, env, bob, carol); if (!BEAST_EXPECT(validProof.has_value())) return; // Negate all three ciphertexts: Enc(m) -> Enc(-m) auto const negSenderAmt = negateCiphertext(setup.senderAmt); auto const negDestAmt = negateCiphertext(setup.destAmt); auto const negIssuerAmt = negateCiphertext(setup.issuerAmt); auto args = setup.sendArgs( bob, carol, requireOptionalRef(validProof, "Missing valid proof"), tecBAD_PROOF); args.senderEncryptedAmt = negSenderAmt; args.destEncryptedAmt = negDestAmt; args.issuerEncryptedAmt = negIssuerAmt; mptAlice.send(args); } // Variant C: Negate only the sender ciphertext. // The verifier uses the sender ciphertext to derive the remainder // commitment: Enc(b) - Enc(m) becomes Enc(b) - (-Enc(m)) = Enc(b+m). // The bulletproof was generated for (b - m), not (b + m), so the // aggregated range proof fails. { ConfidentialSendSetup const setup(mptAlice, bob, carol, alice, sendAmount); auto const validProof = setup.generateProof(mptAlice, env, bob, carol); if (!BEAST_EXPECT(validProof.has_value())) return; auto const negSenderAmt = negateCiphertext(setup.senderAmt); auto args = setup.sendArgs( bob, carol, requireOptionalRef(validProof, "Missing valid proof"), tecBAD_PROOF); args.senderEncryptedAmt = negSenderAmt; mptAlice.send(args); } } void testSendCiphertextCombination(FeatureBitset features) { testcase("Send: ciphertext combination"); // Attack: exploit ElGamal homomorphism to combine ciphertexts // Enc(m1) + Enc(m2) = Enc(m1+m2), inflating the credited amount // without knowing the private keys. using namespace test::jtx; Env env{*this, features}; Account const alice("alice"), bob("bob"), carol("carol"); ConfidentialEnv confEnv{ env, alice, {{.account = bob, .payAmount = 1000, .convertAmount = 200}, {.account = carol, .payAmount = 1000, .convertAmount = 100}}, tfMPTCanTransfer | tfMPTCanHoldConfidentialBalance}; auto& mptAlice = confEnv.mpt; uint64_t const m1 = 10; uint64_t const m2 = 5; // Variant A: Post-signature combination. // Add Enc(m2) to the signed destination ciphertext Enc(m1). // The original signature doesn't cover the combined ciphertext. { auto const seq = env.seq(bob); auto jv = mptAlice.sendJV({.account = bob, .dest = carol, .amt = m1}, seq); auto jtx = env.jt(jv); BEAST_EXPECT(jtx.stx); Serializer s; jtx.stx->add(s); SerialIter sit(s.slice()); STObject obj(sit, sfTransaction); auto const origDestCt = obj.getFieldVL(sfDestinationEncryptedAmount); // Homomorphically add Enc(m2) to the original Enc(m1) Buffer const bf2 = generateBlindingFactor(); auto const encM2 = mptAlice.encryptAmount(carol, m2, bf2); auto const combined = requireOptional( homomorphicAdd( Slice(origDestCt.data(), origDestCt.size()), Slice(encM2.data(), encM2.size())), "Missing combined ciphertext"); obj.setFieldVL(sfDestinationEncryptedAmount, combined); Serializer tampered; obj.add(tampered); auto const jr = env.rpc("submit", strHex(tampered.slice())); BEAST_EXPECT(jr[jss::result][jss::error] == "invalidTransaction"); } // Variant B: Re-signed with combined ciphertext. // Generate a valid proof for m1, then replace dest ciphertext with // Enc(m1) + Enc(m2). Sigma proof fails because the proof was // generated for Enc(m1) only — the combined ciphertext has // different randomness. { ConfidentialSendSetup const setup(mptAlice, bob, carol, alice, m1); auto const validProof = setup.generateProof(mptAlice, env, bob, carol); if (!BEAST_EXPECT(validProof.has_value())) return; // Homomorphically add Enc(m2) to the valid dest ciphertext Buffer const bf2 = generateBlindingFactor(); auto const encM2 = mptAlice.encryptAmount(carol, m2, bf2); auto const combinedDest = homomorphicAdd(setup.destAmt, encM2); BEAST_EXPECT(combinedDest.has_value()); auto args = setup.sendArgs( bob, carol, requireOptionalRef(validProof, "Missing valid proof"), tecBAD_PROOF); args.destEncryptedAmt = combinedDest; mptAlice.send(args); } // Variant C: Cross-transaction ciphertext reuse. // Execute a valid send of m1, then build a new send for m2 using // a combined ciphertext oldEnc(m1) + newEnc(m2) = Enc(m1+m2), // where oldEnc(m1) is the actual ciphertext from the previous tx. // The proof was generated for the new transaction's context, but // the ciphertext includes stale randomness from the old Enc(m1). { // Execute a valid send of m1, capturing the actual ciphertext used ConfidentialSendSetup const setup1(mptAlice, bob, carol, alice, m1); auto const proof1 = setup1.generateProof(mptAlice, env, bob, carol); if (!BEAST_EXPECT(proof1.has_value())) return; mptAlice.send(setup1.sendArgs(bob, carol, requireOptionalRef(proof1, "Missing proof"))); ConfidentialSendSetup const setup2(mptAlice, bob, carol, alice, m2); auto const proof2 = setup2.generateProof(mptAlice, env, bob, carol); if (!BEAST_EXPECT(proof2.has_value())) return; // Combine the actual prior-tx Enc(m1) with the new Enc(m2) auto const crossCombined = homomorphicAdd(setup1.destAmt, setup2.destAmt); BEAST_EXPECT(crossCombined.has_value()); auto args = setup2.sendArgs( bob, carol, requireOptionalRef(proof2, "Missing proof"), tecBAD_PROOF); args.destEncryptedAmt = crossCombined; mptAlice.send(args); } } void testSendCiphertextRerandomization(FeatureBitset features) { testcase("Send: ciphertext rerandomization"); // Attack: substitute the randomness component C1 of an ElGamal // ciphertext (C1, C2) while keeping the message component C2 // unchanged. This "rerandomizes" the ciphertext to break // linkability or forge fresh-looking ciphertexts. // // The compact sigma proof binds C1 to the shared randomness used // across all recipients, so any C1 substitution breaks the proof. using namespace test::jtx; Env env{*this, features}; Account const alice("alice"), bob("bob"), carol("carol"); ConfidentialEnv confEnv{ env, alice, {{.account = bob}, {.account = carol, .payAmount = 1000, .convertAmount = 50}}, tfMPTCanTransfer | tfMPTCanHoldConfidentialBalance}; auto& mptAlice = confEnv.mpt; uint64_t const sendAmount = 10; // Helper: replace C1 in a ciphertext with C1 from another // ciphertext, keeping C2 unchanged. Returns a rerandomized // ciphertext (C1', C2). auto substituteC1 = [](Buffer const& target, Buffer const& source) -> Buffer { Buffer result = target; // Copy C1 (the first ciphertext component) from source. std::memcpy(result.data(), source.data(), kEcCiphertextComponentLength); return result; }; // Variant A: Post-signature C1 substitution. // Replace C1 in the dest ciphertext after signing. // Signature no longer covers the modified ciphertext. { auto const seq = env.seq(bob); auto jv = mptAlice.sendJV({.account = bob, .dest = carol, .amt = sendAmount}, seq); auto jtx = env.jt(jv); BEAST_EXPECT(jtx.stx); Serializer s; jtx.stx->add(s); SerialIter sit(s.slice()); STObject obj(sit, sfTransaction); // Generate a random C1' by encrypting a different amount Buffer const bf2 = generateBlindingFactor(); auto const otherCt = mptAlice.encryptAmount(carol, 99, bf2); // Replace C1 in the dest ciphertext auto const origDestAmt = obj.getFieldVL(sfDestinationEncryptedAmount); Buffer const origBuf(origDestAmt.data(), origDestAmt.size()); auto const rerandomized = substituteC1(origBuf, otherCt); obj.setFieldVL( sfDestinationEncryptedAmount, Slice(rerandomized.data(), rerandomized.size())); Serializer tampered; obj.add(tampered); // Signature verification fails auto const jr = env.rpc("submit", strHex(tampered.slice())); BEAST_EXPECT(jr[jss::result][jss::error] == "invalidTransaction"); } // Variant B: Re-signed C1 substitution. // Replace C1 in the dest ciphertext with a fresh random point // and re-sign. Sigma proof fails because the shared-randomness // binding no longer holds — C1' wasn't generated with the same r // used in the proof. { ConfidentialSendSetup const setup(mptAlice, bob, carol, alice, sendAmount); auto const validProof = setup.generateProof(mptAlice, env, bob, carol); if (!BEAST_EXPECT(validProof.has_value())) return; // Create a ciphertext with different randomness to get C1' Buffer const bf2 = generateBlindingFactor(); auto const otherCt = mptAlice.encryptAmount(carol, sendAmount, bf2); // Replace C1 in dest ciphertext, keep C2 auto const rerandomizedDest = substituteC1(setup.destAmt, otherCt); auto args = setup.sendArgs( bob, carol, requireOptionalRef(validProof, "Missing valid proof"), tecBAD_PROOF); args.destEncryptedAmt = rerandomizedDest; mptAlice.send(args); } } void testSendZeroRandomnessCiphertext(FeatureBitset features) { testcase("Send: zero randomness ciphertext"); // Setting r = 0 in ElGamal yields C1 = O (identity), C2 = mG — // a deterministic ciphertext that reveals the plaintext. using namespace test::jtx; Env env{*this, features}; Account const alice("alice"), bob("bob"), carol("carol"); ConfidentialEnv confEnv{ env, alice, {{.account = bob}, {.account = carol, .payAmount = 1000, .convertAmount = 50}}, tfMPTCanTransfer | tfMPTCanHoldConfidentialBalance}; auto& mptAlice = confEnv.mpt; uint64_t const sendAmount = 10; // ----------------------------------------------------------------- // Variant A: Post-signature zero-randomness substitution // ----------------------------------------------------------------- // Construct a valid ConfidentialMPTSend transaction with proper // ciphertexts and ZKPs, sign it, then replace the sender ciphertext // with a deterministic form (C1 = 0x00...00, C2 = arbitrary). // Since the identity element has no valid compressed encoding, // the modified blob fails deserialization / signature check. { auto const seq = env.seq(bob); auto jv = mptAlice.sendJV({.account = bob, .dest = carol, .amt = sendAmount}, seq); auto jtx = env.jt(jv); BEAST_EXPECT(jtx.stx); // Serialize the signed transaction Serializer s; jtx.stx->add(s); SerialIter sit(s.slice()); STObject obj(sit, sfTransaction); // Replace sender ciphertext with zero-randomness form: // C1 = all zeros (identity element — invalid encoding) // C2 = valid trivial point (simulating mG) Buffer zeroCiphertext(kEcGamalEncryptedTotalLength); std::memset(zeroCiphertext.data(), 0, kEcGamalEncryptedTotalLength); // C2 half: use a valid point so only C1 is the problem auto const& tc = getTrivialCiphertext(); std::memcpy( zeroCiphertext.data() + kEcCiphertextComponentLength, tc.data() + kEcCiphertextComponentLength, kEcCiphertextComponentLength); obj.setFieldVL(sfSenderEncryptedAmount, zeroCiphertext); // Re-serialize with the original (now-stale) signature Serializer tampered; obj.add(tampered); // Signature verification fails because ciphertext fields are // signed — transaction rejected before ZKP verification. auto const jr = env.rpc("submit", strHex(tampered.slice())); BEAST_EXPECT(jr[jss::result][jss::error] == "invalidTransaction"); } // ----------------------------------------------------------------- // Variant B: Re-signed zero-randomness ciphertext // ----------------------------------------------------------------- // Same zero-randomness ciphertext as Variant A (C1 = 0, C2 = mG), // but submitted normally via send() which re-signs the transaction. // Signature verification passes, but preflight's isValidCiphertext // rejects it: the identity element has no valid compressed encoding // on secp256k1, so secp256k1_ec_pubkey_parse fails on C1 = 0. { // Build zero-randomness ciphertext: C1 = all zeros (identity), // C2 = valid trivial point (simulating mG) Buffer zeroCiphertext(kEcGamalEncryptedTotalLength); std::memset(zeroCiphertext.data(), 0, kEcGamalEncryptedTotalLength); auto const& tc = getTrivialCiphertext(); std::memcpy( zeroCiphertext.data() + kEcCiphertextComponentLength, tc.data() + kEcCiphertextComponentLength, kEcCiphertextComponentLength); mptAlice.send( {.account = bob, .dest = carol, .amt = sendAmount, .senderEncryptedAmt = zeroCiphertext, .err = temBAD_CIPHERTEXT}); } // ----------------------------------------------------------------- // Variant C: Deterministic ciphertext reuse across transactions // ----------------------------------------------------------------- // Construct two transactions using identical deterministic // ciphertexts (same fixed blinding factor). Even if a valid // proof could be generated for one, it cannot be reused because // the TransactionContextID (which includes account sequence) // differs between transactions. { // First transaction: generate valid proof for sendAmount ConfidentialSendSetup const setup1(mptAlice, bob, carol, alice, sendAmount); auto const proof1 = setup1.generateProof(mptAlice, env, bob, carol); if (!BEAST_EXPECT(proof1.has_value())) return; // Submit first transaction successfully mptAlice.send(setup1.sendArgs(bob, carol, requireOptionalRef(proof1, "Missing proof"))); mptAlice.mergeInbox({.account = carol}); // Second transaction: reuse the same proof from tx1. // The context hash includes the new account sequence, so the // proof generated for the old sequence is invalid. ConfidentialSendSetup const setup2(mptAlice, bob, carol, alice, sendAmount); mptAlice.send(setup2.sendArgs( bob, carol, requireOptionalRef(proof1, "Missing proof"), tecBAD_PROOF)); } } void testSendRerandomizesRecipientInboxAgainstMergeCancellation(FeatureBitset features) { testcase("Send: recipient inbox rerandomization prevents merge cancellation"); using namespace test::jtx; // Derive the deterministic canonical-zero randomness r0 used for // Bob's first spending balance. auto getCanonicalZeroBlindingFactor = [](AccountID const& account, MPTID const& mptID) { Buffer scalar(kEcBlindingFactorLength); std::array hashInput{}; std::memcpy(hashInput.data(), "EncZero", 7); std::memcpy(hashInput.data() + 7, account.data(), account.size()); std::memcpy(hashInput.data() + 27, mptID.data(), mptID.size()); for (;;) { unsigned int mdLen = kEcBlindingFactorLength; if (EVP_Digest( hashInput.data(), hashInput.size(), scalar.data(), &mdLen, EVP_sha256(), nullptr) != 1) { Throw("Failed to derive canonical zero blinding factor"); } if (secp256k1_ec_seckey_verify(mpt_secp256k1_context(), scalar.data())) return scalar; std::memcpy(hashInput.data(), scalar.data(), scalar.size()); } }; // Pick randomness that would cancel Bob's MergeInbox C1 to infinity // without receiver-side re-randomization. auto negateScalarSum = [](Buffer const& lhs, Buffer const& rhs) { Buffer sum(kEcBlindingFactorLength); Buffer negated(kEcBlindingFactorLength); secp256k1_mpt_scalar_add(sum.data(), lhs.data(), rhs.data()); secp256k1_mpt_scalar_negate(negated.data(), sum.data()); return negated; }; // Without an auditor, target Bob's holder inbox. The crafted send // randomness would make MergeInbox hit the point at infinity unless // ConfidentialMPTSend re-randomizes the recipient ciphertext. { Env env{*this, features}; Account const alice("alice"), bob("bob"), carol("carol"); MPTTester mptAlice(env, alice, {.holders = {bob, carol}}); mptAlice.create({ .ownerCount = 1, .flags = tfMPTCanTransfer | tfMPTCanLock | tfMPTCanHoldConfidentialBalance, }); mptAlice.authorize({.account = bob}); mptAlice.authorize({.account = carol}); mptAlice.pay(alice, bob, 100); mptAlice.pay(alice, carol, 100); mptAlice.generateKeyPair(alice); mptAlice.generateKeyPair(bob); mptAlice.generateKeyPair(carol); mptAlice.set({.account = alice, .issuerPubKey = mptAlice.getPubKey(alice)}); mptAlice.convert({ .account = carol, .amt = 50, .holderPubKey = mptAlice.getPubKey(carol), }); mptAlice.mergeInbox({.account = carol}); Buffer const convertBlindingFactor = generateBlindingFactor(); mptAlice.convert({ .account = bob, .amt = 20, .holderPubKey = mptAlice.getPubKey(bob), .blindingFactor = convertBlindingFactor, }); Buffer const canonicalZeroBlindingFactor = getCanonicalZeroBlindingFactor(bob.id(), mptAlice.issuanceID()); // Holder inbox cancellation happens later in MergeInbox, when // Bob's spending Enc(0; r0) is added to inbox Enc(25; -r0). Buffer const maliciousSendBlindingFactor = negateScalarSum(canonicalZeroBlindingFactor, convertBlindingFactor); mptAlice.send({ .account = carol, .dest = bob, .amt = 5, .blindingFactor = maliciousSendBlindingFactor, }); mptAlice.mergeInbox({.account = bob}); auto const bobSpending = mptAlice.getDecryptedBalance(bob, MPTTester::holderEncryptedSpending); BEAST_EXPECT(bobSpending && *bobSpending == 25); } // With an auditor, verify the destination auditor balance is also // re-randomized. Auditor balance is updated during send, and this crafted // randomness would otherwise make that homomorphic sum hit infinity without // re-randomization. { Env env{*this, features}; Account const alice("alice"), bob("bob"), carol("carol"), auditor("auditor"); MPTTester mptAlice(env, alice, {.holders = {bob, carol}, .auditor = auditor}); mptAlice.create({ .ownerCount = 1, .flags = tfMPTCanTransfer | tfMPTCanLock | tfMPTCanHoldConfidentialBalance, }); mptAlice.authorize({.account = bob}); mptAlice.authorize({.account = carol}); mptAlice.pay(alice, bob, 100); mptAlice.pay(alice, carol, 100); mptAlice.generateKeyPair(alice); mptAlice.generateKeyPair(bob); mptAlice.generateKeyPair(carol); mptAlice.generateKeyPair(auditor); mptAlice.set({ .account = alice, .issuerPubKey = mptAlice.getPubKey(alice), .auditorPubKey = mptAlice.getPubKey(auditor), }); mptAlice.convert({ .account = carol, .amt = 50, .holderPubKey = mptAlice.getPubKey(carol), }); mptAlice.mergeInbox({.account = carol}); Buffer const convertBlindingFactor = generateBlindingFactor(); mptAlice.convert({ .account = bob, .amt = 20, .holderPubKey = mptAlice.getPubKey(bob), .blindingFactor = convertBlindingFactor, }); // This would make the homomorphic sum hit infinity. Buffer const maliciousSendBlindingFactor = negateScalarSum(gMakeZeroBuffer(kEcBlindingFactorLength), convertBlindingFactor); mptAlice.send({ .account = carol, .dest = bob, .amt = 5, .blindingFactor = maliciousSendBlindingFactor, }); auto const bobAuditor = mptAlice.getDecryptedBalance(bob, MPTTester::auditorEncryptedBalance); BEAST_EXPECT(bobAuditor && *bobAuditor == 25); } } void testWithFeats(FeatureBitset features) { // ConfidentialMPTConvert testConvert(features); testConvertPreflight(features); testConvertInvalidProofContextBinding(features); testConvertPreclaim(features); testConvertWithAuditor(features); // ConfidentialMPTMergeInbox testMergeInbox(features); testMergeInboxPreflight(features); testMergeInboxPreclaim(features); testSet(features); testSetPreflight(features); testSetPreclaim(features); // ConfidentialMPTSend testSend(features); testSendPreflight(features); testSendPreclaim(features); testSendRangeProof(features); testSendZeroAmount(features); testSendWithAuditor(features); // ConfidentialMPTClawback testClawback(features); testClawbackPreflight(features); testClawbackPreclaim(features); testClawbackProof(features); testClawbackWithAuditor(features); testClawbackInvalidProofContextBinding(features); testDelete(features); // ConfidentialMPTConvertBack testConvertBack(features); testConvertBackPreflight(features); testConvertBackPreclaim(features); testConvertBackWithAuditor(features); testConvertBackPedersenProof(features); testConvertBackBulletproof(features); // Homomorphic operation tests testSendHomomorphicOverflow(features); testConvertBackHomomorphicCiphertextModification(features); testConvertBackHomomorphicUnderflow(features); // Invalid curve points testSendInvalidCurvePoints(features); testSendWrongGroupPointInjection(features); testConvertIdentityElementRejection(features); testSendWrongIssuerPublicKey(features); // public and private txns testPublicTransfersAfterClearingConfidentialFlag(features); // Replay tests testMutatePrivacy(features); testConvertBackInvalidProofContextBinding(features); testConvertBackProofCiphertextBinding(features); testConvertBackProofVersionMismatch(features); // Crafted-proof Tests testSendSharedRandomnessViolation(features); // Transaction Fee Tests testConfidentialMPTBaseFee(features); // TransferFee (transfer rate) Tests testTransferFee(features); // Zero knowledge proof tests testSendInvalidProofContextBinding(features); testSendForgedEqualityProof(features); testSendForgedRangeProof(features); testSendNegativeValueMalleability(features); testSendFiatShamirBinding(features); testSendProofComponentReuse(features); testSendSpecialWitnessValues(features); testSendCrossStatementProofSubstitution(features); // Ciphertext malleability tests testSendCiphertextMalleability(features); testSendCiphertextNegation(features); testSendCiphertextCombination(features); testSendCiphertextRerandomization(features); testSendZeroRandomnessCiphertext(features); testSendRerandomizesRecipientInboxAgainstMergeCancellation(features); } public: void run() override { using namespace test::jtx; FeatureBitset const all{testableAmendments()}; testWithFeats(all); } }; BEAST_DEFINE_TESTSUITE(ConfidentialTransfer, app, xrpl); } // namespace xrpl