Add destination tag to ConfidentialMPTSend (#6988)

This commit is contained in:
Shawn Xie
2026-04-22 17:07:31 -04:00
committed by GitHub
parent f08121de35
commit 2b65b740d0
6 changed files with 167 additions and 1 deletions

View File

@@ -1138,6 +1138,7 @@ TRANSACTION(ttCONFIDENTIAL_MPT_SEND, 88, ConfidentialMPTSend,
({
{sfMPTokenIssuanceID, soeREQUIRED},
{sfDestination, soeREQUIRED},
{sfDestinationTag, soeOPTIONAL},
{sfSenderEncryptedAmount, soeREQUIRED},
{sfDestinationEncryptedAmount, soeREQUIRED},
{sfIssuerEncryptedAmount, soeREQUIRED},

View File

@@ -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<SF_UINT32::type::value_type>
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<typename SF_UINT32::type::value_type> const& value)
{
object_[sfDestinationTag] = value;
return *this;
}
/**
* @brief Set sfSenderEncryptedAmount (soeREQUIRED)
* @return Reference to this builder for method chaining.

View File

@@ -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));

View File

@@ -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);

View File

@@ -3,6 +3,7 @@
#include <test/jtx/Account.h>
#include <test/jtx/Env.h>
#include <test/jtx/delegate.h>
#include <test/jtx/tag.h>
#include <test/jtx/ter.h>
#include <test/jtx/ticket.h>
#include <test/jtx/txflags.h>
@@ -242,6 +243,7 @@ struct MPTConfidentialSend
std::optional<Buffer> amountCommitment = std::nullopt;
std::optional<Buffer> balanceCommitment = std::nullopt;
std::optional<Account> delegate = std::nullopt;
std::optional<std::uint32_t> destinationTag = std::nullopt;
std::optional<std::uint32_t> ticketSeq = std::nullopt;
std::optional<std::uint32_t> ownerCount = std::nullopt;
std::optional<std::uint32_t> holderCount = std::nullopt;
@@ -612,6 +614,10 @@ private:
if constexpr (requires { arg.delegate; })
delegateAcct = arg.delegate;
std::optional<std::uint32_t> 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();

View File

@@ -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());