From 2b65b740d0b0221d748cdabd9d446a0a8ed8008e Mon Sep 17 00:00:00 2001 From: Shawn Xie <35279399+shawnxie999@users.noreply.github.com> Date: Wed, 22 Apr 2026 17:07:31 -0400 Subject: [PATCH] Add destination tag to ConfidentialMPTSend (#6988) --- .../xrpl/protocol/detail/transactions.macro | 1 + .../transactions/ConfidentialMPTSend.h | 37 ++++++++ .../transactors/token/ConfidentialMPTSend.cpp | 10 +- src/test/app/ConfidentialTransfer_test.cpp | 91 +++++++++++++++++++ src/test/jtx/mpt.h | 8 ++ .../transactions/ConfidentialMPTSendTests.cpp | 21 +++++ 6 files changed, 167 insertions(+), 1 deletion(-) diff --git a/include/xrpl/protocol/detail/transactions.macro b/include/xrpl/protocol/detail/transactions.macro index c2103bab58..fcf1caaf99 100644 --- a/include/xrpl/protocol/detail/transactions.macro +++ b/include/xrpl/protocol/detail/transactions.macro @@ -1138,6 +1138,7 @@ TRANSACTION(ttCONFIDENTIAL_MPT_SEND, 88, ConfidentialMPTSend, ({ {sfMPTokenIssuanceID, soeREQUIRED}, {sfDestination, soeREQUIRED}, + {sfDestinationTag, soeOPTIONAL}, {sfSenderEncryptedAmount, soeREQUIRED}, {sfDestinationEncryptedAmount, soeREQUIRED}, {sfIssuerEncryptedAmount, soeREQUIRED}, diff --git a/include/xrpl/protocol_autogen/transactions/ConfidentialMPTSend.h b/include/xrpl/protocol_autogen/transactions/ConfidentialMPTSend.h index 03a848cc73..f20011811e 100644 --- a/include/xrpl/protocol_autogen/transactions/ConfidentialMPTSend.h +++ b/include/xrpl/protocol_autogen/transactions/ConfidentialMPTSend.h @@ -69,6 +69,32 @@ public: return this->tx_->at(sfDestination); } + /** + * @brief Get sfDestinationTag (soeOPTIONAL) + * @return The field value, or std::nullopt if not present. + */ + [[nodiscard]] + protocol_autogen::Optional + getDestinationTag() const + { + if (hasDestinationTag()) + { + return this->tx_->at(sfDestinationTag); + } + return std::nullopt; + } + + /** + * @brief Check if sfDestinationTag is present. + * @return True if the field is present, false otherwise. + */ + [[nodiscard]] + bool + hasDestinationTag() const + { + return this->tx_->isFieldPresent(sfDestinationTag); + } + /** * @brief Get sfSenderEncryptedAmount (soeREQUIRED) * @return The field value. @@ -266,6 +292,17 @@ public: return *this; } + /** + * @brief Set sfDestinationTag (soeOPTIONAL) + * @return Reference to this builder for method chaining. + */ + ConfidentialMPTSendBuilder& + setDestinationTag(std::decay_t const& value) + { + object_[sfDestinationTag] = value; + return *this; + } + /** * @brief Set sfSenderEncryptedAmount (soeREQUIRED) * @return Reference to this builder for method chaining. diff --git a/src/libxrpl/tx/transactors/token/ConfidentialMPTSend.cpp b/src/libxrpl/tx/transactors/token/ConfidentialMPTSend.cpp index 08a75076ab..13c7a3eea7 100644 --- a/src/libxrpl/tx/transactors/token/ConfidentialMPTSend.cpp +++ b/src/libxrpl/tx/transactors/token/ConfidentialMPTSend.cpp @@ -118,9 +118,17 @@ ConfidentialMPTSend::preclaim(PreclaimContext const& ctx) // Check if destination account exists auto const destination = ctx.tx[sfDestination]; - if (!ctx.view.exists(keylet::account(destination))) + auto const sleDst = ctx.view.read(keylet::account(destination)); + if (!sleDst) return tecNO_TARGET; + // Check destination tag + if (((sleDst->getFlags() & lsfRequireDestTag) != 0u) && + !ctx.tx.isFieldPresent(sfDestinationTag)) + { + return tecDST_TAG_NEEDED; + } + // Check if MPT issuance exists auto const mptIssuanceID = ctx.tx[sfMPTokenIssuanceID]; auto const sleIssuance = ctx.view.read(keylet::mptIssuance(mptIssuanceID)); diff --git a/src/test/app/ConfidentialTransfer_test.cpp b/src/test/app/ConfidentialTransfer_test.cpp index e4d4630c39..321464167f 100644 --- a/src/test/app/ConfidentialTransfer_test.cpp +++ b/src/test/app/ConfidentialTransfer_test.cpp @@ -2400,6 +2400,28 @@ class ConfidentialTransfer_test : public beast::unit_test::suite }); } + // 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({ @@ -7213,6 +7235,74 @@ class ConfidentialTransfer_test : public beast::unit_test::suite } // Exercises ticket-specific error codes for confidential transfer transactions: + + void + testDestinationTag(FeatureBitset features) + { + testcase("test Destination Tag"); + + using namespace test::jtx; + Env env{*this, features}; + Account const alice("alice"); + Account const bob("bob"); + Account const carol("carol"); + MPTTester mptAlice(env, alice, {.holders = {bob, carol}}); + + mptAlice.create({.ownerCount = 1, .flags = tfMPTCanTransfer | tfMPTCanConfidentialAmount}); + mptAlice.authorize({.account = bob}); + mptAlice.authorize({.account = carol}); + + mptAlice.pay(alice, bob, 1000); + mptAlice.pay(alice, carol, 1000); + + 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}); + + // Set RequireDest on carol + env(fset(carol, asfRequireDest)); + env.close(); + + // Send without destination tag — rejected + mptAlice.send({ + .account = bob, + .dest = carol, + .amt = 10, + .proof = getTrivialSendProofHex(), + .senderEncryptedAmt = getTrivialCiphertext(), + .destEncryptedAmt = getTrivialCiphertext(), + .issuerEncryptedAmt = getTrivialCiphertext(), + .amountCommitment = getTrivialCommitment(), + .balanceCommitment = getTrivialCommitment(), + .err = tecDST_TAG_NEEDED, + }); + + // Send with destination tag — succeeds (passes preclaim, + // reaches ZKP verification with the real proof) + mptAlice.send({.account = bob, .dest = carol, .amt = 10, .destinationTag = 42}); + + // Verify the destination tag is in the confirmed transaction + auto const tx = env.tx(); + BEAST_EXPECT(tx); + BEAST_EXPECT(tx->isFieldPresent(sfDestinationTag)); + BEAST_EXPECT((*tx)[sfDestinationTag] == 42); + + env(fclear(carol, asfRequireDest)); + env.close(); + + // Send without destination tag when not required — succeeds + mptAlice.mergeInbox({.account = carol}); + mptAlice.send({.account = bob, .dest = carol, .amt = 10}); + } + // terPRE_TICKET when the ticket doesn't exist yet, and tefNO_TICKET when // the ticket has already been consumed or was never created. void @@ -8660,6 +8750,7 @@ class ConfidentialTransfer_test : public beast::unit_test::suite testSendDepositPreauth(features); testSendCredentialValidation(features); testSendWithAuditor(features); + testDestinationTag(features); // ConfidentialMPTClawback testClawback(features); diff --git a/src/test/jtx/mpt.h b/src/test/jtx/mpt.h index d770f15355..c35ca4b44a 100644 --- a/src/test/jtx/mpt.h +++ b/src/test/jtx/mpt.h @@ -3,6 +3,7 @@ #include #include #include +#include #include #include #include @@ -242,6 +243,7 @@ struct MPTConfidentialSend std::optional amountCommitment = std::nullopt; std::optional balanceCommitment = std::nullopt; std::optional delegate = std::nullopt; + std::optional destinationTag = std::nullopt; std::optional ticketSeq = std::nullopt; std::optional ownerCount = std::nullopt; std::optional holderCount = std::nullopt; @@ -612,6 +614,10 @@ private: if constexpr (requires { arg.delegate; }) delegateAcct = arg.delegate; + std::optional dstTag; + if constexpr (requires { arg.destinationTag; }) + dstTag = arg.destinationTag; + if (ticketSeq && delegateAcct) env_( jv, @@ -623,6 +629,8 @@ private: env_(jv, expectedFlags, expectedTer, ticket::use(*ticketSeq)); else if (delegateAcct) env_(jv, expectedFlags, expectedTer, delegate::as(*delegateAcct)); + else if (dstTag) + env_(jv, expectedFlags, expectedTer, dtag(*dstTag)); else env_(jv, expectedFlags, expectedTer); auto const err = env_.ter(); diff --git a/src/tests/libxrpl/protocol_autogen/transactions/ConfidentialMPTSendTests.cpp b/src/tests/libxrpl/protocol_autogen/transactions/ConfidentialMPTSendTests.cpp index 2a51bdc64b..15cf62e7a9 100644 --- a/src/tests/libxrpl/protocol_autogen/transactions/ConfidentialMPTSendTests.cpp +++ b/src/tests/libxrpl/protocol_autogen/transactions/ConfidentialMPTSendTests.cpp @@ -31,6 +31,7 @@ TEST(TransactionsConfidentialMPTSendTests, BuilderSettersRoundTrip) // Transaction-specific field values auto const mPTokenIssuanceIDValue = canonical_UINT192(); auto const destinationValue = canonical_ACCOUNT(); + auto const destinationTagValue = canonical_UINT32(); auto const senderEncryptedAmountValue = canonical_VL(); auto const destinationEncryptedAmountValue = canonical_VL(); auto const issuerEncryptedAmountValue = canonical_VL(); @@ -55,6 +56,7 @@ TEST(TransactionsConfidentialMPTSendTests, BuilderSettersRoundTrip) }; // Set optional fields + builder.setDestinationTag(destinationTagValue); builder.setAuditorEncryptedAmount(auditorEncryptedAmountValue); builder.setCredentialIDs(credentialIDsValue); @@ -122,6 +124,14 @@ TEST(TransactionsConfidentialMPTSendTests, BuilderSettersRoundTrip) } // Verify optional fields + { + auto const& expected = destinationTagValue; + auto const actualOpt = tx.getDestinationTag(); + ASSERT_TRUE(actualOpt.has_value()) << "Optional field sfDestinationTag should be present"; + expectEqualField(expected, *actualOpt, "sfDestinationTag"); + EXPECT_TRUE(tx.hasDestinationTag()); + } + { auto const& expected = auditorEncryptedAmountValue; auto const actualOpt = tx.getAuditorEncryptedAmount(); @@ -156,6 +166,7 @@ TEST(TransactionsConfidentialMPTSendTests, BuilderFromStTxRoundTrip) // Transaction-specific field values auto const mPTokenIssuanceIDValue = canonical_UINT192(); auto const destinationValue = canonical_ACCOUNT(); + auto const destinationTagValue = canonical_UINT32(); auto const senderEncryptedAmountValue = canonical_VL(); auto const destinationEncryptedAmountValue = canonical_VL(); auto const issuerEncryptedAmountValue = canonical_VL(); @@ -180,6 +191,7 @@ TEST(TransactionsConfidentialMPTSendTests, BuilderFromStTxRoundTrip) feeValue }; + initialBuilder.setDestinationTag(destinationTagValue); initialBuilder.setAuditorEncryptedAmount(auditorEncryptedAmountValue); initialBuilder.setCredentialIDs(credentialIDsValue); @@ -248,6 +260,13 @@ TEST(TransactionsConfidentialMPTSendTests, BuilderFromStTxRoundTrip) } // Verify optional fields + { + auto const& expected = destinationTagValue; + auto const actualOpt = rebuiltTx.getDestinationTag(); + ASSERT_TRUE(actualOpt.has_value()) << "Optional field sfDestinationTag should be present"; + expectEqualField(expected, *actualOpt, "sfDestinationTag"); + } + { auto const& expected = auditorEncryptedAmountValue; auto const actualOpt = rebuiltTx.getAuditorEncryptedAmount(); @@ -333,6 +352,8 @@ TEST(TransactionsConfidentialMPTSendTests, OptionalFieldsReturnNullopt) auto tx = builder.build(publicKey, secretKey); // Verify optional fields are not present + EXPECT_FALSE(tx.hasDestinationTag()); + EXPECT_FALSE(tx.getDestinationTag().has_value()); EXPECT_FALSE(tx.hasAuditorEncryptedAmount()); EXPECT_FALSE(tx.getAuditorEncryptedAmount().has_value()); EXPECT_FALSE(tx.hasCredentialIDs());