diff --git a/CHANGELOG.md b/CHANGELOG.md index ab6af42cc..f8042dda1 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) - Fixed the implementation error in get_latest_open_ledger_sequence method. The change uses the "current" ledger for extracting sequence number. ### Added diff --git a/tests/unit/core/binarycodec/types/test_issue.py b/tests/unit/core/binarycodec/types/test_issue.py index b2c8eb13b..95725e014 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) @@ -77,7 +76,6 @@ def test_from_parser_non_standard_currency(self): def test_from_parser_mpt(self): # Test round-trip: serialize an MPT Issue and then parse it back. test_input = { - "value": "100", "mpt_issuance_id": "BAADF00DBAADF00DBAADF00DBAADF00DBAADF00DBAADF00D", } issue_obj = Issue.from_value(test_input) 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) 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..7527fcdac 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 @@ -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) 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..3246502b1 --- /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 MPTAmount. + + 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/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 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