feat: Implement Confidential mpt holder key update (#8266)

This commit is contained in:
Peter Chen
2026-10-02 22:54:38 +00:00
committed by GitHub
parent f1744cb76e
commit c45363fd8b
19 changed files with 1722 additions and 7 deletions

View File

@@ -255,6 +255,7 @@ words:
- queuable
- Raphson
- rcflags
- reencrypted
- replayer
- repodata
- repomd

View File

@@ -238,6 +238,12 @@ inline constexpr FlagValue tfUniversalMask = ~tfUniversal;
TF_FLAG(tfSponsorshipEnd, 0x00010000) \
TF_FLAG(tfSponsorshipCreate, 0x00020000) \
TF_FLAG(tfSponsorshipReassign, 0x00040000), \
MASK_ADJ(0)) \
\
TRANSACTION(ConfidentialMPTHolderKeyUpdate, \
TF_FLAG(tfHolderKeyRotation, 0x00010000) \
TF_FLAG(tfHolderKeyRecovery, 0x00020000) \
TF_FLAG(tfCancelRecovery, 0x00040000), \
MASK_ADJ(0))
// clang-format on

View File

@@ -439,6 +439,7 @@ LEDGER_ENTRY(ltMPTOKEN, 0x007f, MPToken, mptoken, ({
{sfIssuerKeyMirrorEpoch, SoeOptional},
{sfAuditorKeyMirrorEpoch, SoeOptional},
{sfHolderEncryptionKey, SoeOptional},
{sfRecoveryKey, SoeOptional},
}))
/** A ledger object which tracks Oracle

View File

@@ -330,6 +330,7 @@ TYPED_SFIELD(sfAuditorEncryptionKey, VL, 44)
TYPED_SFIELD(sfAmountCommitment, VL, 45)
TYPED_SFIELD(sfBalanceCommitment, VL, 46)
TYPED_SFIELD(sfBytecode, VL, 47)
TYPED_SFIELD(sfRecoveryKey, VL, 48)
// account (common)
TYPED_SFIELD(sfAccount, ACCOUNT, 1)

View File

@@ -1147,12 +1147,27 @@ TRANSACTION(ttCONFIDENTIAL_MPT_MIRROR_UPDATE, 92, ConfidentialMPTMirrorUpdate,
{sfZKProof, SoeRequired},
}))
/** This transaction rotates or recovers a confidential MPT holder's ElGamal encryption key,
or cancels a recovery. */
#if TRANSACTION_INCLUDE
# include <xrpl/tx/transactors/token/ConfidentialMPTHolderKeyUpdate.h>
#endif
TRANSACTION(ttCONFIDENTIAL_MPT_HOLDER_KEY_UPDATE, 93, ConfidentialMPTHolderKeyUpdate,
({.amendment = featureConfidentialMPTKeyRotation}),
({
{sfMPTokenIssuanceID, SoeRequired},
{sfHolderEncryptionKey, SoeOptional},
{sfConfidentialBalanceSpending, SoeOptional},
{sfConfidentialBalanceInbox, SoeOptional},
{sfZKProof, SoeOptional},
}))
/** This transaction posts an unsigned transaction on-ledger as a
TransactionProposal, pending multi-signature collection. */
#if TRANSACTION_INCLUDE
# include <xrpl/tx/transactors/proposal/TransactionProposalCreate.h>
#endif
TRANSACTION(ttTRANSACTION_PROPOSAL_CREATE, 93, TransactionProposalCreate,
TRANSACTION(ttTRANSACTION_PROPOSAL_CREATE, 95, TransactionProposalCreate,
({.amendment = featureCosign}),
({
{sfProposedTransaction, SoeRequired},

View File

@@ -339,6 +339,30 @@ public:
{
return this->sle_->isFieldPresent(sfHolderEncryptionKey);
}
/**
* @brief Get sfRecoveryKey (SoeOptional)
* @return The field value, or std::nullopt if not present.
*/
[[nodiscard]]
protocol_autogen::Optional<SF_VL::type::value_type>
getRecoveryKey() const
{
if (hasRecoveryKey())
return this->sle_->at(sfRecoveryKey);
return std::nullopt;
}
/**
* @brief Check if sfRecoveryKey is present.
* @return True if the field is present, false otherwise.
*/
[[nodiscard]]
bool
hasRecoveryKey() const
{
return this->sle_->isFieldPresent(sfRecoveryKey);
}
};
/**
@@ -552,6 +576,17 @@ public:
return *this;
}
/**
* @brief Set sfRecoveryKey (SoeOptional)
* @return Reference to this builder for method chaining.
*/
MPTokenBuilder&
setRecoveryKey(std::decay_t<typename SF_VL::type::value_type> const& value)
{
object_[sfRecoveryKey] = value;
return *this;
}
/**
* @brief Build and return the completed MPToken wrapper.
* @param index The ledger entry index.

View File

@@ -0,0 +1,279 @@
// This file is auto-generated. Do not edit.
#pragma once
#include <xrpl/protocol/STTx.h>
#include <xrpl/protocol/STParsedJSON.h>
#include <xrpl/protocol/jss.h>
#include <xrpl/protocol_autogen/TransactionBase.h>
#include <xrpl/protocol_autogen/TransactionBuilderBase.h>
#include <xrpl/json/json_value.h>
#include <stdexcept>
#include <optional>
namespace xrpl::transactions {
class ConfidentialMPTHolderKeyUpdateBuilder;
/**
* @brief Transaction: ConfidentialMPTHolderKeyUpdate
*
* Type: ttCONFIDENTIAL_MPT_HOLDER_KEY_UPDATE (93)
* Delegable: Delegation::NotDelegable
* Amendment: featureConfidentialMPTKeyRotation
* Privileges: Privilege::NoPriv
*
* Immutable wrapper around STTx providing type-safe field access.
* Use ConfidentialMPTHolderKeyUpdateBuilder to construct new transactions.
*/
class ConfidentialMPTHolderKeyUpdate : public TransactionBase
{
public:
static constexpr xrpl::TxType txType = ttCONFIDENTIAL_MPT_HOLDER_KEY_UPDATE;
/**
* @brief Construct a ConfidentialMPTHolderKeyUpdate transaction wrapper from an existing STTx object.
* @throws std::runtime_error if the transaction type doesn't match.
*/
explicit ConfidentialMPTHolderKeyUpdate(std::shared_ptr<STTx const> tx)
: TransactionBase(std::move(tx))
{
// Verify transaction type
if (tx_->getTxnType() != txType)
{
throw std::runtime_error("Invalid transaction type for ConfidentialMPTHolderKeyUpdate");
}
}
// Transaction-specific field getters
/**
* @brief Get sfMPTokenIssuanceID (SoeRequired)
* @return The field value.
*/
[[nodiscard]]
SF_UINT192::type::value_type
getMPTokenIssuanceID() const
{
return this->tx_->at(sfMPTokenIssuanceID);
}
/**
* @brief Get sfHolderEncryptionKey (SoeOptional)
* @return The field value, or std::nullopt if not present.
*/
[[nodiscard]]
protocol_autogen::Optional<SF_VL::type::value_type>
getHolderEncryptionKey() const
{
if (hasHolderEncryptionKey())
{
return this->tx_->at(sfHolderEncryptionKey);
}
return std::nullopt;
}
/**
* @brief Check if sfHolderEncryptionKey is present.
* @return True if the field is present, false otherwise.
*/
[[nodiscard]]
bool
hasHolderEncryptionKey() const
{
return this->tx_->isFieldPresent(sfHolderEncryptionKey);
}
/**
* @brief Get sfConfidentialBalanceSpending (SoeOptional)
* @return The field value, or std::nullopt if not present.
*/
[[nodiscard]]
protocol_autogen::Optional<SF_VL::type::value_type>
getConfidentialBalanceSpending() const
{
if (hasConfidentialBalanceSpending())
{
return this->tx_->at(sfConfidentialBalanceSpending);
}
return std::nullopt;
}
/**
* @brief Check if sfConfidentialBalanceSpending is present.
* @return True if the field is present, false otherwise.
*/
[[nodiscard]]
bool
hasConfidentialBalanceSpending() const
{
return this->tx_->isFieldPresent(sfConfidentialBalanceSpending);
}
/**
* @brief Get sfConfidentialBalanceInbox (SoeOptional)
* @return The field value, or std::nullopt if not present.
*/
[[nodiscard]]
protocol_autogen::Optional<SF_VL::type::value_type>
getConfidentialBalanceInbox() const
{
if (hasConfidentialBalanceInbox())
{
return this->tx_->at(sfConfidentialBalanceInbox);
}
return std::nullopt;
}
/**
* @brief Check if sfConfidentialBalanceInbox is present.
* @return True if the field is present, false otherwise.
*/
[[nodiscard]]
bool
hasConfidentialBalanceInbox() const
{
return this->tx_->isFieldPresent(sfConfidentialBalanceInbox);
}
/**
* @brief Get sfZKProof (SoeOptional)
* @return The field value, or std::nullopt if not present.
*/
[[nodiscard]]
protocol_autogen::Optional<SF_VL::type::value_type>
getZKProof() const
{
if (hasZKProof())
{
return this->tx_->at(sfZKProof);
}
return std::nullopt;
}
/**
* @brief Check if sfZKProof is present.
* @return True if the field is present, false otherwise.
*/
[[nodiscard]]
bool
hasZKProof() const
{
return this->tx_->isFieldPresent(sfZKProof);
}
};
/**
* @brief Builder for ConfidentialMPTHolderKeyUpdate transactions.
*
* Provides a fluent interface for constructing transactions with method chaining.
* Uses STObject internally for flexible transaction construction.
* Inherits common field setters from TransactionBuilderBase.
*/
class ConfidentialMPTHolderKeyUpdateBuilder : public TransactionBuilderBase<ConfidentialMPTHolderKeyUpdateBuilder>
{
public:
/**
* @brief Construct a new ConfidentialMPTHolderKeyUpdateBuilder with required fields.
* @param account The account initiating the transaction.
* @param mPTokenIssuanceID The sfMPTokenIssuanceID field value.
* @param sequence Optional sequence number for the transaction.
* @param fee Optional fee for the transaction.
*/
ConfidentialMPTHolderKeyUpdateBuilder(SF_ACCOUNT::type::value_type account,
std::decay_t<typename SF_UINT192::type::value_type> const& mPTokenIssuanceID, std::optional<SF_UINT32::type::value_type> sequence = std::nullopt,
std::optional<SF_AMOUNT::type::value_type> fee = std::nullopt
)
: TransactionBuilderBase<ConfidentialMPTHolderKeyUpdateBuilder>(ttCONFIDENTIAL_MPT_HOLDER_KEY_UPDATE, account, sequence, fee)
{
setMPTokenIssuanceID(mPTokenIssuanceID);
}
/**
* @brief Construct a ConfidentialMPTHolderKeyUpdateBuilder from an existing STTx object.
* @param tx The existing transaction to copy from.
* @throws std::runtime_error if the transaction type doesn't match.
*/
ConfidentialMPTHolderKeyUpdateBuilder(std::shared_ptr<STTx const> tx)
{
if (tx->getTxnType() != ttCONFIDENTIAL_MPT_HOLDER_KEY_UPDATE)
{
throw std::runtime_error("Invalid transaction type for ConfidentialMPTHolderKeyUpdateBuilder");
}
object_ = *tx;
}
/**
* @brief Transaction-specific field setters
*/
/**
* @brief Set sfMPTokenIssuanceID (SoeRequired)
* @return Reference to this builder for method chaining.
*/
ConfidentialMPTHolderKeyUpdateBuilder&
setMPTokenIssuanceID(std::decay_t<typename SF_UINT192::type::value_type> const& value)
{
object_[sfMPTokenIssuanceID] = value;
return *this;
}
/**
* @brief Set sfHolderEncryptionKey (SoeOptional)
* @return Reference to this builder for method chaining.
*/
ConfidentialMPTHolderKeyUpdateBuilder&
setHolderEncryptionKey(std::decay_t<typename SF_VL::type::value_type> const& value)
{
object_[sfHolderEncryptionKey] = value;
return *this;
}
/**
* @brief Set sfConfidentialBalanceSpending (SoeOptional)
* @return Reference to this builder for method chaining.
*/
ConfidentialMPTHolderKeyUpdateBuilder&
setConfidentialBalanceSpending(std::decay_t<typename SF_VL::type::value_type> const& value)
{
object_[sfConfidentialBalanceSpending] = value;
return *this;
}
/**
* @brief Set sfConfidentialBalanceInbox (SoeOptional)
* @return Reference to this builder for method chaining.
*/
ConfidentialMPTHolderKeyUpdateBuilder&
setConfidentialBalanceInbox(std::decay_t<typename SF_VL::type::value_type> const& value)
{
object_[sfConfidentialBalanceInbox] = value;
return *this;
}
/**
* @brief Set sfZKProof (SoeOptional)
* @return Reference to this builder for method chaining.
*/
ConfidentialMPTHolderKeyUpdateBuilder&
setZKProof(std::decay_t<typename SF_VL::type::value_type> const& value)
{
object_[sfZKProof] = value;
return *this;
}
/**
* @brief Build and return the ConfidentialMPTHolderKeyUpdate wrapper.
* @param publicKey The public key for signing.
* @param secretKey The secret key for signing.
* @return The constructed transaction wrapper.
*/
ConfidentialMPTHolderKeyUpdate
build(PublicKey const& publicKey, SecretKey const& secretKey)
{
sign(publicKey, secretKey);
return ConfidentialMPTHolderKeyUpdate{std::make_shared<STTx>(std::move(object_))};
}
};
} // namespace xrpl::transactions

View File

@@ -18,7 +18,7 @@ class TransactionProposalCreateBuilder;
/**
* @brief Transaction: TransactionProposalCreate
*
* Type: ttTRANSACTION_PROPOSAL_CREATE (93)
* Type: ttTRANSACTION_PROPOSAL_CREATE (95)
* Delegable: Delegation::NotDelegable
* Amendment: featureCosign
* Privileges: Privilege::NoPriv

View File

@@ -0,0 +1,84 @@
#pragma once
#include <xrpl/beast/utility/Journal.h>
#include <xrpl/core/ServiceRegistry.h>
#include <xrpl/ledger/ReadView.h>
#include <xrpl/protocol/STTx.h>
#include <xrpl/protocol/TER.h>
#include <xrpl/protocol/XRPAmount.h>
#include <xrpl/tx/ApplyContext.h>
#include <xrpl/tx/Transactor.h>
#include <cstdint>
namespace xrpl {
/**
* @brief Allows a confidential MPT holder to rotate or recover their ElGamal
* encryption key, or to cancel a pending recovery.
*
* Submitted by the holder. Exactly one of three modes must be selected via
* the transaction flags:
*
* - Rotation (tfHolderKeyRotation): the holder still has their current
* ElGamal private key. They provide a new public key along with their
* current spending/inbox balances re-encrypted under that new key. The
* holder's encryption key and confidential balances are updated
* immediately.
*
* - Recovery (tfHolderKeyRecovery): the holder has lost their current
* private key. They provide a new public key but cannot provide
* re-encrypted balances. The new key is recorded as a pending
* sfRecoveryKey; the confidential balances are left untouched. Completing
* the recovery (rewriting the balances) is done separately by the issuer
* via ConfidentialMPTRecoverBalance.
*
* - Cancel (tfCancelRecovery): the holder revokes a pending recovery
* authorization created by a prior Recovery-mode transaction. No key,
* balances, or proof are carried; authorization is via the holder's
* ordinary XRPL signature. sfRecoveryKey is removed and everything else
* is left untouched. Fails if no recovery is pending.
*
* @note Zero-knowledge proof verification for Rotation and Recovery is
* deferred to a follow-up once the mpt-crypto constructions are finalized.
*/
class ConfidentialMPTHolderKeyUpdate : public Transactor
{
public:
static constexpr auto kConsequencesFactory = ConsequencesFactoryType::Normal;
explicit ConfidentialMPTHolderKeyUpdate(ApplyContext& ctx) : Transactor(ctx)
{
}
static bool
checkExtraFeatures(PreflightContext const& ctx);
static std::uint32_t
getFlagsMask(PreflightContext const& ctx);
static NotTEC
preflight(PreflightContext const& ctx);
static XRPAmount
calculateBaseFee(ReadView const& view, STTx const& tx);
static TER
preclaim(PreclaimContext const& ctx);
TER
doApply() override;
void
visitInvariantEntry(bool isDelete, SLE::ConstRef before, SLE::ConstRef after) override;
[[nodiscard]] bool
finalizeInvariants(
STTx const& tx,
TER result,
XRPAmount fee,
ReadView const& view,
beast::Journal const& j) override;
};
} // namespace xrpl

View File

@@ -41,6 +41,7 @@ constexpr auto kConfidentialMptTxTypes = std::to_array<TxType>({
ttCONFIDENTIAL_MPT_MERGE_INBOX,
ttCONFIDENTIAL_MPT_CLAWBACK,
ttCONFIDENTIAL_MPT_MIRROR_UPDATE,
ttCONFIDENTIAL_MPT_HOLDER_KEY_UPDATE,
});
// Clamp to the cap (== INT64_MAX) before the signed conversion. Invariant
@@ -825,10 +826,10 @@ ValidConfidentialMPToken::finalize(
return false;
}
// Among confidential MPT transactions, only ConfidentialMPTSend and
// ConfidentialMPTMergeInbox leave coaDelta unmodified. Therefore, if a confidential MPT
// transaction reaches here, it must be one of these two types, neither of which will
// modify sfOutstandingAmount
// Reaching here means this confidential MPT transaction left coaDelta
// unmodified (e.g. ConfidentialMPTSend, ConfidentialMPTMergeInbox, or
// ConfidentialMPTHolderKeyUpdate/ConfidentialMPTMirrorUpdate, none of which touch
// sfConfidentialOutstandingAmount), so it must not modify sfOutstandingAmount either.
if (checks.outstandingDelta != 0)
{
JLOG(j.fatal()) << "Invariant failed: OutstandingAmount changed "

View File

@@ -0,0 +1,220 @@
#include <xrpl/tx/transactors/token/ConfidentialMPTHolderKeyUpdate.h>
#include <xrpl/basics/Slice.h>
#include <xrpl/beast/utility/Journal.h>
#include <xrpl/beast/utility/instrumentation.h>
#include <xrpl/core/ServiceRegistry.h>
#include <xrpl/ledger/ReadView.h>
#include <xrpl/protocol/ConfidentialTransfer.h>
#include <xrpl/protocol/Feature.h>
#include <xrpl/protocol/Indexes.h>
#include <xrpl/protocol/LedgerFormats.h>
#include <xrpl/protocol/MPTIssue.h>
#include <xrpl/protocol/Protocol.h>
#include <xrpl/protocol/SField.h>
#include <xrpl/protocol/TER.h>
#include <xrpl/protocol/TxFlags.h>
#include <xrpl/protocol/XRPAmount.h>
#include <xrpl/tx/Transactor.h>
#include <bit>
#include <cstdint>
namespace xrpl {
bool
ConfidentialMPTHolderKeyUpdate::checkExtraFeatures(PreflightContext const& ctx)
{
// Holder key update is only meaningful when confidential transfers are enabled.
return ctx.rules.enabled(featureConfidentialTransfer);
}
std::uint32_t
ConfidentialMPTHolderKeyUpdate::getFlagsMask(PreflightContext const& ctx)
{
return tfConfidentialMPTHolderKeyUpdateMask;
}
NotTEC
ConfidentialMPTHolderKeyUpdate::preflight(PreflightContext const& ctx)
{
bool const rotation = ctx.tx.isFlag(tfHolderKeyRotation);
bool const recovery = ctx.tx.isFlag(tfHolderKeyRecovery);
bool const cancel = ctx.tx.isFlag(tfCancelRecovery);
// Exactly one of the three mode flags must be set.
static constexpr auto modeFlags = tfHolderKeyRotation | tfHolderKeyRecovery | tfCancelRecovery;
if (std::popcount(ctx.tx.getFlags() & modeFlags) != 1)
return temINVALID_FLAG;
// The issuer cannot hold confidential balances.
if (ctx.tx[sfAccount] == MPTIssue(ctx.tx[sfMPTokenIssuanceID]).getIssuer())
return temMALFORMED;
bool const hasHolderKey = ctx.tx.isFieldPresent(sfHolderEncryptionKey);
bool const hasSpending = ctx.tx.isFieldPresent(sfConfidentialBalanceSpending);
bool const hasInbox = ctx.tx.isFieldPresent(sfConfidentialBalanceInbox);
bool const hasProof = ctx.tx.isFieldPresent(sfZKProof);
if (cancel)
{
// Cancel mode only revokes a pending recovery authorization; it
// carries no key material, balances, or proof.
if (hasHolderKey || hasSpending || hasInbox || hasProof)
return temMALFORMED;
return tesSUCCESS;
}
// Rotation and Recovery both require a holder key and a proof.
if (!hasHolderKey || !hasProof)
return temMALFORMED;
// Rotation mode requires re-encrypted balances; Recovery mode must not
// provide them since the holder cannot decrypt the current ones.
if (rotation && (!hasSpending || !hasInbox))
return temMALFORMED;
if (recovery && (hasSpending || hasInbox))
return temMALFORMED;
if (hasSpending &&
ctx.tx[sfConfidentialBalanceSpending].length() != kEcGamalEncryptedTotalLength)
return temBAD_CIPHERTEXT;
if (hasInbox && ctx.tx[sfConfidentialBalanceInbox].length() != kEcGamalEncryptedTotalLength)
return temBAD_CIPHERTEXT;
if (!isValidCompressedECPoint(ctx.tx[sfHolderEncryptionKey]))
return temMALFORMED;
if (hasSpending && !isValidCiphertext(ctx.tx[sfConfidentialBalanceSpending]))
return temBAD_CIPHERTEXT;
if (hasInbox && !isValidCiphertext(ctx.tx[sfConfidentialBalanceInbox]))
return temBAD_CIPHERTEXT;
return tesSUCCESS;
}
XRPAmount
ConfidentialMPTHolderKeyUpdate::calculateBaseFee(ReadView const& view, STTx const& tx)
{
return Transactor::calculateBaseFee(view, tx, kConfidentialFeeMultiplier);
}
TER
ConfidentialMPTHolderKeyUpdate::preclaim(PreclaimContext const& ctx)
{
auto const account = ctx.tx[sfAccount];
auto const mptIssuanceID = ctx.tx[sfMPTokenIssuanceID];
auto const sleIssuance = ctx.view.read(keylet::mptokenIssuance(mptIssuanceID));
if (!sleIssuance)
return tecOBJECT_NOT_FOUND;
if (!sleIssuance->isFlag(lsfMPTCanHoldConfidentialBalance))
return tecNO_PERMISSION;
auto const sleMptoken = ctx.view.read(keylet::mptoken(mptIssuanceID, account));
if (!sleMptoken)
return tecOBJECT_NOT_FOUND;
if (!sleMptoken->isFieldPresent(sfHolderEncryptionKey) ||
!sleMptoken->isFieldPresent(sfConfidentialBalanceSpending) ||
!sleMptoken->isFieldPresent(sfConfidentialBalanceInbox))
{
return tecNO_PERMISSION;
}
if (ctx.tx.isFlag(tfCancelRecovery))
{
// Nothing to cancel if no recovery is pending.
if (!sleMptoken->isFieldPresent(sfRecoveryKey))
return tecNO_PERMISSION;
return tesSUCCESS;
}
if (ctx.tx[sfHolderEncryptionKey] == (*sleMptoken)[sfHolderEncryptionKey])
return tecDUPLICATE;
// Recovery mode: reject if a recovery is already pending
if (ctx.tx.isFlag(tfHolderKeyRecovery) && sleMptoken->isFieldPresent(sfRecoveryKey))
return tecNO_PERMISSION;
return tesSUCCESS;
}
TER
ConfidentialMPTHolderKeyUpdate::doApply()
{
auto const mptIssuanceID = ctx_.tx[sfMPTokenIssuanceID];
auto sleMptoken = view().peek(keylet::mptoken(mptIssuanceID, accountID_));
if (!sleMptoken)
{
// LCOV_EXCL_START
UNREACHABLE(
"xrpl::ConfidentialMPTHolderKeyUpdate::doApply : preclaim already validated the "
"MPToken exists");
return tecINTERNAL;
// LCOV_EXCL_STOP
}
if (ctx_.tx.isFlag(tfCancelRecovery))
{
// The holder revokes their pending recovery authorization; the
// current key and balances are left untouched.
sleMptoken->makeFieldAbsent(sfRecoveryKey);
view().update(sleMptoken);
return tesSUCCESS;
}
auto const newPubKey = ctx_.tx[sfHolderEncryptionKey];
if (ctx_.tx.isFlag(tfHolderKeyRotation))
{
// Replace the key and balances in rotation mode. Rotation proves the
// holder still has the old key, so any pending recovery is cancelled.
(*sleMptoken)[sfHolderEncryptionKey] = newPubKey;
(*sleMptoken)[sfConfidentialBalanceSpending] = ctx_.tx[sfConfidentialBalanceSpending];
(*sleMptoken)[sfConfidentialBalanceInbox] = ctx_.tx[sfConfidentialBalanceInbox];
sleMptoken->makeFieldAbsent(sfRecoveryKey);
incrementConfidentialVersion(*sleMptoken);
}
else if (ctx_.tx.isFlag(tfHolderKeyRecovery))
{
// Recovery mode: the holder cannot decrypt their current balances,
// so only the pending recovery key is recorded. The balances are
// rewritten separately by the issuer via ConfidentialMPTRecoverBalance.
(*sleMptoken)[sfRecoveryKey] = newPubKey;
}
else
{
// LCOV_EXCL_START
UNREACHABLE("xrpl::ConfidentialMPTHolderKeyUpdate::doApply : invalid mode");
return tecINTERNAL;
// LCOV_EXCL_STOP
}
view().update(sleMptoken);
return tesSUCCESS;
}
void
ConfidentialMPTHolderKeyUpdate::visitInvariantEntry(bool, SLE::ConstRef, SLE::ConstRef)
{
}
bool
ConfidentialMPTHolderKeyUpdate::finalizeInvariants(
STTx const&,
TER,
XRPAmount,
ReadView const&,
beast::Journal const&)
{
return true;
}
} // namespace xrpl

View File

@@ -1,6 +1,7 @@
#include <test/jtx/Account.h>
#include <test/jtx/ConfidentialTransfer.h>
#include <test/jtx/Env.h>
#include <test/jtx/amount.h>
#include <test/jtx/mpt.h>
#include <xrpl/basics/Buffer.h>
@@ -20,6 +21,7 @@
#include <cstdint>
#include <memory>
#include <optional>
#include <utility>
#include <vector>
namespace xrpl {
@@ -2454,6 +2456,703 @@ class ConfidentialMPTKeyRotation_test : public ConfidentialTransferTestBase
});
}
void
testConfidentialMPTHolderKeyUpdatePreflight(FeatureBitset features)
{
testcase("ConfidentialMPTHolderKeyUpdate preflight");
using namespace test::jtx;
Env env{*this, features};
Account const alice("alice");
Account const bob("bob");
// Both amendments are required: ConfidentialMPTKeyRotation and ConfidentialTransfer.
if (!features[featureConfidentialMPTKeyRotation] || !features[featureConfidentialTransfer])
{
MPTTester mptAlice(env, alice, {.holders = {bob}});
mptAlice.create({.ownerCount = 1, .flags = tfMPTCanTransfer});
mptAlice.holderKeyUpdate({
.account = bob,
.holderPubKey = gMakeZeroBuffer(kEcPubKeyLength),
.flags = tfHolderKeyRecovery,
.err = temDISABLED,
});
return;
}
ConfidentialEnv ct{env, alice, {{.account = bob, .payAmount = 100, .convertAmount = 40}}};
Account const bobNewKey("bobNewKey");
ct.mpt.generateKeyPair(bobNewKey);
// The flag contains a value outside the recognized mode bits. This is
// rejected by the flags-mask check
for (auto const flags : {0x00080000u, 0x00080000u | tfHolderKeyRecovery, 0x00100000u})
{
ct.mpt.holderKeyUpdate({
.account = bob,
.holderPubKey = ct.mpt.getPubKey(bobNewKey),
.proof = gMakeZeroBuffer(1),
.flags = flags,
.err = temINVALID_FLAG,
});
}
// Exactly one of Rotation, Recovery, and Cancel must be set.
for (auto const flags :
{0u,
tfHolderKeyRotation | tfHolderKeyRecovery,
tfHolderKeyRotation | tfCancelRecovery,
tfHolderKeyRecovery | tfCancelRecovery,
tfHolderKeyRotation | tfHolderKeyRecovery | tfCancelRecovery})
{
ct.mpt.holderKeyUpdate({
.account = bob,
.holderPubKey = ct.mpt.getPubKey(bobNewKey),
.proof = gMakeZeroBuffer(1),
.flags = flags,
.err = temINVALID_FLAG,
});
}
// The issuer cannot rotate or recover a confidential balance it cannot hold.
Account const aliceNewKey("aliceNewKey");
ct.mpt.generateKeyPair(aliceNewKey);
ct.mpt.holderKeyUpdate({
.account = alice,
.holderPubKey = ct.mpt.getPubKey(aliceNewKey),
.proof = gMakeZeroBuffer(1),
.flags = tfHolderKeyRecovery,
.err = temMALFORMED,
});
// HolderEncryptionKey one byte short of the required length.
ct.mpt.holderKeyUpdate({
.account = bob,
.holderPubKey = gMakeZeroBuffer(kEcPubKeyLength - 1),
.proof = gMakeZeroBuffer(1),
.flags = tfHolderKeyRecovery,
.err = temMALFORMED,
});
// HolderEncryptionKey the correct length, but not a well-formed
// compressed secp256k1 point.
ct.mpt.holderKeyUpdate({
.account = bob,
.holderPubKey = gMakeZeroBuffer(kEcPubKeyLength),
.proof = gMakeZeroBuffer(1),
.flags = tfHolderKeyRecovery,
.err = temMALFORMED,
});
// HolderEncryptionKey is entirely absent (as opposed to present with
// the wrong length or format).
ct.mpt.holderKeyUpdate({
.account = bob,
.proof = gMakeZeroBuffer(1),
.flags = tfHolderKeyRecovery,
.err = temMALFORMED,
});
std::optional<Buffer> const cipher = getTrivialCiphertext();
std::optional<Buffer> const none;
// Rotation mode requires both the spending and inbox ciphertexts;
for (auto const& [spending, inbox] :
{std::pair{none, none}, std::pair{cipher, none}, std::pair{none, cipher}})
{
ct.mpt.holderKeyUpdate({
.account = bob,
.holderPubKey = ct.mpt.getPubKey(bobNewKey),
.spendingCiphertext = spending,
.inboxCiphertext = inbox,
.proof = gMakeZeroBuffer(1),
.flags = tfHolderKeyRotation,
.err = temMALFORMED,
});
}
// Recovery and Cancel modes must not include either.
for (auto const& [spending, inbox] :
{std::pair{cipher, none}, std::pair{none, cipher}, std::pair{cipher, cipher}})
{
ct.mpt.holderKeyUpdate({
.account = bob,
.holderPubKey = ct.mpt.getPubKey(bobNewKey),
.spendingCiphertext = spending,
.inboxCiphertext = inbox,
.proof = gMakeZeroBuffer(1),
.flags = tfHolderKeyRecovery,
.err = temMALFORMED,
});
}
for (auto const& [spending, inbox] :
{std::pair{cipher, none}, std::pair{none, cipher}, std::pair{cipher, cipher}})
{
ct.mpt.holderKeyUpdate({
.account = bob,
.spendingCiphertext = spending,
.inboxCiphertext = inbox,
.flags = tfCancelRecovery,
.err = temMALFORMED,
});
}
// Spending ciphertext has the wrong length.
ct.mpt.holderKeyUpdate({
.account = bob,
.holderPubKey = ct.mpt.getPubKey(bobNewKey),
.spendingCiphertext = gMakeZeroBuffer(kEcGamalEncryptedTotalLength - 1),
.inboxCiphertext = getTrivialCiphertext(),
.proof = gMakeZeroBuffer(1),
.flags = tfHolderKeyRotation,
.err = temBAD_CIPHERTEXT,
});
// Inbox ciphertext has the wrong length.
ct.mpt.holderKeyUpdate({
.account = bob,
.holderPubKey = ct.mpt.getPubKey(bobNewKey),
.spendingCiphertext = getTrivialCiphertext(),
.inboxCiphertext = gMakeZeroBuffer(kEcGamalEncryptedTotalLength - 1),
.proof = gMakeZeroBuffer(1),
.flags = tfHolderKeyRotation,
.err = temBAD_CIPHERTEXT,
});
// Spending ciphertext has the correct length, but is not a
// well-formed EC ElGamal ciphertext.
ct.mpt.holderKeyUpdate({
.account = bob,
.holderPubKey = ct.mpt.getPubKey(bobNewKey),
.spendingCiphertext = getBadCiphertext(),
.inboxCiphertext = getTrivialCiphertext(),
.proof = gMakeZeroBuffer(1),
.flags = tfHolderKeyRotation,
.err = temBAD_CIPHERTEXT,
});
// Inbox ciphertext has the correct length, but is not a well-formed
// EC ElGamal ciphertext.
ct.mpt.holderKeyUpdate({
.account = bob,
.holderPubKey = ct.mpt.getPubKey(bobNewKey),
.spendingCiphertext = getTrivialCiphertext(),
.inboxCiphertext = getBadCiphertext(),
.proof = gMakeZeroBuffer(1),
.flags = tfHolderKeyRotation,
.err = temBAD_CIPHERTEXT,
});
// Rotation/Recovery mode requires a ZKProof.
ct.mpt.holderKeyUpdate({
.account = bob,
.holderPubKey = ct.mpt.getPubKey(bobNewKey),
.flags = tfHolderKeyRecovery,
.err = temMALFORMED,
});
// Cancel mode must not include HolderEncryptionKey.
ct.mpt.holderKeyUpdate({
.account = bob,
.holderPubKey = ct.mpt.getPubKey(bobNewKey),
.flags = tfCancelRecovery,
.err = temMALFORMED,
});
// Cancel mode must not include a ZKProof.
ct.mpt.holderKeyUpdate({
.account = bob,
.proof = gMakeZeroBuffer(1),
.flags = tfCancelRecovery,
.err = temMALFORMED,
});
}
void
testConfidentialMPTHolderKeyUpdatePreclaim(FeatureBitset features)
{
testcase("ConfidentialMPTHolderKeyUpdate preclaim");
using namespace test::jtx;
// The 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 | tfMPTCanHoldConfidentialBalance,
});
// Destroy the issuance to test issuance not found.
mptAlice.destroy();
mptAlice.holderKeyUpdate({
.account = bob,
.flags = tfCancelRecovery,
.err = tecOBJECT_NOT_FOUND,
});
}
// The issuance has not enabled confidential balances. The holder is
// authorized so this reaches the confidential-balance-flag check
// rather than failing earlier on a missing 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});
mptAlice.authorize({.account = bob});
mptAlice.holderKeyUpdate({
.account = bob,
.flags = tfCancelRecovery,
.err = tecNO_PERMISSION,
});
}
// carol exists as an account but was never authorized to hold this
// issuance, so she has no MPToken for it at all.
{
Env env{*this, features};
Account const alice("alice");
Account const carol("carol");
MPTTester mptAlice(env, alice);
mptAlice.create({
.ownerCount = 1,
.flags = tfMPTCanTransfer | tfMPTCanHoldConfidentialBalance,
});
env.fund(XRP(1000), carol);
env.close();
Account const carolNewKey("carolNewKey");
mptAlice.generateKeyPair(carolNewKey);
mptAlice.holderKeyUpdate({
.account = carol,
.holderPubKey = mptAlice.getPubKey(carolNewKey),
.proof = gMakeZeroBuffer(1),
.flags = tfHolderKeyRecovery,
.err = tecOBJECT_NOT_FOUND,
});
}
// The holder has an MPToken but no confidential state yet - never
// registered a key or converted anything.
{
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);
Account const bobNewKey("bobNewKey");
mptAlice.generateKeyPair(bobNewKey);
mptAlice.holderKeyUpdate({
.account = bob,
.holderPubKey = mptAlice.getPubKey(bobNewKey),
.proof = gMakeZeroBuffer(1),
.flags = tfHolderKeyRecovery,
.err = tecNO_PERMISSION,
});
}
// Submitting the holder's own current key as the "new" key is a no-op.
{
Env env{*this, features};
Account const alice("alice");
Account const bob("bob");
ConfidentialEnv ct{
env, alice, {{.account = bob, .payAmount = 100, .convertAmount = 40}}};
ct.mpt.holderKeyUpdate({
.account = bob,
.holderPubKey = ct.mpt.getPubKey(bob),
.proof = gMakeZeroBuffer(1),
.flags = tfHolderKeyRecovery,
.err = tecDUPLICATE,
});
}
// A second Recovery-mode transaction must not silently overwrite an
// already-pending RecoveryKey.
{
Env env{*this, features};
Account const alice("alice");
Account const bob("bob");
ConfidentialEnv ct{
env, alice, {{.account = bob, .payAmount = 100, .convertAmount = 40}}};
Account const bobRecoveryKey("bobRecoveryKey");
ct.mpt.generateKeyPair(bobRecoveryKey);
ct.mpt.holderKeyUpdate({
.account = bob,
.holderPubKey = ct.mpt.getPubKey(bobRecoveryKey),
.proof = gMakeZeroBuffer(1),
.flags = tfHolderKeyRecovery,
});
Account const bobRecoveryKey2("bobRecoveryKey2");
ct.mpt.generateKeyPair(bobRecoveryKey2);
ct.mpt.holderKeyUpdate({
.account = bob,
.holderPubKey = ct.mpt.getPubKey(bobRecoveryKey2),
.proof = gMakeZeroBuffer(1),
.flags = tfHolderKeyRecovery,
.err = tecNO_PERMISSION,
});
}
// bob never submitted a Recovery-mode transaction, so there is no
// sfRecoveryKey to cancel.
{
Env env{*this, features};
Account const alice("alice");
Account const bob("bob");
ConfidentialEnv ct{
env, alice, {{.account = bob, .payAmount = 100, .convertAmount = 40}}};
ct.mpt.holderKeyUpdate({
.account = bob,
.flags = tfCancelRecovery,
.err = tecNO_PERMISSION,
});
}
// Runs rotation, recovery, and cancel in sequence and expects each to
// succeed.
auto const allModesSucceed = [&](ConfidentialEnv& ct, Account const& bob) {
Account const bobNewKey("bobNewKey");
ct.mpt.generateKeyPair(bobNewKey);
auto const reEnc = reencryptHolderBalances(ct.mpt, bob, bob, bobNewKey);
BEAST_EXPECT(reEnc.has_value());
if (!reEnc)
return;
ct.mpt.holderKeyUpdate({
.account = bob,
.holderPubKey = ct.mpt.getPubKey(bobNewKey),
.spendingCiphertext = reEnc->first,
.inboxCiphertext = reEnc->second,
.proof = gMakeZeroBuffer(1),
.flags = tfHolderKeyRotation,
});
Account const bobRecoveryKey("bobRecoveryKey");
ct.mpt.generateKeyPair(bobRecoveryKey);
ct.mpt.holderKeyUpdate({
.account = bob,
.holderPubKey = ct.mpt.getPubKey(bobRecoveryKey),
.proof = gMakeZeroBuffer(1),
.flags = tfHolderKeyRecovery,
});
ct.mpt.holderKeyUpdate({
.account = bob,
.flags = tfCancelRecovery,
});
};
// Individual (per-holder) lock.
{
Env env{*this, features};
Account const alice("alice");
Account const bob("bob");
ConfidentialEnv ct{
env, alice, {{.account = bob, .payAmount = 100, .convertAmount = 40}}};
ct.mpt.set({.account = alice, .holder = bob, .flags = tfMPTLock});
allModesSucceed(ct, bob);
}
// Global (issuance-wide) lock.
{
Env env{*this, features};
Account const alice("alice");
Account const bob("bob");
ConfidentialEnv ct{
env, alice, {{.account = bob, .payAmount = 100, .convertAmount = 40}}};
ct.mpt.set({.account = alice, .flags = tfMPTLock});
allModesSucceed(ct, bob);
}
// A stale mirror epoch does not block rotation, recovery, or cancel:
{
Env env{*this, features};
Account const alice("alice");
Account const bob("bob");
ConfidentialEnv ct{
env, alice, {{.account = bob, .payAmount = 100, .convertAmount = 40}}};
// Rotate the issuer's key without refreshing bob's mirror,
// leaving bob's mirror epoch stale relative to the issuance.
Account const aliceNewIssuerKey("aliceNewIssuerKey");
ct.mpt.generateKeyPair(aliceNewIssuerKey);
ct.mpt.set({.account = alice, .issuerPubKey = ct.mpt.getPubKey(aliceNewIssuerKey)});
BEAST_EXPECT(ct.mpt.checkKeyEpochs(1u, std::nullopt));
BEAST_EXPECT(ct.mpt.checkMirrorEpochs(bob, std::nullopt, std::nullopt));
allModesSucceed(ct, bob);
}
}
void
testConfidentialMPTHolderKeyUpdateDoApply(FeatureBitset features)
{
testcase("ConfidentialMPTHolderKeyUpdate doApply");
using namespace test::jtx;
// Rotation mode updates the key and re-encrypted balances, bumps the
// version, and clears any pending recovery.
{
Env env{*this, features};
Account const alice("alice");
Account const bob("bob");
ConfidentialEnv ct{
env, alice, {{.account = bob, .payAmount = 100, .convertAmount = 40}}};
auto const prevVersion = ct.mpt.getMPTokenVersion(bob);
Account const bobNewKey("bobNewKey");
ct.mpt.generateKeyPair(bobNewKey);
auto const reEnc = reencryptHolderBalances(ct.mpt, bob, bob, bobNewKey);
BEAST_EXPECT(reEnc.has_value());
if (!reEnc)
return;
ct.mpt.holderKeyUpdate({
.account = bob,
.holderPubKey = ct.mpt.getPubKey(bobNewKey),
.spendingCiphertext = reEnc->first,
.inboxCiphertext = reEnc->second,
.proof = gMakeZeroBuffer(1),
.flags = tfHolderKeyRotation,
});
auto const sleMptoken = env.le(keylet::mptoken(ct.mpt.issuanceID(), bob.id()));
if (!BEAST_EXPECT(sleMptoken))
return;
auto const newPubKey = ct.mpt.getPubKey(bobNewKey);
BEAST_EXPECT(
newPubKey && strHex((*sleMptoken)[sfHolderEncryptionKey]) == strHex(*newPubKey));
BEAST_EXPECT(!sleMptoken->isFieldPresent(sfRecoveryKey));
BEAST_EXPECT(ct.mpt.getMPTokenVersion(bob) == prevVersion + 1);
// The rotated balances must still decrypt to the same amounts,
// but now only under the NEW private key.
auto const spendingCt =
ct.mpt.getEncryptedBalance(bob, MPTTester::holderEncryptedSpending);
auto const inboxCt = ct.mpt.getEncryptedBalance(bob, MPTTester::holderEncryptedInbox);
BEAST_EXPECT(spendingCt.has_value());
BEAST_EXPECT(inboxCt.has_value());
if (!spendingCt || !inboxCt)
return;
auto const spendingAmt = ct.mpt.decryptAmount(bobNewKey, *spendingCt);
auto const inboxAmt = ct.mpt.decryptAmount(bobNewKey, *inboxCt);
BEAST_EXPECT(spendingAmt && *spendingAmt == 40);
BEAST_EXPECT(inboxAmt && *inboxAmt == 0);
}
// Rotation mode re-encrypts both balances correctly when spending and
// inbox are both non-zero and differ from each other.
{
Env env{*this, features};
Account const alice("alice");
Account const bob("bob");
Account const carol("carol");
ConfidentialEnv ct{
env,
alice,
{{.account = bob, .payAmount = 100, .convertAmount = 40},
{.account = carol, .payAmount = 100, .convertAmount = 50}}};
// carol sends 15 into bob's inbox, which is not merged into
// spending, leaving bob with spending=40, inbox=15.
ct.mpt.send({.account = carol, .dest = bob, .amt = 15});
Account const bobNewKey("bobNewKey");
ct.mpt.generateKeyPair(bobNewKey);
auto const reEnc = reencryptHolderBalances(ct.mpt, bob, bob, bobNewKey);
BEAST_EXPECT(reEnc.has_value());
if (!reEnc)
return;
ct.mpt.holderKeyUpdate({
.account = bob,
.holderPubKey = ct.mpt.getPubKey(bobNewKey),
.spendingCiphertext = reEnc->first,
.inboxCiphertext = reEnc->second,
.proof = gMakeZeroBuffer(1),
.flags = tfHolderKeyRotation,
});
auto const spendingCt =
ct.mpt.getEncryptedBalance(bob, MPTTester::holderEncryptedSpending);
auto const inboxCt = ct.mpt.getEncryptedBalance(bob, MPTTester::holderEncryptedInbox);
BEAST_EXPECT(spendingCt.has_value());
BEAST_EXPECT(inboxCt.has_value());
if (!spendingCt || !inboxCt)
return;
auto const spendingAmt = ct.mpt.decryptAmount(bobNewKey, *spendingCt);
auto const inboxAmt = ct.mpt.decryptAmount(bobNewKey, *inboxCt);
BEAST_EXPECT(spendingAmt && *spendingAmt == 40);
BEAST_EXPECT(inboxAmt && *inboxAmt == 15);
}
// Recovery mode only records the pending recovery key; the current
// key, balances, and version are untouched.
{
Env env{*this, features};
Account const alice("alice");
Account const bob("bob");
ConfidentialEnv ct{
env, alice, {{.account = bob, .payAmount = 100, .convertAmount = 40}}};
auto const prevVersion = ct.mpt.getMPTokenVersion(bob);
auto const prevSpending =
ct.mpt.getDecryptedBalance(bob, MPTTester::holderEncryptedSpending);
auto const prevInbox = ct.mpt.getDecryptedBalance(bob, MPTTester::holderEncryptedInbox);
auto const prevKey = ct.mpt.getPubKey(bob);
Account const bobRecoveryKey("bobRecoveryKey");
ct.mpt.generateKeyPair(bobRecoveryKey);
ct.mpt.holderKeyUpdate({
.account = bob,
.holderPubKey = ct.mpt.getPubKey(bobRecoveryKey),
.proof = gMakeZeroBuffer(1),
.flags = tfHolderKeyRecovery,
});
auto const sleMptoken = env.le(keylet::mptoken(ct.mpt.issuanceID(), bob.id()));
if (!BEAST_EXPECT(sleMptoken))
return;
auto const recoveryKey = ct.mpt.getPubKey(bobRecoveryKey);
BEAST_EXPECT(
sleMptoken->isFieldPresent(sfRecoveryKey) && recoveryKey &&
strHex((*sleMptoken)[sfRecoveryKey]) == strHex(*recoveryKey));
BEAST_EXPECT(
prevKey && strHex((*sleMptoken)[sfHolderEncryptionKey]) == strHex(*prevKey));
BEAST_EXPECT(ct.mpt.getMPTokenVersion(bob) == prevVersion);
BEAST_EXPECT(
ct.mpt.getDecryptedBalance(bob, MPTTester::holderEncryptedSpending) ==
prevSpending);
BEAST_EXPECT(
ct.mpt.getDecryptedBalance(bob, MPTTester::holderEncryptedInbox) == prevInbox);
}
// Cancel mode clears the pending recovery key only; the current key,
// balances, and version are untouched.
{
Env env{*this, features};
Account const alice("alice");
Account const bob("bob");
ConfidentialEnv ct{
env, alice, {{.account = bob, .payAmount = 100, .convertAmount = 40}}};
Account const bobRecoveryKey("bobRecoveryKey");
ct.mpt.generateKeyPair(bobRecoveryKey);
ct.mpt.holderKeyUpdate({
.account = bob,
.holderPubKey = ct.mpt.getPubKey(bobRecoveryKey),
.proof = gMakeZeroBuffer(1),
.flags = tfHolderKeyRecovery,
});
auto const prevVersion = ct.mpt.getMPTokenVersion(bob);
auto const prevSpending =
ct.mpt.getDecryptedBalance(bob, MPTTester::holderEncryptedSpending);
auto const prevInbox = ct.mpt.getDecryptedBalance(bob, MPTTester::holderEncryptedInbox);
auto const prevKey = ct.mpt.getPubKey(bob);
ct.mpt.holderKeyUpdate({
.account = bob,
.flags = tfCancelRecovery,
});
auto const sleMptoken = env.le(keylet::mptoken(ct.mpt.issuanceID(), bob.id()));
if (!BEAST_EXPECT(sleMptoken))
return;
BEAST_EXPECT(!sleMptoken->isFieldPresent(sfRecoveryKey));
BEAST_EXPECT(
prevKey && strHex((*sleMptoken)[sfHolderEncryptionKey]) == strHex(*prevKey));
BEAST_EXPECT(ct.mpt.getMPTokenVersion(bob) == prevVersion);
BEAST_EXPECT(
ct.mpt.getDecryptedBalance(bob, MPTTester::holderEncryptedSpending) ==
prevSpending);
BEAST_EXPECT(
ct.mpt.getDecryptedBalance(bob, MPTTester::holderEncryptedInbox) == prevInbox);
}
// Rotation mode clears a pending recovery key.
{
Env env{*this, features};
Account const alice("alice");
Account const bob("bob");
ConfidentialEnv ct{
env, alice, {{.account = bob, .payAmount = 100, .convertAmount = 40}}};
Account const bobRecoveryKey("bobRecoveryKey");
ct.mpt.generateKeyPair(bobRecoveryKey);
ct.mpt.holderKeyUpdate({
.account = bob,
.holderPubKey = ct.mpt.getPubKey(bobRecoveryKey),
.proof = gMakeZeroBuffer(1),
.flags = tfHolderKeyRecovery,
});
{
auto const sleMptoken = env.le(keylet::mptoken(ct.mpt.issuanceID(), bob.id()));
if (!BEAST_EXPECT(sleMptoken))
return;
BEAST_EXPECT(sleMptoken->isFieldPresent(sfRecoveryKey));
}
Account const bobNewKey("bobNewKey");
ct.mpt.generateKeyPair(bobNewKey);
auto const reEnc = reencryptHolderBalances(ct.mpt, bob, bob, bobNewKey);
BEAST_EXPECT(reEnc.has_value());
if (!reEnc)
return;
ct.mpt.holderKeyUpdate({
.account = bob,
.holderPubKey = ct.mpt.getPubKey(bobNewKey),
.spendingCiphertext = reEnc->first,
.inboxCiphertext = reEnc->second,
.proof = gMakeZeroBuffer(1),
.flags = tfHolderKeyRotation,
});
auto const sleMptoken = env.le(keylet::mptoken(ct.mpt.issuanceID(), bob.id()));
if (!BEAST_EXPECT(sleMptoken))
return;
BEAST_EXPECT(!sleMptoken->isFieldPresent(sfRecoveryKey));
}
}
public:
void
testMPTokenIssuanceSetWithFeats(FeatureBitset features)
@@ -2490,6 +3189,12 @@ public:
testConfidentialMPTMirrorUpdateDoApply(all);
testConfidentialMPTMirrorUpdateMultipleRotationsIssuerMode(all);
testConfidentialMPTMirrorUpdateMultipleRotationsHolderMode(all);
testConfidentialMPTHolderKeyUpdatePreflight(all);
testConfidentialMPTHolderKeyUpdatePreflight(all - featureConfidentialMPTKeyRotation);
testConfidentialMPTHolderKeyUpdatePreflight(all - featureConfidentialTransfer);
testConfidentialMPTHolderKeyUpdatePreclaim(all);
testConfidentialMPTHolderKeyUpdateDoApply(all);
}
};

