diff --git a/.cspell.config.yaml b/.cspell.config.yaml index c1af739255e..b8af0e35dfa 100644 --- a/.cspell.config.yaml +++ b/.cspell.config.yaml @@ -43,6 +43,7 @@ suggestWords: - synched->synced - synch->sync words: + - cusip - abempty - AMMID - AMMMPT diff --git a/include/xrpl/protocol/Protocol.h b/include/xrpl/protocol/Protocol.h index 1b88eea4563..e0a00b01fdd 100644 --- a/include/xrpl/protocol/Protocol.h +++ b/include/xrpl/protocol/Protocol.h @@ -302,6 +302,11 @@ static_assert(Number::kMaxRep >= kMaxMpTokenAmount); */ constexpr std::size_t kMaxDataPayloadLength = 256; +/** + * The maximum length of a structured-data Schema + */ +constexpr std::size_t kMaxSchemaLength = 256; + /** * Vault withdrawal policies */ diff --git a/include/xrpl/protocol/StructuredData.h b/include/xrpl/protocol/StructuredData.h new file mode 100644 index 00000000000..e5d7c118e06 --- /dev/null +++ b/include/xrpl/protocol/StructuredData.h @@ -0,0 +1,78 @@ +#pragma once + +#include + +#include +#include + +namespace xrpl { + +/** + * Structural validation for MPTStructuredData schemas and data. + * + * A schema is a sequence of one-byte type codes describing a packed + * record layout. Data is the corresponding values packed back-to-back + * in schema order: big-endian integers, VL-prefixed str/bin (standard + * XRPL variable-length encoding, byte counts), arrays as a one-byte + * element count followed by that many elements, and tuples with no + * framing of their own (the markers exist only in the schema). + * + * These functions validate structure only. No values are materialized + * and no semantic checks are performed beyond the bool 0x00/0x01 rule. + */ + +/** + * Schema type codes. Codes not listed here are reserved and malformed. + */ +enum class SchemaType : std::uint8_t { + boolean = 0x01, // 1 byte, 0x00 or 0x01 + u8 = 0x02, // 1 byte + u16 = 0x03, // 2 bytes, big-endian + u32 = 0x04, // 4 bytes, big-endian + u64 = 0x05, // 8 bytes, big-endian + u128 = 0x06, // 16 bytes + u256 = 0x07, // 32 bytes + xfl = 0x08, // 8 bytes, XLS-17 floating point + account = 0x09, // 20 bytes, AccountID + currency = 0x0A, // 20 bytes, 160-bit currency code + h160 = 0x0B, // 20 bytes + h256 = 0x0C, // 32 bytes + pubkey = 0x0D, // 33 bytes, compressed public key + str = 0x0E, // VL prefix + UTF-8 bytes + bin = 0x0F, // VL prefix + opaque bytes + array = 0x20, // followed by one element type; data: count byte + elements + tupleOpen = 0x30, + tupleClose = 0x31, +}; + +/** + * Maximum array/tuple nesting depth a schema may declare. + */ +constexpr std::size_t kMaxSchemaDepth = 8; + +/** + * Check that a schema is well-formed. + * + * Well-formed means: non-empty, no longer than kMaxSchemaLength, every + * code is a known SchemaType, every array code is followed by an + * element type, every tupleOpen has a matching tupleClose, and nesting + * does not exceed kMaxSchemaDepth. + */ +[[nodiscard]] bool +isWellFormedSchema(Slice schema); + +/** + * Check that data decodes exactly against a well-formed schema. + * + * Walks both inputs with two cursors. Every field must be present at + * its declared width (VL-prefixed for str/bin), array counts must be + * consistent with the remaining input, and both cursors must end + * exactly exhausted: truncated data and trailing bytes both fail. + * + * The schema is re-validated during the walk, so a malformed schema + * returns false rather than misreading data. + */ +[[nodiscard]] bool +dataMatchesSchema(Slice schema, Slice data); + +} // namespace xrpl diff --git a/include/xrpl/protocol/detail/features.macro b/include/xrpl/protocol/detail/features.macro index e63a7f515dc..fa975dd1056 100644 --- a/include/xrpl/protocol/detail/features.macro +++ b/include/xrpl/protocol/detail/features.macro @@ -15,6 +15,7 @@ // Add new amendments to the top of this list. // Keep it sorted in reverse chronological order. +XRPL_FEATURE(MPTStructuredData, Supported::Yes, VoteBehavior::DefaultNo) XRPL_FEATURE(SmartEscrow, Supported::No, VoteBehavior::DefaultNo) XRPL_FEATURE(LendingProtocolV1_2, Supported::No, VoteBehavior::DefaultNo) XRPL_FIX (Cleanup3_5_0, Supported::Yes, VoteBehavior::DefaultNo) diff --git a/include/xrpl/protocol/detail/ledger_entries.macro b/include/xrpl/protocol/detail/ledger_entries.macro index 18c71b572cd..9c30164ab50 100644 --- a/include/xrpl/protocol/detail/ledger_entries.macro +++ b/include/xrpl/protocol/detail/ledger_entries.macro @@ -408,6 +408,7 @@ LEDGER_ENTRY(ltMPTOKEN_ISSUANCE, 0x007e, MPTokenIssuance, mpt_issuance, ({ {sfOutstandingAmount, SoeRequired}, {sfLockedAmount, SoeOptional}, {sfMPTokenMetadata, SoeOptional}, + {sfMPTokenSchema, SoeOptional}, {sfPreviousTxnID, SoeRequired}, {sfPreviousTxnLgrSeq, SoeRequired}, {sfDomainID, SoeOptional}, diff --git a/include/xrpl/protocol/detail/sfields.macro b/include/xrpl/protocol/detail/sfields.macro index 2cf35743aea..52af3926d9e 100644 --- a/include/xrpl/protocol/detail/sfields.macro +++ b/include/xrpl/protocol/detail/sfields.macro @@ -329,6 +329,7 @@ TYPED_SFIELD(sfAuditorEncryptionKey, VL, 44) TYPED_SFIELD(sfAmountCommitment, VL, 45) TYPED_SFIELD(sfBalanceCommitment, VL, 46) TYPED_SFIELD(sfBytecode, VL, 47) +TYPED_SFIELD(sfMPTokenSchema, VL, 48) // account (common) TYPED_SFIELD(sfAccount, ACCOUNT, 1) diff --git a/include/xrpl/protocol/detail/transactions.macro b/include/xrpl/protocol/detail/transactions.macro index 454aa85ffd0..9500917b507 100644 --- a/include/xrpl/protocol/detail/transactions.macro +++ b/include/xrpl/protocol/detail/transactions.macro @@ -637,6 +637,7 @@ TRANSACTION(ttMPTOKEN_ISSUANCE_CREATE, 54, MPTokenIssuanceCreate, {sfMPTokenMetadata, SoeOptional}, {sfDomainID, SoeOptional}, {sfImmutableFlags, SoeOptional}, + {sfMPTokenSchema, SoeOptional}, })) /** This transaction type destroys a MPTokensIssuance instance */ @@ -664,6 +665,7 @@ TRANSACTION(ttMPTOKEN_ISSUANCE_SET, 56, MPTokenIssuanceSet, {sfHolder, SoeOptional}, {sfDomainID, SoeOptional}, {sfMPTokenMetadata, SoeOptional}, + {sfMPTokenSchema, SoeOptional}, {sfTransferFee, SoeOptional}, {sfImmutableFlags, SoeOptional}, {sfIssuerEncryptionKey, SoeOptional}, diff --git a/include/xrpl/protocol_autogen/ledger_entries/MPTokenIssuance.h b/include/xrpl/protocol_autogen/ledger_entries/MPTokenIssuance.h index 74b5e3c4eb2..0b5d4d836c2 100644 --- a/include/xrpl/protocol_autogen/ledger_entries/MPTokenIssuance.h +++ b/include/xrpl/protocol_autogen/ledger_entries/MPTokenIssuance.h @@ -209,6 +209,30 @@ class MPTokenIssuance : public LedgerEntryBase return this->sle_->isFieldPresent(sfMPTokenMetadata); } + /** + * @brief Get sfMPTokenSchema (SoeOptional) + * @return The field value, or std::nullopt if not present. + */ + [[nodiscard]] + protocol_autogen::Optional + getMPTokenSchema() const + { + if (hasMPTokenSchema()) + return this->sle_->at(sfMPTokenSchema); + return std::nullopt; + } + + /** + * @brief Check if sfMPTokenSchema is present. + * @return True if the field is present, false otherwise. + */ + [[nodiscard]] + bool + hasMPTokenSchema() const + { + return this->sle_->isFieldPresent(sfMPTokenSchema); + } + /** * @brief Get sfPreviousTxnID (SoeRequired) * @return The field value. @@ -571,6 +595,17 @@ class MPTokenIssuanceBuilder : public LedgerEntryBuilderBase const& value) + { + object_[sfMPTokenSchema] = value; + return *this; + } + /** * @brief Set sfPreviousTxnID (SoeRequired) * @return Reference to this builder for method chaining. diff --git a/include/xrpl/protocol_autogen/transactions/MPTokenIssuanceCreate.h b/include/xrpl/protocol_autogen/transactions/MPTokenIssuanceCreate.h index b83de9d8437..e606aae7d7b 100644 --- a/include/xrpl/protocol_autogen/transactions/MPTokenIssuanceCreate.h +++ b/include/xrpl/protocol_autogen/transactions/MPTokenIssuanceCreate.h @@ -202,6 +202,32 @@ class MPTokenIssuanceCreate : public TransactionBase { return this->tx_->isFieldPresent(sfImmutableFlags); } + + /** + * @brief Get sfMPTokenSchema (SoeOptional) + * @return The field value, or std::nullopt if not present. + */ + [[nodiscard]] + protocol_autogen::Optional + getMPTokenSchema() const + { + if (hasMPTokenSchema()) + { + return this->tx_->at(sfMPTokenSchema); + } + return std::nullopt; + } + + /** + * @brief Check if sfMPTokenSchema is present. + * @return True if the field is present, false otherwise. + */ + [[nodiscard]] + bool + hasMPTokenSchema() const + { + return this->tx_->isFieldPresent(sfMPTokenSchema); + } }; /** @@ -312,6 +338,17 @@ class MPTokenIssuanceCreateBuilder : public TransactionBuilderBase const& value) + { + object_[sfMPTokenSchema] = value; + return *this; + } + /** * @brief Build and return the MPTokenIssuanceCreate wrapper. * @param publicKey The public key for signing. diff --git a/include/xrpl/protocol_autogen/transactions/MPTokenIssuanceSet.h b/include/xrpl/protocol_autogen/transactions/MPTokenIssuanceSet.h index 43def05194b..3281e19b221 100644 --- a/include/xrpl/protocol_autogen/transactions/MPTokenIssuanceSet.h +++ b/include/xrpl/protocol_autogen/transactions/MPTokenIssuanceSet.h @@ -136,6 +136,32 @@ class MPTokenIssuanceSet : public TransactionBase return this->tx_->isFieldPresent(sfMPTokenMetadata); } + /** + * @brief Get sfMPTokenSchema (SoeOptional) + * @return The field value, or std::nullopt if not present. + */ + [[nodiscard]] + protocol_autogen::Optional + getMPTokenSchema() const + { + if (hasMPTokenSchema()) + { + return this->tx_->at(sfMPTokenSchema); + } + return std::nullopt; + } + + /** + * @brief Check if sfMPTokenSchema is present. + * @return True if the field is present, false otherwise. + */ + [[nodiscard]] + bool + hasMPTokenSchema() const + { + return this->tx_->isFieldPresent(sfMPTokenSchema); + } + /** * @brief Get sfTransferFee (SoeOptional) * @return The field value, or std::nullopt if not present. @@ -329,6 +355,17 @@ class MPTokenIssuanceSetBuilder : public TransactionBuilderBase const& value) + { + object_[sfMPTokenSchema] = value; + return *this; + } + /** * @brief Set sfTransferFee (SoeOptional) * @return Reference to this builder for method chaining. diff --git a/include/xrpl/tx/invariants/MPTInvariant.h b/include/xrpl/tx/invariants/MPTInvariant.h index ddda348d2ee..237def027cc 100644 --- a/include/xrpl/tx/invariants/MPTInvariant.h +++ b/include/xrpl/tx/invariants/MPTInvariant.h @@ -49,6 +49,13 @@ class ValidMPTIssuance */ std::vector> deletedHoldings_; + /** + * MPTokenIssuances whose sfMPTokenSchema or sfMPTokenMetadata was set or + * changed during apply. finalize() verifies the Schema is well-formed and + * the Metadata decodes against it. Gated on featureMPTStructuredData. + */ + std::vector> structuredDataEntries_; + public: /** * @brief Track MPT issuance and holding creations, deletions, and diff --git a/include/xrpl/tx/transactors/token/MPTokenIssuanceCreate.h b/include/xrpl/tx/transactors/token/MPTokenIssuanceCreate.h index 1aa853d6e27..9a746ada933 100644 --- a/include/xrpl/tx/transactors/token/MPTokenIssuanceCreate.h +++ b/include/xrpl/tx/transactors/token/MPTokenIssuanceCreate.h @@ -31,6 +31,7 @@ struct MPTCreateArgs std::optional assetScale = std::nullopt; std::optional transferFee = std::nullopt; std::optional const& metadata{}; + std::optional const& schema{}; std::optional domainId = std::nullopt; std::optional immutableFlags = std::nullopt; // Set only by callers that issue an MPT representing a wrapped asset diff --git a/src/libxrpl/protocol/StructuredData.cpp b/src/libxrpl/protocol/StructuredData.cpp new file mode 100644 index 00000000000..ff888ce79e8 --- /dev/null +++ b/src/libxrpl/protocol/StructuredData.cpp @@ -0,0 +1,202 @@ +#include + +#include + +#include +#include +#include + +namespace xrpl { + +namespace { + +struct Cursor +{ + Slice s; + std::size_t i = 0; + + [[nodiscard]] std::size_t + remaining() const + { + return s.size() - i; + } +}; + +// Width of a fixed-size type; nullopt for str/bin/array/tuple codes and +// for reserved codes. +std::optional +fixedWidth(std::uint8_t code) +{ + switch (static_cast(code)) + { + case SchemaType::boolean: + case SchemaType::u8: + return 1; + case SchemaType::u16: + return 2; + case SchemaType::u32: + return 4; + case SchemaType::u64: + case SchemaType::xfl: + return 8; + case SchemaType::u128: + return 16; + case SchemaType::account: + case SchemaType::currency: + case SchemaType::h160: + return 20; + case SchemaType::u256: + case SchemaType::h256: + return 32; + case SchemaType::pubkey: + return 33; + default: + return std::nullopt; + } +} + +// Standard XRPL VL length prefix. Returns the payload length and +// advances the cursor past the prefix, or nullopt if the prefix is +// truncated or invalid. +std::optional +readVLPrefix(Cursor& dc) +{ + if (dc.remaining() < 1) + return std::nullopt; + std::size_t const b0 = dc.s[dc.i++]; + if (b0 <= 192) + return b0; + if (b0 <= 240) + { + if (dc.remaining() < 1) + return std::nullopt; + std::size_t const b1 = dc.s[dc.i++]; + return 193 + (b0 - 193) * 256 + b1; + } + if (b0 <= 254) + { + if (dc.remaining() < 2) + return std::nullopt; + std::size_t const b1 = dc.s[dc.i++]; + std::size_t const b2 = dc.s[dc.i++]; + return 12481 + (b0 - 241) * 65536 + b1 * 256 + b2; + } + return std::nullopt; +} + +// Walk one type starting at sc.i, advancing sc past it. When dc is +// non-null, consume the matching data. Re-invoked per array element +// with a reset schema cursor so the element type is applied count +// times against a single data cursor. +bool +parseType(Cursor& sc, Cursor* dc, std::size_t depth) +{ + if (sc.remaining() < 1) + return false; + std::uint8_t const code = sc.s[sc.i++]; + + if (auto const width = fixedWidth(code)) + { + if (dc) + { + if (dc->remaining() < *width) + return false; + if (static_cast(code) == SchemaType::boolean && dc->s[dc->i] > 1) + return false; + dc->i += *width; + } + return true; + } + + switch (static_cast(code)) + { + case SchemaType::str: + case SchemaType::bin: { + if (dc) + { + auto const len = readVLPrefix(*dc); + if (!len || dc->remaining() < *len) + return false; + dc->i += *len; + } + return true; + } + + case SchemaType::array: { + if (depth + 1 > kMaxSchemaDepth) + return false; + // Validate the element type once (schema-only) and record + // its extent, then replay it per element against the data. + std::size_t const elemBegin = sc.i; + if (!parseType(sc, nullptr, depth + 1)) + return false; + if (dc) + { + if (dc->remaining() < 1) + return false; + std::size_t const count = dc->s[dc->i++]; + for (std::size_t n = 0; n < count; ++n) + { + Cursor esc{sc.s, elemBegin}; + if (!parseType(esc, dc, depth + 1)) + return false; + } + } + return true; + } + + case SchemaType::tupleOpen: { + if (depth + 1 > kMaxSchemaDepth) + return false; + while (true) + { + if (sc.remaining() < 1) + return false; // unterminated tuple + if (static_cast(sc.s[sc.i]) == SchemaType::tupleClose) + { + ++sc.i; + return true; + } + if (!parseType(sc, dc, depth + 1)) + return false; + } + } + + default: + // Reserved codes and a tupleClose without a matching open. + return false; + } +} + +} // namespace + +bool +isWellFormedSchema(Slice schema) +{ + if (schema.empty() || schema.size() > kMaxSchemaLength) + return false; + Cursor sc{schema}; + while (sc.i < schema.size()) + { + if (!parseType(sc, nullptr, 0)) + return false; + } + return true; +} + +bool +dataMatchesSchema(Slice schema, Slice data) +{ + if (schema.empty() || schema.size() > kMaxSchemaLength) + return false; + Cursor sc{schema}; + Cursor dc{data}; + while (sc.i < schema.size()) + { + if (!parseType(sc, &dc, 0)) + return false; + } + return dc.i == data.size(); +} + +} // namespace xrpl diff --git a/src/libxrpl/tx/invariants/MPTInvariant.cpp b/src/libxrpl/tx/invariants/MPTInvariant.cpp index e38e8f2b937..12e1793c10f 100644 --- a/src/libxrpl/tx/invariants/MPTInvariant.cpp +++ b/src/libxrpl/tx/invariants/MPTInvariant.cpp @@ -19,6 +19,7 @@ #include #include #include +#include #include #include #include @@ -74,6 +75,7 @@ ValidMPTIssuance::visitEntry(bool isDelete, SLE::const_ref before, SLE::const_re // Skip both blocks when the amendment is off so we avoid wasted work // on the hot path. bool const fix320Enabled = isFeatureEnabled(fixCleanup3_2_0); + bool const structuredDataEnabled = isFeatureEnabled(featureMPTStructuredData); if (after && after->getType() == ltMPTOKEN_ISSUANCE) { @@ -86,18 +88,36 @@ ValidMPTIssuance::visitEntry(bool isDelete, SLE::const_ref before, SLE::const_re mptIssuancesCreated_++; if (fix320Enabled && after->isFieldPresent(sfReferenceHolding)) referenceHoldingSetOnCreate_ = true; + if (structuredDataEnabled && after->isFieldPresent(sfMPTokenSchema)) + structuredDataEntries_.push_back(after); } - else if (fix320Enabled) + else { - // Modified issuance: detect any change to sfReferenceHolding. - bool const beforePresent = before->isFieldPresent(sfReferenceHolding); - bool const afterPresent = after->isFieldPresent(sfReferenceHolding); - if (beforePresent != afterPresent || - (afterPresent && - before->getFieldH256(sfReferenceHolding) != - after->getFieldH256(sfReferenceHolding))) + if (fix320Enabled) + { + // Modified issuance: detect any change to sfReferenceHolding. + bool const beforePresent = before->isFieldPresent(sfReferenceHolding); + bool const afterPresent = after->isFieldPresent(sfReferenceHolding); + if (beforePresent != afterPresent || + (afterPresent && + before->getFieldH256(sfReferenceHolding) != + after->getFieldH256(sfReferenceHolding))) + { + referenceHoldingMutated_ = true; + } + } + if (structuredDataEnabled) { - referenceHoldingMutated_ = true; + // Modified issuance: re-verify whenever the Schema or the + // Metadata it types changed. + auto const fieldChanged = [&](SF_VL const& field) { + bool const beforePresent = before->isFieldPresent(field); + bool const afterPresent = after->isFieldPresent(field); + return beforePresent != afterPresent || + (afterPresent && before->getFieldVL(field) != after->getFieldVL(field)); + }; + if (fieldChanged(sfMPTokenSchema) || fieldChanged(sfMPTokenMetadata)) + structuredDataEntries_.push_back(after); } } } @@ -194,6 +214,33 @@ ValidMPTIssuance::finalize( return false; } + // A Schema on an issuance must be well-formed, and the Metadata it types + // must decode against it. Metadata without a Schema stays opaque. + if (rules.enabled(featureMPTStructuredData)) + { + for (auto const& sleIssuance : structuredDataEntries_) + { + auto const schema = (*sleIssuance)[~sfMPTokenSchema]; + if (!schema) + continue; + + if (!isWellFormedSchema(*schema)) + { + JLOG(j.fatal()) << "Invariant failed: MPTokenIssuance carries " + "a malformed Schema"; + return false; + } + + auto const metadata = (*sleIssuance)[~sfMPTokenMetadata]; + if (metadata && !dataMatchesSchema(*schema, *metadata)) + { + JLOG(j.fatal()) << "Invariant failed: MPTokenIssuance Metadata " + "does not decode against its Schema"; + return false; + } + } + } + if (isTesSuccess(result) || (mptV2Enabled && result == tecINCOMPLETE)) { [[maybe_unused]] diff --git a/src/libxrpl/tx/transactors/token/MPTokenIssuanceCreate.cpp b/src/libxrpl/tx/transactors/token/MPTokenIssuanceCreate.cpp index cd0f8390306..8da8381bb63 100644 --- a/src/libxrpl/tx/transactors/token/MPTokenIssuanceCreate.cpp +++ b/src/libxrpl/tx/transactors/token/MPTokenIssuanceCreate.cpp @@ -15,6 +15,7 @@ #include #include #include +#include #include #include #include @@ -42,6 +43,9 @@ MPTokenIssuanceCreate::checkExtraFeatures(PreflightContext const& ctx) !ctx.rules.enabled(featureConfidentialTransfer)) return false; + if (ctx.tx.isFieldPresent(sfMPTokenSchema) && !ctx.rules.enabled(featureMPTStructuredData)) + return false; + // can not set tifMPTCanHoldConfidentialBalance without featureConfidentialTransfer auto const immutableFlags = ctx.tx[~sfImmutableFlags]; // NOLINTBEGIN(readability-simplify-boolean-expr) @@ -101,9 +105,18 @@ MPTokenIssuanceCreate::preflight(PreflightContext const& ctx) return temMALFORMED; } - if (auto const metadata = ctx.tx[~sfMPTokenMetadata]) + auto const metadata = ctx.tx[~sfMPTokenMetadata]; + if (metadata && (metadata->empty() || metadata->length() > kMaxMpTokenMetadataLength)) + return temMALFORMED; + + // A Schema types the Metadata. Declaring one without Metadata is allowed: + // the layout is fixed now and the record written by a later Set. + if (auto const schema = ctx.tx[~sfMPTokenSchema]) { - if (metadata->empty() || metadata->length() > kMaxMpTokenMetadataLength) + if (schema->empty() || schema->size() > kMaxSchemaLength || !isWellFormedSchema(*schema)) + return temMALFORMED; + + if (metadata && !dataMatchesSchema(*schema, *metadata)) return temMALFORMED; } @@ -173,6 +186,9 @@ MPTokenIssuanceCreate::create( if (args.metadata) (*mptIssuance)[sfMPTokenMetadata] = *args.metadata; + if (args.schema) + (*mptIssuance)[sfMPTokenSchema] = *args.schema; + if (args.domainId) (*mptIssuance)[sfDomainID] = *args.domainId; @@ -222,6 +238,7 @@ MPTokenIssuanceCreate::doApply() .assetScale = tx[~sfAssetScale], .transferFee = tx[~sfTransferFee], .metadata = tx[~sfMPTokenMetadata], + .schema = tx[~sfMPTokenSchema], .domainId = tx[~sfDomainID], .immutableFlags = tx[~sfImmutableFlags], }); diff --git a/src/libxrpl/tx/transactors/token/MPTokenIssuanceSet.cpp b/src/libxrpl/tx/transactors/token/MPTokenIssuanceSet.cpp index 6bd71406319..f5d5033b604 100644 --- a/src/libxrpl/tx/transactors/token/MPTokenIssuanceSet.cpp +++ b/src/libxrpl/tx/transactors/token/MPTokenIssuanceSet.cpp @@ -14,6 +14,7 @@ #include #include #include +#include #include #include #include @@ -27,6 +28,9 @@ namespace xrpl { bool MPTokenIssuanceSet::checkExtraFeatures(PreflightContext const& ctx) { + if (ctx.tx.isFieldPresent(sfMPTokenSchema) && !ctx.rules.enabled(featureMPTStructuredData)) + return false; + return !ctx.tx.isFieldPresent(sfDomainID) || (ctx.rules.enabled(featurePermissionedDomains) && ctx.rules.enabled(featureSingleAssetVault)); @@ -46,7 +50,9 @@ MPTokenIssuanceSet::preflight(PreflightContext const& ctx) auto const metadata = ctx.tx[~sfMPTokenMetadata]; auto const transferFee = ctx.tx[~sfTransferFee]; auto const immutableFlags = ctx.tx[~sfImmutableFlags]; - auto const isMutate = (enableFlags != 0u) || metadata || transferFee || immutableFlags; + auto const schema = ctx.tx[~sfMPTokenSchema]; + auto const isMutate = + (enableFlags != 0u) || metadata || transferFee || immutableFlags || schema; auto const hasIssuerElGamalKey = ctx.tx.isFieldPresent(sfIssuerEncryptionKey); auto const hasAuditorElGamalKey = ctx.tx.isFieldPresent(sfAuditorEncryptionKey); @@ -88,6 +94,16 @@ MPTokenIssuanceSet::preflight(PreflightContext const& ctx) return temMALFORMED; } + if (schema) + { + // A Schema mutates the issuance itself: no Holder, no flags. + if (hasHolder) + return temMALFORMED; + + if ((txFlags & tfUniversalMask) != 0u) + return temMALFORMED; + } + if (ctx.rules.enabled(featureDynamicMPT)) { // Holder field is not allowed when mutating MPTokenIssuance @@ -108,6 +124,11 @@ MPTokenIssuanceSet::preflight(PreflightContext const& ctx) if (metadata && metadata->length() > kMaxMpTokenMetadataLength) return temMALFORMED; + // An empty Schema deletes the field; any other Schema must be usable. + if (schema && !schema->empty() && + (schema->size() > kMaxSchemaLength || !isWellFormedSchema(*schema))) + return temMALFORMED; + // If the immutable flags field is included, at least one flag must be // specified, and undefined flags must not be specified. if (immutableFlags && @@ -205,9 +226,27 @@ MPTokenIssuanceSet::preclaim(PreclaimContext const& ctx) return tecNO_PERMISSION; } - if (isImmutable(lsifMPTMetadata) && ctx.tx.isFieldPresent(sfMPTokenMetadata)) + // lsifMPTMetadata freezes the typed content as a unit: changing the Schema + // reinterprets the Metadata, so neither moves once it is set. + if (isImmutable(lsifMPTMetadata) && + (ctx.tx.isFieldPresent(sfMPTokenMetadata) || ctx.tx.isFieldPresent(sfMPTokenSchema))) return tecNO_PERMISSION; + // Whatever this transaction leaves behind must agree: the resulting + // Metadata decodes against the resulting Schema. + { + auto const schema = ctx.tx.isFieldPresent(sfMPTokenSchema) + ? ctx.tx[~sfMPTokenSchema] + : (*sleMptIssuance)[~sfMPTokenSchema]; + auto const metadata = ctx.tx.isFieldPresent(sfMPTokenMetadata) + ? ctx.tx[~sfMPTokenMetadata] + : (*sleMptIssuance)[~sfMPTokenMetadata]; + + if (schema && !schema->empty() && metadata && !metadata->empty() && + !dataMatchesSchema(*schema, *metadata)) + return tecNO_PERMISSION; + } + if (auto const fee = ctx.tx[~sfTransferFee]) { // A non-zero TransferFee is only valid if the lsfMPTCanTransfer flag @@ -411,6 +450,18 @@ MPTokenIssuanceSet::doApply() } } + if (auto const schema = ctx_.tx[~sfMPTokenSchema]) + { + if (schema->empty()) + { + sle->makeFieldAbsent(sfMPTokenSchema); + } + else + { + sle->setFieldVL(sfMPTokenSchema, *schema); + } + } + if (domainID) { // This is enforced in preflight. diff --git a/src/test/app/MPToken_test.cpp b/src/test/app/MPToken_test.cpp index 7086adf743f..8ca5d7061e3 100644 --- a/src/test/app/MPToken_test.cpp +++ b/src/test/app/MPToken_test.cpp @@ -7707,6 +7707,186 @@ class MPToken_test : public beast::unit_test::Suite } } + // Byte-string helper for Schema/Data blobs with embedded zero bytes. + static std::string + sd(std::initializer_list il) + { + return {il.begin(), il.end()}; + } + + void + testStructuredDataValidation(FeatureBitset features) + { + testcase("invalid Schema/Metadata for MPTStructuredData"); + + using namespace test::jtx; + Account const alice("alice"); + Account const bob("bob"); + + // str, u16, u32, u64 - the XLS worked example + std::string const schema = sd({0x0E, 0x03, 0x04, 0x05}); + std::string const record = sd({0x09}) + "912828YK0" + sd({0x01, 0xA9}) + + sd({0x71, 0x3F, 0xB3, 0x00}) + sd({0x00, 0x00, 0x00, 0x00, 0x00, 0x0F, 0x42, 0x40}); + + // A Schema requires the amendment, on create and on set + { + Env env{*this, features - featureMPTStructuredData}; + MPTTester mptAlice(env, alice, {.holders = {bob}}); + mptAlice.create({.ownerCount = 0, .schema = schema, .err = temDISABLED}); + mptAlice.create( + {.ownerCount = 0, .metadata = record, .schema = schema, .err = temDISABLED}); + + auto const mptID = makeMptID(env.seq(alice), alice); + mptAlice.set({.account = alice, .id = mptID, .schema = schema, .err = temDISABLED}); + } + + // Malformed schemas on create + { + Env env{*this, features}; + MPTTester mptAlice(env, alice); + + // Empty schema + mptAlice.create({.ownerCount = 0, .schema = std::string{}, .err = temMALFORMED}); + // Reserved type code + mptAlice.create({.ownerCount = 0, .schema = sd({0x00}), .err = temMALFORMED}); + // Array without an element type + mptAlice.create({.ownerCount = 0, .schema = sd({0x20}), .err = temMALFORMED}); + // Unterminated tuple + mptAlice.create({.ownerCount = 0, .schema = sd({0x30, 0x05}), .err = temMALFORMED}); + // Over the size cap + mptAlice.create( + {.ownerCount = 0, + .schema = std::string(kMaxSchemaLength + 1, '\x02'), + .err = temMALFORMED}); + } + + // Metadata must decode against a Schema declared alongside it + { + Env env{*this, features}; + MPTTester mptAlice(env, alice); + + mptAlice.create( + {.ownerCount = 0, + .metadata = record + sd({0x00}), + .schema = schema, + .err = temMALFORMED}); + mptAlice.create( + {.ownerCount = 0, + .metadata = record.substr(0, record.size() - 1), + .schema = schema, + .err = temMALFORMED}); + + // Metadata without a Schema stays opaque + mptAlice.create({.ownerCount = 1, .metadata = "not a typed record"}); + } + + // Schema rules on set + { + Env env{*this, features}; + MPTTester mptAlice(env, alice, {.holders = {bob}}); + mptAlice.create({.ownerCount = 1, .metadata = record, .schema = schema}); + + // A Schema mutates the issuance itself: no Holder, no flags + mptAlice.set({.account = alice, .holder = bob, .schema = schema, .err = temMALFORMED}); + mptAlice.set( + {.account = alice, .flags = tfMPTLock, .schema = schema, .err = temMALFORMED}); + // Metadata that no longer decodes against the Schema + mptAlice.set( + {.account = alice, .metadata = record + sd({0x00}), .err = tecNO_PERMISSION}); + // A Schema the stored Metadata does not decode against + mptAlice.set({.account = alice, .schema = sd({0x05}), .err = tecNO_PERMISSION}); + } + + // tifMPTMetadata freezes the Schema as well as the Metadata + { + Env env{*this, features}; + MPTTester mptAlice(env, alice); + mptAlice.create( + {.ownerCount = 1, + .metadata = record, + .schema = schema, + .immutableFlags = tifMPTMetadata}); + mptAlice.set({.account = alice, .metadata = record, .err = tecNO_PERMISSION}); + mptAlice.set({.account = alice, .schema = schema, .err = tecNO_PERMISSION}); + } + } + + void + testStructuredData(FeatureBitset features) + { + testcase("Schema and Metadata lifecycle"); + + using namespace test::jtx; + Account const alice("alice"); + + std::string const schema = sd({0x0E, 0x03, 0x04, 0x05}); + // VL prefix + string, then big-endian u16, u32, u64 + auto const record = [](std::string const& cusip, + std::uint16_t coupon, + std::uint32_t maturity, + std::uint64_t face) { + std::string out = sd({static_cast(cusip.size())}) + cusip; + auto appendBE = [&out](std::uint64_t value, int bytes) { + for (int i = (bytes - 1) * 8; i >= 0; i -= 8) + out += static_cast((value >> i) & 0xFF); + }; + appendBE(coupon, 2); + appendBE(maturity, 4); + appendBE(face, 8); + return out; + }; + + // Typed Metadata fixed at create + { + Env env{*this, features}; + MPTTester mptAlice(env, alice); + auto const v1 = record("912828YK0", 425, 1900000000, 1000000); + mptAlice.create({.ownerCount = 1, .metadata = v1, .schema = schema}); + BEAST_EXPECT(mptAlice.checkSchema(schema)); + BEAST_EXPECT(mptAlice.checkMetadata(v1)); + } + + // Schema declared at create, Metadata written and replaced later + { + Env env{*this, features}; + MPTTester mptAlice(env, alice); + mptAlice.create({.ownerCount = 1, .schema = schema}); + BEAST_EXPECT(mptAlice.checkSchema(schema)); + BEAST_EXPECT(!mptAlice.isMetadataPresent()); + + auto const v1 = record("912828YK0", 425, 1900000000, 1000000); + mptAlice.set({.account = alice, .metadata = v1}); + BEAST_EXPECT(mptAlice.checkMetadata(v1)); + + auto const v2 = record("912828YL8", 450, 1931536000, 2000000); + mptAlice.set({.account = alice, .metadata = v2}); + BEAST_EXPECT(mptAlice.checkMetadata(v2)); + + // The Schema is untouched by any of it + BEAST_EXPECT(mptAlice.checkSchema(schema)); + } + + // Schema and Metadata re-typed together in one transaction + { + Env env{*this, features}; + MPTTester mptAlice(env, alice); + auto const v1 = record("912828YK0", 425, 1900000000, 1000000); + mptAlice.create({.ownerCount = 1, .metadata = v1, .schema = schema}); + + // A bare u64: the old record does not fit it, so both must move at once + std::string const reduced = sd({0x05}); + std::string const face = sd({0x00, 0x00, 0x00, 0x00, 0x00, 0x0F, 0x42, 0x40}); + mptAlice.set({.account = alice, .metadata = face, .schema = reduced}); + BEAST_EXPECT(mptAlice.checkSchema(reduced)); + BEAST_EXPECT(mptAlice.checkMetadata(face)); + + // An empty Schema removes it, leaving the Metadata opaque again + mptAlice.set({.account = alice, .schema = std::string{}}); + BEAST_EXPECT(!mptAlice.checkSchema(reduced)); + BEAST_EXPECT(mptAlice.checkMetadata(face)); + } + } + public: void run() override @@ -7787,6 +7967,10 @@ class MPToken_test : public beast::unit_test::Suite // Test helpers testHelperFunctions(); + // Structured data (MPTStructuredData) + testStructuredDataValidation(all); + testStructuredData(all); + // Dynamic MPT testInvalidCreateDynamic(all); testInvalidSetDynamic(all); diff --git a/src/test/app/invariants/InvariantsMPT_test.cpp b/src/test/app/invariants/InvariantsMPT_test.cpp index 4692463baaa..d03cd0f28ce 100644 --- a/src/test/app/invariants/InvariantsMPT_test.cpp +++ b/src/test/app/invariants/InvariantsMPT_test.cpp @@ -1564,11 +1564,63 @@ class InvariantsMPT_test : public InvariantsBase } public: + void + testMPTStructuredData() + { + testcase("MPT structured data"); + using namespace jtx; + + // str, u16, u32, u64 and a record that matches it + std::string const schema{"\x0E\x03\x04\x05", 4}; + std::string const record = std::string{"\x09", 1} + "912828YK0" + + std::string{"\x01\xA9", 2} + std::string{"\x71\x3F\xB3\x00", 4} + + std::string{"\x00\x00\x00\x00\x00\x0F\x42\x40", 8}; + + // Initialize with a placeholder value because there's no default ctor + Keylet issuanceKeylet = keylet::amendments(); + auto const preclose = Preclose{[&, this](Account const& a, Account const&, Env& env) { + MPTTester mpt(env, a, kMptInitNoFund); + mpt.create({.metadata = record, .schema = schema}); + issuanceKeylet = keylet::mptokenIssuance(mpt.issuanceID()); + return BEAST_EXPECT(env.le(issuanceKeylet)); + }}; + + auto const corrupt = [&](std::function const& mod) { + return [&, mod](Account const&, Account const&, ApplyContext& ac) { + auto sle = ac.view().peek(issuanceKeylet); + if (!sle) + return false; + mod(sle); + ac.view().update(sle); + return true; + }; + }; + + // Metadata corrupted to bytes that do not decode against the Schema + doInvariantCheck( + {{"does not decode against its Schema"}}, + corrupt([](SLE::pointer& sle) { sle->setFieldVL(sfMPTokenMetadata, Blob{0x00}); }), + XRPAmount{}, + STTx{ttACCOUNT_SET, [](STObject&) {}}, + {tecINVARIANT_FAILED, tefINVARIANT_FAILED}, + preclose); + + // Schema corrupted to a malformed descriptor + doInvariantCheck( + {{"carries a malformed Schema"}}, + corrupt([](SLE::pointer& sle) { sle->setFieldVL(sfMPTokenSchema, Blob{0x20}); }), + XRPAmount{}, + STTx{ttACCOUNT_SET, [](STObject&) {}}, + {tecINVARIANT_FAILED, tefINVARIANT_FAILED}, + preclose); + } + void run() override { testConfidentialMPTTransfer(); testMPT(); + testMPTStructuredData(); } }; diff --git a/src/test/jtx/impl/mpt.cpp b/src/test/jtx/impl/mpt.cpp index 0c1ff14eab5..0261e89427a 100644 --- a/src/test/jtx/impl/mpt.cpp +++ b/src/test/jtx/impl/mpt.cpp @@ -226,6 +226,8 @@ MPTTester::createJV(MPTCreate const& arg) jv[sfTransferFee] = *arg.transferFee; if (arg.metadata) jv[sfMPTokenMetadata] = strHex(*arg.metadata); + if (arg.schema) + jv[sfMPTokenSchema] = strHex(*arg.schema); if (arg.maxAmt) jv[sfMaximumAmount] = std::to_string(*arg.maxAmt); if (arg.domainID) @@ -249,6 +251,7 @@ MPTTester::create(MPTCreate const& arg) .assetScale = arg.assetScale, .transferFee = arg.transferFee, .metadata = arg.metadata, + .schema = arg.schema, .immutableFlags = arg.immutableFlags, .domainID = arg.domainID}); if (!isTesSuccess(submit(arg, jv))) @@ -454,6 +457,8 @@ MPTTester::setJV(MPTSet const& arg) jv[sfTransferFee] = *arg.transferFee; if (arg.metadata) jv[sfMPTokenMetadata] = strHex(*arg.metadata); + if (arg.schema) + jv[sfMPTokenSchema] = strHex(*arg.schema); if (arg.issuerPubKey) jv[sfIssuerEncryptionKey] = strHex(*arg.issuerPubKey); if (arg.auditorPubKey) @@ -475,6 +480,7 @@ MPTTester::set(MPTSet const& arg) .immutableFlags = arg.immutableFlags, .transferFee = arg.transferFee, .metadata = arg.metadata, + .schema = arg.schema, .delegate = arg.delegate, .domainID = arg.domainID, .issuerPubKey = arg.issuerPubKey, @@ -623,6 +629,16 @@ MPTTester::isMetadataPresent() const [&](SLEP const& sle) -> bool { return sle->isFieldPresent(sfMPTokenMetadata); }); } +[[nodiscard]] bool +MPTTester::checkSchema(std::string const& schema) const +{ + return forObject([&](SLEP const& sle) -> bool { + if (sle->isFieldPresent(sfMPTokenSchema)) + return strHex(sle->getFieldVL(sfMPTokenSchema)) == strHex(schema); + return false; + }); +} + [[nodiscard]] bool MPTTester::checkTransferFee(std::uint16_t transferFee) const { diff --git a/src/test/jtx/mpt.h b/src/test/jtx/mpt.h index 26329ad78cb..7d8d2dce385 100644 --- a/src/test/jtx/mpt.h +++ b/src/test/jtx/mpt.h @@ -138,6 +138,7 @@ struct MPTCreate std::optional assetScale = std::nullopt; std::optional transferFee = std::nullopt; std::optional metadata = std::nullopt; + std::optional schema = std::nullopt; std::optional ownerCount = std::nullopt; std::optional holderCount = std::nullopt; // authorize if seated. @@ -232,6 +233,7 @@ struct MPTSet std::optional immutableFlags = std::nullopt; std::optional transferFee = std::nullopt; std::optional metadata = std::nullopt; + std::optional schema = std::nullopt; std::optional delegate = std::nullopt; std::optional domainID = std::nullopt; std::optional issuerPubKey = std::nullopt; @@ -603,6 +605,9 @@ class MPTTester [[nodiscard]] bool isMetadataPresent() const; + [[nodiscard]] bool + checkSchema(std::string const& schema) const; + [[nodiscard]] bool checkTransferFee(std::uint16_t transferFee) const; diff --git a/src/tests/libxrpl/protocol/StructuredData.cpp b/src/tests/libxrpl/protocol/StructuredData.cpp new file mode 100644 index 00000000000..e15835c33e3 --- /dev/null +++ b/src/tests/libxrpl/protocol/StructuredData.cpp @@ -0,0 +1,187 @@ +#include + +#include +#include + +#include + +#include +#include +#include +#include + +using namespace xrpl; + +namespace { + +std::vector +bytes(std::initializer_list il) +{ + return {il}; +} + +Slice +slice(std::vector const& v) +{ + return {v.data(), v.size()}; +} + +std::vector +operator+(std::vector a, std::vector const& b) +{ + a.insert(a.end(), b.begin(), b.end()); + return a; +} + +std::vector +utf8(std::string const& s) +{ + return {s.begin(), s.end()}; +} + +} // namespace + +TEST(StructuredData, wellFormedSchemas) +{ + // Every fixed-width code, str/bin, and combinations + for (std::uint8_t code = 0x01; code <= 0x0F; ++code) + EXPECT_TRUE(isWellFormedSchema(slice(bytes({code})))) << "code " << int(code); + + // The spec's worked example: str, u16, u32, u64 + EXPECT_TRUE(isWellFormedSchema(slice(bytes({0x0E, 0x03, 0x04, 0x05})))); + + // Arrays and tuples + EXPECT_TRUE(isWellFormedSchema(slice(bytes({0x20, 0x05})))); // u64[] + EXPECT_TRUE(isWellFormedSchema(slice(bytes({0x30, 0x09, 0x05, 0x31})))); // (account,u64) + EXPECT_TRUE( + isWellFormedSchema(slice(bytes({0x20, 0x30, 0x09, 0x05, 0x31})))); // (account,u64)[] + EXPECT_TRUE(isWellFormedSchema(slice(bytes({0x20, 0x20, 0x02})))); // u8[][] +} + +TEST(StructuredData, malformedSchemas) +{ + EXPECT_FALSE(isWellFormedSchema(slice(bytes({})))); // empty + EXPECT_FALSE(isWellFormedSchema(slice(bytes({0x00})))); // reserved + EXPECT_FALSE(isWellFormedSchema(slice(bytes({0x10})))); // reserved + EXPECT_FALSE(isWellFormedSchema(slice(bytes({0xFF})))); // reserved + EXPECT_FALSE(isWellFormedSchema(slice(bytes({0x20})))); // array, no element + EXPECT_FALSE(isWellFormedSchema(slice(bytes({0x30, 0x05})))); // unterminated tuple + EXPECT_FALSE(isWellFormedSchema(slice(bytes({0x31})))); // close without open + EXPECT_FALSE(isWellFormedSchema(slice(bytes({0x05, 0x00})))); // trailing reserved + + // Over the size cap + std::vector big(kMaxSchemaLength + 1, 0x02); + EXPECT_FALSE(isWellFormedSchema(slice(big))); + std::vector atCap(kMaxSchemaLength, 0x02); + EXPECT_TRUE(isWellFormedSchema(slice(atCap))); + + // Nesting: depth 8 is fine, depth 9 is not + std::vector depth8; + for (int i = 0; i < 8; ++i) + depth8.push_back(0x20); + depth8.push_back(0x02); + EXPECT_TRUE(isWellFormedSchema(slice(depth8))); + std::vector depth9; + for (int i = 0; i < 9; ++i) + depth9.push_back(0x20); + depth9.push_back(0x02); + EXPECT_FALSE(isWellFormedSchema(slice(depth9))); +} + +TEST(StructuredData, fixedWidthData) +{ + auto const schema = bytes({0x02, 0x03, 0x05}); // u8, u16, u64 + auto const data = bytes({0xAA}) + bytes({0x01, 0xA9}) + + bytes({0x00, 0x00, 0x00, 0x00, 0x00, 0x0F, 0x42, 0x40}); + EXPECT_TRUE(dataMatchesSchema(slice(schema), slice(data))); + + // Truncated and trailing both fail + auto truncated = data; + truncated.pop_back(); + EXPECT_FALSE(dataMatchesSchema(slice(schema), slice(truncated))); + auto trailing = data; + trailing.push_back(0x00); + EXPECT_FALSE(dataMatchesSchema(slice(schema), slice(trailing))); + + // Empty data never matches a schema with fields + EXPECT_FALSE(dataMatchesSchema(slice(schema), slice(bytes({})))); +} + +TEST(StructuredData, boolValues) +{ + auto const schema = bytes({0x01}); + EXPECT_TRUE(dataMatchesSchema(slice(schema), slice(bytes({0x00})))); + EXPECT_TRUE(dataMatchesSchema(slice(schema), slice(bytes({0x01})))); + EXPECT_FALSE(dataMatchesSchema(slice(schema), slice(bytes({0x02})))); + EXPECT_FALSE(dataMatchesSchema(slice(schema), slice(bytes({0xFF})))); +} + +TEST(StructuredData, varLengthData) +{ + auto const schema = bytes({0x0E}); // str + + // Empty string: VL prefix 0, no content + EXPECT_TRUE(dataMatchesSchema(slice(schema), slice(bytes({0x00})))); + + // Short string + auto const hello = bytes({0x05}) + utf8("hello"); + EXPECT_TRUE(dataMatchesSchema(slice(schema), slice(hello))); + + // Multibyte UTF-8: prefix counts bytes, not characters + auto const accents = utf8("\xC3\xA9\xC3\xA9"); // "éé", 4 bytes + EXPECT_TRUE(dataMatchesSchema(slice(schema), slice(bytes({0x04}) + accents))); + EXPECT_FALSE(dataMatchesSchema(slice(schema), slice(bytes({0x02}) + accents))); + + // VL prefix longer than remaining input + EXPECT_FALSE(dataMatchesSchema(slice(schema), slice(bytes({0x05}) + utf8("hi")))); + + // Two-byte VL prefix: 300 bytes = 193 + (b0-193)*256 + b1 with b0=193, b1=107 + std::vector payload(300, 0x41); + EXPECT_TRUE(dataMatchesSchema(slice(schema), slice(bytes({193, 107}) + payload))); + + // 0xFF is not a valid VL prefix + EXPECT_FALSE(dataMatchesSchema(slice(schema), slice(bytes({0xFF, 0x00})))); +} + +TEST(StructuredData, arraysAndTuples) +{ + // u16[] with 0 and 3 elements + auto const arr = bytes({0x20, 0x03}); + EXPECT_TRUE(dataMatchesSchema(slice(arr), slice(bytes({0x00})))); + EXPECT_TRUE( + dataMatchesSchema(slice(arr), slice(bytes({0x03, 0x00, 0x01, 0x00, 0x02, 0x00, 0x03})))); + // Count says 3, only 2 present + EXPECT_FALSE(dataMatchesSchema(slice(arr), slice(bytes({0x03, 0x00, 0x01, 0x00, 0x02})))); + // Missing count byte entirely + EXPECT_FALSE(dataMatchesSchema(slice(arr), slice(bytes({})))); + + // (u8,str)[]: two records, second with an empty string + auto const tupArr = bytes({0x20, 0x30, 0x02, 0x0E, 0x31}); + auto const recs = bytes({0x02}) + bytes({0x07, 0x02}) + utf8("ab") + bytes({0x09, 0x00}); + EXPECT_TRUE(dataMatchesSchema(slice(tupArr), slice(recs))); + + // Tuple fields pack flat, no framing in the data + auto const tup = bytes({0x30, 0x02, 0x02, 0x31}); + EXPECT_TRUE(dataMatchesSchema(slice(tup), slice(bytes({0x01, 0x02})))); + EXPECT_FALSE(dataMatchesSchema(slice(tup), slice(bytes({0x01})))); +} + +TEST(StructuredData, specWorkedExample) +{ + // cusip:str, coupon:u16, maturity:u32, face:u64 + auto const schema = bytes({0x0E, 0x03, 0x04, 0x05}); + auto const data = bytes({0x09}) + utf8("912828YK0") + bytes({0x01, 0xA9}) + + bytes({0x71, 0x3F, 0xB3, 0x00}) + bytes({0x00, 0x00, 0x00, 0x00, 0x00, 0x0F, 0x42, 0x40}); + EXPECT_EQ(data.size(), 24u); + EXPECT_TRUE(isWellFormedSchema(slice(schema))); + EXPECT_TRUE(dataMatchesSchema(slice(schema), slice(data))); +} + +TEST(StructuredData, malformedSchemaNeverMatches) +{ + // dataMatchesSchema re-validates the schema rather than misreading data + EXPECT_FALSE(dataMatchesSchema(slice(bytes({})), slice(bytes({})))); + EXPECT_FALSE(dataMatchesSchema(slice(bytes({0x00})), slice(bytes({0x00})))); + EXPECT_FALSE(dataMatchesSchema(slice(bytes({0x20})), slice(bytes({0x00})))); + EXPECT_FALSE(dataMatchesSchema(slice(bytes({0x30, 0x02})), slice(bytes({0x01})))); +} diff --git a/src/tests/libxrpl/protocol_autogen/ledger_entries/MPTokenIssuanceTests.cpp b/src/tests/libxrpl/protocol_autogen/ledger_entries/MPTokenIssuanceTests.cpp index e8c1b645d84..677f39d95c5 100644 --- a/src/tests/libxrpl/protocol_autogen/ledger_entries/MPTokenIssuanceTests.cpp +++ b/src/tests/libxrpl/protocol_autogen/ledger_entries/MPTokenIssuanceTests.cpp @@ -29,6 +29,7 @@ TEST(MPTokenIssuanceTests, BuilderSettersRoundTrip) auto const outstandingAmountValue = canonical_UINT64(); auto const lockedAmountValue = canonical_UINT64(); auto const mPTokenMetadataValue = canonical_VL(); + auto const mPTokenSchemaValue = canonical_VL(); auto const previousTxnIDValue = canonical_UINT256(); auto const previousTxnLgrSeqValue = canonical_UINT32(); auto const domainIDValue = canonical_UINT256(); @@ -54,6 +55,7 @@ TEST(MPTokenIssuanceTests, BuilderSettersRoundTrip) builder.setMaximumAmount(maximumAmountValue); builder.setLockedAmount(lockedAmountValue); builder.setMPTokenMetadata(mPTokenMetadataValue); + builder.setMPTokenSchema(mPTokenSchemaValue); builder.setDomainID(domainIDValue); builder.setImmutableFlags(immutableFlagsValue); builder.setReferenceHolding(referenceHoldingValue); @@ -148,6 +150,14 @@ TEST(MPTokenIssuanceTests, BuilderSettersRoundTrip) EXPECT_TRUE(entry.hasMPTokenMetadata()); } + { + auto const& expected = mPTokenSchemaValue; + auto const actualOpt = entry.getMPTokenSchema(); + ASSERT_TRUE(actualOpt.has_value()); + expectEqualField(expected, *actualOpt, "sfMPTokenSchema"); + EXPECT_TRUE(entry.hasMPTokenSchema()); + } + { auto const& expected = domainIDValue; auto const actualOpt = entry.getDomainID(); @@ -234,6 +244,7 @@ TEST(MPTokenIssuanceTests, BuilderFromSleRoundTrip) auto const outstandingAmountValue = canonical_UINT64(); auto const lockedAmountValue = canonical_UINT64(); auto const mPTokenMetadataValue = canonical_VL(); + auto const mPTokenSchemaValue = canonical_VL(); auto const previousTxnIDValue = canonical_UINT256(); auto const previousTxnLgrSeqValue = canonical_UINT32(); auto const domainIDValue = canonical_UINT256(); @@ -256,6 +267,7 @@ TEST(MPTokenIssuanceTests, BuilderFromSleRoundTrip) sle->at(sfOutstandingAmount) = outstandingAmountValue; sle->at(sfLockedAmount) = lockedAmountValue; sle->at(sfMPTokenMetadata) = mPTokenMetadataValue; + sle->at(sfMPTokenSchema) = mPTokenSchemaValue; sle->at(sfPreviousTxnID) = previousTxnIDValue; sle->at(sfPreviousTxnLgrSeq) = previousTxnLgrSeqValue; sle->at(sfDomainID) = domainIDValue; @@ -401,6 +413,19 @@ TEST(MPTokenIssuanceTests, BuilderFromSleRoundTrip) expectEqualField(expected, *fromBuilderOpt, "sfMPTokenMetadata"); } + { + auto const& expected = mPTokenSchemaValue; + + auto const fromSleOpt = entryFromSle.getMPTokenSchema(); + auto const fromBuilderOpt = entryFromBuilder.getMPTokenSchema(); + + ASSERT_TRUE(fromSleOpt.has_value()); + ASSERT_TRUE(fromBuilderOpt.has_value()); + + expectEqualField(expected, *fromSleOpt, "sfMPTokenSchema"); + expectEqualField(expected, *fromBuilderOpt, "sfMPTokenSchema"); + } + { auto const& expected = domainIDValue; @@ -579,6 +604,8 @@ TEST(MPTokenIssuanceTests, OptionalFieldsReturnNullopt) EXPECT_FALSE(entry.getLockedAmount().has_value()); EXPECT_FALSE(entry.hasMPTokenMetadata()); EXPECT_FALSE(entry.getMPTokenMetadata().has_value()); + EXPECT_FALSE(entry.hasMPTokenSchema()); + EXPECT_FALSE(entry.getMPTokenSchema().has_value()); EXPECT_FALSE(entry.hasDomainID()); EXPECT_FALSE(entry.getDomainID().has_value()); EXPECT_FALSE(entry.hasImmutableFlags()); diff --git a/src/tests/libxrpl/protocol_autogen/transactions/MPTokenIssuanceCreateTests.cpp b/src/tests/libxrpl/protocol_autogen/transactions/MPTokenIssuanceCreateTests.cpp index 82281889503..68c862ec8d2 100644 --- a/src/tests/libxrpl/protocol_autogen/transactions/MPTokenIssuanceCreateTests.cpp +++ b/src/tests/libxrpl/protocol_autogen/transactions/MPTokenIssuanceCreateTests.cpp @@ -35,6 +35,7 @@ TEST(TransactionsMPTokenIssuanceCreateTests, BuilderSettersRoundTrip) auto const mPTokenMetadataValue = canonical_VL(); auto const domainIDValue = canonical_UINT256(); auto const immutableFlagsValue = canonical_UINT32(); + auto const mPTokenSchemaValue = canonical_VL(); MPTokenIssuanceCreateBuilder builder{ accountValue, @@ -49,6 +50,7 @@ TEST(TransactionsMPTokenIssuanceCreateTests, BuilderSettersRoundTrip) builder.setMPTokenMetadata(mPTokenMetadataValue); builder.setDomainID(domainIDValue); builder.setImmutableFlags(immutableFlagsValue); + builder.setMPTokenSchema(mPTokenSchemaValue); auto tx = builder.build(publicKey, secretKey); @@ -114,6 +116,14 @@ TEST(TransactionsMPTokenIssuanceCreateTests, BuilderSettersRoundTrip) EXPECT_TRUE(tx.hasImmutableFlags()); } + { + auto const& expected = mPTokenSchemaValue; + auto const actualOpt = tx.getMPTokenSchema(); + ASSERT_TRUE(actualOpt.has_value()) << "Optional field sfMPTokenSchema should be present"; + expectEqualField(expected, *actualOpt, "sfMPTokenSchema"); + EXPECT_TRUE(tx.hasMPTokenSchema()); + } + } // 2 & 4) Start from an STTx, construct a builder from it, build a new wrapper, @@ -136,6 +146,7 @@ TEST(TransactionsMPTokenIssuanceCreateTests, BuilderFromStTxRoundTrip) auto const mPTokenMetadataValue = canonical_VL(); auto const domainIDValue = canonical_UINT256(); auto const immutableFlagsValue = canonical_UINT32(); + auto const mPTokenSchemaValue = canonical_VL(); // Build an initial transaction MPTokenIssuanceCreateBuilder initialBuilder{ @@ -150,6 +161,7 @@ TEST(TransactionsMPTokenIssuanceCreateTests, BuilderFromStTxRoundTrip) initialBuilder.setMPTokenMetadata(mPTokenMetadataValue); initialBuilder.setDomainID(domainIDValue); initialBuilder.setImmutableFlags(immutableFlagsValue); + initialBuilder.setMPTokenSchema(mPTokenSchemaValue); auto initialTx = initialBuilder.build(publicKey, secretKey); @@ -210,6 +222,13 @@ TEST(TransactionsMPTokenIssuanceCreateTests, BuilderFromStTxRoundTrip) expectEqualField(expected, *actualOpt, "sfImmutableFlags"); } + { + auto const& expected = mPTokenSchemaValue; + auto const actualOpt = rebuiltTx.getMPTokenSchema(); + ASSERT_TRUE(actualOpt.has_value()) << "Optional field sfMPTokenSchema should be present"; + expectEqualField(expected, *actualOpt, "sfMPTokenSchema"); + } + } // 3) Verify wrapper throws when constructed from wrong transaction type. @@ -277,6 +296,8 @@ TEST(TransactionsMPTokenIssuanceCreateTests, OptionalFieldsReturnNullopt) EXPECT_FALSE(tx.getDomainID().has_value()); EXPECT_FALSE(tx.hasImmutableFlags()); EXPECT_FALSE(tx.getImmutableFlags().has_value()); + EXPECT_FALSE(tx.hasMPTokenSchema()); + EXPECT_FALSE(tx.getMPTokenSchema().has_value()); } } diff --git a/src/tests/libxrpl/protocol_autogen/transactions/MPTokenIssuanceSetTests.cpp b/src/tests/libxrpl/protocol_autogen/transactions/MPTokenIssuanceSetTests.cpp index af696ce47b4..3795b919ee8 100644 --- a/src/tests/libxrpl/protocol_autogen/transactions/MPTokenIssuanceSetTests.cpp +++ b/src/tests/libxrpl/protocol_autogen/transactions/MPTokenIssuanceSetTests.cpp @@ -33,6 +33,7 @@ TEST(TransactionsMPTokenIssuanceSetTests, BuilderSettersRoundTrip) auto const holderValue = canonical_ACCOUNT(); auto const domainIDValue = canonical_UINT256(); auto const mPTokenMetadataValue = canonical_VL(); + auto const mPTokenSchemaValue = canonical_VL(); auto const transferFeeValue = canonical_UINT16(); auto const immutableFlagsValue = canonical_UINT32(); auto const issuerEncryptionKeyValue = canonical_VL(); @@ -49,6 +50,7 @@ TEST(TransactionsMPTokenIssuanceSetTests, BuilderSettersRoundTrip) builder.setHolder(holderValue); builder.setDomainID(domainIDValue); builder.setMPTokenMetadata(mPTokenMetadataValue); + builder.setMPTokenSchema(mPTokenSchemaValue); builder.setTransferFee(transferFeeValue); builder.setImmutableFlags(immutableFlagsValue); builder.setIssuerEncryptionKey(issuerEncryptionKeyValue); @@ -100,6 +102,14 @@ TEST(TransactionsMPTokenIssuanceSetTests, BuilderSettersRoundTrip) EXPECT_TRUE(tx.hasMPTokenMetadata()); } + { + auto const& expected = mPTokenSchemaValue; + auto const actualOpt = tx.getMPTokenSchema(); + ASSERT_TRUE(actualOpt.has_value()) << "Optional field sfMPTokenSchema should be present"; + expectEqualField(expected, *actualOpt, "sfMPTokenSchema"); + EXPECT_TRUE(tx.hasMPTokenSchema()); + } + { auto const& expected = transferFeeValue; auto const actualOpt = tx.getTransferFee(); @@ -152,6 +162,7 @@ TEST(TransactionsMPTokenIssuanceSetTests, BuilderFromStTxRoundTrip) auto const holderValue = canonical_ACCOUNT(); auto const domainIDValue = canonical_UINT256(); auto const mPTokenMetadataValue = canonical_VL(); + auto const mPTokenSchemaValue = canonical_VL(); auto const transferFeeValue = canonical_UINT16(); auto const immutableFlagsValue = canonical_UINT32(); auto const issuerEncryptionKeyValue = canonical_VL(); @@ -168,6 +179,7 @@ TEST(TransactionsMPTokenIssuanceSetTests, BuilderFromStTxRoundTrip) initialBuilder.setHolder(holderValue); initialBuilder.setDomainID(domainIDValue); initialBuilder.setMPTokenMetadata(mPTokenMetadataValue); + initialBuilder.setMPTokenSchema(mPTokenSchemaValue); initialBuilder.setTransferFee(transferFeeValue); initialBuilder.setImmutableFlags(immutableFlagsValue); initialBuilder.setIssuerEncryptionKey(issuerEncryptionKeyValue); @@ -217,6 +229,13 @@ TEST(TransactionsMPTokenIssuanceSetTests, BuilderFromStTxRoundTrip) expectEqualField(expected, *actualOpt, "sfMPTokenMetadata"); } + { + auto const& expected = mPTokenSchemaValue; + auto const actualOpt = rebuiltTx.getMPTokenSchema(); + ASSERT_TRUE(actualOpt.has_value()) << "Optional field sfMPTokenSchema should be present"; + expectEqualField(expected, *actualOpt, "sfMPTokenSchema"); + } + { auto const& expected = transferFeeValue; auto const actualOpt = rebuiltTx.getTransferFee(); @@ -308,6 +327,8 @@ TEST(TransactionsMPTokenIssuanceSetTests, OptionalFieldsReturnNullopt) EXPECT_FALSE(tx.getDomainID().has_value()); EXPECT_FALSE(tx.hasMPTokenMetadata()); EXPECT_FALSE(tx.getMPTokenMetadata().has_value()); + EXPECT_FALSE(tx.hasMPTokenSchema()); + EXPECT_FALSE(tx.getMPTokenSchema().has_value()); EXPECT_FALSE(tx.hasTransferFee()); EXPECT_FALSE(tx.getTransferFee().has_value()); EXPECT_FALSE(tx.hasImmutableFlags());