From 834d630f3e636bf97e5eb4a8b3c73a5b695f280d Mon Sep 17 00:00:00 2001 From: Mayukha Vadari Date: Wed, 26 Mar 2025 19:21:43 -0400 Subject: [PATCH 1/8] add MPTCurrency type --- xrpl/constants.py | 2 + xrpl/core/binarycodec/types/issue.py | 6 +-- xrpl/models/amounts/amount.py | 18 ++++--- xrpl/models/amounts/mpt_amount.py | 10 ++++ xrpl/models/currencies/__init__.py | 7 +-- xrpl/models/currencies/currency.py | 3 +- xrpl/models/currencies/mpt_currency.py | 63 ++++++++++++++++++++++++ xrpl/models/transactions/xchain_claim.py | 13 +++-- 8 files changed, 102 insertions(+), 20 deletions(-) create mode 100644 xrpl/models/currencies/mpt_currency.py diff --git a/xrpl/constants.py b/xrpl/constants.py index 0bd6ee1cb..3aa823d16 100644 --- a/xrpl/constants.py +++ b/xrpl/constants.py @@ -42,6 +42,8 @@ class XRPLException(Exception): :meta private: """ +HEX_MPTID_REGEX: Final[Pattern[str]] = re.compile(r"^[0-9A-Fa-f]{48}$") + # Constants for validating amounts. MIN_IOU_EXPONENT: Final[int] = -96 """ diff --git a/xrpl/core/binarycodec/types/issue.py b/xrpl/core/binarycodec/types/issue.py index 609a8e556..5e171eaf4 100644 --- a/xrpl/core/binarycodec/types/issue.py +++ b/xrpl/core/binarycodec/types/issue.py @@ -12,9 +12,9 @@ from xrpl.core.binarycodec.types.currency import Currency from xrpl.core.binarycodec.types.hash192 import HASH192_BYTES, Hash192 from xrpl.core.binarycodec.types.serialized_type import SerializedType -from xrpl.models.amounts.mpt_amount import MPTAmount from xrpl.models.currencies import XRP as XRPModel from xrpl.models.currencies import IssuedCurrency as IssuedCurrencyModel +from xrpl.models.currencies import MPTCurrency as MPTCurrencyModel class Issue(SerializedType): @@ -54,13 +54,13 @@ def from_value(cls: Type[Self], value: Dict[str, str]) -> Self: issuer_bytes = bytes(AccountID.from_value(value["issuer"])) return cls(currency_bytes + issuer_bytes) - if MPTAmount.is_dict_of_model(value): + if MPTCurrencyModel.is_dict_of_model(value): mpt_issuance_id_bytes = bytes(Hash192.from_value(value["mpt_issuance_id"])) return cls(bytes(mpt_issuance_id_bytes)) raise XRPLBinaryCodecException( "Invalid type to construct an Issue: expected XRP, IssuedCurrency or " - f"MPTAmount as a str or dict, received {value.__class__.__name__}." + f"MPTCurrency as a str or dict, received {value.__class__.__name__}." ) @classmethod diff --git a/xrpl/models/amounts/amount.py b/xrpl/models/amounts/amount.py index 58dd2746b..33ac210db 100644 --- a/xrpl/models/amounts/amount.py +++ b/xrpl/models/amounts/amount.py @@ -4,7 +4,9 @@ counterparty. """ -from typing import Union, cast +from typing import Union + +from typing_extensions import TypeGuard from xrpl.models.amounts.issued_currency_amount import IssuedCurrencyAmount from xrpl.models.amounts.mpt_amount import MPTAmount @@ -12,7 +14,7 @@ Amount = Union[IssuedCurrencyAmount, MPTAmount, str] -def is_xrp(amount: Amount) -> bool: +def is_xrp(amount: Amount) -> TypeGuard[str]: """ Returns whether amount is an XRP value, as opposed to an issued currency or MPT value. @@ -26,7 +28,7 @@ def is_xrp(amount: Amount) -> bool: return isinstance(amount, str) -def is_issued_currency(amount: Amount) -> bool: +def is_issued_currency(amount: Amount) -> TypeGuard[IssuedCurrencyAmount]: """ Returns whether amount is an issued currency value, as opposed to an XRP or MPT value. @@ -40,7 +42,7 @@ def is_issued_currency(amount: Amount) -> bool: return isinstance(amount, IssuedCurrencyAmount) -def is_mpt(amount: Amount) -> bool: +def is_mpt(amount: Amount) -> TypeGuard[MPTAmount]: """ Returns whether amount is an MPT value, as opposed to an XRP or an issued currency value. @@ -65,5 +67,9 @@ def get_amount_value(amount: Amount) -> float: The value of the amount irrespective of its currency. """ if is_xrp(amount): - return float(cast(str, amount)) - return float(cast(IssuedCurrencyAmount, amount).value) + return float(amount) + if is_issued_currency(amount): + return float(amount.value) + if is_mpt(amount): + return float(amount.value) + raise ValueError(f"Invalid amount: {repr(amount)}") diff --git a/xrpl/models/amounts/mpt_amount.py b/xrpl/models/amounts/mpt_amount.py index 183f2406b..6954b0d48 100644 --- a/xrpl/models/amounts/mpt_amount.py +++ b/xrpl/models/amounts/mpt_amount.py @@ -8,6 +8,7 @@ from typing_extensions import Self from xrpl.models.base_model import BaseModel +from xrpl.models.currencies.mpt_currency import MPTCurrency from xrpl.models.required import REQUIRED from xrpl.models.utils import KW_ONLY_DATACLASS, require_kwargs_on_init @@ -39,3 +40,12 @@ def to_dict(self: Self) -> Dict[str, str]: The dictionary representation of an MPTAmount. """ return {**super().to_dict(), "value": str(self.value)} + + def to_currency(self: Self) -> MPTCurrency: + """ + Build an MPTCurrency from this MPTAmount. + + Returns: + The MPTCurrency for this MPTAmount. + """ + return MPTCurrency(mpt_issuance_id=self.mpt_issuance_id) diff --git a/xrpl/models/currencies/__init__.py b/xrpl/models/currencies/__init__.py index 00be88436..8323bf47b 100644 --- a/xrpl/models/currencies/__init__.py +++ b/xrpl/models/currencies/__init__.py @@ -1,15 +1,16 @@ """ -The XRP Ledger has two kinds of money: XRP, and issued -currencies. Both types have high precision, although their -formats are different. +The XRP Ledger has three kinds of money: XRP, issued currencies, and MPTs. All types +have high precision, although their formats are different. """ from xrpl.models.currencies.currency import Currency from xrpl.models.currencies.issued_currency import IssuedCurrency +from xrpl.models.currencies.mpt_currency import MPTCurrency from xrpl.models.currencies.xrp import XRP __all__ = [ "Currency", "IssuedCurrency", + "MPTCurrency", "XRP", ] diff --git a/xrpl/models/currencies/currency.py b/xrpl/models/currencies/currency.py index 33f10df09..98b3c6e4e 100644 --- a/xrpl/models/currencies/currency.py +++ b/xrpl/models/currencies/currency.py @@ -7,6 +7,7 @@ from typing import Union from xrpl.models.currencies.issued_currency import IssuedCurrency +from xrpl.models.currencies.mpt_currency import MPTCurrency from xrpl.models.currencies.xrp import XRP -Currency = Union[IssuedCurrency, XRP] +Currency = Union[IssuedCurrency, MPTCurrency, XRP] diff --git a/xrpl/models/currencies/mpt_currency.py b/xrpl/models/currencies/mpt_currency.py new file mode 100644 index 000000000..587ac3881 --- /dev/null +++ b/xrpl/models/currencies/mpt_currency.py @@ -0,0 +1,63 @@ +""" +Specifies an amount in an issued currency, but without a value field. +This format is used for some book order requests. + +See https://xrpl.org/currency-formats.html#specifying-currency-amounts +""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Dict, Union + +from typing_extensions import Self + +import xrpl.models.amounts # not a direct import, to get around circular imports +from xrpl.constants import HEX_MPTID_REGEX +from xrpl.models.base_model import BaseModel +from xrpl.models.required import REQUIRED +from xrpl.models.utils import KW_ONLY_DATACLASS, require_kwargs_on_init + + +def _is_valid_mptid(candidate: str) -> bool: + return bool(HEX_MPTID_REGEX.fullmatch(candidate)) + + +@require_kwargs_on_init +@dataclass(frozen=True, **KW_ONLY_DATACLASS) +class MPTCurrency(BaseModel): + """ + Specifies an amount in an MPT, but without a value field. + This format is used for some book order requests. + + See https://xrpl.org/currency-formats.html#specifying-currency-amounts + """ + + mpt_issuance_id: str = REQUIRED # type: ignore + """ + This field is required. + + :meta hide-value: + """ + + def _get_errors(self: Self) -> Dict[str, str]: + errors = super()._get_errors() + if not _is_valid_mptid(self.mpt_issuance_id): + errors["mpt_issuance_id"] = ( + f"Invalid mpt_issuance_id {self.mpt_issuance_id}" + ) + return errors + + def to_amount(self: Self, value: Union[str, int]) -> xrpl.models.amounts.MPTAmount: + """ + Converts an MPTCurrency to an MPTCurrencyAmount. + + Args: + value: The amount of MPTs in the MPTAmount. + + Returns: + An MPTAmount that represents the MPT and the provided value. + """ + return xrpl.models.amounts.MPTAmount( + mpt_issuance_id=self.mpt_issuance_id, value=str(value) + ) diff --git a/xrpl/models/transactions/xchain_claim.py b/xrpl/models/transactions/xchain_claim.py index 875e84546..1bada4904 100644 --- a/xrpl/models/transactions/xchain_claim.py +++ b/xrpl/models/transactions/xchain_claim.py @@ -7,9 +7,8 @@ from typing_extensions import Self -from xrpl.models.amounts import Amount -from xrpl.models.amounts.amount import is_issued_currency, is_mpt, is_xrp -from xrpl.models.currencies import XRP +from xrpl.models.amounts import Amount, is_issued_currency, is_mpt, is_xrp +from xrpl.models.currencies import XRP, Currency from xrpl.models.required import REQUIRED from xrpl.models.transactions.transaction import Transaction from xrpl.models.transactions.types import TransactionType @@ -81,13 +80,13 @@ def _get_errors(self: Self) -> Dict[str, str]: amount = self.amount if is_xrp(amount): - currency = XRP() + currency: Currency = XRP() elif is_issued_currency(amount): - currency = amount.to_currency() # type: ignore + currency = amount.to_currency() elif is_mpt(amount): - currency = amount.mpt_issuance_id # type: ignore + currency = amount.to_currency() else: - errors["amount"] = "currency can't be derived." + errors["amount"] = "Currency can't be derived." if ( currency != bridge.locking_chain_issue From 4df151c9bfa898fc7bc1964041f9fa60e2b5a1c0 Mon Sep 17 00:00:00 2001 From: Mayukha Vadari Date: Wed, 26 Mar 2025 19:22:29 -0400 Subject: [PATCH 2/8] clean up --- xrpl/core/binarycodec/types/issue.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/xrpl/core/binarycodec/types/issue.py b/xrpl/core/binarycodec/types/issue.py index 5e171eaf4..7527fcdac 100644 --- a/xrpl/core/binarycodec/types/issue.py +++ b/xrpl/core/binarycodec/types/issue.py @@ -80,7 +80,7 @@ def from_parser( Returns: The Issue object constructed from a parser. """ - # Check if it's an MPTAmount by checking mpt_issuance_id byte size + # Check if it's an MPTIssue by checking mpt_issuance_id byte size if length_hint == HASH192_BYTES: mpt_bytes = parser.read(HASH192_BYTES) return cls(mpt_bytes) From 0d616c088e4bf43e7506d63be01edd9deb1b025b Mon Sep 17 00:00:00 2001 From: Mayukha Vadari Date: Thu, 27 Mar 2025 11:16:29 -0400 Subject: [PATCH 3/8] update changelog --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a784b1c12..97b186540 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,7 +8,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [[Unreleased]] ### Fixed -- add `MPTAmount` support in `Issue` (rippled internal type) +- add `MPTCurrency` support in `Issue` (rippled internal type) ### Added - Improved validation for models to also check param types From 86519334b0bb2f1e0ce7f08035d2a00031df818a Mon Sep 17 00:00:00 2001 From: Mayukha Vadari Date: Thu, 27 Mar 2025 11:22:33 -0400 Subject: [PATCH 4/8] fix tests --- tests/unit/core/binarycodec/types/test_issue.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/tests/unit/core/binarycodec/types/test_issue.py b/tests/unit/core/binarycodec/types/test_issue.py index b2c8eb13b..45af802f6 100644 --- a/tests/unit/core/binarycodec/types/test_issue.py +++ b/tests/unit/core/binarycodec/types/test_issue.py @@ -32,7 +32,6 @@ def test_from_value_mpt(self): # Test Issue creation for an MPT amount. # Use a valid 48-character hex string (24 bytes) for mpt_issuance_id. test_input = { - "value": "100", # MPT amounts must be an integer string (no decimal point) "mpt_issuance_id": "BAADF00DBAADF00DBAADF00DBAADF00DBAADF00DBAADF00D", } issue_obj = Issue.from_value(test_input) @@ -75,9 +74,9 @@ def test_from_parser_non_standard_currency(self): self.assertEqual(issue_from_parser.to_json(), expected) def test_from_parser_mpt(self): - # Test round-trip: serialize an MPT Issue and then parse it back. + # Test round-t + # rip: serialize an MPT Issue and then parse it back. test_input = { - "value": "100", "mpt_issuance_id": "BAADF00DBAADF00DBAADF00DBAADF00DBAADF00DBAADF00D", } issue_obj = Issue.from_value(test_input) From 88b0f36d9704843a570b6a7fbb1a15f88dbc5e9e Mon Sep 17 00:00:00 2001 From: Mayukha Vadari Date: Thu, 27 Mar 2025 11:27:03 -0400 Subject: [PATCH 5/8] fix comment --- tests/unit/core/binarycodec/types/test_issue.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/tests/unit/core/binarycodec/types/test_issue.py b/tests/unit/core/binarycodec/types/test_issue.py index 45af802f6..95725e014 100644 --- a/tests/unit/core/binarycodec/types/test_issue.py +++ b/tests/unit/core/binarycodec/types/test_issue.py @@ -74,8 +74,7 @@ def test_from_parser_non_standard_currency(self): self.assertEqual(issue_from_parser.to_json(), expected) def test_from_parser_mpt(self): - # Test round-t - # rip: serialize an MPT Issue and then parse it back. + # Test round-trip: serialize an MPT Issue and then parse it back. test_input = { "mpt_issuance_id": "BAADF00DBAADF00DBAADF00DBAADF00DBAADF00DBAADF00D", } From 7b0dccdbc83ba60040f5674b71036b0f3c4b1cf6 Mon Sep 17 00:00:00 2001 From: Mayukha Vadari Date: Thu, 27 Mar 2025 11:30:35 -0400 Subject: [PATCH 6/8] fix mypy --- xrpl/models/transactions/clawback.py | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/xrpl/models/transactions/clawback.py b/xrpl/models/transactions/clawback.py index 76a20884f..8174c624d 100644 --- a/xrpl/models/transactions/clawback.py +++ b/xrpl/models/transactions/clawback.py @@ -50,17 +50,15 @@ def _get_errors(self: Self) -> Dict[str, str]: # Amount transaction errors if is_xrp(self.amount): errors["amount"] = "``amount`` cannot be XRP." - - if is_issued_currency(self.amount): + elif is_issued_currency(self.amount): if self.holder is not None: errors["amount"] = "Cannot have Holder for currency." - if self.account == self.amount.issuer: # type:ignore + if self.account == self.amount.issuer: errors["amount"] = "Holder's address is wrong." - - if is_mpt(self.amount): + elif is_mpt(self.amount): if self.holder is None: errors["amount"] = "Missing Holder." - if self.account == self.holder: + elif self.account == self.holder: errors["amount"] = "Invalid Holder account." return errors From a700ab1c7129c260225464f6380877aadffd4cb8 Mon Sep 17 00:00:00 2001 From: Mayukha Vadari Date: Thu, 27 Mar 2025 11:39:49 -0400 Subject: [PATCH 7/8] add model tests --- .../models/currencies/test_mpt_currency.py | 42 +++++++++++++++++++ 1 file changed, 42 insertions(+) create mode 100644 tests/unit/models/currencies/test_mpt_currency.py diff --git a/tests/unit/models/currencies/test_mpt_currency.py b/tests/unit/models/currencies/test_mpt_currency.py new file mode 100644 index 000000000..165e8205e --- /dev/null +++ b/tests/unit/models/currencies/test_mpt_currency.py @@ -0,0 +1,42 @@ +from unittest import TestCase + +from xrpl.models.currencies import MPTCurrency +from xrpl.models.exceptions import XRPLModelException + +_MPTID = "00002403C84A0A28E0190E208E982C352BBD5006600555CF" + + +class TestMPTCurrency(TestCase): + def test_correct_mptid_format(self): + obj = MPTCurrency( + mpt_issuance_id=_MPTID, + ) + self.assertTrue(obj.is_valid()) + + def test_lower_mptid_format(self): + obj = MPTCurrency( + mpt_issuance_id=_MPTID.lower(), + ) + self.assertTrue(obj.is_valid()) + + def test_invalid_length(self): + with self.assertRaises(XRPLModelException): + MPTCurrency(mpt_issuance_id=_MPTID[:40]) + + with self.assertRaises(XRPLModelException): + MPTCurrency(mpt_issuance_id=_MPTID + "AA") + + def test_incorrect_hex_format(self): + # the "+" is not allowed in a currency format" + with self.assertRaises(XRPLModelException): + MPTCurrency( + mpt_issuance_id="ABCD" * 11 + "XXXX", + ) + + def test_to_amount(self): + amount = "12" + MPT_currency = MPTCurrency(mpt_issuance_id=_MPTID) + MPT_currency_amount = MPT_currency.to_amount(amount) + + self.assertEqual(MPT_currency_amount.mpt_issuance_id, _MPTID) + self.assertEqual(MPT_currency_amount.value, amount) From 6b078944b757cc990d756d0b7d9d9516a44d0ec0 Mon Sep 17 00:00:00 2001 From: Mayukha Vadari Date: Mon, 31 Mar 2025 12:34:44 -0400 Subject: [PATCH 8/8] fix comment --- xrpl/models/currencies/mpt_currency.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/xrpl/models/currencies/mpt_currency.py b/xrpl/models/currencies/mpt_currency.py index 587ac3881..3246502b1 100644 --- a/xrpl/models/currencies/mpt_currency.py +++ b/xrpl/models/currencies/mpt_currency.py @@ -50,7 +50,7 @@ def _get_errors(self: Self) -> Dict[str, str]: def to_amount(self: Self, value: Union[str, int]) -> xrpl.models.amounts.MPTAmount: """ - Converts an MPTCurrency to an MPTCurrencyAmount. + Converts an MPTCurrency to an MPTAmount. Args: value: The amount of MPTs in the MPTAmount.