View File

@@ -20,6 +20,7 @@
#include <optional>
#include <stdexcept>
#include <string>
#include <utility>
#include <vector>
namespace xrpl {
@@ -188,6 +189,17 @@ protected:
// Proof layout: [compact_sigma | bulletproof]
static constexpr size_t kBulletproofOffset = kEcSendProofLength - kEcDoubleBulletproofLength;
// Decrypts holder's current spending/inbox balances under currentKey and
// re-encrypts them under newKey, returning {spendingCiphertext,
// inboxCiphertext}. Returns std::nullopt if either balance is missing or
// fails to decrypt.
static std::optional<std::pair<Buffer, Buffer>>
reencryptHolderBalances(
test::jtx::MPTTester& mpt,
test::jtx::Account const& holder,
test::jtx::Account const& currentKey,
test::jtx::Account const& newKey);
// Generate a forged aggregated bulletproof (double bulletproof) for
// the given values and blinding factors. Used to test that splicing
// a bulletproof claiming a different remaining balance is rejected.

View File

@@ -27,6 +27,7 @@
#include <optional>
#include <stdexcept>
#include <string>
#include <utility>
#include <vector>
namespace xrpl {
@@ -292,6 +293,30 @@ ConfidentialTransferTestBase::ConfidentialSendSetup::sendArgs(
};
}
std::optional<std::pair<Buffer, Buffer>>
ConfidentialTransferTestBase::reencryptHolderBalances(
test::jtx::MPTTester& mpt,
test::jtx::Account const& holder,
test::jtx::Account const& currentKey,
test::jtx::Account const& newKey)
{
auto const spendingCt =
mpt.getEncryptedBalance(holder, test::jtx::MPTTester::holderEncryptedSpending);
auto const inboxCt =
mpt.getEncryptedBalance(holder, test::jtx::MPTTester::holderEncryptedInbox);
if (!spendingCt || !inboxCt)
return std::nullopt;
auto const spendingAmt = mpt.decryptAmount(currentKey, *spendingCt);
auto const inboxAmt = mpt.decryptAmount(currentKey, *inboxCt);
if (!spendingAmt || !inboxAmt)
return std::nullopt;
return std::pair{
mpt.encryptAmount(newKey, *spendingAmt, generateBlindingFactor()),
mpt.encryptAmount(newKey, *inboxAmt, generateBlindingFactor())};
}
Buffer const&
ConfidentialTransferTestBase::getBadCiphertext()
{

View File

@@ -2291,4 +2291,25 @@ MPTTester::mirrorUpdate(MPTMirrorUpdate const& arg, std::source_location const&
submit(arg, {jv, loc});
}
void
MPTTester::holderKeyUpdate(MPTHolderKeyUpdate const& arg, std::source_location const& loc)
{
json::Value jv;
jv[jss::TransactionType] = jss::ConfidentialMPTHolderKeyUpdate;
setAccountField(jv, arg.account);
setIssuanceIdField(jv, arg.id);
if (arg.holderPubKey)
jv[sfHolderEncryptionKey.jsonName] = strHex(*arg.holderPubKey);
if (arg.spendingCiphertext)
jv[sfConfidentialBalanceSpending.jsonName] = strHex(*arg.spendingCiphertext);
if (arg.inboxCiphertext)
jv[sfConfidentialBalanceInbox.jsonName] = strHex(*arg.inboxCiphertext);
if (arg.proof)
jv[sfZKProof.jsonName] = strHex(*arg.proof);
submit(arg, {jv, loc});
}
} // namespace xrpl::test::jtx

View File

@@ -78,7 +78,8 @@ fillFee(json::Value& jv, ReadView const& view)
auto const txType = jv[jss::TransactionType].asString();
if (txType == jss::ConfidentialMPTConvert || txType == jss::ConfidentialMPTConvertBack ||
txType == jss::ConfidentialMPTSend || txType == jss::ConfidentialMPTMergeInbox ||
txType == jss::ConfidentialMPTClawback || txType == jss::ConfidentialMPTMirrorUpdate)
txType == jss::ConfidentialMPTClawback || txType == jss::ConfidentialMPTMirrorUpdate ||
txType == jss::ConfidentialMPTHolderKeyUpdate)
{
jv[jss::Fee] = to_string(base * (kConfidentialFeeMultiplier + 1));
}

