Merge remote-tracking branch 'Transia-RnD-rippled/dangell7/token-issuance' into develop

# Conflicts:
#	include/xrpl/protocol/Protocol.h
#	include/xrpl/protocol/TER.h
#	include/xrpl/protocol/detail/ledger_entries.macro
#	src/libxrpl/protocol/TER.cpp
This commit is contained in:
Denis Angell
2026-09-13 19:26:58 -04:00
39 changed files with 2412 additions and 40 deletions

View File

@@ -338,6 +338,17 @@ adjustLoanBrokerOwnerCount(
[[nodiscard]] Rate
transferRate(ReadView const& view, AccountID const& issuer);
/**
* Returns the transfer fee charged for a specific currency of the issuer.
* A per-currency TransferFee on the currency's TokenIssuance overrides the
* account-wide TransferRate.
*/
[[nodiscard]] Rate
transferRate(ReadView const& view, AccountID const& issuer, Currency const& currency);
[[nodiscard]] Rate
transferRate(ReadView const& view, Issue const& issue);
/**
* Generate a pseudo-account address from a pseudo owner key.
* @param pseudoOwnerKey The key to generate the address from

View File

@@ -4,6 +4,7 @@
#include <xrpl/beast/utility/Journal.h>
#include <xrpl/ledger/ApplyView.h>
#include <xrpl/ledger/ReadView.h>
#include <xrpl/ledger/helpers/TokenIssuanceHelpers.h>
#include <xrpl/protocol/AccountID.h>
#include <xrpl/protocol/Asset.h>
#include <xrpl/protocol/Issue.h>
@@ -391,7 +392,8 @@ directSendNoFee(
AccountID const& uReceiverID,
STAmount const& saAmount,
bool bCheckIssuer,
beast::Journal j);
beast::Journal j,
EnforceSupplyCap enforceSupplyCap = EnforceSupplyCap::Yes);
/**
* Calls static accountSendIOU if saAmount represents Issue.

View File

@@ -0,0 +1,109 @@
#pragma once
#include <xrpl/basics/Number.h>
#include <xrpl/beast/utility/Journal.h>
#include <xrpl/ledger/ApplyView.h>
#include <xrpl/ledger/ReadView.h>
#include <xrpl/protocol/AccountID.h>
#include <xrpl/protocol/Issue.h>
#include <xrpl/protocol/STAmount.h>
#include <xrpl/protocol/STLedgerEntry.h>
#include <xrpl/protocol/TER.h>
#include <xrpl/protocol/UintTypes.h>
#include <cstdint>
#include <optional>
namespace xrpl {
/**
* ceil(value * 10^scale), exact.
*
* The shift is a pure exponent adjustment; the +1 for a fractional
* positive value is exact because a fractional Number is < 10^16.
* Truncation toward zero already is the ceiling for negative values.
*/
[[nodiscard]] Number
tokenScaledCeil(Number const& value, std::uint8_t scale);
/**
* floor(value * 10^scale) as integer base units; nullopt if it does not
* fit in a non-negative int64.
*/
[[nodiscard]] std::optional<std::int64_t>
tokenBaseUnits(Number const& value, std::uint8_t scale);
/**
* True if the issuance violates
* ceil(IssuedAmount * 10^TokenScale) + MPT OutstandingAmount > MaximumAmount.
* Always false for an uncapped issuance.
*/
[[nodiscard]] bool
tokenSupplyExceeded(ReadView const& view, SLE::const_ref sleIssuance);
/**
* Controls whether an IOU credit hard-fails when a capped TokenIssuance
* would exceed its MaximumAmount. Flow-engine steps pass No: their send
* results are advisory (return values ignored, reverse-pass execution can
* legitimately overshoot transiently) and the supply-cap invariant checker
* gates the final state instead.
*/
enum class EnforceSupplyCap : bool { No = false, Yes = true };
/**
* Maintain IssuedAmount on the issuer's TokenIssuance for a trust-line
* balance move of `amount` from `sender` to `receiver`. Only the amount's
* issuer is adjusted: balance moving away from the issuer increases its
* net issuance, balance returning decreases it.
*
* No-op when the amendment is disabled, neither party is the issuer, or no
* TokenIssuance exists. Returns tecSUPPLY_EXCEEDED when the cap is enforced
* and a capped issuer would exceed MaximumAmount.
*/
[[nodiscard]] TER
adjustTokenIssuance(
ApplyView& view,
AccountID const& sender,
AccountID const& receiver,
STAmount const& amount,
EnforceSupplyCap enforceCap,
beast::Journal j);
/**
* Remaining IOU the issuer can issue under the supply cap, floored to the
* representable amount; nullopt when there is no TokenIssuance or no cap.
*/
[[nodiscard]] std::optional<STAmount>
tokenIssuanceHeadroom(ReadView const& view, Issue const& issue);
/**
* True when the issue's TokenIssuance carries the per-currency lock
* (lsfTokenLocked). The per-currency analog of lsfGlobalFreeze.
*/
[[nodiscard]] bool
isTokenLocked(ReadView const& view, Issue const& issue);
/**
* For an MPT issuance bound to a capped TokenIssuance: the additional MPT
* base units that can be minted under the shared cap
* (MaximumAmount - OutstandingAmount - ceil(IssuedAmount * 10^TokenScale)).
* nullopt when unbound or uncapped.
*/
[[nodiscard]] std::optional<std::int64_t>
mptBoundHeadroom(ReadView const& view, SLE::const_ref sleMptIssuance);
/**
* Validate binding an MPTokenIssuance to a TokenIssuance: the MPT issuance
* exists, is issued by `account`, is not already bound, and its
* MaximumAmount/AssetScale equal the TokenIssuance's MaximumAmount/
* TokenScale.
*/
[[nodiscard]] TER
validateTokenBinding(
ReadView const& view,
MPTID const& mptId,
AccountID const& account,
std::optional<std::uint64_t> const& maximumAmount,
std::uint8_t tokenScale);
} // namespace xrpl

View File

@@ -348,6 +348,15 @@ mptokenIssuance(uint256 const& issuanceKey)
return {ltMPTOKEN_ISSUANCE, issuanceKey};
}
Keylet
tokenIssuance(AccountID const& issuer, Currency const& currency) noexcept;
inline Keylet
tokenIssuance(uint256 const& key)
{
return {ltTOKEN_ISSUANCE, key};
}
Keylet
mptoken(MPTID const& issuanceID, AccountID const& holder) noexcept;

View File

@@ -199,7 +199,13 @@ enum LedgerEntryType : std::uint16_t {
\
LEDGER_OBJECT(Sponsorship, \
LSF_FLAG(lsfSponsorshipRequireSignForFee, 0x00010000) \
LSF_FLAG(lsfSponsorshipRequireSignForReserve, 0x00020000))
LSF_FLAG(lsfSponsorshipRequireSignForReserve, 0x00020000)) \
\
LEDGER_OBJECT(TokenIssuance, \
LSF_FLAG(lsfTokenLocked, 0x00000001) /* True, per-currency global freeze */ \
LSF_FLAG(lsfTokenCannotLock, 0x00000002) /* True, issuer renounced the per-currency lock */ \
LSF_FLAG(lsfTokenWrapped, 0x00000004) /* True, owned by a pseudo-account (blackholed issuer) */ \
LSF_FLAG(lsfTokenVerifiedSupply, 0x00000008)) /* True, IssuedAmount reflects verified legacy supply */
// clang-format on

View File

@@ -391,6 +391,20 @@ constexpr std::size_t kMaxWasmDataLength = 1 * 1024; // 1KB
*/
constexpr std::size_t kWasmTransferLimit = 1 << 20; // 1MB
/**
* Maximum MaximumAmount of a TokenIssuance, in base units (10^15). Bounded
* to what Number/STAmount arithmetic represents exactly, so the supply-cap
* comparison can never be affected by mantissa rounding.
*/
constexpr std::uint64_t kMaxTokenIssuanceAmount = 1'000'000'000'000'000ull;
static_assert(kMaxTokenIssuanceAmount <= kMaxMpTokenAmount);
/**
* Maximum TokenScale of a TokenIssuance: 10^scale must stay in exact int64
* range. 10^19 > 2^63-1 > 10^18
*/
constexpr std::uint8_t kMaxTokenIssuanceScale = 18;
/**
* A ledger index.
*/

View File

@@ -378,6 +378,7 @@ enum TECcodes : TERUnderlyingType {
tecOUT_OF_GAS = 201,
tecBYTECODE_REJECTED = 202,
tecINVALID_PARAMETERS = 203,
tecSUPPLY_EXCEEDED = 204,
};
//------------------------------------------------------------------------------

View File

@@ -162,6 +162,16 @@ inline constexpr FlagValue tfUniversalMask = ~tfUniversal;
TF_FLAG(tfMPTSetCanHoldConfidentialBalance, 0x00000100), \
MASK_ADJ(0)) \
\
TRANSACTION(TokenIssuanceCreate, \
TF_FLAG(tfTokenCannotLock, 0x00000001), \
MASK_ADJ(0)) \
\
TRANSACTION(TokenIssuanceSet, \
TF_FLAG2(tfTokenCannotLock, 0x00000001) \
TF_FLAG(tfTokenLock, 0x00000002) \
TF_FLAG(tfTokenUnlock, 0x00000004), \
MASK_ADJ(0)) \
\
TRANSACTION(NFTokenCreateOffer, \
TF_FLAG(tfSellNFToken, 0x00000001), \
MASK_ADJ(0)) \

View File

@@ -22,25 +22,27 @@ enum class Emittance { Emitable, NotEmitable };
* TxSettings::privileges) and enforced in InvariantCheck.cpp.
*/
enum class Privilege : std::uint16_t {
NoPriv = 0x0000, // The transaction can not do any of the enumerated operations
CreateAcct = 0x0001, // The transaction can create a new ACCOUNT_ROOT object.
CreatePseudoAcct = 0x0002, // The transaction can create a pseudo account,
// which implies createAcct
MustDeleteAcct = 0x0004, // The transaction must delete an ACCOUNT_ROOT object
MayDeleteAcct = 0x0008, // The transaction may delete an ACCOUNT_ROOT
// object, but does not have to
OverrideFreeze = 0x0010, // The transaction can override some freeze rules
ChangeNftCounts = 0x0020, // The transaction can mint or burn an NFT
CreateMptIssuance = 0x0040, // The transaction can create a new MPT issuance
DestroyMptIssuance = 0x0080, // The transaction can destroy an MPT issuance
MustAuthorizeMpt = 0x0100, // The transaction MUST create or delete an MPT
// object (except by issuer)
MayAuthorizeMpt = 0x0200, // The transaction MAY create or delete an MPT
// object (except by issuer)
MayDeleteMpt = 0x0400, // The transaction MAY delete an MPT object. May not create.
MustModifyVault = 0x0800, // The transaction must modify, delete or create, a vault
MayModifyVault = 0x1000, // The transaction MAY modify, delete or create, a vault
MayCreateMpt = 0x2000, // The transaction MAY create an MPT object, except for issuer.
NoPriv = 0x0000, // The transaction can not do any of the enumerated operations
CreateAcct = 0x0001, // The transaction can create a new ACCOUNT_ROOT object.
CreatePseudoAcct = 0x0002, // The transaction can create a pseudo account,
// which implies createAcct
MustDeleteAcct = 0x0004, // The transaction must delete an ACCOUNT_ROOT object
MayDeleteAcct = 0x0008, // The transaction may delete an ACCOUNT_ROOT
// object, but does not have to
OverrideFreeze = 0x0010, // The transaction can override some freeze rules
ChangeNftCounts = 0x0020, // The transaction can mint or burn an NFT
CreateMptIssuance = 0x0040, // The transaction can create a new MPT issuance
DestroyMptIssuance = 0x0080, // The transaction can destroy an MPT issuance
MustAuthorizeMpt = 0x0100, // The transaction MUST create or delete an MPT
// object (except by issuer)
MayAuthorizeMpt = 0x0200, // The transaction MAY create or delete an MPT
// object (except by issuer)
MayDeleteMpt = 0x0400, // The transaction MAY delete an MPT object. May not create.
MustModifyVault = 0x0800, // The transaction must modify, delete or create, a vault
MayModifyVault = 0x1000, // The transaction MAY modify, delete or create, a vault
MayCreateMpt = 0x2000, // The transaction MAY create an MPT object, except for issuer.
CreateTokenIssuance = 0x4000, // The transaction can create a new token issuance
DestroyTokenIssuance = 0x8000, // The transaction can destroy a token issuance
};
// The inner static_cast is not redundant: the underlying type is narrower than

View File