View File

@@ -408,6 +408,24 @@ struct MPTMirrorUpdate
std::optional<TER> err = std::nullopt;
};
/**
* @brief Arguments for building a ConfidentialMPTHolderKeyUpdate test transaction.
*/
struct MPTHolderKeyUpdate
{
std::optional<Account> account = std::nullopt;
std::optional<MPTID> id = std::nullopt;
std::optional<Buffer> holderPubKey = std::nullopt;
std::optional<Buffer> spendingCiphertext = std::nullopt;
std::optional<Buffer> inboxCiphertext = std::nullopt;
std::optional<Buffer> proof = std::nullopt;
std::optional<std::uint32_t> ownerCount = std::nullopt;
std::optional<std::uint32_t> holderCount = std::nullopt;
std::optional<std::uint32_t> flags = std::nullopt;
std::optional<XRPAmount> fee = std::nullopt;
std::optional<TER> err = std::nullopt;
};
/**
* @brief Stores the parameters that are exclusively used to generate a
* Pedersen linkage proof.
@@ -659,6 +677,11 @@ public:
MPTMirrorUpdate const& arg = MPTMirrorUpdate{},
std::source_location const& loc = std::source_location::current());
void
holderKeyUpdate(
MPTHolderKeyUpdate const& arg = MPTHolderKeyUpdate{},
std::source_location const& loc = std::source_location::current());
[[nodiscard]] bool
checkDomainID(std::optional<UInt256> expected) const;

View File

@@ -35,6 +35,7 @@ TEST(MPTokenTests, BuilderSettersRoundTrip)
auto const issuerKeyMirrorEpochValue = canonical_UINT32();
auto const auditorKeyMirrorEpochValue = canonical_UINT32();
auto const holderEncryptionKeyValue = canonical_VL();
auto const recoveryKeyValue = canonical_VL();
MPTokenBuilder builder{
accountValue,
@@ -54,6 +55,7 @@ TEST(MPTokenTests, BuilderSettersRoundTrip)
builder.setIssuerKeyMirrorEpoch(issuerKeyMirrorEpochValue);
builder.setAuditorKeyMirrorEpoch(auditorKeyMirrorEpochValue);
builder.setHolderEncryptionKey(holderEncryptionKeyValue);
builder.setRecoveryKey(recoveryKeyValue);
builder.setLedgerIndex(index);
builder.setFlags(0x1u);
@@ -174,6 +176,14 @@ TEST(MPTokenTests, BuilderSettersRoundTrip)
EXPECT_TRUE(entry.hasHolderEncryptionKey());
}
{
auto const& expected = recoveryKeyValue;
auto const actualOpt = entry.getRecoveryKey();
ASSERT_TRUE(actualOpt.has_value());
expectEqualField(expected, *actualOpt, "sfRecoveryKey");
EXPECT_TRUE(entry.hasRecoveryKey());
}
EXPECT_TRUE(entry.hasLedgerIndex());
auto const ledgerIndex = entry.getLedgerIndex();
ASSERT_TRUE(ledgerIndex.has_value());
@@ -202,6 +212,7 @@ TEST(MPTokenTests, BuilderFromSleRoundTrip)
auto const issuerKeyMirrorEpochValue = canonical_UINT32();
auto const auditorKeyMirrorEpochValue = canonical_UINT32();
auto const holderEncryptionKeyValue = canonical_VL();
auto const recoveryKeyValue = canonical_VL();
auto sle = std::make_shared<SLE>(MPToken::entryType, index);
@@ -220,6 +231,7 @@ TEST(MPTokenTests, BuilderFromSleRoundTrip)
sle->at(sfIssuerKeyMirrorEpoch) = issuerKeyMirrorEpochValue;
sle->at(sfAuditorKeyMirrorEpoch) = auditorKeyMirrorEpochValue;
sle->at(sfHolderEncryptionKey) = holderEncryptionKeyValue;
sle->at(sfRecoveryKey) = recoveryKeyValue;
MPTokenBuilder builderFromSle{sle};
EXPECT_TRUE(builderFromSle.validate());
@@ -410,6 +422,19 @@ TEST(MPTokenTests, BuilderFromSleRoundTrip)
expectEqualField(expected, *fromBuilderOpt, "sfHolderEncryptionKey");
}
{
auto const& expected = recoveryKeyValue;
auto const fromSleOpt = entryFromSle.getRecoveryKey();
auto const fromBuilderOpt = entryFromBuilder.getRecoveryKey();
ASSERT_TRUE(fromSleOpt.has_value());
ASSERT_TRUE(fromBuilderOpt.has_value());
expectEqualField(expected, *fromSleOpt, "sfRecoveryKey");
expectEqualField(expected, *fromBuilderOpt, "sfRecoveryKey");
}
EXPECT_EQ(entryFromSle.getKey(), index);
EXPECT_EQ(entryFromBuilder.getKey(), index);
}
@@ -492,5 +517,7 @@ TEST(MPTokenTests, OptionalFieldsReturnNullopt)
EXPECT_FALSE(entry.getAuditorKeyMirrorEpoch().has_value());
EXPECT_FALSE(entry.hasHolderEncryptionKey());
EXPECT_FALSE(entry.getHolderEncryptionKey().has_value());
EXPECT_FALSE(entry.hasRecoveryKey());
EXPECT_FALSE(entry.getRecoveryKey().has_value());
}
}

View File

@@ -0,0 +1,258 @@
// Auto-generated unit tests for transaction ConfidentialMPTHolderKeyUpdate
#include <gtest/gtest.h>
#include <protocol_autogen/TestHelpers.h>
#include <xrpl/protocol/SecretKey.h>
#include <xrpl/protocol/Seed.h>
#include <xrpl/protocol/STTx.h>
#include <xrpl/protocol_autogen/transactions/ConfidentialMPTHolderKeyUpdate.h>
#include <xrpl/protocol_autogen/transactions/AccountSet.h>
#include <string>
namespace xrpl::transactions {
// 1 & 4) Set fields via builder setters, build, then read them back via
// wrapper getters. After build(), validate() should succeed.
TEST(TransactionsConfidentialMPTHolderKeyUpdateTests, BuilderSettersRoundTrip)
{
// Generate a deterministic keypair for signing
auto const [publicKey, secretKey] =
generateKeyPair(KeyType::Secp256k1, generateSeed("testConfidentialMPTHolderKeyUpdate"));
// Common transaction fields
auto const accountValue = calcAccountID(publicKey);
std::uint32_t const sequenceValue = 1;
auto const feeValue = canonical_AMOUNT();
// Transaction-specific field values
auto const mPTokenIssuanceIDValue = canonical_UINT192();
auto const holderEncryptionKeyValue = canonical_VL();
auto const confidentialBalanceSpendingValue = canonical_VL();
auto const confidentialBalanceInboxValue = canonical_VL();
auto const zKProofValue = canonical_VL();
ConfidentialMPTHolderKeyUpdateBuilder builder{
accountValue,
mPTokenIssuanceIDValue,
sequenceValue,
feeValue
};
// Set optional fields
builder.setHolderEncryptionKey(holderEncryptionKeyValue);
builder.setConfidentialBalanceSpending(confidentialBalanceSpendingValue);
builder.setConfidentialBalanceInbox(confidentialBalanceInboxValue);
builder.setZKProof(zKProofValue);
auto tx = builder.build(publicKey, secretKey);
std::string reason;
EXPECT_TRUE(tx.validate(reason)) << reason;
// Verify signing was applied
EXPECT_FALSE(tx.getSigningPubKey().empty());
EXPECT_TRUE(tx.hasTxnSignature());
// Verify common fields
EXPECT_EQ(tx.getAccount(), accountValue);
EXPECT_EQ(tx.getSequence(), sequenceValue);
EXPECT_EQ(tx.getFee(), feeValue);
// Verify required fields
{
auto const& expected = mPTokenIssuanceIDValue;
auto const actual = tx.getMPTokenIssuanceID();
expectEqualField(expected, actual, "sfMPTokenIssuanceID");
}
// Verify optional fields
{
auto const& expected = holderEncryptionKeyValue;
auto const actualOpt = tx.getHolderEncryptionKey();
ASSERT_TRUE(actualOpt.has_value()) << "Optional field sfHolderEncryptionKey should be present";
expectEqualField(expected, *actualOpt, "sfHolderEncryptionKey");
EXPECT_TRUE(tx.hasHolderEncryptionKey());
}
{
auto const& expected = confidentialBalanceSpendingValue;
auto const actualOpt = tx.getConfidentialBalanceSpending();
ASSERT_TRUE(actualOpt.has_value()) << "Optional field sfConfidentialBalanceSpending should be present";
expectEqualField(expected, *actualOpt, "sfConfidentialBalanceSpending");
EXPECT_TRUE(tx.hasConfidentialBalanceSpending());
}
{
auto const& expected = confidentialBalanceInboxValue;
auto const actualOpt = tx.getConfidentialBalanceInbox();
ASSERT_TRUE(actualOpt.has_value()) << "Optional field sfConfidentialBalanceInbox should be present";
expectEqualField(expected, *actualOpt, "sfConfidentialBalanceInbox");
EXPECT_TRUE(tx.hasConfidentialBalanceInbox());
}
{
auto const& expected = zKProofValue;
auto const actualOpt = tx.getZKProof();
ASSERT_TRUE(actualOpt.has_value()) << "Optional field sfZKProof should be present";
expectEqualField(expected, *actualOpt, "sfZKProof");
EXPECT_TRUE(tx.hasZKProof());
}
}
// 2 & 4) Start from an STTx, construct a builder from it, build a new wrapper,
// and verify all fields match.
TEST(TransactionsConfidentialMPTHolderKeyUpdateTests, BuilderFromStTxRoundTrip)
{
// Generate a deterministic keypair for signing
auto const [publicKey, secretKey] =
generateKeyPair(KeyType::Secp256k1, generateSeed("testConfidentialMPTHolderKeyUpdateFromTx"));
// Common transaction fields
auto const accountValue = calcAccountID(publicKey);
std::uint32_t const sequenceValue = 2;
auto const feeValue = canonical_AMOUNT();
// Transaction-specific field values
auto const mPTokenIssuanceIDValue = canonical_UINT192();
auto const holderEncryptionKeyValue = canonical_VL();
auto const confidentialBalanceSpendingValue = canonical_VL();
auto const confidentialBalanceInboxValue = canonical_VL();
auto const zKProofValue = canonical_VL();
// Build an initial transaction
ConfidentialMPTHolderKeyUpdateBuilder initialBuilder{
accountValue,
mPTokenIssuanceIDValue,
sequenceValue,
feeValue
};
initialBuilder.setHolderEncryptionKey(holderEncryptionKeyValue);
initialBuilder.setConfidentialBalanceSpending(confidentialBalanceSpendingValue);
initialBuilder.setConfidentialBalanceInbox(confidentialBalanceInboxValue);
initialBuilder.setZKProof(zKProofValue);
auto initialTx = initialBuilder.build(publicKey, secretKey);
// Create builder from existing STTx
ConfidentialMPTHolderKeyUpdateBuilder builderFromTx{initialTx.getSTTx()};
auto rebuiltTx = builderFromTx.build(publicKey, secretKey);
std::string reason;
EXPECT_TRUE(rebuiltTx.validate(reason)) << reason;
// Verify common fields
EXPECT_EQ(rebuiltTx.getAccount(), accountValue);
EXPECT_EQ(rebuiltTx.getSequence(), sequenceValue);
EXPECT_EQ(rebuiltTx.getFee(), feeValue);
// Verify required fields
{
auto const& expected = mPTokenIssuanceIDValue;
auto const actual = rebuiltTx.getMPTokenIssuanceID();
expectEqualField(expected, actual, "sfMPTokenIssuanceID");
}
// Verify optional fields
{
auto const& expected = holderEncryptionKeyValue;
auto const actualOpt = rebuiltTx.getHolderEncryptionKey();
ASSERT_TRUE(actualOpt.has_value()) << "Optional field sfHolderEncryptionKey should be present";
expectEqualField(expected, *actualOpt, "sfHolderEncryptionKey");
}
{
auto const& expected = confidentialBalanceSpendingValue;
auto const actualOpt = rebuiltTx.getConfidentialBalanceSpending();
ASSERT_TRUE(actualOpt.has_value()) << "Optional field sfConfidentialBalanceSpending should be present";
expectEqualField(expected, *actualOpt, "sfConfidentialBalanceSpending");
}
{
auto const& expected = confidentialBalanceInboxValue;
auto const actualOpt = rebuiltTx.getConfidentialBalanceInbox();
ASSERT_TRUE(actualOpt.has_value()) << "Optional field sfConfidentialBalanceInbox should be present";
expectEqualField(expected, *actualOpt, "sfConfidentialBalanceInbox");
}
{
auto const& expected = zKProofValue;
auto const actualOpt = rebuiltTx.getZKProof();
ASSERT_TRUE(actualOpt.has_value()) << "Optional field sfZKProof should be present";
expectEqualField(expected, *actualOpt, "sfZKProof");
}
}
// 3) Verify wrapper throws when constructed from wrong transaction type.
TEST(TransactionsConfidentialMPTHolderKeyUpdateTests, WrapperThrowsOnWrongTxType)
{
// Build a valid transaction of a different type
auto const [pk, sk] =
generateKeyPair(KeyType::Secp256k1, generateSeed("testWrongType"));
auto const account = calcAccountID(pk);
AccountSetBuilder wrongBuilder{account, 1, canonical_AMOUNT()};
auto wrongTx = wrongBuilder.build(pk, sk);
EXPECT_THROW(ConfidentialMPTHolderKeyUpdate{wrongTx.getSTTx()}, std::runtime_error);
}
// 4) Verify builder throws when constructed from wrong transaction type.
TEST(TransactionsConfidentialMPTHolderKeyUpdateTests, BuilderThrowsOnWrongTxType)
{
// Build a valid transaction of a different type
auto const [pk, sk] =
generateKeyPair(KeyType::Secp256k1, generateSeed("testWrongTypeBuilder"));
auto const account = calcAccountID(pk);
AccountSetBuilder wrongBuilder{account, 1, canonical_AMOUNT()};
auto wrongTx = wrongBuilder.build(pk, sk);
EXPECT_THROW(ConfidentialMPTHolderKeyUpdateBuilder{wrongTx.getSTTx()}, std::runtime_error);
}
// 5) Build with only required fields and verify optional fields return nullopt.
TEST(TransactionsConfidentialMPTHolderKeyUpdateTests, OptionalFieldsReturnNullopt)
{
// Generate a deterministic keypair for signing
auto const [publicKey, secretKey] =
generateKeyPair(KeyType::Secp256k1, generateSeed("testConfidentialMPTHolderKeyUpdateNullopt"));
// Common transaction fields
auto const accountValue = calcAccountID(publicKey);
std::uint32_t const sequenceValue = 3;
auto const feeValue = canonical_AMOUNT();
// Transaction-specific required field values
auto const mPTokenIssuanceIDValue = canonical_UINT192();
ConfidentialMPTHolderKeyUpdateBuilder builder{
accountValue,
mPTokenIssuanceIDValue,
sequenceValue,
feeValue
};
// Do NOT set optional fields
auto tx = builder.build(publicKey, secretKey);
// Verify optional fields are not present
EXPECT_FALSE(tx.hasHolderEncryptionKey());
EXPECT_FALSE(tx.getHolderEncryptionKey().has_value());
EXPECT_FALSE(tx.hasConfidentialBalanceSpending());
EXPECT_FALSE(tx.getConfidentialBalanceSpending().has_value());
EXPECT_FALSE(tx.hasConfidentialBalanceInbox());
EXPECT_FALSE(tx.getConfidentialBalanceInbox().has_value());
EXPECT_FALSE(tx.hasZKProof());
EXPECT_FALSE(tx.getZKProof().has_value());
}
}