@@ -153,3 +153,4 @@ XRPL_FEATURE(TokenPaychan, Supported::Yes, VoteBehavior::DefaultN
XRPL_FEATURE(Quantum, Supported::Yes, VoteBehavior::DefaultNo)
XRPL_FEATURE(Passkey, Supported::Yes, VoteBehavior::DefaultNo)
XRPL_FEATURE(MPTStructuredData, Supported::Yes, VoteBehavior::DefaultNo)
XRPL_FEATURE(TokenIssuance, Supported::No, VoteBehavior::DefaultNo)

View File

@@ -412,6 +412,7 @@ LEDGER_ENTRY(ltMPTOKEN_ISSUANCE, 0x007e, MPTokenIssuance, mpt_issuance, ({
{sfLockedAmount, SoeOptional},
{sfMPTokenMetadata, SoeOptional},
{sfMPTokenSchema, SoeOptional},
{sfTokenIssuanceID, SoeOptional},
{sfPreviousTxnID, SoeRequired},
{sfPreviousTxnLgrSeq, SoeRequired},
{sfDomainID, SoeOptional},
@@ -702,5 +703,22 @@ LEDGER_ENTRY(ltPASSKEY_LIST, 0x0091, PasskeyList, passkey_list, ({
{sfPasskeys, SoeRequired},
}))
/** A ledger object which describes an IOU token issuance.
\sa keylet::tokenIssuance
*/
LEDGER_ENTRY(ltTOKEN_ISSUANCE, 0x0092, TokenIssuance, token_issuance, ({
{sfIssuer, SoeRequired},
{sfCurrency, SoeRequired},
{sfMaximumAmount, SoeOptional},
{sfIssuedAmount, SoeDefault},
{sfTokenScale, SoeDefault},
{sfMPTokenIssuanceID, SoeOptional},
{sfTransferFee, SoeOptional},
{sfMPTokenMetadata, SoeOptional},
{sfOwnerNode, SoeRequired},
{sfPreviousTxnID, SoeRequired},
{sfPreviousTxnLgrSeq, SoeRequired},
}))
#undef EXPAND
#undef LEDGER_ENTRY_DUPLICATE

View File

@@ -479,3 +479,7 @@ UNTYPED_SFIELD(sfPasskeySignature, OBJECT, 43, SField::kSmdDefault, SFi
UNTYPED_SFIELD(sfPasskey, OBJECT, 44)
UNTYPED_SFIELD(sfPasskeys, ARRAY, 36)
TYPED_SFIELD(sfMPTokenSchema, VL, 53)
TYPED_SFIELD(sfTokenScale, UINT8, 7)
TYPED_SFIELD(sfTokenIssuanceID, UINT256, 44)
TYPED_SFIELD(sfIssuedAmount, NUMBER, 18, SField::kSmdNeedsAsset | SField::kSmdDefault)
TYPED_SFIELD(sfCurrency, CURRENCY, 3)

View File

@@ -1320,3 +1320,37 @@ TRANSACTION(ttPASSKEY_LIST_SET, 103, PasskeyListSet,
({
{sfPasskeys, SoeRequired},
}))
TRANSACTION(ttTOKEN_ISSUANCE_CREATE, 104, TokenIssuanceCreate,
({
.amendment = featureTokenIssuance,
.privileges = Privilege::CreateTokenIssuance,
}),
({
{sfCurrency, SoeRequired},
{sfMaximumAmount, SoeOptional},
{sfTokenScale, SoeOptional},
{sfMPTokenIssuanceID, SoeOptional},
{sfTransferFee, SoeOptional},
{sfMPTokenMetadata, SoeOptional},
}))
TRANSACTION(ttTOKEN_ISSUANCE_SET, 105, TokenIssuanceSet,
({.amendment = featureTokenIssuance}),
({
{sfCurrency, SoeRequired},
{sfMPTokenIssuanceID, SoeOptional},
{sfTransferFee, SoeOptional},
{sfMPTokenMetadata, SoeOptional},
}))
TRANSACTION(ttTOKEN_ISSUANCE_DESTROY, 106, TokenIssuanceDestroy,
({
.amendment = featureTokenIssuance,
.privileges = Privilege::DestroyTokenIssuance,
}),
({
{sfCurrency, SoeRequired},
}))
TRANSACTION(ttTOKEN_CONVERT, 107, TokenConvert,
({.amendment = featureTokenIssuance}),
({
{sfAmount, SoeRequired, SoeMptSupported},
}))

View File

@@ -16,6 +16,7 @@
#include <xrpl/tx/invariants/PermissionedDEXInvariant.h>
#include <xrpl/tx/invariants/PermissionedDomainInvariant.h>
#include <xrpl/tx/invariants/SponsorshipInvariant.h>
#include <xrpl/tx/invariants/TokenIssuanceInvariant.h>
#include <xrpl/tx/invariants/VaultInvariant.h>
#include <cstdint>
@@ -486,7 +487,8 @@ using InvariantChecks = std::tuple<
ValidMPTTransfer,
ObjectHasPseudoAccount,
SponsorshipOwnerCountsMatch,
SponsorshipAccountCountMatchesField>;
SponsorshipAccountCountMatchesField,
ValidTokenIssuance>;
/**
* @brief get a tuple of all invariant checks

View File

@@ -0,0 +1,48 @@
#pragma once
#include <xrpl/beast/utility/Journal.h>
#include <xrpl/ledger/ReadView.h>
#include <xrpl/protocol/STLedgerEntry.h>
#include <xrpl/protocol/STTx.h>
#include <xrpl/protocol/TER.h>
#include <xrpl/protocol/XRPAmount.h>
#include <cstdint>
#include <vector>
namespace xrpl {
/**
* Invariants for TokenIssuance objects:
*
* - created/deleted only by transactions carrying the matching privilege,
* and the privileged transactions create/delete exactly one
* - MaximumAmount, TokenScale, Issuer, and Currency never change; the
* MPT binding is write-once
* - a capped issuance never exceeds
* ceil(IssuedAmount * 10^TokenScale) + MPT OutstandingAmount
* <= MaximumAmount after application
* - deletion only with IssuedAmount == 0
*/
class ValidTokenIssuance
{
std::uint32_t created_ = 0;
std::uint32_t deleted_ = 0;
bool immutableChanged_ = false;
std::vector<SLE::const_pointer> after_;
std::vector<SLE::const_pointer> deletedIssuances_;
public:
void
visitEntry(bool isDelete, SLE::const_ref before, SLE::const_ref after);
[[nodiscard]] bool
finalize(
STTx const& tx,
TER const result,
XRPAmount const fee,
ReadView const& view,
beast::Journal const& j);
};
} // namespace xrpl

View File

@@ -5,6 +5,7 @@
#include <xrpl/beast/utility/instrumentation.h>
#include <xrpl/ledger/ReadView.h>
#include <xrpl/ledger/View.h>
#include <xrpl/ledger/helpers/TokenIssuanceHelpers.h>
#include <xrpl/protocol/AccountID.h>
#include <xrpl/protocol/Feature.h>
#include <xrpl/protocol/Indexes.h>
@@ -33,6 +34,13 @@ checkFreeze(
}
}
// The per-currency lock behaves like a global freeze scoped to one
// currency of the issuer.
if (isTokenLocked(view, Issue{currency, dst}))
{
return terNO_LINE;
}
if (auto sle = view.read(keylet::trustLine(src, dst, currency)))
{
if (sle->isFlag((dst > src) ? lsfHighFreeze : lsfLowFreeze))

View File

@@ -0,0 +1,47 @@
#pragma once
#include <xrpl/beast/utility/Journal.h>
#include <xrpl/ledger/ReadView.h>
#include <xrpl/protocol/STLedgerEntry.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>
namespace xrpl {
class TokenConvert : public Transactor
{
public:
static constexpr auto kConsequencesFactory = ConsequencesFactoryType::Normal;
explicit TokenConvert(ApplyContext& ctx) : Transactor(ctx)
{
}
static bool
checkExtraFeatures(PreflightContext const& ctx);
static NotTEC
preflight(PreflightContext const& ctx);
static TER
preclaim(PreclaimContext const& ctx);
TER
doApply() override;
void
visitInvariantEntry(bool, SLE::const_ref, SLE::const_ref) override
{
}
[[nodiscard]] bool
finalizeInvariants(STTx const&, TER, XRPAmount, ReadView const&, beast::Journal const&) override
{
return true;
}
};
} // namespace xrpl

View File

@@ -0,0 +1,52 @@
#pragma once
#include <xrpl/beast/utility/Journal.h>
#include <xrpl/ledger/ReadView.h>
#include <xrpl/protocol/STLedgerEntry.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 {
class TokenIssuanceCreate : public Transactor
{
public:
static constexpr auto kConsequencesFactory = ConsequencesFactoryType::Normal;
explicit TokenIssuanceCreate(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 TER
preclaim(PreclaimContext const& ctx);
TER
doApply() override;
void
visitInvariantEntry(bool, SLE::const_ref, SLE::const_ref) override
{
}
[[nodiscard]] bool
finalizeInvariants(STTx const&, TER, XRPAmount, ReadView const&, beast::Journal const&) override
{
return true;
}
};
} // namespace xrpl

View File

@@ -0,0 +1,44 @@
#pragma once
#include <xrpl/beast/utility/Journal.h>
#include <xrpl/ledger/ReadView.h>
#include <xrpl/protocol/STLedgerEntry.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>
namespace xrpl {
class TokenIssuanceDestroy : public Transactor
{
public:
static constexpr auto kConsequencesFactory = ConsequencesFactoryType::Normal;
explicit TokenIssuanceDestroy(ApplyContext& ctx) : Transactor(ctx)
{
}
static NotTEC
preflight(PreflightContext const& ctx);
static TER
preclaim(PreclaimContext const& ctx);
TER
doApply() override;
void
visitInvariantEntry(bool, SLE::const_ref, SLE::const_ref) override
{
}
[[nodiscard]] bool
finalizeInvariants(STTx const&, TER, XRPAmount, ReadView const&, beast::Journal const&) override
{
return true;
}
};
} // namespace xrpl

View File

@@ -0,0 +1,52 @@
#pragma once
#include <xrpl/beast/utility/Journal.h>
#include <xrpl/ledger/ReadView.h>
#include <xrpl/protocol/STLedgerEntry.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 {
class TokenIssuanceSet : public Transactor
{
public:
static constexpr auto kConsequencesFactory = ConsequencesFactoryType::Normal;
explicit TokenIssuanceSet(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 TER
preclaim(PreclaimContext const& ctx);
TER
doApply() override;
void
visitInvariantEntry(bool, SLE::const_ref, SLE::const_ref) override
{
}
[[nodiscard]] bool
finalizeInvariants(STTx const&, TER, XRPAmount, ReadView const&, beast::Journal const&) override
{
return true;
}
};
} // namespace xrpl

View File

@@ -308,6 +308,28 @@ transferRate(ReadView const& view, AccountID const& issuer)
return kParityRate;
}
Rate
transferRate(ReadView const& view, AccountID const& issuer, Currency const& currency)
{
// A per-currency TransferFee on the TokenIssuance overrides the
// account-wide TransferRate. Fee is in the XLS-33 format
// (0..50000 = 0%..50%).
if (view.rules().enabled(featureTokenIssuance))
{
if (auto const sle = view.read(keylet::tokenIssuance(issuer, currency));
sle && sle->isFieldPresent(sfTransferFee))
return Rate{1'000'000'000u + 10'000u * sle->getFieldU16(sfTransferFee)};
}
return transferRate(view, issuer);
}
Rate
transferRate(ReadView const& view, Issue const& issue)
{
return transferRate(view, issue.account, issue.currency);
}
void
increaseOwnerCount(
ApplyView& view,

View File

@@ -11,6 +11,7 @@
#include <xrpl/ledger/helpers/DirectoryHelpers.h>
#include <xrpl/ledger/helpers/SponsorHelpers.h>
#include <xrpl/ledger/helpers/TokenHelpers.h>
#include <xrpl/ledger/helpers/TokenIssuanceHelpers.h>
#include <xrpl/protocol/AccountID.h>
#include <xrpl/protocol/AmountConversions.h>
#include <xrpl/protocol/Feature.h>
@@ -136,6 +137,8 @@ isFrozen(
auto sle = view.read(keylet::account(issuer));
if (sle && sle->isFlag(lsfGlobalFreeze))
return true;
if (isTokenLocked(view, Issue{currency, issuer}))
return true;
if (issuer != account)
{
// Check if the issuer froze the line
@@ -414,6 +417,11 @@ issueIOU(
JLOG(j.trace()) << "issueIOU: " << to_string(account) << ": " << amount.getFullText();
if (auto const ter =
adjustTokenIssuance(view, issue.account, account, amount, EnforceSupplyCap::Yes, j);
!isTesSuccess(ter))
return ter;
bool const bSenderHigh = issue.account > account;
auto const index = keylet::trustLine(issue.account, account, issue.currency);
@@ -509,6 +517,11 @@ redeemIOU(
JLOG(j.trace()) << "redeemIOU: " << to_string(account) << ": " << amount.getFullText();
if (auto const ter =
adjustTokenIssuance(view, account, issue.account, amount, EnforceSupplyCap::Yes, j);
!isTesSuccess(ter))
return ter;
bool const bSenderHigh = account > issue.account;
if (auto state = view.peek(keylet::trustLine(account, issue.account, issue.currency)))

View File

@@ -226,6 +226,7 @@ isLedgerEntryOwner(ReadView const& view, SLE const& sle, AccountID const& accoun
case ltDEPOSIT_PREAUTH:
return sle.getAccountID(sfAccount) == account;
case ltMPTOKEN_ISSUANCE:
case ltTOKEN_ISSUANCE:
return sle.getAccountID(sfIssuer) == account;
case ltSIGNER_LIST: {
auto const signerList = view.read(keylet::signerList(account));
@@ -275,6 +276,7 @@ isLedgerEntrySupportedBySponsorship(SLE const& sle)
case ltDELEGATE:
case ltDEPOSIT_PREAUTH:
case ltMPTOKEN_ISSUANCE:
case ltTOKEN_ISSUANCE:
case ltSIGNER_LIST:
case ltCREDENTIAL:
case ltRIPPLE_STATE:

View File

@@ -11,6 +11,7 @@
#include <xrpl/ledger/helpers/MPTokenHelpers.h>
#include <xrpl/ledger/helpers/RippleStateHelpers.h>
#include <xrpl/ledger/helpers/SponsorHelpers.h>
#include <xrpl/ledger/helpers/TokenIssuanceHelpers.h>
#include <xrpl/protocol/AccountID.h>
#include <xrpl/protocol/Asset.h>
#include <xrpl/protocol/Concepts.h>
@@ -47,7 +48,9 @@ bool
isGlobalFrozen(ReadView const& view, Asset const& asset)
{
return asset.visit(
[&](Issue const& issue) { return isGlobalFrozen(view, issue.getIssuer()); },
[&](Issue const& issue) {
return isGlobalFrozen(view, issue.getIssuer()) || isTokenLocked(view, issue);
},
[&](MPTIssue const& issue) { return isGlobalFrozen(view, issue); });
}
@@ -430,7 +433,10 @@ accountHolds(
{
return amount;
}
auto const available = availableMPTAmount(*issuance);
auto available = availableMPTAmount(*issuance);
// A bound issuance shares its cap with the IOU side.
if (auto const headroom = mptBoundHeadroom(view, issuance))
available = std::min(available, *headroom);
if (!mptokensV2)
return STAmount{mptIssue, available};
return view.balanceHookMPT(issuer, mptIssue, available);
@@ -504,6 +510,11 @@ accountFunds(
{
XRPL_ASSERT(saDefault.holds<Issue>(), "xrpl::accountFunds: saDefault holds Issue");
// Note: a capped issuer's funds are NOT clamped to the supply headroom
// here. Funds readings must stay stable across a transaction (deferred
// credits) or partially-crossed issuer offers read as never-funded and
// are removed; the supply cap is enforced at the credit choke point
// instead, which fails an over-cap crossing outright.
if (!saDefault.native() && saDefault.getIssuer() == id)
return saDefault;
@@ -538,7 +549,7 @@ Rate
transferRate(ReadView const& view, Asset const& asset)
{
return asset.visit(
[&](Issue const& issue) { return transferRate(view, issue.getIssuer()); },
[&](Issue const& issue) { return transferRate(view, issue); },
[&](MPTIssue const& issue) { return transferRate(view, issue.getMptID()); });
}
@@ -688,7 +699,8 @@ directSendNoFeeIOU(
STAmount const& saAmount,
bool bCheckIssuer,
SLE::ref sponsorSle,
beast::Journal j)
beast::Journal j,
EnforceSupplyCap enforceSupplyCap = EnforceSupplyCap::Yes)
{
AccountID const& issuer = saAmount.getIssuer();
Currency const& currency = saAmount.get<Issue>().currency;
@@ -712,6 +724,12 @@ directSendNoFeeIOU(
!isXRP(uReceiverID) && uReceiverID != noAccount(),
"xrpl::directSendNoFeeIOU : receiver is not XRP");
// Supply accounting: every trust-line balance move funnels through here.
if (auto const ter =
adjustTokenIssuance(view, uSenderID, uReceiverID, saAmount, enforceSupplyCap, j);
!isTesSuccess(ter))
return ter;
// If the line exists, modify it accordingly.
if (auto const sleRippleState = view.peek(index))
{
@@ -866,13 +884,29 @@ directSendNoLimitIOU(
// Calculate the amount to transfer accounting
// for any transfer fees if the fee is not waived:
saActual = (waiveFee == WaiveTransferFee::Yes) ? saAmount
: multiply(saAmount, transferRate(view, issuer));
saActual = (waiveFee == WaiveTransferFee::Yes)
? saAmount
: multiply(saAmount, transferRate(view, saAmount.get<Issue>()));
JLOG(j.debug()) << "directSendNoLimitIOU> " << to_string(uSenderID) << " - > "
<< to_string(uReceiverID) << " : deliver=" << saAmount.getFullText()
<< " cost=" << saActual.getFullText();
if (view.rules().enabled(featureTokenIssuance))
{
// Redeem from the sender before issuing to the receiver so a capped
// currency never transiently exceeds its supply cap in transit.
TER terResult = directSendNoFeeIOU(view, uSenderID, issuer, saActual, true, sponsorSle, j);
if (tesSUCCESS == terResult)
{
terResult =
directSendNoFeeIOU(view, issuer, uReceiverID, saAmount, true, sponsorSle, j);
}
return terResult;
}
TER terResult = directSendNoFeeIOU(view, issuer, uReceiverID, saAmount, true, sponsorSle, j);
if (tesSUCCESS == terResult)
@@ -938,7 +972,7 @@ directSendNoLimitMultiIOU(
// for any transfer fees if the fee is not waived:
STAmount const actualSend = (waiveFee == WaiveTransferFee::Yes)
? amount
: multiply(amount, transferRate(view, issuer));
: multiply(amount, transferRate(view, amount.get<Issue>()));
actual += actualSend;
takeFromSender += actualSend;
@@ -1233,6 +1267,9 @@ directSendNoFeeMPT(
if (isMPTOverflow(amt, outstanding, maxAmount, AllowMPTOverflow::Yes))
return tecPATH_DRY;
}
// A bound issuance shares its cap with the IOU side.
if (auto const headroom = mptBoundHeadroom(view, sleIssuance); headroom && amt > *headroom)
return tecSUPPLY_EXCEEDED;
(*sleIssuance)[sfOutstandingAmount] += amt;
view.update(sleIssuance);
}
@@ -1525,11 +1562,13 @@ directSendNoFee(
AccountID const& uReceiverID,
STAmount const& saAmount,
bool bCheckIssuer,
beast::Journal j)
beast::Journal j,
EnforceSupplyCap enforceSupplyCap)
{
return saAmount.asset().visit(
[&](Issue const&) {
return directSendNoFeeIOU(view, uSenderID, uReceiverID, saAmount, bCheckIssuer, {}, j);
return directSendNoFeeIOU(
view, uSenderID, uReceiverID, saAmount, bCheckIssuer, {}, j, enforceSupplyCap);
},
[&](MPTIssue const&) {
XRPL_ASSERT(!bCheckIssuer, "xrpl::directSendNoFee : not checking issuer");

View File

@@ -0,0 +1,221 @@
#include <xrpl/ledger/helpers/TokenIssuanceHelpers.h>
#include <xrpl/basics/Log.h>
#include <xrpl/basics/Number.h>
#include <xrpl/beast/utility/Zero.h>
#include <xrpl/ledger/helpers/MPTokenHelpers.h>
#include <xrpl/protocol/Feature.h>
#include <xrpl/protocol/Indexes.h>
#include <xrpl/protocol/LedgerFormats.h>
#include <xrpl/protocol/SField.h>
#include <xrpl/protocol/STLedgerEntry.h>
#include <xrpl/protocol/STNumber.h>
#include <xrpl/protocol/STTakesAsset.h>
#include <xrpl/protocol/TER.h>
#include <cstdint>
#include <optional>
namespace xrpl {
Number
tokenScaledCeil(Number const& value, std::uint8_t scale)
{
Number const shifted(value.mantissa(), value.exponent() + scale);
Number const trunc = shifted.truncate();
if (trunc < shifted)
return trunc + Number(1);
return trunc;
}
std::optional<std::int64_t>
tokenBaseUnits(Number const& value, std::uint8_t scale)
{
if (value < Number{})
return std::nullopt;
Number const shifted(value.mantissa(), value.exponent() + scale);
// 10^18 is exactly representable; anything above it is far beyond any
// legal MaximumAmount.
if (shifted > Number(1'000'000'000'000'000'000LL))
return std::nullopt;
NumberRoundModeGuard const guard(Number::RoundingMode::TowardsZero);
return static_cast<std::int64_t>(shifted);
}
static bool
supplyWouldExceed(ReadView const& view, SLE::const_ref sleIssuance, Number const& issued)
{
auto const maximum = sleIssuance->at(~sfMaximumAmount);
if (!maximum)
return false;
std::uint64_t mptOutstanding = 0;
if (auto const mptId = sleIssuance->at(~sfMPTokenIssuanceID))
{
if (auto const sleMpt = view.read(keylet::mptokenIssuance(*mptId)))
mptOutstanding = sleMpt->at(sfOutstandingAmount);
}
if (mptOutstanding > *maximum)
return true;
Number const iouUnits = tokenScaledCeil(issued, sleIssuance->at(sfTokenScale));
return iouUnits > Number(static_cast<std::int64_t>(*maximum - mptOutstanding));
}
bool
tokenSupplyExceeded(ReadView const& view, SLE::const_ref sleIssuance)
{
return supplyWouldExceed(view, sleIssuance, sleIssuance->at(sfIssuedAmount));
}
TER
adjustTokenIssuance(
ApplyView& view,
AccountID const& sender,
AccountID const& receiver,
STAmount const& amount,
EnforceSupplyCap enforceCap,
beast::Journal j)
{
if (!view.rules().enabled(featureTokenIssuance))
return tesSUCCESS;
if (!amount.holds<Issue>() || amount.native() || amount == beast::kZero)
return tesSUCCESS;
if (amount.negative())
return adjustTokenIssuance(view, receiver, sender, -amount, enforceCap, j);
// Only the amount's issuer has an obligation in this move. Currency
// codes are not unique across issuers, so the counterparty's issuance
// of the same code must never be touched.
AccountID const& issuer = amount.getIssuer();
bool const increasing = issuer == sender;
if (!increasing && issuer != receiver)
return tesSUCCESS;
auto const sle = view.peek(keylet::tokenIssuance(issuer, amount.get<Issue>().currency));
if (!sle)
return tesSUCCESS;
Number const delta = amount;
Number const issued = sle->at(sfIssuedAmount);
Number const next = increasing ? issued + delta : issued - delta;
if (increasing && enforceCap == EnforceSupplyCap::Yes && supplyWouldExceed(view, sle, next))
{
JLOG(j.trace()) << "adjustTokenIssuance: supply cap exceeded for " << issuer;
return tecSUPPLY_EXCEEDED;
}
sle->at(sfIssuedAmount) = next;
associateAsset(*sle, amount.asset());
view.update(sle);
return tesSUCCESS;
}
std::optional<STAmount>
tokenIssuanceHeadroom(ReadView const& view, Issue const& issue)
{
if (!view.rules().enabled(featureTokenIssuance))
return std::nullopt;
auto const sle = view.read(keylet::tokenIssuance(issue.account, issue.currency));
if (!sle)
return std::nullopt;
auto const maximum = sle->at(~sfMaximumAmount);
if (!maximum)
return std::nullopt;
std::uint8_t const scale = sle->at(sfTokenScale);
std::uint64_t mptOutstanding = 0;
if (auto const mptId = sle->at(~sfMPTokenIssuanceID))
{
if (auto const sleMpt = view.read(keylet::mptokenIssuance(*mptId)))
mptOutstanding = sleMpt->at(sfOutstandingAmount);
}
if (mptOutstanding > *maximum)
return STAmount{issue};
Number const capUnits(static_cast<std::int64_t>(*maximum - mptOutstanding));
Number const iouUnits = tokenScaledCeil(sle->at(sfIssuedAmount), scale);
if (iouUnits >= capUnits)
return STAmount{issue};
Number const freeUnits = capUnits - iouUnits;
return STAmount{issue, Number(freeUnits.mantissa(), freeUnits.exponent() - scale)};
}
bool
isTokenLocked(ReadView const& view, Issue const& issue)
{
if (!view.rules().enabled(featureTokenIssuance))
return false;
auto const sle = view.read(keylet::tokenIssuance(issue.account, issue.currency));
return sle && sle->isFlag(lsfTokenLocked);
}
std::optional<std::int64_t>
mptBoundHeadroom(ReadView const& view, SLE::const_ref sleMptIssuance)
{
if (!view.rules().enabled(featureTokenIssuance))
return std::nullopt;
auto const issuanceKey = sleMptIssuance->at(~sfTokenIssuanceID);
if (!issuanceKey)
return std::nullopt;
auto const sleTI = view.read(keylet::tokenIssuance(*issuanceKey));
if (!sleTI)
return std::nullopt; // LCOV_EXCL_LINE
auto const maximum = sleTI->at(~sfMaximumAmount);
if (!maximum)
return std::nullopt;
std::uint64_t const outstanding = sleMptIssuance->at(sfOutstandingAmount);
if (outstanding >= *maximum)
return 0;
Number const room(static_cast<std::int64_t>(*maximum - outstanding));
Number const iouUnits = tokenScaledCeil(sleTI->at(sfIssuedAmount), sleTI->at(sfTokenScale));
if (iouUnits >= room)
return 0;
return tokenBaseUnits(room - iouUnits, 0).value_or(0);
}
TER
validateTokenBinding(
ReadView const& view,
MPTID const& mptId,
AccountID const& account,
std::optional<std::uint64_t> const& maximumAmount,
std::uint8_t tokenScale)
{
auto const sleMpt = view.read(keylet::mptokenIssuance(mptId));
if (!sleMpt)
return tecOBJECT_NOT_FOUND;
if (sleMpt->at(sfIssuer) != account)
return tecNO_PERMISSION;
// The binding is one-to-one: an MPT issuance already bound to a
// TokenIssuance cannot be bound again.
if (sleMpt->isFieldPresent(sfTokenIssuanceID))
return tecDUPLICATE;
// One cap, two views of it: equal maxima in the same base units.
if (sleMpt->at(~sfMaximumAmount) != maximumAmount)
return tecWRONG_ASSET;
if (sleMpt->at(sfAssetScale) != tokenScale)
return tecWRONG_ASSET;
return tesSUCCESS;
}
} // namespace xrpl

View File

@@ -105,6 +105,7 @@ enum class LedgerNameSpace : std::uint16_t {
Loan = 'L',
PasskeyList = 'k',
Sponsorship = '>',
TokenIssuance = 'F',
ContractSource = 'Z',
Contract = 'c',
@@ -564,6 +565,12 @@ mptokenIssuance(MPTID const& issuanceID) noexcept
return {ltMPTOKEN_ISSUANCE, indexHash(LedgerNameSpace::MPTokenIssuance, issuanceID)};
}
Keylet
tokenIssuance(AccountID const& issuer, Currency const& currency) noexcept
{
return {ltTOKEN_ISSUANCE, indexHash(LedgerNameSpace::TokenIssuance, issuer, currency)};
}
Keylet
mptoken(MPTID const& issuanceID, AccountID const& holder) noexcept
{

View File

@@ -111,6 +111,7 @@ transResults()
MAKE_ERROR(tecOUT_OF_GAS, "The WASM code ran out of gas during execution."),
MAKE_ERROR(tecBYTECODE_REJECTED, "The custom WASM code that was run rejected your transaction."),
MAKE_ERROR(tecINVALID_PARAMETERS, "Contract parameters do not match the expected ABI."),
MAKE_ERROR(tecSUPPLY_EXCEEDED, "The transaction would exceed the token's maximum supply."),
MAKE_ERROR(tefALREADY, "The exact transaction was already in this ledger."),
MAKE_ERROR(tefBAD_ADD_AUTH, "Not authorized to add account."),

View File

@@ -0,0 +1,129 @@
#include <xrpl/tx/invariants/TokenIssuanceInvariant.h>
#include <xrpl/basics/Log.h>
#include <xrpl/basics/Number.h>
#include <xrpl/ledger/helpers/TokenIssuanceHelpers.h>
#include <xrpl/protocol/Feature.h>
#include <xrpl/protocol/LedgerFormats.h>
#include <xrpl/protocol/SField.h>
#include <xrpl/protocol/STAccount.h>
#include <xrpl/protocol/STCurrency.h>
#include <xrpl/protocol/STNumber.h>
#include <xrpl/tx/invariants/InvariantCheckPrivilege.h>
namespace xrpl {
void
ValidTokenIssuance::visitEntry(bool isDelete, SLE::const_ref before, SLE::const_ref after)
{
auto const type = before ? before->getType() : after ? after->getType() : ltANY;
if (type != ltTOKEN_ISSUANCE)
return;
if (!before && after)
{
++created_;
after_.push_back(after);
return;
}
if (isDelete)
{
++deleted_;
deletedIssuances_.push_back(before);
return;
}
if (before && after)
{
if (before->at(~sfMaximumAmount) != after->at(~sfMaximumAmount) ||
before->at(sfTokenScale) != after->at(sfTokenScale) ||
before->at(sfIssuer) != after->at(sfIssuer) ||
before->at(sfCurrency) != after->at(sfCurrency) ||
(before->isFieldPresent(sfMPTokenIssuanceID) &&
before->at(~sfMPTokenIssuanceID) != after->at(~sfMPTokenIssuanceID)))
immutableChanged_ = true;
after_.push_back(after);
}
}
bool
ValidTokenIssuance::finalize(
STTx const& tx,
TER const result,
XRPAmount const,
ReadView const& view,
beast::Journal const& j)
{
// No TokenIssuance can exist before the amendment, so any activity at
// all pre-amendment is a hard failure.
if (!view.rules().enabled(featureTokenIssuance) &&
(created_ != 0 || deleted_ != 0 || !after_.empty() || !deletedIssuances_.empty()))
{
JLOG(j.fatal()) << "Invariant failed: TokenIssuance changed without amendment";
return false;
}
// The destructive checks run regardless of the transaction result: a
// failed transaction must not have created, deleted, or mutated
// anything (and carries no privileges).
if (created_ != 0 && !hasPrivilege(tx, Privilege::CreateTokenIssuance))
{
JLOG(j.fatal()) << "Invariant failed: TokenIssuance created without privilege";
return false;
}
if (deleted_ != 0 && !hasPrivilege(tx, Privilege::DestroyTokenIssuance))
{
JLOG(j.fatal()) << "Invariant failed: TokenIssuance deleted without privilege";
return false;
}
if (immutableChanged_)
{
JLOG(j.fatal()) << "Invariant failed: TokenIssuance immutable field changed";
return false;
}
for (auto const& sle : deletedIssuances_)
{
if (Number const issued = sle->at(sfIssuedAmount); issued != Number{})
{
JLOG(j.fatal()) << "Invariant failed: TokenIssuance deleted with "
"outstanding IssuedAmount";
return false;
}
}
for (auto const& sle : after_)
{
if (tokenSupplyExceeded(view, sle))
{
JLOG(j.fatal()) << "Invariant failed: TokenIssuance supply cap exceeded";
return false;
}
}
if (!isTesSuccess(result))
return true;
// The privileged transactions must do exactly what they claim.
if (hasPrivilege(tx, Privilege::CreateTokenIssuance) && (created_ != 1 || deleted_ != 0))
{
JLOG(j.fatal()) << "Invariant failed: TokenIssuanceCreate succeeded "
"without creating exactly one issuance";
return false;
}
if (hasPrivilege(tx, Privilege::DestroyTokenIssuance) && (deleted_ != 1 || created_ != 0))
{
JLOG(j.fatal()) << "Invariant failed: TokenIssuanceDestroy succeeded "
"without deleting exactly one issuance";
return false;
}
return true;
}
} // namespace xrpl

View File

@@ -11,6 +11,7 @@
#include <xrpl/ledger/helpers/AccountRootHelpers.h>
#include <xrpl/ledger/helpers/MPTokenHelpers.h>
#include <xrpl/ledger/helpers/TokenHelpers.h>
#include <xrpl/ledger/helpers/TokenIssuanceHelpers.h>
#include <xrpl/protocol/AccountID.h>
#include <xrpl/protocol/Asset.h>
#include <xrpl/protocol/Book.h>
@@ -1482,7 +1483,7 @@ BookStep<TIn, TOut, TDerived>::rate(
[&](Issue const& issue) -> Rate {
if (isXRP(issue.account) || issue.account == dstAccount)
return kParityRate;
return transferRate(view, issue.account);
return transferRate(view, issue);
},
[&](MPTIssue const& mptIssue) -> Rate {
// For MPT, parity applies only when this asset is the final strand

View File

@@ -8,6 +8,7 @@
#include <xrpl/ledger/helpers/AccountRootHelpers.h>
#include <xrpl/ledger/helpers/RippleStateHelpers.h>
#include <xrpl/ledger/helpers/TokenHelpers.h>
#include <xrpl/ledger/helpers/TokenIssuanceHelpers.h>
#include <xrpl/protocol/AccountID.h>
#include <xrpl/protocol/AmountConversions.h>
#include <xrpl/protocol/IOUAmount.h>
@@ -481,11 +482,26 @@ DirectStepI<TDerived>::maxPaymentFlow(ReadView const& sb) const
auto const srcOwed = toAmount<IOUAmount>(
accountHolds(sb, src_, currency_, dst_, FreezeHandling::IgnoreFreeze, j_));
// A capped issuer at the head of a strand expands supply, so the
// headroom bounds the flow. Mid-strand the issuer only ripples
// (redeem-then-reissue, sum-neutral), which the cap never blocks.
std::optional<STAmount> headroom;
if (prevStep_ == nullptr)
headroom = tokenIssuanceHeadroom(sb, Issue{currency_, src_});
if (srcOwed.signum() > 0)
return {srcOwed, DebtDirection::Redeems};
{
auto flow = srcOwed;
if (headroom)
flow = std::min(flow, toAmount<IOUAmount>(*headroom));
return {flow, DebtDirection::Redeems};
}
// srcOwed is negative or zero
return {creditLimit2(sb, dst_, src_, currency_) + srcOwed, DebtDirection::Issues};
auto flow = creditLimit2(sb, dst_, src_, currency_) + srcOwed;
if (headroom)
flow = std::min(flow, toAmount<IOUAmount>(*headroom));
return {flow, DebtDirection::Issues};
}
template <class TDerived>
@@ -543,7 +559,8 @@ DirectStepI<TDerived>::revImp(
dst_,
toSTAmount(srcToDst, srcToDstIss),
/*checkIssuer*/ true,
j_);
j_,
EnforceSupplyCap::No);
JLOG(j_.trace()) << "DirectStepI::rev: Non-limiting"
<< " srcRedeems: " << redeems(srcDebtDir) << " in: " << to_string(in)
<< " srcToDst: " << to_string(srcToDst) << " out: " << to_string(out);
@@ -560,7 +577,8 @@ DirectStepI<TDerived>::revImp(
dst_,
toSTAmount(maxSrcToDst, srcToDstIss),
/*checkIssuer*/ true,
j_);
j_,
EnforceSupplyCap::No);
JLOG(j_.trace()) << "DirectStepI::rev: Limiting"
<< " srcRedeems: " << redeems(srcDebtDir) << " in: " << to_string(in)
<< " srcToDst: " << to_string(maxSrcToDst) << " out: " << to_string(out);
@@ -656,7 +674,8 @@ DirectStepI<TDerived>::fwdImp(
dst_,
toSTAmount(cache_->srcToDst, srcToDstIss),
/*checkIssuer*/ true,
j_);
j_,
EnforceSupplyCap::No);
JLOG(j_.trace()) << "DirectStepI::fwd: Non-limiting"
<< " srcRedeems: " << redeems(srcDebtDir) << " in: " << to_string(in)
<< " srcToDst: " << to_string(srcToDst) << " out: " << to_string(out);
@@ -673,7 +692,8 @@ DirectStepI<TDerived>::fwdImp(
dst_,
toSTAmount(cache_->srcToDst, srcToDstIss),
/*checkIssuer*/ true,
j_);
j_,
EnforceSupplyCap::No);
JLOG(j_.trace()) << "DirectStepI::rev: Limiting"
<< " srcRedeems: " << redeems(srcDebtDir) << " in: " << to_string(actualIn)
<< " srcToDst: " << to_string(srcToDst) << " out: " << to_string(out);
@@ -763,7 +783,7 @@ DirectStepI<TDerived>::qualitiesSrcIssues(ReadView const& sb, DebtDirection prev
"issue");
std::uint32_t const srcQOut =
redeems(prevStepDebtDirection) ? transferRate(sb, src_).value : QUALITY_ONE;
redeems(prevStepDebtDirection) ? transferRate(sb, src_, currency_).value : QUALITY_ONE;
auto dstQIn = static_cast<TDerived const*>(this)->quality(sb, QualityDirection::In);
if (isLast_ && dstQIn > QUALITY_ONE)

View File

@@ -36,6 +36,11 @@ MPTokenIssuanceDestroy::preclaim(PreclaimContext const& ctx)
if ((*sleMPT)[~sfLockedAmount].value_or(0) != 0)
return tecHAS_OBLIGATIONS; // LCOV_EXCL_LINE
// An issuance bound to a TokenIssuance must outlive the binding;
// destroy the TokenIssuance first.
if (sleMPT->isFieldPresent(sfTokenIssuanceID))
return tecHAS_OBLIGATIONS;
return tesSUCCESS;
}

View File

@@ -0,0 +1,222 @@
#include <xrpl/tx/transactors/token/TokenConvert.h>
#include <xrpl/basics/Number.h>
#include <xrpl/ledger/helpers/MPTokenHelpers.h>
#include <xrpl/ledger/helpers/RippleStateHelpers.h>
#include <xrpl/ledger/helpers/TokenHelpers.h>
#include <xrpl/ledger/helpers/TokenIssuanceHelpers.h>
#include <xrpl/protocol/Feature.h>
#include <xrpl/protocol/Indexes.h>
#include <xrpl/protocol/LedgerFormats.h>
#include <xrpl/protocol/SField.h>
#include <xrpl/protocol/STCurrency.h>
#include <xrpl/protocol/STLedgerEntry.h>
#include <xrpl/protocol/STNumber.h>
#include <xrpl/protocol/STTx.h>
#include <xrpl/protocol/TER.h>
#include <cstdint>
#include <expected>
namespace xrpl {
namespace {
struct ConvertInfo
{
bool iouToMpt = false;
Issue issue;
MPTIssue mptIssue;
std::int64_t units = 0; // MPT base units minted or burned
STAmount iouAmount; // exact IOU amount debited or credited
};
std::expected<ConvertInfo, TER>
checkConvert(ReadView const& view, STTx const& tx, beast::Journal j)
{
AccountID const account = tx[sfAccount];
STAmount const amount{tx[sfAmount]};
SLE::const_pointer sleIssuance;
ConvertInfo info;
info.iouToMpt = amount.holds<Issue>();
if (info.iouToMpt)
{
info.issue = amount.get<Issue>();
sleIssuance = view.read(keylet::tokenIssuance(info.issue.account, info.issue.currency));
if (!sleIssuance)
return std::unexpected(tecOBJECT_NOT_FOUND);
auto const mptId = sleIssuance->at(~sfMPTokenIssuanceID);
if (!mptId)
return std::unexpected(tecNO_PERMISSION);
info.mptIssue = MPTIssue{*mptId};
}
else
{
info.mptIssue = amount.get<MPTIssue>();
auto const sleMpt = view.read(keylet::mptokenIssuance(info.mptIssue.getMptID()));
if (!sleMpt)
return std::unexpected(tecOBJECT_NOT_FOUND);
auto const issuanceKey = sleMpt->at(~sfTokenIssuanceID);
if (!issuanceKey)
return std::unexpected(tecNO_PERMISSION);
sleIssuance = view.read(keylet::tokenIssuance(*issuanceKey));
if (!sleIssuance)
return std::unexpected(tecINTERNAL); // LCOV_EXCL_LINE
info.issue = Issue{sleIssuance->at(sfCurrency), sleIssuance->at(sfIssuer)};
}
if (sleIssuance->isFlag(lsfTokenLocked))
return std::unexpected(tecLOCKED);
std::uint8_t const scale = sleIssuance->at(sfTokenScale);
// Both representations are touched in both directions, so neither side
// may be frozen or locked.
if (isFrozen(view, account, info.issue) || isDeepFrozen(view, account, info.issue))
return std::unexpected(tecFROZEN);
if (isFrozen(view, account, info.mptIssue))
return std::unexpected(tecLOCKED);
// The holder needs an existing, authorized MPToken; conversion never
// creates one (that would silently take a reserve).
if (!view.exists(keylet::mptoken(info.mptIssue.getMptID(), account)))
return std::unexpected(tecNO_ENTRY);
if (auto const ter = requireAuth(view, info.mptIssue, account, AuthType::StrongAuth);
!isTesSuccess(ter))
return std::unexpected(ter);
if (info.iouToMpt)
{
// Over-range (an uncapped binding with a high scale) or sub-unit
// amounts cannot be represented in base units.
auto const units = tokenBaseUnits(amount, scale);
if (!units || *units == 0)
return std::unexpected(tecPRECISION_LOSS);
info.units = *units;
info.iouAmount = STAmount{info.issue, Number(info.units, -scale)};
auto const spendable =
accountHolds(view, account, info.issue, FreezeHandling::ZeroIfFrozen, j);
if (spendable < info.iouAmount)
return std::unexpected(tecINSUFFICIENT_FUNDS);
}
else
{
info.units = amount.mpt().value();
info.iouAmount = STAmount{info.issue, Number(info.units, -scale)};
auto const spendable = accountHolds(
view,
account,
info.mptIssue,
FreezeHandling::ZeroIfFrozen,
AuthHandling::ZeroIfUnauthorized,
j);
if (spendable < amount)
return std::unexpected(tecINSUFFICIENT_FUNDS);
// The trust line must already exist with limit headroom; conversion
// never creates one and never pushes a holder past their limit.
auto const lineKey = keylet::trustLine(account, info.issue.account, info.issue.currency);
auto const sleLine = view.read(lineKey);
if (!sleLine)
return std::unexpected(tecNO_LINE);
bool const accountLow = account < info.issue.account;
STAmount const limit = sleLine->getFieldAmount(accountLow ? sfLowLimit : sfHighLimit);
STAmount balance = sleLine->getFieldAmount(sfBalance);
if (!accountLow)
balance.negate();
balance += info.iouAmount;
if (limit < balance)
return std::unexpected(tecLIMIT_EXCEEDED);
}
return info;
}
} // namespace
bool
TokenConvert::checkExtraFeatures(PreflightContext const& ctx)
{
return ctx.rules.enabled(featureMPTokensV1);
}
NotTEC
TokenConvert::preflight(PreflightContext const& ctx)
{
STAmount const amount{ctx.tx[sfAmount]};
if (amount.native())
return temBAD_AMOUNT;
if (amount <= beast::kZero)
return temBAD_AMOUNT;
// The issuer cannot hold its own token in either representation.
if (amount.getIssuer() == ctx.tx[sfAccount])
return temMALFORMED;
return tesSUCCESS;
}
TER
TokenConvert::preclaim(PreclaimContext const& ctx)
{
auto const info = checkConvert(ctx.view, ctx.tx, ctx.j);
if (!info)
return info.error();
return tesSUCCESS;
}
TER
TokenConvert::doApply()
{
auto const infoExp = checkConvert(view(), ctx_.tx, j_);
if (!infoExp)
return infoExp.error();
auto const& info = *infoExp;
AccountID const& issuer = info.issue.account;
STAmount const mptAmount{info.mptIssue, info.units};
if (info.iouToMpt)
{
// Redeem the IOU (exactly units * 10^-scale; sub-unit dust stays on
// the trust line), then mint the bound MPT. Sum-neutral.
if (auto const ter = directSendNoFee(view(), accountID_, issuer, info.iouAmount, true, j_);
!isTesSuccess(ter))
return ter;
// Defensive cap check; a conversion is sum-neutral so this can only
// trip if the two sides have drifted.
auto const sleMpt = view().read(keylet::mptokenIssuance(info.mptIssue.getMptID()));
if (!sleMpt)
return tecINTERNAL; // LCOV_EXCL_LINE
if (auto const maximum = sleMpt->at(~sfMaximumAmount))
{
auto const minted = static_cast<std::uint64_t>(info.units);
if (minted > *maximum || sleMpt->at(sfOutstandingAmount) > *maximum - minted)
return tecSUPPLY_EXCEEDED; // LCOV_EXCL_LINE
}
return directSendNoFee(view(), issuer, accountID_, mptAmount, false, j_);
}
// Burn the MPT, then credit the IOU. This direction is exact.
if (auto const ter = directSendNoFee(view(), accountID_, issuer, mptAmount, false, j_);
!isTesSuccess(ter))
return ter;
return directSendNoFee(view(), issuer, accountID_, info.iouAmount, true, j_);
}
} // namespace xrpl

View File

@@ -0,0 +1,150 @@
#include <xrpl/tx/transactors/token/TokenIssuanceCreate.h>
#include <xrpl/ledger/helpers/AccountRootHelpers.h>
#include <xrpl/ledger/helpers/DirectoryHelpers.h>
#include <xrpl/ledger/helpers/SponsorHelpers.h>
#include <xrpl/ledger/helpers/TokenIssuanceHelpers.h>
#include <xrpl/protocol/Feature.h>
#include <xrpl/protocol/Indexes.h>
#include <xrpl/protocol/LedgerFormats.h>
#include <xrpl/protocol/Protocol.h>
#include <xrpl/protocol/SField.h>
#include <xrpl/protocol/STCurrency.h>
#include <xrpl/protocol/STLedgerEntry.h>
#include <xrpl/protocol/STTx.h>
#include <xrpl/protocol/TER.h>
#include <xrpl/protocol/TxFlags.h>
#include <xrpl/protocol/UintTypes.h>
#include <memory>
namespace xrpl {
bool
TokenIssuanceCreate::checkExtraFeatures(PreflightContext const& ctx)
{
// Binding to an MPT issuance requires the MPT amendment.
return !ctx.tx.isFieldPresent(sfMPTokenIssuanceID) || ctx.rules.enabled(featureMPTokensV1);
}
std::uint32_t
TokenIssuanceCreate::getFlagsMask(PreflightContext const& ctx)
{
return tfTokenIssuanceCreateMask;
}
NotTEC
TokenIssuanceCreate::preflight(PreflightContext const& ctx)
{
Currency const currency = ctx.tx[sfCurrency];
if (isXRP(currency) || currency == badCurrency())
return temBAD_CURRENCY;
if (auto const fee = ctx.tx[~sfTransferFee]; fee && *fee > kMaxTransferFee)
return temBAD_TRANSFER_FEE;
if (auto const metadata = ctx.tx[~sfMPTokenMetadata];
metadata && (metadata->empty() || metadata->length() > kMaxMpTokenMetadataLength))
return temMALFORMED;
if (auto const maximum = ctx.tx[~sfMaximumAmount];
maximum && (*maximum == 0 || *maximum > kMaxTokenIssuanceAmount))
return temMALFORMED;
if (auto const scale = ctx.tx[~sfTokenScale]; scale && *scale > kMaxTokenIssuanceScale)
return temMALFORMED;
// The base unit must be defined for anything that counts in base units.
if ((ctx.tx.isFieldPresent(sfMaximumAmount) || ctx.tx.isFieldPresent(sfMPTokenIssuanceID)) &&
!ctx.tx.isFieldPresent(sfTokenScale))
return temMALFORMED;
return tesSUCCESS;
}
TER
TokenIssuanceCreate::preclaim(PreclaimContext const& ctx)
{
AccountID const account = ctx.tx[sfAccount];
Currency const currency = ctx.tx[sfCurrency];
if (ctx.view.exists(keylet::tokenIssuance(account, currency)))
return tecDUPLICATE;
if (auto const mptId = ctx.tx[~sfMPTokenIssuanceID])
return validateTokenBinding(
ctx.view, *mptId, account, ctx.tx[~sfMaximumAmount], ctx.tx[~sfTokenScale].value_or(0));
return tesSUCCESS;
}
TER
TokenIssuanceCreate::doApply()
{
auto ctx = ctx_.getApplyViewContext();
auto const& tx = ctx_.tx;
Currency const currency = tx[sfCurrency];
auto const acct = ctx.view.peek(keylet::account(accountID_));
if (!acct)
return tecINTERNAL; // LCOV_EXCL_LINE
auto const sponsorExp = getEffectiveTxReserveSponsor(ctx, acct);
if (!sponsorExp)
return sponsorExp.error(); // LCOV_EXCL_LINE
auto const sponsorSle = *sponsorExp;
if (auto const ret =
checkReserve(ctx, acct, preFeeBalance_, sponsorSle, {.ownerCountDelta = 1}, j_);
!isTesSuccess(ret))
return ret;
auto const issuanceKeylet = keylet::tokenIssuance(accountID_, currency);
auto const ownerNode = ctx.view.dirInsert(
keylet::ownerDir(accountID_), issuanceKeylet, describeOwnerDir(accountID_));
if (!ownerNode)
return tecDIR_FULL; // LCOV_EXCL_LINE
auto issuance = std::make_shared<SLE>(issuanceKeylet);
std::uint32_t flags = 0;
if (tx.isFlag(tfTokenCannotLock))
flags |= lsfTokenCannotLock;
(*issuance)[sfFlags] = flags;
(*issuance)[sfIssuer] = accountID_;
issuance->setFieldCurrency(sfCurrency, STCurrency{sfCurrency, currency});
(*issuance)[sfOwnerNode] = *ownerNode;
if (auto const maximum = tx[~sfMaximumAmount])
(*issuance)[sfMaximumAmount] = *maximum;
if (auto const scale = tx[~sfTokenScale])
issuance->at(sfTokenScale) = *scale;
if (auto const fee = tx[~sfTransferFee])
issuance->at(sfTransferFee) = *fee;
if (auto const metadata = tx[~sfMPTokenMetadata])
(*issuance)[sfMPTokenMetadata] = *metadata;
if (auto const mptId = tx[~sfMPTokenIssuanceID])
{
(*issuance)[sfMPTokenIssuanceID] = *mptId;
auto const sleMpt = ctx.view.peek(keylet::mptokenIssuance(*mptId));
if (!sleMpt || sleMpt->isFieldPresent(sfTokenIssuanceID))
return tecINTERNAL; // LCOV_EXCL_LINE
(*sleMpt)[sfTokenIssuanceID] = issuanceKeylet.key;
ctx.view.update(sleMpt);
}
addSponsorToLedgerEntry(issuance, sponsorSle);
ctx.view.insert(issuance);
increaseOwnerCount(ctx.view, acct, sponsorSle, 1, j_);
return tesSUCCESS;
}
} // namespace xrpl

View File

@@ -0,0 +1,79 @@
#include <xrpl/tx/transactors/token/TokenIssuanceDestroy.h>
#include <xrpl/basics/Number.h>
#include <xrpl/ledger/helpers/AccountRootHelpers.h>
#include <xrpl/protocol/Indexes.h>
#include <xrpl/protocol/LedgerFormats.h>
#include <xrpl/protocol/SField.h>
#include <xrpl/protocol/STCurrency.h>
#include <xrpl/protocol/STLedgerEntry.h>
#include <xrpl/protocol/STNumber.h>
#include <xrpl/protocol/STTx.h>
#include <xrpl/protocol/TER.h>
#include <xrpl/protocol/UintTypes.h>
namespace xrpl {
NotTEC
TokenIssuanceDestroy::preflight(PreflightContext const& ctx)
{
return tesSUCCESS;
}
TER
TokenIssuanceDestroy::preclaim(PreclaimContext const& ctx)
{
AccountID const account = ctx.tx[sfAccount];
Currency const currency = ctx.tx[sfCurrency];
auto const sle = ctx.view.read(keylet::tokenIssuance(account, currency));
if (!sle)
return tecNO_ENTRY;
if (sle->isFlag(lsfTokenWrapped))
return tecNO_PERMISSION;
if (Number const issued = sle->at(sfIssuedAmount); issued != Number{})
return tecHAS_OBLIGATIONS;
if (auto const mptId = sle->at(~sfMPTokenIssuanceID))
{
// A missing bound issuance leaves nothing outstanding on that side.
if (auto const sleMpt = ctx.view.read(keylet::mptokenIssuance(*mptId)); sleMpt &&
(sleMpt->at(sfOutstandingAmount) != 0 || sleMpt->at(~sfLockedAmount).value_or(0) != 0))
return tecHAS_OBLIGATIONS;
}
return tesSUCCESS;
}
TER
TokenIssuanceDestroy::doApply()
{
Currency const currency = ctx_.tx[sfCurrency];
auto const sle = view().peek(keylet::tokenIssuance(accountID_, currency));
if (!sle)
return tecINTERNAL; // LCOV_EXCL_LINE
// Unbind the MPT issuance; with both sides at zero this is harmless and
// lets the MPT issuance outlive the TokenIssuance.
if (auto const mptId = sle->at(~sfMPTokenIssuanceID))
{
if (auto const sleMpt = view().peek(keylet::mptokenIssuance(*mptId)))
{
sleMpt->makeFieldAbsent(sfTokenIssuanceID);
view().update(sleMpt);
}
}
if (!view().dirRemove(keylet::ownerDir(accountID_), (*sle)[sfOwnerNode], sle->key(), false))
return tefBAD_LEDGER; // LCOV_EXCL_LINE
decreaseOwnerCountForObject(view(), accountID_, sle, 1, j_);
view().erase(sle);
return tesSUCCESS;
}
} // namespace xrpl

View File

@@ -0,0 +1,125 @@
#include <xrpl/tx/transactors/token/TokenIssuanceSet.h>
#include <xrpl/ledger/helpers/TokenIssuanceHelpers.h>
#include <xrpl/protocol/Feature.h>
#include <xrpl/protocol/Indexes.h>
#include <xrpl/protocol/LedgerFormats.h>
#include <xrpl/protocol/Protocol.h>
#include <xrpl/protocol/SField.h>
#include <xrpl/protocol/STCurrency.h>
#include <xrpl/protocol/STLedgerEntry.h>
#include <xrpl/protocol/STTx.h>
#include <xrpl/protocol/TER.h>
#include <xrpl/protocol/TxFlags.h>
#include <xrpl/protocol/UintTypes.h>
namespace xrpl {
bool
TokenIssuanceSet::checkExtraFeatures(PreflightContext const& ctx)
{
return !ctx.tx.isFieldPresent(sfMPTokenIssuanceID) || ctx.rules.enabled(featureMPTokensV1);
}
std::uint32_t
TokenIssuanceSet::getFlagsMask(PreflightContext const& ctx)
{
return tfTokenIssuanceSetMask;
}
NotTEC
TokenIssuanceSet::preflight(PreflightContext const& ctx)
{
auto const flags = ctx.tx.getFlags();
if ((flags & tfTokenLock) && (flags & tfTokenUnlock))
return temINVALID_FLAG;
// Renouncing the lock while requesting it is contradictory.
if ((flags & tfTokenCannotLock) && (flags & tfTokenLock))
return temINVALID_FLAG;
if (auto const fee = ctx.tx[~sfTransferFee]; fee && *fee > kMaxTransferFee)
return temBAD_TRANSFER_FEE;
if (auto const metadata = ctx.tx[~sfMPTokenMetadata];
metadata && (metadata->empty() || metadata->length() > kMaxMpTokenMetadataLength))
return temMALFORMED;
return tesSUCCESS;
}
TER
TokenIssuanceSet::preclaim(PreclaimContext const& ctx)
{
AccountID const account = ctx.tx[sfAccount];
Currency const currency = ctx.tx[sfCurrency];
auto const sle = ctx.view.read(keylet::tokenIssuance(account, currency));
if (!sle)
return tecNO_ENTRY;
if (sle->at(sfIssuer) != account)
return tecINTERNAL; // LCOV_EXCL_LINE
// Nobody speaks for a dead issuer: wrapped issuances are not configurable.
if (sle->isFlag(lsfTokenWrapped))
return tecNO_PERMISSION;
if (ctx.tx.isFlag(tfTokenLock) && sle->isFlag(lsfTokenCannotLock))
return tecNO_PERMISSION;
if (auto const mptId = ctx.tx[~sfMPTokenIssuanceID])
{
// The binding is write-once.
if (sle->isFieldPresent(sfMPTokenIssuanceID))
return tecNO_PERMISSION;
return validateTokenBinding(
ctx.view, *mptId, account, sle->at(~sfMaximumAmount), sle->at(sfTokenScale));
}
return tesSUCCESS;
}
TER
TokenIssuanceSet::doApply()
{
auto const& tx = ctx_.tx;
Currency const currency = tx[sfCurrency];
auto const sle = view().peek(keylet::tokenIssuance(accountID_, currency));
if (!sle)
return tecINTERNAL; // LCOV_EXCL_LINE
std::uint32_t flags = sle->getFlags();
if (tx.isFlag(tfTokenLock))
flags |= lsfTokenLocked;
else if (tx.isFlag(tfTokenUnlock))
flags &= ~lsfTokenLocked;
if (tx.isFlag(tfTokenCannotLock))
flags |= lsfTokenCannotLock;
(*sle)[sfFlags] = flags;
if (auto const fee = tx[~sfTransferFee])
sle->at(sfTransferFee) = *fee;
if (auto const metadata = tx[~sfMPTokenMetadata])
(*sle)[sfMPTokenMetadata] = *metadata;
if (auto const mptId = tx[~sfMPTokenIssuanceID])
{
(*sle)[sfMPTokenIssuanceID] = *mptId;
auto const sleMpt = view().peek(keylet::mptokenIssuance(*mptId));
if (!sleMpt || sleMpt->isFieldPresent(sfTokenIssuanceID))
return tecINTERNAL; // LCOV_EXCL_LINE
(*sleMpt)[sfTokenIssuanceID] = sle->key();
view().update(sleMpt);
}
view().update(sle);
return tesSUCCESS;
}
} // namespace xrpl

View File

@@ -0,0 +1,826 @@
#include <test/jtx/Account.h>
#include <test/jtx/Env.h>
#include <test/jtx/TestHelpers.h>
#include <test/jtx/amount.h>
#include <test/jtx/balance.h> // IWYU pragma: keep
#include <test/jtx/envconfig.h>
#include <test/jtx/flags.h>
#include <test/jtx/mpt.h>
#include <test/jtx/offer.h>
#include <test/jtx/pay.h>
#include <test/jtx/ter.h>
#include <test/jtx/trust.h>
#include <test/jtx/txflags.h>
#include <xrpl/basics/Number.h>
#include <xrpl/beast/unit_test/suite.h>
#include <xrpl/json/json_value.h>
#include <xrpl/json/to_string.h>
#include <xrpl/ledger/helpers/TokenHelpers.h>
#include <xrpl/ledger/helpers/TokenIssuanceHelpers.h>
#include <xrpl/protocol/Feature.h>
#include <xrpl/protocol/Indexes.h>
#include <xrpl/protocol/STNumber.h>
#include <xrpl/protocol/TxFlags.h>
#include <xrpl/protocol/jss.h>
namespace xrpl {
class TokenIssuance_test : public beast::unit_test::Suite
{
FeatureBitset const all_{test::jtx::testableAmendments()};
static json::Value
tiCreate(test::jtx::Account const& account, std::string const& currency)
{
json::Value jv;
jv[jss::TransactionType] = jss::TokenIssuanceCreate;
jv[jss::Account] = account.human();
jv[sfCurrency.jsonName] = currency;
return jv;
}
static json::Value
tiSet(test::jtx::Account const& account, std::string const& currency)
{
json::Value jv;
jv[jss::TransactionType] = jss::TokenIssuanceSet;
jv[jss::Account] = account.human();
jv[sfCurrency.jsonName] = currency;
return jv;
}
static json::Value
tiDestroy(test::jtx::Account const& account, std::string const& currency)
{
json::Value jv;
jv[jss::TransactionType] = jss::TokenIssuanceDestroy;
jv[jss::Account] = account.human();
jv[sfCurrency.jsonName] = currency;
return jv;
}
static json::Value
tiConvert(test::jtx::Account const& account, STAmount const& amount)
{
json::Value jv;
jv[jss::TransactionType] = jss::TokenConvert;
jv[jss::Account] = account.human();
jv[jss::Amount] = amount.getJson(JsonOptions::Values::None);
return jv;
}
static std::shared_ptr<SLE const>
issuanceSle(test::jtx::Env& env, test::jtx::Account const& issuer, test::jtx::IOU const& iou)
{
return env.le(keylet::tokenIssuance(issuer.id(), iou.currency));
}
bool
expectIssued(
test::jtx::Env& env,
test::jtx::Account const& issuer,
test::jtx::IOU const& iou,
Number const& expected)
{
auto const sle = issuanceSle(env, issuer, iou);
if (!BEAST_EXPECT(sle))
return false;
Number const issued = (*sle)[sfIssuedAmount];
return BEAST_EXPECTS(issued == expected, to_string(issued) + " != " + to_string(expected));
}
void
testDisabled(FeatureBitset features)
{
testcase("disabled");
using namespace test::jtx;
Env env(*this, features - featureTokenIssuance);
Account const gw{"gw"};
env.fund(XRP(10'000), gw);
env.close();
env(tiCreate(gw, "USD"), Ter(temDISABLED));
env(tiSet(gw, "USD"), Ter(temDISABLED));
env(tiDestroy(gw, "USD"), Ter(temDISABLED));
env(tiConvert(gw, gw["USD"](10)), Ter(temDISABLED));
}
void
testCreatePreflight(FeatureBitset features)
{
testcase("create preflight");
using namespace test::jtx;
Env env(*this, features);
Account const gw{"gw"};
env.fund(XRP(10'000), gw);
env.close();
// XRP is not a valid currency for an issuance
env(tiCreate(gw, "XRP"), Ter(temBAD_CURRENCY));
// Bad transfer fee
{
auto jv = tiCreate(gw, "USD");
jv[sfTransferFee.jsonName] = 50'001;
env(jv, Ter(temBAD_TRANSFER_FEE));
}
// Zero maximum
{
auto jv = tiCreate(gw, "USD");
jv[sfMaximumAmount.jsonName] = "0";
jv[sfTokenScale.jsonName] = 0;
env(jv, Ter(temMALFORMED));
}
// Maximum above the representable bound
{
auto jv = tiCreate(gw, "USD");
jv[sfMaximumAmount.jsonName] = "1000000000000001";
jv[sfTokenScale.jsonName] = 0;
env(jv, Ter(temMALFORMED));
}
// Maximum without a scale
{
auto jv = tiCreate(gw, "USD");
jv[sfMaximumAmount.jsonName] = "1000";
env(jv, Ter(temMALFORMED));
}
// Scale out of range
{
auto jv = tiCreate(gw, "USD");
jv[sfMaximumAmount.jsonName] = "1000";
jv[sfTokenScale.jsonName] = 19;
env(jv, Ter(temMALFORMED));
}
// Invalid flags
env(tiCreate(gw, "USD"), Txflags(0x00000008), Ter(temINVALID_FLAG));
// Empty metadata
{
auto jv = tiCreate(gw, "USD");
jv[sfMPTokenMetadata.jsonName] = "";
env(jv, Ter(temMALFORMED));
}
}
void
testCreate(FeatureBitset features)
{
testcase("create");
using namespace test::jtx;
Env env(*this, features);
Account const gw{"gw"};
auto const USD = gw["USD"];
env.fund(XRP(10'000), gw);
env.close();
BEAST_EXPECT(env.ownerCount(gw) == 0);
// Uncapped, metadata-only issuance
env(tiCreate(gw, "USD"));
env.close();
{
auto const sle = issuanceSle(env, gw, USD);
if (!BEAST_EXPECT(sle))
return;
BEAST_EXPECT((*sle)[sfIssuer] == gw.id());
BEAST_EXPECT(!sle->isFieldPresent(sfMaximumAmount));
Number const issued = (*sle)[sfIssuedAmount];
BEAST_EXPECT(issued == Number{});
}
BEAST_EXPECT(env.ownerCount(gw) == 1);
// Duplicate fails
env(tiCreate(gw, "USD"), Ter(tecDUPLICATE));
// Capped issuance for another currency, with CannotLock renounced
{
auto jv = tiCreate(gw, "EUR");
jv[sfMaximumAmount.jsonName] = "1000";
jv[sfTokenScale.jsonName] = 0;
env(jv, Txflags(tfTokenCannotLock));
env.close();
auto const sle = issuanceSle(env, gw, gw["EUR"]);
if (!BEAST_EXPECT(sle))
return;
BEAST_EXPECT((*sle)[sfMaximumAmount] == 1000);
BEAST_EXPECT((*sle)[sfTokenScale] == 0);
BEAST_EXPECT(sle->isFlag(lsfTokenCannotLock));
}
BEAST_EXPECT(env.ownerCount(gw) == 2);
}
void
testSetAndLock(FeatureBitset features)
{
testcase("set and lock");
using namespace test::jtx;
Env env(*this, features);
Account const gw{"gw"};
Account const alice{"alice"};
Account const bob{"bob"};
auto const USD = gw["USD"];
env.fund(XRP(10'000), gw, alice, bob);
env.close();
env.trust(USD(1'000), alice, bob);
env.close();
// Set on a missing issuance
env(tiSet(gw, "USD"), Ter(tecNO_ENTRY));
env(tiCreate(gw, "USD"));
env.close();
env(pay(gw, alice, USD(100)));
env.close();
// Contradictory flags
env(tiSet(gw, "USD"), Txflags(tfTokenLock | tfTokenUnlock), Ter(temINVALID_FLAG));
env(tiSet(gw, "USD"), Txflags(tfTokenLock | tfTokenCannotLock), Ter(temINVALID_FLAG));
// Lock the currency: holder-to-holder transfers stop, redemption
// to the issuer still works (GlobalFreeze semantics, scoped).
env(tiSet(gw, "USD"), Txflags(tfTokenLock));
env.close();
{
auto const sle = issuanceSle(env, gw, USD);
if (!BEAST_EXPECT(sle))
return;
BEAST_EXPECT(sle->isFlag(lsfTokenLocked));
}
env(pay(alice, bob, USD(10)), Ter(tecPATH_DRY));
env.close();
env(pay(alice, gw, USD(10)));
env.close();
env.require(Balance(alice, USD(90)));
// Unlock restores transfers
env(tiSet(gw, "USD"), Txflags(tfTokenUnlock));
env.close();
env(pay(alice, bob, USD(10)));
env.close();
env.require(Balance(bob, USD(10)));
// Metadata and fee updates
{
auto jv = tiSet(gw, "USD");
jv[sfTransferFee.jsonName] = 100;
jv[sfMPTokenMetadata.jsonName] = strHex(std::string{"meta"});
env(jv);
env.close();
auto const sle = issuanceSle(env, gw, USD);
if (!BEAST_EXPECT(sle))
return;
BEAST_EXPECT((*sle)[sfTransferFee] == 100);
BEAST_EXPECT(sle->isFieldPresent(sfMPTokenMetadata));
}
// Renounce locking, then locking fails
env(tiSet(gw, "USD"), Txflags(tfTokenCannotLock));
env.close();
env(tiSet(gw, "USD"), Txflags(tfTokenLock), Ter(tecNO_PERMISSION));
}
void
testDestroy(FeatureBitset features)
{
testcase("destroy");
using namespace test::jtx;
Env env(*this, features);
Account const gw{"gw"};
Account const alice{"alice"};
auto const USD = gw["USD"];
env.fund(XRP(10'000), gw, alice);
env.close();
env.trust(USD(1'000), alice);
env.close();
env(tiDestroy(gw, "USD"), Ter(tecNO_ENTRY));
env(tiCreate(gw, "USD"));
env.close();
env(pay(gw, alice, USD(100)));
env.close();
if (!expectIssued(env, gw, USD, Number(100)))
return;
// Outstanding tokens block destruction. Close before the redemption
// so the failed destroy is not replayed after it in canonical order.
env(tiDestroy(gw, "USD"), Ter(tecHAS_OBLIGATIONS));
env.close();
env(pay(alice, gw, USD(100)));
env.close();
if (!expectIssued(env, gw, USD, Number(0)))
return;
env(tiDestroy(gw, "USD"));
env.close();
BEAST_EXPECT(!issuanceSle(env, gw, USD));
BEAST_EXPECT(env.ownerCount(gw) == 0);
}
void
testSupplyCap(FeatureBitset features)
{
testcase("supply cap");
using namespace test::jtx;
Env env(*this, features);
Account const gw{"gw"};
Account const alice{"alice"};
Account const bob{"bob"};
Account const carol{"carol"};
auto const USD = gw["USD"];
env.fund(XRP(10'000), gw, alice, bob, carol);
env.close();
env.trust(USD(10'000), alice, bob, carol);
env.close();
{
auto jv = tiCreate(gw, "USD");
jv[sfMaximumAmount.jsonName] = "1000";
jv[sfTokenScale.jsonName] = 0;
env(jv);
env.close();
}
// Issue up to 600
env(pay(gw, alice, USD(600)));
env.close();
if (!expectIssued(env, gw, USD, Number(600)))
return;
// 500 more would breach the cap
env(pay(gw, alice, USD(500)), Ter(tecPATH_PARTIAL));
env.close();
expectIssued(env, gw, USD, Number(600));
// Redemption frees headroom
env(pay(alice, gw, USD(100)));
env.close();
if (!expectIssued(env, gw, USD, Number(500)))
return;
// Fill the cap exactly
env(pay(gw, alice, USD(500)));
env.close();
if (!expectIssued(env, gw, USD, Number(1000)))
return;
env(pay(gw, bob, USD(1)), Ter(tecPATH_PARTIAL));
env.close();
// Holder-to-holder transfers are unaffected by the cap
env(pay(alice, bob, USD(250)));
expectIssued(env, gw, USD, Number(1000));
env.close();
env.require(Balance(bob, USD(250)));
env.require(Balance(alice, USD(750)));
if (!expectIssued(env, gw, USD, Number(1000)))
return;
// An issuer's offer within the remaining headroom crosses in full
env(pay(alice, gw, USD(400)));
env.close();
if (!expectIssued(env, gw, USD, Number(600)))
return;
env(offer(gw, XRP(300), USD(300)));
env.close();
env(offer(bob, USD(300), XRP(300)));
env.close();
env.require(Balance(bob, USD(550)));
if (!expectIssued(env, gw, USD, Number(900)))
return;
// An issuer's offer beyond the headroom can never push issuance past
// the cap: the supply invariant rejects the crossing outright.
env(offer(gw, XRP(600), USD(600)));
env.close();
env(offer(carol, USD(600), XRP(600)), Ter(tecINVARIANT_FAILED));
env.close();
env.require(Balance(carol, USD(0)));
if (!expectIssued(env, gw, USD, Number(900)))
return;
}
void
testBinding(FeatureBitset features)
{
testcase("binding");
using namespace test::jtx;
Env env(*this, features);
Account const gw{"gw"};
Account const alice{"alice"};
MPTTester mpt(env, gw, {.holders = {alice}});
mpt.create({.maxAmt = 100'000, .assetScale = 2});
auto const badId = makeMptID(env.seq(gw) + 100, gw.id());
// Binding a nonexistent MPT issuance
{
auto jv = tiCreate(gw, "USD");
jv[sfMaximumAmount.jsonName] = "100000";
jv[sfTokenScale.jsonName] = 2;
jv[sfMPTokenIssuanceID.jsonName] = to_string(badId);
env(jv, Ter(tecOBJECT_NOT_FOUND));
}
// Cap mismatch
{
auto jv = tiCreate(gw, "USD");
jv[sfMaximumAmount.jsonName] = "99999";
jv[sfTokenScale.jsonName] = 2;
jv[sfMPTokenIssuanceID.jsonName] = to_string(mpt.issuanceID());
env(jv, Ter(tecWRONG_ASSET));
}
// Scale mismatch
{
auto jv = tiCreate(gw, "USD");
jv[sfMaximumAmount.jsonName] = "100000";
jv[sfTokenScale.jsonName] = 3;
jv[sfMPTokenIssuanceID.jsonName] = to_string(mpt.issuanceID());
env(jv, Ter(tecWRONG_ASSET));
}
// Not the MPT issuer
{
auto jv = tiCreate(alice, "USD");
jv[sfMaximumAmount.jsonName] = "100000";
jv[sfTokenScale.jsonName] = 2;
jv[sfMPTokenIssuanceID.jsonName] = to_string(mpt.issuanceID());
env(jv, Ter(tecNO_PERMISSION));
}
// Valid binding
{
auto jv = tiCreate(gw, "USD");
jv[sfMaximumAmount.jsonName] = "100000";
jv[sfTokenScale.jsonName] = 2;
jv[sfMPTokenIssuanceID.jsonName] = to_string(mpt.issuanceID());
env(jv);
env.close();
}
{
auto const sle = issuanceSle(env, gw, gw["USD"]);
if (!BEAST_EXPECT(sle))
return;
BEAST_EXPECT((*sle)[~sfMPTokenIssuanceID] == mpt.issuanceID());
// The MPT issuance carries the back-pointer
auto const sleMpt = env.le(keylet::mptokenIssuance(mpt.issuanceID()));
if (!BEAST_EXPECT(sleMpt))
return;
BEAST_EXPECT((*sleMpt)[~sfTokenIssuanceID] == sle->key());
}
// A second issuance cannot bind the same MPT
{
auto jv = tiCreate(gw, "EUR");
jv[sfMaximumAmount.jsonName] = "100000";
jv[sfTokenScale.jsonName] = 2;
jv[sfMPTokenIssuanceID.jsonName] = to_string(mpt.issuanceID());
env(jv, Ter(tecDUPLICATE));
}
// The binding is write-once
{
auto jv = tiSet(gw, "USD");
jv[sfMPTokenIssuanceID.jsonName] = to_string(mpt.issuanceID());
env(jv, Ter(tecNO_PERMISSION));
}
// Destroy unbinds: back-pointer is cleared
env(tiDestroy(gw, "USD"));
env.close();
{
auto const sleMpt = env.le(keylet::mptokenIssuance(mpt.issuanceID()));
if (!BEAST_EXPECT(sleMpt))
return;
BEAST_EXPECT(!sleMpt->isFieldPresent(sfTokenIssuanceID));
}
}
void
testConvert(FeatureBitset features)
{
testcase("convert");
using namespace test::jtx;
Env env(*this, features);
Account const gw{"gw"};
Account const alice{"alice"};
Account const bob{"bob"};
Account const carol{"carol"};
auto const USD = gw["USD"];
MPTTester mpt(env, gw, {.holders = {alice, bob, carol}});
mpt.create({.maxAmt = 100'000, .assetScale = 2});
MPTIssue const mptIssue{mpt.issuanceID()};
env.trust(USD(10'000), alice, carol);
env.close();
// No TokenIssuance yet
env(tiConvert(alice, USD(10)), Ter(tecOBJECT_NOT_FOUND));
// Unbound issuance: conversion unavailable
env(tiCreate(gw, "EUR"));
env.close();
env(tiConvert(alice, gw["EUR"](10)), Ter(tecNO_PERMISSION));
{
auto jv = tiCreate(gw, "USD");
jv[sfMaximumAmount.jsonName] = "100000";
jv[sfTokenScale.jsonName] = 2;
jv[sfMPTokenIssuanceID.jsonName] = to_string(mpt.issuanceID());
env(jv);
env.close();
}
// Issue after the object exists so IssuedAmount tracks the float
env(pay(gw, alice, USD(100.505)));
env.close();
// The issuer cannot convert its own token
env(tiConvert(gw, USD(10)), Ter(temMALFORMED));
// No MPToken for the holder yet
env(tiConvert(alice, USD(10)), Ter(tecNO_ENTRY));
mpt.authorize({.account = alice});
mpt.authorize({.account = bob});
env.close();
// Sub-base-unit amount floors to zero units
env(tiConvert(alice, USD(0.001)), Ter(tecPRECISION_LOSS));
// Insufficient balance
env(tiConvert(alice, USD(200)), Ter(tecINSUFFICIENT_FUNDS));
// IOU -> MPT: debit exactly 10.50, mint 1050 units, dust stays
env(tiConvert(alice, USD(10.505)));
env.close();
env.require(Balance(alice, USD(90.005)));
BEAST_EXPECT(mpt.checkMPTokenAmount(alice, 1'050));
BEAST_EXPECT(mpt.checkMPTokenOutstandingAmount(1'050));
if (!expectIssued(env, gw, USD, Number(90'005, -3)))
return;
// MPT -> IOU: exact
env(tiConvert(alice, STAmount{mptIssue, 1'000}));
env.close();
env.require(Balance(alice, USD(100.005)));
BEAST_EXPECT(mpt.checkMPTokenAmount(alice, 50));
BEAST_EXPECT(mpt.checkMPTokenOutstandingAmount(50));
if (!expectIssued(env, gw, USD, Number(100'005, -3)))
return;
// More MPT than held
env(tiConvert(alice, STAmount{mptIssue, 51}), Ter(tecINSUFFICIENT_FUNDS));
// bob holds MPT (paid directly) but has no trust line
mpt.pay(gw, bob, 500);
env.close();
env(tiConvert(bob, STAmount{mptIssue, 500}), Ter(tecNO_LINE));
// A too-low limit blocks the credit
env.trust(USD(1), bob);
env.close();
env(tiConvert(bob, STAmount{mptIssue, 500}), Ter(tecLIMIT_EXCEEDED));
env.trust(USD(1'000), bob);
env.close();
env(tiConvert(bob, STAmount{mptIssue, 500}));
env.close();
env.require(Balance(bob, USD(5)));
// carol has a line and IOU but no MPToken
env(pay(gw, carol, USD(10)));
env.close();
env(tiConvert(carol, USD(10)), Ter(tecNO_ENTRY));
// Locking the currency blocks conversion. Close between the failed
// convert and the unlock so canonical-order replay cannot flip it.
env(tiSet(gw, "USD"), Txflags(tfTokenLock));
env.close();
env(tiConvert(alice, USD(10)), Ter(tecLOCKED));
env.close();
env(tiSet(gw, "USD"), Txflags(tfTokenUnlock));
env.close();
// A frozen trust line blocks conversion
env(trust(gw, USD(0), alice, tfSetFreeze));
env.close();
env(tiConvert(alice, USD(10)), Ter(tecFROZEN));
env.close();
env(trust(gw, USD(0), alice, tfClearFreeze));
env.close();
// Conversions are sum-neutral: they work even at a full cap.
// Fill the cap: issued 105.01 (alice) + 0.05 (alice MPT->wait)
// Just verify a round trip leaves the counters unchanged.
auto const sleBefore = issuanceSle(env, gw, USD);
if (!BEAST_EXPECT(sleBefore))
return;
Number const issuedBefore = (*sleBefore)[sfIssuedAmount];
env(tiConvert(alice, USD(50)));
env.close();
BEAST_EXPECT(mpt.checkMPTokenAmount(alice, 5'050));
env(tiConvert(alice, STAmount{mptIssue, 5'000}));
env.close();
BEAST_EXPECT(mpt.checkMPTokenAmount(alice, 50));
auto const sleAfter = issuanceSle(env, gw, USD);
if (!BEAST_EXPECT(sleAfter))
return;
Number const issuedAfter = (*sleAfter)[sfIssuedAmount];
BEAST_EXPECT(issuedBefore == issuedAfter);
BEAST_EXPECT(mpt.checkMPTokenAmount(alice, 50));
}
void
testSharedCurrencyCode(FeatureBitset features)
{
testcase("shared currency code");
using namespace test::jtx;
// Two issuers of the same currency code: moving one issuer's token
// must never touch the other issuer's counter.
Env env(*this, features);
Account const gwA{"gwA"};
Account const gwB{"gwB"};
Account const alice{"alice"};
auto const aUSD = gwA["USD"];
auto const bUSD = gwB["USD"];
env.fund(XRP(10'000), gwA, gwB, alice);
env.close();
env.trust(aUSD(10'000), gwB, alice);
env.trust(bUSD(10'000), alice);
env.close();
env(tiCreate(gwA, "USD"));
{
auto jv = tiCreate(gwB, "USD");
jv[sfMaximumAmount.jsonName] = "1000";
jv[sfTokenScale.jsonName] = 0;
env(jv);
}
env.close();
// gwB receiving gwA's USD affects only gwA's counter
env(pay(gwA, gwB, aUSD(100)));
env.close();
if (!expectIssued(env, gwA, aUSD, Number(100)))
return;
if (!expectIssued(env, gwB, bUSD, Number(0)))
return;
// gwB issuing its own USD affects only gwB's counter
env(pay(gwB, alice, bUSD(500)));
env.close();
if (!expectIssued(env, gwA, aUSD, Number(100)))
return;
if (!expectIssued(env, gwB, bUSD, Number(500)))
return;
// gwB's cap is intact: 501 more would breach it
env(pay(gwB, alice, bUSD(501)), Ter(tecPATH_PARTIAL));
env.close();
env(pay(gwB, alice, bUSD(500)));
env.close();
if (!expectIssued(env, gwB, bUSD, Number(1000)))
return;
}
void
testBoundMptDestroy(FeatureBitset features)
{
testcase("bound MPT destroy");
using namespace test::jtx;
Env env(*this, features);
Account const gw{"gw"};
MPTTester mpt(env, gw, MPTInit{});
mpt.create({.maxAmt = 100'000, .assetScale = 2});
{
auto jv = tiCreate(gw, "USD");
jv[sfMaximumAmount.jsonName] = "100000";
jv[sfTokenScale.jsonName] = 2;
jv[sfMPTokenIssuanceID.jsonName] = to_string(mpt.issuanceID());
env(jv);
env.close();
}
// A bound MPT issuance cannot be destroyed out from under the
// TokenIssuance.
mpt.destroy({.err = tecHAS_OBLIGATIONS});
env.close();
// Destroying the TokenIssuance unbinds; then the MPT can go.
env(tiDestroy(gw, "USD"));
env.close();
mpt.destroy({});
}
void
testWithoutMptAmendment(FeatureBitset features)
{
testcase("without MPT amendment");
using namespace test::jtx;
Env env(*this, features - featureMPTokensV1);
Account const gw{"gw"};
auto const USD = gw["USD"];
env.fund(XRP(10'000), gw);
env.close();
// Plain issuances work without the MPT amendment
{
auto jv = tiCreate(gw, "USD");
jv[sfMaximumAmount.jsonName] = "1000";
jv[sfTokenScale.jsonName] = 0;
env(jv);
env.close();
}
BEAST_EXPECT(issuanceSle(env, gw, USD) != nullptr);
// Binding and conversion require it
{
auto jv = tiCreate(gw, "EUR");
jv[sfMaximumAmount.jsonName] = "1000";
jv[sfTokenScale.jsonName] = 0;
jv[sfMPTokenIssuanceID.jsonName] = to_string(makeMptID(env.seq(gw), gw.id()));
env(jv, Ter(temDISABLED));
}
env(tiConvert(gw, USD(10)), Ter(temDISABLED));
}
void
testLedgerEntryRPC(FeatureBitset features)
{
testcase("ledger_entry");
using namespace test::jtx;
Env env(*this, features);
Account const gw{"gw"};
env.fund(XRP(10'000), gw);
env.close();
env(tiCreate(gw, "USD"));
env.close();
json::Value params;
params[jss::token_issuance][jss::issuer] = gw.human();
params[jss::token_issuance][jss::currency] = "USD";
auto const jrr = env.rpc("json", "ledger_entry", to_string(params))[jss::result];
BEAST_EXPECTS(jrr[jss::node][sfIssuer.jsonName] == gw.human(), to_string(jrr));
}
public:
void
run() override
{
testDisabled(all_);
testCreatePreflight(all_);
testCreate(all_);
testSetAndLock(all_);
testDestroy(all_);
testSupplyCap(all_);
testBinding(all_);
testConvert(all_);
testSharedCurrencyCode(all_);
testBoundMptDestroy(all_);
testWithoutMptAmendment(all_);
testLedgerEntryRPC(all_);
}
};
BEAST_DEFINE_TESTSUITE(TokenIssuance, app, xrpl);
} // namespace xrpl

View File

@@ -5199,7 +5199,7 @@ NetworkOPsImp::getBookPage(
MetaView lesActive(lpLedger, tapNONE, true);
OrderBookIterator obIterator(lesActive, book);
auto const rate = transferRate(lesActive, book.out.account);
auto const rate = transferRate(lesActive, Issue{book.out.currency, book.out.account});
bool const bGlobalFreeze =
lesActive.isGlobalFrozen(book.out.account) || lesActive.isGlobalFrozen(book.in.account);

View File

@@ -314,6 +314,7 @@ doAccountObjects(rpc::JsonContext& context)
{.name = jss::permissioned_domain, .type = ltPERMISSIONED_DOMAIN},
{.name = jss::vault, .type = ltVAULT},
{.name = jss::sponsorship, .type = ltSPONSORSHIP},
{.name = jss::token_issuance, .type = ltTOKEN_ISSUANCE},
};
typeFilter.emplace();

View File

@@ -564,6 +564,41 @@ parseMPTokenIssuance(
return keylet::mptokenIssuance(*mptIssuanceID).key;
}
static std::expected<uint256, json::Value>
parseTokenIssuance(
json::Value const& params,
json::StaticString const fieldName,
[[maybe_unused]] unsigned const apiVersion)
{
if (!params.isObject())
{
return parseObjectID(params, fieldName);
}
if (auto const value = ledger_entry_helpers::hasRequired(params, {jss::issuer, jss::currency});
!value)
{
return std::unexpected(value.error());
}
auto const issuer = ledger_entry_helpers::parse<AccountID>(params[jss::issuer]);
if (!issuer)
{
return ledger_entry_helpers::invalidFieldError(
"malformedAddress", jss::issuer, "AccountID");
}
Currency currency;
if (!params[jss::currency].isString() || params[jss::currency] == "" ||
!toCurrency(currency, params[jss::currency].asString()))
{
return ledger_entry_helpers::invalidFieldError(
"malformedCurrency", jss::currency, "Currency");
}
return keylet::tokenIssuance(*issuer, currency).key;
}
static std::expected<uint256, json::Value>
parseNFTokenOffer(
json::Value const& params,